import 'package:flutter_test/flutter_test.dart'; import 'package:groci/models/ocr_element.dart'; import 'package:groci/services/receipt_parse_config.dart'; import 'package:groci/services/receipt_parser.dart'; import 'package:groci/services/receipt_row_builder.dart'; import 'package:groci/services/receipt_spatial_parser.dart'; /// Synthetic two-column receipt layout (800×1200 image). const _imageWidth = 800.0; const _imageHeight = 1200.0; const _nameLeft = 40.0; const _priceLeft = 620.0; const _rowHeight = 24.0; const _rowGap = 8.0; OcrElement _word(String text, double row, {bool price = false}) { final y = row * (_rowHeight + _rowGap) + 100; final left = price ? _priceLeft : _nameLeft; final width = text.length * 10.0 + 20; return OcrElement( text: text, left: left, top: y, right: left + width, bottom: y + _rowHeight, ); } OcrLine _line(List elements) { var left = double.infinity; var top = double.infinity; var right = 0.0; var bottom = 0.0; for (final e in elements) { if (e.left < left) left = e.left; if (e.top < top) top = e.top; if (e.right > right) right = e.right; if (e.bottom > bottom) bottom = e.bottom; } return OcrLine( text: elements.map((e) => e.text).join(' '), elements: elements, left: left, top: top, right: right, bottom: bottom, ); } OcrDocument _document( List> rows, { String? flattened, }) { final elements = [for (final row in rows) ...row]; final lines = [for (final row in rows) _line(row)]; return OcrDocument( elements: elements, lines: lines, imageWidth: _imageWidth, imageHeight: _imageHeight, flattenedText: flattened ?? lines.map((l) => l.text).join('\n'), ); } void main() { setUpAll(() async { await ReceiptParser.loadConfig(); }); group('ReceiptRowBuilder', () { test('clusters name and price on the same row by Y position', () { final rows = ReceiptRowBuilder(receiptParseConfig).buildRows( _document([ [_word('MILK', 0), _word('1,50', 0, price: true)], ]), ); expect(rows, hasLength(1)); expect(rows.single.kind.name, 'item'); expect(rows.single.nameText, 'MILK'); expect(rows.single.price, 1.50); }); test('classifies pure quantity row before item logic', () { final rows = ReceiptRowBuilder(receiptParseConfig).buildRows( _document([ [_word('2', 1), _word('X', 1), _word('2,99', 1, price: true)], ], flattened: '2 X 2,99'), ); expect(rows, hasLength(1)); expect(rows.single.kind.name, 'quantityModifier'); expect(rows.single.quantity, 2); expect(rows.single.unitPrice, 2.99); }); test('treats row without price-column token as name-only', () { final rows = ReceiptRowBuilder(receiptParseConfig).buildRows( _document([ [_word('BROCCOLI', 0)], ]), ); expect(rows.single.kind.name, 'nameOnly'); expect(rows.single.nameText, 'BROCCOLI'); }); test('does not merge adjacent ML Kit lines into one row', () { final rows = ReceiptRowBuilder(receiptParseConfig).buildRows( _document([ [_word('BIO', 0), _word('VASTKOKEND', 0), _word('AARD', 0)], [_word('2', 1), _word('X', 1), _word('1,99', 1, price: true)], ]), ); expect(rows, hasLength(2)); expect(rows[0].nameText, contains('BIO')); expect(rows[1].kind.name, 'quantityModifier'); }); }); group('ReceiptSpatialParser lookahead', () { test('merges Jumbo-style name row with following quantity modifier', () { final result = ReceiptSpatialParser.parse( _document([ [_word('AARDBEIEN', 0)], [_word('2', 1), _word('X', 1), _word('3,99', 1, price: true)], [_word('Totaal', 2), _word('7,98', 2, price: true)], ]), ); expect(result.items, hasLength(1)); expect(result.items.single.name, 'AARDBEIEN'); expect(result.items.single.quantity, 2); expect(result.items.single.unitPrice, 3.99); expect(result.items.single.price, 7.98); expect(result.items.single.sourceBounds, isNotNull); }); test('overrides inline price when quantity modifier follows (Kruidvat)', () { final result = ReceiptSpatialParser.parse( _document([ [_word('ZEEPTABLET', 0), _word('NEUTRAL', 0), _word('7,98', 0, price: true)], [_word('2', 1), _word('X', 1), _word('3,99', 1, price: true)], ]), ); expect(result.items, hasLength(1)); expect(result.items.single.quantity, 2); expect(result.items.single.unitPrice, 3.99); expect(result.items.single.price, 7.98); }); test('flags name-only row without following modifier for manual review', () { final result = ReceiptSpatialParser.parse( _document([ [_word('MILK', 0)], [_word('BREAD', 1), _word('2,00', 1, price: true)], ]), ); expect(result.items, hasLength(2)); expect(result.items[0].name, 'MILK'); expect(result.items[0].needsManualReview, isTrue); expect(result.items[1].name, 'BREAD'); expect(result.items[1].price, 2.00); }); test('parses two side-by-side items without cross-assigning prices', () { final result = ReceiptSpatialParser.parse( _document([ [_word('MILK', 0), _word('1,50', 0, price: true)], [_word('BREAD', 1), _word('2,00', 1, price: true)], [_word('Totaal', 2), _word('3,50', 2, price: true)], ]), ); expect(result.items.map((i) => i.name), ['MILK', 'BREAD']); expect(result.items.map((i) => i.price), [1.50, 2.00]); expect(result.validationPassed, isTrue); }); test('fails validation when printed item count does not match', () { final result = ReceiptSpatialParser.parse( _document([ [_word('MILK', 0), _word('1,50', 0, price: true)], [_word('Aantal Artikel: 3', 1)], ]), ); expect(result.expectedItemCount, 3); expect(result.validationPassed, isFalse); }); }); }