feat(mobile): upload engine with preparation, batching states and retries
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
/// 服务端上传历史页 widget 测试(P3 计划 Task 6,A19 语义)。
|
||||
///
|
||||
/// 覆盖:A19 数据源区分横幅、行内容(clientTaskId 短码/KB/状态标签/
|
||||
/// 设备短码 + 「本设备」标记)、游标加载更多、错误重试。
|
||||
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/device_id.dart';
|
||||
import 'package:photo_live/core/session_store.dart';
|
||||
import 'package:photo_live/l10n/app_localizations.dart';
|
||||
import 'package:photo_live/pages/server_history_page.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
|
||||
const String _kOwnDevice = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||
const String _kOtherDevice = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
|
||||
|
||||
class _FixedDeviceIdStore implements DeviceIdStore {
|
||||
_FixedDeviceIdStore(this._value);
|
||||
final String _value;
|
||||
|
||||
@override
|
||||
Future<String?> read() async => _value;
|
||||
|
||||
@override
|
||||
Future<void> write(String value) async {}
|
||||
}
|
||||
|
||||
/// 契约 `UploadSessionRecord` 响应体(GET /uploads 条目)。
|
||||
Map<String, dynamic> sessionJson({
|
||||
String uploadId = 'upl-0001',
|
||||
String albumId = 'alb-1',
|
||||
String clientTaskId = 'clienttask000000000000000000000001',
|
||||
String state = 'confirmed',
|
||||
int expectedBytes = 2048,
|
||||
String createdAt = '2026-09-19T08:00:00Z',
|
||||
String photoId = 'pho-0001',
|
||||
String processingState = 'ready',
|
||||
String? sourceDeviceId = _kOwnDevice,
|
||||
}) =>
|
||||
<String, dynamic>{
|
||||
'uploadId': uploadId,
|
||||
'albumId': albumId,
|
||||
'clientTaskId': clientTaskId,
|
||||
'state': state,
|
||||
'expectedBytes': expectedBytes,
|
||||
'contentType': 'image/jpeg',
|
||||
'createdAt': createdAt,
|
||||
'sourceDeviceId': sourceDeviceId,
|
||||
'photo': <String, dynamic>{
|
||||
'photoId': photoId,
|
||||
'processingState': processingState,
|
||||
'visibility': 'published',
|
||||
},
|
||||
};
|
||||
|
||||
void main() {
|
||||
late FakeSessionStore sessionStore;
|
||||
|
||||
setUp(() {
|
||||
sessionStore = FakeSessionStore()
|
||||
..session = Session(token: 'tok-1', account: 'alice');
|
||||
});
|
||||
|
||||
Future<void> pumpPage(WidgetTester tester, ApiClient client) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
apiClientProvider.overrideWith((ref) => client),
|
||||
deviceIdProvider.overrideWith(
|
||||
(ref) => DeviceId(store: _FixedDeviceIdStore(_kOwnDevice)),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const ServerHistoryPage(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
ApiClient clientOf(FakeAdapter adapter) => ApiClient(
|
||||
sessionStore: sessionStore,
|
||||
dio: Dio(BaseOptions(baseUrl: 'http://test.local'))
|
||||
..httpClientAdapter = adapter,
|
||||
);
|
||||
|
||||
testWidgets('A19 横幅 + 行内容(短码/KB/状态标签/设备短码/本设备标记)',
|
||||
(tester) async {
|
||||
final adapter = FakeAdapter((o) {
|
||||
if (o.method == 'GET' && o.path == '/v1/uploads') {
|
||||
return (
|
||||
200,
|
||||
cursorPageBody([
|
||||
sessionJson(clientTaskId: 'owndevice0000000000000000000001'),
|
||||
sessionJson(
|
||||
clientTaskId: 'otherdevice0000000000000000000002',
|
||||
state: 'pending',
|
||||
processingState: 'processing',
|
||||
expectedBytes: 5 * 1024 * 1024,
|
||||
sourceDeviceId: _kOtherDevice,
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
throw StateError('unexpected ${o.method} ${o.path}');
|
||||
});
|
||||
|
||||
await pumpPage(tester, clientOf(adapter));
|
||||
|
||||
// A19 数据源区分横幅。
|
||||
expect(find.textContaining('cross-device'), findsOneWidget);
|
||||
expect(find.textContaining('Queue tab'), findsOneWidget);
|
||||
|
||||
// 行:clientTaskId 前 8 位 + KB(2048B → 2 KB;5MB → 5120 KB)。
|
||||
expect(find.textContaining('owndevic'), findsOneWidget);
|
||||
expect(find.textContaining('2 KB'), findsOneWidget);
|
||||
expect(find.textContaining('otherdev'), findsOneWidget);
|
||||
expect(find.textContaining('5120 KB'), findsOneWidget);
|
||||
|
||||
// 状态标签(会话状态 + 处理状态)。
|
||||
expect(find.text('Confirmed'), findsOneWidget);
|
||||
expect(find.text('Pending'), findsOneWidget);
|
||||
expect(find.text('Ready'), findsOneWidget);
|
||||
expect(find.text('Processing'), findsOneWidget);
|
||||
|
||||
// 设备短码(本机设备aaaaaaaa + 「本设备」;他机无标记)。
|
||||
expect(find.textContaining(_kOwnDevice.substring(0, 8)), findsOneWidget);
|
||||
expect(find.textContaining(_kOtherDevice.substring(0, 8)), findsOneWidget);
|
||||
expect(find.textContaining('(This device)'), findsOneWidget);
|
||||
|
||||
// listUploads 请求带分页 limit=20。
|
||||
expect(adapter.requests.single.uri.queryParameters['limit'], '20');
|
||||
});
|
||||
|
||||
testWidgets('游标加载更多(滚动触底 → 第二页带 cursor)', (tester) async {
|
||||
final page1 = List<Map<String, dynamic>>.generate(
|
||||
20,
|
||||
(i) => sessionJson(
|
||||
uploadId: 'upl-p1-$i',
|
||||
clientTaskId: 'page1row$i.padLeft-000000000000000000001',
|
||||
),
|
||||
);
|
||||
final adapter = FakeAdapter((o) {
|
||||
final cursor = o.uri.queryParameters['cursor'];
|
||||
if (cursor == null) {
|
||||
return (200, cursorPageBody(page1, nextCursor: 'hc1'));
|
||||
}
|
||||
if (cursor == 'hc1') {
|
||||
return (
|
||||
200,
|
||||
cursorPageBody(
|
||||
[sessionJson(clientTaskId: 'page2item000000000000000000000001')],
|
||||
),
|
||||
);
|
||||
}
|
||||
throw StateError('unexpected cursor $cursor');
|
||||
});
|
||||
|
||||
await pumpPage(tester, clientOf(adapter));
|
||||
expect(adapter.requests, hasLength(1), reason: '首页无 cursor');
|
||||
|
||||
await tester.drag(find.byType(ListView), const Offset(0, -6000));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(adapter.requests.last.uri.queryParameters['cursor'], 'hc1');
|
||||
expect(find.textContaining('page2ite'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('加载失败展示错误与重试;恢复后重试成功', (tester) async {
|
||||
var failFirst = true;
|
||||
final adapter = FakeAdapter((o) {
|
||||
if (failFirst) {
|
||||
throw DioException(
|
||||
requestOptions: RequestOptions(path: o.path),
|
||||
type: DioExceptionType.connectionError,
|
||||
);
|
||||
}
|
||||
return (200, cursorPageBody([sessionJson()]));
|
||||
});
|
||||
|
||||
await pumpPage(tester, clientOf(adapter));
|
||||
expect(find.textContaining('Network error'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.cloud_off), findsOneWidget);
|
||||
|
||||
failFirst = false;
|
||||
await tester.tap(find.text('Retry'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('Network error'), findsNothing);
|
||||
expect(find.textContaining('clientta'), findsOneWidget, reason: '重试后行渲染');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user