bbea1dd364
Co-Authored-By: Claude Code <noreply@anthropic.com>
241 lines
8.4 KiB
Dart
241 lines
8.4 KiB
Dart
/// 相册表单纯逻辑测试(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>[
|
||
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'], <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,
|
||
},
|
||
]);
|
||
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(), <String, dynamic>{
|
||
'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(), <String, dynamic>{'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, <String>['translations']);
|
||
expect(json['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,
|
||
},
|
||
]);
|
||
});
|
||
|
||
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, <String, dynamic>{'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(), <String, dynamic>{
|
||
'startAt': '2026-09-18T08:00:00.000Z',
|
||
});
|
||
});
|
||
});
|
||
}
|