Files
observer/flutter_app/test/model_manager_test.dart
2026-09-03 17:50:23 +08:00

498 lines
20 KiB
Dart

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<int> bytes) => sha256.convert(bytes).toString();
/// 分两段到达的下载流(中间 50ms 停顿,供取消测试在下载中触发)
Stream<List<int>> _delayedChunks() async* {
yield [1, 2, 3, 4];
await Future<void>.delayed(const Duration(milliseconds: 50));
yield [5, 6, 7, 8];
}
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({
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<void>.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<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);
}
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 = <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, 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<void>.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<void>.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: '清理只在网络拉取成功后执行,离线首启不得误删已下载模型');
});
}