fix: harden world travel presentation
This commit is contained in:
@@ -23,6 +23,15 @@ The reference screenshots were inspected but are not shipped or referenced by th
|
|||||||
- `ContextPanelComponent` renders the current/selected location, description, recommended level, safe status, hunting status, and server artwork.
|
- `ContextPanelComponent` renders the current/selected location, description, recommended level, safe status, hunting status, and server artwork.
|
||||||
- Seed paths now match the delivered assets and are covered by the API seed test.
|
- Seed paths now match the delivered assets and are covered by the API seed test.
|
||||||
|
|
||||||
|
## Review-fix round
|
||||||
|
|
||||||
|
- Map-node geography is now anchored to the stable location keys (`south-gate` and `burned-road`). Current, reachable, and selected state only changes presentation; a regression test swaps the authoritative current location without moving either node.
|
||||||
|
- On a server `COMPLETED` response, the selected connection is cleared and the store stays loading until the authoritative character/location/travel reload completes. All travel actions are disabled in that interval; no local arrival or location update is performed.
|
||||||
|
- The moving countdown is a `<time role="timer">` without a broad live region. The selected and travelling mode headings remain labelled for assistive technology.
|
||||||
|
- The decision panel now occupies normal document flow below the map scene, reserving a non-overlapping zone at desktop and narrow breakpoints.
|
||||||
|
- The original delivered PNGs remain untouched. A deterministic local System.Drawing JPEG conversion (quality 86, high-quality bicubic) provides runtime derivatives: `map_ashen_realm-1440.jpg` (1440x810, 233,778 B), `Suedtor-960.jpg` (960x540, 94,286 B), and `Aschestrasse-960.jpg` (960x540, 93,277 B). The WorldPage and ContextPanel prefer these derivatives while retaining each server artwork path as the `<img>` fallback.
|
||||||
|
- The load error is exposed as an alert and its retry control is covered by a store-load regression test.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
- RED confirmed: four new WorldPage contracts failed against the empty placeholder; seed artwork-path assertion failed against the former WebP paths.
|
- RED confirmed: four new WorldPage contracts failed against the empty placeholder; seed artwork-path assertion failed against the former WebP paths.
|
||||||
@@ -30,6 +39,10 @@ The reference screenshots were inspected but are not shipped or referenced by th
|
|||||||
- Full web tests: 18/18 pass.
|
- Full web tests: 18/18 pass.
|
||||||
- Affected API seed tests: 2/2 pass.
|
- Affected API seed tests: 2/2 pass.
|
||||||
- `npm run build:web` and `npm run build:api` pass.
|
- `npm run build:web` and `npm run build:api` pass.
|
||||||
|
- Review focused World/Store/Context tests: 19 pass (4 files; 4 pre-existing skips).
|
||||||
|
- Review full web suite: 23/23 pass.
|
||||||
|
- Affected API seed test: 2/2 pass.
|
||||||
|
- Both review builds (`npm run build:web`, `npm run build:api`) pass.
|
||||||
|
|
||||||
## Remaining concern
|
## Remaining concern
|
||||||
|
|
||||||
|
|||||||
BIN
apps/web/public/images/backgrounds/runtime/Aschestrasse-960.jpg
Normal file
BIN
apps/web/public/images/backgrounds/runtime/Aschestrasse-960.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
BIN
apps/web/public/images/backgrounds/runtime/Suedtor-960.jpg
Normal file
BIN
apps/web/public/images/backgrounds/runtime/Suedtor-960.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 228 KiB |
@@ -1,11 +1,13 @@
|
|||||||
<section class="travel-panel" aria-live="polite">
|
<section class="travel-panel" aria-labelledby="travel-panel-title">
|
||||||
@if (travellingTravel; as travel) {
|
@if (travellingTravel; as travel) {
|
||||||
<span class="travel-panel__eyebrow">REISE LÄUFT</span>
|
<span class="travel-panel__eyebrow" role="status">REISE LÄUFT</span>
|
||||||
<h2>Reiseziel: {{ travel.targetLocation.name }}</h2>
|
<h2 id="travel-panel-title">Reiseziel: {{ travel.targetLocation.name }}</h2>
|
||||||
<dl class="travel-panel__details">
|
<dl class="travel-panel__details">
|
||||||
<div>
|
<div>
|
||||||
<dt>Ankunft in</dt>
|
<dt>Ankunft in</dt>
|
||||||
<dd>{{ formatDuration(remainingSeconds ?? 0) }}</dd>
|
<dd>
|
||||||
|
<time role="timer">{{ formatDuration(remainingSeconds ?? 0) }}</time>
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>Ziel</dt>
|
<dt>Ziel</dt>
|
||||||
@@ -15,7 +17,7 @@
|
|||||||
<button type="button" data-travel-start disabled>Reise läuft</button>
|
<button type="button" data-travel-start disabled>Reise läuft</button>
|
||||||
} @else if (selectedConnection; as connection) {
|
} @else if (selectedConnection; as connection) {
|
||||||
<span class="travel-panel__eyebrow">REISEN</span>
|
<span class="travel-panel__eyebrow">REISEN</span>
|
||||||
<h2>Zur {{ connection.targetLocation.name }} reisen?</h2>
|
<h2 id="travel-panel-title">Zur {{ connection.targetLocation.name }} reisen?</h2>
|
||||||
<dl class="travel-panel__details">
|
<dl class="travel-panel__details">
|
||||||
<div>
|
<div>
|
||||||
<dt>Ziel</dt>
|
<dt>Ziel</dt>
|
||||||
@@ -37,6 +39,8 @@
|
|||||||
</button>
|
</button>
|
||||||
} @else {
|
} @else {
|
||||||
<span class="travel-panel__eyebrow">REISEN</span>
|
<span class="travel-panel__eyebrow">REISEN</span>
|
||||||
<p class="travel-panel__instruction">Wähle einen erreichbaren Ort auf der Karte.</p>
|
<p id="travel-panel-title" class="travel-panel__instruction">
|
||||||
|
Wähle einen erreichbaren Ort auf der Karte.
|
||||||
|
</p>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<app-location-node
|
<app-location-node
|
||||||
class="world-page__node world-page__node--current"
|
[class]="'world-page__node world-page__node--' + location.key"
|
||||||
[location]="{ id: location.id, key: location.key, name: location.name }"
|
[location]="{ id: location.id, key: location.key, name: location.name }"
|
||||||
[current]="true"
|
[current]="true"
|
||||||
[disabled]="true"
|
[disabled]="true"
|
||||||
@@ -26,25 +26,25 @@
|
|||||||
|
|
||||||
@for (connection of location.connections; track connection.targetLocation.id) {
|
@for (connection of location.connections; track connection.targetLocation.id) {
|
||||||
<app-location-node
|
<app-location-node
|
||||||
class="world-page__node world-page__node--reachable"
|
[class]="'world-page__node world-page__node--' + connection.targetLocation.key"
|
||||||
[location]="connection.targetLocation"
|
[location]="connection.targetLocation"
|
||||||
[selected]="
|
[selected]="
|
||||||
worldStore.selectedConnection()?.targetLocation?.id === connection.targetLocation.id
|
worldStore.selectedConnection()?.targetLocation?.id === connection.targetLocation.id
|
||||||
"
|
"
|
||||||
[disabled]="worldStore.currentTravel()?.status === 'TRAVELLING'"
|
[disabled]="worldStore.loading() || worldStore.currentTravel()?.status !== 'IDLE'"
|
||||||
(choose)="selectConnection(connection)"
|
(choose)="selectConnection(connection)"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|
||||||
<app-travel-panel
|
|
||||||
class="world-page__travel-panel"
|
|
||||||
[selectedConnection]="worldStore.selectedConnection()"
|
|
||||||
[currentTravel]="worldStore.currentTravel()"
|
|
||||||
[remainingSeconds]="worldStore.remainingSeconds()"
|
|
||||||
[busy]="worldStore.loading()"
|
|
||||||
(travelStart)="worldStore.startTravel()"
|
|
||||||
/>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<app-travel-panel
|
||||||
|
class="world-page__travel-panel"
|
||||||
|
[selectedConnection]="worldStore.selectedConnection()"
|
||||||
|
[currentTravel]="worldStore.currentTravel()"
|
||||||
|
[remainingSeconds]="worldStore.remainingSeconds()"
|
||||||
|
[busy]="worldStore.loading()"
|
||||||
|
(travelStart)="worldStore.startTravel()"
|
||||||
|
/>
|
||||||
} @else {
|
} @else {
|
||||||
<section class="world-page__empty" aria-live="polite">
|
<section class="world-page__empty" aria-live="polite">
|
||||||
<p>Weltkarte wird vorbereitet.</p>
|
<p>Weltkarte wird vorbereitet.</p>
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
@if (worldStore.error(); as error) {
|
@if (worldStore.error(); as error) {
|
||||||
<section class="world-page__error" role="alert">
|
<section class="world-page__error" role="alert">
|
||||||
<p>{{ error }}</p>
|
<p>{{ error }}</p>
|
||||||
<button type="button" (click)="retry()">Erneut versuchen</button>
|
<button type="button" data-world-retry (click)="retry()">Erneut versuchen</button>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -77,20 +77,18 @@
|
|||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.world-page__node--current {
|
.world-page__node--south-gate {
|
||||||
inset: 58% auto auto 12%;
|
inset: 58% auto auto 12%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.world-page__node--reachable {
|
.world-page__node--burned-road {
|
||||||
inset: 39% auto auto 60%;
|
inset: 39% auto auto 60%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.world-page__travel-panel {
|
.world-page__travel-panel {
|
||||||
position: absolute;
|
display: block;
|
||||||
z-index: 3;
|
inline-size: min(29rem, 100%);
|
||||||
inset: auto 50% var(--ar-space-5) auto;
|
margin: var(--ar-space-4) auto 0;
|
||||||
inline-size: min(29rem, calc(100% - 2rem));
|
|
||||||
transform: translateX(50%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.world-page__loading,
|
.world-page__loading,
|
||||||
@@ -148,11 +146,11 @@
|
|||||||
min-block-size: 34rem;
|
min-block-size: 34rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.world-page__node--current {
|
.world-page__node--south-gate {
|
||||||
inset-inline-start: 6%;
|
inset-inline-start: 6%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.world-page__node--reachable {
|
.world-page__node--burned-road {
|
||||||
inset-inline-start: 51%;
|
inset-inline-start: 51%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,24 @@ const southGate: CurrentLocationResponse = {
|
|||||||
connections: [burnedRoadConnection],
|
connections: [burnedRoadConnection],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const burnedRoad: CurrentLocationResponse = {
|
||||||
|
...southGate,
|
||||||
|
id: 'burned-road-id',
|
||||||
|
key: 'burned-road',
|
||||||
|
name: 'Verbrannte Straße',
|
||||||
|
description: 'Die erste Jagdzone zwischen Asche und zerbrochenen Wagen.',
|
||||||
|
isSafe: false,
|
||||||
|
huntingEnabled: true,
|
||||||
|
artworkPath: '/images/backgrounds/Aschestrasse.png',
|
||||||
|
connections: [
|
||||||
|
{
|
||||||
|
targetLocation: { id: 'south-gate-id', key: 'south-gate', name: 'Südtor von Graufurt' },
|
||||||
|
travelDurationSeconds: 10,
|
||||||
|
danger: 'LOW',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
describe('WorldPageComponent', () => {
|
describe('WorldPageComponent', () => {
|
||||||
let selectedConnection: ReturnType<typeof signal<CurrentLocationConnection | null>>;
|
let selectedConnection: ReturnType<typeof signal<CurrentLocationConnection | null>>;
|
||||||
let store: {
|
let store: {
|
||||||
@@ -93,6 +111,32 @@ describe('WorldPageComponent', () => {
|
|||||||
expect(element.textContent).toContain('Niedrig');
|
expect(element.textContent).toContain('Niedrig');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps each location at its geographic position when the authoritative current location swaps', () => {
|
||||||
|
const fixture = TestBed.createComponent(WorldPageComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
expect(
|
||||||
|
element.querySelector('.world-page__node--south-gate [data-location-key="south-gate"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
element.querySelector('.world-page__node--burned-road [data-location-key="burned-road"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(element.querySelector('.world-page__scene')?.getAttribute('style')).toContain(
|
||||||
|
'map_ashen_realm-1440.jpg',
|
||||||
|
);
|
||||||
|
|
||||||
|
store.currentLocation.set(burnedRoad);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
element.querySelector('.world-page__node--south-gate [data-location-key="south-gate"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
element.querySelector('.world-page__node--burned-road [data-location-key="burned-road"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('starts server-authoritative travel from the travel panel', () => {
|
it('starts server-authoritative travel from the travel panel', () => {
|
||||||
const fixture = TestBed.createComponent(WorldPageComponent);
|
const fixture = TestBed.createComponent(WorldPageComponent);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
@@ -123,5 +167,42 @@ describe('WorldPageComponent', () => {
|
|||||||
expect(element.textContent).toContain('Ankunft in');
|
expect(element.textContent).toContain('Ankunft in');
|
||||||
expect(element.textContent).toContain('00:00:06');
|
expect(element.textContent).toContain('00:00:06');
|
||||||
expect(element.querySelector<HTMLButtonElement>('[data-travel-start]')?.disabled).toBe(true);
|
expect(element.querySelector<HTMLButtonElement>('[data-travel-start]')?.disabled).toBe(true);
|
||||||
|
expect(element.querySelector('app-travel-panel section')?.hasAttribute('aria-live')).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(element.querySelector<HTMLElement>('time[role="timer"]')?.textContent).toContain(
|
||||||
|
'00:00:06',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('locks nodes and selected travel action while authoritative completion reloads', () => {
|
||||||
|
store.currentTravel.set({
|
||||||
|
status: 'COMPLETED',
|
||||||
|
targetLocation: burnedRoadConnection.targetLocation,
|
||||||
|
});
|
||||||
|
store.loading.set(true);
|
||||||
|
store.selectConnection(burnedRoadConnection);
|
||||||
|
const fixture = TestBed.createComponent(WorldPageComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
expect(
|
||||||
|
element.querySelector<HTMLButtonElement>('[data-location-key="burned-road"]')?.disabled,
|
||||||
|
).toBe(true);
|
||||||
|
expect(element.querySelector<HTMLButtonElement>('[data-travel-start]')?.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries a displayed world error through the store', () => {
|
||||||
|
store.error.set('Weltzustand nicht verfügbar.');
|
||||||
|
const fixture = TestBed.createComponent(WorldPageComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
expect(element.querySelector('[role="alert"]')?.textContent).toContain(
|
||||||
|
'Weltzustand nicht verfügbar.',
|
||||||
|
);
|
||||||
|
element.querySelector<HTMLButtonElement>('[data-world-retry]')?.click();
|
||||||
|
|
||||||
|
expect(store.load).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,14 +12,15 @@ import { WorldStore } from './world.store';
|
|||||||
})
|
})
|
||||||
export class WorldPageComponent implements OnInit {
|
export class WorldPageComponent implements OnInit {
|
||||||
protected readonly worldStore = inject(WorldStore);
|
protected readonly worldStore = inject(WorldStore);
|
||||||
protected readonly mapBackground = "url('/images/backgrounds/map_ashen_realm.png')";
|
protected readonly mapBackground =
|
||||||
|
"url('/images/backgrounds/runtime/map_ashen_realm-1440.jpg'), url('/images/backgrounds/map_ashen_realm.png')";
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
void this.worldStore.load();
|
void this.worldStore.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected selectConnection(connection: CurrentLocationConnection): void {
|
protected selectConnection(connection: CurrentLocationConnection): void {
|
||||||
if (this.worldStore.currentTravel()?.status !== 'TRAVELLING') {
|
if (!this.worldStore.loading() && this.worldStore.currentTravel()?.status === 'IDLE') {
|
||||||
this.worldStore.selectConnection(connection);
|
this.worldStore.selectConnection(connection);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,4 +204,37 @@ describe('WorldStore', () => {
|
|||||||
expect(api.getCharacter).toHaveBeenCalledTimes(2);
|
expect(api.getCharacter).toHaveBeenCalledTimes(2);
|
||||||
expect(api.getCurrentLocation).toHaveBeenCalledTimes(2);
|
expect(api.getCurrentLocation).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('clears selection and rejects a second start while authoritative completion reload is pending', async () => {
|
||||||
|
const pendingCharacter = new Subject<CharacterResponse>();
|
||||||
|
const pendingLocation = new Subject<CurrentLocationResponse>();
|
||||||
|
api.getCharacter.mockReturnValueOnce(of(character)).mockReturnValue(pendingCharacter);
|
||||||
|
api.getCurrentLocation
|
||||||
|
.mockReturnValueOnce(of(currentLocation))
|
||||||
|
.mockReturnValue(pendingLocation);
|
||||||
|
api.getCurrentTravel
|
||||||
|
.mockReturnValueOnce(of(travelling))
|
||||||
|
.mockReturnValueOnce(of({ status: 'COMPLETED', targetLocation: travelling.targetLocation }));
|
||||||
|
|
||||||
|
await store.load();
|
||||||
|
store.selectConnection(currentLocation.connections[0]);
|
||||||
|
await vi.advanceTimersByTimeAsync(10_000);
|
||||||
|
|
||||||
|
expect(store.selectedConnection()).toBeNull();
|
||||||
|
expect(store.loading()).toBe(true);
|
||||||
|
expect(store.currentLocation()).toEqual(currentLocation);
|
||||||
|
|
||||||
|
await store.startTravel();
|
||||||
|
|
||||||
|
expect(api.startTravel).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
pendingCharacter.next(character);
|
||||||
|
pendingCharacter.complete();
|
||||||
|
pendingLocation.next(currentLocation);
|
||||||
|
pendingLocation.complete();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
|
||||||
|
expect(store.loading()).toBe(false);
|
||||||
|
expect(store.currentTravel()).toEqual({ status: 'IDLE' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -132,7 +132,18 @@ export class WorldStore implements OnDestroy {
|
|||||||
|
|
||||||
this.remainingSecondsState.set(null);
|
this.remainingSecondsState.set(null);
|
||||||
if (travel.status === 'COMPLETED') {
|
if (travel.status === 'COMPLETED') {
|
||||||
await this.reloadAuthoritativeState();
|
this.selectedConnectionState.set(null);
|
||||||
|
this.loadingState.set(true);
|
||||||
|
try {
|
||||||
|
await this.reloadAuthoritativeState();
|
||||||
|
if (!this.destroyed) {
|
||||||
|
this.currentTravelState.set({ status: 'IDLE' });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!this.destroyed) {
|
||||||
|
this.loadingState.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,12 @@
|
|||||||
<p class="context-panel__selection">Ausgewähltes Ziel: {{ selected.targetLocation.name }}</p>
|
<p class="context-panel__selection">Ausgewähltes Ziel: {{ selected.targetLocation.name }}</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
<img
|
<picture class="context-panel__artwork">
|
||||||
class="context-panel__artwork"
|
@if (runtimeArtworkPath(location.artworkPath); as runtimeArtwork) {
|
||||||
[src]="location.artworkPath"
|
<source [srcset]="runtimeArtwork" type="image/jpeg" />
|
||||||
[alt]="'Ortsansicht: ' + location.name"
|
}
|
||||||
/>
|
<img [src]="location.artworkPath" [alt]="'Ortsansicht: ' + location.name" />
|
||||||
|
</picture>
|
||||||
<p class="context-panel__description">{{ location.description }}</p>
|
<p class="context-panel__description">{{ location.description }}</p>
|
||||||
|
|
||||||
<dl class="context-panel__facts">
|
<dl class="context-panel__facts">
|
||||||
|
|||||||
@@ -42,8 +42,13 @@
|
|||||||
.context-panel__artwork {
|
.context-panel__artwork {
|
||||||
display: block;
|
display: block;
|
||||||
inline-size: 100%;
|
inline-size: 100%;
|
||||||
aspect-ratio: 16 / 9;
|
|
||||||
margin-block-end: var(--ar-space-3);
|
margin-block-end: var(--ar-space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-panel__artwork img {
|
||||||
|
display: block;
|
||||||
|
inline-size: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
border: 1px solid var(--ar-border);
|
border: 1px solid var(--ar-border);
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { signal } from '@angular/core';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { WorldStore } from '../../features/world/world.store';
|
||||||
|
import { ContextPanelComponent } from './context-panel.component';
|
||||||
|
|
||||||
|
describe('ContextPanelComponent', () => {
|
||||||
|
it('uses a runtime location derivative while preserving the API artwork path as image fallback', async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ContextPanelComponent],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: WorldStore,
|
||||||
|
useValue: {
|
||||||
|
currentLocation: signal({
|
||||||
|
id: 'south-gate-id',
|
||||||
|
key: 'south-gate',
|
||||||
|
name: 'Südtor von Graufurt',
|
||||||
|
description: 'Der letzte sichere Schritt vor den Aschenfeldern.',
|
||||||
|
regionKey: 'ashen-fields',
|
||||||
|
minRecommendedLevel: 1,
|
||||||
|
maxRecommendedLevel: 1,
|
||||||
|
dangerLevel: 0,
|
||||||
|
isSafe: true,
|
||||||
|
huntingEnabled: false,
|
||||||
|
artworkPath: '/images/backgrounds/Suedtor.png',
|
||||||
|
connections: [],
|
||||||
|
}),
|
||||||
|
selectedConnection: signal(null),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(ContextPanelComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
expect(element.querySelector<HTMLSourceElement>('source')?.srcset).toContain(
|
||||||
|
'/images/backgrounds/runtime/Suedtor-960.jpg',
|
||||||
|
);
|
||||||
|
expect(element.querySelector<HTMLImageElement>('img')?.getAttribute('src')).toBe(
|
||||||
|
'/images/backgrounds/Suedtor.png',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
import { Component, inject } from '@angular/core';
|
||||||
import { WorldStore } from '../../features/world/world.store';
|
import { WorldStore } from '../../features/world/world.store';
|
||||||
|
|
||||||
|
const runtimeArtworkPaths: Readonly<Record<string, string>> = {
|
||||||
|
'/images/backgrounds/Suedtor.png': '/images/backgrounds/runtime/Suedtor-960.jpg',
|
||||||
|
'/images/backgrounds/Aschestrasse.png': '/images/backgrounds/runtime/Aschestrasse-960.jpg',
|
||||||
|
};
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-context-panel',
|
selector: 'app-context-panel',
|
||||||
templateUrl: './context-panel.component.html',
|
templateUrl: './context-panel.component.html',
|
||||||
@@ -8,4 +13,8 @@ import { WorldStore } from '../../features/world/world.store';
|
|||||||
})
|
})
|
||||||
export class ContextPanelComponent {
|
export class ContextPanelComponent {
|
||||||
protected readonly worldStore = inject(WorldStore);
|
protected readonly worldStore = inject(WorldStore);
|
||||||
|
|
||||||
|
protected runtimeArtworkPath(artworkPath: string): string | undefined {
|
||||||
|
return runtimeArtworkPaths[artworkPath];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user