Files
photo-live/apps/mobile/test/features/album_form_test.dart
T

241 lines
8.4 KiB
Dart
Raw Normal View History

/// 相册表单纯逻辑测试(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',
});
});
});
}