This commit is contained in:
2026-09-03 17:50:23 +08:00
parent 7bfafc7be3
commit 77d3aad6fc
45 changed files with 1398 additions and 1088 deletions
@@ -0,0 +1,93 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:observer/camera/camera_view_model.dart';
import 'package:observer/detection/detection_result.dart';
import 'package:observer/reminder/reminder.dart';
DetectionResult box(
String label,
double score,
double l,
double t,
double r,
double b, {
int modelId = -1,
int classId = 0,
}) =>
DetectionResult(
label: label,
score: score,
left: l,
top: t,
right: r,
bottom: b,
modelId: modelId,
classId: classId,
);
/// 真实 Reminder 会触碰音频/震动插件通道,测试用记录桩(onDetected 同步记录)。
/// AudioPlayer 构造里的 _create 平台调用在测试环境抛 MissingPluginException
/// 已被插件内部 catch,不影响用例。
class _RecordingReminder extends Reminder {
final List<String> alerts = [];
@override
void onDetected(String label) => alerts.add(label);
}
void feed(CameraViewModel vm, List<DetectionResult> results) {
vm.onFramesAnalyzed(results, 90, 1280, 720, const [], const [],
lastProcessMs: 8);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('dedupeVisibleOverlaps', () {
test('同类别同位置重复框:只保留最高分', () {
final visible = [
box('雉鸡', 0.6, 0.3, 0.3, 0.5, 0.5, modelId: 1),
box('斑鸠', 0.5, 0.3, 0.3, 0.5, 0.5, modelId: 2),
];
final out = dedupeVisibleOverlaps(visible);
expect(out.length, 1);
expect(out.single.label, '雉鸡');
expect(out.single.score, 0.6);
});
test('不同类别同位置(目标×疑似):不同语义各自保留', () {
final visible = [
box('雉鸡', 0.6, 0.3, 0.3, 0.5, 0.5, classId: 0),
box('生境', 0.8, 0.2, 0.2, 0.6, 0.6, classId: 1),
];
final out = dedupeVisibleOverlaps(visible);
expect(out.length, 2);
});
test('中心不在对方框内的偏移重叠:两个独立目标不误并', () {
// 覆盖 ~40% 但中心互不在对方框内(并肩两个目标)
final visible = [
box('雉鸡', 0.7, 0.10, 0.10, 0.40, 0.40),
box('雉鸡', 0.5, 0.28, 0.10, 0.58, 0.40),
];
expect(dedupeVisibleOverlaps(visible).length, 2);
});
});
test('跨标签同目标补挂:交替检出同一目标只一条轨迹/一次提醒', () async {
final reminder = _RecordingReminder();
final vm = CameraViewModel(reminder: reminder);
feed(vm, [box('雉鸡', 0.6, 0.2, 0.2, 0.5, 0.5, modelId: 1)]);
// 越过 500ms 显示窗(确认轨迹开始显示、提醒窗口开启)
await Future<void>.delayed(const Duration(milliseconds: 600));
// 模型 2 检出同一目标:细长小框贴大框边角(中心距 ~0.2 > 跨标签收紧半径
// 0.072、覆盖 ~0.31 也不到 0.45),但小框中心在大框内 → 补挂进雉鸡
// 轨迹而不是新建第二条
feed(vm, [box('斑鸠', 0.55, 0.38, 0.445, 0.62, 0.535, modelId: 2)]);
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(reminder.alerts, ['雉鸡'],
reason: '补挂进原轨迹:提醒按轨迹原标签只触发一次;若新建第二条轨迹'
'(斑鸠未达 500ms 显示窗)本帧不会触发提醒');
expect(vm.state.results.length, 1, reason: '同位置只有一条轨迹可见');
expect(vm.state.results.single.label, '斑鸠', reason: '框内容跟随后到的检测');
});
}
+126 -53
View File
@@ -120,33 +120,33 @@ void main() {
await tester.tap(finder);
}
testWidgets('未下载:显示使用按钮,点击后下载完成自动变为使用', (tester) async {
testWidgets('未下载:显示下载按钮,点击后下载完成自动变为使用', (tester) async {
final m = manager([_item()]);
await tester.runAsync(() => m.refresh());
await pumpSection(tester, m);
expect(find.text('数据集A'), findsOneWidget);
expect(find.text('使用'), findsOneWidget);
expect(find.text('下载'), findsOneWidget);
await tapVisible(tester, find.text('使用'));
await tapVisible(tester, find.text('下载'));
await tester.pump(); // 下载启动,进度条出现
// 驱动下载 + 自动激活的 IO 链走完,直至 UI 呈现「使用」
await pumpUntilFound(tester, find.text('使用'));
// 驱动下载 + 自动激活的 IO 链走完,直至 UI 呈现「使用
await pumpUntilFound(tester, find.text('使用'));
await tester.pumpAndSettle();
expect(m.isDownloaded(7, kVariantS), isTrue);
expect(m.isActive(7, kVariantS), isTrue);
expect(find.text('使用'), findsOneWidget);
expect(find.text('使用'), findsOneWidget);
});
testWidgets('已激活:再次点击取消使用', (tester) async {
testWidgets('已激活:点击使用中取消使用', (tester) async {
final m = manager([_item()]);
await tester.runAsync(() => m.refresh());
await tester.runAsync(() => m.downloadModel(m.catalog.first));
await pumpSection(tester, m);
expect(find.text('使用'), findsOneWidget);
await tapVisible(tester, find.text('使用'));
expect(find.text('使用'), findsOneWidget);
await tapVisible(tester, find.text('使用'));
// 排空 setActive 的激活集重载与落盘 IO,直至 UI 呈现「使用」
await pumpUntilFound(tester, find.text('使用'));
await tester.pumpAndSettle();
@@ -169,7 +169,7 @@ void main() {
expect(find.text('暂无已发布模型'), findsOneWidget);
});
testWidgets('下载中:显示进度和取消按钮,取消后恢复使用', (tester) async {
testWidgets('下载中:显示进度和取消按钮,取消后恢复下载', (tester) async {
final m = ModelManager(
baseUrl: 'http://test.local',
client: _StreamingClient(),
@@ -178,83 +178,156 @@ void main() {
await tester.runAsync(() => m.refresh());
await pumpSection(tester, m);
await tapVisible(tester, find.text('使用'));
await tapVisible(tester, find.text('下载'));
// 首个分块到达,进度条与取消按钮出现(IO 链需交替驱动)
await pumpUntilFound(tester, find.text('取消'),
fake: const Duration(milliseconds: 400));
expect(find.text('取消'), findsOneWidget);
await tapVisible(tester, find.text('取消'));
// 推进 fake 时钟触发延迟分块 → 取消分支清场(.part 删除等真实 IO),直至恢复「使用
await pumpUntilFound(tester, find.text('使用'),
// 推进 fake 时钟触发延迟分块 → 取消分支清场(.part 删除等真实 IO),直至恢复「下载
await pumpUntilFound(tester, find.text('下载'),
fake: const Duration(milliseconds: 400));
await tester.pumpAndSettle();
expect(m.isDownloaded(7, kVariantS), isFalse);
expect(m.isActive(7, kVariantS), isFalse);
expect(find.text('使用'), findsOneWidget);
expect(find.text('下载'), findsOneWidget);
});
testWidgets('双档位:s/n 两卡独立,n 档下载备好后切档即用', (tester) async {
final m =
manager([_item(), _item(variant: kVariantN, name: '数据集B')]);
testWidgets('双档位:同物种合并一张卡,下载只启用目标档,使用即停旧档', (tester) async {
final m = manager([_item(), _item(variant: kVariantN)]);
await tester.runAsync(() => m.refresh());
await pumpSection(tester, m);
// 分段控件默认 s;两卡并排,s 卡按钮「使用」,非当前档 n 卡按钮「下载」
expect(find.text('s 高识别'), findsOneWidget);
// 合并卡:同一数据集只一张卡,无双卡/无档位角标;双档行标注两档
expect(find.text('数据集A'), findsOneWidget);
expect(find.text('数据集B'), findsOneWidget);
expect(find.text('s'), findsOneWidget, reason: '档位角标');
expect(find.text('n'), findsOneWidget, reason: '档位角标');
expect(find.text('使用'), findsOneWidget);
expect(find.text('下载'), findsOneWidget, reason: '非当前档入口为「下载」');
expect(find.text('下载'), findsOneWidget, reason: '合并卡只有一个主按钮');
expect(find.textContaining('高精度 v1.0.0'), findsOneWidget);
expect(find.textContaining('高性能 v1.0.0'), findsOneWidget);
// 先激活 s 档
await tapVisible(tester, find.text('使用'));
await pumpUntilFound(tester, find.text('已使用'));
expect(m.isActive(7, kVariantS), isTrue);
// 下载 n 档:完成后自动备好(n 非当前档),显示「已备好」而非「已使用」
// 一次下载取回双档:同物种只启用目标档(默认高精度 s),n 档备好不启用。
// 双档下载收尾链交错,等 models 真正重载完成再断言
await tapVisible(tester, find.text('下载'));
await pumpUntilFound(tester, find.text('已备好'));
await tester.pumpAndSettle();
expect(m.isActive(7, kVariantN), isTrue);
expect(m.mode, kVariantS, reason: '下载非当前档不应改变识别档位');
expect(m.models.length, 1);
expect(m.models.first.variant, kVariantS, reason: '当前仍只加载 s 档');
// 点「已备好」切换档位 → n 档立即生效(已使用),s 档转为备好态
await tapVisible(tester, find.text('已备好'));
// 「已使用」切换前后都在场(s 使用 → n 使用),须等 manager 状态真正切档
await pumpUntilFound(tester, find.text('使用中'));
for (var i = 0;
i < 50 &&
!(m.mode == kVariantN &&
m.models.isNotEmpty &&
m.models.first.variant == kVariantN);
!(m.isDownloaded(7, kVariantS) &&
m.isDownloaded(7, kVariantN) &&
m.isActive(7, kVariantS) &&
!m.isActive(7, kVariantN) &&
m.models.length == 1 &&
m.models.first.variant == kVariantS);
i++) {
await pumpRealIo(tester);
}
await tester.pumpAndSettle();
expect(m.mode, kVariantN);
expect(m.isDownloaded(7, kVariantS), isTrue);
expect(m.isDownloaded(7, kVariantN), isTrue);
expect(m.isActive(7, kVariantS), isTrue);
expect(m.isActive(7, kVariantN), isFalse,
reason: '同物种同时只运行目标档,另一档备好不叠加启用');
expect(m.mode, kVariantS, reason: '下载双档不应改变目标档');
expect(m.models.length, 1);
expect(m.models.first.variant, kVariantN, reason: '切档后热加载 n 档模型');
expect(find.text('使用'), findsOneWidget, reason: 'n 档卡已使用');
expect(find.text('已备好'), findsOneWidget, reason: 's 档转为备好态');
expect(m.models.first.variant, kVariantS);
expect(find.text('使用'), findsOneWidget);
// 目标档切到高性能:运行中的 s 不受影响;n 已下载未启用 → 主按钮「使用」
// (保存目标档偏好是真实 IO,等按钮呈现才算 UI 落定;2026-09-03 简化:
// 不再叫「改用X」、不显示「当前使用 X」提示)
await tapVisible(tester, find.text('高性能'));
await pumpUntilFound(tester, find.text('使用'));
await tester.pumpAndSettle();
expect(m.mode, kVariantN);
expect(m.isActive(7, kVariantS), isTrue,
reason: '切换目标档不应改变运行中的模型');
expect(find.text('使用'), findsOneWidget);
expect(find.textContaining('当前使用'), findsNothing);
// 点「使用」:同物种 s 自动停用,n 热启用(setActive 尾部的落盘
// 与 notify 是真实 IO,等 UI 呈现「使用中」才算整链落定)
await tapVisible(tester, find.text('使用'));
await pumpUntilFound(tester, find.text('使用中'));
await tester.pumpAndSettle();
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(find.text('使用中'), findsOneWidget, reason: '主按钮跟随当前档');
});
testWidgets('识别模式分段控件:点击切档生效', (tester) async {
final m = manager([_item(), _item(variant: kVariantN, name: '数据集A')]);
testWidgets('不同动物可用不同档位:使用只作用于点击的动物', (tester) async {
final m = manager([
_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 tester.runAsync(() => m.refresh());
// 两个物种都下载并运行默认档(高精度 s),各自 n 档备好
for (final it in m.catalog) {
await tester.runAsync(() => m.downloadModel(it));
}
await pumpSection(tester, m);
await tapVisible(tester, find.text('n 高性能'));
for (var i = 0; i < 50 && m.mode != kVariantN; i++) {
expect(m.models.length, 2);
expect(find.text('使用中'), findsNWidgets(2));
// 目标档切高性能:两卡主按钮都变「使用」(各自 s 仍在运行、n 已备好)
await tapVisible(tester, find.text('高性能'));
await pumpUntilFound(tester, find.text('使用'));
expect(find.text('使用'), findsNWidgets(2));
// 只点第一张卡(数据集 7,目录序在前):该动物切到高性能,另一动物保持高精度
await tapVisible(tester, find.text('使用').first);
await pumpUntilFound(tester, find.text('使用中')); // 数据集 7 的卡先翻为使用中
for (var i = 0;
i < 50 &&
!(m.models.length == 2 &&
m.models.firstWhere((b) => b.datasetId == 7).variant ==
kVariantN &&
m.models.firstWhere((b) => b.datasetId == 8).variant ==
kVariantS);
i++) {
await pumpRealIo(tester);
}
await tester.pumpAndSettle();
expect(m.mode, kVariantN, reason: '点击分段控件应切档');
// 档位持久化(重启恢复)由 model_manager_test「识别档位持久化」覆盖;
expect(m.isActive(7, kVariantN), isTrue);
expect(m.isActive(7, kVariantS), isFalse, reason: '启用目标档自动停用同动物另一档');
expect(m.isActive(8, kVariantS), isTrue, reason: '未点击的动物保持原档位');
expect(m.isActive(8, kVariantN), isFalse);
expect(m.models.length, 2, reason: '两个动物仍同时识别');
expect(m.models.firstWhere((b) => b.datasetId == 7).variant, kVariantN);
expect(m.models.firstWhere((b) => b.datasetId == 8).variant, kVariantS);
expect(find.text('使用中'), findsOneWidget, reason: '只有数据集 7 的卡使用中');
expect(find.text('使用'), findsOneWidget,
reason: '数据集 8 目标档已备好:按钮就是使用(不叫改用)');
expect(find.textContaining('当前使用'), findsNothing);
});
testWidgets('识别模式分段控件:只改目标档偏好,不影响已在运行的模型', (tester) async {
final m = manager([_item(), _item(variant: kVariantN)]);
await tester.runAsync(() => m.refresh());
await tester.runAsync(() => m.downloadModel(m.catalog.first)); // s 启用
await pumpSection(tester, m);
expect(find.text('高精度'), findsOneWidget, reason: '分段控件选中默认档');
expect(find.text('高性能'), findsOneWidget);
expect(find.text('使用中'), findsOneWidget);
await tapVisible(tester, find.text('高性能'));
// 目标档 n 尚未下载(之前只下了 s)→ 主按钮变为「下载」:仍不切运行模型
await pumpUntilFound(tester, find.text('下载'));
await tester.pumpAndSettle();
expect(m.mode, kVariantN, reason: '点击分段控件应改目标档');
expect(m.isActive(7, kVariantS), isTrue, reason: '只改偏好不切运行模型');
expect(m.models.length, 1);
expect(m.models.first.variant, kVariantS, reason: '没有热加载/切换');
expect(find.text('下载'), findsOneWidget, reason: '卡片按钮面向新目标档');
expect(find.textContaining('当前使用'), findsNothing);
// 目标档偏好持久化由 model_manager_test「识别目标档位偏好持久化」覆盖;
// widget 测试内 tap 触发的文件写与真实 IO 交错不可控,不在本层断言重启
});
}
+180 -31
View File
@@ -153,23 +153,28 @@ void main() {
expect(m2.models.first.version, 'v2.0.0');
});
test('autoUpdate:已激活模型出新版本,refresh 后自动重下并立即生效', () async {
test('autoUpdate:已下载模型出新版本,自动补齐文件但不改变激活状态', () async {
final m = manager(client([_item()]));
await m.refresh();
await m.downloadModel(m.catalog.first);
await m.downloadModel(m.catalog.first); // (7, s) v1 下载并激活
expect(downloadHits, 1);
// 重启 + 服务器目录出 v2refresh 内部 autoUpdate 自动重下(无需手动)
// 模拟新会话(新实例 + 服务器目录出 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.isActive(7, kVariantS), isTrue, reason: '自动更新应保持激活');
expect(m2.models.first.version, 'v2.0.0',
reason: '自动更新后立即生效新版本字节');
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);
});
@@ -189,17 +194,25 @@ void main() {
expect(m2.errorOf(7, kVariantS), isNotNull);
});
test('激活集持久化:重启后恢复激活且已下载的模型', () async {
test('会话制:进入新会话清空激活,文件保留,再启用不重新下载', () async {
final m = manager(client([_item()]));
await m.refresh();
await m.downloadModel(m.catalog.first);
expect(m.isActive(7, kVariantS), isTrue);
// 同一 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: '重启不应触发下载');
// 每次进入视野页 = 新会话:清空激活与已加载模型(不恢复上次使用的模型)
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 {
@@ -272,24 +285,24 @@ void main() {
reason: '取消后 .part 残留应被清理');
});
test('双档位:n 档独立目录 models/7/n/非当前档下载不加载', () async {
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 自动激活并加载
// 默认目标档 s:下载 s 自动激活并加载
await m.downloadModel(m.catalog.first); // (7, s)
expect(m.models.length, 1);
expect(m.models.first.variant, kVariantS);
// 非当前档(n)下载:自动备好(激活保留),但 s 档加载列表不变
// 同物种已有 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), isTrue, reason: '下载完成应自动备好');
expect(m.models.length, 1, reason: '非当前档模型不应进入加载列表');
expect(m.isActive(7, kVariantN), isFalse, reason: '同物种运行中不叠加启用');
expect(m.models.length, 1, reason: '备好的 n 档不进加载列表');
expect(m.models.first.variant, kVariantS);
// n 档文件在独立子目录(目录键 = 档位),s 档仍为同级文件
@@ -298,38 +311,108 @@ void main() {
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(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.setMode(kVariantS);
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 {
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.downloadModel(m.catalog.first); // (7, s) 下载自动激活
await m.downloadModel(m.catalog.last); // (7, n) 备好不启用
await m.setMode(kVariantN);
// 同一 root 新建 manager 模拟重启:档位与激活集都持久化
// 同一 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: '重启与切档不应触发下载');
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 {
@@ -345,4 +428,70 @@ void main() {
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: '清理只在网络拉取成功后执行,离线首启不得误删已下载模型');
});
}
+45
View File
@@ -13,6 +13,20 @@ DetectionResult box(String label, double score, double x, double y,
bottom: y + 0.1,
modelId: modelId,
modelName: modelName,
classId: 0,
);
DetectionResult rect(String label, double score, double l, double t, double r,
double b, {int modelId = -1}) =>
DetectionResult(
label: label,
score: score,
left: l,
top: t,
right: r,
bottom: b,
modelId: modelId,
classId: 0,
);
void main() {
@@ -63,4 +77,35 @@ void main() {
final merged = mergeAcrossModels(single, 0.45);
expect(identical(merged, single), isTrue);
});
test('不同模型偏移框(小框中心在大框内):0.45 阈值外的窗口也去重', () {
// 覆盖 ≈ 0.31 < 0.45:纯阈值会漏;小框中心在大框内 → 同一目标取高分
// 2026-09-03:不同输入分辨率模型对同一目标的框几何系统性偏移)
final all = [
rect('target', 0.5, 0.38, 0.445, 0.62, 0.535, modelId: 1),
rect('second', 0.6, 0.2, 0.2, 0.5, 0.5, modelId: 2),
];
final merged = mergeAcrossModels(all, 0.45);
expect(merged.length, 1);
expect(merged.first.label, 'second');
expect(merged.first.score, 0.6);
});
test('同模型偏移框:不套用跨模型窗口,正常保留', () {
final all = [
rect('target', 0.5, 0.38, 0.445, 0.62, 0.535, modelId: 1),
rect('second', 0.6, 0.2, 0.2, 0.5, 0.5, modelId: 1),
];
final merged = mergeAcrossModels(all, 0.45);
expect(merged.length, 2, reason: '同模型框对由模型内类内 NMS 负责');
});
test('无来源模型(modelId -1):不套用跨模型窗口(存量语义不变)', () {
final all = [
rect('target', 0.5, 0.38, 0.445, 0.62, 0.535),
rect('second', 0.6, 0.2, 0.2, 0.5, 0.5),
];
final merged = mergeAcrossModels(all, 0.45);
expect(merged.length, 2);
});
}