mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter

test: add tests for theme editor & search screens

Changed files
+429 -3
lib
src
features
settings
presentation
test
src
features
search
settings
presentation
+17 -3
lib/src/features/settings/presentation/screens/theme_editor_screen.dart
··· 24 24 25 25 class _ThemeEditorScreenState extends ConsumerState<ThemeEditorScreen> { 26 26 late String _name; 27 + late TextEditingController _nameController; 27 28 late String _basePackId; 28 29 late ThemeRoleOverrides _overrides; 29 30 late TypographyScale _typographyScale; ··· 34 35 @override 35 36 void initState() { 36 37 super.initState(); 38 + _nameController = TextEditingController(); 37 39 _loadOrCreateDraft(); 38 40 } 39 41 42 + @override 43 + void dispose() { 44 + _nameController.dispose(); 45 + super.dispose(); 46 + } 47 + 40 48 Future<void> _loadOrCreateDraft() async { 49 + await Future.microtask(() {}); 41 50 if (widget.customThemeId != null) { 42 51 final repo = ref.read(customThemeRepositoryProvider); 43 52 final existing = await repo.getById(widget.customThemeId!); ··· 45 54 setState(() { 46 55 _existingId = existing.id; 47 56 _name = existing.name; 57 + _nameController.text = _name; 48 58 _basePackId = existing.basePackId; 49 59 _overrides = existing.overrides; 50 60 _typographyScale = existing.typographyScale; ··· 57 67 final themeState = ref.read(themeControllerProvider); 58 68 setState(() { 59 69 _name = 'My Custom Theme'; 70 + _nameController.text = _name; 60 71 _basePackId = themeState.currentPackId; 61 72 _overrides = ThemeRoleOverrides.empty; 62 73 _typographyScale = TypographyScale.normal; ··· 168 179 labelText: 'Theme Name', 169 180 border: OutlineInputBorder(), 170 181 ), 171 - controller: TextEditingController(text: _name), 182 + 183 + controller: _nameController, 172 184 onChanged: (value) { 173 - _name = value; 174 - _isDirty = true; 185 + setState(() { 186 + _name = value; 187 + _isDirty = true; 188 + }); 175 189 }, 176 190 ), 177 191 ),
+182
test/src/features/search/presentation/search_screen_test.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 + import 'package:flutter_test/flutter_test.dart'; 4 + import 'package:lazurite/src/core/domain/post.dart'; 5 + import 'package:lazurite/src/core/utils/pagination.dart'; 6 + import 'package:lazurite/src/features/search/application/search_providers.dart'; 7 + import 'package:lazurite/src/features/search/infrastructure/search_repository.dart'; 8 + import 'package:lazurite/src/features/search/presentation/search_screen.dart'; 9 + import 'package:lazurite/src/features/search/presentation/widgets/search_bar_widget.dart'; 10 + import 'package:lazurite/src/infrastructure/db/app_database.dart' hide Post; 11 + import 'package:mocktail/mocktail.dart'; 12 + 13 + class MockSearchRepository extends Mock implements SearchRepository {} 14 + 15 + class TestApp extends StatelessWidget { 16 + const TestApp({required this.home, super.key}); 17 + 18 + final Widget home; 19 + 20 + @override 21 + Widget build(BuildContext context) { 22 + return MaterialApp(home: home); 23 + } 24 + } 25 + 26 + void main() { 27 + late MockSearchRepository mockRepository; 28 + 29 + setUp(() { 30 + mockRepository = MockSearchRepository(); 31 + 32 + when(() => mockRepository.watchRecentSearches()).thenAnswer((_) => Stream.value([])); 33 + when(() => mockRepository.saveRecentSearch(any())).thenAnswer((_) async {}); 34 + }); 35 + 36 + Widget createSubject({String? initialQuery}) { 37 + return ProviderScope( 38 + overrides: [searchRepositoryProvider.overrideWithValue(mockRepository)], 39 + child: TestApp(home: SearchScreen(initialQuery: initialQuery)), 40 + ); 41 + } 42 + 43 + group('SearchScreen', () { 44 + testWidgets('shows recent searches when query is empty', (tester) async { 45 + when(() => mockRepository.watchRecentSearches()).thenAnswer( 46 + (_) => Stream.value([ 47 + RecentSearche(id: 1, query: 'flutter', searchedAt: DateTime.now()), 48 + RecentSearche(id: 2, query: 'dart', searchedAt: DateTime.now()), 49 + ]), 50 + ); 51 + 52 + await tester.pumpWidget(createSubject()); 53 + await tester.pumpAndSettle(); 54 + 55 + expect(find.text('Recent Searches'), findsOneWidget); 56 + expect(find.text('flutter'), findsOneWidget); 57 + expect(find.text('dart'), findsOneWidget); 58 + expect(find.byType(SearchBarWidget), findsOneWidget); 59 + }); 60 + 61 + testWidgets('shows empty state when no recent searches', (tester) async { 62 + when(() => mockRepository.watchRecentSearches()).thenAnswer((_) => Stream.value([])); 63 + 64 + await tester.pumpWidget(createSubject()); 65 + await tester.pumpAndSettle(); 66 + 67 + expect(find.text('Search for posts and people'), findsOneWidget); 68 + expect(find.text('Type a query to find posts or people'), findsOneWidget); 69 + }); 70 + 71 + testWidgets('performs search and shows results', (tester) async { 72 + const query = 'bluesky'; 73 + final posts = [ 74 + Post( 75 + uri: 'at://did:plc:123/app.bsky.feed.post/1', 76 + cid: 'cid1', 77 + author: const Author(did: 'did:plc:123', handle: 'alice.test', displayName: 'Alice'), 78 + record: {}, 79 + text: 'Hello Bluesky!', 80 + indexedAt: DateTime.now(), 81 + ), 82 + ]; 83 + final result = PaginatedResult(items: posts, cursor: null); 84 + 85 + when(() => mockRepository.searchPosts(query, cursor: null)).thenAnswer((_) async => result); 86 + when( 87 + () => mockRepository.searchActors(query, cursor: null), 88 + ).thenAnswer((_) async => const PaginatedResult(items: [], cursor: null)); 89 + 90 + await tester.pumpWidget(createSubject()); 91 + await tester.pumpAndSettle(); 92 + 93 + await tester.enterText(find.byType(TextField), query); 94 + await tester.testTextInput.receiveAction(TextInputAction.search); 95 + await tester.pumpAndSettle(); 96 + 97 + expect(find.text('Posts'), findsOneWidget); 98 + expect(find.text('People'), findsOneWidget); 99 + 100 + expect(find.text('Hello Bluesky!'), findsOneWidget); 101 + expect(find.text('Alice'), findsOneWidget); 102 + 103 + verify(() => mockRepository.searchPosts(query, cursor: null)).called(1); 104 + }); 105 + 106 + testWidgets('initial query populates search bar and results', (tester) async { 107 + const query = 'initial'; 108 + when( 109 + () => mockRepository.searchPosts(query, cursor: null), 110 + ).thenAnswer((_) async => const PaginatedResult(items: [], cursor: null)); 111 + when( 112 + () => mockRepository.searchActors(query, cursor: null), 113 + ).thenAnswer((_) async => const PaginatedResult(items: [], cursor: null)); 114 + 115 + await tester.pumpWidget(createSubject(initialQuery: query)); 116 + await tester.pumpAndSettle(); 117 + 118 + expect(find.text(query), findsOneWidget); 119 + expect(find.byType(TabBar), findsOneWidget); 120 + }); 121 + 122 + testWidgets('clearing search returns to recent searches', (tester) async { 123 + const query = 'test'; 124 + when( 125 + () => mockRepository.searchPosts(query, cursor: null), 126 + ).thenAnswer((_) async => const PaginatedResult(items: [], cursor: null)); 127 + when( 128 + () => mockRepository.searchActors(query, cursor: null), 129 + ).thenAnswer((_) async => const PaginatedResult(items: [], cursor: null)); 130 + 131 + await tester.pumpWidget(createSubject(initialQuery: query)); 132 + await tester.pumpAndSettle(); 133 + 134 + expect(find.byIcon(Icons.clear), findsOneWidget); 135 + await tester.tap(find.byIcon(Icons.clear)); 136 + await tester.pumpAndSettle(); 137 + 138 + expect(find.text('Search for posts and people'), findsOneWidget); 139 + expect(find.byType(TabBar), findsNothing); 140 + }); 141 + 142 + testWidgets('switches between Posts and People tabs', (tester) async { 143 + const query = 'test'; 144 + when( 145 + () => mockRepository.searchPosts(query, cursor: null), 146 + ).thenAnswer((_) async => const PaginatedResult(items: [], cursor: null)); 147 + when( 148 + () => mockRepository.searchActors(query, cursor: null), 149 + ).thenAnswer((_) async => const PaginatedResult(items: [], cursor: null)); 150 + 151 + await tester.pumpWidget(createSubject(initialQuery: query)); 152 + await tester.pumpAndSettle(); 153 + 154 + expect(find.text('No posts found'), findsOneWidget); 155 + 156 + await tester.tap(find.text('People')); 157 + await tester.pumpAndSettle(); 158 + 159 + expect(find.text('No people found'), findsOneWidget); 160 + }); 161 + 162 + testWidgets('retry on error', (tester) async { 163 + const query = 'error'; 164 + when(() => mockRepository.searchPosts(query, cursor: null)).thenThrow('Network Error'); 165 + when( 166 + () => mockRepository.searchActors(query, cursor: null), 167 + ).thenAnswer((_) async => const PaginatedResult(items: [], cursor: null)); 168 + 169 + await tester.pumpWidget(createSubject(initialQuery: query)); 170 + await tester.pumpAndSettle(); 171 + 172 + expect(find.text('Error: Network Error'), findsOneWidget); 173 + expect(find.text('Retry'), findsOneWidget); 174 + 175 + await tester.tap(find.text('Retry')); 176 + await tester.pump(); // Start retry 177 + await tester.pump(const Duration(milliseconds: 100)); 178 + 179 + verify(() => mockRepository.searchPosts(query, cursor: null)).called(greaterThan(1)); 180 + }); 181 + }); 182 + }
+230
test/src/features/settings/presentation/screens/theme_editor_screen_test.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 + import 'package:flutter_test/flutter_test.dart'; 4 + import 'package:go_router/go_router.dart'; 5 + import 'package:lazurite/src/app/providers.dart'; 6 + import 'package:lazurite/src/app/theme_controller.dart'; 7 + import 'package:lazurite/src/app/theming/custom_theme_draft.dart'; 8 + import 'package:lazurite/src/app/theming/theme_pack.dart'; 9 + import 'package:lazurite/src/app/theming/theme_spec.dart'; 10 + import 'package:lazurite/src/app/theming/theme_variant.dart'; 11 + import 'package:lazurite/src/features/settings/presentation/screens/theme_editor_screen.dart'; 12 + import 'package:lazurite/src/features/settings/presentation/widgets/color_role_picker.dart'; 13 + import 'package:lazurite/src/infrastructure/db/daos/local_settings_dao.dart'; 14 + import 'package:lazurite/src/infrastructure/theming/custom_theme_repository.dart'; 15 + import 'package:mocktail/mocktail.dart'; 16 + 17 + class MockCustomThemeRepository extends Mock implements CustomThemeRepository {} 18 + 19 + class MockLocalSettingsDao extends Mock implements LocalSettingsDao {} 20 + 21 + class TestApp extends StatelessWidget { 22 + const TestApp({required this.home, super.key}); 23 + 24 + final Widget home; 25 + 26 + @override 27 + Widget build(BuildContext context) { 28 + return MaterialApp.router( 29 + routerConfig: GoRouter( 30 + routes: [ 31 + GoRoute( 32 + path: '/', 33 + builder: (context, state) => Scaffold( 34 + body: Center( 35 + child: TextButton( 36 + onPressed: () => context.push('/editor'), 37 + child: const Text('Push'), 38 + ), 39 + ), 40 + ), 41 + ), 42 + GoRoute(path: '/editor', builder: (context, state) => home), 43 + ], 44 + ), 45 + ); 46 + } 47 + } 48 + 49 + void main() { 50 + late MockCustomThemeRepository mockRepository; 51 + late MockLocalSettingsDao mockSettingsDao; 52 + 53 + const testPack = ThemePack( 54 + id: 'test_pack', 55 + name: 'Test Pack', 56 + variants: [ 57 + ThemeVariant( 58 + id: 'test_variant', 59 + name: 'Test Variant', 60 + brightness: Brightness.dark, 61 + spec: ThemeSpec( 62 + primary: Colors.blue, 63 + secondary: Colors.teal, 64 + tertiary: Colors.amber, 65 + surface: Colors.black, 66 + surfaceContainerLow: Colors.black12, 67 + surfaceContainerHigh: Colors.black26, 68 + outlineVariant: Colors.grey, 69 + ), 70 + derivedScheme: ColorScheme.dark(), 71 + ), 72 + ], 73 + ); 74 + 75 + setUp(() { 76 + mockRepository = MockCustomThemeRepository(); 77 + mockSettingsDao = MockLocalSettingsDao(); 78 + 79 + registerFallbackValue( 80 + CustomThemeDraft.create( 81 + name: 'fallback', 82 + basePackId: 'fallback', 83 + overrides: ThemeRoleOverrides.empty, 84 + ), 85 + ); 86 + 87 + when(() => mockRepository.getById(any())).thenAnswer((invocation) async { 88 + final id = invocation.positionalArguments.first as String; 89 + return CustomThemeDraft.create( 90 + name: 'Generic Draft', 91 + basePackId: 'test_pack', 92 + overrides: ThemeRoleOverrides.empty, 93 + ).copyWith(id: id); 94 + }); 95 + 96 + when(() => mockSettingsDao.get(ThemeSettingsKeys.themeMode)).thenAnswer((_) async => 'dark'); 97 + when( 98 + () => mockSettingsDao.get(ThemeSettingsKeys.themePackId), 99 + ).thenAnswer((_) async => 'test_pack'); 100 + when(() => mockSettingsDao.get(ThemeSettingsKeys.customThemeId)).thenAnswer((_) async => null); 101 + when( 102 + () => mockSettingsDao.get(ThemeSettingsKeys.dynamicColorEnabled), 103 + ).thenAnswer((_) async => 'false'); 104 + 105 + when(() => mockSettingsDao.set(any(), any())).thenAnswer((_) async {}); 106 + when(() => mockSettingsDao.remove(any())).thenAnswer((_) async => 1); 107 + }); 108 + 109 + Widget createSubject({String? customThemeId}) { 110 + return ProviderScope( 111 + overrides: [ 112 + customThemeRepositoryProvider.overrideWithValue(mockRepository), 113 + localSettingsDaoProvider.overrideWithValue(mockSettingsDao), 114 + availableThemePacksProvider.overrideWith((ref) => [testPack]), 115 + ], 116 + child: TestApp(home: ThemeEditorScreen(customThemeId: customThemeId)), 117 + ); 118 + } 119 + 120 + group('ThemeEditorScreen', () { 121 + testWidgets('reset clears overrides', (tester) async { 122 + await tester.pumpWidget(createSubject()); 123 + await tester.tap(find.text('Push')); 124 + await tester.pumpAndSettle(); 125 + 126 + expect(find.text('My Custom Theme'), findsOneWidget); 127 + expect(find.text('Test Pack'), findsOneWidget); 128 + expect(find.byType(ColorRolePicker), findsNWidgets(7)); 129 + }); 130 + 131 + testWidgets('loads default state for new theme', (tester) async { 132 + await tester.pumpWidget(createSubject()); 133 + await tester.tap(find.text('Push')); 134 + await tester.pumpAndSettle(); 135 + 136 + expect(find.text('My Custom Theme'), findsOneWidget); 137 + expect(find.text('Test Pack'), findsOneWidget); 138 + expect(find.byType(ColorRolePicker), findsNWidgets(7)); 139 + }); 140 + 141 + testWidgets('loads existing theme data', (tester) async { 142 + final existingDraft = CustomThemeDraft.create( 143 + name: 'Existing Theme', 144 + basePackId: 'test_pack', 145 + overrides: const ThemeRoleOverrides(primary: Colors.red), 146 + ).copyWith(id: 'existing_id'); 147 + 148 + when(() => mockRepository.getById('existing_id')).thenAnswer((_) async => existingDraft); 149 + 150 + await tester.pumpWidget(createSubject(customThemeId: 'existing_id')); 151 + await tester.tap(find.text('Push')); 152 + await tester.pumpAndSettle(); 153 + 154 + expect(find.text('Existing Theme'), findsOneWidget); 155 + }); 156 + 157 + testWidgets('updates override when color picked', (tester) async { 158 + await tester.pumpWidget(createSubject()); 159 + await tester.tap(find.text('Push')); 160 + await tester.pumpAndSettle(); 161 + 162 + await tester.tap(find.text('Primary')); 163 + await tester.pumpAndSettle(); 164 + 165 + expect(find.text('Select Color'), findsOneWidget); 166 + }); 167 + 168 + testWidgets('save creates new theme and sets it', (tester) async { 169 + when( 170 + () => mockRepository.save(any()), 171 + ).thenAnswer((_) async => const ValidationResult.valid()); 172 + 173 + await tester.pumpWidget(createSubject()); 174 + await tester.tap(find.text('Push')); 175 + await tester.pumpAndSettle(); 176 + 177 + await tester.tap(find.text('Save')); 178 + await tester.pumpAndSettle(); 179 + 180 + verify(() => mockRepository.save(any(that: isA<CustomThemeDraft>()))).called(1); 181 + 182 + verify(() => mockSettingsDao.set(ThemeSettingsKeys.customThemeId, any())).called(1); 183 + 184 + expect(find.byType(ThemeEditorScreen), findsNothing); 185 + }); 186 + 187 + testWidgets('save updates existing theme', (tester) async { 188 + final existingDraft = CustomThemeDraft.create( 189 + name: 'Existing', 190 + basePackId: 'test_pack', 191 + overrides: ThemeRoleOverrides.empty, 192 + ).copyWith(id: 'existing_id'); 193 + 194 + when(() => mockRepository.getById('existing_id')).thenAnswer((_) async => existingDraft); 195 + when( 196 + () => mockRepository.save(any()), 197 + ).thenAnswer((_) async => const ValidationResult.valid()); 198 + 199 + await tester.pumpWidget(createSubject(customThemeId: 'existing_id')); 200 + await tester.tap(find.text('Push')); 201 + await tester.pumpAndSettle(); 202 + 203 + await tester.enterText(find.byType(TextField), 'Updated Name'); 204 + await tester.pump(); 205 + 206 + await tester.tap(find.text('Save')); 207 + await tester.pump(); 208 + await tester.pump(); 209 + 210 + verify(() => mockRepository.save(any())).called(1); 211 + }); 212 + 213 + testWidgets('shows error snackbar on save failure', (tester) async { 214 + when( 215 + () => mockRepository.save(any()), 216 + ).thenAnswer((_) async => const ValidationResult.invalid('Save failed')); 217 + 218 + await tester.pumpWidget(createSubject()); 219 + await tester.tap(find.text('Push')); 220 + await tester.pumpAndSettle(); 221 + 222 + await tester.tap(find.text('Save')); 223 + await tester.pump(); 224 + await tester.pump(); 225 + 226 + expect(find.text('Save failed'), findsOneWidget); 227 + expect(find.byType(ThemeEditorScreen), findsOneWidget); 228 + }); 229 + }); 230 + }