import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:nearle_pos/core/services/barcode_service.dart'; /// The scanner is a keyboard, so the only thing separating a scan from the /// cashier typing is timing. These drive the handler with a controlled clock /// rather than real delays, so the boundary is exact. void main() { late List scans; late int manualKeys; late DateTime now; late bool editingText; setUp(() { scans = []; manualKeys = 0; now = DateTime(2026, 7, 31, 10); editingText = false; }); BarcodeService build() => BarcodeService( onScan: scans.add, onManualKey: () => manualKeys++, clock: () => now, isEditingText: () => editingText, ); KeyEvent char(String c) => KeyDownEvent( physicalKey: PhysicalKeyboardKey.keyA, logicalKey: LogicalKeyboardKey.keyA, character: c, timeStamp: Duration.zero, ); KeyEvent enterKey() => const KeyDownEvent( physicalKey: PhysicalKeyboardKey.enter, logicalKey: LogicalKeyboardKey.enter, timeStamp: Duration.zero, ); /// Feeds [text] with a fixed gap between keystrokes, returning which /// keystrokes the service swallowed. List type( BarcodeService service, String text, { required int gapMs, }) { return [ for (final c in text.split('')) ...[ () { now = now.add(Duration(milliseconds: gapMs)); return service.handleKey(char(c)); }(), ], ]; } group('scanner input', () { test('a fast burst terminated by Enter is billed as a scan', () { final service = build(); type(service, '8901234500011', gapMs: 5); now = now.add(const Duration(milliseconds: 5)); final enterConsumed = service.handleKey(enterKey()); expect(scans, ['8901234500011']); expect(enterConsumed, isTrue, reason: 'the scanner terminator must not reach the focused field',); expect(manualKeys, 0); }); test('the burst is swallowed so it cannot also land in a text field', () { final service = build(); final consumed = type(service, '8901234500011', gapMs: 5); // The first keystroke cannot be judged yet — nothing has arrived to // measure a gap against — so exactly one character escapes. Everything // after it is recognised as machine-paced and consumed. expect(consumed.first, isFalse); expect(consumed.skip(1), everyElement(isTrue)); }); test('a scanner that sends no Enter still flushes on idle', () async { final service = build(); type(service, '8901234500011', gapMs: 5); expect(scans, isEmpty, reason: 'nothing has flushed yet'); await Future.delayed(const Duration(milliseconds: 300)); expect(scans, ['8901234500011']); }); }); group('human input', () { test('typing at human speed is never treated as a scan', () { final service = build(); final consumed = type(service, '9876543210', gapMs: 200); now = now.add(const Duration(milliseconds: 200)); final enterConsumed = service.handleKey(enterKey()); expect(scans, isEmpty); expect(consumed, everyElement(isFalse), reason: 'hand-typed characters must reach the field',); expect(enterConsumed, isFalse, reason: 'swallowing Enter would break form submission',); }); test('a fast typist in a text field does not trigger a phantom scan', () { editingText = true; final service = build(); // ~100ms per character is a quick typist and used to clear the bar. type(service, '9876543210', gapMs: 100); now = now.add(const Duration(milliseconds: 100)); final enterConsumed = service.handleKey(enterKey()); expect(scans, isEmpty, reason: 'entering a mobile number must not jump to billing',); expect(enterConsumed, isFalse); }); test('the same speed does count as a scan when no field has focus', () { editingText = false; final service = build(); type(service, '9876543210', gapMs: 100); now = now.add(const Duration(milliseconds: 100)); service.handleKey(enterKey()); expect(scans, ['9876543210']); }); test('a pause mid-burst starts a new entry', () { final service = build(); type(service, '890123', gapMs: 5); now = now.add(const Duration(milliseconds: 500)); // cashier hesitates type(service, '4500011', gapMs: 5); now = now.add(const Duration(milliseconds: 5)); service.handleKey(enterKey()); expect(scans, ['4500011'], reason: 'only the characters after the pause form the code',); }); }); group('rejected input', () { test('a buffer shorter than a barcode is reported as manual input', () { final service = build(); type(service, 'abc', gapMs: 5); now = now.add(const Duration(milliseconds: 5)); final enterConsumed = service.handleKey(enterKey()); expect(scans, isEmpty); expect(manualKeys, 1); expect(enterConsumed, isFalse); }); test('non-alphanumeric characters are ignored entirely', () { final service = build(); now = now.add(const Duration(milliseconds: 5)); expect(service.handleKey(char(r'$')), isFalse); expect(scans, isEmpty); }); }); }