From 305c2151dde0bf34c7c2ff93cc1e6813f51a8330 Mon Sep 17 00:00:00 2001 From: Developer Date: Sun, 30 Aug 2026 08:50:01 -0400 Subject: [PATCH] fix(garage): establish data-driven 4-tier motorcycle image hierarchy with isolated fallback --- .../garage/motorcycle_image_service.dart | 203 +++++++++++++----- test/maintenance_v102_test.dart | 2 +- ...motorcycle_image_selection_audit_test.dart | 182 ++++++++++++++++ 3 files changed, 331 insertions(+), 56 deletions(-) create mode 100644 test/motorcycle_image_selection_audit_test.dart diff --git a/lib/services/garage/motorcycle_image_service.dart b/lib/services/garage/motorcycle_image_service.dart index 046ac4f..abf8aed 100644 --- a/lib/services/garage/motorcycle_image_service.dart +++ b/lib/services/garage/motorcycle_image_service.dart @@ -19,92 +19,185 @@ class MotorcycleImageReference { bool get hasImage => assetPath != null || networkUrl != null || localFilePath != null; } +/// Metadata descriptor for registered motorcycle reference images +class MotorcycleImageDescriptor { + final String make; + final String model; + final int? exactYear; + final int? minYear; + final int? maxYear; + final List modelAliases; + final String assetPath; + final String caption; + final String attribution; + final bool isPhoto; + + const MotorcycleImageDescriptor({ + required this.make, + required this.model, + this.exactYear, + this.minYear, + this.maxYear, + this.modelAliases = const [], + required this.assetPath, + required this.caption, + this.attribution = 'Factory Technical Reference', + this.isPhoto = true, + }); + + /// Normalizes vehicle names for invariant comparison + static String normalize(String input) { + return input.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), ''); + } + + /// Evaluates whether this descriptor matches the specified make, model, and year + bool matches(String targetMake, String targetModel, int? targetYear) { + final normTargetMake = normalize(targetMake); + final normMake = normalize(make); + if (normMake != normTargetMake) return false; + + final normTargetModel = normalize(targetModel); + final normModel = normalize(model); + final isModelMatch = normModel == normTargetModel || + modelAliases.any((alias) => normalize(alias) == normTargetModel); + + if (!isModelMatch) return false; + + // Exact year check + if (exactYear != null) { + return targetYear == exactYear; + } + + // Generation / Year range check + if (minYear != null || maxYear != null) { + if (targetYear == null) return false; + if (minYear != null && targetYear < minYear!) return false; + if (maxYear != null && targetYear > maxYear!) return false; + return true; + } + + return true; + } + + MotorcycleImageReference toReference() { + return MotorcycleImageReference( + assetPath: assetPath, + caption: caption, + attribution: attribution, + isPhoto: isPhoto, + ); + } +} + /// Central extensible repository providing authentic motorcycle reference images class MotorcycleImageService { static const MotorcycleImageService _instance = MotorcycleImageService._internal(); factory MotorcycleImageService() => _instance; const MotorcycleImageService._internal(); - /// Known registry mapping (Make + Model + Optional Year) to curated reference images - static final Map _registry = { - // 2001 Yamaha YZF-R6 (User's field test vehicle) - 'yamaha_yzf-r6_2001': const MotorcycleImageReference( + /// List of verified physical photographic assets bundled in the APK + static const List bundledPhotographicAssets = [ + 'assets/images/motorcycles/yamaha_yzf_r6_2001.png', + ]; + + /// Default registry of curated motorcycle reference descriptors + static final List _defaultDescriptors = [ + // Tier 1 & 2: 1st Gen Yamaha YZF-R6 (1999-2002 5EB) including 2001 field test vehicle + const MotorcycleImageDescriptor( + make: 'Yamaha', + model: 'YZF-R6', + minYear: 1999, + maxYear: 2002, + modelAliases: ['R6', 'YZFR6', 'YZF R6'], assetPath: 'assets/images/motorcycles/yamaha_yzf_r6_2001.png', caption: '2001 Yamaha YZF-R6 (5EB)', attribution: 'Factory Supersport Reference', isPhoto: true, ), - 'yamaha_r6_2001': const MotorcycleImageReference( - assetPath: 'assets/images/motorcycles/yamaha_yzf_r6_2001.png', - caption: '2001 Yamaha YZF-R6 (5EB)', - attribution: 'Factory Supersport Reference', - isPhoto: true, - ), - 'yamaha_yzf-r6': const MotorcycleImageReference( - assetPath: 'assets/images/motorcycles/yamaha_yzf_r6_2001.png', - caption: 'Yamaha YZF-R6 Supersport', - attribution: 'Factory Technical Reference', - isPhoto: true, - ), - 'yamaha_r6': const MotorcycleImageReference( - assetPath: 'assets/images/motorcycles/yamaha_yzf_r6_2001.png', - caption: 'Yamaha YZF-R6 Supersport', - attribution: 'Factory Technical Reference', - isPhoto: true, - ), - 'yamaha_yzf-r1': const MotorcycleImageReference( - assetPath: 'assets/images/motorcycles/yamaha_r1.png', - caption: 'Yamaha YZF-R1 Superbike', - attribution: 'Crossplane Superbike Reference', - isPhoto: true, - ), - 'yamaha_mt-09': const MotorcycleImageReference( - assetPath: 'assets/images/motorcycles/yamaha_mt09.png', - caption: 'Yamaha MT-09 Hyper Naked', - attribution: 'CP3 Triple Reference', - isPhoto: true, - ), - }; + ]; + + static final List _customDescriptors = []; + + /// Registers a custom image descriptor (for extensions/plugins/tests) + static void registerDescriptor(MotorcycleImageDescriptor descriptor) { + _customDescriptors.add(descriptor); + } + + /// Clears custom descriptors (resets to default registry) + static void resetRegistry() { + _customDescriptors.clear(); + } + + /// Returns all active descriptors in evaluation order (custom first, then defaults) + List get allDescriptors => [ + ..._customDescriptors, + ..._defaultDescriptors, + ]; /// Resolves the best available photographic image or reference for a motorcycle + /// + /// Matching Hierarchy: + /// 1. Exact Year Match (Make + Model + Exact Year) + /// 2. Generation / Year-Range Match (Make + Model + Generation Range) + /// 3. Make + Model Match (Ageless / General Model Reference) + /// 4. Dynamic Technical Schematic Fallback (Accurate make/model/year vector card) MotorcycleImageReference getReferenceImage({ required String? make, required String? model, int? year, }) { - if (make == null || model == null) { - return const MotorcycleImageReference(caption: 'Generic Motorcycle Reference'); + if (make == null || model == null || make.trim().isEmpty || model.trim().isEmpty) { + return const MotorcycleImageReference( + caption: 'Motorcycle Technical Reference', + attribution: 'Schematic Vector', + isPhoto: false, + ); } - final cleanMake = make.trim().toLowerCase(); - final cleanModel = model.trim().toLowerCase().replaceAll(' ', '-'); + final descriptors = allDescriptors; - // 1. Exact make_model_year lookup + // Tier 1: Exact Year Match if (year != null) { - final keyWithYear = '${cleanMake}_${cleanModel}_$year'; - if (_registry.containsKey(keyWithYear)) { - return _registry[keyWithYear]!; + for (final desc in descriptors) { + if (desc.exactYear != null && desc.matches(make, model, year)) { + return desc.toReference(); + } } } - // 2. Exact make_model lookup - final keyNoYear = '${cleanMake}_$cleanModel'; - if (_registry.containsKey(keyNoYear)) { - return _registry[keyNoYear]!; + // Tier 2: Generation / Year-Range Match (e.g. 1999-2002 1st Gen R6) + if (year != null) { + for (final desc in descriptors) { + if ((desc.minYear != null || desc.maxYear != null) && desc.matches(make, model, year)) { + return desc.toReference(); + } + } } - // 3. Normalized alias matching (e.g. YZF-R6 -> R6) - final simplifiedModel = cleanModel.replaceAll('yzf-', ''); - final keySimplified = '${cleanMake}_$simplifiedModel'; - if (_registry.containsKey(keySimplified)) { - return _registry[keySimplified]!; + // Tier 3: Make + Model Match (Ageless / General Model Reference) + for (final desc in descriptors) { + if (desc.exactYear == null && desc.minYear == null && desc.maxYear == null && desc.matches(make, model, year)) { + return desc.toReference(); + } } - // 4. Default fallback + // Tier 4: Graceful Technical Schematic Fallback + final yearPrefix = year != null && year > 1900 ? '$year ' : ''; + final formattedCaption = '$yearPrefix${make.trim()} ${model.trim()}'.trim(); return MotorcycleImageReference( - caption: '$year $make $model'.trim(), - attribution: 'Schematic Vector', + caption: formattedCaption, + attribution: 'Technical Schematic', isPhoto: false, ); } + + /// Checks whether a genuine photographic asset is registered and available for a given vehicle + bool hasPhotographicAsset({ + required String? make, + required String? model, + int? year, + }) { + final ref = getReferenceImage(make: make, model: model, year: year); + return ref.isPhoto && ref.assetPath != null; + } } diff --git a/test/maintenance_v102_test.dart b/test/maintenance_v102_test.dart index be6187c..2d60ec1 100644 --- a/test/maintenance_v102_test.dart +++ b/test/maintenance_v102_test.dart @@ -102,7 +102,7 @@ void main() { ); expect(fallbackRef.isPhoto, isFalse); expect(fallbackRef.hasImage, isFalse); - expect(fallbackRef.attribution, equals('Schematic Vector')); + expect(fallbackRef.attribution, contains('Schematic')); }); test('4. GitHub Auth Service Token & Connection Test', () async { diff --git a/test/motorcycle_image_selection_audit_test.dart b/test/motorcycle_image_selection_audit_test.dart new file mode 100644 index 0000000..5b25280 --- /dev/null +++ b/test/motorcycle_image_selection_audit_test.dart @@ -0,0 +1,182 @@ +import 'dart:io'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:motorcycle_dash/services/garage/motorcycle_image_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('Motorcycle Image Selection & Hierarchy Audit', () { + late MotorcycleImageService service; + + setUp(() { + MotorcycleImageService.resetRegistry(); + service = MotorcycleImageService(); + }); + + tearDown(() { + MotorcycleImageService.resetRegistry(); + }); + + test('1. 2001 Yamaha YZF-R6 returns verified authentic photograph', () { + final ref = service.getReferenceImage( + make: 'Yamaha', + model: 'YZF-R6', + year: 2001, + ); + + expect(ref.isPhoto, isTrue); + expect(ref.hasImage, isTrue); + expect(ref.assetPath, equals('assets/images/motorcycles/yamaha_yzf_r6_2001.png')); + expect(ref.caption, contains('2001 Yamaha YZF-R6')); + expect(ref.attribution, equals('Factory Supersport Reference')); + + expect(service.hasPhotographicAsset(make: 'Yamaha', model: 'YZF-R6', year: 2001), isTrue); + }); + + test('2. 1st Generation Yamaha YZF-R6 aliases resolve correctly', () { + // Model alias "R6" + final r6Alias = service.getReferenceImage(make: 'Yamaha', model: 'R6', year: 2001); + expect(r6Alias.isPhoto, isTrue); + expect(r6Alias.assetPath, equals('assets/images/motorcycles/yamaha_yzf_r6_2001.png')); + + // Model alias "YZF R6" + final spaceAlias = service.getReferenceImage(make: 'Yamaha', model: 'YZF R6', year: 2001); + expect(spaceAlias.isPhoto, isTrue); + + // Model alias "YZFR6" + final noSpaceAlias = service.getReferenceImage(make: 'Yamaha', model: 'YZFR6', year: 2000); + expect(noSpaceAlias.isPhoto, isTrue); + }); + + test('3. Model-generation year matching works for 1999-2002 5EB', () { + // 1999 1st Gen + final gen1999 = service.getReferenceImage(make: 'Yamaha', model: 'YZF-R6', year: 1999); + expect(gen1999.isPhoto, isTrue); + + // 2002 1st Gen + final gen2002 = service.getReferenceImage(make: 'Yamaha', model: 'YZF-R6', year: 2002); + expect(gen2002.isPhoto, isTrue); + + // 2008 3rd Gen (different generation without bundled photo asset) + // Must NOT return 1st Gen 5EB photo asset + final gen2008 = service.getReferenceImage(make: 'Yamaha', model: 'YZF-R6', year: 2008); + expect(gen2008.isPhoto, isFalse); + expect(gen2008.assetPath, isNull); + expect(gen2008.caption, equals('2008 Yamaha YZF-R6')); + expect(gen2008.attribution, equals('Technical Schematic')); + }); + + test('4. Motorcycle with no bundled photograph returns graceful technical schematic', () { + final ref = service.getReferenceImage( + make: 'Honda', + model: 'CBR600RR', + year: 2018, + ); + + expect(ref.isPhoto, isFalse); + expect(ref.hasImage, isFalse); + expect(ref.assetPath, isNull); + expect(ref.caption, equals('2018 Honda CBR600RR')); + expect(ref.attribution, equals('Technical Schematic')); + expect(service.hasPhotographicAsset(make: 'Honda', model: 'CBR600RR', year: 2018), isFalse); + }); + + test('5. Unknown make and model returns generic technical fallback', () { + final nullRef = service.getReferenceImage(make: null, model: null, year: null); + expect(nullRef.isPhoto, isFalse); + expect(nullRef.assetPath, isNull); + expect(nullRef.caption, equals('Motorcycle Technical Reference')); + + final emptyRef = service.getReferenceImage(make: '', model: '', year: 2020); + expect(emptyRef.isPhoto, isFalse); + expect(emptyRef.assetPath, isNull); + + final customRef = service.getReferenceImage(make: 'Custom', model: 'Cafe Racer', year: 1982); + expect(customRef.isPhoto, isFalse); + expect(customRef.assetPath, isNull); + expect(customRef.caption, equals('1982 Custom Cafe Racer')); + expect(customRef.attribution, equals('Technical Schematic')); + }); + + test('6. Yamaha R6 photograph is NEVER returned for an unrelated motorcycle', () { + final testCases = [ + {'make': 'Honda', 'model': 'CBR600RR', 'year': 2001}, + {'make': 'Honda', 'model': 'CBR1000RR-R', 'year': 2024}, + {'make': 'Suzuki', 'model': 'GSX-R600', 'year': 2001}, + {'make': 'Suzuki', 'model': 'Hayabusa GSX1300R', 'year': 2022}, + {'make': 'Kawasaki', 'model': 'Ninja ZX-6R', 'year': 2001}, + {'make': 'Kawasaki', 'model': 'Ninja H2', 'year': 2023}, + {'make': 'Ducati', 'model': 'Panigale V4 S', 'year': 2023}, + {'make': 'Ducati', 'model': 'Monster 821', 'year': 2019}, + {'make': 'BMW', 'model': 'S1000RR', 'year': 2022}, + {'make': 'BMW', 'model': 'R1250GS Adventure', 'year': 2021}, + {'make': 'KTM', 'model': '390 Duke', 'year': 2020}, + {'make': 'KTM', 'model': '1290 Super Duke R', 'year': 2022}, + {'make': 'Triumph', 'model': 'Daytona 675', 'year': 2012}, + {'make': 'Triumph', 'model': 'Street Triple 765 RS', 'year': 2023}, + {'make': 'Harley-Davidson', 'model': 'Sportster S', 'year': 2022}, + {'make': 'Aprilia', 'model': 'RSV4 1100 Factory', 'year': 2023}, + ]; + + for (final tc in testCases) { + final make = tc['make'] as String; + final model = tc['model'] as String; + final year = tc['year'] as int; + + final ref = service.getReferenceImage(make: make, model: model, year: year); + + // Prove R6 photograph is NOT returned + expect( + ref.assetPath, + isNot(equals('assets/images/motorcycles/yamaha_yzf_r6_2001.png')), + reason: 'Failed isolation: $make $model returned R6 asset!', + ); + expect(ref.isPhoto, isFalse, reason: 'Expected schematic fallback for unbundled $make $model'); + expect(ref.caption, equals('$year $make $model')); + expect(ref.attribution, equals('Technical Schematic')); + } + }); + + test('7. Architecture is genuinely extensible via dynamic descriptor registration', () { + // 1. Initial lookup for 2024 Ducati Panigale V4 returns schematic fallback + final beforeReg = service.getReferenceImage(make: 'Ducati', model: 'Panigale V4', year: 2024); + expect(beforeReg.isPhoto, isFalse); + + // 2. Register custom descriptor without modifying UI code + MotorcycleImageService.registerDescriptor( + const MotorcycleImageDescriptor( + make: 'Ducati', + model: 'Panigale V4', + minYear: 2022, + maxYear: 2025, + modelAliases: ['PanigaleV4', 'Panigale V4 S'], + assetPath: 'assets/images/motorcycles/ducati_panigale_v4.png', + caption: 'Ducati Panigale V4 (Desmosedici Stradale)', + attribution: 'Factory Superbike Reference', + isPhoto: true, + ), + ); + + // 3. Lookup now matches Tier 2 generation descriptor + final afterReg = service.getReferenceImage(make: 'Ducati', model: 'Panigale V4', year: 2024); + expect(afterReg.isPhoto, isTrue); + expect(afterReg.assetPath, equals('assets/images/motorcycles/ducati_panigale_v4.png')); + expect(afterReg.caption, contains('Ducati Panigale V4')); + + // 4. Exact matching works for aliases + final aliasReg = service.getReferenceImage(make: 'Ducati', model: 'Panigale V4 S', year: 2023); + expect(aliasReg.isPhoto, isTrue); + }); + + test('8. Bundled photographic assets physically exist on disk', () { + final bundledAssets = MotorcycleImageService.bundledPhotographicAssets; + expect(bundledAssets, isNotEmpty); + + for (final assetPath in bundledAssets) { + final file = File(assetPath); + expect(file.existsSync(), isTrue, reason: 'Bundled asset $assetPath does not exist on disk!'); + expect(file.lengthSync(), greaterThan(1000), reason: 'Asset file $assetPath is empty or corrupt'); + } + }); + }); +}