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: '框内容跟随后到的检测');
});
}