483 lines
13 KiB
Dart
483 lines
13 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:fuel_tax_tracker/services/receipt_parser.dart';
|
|
|
|
void main() {
|
|
group('ReceiptParser', () {
|
|
test('parses a typical gas station receipt', () {
|
|
const receipt = '''
|
|
SHELL OIL 12345
|
|
123 MAIN ST
|
|
PUMP 04
|
|
GALLONS 12.345
|
|
PRICE/GAL \$3.599
|
|
FUEL TOTAL \$44.44
|
|
THANK YOU
|
|
''';
|
|
|
|
final result = ReceiptParser.parse(receipt);
|
|
|
|
expect(result.gallons, 12.345);
|
|
expect(result.pricePerGallon, 3.599);
|
|
expect(result.totalCost, 44.44);
|
|
});
|
|
|
|
test('parses a tabular receipt where labels and values sit on separate lines', () {
|
|
// Mirrors a real Casey's receipt: "Pump / Gallons / Price" is a
|
|
// header row, with the actual values on the next line, and the
|
|
// total is phrased "Total Sale" rather than a bare "Total".
|
|
const receipt = '''
|
|
Casey's
|
|
Store #4021
|
|
420 W. 6TH STREET
|
|
KEARNEY, MO 64060
|
|
Date 08/08/2026
|
|
Time 02:21
|
|
VI
|
|
########8533
|
|
Pump Gallons Price
|
|
07 32.115 \$ 3.759
|
|
Product Amount
|
|
87E10 \$ 120.72
|
|
Total Sale \$ 120.72
|
|
Auth #
|
|
Visa
|
|
Seq # 008299
|
|
98
|
|
''';
|
|
|
|
final result = ReceiptParser.parse(receipt);
|
|
|
|
expect(result.gallons, 32.115);
|
|
expect(result.pricePerGallon, 3.759);
|
|
expect(result.totalCost, 120.72);
|
|
});
|
|
|
|
test('does not confuse SUBTOTAL with TOTAL', () {
|
|
const receipt = '''
|
|
SUBTOTAL 10.00
|
|
TAX 0.50
|
|
TOTAL 10.50
|
|
''';
|
|
|
|
final result = ReceiptParser.parse(receipt);
|
|
|
|
expect(result.totalCost, 10.50);
|
|
});
|
|
|
|
test('derives total when gallons and price per gallon are known', () {
|
|
const receipt = '''
|
|
GALLONS 10.000
|
|
PPG 3.500
|
|
''';
|
|
|
|
final result = ReceiptParser.parse(receipt);
|
|
|
|
expect(result.gallons, 10.0);
|
|
expect(result.pricePerGallon, 3.5);
|
|
expect(result.totalCost, 35.0);
|
|
});
|
|
|
|
test('derives gallons when total and price per gallon are known', () {
|
|
const receipt = '''
|
|
PRICE PER GALLON \$4.000
|
|
TOTAL \$20.00
|
|
''';
|
|
|
|
final result = ReceiptParser.parse(receipt);
|
|
|
|
expect(result.pricePerGallon, 4.0);
|
|
expect(result.totalCost, 20.0);
|
|
expect(result.gallons, 5.0);
|
|
});
|
|
|
|
test('returns nulls when nothing recognizable is present', () {
|
|
const receipt = 'THANK YOU FOR YOUR PURCHASE';
|
|
|
|
final result = ReceiptParser.parse(receipt);
|
|
|
|
expect(result.gallons, isNull);
|
|
expect(result.pricePerGallon, isNull);
|
|
expect(result.totalCost, isNull);
|
|
expect(result.date, isNull);
|
|
});
|
|
});
|
|
|
|
group('ReceiptParser state parsing', () {
|
|
test('detects a state abbreviation directly before a ZIP code', () {
|
|
const receipt = '''
|
|
Casey's
|
|
420 W. 6TH STREET
|
|
KEARNEY, MO 64060
|
|
''';
|
|
|
|
expect(ReceiptParser.parse(receipt).state, 'MO');
|
|
});
|
|
|
|
test('detects a non-Missouri state the same way', () {
|
|
const receipt = '''
|
|
Swift Stop #3
|
|
1809 Hobbs Hwy
|
|
Seminole TX 79360
|
|
''';
|
|
|
|
expect(ReceiptParser.parse(receipt).state, 'TX');
|
|
});
|
|
|
|
test('falls back to a state right after a comma when no ZIP is nearby', () {
|
|
const receipt = '''
|
|
Buc-ee's
|
|
6988 Buc-ee's Blvd
|
|
Leeds, AL
|
|
''';
|
|
|
|
expect(ReceiptParser.parse(receipt).state, 'AL');
|
|
});
|
|
|
|
test('does not mistake "VI" (Visa) for the Virgin Islands', () {
|
|
const receipt = '''
|
|
Casey's
|
|
VI
|
|
########8533
|
|
Pump Gallons Price
|
|
07 32.115 \$ 3.759
|
|
''';
|
|
|
|
expect(ReceiptParser.parse(receipt).state, isNull);
|
|
});
|
|
|
|
test('does not mistake the word "In" for Indiana', () {
|
|
const receipt = '''
|
|
Gallons 10.000
|
|
Price/Gal \$3.399
|
|
Total Fuel \$33.99
|
|
All Taxes Included
|
|
In Fuel Price.
|
|
''';
|
|
|
|
expect(ReceiptParser.parse(receipt).state, isNull);
|
|
});
|
|
|
|
test('returns null when no address-like state pattern is present', () {
|
|
expect(ReceiptParser.parse('THANK YOU FOR YOUR PURCHASE').state, isNull);
|
|
});
|
|
});
|
|
|
|
group('ReceiptParser date parsing', () {
|
|
test('parses "Date: M/D/YYYY" + "Time: H:MM:SS AM/PM" on separate lines', () {
|
|
const receipt = '''
|
|
Date: 8/22/2024
|
|
Time: 10:11:00 AM
|
|
Gallons 10.000
|
|
''';
|
|
|
|
final result = ReceiptParser.parse(receipt);
|
|
|
|
expect(result.date, DateTime(2024, 8, 22, 10, 11));
|
|
});
|
|
|
|
test('parses "DATE M/D/YY H:MM" on the same line', () {
|
|
const receipt = '''
|
|
DATE 3/26/22 18:12
|
|
PUMP# 06
|
|
''';
|
|
|
|
final result = ReceiptParser.parse(receipt);
|
|
|
|
expect(result.date, DateTime(2022, 3, 26, 18, 12));
|
|
});
|
|
|
|
test('parses a 2-digit year and a no-space AM/PM time', () {
|
|
const receipt = '''
|
|
Date 01/23/20
|
|
Time 02:11PM
|
|
''';
|
|
|
|
final result = ReceiptParser.parse(receipt);
|
|
|
|
expect(result.date, DateTime(2020, 1, 23, 14, 11));
|
|
});
|
|
|
|
test('12 AM and 12 PM convert correctly (midnight/noon edge case)', () {
|
|
expect(ReceiptParser.parse('Date 1/1/24 12:00 AM').date, DateTime(2024, 1, 1, 0, 0));
|
|
expect(ReceiptParser.parse('Date 1/1/24 12:30 PM').date, DateTime(2024, 1, 1, 12, 30));
|
|
});
|
|
|
|
test('falls back to midnight when a date is found but no time is nearby', () {
|
|
// Comfortably more than the 40-character "nearby" search window
|
|
// between the date and the unrelated text before a time appears
|
|
// much later, regardless of how this string literal is indented.
|
|
final filler = 'x' * 100;
|
|
final receipt = 'Date 12/14/16\n$filler\nTime 5:11 PM';
|
|
|
|
final result = ReceiptParser.parse(receipt);
|
|
|
|
// Too far from the date to count as "nearby", so this should be
|
|
// date-only at midnight — still better than no date at all, and the
|
|
// confirm screen lets the user fix the time manually either way.
|
|
expect(result.date, isNotNull);
|
|
expect(result.date!.year, 2016);
|
|
expect(result.date!.month, 12);
|
|
expect(result.date!.day, 14);
|
|
expect(result.date!.hour, 0);
|
|
expect(result.date!.minute, 0);
|
|
});
|
|
|
|
test('returns null when no date-like pattern is present', () {
|
|
final result = ReceiptParser.parse('GALLONS 10.000\nPPG 3.500');
|
|
|
|
expect(result.date, isNull);
|
|
});
|
|
});
|
|
|
|
// Transcribed from a batch of real photographed receipts across different
|
|
// gas station chains, to catch label/layout variations the handful of
|
|
// hand-written cases above don't happen to cover.
|
|
group('ReceiptParser against real receipt layouts', () {
|
|
test('Break Time — labels adjacent to values on the same line', () {
|
|
const receipt = '''
|
|
Date: 8/22/2024
|
|
Time: 10:11:00 AM
|
|
OXY87
|
|
Pump Number 22
|
|
Gallons 10.000
|
|
Price/Gal \$3.399
|
|
Total Fuel \$33.99
|
|
Total Sale \$33.99
|
|
Visa \$33.99
|
|
All Taxes Included
|
|
In Fuel Price.
|
|
''';
|
|
|
|
final result = ReceiptParser.parse(receipt);
|
|
|
|
expect(result.gallons, 10.000);
|
|
expect(result.pricePerGallon, 3.399);
|
|
expect(result.totalCost, 33.99);
|
|
});
|
|
|
|
test('Break Time — tabular Pump/Gallons/Price header row', () {
|
|
const receipt = '''
|
|
Member# 111795637651
|
|
Invoice# 11733
|
|
Date: 03/23/22
|
|
Time: 10:05
|
|
Auth# 020413
|
|
VI Acct #
|
|
************0344
|
|
Pump Gallons Price
|
|
14 5.116 \$ 5.499
|
|
Product Amount
|
|
Regular \$ 28.13
|
|
Total Sale \$ 28.13
|
|
SALE- Contactless
|
|
Approved
|
|
''';
|
|
|
|
final result = ReceiptParser.parse(receipt);
|
|
|
|
expect(result.gallons, 5.116);
|
|
expect(result.pricePerGallon, 5.499);
|
|
expect(result.totalCost, 28.13);
|
|
});
|
|
|
|
test('Buc-ee\'s — abbreviated "PRICE/G" label', () {
|
|
const receipt = '''
|
|
BUC-EE'S
|
|
6988 Buc-ee's Blvd
|
|
Leeds AL
|
|
PUMP No. 63
|
|
GALLONS 9.999
|
|
PRICE/G \$2.499
|
|
TOTAL FUEL \$24.99
|
|
Regular
|
|
TOTAL SALE \$24.99
|
|
Visa \$24.99
|
|
''';
|
|
|
|
final result = ReceiptParser.parse(receipt);
|
|
|
|
expect(result.gallons, 9.999);
|
|
expect(result.pricePerGallon, 2.499);
|
|
expect(result.totalCost, 24.99);
|
|
});
|
|
|
|
test('Costco — tabular header row, "Total Sale" phrasing', () {
|
|
const receipt = '''
|
|
Costco #01448
|
|
1524 Beasie RD
|
|
Murfreesboro TN
|
|
Date: 01/03/24
|
|
Time: 19:31
|
|
Pump Gallons Price
|
|
11 12.598 \$ 2.399
|
|
Product Amount
|
|
Regular \$ 30.22
|
|
Total Sale \$ 30.22
|
|
SALE- Contactless
|
|
''';
|
|
|
|
final result = ReceiptParser.parse(receipt);
|
|
|
|
expect(result.gallons, 12.598);
|
|
expect(result.pricePerGallon, 2.399);
|
|
expect(result.totalCost, 30.22);
|
|
});
|
|
|
|
test('Murphy USA — "QTY(GAL)" label instead of "GALLONS"', () {
|
|
const receipt = '''
|
|
PUMP: 8
|
|
PROD: UNLEAD
|
|
PRICE/GAL: \$1.799
|
|
NET/GAL: \$1.799
|
|
QTY(GAL): 13.164
|
|
FUEL TOTAL: \$23.68
|
|
NET TOTAL: \$23.68
|
|
''';
|
|
|
|
final result = ReceiptParser.parse(receipt);
|
|
|
|
expect(result.gallons, 13.164);
|
|
expect(result.pricePerGallon, 1.799);
|
|
expect(result.totalCost, 23.68);
|
|
});
|
|
|
|
test('Phillips 66 — "PRICE/G" label and "FUEL SALE" as the total', () {
|
|
const receipt = '''
|
|
WELCOME
|
|
LAWNSIDE PHILLIPS 66
|
|
355 Warrington Ave
|
|
Lawnside NJ 08045
|
|
DATE 3/26/22 18:12
|
|
TRAN# 060757
|
|
PUMP# 06
|
|
SERVICE LEVEL: FULL
|
|
PRODUCT: REGULAR
|
|
GALLONS 3.642
|
|
PRICE/G \$4.119
|
|
FUEL SALE \$15.00
|
|
CREDIT \$15.00
|
|
''';
|
|
|
|
final result = ReceiptParser.parse(receipt);
|
|
|
|
expect(result.gallons, 3.642);
|
|
expect(result.pricePerGallon, 4.119);
|
|
expect(result.totalCost, 15.00);
|
|
});
|
|
|
|
test('Phillips 66 — second "FUEL SALE" example', () {
|
|
const receipt = '''
|
|
WELCOME
|
|
SWIFT STOP #3
|
|
1809 HOBBS HWY
|
|
SEMINOLE TX 79360
|
|
DATE 5/9/23 11:16
|
|
TRAN#9103742
|
|
PUMP# 10
|
|
SERVICE LEVEL: SELF
|
|
PRODUCT: DIESEL 2
|
|
GALLONS: 29.854
|
|
PRICE/G: \$3.499
|
|
FUEL SALE \$181.66
|
|
CREDIT \$181.66
|
|
''';
|
|
|
|
final result = ReceiptParser.parse(receipt);
|
|
|
|
expect(result.gallons, 29.854);
|
|
expect(result.pricePerGallon, 3.499);
|
|
expect(result.totalCost, 181.66);
|
|
});
|
|
|
|
test('Phillips 66 / Murphy-style — "QTY(GAL)" plus "FUEL TOTAL"', () {
|
|
const receipt = '''
|
|
Verified by PIN
|
|
PIN USED
|
|
PUMP: 5
|
|
PROD: UNLEAD
|
|
PRICE/GAL: \$3.959
|
|
NET/GAL: \$3.959
|
|
QTY(GAL): 11.068
|
|
FUEL TOTAL: \$43.82
|
|
NET TOTAL: \$43.82
|
|
GET REWARDED!
|
|
''';
|
|
|
|
final result = ReceiptParser.parse(receipt);
|
|
|
|
expect(result.gallons, 11.068);
|
|
expect(result.pricePerGallon, 3.959);
|
|
expect(result.totalCost, 43.82);
|
|
});
|
|
|
|
test('QuikTrip — tabular header row, total not printed on receipt (derived)', () {
|
|
const receipt = '''
|
|
QUIKTRIP #01012
|
|
383 George Liles PWK
|
|
Concord, NC
|
|
Invoice # 0000000
|
|
Date 01/23/20
|
|
Time 02:11PM
|
|
Auth # 03581I
|
|
Acct #
|
|
************1821
|
|
Pump Gallons Price
|
|
14 10.496 \$2.359
|
|
''';
|
|
|
|
final result = ReceiptParser.parse(receipt);
|
|
|
|
expect(result.gallons, 10.496);
|
|
expect(result.pricePerGallon, 2.359);
|
|
// Not printed within the visible receipt text — derived from
|
|
// gallons * price rather than found via a label.
|
|
expect(result.totalCost, closeTo(10.496 * 2.359, 0.01));
|
|
});
|
|
|
|
test('Sinclair — unit letter attached with no space, and a negative '
|
|
'per-gallon discount line that must not be mistaken for gallons', () {
|
|
const receipt = '''
|
|
PUMP# 3
|
|
UNLEADED CR 17.364G
|
|
PRICE/GAL \$2.649
|
|
DISCOUNTS BEFORE
|
|
FUELING
|
|
Debit Di/GAL \$-0.100
|
|
FUEL TOTAL \$46.00
|
|
DEBIT \$46.00
|
|
''';
|
|
|
|
final result = ReceiptParser.parse(receipt);
|
|
|
|
expect(result.gallons, 17.364);
|
|
expect(result.pricePerGallon, 2.649);
|
|
expect(result.totalCost, 46.00);
|
|
});
|
|
|
|
test('Sinclair — PPG and GALLONS/FUEL TOTAL both split across header/data rows', () {
|
|
const receipt = '''
|
|
SINCLAIR
|
|
5905 NW 72ND ST
|
|
KANSAS CITY MO
|
|
Date 12/14/16
|
|
Time 5:11 PM
|
|
Auth 068113
|
|
VISA
|
|
Pump Product PPG
|
|
05 UNLD \$1.999
|
|
Gallons Fuel Total
|
|
9.505 \$19.00
|
|
Thank you
|
|
''';
|
|
|
|
final result = ReceiptParser.parse(receipt);
|
|
|
|
expect(result.gallons, 9.505);
|
|
expect(result.pricePerGallon, 1.999);
|
|
// "Fuel Total" is split from its value by a newline in this layout,
|
|
// so it isn't label-matched — derived from gallons * price instead,
|
|
// landing within a cent of the printed $19.00.
|
|
expect(result.totalCost, closeTo(19.00, 0.01));
|
|
});
|
|
});
|
|
}
|