chain-studio/test/runs_error_classification_test.dart
flemming-it ea2cd87b5e fix(runs): stop claiming the hub is unreachable on RPC-level errors
The runs monitor folded every load failure into 'hub not reachable',
contradicting the sidebar's green connected dot whenever the hub
answered but the RPC failed — most visibly against a pre-0.22 hub
whose version predates the ListInvocations RPC (UNIMPLEMENTED).

Classify the failure instead (top-level, unit-tested):
- UNIMPLEMENTED -> 'this view needs a newer hub version' with a
  doctor-page link (the update banner lives there)
- FAILED_PRECONDITION from the detached gate -> the regular
  feature-off empty state with the guide button
- UNAVAILABLE / DEADLINE_EXCEEDED / socket-level failures -> the
  honest 'hub not reachable' state (unchanged)
- everything else -> a load-failed state with the friendly error
  and a copyable detail box

The error view is a public callback-driven widget so the tests pump
each variant without a live hub. New DE+EN strings for the too-old
and load-failed states; grpcCodeOf/grpcMessageOf exposed from the
friendly-error mapper instead of duplicating the duck-typing.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-07-18 17:33:11 +02:00

172 lines
5.4 KiB
Dart

// Runs-monitor error classification — the page used to render
// every load failure as "hub not reachable", contradicting the
// sidebar's green "connected" dot whenever the hub answered but
// the RPC failed (usertest finding: a 0.21 hub without the
// ListInvocations RPC answered UNIMPLEMENTED and the page claimed
// the hub was down). These tests pin the classification and the
// widget rendition of each state.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:chain_studio/l10n/app_localizations.dart';
import 'package:chain_studio/pages/runs.dart';
/// Duck-typed stand-in for `GrpcError` — the classifier reads
/// `.code` and `.message` off whatever object arrives, exactly
/// like the friendly-error mapper does.
class _FakeGrpcError {
final int code;
final String? message;
const _FakeGrpcError(this.code, [this.message]);
@override
String toString() => 'gRPC Error (code: $code, message: $message)';
}
Widget _host(Widget child) => MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('de'),
home: Scaffold(body: child),
);
void main() {
group('classifyRunsLoadError', () {
test('UNIMPLEMENTED means the hub is too old, not unreachable', () {
expect(
classifyRunsLoadError(
const _FakeGrpcError(12, 'grpc.Hub/ListInvocations unimplemented'),
),
RunsLoadIssue.unsupported,
);
});
test('FAILED_PRECONDITION from the detached gate is feature-off', () {
expect(
classifyRunsLoadError(
const _FakeGrpcError(
9,
'detached invocations are not enabled — set detached.enabled: '
'true in the operator config',
),
),
RunsLoadIssue.featureDisabled,
);
});
test('other FAILED_PRECONDITION errors keep their own story', () {
expect(
classifyRunsLoadError(const _FakeGrpcError(9, 'store busy')),
RunsLoadIssue.other,
);
});
test('UNAVAILABLE and DEADLINE_EXCEEDED are unreachable', () {
expect(
classifyRunsLoadError(const _FakeGrpcError(14, 'connection refused')),
RunsLoadIssue.unreachable,
);
expect(
classifyRunsLoadError(const _FakeGrpcError(4, 'deadline exceeded')),
RunsLoadIssue.unreachable,
);
});
test('socket-level failures without a gRPC code are unreachable', () {
expect(
classifyRunsLoadError(
Exception('SocketException: Connection refused (port 50051)'),
),
RunsLoadIssue.unreachable,
);
});
test('arbitrary errors fall through to other', () {
expect(
classifyRunsLoadError(const FormatException('bad payload')),
RunsLoadIssue.other,
);
});
});
group('RunsLoadErrorView', () {
testWidgets('feature-off renders the plain-language empty state, '
'never "not reachable"', (tester) async {
await tester.pumpWidget(
_host(
RunsLoadErrorView(
error: const _FakeGrpcError(
9,
'detached invocations are not enabled',
),
issue: RunsLoadIssue.featureDisabled,
onOpenGuide: () {},
),
),
);
await tester.pumpAndSettle();
expect(find.text('Keine Läufe im Hintergrund'), findsOneWidget);
expect(find.text('Anleitung öffnen'), findsOneWidget);
expect(find.text('Hub nicht erreichbar'), findsNothing);
});
testWidgets('too-old hub says so and links to the doctor page', (
tester,
) async {
var doctorOpened = false;
await tester.pumpWidget(
_host(
RunsLoadErrorView(
error: const _FakeGrpcError(12, 'unimplemented'),
issue: RunsLoadIssue.unsupported,
onOpenGuide: () {},
onOpenDoctor: () => doctorOpened = true,
),
),
);
await tester.pumpAndSettle();
expect(
find.text('Diese Ansicht braucht eine neuere Hub-Version'),
findsOneWidget,
);
expect(find.text('Hub nicht erreichbar'), findsNothing);
await tester.tap(find.text('Diagnose öffnen'));
expect(doctorOpened, isTrue);
});
testWidgets('a genuinely unreachable hub still says unreachable', (
tester,
) async {
await tester.pumpWidget(
_host(
RunsLoadErrorView(
error: const _FakeGrpcError(14, 'connection refused'),
issue: RunsLoadIssue.unreachable,
onOpenGuide: () {},
),
),
);
await tester.pumpAndSettle();
expect(find.text('Hub nicht erreichbar'), findsOneWidget);
});
testWidgets('other errors surface a copyable detail box', (tester) async {
await tester.pumpWidget(
_host(
RunsLoadErrorView(
error: const _FakeGrpcError(13, 'internal boom'),
issue: RunsLoadIssue.other,
onOpenGuide: () {},
),
),
);
await tester.pumpAndSettle();
expect(find.text('Läufe konnten nicht geladen werden'), findsOneWidget);
expect(find.text('Hub nicht erreichbar'), findsNothing);
// The copy affordance is the hard rule: the detail box must
// be present so the operator can copy the real message.
expect(find.byIcon(Icons.content_copy), findsWidgets);
});
});
}