MotorSignal-App/test/community_social_feed_test....

541 lines
19 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:motorcycle_dash/core/theme/theme_extensions.dart';
import 'package:motorcycle_dash/database/database.dart';
import 'package:motorcycle_dash/features/social/feed/social_feed_screen.dart';
import 'package:motorcycle_dash/features/social/feed/widgets/feed_comments_bottom_sheet.dart';
import 'package:motorcycle_dash/features/social/feed/widgets/share_session_modal.dart';
import 'package:motorcycle_dash/features/social/social_hub_screen.dart';
import 'package:motorcycle_dash/services/map/map_provider_interface.dart';
import 'package:motorcycle_dash/services/social/social_feed_models.dart';
import 'package:motorcycle_dash/services/social/social_feed_providers.dart';
import 'package:motorcycle_dash/services/social/social_feed_service.dart';
Widget _createTestApp({required Widget child, List<Override> overrides = const []}) {
return ProviderScope(
overrides: overrides,
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: const [
MotorSignalThemeColors.dark,
],
),
home: Scaffold(body: child),
),
);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('SocialFeed Models Unit Tests', () {
test('FeedFilter display names', () {
expect(FeedFilter.allActivity.displayName, 'All Activity');
expect(FeedFilter.clubOnly.displayName, 'Club Only');
expect(FeedFilter.nearbyPacks.displayName, 'Nearby Packs');
});
test('FeedPostComment toJson and fromJson roundtrip', () {
final comment = FeedPostComment(
id: 'c123',
authorUid: 'uid_test',
authorDisplayName: 'Test Rider',
authorHandle: 'testrider',
content: 'Great pace through section 2!',
createdAt: DateTime(2026, 9, 29, 10, 0),
);
final json = comment.toJson();
expect(json['id'], 'c123');
expect(json['authorUid'], 'uid_test');
expect(json['content'], 'Great pace through section 2!');
final parsed = FeedPostComment.fromJson({
'id': 'c123',
'authorUid': 'uid_test',
'authorDisplayName': 'Test Rider',
'authorHandle': 'testrider',
'content': 'Great pace through section 2!',
'createdAt': '2026-09-29T10:00:00.000',
});
expect(parsed.id, 'c123');
expect(parsed.authorDisplayName, 'Test Rider');
expect(parsed.content, 'Great pace through section 2!');
});
test('SocialFeedItem serialization and copyWith', () {
final item = SocialFeedItem(
id: 'feed_1',
authorUid: 'uid_1',
authorDisplayName: 'Apex Marcus',
authorHandle: 'ApexMarcus',
authorBadge: 'PRO',
clubName: 'Bay Area Twisties Club',
locationName: "Alice's Restaurant",
timestamp: DateTime(2026, 9, 29, 12, 0),
rideTitle: 'Sunday Skyline Carve',
bikeModel: '2024 Ducati Streetfighter V4',
distanceMiles: 68.4,
durationSeconds: 4440,
maxLeanAngleDeg: 51.0,
maxLeanSide: 'RIGHT',
paceFactor: '1.32x Spirited',
hazardCount: 0,
elevationFt: 2140,
snapshotType: FeedSnapshotType.routeTrack,
routePoints: const [AppLatLng(37.38, -122.26)],
fistBumpCount: 42,
fistBumpUserIds: const ['uid_other'],
comments: const [],
);
expect(item.isFistBumpedBy('uid_other'), isTrue);
expect(item.isFistBumpedBy('uid_nonexistent'), isFalse);
final updated = item.copyWith(fistBumpCount: 43);
expect(updated.fistBumpCount, 43);
expect(updated.id, 'feed_1');
final json = item.toJson();
expect(json['id'], 'feed_1');
expect(json['authorHandle'], 'ApexMarcus');
expect(json['distanceMiles'], 68.4);
expect(json['maxLeanAngleDeg'], 51.0);
});
});
group('SocialFeedService Unit Tests', () {
late SocialFeedService service;
setUp(() {
service = SocialFeedService();
});
tearDown(() {
service.dispose();
});
test('Initializes with default canonical items', () async {
final stream = service.getFeedStream(filter: FeedFilter.allActivity);
final items = await stream.first;
expect(items.length, greaterThanOrEqualTo(3));
expect(items.first.authorHandle, 'ApexMarcus');
});
test('Filters feed by clubOnly and nearbyPacks', () async {
final clubStream = service.getFeedStream(
filter: FeedFilter.clubOnly,
userClubName: 'Pacific Coast Riders',
);
final clubItems = await clubStream.first;
expect(clubItems.every((i) => i.clubName == 'Pacific Coast Riders'), isTrue);
final nearbyStream = service.getFeedStream(filter: FeedFilter.nearbyPacks);
final nearbyItems = await nearbyStream.first;
expect(nearbyItems.every((i) => i.distanceMiles < 60.0), isTrue);
});
test('Toggles fist bump reaction', () async {
final itemsBefore = await service.getFeedStream().first;
final target = itemsBefore.first;
final initialCount = target.fistBumpCount;
// Bump
await service.toggleFistBump(postId: target.id, userUid: 'test_rider_uid');
final itemsAfterBump = await service.getFeedStream().first;
final bumpedItem = itemsAfterBump.firstWhere((i) => i.id == target.id);
expect(bumpedItem.fistBumpCount, initialCount + 1);
expect(bumpedItem.isFistBumpedBy('test_rider_uid'), isTrue);
// Unbump
await service.toggleFistBump(postId: target.id, userUid: 'test_rider_uid');
final itemsAfterUnbump = await service.getFeedStream().first;
final unbumpedItem = itemsAfterUnbump.firstWhere((i) => i.id == target.id);
expect(unbumpedItem.fistBumpCount, initialCount);
expect(unbumpedItem.isFistBumpedBy('test_rider_uid'), isFalse);
});
test('Adds comment to post', () async {
final comment = FeedPostComment(
id: 'new_c',
authorUid: 'uid_test',
authorDisplayName: 'Dave S',
content: 'Clean line through section 2!',
createdAt: DateTime.now(),
);
await service.addComment(postId: 'feed_post_1', comment: comment);
final items = await service.getFeedStream().first;
final post = items.firstWhere((i) => i.id == 'feed_post_1');
expect(post.comments.any((c) => c.content == 'Clean line through section 2!'), isTrue);
});
test('Shares recent ride to community feed', () async {
final newPost = await service.shareRecentRide(
authorUid: 'uid_author',
authorDisplayName: 'Marcus Vance',
authorHandle: 'ApexMarcus',
locationName: 'Pescadero Creek',
rideTitle: 'Late Afternoon Run',
bikeModel: 'Ducati Panigale V4 S',
distanceMiles: 38.2,
durationSeconds: 2100,
maxLeanAngleDeg: 52.0,
);
expect(newPost.rideTitle, 'Late Afternoon Run');
expect(newPost.bikeModel, 'Ducati Panigale V4 S');
final items = await service.getFeedStream().first;
expect(items.first.id, newPost.id);
expect(items.first.rideTitle, 'Late Afternoon Run');
});
});
group('SocialFeedScreen & Sub-Widgets Widget Tests', () {
testWidgets('Renders SocialFeedScreen with header pills and tabs', (tester) async {
await tester.binding.setSurfaceSize(const Size(400, 800));
await tester.pumpWidget(
_createTestApp(
child: const SocialFeedScreen(),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(find.text('Community Feed'), findsOneWidget);
expect(find.text('SQUAD & CANYON FEED'), findsOneWidget);
expect(find.byKey(const ValueKey('feed_search_toggle_button')), findsOneWidget);
expect(find.text('All Activity'), findsOneWidget);
expect(find.text('Club Only'), findsOneWidget);
expect(find.text('Nearby Packs'), findsOneWidget);
expect(find.text('SHARE RECENT SESSION'), findsOneWidget);
// Verify Feed item contents
expect(find.text('@ApexMarcus'), findsOneWidget);
expect(find.text('Sunday Skyline & Pescadero Carve'), findsOneWidget);
expect(find.text('2024 Ducati Streetfighter V4'), findsOneWidget);
expect(find.text('51°'), findsOneWidget);
// Switch to Club Only tab
await tester.tap(find.text('Club Only'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
// Switch to Nearby Packs tab
await tester.tap(find.text('Nearby Packs'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
});
testWidgets('Tapping Fist Bump updates bump count with scale animation', (tester) async {
await tester.binding.setSurfaceSize(const Size(400, 800));
final service = SocialFeedService();
await tester.pumpWidget(
_createTestApp(
overrides: [
socialFeedServiceProvider.overrideWithValue(service),
],
child: const SocialFeedScreen(),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
// Scroll slightly so the card action bar clears the sticky button
await tester.drag(find.byType(ListView), const Offset(0, -180));
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(find.text('42 Bumps'), findsOneWidget);
// Tap fist bump
await tester.tap(find.byKey(const ValueKey('fist_bump_button_feed_post_1')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
expect(find.text('43 Bumps'), findsOneWidget);
});
testWidgets('Comments bottom sheet opens and displays comments', (tester) async {
await tester.binding.setSurfaceSize(const Size(400, 800));
final item = SocialFeedItem(
id: 'test_item',
authorUid: 'uid_1',
authorDisplayName: 'Sarah Chen',
authorHandle: 'SarahSweep',
locationName: 'Mulholland Hwy',
timestamp: DateTime.now(),
rideTitle: 'The Snake Pace Lap',
bikeModel: 'Yamaha MT-09 SP',
distanceMiles: 44.2,
durationSeconds: 2900,
maxLeanAngleDeg: 48.0,
comments: [
FeedPostComment(
id: 'c1',
authorUid: 'uid_2',
authorDisplayName: 'Marcus Vance',
authorHandle: 'ApexMarcus',
content: 'Great line through the esses!',
createdAt: DateTime.now(),
),
],
);
await tester.pumpWidget(
_createTestApp(
child: Builder(
builder: (context) => ElevatedButton(
onPressed: () => FeedCommentsBottomSheet.show(context, item),
child: const Text('Open Comments'),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Open Comments'));
await tester.pumpAndSettle();
expect(find.text('Rider Comments'), findsOneWidget);
expect(find.text('Great line through the esses!'), findsOneWidget);
expect(find.text('Marcus Vance'), findsOneWidget);
});
testWidgets('ShareSessionModal renders and allows form submission', (tester) async {
await tester.binding.setSurfaceSize(const Size(400, 900));
final service = SocialFeedService();
await tester.pumpWidget(
_createTestApp(
overrides: [
socialFeedServiceProvider.overrideWithValue(service),
],
child: Builder(
builder: (context) => ElevatedButton(
onPressed: () => ShareSessionModal.show(context),
child: const Text('Open Share Modal'),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Open Share Modal'));
await tester.pumpAndSettle();
expect(find.text('Share Recent Session'), findsOneWidget);
expect(find.text('PUBLISH TO COMMUNITY FEED'), findsOneWidget);
await tester.tap(find.text('PUBLISH TO COMMUNITY FEED'));
await tester.pumpAndSettle();
// Modal closed and snackbar shown
expect(find.text('Ride session published to Community Feed!'), findsOneWidget);
});
testWidgets('SocialHubScreen contains Community Feed card', (tester) async {
await tester.binding.setSurfaceSize(const Size(400, 800));
await tester.pumpWidget(
_createTestApp(
child: const SocialHubScreen(),
),
);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('social_card_feed')), findsOneWidget);
expect(find.text('Community Feed'), findsOneWidget);
});
testWidgets('Tapping INSPECT TELEMETRY GRAPH opens FeedTelemetryInspectorModal', (tester) async {
await tester.binding.setSurfaceSize(const Size(400, 1000));
final service = SocialFeedService();
await tester.pumpWidget(
_createTestApp(
overrides: [
socialFeedServiceProvider.overrideWithValue(service),
],
child: const SocialFeedScreen(),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
// Scroll so the card inspect telemetry button is visible
await tester.drag(find.byType(ListView), const Offset(0, -100));
await tester.pumpAndSettle();
final inspectBtn = find.text('INSPECT TELEMETRY GRAPH');
expect(inspectBtn, findsWidgets);
await tester.tap(inspectBtn.first);
await tester.pumpAndSettle();
// Verify inspector modal opened with metrics and tabs
expect(find.text('PEAK LEAN'), findsOneWidget);
expect(find.text('LEAN & APEX'), findsOneWidget);
expect(find.text('SPEED & G-FORCE'), findsOneWidget);
expect(find.text('OPEN FULL RIDE REPLAY'), findsOneWidget);
});
});
group('BUG-003: Share Recent Session Read-Only Telemetry Integrity Tests', () {
test(
'SocialFeedService.shareRecentRide enforces authoritativeRide telemetry over forged input parameters',
() async {
final service = SocialFeedService();
addTearDown(service.dispose);
final recordedRide = Ride(
id: 77,
startTime: DateTime(2026, 10, 10, 8, 0),
endTime: DateTime(2026, 10, 10, 8, 42),
distance: 31.4,
distanceMiles: 31.4,
movingTime: 2520, // 42 mins
stoppedTime: 0,
topSpeed: 74.5,
topSpeedMph: 74.5,
averageSpeed: 44.8,
maxLeanLeft: 47.0,
maxLeanRight: 39.0,
maxAccelG: 0.55,
maxBrakingG: 0.62,
elevationGain: 300.0, // ~984 ft
elevationLoss: 100.0,
);
final published = await service.shareRecentRide(
authorUid: 'uid_1',
authorDisplayName: 'Test Rider',
authorHandle: 'test_rider',
locationName: 'Highway 9',
rideTitle: 'Morning Run',
bikeModel: 'Yamaha R1',
// Attempt to pass forged telemetry values alongside authoritativeRide
distanceMiles: 999.9,
durationSeconds: 99999,
maxLeanAngleDeg: 88.0,
maxLeanSide: 'RIGHT',
elevationFt: 50000,
authoritativeRide: recordedRide,
routePoints: const [
AppLatLng(37.2500, -122.1200),
AppLatLng(37.2900, -122.1600),
],
);
expect(published.rideId, 77);
expect(published.distanceMiles, closeTo(31.4, 0.001));
expect(published.durationSeconds, 2520);
expect(published.maxLeanAngleDeg, closeTo(47.0, 0.001));
expect(published.maxLeanSide, 'LEFT');
expect(published.elevationFt, (300.0 * 3.28084).round());
},
);
testWidgets(
'ShareSessionModal renders telemetry as read-only tiles and publishes authoritative Ride values',
(tester) async {
await tester.binding.setSurfaceSize(const Size(400, 900));
final service = SocialFeedService();
addTearDown(service.dispose);
final recordedRide = Ride(
id: 42,
startTime: DateTime(2026, 10, 12, 9, 0),
endTime: DateTime(2026, 10, 12, 9, 42),
distance: 31.4,
distanceMiles: 31.4,
movingTime: 2520, // 42 mins
stoppedTime: 0,
topSpeed: 78.0,
topSpeedMph: 78.0,
averageSpeed: 45.2,
maxLeanLeft: 47.0,
maxLeanRight: 41.0,
maxAccelG: 0.5,
maxBrakingG: 0.6,
elevationGain: 450.0,
elevationLoss: 150.0,
motorcycleYear: 2024,
motorcycleMake: 'Aprilia',
motorcycleModel: 'RSV4 Factory',
);
await tester.pumpWidget(
_createTestApp(
overrides: [
socialFeedServiceProvider.overrideWithValue(service),
],
child: Builder(
builder: (context) => ElevatedButton(
onPressed: () => ShareSessionModal.show(
context,
initialRide: recordedRide,
),
child: const Text('Open Share Modal'),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Open Share Modal'));
await tester.pumpAndSettle();
// Only 3 editable TextFormFields exist (Title, Motorcycle Model, Location)
// Telemetry fields (Distance, Time, Lean) are NOT editable TextFormFields
expect(find.byType(TextFormField), findsNWidgets(3));
// Verify read-only telemetry tiles display the authoritative Ride metrics
expect(
find.byKey(const ValueKey('share_session_readonly_distance')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('share_session_readonly_duration')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('share_session_readonly_lean')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('share_session_readonly_lean_side')),
findsOneWidget,
);
expect(find.text('31.4'), findsOneWidget);
expect(find.text('42'), findsOneWidget);
expect(find.text('47°'), findsOneWidget);
expect(find.text('LEFT'), findsOneWidget);
// Edit metadata (Title) and publish
await tester.enterText(
find.byType(TextFormField).first,
'Verified Highway 9 Dawn Patrol',
);
await tester.tap(find.text('PUBLISH TO COMMUNITY FEED'));
await tester.pumpAndSettle();
final feedItems = await service.getFeedStream().first;
final latest = feedItems.first;
expect(latest.rideTitle, 'Verified Highway 9 Dawn Patrol');
expect(latest.distanceMiles, closeTo(31.4, 0.001));
expect(latest.durationSeconds, 2520);
expect(latest.maxLeanAngleDeg, closeTo(47.0, 0.001));
expect(latest.maxLeanSide, 'LEFT');
},
);
});
}