From 34515bf3185374aaf9c98b31b2778c443cdc1de8 Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Tue, 10 Feb 2026 13:19:17 +0800 Subject: [PATCH 1/5] feat: Add server selection dialog for speed test - Add server selection dialog before running speed test when servers are available - Update SpeedTestWidget with showServerSelectionDialog parameter - Add IVER-SERV_DLG and DHOME-SERV_DLG tests for dialog functionality - Add STV-SERVER tests for server selection on Speed Test view - Add healthCheckServersData test data for server list Co-Authored-By: Claude Opus 4.5 --- .../components/widgets/atomic/speed_test.dart | 57 ++++++++- .../shared_widgets/speed_test_widget.dart | 114 +++++++++++++---- .../health_check/views/speed_test_view.dart | 85 ++++++------- .../dashboard_home_view_test.dart | 80 +++++++++++- .../speed_test_selection_view_test.dart | 101 +++++++++++++++ .../localizations/speed_test_view_test.dart | 115 ++++++++++++++++-- .../instant_verify_view_test.dart | 51 +++++++- test/test_data/health_check_state_data.dart | 28 +++++ 8 files changed, 535 insertions(+), 96 deletions(-) create mode 100644 test/page/health_check/views/localizations/speed_test_selection_view_test.dart diff --git a/lib/page/dashboard/views/components/widgets/atomic/speed_test.dart b/lib/page/dashboard/views/components/widgets/atomic/speed_test.dart index d6a14782f..a0a18498e 100644 --- a/lib/page/dashboard/views/components/widgets/atomic/speed_test.dart +++ b/lib/page/dashboard/views/components/widgets/atomic/speed_test.dart @@ -2,12 +2,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/constants/build_config.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; +import 'package:privacy_gui/page/components/shortcuts/dialogs.dart'; import 'package:privacy_gui/page/dashboard/models/display_mode.dart'; import 'package:privacy_gui/page/dashboard/providers/dashboard_home_provider.dart'; import 'package:privacy_gui/page/dashboard/views/components/core/display_mode_widget.dart'; import 'package:privacy_gui/page/dashboard/views/components/widgets/parts/external_speed_test_links.dart'; import 'package:privacy_gui/page/dashboard/views/components/widgets/parts/internal_speed_test_result.dart'; import 'package:privacy_gui/page/health_check/models/health_check_enum.dart'; +import 'package:privacy_gui/page/health_check/models/health_check_server.dart'; import 'package:privacy_gui/page/health_check/models/speed_test_ui_model.dart'; import 'package:privacy_gui/page/health_check/providers/health_check_provider.dart'; import 'package:privacy_gui/page/health_check/providers/health_check_state.dart'; @@ -30,6 +32,54 @@ class CustomSpeedTest extends DisplayModeConsumerWidget { DisplayMode.expanded => 300, }; + /// Runs the speed test with server selection dialog. + Future _runSpeedTestWithServerSelection( + BuildContext context, + WidgetRef ref, + ) async { + final servers = ref.read(healthCheckProvider).servers; + + // Show server selection if servers are available + if (servers.isNotEmpty) { + final selected = await _showServerSelectionDialog(context, servers); + if (selected == null) return; // User cancelled + + ref.read(healthCheckProvider.notifier).setSelectedServer(selected); + } + + // Run the speed test + ref.read(healthCheckProvider.notifier).runHealthCheck(Module.speedtest); + } + + /// Shows a dialog for server selection. + Future _showServerSelectionDialog( + BuildContext context, + List servers, + ) async { + return showSimpleAppDialog( + context, + title: loc(context).selectServer, + content: Builder( + builder: (dialogContext) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: servers.map((server) { + return AppListTile( + key: Key('server_${server.serverID}'), + title: AppText.bodyMedium(server.serverName.isNotEmpty + ? server.serverName + : server.serverHostname), + onTap: () => Navigator.of(dialogContext, rootNavigator: true) + .pop(server), + ); + }).toList(), + ); + }, + ), + ); + } + @override Widget buildCompactView(BuildContext context, WidgetRef ref) { // Compact: Minimal view with just download/upload stats + controls @@ -84,11 +134,8 @@ class CustomSpeedTest extends DisplayModeConsumerWidget { child: isRunning ? const CircularProgressIndicator(strokeWidth: 2) : IconButton( - onPressed: () { - ref - .read(healthCheckProvider.notifier) - .runHealthCheck(Module.speedtest); - }, + onPressed: () => + _runSpeedTestWithServerSelection(context, ref), icon: const Icon(Icons.play_arrow, size: 20), padding: EdgeInsets.zero, color: Theme.of(context).colorScheme.primary, diff --git a/lib/page/health_check/shared_widgets/speed_test_widget.dart b/lib/page/health_check/shared_widgets/speed_test_widget.dart index 930941822..d68de7c49 100644 --- a/lib/page/health_check/shared_widgets/speed_test_widget.dart +++ b/lib/page/health_check/shared_widgets/speed_test_widget.dart @@ -3,7 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/page/components/customs/animated_digital_text.dart'; +import 'package:privacy_gui/page/components/shortcuts/dialogs.dart'; import 'package:privacy_gui/page/health_check/models/health_check_enum.dart'; +import 'package:privacy_gui/page/health_check/models/health_check_server.dart'; import 'package:privacy_gui/page/health_check/models/speed_test_ui_model.dart'; import 'package:privacy_gui/page/health_check/providers/health_check_provider.dart'; import 'package:privacy_gui/page/health_check/providers/health_check_state.dart'; @@ -46,6 +48,11 @@ class SpeedTestWidget extends ConsumerWidget { /// If true, shows the result summary (download/upload) below the meter when complete. final bool showResultSummary; + /// If true, shows a server selection dialog when the user taps the Go button + /// without a server selected. If false, the button is disabled when no server + /// is selected. + final bool showServerSelectionDialog; + const SpeedTestWidget({ super.key, this.showDetails = true, @@ -55,8 +62,42 @@ class SpeedTestWidget extends ConsumerWidget { this.showLatestOnIdle = true, this.meterSize, this.showResultSummary = true, + this.showServerSelectionDialog = true, }); + /// Runs the speed test with optional server selection. + /// + /// If [showServerSelectionDialog] is true and servers are available, + /// shows a dialog to select a server before running the test. + Future _runSpeedTestWithServerSelection( + BuildContext context, + WidgetRef ref, + ) async { + final isSpeedCheckSupported = ref + .read(healthCheckProvider.select((s) => s.isSpeedTestModuleSupported)); + + if (!isSpeedCheckSupported) { + // If not supported, navigate to the external test page. + context.pushNamed(RouteNamed.speedTestExternal); + return; + } + + // Show server selection if enabled and servers are available + if (showServerSelectionDialog) { + final servers = ref.read(healthCheckProvider).servers; + if (servers.isNotEmpty) { + final selected = + await _showServerSelectionDialog(context, ref, servers); + if (selected == null) return; // User cancelled + + ref.read(healthCheckProvider.notifier).setSelectedServer(selected); + } + } + + // Run the speed test + ref.read(healthCheckProvider.notifier).runHealthCheck(Module.speedtest); + } + @override Widget build(BuildContext context, WidgetRef ref) { final healthCheckState = ref.watch(healthCheckProvider); @@ -265,9 +306,7 @@ class SpeedTestWidget extends ConsumerWidget { if (state.status == HealthCheckStatus.complete) { if (!showResultSummary) { return IconButton( - onPressed: () => ref - .read(healthCheckProvider.notifier) - .runHealthCheck(Module.speedtest), + onPressed: () => _runSpeedTestWithServerSelection(context, ref), icon: Icon(Icons.replay, color: Theme.of(context).colorScheme.primary), tooltip: loc(context).testAgain, @@ -275,9 +314,7 @@ class SpeedTestWidget extends ConsumerWidget { } // Show single-line result with tap-to-retry return InkWell( - onTap: () => ref - .read(healthCheckProvider.notifier) - .runHealthCheck(Module.speedtest), + onTap: () => _runSpeedTestWithServerSelection(context, ref), borderRadius: BorderRadius.circular(AppRadius.md), child: Padding( padding: const EdgeInsets.symmetric( @@ -288,12 +325,24 @@ class SpeedTestWidget extends ConsumerWidget { Icon(Icons.arrow_downward, size: 14, color: Theme.of(context).colorScheme.primary), AppGap.xs(), - AppText.titleSmall(result.downloadSpeed), + Flexible( + child: AppText.titleSmall( + result.downloadSpeed, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), AppGap.md(), Icon(Icons.arrow_upward, size: 14, color: Theme.of(context).colorScheme.primary), AppGap.xs(), - AppText.titleSmall(result.uploadSpeed), + Flexible( + child: AppText.titleSmall( + result.uploadSpeed, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), AppGap.md(), Container( width: 1, @@ -501,27 +550,19 @@ class SpeedTestWidget extends ConsumerWidget { // indicatorPathStrokeWidth: 8, // markerRadius: 2, centerBuilder: (context, value) { + // Button is always enabled - the method handles server selection logic + final buttonColor = Theme.of(context).colorScheme.primary; + return SizedBox( width: meterSize == null ? 102 : meterSize! / 2.0, height: meterSize == null ? 102 : meterSize! / 2.0, child: Material( shape: const CircleBorder(), - color: Theme.of(context).colorScheme.primary, + color: buttonColor, child: InkWell( key: const Key('goBtn'), customBorder: const CircleBorder(), - onTap: () { - final isSpeedCheckSupported = ref.watch(healthCheckProvider - .select((s) => s.isSpeedTestModuleSupported)); - if (isSpeedCheckSupported) { - ref - .read(healthCheckProvider.notifier) - .runHealthCheck(Module.speedtest); - } else { - // If not supported, navigate to the external test page. - context.pushNamed(RouteNamed.speedTestExternal); - } - }, + onTap: () => _runSpeedTestWithServerSelection(context, ref), child: Center( child: AppText.bodyLarge(loc(context).go, color: Theme.of(context).colorScheme.onPrimary), @@ -625,4 +666,35 @@ class SpeedTestWidget extends ConsumerWidget { ], ); } + + /// Shows a dialog for server selection. + /// Returns the selected server, or null if canceled. + Future _showServerSelectionDialog( + BuildContext context, + WidgetRef ref, + List servers, + ) async { + return showSimpleAppDialog( + context, + title: loc(context).selectServer, + content: Builder( + builder: (dialogContext) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: servers.map((server) { + return AppListTile( + key: Key('server_${server.serverID}'), + title: AppText.bodyMedium(server.serverName.isNotEmpty + ? server.serverName + : server.serverHostname), + onTap: () => Navigator.of(dialogContext, rootNavigator: true) + .pop(server), + ); + }).toList(), + ); + }, + ), + ); + } } diff --git a/lib/page/health_check/views/speed_test_view.dart b/lib/page/health_check/views/speed_test_view.dart index 67605d9ca..69d560f3f 100644 --- a/lib/page/health_check/views/speed_test_view.dart +++ b/lib/page/health_check/views/speed_test_view.dart @@ -5,7 +5,6 @@ import 'package:privacy_gui/page/components/ui_kit_page_view.dart'; import 'package:privacy_gui/page/health_check/models/health_check_server.dart'; import 'package:privacy_gui/page/health_check/models/speed_test_ui_model.dart'; import 'package:privacy_gui/page/health_check/providers/health_check_provider.dart'; -import 'package:privacy_gui/page/health_check/views/components/speed_test_server_selection_dialog.dart'; import 'package:privacy_gui/page/health_check/shared_widgets/speed_test_widget.dart'; import 'package:ui_kit_library/ui_kit.dart'; @@ -24,12 +23,12 @@ class SpeedTestView extends ConsumerWidget { final healthCheckState = ref.watch(healthCheckProvider); final servers = healthCheckState.servers; final selectedServer = healthCheckState.selectedServer; - final mainWidget = SpeedTestWidget( showDetails: true, showInfoPanel: true, showStepDescriptions: true, showLatestOnIdle: false, // History is shown separately in this view + showServerSelectionDialog: false, // SpeedTestView uses its own dropdown meterSize: context.isMobileLayout ? context.colWidth(3) : context.colWidth(5), // Make it larger on desktop @@ -45,21 +44,38 @@ class SpeedTestView extends ConsumerWidget { final historyWidget = _buildHistoryPanel(context, historicalTests); + final serverSelection = servers.isNotEmpty + ? Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.md), + child: SizedBox( + width: 250, + child: AppDropdown( + items: servers, + value: selectedServer, + itemAsString: (s) => s.toString(), + hint: '-----', + onChanged: (server) { + if (server != null) { + ref + .read(healthCheckProvider.notifier) + .setSelectedServer(server); + } + }, + ), + ), + ), + ) + : const SizedBox.shrink(); + return UiKitPageView.withSliver( scrollable: true, title: loc(context).speedTest, - actions: servers.isNotEmpty - ? [ - AppIconButton( - icon: const Icon(Icons.dns_outlined), - onTap: () => _showServerSelectionDialog( - context, ref, servers, selectedServer), - ), - ] - : null, child: (context, constraints) => AppResponsiveLayout( mobile: (ctx) => Column( children: [ + serverSelection, mainWidget, AppGap.xxl(), performanceDescriptionCard, @@ -70,7 +86,15 @@ class SpeedTestView extends ConsumerWidget { desktop: (ctx) => Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded(flex: 6, child: mainWidget), + Expanded( + flex: 6, + child: Column( + children: [ + serverSelection, + mainWidget, + ], + ), + ), AppGap.gutter(), Expanded( flex: 6, @@ -193,41 +217,4 @@ class SpeedTestView extends ConsumerWidget { }; return (resultTitle, resultDesc); } - - /// Shows the server selection dialog. - void _showServerSelectionDialog( - BuildContext context, - WidgetRef ref, - List servers, - HealthCheckServer? currentSelection, - ) { - final selectedNotifier = - ValueNotifier(currentSelection); - - showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: AppText.titleMedium(loc(context).selectServer), - content: SpeedTestServerSelectionList( - servers: servers, - notifier: selectedNotifier, - ), - actions: [ - AppButton.text( - label: loc(context).cancel, - onTap: () => Navigator.of(dialogContext).pop(), - ), - AppButton.primary( - label: loc(context).ok, - onTap: () { - ref - .read(healthCheckProvider.notifier) - .setSelectedServer(selectedNotifier.value); - Navigator.of(dialogContext).pop(); - }, - ), - ], - ), - ); - } } diff --git a/test/page/dashboard/localizations/dashboard_home_view_test.dart b/test/page/dashboard/localizations/dashboard_home_view_test.dart index 1619defa6..4a70810e4 100644 --- a/test/page/dashboard/localizations/dashboard_home_view_test.dart +++ b/test/page/dashboard/localizations/dashboard_home_view_test.dart @@ -12,6 +12,8 @@ import 'package:privacy_gui/page/dashboard/views/components/fixed_layout/network import 'package:privacy_gui/page/dashboard/views/components/fixed_layout/port_and_speed.dart'; import 'package:privacy_gui/page/dashboard/views/components/fixed_layout/quick_panel.dart'; import 'package:privacy_gui/page/dashboard/views/components/fixed_layout/wifi_grid.dart'; +import 'package:privacy_gui/page/health_check/models/health_check_server.dart'; +import 'package:privacy_gui/page/health_check/providers/health_check_provider.dart'; import 'package:privacy_gui/page/health_check/providers/health_check_state.dart'; import 'package:privacy_gui/page/instant_privacy/providers/instant_privacy_state.dart'; import 'package:privacy_gui/route/route_model.dart'; @@ -42,6 +44,7 @@ import '../../../test_data/_index.dart'; // - DHOME-VERT_OFFLINE: Offline WAN indicator. // - DHOME-VERT_QR: Hover QR tooltip. // - DHOME-VERT_VPN: VPN connected/disconnected. +// - DHOME-SERV_DLG: Server selection dialog appears on speed test. final List _noLanScreens = [ ...responsiveMobileScreens.map( @@ -61,6 +64,15 @@ final List _verticalScreens = [ ), ]; +// Screens that render SpeedTestWidget with goBtn (excludes 744w tablet) +final List _speedTestDialogScreens = [ + ...responsiveMobileScreens.map( + (screen) => screen.copyWith(name: '${screen.name}-Tall', height: 2480), + ), + ...responsiveDesktopScreens.where((screen) => screen.width >= 1080).map( + (screen) => screen.copyWith(name: '${screen.name}-Tall', height: 1280)), +]; + void main() { final testHelper = TestHelper(); late TopologyTestData topologyTestData; @@ -88,11 +100,16 @@ void main() { overrides: overrides, ); await tester.runAsync(() async { - // TODO - await precacheImage( - Assets.images.devices.routerLn12.provider(), - context, - ); + final element = tester.element(find.byType(DashboardHomeView)); + final images = [ + Assets.images.devices.routerMx6200.provider(), + Assets.images.devices.routerWhw03.provider(), + Assets.images.devices.routerMr7500.provider(), + Assets.images.speedtestPowered.provider(), + ]; + for (final image in images) { + await precacheImage(image, element); + } }); await tester.pumpAndSettle(); return context; @@ -444,4 +461,57 @@ void main() { goldenFilename: 'DHOME-VERT_VPN_DISCONNECTED_01_card', helper: testHelper, ); + + // Test ID: DHOME-SERV_DLG — server selection dialog appears on speed test + testLocalizations( + 'dashboard home view - server selection dialog appears on Go', + (tester, screen) async { + // Prepare servers data + final servers = healthCheckServersData + .map((json) => HealthCheckServer.fromJson(json)) + .toList(); + + // Setup dashboard home state with LAN ports (required for SpeedTestWidget) + when(testHelper.mockDashboardHomeNotifier.build()).thenReturn( + DashboardHomeState.fromMap(dashboardHomeStateData), + ); + + // Setup state with servers and SpeedTest module enabled + final stateWithServers = + HealthCheckState.fromJson(healthCheckInitStateWithModules).copyWith( + servers: servers, + selectedServer: () => null, + ); + + when(testHelper.mockHealthCheckProvider.build()) + .thenReturn(stateWithServers); + when(testHelper.mockHealthCheckProvider + .runHealthCheck(Module.speedtest, serverId: anyNamed('serverId'))) + .thenAnswer((_) async {}); + + final context = await pumpDashboard(tester, screen); + final loc = testHelper.loc(context); + + // Scroll to make Go button visible + final portAndSpeed = find.byType(FixedDashboardHomePortAndSpeed); + if (portAndSpeed.evaluate().isNotEmpty) { + await tester.ensureVisible(portAndSpeed.first); + await tester.pumpAndSettle(); + } + + // Find and tap Go button + final goButton = find.byKey(const Key('goBtn')); + expect(goButton, findsOneWidget); + await tester.tap(goButton); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 1500)); + // Verify dialog appears + expect(find.byType(AppDialog), findsOneWidget); + expect(find.text(loc.selectServer), findsOneWidget); + expect(find.text(servers[0].serverName), findsOneWidget); + }, + screens: _speedTestDialogScreens, + goldenFilename: 'DHOME-SERV_DLG_01_dialog', + helper: testHelper, + ); } diff --git a/test/page/health_check/views/localizations/speed_test_selection_view_test.dart b/test/page/health_check/views/localizations/speed_test_selection_view_test.dart new file mode 100644 index 000000000..5fc8659b7 --- /dev/null +++ b/test/page/health_check/views/localizations/speed_test_selection_view_test.dart @@ -0,0 +1,101 @@ +// View ID: STSEL +// Reference: lib/page/health_check/views/speed_test_selection.dart +// +// ## Test Cases +// +// | Test ID | Description | +// |------------------|---------------------------------------------------------| +// | STSEL-INIT-01 | Verify initial state with both options enabled. | +// | STSEL-DISABLED-01| Verify state when router speed test is not supported. | + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:privacy_gui/page/health_check/_health_check.dart'; +import 'package:privacy_gui/page/health_check/views/speed_test_selection.dart'; +import 'package:privacy_gui/providers/connectivity/_connectivity.dart'; + +import '../../../../common/config.dart'; +import '../../../../common/test_helper.dart'; +import '../../../../common/test_responsive_widget.dart'; +import '../../../../test_data/health_check_state_data.dart'; + +void main() { + final testHelper = TestHelper(); + + setUp(() { + testHelper.setup(); + }); + + // Test ID: STSEL-INIT-01 + testLocalizations( + 'Verify initial state with both options enabled.', + (tester, screen) async { + // Mock connectivity as behind router (enables Internet to Device option) + when(testHelper.mockConnectivityNotifier.build()).thenReturn( + const ConnectivityState( + hasInternet: true, + connectivityInfo: ConnectivityInfo( + routerType: RouterType.behindManaged, + ), + ), + ); + // Mock speed test module as supported + when(testHelper.mockHealthCheckProvider.build()).thenReturn( + HealthCheckState.fromJson(healthCheckInitStateWithModules), + ); + + final context = await testHelper.pumpView( + tester, + child: const SpeedTestSelectionView(), + locale: screen.locale, + ); + await tester.pumpAndSettle(); + + // Verify both options are visible + expect(find.text(testHelper.loc(context).speedTest), findsOneWidget); + expect(find.text(testHelper.loc(context).speedTestInternetToRouter), + findsOneWidget); + expect(find.text(testHelper.loc(context).speedTestInternetToDevice), + findsOneWidget); + }, + goldenFilename: 'STSEL-INIT-01-both_enabled', + helper: testHelper, + screens: responsiveAllScreens, + ); + + // Test ID: STSEL-DISABLED-01 + testLocalizations( + 'Verify state when router speed test is not supported.', + (tester, screen) async { + // Mock connectivity as behind router + when(testHelper.mockConnectivityNotifier.build()).thenReturn( + const ConnectivityState( + hasInternet: true, + connectivityInfo: ConnectivityInfo( + routerType: RouterType.behindManaged, + ), + ), + ); + // Mock speed test module as NOT supported (empty modules list) + when(testHelper.mockHealthCheckProvider.build()).thenReturn( + HealthCheckState.fromJson(healthCheckInitState), + ); + + final context = await testHelper.pumpView( + tester, + child: const SpeedTestSelectionView(), + locale: screen.locale, + ); + await tester.pumpAndSettle(); + + // Both cards should be visible but Internet to Router should be disabled + expect(find.text(testHelper.loc(context).speedTestInternetToRouter), + findsOneWidget); + expect(find.text(testHelper.loc(context).speedTestInternetToDevice), + findsOneWidget); + }, + goldenFilename: 'STSEL-DISABLED-01-router_test_disabled', + helper: testHelper, + screens: responsiveAllScreens, + ); +} diff --git a/test/page/health_check/views/localizations/speed_test_view_test.dart b/test/page/health_check/views/localizations/speed_test_view_test.dart index fe5ef2786..ea1dafd26 100644 --- a/test/page/health_check/views/localizations/speed_test_view_test.dart +++ b/test/page/health_check/views/localizations/speed_test_view_test.dart @@ -20,7 +20,8 @@ // | STV-ERROR-05 | Verify the UI when a database error occurs. | // | STV-ERROR-06 | Verify the UI when a timeout/unknown error occurs.| // | STV-HISTORY-01 | Verify the history panel displays records. | -// | STV-ACTION-01 | Verify tapping 'Go' button starts the test. | +// | STV-SERVER-01 | Verify server dropdown and selection flow. | +// | STV-SERVER-02 | Verify Go button runs test with selected server. | // import 'package:flutter/material.dart'; @@ -28,6 +29,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; import 'package:privacy_gui/page/health_check/_health_check.dart'; import 'package:privacy_gui/page/health_check/models/health_check_enum.dart'; +import 'package:privacy_gui/page/health_check/models/health_check_server.dart'; import 'package:privacy_gui/page/health_check/models/speed_test_ui_model.dart'; import 'package:ui_kit_library/ui_kit.dart'; @@ -405,7 +407,6 @@ void main() { testLocalizations( 'Verify the history panel displays records.', (tester, screen) async { - // Mock a state with historical data final historyState = HealthCheckState.fromJson(healthCheckInitState).copyWith( historicalSpeedTests: [ @@ -442,13 +443,28 @@ void main() { screens: screens, ); - // Test ID: STV-ACTION-01 + // Test ID: STV-SERVER-01 testLocalizations( - 'Verify tapping "Go" button starts the test.', + 'Verify server dropdown selection flow', (tester, screen) async { - final testState = HealthCheckState.fromJson(healthCheckInitState) - .copyWith(healthCheckModules: ['SpeedTest']); - when(testHelper.mockHealthCheckProvider.build()).thenReturn(testState); + // 1. Enable Service Support + when(testHelper.mockServiceHelper.isSupportHealthCheckManager2()) + .thenReturn(true); + + // 2. Prepare Data + final servers = healthCheckServersData + .map((json) => HealthCheckServer.fromJson(json)) + .toList(); + + // Initialize with NO server selected (default state) + final stateWithServers = HealthCheckState.fromJson(healthCheckInitState) + .copyWith(servers: servers, selectedServer: () => null); + + // 3. Setup Mock + when(testHelper.mockHealthCheckProvider.build()) + .thenReturn(stateWithServers); + + // 4. Pump Widget await testHelper.pumpView( tester, child: const SpeedTestView(), @@ -456,15 +472,94 @@ void main() { ); await tester.pumpAndSettle(); - final runButton = find.byKey(const Key('goBtn')); - expect(runButton, findsOneWidget); - await tester.tap(runButton); + // 5. Verification - Dropdown is displayed with hint text + expect(find.text('-----'), findsOneWidget); + + // 6. Dropdown exists + final serverDropdown = find.byType(AppDropdown); + expect(serverDropdown, findsOneWidget); + + await testHelper.takeScreenshot( + tester, + 'STV-SERVER-01-01-initial_with_dropdown', + ); + + // 7. Open dropdown + await tester.tap(serverDropdown); + await tester.pumpAndSettle(); + + await testHelper.takeScreenshot( + tester, + 'STV-SERVER-01-02-dropdown_open', + ); + + // 8. Select server + final serverItem = find.text(servers[0].toString()).last; + expect(serverItem, findsOneWidget); + + await tester.tap(serverItem); + await tester.pumpAndSettle(); + + // 9. Verify that the selection action was called + verify(testHelper.mockHealthCheckProvider.setSelectedServer(any)) + .called(1); + }, + goldenFilename: 'STV-SERVER-01-03-final', + helper: testHelper, + screens: screens, + ); + + // Test ID: STV-SERVER-02 + testLocalizations( + 'Verify Go button runs test with selected server', + (tester, screen) async { + // 1. Enable Service Support + when(testHelper.mockServiceHelper.isSupportHealthCheckManager2()) + .thenReturn(true); + + // 2. Prepare Data + final servers = healthCheckServersData + .map((json) => HealthCheckServer.fromJson(json)) + .toList(); + + // Initialize with server selected (Enabled state) + final stateWithSelection = + HealthCheckState.fromJson(healthCheckInitState).copyWith( + servers: servers, + selectedServer: () => servers[0], + healthCheckModules: ['SpeedTest'], + ); + // 3. Setup Mock + when(testHelper.mockHealthCheckProvider.build()) + .thenReturn(stateWithSelection); + + // 4. Pump Widget + await testHelper.pumpView( + tester, + child: const SpeedTestView(), + locale: screen.locale, + ); + await tester.pumpAndSettle(); + + // 5. Verification - Go button is visible + final goButton = find.byKey(const Key('goBtn')); + expect(goButton, findsOneWidget); + + await testHelper.takeScreenshot( + tester, + 'STV-SERVER-02-01-server_selected', + ); + + // 6. Action: Tap Go + await tester.tap(goButton); await tester.pump(); + // 7. Verify runHealthCheck was called verify(testHelper.mockHealthCheckProvider .runHealthCheck(Module.speedtest, serverId: anyNamed('serverId'))) .called(1); }, + goldenFilename: 'STV-SERVER-02-02-final', helper: testHelper, screens: screens, ); diff --git a/test/page/instant_verify/views/localizations/instant_verify_view_test.dart b/test/page/instant_verify/views/localizations/instant_verify_view_test.dart index b69169f9f..85bdd9069 100644 --- a/test/page/instant_verify/views/localizations/instant_verify_view_test.dart +++ b/test/page/instant_verify/views/localizations/instant_verify_view_test.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; import 'package:privacy_gui/core/data/providers/node_internet_status_provider.dart'; +import 'package:privacy_gui/page/health_check/models/health_check_server.dart'; +import 'package:privacy_gui/page/health_check/providers/health_check_provider.dart'; import 'package:privacy_gui/page/health_check/providers/health_check_state.dart'; import 'package:privacy_gui/page/health_check/shared_widgets/speed_test_widget.dart'; // import 'package:privacy_gui/page/instant_topology/views/instant_topology_view.dart'; @@ -26,6 +28,7 @@ import '../../../../test_data/instant_verify_test_state.dart'; // - IVER-MULTI_DNS: Connectivity card display when multiple DNS entries exist. // - IVER-SPEEDTEST: Internal health-check speed test widget rendered. // - IVER-SPEEDTEST_INIT: Modules configured but idle still show speed test panel. +// - IVER-SERV_DLG: Server selection dialog appears when clicking Go. // - IVER-PING: Ping tile launches ping modal via PageBottomBar. // - IVER-TRACEROUTE: Traceroute tile launches traceroute modal. @@ -166,6 +169,46 @@ void main() { helper: testHelper, ); + // Test ID: IVER-SERV_DLG — server selection dialog appears when clicking Go + testLocalizations( + 'instant verify view - server selection dialog appears on Go', + (tester, screen) async { + // Prepare servers data + final servers = healthCheckServersData + .map((json) => HealthCheckServer.fromJson(json)) + .toList(); + + // Setup state with servers and SpeedTest module enabled + final stateWithServers = + HealthCheckState.fromJson(healthCheckInitStateWithModules).copyWith( + servers: servers, + selectedServer: () => null, + ); + + when(testHelper.mockHealthCheckProvider.build()) + .thenReturn(stateWithServers); + when(testHelper.mockHealthCheckProvider + .runHealthCheck(Module.speedtest, serverId: anyNamed('serverId'))) + .thenAnswer((_) async {}); + + final context = await pumpInstantVerify(tester, screen); + final loc = testHelper.loc(context); + + // Find and tap Go button + final goButton = find.byKey(const Key('goBtn')); + expect(goButton, findsOneWidget); + await tester.tap(goButton); + await tester.pumpAndSettle(); + + // Verify dialog appears with server list + expect(find.text(loc.selectServer), findsOneWidget); + expect(find.text(servers[0].serverName), findsOneWidget); + }, + screens: _infoScreens, + goldenFilename: 'IVER-SERV_DLG_01_dialog', + helper: testHelper, + ); + // Test ID: IVER-PING — tapping ping card opens modal testLocalizations( 'instant verify view - ping dialog', @@ -173,9 +216,7 @@ void main() { await pumpInstantVerify(tester, screen); await tester.tap(find.byKey(const ValueKey('ping'))); - // Use pump with fixed duration to avoid pumpAndSettle timeout - // from modal or network-related animations - await tester.pump(const Duration(milliseconds: 500)); + await tester.pumpAndSettle(); expect(find.byType(PingNetworkModal), findsOneWidget); }, screens: _infoScreens, @@ -190,9 +231,7 @@ void main() { await pumpInstantVerify(tester, screen); await tester.tap(find.byKey(const ValueKey('traceroute'))); - // Use pump with fixed duration to avoid pumpAndSettle timeout - // from modal or network-related animations - await tester.pump(const Duration(milliseconds: 500)); + await tester.pumpAndSettle(); expect(find.byType(TracerouteModal), findsOneWidget); }, screens: _infoScreens, diff --git a/test/test_data/health_check_state_data.dart b/test/test_data/health_check_state_data.dart index d7651c8cd..7b1232d93 100644 --- a/test/test_data/health_check_state_data.dart +++ b/test/test_data/health_check_state_data.dart @@ -129,3 +129,31 @@ const healthCheckStateError = '''{"status": "complete", "result":null, "error":{"result":"Empty resultID","error":null} }'''; + +/// Mock server list for server selection dialog tests +const healthCheckServersData = [ + { + 'serverID': '3967', + 'serverName': 'Speedtest Server 1', + 'serverLocation': 'Los Angeles, CA', + 'serverCountry': 'USA', + 'serverHostname': 'speedtest1.example.com', + 'serverPort': 8080, + }, + { + 'serverID': '4521', + 'serverName': 'Speedtest Server 2', + 'serverLocation': 'New York, NY', + 'serverCountry': 'USA', + 'serverHostname': 'speedtest2.example.com', + 'serverPort': 8080, + }, + { + 'serverID': '5892', + 'serverName': 'Speedtest Server 3', + 'serverLocation': 'Tokyo', + 'serverCountry': 'Japan', + 'serverHostname': 'speedtest3.example.com', + 'serverPort': 8080, + }, +]; From afaf727c8705d2385deb25b69065446f0ddb7bcb Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Tue, 10 Feb 2026 13:57:42 +0800 Subject: [PATCH 2/5] fix: Address Qodo code review feedback - Use localization for dropdown hint instead of hardcoded '-----' - Remove unused ref parameter from _showServerSelectionDialog - Add scroll constraint to server list dialog to prevent overflow - Update test to use localized hint text Co-Authored-By: Claude Opus 4.5 --- .../shared_widgets/speed_test_widget.dart | 38 +++++++++++-------- .../health_check/views/speed_test_view.dart | 2 +- .../localizations/speed_test_view_test.dart | 4 +- 3 files changed, 25 insertions(+), 19 deletions(-) diff --git a/lib/page/health_check/shared_widgets/speed_test_widget.dart b/lib/page/health_check/shared_widgets/speed_test_widget.dart index d68de7c49..ee53c151e 100644 --- a/lib/page/health_check/shared_widgets/speed_test_widget.dart +++ b/lib/page/health_check/shared_widgets/speed_test_widget.dart @@ -86,8 +86,7 @@ class SpeedTestWidget extends ConsumerWidget { if (showServerSelectionDialog) { final servers = ref.read(healthCheckProvider).servers; if (servers.isNotEmpty) { - final selected = - await _showServerSelectionDialog(context, ref, servers); + final selected = await _showServerSelectionDialog(context, servers); if (selected == null) return; // User cancelled ref.read(healthCheckProvider.notifier).setSelectedServer(selected); @@ -671,7 +670,6 @@ class SpeedTestWidget extends ConsumerWidget { /// Returns the selected server, or null if canceled. Future _showServerSelectionDialog( BuildContext context, - WidgetRef ref, List servers, ) async { return showSimpleAppDialog( @@ -679,19 +677,27 @@ class SpeedTestWidget extends ConsumerWidget { title: loc(context).selectServer, content: Builder( builder: (dialogContext) { - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: servers.map((server) { - return AppListTile( - key: Key('server_${server.serverID}'), - title: AppText.bodyMedium(server.serverName.isNotEmpty - ? server.serverName - : server.serverHostname), - onTap: () => Navigator.of(dialogContext, rootNavigator: true) - .pop(server), - ); - }).toList(), + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(dialogContext).size.height * 0.5, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: servers.map((server) { + return AppListTile( + key: Key('server_${server.serverID}'), + title: AppText.bodyMedium(server.serverName.isNotEmpty + ? server.serverName + : server.serverHostname), + onTap: () => + Navigator.of(dialogContext, rootNavigator: true) + .pop(server), + ); + }).toList(), + ), + ), ); }, ), diff --git a/lib/page/health_check/views/speed_test_view.dart b/lib/page/health_check/views/speed_test_view.dart index 69d560f3f..99160a5f7 100644 --- a/lib/page/health_check/views/speed_test_view.dart +++ b/lib/page/health_check/views/speed_test_view.dart @@ -55,7 +55,7 @@ class SpeedTestView extends ConsumerWidget { items: servers, value: selectedServer, itemAsString: (s) => s.toString(), - hint: '-----', + hint: loc(context).selectServer, onChanged: (server) { if (server != null) { ref diff --git a/test/page/health_check/views/localizations/speed_test_view_test.dart b/test/page/health_check/views/localizations/speed_test_view_test.dart index ea1dafd26..902bdcd47 100644 --- a/test/page/health_check/views/localizations/speed_test_view_test.dart +++ b/test/page/health_check/views/localizations/speed_test_view_test.dart @@ -465,7 +465,7 @@ void main() { .thenReturn(stateWithServers); // 4. Pump Widget - await testHelper.pumpView( + final context = await testHelper.pumpView( tester, child: const SpeedTestView(), locale: screen.locale, @@ -473,7 +473,7 @@ void main() { await tester.pumpAndSettle(); // 5. Verification - Dropdown is displayed with hint text - expect(find.text('-----'), findsOneWidget); + expect(find.text(testHelper.loc(context).selectServer), findsOneWidget); // 6. Dropdown exists final serverDropdown = find.byType(AppDropdown); From 8803e8778510f4ab47fb3b0da9a664018ec9091c Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Tue, 10 Feb 2026 15:46:37 +0800 Subject: [PATCH 3/5] fix(ui): resolve overflow issues in brutal theme and fix firmware update test --- .../advanced_settings_view.dart | 8 +- .../dashboard/views/dashboard_menu_view.dart | 14 ++- lib/theme/theme_json_config.dart | 3 + test/common/di.dart | 5 +- test/common/theme_data.dart | 15 +-- .../firmware_update_detail_view_test.dart | 3 +- .../localizations/node_detail_view_test.dart | 2 - test/theme/theme_json_config_test.dart | 99 +++++++++++-------- 8 files changed, 86 insertions(+), 63 deletions(-) diff --git a/lib/page/advanced_settings/advanced_settings_view.dart b/lib/page/advanced_settings/advanced_settings_view.dart index 43bbbb3f5..231c9d3b8 100644 --- a/lib/page/advanced_settings/advanced_settings_view.dart +++ b/lib/page/advanced_settings/advanced_settings_view.dart @@ -37,8 +37,8 @@ class _AdvancedSettingsViewState extends ConsumerState { crossAxisCount: 2, mainAxisSpacing: AppSpacing.lg, crossAxisSpacing: AppSpacing.lg, - childAspectRatio: (430 / 56), - mainAxisExtent: 56, + childAspectRatio: (430 / 60), + mainAxisExtent: 60, ), physics: const ScrollPhysics(), itemCount: advancedSettings.length, @@ -108,6 +108,10 @@ class _AdvancedSettingsViewState extends ConsumerState { title: item.title, trailing: AppIcon.font(AppFontIcons.chevronRight), onTap: disabled ? null : item.onTap, + padding: EdgeInsets.symmetric( + vertical: AppSpacing.md, + horizontal: AppSpacing.xl, + ), ), ); } diff --git a/lib/page/dashboard/views/dashboard_menu_view.dart b/lib/page/dashboard/views/dashboard_menu_view.dart index 2f7649d25..6c2a232e0 100644 --- a/lib/page/dashboard/views/dashboard_menu_view.dart +++ b/lib/page/dashboard/views/dashboard_menu_view.dart @@ -353,13 +353,17 @@ class AppMenuCard extends StatelessWidget { ), if (title != null) Padding( - padding: EdgeInsets.only(top: AppSpacing.sm), - child: Wrap( - crossAxisAlignment: WrapCrossAlignment.start, - spacing: AppSpacing.sm, + padding: EdgeInsets.only(top: AppSpacing.xs), + child: Row( children: [ - AppText.titleSmall(title ?? ''), + Flexible( + child: AppText.titleSmall( + title ?? '', + overflow: TextOverflow.ellipsis, + ), + ), if (isBeta) ...[ + AppGap.sm(), AppBadge( label: 'BETA', color: Theme.of(context) diff --git a/lib/theme/theme_json_config.dart b/lib/theme/theme_json_config.dart index c047894dd..4d6db30b1 100644 --- a/lib/theme/theme_json_config.dart +++ b/lib/theme/theme_json_config.dart @@ -62,12 +62,14 @@ class ThemeJsonConfig { final colors = json['colors'] as Map?; final visualEffects = json['visualEffects'] as int?; + final globalOverlay = json['globalOverlay'] as String?; // Compose light theme JSON final lightJson = { 'style': style, 'brightness': 'light', if (visualEffects != null) 'visualEffects': visualEffects, + if (globalOverlay != null) 'globalOverlay': globalOverlay, if (seedColor != null) 'seedColor': seedColor, if (overrides != null) 'overrides': overrides, ...?(colors?['light'] as Map?), @@ -78,6 +80,7 @@ class ThemeJsonConfig { 'style': style, 'brightness': 'dark', if (visualEffects != null) 'visualEffects': visualEffects, + if (globalOverlay != null) 'globalOverlay': globalOverlay, if (seedColor != null) 'seedColor': seedColor, if (overrides != null) 'overrides': overrides, ...?(colors?['dark'] as Map?), diff --git a/test/common/di.dart b/test/common/di.dart index c6978f775..1144878cd 100644 --- a/test/common/di.dart +++ b/test/common/di.dart @@ -3,6 +3,7 @@ import 'package:privacy_gui/core/jnap/actions/jnap_service_supported.dart'; import 'package:privacy_gui/di.dart'; import 'package:privacy_gui/theme/theme_json_config.dart'; import '../mocks/jnap_service_supported_mocks.dart'; +import 'theme_data.dart'; void mockDependencyRegister() { // Unregister if already exists to allow fresh mock setup @@ -15,11 +16,11 @@ void mockDependencyRegister() { final config = ThemeJsonConfig.defaultConfig(); if (!getIt.isRegistered(instanceName: 'lightThemeData')) { - getIt.registerSingleton(config.createLightTheme(), + getIt.registerSingleton(mockLightThemeData, instanceName: 'lightThemeData'); } if (!getIt.isRegistered(instanceName: 'darkThemeData')) { - getIt.registerSingleton(config.createDarkTheme(), + getIt.registerSingleton(mockDarkThemeData, instanceName: 'darkThemeData'); } } diff --git a/test/common/theme_data.dart b/test/common/theme_data.dart index 69539096d..39370f7c4 100644 --- a/test/common/theme_data.dart +++ b/test/common/theme_data.dart @@ -9,19 +9,20 @@ import 'package:privacy_gui/theme/theme_json_config.dart'; /// /// Use this theme to generate snapshots with localizations -/// To avoid animation in snapshots, set visualEffects to 0 -/// Flat has no animation and global effects -/// +/// To avoid animation in snapshots, don't enable shimmer and topology visual effects +/// style: flat, glass, pixel, aurora, brutal, neumorphic final mockLightThemeData = ThemeJsonConfig.fromJson({ - 'style': 'flat', - 'visualEffects': 0, + 'style': 'brutal', + 'visualEffects': 15, 'brightness': 'light', + 'globalOverlay': 'none', }).createLightTheme(); final mockDarkThemeData = ThemeJsonConfig.fromJson({ - 'style': 'flat', - 'visualEffects': 0, + 'style': 'brutal', + 'visualEffects': 15, 'brightness': 'dark', + 'globalOverlay': 'none', }).createDarkTheme(); /// diff --git a/test/page/firmware_update/views/localizations/firmware_update_detail_view_test.dart b/test/page/firmware_update/views/localizations/firmware_update_detail_view_test.dart index 477f43375..3d35f1327 100644 --- a/test/page/firmware_update/views/localizations/firmware_update_detail_view_test.dart +++ b/test/page/firmware_update/views/localizations/firmware_update_detail_view_test.dart @@ -186,7 +186,7 @@ void main() { expect(find.text(testHelper.loc(context).firmwareUpdate), findsNothing); // Verify progress indicator and texts - expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.byType(AppLoader), findsOneWidget); expect(find.text('Linksys03056'), findsOneWidget); expect(find.text(testHelper.loc(context).firmwareInstallingTitle), findsOneWidget); @@ -368,7 +368,6 @@ void main() { // Verify GridView.builder is present for multiple updating nodes expect(find.byType(GridView), findsOneWidget); - await testHelper.takeScreenshot(tester, 'XXXXX-FUDV-UPDATING_4_NODES'); // Verify progress indicators and texts for all four nodes expect(find.byType(AppLoader), findsNWidgets(4)); expect(find.text('Linksys03056'), findsOneWidget); diff --git a/test/page/nodes/localizations/node_detail_view_test.dart b/test/page/nodes/localizations/node_detail_view_test.dart index 0fadb01bf..ac94bceaa 100644 --- a/test/page/nodes/localizations/node_detail_view_test.dart +++ b/test/page/nodes/localizations/node_detail_view_test.dart @@ -124,8 +124,6 @@ void main() { expect(find.text(loc.modelNumber), findsOneWidget); expect(find.text(loc.serialNumber), findsOneWidget); expect(find.text(loc.macAddress), findsOneWidget); - // testHelper.takeScreenshot(tester, 'XXXXX-NDVL-INFO-01-desktop'); - // Verify device tab elements expect(find.text(loc.nDevices(_deviceList.length)), findsOneWidget); expect(find.text(loc.filters), findsWidgets); diff --git a/test/theme/theme_json_config_test.dart b/test/theme/theme_json_config_test.dart index 8e21237f3..c6db4f6eb 100644 --- a/test/theme/theme_json_config_test.dart +++ b/test/theme/theme_json_config_test.dart @@ -1,58 +1,71 @@ -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:privacy_gui/theme/theme_json_config.dart'; void main() { group('ThemeJsonConfig', () { - test('defaultConfig should return glass style and light/dark brightness', - () { - final config = ThemeJsonConfig.defaultConfig(); - // Since we can't verify private fields, we verify behavior via createTheme - final lightTheme = config.createLightTheme(); - final darkTheme = config.createDarkTheme(); - - expect(lightTheme.brightness, Brightness.light); - expect(darkTheme.brightness, Brightness.dark); - }); + test('parses visualEffects correctly', () { + final json = { + 'style': 'glass', + 'visualEffects': 123, + }; - test('fromJsonString should parse valid JSON correctly', () { - const jsonStr = ''' - { - "style": "flat", - "seedColor": "#FF0000", - "colors": { - "light": { "primary": "#00FF00" } - } - } - '''; - final config = ThemeJsonConfig.fromJsonString(jsonStr); - final lightTheme = config.createLightTheme(); - - // Verify seed color usage (indirectly or if feasible to check theme properties) - // Note: Checking exact generated colors relies on ui_kit implementation details. - // Here we check basic valid construction. - expect(lightTheme, isNotNull); - expect(lightTheme.brightness, Brightness.light); + final config = ThemeJsonConfig.fromJson(json); + + expect(config.lightJson['visualEffects'], 123); + expect(config.darkJson['visualEffects'], 123); }); - test('fromJsonString should return default config for empty string', () { - final config = ThemeJsonConfig.fromJsonString(''); - final lightTheme = config.createLightTheme(); - expect(lightTheme.brightness, Brightness.light); + test('parses globalOverlay correctly when present', () { + final json = { + 'style': 'glass', + 'globalOverlay': 'none', + }; + + final config = ThemeJsonConfig.fromJson(json); + + expect(config.lightJson['globalOverlay'], 'none'); + expect(config.darkJson['globalOverlay'], 'none'); }); - test('fromJsonString should return default config for invalid JSON', () { - final config = ThemeJsonConfig.fromJsonString('{invalid-json}'); - final lightTheme = config.createLightTheme(); - expect(lightTheme, isNotNull); + test('omits globalOverlay when absent', () { + final json = { + 'style': 'glass', + }; + + final config = ThemeJsonConfig.fromJson(json); + + expect(config.lightJson.containsKey('globalOverlay'), isFalse); + expect(config.darkJson.containsKey('globalOverlay'), isFalse); }); - test('createLightTheme with overrideSeedColor should override config', () { - final config = ThemeJsonConfig.defaultConfig(); - final lightTheme = config.createLightTheme(Colors.blue); - expect(lightTheme, isNotNull); - // Ideally we would check if the theme is seeded with blue, but generated colors are complex. - // At least ensuring no crash. + test('parses all fields correctly in full json', () { + final json = { + 'style': 'glass', + 'visualEffects': 42, + 'globalOverlay': 'liquid', + 'seedColor': '#FF0000', + 'colors': { + 'light': {'primary': '#00FF00'}, + 'dark': {'primary': '#0000FF'} + } + }; + + final config = ThemeJsonConfig.fromJson(json); + + // Verify light theme json + expect(config.lightJson['style'], 'glass'); + expect(config.lightJson['visualEffects'], 42); + expect(config.lightJson['globalOverlay'], 'liquid'); + expect(config.lightJson['seedColor'], '#FF0000'); + expect(config.lightJson['primary'], '#00FF00'); + + // Verify dark theme json + expect(config.darkJson['style'], 'glass'); + expect(config.darkJson['visualEffects'], 42); + expect(config.darkJson['globalOverlay'], 'liquid'); + // seedColor should be propagated if present in root + expect(config.darkJson['seedColor'], '#FF0000'); + expect(config.darkJson['primary'], '#0000FF'); }); }); } From 4a1433001494e9144931adf9604653a8fe9ac146 Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Tue, 10 Feb 2026 15:48:42 +0800 Subject: [PATCH 4/5] chore: add tool scripts --- test/common/theme_data.dart | 4 +- tool/generate_screenshot_browser.dart | 978 ++++++++++++++++++++++++++ 2 files changed, 980 insertions(+), 2 deletions(-) create mode 100644 tool/generate_screenshot_browser.dart diff --git a/test/common/theme_data.dart b/test/common/theme_data.dart index 39370f7c4..038f4b7d4 100644 --- a/test/common/theme_data.dart +++ b/test/common/theme_data.dart @@ -12,14 +12,14 @@ import 'package:privacy_gui/theme/theme_json_config.dart'; /// To avoid animation in snapshots, don't enable shimmer and topology visual effects /// style: flat, glass, pixel, aurora, brutal, neumorphic final mockLightThemeData = ThemeJsonConfig.fromJson({ - 'style': 'brutal', + 'style': 'glass', 'visualEffects': 15, 'brightness': 'light', 'globalOverlay': 'none', }).createLightTheme(); final mockDarkThemeData = ThemeJsonConfig.fromJson({ - 'style': 'brutal', + 'style': 'glass', 'visualEffects': 15, 'brightness': 'dark', 'globalOverlay': 'none', diff --git a/tool/generate_screenshot_browser.dart b/tool/generate_screenshot_browser.dart new file mode 100644 index 000000000..ef418e8d5 --- /dev/null +++ b/tool/generate_screenshot_browser.dart @@ -0,0 +1,978 @@ +import 'dart:convert'; +import 'dart:io'; + +/// Generates an interactive HTML browser for screenshot test outputs. +/// +/// Usage: dart run tool/generate_screenshot_browser.dart [options] +/// +/// Options: +/// --output, -o Output HTML path (default: snapshots/screenshot_browser.html) +/// --locales, -l Comma-separated list of locales to include (default: all) +/// --devices, -d Comma-separated list of devices to include (default: all) +/// --page-size, -p Number of items per page (default: 50) +/// --thumbnails Generate thumbnails (requires ImageMagick) +/// +/// Examples: +/// dart run tool/generate_screenshot_browser.dart +/// dart run tool/generate_screenshot_browser.dart -l en,ja,zh-TW -d Device480w,Device1280w +/// dart run tool/generate_screenshot_browser.dart --page-size 30 +void main(List args) async { + final config = parseArgs(args); + + final snapshotsDir = Directory('snapshots'); + if (!snapshotsDir.existsSync()) { + stderr.writeln('Error: snapshots directory not found'); + exit(1); + } + + stdout.writeln('Scanning snapshots directory...'); + var screenshots = await scanScreenshots(snapshotsDir); + stdout.writeln('Found ${screenshots.length} total screenshots'); + + // Apply locale/device filters + if (config.locales.isNotEmpty) { + screenshots = screenshots.where((s) => config.locales.contains(s['locale'])).toList(); + stdout.writeln('Filtered to ${screenshots.length} screenshots (locales: ${config.locales.join(', ')})'); + } + + if (config.devices.isNotEmpty) { + screenshots = screenshots.where((s) => config.devices.contains(s['device'])).toList(); + stdout.writeln('Filtered to ${screenshots.length} screenshots (devices: ${config.devices.join(', ')})'); + } + + // Extract unique values for filters + final locales = screenshots.map((s) => s['locale'] as String).toSet().toList()..sort(); + final devices = screenshots.map((s) => s['device'] as String).toSet().toList()..sort(); + final groups = screenshots.map((s) => s['group'] as String).toSet().toList()..sort(); + + stdout.writeln('Locales: ${locales.length}'); + stdout.writeln('Device types: ${devices.length}'); + stdout.writeln('Feature groups: ${groups.length}'); + + // Generate thumbnails if requested + if (config.generateThumbnails) { + await generateThumbnails(snapshotsDir, screenshots); + } + + final html = generateHtml( + screenshots, + locales, + devices, + groups, + pageSize: config.pageSize, + hasThumbnails: config.generateThumbnails, + ); + + final outputFile = File(config.outputPath); + await outputFile.writeAsString(html); + stdout.writeln('Generated: ${config.outputPath}'); + + // Print size statistics + final htmlSize = outputFile.lengthSync(); + stdout.writeln('HTML size: ${(htmlSize / 1024).toStringAsFixed(1)} KB'); + + // Estimate total image size + var totalImageSize = 0; + for (final s in screenshots) { + final file = File('${snapshotsDir.path}/${s['path']}'); + if (file.existsSync()) { + totalImageSize += file.lengthSync(); + } + } + stdout.writeln('Total image size: ${(totalImageSize / 1024 / 1024).toStringAsFixed(1)} MB'); +} + +class Config { + final String outputPath; + final List locales; + final List devices; + final int pageSize; + final bool generateThumbnails; + + Config({ + required this.outputPath, + required this.locales, + required this.devices, + required this.pageSize, + required this.generateThumbnails, + }); +} + +Config parseArgs(List args) { + String outputPath = 'snapshots/screenshot_browser.html'; + List locales = []; + List devices = []; + int pageSize = 50; + bool generateThumbnails = false; + + for (var i = 0; i < args.length; i++) { + switch (args[i]) { + case '--output': + case '-o': + outputPath = args[++i]; + break; + case '--locales': + case '-l': + locales = args[++i].split(',').map((s) => s.trim()).toList(); + break; + case '--devices': + case '-d': + devices = args[++i].split(',').map((s) => s.trim()).toList(); + break; + case '--page-size': + case '-p': + pageSize = int.parse(args[++i]); + break; + case '--thumbnails': + generateThumbnails = true; + break; + } + } + + return Config( + outputPath: outputPath, + locales: locales, + devices: devices, + pageSize: pageSize, + generateThumbnails: generateThumbnails, + ); +} + +Future>> scanScreenshots(Directory dir) async { + final screenshots = >[]; + + // First try to load from JSON report if available + final jsonFiles = dir.listSync().whereType().where( + (f) => f.path.endsWith('.json') && f.path.contains('test-reports') + ); + + final jsonData = >[]; + for (final jsonFile in jsonFiles) { + try { + final content = await jsonFile.readAsString(); + final List data = jsonDecode(content); + jsonData.addAll(data.cast>()); + } catch (e) { + stderr.writeln('Warning: Could not parse ${jsonFile.path}: $e'); + } + } + + if (jsonData.isNotEmpty) { + for (final item in jsonData) { + final filePath = item['filePath'] as String?; + if (filePath == null) continue; + + final fullPath = '${dir.path}/$filePath'; + if (!File(fullPath).existsSync()) continue; + + final tsName = item['tsName'] as String? ?? _extractName(filePath); + screenshots.add({ + 'path': filePath, + 'locale': item['locale'] ?? _extractLocale(filePath), + 'device': item['deviceType'] ?? _extractDevice(filePath), + 'group': _extractScreenId(tsName), + 'name': tsName, + 'result': item['result'] ?? 'unknown', + 'testFile': item['testCaseFilePath'] ?? '', + }); + } + } + + // Also scan directories for any screenshots not in JSON + await for (final entity in dir.list(recursive: true)) { + if (entity is! File) continue; + if (!entity.path.endsWith('.png')) continue; + if (entity.path.contains('/thumbnails/')) continue; + + final relativePath = entity.path.replaceFirst('${dir.path}/', ''); + + // Skip if already in JSON data + if (screenshots.any((s) => s['path'] == relativePath)) continue; + + // Parse path structure: locale/device/filename.png or just filename.png + final parts = relativePath.split('/'); + + String locale = 'root'; + String device = 'default'; + String name = parts.last.replaceAll('.png', ''); + + if (parts.length >= 3) { + locale = parts[0]; + device = parts[1]; + name = _extractName(parts.last); + } else if (parts.length == 2) { + locale = parts[0]; + name = _extractName(parts.last); + } + + screenshots.add({ + 'path': relativePath, + 'locale': locale, + 'device': device, + 'group': _extractScreenId(name), + 'name': name, + 'result': 'unknown', + 'testFile': '', + }); + } + + return screenshots; +} + +String _extractLocale(String path) { + final match = RegExp(r'-([a-z]{2}(?:-[A-Z]{2})?)\.png$').firstMatch(path); + return match?.group(1) ?? 'unknown'; +} + +String _extractDevice(String path) { + final match = RegExp(r'Device\d+w').firstMatch(path); + return match?.group(0) ?? 'default'; +} + +String _extractName(String filename) { + return filename + .replaceAll('.png', '') + .replaceAll(RegExp(r'-Device\d+w.*$'), '') + .trim(); +} + +/// Extracts screen ID from filename or tsName for grouping. +/// +/// Groups screenshots by screen/feature area for easier browsing. +String _extractScreenId(String name) { + // Pattern 1: Uppercase prefix with dash/underscore (e.g., PNPS-STEP1_WIFI_01) + final prefixMatch = RegExp(r'^([A-Z]+-[A-Z0-9_]+)').firstMatch(name); + if (prefixMatch != null) { + final prefix = prefixMatch.group(1)!; + // Extract main screen ID: PNPS-STEP1_WIFI_01 → PNPS + return prefix.split('-').first; + } + + // Pattern 2: Simple uppercase abbreviation (DDNS, DMZ, VPN) + final abbrevMatch = RegExp(r'^([A-Z]{2,})(?:\s|-)').firstMatch(name); + if (abbrevMatch != null) { + return abbrevMatch.group(1)!; + } + + // Pattern 3: "xxx view - ..." or "xxx view ..." pattern + final viewMatch = RegExp(r'^(.+?)\s+view(?:\s+-|\s|$)', caseSensitive: false).firstMatch(name); + if (viewMatch != null) { + return '${viewMatch.group(1)} View'; + } + + // Pattern 4: "dashboard xxx view - ..." pattern + if (name.startsWith('dashboard ')) { + final parts = name.split(' '); + if (parts.length >= 3) { + return 'Dashboard ${_capitalize(parts[1])}'; + } + return 'Dashboard'; + } + + // Pattern 5: "instant xxx view - ..." pattern + if (name.startsWith('instant ')) { + final parts = name.split(' '); + if (parts.length >= 3) { + return 'Instant ${_capitalize(parts[1])}'; + } + return 'Instant'; + } + + // Pattern 6: "local xxx view - ..." pattern + if (name.startsWith('local ')) { + final parts = name.split(' '); + if (parts.length >= 3) { + return 'Local ${_capitalize(parts[1])}'; + } + return 'Local'; + } + + // Pattern 7: "node xxx view - ..." pattern + if (name.startsWith('node ')) { + return 'Node Detail'; + } + + // Pattern 8: "login xxx view - ..." pattern + if (name.startsWith('login ')) { + return 'Login'; + } + + // Pattern 9: "device xxx view - ..." pattern + if (name.startsWith('device ')) { + return 'Device Detail'; + } + + // Pattern 10: Test case patterns - group by test type + if (name.startsWith('Verify ') || name.startsWith('It should') || + name.startsWith('should ') || name.startsWith('WHEN ')) { + // Try to extract feature from test name + if (name.contains('speed test') || name.contains('Speedtest')) return 'Speed Test'; + if (name.contains('WiFi') || name.contains('wifi') || name.contains('SSID')) return 'WiFi'; + if (name.contains('MAC')) return 'MAC Filtering'; + if (name.contains('VPN')) return 'VPN'; + if (name.contains('firmware') || name.contains('Firmware')) return 'Firmware'; + if (name.contains('PPPoE') || name.contains('DHCP') || name.contains('ISP')) return 'Internet Settings'; + if (name.contains('static route')) return 'Static Routing'; + if (name.contains('banner') || name.contains('install')) return 'Install Prompt'; + if (name.contains('password') || name.contains('login') || name.contains('admin')) return 'Authentication'; + if (name.contains('modem') || name.contains('router')) return 'Setup Wizard'; + if (name.contains('node')) return 'Node'; + if (name.contains('channel') || name.contains('security mode')) return 'WiFi Advanced'; + return 'Verification Tests'; + } + + // Pattern 11: "status: xxx" pattern + if (name.startsWith('status:')) { + return 'PNP Status'; + } + + // Pattern 12: Port forwarding related + if (name.contains('port forwarding') || name.contains('Port forwarding')) { + return 'Port Forwarding'; + } + if (name.contains('port triggerring') || name.contains('Port triggerring')) { + return 'Port Triggering'; + } + + // Pattern 13: Other specific patterns + if (name.contains('Snack bar')) return 'Snack Bar'; + if (name.contains('Dialog')) return 'Dialogs'; + if (name.contains('General Settings')) return 'General Settings'; + if (name.contains('Manual firmware')) return 'Firmware'; + + // Default: take first meaningful word(s) + final words = name.split(' '); + if (words.isNotEmpty) { + return _capitalize(words.first); + } + + return 'Other'; +} + +String _capitalize(String s) { + if (s.isEmpty) return s; + return s[0].toUpperCase() + s.substring(1).toLowerCase(); +} + +Future generateThumbnails(Directory snapshotsDir, List> screenshots) async { + final thumbDir = Directory('${snapshotsDir.path}/thumbnails'); + if (!thumbDir.existsSync()) { + thumbDir.createSync(recursive: true); + } + + stdout.writeln('Generating thumbnails...'); + + for (var i = 0; i < screenshots.length; i++) { + final s = screenshots[i]; + final sourcePath = '${snapshotsDir.path}/${s['path']}'; + final thumbPath = '${thumbDir.path}/${s['path']}'.replaceAll('.png', '_thumb.jpg'); + + final thumbFile = File(thumbPath); + if (thumbFile.existsSync()) continue; + + // Create directory structure + final thumbParent = thumbFile.parent; + if (!thumbParent.existsSync()) { + thumbParent.createSync(recursive: true); + } + + // Use ImageMagick to create thumbnail + final result = await Process.run('convert', [ + sourcePath, + '-resize', '300x', + '-quality', '80', + thumbPath, + ]); + + if (result.exitCode != 0) { + stderr.writeln('Warning: Failed to create thumbnail for ${s['path']}'); + } + + if ((i + 1) % 100 == 0) { + stdout.writeln('Generated ${i + 1}/${screenshots.length} thumbnails'); + } + } + + stdout.writeln('Thumbnails complete'); +} + +String generateHtml( + List> screenshots, + List locales, + List devices, + List groups, + {int pageSize = 50, bool hasThumbnails = false} +) { + return ''' + + + + + + Screenshot Browser - PrivacyGUI + + + +
+

