2026-05-17 18:40:03 +07:00

620 lines
23 KiB
Dart

// 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.withValues(alpha: 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);
});
});
});
}