import 'dart:convert'; import 'dart:io'; import 'package:crypto/crypto.dart' show sha256; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.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(); /// 分两段到达的下载流(中间 50ms 停顿,供取消测试在下载中触发) Stream> _delayedChunks() async* { yield [1, 2, 3, 4]; await Future.delayed(const Duration(milliseconds: 50)); yield [5, 6, 7, 8]; } Map _catalog(List> models) => { 'code': 0, 'message': 'ok', 'data': {'version': '0.0.2', 'notes': '', 'models': models} }; Map _item({ int datasetId = 7, String variant = kVariantS, String name = '数据集A', String version = 'v1.0.0', String sha = '', }) => { 'datasetId': datasetId, 'datasetName': name, if (variant.isNotEmpty) 'variant': variant, 'version': version, 'labels': ['target', 'suspect'], 'sizeBytes': _modelBytes.length, 'sha256': sha.isEmpty ? _shaHex(_modelBytes) : sha, 'downloadUrl': '/download/models/$datasetId/$variant.tflite', 'coverUrl': '/api/v1/app/cover?namePrefix=DS001', }; void main() { late Directory root; late int downloadHits; setUp(() async { root = await Directory.systemTemp.createTemp('model-manager-test'); downloadHits = 0; }); tearDown(() async { // refresh 末尾的 autoUpdate 为不阻塞目录刷新的 fire-and-forget:其真实 // 文件 IO 可能晚于 test body 结束,delete 撞上迟到写入会 Directory not // empty → 等待后重试清根(迟到链结束后即可删净) for (var i = 0; i < 40; i++) { try { await root.delete(recursive: true); return; } on FileSystemException { await Future.delayed(const Duration(milliseconds: 10)); } } await root.delete(recursive: true); }); ModelManager manager(MockClient client) => ModelManager( baseUrl: 'http://test.local', client: client, rootDir: () async => root, ); MockClient client(List> models) => MockClient((req) async { if (req.url.path == '/api/v1/app/update') { // Response(String) 默认 latin1 编码,中文数据集名会抛异常 → 用 bytes return http.Response.bytes( utf8.encode(jsonEncode(_catalog(models))), 200); } if (req.url.path.startsWith('/download/models/')) { downloadHits++; return http.Response.bytes(_modelBytes, 200); } return http.Response('not found', 404); }); test('refresh 只拉目录:不下载任何模型,coverUrl 解析正确', () async { final m = manager(client([_item()])); await m.refresh(); expect(m.ready, isTrue); expect(m.error, isNull); expect(downloadHits, 0, reason: 'refresh 不应触发下载'); expect(m.models, isEmpty, reason: '未激活的模型不应出现在 models'); expect(m.catalog.length, 1); expect(m.catalog.first.coverUrl, '/api/v1/app/cover?namePrefix=DS001'); expect(m.catalog.first.variant, kVariantS, reason: '旧目录无 variant 字段(单档 s)应归为 s'); }); test('downloadModel:下载+校验+落盘+自动激活+进度回调', () async { final m = manager(client([_item()])); await m.refresh(); final progresses = []; final ok = await m.downloadModel(m.catalog.first, onProgress: (r, t) { progresses.add(t == 0 ? 0 : r / t); }); expect(ok, isTrue); expect(downloadHits, 1); expect(m.isDownloaded(7, kVariantS), isTrue); expect(m.isActive(7, kVariantS), isTrue, reason: '下载完成应自动使用'); expect(progresses.last, 1.0); expect(m.models.length, 1); expect(m.models.first.datasetName, '数据集A'); expect(m.models.first.variant, kVariantS); final dir = Directory('${root.path}/7'); expect(await File('${dir.path}/model.tflite').exists(), isTrue); expect(await File('${dir.path}/labels.json').exists(), isTrue); expect(await File('${dir.path}/meta.json').exists(), isTrue); }); test('已下载未激活:setActive 直接使用,不触发下载', () async { final m = manager(client([_item()])); await m.refresh(); await m.downloadModel(m.catalog.first); await m.setActive(7, kVariantS, false); expect(m.isActive(7, kVariantS), isFalse); expect(m.models, isEmpty); final before = downloadHits; await m.setActive(7, kVariantS, true); expect(m.isActive(7, kVariantS), isTrue); expect(downloadHits, before, reason: '已下载直接使用不应重新下载'); expect(m.models.length, 1); }); test('版本更新:downloadModel 重新下载', () async { final m = manager(client([_item()])); await m.refresh(); await m.downloadModel(m.catalog.first); expect(downloadHits, 1); final m2 = manager(client([_item(version: 'v2.0.0')])); await m2.refresh(); await m2.downloadModel(m2.catalog.first); expect(downloadHits, 2); expect(m2.models.first.version, 'v2.0.0'); }); test('autoUpdate:已下载模型出新版本,自动补齐文件但不改变激活状态', () async { final m = manager(client([_item()])); await m.refresh(); await m.downloadModel(m.catalog.first); // (7, s) v1 下载并激活 expect(downloadHits, 1); // 模拟新会话(新实例 + 服务器目录出 v2):文件仍在 → autoUpdate 只补文件 final m2 = manager(client([_item(version: 'v2.0.0')])); await m2.refresh(); for (var i = 0; i < 50 && downloadHits < 2; i++) { await Future.delayed(const Duration(milliseconds: 20)); } expect(downloadHits, 2, reason: '新版本应自动补齐(只更新文件不激活)'); expect(m2.isDownloaded(7, kVariantS), isTrue); expect(m2.isActive(7, kVariantS), isFalse, reason: '会话制:自动补齐不激活任何模型'); expect(m2.models, isEmpty, reason: '新会话从仅预览开始'); // 手动启用后跑的是新版本 await m2.setActive(7, kVariantS, true); expect(m2.models.first.version, 'v2.0.0'); expect(m2.models.first.bytes, _modelBytes); }); test('sha256 不匹配:重试后失败、不激活、错误可见', () async { final m = manager(client([_item()])); await m.refresh(); final bad = _item(version: 'v3.0.0'); bad['sha256'] = _shaHex([9, 9, 9]); final m2 = manager(client([bad])); await m2.refresh(); final ok = await m2.downloadModel(m2.catalog.first); expect(ok, isFalse); expect(downloadHits, 2, reason: '校验失败应重试一次'); expect(m2.isDownloaded(7, kVariantS), isFalse); expect(m2.isActive(7, kVariantS), isFalse); expect(m2.errorOf(7, kVariantS), isNotNull); }); test('会话制:进入新会话清空激活,文件保留,再启用不重新下载', () async { final m = manager(client([_item()])); await m.refresh(); await m.downloadModel(m.catalog.first); expect(m.isActive(7, kVariantS), isTrue); // 每次进入视野页 = 新会话:清空激活与已加载模型(不恢复上次使用的模型) m.resetForSession(); expect(m.isActive(7, kVariantS), isFalse, reason: '新会话不自动恢复上次使用的模型'); expect(m.models, isEmpty); expect(m.isDownloaded(7, kVariantS), isTrue, reason: '文件保留:清单显示已下载'); // 手动启用:直接用本地文件,不重新下载 final before = downloadHits; await m.setActive(7, kVariantS, true); expect(m.isActive(7, kVariantS), isTrue); expect(m.models.length, 1); expect(downloadHits, before, reason: '已下载启用不应重新下载'); }); test('目录下线:清理本地并移除激活', () async { final m = manager(client([_item()])); await m.refresh(); await m.downloadModel(m.catalog.first); expect(await Directory('${root.path}/7').exists(), isTrue); final m2 = manager(client([])); await m2.refresh(); expect(m2.models, isEmpty); expect(m2.isActive(7, kVariantS), isFalse, reason: '下线的模型应移出激活集'); expect(await Directory('${root.path}/7').exists(), isFalse, reason: '下线的数据集模型目录应被清理'); }); test('多模型:只激活其一则只加载其一', () async { final m = manager( client([_item(datasetId: 7, name: '数据集A'), _item(datasetId: 8, name: '数据集B')])); await m.refresh(); await m.downloadModel(m.catalog.first); // 只下载并激活 7 expect(m.models.length, 1); expect(m.models.first.datasetId, 7); await m.downloadModel(m.catalog.last); // 下载 8 自动激活 expect(m.models.length, 2); }); test('目录接口异常:不覆盖已有就绪状态', () async { final m = manager(client([_item()])); await m.refresh(); await m.downloadModel(m.catalog.first); expect(m.ready, isTrue); final broken = manager(MockClient((_) async => http.Response('boom', 500))); await broken.refresh(); expect(broken.ready, isFalse); expect(broken.error, isNotNull); }); test('取消下载:中断后恢复未下载、不记错误', () async { final m = manager(MockClient((req) async { if (req.url.path == '/api/v1/app/update') { return http.Response.bytes( utf8.encode(jsonEncode(_catalog([_item()]))), 200); } if (req.url.path.startsWith('/download/models/')) { downloadHits++; return http.Response.fromStream(http.StreamedResponse( _delayedChunks(), 200, contentLength: _modelBytes.length)); } return http.Response('not found', 404); })); await m.refresh(); final fut = m.downloadModel(m.catalog.first); // 首个分块到达后取消(模拟用户在下载中点取消) await Future.delayed(const Duration(milliseconds: 20)); m.cancelDownload(7, kVariantS); final ok = await fut; expect(ok, isFalse); expect(downloadHits, 1); expect(m.isDownloaded(7, kVariantS), isFalse); expect(m.isActive(7, kVariantS), isFalse); expect(m.progressOf(7, kVariantS), isNull); expect(m.errorOf(7, kVariantS), isNull, reason: '取消不记错误'); expect(await File('${root.path}/7/model.tflite.part').exists(), isFalse, reason: '取消后 .part 残留应被清理'); }); test('双档位:n 档独立目录 models/7/n/,同物种只运行目标档', () async { final m = manager(client( [_item(), _item(variant: kVariantN, name: '数据集A', version: 'v1.0.0')])); await m.refresh(); expect(m.catalog.length, 2); // 默认目标档 s:下载 s 自动激活并加载 await m.downloadModel(m.catalog.first); // (7, s) expect(m.models.length, 1); expect(m.models.first.variant, kVariantS); // 同物种已有 s 在使用时下载 n:备好不启用(同物种一次只运行一档) final nItem = m.catalog.last; expect(nItem.variant, kVariantN); await m.downloadModel(nItem); expect(m.isDownloaded(7, kVariantN), isTrue); expect(m.isActive(7, kVariantN), isFalse, reason: '同物种运行中不叠加启用'); expect(m.models.length, 1, reason: '备好的 n 档不进加载列表'); expect(m.models.first.variant, kVariantS); // n 档文件在独立子目录(目录键 = 档位),s 档仍为同级文件 expect(await File('${root.path}/7/n/model.tflite').exists(), isTrue); expect(await File('${root.path}/7/n/meta.json').exists(), isTrue); expect(await File('${root.path}/7/model.tflite').exists(), isTrue, reason: 's 档 legacy 同级布局保持不变'); // 切目标档:只改偏好,不切换运行中的模型 final before = downloadHits; await m.setMode(kVariantN); expect(m.mode, kVariantN); expect(downloadHits, before, reason: '切目标档不应触发下载'); expect(m.isActive(7, kVariantS), isTrue, reason: '切档不改运行状态'); expect(m.models.length, 1); expect(m.models.first.variant, kVariantS); // 改用 n:同物种 s 自动停用,n 启用(无需重新下载) await m.setActive(7, kVariantN, true); 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(m.models.first.datasetName, '数据集A'); // 切回 s 档恢复 s 模型 await m.setActive(7, kVariantS, true); expect(m.models.length, 1); expect(m.models.first.variant, kVariantS); expect(m.isActive(7, kVariantN), isFalse); }); test('识别目标档位偏好持久化;激活集为会话态不跨重启', () async { final m = manager(client( [_item(), _item(variant: kVariantN, name: '数据集A', version: 'v1.0.0')])); await m.refresh(); await m.downloadModel(m.catalog.first); // (7, s) 下载自动激活 await m.downloadModel(m.catalog.last); // (7, n) 备好不启用 await m.setMode(kVariantN); // 同一 root 新建 manager 模拟重启:目标档偏好持久化,激活集不恢复 final m2 = manager(client( [_item(), _item(variant: kVariantN, name: '数据集A', version: 'v1.0.0')])); await m2.refresh(); expect(m2.mode, kVariantN, reason: '识别目标档位是用户偏好,应持久化'); expect(m2.isActive(7, kVariantS), isFalse, reason: '激活集不跨会话恢复'); expect(m2.isActive(7, kVariantN), isFalse); expect(m2.models, isEmpty, reason: '重启/新会话从仅预览开始'); expect(m2.isDownloaded(7, kVariantS), isTrue, reason: '文件保留可即点即用'); expect(m2.isDownloaded(7, kVariantN), isTrue); expect(downloadHits, 2, reason: '重启不应触发下载'); }); test('多物种混合档位:改用一物种不影响其它物种的档位', () async { final m = manager(client([ _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 m.refresh(); for (final it in m.catalog) { await m.downloadModel(it); } expect(m.models.length, 2); expect(m.isActive(7, kVariantS), isTrue); expect(m.isActive(8, kVariantS), isTrue); // 只把 7 改到 n:8 保持 s —— 两物种不同档位并行运行 await m.setActive(7, kVariantN, true); expect(m.models.length, 2); final byId = {for (final b in m.models) b.datasetId: b.variant}; expect(byId, {7: kVariantN, 8: kVariantS}); expect(m.isActive(7, kVariantS), isFalse); expect(m.isActive(8, kVariantS), isTrue); expect(m.isActive(8, kVariantN), isFalse); // 7 改回 s await m.setActive(7, kVariantS, true); expect(m.models.length, 2); expect(m.models.firstWhere((b) => b.datasetId == 7).variant, kVariantS); }); test('目标档决定首次下载自动启用:非目标档先落地不启用,目标档落地启用', () async { final m = manager(client( [_item(), _item(variant: kVariantN, name: '数据集A', version: 'v1.0.0')])); await m.refresh(); await m.setMode(kVariantN); // 目标高性能 // 先下 s(非目标档):数据集尚无档位在使用,但不应启用 s await m.downloadModel(m.catalog.first); expect(m.isDownloaded(7, kVariantS), isTrue); expect(m.isActive(7, kVariantS), isFalse, reason: '非目标档落地不自动启用'); expect(m.models, isEmpty); // 目标档 n 落地:自动启用(s 保持备好) await m.downloadModel(m.catalog.last); expect(m.isActive(7, kVariantN), isTrue); expect(m.isActive(7, kVariantS), isFalse); expect(m.models.length, 1); expect(m.models.first.variant, kVariantN); }); test('单档目录(无目标档条目):下载即自动启用仅有的档', () async { final m = manager(client([_item()])); // 目录只有 s await m.refresh(); await m.setMode(kVariantN); // 目标高性能但目录没有 n await m.downloadModel(m.catalog.first); expect(m.isActive(7, kVariantS), isTrue, reason: '无目标档时启用仅有的档'); expect(m.models.length, 1); }); test('数据集仍在但 n 档下线:清理 n 档子目录,保留数据集目录', () async { final m = manager(client([_item(variant: kVariantN)])); await m.refresh(); await m.downloadModel(m.catalog.first); // (7, n) expect(await File('${root.path}/7/n/model.tflite').exists(), isTrue); final m2 = manager(client([_item()])); // 目录只剩 s 档 await m2.refresh(); expect(await File('${root.path}/7/n/model.tflite').exists(), isFalse, reason: '数据集仍在但 n 档下线:应清理 n 档子目录'); expect(await Directory('${root.path}/7').exists(), isTrue, reason: '数据集仍在(s 档):不应删整目录'); }); test('目录缓存:断网重启后展示上次目录、标出已下载、保留文件且不恢复激活', () async { final m = manager(client([_item()])); await m.refresh(); await m.downloadModel(m.catalog.first); // (7, s) 下载并激活 expect(m.isDownloaded(7, kVariantS), isTrue); // 模拟重启 + 断网:同 root 新实例、目录接口失败 final offline = manager(MockClient((_) async => http.Response('boom', 500))); await offline.refresh(); expect(offline.catalog.length, 1, reason: '无网络也应以缓存目录展示模型清单'); expect(offline.catalog.first.datasetId, 7); expect(offline.error, isNotNull, reason: '未拉取成功过仍提示目录刷新失败'); expect(offline.ready, isFalse); expect(offline.isDownloaded(7, kVariantS), isTrue, reason: '缓存目录同样扫描本地文件:按钮不误导为未下载'); expect(offline.isActive(7, kVariantS), isFalse, reason: '会话制:不恢复激活'); expect(offline.models, isEmpty); expect(await Directory('${root.path}/7').exists(), isTrue, reason: '离线降级(非权威目录)不得清理本地模型'); }); test('目录缓存:网络成功覆盖缓存,再离线读到最近一次目录', () async { final m = manager(client([_item(datasetId: 7, name: '数据集A')])); await m.refresh(); // 缓存 v1:只有数据集 7 的 s 档 final m2 = manager(client([ _item(datasetId: 7, name: '数据集A'), _item(datasetId: 7, variant: kVariantN, name: '数据集A'), _item(datasetId: 8, name: '数据集B'), ])); await m2.refresh(); expect(m2.catalog.length, 3); final offline = manager(MockClient((_) async => http.Response('boom', 500))); await offline.refresh(); expect(offline.catalog.length, 3, reason: '缓存应为最近一次成功拉取的目录'); expect(offline.catalog.map((c) => c.datasetId).toSet(), {7, 8}); expect( offline.catalog .where((c) => c.datasetId == 7) .map((c) => c.variant) .toSet(), {kVariantS, kVariantN}); }); test('升级后断网首启(无缓存文件):拉取失败不执行任何清理', () async { // 旧版本下载的文件直接落盘(无 catalog.json):升级首启即断网的最坏情况 final dir = Directory('${root.path}/9'); await dir.create(recursive: true); await File('${dir.path}/model.tflite').writeAsBytes(_modelBytes); await File('${dir.path}/meta.json') .writeAsString(jsonEncode({'version': 'v1.0.0'})); final offline = manager(MockClient((_) async => http.Response('boom', 500))); await offline.refresh(); expect(offline.error, isNotNull); expect(offline.catalog, isEmpty, reason: '无缓存可兜底:无可展示目录'); expect(await dir.exists(), isTrue, reason: '清理只在网络拉取成功后执行,离线首启不得误删已下载模型'); }); }