diff --git a/lib/page/dashboard/views/components/effects/jiggle_shake.dart b/lib/page/dashboard/views/components/effects/jiggle_shake.dart index 702142ab7..cb414e05f 100644 --- a/lib/page/dashboard/views/components/effects/jiggle_shake.dart +++ b/lib/page/dashboard/views/components/effects/jiggle_shake.dart @@ -26,6 +26,10 @@ class _JiggleShakeState extends State late final Animation _animation; final _random = Random(); + /// Platform "reduce motion" accessibility flag. Read from MediaQuery in + /// didChangeDependencies (not initState, where inherited widgets are unsafe). + bool _reduceMotion = false; + @override void initState() { super.initState(); @@ -42,28 +46,38 @@ class _JiggleShakeState extends State begin: startPositive ? -maxRad : maxRad, end: startPositive ? maxRad : -maxRad, ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut)); + } - if (widget.active) { - _startShaking(); - } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // MediaQuery is only safe to read here — not in initState. + _reduceMotion = MediaQuery.maybeDisableAnimationsOf(context) ?? false; + _syncAnimation(); } @override void didUpdateWidget(covariant JiggleShake oldWidget) { super.didUpdateWidget(oldWidget); if (widget.active != oldWidget.active) { - if (widget.active) { - _startShaking(); - } else { - _stopShaking(); - } + _syncAnimation(); + } + } + + /// Starts or stops the shake based on [active] and the reduce-motion flag. + void _syncAnimation() { + if (widget.active && !_reduceMotion) { + _startShaking(); + } else { + _stopShaking(); } } void _startShaking() { + if (_controller.isAnimating) return; // Add a tiny random delay before starting to enhance the organic feel Future.delayed(Duration(milliseconds: _random.nextInt(50)), () { - if (mounted && widget.active) { + if (mounted && widget.active && !_reduceMotion) { _controller.repeat(reverse: true); } }); @@ -71,7 +85,6 @@ class _JiggleShakeState extends State void _stopShaking() { _controller.stop(); - _controller.animateTo(0.5); // Reset to center (approx) or just reset _controller.reset(); } @@ -83,13 +96,14 @@ class _JiggleShakeState extends State @override Widget build(BuildContext context) { - // Optimization: If not active and not animating, return child directly (or Rotation with 0) - // But RotationTransition with 0 turns is cheap. return AnimatedBuilder( animation: _animation, builder: (context, child) { - // If inactive, force 0 rotation - final turns = widget.active ? _animation.value : 0.0; + // build() is the single source of truth for the rendered angle: + // force 0 when inactive or when reduce-motion is on (deterministic, + // static rendering — also what golden tests rely on). + final turns = + (widget.active && !_reduceMotion) ? _animation.value : 0.0; return Transform.rotate( angle: turns, child: child, diff --git a/run_golden_verify.sh b/run_golden_verify.sh index 40027e079..954282969 100755 --- a/run_golden_verify.sh +++ b/run_golden_verify.sh @@ -9,20 +9,13 @@ else DART="dart" fi -EMBED_FLAG="" - -while getopts l:s:f:v:-: flag +while getopts l:s:f:v: flag do case "${flag}" in l) locales=${OPTARG};; s) screens=${OPTARG};; f) file=${OPTARG};; v) version=${OPTARG};; - -) - case "${OPTARG}" in - embed) EMBED_FLAG="--embed";; - esac - ;; esac done @@ -43,7 +36,6 @@ echo "*********************Golden Test Verification********************" echo "Locales: $locales" echo "Screens: $screens" echo "Version: $version" -echo "Embed images: ${EMBED_FLAG:-no}" if [ -z "$file" ]; then IFS=',' read -ra LOCS <<< "$locales" @@ -57,7 +49,7 @@ if [ -z "$file" ]; then rm -f $REPORT_DIR/tests.json done - $DART run test_scripts/combine_results.dart $REPORT_DIR "$version" $EMBED_FLAG || FAILED=1 + $DART run test_scripts/combine_results.dart $REPORT_DIR "$version" || FAILED=1 echo "" echo "Report generated: $REPORT_DIR/golden_verify_report.html" else @@ -68,7 +60,7 @@ else --dart-define=visualEffects=0 || FAILED=1 $DART run test_scripts/test_result_parser.dart $REPORT_DIR/tests.json "$locales" || FAILED=1 rm -f $REPORT_DIR/tests.json - $DART run test_scripts/combine_results.dart $REPORT_DIR "$version" $EMBED_FLAG || FAILED=1 + $DART run test_scripts/combine_results.dart $REPORT_DIR "$version" || FAILED=1 echo "" echo "Report generated: $REPORT_DIR/golden_verify_report.html" fi diff --git a/test/golden_test/golden_framework/golden_interactions.dart b/test/golden_test/golden_framework/golden_interactions.dart new file mode 100644 index 000000000..fefcf5050 --- /dev/null +++ b/test/golden_test/golden_framework/golden_interactions.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Shared interaction helpers for golden `Interaction.steps`. +/// +/// These exist to keep interaction steps locale-independent and free of +/// brittle geometry-based gestures. + +/// Switches to the tab at [index] by driving the [TabController] directly, +/// then pumps until the tab-change animation settles. +/// +/// Why not `tester.tap(find.byType(Tab).at(index))`? The USP pages use a +/// non-scrollable TabBar (`TabAlignment.fill`), so long localized labels +/// (e.g. Danish, German, Russian) push the rightmost tab's center off-screen. +/// A geometric tap then lands outside the surface, misses, and the tab never +/// switches — breaking every interaction that expects that tab's content, but +/// only in the locales with long labels. Driving the controller expresses the +/// real intent ("show the Nth tab") and works in every locale. +/// +/// Assumes exactly one [TabBar] is on screen (the common case for USP detail +/// views). Pass [tabBarFinder] to disambiguate if a view ever nests TabBars. +Future switchToTab( + WidgetTester tester, + int index, { + Finder? tabBarFinder, +}) async { + final finder = tabBarFinder ?? find.byType(TabBar); + final tabBar = tester.widget(finder.first); + final controller = tabBar.controller; + assert(controller != null, 'TabBar has no controller to drive.'); + controller!.animateTo(index); + + // Let the tab-change + TabBarView transition settle. TabController animations + // run ~300ms; pump generously past that so the target tab's content is fully + // built and interactive before the caller queries the tree. + await tester.pump(); + for (int i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 50)); + } +} diff --git a/test/golden_test/golden_framework/golden_runner.dart b/test/golden_test/golden_framework/golden_runner.dart index c5b60fcd6..bab134ff0 100644 --- a/test/golden_test/golden_framework/golden_runner.dart +++ b/test/golden_test/golden_framework/golden_runner.dart @@ -314,6 +314,17 @@ Widget _buildGoldenWidget( themeMode: brightness == Brightness.dark ? ThemeMode.dark : ThemeMode.light, + // Force "reduce motion" for every golden so non-deterministic / + // looping animations (e.g. dashboard JiggleShake) render at a + // fixed, static frame. Applied inside the app via builder so the + // views under test actually observe disableAnimations: true + // (a MediaQuery wrapped outside MaterialApp would be overridden). + // This does NOT touch the global diffThreshold; it only removes the + // animation source of flakiness. + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(disableAnimations: true), + child: child ?? const SizedBox.shrink(), + ), routerConfig: router, ), ), diff --git a/test/golden_test/page/port_forwarding/localizations/usp_port_forwarding_detail_view_test.dart b/test/golden_test/page/port_forwarding/localizations/usp_port_forwarding_detail_view_test.dart index 6c8f093dd..c95d4f4d9 100644 --- a/test/golden_test/page/port_forwarding/localizations/usp_port_forwarding_detail_view_test.dart +++ b/test/golden_test/page/port_forwarding/localizations/usp_port_forwarding_detail_view_test.dart @@ -9,6 +9,7 @@ import 'package:privacy_gui/page/port_forwarding/views/components/usp_single_por import 'package:privacy_gui/page/port_forwarding/views/usp_port_forwarding_detail_view.dart'; import 'package:ui_kit_library/ui_kit.dart' show AppIconButton; +import '../../../golden_framework/golden_interactions.dart'; import '../../../golden_framework/golden_runner.dart'; import '../../../golden_framework/golden_test_config.dart'; import '../../../golden_framework/mocks/mock_port_forwarding.dart'; @@ -38,9 +39,7 @@ void main() { portForwardingOverrides(dataState()), ), steps: (tester) async { - await tester.tap(find.byType(Tab).at(1)); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 300)); + await switchToTab(tester, 1); }, ), 'tab_port_triggering': Interaction( @@ -48,9 +47,7 @@ void main() { portForwardingOverrides(dataState()), ), steps: (tester) async { - await tester.tap(find.byType(Tab).at(2)); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 300)); + await switchToTab(tester, 2); }, ), // --- Empty tab views --- @@ -59,9 +56,7 @@ void main() { portForwardingOverrides(emptyDataState), ), steps: (tester) async { - await tester.tap(find.byType(Tab).at(1)); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 300)); + await switchToTab(tester, 1); }, ), 'empty_port_triggering': Interaction( @@ -69,9 +64,7 @@ void main() { portForwardingOverrides(emptyDataState), ), steps: (tester) async { - await tester.tap(find.byType(Tab).at(2)); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 300)); + await switchToTab(tester, 2); }, ), // --- Add dialogs --- @@ -94,11 +87,7 @@ void main() { portForwardingOverrides(dataState()), ), steps: (tester) async { - await tester.tap(find.byType(Tab).at(1)); - await tester.pump(); - for (int i = 0; i < 10; i++) { - await tester.pump(const Duration(milliseconds: 50)); - } + await switchToTab(tester, 1); final addBtn = find.descendant( of: find.byType(UspPortRangeTab), matching: find.byType(AppIconButton), @@ -115,11 +104,7 @@ void main() { portForwardingOverrides(dataState()), ), steps: (tester) async { - await tester.tap(find.byType(Tab).at(2)); - await tester.pump(); - for (int i = 0; i < 10; i++) { - await tester.pump(const Duration(milliseconds: 50)); - } + await switchToTab(tester, 2); final addBtn = find.descendant( of: find.byType(UspPortTriggeringTab), matching: find.byType(AppIconButton), @@ -154,11 +139,7 @@ void main() { portForwardingOverrides(dataState()), ), steps: (tester) async { - await tester.tap(find.byType(Tab).at(1)); - await tester.pump(); - for (int i = 0; i < 10; i++) { - await tester.pump(const Duration(milliseconds: 50)); - } + await switchToTab(tester, 1); final buttons = find.descendant( of: find.byType(UspPortRangeTab), matching: find.byType(AppIconButton), @@ -175,11 +156,7 @@ void main() { portForwardingOverrides(dataState()), ), steps: (tester) async { - await tester.tap(find.byType(Tab).at(2)); - await tester.pump(); - for (int i = 0; i < 10; i++) { - await tester.pump(const Duration(milliseconds: 50)); - } + await switchToTab(tester, 2); final buttons = find.descendant( of: find.byType(UspPortTriggeringTab), matching: find.byType(AppIconButton), @@ -255,11 +232,7 @@ void main() { portForwardingOverrides(dataState()), ), steps: (tester) async { - await tester.tap(find.byType(Tab).at(1)); - await tester.pump(); - for (int i = 0; i < 10; i++) { - await tester.pump(const Duration(milliseconds: 50)); - } + await switchToTab(tester, 1); final addBtn = find.descendant( of: find.byType(UspPortRangeTab), matching: find.byType(AppIconButton), diff --git a/test/golden_test/page/statistics/localizations/usp_statistics_view_test.dart b/test/golden_test/page/statistics/localizations/usp_statistics_view_test.dart index 9c4cfb1c3..87f3c15fc 100644 --- a/test/golden_test/page/statistics/localizations/usp_statistics_view_test.dart +++ b/test/golden_test/page/statistics/localizations/usp_statistics_view_test.dart @@ -1,7 +1,6 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; import 'package:privacy_gui/page/statistics/views/usp_statistics_view.dart'; +import '../../../golden_framework/golden_interactions.dart'; import '../../../golden_framework/golden_runner.dart'; import '../../../golden_framework/golden_test_config.dart'; import '../../../golden_framework/mocks/mock_statistics.dart'; @@ -35,11 +34,7 @@ void main() { ), ), steps: (tester) async { - await tester.tap(find.byType(Tab).at(1)); - await tester.pump(); - for (int i = 0; i < 10; i++) { - await tester.pump(const Duration(milliseconds: 50)); - } + await switchToTab(tester, 1); }, ), 'tab_system': Interaction( @@ -51,11 +46,7 @@ void main() { ), ), steps: (tester) async { - await tester.tap(find.byType(Tab).at(2)); - await tester.pump(); - for (int i = 0; i < 10; i++) { - await tester.pump(const Duration(milliseconds: 50)); - } + await switchToTab(tester, 2); }, ), }, diff --git a/test/golden_test/page/wifi_settings/localizations/usp_wifi_settings_view_test.dart b/test/golden_test/page/wifi_settings/localizations/usp_wifi_settings_view_test.dart index 7599e3718..f7c39f7cd 100644 --- a/test/golden_test/page/wifi_settings/localizations/usp_wifi_settings_view_test.dart +++ b/test/golden_test/page/wifi_settings/localizations/usp_wifi_settings_view_test.dart @@ -1,7 +1,7 @@ -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:privacy_gui/page/wifi_settings/views/usp_wifi_settings_view.dart'; +import '../../../golden_framework/golden_interactions.dart'; import '../../../golden_framework/golden_runner.dart'; import '../../../golden_framework/golden_test_config.dart'; import '../../../golden_framework/mocks/mock_wifi_settings.dart'; @@ -43,11 +43,7 @@ void main() { ), ), steps: (tester) async { - await tester.tap(find.byType(Tab).at(1)); - await tester.pump(); - for (int i = 0; i < 10; i++) { - await tester.pump(const Duration(milliseconds: 50)); - } + await switchToTab(tester, 1); }, ), 'tab_advanced_dfs_off': Interaction( @@ -58,11 +54,7 @@ void main() { ), ), steps: (tester) async { - await tester.tap(find.byType(Tab).at(1)); - await tester.pump(); - for (int i = 0; i < 10; i++) { - await tester.pump(const Duration(milliseconds: 50)); - } + await switchToTab(tester, 1); }, ), 'dialog_edit_name': Interaction( @@ -73,7 +65,11 @@ void main() { ), ), steps: (tester) async { - await tester.tap(find.bySemanticsLabel('wifi-name-2.4GHz').first); + // SSID value from fixture data — locale-independent. The golden + // runner does not build a semantics tree, so bySemanticsLabel finds + // nothing; tap the SSID text like the sibling dialog interactions. + // First per-band card is 2.4GHz (ssid: 'HomeNetwork'). + await tester.tap(find.text('HomeNetwork').first); await tester.pump(); for (int i = 0; i < 10; i++) { await tester.pump(const Duration(milliseconds: 50)); diff --git a/test_scripts/combine_results.dart b/test_scripts/combine_results.dart index 89b396dc9..e092888e2 100644 --- a/test_scripts/combine_results.dart +++ b/test_scripts/combine_results.dart @@ -7,6 +7,14 @@ part 'html_generate_functions.dart'; const _coverageIgnore = ['apps', 'system_log', 'test_console']; +/// Strip the report folder prefix so [path] is relative to the report location. +String _relativeToReport(String path, String folderStr) { + if (path.startsWith('$folderStr/')) { + return path.substring(folderStr.length + 1); + } + return path; +} + List> _loadOverflowWarnings() { final file = File('goldens/overflow_warnings.json'); if (!file.existsSync()) return []; @@ -88,7 +96,6 @@ void main(List args) { exit(1); } final version = args.length > 1 ? args[1] : '0.0.0'; - final embedImages = args.contains('--embed'); // find parsed test report json files on target folder final files = folder.listSync().where((e) => @@ -125,29 +132,37 @@ void main(List args) { } final devices = deviceSet.toList(); - // Process failure image paths + // Process failure image paths — make them relative to the report location for (final test in jsonObjects) { final failureImages = test['failureImages'] as Map?; if (failureImages == null) continue; for (final key in ['expected', 'actual', 'diff']) { final path = failureImages[key] as String?; if (path == null) continue; - if (embedImages) { - final file = File(path); - if (file.existsSync()) { - final bytes = file.readAsBytesSync(); - final b64 = base64Encode(bytes); - failureImages[key] = 'data:image/png;base64,$b64'; - } - } else { - // Strip folder prefix so path is relative to the report location - if (path.startsWith('$folderStr/')) { - failureImages[key] = path.substring(folderStr.length + 1); - } else { - failureImages[key] = path; - } - } + failureImages[key] = _relativeToReport(path, folderStr); + } + } + + // Attach the golden image path for every test (including passing ones) so the + // report can link to the original golden. Goldens live in a `goldens/` + // directory next to the test file, named `{tsName}-{deviceType}-{locale}.png`. + for (final test in jsonObjects) { + final testCaseFilePath = test['testCaseFilePath'] as String?; + final tsName = test['tsName'] as String?; + final deviceType = test['deviceType'] as String?; + final locale = test['locale'] as String?; + if (testCaseFilePath == null || + tsName == null || + deviceType == null || + locale == null) { + continue; } + final relativeTestPath = testCaseFilePath.startsWith('/') + ? testCaseFilePath.substring(1) + : testCaseFilePath; + final testDir = relativeTestPath.replaceFirst(RegExp(r'/[^/]+$'), ''); + final goldenPath = '$testDir/goldens/$tsName-$deviceType-$locale.png'; + test['goldenPath'] = _relativeToReport(goldenPath, folderStr); } // Scan coverage @@ -180,7 +195,6 @@ void main(List args) { resultObj['overflowCount'] = overflowWarnings.length; resultObj['version'] = version; resultObj['timestamp'] = DateTime.now().toIso8601String(); - resultObj['embedImages'] = embedImages; final htmlReport = generateHTMLReport(resultObj, version); final reportHTMLFile = File('$folderStr/golden_verify_report.html'); diff --git a/test_scripts/html_generate_functions.dart b/test_scripts/html_generate_functions.dart index 1ac1c390a..4961f070d 100644 --- a/test_scripts/html_generate_functions.dart +++ b/test_scripts/html_generate_functions.dart @@ -184,6 +184,11 @@ String generateHTMLReport(Map result, String version) { .test-icon.fail { color: var(--color-fail); } .test-name { flex: 1; } .test-meta { font-size: 0.75rem; color: var(--color-text-muted); } + .view-golden { + font-size: 0.75rem; color: var(--color-accent); cursor: pointer; + margin-right: 0.75rem; text-decoration: none; white-space: nowrap; + } + .view-golden:hover { text-decoration: underline; } .overflow-badge { font-size: 0.65rem; padding: 0.125rem 0.375rem; border-radius: 3px; background: #fef3c7; color: #92400e; @@ -598,6 +603,10 @@ String generateHTMLReport(Map result, String version) { let html = '
'; html += '' + icon + ''; html += '' + escapeHtml(name) + overflowTag + ''; + if (isPass && t.goldenPath) { + const goldenCaption = name + ' (' + (t.deviceType || '') + ' / ' + (t.locale || '') + ')'; + html += 'View golden'; + } html += '' + (t.deviceType || '') + ' / ' + (t.locale || '') + ''; html += '
'; @@ -646,14 +655,39 @@ String generateHTMLReport(Map result, String version) { return div.innerHTML; } - // Lightbox + // Lightbox — holds a list of { src, caption } items let lbImages = []; let lbIndex = 0; + // Open the comparison images (expected/actual/diff) with cross-image nav function openLightbox(img) { - lbImages = [...document.querySelectorAll('.image-comparison img[onclick]')]; - lbIndex = lbImages.indexOf(img); + const nodes = [...document.querySelectorAll('.image-comparison img[onclick]')]; + lbImages = nodes.map(function(node) { + const figure = node.closest('figure'); + const label = figure ? figure.querySelector('figcaption')?.textContent || '' : ''; + const details = figure ? figure.closest('.failure-details') : null; + const row = details ? details.previousElementSibling : null; + const testName = row ? row.querySelector('.test-name')?.textContent || '' : ''; + return { src: node.src, caption: testName + ' (' + label + ')' }; + }); + lbIndex = nodes.indexOf(img); if (lbIndex === -1) lbIndex = 0; + openLightboxAt(lbImages, lbIndex); + } + + // Open a single golden image (no cross-image nav) + function openGolden(link) { + const src = link.getAttribute('data-golden-src'); + const caption = link.getAttribute('data-golden-caption') || ''; + openLightboxAt([{ src: src, caption: caption }], 0); + } + + function openLightboxAt(images, index) { + lbImages = images; + lbIndex = index; + const single = lbImages.length <= 1; + document.querySelector('.lb-prev').style.display = single ? 'none' : ''; + document.querySelector('.lb-next').style.display = single ? 'none' : ''; showLightboxImage(); document.getElementById('lightbox').classList.add('open'); } @@ -663,20 +697,17 @@ String generateHTMLReport(Map result, String version) { } function navLightbox(dir) { + if (lbImages.length <= 1) return; lbIndex = (lbIndex + dir + lbImages.length) % lbImages.length; showLightboxImage(); } var showLightboxImage = function() { - const img = lbImages[lbIndex]; - document.getElementById('lb-img').src = img.src; - const figure = img.closest('figure'); - const label = figure ? figure.querySelector('figcaption')?.textContent || '' : ''; - const details = figure ? figure.closest('.failure-details') : null; - const row = details ? details.previousElementSibling : null; - const testName = row ? row.querySelector('.test-name')?.textContent || '' : ''; - const pos = (lbIndex + 1) + '/' + lbImages.length; - document.getElementById('lb-caption').textContent = pos + ' — ' + testName + ' (' + label + ')'; + const item = lbImages[lbIndex]; + if (!item) return; + document.getElementById('lb-img').src = item.src; + const pos = lbImages.length > 1 ? (lbIndex + 1) + '/' + lbImages.length + ' — ' : ''; + document.getElementById('lb-caption').textContent = pos + item.caption; }; document.addEventListener('keydown', (e) => {