This commit is contained in:
2026-09-03 10:01:42 +08:00
parent 71d042906c
commit 7bfafc7be3
18 changed files with 671 additions and 228 deletions
+110 -33
View File
@@ -20,15 +20,27 @@ Stream<List<int>> _delayedChunks() async* {
yield [5, 6, 7, 8];
}
Map<String, dynamic> _item() => {
'datasetId': 7,
'datasetName': '环颈雉鸡数据集',
Map<String, dynamic> _item({
int datasetId = 7,
String variant = kVariantS,
String name = '数据集A',
}) =>
{
'datasetId': datasetId,
'datasetName': name,
if (variant.isNotEmpty) 'variant': variant,
'version': 'v1.0.0',
'labels': ['pheasant', 'suspect'],
'labels': ['target', 'suspect'],
'sizeBytes': _modelBytes.length,
'sha256': _shaHex(_modelBytes),
'downloadUrl': '/download/models/7/latest.tflite',
'coverUrl': '/api/v1/app/cover?namePrefix=RNPHE',
'downloadUrl': '/download/models/$datasetId/$variant.tflite',
'coverUrl': '/api/v1/app/cover?namePrefix=DS001',
};
Map<String, dynamic> _catalog(List<Map<String, dynamic>> models) => {
'code': 0,
'message': 'ok',
'data': {'models': models}
};
/// 真实流式下载客户端:send 立即返回分块流(MockClient 的 Response.fromStream
@@ -38,11 +50,8 @@ class _StreamingClient extends http.BaseClient {
Future<http.StreamedResponse> send(http.BaseRequest request) async {
if (request.url.path == '/api/v1/app/update') {
return http.StreamedResponse(
http.ByteStream.fromBytes(utf8.encode(jsonEncode({
'code': 0,
'message': 'ok',
'data': {'models': [_item()]}
}))),
http.ByteStream.fromBytes(utf8.encode(
jsonEncode(_catalog([_item()])))),
200);
}
return http.StreamedResponse(_delayedChunks(), 200,
@@ -59,17 +68,12 @@ void main() {
tearDown(() => root.delete(recursive: true));
ModelManager manager() => ModelManager(
ModelManager manager(List<Map<String, dynamic>> models) => ModelManager(
baseUrl: 'http://test.local',
client: MockClient((req) async {
if (req.url.path == '/api/v1/app/update') {
return http.Response.bytes(
utf8.encode(jsonEncode({
'code': 0,
'message': 'ok',
'data': {'models': [_item()]}
})),
200);
utf8.encode(jsonEncode(_catalog(models))), 200);
}
if (req.url.path.startsWith('/download/models/')) {
return http.Response.bytes(_modelBytes, 200);
@@ -99,44 +103,55 @@ void main() {
}
Future<void> pumpSection(WidgetTester tester, ModelManager m) async {
// 与设置弹层一致:区块置于可滚动容器(区块含识别模式行后超出测试视口,
// 点按前需 ensureVisible 滚动到目标)
await tester.pumpWidget(MaterialApp(
home: Scaffold(body: ModelCatalogSection(manager: m))));
home: Scaffold(
body: SingleChildScrollView(
child: ModelCatalogSection(manager: m)))));
await tester.pumpAndSettle(const Duration(milliseconds: 50),
EnginePhase.sendSemanticsUpdate, const Duration(seconds: 5));
}
/// 滚动到目标可见后点按(区块可超出视口)
Future<void> tapVisible(WidgetTester tester, Finder finder) async {
await tester.ensureVisible(finder);
await tester.pump();
await tester.tap(finder);
}
testWidgets('未下载:显示使用按钮,点击后下载完成自动变为已使用', (tester) async {
final m = manager();
final m = manager([_item()]);
await tester.runAsync(() => m.refresh());
await pumpSection(tester, m);
expect(find.text('环颈雉鸡数据集'), findsOneWidget);
expect(find.text('数据集A'), findsOneWidget);
expect(find.text('使用'), findsOneWidget);
await tester.tap(find.text('使用'));
await tapVisible(tester, find.text('使用'));
await tester.pump(); // 下载启动,进度条出现
// 驱动下载 + 自动激活的 IO 链走完,直至 UI 呈现「已使用」
await pumpUntilFound(tester, find.text('已使用'));
await tester.pumpAndSettle();
expect(m.isDownloaded(7), isTrue);
expect(m.isActive(7), isTrue);
expect(m.isDownloaded(7, kVariantS), isTrue);
expect(m.isActive(7, kVariantS), isTrue);
expect(find.text('已使用'), findsOneWidget);
});
testWidgets('已激活:再次点击取消使用', (tester) async {
final m = manager();
final m = manager([_item()]);
await tester.runAsync(() => m.refresh());
await tester.runAsync(() => m.downloadModel(m.catalog.first));
await pumpSection(tester, m);
expect(find.text('已使用'), findsOneWidget);
await tester.tap(find.text('已使用'));
await tapVisible(tester, find.text('已使用'));
// 排空 setActive 的激活集重载与落盘 IO,直至 UI 呈现「使用」
await pumpUntilFound(tester, find.text('使用'));
await tester.pumpAndSettle();
expect(m.isActive(7), isFalse);
expect(m.isActive(7, kVariantS), isFalse);
expect(find.text('使用'), findsOneWidget);
});
@@ -144,8 +159,7 @@ void main() {
final m = ModelManager(
baseUrl: 'http://test.local',
client: MockClient((_) async => http.Response.bytes(
utf8.encode(jsonEncode(
{'code': 0, 'message': 'ok', 'data': {'models': []}})),
utf8.encode(jsonEncode(_catalog([]))),
200)),
rootDir: () async => root,
);
@@ -164,20 +178,83 @@ void main() {
await tester.runAsync(() => m.refresh());
await pumpSection(tester, m);
await tester.tap(find.text('使用'));
await tapVisible(tester, find.text('使用'));
// 首个分块到达,进度条与取消按钮出现(IO 链需交替驱动)
await pumpUntilFound(tester, find.text('取消'),
fake: const Duration(milliseconds: 400));
expect(find.text('取消'), findsOneWidget);
await tester.tap(find.text('取消'));
await tapVisible(tester, find.text('取消'));
// 推进 fake 时钟触发延迟分块 → 取消分支清场(.part 删除等真实 IO),直至恢复「使用」
await pumpUntilFound(tester, find.text('使用'),
fake: const Duration(milliseconds: 400));
await tester.pumpAndSettle();
expect(m.isDownloaded(7), isFalse);
expect(m.isActive(7), isFalse);
expect(m.isDownloaded(7, kVariantS), isFalse);
expect(m.isActive(7, kVariantS), isFalse);
expect(find.text('使用'), findsOneWidget);
});
testWidgets('双档位:s/n 两卡独立,n 档下载备好后切档即用', (tester) async {
final m =
manager([_item(), _item(variant: kVariantN, name: '数据集B')]);
await tester.runAsync(() => m.refresh());
await pumpSection(tester, m);
// 分段控件默认 s;两卡并排,s 卡按钮「使用」,非当前档 n 卡按钮「下载」
expect(find.text('s 高识别'), findsOneWidget);
expect(find.text('数据集A'), findsOneWidget);
expect(find.text('数据集B'), findsOneWidget);
expect(find.text('s'), findsOneWidget, reason: '档位角标');
expect(find.text('n'), findsOneWidget, reason: '档位角标');
expect(find.text('使用'), findsOneWidget);
expect(find.text('下载'), findsOneWidget, reason: '非当前档入口为「下载」');
// 先激活 s 档
await tapVisible(tester, find.text('使用'));
await pumpUntilFound(tester, find.text('已使用'));
expect(m.isActive(7, kVariantS), isTrue);
// 下载 n 档:完成后自动备好(n 非当前档),显示「已备好」而非「已使用」
await tapVisible(tester, find.text('下载'));
await pumpUntilFound(tester, find.text('已备好'));
await tester.pumpAndSettle();
expect(m.isActive(7, kVariantN), isTrue);
expect(m.mode, kVariantS, reason: '下载非当前档不应改变识别档位');
expect(m.models.length, 1);
expect(m.models.first.variant, kVariantS, reason: '当前仍只加载 s 档');
// 点「已备好」切换档位 → n 档立即生效(已使用),s 档转为备好态
await tapVisible(tester, find.text('已备好'));
// 「已使用」切换前后都在场(s 使用 → n 使用),须等 manager 状态真正切档
for (var i = 0;
i < 50 &&
!(m.mode == kVariantN &&
m.models.isNotEmpty &&
m.models.first.variant == kVariantN);
i++) {
await pumpRealIo(tester);
}
await tester.pumpAndSettle();
expect(m.mode, kVariantN);
expect(m.models.length, 1);
expect(m.models.first.variant, kVariantN, reason: '切档后热加载 n 档模型');
expect(find.text('已使用'), findsOneWidget, reason: 'n 档卡已使用');
expect(find.text('已备好'), findsOneWidget, reason: 's 档转为备好态');
});
testWidgets('识别模式分段控件:点击切档生效', (tester) async {
final m = manager([_item(), _item(variant: kVariantN, name: '数据集A')]);
await tester.runAsync(() => m.refresh());
await pumpSection(tester, m);
await tapVisible(tester, find.text('n 高性能'));
for (var i = 0; i < 50 && m.mode != kVariantN; i++) {
await pumpRealIo(tester);
}
await tester.pumpAndSettle();
expect(m.mode, kVariantN, reason: '点击分段控件应切档');
// 档位持久化(重启恢复)由 model_manager_test「识别档位持久化」覆盖;
// widget 测试内 tap 触发的文件写与真实 IO 交错不可控,不在本层断言重启
});
}