fix: unify motorcycle image rendering paths

Consolidate motorcycle image resolution and rendering so Garage and Technical Details use the same authoritative image hierarchy.
This commit is contained in:
bponders12-cell 2026-09-01 20:31:24 -04:00 committed by GitHub
parent 432253b11f
commit ed269a0e67
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 276 additions and 234 deletions

View File

@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import '../../../services/motorcycle_image_service.dart'; import '../../../services/motorcycle_image_service.dart';
/// Shared motorcycle image widget used by Garage and motorcycle detail views.
/// It always resolves through the authoritative MotorcycleImageService.
class MotorcyclePhoto extends StatelessWidget { class MotorcyclePhoto extends StatelessWidget {
final int year; final int year;
final String make; final String make;
@ -9,6 +11,7 @@ class MotorcyclePhoto extends StatelessWidget {
final double height; final double height;
final BorderRadius borderRadius; final BorderRadius borderRadius;
final bool showAttribution; final bool showAttribution;
final BoxFit fit;
const MotorcyclePhoto({ const MotorcyclePhoto({
super.key, super.key,
@ -18,6 +21,7 @@ class MotorcyclePhoto extends StatelessWidget {
this.height = 190, this.height = 190,
this.borderRadius = const BorderRadius.all(Radius.circular(14)), this.borderRadius = const BorderRadius.all(Radius.circular(14)),
this.showAttribution = true, this.showAttribution = true,
this.fit = BoxFit.cover,
}); });
@override @override
@ -41,23 +45,8 @@ class MotorcyclePhoto extends StatelessWidget {
: Stack( : Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
Image.network( _buildImage(image),
image.imageUrl, if (showAttribution && image.isPhoto)
fit: BoxFit.cover,
filterQuality: FilterQuality.medium,
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
return const Center(
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.cyanAccent,
),
);
},
errorBuilder: (_, _, _) =>
const _FallbackMotorcycleImage(),
),
if (showAttribution)
Positioned( Positioned(
left: 10, left: 10,
right: 10, right: 10,
@ -70,10 +59,7 @@ class MotorcyclePhoto extends StatelessWidget {
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5),
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
horizontal: 7,
vertical: 3,
),
child: Text( child: Text(
_attribution(image), _attribution(image),
maxLines: 1, maxLines: 1,
@ -96,6 +82,36 @@ class MotorcyclePhoto extends StatelessWidget {
); );
} }
Widget _buildImage(MotorcycleImage image) {
if (image.assetPath != null) {
return Image.asset(
image.assetPath!,
fit: fit,
errorBuilder: (_, _, _) => const _FallbackMotorcycleImage(),
);
}
if (image.imageUrl != null) {
return Image.network(
image.imageUrl!,
fit: fit,
filterQuality: FilterQuality.medium,
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
return const Center(
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.cyanAccent,
),
);
},
errorBuilder: (_, _, _) => const _FallbackMotorcycleImage(),
);
}
return const _FallbackMotorcycleImage();
}
String _attribution(MotorcycleImage image) { String _attribution(MotorcycleImage image) {
final credit = image.credit?.trim(); final credit = image.credit?.trim();
final license = image.license?.trim(); final license = image.license?.trim();
@ -103,7 +119,7 @@ class MotorcyclePhoto extends StatelessWidget {
return 'Photo: $credit • $license'; return 'Photo: $credit • $license';
} }
if (credit != null && credit.isNotEmpty) return 'Photo: $credit'; if (credit != null && credit.isNotEmpty) return 'Photo: $credit';
return 'Photo: Wikimedia Commons'; return 'Photo: ${image.attribution}';
} }
} }

View File

