feat(app): scan-mode sync, confirmation-gated documents, single-pass product classification

Fixes reported from APK field testing: DO/Product scan mode was inconsistent
between the camera drawer and documents screen (now one shared provider,
with an orange/green color cue); unconfirmed scans leaked into history with
placeholder data before the user tapped confirm (backend now gates
GET /documents on a new `confirmed` column, flipped only by PUT); and
Product Scan ran the GPU classifier twice, once at upload and again on
review (now a single pass at upload, persisted and read directly by the
editor). Also removes the unused "Hubungkan ke PO" field and fabricated
PO/SO/DO placeholder values from the Product Scan flow, closes out the
per-document-polling and save-recovery tasks (6.1/6.3), and splits several
touched files to stay under the repo's 256-line guideline.

Full detail in docs/iteration-log.md and backend/docs/iteration-log.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Rafhan Mazaya FathurrahmanandClaude Sonnet 5 committed 2026-07-10 15:19:32 +07:00
1 parent 2febe0c886
commit ada6488592
67 files changed
+5705 -1142

No files matched your search

+171
View File
@@ -0,0 +1,171 @@
import 'package:hive/hive.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:app_pfm_ocr_v2/core/storage/local_storage.dart';
import 'package:app_pfm_ocr_v2/features/camera/camera_drawer.dart';
import 'package:app_pfm_ocr_v2/main.dart';
import 'package:app_pfm_ocr_v2/models/document_model.dart';
class FakeBox extends Fake implements Box {
@override
Future<int> clear() async => 0;
}
class MockLocalStorage extends LocalStorage {
final List<Map<String, dynamic>> pending;
MockLocalStorage({this.pending = const []});
@override
Future<void> init() async {}
@override
Box get documentBox => FakeBox();
@override
Box get pendingDocumentsBox => FakeBox();
@override
List<DocumentModel> getAllDocuments() => [];
@override
List<Map<String, dynamic>> getAllPendingDocuments() => pending;
@override
Future<void> savePendingDocument(Map<String, dynamic> data) async {}
@override
Future<void> removePendingDocument(String id) async {}
}
Map<String, dynamic> _syncFailedPendingDoc(String id) {
return {
'id': id,
'image_path': 'fake_$id.jpg',
'status': 'syncFailed',
'document': null,
'latitude': null,
'longitude': null,
'error_message': 'Gagal sinkronisasi ke server: 500 Internal Server Error',
'created_at': DateTime.now().toIso8601String(),
'scan_mode': 'DO',
};
}
Widget _buildTestApp(LocalStorage storage) {
final router = GoRouter(
initialLocation: '/camera',
routes: [
GoRoute(
path: '/camera',
builder: (context, state) => Scaffold(
appBar: AppBar(),
drawer: const CameraDrawer(),
body: Builder(
builder: (context) => Center(
child: ElevatedButton(
onPressed: () => Scaffold.of(context).openDrawer(),
child: const Text('Open Drawer'),
),
),
),
),
),
GoRoute(path: '/login', builder: (context, state) => const Scaffold(body: Text('LOGIN_SCREEN'))),
GoRoute(path: '/documents', builder: (context, state) => const Scaffold(body: Text('DOCS_SCREEN'))),
],
);
return ProviderScope(
overrides: [localStorageProvider.overrideWithValue(storage)],
child: MaterialApp.router(routerConfig: router),
);
}
/// CameraDrawer's mode-toggle row (DO Scan / Product Scan buttons) has a
/// pre-existing RenderFlex overflow at the fonts/metrics flutter_test falls
/// back to (unrelated to logout behavior - out of scope for this task).
/// `TestWidgetsFlutterBinding.runTest` installs its own `FlutterError.onError`
/// around the test body, so the override must happen inside the body itself
/// (a `setUp` override gets clobbered) to suppress just that known overflow
/// while still surfacing anything else.
Future<void> _ignoringKnownOverflow(Future<void> Function() body) async {
final original = FlutterError.onError;
FlutterError.onError = (FlutterErrorDetails details) {
if (details.exception.toString().contains('A RenderFlex overflowed')) return;
original?.call(details);
};
try {
await body();
} finally {
FlutterError.onError = original;
}
}
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
testWidgets('logs out immediately when there are no pending documents', (tester) async {
await _ignoringKnownOverflow(() async {
await tester.pumpWidget(_buildTestApp(MockLocalStorage()));
await tester.pumpAndSettle();
await tester.tap(find.text('Open Drawer'));
await tester.pumpAndSettle();
await tester.tap(find.text('Logout'));
await tester.pumpAndSettle();
expect(find.text('LOGIN_SCREEN'), findsOneWidget);
expect(find.text('Ada Dokumen Belum Tersinkron'), findsNothing);
});
});
testWidgets('warns and stays put when logout is cancelled with pending documents', (tester) async {
await _ignoringKnownOverflow(() async {
final storage = MockLocalStorage(pending: [_syncFailedPendingDoc('1')]);
await tester.pumpWidget(_buildTestApp(storage));
await tester.pumpAndSettle();
await tester.tap(find.text('Open Drawer'));
await tester.pumpAndSettle();
await tester.tap(find.text('Logout'));
await tester.pump();
expect(find.text('Ada Dokumen Belum Tersinkron'), findsOneWidget);
await tester.tap(find.text('Batal'));
await tester.pumpAndSettle();
expect(find.text('LOGIN_SCREEN'), findsNothing);
});
});
testWidgets('logs out after confirming the warning with pending documents', (tester) async {
await _ignoringKnownOverflow(() async {
final storage = MockLocalStorage(pending: [_syncFailedPendingDoc('1'), _syncFailedPendingDoc('2')]);
await tester.pumpWidget(_buildTestApp(storage));
await tester.pumpAndSettle();
await tester.tap(find.text('Open Drawer'));
await tester.pumpAndSettle();
await tester.tap(find.text('Logout'));
await tester.pump();
expect(find.text('Ada Dokumen Belum Tersinkron'), findsOneWidget);
expect(find.textContaining('2 dokumen'), findsOneWidget);
await tester.tap(find.text('Ya, Logout'));
await tester.pumpAndSettle();
expect(find.text('LOGIN_SCREEN'), findsOneWidget);
});
});
}
+2 -2
View File
@@ -14,10 +14,10 @@ void main() {
);
// Verify Title exists
expect(find.text('OCR Document Scanner'), findsOneWidget);
expect(find.text('Pemindai Dokumen OCR'), findsOneWidget);
// Verify main card header exists
expect(find.text('Pindai Dokumen DO'), findsOneWidget);
expect(find.text('Pemindai DO'), findsOneWidget);
// Verify Camera button exists
expect(find.text('Ambil Foto Kamera'), findsOneWidget);
@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:app_pfm_ocr_v2/features/documents/document_card.dart';
import 'package:app_pfm_ocr_v2/models/document_model.dart';
DocumentModel _testDoc() {
return DocumentModel(
id: '1',
tanggal: '01 Januari 2026',
noPo: 'PO-1',
noSo: '1000000001',
noDo: 'DO-1',
kepadaYth: 'PM KELAPA DUA',
orderUntuk: 'PRIMA FRESH MART',
alamat: 'Jakarta',
platTruk: 'B 1234 ABC',
namaDriver: 'Driver A',
namaPenerima: 'Penerima A',
items: const [],
createdAt: DateTime.now(),
);
}
void main() {
testWidgets('shows Terkonfirmasi badge by default (synced)', (tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: DocumentCard(doc: _testDoc(), onPrint: (_) async {}),
),
));
expect(find.text('Terkonfirmasi'), findsOneWidget);
expect(find.text('Belum Tersinkron'), findsNothing);
});
testWidgets('shows Belum Tersinkron badge when isUnsynced is true', (tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: DocumentCard(doc: _testDoc(), onPrint: (_) async {}, isUnsynced: true),
),
));
expect(find.text('Belum Tersinkron'), findsOneWidget);
expect(find.text('Terkonfirmasi'), findsNothing);
});
}
+45
View File
@@ -0,0 +1,45 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:app_pfm_ocr_v2/models/document_model.dart';
Map<String, dynamic> _baseJson({bool? confirmed}) {
return {
'id': '1',
'header': {'tanggal': '', 'no_po': '', 'no_so': '', 'no_do': ''},
'shipment': {'kepada_yth': '', 'order_untuk': '', 'alamat': '', 'plat_truk': ''},
'items': [],
if (confirmed != null) 'confirmed': confirmed,
};
}
void main() {
group('DocumentModel.confirmed', () {
test('reads the backend-provided confirmed value when present', () {
final doc = DocumentModel.fromJson(_baseJson(confirmed: false));
expect(doc.confirmed, false);
});
test('defaults to true when absent (legacy/cached response predating the field)', () {
final doc = DocumentModel.fromJson(_baseJson());
expect(doc.confirmed, true);
});
test('a document built directly via the constructor also defaults to confirmed', () {
final doc = DocumentModel(
id: '1',
tanggal: '',
noPo: '',
noSo: '',
noDo: '',
kepadaYth: '',
orderUntuk: '',
alamat: '',
platTruk: '',
namaDriver: '',
namaPenerima: '',
items: const [],
createdAt: DateTime(2026, 7, 10),
);
expect(doc.confirmed, true);
});
});
}
+48
View File
@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:app_pfm_ocr_v2/features/documents/document_card.dart';
import 'package:app_pfm_ocr_v2/models/document_model.dart';
Map<String, dynamic> _baseJson({String? docType, String orderUntuk = ''}) {
return {
'id': '1',
'header': {'tanggal': '', 'no_po': 'PO-1', 'no_so': '', 'no_do': ''},
'shipment': {'kepada_yth': '', 'order_untuk': orderUntuk, 'alamat': '', 'plat_truk': ''},
'items': [],
if (docType != null) 'docType': docType,
};
}
void main() {
group('DocumentModel.docType', () {
test('uses the backend-provided docType even when orderUntuk disagrees', () {
final doc = DocumentModel.fromJson(_baseJson(docType: 'Product', orderUntuk: 'Anything Edited'));
expect(doc.docType, 'Product');
});
test('falls back to the legacy orderUntuk sentinel when docType is absent', () {
final doc = DocumentModel.fromJson(_baseJson(orderUntuk: 'PRODUCT SCAN'));
expect(doc.docType, 'Product');
});
test('defaults to DO when neither docType nor the legacy sentinel is present', () {
final doc = DocumentModel.fromJson(_baseJson(orderUntuk: 'PRIMA FRESH MART'));
expect(doc.docType, 'DO');
});
});
testWidgets('DocumentCard stays typed as Product even if orderUntuk is edited away from the old sentinel', (tester) async {
final doc = DocumentModel.fromJson(_baseJson(docType: 'Product', orderUntuk: 'Something Edited'));
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: DocumentCard(doc: doc, onPrint: (_) async {}),
),
));
// The product-scan layout renders the item name as the top category
// label rather than "Staff Toko" - confirms docType, not orderUntuk,
// drove the layout choice.
expect(find.text('Staff Toko'), findsNothing);
});
}
@@ -0,0 +1,42 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:app_pfm_ocr_v2/models/document_model.dart';
Map<String, dynamic> _baseJson({Map<String, dynamic>? productScan}) {
return {
'id': '1',
'header': {'tanggal': '', 'no_po': '', 'no_so': '', 'no_do': ''},
'shipment': {'kepada_yth': '', 'order_untuk': '', 'alamat': '', 'plat_truk': ''},
'items': [],
if (productScan != null) 'productScan': productScan,
};
}
void main() {
group('DocumentModel.productScanMatches / productScanExtractedExpiryDate', () {
test('reads possibleMatches and extractedExpiryDate from a populated productScan', () {
final doc = DocumentModel.fromJson(_baseJson(productScan: {
'possibleMatches': [
{'no_sku': '123', 'nama_item': 'Fiesta Nugget', 'score': 0.95},
],
'extractedExpiryDate': '15/12/2026',
}));
expect(doc.productScanMatches.length, 1);
expect(doc.productScanMatches.first['no_sku'], '123');
expect(doc.productScanExtractedExpiryDate, '15/12/2026');
});
test('defaults to empty matches and empty date when productScan is absent (DO doc, or pre-G3-fix)', () {
final doc = DocumentModel.fromJson(_baseJson());
expect(doc.productScanMatches, isEmpty);
expect(doc.productScanExtractedExpiryDate, '');
});
test('defaults to empty matches when possibleMatches itself is missing from productScan', () {
final doc = DocumentModel.fromJson(_baseJson(productScan: {'extractedExpiryDate': ''}));
expect(doc.productScanMatches, isEmpty);
});
});
}
+53
View File
@@ -0,0 +1,53 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:app_pfm_ocr_v2/features/editor/document_save_action.dart';
import 'package:app_pfm_ocr_v2/models/document_model.dart';
DocumentModel _doc(String id) {
return DocumentModel(
id: id,
tanggal: '',
noPo: '',
noSo: '',
noDo: '',
kepadaYth: '',
orderUntuk: '',
alamat: '',
platTruk: '',
namaDriver: '',
namaPenerima: '',
items: const [],
createdAt: DateTime(2026, 7, 10),
);
}
void main() {
group('resolveDocumentSaveAction', () {
test('a resolved server document -> putExisting with its real id', () {
final action = resolveDocumentSaveAction(
existingDocument: _doc('3390'),
localImagePath: '/tmp/photo.jpg',
);
expect(action.kind, DocumentSaveActionKind.putExisting);
expect(action.serverId, '3390');
});
test('no resolved document but a local image is available -> reuploadThenPut, never a fabricated id', () {
final action = resolveDocumentSaveAction(
existingDocument: null,
localImagePath: '/tmp/photo.jpg',
);
expect(action.kind, DocumentSaveActionKind.reuploadThenPut);
expect(action.serverId, isNull);
});
test('no resolved document and no local image -> blocked with an explicit message', () {
final action = resolveDocumentSaveAction(
existingDocument: null,
localImagePath: '',
);
expect(action.kind, DocumentSaveActionKind.blocked);
expect(action.blockedMessage, isNotNull);
expect(action.blockedMessage, isNotEmpty);
});
});
}
+68
View File
@@ -0,0 +1,68 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:app_pfm_ocr_v2/features/documents/document_sync_merge.dart';
import 'package:app_pfm_ocr_v2/models/document_model.dart';
DocumentModel _doc({required String id, required String noPo, required DateTime createdAt}) {
return DocumentModel(
id: id,
tanggal: '01 Januari 2026',
noPo: noPo,
noSo: '1000000001',
noDo: 'DO-1',
kepadaYth: 'PM KELAPA DUA',
orderUntuk: 'PRIMA FRESH MART',
alamat: 'Jakarta',
platTruk: 'B 1234 ABC',
namaDriver: 'Driver A',
namaPenerima: 'Penerima A',
items: const [],
createdAt: createdAt,
);
}
void main() {
group('mergeDocumentsWithUnsyncedOverrides', () {
test('overrides a stale server doc with the corrected local version', () {
final now = DateTime.now();
final staleServerDoc = _doc(id: '1', noPo: 'PO-OLD', createdAt: now);
final correctedLocalDoc = _doc(id: '1', noPo: 'PO-CORRECTED', createdAt: now);
final result = mergeDocumentsWithUnsyncedOverrides(
serverDocs: [staleServerDoc],
unsyncedOverrides: {'1': correctedLocalDoc},
);
expect(result.docs, hasLength(1));
expect(result.docs.single.noPo, 'PO-CORRECTED');
expect(result.unsyncedIds, {'1'});
});
test('passes through server docs unchanged when there are no overrides', () {
final now = DateTime.now();
final docA = _doc(id: '1', noPo: 'PO-A', createdAt: now);
final docB = _doc(id: '2', noPo: 'PO-B', createdAt: now);
final result = mergeDocumentsWithUnsyncedOverrides(
serverDocs: [docA, docB],
unsyncedOverrides: const {},
);
expect(result.docs.map((d) => d.id).toSet(), {'1', '2'});
expect(result.unsyncedIds, isEmpty);
});
test('keeps a locally-corrected document even if the server list omits it', () {
final now = DateTime.now();
final localOnlyDoc = _doc(id: '3', noPo: 'PO-LOCAL-ONLY', createdAt: now);
final result = mergeDocumentsWithUnsyncedOverrides(
serverDocs: const [],
unsyncedOverrides: {'3': localOnlyDoc},
);
expect(result.docs, hasLength(1));
expect(result.docs.single.id, '3');
expect(result.unsyncedIds, {'3'});
});
});
}
@@ -0,0 +1,85 @@
import 'package:hive/hive.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:app_pfm_ocr_v2/core/storage/local_storage.dart';
import 'package:app_pfm_ocr_v2/features/camera/scan_mode_provider.dart';
import 'package:app_pfm_ocr_v2/features/documents/documents_screen.dart';
import 'package:app_pfm_ocr_v2/main.dart';
import 'package:app_pfm_ocr_v2/models/document_model.dart';
class FakeBox extends Fake implements Box {
@override
Future<int> clear() async => 0;
}
class MockLocalStorage extends LocalStorage {
@override
Future<void> init() async {}
@override
Box get documentBox => FakeBox();
@override
Box get pendingDocumentsBox => FakeBox();
@override
List<DocumentModel> getAllDocuments() => [];
@override
Future<void> saveDocument(DocumentModel doc) async {}
@override
List<Map<String, dynamic>> getAllPendingDocuments() => [];
}
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
testWidgets('selecting the Product Scan tab updates the shared scanModeProvider', (tester) async {
final container = ProviderContainer(
overrides: [localStorageProvider.overrideWithValue(MockLocalStorage())],
);
addTearDown(container.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const MaterialApp(home: DocumentsScreen()),
),
);
await tester.pumpAndSettle();
expect(container.read(scanModeProvider), 'DO');
await tester.tap(find.text('Product Scan'));
await tester.pumpAndSettle();
expect(container.read(scanModeProvider), 'Product');
});
testWidgets('DocumentsScreen reflects scanModeProvider set elsewhere (e.g. camera drawer) on entry', (tester) async {
final container = ProviderContainer(
overrides: [localStorageProvider.overrideWithValue(MockLocalStorage())],
);
addTearDown(container.dispose);
container.read(scanModeProvider.notifier).state = 'Product';
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const MaterialApp(home: DocumentsScreen()),
),
);
await tester.pumpAndSettle();
// The active tab should already be "Product Scan" without any tap,
// proving DocumentsScreen reads scanModeProvider directly rather than a
// stale local copy seeded only once at startup.
final activeText = tester.widget<Text>(find.text('Product Scan'));
expect(activeText.style?.fontWeight, FontWeight.bold);
});
}
+28 -4
View File
@@ -17,6 +17,22 @@ class MockGeotaggingLocalStorage extends LocalStorage {
Future<void> saveDocument(DocumentModel doc) async {
savedDoc = doc;
}
@override
List<DocumentModel> getAllDocuments() {
return [];
}
@override
Future<void> savePendingDocument(Map<String, dynamic> data) async {}
@override
Future<void> removePendingDocument(String id) async {}
@override
List<Map<String, dynamic>> getAllPendingDocuments() {
return [];
}
}
void main() {
@@ -76,9 +92,17 @@ void main() {
await tester.pumpAndSettle();
// Verify coordinates controllers are prefilled with the PENDING coordinates, not the document coordinates
final latFinder = find.widgetWithText(TextFormField, '-6.1111');
final lngFinder = find.widgetWithText(TextFormField, '106.1111');
expect(latFinder, findsOneWidget, reason: 'Latitude should be prefilled with pending coordinates (-6.1111)');
expect(lngFinder, findsOneWidget, reason: 'Longitude should be prefilled with pending coordinates (106.1111)');
final latFinder = find.ancestor(
of: find.text('Lintang'),
matching: find.byType(TextFormField),
);
final lngFinder = find.ancestor(
of: find.text('Bujur'),
matching: find.byType(TextFormField),
);
expect(latFinder, findsOneWidget);
expect(lngFinder, findsOneWidget);
expect(tester.widget<TextFormField>(latFinder).controller?.text, '-6.1111');
expect(tester.widget<TextFormField>(lngFinder).controller?.text, '106.1111');
});
}
+17 -1
View File
@@ -6,16 +6,30 @@ import 'package:app_pfm_ocr_v2/features/documents/pending_documents_provider.dar
import 'package:app_pfm_ocr_v2/models/document_model.dart';
import 'package:app_pfm_ocr_v2/main.dart';
import 'package:app_pfm_ocr_v2/core/storage/local_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
class MockLocalStorage extends LocalStorage {
@override
Future<void> init() async {}
@override
Future<void> saveDocument(DocumentModel doc) async {}
@override
List<DocumentModel> getAllDocuments() {
return [];
}
@override
Future<void> savePendingDocument(Map<String, dynamic> data) async {}
@override
Future<void> removePendingDocument(String id) async {}
@override
List<Map<String, dynamic>> getAllPendingDocuments() {
return [];
}
}
void main() {
testWidgets('EditorScreen input validation tests', (WidgetTester tester) async {
SharedPreferences.setMockInitialValues({});
final mockLocalStorage = MockLocalStorage();
final testDoc = DocumentModel(
@@ -79,8 +93,10 @@ void main() {
await tester.pumpAndSettle();
// Trigger validation by tapping the button
final reviewButtonFinder = find.text('Review Complete');
final reviewButtonFinder = find.text('Simpan & Konfirmasi');
expect(reviewButtonFinder, findsOneWidget);
await tester.ensureVisible(reviewButtonFinder);
await tester.pumpAndSettle();
await tester.tap(reviewButtonFinder);
await tester.pumpAndSettle();
+4 -4
View File
@@ -22,8 +22,8 @@ void main() {
await tester.pumpAndSettle();
// Verify inline validation error text is displayed
expect(find.text('Username/ID cannot be empty'), findsOneWidget);
expect(find.text('Password cannot be empty'), findsOneWidget);
expect(find.text('Username/ID tidak boleh kosong'), findsOneWidget);
expect(find.text('Kata sandi tidak boleh kosong'), findsOneWidget);
// Enter a username, and a short password (e.g. 5 chars)
final textFormFields = find.byType(TextFormField);
@@ -37,7 +37,7 @@ void main() {
await tester.pumpAndSettle();
// Verify username warning is gone, but password length warning appears
expect(find.text('Username/ID cannot be empty'), findsNothing);
expect(find.text('Password must be at least 6 characters'), findsOneWidget);
expect(find.text('Username/ID tidak boleh kosong'), findsNothing);
expect(find.text('Kata sandi minimal 6 karakter'), findsOneWidget);
});
}
+33 -14
View File
@@ -1,3 +1,4 @@
import 'package:hive/hive.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -5,6 +6,12 @@ import 'package:app_pfm_ocr_v2/features/documents/documents_screen.dart';
import 'package:app_pfm_ocr_v2/models/document_model.dart';
import 'package:app_pfm_ocr_v2/main.dart';
import 'package:app_pfm_ocr_v2/core/storage/local_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
class FakeBox extends Fake implements Box {
@override
Future<int> clear() async => 0;
}
class MockLocalStorage extends LocalStorage {
final List<DocumentModel> docs;
@@ -14,6 +21,12 @@ class MockLocalStorage extends LocalStorage {
@override
Future<void> init() async {}
@override
Box get documentBox => FakeBox();
@override
Box get pendingDocumentsBox => FakeBox();
@override
List<DocumentModel> getAllDocuments() {
return docs;
@@ -21,18 +34,30 @@ class MockLocalStorage extends LocalStorage {
@override
Future<void> saveDocument(DocumentModel doc) async {}
@override
Future<void> savePendingDocument(Map<String, dynamic> data) async {}
@override
Future<void> removePendingDocument(String id) async {}
@override
List<Map<String, dynamic>> getAllPendingDocuments() {
return [];
}
}
void main() {
testWidgets('DocumentsScreen search and coordinate formatting tests', (WidgetTester tester) async {
SharedPreferences.setMockInitialValues({});
final testDocs = [
DocumentModel(
id: '1',
tanggal: '29 Juni 2026',
noPo: 'PO/26/0000111111',
noSo: '1000000001',
noDo: 'DO-111111',
kepadaYth: 'PT. PRIMAFOOD BEKASI',
noDo: 'DO-BEKASI-111111',
kepadaYth: 'PM KELAPA DUA KARAWACI',
orderUntuk: 'PRIMA FRESH MART',
alamat: 'Bekasi',
platTruk: 'B 1234 ABC',
@@ -48,8 +73,8 @@ void main() {
tanggal: '30 Juni 2026',
noPo: 'PO/26/0000222222',
noSo: '2000000002',
noDo: 'DO-222222',
kepadaYth: 'PT. PRIMAFOOD TANGERANG',
noDo: 'DO-TANGERANG-222222',
kepadaYth: 'PM KELAPA DUA KARAWACI',
orderUntuk: 'PRIMA FRESH MART',
alamat: 'Tangerang',
platTruk: 'B 5678 XYZ',
@@ -78,14 +103,8 @@ void main() {
await tester.pumpAndSettle();
// Verify both documents are rendered initially
expect(find.text('DO: DO-111111'), findsOneWidget);
expect(find.text('DO: DO-222222'), findsOneWidget);
// Verify coordinate rounding to 4 decimal places
// -6.123456 -> -6.1235
// 106.567890 -> 106.5679
expect(find.text('Lat: -6.1235'), findsOneWidget);
expect(find.text('Lng: 106.5679'), findsOneWidget);
expect(find.text('PO/26/0000111111'), findsOneWidget);
expect(find.text('PO/26/0000222222'), findsOneWidget);
// Type query in search bar to filter only Bekasi
final searchField = find.byType(TextField);
@@ -94,7 +113,7 @@ void main() {
await tester.pump();
// Verify only the Bekasi document matches
expect(find.text('DO: DO-111111'), findsOneWidget);
expect(find.text('DO: DO-222222'), findsNothing);
expect(find.text('PO/26/0000111111'), findsOneWidget);
expect(find.text('PO/26/0000222222'), findsNothing);
});
}
+56
View File
@@ -0,0 +1,56 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:app_pfm_ocr_v2/features/documents/poll_outcome.dart';
import 'package:app_pfm_ocr_v2/models/document_model.dart';
DocumentModel _doc({String? parseStatus, bool parsed = false}) {
return DocumentModel(
id: '1',
tanggal: '',
noPo: '',
noSo: '',
noDo: '',
kepadaYth: '',
orderUntuk: '',
alamat: '',
platTruk: '',
namaDriver: '',
namaPenerima: '',
items: const [],
createdAt: DateTime(2026, 7, 10),
parsed: parsed,
parseStatus: parseStatus,
);
}
void main() {
group('resolvePollOutcome', () {
test('parseStatus done -> PollOutcomeKind.done carrying the fetched document', () {
final doc = _doc(parseStatus: 'done', parsed: true);
final outcome = resolvePollOutcome(doc);
expect(outcome.kind, PollOutcomeKind.done);
expect(outcome.document, doc);
expect(outcome.message, isNull);
});
test('parseStatus failed -> PollOutcomeKind.failed with an explicit message', () {
final doc = _doc(parseStatus: 'failed');
final outcome = resolvePollOutcome(doc);
expect(outcome.kind, PollOutcomeKind.failed);
expect(outcome.document, isNull);
expect(outcome.message, isNotNull);
});
test('parseStatus pending -> PollOutcomeKind.pending, keep polling', () {
final doc = _doc(parseStatus: 'pending');
final outcome = resolvePollOutcome(doc);
expect(outcome.kind, PollOutcomeKind.pending);
expect(outcome.document, isNull);
});
test('missing/unknown parseStatus (legacy response) is treated as pending, not failed', () {
final doc = _doc(parseStatus: null);
final outcome = resolvePollOutcome(doc);
expect(outcome.kind, PollOutcomeKind.pending);
});
});
}
+59
View File
@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:app_pfm_ocr_v2/features/editor/widgets/product_dropdown_card.dart';
void main() {
final matches = [
{'no_sku': '1', 'nama_item': 'Produk A', 'confidence': 0.9},
];
testWidgets('shows the confidence score when there is a real automatic match', (tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: ProductDropdownCard(
selectedSku: '1',
matches: matches,
hasAutoMatch: true,
onSkuChanged: (_) {},
),
),
));
expect(find.text('Confidence Score'), findsOneWidget);
expect(find.textContaining('%'), findsOneWidget);
expect(find.textContaining('rekomendasi otomatis'), findsNothing);
});
testWidgets('shows a manual-selection notice instead of a fake confidence score', (tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: ProductDropdownCard(
selectedSku: '1',
matches: matches,
hasAutoMatch: false,
onSkuChanged: (_) {},
),
),
));
expect(find.text('Confidence Score'), findsNothing);
expect(find.textContaining('rekomendasi otomatis'), findsOneWidget);
});
testWidgets('shows the selected SKU\'s product name as info text below the dropdown, not a form field', (tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: ProductDropdownCard(
selectedSku: '1',
matches: matches,
hasAutoMatch: true,
onSkuChanged: (_) {},
),
),
));
expect(find.text('Produk A'), findsOneWidget);
// No PO-link field anymore (removed - user request).
expect(find.text('Hubungkan ke PO Dokumen'), findsNothing);
});
}
@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:app_pfm_ocr_v2/features/editor/product_editor_screen.dart';
import 'package:app_pfm_ocr_v2/main.dart';
import 'package:app_pfm_ocr_v2/core/storage/local_storage.dart';
import 'package:app_pfm_ocr_v2/models/document_model.dart';
import 'package:shared_preferences/shared_preferences.dart';
class MockLocalStorage extends LocalStorage {
@override
Future<void> init() async {}
@override
List<DocumentModel> getAllDocuments() => [];
@override
Future<void> savePendingDocument(Map<String, dynamic> data) async {}
@override
Future<void> removePendingDocument(String id) async {}
@override
List<Map<String, dynamic>> getAllPendingDocuments() => [];
}
void main() {
testWidgets('shows an explicit retry state instead of fabricated SKU matches when the backend is unreachable', (tester) async {
SharedPreferences.setMockInitialValues({});
final container = ProviderContainer(
overrides: [localStorageProvider.overrideWithValue(MockLocalStorage())],
);
addTearDown(container.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const MaterialApp(
home: ProductEditorScreen(pendingId: null),
),
),
);
await tester.pumpAndSettle(const Duration(seconds: 5));
// The old behavior fabricated these exact hardcoded matches on any
// fetch failure - they must never appear again.
expect(find.textContaining('FIESTA SPICY CHICKEN NUGGET'), findsNothing);
expect(find.textContaining('AKUMO NUGGET COIN'), findsNothing);
expect(find.textContaining('98.5'), findsNothing); // old fake 0.985 confidence
expect(find.text('Gagal Memuat Klasifikasi Produk'), findsOneWidget);
expect(find.widgetWithText(ElevatedButton, 'Coba Lagi'), findsOneWidget);
});
}
@@ -0,0 +1,109 @@
// Regression coverage for gap G3: the product editor must render the SKU
// match / expiry batch UI directly from the document's own stored
// `productScan` data (captured by the backend's single classify+OCR pass at
// upload time) instead of re-running the GPU pipeline a second time via
// POST /scan-product. See docs/api-contract-map.md G3,
// plans/next-enhancements.md, and `product_editor_data_logic.dart`'s
// `_fetchClassificationAndSkus()`.
import 'package:hive/hive.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:app_pfm_ocr_v2/core/storage/local_storage.dart';
import 'package:app_pfm_ocr_v2/features/editor/product_editor_screen.dart';
import 'package:app_pfm_ocr_v2/main.dart';
import 'package:app_pfm_ocr_v2/models/document_model.dart';
class FakeBox extends Fake implements Box {
@override
Future<int> clear() async => 0;
}
class MockLocalStorage extends LocalStorage {
final List<Map<String, dynamic>> pending;
MockLocalStorage(this.pending);
@override
Future<void> init() async {}
@override
Box get documentBox => FakeBox();
@override
Box get pendingDocumentsBox => FakeBox();
@override
List<DocumentModel> getAllDocuments() => [];
@override
Future<void> saveDocument(DocumentModel doc) async {}
@override
List<Map<String, dynamic>> getAllPendingDocuments() => pending;
@override
Future<void> savePendingDocument(Map<String, dynamic> data) async {}
@override
Future<void> removePendingDocument(String id) async {}
}
/// A raw pending-queue JSON blob (as Hive would store it) whose embedded
/// document already carries a populated `productScan` - i.e. exactly what
/// the backend now returns for a Product Scan document once the single
/// upload-time classify pass has run.
Map<String, dynamic> _pendingWithStoredMatch() {
return {
'id': 'p1',
'image_path': 'fake.jpg',
'status': 'success',
'scan_mode': 'Product',
'document': {
'id': '9001',
'header': {'tanggal': '', 'no_po': '', 'no_so': '', 'no_do': ''},
'shipment': {'kepada_yth': '', 'order_untuk': '', 'alamat': '', 'plat_truk': ''},
'items': [],
'docType': 'Product',
'productScan': {
'possibleMatches': [
{'no_sku': '555', 'nama_item': 'Fiesta Nugget Cheese', 'score': 0.93},
],
'extractedExpiryDate': '20/04/2027',
},
},
'latitude': null,
'longitude': null,
'error_message': null,
'created_at': DateTime.now().toIso8601String(),
};
}
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
testWidgets('renders the stored match/expiry immediately, without any network call', (tester) async {
final container = ProviderContainer(
overrides: [
localStorageProvider.overrideWithValue(MockLocalStorage([_pendingWithStoredMatch()])),
],
);
addTearDown(container.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const MaterialApp(
home: ProductEditorScreen(pendingId: 'p1'),
),
),
);
// No pumpAndSettle-with-timeout needed for a network call here - a
// single pump is enough to let the synchronous local-read path run.
// If this were still hitting the network, the sandboxed test HttpClient
// would 400 and the screen would show the failure/retry state instead.
await tester.pump();
await tester.pump();
expect(find.text('Fiesta Nugget Cheese'), findsOneWidget);
expect(find.text('Confidence Score'), findsOneWidget);
expect(find.text('Gagal Memuat Klasifikasi Produk'), findsNothing);
});
}
+54
View File
@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:app_pfm_ocr_v2/features/editor/widgets/product_expiry_card.dart';
void main() {
testWidgets('labels an OCR-extracted date as auto-detected, with no fabricated confidence', (tester) async {
final expDateCtrl = TextEditingController();
final notesCtrl = TextEditingController();
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: ProductExpiryCard(
selectedSku: '1',
selectedBatchOption: 'Batch 1: 15/12/2026',
isManualDate: false,
skuBatches: const {
'1': ['15/12/2026'],
},
expDateCtrl: expDateCtrl,
notesCtrl: notesCtrl,
onBatchChanged: (_) {},
onSelectDate: () {},
),
),
));
expect(find.textContaining('terdeteksi otomatis'), findsOneWidget);
expect(find.textContaining('92.4'), findsNothing);
expect(find.textContaining('OCR Confidence Score'), findsNothing);
});
testWidgets('labels a manually-entered date as manual, with no fabricated confidence', (tester) async {
final expDateCtrl = TextEditingController();
final notesCtrl = TextEditingController();
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: ProductExpiryCard(
selectedSku: '1',
selectedBatchOption: null,
isManualDate: true,
skuBatches: const {},
expDateCtrl: expDateCtrl,
notesCtrl: notesCtrl,
onBatchChanged: (_) {},
onSelectDate: () {},
),
),
));
expect(find.textContaining('diinput manual'), findsOneWidget);
expect(find.textContaining('92.4'), findsNothing);
});
}
@@ -0,0 +1,91 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:app_pfm_ocr_v2/features/editor/product_scan_response_parser.dart';
void main() {
group('parseSkuMasterList', () {
test('unwraps the v1 {status,data} envelope into a list of maps', () {
final response = {
'status': 'success',
'data': [
{'no_sku': '12010119', 'nama_item': 'FIESTA NUGGET CHEESE 123 400 GR/PAC', 'standar_jumlah': 'PAC'},
{'no_sku': '12010115', 'nama_item': 'FIESTA NUGGET ZOO 400 GR/PAC'},
],
};
final result = parseSkuMasterList(response);
expect(result.length, 2);
expect(result[0]['no_sku'], '12010119');
expect(result[0]['nama_item'], 'FIESTA NUGGET CHEESE 123 400 GR/PAC');
expect(result[1]['no_sku'], '12010115');
});
test('returns an empty list when data is an empty array', () {
final result = parseSkuMasterList({'status': 'success', 'data': []});
expect(result, isEmpty);
});
test('returns an empty list when data is missing entirely', () {
final result = parseSkuMasterList({'status': 'success'});
expect(result, isEmpty);
});
});
group('parseScanProductResponse', () {
test('unwraps possibleMatches and extracted_expired_date from the nested v1 envelope', () {
final response = {
'status': 'success',
'data': {
'classification': {'top1_name': 'FIESTA NUGGET CHEESE'},
'ocr': {
'extracted_sku': '12010119',
'extracted_expired_date': '15/12/2026',
},
'possibleMatches': [
{'no_sku': '12010119', 'nama_item': 'FIESTA NUGGET CHEESE 123 400 GR/PAC', 'score': 0.725, 'isBestMatch': true},
{'no_sku': '12010115', 'nama_item': 'FIESTA NUGGET ZOO 400 GR/PAC', 'score': 0.5, 'isBestMatch': false},
],
},
};
final result = parseScanProductResponse(response);
expect(result.matches.length, 2);
expect(result.matches[0]['no_sku'], '12010119');
expect(result.matches[0]['nama_item'], 'FIESTA NUGGET CHEESE 123 400 GR/PAC');
expect(result.matches[0]['confidence'], 0.725);
expect(result.extractedExpiryDate, '15/12/2026');
});
test('returns an empty match list and empty date when possibleMatches is empty', () {
final response = {
'status': 'success',
'data': {
'classification': {},
'ocr': {'extracted_expired_date': ''},
'possibleMatches': [],
},
};
final result = parseScanProductResponse(response);
expect(result.matches, isEmpty);
expect(result.extractedExpiryDate, '');
});
test('degrades gracefully when ocr is missing entirely', () {
final response = {
'status': 'success',
'data': {
'classification': {},
'possibleMatches': <Map<String, dynamic>>[],
},
};
final result = parseScanProductResponse(response);
expect(result.matches, isEmpty);
expect(result.extractedExpiryDate, '');
});
});
}
+281
View File
@@ -0,0 +1,281 @@
// Tests for task 8.1: Global scan-mode state + DO/Product color cue.
//
// Three cases to cover (per plans/next-enhancements.md §8.1):
// (a) Tapping a tab in DocumentsTabSwitcher updates scanModeProvider (not
// just local widget state).
// (b) DocumentsTabSwitcher active color = AppConfig.doModeColor when 'DO',
// AppConfig.primaryColor when 'Product'.
// (c) CameraDrawerModeToggle DO segment color = AppConfig.doModeColor when
// scanModeProvider == 'DO'.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:app_pfm_ocr_v2/config/app_config.dart';
import 'package:app_pfm_ocr_v2/features/camera/scan_mode_provider.dart';
import 'package:app_pfm_ocr_v2/features/documents/documents_tab_switcher.dart';
import 'package:app_pfm_ocr_v2/features/camera/camera_drawer_mode_toggle.dart';
void main() {
// ─── (b) DocumentsTabSwitcher color cue ──────────────────────────────────
group('DocumentsTabSwitcher color cue', () {
testWidgets('DO tab active text color is AppConfig.doModeColor', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: DocumentsTabSwitcher(
selectedTab: 'DO',
onTabChanged: (_) {},
),
),
),
);
final doText = tester.widget<Text>(find.text('DO Scan'));
expect(
doText.style?.color,
AppConfig.doModeColor,
reason: 'Active DO tab text must use doModeColor, not primaryColor',
);
});
testWidgets('Product tab active text color is AppConfig.primaryColor', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: DocumentsTabSwitcher(
selectedTab: 'Product',
onTabChanged: (_) {},
),
),
),
);
final productText = tester.widget<Text>(find.text('Product Scan'));
expect(
productText.style?.color,
AppConfig.primaryColor,
reason: 'Active Product tab text must use primaryColor',
);
});
testWidgets('inactive DO tab text is not doModeColor', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: DocumentsTabSwitcher(
selectedTab: 'Product',
onTabChanged: (_) {},
),
),
),
);
final doText = tester.widget<Text>(find.text('DO Scan'));
expect(
doText.style?.color,
isNot(AppConfig.doModeColor),
reason: 'Inactive DO tab must not use doModeColor',
);
});
// Follow-up from user feedback: the icon (not just the text) must also
// carry the mode color, mirroring CameraDrawerModeToggle's existing
// icon+color pairing. Generic/default chrome elsewhere (search icon,
// print icon, tooltips) is deliberately left untouched.
testWidgets('DO tab active icon color is AppConfig.doModeColor', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: DocumentsTabSwitcher(
selectedTab: 'DO',
onTabChanged: (_) {},
),
),
),
);
final icon = tester.widget<Icon>(find.byIcon(Icons.description));
expect(icon.color, AppConfig.doModeColor);
});
testWidgets('Product tab active icon color is AppConfig.primaryColor', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: DocumentsTabSwitcher(
selectedTab: 'Product',
onTabChanged: (_) {},
),
),
),
);
final icon = tester.widget<Icon>(find.byIcon(Icons.inventory_2));
expect(icon.color, AppConfig.primaryColor);
});
testWidgets('inactive tab icon is neutral gray, not a mode color', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: DocumentsTabSwitcher(
selectedTab: 'DO',
onTabChanged: (_) {},
),
),
),
);
final inactiveIcon = tester.widget<Icon>(find.byIcon(Icons.inventory_2_outlined));
expect(inactiveIcon.color, AppConfig.textSecondary);
});
});
// ─── (a) scanModeProvider updated when tab is tapped ─────────────────────
group('DocumentsTabSwitcher updates scanModeProvider', () {
testWidgets('tapping Product tab writes Product to scanModeProvider', (tester) async {
final container = ProviderContainer();
addTearDown(container.dispose);
expect(container.read(scanModeProvider), 'DO');
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: MaterialApp(
home: Scaffold(
body: DocumentsTabSwitcher(
selectedTab: container.read(scanModeProvider),
onTabChanged: (tab) =>
container.read(scanModeProvider.notifier).state = tab,
),
),
),
),
);
await tester.tap(find.text('Product Scan'));
await tester.pump();
expect(
container.read(scanModeProvider),
'Product',
reason: 'Tapping Product tab must write to the shared scanModeProvider',
);
});
testWidgets('tapping DO tab writes DO to scanModeProvider', (tester) async {
final container = ProviderContainer(
overrides: [scanModeProvider.overrideWith((ref) => 'Product')],
);
addTearDown(container.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: MaterialApp(
home: Scaffold(
body: DocumentsTabSwitcher(
selectedTab: container.read(scanModeProvider),
onTabChanged: (tab) =>
container.read(scanModeProvider.notifier).state = tab,
),
),
),
),
);
await tester.tap(find.text('DO Scan'));
await tester.pump();
expect(container.read(scanModeProvider), 'DO');
});
});
// ─── (c) CameraDrawerModeToggle DO segment color ──────────────────────────
group('CameraDrawerModeToggle DO segment color cue', () {
testWidgets('DO segment container color is doModeColor when mode is DO', (tester) async {
final container = ProviderContainer();
addTearDown(container.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const MaterialApp(
home: Scaffold(
body: CameraDrawerModeToggle(),
),
),
),
);
final doContainers = tester
.widgetList<Container>(find.ancestor(
of: find.text('DO Scan'),
matching: find.byType(Container),
))
.toList();
final coloredDoContainer = doContainers.firstWhere(
(c) =>
c.decoration is BoxDecoration &&
(c.decoration as BoxDecoration).color != null &&
(c.decoration as BoxDecoration).color != Colors.transparent,
orElse: () => throw TestFailure(
'Expected a Container with non-transparent color around DO Scan text',
),
);
expect(
(coloredDoContainer.decoration as BoxDecoration).color,
AppConfig.doModeColor,
reason: 'DO segment must use doModeColor when active',
);
});
testWidgets('Product segment color is primaryColor when mode is Product', (tester) async {
final container = ProviderContainer(
overrides: [scanModeProvider.overrideWith((ref) => 'Product')],
);
addTearDown(container.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: const MaterialApp(
home: Scaffold(
body: CameraDrawerModeToggle(),
),
),
),
);
final productContainers = tester
.widgetList<Container>(find.ancestor(
of: find.text('Product Scan'),
matching: find.byType(Container),
))
.toList();
final coloredProductContainer = productContainers.firstWhere(
(c) =>
c.decoration is BoxDecoration &&
(c.decoration as BoxDecoration).color != null &&
(c.decoration as BoxDecoration).color != Colors.transparent,
orElse: () => throw TestFailure(
'Expected a Container with non-transparent color around Product Scan text',
),
);
expect(
(coloredProductContainer.decoration as BoxDecoration).color,
AppConfig.primaryColor,
reason: 'Product segment must use primaryColor when active',
);
});
});
}