This commit is contained in:
2026-08-31 16:37:26 +08:00
parent a1ca9d9a43
commit d863a42d8a
9 changed files with 291 additions and 67 deletions
+91 -35
View File
@@ -11,17 +11,27 @@ const _modelBytes = [1, 2, 3, 4, 5, 6, 7, 8];
String _shaHex(List<int> bytes) => sha256.convert(bytes).toString();
Map<String, dynamic> _catalog(List<Map<String, dynamic>> models) =>
{'code': 0, 'message': 'ok', 'data': {'version': '0.0.2', 'notes': '', 'models': models}};
Map<String, dynamic> _catalog(List<Map<String, dynamic>> models) => {
'code': 0,
'message': 'ok',
'data': {'version': '0.0.2', 'notes': '', 'models': models}
};
Map<String, dynamic> _item({String version = 'v1.0.0', String sha = ''}) => {
'datasetId': 7,
'datasetName': '环颈雉鸡数据集',
Map<String, dynamic> _item({
int datasetId = 7,
String name = '环颈雉鸡数据集',
String version = 'v1.0.0',
String sha = '',
}) =>
{
'datasetId': datasetId,
'datasetName': name,
'version': version,
'labels': ['pheasant', 'suspect'],
'sizeBytes': _modelBytes.length,
'sha256': sha.isEmpty ? _shaHex(_modelBytes) : sha,
'downloadUrl': '/download/models/环颈雉鸡数据集/latest.tflite',
'downloadUrl': '/download/models/$datasetId/latest.tflite',
'coverUrl': '/api/v1/app/cover?namePrefix=RNPHE',
};
void main() {
@@ -41,10 +51,12 @@ void main() {
rootDir: () async => root,
);
MockClient client(List<Map<String, dynamic>> models) => MockClient((req) async {
MockClient client(List<Map<String, dynamic>> 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);
return http.Response.bytes(
utf8.encode(jsonEncode(_catalog(models))), 200);
}
if (req.url.path.startsWith('/download/models/')) {
downloadHits++;
@@ -53,16 +65,34 @@ void main() {
return http.Response('not found', 404);
});
test('首次拉取:下载模型并落盘(model/labels/meta', () async {
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=RNPHE');
});
test('downloadModel:下载+校验+落盘+自动激活+进度回调', () async {
final m = manager(client([_item()]));
await m.refresh();
final progresses = <double>[];
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), isTrue);
expect(m.isActive(7), isTrue, reason: '下载完成应自动使用');
expect(progresses.last, 1.0);
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);
@@ -70,67 +100,93 @@ void main() {
expect(await File('${dir.path}/meta.json').exists(), isTrue);
});
test('版本未变不重复下载(meta 命中直接跳过)', () async {
test('已下载未激活:setActive 直接使用,不触发下载', () async {
final m = manager(client([_item()]));
await m.refresh();
expect(downloadHits, 1);
await m.downloadModel(m.catalog.first);
await m.setActive(7, false);
expect(m.isActive(7), isFalse);
expect(m.models, isEmpty);
await m.refresh();
expect(downloadHits, 1, reason: 'meta 匹配应跳过下载');
final before = downloadHits;
await m.setActive(7, true);
expect(m.isActive(7), isTrue);
expect(downloadHits, before, reason: '已下载直接使用不应重新下载');
expect(m.models.length, 1);
});
test('版本更新触发重新下载', () async {
test('版本更新downloadModel 重新下载', () async {
final m = manager(client([_item()]));
await m.refresh();
await m.downloadModel(m.catalog.first);
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();
await m2.downloadModel(m2.catalog.first);
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 与文件不符(被篡改/损坏)→ 下载校验失败
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();
expect(m2.models.length, 0, reason: '校验失败的模型不应加载');
expect(m2.error, isNotNull);
expect(m2.error, contains('环颈雉鸡数据集'));
final ok = await m2.downloadModel(m2.catalog.first);
expect(ok, isFalse);
expect(downloadHits, 2, reason: '校验失败应重试一次');
expect(m2.isDownloaded(7), isFalse);
expect(m2.isActive(7), isFalse);
expect(m2.errorOf(7), isNotNull);
});
test('目录下线:清理本地并清空模型', () async {
test('激活集持久化:重启后恢复激活且已下载的模型', () async {
final m = manager(client([_item()]));
await m.refresh();
expect(m.models.length, 1);
await m.downloadModel(m.catalog.first);
// 同一 root 新建 manager 模拟重启
final m2 = manager(client([_item()]));
await m2.refresh();
expect(m2.isActive(7), 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), isFalse, reason: '下线的模型应移出激活集');
expect(await Directory('${root.path}/7').exists(), isFalse,
reason: '下线的数据集模型目录应被清理');
});
test('多模型:只激活其一则只加载其一', () async {
final m = manager(
client([_item(datasetId: 7, name: '环颈雉'), _item(datasetId: 8, name: '斑鸠')]));
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)));