fix(garage): establish data-driven 4-tier motorcycle image hierarchy with isolated fallback

This commit is contained in:
Developer 2026-08-30 08:50:01 -04:00
parent cfc9dfb675
commit 305c2151dd
3 changed files with 331 additions and 56 deletions

View File

@ -19,92 +19,185 @@ class MotorcycleImageReference {
bool get hasImage => assetPath != null || networkUrl != null || localFilePath != null; 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<String> 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 /// Central extensible repository providing authentic motorcycle reference images
class MotorcycleImageService { class MotorcycleImageService {
static const MotorcycleImageService _instance = MotorcycleImageService._internal(); static const MotorcycleImageService _instance = MotorcycleImageService._internal();
factory MotorcycleImageService() => _instance; factory MotorcycleImageService() => _instance;
const MotorcycleImageService._internal(); const MotorcycleImageService._internal();
/// Known registry mapping (Make + Model + Optional Year) to curated reference images /// List of verified physical photographic assets bundled in the APK
static final Map<String, MotorcycleImageReference> _registry = { static const List<String> bundledPhotographicAssets = [
// 2001 Yamaha YZF-R6 (User's field test vehicle) 'assets/images/motorcycles/yamaha_yzf_r6_2001.png',
'yamaha_yzf-r6_2001': const MotorcycleImageReference( ];
/// Default registry of curated motorcycle reference descriptors
static final List<MotorcycleImageDescriptor> _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', assetPath: 'assets/images/motorcycles/yamaha_yzf_r6_2001.png',
caption: '2001 Yamaha YZF-R6 (5EB)', caption: '2001 Yamaha YZF-R6 (5EB)',
attribution: 'Factory Supersport Reference', attribution: 'Factory Supersport Reference',
isPhoto: true, isPhoto: true,
), ),
'yamaha_r6_2001': const MotorcycleImageReference( ];
assetPath: 'assets/images/motorcycles/yamaha_yzf_r6_2001.png',
caption: '2001 Yamaha YZF-R6 (5EB)', static final List<MotorcycleImageDescriptor> _customDescriptors = [];
attribution: 'Factory Supersport Reference',
isPhoto: true, /// Registers a custom image descriptor (for extensions/plugins/tests)
), static void registerDescriptor(MotorcycleImageDescriptor descriptor) {
'yamaha_yzf-r6': const MotorcycleImageReference( _customDescriptors.add(descriptor);
assetPath: 'assets/images/motorcycles/yamaha_yzf_r6_2001.png', }
caption: 'Yamaha YZF-R6 Supersport',
attribution: 'Factory Technical Reference', /// Clears custom descriptors (resets to default registry)
isPhoto: true, static void resetRegistry() {
), _customDescriptors.clear();
'yamaha_r6': const MotorcycleImageReference( }
assetPath: 'assets/images/motorcycles/yamaha_yzf_r6_2001.png',
caption: 'Yamaha YZF-R6 Supersport', /// Returns all active descriptors in evaluation order (custom first, then defaults)
attribution: 'Factory Technical Reference', List<MotorcycleImageDescriptor> get allDescriptors => [
isPhoto: true, ..._customDescriptors,
), ..._defaultDescriptors,
'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,
),
};
/// Resolves the best available photographic image or reference for a motorcycle /// 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({ MotorcycleImageReference getReferenceImage({
required String? make, required String? make,
required String? model, required String? model,
int? year, int? year,
}) { }) {
if (make == null || model == null) { if (make == null || model == null || make.trim().isEmpty || model.trim().isEmpty) {
return const MotorcycleImageReference(caption: 'Generic Motorcycle Reference'); return const MotorcycleImageReference(
} caption: 'Motorcycle Technical Reference',
final cleanMake = make.trim().toLowerCase();
final cleanModel = model.trim().toLowerCase().replaceAll(' ', '-');
// 1. Exact make_model_year lookup
if (year != null) {
final keyWithYear = '${cleanMake}_${cleanModel}_$year';
if (_registry.containsKey(keyWithYear)) {
return _registry[keyWithYear]!;
}
}
// 2. Exact make_model lookup
final keyNoYear = '${cleanMake}_$cleanModel';
if (_registry.containsKey(keyNoYear)) {
return _registry[keyNoYear]!;
}
// 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]!;
}
// 4. Default fallback
return MotorcycleImageReference(
caption: '$year $make $model'.trim(),
attribution: 'Schematic Vector', attribution: 'Schematic Vector',
isPhoto: false, isPhoto: false,
); );
} }
final descriptors = allDescriptors;
// Tier 1: Exact Year Match
if (year != null) {
for (final desc in descriptors) {
if (desc.exactYear != null && desc.matches(make, model, year)) {
return desc.toReference();
}
}
}
// 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();
}
}
}
// 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();
}
}
// Tier 4: Graceful Technical Schematic Fallback
final yearPrefix = year != null && year > 1900 ? '$year ' : '';
final formattedCaption = '$yearPrefix${make.trim()} ${model.trim()}'.trim();
return MotorcycleImageReference(
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;
}
} }

View File

@ -102,7 +102,7 @@ void main() {
); );
expect(fallbackRef.isPhoto, isFalse); expect(fallbackRef.isPhoto, isFalse);
expect(fallbackRef.hasImage, 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 { test('4. GitHub Auth Service Token & Connection Test', () async {

View File

@ -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');
}
});
});
}