Files
photo-live/apps/mobile/test/pages/account_pages_ui03_test.dart
T
2026-09-22 00:28:39 +08:00

454 lines
16 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.
/// UI-03 页面 01-04(登录/设置/修改密码/语言选择)重设计回归。
///
/// 覆盖:登录拼贴占位与 l10n 文案、密码显示/隐藏切换、键盘压缩布局、
/// 登录页右上语言入口复用共享面板;设置账号摘要卡(首字母头像)、分组
/// 与真实版本号、上传帮助面板、退出确认(有任务/无任务两态);修改密码
/// 确认不匹配即时提示(修正新密码即清除)、提交中防重复;语言面板
/// Done 提交/取消不改(各语言以自身语言书写)。
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/app/locale_controller.dart';
import 'package:photo_live/core/api_client.dart';
import 'package:photo_live/core/error_codes.dart';
import 'package:photo_live/core/session_store.dart';
import 'package:photo_live/features/uploads/task_state.dart';
import 'package:photo_live/features/uploads/task_store.dart';
import 'package:photo_live/l10n/app_localizations.dart';
import 'package:photo_live/pages/home_page.dart';
import 'package:photo_live/pages/login_page.dart';
import 'package:photo_live/pages/password_change_page.dart';
import '../helpers/fakes.dart';
void main() {
late FakeSessionStore session;
late TaskStore taskStore;
setUp(() {
session = FakeSessionStore();
taskStore = TaskStore.memory();
});
ApiClient clientOf(FakeAdapter adapter) => ApiClient(
sessionStore: session,
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
..httpClientAdapter = adapter,
);
/// 登录页(默认 en;不触网——未提交不发请求)。
Future<void> pumpLogin(WidgetTester tester, ApiClient client) async {
await tester.pumpWidget(
ProviderScope(
overrides: [...pageTestOverrides(session: session, client: client)],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const LoginPage(),
),
),
);
await tester.pumpAndSettle();
}
/// 主壳设置页签(IndexedStack 三页签同建:相册列表走空页兜底)。
Future<ProviderContainer> pumpSettings(
WidgetTester tester, {
FakeLocaleStore? localeStore,
AppLocaleChoice initialLocale = AppLocaleChoice.system,
}) async {
final adapter = FakeAdapter(
(o) => o.path == '/v1/albums'
? (200, cursorPageBody(<Map<String, dynamic>>[]))
: throw StateError('unexpected ${o.path}'),
);
final store = localeStore ?? FakeLocaleStore();
await tester.pumpWidget(
ProviderScope(
overrides: [
...pageTestOverrides(
session: session,
taskStore: taskStore,
client: clientOf(adapter),
),
localeSettingsProvider.overrideWith(
() => LocaleSettings(initial: initialLocale, store: store),
),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const HomePage(),
),
),
);
await tester.pumpAndSettle();
// 切到设置页签(底部导航第三项;IndexedStack 默认在相册页签)。
await tester.tap(find.byIcon(Icons.settings_outlined));
await tester.pumpAndSettle();
return ProviderScope.containerOf(
tester.element(find.byType(HomePage)),
);
}
/// 修改密码页(推入命名路由,上级页面带 MARK)。
Future<void> pumpPasswordChange(WidgetTester tester, ApiClient client) async {
await tester.pumpWidget(
ProviderScope(
overrides: [apiClientProvider.overrideWith((ref) => client)],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: Center(child: Text('MARK'))),
routes: {
'/password-change': (context) => const PasswordChangePage(),
},
),
),
);
tester
.state<NavigatorState>(find.byType(Navigator).first)
.pushNamed('/password-change');
await tester.pumpAndSettle();
}
UploadTaskDraft makeDraft(String id) => UploadTaskDraft(
id: id,
accountId: 'staff01',
albumId: 'alb-1',
clientTaskId: 'ct-$id',
sourceFilePath: '/tmp/$id.jpg',
filename: '$id.jpg',
bytes: 10,
contentType: 'image/jpeg',
);
/// 任务库写通知 → 200ms 防抖后角标计数生效。
Future<void> pumpCounts(WidgetTester tester) async {
await tester.pump(const Duration(milliseconds: 450));
}
group('页面 01 登录', () {
testWidgets('拼贴占位 + 主标题/说明/管理员提示均来自 l10n', (tester) async {
await pumpLogin(tester, clientOf(FakeAdapter(
(o) => throw StateError('不应发起请求: ${o.path}'))));
expect(find.byKey(const Key('login-collage')), findsOneWidget,
reason: '拼贴渐变占位在场');
expect(find.text('Your event. In focus.'), findsOneWidget);
expect(
find.text('Sign in to manage albums and photos'),
findsOneWidget,
);
expect(find.text('Ask admin for account'), findsOneWidget);
});
testWidgets('密码显示/隐藏切换(Tooltip 语义 + obscureText 翻转)',
(tester) async {
await pumpLogin(tester, clientOf(FakeAdapter(
(o) => throw StateError('不应发起请求: ${o.path}'))));
TextField passwordField() => tester.widget<TextField>(
find.descendant(
of: find.widgetWithText(TextFormField, 'Password'),
matching: find.byType(TextField),
),
);
expect(passwordField().obscureText, isTrue);
expect(find.byTooltip('Show password'), findsOneWidget);
await tester.tap(find.byTooltip('Show password'));
await tester.pump();
expect(passwordField().obscureText, isFalse,
reason: '切换后明文可见');
expect(find.byTooltip('Hide password'), findsOneWidget);
await tester.tap(find.byTooltip('Hide password'));
await tester.pump();
expect(passwordField().obscureText, isTrue);
});
testWidgets('键盘开启 → 拼贴压缩(高度显著缩小,整页可滚动)', (tester) async {
await pumpLogin(tester, clientOf(FakeAdapter(
(o) => throw StateError('不应发起请求: ${o.path}'))));
final before = tester.getSize(find.byKey(const Key('login-collage')));
tester.view.viewInsets = const FakeViewPadding(bottom: 420);
addTearDown(tester.view.resetViewInsets);
await tester.pumpAndSettle();
final after = tester.getSize(find.byKey(const Key('login-collage')));
expect(after.height, lessThan(before.height),
reason: '键盘开启时拼贴压缩(28% → 10% 档)');
expect(find.byKey(const Key('login-collage')), findsOneWidget,
reason: '压缩而非移除');
// 表单仍可达(滚动到密码字段并聚焦)。
await tester.ensureVisible(find.text('Password'));
expect(find.text('Password'), findsOneWidget);
});
testWidgets('右上语言入口打开共享语言面板', (tester) async {
await pumpLogin(tester, clientOf(FakeAdapter(
(o) => throw StateError('不应发起请求: ${o.path}'))));
await tester.tap(find.text('EN'));
await tester.pumpAndSettle();
expect(find.text('Follow system'), findsOneWidget);
expect(find.text('Done'), findsOneWidget);
});
testWidgets('失败 → 表单附近内联错误徽章', (tester) async {
final adapter = FakeAdapter(
(o) => o.path == '/v1/auth/password'
? (401, errorBody(ErrorCodes.authInvalidCredentials))
: throw StateError('unexpected ${o.path}'),
);
await pumpLogin(tester, clientOf(adapter));
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.pumpAndSettle();
expect(find.text('Incorrect account or password'), findsOneWidget);
});
});
group('页面 02 设置', () {
setUp(() {
session.session = Session(
token: 'tok-live-1',
expiresAt: DateTime.utc(2099, 1, 1),
account: 'staff01',
);
});
testWidgets('账号摘要卡:首字母头像 + 账号(非编辑入口)', (tester) async {
await pumpSettings(tester);
expect(find.text('staff01'), findsOneWidget);
expect(find.text('Currently signed-in account'), findsOneWidget);
// 首字母头像占位(staff01 → S)。
final avatarText = find.descendant(
of: find.byType(CircleAvatar),
matching: find.text('S'),
);
expect(avatarText, findsOneWidget);
});
testWidgets('分组标题在场 + 版本来自 kAppVersion', (tester) async {
await pumpSettings(tester);
expect(find.text('Account'), findsOneWidget);
expect(find.text('General'), findsOneWidget);
expect(find.text(kAppVersion), findsOneWidget, reason: '真实版本号');
expect(find.text('Language'), findsOneWidget);
expect(find.text('Change password'), findsOneWidget);
});
testWidgets('上传帮助 → 底部面板展示系统限制说明', (tester) async {
await pumpSettings(tester);
await tester.tap(find.text('Upload help'));
await tester.pumpAndSettle();
expect(find.text('How background uploads work'), findsOneWidget);
expect(
find.textContaining('system keeps uploads running'),
findsOneWidget,
reason: '帮助正文说明系统限制,不承诺后台持续',
);
});
testWidgets('有进行中任务 → 退出确认明确任务保留', (tester) async {
await pumpSettings(tester);
final task = await taskStore.create(makeDraft('t-1'));
await taskStore.updateState(task.id, UploadTaskState.queued);
await pumpCounts(tester);
await tester.ensureVisible(find.text('Sign out'));
await tester.pumpAndSettle();
await tester.tap(find.text('Sign out'));
await tester.pumpAndSettle();
expect(
find.text(
'Local upload tasks are preserved on this device and will '
'resume when you sign back in.',
),
findsOneWidget,
);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(session.session, isNotNull, reason: '取消不登出');
});
testWidgets('无任务 → 退出确认为常规文案', (tester) async {
await pumpSettings(tester);
await tester.ensureVisible(find.text('Sign out'));
await tester.pumpAndSettle();
await tester.tap(find.text('Sign out'));
await tester.pumpAndSettle();
expect(
find.text('You will be signed out on this device.'),
findsOneWidget,
);
});
});
group('页面 03 修改密码', () {
setUp(() {
session.session = Session(
token: 'tok-live-1',
expiresAt: DateTime.utc(2099, 1, 1),
account: 'staff01',
);
});
testWidgets('确认不匹配即时提示;修正新密码即清除', (tester) async {
await pumpPasswordChange(
tester,
clientOf(FakeAdapter((o) => throw StateError('不应发起请求'))),
);
await tester.enterText(
find.widgetWithText(TextFormField, 'New password'),
'brand-new-pass-9',
);
await tester.enterText(
find.widgetWithText(TextFormField, 'Confirm new password'),
'different-pass-9',
);
await tester.pump();
expect(find.text('New passwords do not match'), findsOneWidget,
reason: '输入即校验,不等提交');
// 修正新密码(与确认一致)→ 监听器复验清除错误。
await tester.enterText(
find.widgetWithText(TextFormField, 'New password'),
'different-pass-9',
);
await tester.pump();
expect(find.text('New passwords do not match'), findsNothing);
});
testWidgets('提交中按钮禁用(防重复提交)', (tester) async {
final gate = Completer<void>();
final adapter = FakeAdapter((o) async {
await gate.future;
return (204, null);
});
await pumpPasswordChange(tester, clientOf(adapter));
await tester.enterText(
find.widgetWithText(TextFormField, 'Current password'),
'old-pass-123',
);
await tester.enterText(
find.widgetWithText(TextFormField, 'New password'),
'brand-new-pass-9',
);
await tester.enterText(
find.widgetWithText(TextFormField, 'Confirm new password'),
'brand-new-pass-9',
);
await tester.tap(find.widgetWithText(FilledButton, 'Save password'));
await tester.pump(const Duration(milliseconds: 50));
expect(find.byType(CircularProgressIndicator), findsOneWidget);
await tester.tap(
find.byType(FilledButton),
warnIfMissed: false,
);
await tester.pump(const Duration(milliseconds: 50));
expect(adapter.requests, hasLength(1), reason: '加载中不重复提交');
gate.complete();
await tester.pumpAndSettle();
expect(find.text('MARK'), findsOneWidget, reason: '成功后 pop 回上级');
});
});
group('页面 04 语言选择(共享面板)', () {
setUp(() {
session.session = Session(
token: 'tok-live-1',
expiresAt: DateTime.utc(2099, 1, 1),
account: 'staff01',
);
});
testWidgets('各语言以自身语言书写 + Done 提交(持久化 + 即刻刷新)',
(tester) async {
final store = FakeLocaleStore();
final container = await pumpSettings(
tester,
localeStore: store,
initialLocale: AppLocaleChoice.system,
);
await tester.tap(find.text('Language'));
await tester.pumpAndSettle();
// 各语言以自身语言书写(不随当前界面语言变化)。
// 「Follow system」两次:语言行尾当前值 + 弹层选项。
expect(find.text('Follow system'), findsNWidgets(2));
expect(find.text('English'), findsOneWidget);
expect(find.text('中文'), findsOneWidget);
await tester.tap(find.text('中文'));
await tester.pump();
expect(store.writes, isEmpty, reason: 'Done 前不写持久层');
await tester.tap(find.text('Done'));
await tester.pumpAndSettle();
expect(container.read(localeSettingsProvider), AppLocaleChoice.zh);
expect(store.writes, ['zh'], reason: 'Done 才写持久层');
// 本 harness 的 MaterialApp 不随 provider 重接 locale(live 全 App
// 重建在 app/locale_test.dart 覆盖);此处断言行尾当前值更新。
expect(find.text('中文'), findsOneWidget, reason: '语言行尾更新为中文');
expect(find.byType(IndexedStack), findsOneWidget,
reason: '语言刷新不重建页签结构');
});
testWidgets('取消(外部点击关闭)不改变语言偏好', (tester) async {
final store = FakeLocaleStore();
final container = await pumpSettings(
tester,
localeStore: store,
initialLocale: AppLocaleChoice.system,
);
await tester.tap(find.text('Language'));
await tester.pumpAndSettle();
await tester.tap(find.text('English'));
await tester.pump();
// 点击面板外(屏障)关闭 = 取消。
await tester.tapAt(const Offset(20, 20));
await tester.pumpAndSettle();
expect(container.read(localeSettingsProvider), AppLocaleChoice.system,
reason: '未提交不改档位');
expect(store.writes, isEmpty, reason: '取消不写持久层');
expect(find.text('设置'), findsNothing, reason: '界面保持英文');
});
});
}