Files
receipity/test/receipt_spatial_parser_test.dart
T
2026-09-02 13:38:47 +02:00

196 lines
6.0 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});
}