// Accessibility gate for every sidebar page, light AND dark: // // * textContrastGuideline — WCAG AA text contrast (>= 4.5:1 for // normal text, >= 3:1 for large text) on whatever the page // renders without a hub (empty/offline states included; those // are exactly the states a fresh operator sees first). // * labeledTapTargetGuideline — every tappable target exposes a // semantic label, so icon-only buttons must carry a tooltip or // Semantics label. Screen-reader users get a name for every // action. // // The Android/iOS tap-target SIZE guidelines are deliberately not // applied: Studio is a desktop app driven by pointer, and Flutter's // desktop defaults (e.g. 34px DropdownMenu items) fail the 48px // mobile rule by design. // // Navigation mirrors sidebar_test.dart: pump StudioApp directly, // tap the sidebar destination keys, pump a fixed duration (the app // holds long-lived timers, so pumpAndSettle never settles). import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:chain_studio/data/hub.dart'; import 'package:chain_studio/main.dart'; const _destinations = [ 'welcome', 'store', 'doctor', 'flows', 'audit', 'approvals', 'runs', 'federation', ]; void main() { for (final mode in [ThemeModeValue.light, ThemeModeValue.dark]) { testWidgets('all pages meet a11y guidelines — ${mode.name}', (tester) async { SharedPreferences.setMockInitialValues({}); // Layout correctness across sizes is responsive_test.dart's // job; this suite audits colors and labels at a normal size. tester.view.physicalSize = const Size(1280, 800); tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.reset); final handle = tester.ensureSemantics(); await tester.pumpWidget( StudioApp( initialThemeMode: mode, initialLocale: const Locale('de'), ), ); await tester.pump(const Duration(milliseconds: 100)); // Collect violations across ALL pages before failing, so one // regression does not mask the rest of the sweep. final violations = []; for (final id in _destinations) { await tester.tap(find.byKey(ValueKey('sidebar-item-$id'))); await tester.pump(const Duration(milliseconds: 400)); for (final (guideline, what) in [ (textContrastGuideline, 'text contrast'), (labeledTapTargetGuideline, 'unlabeled tap targets'), ]) { final result = await guideline.evaluate(tester); if (!result.passed) { final reason = result.reason ?? ''; // Known evaluator false positive: the workspace switcher // wraps its trigger in an OverlayPortal + Tooltip, which // makes the contrast guideline lose the text render and // measure the chip's own fill (#27272A) against the page // canvas instead. The actual label text is onSurface on // surfaceContainerHighest (>10:1). Skip exactly that node. if (what == 'text contrast' && reason.contains('_OverlayPortalState') && reason.contains('Arbeitsbereich')) { continue; } violations.add('page "$id" (${mode.name}) $what: $reason'); } } } handle.dispose(); // Tear the app down explicitly and drain in-flight one-shot // timers (status polls, tooltip delays) — the binding asserts // !timersPending after the test body. await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(const Duration(minutes: 1)); // The suite's long-standing pending-timer flake, finally // caught with a creation stack: when the last gRPC stream // closes, Http2ClientConnection._handleActiveStateChanged // arms the channel's 5-minute idleTimeout timer — even on a // shut-down connection — so a 1-minute drain never covered // it. Close the channel in real-async space (lets in-flight // socket callbacks land), then pump PAST the idle timeout so // the timer fires inside the test body. await tester.runAsync(() async { HubService.instance.debugResetChannel(); await Future.delayed(const Duration(milliseconds: 100)); }); await tester.pump(const Duration(minutes: 6)); expect( violations, isEmpty, reason: 'a11y violations:\n${violations.join('\n\n')}', ); }); } }