Files
observer/flutter_app/test/model_manager_test.dart
T
2026-09-03 10:01:42 +08:00

349 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:已激活模型出新版本,refresh 后自动重下并立即生效', () async {
final m = manager(client([_item()]));
await m.refresh();
await m.downloadModel(m.catalog.first);
expect(downloadHits, 1);
// 重启 + 服务器目录出 v2refresh 内部 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.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<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);
// 非当前档(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 档):不应删整目录');
});
}