test(frontend): implement unit and widget tests for core flows and use cases

This commit is contained in:
2026-05-17 02:15:54 +07:00
parent 790db043a9
commit 558ef66a55
11 changed files with 4595 additions and 1 deletions
@@ -0,0 +1,366 @@
// test/unit/geofence_calculation_test.dart
//
// Unit test untuk GeofenceCalculator — Haversine formula & zone check.
// Jalankan: flutter test test/unit/geofence_calculation_test.dart
//
// Pure Dart test — tidak ada dependency eksternal.
import 'dart:math' as math;
import 'package:flutter_test/flutter_test.dart';
// ---------- GeofenceCalculator (mirror logika dari project) ----------
class GeofenceCalculator {
static const double _earthRadiusMeters = 6371000.0;
/// Hitung jarak (meter) antara dua koordinat GPS menggunakan Haversine formula.
double haversineDistance(
double lat1,
double lng1,
double lat2,
double lng2,
) {
final dLat = _toRad(lat2 - lat1);
final dLng = _toRad(lng2 - lng1);
final a = math.sin(dLat / 2) * math.sin(dLat / 2) +
math.cos(_toRad(lat1)) *
math.cos(_toRad(lat2)) *
math.sin(dLng / 2) *
math.sin(dLng / 2);
final c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a));
return _earthRadiusMeters * c;
}
/// Return true jika koordinat berada di dalam radius dari center.
bool isInsideGeofence({
required double centerLat,
required double centerLng,
required double radiusMeters,
required double currentLat,
required double currentLng,
}) {
final dist =
haversineDistance(centerLat, centerLng, currentLat, currentLng);
return dist <= radiusMeters;
}
/// Return true jika user baru saja keluar dari geofence.
bool hasExitedGeofence({
required double centerLat,
required double centerLng,
required double radiusMeters,
required double previousLat,
required double previousLng,
required double currentLat,
required double currentLng,
}) {
final wasInside = isInsideGeofence(
centerLat: centerLat,
centerLng: centerLng,
radiusMeters: radiusMeters,
currentLat: previousLat,
currentLng: previousLng,
);
final isInside = isInsideGeofence(
centerLat: centerLat,
centerLng: centerLng,
radiusMeters: radiusMeters,
currentLat: currentLat,
currentLng: currentLng,
);
return wasInside && !isInside;
}
/// Return true jika user baru saja masuk ke geofence.
bool hasEnteredGeofence({
required double centerLat,
required double centerLng,
required double radiusMeters,
required double previousLat,
required double previousLng,
required double currentLat,
required double currentLng,
}) {
final wasInside = isInsideGeofence(
centerLat: centerLat,
centerLng: centerLng,
radiusMeters: radiusMeters,
currentLat: previousLat,
currentLng: previousLng,
);
final isInside = isInsideGeofence(
centerLat: centerLat,
centerLng: centerLng,
radiusMeters: radiusMeters,
currentLat: currentLat,
currentLng: currentLng,
);
return !wasInside && isInside;
}
double _toRad(double deg) => deg * math.pi / 180.0;
}
// ---------- Koordinat referensi ----------
// ITS (Institut Teknologi Sepuluh Nopember) Surabaya
const double _itsCenterLat = -7.2754;
const double _itsCenterLng = 112.7920;
// ---------- Tests ----------
void main() {
late GeofenceCalculator calc;
setUp(() {
calc = GeofenceCalculator();
});
// ── HAVERSINE DISTANCE TESTS ─────────────────────────────────────
group('haversineDistance — akurasi jarak', () {
test('jarak titik yang sama → 0 meter', () {
final dist = calc.haversineDistance(
_itsCenterLat, _itsCenterLng, _itsCenterLat, _itsCenterLng);
expect(dist, closeTo(0.0, 0.001));
});
test('jarak ~111 km per derajat lintang (ekuator)', () {
// 1 derajat lintang ≈ 111 km
final dist =
calc.haversineDistance(0.0, 0.0, 1.0, 0.0); // 0° → 1°N di ekuator
expect(dist, closeTo(111_195, 500)); // ±500 meter toleransi
});
test('jarak ~111 km per derajat bujur (di ekuator)', () {
final dist = calc.haversineDistance(0.0, 0.0, 0.0, 1.0);
expect(dist, closeTo(111_195, 500));
});
test('jarak ITS ke Tunjungan Plaza ≈ 7.6 km', () {
// Tunjungan Plaza Surabaya: -7.2611, 112.7380
const tpLat = -7.2611;
const tpLng = 112.7380;
final dist = calc.haversineDistance(
_itsCenterLat, _itsCenterLng, tpLat, tpLng);
// Jarak darat sekitar 7-8 km, Haversine (garis lurus) ≈ 5-6 km
expect(dist, greaterThan(4000));
expect(dist, lessThan(9000));
});
test('jarak simetris: A→B == B→A', () {
const lat1 = -7.2754;
const lng1 = 112.7920;
const lat2 = -7.2611;
const lng2 = 112.7380;
final d1 = calc.haversineDistance(lat1, lng1, lat2, lng2);
final d2 = calc.haversineDistance(lat2, lng2, lat1, lng1);
expect(d1, closeTo(d2, 0.001));
});
test('jarak ≥ 0 untuk semua koordinat valid', () {
final dist =
calc.haversineDistance(-90.0, -180.0, 90.0, 180.0);
expect(dist, greaterThanOrEqualTo(0));
});
});
// ── IS INSIDE GEOFENCE ───────────────────────────────────────────
group('isInsideGeofence — pengecekan zona', () {
// Radius 500 meter dari ITS
const radius = 500.0;
test('titik tepat di center → inside', () {
expect(
calc.isInsideGeofence(
centerLat: _itsCenterLat,
centerLng: _itsCenterLng,
radiusMeters: radius,
currentLat: _itsCenterLat,
currentLng: _itsCenterLng,
),
isTrue,
);
});
test('titik 100 meter dari center → inside radius 500m', () {
// ±0.001° ≈ 100-111 meter
expect(
calc.isInsideGeofence(
centerLat: _itsCenterLat,
centerLng: _itsCenterLng,
radiusMeters: radius,
currentLat: _itsCenterLat + 0.0009,
currentLng: _itsCenterLng,
),
isTrue,
);
});
test('titik 600 meter dari center → outside radius 500m', () {
// ±0.006° ≈ 600+ meter
expect(
calc.isInsideGeofence(
centerLat: _itsCenterLat,
centerLng: _itsCenterLng,
radiusMeters: radius,
currentLat: _itsCenterLat + 0.006,
currentLng: _itsCenterLng,
),
isFalse,
);
});
test('radius sangat kecil (10m) — hanya titik sangat dekat yang inside',
() {
// ±0.0001° ≈ 11 meter
expect(
calc.isInsideGeofence(
centerLat: _itsCenterLat,
centerLng: _itsCenterLng,
radiusMeters: 10,
currentLat: _itsCenterLat + 0.0001,
currentLng: _itsCenterLng,
),
isFalse,
);
});
test('radius sangat besar (10km) — titik jauh masih inside', () {
expect(
calc.isInsideGeofence(
centerLat: _itsCenterLat,
centerLng: _itsCenterLng,
radiusMeters: 10000,
currentLat: _itsCenterLat + 0.05,
currentLng: _itsCenterLng,
),
isTrue,
);
});
});
// ── GEOFENCE EXIT DETECTION ───────────────────────────────────────
group('hasExitedGeofence', () {
const centerLat = _itsCenterLat;
const centerLng = _itsCenterLng;
const radius = 500.0;
// Koordinat dalam radius
const insideLat = _itsCenterLat + 0.001; // ≈ 111m
const insideLng = _itsCenterLng;
// Koordinat di luar radius
const outsideLat = _itsCenterLat + 0.01; // ≈ 1110m
const outsideLng = _itsCenterLng;
test('pindah dari dalam ke luar → hasExited = true', () {
expect(
calc.hasExitedGeofence(
centerLat: centerLat,
centerLng: centerLng,
radiusMeters: radius,
previousLat: insideLat,
previousLng: insideLng,
currentLat: outsideLat,
currentLng: outsideLng,
),
isTrue,
);
});
test('masih di dalam → hasExited = false', () {
expect(
calc.hasExitedGeofence(
centerLat: centerLat,
centerLng: centerLng,
radiusMeters: radius,
previousLat: insideLat,
previousLng: insideLng,
currentLat: insideLat + 0.0005,
currentLng: insideLng,
),
isFalse,
);
});
test('sudah di luar dan makin jauh → hasExited = false', () {
expect(
calc.hasExitedGeofence(
centerLat: centerLat,
centerLng: centerLng,
radiusMeters: radius,
previousLat: outsideLat,
previousLng: outsideLng,
currentLat: outsideLat + 0.005,
currentLng: outsideLng,
),
isFalse,
);
});
});
// ── GEOFENCE ENTER DETECTION ─────────────────────────────────────
group('hasEnteredGeofence', () {
const centerLat = _itsCenterLat;
const centerLng = _itsCenterLng;
const radius = 500.0;
const insideLat = _itsCenterLat + 0.001;
const insideLng = _itsCenterLng;
const outsideLat = _itsCenterLat + 0.01;
const outsideLng = _itsCenterLng;
test('pindah dari luar ke dalam → hasEntered = true', () {
expect(
calc.hasEnteredGeofence(
centerLat: centerLat,
centerLng: centerLng,
radiusMeters: radius,
previousLat: outsideLat,
previousLng: outsideLng,
currentLat: insideLat,
currentLng: insideLng,
),
isTrue,
);
});
test('sudah di dalam → hasEntered = false', () {
expect(
calc.hasEnteredGeofence(
centerLat: centerLat,
centerLng: centerLng,
radiusMeters: radius,
previousLat: insideLat,
previousLng: insideLng,
currentLat: insideLat + 0.0001,
currentLng: insideLng,
),
isFalse,
);
});
});
// ── REAL WORLD REFERENCE TESTS ────────────────────────────────────
group('real-world reference distances', () {
test('jarak ITS ke Surabaya Gubeng station ≈ 6-8 km', () {
// Stasiun Gubeng: -7.2651, 112.7509
final dist = calc.haversineDistance(
_itsCenterLat, _itsCenterLng, -7.2651, 112.7509);
expect(dist, greaterThan(4000));
expect(dist, lessThan(10000));
});
test('jarak Jakarta ke Surabaya ≈ 700-800 km', () {
// Jakarta: -6.2088, 106.8456
// Surabaya: -7.2575, 112.7521
final dist =
calc.haversineDistance(-6.2088, 106.8456, -7.2575, 112.7521);
expect(dist, greaterThan(650_000));
expect(dist, lessThan(850_000));
});
});
}
@@ -0,0 +1,249 @@
// test/unit/login_use_case_test.dart
//
// Unit test untuk LoginUseCase / AuthRepositoryImpl.
// Jalankan: flutter test test/unit/login_use_case_test.dart
//
// Dependencies (pubspec.yaml dev_dependencies):
// flutter_test: sdk: flutter
// mockito: ^5.4.4
// build_runner: ^2.4.6
import 'package:dartz/dartz.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
// ---------- Minimal stubs (copy dari project asli) ----------
abstract class Failure {
final String message;
const Failure(this.message);
}
class ServerFailure extends Failure {
const ServerFailure(super.message);
}
class NetworkFailure extends Failure {
const NetworkFailure(super.message);
}
class AuthFailure extends Failure {
const AuthFailure(super.message);
}
class UserEntity {
final String token;
final String role;
UserEntity({required this.token, required this.role});
}
abstract class AuthRepository {
Future<Either<Failure, UserEntity>> login(String email, String password);
}
// ---------- Mock abstract classes ----------
@GenerateMocks([AuthRepository])
// File mock di-generate via: flutter pub run build_runner build
// Untuk demo tanpa build_runner, kita buat manual mock di bawah
class MockAuthRepository extends Mock implements AuthRepository {}
// ---------- Use case ----------
class LoginUseCase {
final AuthRepository repository;
LoginUseCase(this.repository);
Future<Either<Failure, UserEntity>> call(String email, String password) {
if (email.trim().isEmpty) {
return Future.value(const Left(AuthFailure('Email tidak boleh kosong')));
}
if (password.length < 6) {
return Future.value(
const Left(AuthFailure('Password minimal 6 karakter')));
}
return repository.login(email, password);
}
}
// ---------- Tests ----------
void main() {
late LoginUseCase loginUseCase;
late MockAuthRepository mockAuthRepository;
setUp(() {
mockAuthRepository = MockAuthRepository();
loginUseCase = LoginUseCase(mockAuthRepository);
});
group('LoginUseCase — validasi input', () {
test('harus return AuthFailure jika email kosong', () async {
final result = await loginUseCase.call('', 'password123');
expect(result.isLeft(), true);
result.fold(
(failure) {
expect(failure, isA<AuthFailure>());
expect(failure.message, contains('kosong'));
},
(_) => fail('Seharusnya return Left'),
);
});
test('harus return AuthFailure jika email hanya spasi', () async {
final result = await loginUseCase.call(' ', 'password123');
expect(result.isLeft(), true);
result.fold(
(failure) => expect(failure, isA<AuthFailure>()),
(_) => fail('Seharusnya return Left'),
);
});
test('harus return AuthFailure jika password kurang dari 6 karakter',
() async {
final result = await loginUseCase.call('user@test.com', '123');
expect(result.isLeft(), true);
result.fold(
(failure) {
expect(failure, isA<AuthFailure>());
expect(failure.message, contains('6'));
},
(_) => fail('Seharusnya return Left'),
);
});
test('harus return AuthFailure jika password tepat 5 karakter', () async {
final result = await loginUseCase.call('user@test.com', 'abc12');
expect(result.isLeft(), true);
result.fold(
(failure) => expect(failure, isA<AuthFailure>()),
(_) => fail('Seharusnya return Left'),
);
});
});
group('LoginUseCase — delegasi ke repository', () {
test('harus call repository.login dengan email dan password yang benar',
() async {
const email = 'evan@test.com';
const password = 'password123';
final fakeUser = UserEntity(token: 'jwt_token_xyz', role: 'ROLE_USER');
when(mockAuthRepository.login(email, password))
.thenAnswer((_) async => Right(fakeUser));
final result = await loginUseCase.call(email, password);
verify(mockAuthRepository.login(email, password)).called(1);
expect(result.isRight(), true);
});
test('harus return UserEntity dengan token saat login sukses', () async {
const email = 'guardian@test.com';
const password = 'secure123';
final fakeUser = UserEntity(token: 'guardian_jwt', role: 'ROLE_GUARDIAN');
when(mockAuthRepository.login(email, password))
.thenAnswer((_) async => Right(fakeUser));
final result = await loginUseCase.call(email, password);
result.fold(
(_) => fail('Seharusnya sukses'),
(user) {
expect(user.token, 'guardian_jwt');
expect(user.role, 'ROLE_GUARDIAN');
},
);
});
test('harus return ServerFailure saat API gagal', () async {
const email = 'user@test.com';
const password = 'password123';
when(mockAuthRepository.login(email, password)).thenAnswer(
(_) async => const Left(ServerFailure('Server tidak merespons')));
final result = await loginUseCase.call(email, password);
expect(result.isLeft(), true);
result.fold(
(failure) {
expect(failure, isA<ServerFailure>());
expect(failure.message, 'Server tidak merespons');
},
(_) => fail('Seharusnya return Left'),
);
});
test('harus return NetworkFailure saat tidak ada koneksi', () async {
const email = 'user@test.com';
const password = 'password123';
when(mockAuthRepository.login(email, password)).thenAnswer((_) async =>
const Left(NetworkFailure('Tidak ada koneksi internet')));
final result = await loginUseCase.call(email, password);
result.fold(
(failure) => expect(failure, isA<NetworkFailure>()),
(_) => fail('Seharusnya return Left'),
);
});
test('tidak boleh call repository jika validasi gagal', () async {
await loginUseCase.call('', 'password123');
verifyNever(mockAuthRepository.login('ignored', 'ignored'));
});
test('harus return Right dengan role ROLE_USER untuk user biasa', () async {
final fakeUser = UserEntity(token: 'user_token', role: 'ROLE_USER');
when(mockAuthRepository.login('user@test.com', 'user1234'))
.thenAnswer((_) async => Right(fakeUser));
final result = await loginUseCase.call('user@test.com', 'user1234');
result.fold(
(_) => fail('Seharusnya sukses'),
(user) => expect(user.role, 'ROLE_USER'),
);
});
test('password tepat 6 karakter harus lolos validasi', () async {
final fakeUser = UserEntity(token: 'tok', role: 'ROLE_USER');
when(mockAuthRepository.login('a@b.com', 'abc123'))
.thenAnswer((_) async => Right(fakeUser));
final result = await loginUseCase.call('a@b.com', 'abc123');
expect(result.isRight(), true);
});
});
group('LoginUseCase — edge cases', () {
test('harus handle karakter spesial di email', () async {
final fakeUser = UserEntity(token: 'tok', role: 'ROLE_USER');
const email = 'user+tag@example.co.id';
when(mockAuthRepository.login(email, 'password123'))
.thenAnswer((_) async => Right(fakeUser));
final result = await loginUseCase.call(email, 'password123');
expect(result.isRight(), true);
});
test('harus handle exception dari repository', () async {
when(mockAuthRepository.login('user@test.com', 'password123'))
.thenThrow(Exception('Unexpected error'));
// LoginUseCase tidak wrap exception dari repo sendiri — test ini
// memverifikasi bahwa exception propagate (akan di-handle di BLoC)
expect(
() => loginUseCase.call('user@test.com', 'password123'),
throwsException,
);
});
});
}
@@ -0,0 +1,473 @@
// test/unit/obstacle_analyzer_test.dart
//
// 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'));
});
});
}
@@ -0,0 +1,382 @@
// test/unit/register_use_case_test.dart
//
// Unit test untuk RegisterUseCase.
// Jalankan: flutter test test/unit/register_use_case_test.dart
import 'package:dartz/dartz.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
// ---------- Stubs ----------
abstract class Failure {
final String message;
const Failure(this.message);
}
class ServerFailure extends Failure {
const ServerFailure(super.message);
}
class ValidationFailure extends Failure {
const ValidationFailure(super.message);
}
class NetworkFailure extends Failure {
const NetworkFailure(super.message);
}
class UserEntity {
final String token;
final String role;
final String displayName;
final String? uniqueUserId; // hanya ROLE_USER yang punya
UserEntity({
required this.token,
required this.role,
required this.displayName,
this.uniqueUserId,
});
}
abstract class RegisterRepository {
Future<Either<Failure, UserEntity>> register({
required String email,
required String password,
required String displayName,
required String role, // 'ROLE_USER' | 'ROLE_GUARDIAN'
});
}
class MockRegisterRepository extends Mock implements RegisterRepository {}
// ---------- Use case ----------
class RegisterUseCase {
final RegisterRepository repository;
RegisterUseCase(this.repository);
Future<Either<Failure, UserEntity>> call({
required String email,
required String password,
required String displayName,
required String role,
}) async {
// Validasi email format sederhana
if (email.trim().isEmpty || !email.contains('@')) {
return const Left(ValidationFailure('Format email tidak valid'));
}
// Validasi password
if (password.length < 6) {
return const Left(ValidationFailure('Password harus minimal 6 karakter'));
}
// Validasi displayName
if (displayName.trim().isEmpty) {
return const Left(ValidationFailure('Nama tidak boleh kosong'));
}
// Validasi role
if (role != 'ROLE_USER' && role != 'ROLE_GUARDIAN') {
return const Left(ValidationFailure('Role tidak valid'));
}
return repository.register(
email: email.trim(),
password: password,
displayName: displayName.trim(),
role: role,
);
}
}
// ---------- Tests ----------
void main() {
late RegisterUseCase registerUseCase;
late MockRegisterRepository mockRepo;
setUp(() {
mockRepo = MockRegisterRepository();
registerUseCase = RegisterUseCase(mockRepo);
});
group('RegisterUseCase — validasi email', () {
test('harus gagal jika email kosong', () async {
final result = await registerUseCase.call(
email: '',
password: 'password123',
displayName: 'Evan',
role: 'ROLE_USER',
);
expect(result.isLeft(), true);
result.fold(
(f) => expect(f, isA<ValidationFailure>()),
(_) => fail('Seharusnya gagal'),
);
});
test('harus gagal jika email tanpa @', () async {
final result = await registerUseCase.call(
email: 'bukan-email',
password: 'password123',
displayName: 'Evan',
role: 'ROLE_USER',
);
expect(result.isLeft(), true);
});
test('harus berhasil dengan email valid', () async {
final fakeUser = UserEntity(
token: 'tok',
role: 'ROLE_USER',
displayName: 'Evan',
uniqueUserId: 'ABC123DEF456',
);
when(mockRepo.register(
email: 'evan@test.com',
password: 'password123',
displayName: 'Evan',
role: 'ROLE_USER',
)).thenAnswer((_) async => Right(fakeUser));
final result = await registerUseCase.call(
email: 'evan@test.com',
password: 'password123',
displayName: 'Evan',
role: 'ROLE_USER',
);
expect(result.isRight(), true);
});
});
group('RegisterUseCase — validasi password', () {
test('harus gagal jika password kurang dari 6 karakter', () async {
final result = await registerUseCase.call(
email: 'evan@test.com',
password: '12345',
displayName: 'Evan',
role: 'ROLE_USER',
);
expect(result.isLeft(), true);
result.fold(
(f) {
expect(f, isA<ValidationFailure>());
expect(f.message, contains('6'));
},
(_) => fail('Seharusnya gagal'),
);
});
test('harus gagal jika password kosong', () async {
final result = await registerUseCase.call(
email: 'evan@test.com',
password: '',
displayName: 'Evan',
role: 'ROLE_USER',
);
expect(result.isLeft(), true);
});
test('password tepat 6 karakter harus lolos', () async {
final fakeUser =
UserEntity(token: 'tok', role: 'ROLE_USER', displayName: 'Evan');
when(mockRepo.register(
email: 'evan@test.com',
password: 'abc123',
displayName: 'Evan',
role: 'ROLE_USER',
)).thenAnswer((_) async => Right(fakeUser));
final result = await registerUseCase.call(
email: 'evan@test.com',
password: 'abc123',
displayName: 'Evan',
role: 'ROLE_USER',
);
expect(result.isRight(), true);
});
});
group('RegisterUseCase — validasi displayName', () {
test('harus gagal jika displayName kosong', () async {
final result = await registerUseCase.call(
email: 'evan@test.com',
password: 'password123',
displayName: '',
role: 'ROLE_USER',
);
expect(result.isLeft(), true);
});
test('harus gagal jika displayName hanya spasi', () async {
final result = await registerUseCase.call(
email: 'evan@test.com',
password: 'password123',
displayName: ' ',
role: 'ROLE_USER',
);
expect(result.isLeft(), true);
});
});
group('RegisterUseCase — validasi role', () {
test('harus gagal jika role bukan ROLE_USER atau ROLE_GUARDIAN', () async {
final result = await registerUseCase.call(
email: 'evan@test.com',
password: 'password123',
displayName: 'Evan',
role: 'ROLE_ADMIN',
);
expect(result.isLeft(), true);
result.fold(
(f) => expect(f, isA<ValidationFailure>()),
(_) => fail('Seharusnya gagal'),
);
});
test('ROLE_USER harus valid', () async {
final fakeUser = UserEntity(
token: 'tok',
role: 'ROLE_USER',
displayName: 'Evan',
uniqueUserId: 'ABC123DEF456',
);
when(mockRepo.register(
email: 'evan@test.com',
password: 'password123',
displayName: 'Evan',
role: 'ROLE_USER',
)).thenAnswer((_) async => Right(fakeUser));
final result = await registerUseCase.call(
email: 'evan@test.com',
password: 'password123',
displayName: 'Evan',
role: 'ROLE_USER',
);
expect(result.isRight(), true);
});
test('ROLE_GUARDIAN harus valid', () async {
final fakeUser = UserEntity(
token: 'tok',
role: 'ROLE_GUARDIAN',
displayName: 'Bambang',
);
when(mockRepo.register(
email: 'bambang@test.com',
password: 'guardian123',
displayName: 'Bambang',
role: 'ROLE_GUARDIAN',
)).thenAnswer((_) async => Right(fakeUser));
final result = await registerUseCase.call(
email: 'bambang@test.com',
password: 'guardian123',
displayName: 'Bambang',
role: 'ROLE_GUARDIAN',
);
expect(result.isRight(), true);
});
});
group('RegisterUseCase — ROLE_USER mendapat uniqueUserId', () {
test('ROLE_USER harus mendapat uniqueUserId dari backend', () async {
final fakeUser = UserEntity(
token: 'user_jwt',
role: 'ROLE_USER',
displayName: 'Evan',
uniqueUserId: 'ABC123DEF456',
);
when(mockRepo.register(
email: 'evan@test.com',
password: 'password123',
displayName: 'Evan',
role: 'ROLE_USER',
)).thenAnswer((_) async => Right(fakeUser));
final result = await registerUseCase.call(
email: 'evan@test.com',
password: 'password123',
displayName: 'Evan',
role: 'ROLE_USER',
);
result.fold(
(_) => fail('Seharusnya sukses'),
(user) {
expect(user.uniqueUserId, isNotNull);
expect(user.uniqueUserId!.length, 12);
expect(user.role, 'ROLE_USER');
},
);
});
test('ROLE_GUARDIAN tidak perlu uniqueUserId', () async {
final fakeUser = UserEntity(
token: 'guardian_jwt',
role: 'ROLE_GUARDIAN',
displayName: 'Bambang',
uniqueUserId: null,
);
when(mockRepo.register(
email: 'bambang@test.com',
password: 'guardian123',
displayName: 'Bambang',
role: 'ROLE_GUARDIAN',
)).thenAnswer((_) async => Right(fakeUser));
final result = await registerUseCase.call(
email: 'bambang@test.com',
password: 'guardian123',
displayName: 'Bambang',
role: 'ROLE_GUARDIAN',
);
result.fold(
(_) => fail('Seharusnya sukses'),
(user) => expect(user.uniqueUserId, isNull),
);
});
});
group('RegisterUseCase — error dari server', () {
test('harus propagate ServerFailure dari repository', () async {
when(mockRepo.register(
email: 'taken@test.com',
password: 'password123',
displayName: 'Duplicate',
role: 'ROLE_USER',
)).thenAnswer(
(_) async => const Left(ServerFailure('Email sudah terdaftar')));
final result = await registerUseCase.call(
email: 'taken@test.com',
password: 'password123',
displayName: 'Duplicate',
role: 'ROLE_USER',
);
result.fold(
(f) {
expect(f, isA<ServerFailure>());
expect(f.message, 'Email sudah terdaftar');
},
(_) => fail('Seharusnya gagal'),
);
});
test('tidak boleh call repo jika validasi gagal', () async {
await registerUseCase.call(
email: '',
password: 'password123',
displayName: 'Evan',
role: 'ROLE_USER',
);
verifyNever(mockRepo.register(
email: 'ignored',
password: 'ignored',
displayName: 'ignored',
role: 'ignored',
));
});
});
}
@@ -0,0 +1,359 @@
// test/unit/voice_command_handler_test.dart
//
// Unit test untuk VoiceCommandHandler — matching & dispatching command.
// Jalankan: flutter test test/unit/voice_command_handler_test.dart
//
// Pure Dart test, tidak butuh platform plugin.
import 'package:flutter_test/flutter_test.dart';
// ---------- Stubs (mirror dari project asli) ----------
enum VoiceCommandKey {
openWalkguide,
startWalkguide,
stopWalkguide,
callGuardian,
openNotification,
readAllNotif,
openSos,
sendSos,
whereAmI,
openActivity,
openNavigation,
openSettings,
repeatLast,
stopTts,
}
class VoiceCommand {
final VoiceCommandKey key;
final String phrase;
final bool enabled;
const VoiceCommand(
{required this.key, required this.phrase, required this.enabled});
}
// Minimal TtsService stub
class _FakeTts {
String? lastSpeech;
bool stopped = false;
void repeatLast() => lastSpeech = 'repeated';
void stop() => stopped = true;
}
// Minimal SttService stub
class _FakeStt {
void Function(String)? onResult;
void simulateUtterance(String text) => onResult?.call(text);
}
// VoiceCommandHandler (mirror dari project)
typedef CommandCallback = void Function(VoiceCommandKey key);
class VoiceCommandHandler {
final _FakeStt _stt;
final _FakeTts _tts;
List<VoiceCommand> _commands = [];
CommandCallback? onCommand;
VoiceCommandHandler(this._stt, this._tts);
void loadCommands(List<VoiceCommand> commands) {
_commands = commands;
_stt.onResult = _processText;
}
void loadDefaultCommands() {
_commands = const [
VoiceCommand(
key: VoiceCommandKey.openWalkguide,
phrase: 'open walkguide',
enabled: true),
VoiceCommand(
key: VoiceCommandKey.startWalkguide,
phrase: 'start walkguide',
enabled: true),
VoiceCommand(
key: VoiceCommandKey.stopWalkguide,
phrase: 'stop walkguide',
enabled: true),
VoiceCommand(
key: VoiceCommandKey.callGuardian,
phrase: 'call guardian',
enabled: true),
VoiceCommand(
key: VoiceCommandKey.openNotification,
phrase: 'open notifications',
enabled: true),
VoiceCommand(
key: VoiceCommandKey.readAllNotif,
phrase: 'read all my notifications',
enabled: true),
VoiceCommand(
key: VoiceCommandKey.openSos, phrase: 'open sos', enabled: true),
VoiceCommand(
key: VoiceCommandKey.sendSos, phrase: 'send sos', enabled: true),
VoiceCommand(
key: VoiceCommandKey.whereAmI, phrase: 'where am i', enabled: true),
VoiceCommand(
key: VoiceCommandKey.openActivity,
phrase: 'open activity log',
enabled: true),
VoiceCommand(
key: VoiceCommandKey.openNavigation,
phrase: 'open navigation',
enabled: true),
VoiceCommand(
key: VoiceCommandKey.openSettings,
phrase: 'open settings',
enabled: true),
VoiceCommand(
key: VoiceCommandKey.repeatLast, phrase: 'repeat', enabled: true),
VoiceCommand(key: VoiceCommandKey.stopTts, phrase: 'stop', enabled: true),
];
_stt.onResult = _processText;
}
void _processText(String text) {
final lower = text.toLowerCase().trim();
for (final cmd in _commands) {
if (!cmd.enabled) continue;
if (lower.contains(cmd.phrase.toLowerCase())) {
_handleCommand(cmd.key);
return;
}
}
}
void _handleCommand(VoiceCommandKey key) {
onCommand?.call(key);
if (key == VoiceCommandKey.repeatLast) _tts.repeatLast();
if (key == VoiceCommandKey.stopTts) _tts.stop();
}
}
// ---------- Tests ----------
void main() {
late VoiceCommandHandler handler;
late _FakeStt fakeStt;
late _FakeTts fakeTts;
final List<VoiceCommandKey> dispatchedCommands = [];
setUp(() {
fakeStt = _FakeStt();
fakeTts = _FakeTts();
handler = VoiceCommandHandler(fakeStt, fakeTts);
dispatchedCommands.clear();
handler.loadDefaultCommands();
handler.onCommand = dispatchedCommands.add;
});
// ── BASIC COMMAND MATCHING ────────────────────────────────────────
group('default commands — exact phrase match', () {
test('"start walkguide" → startWalkguide', () {
fakeStt.simulateUtterance('start walkguide');
expect(dispatchedCommands, contains(VoiceCommandKey.startWalkguide));
});
test('"stop walkguide" → stopWalkguide', () {
fakeStt.simulateUtterance('stop walkguide');
expect(dispatchedCommands, contains(VoiceCommandKey.stopWalkguide));
});
test('"call guardian" → callGuardian', () {
fakeStt.simulateUtterance('call guardian');
expect(dispatchedCommands, contains(VoiceCommandKey.callGuardian));
});
test('"open notifications" → openNotification', () {
fakeStt.simulateUtterance('open notifications');
expect(dispatchedCommands, contains(VoiceCommandKey.openNotification));
});
test('"send sos" → sendSos', () {
fakeStt.simulateUtterance('send sos');
expect(dispatchedCommands, contains(VoiceCommandKey.sendSos));
});
test('"where am i" → whereAmI', () {
fakeStt.simulateUtterance('where am i');
expect(dispatchedCommands, contains(VoiceCommandKey.whereAmI));
});
test('"open activity log" → openActivity', () {
fakeStt.simulateUtterance('open activity log');
expect(dispatchedCommands, contains(VoiceCommandKey.openActivity));
});
test('"open navigation" → openNavigation', () {
fakeStt.simulateUtterance('open navigation');
expect(dispatchedCommands, contains(VoiceCommandKey.openNavigation));
});
test('"open settings" → openSettings', () {
fakeStt.simulateUtterance('open settings');
expect(dispatchedCommands, contains(VoiceCommandKey.openSettings));
});
test('"read all my notifications" → readAllNotif', () {
fakeStt.simulateUtterance('read all my notifications');
expect(dispatchedCommands, contains(VoiceCommandKey.readAllNotif));
});
});
// ── CASE INSENSITIVITY ────────────────────────────────────────────
group('case insensitive matching', () {
test('"START WALKGUIDE" → startWalkguide', () {
fakeStt.simulateUtterance('START WALKGUIDE');
expect(dispatchedCommands, contains(VoiceCommandKey.startWalkguide));
});
test('"Start Walkguide" → startWalkguide', () {
fakeStt.simulateUtterance('Start Walkguide');
expect(dispatchedCommands, contains(VoiceCommandKey.startWalkguide));
});
test('"CALL GUARDIAN" → callGuardian', () {
fakeStt.simulateUtterance('CALL GUARDIAN');
expect(dispatchedCommands, contains(VoiceCommandKey.callGuardian));
});
test('"Send SOS" → sendSos', () {
fakeStt.simulateUtterance('Send SOS');
expect(dispatchedCommands, contains(VoiceCommandKey.sendSos));
});
});
// ── PHRASE CONTAINS MATCH (dari kalimat lebih panjang) ───────────
group('phrase contains match', () {
test('kalimat panjang mengandung "start walkguide" → startWalkguide', () {
fakeStt.simulateUtterance('tolong start walkguide sekarang');
expect(dispatchedCommands, contains(VoiceCommandKey.startWalkguide));
});
test('kalimat panjang mengandung "send sos" → sendSos', () {
fakeStt.simulateUtterance('saya mau send sos ke guardian');
expect(dispatchedCommands, contains(VoiceCommandKey.sendSos));
});
test('kalimat panjang mengandung "call guardian" → callGuardian', () {
fakeStt.simulateUtterance('bisa tolong call guardian untuk aku');
expect(dispatchedCommands, contains(VoiceCommandKey.callGuardian));
});
});
// ── NO MATCH ──────────────────────────────────────────────────────
group('unrecognized input tidak memicu command', () {
test('kata random tidak memicu command', () {
fakeStt.simulateUtterance('halo apa kabar');
expect(dispatchedCommands, isEmpty);
});
test('string kosong tidak memicu command', () {
fakeStt.simulateUtterance('');
expect(dispatchedCommands, isEmpty);
});
test('kata mirip tapi bukan phrase yang valid tidak match', () {
fakeStt.simulateUtterance('starting guide');
expect(dispatchedCommands, isEmpty);
});
test('spasi saja tidak memicu command', () {
fakeStt.simulateUtterance(' ');
expect(dispatchedCommands, isEmpty);
});
});
// ── BUILT-IN TTS ACTIONS ─────────────────────────────────────────
group('built-in TTS actions', () {
test('"repeat" → memanggil tts.repeatLast()', () {
fakeStt.simulateUtterance('repeat');
expect(fakeTts.lastSpeech, 'repeated');
expect(dispatchedCommands, contains(VoiceCommandKey.repeatLast));
});
test('"stop" → memanggil tts.stop()', () {
fakeStt.simulateUtterance('stop');
expect(fakeTts.stopped, true);
expect(dispatchedCommands, contains(VoiceCommandKey.stopTts));
});
});
// ── DISABLED COMMAND ─────────────────────────────────────────────
group('disabled command tidak memicu callback', () {
test('command disabled tidak di-dispatch', () {
handler.loadCommands([
const VoiceCommand(
key: VoiceCommandKey.sendSos, phrase: 'send sos', enabled: false),
const VoiceCommand(
key: VoiceCommandKey.callGuardian,
phrase: 'call guardian',
enabled: true),
]);
fakeStt.simulateUtterance('send sos');
expect(dispatchedCommands, isEmpty);
});
test('command enabled di-dispatch, disabled tidak', () {
handler.loadCommands([
const VoiceCommand(
key: VoiceCommandKey.sendSos, phrase: 'send sos', enabled: false),
const VoiceCommand(
key: VoiceCommandKey.callGuardian,
phrase: 'call guardian',
enabled: true),
]);
fakeStt.simulateUtterance('call guardian');
expect(dispatchedCommands, contains(VoiceCommandKey.callGuardian));
expect(dispatchedCommands, isNot(contains(VoiceCommandKey.sendSos)));
});
});
// ── CUSTOM COMMANDS ───────────────────────────────────────────────
group('loadCommands dengan custom phrase', () {
test('custom phrase "mulai jalan" → startWalkguide', () {
handler.loadCommands([
const VoiceCommand(
key: VoiceCommandKey.startWalkguide,
phrase: 'mulai jalan',
enabled: true),
]);
fakeStt.simulateUtterance('mulai jalan');
expect(dispatchedCommands, contains(VoiceCommandKey.startWalkguide));
});
test('custom phrase "darurat" → sendSos', () {
handler.loadCommands([
const VoiceCommand(
key: VoiceCommandKey.sendSos, phrase: 'darurat', enabled: true),
]);
fakeStt.simulateUtterance('ini darurat tolong');
expect(dispatchedCommands, contains(VoiceCommandKey.sendSos));
});
});
// ── FIRST MATCH WINS ─────────────────────────────────────────────
group('hanya satu command yang dipicu per utterance', () {
test('hanya command pertama yang match yang dipicu', () {
fakeStt.simulateUtterance('stop walkguide now');
// "stop walkguide" match stopWalkguide, tapi "stop" juga ada di list
// Urutan di default list: stopWalkguide (index 2) sebelum stopTts (index 13)
expect(dispatchedCommands.length, 1);
});
});
}