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,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<Response<String>>();
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<String> _response(Map<String, dynamic> json) => Response(
requestOptions: RequestOptions(path: '/test'),
data: jsonEncode(json),
statusCode: 200,
);
class _Transport implements GopeedTransport {
final urls = <String>[];
Future<Response<String>> Function(String) onProxy = (_) async => _response({'data': []});
@override
Future<Response<String>> proxyRequest(String uri, {dynamic data, Options? options}) {
urls.add(uri);
return onProxy(uri);
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
@@ -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);
}
@@ -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());
}
}
@@ -0,0 +1,141 @@
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart' as material show AdaptiveTextSelectionToolbar, Icons;
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/shared/theme/app_component_themes.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:gopeed/shared/widgets/app_search_field.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
void main() {
for (final direction in TextDirection.values) {
for (final width in [240.0, 480.0]) {
testWidgets(
'scrolling and selecting long search text preserves the icon ($direction, $width)',
(tester) async {
final controller = TextEditingController();
final focus = FocusNode();
addTearDown(controller.dispose);
addTearDown(focus.dispose);
await _pumpField(tester, controller, focus, width: width, direction: direction);
focus.requestFocus();
await tester.pumpAndSettle();
final iconBefore = await _iconPixels(tester);
final iconRect = tester.getRect(find.byIcon(material.Icons.search_rounded));
final text = List.filled(
20,
direction == TextDirection.ltr ? 'long-search-text-' : 'نص البحث الطويل ',
).join();
controller.value = TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: text.length),
);
await tester.pumpAndSettle();
final editable = tester.state<EditableTextState>(find.byType(EditableText));
expect(editable.renderEditable.maxScrollExtent, greaterThan(0));
editable.renderEditable.offset.jumpTo(editable.renderEditable.maxScrollExtent);
await tester.pumpAndSettle();
expect(editable.renderEditable.offset.pixels, greaterThan(0));
expect(tester.getRect(find.byIcon(material.Icons.search_rounded)), iconRect);
expect(await _iconPixels(tester), orderedEquals(iconBefore));
controller.selection = TextSelection(baseOffset: 0, extentOffset: text.length);
await tester.pumpAndSettle();
expect(await _iconPixels(tester), orderedEquals(iconBefore));
expect(tester.takeException(), isNull);
},
variant: TargetPlatformVariant({TargetPlatform.android, TargetPlatform.macOS}),
);
}
}
testWidgets(
'search text keeps Android native selection actions',
(tester) async {
final controller = TextEditingController(text: 'search selection');
final focus = FocusNode();
addTearDown(controller.dispose);
addTearDown(focus.dispose);
await _pumpField(tester, controller, focus);
await tester.longPress(find.byType(EditableText));
await tester.pumpAndSettle();
expect(find.byType(material.AdaptiveTextSelectionToolbar), findsOneWidget);
expect(find.byType(shad.MobileEditableTextContextMenu), findsNothing);
},
skip: kIsWeb,
variant: TargetPlatformVariant({TargetPlatform.android}),
);
testWidgets(
'search text keeps desktop native selection actions',
(tester) async {
final controller = TextEditingController(text: 'search selection');
final focus = FocusNode();
addTearDown(controller.dispose);
addTearDown(focus.dispose);
await _pumpField(tester, controller, focus);
final gesture = await tester.startGesture(
tester.getCenter(find.byType(EditableText)),
kind: PointerDeviceKind.mouse,
buttons: kSecondaryMouseButton,
);
await gesture.up();
await tester.pumpAndSettle();
expect(find.byType(material.AdaptiveTextSelectionToolbar), findsOneWidget);
expect(find.byType(shad.DesktopEditableTextContextMenu), findsNothing);
},
skip: kIsWeb,
variant: TargetPlatformVariant({TargetPlatform.macOS}),
);
}
Future<void> _pumpField(
WidgetTester tester,
TextEditingController controller,
FocusNode focus, {
double width = 320,
TextDirection direction = TextDirection.ltr,
}) async {
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: AppComponentThemes(
child: Directionality(
textDirection: direction,
child: Center(
child: RepaintBoundary(
key: const ValueKey('search-paint-boundary'),
child: SizedBox(
width: width,
child: AppSearchField(controller: controller, focusNode: focus, placeholder: const Text('Search')),
),
),
),
),
),
),
);
await tester.pumpAndSettle();
}
Future<List<int>> _iconPixels(WidgetTester tester) async {
final boundaryFinder = find.byKey(const ValueKey('search-paint-boundary'));
final boundary = tester.renderObject<RenderRepaintBoundary>(boundaryFinder);
final rect = tester.getRect(find.byIcon(material.Icons.search_rounded)).shift(-tester.getTopLeft(boundaryFinder));
final snapshot = await tester.runAsync(() async {
final image = await boundary.toImage(pixelRatio: 1);
final width = image.width;
final bytes = (await image.toByteData(format: ui.ImageByteFormat.rawRgba))!.buffer.asUint8List();
image.dispose();
return (width: width, pixels: Uint8List.fromList(bytes));
});
return [
for (var y = rect.top.floor(); y < rect.bottom.ceil(); y++)
for (var x = rect.left.floor(); x < rect.right.ceil(); x++)
...snapshot!.pixels.sublist((y * snapshot.width + x) * 4, (y * snapshot.width + x) * 4 + 4),
];
}