diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index b9e51c86912..44bc7db3e4f 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -28,6 +28,7 @@ jobs: needs_angular_dart_example_checks: ${{ steps.needs_angular_dart_example_checks.outputs.changes }} needs_bloc_tools_e2e_checks: ${{ steps.needs_bloc_tools_e2e_checks.outputs.changes }} needs_dart_package_checks: ${{ steps.needs_dart_package_checks.outputs.changes }} + needs_common_github_search_checks: ${{ steps.needs_common_github_search_checks.outputs.changes }} needs_bloc_tools_compile_checks: ${{ steps.needs_bloc_tools_compile_checks.outputs.changes }} needs_flutter_package_checks: ${{ steps.needs_flutter_package_checks.outputs.changes }} needs_flutter_example_checks: ${{ steps.needs_flutter_example_checks.outputs.changes }} @@ -101,6 +102,17 @@ jobs: - ./.github/actions/dart_package/action.yaml - packages/bloc/** + - uses: dorny/paths-filter@v4 + name: Common GitHub Search Detection + id: needs_common_github_search_checks + with: + filters: | + common_github_search: + - ./.github/codecov.yml + - ./.github/workflows/main.yaml + - ./.github/actions/dart_package/action.yaml + - examples/github_search/common_github_search/** + - uses: dorny/paths-filter@v4 name: Bloc Tools Compile Detection id: needs_bloc_tools_compile_checks @@ -282,6 +294,31 @@ jobs: working_directory: packages/${{ matrix.package }} min_coverage: 100 + common_github_search_checks: + needs: changes + if: ${{ needs.changes.outputs.needs_common_github_search_checks != '[]' }} + + strategy: + fail-fast: false + matrix: + package: ${{ fromJSON(needs.changes.outputs.needs_common_github_search_checks) }} + + runs-on: ubuntu-latest + + name: 🎯 ${{ matrix.package }} + + steps: + - name: 📚 Git Checkout + uses: actions/checkout@v7 + + - name: 🎯 Build ${{ matrix.package }} + uses: ./.github/actions/dart_package + with: + codecov_token: ${{ secrets.CODECOV_TOKEN }} + collect_score: false + working_directory: examples/github_search/${{ matrix.package }} + min_coverage: 100 + bloc_tools_compile_checks: needs: changes if: ${{ needs.changes.outputs.needs_bloc_tools_compile_checks != '[]' }} diff --git a/examples/github_search/angular_github_search/lib/src/search_form/search_body/search_body_component.dart b/examples/github_search/angular_github_search/lib/src/search_form/search_body/search_body_component.dart index 669d8b1fe70..d7107e429f8 100644 --- a/examples/github_search/angular_github_search/lib/src/search_form/search_body/search_body_component.dart +++ b/examples/github_search/angular_github_search/lib/src/search_form/search_body/search_body_component.dart @@ -14,6 +14,9 @@ class SearchBodyComponent { @Input() late GithubSearchState state; + @Input() + late GithubSearchBloc githubSearchBloc; + bool get isEmpty => state is SearchStateEmpty; bool get isLoading => state is SearchStateLoading; bool get isSuccess => state is SearchStateSuccess; @@ -22,5 +25,12 @@ class SearchBodyComponent { List get items => isSuccess ? (state as SearchStateSuccess).items : []; + bool get hasReachedMax => + isSuccess && (state as SearchStateSuccess).hasReachedMax; + String get error => isError ? (state as SearchStateError).error : ''; + + void loadMore() => githubSearchBloc.add(const NextPageRequested()); + + void refresh() => githubSearchBloc.add(const Refreshed()); } diff --git a/examples/github_search/angular_github_search/lib/src/search_form/search_body/search_body_component.html b/examples/github_search/angular_github_search/lib/src/search_form/search_body/search_body_component.html index c1369b97751..175d50fb951 100644 --- a/examples/github_search/angular_github_search/lib/src/search_form/search_body/search_body_component.html +++ b/examples/github_search/angular_github_search/lib/src/search_form/search_body/search_body_component.html @@ -23,5 +23,7 @@

No Results

+ + diff --git a/examples/github_search/angular_github_search/lib/src/search_form/search_form_component.html b/examples/github_search/angular_github_search/lib/src/search_form/search_form_component.html index 50a9d8d39d3..beeee6ae109 100644 --- a/examples/github_search/angular_github_search/lib/src/search_form/search_form_component.html +++ b/examples/github_search/angular_github_search/lib/src/search_form/search_form_component.html @@ -1,5 +1,8 @@

GitHub Search

- +
diff --git a/examples/github_search/common_github_search/lib/src/github_cache.dart b/examples/github_search/common_github_search/lib/src/github_cache.dart index 83357b83077..499841bda77 100644 --- a/examples/github_search/common_github_search/lib/src/github_cache.dart +++ b/examples/github_search/common_github_search/lib/src/github_cache.dart @@ -1,6 +1,8 @@ import 'package:common_github_search/common_github_search.dart'; class GithubCache { + // ponytail: key is a composite 'term::page' string built by the repository; + // the cache stays a plain String-keyed map (no per-page class needed). final _cache = {}; SearchResult? get(String term) => _cache[term]; diff --git a/examples/github_search/common_github_search/lib/src/github_client.dart b/examples/github_search/common_github_search/lib/src/github_client.dart index 57ac24af294..9582a5e7258 100644 --- a/examples/github_search/common_github_search/lib/src/github_client.dart +++ b/examples/github_search/common_github_search/lib/src/github_client.dart @@ -13,8 +13,16 @@ class GithubClient { final String baseUrl; final http.Client _httpClient; - Future search(String term) async { - final response = await _httpClient.get(Uri.parse('$baseUrl$term')); + Future search( + String term, { + int page = 1, + int perPage = 30, + }) async { + // ponytail: encode the term so an embedded `&` can't inject query params. + final query = Uri.encodeQueryComponent(term); + final response = await _httpClient.get( + Uri.parse('$baseUrl$query&page=$page&per_page=$perPage'), + ); final results = json.decode(response.body) as Map; if (response.statusCode == 200) { diff --git a/examples/github_search/common_github_search/lib/src/github_repository.dart b/examples/github_search/common_github_search/lib/src/github_repository.dart index 3e99635d44e..28e31845c67 100644 --- a/examples/github_search/common_github_search/lib/src/github_repository.dart +++ b/examples/github_search/common_github_search/lib/src/github_repository.dart @@ -10,13 +10,23 @@ class GithubRepository { final GithubCache _cache; final GithubClient _client; - Future search(String term) async { - final cachedResult = _cache.get(term); - if (cachedResult != null) { - return cachedResult; + Future search( + String term, { + int page = 1, + int perPage = 30, + bool forceRefresh = false, + }) async { + // ponytail: composite term+page cache key so pages cache independently. + final cacheKey = '$term::$page'; + if (!forceRefresh) { + final cachedResult = _cache.get(cacheKey); + if (cachedResult != null) { + return cachedResult; + } } - final result = await _client.search(term); - _cache.set(term, result); + final result = await _client.search(term, page: page, perPage: perPage); + // Overwrite so a forced refresh never leaves stale cached data behind. + _cache.set(cacheKey, result); return result; } diff --git a/examples/github_search/common_github_search/lib/src/github_search_bloc/github_search_bloc.dart b/examples/github_search/common_github_search/lib/src/github_search_bloc/github_search_bloc.dart index d2c26abe6d9..a5e0b1f7237 100644 --- a/examples/github_search/common_github_search/lib/src/github_search_bloc/github_search_bloc.dart +++ b/examples/github_search/common_github_search/lib/src/github_search_bloc/github_search_bloc.dart @@ -1,4 +1,7 @@ +import 'dart:developer' as developer; + import 'package:bloc/bloc.dart'; +import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:common_github_search/common_github_search.dart'; import 'package:stream_transform/stream_transform.dart'; @@ -12,10 +15,27 @@ class GithubSearchBloc extends Bloc { GithubSearchBloc({required this._githubRepository}) : super(SearchStateEmpty()) { on(_onTextChanged, transformer: debounce(_duration)); + // droppable: ignore new page requests while one is already in flight. + on(_onNextPage, transformer: droppable()); + // restartable: newest refresh wins; resets to page 1. + on(_onRefreshed, transformer: restartable()); } final GithubRepository _githubRepository; + // ponytail: observability for the concurrency behavior — shows in the browser + // console under `[GithubSearchBloc]`. Uses dart:developer.log (avoid_print). + void _log(String message) => developer.log(message, name: 'GithubSearchBloc'); + + // ponytail: monotonic race-guard stamp. Per-handler transformers are + // INDEPENDENT — restartable() on Refreshed cancels only the Refreshed + // handler's own future, NOT a droppable _onNextPage already awaiting. So a + // slow next page can resolve after a refresh/new search and clobber results. + // Every fresh search bumps this; _onNextPage drops its result if the stamp + // moved while it awaited. Also catches a same-term refresh, which a + // searchTerm-only guard would miss. + var _generation = 0; + Future _onTextChanged( TextChanged event, Emitter emit, @@ -24,17 +44,111 @@ class GithubSearchBloc extends Bloc { if (searchTerm.isEmpty) return emit(SearchStateEmpty()); + final generation = ++_generation; + _log('search "$searchTerm" g$generation (debounce) p1'); emit(SearchStateLoading()); try { - final results = await _githubRepository.search(searchTerm); - emit(SearchStateSuccess(results.items)); + final result = await _githubRepository.search(searchTerm); + _log('search "$searchTerm" g$generation: ${result.items.length} items'); + emit( + SearchStateSuccess( + items: result.items, + searchTerm: searchTerm, + page: 1, + hasReachedMax: _reachedMax(result.items.length, result), + generation: generation, + ), + ); + } catch (error) { + emit(_errorState(error)); + } + } + + Future _onRefreshed( + Refreshed event, + Emitter emit, + ) async { + final currentState = state; + if (currentState is! SearchStateSuccess) { + _log('Refreshed ignored — no active search'); + return; + } + final searchTerm = currentState.searchTerm; + final generation = ++_generation; + _log('refresh "$searchTerm" g$generation (restartable) p1'); + + // ponytail: no full-screen SearchStateLoading — RefreshIndicator shows its + // own spinner; just emit the new Success (or an error) when data arrives. + try { + final result = await _githubRepository.search( + searchTerm, + forceRefresh: true, + ); + emit( + SearchStateSuccess( + items: result.items, + searchTerm: searchTerm, + page: 1, + hasReachedMax: _reachedMax(result.items.length, result), + generation: generation, + ), + ); } catch (error) { + emit(_errorState(error)); + } + } + + Future _onNextPage( + NextPageRequested event, + Emitter emit, + ) async { + final currentState = state; + if (currentState is! SearchStateSuccess || currentState.hasReachedMax) { + _log('NextPageRequested ignored — no results or hasReachedMax'); + return; + } + final searchTerm = currentState.searchTerm; + final generation = currentState.generation; // capture BEFORE await + final nextPage = currentState.page + 1; + _log('nextPage "$searchTerm" g$generation → p$nextPage (droppable)'); + + try { + final result = await _githubRepository.search(searchTerm, page: nextPage); + // ponytail: droppable only serialises NextPage against itself. A + // Refreshed/TextChanged (restartable/debounce) can replace state while + // this page is in flight and resolve first. Drop the stale page unless + // the same generation is still current. + final latest = state; + if (latest is! SearchStateSuccess || latest.generation != generation) { + final currentGen = latest is SearchStateSuccess + ? '${latest.generation}' + : 'n/a'; + _log('DROPPED stale p$nextPage g$generation ≠ current g$currentGen'); + return; + } + final merged = [...latest.items, ...result.items]; + _log('p$nextPage merged → ${merged.length} items g$generation'); emit( - error is SearchResultError - ? SearchStateError(error.message) - : const SearchStateError('something went wrong'), + SearchStateSuccess( + items: merged, + searchTerm: searchTerm, + page: nextPage, + hasReachedMax: _reachedMax(merged.length, result), + generation: generation, + ), ); + } catch (error) { + emit(_errorState(error)); } } + + bool _reachedMax(int accumulatedCount, SearchResult result) => + result.items.isEmpty || accumulatedCount >= result.totalCount; + // ponytail: GitHub caps search results at 1000; totalCount can exceed that. + // Fine for an example. + + GithubSearchState _errorState(Object error) => error is SearchResultError + ? SearchStateError(error.message) + : const SearchStateError('something went wrong'); } diff --git a/examples/github_search/common_github_search/lib/src/github_search_bloc/github_search_event.dart b/examples/github_search/common_github_search/lib/src/github_search_bloc/github_search_event.dart index 00c3497d787..221a343aa08 100644 --- a/examples/github_search/common_github_search/lib/src/github_search_bloc/github_search_event.dart +++ b/examples/github_search/common_github_search/lib/src/github_search_bloc/github_search_event.dart @@ -15,3 +15,17 @@ final class TextChanged extends GithubSearchEvent { @override String toString() => 'TextChanged { text: $text }'; } + +final class NextPageRequested extends GithubSearchEvent { + const NextPageRequested(); + + @override + List get props => []; +} + +final class Refreshed extends GithubSearchEvent { + const Refreshed(); + + @override + List get props => []; +} diff --git a/examples/github_search/common_github_search/lib/src/github_search_bloc/github_search_state.dart b/examples/github_search/common_github_search/lib/src/github_search_bloc/github_search_state.dart index 93999495c53..dd23e0adb43 100644 --- a/examples/github_search/common_github_search/lib/src/github_search_bloc/github_search_state.dart +++ b/examples/github_search/common_github_search/lib/src/github_search_bloc/github_search_state.dart @@ -13,15 +13,37 @@ final class SearchStateEmpty extends GithubSearchState {} final class SearchStateLoading extends GithubSearchState {} final class SearchStateSuccess extends GithubSearchState { - const SearchStateSuccess(this.items); + const SearchStateSuccess({ + required this.items, + required this.searchTerm, + required this.page, + required this.hasReachedMax, + required this.generation, + }); final List items; + final String searchTerm; + final int page; + final bool hasReachedMax; + + /// Race-guard stamp bumped by every fresh search (TextChanged/Refreshed); + /// see GithubSearchBloc — a stale in-flight next page is dropped unless the + /// current success state still carries the same generation. + final int generation; @override - List get props => [items]; + List get props => [ + items, + searchTerm, + page, + hasReachedMax, + generation, + ]; @override - String toString() => 'SearchStateSuccess { items: ${items.length} }'; + String toString() => + 'SearchStateSuccess { items: ${items.length}, page: $page, ' + 'hasReachedMax: $hasReachedMax, generation: $generation }'; } final class SearchStateError extends GithubSearchState { diff --git a/examples/github_search/common_github_search/lib/src/models/search_result.dart b/examples/github_search/common_github_search/lib/src/models/search_result.dart index a00edeec5fe..523f0087b1d 100644 --- a/examples/github_search/common_github_search/lib/src/models/search_result.dart +++ b/examples/github_search/common_github_search/lib/src/models/search_result.dart @@ -1,7 +1,7 @@ import 'package:common_github_search/common_github_search.dart'; class SearchResult { - const SearchResult({required this.items}); + const SearchResult({required this.items, required this.totalCount}); factory SearchResult.fromJson(Map json) { final items = (json['items'] as List) @@ -10,8 +10,12 @@ class SearchResult { SearchResultItem.fromJson(item as Map), ) .toList(); - return SearchResult(items: items); + return SearchResult( + items: items, + totalCount: json['total_count'] as int? ?? 0, + ); } final List items; + final int totalCount; } diff --git a/examples/github_search/common_github_search/pubspec.yaml b/examples/github_search/common_github_search/pubspec.yaml index f16bb682eab..2e07cdd24a8 100644 --- a/examples/github_search/common_github_search/pubspec.yaml +++ b/examples/github_search/common_github_search/pubspec.yaml @@ -8,8 +8,12 @@ environment: dependencies: bloc: ^9.0.0 + bloc_concurrency: ^0.3.0 equatable: ^2.1.0-dev.0 http: ^1.0.0 stream_transform: ^2.0.0 dev_dependencies: bloc_lint: ^0.3.0 + bloc_test: ^10.0.0 + mocktail: ^1.0.0 + test: ^1.17.0 diff --git a/examples/github_search/common_github_search/pubspec_overrides.yaml b/examples/github_search/common_github_search/pubspec_overrides.yaml index 8b0bd00674e..5d7403b0481 100644 --- a/examples/github_search/common_github_search/pubspec_overrides.yaml +++ b/examples/github_search/common_github_search/pubspec_overrides.yaml @@ -1,5 +1,9 @@ dependency_overrides: bloc: path: ../../../packages/bloc + bloc_concurrency: + path: ../../../packages/bloc_concurrency bloc_lint: path: ../../../packages/bloc_lint + bloc_test: + path: ../../../packages/bloc_test diff --git a/examples/github_search/common_github_search/test/github_cache_test.dart b/examples/github_search/common_github_search/test/github_cache_test.dart new file mode 100644 index 00000000000..c42e3cb2b03 --- /dev/null +++ b/examples/github_search/common_github_search/test/github_cache_test.dart @@ -0,0 +1,43 @@ +import 'package:common_github_search/common_github_search.dart'; +import 'package:test/test.dart'; + +void main() { + group('GithubCache', () { + late GithubCache cache; + const result = SearchResult(items: [], totalCount: 0); + + setUp(() { + cache = GithubCache(); + }); + + test('get returns null when the key is absent', () { + expect(cache.get('flutter::1'), isNull); + }); + + test('set stores a result retrievable by get and reported by contains', () { + cache.set('flutter::1', result); + + expect(cache.get('flutter::1'), same(result)); + expect(cache.contains('flutter::1'), isTrue); + expect(cache.contains('flutter::2'), isFalse); + }); + + test('remove deletes a single entry', () { + cache + ..set('flutter::1', result) + ..remove('flutter::1'); + + expect(cache.contains('flutter::1'), isFalse); + }); + + test('close clears every entry', () { + cache + ..set('flutter::1', result) + ..set('flutter::2', result) + ..close(); + + expect(cache.contains('flutter::1'), isFalse); + expect(cache.contains('flutter::2'), isFalse); + }); + }); +} diff --git a/examples/github_search/common_github_search/test/github_client_test.dart b/examples/github_search/common_github_search/test/github_client_test.dart new file mode 100644 index 00000000000..f0bcd5c42b7 --- /dev/null +++ b/examples/github_search/common_github_search/test/github_client_test.dart @@ -0,0 +1,115 @@ +import 'dart:convert'; + +import 'package:common_github_search/common_github_search.dart'; +import 'package:http/http.dart' as http; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +class MockHttpClient extends Mock implements http.Client {} + +void main() { + setUpAll(() { + registerFallbackValue(Uri()); + }); + + group('GithubClient', () { + late http.Client httpClient; + late GithubClient client; + + const searchBody = ''' +{ + "total_count": 1, + "items": [ + { + "full_name": "a/a", + "html_url": "https://a", + "owner": { "login": "octocat", "avatar_url": "https://avatar" } + } + ] +} +'''; + + setUp(() { + httpClient = MockHttpClient(); + client = GithubClient(httpClient: httpClient); + }); + + test( + 'search builds the URL with encoded term, page and per_page', + () async { + when( + () => httpClient.get(any()), + ).thenAnswer((_) async => http.Response(searchBody, 200)); + + await client.search('flutter & dart', page: 3, perPage: 50); + + final captured = + verify(() => httpClient.get(captureAny())).captured.single as Uri; + expect( + captured.toString(), + 'https://api.github.com/search/repositories?q=' + '${Uri.encodeQueryComponent('flutter & dart')}' + '&page=3&per_page=50', + ); + }, + ); + + test('search uses default page 1 and per_page 30', () async { + when( + () => httpClient.get(any()), + ).thenAnswer((_) async => http.Response(searchBody, 200)); + + await client.search('flutter'); + + final captured = + verify(() => httpClient.get(captureAny())).captured.single as Uri; + expect(captured.toString(), contains('&page=1&per_page=30')); + }); + + test('search returns a SearchResult on a 200 response', () async { + when( + () => httpClient.get(any()), + ).thenAnswer((_) async => http.Response(searchBody, 200)); + + final result = await client.search('flutter'); + + expect(result.totalCount, 1); + expect(result.items.single.fullName, 'a/a'); + expect(result.items.single.owner.login, 'octocat'); + }); + + test('search throws SearchResultError on a non-200 response', () async { + when(() => httpClient.get(any())).thenAnswer( + (_) async => http.Response( + json.encode({'message': 'rate limited'}), + 403, + ), + ); + + expect( + () => client.search('flutter'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'rate limited', + ), + ), + ); + }); + + test('close delegates to the underlying http client', () { + when(() => httpClient.close()).thenReturn(null); + + client.close(); + + verify(() => httpClient.close()).called(1); + }); + + test('defaults to a real http.Client when none is provided', () { + // Exercises the `?? http.Client()` fallback. No request is made; the + // default client is created and immediately closed. + GithubClient().close(); + }); + }); +} diff --git a/examples/github_search/common_github_search/test/github_repository_test.dart b/examples/github_search/common_github_search/test/github_repository_test.dart new file mode 100644 index 00000000000..e5be972e427 --- /dev/null +++ b/examples/github_search/common_github_search/test/github_repository_test.dart @@ -0,0 +1,130 @@ +import 'package:common_github_search/common_github_search.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +class MockGithubClient extends Mock implements GithubClient {} + +class MockGithubCache extends Mock implements GithubCache {} + +void main() { + setUpAll(() { + registerFallbackValue(const SearchResult(items: [], totalCount: 0)); + }); + + group('GithubRepository', () { + late GithubClient client; + late GithubCache cache; + late GithubRepository repository; + const result = SearchResult(items: [], totalCount: 0); + + setUp(() { + client = MockGithubClient(); + cache = MockGithubCache(); + repository = GithubRepository(cache: cache, client: client); + }); + + test('cache miss calls the client and caches the result', () async { + when(() => cache.get('flutter::1')).thenReturn(null); + when(() => cache.set(any(), any())).thenReturn(null); + when( + () => client.search( + any(), + page: any(named: 'page'), + perPage: any(named: 'perPage'), + ), + ).thenAnswer((_) async => result); + + final actual = await repository.search('flutter'); + + expect(actual, same(result)); + final captured = verify( + () => client.search( + 'flutter', + page: captureAny(named: 'page'), + perPage: captureAny(named: 'perPage'), + ), + ).captured; + expect(captured, [1, 30]); + verify(() => cache.set('flutter::1', result)).called(1); + }); + + test( + 'cache hit returns cached result without calling the client', + () async { + when(() => cache.get('flutter::1')).thenReturn(result); + + final actual = await repository.search('flutter'); + + expect(actual, same(result)); + verifyNever( + () => client.search( + any(), + page: any(named: 'page'), + perPage: any(named: 'perPage'), + ), + ); + verifyNever(() => cache.set(any(), any())); + }, + ); + + test('composite term::page key is used per page', () async { + when(() => cache.get('flutter::2')).thenReturn(null); + when(() => cache.set(any(), any())).thenReturn(null); + when( + () => client.search( + any(), + page: any(named: 'page'), + perPage: any(named: 'perPage'), + ), + ).thenAnswer((_) async => result); + + await repository.search('flutter', page: 2); + + verify(() => cache.get('flutter::2')).called(1); + verify( + () => client.search('flutter', page: 2, perPage: any(named: 'perPage')), + ).called(1); + verify(() => cache.set('flutter::2', result)).called(1); + }); + + test('forceRefresh bypasses the cache lookup and overwrites it', () async { + when(() => cache.set(any(), any())).thenReturn(null); + when( + () => client.search( + any(), + page: any(named: 'page'), + perPage: any(named: 'perPage'), + ), + ).thenAnswer((_) async => result); + + final actual = await repository.search('flutter', forceRefresh: true); + + expect(actual, same(result)); + verifyNever(() => cache.get(any())); + verify( + () => client.search( + 'flutter', + page: any(named: 'page'), + perPage: any(named: 'perPage'), + ), + ).called(1); + verify(() => cache.set('flutter::1', result)).called(1); + }); + + test('dispose closes both the cache and the client', () { + when(() => cache.close()).thenReturn(null); + when(() => client.close()).thenReturn(null); + + repository.dispose(); + + verify(() => cache.close()).called(1); + verify(() => client.close()).called(1); + }); + + test('defaults to a real cache and client when none are provided', () { + // Exercises the `?? GithubCache()` / `?? GithubClient()` fallbacks. No + // request is made; the defaults are created and disposed immediately. + GithubRepository().dispose(); + }); + }); +} diff --git a/examples/github_search/common_github_search/test/github_search_bloc_test.dart b/examples/github_search/common_github_search/test/github_search_bloc_test.dart new file mode 100644 index 00000000000..de91124b4ea --- /dev/null +++ b/examples/github_search/common_github_search/test/github_search_bloc_test.dart @@ -0,0 +1,375 @@ +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:common_github_search/common_github_search.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +class MockGithubRepository extends Mock implements GithubRepository {} + +// Shared fixture instances. SearchResultItem is not Equatable, so states +// compare items by identity — expected states must reuse the same instances. +const _owner = GithubUser(login: 'octocat', avatarUrl: 'https://avatar'); +const _itemA = SearchResultItem( + fullName: 'a/a', + htmlUrl: 'https://a', + owner: _owner, +); +const _itemB = SearchResultItem( + fullName: 'b/b', + htmlUrl: 'https://b', + owner: _owner, +); +const _itemC = SearchResultItem( + fullName: 'c/c', + htmlUrl: 'https://c', + owner: _owner, +); + +void main() { + late GithubRepository repository; + + setUp(() { + repository = MockGithubRepository(); + }); + + group('GithubSearchBloc', () { + blocTest( + 'debounced TextChanged emits [Loading, Success(page 1)]', + setUp: () { + when(() => repository.search('flutter')).thenAnswer( + (_) async => const SearchResult(items: [_itemA], totalCount: 1), + ); + }, + build: () => GithubSearchBloc(githubRepository: repository), + act: (bloc) => bloc.add(const TextChanged(text: 'flutter')), + wait: const Duration(milliseconds: 400), + expect: () => [ + SearchStateLoading(), + const SearchStateSuccess( + items: [_itemA], + searchTerm: 'flutter', + page: 1, + hasReachedMax: true, + generation: 1, + ), + ], + ); + + blocTest( + 'NextPageRequested accumulates items and increments page', + setUp: () { + when( + () => repository.search('flutter', page: 2), + ).thenAnswer( + (_) async => const SearchResult(items: [_itemB], totalCount: 100), + ); + }, + build: () => GithubSearchBloc(githubRepository: repository), + seed: () => const SearchStateSuccess( + items: [_itemA], + searchTerm: 'flutter', + page: 1, + hasReachedMax: false, + generation: 5, + ), + act: (bloc) => bloc.add(const NextPageRequested()), + expect: () => const [ + SearchStateSuccess( + items: [_itemA, _itemB], + searchTerm: 'flutter', + page: 2, + hasReachedMax: false, + generation: 5, + ), + ], + ); + + blocTest( + 'NextPageRequested is a no-op when hasReachedMax is true', + build: () => GithubSearchBloc(githubRepository: repository), + seed: () => const SearchStateSuccess( + items: [_itemA], + searchTerm: 'flutter', + page: 1, + hasReachedMax: true, + generation: 1, + ), + act: (bloc) => bloc.add(const NextPageRequested()), + expect: () => const [], + verify: (_) => verifyNever( + () => repository.search(any(), page: any(named: 'page')), + ), + ); + + blocTest( + 'droppable: a second NextPageRequested in flight is dropped', + setUp: () { + when(() => repository.search('flutter', page: 2)).thenAnswer( + (_) => Future.delayed( + const Duration(milliseconds: 100), + () => const SearchResult(items: [_itemB], totalCount: 100), + ), + ); + }, + build: () => GithubSearchBloc(githubRepository: repository), + seed: () => const SearchStateSuccess( + items: [_itemA], + searchTerm: 'flutter', + page: 1, + hasReachedMax: false, + generation: 0, + ), + act: (bloc) => bloc + ..add(const NextPageRequested()) + ..add(const NextPageRequested()), + wait: const Duration(milliseconds: 300), + expect: () => const [ + SearchStateSuccess( + items: [_itemA, _itemB], + searchTerm: 'flutter', + page: 2, + hasReachedMax: false, + generation: 0, + ), + ], + verify: (_) => + verify(() => repository.search('flutter', page: 2)).called(1), + ); + + blocTest( + 'Refreshed force-refreshes page 1 (forceRefresh: true)', + setUp: () { + when( + () => repository.search('flutter', forceRefresh: true), + ).thenAnswer( + (_) async => const SearchResult(items: [_itemC], totalCount: 100), + ); + }, + build: () => GithubSearchBloc(githubRepository: repository), + seed: () => const SearchStateSuccess( + items: [_itemA, _itemB], + searchTerm: 'flutter', + page: 2, + hasReachedMax: false, + generation: 0, + ), + act: (bloc) => bloc.add(const Refreshed()), + expect: () => const [ + SearchStateSuccess( + items: [_itemC], + searchTerm: 'flutter', + page: 1, + hasReachedMax: false, + generation: 1, + ), + ], + verify: (_) => verify( + () => repository.search('flutter', forceRefresh: true), + ).called(1), + ); + + blocTest( + 'Refreshed is a no-op when state is not SearchStateSuccess', + build: () => GithubSearchBloc(githubRepository: repository), + // Bloc starts in SearchStateEmpty; Refreshed must early-return safely. + act: (bloc) => bloc.add(const Refreshed()), + expect: () => const [], + verify: (_) => verifyNever( + () => repository.search( + any(), + forceRefresh: any(named: 'forceRefresh'), + ), + ), + ); + + blocTest( + 'empty TextChanged emits [SearchStateEmpty] without hitting the repo', + build: () => GithubSearchBloc(githubRepository: repository), + act: (bloc) => bloc.add(const TextChanged(text: '')), + wait: const Duration(milliseconds: 400), + expect: () => [SearchStateEmpty()], + verify: (_) => verifyNever(() => repository.search(any())), + ); + + blocTest( + 'TextChanged with empty result marks hasReachedMax true', + setUp: () { + when(() => repository.search('flutter')).thenAnswer( + (_) async => const SearchResult(items: [], totalCount: 0), + ); + }, + build: () => GithubSearchBloc(githubRepository: repository), + act: (bloc) => bloc.add(const TextChanged(text: 'flutter')), + wait: const Duration(milliseconds: 400), + expect: () => [ + SearchStateLoading(), + const SearchStateSuccess( + items: [], + searchTerm: 'flutter', + page: 1, + hasReachedMax: true, + generation: 1, + ), + ], + ); + + blocTest( + 'TextChanged maps SearchResultError to SearchStateError(message)', + setUp: () { + when( + () => repository.search('flutter'), + ).thenThrow(SearchResultError(message: 'rate limited')); + }, + build: () => GithubSearchBloc(githubRepository: repository), + act: (bloc) => bloc.add(const TextChanged(text: 'flutter')), + wait: const Duration(milliseconds: 400), + expect: () => [ + SearchStateLoading(), + const SearchStateError('rate limited'), + ], + ); + + blocTest( + 'TextChanged maps a generic error to the fallback message', + setUp: () { + when(() => repository.search('flutter')).thenThrow(Exception('boom')); + }, + build: () => GithubSearchBloc(githubRepository: repository), + act: (bloc) => bloc.add(const TextChanged(text: 'flutter')), + wait: const Duration(milliseconds: 400), + expect: () => [ + SearchStateLoading(), + const SearchStateError('something went wrong'), + ], + ); + + blocTest( + 'Refreshed emits SearchStateError when the repo throws', + setUp: () { + when( + () => repository.search('flutter', forceRefresh: true), + ).thenThrow(SearchResultError(message: 'refresh failed')); + }, + build: () => GithubSearchBloc(githubRepository: repository), + seed: () => const SearchStateSuccess( + items: [_itemA], + searchTerm: 'flutter', + page: 1, + hasReachedMax: false, + generation: 0, + ), + act: (bloc) => bloc.add(const Refreshed()), + expect: () => const [ + SearchStateError('refresh failed'), + ], + ); + + blocTest( + 'NextPageRequested is a no-op when state is not SearchStateSuccess', + build: () => GithubSearchBloc(githubRepository: repository), + act: (bloc) => bloc.add(const NextPageRequested()), + expect: () => const [], + verify: (_) => verifyNever( + () => repository.search(any(), page: any(named: 'page')), + ), + ); + + blocTest( + 'NextPageRequested emits SearchStateError when the repo throws', + setUp: () { + when( + () => repository.search('flutter', page: 2), + ).thenThrow(Exception('boom')); + }, + build: () => GithubSearchBloc(githubRepository: repository), + seed: () => const SearchStateSuccess( + items: [_itemA], + searchTerm: 'flutter', + page: 1, + hasReachedMax: false, + generation: 0, + ), + act: (bloc) => bloc.add(const NextPageRequested()), + expect: () => const [ + SearchStateError('something went wrong'), + ], + ); + + blocTest( + 'NextPageRequested with empty page marks hasReachedMax true', + setUp: () { + when(() => repository.search('flutter', page: 2)).thenAnswer( + (_) async => const SearchResult(items: [], totalCount: 100), + ); + }, + build: () => GithubSearchBloc(githubRepository: repository), + seed: () => const SearchStateSuccess( + items: [_itemA], + searchTerm: 'flutter', + page: 1, + hasReachedMax: false, + generation: 0, + ), + act: (bloc) => bloc.add(const NextPageRequested()), + expect: () => const [ + SearchStateSuccess( + items: [_itemA], + searchTerm: 'flutter', + page: 2, + hasReachedMax: true, + generation: 0, + ), + ], + ); + + blocTest( + 'race guard: a slow next page resolving after a refresh is dropped', + setUp: () { + // Next page hangs until we complete it manually, AFTER the refresh. + final pageCompleter = Completer(); + when( + () => repository.search('flutter', page: 2), + ).thenAnswer((_) => pageCompleter.future); + when( + () => repository.search('flutter', forceRefresh: true), + ).thenAnswer( + (_) async => const SearchResult(items: [_itemC], totalCount: 100), + ); + _pageCompleter = pageCompleter; + }, + build: () => GithubSearchBloc(githubRepository: repository), + // generation 0 matches the bloc's internal counter initial value. + seed: () => const SearchStateSuccess( + items: [_itemA], + searchTerm: 'flutter', + page: 1, + hasReachedMax: false, + generation: 0, + ), + act: (bloc) async { + bloc.add(const NextPageRequested()); // captures generation 0, awaits + await Future.delayed(Duration.zero); + bloc.add(const Refreshed()); // bumps generation to 1, emits page 1 + await Future.delayed(const Duration(milliseconds: 50)); + _pageCompleter.complete( + const SearchResult(items: [_itemB], totalCount: 100), + ); // resolves late -> must be dropped, not merged + }, + wait: const Duration(milliseconds: 100), + // Only the refresh survives; the stale next page (would-be + // [_itemA, _itemB]) is discarded because generation moved from 0 to 1. + expect: () => const [ + SearchStateSuccess( + items: [_itemC], + searchTerm: 'flutter', + page: 1, + hasReachedMax: false, + generation: 1, + ), + ], + ); + }); +} + +late Completer _pageCompleter; diff --git a/examples/github_search/common_github_search/test/github_search_event_test.dart b/examples/github_search/common_github_search/test/github_search_event_test.dart new file mode 100644 index 00000000000..b050b9b20ab --- /dev/null +++ b/examples/github_search/common_github_search/test/github_search_event_test.dart @@ -0,0 +1,46 @@ +import 'package:common_github_search/common_github_search.dart'; +import 'package:test/test.dart'; + +void main() { + group('TextChanged', () { + test('supports value equality via props', () { + expect( + const TextChanged(text: 'flutter'), + const TextChanged(text: 'flutter'), + ); + expect( + const TextChanged(text: 'flutter'), + isNot(const TextChanged(text: 'bloc')), + ); + }); + + test('toString includes the text', () { + expect( + const TextChanged(text: 'flutter').toString(), + 'TextChanged { text: flutter }', + ); + }); + }); + + group('NextPageRequested', () { + test('supports value equality with empty props', () { + expect(const NextPageRequested(), const NextPageRequested()); + expect(const NextPageRequested().props, isEmpty); + }); + + test('toString uses the default Equatable representation', () { + expect(const NextPageRequested().toString(), 'NextPageRequested()'); + }); + }); + + group('Refreshed', () { + test('supports value equality with empty props', () { + expect(const Refreshed(), const Refreshed()); + expect(const Refreshed().props, isEmpty); + }); + + test('toString uses the default Equatable representation', () { + expect(const Refreshed().toString(), 'Refreshed()'); + }); + }); +} diff --git a/examples/github_search/common_github_search/test/github_search_state_test.dart b/examples/github_search/common_github_search/test/github_search_state_test.dart new file mode 100644 index 00000000000..6590839f9c4 --- /dev/null +++ b/examples/github_search/common_github_search/test/github_search_state_test.dart @@ -0,0 +1,82 @@ +import 'package:common_github_search/common_github_search.dart'; +import 'package:test/test.dart'; + +const _owner = GithubUser(login: 'octocat', avatarUrl: 'https://avatar'); +const _item = SearchResultItem( + fullName: 'a/a', + htmlUrl: 'https://a', + owner: _owner, +); + +void main() { + group('SearchStateEmpty', () { + test('supports value equality', () { + expect(SearchStateEmpty(), SearchStateEmpty()); + }); + }); + + group('SearchStateLoading', () { + test('supports value equality', () { + expect(SearchStateLoading(), SearchStateLoading()); + }); + }); + + group('SearchStateSuccess', () { + const state = SearchStateSuccess( + items: [_item], + searchTerm: 'flutter', + page: 1, + hasReachedMax: false, + generation: 1, + ); + + test('supports value equality via props', () { + expect( + state, + const SearchStateSuccess( + items: [_item], + searchTerm: 'flutter', + page: 1, + hasReachedMax: false, + generation: 1, + ), + ); + }); + + test('differs when any field differs', () { + expect( + state, + isNot( + const SearchStateSuccess( + items: [_item], + searchTerm: 'flutter', + page: 2, + hasReachedMax: false, + generation: 1, + ), + ), + ); + }); + + test('toString summarises item count, page, max and generation', () { + expect( + state.toString(), + 'SearchStateSuccess { items: 1, page: 1, ' + 'hasReachedMax: false, generation: 1 }', + ); + }); + }); + + group('SearchStateError', () { + test('supports value equality via props', () { + expect( + const SearchStateError('boom'), + const SearchStateError('boom'), + ); + expect( + const SearchStateError('boom'), + isNot(const SearchStateError('bang')), + ); + }); + }); +} diff --git a/examples/github_search/common_github_search/test/models_test.dart b/examples/github_search/common_github_search/test/models_test.dart new file mode 100644 index 00000000000..789b46d6157 --- /dev/null +++ b/examples/github_search/common_github_search/test/models_test.dart @@ -0,0 +1,64 @@ +import 'package:common_github_search/common_github_search.dart'; +import 'package:test/test.dart'; + +void main() { + group('GithubUser', () { + test('fromJson maps login and avatar_url', () { + final user = GithubUser.fromJson({ + 'login': 'octocat', + 'avatar_url': 'https://avatar', + }); + + expect(user.login, 'octocat'); + expect(user.avatarUrl, 'https://avatar'); + }); + }); + + group('SearchResultItem', () { + test('fromJson maps fields and nested owner', () { + final item = SearchResultItem.fromJson({ + 'full_name': 'a/a', + 'html_url': 'https://a', + 'owner': {'login': 'octocat', 'avatar_url': 'https://avatar'}, + }); + + expect(item.fullName, 'a/a'); + expect(item.htmlUrl, 'https://a'); + expect(item.owner.login, 'octocat'); + expect(item.owner.avatarUrl, 'https://avatar'); + }); + }); + + group('SearchResult', () { + test('fromJson maps items and total_count', () { + final result = SearchResult.fromJson({ + 'total_count': 42, + 'items': [ + { + 'full_name': 'a/a', + 'html_url': 'https://a', + 'owner': {'login': 'octocat', 'avatar_url': 'https://avatar'}, + }, + ], + }); + + expect(result.totalCount, 42); + expect(result.items.single.fullName, 'a/a'); + }); + + test('fromJson defaults total_count to 0 when missing', () { + final result = SearchResult.fromJson({'items': []}); + + expect(result.totalCount, 0); + expect(result.items, isEmpty); + }); + }); + + group('SearchResultError', () { + test('fromJson maps message', () { + final error = SearchResultError.fromJson({'message': 'not found'}); + + expect(error.message, 'not found'); + }); + }); +} diff --git a/examples/github_search/flutter_github_search/lib/search_form.dart b/examples/github_search/flutter_github_search/lib/search_form.dart index 43b2e65763f..c3fe2883c10 100644 --- a/examples/github_search/flutter_github_search/lib/search_form.dart +++ b/examples/github_search/flutter_github_search/lib/search_form.dart @@ -78,25 +78,97 @@ class _SearchBody extends StatelessWidget { SearchStateSuccess() => state.items.isEmpty ? const Text('No Results') - : Expanded(child: _SearchResults(items: state.items)), + : Expanded(child: _SearchResults(state: state)), }; }, ); } } -class _SearchResults extends StatelessWidget { - const _SearchResults({required this.items}); +class _SearchResults extends StatefulWidget { + const _SearchResults({required this.state}); - final List items; + final SearchStateSuccess state; + + @override + State<_SearchResults> createState() => _SearchResultsState(); +} + +class _SearchResultsState extends State<_SearchResults> { + final _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { - return ListView.builder( - itemCount: items.length, - itemBuilder: (BuildContext context, int index) { - return _SearchResultItem(item: items[index]); + final items = widget.state.items; + final hasReachedMax = widget.state.hasReachedMax; + return RefreshIndicator( + onRefresh: () { + // ponytail: RefreshIndicator needs a Future to dismiss its spinner — + // fire Refreshed and await the next terminal (success/error) state. + // orElse guards a closed stream (firstWhere would throw StateError), + // catchError guards any other stream error, and timeout guarantees the + // spinner always dismisses even if no terminal state ever arrives. + final bloc = context.read()..add(const Refreshed()); + return bloc.stream + .firstWhere( + (s) => s is SearchStateSuccess || s is SearchStateError, + orElse: () => bloc.state, + ) + .timeout( + const Duration(seconds: 10), + onTimeout: () => bloc.state, + ) + .catchError((_) => bloc.state); }, + child: ListView.builder( + controller: _scrollController, + itemCount: hasReachedMax ? items.length : items.length + 1, + itemBuilder: (BuildContext context, int index) { + return index >= items.length + ? const _BottomLoader() + : _SearchResultItem(item: items[index]); + }, + ), + ); + } + + void _onScroll() { + if (_isBottom) { + context.read().add(const NextPageRequested()); + } + } + + bool get _isBottom { + if (!_scrollController.hasClients) return false; + final maxScroll = _scrollController.position.maxScrollExtent; + final currentScroll = _scrollController.offset; + return currentScroll >= (maxScroll * 0.9); + } +} + +class _BottomLoader extends StatelessWidget { + const _BottomLoader(); + + @override + Widget build(BuildContext context) { + return const Center( + child: SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator.adaptive(strokeWidth: 1.5), + ), ); } }