389 lines
12 KiB
Dart
389 lines
12 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:motorcycle_dash/core/theme/theme.dart';
|
|
import 'package:motorcycle_dash/core/theme/theme_mode_provider.dart';
|
|
import 'package:motorcycle_dash/features/settings/settings_screen.dart';
|
|
import 'package:motorcycle_dash/features/settings/account_screen.dart';
|
|
import 'package:motorcycle_dash/features/entitlements/pro_screen.dart';
|
|
import 'package:motorcycle_dash/services/auth/auth_providers.dart';
|
|
import 'package:motorcycle_dash/services/auth/auth_service.dart';
|
|
import 'package:motorcycle_dash/services/auth/auth_state.dart';
|
|
import 'package:motorcycle_dash/services/auth/auth_user.dart';
|
|
import 'package:motorcycle_dash/services/billing/billing_providers.dart';
|
|
import 'package:motorcycle_dash/services/billing/fake_billing_service.dart';
|
|
import 'package:motorcycle_dash/services/entitlements/entitlement_providers.dart';
|
|
import 'package:motorcycle_dash/services/entitlements/entitlement_storage.dart';
|
|
import 'package:motorcycle_dash/database/database.dart';
|
|
import 'package:motorcycle_dash/features/records/records_service.dart';
|
|
import 'package:drift/native.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
class _FakeAuth implements AuthService {
|
|
@override Future<void> ensureBackendIdentity() async {}
|
|
|
|
final _states = StreamController<AuthState>.broadcast();
|
|
AuthUser? user;
|
|
|
|
_FakeAuth({this.user});
|
|
|
|
@override
|
|
Stream<AuthState> get authStateChanges => _states.stream;
|
|
|
|
@override
|
|
AuthState get currentState => user == null
|
|
? const AuthState.unauthenticated(isFirebaseConfigured: true)
|
|
: (user!.isAnonymous
|
|
? AuthState.anonymous(user!, isFirebaseConfigured: true)
|
|
: AuthState.authenticated(user!, isFirebaseConfigured: true));
|
|
|
|
@override
|
|
AuthUser? get currentUser => user;
|
|
|
|
@override
|
|
bool get isConfigured => true;
|
|
|
|
@override
|
|
bool get isEmailVerified => user?.emailVerified ?? false;
|
|
|
|
@override
|
|
Future<void> deleteCurrentUser() async {
|
|
user = null;
|
|
_states.add(currentState);
|
|
}
|
|
|
|
@override
|
|
void dispose() => _states.close();
|
|
|
|
@override
|
|
Future<String?> getIdToken([bool forceRefresh = false]) async => 'token';
|
|
|
|
@override
|
|
Future<void> reauthenticate({String? password}) async {}
|
|
|
|
@override
|
|
Future<void> reloadCurrentUser() async {}
|
|
|
|
@override
|
|
Future<void> sendEmailVerification() async {}
|
|
|
|
@override
|
|
Future<void> sendPasswordResetEmail(String email) async {}
|
|
|
|
@override
|
|
Future<void> signInAnonymously() async {
|
|
user = const AuthUser(
|
|
uid: 'anon-123',
|
|
email: null,
|
|
isAnonymous: true,
|
|
providerId: 'anonymous',
|
|
);
|
|
_states.add(currentState);
|
|
}
|
|
|
|
@override
|
|
Future<void> signInWithEmailAndPassword(String email, String password) async {
|
|
user = AuthUser(
|
|
uid: 'user-123',
|
|
email: email,
|
|
isAnonymous: false,
|
|
providerId: 'password',
|
|
);
|
|
_states.add(currentState);
|
|
}
|
|
|
|
@override
|
|
Future<void> createUserWithEmailAndPassword(String email, String password,
|
|
{String? displayName}) async {
|
|
user = AuthUser(
|
|
uid: 'user-123',
|
|
email: email,
|
|
displayName: displayName,
|
|
isAnonymous: false,
|
|
providerId: 'password',
|
|
);
|
|
_states.add(currentState);
|
|
}
|
|
|
|
@override
|
|
Future<void> signInWithGoogle() async {
|
|
user = const AuthUser(
|
|
uid: 'google-123',
|
|
email: 'test@motorcycle.app',
|
|
displayName: 'Test Rider',
|
|
isAnonymous: false,
|
|
providerId: 'google.com',
|
|
);
|
|
_states.add(currentState);
|
|
}
|
|
|
|
@override
|
|
Future<void> signOut() async {
|
|
user = null;
|
|
_states.add(currentState);
|
|
}
|
|
}
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
final canonicalViewports = <String, Size>{
|
|
'320x640 (very narrow phone)': const Size(320, 640),
|
|
'400x800 (standard phone)': const Size(400, 800),
|
|
'412x915 (tall modern phone)': const Size(412, 915),
|
|
'640x320 (short landscape)': const Size(640, 320),
|
|
'780x360 (wide landscape)': const Size(780, 360),
|
|
'800x360 (wide landscape)': const Size(800, 360),
|
|
'840x390 (ultra-wide landscape)': const Size(840, 390),
|
|
'768x1024 (portrait tablet)': const Size(768, 1024),
|
|
'1024x768 (landscape tablet)': const Size(1024, 768),
|
|
};
|
|
|
|
Widget buildTestHost({
|
|
required Widget child,
|
|
required SharedPreferences prefs,
|
|
required AppDatabase db,
|
|
_FakeAuth? auth,
|
|
FakeBillingService? billing,
|
|
}) {
|
|
final fakeAuth = auth ?? _FakeAuth();
|
|
final fakeBilling = billing ?? FakeBillingService();
|
|
|
|
return ProviderScope(
|
|
overrides: [
|
|
sharedPreferencesProvider.overrideWithValue(prefs),
|
|
databaseProvider.overrideWithValue(db),
|
|
authServiceProvider.overrideWithValue(fakeAuth),
|
|
billingServiceProvider.overrideWithValue(fakeBilling),
|
|
entitlementStorageProvider.overrideWithValue(InMemoryEntitlementStorage()),
|
|
],
|
|
child: Consumer(
|
|
builder: (context, ref, _) {
|
|
final themeMode = ref.watch(appThemeModeProvider);
|
|
return MaterialApp(
|
|
themeMode: themeMode,
|
|
theme: AppTheme.lightTheme,
|
|
darkTheme: AppTheme.darkTheme,
|
|
home: child,
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
group('Canonical 9-Viewport SettingsScreen Responsive Tests', () {
|
|
for (final entry in canonicalViewports.entries) {
|
|
testWidgets('renders cleanly on ${entry.key}', (tester) async {
|
|
tester.view.physicalSize = entry.value;
|
|
tester.view.devicePixelRatio = 1.0;
|
|
addTearDown(() => tester.view.resetPhysicalSize());
|
|
|
|
SharedPreferences.setMockInitialValues({});
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
|
addTearDown(() => db.close());
|
|
|
|
await tester.pumpWidget(
|
|
buildTestHost(
|
|
child: const SettingsScreen(),
|
|
prefs: prefs,
|
|
db: db,
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Settings'), findsOneWidget);
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
}
|
|
});
|
|
|
|
group('Canonical 9-Viewport AccountScreen Responsive Tests', () {
|
|
for (final entry in canonicalViewports.entries) {
|
|
testWidgets('renders signed-out view cleanly on ${entry.key}', (tester) async {
|
|
tester.view.physicalSize = entry.value;
|
|
tester.view.devicePixelRatio = 1.0;
|
|
addTearDown(() => tester.view.resetPhysicalSize());
|
|
|
|
SharedPreferences.setMockInitialValues({});
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
|
addTearDown(() => db.close());
|
|
|
|
await tester.pumpWidget(
|
|
buildTestHost(
|
|
child: const AccountScreen(),
|
|
prefs: prefs,
|
|
db: db,
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Account & Cloud Identity'), findsOneWidget);
|
|
expect(find.text('SIGN IN TO MOTORCLOUD'), findsOneWidget);
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
|
|
testWidgets('renders signed-in view cleanly on ${entry.key}', (tester) async {
|
|
tester.view.physicalSize = entry.value;
|
|
tester.view.devicePixelRatio = 1.0;
|
|
addTearDown(() => tester.view.resetPhysicalSize());
|
|
|
|
SharedPreferences.setMockInitialValues({});
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
|
addTearDown(() => db.close());
|
|
final auth = _FakeAuth(
|
|
user: const AuthUser(
|
|
uid: 'rider-abc',
|
|
email: 'rider@motorcycle.app',
|
|
displayName: 'Rider Alpha',
|
|
isAnonymous: false,
|
|
providerId: 'password',
|
|
emailVerified: true,
|
|
),
|
|
);
|
|
|
|
await tester.pumpWidget(
|
|
buildTestHost(
|
|
child: const AccountScreen(),
|
|
prefs: prefs,
|
|
db: db,
|
|
auth: auth,
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Account & Cloud Identity'), findsOneWidget);
|
|
expect(find.text('Rider Alpha'), findsOneWidget);
|
|
expect(find.text('SIGN OUT'), findsOneWidget);
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
}
|
|
});
|
|
|
|
group('Canonical 9-Viewport ProScreen Responsive Tests', () {
|
|
for (final entry in canonicalViewports.entries) {
|
|
testWidgets('renders cleanly on ${entry.key}', (tester) async {
|
|
tester.view.physicalSize = entry.value;
|
|
tester.view.devicePixelRatio = 1.0;
|
|
addTearDown(() => tester.view.resetPhysicalSize());
|
|
|
|
SharedPreferences.setMockInitialValues({});
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
|
addTearDown(() => db.close());
|
|
|
|
await tester.pumpWidget(
|
|
buildTestHost(
|
|
child: const ProScreen(),
|
|
prefs: prefs,
|
|
db: db,
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('MOTORSIGNAL'), findsOneWidget);
|
|
expect(find.text('PRO LIFETIME'), findsOneWidget);
|
|
expect(find.text('PRO MONTHLY'), findsOneWidget);
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
}
|
|
});
|
|
|
|
group('ThemeMode State & Switching Verification', () {
|
|
testWidgets('toggles through system, light, dark without data loss', (tester) async {
|
|
SharedPreferences.setMockInitialValues({});
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
|
addTearDown(() => db.close());
|
|
|
|
await tester.pumpWidget(
|
|
buildTestHost(
|
|
child: const SettingsScreen(),
|
|
prefs: prefs,
|
|
db: db,
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
final container = ProviderScope.containerOf(tester.element(find.byType(SettingsScreen)));
|
|
expect(container.read(appThemeModeProvider), ThemeMode.system);
|
|
|
|
// Switch to Dark
|
|
await container.read(appThemeModeProvider.notifier).setThemeMode(ThemeMode.dark);
|
|
await tester.pumpAndSettle();
|
|
expect(container.read(appThemeModeProvider), ThemeMode.dark);
|
|
|
|
// Switch to Light
|
|
await container.read(appThemeModeProvider.notifier).setThemeMode(ThemeMode.light);
|
|
await tester.pumpAndSettle();
|
|
expect(container.read(appThemeModeProvider), ThemeMode.light);
|
|
|
|
// Switch back to System
|
|
await container.read(appThemeModeProvider.notifier).setThemeMode(ThemeMode.system);
|
|
await tester.pumpAndSettle();
|
|
expect(container.read(appThemeModeProvider), ThemeMode.system);
|
|
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
});
|
|
|
|
group('Short Landscape & IME Safety', () {
|
|
testWidgets('auth forms in 640x320 landscape allow scrolling and input with keyboard', (tester) async {
|
|
tester.view.physicalSize = const Size(640, 320);
|
|
tester.view.devicePixelRatio = 1.0;
|
|
addTearDown(() => tester.view.resetPhysicalSize());
|
|
|
|
SharedPreferences.setMockInitialValues({});
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
|
addTearDown(() => db.close());
|
|
|
|
await tester.pumpWidget(
|
|
buildTestHost(
|
|
child: const AccountScreen(),
|
|
prefs: prefs,
|
|
db: db,
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
// Enter email and password
|
|
await tester.enterText(find.byType(TextFormField).first, 'test@rider.com');
|
|
await tester.enterText(find.byType(TextFormField).last, 'secret123');
|
|
await tester.pump();
|
|
|
|
expect(find.text('test@rider.com'), findsOneWidget);
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
});
|
|
|
|
group('Legacy Theme Retirement Audit Assertion', () {
|
|
test('verifies zero references to retired theme identifiers in lib/', () {
|
|
final libDir = Directory('lib');
|
|
final files = libDir.listSync(recursive: true).whereType<File>().where((f) => f.path.endsWith('.dart'));
|
|
|
|
final retiredTerms = [
|
|
'DashboardThemeMode',
|
|
'dashboardThemeModeProvider',
|
|
'EvolutionSettingsView',
|
|
'app_dashboard_theme_mode',
|
|
];
|
|
|
|
for (final file in files) {
|
|
final text = file.readAsStringSync();
|
|
for (final term in retiredTerms) {
|
|
expect(
|
|
text.contains(term),
|
|
isFalse,
|
|
reason: 'Found retired term "$term" in ${file.path}',
|
|
);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|