feat(app): 完善播放协议搜索与空状态
This commit is contained in:
1 parent
671e0ce143
commit
ef29fc5f15
85 files changed
+2976
-768
No files matched your search
@@ -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')),
|
||||
'不再使用',
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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')),
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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>(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
@@ -93,6 +93,7 @@ void main() {
|
||||
'durationMs': '180000',
|
||||
'finished': false,
|
||||
'playCount': '2',
|
||||
'viewCount': '1800',
|
||||
});
|
||||
|
||||
expect(record.dramaId, '2111012753262120962');
|
||||
|
||||
@@ -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() {}
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
Reference in new issue
Block a user