mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-14 04:13:43 +00:00
Merge remote-tracking branch 'origin/main' into codex/fix-bt-file-selection
# Conflicts: # ui/flutter/test/features/tasks/domain/task_record_test.dart
This commit is contained in:
commit
a83e3a2b84
272 files changed
+17212
-1065
No files matched your search
@@ -0,0 +1,139 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/api/model/downloader_config.dart';
|
||||
import 'package:gopeed/app/application/app_notification_controller.dart';
|
||||
import 'package:gopeed/app/application/app_runtime_controller.dart';
|
||||
import 'package:gopeed/core/common/api_server_state.dart';
|
||||
import 'package:gopeed/core/common/start_config.dart';
|
||||
import 'package:gopeed/core/common/task_event.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
const channel = MethodChannel('dexterous.com/flutter/local_notifications');
|
||||
final calls = <MethodCall>[];
|
||||
var permissionGranted = true;
|
||||
late StreamController<TaskEvent> events;
|
||||
|
||||
setUp(() {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.macOS;
|
||||
MacOSFlutterLocalNotificationsPlugin.registerWith();
|
||||
calls.clear();
|
||||
permissionGranted = true;
|
||||
events = StreamController<TaskEvent>.broadcast();
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async {
|
||||
calls.add(call);
|
||||
return permissionGranted;
|
||||
});
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
unawaited(events.close());
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, null);
|
||||
});
|
||||
|
||||
Future<ProviderContainer> start({required bool enabled}) async {
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
appRuntimeControllerProvider.overrideWith(() => _TestRuntimeController(enabled)),
|
||||
appNotificationControllerProvider.overrideWith(() => _TestNotificationController(events.stream)),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
await container.read(appRuntimeControllerProvider.future);
|
||||
await container.read(appNotificationControllerProvider.future);
|
||||
return container;
|
||||
}
|
||||
|
||||
Map<dynamic, dynamic> initialization() => calls.lastWhere((call) => call.method == 'initialize').arguments as Map;
|
||||
|
||||
test('macOS requests alerts and sound when download notifications are enabled at startup', () async {
|
||||
await start(enabled: true);
|
||||
|
||||
expect(initialization()['requestAlertPermission'], isTrue);
|
||||
expect(initialization()['requestSoundPermission'], isTrue);
|
||||
expect(initialization()['requestBadgePermission'], isFalse);
|
||||
});
|
||||
|
||||
test('disabled notifications defer authorization until the setting is enabled', () async {
|
||||
final container = await start(enabled: false);
|
||||
expect(initialization()['requestAlertPermission'], isFalse);
|
||||
expect(initialization()['requestSoundPermission'], isFalse);
|
||||
|
||||
final runtime = container.read(appRuntimeControllerProvider.notifier) as _TestRuntimeController;
|
||||
runtime.setNotificationsEnabled(true);
|
||||
await container.pump();
|
||||
await container.read(appNotificationControllerProvider.future);
|
||||
|
||||
expect(initialization()['requestAlertPermission'], isTrue);
|
||||
expect(initialization()['requestSoundPermission'], isTrue);
|
||||
|
||||
runtime.setNotificationsEnabled(false);
|
||||
await container.pump();
|
||||
await container.read(appNotificationControllerProvider.future);
|
||||
expect(initialization()['requestAlertPermission'], isFalse);
|
||||
expect(initialization()['requestSoundPermission'], isFalse);
|
||||
});
|
||||
|
||||
test('denied macOS authorization does not fail notification controller startup', () async {
|
||||
permissionGranted = false;
|
||||
final container = await start(enabled: true);
|
||||
|
||||
expect(container.read(appNotificationControllerProvider).requireValue.started, isTrue);
|
||||
});
|
||||
|
||||
test('terminal task events notify once after enabling and stop after disabling', () async {
|
||||
final container = await start(enabled: false);
|
||||
final runtime = container.read(appRuntimeControllerProvider.notifier) as _TestRuntimeController;
|
||||
const done = TaskEvent(type: TaskEventType.done, taskId: '1', name: 'archive.zip');
|
||||
events.add(done);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(calls.where((call) => call.method == 'show'), isEmpty);
|
||||
|
||||
runtime.setNotificationsEnabled(true);
|
||||
await container.pump();
|
||||
await container.read(appNotificationControllerProvider.future);
|
||||
events.add(done);
|
||||
events.add(const TaskEvent(type: TaskEventType.error, taskId: '2', name: 'failed.zip'));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
final notifications = calls.where((call) => call.method == 'show').toList();
|
||||
expect(notifications, hasLength(2));
|
||||
expect(notifications.map((call) => (call.arguments as Map)['body']), ['archive.zip', 'failed.zip']);
|
||||
|
||||
runtime.setNotificationsEnabled(false);
|
||||
await container.pump();
|
||||
await container.read(appNotificationControllerProvider.future);
|
||||
events.add(done);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(calls.where((call) => call.method == 'show'), hasLength(2));
|
||||
});
|
||||
}
|
||||
|
||||
class _TestNotificationController extends AppNotificationController {
|
||||
_TestNotificationController(this.taskEvents);
|
||||
|
||||
@override
|
||||
final Stream<TaskEvent> taskEvents;
|
||||
}
|
||||
|
||||
class _TestRuntimeController extends AppRuntimeController {
|
||||
_TestRuntimeController(this.enabled);
|
||||
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
Future<AppRuntimeState> build() async => _runtime(enabled);
|
||||
|
||||
void setNotificationsEnabled(bool enabled) => state = AsyncData(_runtime(enabled));
|
||||
|
||||
AppRuntimeState _runtime(bool enabled) => AppRuntimeState(
|
||||
startConfig: StartConfig(),
|
||||
apiServerState: ApiServerState.fromJson({}),
|
||||
downloaderConfig: DownloaderConfig()..extra.desktopNotification = enabled,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/api/model/downloader_config.dart';
|
||||
import 'package:gopeed/app/application/app_platform_controller.dart';
|
||||
import 'package:gopeed/app/application/app_runtime_controller.dart';
|
||||
import 'package:gopeed/core/common/api_server_state.dart';
|
||||
import 'package:gopeed/core/common/start_config.dart';
|
||||
import 'package:gopeed/util/updater.dart';
|
||||
|
||||
void main() {
|
||||
test('runtime updates do not rebuild the platform controller', () async {
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
appRuntimeControllerProvider.overrideWith(_TestRuntimeController.new),
|
||||
appPlatformControllerProvider.overrideWith(_TestPlatformController.new),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
final subscription = container.listen(appPlatformControllerProvider, (_, _) {}, fireImmediately: true);
|
||||
addTearDown(subscription.close);
|
||||
|
||||
await container.read(appPlatformControllerProvider.future);
|
||||
final platformController = container.read(appPlatformControllerProvider.notifier) as _TestPlatformController;
|
||||
final runtimeController = container.read(appRuntimeControllerProvider.notifier) as _TestRuntimeController;
|
||||
|
||||
expect(platformController.buildCount, 1);
|
||||
|
||||
runtimeController.completeRuntime();
|
||||
await container.read(appRuntimeControllerProvider.future);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(platformController.updateCheckCount, 1);
|
||||
|
||||
final config = DownloaderConfig.fromJson(runtimeController.state.requireValue.downloaderConfig.toJson());
|
||||
config.extra.locale = 'zh';
|
||||
runtimeController.replaceDownloaderConfig(config);
|
||||
await container.pump();
|
||||
|
||||
expect(platformController.buildCount, 1);
|
||||
});
|
||||
}
|
||||
|
||||
class _TestPlatformController extends AppPlatformController {
|
||||
int buildCount = 0;
|
||||
int updateCheckCount = 0;
|
||||
|
||||
@override
|
||||
bool get isDesktop => false;
|
||||
|
||||
@override
|
||||
Future<AppPlatformState> build() {
|
||||
buildCount++;
|
||||
return super.build();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<VersionInfo?> checkForUpdate() async {
|
||||
updateCheckCount++;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class _TestRuntimeController extends AppRuntimeController {
|
||||
final _pendingRuntime = Completer<AppRuntimeState>();
|
||||
|
||||
@override
|
||||
Future<AppRuntimeState> build() => _pendingRuntime.future;
|
||||
|
||||
void completeRuntime() {
|
||||
_pendingRuntime.complete(
|
||||
AppRuntimeState(
|
||||
startConfig: StartConfig(),
|
||||
apiServerState: const ApiServerState(
|
||||
enabled: false,
|
||||
mcpEnabled: false,
|
||||
running: false,
|
||||
network: '',
|
||||
address: '',
|
||||
runningPort: 0,
|
||||
pendingApply: false,
|
||||
lastError: '',
|
||||
),
|
||||
downloaderConfig: DownloaderConfig(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:gopeed/app/router/app_router.dart';
|
||||
import 'package:gopeed/features/auth/application/web_auth_controller.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
for (final platform in TargetPlatform.values) {
|
||||
test('$platform preserves the expected section route stacks', () {
|
||||
debugDefaultTargetPlatformOverride = platform;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
final auth = WebAuthController();
|
||||
final router = AppRouter.build(auth);
|
||||
addTearDown(router.dispose);
|
||||
addTearDown(auth.dispose);
|
||||
final isMobile = platform == TargetPlatform.android || platform == TargetPlatform.iOS;
|
||||
|
||||
for (final entry in {
|
||||
'/': ['/'],
|
||||
'/extensions': isMobile ? ['/', 'extensions'] : ['/extensions'],
|
||||
'/extensions/example': isMobile ? ['/', 'extensions', ':id'] : ['/extensions', ':id'],
|
||||
'/settings': isMobile ? ['/', 'settings'] : ['/settings'],
|
||||
'/settings/general': isMobile ? ['/', 'settings', ':section'] : ['/settings', ':section'],
|
||||
'/tasks/example': ['/', 'tasks/:id'],
|
||||
'/create': ['/', 'create'],
|
||||
}.entries) {
|
||||
final match = router.configuration.findMatch(Uri.parse(entry.key));
|
||||
expect(match.isError, isFalse);
|
||||
final shell = match.matches.single as ShellRouteMatch;
|
||||
expect(shell.matches.map((match) => (match.route as GoRoute).path), entry.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/app/application/app_runtime_controller.dart';
|
||||
import 'package:gopeed/app/router/app_router.dart';
|
||||
import 'package:gopeed/features/auth/application/web_auth_controller.dart';
|
||||
import 'package:gopeed/shared/theme/app_theme.dart';
|
||||
import 'package:gopeed/shared/widgets/app_toast.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
class _StartingRuntime extends AppRuntimeController {
|
||||
final pending = Completer<AppRuntimeState>();
|
||||
|
||||
@override
|
||||
Future<AppRuntimeState> build() => pending.future;
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'cold startup registers Android back and confirms exit before runtime is ready',
|
||||
(tester) async {
|
||||
final platformCalls = <MethodCall>[];
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async {
|
||||
platformCalls.add(call);
|
||||
return null;
|
||||
});
|
||||
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null));
|
||||
final runtime = _StartingRuntime();
|
||||
final auth = WebAuthController();
|
||||
final router = AppRouter.build(auth);
|
||||
addTearDown(router.dispose);
|
||||
addTearDown(auth.dispose);
|
||||
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [appRuntimeControllerProvider.overrideWith(() => runtime)],
|
||||
child: shad.ShadcnApp.router(
|
||||
theme: AppTheme.dark(),
|
||||
materialTheme: AppTheme.materialDark(),
|
||||
routerConfig: router,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(find.byType(shad.CircularProgressIndicator), findsOneWidget);
|
||||
expect(
|
||||
platformCalls.where((call) => call.method == 'SystemNavigator.setFrameworkHandlesBack').last.arguments,
|
||||
isTrue,
|
||||
);
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pump();
|
||||
expect(platformCalls.where((call) => call.method == 'SystemNavigator.pop'), isEmpty);
|
||||
expect(find.byType(AppToastContent), findsOneWidget);
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pump();
|
||||
expect(platformCalls.where((call) => call.method == 'SystemNavigator.pop'), hasLength(1));
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
},
|
||||
variant: TargetPlatformVariant.only(TargetPlatform.android),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:gopeed/app/router/mobile_exit_guard.dart';
|
||||
import 'package:gopeed/shared/navigation/app_exit_confirmation_controller.dart';
|
||||
import 'package:gopeed/shared/theme/app_theme.dart';
|
||||
import 'package:gopeed/shared/widgets/app_toast.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
void main() {
|
||||
late List<MethodCall> exits;
|
||||
late GoRouter router;
|
||||
|
||||
setUp(() {
|
||||
exits = [];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
(call) async {
|
||||
if (call.method == 'SystemNavigator.pop') exits.add(call);
|
||||
return null;
|
||||
},
|
||||
);
|
||||
router = GoRouter(
|
||||
routes: [
|
||||
ShellRoute(
|
||||
builder: (_, _, child) => MobileExitGuard(child: child),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
builder: (_, _) => const SizedBox.expand(),
|
||||
routes: [
|
||||
GoRoute(path: 'details', builder: (_, _) => const SizedBox.expand()),
|
||||
for (final path in ['extensions', 'settings'])
|
||||
GoRoute(path: path, builder: (_, _) => const SizedBox.expand()),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
router.dispose();
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
Future<void> mount(WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp.router(theme: AppTheme.dark(), materialTheme: AppTheme.materialDark(), routerConfig: router),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('root back shows bottom prompt, second back exits', (tester) async {
|
||||
await mount(tester);
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pump();
|
||||
expect(exits, isEmpty);
|
||||
expect(find.byType(AppToastContent), findsOneWidget);
|
||||
expect(tester.getCenter(find.byType(AppToastContent)).dx, 400);
|
||||
expect(tester.getBottomLeft(find.byType(AppToastContent)).dy, 576);
|
||||
await tester.pump(const Duration(milliseconds: 1900));
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pump();
|
||||
expect(exits, hasLength(1));
|
||||
expect(find.byType(AppToastContent), findsNothing);
|
||||
}, variant: TargetPlatformVariant.only(TargetPlatform.android));
|
||||
|
||||
testWidgets('expired prompt requires a fresh pair of back actions', (tester) async {
|
||||
await mount(tester);
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pump();
|
||||
await tester.pump(AppExitConfirmationController.confirmationWindow);
|
||||
expect(find.byType(AppToastContent), findsNothing);
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pump();
|
||||
expect(exits, isEmpty);
|
||||
expect(find.byType(AppToastContent), findsOneWidget);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
}, variant: TargetPlatformVariant.only(TargetPlatform.android));
|
||||
|
||||
testWidgets('nested page pops without prompting or exiting', (tester) async {
|
||||
await mount(tester);
|
||||
router.push('/details');
|
||||
await tester.pumpAndSettle();
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pumpAndSettle();
|
||||
expect(router.routeInformationProvider.value.uri.path, '/');
|
||||
expect(find.byType(AppToastContent), findsNothing);
|
||||
expect(exits, isEmpty);
|
||||
}, variant: TargetPlatformVariant.only(TargetPlatform.android));
|
||||
testWidgets('backgrounding clears the exit confirmation', (tester) async {
|
||||
await mount(tester);
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pump();
|
||||
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
|
||||
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
||||
await tester.pump();
|
||||
expect(find.byType(AppToastContent), findsNothing);
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pump();
|
||||
expect(exits, isEmpty);
|
||||
expect(find.byType(AppToastContent), findsOneWidget);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
}, variant: TargetPlatformVariant.only(TargetPlatform.android));
|
||||
for (final path in ['/extensions', '/settings']) {
|
||||
testWidgets('$path returns to tasks before confirming exit', (tester) async {
|
||||
await mount(tester);
|
||||
router.go(path);
|
||||
await tester.pumpAndSettle();
|
||||
expect(router.canPop(), isTrue);
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pumpAndSettle();
|
||||
expect(router.routeInformationProvider.value.uri.path, '/');
|
||||
expect(find.byType(AppToastContent), findsNothing);
|
||||
expect(exits, isEmpty);
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pump();
|
||||
expect(find.byType(AppToastContent), findsOneWidget);
|
||||
expect(exits, isEmpty);
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pump();
|
||||
expect(exits, hasLength(1));
|
||||
}, variant: TargetPlatformVariant.only(TargetPlatform.android));
|
||||
}
|
||||
testWidgets(
|
||||
'dialog back dismisses the dialog before exit confirmation',
|
||||
(tester) async {
|
||||
await mount(tester);
|
||||
final context = router.routerDelegate.navigatorKey.currentContext!;
|
||||
showGeneralDialog<void>(
|
||||
context: context,
|
||||
pageBuilder: (_, _, _) => const Center(child: Text('Dialog content')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Dialog content'), findsNothing);
|
||||
expect(find.byType(AppToastContent), findsNothing);
|
||||
expect(exits, isEmpty);
|
||||
},
|
||||
variant: TargetPlatformVariant.only(TargetPlatform.android),
|
||||
);
|
||||
|
||||
testWidgets('bottom prompt stays above the Android safe area', (tester) async {
|
||||
tester.view.padding = const FakeViewPadding(bottom: 48);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPadding);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await mount(tester);
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pump();
|
||||
expect(tester.getBottomLeft(find.byType(AppToastContent)).dy, tester.view.physicalSize.height - 48 - 24);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
}, variant: TargetPlatformVariant.only(TargetPlatform.android));
|
||||
}
|
||||
@@ -1,8 +1,58 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:gopeed/features/extensions/application/pending_extension_install.dart';
|
||||
|
||||
import 'package:app_links_platform_interface/app_links_platform_interface.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/api/model/downloader_config.dart';
|
||||
import 'package:gopeed/app/application/app_runtime_controller.dart';
|
||||
import 'package:gopeed/app/application/app_deep_link_controller.dart';
|
||||
import 'package:gopeed/core/common/api_server_state.dart';
|
||||
import 'package:gopeed/core/common/start_config.dart';
|
||||
import 'package:share_handler/share_handler.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('receives initial and subsequent scheme extension URLs with UTF-8 params', () async {
|
||||
final originalPlatform = AppLinksPlatform.instance;
|
||||
final initialUrl = 'https://github.com/author/扩展';
|
||||
Uri link(String url) => Uri.parse('gopeed:///extension').replace(
|
||||
queryParameters: {
|
||||
'params': base64Encode(utf8.encode(jsonEncode({'url': url}))),
|
||||
},
|
||||
);
|
||||
final platform = _FakeAppLinksPlatform(initialLink: link(initialUrl));
|
||||
AppLinksPlatform.instance = platform;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
const MethodChannel('window_manager'),
|
||||
(_) async => false,
|
||||
);
|
||||
final container = ProviderContainer(
|
||||
overrides: [appRuntimeControllerProvider.overrideWith(_FakeRuntimeController.new)],
|
||||
);
|
||||
try {
|
||||
await container.read(appDeepLinkControllerProvider.future);
|
||||
expect(container.read(pendingExtensionInstallProvider)?.url, initialUrl);
|
||||
container.read(pendingExtensionInstallProvider.notifier).clear();
|
||||
platform.emit(link('https://github.com/author/second'));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(container.read(pendingExtensionInstallProvider)?.url, 'https://github.com/author/second');
|
||||
} finally {
|
||||
container.dispose();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
AppLinksPlatform.instance = originalPlatform;
|
||||
await platform.close();
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
const MethodChannel('window_manager'),
|
||||
null,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps the established path-style Gopeed deep-link routes', () {
|
||||
expect(gopeedDeepLinkRoute(Uri.parse('gopeed:///create')), '/create');
|
||||
expect(gopeedDeepLinkRoute(Uri.parse('gopeed:///extension')), '/extension');
|
||||
@@ -27,4 +77,75 @@ void main() {
|
||||
|
||||
expect(sharedMediaUri(media), Uri.parse('magnet:?xt=urn:btih:example'));
|
||||
});
|
||||
|
||||
test('keeps listening for browser takeover links after runtime settings change', () async {
|
||||
final originalPlatform = AppLinksPlatform.instance;
|
||||
final appLinksPlatform = _FakeAppLinksPlatform();
|
||||
AppLinksPlatform.instance = appLinksPlatform;
|
||||
final container = ProviderContainer(
|
||||
overrides: [appRuntimeControllerProvider.overrideWith(_FakeRuntimeController.new)],
|
||||
);
|
||||
|
||||
try {
|
||||
await container.read(appDeepLinkControllerProvider.future);
|
||||
expect(appLinksPlatform.hasListener, isTrue);
|
||||
|
||||
container.read(appRuntimeControllerProvider.notifier).replaceDownloaderConfig(DownloaderConfig());
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(appLinksPlatform.hasListener, isTrue);
|
||||
} finally {
|
||||
container.dispose();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
AppLinksPlatform.instance = originalPlatform;
|
||||
await appLinksPlatform.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeAppLinksPlatform extends AppLinksPlatform {
|
||||
_FakeAppLinksPlatform({this.initialLink});
|
||||
|
||||
final Uri? initialLink;
|
||||
void emit(Uri uri) => _links.add(uri);
|
||||
final _links = StreamController<Uri>.broadcast();
|
||||
|
||||
bool get hasListener => _links.hasListener;
|
||||
|
||||
@override
|
||||
Future<Uri?> getInitialLink() async => initialLink;
|
||||
|
||||
@override
|
||||
Stream<Uri> get uriLinkStream => _links.stream;
|
||||
|
||||
Future<void> close() => _links.close();
|
||||
}
|
||||
|
||||
class _FakeRuntimeController extends AppRuntimeController {
|
||||
@override
|
||||
Future<AppRuntimeState> build() async {
|
||||
final config = StartConfig()
|
||||
..network = 'tcp'
|
||||
..address = '127.0.0.1:9999'
|
||||
..apiEnable = true
|
||||
..apiToken = ''
|
||||
..storage = 'bolt'
|
||||
..storageDir = ''
|
||||
..refreshInterval = 0;
|
||||
return AppRuntimeState(
|
||||
startConfig: config,
|
||||
apiServerState: const ApiServerState(
|
||||
enabled: true,
|
||||
mcpEnabled: false,
|
||||
running: true,
|
||||
network: 'tcp',
|
||||
address: '127.0.0.1:9999',
|
||||
runningPort: 9999,
|
||||
pendingApply: false,
|
||||
lastError: '',
|
||||
),
|
||||
downloaderConfig: DownloaderConfig(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/core/common/libgopeed_channel.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
const channel = MethodChannel('gopeed.com/libgopeed');
|
||||
final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
|
||||
tearDown(() {
|
||||
messenger.setMockMethodCallHandler(channel, null);
|
||||
});
|
||||
|
||||
test('mobile invokes carry unique request IDs for async callbacks', () async {
|
||||
final requestIDs = <int>[];
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
expect(call.method, 'invoke');
|
||||
final arguments = (call.arguments as Map<Object?, Object?>).cast<String, Object?>();
|
||||
requestIDs.add(arguments['requestID']! as int);
|
||||
return '{"code":0}';
|
||||
});
|
||||
|
||||
final bridge = LibgopeedChannel();
|
||||
await Future.wait([bridge.invoke('GET', '/api/v1/info'), bridge.invoke('GET', '/api/v1/config')]);
|
||||
|
||||
expect(requestIDs, [0, 1]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/core/utils/compact_count_formatter.dart';
|
||||
|
||||
void main() {
|
||||
test('keeps small counts exact and compacts larger counts to a bounded label', () {
|
||||
expect(CompactCountFormatter.format(-1), '0');
|
||||
expect(CompactCountFormatter.format(0), '0');
|
||||
expect(CompactCountFormatter.format(9999), '9999');
|
||||
expect(CompactCountFormatter.format(10000), '1w');
|
||||
expect(CompactCountFormatter.format(12345), '1.2w');
|
||||
expect(CompactCountFormatter.format(99999), '9.9w');
|
||||
expect(CompactCountFormatter.format(123456), '12w');
|
||||
expect(CompactCountFormatter.format(1000000), '1m');
|
||||
expect(CompactCountFormatter.format(1234567), '1.2m');
|
||||
expect(CompactCountFormatter.format(10000000), '10m');
|
||||
expect(CompactCountFormatter.format(12345678), '12m');
|
||||
expect(CompactCountFormatter.format(100000000), '100m');
|
||||
expect(CompactCountFormatter.format(123456789), '123m');
|
||||
expect(CompactCountFormatter.format(1000000000), '1b');
|
||||
expect(CompactCountFormatter.format(1234567890), '1.2b');
|
||||
expect(CompactCountFormatter.format(12345678901), '12b');
|
||||
expect(CompactCountFormatter.format(1234567890123), '1.2t');
|
||||
expect(CompactCountFormatter.format(1234567890123456), '1.2q');
|
||||
expect(CompactCountFormatter.format(9223372036854775807), '9.2qi');
|
||||
});
|
||||
}
|
||||
@@ -9,9 +9,9 @@ void main() {
|
||||
test('create-task child window has a localized native title', () {
|
||||
expect(
|
||||
AppWindowBootstrap.subWindowTitle(AppWindowType.createTask, appLocalizationsFor('en')),
|
||||
'Create Task - Gopeed',
|
||||
'Gopeed-Create Task',
|
||||
);
|
||||
expect(AppWindowBootstrap.subWindowTitle(AppWindowType.createTask, appLocalizationsFor('zh')), '创建任务 - Gopeed');
|
||||
expect(AppWindowBootstrap.subWindowTitle(AppWindowType.createTask, appLocalizationsFor('zh')), 'Gopeed-创建任务');
|
||||
});
|
||||
|
||||
test('create-task payload only carries initial task data', () {
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:gopeed/api/model/extension.dart';
|
||||
import 'package:gopeed/features/extensions/presentation/widgets/extension_setting_field.dart';
|
||||
import 'package:gopeed/shared/widgets/app_text_field.dart';
|
||||
|
||||
void main() {
|
||||
Future<void> pumpField(WidgetTester tester, Setting setting, TextEditingController controller) async {
|
||||
await tester.pumpWidget(
|
||||
ShadcnApp(
|
||||
home: Scaffold(
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 280,
|
||||
child: ExtensionSettingField(setting: setting, controller: controller),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Setting setting(SettingType type) =>
|
||||
Setting(name: 'test', title: 'Test setting', description: 'Description', required: false, type: type);
|
||||
|
||||
testWidgets('boolean switch loads and updates the saved value', (tester) async {
|
||||
final controller = TextEditingController(text: 'true');
|
||||
addTearDown(controller.dispose);
|
||||
await pumpField(tester, setting(SettingType.boolean), controller);
|
||||
expect(find.byType(AppTextField), findsNothing);
|
||||
expect(tester.widget<Switch>(find.byType(Switch)).value, isTrue);
|
||||
await tester.tap(find.byType(Switch));
|
||||
await tester.pumpAndSettle();
|
||||
expect(controller.text, 'false');
|
||||
expect(tester.widget<Switch>(find.byType(Switch)).value, isFalse);
|
||||
});
|
||||
|
||||
for (final type in [SettingType.string, SettingType.number]) {
|
||||
testWidgets(
|
||||
'$type options show labels and save selected values',
|
||||
(tester) async {
|
||||
final field = setting(type)..options = [Option(label: 'First', value: 1), Option(label: 'Second', value: 2)];
|
||||
final controller = TextEditingController(text: '1');
|
||||
addTearDown(controller.dispose);
|
||||
await pumpField(tester, field, controller);
|
||||
expect(find.byType(AppTextField), findsNothing);
|
||||
expect(find.text('First'), findsOneWidget);
|
||||
await tester.tap(find.byType(Select<String>));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
await tester.tap(find.text('Second'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(controller.text, '2');
|
||||
expect(find.text('Second'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
variant: TargetPlatformVariant({TargetPlatform.android, TargetPlatform.macOS}),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('number field accepts decimals and rejects nonnumeric input', (tester) async {
|
||||
final controller = TextEditingController();
|
||||
addTearDown(controller.dispose);
|
||||
await pumpField(tester, setting(SettingType.number), controller);
|
||||
await tester.enterText(find.byType(AppTextField), '-12.5');
|
||||
expect(controller.text, '-12.5');
|
||||
await tester.enterText(find.byType(AppTextField), 'invalid');
|
||||
expect(controller.text, '-12.5');
|
||||
});
|
||||
|
||||
testWidgets('string with empty options remains a text field', (tester) async {
|
||||
final controller = TextEditingController(text: 'original');
|
||||
addTearDown(controller.dispose);
|
||||
await pumpField(tester, setting(SettingType.string)..options = [], controller);
|
||||
await tester.enterText(find.byType(AppTextField), 'updated');
|
||||
expect(controller.text, 'updated');
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/api/model/task.dart' as api;
|
||||
import 'package:gopeed/core/icons/gopeed_icons.dart';
|
||||
import 'package:gopeed/features/tasks/domain/task_record.dart';
|
||||
|
||||
void main() {
|
||||
@@ -41,4 +42,19 @@ void main() {
|
||||
expect(record.files.length, 3);
|
||||
expect(record.totalBytes, 60);
|
||||
});
|
||||
|
||||
test('file names preserve hash and question mark characters for type detection', () {
|
||||
expect(
|
||||
taskFileTypeIcon('Slow English Podcast #2 _ Eating, Talking, and Learning Natural English.mp4'),
|
||||
GopeedIcons.fileVideo,
|
||||
);
|
||||
expect(taskFileTypeIcon('What is this? #2.MP4'), GopeedIcons.fileVideo);
|
||||
expect(taskFileTypeIcon('Episode #2.mp3'), GopeedIcons.fileAudio);
|
||||
});
|
||||
|
||||
test('URL fallback ignores query and fragment when detecting file type', () {
|
||||
expect(taskFileTypeIcon('https://example.com/video.mp4?token=abc#part'), GopeedIcons.fileVideo);
|
||||
expect(taskFileTypeIcon('https://example.com/download?name=video.mp4'), GopeedIcons.file);
|
||||
expect(taskFileTypeIcon('Playlist #2.mp4', isFolder: true), GopeedIcons.folder);
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||
import 'package:flutter/widgets.dart' as flutter show Row;
|
||||
import 'package:flutter/foundation.dart' show TargetPlatform, debugDefaultTargetPlatformOverride;
|
||||
import 'package:flutter/material.dart' show Icons;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -8,19 +11,197 @@ import 'package:gopeed/api/model/create_task.dart';
|
||||
import 'package:gopeed/api/model/downloader_config.dart';
|
||||
import 'package:gopeed/api/model/options.dart';
|
||||
import 'package:gopeed/api/model/request.dart';
|
||||
import 'package:gopeed/api/model/resolve_result.dart';
|
||||
import 'package:gopeed/api/model/resource.dart';
|
||||
import 'package:gopeed/core/capabilities/app_capabilities.dart';
|
||||
import 'package:gopeed/core/capabilities/app_navigation_capability.dart';
|
||||
import 'package:gopeed/core/capabilities/capability_rpc.dart';
|
||||
import 'package:gopeed/core/capabilities/gopeed_capability.dart';
|
||||
import 'package:gopeed/core/capabilities/storage_capability.dart';
|
||||
import 'package:gopeed/features/tasks/presentation/pages/create_task_window_page.dart';
|
||||
import 'package:gopeed/features/tasks/application/pending_create_task.dart';
|
||||
import 'package:gopeed/shared/services/download_directory_picker.dart';
|
||||
import 'package:gopeed/shared/theme/app_component_themes.dart';
|
||||
import 'package:gopeed/shared/theme/app_design_tokens.dart';
|
||||
import 'package:gopeed/shared/theme/app_theme.dart';
|
||||
import 'package:gopeed/shared/widgets/app_loading_button.dart';
|
||||
import 'package:gopeed/shared/widgets/app_tooltip.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
void main() {
|
||||
for (final navigationFails in [false, true]) {
|
||||
testWidgets('child creation selects downloads before closing (navigation fails: $navigationFails)', (tester) async {
|
||||
tester.view.physicalSize = const Size(1024, 720);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final events = <String>[];
|
||||
const channel = MethodChannel('window_manager');
|
||||
final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
events.add(call.method);
|
||||
return null;
|
||||
});
|
||||
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
|
||||
final config = DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = true;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => config)
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit())
|
||||
..bind(GopeedMethods.createTask, (_) {
|
||||
events.add('created');
|
||||
return 'task-id';
|
||||
})
|
||||
..bind(NavigationMethods.showDownloadingTasks, (_) {
|
||||
events.add('downloads');
|
||||
if (navigationFails) throw StateError('Navigation unavailable');
|
||||
return const RpcUnit();
|
||||
});
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
|
||||
child: shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: AppComponentThemes(
|
||||
child: CreateTaskWindowPage(
|
||||
windowController: WindowController.fromWindowId('child'),
|
||||
initialTask: CreateTask(req: Request(url: 'https://example.com/file.apk')),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Confirm'));
|
||||
// The mocked native close deliberately leaves the widget mounted.
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(events, ['created', 'downloads', 'close']);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('repeated external tasks update the open create route and replace request metadata', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
try {
|
||||
tester.view.physicalSize = const Size(700, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
CreateTask? submitted;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
|
||||
..bind(GopeedMethods.createTask, (task) {
|
||||
submitted = task;
|
||||
return 'created-id';
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
final container = ProviderContainer(
|
||||
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
final pending = container.read(pendingCreateTaskProvider.notifier);
|
||||
pending.set(
|
||||
CreateTask(
|
||||
req: Request(
|
||||
url: 'https://example.com/first.zip',
|
||||
rawUrl: 'https://example.com/first',
|
||||
labels: {'source': 'first'},
|
||||
extra: ReqExtraHttp(method: 'POST', body: 'old-body', header: {'Cookie': 'old-cookie'}).toJson(),
|
||||
proxy: RequestProxy(mode: RequestProxyMode.custom, host: 'localhost:8080'),
|
||||
skipVerifyCert: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
final router = GoRouter(
|
||||
initialLocation: '/create',
|
||||
routes: [
|
||||
GoRoute(path: '/', builder: (_, _) => const SizedBox()),
|
||||
GoRoute(path: '/create', builder: (_, _) => const CreateTaskWindowPage()),
|
||||
],
|
||||
);
|
||||
addTearDown(router.dispose);
|
||||
await tester.pumpWidget(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: shad.ShadcnApp.router(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
routerConfig: router,
|
||||
builder: (_, child) => AppComponentThemes(child: child!),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(_fieldText(tester, 'create-task-url-input'), 'https://example.com/first.zip');
|
||||
expect(container.read(pendingCreateTaskProvider), isNull);
|
||||
final pageState = tester.state(find.byType(CreateTaskWindowPage));
|
||||
|
||||
for (final url in [
|
||||
'https://example.com/second.zip',
|
||||
'https://example.com/third.zip',
|
||||
'https://example.com/third.zip',
|
||||
]) {
|
||||
pending.set(CreateTask(req: Request(url: url)));
|
||||
router.go('/create');
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.state(find.byType(CreateTaskWindowPage)), same(pageState));
|
||||
expect(_fieldText(tester, 'create-task-url-input'), url);
|
||||
expect(container.read(pendingCreateTaskProvider), isNull);
|
||||
}
|
||||
await tester.tap(find.byKey(const ValueKey('create-task-direct-download-toggle')));
|
||||
await tester.tap(find.text('Confirm'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(submitted?.req?.url, 'https://example.com/third.zip');
|
||||
expect(submitted?.req?.rawUrl, isNull);
|
||||
expect(submitted?.req?.labels, isNull);
|
||||
expect(submitted?.req?.extra, isNull);
|
||||
expect(submitted?.req?.proxy?.mode, RequestProxyMode.follow);
|
||||
expect(submitted?.req?.skipVerifyCert, isFalse);
|
||||
expect(tester.takeException(), isNull);
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('mobile create task page stays within the system safe areas', (WidgetTester tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
tester.view.physicalSize = const Size(700, 760);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.padding = const FakeViewPadding(top: 32, bottom: 24);
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
addTearDown(tester.view.resetPadding);
|
||||
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'));
|
||||
final capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
|
||||
|
||||
try {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)],
|
||||
child: shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: AppComponentThemes(child: const CreateTaskWindowPage()),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final contentPadding = tester.widget<Padding>(find.byKey(const ValueKey('create-task-safe-content')));
|
||||
expect((contentPadding.padding as EdgeInsets).top, 32);
|
||||
expect(
|
||||
tester.getBottomRight(find.byKey(const ValueKey('create-task-confirm-button'))).dy,
|
||||
lessThanOrEqualTo(tester.view.physicalSize.height / tester.view.devicePixelRatio - 24),
|
||||
);
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('browser extension request parameters populate the form and survive submission', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
@@ -121,7 +302,10 @@ void main() {
|
||||
|
||||
final connectionsInput = find.byKey(const ValueKey('create-task-connections-input'));
|
||||
expect(connectionsInput, findsOneWidget);
|
||||
expect(tester.getSize(connectionsInput).width, AppDesignTokens.settingsNumberControlWidth);
|
||||
expect(
|
||||
tester.getSize(connectionsInput).width,
|
||||
tester.getSize(find.byKey(const ValueKey('create-task-rename-input'))).width,
|
||||
);
|
||||
final connectionsField = tester.widget<shad.TextField>(connectionsInput);
|
||||
expect(connectionsField.controller!.text, '12');
|
||||
expect(connectionsField.features.single, isA<shad.InputSpinnerFeature>());
|
||||
@@ -280,6 +464,58 @@ void main() {
|
||||
expect(submitted?.opts?.asDefaultPath, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('resolved task keeps rename empty and uses equal-width single-line actions', (WidgetTester tester) async {
|
||||
tester.view.physicalSize = const Size(700, 700);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
|
||||
..bind(
|
||||
GopeedMethods.resolve,
|
||||
(_) => ResolveResult(
|
||||
id: 'resolved-id',
|
||||
res: Resource(
|
||||
name: 'server-generated-name',
|
||||
files: [FileInfo(name: 'video.mp4', size: 1024)],
|
||||
),
|
||||
),
|
||||
)
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
final capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)],
|
||||
child: shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: AppComponentThemes(child: const CreateTaskWindowPage()),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byKey(const ValueKey('create-task-url-input')), 'https://example.com/video.mp4');
|
||||
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(_fieldText(tester, 'create-task-rename-input'), isEmpty);
|
||||
final createButton = tester.widget<AppLoadingButton>(find.byKey(const ValueKey('resolve-create-button')));
|
||||
expect(createButton.child, isA<SizedBox>());
|
||||
expect((createButton.child as SizedBox).width, 68);
|
||||
expect(
|
||||
find.descendant(of: find.byKey(const ValueKey('resolve-create-button')), matching: find.text('Create')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const ValueKey('resolve-cancel-button'))),
|
||||
tester.getSize(find.byKey(const ValueKey('resolve-create-button'))),
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('create history supports filtering, deleting one entry, and clearing all entries', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
@@ -324,6 +560,7 @@ void main() {
|
||||
|
||||
await tester.tap(find.byIcon(Icons.history));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('History Links'), findsOneWidget);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const ValueKey('create-history-title-bar'))).width,
|
||||
tester.getSize(find.byKey(const ValueKey('create-history-content'))).width,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/features/tasks/presentation/widgets/history_search_field.dart';
|
||||
import 'package:gopeed/l10n/l10n.dart';
|
||||
import 'package:gopeed/shared/theme/app_component_themes.dart';
|
||||
import 'package:gopeed/shared/theme/app_theme.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
void main() {
|
||||
for (final platform in [TargetPlatform.android, TargetPlatform.iOS, TargetPlatform.macOS]) {
|
||||
for (final scale in [1.0, 1.5, 2.0]) {
|
||||
testWidgets('history hint and input line align on $platform at scale $scale', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = platform;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
final controller = TextEditingController();
|
||||
addTearDown(controller.dispose);
|
||||
String? query;
|
||||
for (final width in [280.0, 360.0, 600.0]) {
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: AppComponentThemes(
|
||||
child: Builder(
|
||||
builder: (context) => Localizations.override(
|
||||
context: context,
|
||||
locale: const Locale('zh'),
|
||||
child: MediaQuery(
|
||||
data: MediaQueryData(textScaler: TextScaler.linear(scale)),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
child: HistorySearchField(controller: controller, onChanged: (value) => query = value),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final editable = tester.state<EditableTextState>(find.byType(EditableText)).renderEditable;
|
||||
final hint = tester.renderObject<RenderBox>(find.text('搜索历史记录…'));
|
||||
expect(editable.preferredLineHeight, lessThanOrEqualTo(editable.size.height));
|
||||
expect(hint.size.height, closeTo(editable.preferredLineHeight, 0.1));
|
||||
expect(hint.localToGlobal(Offset.zero).dy, closeTo(editable.localToGlobal(Offset.zero).dy, 0.1));
|
||||
expect(tester.takeException(), isNull);
|
||||
}
|
||||
await tester.enterText(find.byType(EditableText), 'example');
|
||||
expect(query, 'example');
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/features/tasks/presentation/widgets/create_task_action_buttons.dart';
|
||||
import 'package:gopeed/l10n/l10n.dart';
|
||||
import 'package:gopeed/shared/theme/app_component_themes.dart';
|
||||
import 'package:gopeed/shared/theme/app_theme.dart';
|
||||
import 'package:gopeed/shared/widgets/app_loading_button.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
void main() {
|
||||
for (final width in [390.0, 1024.0]) {
|
||||
for (final language in ['en', 'zh', 'de']) {
|
||||
testWidgets('resolve actions stay equal at $width in $language, including loading', (tester) async {
|
||||
tester.view.physicalSize = Size(width, 760);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
var submitting = false;
|
||||
var cancelled = false;
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: Builder(
|
||||
builder: (context) => Localizations.override(
|
||||
context: context,
|
||||
locale: Locale(language),
|
||||
delegates: const [AppLocalizations.delegate],
|
||||
child: AppComponentThemes(
|
||||
child: Center(
|
||||
child: StatefulBuilder(
|
||||
builder: (context, setState) => shad.AlertDialog(
|
||||
padding: const EdgeInsets.all(18),
|
||||
content: SizedBox(width: width < 760 ? width - 32 : 720, height: 100),
|
||||
actions: [
|
||||
Flexible(
|
||||
child: CreateTaskActionButtons(
|
||||
submitting: submitting,
|
||||
onCancel: () => cancelled = true,
|
||||
onSubmit: () => setState(() => submitting = true),
|
||||
cancelLabel: context.l10n.cancel,
|
||||
submitLabel: context.l10n.createAction,
|
||||
cancelButtonKey: const ValueKey('resolve-cancel-button'),
|
||||
submitButtonKey: const ValueKey('resolve-create-button'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final cancel = find.byKey(const ValueKey('resolve-cancel-button'));
|
||||
final create = find.byKey(const ValueKey('resolve-create-button'));
|
||||
final cancelRect = tester.getRect(cancel);
|
||||
final createRect = tester.getRect(create);
|
||||
expect(cancelRect.size, createRect.size);
|
||||
expect(cancelRect.center.dy, createRect.center.dy);
|
||||
expect(createRect.left - cancelRect.right, 12);
|
||||
expect((tester.widget<shad.SecondaryButton>(cancel).child as SizedBox).width, 68);
|
||||
expect((tester.widget<AppLoadingButton>(create).child as SizedBox).width, 68);
|
||||
expect(cancelRect.left, greaterThanOrEqualTo(0));
|
||||
expect(createRect.right, lessThanOrEqualTo(width));
|
||||
expect(find.text(appLocalizationsFor(language).createAction), findsOneWidget);
|
||||
await tester.tap(cancel);
|
||||
expect(cancelled, isTrue);
|
||||
await tester.tap(create);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
expect(tester.widget<AppLoadingButton>(create).loading, isTrue);
|
||||
expect(tester.widget<shad.SecondaryButton>(cancel).onPressed, isNull);
|
||||
expect(tester.getRect(cancel), cancelRect);
|
||||
expect(tester.getRect(create), createRect);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,30 @@ import 'package:gopeed/shared/theme/app_theme.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
void main() {
|
||||
testWidgets('provides the standard text field appearance', (tester) async {
|
||||
late shad.TextFieldTheme textFieldTheme;
|
||||
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: AppComponentThemes(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
textFieldTheme = shad.ComponentTheme.of<shad.TextFieldTheme>(context);
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(textFieldTheme.filled, isTrue);
|
||||
expect(textFieldTheme.border, Border.all(color: AppPalette.light.border));
|
||||
expect(textFieldTheme.borderRadius, BorderRadius.circular(AppDesignTokens.controlRadius));
|
||||
expect(textFieldTheme.padding, AppDesignTokens.textFieldPadding);
|
||||
});
|
||||
|
||||
testWidgets('provides the standard checkbox metrics', (tester) async {
|
||||
late shad.CheckboxTheme checkboxTheme;
|
||||
|
||||
|
||||
@@ -134,7 +134,10 @@ void main() {
|
||||
of: find.byKey(const ValueKey('text-only-loading-button')),
|
||||
matching: find.byType(shad.CircularProgressIndicator),
|
||||
);
|
||||
expect(find.text('Login'), findsNothing);
|
||||
final hiddenLabel = find.text('Login');
|
||||
expect(hiddenLabel, findsOneWidget);
|
||||
expect(tester.widget<Opacity>(find.ancestor(of: hiddenLabel, matching: find.byType(Opacity))).opacity, 0);
|
||||
expect(find.ancestor(of: hiddenLabel, matching: find.byType(ExcludeSemantics)), findsOneWidget);
|
||||
expect(loader, findsOneWidget);
|
||||
expect(tester.getRect(loader).center, bounds.center);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:flutter/foundation.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_path_picker_field.dart';
|
||||
import 'package:gopeed/shared/widgets/app_text_field.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
void main() {
|
||||
testWidgets('file paths remain editable and Web hides native file selection', (tester) async {
|
||||
final controller = TextEditingController(text: '/scripts/old.sh');
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: AppComponentThemes(
|
||||
child: Center(
|
||||
child: AppPathPickerField.file(
|
||||
controller: controller,
|
||||
desktopWidth: 320,
|
||||
pickerKey: const ValueKey('file-picker'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const ValueKey('file-picker')), kIsWeb ? findsNothing : findsOneWidget);
|
||||
expect(tester.widget<AppTextField>(find.byType(AppTextField)).readOnly, isFalse);
|
||||
await tester.enterText(find.byType(EditableText), '/scripts/custom.sh');
|
||||
await tester.pump();
|
||||
expect(controller.text, '/scripts/custom.sh');
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart' as material show AdaptiveTextSelectionToolbar;
|
||||
import 'package:flutter/services.dart' show BrowserContextMenu;
|
||||
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_text_field.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
void main() {
|
||||
testWidgets('Android long press uses Flutter adaptive text selection toolbar', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
final controller = TextEditingController(text: 'https://example.com/download');
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
try {
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: AppComponentThemes(
|
||||
child: Center(
|
||||
child: SizedBox(width: 320, child: AppTextField(controller: controller)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.longPress(find.byType(AppTextField));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(material.AdaptiveTextSelectionToolbar), findsOneWidget);
|
||||
expect(find.byType(shad.MobileEditableTextContextMenu), findsNothing);
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
}, skip: kIsWeb);
|
||||
|
||||
testWidgets('desktop right click uses Flutter adaptive text selection toolbar', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.macOS;
|
||||
final controller = TextEditingController(text: 'https://example.com/download');
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
try {
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: AppComponentThemes(
|
||||
child: Center(
|
||||
child: SizedBox(width: 320, child: AppTextField(controller: controller)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final field = find.byType(AppTextField);
|
||||
final gesture = await tester.startGesture(
|
||||
tester.getCenter(field),
|
||||
kind: PointerDeviceKind.mouse,
|
||||
buttons: kSecondaryMouseButton,
|
||||
);
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(material.AdaptiveTextSelectionToolbar), findsOneWidget);
|
||||
expect(find.byType(shad.DesktopEditableTextContextMenu), findsNothing);
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
}, skip: kIsWeb);
|
||||
|
||||
testWidgets('Web right click keeps the browser menu and creates no Flutter overlay', (tester) async {
|
||||
final controller = TextEditingController(text: 'https://example.com/download');
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
disableBrowserContextMenu: false,
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: AppComponentThemes(
|
||||
child: Center(
|
||||
child: SizedBox(width: 320, child: AppTextField(controller: controller)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(BrowserContextMenu.enabled, isTrue);
|
||||
final gesture = await tester.startGesture(
|
||||
tester.getCenter(find.byType(AppTextField)),
|
||||
kind: PointerDeviceKind.mouse,
|
||||
buttons: kSecondaryMouseButton,
|
||||
);
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(material.AdaptiveTextSelectionToolbar), findsNothing);
|
||||
expect(find.byType(shad.DesktopEditableTextContextMenu), findsNothing);
|
||||
expect(find.byType(shad.MobileEditableTextContextMenu), findsNothing);
|
||||
}, skip: !kIsWeb);
|
||||
|
||||
test('AppTextField defaults to the shared context menu policy', () {
|
||||
expect(const AppTextField().contextMenuBuilder, same(appTextFieldContextMenuBuilder));
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'package:flutter/material.dart' show Icons;
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/api/model/downloader_config.dart';
|
||||
import 'package:gopeed/features/settings/presentation/widgets/app_update_dialog.dart';
|
||||
import 'package:gopeed/features/settings/presentation/widgets/app_update_notes_viewport.dart';
|
||||
import 'package:gopeed/shared/theme/app_theme.dart';
|
||||
import 'package:gopeed/util/arch/arch.dart';
|
||||
import 'package:gopeed/util/github_mirror.dart';
|
||||
@@ -193,10 +195,47 @@ void main() {
|
||||
expect(find.text('下载更快'), findsNothing);
|
||||
expect(find.text('Later'), findsOneWidget);
|
||||
expect(find.text('Update Now'), findsOneWidget);
|
||||
expect(find.text("What's New"), findsNothing);
|
||||
final versionText = tester.widget<Text>(find.text('2.0.0-beta.1 → 2.0.0-beta.2'));
|
||||
expect(versionText.style?.decoration, TextDecoration.none);
|
||||
expect(versionText.style?.inherit, isFalse);
|
||||
expect(find.byIcon(Icons.system_update_alt_outlined), findsNothing);
|
||||
final dialog = tester.getRect(find.byKey(const ValueKey('app-update-dialog')));
|
||||
expect(dialog.width, lessThan(320));
|
||||
expect(tester.getCenter(find.byKey(const ValueKey('app-update-heading'))).dx, closeTo(dialog.center.dx, 0.01));
|
||||
expect(tester.getCenter(find.byKey(const ValueKey('app-update-actions'))).dx, closeTo(dialog.center.dx, 0.01));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('release notes keep the last line above the fade when scrolled to the end', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.dark(),
|
||||
materialTheme: AppTheme.materialDark(),
|
||||
home: Center(
|
||||
child: SizedBox(
|
||||
width: 280,
|
||||
child: AppUpdateNotesViewport(
|
||||
child: Column(children: [for (var i = 0; i < 30; i++) Text('Release note $i')]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final scrollbar = tester.widget<shad.Scrollbar>(find.byType(shad.Scrollbar));
|
||||
expect(scrollbar.thumbVisibility, isTrue);
|
||||
final controller = scrollbar.controller!;
|
||||
expect(controller.position.maxScrollExtent, greaterThan(0));
|
||||
controller.jumpTo(controller.position.maxScrollExtent);
|
||||
await tester.pumpAndSettle();
|
||||
final viewport = tester.getRect(find.byType(AppUpdateNotesViewport));
|
||||
final lastLine = tester.getRect(find.text('Release note 29'));
|
||||
expect(lastLine.bottom, lessThanOrEqualTo(viewport.bottom - 24));
|
||||
expect(lastLine.top, greaterThan(viewport.top));
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
+843
-108
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user