mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-07 09:00:47 +00:00
perf(extensions): load store summaries and check updates asynchronously (#1583)
This commit is contained in:
41 files changed
+1567
-257
No files matched your search
@@ -0,0 +1,265 @@
|
||||
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<List<Extension>>();
|
||||
final update = Completer<UpdateCheckExtensionResp>();
|
||||
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<UpdateCheckExtensionResp>();
|
||||
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<UpdateCheckExtensionResp>();
|
||||
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<StoreExtensionPage>();
|
||||
final latest = Completer<StoreExtensionPage>();
|
||||
final site = _SiteApi()
|
||||
..onList = (query) => switch (query) {
|
||||
'old' => old.future,
|
||||
'new' => latest.future,
|
||||
_ => Future.value(_page('market')),
|
||||
};
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getExtensions, (_) => <Extension>[]);
|
||||
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, (_) => <Extension>[]);
|
||||
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<StoreExtension>();
|
||||
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);
|
||||
expect(site.detailCalls, 3);
|
||||
site.onDetail = (_, _) async => StoreExtension.fromJson({'id': 'market', 'readme': 'loaded'});
|
||||
container.invalidate(provider);
|
||||
expect((await container.read(provider.future)).readme, 'loaded');
|
||||
expect(site.detailCalls, 4);
|
||||
});
|
||||
|
||||
test('details automatically recover on the third attempt and retain the result', () async {
|
||||
final site = _SiteApi();
|
||||
site.onDetail = (id, _) async {
|
||||
if (site.detailCalls < 3) throw StateError('offline');
|
||||
return StoreExtension.fromJson({'id': id, 'readme': 'recovered'});
|
||||
};
|
||||
final container = ProviderContainer(overrides: [gopeedSiteApiProvider.overrideWithValue(site)]);
|
||||
addTearDown(container.dispose);
|
||||
final provider = storeExtensionDetailsProvider((id: 'market', version: '1.0.0'));
|
||||
final subscription = container.listen(provider, (_, _) {});
|
||||
expect((await container.read(provider.future)).readme, 'recovered');
|
||||
expect(site.detailCalls, 3);
|
||||
subscription.close();
|
||||
await _settle();
|
||||
expect((await container.read(provider.future)).readme, 'recovered');
|
||||
expect(site.detailCalls, 3);
|
||||
});
|
||||
|
||||
test('closing details stops pending automatic retries', () async {
|
||||
final site = _SiteApi()..onDetail = (_, _) => Future.error(StateError('offline'));
|
||||
final container = ProviderContainer(overrides: [gopeedSiteApiProvider.overrideWithValue(site)]);
|
||||
final provider = storeExtensionDetailsProvider((id: 'market', version: '1.0.0'));
|
||||
container.listen(provider, (_, _) {});
|
||||
await _settle();
|
||||
expect(site.detailCalls, 1);
|
||||
container.dispose();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 350));
|
||||
expect(site.detailCalls, 1);
|
||||
});
|
||||
}
|
||||
|
||||
ProviderContainer _container(CapabilityRegistry registry, _SiteApi site) {
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry))),
|
||||
gopeedSiteApiProvider.overrideWithValue(site),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
return container;
|
||||
}
|
||||
|
||||
Future<void> _settle() async {
|
||||
for (var i = 0; i < 5; i++) {
|
||||
await Future<void>.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<StoreExtensionPage> Function(String?) onList = (_) async => _page('market');
|
||||
Future<StoreExtension> Function(String, String?) onDetail = (id, _) async =>
|
||||
StoreExtension.fromJson({'id': id, 'readme': null});
|
||||
|
||||
@override
|
||||
Future<StoreExtensionPage> 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<StoreExtension> getExtension(String id, {String? version}) {
|
||||
detailCalls++;
|
||||
return onDetail(id, version);
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart' show Icons;
|
||||
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/extension.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 attempts = List.generate(3, (_) => Completer<StoreExtension>());
|
||||
final site = _DetailApi();
|
||||
site.onRequest = () => attempts[site.detailCalls - 1].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);
|
||||
expect(site.detailCalls, 1);
|
||||
for (var attempt = 0; attempt < 2; attempt++) {
|
||||
attempts[attempt].completeError(StateError('offline'));
|
||||
await tester.pump();
|
||||
expect(find.byKey(const ValueKey('extension-details-loading')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('extension-details-retry')), findsNothing);
|
||||
await tester.pump(Duration(milliseconds: 300 * (attempt + 1)));
|
||||
expect(site.detailCalls, attempt + 2);
|
||||
}
|
||||
attempts.last.completeError(StateError('offline'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(find.byKey(const ValueKey('extension-details-retry')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('extension-details-loading')), findsNothing);
|
||||
expect(find.byIcon(Icons.refresh_rounded), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('extension-details-error-illustration')), findsOneWidget);
|
||||
expect(find.text('Unable to load extension details'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('extension-details-install')), findsOneWidget);
|
||||
await tester.pump(const Duration(seconds: 5));
|
||||
expect(site.detailCalls, 3);
|
||||
final reload = Completer<StoreExtension>();
|
||||
site.onRequest = () => reload.future;
|
||||
await tester.tap(find.byKey(const ValueKey('extension-details-retry')));
|
||||
await tester.pump();
|
||||
expect(site.detailCalls, 4);
|
||||
expect(find.byKey(const ValueKey('extension-details-loading')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('extension-details-retry')), findsNothing);
|
||||
reload.complete(
|
||||
StoreExtension.fromJson({
|
||||
'id': 'market',
|
||||
'title': 'Market',
|
||||
'version': '1.0.0',
|
||||
'readme': '# Loaded details',
|
||||
'homepage': 'https://example.com',
|
||||
}),
|
||||
);
|
||||
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();
|
||||
});
|
||||
|
||||
testWidgets('installed summary uses local details without a remote request at width $width', (tester) async {
|
||||
tester.view.physicalSize = Size(width, 844);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final directory = Directory.systemTemp.createTempSync('gopeed-installed-detail-');
|
||||
addTearDown(() => directory.deleteSync(recursive: true));
|
||||
File('${directory.path}/README.md').writeAsStringSync('# Installed documentation');
|
||||
final installed = Extension(
|
||||
identity: 'market',
|
||||
name: 'market',
|
||||
author: 'Local author',
|
||||
title: 'Local extension',
|
||||
description: 'Installed manifest',
|
||||
icon: '',
|
||||
version: '1.0.0',
|
||||
homepage: 'https://example.com/local',
|
||||
repository: Repository(url: 'https://github.com/test/local', directory: ''),
|
||||
disabled: false,
|
||||
devMode: true,
|
||||
devPath: directory.path,
|
||||
);
|
||||
final site = _DetailApi()..request = Completer<StoreExtension>().future;
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
extensionsControllerProvider.overrideWith(() => _InstalledSummaryController(installed)),
|
||||
gopeedSiteApiProvider.overrideWithValue(site),
|
||||
],
|
||||
child: shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: shad.Scaffold(
|
||||
child: ExtensionDetailView(
|
||||
item: ExtensionListItem(installed: installed, store: _summary()),
|
||||
mobile: width < 600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(site.detailCalls, 0);
|
||||
expect(find.byKey(const ValueKey('extension-details-loading')), findsNothing);
|
||||
expect(find.byKey(const ValueKey('extension-details-retry')), findsNothing);
|
||||
expect(find.text('Installed documentation'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('extension-details-homepage')), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
StoreExtension _summary() => StoreExtension.fromJson({
|
||||
'id': 'market',
|
||||
'title': 'Market',
|
||||
'version': '1.0.0',
|
||||
'repoUrl': 'https://github.com/test/repo',
|
||||
});
|
||||
|
||||
class _SummaryController extends ExtensionsController {
|
||||
@override
|
||||
Future<ExtensionsState> build() async => ExtensionsState(storeExtensions: [_summary()]);
|
||||
}
|
||||
|
||||
class _DetailApi implements GopeedSiteApi {
|
||||
late Future<StoreExtension> request;
|
||||
Future<StoreExtension> Function()? onRequest;
|
||||
int detailCalls = 0;
|
||||
|
||||
@override
|
||||
Future<StoreExtension> getExtension(String id, {String? version}) {
|
||||
detailCalls++;
|
||||
return onRequest?.call() ?? request;
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _InstalledSummaryController extends ExtensionsController {
|
||||
_InstalledSummaryController(this.installed);
|
||||
|
||||
final Extension installed;
|
||||
|
||||
@override
|
||||
Future<ExtensionsState> build() async =>
|
||||
ExtensionsState(installedExtensions: [installed], storeExtensions: [_summary()]);
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/features/extensions/application/extensions_controller.dart';
|
||||
import 'package:gopeed/features/extensions/presentation/pages/extensions_page.dart';
|
||||
import 'package:gopeed/shared/theme/app_theme.dart';
|
||||
import 'package:gopeed/shared/widgets/app_empty_state.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
void main() {
|
||||
for (final width in [390.0, 768.0, 1024.0]) {
|
||||
testWidgets('empty extension market uses the shared state at width $width', (tester) async {
|
||||
await _pumpPage(tester, width, const ExtensionsState());
|
||||
expect(find.byType(AppEmptyState), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('extensions-empty-illustration')), findsOneWidget);
|
||||
expect(find.text('No extensions'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('extensions-initial-skeleton')), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('empty installed list matches the market and can switch tabs at width $width', (tester) async {
|
||||
await _pumpPage(tester, width, const ExtensionsState(listFilter: ExtensionListFilter.installed));
|
||||
expect(find.text('No extensions'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('extensions-empty-browse')), findsNothing);
|
||||
await tester.tap(find.text('Market'));
|
||||
await tester.pumpAndSettle();
|
||||
final container = ProviderScope.containerOf(tester.element(find.byType(ExtensionsPage)));
|
||||
expect(container.read(extensionsControllerProvider).value!.listFilter, ExtensionListFilter.market);
|
||||
expect(find.text('No extensions'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('empty search explains no matches at width $width', (tester) async {
|
||||
await _pumpPage(tester, width, const ExtensionsState(storeQuery: 'no-match'));
|
||||
expect(find.byKey(const ValueKey('extensions-empty-illustration')), findsOneWidget);
|
||||
expect(find.text('No matching extensions found'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('extension load failure can retry from the shared state at width $width', (tester) async {
|
||||
final controller = _FailedExtensionsController();
|
||||
await _pumpPage(tester, width, const ExtensionsState(), controller: controller);
|
||||
expect(find.byKey(const ValueKey('extensions-error-illustration')), findsOneWidget);
|
||||
expect(find.text('Unable to load extensions'), findsOneWidget);
|
||||
expect(find.textContaining('test-network-error'), findsNothing);
|
||||
await tester.tap(find.byKey(const ValueKey('extensions-error-retry')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(controller.retryCalls, 1);
|
||||
expect(find.byKey(const ValueKey('extensions-error-state')), findsNothing);
|
||||
expect(find.text('No extensions'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pumpPage(
|
||||
WidgetTester tester,
|
||||
double width,
|
||||
ExtensionsState initial, {
|
||||
ExtensionsController? controller,
|
||||
}) async {
|
||||
tester.view.physicalSize = Size(width, 768);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [extensionsControllerProvider.overrideWith(() => controller ?? _EmptyExtensionsController(initial))],
|
||||
child: shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: const ExtensionsPage(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
class _EmptyExtensionsController extends ExtensionsController {
|
||||
_EmptyExtensionsController(this.initial);
|
||||
|
||||
final ExtensionsState initial;
|
||||
|
||||
@override
|
||||
Future<ExtensionsState> build() async => initial;
|
||||
}
|
||||
|
||||
class _FailedExtensionsController extends ExtensionsController {
|
||||
int retryCalls = 0;
|
||||
|
||||
@override
|
||||
Future<ExtensionsState> build() async => throw StateError('test-network-error');
|
||||
|
||||
@override
|
||||
Future<void> loadInitialData() async {
|
||||
retryCalls++;
|
||||
state = const AsyncValue.data(ExtensionsState());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user