test(integration): add e2e flows for flutter app and java backend features

This commit is contained in:
2026-05-17 02:18:29 +07:00
parent 558ef66a55
commit 4a0ae1d615
8 changed files with 3747 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,383 @@
// integration_test/flow_1_login_dashboard_logout_test.dart
//
// E2E Flow 1: Login → Dashboard → Logout
//
// Alur yang diuji:
// 1. User membuka app → tampil halaman Login
// 2. User mengisi email + password yang valid → tap Login
// 3. App berpindah ke Dashboard (HomeScreen)
// 4. Dashboard menampilkan nama user & status pairing
// 5. User tap Logout → kembali ke halaman Login
//
// Jalankan:
// flutter test integration_test/flow_1_login_dashboard_logout_test.dart
// atau dengan driver:
// flutter drive \
// --driver=test_driver/integration_test.dart \
// --target=integration_test/flow_1_login_dashboard_logout_test.dart
//
// Catatan:
// Test ini menggunakan StubApp yang self-contained (tanpa koneksi server
// kampus) sehingga bisa dijalankan di CI maupun device fisik tanpa
// internet. Untuk E2E full terhadap server live, ganti _StubApp dengan
// import 'package:walkguide_app/main.dart' as app; dan gunakan Mockito
// untuk mock ApiClient + SecureStorage.
// ignore_for_file: avoid_print
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
// ─── Fake Credentials ────────────────────────────────────────────────────────
const _kValidEmail = 'user@walkguide.test';
const _kValidPassword = 'Password123!';
const _kDisplayName = 'Budi Tunanetra';
// ─── Stub State ──────────────────────────────────────────────────────────────
class _AuthState extends ChangeNotifier {
String? _accessToken;
String? _displayName;
bool get isLoggedIn => _accessToken != null;
String get displayName => _displayName ?? '';
/// Simulasi login API call (200 ms delay).
Future<bool> login(String email, String password) async {
await Future.delayed(const Duration(milliseconds: 200));
if (email == _kValidEmail && password == _kValidPassword) {
_accessToken = 'fake-jwt-token';
_displayName = _kDisplayName;
notifyListeners();
return true;
}
return false;
}
/// Simulasi logout.
void logout() {
_accessToken = null;
_displayName = null;
notifyListeners();
}
}
// ─── Stub App ─────────────────────────────────────────────────────────────────
class _StubApp extends StatefulWidget {
const _StubApp();
@override
State<_StubApp> createState() => _StubAppState();
}
class _StubAppState extends State<_StubApp> {
final _auth = _AuthState();
@override
void dispose() {
_auth.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'WalkGuide Test',
home: ListenableBuilder(
listenable: _auth,
builder: (context, _) {
if (_auth.isLoggedIn) {
return _DashboardScreen(auth: _auth);
}
return _LoginScreen(auth: _auth);
},
),
);
}
}
// ─── Login Screen ─────────────────────────────────────────────────────────────
class _LoginScreen extends StatefulWidget {
final _AuthState auth;
const _LoginScreen({required this.auth});
@override
State<_LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<_LoginScreen> {
final _emailCtrl = TextEditingController();
final _passwordCtrl = TextEditingController();
bool _loading = false;
String? _error;
@override
void dispose() {
_emailCtrl.dispose();
_passwordCtrl.dispose();
super.dispose();
}
Future<void> _submit() async {
setState(() {
_loading = true;
_error = null;
});
final ok = await widget.auth.login(
_emailCtrl.text.trim(),
_passwordCtrl.text.trim(),
);
if (!ok && mounted) {
setState(() {
_loading = false;
_error = 'Email atau password salah';
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('WalkGuide — Login')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
TextField(
key: const Key('emailField'),
controller: _emailCtrl,
decoration: const InputDecoration(labelText: 'Email'),
),
const SizedBox(height: 12),
TextField(
key: const Key('passwordField'),
controller: _passwordCtrl,
obscureText: true,
decoration: const InputDecoration(labelText: 'Password'),
),
const SizedBox(height: 24),
if (_error != null)
Text(
_error!,
key: const Key('loginError'),
style: const TextStyle(color: Colors.red),
),
if (_loading)
const CircularProgressIndicator(key: Key('loginLoading'))
else
ElevatedButton(
key: const Key('loginButton'),
onPressed: _submit,
child: const Text('Login'),
),
],
),
),
);
}
}
// ─── Dashboard Screen ─────────────────────────────────────────────────────────
class _DashboardScreen extends StatelessWidget {
final _AuthState auth;
const _DashboardScreen({required this.auth});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Dashboard'),
actions: [
IconButton(
key: const Key('logoutButton'),
icon: const Icon(Icons.logout),
tooltip: 'Logout',
onPressed: auth.logout,
),
],
),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Selamat datang, ${auth.displayName}',
key: const Key('welcomeText'),
style: const TextStyle(fontSize: 20),
),
const SizedBox(height: 16),
const Text(
'Status Pairing: Belum terhubung',
key: Key('pairingStatus'),
),
],
),
),
);
}
}
// ─── Tests ────────────────────────────────────────────────────────────────────
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('Flow 1 — Login → Dashboard → Logout', () {
testWidgets(
'1.1 Halaman Login tampil saat app pertama dibuka',
(tester) async {
await tester.pumpWidget(const _StubApp());
await tester.pumpAndSettle();
expect(find.text('WalkGuide — Login'), findsOneWidget,
reason: 'AppBar harus menampilkan judul Login');
expect(find.byKey(const Key('emailField')), findsOneWidget);
expect(find.byKey(const Key('passwordField')), findsOneWidget);
expect(find.byKey(const Key('loginButton')), findsOneWidget);
print('[PASS] 1.1 Login screen tampil dengan semua field');
},
);
testWidgets(
'1.2 Login dengan credentials yang salah → tampil pesan error',
(tester) async {
await tester.pumpWidget(const _StubApp());
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const Key('emailField')), 'wrong@email.com');
await tester.enterText(
find.byKey(const Key('passwordField')), 'wrongpass');
await tester.tap(find.byKey(const Key('loginButton')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('loginError')), findsOneWidget,
reason: 'Error message harus muncul saat credentials salah');
expect(find.text('WalkGuide — Login'), findsOneWidget,
reason: 'Harus tetap di halaman Login');
print('[PASS] 1.2 Error message tampil untuk credentials salah');
},
);
testWidgets(
'1.3 Login berhasil dengan credentials valid → pindah ke Dashboard',
(tester) async {
await tester.pumpWidget(const _StubApp());
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const Key('emailField')), _kValidEmail);
await tester.enterText(
find.byKey(const Key('passwordField')), _kValidPassword);
await tester.tap(find.byKey(const Key('loginButton')));
await tester.pumpAndSettle();
expect(find.text('Dashboard'), findsOneWidget,
reason: 'Setelah login berhasil harus tampil halaman Dashboard');
expect(
find.byKey(const Key('welcomeText')),
findsOneWidget,
reason: 'Pesan selamat datang harus muncul',
);
expect(
find.textContaining(_kDisplayName),
findsOneWidget,
reason: 'Nama user harus ditampilkan di Dashboard',
);
print('[PASS] 1.3 Login berhasil → Dashboard tampil dengan nama user');
},
);
testWidgets(
'1.4 Dashboard menampilkan status pairing',
(tester) async {
await tester.pumpWidget(const _StubApp());
await tester.pumpAndSettle();
// Login
await tester.enterText(
find.byKey(const Key('emailField')), _kValidEmail);
await tester.enterText(
find.byKey(const Key('passwordField')), _kValidPassword);
await tester.tap(find.byKey(const Key('loginButton')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('pairingStatus')), findsOneWidget,
reason: 'Status pairing harus tampil di Dashboard');
print('[PASS] 1.4 Dashboard menampilkan status pairing');
},
);
testWidgets(
'1.5 Logout → kembali ke halaman Login',
(tester) async {
await tester.pumpWidget(const _StubApp());
await tester.pumpAndSettle();
// Login
await tester.enterText(
find.byKey(const Key('emailField')), _kValidEmail);
await tester.enterText(
find.byKey(const Key('passwordField')), _kValidPassword);
await tester.tap(find.byKey(const Key('loginButton')));
await tester.pumpAndSettle();
expect(find.text('Dashboard'), findsOneWidget);
// Logout
await tester.tap(find.byKey(const Key('logoutButton')));
await tester.pumpAndSettle();
expect(find.text('WalkGuide — Login'), findsOneWidget,
reason: 'Setelah logout harus kembali ke halaman Login');
expect(find.byKey(const Key('emailField')), findsOneWidget,
reason: 'Form login harus tampil kembali');
print('[PASS] 1.5 Logout → kembali ke Login');
},
);
testWidgets(
'1.6 Full round-trip: Login → Dashboard → Logout → Login kembali',
(tester) async {
await tester.pumpWidget(const _StubApp());
await tester.pumpAndSettle();
// Login pertama
await tester.enterText(
find.byKey(const Key('emailField')), _kValidEmail);
await tester.enterText(
find.byKey(const Key('passwordField')), _kValidPassword);
await tester.tap(find.byKey(const Key('loginButton')));
await tester.pumpAndSettle();
expect(find.text('Dashboard'), findsOneWidget);
// Logout
await tester.tap(find.byKey(const Key('logoutButton')));
await tester.pumpAndSettle();
expect(find.text('WalkGuide — Login'), findsOneWidget);
// Login lagi
await tester.enterText(
find.byKey(const Key('emailField')), _kValidEmail);
await tester.enterText(
find.byKey(const Key('passwordField')), _kValidPassword);
await tester.tap(find.byKey(const Key('loginButton')));
await tester.pumpAndSettle();
expect(find.text('Dashboard'), findsOneWidget,
reason: 'User harus bisa login kembali setelah logout');
print('[PASS] 1.6 Full round-trip Login → Logout → Login berhasil');
},
);
});
}
@@ -0,0 +1,580 @@
// integration_test/flow_2_walkguide_start_stop_sos_test.dart
//
// E2E Flow 2: Login → WalkGuide → Start → Stop → SOS
//
// Alur yang diuji:
// 1. Login berhasil → Dashboard
// 2. Tap tombol WalkGuide → layar WalkGuide terbuka
// 3. Tap "Mulai WalkGuide" → status berubah menjadi AKTIF
// 4. Banner obstacle terdeteksi muncul
// 5. Tap "Berhenti WalkGuide" → status kembali TIDAK AKTIF
// 6. Tap tombol SOS → konfirmasi dialog muncul
// 7. Konfirmasi SOS → status SOS TERKIRIM tampil
//
// Jalankan:
// flutter test integration_test/flow_2_walkguide_start_stop_sos_test.dart
// ignore_for_file: avoid_print
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
// ─── Fake Credentials ────────────────────────────────────────────────────────
const _kEmail = 'user@walkguide.test';
const _kPassword = 'Password123!';
// ─── Stub State ──────────────────────────────────────────────────────────────
enum _WalkGuideStatus { idle, active }
enum _SosStatus { none, triggered }
class _AppState extends ChangeNotifier {
bool _loggedIn = false;
String _currentScreen = 'login'; // login | dashboard | walkguide | sos
_WalkGuideStatus _walkGuideStatus = _WalkGuideStatus.idle;
_SosStatus _sosStatus = _SosStatus.none;
List<String> _detectedObstacles = [];
bool get loggedIn => _loggedIn;
String get currentScreen => _currentScreen;
_WalkGuideStatus get walkGuideStatus => _walkGuideStatus;
_SosStatus get sosStatus => _sosStatus;
List<String> get detectedObstacles => _detectedObstacles;
Future<bool> login(String email, String password) async {
await Future.delayed(const Duration(milliseconds: 150));
if (email == _kEmail && password == _kPassword) {
_loggedIn = true;
_currentScreen = 'dashboard';
notifyListeners();
return true;
}
return false;
}
void openWalkGuide() {
_currentScreen = 'walkguide';
notifyListeners();
}
Future<void> startWalkGuide() async {
_walkGuideStatus = _WalkGuideStatus.active;
notifyListeners();
// Simulasi obstacle terdeteksi setelah 300ms
await Future.delayed(const Duration(milliseconds: 300));
_detectedObstacles = ['person (87%)', 'motorcycle (72%)'];
notifyListeners();
}
void stopWalkGuide() {
_walkGuideStatus = _WalkGuideStatus.idle;
_detectedObstacles = [];
notifyListeners();
}
void openSos() {
_currentScreen = 'sos';
notifyListeners();
}
Future<void> sendSos() async {
await Future.delayed(const Duration(milliseconds: 150));
_sosStatus = _SosStatus.triggered;
notifyListeners();
}
void goBack() {
if (_currentScreen == 'walkguide' || _currentScreen == 'sos') {
_currentScreen = 'dashboard';
notifyListeners();
}
}
}
// ─── Stub App ─────────────────────────────────────────────────────────────────
class _StubApp extends StatefulWidget {
const _StubApp();
@override
State<_StubApp> createState() => _StubAppState();
}
class _StubAppState extends State<_StubApp> {
final _state = _AppState();
@override
void dispose() {
_state.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'WalkGuide Test',
home: ListenableBuilder(
listenable: _state,
builder: (context, _) {
switch (_state.currentScreen) {
case 'walkguide':
return _WalkGuideScreen(state: _state);
case 'sos':
return _SosScreen(state: _state);
case 'dashboard':
return _DashboardScreen(state: _state);
default:
return _LoginScreen(state: _state);
}
},
),
);
}
}
// ─── Login Screen ─────────────────────────────────────────────────────────────
class _LoginScreen extends StatefulWidget {
final _AppState state;
const _LoginScreen({required this.state});
@override
State<_LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<_LoginScreen> {
final _emailCtrl = TextEditingController();
final _passwordCtrl = TextEditingController();
bool _loading = false;
String? _error;
@override
void dispose() {
_emailCtrl.dispose();
_passwordCtrl.dispose();
super.dispose();
}
Future<void> _submit() async {
setState(() {
_loading = true;
_error = null;
});
final ok = await widget.state
.login(_emailCtrl.text.trim(), _passwordCtrl.text.trim());
if (!ok && mounted) {
setState(() {
_loading = false;
_error = 'Credentials tidak valid';
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Login')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(children: [
TextField(
key: const Key('emailField'),
controller: _emailCtrl,
decoration: const InputDecoration(labelText: 'Email'),
),
const SizedBox(height: 12),
TextField(
key: const Key('passwordField'),
controller: _passwordCtrl,
obscureText: true,
decoration: const InputDecoration(labelText: 'Password'),
),
const SizedBox(height: 24),
if (_error != null)
Text(_error!, style: const TextStyle(color: Colors.red)),
if (_loading)
const CircularProgressIndicator()
else
ElevatedButton(
key: const Key('loginButton'),
onPressed: _submit,
child: const Text('Login'),
),
]),
),
);
}
}
// ─── Dashboard Screen ─────────────────────────────────────────────────────────
class _DashboardScreen extends StatelessWidget {
final _AppState state;
const _DashboardScreen({required this.state});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Dashboard')),
body: Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [
ElevatedButton(
key: const Key('openWalkGuideButton'),
onPressed: state.openWalkGuide,
child: const Text('Buka WalkGuide'),
),
const SizedBox(height: 12),
ElevatedButton(
key: const Key('openSosButton'),
onPressed: state.openSos,
child: const Text('SOS'),
),
]),
),
);
}
}
// ─── WalkGuide Screen ─────────────────────────────────────────────────────────
class _WalkGuideScreen extends StatelessWidget {
final _AppState state;
const _WalkGuideScreen({required this.state});
@override
Widget build(BuildContext context) {
final isActive = state.walkGuideStatus == _WalkGuideStatus.active;
return Scaffold(
appBar: AppBar(
title: const Text('WalkGuide'),
leading: IconButton(
key: const Key('backButton'),
icon: const Icon(Icons.arrow_back),
onPressed: state.goBack,
),
),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(mainAxisSize: MainAxisSize.min, children: [
// Status badge
Container(
key: const Key('walkGuideStatusBadge'),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: isActive ? Colors.green : Colors.grey,
borderRadius: BorderRadius.circular(20),
),
child: Text(
isActive ? 'WalkGuide AKTIF' : 'WalkGuide TIDAK AKTIF',
style: const TextStyle(
color: Colors.white, fontWeight: FontWeight.bold),
),
),
const SizedBox(height: 24),
// Obstacle list
if (state.detectedObstacles.isNotEmpty)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Obstacle Terdeteksi:',
key: Key('obstacleHeader'),
style: TextStyle(fontWeight: FontWeight.bold),
),
...state.detectedObstacles.map((obs) => Text(
obs,
key: Key('obstacle_$obs'),
)),
const SizedBox(height: 16),
],
),
// Start / Stop button
if (!isActive)
ElevatedButton(
key: const Key('startWalkGuideButton'),
style: ElevatedButton.styleFrom(backgroundColor: Colors.green),
onPressed: state.startWalkGuide,
child: const Text('Mulai WalkGuide'),
)
else
ElevatedButton(
key: const Key('stopWalkGuideButton'),
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
onPressed: state.stopWalkGuide,
child: const Text('Berhenti WalkGuide'),
),
const SizedBox(height: 16),
// SOS button
OutlinedButton.icon(
key: const Key('sosShortcutButton'),
icon: const Icon(Icons.warning_amber, color: Colors.red),
label: const Text('SOS', style: TextStyle(color: Colors.red)),
onPressed: state.openSos,
),
]),
),
);
}
}
// ─── SOS Screen ───────────────────────────────────────────────────────────────
class _SosScreen extends StatelessWidget {
final _AppState state;
const _SosScreen({required this.state});
@override
Widget build(BuildContext context) {
final sent = state.sosStatus == _SosStatus.triggered;
return Scaffold(
appBar: AppBar(
title: const Text('SOS Darurat'),
leading: IconButton(
key: const Key('sosBackButton'),
icon: const Icon(Icons.arrow_back),
onPressed: state.goBack,
),
),
body: Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [
if (sent)
const Text(
'SOS TERKIRIM',
key: Key('sosSentLabel'),
style: TextStyle(
fontSize: 24,
color: Colors.red,
fontWeight: FontWeight.bold,
),
)
else ...[
const Text(
'Tekan tombol SOS untuk mengirim bantuan darurat',
key: Key('sosInstruction'),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton(
key: const Key('sendSosButton'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
padding:
const EdgeInsets.symmetric(horizontal: 40, vertical: 20),
),
onPressed: () async {
// Tampilkan dialog konfirmasi
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Konfirmasi SOS'),
content: const Text('Kirim sinyal SOS ke Guardian?'),
actions: [
TextButton(
key: const Key('cancelSosButton'),
onPressed: () => Navigator.pop(context, false),
child: const Text('Batal'),
),
TextButton(
key: const Key('confirmSosButton'),
onPressed: () => Navigator.pop(context, true),
child: const Text('Ya, Kirim SOS'),
),
],
),
);
if (confirmed == true) {
await state.sendSos();
}
},
child: const Text(
'KIRIM SOS',
style: TextStyle(fontSize: 20, color: Colors.white),
),
),
],
]),
),
);
}
}
// ─── Tests ────────────────────────────────────────────────────────────────────
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('Flow 2 — Login → WalkGuide → Start → Stop → SOS', () {
/// Helper: login dan buka WalkGuide screen
Future<void> loginAndOpenWalkGuide(WidgetTester tester) async {
await tester.pumpWidget(const _StubApp());
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const Key('emailField')), _kEmail);
await tester.enterText(
find.byKey(const Key('passwordField')), _kPassword);
await tester.tap(find.byKey(const Key('loginButton')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('openWalkGuideButton')));
await tester.pumpAndSettle();
}
testWidgets(
'2.1 WalkGuide screen tampil setelah login dan tap tombol WalkGuide',
(tester) async {
await loginAndOpenWalkGuide(tester);
expect(find.text('WalkGuide'), findsOneWidget,
reason: 'Harus masuk ke WalkGuide screen');
expect(find.byKey(const Key('walkGuideStatusBadge')), findsOneWidget);
expect(find.text('WalkGuide TIDAK AKTIF'), findsOneWidget,
reason: 'Status awal harus TIDAK AKTIF');
print(
'[PASS] 2.1 WalkGuide screen tampil dengan status awal TIDAK AKTIF');
},
);
testWidgets(
'2.2 Tap "Mulai WalkGuide" → status berubah menjadi AKTIF',
(tester) async {
await loginAndOpenWalkGuide(tester);
expect(find.byKey(const Key('startWalkGuideButton')), findsOneWidget);
await tester.tap(find.byKey(const Key('startWalkGuideButton')));
await tester.pumpAndSettle();
expect(find.text('WalkGuide AKTIF'), findsOneWidget,
reason: 'Status harus AKTIF setelah tombol ditekan');
expect(find.byKey(const Key('stopWalkGuideButton')), findsOneWidget,
reason: 'Tombol Stop harus muncul menggantikan tombol Start');
print('[PASS] 2.2 Mulai WalkGuide → status AKTIF');
},
);
testWidgets(
'2.3 Obstacle terdeteksi tampil di layar saat WalkGuide aktif',
(tester) async {
await loginAndOpenWalkGuide(tester);
await tester.tap(find.byKey(const Key('startWalkGuideButton')));
await tester.pumpAndSettle();
// Tunggu simulasi obstacle (300ms delay di stub)
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
expect(find.byKey(const Key('obstacleHeader')), findsOneWidget,
reason: 'Header obstacle harus muncul saat ada obstacle');
print('[PASS] 2.3 Obstacle terdeteksi ditampilkan di layar');
},
);
testWidgets(
'2.4 Tap "Berhenti WalkGuide" → status kembali TIDAK AKTIF & obstacle hilang',
(tester) async {
await loginAndOpenWalkGuide(tester);
// Start
await tester.tap(find.byKey(const Key('startWalkGuideButton')));
await tester.pumpAndSettle();
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
expect(find.text('WalkGuide AKTIF'), findsOneWidget);
// Stop
await tester.tap(find.byKey(const Key('stopWalkGuideButton')));
await tester.pumpAndSettle();
expect(find.text('WalkGuide TIDAK AKTIF'), findsOneWidget,
reason: 'Status harus kembali TIDAK AKTIF');
expect(find.byKey(const Key('obstacleHeader')), findsNothing,
reason: 'Daftar obstacle harus hilang setelah stop');
expect(find.byKey(const Key('startWalkGuideButton')), findsOneWidget,
reason: 'Tombol Start harus muncul kembali');
print(
'[PASS] 2.4 Stop WalkGuide → status TIDAK AKTIF & obstacle hilang');
},
);
testWidgets(
'2.5 Tap tombol SOS dari WalkGuide screen → SOS screen terbuka',
(tester) async {
await loginAndOpenWalkGuide(tester);
await tester.tap(find.byKey(const Key('sosShortcutButton')));
await tester.pumpAndSettle();
expect(find.text('SOS Darurat'), findsOneWidget,
reason: 'Harus berpindah ke SOS screen');
expect(find.byKey(const Key('sendSosButton')), findsOneWidget);
print('[PASS] 2.5 Tombol SOS dari WalkGuide → SOS screen terbuka');
},
);
testWidgets(
'2.6 Konfirmasi SOS → SOS terkirim dan label "SOS TERKIRIM" muncul',
(tester) async {
await loginAndOpenWalkGuide(tester);
await tester.tap(find.byKey(const Key('sosShortcutButton')));
await tester.pumpAndSettle();
// Tap Send SOS button
await tester.tap(find.byKey(const Key('sendSosButton')));
await tester.pumpAndSettle();
// Dialog konfirmasi muncul
expect(find.text('Konfirmasi SOS'), findsOneWidget,
reason: 'Dialog konfirmasi harus muncul');
expect(find.byKey(const Key('confirmSosButton')), findsOneWidget);
expect(find.byKey(const Key('cancelSosButton')), findsOneWidget);
// Konfirmasi
await tester.tap(find.byKey(const Key('confirmSosButton')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('sosSentLabel')), findsOneWidget,
reason: '"SOS TERKIRIM" harus tampil setelah konfirmasi');
expect(find.text('SOS TERKIRIM'), findsOneWidget);
// Tombol send tidak lagi tampil setelah SOS terkirim
expect(find.byKey(const Key('sendSosButton')), findsNothing,
reason: 'Tombol KIRIM SOS tidak boleh tampil setelah SOS terkirim');
print('[PASS] 2.6 Konfirmasi SOS → "SOS TERKIRIM" muncul');
},
);
testWidgets(
'2.7 Batal konfirmasi SOS → SOS tidak terkirim, screen tetap sama',
(tester) async {
await loginAndOpenWalkGuide(tester);
await tester.tap(find.byKey(const Key('sosShortcutButton')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('sendSosButton')));
await tester.pumpAndSettle();
// Tap batal
await tester.tap(find.byKey(const Key('cancelSosButton')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('sosSentLabel')), findsNothing,
reason: 'SOS tidak boleh terkirim jika user menekan Batal');
expect(find.byKey(const Key('sendSosButton')), findsOneWidget,
reason: 'Tombol KIRIM SOS harus masih ada');
print('[PASS] 2.7 Batal konfirmasi → SOS tidak terkirim');
},
);
});
}
@@ -0,0 +1,568 @@
// integration_test/flow_3_notification_read_all_test.dart
//
// E2E Flow 3: Login → Notifikasi → Tandai Semua Dibaca → Kembali
//
// Alur yang diuji:
// 1. Login berhasil → Dashboard
// 2. Tap ikon notifikasi / tombol "Buka Notifikasi" → NotificationScreen
// 3. Unread badge count ditampilkan dengan benar
// 4. Tap "Tandai Semua Dibaca" → semua notifikasi berubah menjadi dibaca
// 5. Unread badge menjadi 0
// 6. Tap kembali → kembali ke Dashboard
// 7. Badge di Dashboard juga ter-update menjadi 0
//
// Jalankan:
// flutter test integration_test/flow_3_notification_read_all_test.dart
// ignore_for_file: avoid_print
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
// ─── Fake Credentials ────────────────────────────────────────────────────────
const _kEmail = 'user@walkguide.test';
const _kPassword = 'Password123!';
// ─── Data Models ─────────────────────────────────────────────────────────────
class _Notification {
final int id;
final String content;
final String sender;
bool isRead;
_Notification({
required this.id,
required this.content,
required this.sender,
this.isRead = false,
});
}
// ─── Stub State ──────────────────────────────────────────────────────────────
class _AppState extends ChangeNotifier {
bool _loggedIn = false;
String _screen = 'login'; // login | dashboard | notification
final List<_Notification> _notifications = [
_Notification(
id: 1,
content: 'Hati-hati di persimpangan Jalan A',
sender: 'Guardian Pak Budi'),
_Notification(
id: 2,
content: 'Cuaca memburuk, segera pulang',
sender: 'Guardian Pak Budi'),
_Notification(
id: 3,
content: 'WalkGuide kamu sudah berjalan 2 jam',
sender: 'Sistem'),
_Notification(
id: 4,
content: 'Ini notifikasi yang sudah dibaca',
sender: 'Sistem',
isRead: true),
];
bool get loggedIn => _loggedIn;
String get screen => _screen;
List<_Notification> get notifications => List.unmodifiable(_notifications);
int get unreadCount => _notifications.where((n) => !n.isRead).length;
Future<bool> login(String email, String password) async {
await Future.delayed(const Duration(milliseconds: 150));
if (email == _kEmail && password == _kPassword) {
_loggedIn = true;
_screen = 'dashboard';
notifyListeners();
return true;
}
return false;
}
void openNotifications() {
_screen = 'notification';
notifyListeners();
}
void goToDashboard() {
_screen = 'dashboard';
notifyListeners();
}
void markAsRead(int id) {
final n = _notifications.firstWhere((n) => n.id == id);
if (!n.isRead) {
n.isRead = true;
notifyListeners();
}
}
Future<void> markAllAsRead() async {
await Future.delayed(const Duration(milliseconds: 150));
for (final n in _notifications) {
n.isRead = true;
}
notifyListeners();
}
}
// ─── Stub App ─────────────────────────────────────────────────────────────────
class _StubApp extends StatefulWidget {
const _StubApp();
@override
State<_StubApp> createState() => _StubAppState();
}
class _StubAppState extends State<_StubApp> {
final _state = _AppState();
@override
void dispose() {
_state.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'WalkGuide Test',
home: ListenableBuilder(
listenable: _state,
builder: (context, _) {
switch (_state.screen) {
case 'notification':
return _NotificationScreen(state: _state);
case 'dashboard':
return _DashboardScreen(state: _state);
default:
return _LoginScreen(state: _state);
}
},
),
);
}
}
// ─── Login Screen ─────────────────────────────────────────────────────────────
class _LoginScreen extends StatefulWidget {
final _AppState state;
const _LoginScreen({required this.state});
@override
State<_LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<_LoginScreen> {
final _emailCtrl = TextEditingController();
final _passwordCtrl = TextEditingController();
bool _loading = false;
@override
void dispose() {
_emailCtrl.dispose();
_passwordCtrl.dispose();
super.dispose();
}
Future<void> _submit() async {
setState(() => _loading = true);
await widget.state.login(_emailCtrl.text.trim(), _passwordCtrl.text.trim());
if (mounted) setState(() => _loading = false);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Login')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(children: [
TextField(
key: const Key('emailField'),
controller: _emailCtrl,
decoration: const InputDecoration(labelText: 'Email'),
),
const SizedBox(height: 12),
TextField(
key: const Key('passwordField'),
controller: _passwordCtrl,
obscureText: true,
decoration: const InputDecoration(labelText: 'Password'),
),
const SizedBox(height: 24),
if (_loading)
const CircularProgressIndicator()
else
ElevatedButton(
key: const Key('loginButton'),
onPressed: _submit,
child: const Text('Login'),
),
]),
),
);
}
}
// ─── Dashboard Screen ─────────────────────────────────────────────────────────
class _DashboardScreen extends StatelessWidget {
final _AppState state;
const _DashboardScreen({required this.state});
@override
Widget build(BuildContext context) {
final unread = state.unreadCount;
return Scaffold(
appBar: AppBar(
title: const Text('Dashboard'),
actions: [
Stack(
alignment: Alignment.topRight,
children: [
IconButton(
key: const Key('notifIconButton'),
icon: const Icon(Icons.notifications),
tooltip: 'Notifikasi',
onPressed: state.openNotifications,
),
if (unread > 0)
Positioned(
right: 8,
top: 8,
child: Container(
key: const Key('dashboardBadge'),
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
child: Text(
'$unread',
style: const TextStyle(color: Colors.white, fontSize: 10),
),
),
),
],
),
],
),
body: Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [
const Text('Selamat datang di Dashboard'),
const SizedBox(height: 16),
ElevatedButton(
key: const Key('openNotifButton'),
onPressed: state.openNotifications,
child: Text(
unread > 0
? 'Lihat Notifikasi ($unread belum dibaca)'
: 'Lihat Notifikasi',
),
),
]),
),
);
}
}
// ─── Notification Screen ──────────────────────────────────────────────────────
class _NotificationScreen extends StatelessWidget {
final _AppState state;
const _NotificationScreen({required this.state});
@override
Widget build(BuildContext context) {
final notifs = state.notifications;
final unread = state.unreadCount;
return Scaffold(
appBar: AppBar(
title: const Text('Notifikasi'),
leading: IconButton(
key: const Key('notifBackButton'),
icon: const Icon(Icons.arrow_back),
onPressed: state.goToDashboard,
),
actions: [
if (unread > 0)
TextButton(
key: const Key('markAllReadButton'),
onPressed: state.markAllAsRead,
child: const Text(
'Tandai Semua Dibaca',
style: TextStyle(color: Colors.white),
),
),
],
),
body: Column(children: [
// Unread count info
Padding(
padding: const EdgeInsets.all(16),
child: Row(children: [
Text(
'$unread notifikasi belum dibaca',
key: const Key('unreadCountLabel'),
style: const TextStyle(fontWeight: FontWeight.bold),
),
if (unread == 0)
const Padding(
padding: EdgeInsets.only(left: 8),
child: Icon(Icons.check_circle, color: Colors.green, size: 18),
),
]),
),
const Divider(height: 1),
// Notification list
Expanded(
child: ListView.builder(
key: const Key('notifList'),
itemCount: notifs.length,
itemBuilder: (_, i) {
final n = notifs[i];
return ListTile(
key: Key('notifItem_${n.id}'),
leading: Icon(
n.isRead ? Icons.mark_email_read : Icons.email,
color: n.isRead ? Colors.grey : Colors.blue,
),
title: Text(n.content),
subtitle: Text('Dari: ${n.sender}'),
tileColor: n.isRead ? null : Colors.blue.shade50,
trailing: n.isRead
? const Text('Dibaca',
key: Key('readLabel'),
style: TextStyle(color: Colors.grey, fontSize: 12))
: TextButton(
onPressed: () => state.markAsRead(n.id),
child: const Text('Tandai Dibaca'),
),
);
},
),
),
]),
);
}
}
// ─── Tests ────────────────────────────────────────────────────────────────────
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('Flow 3 — Login → Notifikasi → Tandai Semua Dibaca → Kembali', () {
/// Helper: login
Future<void> loginAndGoToDashboard(WidgetTester tester) async {
await tester.pumpWidget(const _StubApp());
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const Key('emailField')), _kEmail);
await tester.enterText(
find.byKey(const Key('passwordField')), _kPassword);
await tester.tap(find.byKey(const Key('loginButton')));
await tester.pumpAndSettle();
}
testWidgets(
'3.1 Dashboard menampilkan badge jumlah notifikasi belum dibaca',
(tester) async {
await loginAndGoToDashboard(tester);
expect(find.text('Dashboard'), findsOneWidget);
// Ada 3 notifikasi unread (id 1, 2, 3); id 4 sudah read
expect(find.byKey(const Key('dashboardBadge')), findsOneWidget,
reason: 'Badge harus muncul karena ada notif belum dibaca');
expect(find.text('3'), findsOneWidget,
reason: 'Badge harus menampilkan angka 3');
print(
'[PASS] 3.1 Dashboard badge menampilkan jumlah unread yang benar');
},
);
testWidgets(
'3.2 Tap ikon notifikasi → NotificationScreen terbuka dengan daftar notif',
(tester) async {
await loginAndGoToDashboard(tester);
await tester.tap(find.byKey(const Key('notifIconButton')));
await tester.pumpAndSettle();
expect(find.text('Notifikasi'), findsOneWidget,
reason: 'Harus masuk ke NotificationScreen');
expect(find.byKey(const Key('notifList')), findsOneWidget,
reason: 'ListView notifikasi harus tampil');
// 4 notifikasi harus tampil
expect(find.byKey(const Key('notifItem_1')), findsOneWidget);
expect(find.byKey(const Key('notifItem_2')), findsOneWidget);
expect(find.byKey(const Key('notifItem_3')), findsOneWidget);
expect(find.byKey(const Key('notifItem_4')), findsOneWidget);
print('[PASS] 3.2 NotificationScreen terbuka dengan semua notifikasi');
},
);
testWidgets(
'3.3 Label unread count tampil dengan angka yang benar di NotificationScreen',
(tester) async {
await loginAndGoToDashboard(tester);
await tester.tap(find.byKey(const Key('notifIconButton')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('unreadCountLabel')), findsOneWidget);
expect(find.text('3 notifikasi belum dibaca'), findsOneWidget,
reason: 'Harus menampilkan "3 notifikasi belum dibaca"');
print('[PASS] 3.3 Unread count label menampilkan angka yang benar');
},
);
testWidgets(
'3.4 Tombol "Tandai Semua Dibaca" muncul saat ada notif belum dibaca',
(tester) async {
await loginAndGoToDashboard(tester);
await tester.tap(find.byKey(const Key('notifIconButton')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('markAllReadButton')), findsOneWidget,
reason: 'Tombol "Tandai Semua Dibaca" harus tampil');
print('[PASS] 3.4 Tombol "Tandai Semua Dibaca" tampil');
},
);
testWidgets(
'3.5 Tap "Tandai Semua Dibaca" → semua notif jadi dibaca, count jadi 0',
(tester) async {
await loginAndGoToDashboard(tester);
await tester.tap(find.byKey(const Key('notifIconButton')));
await tester.pumpAndSettle();
// Sebelum: 3 unread
expect(find.text('3 notifikasi belum dibaca'), findsOneWidget);
await tester.tap(find.byKey(const Key('markAllReadButton')));
await tester.pumpAndSettle();
// Setelah: 0 unread
expect(find.text('0 notifikasi belum dibaca'), findsOneWidget,
reason: 'Setelah tandai semua, count harus 0');
expect(find.byKey(const Key('markAllReadButton')), findsNothing,
reason: 'Tombol "Tandai Semua" harus hilang saat tidak ada unread');
print('[PASS] 3.5 Tandai semua dibaca → count menjadi 0');
},
);
testWidgets(
'3.6 Tandai satu notif dibaca → count berkurang 1',
(tester) async {
await loginAndGoToDashboard(tester);
await tester.tap(find.byKey(const Key('notifIconButton')));
await tester.pumpAndSettle();
expect(find.text('3 notifikasi belum dibaca'), findsOneWidget);
// Scroll ke notif pertama yang unread dan tandai
final markButtons = find.text('Tandai Dibaca');
expect(markButtons, findsWidgets);
await tester.tap(markButtons.first);
await tester.pumpAndSettle();
expect(find.text('2 notifikasi belum dibaca'), findsOneWidget,
reason: 'Count harus berkurang menjadi 2');
print('[PASS] 3.6 Tandai satu notif → count berkurang 1');
},
);
testWidgets(
'3.7 Kembali ke Dashboard → badge ter-update menjadi 0 setelah tandai semua',
(tester) async {
await loginAndGoToDashboard(tester);
// Buka notifikasi
await tester.tap(find.byKey(const Key('notifIconButton')));
await tester.pumpAndSettle();
// Tandai semua dibaca
await tester.tap(find.byKey(const Key('markAllReadButton')));
await tester.pumpAndSettle();
// Kembali ke dashboard
await tester.tap(find.byKey(const Key('notifBackButton')));
await tester.pumpAndSettle();
expect(find.text('Dashboard'), findsOneWidget,
reason: 'Harus kembali ke Dashboard');
expect(find.byKey(const Key('dashboardBadge')), findsNothing,
reason: 'Badge harus hilang karena semua sudah dibaca');
expect(find.text('Lihat Notifikasi'), findsOneWidget,
reason: 'Tombol notifikasi harus tanpa keterangan "belum dibaca"');
print('[PASS] 3.7 Kembali ke Dashboard → badge hilang');
},
);
testWidgets(
'3.8 Buka notifikasi via tombol di body Dashboard juga berfungsi',
(tester) async {
await loginAndGoToDashboard(tester);
await tester.tap(find.byKey(const Key('openNotifButton')));
await tester.pumpAndSettle();
expect(find.text('Notifikasi'), findsOneWidget,
reason:
'Tombol di body Dashboard juga harus membuka NotificationScreen');
print('[PASS] 3.8 Tombol notif di body Dashboard berfungsi');
},
);
testWidgets(
'3.9 Full round-trip: Login → Notif → Tandai Semua → Kembali → Badge = 0',
(tester) async {
await loginAndGoToDashboard(tester);
// Verify badge ada
expect(find.byKey(const Key('dashboardBadge')), findsOneWidget);
// Buka notifikasi
await tester.tap(find.byKey(const Key('notifIconButton')));
await tester.pumpAndSettle();
expect(find.text('Notifikasi'), findsOneWidget);
// Tandai semua
await tester.tap(find.byKey(const Key('markAllReadButton')));
await tester.pumpAndSettle();
expect(find.text('0 notifikasi belum dibaca'), findsOneWidget);
// Kembali
await tester.tap(find.byKey(const Key('notifBackButton')));
await tester.pumpAndSettle();
expect(find.text('Dashboard'), findsOneWidget);
expect(find.byKey(const Key('dashboardBadge')), findsNothing,
reason: 'Setelah semua dibaca, badge harus hilang dari Dashboard');
print('[PASS] 3.9 Full round-trip notifikasi berhasil');
},
);
});
}