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.

at main 474 lines 19 kB view raw
1import 'package:atproto/com_atproto_identity_resolvehandle.dart'; 2import 'package:atproto/com_atproto_repo_describerepo.dart'; 3import 'package:atproto/com_atproto_repo_getrecord.dart'; 4import 'package:atproto/com_atproto_repo_listrecords.dart'; 5import 'package:atproto_core/atproto_core.dart'; 6import 'package:bluesky/app_bsky_actor_defs.dart'; 7import 'package:bloc_test/bloc_test.dart'; 8import 'package:flutter_test/flutter_test.dart'; 9import 'package:lazurite/features/devtools/cubit/dev_tools_cubit.dart'; 10 11class FakeDevToolsRepository implements DevToolsRepository { 12 FakeDevToolsRepository({ 13 this.resolveHandleHandler, 14 this.describeRepoHandler, 15 this.searchActorsTypeaheadHandler, 16 this.listRecordsHandler, 17 this.getRecordHandler, 18 }); 19 20 Future<IdentityResolveHandleOutput> Function({required String handle})? resolveHandleHandler; 21 Future<RepoDescribeRepoOutput> Function({required String repo, String? serviceHost})? describeRepoHandler; 22 Future<List<ProfileViewBasic>> Function({required String query, int limit})? searchActorsTypeaheadHandler; 23 Future<RepoListRecordsOutput> Function({ 24 required String repo, 25 required String collection, 26 int? limit, 27 String? cursor, 28 bool? reverse, 29 String? serviceHost, 30 })? 31 listRecordsHandler; 32 Future<RepoGetRecordOutput> Function({ 33 required String repo, 34 required String collection, 35 required String rkey, 36 String? serviceHost, 37 })? 38 getRecordHandler; 39 40 @override 41 Future<RepoDescribeRepoOutput> describeRepo({required String repo, String? serviceHost}) { 42 return describeRepoHandler!.call(repo: repo, serviceHost: serviceHost); 43 } 44 45 @override 46 Future<List<ProfileViewBasic>> searchActorsTypeahead({required String query, int limit = 8}) async { 47 final handler = searchActorsTypeaheadHandler; 48 if (handler == null) { 49 return const []; 50 } 51 return handler(query: query, limit: limit); 52 } 53 54 @override 55 Future<RepoGetRecordOutput> getRecord({ 56 required String repo, 57 required String collection, 58 required String rkey, 59 String? serviceHost, 60 }) { 61 return getRecordHandler!.call(repo: repo, collection: collection, rkey: rkey, serviceHost: serviceHost); 62 } 63 64 @override 65 Future<RepoListRecordsOutput> listRecords({ 66 required String repo, 67 required String collection, 68 int? limit, 69 String? cursor, 70 bool? reverse, 71 String? serviceHost, 72 }) { 73 return listRecordsHandler!.call( 74 repo: repo, 75 collection: collection, 76 limit: limit, 77 cursor: cursor, 78 reverse: reverse, 79 serviceHost: serviceHost, 80 ); 81 } 82 83 @override 84 Future<IdentityResolveHandleOutput> resolveHandle({required String handle}) { 85 return resolveHandleHandler!.call(handle: handle); 86 } 87} 88 89void main() { 90 group('DevToolsCubit', () { 91 test('initial state is DevToolsState with initial status', () { 92 final cubit = DevToolsCubit(repository: FakeDevToolsRepository()); 93 expect(cubit.state, const DevToolsState()); 94 }); 95 96 blocTest<DevToolsCubit, DevToolsState>( 97 'clearInput resets to the initial state', 98 build: () => DevToolsCubit(repository: FakeDevToolsRepository()), 99 seed: () => const DevToolsState(status: DevToolsStatus.repoLoaded, did: 'did:plc:test'), 100 act: (cubit) => cubit.clearInput(), 101 expect: () => [const DevToolsState()], 102 ); 103 104 blocTest<DevToolsCubit, DevToolsState>( 105 'queryTypeahead loads suggestions for @handle input', 106 build: () { 107 final repository = FakeDevToolsRepository( 108 searchActorsTypeaheadHandler: ({required String query, int limit = 8}) async { 109 expect(query, 'alice'); 110 expect(limit, 8); 111 return const [ProfileViewBasic(did: 'did:plc:alice', handle: 'alice.bsky.social')]; 112 }, 113 ); 114 return DevToolsCubit(repository: repository); 115 }, 116 act: (cubit) => cubit.queryTypeahead('@alice'), 117 expect: () => [ 118 isA<DevToolsState>() 119 .having((state) => state.isTypeaheadLoading, 'isTypeaheadLoading', isTrue) 120 .having((state) => state.typeaheadActors, 'typeaheadActors', isEmpty), 121 isA<DevToolsState>() 122 .having((state) => state.isTypeaheadLoading, 'isTypeaheadLoading', isFalse) 123 .having((state) => state.typeaheadActors.length, 'typeaheadActors.length', 1) 124 .having((state) => state.typeaheadActors.first.handle, 'first actor handle', 'alice.bsky.social'), 125 ], 126 ); 127 128 blocTest<DevToolsCubit, DevToolsState>( 129 'queryTypeahead clears suggestions when input is not an @handle', 130 build: () => DevToolsCubit(repository: FakeDevToolsRepository()), 131 seed: () => const DevToolsState( 132 typeaheadActors: [ProfileViewBasic(did: 'did:plc:alice', handle: 'alice.bsky.social')], 133 isTypeaheadLoading: true, 134 ), 135 act: (cubit) => cubit.queryTypeahead('alice'), 136 expect: () => [ 137 isA<DevToolsState>() 138 .having((state) => state.isTypeaheadLoading, 'isTypeaheadLoading', isFalse) 139 .having((state) => state.typeaheadActors, 'typeaheadActors', isEmpty), 140 ], 141 ); 142 143 blocTest<DevToolsCubit, DevToolsState>( 144 'resolve handle loads repo and progressive collection counts', 145 build: () { 146 var describeRepoCalls = 0; 147 final repository = FakeDevToolsRepository( 148 resolveHandleHandler: ({required String handle}) async { 149 expect(handle, 'alice.bsky.social'); 150 return const IdentityResolveHandleOutput(did: 'did:plc:alice'); 151 }, 152 describeRepoHandler: ({required String repo, String? serviceHost}) async { 153 expect(repo, 'did:plc:alice'); 154 if (describeRepoCalls == 0) { 155 expect(serviceHost, isNull); 156 } else { 157 expect(serviceHost, 'alice.host'); 158 } 159 describeRepoCalls++; 160 return const RepoDescribeRepoOutput( 161 handle: 'alice.bsky.social', 162 did: 'did:plc:alice', 163 didDoc: { 164 'service': [ 165 {'id': '#atproto_pds', 'type': 'AtprotoPersonalDataServer', 'serviceEndpoint': 'https://alice.host'}, 166 ], 167 }, 168 collections: ['app.bsky.feed.post'], 169 handleIsCorrect: true, 170 ); 171 }, 172 listRecordsHandler: 173 ({ 174 required String repo, 175 required String collection, 176 int? limit, 177 String? cursor, 178 bool? reverse, 179 String? serviceHost, 180 }) async { 181 expect(repo, 'did:plc:alice'); 182 expect(collection, 'app.bsky.feed.post'); 183 expect(serviceHost, 'alice.host'); 184 return RepoListRecordsOutput( 185 cursor: cursor == null ? 'next' : null, 186 records: [ 187 RepoListRecordsRecord( 188 uri: AtUri('at://did:plc:alice/app.bsky.feed.post/${cursor == null ? '1' : '2'}'), 189 cid: 'cid-${cursor ?? 'first'}', 190 value: {'text': 'post ${cursor ?? 'first'}'}, 191 ), 192 ], 193 ); 194 }, 195 ); 196 197 return DevToolsCubit(repository: repository); 198 }, 199 act: (cubit) => cubit.resolve('@alice.bsky.social'), 200 wait: const Duration(milliseconds: 10), 201 expect: () => [ 202 const DevToolsState(status: DevToolsStatus.loading), 203 isA<DevToolsState>() 204 .having((state) => state.status, 'status', DevToolsStatus.repoLoaded) 205 .having((state) => state.did, 'did', 'did:plc:alice') 206 .having((state) => state.repoHandle, 'repoHandle', 'alice.bsky.social') 207 .having((state) => state.collections.first.recordCount, 'initial count', isNull) 208 .having((state) => state.isCollectionCountsLoading, 'count loading', isTrue), 209 isA<DevToolsState>() 210 .having((state) => state.status, 'status', DevToolsStatus.repoLoaded) 211 .having((state) => state.collections.first.recordCount, 'resolved count', 2) 212 .having((state) => state.totalRepoRecords, 'total repo records', 2) 213 .having((state) => state.isCollectionCountsLoading, 'count loading', isFalse), 214 ], 215 ); 216 217 blocTest<DevToolsCubit, DevToolsState>( 218 'resolve AT-URI loads collection and full record JSON', 219 build: () { 220 var describeRepoCalls = 0; 221 final repository = FakeDevToolsRepository( 222 resolveHandleHandler: ({required String handle}) async => 223 const IdentityResolveHandleOutput(did: 'did:plc:alice'), 224 describeRepoHandler: ({required String repo, String? serviceHost}) async { 225 expect(repo, 'did:plc:alice'); 226 if (describeRepoCalls == 0) { 227 expect(serviceHost, isNull); 228 } else { 229 expect(serviceHost, 'alice.host'); 230 } 231 describeRepoCalls++; 232 return const RepoDescribeRepoOutput( 233 handle: 'alice.bsky.social', 234 did: 'did:plc:alice', 235 didDoc: { 236 'service': [ 237 {'id': '#atproto_pds', 'type': 'AtprotoPersonalDataServer', 'serviceEndpoint': 'https://alice.host'}, 238 ], 239 }, 240 collections: ['app.bsky.feed.post'], 241 handleIsCorrect: true, 242 ); 243 }, 244 listRecordsHandler: 245 ({ 246 required String repo, 247 required String collection, 248 int? limit, 249 String? cursor, 250 bool? reverse, 251 String? serviceHost, 252 }) async { 253 expect(serviceHost, 'alice.host'); 254 if (limit == 100) { 255 return const RepoListRecordsOutput( 256 records: [ 257 RepoListRecordsRecord( 258 uri: AtUri('at://did:plc:alice/app.bsky.feed.post/3kz'), 259 cid: 'cid-count', 260 value: {'text': 'count me'}, 261 ), 262 ], 263 ); 264 } 265 266 return const RepoListRecordsOutput( 267 records: [ 268 RepoListRecordsRecord( 269 uri: AtUri('at://did:plc:alice/app.bsky.feed.post/3kz'), 270 cid: 'cid-list', 271 value: {'text': 'summary'}, 272 ), 273 ], 274 ); 275 }, 276 getRecordHandler: 277 ({required String repo, required String collection, required String rkey, String? serviceHost}) async { 278 expect(repo, 'did:plc:alice'); 279 expect(collection, 'app.bsky.feed.post'); 280 expect(rkey, '3kz'); 281 expect(serviceHost, 'alice.host'); 282 return const RepoGetRecordOutput( 283 uri: AtUri('at://did:plc:alice/app.bsky.feed.post/3kz'), 284 cid: 'cid-full', 285 value: { 286 'text': 'full record', 287 'nested': {'ok': true}, 288 }, 289 ); 290 }, 291 ); 292 293 return DevToolsCubit(repository: repository); 294 }, 295 act: (cubit) => cubit.resolve('at://alice.bsky.social/app.bsky.feed.post/3kz'), 296 wait: const Duration(milliseconds: 10), 297 expect: () => [ 298 const DevToolsState(status: DevToolsStatus.loading), 299 isA<DevToolsState>() 300 .having((state) => state.status, 'status', DevToolsStatus.recordLoaded) 301 .having((state) => state.did, 'did', 'did:plc:alice') 302 .having((state) => state.selectedCollection, 'selectedCollection', 'app.bsky.feed.post') 303 .having((state) => state.records?.length, 'list records', 1) 304 .having((state) => state.selectedRecord?.cid, 'full cid', 'cid-full') 305 .having((state) => state.selectedRecord?.value['nested'], 'nested JSON', {'ok': true}), 306 isA<DevToolsState>() 307 .having((state) => state.collections.first.recordCount, 'collection count', 1) 308 .having((state) => state.totalRepoRecords, 'total repo records', 1), 309 ], 310 ); 311 312 blocTest<DevToolsCubit, DevToolsState>( 313 'loadRecord replaces list preview with getRecord response', 314 build: () { 315 final repository = FakeDevToolsRepository( 316 getRecordHandler: 317 ({required String repo, required String collection, required String rkey, String? serviceHost}) async { 318 return const RepoGetRecordOutput( 319 uri: AtUri('at://did:plc:test/app.bsky.feed.post/123'), 320 cid: 'cid123', 321 value: { 322 'text': 'Expanded', 323 'reply': {'root': 'abc'}, 324 }, 325 ); 326 }, 327 ); 328 329 return DevToolsCubit(repository: repository); 330 }, 331 seed: () => const DevToolsState( 332 status: DevToolsStatus.collectionLoaded, 333 did: 'did:plc:test', 334 collections: [CollectionSummary('app.bsky.feed.post', recordCount: 1)], 335 selectedCollection: 'app.bsky.feed.post', 336 records: [ 337 RepoListRecordsRecord( 338 uri: AtUri('at://did:plc:test/app.bsky.feed.post/123'), 339 cid: 'cid-list', 340 value: {'text': 'Summary'}, 341 ), 342 ], 343 ), 344 act: (cubit) => cubit.loadRecord( 345 const RepoListRecordsRecord( 346 uri: AtUri('at://did:plc:test/app.bsky.feed.post/123'), 347 cid: 'cid-list', 348 value: {'text': 'Summary'}, 349 ), 350 ), 351 expect: () => [ 352 isA<DevToolsState>() 353 .having((state) => state.status, 'status', DevToolsStatus.collectionLoaded) 354 .having((state) => state.isRecordLoading, 'isRecordLoading', isTrue), 355 isA<DevToolsState>() 356 .having((state) => state.status, 'status', DevToolsStatus.recordLoaded) 357 .having((state) => state.isRecordLoading, 'isRecordLoading', isFalse) 358 .having((state) => state.selectedRecord?.cid, 'cid', 'cid123') 359 .having((state) => state.selectedRecord?.value['reply'], 'expanded value', {'root': 'abc'}), 360 ], 361 ); 362 363 blocTest<DevToolsCubit, DevToolsState>( 364 'loadCollection keeps repo view active while records load', 365 build: () { 366 final repository = FakeDevToolsRepository( 367 listRecordsHandler: 368 ({ 369 required String repo, 370 required String collection, 371 int? limit, 372 String? cursor, 373 bool? reverse, 374 String? serviceHost, 375 }) async { 376 return const RepoListRecordsOutput( 377 records: [ 378 RepoListRecordsRecord( 379 uri: AtUri('at://did:plc:test/app.bsky.feed.post/123'), 380 cid: 'cid123', 381 value: {'text': 'Summary'}, 382 ), 383 ], 384 ); 385 }, 386 ); 387 388 return DevToolsCubit(repository: repository); 389 }, 390 seed: () => const DevToolsState( 391 status: DevToolsStatus.repoLoaded, 392 did: 'did:plc:test', 393 repoHandle: 'test.bsky.social', 394 collections: [CollectionSummary('app.bsky.feed.post', recordCount: 1)], 395 ), 396 act: (cubit) => cubit.loadCollection('app.bsky.feed.post'), 397 expect: () => [ 398 isA<DevToolsState>() 399 .having((state) => state.status, 'status', DevToolsStatus.repoLoaded) 400 .having((state) => state.isCollectionLoading, 'isCollectionLoading', isTrue), 401 isA<DevToolsState>() 402 .having((state) => state.status, 'status', DevToolsStatus.collectionLoaded) 403 .having((state) => state.isCollectionLoading, 'isCollectionLoading', isFalse) 404 .having((state) => state.selectedCollection, 'selectedCollection', 'app.bsky.feed.post') 405 .having((state) => state.records?.length, 'records', 1), 406 ], 407 ); 408 409 blocTest<DevToolsCubit, DevToolsState>( 410 'invalid AT-URI surfaces a clear error', 411 build: () => DevToolsCubit(repository: FakeDevToolsRepository()), 412 act: (cubit) => cubit.resolve('at://bad uri'), 413 expect: () => [ 414 const DevToolsState(status: DevToolsStatus.loading), 415 isA<DevToolsState>() 416 .having((state) => state.status, 'status', DevToolsStatus.error) 417 .having((state) => state.errorMessage, 'error', 'Invalid AT-URI'), 418 ], 419 ); 420 421 blocTest<DevToolsCubit, DevToolsState>( 422 'goBackToCollection clears selectedRecord', 423 build: () => DevToolsCubit(repository: FakeDevToolsRepository()), 424 seed: () => const DevToolsState( 425 status: DevToolsStatus.recordLoaded, 426 did: 'did:plc:test', 427 collections: [CollectionSummary('app.bsky.feed.post', recordCount: 1)], 428 selectedCollection: 'app.bsky.feed.post', 429 records: [ 430 RepoListRecordsRecord( 431 uri: AtUri('at://did:plc:test/app.bsky.feed.post/123'), 432 cid: 'cid', 433 value: {'text': 'Summary'}, 434 ), 435 ], 436 selectedRecord: RecordInfo(uri: 'at://did:plc:test/app.bsky.feed.post/123', value: {'text': 'Full'}), 437 ), 438 act: (cubit) => cubit.goBackToCollection(), 439 expect: () => [ 440 isA<DevToolsState>() 441 .having((state) => state.status, 'status', DevToolsStatus.collectionLoaded) 442 .having((state) => state.selectedRecord, 'selectedRecord', isNull) 443 .having((state) => state.records?.length, 'records length', 1), 444 ], 445 ); 446 447 blocTest<DevToolsCubit, DevToolsState>( 448 'goBackToRepo clears collection and record state', 449 build: () => DevToolsCubit(repository: FakeDevToolsRepository()), 450 seed: () => const DevToolsState( 451 status: DevToolsStatus.recordLoaded, 452 did: 'did:plc:test', 453 collections: [CollectionSummary('app.bsky.feed.post', recordCount: 1)], 454 selectedCollection: 'app.bsky.feed.post', 455 records: [ 456 RepoListRecordsRecord( 457 uri: AtUri('at://did:plc:test/app.bsky.feed.post/123'), 458 cid: 'cid', 459 value: {'text': 'Summary'}, 460 ), 461 ], 462 selectedRecord: RecordInfo(uri: 'at://did:plc:test/app.bsky.feed.post/123', value: {'text': 'Full'}), 463 ), 464 act: (cubit) => cubit.goBackToRepo(), 465 expect: () => [ 466 isA<DevToolsState>() 467 .having((state) => state.status, 'status', DevToolsStatus.repoLoaded) 468 .having((state) => state.selectedCollection, 'selectedCollection', isNull) 469 .having((state) => state.records, 'records', isNull) 470 .having((state) => state.selectedRecord, 'selectedRecord', isNull), 471 ], 472 ); 473 }); 474}