feat(web): add the quest journal

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-22 23:12:09 +02:00
parent 636efb6b5a
commit bc0d910190
12 changed files with 602 additions and 7 deletions

View File

@@ -0,0 +1,139 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import type { QuestView } from '../../core/api/game-api.models';
import { QuestPageComponent } from './quest-page.component';
import { QuestStore } from './quest.store';
function quest(over: Partial<QuestView> = {}): QuestView {
return {
key: 'trouble-beyond-the-gate',
title: 'Trouble Beyond the Gate',
description: 'The warden wants five Ashen Pelts.',
status: 'ACTIVE',
objectives: [
{
key: 'collect-pelts-first',
description: 'Collect Ashen Pelts',
type: 'COLLECT_ITEM',
targetKey: 'ash-pelt',
required: 5,
current: 1,
completed: false,
},
],
currentObjectiveKey: 'collect-pelts-first',
hint: null,
...over,
};
}
async function setup(quests: QuestView[], error: string | null = null) {
const questsSignal = signal(quests);
const store = {
quests: questsSignal.asReadonly(),
activeQuests: signal(quests.filter((entry) => entry.status === 'ACTIVE')),
completedQuests: signal(
quests.filter((entry) => entry.status === 'COMPLETED'),
),
availableQuests: signal(
quests.filter((entry) => entry.status === 'AVAILABLE'),
),
loading: signal(false),
error: signal(error),
load: vi.fn().mockResolvedValue(undefined),
setQuest: vi.fn(),
};
await TestBed.configureTestingModule({
imports: [QuestPageComponent],
providers: [{ provide: QuestStore, useValue: store }],
}).compileComponents();
const fixture = TestBed.createComponent(QuestPageComponent);
fixture.detectChanges();
return { fixture, store, text: () => fixture.nativeElement.textContent ?? '' };
}
describe('QuestPageComponent', () => {
it('loads the quest log on init', async () => {
const { store } = await setup([quest()]);
expect(store.load).toHaveBeenCalledOnce();
});
it('renders the active quest with its current objective', async () => {
const { fixture, text } = await setup([quest()]);
expect(text()).toContain('Trouble Beyond the Gate');
expect(text()).toContain('The warden wants five Ashen Pelts.');
expect(
fixture.nativeElement.querySelector('[data-objective-description]')
.textContent,
).toContain('Collect Ashen Pelts');
});
it('renders collect progress as current over required', async () => {
const { fixture } = await setup([quest()]);
// The block slice §12 prints verbatim.
expect(
fixture.nativeElement
.querySelector('[data-objective-progress]')
.textContent.replace(/\s+/g, ' ')
.trim(),
).toBe('1 / 5');
});
it('renders the blocked hint under the objective', async () => {
const { fixture } = await setup([
quest({
hint: 'You cannot carry enough pelts. Return to the South Gate Warden.',
}),
]);
expect(
fixture.nativeElement.querySelector('[data-objective-hint]').textContent,
).toContain('You cannot carry enough pelts.');
});
it('shows no hint when the step is merely unfinished', async () => {
const { fixture } = await setup([quest()]);
expect(
fixture.nativeElement.querySelector('[data-objective-hint]'),
).toBeNull();
});
it('shows an empty state when nothing is taken on', async () => {
const { text } = await setup([]);
expect(text()).toContain('You have taken nothing on.');
});
it('lists completed quests separately from active ones', async () => {
const { fixture, text } = await setup([
quest({
key: 'done-quest',
title: 'An Older Errand',
status: 'COMPLETED',
currentObjectiveKey: null,
}),
]);
expect(text()).toContain('An Older Errand');
expect(
fixture.nativeElement.querySelector('[data-quest-completed]'),
).not.toBeNull();
// The active panel is still empty, so the two do not blur together.
expect(text()).toContain('You have taken nothing on.');
});
it('offers a retry when the log could not be read', async () => {
const { fixture, store } = await setup([], 'Could not load your quests.');
fixture.nativeElement.querySelector('[data-quest-retry]').click();
expect(store.load).toHaveBeenCalledTimes(2);
});
});