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>
105 lines
4.1 KiB
Dart
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')}',
|
|
);
|
|
});
|
|
}
|
|
}
|