From 15738644b021401aff8c2834328f7f1a00670a5a Mon Sep 17 00:00:00 2001 From: Levi Date: Sat, 10 Oct 2026 17:29:06 +0800 Subject: [PATCH] perf(extensions): load store summaries without waiting for update checks --- ui/flutter/lib/api/gopeed_site_api.dart | 61 ++++- ui/flutter/lib/api/model/store_extension.dart | 3 + .../application/extensions_controller.dart | 146 +++++++---- .../store_extension_details_provider.dart | 25 ++ .../widgets/extension_detail_view.dart | 27 +- ui/flutter/test/api/gopeed_site_api_test.dart | 78 ++++++ .../extensions_controller_test.dart | 234 ++++++++++++++++++ .../extension_detail_loading_test.dart | 89 +++++++ 8 files changed, 605 insertions(+), 58 deletions(-) create mode 100644 ui/flutter/lib/features/extensions/application/store_extension_details_provider.dart create mode 100644 ui/flutter/test/api/gopeed_site_api_test.dart create mode 100644 ui/flutter/test/features/extensions/application/extensions_controller_test.dart create mode 100644 ui/flutter/test/features/extensions/presentation/widgets/extension_detail_loading_test.dart diff --git a/ui/flutter/lib/api/gopeed_site_api.dart b/ui/flutter/lib/api/gopeed_site_api.dart index e90dedda..73c34f8f 100644 --- a/ui/flutter/lib/api/gopeed_site_api.dart +++ b/ui/flutter/lib/api/gopeed_site_api.dart @@ -11,6 +11,8 @@ class GopeedSiteApi { static final instance = GopeedSiteApi._(); static const _host = 'gopeed.com'; + final _storePages = {}; + final _storeRequests = >{}; Future> getReleases({int perPage = 10}) async { final json = await _getJson('/api/releases', queryParameters: {'per_page': perPage.clamp(1, 100).toString()}); @@ -26,20 +28,52 @@ class GopeedSiteApi { StoreExtensionSort sort = StoreExtensionSort.stars, StoreSortOrder order = StoreSortOrder.desc, String? query, + bool forceRefresh = false, }) async { - final json = await _getJson( - '/api/extensions', - queryParameters: { - 'page': page.toString(), - 'limit': limit.clamp(1, 100).toString(), - 'sort': sort.name, - 'order': order.name, - if (query != null && query.trim().isNotEmpty) 'q': query.trim(), - }, - ); + final params = { + 'page': page.toString(), + 'limit': limit.clamp(1, 100).toString(), + 'sort': sort.name, + 'order': order.name, + 'view': 'summary', + if (query != null && query.trim().isNotEmpty) 'q': query.trim(), + }; + final key = Uri.https(_host, '/api/extensions', params).toString(); + final now = DateTime.now(); + _storePages.removeWhere((_, entry) => !now.isBefore(entry.expires)); + final cached = _storePages[key]; + if (!forceRefresh && cached != null) return cached.page; + final pending = _storeRequests[key]; + if (pending != null) return pending; + final request = _fetchStorePage(params); + _storeRequests[key] = request; + try { + final result = await request; + if (_storePages.length >= 32) _storePages.remove(_storePages.keys.first); + _storePages[key] = (page: result, expires: DateTime.now().add(const Duration(minutes: 2))); + return result; + } finally { + _storeRequests.remove(key); + } + } + + Future _fetchStorePage(Map params) async { + final json = await _getJson('/api/extensions', queryParameters: params); return StoreExtensionPage.fromJson(json as Map); } + Future getExtension(String id, {String? version}) async { + final json = await _getJsonUri( + Uri( + scheme: 'https', + host: _host, + pathSegments: ['api', 'extensions', id], + queryParameters: {if (version != null && version.isNotEmpty) 'version': version}, + ), + ); + return StoreExtension.fromJson(json as Map); + } + Future reportExtensionInstall(String id) async { final uri = Uri.https(_host, '/api/extensions/install'); await proxyRequest( @@ -49,8 +83,11 @@ class GopeedSiteApi { ); } - Future _getJson(String path, {Map? queryParameters}) async { - final uri = Uri.https(_host, path, queryParameters); + Future _getJson(String path, {Map? queryParameters}) { + return _getJsonUri(Uri.https(_host, path, queryParameters)); + } + + Future _getJsonUri(Uri uri) async { final Response response = await proxyRequest(uri.toString()); if (response.data == null || response.data!.isEmpty) { throw Exception('Empty response from $uri'); diff --git a/ui/flutter/lib/api/model/store_extension.dart b/ui/flutter/lib/api/model/store_extension.dart index ca5a9b7b..7fe6f025 100644 --- a/ui/flutter/lib/api/model/store_extension.dart +++ b/ui/flutter/lib/api/model/store_extension.dart @@ -64,6 +64,7 @@ class StoreExtension { final List topics; final DateTime? createdAt; final DateTime? updatedAt; + final bool hasDetails; StoreExtension({ required this.id, @@ -84,6 +85,7 @@ class StoreExtension { required this.topics, this.createdAt, this.updatedAt, + this.hasDetails = true, }); factory StoreExtension.fromJson(Map json) { @@ -106,6 +108,7 @@ class StoreExtension { topics: _parseTopics(json['topics']), createdAt: _parseDate(json['createdAt']), updatedAt: _parseDate(json['updatedAt']), + hasDetails: json.containsKey('readme'), ); } diff --git a/ui/flutter/lib/features/extensions/application/extensions_controller.dart b/ui/flutter/lib/features/extensions/application/extensions_controller.dart index 74e439d3..3fe87fcb 100644 --- a/ui/flutter/lib/features/extensions/application/extensions_controller.dart +++ b/ui/flutter/lib/features/extensions/application/extensions_controller.dart @@ -3,13 +3,13 @@ import 'dart:collection'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../api/gopeed_site_api.dart'; import '../../../api/model/extension.dart'; import '../../../api/model/install_extension.dart'; import '../../../api/model/store_extension.dart'; import '../../../api/model/switch_extension.dart'; import '../../../api/model/update_extension_settings.dart'; import '../../../core/capabilities/app_capabilities.dart'; +import 'store_extension_details_provider.dart'; final extensionsControllerProvider = AsyncNotifierProvider( ExtensionsController.new, @@ -121,63 +121,81 @@ class ExtensionsState { class ExtensionsController extends AsyncNotifier { static const manualInstallBusyKey = '__manual_install__'; int _devModeCount = 0; + int _installedRevision = 0; + int _storeRevision = 0; + Future? _updateCheck; + bool _checkUpdatesAgain = false; @override - Future build() async { - var next = const ExtensionsState(loadingInstalled: true, loadingStore: true); + Future build() => _loadInitialData(); + + Future _loadInitialData({bool forceRefresh = false}) async { + final next = _current.copyWith(loadingInstalled: true, loadingStore: true); state = AsyncValue.data(next); - final installed = await _loadInstalled(refreshUpdates: true, current: next); - next = state.value ?? installed; - await _refreshStore(current: next); - return state.value ?? next; + await Future.wait([ + _loadInstalled(refreshUpdates: true, current: next), + _refreshStore(current: _current, forceRefresh: forceRefresh), + ]); + return ref.mounted ? _current : next; } Future loadInitialData() async { - state = const AsyncValue.loading(); - state = AsyncValue.data(await build()); + await _loadInitialData(forceRefresh: true); } Future loadInstalled({bool refreshUpdates = false}) async { + if (!ref.mounted) return; await _loadInstalled(refreshUpdates: refreshUpdates, current: _current.copyWith(loadingInstalled: true)); } Future refreshStore() async { - await _refreshStore(current: _current.copyWith(loadingStore: true)); + await _refreshStore(current: _current.copyWith(loadingStore: true), forceRefresh: true); } Future loadMoreStore() async { final current = _current; final pagination = current.storePagination; if (pagination == null || !pagination.hasNext || current.loadingMoreStore) return; + final revision = _storeRevision; state = AsyncValue.data(current.copyWith(loadingMoreStore: true)); try { - final page = await GopeedSiteApi.instance.getExtensions( - page: pagination.page + 1, - limit: pagination.limit, - sort: current.storeSort, - query: current.storeQuery, - ); + final page = await ref + .read(gopeedSiteApiProvider) + .getExtensions( + page: pagination.page + 1, + limit: pagination.limit, + sort: current.storeSort, + query: current.storeQuery, + ); + if (!ref.mounted || revision != _storeRevision) return; state = AsyncValue.data( _current.copyWith( - storeExtensions: [..._current.storeExtensions, ...page.data], + // Cached pages can briefly overlap when stars/install counts change. + storeExtensions: { + for (final extension in _current.storeExtensions) extension.id: extension, + for (final extension in page.data) extension.id: extension, + }.values.toList(), storePagination: page.pagination, loadingMoreStore: false, ), ); } catch (_) { + if (!ref.mounted || revision != _storeRevision) return; state = AsyncValue.data(_current.copyWith(loadingMoreStore: false)); rethrow; } } Future searchStore(String query) async { + if (query.trim() == _current.storeQuery) return; state = AsyncValue.data(_current.copyWith(storeQuery: query.trim())); - await refreshStore(); + await _refreshStore(current: _current); } Future changeSort(StoreExtensionSort sort) async { + if (sort == _current.storeSort) return; state = AsyncValue.data(_current.copyWith(storeSort: sort)); - await refreshStore(); + await _refreshStore(current: _current); } void changeFilter(ExtensionListFilter filter) { @@ -255,23 +273,43 @@ class ExtensionsController extends AsyncNotifier { }); } - Future checkUpdate() async { - final flags = {}; - for (final ext in _current.installedExtensions) { - try { - final resp = await ref.read(gopeedServiceProvider).upgradeCheckExtension(ext.identity); - if (resp.newVersion.isNotEmpty) { - flags[ext.identity] = resp.newVersion; + Future checkUpdate() { + if (!ref.mounted) return Future.value(); + _checkUpdatesAgain = true; + // Installation/refresh can request a fresh scan while one is running. + // Coalesce those requests instead of launching overlapping Git operations. + return _updateCheck ??= _runUpdateChecks().whenComplete(() => _updateCheck = null); + } + + Future _runUpdateChecks() async { + final service = ref.read(gopeedServiceProvider); + while (ref.mounted && _checkUpdatesAgain) { + _checkUpdatesAgain = false; + final revision = _installedRevision; + final installed = List.of(_current.installedExtensions); + final flags = {}; + for (final ext in installed) { + if (!ref.mounted) return; + if (revision != _installedRevision) break; + try { + final resp = await service.upgradeCheckExtension(ext.identity); + if (!ref.mounted) return; + if (revision != _installedRevision) break; + if (resp.newVersion.isNotEmpty) flags[ext.identity] = resp.newVersion; + } catch (_) { + // A failed update check must never prevent loading the store. } - } catch (_) {} + } + if (ref.mounted && revision == _installedRevision) { + state = AsyncValue.data(_current.copyWith(updateFlags: flags)); + } } - state = AsyncValue.data(_current.copyWith(updateFlags: flags)); } void tryOpenDevMode() { if (_devModeCount == 0) { Future.delayed(const Duration(seconds: 2), () { - if (_current.devMode) return; + if (!ref.mounted || _current.devMode) return; _devModeCount = 0; }); } @@ -284,26 +322,40 @@ class ExtensionsController extends AsyncNotifier { ExtensionsState get _current => state.value ?? const ExtensionsState(); Future _loadInstalled({required bool refreshUpdates, required ExtensionsState current}) async { + final revision = ++_installedRevision; state = AsyncValue.data(current.copyWith(loadingInstalled: true)); final installed = await ref.read(gopeedServiceProvider).getExtensions(); + if (!ref.mounted) return current; + if (revision != _installedRevision) return _current; state = AsyncValue.data(_current.copyWith(installedExtensions: installed, loadingInstalled: false)); if (refreshUpdates) { - await checkUpdate(); + unawaited(checkUpdate()); } return _current; } - Future _refreshStore({required ExtensionsState current}) async { - state = AsyncValue.data(current.copyWith(loadingStore: true)); - final page = await GopeedSiteApi.instance.getExtensions( - page: 1, - limit: 20, - sort: _current.storeSort, - query: _current.storeQuery, - ); - state = AsyncValue.data( - _current.copyWith(storeExtensions: page.data, storePagination: page.pagination, loadingStore: false), - ); + Future _refreshStore({required ExtensionsState current, bool forceRefresh = false}) async { + final revision = ++_storeRevision; + state = AsyncValue.data(current.copyWith(loadingStore: true, loadingMoreStore: false)); + try { + final page = await ref + .read(gopeedSiteApiProvider) + .getExtensions( + page: 1, + limit: 20, + sort: current.storeSort, + query: current.storeQuery, + forceRefresh: forceRefresh, + ); + if (!ref.mounted || revision != _storeRevision) return; + state = AsyncValue.data( + _current.copyWith(storeExtensions: page.data, storePagination: page.pagination, loadingStore: false), + ); + } catch (_) { + if (!ref.mounted || revision != _storeRevision) return; + state = AsyncValue.data(_current.copyWith(loadingStore: false)); + rethrow; + } } Future _runBusy(String id, Future Function() action) async { @@ -312,12 +364,15 @@ class ExtensionsController extends AsyncNotifier { try { await action(); } finally { - final busy = Set.of(_current.busyExtensionIds)..remove(id); - state = AsyncValue.data(_current.copyWith(busyExtensionIds: busy)); + if (ref.mounted) { + final busy = Set.of(_current.busyExtensionIds)..remove(id); + state = AsyncValue.data(_current.copyWith(busyExtensionIds: busy)); + } } } void _bumpStoreInstallCount(String id) { + if (!ref.mounted) return; final current = _current; final index = current.storeExtensions.indexWhere((e) => e.id == id); if (index < 0) return; @@ -342,14 +397,17 @@ class ExtensionsController extends AsyncNotifier { topics: ext.topics, createdAt: ext.createdAt, updatedAt: ext.updatedAt, + hasDetails: ext.hasDetails, ); state = AsyncValue.data(current.copyWith(storeExtensions: updated)); } void _reportInstallSafe(String id) { + if (!ref.mounted) return; + final siteApi = ref.read(gopeedSiteApiProvider); unawaited(() async { try { - await GopeedSiteApi.instance.reportExtensionInstall(id); + await siteApi.reportExtensionInstall(id); } catch (_) {} }()); } diff --git a/ui/flutter/lib/features/extensions/application/store_extension_details_provider.dart b/ui/flutter/lib/features/extensions/application/store_extension_details_provider.dart new file mode 100644 index 00000000..5d222a5d --- /dev/null +++ b/ui/flutter/lib/features/extensions/application/store_extension_details_provider.dart @@ -0,0 +1,25 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../api/gopeed_site_api.dart'; +import '../../../api/model/store_extension.dart'; + +final gopeedSiteApiProvider = Provider((ref) => GopeedSiteApi.instance); + +typedef StoreExtensionDetailsKey = ({String id, String version}); + +// A shared future deduplicates drawer/page requests. Successful details survive +// closing the view, while failures can be retried and new versions get a new key. +final storeExtensionDetailsProvider = FutureProvider.autoDispose.family(( + ref, + key, +) async { + final extension = await ref.watch(gopeedSiteApiProvider).getExtension(key.id, version: key.version); + if (ref.mounted) { + final keepAlive = ref.keepAlive(); + final expiry = Timer(const Duration(minutes: 30), keepAlive.close); + ref.onDispose(expiry.cancel); + } + return extension; +}); diff --git a/ui/flutter/lib/features/extensions/presentation/widgets/extension_detail_view.dart b/ui/flutter/lib/features/extensions/presentation/widgets/extension_detail_view.dart index 87837560..0aa29c2b 100644 --- a/ui/flutter/lib/features/extensions/presentation/widgets/extension_detail_view.dart +++ b/ui/flutter/lib/features/extensions/presentation/widgets/extension_detail_view.dart @@ -19,6 +19,7 @@ import '../../../../shared/widgets/app_toast.dart'; import '../../../../shared/widgets/detail/app_detail_surface.dart'; import '../../../../util/util.dart'; import '../../application/extensions_controller.dart'; +import '../../application/store_extension_details_provider.dart'; import 'extension_icon.dart'; import 'extension_update_dialog.dart'; @@ -50,7 +51,13 @@ class ExtensionDetailView extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final palette = AppPalette.of(context); final state = ref.watch(extensionsControllerProvider).value; - final current = state?.findItem(item.id) ?? item; + final summary = state?.findItem(item.id) ?? item; + final summaryStore = summary.store; + final detailKey = summaryStore == null || summaryStore.hasDetails + ? null + : (id: summaryStore.id, version: summaryStore.version); + final details = detailKey == null ? null : ref.watch(storeExtensionDetailsProvider(detailKey)); + final current = ExtensionListItem(installed: summary.installed, store: details?.value ?? summaryStore); final installed = current.installed; final store = current.store; final busy = state?.busyExtensionIds.contains(current.id) ?? false; @@ -103,7 +110,7 @@ class ExtensionDetailView extends ConsumerWidget { 'v${current.version}', style: TextStyle(color: palette.textMuted, fontSize: 11.5, fontWeight: FontWeight.w600), ), - if (store != null) _ExtensionStats(store: store), + if (summaryStore != null) _ExtensionStats(store: summaryStore), if (installed != null && canUpdate) _ExtensionStatus(label: context.l10n.extensionCanUpdate, emphasized: true), ], @@ -162,6 +169,21 @@ class ExtensionDetailView extends ConsumerWidget { ), ], ), + if (details?.isLoading == true) + const Padding( + key: ValueKey('extension-details-loading'), + padding: EdgeInsets.only(top: 24), + child: Center(child: shad.CircularProgressIndicator()), + ), + if (details?.hasError == true && detailKey != null) + Padding( + padding: const EdgeInsets.only(top: 24), + child: shad.SecondaryButton( + key: const ValueKey('extension-details-retry'), + onPressed: () => ref.invalidate(storeExtensionDetailsProvider(detailKey)), + child: Text(context.l10n.retry), + ), + ), if (store != null) ...[SizedBox(height: mobile ? 24 : 28), _ExtensionReadme(item: current, mobile: mobile)], ], ); @@ -204,6 +226,7 @@ class _ExtensionReadmeState extends State<_ExtensionReadme> { final previous = oldWidget.item.installed; final current = widget.item.installed; if (previous?.identity != current?.identity || + oldWidget.item.id != widget.item.id || previous?.devMode != current?.devMode || previous?.devPath != current?.devPath || oldWidget.item.store?.readme != widget.item.store?.readme) { diff --git a/ui/flutter/test/api/gopeed_site_api_test.dart b/ui/flutter/test/api/gopeed_site_api_test.dart new file mode 100644 index 00000000..fdda9510 --- /dev/null +++ b/ui/flutter/test/api/gopeed_site_api_test.dart @@ -0,0 +1,78 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/api/api.dart' as api; +import 'package:gopeed/api/gopeed_site_api.dart'; +import 'package:gopeed/core/network/gopeed/gopeed_transport.dart'; + +void main() { + test('summary requests are shared and cached; explicit refresh requests fresh data', () async { + final pending = Completer>(); + final transport = _Transport()..onProxy = (_) => pending.future; + api.setTransportForTesting(transport); + final site = GopeedSiteApi.instance; + final first = site.getExtensions(query: 'cache-test', forceRefresh: true); + final second = site.getExtensions(query: 'cache-test'); + expect(transport.urls.length, 1); + expect(Uri.parse(transport.urls.single).queryParameters['view'], 'summary'); + pending.complete( + _response({ + 'data': [ + {'id': 'test'}, + ], + }), + ); + expect((await first).data.single.hasDetails, isFalse); + await second; + await site.getExtensions(query: 'cache-test'); + expect(transport.urls.length, 1); + await site.getExtensions(query: 'cache-test', forceRefresh: true); + expect(transport.urls.length, 2); + }); + + test('failed lists are not cached', () async { + final transport = _Transport()..onProxy = (_) => Future.error(StateError('offline')); + api.setTransportForTesting(transport); + final site = GopeedSiteApi.instance; + await expectLater(site.getExtensions(query: 'error-test'), throwsStateError); + transport.onProxy = (_) async => _response({ + 'data': [ + {'id': 'recovered'}, + ], + }); + expect((await site.getExtensions(query: 'error-test')).data.single.id, 'recovered'); + expect(transport.urls.length, 2); + }); + + test('detail IDs are encoded once and a null README is still loaded detail', () async { + final transport = _Transport()..onProxy = (_) async => _response({'id': '开发者@name', 'readme': null}); + api.setTransportForTesting(transport); + final result = await GopeedSiteApi.instance.getExtension('开发者@name', version: '1.2.3'); + final uri = Uri.parse(transport.urls.single); + expect(uri.pathSegments, ['api', 'extensions', '开发者@name']); + expect(uri.queryParameters['version'], '1.2.3'); + expect(result.hasDetails, isTrue); + }); +} + +Response _response(Map json) => Response( + requestOptions: RequestOptions(path: '/test'), + data: jsonEncode(json), + statusCode: 200, +); + +class _Transport implements GopeedTransport { + final urls = []; + Future> Function(String) onProxy = (_) async => _response({'data': []}); + + @override + Future> proxyRequest(String uri, {dynamic data, Options? options}) { + urls.add(uri); + return onProxy(uri); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/ui/flutter/test/features/extensions/application/extensions_controller_test.dart b/ui/flutter/test/features/extensions/application/extensions_controller_test.dart new file mode 100644 index 00000000..37f4179c --- /dev/null +++ b/ui/flutter/test/features/extensions/application/extensions_controller_test.dart @@ -0,0 +1,234 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/api/gopeed_site_api.dart'; +import 'package:gopeed/api/model/extension.dart'; +import 'package:gopeed/api/model/store_extension.dart'; +import 'package:gopeed/api/model/update_check_extension_resp.dart'; +import 'package:gopeed/core/capabilities/app_capabilities.dart'; +import 'package:gopeed/core/capabilities/capability_rpc.dart'; +import 'package:gopeed/core/capabilities/gopeed_capability.dart'; +import 'package:gopeed/features/extensions/application/extensions_controller.dart'; +import 'package:gopeed/features/extensions/application/store_extension_details_provider.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('store loads while installed data and update checks are still pending', () async { + final installed = Completer>(); + final update = Completer(); + var updateCalls = 0; + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getExtensions, (_) => installed.future) + ..bind(GopeedMethods.checkExtensionUpdate, (_) { + updateCalls++; + return update.future; + }); + final site = _SiteApi(); + final container = _container(registry, site); + final subscription = container.listen(extensionsControllerProvider, (_, _) {}); + addTearDown(subscription.close); + + await _settle(); + expect(site.listCalls, 1); + expect(container.read(extensionsControllerProvider).value?.storeExtensions.single.id, 'market'); + expect(container.read(extensionsControllerProvider).value?.loadingStore, isFalse); + expect(updateCalls, 0); + + installed.complete([_installed()]); + await _settle(); + expect(updateCalls, 1); + expect(container.read(extensionsControllerProvider).value?.loadingInstalled, isFalse); + expect(container.read(extensionsControllerProvider).value?.storeExtensions.single.id, 'market'); + update.complete(UpdateCheckExtensionResp(newVersion: '2.0.0')); + await _settle(); + expect(container.read(extensionsControllerProvider).value?.updateFlags, {'gopeed@test': '2.0.0'}); + }); + + test('a stale update scan cannot restore flags after installed data changes', () async { + var installed = [_installed()]; + final update = Completer(); + var updateCalls = 0; + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getExtensions, (_) => installed) + ..bind(GopeedMethods.checkExtensionUpdate, (_) { + updateCalls++; + return update.future; + }); + final container = _container(registry, _SiteApi()); + final controller = container.read(extensionsControllerProvider.notifier); + await _settle(); + final running = controller.checkUpdate(); + expect(updateCalls, 1); + installed = []; + await controller.loadInstalled(); + update.complete(UpdateCheckExtensionResp(newVersion: '2.0.0')); + await running; + expect(container.read(extensionsControllerProvider).value?.installedExtensions, isEmpty); + expect(container.read(extensionsControllerProvider).value?.updateFlags, isEmpty); + }); + + test('an update scan can finish after the controller is disposed', () async { + final update = Completer(); + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getExtensions, (_) => [_installed()]) + ..bind(GopeedMethods.checkExtensionUpdate, (_) => update.future); + final container = _container(registry, _SiteApi()); + final controller = container.read(extensionsControllerProvider.notifier); + await _settle(); + final running = controller.checkUpdate(); + container.dispose(); + update.complete(UpdateCheckExtensionResp(newVersion: '2.0.0')); + await running; + }); + + test('old search results cannot replace a newer search', () async { + final old = Completer(); + final latest = Completer(); + final site = _SiteApi() + ..onList = (query) => switch (query) { + 'old' => old.future, + 'new' => latest.future, + _ => Future.value(_page('market')), + }; + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getExtensions, (_) => []); + final container = _container(registry, site); + final controller = container.read(extensionsControllerProvider.notifier); + await _settle(); + final first = controller.searchStore('old'); + final second = controller.searchStore('new'); + latest.complete(_page('new-result')); + await second; + old.complete(_page('old-result')); + await first; + final state = container.read(extensionsControllerProvider).value!; + expect(state.storeQuery, 'new'); + expect(state.storeExtensions.single.id, 'new-result'); + expect(state.loadingStore, isFalse); + }); + + test('overlapping cached pages do not duplicate an extension', () async { + final site = _SiteApi(); + site.onList = (_) async => StoreExtensionPage.fromJson({ + 'data': [ + {'id': 'market', 'version': '1.0.0'}, + if (site.listCalls > 1) {'id': 'second', 'version': '1.0.0'}, + ], + 'pagination': {'page': site.listCalls, 'limit': 20, 'hasNext': site.listCalls == 1}, + }); + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getExtensions, (_) => []); + final container = _container(registry, site); + final controller = container.read(extensionsControllerProvider.notifier); + await _settle(); + await controller.loadMoreStore(); + expect(container.read(extensionsControllerProvider).value?.storeExtensions.map((e) => e.id), ['market', 'second']); + }); + + test('detail requests are shared, cache empty READMEs, and distinguish versions', () async { + final detail = Completer(); + final site = _SiteApi()..onDetail = (_, _) => detail.future; + final container = ProviderContainer(overrides: [gopeedSiteApiProvider.overrideWithValue(site)]); + addTearDown(container.dispose); + const key = (id: 'market', version: '1.0.0'); + final provider = storeExtensionDetailsProvider(key); + final subscription = container.listen(provider, (_, _) {}); + final first = container.read(provider.future); + final second = container.read(provider.future); + expect(site.detailCalls, 1); + detail.complete(StoreExtension.fromJson({'id': 'market', 'version': '1.0.0', 'readme': null})); + expect((await first).hasDetails, isTrue); + await second; + subscription.close(); + await _settle(); + await container.read(provider.future); + expect(site.detailCalls, 1); + await container.read(storeExtensionDetailsProvider((id: 'market', version: '2.0.0')).future); + expect(site.detailCalls, 2); + }); + + test('failed details can be retried', () async { + final site = _SiteApi()..onDetail = (_, _) => Future.error(StateError('offline')); + final container = ProviderContainer(overrides: [gopeedSiteApiProvider.overrideWithValue(site)]); + addTearDown(container.dispose); + final provider = storeExtensionDetailsProvider((id: 'market', version: '1.0.0')); + final subscription = container.listen(provider, (_, _) {}); + addTearDown(subscription.close); + await expectLater(container.read(provider.future), throwsStateError); + site.onDetail = (_, _) async => StoreExtension.fromJson({'id': 'market', 'readme': 'loaded'}); + container.invalidate(provider); + expect((await container.read(provider.future)).readme, 'loaded'); + expect(site.detailCalls, 2); + }); +} + +ProviderContainer _container(CapabilityRegistry registry, _SiteApi site) { + final container = ProviderContainer( + overrides: [ + appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry))), + gopeedSiteApiProvider.overrideWithValue(site), + ], + ); + addTearDown(container.dispose); + return container; +} + +Future _settle() async { + for (var i = 0; i < 5; i++) { + await Future.delayed(Duration.zero); + } +} + +StoreExtensionPage _page(String id) => StoreExtensionPage.fromJson({ + 'data': [ + {'id': id, 'version': '1.0.0'}, + ], + 'pagination': {'page': 1, 'limit': 20, 'total': 1, 'totalPages': 1}, +}); + +Extension _installed() => Extension( + identity: 'gopeed@test', + name: 'test', + author: 'gopeed', + title: 'Test', + description: '', + icon: '', + version: '1.0.0', + homepage: '', + repository: null, + disabled: false, + devMode: false, + devPath: '', +); + +class _SiteApi implements GopeedSiteApi { + var listCalls = 0; + var detailCalls = 0; + Future Function(String?) onList = (_) async => _page('market'); + Future Function(String, String?) onDetail = (id, _) async => + StoreExtension.fromJson({'id': id, 'readme': null}); + + @override + Future getExtensions({ + int page = 1, + int limit = 20, + StoreExtensionSort sort = StoreExtensionSort.stars, + StoreSortOrder order = StoreSortOrder.desc, + String? query, + bool forceRefresh = false, + }) { + listCalls++; + return onList(query); + } + + @override + Future getExtension(String id, {String? version}) { + detailCalls++; + return onDetail(id, version); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/ui/flutter/test/features/extensions/presentation/widgets/extension_detail_loading_test.dart b/ui/flutter/test/features/extensions/presentation/widgets/extension_detail_loading_test.dart new file mode 100644 index 00000000..60487921 --- /dev/null +++ b/ui/flutter/test/features/extensions/presentation/widgets/extension_detail_loading_test.dart @@ -0,0 +1,89 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/api/gopeed_site_api.dart'; +import 'package:gopeed/api/model/store_extension.dart'; +import 'package:gopeed/features/extensions/application/extensions_controller.dart'; +import 'package:gopeed/features/extensions/application/store_extension_details_provider.dart'; +import 'package:gopeed/features/extensions/presentation/widgets/extension_detail_view.dart'; +import 'package:gopeed/shared/theme/app_theme.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +void main() { + for (final width in [390.0, 1024.0]) { + testWidgets('summary details load and can retry at width $width', (tester) async { + tester.view.physicalSize = Size(width, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final pending = Completer(); + final site = _DetailApi()..request = pending.future; + await tester.pumpWidget( + ProviderScope( + overrides: [ + extensionsControllerProvider.overrideWith(_SummaryController.new), + gopeedSiteApiProvider.overrideWithValue(site), + ], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: shad.Scaffold( + child: ExtensionDetailView( + item: ExtensionListItem(store: _summary()), + mobile: width < 600, + ), + ), + ), + ), + ); + await tester.pump(); + expect(find.byKey(const ValueKey('extension-details-loading')), findsOneWidget); + expect(find.byKey(const ValueKey('extension-details-install')), findsOneWidget); + pending.completeError(StateError('offline')); + await tester.pump(); + await tester.pump(); + expect(find.byKey(const ValueKey('extension-details-retry')), findsOneWidget); + site.request = Future.value( + StoreExtension.fromJson({ + 'id': 'market', + 'title': 'Market', + 'version': '1.0.0', + 'readme': '# Loaded details', + 'homepage': 'https://example.com', + }), + ); + await tester.tap(find.byKey(const ValueKey('extension-details-retry'))); + await tester.pumpAndSettle(); + expect(find.text('Loaded details'), findsOneWidget); + expect(find.byKey(const ValueKey('extension-details-homepage')), findsOneWidget); + expect(find.byKey(const ValueKey('extension-details-retry')), findsNothing); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + }); + } +} + +StoreExtension _summary() => StoreExtension.fromJson({ + 'id': 'market', + 'title': 'Market', + 'version': '1.0.0', + 'repoUrl': 'https://github.com/test/repo', +}); + +class _SummaryController extends ExtensionsController { + @override + Future build() async => ExtensionsState(storeExtensions: [_summary()]); +} + +class _DetailApi implements GopeedSiteApi { + late Future request; + + @override + Future getExtension(String id, {String? version}) => request; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +}