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