chain-studio/test/a11y_test.dart
flemming-it 66b26304fd feat(test): hermetic hub fake + state-matrix sweep across all pages
The widget suites used to talk to whatever listens on the real
endpoint — results depended on the operator's machine (a running
hub fed real data into a11y/responsive runs and its gRPC channel
timers caused the historic flake). Hardening round:

- HubService.instance is now injectable (debugSetInstance);
  FakeHubService (test/support/fake_hub.dart) answers every member
  the pages touch with healthy-empty defaults and scripts per-RPC
  failures (UNIMPLEMENTED / UNAVAILABLE / detached gate) through a
  GrpcError-shaped fake. Unimplemented members are recorded and
  fail the sweep with the exact list.
- state_matrix_test.dart pins the app-wide invariants for every
  sidebar page x hub condition: healthy => no unreachable claims
  and no raw error text; hub gone => honest unreachable states;
  UNIMPLEMENTED => never 'not reachable' while the sidebar shows
  connected; detached gate => plain-language feature-off state.
- a11y + responsive sweeps now inject the fake (hermetic); the
  6-minute idle-timer drain workaround is gone with the cause.

Real bugs the new sweep caught immediately:
- every data page (store, doctor, audit, approvals, federation)
  folded ANY load failure into 'hub not reachable' — the runs-page
  bug class; they now share HubLoadErrorView, which classifies
  into unreachable / needs-newer-hub / load-failed-with-copyable-
  detail (new generic DE+EN strings)
- the approvals page's hidden tab had no future listener: a load
  failure there surfaced as an uncaught async error
- the audit status bar rendered the raw gRPC error wall verbatim;
  it now shows the classified friendly headline (still selectable)

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-07-19 01:02:51 +02:00

105 lines
4.1 KiB
Dart

// 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';
import 'support/fake_hub.dart';
const _destinations = <String>[
'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({});
// Hermetic: pages render against the scriptable fake, never a
// hub that happens to listen on the operator's machine (the
// suite's historic pending-timer flake came from real gRPC
// channels arming their 5-minute idle timer mid-test).
installFakeHub();
// 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 = <String>[];
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));
expect(
violations,
isEmpty,
reason: 'a11y violations:\n${violations.join('\n\n')}',
);
});
}
}