feat(02-03): journal + reveal modal + harvest pointer wiring
Task 2 of Plan 02-03: ship the Memory Journal UI surfaces (D-23/D-24/D-25) and wire harvest + compost pointer events through the Garden scene to the sim → store → React reveal flow. src/ui/journal/ (new module): - Journal.tsx — full-screen modal (D-24); fragments grouped by Season; DOM-rendered text with userSelect: text per MEMR-05; reads harvestedFragmentIds from the store; resolves ids against the eager `fragments` corpus (defensive — unresolvable ids skip silently). - FragmentRevealModal.tsx — D-25 active-play reveal modal; backdrop click + inner Close button dismiss; event.stopPropagation on the article body so clicking inside the text doesn't dismiss; defensive silent dismiss on unresolvable id. - journal-icon.tsx — D-23 + D-29 corner affordance; gated by selectJournalRevealed (`harvestedFragmentIds.length > 0`); local open state (no store pollution); 'j' hotkey deferred to Plan 02-05. - index.ts — barrel. - 16 new Vitest cases across 3 test files (Journal: 7 / FragmentRevealModal: 6 / journal-icon: 3); all green. src/App.tsx — mount FragmentRevealModal + JournalIcon as DOM siblings of PhaserGame. src/ui/index.ts — re-export ./journal. src/game/scenes/Garden.ts — harvest/compost pointer flow: - create() builds a SimContext from the eager `fragments` corpus filtered to Season 1; passed to every simulateOneTick call (Phase 4+ should swap to await loadSeasonFragments(currentSeason) when Season transitions land). - handleTilePointerDown branches on tile state: empty → seed picker event; ready plant → enqueue 'harvest' command; immature plant → enqueue 'compost' command (TODO Plan 02-04: render the Ink-authored compost acknowledgement beat from compost-acknowledgements.ink). - update() detects newly-appended harvestedFragmentIds and sets fragmentRevealId so the reveal modal pops with the new fragment text. - BLOCKER 3 invariant preserved — sim writes tickCount, never lastTickAt. content/dialogue/season1/compost-acknowledgements.ink — authored content for the GARD-04 + D-07 compost beat. 6 short lines in the gardener-keeper voice (NOT Lura — she's the warmth anchor; the garden's voice is the contrast). Plan 02-04 wires the inkjs runtime; Plan 02-03 ships the content so the writer can iterate independently. 214/214 tests green (was 163; +51 new this plan); npm run lint exits 0; npm run ci exits 0; npm run build exits 0 with the expected INEFFECTIVE_DYNAMIC_IMPORT warnings (eager `fragments` export still imports the Season-1 yaml/md statically alongside the lazy loadSeasonFragments path — documented in 02-02-SUMMARY.md). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||
import { Journal } from './Journal';
|
||||
import { appStore } from '../../store';
|
||||
|
||||
// Phaser does not initialize under happy-dom. The Journal does not import
|
||||
// Phaser directly, but the @testing-library cleanup chain can transitively
|
||||
// pull modules that do — and we want to keep these tests isolated from
|
||||
// the Phaser runtime regardless.
|
||||
vi.mock('../../game/event-bus', () => ({
|
||||
eventBus: {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
removeAllListeners: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
function resetStore(harvested: string[] = []): void {
|
||||
appStore.setState({ harvestedFragmentIds: harvested });
|
||||
}
|
||||
|
||||
describe('Journal (D-24 full-screen modal, MEMR-04, MEMR-05)', () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
resetStore();
|
||||
});
|
||||
|
||||
it('returns null when open=false', () => {
|
||||
const { container } = render(<Journal open={false} onClose={() => {}} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the empty-state copy from uiStrings when no fragments are harvested', () => {
|
||||
render(<Journal open={true} onClose={() => {}} />);
|
||||
// From content/seasons/01-soil/ui-strings.yaml: "Nothing yet. Plant something."
|
||||
expect(screen.getByText('Nothing yet. Plant something.')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders the body of a harvested fragment in selectable DOM (MEMR-05)', () => {
|
||||
resetStore(['season1.soil.first-bloom']);
|
||||
render(<Journal open={true} onClose={() => {}} />);
|
||||
// The body in fragments.yaml starts with "The first thing that grew was rosemary."
|
||||
expect(screen.getByText(/The first thing that grew was rosemary/)).toBeTruthy();
|
||||
// And the article wrapper carries userSelect: text (MEMR-05).
|
||||
const article = document.querySelector('article[data-fragment-id="season1.soil.first-bloom"]');
|
||||
expect(article).not.toBeNull();
|
||||
expect((article as HTMLElement).style.userSelect).toBe('text');
|
||||
});
|
||||
|
||||
it('groups fragments by Season (D-24)', () => {
|
||||
resetStore(['season1.soil.first-bloom', 'season1.soil.the-cat']);
|
||||
render(<Journal open={true} onClose={() => {}} />);
|
||||
// Section header text "Season 1"
|
||||
expect(screen.getByText('Season 1')).toBeTruthy();
|
||||
// Both fragments rendered.
|
||||
expect(screen.getByText(/The first thing that grew was rosemary/)).toBeTruthy();
|
||||
expect(screen.getByText(/The cat is missing now too/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('the close button invokes the onClose callback exactly once', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<Journal open={true} onClose={onClose} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /close journal/i }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('aria-label on the dialog is "Memory Journal" (accessibility)', () => {
|
||||
render(<Journal open={true} onClose={() => {}} />);
|
||||
expect(screen.getByLabelText('Memory Journal')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('silently skips harvested ids that do not resolve to a known Fragment (defensive)', () => {
|
||||
resetStore(['season1.soil.first-bloom', 'season9.bogus.never-shipped']);
|
||||
render(<Journal open={true} onClose={() => {}} />);
|
||||
// Real one renders; bogus one does not crash, just doesn't appear.
|
||||
expect(screen.getByText(/The first thing that grew was rosemary/)).toBeTruthy();
|
||||
expect(screen.queryByText(/season9.bogus/)).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user