feat(mobile): album management pages with qr share
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -30,12 +30,16 @@ class RecordedRequest {
|
||||
const RecordedRequest({
|
||||
required this.method,
|
||||
required this.path,
|
||||
required this.uri,
|
||||
required this.headers,
|
||||
required this.body,
|
||||
});
|
||||
|
||||
final String method;
|
||||
final String path;
|
||||
|
||||
/// 完整 URI(含 query,游标/过滤参数断言用)。
|
||||
final Uri uri;
|
||||
final Map<String, dynamic> headers;
|
||||
final Object? body;
|
||||
}
|
||||
@@ -61,6 +65,7 @@ class FakeAdapter implements HttpClientAdapter {
|
||||
RecordedRequest(
|
||||
method: options.method,
|
||||
path: options.path,
|
||||
uri: options.uri,
|
||||
headers: Map<String, dynamic>.from(options.headers),
|
||||
body: options.data,
|
||||
),
|
||||
@@ -98,3 +103,144 @@ Map<String, dynamic> errorBody(String code) => <String, dynamic>{
|
||||
'message': 'stub message',
|
||||
'requestId': 'req-stub',
|
||||
};
|
||||
|
||||
// ── 相册域 fixtures(P3 计划 Task 4;形状对齐 openapi.yaml schemas)──
|
||||
|
||||
/// 契约 `Album` 响应体(GET/POST /albums、PATCH /albums/{id})。
|
||||
Map<String, dynamic> albumJson({
|
||||
String id = 'alb-1',
|
||||
String publicToken = 'sharetoken000000000001',
|
||||
String ownerId = 'u-owner',
|
||||
String status = 'active',
|
||||
String defaultLocale = 'zh-CN',
|
||||
String timezone = 'Asia/Shanghai',
|
||||
String startAt = '2026-09-18T06:30:00Z', // 上海墙钟 14:30
|
||||
String? endAt,
|
||||
bool uploadEnabled = true,
|
||||
bool publicEnabled = true,
|
||||
bool downloadEnabled = true,
|
||||
bool originalDownloadEnabled = false,
|
||||
List<Map<String, dynamic>>? translations,
|
||||
List<Map<String, dynamic>> categories = const <Map<String, dynamic>>[],
|
||||
}) =>
|
||||
<String, dynamic>{
|
||||
'id': id,
|
||||
'publicToken': publicToken,
|
||||
'ownerId': ownerId,
|
||||
'status': status,
|
||||
'defaultLocale': defaultLocale,
|
||||
'timezone': timezone,
|
||||
'startAt': startAt,
|
||||
'endAt': endAt,
|
||||
'uploadEnabled': uploadEnabled,
|
||||
'publicEnabled': publicEnabled,
|
||||
'downloadEnabled': downloadEnabled,
|
||||
'originalDownloadEnabled': originalDownloadEnabled,
|
||||
'watermarkVersion': 0,
|
||||
'translations': 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,
|
||||
},
|
||||
],
|
||||
'categories': categories,
|
||||
'createdAt': '2026-09-01T00:00:00Z',
|
||||
'updatedAt': '2026-09-01T00:00:00Z',
|
||||
'statsSyncedAt': null,
|
||||
};
|
||||
|
||||
/// 契约 `Category` 响应体。
|
||||
Map<String, dynamic> categoryJson({
|
||||
String id = 'cat-1',
|
||||
String albumId = 'alb-1',
|
||||
int sortOrder = 0,
|
||||
List<Map<String, dynamic>>? translations,
|
||||
}) =>
|
||||
<String, dynamic>{
|
||||
'id': id,
|
||||
'albumId': albumId,
|
||||
'sortOrder': sortOrder,
|
||||
'translations': translations ??
|
||||
<Map<String, dynamic>>[
|
||||
<String, dynamic>{'locale': 'zh-CN', 'name': '仪式'},
|
||||
<String, dynamic>{'locale': 'en', 'name': 'Ceremony'},
|
||||
],
|
||||
};
|
||||
|
||||
/// 契约 `Member` 响应体。
|
||||
Map<String, dynamic> memberJson({
|
||||
String userId = 'u-2',
|
||||
String account = 'staff02',
|
||||
String displayName = 'Staff Two',
|
||||
String role = 'collaborator',
|
||||
}) =>
|
||||
<String, dynamic>{
|
||||
'userId': userId,
|
||||
'account': account,
|
||||
'displayName': displayName,
|
||||
'role': role,
|
||||
'status': 'active',
|
||||
};
|
||||
|
||||
/// 契约 `AdminUserLookupItem` 响应体。
|
||||
Map<String, dynamic> lookupUserJson({
|
||||
String id = 'u-9',
|
||||
String account = 'staff09',
|
||||
String displayName = 'Staff Nine',
|
||||
}) =>
|
||||
<String, dynamic>{
|
||||
'id': id,
|
||||
'account': account,
|
||||
'displayName': displayName,
|
||||
};
|
||||
|
||||
/// 契约 `AdminPhoto` 响应体。
|
||||
Map<String, dynamic> adminPhotoJson({
|
||||
String photoId = 'ph-00000001',
|
||||
String albumId = 'alb-1',
|
||||
String visibility = 'published',
|
||||
String processingState = 'ready',
|
||||
int bytes = 123456,
|
||||
}) =>
|
||||
<String, dynamic>{
|
||||
'photoId': photoId,
|
||||
'albumId': albumId,
|
||||
'categoryId': null,
|
||||
'uploaderId': 'u-2',
|
||||
'visibility': visibility,
|
||||
'processingState': processingState,
|
||||
'bytes': bytes,
|
||||
'width': 4000,
|
||||
'height': 3000,
|
||||
'shotAt': null,
|
||||
'uploadedAt': '2026-09-18T07:00:00Z',
|
||||
'publishedAt': '2026-09-18T07:05:00Z',
|
||||
'likeCount': 0,
|
||||
'uploader': <String, dynamic>{
|
||||
'userId': 'u-2',
|
||||
'account': 'staff02',
|
||||
'displayName': 'Staff Two',
|
||||
},
|
||||
};
|
||||
|
||||
/// 契约游标分页响应体(hasMore 与 nextCursor 成对)。
|
||||
Map<String, dynamic> cursorPageBody(
|
||||
List<Map<String, dynamic>> items, {
|
||||
String? nextCursor,
|
||||
}) =>
|
||||
<String, dynamic>{
|
||||
'items': items,
|
||||
'nextCursor': nextCursor,
|
||||
'hasMore': nextCursor != null,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
/// 相册详情页 widget 测试(P3 计划 Task 4)。
|
||||
///
|
||||
/// 覆盖:详情信息区(标题/状态/时间时区/令牌/开关标签)与动作行;
|
||||
/// 分类管理三流(新增对话框载荷、名称空校验拦截、编辑 PATCH、删除确认)。
|
||||
library;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:photo_live/core/api_client.dart';
|
||||
import 'package:photo_live/l10n/app_localizations.dart';
|
||||
import 'package:photo_live/pages/album_detail_page.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
|
||||
void main() {
|
||||
late FakeSessionStore store;
|
||||
late List<Map<String, dynamic>> categories;
|
||||
|
||||
setUp(() {
|
||||
store = FakeSessionStore();
|
||||
categories = <Map<String, dynamic>>[
|
||||
categoryJson(id: 'cat-1', sortOrder: 0),
|
||||
];
|
||||
});
|
||||
|
||||
/// 路由:GET 详情(反映可变分类表)/ 分类 CRUD;其余路径直接抛错。
|
||||
FakeAdapter detailAdapter({
|
||||
(int, Object?)? Function(String method, String path)? onCategoryMutation,
|
||||
}) =>
|
||||
FakeAdapter((o) {
|
||||
if (o.path == '/v1/albums/alb-1' && o.method == 'GET') {
|
||||
return (200, albumJson(categories: categories));
|
||||
}
|
||||
if (o.path == '/v1/albums/alb-1/categories' && o.method == 'POST') {
|
||||
return onCategoryMutation?.call('POST', o.path) ?? (201, categoryJson(id: 'cat-new'));
|
||||
}
|
||||
if (o.path.startsWith('/v1/categories/') && o.method == 'PATCH') {
|
||||
return onCategoryMutation?.call('PATCH', o.path) ??
|
||||
(200, categoryJson(id: 'cat-1'));
|
||||
}
|
||||
if (o.path.startsWith('/v1/categories/') && o.method == 'DELETE') {
|
||||
return onCategoryMutation?.call('DELETE', o.path) ?? (204, null);
|
||||
}
|
||||
throw StateError('unexpected ${o.method} ${o.path}');
|
||||
});
|
||||
|
||||
ApiClient clientOf(FakeAdapter adapter) => ApiClient(
|
||||
sessionStore: store,
|
||||
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
|
||||
..httpClientAdapter = adapter,
|
||||
);
|
||||
|
||||
Future<void> pumpDetail(WidgetTester tester, ApiClient client) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [apiClientProvider.overrideWith((ref) => client)],
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const AlbumDetailPage(albumId: 'alb-1'),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('加载详情 → 信息区 + 动作行 + 分类列表', (tester) async {
|
||||
final adapter = detailAdapter();
|
||||
await pumpDetail(tester, clientOf(adapter));
|
||||
|
||||
// 信息区(标题同时出现在 AppBar 与信息卡)。
|
||||
expect(find.text('开幕晚宴'), findsNWidgets(2));
|
||||
expect(find.text('Active'), findsOneWidget);
|
||||
expect(
|
||||
find.text('2026/9/18 14:30 ~ Not set (Asia/Shanghai)'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('sharetoken000000000001'), findsOneWidget);
|
||||
expect(find.text('大宴会厅'), findsOneWidget);
|
||||
expect(find.text('Upload'), findsOneWidget);
|
||||
expect(find.text('Original'), findsOneWidget);
|
||||
// 动作行(5 项)。
|
||||
expect(find.text('Edit'), findsOneWidget);
|
||||
expect(find.text('Members'), findsOneWidget);
|
||||
// 「Categories」同时出现在动作按钮与分区标题。
|
||||
expect(find.text('Categories'), findsNWidgets(2));
|
||||
expect(find.text('Share QR code'), findsOneWidget);
|
||||
expect(find.text('Photos'), findsOneWidget);
|
||||
// 分类条目(zh · en 名称连接)。
|
||||
expect(find.text('仪式 · Ceremony'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('新增分类:填名称与排序 → POST 载荷 → 列表刷新', (tester) async {
|
||||
var posted = false;
|
||||
final adapter = detailAdapter(
|
||||
onCategoryMutation: (method, path) {
|
||||
if (method == 'POST') {
|
||||
posted = true;
|
||||
categories = <Map<String, dynamic>>[
|
||||
...categories,
|
||||
categoryJson(
|
||||
id: 'cat-new',
|
||||
sortOrder: 5,
|
||||
translations: <Map<String, dynamic>>[
|
||||
<String, dynamic>{'locale': 'zh-CN', 'name': '签到'},
|
||||
<String, dynamic>{'locale': 'en', 'name': 'Check-in'},
|
||||
],
|
||||
),
|
||||
];
|
||||
return (201, categoryJson(id: 'cat-new', sortOrder: 5));
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
await pumpDetail(tester, clientOf(adapter));
|
||||
|
||||
await tester.tap(find.byIcon(Icons.add_circle_outline));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, 'Chinese name'),
|
||||
'签到',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, 'English name'),
|
||||
'Check-in',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, 'Sort order'),
|
||||
'5',
|
||||
);
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(posted, isTrue);
|
||||
// 成功后详情重载会追加 GET,故按方法+路径取 POST。
|
||||
final request = adapter.requests.singleWhere(
|
||||
(r) => r.method == 'POST' && r.path == '/v1/albums/alb-1/categories',
|
||||
);
|
||||
expect(request.path, '/v1/albums/alb-1/categories');
|
||||
expect(request.body, <String, dynamic>{
|
||||
'sortOrder': 5,
|
||||
'translations': <Map<String, dynamic>>[
|
||||
<String, dynamic>{'locale': 'zh-CN', 'name': '签到'},
|
||||
<String, dynamic>{'locale': 'en', 'name': 'Check-in'},
|
||||
],
|
||||
});
|
||||
// 详情重载后列表含新分类。
|
||||
expect(find.text('签到 · Check-in'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('新增分类:名称全空 → 客户端拦截,不发请求', (tester) async {
|
||||
final adapter = detailAdapter();
|
||||
await pumpDetail(tester, clientOf(adapter));
|
||||
|
||||
final requestsBefore = adapter.requests.length;
|
||||
await tester.tap(find.byIcon(Icons.add_circle_outline));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.pump();
|
||||
|
||||
expect(
|
||||
find.text('At least one language name is required'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(adapter.requests, hasLength(requestsBefore));
|
||||
});
|
||||
|
||||
testWidgets('编辑分类:预填并改名 → PATCH 全量 translations + sortOrder',
|
||||
(tester) async {
|
||||
var patched = false;
|
||||
final adapter = detailAdapter(
|
||||
onCategoryMutation: (method, path) {
|
||||
if (method == 'PATCH') {
|
||||
patched = true;
|
||||
categories = <Map<String, dynamic>>[
|
||||
categoryJson(
|
||||
id: 'cat-1',
|
||||
sortOrder: 2,
|
||||
translations: <Map<String, dynamic>>[
|
||||
<String, dynamic>{'locale': 'zh-CN', 'name': '签到墙'},
|
||||
<String, dynamic>{'locale': 'en', 'name': 'Ceremony'},
|
||||
],
|
||||
),
|
||||
];
|
||||
return (200, categoryJson(id: 'cat-1'));
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
await pumpDetail(tester, clientOf(adapter));
|
||||
|
||||
await tester.tap(find.text('仪式 · Ceremony'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 预填检查。
|
||||
final zhField = tester.widget<TextFormField>(
|
||||
find.widgetWithText(TextFormField, 'Chinese name'),
|
||||
);
|
||||
expect(zhField.controller!.text, '仪式');
|
||||
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, 'Chinese name'),
|
||||
'签到墙',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, 'Sort order'),
|
||||
'2',
|
||||
);
|
||||
await tester.tap(find.text('Save'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(patched, isTrue);
|
||||
// 成功后详情重载会追加 GET,故按方法+路径取 PATCH。
|
||||
final request = adapter.requests.singleWhere(
|
||||
(r) => r.method == 'PATCH' && r.path == '/v1/categories/cat-1',
|
||||
);
|
||||
expect(request.body, <String, dynamic>{
|
||||
'sortOrder': 2,
|
||||
'translations': <Map<String, dynamic>>[
|
||||
<String, dynamic>{'locale': 'zh-CN', 'name': '签到墙'},
|
||||
<String, dynamic>{'locale': 'en', 'name': 'Ceremony'},
|
||||
],
|
||||
});
|
||||
expect(find.text('签到墙 · Ceremony'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('删除分类:确认对话框 → DELETE → 列表移除', (tester) async {
|
||||
final adapter = detailAdapter(
|
||||
onCategoryMutation: (method, path) {
|
||||
if (method == 'DELETE') {
|
||||
categories = <Map<String, dynamic>>[];
|
||||
return (204, null);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
await pumpDetail(tester, clientOf(adapter));
|
||||
|
||||
await tester.tap(find.byIcon(Icons.delete_outline));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text('Delete this category? Its photos will become uncategorized.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
// 取消可用:先取消再重删,验证确认门。
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('仪式 · Ceremony'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.delete_outline));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
adapter.requests.any(
|
||||
(r) => r.method == 'DELETE' && r.path == '/v1/categories/cat-1',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(find.text('仪式 · Ceremony'), findsNothing);
|
||||
expect(find.text('No categories yet. Tap + to add one'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
/// 相册创建/编辑表单 widget 测试(P3 计划 Task 4)。
|
||||
///
|
||||
/// 覆盖:校验矩阵(默认语言标题缺失/未选开始时间/endAt 早于 startAt)、
|
||||
/// 创建成功 POST 载荷形状、编辑预加载(字段与四开关初值、创建仅两开关)、
|
||||
/// PATCH 最小化(仅拨动的开关出现在请求体)、endAt 显式清除、
|
||||
/// 标题修改仅发全量 translations。
|
||||
library;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:photo_live/core/api_client.dart';
|
||||
import 'package:photo_live/features/albums/album_form.dart';
|
||||
import 'package:photo_live/features/albums/models.dart';
|
||||
import 'package:photo_live/l10n/app_localizations.dart';
|
||||
import 'package:photo_live/pages/album_form_page.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
|
||||
void main() {
|
||||
late FakeSessionStore store;
|
||||
|
||||
setUp(() {
|
||||
store = FakeSessionStore();
|
||||
});
|
||||
|
||||
ApiClient clientOf(FakeAdapter adapter) => ApiClient(
|
||||
sessionStore: store,
|
||||
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
|
||||
..httpClientAdapter = adapter,
|
||||
);
|
||||
|
||||
Future<void> pumpForm(
|
||||
WidgetTester tester,
|
||||
ApiClient client, {
|
||||
Album? existing,
|
||||
AlbumFormDraft? initialDraft,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [apiClientProvider.overrideWith((ref) => client)],
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: AlbumFormPage(existing: existing, initialDraft: initialDraft),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// 创建模式合法草稿(测试注入开始/结束时间,避开平台日期选择器)。
|
||||
AlbumFormDraft createDraft({
|
||||
String zhTitle = '开幕晚宴',
|
||||
String enTitle = 'Opening Gala',
|
||||
String defaultLocale = 'zh-CN',
|
||||
DateTime? startAt,
|
||||
DateTime? endAt,
|
||||
bool downloadEnabled = true,
|
||||
bool originalDownloadEnabled = false,
|
||||
}) =>
|
||||
AlbumFormDraft(
|
||||
zh: AlbumTranslationDraft(locale: 'zh-CN', title: zhTitle),
|
||||
en: AlbumTranslationDraft(locale: 'en', title: enTitle),
|
||||
defaultLocale: defaultLocale,
|
||||
timezone: 'Asia/Shanghai',
|
||||
startAt: startAt ?? DateTime.utc(2026, 9, 18, 6, 30),
|
||||
endAt: endAt,
|
||||
downloadEnabled: downloadEnabled,
|
||||
originalDownloadEnabled: originalDownloadEnabled,
|
||||
);
|
||||
|
||||
/// 表单长于测试视口(800x600):按钮常在折叠线下,tap 前先滚动可见。
|
||||
Future<void> tapVisible(WidgetTester tester, Finder finder) async {
|
||||
await tester.ensureVisible(finder);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(finder);
|
||||
}
|
||||
|
||||
testWidgets('创建:空表单提交 → 汇总错误(默认语言标题/开始时间),不发请求',
|
||||
(tester) async {
|
||||
final adapter = FakeAdapter((_) => throw StateError('不应发起请求'));
|
||||
await pumpForm(tester, clientOf(adapter));
|
||||
|
||||
// 创建模式仅两开关(download/original)。
|
||||
expect(find.byType(SwitchListTile), findsNWidgets(2));
|
||||
|
||||
await tapVisible(tester, find.text('Create'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Default locale must have a title'), findsOneWidget);
|
||||
// 开始时间行占位与汇总错误复用同一文案 → 2 处。
|
||||
expect(find.text('Pick a start time'), findsNWidgets(2));
|
||||
expect(adapter.requests, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('创建:defaultLocale=en 而 en 标题空 → 默认语言覆盖错误',
|
||||
(tester) async {
|
||||
final adapter = FakeAdapter((_) => throw StateError('不应发起请求'));
|
||||
await pumpForm(
|
||||
tester,
|
||||
clientOf(adapter),
|
||||
initialDraft: createDraft(defaultLocale: 'en', enTitle: ''),
|
||||
);
|
||||
|
||||
await tapVisible(tester, find.text('Create'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Default locale must have a title'), findsOneWidget);
|
||||
expect(adapter.requests, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('创建:endAt 早于 startAt → 时间序错误', (tester) async {
|
||||
final adapter = FakeAdapter((_) => throw StateError('不应发起请求'));
|
||||
await pumpForm(
|
||||
tester,
|
||||
clientOf(adapter),
|
||||
initialDraft: createDraft(
|
||||
endAt: DateTime.utc(2026, 9, 18, 5, 0),
|
||||
),
|
||||
);
|
||||
|
||||
await tapVisible(tester, find.text('Create'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('End time must be after start time'), findsOneWidget);
|
||||
expect(adapter.requests, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('创建:合法提交 → POST 载荷形状(双语 translations/UTC 时间/开关)',
|
||||
(tester) async {
|
||||
final adapter = FakeAdapter(
|
||||
(o) => o.path == '/v1/albums'
|
||||
? (201, albumJson())
|
||||
: throw StateError('unexpected ${o.path}'),
|
||||
);
|
||||
await pumpForm(
|
||||
tester,
|
||||
clientOf(adapter),
|
||||
initialDraft: createDraft(
|
||||
endAt: DateTime.utc(2026, 9, 19, 10, 0),
|
||||
downloadEnabled: false,
|
||||
originalDownloadEnabled: true,
|
||||
),
|
||||
);
|
||||
|
||||
await tapVisible(tester, find.text('Create'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final request = adapter.requests.single;
|
||||
expect(request.method, 'POST');
|
||||
expect(request.path, '/v1/albums');
|
||||
expect(request.body, <String, dynamic>{
|
||||
'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,
|
||||
},
|
||||
],
|
||||
'defaultLocale': 'zh-CN',
|
||||
'startAt': '2026-09-18T06:30:00.000Z',
|
||||
'endAt': '2026-09-19T10:00:00.000Z',
|
||||
'timezone': 'Asia/Shanghai',
|
||||
'downloadEnabled': false,
|
||||
'originalDownloadEnabled': true,
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('编辑:预加载既有值(字段、四开关独立初值、无变更时保存禁用)',
|
||||
(tester) async {
|
||||
final adapter = FakeAdapter(
|
||||
(o) => throw StateError('编辑预加载不应发起请求:${o.path}'),
|
||||
);
|
||||
final existing = Album.fromJson(
|
||||
albumJson(
|
||||
publicEnabled: false,
|
||||
originalDownloadEnabled: true,
|
||||
endAt: '2026-09-19T10:00:00Z',
|
||||
),
|
||||
);
|
||||
await pumpForm(tester, clientOf(adapter), existing: existing);
|
||||
|
||||
// 编辑模式四开关。
|
||||
expect(find.byType(SwitchListTile), findsNWidgets(4));
|
||||
// 字段预填(当前 zh tab;title 为第一个文本字段)。
|
||||
final titleField = tester.widget<TextFormField>(
|
||||
find.byType(TextFormField).first,
|
||||
);
|
||||
expect(titleField.controller!.text, '开幕晚宴');
|
||||
// 开关初值与原相册一致。
|
||||
expect(
|
||||
tester
|
||||
.widget<SwitchListTile>(
|
||||
find.widgetWithText(SwitchListTile, 'Allow uploads'),
|
||||
)
|
||||
.value,
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<SwitchListTile>(
|
||||
find.widgetWithText(SwitchListTile, 'Publicly visible'),
|
||||
)
|
||||
.value,
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<SwitchListTile>(
|
||||
find.widgetWithText(SwitchListTile, 'Allow original downloads'),
|
||||
)
|
||||
.value,
|
||||
true,
|
||||
);
|
||||
// 时间回显按相册时区。
|
||||
expect(find.text('2026/9/18 14:30'), findsOneWidget);
|
||||
expect(find.text('2026/9/19 18:00'), findsOneWidget);
|
||||
// 无任何变更 → 保存禁用(PATCH minProperties 1)。
|
||||
expect(
|
||||
tester.widget<FilledButton>(find.byType(FilledButton)).onPressed,
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('编辑:仅拨动 originalDownloadEnabled → PATCH 单键载荷',
|
||||
(tester) async {
|
||||
final adapter = FakeAdapter(
|
||||
(o) => o.path == '/v1/albums/alb-1'
|
||||
? (200, albumJson(originalDownloadEnabled: true))
|
||||
: throw StateError('unexpected ${o.path}'),
|
||||
);
|
||||
final existing = Album.fromJson(albumJson());
|
||||
await pumpForm(tester, clientOf(adapter), existing: existing);
|
||||
|
||||
await tapVisible(
|
||||
tester,
|
||||
find.widgetWithText(SwitchListTile, 'Allow original downloads'),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await tapVisible(tester, find.text('Save'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final request = adapter.requests.single;
|
||||
expect(request.method, 'PATCH');
|
||||
expect(request.path, '/v1/albums/alb-1');
|
||||
expect(request.body, <String, dynamic>{
|
||||
'originalDownloadEnabled': true,
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('编辑:清除 endAt → PATCH 序列化 "endAt": null', (tester) async {
|
||||
final adapter = FakeAdapter(
|
||||
(o) => o.path == '/v1/albums/alb-1'
|
||||
? (200, albumJson(endAt: null))
|
||||
: throw StateError('unexpected ${o.path}'),
|
||||
);
|
||||
final existing = Album.fromJson(
|
||||
albumJson(endAt: '2026-09-19T10:00:00Z'),
|
||||
);
|
||||
await pumpForm(tester, clientOf(adapter), existing: existing);
|
||||
|
||||
await tapVisible(tester, find.byTooltip('Clear end time'));
|
||||
await tester.pump();
|
||||
|
||||
await tapVisible(tester, find.text('Save'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(adapter.requests.single.body, <String, dynamic>{'endAt': null});
|
||||
});
|
||||
|
||||
testWidgets('编辑:修改 zh 标题 → 仅发全量 translations(不含开关/时间)',
|
||||
(tester) async {
|
||||
final adapter = FakeAdapter(
|
||||
(o) => o.path == '/v1/albums/alb-1'
|
||||
? (200, albumJson())
|
||||
: throw StateError('unexpected ${o.path}'),
|
||||
);
|
||||
final existing = Album.fromJson(albumJson());
|
||||
await pumpForm(tester, clientOf(adapter), existing: existing);
|
||||
|
||||
await tester.enterText(
|
||||
find.byType(TextFormField).first,
|
||||
'闭幕演出',
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await tapVisible(tester, find.text('Save'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final body = adapter.requests.single.body as Map<String, dynamic>;
|
||||
expect(body.keys, <String>['translations']);
|
||||
expect(body['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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/// 成员管理页 widget 测试(P3 计划 Task 4)。
|
||||
///
|
||||
/// 覆盖:列表角色标签/owner 徽标/本人与创建者不可移除;账号前缀搜索
|
||||
/// 防抖(300ms、≥2 字符、连续输入合并);邀请载荷与 MEMBER_CONFLICT
|
||||
/// 本地化;移除确认流。
|
||||
library;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:photo_live/core/api_client.dart';
|
||||
import 'package:photo_live/core/error_codes.dart';
|
||||
import 'package:photo_live/core/session_store.dart';
|
||||
import 'package:photo_live/features/albums/models.dart';
|
||||
import 'package:photo_live/l10n/app_localizations.dart';
|
||||
import 'package:photo_live/pages/album_members_page.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
|
||||
void main() {
|
||||
late FakeSessionStore store;
|
||||
late List<Map<String, dynamic>> members;
|
||||
|
||||
setUp(() {
|
||||
store = FakeSessionStore()
|
||||
..session = Session(
|
||||
token: 'tok-live',
|
||||
expiresAt: DateTime.utc(2099, 1, 1),
|
||||
account: 'staff01',
|
||||
);
|
||||
members = <Map<String, dynamic>>[
|
||||
memberJson(
|
||||
userId: 'u-owner',
|
||||
account: 'boss01',
|
||||
displayName: 'The Owner',
|
||||
role: 'owner',
|
||||
),
|
||||
memberJson(userId: 'u-1', account: 'staff01', displayName: 'Staff One'),
|
||||
memberJson(userId: 'u-2', account: 'staff02', displayName: 'Staff Two'),
|
||||
];
|
||||
});
|
||||
|
||||
FakeAdapter membersAdapter({
|
||||
(int, Object?)? Function(String method, String path)? onMutation,
|
||||
}) =>
|
||||
FakeAdapter((o) {
|
||||
if (o.path == '/v1/albums/alb-1/members' && o.method == 'GET') {
|
||||
return (200, cursorPageBody(members));
|
||||
}
|
||||
if (o.path == '/v1/albums/alb-1/members' && o.method == 'POST') {
|
||||
final override = onMutation?.call('POST', o.path);
|
||||
if (override != null) return override;
|
||||
members = <Map<String, dynamic>>[
|
||||
...members,
|
||||
memberJson(userId: 'u-9', account: 'staff09'),
|
||||
];
|
||||
return (201, memberJson(userId: 'u-9', account: 'staff09'));
|
||||
}
|
||||
if (o.path == '/v1/albums/alb-1/members/u-2' && o.method == 'DELETE') {
|
||||
final override = onMutation?.call('DELETE', o.path);
|
||||
if (override != null) return override;
|
||||
members = members
|
||||
.where((m) => m['userId'] != 'u-2')
|
||||
.toList();
|
||||
return (204, null);
|
||||
}
|
||||
if (o.path == '/v1/admin/users' && o.method == 'GET') {
|
||||
return (200, <String, dynamic>{
|
||||
'items': <Map<String, dynamic>>[lookupUserJson()],
|
||||
});
|
||||
}
|
||||
throw StateError('unexpected ${o.method} ${o.path}');
|
||||
});
|
||||
|
||||
ApiClient clientOf(FakeAdapter adapter) => ApiClient(
|
||||
sessionStore: store,
|
||||
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
|
||||
..httpClientAdapter = adapter,
|
||||
);
|
||||
|
||||
Future<void> pumpMembers(WidgetTester tester, ApiClient client) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
apiClientProvider.overrideWith((ref) => client),
|
||||
// 本人识别走会话存储:覆写为内存假件(secure storage 插件缺失)。
|
||||
sessionStoreProvider.overrideWith((ref) => store),
|
||||
],
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: AlbumMembersPage(
|
||||
album: Album.fromJson(albumJson()),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('列表渲染:角色标签、owner 徽标、本人/创建者无移除按钮',
|
||||
(tester) async {
|
||||
await pumpMembers(tester, clientOf(membersAdapter()));
|
||||
|
||||
expect(find.text('Owner'), findsOneWidget);
|
||||
expect(find.text('Collaborator'), findsNWidgets(2));
|
||||
expect(find.byIcon(Icons.star), findsOneWidget, reason: 'owner 徽标');
|
||||
// 本人标记。
|
||||
expect(find.text('staff01 (you)'), findsOneWidget);
|
||||
// 仅 staff02(非 owner、非本人)有移除按钮。
|
||||
expect(find.byIcon(Icons.person_remove_outlined), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('搜索防抖:<2 字符不请求;连续输入合并为最后一次(300ms)',
|
||||
(tester) async {
|
||||
final adapter = membersAdapter();
|
||||
await pumpMembers(tester, clientOf(adapter));
|
||||
|
||||
final field = find.byType(TextField);
|
||||
// 1 个字符:无请求。
|
||||
await tester.enterText(field, 's');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
expect(
|
||||
adapter.requests.where((r) => r.path == '/v1/admin/users'),
|
||||
isEmpty,
|
||||
);
|
||||
|
||||
// 连续输入两次,防抖合并为最后一次。
|
||||
await tester.enterText(field, 'st');
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await tester.enterText(field, 'sta');
|
||||
await tester.pump(const Duration(milliseconds: 350));
|
||||
|
||||
final lookups = adapter.requests
|
||||
.where((r) => r.path == '/v1/admin/users')
|
||||
.toList();
|
||||
expect(lookups, hasLength(1));
|
||||
expect(lookups.single.uri.queryParameters['accountPrefix'], 'sta');
|
||||
});
|
||||
|
||||
testWidgets('搜索命中 → 点击邀请 → POST userId 载荷 → 列表刷新 + 提示',
|
||||
(tester) async {
|
||||
final adapter = membersAdapter();
|
||||
await pumpMembers(tester, clientOf(adapter));
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'staff09');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
|
||||
// 搜索框 EditableText 与结果条目同名,按 ListTile 定位结果。
|
||||
expect(find.widgetWithText(ListTile, 'staff09'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Invite'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final invite = adapter.requests.singleWhere(
|
||||
(r) => r.method == 'POST' && r.path == '/v1/albums/alb-1/members',
|
||||
);
|
||||
expect(invite.body, <String, dynamic>{'userId': 'u-9'});
|
||||
expect(find.text('Invited'), findsOneWidget);
|
||||
// 邀请成功后列表重载(staff09 入列)。
|
||||
expect(find.text('Collaborator'), findsNWidgets(3));
|
||||
});
|
||||
|
||||
testWidgets('邀请冲突(409 MEMBER_CONFLICT)→ 已是成员文案', (tester) async {
|
||||
final adapter = membersAdapter(
|
||||
onMutation: (method, path) =>
|
||||
method == 'POST' ? (409, errorBody(ErrorCodes.memberConflict)) : null,
|
||||
);
|
||||
await pumpMembers(tester, clientOf(adapter));
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'staff09');
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await tester.tap(find.text('Invite'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('User is already a member'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('移除成员:确认对话框门控 → DELETE → 列表刷新', (tester) async {
|
||||
final adapter = membersAdapter();
|
||||
await pumpMembers(tester, clientOf(adapter));
|
||||
|
||||
await tester.tap(find.byIcon(Icons.person_remove_outlined));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Remove this member?'), findsOneWidget);
|
||||
// 取消不发请求。
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
adapter.requests.any((r) => r.method == 'DELETE'),
|
||||
isFalse,
|
||||
);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.person_remove_outlined));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
adapter.requests.any(
|
||||
(r) => r.method == 'DELETE' && r.path == '/v1/albums/alb-1/members/u-2',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(find.text('staff02'), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/// 照片管理占位页 widget 测试(P3 计划 Task 4)。
|
||||
///
|
||||
/// 覆盖:Task 9 占位横幅、基础列表(可见性/处理状态标签、时区时间)、
|
||||
/// 游标加载更多。完整交互由 Task 9 替换。
|
||||
library;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:photo_live/core/api_client.dart';
|
||||
import 'package:photo_live/features/albums/models.dart';
|
||||
import 'package:photo_live/l10n/app_localizations.dart';
|
||||
import 'package:photo_live/pages/album_photos_page.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
|
||||
void main() {
|
||||
late FakeSessionStore store;
|
||||
|
||||
setUp(() {
|
||||
store = FakeSessionStore();
|
||||
});
|
||||
|
||||
ApiClient clientOf(FakeAdapter adapter) => ApiClient(
|
||||
sessionStore: store,
|
||||
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
|
||||
..httpClientAdapter = adapter,
|
||||
);
|
||||
|
||||
Future<void> pumpPhotos(WidgetTester tester, ApiClient client) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [apiClientProvider.overrideWith((ref) => client)],
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: AlbumPhotosPage(album: Album.fromJson(albumJson())),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('占位横幅 + 列表标签 + 游标加载第二页', (tester) async {
|
||||
final page1 = List<Map<String, dynamic>>.generate(
|
||||
20,
|
||||
(i) => adminPhotoJson(
|
||||
photoId: 'ph-${(i + 1).toString().padLeft(8, '0')}',
|
||||
visibility: i % 2 == 0 ? 'published' : 'unpublished',
|
||||
processingState: i % 2 == 0 ? 'ready' : 'processing',
|
||||
),
|
||||
);
|
||||
final page2 = <Map<String, dynamic>>[
|
||||
// 区分度高的 ID:第二页条目渲染后可按标题前缀断言。
|
||||
adminPhotoJson(photoId: 'page2item'),
|
||||
];
|
||||
final adapter = FakeAdapter((o) {
|
||||
if (o.path != '/v1/albums/alb-1/photos') {
|
||||
throw StateError('unexpected ${o.method} ${o.path}');
|
||||
}
|
||||
final cursor = o.uri.queryParameters['cursor'];
|
||||
if (cursor == null) {
|
||||
return (200, cursorPageBody(page1, nextCursor: 'pc1'));
|
||||
}
|
||||
if (cursor == 'pc1') return (200, cursorPageBody(page2));
|
||||
throw StateError('unexpected cursor $cursor');
|
||||
});
|
||||
|
||||
await pumpPhotos(tester, clientOf(adapter));
|
||||
|
||||
// Task 9 占位横幅。
|
||||
expect(
|
||||
find.textContaining('the full experience ships in Task 9'),
|
||||
findsOneWidget,
|
||||
);
|
||||
// 标签与条目(ListView 懒构建,只断言在渲染的可见条目)。
|
||||
expect(find.text('Published'), findsWidgets);
|
||||
expect(find.text('Unpublished'), findsWidgets);
|
||||
expect(find.text('Ready'), findsWidgets);
|
||||
expect(find.text('Processing'), findsWidgets);
|
||||
expect(find.text('2026/9/18 15:00'), findsWidgets, reason: '相册时区展示');
|
||||
|
||||
// 滚动加载第二页。
|
||||
await tester.drag(find.byType(ListView), const Offset(0, -4000));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
adapter.requests.last.uri.queryParameters['cursor'],
|
||||
'pc1',
|
||||
);
|
||||
// 第二页条目渲染(标题展示 photoId 前 8 位)。
|
||||
expect(find.textContaining('page2ite'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/// 相册列表页签 widget 测试(P3 计划 Task 4)。
|
||||
///
|
||||
/// 覆盖:首屏加载与卡片标签行(状态/开关/时区时间/令牌短显)、游标
|
||||
/// 加载更多(第二页携带 cursor)、空态文案、首屏错误 + 重试恢复。
|
||||
library;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:photo_live/core/api_client.dart';
|
||||
import 'package:photo_live/core/error_codes.dart';
|
||||
import 'package:photo_live/features/albums/providers.dart';
|
||||
import 'package:photo_live/l10n/app_localizations.dart';
|
||||
import 'package:photo_live/pages/albums_tab.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
|
||||
void main() {
|
||||
late FakeSessionStore store;
|
||||
|
||||
setUp(() {
|
||||
store = FakeSessionStore();
|
||||
});
|
||||
|
||||
ApiClient clientOf(FakeAdapter adapter) => ApiClient(
|
||||
sessionStore: store,
|
||||
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
|
||||
..httpClientAdapter = adapter,
|
||||
);
|
||||
|
||||
Future<void> pumpTab(WidgetTester tester, ApiClient client) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [apiClientProvider.overrideWith((ref) => client)],
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const AlbumsTab(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('首屏加载 → 卡片渲染标题/状态/开关标签/时区时间/令牌短显',
|
||||
(tester) async {
|
||||
final adapter = FakeAdapter(
|
||||
(o) => o.path == '/v1/albums'
|
||||
? (200, cursorPageBody([albumJson(
|
||||
status: 'offline',
|
||||
uploadEnabled: true,
|
||||
publicEnabled: false,
|
||||
downloadEnabled: true,
|
||||
originalDownloadEnabled: false,
|
||||
)]))
|
||||
: throw StateError('unexpected ${o.path}'),
|
||||
);
|
||||
await pumpTab(tester, clientOf(adapter));
|
||||
|
||||
expect(find.text('开幕晚宴'), findsOneWidget);
|
||||
expect(find.text('Offline'), findsOneWidget);
|
||||
// 四个开关标签(色调区分开关,文案一致)。
|
||||
expect(find.text('Upload'), findsOneWidget);
|
||||
expect(find.text('Public'), findsOneWidget);
|
||||
expect(find.text('Download'), findsOneWidget);
|
||||
expect(find.text('Original'), findsOneWidget);
|
||||
// Asia/Shanghai 墙钟 14:30;结束未定。
|
||||
expect(find.text('2026/9/18 14:30 ~ Not set'), findsOneWidget);
|
||||
// 令牌短显(前 10 字符 + 省略号)。
|
||||
expect(find.text('sharetoken…'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.add), findsOneWidget, reason: 'FAB 新建入口');
|
||||
});
|
||||
|
||||
testWidgets('滚动接近底部 → 游标加载第二页(携带 cursor)', (tester) async {
|
||||
final page1 = List<Map<String, dynamic>>.generate(
|
||||
20,
|
||||
(i) => albumJson(id: 'alb-${i + 1}'),
|
||||
);
|
||||
final page2 = List<Map<String, dynamic>>.generate(
|
||||
10,
|
||||
(i) => albumJson(id: 'alb-${i + 21}'),
|
||||
);
|
||||
final adapter = FakeAdapter(
|
||||
(o) {
|
||||
if (o.path != '/v1/albums') throw StateError('unexpected ${o.path}');
|
||||
final cursor = o.uri.queryParameters['cursor'];
|
||||
if (cursor == null) return (200, cursorPageBody(page1, nextCursor: 'c1'));
|
||||
if (cursor == 'c1') return (200, cursorPageBody(page2));
|
||||
throw StateError('unexpected cursor $cursor');
|
||||
},
|
||||
);
|
||||
await pumpTab(tester, clientOf(adapter));
|
||||
|
||||
// 首屏只拉第一页(20 条入 state;ListView 懒构建,不逐条断言渲染)。
|
||||
expect(adapter.requests, hasLength(1));
|
||||
final container = ProviderScope.containerOf(
|
||||
tester.element(find.byType(AlbumsTab)),
|
||||
);
|
||||
expect(container.read(albumListProvider).items, hasLength(20));
|
||||
|
||||
// 上滑触发加载更多。
|
||||
await tester.drag(find.byType(ListView), const Offset(0, -2400));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(adapter.requests, hasLength(2));
|
||||
expect(
|
||||
adapter.requests.last.uri.queryParameters['cursor'],
|
||||
'c1',
|
||||
reason: '第二页携带游标 c1',
|
||||
);
|
||||
expect(
|
||||
container.read(albumListProvider).items,
|
||||
hasLength(30),
|
||||
reason: '两页累积',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('空列表 → 空态文案', (tester) async {
|
||||
final adapter = FakeAdapter(
|
||||
(o) => o.path == '/v1/albums'
|
||||
? (200, cursorPageBody(<Map<String, dynamic>>[]))
|
||||
: throw StateError('unexpected ${o.path}'),
|
||||
);
|
||||
await pumpTab(tester, clientOf(adapter));
|
||||
|
||||
expect(
|
||||
find.text('No albums yet. Tap "Create album" to get started'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('首屏错误 → 错误视图,重试后恢复', (tester) async {
|
||||
var failFirst = true;
|
||||
final adapter = FakeAdapter(
|
||||
(o) {
|
||||
if (o.path != '/v1/albums') throw StateError('unexpected ${o.path}');
|
||||
if (failFirst) {
|
||||
failFirst = false;
|
||||
return (500, errorBody(ErrorCodes.internalError));
|
||||
}
|
||||
return (200, cursorPageBody([albumJson()]));
|
||||
},
|
||||
);
|
||||
await pumpTab(tester, clientOf(adapter));
|
||||
|
||||
expect(
|
||||
find.text('Service temporarily unavailable. Please retry later'),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Retry'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('开幕晚宴'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -69,18 +69,21 @@ void main() {
|
||||
testWidgets('退出登录 → api.logout + 清会话 + 清栈回 /login(服务端失败亦然)',
|
||||
(tester) async {
|
||||
// 服务端撤销失败(500)也照样本地登出——尽力而为语义。
|
||||
final adapter = FakeAdapter(
|
||||
(o) => o.path == '/v1/auth/logout'
|
||||
? (500, errorBody('INTERNAL_ERROR'))
|
||||
: throw StateError('unexpected ${o.path}'),
|
||||
);
|
||||
// 首页「相册」页签(Task 4)进入即自动拉列表,需一并兜底路由。
|
||||
final adapter = FakeAdapter((o) {
|
||||
if (o.path == '/v1/auth/logout') return (500, errorBody('INTERNAL_ERROR'));
|
||||
if (o.path == '/v1/albums') return (500, errorBody('INTERNAL_ERROR'));
|
||||
throw StateError('unexpected ${o.path}');
|
||||
});
|
||||
await pumpHome(tester, clientOf(adapter));
|
||||
|
||||
await tester.tap(find.text('Sign out'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(adapter.requests.single.path, '/v1/auth/logout');
|
||||
expect(adapter.requests.single.method, 'POST');
|
||||
final logout = adapter.requests.singleWhere(
|
||||
(r) => r.path == '/v1/auth/logout',
|
||||
);
|
||||
expect(logout.method, 'POST');
|
||||
expect(store.session, isNull, reason: '本地会话必须清除');
|
||||
expect(find.byType(LoginPage), findsOneWidget);
|
||||
expect(find.byType(HomePage), findsNothing, reason: '主壳已出栈');
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/// 分享二维码对话框 widget 测试(P3 计划 Task 4)。
|
||||
///
|
||||
/// 覆盖:QrImageView 以 viewer URL({VIEWER_BASE}/a/{token})渲染、
|
||||
/// URL 文本可见、复制按钮写入剪贴板并提示。
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:photo_live/core/config.dart';
|
||||
import 'package:photo_live/l10n/app_localizations.dart';
|
||||
import 'package:photo_live/pages/share_qr_dialog.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
|
||||
void main() {
|
||||
const token = 'sharetoken000000000001';
|
||||
|
||||
Future<void> pumpDialog(WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
// 复制提示走 ScaffoldMessenger:包一层 Scaffold 供 SnackBar 挂载。
|
||||
home: const Scaffold(
|
||||
body: Center(child: ShareQrDialog(publicToken: token)),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('渲染 QrImageView,data 为观众链接;URL 文本可见', (tester) async {
|
||||
await pumpDialog(tester);
|
||||
|
||||
final url = ApiConfig.viewerAlbumUrl(token);
|
||||
// qr_flutter 4.1 未公开 QrImageView.data(私有 _data),以同源的
|
||||
// SelectableText 文本断言 URL 构造(二维码与文本同用该局部变量)。
|
||||
expect(find.byType(QrImageView), findsOneWidget);
|
||||
expect(
|
||||
tester.widget<SelectableText>(find.byType(SelectableText)).data,
|
||||
url,
|
||||
);
|
||||
expect(url, 'http://10.0.2.2:5175/a/$token');
|
||||
expect(find.text('Scan the code or open the link to view'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('复制按钮 → 剪贴板收到 URL + 已复制提示', (tester) async {
|
||||
final clipboardCalls = <MethodCall>[];
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
(call) async {
|
||||
clipboardCalls.add(call);
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
await pumpDialog(tester);
|
||||
await tester.tap(find.text('Copy link'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final url = ApiConfig.viewerAlbumUrl(token);
|
||||
expect(
|
||||
clipboardCalls.any(
|
||||
(c) =>
|
||||
c.method == 'Clipboard.setData' &&
|
||||
(c.arguments as Map<String, dynamic>)['text'] == url,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(find.text('Copied'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user