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',
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
}
|