c90f8f1e5c
- scripts/compile-ink.mjs: build-time inklecate runner using bundled binary (BLOCKER 4 — uses node_modules/inklecate/bin, not stale -windows/-mac path strings). Assumption A6 verified first-try on Windows; the same binary path resolution works on macOS + Linux per the wrapper's own getInklecatePath convention. - scripts/compile-ink.test.mjs: 3 Vitest cases proving the compiler runs + emits valid JSON with inkVersion. wipe=false for the test path so it can run in parallel with the ink-loader test without racing on the wipe step. - 4 Season-1 .ink files authored in voice (Lura warmth-anchor, gardener-keeper for compost): lura-arrival.ink, lura-mid.ink, lura-farewell.ink, compost-acknowledgements.ink (rewrite of Plan 02-03 scaffolded version into VAR-driven branch shape consumable by the runtime). - src/content/ink-loader.ts: loadInkStory + bindGardenStateToInk + INK_VARIABLE_MAP. Centralized snake_case slot mapping per Pitfall 4. UTF-8 BOM stripped before Story instantiation. - src/content/ink-loader.test.ts: 8 cases — Story instantiation for all 4 beats, fragment_count binding, Pitfall 4 snake_case enforcement, silent skip for stories missing declared vars. - package.json: build now runs compile:ink first; ci chain runs compile:ink before test so ink-loader.test.ts's precondition check passes. - .gitignore: src/content/compiled-ink/ excluded (regenerated on every build). npm run ci exits 0; 11 new tests green (228 total). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
143 lines
5.0 KiB
TypeScript
143 lines
5.0 KiB
TypeScript
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> = {}): 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<AppStoreShape>),
|
|
} 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<string, unknown>
|
|
)['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<string, unknown>
|
|
)['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<string, unknown>
|
|
)['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');
|
|
});
|
|
});
|