354 lines
12 KiB
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);
|
|
},
|
|
);
|
|
});
|
|
}
|