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(); Map _catalog(List> models) => {'code': 0, 'message': 'ok', 'data': {'version': '0.0.2', 'notes': '', 'models': models}}; Map _item({String version = 'v1.0.0', String sha = ''}) => { 'datasetId': 7, 'datasetName': '野鸡数据集', 'version': version, 'labels': ['pheasant', 'suspect'], 'sizeBytes': _modelBytes.length, 'sha256': sha.isEmpty ? _shaHex(_modelBytes) : sha, 'downloadUrl': '/download/models/野鸡数据集/latest.tflite', }; void main() { late Directory root; late int downloadHits; setUp(() async { root = await Directory.systemTemp.createTemp('model-manager-test'); downloadHits = 0; }); tearDown(() => 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('首次拉取:下载模型并落盘(model/labels/meta)', () async { final m = manager(client([_item()])); await m.refresh(); expect(m.ready, isTrue); expect(m.error, isNull); expect(m.models.length, 1); expect(m.models.first.datasetName, '野鸡数据集'); expect(m.models.first.bytes, _modelBytes); expect(downloadHits, 1); 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('版本未变不重复下载(meta 命中直接跳过)', () async { final m = manager(client([_item()])); await m.refresh(); expect(downloadHits, 1); await m.refresh(); expect(downloadHits, 1, reason: 'meta 匹配应跳过下载'); expect(m.models.length, 1); }); test('版本更新触发重新下载', () async { final m = manager(client([_item()])); await m.refresh(); expect(downloadHits, 1); await m.refresh(); expect(downloadHits, 1); // 发布新版本:再次刷新应重下 await m.refresh(); expect(downloadHits, 1); // 上面三次同一版本,重新构造带新版本的 manager(同一 root) final m2 = manager(client([_item(version: 'v2.0.0')])); await m2.refresh(); expect(downloadHits, 2); expect(m2.models.first.version, 'v2.0.0'); }); test('sha256 不匹配:重试后失败,保留旧模型并报错', () async { // 第一次下载成功(sha 匹配) final m1 = manager(client([_item()])); await m1.refresh(); expect(m1.models.length, 1); // 服务器 sha 与文件不符(被篡改/损坏)→ 下载校验失败 final bad = _item(version: 'v3.0.0'); bad['sha256'] = _shaHex([9, 9, 9]); final m2 = manager(client([bad])); await m2.refresh(); expect(m2.models.length, 0, reason: '校验失败的模型不应加载'); expect(m2.error, isNotNull); expect(m2.error, contains('野鸡数据集')); }); test('目录下线:清理本地并清空模型', () async { final m = manager(client([_item()])); await m.refresh(); expect(m.models.length, 1); expect(await Directory('${root.path}/7').exists(), isTrue); final m2 = manager(client([])); await m2.refresh(); expect(m2.models, isEmpty); expect(await Directory('${root.path}/7').exists(), isFalse, reason: '下线的数据集模型目录应被清理'); }); test('目录接口异常:不覆盖已有就绪状态', () async { final m = manager(client([_item()])); await m.refresh(); expect(m.ready, isTrue); final broken = manager(MockClient((_) async => http.Response('boom', 500))); await broken.refresh(); expect(broken.ready, isFalse); expect(broken.error, isNotNull); }); }