196 lines
6.0 KiB
Dart
196 lines
6.0 KiB
Dart
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<OcrElement> 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<List<OcrElement>> 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);
|
||
});
|
||
});
|
||
}
|