perf(extensions): load store summaries and check updates asynchronously (#1583)

This commit is contained in:
Levi authored and GitHub committed 2026-10-14 20:08:41 +08:00
1 parent b86c2c18e7
commit 12860bb207
41 files changed
+1567 -257

No files matched your search

@@ -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()]);
}
@@ -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());
}
}