import 'dart:convert'; import 'dart:io'; import 'package:crypto/crypto.dart' show sha256; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:observer/camera/model_catalog_section.dart'; import 'package:observer/models/model_manager.dart'; const _modelBytes = [1, 2, 3, 4, 5, 6, 7, 8]; String _shaHex(List bytes) => sha256.convert(bytes).toString(); /// 分两段到达的下载流(中间 500ms 停顿:足够「取消」按钮出现并点击, /// 且第二次分块到达前取消标志必然已置位——每轮 pump 只推进 400ms fake 时钟) Stream> _delayedChunks() async* { yield [1, 2, 3, 4]; await Future.delayed(const Duration(milliseconds: 500)); yield [5, 6, 7, 8]; } Map _item({ int datasetId = 7, String variant = kVariantS, String name = '数据集A', }) => { 'datasetId': datasetId, 'datasetName': name, if (variant.isNotEmpty) 'variant': variant, 'version': 'v1.0.0', 'labels': ['target', 'suspect'], 'sizeBytes': _modelBytes.length, 'sha256': _shaHex(_modelBytes), 'downloadUrl': '/download/models/$datasetId/$variant.tflite', 'coverUrl': '/api/v1/app/cover?namePrefix=DS001', }; Map _catalog(List> models) => { 'code': 0, 'message': 'ok', 'data': {'models': models} }; /// 真实流式下载客户端:send 立即返回分块流(MockClient 的 Response.fromStream /// 会先消费完整流再返回,导致进度永远停在 0)。 class _StreamingClient extends http.BaseClient { @override Future send(http.BaseRequest request) async { if (request.url.path == '/api/v1/app/update') { return http.StreamedResponse( http.ByteStream.fromBytes(utf8.encode( jsonEncode(_catalog([_item()])))), 200); } return http.StreamedResponse(_delayedChunks(), 200, contentLength: _modelBytes.length); } } void main() { late Directory root; setUp(() async { root = await Directory.systemTemp.createTemp('catalog-section-test'); }); tearDown(() => root.delete(recursive: true)); ModelManager manager(List> 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(_catalog(models))), 200); } if (req.url.path.startsWith('/download/models/')) { return http.Response.bytes(_modelBytes, 200); } return http.Response('not found', 404); }), rootDir: () async => root, ); /// 推进真实异步链:pump 冲刷 fake 微任务队列(并推进 fake 时钟触发 fake 定时器), /// 再用 runAsync 让真实事件循环转动,送达文件 IO 完成事件。 /// testWidgets 的 fake-async 区中发起的真实 IO 必须这样交替驱动,否则异步链卡死。 Future pumpRealIo(WidgetTester tester, {Duration fake = Duration.zero}) async { await tester.pump(fake); await tester.runAsync( () => Future.delayed(const Duration(milliseconds: 5))); await tester.pump(); } /// 交替 pump/真实等待,直至 finder 命中(UI 呈现目标状态)。 Future pumpUntilFound(WidgetTester tester, Finder finder, {Duration fake = Duration.zero}) async { for (var i = 0; i < 100 && finder.evaluate().isEmpty; i++) { await pumpRealIo(tester, fake: fake); } } Future pumpSection(WidgetTester tester, ModelManager m) async { // 与设置弹层一致:区块置于可滚动容器(区块含识别模式行后超出测试视口, // 点按前需 ensureVisible 滚动到目标) await tester.pumpWidget(MaterialApp( home: Scaffold( body: SingleChildScrollView( child: ModelCatalogSection(manager: m))))); await tester.pumpAndSettle(const Duration(milliseconds: 50), EnginePhase.sendSemanticsUpdate, const Duration(seconds: 5)); } /// 滚动到目标可见后点按(区块可超出视口) Future tapVisible(WidgetTester tester, Finder finder) async { await tester.ensureVisible(finder); await tester.pump(); await tester.tap(finder); } testWidgets('未下载:显示使用按钮,点击后下载完成自动变为已使用', (tester) async { final m = manager([_item()]); await tester.runAsync(() => m.refresh()); await pumpSection(tester, m); expect(find.text('数据集A'), findsOneWidget); expect(find.text('使用'), findsOneWidget); await tapVisible(tester, find.text('使用')); await tester.pump(); // 下载启动,进度条出现 // 驱动下载 + 自动激活的 IO 链走完,直至 UI 呈现「已使用」 await pumpUntilFound(tester, find.text('已使用')); await tester.pumpAndSettle(); expect(m.isDownloaded(7, kVariantS), isTrue); expect(m.isActive(7, kVariantS), isTrue); expect(find.text('已使用'), findsOneWidget); }); testWidgets('已激活:再次点击取消使用', (tester) async { 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 tapVisible(tester, find.text('已使用')); // 排空 setActive 的激活集重载与落盘 IO,直至 UI 呈现「使用」 await pumpUntilFound(tester, find.text('使用')); await tester.pumpAndSettle(); expect(m.isActive(7, kVariantS), isFalse); expect(find.text('使用'), findsOneWidget); }); testWidgets('目录为空:显示暂无已发布模型', (tester) async { final m = ModelManager( baseUrl: 'http://test.local', client: MockClient((_) async => http.Response.bytes( utf8.encode(jsonEncode(_catalog([]))), 200)), rootDir: () async => root, ); await tester.runAsync(() => m.refresh()); await pumpSection(tester, m); expect(find.text('暂无已发布模型'), findsOneWidget); }); testWidgets('下载中:显示进度和取消按钮,取消后恢复使用', (tester) async { final m = ModelManager( baseUrl: 'http://test.local', client: _StreamingClient(), rootDir: () async => root, ); await tester.runAsync(() => m.refresh()); await pumpSection(tester, m); await tapVisible(tester, find.text('使用')); // 首个分块到达,进度条与取消按钮出现(IO 链需交替驱动) await pumpUntilFound(tester, find.text('取消'), fake: const Duration(milliseconds: 400)); expect(find.text('取消'), findsOneWidget); 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, 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 交错不可控,不在本层断言重启 }); }