feat(mobile): album management pages with qr share

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-19 01:56:38 +08:00
parent 8be21b856b
commit bbea1dd364
27 changed files with 5485 additions and 28 deletions
@@ -0,0 +1,240 @@
/// 相册表单纯逻辑测试(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',
});
});
});
}
@@ -0,0 +1,50 @@
/// 相册时区格式化纯逻辑测试(P3 计划 Task 4)。
///
/// 覆盖:UTC 瞬间 → Asia/Shanghai 墙钟展示、无效时区回退 UTC、
/// 墙钟 → UTC 提交往返一致、IANA 时区名校验。
library;
import 'package:flutter_test/flutter_test.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:photo_live/features/albums/album_format.dart';
void main() {
setUpAll(initializeDateFormatting);
test('UTC 瞬间按 Asia/Shanghai 展示(intl 格式化)', () {
final utc = DateTime.utc(2026, 9, 18, 6, 30);
expect(formatAlbumDateTime(utc, 'Asia/Shanghai', 'en'), '2026/9/18 14:30');
});
test('非 Asia/Shanghai 时区亦按各自墙钟展示(America/New_York)', () {
final utc = DateTime.utc(2026, 9, 18, 6, 30);
// 9 月为纽约夏令时 UTC-4 → 02:30。
expect(
formatAlbumDateTime(utc, 'America/New_York', 'en'),
'2026/9/18 02:30',
);
});
test('无效时区回退 UTC 展示', () {
final utc = DateTime.utc(2026, 9, 18, 6, 30);
expect(formatAlbumDateTime(utc, 'Mars/Olympus', 'en'), '2026/9/18 06:30');
});
test('墙钟 → UTC 往返一致(表单提交与回显)', () {
final naive = DateTime(2026, 9, 18, 14, 30);
final utc = albumWallClockToUtc(naive, 'Asia/Shanghai');
expect(utc.toUtc(), DateTime.utc(2026, 9, 18, 6, 30));
final zoned = tryToAlbumZone(utc, 'Asia/Shanghai');
expect(zoned, isNotNull);
expect(zoned!.hour, 14);
expect(zoned.minute, 30);
});
test('isValidTimezone:空/非法拒绝,合法通过', () {
expect(isValidTimezone(''), isFalse);
expect(isValidTimezone(' '), isFalse);
expect(isValidTimezone('Not/AZone'), isFalse);
expect(isValidTimezone('Asia/Shanghai'), isTrue);
expect(isValidTimezone('UTC'), isTrue);
});
}