@ -1,4 +1,10 @@
/// Metadata for a motorcycle photograph or reference image import '../motorcycle_image_service.dart' as unified;
/// Backward-compatible facade for older Garage imports.
///
/// Image resolution is implemented exclusively by the unified service in
/// `services/motorcycle_image_service.dart`; this facade exists temporarily so
/// existing callers/tests can migrate without maintaining a second registry.
class MotorcycleImageReference { class MotorcycleImageReference {
final String? assetPath; final String? assetPath;
final String? networkUrl; final String? networkUrl;
@ -19,185 +25,80 @@ 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 extends unified.MotorcycleImageDescriptor {
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({ const MotorcycleImageDescriptor({
required this.make, required super.make,
required this.model, required super.model,
this.exactYear, super.exactYear,
this.minYear, super.minYear,
this.maxYear, super.maxYear,
this.modelAliases = const [], super.modelAliases,
required this.assetPath, required super.assetPath,
required this.caption, required super.caption,
this.attribution = 'Factory Technical Reference', super.attribution,
this.isPhoto = true, super.isPhoto,
}); });
/// 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 /// Compatibility facade. New UI code should import the unified service.
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();
/// List of verified physical photographic assets bundled in the APK static List<String> get bundledPhotographicAssets =>
static const List<String> bundledPhotographicAssets = [ unified.MotorcycleImageService.bundledPhotographicAssets;
'assets/images/motorcycles/yamaha_yzf_r6_2001.png',
];
/// 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',
caption: '2001 Yamaha YZF-R6 (5EB)',
attribution: 'Factory Supersport Reference',
isPhoto: true,
),
];
static final List<MotorcycleImageDescriptor> _customDescriptors = [];
/// Registers a custom image descriptor (for extensions/plugins/tests)
static void registerDescriptor(MotorcycleImageDescriptor descriptor) { static void registerDescriptor(MotorcycleImageDescriptor descriptor) {
_customDescriptors.add(descriptor); unified.MotorcycleImageService.registerDescriptor(descriptor);
} }
/// Clears custom descriptors (resets to default registry)
static void resetRegistry() { static void resetRegistry() {
_customDescriptors.clear(); unified.MotorcycleImageService.resetRegistry();
} }
/// Returns all active descriptors in evaluation order (custom first, then defaults) List<MotorcycleImageDescriptor> get allDescriptors =>
List<MotorcycleImageDescriptor> get allDescriptors => [ unified.MotorcycleImageService.instance.allDescriptors
..._customDescriptors, .map((d) => MotorcycleImageDescriptor(
..._defaultDescriptors, make: d.make,
]; model: d.model,
exactYear: d.exactYear,
minYear: d.minYear,
maxYear: d.maxYear,
modelAliases: d.modelAliases,
assetPath: d.assetPath,
caption: d.caption,
attribution: d.attribution,
isPhoto: d.isPhoto,
))
.toList();
/// 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 || make.trim().isEmpty || model.trim().isEmpty) { final image = unified.MotorcycleImageService.instance.getReferenceImage(
return const MotorcycleImageReference( make: make,
caption: 'Motorcycle Technical Reference', model: model,
attribution: 'Schematic Vector', year: year,
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( return MotorcycleImageReference(
caption: formattedCaption, assetPath: image.assetPath,
attribution: 'Technical Schematic', networkUrl: image.imageUrl,
isPhoto: false, caption: image.caption,
attribution: image.attribution,
isPhoto: image.isPhoto,
); );
} }
/// Checks whether a genuine photographic asset is registered and available for a given vehicle
bool hasPhotographicAsset({ bool hasPhotographicAsset({
required String? make, required String? make,
required String? model, required String? model,
int? year, int? year,
}) { }) => unified.MotorcycleImageService.instance.hasPhotographicAsset(
final ref = getReferenceImage(make: make, model: model, year: year); make: make,
return ref.isPhoto && ref.assetPath != null; model: model,
} year: year,
);
} }

View File

