import { describe, it, expect, beforeAll } from 'vitest'; import { existsSync } from 'node:fs'; import { resolve } from 'node:path'; import { Story } from 'inkjs'; import { loadInkStory, bindGardenStateToInk, INK_VARIABLE_MAP, } from './ink-loader'; import type { AppStoreShape } from '../store'; /** * Phase 2 Plan 02-04 Task 1 sanity tests for the Ink runtime loader. * * Precondition (W9): the test file does NOT call compileAllInk() — * concurrent invocations of the compile script would race on the * src/content/compiled-ink/ wipe step. Instead, we assert the compiled * artefacts exist and surface a clear fix-it message if they don't. The * `npm run ci` chain runs `compile:ink` BEFORE `test`, so the artefact * is always present in CI. * * The `compiledExists` check happens INSIDE beforeAll (not at module * eval) because compile-ink.test.mjs may wipe + regenerate the * compiled-ink/ directory at test-execution time. Reading existsSync at * module eval would race with that test file's wipe step. */ beforeAll(() => { const compiledExists = existsSync( resolve(process.cwd(), 'src/content/compiled-ink/season1/lura-arrival.ink.json'), ); if (!compiledExists) { throw new Error( 'ink-loader.test.ts: compiled Ink JSON missing. Run `npm run compile:ink` (or `npm run build`) before this suite.', ); } }); function emptySnapshot(overrides: Partial = {}): AppStoreShape { return { // GardenSlice tiles: new Array(16).fill(null), unlockedPlantTypes: ['rosemary'], tickCount: 0, lastTickAt: 0, pendingCommands: [], enqueueCommand: () => {}, drainCommands: () => [], applyTilesAndUnlocks: () => {}, setTickCount: () => {}, setLastTickAt: () => {}, // MemorySlice harvestedFragmentIds: [], fragmentRevealId: null, setHarvested: () => {}, setFragmentRevealId: () => {}, // NarrativeSlice luraBeatProgress: { arrived: false, mid: false, farewell: false, pending: null }, dialogueOverlayOpen: false, setLuraBeatProgress: () => {}, setDialogueOverlayOpen: () => {}, // SessionSlice beginGateDismissed: false, persistenceToastShown: false, letterOverlayOpen: false, pendingLetterEventBlock: null, dismissBeginGate: () => {}, setPersistenceToastShown: () => {}, openLetter: () => {}, dismissLetter: () => {}, ...(overrides as Partial), } as AppStoreShape; } describe('loadInkStory', () => { it('returns an inkjs Story instance for lura-arrival', async () => { const story = await loadInkStory('lura-arrival'); expect(story).toBeInstanceOf(Story); }); it('returns an inkjs Story instance for compost-acknowledgements', async () => { const story = await loadInkStory('compost-acknowledgements'); expect(story).toBeInstanceOf(Story); }); it('returns an inkjs Story instance for lura-mid + lura-farewell', async () => { const m = await loadInkStory('lura-mid'); const f = await loadInkStory('lura-farewell'); expect(m).toBeInstanceOf(Story); expect(f).toBeInstanceOf(Story); }); }); describe('bindGardenStateToInk', () => { it('sets fragment_count on a story that declares the VAR', async () => { const story = await loadInkStory('lura-arrival'); const snap = emptySnapshot({ harvestedFragmentIds: ['a', 'b', 'c'] }); bindGardenStateToInk(story, snap); // Read back via the same variablesState surface to confirm the bind landed. const value = ( story.variablesState as unknown as Record )['fragment_count']; expect(value).toBe(3); }); it('does NOT throw when binding to a story missing some variables (compost has only fragment_count)', async () => { const story = await loadInkStory('compost-acknowledgements'); const snap = emptySnapshot({ harvestedFragmentIds: ['a', 'b'] }); expect(() => bindGardenStateToInk(story, snap)).not.toThrow(); // fragment_count was declared and should be set. const fc = ( story.variablesState as unknown as Record )['fragment_count']; expect(fc).toBe(2); }); it('sets last_plant_type to empty string when there are no harvests', async () => { const story = await loadInkStory('lura-arrival'); const snap = emptySnapshot({ harvestedFragmentIds: [] }); bindGardenStateToInk(story, snap); const lpt = ( story.variablesState as unknown as Record )['last_plant_type']; expect(lpt).toBe(''); }); }); describe('INK_VARIABLE_MAP (Pitfall 4 — snake_case mandatory)', () => { it('every key is snake_case (lowercase letters + underscores only)', () => { const keys = Object.keys(INK_VARIABLE_MAP); expect(keys.length).toBeGreaterThan(0); for (const key of keys) { expect(key).toMatch(/^[a-z][a-z_]*$/); } }); it('declares the three Phase-2 slots', () => { const keys = Object.keys(INK_VARIABLE_MAP); expect(keys).toContain('fragment_count'); expect(keys).toContain('last_plant_type'); expect(keys).toContain('last_fragment_title'); }); });