/// 相册创建/编辑表单 widget 测试(P3 计划 Task 4)。 /// /// 覆盖:校验矩阵(默认语言标题缺失/未选开始时间/endAt 早于 startAt)、 /// 创建成功 POST 载荷形状、编辑预加载(字段与四开关初值、创建仅两开关)、 /// 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/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); } testWidgets('创建:空表单提交 → 汇总错误(默认语言标题/开始时间),不发请求', (tester) async { final adapter = FakeAdapter((_) => throw StateError('不应发起请求')); await pumpForm(tester, clientOf(adapter)); // 创建模式仅两开关(download/original)。 expect(find.byType(SwitchListTile), findsNWidgets(2)); await tapVisible(tester, find.text('Create')); 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('创建:defaultLocale=en 而 en 标题空 → 默认语言覆盖错误', (tester) async { final adapter = FakeAdapter((_) => throw StateError('不应发起请求')); await pumpForm( tester, clientOf(adapter), initialDraft: createDraft(defaultLocale: 'en', enTitle: ''), ); await tapVisible(tester, find.text('Create')); await tester.pump(); expect(find.text('Default locale must have a title'), findsOneWidget); expect(adapter.requests, isEmpty); }); testWidgets('创建: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.text('Create')); await tester.pump(); expect(find.text('End time must be after start time'), findsOneWidget); expect(adapter.requests, isEmpty); }); testWidgets('创建:合法提交 → 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), downloadEnabled: false, originalDownloadEnabled: true, ), ); await tapVisible(tester, find.text('Create')); 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', 'downloadEnabled': false, 'originalDownloadEnabled': true, }); }); testWidgets('编辑:预加载既有值(字段、四开关独立初值、无变更时保存禁用)', (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); // 编辑模式四开关。 expect(find.byType(SwitchListTile), findsNWidgets(4)); // 字段预填(当前 zh tab;title 为第一个文本字段)。 final titleField = tester.widget( find.byType(TextFormField).first, ); expect(titleField.controller!.text, '开幕晚宴'); // 开关初值与原相册一致。 expect( tester .widget( find.widgetWithText(SwitchListTile, 'Allow uploads'), ) .value, true, ); expect( tester .widget( find.widgetWithText(SwitchListTile, 'Publicly visible'), ) .value, false, ); expect( tester .widget( find.widgetWithText(SwitchListTile, 'Allow original downloads'), ) .value, true, ); // 时间回显按相册时区。 expect(find.text('2026/9/18 14:30'), findsOneWidget); expect(find.text('2026/9/19 18:00'), findsOneWidget); // 无任何变更 → 保存禁用(PATCH minProperties 1)。 expect( tester.widget(find.byType(FilledButton)).onPressed, isNull, ); }); testWidgets('编辑:仅拨动 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 tapVisible( tester, find.widgetWithText(SwitchListTile, 'Allow original downloads'), ); await tester.pump(); await tapVisible(tester, find.text('Save')); 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('编辑:清除 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')); await tester.pumpAndSettle(); expect(adapter.requests.single.body, {'endAt': null}); }); testWidgets('编辑:修改 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).first, '闭幕演出', ); await tester.pump(); await tapVisible(tester, find.text('Save')); 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, }, ]); }); }