Files
photo-live/apps/mobile/test/pages/album_form_page_test.dart
T
浅念 e44b35db28 feat(albums): date-only pickers for start/end (create & edit, admin + app)
- 管理后台:DatePicker type=datetime → date;提交归一(开始=当天 00:00、
  结束=当天 23:59 整天活动语义;契约仍 datetime 仅交互粒度)
- App:去掉时间选择步骤(选日期即定;同上归一);表单显示只显 yyyy/M/d
- e2e 日期填入改 '2026-10-01'(date 格式);widget 断言日期粒度
- admin 87/87 + build、app 397/397 全绿

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 14:13:58 +08:00

567 lines
20 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);
adapter.requests.clear();
// 顶栏 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'), findsOneWidget); // UI06:日期粒度显示
expect(find.text('2026/9/19'), findsOneWidget); // UI06:日期粒度显示
// 无任何变更 → 保存禁用(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()),
);
adapter.requests.clear();
// UI06:banner 现状预检(GET /assets)已记录——清空后断言只看用户动作。
adapter.requests.clear();
// 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()),
);
adapter.requests.clear();
// UI06:banner 现状预检(GET /assets)已记录——清空后断言只看用户动作。
adapter.requests.clear();
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);
adapter.requests.clear();
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);
adapter.requests.clear();
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);
adapter.requests.clear();
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);
adapter.requests.clear();
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);
});
}