583 lines
21 KiB
Dart
583 lines
21 KiB
Dart
// test/widget/walk_guide_screen_test.dart
|
|
// ignore_for_file: prefer_const_constructors, prefer_const_literals_to_create_immutables
|
|
//
|
|
// 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.withValues(alpha: 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);
|
|
});
|
|
});
|
|
});
|
|
}
|