// 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('too-old hub leads with an in-place update button, ' 'doctor demoted to secondary', (tester) async { var updateTriggered = false; await tester.pumpWidget( _host( RunsLoadErrorView( error: const _FakeGrpcError(12, 'unimplemented'), issue: RunsLoadIssue.unsupported, onOpenGuide: () {}, onOpenDoctor: () {}, onUpdateHub: () => updateTriggered = true, ), ), ); await tester.pumpAndSettle(); // Primary CTA is the fix, not the detour. expect(find.text('Hub jetzt aktualisieren'), findsOneWidget); expect(find.text('Diagnose öffnen'), findsOneWidget); await tester.tap(find.text('Hub jetzt aktualisieren')); expect(updateTriggered, isTrue); }); testWidgets('while updating, the button shows progress and is disabled', ( tester, ) async { var updateTriggered = false; await tester.pumpWidget( _host( RunsLoadErrorView( error: const _FakeGrpcError(12, 'unimplemented'), issue: RunsLoadIssue.unsupported, onOpenGuide: () {}, onOpenDoctor: () {}, onUpdateHub: () => updateTriggered = true, updating: true, ), ), ); // The spinner animates forever, so pump one frame rather than // pumpAndSettle (which would time out waiting for it to stop). await tester.pump(); expect(find.text('Hub wird aktualisiert…'), findsOneWidget); // Disabled while in flight — a tap must not re-fire. await tester.tap(find.byType(FilledButton), warnIfMissed: false); expect(updateTriggered, isFalse); }); 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); }); }); }