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

Configure Feed

Select the types of activity you want to include in your feed.

feat: search, index, and sync cubits

+921 -31
+4 -4
docs/tasks/phase-7.md
··· 60 60 61 61 ### Cubit 62 62 63 - - [ ] `SemanticSearchCubit` - `search(query)` with 500ms debounce, `setScope(source)`, `clearResults()` 64 - - [ ] `SemanticSearchState` - `status` (initial/searching/loaded/error/unavailable), `results`, `query`, `scope` (saved/liked/both) 65 - - [ ] `LikedPostsSyncCubit` - `sync()` triggers like sync, exposes sync progress 66 - - [ ] `SemanticIndexCubit` - exposes `backfillProgress`, `indexedCount`, `reindex()` action 63 + - [x] `SemanticSearchCubit` - `search(query)` with 500ms debounce, `setScope(source)`, `clearResults()` 64 + - [x] `SemanticSearchState` - `status` (initial/searching/loaded/error/unavailable), `results`, `query`, `scope` (saved/liked/both) 65 + - [x] `LikedPostsSyncCubit` - `sync()` triggers like sync, exposes sync progress 66 + - [x] `SemanticIndexCubit` - exposes `backfillProgress`, `indexedCount`, `reindex()` action 67 67 68 68 ### UI 69 69
+52
lib/features/feed/cubit/liked_posts_sync_cubit.dart
··· 1 + import 'package:equatable/equatable.dart'; 2 + import 'package:flutter_bloc/flutter_bloc.dart'; 3 + import 'package:lazurite/features/feed/data/liked_posts_repository.dart'; 4 + 5 + enum LikedPostsSyncStatus { idle, syncing, synced, error } 6 + 7 + class LikedPostsSyncState extends Equatable { 8 + const LikedPostsSyncState({this.status = LikedPostsSyncStatus.idle, this.errorMessage}); 9 + 10 + final LikedPostsSyncStatus status; 11 + final String? errorMessage; 12 + 13 + bool get isSyncing => status == LikedPostsSyncStatus.syncing; 14 + 15 + LikedPostsSyncState copyWith({LikedPostsSyncStatus? status, String? errorMessage}) => LikedPostsSyncState( 16 + status: status ?? this.status, 17 + errorMessage: errorMessage ?? this.errorMessage, 18 + ); 19 + 20 + @override 21 + List<Object?> get props => [status, errorMessage]; 22 + } 23 + 24 + /// Cubit that triggers a sync of the user's liked posts from the Bluesky API. 25 + /// 26 + /// Exposes [sync] to kick off the sync operation. 27 + /// Progress is represented as status transitions: idle → syncing → synced/error. 28 + class LikedPostsSyncCubit extends Cubit<LikedPostsSyncState> { 29 + LikedPostsSyncCubit({required LikedPostsRepository repository, required String accountDid}) 30 + : _repository = repository, 31 + _accountDid = accountDid, 32 + super(const LikedPostsSyncState()); 33 + 34 + final LikedPostsRepository _repository; 35 + final String _accountDid; 36 + 37 + /// Sync liked posts for this account from the Bluesky API. 38 + /// 39 + /// A no-op if a sync is already in progress. 40 + Future<void> sync() async { 41 + if (state.isSyncing) return; 42 + emit(const LikedPostsSyncState(status: LikedPostsSyncStatus.syncing)); 43 + try { 44 + await _repository.syncLikes(_accountDid); 45 + if (!isClosed) emit(const LikedPostsSyncState(status: LikedPostsSyncStatus.synced)); 46 + } catch (e) { 47 + if (!isClosed) { 48 + emit(LikedPostsSyncState(status: LikedPostsSyncStatus.error, errorMessage: 'Sync failed: $e')); 49 + } 50 + } 51 + } 52 + }
+138
lib/features/search/cubit/semantic_index_cubit.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:equatable/equatable.dart'; 4 + import 'package:flutter_bloc/flutter_bloc.dart'; 5 + import 'package:lazurite/features/search/data/embedding_repository.dart'; 6 + import 'package:lazurite/features/search/data/semantic_indexer.dart'; 7 + 8 + enum SemanticIndexStatus { idle, backfilling, error } 9 + 10 + class SemanticIndexState extends Equatable { 11 + const SemanticIndexState({ 12 + this.status = SemanticIndexStatus.idle, 13 + this.indexedCount = 0, 14 + this.backfillCompleted, 15 + this.backfillTotal, 16 + this.errorMessage, 17 + }); 18 + 19 + final SemanticIndexStatus status; 20 + 21 + /// Number of posts currently in the embedding index for this account. 22 + final int indexedCount; 23 + 24 + /// Number of posts completed during the current backfill, or null when idle. 25 + final int? backfillCompleted; 26 + 27 + /// Total posts to backfill, or null when idle. 28 + final int? backfillTotal; 29 + 30 + final String? errorMessage; 31 + 32 + bool get isBackfilling => status == SemanticIndexStatus.backfilling; 33 + 34 + SemanticIndexState copyWith({ 35 + SemanticIndexStatus? status, 36 + int? indexedCount, 37 + int? backfillCompleted, 38 + int? backfillTotal, 39 + String? errorMessage, 40 + }) => SemanticIndexState( 41 + status: status ?? this.status, 42 + indexedCount: indexedCount ?? this.indexedCount, 43 + backfillCompleted: backfillCompleted ?? this.backfillCompleted, 44 + backfillTotal: backfillTotal ?? this.backfillTotal, 45 + errorMessage: errorMessage ?? this.errorMessage, 46 + ); 47 + 48 + @override 49 + List<Object?> get props => [status, indexedCount, backfillCompleted, backfillTotal, errorMessage]; 50 + } 51 + 52 + /// Cubit that manages the semantic embedding index for an account. 53 + /// 54 + /// Exposes [loadCount] to refresh [SemanticIndexState.indexedCount] and 55 + /// [reindex] to clear the existing index and re-embed all saved/liked posts. 56 + /// [SemanticIndexState.backfillCompleted] and [SemanticIndexState.backfillTotal] 57 + /// track backfill progress for display in the settings UI. 58 + class SemanticIndexCubit extends Cubit<SemanticIndexState> { 59 + SemanticIndexCubit({ 60 + required SemanticIndexer indexer, 61 + required EmbeddingRepository embeddingRepository, 62 + required String accountDid, 63 + }) : _indexer = indexer, 64 + _embeddingRepository = embeddingRepository, 65 + _accountDid = accountDid, 66 + super(const SemanticIndexState()); 67 + 68 + final SemanticIndexer _indexer; 69 + final EmbeddingRepository _embeddingRepository; 70 + final String _accountDid; 71 + StreamSubscription<(int, int)>? _backfillSubscription; 72 + 73 + /// Refresh [SemanticIndexState.indexedCount] from the embedding store. 74 + void loadCount() { 75 + final count = _embeddingRepository.countByAccount(_accountDid); 76 + emit(state.copyWith(indexedCount: count)); 77 + } 78 + 79 + /// Clear the existing index for this account and re-embed all posts. 80 + /// 81 + /// A no-op if a backfill is already in progress. 82 + Future<void> reindex() async { 83 + if (state.isBackfilling) return; 84 + 85 + final existing = _embeddingRepository.queryByAccount(_accountDid); 86 + for (final post in existing) { 87 + _embeddingRepository.deleteByUri(post.postUri); 88 + } 89 + 90 + emit( 91 + const SemanticIndexState( 92 + status: SemanticIndexStatus.backfilling, 93 + indexedCount: 0, 94 + backfillCompleted: 0, 95 + backfillTotal: 0, 96 + ), 97 + ); 98 + 99 + await _backfillSubscription?.cancel(); 100 + 101 + var hadError = false; 102 + _backfillSubscription = _indexer 103 + .backfill(_accountDid) 104 + .listen( 105 + (progress) { 106 + final (completed, total) = progress; 107 + if (!isClosed) { 108 + emit( 109 + state.copyWith( 110 + status: SemanticIndexStatus.backfilling, 111 + backfillCompleted: completed, 112 + backfillTotal: total, 113 + indexedCount: completed, 114 + ), 115 + ); 116 + } 117 + }, 118 + onDone: () { 119 + if (!isClosed && !hadError) { 120 + final count = _embeddingRepository.countByAccount(_accountDid); 121 + emit(SemanticIndexState(status: SemanticIndexStatus.idle, indexedCount: count)); 122 + } 123 + }, 124 + onError: (Object e) { 125 + hadError = true; 126 + if (!isClosed) { 127 + emit(SemanticIndexState(status: SemanticIndexStatus.error, errorMessage: 'Indexing failed: $e')); 128 + } 129 + }, 130 + ); 131 + } 132 + 133 + @override 134 + Future<void> close() async { 135 + await _backfillSubscription?.cancel(); 136 + return super.close(); 137 + } 138 + }
+131
lib/features/search/cubit/semantic_search_cubit.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:equatable/equatable.dart'; 4 + import 'package:flutter_bloc/flutter_bloc.dart'; 5 + import 'package:lazurite/core/embedding/embedding_service.dart'; 6 + import 'package:lazurite/features/search/data/semantic_search_repository.dart'; 7 + import 'package:lazurite/features/search/data/semantic_search_result.dart'; 8 + 9 + enum SemanticSearchStatus { initial, searching, loaded, error, unavailable } 10 + 11 + /// Scope filter for semantic search results. 12 + enum SearchScope { saved, liked, both } 13 + 14 + class SemanticSearchState extends Equatable { 15 + const SemanticSearchState({ 16 + this.status = SemanticSearchStatus.initial, 17 + this.results = const [], 18 + this.query = '', 19 + this.scope = SearchScope.both, 20 + this.errorMessage, 21 + }); 22 + 23 + final SemanticSearchStatus status; 24 + final List<SemanticSearchResult> results; 25 + final String query; 26 + final SearchScope scope; 27 + final String? errorMessage; 28 + 29 + SemanticSearchState copyWith({ 30 + SemanticSearchStatus? status, 31 + List<SemanticSearchResult>? results, 32 + String? query, 33 + SearchScope? scope, 34 + String? errorMessage, 35 + }) => SemanticSearchState( 36 + status: status ?? this.status, 37 + results: results ?? this.results, 38 + query: query ?? this.query, 39 + scope: scope ?? this.scope, 40 + errorMessage: errorMessage ?? this.errorMessage, 41 + ); 42 + 43 + @override 44 + List<Object?> get props => [status, results, query, scope, errorMessage]; 45 + } 46 + 47 + /// Cubit for on-device semantic (vector) search over saved and liked posts. 48 + /// 49 + /// Exposes a [search] method with a 500 ms debounce, a [setScope] method to 50 + /// filter results by source, and a [clearResults] method to reset state. 51 + /// 52 + /// The initial state is [SemanticSearchStatus.unavailable] when the 53 + /// [EmbeddingService] is not available (e.g. model failed to load). 54 + class SemanticSearchCubit extends Cubit<SemanticSearchState> { 55 + SemanticSearchCubit({ 56 + required SemanticSearchRepository repository, 57 + required EmbeddingService embeddingService, 58 + required String accountDid, 59 + Duration debounceDuration = const Duration(milliseconds: 500), 60 + }) : _repository = repository, 61 + _embeddingService = embeddingService, 62 + _accountDid = accountDid, 63 + _debounceDuration = debounceDuration, 64 + super( 65 + embeddingService.isAvailable 66 + ? const SemanticSearchState() 67 + : const SemanticSearchState(status: SemanticSearchStatus.unavailable), 68 + ); 69 + 70 + final SemanticSearchRepository _repository; 71 + final EmbeddingService _embeddingService; 72 + final String _accountDid; 73 + final Duration _debounceDuration; 74 + Timer? _debounce; 75 + 76 + /// Queue a search for [query], debounced by [_debounceDuration]. 77 + /// 78 + /// An empty query clears results immediately without waiting for the debounce. 79 + /// A no-op when the embedding service is unavailable. 80 + void search(String query) { 81 + if (!_embeddingService.isAvailable) { 82 + emit(const SemanticSearchState(status: SemanticSearchStatus.unavailable)); 83 + return; 84 + } 85 + _debounce?.cancel(); 86 + if (query.trim().isEmpty) { 87 + emit(SemanticSearchState(scope: state.scope)); 88 + return; 89 + } 90 + _debounce = Timer(_debounceDuration, () => unawaited(_doSearch(query))); 91 + } 92 + 93 + /// Change the search scope and immediately re-run the current query. 94 + Future<void> setScope(SearchScope scope) async { 95 + if (state.scope == scope) return; 96 + emit(state.copyWith(scope: scope)); 97 + if (state.query.trim().isNotEmpty && _embeddingService.isAvailable) { 98 + _debounce?.cancel(); 99 + await _doSearch(state.query); 100 + } 101 + } 102 + 103 + /// Reset to the initial state, preserving the current scope. 104 + void clearResults() { 105 + _debounce?.cancel(); 106 + emit(SemanticSearchState(scope: state.scope)); 107 + } 108 + 109 + Future<void> _doSearch(String query) async { 110 + emit(state.copyWith(status: SemanticSearchStatus.searching, query: query)); 111 + try { 112 + final source = switch (state.scope) { 113 + SearchScope.saved => 'saved', 114 + SearchScope.liked => 'liked', 115 + SearchScope.both => null, 116 + }; 117 + final results = await _repository.search(query, _accountDid, source: source); 118 + if (isClosed) return; 119 + emit(state.copyWith(status: SemanticSearchStatus.loaded, results: results)); 120 + } catch (e) { 121 + if (isClosed) return; 122 + emit(state.copyWith(status: SemanticSearchStatus.error, errorMessage: 'Search failed: $e')); 123 + } 124 + } 125 + 126 + @override 127 + Future<void> close() { 128 + _debounce?.cancel(); 129 + return super.close(); 130 + } 131 + }
+5 -5
lib/features/search/data/semantic_indexer.dart
··· 7 7 import 'package:bluesky/app_bsky_feed_defs.dart'; 8 8 import 'package:lazurite/core/database/app_database.dart'; 9 9 import 'package:lazurite/core/embedding/embedding_service.dart'; 10 + import 'package:lazurite/core/logging/app_logger.dart'; 10 11 import 'package:lazurite/core/objectbox/embedded_post.dart'; 11 12 import 'package:lazurite/features/search/data/embedding_repository.dart'; 12 13 import 'package:lazurite/features/search/data/post_text_extractor.dart'; ··· 121 122 for (final req in chunk) { 122 123 try { 123 124 await indexPost(req.postUri, req.postJson, req.accountDid, req.source); 124 - } catch (_) { 125 - // Skip posts that fail to embed; don't abort the entire backfill. 125 + } catch (e) { 126 + log.d('Failed to backfill ${req.postUri}: $e'); 126 127 } 127 128 completed++; 128 129 yield (completed, total); 129 130 } 130 - // Yield to the event loop between chunks. 131 131 await Future<void>.delayed(Duration.zero); 132 132 } 133 133 } ··· 139 139 final req = _queue.removeFirst(); 140 140 try { 141 141 await indexPost(req.postUri, req.postJson, req.accountDid, req.source); 142 - } catch (_) { 143 - // Swallow errors so the queue keeps draining. 142 + } catch (e) { 143 + log.d('Failed to index ${req.postUri}: $e'); 144 144 } 145 145 } 146 146 _draining = false;
+2 -3
lib/features/search/data/semantic_search_repository.dart
··· 23 23 24 24 /// Search for posts semantically similar to [query]. 25 25 /// 26 - /// Returns an empty list when [EmbeddingService.isAvailable] is false or 27 - /// when [query] is blank. 26 + /// Returns an empty list when [EmbeddingService.isAvailable] is false 27 + /// or when [query] is blank. 28 28 /// 29 29 /// [source] narrows results to 'saved', 'liked', or both when null. 30 30 /// [maxResults] caps the number of results (default 20). ··· 49 49 final results = <SemanticSearchResult>[]; 50 50 for (final result in rawResults) { 51 51 final post = result.object; 52 - // Cosine distance is in [0, 2]; similarity = 1 - distance, clamped to [0, 1]. 53 52 final similarity = (1.0 - result.score).clamp(0.0, 1.0); 54 53 final scorePercent = similarity * 100.0; 55 54
+31
lib/main.dart
··· 35 35 import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; 36 36 import 'package:lazurite/features/profile/data/profile_action_repository.dart'; 37 37 import 'package:lazurite/features/profile/data/profile_repository.dart'; 38 + import 'package:lazurite/features/feed/cubit/liked_posts_sync_cubit.dart'; 38 39 import 'package:lazurite/features/search/bloc/search_bloc.dart'; 39 40 import 'package:lazurite/core/embedding/embedding_service.dart'; 40 41 import 'package:lazurite/features/feed/data/liked_posts_repository.dart'; 42 + import 'package:lazurite/features/search/cubit/semantic_index_cubit.dart'; 43 + import 'package:lazurite/features/search/cubit/semantic_search_cubit.dart'; 41 44 import 'package:lazurite/features/search/data/embedding_repository.dart'; 42 45 import 'package:lazurite/features/search/data/search_repository.dart'; 43 46 import 'package:lazurite/features/search/data/semantic_indexer.dart'; 47 + import 'package:lazurite/features/search/data/semantic_search_repository.dart'; 44 48 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 45 49 import 'package:lazurite/features/settings/bloc/settings_state.dart'; 46 50 import 'package:lazurite/features/settings/data/video_repository.dart'; ··· 304 308 semanticIndexer: context.read<SemanticIndexer>(), 305 309 ), 306 310 ), 311 + RepositoryProvider( 312 + create: (context) => SemanticSearchRepository( 313 + embeddingService: context.read<EmbeddingService>(), 314 + embeddingRepository: context.read<EmbeddingRepository>(), 315 + database: widget.database, 316 + ), 317 + ), 307 318 RepositoryProvider.value(value: accountDid), 308 319 ], 309 320 child: MultiBlocProvider( ··· 336 347 accountDid: accountDid, 337 348 postActionRepository: context.read<PostActionRepository>(), 338 349 semanticIndexer: context.read<SemanticIndexer>(), 350 + ), 351 + ), 352 + BlocProvider( 353 + create: (context) => SemanticSearchCubit( 354 + repository: context.read<SemanticSearchRepository>(), 355 + embeddingService: context.read<EmbeddingService>(), 356 + accountDid: accountDid, 357 + ), 358 + ), 359 + BlocProvider( 360 + create: (context) => SemanticIndexCubit( 361 + indexer: context.read<SemanticIndexer>(), 362 + embeddingRepository: context.read<EmbeddingRepository>(), 363 + accountDid: accountDid, 364 + ), 365 + ), 366 + BlocProvider( 367 + create: (context) => LikedPostsSyncCubit( 368 + repository: context.read<LikedPostsRepository>(), 369 + accountDid: accountDid, 339 370 ), 340 371 ), 341 372 ],
+2 -2
test/core/embedding/word_piece_tokenizer_test.dart
··· 102 102 103 103 test('sub-word fallback: ab + ##c → [6, 3]', () { 104 104 final result = tokenizer.tokenize('abbc'); 105 - expect(result[1], equals(6)); // "ab" 106 - expect(result[2], equals(4)); // "##bc" 105 + expect(result[1], equals(6)); 106 + expect(result[2], equals(4)); 107 107 expect(result[3], equals(WordPieceTokenizer.sepId)); 108 108 }); 109 109
+90
test/features/feed/cubit/liked_posts_sync_cubit_test.dart
··· 1 + import 'package:bloc_test/bloc_test.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:lazurite/features/feed/cubit/liked_posts_sync_cubit.dart'; 4 + import 'package:lazurite/features/feed/data/liked_posts_repository.dart'; 5 + import 'package:mocktail/mocktail.dart'; 6 + 7 + class MockLikedPostsRepository extends Mock implements LikedPostsRepository {} 8 + 9 + const _accountDid = 'did:plc:testuser'; 10 + 11 + void main() { 12 + late MockLikedPostsRepository mockRepo; 13 + 14 + setUp(() { 15 + mockRepo = MockLikedPostsRepository(); 16 + }); 17 + 18 + LikedPostsSyncCubit buildCubit() => 19 + LikedPostsSyncCubit(repository: mockRepo, accountDid: _accountDid); 20 + 21 + group('LikedPostsSyncCubit', () { 22 + group('initial state', () { 23 + test('is idle with no error', () { 24 + final cubit = buildCubit(); 25 + expect(cubit.state.status, LikedPostsSyncStatus.idle); 26 + expect(cubit.state.isSyncing, isFalse); 27 + expect(cubit.state.errorMessage, isNull); 28 + }); 29 + }); 30 + 31 + group('sync', () { 32 + blocTest<LikedPostsSyncCubit, LikedPostsSyncState>( 33 + 'emits syncing then synced on success', 34 + build: buildCubit, 35 + setUp: () { 36 + when(() => mockRepo.syncLikes(_accountDid)).thenAnswer((_) async {}); 37 + }, 38 + act: (cubit) => cubit.sync(), 39 + expect: () => [ 40 + const LikedPostsSyncState(status: LikedPostsSyncStatus.syncing), 41 + const LikedPostsSyncState(status: LikedPostsSyncStatus.synced), 42 + ], 43 + verify: (_) => verify(() => mockRepo.syncLikes(_accountDid)).called(1), 44 + ); 45 + 46 + blocTest<LikedPostsSyncCubit, LikedPostsSyncState>( 47 + 'emits syncing then error when repository throws', 48 + build: buildCubit, 49 + setUp: () { 50 + when(() => mockRepo.syncLikes(_accountDid)).thenThrow(Exception('network timeout')); 51 + }, 52 + act: (cubit) => cubit.sync(), 53 + expect: () => [ 54 + const LikedPostsSyncState(status: LikedPostsSyncStatus.syncing), 55 + predicate<LikedPostsSyncState>( 56 + (s) => s.status == LikedPostsSyncStatus.error && s.errorMessage != null, 57 + ), 58 + ], 59 + ); 60 + 61 + blocTest<LikedPostsSyncCubit, LikedPostsSyncState>( 62 + 'is a no-op when already syncing', 63 + build: buildCubit, 64 + seed: () => const LikedPostsSyncState(status: LikedPostsSyncStatus.syncing), 65 + act: (cubit) => cubit.sync(), 66 + expect: () => [], 67 + verify: (_) => verifyNever(() => mockRepo.syncLikes(any())), 68 + ); 69 + 70 + blocTest<LikedPostsSyncCubit, LikedPostsSyncState>( 71 + 'can sync again after a previous sync completes', 72 + build: buildCubit, 73 + setUp: () { 74 + when(() => mockRepo.syncLikes(_accountDid)).thenAnswer((_) async {}); 75 + }, 76 + act: (cubit) async { 77 + await cubit.sync(); 78 + await cubit.sync(); 79 + }, 80 + expect: () => [ 81 + const LikedPostsSyncState(status: LikedPostsSyncStatus.syncing), 82 + const LikedPostsSyncState(status: LikedPostsSyncStatus.synced), 83 + const LikedPostsSyncState(status: LikedPostsSyncStatus.syncing), 84 + const LikedPostsSyncState(status: LikedPostsSyncStatus.synced), 85 + ], 86 + verify: (_) => verify(() => mockRepo.syncLikes(_accountDid)).called(2), 87 + ); 88 + }); 89 + }); 90 + }
-1
test/features/feed/cubit/saved_posts_cubit_test.dart
··· 40 40 cursor: any(named: 'cursor'), 41 41 ), 42 42 ).thenAnswer((_) async => const BookmarkGetBookmarksOutput(bookmarks: [])); 43 - // Stub indexer methods so tests that don't verify them still pass. 44 43 when(() => mockIndexer.queueIndexPost(any(), any(), any(), any())).thenReturn(null); 45 44 when(() => mockIndexer.removePost(any())).thenReturn(null); 46 45 });
+152
test/features/search/cubit/semantic_index_cubit_test.dart
··· 1 + import 'package:bloc_test/bloc_test.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:lazurite/core/objectbox/embedded_post.dart'; 4 + import 'package:lazurite/features/search/cubit/semantic_index_cubit.dart'; 5 + import 'package:lazurite/features/search/data/embedding_repository.dart'; 6 + import 'package:lazurite/features/search/data/semantic_indexer.dart'; 7 + import 'package:mocktail/mocktail.dart'; 8 + 9 + class MockSemanticIndexer extends Mock implements SemanticIndexer {} 10 + 11 + class MockEmbeddingRepository extends Mock implements EmbeddingRepository {} 12 + 13 + const _accountDid = 'did:plc:testuser'; 14 + 15 + void main() { 16 + late MockSemanticIndexer mockIndexer; 17 + late MockEmbeddingRepository mockRepo; 18 + 19 + setUp(() { 20 + mockIndexer = MockSemanticIndexer(); 21 + mockRepo = MockEmbeddingRepository(); 22 + 23 + when(() => mockRepo.countByAccount(_accountDid)).thenReturn(0); 24 + when(() => mockRepo.queryByAccount(_accountDid)).thenReturn([]); 25 + when(() => mockRepo.deleteByUri(any())).thenReturn(null); 26 + when(() => mockIndexer.backfill(_accountDid)).thenAnswer((_) => const Stream.empty()); 27 + }); 28 + 29 + SemanticIndexCubit buildCubit() => 30 + SemanticIndexCubit(indexer: mockIndexer, embeddingRepository: mockRepo, accountDid: _accountDid); 31 + 32 + group('SemanticIndexCubit', () { 33 + group('initial state', () { 34 + test('has idle status and zero indexed count', () { 35 + final cubit = buildCubit(); 36 + expect(cubit.state.status, SemanticIndexStatus.idle); 37 + expect(cubit.state.indexedCount, 0); 38 + expect(cubit.state.backfillCompleted, isNull); 39 + expect(cubit.state.backfillTotal, isNull); 40 + expect(cubit.state.isBackfilling, isFalse); 41 + }); 42 + }); 43 + 44 + group('loadCount', () { 45 + blocTest<SemanticIndexCubit, SemanticIndexState>( 46 + 'updates indexedCount from repository', 47 + build: buildCubit, 48 + setUp: () => when(() => mockRepo.countByAccount(_accountDid)).thenReturn(42), 49 + act: (cubit) => cubit.loadCount(), 50 + expect: () => [ 51 + predicate<SemanticIndexState>((s) => s.indexedCount == 42 && s.status == SemanticIndexStatus.idle), 52 + ], 53 + ); 54 + 55 + blocTest<SemanticIndexCubit, SemanticIndexState>( 56 + 'emits zero when no posts are indexed', 57 + build: buildCubit, 58 + act: (cubit) => cubit.loadCount(), 59 + expect: () => [predicate<SemanticIndexState>((s) => s.indexedCount == 0)], 60 + ); 61 + }); 62 + 63 + group('reindex', () { 64 + blocTest<SemanticIndexCubit, SemanticIndexState>( 65 + 'clears existing embeddings before backfill', 66 + build: buildCubit, 67 + setUp: () { 68 + final post1 = EmbeddedPost( 69 + postUri: 'at://1', 70 + accountDid: _accountDid, 71 + source: 'saved', 72 + indexedText: 'text', 73 + embeddedAt: DateTime.now(), 74 + ); 75 + final post2 = EmbeddedPost( 76 + postUri: 'at://2', 77 + accountDid: _accountDid, 78 + source: 'liked', 79 + indexedText: 'other', 80 + embeddedAt: DateTime.now(), 81 + ); 82 + when(() => mockRepo.queryByAccount(_accountDid)).thenReturn([post1, post2]); 83 + when(() => mockIndexer.backfill(_accountDid)).thenAnswer((_) => const Stream.empty()); 84 + when(() => mockRepo.countByAccount(_accountDid)).thenReturn(0); 85 + }, 86 + act: (cubit) => cubit.reindex(), 87 + verify: (_) { 88 + verify(() => mockRepo.deleteByUri('at://1')).called(1); 89 + verify(() => mockRepo.deleteByUri('at://2')).called(1); 90 + }, 91 + ); 92 + 93 + blocTest<SemanticIndexCubit, SemanticIndexState>( 94 + 'emits backfilling with progress then idle on completion', 95 + build: buildCubit, 96 + setUp: () { 97 + when( 98 + () => mockIndexer.backfill(_accountDid), 99 + ).thenAnswer((_) => Stream.fromIterable([(1, 3), (2, 3), (3, 3)])); 100 + when(() => mockRepo.countByAccount(_accountDid)).thenReturn(3); 101 + }, 102 + act: (cubit) => cubit.reindex(), 103 + expect: () => [ 104 + predicate<SemanticIndexState>((s) => s.isBackfilling && s.backfillCompleted == 0 && s.backfillTotal == 0), 105 + predicate<SemanticIndexState>((s) => s.isBackfilling && s.backfillCompleted == 1 && s.backfillTotal == 3), 106 + predicate<SemanticIndexState>((s) => s.isBackfilling && s.backfillCompleted == 2 && s.backfillTotal == 3), 107 + predicate<SemanticIndexState>((s) => s.isBackfilling && s.backfillCompleted == 3 && s.backfillTotal == 3), 108 + predicate<SemanticIndexState>( 109 + (s) => s.status == SemanticIndexStatus.idle && s.indexedCount == 3 && s.backfillCompleted == null, 110 + ), 111 + ], 112 + ); 113 + 114 + blocTest<SemanticIndexCubit, SemanticIndexState>( 115 + 'emits error when backfill stream emits an error', 116 + build: buildCubit, 117 + setUp: () { 118 + when(() => mockIndexer.backfill(_accountDid)).thenAnswer((_) => Stream.error(Exception('embedding failure'))); 119 + }, 120 + act: (cubit) => cubit.reindex(), 121 + expect: () => [ 122 + predicate<SemanticIndexState>((s) => s.isBackfilling), 123 + predicate<SemanticIndexState>((s) => s.status == SemanticIndexStatus.error && s.errorMessage != null), 124 + ], 125 + ); 126 + 127 + blocTest<SemanticIndexCubit, SemanticIndexState>( 128 + 'is a no-op when already backfilling', 129 + build: buildCubit, 130 + seed: () => 131 + const SemanticIndexState(status: SemanticIndexStatus.backfilling, backfillCompleted: 5, backfillTotal: 10), 132 + act: (cubit) => cubit.reindex(), 133 + expect: () => [], 134 + verify: (_) => verifyNever(() => mockIndexer.backfill(any())), 135 + ); 136 + 137 + blocTest<SemanticIndexCubit, SemanticIndexState>( 138 + 'idle backfill emits initial backfilling then immediate idle', 139 + build: buildCubit, 140 + setUp: () { 141 + when(() => mockIndexer.backfill(_accountDid)).thenAnswer((_) => const Stream.empty()); 142 + when(() => mockRepo.countByAccount(_accountDid)).thenReturn(0); 143 + }, 144 + act: (cubit) => cubit.reindex(), 145 + expect: () => [ 146 + predicate<SemanticIndexState>((s) => s.isBackfilling && s.backfillCompleted == 0), 147 + predicate<SemanticIndexState>((s) => s.status == SemanticIndexStatus.idle && s.backfillCompleted == null), 148 + ], 149 + ); 150 + }); 151 + }); 152 + }
+310
test/features/search/cubit/semantic_search_cubit_test.dart
··· 1 + import 'dart:typed_data'; 2 + 3 + import 'package:bloc_test/bloc_test.dart'; 4 + import 'package:flutter_test/flutter_test.dart'; 5 + import 'package:lazurite/core/embedding/embedding_service.dart'; 6 + import 'package:lazurite/features/search/cubit/semantic_search_cubit.dart'; 7 + import 'package:lazurite/features/search/data/semantic_search_repository.dart'; 8 + import 'package:lazurite/features/search/data/semantic_search_result.dart'; 9 + import 'package:mocktail/mocktail.dart'; 10 + 11 + class MockSemanticSearchRepository extends Mock implements SemanticSearchRepository {} 12 + 13 + Future<EmbeddingService> _makeAvailableService() async { 14 + final service = EmbeddingService.forTesting((_) async => Float32List.fromList(List.filled(384, 0.1))); 15 + await service.initialize(); 16 + return service; 17 + } 18 + 19 + EmbeddingService _unavailableService() => EmbeddingService(); 20 + 21 + const _accountDid = 'did:plc:testuser'; 22 + 23 + const _result1 = SemanticSearchResult( 24 + postUri: 'at://did:plc:a/app.bsky.feed.post/1', 25 + score: 85.0, 26 + source: 'saved', 27 + postJson: '{}', 28 + ); 29 + 30 + const _result2 = SemanticSearchResult( 31 + postUri: 'at://did:plc:b/app.bsky.feed.post/2', 32 + score: 72.5, 33 + source: 'liked', 34 + postJson: '{}', 35 + ); 36 + 37 + void main() { 38 + late MockSemanticSearchRepository mockRepo; 39 + late EmbeddingService availableService; 40 + 41 + setUp(() async { 42 + mockRepo = MockSemanticSearchRepository(); 43 + availableService = await _makeAvailableService(); 44 + }); 45 + 46 + SemanticSearchCubit buildCubit() => SemanticSearchCubit( 47 + repository: mockRepo, 48 + embeddingService: availableService, 49 + accountDid: _accountDid, 50 + debounceDuration: Duration.zero, 51 + ); 52 + 53 + group('SemanticSearchCubit', () { 54 + group('initial state', () { 55 + test('is initial when embedding service is available', () { 56 + final cubit = buildCubit(); 57 + expect(cubit.state.status, SemanticSearchStatus.initial); 58 + expect(cubit.state.results, isEmpty); 59 + expect(cubit.state.query, ''); 60 + expect(cubit.state.scope, SearchScope.both); 61 + expect(cubit.state.errorMessage, isNull); 62 + }); 63 + 64 + test('is unavailable when embedding service is not available', () { 65 + final cubit = SemanticSearchCubit( 66 + repository: mockRepo, 67 + embeddingService: _unavailableService(), 68 + accountDid: _accountDid, 69 + ); 70 + expect(cubit.state.status, SemanticSearchStatus.unavailable); 71 + }); 72 + }); 73 + 74 + group('search', () { 75 + blocTest<SemanticSearchCubit, SemanticSearchState>( 76 + 'empty query clears results immediately without calling repository', 77 + build: buildCubit, 78 + seed: () => const SemanticSearchState( 79 + status: SemanticSearchStatus.loaded, 80 + results: [_result1], 81 + query: 'rust', 82 + scope: SearchScope.saved, 83 + ), 84 + act: (cubit) => cubit.search(''), 85 + expect: () => [ 86 + predicate<SemanticSearchState>( 87 + (s) => 88 + s.status == SemanticSearchStatus.initial && 89 + s.results.isEmpty && 90 + s.query == '' && 91 + s.scope == SearchScope.saved, 92 + ), 93 + ], 94 + verify: (_) => verifyNever(() => mockRepo.search(any(), any())), 95 + ); 96 + 97 + blocTest<SemanticSearchCubit, SemanticSearchState>( 98 + 'whitespace-only query clears results', 99 + build: buildCubit, 100 + act: (cubit) => cubit.search(' '), 101 + expect: () => [ 102 + predicate<SemanticSearchState>((s) => s.status == SemanticSearchStatus.initial && s.results.isEmpty), 103 + ], 104 + verify: (_) => verifyNever(() => mockRepo.search(any(), any())), 105 + ); 106 + 107 + blocTest<SemanticSearchCubit, SemanticSearchState>( 108 + 'emits searching then loaded on successful search', 109 + build: buildCubit, 110 + setUp: () { 111 + when( 112 + () => mockRepo.search( 113 + any(), 114 + any(), 115 + source: any(named: 'source'), 116 + maxResults: any(named: 'maxResults'), 117 + ), 118 + ).thenAnswer((_) async => [_result1, _result2]); 119 + }, 120 + act: (cubit) => cubit.search('flutter'), 121 + expect: () => [ 122 + predicate<SemanticSearchState>((s) => s.status == SemanticSearchStatus.searching && s.query == 'flutter'), 123 + predicate<SemanticSearchState>( 124 + (s) => s.status == SemanticSearchStatus.loaded && s.results.length == 2 && s.results.first == _result1, 125 + ), 126 + ], 127 + ); 128 + 129 + blocTest<SemanticSearchCubit, SemanticSearchState>( 130 + 'passes null source for SearchScope.both', 131 + build: buildCubit, 132 + setUp: () { 133 + when( 134 + () => mockRepo.search( 135 + any(), 136 + any(), 137 + source: any(named: 'source'), 138 + maxResults: any(named: 'maxResults'), 139 + ), 140 + ).thenAnswer((_) async => const []); 141 + }, 142 + act: (cubit) => cubit.search('test'), 143 + verify: (_) { 144 + verify( 145 + () => mockRepo.search('test', _accountDid, source: null, maxResults: any(named: 'maxResults')), 146 + ).called(1); 147 + }, 148 + ); 149 + 150 + blocTest<SemanticSearchCubit, SemanticSearchState>( 151 + 'passes saved source for SearchScope.saved', 152 + build: buildCubit, 153 + seed: () => const SemanticSearchState(scope: SearchScope.saved), 154 + setUp: () { 155 + when( 156 + () => mockRepo.search( 157 + any(), 158 + any(), 159 + source: any(named: 'source'), 160 + maxResults: any(named: 'maxResults'), 161 + ), 162 + ).thenAnswer((_) async => const []); 163 + }, 164 + act: (cubit) => cubit.search('test'), 165 + verify: (_) { 166 + verify( 167 + () => mockRepo.search( 168 + 'test', 169 + _accountDid, 170 + source: 'saved', 171 + maxResults: any(named: 'maxResults'), 172 + ), 173 + ).called(1); 174 + }, 175 + ); 176 + 177 + blocTest<SemanticSearchCubit, SemanticSearchState>( 178 + 'passes liked source for SearchScope.liked', 179 + build: buildCubit, 180 + seed: () => const SemanticSearchState(scope: SearchScope.liked), 181 + setUp: () { 182 + when( 183 + () => mockRepo.search( 184 + any(), 185 + any(), 186 + source: any(named: 'source'), 187 + maxResults: any(named: 'maxResults'), 188 + ), 189 + ).thenAnswer((_) async => const []); 190 + }, 191 + act: (cubit) => cubit.search('test'), 192 + verify: (_) { 193 + verify( 194 + () => mockRepo.search( 195 + 'test', 196 + _accountDid, 197 + source: 'liked', 198 + maxResults: any(named: 'maxResults'), 199 + ), 200 + ).called(1); 201 + }, 202 + ); 203 + 204 + blocTest<SemanticSearchCubit, SemanticSearchState>( 205 + 'emits error when repository throws', 206 + build: buildCubit, 207 + setUp: () { 208 + when( 209 + () => mockRepo.search( 210 + any(), 211 + any(), 212 + source: any(named: 'source'), 213 + maxResults: any(named: 'maxResults'), 214 + ), 215 + ).thenThrow(Exception('network error')); 216 + }, 217 + act: (cubit) => cubit.search('rust'), 218 + expect: () => [ 219 + predicate<SemanticSearchState>((s) => s.status == SemanticSearchStatus.searching), 220 + predicate<SemanticSearchState>((s) => s.status == SemanticSearchStatus.error && s.errorMessage != null), 221 + ], 222 + ); 223 + 224 + blocTest<SemanticSearchCubit, SemanticSearchState>( 225 + 'emits unavailable when service is not available', 226 + build: () => 227 + SemanticSearchCubit(repository: mockRepo, embeddingService: _unavailableService(), accountDid: _accountDid), 228 + act: (cubit) => cubit.search('flutter'), 229 + expect: () => [predicate<SemanticSearchState>((s) => s.status == SemanticSearchStatus.unavailable)], 230 + verify: (_) => verifyNever(() => mockRepo.search(any(), any())), 231 + ); 232 + }); 233 + 234 + group('setScope', () { 235 + blocTest<SemanticSearchCubit, SemanticSearchState>( 236 + 'changes scope and re-searches when query is non-empty', 237 + build: buildCubit, 238 + seed: () => const SemanticSearchState( 239 + status: SemanticSearchStatus.loaded, 240 + query: 'flutter', 241 + scope: SearchScope.both, 242 + results: [_result1], 243 + ), 244 + setUp: () { 245 + when( 246 + () => mockRepo.search( 247 + any(), 248 + any(), 249 + source: any(named: 'source'), 250 + maxResults: any(named: 'maxResults'), 251 + ), 252 + ).thenAnswer((_) async => [_result2]); 253 + }, 254 + act: (cubit) => cubit.setScope(SearchScope.saved), 255 + expect: () => [ 256 + predicate<SemanticSearchState>( 257 + (s) => s.scope == SearchScope.saved && s.status == SemanticSearchStatus.loaded, 258 + ), 259 + predicate<SemanticSearchState>((s) => s.status == SemanticSearchStatus.searching), 260 + predicate<SemanticSearchState>( 261 + (s) => s.status == SemanticSearchStatus.loaded && s.results.length == 1 && s.results.first == _result2, 262 + ), 263 + ], 264 + ); 265 + 266 + blocTest<SemanticSearchCubit, SemanticSearchState>( 267 + 'changes scope without re-searching when query is empty', 268 + build: buildCubit, 269 + act: (cubit) => cubit.setScope(SearchScope.liked), 270 + expect: () => [ 271 + predicate<SemanticSearchState>( 272 + (s) => s.scope == SearchScope.liked && s.status == SemanticSearchStatus.initial, 273 + ), 274 + ], 275 + verify: (_) => verifyNever(() => mockRepo.search(any(), any())), 276 + ); 277 + 278 + blocTest<SemanticSearchCubit, SemanticSearchState>( 279 + 'is a no-op when scope is unchanged', 280 + build: buildCubit, 281 + act: (cubit) => cubit.setScope(SearchScope.both), 282 + expect: () => [], 283 + verify: (_) => verifyNever(() => mockRepo.search(any(), any())), 284 + ); 285 + }); 286 + 287 + group('clearResults', () { 288 + blocTest<SemanticSearchCubit, SemanticSearchState>( 289 + 'resets to initial status with results cleared, preserving scope', 290 + build: buildCubit, 291 + seed: () => const SemanticSearchState( 292 + status: SemanticSearchStatus.loaded, 293 + results: [_result1], 294 + query: 'flutter', 295 + scope: SearchScope.saved, 296 + ), 297 + act: (cubit) => cubit.clearResults(), 298 + expect: () => [ 299 + predicate<SemanticSearchState>( 300 + (s) => 301 + s.status == SemanticSearchStatus.initial && 302 + s.results.isEmpty && 303 + s.query == '' && 304 + s.scope == SearchScope.saved, 305 + ), 306 + ], 307 + ); 308 + }); 309 + }); 310 + }
+4 -11
test/features/search/data/semantic_indexer_test.dart
··· 1 1 import 'dart:convert'; 2 2 import 'dart:typed_data'; 3 3 4 + import 'package:drift/drift.dart' show Value; 4 5 import 'package:drift/native.dart'; 5 6 import 'package:flutter_test/flutter_test.dart'; 6 7 import 'package:lazurite/core/database/app_database.dart'; ··· 8 9 import 'package:lazurite/core/objectbox/embedded_post.dart'; 9 10 import 'package:lazurite/core/objectbox/objectbox_store.dart'; 10 11 import 'package:lazurite/features/search/data/embedding_repository.dart'; 11 - import 'package:drift/drift.dart' show Value; 12 12 import 'package:lazurite/features/search/data/post_text_extractor.dart'; 13 13 import 'package:lazurite/features/search/data/semantic_indexer.dart'; 14 14 import 'package:lazurite/objectbox.g.dart'; ··· 32 32 33 33 EmbeddingService _availableService() => EmbeddingService.forTesting((_) async => _unitVector()); 34 34 35 - EmbeddingService _unavailableService() { 36 - // Returns an uninitialized service (isAvailable == false). 37 - return EmbeddingService(); 38 - } 35 + /// Returns an uninitialized service (isAvailable == false). 36 + EmbeddingService _unavailableService() => EmbeddingService(); 39 37 40 38 /// Minimal valid PostView JSON with a text record. 41 39 String _savedPostJson(String text) => jsonEncode({ ··· 132 130 final indexer = makeIndexer(); 133 131 await indexer.initialize(); 134 132 135 - // JSON that cannot be parsed as PostView or FeedViewPost. 136 133 await indexer.indexPost('at://did/post/1', '{}', 'did:plc:user', 'saved'); 137 134 138 135 expect(embeddingRepo.countByAccount('did:plc:user'), equals(0)); ··· 182 179 183 180 indexer.queueIndexPost('at://did/post/1', _savedPostJson('queued post'), 'did:plc:user', 'saved'); 184 181 185 - // Drain the event loop to allow the queue to process. 186 182 await Future<void>.delayed(Duration.zero); 187 183 await Future<void>.delayed(Duration.zero); 188 184 ··· 206 202 indexer.queueIndexPost('at://did/post/2', _savedPostJson('two'), 'did:plc:user', 'saved'); 207 203 indexer.queueIndexPost('at://did/post/3', _savedPostJson('three'), 'did:plc:user', 'saved'); 208 204 209 - // Wait for all items to process. 210 205 await Future<void>.delayed(const Duration(milliseconds: 100)); 211 206 212 207 expect(embeddingRepo.countByAccount('did:plc:user'), equals(3)); ··· 286 281 embeddingRepository: embeddingRepo, 287 282 database: database, 288 283 ); 284 + 289 285 await countingIndexer.initialize(); 290 - 291 286 await countingIndexer.backfill('did:plc:user').toList(); 292 - 293 - // Already indexed, so embed should not be called. 294 287 expect(embedCallCount, equals(0)); 295 288 }); 296 289
-5
test/features/search/data/semantic_search_repository_test.dart
··· 198 198 expect(results, hasLength(1)); 199 199 expect(results.first.source, equals('liked')); 200 200 final decoded = jsonDecode(results.first.postJson) as Map<String, dynamic>; 201 - // liked post JSON has a nested 'post' key 202 201 expect((decoded['post'] as Map<String, dynamic>)['uri'], equals('at://did/post/2')); 203 202 }); 204 203 ··· 216 215 test('identical-vector query produces near-100% score', () async { 217 216 await insertSavedPost('at://did/post/1', 'did:plc:user'); 218 217 219 - // Query with the same unit vector the post was indexed with. 220 218 final svc = EmbeddingService.forTesting((_) async => _unitVector()); 221 219 await svc.initialize(); 222 220 final repo = makeRepo(service: svc); 223 221 final results = await repo.search('hello', 'did:plc:user'); 224 222 225 223 expect(results, isNotEmpty); 226 - // Score should be very close to 100%. 227 224 expect(results.first.score, greaterThan(90.0)); 228 225 }); 229 226 ··· 275 272 }); 276 273 277 274 test('skips results whose postJson cannot be found in Drift', () async { 278 - // Insert embedding in ObjectBox but NOT in Drift. 279 275 embeddingRepo.upsert( 280 276 EmbeddedPost( 281 277 postUri: 'at://did/post/orphan', ··· 290 286 final repo = makeRepo(); 291 287 final results = await repo.search('hello', 'did:plc:user'); 292 288 293 - // The orphaned result is silently skipped. 294 289 expect(results, isEmpty); 295 290 }); 296 291