Screenshot Browser - PrivacyGUI

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ 0 screenshots +
+
+
+ +
+ + +
+ + + +
+ Esc Close   + Navigate   + / Search +
+ + + + +'''; +} From 8e4fbd0c070c66e9e10b18c864eea0f3e749db00 Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Tue, 10 Feb 2026 15:59:45 +0800 Subject: [PATCH 5/5] formatted --- tool/generate_screenshot_browser.dart | 83 ++++++++++++++++----------- 1 file changed, 51 insertions(+), 32 deletions(-) diff --git a/tool/generate_screenshot_browser.dart b/tool/generate_screenshot_browser.dart index ef418e8d5..ab91c2a78 100644 --- a/tool/generate_screenshot_browser.dart +++ b/tool/generate_screenshot_browser.dart @@ -31,19 +31,26 @@ void main(List args) async { // Apply locale/device filters if (config.locales.isNotEmpty) { - screenshots = screenshots.where((s) => config.locales.contains(s['locale'])).toList(); - stdout.writeln('Filtered to ${screenshots.length} screenshots (locales: ${config.locales.join(', ')})'); + screenshots = + screenshots.where((s) => config.locales.contains(s['locale'])).toList(); + stdout.writeln( + 'Filtered to ${screenshots.length} screenshots (locales: ${config.locales.join(', ')})'); } if (config.devices.isNotEmpty) { - screenshots = screenshots.where((s) => config.devices.contains(s['device'])).toList(); - stdout.writeln('Filtered to ${screenshots.length} screenshots (devices: ${config.devices.join(', ')})'); + screenshots = + screenshots.where((s) => config.devices.contains(s['device'])).toList(); + stdout.writeln( + 'Filtered to ${screenshots.length} screenshots (devices: ${config.devices.join(', ')})'); } // Extract unique values for filters - final locales = screenshots.map((s) => s['locale'] as String).toSet().toList()..sort(); - final devices = screenshots.map((s) => s['device'] as String).toSet().toList()..sort(); - final groups = screenshots.map((s) => s['group'] as String).toSet().toList()..sort(); + final locales = screenshots.map((s) => s['locale'] as String).toSet().toList() + ..sort(); + final devices = screenshots.map((s) => s['device'] as String).toSet().toList() + ..sort(); + final groups = screenshots.map((s) => s['group'] as String).toSet().toList() + ..sort(); stdout.writeln('Locales: ${locales.length}'); stdout.writeln('Device types: ${devices.length}'); @@ -79,7 +86,8 @@ void main(List args) async { totalImageSize += file.lengthSync(); } } - stdout.writeln('Total image size: ${(totalImageSize / 1024 / 1024).toStringAsFixed(1)} MB'); + stdout.writeln( + 'Total image size: ${(totalImageSize / 1024 / 1024).toStringAsFixed(1)} MB'); } class Config { @@ -143,8 +151,7 @@ Future>> scanScreenshots(Directory dir) async { // First try to load from JSON report if available final jsonFiles = dir.listSync().whereType().where( - (f) => f.path.endsWith('.json') && f.path.contains('test-reports') - ); + (f) => f.path.endsWith('.json') && f.path.contains('test-reports')); final jsonData = >[]; for (final jsonFile in jsonFiles) { @@ -255,7 +262,8 @@ String _extractScreenId(String name) { } // Pattern 3: "xxx view - ..." or "xxx view ..." pattern - final viewMatch = RegExp(r'^(.+?)\s+view(?:\s+-|\s|$)', caseSensitive: false).firstMatch(name); + final viewMatch = RegExp(r'^(.+?)\s+view(?:\s+-|\s|$)', caseSensitive: false) + .firstMatch(name); if (viewMatch != null) { return '${viewMatch.group(1)} View'; } @@ -303,21 +311,32 @@ String _extractScreenId(String name) { } // Pattern 10: Test case patterns - group by test type - if (name.startsWith('Verify ') || name.startsWith('It should') || - name.startsWith('should ') || name.startsWith('WHEN ')) { + if (name.startsWith('Verify ') || + name.startsWith('It should') || + name.startsWith('should ') || + name.startsWith('WHEN ')) { // Try to extract feature from test name - if (name.contains('speed test') || name.contains('Speedtest')) return 'Speed Test'; - if (name.contains('WiFi') || name.contains('wifi') || name.contains('SSID')) return 'WiFi'; + if (name.contains('speed test') || name.contains('Speedtest')) + return 'Speed Test'; + if (name.contains('WiFi') || name.contains('wifi') || name.contains('SSID')) + return 'WiFi'; if (name.contains('MAC')) return 'MAC Filtering'; if (name.contains('VPN')) return 'VPN'; - if (name.contains('firmware') || name.contains('Firmware')) return 'Firmware'; - if (name.contains('PPPoE') || name.contains('DHCP') || name.contains('ISP')) return 'Internet Settings'; + if (name.contains('firmware') || name.contains('Firmware')) + return 'Firmware'; + if (name.contains('PPPoE') || name.contains('DHCP') || name.contains('ISP')) + return 'Internet Settings'; if (name.contains('static route')) return 'Static Routing'; - if (name.contains('banner') || name.contains('install')) return 'Install Prompt'; - if (name.contains('password') || name.contains('login') || name.contains('admin')) return 'Authentication'; - if (name.contains('modem') || name.contains('router')) return 'Setup Wizard'; + if (name.contains('banner') || name.contains('install')) + return 'Install Prompt'; + if (name.contains('password') || + name.contains('login') || + name.contains('admin')) return 'Authentication'; + if (name.contains('modem') || name.contains('router')) + return 'Setup Wizard'; if (name.contains('node')) return 'Node'; - if (name.contains('channel') || name.contains('security mode')) return 'WiFi Advanced'; + if (name.contains('channel') || name.contains('security mode')) + return 'WiFi Advanced'; return 'Verification Tests'; } @@ -354,7 +373,8 @@ String _capitalize(String s) { return s[0].toUpperCase() + s.substring(1).toLowerCase(); } -Future generateThumbnails(Directory snapshotsDir, List> screenshots) async { +Future generateThumbnails( + Directory snapshotsDir, List> screenshots) async { final thumbDir = Directory('${snapshotsDir.path}/thumbnails'); if (!thumbDir.existsSync()) { thumbDir.createSync(recursive: true); @@ -365,7 +385,8 @@ Future generateThumbnails(Directory snapshotsDir, List generateThumbnails(Directory snapshotsDir, List generateThumbnails(Directory snapshotsDir, List> screenshots, - List locales, - List devices, - List groups, - {int pageSize = 50, bool hasThumbnails = false} -) { +String generateHtml(List> screenshots, + List locales, List devices, List groups, + {int pageSize = 50, bool hasThumbnails = false}) { return '''