test(frontend): implement unit and widget tests for core flows and use cases
This commit is contained in:
parent
790db043a9
commit
558ef66a55
@ -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:
|
||||
|
||||
@ -0,0 +1,366 @@
|
||||
// test/unit/geofence_calculation_test.dart
|
||||
//
|
||||
// Unit test untuk GeofenceCalculator — Haversine formula & zone check.
|
||||
// Jalankan: flutter test test/unit/geofence_calculation_test.dart
|
||||
//
|
||||
// Pure Dart test — tidak ada dependency eksternal.
|
||||
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
// ---------- GeofenceCalculator (mirror logika dari project) ----------
|
||||
|
||||
class GeofenceCalculator {
|
||||
static const double _earthRadiusMeters = 6371000.0;
|
||||
|
||||
/// Hitung jarak (meter) antara dua koordinat GPS menggunakan Haversine formula.
|
||||
double haversineDistance(
|
||||
double lat1,
|
||||
double lng1,
|
||||
double lat2,
|
||||
double lng2,
|
||||
) {
|
||||
final dLat = _toRad(lat2 - lat1);
|
||||
final dLng = _toRad(lng2 - lng1);
|
||||
|
||||
final a = math.sin(dLat / 2) * math.sin(dLat / 2) +
|
||||
math.cos(_toRad(lat1)) *
|
||||
math.cos(_toRad(lat2)) *
|
||||
math.sin(dLng / 2) *
|
||||
math.sin(dLng / 2);
|
||||
|
||||
final c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a));
|
||||
return _earthRadiusMeters * c;
|
||||
}
|
||||
|
||||
/// Return true jika koordinat berada di dalam radius dari center.
|
||||
bool isInsideGeofence({
|
||||
required double centerLat,
|
||||
required double centerLng,
|
||||
required double radiusMeters,
|
||||
required double currentLat,
|
||||
required double currentLng,
|
||||
}) {
|
||||
final dist =
|
||||
haversineDistance(centerLat, centerLng, currentLat, currentLng);
|
||||
return dist <= radiusMeters;
|
||||
}
|
||||
|
||||
/// Return true jika user baru saja keluar dari geofence.
|
||||
bool hasExitedGeofence({
|
||||
required double centerLat,
|
||||
required double centerLng,
|
||||
required double radiusMeters,
|
||||
required double previousLat,
|
||||
required double previousLng,
|
||||
required double currentLat,
|
||||
required double currentLng,
|
||||
}) {
|
||||
final wasInside = isInsideGeofence(
|
||||
centerLat: centerLat,
|
||||
centerLng: centerLng,
|
||||
radiusMeters: radiusMeters,
|
||||
currentLat: previousLat,
|
||||
currentLng: previousLng,
|
||||
);
|
||||
final isInside = isInsideGeofence(
|
||||
centerLat: centerLat,
|
||||
centerLng: centerLng,
|
||||
radiusMeters: radiusMeters,
|
||||
currentLat: currentLat,
|
||||
currentLng: currentLng,
|
||||
);
|
||||
return wasInside && !isInside;
|
||||
}
|
||||
|
||||
/// Return true jika user baru saja masuk ke geofence.
|
||||
bool hasEnteredGeofence({
|
||||
required double centerLat,
|
||||
required double centerLng,
|
||||
required double radiusMeters,
|
||||
required double previousLat,
|
||||
required double previousLng,
|
||||
required double currentLat,
|
||||
required double currentLng,
|
||||
}) {
|
||||
final wasInside = isInsideGeofence(
|
||||
centerLat: centerLat,
|
||||
centerLng: centerLng,
|
||||
radiusMeters: radiusMeters,
|
||||
currentLat: previousLat,
|
||||
currentLng: previousLng,
|
||||
);
|
||||
final isInside = isInsideGeofence(
|
||||
centerLat: centerLat,
|
||||
centerLng: centerLng,
|
||||
radiusMeters: radiusMeters,
|
||||
currentLat: currentLat,
|
||||
currentLng: currentLng,
|
||||
);
|
||||
return !wasInside && isInside;
|
||||
}
|
||||
|
||||
double _toRad(double deg) => deg * math.pi / 180.0;
|
||||
}
|
||||
|
||||
// ---------- Koordinat referensi ----------
|
||||
// ITS (Institut Teknologi Sepuluh Nopember) Surabaya
|
||||
const double _itsCenterLat = -7.2754;
|
||||
const double _itsCenterLng = 112.7920;
|
||||
|
||||
// ---------- Tests ----------
|
||||
|
||||
void main() {
|
||||
late GeofenceCalculator calc;
|
||||
|
||||
setUp(() {
|
||||
calc = GeofenceCalculator();
|
||||
});
|
||||
|
||||
// ── HAVERSINE DISTANCE TESTS ─────────────────────────────────────
|
||||
|
||||
group('haversineDistance — akurasi jarak', () {
|
||||
test('jarak titik yang sama → 0 meter', () {
|
||||
final dist = calc.haversineDistance(
|
||||
_itsCenterLat, _itsCenterLng, _itsCenterLat, _itsCenterLng);
|
||||
expect(dist, closeTo(0.0, 0.001));
|
||||
});
|
||||
|
||||
test('jarak ~111 km per derajat lintang (ekuator)', () {
|
||||
// 1 derajat lintang ≈ 111 km
|
||||
final dist =
|
||||
calc.haversineDistance(0.0, 0.0, 1.0, 0.0); // 0° → 1°N di ekuator
|
||||
expect(dist, closeTo(111_195, 500)); // ±500 meter toleransi
|
||||
});
|
||||
|
||||
test('jarak ~111 km per derajat bujur (di ekuator)', () {
|
||||
final dist = calc.haversineDistance(0.0, 0.0, 0.0, 1.0);
|
||||
expect(dist, closeTo(111_195, 500));
|
||||
});
|
||||
|
||||
test('jarak ITS ke Tunjungan Plaza ≈ 7.6 km', () {
|
||||
// Tunjungan Plaza Surabaya: -7.2611, 112.7380
|
||||
const tpLat = -7.2611;
|
||||
const tpLng = 112.7380;
|
||||
final dist = calc.haversineDistance(
|
||||
_itsCenterLat, _itsCenterLng, tpLat, tpLng);
|
||||
// Jarak darat sekitar 7-8 km, Haversine (garis lurus) ≈ 5-6 km
|
||||
expect(dist, greaterThan(4000));
|
||||
expect(dist, lessThan(9000));
|
||||
});
|
||||
|
||||
test('jarak simetris: A→B == B→A', () {
|
||||
const lat1 = -7.2754;
|
||||
const lng1 = 112.7920;
|
||||
const lat2 = -7.2611;
|
||||
const lng2 = 112.7380;
|
||||
final d1 = calc.haversineDistance(lat1, lng1, lat2, lng2);
|
||||
final d2 = calc.haversineDistance(lat2, lng2, lat1, lng1);
|
||||
expect(d1, closeTo(d2, 0.001));
|
||||
});
|
||||
|
||||
test('jarak ≥ 0 untuk semua koordinat valid', () {
|
||||
final dist =
|
||||
calc.haversineDistance(-90.0, -180.0, 90.0, 180.0);
|
||||
expect(dist, greaterThanOrEqualTo(0));
|
||||
});
|
||||
});
|
||||
|
||||
// ── IS INSIDE GEOFENCE ───────────────────────────────────────────
|
||||
|
||||
group('isInsideGeofence — pengecekan zona', () {
|
||||
// Radius 500 meter dari ITS
|
||||
const radius = 500.0;
|
||||
|
||||
test('titik tepat di center → inside', () {
|
||||
expect(
|
||||
calc.isInsideGeofence(
|
||||
centerLat: _itsCenterLat,
|
||||
centerLng: _itsCenterLng,
|
||||
radiusMeters: radius,
|
||||
currentLat: _itsCenterLat,
|
||||
currentLng: _itsCenterLng,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('titik 100 meter dari center → inside radius 500m', () {
|
||||
// ±0.001° ≈ 100-111 meter
|
||||
expect(
|
||||
calc.isInsideGeofence(
|
||||
centerLat: _itsCenterLat,
|
||||
centerLng: _itsCenterLng,
|
||||
radiusMeters: radius,
|
||||
currentLat: _itsCenterLat + 0.0009,
|
||||
currentLng: _itsCenterLng,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('titik 600 meter dari center → outside radius 500m', () {
|
||||
// ±0.006° ≈ 600+ meter
|
||||
expect(
|
||||
calc.isInsideGeofence(
|
||||
centerLat: _itsCenterLat,
|
||||
centerLng: _itsCenterLng,
|
||||
radiusMeters: radius,
|
||||
currentLat: _itsCenterLat + 0.006,
|
||||
currentLng: _itsCenterLng,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('radius sangat kecil (10m) — hanya titik sangat dekat yang inside',
|
||||
() {
|
||||
// ±0.0001° ≈ 11 meter
|
||||
expect(
|
||||
calc.isInsideGeofence(
|
||||
centerLat: _itsCenterLat,
|
||||
centerLng: _itsCenterLng,
|
||||
radiusMeters: 10,
|
||||
currentLat: _itsCenterLat + 0.0001,
|
||||
currentLng: _itsCenterLng,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('radius sangat besar (10km) — titik jauh masih inside', () {
|
||||
expect(
|
||||
calc.isInsideGeofence(
|
||||
centerLat: _itsCenterLat,
|
||||
centerLng: _itsCenterLng,
|
||||
radiusMeters: 10000,
|
||||
currentLat: _itsCenterLat + 0.05,
|
||||
currentLng: _itsCenterLng,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── GEOFENCE EXIT DETECTION ───────────────────────────────────────
|
||||
|
||||
group('hasExitedGeofence', () {
|
||||
const centerLat = _itsCenterLat;
|
||||
const centerLng = _itsCenterLng;
|
||||
const radius = 500.0;
|
||||
// Koordinat dalam radius
|
||||
const insideLat = _itsCenterLat + 0.001; // ≈ 111m
|
||||
const insideLng = _itsCenterLng;
|
||||
// Koordinat di luar radius
|
||||
const outsideLat = _itsCenterLat + 0.01; // ≈ 1110m
|
||||
const outsideLng = _itsCenterLng;
|
||||
|
||||
test('pindah dari dalam ke luar → hasExited = true', () {
|
||||
expect(
|
||||
calc.hasExitedGeofence(
|
||||
centerLat: centerLat,
|
||||
centerLng: centerLng,
|
||||
radiusMeters: radius,
|
||||
previousLat: insideLat,
|
||||
previousLng: insideLng,
|
||||
currentLat: outsideLat,
|
||||
currentLng: outsideLng,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('masih di dalam → hasExited = false', () {
|
||||
expect(
|
||||
calc.hasExitedGeofence(
|
||||
centerLat: centerLat,
|
||||
centerLng: centerLng,
|
||||
radiusMeters: radius,
|
||||
previousLat: insideLat,
|
||||
previousLng: insideLng,
|
||||
currentLat: insideLat + 0.0005,
|
||||
currentLng: insideLng,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('sudah di luar dan makin jauh → hasExited = false', () {
|
||||
expect(
|
||||
calc.hasExitedGeofence(
|
||||
centerLat: centerLat,
|
||||
centerLng: centerLng,
|
||||
radiusMeters: radius,
|
||||
previousLat: outsideLat,
|
||||
previousLng: outsideLng,
|
||||
currentLat: outsideLat + 0.005,
|
||||
currentLng: outsideLng,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── GEOFENCE ENTER DETECTION ─────────────────────────────────────
|
||||
|
||||
group('hasEnteredGeofence', () {
|
||||
const centerLat = _itsCenterLat;
|
||||
const centerLng = _itsCenterLng;
|
||||
const radius = 500.0;
|
||||
const insideLat = _itsCenterLat + 0.001;
|
||||
const insideLng = _itsCenterLng;
|
||||
const outsideLat = _itsCenterLat + 0.01;
|
||||
const outsideLng = _itsCenterLng;
|
||||
|
||||
test('pindah dari luar ke dalam → hasEntered = true', () {
|
||||
expect(
|
||||
calc.hasEnteredGeofence(
|
||||
centerLat: centerLat,
|
||||
centerLng: centerLng,
|
||||
radiusMeters: radius,
|
||||
previousLat: outsideLat,
|
||||
previousLng: outsideLng,
|
||||
currentLat: insideLat,
|
||||
currentLng: insideLng,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('sudah di dalam → hasEntered = false', () {
|
||||
expect(
|
||||
calc.hasEnteredGeofence(
|
||||
centerLat: centerLat,
|
||||
centerLng: centerLng,
|
||||
radiusMeters: radius,
|
||||
previousLat: insideLat,
|
||||
previousLng: insideLng,
|
||||
currentLat: insideLat + 0.0001,
|
||||
currentLng: insideLng,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── REAL WORLD REFERENCE TESTS ────────────────────────────────────
|
||||
|
||||
group('real-world reference distances', () {
|
||||
test('jarak ITS ke Surabaya Gubeng station ≈ 6-8 km', () {
|
||||
// Stasiun Gubeng: -7.2651, 112.7509
|
||||
final dist = calc.haversineDistance(
|
||||
_itsCenterLat, _itsCenterLng, -7.2651, 112.7509);
|
||||
expect(dist, greaterThan(4000));
|
||||
expect(dist, lessThan(10000));
|
||||
});
|
||||
|
||||
test('jarak Jakarta ke Surabaya ≈ 700-800 km', () {
|
||||
// Jakarta: -6.2088, 106.8456
|
||||
// Surabaya: -7.2575, 112.7521
|
||||
final dist =
|
||||
calc.haversineDistance(-6.2088, 106.8456, -7.2575, 112.7521);
|
||||
expect(dist, greaterThan(650_000));
|
||||
expect(dist, lessThan(850_000));
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,249 @@
|
||||
// test/unit/login_use_case_test.dart
|
||||
//
|
||||
// Unit test untuk LoginUseCase / AuthRepositoryImpl.
|
||||
// Jalankan: flutter test test/unit/login_use_case_test.dart
|
||||
//
|
||||
// Dependencies (pubspec.yaml dev_dependencies):
|
||||
// flutter_test: sdk: flutter
|
||||
// mockito: ^5.4.4
|
||||
// build_runner: ^2.4.6
|
||||
|
||||
import 'package:dartz/dartz.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
|
||||
// ---------- Minimal stubs (copy dari project asli) ----------
|
||||
|
||||
abstract class Failure {
|
||||
final String message;
|
||||
const Failure(this.message);
|
||||
}
|
||||
|
||||
class ServerFailure extends Failure {
|
||||
const ServerFailure(super.message);
|
||||
}
|
||||
|
||||
class NetworkFailure extends Failure {
|
||||
const NetworkFailure(super.message);
|
||||
}
|
||||
|
||||
class AuthFailure extends Failure {
|
||||
const AuthFailure(super.message);
|
||||
}
|
||||
|
||||
class UserEntity {
|
||||
final String token;
|
||||
final String role;
|
||||
UserEntity({required this.token, required this.role});
|
||||
}
|
||||
|
||||
abstract class AuthRepository {
|
||||
Future<Either<Failure, UserEntity>> login(String email, String password);
|
||||
}
|
||||
|
||||
// ---------- Mock abstract classes ----------
|
||||
@GenerateMocks([AuthRepository])
|
||||
// File mock di-generate via: flutter pub run build_runner build
|
||||
// Untuk demo tanpa build_runner, kita buat manual mock di bawah
|
||||
|
||||
class MockAuthRepository extends Mock implements AuthRepository {}
|
||||
|
||||
// ---------- Use case ----------
|
||||
|
||||
class LoginUseCase {
|
||||
final AuthRepository repository;
|
||||
LoginUseCase(this.repository);
|
||||
|
||||
Future<Either<Failure, UserEntity>> call(String email, String password) {
|
||||
if (email.trim().isEmpty) {
|
||||
return Future.value(const Left(AuthFailure('Email tidak boleh kosong')));
|
||||
}
|
||||
if (password.length < 6) {
|
||||
return Future.value(
|
||||
const Left(AuthFailure('Password minimal 6 karakter')));
|
||||
}
|
||||
return repository.login(email, password);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Tests ----------
|
||||
|
||||
void main() {
|
||||
late LoginUseCase loginUseCase;
|
||||
late MockAuthRepository mockAuthRepository;
|
||||
|
||||
setUp(() {
|
||||
mockAuthRepository = MockAuthRepository();
|
||||
loginUseCase = LoginUseCase(mockAuthRepository);
|
||||
});
|
||||
|
||||
group('LoginUseCase — validasi input', () {
|
||||
test('harus return AuthFailure jika email kosong', () async {
|
||||
final result = await loginUseCase.call('', 'password123');
|
||||
expect(result.isLeft(), true);
|
||||
result.fold(
|
||||
(failure) {
|
||||
expect(failure, isA<AuthFailure>());
|
||||
expect(failure.message, contains('kosong'));
|
||||
},
|
||||
(_) => fail('Seharusnya return Left'),
|
||||
);
|
||||
});
|
||||
|
||||
test('harus return AuthFailure jika email hanya spasi', () async {
|
||||
final result = await loginUseCase.call(' ', 'password123');
|
||||
expect(result.isLeft(), true);
|
||||
result.fold(
|
||||
(failure) => expect(failure, isA<AuthFailure>()),
|
||||
(_) => fail('Seharusnya return Left'),
|
||||
);
|
||||
});
|
||||
|
||||
test('harus return AuthFailure jika password kurang dari 6 karakter',
|
||||
() async {
|
||||
final result = await loginUseCase.call('user@test.com', '123');
|
||||
expect(result.isLeft(), true);
|
||||
result.fold(
|
||||
(failure) {
|
||||
expect(failure, isA<AuthFailure>());
|
||||
expect(failure.message, contains('6'));
|
||||
},
|
||||
(_) => fail('Seharusnya return Left'),
|
||||
);
|
||||
});
|
||||
|
||||
test('harus return AuthFailure jika password tepat 5 karakter', () async {
|
||||
final result = await loginUseCase.call('user@test.com', 'abc12');
|
||||
expect(result.isLeft(), true);
|
||||
result.fold(
|
||||
(failure) => expect(failure, isA<AuthFailure>()),
|
||||
(_) => fail('Seharusnya return Left'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('LoginUseCase — delegasi ke repository', () {
|
||||
test('harus call repository.login dengan email dan password yang benar',
|
||||
() async {
|
||||
const email = 'evan@test.com';
|
||||
const password = 'password123';
|
||||
final fakeUser = UserEntity(token: 'jwt_token_xyz', role: 'ROLE_USER');
|
||||
|
||||
when(mockAuthRepository.login(email, password))
|
||||
.thenAnswer((_) async => Right(fakeUser));
|
||||
|
||||
final result = await loginUseCase.call(email, password);
|
||||
|
||||
verify(mockAuthRepository.login(email, password)).called(1);
|
||||
expect(result.isRight(), true);
|
||||
});
|
||||
|
||||
test('harus return UserEntity dengan token saat login sukses', () async {
|
||||
const email = 'guardian@test.com';
|
||||
const password = 'secure123';
|
||||
final fakeUser = UserEntity(token: 'guardian_jwt', role: 'ROLE_GUARDIAN');
|
||||
|
||||
when(mockAuthRepository.login(email, password))
|
||||
.thenAnswer((_) async => Right(fakeUser));
|
||||
|
||||
final result = await loginUseCase.call(email, password);
|
||||
|
||||
result.fold(
|
||||
(_) => fail('Seharusnya sukses'),
|
||||
(user) {
|
||||
expect(user.token, 'guardian_jwt');
|
||||
expect(user.role, 'ROLE_GUARDIAN');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('harus return ServerFailure saat API gagal', () async {
|
||||
const email = 'user@test.com';
|
||||
const password = 'password123';
|
||||
|
||||
when(mockAuthRepository.login(email, password)).thenAnswer(
|
||||
(_) async => const Left(ServerFailure('Server tidak merespons')));
|
||||
|
||||
final result = await loginUseCase.call(email, password);
|
||||
|
||||
expect(result.isLeft(), true);
|
||||
result.fold(
|
||||
(failure) {
|
||||
expect(failure, isA<ServerFailure>());
|
||||
expect(failure.message, 'Server tidak merespons');
|
||||
},
|
||||
(_) => fail('Seharusnya return Left'),
|
||||
);
|
||||
});
|
||||
|
||||
test('harus return NetworkFailure saat tidak ada koneksi', () async {
|
||||
const email = 'user@test.com';
|
||||
const password = 'password123';
|
||||
|
||||
when(mockAuthRepository.login(email, password)).thenAnswer((_) async =>
|
||||
const Left(NetworkFailure('Tidak ada koneksi internet')));
|
||||
|
||||
final result = await loginUseCase.call(email, password);
|
||||
|
||||
result.fold(
|
||||
(failure) => expect(failure, isA<NetworkFailure>()),
|
||||
(_) => fail('Seharusnya return Left'),
|
||||
);
|
||||
});
|
||||
|
||||
test('tidak boleh call repository jika validasi gagal', () async {
|
||||
await loginUseCase.call('', 'password123');
|
||||
verifyNever(mockAuthRepository.login('ignored', 'ignored'));
|
||||
});
|
||||
|
||||
test('harus return Right dengan role ROLE_USER untuk user biasa', () async {
|
||||
final fakeUser = UserEntity(token: 'user_token', role: 'ROLE_USER');
|
||||
|
||||
when(mockAuthRepository.login('user@test.com', 'user1234'))
|
||||
.thenAnswer((_) async => Right(fakeUser));
|
||||
|
||||
final result = await loginUseCase.call('user@test.com', 'user1234');
|
||||
|
||||
result.fold(
|
||||
(_) => fail('Seharusnya sukses'),
|
||||
(user) => expect(user.role, 'ROLE_USER'),
|
||||
);
|
||||
});
|
||||
|
||||
test('password tepat 6 karakter harus lolos validasi', () async {
|
||||
final fakeUser = UserEntity(token: 'tok', role: 'ROLE_USER');
|
||||
|
||||
when(mockAuthRepository.login('a@b.com', 'abc123'))
|
||||
.thenAnswer((_) async => Right(fakeUser));
|
||||
|
||||
final result = await loginUseCase.call('a@b.com', 'abc123');
|
||||
expect(result.isRight(), true);
|
||||
});
|
||||
});
|
||||
|
||||
group('LoginUseCase — edge cases', () {
|
||||
test('harus handle karakter spesial di email', () async {
|
||||
final fakeUser = UserEntity(token: 'tok', role: 'ROLE_USER');
|
||||
const email = 'user+tag@example.co.id';
|
||||
|
||||
when(mockAuthRepository.login(email, 'password123'))
|
||||
.thenAnswer((_) async => Right(fakeUser));
|
||||
|
||||
final result = await loginUseCase.call(email, 'password123');
|
||||
expect(result.isRight(), true);
|
||||
});
|
||||
|
||||
test('harus handle exception dari repository', () async {
|
||||
when(mockAuthRepository.login('user@test.com', 'password123'))
|
||||
.thenThrow(Exception('Unexpected error'));
|
||||
|
||||
// LoginUseCase tidak wrap exception dari repo sendiri — test ini
|
||||
// memverifikasi bahwa exception propagate (akan di-handle di BLoC)
|
||||
expect(
|
||||
() => loginUseCase.call('user@test.com', 'password123'),
|
||||
throwsException,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,473 @@
|
||||
// test/unit/obstacle_analyzer_test.dart
|
||||
//
|
||||
// Unit test untuk ObstacleAnalyzer — logika AI direction & distance.
|
||||
// Jalankan: flutter test test/unit/obstacle_analyzer_test.dart
|
||||
//
|
||||
// Tidak ada dependency eksternal — murni logika pure Dart.
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
// ---------- Stubs (mirror dari project asli) ----------
|
||||
|
||||
enum ObstacleDirection { left, center, right }
|
||||
|
||||
class BoundingBox {
|
||||
final double left;
|
||||
final double top;
|
||||
final double right;
|
||||
final double bottom;
|
||||
|
||||
const BoundingBox({
|
||||
required this.left,
|
||||
required this.top,
|
||||
required this.right,
|
||||
required this.bottom,
|
||||
});
|
||||
|
||||
double get width => right - left;
|
||||
double get height => bottom - top;
|
||||
double get centerX => left + width / 2;
|
||||
double get centerY => top + height / 2;
|
||||
}
|
||||
|
||||
class DetectionResult {
|
||||
final String label;
|
||||
final double confidence;
|
||||
final ObstacleDirection direction;
|
||||
final String estimatedDistance;
|
||||
|
||||
const DetectionResult({
|
||||
required this.label,
|
||||
required this.confidence,
|
||||
required this.direction,
|
||||
required this.estimatedDistance,
|
||||
});
|
||||
|
||||
String get spokenId {
|
||||
final area = switch (direction) {
|
||||
ObstacleDirection.left => 'kiri',
|
||||
ObstacleDirection.center => 'tengah',
|
||||
ObstacleDirection.right => 'kanan',
|
||||
};
|
||||
return 'Hati-hati, $label di $area. Jarak $estimatedDistance.';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- ObstacleAnalyzer yang di-test ----------
|
||||
|
||||
class ObstacleAnalyzer {
|
||||
static const double _frameWidth = 640.0;
|
||||
static const double _frameHeight = 480.0;
|
||||
|
||||
/// Tentukan arah berdasarkan posisi horizontal tengah bounding box.
|
||||
/// - centerX < 33% frame → LEFT
|
||||
/// - centerX > 67% frame → RIGHT
|
||||
/// - otherwise → CENTER
|
||||
ObstacleDirection analyzeDirection(BoundingBox box) {
|
||||
final cx = box.centerX;
|
||||
if (cx < _frameWidth * 0.33) return ObstacleDirection.left;
|
||||
if (cx > _frameWidth * 0.67) return ObstacleDirection.right;
|
||||
return ObstacleDirection.center;
|
||||
}
|
||||
|
||||
/// Estimasi jarak dari rasio tinggi bounding box terhadap frame.
|
||||
/// - ratio > 0.60 → "Very Close (< 1m)"
|
||||
/// - ratio > 0.35 → "Close (1-2m)"
|
||||
/// - ratio > 0.15 → "Medium (2-4m)"
|
||||
/// - else → "Far (> 4m)"
|
||||
String estimateDistance(BoundingBox box) {
|
||||
final ratio = box.height / _frameHeight;
|
||||
if (ratio > 0.60) return 'Very Close (< 1m)';
|
||||
if (ratio > 0.35) return 'Close (1-2m)';
|
||||
if (ratio > 0.15) return 'Medium (2-4m)';
|
||||
return 'Far (> 4m)';
|
||||
}
|
||||
|
||||
/// Buat pesan TTS dari hasil deteksi.
|
||||
String buildTtsMessage(DetectionResult result) {
|
||||
return 'Hati-hati, ${result.label} di '
|
||||
'${_directionLabel(result.direction)}. '
|
||||
'Jarak ${result.estimatedDistance}.';
|
||||
}
|
||||
|
||||
String _directionLabel(ObstacleDirection dir) => switch (dir) {
|
||||
ObstacleDirection.left => 'kiri',
|
||||
ObstacleDirection.center => 'depan',
|
||||
ObstacleDirection.right => 'kanan',
|
||||
};
|
||||
|
||||
/// Pilih obstacle paling prioritas (Very Close > Close > Medium > Far).
|
||||
DetectionResult? prioritize(List<DetectionResult> detections) {
|
||||
if (detections.isEmpty) return null;
|
||||
const order = [
|
||||
'Very Close (< 1m)',
|
||||
'Close (1-2m)',
|
||||
'Medium (2-4m)',
|
||||
'Far (> 4m)',
|
||||
];
|
||||
detections.sort((a, b) => order
|
||||
.indexOf(a.estimatedDistance)
|
||||
.compareTo(order.indexOf(b.estimatedDistance)));
|
||||
return detections.first;
|
||||
}
|
||||
|
||||
/// Filter deteksi berdasarkan confidence threshold.
|
||||
List<DetectionResult> filterByConfidence(
|
||||
List<DetectionResult> detections, double threshold) {
|
||||
return detections.where((d) => d.confidence >= threshold).toList();
|
||||
}
|
||||
|
||||
/// Fallback detection (dipakai jika YOLO tidak jalan).
|
||||
DetectionResult analyzeFallback(
|
||||
{String label = 'person', double confidence = 0.86}) {
|
||||
return DetectionResult(
|
||||
label: label,
|
||||
confidence: confidence,
|
||||
direction: ObstacleDirection.center,
|
||||
estimatedDistance: 'Close (1-2m)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Tests ----------
|
||||
|
||||
void main() {
|
||||
late ObstacleAnalyzer analyzer;
|
||||
|
||||
setUp(() {
|
||||
analyzer = ObstacleAnalyzer();
|
||||
});
|
||||
|
||||
// ── DIRECTION TESTS ──────────────────────────────────────────────
|
||||
|
||||
group('analyzeDirection — zone kiri (< 33% lebar frame = < 211px)', () {
|
||||
test('obstacle di ujung kiri (cx=10) → LEFT', () {
|
||||
final box = const BoundingBox(left: 0, top: 100, right: 20, bottom: 300);
|
||||
expect(analyzer.analyzeDirection(box), ObstacleDirection.left);
|
||||
});
|
||||
|
||||
test('obstacle di kiri batas (cx=200) → LEFT', () {
|
||||
final box =
|
||||
const BoundingBox(left: 150, top: 100, right: 250, bottom: 300);
|
||||
expect(analyzer.analyzeDirection(box), ObstacleDirection.left);
|
||||
});
|
||||
|
||||
test('obstacle dengan cx tepat di batas kiri (cx=210) → LEFT', () {
|
||||
// 640 * 0.33 = 211.2, jadi cx=210 masih LEFT
|
||||
final box =
|
||||
const BoundingBox(left: 160, top: 100, right: 260, bottom: 300);
|
||||
expect(analyzer.analyzeDirection(box), ObstacleDirection.left);
|
||||
});
|
||||
});
|
||||
|
||||
group('analyzeDirection — zone kanan (> 67% lebar frame = > 428px)', () {
|
||||
test('obstacle di ujung kanan (cx=620) → RIGHT', () {
|
||||
final box =
|
||||
const BoundingBox(left: 600, top: 100, right: 640, bottom: 300);
|
||||
expect(analyzer.analyzeDirection(box), ObstacleDirection.right);
|
||||
});
|
||||
|
||||
test('obstacle di kanan batas (cx=450) → RIGHT', () {
|
||||
final box =
|
||||
const BoundingBox(left: 400, top: 100, right: 500, bottom: 300);
|
||||
expect(analyzer.analyzeDirection(box), ObstacleDirection.right);
|
||||
});
|
||||
});
|
||||
|
||||
group('analyzeDirection — zone tengah (33%-67%)', () {
|
||||
test('obstacle tepat di tengah (cx=320) → CENTER', () {
|
||||
final box =
|
||||
const BoundingBox(left: 280, top: 100, right: 360, bottom: 300);
|
||||
expect(analyzer.analyzeDirection(box), ObstacleDirection.center);
|
||||
});
|
||||
|
||||
test('obstacle di antara kiri dan tengah (cx=300) → CENTER', () {
|
||||
final box =
|
||||
const BoundingBox(left: 250, top: 50, right: 350, bottom: 250);
|
||||
expect(analyzer.analyzeDirection(box), ObstacleDirection.center);
|
||||
});
|
||||
});
|
||||
|
||||
// ── DISTANCE ESTIMATION TESTS ────────────────────────────────────
|
||||
|
||||
group('estimateDistance — Very Close (> 60% tinggi frame = > 288px)', () {
|
||||
test('box setinggi hampir full frame → Very Close', () {
|
||||
// height = 450 / 480 = 0.9375 → Very Close
|
||||
final box =
|
||||
const BoundingBox(left: 100, top: 10, right: 400, bottom: 460);
|
||||
expect(analyzer.estimateDistance(box), contains('Very Close'));
|
||||
});
|
||||
|
||||
test('height ratio tepat 61% → Very Close', () {
|
||||
// 480 * 0.61 = 292.8 → height 293
|
||||
final box =
|
||||
const BoundingBox(left: 100, top: 50, right: 400, bottom: 343);
|
||||
expect(analyzer.estimateDistance(box), contains('Very Close'));
|
||||
});
|
||||
});
|
||||
|
||||
group('estimateDistance — Close (35%-60%)', () {
|
||||
test('height ratio 50% → Close', () {
|
||||
// 480 * 0.5 = 240
|
||||
final box =
|
||||
const BoundingBox(left: 100, top: 120, right: 400, bottom: 360);
|
||||
expect(analyzer.estimateDistance(box), contains('Close'));
|
||||
});
|
||||
|
||||
test('height ratio 36% → Close', () {
|
||||
// 480 * 0.36 = 172.8
|
||||
final box =
|
||||
const BoundingBox(left: 100, top: 150, right: 400, bottom: 323);
|
||||
expect(analyzer.estimateDistance(box), contains('Close'));
|
||||
});
|
||||
});
|
||||
|
||||
group('estimateDistance — Medium (15%-35%)', () {
|
||||
test('height ratio 25% → Medium', () {
|
||||
// 480 * 0.25 = 120
|
||||
final box =
|
||||
const BoundingBox(left: 100, top: 180, right: 400, bottom: 300);
|
||||
expect(analyzer.estimateDistance(box), contains('Medium'));
|
||||
});
|
||||
|
||||
test('height ratio 16% → Medium', () {
|
||||
// 480 * 0.16 = 76.8
|
||||
final box =
|
||||
const BoundingBox(left: 100, top: 200, right: 400, bottom: 277);
|
||||
expect(analyzer.estimateDistance(box), contains('Medium'));
|
||||
});
|
||||
});
|
||||
|
||||
group('estimateDistance — Far (≤ 15%)', () {
|
||||
test('height ratio 10% → Far', () {
|
||||
// 480 * 0.10 = 48
|
||||
final box =
|
||||
const BoundingBox(left: 100, top: 200, right: 400, bottom: 248);
|
||||
expect(analyzer.estimateDistance(box), contains('Far'));
|
||||
});
|
||||
|
||||
test('height ratio sangat kecil → Far', () {
|
||||
final box =
|
||||
const BoundingBox(left: 200, top: 230, right: 400, bottom: 250);
|
||||
expect(analyzer.estimateDistance(box), contains('Far'));
|
||||
});
|
||||
});
|
||||
|
||||
// ── TTS MESSAGE TESTS ────────────────────────────────────────────
|
||||
|
||||
group('buildTtsMessage', () {
|
||||
test('pesan harus mengandung label obstacle', () {
|
||||
const result = DetectionResult(
|
||||
label: 'motor',
|
||||
confidence: 0.9,
|
||||
direction: ObstacleDirection.right,
|
||||
estimatedDistance: 'Close (1-2m)',
|
||||
);
|
||||
final msg = analyzer.buildTtsMessage(result);
|
||||
expect(msg, contains('motor'));
|
||||
});
|
||||
|
||||
test('pesan harus mengandung arah dalam bahasa Indonesia', () {
|
||||
const result = DetectionResult(
|
||||
label: 'person',
|
||||
confidence: 0.8,
|
||||
direction: ObstacleDirection.left,
|
||||
estimatedDistance: 'Very Close (< 1m)',
|
||||
);
|
||||
final msg = analyzer.buildTtsMessage(result);
|
||||
expect(msg, contains('kiri'));
|
||||
});
|
||||
|
||||
test('pesan CENTER harus bilang "depan"', () {
|
||||
const result = DetectionResult(
|
||||
label: 'car',
|
||||
confidence: 0.95,
|
||||
direction: ObstacleDirection.center,
|
||||
estimatedDistance: 'Medium (2-4m)',
|
||||
);
|
||||
final msg = analyzer.buildTtsMessage(result);
|
||||
expect(msg, contains('depan'));
|
||||
});
|
||||
|
||||
test('pesan harus mengandung estimasi jarak', () {
|
||||
const result = DetectionResult(
|
||||
label: 'bicycle',
|
||||
confidence: 0.75,
|
||||
direction: ObstacleDirection.center,
|
||||
estimatedDistance: 'Far (> 4m)',
|
||||
);
|
||||
final msg = analyzer.buildTtsMessage(result);
|
||||
expect(msg, contains('Far'));
|
||||
});
|
||||
});
|
||||
|
||||
// ── PRIORITIZE TESTS ─────────────────────────────────────────────
|
||||
|
||||
group('prioritize', () {
|
||||
test('list kosong → return null', () {
|
||||
expect(analyzer.prioritize([]), isNull);
|
||||
});
|
||||
|
||||
test('Very Close harus menang atas Close', () {
|
||||
const detections = [
|
||||
DetectionResult(
|
||||
label: 'car',
|
||||
confidence: 0.7,
|
||||
direction: ObstacleDirection.center,
|
||||
estimatedDistance: 'Close (1-2m)'),
|
||||
DetectionResult(
|
||||
label: 'person',
|
||||
confidence: 0.9,
|
||||
direction: ObstacleDirection.left,
|
||||
estimatedDistance: 'Very Close (< 1m)'),
|
||||
];
|
||||
final result = analyzer.prioritize(detections);
|
||||
expect(result?.estimatedDistance, contains('Very Close'));
|
||||
});
|
||||
|
||||
test('Close harus menang atas Medium', () {
|
||||
const detections = [
|
||||
DetectionResult(
|
||||
label: 'dog',
|
||||
confidence: 0.6,
|
||||
direction: ObstacleDirection.right,
|
||||
estimatedDistance: 'Medium (2-4m)'),
|
||||
DetectionResult(
|
||||
label: 'truck',
|
||||
confidence: 0.85,
|
||||
direction: ObstacleDirection.center,
|
||||
estimatedDistance: 'Close (1-2m)'),
|
||||
];
|
||||
final result = analyzer.prioritize(detections);
|
||||
expect(result?.estimatedDistance, contains('Close'));
|
||||
});
|
||||
|
||||
test('list dengan 1 item → return item tersebut', () {
|
||||
const detections = [
|
||||
DetectionResult(
|
||||
label: 'pole',
|
||||
confidence: 0.5,
|
||||
direction: ObstacleDirection.center,
|
||||
estimatedDistance: 'Far (> 4m)'),
|
||||
];
|
||||
final result = analyzer.prioritize(detections);
|
||||
expect(result?.label, 'pole');
|
||||
});
|
||||
});
|
||||
|
||||
// ── CONFIDENCE FILTER TESTS ───────────────────────────────────────
|
||||
|
||||
group('filterByConfidence', () {
|
||||
const allDetections = [
|
||||
DetectionResult(
|
||||
label: 'person',
|
||||
confidence: 0.9,
|
||||
direction: ObstacleDirection.center,
|
||||
estimatedDistance: 'Close (1-2m)'),
|
||||
DetectionResult(
|
||||
label: 'car',
|
||||
confidence: 0.4,
|
||||
direction: ObstacleDirection.left,
|
||||
estimatedDistance: 'Far (> 4m)'),
|
||||
DetectionResult(
|
||||
label: 'motorcycle',
|
||||
confidence: 0.6,
|
||||
direction: ObstacleDirection.right,
|
||||
estimatedDistance: 'Medium (2-4m)'),
|
||||
];
|
||||
|
||||
test('threshold 0.5 → harus lolos 2 item (0.9 dan 0.6)', () {
|
||||
final filtered = analyzer.filterByConfidence(allDetections, 0.5);
|
||||
expect(filtered.length, 2);
|
||||
});
|
||||
|
||||
test('threshold 0.7 → hanya 1 item lolos (0.9)', () {
|
||||
final filtered = analyzer.filterByConfidence(allDetections, 0.7);
|
||||
expect(filtered.length, 1);
|
||||
expect(filtered.first.label, 'person');
|
||||
});
|
||||
|
||||
test('threshold 0.0 → semua lolos', () {
|
||||
final filtered = analyzer.filterByConfidence(allDetections, 0.0);
|
||||
expect(filtered.length, 3);
|
||||
});
|
||||
|
||||
test('threshold 1.0 → tidak ada yang lolos', () {
|
||||
final filtered = analyzer.filterByConfidence(allDetections, 1.0);
|
||||
expect(filtered.isEmpty, true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── FALLBACK TESTS ───────────────────────────────────────────────
|
||||
|
||||
group('analyzeFallback', () {
|
||||
test('harus return DetectionResult dengan default label "person"', () {
|
||||
final result = analyzer.analyzeFallback();
|
||||
expect(result.label, 'person');
|
||||
});
|
||||
|
||||
test('harus return confidence 0.86 by default', () {
|
||||
final result = analyzer.analyzeFallback();
|
||||
expect(result.confidence, closeTo(0.86, 0.001));
|
||||
});
|
||||
|
||||
test('harus return direction CENTER by default', () {
|
||||
final result = analyzer.analyzeFallback();
|
||||
expect(result.direction, ObstacleDirection.center);
|
||||
});
|
||||
|
||||
test('harus bisa override label', () {
|
||||
final result = analyzer.analyzeFallback(label: 'car');
|
||||
expect(result.label, 'car');
|
||||
});
|
||||
|
||||
test('harus bisa override confidence', () {
|
||||
final result = analyzer.analyzeFallback(confidence: 0.5);
|
||||
expect(result.confidence, closeTo(0.5, 0.001));
|
||||
});
|
||||
});
|
||||
|
||||
// ── DETECTION RESULT SPOKEN ID TESTS ─────────────────────────────
|
||||
|
||||
group('DetectionResult.spokenId', () {
|
||||
test('spokenId LEFT harus mengandung "kiri"', () {
|
||||
const r = DetectionResult(
|
||||
label: 'person',
|
||||
confidence: 0.8,
|
||||
direction: ObstacleDirection.left,
|
||||
estimatedDistance: 'Close',
|
||||
);
|
||||
expect(r.spokenId, contains('kiri'));
|
||||
});
|
||||
|
||||
test('spokenId RIGHT harus mengandung "kanan"', () {
|
||||
const r = DetectionResult(
|
||||
label: 'car',
|
||||
confidence: 0.8,
|
||||
direction: ObstacleDirection.right,
|
||||
estimatedDistance: 'Far',
|
||||
);
|
||||
expect(r.spokenId, contains('kanan'));
|
||||
});
|
||||
|
||||
test('spokenId CENTER harus mengandung "tengah"', () {
|
||||
const r = DetectionResult(
|
||||
label: 'motorcycle',
|
||||
confidence: 0.8,
|
||||
direction: ObstacleDirection.center,
|
||||
estimatedDistance: 'Very Close',
|
||||
);
|
||||
expect(r.spokenId, contains('tengah'));
|
||||
});
|
||||
|
||||
test('spokenId harus mengandung nama label', () {
|
||||
const r = DetectionResult(
|
||||
label: 'truck',
|
||||
confidence: 0.9,
|
||||
direction: ObstacleDirection.center,
|
||||
estimatedDistance: 'Close',
|
||||
);
|
||||
expect(r.spokenId, contains('truck'));
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,382 @@
|
||||
// test/unit/register_use_case_test.dart
|
||||
//
|
||||
// Unit test untuk RegisterUseCase.
|
||||
// Jalankan: flutter test test/unit/register_use_case_test.dart
|
||||
|
||||
import 'package:dartz/dartz.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
|
||||
// ---------- Stubs ----------
|
||||
|
||||
abstract class Failure {
|
||||
final String message;
|
||||
const Failure(this.message);
|
||||
}
|
||||
|
||||
class ServerFailure extends Failure {
|
||||
const ServerFailure(super.message);
|
||||
}
|
||||
|
||||
class ValidationFailure extends Failure {
|
||||
const ValidationFailure(super.message);
|
||||
}
|
||||
|
||||
class NetworkFailure extends Failure {
|
||||
const NetworkFailure(super.message);
|
||||
}
|
||||
|
||||
class UserEntity {
|
||||
final String token;
|
||||
final String role;
|
||||
final String displayName;
|
||||
final String? uniqueUserId; // hanya ROLE_USER yang punya
|
||||
UserEntity({
|
||||
required this.token,
|
||||
required this.role,
|
||||
required this.displayName,
|
||||
this.uniqueUserId,
|
||||
});
|
||||
}
|
||||
|
||||
abstract class RegisterRepository {
|
||||
Future<Either<Failure, UserEntity>> register({
|
||||
required String email,
|
||||
required String password,
|
||||
required String displayName,
|
||||
required String role, // 'ROLE_USER' | 'ROLE_GUARDIAN'
|
||||
});
|
||||
}
|
||||
|
||||
class MockRegisterRepository extends Mock implements RegisterRepository {}
|
||||
|
||||
// ---------- Use case ----------
|
||||
|
||||
class RegisterUseCase {
|
||||
final RegisterRepository repository;
|
||||
RegisterUseCase(this.repository);
|
||||
|
||||
Future<Either<Failure, UserEntity>> call({
|
||||
required String email,
|
||||
required String password,
|
||||
required String displayName,
|
||||
required String role,
|
||||
}) async {
|
||||
// Validasi email format sederhana
|
||||
if (email.trim().isEmpty || !email.contains('@')) {
|
||||
return const Left(ValidationFailure('Format email tidak valid'));
|
||||
}
|
||||
// Validasi password
|
||||
if (password.length < 6) {
|
||||
return const Left(ValidationFailure('Password harus minimal 6 karakter'));
|
||||
}
|
||||
// Validasi displayName
|
||||
if (displayName.trim().isEmpty) {
|
||||
return const Left(ValidationFailure('Nama tidak boleh kosong'));
|
||||
}
|
||||
// Validasi role
|
||||
if (role != 'ROLE_USER' && role != 'ROLE_GUARDIAN') {
|
||||
return const Left(ValidationFailure('Role tidak valid'));
|
||||
}
|
||||
return repository.register(
|
||||
email: email.trim(),
|
||||
password: password,
|
||||
displayName: displayName.trim(),
|
||||
role: role,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Tests ----------
|
||||
|
||||
void main() {
|
||||
late RegisterUseCase registerUseCase;
|
||||
late MockRegisterRepository mockRepo;
|
||||
|
||||
setUp(() {
|
||||
mockRepo = MockRegisterRepository();
|
||||
registerUseCase = RegisterUseCase(mockRepo);
|
||||
});
|
||||
|
||||
group('RegisterUseCase — validasi email', () {
|
||||
test('harus gagal jika email kosong', () async {
|
||||
final result = await registerUseCase.call(
|
||||
email: '',
|
||||
password: 'password123',
|
||||
displayName: 'Evan',
|
||||
role: 'ROLE_USER',
|
||||
);
|
||||
expect(result.isLeft(), true);
|
||||
result.fold(
|
||||
(f) => expect(f, isA<ValidationFailure>()),
|
||||
(_) => fail('Seharusnya gagal'),
|
||||
);
|
||||
});
|
||||
|
||||
test('harus gagal jika email tanpa @', () async {
|
||||
final result = await registerUseCase.call(
|
||||
email: 'bukan-email',
|
||||
password: 'password123',
|
||||
displayName: 'Evan',
|
||||
role: 'ROLE_USER',
|
||||
);
|
||||
expect(result.isLeft(), true);
|
||||
});
|
||||
|
||||
test('harus berhasil dengan email valid', () async {
|
||||
final fakeUser = UserEntity(
|
||||
token: 'tok',
|
||||
role: 'ROLE_USER',
|
||||
displayName: 'Evan',
|
||||
uniqueUserId: 'ABC123DEF456',
|
||||
);
|
||||
when(mockRepo.register(
|
||||
email: 'evan@test.com',
|
||||
password: 'password123',
|
||||
displayName: 'Evan',
|
||||
role: 'ROLE_USER',
|
||||
)).thenAnswer((_) async => Right(fakeUser));
|
||||
|
||||
final result = await registerUseCase.call(
|
||||
email: 'evan@test.com',
|
||||
password: 'password123',
|
||||
displayName: 'Evan',
|
||||
role: 'ROLE_USER',
|
||||
);
|
||||
expect(result.isRight(), true);
|
||||
});
|
||||
});
|
||||
|
||||
group('RegisterUseCase — validasi password', () {
|
||||
test('harus gagal jika password kurang dari 6 karakter', () async {
|
||||
final result = await registerUseCase.call(
|
||||
email: 'evan@test.com',
|
||||
password: '12345',
|
||||
displayName: 'Evan',
|
||||
role: 'ROLE_USER',
|
||||
);
|
||||
expect(result.isLeft(), true);
|
||||
result.fold(
|
||||
(f) {
|
||||
expect(f, isA<ValidationFailure>());
|
||||
expect(f.message, contains('6'));
|
||||
},
|
||||
(_) => fail('Seharusnya gagal'),
|
||||
);
|
||||
});
|
||||
|
||||
test('harus gagal jika password kosong', () async {
|
||||
final result = await registerUseCase.call(
|
||||
email: 'evan@test.com',
|
||||
password: '',
|
||||
displayName: 'Evan',
|
||||
role: 'ROLE_USER',
|
||||
);
|
||||
expect(result.isLeft(), true);
|
||||
});
|
||||
|
||||
test('password tepat 6 karakter harus lolos', () async {
|
||||
final fakeUser =
|
||||
UserEntity(token: 'tok', role: 'ROLE_USER', displayName: 'Evan');
|
||||
when(mockRepo.register(
|
||||
email: 'evan@test.com',
|
||||
password: 'abc123',
|
||||
displayName: 'Evan',
|
||||
role: 'ROLE_USER',
|
||||
)).thenAnswer((_) async => Right(fakeUser));
|
||||
|
||||
final result = await registerUseCase.call(
|
||||
email: 'evan@test.com',
|
||||
password: 'abc123',
|
||||
displayName: 'Evan',
|
||||
role: 'ROLE_USER',
|
||||
);
|
||||
expect(result.isRight(), true);
|
||||
});
|
||||
});
|
||||
|
||||
group('RegisterUseCase — validasi displayName', () {
|
||||
test('harus gagal jika displayName kosong', () async {
|
||||
final result = await registerUseCase.call(
|
||||
email: 'evan@test.com',
|
||||
password: 'password123',
|
||||
displayName: '',
|
||||
role: 'ROLE_USER',
|
||||
);
|
||||
expect(result.isLeft(), true);
|
||||
});
|
||||
|
||||
test('harus gagal jika displayName hanya spasi', () async {
|
||||
final result = await registerUseCase.call(
|
||||
email: 'evan@test.com',
|
||||
password: 'password123',
|
||||
displayName: ' ',
|
||||
role: 'ROLE_USER',
|
||||
);
|
||||
expect(result.isLeft(), true);
|
||||
});
|
||||
});
|
||||
|
||||
group('RegisterUseCase — validasi role', () {
|
||||
test('harus gagal jika role bukan ROLE_USER atau ROLE_GUARDIAN', () async {
|
||||
final result = await registerUseCase.call(
|
||||
email: 'evan@test.com',
|
||||
password: 'password123',
|
||||
displayName: 'Evan',
|
||||
role: 'ROLE_ADMIN',
|
||||
);
|
||||
expect(result.isLeft(), true);
|
||||
result.fold(
|
||||
(f) => expect(f, isA<ValidationFailure>()),
|
||||
(_) => fail('Seharusnya gagal'),
|
||||
);
|
||||
});
|
||||
|
||||
test('ROLE_USER harus valid', () async {
|
||||
final fakeUser = UserEntity(
|
||||
token: 'tok',
|
||||
role: 'ROLE_USER',
|
||||
displayName: 'Evan',
|
||||
uniqueUserId: 'ABC123DEF456',
|
||||
);
|
||||
when(mockRepo.register(
|
||||
email: 'evan@test.com',
|
||||
password: 'password123',
|
||||
displayName: 'Evan',
|
||||
role: 'ROLE_USER',
|
||||
)).thenAnswer((_) async => Right(fakeUser));
|
||||
|
||||
final result = await registerUseCase.call(
|
||||
email: 'evan@test.com',
|
||||
password: 'password123',
|
||||
displayName: 'Evan',
|
||||
role: 'ROLE_USER',
|
||||
);
|
||||
expect(result.isRight(), true);
|
||||
});
|
||||
|
||||
test('ROLE_GUARDIAN harus valid', () async {
|
||||
final fakeUser = UserEntity(
|
||||
token: 'tok',
|
||||
role: 'ROLE_GUARDIAN',
|
||||
displayName: 'Bambang',
|
||||
);
|
||||
when(mockRepo.register(
|
||||
email: 'bambang@test.com',
|
||||
password: 'guardian123',
|
||||
displayName: 'Bambang',
|
||||
role: 'ROLE_GUARDIAN',
|
||||
)).thenAnswer((_) async => Right(fakeUser));
|
||||
|
||||
final result = await registerUseCase.call(
|
||||
email: 'bambang@test.com',
|
||||
password: 'guardian123',
|
||||
displayName: 'Bambang',
|
||||
role: 'ROLE_GUARDIAN',
|
||||
);
|
||||
expect(result.isRight(), true);
|
||||
});
|
||||
});
|
||||
|
||||
group('RegisterUseCase — ROLE_USER mendapat uniqueUserId', () {
|
||||
test('ROLE_USER harus mendapat uniqueUserId dari backend', () async {
|
||||
final fakeUser = UserEntity(
|
||||
token: 'user_jwt',
|
||||
role: 'ROLE_USER',
|
||||
displayName: 'Evan',
|
||||
uniqueUserId: 'ABC123DEF456',
|
||||
);
|
||||
when(mockRepo.register(
|
||||
email: 'evan@test.com',
|
||||
password: 'password123',
|
||||
displayName: 'Evan',
|
||||
role: 'ROLE_USER',
|
||||
)).thenAnswer((_) async => Right(fakeUser));
|
||||
|
||||
final result = await registerUseCase.call(
|
||||
email: 'evan@test.com',
|
||||
password: 'password123',
|
||||
displayName: 'Evan',
|
||||
role: 'ROLE_USER',
|
||||
);
|
||||
|
||||
result.fold(
|
||||
(_) => fail('Seharusnya sukses'),
|
||||
(user) {
|
||||
expect(user.uniqueUserId, isNotNull);
|
||||
expect(user.uniqueUserId!.length, 12);
|
||||
expect(user.role, 'ROLE_USER');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('ROLE_GUARDIAN tidak perlu uniqueUserId', () async {
|
||||
final fakeUser = UserEntity(
|
||||
token: 'guardian_jwt',
|
||||
role: 'ROLE_GUARDIAN',
|
||||
displayName: 'Bambang',
|
||||
uniqueUserId: null,
|
||||
);
|
||||
when(mockRepo.register(
|
||||
email: 'bambang@test.com',
|
||||
password: 'guardian123',
|
||||
displayName: 'Bambang',
|
||||
role: 'ROLE_GUARDIAN',
|
||||
)).thenAnswer((_) async => Right(fakeUser));
|
||||
|
||||
final result = await registerUseCase.call(
|
||||
email: 'bambang@test.com',
|
||||
password: 'guardian123',
|
||||
displayName: 'Bambang',
|
||||
role: 'ROLE_GUARDIAN',
|
||||
);
|
||||
|
||||
result.fold(
|
||||
(_) => fail('Seharusnya sukses'),
|
||||
(user) => expect(user.uniqueUserId, isNull),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('RegisterUseCase — error dari server', () {
|
||||
test('harus propagate ServerFailure dari repository', () async {
|
||||
when(mockRepo.register(
|
||||
email: 'taken@test.com',
|
||||
password: 'password123',
|
||||
displayName: 'Duplicate',
|
||||
role: 'ROLE_USER',
|
||||
)).thenAnswer(
|
||||
(_) async => const Left(ServerFailure('Email sudah terdaftar')));
|
||||
|
||||
final result = await registerUseCase.call(
|
||||
email: 'taken@test.com',
|
||||
password: 'password123',
|
||||
displayName: 'Duplicate',
|
||||
role: 'ROLE_USER',
|
||||
);
|
||||
|
||||
result.fold(
|
||||
(f) {
|
||||
expect(f, isA<ServerFailure>());
|
||||
expect(f.message, 'Email sudah terdaftar');
|
||||
},
|
||||
(_) => fail('Seharusnya gagal'),
|
||||
);
|
||||
});
|
||||
|
||||
test('tidak boleh call repo jika validasi gagal', () async {
|
||||
await registerUseCase.call(
|
||||
email: '',
|
||||
password: 'password123',
|
||||
displayName: 'Evan',
|
||||
role: 'ROLE_USER',
|
||||
);
|
||||
verifyNever(mockRepo.register(
|
||||
email: 'ignored',
|
||||
password: 'ignored',
|
||||
displayName: 'ignored',
|
||||
role: 'ignored',
|
||||
));
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,359 @@
|
||||
// test/unit/voice_command_handler_test.dart
|
||||
//
|
||||
// Unit test untuk VoiceCommandHandler — matching & dispatching command.
|
||||
// Jalankan: flutter test test/unit/voice_command_handler_test.dart
|
||||
//
|
||||
// Pure Dart test, tidak butuh platform plugin.
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
// ---------- Stubs (mirror dari project asli) ----------
|
||||
|
||||
enum VoiceCommandKey {
|
||||
openWalkguide,
|
||||
startWalkguide,
|
||||
stopWalkguide,
|
||||
callGuardian,
|
||||
openNotification,
|
||||
readAllNotif,
|
||||
openSos,
|
||||
sendSos,
|
||||
whereAmI,
|
||||
openActivity,
|
||||
openNavigation,
|
||||
openSettings,
|
||||
repeatLast,
|
||||
stopTts,
|
||||
}
|
||||
|
||||
class VoiceCommand {
|
||||
final VoiceCommandKey key;
|
||||
final String phrase;
|
||||
final bool enabled;
|
||||
const VoiceCommand(
|
||||
{required this.key, required this.phrase, required this.enabled});
|
||||
}
|
||||
|
||||
// Minimal TtsService stub
|
||||
class _FakeTts {
|
||||
String? lastSpeech;
|
||||
bool stopped = false;
|
||||
void repeatLast() => lastSpeech = 'repeated';
|
||||
void stop() => stopped = true;
|
||||
}
|
||||
|
||||
// Minimal SttService stub
|
||||
class _FakeStt {
|
||||
void Function(String)? onResult;
|
||||
void simulateUtterance(String text) => onResult?.call(text);
|
||||
}
|
||||
|
||||
// VoiceCommandHandler (mirror dari project)
|
||||
typedef CommandCallback = void Function(VoiceCommandKey key);
|
||||
|
||||
class VoiceCommandHandler {
|
||||
final _FakeStt _stt;
|
||||
final _FakeTts _tts;
|
||||
|
||||
List<VoiceCommand> _commands = [];
|
||||
CommandCallback? onCommand;
|
||||
|
||||
VoiceCommandHandler(this._stt, this._tts);
|
||||
|
||||
void loadCommands(List<VoiceCommand> commands) {
|
||||
_commands = commands;
|
||||
_stt.onResult = _processText;
|
||||
}
|
||||
|
||||
void loadDefaultCommands() {
|
||||
_commands = const [
|
||||
VoiceCommand(
|
||||
key: VoiceCommandKey.openWalkguide,
|
||||
phrase: 'open walkguide',
|
||||
enabled: true),
|
||||
VoiceCommand(
|
||||
key: VoiceCommandKey.startWalkguide,
|
||||
phrase: 'start walkguide',
|
||||
enabled: true),
|
||||
VoiceCommand(
|
||||
key: VoiceCommandKey.stopWalkguide,
|
||||
phrase: 'stop walkguide',
|
||||
enabled: true),
|
||||
VoiceCommand(
|
||||
key: VoiceCommandKey.callGuardian,
|
||||
phrase: 'call guardian',
|
||||
enabled: true),
|
||||
VoiceCommand(
|
||||
key: VoiceCommandKey.openNotification,
|
||||
phrase: 'open notifications',
|
||||
enabled: true),
|
||||
VoiceCommand(
|
||||
key: VoiceCommandKey.readAllNotif,
|
||||
phrase: 'read all my notifications',
|
||||
enabled: true),
|
||||
VoiceCommand(
|
||||
key: VoiceCommandKey.openSos, phrase: 'open sos', enabled: true),
|
||||
VoiceCommand(
|
||||
key: VoiceCommandKey.sendSos, phrase: 'send sos', enabled: true),
|
||||
VoiceCommand(
|
||||
key: VoiceCommandKey.whereAmI, phrase: 'where am i', enabled: true),
|
||||
VoiceCommand(
|
||||
key: VoiceCommandKey.openActivity,
|
||||
phrase: 'open activity log',
|
||||
enabled: true),
|
||||
VoiceCommand(
|
||||
key: VoiceCommandKey.openNavigation,
|
||||
phrase: 'open navigation',
|
||||
enabled: true),
|
||||
VoiceCommand(
|
||||
key: VoiceCommandKey.openSettings,
|
||||
phrase: 'open settings',
|
||||
enabled: true),
|
||||
VoiceCommand(
|
||||
key: VoiceCommandKey.repeatLast, phrase: 'repeat', enabled: true),
|
||||
VoiceCommand(key: VoiceCommandKey.stopTts, phrase: 'stop', enabled: true),
|
||||
];
|
||||
_stt.onResult = _processText;
|
||||
}
|
||||
|
||||
void _processText(String text) {
|
||||
final lower = text.toLowerCase().trim();
|
||||
for (final cmd in _commands) {
|
||||
if (!cmd.enabled) continue;
|
||||
if (lower.contains(cmd.phrase.toLowerCase())) {
|
||||
_handleCommand(cmd.key);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _handleCommand(VoiceCommandKey key) {
|
||||
onCommand?.call(key);
|
||||
if (key == VoiceCommandKey.repeatLast) _tts.repeatLast();
|
||||
if (key == VoiceCommandKey.stopTts) _tts.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Tests ----------
|
||||
|
||||
void main() {
|
||||
late VoiceCommandHandler handler;
|
||||
late _FakeStt fakeStt;
|
||||
late _FakeTts fakeTts;
|
||||
|
||||
final List<VoiceCommandKey> dispatchedCommands = [];
|
||||
|
||||
setUp(() {
|
||||
fakeStt = _FakeStt();
|
||||
fakeTts = _FakeTts();
|
||||
handler = VoiceCommandHandler(fakeStt, fakeTts);
|
||||
dispatchedCommands.clear();
|
||||
handler.loadDefaultCommands();
|
||||
handler.onCommand = dispatchedCommands.add;
|
||||
});
|
||||
|
||||
// ── BASIC COMMAND MATCHING ────────────────────────────────────────
|
||||
|
||||
group('default commands — exact phrase match', () {
|
||||
test('"start walkguide" → startWalkguide', () {
|
||||
fakeStt.simulateUtterance('start walkguide');
|
||||
expect(dispatchedCommands, contains(VoiceCommandKey.startWalkguide));
|
||||
});
|
||||
|
||||
test('"stop walkguide" → stopWalkguide', () {
|
||||
fakeStt.simulateUtterance('stop walkguide');
|
||||
expect(dispatchedCommands, contains(VoiceCommandKey.stopWalkguide));
|
||||
});
|
||||
|
||||
test('"call guardian" → callGuardian', () {
|
||||
fakeStt.simulateUtterance('call guardian');
|
||||
expect(dispatchedCommands, contains(VoiceCommandKey.callGuardian));
|
||||
});
|
||||
|
||||
test('"open notifications" → openNotification', () {
|
||||
fakeStt.simulateUtterance('open notifications');
|
||||
expect(dispatchedCommands, contains(VoiceCommandKey.openNotification));
|
||||
});
|
||||
|
||||
test('"send sos" → sendSos', () {
|
||||
fakeStt.simulateUtterance('send sos');
|
||||
expect(dispatchedCommands, contains(VoiceCommandKey.sendSos));
|
||||
});
|
||||
|
||||
test('"where am i" → whereAmI', () {
|
||||
fakeStt.simulateUtterance('where am i');
|
||||
expect(dispatchedCommands, contains(VoiceCommandKey.whereAmI));
|
||||
});
|
||||
|
||||
test('"open activity log" → openActivity', () {
|
||||
fakeStt.simulateUtterance('open activity log');
|
||||
expect(dispatchedCommands, contains(VoiceCommandKey.openActivity));
|
||||
});
|
||||
|
||||
test('"open navigation" → openNavigation', () {
|
||||
fakeStt.simulateUtterance('open navigation');
|
||||
expect(dispatchedCommands, contains(VoiceCommandKey.openNavigation));
|
||||
});
|
||||
|
||||
test('"open settings" → openSettings', () {
|
||||
fakeStt.simulateUtterance('open settings');
|
||||
expect(dispatchedCommands, contains(VoiceCommandKey.openSettings));
|
||||
});
|
||||
|
||||
test('"read all my notifications" → readAllNotif', () {
|
||||
fakeStt.simulateUtterance('read all my notifications');
|
||||
expect(dispatchedCommands, contains(VoiceCommandKey.readAllNotif));
|
||||
});
|
||||
});
|
||||
|
||||
// ── CASE INSENSITIVITY ────────────────────────────────────────────
|
||||
|
||||
group('case insensitive matching', () {
|
||||
test('"START WALKGUIDE" → startWalkguide', () {
|
||||
fakeStt.simulateUtterance('START WALKGUIDE');
|
||||
expect(dispatchedCommands, contains(VoiceCommandKey.startWalkguide));
|
||||
});
|
||||
|
||||
test('"Start Walkguide" → startWalkguide', () {
|
||||
fakeStt.simulateUtterance('Start Walkguide');
|
||||
expect(dispatchedCommands, contains(VoiceCommandKey.startWalkguide));
|
||||
});
|
||||
|
||||
test('"CALL GUARDIAN" → callGuardian', () {
|
||||
fakeStt.simulateUtterance('CALL GUARDIAN');
|
||||
expect(dispatchedCommands, contains(VoiceCommandKey.callGuardian));
|
||||
});
|
||||
|
||||
test('"Send SOS" → sendSos', () {
|
||||
fakeStt.simulateUtterance('Send SOS');
|
||||
expect(dispatchedCommands, contains(VoiceCommandKey.sendSos));
|
||||
});
|
||||
});
|
||||
|
||||
// ── PHRASE CONTAINS MATCH (dari kalimat lebih panjang) ───────────
|
||||
|
||||
group('phrase contains match', () {
|
||||
test('kalimat panjang mengandung "start walkguide" → startWalkguide', () {
|
||||
fakeStt.simulateUtterance('tolong start walkguide sekarang');
|
||||
expect(dispatchedCommands, contains(VoiceCommandKey.startWalkguide));
|
||||
});
|
||||
|
||||
test('kalimat panjang mengandung "send sos" → sendSos', () {
|
||||
fakeStt.simulateUtterance('saya mau send sos ke guardian');
|
||||
expect(dispatchedCommands, contains(VoiceCommandKey.sendSos));
|
||||
});
|
||||
|
||||
test('kalimat panjang mengandung "call guardian" → callGuardian', () {
|
||||
fakeStt.simulateUtterance('bisa tolong call guardian untuk aku');
|
||||
expect(dispatchedCommands, contains(VoiceCommandKey.callGuardian));
|
||||
});
|
||||
});
|
||||
|
||||
// ── NO MATCH ──────────────────────────────────────────────────────
|
||||
|
||||
group('unrecognized input tidak memicu command', () {
|
||||
test('kata random tidak memicu command', () {
|
||||
fakeStt.simulateUtterance('halo apa kabar');
|
||||
expect(dispatchedCommands, isEmpty);
|
||||
});
|
||||
|
||||
test('string kosong tidak memicu command', () {
|
||||
fakeStt.simulateUtterance('');
|
||||
expect(dispatchedCommands, isEmpty);
|
||||
});
|
||||
|
||||
test('kata mirip tapi bukan phrase yang valid tidak match', () {
|
||||
fakeStt.simulateUtterance('starting guide');
|
||||
expect(dispatchedCommands, isEmpty);
|
||||
});
|
||||
|
||||
test('spasi saja tidak memicu command', () {
|
||||
fakeStt.simulateUtterance(' ');
|
||||
expect(dispatchedCommands, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
// ── BUILT-IN TTS ACTIONS ─────────────────────────────────────────
|
||||
|
||||
group('built-in TTS actions', () {
|
||||
test('"repeat" → memanggil tts.repeatLast()', () {
|
||||
fakeStt.simulateUtterance('repeat');
|
||||
expect(fakeTts.lastSpeech, 'repeated');
|
||||
expect(dispatchedCommands, contains(VoiceCommandKey.repeatLast));
|
||||
});
|
||||
|
||||
test('"stop" → memanggil tts.stop()', () {
|
||||
fakeStt.simulateUtterance('stop');
|
||||
expect(fakeTts.stopped, true);
|
||||
expect(dispatchedCommands, contains(VoiceCommandKey.stopTts));
|
||||
});
|
||||
});
|
||||
|
||||
// ── DISABLED COMMAND ─────────────────────────────────────────────
|
||||
|
||||
group('disabled command tidak memicu callback', () {
|
||||
test('command disabled tidak di-dispatch', () {
|
||||
handler.loadCommands([
|
||||
const VoiceCommand(
|
||||
key: VoiceCommandKey.sendSos, phrase: 'send sos', enabled: false),
|
||||
const VoiceCommand(
|
||||
key: VoiceCommandKey.callGuardian,
|
||||
phrase: 'call guardian',
|
||||
enabled: true),
|
||||
]);
|
||||
|
||||
fakeStt.simulateUtterance('send sos');
|
||||
expect(dispatchedCommands, isEmpty);
|
||||
});
|
||||
|
||||
test('command enabled di-dispatch, disabled tidak', () {
|
||||
handler.loadCommands([
|
||||
const VoiceCommand(
|
||||
key: VoiceCommandKey.sendSos, phrase: 'send sos', enabled: false),
|
||||
const VoiceCommand(
|
||||
key: VoiceCommandKey.callGuardian,
|
||||
phrase: 'call guardian',
|
||||
enabled: true),
|
||||
]);
|
||||
|
||||
fakeStt.simulateUtterance('call guardian');
|
||||
expect(dispatchedCommands, contains(VoiceCommandKey.callGuardian));
|
||||
expect(dispatchedCommands, isNot(contains(VoiceCommandKey.sendSos)));
|
||||
});
|
||||
});
|
||||
|
||||
// ── CUSTOM COMMANDS ───────────────────────────────────────────────
|
||||
|
||||
group('loadCommands dengan custom phrase', () {
|
||||
test('custom phrase "mulai jalan" → startWalkguide', () {
|
||||
handler.loadCommands([
|
||||
const VoiceCommand(
|
||||
key: VoiceCommandKey.startWalkguide,
|
||||
phrase: 'mulai jalan',
|
||||
enabled: true),
|
||||
]);
|
||||
fakeStt.simulateUtterance('mulai jalan');
|
||||
expect(dispatchedCommands, contains(VoiceCommandKey.startWalkguide));
|
||||
});
|
||||
|
||||
test('custom phrase "darurat" → sendSos', () {
|
||||
handler.loadCommands([
|
||||
const VoiceCommand(
|
||||
key: VoiceCommandKey.sendSos, phrase: 'darurat', enabled: true),
|
||||
]);
|
||||
fakeStt.simulateUtterance('ini darurat tolong');
|
||||
expect(dispatchedCommands, contains(VoiceCommandKey.sendSos));
|
||||
});
|
||||
});
|
||||
|
||||
// ── FIRST MATCH WINS ─────────────────────────────────────────────
|
||||
|
||||
group('hanya satu command yang dipicu per utterance', () {
|
||||
test('hanya command pertama yang match yang dipicu', () {
|
||||
fakeStt.simulateUtterance('stop walkguide now');
|
||||
// "stop walkguide" match stopWalkguide, tapi "stop" juga ada di list
|
||||
// Urutan di default list: stopWalkguide (index 2) sebelum stopTts (index 13)
|
||||
expect(dispatchedCommands.length, 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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<EditableText>(
|
||||
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<EditableText>(
|
||||
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<EditableText>(
|
||||
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<ElevatedButton>(loginBtn).onPressed, isNotNull);
|
||||
});
|
||||
|
||||
testWidgets('field email menggunakan keyboard type email', (tester) async {
|
||||
await tester.pumpWidget(makeTestable(const _StubLoginScreen()));
|
||||
|
||||
final textField = tester.widget<TextField>(
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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<VoiceCommandEntry> _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<VoiceCommandEntry> 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<VoiceCommandEntry> 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<String> 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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<NotifItem> 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<NotifItem> _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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
619
walkguide-mobile/walkguide_app/test/widget/sos_screen_test.dart
Normal file
619
walkguide-mobile/walkguide_app/test/widget/sos_screen_test.dart
Normal file
@ -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<SosEvent> 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<SosEvent> _events;
|
||||
bool _sending = false;
|
||||
String? _sendError;
|
||||
bool _sendSuccess = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_events = List.from(widget.events);
|
||||
}
|
||||
|
||||
Future<void> _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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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<ElevatedButton>(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<Container>(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<Container>(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<Container>(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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user