1724 lines
59 KiB
Dart
1724 lines
59 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('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);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|