/// 登录页 widget 测试(P3 计划 Task 3)。 /// /// 覆盖:空字段客户端校验(不发请求)、成功登录写会话并跳 /home、 /// 凭据错误/限速(429)错误码本地化文案、提交中加载态(按钮禁用防重复提交)。 /// API 层经 FakeAdapter(dio HttpClientAdapter)打桩,Provider 以 override /// 注入测试客户端(无真实网络)。 library; import 'dart:async'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:photo_live/core/api_client.dart'; import 'package:photo_live/core/error_codes.dart'; import 'package:photo_live/l10n/app_localizations.dart'; import 'package:photo_live/pages/login_page.dart'; import '../helpers/fakes.dart'; void main() { late FakeSessionStore store; late int unauthorizedCalls; setUp(() { store = FakeSessionStore(); unauthorizedCalls = 0; }); ApiClient clientOf(FakeAdapter adapter) => ApiClient( sessionStore: store, dio: Dio(BaseOptions(baseUrl: 'http://test.local')) ..httpClientAdapter = adapter, )..onUnauthorized = () => unauthorizedCalls++; Future pumpLoginPage(WidgetTester tester, ApiClient client) async { await tester.pumpWidget( ProviderScope( overrides: [apiClientProvider.overrideWith((ref) => client)], child: MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: const LoginPage(), routes: { '/home': (context) => const Scaffold(body: Center(child: Text('HOME_MARK'))), }, ), ), ); await tester.pumpAndSettle(); } Future fillAndSubmit(WidgetTester tester) async { await tester.enterText( find.widgetWithText(TextFormField, 'Account'), 'staff01', ); await tester.enterText( find.widgetWithText(TextFormField, 'Password'), 'pw-123456', ); await tester.tap(find.text('Sign in')); await tester.pump(); // 触发提交(含 SnackBar 动画首帧)。 } testWidgets('空字段 → 客户端校验提示,不发请求', (tester) async { final adapter = FakeAdapter((_) => throw StateError('不应发起请求')); await pumpLoginPage(tester, clientOf(adapter)); await tester.tap(find.text('Sign in')); await tester.pump(); // 测试环境默认 locale=en:账号/密码两个必填提示。 expect(find.text('This field is required'), findsNWidgets(2)); expect(adapter.requests, isEmpty); }); testWidgets('成功 → 写会话并跳 /home', (tester) async { final adapter = FakeAdapter( (o) => o.path == '/v1/auth/password' ? (200, loginOkBody()) : throw StateError('unexpected ${o.path}'), ); await pumpLoginPage(tester, clientOf(adapter)); await fillAndSubmit(tester); await tester.pumpAndSettle(); expect(find.text('HOME_MARK'), findsOneWidget); expect(store.session?.token, 'tok-new'); expect(store.session?.account, 'staff01'); expect( adapter.requests.single.body, {'account': 'staff01', 'password': 'pw-123456'}, ); }); testWidgets('凭据错误(401)→ 「账号或密码错误」文案,不驱逐通知', (tester) async { final adapter = FakeAdapter( (o) => o.path == '/v1/auth/password' ? (401, errorBody(ErrorCodes.authInvalidCredentials)) : throw StateError('unexpected ${o.path}'), ); await pumpLoginPage(tester, clientOf(adapter)); await fillAndSubmit(tester); await tester.pumpAndSettle(); expect(find.text('Incorrect account or password'), findsOneWidget); expect(store.session, isNull, reason: '登录失败不落会话'); expect(unauthorizedCalls, 0, reason: '登录 401 不触发驱逐'); expect(find.text('HOME_MARK'), findsNothing); }); testWidgets('限速(429 AUTH_RATE_LIMITED)→ 稍后再试文案', (tester) async { final adapter = FakeAdapter( (o) => o.path == '/v1/auth/password' ? (429, errorBody(ErrorCodes.authRateLimited)) : throw StateError('unexpected ${o.path}'), ); await pumpLoginPage(tester, clientOf(adapter)); await fillAndSubmit(tester); await tester.pumpAndSettle(); expect( find.text('Too many attempts. Please try again later'), findsOneWidget, ); }); testWidgets('提交中 → 加载指示且按钮禁用(防重复提交)', (tester) async { final gate = Completer(); final adapter = FakeAdapter((o) async { await gate.future; return (200, loginOkBody()); }); await pumpLoginPage(tester, clientOf(adapter)); await fillAndSubmit(tester); // dio 链路经真实定时器推进(fake-async 需步进时钟才抵达适配器)。 await tester.pump(const Duration(milliseconds: 50)); expect(find.byType(CircularProgressIndicator), findsOneWidget); // 加载中再点按钮(onPressed=null)不产生第二个请求。 await tester.tap(find.byType(FilledButton), warnIfMissed: false); await tester.pump(const Duration(milliseconds: 50)); expect(adapter.requests, hasLength(1)); gate.complete(); await tester.pumpAndSettle(); expect(find.text('HOME_MARK'), findsOneWidget); }); }