import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; import 'package:fruitshorts/core/data/models/auth_session.dart'; import 'package:fruitshorts/core/data/remote/auth_api.dart'; import 'package:fruitshorts/core/data/remote/api_client.dart'; import 'package:fruitshorts/core/data/services/auth_controller.dart'; import 'package:fruitshorts/core/data/services/auth_session_store.dart'; import 'package:fruitshorts/core/platform/haptics.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/auth/auth_view.dart'; import '../support/fake_http_adapter.dart'; import '../support/fake_haptics.dart'; /// 登录/注册页的可读证据:校验提示 + 登录/注册切换 + 未登录时不会去发请求。 void main() { setUp(() { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); }); Future pumpAuthPage( WidgetTester tester, { bool signUp = false, AuthApi? api, FakeHaptics? haptics, }) async { final auth = AuthController( api ?? AuthApi( // 这个测试不联网:请求一律 500,正好用于验证"本地校验没过就不发请求" ApiClient( baseUrl: 'http://127.0.0.1:1', adapter: FakeHttpAdapter( (_) async => FakeHttpResponse('{}', 500), ), ), ), AuthSessionStore(), ); await tester.pumpWidget( MultiProvider( providers: [ ChangeNotifierProvider.value(value: auth), Provider.value(value: haptics ?? FakeHaptics()), ], child: MaterialApp( locale: const Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: AuthPage(signUpMode: signUp), ), ), ); await tester.pumpAndSettle(); await tester.ensureVisible(find.byKey(const Key('auth-entry-email'))); await tester.tap(find.byKey(const Key('auth-entry-email'))); await tester.pumpAndSettle(); } testWidgets('should_show_email_error_and_stay_on_page_when_form_is_empty', ( tester, ) async { await pumpAuthPage(tester); expect(find.text('Welcome back'), findsOneWidget); await tester.tap(find.byType(FilledButton)); await tester.pumpAndSettle(); expect(find.text('Enter your email'), findsOneWidget); expect(find.text('Welcome back'), findsOneWidget); expect(find.byKey(const Key('centered-toast')), findsNothing); }); testWidgets( 'should_reject_weak_password_locally_when_only_letters_are_typed', (tester) async { await pumpAuthPage(tester); await tester.enterText(find.byType(TextField).first, 'a@b.com'); await tester.enterText(find.byType(TextField).last, 'abcdefgh'); await tester.tap(find.byType(FilledButton)); await tester.pumpAndSettle(); expect( find.text('Password needs both letters and numbers'), findsOneWidget, ); expect(find.byKey(const Key('centered-toast')), findsNothing); }, ); testWidgets('should_show_centered_toast_when_login_api_returns_an_error', ( tester, ) async { final api = _FailingAuthApi(loginMessage: 'Invalid email or password'); await pumpAuthPage(tester, api: api); await _enterValidCredentials(tester); await tester.ensureVisible(find.byKey(const Key('auth-submit'))); await tester.tap(find.byKey(const Key('auth-submit'))); await tester.pumpAndSettle(); final toast = find.byKey(const Key('centered-toast')); expect(toast, findsOneWidget); expect(find.text('Sign in to continue'), findsOneWidget); expect(find.byType(AlertDialog), findsNothing); expect(api.loginCalls, 1); expect( tester.getCenter(toast), offsetMoreOrLessEquals( tester.getCenter(find.byType(Scaffold)), epsilon: 1, ), ); expect( tester .widget(find.byKey(const Key('auth-submit'))) .onPressed, isNotNull, ); await _pumpToastAway(tester); expect(toast, findsNothing); }); testWidgets('should_show_centered_toast_when_register_api_returns_an_error', ( tester, ) async { final api = _FailingAuthApi(registerMessage: 'Email already exists'); await pumpAuthPage(tester, signUp: true, api: api); await _enterValidCredentials(tester); await tester.ensureVisible(find.byKey(const Key('auth-submit'))); await tester.tap(find.byKey(const Key('auth-submit'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('centered-toast')), findsOneWidget); expect(find.text('Email already exists'), findsOneWidget); expect(find.byType(AlertDialog), findsNothing); expect(api.registerCalls, 1); await _pumpToastAway(tester); expect(find.byKey(const Key('centered-toast')), findsNothing); }); testWidgets( 'should_show_network_error_when_login_throws_an_unknown_exception', (tester) async { final api = _FailingAuthApi(loginThrowsUnknown: true); await pumpAuthPage(tester, api: api); await _enterValidCredentials(tester); await tester.ensureVisible(find.byKey(const Key('auth-submit'))); await tester.tap(find.byKey(const Key('auth-submit'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('centered-toast')), findsOneWidget); expect(find.text('Network error, please try again'), findsOneWidget); expect(find.byType(AlertDialog), findsNothing); await _pumpToastAway(tester); expect(find.byKey(const Key('centered-toast')), findsNothing); }, ); testWidgets( 'should_flip_between_sign_in_and_sign_up_when_switch_link_is_tapped', (tester) async { await pumpAuthPage(tester); expect(find.text('Welcome back'), findsOneWidget); await tester.tap(find.text('New here? Create an account')); await tester.pumpAndSettle(); expect(find.text('Create your account'), findsOneWidget); expect(find.text('Already have an account? Sign in'), findsOneWidget); }, ); testWidgets('should_show_brand_and_form_accessibility_when_page_opens', ( tester, ) async { await pumpAuthPage(tester); expect(find.text('FruitShorts'), findsOneWidget); expect(find.text('Short dramas made for Indonesia'), findsOneWidget); final email = tester.widget(find.byKey(const Key('auth-email'))); final password = tester.widget( find.byKey(const Key('auth-password')), ); expect(email.autofillHints, contains(AutofillHints.email)); expect(password.autofillHints, contains(AutofillHints.password)); expect( tester.getSize(find.byKey(const Key('auth-submit'))).height, greaterThanOrEqualTo(44), ); expect( tester.getSize(find.byKey(const Key('auth-password-visibility'))).height, greaterThanOrEqualTo(44), ); expect( tester.getSize(find.byKey(const Key('auth-switch-mode'))).height, greaterThanOrEqualTo(44), ); }); testWidgets('should_toggle_password_visibility_when_eye_button_is_tapped', ( tester, ) async { await pumpAuthPage(tester); expect( tester .widget(find.byKey(const Key('auth-password'))) .obscureText, isTrue, ); await tester.tap(find.byKey(const Key('auth-password-visibility'))); await tester.pump(); expect( tester .widget(find.byKey(const Key('auth-password'))) .obscureText, isFalse, ); expect(find.byTooltip('Hide password'), findsOneWidget); }); testWidgets('should_move_focus_from_email_to_password_when_next_is_pressed', ( tester, ) async { await pumpAuthPage(tester); await tester.tap(find.byKey(const Key('auth-email'))); await tester.testTextInput.receiveAction(TextInputAction.next); await tester.pump(); expect( FocusManager.instance.primaryFocus?.debugLabel, 'auth-password-focus', ); }); testWidgets('should_disable_form_and_submit_while_done_action_is_pending', ( tester, ) async { final api = _PendingAuthApi(); final haptics = FakeHaptics(); await pumpAuthPage(tester, api: api, haptics: haptics); await tester.enterText(find.byKey(const Key('auth-email')), 'a@b.com'); await tester.enterText(find.byKey(const Key('auth-password')), 'abcd1234'); await tester.tap(find.byKey(const Key('auth-password'))); await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pump(); expect(api.loginCalls, 1); expect(haptics.lightImpacts, 1); expect( tester.widget(find.byKey(const Key('auth-email'))).enabled, isFalse, ); expect( tester.widget(find.byKey(const Key('auth-password'))).enabled, isFalse, ); expect( tester .widget(find.byKey(const Key('auth-password-visibility'))) .onPressed, isNull, ); expect( tester .widget(find.byKey(const Key('auth-submit'))) .onPressed, isNull, ); expect( tester .widget(find.byKey(const Key('auth-switch-mode'))) .onPressed, isNull, ); api.release(); await tester.pumpAndSettle(); }); testWidgets('should_render_validation_error_with_theme_semantic_color', ( tester, ) async { await pumpAuthPage(tester); await tester.enterText(find.byKey(const Key('auth-email')), 'a@b.com'); await tester.enterText(find.byKey(const Key('auth-password')), 'abcdefgh'); await tester.tap(find.byKey(const Key('auth-submit'))); await tester.pumpAndSettle(); final errorFinder = find.byKey(const Key('auth-error')); final error = tester.widget(errorFinder); final expected = Theme.of(tester.element(errorFinder)).colorScheme.error; expect(error.style?.color, expected); }); testWidgets( 'should_keep_submit_reachable_when_keyboard_opens_on_small_screen', (tester) async { tester.view.physicalSize = const Size(320, 568); tester.view.devicePixelRatio = 1; tester.view.viewInsets = const FakeViewPadding(bottom: 260); addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetViewInsets); await pumpAuthPage(tester); await tester.scrollUntilVisible( find.byKey(const Key('auth-submit')), 120, scrollable: find .descendant( of: find.byKey(const Key('auth-scroll')), matching: find.byType(Scrollable), ) .first, ); await tester.pumpAndSettle(); expect( find.byKey(const Key('auth-submit')).hitTestable(), findsOneWidget, ); expect(tester.takeException(), isNull); }, ); testWidgets('should_keep_password_above_ios_keyboard', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; addTearDown(() => debugDefaultTargetPlatformOverride = null); tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; tester.view.viewInsets = const FakeViewPadding(bottom: 300); addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetViewInsets); await pumpAuthPage(tester); await tester.tap(find.byKey(const Key('auth-password'))); await tester.pumpAndSettle(); _expectAboveKeyboard(tester, const Key('auth-password'), 300); debugDefaultTargetPlatformOverride = null; }); testWidgets('should_keep_signup_password_above_ios_keyboard', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; addTearDown(() => debugDefaultTargetPlatformOverride = null); tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; tester.view.viewInsets = const FakeViewPadding(bottom: 300); addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetViewInsets); await pumpAuthPage(tester, signUp: true); await tester.tap(find.byKey(const Key('auth-password'))); await tester.pumpAndSettle(); _expectAboveKeyboard(tester, const Key('auth-password'), 300); debugDefaultTargetPlatformOverride = null; }); testWidgets('should_keep_password_above_android_channel_keyboard', ( tester, ) async { debugDefaultTargetPlatformOverride = TargetPlatform.android; addTearDown(() => debugDefaultTargetPlatformOverride = null); const keyboardChannel = MethodChannel('com.stormg.lumadrama/keyboard'); tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( keyboardChannel, (call) async => call.method == 'get' ? 300 : null, ); addTearDown(() { tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( keyboardChannel, null, ); }); tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; tester.view.viewInsets = const FakeViewPadding(); addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetViewInsets); await pumpAuthPage(tester); await tester.binding.defaultBinaryMessenger.handlePlatformMessage( keyboardChannel.name, const StandardMethodCodec().encodeMethodCall( const MethodCall('insetsChanged', 300), ), (_) {}, ); await tester.pump(); await tester.tap(find.byKey(const Key('auth-password'))); await tester.pumpAndSettle(); _expectAboveKeyboard(tester, const Key('auth-password'), 300); debugDefaultTargetPlatformOverride = null; }); } Future _enterValidCredentials(WidgetTester tester) async { await tester.enterText(find.byKey(const Key('auth-email')), 'a@b.com'); await tester.enterText(find.byKey(const Key('auth-password')), 'abcd1234'); } Future _pumpToastAway(WidgetTester tester) async { await tester.pump(const Duration(seconds: 3)); await tester.pumpAndSettle(); } void _expectAboveKeyboard(WidgetTester tester, Key fieldKey, double inset) { final screenHeight = tester.view.physicalSize.height / tester.view.devicePixelRatio; expect( tester.getBottomLeft(find.byKey(fieldKey)).dy, lessThanOrEqualTo(screenHeight - inset), ); } class _FailingAuthApi extends AuthApi { _FailingAuthApi({ this.loginMessage = 'login failed', this.registerMessage = 'register failed', this.loginThrowsUnknown = false, }) : super( ApiClient( baseUrl: 'http://127.0.0.1:1', adapter: FakeHttpAdapter((_) async => FakeHttpResponse('{}', 500)), ), ); final String loginMessage; final String registerMessage; final bool loginThrowsUnknown; int loginCalls = 0; int registerCalls = 0; @override Future login({ required String email, required String password, required String deviceId, String platform = '', String osVersion = '', String appVersion = '', }) async { loginCalls++; if (loginThrowsUnknown) throw StateError('unexpected'); throw ApiException(401, loginMessage); } @override Future register({ required String email, required String password, required String deviceId, String platform = '', String osVersion = '', String appVersion = '', }) async { registerCalls++; throw ApiException(409, registerMessage); } } class _PendingAuthApi extends AuthApi { _PendingAuthApi() : super( ApiClient( baseUrl: 'http://127.0.0.1:1', adapter: FakeHttpAdapter((_) async => FakeHttpResponse('{}', 500)), ), ); final _gate = Completer(); int loginCalls = 0; void release() => _gate.complete(); @override Future login({ required String email, required String password, required String deviceId, String platform = '', String osVersion = '', String appVersion = '', }) async { loginCalls++; await _gate.future; throw const ApiException(500, 'pending failure'); } }