test: add unit coverage for scanner parsing and reporting utils

This commit is contained in:
Lukas Müllner 2026-03-04 12:25:21 +01:00
parent 9daacaf0d8
commit 8cc58d6a76
6 changed files with 148 additions and 31 deletions

View file

@ -6,6 +6,7 @@ import 'package:camera/camera.dart';
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart'; import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
import 'services/collection_service.dart'; import 'services/collection_service.dart';
import 'theme/app_colors.dart'; import 'theme/app_colors.dart';
import 'utils/scanner_utils.dart';
/// Screen that uses the camera to scan text (OCR) from a Hot Wheels package /// Screen that uses the camera to scan text (OCR) from a Hot Wheels package
/// and extract the hw_id (e.g. "JKF21"). /// and extract the hw_id (e.g. "JKF21").
@ -41,10 +42,6 @@ class _ScannerScreenState extends State<ScannerScreen> {
bool _autoScanEnabled = false; bool _autoScanEnabled = false;
Timer? _autoScanTimer; Timer? _autoScanTimer;
// Matches typical Hot Wheels model IDs: 25 uppercase letters followed by
// 24 digits, e.g. JKF21, HCV73, GRX33, FYD83.
final _hwIdPattern = RegExp(r'\b([A-Z]{2,5}\d{2,4})\b');
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -117,18 +114,11 @@ class _ScannerScreenState extends State<ScannerScreen> {
final inputImage = InputImage.fromFilePath(xFile.path); final inputImage = InputImage.fromFilePath(xFile.path);
final recognized = await _textRecognizer.processImage(inputImage); final recognized = await _textRecognizer.processImage(inputImage);
// Search all recognized text blocks for something matching the HW ID pattern. final found = extractHwIdFromLines(
String? found; recognized.blocks
for (final block in recognized.blocks) { .expand((block) => block.lines)
for (final line in block.lines) { .map((line) => line.text),
final match = _hwIdPattern.firstMatch(line.text.toUpperCase()); );
if (match != null) {
found = match.group(1);
break;
}
}
if (found != null) break;
}
// Clean up the temp image. // Clean up the temp image.
try { try {

View file

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../main.dart'; import '../main.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import '../utils/reporting_utils.dart';
class MyReportsScreen extends StatefulWidget { class MyReportsScreen extends StatefulWidget {
const MyReportsScreen({super.key}); const MyReportsScreen({super.key});
@ -184,7 +185,7 @@ class _MyReportsScreenState extends State<MyReportsScreen> {
if (createdAt != null) ...[ if (createdAt != null) ...[
const SizedBox(height: 10), const SizedBox(height: 10),
Text( Text(
_formatDateTime(createdAt), formatReportDateTime(createdAt),
style: const TextStyle(fontSize: 12, color: AppColors.textHint), style: const TextStyle(fontSize: 12, color: AppColors.textHint),
), ),
], ],
@ -198,14 +199,6 @@ class _MyReportsScreenState extends State<MyReportsScreen> {
); );
} }
String _formatDateTime(DateTime date) {
final d = date.toLocal();
final mm = d.month.toString().padLeft(2, '0');
final dd = d.day.toString().padLeft(2, '0');
final hh = d.hour.toString().padLeft(2, '0');
final min = d.minute.toString().padLeft(2, '0');
return '$dd.$mm.${d.year} $hh:$min';
}
} }
class _StatusChip extends StatelessWidget { class _StatusChip extends StatelessWidget {
@ -215,12 +208,13 @@ class _StatusChip extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final normalized = status.toLowerCase(); final normalized = normalizeReportStatus(status);
final (label, color) = switch (normalized) { final label = reportStatusLabel(normalized);
'reviewed' => ('Reviewed', Colors.blueGrey), final color = switch (normalized) {
'resolved' => ('Resolved', AppColors.success), ReportStatus.reviewed => Colors.blueGrey,
'dismissed' => ('Dismissed', AppColors.error), ReportStatus.resolved => AppColors.success,
_ => ('Open', AppColors.orange), ReportStatus.dismissed => AppColors.error,
ReportStatus.open => AppColors.orange,
}; };
return Container( return Container(

View file

@ -0,0 +1,41 @@
enum ReportStatus {
open,
reviewed,
resolved,
dismissed,
}
ReportStatus normalizeReportStatus(String? status) {
switch ((status ?? '').toLowerCase()) {
case 'reviewed':
return ReportStatus.reviewed;
case 'resolved':
return ReportStatus.resolved;
case 'dismissed':
return ReportStatus.dismissed;
default:
return ReportStatus.open;
}
}
String reportStatusLabel(ReportStatus status) {
switch (status) {
case ReportStatus.reviewed:
return 'Reviewed';
case ReportStatus.resolved:
return 'Resolved';
case ReportStatus.dismissed:
return 'Dismissed';
case ReportStatus.open:
return 'Open';
}
}
String formatReportDateTime(DateTime date) {
final d = date.toLocal();
final mm = d.month.toString().padLeft(2, '0');
final dd = d.day.toString().padLeft(2, '0');
final hh = d.hour.toString().padLeft(2, '0');
final min = d.minute.toString().padLeft(2, '0');
return '$dd.$mm.${d.year} $hh:$min';
}

View file

@ -0,0 +1,13 @@
String? extractHwIdFromLines(Iterable<String> lines) {
final pattern = RegExp(r'\b([A-Z]{2,5}\d{2,4})\b');
for (final raw in lines) {
final line = raw.toUpperCase();
final match = pattern.firstMatch(line);
if (match != null) {
return match.group(1);
}
}
return null;
}

View file

@ -0,0 +1,40 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:hwhub/utils/reporting_utils.dart';
void main() {
group('normalizeReportStatus', () {
test('maps known statuses', () {
expect(normalizeReportStatus('open'), ReportStatus.open);
expect(normalizeReportStatus('reviewed'), ReportStatus.reviewed);
expect(normalizeReportStatus('resolved'), ReportStatus.resolved);
expect(normalizeReportStatus('dismissed'), ReportStatus.dismissed);
});
test('is case-insensitive and defaults unknown to open', () {
expect(normalizeReportStatus('ReSoLvEd'), ReportStatus.resolved);
expect(normalizeReportStatus('other'), ReportStatus.open);
expect(normalizeReportStatus(null), ReportStatus.open);
});
});
group('reportStatusLabel', () {
test('returns user-facing labels', () {
expect(reportStatusLabel(ReportStatus.open), 'Open');
expect(reportStatusLabel(ReportStatus.reviewed), 'Reviewed');
expect(reportStatusLabel(ReportStatus.resolved), 'Resolved');
expect(reportStatusLabel(ReportStatus.dismissed), 'Dismissed');
});
});
group('formatReportDateTime', () {
test('formats timestamp as dd.mm.yyyy hh:mm', () {
final value = DateTime.utc(2026, 3, 4, 8, 5);
final formatted = formatReportDateTime(value);
final local = value.toLocal();
final expected =
'${local.day.toString().padLeft(2, '0')}.${local.month.toString().padLeft(2, '0')}.${local.year} ${local.hour.toString().padLeft(2, '0')}:${local.minute.toString().padLeft(2, '0')}';
expect(formatted, expected);
});
});
}

View file

@ -0,0 +1,39 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:hwhub/utils/scanner_utils.dart';
void main() {
group('extractHwIdFromLines', () {
test('returns first matching HW ID', () {
final result = extractHwIdFromLines([
'some random text',
'MODEL JKF21 PREMIUM',
'another HCV73',
]);
expect(result, 'JKF21');
});
test('matches lower-case input by normalizing to upper-case', () {
final result = extractHwIdFromLines([
'abc',
'new release grx33',
]);
expect(result, 'GRX33');
});
test('returns null when no pattern is present', () {
final result = extractHwIdFromLines([
'hot wheels',
'no sku here',
]);
expect(result, isNull);
});
test('supports 2 to 5 letters and 2 to 4 digits', () {
expect(extractHwIdFromLines(['AB12']), 'AB12');
expect(extractHwIdFromLines(['ABCDE1234']), 'ABCDE1234');
});
});
}