MotorSignal-App/test/create_club_screen_test.dart

354 lines
12 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/features/social/clubs/create_club_screen.dart';
import 'package:motorcycle_dash/services/auth/auth_providers.dart';
import 'package:motorcycle_dash/services/auth/auth_user.dart';
import 'package:motorcycle_dash/services/clubs/club_models.dart';
import 'package:motorcycle_dash/services/clubs/club_providers.dart';
import 'package:motorcycle_dash/services/clubs/club_service.dart';
import 'package:motorcycle_dash/services/clubs/club_slug_validator.dart';
class FakeCreateClubService extends ClubService {
final Set<String> takenSlugs;
Club? lastCreatedClub;
bool shouldFailSlugTaken;
FakeCreateClubService({
this.takenSlugs = const {},
this.shouldFailSlugTaken = false,
});
@override
Future<bool> isSlugAvailable(String slug) async {
final validation = ClubSlugValidator.validate(slug);
if (!validation.isValid) return false;
return !takenSlugs.contains(validation.normalizedSlug!);
}
@override
Future<Club> createClub({
required String name,
required String slug,
String description = '',
ClubVisibility visibility = ClubVisibility.public,
ClubAdmissionPolicy admissionPolicy = ClubAdmissionPolicy.request,
String? logoUrl,
String? bannerUrl,
String? homeRegion,
String? motorcycleFocus,
String? callerUid,
}) async {
if (shouldFailSlugTaken || takenSlugs.contains(slug)) {
throw const ClubOperationException(
'This club slug is already taken.',
code: 'slug-taken',
);
}
final club = Club(
id: 'created_club_123',
name: name,
slug: slug,
description: description,
visibility: visibility,
admissionPolicy: admissionPolicy,
ownerUid: callerUid ?? 'user_1',
memberCount: 1,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
homeRegion: homeRegion,
motorcycleFocus: motorcycleFocus,
);
lastCreatedClub = club;
return club;
}
}
void main() {
group('CreateClubScreen Widget Tests', () {
testWidgets('renders creation form and handles creation flow', (tester) async {
tester.view.physicalSize = const Size(1000, 2000);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
const user = AuthUser(uid: 'alice', isAnonymous: false);
final fakeService = FakeCreateClubService();
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => user),
clubServiceProvider.overrideWithValue(fakeService),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const CreateClubScreen(),
),
),
);
expect(find.text('Create Club'), findsWidgets);
expect(find.text('Found a Motorcycle Club'), findsOneWidget);
expect(find.text('Club Name *'), findsOneWidget);
expect(find.text('Club Handle / Slug *'), findsOneWidget);
// Fill in Name
await tester.enterText(
find.widgetWithText(TextFormField, 'Club Name *'),
'Smoky Mountain MC',
);
// Fill in Slug
await tester.enterText(
find.widgetWithText(TextFormField, 'Club Handle / Slug *'),
'smoky-mountain-mc',
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 500)); // debounce
expect(find.text('Handle is available'), findsOneWidget);
final submitButton = find.byKey(const ValueKey('create_club_submit_button'));
expect(submitButton, findsOneWidget);
await tester.tap(submitButton);
await tester.pump();
expect(fakeService.lastCreatedClub, isNotNull);
expect(fakeService.lastCreatedClub!.name, equals('Smoky Mountain MC'));
expect(fakeService.lastCreatedClub!.slug, equals('smoky-mountain-mc'));
});
testWidgets('handles slug-taken conflict gracefully', (tester) async {
tester.view.physicalSize = const Size(1000, 2000);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
const user = AuthUser(uid: 'alice', isAnonymous: false);
final fakeService = FakeCreateClubService(shouldFailSlugTaken: true);
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => user),
clubServiceProvider.overrideWithValue(fakeService),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const CreateClubScreen(),
),
),
);
await tester.enterText(
find.widgetWithText(TextFormField, 'Club Name *'),
'Contested Name',
);
await tester.enterText(
find.widgetWithText(TextFormField, 'Club Handle / Slug *'),
'contested-slug',
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
final submitButton = find.byKey(const ValueKey('create_club_submit_button'));
expect(submitButton, findsOneWidget);
await tester.tap(submitButton);
await tester.pumpAndSettle();
expect(
find.text('This handle was just taken by another club. Please pick a different handle.'),
findsOneWidget,
);
});
testWidgets('renders across responsive viewports and keyboard constraints without overflow', (tester) async {
const user = AuthUser(uid: 'alice', isAnonymous: false);
final fakeService = FakeCreateClubService();
const viewports = [
Size(412, 915), // Standard portrait
Size(915, 412), // Landscape
Size(412, 400), // Keyboard-constrained height
];
for (final vp in viewports) {
tester.view.physicalSize = vp;
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => user),
clubServiceProvider.overrideWithValue(fakeService),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const CreateClubScreen(),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull, reason: 'No layout overflow on $vp');
expect(find.text('Found a Motorcycle Club'), findsOneWidget);
expect(find.byType(ListView), findsOneWidget);
}
});
testWidgets('renders account required screen when unauthenticated', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => null),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const CreateClubScreen(),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Account Required'), findsOneWidget);
expect(
find.text('Sign in or create a MotorSignal account to join and manage riding clubs.'),
findsOneWidget,
);
expect(find.text('Sign In / Register'), findsOneWidget);
});
testWidgets(
'landscape (800x360) with keyboard inset scrolls to lower fields and submit button, and portrait renders cleanly (#77)',
(tester) async {
const user = AuthUser(uid: 'alice', isAnonymous: false);
final fakeService = FakeCreateClubService();
// 1. Landscape (800x360) with simulated soft keyboard inset
tester.view.physicalSize = const Size(800, 360);
tester.view.devicePixelRatio = 1.0;
tester.view.viewInsets = const FakeViewPadding(bottom: 180);
tester.view.padding = const FakeViewPadding(top: 24);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetViewInsets);
addTearDown(tester.view.resetPadding);
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => user),
clubServiceProvider.overrideWithValue(fakeService),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const CreateClubScreen(),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
// Scrollbar should be present for clear scroll affordance in landscape
expect(find.byType(Scrollbar), findsOneWidget);
// Two-column layout in landscape: Club Name and Club Handle share a row
final nameFinder = find.widgetWithText(
TextFormField,
'Club Name *',
skipOffstage: false,
);
final slugFinder = find.widgetWithText(
TextFormField,
'Club Handle / Slug *',
skipOffstage: false,
);
expect(nameFinder, findsOneWidget);
expect(slugFinder, findsOneWidget);
await tester.ensureVisible(nameFinder);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(
tester.getTopLeft(nameFinder).dx,
lessThan(tester.getTopLeft(slugFinder).dx),
);
// Enter Club Name & Handle
await tester.enterText(nameFinder, 'Canyon Carvers MC');
await tester.enterText(slugFinder, 'canyon-carvers-mc');
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(tester.takeException(), isNull);
// Lower fields and submit button remain mounted and scrollable into the visible area above keyboard
final regionFinder = find.widgetWithText(
TextFormField,
'Home region (optional)',
skipOffstage: false,
);
final focusFinder = find.widgetWithText(
TextFormField,
'Motorcycle focus (optional)',
skipOffstage: false,
);
final submitFinder = find.byKey(
const ValueKey('create_club_submit_button'),
skipOffstage: false,
);
expect(regionFinder, findsOneWidget);
expect(focusFinder, findsOneWidget);
expect(submitFinder, findsOneWidget);
await tester.ensureVisible(regionFinder);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(
tester.getRect(regionFinder).bottom,
lessThanOrEqualTo(360.0 - 180.0 + 1.0),
);
await tester.enterText(regionFinder, 'SoCal');
await tester.enterText(focusFinder, 'Sport bikes');
await tester.pump();
// Scroll to and tap the primary submit button while keyboard inset is active
await tester.ensureVisible(submitFinder);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(
tester.getRect(submitFinder).bottom,
lessThanOrEqualTo(360.0 - 180.0 + 1.0),
);
await tester.tap(submitFinder);
await tester.pump();
expect(fakeService.lastCreatedClub, isNotNull);
expect(fakeService.lastCreatedClub!.name, equals('Canyon Carvers MC'));
expect(fakeService.lastCreatedClub!.homeRegion, equals('SoCal'));
// 2. Portrait (400x800) with keyboard inset check
tester.view.physicalSize = const Size(400, 800);
tester.view.viewInsets = const FakeViewPadding(bottom: 280);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
},
);
});
}