// Unit tests for the `friendlyError` mapper. These check the // duck-typed shape contract — `GrpcError` from package:grpc is // expected to expose `.code` (int), `.message` (String?), and // `.codeName` (String) — without taking grpc as a Studio // dependency. The fakes here mimic that shape. import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fai_studio/data/friendly_error.dart'; import 'package:fai_studio/l10n/app_localizations.dart'; class _FakeGrpcError { final int code; final String? message; final String codeName; _FakeGrpcError(this.code, this.codeName, [this.message]); } Future _loadL10n(Locale locale) async { return await AppLocalizations.delegate.load(locale); } void main() { WidgetsFlutterBinding.ensureInitialized(); setUpAll(() async { // Force loading the gen-l10n delegate before tests so each // call to `_loadL10n` is fast. await _loadL10n(const Locale('en')); }); test('UNAVAILABLE maps to hub-not-reachable copy', () async { final l = await _loadL10n(const Locale('en')); final r = friendlyError(_FakeGrpcError(14, 'UNAVAILABLE', 'connection refused'), l); expect(r.headline, l.errUnavailable); expect(r.hint, l.errUnavailableHint); expect(r.detail, 'connection refused'); }); test('FAILED_PRECONDITION carries module detail in body', () async { final l = await _loadL10n(const Locale('en')); final r = friendlyError( _FakeGrpcError(9, 'FAILED_PRECONDITION', 'install error: no store entry for system.approval'), l, ); expect(r.headline, l.errFailedPrecondition); expect(r.hint, l.errFailedPreconditionHint); expect(r.detail, contains('system.approval')); }); test('NOT_FOUND has a recovery hint', () async { final l = await _loadL10n(const Locale('en')); final r = friendlyError(_FakeGrpcError(5, 'NOT_FOUND', 'no such module'), l); expect(r.headline, l.errNotFound); expect(r.hint, isNotNull); }); test('Unknown code falls back to codeName: message', () async { final l = await _loadL10n(const Locale('en')); final r = friendlyError(_FakeGrpcError(99, 'CUSTOM_CODE', 'something'), l); expect(r.headline, contains('CUSTOM_CODE')); expect(r.headline, contains('something')); expect(r.hint, isNull); }); test('Non-gRPC error renders toString as headline', () async { final l = await _loadL10n(const Locale('en')); final r = friendlyError(FormatException('bad url'), l); expect(r.headline, contains('bad url')); }); test('Locale switches the headline language', () async { final lDe = await _loadL10n(const Locale('de')); final r = friendlyError(_FakeGrpcError(14, 'UNAVAILABLE', 'connection refused'), lDe); expect(r.headline, lDe.errUnavailable); final lEn = await _loadL10n(const Locale('en')); expect(lDe.errUnavailable, isNot(equals(lEn.errUnavailable))); }); // --- Hub-pattern-specific detection --- // The mapper inspects the message DETAIL for hub patterns and // returns a more specific FriendlyError than the gRPC code's // default. These guards lock the matchers — a regression here // would silently fall back to the generic gRPC-code copy. test("approval rejection maps to specific headline", () async { final l = await _loadL10n(const Locale('en')); final r = friendlyError( _FakeGrpcError( 7, 'PERMISSION_DENIED', "step 'ap1' rejected by alice: needs more detail", ), l, ); expect(r.headline, l.errApprovalRejected); expect(r.hint, l.errApprovalRejectedHint); }); test('approval timeout maps to specific headline', () async { final l = await _loadL10n(const Locale('en')); final r = friendlyError( _FakeGrpcError( 4, 'DEADLINE_EXCEEDED', "step 'ap1' timed out waiting for approval after 600s", ), l, ); expect(r.headline, l.errApprovalTimedOut); }); test('output-too-large maps to specific headline', () async { final l = await _loadL10n(const Locale('en')); final r = friendlyError( _FakeGrpcError( 8, 'RESOURCE_EXHAUSTED', "step 'extract' produced 52428800 bytes of output, " "exceeding the 10 MB cap", ), l, ); expect(r.headline, l.errOutputTooLarge); }); test('host-service-not-declared maps to specific headline', () async { final l = await _loadL10n(const Locale('en')); final r = friendlyError( _FakeGrpcError( 14, 'UNAVAILABLE', "step 'summarise' requires service 'ollama' which is not declared " "in operator config", ), l, ); expect(r.headline, l.errServiceUnavailableForStep); }); test('missing value maps to specific headline', () async { final l = await _loadL10n(const Locale('en')); final r = friendlyError( _FakeGrpcError( 3, 'INVALID_ARGUMENT', r"step 'classify' references missing value '$inputs.text'", ), l, ); expect(r.headline, l.errMissingValue); }); }