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: FollowAuditCubit for follow audit state management

+595 -46
+16 -16
docs/tasks/phase-8.md
··· 23 23 24 24 #### Cubit 25 25 26 - - [ ] `FollowAuditState` — `status` (initial/fetching/classifying/ready/unfollowing/complete/error), `results`, `totalFollows`, `progress`, `failedProfiles`, `unfollowedCount`, `errorMessage`, `visibleStatuses` 27 - - [ ] `FollowAuditCubit` — depends on `FollowAuditRepository`, authenticated DID 28 - - [ ] `audit()` — orchestrates fetch → classify → ready, emits progress updates during each phase 29 - - [ ] `toggleSelection(int index)` — toggle individual record selection 30 - - [ ] `selectAllByStatus(FollowStatus)` / `deselectAllByStatus(FollowStatus)` — bulk select/deselect by category 31 - - [ ] `toggleVisibility(FollowStatus)` — show/hide category in results list 32 - - [ ] `confirmUnfollow()` — call `batchUnfollow` with selected records, emit unfollowing → complete, clear unfollowed records from results 26 + - [x] `FollowAuditState` — `status` (initial/fetching/classifying/ready/unfollowing/complete/error), `results`, `totalFollows`, `progress`, `failedProfiles`, `unfollowedCount`, `errorMessage`, `visibleStatuses` 27 + - [x] `FollowAuditCubit` — depends on `FollowAuditRepository`, authenticated DID 28 + - [x] `audit()` — orchestrates fetch → classify → ready, emits progress updates during each phase 29 + - [x] `toggleSelection(int index)` — toggle individual record selection 30 + - [x] `selectAllByStatus(FollowStatus)` / `deselectAllByStatus(FollowStatus)` — bulk select/deselect by category 31 + - [x] `toggleVisibility(FollowStatus)` — show/hide category in results list 32 + - [x] `confirmUnfollow()` — call `batchUnfollow` with selected records, emit unfollowing → complete, clear unfollowed records from results 33 33 34 34 ### UI 35 35 ··· 79 79 80 80 #### Unit Tests — Cubit 81 81 82 - - [ ] `audit()` — state transitions: initial → fetching → classifying → ready 83 - - [ ] `audit()` — progress updates emitted during fetch and classify phases 84 - - [ ] `audit()` — error during fetch: initial → fetching → error 85 - - [ ] `audit()` — empty results: transitions to ready with empty list 86 - - [ ] `toggleSelection` — toggles selected flag on correct index, emits new state 87 - - [ ] `selectAllByStatus` / `deselectAllByStatus` — selects/deselects all records matching status 88 - - [ ] `toggleVisibility` — adds/removes status from visibleStatuses set 89 - - [ ] `confirmUnfollow` — state transitions: ready → unfollowing → complete, unfollowed records removed from results 90 - - [ ] `confirmUnfollow` — error during unfollow: ready → unfollowing → error with partial count 82 + - [x] `audit()` — state transitions: initial → fetching → classifying → ready 83 + - [x] `audit()` — progress updates emitted during fetch and classify phases 84 + - [x] `audit()` — error during fetch: initial → fetching → error 85 + - [x] `audit()` — empty results: transitions to ready with empty list 86 + - [x] `toggleSelection` — toggles selected flag on correct index, emits new state 87 + - [x] `selectAllByStatus` / `deselectAllByStatus` — selects/deselects all records matching status 88 + - [x] `toggleVisibility` — adds/removes status from visibleStatuses set 89 + - [x] `confirmUnfollow` — state transitions: ready → unfollowing → complete, unfollowed records removed from results 90 + - [x] `confirmUnfollow` — error during unfollow: ready → unfollowing → error with partial count 91 91 92 92 #### Widget Tests (FollowAuditScreen) 93 93
+170
lib/features/profile/cubit/follow_audit_cubit.dart
··· 1 + import 'package:equatable/equatable.dart'; 2 + import 'package:flutter_bloc/flutter_bloc.dart'; 3 + import 'package:lazurite/core/logging/app_logger.dart'; 4 + import 'package:lazurite/features/profile/data/follow_audit_repository.dart'; 5 + 6 + enum FollowAuditStatus { initial, fetching, classifying, ready, unfollowing, complete, error } 7 + 8 + class FollowAuditState extends Equatable { 9 + const FollowAuditState({ 10 + this.status = FollowAuditStatus.initial, 11 + this.results = const [], 12 + this.totalFollows = 0, 13 + this.progress = 0, 14 + this.failedProfiles = 0, 15 + this.unfollowedCount = 0, 16 + this.errorMessage, 17 + this.visibleStatuses = const {}, 18 + }); 19 + 20 + final FollowAuditStatus status; 21 + final List<ClassifiedFollow> results; 22 + final int totalFollows; 23 + final int progress; 24 + final int failedProfiles; 25 + final int unfollowedCount; 26 + final String? errorMessage; 27 + final Set<FollowStatus> visibleStatuses; 28 + 29 + List<ClassifiedFollow> get selectedResults => results.where((r) => r.selected).toList(); 30 + 31 + FollowAuditState copyWith({ 32 + FollowAuditStatus? status, 33 + List<ClassifiedFollow>? results, 34 + int? totalFollows, 35 + int? progress, 36 + int? failedProfiles, 37 + int? unfollowedCount, 38 + String? errorMessage, 39 + Set<FollowStatus>? visibleStatuses, 40 + bool clearError = false, 41 + }) { 42 + return FollowAuditState( 43 + status: status ?? this.status, 44 + results: results ?? this.results, 45 + totalFollows: totalFollows ?? this.totalFollows, 46 + progress: progress ?? this.progress, 47 + failedProfiles: failedProfiles ?? this.failedProfiles, 48 + unfollowedCount: unfollowedCount ?? this.unfollowedCount, 49 + errorMessage: clearError ? null : (errorMessage ?? this.errorMessage), 50 + visibleStatuses: visibleStatuses ?? this.visibleStatuses, 51 + ); 52 + } 53 + 54 + @override 55 + List<Object?> get props => [ 56 + status, 57 + results, 58 + results.map((r) => r.selected).toList(), 59 + totalFollows, 60 + progress, 61 + failedProfiles, 62 + unfollowedCount, 63 + errorMessage, 64 + visibleStatuses, 65 + ]; 66 + } 67 + 68 + class FollowAuditCubit extends Cubit<FollowAuditState> { 69 + FollowAuditCubit({required FollowAuditRepository repository, required String ownDid}) 70 + : _repository = repository, 71 + _ownDid = ownDid, 72 + super(const FollowAuditState()); 73 + 74 + final FollowAuditRepository _repository; 75 + final String _ownDid; 76 + 77 + /// Fetches all follows then classifies them, emitting progress states along the way. 78 + Future<void> audit() async { 79 + emit(state.copyWith(status: FollowAuditStatus.fetching, progress: 0, clearError: true)); 80 + 81 + List<FollowRecord> records; 82 + try { 83 + records = await _repository.fetchAllFollows( 84 + _ownDid, 85 + onProgress: (fetched) { 86 + emit(state.copyWith(status: FollowAuditStatus.fetching, progress: fetched)); 87 + }, 88 + ); 89 + } catch (error, stackTrace) { 90 + log.e('FollowAuditCubit: fetch failed', error: error, stackTrace: stackTrace); 91 + emit(state.copyWith(status: FollowAuditStatus.error, errorMessage: error.toString())); 92 + return; 93 + } 94 + 95 + emit(state.copyWith(status: FollowAuditStatus.classifying, totalFollows: records.length, progress: 0)); 96 + 97 + try { 98 + final (:results, :failedCount) = await _repository.classifyFollows( 99 + records, 100 + _ownDid, 101 + onProgress: (classified) { 102 + emit(state.copyWith(status: FollowAuditStatus.classifying, progress: classified)); 103 + }, 104 + ); 105 + 106 + emit( 107 + state.copyWith( 108 + status: FollowAuditStatus.ready, 109 + results: results, 110 + failedProfiles: failedCount, 111 + progress: records.length, 112 + visibleStatuses: FollowStatus.values.toSet(), 113 + ), 114 + ); 115 + } catch (error, stackTrace) { 116 + log.e('FollowAuditCubit: classify failed', error: error, stackTrace: stackTrace); 117 + emit(state.copyWith(status: FollowAuditStatus.error, errorMessage: error.toString())); 118 + } 119 + } 120 + 121 + /// Toggles the selection of the result at [index]. 122 + void toggleSelection(int index) { 123 + if (index < 0 || index >= state.results.length) return; 124 + final updated = List<ClassifiedFollow>.from(state.results); 125 + updated[index] = updated[index].copyWith(selected: !updated[index].selected); 126 + emit(state.copyWith(results: updated)); 127 + } 128 + 129 + /// Selects all results with the given [status]. 130 + void selectAllByStatus(FollowStatus status) { 131 + final updated = state.results.map((r) => r.status == status ? r.copyWith(selected: true) : r).toList(); 132 + emit(state.copyWith(results: updated)); 133 + } 134 + 135 + /// Deselects all results with the given [status]. 136 + void deselectAllByStatus(FollowStatus status) { 137 + final updated = state.results.map((r) => r.status == status ? r.copyWith(selected: false) : r).toList(); 138 + emit(state.copyWith(results: updated)); 139 + } 140 + 141 + /// Toggles visibility of [status] in the results list. 142 + void toggleVisibility(FollowStatus status) { 143 + final current = Set<FollowStatus>.from(state.visibleStatuses); 144 + if (current.contains(status)) { 145 + current.remove(status); 146 + } else { 147 + current.add(status); 148 + } 149 + emit(state.copyWith(visibleStatuses: current)); 150 + } 151 + 152 + /// Batch-deletes selected follows, transitioning to complete on success. 153 + Future<void> confirmUnfollow() async { 154 + final selected = state.selectedResults; 155 + if (selected.isEmpty) return; 156 + 157 + emit(state.copyWith(status: FollowAuditStatus.unfollowing)); 158 + 159 + try { 160 + final count = await _repository.batchUnfollow(selected, _ownDid); 161 + final selectedUris = selected.map((r) => r.record.uri).toSet(); 162 + final remaining = state.results.where((r) => !selectedUris.contains(r.record.uri)).toList(); 163 + 164 + emit(state.copyWith(status: FollowAuditStatus.complete, results: remaining, unfollowedCount: count)); 165 + } catch (error, stackTrace) { 166 + log.e('FollowAuditCubit: unfollow failed', error: error, stackTrace: stackTrace); 167 + emit(state.copyWith(status: FollowAuditStatus.error, errorMessage: error.toString())); 168 + } 169 + } 170 + }
+19 -18
lib/features/profile/data/follow_audit_repository.dart
··· 31 31 final String statusLabel; 32 32 final bool selected; 33 33 34 + ClassifiedFollow copyWith({bool? selected}) { 35 + return ClassifiedFollow( 36 + record: record, 37 + handle: handle, 38 + status: status, 39 + statusLabel: statusLabel, 40 + selected: selected ?? this.selected, 41 + ); 42 + } 43 + 34 44 @override 35 45 List<Object?> get props => [record.uri, handle, status, statusLabel]; 36 46 37 47 @override 38 48 bool get stringify => false; 39 - 40 - set selected(bool value) => selected = value; 41 49 } 42 50 43 51 const _profileBatchSize = 25; ··· 52 60 53 61 final dynamic _bluesky; 54 62 55 - /// Paginates all follow records for [did] and returns them as a flat list. 56 - Future<List<FollowRecord>> fetchAllFollows(String did) async { 63 + Future<List<FollowRecord>> fetchAllFollows(String did, {void Function(int fetched)? onProgress}) async { 57 64 final records = <FollowRecord>[]; 58 65 String? cursor; 59 66 ··· 73 80 records.add(FollowRecord(uri: uri, rkey: rkey, subjectDid: subjectDid)); 74 81 } 75 82 cursor = response.data.cursor as String?; 83 + onProgress?.call(records.length); 76 84 } while (cursor != null); 77 85 78 86 return records; 79 87 } 80 88 81 - /// Classifies each follow in [records] by fetching profiles in batches. 82 - /// 83 - /// Uses 2 concurrent batches of 25 with a 500ms delay between groups. 84 - /// Falls back to per-DID lookup for missing batch entries. 85 - /// Returns the classified (problematic) follows and a count of profiles 86 - /// that could not be fetched at all. 87 89 Future<({List<ClassifiedFollow> results, int failedCount})> classifyFollows( 88 90 List<FollowRecord> records, 89 - String ownDid, 90 - ) async { 91 + String ownDid, { 92 + void Function(int classified)? onProgress, 93 + }) async { 91 94 final results = <ClassifiedFollow>[]; 92 95 var failedCount = 0; 96 + var processedCount = 0; 93 97 94 98 final recordByDid = <String, FollowRecord>{for (final r in records) r.subjectDid: r}; 95 99 final dids = records.map((r) => r.subjectDid).toList(); ··· 115 119 results.addAll(br.classified); 116 120 failedCount += br.failedCount; 117 121 } 122 + 123 + processedCount += groupDids.length; 124 + onProgress?.call(processedCount); 118 125 } 119 126 120 127 return (results: results, failedCount: failedCount); ··· 156 163 return _BatchResult(classified: classified, failedCount: failedCount); 157 164 } 158 165 159 - /// Fetches a batch via getProfiles with retry. Returns a map of DID → ProfileView 160 - /// for successfully resolved profiles. Missing DIDs will not appear in the map. 161 166 Future<Map<String, ProfileView>> _fetchBatchWithRetry(List<String> batch) async { 162 167 for (var attempt = 0; attempt <= _maxRetries; attempt++) { 163 168 try { ··· 210 215 return const _SingleResult(profile: null, status: null, failed: true); 211 216 } 212 217 213 - /// Batch-deletes selected follows via applyWrites in chunks of 200. 214 - /// 215 - /// [ownDid] is the authenticated user's DID (repo owner). 216 - /// Executes batches sequentially. Returns the number of successfully deleted records. 217 218 Future<int> batchUnfollow(List<ClassifiedFollow> selected, String ownDid) async { 218 219 if (selected.isEmpty) return 0; 219 220
+389
test/features/profile/cubit/follow_audit_cubit_test.dart
··· 1 + import 'package:bloc_test/bloc_test.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:lazurite/features/profile/cubit/follow_audit_cubit.dart'; 4 + import 'package:lazurite/features/profile/data/follow_audit_repository.dart'; 5 + 6 + class _FakeFollowAuditRepository implements FollowAuditRepository { 7 + _FakeFollowAuditRepository({ 8 + List<FollowRecord> fetchResult = const [], 9 + List<ClassifiedFollow> classifyResult = const [], 10 + int classifyFailedCount = 0, 11 + int batchUnfollowResult = 0, 12 + Exception? fetchError, 13 + Exception? classifyError, 14 + Exception? unfollowError, 15 + List<int>? fetchProgressValues, 16 + List<int>? classifyProgressValues, 17 + }) : _fetchResult = fetchResult, 18 + _classifyResult = classifyResult, 19 + _classifyFailedCount = classifyFailedCount, 20 + _batchUnfollowResult = batchUnfollowResult, 21 + _fetchError = fetchError, 22 + _classifyError = classifyError, 23 + _unfollowError = unfollowError, 24 + _fetchProgressValues = fetchProgressValues, 25 + _classifyProgressValues = classifyProgressValues; 26 + 27 + final List<FollowRecord> _fetchResult; 28 + final List<ClassifiedFollow> _classifyResult; 29 + final int _classifyFailedCount; 30 + final int _batchUnfollowResult; 31 + final Exception? _fetchError; 32 + final Exception? _classifyError; 33 + final Exception? _unfollowError; 34 + final List<int>? _fetchProgressValues; 35 + final List<int>? _classifyProgressValues; 36 + 37 + @override 38 + Future<List<FollowRecord>> fetchAllFollows(String did, {void Function(int fetched)? onProgress}) async { 39 + if (_fetchError != null) throw _fetchError; 40 + if (_fetchProgressValues != null) { 41 + for (final v in _fetchProgressValues) { 42 + onProgress?.call(v); 43 + } 44 + } 45 + return _fetchResult; 46 + } 47 + 48 + @override 49 + Future<({List<ClassifiedFollow> results, int failedCount})> classifyFollows( 50 + List<FollowRecord> records, 51 + String ownDid, { 52 + void Function(int classified)? onProgress, 53 + }) async { 54 + if (_classifyError != null) throw _classifyError; 55 + if (_classifyProgressValues != null) { 56 + for (final v in _classifyProgressValues) { 57 + onProgress?.call(v); 58 + } 59 + } 60 + return (results: _classifyResult, failedCount: _classifyFailedCount); 61 + } 62 + 63 + @override 64 + Future<int> batchUnfollow(List<ClassifiedFollow> selected, String ownDid) async { 65 + if (_unfollowError != null) throw _unfollowError; 66 + return _batchUnfollowResult; 67 + } 68 + 69 + @override 70 + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); 71 + } 72 + 73 + const _ownDid = 'did:plc:owner'; 74 + 75 + FollowRecord _record(String did, [String? rkey]) { 76 + final k = rkey ?? 'rkey${did.hashCode.abs()}'; 77 + return FollowRecord(uri: 'at:///app.bsky.graph.follow/$k', rkey: k, subjectDid: did); 78 + } 79 + 80 + ClassifiedFollow _classified(String did, FollowStatus status) { 81 + return ClassifiedFollow(record: _record(did), handle: '$did.bsky.social', status: status, statusLabel: status.name); 82 + } 83 + 84 + FollowAuditCubit _cubit(_FakeFollowAuditRepository repo) => FollowAuditCubit(repository: repo, ownDid: _ownDid); 85 + 86 + void main() { 87 + group('FollowAuditState', () { 88 + test('initial state has correct defaults', () { 89 + const s = FollowAuditState(); 90 + expect(s.status, FollowAuditStatus.initial); 91 + expect(s.results, isEmpty); 92 + expect(s.totalFollows, 0); 93 + expect(s.progress, 0); 94 + expect(s.failedProfiles, 0); 95 + expect(s.unfollowedCount, 0); 96 + expect(s.errorMessage, isNull); 97 + expect(s.visibleStatuses, isEmpty); 98 + }); 99 + 100 + test('selectedResults returns only selected items', () { 101 + final s = FollowAuditState( 102 + results: [ 103 + _classified('did:plc:a', FollowStatus.deleted), 104 + _classified('did:plc:b', FollowStatus.blockedBy).copyWith(selected: true), 105 + ], 106 + ); 107 + expect(s.selectedResults.length, 1); 108 + }); 109 + 110 + test('copyWith clears errorMessage when clearError is true', () { 111 + const s = FollowAuditState(errorMessage: 'oops'); 112 + final cleared = s.copyWith(clearError: true); 113 + expect(cleared.errorMessage, isNull); 114 + }); 115 + 116 + test('Equatable props cover all fields', () { 117 + const a = FollowAuditState(totalFollows: 5); 118 + const b = FollowAuditState(totalFollows: 5); 119 + expect(a, equals(b)); 120 + 121 + const c = FollowAuditState(totalFollows: 6); 122 + expect(a, isNot(equals(c))); 123 + }); 124 + }); 125 + 126 + group('FollowAuditCubit.audit', () { 127 + blocTest<FollowAuditCubit, FollowAuditState>( 128 + 'transitions initial → fetching → classifying → ready', 129 + build: () => _cubit( 130 + _FakeFollowAuditRepository( 131 + fetchResult: [_record('did:plc:alice')], 132 + classifyResult: [_classified('did:plc:alice', FollowStatus.blockedBy)], 133 + ), 134 + ), 135 + act: (cubit) => cubit.audit(), 136 + expect: () => [ 137 + isA<FollowAuditState>().having((s) => s.status, 'status', FollowAuditStatus.fetching), 138 + isA<FollowAuditState>().having((s) => s.status, 'status', FollowAuditStatus.classifying), 139 + isA<FollowAuditState>() 140 + .having((s) => s.status, 'status', FollowAuditStatus.ready) 141 + .having((s) => s.results.length, 'results', 1) 142 + .having((s) => s.totalFollows, 'totalFollows', 1), 143 + ], 144 + ); 145 + 146 + blocTest<FollowAuditCubit, FollowAuditState>( 147 + 'emits progress updates during fetch phase', 148 + build: () => _cubit( 149 + _FakeFollowAuditRepository( 150 + fetchResult: [_record('did:plc:a'), _record('did:plc:b'), _record('did:plc:c')], 151 + fetchProgressValues: [1, 2, 3], 152 + classifyResult: [], 153 + ), 154 + ), 155 + act: (cubit) => cubit.audit(), 156 + expect: () => [ 157 + isA<FollowAuditState>() 158 + .having((s) => s.status, 'status', FollowAuditStatus.fetching) 159 + .having((s) => s.progress, 'progress', 0), 160 + isA<FollowAuditState>() 161 + .having((s) => s.status, 'status', FollowAuditStatus.fetching) 162 + .having((s) => s.progress, 'progress', 1), 163 + isA<FollowAuditState>() 164 + .having((s) => s.status, 'status', FollowAuditStatus.fetching) 165 + .having((s) => s.progress, 'progress', 2), 166 + isA<FollowAuditState>() 167 + .having((s) => s.status, 'status', FollowAuditStatus.fetching) 168 + .having((s) => s.progress, 'progress', 3), 169 + isA<FollowAuditState>().having((s) => s.status, 'status', FollowAuditStatus.classifying), 170 + isA<FollowAuditState>().having((s) => s.status, 'status', FollowAuditStatus.ready), 171 + ], 172 + ); 173 + 174 + blocTest<FollowAuditCubit, FollowAuditState>( 175 + 'emits progress updates during classify phase', 176 + build: () => _cubit( 177 + _FakeFollowAuditRepository( 178 + fetchResult: [_record('did:plc:a'), _record('did:plc:b')], 179 + classifyProgressValues: [1, 2], 180 + classifyResult: [], 181 + ), 182 + ), 183 + act: (cubit) => cubit.audit(), 184 + expect: () => [ 185 + isA<FollowAuditState>().having((s) => s.status, 'status', FollowAuditStatus.fetching), 186 + isA<FollowAuditState>() 187 + .having((s) => s.status, 'status', FollowAuditStatus.classifying) 188 + .having((s) => s.progress, 'progress', 0), 189 + isA<FollowAuditState>() 190 + .having((s) => s.status, 'status', FollowAuditStatus.classifying) 191 + .having((s) => s.progress, 'progress', 1), 192 + isA<FollowAuditState>() 193 + .having((s) => s.status, 'status', FollowAuditStatus.classifying) 194 + .having((s) => s.progress, 'progress', 2), 195 + isA<FollowAuditState>().having((s) => s.status, 'status', FollowAuditStatus.ready), 196 + ], 197 + ); 198 + 199 + blocTest<FollowAuditCubit, FollowAuditState>( 200 + 'transitions to error when fetch fails', 201 + build: () => _cubit(_FakeFollowAuditRepository(fetchError: Exception('network error'))), 202 + act: (cubit) => cubit.audit(), 203 + expect: () => [ 204 + isA<FollowAuditState>().having((s) => s.status, 'status', FollowAuditStatus.fetching), 205 + isA<FollowAuditState>() 206 + .having((s) => s.status, 'status', FollowAuditStatus.error) 207 + .having((s) => s.errorMessage, 'errorMessage', isNotNull), 208 + ], 209 + ); 210 + 211 + blocTest<FollowAuditCubit, FollowAuditState>( 212 + 'transitions to ready with empty results when no problematic follows found', 213 + build: () => _cubit( 214 + _FakeFollowAuditRepository(fetchResult: [_record('did:plc:alice'), _record('did:plc:bob')], classifyResult: []), 215 + ), 216 + act: (cubit) => cubit.audit(), 217 + expect: () => [ 218 + isA<FollowAuditState>().having((s) => s.status, 'status', FollowAuditStatus.fetching), 219 + isA<FollowAuditState>().having((s) => s.status, 'status', FollowAuditStatus.classifying), 220 + isA<FollowAuditState>() 221 + .having((s) => s.status, 'status', FollowAuditStatus.ready) 222 + .having((s) => s.results, 'results', isEmpty) 223 + .having((s) => s.totalFollows, 'totalFollows', 2), 224 + ], 225 + ); 226 + 227 + blocTest<FollowAuditCubit, FollowAuditState>( 228 + 'records failedProfiles from classify', 229 + build: () => _cubit( 230 + _FakeFollowAuditRepository(fetchResult: [_record('did:plc:alice')], classifyResult: [], classifyFailedCount: 3), 231 + ), 232 + act: (cubit) => cubit.audit(), 233 + expect: () => [ 234 + isA<FollowAuditState>().having((s) => s.status, 'status', FollowAuditStatus.fetching), 235 + isA<FollowAuditState>().having((s) => s.status, 'status', FollowAuditStatus.classifying), 236 + isA<FollowAuditState>() 237 + .having((s) => s.status, 'status', FollowAuditStatus.ready) 238 + .having((s) => s.failedProfiles, 'failedProfiles', 3), 239 + ], 240 + ); 241 + }); 242 + 243 + group('FollowAuditCubit.toggleSelection', () { 244 + test('toggles selected flag on correct index and emits new state', () { 245 + final cf = _classified('did:plc:alice', FollowStatus.blockedBy); 246 + final cubit = FollowAuditCubit(repository: _FakeFollowAuditRepository(), ownDid: _ownDid); 247 + 248 + cubit.emit(FollowAuditState(status: FollowAuditStatus.ready, results: [cf])); 249 + cubit.toggleSelection(0); 250 + expect(cubit.state.results[0].selected, isTrue); 251 + }); 252 + 253 + test('toggling twice restores original selection', () { 254 + final cf = _classified('did:plc:alice', FollowStatus.blockedBy); 255 + final cubit = FollowAuditCubit(repository: _FakeFollowAuditRepository(), ownDid: _ownDid); 256 + cubit.emit(FollowAuditState(status: FollowAuditStatus.ready, results: [cf])); 257 + 258 + cubit.toggleSelection(0); 259 + cubit.toggleSelection(0); 260 + 261 + expect(cubit.state.results[0].selected, isFalse); 262 + }); 263 + 264 + test('out-of-bounds index does nothing', () { 265 + final cubit = FollowAuditCubit(repository: _FakeFollowAuditRepository(), ownDid: _ownDid); 266 + cubit.emit(const FollowAuditState(status: FollowAuditStatus.ready)); 267 + 268 + cubit.toggleSelection(99); 269 + 270 + expect(cubit.state.results, isEmpty); 271 + }); 272 + }); 273 + 274 + group('FollowAuditCubit.selectAllByStatus / deselectAllByStatus', () { 275 + test('selectAllByStatus selects all records matching status', () { 276 + final records = [ 277 + _classified('did:plc:a', FollowStatus.deleted), 278 + _classified('did:plc:b', FollowStatus.deleted), 279 + _classified('did:plc:c', FollowStatus.blockedBy), 280 + ]; 281 + final cubit = FollowAuditCubit(repository: _FakeFollowAuditRepository(), ownDid: _ownDid); 282 + cubit.emit(FollowAuditState(status: FollowAuditStatus.ready, results: records)); 283 + 284 + cubit.selectAllByStatus(FollowStatus.deleted); 285 + 286 + final state = cubit.state; 287 + expect(state.results[0].selected, isTrue); 288 + expect(state.results[1].selected, isTrue); 289 + expect(state.results[2].selected, isFalse); 290 + }); 291 + 292 + test('deselectAllByStatus deselects all records matching status', () { 293 + final records = [ 294 + _classified('did:plc:a', FollowStatus.deleted).copyWith(selected: true), 295 + _classified('did:plc:b', FollowStatus.deleted).copyWith(selected: true), 296 + _classified('did:plc:c', FollowStatus.blockedBy).copyWith(selected: true), 297 + ]; 298 + final cubit = FollowAuditCubit(repository: _FakeFollowAuditRepository(), ownDid: _ownDid); 299 + cubit.emit(FollowAuditState(status: FollowAuditStatus.ready, results: records)); 300 + 301 + cubit.deselectAllByStatus(FollowStatus.deleted); 302 + 303 + final state = cubit.state; 304 + expect(state.results[0].selected, isFalse); 305 + expect(state.results[1].selected, isFalse); 306 + expect(state.results[2].selected, isTrue); 307 + }); 308 + }); 309 + 310 + group('FollowAuditCubit.toggleVisibility', () { 311 + test('adds status to visibleStatuses when not present', () { 312 + final cubit = FollowAuditCubit(repository: _FakeFollowAuditRepository(), ownDid: _ownDid); 313 + cubit.emit(const FollowAuditState(status: FollowAuditStatus.ready, visibleStatuses: {})); 314 + 315 + cubit.toggleVisibility(FollowStatus.deleted); 316 + 317 + expect(cubit.state.visibleStatuses, contains(FollowStatus.deleted)); 318 + }); 319 + 320 + test('removes status from visibleStatuses when present', () { 321 + final cubit = FollowAuditCubit(repository: _FakeFollowAuditRepository(), ownDid: _ownDid); 322 + cubit.emit( 323 + const FollowAuditState( 324 + status: FollowAuditStatus.ready, 325 + visibleStatuses: {FollowStatus.deleted, FollowStatus.blockedBy}, 326 + ), 327 + ); 328 + 329 + cubit.toggleVisibility(FollowStatus.deleted); 330 + 331 + expect(cubit.state.visibleStatuses, isNot(contains(FollowStatus.deleted))); 332 + expect(cubit.state.visibleStatuses, contains(FollowStatus.blockedBy)); 333 + }); 334 + }); 335 + 336 + group('FollowAuditCubit.confirmUnfollow', () { 337 + blocTest<FollowAuditCubit, FollowAuditState>( 338 + 'transitions ready → unfollowing → complete, removes unfollowed records', 339 + build: () { 340 + final repo = _FakeFollowAuditRepository(batchUnfollowResult: 2); 341 + return FollowAuditCubit(repository: repo, ownDid: _ownDid); 342 + }, 343 + seed: () => FollowAuditState( 344 + status: FollowAuditStatus.ready, 345 + results: [ 346 + _classified('did:plc:a', FollowStatus.deleted).copyWith(selected: true), 347 + _classified('did:plc:b', FollowStatus.blockedBy).copyWith(selected: true), 348 + _classified('did:plc:c', FollowStatus.suspended), 349 + ], 350 + ), 351 + act: (cubit) => cubit.confirmUnfollow(), 352 + expect: () => [ 353 + isA<FollowAuditState>().having((s) => s.status, 'status', FollowAuditStatus.unfollowing), 354 + isA<FollowAuditState>() 355 + .having((s) => s.status, 'status', FollowAuditStatus.complete) 356 + .having((s) => s.unfollowedCount, 'unfollowedCount', 2) 357 + .having((s) => s.results.length, 'results length', 1), 358 + ], 359 + ); 360 + 361 + blocTest<FollowAuditCubit, FollowAuditState>( 362 + 'does nothing when no items selected', 363 + build: () => FollowAuditCubit(repository: _FakeFollowAuditRepository(), ownDid: _ownDid), 364 + seed: () => 365 + FollowAuditState(status: FollowAuditStatus.ready, results: [_classified('did:plc:a', FollowStatus.deleted)]), 366 + act: (cubit) => cubit.confirmUnfollow(), 367 + expect: () => [], 368 + ); 369 + 370 + blocTest<FollowAuditCubit, FollowAuditState>( 371 + 'transitions to error when batchUnfollow fails', 372 + build: () { 373 + final repo = _FakeFollowAuditRepository(unfollowError: Exception('applyWrites failed')); 374 + return FollowAuditCubit(repository: repo, ownDid: _ownDid); 375 + }, 376 + seed: () => FollowAuditState( 377 + status: FollowAuditStatus.ready, 378 + results: [_classified('did:plc:a', FollowStatus.deleted).copyWith(selected: true)], 379 + ), 380 + act: (cubit) => cubit.confirmUnfollow(), 381 + expect: () => [ 382 + isA<FollowAuditState>().having((s) => s.status, 'status', FollowAuditStatus.unfollowing), 383 + isA<FollowAuditState>() 384 + .having((s) => s.status, 'status', FollowAuditStatus.error) 385 + .having((s) => s.errorMessage, 'errorMessage', isNotNull), 386 + ], 387 + ); 388 + }); 389 + }
+1 -12
test/features/profile/data/follow_audit_repository_test.dart
··· 229 229 expect(cf.selected, isFalse); 230 230 }); 231 231 232 - test('selected is mutable', () { 233 - final cf = ClassifiedFollow( 234 - record: _followRecord('did:plc:alice'), 235 - handle: null, 236 - status: FollowStatus.suspended, 237 - statusLabel: 'Suspended', 238 - ); 239 - cf.selected = true; 240 - expect(cf.selected, isTrue); 241 - }); 242 - 243 232 test('Equatable excludes selected from equality', () { 244 233 final record = _followRecord('did:plc:alice'); 245 234 final a = ClassifiedFollow( ··· 253 242 handle: 'alice.bsky.social', 254 243 status: FollowStatus.blockedBy, 255 244 statusLabel: 'Blocked by', 245 + selected: true, 256 246 ); 257 - b.selected = true; 258 247 expect(a, equals(b)); 259 248 }); 260 249