MotorSignal-App/test/community_social_feed_test....

1940 lines
67 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/app_colors.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 [],
ThemeData? theme,
}) {
return ProviderScope(
overrides: overrides,
child: MaterialApp(
theme: theme ??
ThemeData.dark().copyWith(
colorScheme: const ColorScheme.dark(primary: AppColors.brandBlue),
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);
});
test('SocialFeedItem.fromJson preserves a missing location as empty', () {
final item = SocialFeedItem.fromJson({
'id': 'feed_missing_location',
'authorUid': 'uid_1',
'timestamp': '2026-10-17T12:00:00.000Z',
});
expect(item.locationName, isEmpty);
});
});
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 feed timestamps without fabricated empty locations', (tester) async {
await tester.binding.setSurfaceSize(const Size(400, 1000));
final service = SocialFeedService();
await service.shareRecentRide(
authorUid: 'uid_known_location',
authorDisplayName: 'Known Location Rider',
authorHandle: 'known_location',
locationName: 'Known Place',
rideTitle: 'Known Location Ride',
bikeModel: 'Test Bike',
);
await service.shareRecentRide(
authorUid: 'uid_empty_location',
authorDisplayName: 'Empty Location Rider',
authorHandle: 'empty_location',
locationName: '',
rideTitle: 'Empty Location Ride',
bikeModel: 'Test Bike',
);
await tester.pumpWidget(
_createTestApp(
overrides: [
socialFeedServiceProvider.overrideWithValue(service),
],
child: const SocialFeedScreen(),
),
);
await tester.pumpAndSettle();
expect(find.text('Just now'), findsOneWidget);
expect(find.text('Just now • Known Place'), findsOneWidget);
expect(find.textContaining('Location unavailable'), findsNothing);
});
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 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 when a ride is selected', (tester) async {
await tester.binding.setSurfaceSize(const Size(400, 900));
final service = SocialFeedService();
final ride = Ride(
id: 12,
startTime: DateTime(2026, 10, 12, 8, 0),
endTime: DateTime(2026, 10, 12, 8, 45),
distance: 28.5,
distanceMiles: 28.5,
movingTime: 2700,
stoppedTime: 0,
topSpeed: 72.0,
topSpeedMph: 72.0,
averageSpeed: 38.0,
maxLeanLeft: 44.0,
maxLeanRight: 41.0,
maxAccelG: 0.45,
maxBrakingG: 0.52,
elevationGain: 320.0,
elevationLoss: 110.0,
);
await tester.pumpWidget(
_createTestApp(
overrides: [
socialFeedServiceProvider.overrideWithValue(service),
],
child: Builder(
builder: (context) => ElevatedButton(
onPressed: () => ShareSessionModal.show(context, initialRide: ride),
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 (>= 80.47m), telemetry samples, 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),
],
telemetrySamples: [
FeedTelemetrySample(
progress: 0.0,
speedMph: 35.0,
leanAngleDeg: 18.0,
latitude: 37.2500,
longitude: -122.1200,
timestamp: DateTime(2026, 10, 12, 8, 0),
),
FeedTelemetrySample(
progress: 1.0,
speedMph: 48.0,
leanAngleDeg: 44.0,
latitude: 37.2900,
longitude: -122.1600,
timestamp: DateTime(2026, 10, 12, 8, 30),
),
],
);
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
? AppColors.brandBlueStrong
: AppColors.brandBlue;
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),
);
// 6. ShareSessionModal contrast in both Light and Dark modes
await tester.tap(
find.byKey(const ValueKey('feed_share_session_button')),
);
await tester.pumpAndSettle();
final shareModalTitle = tester.widget<Text>(
find.text('Share Recent Session'),
);
expect(
contrastRatio(
shareModalTitle.style!.color!,
themeColors.surfaceCardPrimary,
),
greaterThanOrEqualTo(4.5),
);
// Publish button is disabled when no recorded ride is selected
final publishBtn = tester.widget<ElevatedButton>(
find.widgetWithText(ElevatedButton, 'PUBLISH TO COMMUNITY FEED'),
);
expect(publishBtn.onPressed, isNull);
},
);
}
});
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,
);
},
);
}
});
group('Sprint 1 Remediation: BUG-005 / Issue #52 Landscape Overflow', () {
for (final viewport in const [
Size(800, 360),
Size(780, 360),
Size(840, 390),
]) {
testWidgets(
'Empty feed state renders without 90px bottom overflow in landscape ${viewport.width.toInt()}x${viewport.height.toInt()}',
(tester) async {
await tester.binding.setSurfaceSize(viewport);
final service = SocialFeedService();
addTearDown(service.dispose);
await tester.pumpWidget(
ProviderScope(
overrides: [
socialFeedServiceProvider.overrideWithValue(service),
],
child: MediaQuery(
data: MediaQueryData(size: viewport),
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: const [MotorSignalThemeColors.dark],
),
home: const SocialFeedScreen(),
),
),
),
);
await tester.pumpAndSettle();
// Must not throw RenderFlex bottom overflow
expect(tester.takeException(), isNull);
expect(find.byType(SingleChildScrollView), findsWidgets);
expect(find.byKey(const ValueKey('feed_share_session_button')), findsOneWidget);
},
);
}
});
group('Issue #71 Landscape Feed Floating Button Overlap', () {
for (final viewport in const [
Size(800, 360),
Size(640, 360),
Size(400, 800),
]) {
testWidgets(
'Empty feed message and action buttons are not covered by share button on ${viewport.width.toInt()}x${viewport.height.toInt()}',
(tester) async {
await tester.binding.setSurfaceSize(viewport);
addTearDown(() => tester.binding.setSurfaceSize(null));
final service = SocialFeedService();
addTearDown(service.dispose);
await tester.pumpWidget(
ProviderScope(
overrides: [
socialFeedServiceProvider.overrideWithValue(service),
feedSearchQueryProvider.overrideWith((ref) => 'nomatchxyz'),
],
child: MediaQuery(
data: MediaQueryData(size: viewport),
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: const [MotorSignalThemeColors.dark],
),
home: const SocialFeedScreen(),
),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
final shareBtnFinder = find.byKey(const ValueKey('feed_share_session_button'));
final emptyActionFinder = find.byKey(const ValueKey('empty_feed_group_rides_button'));
expect(shareBtnFinder, findsOneWidget);
expect(emptyActionFinder, findsOneWidget);
// Scroll until visible if needed in compact landscape
final scrollableFinder = find.byType(Scrollable).last;
await tester.scrollUntilVisible(emptyActionFinder, 100, scrollable: scrollableFinder);
await tester.pumpAndSettle();
final shareBtnRect = tester.getRect(shareBtnFinder);
final emptyActionRect = tester.getRect(emptyActionFinder);
expect(emptyActionRect.overlaps(shareBtnRect), isFalse,
reason: 'Empty action button $emptyActionRect overlaps share button $shareBtnRect');
expect(emptyActionRect.bottom <= shareBtnRect.top, isTrue,
reason: 'Empty action button bottom ${emptyActionRect.bottom} should be <= share button top ${shareBtnRect.top}');
},
);
testWidgets(
'Populated feed list scrolls so last item is above share button on ${viewport.width.toInt()}x${viewport.height.toInt()}',
(tester) async {
await tester.binding.setSurfaceSize(viewport);
addTearDown(() => tester.binding.setSurfaceSize(null));
final service = SocialFeedService();
addTearDown(service.dispose);
await tester.pumpWidget(
ProviderScope(
overrides: [
socialFeedServiceProvider.overrideWithValue(service),
],
child: MediaQuery(
data: MediaQueryData(size: viewport),
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: const [MotorSignalThemeColors.dark],
),
home: const SocialFeedScreen(),
),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
final shareBtnFinder = find.byKey(const ValueKey('feed_share_session_button'));
expect(shareBtnFinder, findsOneWidget);
// Scroll until the bottom actions of the last post are visible
final lastActionFinder = find.byKey(const ValueKey('inspect_telemetry_btn_feed_post_4'));
final feedListFinder = find.byKey(const ValueKey('social_feed_list_view'));
final feedScrollableFinder = find.descendant(
of: feedListFinder,
matching: find.byType(Scrollable),
);
await tester.scrollUntilVisible(
lastActionFinder,
100,
scrollable: feedScrollableFinder,
);
await tester.pumpAndSettle();
expect(lastActionFinder, findsOneWidget);
final lastActionRect = tester.getRect(lastActionFinder);
final shareBtnRect = tester.getRect(shareBtnFinder);
expect(lastActionRect.overlaps(shareBtnRect), isFalse,
reason: 'Last item bottom action $lastActionRect should not overlap share button $shareBtnRect');
expect(lastActionRect.bottom <= shareBtnRect.top, isTrue,
reason: 'Last item bottom action bottom ${lastActionRect.bottom} should be <= share button top ${shareBtnRect.top}');
},
);
}
});
group('ShareSessionModal Landscape Keyboard & Scroll Layout', () {
final testRide = Ride(
id: 99,
startTime: DateTime(2026, 10, 15, 9, 0),
endTime: DateTime(2026, 10, 15, 10, 15),
distance: 35.5,
distanceMiles: 35.5,
movingTime: 3600,
stoppedTime: 900,
topSpeed: 65.0,
topSpeedMph: 65.0,
averageSpeed: 35.5,
maxLeanLeft: 38.0,
maxLeanRight: 42.0,
maxAccelG: 0.6,
maxBrakingG: 0.7,
elevationGain: 1200.0,
elevationLoss: 1100.0,
);
for (final viewport in const [
Size(800, 360),
Size(640, 360),
Size(400, 800),
]) {
final isLandscape = viewport.width > viewport.height;
for (final keyboardInset in isLandscape ? [0.0, 180.0] : [0.0]) {
testWidgets(
'ShareSessionModal on ${viewport.width.toInt()}x${viewport.height.toInt()} with keyboard inset $keyboardInset has scrollable fields and publish button does not cover title',
(tester) async {
await tester.binding.setSurfaceSize(viewport);
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
ProviderScope(
child: MediaQuery(
data: MediaQueryData(
size: viewport,
viewInsets: EdgeInsets.only(bottom: keyboardInset),
),
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: const [MotorSignalThemeColors.dark],
),
home: Scaffold(
body: Builder(
builder: (ctx) => ElevatedButton(
key: const ValueKey('open_modal_btn'),
onPressed: () => ShareSessionModal.show(ctx, initialRide: testRide),
child: const Text('Open Modal'),
),
),
),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('open_modal_btn')));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
final titleFinder = find.byType(TextFormField).first;
expect(titleFinder, findsOneWidget);
final publishBtnFinder = find.widgetWithText(ElevatedButton, 'PUBLISH TO COMMUNITY FEED');
expect(publishBtnFinder, findsOneWidget);
// In landscape or when keyboard is open, title rect must not be overlapped by publish button rect
final titleRect = tester.getRect(titleFinder);
final publishRect = tester.getRect(publishBtnFinder);
expect(
titleRect.overlaps(publishRect),
isFalse,
reason: 'Title field $titleRect is covered by publish button $publishRect',
);
// Verify scrolling inside modal allows reaching the publish button and fields
final modalScrollable = find.descendant(
of: find.byType(ShareSessionModal),
matching: find.byType(Scrollable),
);
expect(modalScrollable, findsWidgets);
await tester.scrollUntilVisible(
publishBtnFinder,
100,
scrollable: modalScrollable.first,
);
await tester.pumpAndSettle();
expect(publishBtnFinder.hitTestable(), findsOneWidget);
},
);
}
}
});
group('Sprint 1 Remediation: BUG-003 / Sub-Issue #81 Starting Location & Canyon Removal', () {
testWidgets('ShareSessionModal displays STARTING LOCATION, removes Canyon label, and defaults to Morning Ride', (tester) async {
await tester.binding.setSurfaceSize(const Size(400, 800));
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: const [MotorSignalThemeColors.dark],
),
home: const Scaffold(
body: ShareSessionModal(),
),
),
),
);
await tester.pumpAndSettle();
// Check STARTING LOCATION label exists and LOCATION / CANYON is gone
expect(find.text('STARTING LOCATION'), findsOneWidget);
expect(find.text('LOCATION / CANYON'), findsNothing);
// Check title defaults to Morning Ride (not Morning Canyon Carve)
expect(find.text('Morning Ride'), findsOneWidget);
expect(find.text('Morning Canyon Carve'), findsNothing);
// Check starting location initial fallback is Location unavailable (privacy-safe)
expect(find.text('Location unavailable'), findsOneWidget);
expect(find.text('Skyline Blvd & Highway 9'), findsNothing);
});
});
group('PR #78 Review Threads Regression Suite', () {
test(
'Thread 1: verifyGpsCriteria and shareRecentRide reject empty or zero-span telemetrySamples',
() async {
final service = SocialFeedService();
addTearDown(service.dispose);
final recordedRide = Ride(
id: 101,
startTime: DateTime(2026, 10, 15, 9, 0),
endTime: DateTime(2026, 10, 15, 9, 30),
distance: 18.4,
distanceMiles: 18.4,
movingTime: 1800,
stoppedTime: 0,
topSpeed: 64.0,
topSpeedMph: 64.0,
averageSpeed: 36.8,
maxLeanLeft: 38.0,
maxLeanRight: 35.0,
maxAccelG: 0.4,
maxBrakingG: 0.5,
elevationGain: 210.0,
elevationLoss: 90.0,
);
// Reachable via shareRecentRide when telemetrySamples defaults to empty
final publishedEmptySamples = await service.shareRecentRide(
authorUid: 'uid_t1',
authorDisplayName: 'Rider One',
authorHandle: 'rider_one',
locationName: 'Saratoga, CA',
rideTitle: 'Morning Ride',
bikeModel: 'Yamaha MT-09',
authoritativeRide: recordedRide,
routePoints: const [
AppLatLng(37.2500, -122.1200),
AppLatLng(37.2900, -122.1600),
],
telemetrySamples: const [],
);
expect(publishedEmptySamples.isGpsVerified, isFalse);
expect(publishedEmptySamples.hasVerifiedGps, isFalse);
// Direct model construction with empty telemetrySamples
final itemEmptySamples = SocialFeedItem(
id: 't1_empty',
authorUid: 'uid_t1',
authorDisplayName: 'Rider One',
authorHandle: 'rider_one',
locationName: 'Saratoga, CA',
timestamp: DateTime(2026, 10, 15, 9, 30),
rideTitle: 'Morning Ride',
bikeModel: 'Yamaha MT-09',
distanceMiles: 18.4,
durationSeconds: 1800,
maxLeanAngleDeg: 38.0,
isDemo: false,
isGpsVerified: true,
routePoints: const [
AppLatLng(37.2500, -122.1200),
AppLatLng(37.2900, -122.1600),
],
telemetrySamples: const [],
);
expect(itemEmptySamples.hasVerifiedGps, isFalse);
// Zero recorded progress span across samples must also be rejected
final itemZeroSpan = itemEmptySamples.copyWith(
id: 't1_zero_span',
telemetrySamples: const [
FeedTelemetrySample(
progress: 0.0,
speedMph: 35.0,
leanAngleDeg: 12.0,
latitude: 37.2500,
longitude: -122.1200,
),
FeedTelemetrySample(
progress: 0.0,
speedMph: 40.0,
leanAngleDeg: 18.0,
latitude: 37.2900,
longitude: -122.1600,
),
],
);
expect(itemZeroSpan.hasVerifiedGps, isFalse);
// Zero recorded timestamp span across samples (identical timestamps) must also be rejected
final sameInstant = DateTime(2026, 10, 15, 9, 0, 0);
final itemZeroTimestampSpan = itemEmptySamples.copyWith(
id: 't1_zero_ts_span',
telemetrySamples: [
FeedTelemetrySample(
progress: 0.0,
speedMph: 35.0,
leanAngleDeg: 12.0,
latitude: 37.2500,
longitude: -122.1200,
timestamp: sameInstant,
),
FeedTelemetrySample(
progress: 1.0,
speedMph: 40.0,
leanAngleDeg: 18.0,
latitude: 37.2900,
longitude: -122.1600,
timestamp: sameInstant,
),
],
);
expect(itemZeroTimestampSpan.hasVerifiedGps, isFalse);
},
);
test(
'Thread 2: verifyGpsCriteria measures 0.05-mile (~80.47m) displacement geodesically including longitude-at-latitude',
() {
// Case A: 0.0003 deg on each axis at equator (~47.2m < 80.47m) must fail
final equatorialSubThreshold = SocialFeedItem.verifyGpsCriteria(
isDemo: false,
distanceMiles: 1.0,
durationSeconds: 180,
routePoints: const [
AppLatLng(0.0, 0.0),
AppLatLng(0.0003, 0.0003),
],
telemetrySamples: const [
FeedTelemetrySample(
progress: 0.0,
speedMph: 20.0,
leanAngleDeg: 5.0,
latitude: 0.0,
longitude: 0.0,
),
FeedTelemetrySample(
progress: 1.0,
speedMph: 25.0,
leanAngleDeg: 8.0,
latitude: 0.0003,
longitude: 0.0003,
),
],
);
expect(equatorialSubThreshold, isFalse);
// Case B: 0.0010 deg longitude delta at 60°N (~55.6m < 80.47m) must fail
final highLatLongitudeSubThreshold = SocialFeedItem.verifyGpsCriteria(
isDemo: false,
distanceMiles: 1.0,
durationSeconds: 180,
routePoints: const [
AppLatLng(60.0, -149.0000),
AppLatLng(60.0, -149.0010),
],
telemetrySamples: const [
FeedTelemetrySample(
progress: 0.0,
speedMph: 20.0,
leanAngleDeg: 5.0,
latitude: 60.0,
longitude: -149.0000,
),
FeedTelemetrySample(
progress: 1.0,
speedMph: 25.0,
leanAngleDeg: 8.0,
latitude: 60.0,
longitude: -149.0010,
),
],
);
expect(highLatLongitudeSubThreshold, isFalse);
// Case C: 0.0020 deg longitude delta at 60°N (~111.2m >= 80.47m) passes
final highLatAboveThreshold = SocialFeedItem.verifyGpsCriteria(
isDemo: false,
distanceMiles: 1.0,
durationSeconds: 180,
routePoints: const [
AppLatLng(60.0, -149.0000),
AppLatLng(60.0, -149.0020),
],
telemetrySamples: const [
FeedTelemetrySample(
progress: 0.0,
speedMph: 20.0,
leanAngleDeg: 5.0,
latitude: 60.0,
longitude: -149.0000,
),
FeedTelemetrySample(
progress: 1.0,
speedMph: 25.0,
leanAngleDeg: 8.0,
latitude: 60.0,
longitude: -149.0020,
),
],
);
expect(highLatAboveThreshold, isTrue);
},
);
testWidgets(
'Thread 3: ShareSessionModal excludes stopped-frame lean (< 3.0 MPH gate) from published telemetrySamples',
(tester) async {
await tester.binding.setSurfaceSize(const Size(400, 900));
final service = SocialFeedService();
addTearDown(service.dispose);
final ride = Ride(
id: 303,
startTime: DateTime(2026, 10, 16, 10, 0),
endTime: DateTime(2026, 10, 16, 10, 20),
distance: 12.5,
distanceMiles: 12.5,
movingTime: 1200,
stoppedTime: 60,
topSpeed: 58.0,
topSpeedMph: 58.0,
averageSpeed: 37.5,
maxLeanLeft: 32.0,
maxLeanRight: 28.0,
maxAccelG: 0.35,
maxBrakingG: 0.42,
elevationGain: 180.0,
elevationLoss: 60.0,
);
final rawSamples = [
// Stopped kickstand frame (0.0 MPH, -54° lean)
TelemetrySample(
id: 1,
rideId: 303,
timestamp: DateTime(2026, 10, 16, 10, 0, 0),
latitude: 37.2500,
longitude: -122.1200,
altitude: 100.0,
speed: 0.0,
heading: 90.0,
gpsAccuracy: 4.0,
leanAngle: -54.0,
pitchAngle: 0.0,
accelX: 0.0,
accelY: 0.0,
accelZ: 1.0,
longitudinalG: 0.0,
lateralG: 0.0,
),
// Moving corner apex (48.0 MPH, -32° lean)
TelemetrySample(
id: 2,
rideId: 303,
timestamp: DateTime(2026, 10, 16, 10, 10, 0),
latitude: 37.2700,
longitude: -122.1400,
altitude: 150.0,
speed: 48.0,
heading: 110.0,
gpsAccuracy: 4.0,
leanAngle: -32.0,
pitchAngle: 1.0,
accelX: 0.1,
accelY: -0.6,
accelZ: 1.0,
longitudinalG: 0.1,
lateralG: -0.62,
),
// Moving corner exit (42.0 MPH, +28° lean)
TelemetrySample(
id: 3,
rideId: 303,
timestamp: DateTime(2026, 10, 16, 10, 15, 0),
latitude: 37.2850,
longitude: -122.1550,
altitude: 170.0,
speed: 42.0,
heading: 130.0,
gpsAccuracy: 4.0,
leanAngle: 28.0,
pitchAngle: 0.5,
accelX: 0.05,
accelY: 0.5,
accelZ: 1.0,
longitudinalG: 0.05,
lateralG: 0.52,
),
// Sub-3.0 MPH dismount artifact (1.8 MPH, +58° lean)
TelemetrySample(
id: 4,
rideId: 303,
timestamp: DateTime(2026, 10, 16, 10, 20, 0),
latitude: 37.2900,
longitude: -122.1600,
altitude: 180.0,
speed: 1.8,
heading: 135.0,
gpsAccuracy: 4.0,
leanAngle: 58.0,
pitchAngle: 0.0,
accelX: 0.0,
accelY: 0.0,
accelZ: 1.0,
longitudinalG: 0.0,
lateralG: 0.0,
),
];
await tester.pumpWidget(
_createTestApp(
overrides: [
socialFeedServiceProvider.overrideWithValue(service),
],
child: Builder(
builder: (context) => ElevatedButton(
onPressed: () => ShareSessionModal.show(
context,
initialRide: ride,
initialTelemetrySamples: rawSamples,
),
child: const Text('Open Share Modal'),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Open Share Modal'));
await tester.pumpAndSettle();
await tester.tap(find.text('PUBLISH TO COMMUNITY FEED'));
await tester.pumpAndSettle();
final feedItems = await service.getFeedStream().first;
final published = feedItems.first;
expect(published.maxLeanAngleDeg, closeTo(32.0, 0.001));
expect(published.telemetrySamples, hasLength(4));
// Stopped frames (< 3.0 MPH) must have lean gated to 0.0°
expect(published.telemetrySamples[0].leanAngleDeg, 0.0);
expect(published.telemetrySamples[3].leanAngleDeg, 0.0);
// Moving frames (>= 3.0 MPH) preserve their recorded lean angles
expect(published.telemetrySamples[1].leanAngleDeg, closeTo(-32.0, 0.001));
expect(published.telemetrySamples[2].leanAngleDeg, closeTo(28.0, 0.001));
// Max apex across the published trace matches the authoritative 32° max
final maxTraceLean = published.telemetrySamples
.map((s) => s.leanAngleDeg.abs())
.reduce((a, b) => a > b ? a : b);
expect(maxTraceLean, closeTo(32.0, 0.001));
},
);
});
group('Issue #49 UI Color & Theme Alignment Tests', () {
final darkTheme = ThemeData.dark().copyWith(
colorScheme: const ColorScheme.dark(primary: AppColors.brandBlue),
extensions: const [MotorSignalThemeColors.dark],
);
final lightTheme = ThemeData.light().copyWith(
colorScheme: const ColorScheme.light(primary: AppColors.brandBlueStrong),
extensions: const [MotorSignalThemeColors.light],
);
testWidgets('ShareSessionModal primary publish button uses brand primary color in dark mode', (tester) async {
await tester.binding.setSurfaceSize(const Size(400, 900));
final ride = Ride(
id: 1,
startTime: DateTime.now().subtract(const Duration(minutes: 30)),
endTime: DateTime.now(),
distance: 12.5,
distanceMiles: 12.5,
movingTime: 1800,
stoppedTime: 0,
topSpeed: 55.0,
topSpeedMph: 55.0,
averageSpeed: 38.0,
maxLeanLeft: 28.0,
maxLeanRight: 25.0,
maxAccelG: 0.45,
maxBrakingG: 0.52,
elevationGain: 320.0,
elevationLoss: 110.0,
);
await tester.pumpWidget(
_createTestApp(
theme: darkTheme,
child: Builder(
builder: (context) => ElevatedButton(
onPressed: () => ShareSessionModal.show(context, initialRide: ride),
child: const Text('Open Modal Dark'),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Open Modal Dark'));
await tester.pumpAndSettle();
final publishBtnFinder = find.widgetWithText(ElevatedButton, 'PUBLISH TO COMMUNITY FEED');
expect(publishBtnFinder, findsOneWidget);
final darkPublishBtn = tester.widget<ElevatedButton>(publishBtnFinder);
expect(darkPublishBtn.style?.backgroundColor?.resolve({}), AppColors.brandBlue);
expect(darkPublishBtn.style?.foregroundColor?.resolve({}), AppColors.onBrand);
// Check rendered text colors in dark mode modal
for (final text in tester.widgetList<Text>(find.byType(Text))) {
final color = text.style?.color;
if (color != null) {
expect(color == const Color(0xFF00272E), isFalse);
expect(color == const Color(0xFF00363D), isFalse);
expect(color == const Color(0xFFBAC9CC), isFalse);
expect(color == const Color(0xFFC3F5FF), isFalse);
expect(color == AppColors.electricCyan, isFalse);
}
}
});
testWidgets('ShareSessionModal primary publish button uses brand primary color in light mode', (tester) async {
await tester.binding.setSurfaceSize(const Size(400, 900));
final ride = Ride(
id: 2,
startTime: DateTime.now().subtract(const Duration(minutes: 30)),
endTime: DateTime.now(),
distance: 12.5,
distanceMiles: 12.5,
movingTime: 1800,
stoppedTime: 0,
topSpeed: 55.0,
topSpeedMph: 55.0,
averageSpeed: 38.0,
maxLeanLeft: 28.0,
maxLeanRight: 25.0,
maxAccelG: 0.45,
maxBrakingG: 0.52,
elevationGain: 320.0,
elevationLoss: 110.0,
);
await tester.pumpWidget(
_createTestApp(
theme: lightTheme,
child: Builder(
builder: (context) => ElevatedButton(
onPressed: () => ShareSessionModal.show(context, initialRide: ride),
child: const Text('Open Modal Light'),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Open Modal Light'));
await tester.pumpAndSettle();
final lightPublishBtnFinder = find.widgetWithText(ElevatedButton, 'PUBLISH TO COMMUNITY FEED');
expect(lightPublishBtnFinder, findsOneWidget);
final lightPublishBtn = tester.widget<ElevatedButton>(lightPublishBtnFinder);
expect(lightPublishBtn.style?.backgroundColor?.resolve({}), AppColors.brandBlueStrong);
expect(lightPublishBtn.style?.foregroundColor?.resolve({}), AppColors.onBrand);
// Check rendered text colors in light mode modal
for (final text in tester.widgetList<Text>(find.byType(Text))) {
final color = text.style?.color;
if (color != null) {
expect(color == const Color(0xFF00272E), isFalse);
expect(color == const Color(0xFF00363D), isFalse);
expect(color == const Color(0xFFBAC9CC), isFalse);
expect(color == const Color(0xFFC3F5FF), isFalse);
expect(color == AppColors.electricCyan, isFalse);
}
}
});
testWidgets('SocialFeedScreen header and sticky button use brand primary and theme colors in dark and light mode', (tester) async {
await tester.binding.setSurfaceSize(const Size(400, 900));
// Dark theme test
await tester.pumpWidget(
_createTestApp(
theme: darkTheme,
child: const SocialFeedScreen(),
),
);
await tester.pumpAndSettle();
final darkShareBtnFinder = find.byKey(const ValueKey('feed_share_session_button'));
expect(darkShareBtnFinder, findsOneWidget);
final darkShareBtn = tester.widget<ElevatedButton>(darkShareBtnFinder);
expect(darkShareBtn.style?.foregroundColor?.resolve({}), AppColors.onBrand);
// Verify selected filter tab uses brandBlue in dark theme
final allActivityText = find.text('All Activity');
expect(allActivityText, findsOneWidget);
final animatedContainers = tester.widgetList<AnimatedContainer>(find.byType(AnimatedContainer));
final selectedTabContainer = animatedContainers.firstWhere((c) {
final box = c.decoration as BoxDecoration?;
return box?.color == AppColors.brandBlue;
});
expect(selectedTabContainer, isNotNull);
// Verify no rendered text widget uses old cyan/navy literals
for (final text in tester.widgetList<Text>(find.byType(Text))) {
final color = text.style?.color;
if (color != null) {
expect(color == const Color(0xFF00272E), isFalse);
expect(color == const Color(0xFF00363D), isFalse);
expect(color == const Color(0xFFBAC9CC), isFalse);
expect(color == const Color(0xFFC3F5FF), isFalse);
expect(color == AppColors.electricCyan, isFalse);
}
}
// Light theme test
await tester.pumpWidget(
_createTestApp(
theme: lightTheme,
child: const SocialFeedScreen(),
),
);
await tester.pumpAndSettle();
final lightShareBtnFinder = find.byKey(const ValueKey('feed_share_session_button'));
expect(lightShareBtnFinder, findsOneWidget);
final lightShareBtn = tester.widget<ElevatedButton>(lightShareBtnFinder);
expect(lightShareBtn.style?.foregroundColor?.resolve({}), AppColors.onBrand);
// Verify selected filter tab uses brandBlueStrong in light theme
final lightAnimatedContainers = tester.widgetList<AnimatedContainer>(find.byType(AnimatedContainer));
final lightSelectedTabContainer = lightAnimatedContainers.firstWhere((c) {
final box = c.decoration as BoxDecoration?;
return box?.color == AppColors.brandBlueStrong;
});
expect(lightSelectedTabContainer, isNotNull);
// Verify no rendered text widget uses old cyan/navy literals
for (final text in tester.widgetList<Text>(find.byType(Text))) {
final color = text.style?.color;
if (color != null) {
expect(color == const Color(0xFF00272E), isFalse);
expect(color == const Color(0xFF00363D), isFalse);
expect(color == const Color(0xFFBAC9CC), isFalse);
expect(color == const Color(0xFFC3F5FF), isFalse);
expect(color == AppColors.electricCyan, isFalse);
}
}
});
});
}