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>
This commit is contained in:
flemming-it 2026-07-19 01:02:51 +02:00
parent adc5fc2311
commit 66b26304fd
19 changed files with 733 additions and 97 deletions

202
test/state_matrix_test.dart Normal file
View file

@ -0,0 +1,202 @@
// State-matrix sweep every sidebar page against every hub
// condition, with the app-wide invariants that single-page tests
// keep missing:
//
// 1. healthy-empty -> no page may claim "hub not reachable",
// no raw error text anywhere, and the fake
// must cover every HubService member the
// pages touch (hermeticity guarantee).
// 2. hub gone -> the data pages show the honest
// unreachable state; still no raw errors.
// 3. hub too old -> RPCs answer UNIMPLEMENTED: NO page may
// say "not reachable" while the sidebar
// shows connected (the runs-page bug class,
// now pinned for every page).
// 4. feature off -> the detached gate error renders the
// plain-language feature-off empty state.
//
// The suite runs against the scriptable FakeHubService never a
// real hub so results cannot depend on the operator's machine.
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',
];
/// Substrings that must NEVER appear in rendered text they mean
/// a raw thrown object reached the UI instead of a classified,
/// localised state.
const _rawErrorMarkers = <String>[
'gRPC Error',
'UnimplementedError',
'SocketException',
'Instance of',
'StackTrace',
];
/// Page-primary read RPCs the ones an older hub would be
/// missing. Scenario 3 fails exactly these with UNIMPLEMENTED.
const _pageReads = <String>{
'doctor',
'searchStore',
'recentEvents',
'streamEvents',
'pendingApprovals',
'listApprovalsRecords',
'listDetachedRuns',
'listSatellites',
};
String _renderedText(WidgetTester tester) {
final buf = StringBuffer();
for (final w in tester.widgetList<Text>(find.byType(Text))) {
buf.writeln(w.data ?? w.textSpan?.toPlainText() ?? '');
}
for (final w in tester.widgetList<SelectableText>(
find.byType(SelectableText),
)) {
buf.writeln(w.data ?? '');
}
return buf.toString();
}
Future<void> _boot(WidgetTester tester) async {
SharedPreferences.setMockInitialValues({});
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
const StudioApp(
initialThemeMode: ThemeModeValue.dark,
initialLocale: Locale('de'),
),
);
await tester.pump(const Duration(milliseconds: 100));
}
Future<void> _goto(WidgetTester tester, String id) async {
await tester.tap(find.byKey(ValueKey('sidebar-item-$id')));
await tester.pump(const Duration(milliseconds: 400));
await tester.pump(const Duration(milliseconds: 400));
}
Future<void> _teardownApp(WidgetTester tester) async {
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(minutes: 1));
}
void main() {
testWidgets('healthy hub: no unreachable claims, no raw errors, '
'fake covers all members', (tester) async {
final fake = installFakeHub();
await _boot(tester);
final violations = <String>[];
for (final id in _destinations) {
await _goto(tester, id);
final text = _renderedText(tester);
if (text.contains('nicht erreichbar')) {
violations.add('page "$id" claims unreachable on a healthy hub');
}
for (final marker in _rawErrorMarkers) {
if (text.contains(marker)) {
violations.add('page "$id" renders raw error text "$marker"');
}
}
}
await _teardownApp(tester);
expect(
fake.missing,
isEmpty,
reason:
'pages touched HubService members the fake does not '
'implement — add healthy defaults for: ${fake.missing}',
);
expect(violations, isEmpty, reason: violations.join('\n'));
});
testWidgets('hub gone: data pages say unreachable, never raw errors', (
tester,
) async {
final fake = installFakeHub();
fake.failWith(kUnavailable);
await _boot(tester);
final violations = <String>[];
// Pages that render a full-page load state from the hub.
for (final id in ['store', 'doctor', 'audit', 'approvals', 'runs',
'federation']) {
await _goto(tester, id);
final text = _renderedText(tester);
if (!text.contains('nicht erreichbar')) {
violations.add('page "$id" hides that the hub is unreachable');
}
for (final marker in _rawErrorMarkers) {
if (text.contains(marker)) {
violations.add('page "$id" renders raw error text "$marker"');
}
}
}
await _teardownApp(tester);
expect(violations, isEmpty, reason: violations.join('\n'));
});
testWidgets('hub too old (UNIMPLEMENTED): connected is never shown '
'as unreachable', (tester) async {
final fake = installFakeHub();
fake.failWith(kUnimplemented, only: _pageReads);
await _boot(tester);
final violations = <String>[];
for (final id in _destinations) {
await _goto(tester, id);
final text = _renderedText(tester);
// THE invariant: the hub answered, so no page may
// contradict the sidebar's connected state.
if (text.contains('nicht erreichbar')) {
violations.add(
'page "$id" claims unreachable although the hub answered '
'(UNIMPLEMENTED)',
);
}
for (final marker in _rawErrorMarkers) {
if (text.contains(marker)) {
violations.add('page "$id" renders raw error text "$marker"');
}
}
}
// Spot-check the classified rendition on an affected page.
await _goto(tester, 'doctor');
expect(
find.textContaining('neuere Hub-Version'),
findsWidgets,
reason: 'the too-old state must say so in plain language',
);
await _teardownApp(tester);
expect(violations, isEmpty, reason: violations.join('\n'));
});
testWidgets('detached gate error renders the feature-off state', (
tester,
) async {
final fake = installFakeHub();
fake.failWith(kDetachedOff, only: {'listDetachedRuns'});
await _boot(tester);
await _goto(tester, 'runs');
expect(find.text('Keine Läufe im Hintergrund'), findsOneWidget);
expect(find.textContaining('nicht erreichbar'), findsNothing);
await _teardownApp(tester);
});
}