@ -3,41 +3,133 @@ import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
/// Resolved motorcycle image metadata used by every motorcycle UI surface.
class MotorcycleImage { class MotorcycleImage {
final String imageUrl; final String? assetPath;
final String sourceUrl; final String? imageUrl;
final String? sourceUrl;
final String? credit; final String? credit;
final String? license; final String? license;
final String caption;
final String attribution;
final bool isPhoto;
const MotorcycleImage({ const MotorcycleImage({
required this.imageUrl, this.assetPath,
required this.sourceUrl, this.imageUrl,
this.sourceUrl,
this.credit, this.credit,
this.license, this.license,
this.caption = 'Motorcycle Technical Reference',
this.attribution = 'Technical Schematic',
this.isPhoto = false,
}); });
bool get hasImage => assetPath != null || imageUrl != null;
} }
/// Resolves a real motorcycle photograph for a make/model/year selection. /// Optional curated image descriptor for a motorcycle make/model/year range.
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,
});
static String normalize(String input) =>
input.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), '');
bool matches(String targetMake, String targetModel, int? targetYear) {
if (normalize(make) != normalize(targetMake)) return false;
final normalizedTargetModel = normalize(targetModel);
final modelMatches = normalize(model) == normalizedTargetModel ||
modelAliases.any((alias) => normalize(alias) == normalizedTargetModel);
if (!modelMatches) return false;
if (exactYear != null) return targetYear == exactYear;
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;
}
MotorcycleImage toImage() => MotorcycleImage(
assetPath: assetPath,
caption: caption,
attribution: attribution,
isPhoto: isPhoto,
);
}
/// Single authoritative resolver for motorcycle imagery throughout MotorSignal.
/// ///
/// Known exact matches are preferred first. Other motorcycles are resolved /// Resolution order:
/// from Wikimedia Commons using its public API, with licensing metadata kept /// 1. Curated exact-year image
/// alongside the image for attribution in the UI. /// 2. Curated generation/year-range image
/// 3. Curated model image
/// 4. Wikimedia Commons photograph with compatible licensing
/// 5. Technical-reference fallback with no stale motorcycle imagery
class MotorcycleImageService { class MotorcycleImageService {
MotorcycleImageService._(); MotorcycleImageService._();
static final MotorcycleImageService instance = MotorcycleImageService._(); static final MotorcycleImageService instance = MotorcycleImageService._();
static const List<String> bundledPhotographicAssets = [
'assets/images/motorcycles/yamaha_yzf_r6_2001.png',
];
static const List<MotorcycleImageDescriptor> _defaultDescriptors = [
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,
),
];
static final List<MotorcycleImageDescriptor> _customDescriptors = [];
final Map<String, MotorcycleImage?> _cache = {}; final Map<String, MotorcycleImage?> _cache = {};
static const _exactMatches = <String, MotorcycleImage>{ static void registerDescriptor(MotorcycleImageDescriptor descriptor) {
'2001|yamaha|yzf-r6': MotorcycleImage( _customDescriptors.add(descriptor);
imageUrl: instance._cache.clear();
'https://upload.wikimedia.org/wikipedia/commons/e/e0/2001_Yamaha_YZFR600.jpg', }
sourceUrl:
'https://commons.wikimedia.org/wiki/File:2001_Yamaha_YZFR600.jpg', static void resetRegistry() {
credit: 'James McCauley / Wikimedia Commons', _customDescriptors.clear();
license: 'CC BY 2.0', instance._cache.clear();
), }
};
List<MotorcycleImageDescriptor> get allDescriptors => [
..._customDescriptors,
..._defaultDescriptors,
];
Future<MotorcycleImage?> resolve({ Future<MotorcycleImage?> resolve({
required int year, required int year,
@ -47,10 +139,10 @@ class MotorcycleImageService {
final key = _key(year, make, model); final key = _key(year, make, model);
if (_cache.containsKey(key)) return _cache[key]; if (_cache.containsKey(key)) return _cache[key];
final exact = _exactMatches[key]; final curated = _resolveCurated(make: make, model: model, year: year);
if (exact != null) { if (curated != null) {
_cache[key] = exact; _cache[key] = curated;
return exact; return curated;
} }
try { try {
@ -88,13 +180,14 @@ class MotorcycleImageService {
MotorcycleImage? best; MotorcycleImage? best;
var bestScore = -1; var bestScore = -1;
final normalizedModel = _normalize(model); final normalizedModel = MotorcycleImageDescriptor.normalize(model);
final normalizedMake = _normalize(make); final normalizedMake = MotorcycleImageDescriptor.normalize(make);
final normalizedYear = year.toString(); final normalizedYear = year.toString();
for (final rawPage in pages) { for (final rawPage in pages) {
final page = Map<String, dynamic>.from(rawPage as Map); final page = Map<String, dynamic>.from(rawPage as Map);
final title = (page['title']?.toString() ?? '').toLowerCase(); final title = (page['title']?.toString() ?? '').toLowerCase();
final normalizedTitle = title.replaceAll(RegExp(r'[^a-z0-9]'), '');
final infoList = page['imageinfo']; final infoList = page['imageinfo'];
if (infoList is! List || infoList.isEmpty) continue; if (infoList is! List || infoList.isEmpty) continue;
final info = Map<String, dynamic>.from(infoList.first as Map); final info = Map<String, dynamic>.from(infoList.first as Map);
@ -113,8 +206,8 @@ class MotorcycleImageService {
if (!allowed) continue; if (!allowed) continue;
var score = 0; var score = 0;
if (title.contains(normalizedModel)) score += 5; if (normalizedTitle.contains(normalizedModel)) score += 5;
if (title.contains(normalizedMake)) score += 3; if (normalizedTitle.contains(normalizedMake)) score += 3;
if (title.contains(normalizedYear)) score += 4; if (title.contains(normalizedYear)) score += 4;
if (title.contains('motorcycle') || title.contains('motorbike')) score += 1; if (title.contains('motorcycle') || title.contains('motorbike')) score += 1;
@ -126,6 +219,9 @@ class MotorcycleImageService {
credit: _metadataValue(metadata, 'Artist') ?? credit: _metadataValue(metadata, 'Artist') ??
_metadataValue(metadata, 'Credit'), _metadataValue(metadata, 'Credit'),
license: license, license: license,
caption: '$year $make $model',
attribution: 'Wikimedia Commons',
isPhoto: true,
); );
} }
} }
@ -139,13 +235,66 @@ class MotorcycleImageService {
} }
} }
String _key(int year, String make, String model) => /// Synchronous curated/fallback lookup for screens that should never wait on
'$year|${_normalize(make)}|${_normalize(model)}'; /// network resolution. This is the same authoritative hierarchy used by UI.
MotorcycleImage getReferenceImage({
required String? make,
required String? model,
int? year,
}) {
if (make == null || model == null || make.trim().isEmpty || model.trim().isEmpty) {
return const MotorcycleImage();
}
String _normalize(String value) => value final curated = _resolveCurated(make: make, model: model, year: year);
.toLowerCase() if (curated != null) return curated;
.replaceAll(RegExp(r'[^a-z0-9]+'), '-')
.replaceAll(RegExp(r'^-+|-+$'), ''); final yearPrefix = year != null && year > 1900 ? '$year ' : '';
return MotorcycleImage(
caption: '$yearPrefix${make.trim()} ${model.trim()}'.trim(),
attribution: 'Technical Schematic',
isPhoto: false,
);
}
bool hasPhotographicAsset({
required String? make,
required String? model,
int? year,
}) => getReferenceImage(make: make, model: model, year: year).isPhoto;
MotorcycleImage? _resolveCurated({
required String make,
required String model,
required int? year,
}) {
if (year != null) {
for (final descriptor in allDescriptors) {
if (descriptor.exactYear != null && descriptor.matches(make, model, year)) {
return descriptor.toImage();
}
}
for (final descriptor in allDescriptors) {
if ((descriptor.minYear != null || descriptor.maxYear != null) &&
descriptor.matches(make, model, year)) {
return descriptor.toImage();
}
}
}
for (final descriptor in allDescriptors) {
if (descriptor.exactYear == null &&
descriptor.minYear == null &&
descriptor.maxYear == null &&
descriptor.matches(make, model, year)) {
return descriptor.toImage();
}
}
return null;
}
String _key(int year, String make, String model) =>
'$year|${MotorcycleImageDescriptor.normalize(make)}|${MotorcycleImageDescriptor.normalize(model)}';
String? _metadataValue(Map<String, dynamic> metadata, String key) { String? _metadataValue(Map<String, dynamic> metadata, String key) {
final value = metadata[key]; final value = metadata[key];

View File

@ -1,6 +1,6 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:motorcycle_dash/services/garage/motorcycle_image_service.dart'; import 'package:motorcycle_dash/services/motorcycle_image_service.dart';
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
@ -10,7 +10,7 @@ void main() {
setUp(() { setUp(() {
MotorcycleImageService.resetRegistry(); MotorcycleImageService.resetRegistry();
service = MotorcycleImageService(); service = MotorcycleImageService.instance;
}); });
tearDown(() { tearDown(() {
@ -18,47 +18,34 @@ void main() {
}); });
test('1. 2001 Yamaha YZF-R6 returns verified authentic photograph', () { test('1. 2001 Yamaha YZF-R6 returns verified authentic photograph', () {
final ref = service.getReferenceImage( final ref = service.getReferenceImage(make: 'Yamaha', model: 'YZF-R6', year: 2001);
make: 'Yamaha',
model: 'YZF-R6',
year: 2001,
);
expect(ref.isPhoto, isTrue); expect(ref.isPhoto, isTrue);
expect(ref.hasImage, isTrue); expect(ref.hasImage, isTrue);
expect(ref.assetPath, equals('assets/images/motorcycles/yamaha_yzf_r6_2001.png')); expect(ref.assetPath, equals('assets/images/motorcycles/yamaha_yzf_r6_2001.png'));
expect(ref.caption, contains('2001 Yamaha YZF-R6')); expect(ref.caption, contains('2001 Yamaha YZF-R6'));
expect(ref.attribution, equals('Factory Supersport Reference')); expect(ref.attribution, equals('Factory Supersport Reference'));
expect(service.hasPhotographicAsset(make: 'Yamaha', model: 'YZF-R6', year: 2001), isTrue); expect(service.hasPhotographicAsset(make: 'Yamaha', model: 'YZF-R6', year: 2001), isTrue);
}); });
test('2. 1st Generation Yamaha YZF-R6 aliases resolve correctly', () { test('2. 1st Generation Yamaha YZF-R6 aliases resolve correctly', () {
// Model alias "R6"
final r6Alias = service.getReferenceImage(make: 'Yamaha', model: 'R6', year: 2001); final r6Alias = service.getReferenceImage(make: 'Yamaha', model: 'R6', year: 2001);
expect(r6Alias.isPhoto, isTrue); expect(r6Alias.isPhoto, isTrue);
expect(r6Alias.assetPath, equals('assets/images/motorcycles/yamaha_yzf_r6_2001.png')); 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); final spaceAlias = service.getReferenceImage(make: 'Yamaha', model: 'YZF R6', year: 2001);
expect(spaceAlias.isPhoto, isTrue); expect(spaceAlias.isPhoto, isTrue);
// Model alias "YZFR6"
final noSpaceAlias = service.getReferenceImage(make: 'Yamaha', model: 'YZFR6', year: 2000); final noSpaceAlias = service.getReferenceImage(make: 'Yamaha', model: 'YZFR6', year: 2000);
expect(noSpaceAlias.isPhoto, isTrue); expect(noSpaceAlias.isPhoto, isTrue);
}); });
test('3. Model-generation year matching works for 1999-2002 5EB', () { 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); final gen1999 = service.getReferenceImage(make: 'Yamaha', model: 'YZF-R6', year: 1999);
expect(gen1999.isPhoto, isTrue); expect(gen1999.isPhoto, isTrue);
// 2002 1st Gen
final gen2002 = service.getReferenceImage(make: 'Yamaha', model: 'YZF-R6', year: 2002); final gen2002 = service.getReferenceImage(make: 'Yamaha', model: 'YZF-R6', year: 2002);
expect(gen2002.isPhoto, isTrue); 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); final gen2008 = service.getReferenceImage(make: 'Yamaha', model: 'YZF-R6', year: 2008);
expect(gen2008.isPhoto, isFalse); expect(gen2008.isPhoto, isFalse);
expect(gen2008.assetPath, isNull); expect(gen2008.assetPath, isNull);
@ -67,12 +54,7 @@ void main() {
}); });
test('4. Motorcycle with no bundled photograph returns graceful technical schematic', () { test('4. Motorcycle with no bundled photograph returns graceful technical schematic', () {
final ref = service.getReferenceImage( final ref = service.getReferenceImage(make: 'Honda', model: 'CBR600RR', year: 2018);
make: 'Honda',
model: 'CBR600RR',
year: 2018,
);
expect(ref.isPhoto, isFalse); expect(ref.isPhoto, isFalse);
expect(ref.hasImage, isFalse); expect(ref.hasImage, isFalse);
expect(ref.assetPath, isNull); expect(ref.assetPath, isNull);
@ -122,10 +104,8 @@ void main() {
final make = tc['make'] as String; final make = tc['make'] as String;
final model = tc['model'] as String; final model = tc['model'] as String;
final year = tc['year'] as int; final year = tc['year'] as int;
final ref = service.getReferenceImage(make: make, model: model, year: year); final ref = service.getReferenceImage(make: make, model: model, year: year);
// Prove R6 photograph is NOT returned
expect( expect(
ref.assetPath, ref.assetPath,
isNot(equals('assets/images/motorcycles/yamaha_yzf_r6_2001.png')), isNot(equals('assets/images/motorcycles/yamaha_yzf_r6_2001.png')),
@ -138,11 +118,9 @@ void main() {
}); });
test('7. Architecture is genuinely extensible via dynamic descriptor registration', () { 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); final beforeReg = service.getReferenceImage(make: 'Ducati', model: 'Panigale V4', year: 2024);
expect(beforeReg.isPhoto, isFalse); expect(beforeReg.isPhoto, isFalse);
// 2. Register custom descriptor without modifying UI code
MotorcycleImageService.registerDescriptor( MotorcycleImageService.registerDescriptor(
const MotorcycleImageDescriptor( const MotorcycleImageDescriptor(
make: 'Ducati', make: 'Ducati',
@ -157,13 +135,11 @@ void main() {
), ),
); );
// 3. Lookup now matches Tier 2 generation descriptor
final afterReg = service.getReferenceImage(make: 'Ducati', model: 'Panigale V4', year: 2024); final afterReg = service.getReferenceImage(make: 'Ducati', model: 'Panigale V4', year: 2024);
expect(afterReg.isPhoto, isTrue); expect(afterReg.isPhoto, isTrue);
expect(afterReg.assetPath, equals('assets/images/motorcycles/ducati_panigale_v4.png')); expect(afterReg.assetPath, equals('assets/images/motorcycles/ducati_panigale_v4.png'));
expect(afterReg.caption, contains('Ducati Panigale V4')); 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); final aliasReg = service.getReferenceImage(make: 'Ducati', model: 'Panigale V4 S', year: 2023);
expect(aliasReg.isPhoto, isTrue); expect(aliasReg.isPhoto, isTrue);
}); });