feat(web): translate error messages surfaced from API error codes to English

Translates the five client-side error-message maps (world, hunting, combat,
inventory, local-location stores) that re-translate already-English API
error codes back to display text, per design doc §4. Also fixes four
component specs that hard-coded the same German strings when asserting
rendered error text (combat-page, hunt-page, location-interaction-panel,
location-page).
This commit is contained in:
Bastian Wagner
2026-08-21 21:29:10 +02:00
parent 5222bfb2ab
commit 5586e6c672
14 changed files with 53 additions and 53 deletions

View File

@@ -510,12 +510,12 @@ describe('CombatPageComponent', () => {
it('shows an error and retries loading the combat', async () => {
const fixture = await setup(null);
combatStore.error.set('Dieser Kampf wurde nicht gefunden.');
combatStore.error.set('This combat could not be found.');
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[role="alert"]')?.textContent).toContain(
'Dieser Kampf wurde nicht gefunden.',
'This combat could not be found.',
);
element.querySelector<HTMLButtonElement>('[data-combat-retry]')?.click();

View File

@@ -80,7 +80,7 @@ describe('CombatStore', () => {
await store.startCombat('encounter-1');
expect(store.combat()).toBeNull();
expect(store.error()).toBe('Du befindest dich bereits in einem Kampf.');
expect(store.error()).toBe("You're already in a combat.");
expect(store.errorCode()).toBe('COMBAT_ALREADY_ACTIVE');
});
@@ -97,7 +97,7 @@ describe('CombatStore', () => {
await store.startCombat('encounter-1');
expect(store.error()).toBe('Du bist zu schwer verwundet, um zu kämpfen. Warte, bis du dich erholt hast.');
expect(store.error()).toBe("You're too badly wounded to fight. Wait until you've recovered.");
});
it('loads the running combat and clears the error that sent us looking for it', async () => {
@@ -150,7 +150,7 @@ describe('CombatStore', () => {
await store.loadCombat('unknown');
expect(store.error()).toBe('Dieser Kampf wurde nicht gefunden.');
expect(store.error()).toBe('This combat could not be found.');
});
it('sends only the ATTACK action and replaces combat with the server response', async () => {

View File

@@ -4,20 +4,20 @@ import { firstValueFrom } from 'rxjs';
import { Combat, CombatAction } from '../../core/api/game-api.models';
import { GameApiService } from '../../core/api/game-api.service';
const GENERIC_ERROR_MESSAGE = 'Der Kampf konnte nicht geladen werden.';
const GENERIC_ERROR_MESSAGE = 'Could not load the combat.';
// Mirrors the combat error codes returned by the combat endpoints.
// Unknown/missing codes fall back to `GENERIC_ERROR_MESSAGE`.
const COMBAT_ERROR_MESSAGES: Readonly<Record<string, string>> = {
HUNT_ENCOUNTER_NOT_FOUND: 'Diese Begegnung wurde nicht gefunden.',
HUNT_ENCOUNTER_ALREADY_CONSUMED: 'Diese Begegnung wurde bereits genutzt.',
INVALID_HUNT_ENCOUNTER: 'Diese Begegnung ist nicht mehr gültig.',
CHARACTER_TRAVELLING: 'Du kannst nicht kämpfen, während du unterwegs bist.',
CHARACTER_TOO_WOUNDED: 'Du bist zu schwer verwundet, um zu kämpfen. Warte, bis du dich erholt hast.',
COMBAT_ALREADY_ACTIVE: 'Du befindest dich bereits in einem Kampf.',
COMBAT_NOT_FOUND: 'Dieser Kampf wurde nicht gefunden.',
COMBAT_ALREADY_FINISHED: 'Dieser Kampf ist bereits beendet.',
COMBAT_NO_POTIONS_REMAINING: 'Du hast keine Tränke mehr.',
HUNT_ENCOUNTER_NOT_FOUND: 'This encounter could not be found.',
HUNT_ENCOUNTER_ALREADY_CONSUMED: 'This encounter has already been used.',
INVALID_HUNT_ENCOUNTER: 'This encounter is no longer valid.',
CHARACTER_TRAVELLING: "You can't fight while travelling.",
CHARACTER_TOO_WOUNDED: "You're too badly wounded to fight. Wait until you've recovered.",
COMBAT_ALREADY_ACTIVE: "You're already in a combat.",
COMBAT_NOT_FOUND: 'This combat could not be found.',
COMBAT_ALREADY_FINISHED: 'This combat has already ended.',
COMBAT_NO_POTIONS_REMAINING: 'You have no potions left.',
};
@Injectable({ providedIn: 'root' })

View File

@@ -228,7 +228,7 @@ describe('HuntPageComponent', () => {
const fixture = await setup(burnedRoad, threeEncounterHunt);
combatStore.startCombat.mockImplementation(async () => {
combatStore.errorCode.set('COMBAT_ALREADY_ACTIVE');
combatStore.error.set('Du befindest dich bereits in einem Kampf.');
combatStore.error.set("You're already in a combat.");
});
combatStore.loadActiveCombat.mockResolvedValue({ ...startedCombat, id: 'combat-running' });
const element = fixture.nativeElement as HTMLElement;
@@ -249,7 +249,7 @@ describe('HuntPageComponent', () => {
const fixture = await setup(burnedRoad, threeEncounterHunt);
combatStore.startCombat.mockImplementation(async () => {
combatStore.errorCode.set('HUNT_ENCOUNTER_ALREADY_CONSUMED');
combatStore.error.set('Diese Begegnung wurde bereits genutzt.');
combatStore.error.set('This encounter has already been used.');
});
const element = fixture.nativeElement as HTMLElement;
@@ -267,12 +267,12 @@ describe('HuntPageComponent', () => {
it('shows a combat-start error and dismisses it', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt);
combatStore.error.set('Du befindest dich bereits in einem Kampf.');
combatStore.error.set("You're already in a combat.");
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const alerts = Array.from(element.querySelectorAll('[role="alert"]'));
expect(alerts.some((alert) => alert.textContent?.includes('Du befindest dich bereits in einem Kampf.'))).toBe(
expect(alerts.some((alert) => alert.textContent?.includes("You're already in a combat."))).toBe(
true,
);
@@ -380,12 +380,12 @@ describe('HuntPageComponent', () => {
it('displays a hunting error and retries via startHunt when there is no current hunt', async () => {
const fixture = await setup(burnedRoad);
huntingStore.error.set('An diesem Ort gibt es keine Jagdgebiete.');
huntingStore.error.set("There's no hunting ground at this location.");
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[role="alert"]')?.textContent).toContain(
'An diesem Ort gibt es keine Jagdgebiete.',
"There's no hunting ground at this location.",
);
element.querySelector<HTMLButtonElement>('[data-hunt-retry]')?.click();

View File

@@ -133,7 +133,7 @@ describe('HuntingStore', () => {
await store.startHunt();
expect(store.error()).toBe('Dein Charakter konnte nicht gefunden werden.');
expect(store.error()).toBe('Your character could not be found.');
});
it('maps CHARACTER_TRAVELLING to its German message', async () => {
@@ -149,7 +149,7 @@ describe('HuntingStore', () => {
await store.startHunt();
expect(store.error()).toBe('Du kannst nicht jagen, während du unterwegs bist.');
expect(store.error()).toBe("You can't hunt while travelling.");
});
it('maps HUNTING_NOT_AVAILABLE to its German message', async () => {
@@ -165,7 +165,7 @@ describe('HuntingStore', () => {
await store.startHunt();
expect(store.error()).toBe('An diesem Ort gibt es keine Jagdgebiete.');
expect(store.error()).toBe("There's no hunting ground at this location.");
});
it('maps NO_HUNT_ENCOUNTERS_AVAILABLE to its German message', async () => {
@@ -185,7 +185,7 @@ describe('HuntingStore', () => {
await store.startHunt();
expect(store.error()).toBe('Aktuell sind hier keine Gegner zu finden.');
expect(store.error()).toBe('No enemies can currently be found here.');
});
it('falls back to the generic message for an HttpErrorResponse with no known code', async () => {
@@ -201,7 +201,7 @@ describe('HuntingStore', () => {
await store.startHunt();
expect(store.error()).toBe('Weltzustand konnte nicht geladen werden.');
expect(store.error()).toBe('Could not load world state.');
});
it('uses the message of a genuine non-HTTP Error', async () => {

View File

@@ -4,15 +4,15 @@ import { firstValueFrom } from 'rxjs';
import { HuntResult } from '../../core/api/game-api.models';
import { GameApiService } from '../../core/api/game-api.service';
const GENERIC_ERROR_MESSAGE = 'Weltzustand konnte nicht geladen werden.';
const GENERIC_ERROR_MESSAGE = 'Could not load world state.';
// Mirrors the hunt error codes returned by `POST /api/hunts`.
// Unknown/missing codes fall back to `GENERIC_ERROR_MESSAGE`.
const HUNT_ERROR_MESSAGES: Readonly<Record<string, string>> = {
CHARACTER_NOT_FOUND: 'Dein Charakter konnte nicht gefunden werden.',
CHARACTER_TRAVELLING: 'Du kannst nicht jagen, während du unterwegs bist.',
HUNTING_NOT_AVAILABLE: 'An diesem Ort gibt es keine Jagdgebiete.',
NO_HUNT_ENCOUNTERS_AVAILABLE: 'Aktuell sind hier keine Gegner zu finden.',
CHARACTER_NOT_FOUND: 'Your character could not be found.',
CHARACTER_TRAVELLING: "You can't hunt while travelling.",
HUNTING_NOT_AVAILABLE: "There's no hunting ground at this location.",
NO_HUNT_ENCOUNTERS_AVAILABLE: 'No enemies can currently be found here.',
};
@Injectable({ providedIn: 'root' })

View File

@@ -140,6 +140,6 @@ describe('InventoryStore', () => {
await store.equip('item-blade');
expect(store.error()).toBe('Dieser Gegenstand kann nicht ausgerüstet werden.');
expect(store.error()).toBe("This item can't be equipped.");
});
});

View File

@@ -5,15 +5,15 @@ import { EquipmentResponse, InventoryItem, InventoryResponse } from '../../core/
import { GameApiService } from '../../core/api/game-api.service';
import { WorldStore } from '../world/world.store';
const GENERIC_ERROR_MESSAGE = 'Inventar konnte nicht geladen werden.';
const GENERIC_ERROR_MESSAGE = 'Could not load the inventory.';
// Mirrors `EquipmentErrorCode` in `apps/api/src/equipment/equipment.errors.ts`.
const EQUIPMENT_ERROR_MESSAGES: Readonly<Record<string, string>> = {
CHARACTER_ITEM_NOT_FOUND: 'Dieser Gegenstand konnte nicht gefunden werden.',
ITEM_NOT_OWNED: 'Dieser Gegenstand gehört dir nicht.',
ITEM_NOT_EQUIPPABLE: 'Dieser Gegenstand kann nicht ausgerüstet werden.',
INVALID_EQUIPMENT_SLOT: 'Dieser Ausrüstungsplatz ist ungültig.',
CHARACTER_IN_COMBAT: 'Ausrüstung kann während eines Kampfes nicht geändert werden.',
CHARACTER_ITEM_NOT_FOUND: 'This item could not be found.',
ITEM_NOT_OWNED: "This item doesn't belong to you.",
ITEM_NOT_EQUIPPABLE: "This item can't be equipped.",
INVALID_EQUIPMENT_SLOT: 'This equipment slot is invalid.',
CHARACTER_IN_COMBAT: "Equipment can't be changed during combat.",
};
@Injectable({ providedIn: 'root' })

View File

@@ -93,7 +93,7 @@ describe('LocalLocationStore', () => {
await store.runInteraction('inspect-tracks');
expect(store.interactionError()).toBe('Hier gibt es dazu nichts zu entdecken.');
expect(store.interactionError()).toBe("There's nothing to discover here.");
expect(store.interactionResult()).toBeNull();
expect(store.interactionOpen()).toBe(true);
});
@@ -107,7 +107,7 @@ describe('LocalLocationStore', () => {
await store.runInteraction('inspect-tracks');
expect(store.interactionError()).toBe('Diese Handlung ist gerade nicht möglich.');
expect(store.interactionError()).toBe("This action isn't possible right now.");
});
it('closes the panel without clearing the location', async () => {

View File

@@ -9,12 +9,12 @@ import {
import { GameApiService } from '../../core/api/game-api.service';
import { WorldStore } from './world.store';
const GENERIC_INTERACTION_ERROR = 'Diese Handlung ist gerade nicht möglich.';
const GENERIC_INTERACTION_ERROR = "This action isn't possible right now.";
// Mirrors the codes in `apps/api/src/world/world.errors.ts`.
const INTERACTION_ERROR_MESSAGES: Readonly<Record<string, string>> = {
LOCATION_INTERACTION_UNAVAILABLE: 'Hier gibt es dazu nichts zu entdecken.',
CHARACTER_NOT_FOUND: 'Dein Charakter konnte nicht gefunden werden.',
LOCATION_INTERACTION_UNAVAILABLE: "There's nothing to discover here.",
CHARACTER_NOT_FOUND: 'Your character could not be found.',
};
/**

View File

@@ -61,10 +61,10 @@ describe('LocationInteractionPanelComponent', () => {
});
it('reports a rejected interaction in place instead of navigating away', async () => {
const { element } = await setup({ error: 'Hier gibt es dazu nichts zu entdecken.' });
const { element } = await setup({ error: "There's nothing to discover here." });
expect(element.querySelector('[role="alert"]')?.textContent).toContain(
'Hier gibt es dazu nichts zu entdecken.',
"There's nothing to discover here.",
);
});

View File

@@ -220,7 +220,7 @@ describe('LocationPageComponent', () => {
it('offers a retry when the location could not be loaded', async () => {
const { fixture, element } = await setup(null);
error.set('Weltzustand konnte nicht geladen werden.');
error.set('Could not load world state.');
fixture.detectChanges();
expect(element.querySelector('[role="alert"]')?.textContent).toContain(

View File

@@ -284,7 +284,7 @@ describe('WorldStore', () => {
await store.startTravel();
expect(store.error()).toBe('Du befindest dich bereits auf Reisen.');
expect(store.error()).toBe("You're already travelling.");
pendingResync.next({ status: 'IDLE' });
pendingResync.complete();
@@ -307,7 +307,7 @@ describe('WorldStore', () => {
await store.startTravel();
expect(store.error()).toBe('Weltzustand konnte nicht geladen werden.');
expect(store.error()).toBe('Could not load world state.');
pendingResync.next({ status: 'IDLE' });
pendingResync.complete();

View File

@@ -10,15 +10,15 @@ import {
} from '../../core/api/game-api.models';
import { GameApiService } from '../../core/api/game-api.service';
const GENERIC_ERROR_MESSAGE = 'Weltzustand konnte nicht geladen werden.';
const GENERIC_ERROR_MESSAGE = 'Could not load world state.';
// Mirrors the `TravelErrorCode` union in `apps/api/src/travel/travel.errors.ts`.
// Unknown/missing codes fall back to `GENERIC_ERROR_MESSAGE`.
const TRAVEL_ERROR_MESSAGES: Readonly<Record<string, string>> = {
TRAVEL_ALREADY_ACTIVE: 'Du befindest dich bereits auf Reisen.',
INVALID_TRAVEL_TARGET: 'Dieses Ziel ist von hier aus nicht erreichbar.',
CHARACTER_NOT_FOUND: 'Dein Charakter konnte nicht gefunden werden.',
TRAVEL_STATE_INVALID: 'Der Reisezustand ist ungültig. Bitte lade die Seite neu.',
TRAVEL_ALREADY_ACTIVE: "You're already travelling.",
INVALID_TRAVEL_TARGET: "This destination isn't reachable from here.",
CHARACTER_NOT_FOUND: 'Your character could not be found.',
TRAVEL_STATE_INVALID: 'Travel state is invalid. Please reload the page.',
};
@Injectable({ providedIn: 'root' })