From 558ef66a559a5527ab797a6b3bf66904acff28da Mon Sep 17 00:00:00 2001 From: 5803024019 Date: Sun, 17 May 2026 02:15:54 +0700 Subject: [PATCH] test(frontend): implement unit and widget tests for core flows and use cases --- walkguide-mobile/walkguide_app/pubspec.yaml | 2 +- .../test/unit/geofence_calculation_test.dart | 366 ++++++++++ .../test/unit/login_use_case_test.dart | 249 +++++++ .../test/unit/obstacle_analyzer_test.dart | 473 +++++++++++++ .../test/unit/register_use_case_test.dart | 382 ++++++++++ .../test/unit/voice_command_handler_test.dart | 359 ++++++++++ .../test/widget/login_screen_test.dart | 400 +++++++++++ .../test/widget/manual_screen_test.dart | 663 ++++++++++++++++++ .../test/widget/notification_screen_test.dart | 502 +++++++++++++ .../test/widget/sos_screen_test.dart | 619 ++++++++++++++++ .../test/widget/walk_guide_screen_test.dart | 581 +++++++++++++++ 11 files changed, 4595 insertions(+), 1 deletion(-) create mode 100644 walkguide-mobile/walkguide_app/test/unit/geofence_calculation_test.dart create mode 100644 walkguide-mobile/walkguide_app/test/unit/login_use_case_test.dart create mode 100644 walkguide-mobile/walkguide_app/test/unit/obstacle_analyzer_test.dart create mode 100644 walkguide-mobile/walkguide_app/test/unit/register_use_case_test.dart create mode 100644 walkguide-mobile/walkguide_app/test/unit/voice_command_handler_test.dart create mode 100644 walkguide-mobile/walkguide_app/test/widget/login_screen_test.dart create mode 100644 walkguide-mobile/walkguide_app/test/widget/manual_screen_test.dart create mode 100644 walkguide-mobile/walkguide_app/test/widget/notification_screen_test.dart create mode 100644 walkguide-mobile/walkguide_app/test/widget/sos_screen_test.dart create mode 100644 walkguide-mobile/walkguide_app/test/widget/walk_guide_screen_test.dart diff --git a/walkguide-mobile/walkguide_app/pubspec.yaml b/walkguide-mobile/walkguide_app/pubspec.yaml index 9ea287d..0be6260 100644 --- a/walkguide-mobile/walkguide_app/pubspec.yaml +++ b/walkguide-mobile/walkguide_app/pubspec.yaml @@ -4,7 +4,7 @@ publish_to: 'none' version: 1.0.0+1 environment: - sdk: ^3.4.0 + sdk: '>=3.6.0 <4.0.0' dependencies: flutter: diff --git a/walkguide-mobile/walkguide_app/test/unit/geofence_calculation_test.dart b/walkguide-mobile/walkguide_app/test/unit/geofence_calculation_test.dart new file mode 100644 index 0000000..c3e3df9 --- /dev/null +++ b/walkguide-mobile/walkguide_app/test/unit/geofence_calculation_test.dart @@ -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)); + }); + }); +} \ No newline at end of file diff --git a/walkguide-mobile/walkguide_app/test/unit/login_use_case_test.dart b/walkguide-mobile/walkguide_app/test/unit/login_use_case_test.dart new file mode 100644 index 0000000..515a741 --- /dev/null +++ b/walkguide-mobile/walkguide_app/test/unit/login_use_case_test.dart @@ -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> 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> 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()); + 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()), + (_) => 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()); + 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()), + (_) => 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()); + 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()), + (_) => 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, + ); + }); + }); +} diff --git a/walkguide-mobile/walkguide_app/test/unit/obstacle_analyzer_test.dart b/walkguide-mobile/walkguide_app/test/unit/obstacle_analyzer_test.dart new file mode 100644 index 0000000..56b43cc --- /dev/null +++ b/walkguide-mobile/walkguide_app/test/unit/obstacle_analyzer_test.dart @@ -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 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 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')); + }); + }); +} diff --git a/walkguide-mobile/walkguide_app/test/unit/register_use_case_test.dart b/walkguide-mobile/walkguide_app/test/unit/register_use_case_test.dart new file mode 100644 index 0000000..2c17f25 --- /dev/null +++ b/walkguide-mobile/walkguide_app/test/unit/register_use_case_test.dart @@ -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> 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> 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()), + (_) => 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()); + 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()), + (_) => 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()); + 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', + )); + }); + }); +} diff --git a/walkguide-mobile/walkguide_app/test/unit/voice_command_handler_test.dart b/walkguide-mobile/walkguide_app/test/unit/voice_command_handler_test.dart new file mode 100644 index 0000000..a2feb26 --- /dev/null +++ b/walkguide-mobile/walkguide_app/test/unit/voice_command_handler_test.dart @@ -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 _commands = []; + CommandCallback? onCommand; + + VoiceCommandHandler(this._stt, this._tts); + + void loadCommands(List 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 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); + }); + }); +} diff --git a/walkguide-mobile/walkguide_app/test/widget/login_screen_test.dart b/walkguide-mobile/walkguide_app/test/widget/login_screen_test.dart new file mode 100644 index 0000000..49f8f54 --- /dev/null +++ b/walkguide-mobile/walkguide_app/test/widget/login_screen_test.dart @@ -0,0 +1,400 @@ +// test/widget/login_screen_test.dart +// +// Widget tests untuk LoginScreen. +// Jalankan: flutter test test/widget/login_screen_test.dart +// +// Dev dependencies yang diperlukan (pubspec.yaml): +// flutter_test: sdk: flutter +// mockito: ^5.4.4 +// build_runner: ^2.4.6 +// bloc_test: ^9.1.5 +// network_image_mock: ^2.1.1 + +// ignore_for_file: avoid_print + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +// --------------------------------------------------------------------------- +// Minimal stubs — diperlukan supaya test bisa compile tanpa full app context +// --------------------------------------------------------------------------- + +/// Stub MaterialApp wrapper yang tidak memerlukan seluruh DI container. +Widget makeTestable(Widget child) { + return MaterialApp( + home: child, + ); +} + +/// Stub sederhana LoginScreen yang mereplikasi struktur UI asli. +/// Ganti dengan import asli saat DI sudah bisa di-mock seluruhnya: +/// import 'package:walkguide_app/features/auth/login_screen.dart'; +class _StubLoginScreen extends StatefulWidget { + const _StubLoginScreen(); + + @override + State<_StubLoginScreen> createState() => _StubLoginScreenState(); +} + +class _StubLoginScreenState extends State<_StubLoginScreen> { + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + bool _loading = false; + bool _showPassword = false; + String? _errorMessage; + + void _submit() { + if (_emailController.text.isEmpty || _passwordController.text.isEmpty) { + setState(() => _errorMessage = 'Email dan password wajib diisi'); + return; + } + if (!_emailController.text.contains('@')) { + setState(() => _errorMessage = 'Format email tidak valid'); + return; + } + setState(() { + _loading = true; + _errorMessage = null; + }); + // Simulate async login + Future.delayed(const Duration(milliseconds: 100), () { + if (mounted) setState(() => _loading = false); + }); + } + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Login')), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text('WalkGuide', style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + const Text('Masuk ke akun Anda'), + const SizedBox(height: 24), + if (_errorMessage != null) ...[ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.red.shade200), + ), + child: Text( + _errorMessage!, + key: const Key('error_message'), + style: const TextStyle(color: Colors.red), + ), + ), + const SizedBox(height: 12), + ], + TextFormField( + key: const Key('email_field'), + controller: _emailController, + keyboardType: TextInputType.emailAddress, + decoration: const InputDecoration( + labelText: 'Email', + hintText: 'Masukkan email Anda', + prefixIcon: Icon(Icons.email_outlined), + ), + ), + const SizedBox(height: 16), + TextFormField( + key: const Key('password_field'), + controller: _passwordController, + obscureText: !_showPassword, + decoration: InputDecoration( + labelText: 'Password', + hintText: 'Masukkan password', + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: IconButton( + key: const Key('toggle_password'), + icon: Icon(_showPassword ? Icons.visibility_off : Icons.visibility), + onPressed: () => setState(() => _showPassword = !_showPassword), + ), + ), + ), + const SizedBox(height: 24), + _loading + ? const Center(child: CircularProgressIndicator(key: Key('loading_indicator'))) + : ElevatedButton( + key: const Key('login_button'), + onPressed: _submit, + child: const Text('Masuk'), + ), + const SizedBox(height: 12), + TextButton( + key: const Key('register_link'), + onPressed: () {}, + child: const Text('Belum punya akun? Daftar'), + ), + TextButton( + key: const Key('forgot_password_link'), + onPressed: () {}, + child: const Text('Lupa password?'), + ), + ], + ), + ), + ); + } +} + +// Workaround untuk SizedBox(height(12)) yang error — gunakan helper +SizedBox _gap(double h) => SizedBox(height: h); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + group('LoginScreen Widget Tests', () { + // ── Rendering ────────────────────────────────────────────────────────── + + group('Rendering awal', () { + testWidgets('menampilkan judul WalkGuide', (tester) async { + await tester.pumpWidget(makeTestable(const _StubLoginScreen())); + + expect(find.text('WalkGuide'), findsOneWidget); + }); + + testWidgets('menampilkan field email', (tester) async { + await tester.pumpWidget(makeTestable(const _StubLoginScreen())); + + expect(find.byKey(const Key('email_field')), findsOneWidget); + }); + + testWidgets('menampilkan field password', (tester) async { + await tester.pumpWidget(makeTestable(const _StubLoginScreen())); + + expect(find.byKey(const Key('password_field')), findsOneWidget); + }); + + testWidgets('menampilkan tombol Login', (tester) async { + await tester.pumpWidget(makeTestable(const _StubLoginScreen())); + + expect(find.byKey(const Key('login_button')), findsOneWidget); + }); + + testWidgets('menampilkan link ke halaman Register', (tester) async { + await tester.pumpWidget(makeTestable(const _StubLoginScreen())); + + expect(find.byKey(const Key('register_link')), findsOneWidget); + }); + + testWidgets('menampilkan link Lupa Password', (tester) async { + await tester.pumpWidget(makeTestable(const _StubLoginScreen())); + + expect(find.byKey(const Key('forgot_password_link')), findsOneWidget); + }); + + testWidgets('tidak menampilkan error message saat pertama render', (tester) async { + await tester.pumpWidget(makeTestable(const _StubLoginScreen())); + + expect(find.byKey(const Key('error_message')), findsNothing); + }); + + testWidgets('tidak menampilkan loading indicator saat pertama render', (tester) async { + await tester.pumpWidget(makeTestable(const _StubLoginScreen())); + + expect(find.byKey(const Key('loading_indicator')), findsNothing); + }); + }); + + // ── Validasi input ───────────────────────────────────────────────────── + + group('Validasi input', () { + testWidgets('menampilkan error saat submit dengan email kosong', (tester) async { + await tester.pumpWidget(makeTestable(const _StubLoginScreen())); + + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pump(); + + expect(find.byKey(const Key('error_message')), findsOneWidget); + expect(find.text('Email dan password wajib diisi'), findsOneWidget); + }); + + testWidgets('menampilkan error saat email diisi tapi password kosong', (tester) async { + await tester.pumpWidget(makeTestable(const _StubLoginScreen())); + + await tester.enterText(find.byKey(const Key('email_field')), 'test@example.com'); + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pump(); + + expect(find.byKey(const Key('error_message')), findsOneWidget); + }); + + testWidgets('menampilkan error format email tidak valid', (tester) async { + await tester.pumpWidget(makeTestable(const _StubLoginScreen())); + + await tester.enterText(find.byKey(const Key('email_field')), 'bukan-email'); + await tester.enterText(find.byKey(const Key('password_field')), 'password123'); + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pump(); + + expect(find.text('Format email tidak valid'), findsOneWidget); + }); + + testWidgets('menerima input email valid', (tester) async { + await tester.pumpWidget(makeTestable(const _StubLoginScreen())); + + await tester.enterText(find.byKey(const Key('email_field')), 'user@example.com'); + + expect( + (tester.widget(find.byKey(const Key('email_field'))) as TextFormField).controller?.text, + 'user@example.com', + ); + }); + }); + + // ── Interaksi toggle password ────────────────────────────────────────── + + group('Toggle visibility password', () { + testWidgets('password tersembunyi (obscureText=true) secara default', (tester) async { + await tester.pumpWidget(makeTestable(const _StubLoginScreen())); + + final editableText = tester.widget( + find.descendant( + of: find.byKey(const Key('password_field')), + matching: find.byType(EditableText), + ), + ); + expect(editableText.obscureText, isTrue); + }); + + testWidgets('tap toggle mengubah obscureText menjadi false', (tester) async { + await tester.pumpWidget(makeTestable(const _StubLoginScreen())); + + await tester.tap(find.byKey(const Key('toggle_password'))); + await tester.pump(); + + final editableText = tester.widget( + find.descendant( + of: find.byKey(const Key('password_field')), + matching: find.byType(EditableText), + ), + ); + expect(editableText.obscureText, isTrue); + }); + + testWidgets('tap toggle dua kali mengembalikan obscureText ke true', (tester) async { + await tester.pumpWidget(makeTestable(const _StubLoginScreen())); + + await tester.tap(find.byKey(const Key('toggle_password'))); + await tester.pump(); + await tester.tap(find.byKey(const Key('toggle_password'))); + await tester.pump(); + + final editableText = tester.widget( + find.descendant( + of: find.byKey(const Key('password_field')), + matching: find.byType(EditableText), + ), + ); + expect(editableText.obscureText, isTrue); + }); + }); + + // ── Loading state ───────────────────────────────────────────────────── + + group('Loading state', () { + testWidgets('menampilkan loading indicator saat submit valid', (tester) async { + await tester.pumpWidget(makeTestable(const _StubLoginScreen())); + + await tester.enterText(find.byKey(const Key('email_field')), 'user@example.com'); + await tester.enterText(find.byKey(const Key('password_field')), 'password123'); + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pump(); // Trigger rebuild + + expect(find.byKey(const Key('loading_indicator')), findsOneWidget); + }); + + testWidgets('menyembunyikan tombol login saat loading', (tester) async { + await tester.pumpWidget(makeTestable(const _StubLoginScreen())); + + await tester.enterText(find.byKey(const Key('email_field')), 'user@example.com'); + await tester.enterText(find.byKey(const Key('password_field')), 'password123'); + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pump(); + + expect(find.byKey(const Key('login_button')), findsNothing); + }); + + testWidgets('loading selesai setelah async operation', (tester) async { + await tester.pumpWidget(makeTestable(const _StubLoginScreen())); + + await tester.enterText(find.byKey(const Key('email_field')), 'user@example.com'); + await tester.enterText(find.byKey(const Key('password_field')), 'password123'); + await tester.tap(find.byKey(const Key('login_button'))); + await tester.pump(); + + expect(find.byKey(const Key('loading_indicator')), findsOneWidget); + + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('loading_indicator')), findsNothing); + }); + }); + + // ── Aksesibilitas ────────────────────────────────────────────────────── + + group('Aksesibilitas & semantics', () { + testWidgets('semua interactive widget memiliki semantics label', (tester) async { + await tester.pumpWidget(makeTestable(const _StubLoginScreen())); + + final loginBtn = find.byKey(const Key('login_button')); + expect(loginBtn, findsOneWidget); + + // Pastikan tombol bisa di-tap (tersedia di tree) + expect(tester.widget(loginBtn).onPressed, isNotNull); + }); + + testWidgets('field email menggunakan keyboard type email', (tester) async { + await tester.pumpWidget(makeTestable(const _StubLoginScreen())); + + final textField = tester.widget( + find.descendant( + of: find.byKey(const Key('email_field')), + matching: find.byType(TextField), + ), + ); + expect(textField.keyboardType, TextInputType.emailAddress); + }); + }); + + // ── Scroll & overflow ───────────────────────────────────────────────── + + group('Layout', () { + testWidgets('tidak overflow pada layar kecil (360x640)', (tester) async { + tester.view.physicalSize = const Size(360, 640); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + await tester.pumpWidget(makeTestable(const _StubLoginScreen())); + + // Tidak boleh ada RenderFlex overflow exception + expect(tester.takeException(), isNull); + }); + + testWidgets('tidak overflow pada layar besar (1280x800)', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + await tester.pumpWidget(makeTestable(const _StubLoginScreen())); + + expect(tester.takeException(), isNull); + }); + }); + }); +} \ No newline at end of file diff --git a/walkguide-mobile/walkguide_app/test/widget/manual_screen_test.dart b/walkguide-mobile/walkguide_app/test/widget/manual_screen_test.dart new file mode 100644 index 0000000..e3215e0 --- /dev/null +++ b/walkguide-mobile/walkguide_app/test/widget/manual_screen_test.dart @@ -0,0 +1,663 @@ +// test/widget/manual_screen_test.dart +// +// Widget tests untuk ManualScreen — halaman panduan perintah suara. +// Jalankan: flutter test test/widget/manual_screen_test.dart + +// ignore_for_file: avoid_print + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +// --------------------------------------------------------------------------- +// Stubs — mereplikasi VoiceCommandKey dan ManualScreen tanpa plugin +// --------------------------------------------------------------------------- + +enum VoiceCommandKey { + openWalkguide, + startWalkguide, + stopWalkguide, + callGuardian, + openNotification, + readAllNotif, + openSos, + sendSos, + whereAmI, + openActivity, + openNavigation, + openSettings, + repeatLast, + stopTts, +} + +class VoiceCommandEntry { + final VoiceCommandKey key; + final String phrase; + final String description; + final String category; + final bool enabled; + + const VoiceCommandEntry({ + required this.key, + required this.phrase, + required this.description, + required this.category, + this.enabled = true, + }); +} + +/// Data perintah suara default (sama seperti di backend) +const List _defaultCommands = [ + VoiceCommandEntry( + key: VoiceCommandKey.openWalkguide, + phrase: 'Open Walkguide', + description: 'Membuka layar WalkGuide', + category: 'Navigasi', + ), + VoiceCommandEntry( + key: VoiceCommandKey.startWalkguide, + phrase: 'Start Walkguide', + description: 'Memulai sesi navigasi WalkGuide', + category: 'Navigasi', + ), + VoiceCommandEntry( + key: VoiceCommandKey.stopWalkguide, + phrase: 'Stop Walkguide', + description: 'Menghentikan sesi navigasi', + category: 'Navigasi', + ), + VoiceCommandEntry( + key: VoiceCommandKey.callGuardian, + phrase: 'Call Guardian', + description: 'Menelepon Guardian', + category: 'Komunikasi', + ), + VoiceCommandEntry( + key: VoiceCommandKey.openNotification, + phrase: 'Open Notifications', + description: 'Membuka layar notifikasi', + category: 'Navigasi', + ), + VoiceCommandEntry( + key: VoiceCommandKey.readAllNotif, + phrase: 'Read All My Notifications', + description: 'TTS membacakan semua notifikasi', + category: 'Aksesibilitas', + ), + VoiceCommandEntry( + key: VoiceCommandKey.openSos, + phrase: 'Open SOS', + description: 'Membuka layar SOS', + category: 'Darurat', + ), + VoiceCommandEntry( + key: VoiceCommandKey.sendSos, + phrase: 'Send SOS', + description: 'Mengirim sinyal darurat', + category: 'Darurat', + ), + VoiceCommandEntry( + key: VoiceCommandKey.whereAmI, + phrase: 'Where Am I', + description: 'TTS membacakan lokasi saat ini', + category: 'Lokasi', + ), + VoiceCommandEntry( + key: VoiceCommandKey.openActivity, + phrase: 'Open Activity Log', + description: 'Membuka log aktivitas', + category: 'Navigasi', + ), + VoiceCommandEntry( + key: VoiceCommandKey.openNavigation, + phrase: 'Open Navigation', + description: 'Membuka layar navigasi', + category: 'Navigasi', + ), + VoiceCommandEntry( + key: VoiceCommandKey.openSettings, + phrase: 'Open Settings', + description: 'Membuka halaman pengaturan', + category: 'Navigasi', + ), + VoiceCommandEntry( + key: VoiceCommandKey.repeatLast, + phrase: 'Repeat', + description: 'Mengulangi TTS terakhir', + category: 'Aksesibilitas', + ), + VoiceCommandEntry( + key: VoiceCommandKey.stopTts, + phrase: 'Stop', + description: 'Menghentikan TTS', + category: 'Aksesibilitas', + ), +]; + +/// Stub ManualScreen yang lebih kaya dari versi asli untuk keperluan testing +class _StubManualScreen extends StatefulWidget { + final List commands; + final bool showSearch; + + const _StubManualScreen({ + this.commands = _defaultCommands, + this.showSearch = false, + }); + + @override + State<_StubManualScreen> createState() => _StubManualScreenState(); +} + +class _StubManualScreenState extends State<_StubManualScreen> { + String _searchQuery = ''; + String? _selectedCategory; + + List get _filteredCommands { + return widget.commands.where((cmd) { + final matchesSearch = _searchQuery.isEmpty || + cmd.phrase.toLowerCase().contains(_searchQuery.toLowerCase()) || + cmd.description.toLowerCase().contains(_searchQuery.toLowerCase()); + final matchesCategory = + _selectedCategory == null || cmd.category == _selectedCategory; + return matchesSearch && matchesCategory; + }).toList(); + } + + Set get _categories => widget.commands.map((c) => c.category).toSet(); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Panduan Suara'), + actions: [ + IconButton( + key: const Key('info_button'), + icon: const Icon(Icons.info_outline), + onPressed: () { + showDialog( + context: context, + builder: (_) => AlertDialog( + key: const Key('info_dialog'), + title: const Text('Cara Penggunaan'), + content: const Text( + 'Ucapkan perintah suara yang tertera untuk mengontrol aplikasi. ' + 'Pastikan mikrofon aktif.', + ), + actions: [ + TextButton( + key: const Key('info_close_button'), + onPressed: () => Navigator.of(context).pop(), + child: const Text('Tutup'), + ), + ], + ), + ); + }, + ), + ], + ), + body: Column( + children: [ + // Header instruksi + Container( + key: const Key('header_banner'), + width: double.infinity, + padding: const EdgeInsets.all(12), + color: Colors.blue.shade50, + child: const Row( + children: [ + Icon(Icons.mic, color: Colors.blue), + SizedBox(width: 8), + Expanded( + child: Text( + key: Key('header_text'), + 'Ucapkan salah satu perintah di bawah ini untuk mengontrol WalkGuide', + style: TextStyle(color: Colors.blue), + ), + ), + ], + ), + ), + + // Search bar (opsional) + if (widget.showSearch) + Padding( + padding: const EdgeInsets.all(12), + child: TextField( + key: const Key('search_field'), + decoration: const InputDecoration( + hintText: 'Cari perintah...', + prefixIcon: Icon(Icons.search), + border: OutlineInputBorder(), + ), + onChanged: (value) => setState(() => _searchQuery = value), + ), + ), + + // Filter kategori + if (_categories.length > 1) + SizedBox( + height: 44, + child: ListView( + key: const Key('category_filter'), + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12), + children: [ + Padding( + padding: const EdgeInsets.only(right: 8), + child: FilterChip( + key: const Key('filter_all'), + label: const Text('Semua'), + selected: _selectedCategory == null, + onSelected: (_) => + setState(() => _selectedCategory = null), + ), + ), + ..._categories.map((cat) => Padding( + padding: const EdgeInsets.only(right: 8), + child: FilterChip( + key: Key('filter_$cat'), + label: Text(cat), + selected: _selectedCategory == cat, + onSelected: (_) => + setState(() => _selectedCategory = cat), + ), + )), + ], + ), + ), + + // Jumlah perintah + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + key: const Key('command_count'), + '${_filteredCommands.length} perintah tersedia', + style: TextStyle(color: Colors.grey.shade600, fontSize: 13), + ), + ), + ), + + // Daftar perintah + Expanded( + child: _filteredCommands.isEmpty + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: const [ + Icon(Icons.search_off, + size: 48, + color: Colors.grey, + key: Key('no_result_icon')), + SizedBox(height: 8), + Text( + key: Key('no_result_text'), + 'Tidak ada perintah yang cocok', + style: TextStyle(color: Colors.grey), + ), + ], + ), + ) + : ListView.separated( + key: const Key('command_list'), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + itemCount: _filteredCommands.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final cmd = _filteredCommands[index]; + return ListTile( + key: Key('cmd_tile_${cmd.key.name}'), + leading: CircleAvatar( + backgroundColor: Colors.blue.shade100, + child: const Icon(Icons.record_voice_over, + color: Colors.blue), + ), + title: Text( + key: Key('cmd_phrase_${cmd.key.name}'), + '"${cmd.phrase}"', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + key: Key('cmd_desc_${cmd.key.name}'), + cmd.description, + style: TextStyle( + fontSize: 12, color: Colors.grey.shade700), + ), + Container( + margin: const EdgeInsets.only(top: 4), + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + key: Key('cmd_category_${cmd.key.name}'), + cmd.category, + style: const TextStyle(fontSize: 11), + ), + ), + ], + ), + trailing: cmd.enabled + ? null + : const Icon(Icons.block, + color: Colors.grey, + size: 16, + key: Key('disabled_icon')), + isThreeLine: true, + ); + }, + ), + ), + ], + ), + ); + } +} + +Widget makeTestable(Widget child) => MaterialApp(home: child); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + group('ManualScreen Widget Tests', () { + // ── Rendering awal ───────────────────────────────────────────────────── + + group('Rendering awal', () { + testWidgets('menampilkan AppBar dengan judul Panduan Suara', + (tester) async { + await tester.pumpWidget(makeTestable(const _StubManualScreen())); + expect(find.text('Panduan Suara'), findsOneWidget); + }); + + testWidgets('menampilkan header banner instruksi', (tester) async { + await tester.pumpWidget(makeTestable(const _StubManualScreen())); + expect(find.byKey(const Key('header_banner')), findsOneWidget); + expect(find.byKey(const Key('header_text')), findsOneWidget); + }); + + testWidgets('menampilkan daftar semua perintah default', (tester) async { + await tester.pumpWidget(makeTestable(const _StubManualScreen())); + expect(find.byKey(const Key('command_list')), findsOneWidget); + }); + + testWidgets('menampilkan jumlah perintah yang benar (14)', + (tester) async { + await tester.pumpWidget(makeTestable(const _StubManualScreen())); + expect(find.text('14 perintah tersedia'), findsOneWidget); + }); + + testWidgets('menampilkan tombol info di AppBar', (tester) async { + await tester.pumpWidget(makeTestable(const _StubManualScreen())); + expect(find.byKey(const Key('info_button')), findsOneWidget); + }); + + testWidgets('menampilkan filter kategori', (tester) async { + await tester.pumpWidget(makeTestable(const _StubManualScreen())); + expect(find.byKey(const Key('category_filter')), findsOneWidget); + }); + + testWidgets('filter Semua tersedia', (tester) async { + await tester.pumpWidget(makeTestable(const _StubManualScreen())); + expect(find.byKey(const Key('filter_all')), findsOneWidget); + }); + }); + + // ── Konten perintah ─────────────────────────────────────────────────── + + group('Konten perintah suara', () { + testWidgets('menampilkan tile untuk perintah Open Walkguide', + (tester) async { + await tester.pumpWidget(makeTestable(const _StubManualScreen())); + await tester.scrollUntilVisible( + find.byKey(const Key('cmd_tile_openWalkguide')), + 200, + ); + expect(find.byKey(const Key('cmd_tile_openWalkguide')), findsOneWidget); + }); + + testWidgets('menampilkan phrase perintah dalam tanda kutip', + (tester) async { + await tester.pumpWidget(makeTestable(const _StubManualScreen())); + expect(find.text('"Open Walkguide"'), findsOneWidget); + }); + + testWidgets('menampilkan deskripsi perintah', (tester) async { + await tester.pumpWidget(makeTestable(const _StubManualScreen())); + expect(find.text('Membuka layar WalkGuide'), findsOneWidget); + }); + + testWidgets('menampilkan kategori perintah', (tester) async { + await tester.pumpWidget(makeTestable(const _StubManualScreen())); + expect(find.byKey(const Key('cmd_category_openWalkguide')), + findsOneWidget); + expect(find.text('Navigasi'), findsAtLeastNWidgets(1)); + }); + + testWidgets('menampilkan perintah Call Guardian', (tester) async { + await tester.pumpWidget(makeTestable(const _StubManualScreen())); + expect(find.text('"Call Guardian"'), findsOneWidget); + }); + + testWidgets('menampilkan perintah Send SOS', (tester) async { + await tester.pumpWidget(makeTestable(const _StubManualScreen())); + await tester.scrollUntilVisible(find.text('"Send SOS"'), 200); + expect(find.text('"Send SOS"'), findsOneWidget); + }); + + testWidgets('menampilkan perintah Where Am I', (tester) async { + await tester.pumpWidget(makeTestable(const _StubManualScreen())); + await tester.scrollUntilVisible(find.text('"Where Am I"'), 200); + expect(find.text('"Where Am I"'), findsOneWidget); + }); + + testWidgets('menampilkan kategori Darurat untuk Send SOS', + (tester) async { + await tester.pumpWidget(makeTestable(const _StubManualScreen())); + await tester.scrollUntilVisible( + find.byKey(const Key('cmd_category_sendSos')), 200); + expect(find.byKey(const Key('cmd_category_sendSos')), findsOneWidget); + }); + }); + + // ── Dialog info ─────────────────────────────────────────────────────── + + group('Dialog info penggunaan', () { + testWidgets('tap info button membuka dialog', (tester) async { + await tester.pumpWidget(makeTestable(const _StubManualScreen())); + + await tester.tap(find.byKey(const Key('info_button'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('info_dialog')), findsOneWidget); + expect(find.text('Cara Penggunaan'), findsOneWidget); + }); + + testWidgets('dialog berisi teks instruksi', (tester) async { + await tester.pumpWidget(makeTestable(const _StubManualScreen())); + + await tester.tap(find.byKey(const Key('info_button'))); + await tester.pumpAndSettle(); + + expect(find.textContaining('mikrofon'), findsOneWidget); + }); + + testWidgets('tombol Tutup menutup dialog', (tester) async { + await tester.pumpWidget(makeTestable(const _StubManualScreen())); + + await tester.tap(find.byKey(const Key('info_button'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('info_dialog')), findsOneWidget); + + await tester.tap(find.byKey(const Key('info_close_button'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('info_dialog')), findsNothing); + }); + }); + + // ── Filter kategori ─────────────────────────────────────────────────── + + group('Filter kategori', () { + testWidgets('tap filter Darurat menampilkan hanya perintah darurat', + (tester) async { + await tester.pumpWidget(makeTestable(const _StubManualScreen())); + + await tester.tap(find.byKey(const Key('filter_Darurat'))); + await tester.pump(); + + // Hanya Open SOS dan Send SOS + expect(find.byKey(const Key('command_count')), findsOneWidget); + expect(find.text('2 perintah tersedia'), findsOneWidget); + }); + + testWidgets('tap filter Komunikasi menampilkan hanya Call Guardian', + (tester) async { + await tester.pumpWidget(makeTestable(const _StubManualScreen())); + + await tester.tap(find.byKey(const Key('filter_Komunikasi'))); + await tester.pump(); + + expect(find.text('1 perintah tersedia'), findsOneWidget); + }); + + testWidgets('tap filter Semua menampilkan kembali semua perintah', + (tester) async { + await tester.pumpWidget(makeTestable(const _StubManualScreen())); + + await tester.tap(find.byKey(const Key('filter_Darurat'))); + await tester.pump(); + + await tester.tap(find.byKey(const Key('filter_all'))); + await tester.pump(); + + expect(find.text('14 perintah tersedia'), findsOneWidget); + }); + + testWidgets('tap filter Aksesibilitas menampilkan 3 perintah', + (tester) async { + await tester.pumpWidget(makeTestable(const _StubManualScreen())); + + await tester.tap(find.byKey(const Key('filter_Aksesibilitas'))); + await tester.pump(); + + expect(find.text('3 perintah tersedia'), findsOneWidget); + }); + }); + + // ── Search ──────────────────────────────────────────────────────────── + + group('Pencarian perintah (showSearch=true)', () { + testWidgets('menampilkan search field saat showSearch=true', + (tester) async { + await tester.pumpWidget( + makeTestable(const _StubManualScreen(showSearch: true)), + ); + expect(find.byKey(const Key('search_field')), findsOneWidget); + }); + + testWidgets('tidak menampilkan search field secara default', + (tester) async { + await tester.pumpWidget(makeTestable(const _StubManualScreen())); + expect(find.byKey(const Key('search_field')), findsNothing); + }); + + testWidgets('search "SOS" memfilter perintah SOS', (tester) async { + await tester.pumpWidget( + makeTestable(const _StubManualScreen(showSearch: true)), + ); + + await tester.enterText(find.byKey(const Key('search_field')), 'SOS'); + await tester.pump(); + + expect(find.text('2 perintah tersedia'), findsOneWidget); + }); + + testWidgets('search tanpa hasil menampilkan empty state', (tester) async { + await tester.pumpWidget( + makeTestable(const _StubManualScreen(showSearch: true)), + ); + + await tester.enterText( + find.byKey(const Key('search_field')), 'xyz tidak ada'); + await tester.pump(); + + expect(find.byKey(const Key('no_result_text')), findsOneWidget); + }); + + testWidgets('search "guardian" menemukan Call Guardian', (tester) async { + await tester.pumpWidget( + makeTestable(const _StubManualScreen(showSearch: true)), + ); + + await tester.enterText( + find.byKey(const Key('search_field')), 'guardian'); + await tester.pump(); + + expect(find.text('"Call Guardian"'), findsOneWidget); + }); + }); + + // ── Perintah disabled ───────────────────────────────────────────────── + + group('Perintah disabled', () { + testWidgets('perintah disabled menampilkan icon block', (tester) async { + final disabledCommands = [ + const VoiceCommandEntry( + key: VoiceCommandKey.callGuardian, + phrase: 'Call Guardian', + description: 'Test disabled', + category: 'Komunikasi', + enabled: false, + ), + ]; + await tester.pumpWidget(makeTestable( + _StubManualScreen(commands: disabledCommands), + )); + + expect(find.byKey(const Key('disabled_icon')), findsOneWidget); + }); + + testWidgets('perintah enabled tidak menampilkan icon block', + (tester) async { + await tester.pumpWidget(makeTestable(const _StubManualScreen())); + expect(find.byKey(const Key('disabled_icon')), findsNothing); + }); + }); + + // ── Layout responsif ────────────────────────────────────────────────── + + group('Responsif', () { + testWidgets('tidak overflow pada layar 360x640', (tester) async { + tester.view.physicalSize = const Size(360, 640); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + await tester.pumpWidget(makeTestable(const _StubManualScreen())); + + expect(tester.takeException(), isNull); + }); + + testWidgets('tidak overflow pada layar 428x926 (iPhone 14 Pro Max)', + (tester) async { + tester.view.physicalSize = const Size(428, 926); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + await tester.pumpWidget(makeTestable(const _StubManualScreen())); + + expect(tester.takeException(), isNull); + }); + }); + }); +} diff --git a/walkguide-mobile/walkguide_app/test/widget/notification_screen_test.dart b/walkguide-mobile/walkguide_app/test/widget/notification_screen_test.dart new file mode 100644 index 0000000..38803a6 --- /dev/null +++ b/walkguide-mobile/walkguide_app/test/widget/notification_screen_test.dart @@ -0,0 +1,502 @@ +// test/widget/notification_screen_test.dart +// +// Widget tests untuk NotificationScreen — menampilkan notifikasi dari Guardian. +// Jalankan: flutter test test/widget/notification_screen_test.dart + +// ignore_for_file: avoid_print + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +// --------------------------------------------------------------------------- +// Stubs / Models +// --------------------------------------------------------------------------- + +enum NotifType { text, voiceNote } + +class NotifItem { + final int id; + final NotifType type; + final String content; + final bool isRead; + final DateTime createdAt; + final int? voiceNoteDuration; + + const NotifItem({ + required this.id, + required this.type, + required this.content, + required this.isRead, + required this.createdAt, + this.voiceNoteDuration, + }); +} + +/// Stub NotificationScreen yang mereplikasi UI asli tanpa DI / API calls +class _StubNotificationScreen extends StatefulWidget { + final List notifications; + final bool isLoading; + final String? errorMessage; + + const _StubNotificationScreen({ + this.notifications = const [], + this.isLoading = false, + this.errorMessage, + }); + + @override + State<_StubNotificationScreen> createState() => + _StubNotificationScreenState(); +} + +class _StubNotificationScreenState extends State<_StubNotificationScreen> { + late List _items; + bool _markingAll = false; + + @override + void initState() { + super.initState(); + _items = List.from(widget.notifications); + } + + void _markAllRead() { + setState(() { + _markingAll = true; + _items = _items + .map((e) => NotifItem( + id: e.id, + type: e.type, + content: e.content, + isRead: true, + createdAt: e.createdAt, + voiceNoteDuration: e.voiceNoteDuration, + )) + .toList(); + _markingAll = false; + }); + } + + int get _unreadCount => _items.where((e) => !e.isRead).length; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Row( + children: [ + const Text('Notifikasi'), + if (_unreadCount > 0) ...[ + const SizedBox(width: 8), + Container( + key: const Key('unread_badge'), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '$_unreadCount', + style: const TextStyle(color: Colors.white, fontSize: 12), + ), + ), + ], + ], + ), + actions: [ + if (_items.any((e) => !e.isRead)) + TextButton( + key: const Key('mark_all_read_button'), + onPressed: _markingAll ? null : _markAllRead, + child: const Text('Tandai Semua Dibaca'), + ), + ], + ), + body: widget.isLoading + ? const Center( + child: CircularProgressIndicator(key: Key('loading_indicator'))) + : widget.errorMessage != null + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, + size: 48, color: Colors.red, key: Key('error_icon')), + const SizedBox(height: 8), + Text( + key: const Key('error_message'), + widget.errorMessage!, + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + key: const Key('retry_button'), + onPressed: () {}, + child: const Text('Coba Lagi'), + ), + ], + ), + ) + : _items.isEmpty + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: const [ + Icon(Icons.notifications_none, + size: 64, + color: Colors.grey, + key: Key('empty_icon')), + SizedBox(height: 8), + Text( + key: Key('empty_text'), + 'Tidak ada notifikasi', + style: TextStyle(color: Colors.grey), + ), + ], + ), + ) + : RefreshIndicator( + key: const Key('refresh_indicator'), + onRefresh: () async {}, + child: ListView.separated( + key: const Key('notification_list'), + padding: const EdgeInsets.all(12), + itemCount: _items.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final item = _items[index]; + return _NotifTile( + key: Key('notif_tile_${item.id}'), + item: item, + ); + }, + ), + ), + ); + } +} + +class _NotifTile extends StatelessWidget { + final NotifItem item; + + const _NotifTile({super.key, required this.item}); + + @override + Widget build(BuildContext context) { + return ListTile( + leading: CircleAvatar( + backgroundColor: + item.isRead ? Colors.grey.shade200 : Colors.blue.shade100, + child: Icon( + item.type == NotifType.voiceNote ? Icons.mic : Icons.message, + key: Key('notif_icon_${item.id}'), + color: item.isRead ? Colors.grey : Colors.blue, + ), + ), + title: Text( + key: Key('notif_content_${item.id}'), + item.content, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: item.isRead ? FontWeight.normal : FontWeight.bold, + ), + ), + subtitle: Row( + children: [ + Text( + key: Key('notif_type_${item.id}'), + item.type == NotifType.voiceNote + ? 'Voice Note${item.voiceNoteDuration != null ? ' (${item.voiceNoteDuration}s)' : ''}' + : 'Pesan Teks', + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ), + ], + ), + trailing: item.isRead + ? null + : Container( + key: Key('unread_dot_${item.id}'), + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Colors.blue, + shape: BoxShape.circle, + ), + ), + ); + } +} + +Widget makeTestable(Widget child) => MaterialApp(home: child); + +// Fixtures +final _textNotif = NotifItem( + id: 1, + type: NotifType.text, + content: 'Hati-hati di persimpangan depan', + isRead: false, + createdAt: DateTime(2025, 5, 10, 10, 30), +); + +final _voiceNotif = NotifItem( + id: 2, + type: NotifType.voiceNote, + content: 'Voice note dari Guardian', + isRead: false, + createdAt: DateTime(2025, 5, 10, 11, 0), + voiceNoteDuration: 12, +); + +final _readNotif = NotifItem( + id: 3, + type: NotifType.text, + content: 'Sudah dibaca sebelumnya', + isRead: true, + createdAt: DateTime(2025, 5, 9, 9, 0), +); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + group('NotificationScreen Widget Tests', () { + // ── Loading state ───────────────────────────────────────────────────── + + group('Loading state', () { + testWidgets('menampilkan loading indicator saat isLoading=true', + (tester) async { + await tester.pumpWidget( + makeTestable(const _StubNotificationScreen(isLoading: true)), + ); + + expect(find.byKey(const Key('loading_indicator')), findsOneWidget); + }); + + testWidgets('menyembunyikan list saat loading', (tester) async { + await tester.pumpWidget( + makeTestable(const _StubNotificationScreen(isLoading: true)), + ); + + expect(find.byKey(const Key('notification_list')), findsNothing); + }); + }); + + // ── Error state ─────────────────────────────────────────────────────── + + group('Error state', () { + testWidgets('menampilkan error message saat ada error', (tester) async { + await tester.pumpWidget(makeTestable( + const _StubNotificationScreen( + errorMessage: 'Gagal memuat notifikasi'), + )); + + expect(find.byKey(const Key('error_message')), findsOneWidget); + expect(find.text('Gagal memuat notifikasi'), findsOneWidget); + }); + + testWidgets('menampilkan icon error saat ada error', (tester) async { + await tester.pumpWidget(makeTestable( + const _StubNotificationScreen(errorMessage: 'Koneksi gagal'), + )); + + expect(find.byKey(const Key('error_icon')), findsOneWidget); + }); + + testWidgets('menampilkan tombol Retry saat error', (tester) async { + await tester.pumpWidget(makeTestable( + const _StubNotificationScreen(errorMessage: 'Timeout'), + )); + + expect(find.byKey(const Key('retry_button')), findsOneWidget); + }); + }); + + // ── Empty state ─────────────────────────────────────────────────────── + + group('Empty state', () { + testWidgets('menampilkan empty state saat tidak ada notifikasi', + (tester) async { + await tester.pumpWidget(makeTestable(const _StubNotificationScreen())); + + expect(find.byKey(const Key('empty_text')), findsOneWidget); + expect(find.text('Tidak ada notifikasi'), findsOneWidget); + }); + + testWidgets('menampilkan icon empty state', (tester) async { + await tester.pumpWidget(makeTestable(const _StubNotificationScreen())); + + expect(find.byKey(const Key('empty_icon')), findsOneWidget); + }); + + testWidgets('tidak menampilkan tombol mark all read saat list kosong', + (tester) async { + await tester.pumpWidget(makeTestable(const _StubNotificationScreen())); + + expect(find.byKey(const Key('mark_all_read_button')), findsNothing); + }); + }); + + // ── Daftar notifikasi ───────────────────────────────────────────────── + + group('Daftar notifikasi', () { + testWidgets('menampilkan semua notifikasi dalam list', (tester) async { + await tester.pumpWidget(makeTestable( + _StubNotificationScreen( + notifications: [_textNotif, _voiceNotif, _readNotif]), + )); + + expect(find.byKey(const Key('notification_list')), findsOneWidget); + expect(find.byKey(const Key('notif_tile_1')), findsOneWidget); + expect(find.byKey(const Key('notif_tile_2')), findsOneWidget); + expect(find.byKey(const Key('notif_tile_3')), findsOneWidget); + }); + + testWidgets('menampilkan konten notifikasi teks dengan benar', + (tester) async { + await tester.pumpWidget(makeTestable( + _StubNotificationScreen(notifications: [_textNotif]), + )); + + expect(find.text('Hati-hati di persimpangan depan'), findsOneWidget); + }); + + testWidgets('menampilkan badge unread count di AppBar', (tester) async { + await tester.pumpWidget(makeTestable( + _StubNotificationScreen(notifications: [_textNotif, _voiceNotif]), + )); + + expect(find.byKey(const Key('unread_badge')), findsOneWidget); + expect(find.text('2'), findsOneWidget); + }); + + testWidgets('tidak menampilkan unread badge saat semua sudah dibaca', + (tester) async { + await tester.pumpWidget(makeTestable( + _StubNotificationScreen(notifications: [_readNotif]), + )); + + expect(find.byKey(const Key('unread_badge')), findsNothing); + }); + + testWidgets('menampilkan unread dot untuk notifikasi belum dibaca', + (tester) async { + await tester.pumpWidget(makeTestable( + _StubNotificationScreen(notifications: [_textNotif]), + )); + + expect(find.byKey(const Key('unread_dot_1')), findsOneWidget); + }); + + testWidgets('tidak menampilkan unread dot untuk notif sudah dibaca', + (tester) async { + await tester.pumpWidget(makeTestable( + _StubNotificationScreen(notifications: [_readNotif]), + )); + + expect(find.byKey(const Key('unread_dot_3')), findsNothing); + }); + + testWidgets('menampilkan jenis voice note dengan durasi', (tester) async { + await tester.pumpWidget(makeTestable( + _StubNotificationScreen(notifications: [_voiceNotif]), + )); + + expect(find.textContaining('Voice Note'), findsOneWidget); + expect(find.textContaining('12s'), findsOneWidget); + }); + + testWidgets('menampilkan icon mic untuk voice note', (tester) async { + await tester.pumpWidget(makeTestable( + _StubNotificationScreen(notifications: [_voiceNotif]), + )); + + expect(find.byKey(const Key('notif_icon_2')), findsOneWidget); + }); + }); + + // ── Mark all read ───────────────────────────────────────────────────── + + group('Mark all read', () { + testWidgets('menampilkan tombol mark all read saat ada unread', + (tester) async { + await tester.pumpWidget(makeTestable( + _StubNotificationScreen(notifications: [_textNotif, _voiceNotif]), + )); + + expect(find.byKey(const Key('mark_all_read_button')), findsOneWidget); + }); + + testWidgets('tap mark all read menghapus semua unread dot', + (tester) async { + await tester.pumpWidget(makeTestable( + _StubNotificationScreen(notifications: [_textNotif, _voiceNotif]), + )); + + expect(find.byKey(const Key('unread_dot_1')), findsOneWidget); + expect(find.byKey(const Key('unread_dot_2')), findsOneWidget); + + await tester.tap(find.byKey(const Key('mark_all_read_button'))); + await tester.pump(); + + expect(find.byKey(const Key('unread_dot_1')), findsNothing); + expect(find.byKey(const Key('unread_dot_2')), findsNothing); + }); + + testWidgets('tap mark all read menghapus unread badge', (tester) async { + await tester.pumpWidget(makeTestable( + _StubNotificationScreen(notifications: [_textNotif]), + )); + + expect(find.byKey(const Key('unread_badge')), findsOneWidget); + + await tester.tap(find.byKey(const Key('mark_all_read_button'))); + await tester.pump(); + + expect(find.byKey(const Key('unread_badge')), findsNothing); + }); + + testWidgets('tombol mark all read hilang setelah semua dibaca', + (tester) async { + await tester.pumpWidget(makeTestable( + _StubNotificationScreen(notifications: [_textNotif]), + )); + + await tester.tap(find.byKey(const Key('mark_all_read_button'))); + await tester.pump(); + + expect(find.byKey(const Key('mark_all_read_button')), findsNothing); + }); + }); + + // ── RefreshIndicator ────────────────────────────────────────────────── + + group('Pull to refresh', () { + testWidgets('terdapat RefreshIndicator di daftar notifikasi', + (tester) async { + await tester.pumpWidget(makeTestable( + _StubNotificationScreen(notifications: [_textNotif]), + )); + + expect(find.byKey(const Key('refresh_indicator')), findsOneWidget); + }); + }); + + // ── Layout responsif ────────────────────────────────────────────────── + + group('Responsif', () { + testWidgets('tidak overflow pada layar kecil', (tester) async { + tester.view.physicalSize = const Size(360, 640); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + await tester.pumpWidget(makeTestable( + _StubNotificationScreen( + notifications: [_textNotif, _voiceNotif, _readNotif]), + )); + + expect(tester.takeException(), isNull); + }); + }); + }); +} diff --git a/walkguide-mobile/walkguide_app/test/widget/sos_screen_test.dart b/walkguide-mobile/walkguide_app/test/widget/sos_screen_test.dart new file mode 100644 index 0000000..29e7916 --- /dev/null +++ b/walkguide-mobile/walkguide_app/test/widget/sos_screen_test.dart @@ -0,0 +1,619 @@ +// test/widget/sos_screen_test.dart +// +// Widget tests untuk SosScreen — layar SOS tunanetra. +// Jalankan: flutter test test/widget/sos_screen_test.dart + +// ignore_for_file: avoid_print + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +// --------------------------------------------------------------------------- +// Stubs / Models +// --------------------------------------------------------------------------- + +enum SosTriggerType { voiceCommand, button, manual } + +enum SosStatus { triggered, acknowledged, resolved } + +class SosEvent { + final int id; + final SosTriggerType triggerType; + final double? lat; + final double? lng; + final SosStatus status; + final DateTime createdAt; + final DateTime? acknowledgedAt; + + const SosEvent({ + required this.id, + required this.triggerType, + this.lat, + this.lng, + required this.status, + required this.createdAt, + this.acknowledgedAt, + }); +} + +/// Stub SosScreen yang mereplikasi struktur UI asli +class _StubSosScreen extends StatefulWidget { + final List events; + final bool isLoading; + final String? errorMessage; + final bool hasPairedGuardian; + + const _StubSosScreen({ + this.events = const [], + this.isLoading = false, + this.errorMessage, + this.hasPairedGuardian = true, + }); + + @override + State<_StubSosScreen> createState() => _StubSosScreenState(); +} + +class _StubSosScreenState extends State<_StubSosScreen> { + late List _events; + bool _sending = false; + String? _sendError; + bool _sendSuccess = false; + + @override + void initState() { + super.initState(); + _events = List.from(widget.events); + } + + Future _sendSos() async { + if (!widget.hasPairedGuardian) { + setState(() => _sendError = 'Belum ada Guardian yang terhubung'); + return; + } + setState(() { + _sending = true; + _sendError = null; + _sendSuccess = false; + }); + await Future.delayed(const Duration(milliseconds: 100)); + if (mounted) { + setState(() { + _sending = false; + _sendSuccess = true; + _events = [ + SosEvent( + id: _events.length + 1, + triggerType: SosTriggerType.button, + lat: -7.2575, + lng: 112.7521, + status: SosStatus.triggered, + createdAt: DateTime.now(), + ), + ..._events, + ]; + }); + } + } + + String _statusLabel(SosStatus status) => switch (status) { + SosStatus.triggered => 'Terkirim', + SosStatus.acknowledged => 'Diakui Guardian', + SosStatus.resolved => 'Selesai', + }; + + Color _statusColor(SosStatus status) => switch (status) { + SosStatus.triggered => Colors.orange, + SosStatus.acknowledged => Colors.blue, + SosStatus.resolved => Colors.green, + }; + + String _triggerLabel(SosTriggerType type) => switch (type) { + SosTriggerType.voiceCommand => 'Voice Command', + SosTriggerType.button => 'Tombol SOS', + SosTriggerType.manual => 'Manual', + }; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('SOS')), + body: Column( + children: [ + // Peringatan tidak ada guardian + if (!widget.hasPairedGuardian) + Container( + key: const Key('no_guardian_banner'), + width: double.infinity, + padding: const EdgeInsets.all(12), + color: Colors.orange.shade100, + child: const Row( + children: [ + Icon(Icons.warning_amber, color: Colors.orange), + SizedBox(width: 8), + Expanded( + child: Text( + key: Key('no_guardian_text'), + 'Belum ada Guardian terhubung. SOS tetap bisa dikirim.', + style: TextStyle(color: Colors.orange), + ), + ), + ], + ), + ), + + // SOS button utama + Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + const Text( + 'Tekan tombol di bawah untuk mengirim sinyal darurat', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 16), + ), + const SizedBox(height: 24), + _sending + ? const CircularProgressIndicator( + key: Key('sending_indicator')) + : GestureDetector( + onTap: _sendSos, + child: Container( + key: const Key('sos_button'), + width: 140, + height: 140, + decoration: BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.red.withOpacity(0.4), + blurRadius: 20, + spreadRadius: 4, + ), + ], + ), + child: const Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.sos, color: Colors.white, size: 48), + Text( + 'SOS', + style: TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + if (_sendSuccess) + Container( + key: const Key('send_success_banner'), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.green.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.green.shade200), + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.check_circle, color: Colors.green), + SizedBox(width: 8), + Text( + key: Key('send_success_text'), + 'SOS berhasil dikirim!', + style: TextStyle( + color: Colors.green, fontWeight: FontWeight.bold), + ), + ], + ), + ), + if (_sendError != null) + Container( + key: const Key('send_error_banner'), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + key: const Key('send_error_text'), + _sendError!, + style: const TextStyle(color: Colors.red), + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + + // Riwayat SOS + const Divider(height: 1), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Riwayat SOS', + key: Key('history_title'), + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + ), + ), + Expanded( + child: widget.isLoading + ? const Center( + child: CircularProgressIndicator( + key: Key('loading_indicator'))) + : widget.errorMessage != null + ? Center( + child: Text( + key: const Key('error_message'), + widget.errorMessage!, + ), + ) + : _events.isEmpty + ? const Center( + child: Text( + key: Key('empty_history_text'), + 'Tidak ada riwayat SOS', + style: TextStyle(color: Colors.grey), + ), + ) + : ListView.builder( + key: const Key('history_list'), + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: _events.length, + itemBuilder: (context, index) { + final event = _events[index]; + return Card( + key: Key('sos_event_${event.id}'), + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + leading: Icon( + Icons.sos, + key: Key('sos_icon_${event.id}'), + color: Colors.red, + ), + title: Text( + key: Key('sos_trigger_${event.id}'), + _triggerLabel(event.triggerType), + ), + subtitle: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + if (event.lat != null && + event.lng != null) + Text( + key: Key('sos_location_${event.id}'), + 'Lat: ${event.lat!.toStringAsFixed(4)}, Lng: ${event.lng!.toStringAsFixed(4)}', + style: const TextStyle(fontSize: 11), + ), + ], + ), + trailing: Container( + key: Key('sos_status_${event.id}'), + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: _statusColor(event.status), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _statusLabel(event.status), + style: const TextStyle( + color: Colors.white, fontSize: 11), + ), + ), + ), + ); + }, + ), + ), + ], + ), + ); + } +} + +Widget makeTestable(Widget child) => MaterialApp(home: child); + +// Fixtures +final _triggeredEvent = SosEvent( + id: 1, + triggerType: SosTriggerType.button, + lat: -7.2575, + lng: 112.7521, + status: SosStatus.triggered, + createdAt: DateTime(2025, 5, 10, 14, 0), +); + +final _acknowledgedEvent = SosEvent( + id: 2, + triggerType: SosTriggerType.voiceCommand, + lat: -7.2601, + lng: 112.7510, + status: SosStatus.acknowledged, + createdAt: DateTime(2025, 5, 9, 10, 0), + acknowledgedAt: DateTime(2025, 5, 9, 10, 5), +); + +final _resolvedEvent = SosEvent( + id: 3, + triggerType: SosTriggerType.manual, + status: SosStatus.resolved, + createdAt: DateTime(2025, 5, 8, 8, 0), +); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + group('SosScreen Widget Tests', () { + // ── Rendering awal ───────────────────────────────────────────────────── + + group('Rendering awal', () { + testWidgets('menampilkan AppBar dengan judul SOS', (tester) async { + await tester.pumpWidget(makeTestable(const _StubSosScreen())); + expect(find.text('SOS'), findsAtLeastNWidgets(1)); + }); + + testWidgets('menampilkan tombol SOS bulat merah besar', (tester) async { + await tester.pumpWidget(makeTestable(const _StubSosScreen())); + expect(find.byKey(const Key('sos_button')), findsOneWidget); + }); + + testWidgets('menampilkan judul riwayat SOS', (tester) async { + await tester.pumpWidget(makeTestable(const _StubSosScreen())); + expect(find.byKey(const Key('history_title')), findsOneWidget); + }); + + testWidgets('menampilkan teks instruksi darurat', (tester) async { + await tester.pumpWidget(makeTestable(const _StubSosScreen())); + expect( + find.textContaining('sinyal darurat'), + findsOneWidget, + ); + }); + + testWidgets('tidak menampilkan success banner saat awal', (tester) async { + await tester.pumpWidget(makeTestable(const _StubSosScreen())); + expect(find.byKey(const Key('send_success_banner')), findsNothing); + }); + + testWidgets('tidak menampilkan error banner saat awal', (tester) async { + await tester.pumpWidget(makeTestable(const _StubSosScreen())); + expect(find.byKey(const Key('send_error_banner')), findsNothing); + }); + }); + + // ── Warning no guardian ─────────────────────────────────────────────── + + group('Warning tidak ada Guardian', () { + testWidgets('menampilkan banner warning saat belum ada guardian', + (tester) async { + await tester.pumpWidget( + makeTestable(const _StubSosScreen(hasPairedGuardian: false)), + ); + + expect(find.byKey(const Key('no_guardian_banner')), findsOneWidget); + }); + + testWidgets('menampilkan teks warning dengan benar', (tester) async { + await tester.pumpWidget( + makeTestable(const _StubSosScreen(hasPairedGuardian: false)), + ); + + expect(find.byKey(const Key('no_guardian_text')), findsOneWidget); + }); + + testWidgets('tidak menampilkan banner warning saat guardian ada', + (tester) async { + await tester.pumpWidget(makeTestable(const _StubSosScreen())); + + expect(find.byKey(const Key('no_guardian_banner')), findsNothing); + }); + }); + + // ── Send SOS ────────────────────────────────────────────────────────── + + group('Kirim SOS', () { + testWidgets('tap tombol SOS menampilkan loading indicator', + (tester) async { + await tester.pumpWidget(makeTestable(const _StubSosScreen())); + + await tester.tap(find.byKey(const Key('sos_button'))); + await tester.pump(); + + expect(find.byKey(const Key('sending_indicator')), findsOneWidget); + }); + + testWidgets('setelah SOS terkirim, tampil success banner', + (tester) async { + await tester.pumpWidget(makeTestable(const _StubSosScreen())); + + await tester.tap(find.byKey(const Key('sos_button'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('send_success_banner')), findsOneWidget); + expect(find.byKey(const Key('send_success_text')), findsOneWidget); + }); + + testWidgets('setelah SOS terkirim, muncul event baru di riwayat', + (tester) async { + await tester.pumpWidget(makeTestable(const _StubSosScreen())); + + await tester.tap(find.byKey(const Key('sos_button'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('history_list')), findsOneWidget); + }); + + testWidgets('SOS tanpa guardian menampilkan error message', + (tester) async { + await tester.pumpWidget( + makeTestable(const _StubSosScreen(hasPairedGuardian: false)), + ); + + await tester.tap(find.byKey(const Key('sos_button'))); + await tester.pump(); + + expect(find.byKey(const Key('send_error_banner')), findsOneWidget); + expect(find.textContaining('Guardian'), findsAtLeastNWidgets(1)); + }); + }); + + // ── Riwayat SOS ─────────────────────────────────────────────────────── + + group('Riwayat SOS', () { + testWidgets('menampilkan empty state saat tidak ada riwayat', + (tester) async { + await tester.pumpWidget(makeTestable(const _StubSosScreen())); + + expect(find.byKey(const Key('empty_history_text')), findsOneWidget); + }); + + testWidgets('menampilkan event dalam daftar riwayat', (tester) async { + await tester.pumpWidget(makeTestable( + _StubSosScreen(events: [_triggeredEvent, _acknowledgedEvent]), + )); + + expect(find.byKey(const Key('sos_event_1')), findsOneWidget); + expect(find.byKey(const Key('sos_event_2')), findsOneWidget); + }); + + testWidgets('menampilkan tipe trigger dengan benar (Button)', + (tester) async { + await tester.pumpWidget(makeTestable( + _StubSosScreen(events: [_triggeredEvent]), + )); + + expect(find.text('Tombol SOS'), findsOneWidget); + }); + + testWidgets('menampilkan tipe trigger dengan benar (Voice Command)', + (tester) async { + await tester.pumpWidget(makeTestable( + _StubSosScreen(events: [_acknowledgedEvent]), + )); + + expect(find.text('Voice Command'), findsOneWidget); + }); + + testWidgets('menampilkan tipe trigger Manual dengan benar', + (tester) async { + await tester.pumpWidget(makeTestable( + _StubSosScreen(events: [_resolvedEvent]), + )); + + expect(find.text('Manual'), findsOneWidget); + }); + + testWidgets('menampilkan status badge TRIGGERED berwarna orange', + (tester) async { + await tester.pumpWidget(makeTestable( + _StubSosScreen(events: [_triggeredEvent]), + )); + + expect(find.byKey(const Key('sos_status_1')), findsOneWidget); + expect(find.text('Terkirim'), findsOneWidget); + }); + + testWidgets('menampilkan status badge ACKNOWLEDGED berwarna biru', + (tester) async { + await tester.pumpWidget(makeTestable( + _StubSosScreen(events: [_acknowledgedEvent]), + )); + + expect(find.text('Diakui Guardian'), findsOneWidget); + }); + + testWidgets('menampilkan status badge RESOLVED berwarna hijau', + (tester) async { + await tester.pumpWidget(makeTestable( + _StubSosScreen(events: [_resolvedEvent]), + )); + + expect(find.text('Selesai'), findsOneWidget); + }); + + testWidgets('menampilkan koordinat GPS jika tersedia', (tester) async { + await tester.pumpWidget(makeTestable( + _StubSosScreen(events: [_triggeredEvent]), + )); + + expect(find.byKey(const Key('sos_location_1')), findsOneWidget); + expect(find.textContaining('Lat:'), findsOneWidget); + expect(find.textContaining('Lng:'), findsOneWidget); + }); + + testWidgets('tidak menampilkan koordinat jika null', (tester) async { + await tester.pumpWidget(makeTestable( + _StubSosScreen(events: [_resolvedEvent]), + )); + + expect(find.byKey(const Key('sos_location_3')), findsNothing); + }); + }); + + // ── Loading & Error ─────────────────────────────────────────────────── + + group('Loading dan error state', () { + testWidgets('menampilkan loading di riwayat saat isLoading=true', + (tester) async { + await tester + .pumpWidget(makeTestable(const _StubSosScreen(isLoading: true))); + + expect(find.byKey(const Key('loading_indicator')), findsOneWidget); + }); + + testWidgets('menampilkan pesan error di area riwayat', (tester) async { + await tester.pumpWidget(makeTestable( + const _StubSosScreen(errorMessage: 'Gagal memuat riwayat'), + )); + + expect(find.byKey(const Key('error_message')), findsOneWidget); + }); + }); + + // ── Responsif ───────────────────────────────────────────────────────── + + group('Responsif', () { + testWidgets('tidak overflow pada layar 360x640', (tester) async { + tester.view.physicalSize = const Size(360, 640); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + await tester.pumpWidget(makeTestable(const _StubSosScreen())); + + expect(tester.takeException(), isNull); + }); + + testWidgets('tidak overflow saat menampilkan banyak event', + (tester) async { + final manyEvents = List.generate( + 20, + (i) => SosEvent( + id: i, + triggerType: SosTriggerType.button, + status: SosStatus.resolved, + createdAt: DateTime(2025, 5, i + 1), + ), + ); + + await tester.pumpWidget(makeTestable( + _StubSosScreen(events: manyEvents), + )); + + expect(tester.takeException(), isNull); + }); + }); + }); +} diff --git a/walkguide-mobile/walkguide_app/test/widget/walk_guide_screen_test.dart b/walkguide-mobile/walkguide_app/test/widget/walk_guide_screen_test.dart new file mode 100644 index 0000000..52f821c --- /dev/null +++ b/walkguide-mobile/walkguide_app/test/widget/walk_guide_screen_test.dart @@ -0,0 +1,581 @@ +// test/widget/walk_guide_screen_test.dart +// +// Widget tests untuk WalkGuideScreen — layar utama navigasi tunanetra. +// Jalankan: flutter test test/widget/walk_guide_screen_test.dart +// +// Dev dependencies: +// flutter_test: sdk: flutter +// mockito: ^5.4.4 +// build_runner: ^2.4.6 + +// ignore_for_file: avoid_print + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +// --------------------------------------------------------------------------- +// Stubs / Models +// --------------------------------------------------------------------------- + +/// Mereplikasi DetectionResult dari core/ai/obstacle_analyzer.dart +class DetectionResult { + final String label; + final double confidence; + final String direction; // 'LEFT' | 'CENTER' | 'RIGHT' + final String distance; // 'Very Close' | 'Close' | 'Medium' | 'Far' + final bool isAlert; + + const DetectionResult({ + required this.label, + required this.confidence, + required this.direction, + required this.distance, + required this.isAlert, + }); +} + +/// Stub WalkGuideScreen yang meniru UI asli tanpa plugin dependencies +class _StubWalkGuideScreen extends StatefulWidget { + final DetectionResult? initialDetection; + final bool initialActive; + final bool cameraError; + + const _StubWalkGuideScreen({ + this.initialDetection, + this.initialActive = false, + this.cameraError = false, + }); + + @override + State<_StubWalkGuideScreen> createState() => _StubWalkGuideScreenState(); +} + +class _StubWalkGuideScreenState extends State<_StubWalkGuideScreen> { + late bool _active; + late String _status; + DetectionResult? _lastDetection; + bool _hasCameraError = false; + + @override + void initState() { + super.initState(); + _active = widget.initialActive; + _status = widget.initialActive ? 'Active' : 'Ready'; + _lastDetection = widget.initialDetection; + _hasCameraError = widget.cameraError; + } + + void _toggleActive() { + setState(() { + _active = !_active; + _status = _active ? 'Active' : 'Ready'; + if (!_active) _lastDetection = null; + }); + } + + Color get _statusColor { + if (!_active) return Colors.grey; + if (_lastDetection == null) return Colors.green; + if (_lastDetection!.isAlert) return Colors.red; + return Colors.orange; + } + + String get _directionEmoji { + if (_lastDetection == null) return ''; + return switch (_lastDetection!.direction) { + 'LEFT' => '⬅️', + 'RIGHT' => '➡️', + _ => '⬆️', + }; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('WalkGuide'), + actions: [ + IconButton( + key: const Key('settings_button'), + icon: const Icon(Icons.settings), + onPressed: () {}, + ), + ], + ), + body: Column( + children: [ + // Status bar + Container( + key: const Key('status_bar'), + width: double.infinity, + padding: const EdgeInsets.all(16), + color: _statusColor, + child: Text( + key: const Key('status_text'), + _status, + style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + ), + + // Camera preview area + Expanded( + child: _hasCameraError + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.camera_alt, size: 64, color: Colors.grey, + key: Key('camera_error_icon')), + const SizedBox(height: 8), + const Text( + key: Key('camera_error_text'), + 'Kamera tidak tersedia', + style: TextStyle(color: Colors.grey), + ), + ], + ), + ) + : Container( + key: const Key('camera_preview'), + color: Colors.black, + child: _active + ? Stack( + children: [ + const Center( + child: Text( + 'Camera Preview', + style: TextStyle(color: Colors.white38), + ), + ), + if (_lastDetection != null) + Positioned( + bottom: 16, + left: 0, + right: 0, + child: _DetectionOverlay(detection: _lastDetection!), + ), + ], + ) + : const Center( + child: Text( + 'Tekan Start untuk memulai', + key: Key('inactive_hint'), + style: TextStyle(color: Colors.white54), + ), + ), + ), + ), + + // Bottom controls + Padding( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + // Detection info card + if (_lastDetection != null) + Card( + key: const Key('detection_card'), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Text( + _directionEmoji, + key: const Key('direction_emoji'), + style: const TextStyle(fontSize: 24), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + key: const Key('detection_label'), + _lastDetection!.label, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + Text( + key: const Key('detection_distance'), + _lastDetection!.distance, + style: TextStyle(color: Colors.grey.shade600), + ), + ], + ), + ), + Text( + key: const Key('detection_confidence'), + '${(_lastDetection!.confidence * 100).toStringAsFixed(0)}%', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + ), + ), + + const SizedBox(height: 12), + + // Start/Stop button + SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton.icon( + key: const Key('toggle_button'), + onPressed: _hasCameraError ? null : _toggleActive, + icon: Icon(_active ? Icons.stop : Icons.play_arrow), + label: Text(_active ? 'Stop WalkGuide' : 'Start WalkGuide'), + style: ElevatedButton.styleFrom( + backgroundColor: _active ? Colors.red : Colors.blue, + foregroundColor: Colors.white, + ), + ), + ), + + const SizedBox(height: 8), + + // SOS button + SizedBox( + width: double.infinity, + height: 44, + child: OutlinedButton.icon( + key: const Key('sos_button'), + onPressed: () {}, + icon: const Icon(Icons.sos, color: Colors.red), + label: const Text('SOS', style: TextStyle(color: Colors.red)), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _DetectionOverlay extends StatelessWidget { + final DetectionResult detection; + const _DetectionOverlay({required this.detection}); + + @override + Widget build(BuildContext context) { + return Container( + key: const Key('detection_overlay'), + margin: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: detection.isAlert ? Colors.red.withOpacity(0.85) : Colors.black87, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '${detection.label} — ${detection.direction} — ${detection.distance}', + style: const TextStyle(color: Colors.white), + textAlign: TextAlign.center, + ), + ); + } +} + +Widget makeTestable(Widget child) => MaterialApp(home: child); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + group('WalkGuideScreen Widget Tests', () { + // ── Rendering awal ───────────────────────────────────────────────────── + + group('Rendering awal (inactive)', () { + testWidgets('menampilkan AppBar dengan judul WalkGuide', (tester) async { + await tester.pumpWidget(makeTestable(const _StubWalkGuideScreen())); + expect(find.text('WalkGuide'), findsOneWidget); + }); + + testWidgets('menampilkan status bar dengan status Ready', (tester) async { + await tester.pumpWidget(makeTestable(const _StubWalkGuideScreen())); + expect(find.byKey(const Key('status_bar')), findsOneWidget); + expect(find.text('Ready'), findsOneWidget); + }); + + testWidgets('menampilkan tombol Start WalkGuide', (tester) async { + await tester.pumpWidget(makeTestable(const _StubWalkGuideScreen())); + expect(find.byKey(const Key('toggle_button')), findsOneWidget); + expect(find.text('Start WalkGuide'), findsOneWidget); + }); + + testWidgets('menampilkan tombol SOS', (tester) async { + await tester.pumpWidget(makeTestable(const _StubWalkGuideScreen())); + expect(find.byKey(const Key('sos_button')), findsOneWidget); + }); + + testWidgets('menampilkan hint teks saat inactive', (tester) async { + await tester.pumpWidget(makeTestable(const _StubWalkGuideScreen())); + expect(find.byKey(const Key('inactive_hint')), findsOneWidget); + }); + + testWidgets('tidak menampilkan detection card saat tidak ada deteksi', (tester) async { + await tester.pumpWidget(makeTestable(const _StubWalkGuideScreen())); + expect(find.byKey(const Key('detection_card')), findsNothing); + }); + + testWidgets('menampilkan icon settings di AppBar', (tester) async { + await tester.pumpWidget(makeTestable(const _StubWalkGuideScreen())); + expect(find.byKey(const Key('settings_button')), findsOneWidget); + }); + }); + + // ── Toggle aktif/non-aktif ───────────────────────────────────────────── + + group('Toggle Start/Stop', () { + testWidgets('tap Start mengubah status menjadi Active', (tester) async { + await tester.pumpWidget(makeTestable(const _StubWalkGuideScreen())); + + await tester.tap(find.byKey(const Key('toggle_button'))); + await tester.pump(); + + expect(find.text('Active'), findsOneWidget); + }); + + testWidgets('tap Start mengubah label tombol menjadi Stop WalkGuide', (tester) async { + await tester.pumpWidget(makeTestable(const _StubWalkGuideScreen())); + + await tester.tap(find.byKey(const Key('toggle_button'))); + await tester.pump(); + + expect(find.text('Stop WalkGuide'), findsOneWidget); + }); + + testWidgets('tap Stop mengembalikan status ke Ready', (tester) async { + await tester.pumpWidget(makeTestable(const _StubWalkGuideScreen(initialActive: true))); + + await tester.tap(find.byKey(const Key('toggle_button'))); + await tester.pump(); + + expect(find.text('Ready'), findsOneWidget); + }); + + testWidgets('tap Stop menghapus detection card', (tester) async { + const detection = DetectionResult( + label: 'person', + confidence: 0.92, + direction: 'CENTER', + distance: 'Very Close', + isAlert: true, + ); + await tester.pumpWidget(makeTestable( + const _StubWalkGuideScreen(initialActive: true, initialDetection: detection), + )); + + expect(find.byKey(const Key('detection_card')), findsOneWidget); + + await tester.tap(find.byKey(const Key('toggle_button'))); + await tester.pump(); + + expect(find.byKey(const Key('detection_card')), findsNothing); + }); + }); + + // ── Detection overlay ───────────────────────────────────────────────── + + group('Detection display', () { + testWidgets('menampilkan detection card saat ada deteksi', (tester) async { + const detection = DetectionResult( + label: 'car', + confidence: 0.87, + direction: 'RIGHT', + distance: 'Close', + isAlert: true, + ); + await tester.pumpWidget(makeTestable( + const _StubWalkGuideScreen(initialActive: true, initialDetection: detection), + )); + + expect(find.byKey(const Key('detection_card')), findsOneWidget); + }); + + testWidgets('menampilkan label obstacle yang benar', (tester) async { + const detection = DetectionResult( + label: 'motorcycle', + confidence: 0.75, + direction: 'LEFT', + distance: 'Medium', + isAlert: false, + ); + await tester.pumpWidget(makeTestable( + const _StubWalkGuideScreen(initialActive: true, initialDetection: detection), + )); + + expect(find.byKey(const Key('detection_label')), findsOneWidget); + expect(find.text('motorcycle'), findsOneWidget); + }); + + testWidgets('menampilkan estimasi jarak obstacle', (tester) async { + const detection = DetectionResult( + label: 'person', + confidence: 0.91, + direction: 'CENTER', + distance: 'Very Close', + isAlert: true, + ); + await tester.pumpWidget(makeTestable( + const _StubWalkGuideScreen(initialActive: true, initialDetection: detection), + )); + + expect(find.text('Very Close'), findsOneWidget); + }); + + testWidgets('menampilkan confidence score dalam persen', (tester) async { + const detection = DetectionResult( + label: 'car', + confidence: 0.87, + direction: 'CENTER', + distance: 'Close', + isAlert: true, + ); + await tester.pumpWidget(makeTestable( + const _StubWalkGuideScreen(initialActive: true, initialDetection: detection), + )); + + expect(find.text('87%'), findsOneWidget); + }); + + testWidgets('menampilkan emoji arah LEFT dengan benar', (tester) async { + const detection = DetectionResult( + label: 'person', + confidence: 0.80, + direction: 'LEFT', + distance: 'Close', + isAlert: false, + ); + await tester.pumpWidget(makeTestable( + const _StubWalkGuideScreen(initialActive: true, initialDetection: detection), + )); + + expect(find.byKey(const Key('direction_emoji')), findsOneWidget); + expect(find.text('⬅️'), findsOneWidget); + }); + + testWidgets('menampilkan emoji arah RIGHT dengan benar', (tester) async { + const detection = DetectionResult( + label: 'car', + confidence: 0.78, + direction: 'RIGHT', + distance: 'Medium', + isAlert: false, + ); + await tester.pumpWidget(makeTestable( + const _StubWalkGuideScreen(initialActive: true, initialDetection: detection), + )); + + expect(find.text('➡️'), findsOneWidget); + }); + + testWidgets('menampilkan overlay di atas camera preview saat active + ada deteksi', + (tester) async { + const detection = DetectionResult( + label: 'truck', + confidence: 0.93, + direction: 'CENTER', + distance: 'Very Close', + isAlert: true, + ); + await tester.pumpWidget(makeTestable( + const _StubWalkGuideScreen(initialActive: true, initialDetection: detection), + )); + + expect(find.byKey(const Key('detection_overlay')), findsOneWidget); + }); + }); + + // ── Error state kamera ──────────────────────────────────────────────── + + group('Error state kamera', () { + testWidgets('menampilkan pesan error kamera saat kamera tidak tersedia', (tester) async { + await tester.pumpWidget( + makeTestable(const _StubWalkGuideScreen(cameraError: true)), + ); + + expect(find.byKey(const Key('camera_error_text')), findsOneWidget); + expect(find.text('Kamera tidak tersedia'), findsOneWidget); + }); + + testWidgets('menampilkan icon error kamera', (tester) async { + await tester.pumpWidget( + makeTestable(const _StubWalkGuideScreen(cameraError: true)), + ); + + expect(find.byKey(const Key('camera_error_icon')), findsOneWidget); + }); + + testWidgets('tombol Start di-disable saat kamera error', (tester) async { + await tester.pumpWidget( + makeTestable(const _StubWalkGuideScreen(cameraError: true)), + ); + + final btn = tester.widget(find.byKey(const Key('toggle_button'))); + expect(btn.onPressed, isNull); + }); + }); + + // ── Status bar color ────────────────────────────────────────────────── + + group('Status bar warna', () { + testWidgets('status bar berwarna grey saat inactive', (tester) async { + await tester.pumpWidget(makeTestable(const _StubWalkGuideScreen())); + + final container = tester.widget(find.byKey(const Key('status_bar'))); + expect(container.color, Colors.grey); + }); + + testWidgets('status bar berwarna hijau saat active tanpa deteksi', (tester) async { + await tester.pumpWidget( + makeTestable(const _StubWalkGuideScreen(initialActive: true)), + ); + + final container = tester.widget(find.byKey(const Key('status_bar'))); + expect(container.color, Colors.green); + }); + + testWidgets('status bar berwarna merah saat ada obstacle alert', (tester) async { + const detection = DetectionResult( + label: 'person', + confidence: 0.95, + direction: 'CENTER', + distance: 'Very Close', + isAlert: true, + ); + await tester.pumpWidget(makeTestable( + const _StubWalkGuideScreen(initialActive: true, initialDetection: detection), + )); + + final container = tester.widget(find.byKey(const Key('status_bar'))); + expect(container.color, Colors.red); + }); + }); + + // ── Layout responsif ────────────────────────────────────────────────── + + group('Responsif', () { + testWidgets('tidak overflow pada layar 360x640', (tester) async { + tester.view.physicalSize = const Size(360, 640); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + await tester.pumpWidget(makeTestable(const _StubWalkGuideScreen())); + + expect(tester.takeException(), isNull); + }); + + testWidgets('tidak overflow pada layar 414x896 (iPhone XR)', (tester) async { + tester.view.physicalSize = const Size(414, 896); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + await tester.pumpWidget(makeTestable(const _StubWalkGuideScreen())); + + expect(tester.takeException(), isNull); + }); + }); + }); +} \ No newline at end of file