// 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:chain_studio/data/friendly_error.dart'; import 'package:chain_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: module manifest is invalid'), l, ); expect(r.headline, l.errFailedPrecondition); expect(r.hint, l.errFailedPreconditionHint); expect(r.detail, contains('manifest')); }); 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); }); test('no-store-entry maps to the three-path recovery hint', () async { // The flow quick-fix dead end: even with the honest badge this // error stays reachable (stale store snapshot, refresh race, // older hub) — it must never render as the generic // FAILED_PRECONDITION copy again. final l = await _loadL10n(const Locale('en')); final r = friendlyError( _FakeGrpcError( 9, 'FAILED_PRECONDITION', "install error: no store entry for 'example-provider/tool.summarize'" ' — run `chain store search ` to see what\'s available', ), l, ); expect(r.headline, l.errNoStoreEntry); expect(r.hint, l.errNoStoreEntryHint); // The hint names all three acquisition paths. expect(r.hint, contains('chain install --link')); expect(r.hint, contains('Stores')); expect(r.hint, contains('integration')); // The verbatim hub message stays copyable. expect(r.detail, contains("example-provider/tool.summarize")); }); test('no-store-entry is classified in German too', () async { final l = await _loadL10n(const Locale('de')); final r = friendlyError( _FakeGrpcError( 9, 'FAILED_PRECONDITION', "no store entry for 'x.y' — run `chain store search `", ), l, ); expect(r.headline, l.errNoStoreEntry); expect(r.hint, contains('chain install --link')); expect(r.hint, contains('Anbindung')); }); }