955f9b13c1
Task 9 (P3 plan): queue tab replaces the stub, continuous capture page enqueues per shot, album detail gains an upload FAB, and the upload engine lifecycle is now bound to the login session. - Engine scope: session account drives UploadEngine start/stop; wired from app boot, login success, logout, and 401 eviction. Tasks stay in the store across logout (accountId hard isolation). - Queue tab: album x state x time filter bar, task rows with thumbnail, state chip, localized error copy, attempt count, per-row progress via a broadcast progress hub, state-dependent actions (re-pick retry, retryNow, cancel, delete), batch retry bar, 200ms-debounced live refresh from drift table updates. - Capture page: camera preview via injected CaptureSource, one enqueue per shutter press, persistent album/category/badges overlay with a switch-only-affects-new-shots hint, gallery multi-pick, exit confirm while uploads are pending, permission-denied and init-failure views. - POST_NOTIFICATIONS runtime permission via a small MethodChannel (photolive/permissions) + PermissionPlugin.kt: single runtime permission need, avoids permission_handler dependency; requested once on first enqueue, silent degradation when denied or unavailable. - Album detail FAB: capture-and-upload / pick-from-gallery sheet. - l10n: ~50 new keys in zh/en arbs; tests: +45 (filter matrix, engine scope lifecycle, permission gate, queue tab, capture page, FAB entry); existing page tests updated to inject session/task-store fakes. Co-Authored-By: Claude Code <noreply@anthropic.com>
417 lines
14 KiB
Dart
417 lines
14 KiB
Dart
/// 相册详情页 widget 测试(P3 计划 Task 4)。
|
||
///
|
||
/// 覆盖:详情信息区(标题/状态/时间时区/令牌/开关标签)与动作行;
|
||
/// 分类管理三流(新增对话框载荷、名称空校验拦截、编辑 PATCH、删除确认)。
|
||
library;
|
||
|
||
import 'dart:io';
|
||
|
||
import 'package:camera/camera.dart' show XFile;
|
||
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/notification_permission.dart';
|
||
import 'package:photo_live/core/session_store.dart';
|
||
import 'package:photo_live/features/uploads/enqueue.dart';
|
||
import 'package:photo_live/features/uploads/preparer.dart';
|
||
import 'package:photo_live/features/uploads/providers.dart';
|
||
import 'package:photo_live/features/uploads/task_store.dart';
|
||
import 'package:photo_live/l10n/app_localizations.dart';
|
||
import 'package:photo_live/pages/album_detail_page.dart';
|
||
import 'package:photo_live/pages/capture_page.dart';
|
||
|
||
import '../helpers/fakes.dart';
|
||
|
||
/// JPEG 魔数最小字节(入队准备器真实文件驱动)。
|
||
const List<int> kJpgBytes = <int>[
|
||
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00,
|
||
];
|
||
|
||
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(
|
||
// Task 9 起详情页有上传 FAB(触达 sessionAccountProvider),
|
||
// 真实平台通道在测试环境挂起,必须注入替身。
|
||
overrides: [
|
||
...pageTestOverrides(session: store, client: 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);
|
||
// 「Upload」出现两次:开关标签 chip + Task 9 上传 FAB 文案。
|
||
expect(find.text('Upload'), findsNWidgets(2));
|
||
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);
|
||
});
|
||
|
||
group('上传入口 FAB(Task 9)', () {
|
||
late Directory tmp;
|
||
late List<String> galleryPaths;
|
||
late TaskStore taskStore;
|
||
|
||
setUp(() async {
|
||
store.session = Session(
|
||
token: 'tok-1',
|
||
expiresAt: DateTime.utc(2099, 1, 1),
|
||
account: 'alice',
|
||
);
|
||
tmp = await Directory.systemTemp.createTemp('detail-upload-');
|
||
galleryPaths = <String>[];
|
||
taskStore = TaskStore.memory();
|
||
});
|
||
|
||
tearDown(() async {
|
||
await taskStore.close();
|
||
await tmp.delete(recursive: true);
|
||
});
|
||
|
||
/// 详情页 + 上传链路替身(内存任务库/临时目录准备器/多选替身/
|
||
/// 非平台相机源)。
|
||
Future<void> pumpDetailForUpload(WidgetTester tester) async {
|
||
final adapter = detailAdapter();
|
||
await tester.pumpWidget(
|
||
ProviderScope(
|
||
overrides: [
|
||
...pageTestOverrides(
|
||
session: store,
|
||
taskStore: taskStore,
|
||
client: clientOf(adapter),
|
||
),
|
||
multiImagePickerProvider
|
||
.overrideWith((ref) => () async => galleryPaths),
|
||
uploadPreparerProvider.overrideWith(
|
||
(ref) => UploadPreparer(
|
||
taskStore,
|
||
baseDirProvider: () async => tmp,
|
||
),
|
||
),
|
||
notificationPermissionProvider.overrideWith(
|
||
(ref) => NotificationPermission(isAndroid: false),
|
||
),
|
||
captureSourceFactoryProvider
|
||
.overrideWith((ref) => () async => _StaticCaptureSource()),
|
||
],
|
||
child: MaterialApp(
|
||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||
supportedLocales: AppLocalizations.supportedLocales,
|
||
home: const AlbumDetailPage(albumId: 'alb-1'),
|
||
),
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
}
|
||
|
||
testWidgets('FAB → 底部弹层双入口(拍照上传 / 从相册选择)', (tester) async {
|
||
await pumpDetailForUpload(tester);
|
||
|
||
await tester.tap(find.byType(FloatingActionButton));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.text('Capture and upload'), findsOneWidget);
|
||
expect(find.text('Pick from gallery'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('拍照上传 → 进入连拍页(目标相册带出)', (tester) async {
|
||
await pumpDetailForUpload(tester);
|
||
|
||
await tester.tap(find.byType(FloatingActionButton));
|
||
await tester.pumpAndSettle();
|
||
await tester.tap(find.text('Capture and upload'));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.byType(CapturePage), findsOneWidget);
|
||
// 连拍页常驻信息层显示目标相册名(与详情标题同源:默认 zh 主标题)。
|
||
expect(
|
||
find.descendant(
|
||
of: find.byType(CapturePage),
|
||
matching: find.text('开幕晚宴'),
|
||
),
|
||
findsOneWidget,
|
||
);
|
||
});
|
||
|
||
testWidgets('从相册选择 → 多选入队 + 入队数提示', (tester) async {
|
||
final pick1 = File('${tmp.path}/pick1.jpg')..writeAsBytesSync(kJpgBytes);
|
||
final pick2 = File('${tmp.path}/pick2.jpg')..writeAsBytesSync(kJpgBytes);
|
||
galleryPaths.addAll([pick1.path, pick2.path]);
|
||
await pumpDetailForUpload(tester);
|
||
|
||
await tester.tap(find.byType(FloatingActionButton));
|
||
await tester.pumpAndSettle();
|
||
// 入队链路含真实文件 IO:真实事件循环内触发行内动作。
|
||
final tile = find.widgetWithText(ListTile, 'Pick from gallery');
|
||
await tester.runAsync(() async {
|
||
(tile.evaluate().single.widget as ListTile).onTap!();
|
||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||
});
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(
|
||
find.text('Added 2 photo(s) to the upload queue'),
|
||
findsOneWidget,
|
||
);
|
||
final tasks = await taskStore.listByAccount('alice');
|
||
expect(tasks, hasLength(2));
|
||
expect(tasks.every((t) => t.albumId == 'alb-1'), isTrue);
|
||
});
|
||
});
|
||
}
|
||
|
||
/// 相机源替身(详情页跳连拍页测试:仅就绪,不拍摄)。
|
||
class _StaticCaptureSource implements CaptureSource {
|
||
@override
|
||
Future<void> initialize() async {}
|
||
|
||
@override
|
||
Future<XFile> capture() => throw UnimplementedError();
|
||
|
||
@override
|
||
Widget buildPreview() => const SizedBox();
|
||
|
||
@override
|
||
Future<void> dispose() async {}
|
||
}
|