/// 相册创建/编辑表单 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 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 tapVisible(WidgetTester tester, Finder finder) async { await tester.ensureVisible(finder); await tester.pumpAndSettle(); await tester.tap(finder); } /// 切到 Access 页签(编辑模式顶栏 TabBar)。 Future 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), 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, { 'translations': >[ { 'locale': 'zh-CN', 'title': '开幕晚宴', 'description': null, 'venue': null, 'address': null, }, { '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(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( 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(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(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( find.widgetWithText(SwitchListTile, 'Allow uploads'), ) .value, true, ); expect( tester .widget( 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(original).onChanged, isNull, reason: '总下载关闭 → 原图开关禁用'); expect(tester.widget(original).value, isFalse, reason: '生效值恒为 false'); // 保存 → PATCH 联动关断原图(不允许「原图开而下载关」的矛盾态)。 await tester.tap(find.text('Save changes')); await tester.pumpAndSettle(); expect(adapter.requests.single.body, { '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, { '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, {'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; expect(body.keys, ['translations']); expect(body['translations'], >[ { 'locale': 'zh-CN', 'title': '闭幕演出', 'description': null, 'venue': '大宴会厅', 'address': null, }, { '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 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( 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(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); }); }