mobile bluesky app made with flutter
lazurite.stormlightlabs.org/
mobile
bluesky
flutter
1import 'package:atproto/com_atproto_repo_listrecords.dart';
2import 'package:atproto_core/atproto_core.dart';
3import 'package:bluesky/app_bsky_actor_defs.dart';
4import 'package:bloc_test/bloc_test.dart';
5import 'package:flutter/material.dart';
6import 'package:flutter_bloc/flutter_bloc.dart';
7import 'package:flutter_test/flutter_test.dart';
8import 'package:lazurite/features/devtools/cubit/dev_tools_cubit.dart';
9import 'package:lazurite/features/devtools/presentation/dev_tools_screen.dart';
10import 'package:mocktail/mocktail.dart';
11
12class MockDevToolsCubit extends MockCubit<DevToolsState> implements DevToolsCubit {}
13
14class FakeDevToolsState extends Fake implements DevToolsState {}
15
16late MockDevToolsCubit mockDevToolsCubit;
17
18void main() {
19 setUpAll(() {
20 registerFallbackValue(FakeDevToolsState());
21 registerFallbackValue(
22 const RepoListRecordsRecord(
23 uri: AtUri('at://did:plc:test/app.bsky.feed.post/123'),
24 cid: 'cid123',
25 value: {'text': 'Test'},
26 ),
27 );
28 });
29
30 setUp(() {
31 mockDevToolsCubit = MockDevToolsCubit();
32
33 when(() => mockDevToolsCubit.state).thenReturn(const DevToolsState());
34 when(() => mockDevToolsCubit.resolve(any())).thenAnswer((_) async {});
35 when(() => mockDevToolsCubit.queryTypeahead(any())).thenAnswer((_) async {});
36 when(() => mockDevToolsCubit.loadCollection(any())).thenAnswer((_) async {});
37 when(() => mockDevToolsCubit.loadRecord(any())).thenAnswer((_) async {});
38 when(() => mockDevToolsCubit.loadMoreRecords()).thenAnswer((_) async {});
39 when(() => mockDevToolsCubit.goBackToRepo()).thenReturn(null);
40 when(() => mockDevToolsCubit.goBackToCollection()).thenReturn(null);
41 when(() => mockDevToolsCubit.clearTypeahead()).thenReturn(null);
42 when(() => mockDevToolsCubit.clearInput()).thenReturn(null);
43
44 whenListen(mockDevToolsCubit, const Stream<DevToolsState>.empty(), initialState: const DevToolsState());
45 });
46
47 Widget buildSubject({String? initialQuery}) {
48 return MaterialApp(
49 home: BlocProvider<DevToolsCubit>.value(
50 value: mockDevToolsCubit,
51 child: DevToolsScreen(initialQuery: initialQuery),
52 ),
53 );
54 }
55
56 group('DevToolsScreen', () {
57 testWidgets('renders empty state initially', (tester) async {
58 await tester.pumpWidget(buildSubject());
59
60 expect(find.text('PDS Explorer'), findsAtLeastNWidgets(1));
61 expect(find.textContaining('Enter a handle, DID, or AT-URI'), findsOneWidget);
62 expect(find.text('Inspired by pds.ls'), findsOneWidget);
63 });
64
65 testWidgets('renders loading state', (tester) async {
66 when(() => mockDevToolsCubit.state).thenReturn(const DevToolsState(status: DevToolsStatus.loading));
67 whenListen(
68 mockDevToolsCubit,
69 const Stream<DevToolsState>.empty(),
70 initialState: const DevToolsState(status: DevToolsStatus.loading),
71 );
72
73 await tester.pumpWidget(buildSubject());
74
75 expect(find.byType(CircularProgressIndicator), findsOneWidget);
76 });
77
78 testWidgets('renders error state', (tester) async {
79 when(
80 () => mockDevToolsCubit.state,
81 ).thenReturn(const DevToolsState(status: DevToolsStatus.error, errorMessage: 'Test error'));
82 whenListen(
83 mockDevToolsCubit,
84 const Stream<DevToolsState>.empty(),
85 initialState: const DevToolsState(status: DevToolsStatus.error, errorMessage: 'Test error'),
86 );
87
88 await tester.pumpWidget(buildSubject());
89
90 expect(find.text('Error'), findsOneWidget);
91 expect(find.text('Test error'), findsOneWidget);
92 });
93
94 testWidgets('renders repo overview with collection counts', (tester) async {
95 const state = DevToolsState(
96 status: DevToolsStatus.repoLoaded,
97 did: 'did:plc:test',
98 handle: 'test.bsky.social',
99 repoHandle: 'test.bsky.social',
100 collections: [
101 CollectionSummary('app.bsky.feed.post', recordCount: 2),
102 CollectionSummary('app.bsky.feed.like', recordCount: 3),
103 ],
104 );
105
106 when(() => mockDevToolsCubit.state).thenReturn(state);
107 whenListen(mockDevToolsCubit, const Stream<DevToolsState>.empty(), initialState: state);
108
109 await tester.pumpWidget(buildSubject());
110
111 expect(find.text('test.bsky.social'), findsAtLeastNWidgets(1));
112 expect(find.text('did:plc:test'), findsOneWidget);
113 expect(find.text('2 collections'), findsOneWidget);
114 expect(find.text('5 records'), findsOneWidget);
115 expect(find.text('app.bsky.feed.post'), findsOneWidget);
116 expect(find.text('app.bsky.feed.like'), findsOneWidget);
117 });
118
119 testWidgets('submitting search calls cubit resolve', (tester) async {
120 await tester.pumpWidget(buildSubject());
121
122 await tester.enterText(find.byType(TextField), 'alice.bsky.social');
123 await tester.tap(find.text('Resolve'));
124
125 verify(() => mockDevToolsCubit.resolve('alice.bsky.social')).called(1);
126 });
127
128 testWidgets('typing @handle triggers typeahead query', (tester) async {
129 await tester.pumpWidget(buildSubject());
130
131 await tester.enterText(find.byType(TextField), '@ali');
132 await tester.pump();
133
134 verify(() => mockDevToolsCubit.queryTypeahead('@ali')).called(1);
135 });
136
137 testWidgets('tapping a typeahead suggestion resolves the selected handle', (tester) async {
138 const state = DevToolsState(
139 typeaheadActors: [ProfileViewBasic(did: 'did:plc:alice', handle: 'alice.bsky.social', displayName: 'Alice')],
140 );
141 when(() => mockDevToolsCubit.state).thenReturn(state);
142 whenListen(mockDevToolsCubit, const Stream<DevToolsState>.empty(), initialState: state);
143
144 await tester.pumpWidget(buildSubject());
145 await tester.enterText(find.byType(TextField), '@a');
146 await tester.pump();
147
148 await tester.tap(find.text('Alice'));
149
150 verify(() => mockDevToolsCubit.resolve('alice.bsky.social')).called(1);
151 verify(() => mockDevToolsCubit.clearTypeahead()).called(1);
152 });
153
154 testWidgets('initial query prefills the input and resolves automatically', (tester) async {
155 await tester.pumpWidget(buildSubject(initialQuery: 'did:plc:test'));
156 await tester.pump();
157
158 expect(find.widgetWithText(TextField, 'did:plc:test'), findsOneWidget);
159 verify(() => mockDevToolsCubit.resolve('did:plc:test')).called(1);
160 });
161
162 testWidgets('tapping a record calls cubit loadRecord', (tester) async {
163 const record = RepoListRecordsRecord(
164 uri: AtUri('at://did:plc:test/app.bsky.feed.post/123'),
165 cid: 'cid123',
166 value: {'text': 'Summary'},
167 );
168 const state = DevToolsState(
169 status: DevToolsStatus.collectionLoaded,
170 did: 'did:plc:test',
171 handle: 'test.bsky.social',
172 repoHandle: 'test.bsky.social',
173 collections: [CollectionSummary('app.bsky.feed.post', recordCount: 1)],
174 selectedCollection: 'app.bsky.feed.post',
175 records: [record],
176 );
177
178 when(() => mockDevToolsCubit.state).thenReturn(state);
179 whenListen(mockDevToolsCubit, const Stream<DevToolsState>.empty(), initialState: state);
180
181 await tester.pumpWidget(buildSubject());
182 await tester.tap(find.text('123'));
183
184 verify(() => mockDevToolsCubit.loadRecord(record)).called(1);
185 });
186
187 testWidgets('renders breadcrumbs for record navigation', (tester) async {
188 const state = DevToolsState(
189 status: DevToolsStatus.recordLoaded,
190 did: 'did:plc:test',
191 handle: 'test.bsky.social',
192 repoHandle: 'test.bsky.social',
193 collections: [CollectionSummary('app.bsky.feed.post', recordCount: 1)],
194 selectedCollection: 'app.bsky.feed.post',
195 selectedRecord: RecordInfo(
196 uri: 'at://did:plc:test/app.bsky.feed.post/123',
197 cid: 'cid123',
198 value: {'text': 'Summary'},
199 ),
200 );
201
202 when(() => mockDevToolsCubit.state).thenReturn(state);
203 whenListen(mockDevToolsCubit, const Stream<DevToolsState>.empty(), initialState: state);
204
205 await tester.pumpWidget(buildSubject());
206
207 expect(find.byKey(const ValueKey('dev-tools-breadcrumb-repo')), findsOneWidget);
208 expect(find.byKey(const ValueKey('dev-tools-breadcrumb-collection')), findsOneWidget);
209 expect(find.byKey(const ValueKey('dev-tools-breadcrumb-record')), findsOneWidget);
210 expect(find.text('test.bsky.social'), findsAtLeastNWidgets(1));
211 expect(find.text('app.bsky.feed.post'), findsAtLeastNWidgets(1));
212 expect(find.text('123'), findsAtLeastNWidgets(1));
213 });
214
215 testWidgets('tapping repo breadcrumb calls cubit goBackToRepo', (tester) async {
216 const state = DevToolsState(
217 status: DevToolsStatus.collectionLoaded,
218 did: 'did:plc:test',
219 handle: 'test.bsky.social',
220 repoHandle: 'test.bsky.social',
221 collections: [CollectionSummary('app.bsky.feed.post', recordCount: 1)],
222 selectedCollection: 'app.bsky.feed.post',
223 records: [
224 RepoListRecordsRecord(
225 uri: AtUri('at://did:plc:test/app.bsky.feed.post/123'),
226 cid: 'cid123',
227 value: {'text': 'Summary'},
228 ),
229 ],
230 );
231
232 when(() => mockDevToolsCubit.state).thenReturn(state);
233 whenListen(mockDevToolsCubit, const Stream<DevToolsState>.empty(), initialState: state);
234
235 await tester.pumpWidget(buildSubject());
236 await tester.tap(find.byKey(const ValueKey('dev-tools-breadcrumb-repo')));
237
238 verify(() => mockDevToolsCubit.goBackToRepo()).called(1);
239 });
240
241 testWidgets('tapping collection breadcrumb calls cubit goBackToCollection', (tester) async {
242 const state = DevToolsState(
243 status: DevToolsStatus.recordLoaded,
244 did: 'did:plc:test',
245 handle: 'test.bsky.social',
246 repoHandle: 'test.bsky.social',
247 collections: [CollectionSummary('app.bsky.feed.post', recordCount: 1)],
248 selectedCollection: 'app.bsky.feed.post',
249 selectedRecord: RecordInfo(
250 uri: 'at://did:plc:test/app.bsky.feed.post/123',
251 cid: 'cid123',
252 value: {'text': 'Summary'},
253 ),
254 );
255
256 when(() => mockDevToolsCubit.state).thenReturn(state);
257 whenListen(mockDevToolsCubit, const Stream<DevToolsState>.empty(), initialState: state);
258
259 await tester.pumpWidget(buildSubject());
260 await tester.tap(find.byKey(const ValueKey('dev-tools-breadcrumb-collection')));
261
262 verify(() => mockDevToolsCubit.goBackToCollection()).called(1);
263 });
264
265 testWidgets('shows breadcrumb progress without full-screen spinner during record navigation', (tester) async {
266 const state = DevToolsState(
267 status: DevToolsStatus.collectionLoaded,
268 did: 'did:plc:test',
269 handle: 'test.bsky.social',
270 repoHandle: 'test.bsky.social',
271 collections: [CollectionSummary('app.bsky.feed.post', recordCount: 1)],
272 selectedCollection: 'app.bsky.feed.post',
273 records: [
274 RepoListRecordsRecord(
275 uri: AtUri('at://did:plc:test/app.bsky.feed.post/123'),
276 cid: 'cid123',
277 value: {'text': 'Summary'},
278 ),
279 ],
280 isRecordLoading: true,
281 );
282
283 when(() => mockDevToolsCubit.state).thenReturn(state);
284 whenListen(mockDevToolsCubit, const Stream<DevToolsState>.empty(), initialState: state);
285
286 await tester.pumpWidget(buildSubject());
287
288 expect(find.byKey(const ValueKey('app-breadcrumbs-loading')), findsOneWidget);
289 expect(find.byType(LinearProgressIndicator), findsOneWidget);
290 expect(find.byType(CircularProgressIndicator), findsNothing);
291 expect(find.text('123'), findsOneWidget);
292 });
293 });
294}