475 lines
16 KiB
Dart
475 lines
16 KiB
Dart
// 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<DetectionResult> detections) {
|
|
if (detections.isEmpty) return null;
|
|
const order = [
|
|
'Very Close (< 1m)',
|
|
'Close (1-2m)',
|
|
'Medium (2-4m)',
|
|
'Far (> 4m)',
|
|
];
|
|
detections.sort((a, b) => order
|
|
.indexOf(a.estimatedDistance)
|
|
.compareTo(order.indexOf(b.estimatedDistance)));
|
|
return detections.first;
|
|
}
|
|
|
|
/// Filter deteksi berdasarkan confidence threshold.
|
|
List<DetectionResult> filterByConfidence(
|
|
List<DetectionResult> 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'));
|
|
});
|
|
});
|
|
}
|