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:已激活模型出新版本,refresh 后自动重下并立即生效', () async { final m = manager(client([_item()])); await m.refresh(); await m.downloadModel(m.catalog.first); expect(downloadHits, 1); // 重启 + 服务器目录出 v2:refresh 内部 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.isActive(7, kVariantS), isTrue, reason: '自动更新应保持激活'); expect(m2.models.first.version, 'v2.0.0', reason: '自动更新后立即生效新版本字节'); 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); // 同一 root 新建 manager 模拟重启 final m2 = manager(client([_item()])); await m2.refresh(); expect(m2.isActive(7, kVariantS), isTrue, reason: '激活集应持久化'); expect(m2.models.length, 1); expect(downloadHits, 1, 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); // 非当前档(n)下载:自动备好(激活保留),但 s 档加载列表不变 final nItem = m.catalog.last; expect(nItem.variant, kVariantN); await m.downloadModel(nItem); expect(m.isDownloaded(7, kVariantN), isTrue); expect(m.isActive(7, kVariantN), isTrue, reason: '下载完成应自动备好'); expect(m.models.length, 1, reason: '非当前档模型不应进入加载列表'); 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 同级布局保持不变'); // 切档:热加载 n 档已备模型,s 档不再加载(无需重新下载) final before = downloadHits; await m.setMode(kVariantN); expect(m.mode, kVariantN); expect(downloadHits, before, reason: '切档不应触发下载'); expect(m.models.length, 1); expect(m.models.first.variant, kVariantN); expect(m.models.first.datasetName, '数据集A'); // 切回 s 档恢复 s 模型 await m.setMode(kVariantS); expect(m.models.length, 1); expect(m.models.first.variant, kVariantS); }); 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), isTrue, reason: '激活集应持久化'); expect(m2.models.length, 1); expect(m2.models.first.variant, kVariantN, reason: '重启后加载当前档位模型'); expect(downloadHits, 2, reason: '重启与切档不应触发下载'); }); 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 档):不应删整目录'); }); }