/// 相册表单纯逻辑测试(P3 计划 Task 4)。 /// /// 覆盖校验矩阵(默认语言覆盖/其他字段漏填标题/时间序/时区)与载荷 /// 构建:创建体形状、PATCH 最小化(无变更→null、单开关独立、endAt /// 显式清除序列化为 null、translations 全量替换且不带其他键)。 library; import 'package:flutter_test/flutter_test.dart'; import 'package:photo_live/features/albums/album_form.dart'; import 'package:photo_live/features/albums/models.dart'; import '../helpers/fakes.dart'; /// 与 albumJson 缺省值对齐的草稿(「原样」编辑基线)。 AlbumFormDraft draftOf({ String zhTitle = '开幕晚宴', String zhVenue = '大宴会厅', String enTitle = 'Opening Gala', String defaultLocale = 'zh-CN', String timezone = 'Asia/Shanghai', DateTime? startAt, DateTime? endAt, bool uploadEnabled = true, bool publicEnabled = true, bool downloadEnabled = true, bool originalDownloadEnabled = false, }) => AlbumFormDraft( zh: AlbumTranslationDraft( locale: 'zh-CN', title: zhTitle, venue: zhVenue, ), en: AlbumTranslationDraft(locale: 'en', title: enTitle), defaultLocale: defaultLocale, timezone: timezone, startAt: startAt ?? DateTime.utc(2026, 9, 18, 6, 30), endAt: endAt, uploadEnabled: uploadEnabled, publicEnabled: publicEnabled, downloadEnabled: downloadEnabled, originalDownloadEnabled: originalDownloadEnabled, ); void main() { group('validate(客户端预检矩阵)', () { test('默认语言标题为空 → defaultLocaleMissing;未选开始 → startAtRequired', () { // 直接构造(不经 draftOf:其 startAt 缺省会吞掉显式 null)。 final errors = AlbumFormDraft( zh: const AlbumTranslationDraft( locale: 'zh-CN', title: ' ', venue: '大宴会厅', ), en: const AlbumTranslationDraft(locale: 'en', title: 'Opening Gala'), defaultLocale: 'zh-CN', timezone: 'Asia/Shanghai', startAt: null, ).validate(); expect(errors, containsAll([ AlbumFormError.defaultLocaleMissing, AlbumFormError.startAtRequired, ])); }); test('非默认语言填了其他字段但没填标题 → titleRequiredWithFields', () { final errors = draftOf(enTitle: '') .validate() .where((e) => e == AlbumFormError.titleRequiredWithFields) .toList(); // en 无标题且无其他字段 → 条目整体跳过,不算错误。 expect(errors, isEmpty); final errors2 = AlbumFormDraft( zh: const AlbumTranslationDraft(locale: 'zh-CN', title: '标题'), en: const AlbumTranslationDraft(locale: 'en', venue: 'Main Hall'), defaultLocale: 'zh-CN', timezone: 'Asia/Shanghai', startAt: DateTime.utc(2026, 9, 18, 6, 30), ).validate(); expect(errors2, contains(AlbumFormError.titleRequiredWithFields)); expect(errors2, isNot(contains(AlbumFormError.defaultLocaleMissing))); }); test('endAt 必须严格晚于 startAt(相等/早于报错,null 放行)', () { final startAt = DateTime.utc(2026, 9, 18, 6, 30); expect( draftOf(startAt: startAt, endAt: startAt).validate(), contains(AlbumFormError.endAtNotAfterStartAt), ); expect( draftOf( startAt: startAt, endAt: startAt.subtract(const Duration(minutes: 1)), ).validate(), contains(AlbumFormError.endAtNotAfterStartAt), ); expect( draftOf(startAt: startAt, endAt: null).validate(), isNot(contains(AlbumFormError.endAtNotAfterStartAt)), ); }); test('时区校验:空白/非法 IANA 名报错,Asia/Shanghai 通过', () { expect( draftOf(timezone: '').validate(), contains(AlbumFormError.timezoneInvalid), ); expect( draftOf(timezone: 'Mars/Olympus').validate(), contains(AlbumFormError.timezoneInvalid), ); expect( draftOf(timezone: 'Asia/Shanghai').validate(), isNot(contains(AlbumFormError.timezoneInvalid)), ); }); }); group('toCreateRequest', () { test('载荷形状:trim、空字段归一 null、不含 upload/public', () { final req = draftOf( zhTitle: ' 开幕晚宴 ', zhVenue: ' ', enTitle: 'Opening Gala', downloadEnabled: false, originalDownloadEnabled: true, ).toCreateRequest(); final json = req.toJson(); expect(json['translations'], >[ { 'locale': 'zh-CN', 'title': '开幕晚宴', 'description': null, 'venue': null, 'address': null, }, { 'locale': 'en', 'title': 'Opening Gala', 'description': null, 'venue': null, 'address': null, }, ]); expect(json['defaultLocale'], 'zh-CN'); expect(json['startAt'], '2026-09-18T06:30:00.000Z'); expect(json['endAt'], isNull); expect(json['timezone'], 'Asia/Shanghai'); expect(json['downloadEnabled'], false); expect(json['originalDownloadEnabled'], true); // 契约:创建请求体不含 uploadEnabled/publicEnabled(服务端缺省 true)。 expect(json.containsKey('uploadEnabled'), isFalse); expect(json.containsKey('publicEnabled'), isFalse); }); }); group('toUpdatePatch(PATCH 最小化)', () { test('与原值完全一致 → null(不提交)', () { final original = Album.fromJson(albumJson()); expect(draftOf().toUpdatePatch(original), isNull); }); test('仅拨动 originalDownloadEnabled → 单键载荷(开关独立性)', () { final original = Album.fromJson(albumJson()); final patch = draftOf(originalDownloadEnabled: true).toUpdatePatch(original); expect(patch, isNotNull); expect(patch!.toJson(), { 'originalDownloadEnabled': true, }); }); test('endAt 显式清除 → 序列化为 "endAt": null', () { final original = Album.fromJson( albumJson(endAt: '2026-09-19T10:00:00Z'), ); // 用户清除 endAt 后:startAt/endAt 为原开始时间与 null。 final patch = AlbumFormDraft( zh: AlbumTranslationDraft( locale: 'zh-CN', title: '开幕晚宴', venue: '大宴会厅', ), en: const AlbumTranslationDraft(locale: 'en', title: 'Opening Gala'), defaultLocale: 'zh-CN', timezone: 'Asia/Shanghai', startAt: original.startAt, endAt: null, ).toUpdatePatch(original); expect(patch, isNotNull); expect(patch!.toJson(), {'endAt': null}); }); test('仅修改标题 → translations 全量替换且不含其他键', () { final original = Album.fromJson(albumJson()); final patch = draftOf(zhTitle: '闭幕演出').toUpdatePatch(original); expect(patch, isNotNull); final json = patch!.toJson(); expect(json.keys, ['translations']); expect(json['translations'], >[ { 'locale': 'zh-CN', 'title': '闭幕演出', 'description': null, 'venue': '大宴会厅', 'address': null, }, { 'locale': 'en', 'title': 'Opening Gala', 'description': null, 'venue': null, 'address': null, }, ]); }); test('切换 defaultLocale(内容未变)→ 仅 defaultLocale 单键', () { final original = Album.fromJson(albumJson()); final patch = draftOf(defaultLocale: 'en').toUpdatePatch(original); expect(patch, isNotNull); final json = patch!.toJson(); // 最小化:translations 未变则不重发(服务端按存量校验 en 覆盖, // 客户端 validate 已先行拦截 en 标题缺失的切换)。 expect(json, {'defaultLocale': 'en'}); }); test('修改开始时间 → 仅 startAt 键', () { final original = Album.fromJson(albumJson()); final patch = draftOf( startAt: DateTime.utc(2026, 9, 18, 8, 0), ).toUpdatePatch(original); expect(patch!.toJson(), { 'startAt': '2026-09-18T08:00:00.000Z', }); }); }); }