MotorSignal-App/test/community_social_feed_test....

984 lines
34 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:motorcycle_dash/core/theme/theme_extensions.dart';
import 'package:motorcycle_dash/database/database.dart';
import 'package:motorcycle_dash/features/social/feed/social_feed_screen.dart';
import 'package:motorcycle_dash/features/social/feed/widgets/feed_comments_bottom_sheet.dart';
import 'package:motorcycle_dash/features/social/feed/widgets/feed_telemetry_inspector_modal.dart';
import 'package:motorcycle_dash/features/social/feed/widgets/share_session_modal.dart';
import 'package:motorcycle_dash/features/social/social_hub_screen.dart';
import 'package:motorcycle_dash/services/map/map_provider_interface.dart';
import 'package:motorcycle_dash/services/social/social_feed_models.dart';
import 'package:motorcycle_dash/services/social/social_feed_providers.dart';
import 'package:motorcycle_dash/services/social/social_feed_service.dart';
Widget _createTestApp({required Widget child, List<Override> overrides = const []}) {
return ProviderScope(
overrides: overrides,
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: const [
MotorSignalThemeColors.dark,
],
),
home: Scaffold(body: child),
),
);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('SocialFeed Models Unit Tests', () {
test('FeedFilter display names', () {
expect(FeedFilter.allActivity.displayName, 'All Activity');
expect(FeedFilter.clubOnly.displayName, 'Club Only');
expect(FeedFilter.nearbyPacks.displayName, 'Nearby Packs');
});
test('FeedPostComment toJson and fromJson roundtrip', () {
final comment = FeedPostComment(
id: 'c123',
authorUid: 'uid_test',
authorDisplayName: 'Test Rider',
authorHandle: 'testrider',
content: 'Great pace through section 2!',
createdAt: DateTime(2026, 9, 29, 10, 0),
);
final json = comment.toJson();
expect(json['id'], 'c123');
expect(json['authorUid'], 'uid_test');
expect(json['content'], 'Great pace through section 2!');
final parsed = FeedPostComment.fromJson({
'id': 'c123',
'authorUid': 'uid_test',
'authorDisplayName': 'Test Rider',
'authorHandle': 'testrider',
'content': 'Great pace through section 2!',
'createdAt': '2026-09-29T10:00:00.000',
});
expect(parsed.id, 'c123');
expect(parsed.authorDisplayName, 'Test Rider');
expect(parsed.content, 'Great pace through section 2!');
});
test('SocialFeedItem serialization and copyWith', () {
final item = SocialFeedItem(
id: 'feed_1',
authorUid: 'uid_1',
authorDisplayName: 'Apex Marcus',
authorHandle: 'ApexMarcus',
authorBadge: 'PRO',
clubName: 'Bay Area Twisties Club',
locationName: "Alice's Restaurant",
timestamp: DateTime(2026, 9, 29, 12, 0),
rideTitle: 'Sunday Skyline Carve',
bikeModel: '2024 Ducati Streetfighter V4',
distanceMiles: 68.4,
durationSeconds: 4440,
maxLeanAngleDeg: 51.0,
maxLeanSide: 'RIGHT',
paceFactor: '1.32x Spirited',
hazardCount: 0,
elevationFt: 2140,
snapshotType: FeedSnapshotType.routeTrack,
routePoints: const [AppLatLng(37.38, -122.26)],
fistBumpCount: 42,
fistBumpUserIds: const ['uid_other'],
comments: const [],
);
expect(item.isFistBumpedBy('uid_other'), isTrue);
expect(item.isFistBumpedBy('uid_nonexistent'), isFalse);
final updated = item.copyWith(fistBumpCount: 43);
expect(updated.fistBumpCount, 43);
expect(updated.id, 'feed_1');
final json = item.toJson();
expect(json['id'], 'feed_1');
expect(json['authorHandle'], 'ApexMarcus');
expect(json['distanceMiles'], 68.4);
expect(json['maxLeanAngleDeg'], 51.0);
});
});
group('SocialFeedService Unit Tests', () {
late SocialFeedService service;
setUp(() {
service = SocialFeedService();
});
tearDown(() {
service.dispose();
});
test('Initializes with default canonical items', () async {
final stream = service.getFeedStream(filter: FeedFilter.allActivity);
final items = await stream.first;
expect(items.length, greaterThanOrEqualTo(3));
expect(items.first.authorHandle, 'ApexMarcus');
});
test('Filters feed by clubOnly and nearbyPacks', () async {
final clubStream = service.getFeedStream(
filter: FeedFilter.clubOnly,
userClubName: 'Pacific Coast Riders',
);
final clubItems = await clubStream.first;
expect(clubItems.every((i) => i.clubName == 'Pacific Coast Riders'), isTrue);
final nearbyStream = service.getFeedStream(filter: FeedFilter.nearbyPacks);
final nearbyItems = await nearbyStream.first;
expect(nearbyItems.every((i) => i.distanceMiles < 60.0), isTrue);
});
test('Toggles fist bump reaction', () async {
final itemsBefore = await service.getFeedStream().first;
final target = itemsBefore.first;
final initialCount = target.fistBumpCount;
// Bump
await service.toggleFistBump(postId: target.id, userUid: 'test_rider_uid');
final itemsAfterBump = await service.getFeedStream().first;
final bumpedItem = itemsAfterBump.firstWhere((i) => i.id == target.id);
expect(bumpedItem.fistBumpCount, initialCount + 1);
expect(bumpedItem.isFistBumpedBy('test_rider_uid'), isTrue);
// Unbump
await service.toggleFistBump(postId: target.id, userUid: 'test_rider_uid');
final itemsAfterUnbump = await service.getFeedStream().first;
final unbumpedItem = itemsAfterUnbump.firstWhere((i) => i.id == target.id);
expect(unbumpedItem.fistBumpCount, initialCount);
expect(unbumpedItem.isFistBumpedBy('test_rider_uid'), isFalse);
});
test('Adds comment to post', () async {
final comment = FeedPostComment(
id: 'new_c',
authorUid: 'uid_test',
authorDisplayName: 'Dave S',
content: 'Clean line through section 2!',
createdAt: DateTime.now(),
);
await service.addComment(postId: 'feed_post_1', comment: comment);
final items = await service.getFeedStream().first;
final post = items.firstWhere((i) => i.id == 'feed_post_1');
expect(post.comments.any((c) => c.content == 'Clean line through section 2!'), isTrue);
});
test('Shares recent ride to community feed', () async {
final newPost = await service.shareRecentRide(
authorUid: 'uid_author',
authorDisplayName: 'Marcus Vance',
authorHandle: 'ApexMarcus',
locationName: 'Pescadero Creek',
rideTitle: 'Late Afternoon Run',
bikeModel: 'Ducati Panigale V4 S',
distanceMiles: 38.2,
durationSeconds: 2100,
maxLeanAngleDeg: 52.0,
);
expect(newPost.rideTitle, 'Late Afternoon Run');
expect(newPost.bikeModel, 'Ducati Panigale V4 S');
final items = await service.getFeedStream().first;
expect(items.first.id, newPost.id);
expect(items.first.rideTitle, 'Late Afternoon Run');
});
});
group('SocialFeedScreen & Sub-Widgets Widget Tests', () {
testWidgets('Renders SocialFeedScreen with header pills and tabs', (tester) async {
await tester.binding.setSurfaceSize(const Size(400, 800));
await tester.pumpWidget(
_createTestApp(
child: const SocialFeedScreen(),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(find.text('Community Feed'), findsOneWidget);
expect(find.text('SQUAD & CANYON FEED'), findsOneWidget);
expect(find.byKey(const ValueKey('feed_search_toggle_button')), findsOneWidget);
expect(find.text('All Activity'), findsOneWidget);
expect(find.text('Club Only'), findsOneWidget);
expect(find.text('Nearby Packs'), findsOneWidget);
expect(find.text('SHARE RECENT SESSION'), findsOneWidget);
// Verify Feed item contents
expect(find.text('@ApexMarcus'), findsOneWidget);
expect(find.text('Sunday Skyline & Pescadero Carve'), findsOneWidget);
expect(find.text('2024 Ducati Streetfighter V4'), findsOneWidget);
expect(find.text('51°'), findsOneWidget);
// Switch to Club Only tab
await tester.tap(find.text('Club Only'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
// Switch to Nearby Packs tab
await tester.tap(find.text('Nearby Packs'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
});
testWidgets('Tapping Fist Bump updates bump count with scale animation', (tester) async {
await tester.binding.setSurfaceSize(const Size(400, 800));
final service = SocialFeedService();
await tester.pumpWidget(
_createTestApp(
overrides: [
socialFeedServiceProvider.overrideWithValue(service),
],
child: const SocialFeedScreen(),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
// Scroll slightly so the card action bar clears the sticky button
await tester.drag(find.byType(ListView), const Offset(0, -180));
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(find.text('42 Bumps'), findsOneWidget);
// Tap fist bump
await tester.tap(find.byKey(const ValueKey('fist_bump_button_feed_post_1')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
expect(find.text('43 Bumps'), findsOneWidget);
});
testWidgets('Comments bottom sheet opens and displays comments', (tester) async {
await tester.binding.setSurfaceSize(const Size(400, 800));
final item = SocialFeedItem(
id: 'test_item',
authorUid: 'uid_1',
authorDisplayName: 'Sarah Chen',
authorHandle: 'SarahSweep',
locationName: 'Mulholland Hwy',
timestamp: DateTime.now(),
rideTitle: 'The Snake Pace Lap',
bikeModel: 'Yamaha MT-09 SP',
distanceMiles: 44.2,
durationSeconds: 2900,
maxLeanAngleDeg: 48.0,
comments: [
FeedPostComment(
id: 'c1',
authorUid: 'uid_2',
authorDisplayName: 'Marcus Vance',
authorHandle: 'ApexMarcus',
content: 'Great line through the esses!',
createdAt: DateTime.now(),
),
],
);
await tester.pumpWidget(
_createTestApp(
child: Builder(
builder: (context) => ElevatedButton(
onPressed: () => FeedCommentsBottomSheet.show(context, item),
child: const Text('Open Comments'),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Open Comments'));
await tester.pumpAndSettle();
expect(find.text('Rider Comments'), findsOneWidget);
expect(find.text('Great line through the esses!'), findsOneWidget);
expect(find.text('Marcus Vance'), findsOneWidget);
});
testWidgets('ShareSessionModal renders and allows form submission', (tester) async {
await tester.binding.setSurfaceSize(const Size(400, 900));
final service = SocialFeedService();
await tester.pumpWidget(
_createTestApp(
overrides: [
socialFeedServiceProvider.overrideWithValue(service),
],
child: Builder(
builder: (context) => ElevatedButton(
onPressed: () => ShareSessionModal.show(context),
child: const Text('Open Share Modal'),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Open Share Modal'));
await tester.pumpAndSettle();
expect(find.text('Share Recent Session'), findsOneWidget);
expect(find.text('PUBLISH TO COMMUNITY FEED'), findsOneWidget);
await tester.tap(find.text('PUBLISH TO COMMUNITY FEED'));
await tester.pumpAndSettle();
// Modal closed and snackbar shown
expect(find.text('Ride session published to Community Feed!'), findsOneWidget);
});
testWidgets('SocialHubScreen contains Community Feed card', (tester) async {
await tester.binding.setSurfaceSize(const Size(400, 800));
await tester.pumpWidget(
_createTestApp(
child: const SocialHubScreen(),
),
);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('social_card_feed')), findsOneWidget);
expect(find.text('Community Feed'), findsOneWidget);
});
testWidgets('Tapping INSPECT TELEMETRY GRAPH opens FeedTelemetryInspectorModal', (tester) async {
await tester.binding.setSurfaceSize(const Size(400, 1000));
final service = SocialFeedService();
await tester.pumpWidget(
_createTestApp(
overrides: [
socialFeedServiceProvider.overrideWithValue(service),
],
child: const SocialFeedScreen(),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
// Scroll so the card inspect telemetry button is visible
await tester.drag(find.byType(ListView), const Offset(0, -100));
await tester.pumpAndSettle();
final inspectBtn = find.text('INSPECT TELEMETRY GRAPH');
expect(inspectBtn, findsWidgets);
await tester.tap(inspectBtn.first);
await tester.pumpAndSettle();
// Verify inspector modal opened with metrics and tabs
expect(find.text('PEAK LEAN'), findsOneWidget);
expect(find.text('LEAN & APEX'), findsOneWidget);
expect(find.text('SPEED & G-FORCE'), findsOneWidget);
expect(find.text('OPEN FULL RIDE REPLAY'), findsOneWidget);
});
});
group('BUG-003: Share Recent Session Read-Only Telemetry Integrity Tests', () {
test(
'SocialFeedService.shareRecentRide enforces authoritativeRide telemetry over forged input parameters',
() async {
final service = SocialFeedService();
addTearDown(service.dispose);
final recordedRide = Ride(
id: 77,
startTime: DateTime(2026, 10, 10, 8, 0),
endTime: DateTime(2026, 10, 10, 8, 42),
distance: 31.4,
distanceMiles: 31.4,
movingTime: 2520, // 42 mins
stoppedTime: 0,
topSpeed: 74.5,
topSpeedMph: 74.5,
averageSpeed: 44.8,
maxLeanLeft: 47.0,
maxLeanRight: 39.0,
maxAccelG: 0.55,
maxBrakingG: 0.62,
elevationGain: 300.0, // ~984 ft
elevationLoss: 100.0,
);
final published = await service.shareRecentRide(
authorUid: 'uid_1',
authorDisplayName: 'Test Rider',
authorHandle: 'test_rider',
locationName: 'Highway 9',
rideTitle: 'Morning Run',
bikeModel: 'Yamaha R1',
// Attempt to pass forged telemetry values alongside authoritativeRide
distanceMiles: 999.9,
durationSeconds: 99999,
maxLeanAngleDeg: 88.0,
maxLeanSide: 'RIGHT',
elevationFt: 50000,
authoritativeRide: recordedRide,
routePoints: const [
AppLatLng(37.2500, -122.1200),
AppLatLng(37.2900, -122.1600),
],
);
expect(published.rideId, 77);
expect(published.distanceMiles, closeTo(31.4, 0.001));
expect(published.durationSeconds, 2520);
expect(published.maxLeanAngleDeg, closeTo(47.0, 0.001));
expect(published.maxLeanSide, 'LEFT');
expect(published.elevationFt, (300.0 * 3.28084).round());
},
);
testWidgets(
'ShareSessionModal renders telemetry as read-only tiles and publishes authoritative Ride values',
(tester) async {
await tester.binding.setSurfaceSize(const Size(400, 900));
final service = SocialFeedService();
addTearDown(service.dispose);
final recordedRide = Ride(
id: 42,
startTime: DateTime(2026, 10, 12, 9, 0),
endTime: DateTime(2026, 10, 12, 9, 42),
distance: 31.4,
distanceMiles: 31.4,
movingTime: 2520, // 42 mins
stoppedTime: 0,
topSpeed: 78.0,
topSpeedMph: 78.0,
averageSpeed: 45.2,
maxLeanLeft: 47.0,
maxLeanRight: 41.0,
maxAccelG: 0.5,
maxBrakingG: 0.6,
elevationGain: 450.0,
elevationLoss: 150.0,
motorcycleYear: 2024,
motorcycleMake: 'Aprilia',
motorcycleModel: 'RSV4 Factory',
);
await tester.pumpWidget(
_createTestApp(
overrides: [
socialFeedServiceProvider.overrideWithValue(service),
],
child: Builder(
builder: (context) => ElevatedButton(
onPressed: () => ShareSessionModal.show(
context,
initialRide: recordedRide,
),
child: const Text('Open Share Modal'),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Open Share Modal'));
await tester.pumpAndSettle();
// Only 3 editable TextFormFields exist (Title, Motorcycle Model, Location)
// Telemetry fields (Distance, Time, Lean) are NOT editable TextFormFields
expect(find.byType(TextFormField), findsNWidgets(3));
// Verify read-only telemetry tiles display the authoritative Ride metrics
expect(
find.byKey(const ValueKey('share_session_readonly_distance')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('share_session_readonly_duration')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('share_session_readonly_lean')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('share_session_readonly_lean_side')),
findsOneWidget,
);
expect(find.text('31.4'), findsOneWidget);
expect(find.text('42'), findsOneWidget);
expect(find.text('47°'), findsOneWidget);
expect(find.text('LEFT'), findsOneWidget);
// Edit metadata (Title) and publish
await tester.enterText(
find.byType(TextFormField).first,
'Verified Highway 9 Dawn Patrol',
);
await tester.tap(find.text('PUBLISH TO COMMUNITY FEED'));
await tester.pumpAndSettle();
final feedItems = await service.getFeedStream().first;
final latest = feedItems.first;
expect(latest.rideTitle, 'Verified Highway 9 Dawn Patrol');
expect(latest.distanceMiles, closeTo(31.4, 0.001));
expect(latest.durationSeconds, 2520);
expect(latest.maxLeanAngleDeg, closeTo(47.0, 0.001));
expect(latest.maxLeanSide, 'LEFT');
},
);
});
group('BUG-004: Published Ride Real Telemetry & GPS Verified Badge Tests', () {
test(
'SocialFeedItem.verifyGpsCriteria and hasVerifiedGps reject demo posts, forged JSON, and zero-displacement routes',
() {
// 1. Demo post with valid routePoints still must NOT be GPS verified
final demoPost = SocialFeedItem(
id: 'demo_1',
authorUid: 'uid_1',
authorDisplayName: 'Demo Rider',
authorHandle: 'demo',
locationName: 'Skyline',
timestamp: DateTime(2026, 10, 12),
rideTitle: 'Demo Ride',
bikeModel: 'R1',
distanceMiles: 40.0,
durationSeconds: 2400,
maxLeanAngleDeg: 45.0,
isDemo: true,
isGpsVerified: true,
routePoints: const [
AppLatLng(37.3861, -122.2641),
AppLatLng(37.2500, -122.1800),
],
);
expect(demoPost.hasVerifiedGps, isFalse);
// 2. Non-demo post with < 2 points or zero displacement (< 100m) must NOT be GPS verified
final stationaryPost = SocialFeedItem(
id: 'stat_1',
authorUid: 'uid_1',
authorDisplayName: 'Rider',
authorHandle: 'rider',
locationName: 'Garage',
timestamp: DateTime(2026, 10, 12),
rideTitle: 'Garage Idle',
bikeModel: 'R1',
distanceMiles: 5.0,
durationSeconds: 600,
maxLeanAngleDeg: 10.0,
isDemo: false,
isGpsVerified: true,
routePoints: const [
AppLatLng(37.38610, -122.26410),
AppLatLng(37.38611, -122.26411),
],
);
expect(stationaryPost.hasVerifiedGps, isFalse);
// 3. Untrusted Firestore JSON claiming isGpsVerified: true with empty routePoints is rejected
final forgedFromJson = SocialFeedItem.fromJson({
'id': 'forged_1',
'authorUid': 'uid_forger',
'authorDisplayName': 'Forger',
'authorHandle': 'forger',
'locationName': 'Nowhere',
'timestamp': '2026-10-12T10:00:00.000Z',
'rideTitle': 'Fake Verified Run',
'bikeModel': 'H2R',
'distanceMiles': 100.0,
'durationSeconds': 3600,
'maxLeanAngleDeg': 60.0,
'isDemo': false,
'isGpsVerified': true,
'routePoints': <Map<String, dynamic>>[],
});
expect(forgedFromJson.isGpsVerified, isFalse);
expect(forgedFromJson.hasVerifiedGps, isFalse);
// 4. Real non-demo ride with >= 2 distinct GPS coordinates (>= 100m), positive distance & duration is verified
final validPost = SocialFeedItem(
id: 'valid_1',
authorUid: 'uid_real',
authorDisplayName: 'Real Rider',
authorHandle: 'realrider',
locationName: 'Highway 9',
timestamp: DateTime(2026, 10, 12),
rideTitle: 'Real Canyon Run',
bikeModel: 'Street Triple RS',
distanceMiles: 24.5,
durationSeconds: 1800,
maxLeanAngleDeg: 44.0,
isDemo: false,
isGpsVerified: true,
routePoints: const [
AppLatLng(37.2500, -122.1200),
AppLatLng(37.2900, -122.1600),
],
);
expect(validPost.hasVerifiedGps, isTrue);
},
);
testWidgets(
'Unverified/demo feed posts hide GPS VERIFIED badge and show explicit empty telemetry state in inspector',
(tester) async {
await tester.binding.setSurfaceSize(const Size(400, 1000));
final service = SocialFeedService();
addTearDown(service.dispose);
await tester.pumpWidget(
_createTestApp(
overrides: [
socialFeedServiceProvider.overrideWithValue(service),
],
child: const SocialFeedScreen(),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
// Default feed items are demo items (isDemo: true), so GPS VERIFIED badge must NOT appear
expect(find.text('GPS VERIFIED'), findsNothing);
expect(find.text('NO GPS TRACK RECORDED'), findsWidgets);
// Open telemetry inspector on unverified/demo post
await tester.drag(find.byType(ListView), const Offset(0, -100));
await tester.pumpAndSettle();
await tester.tap(find.text('INSPECT TELEMETRY GRAPH').first);
await tester.pumpAndSettle();
// Must show explicit no-telemetry-samples state instead of synthetic sine wave
expect(
find.byKey(const ValueKey('inspector_no_telemetry_state')),
findsOneWidget,
);
expect(find.text('NO RECORDED TELEMETRY SAMPLES'), findsOneWidget);
expect(find.text('TELEMETRY TRACE UNAVAILABLE'), findsOneWidget);
},
);
testWidgets(
'Verified ride with recorded telemetrySamples renders GPS VERIFIED badge and real interpolated scrubber values',
(tester) async {
await tester.binding.setSurfaceSize(const Size(400, 1000));
final verifiedItem = SocialFeedItem(
id: 'verified_post_99',
rideId: 99,
authorUid: 'uid_verified',
authorDisplayName: 'Elena Rostova',
authorHandle: 'ElenaApex',
locationName: 'Angeles Crest Hwy',
timestamp: DateTime.now(),
rideTitle: 'Verified Morning Telemetry Run',
bikeModel: '2024 Aprilia Tuono V4',
distanceMiles: 20.0,
durationSeconds: 1500,
maxLeanAngleDeg: 46.0,
maxLeanSide: 'LEFT',
elevationFt: 1800,
isDemo: false,
isGpsVerified: true,
routePoints: const [
AppLatLng(34.2000, -118.1500),
AppLatLng(34.2400, -118.1100),
AppLatLng(34.2800, -118.0700),
],
telemetrySamples: const [
FeedTelemetrySample(
progress: 0.0,
speedMph: 35.0,
leanAngleDeg: 10.0,
lateralG: 0.18,
altitudeFt: 1200.0,
latitude: 34.2000,
longitude: -118.1500,
),
FeedTelemetrySample(
progress: 0.5,
speedMph: 63.0,
leanAngleDeg: -46.0,
lateralG: -0.84,
altitudeFt: 2400.0,
latitude: 34.2400,
longitude: -118.1100,
),
FeedTelemetrySample(
progress: 1.0,
speedMph: 42.0,
leanAngleDeg: 15.0,
lateralG: 0.25,
altitudeFt: 3000.0,
latitude: 34.2800,
longitude: -118.0700,
),
],
);
await tester.pumpWidget(
_createTestApp(
child: Builder(
builder: (context) => Column(
children: [
if (verifiedItem.hasVerifiedGps)
const Text('GPS VERIFIED'),
ElevatedButton(
onPressed: () =>
FeedTelemetryInspectorModal.show(context, verifiedItem),
child: const Text('Inspect Verified Ride'),
),
],
),
),
),
);
await tester.pumpAndSettle();
expect(verifiedItem.hasVerifiedGps, isTrue);
expect(find.text('GPS VERIFIED'), findsOneWidget);
await tester.tap(find.text('Inspect Verified Ride'));
await tester.pumpAndSettle();
// No empty-state overlay when real telemetry samples exist
expect(
find.byKey(const ValueKey('inspector_no_telemetry_state')),
findsNothing,
);
// At default _scrubFraction = 0.5, scrubber readout matches sample[1] (63 MPH, 46°, 0.84G)
expect(find.text('POINT: 10.0 MI (50%)'), findsOneWidget);
expect(find.text('63 MPH'), findsOneWidget);
expect(find.text('46°'), findsWidgets);
expect(find.text('0.84G'), findsOneWidget);
},
);
});
group('BUG-002: Social Feed Typography & Theme Contrast Tests', () {
double contrastRatio(Color fg, Color bg) {
final l1 = fg.computeLuminance();
final l2 = bg.computeLuminance();
final brightest = l1 > l2 ? l1 : l2;
final darkest = l1 > l2 ? l2 : l1;
return (brightest + 0.05) / (darkest + 0.05);
}
for (final isLight in [false, true]) {
testWidgets(
'SocialFeedScreen and FeedCommentsBottomSheet maintain WCAG AA (>= 4.5:1) contrast in ${isLight ? "Light" : "Dark"} mode',
(tester) async {
await tester.binding.setSurfaceSize(const Size(412, 915));
final themeColors = isLight
? MotorSignalThemeColors.light
: MotorSignalThemeColors.dark;
final baseTheme = isLight ? ThemeData.light() : ThemeData.dark();
final service = SocialFeedService();
addTearDown(service.dispose);
await tester.pumpWidget(
ProviderScope(
overrides: [
socialFeedServiceProvider.overrideWithValue(service),
],
child: MediaQuery(
data: const MediaQueryData(
size: Size(412, 915),
textScaler: TextScaler.linear(1.2),
),
child: MaterialApp(
theme: baseTheme.copyWith(
extensions: [themeColors],
),
home: const SocialFeedScreen(),
),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(tester.takeException(), isNull);
// 1. AppBar title contrast against canvas surface
final appBarTitle = tester.widget<Text>(find.text('Community Feed'));
expect(
contrastRatio(appBarTitle.style!.color!, themeColors.surfaceCanvas),
greaterThanOrEqualTo(4.5),
);
// 2. Card primary text (@authorHandle and rideTitle) against card primary surface
final handleText = tester.widget<Text>(find.text('@ApexMarcus'));
expect(
contrastRatio(
handleText.style!.color!,
themeColors.surfaceCardPrimary,
),
greaterThanOrEqualTo(4.5),
);
final rideTitleText = tester.widget<Text>(
find.text('Sunday Skyline & Pescadero Carve'),
);
expect(
contrastRatio(
rideTitleText.style!.color!,
themeColors.surfaceCardPrimary,
),
greaterThanOrEqualTo(4.5),
);
// 3. Card secondary text (clubName) against card primary surface
final clubText = tester.widget<Text>(
find.text('Bay Area Twisties Club'),
);
expect(
contrastRatio(
clubText.style!.color!,
themeColors.surfaceCardPrimary,
),
greaterThanOrEqualTo(4.5),
);
// 4. Filter tabs (selected vs unselected) contrast
final selectedFilterText = tester.widget<Text>(
find.text('All Activity'),
);
final selectedFilterBg = isLight
? const Color(0xFF0047B3)
: const Color(0xFF00E5FF);
expect(
contrastRatio(selectedFilterText.style!.color!, selectedFilterBg),
greaterThanOrEqualTo(4.5),
);
final unselectedFilterText = tester.widget<Text>(
find.text('Club Only'),
);
expect(
contrastRatio(
unselectedFilterText.style!.color!,
themeColors.surfaceCardSecondary,
),
greaterThanOrEqualTo(4.5),
);
// 5. Telemetry metric labels and values contrast against metric cell surface
final distLabel = tester.widget<Text>(find.text('DISTANCE').first);
expect(
contrastRatio(
distLabel.style!.color!,
themeColors.surfaceCardPrimary,
),
greaterThanOrEqualTo(4.5),
);
final leanValue = tester.widget<Text>(find.text('51°').first);
expect(
contrastRatio(
leanValue.style!.color!,
themeColors.surfaceCardPrimary,
),
greaterThanOrEqualTo(4.5),
);
},
);
}
});
group('BUG-005: GPX Export Snackbar Horizontal Overflow Tests', () {
for (final viewport in const [
Size(320, 640),
Size(360, 800),
Size(800, 480),
]) {
testWidgets(
'GPX export snackbar renders long rideTitle without RenderFlex overflow at ${viewport.width.toInt()}x${viewport.height.toInt()} (1.5x text scale)',
(tester) async {
await tester.binding.setSurfaceSize(viewport);
final service = SocialFeedService();
addTearDown(service.dispose);
const longRideTitle =
'Ultra Long Sunday Skyline Boulevard & Pescadero Creek Canyon Telemetry Endurance Carve Session With Full Squad Pack';
final post = await service.shareRecentRide(
authorUid: 'uid_overflow_test',
authorDisplayName: 'Marcus Vance',
authorHandle: 'ApexMarcus',
locationName: 'Skyline Blvd',
rideTitle: longRideTitle,
bikeModel: '2024 Ducati Streetfighter V4',
distanceMiles: 68.4,
durationSeconds: 4440,
maxLeanAngleDeg: 51.0,
);
await tester.pumpWidget(
ProviderScope(
overrides: [
socialFeedServiceProvider.overrideWithValue(service),
],
child: MediaQuery(
data: MediaQueryData(
size: viewport,
textScaler: const TextScaler.linear(1.5),
),
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: const [MotorSignalThemeColors.dark],
),
home: const SocialFeedScreen(),
),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
final gpxButton = find.byKey(
ValueKey('gpx_export_button_${post.id}'),
);
await tester.scrollUntilVisible(
gpxButton,
120.0,
scrollable: find.descendant(
of: find.byKey(const ValueKey('social_feed_list_view')),
matching: find.byType(Scrollable),
),
);
await tester.pumpAndSettle();
await tester.tap(gpxButton);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
// Must not throw any RenderFlex overflow exception
expect(tester.takeException(), isNull);
expect(
find.text('GPX track exported for $longRideTitle'),
findsOneWidget,
);
},
);
}
});
}