// test/unit/obstacle_analyzer_test.dart // ignore_for_file: prefer_const_declarations // // Unit test untuk ObstacleAnalyzer — logika AI direction & distance. // Jalankan: flutter test test/unit/obstacle_analyzer_test.dart // // Tidak ada dependency eksternal — murni logika pure Dart. import 'package:flutter_test/flutter_test.dart'; // ---------- Stubs (mirror dari project asli) ---------- enum ObstacleDirection { left, center, right } class BoundingBox { final double left; final double top; final double right; final double bottom; const BoundingBox({ required this.left, required this.top, required this.right, required this.bottom, }); double get width => right - left; double get height => bottom - top; double get centerX => left + width / 2; double get centerY => top + height / 2; } class DetectionResult { final String label; final double confidence; final ObstacleDirection direction; final String estimatedDistance; const DetectionResult({ required this.label, required this.confidence, required this.direction, required this.estimatedDistance, }); String get spokenId { final area = switch (direction) { ObstacleDirection.left => 'kiri', ObstacleDirection.center => 'tengah', ObstacleDirection.right => 'kanan', }; return 'Hati-hati, $label di $area. Jarak $estimatedDistance.'; } } // ---------- ObstacleAnalyzer yang di-test ---------- class ObstacleAnalyzer { static const double _frameWidth = 640.0; static const double _frameHeight = 480.0; /// Tentukan arah berdasarkan posisi horizontal tengah bounding box. /// - centerX < 33% frame → LEFT /// - centerX > 67% frame → RIGHT /// - otherwise → CENTER ObstacleDirection analyzeDirection(BoundingBox box) { final cx = box.centerX; if (cx < _frameWidth * 0.33) return ObstacleDirection.left; if (cx > _frameWidth * 0.67) return ObstacleDirection.right; return ObstacleDirection.center; } /// Estimasi jarak dari rasio tinggi bounding box terhadap frame. /// - ratio > 0.60 → "Very Close (< 1m)" /// - ratio > 0.35 → "Close (1-2m)" /// - ratio > 0.15 → "Medium (2-4m)" /// - else → "Far (> 4m)" String estimateDistance(BoundingBox box) { final ratio = box.height / _frameHeight; if (ratio > 0.60) return 'Very Close (< 1m)'; if (ratio > 0.35) return 'Close (1-2m)'; if (ratio > 0.15) return 'Medium (2-4m)'; return 'Far (> 4m)'; } /// Buat pesan TTS dari hasil deteksi. String buildTtsMessage(DetectionResult result) { return 'Hati-hati, ${result.label} di ' '${_directionLabel(result.direction)}. ' 'Jarak ${result.estimatedDistance}.'; } String _directionLabel(ObstacleDirection dir) => switch (dir) { ObstacleDirection.left => 'kiri', ObstacleDirection.center => 'depan', ObstacleDirection.right => 'kanan', }; /// Pilih obstacle paling prioritas (Very Close > Close > Medium > Far). DetectionResult? prioritize(List detections) { if (detections.isEmpty) return null; const order = [ 'Very Close (< 1m)', 'Close (1-2m)', 'Medium (2-4m)', 'Far (> 4m)', ]; final sorted = List.of(detections); sorted.sort((a, b) => order .indexOf(a.estimatedDistance) .compareTo(order.indexOf(b.estimatedDistance))); return sorted.first; } /// Filter deteksi berdasarkan confidence threshold. List filterByConfidence( List detections, double threshold) { return detections.where((d) => d.confidence >= threshold).toList(); } /// Fallback detection (dipakai jika YOLO tidak jalan). DetectionResult analyzeFallback( {String label = 'person', double confidence = 0.86}) { return DetectionResult( label: label, confidence: confidence, direction: ObstacleDirection.center, estimatedDistance: 'Close (1-2m)', ); } } // ---------- Tests ---------- void main() { late ObstacleAnalyzer analyzer; setUp(() { analyzer = ObstacleAnalyzer(); }); // ── DIRECTION TESTS ────────────────────────────────────────────── group('analyzeDirection — zone kiri (< 33% lebar frame = < 211px)', () { test('obstacle di ujung kiri (cx=10) → LEFT', () { final box = const BoundingBox(left: 0, top: 100, right: 20, bottom: 300); expect(analyzer.analyzeDirection(box), ObstacleDirection.left); }); test('obstacle di kiri batas (cx=200) → LEFT', () { final box = const BoundingBox(left: 150, top: 100, right: 250, bottom: 300); expect(analyzer.analyzeDirection(box), ObstacleDirection.left); }); test('obstacle dengan cx tepat di batas kiri (cx=210) → LEFT', () { // 640 * 0.33 = 211.2, jadi cx=210 masih LEFT final box = const BoundingBox(left: 160, top: 100, right: 260, bottom: 300); expect(analyzer.analyzeDirection(box), ObstacleDirection.left); }); }); group('analyzeDirection — zone kanan (> 67% lebar frame = > 428px)', () { test('obstacle di ujung kanan (cx=620) → RIGHT', () { final box = const BoundingBox(left: 600, top: 100, right: 640, bottom: 300); expect(analyzer.analyzeDirection(box), ObstacleDirection.right); }); test('obstacle di kanan batas (cx=450) → RIGHT', () { final box = const BoundingBox(left: 400, top: 100, right: 500, bottom: 300); expect(analyzer.analyzeDirection(box), ObstacleDirection.right); }); }); group('analyzeDirection — zone tengah (33%-67%)', () { test('obstacle tepat di tengah (cx=320) → CENTER', () { final box = const BoundingBox(left: 280, top: 100, right: 360, bottom: 300); expect(analyzer.analyzeDirection(box), ObstacleDirection.center); }); test('obstacle di antara kiri dan tengah (cx=300) → CENTER', () { final box = const BoundingBox(left: 250, top: 50, right: 350, bottom: 250); expect(analyzer.analyzeDirection(box), ObstacleDirection.center); }); }); // ── DISTANCE ESTIMATION TESTS ──────────────────────────────────── group('estimateDistance — Very Close (> 60% tinggi frame = > 288px)', () { test('box setinggi hampir full frame → Very Close', () { // height = 450 / 480 = 0.9375 → Very Close final box = const BoundingBox(left: 100, top: 10, right: 400, bottom: 460); expect(analyzer.estimateDistance(box), contains('Very Close')); }); test('height ratio tepat 61% → Very Close', () { // 480 * 0.61 = 292.8 → height 293 final box = const BoundingBox(left: 100, top: 50, right: 400, bottom: 343); expect(analyzer.estimateDistance(box), contains('Very Close')); }); }); group('estimateDistance — Close (35%-60%)', () { test('height ratio 50% → Close', () { // 480 * 0.5 = 240 final box = const BoundingBox(left: 100, top: 120, right: 400, bottom: 360); expect(analyzer.estimateDistance(box), contains('Close')); }); test('height ratio 36% → Close', () { // 480 * 0.36 = 172.8 final box = const BoundingBox(left: 100, top: 150, right: 400, bottom: 323); expect(analyzer.estimateDistance(box), contains('Close')); }); }); group('estimateDistance — Medium (15%-35%)', () { test('height ratio 25% → Medium', () { // 480 * 0.25 = 120 final box = const BoundingBox(left: 100, top: 180, right: 400, bottom: 300); expect(analyzer.estimateDistance(box), contains('Medium')); }); test('height ratio 16% → Medium', () { // 480 * 0.16 = 76.8 final box = const BoundingBox(left: 100, top: 200, right: 400, bottom: 277); expect(analyzer.estimateDistance(box), contains('Medium')); }); }); group('estimateDistance — Far (≤ 15%)', () { test('height ratio 10% → Far', () { // 480 * 0.10 = 48 final box = const BoundingBox(left: 100, top: 200, right: 400, bottom: 248); expect(analyzer.estimateDistance(box), contains('Far')); }); test('height ratio sangat kecil → Far', () { final box = const BoundingBox(left: 200, top: 230, right: 400, bottom: 250); expect(analyzer.estimateDistance(box), contains('Far')); }); }); // ── TTS MESSAGE TESTS ──────────────────────────────────────────── group('buildTtsMessage', () { test('pesan harus mengandung label obstacle', () { const result = DetectionResult( label: 'motor', confidence: 0.9, direction: ObstacleDirection.right, estimatedDistance: 'Close (1-2m)', ); final msg = analyzer.buildTtsMessage(result); expect(msg, contains('motor')); }); test('pesan harus mengandung arah dalam bahasa Indonesia', () { const result = DetectionResult( label: 'person', confidence: 0.8, direction: ObstacleDirection.left, estimatedDistance: 'Very Close (< 1m)', ); final msg = analyzer.buildTtsMessage(result); expect(msg, contains('kiri')); }); test('pesan CENTER harus bilang "depan"', () { const result = DetectionResult( label: 'car', confidence: 0.95, direction: ObstacleDirection.center, estimatedDistance: 'Medium (2-4m)', ); final msg = analyzer.buildTtsMessage(result); expect(msg, contains('depan')); }); test('pesan harus mengandung estimasi jarak', () { const result = DetectionResult( label: 'bicycle', confidence: 0.75, direction: ObstacleDirection.center, estimatedDistance: 'Far (> 4m)', ); final msg = analyzer.buildTtsMessage(result); expect(msg, contains('Far')); }); }); // ── PRIORITIZE TESTS ───────────────────────────────────────────── group('prioritize', () { test('list kosong → return null', () { expect(analyzer.prioritize([]), isNull); }); test('Very Close harus menang atas Close', () { const detections = [ DetectionResult( label: 'car', confidence: 0.7, direction: ObstacleDirection.center, estimatedDistance: 'Close (1-2m)'), DetectionResult( label: 'person', confidence: 0.9, direction: ObstacleDirection.left, estimatedDistance: 'Very Close (< 1m)'), ]; final result = analyzer.prioritize(detections); expect(result?.estimatedDistance, contains('Very Close')); }); test('Close harus menang atas Medium', () { const detections = [ DetectionResult( label: 'dog', confidence: 0.6, direction: ObstacleDirection.right, estimatedDistance: 'Medium (2-4m)'), DetectionResult( label: 'truck', confidence: 0.85, direction: ObstacleDirection.center, estimatedDistance: 'Close (1-2m)'), ]; final result = analyzer.prioritize(detections); expect(result?.estimatedDistance, contains('Close')); }); test('list dengan 1 item → return item tersebut', () { const detections = [ DetectionResult( label: 'pole', confidence: 0.5, direction: ObstacleDirection.center, estimatedDistance: 'Far (> 4m)'), ]; final result = analyzer.prioritize(detections); expect(result?.label, 'pole'); }); }); // ── CONFIDENCE FILTER TESTS ─────────────────────────────────────── group('filterByConfidence', () { const allDetections = [ DetectionResult( label: 'person', confidence: 0.9, direction: ObstacleDirection.center, estimatedDistance: 'Close (1-2m)'), DetectionResult( label: 'car', confidence: 0.4, direction: ObstacleDirection.left, estimatedDistance: 'Far (> 4m)'), DetectionResult( label: 'motorcycle', confidence: 0.6, direction: ObstacleDirection.right, estimatedDistance: 'Medium (2-4m)'), ]; test('threshold 0.5 → harus lolos 2 item (0.9 dan 0.6)', () { final filtered = analyzer.filterByConfidence(allDetections, 0.5); expect(filtered.length, 2); }); test('threshold 0.7 → hanya 1 item lolos (0.9)', () { final filtered = analyzer.filterByConfidence(allDetections, 0.7); expect(filtered.length, 1); expect(filtered.first.label, 'person'); }); test('threshold 0.0 → semua lolos', () { final filtered = analyzer.filterByConfidence(allDetections, 0.0); expect(filtered.length, 3); }); test('threshold 1.0 → tidak ada yang lolos', () { final filtered = analyzer.filterByConfidence(allDetections, 1.0); expect(filtered.isEmpty, true); }); }); // ── FALLBACK TESTS ─────────────────────────────────────────────── group('analyzeFallback', () { test('harus return DetectionResult dengan default label "person"', () { final result = analyzer.analyzeFallback(); expect(result.label, 'person'); }); test('harus return confidence 0.86 by default', () { final result = analyzer.analyzeFallback(); expect(result.confidence, closeTo(0.86, 0.001)); }); test('harus return direction CENTER by default', () { final result = analyzer.analyzeFallback(); expect(result.direction, ObstacleDirection.center); }); test('harus bisa override label', () { final result = analyzer.analyzeFallback(label: 'car'); expect(result.label, 'car'); }); test('harus bisa override confidence', () { final result = analyzer.analyzeFallback(confidence: 0.5); expect(result.confidence, closeTo(0.5, 0.001)); }); }); // ── DETECTION RESULT SPOKEN ID TESTS ───────────────────────────── group('DetectionResult.spokenId', () { test('spokenId LEFT harus mengandung "kiri"', () { const r = DetectionResult( label: 'person', confidence: 0.8, direction: ObstacleDirection.left, estimatedDistance: 'Close', ); expect(r.spokenId, contains('kiri')); }); test('spokenId RIGHT harus mengandung "kanan"', () { const r = DetectionResult( label: 'car', confidence: 0.8, direction: ObstacleDirection.right, estimatedDistance: 'Far', ); expect(r.spokenId, contains('kanan')); }); test('spokenId CENTER harus mengandung "tengah"', () { const r = DetectionResult( label: 'motorcycle', confidence: 0.8, direction: ObstacleDirection.center, estimatedDistance: 'Very Close', ); expect(r.spokenId, contains('tengah')); }); test('spokenId harus mengandung nama label', () { const r = DetectionResult( label: 'truck', confidence: 0.9, direction: ObstacleDirection.center, estimatedDistance: 'Close', ); expect(r.spokenId, contains('truck')); }); }); }