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). /// /// Y increases downward; name column ~left 40–420, price column ~right 620–760. 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, ); } OcrDocument _document(List elements, {String? flattened}) { return OcrDocument( elements: elements, imageWidth: _imageWidth, imageHeight: _imageHeight, flattenedText: flattened ?? elements.map((e) => e.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', 0), _word('X', 0), _word('2,99', 0, 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'); }); }); 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', 1), _word('Artikel:', 1), _word('3', 1, price: true), ]), ); expect(result.expectedItemCount, 3); expect(result.validationPassed, isFalse); }); }); }