feat(app): 完善播放协议搜索与空状态

This commit is contained in:
Zhou Weidong committed 2026-10-23 12:38:47 +08:00
1 parent 671e0ce143
commit ef29fc5f15
85 files changed
+2976 -768

No files matched your search

+18
View File
@@ -70,6 +70,24 @@ void main() {
auth: auth,
);
final l10n = await AppLocalizations.delegate.load(const Locale('en'));
final titleTop = tester
.getTopLeft(find.text(l10n.accountDeactivateReason))
.dy;
final fieldTop = tester
.getTopLeft(find.byKey(const Key('account-deactivate-reason')))
.dy;
expect(fieldTop, greaterThan(titleTop));
expect(find.text(l10n.accountDeactivateReasonUnused), findsOneWidget);
await tester.tap(find.byKey(const Key('account-deactivate-reason-0')));
await tester.pumpAndSettle();
expect(
tester
.widget<TextField>(find.byKey(const Key('account-deactivate-reason')))
.controller
?.text,
l10n.accountDeactivateReasonUnused,
);
await tester.enterText(
find.byKey(const Key('account-deactivate-reason')),
'不再使用',
+16
View File
@@ -61,6 +61,22 @@ void main() {
expect(find.byKey(const Key('mine-account-devices')), findsOneWidget);
expect(find.byKey(const Key('mine-account-export')), findsOneWidget);
expect(find.byKey(const Key('mine-account-deactivate')), findsOneWidget);
expect(find.byKey(const Key('settings-notices')), findsNothing);
final profileTop = tester
.getTopLeft(find.byKey(const Key('mine-account-profile')))
.dy;
final devicesTop = tester
.getTopLeft(find.byKey(const Key('mine-account-devices')))
.dy;
final exportTop = tester
.getTopLeft(find.byKey(const Key('mine-account-export')))
.dy;
final deactivateTop = tester
.getTopLeft(find.byKey(const Key('mine-account-deactivate')))
.dy;
expect(devicesTop, greaterThan(profileTop));
expect(exportTop, greaterThan(devicesTop));
expect(deactivateTop, greaterThan(exportTop));
});
}
+115 -63
View File
@@ -53,70 +53,82 @@ void main() {
);
}
test('should_store_session_and_expose_signed_in_state_when_register_succeeds', () async {
late AuthController auth;
final adapter = FakeHttpAdapter((options) async {
expect(options.uri.path, '/app/auth/register');
expect(options.headers['X-Device-Id'], isNotEmpty);
expect(options.headers.containsKey('Authorization'), isFalse);
return FakeHttpResponse(tokenBody(accessToken: 'a1', refreshToken: 'r1'));
});
final store = AuthSessionStore();
auth = AuthController(
AuthApi(clientWith(() => auth, adapter: adapter)),
store,
);
expect(auth.isSignedIn, isFalse);
await auth.register(email: 'a@b.com', password: 'abcd1234');
expect(auth.isSignedIn, isTrue);
expect(auth.accessToken, 'a1');
expect(auth.user?.nickname, 'Tester');
// 落盘:新建一个 store 也能读回来
expect((await AuthSessionStore().read())?.refreshToken, 'r1');
});
test('should_refresh_once_and_retry_with_new_token_when_response_code_is_401', () async {
var calls = 0;
late AuthController auth;
final adapter = FakeHttpAdapter((options) async {
calls++;
if (options.uri.path == '/app/auth/refresh') {
return FakeHttpResponse(tokenBody(accessToken: 'a2', refreshToken: 'r2'));
}
if (options.uri.path != '/app/me') {
// 注册等其它请求:直接发第一版令牌
return FakeHttpResponse(tokenBody(accessToken: 'a1', refreshToken: 'r1'));
}
final token = options.headers['Authorization'];
if (token == 'Bearer a1') {
test(
'should_store_session_and_expose_signed_in_state_when_register_succeeds',
() async {
late AuthController auth;
final adapter = FakeHttpAdapter((options) async {
expect(options.uri.path, '/app/auth/register');
expect(options.headers['X-Device-Id'], isNotEmpty);
expect(options.headers.containsKey('Authorization'), isFalse);
return FakeHttpResponse(
jsonEncode({'code': 401, 'message': '未登录或登录已过期', 'data': null}),
tokenBody(accessToken: 'a1', refreshToken: 'r1'),
);
}
expect(token, 'Bearer a2');
return FakeHttpResponse(
jsonEncode({
'code': 0,
'message': 'ok',
'data': {'hit': true},
}),
});
final store = AuthSessionStore();
auth = AuthController(
AuthApi(clientWith(() => auth, adapter: adapter)),
store,
);
});
final store = AuthSessionStore();
auth = AuthController(
AuthApi(clientWith(() => auth, adapter: adapter)),
store,
);
await auth.register(email: 'a@b.com', password: 'abcd1234');
calls = 0;
final api = clientWith(() => auth, adapter: adapter);
final data = await api.get('/app/me');
expect((data as Map)['hit'], isTrue);
expect(calls, 3); // 原请求(401) + 刷新 + 重试
expect(auth.accessToken, 'a2');
});
expect(auth.isSignedIn, isFalse);
await auth.register(email: 'a@b.com', password: 'abcd1234');
expect(auth.isSignedIn, isTrue);
expect(auth.accessToken, 'a1');
expect(auth.user?.nickname, 'Tester');
// 落盘:新建一个 store 也能读回来
expect((await AuthSessionStore().read())?.refreshToken, 'r1');
},
);
test(
'should_refresh_once_and_retry_with_new_token_when_response_code_is_401',
() async {
var calls = 0;
late AuthController auth;
final adapter = FakeHttpAdapter((options) async {
calls++;
if (options.uri.path == '/app/auth/refresh') {
return FakeHttpResponse(
tokenBody(accessToken: 'a2', refreshToken: 'r2'),
);
}
if (options.uri.path != '/app/me') {
// 注册等其它请求:直接发第一版令牌
return FakeHttpResponse(
tokenBody(accessToken: 'a1', refreshToken: 'r1'),
);
}
final token = options.headers['Authorization'];
if (token == 'Bearer a1') {
return FakeHttpResponse(
jsonEncode({'code': 401, 'message': '未登录或登录已过期', 'data': null}),
);
}
expect(token, 'Bearer a2');
return FakeHttpResponse(
jsonEncode({
'code': 0,
'message': 'ok',
'data': {'hit': true},
}),
);
});
final store = AuthSessionStore();
auth = AuthController(
AuthApi(clientWith(() => auth, adapter: adapter)),
store,
);
await auth.register(email: 'a@b.com', password: 'abcd1234');
calls = 0;
final api = clientWith(() => auth, adapter: adapter);
final data = await api.get('/app/me');
expect((data as Map)['hit'], isTrue);
expect(calls, 3); // 原请求(401) + 刷新 + 重试
expect(auth.accessToken, 'a2');
},
);
test('should_clear_session_when_refresh_itself_fails', () async {
late AuthController auth;
@@ -127,7 +139,9 @@ void main() {
);
}
if (options.uri.path == '/app/auth/register') {
return FakeHttpResponse(tokenBody(accessToken: 'a1', refreshToken: 'r1'));
return FakeHttpResponse(
tokenBody(accessToken: 'a1', refreshToken: 'r1'),
);
}
return FakeHttpResponse(
jsonEncode({'code': 401, 'message': '未登录或登录已过期', 'data': null}),
@@ -146,6 +160,40 @@ void main() {
expect(await AuthSessionStore().read(), isNull);
});
test(
'should_keep_session_when_refresh_fails_because_server_is_down',
() async {
late AuthController auth;
final adapter = FakeHttpAdapter((options) async {
if (options.uri.path == '/app/auth/refresh') {
return FakeHttpResponse(
jsonEncode({'code': 500, 'message': '服务重启中', 'data': null}),
);
}
if (options.uri.path == '/app/auth/register') {
return FakeHttpResponse(
tokenBody(accessToken: 'a1', refreshToken: 'r1'),
);
}
return FakeHttpResponse(
jsonEncode({'code': 401, 'message': '未登录或登录已过期', 'data': null}),
);
});
final store = AuthSessionStore();
auth = AuthController(
AuthApi(clientWith(() => auth, adapter: adapter)),
store,
);
await auth.register(email: 'a@b.com', password: 'abcd1234');
final api = clientWith(() => auth, adapter: adapter);
await expectLater(api.get('/app/me'), throwsA(isA<ApiException>()));
expect(auth.isSignedIn, isTrue);
expect(auth.accessToken, 'a1');
expect((await AuthSessionStore().read())?.refreshToken, 'r1');
},
);
test('should_clear_local_session_when_sign_out_hits_server_error', () async {
late AuthController auth;
final adapter = FakeHttpAdapter((options) async {
@@ -183,7 +231,9 @@ void main() {
final body = (options.data as Map).cast<String, dynamic>();
expect(body['targetType'], 'PHONE');
expect(body['code'], '123456');
return FakeHttpResponse(tokenBody(accessToken: 'p1', refreshToken: 'pr1'));
return FakeHttpResponse(
tokenBody(accessToken: 'p1', refreshToken: 'pr1'),
);
});
final store = AuthSessionStore();
auth = AuthController(
@@ -205,7 +255,9 @@ void main() {
final body = (options.data as Map).cast<String, dynamic>();
expect(body['targetType'], 'PHONE');
expect(body['code'], '654321');
return FakeHttpResponse(tokenBody(accessToken: 'p2', refreshToken: 'pr2'));
return FakeHttpResponse(
tokenBody(accessToken: 'p2', refreshToken: 'pr2'),
);
});
auth = AuthController(
AuthApi(clientWith(() => auth, adapter: adapter)),
+3
View File
@@ -461,6 +461,7 @@ class _FailingAuthApi extends AuthApi {
required String password,
required String deviceId,
String platform = '',
String osVersion = '',
String appVersion = '',
}) async {
loginCalls++;
@@ -474,6 +475,7 @@ class _FailingAuthApi extends AuthApi {
required String password,
required String deviceId,
String platform = '',
String osVersion = '',
String appVersion = '',
}) async {
registerCalls++;
@@ -501,6 +503,7 @@ class _PendingAuthApi extends AuthApi {
required String password,
required String deviceId,
String platform = '',
String osVersion = '',
String appVersion = '',
}) async {
loginCalls++;
+27
View File
@@ -183,6 +183,32 @@ void main() {
},
);
testWidgets(
'should_show_app_logo_lower_on_the_page_when_landing_opens',
(tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await pumpAuthPage(tester, openEmail: false);
final image = tester.widget<Image>(
find.descendant(
of: find.byKey(const Key('auth-brand-logo')),
matching: find.byType(Image),
),
);
expect(
(image.image as AssetImage).assetName,
'assets/icon/fruitshorts.png',
);
expect(
tester.getTopLeft(find.byKey(const Key('auth-brand-logo'))).dy,
84,
);
},
);
testWidgets('should_render_40px_back_button_when_page_is_pushed', (
tester,
) async {
@@ -380,6 +406,7 @@ class _PendingAuthApi extends AuthApi {
required String password,
required String deviceId,
String platform = '',
String osVersion = '',
String appVersion = '',
}) async {
loginCalls++;
+1
View File
@@ -234,6 +234,7 @@ class _FailingPhoneAuthApi extends AuthApi {
required String code,
required String deviceId,
String platform = '',
String osVersion = '',
String appVersion = '',
}) async {
loginWithPhoneCalls++;
@@ -431,6 +431,11 @@ void main() {
final successText = tester.widget<Text>(find.text('发表成功'));
expect(successText.style?.color, Colors.white);
expect(successText.style?.fontSize, 14);
expect(successText.style?.decoration, TextDecoration.none);
final screen = tester.view.physicalSize / tester.view.devicePixelRatio;
final toast = tester.getRect(successOverlay);
expect(toast.center.dy, lessThan(screen.height * 0.4));
expect(toast.center.dy, greaterThan(screen.height * 0.2));
final placeholderRect = tester.getRect(
find.byKey(const Key('comment-input-placeholder')),
);
+27
View File
@@ -0,0 +1,27 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/config/app_config.dart';
import 'package:fruitshorts/core/legal/legal_page.dart';
void main() {
tearDown(AppConfig.clearRuntimeBaseUrl);
test('should_build_language_page_when_locale_is_supported', () {
AppConfig.applyRuntimeBaseUrl('http://192.168.31.243:8081');
expect(
legalPageUrl(legalPrivacyPath, const Locale('zh')),
'http://192.168.31.243:8081/privacy/zh',
);
expect(
legalPageUrl(legalTermsPath, const Locale('id')),
'http://192.168.31.243:8081/terms/id',
);
expect(
legalPageUrl(legalPrivacyPath, const Locale('fr')),
'http://192.168.31.243:8081/privacy/en',
);
});
}
+33
View File
@@ -0,0 +1,33 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/legal/legal_html.dart';
import 'package:fruitshorts/core/legal/legal_web_page.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
void main() {
test('should_keep_article_body_when_page_wraps_html', () {
const html = '<html><body><main><article><p>第一行</p></article></main></body></html>';
expect(legalArticle(html), '<p>第一行</p>');
});
testWidgets('should_show_agreement_text_when_page_loads', (tester) async {
await tester.pumpWidget(
MaterialApp(
locale: const Locale('zh'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: LegalWebPage(
url: 'http://example.test/privacy/zh',
load: (_) async => '<p>协议正文</p>',
),
),
);
await tester.pumpAndSettle();
expect(find.text('协议正文'), findsOneWidget);
expect(find.byKey(const Key('legal-web-loading')), findsNothing);
});
}
+2 -2
View File
@@ -97,8 +97,8 @@ void main() {
);
expect(material.color, AppColors.headerScrim);
expect(icon.color, AppColors.onSurface);
expect(icon.size, 22);
expect(icon.weight, 600);
expect(icon.size, 34);
expect(icon.weight, 400);
final shadow =
tester
.widget<DecoratedBox>(
+48 -4
View File
@@ -129,13 +129,16 @@ void main() {
await tester.tap(find.byKey(const Key('following-edit')));
await tester.pumpAndSettle();
expect(
find.byKey(const Key('following-remove-the-signal-demo')),
find.byKey(const Key('following-select-the-signal-demo')),
findsOneWidget,
);
expect(find.byKey(const Key('following-cancel-follow')), findsOneWidget);
await tester.tap(find.byKey(const Key('following-remove-the-signal-demo')));
await tester.tap(find.byKey(const Key('following-card-the-signal-demo')));
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsOneWidget);
await tester.tap(find.byKey(const Key('following-cancel-follow')));
await tester.pumpAndSettle();
expect(find.text('Unfollow the selected dramas?'), findsOneWidget);
await tester.tap(
find.descendant(
of: find.byType(AlertDialog),
@@ -145,7 +148,7 @@ void main() {
await tester.pumpAndSettle();
expect(
find.byKey(const Key('following-remove-the-signal-demo')),
find.byKey(const Key('following-select-the-signal-demo')),
findsNothing,
);
expect(find.byIcon(Icons.edit_outlined), findsOneWidget);
@@ -159,6 +162,47 @@ void main() {
expect(find.byKey(const Key('following-history-empty')), findsOneWidget);
});
testWidgets('should_unfollow_every_selected_drama_when_edit_confirms', (
tester,
) async {
_signIn();
final catalog = FakeCatalogRepository();
final dramas = catalog.dramas.take(2).toList();
final favorites = InMemoryFavoriteRepository(
cards: dramas,
saved: dramas.map((drama) => drama.id).toSet(),
);
await tester.pumpWidget(_app(catalog: catalog, favorites: favorites));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-follow')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('following-edit')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('following-select-all')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('following-cancel-follow')));
await tester.pumpAndSettle();
await tester.tap(
find.descendant(
of: find.byType(AlertDialog),
matching: find.byType(FilledButton),
),
);
await tester.pumpAndSettle();
expect(
find.byKey(const Key('following-card-the-signal-demo')),
findsNothing,
);
expect(
find.byKey(const Key('following-card-city-lights-demo')),
findsNothing,
);
expect(find.byIcon(Icons.edit_outlined), findsOneWidget);
expect(favorites.removeManyCalls, 1);
});
testWidgets('should_show_auth_gate_when_signed_out', (tester) async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
@@ -176,6 +176,9 @@ class _PagedFavoriteRepository implements FavoriteRepository {
@override
Future<void> remove(String dramaId) async {}
@override
Future<void> removeMany(List<String> dramaIds) async {}
@override
Future<bool> toggle(String dramaId) async => true;
}
+16 -12
View File
@@ -28,7 +28,7 @@ import '../support/fake_notice_repository.dart';
import '../support/widget_player.dart';
void main() {
testWidgets('should_render_visitor_user_vip_and_seven_grid_entries', (
testWidgets('should_render_visitor_user_vip_and_eight_grid_entries', (
tester,
) async {
await _pumpApp(tester, signedIn: false);
@@ -118,8 +118,9 @@ void main() {
Key('mine-help'),
Key('mine-language'),
Key('mine-settings'),
Key('mine-notices'),
];
expect(entryKeys, hasLength(7));
expect(entryKeys, hasLength(8));
for (final entryKey in entryKeys) {
expect(find.byKey(entryKey), findsOneWidget);
}
@@ -155,6 +156,11 @@ void main() {
Icons.settings_outlined,
AppColors.mineIconSettings,
),
(
Key('mine-notices'),
Icons.notifications_none_rounded,
AppColors.mineIconNotices,
),
];
for (final entry in coloredIcons) {
final icon = tester.widget<Icon>(
@@ -172,9 +178,9 @@ void main() {
expect(
find.descendant(
of: find.byKey(const Key('mine-menu-cell-7')),
matching: find.byType(Icon),
matching: find.byIcon(Icons.notifications_none_rounded),
),
findsNothing,
findsOneWidget,
);
expect(
find.descendant(
@@ -280,15 +286,12 @@ void main() {
];
expect(signedInOrder, [...signedInOrder]..sort());
expect(find.byKey(const Key('mine-notices')), findsOneWidget);
await tester.tap(find.byKey(const Key('mine-edit')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('mine-edit-profile')), findsOneWidget);
expect(find.byKey(const Key('mine-edit-works')), findsOneWidget);
expect(find.byKey(const Key('mine-edit-creator')), findsOneWidget);
expect(find.byKey(const Key('mine-edit-notices')), findsOneWidget);
await tester.tap(find.byKey(const Key('mine-edit-works')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('mine-placeholder-page')), findsOneWidget);
expect(find.byKey(const Key('account-profile-page')), findsOneWidget);
expect(find.byKey(const Key('mine-edit-works')), findsNothing);
expect(find.byKey(const Key('mine-edit-creator')), findsNothing);
expect(tester.takeException(), isNull);
});
@@ -353,7 +356,7 @@ void main() {
expect(find.byType(FloatingActionButton), findsNothing);
});
testWidgets('should_expose_seven_menu_destinations_in_order', (tester) async {
testWidgets('should_expose_eight_menu_destinations_in_order', (tester) async {
tester.view.physicalSize = const Size(390, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
@@ -384,6 +387,7 @@ void main() {
(Key('mine-history'), MineDestination.history),
(Key('mine-watch-preferences'), MineDestination.preferences),
(Key('mine-help'), MineDestination.help),
(Key('mine-notices'), MineDestination.notices),
];
for (final row in rows) {
await tester.tap(find.byKey(row.$1));
+2 -1
View File
@@ -59,7 +59,8 @@ void main() {
final title = tester.widget<Text>(find.byKey(const Key('subpage-title')));
expect(title.style?.fontSize, 18);
expect(title.style?.fontWeight, FontWeight.w800);
expect(find.byKey(const Key('settings-notices')), findsOneWidget);
expect(find.byKey(const Key('settings-notices')), findsNothing);
expect(find.byKey(const Key('mine-account-card')), findsNothing);
expect(find.byKey(const Key('server-endpoint-setting')), findsNothing);
expect(find.text('Server address'), findsNothing);
expect(find.text('English'), findsNothing);
+2 -4
View File
@@ -46,11 +46,9 @@ void main() {
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-mine')));
await tester.pumpAndSettle();
await tester.ensureVisible(find.byKey(const Key('mine-settings')));
await tester.ensureVisible(find.byKey(const Key('mine-notices')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('mine-settings')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('settings-notices')));
await tester.tap(find.byKey(const Key('mine-notices')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('notices-page')), findsOneWidget);
+18 -2
View File
@@ -10,14 +10,30 @@ void main() {
'accessible': true,
'accessType': 'FREE',
'playUrl': '/files/episode/12.m3u8?e=1&s=abc',
'qualities': ['AUTO', '720P', '1080P'],
'qualities': ['AUTO', '720p', '1080p'],
'tracks': [
{'quality': 'AUTO', 'playUrl': '/files/episode/12.m3u8?e=1&s=abc'},
{'quality': '720p', 'playUrl': '/files/transcoded/12/720p/index.m3u8?s=1'},
],
'likeCount': '17',
'commentCount': 5,
});
expect(ticket.qualities, ['AUTO', '720P', '1080P']);
expect(ticket.qualities, ['AUTO', '720p', '1080p']);
expect(ticket.tracks[1].playUrl, contains('/720p/index.m3u8'));
expect(ticket.likeCount, 17);
expect(ticket.commentCount, 5);
expect(
siblingRenditionUrl(ticket.playUrl, '720P'),
isNull,
);
expect(
siblingRenditionUrl(
'/files/transcoded/9/master.m3u8?e=1&s=abc',
'720P',
),
'/files/transcoded/9/720p/index.m3u8?e=1&s=abc',
);
});
test('should_default_to_empty_qualities_when_field_is_missing', () {
+1
View File
@@ -93,6 +93,7 @@ void main() {
'durationMs': '180000',
'finished': false,
'playCount': '2',
'viewCount': '1800',
});
expect(record.dramaId, '2111012753262120962');
+2 -2
View File
@@ -12,8 +12,8 @@ import '../support/fake_haptics.dart';
import '../support/fake_interaction_repositories.dart';
void main() {
testWidgets('double tap within 320ms never pauses', (tester) async {
for (final delay in [120, 250, 300]) {
testWidgets('double tap within 400ms never pauses', (tester) async {
for (final delay in [120, 250, 300, 380]) {
final repository = FakeInteractionRepository();
final haptics = FakeHaptics();
final key = GlobalKey<_HarnessState>();
@@ -0,0 +1,29 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/pages/player/widgets/player_double_tap_hearts.dart';
void main() {
testWidgets('should_show_three_medium_hearts_when_double_tap_burst_starts', (
tester,
) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(body: PlayerDoubleTapHeartBurst(onCompleted: _noop)),
),
);
await tester.pump(const Duration(milliseconds: 160));
expect(find.byIcon(Icons.favorite_rounded), findsNWidgets(3));
expect(_size(tester, 0), 48);
expect(_size(tester, 1), 36);
expect(_size(tester, 2), 40);
});
}
double _size(WidgetTester tester, int index) {
return tester.widget<Icon>(find.byKey(Key('double-tap-heart-$index'))).size!;
}
void _noop() {}
+77 -2
View File
@@ -118,6 +118,75 @@ void main() {
expect(disabledText.style?.color, AppColors.subtle);
});
testWidgets('should_enable_quality_when_ticket_uses_lowercase_codes', (
tester,
) async {
await _openSheet(
tester,
qualities: const ['AUTO', '360p', '480p', '720p', '1080p'],
);
expect(find.text('720P'), findsOneWidget);
expect(find.text('1080P'), findsOneWidget);
expect(find.textContaining('暂不可用'), findsNothing);
final enabled = tester.widget<Text>(
find.descendant(
of: find.byKey(const Key('player-more-quality-720P')),
matching: find.byType(Text),
),
);
expect(enabled.style?.color, isNot(AppColors.subtle));
});
testWidgets('should_report_selected_quality_when_a_rendition_is_tapped', (
tester,
) async {
String? picked;
await _openSheet(
tester,
qualities: const ['AUTO', '720p'],
onQuality: (quality) async {
picked = quality;
return true;
},
);
await tester.ensureVisible(find.byKey(const Key('player-more-quality-720P')));
await tester.tap(find.byKey(const Key('player-more-quality-720P')));
await tester.pump();
expect(picked, '720P');
final selected = tester.widget<Text>(
find.descendant(
of: find.byKey(const Key('player-more-quality-720P')),
matching: find.byType(Text),
),
);
expect(selected.style?.color, AppColors.onSurface);
});
testWidgets('should_restore_previous_quality_when_switch_fails', (
tester,
) async {
await _openSheet(
tester,
qualities: const ['AUTO', '720p'],
onQuality: (_) async => false,
);
await tester.ensureVisible(find.byKey(const Key('player-more-quality-720P')));
await tester.tap(find.byKey(const Key('player-more-quality-720P')));
await tester.pumpAndSettle();
final restored = tester.widget<Text>(
find.descendant(
of: find.byKey(const Key('player-more-quality-AUTO')),
matching: find.byType(Text),
),
);
expect(restored.style?.color, AppColors.onSurface);
});
testWidgets('should_trigger_pip_and_close_sheet_when_tapped', (tester) async {
var pipCalls = 0;
await _openSheet(tester, onPip: () => pipCalls++);
@@ -142,7 +211,12 @@ void main() {
});
}
Future<void> _openSheet(WidgetTester tester, {VoidCallback? onPip}) async {
Future<void> _openSheet(
WidgetTester tester, {
VoidCallback? onPip,
Future<bool> Function(String quality)? onQuality,
List<String> qualities = const ['AUTO'],
}) async {
await tester.pumpWidget(
MaterialApp(
supportedLocales: AppLocalizations.supportedLocales,
@@ -158,11 +232,12 @@ Future<void> _openSheet(WidgetTester tester, {VoidCallback? onPip}) async {
isScrollControlled: true,
builder: (_) => PlayerMoreSheet(
l10n: AppLocalizations.of(context)!,
state: const PlayerMoreState(speed: 1.0, qualities: ['AUTO']),
state: PlayerMoreState(speed: 1.0, qualities: qualities),
actions: PlayerMoreActions(
onSpeedChanged: (_) {},
onClear: () {},
onDownload: () {},
onQualityChanged: onQuality ?? (_) async => true,
onPip: onPip,
),
),
@@ -69,6 +69,51 @@ void main() {
expect(_opacity(tester), 0);
expect(find.byKey(const Key('player-debug-video')), findsNothing);
});
testWidgets('should_cover_new_video_only_while_handoff_is_held', (
tester,
) async {
final current = VideoPlayerController.networkUrl(
Uri.parse('http://192.168.31.187:8081/files/720.m3u8'),
);
final previous = VideoPlayerController.networkUrl(
Uri.parse('http://192.168.31.187:8081/files/360.m3u8'),
);
addTearDown(current.dispose);
addTearDown(previous.dispose);
const ready = VideoPlayerValue(
duration: Duration(minutes: 1),
size: Size(1280, 720),
position: Duration(seconds: 3),
isInitialized: true,
isPlaying: true,
);
current.value = ready;
previous.value = ready;
Future<void> pump(VideoPlayerController? handoff) {
return tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: PlayerVideoSurface(
controller: current,
handoff: handoff,
videoUrl: current.dataSource,
theater: false,
),
),
),
);
}
await pump(previous);
expect(find.byKey(const Key('player-video-handoff')), findsOneWidget);
expect(_opacity(tester), 1);
await pump(null);
expect(find.byKey(const Key('player-video-handoff')), findsNothing);
expect(find.byType(VideoPlayer), findsOneWidget);
});
}
Future<VideoPlayerController> _pumpSurface(
+28 -1
View File
@@ -154,12 +154,37 @@ void main() {
await tester.pumpAndSettle();
});
testWidgets('feed progress thumb stays on the finger when dragged right', (
tester,
) async {
await _pumpFeedOverlay(tester);
final bar = find.byKey(const Key('home-feed-progress'));
final gesture = await tester.startGesture(tester.getCenter(bar));
await gesture.moveBy(const Offset(800, 0));
await tester.pump();
final thumbCenter = tester
.getRect(find.byKey(const Key('home-feed-progress-thumb')))
.center
.dx;
final playedRight = tester
.getRect(find.byKey(const Key('home-feed-progress-played')))
.right;
expect(thumbCenter, closeTo(tester.getRect(bar).right, 0.5));
expect(playedRight, closeTo(thumbCenter, 0.5));
await gesture.up();
await tester.pumpAndSettle();
});
testWidgets('full player drag clears chrome and release restores it', (
tester,
) async {
await _pumpFullPlayerOverlay(tester);
expect(find.byKey(const Key('player-controls-top')), findsOneWidget);
expect(find.byKey(const Key('player-bottom-actions')), findsOneWidget);
final progressTop = tester.getTopLeft(find.byType(Slider)).dy;
final gesture = await tester.startGesture(
tester.getCenter(find.byKey(const Key('player-progress'))),
@@ -170,8 +195,9 @@ void main() {
expect(_opacity(tester, const Key('player-controls-top')), 0);
expect(_opacity(tester, const Key('player-interaction-rail')), 0);
expect(_offstage(tester, const Key('player-controls-bottom')), isTrue);
expect(_offstage(tester, const Key('player-bottom-actions')), isTrue);
expect(_opacity(tester, const Key('player-bottom-actions')), 0);
expect(find.byKey(const Key('player-progress')), findsOneWidget);
expect(tester.getTopLeft(find.byType(Slider)).dy, progressTop);
await gesture.up();
await tester.pumpAndSettle();
@@ -179,6 +205,7 @@ void main() {
expect(_opacity(tester, const Key('player-controls-top')), 1);
expect(_opacity(tester, const Key('player-interaction-rail')), 1);
expect(_offstage(tester, const Key('player-bottom-actions')), isFalse);
expect(tester.getTopLeft(find.byType(Slider)).dy, progressTop);
});
}
+37
View File
@@ -0,0 +1,37 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/config/app_config.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/settings/legal_links_card.dart';
void main() {
tearDown(AppConfig.clearRuntimeBaseUrl);
testWidgets('should_open_terms_and_privacy_for_the_current_language', (
tester,
) async {
AppConfig.applyRuntimeBaseUrl('http://192.168.31.243:8081/');
final opened = <String>[];
await tester.pumpWidget(
MaterialApp(
locale: const Locale('id'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: LegalLinksCard(open: (url) async => opened.add(url)),
),
),
);
await tester.tap(find.byKey(const Key('settings-terms')));
await tester.tap(find.byKey(const Key('settings-privacy')));
await tester.pump();
expect(opened, [
'http://192.168.31.243:8081/terms/id',
'http://192.168.31.243:8081/privacy/id',
]);
});
}
+5 -3
View File
@@ -1,10 +1,9 @@
// Author: fruit.com.cn
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/player_port.dart';
// Widget tests exercise navigation and scheduling without a native decoder.
PlayerPort createWidgetPlayer(Episode episode) => WidgetPlayer();
/// 组件测试用播放器,不创建原生解码器。
class WidgetPlayer implements PlayerPort {
@override
Future<void> initialize() async {}
@@ -18,3 +17,6 @@ class WidgetPlayer implements PlayerPort {
@override
Future<void> dispose() async {}
}
/// 组件测试的播放器工厂。
PlayerPort createWidgetPlayer(Episode episode) => WidgetPlayer();