Files
photo-live/apps/mobile/test/pages/album_form_page_test.dart
T

567 lines
20 KiB
Dart
Raw Normal View History

/// 相册创建/编辑表单 widget 测试(UI-03 §4 页面 09-10 重设计后)。
///
/// 覆盖:创建两分组结构(Event details / Description & languages)与默认
/// 语言必选、无 Access 页签;校验矩阵(默认语言标题缺失/未选开始时间/
/// endAt 早于 startAt);创建 POST 载荷形状;编辑 Details/Access 页签共享
/// 草稿(切页签不丢输入)、四开关与说明、原图下载受总下载约束(禁用 +
/// PATCH 联动关断)、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/core/error_codes.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);
}
/// 切到 Access 页签(编辑模式顶栏 TabBar)。
Future<void> openAccessTab(WidgetTester tester) async {
await tester.tap(find.text('Access'));
await tester.pumpAndSettle();
}
testWidgets('页面 09 创建:两分组 + 默认语言必选 + 双语 tab + 无 Access 页签',
(tester) async {
final adapter = FakeAdapter((_) => throw StateError('不应发起请求'));
await pumpForm(tester, clientOf(adapter));
expect(find.text('Create album'), findsNWidgets(2), reason: '标题与底部按钮');
// 两个分组标题(SectionHeader)。
expect(find.text('Event details'), findsOneWidget);
expect(find.text('Description & languages'), findsOneWidget);
// 默认语言选择器(必选标记)+ 语言 tab(创建模式仅一个 TabBar)。
expect(find.text('Default language *'), findsOneWidget);
expect(find.byType(TabBar), findsOneWidget);
expect(find.descendant(of: find.byType(TabBar), matching: find.text('中文')),
findsOneWidget);
expect(
find.descendant(of: find.byType(TabBar), matching: find.text('English')),
findsOneWidget);
expect(find.byType(SegmentedButton<String>), findsOneWidget);
// 创建模式:无 Access 页签、无开关(契约缺省)。
expect(find.text('Access'), findsNothing);
expect(find.byType(SwitchListTile), findsNothing);
// 默认时区显式可见(不静默取设备时区)。
expect(find.text('Asia/Shanghai'), findsOneWidget);
});
testWidgets('页面 09 创建:空表单提交 → 汇总错误(默认语言标题/开始时间),不发请求',
(tester) async {
final adapter = FakeAdapter((_) => throw StateError('不应发起请求'));
await pumpForm(tester, clientOf(adapter));
await tapVisible(tester, find.widgetWithText(FilledButton, 'Create album'));
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('页面 09 创建:defaultLocale=en 而 en 标题空 → 默认语言覆盖错误',
(tester) async {
final adapter = FakeAdapter((_) => throw StateError('不应发起请求'));
await pumpForm(
tester,
clientOf(adapter),
initialDraft: createDraft(defaultLocale: 'en', enTitle: ''),
);
await tapVisible(tester, find.widgetWithText(FilledButton, 'Create album'));
await tester.pump();
expect(find.text('Default locale must have a title'), findsOneWidget);
expect(adapter.requests, isEmpty);
});
testWidgets('页面 09 创建: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.widgetWithText(FilledButton, 'Create album'));
await tester.pump();
expect(find.text('End time must be after start time'), findsOneWidget);
expect(adapter.requests, isEmpty);
});
testWidgets('页面 09 创建:合法提交 → 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),
),
);
await tapVisible(tester, find.widgetWithText(FilledButton, 'Create album'));
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',
// 创建无 Access 页签:download/original 走契约缺省。
'downloadEnabled': true,
'originalDownloadEnabled': false,
});
});
testWidgets('页面 09 创建:保存失败 → 提示并保留输入(不弹回)', (tester) async {
final adapter = FakeAdapter(
(o) => o.path == '/v1/albums'
? (500, errorBody(ErrorCodes.internalError))
: throw StateError('unexpected ${o.path}'),
);
await pumpForm(tester, clientOf(adapter), initialDraft: createDraft());
await tapVisible(tester, find.widgetWithText(FilledButton, 'Create album'));
await tester.pumpAndSettle();
expect(find.byType(AlbumFormPage), findsOneWidget, reason: '失败不弹回');
expect(
find.text('Service temporarily unavailable. Please retry later'),
findsOneWidget,
);
// 输入保留。
expect(
tester.widget<TextFormField>(find.byType(TextFormField).at(1)).controller!
.text,
'开幕晚宴',
);
});
testWidgets('页面 10 编辑:Details/Access 页签 + 预填 + 无变更保存禁用',
(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);
adapter.requests.clear();
// 顶栏 TabBar(Details/Access)+ 内容区语言 TabBar。
expect(find.text('Edit album'), findsOneWidget);
expect(find.text('Details'), findsOneWidget);
expect(find.text('Access'), findsOneWidget);
expect(find.byType(TabBar), findsNWidgets(2));
expect(find.text('Save changes'), findsOneWidget);
// 字段预填(zh tab;TextFormField 顺序 = 时区 → 标题(zh tab))。
final titleField = tester.widget<TextFormField>(
find.byType(TextFormField).at(1),
);
expect(titleField.controller!.text, '开幕晚宴');
// 时间回显按相册时区。
expect(find.text('2026/9/18'), findsOneWidget); // UI06:日期粒度显示
expect(find.text('2026/9/19'), findsOneWidget); // UI06:日期粒度显示
// 无任何变更 → 保存禁用(PATCH minProperties 1)。
expect(
tester.widget<FilledButton>(find.byType(FilledButton)).onPressed,
isNull,
);
});
testWidgets('页面 10 编辑:切页签共享草稿(不丢输入、不丢开关)', (tester) async {
final adapter = FakeAdapter(
(o) => throw StateError('不应发起请求:${o.path}'),
);
await pumpForm(
tester,
clientOf(adapter),
existing: Album.fromJson(albumJson()),
);
adapter.requests.clear();
// UI06:banner 现状预检(GET /assets)已记录——清空后断言只看用户动作。
adapter.requests.clear();
// Details 页签输入标题。
await tester.enterText(find.byType(TextFormField).at(1), '闭幕演出');
await tester.pump();
// 切 Access → 切回 Details:输入保留(共享草稿)。
await openAccessTab(tester);
expect(find.byType(SwitchListTile), findsNWidgets(4));
await tester.tap(find.text('Details'));
await tester.pumpAndSettle();
expect(
tester.widget<TextFormField>(find.byType(TextFormField).at(1)).controller!
.text,
'闭幕演出',
);
// 未保存 → 不发请求。
expect(adapter.requests, isEmpty);
});
testWidgets('页面 10 编辑:Access 四开关各配说明', (tester) async {
final adapter = FakeAdapter(
(o) => throw StateError('不应发起请求:${o.path}'),
);
await pumpForm(
tester,
clientOf(adapter),
existing: Album.fromJson(albumJson()),
);
adapter.requests.clear();
// UI06:banner 现状预检(GET /assets)已记录——清空后断言只看用户动作。
adapter.requests.clear();
await openAccessTab(tester);
expect(
find.widgetWithText(SwitchListTile, 'Allow uploads'),
findsOneWidget,
);
expect(
find.text('Allow staff to upload photos to this album'),
findsOneWidget,
);
expect(
find.widgetWithText(SwitchListTile, 'Public access'),
findsOneWidget,
);
expect(
find.text('Anyone with the link can view photos'),
findsOneWidget,
);
expect(
find.widgetWithText(SwitchListTile, 'Allow downloads'),
findsOneWidget,
);
expect(
find.text('Viewers can download browse-quality photos'),
findsOneWidget,
);
expect(
find.widgetWithText(SwitchListTile, 'Original downloads'),
findsOneWidget,
);
expect(
find.text('Viewers can download original photos'),
findsOneWidget,
);
// 开关初值与原相册一致。
expect(
tester
.widget<SwitchListTile>(
find.widgetWithText(SwitchListTile, 'Allow uploads'),
)
.value,
true,
);
expect(
tester
.widget<SwitchListTile>(
find.widgetWithText(SwitchListTile, 'Public access'),
)
.value,
true,
);
});
testWidgets('页面 10 编辑:原图下载受总下载约束(禁用 + PATCH 联动关断)',
(tester) async {
final adapter = FakeAdapter(
(o) => o.path == '/v1/albums/alb-1'
? (200, albumJson(downloadEnabled: false))
: throw StateError('unexpected ${o.path}'),
);
// 原相册:下载开、原图开。
final existing =
Album.fromJson(albumJson(originalDownloadEnabled: true));
await pumpForm(tester, clientOf(adapter), existing: existing);
adapter.requests.clear();
await openAccessTab(tester);
// 关闭总下载 → 原图开关禁用且显示为关。
await tester.tap(find.widgetWithText(SwitchListTile, 'Allow downloads'));
await tester.pump();
final original = find.widgetWithText(SwitchListTile, 'Original downloads');
expect(tester.widget<SwitchListTile>(original).onChanged, isNull,
reason: '总下载关闭 → 原图开关禁用');
expect(tester.widget<SwitchListTile>(original).value, isFalse,
reason: '生效值恒为 false');
// 保存 → PATCH 联动关断原图(不允许「原图开而下载关」的矛盾态)。
await tester.tap(find.text('Save changes'));
await tester.pumpAndSettle();
expect(adapter.requests.single.body, <String, dynamic>{
'downloadEnabled': false,
'originalDownloadEnabled': false,
});
});
testWidgets('页面 10 编辑:仅拨动 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);
adapter.requests.clear();
await openAccessTab(tester);
await tester
.tap(find.widgetWithText(SwitchListTile, 'Original downloads'));
await tester.pump();
await tester.ensureVisible(find.text('Save changes'));
await tester.pumpAndSettle();
await tester.tap(find.text('Save changes'));
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('页面 10 编辑:清除 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);
adapter.requests.clear();
await tapVisible(tester, find.byTooltip('Clear end time'));
await tester.pump();
await tapVisible(tester, find.text('Save changes'));
await tester.pumpAndSettle();
expect(adapter.requests.single.body, <String, dynamic>{'endAt': null});
});
testWidgets('页面 10 编辑:修改 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);
adapter.requests.clear();
await tester.enterText(
find.byType(TextFormField).at(1),
'闭幕演出',
);
await tester.pump();
await tapVisible(tester, find.text('Save changes'));
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,
},
]);
});
testWidgets('页面 10 编辑:未保存变更退出 → 放弃确认(继续编辑/放弃两路)',
(tester) async {
final adapter = FakeAdapter(
(o) => throw StateError('不应发起请求:${o.path}'),
);
final client = clientOf(adapter);
/// 以父路由承载(表单作为可弹出路由,AppBar 才有返回键)。
Future<void> pumpPushedForm() async {
await tester.pumpWidget(
ProviderScope(
overrides: [apiClientProvider.overrideWith((ref) => client)],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: Builder(
builder: (context) => Center(
child: TextButton(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => AlbumFormPage(
existing: Album.fromJson(albumJson()),
),
),
),
child: const Text('open form'),
),
),
),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('open form'));
await tester.pumpAndSettle();
}
await pumpPushedForm();
// 无变更:系统返回直接弹出(无确认框)。
await tester.pageBack();
await tester.pumpAndSettle();
expect(find.byType(AlbumFormPage), findsNothing);
// 重新进入并制造变更。
await pumpPushedForm();
await tester.enterText(find.byType(TextFormField).at(1), '闭幕演出');
await tester.pump();
await tester.pageBack();
await tester.pumpAndSettle();
expect(find.text('Discard changes?'), findsOneWidget);
expect(
find.text('Your edits are not saved yet. Leaving now will discard them.'),
findsOneWidget,
);
// 继续编辑 → 留在页面,输入保留。
await tester.tap(find.text('Keep editing'));
await tester.pumpAndSettle();
expect(find.byType(AlbumFormPage), findsOneWidget);
expect(
tester.widget<TextFormField>(find.byType(TextFormField).at(1)).controller!
.text,
'闭幕演出',
);
// 放弃 → 弹出页面。
await tester.pageBack();
await tester.pumpAndSettle();
await tester.tap(find.text('Discard'));
await tester.pumpAndSettle();
expect(find.byType(AlbumFormPage), findsNothing);
});
}