Files
photo-live/apps/mobile/test/pages/album_form_page_test.dart
T
浅念 8bd09eefb9 feat(mobile): UI-03 forms and collaboration pages (09-12)
- Page 09 create: grouped form (Event details / Description & languages),
  required default-language selector, zh-CN/en tabs with per-locale
  title/description/venue/address, explicit timezone field, bottom save
  button kept keyboard-reachable, create mode has no Access tab
- Page 10 edit: Details/Access tabs sharing one draft; Access has four
  switches with explanations; original downloads constrained by downloads
  (disabled + forced off in PATCH); unsaved-changes PopScope confirm;
  save commits all changes in one minimal PATCH
- Page 11 members: Owner group + Collaborators list with initial avatars
  and per-row remove; full-width Invite colleague opens account-search
  bottom sheet (debounced prefix search, race guard, already-added state);
  remove confirm names the member
- Page 12 categories: extracted to standalone page with per-row edit/
  delete menu and New category sheet (language tabs + name + sort order);
  delete confirm mentions photo impact; collaborator browse-only; old
  inline section removed from album detail
- l10n: new form/members/categories keys both locales; regenerated
- tests: form 14, members 6, categories 7, detail nav 1; full suite green

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 01:23:02 +08:00

556 lines
19 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 测试(UI-03 §4 页面 09-10 重设计后)。
///
/// 覆盖:创建两分组结构(Event details / Description & languages)与默认
/// 语言必选、无 Access 页签;校验矩阵(默认语言标题缺失/未选开始时间/
/// endAt 早于 startAt);创建 POST 载荷形状;编辑 Details/Access 页签共享
/// 草稿(切页签不丢输入)、四开关与说明、原图下载受总下载约束(禁用 +
/// PATCH 联动关断)、PATCH 最小化、endAt 显式清除、标题修改仅发全量
/// translations;未保存退出确认;保存失败保留输入。
library;
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/features/albums/album_form.dart';
import 'package:photo_live/features/albums/models.dart';
import 'package:photo_live/l10n/app_localizations.dart';
import 'package:photo_live/pages/album_form_page.dart';
import '../helpers/fakes.dart';
void main() {
late FakeSessionStore store;
setUp(() {
store = FakeSessionStore();
});
ApiClient clientOf(FakeAdapter adapter) => ApiClient(
sessionStore: store,
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
..httpClientAdapter = adapter,
);
Future<void> pumpForm(
WidgetTester tester,
ApiClient client, {
Album? existing,
AlbumFormDraft? initialDraft,
}) async {
await tester.pumpWidget(
ProviderScope(
overrides: [apiClientProvider.overrideWith((ref) => client)],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: AlbumFormPage(existing: existing, initialDraft: initialDraft),
),
),
);
await tester.pumpAndSettle();
}
/// 创建模式合法草稿(测试注入开始/结束时间,避开平台日期选择器)。
AlbumFormDraft createDraft({
String zhTitle = '开幕晚宴',
String enTitle = 'Opening Gala',
String defaultLocale = 'zh-CN',
DateTime? startAt,
DateTime? endAt,
bool downloadEnabled = true,
bool originalDownloadEnabled = false,
}) =>
AlbumFormDraft(
zh: AlbumTranslationDraft(locale: 'zh-CN', title: zhTitle),
en: AlbumTranslationDraft(locale: 'en', title: enTitle),
defaultLocale: defaultLocale,
timezone: 'Asia/Shanghai',
startAt: startAt ?? DateTime.utc(2026, 9, 18, 6, 30),
endAt: endAt,
downloadEnabled: downloadEnabled,
originalDownloadEnabled: originalDownloadEnabled,
);
/// 表单长于测试视口(800x600):按钮常在折叠线下,tap 前先滚动可见。
Future<void> tapVisible(WidgetTester tester, Finder finder) async {
await tester.ensureVisible(finder);
await tester.pumpAndSettle();
await tester.tap(finder);
}
/// 切到 Access 页签(编辑模式顶栏 TabBar)。
Future<void> openAccessTab(WidgetTester tester) async {
await tester.tap(find.text('Access'));
await tester.pumpAndSettle();
}
testWidgets('页面 09 创建:两分组 + 默认语言必选 + 双语 tab + 无 Access 页签',
(tester) async {
final adapter = FakeAdapter((_) => throw StateError('不应发起请求'));
await pumpForm(tester, clientOf(adapter));
expect(find.text('Create album'), findsNWidgets(2), reason: '标题与底部按钮');
// 两个分组标题(SectionHeader)。
expect(find.text('Event details'), findsOneWidget);
expect(find.text('Description & languages'), findsOneWidget);
// 默认语言选择器(必选标记)+ 语言 tab(创建模式仅一个 TabBar)。
expect(find.text('Default language *'), findsOneWidget);
expect(find.byType(TabBar), findsOneWidget);
expect(find.descendant(of: find.byType(TabBar), matching: find.text('中文')),
findsOneWidget);
expect(
find.descendant(of: find.byType(TabBar), matching: find.text('English')),
findsOneWidget);
expect(find.byType(SegmentedButton<String>), findsOneWidget);
// 创建模式:无 Access 页签、无开关(契约缺省)。
expect(find.text('Access'), findsNothing);
expect(find.byType(SwitchListTile), findsNothing);
// 默认时区显式可见(不静默取设备时区)。
expect(find.text('Asia/Shanghai'), findsOneWidget);
});
testWidgets('页面 09 创建:空表单提交 → 汇总错误(默认语言标题/开始时间),不发请求',
(tester) async {
final adapter = FakeAdapter((_) => throw StateError('不应发起请求'));
await pumpForm(tester, clientOf(adapter));
await tapVisible(tester, find.widgetWithText(FilledButton, 'Create album'));
await tester.pump();
expect(find.text('Default locale must have a title'), findsOneWidget);
// 开始时间行占位与汇总错误复用同一文案 → 2 处。
expect(find.text('Pick a start time'), findsNWidgets(2));
expect(adapter.requests, isEmpty);
});
testWidgets('页面 09 创建:defaultLocale=en 而 en 标题空 → 默认语言覆盖错误',
(tester) async {
final adapter = FakeAdapter((_) => throw StateError('不应发起请求'));
await pumpForm(
tester,
clientOf(adapter),
initialDraft: createDraft(defaultLocale: 'en', enTitle: ''),
);
await tapVisible(tester, find.widgetWithText(FilledButton, 'Create album'));
await tester.pump();
expect(find.text('Default locale must have a title'), findsOneWidget);
expect(adapter.requests, isEmpty);
});
testWidgets('页面 09 创建:endAt 早于 startAt → 时间序错误', (tester) async {
final adapter = FakeAdapter((_) => throw StateError('不应发起请求'));
await pumpForm(
tester,
clientOf(adapter),
initialDraft: createDraft(
endAt: DateTime.utc(2026, 9, 18, 5, 0),
),
);
await tapVisible(tester, find.widgetWithText(FilledButton, 'Create album'));
await tester.pump();
expect(find.text('End time must be after start time'), findsOneWidget);
expect(adapter.requests, isEmpty);
});
testWidgets('页面 09 创建:合法提交 → POST 载荷形状(双语 translations/UTC 时间)',
(tester) async {
final adapter = FakeAdapter(
(o) => o.path == '/v1/albums'
? (201, albumJson())
: throw StateError('unexpected ${o.path}'),
);
await pumpForm(
tester,
clientOf(adapter),
initialDraft: createDraft(
endAt: DateTime.utc(2026, 9, 19, 10, 0),
),
);
await tapVisible(tester, find.widgetWithText(FilledButton, 'Create album'));
await tester.pumpAndSettle();
final request = adapter.requests.single;
expect(request.method, 'POST');
expect(request.path, '/v1/albums');
expect(request.body, <String, dynamic>{
'translations': <Map<String, dynamic>>[
<String, dynamic>{
'locale': 'zh-CN',
'title': '开幕晚宴',
'description': null,
'venue': null,
'address': null,
},
<String, dynamic>{
'locale': 'en',
'title': 'Opening Gala',
'description': null,
'venue': null,
'address': null,
},
],
'defaultLocale': 'zh-CN',
'startAt': '2026-09-18T06:30:00.000Z',
'endAt': '2026-09-19T10:00:00.000Z',
'timezone': 'Asia/Shanghai',
// 创建无 Access 页签:download/original 走契约缺省。
'downloadEnabled': true,
'originalDownloadEnabled': false,
});
});
testWidgets('页面 09 创建:保存失败 → 提示并保留输入(不弹回)', (tester) async {
final adapter = FakeAdapter(
(o) => o.path == '/v1/albums'
? (500, errorBody(ErrorCodes.internalError))
: throw StateError('unexpected ${o.path}'),
);
await pumpForm(tester, clientOf(adapter), initialDraft: createDraft());
await tapVisible(tester, find.widgetWithText(FilledButton, 'Create album'));
await tester.pumpAndSettle();
expect(find.byType(AlbumFormPage), findsOneWidget, reason: '失败不弹回');
expect(
find.text('Service temporarily unavailable. Please retry later'),
findsOneWidget,
);
// 输入保留。
expect(
tester.widget<TextFormField>(find.byType(TextFormField).at(1)).controller!
.text,
'开幕晚宴',
);
});
testWidgets('页面 10 编辑:Details/Access 页签 + 预填 + 无变更保存禁用',
(tester) async {
final adapter = FakeAdapter(
(o) => throw StateError('编辑预加载不应发起请求:${o.path}'),
);
final existing = Album.fromJson(
albumJson(
publicEnabled: false,
originalDownloadEnabled: true,
endAt: '2026-09-19T10:00:00Z',
),
);
await pumpForm(tester, clientOf(adapter), existing: existing);
// 顶栏 TabBar(Details/Access)+ 内容区语言 TabBar。
expect(find.text('Edit album'), findsOneWidget);
expect(find.text('Details'), findsOneWidget);
expect(find.text('Access'), findsOneWidget);
expect(find.byType(TabBar), findsNWidgets(2));
expect(find.text('Save changes'), findsOneWidget);
// 字段预填(zh tab;TextFormField 顺序 = 时区 → 标题(zh tab))。
final titleField = tester.widget<TextFormField>(
find.byType(TextFormField).at(1),
);
expect(titleField.controller!.text, '开幕晚宴');
// 时间回显按相册时区。
expect(find.text('2026/9/18 14:30'), findsOneWidget);
expect(find.text('2026/9/19 18:00'), findsOneWidget);
// 无任何变更 → 保存禁用(PATCH minProperties 1)。
expect(
tester.widget<FilledButton>(find.byType(FilledButton)).onPressed,
isNull,
);
});
testWidgets('页面 10 编辑:切页签共享草稿(不丢输入、不丢开关)', (tester) async {
final adapter = FakeAdapter(
(o) => throw StateError('不应发起请求:${o.path}'),
);
await pumpForm(
tester,
clientOf(adapter),
existing: Album.fromJson(albumJson()),
);
// Details 页签输入标题。
await tester.enterText(find.byType(TextFormField).at(1), '闭幕演出');
await tester.pump();
// 切 Access → 切回 Details:输入保留(共享草稿)。
await openAccessTab(tester);
expect(find.byType(SwitchListTile), findsNWidgets(4));
await tester.tap(find.text('Details'));
await tester.pumpAndSettle();
expect(
tester.widget<TextFormField>(find.byType(TextFormField).at(1)).controller!
.text,
'闭幕演出',
);
// 未保存 → 不发请求。
expect(adapter.requests, isEmpty);
});
testWidgets('页面 10 编辑:Access 四开关各配说明', (tester) async {
final adapter = FakeAdapter(
(o) => throw StateError('不应发起请求:${o.path}'),
);
await pumpForm(
tester,
clientOf(adapter),
existing: Album.fromJson(albumJson()),
);
await openAccessTab(tester);
expect(
find.widgetWithText(SwitchListTile, 'Allow uploads'),
findsOneWidget,
);
expect(
find.text('Allow staff to upload photos to this album'),
findsOneWidget,
);
expect(
find.widgetWithText(SwitchListTile, 'Public access'),
findsOneWidget,
);
expect(
find.text('Anyone with the link can view photos'),
findsOneWidget,
);
expect(
find.widgetWithText(SwitchListTile, 'Allow downloads'),
findsOneWidget,
);
expect(
find.text('Viewers can download browse-quality photos'),
findsOneWidget,
);
expect(
find.widgetWithText(SwitchListTile, 'Original downloads'),
findsOneWidget,
);
expect(
find.text('Viewers can download original photos'),
findsOneWidget,
);
// 开关初值与原相册一致。
expect(
tester
.widget<SwitchListTile>(
find.widgetWithText(SwitchListTile, 'Allow uploads'),
)
.value,
true,
);
expect(
tester
.widget<SwitchListTile>(
find.widgetWithText(SwitchListTile, 'Public access'),
)
.value,
true,
);
});
testWidgets('页面 10 编辑:原图下载受总下载约束(禁用 + PATCH 联动关断)',
(tester) async {
final adapter = FakeAdapter(
(o) => o.path == '/v1/albums/alb-1'
? (200, albumJson(downloadEnabled: false))
: throw StateError('unexpected ${o.path}'),
);
// 原相册:下载开、原图开。
final existing =
Album.fromJson(albumJson(originalDownloadEnabled: true));
await pumpForm(tester, clientOf(adapter), existing: existing);
await openAccessTab(tester);
// 关闭总下载 → 原图开关禁用且显示为关。
await tester.tap(find.widgetWithText(SwitchListTile, 'Allow downloads'));
await tester.pump();
final original = find.widgetWithText(SwitchListTile, 'Original downloads');
expect(tester.widget<SwitchListTile>(original).onChanged, isNull,
reason: '总下载关闭 → 原图开关禁用');
expect(tester.widget<SwitchListTile>(original).value, isFalse,
reason: '生效值恒为 false');
// 保存 → PATCH 联动关断原图(不允许「原图开而下载关」的矛盾态)。
await tester.tap(find.text('Save changes'));
await tester.pumpAndSettle();
expect(adapter.requests.single.body, <String, dynamic>{
'downloadEnabled': false,
'originalDownloadEnabled': false,
});
});
testWidgets('页面 10 编辑:仅拨动 originalDownloadEnabled → PATCH 单键载荷',
(tester) async {
final adapter = FakeAdapter(
(o) => o.path == '/v1/albums/alb-1'
? (200, albumJson(originalDownloadEnabled: true))
: throw StateError('unexpected ${o.path}'),
);
final existing = Album.fromJson(albumJson());
await pumpForm(tester, clientOf(adapter), existing: existing);
await openAccessTab(tester);
await tester
.tap(find.widgetWithText(SwitchListTile, 'Original downloads'));
await tester.pump();
await tester.ensureVisible(find.text('Save changes'));
await tester.pumpAndSettle();
await tester.tap(find.text('Save changes'));
await tester.pumpAndSettle();
final request = adapter.requests.single;
expect(request.method, 'PATCH');
expect(request.path, '/v1/albums/alb-1');
expect(request.body, <String, dynamic>{
'originalDownloadEnabled': true,
});
});
testWidgets('页面 10 编辑:清除 endAt → PATCH 序列化 "endAt": null', (tester) async {
final adapter = FakeAdapter(
(o) => o.path == '/v1/albums/alb-1'
? (200, albumJson(endAt: null))
: throw StateError('unexpected ${o.path}'),
);
final existing = Album.fromJson(
albumJson(endAt: '2026-09-19T10:00:00Z'),
);
await pumpForm(tester, clientOf(adapter), existing: existing);
await tapVisible(tester, find.byTooltip('Clear end time'));
await tester.pump();
await tapVisible(tester, find.text('Save changes'));
await tester.pumpAndSettle();
expect(adapter.requests.single.body, <String, dynamic>{'endAt': null});
});
testWidgets('页面 10 编辑:修改 zh 标题 → 仅发全量 translations(不含开关/时间)',
(tester) async {
final adapter = FakeAdapter(
(o) => o.path == '/v1/albums/alb-1'
? (200, albumJson())
: throw StateError('unexpected ${o.path}'),
);
final existing = Album.fromJson(albumJson());
await pumpForm(tester, clientOf(adapter), existing: existing);
await tester.enterText(
find.byType(TextFormField).at(1),
'闭幕演出',
);
await tester.pump();
await tapVisible(tester, find.text('Save changes'));
await tester.pumpAndSettle();
final body = adapter.requests.single.body as Map<String, dynamic>;
expect(body.keys, <String>['translations']);
expect(body['translations'], <Map<String, dynamic>>[
<String, dynamic>{
'locale': 'zh-CN',
'title': '闭幕演出',
'description': null,
'venue': '大宴会厅',
'address': null,
},
<String, dynamic>{
'locale': 'en',
'title': 'Opening Gala',
'description': null,
'venue': null,
'address': null,
},
]);
});
testWidgets('页面 10 编辑:未保存变更退出 → 放弃确认(继续编辑/放弃两路)',
(tester) async {
final adapter = FakeAdapter(
(o) => throw StateError('不应发起请求:${o.path}'),
);
final client = clientOf(adapter);
/// 以父路由承载(表单作为可弹出路由,AppBar 才有返回键)。
Future<void> pumpPushedForm() async {
await tester.pumpWidget(
ProviderScope(
overrides: [apiClientProvider.overrideWith((ref) => client)],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: Builder(
builder: (context) => Center(
child: TextButton(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => AlbumFormPage(
existing: Album.fromJson(albumJson()),
),
),
),
child: const Text('open form'),
),
),
),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('open form'));
await tester.pumpAndSettle();
}
await pumpPushedForm();
// 无变更:系统返回直接弹出(无确认框)。
await tester.pageBack();
await tester.pumpAndSettle();
expect(find.byType(AlbumFormPage), findsNothing);
// 重新进入并制造变更。
await pumpPushedForm();
await tester.enterText(find.byType(TextFormField).at(1), '闭幕演出');
await tester.pump();
await tester.pageBack();
await tester.pumpAndSettle();
expect(find.text('Discard changes?'), findsOneWidget);
expect(
find.text('Your edits are not saved yet. Leaving now will discard them.'),
findsOneWidget,
);
// 继续编辑 → 留在页面,输入保留。
await tester.tap(find.text('Keep editing'));
await tester.pumpAndSettle();
expect(find.byType(AlbumFormPage), findsOneWidget);
expect(
tester.widget<TextFormField>(find.byType(TextFormField).at(1)).controller!
.text,
'闭幕演出',
);
// 放弃 → 弹出页面。
await tester.pageBack();
await tester.pumpAndSettle();
await tester.tap(find.text('Discard'));
await tester.pumpAndSettle();
expect(find.byType(AlbumFormPage), findsNothing);
});
}