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('双档位:同物种合并一张卡,下载只启用目标档,使用即停旧档', (tester) async { final m = manager([_item(), _item(variant: kVariantN)]); await tester.runAsync(() => m.refresh()); await pumpSection(tester, m); // 合并卡:同一数据集只一张卡,无双卡/无档位角标;双档行标注两档 expect(find.text('数据集A'), findsOneWidget); expect(find.text('下载'), findsOneWidget, reason: '合并卡只有一个主按钮'); expect(find.textContaining('高精度 v1.0.0'), findsOneWidget); expect(find.textContaining('高性能 v1.0.0'), findsOneWidget); // 一次下载取回双档:同物种只启用目标档(默认高精度 s),n 档备好不启用。 // 双档下载收尾链交错,等 models 真正重载完成再断言 await tapVisible(tester, find.text('下载')); await pumpUntilFound(tester, find.text('使用中')); for (var i = 0; i < 50 && !(m.isDownloaded(7, kVariantS) && m.isDownloaded(7, kVariantN) && m.isActive(7, kVariantS) && !m.isActive(7, kVariantN) && m.models.length == 1 && m.models.first.variant == kVariantS); i++) { await pumpRealIo(tester); } await tester.pumpAndSettle(); expect(m.isDownloaded(7, kVariantS), isTrue); expect(m.isDownloaded(7, kVariantN), isTrue); expect(m.isActive(7, kVariantS), isTrue); expect(m.isActive(7, kVariantN), isFalse, reason: '同物种同时只运行目标档,另一档备好不叠加启用'); expect(m.mode, kVariantS, reason: '下载双档不应改变目标档'); expect(m.models.length, 1); expect(m.models.first.variant, kVariantS); expect(find.text('使用中'), findsOneWidget); // 目标档切到高性能:运行中的 s 不受影响;n 已下载未启用 → 主按钮「使用」 // (保存目标档偏好是真实 IO,等按钮呈现才算 UI 落定;2026-09-03 简化: // 不再叫「改用X」、不显示「当前使用 X」提示) await tapVisible(tester, find.text('高性能')); await pumpUntilFound(tester, find.text('使用')); await tester.pumpAndSettle(); expect(m.mode, kVariantN); expect(m.isActive(7, kVariantS), isTrue, reason: '切换目标档不应改变运行中的模型'); expect(find.text('使用'), findsOneWidget); expect(find.textContaining('当前使用'), findsNothing); // 点「使用」:同物种 s 自动停用,n 热启用(setActive 尾部的落盘 // 与 notify 是真实 IO,等 UI 呈现「使用中」才算整链落定) await tapVisible(tester, find.text('使用')); await pumpUntilFound(tester, find.text('使用中')); await tester.pumpAndSettle(); expect(m.isActive(7, kVariantS), isFalse, reason: '启用目标档自动停用另一档'); expect(m.isActive(7, kVariantN), isTrue); expect(m.models.length, 1); expect(m.models.first.variant, kVariantN); expect(find.text('使用中'), findsOneWidget, reason: '主按钮跟随当前档'); }); testWidgets('不同动物可用不同档位:使用只作用于点击的动物', (tester) async { final m = manager([ _item(datasetId: 7, name: '数据集A'), _item(datasetId: 7, variant: kVariantN, name: '数据集A'), _item(datasetId: 8, name: '数据集B'), _item(datasetId: 8, variant: kVariantN, name: '数据集B'), ]); await tester.runAsync(() => m.refresh()); // 两个物种都下载并运行默认档(高精度 s),各自 n 档备好 for (final it in m.catalog) { await tester.runAsync(() => m.downloadModel(it)); } await pumpSection(tester, m); expect(m.models.length, 2); expect(find.text('使用中'), findsNWidgets(2)); // 目标档切高性能:两卡主按钮都变「使用」(各自 s 仍在运行、n 已备好) await tapVisible(tester, find.text('高性能')); await pumpUntilFound(tester, find.text('使用')); expect(find.text('使用'), findsNWidgets(2)); // 只点第一张卡(数据集 7,目录序在前):该动物切到高性能,另一动物保持高精度 await tapVisible(tester, find.text('使用').first); await pumpUntilFound(tester, find.text('使用中')); // 数据集 7 的卡先翻为使用中 for (var i = 0; i < 50 && !(m.models.length == 2 && m.models.firstWhere((b) => b.datasetId == 7).variant == kVariantN && m.models.firstWhere((b) => b.datasetId == 8).variant == kVariantS); i++) { await pumpRealIo(tester); } await tester.pumpAndSettle(); expect(m.isActive(7, kVariantN), isTrue); expect(m.isActive(7, kVariantS), isFalse, reason: '启用目标档自动停用同动物另一档'); expect(m.isActive(8, kVariantS), isTrue, reason: '未点击的动物保持原档位'); expect(m.isActive(8, kVariantN), isFalse); expect(m.models.length, 2, reason: '两个动物仍同时识别'); expect(m.models.firstWhere((b) => b.datasetId == 7).variant, kVariantN); expect(m.models.firstWhere((b) => b.datasetId == 8).variant, kVariantS); expect(find.text('使用中'), findsOneWidget, reason: '只有数据集 7 的卡使用中'); expect(find.text('使用'), findsOneWidget, reason: '数据集 8 目标档已备好:按钮就是使用(不叫改用)'); expect(find.textContaining('当前使用'), findsNothing); }); testWidgets('识别模式分段控件:只改目标档偏好,不影响已在运行的模型', (tester) async { final m = manager([_item(), _item(variant: kVariantN)]); await tester.runAsync(() => m.refresh()); await tester.runAsync(() => m.downloadModel(m.catalog.first)); // s 启用 await pumpSection(tester, m); expect(find.text('高精度'), findsOneWidget, reason: '分段控件选中默认档'); expect(find.text('高性能'), findsOneWidget); expect(find.text('使用中'), findsOneWidget); await tapVisible(tester, find.text('高性能')); // 目标档 n 尚未下载(之前只下了 s)→ 主按钮变为「下载」:仍不切运行模型 await pumpUntilFound(tester, find.text('下载')); await tester.pumpAndSettle(); expect(m.mode, kVariantN, reason: '点击分段控件应改目标档'); expect(m.isActive(7, kVariantS), isTrue, reason: '只改偏好不切运行模型'); expect(m.models.length, 1); expect(m.models.first.variant, kVariantS, reason: '没有热加载/切换'); expect(find.text('下载'), findsOneWidget, reason: '卡片按钮面向新目标档'); expect(find.textContaining('当前使用'), findsNothing); // 目标档偏好持久化由 model_manager_test「识别目标档位偏好持久化」覆盖; // widget 测试内 tap 触发的文件写与真实 IO 交错不可控,不在本层断言重启 }); }