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:
1 parent
2febe0c886
commit
ada6488592
67 files changed
+5705
-1142
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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, '');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user