Files
photo-live/apps/mobile/test/pages/login_page_test.dart
T
浅念 955f9b13c1 feat(mobile): upload queue ui and continuous capture page
Task 9 (P3 plan): queue tab replaces the stub, continuous capture page
enqueues per shot, album detail gains an upload FAB, and the upload
engine lifecycle is now bound to the login session.

- Engine scope: session account drives UploadEngine start/stop; wired
  from app boot, login success, logout, and 401 eviction. Tasks stay in
  the store across logout (accountId hard isolation).
- Queue tab: album x state x time filter bar, task rows with thumbnail,
  state chip, localized error copy, attempt count, per-row progress via
  a broadcast progress hub, state-dependent actions (re-pick retry,
  retryNow, cancel, delete), batch retry bar, 200ms-debounced live
  refresh from drift table updates.
- Capture page: camera preview via injected CaptureSource, one enqueue
  per shutter press, persistent album/category/badges overlay with a
  switch-only-affects-new-shots hint, gallery multi-pick, exit confirm
  while uploads are pending, permission-denied and init-failure views.
- POST_NOTIFICATIONS runtime permission via a small MethodChannel
  (photolive/permissions) + PermissionPlugin.kt: single runtime
  permission need, avoids permission_handler dependency; requested once
  on first enqueue, silent degradation when denied or unavailable.
- Album detail FAB: capture-and-upload / pick-from-gallery sheet.
- l10n: ~50 new keys in zh/en arbs; tests: +45 (filter matrix, engine
  scope lifecycle, permission gate, queue tab, capture page, FAB entry);
  existing page tests updated to inject session/task-store fakes.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-19 04:47:31 +08:00

160 lines
5.5 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 登录页 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<void> pumpLoginPage(WidgetTester tester, ApiClient client) async {
await tester.pumpWidget(
ProviderScope(
// Task 9 起登录成功会刷新 sessionAccountProvider——真实
// flutter_secure_storage 平台通道在测试环境挂起,必须注入替身。
overrides: [
...pageTestOverrides(session: store, client: 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<void> 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<void>();
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);
});
}