test: add unit coverage for scanner parsing and reporting utils
This commit is contained in:
parent
9daacaf0d8
commit
8cc58d6a76
6 changed files with 148 additions and 31 deletions
|
|
@ -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: 2–5 uppercase letters followed by
|
|
||||||
// 2–4 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 {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
41
lib/utils/reporting_utils.dart
Normal file
41
lib/utils/reporting_utils.dart
Normal 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';
|
||||||
|
}
|
||||||
13
lib/utils/scanner_utils.dart
Normal file
13
lib/utils/scanner_utils.dart
Normal 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;
|
||||||
|
}
|
||||||
40
test/utils/reporting_utils_test.dart
Normal file
40
test/utils/reporting_utils_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
39
test/utils/scanner_utils_test.dart
Normal file
39
test/utils/scanner_utils_test.dart
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue