diff --git a/.gitignore b/.gitignore index 47b3c74..a85a7db 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ publish/iching.js .prov/ logs/ .research/ +/data-acquisition/ +# Local JSON -> TS acquisition tooling depends on ignored /data-acquisition inputs. +/scripts/data-acquisition/ diff --git a/apps/cli/src/__tests__/config-command.test.ts b/apps/cli/src/__tests__/config-command.test.ts index db57b58..c71caa1 100644 --- a/apps/cli/src/__tests__/config-command.test.ts +++ b/apps/cli/src/__tests__/config-command.test.ts @@ -53,13 +53,14 @@ describe("config command", () => { await rm(dataDir, { recursive: true, force: true }); }); - test("list shows every persisted key, including castMethod, castMode, and taijituStyle", async () => { + test("list shows every persisted key, including language and cast settings", async () => { const { exitCode, stdout } = await runCli(dataDir, ["config", "list"]); expect(exitCode).toBe(0); // Every field that JsonConfigStore.DEFAULT_CONFIG persists must be visible. for (const key of [ "theme", "motion", + "language", "color", "timezone", "glyphAnim", @@ -78,6 +79,12 @@ describe("config command", () => { expect(stdout.trim()).toBe("auto"); }, 20_000); + test("get language returns the default value", async () => { + const { exitCode, stdout } = await runCli(dataDir, ["config", "get", "language"]); + expect(exitCode).toBe(0); + expect(stdout.trim()).toBe("zh-Hant"); + }, 20_000); + test("get taijituStyle returns the default value", async () => { const { exitCode, stdout } = await runCli(dataDir, ["config", "get", "taijituStyle"]); expect(exitCode).toBe(0); @@ -105,6 +112,14 @@ describe("config command", () => { expect(getResult.stdout.trim()).toBe("yarrow"); }, 20_000); + test("set language en persists, reload reads en", async () => { + const setResult = await runCli(dataDir, ["config", "set", "language", "en"]); + expect(setResult.exitCode).toBe(0); + const getResult = await runCli(dataDir, ["config", "get", "language"]); + expect(getResult.exitCode).toBe(0); + expect(getResult.stdout.trim()).toBe("en"); + }, 20_000); + test("set castMode rejects yarrow (now out of castMode's domain)", async () => { const { exitCode, stderr } = await runCli(dataDir, ["config", "set", "castMode", "yarrow"]); expect(exitCode).not.toBe(0); @@ -136,6 +151,12 @@ describe("config command", () => { expect(stderr.toLowerCase()).toContain("invalid value"); }, 20_000); + test("set language rejects invalid value", async () => { + const { exitCode, stderr } = await runCli(dataDir, ["config", "set", "language", "latin"]); + expect(exitCode).not.toBe(0); + expect(stderr.toLowerCase()).toContain("invalid value"); + }, 20_000); + test("get unknown key reports error", async () => { const { exitCode, stderr } = await runCli(dataDir, ["config", "get", "notARealKey"]); expect(exitCode).not.toBe(0); diff --git a/apps/cli/src/__tests__/scene-factories.test.ts b/apps/cli/src/__tests__/scene-factories.test.ts index 9f778a1..80e2b3f 100644 --- a/apps/cli/src/__tests__/scene-factories.test.ts +++ b/apps/cli/src/__tests__/scene-factories.test.ts @@ -8,7 +8,12 @@ import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { JsonlJournalStore } from "@iching/storage"; -import { BrowseScene, DetailScene, JournalScene } from "@iching/terminal"; +import { + BrowseScene, + DetailScene, + JournalScene, + ShuoGuaChapterScene, +} from "@iching/terminal"; import { makeBrowseFactory, makeJournalFactory, @@ -42,6 +47,13 @@ describe("makeBrowseFactory", () => { expect(factory({ type: "openJournal" })).toBeNull(); expect(factory({ type: "openSettings" })).toBeNull(); }); + + test("typed openShuoguaChapter signal returns ShuoGuaChapterScene", () => { + const factory = makeBrowseFactory({ journal }); + const scene = factory({ type: "openShuoguaChapter", chapter: 3, op: "nuclear" }); + expect(scene).toBeInstanceOf(ShuoGuaChapterScene); + expect((scene as ShuoGuaChapterScene).getOp()).toBe("nuclear"); + }); }); describe("makeJournalFactory", () => { @@ -73,6 +85,17 @@ describe("makeJournalFactory", () => { expect(scene).toBeInstanceOf(BrowseScene); }); + test("typed openShuoguaChapter returns ShuoGuaChapterScene", () => { + const factory = makeJournalFactory({ + journal, + entries: [], + session: { cols: 80, rows: 24 }, + }); + const scene = factory({ type: "openShuoguaChapter", chapter: 6, op: "mirror" }); + expect(scene).toBeInstanceOf(ShuoGuaChapterScene); + expect((scene as ShuoGuaChapterScene).getOp()).toBe("mirror"); + }); + test("typed openJournal resets to JournalScene (j-from-replay path)", () => { const factory = makeJournalFactory({ journal, diff --git a/apps/cli/src/__tests__/style-union-parity.test.ts b/apps/cli/src/__tests__/style-union-parity.test.ts new file mode 100644 index 0000000..bc6a8ca --- /dev/null +++ b/apps/cli/src/__tests__/style-union-parity.test.ts @@ -0,0 +1,80 @@ +import { describe, test, expect } from "bun:test"; +import { STYLES, QUOTE_STYLES, type Hexagram } from "@iching/core"; +import { VALID_STYLES } from "../commands/hexagram.js"; +import { hexagramToJson } from "../output/json.js"; + +/** + * U7 parity test — Style union, hardcoded enumeration lockstep. + * + * The four lists below MUST agree on the shape of the Style universe so + * future additions can't drift between them. + * + * Style (type) — types.ts + * STYLES (array) — data/trigrams.ts (must match Style at runtime) + * QUOTE_STYLES — data/trigrams.ts (Style minus "st" minus "gc") + * VALID_STYLES — apps/cli/src/commands/hexagram.ts (Style minus "st") + * + * "gc" is in Style and VALID_STYLES but intentionally NOT in QUOTE_STYLES: + * 卦辭 is the root oracle, not a random-quotable commentary lineage. + */ + +describe("Style union — parity across hardcoded lists", () => { + test("STYLES carries the full Style union (7 keys after U7)", () => { + expect(STYLES.sort()).toEqual(["dx", "en", "gc", "st", "te", "tu", "w"]); + }); + + test("QUOTE_STYLES is STYLES minus \"st\" minus \"gc\" (5 quote-able lineages)", () => { + const expected = STYLES.filter((s) => s !== "st" && s !== "gc").sort(); + expect(QUOTE_STYLES.sort()).toEqual(expected); + expect(QUOTE_STYLES).not.toContain("gc"); + expect(QUOTE_STYLES).not.toContain("st"); + }); + + test("VALID_STYLES (CLI) is STYLES minus \"st\" — includes gc for explicit lookup", () => { + const expected = STYLES.filter((s) => s !== "st").sort(); + expect([...VALID_STYLES].sort()).toEqual(expected); + expect(VALID_STYLES).toContain("gc"); + expect(VALID_STYLES).not.toContain("st"); + }); +}); + +describe("hexagramToJson — conditional gc emission", () => { + function makeHex(overrides: Partial = {}): Hexagram { + return { + u: "X", + n: "X", + p: "X", + ename: "X", + l: [1, 1, 1, 1, 1, 1], + dx: "dx", + tu: "tu", + en: "en", + te: "te", + w: "w", + yao: ["", "", "", "", "", ""], + yaoEn: ["", "", "", "", "", ""], + ...overrides, + }; + } + + test("legacy hexagram (no gc populated) — gc key absent from commentary", () => { + const json = hexagramToJson(1, makeHex()) as { + commentary: Record; + }; + expect("gc" in json.commentary).toBe(false); + expect(json.commentary).toMatchObject({ + dx: "dx", + tu: "tu", + en: "en", + te: "te", + w: "w", + }); + }); + + test("hexagram with gc populated — gc key present in commentary", () => { + const json = hexagramToJson(1, makeHex({ gc: "元亨利貞" })) as { + commentary: Record; + }; + expect(json.commentary.gc).toBe("元亨利貞"); + }); +}); diff --git a/apps/cli/src/app/scene-factories.ts b/apps/cli/src/app/scene-factories.ts index 7e6d460..7b4b90e 100644 --- a/apps/cli/src/app/scene-factories.ts +++ b/apps/cli/src/app/scene-factories.ts @@ -13,6 +13,7 @@ import { JournalScene, type Scene, type SceneFactory, + ShuoGuaChapterScene, } from "@iching/terminal"; export interface SessionDims { @@ -44,6 +45,9 @@ export function makeDetailScene(kw: number, deps: DetailDeps): DetailScene { export function makeBrowseFactory(deps: DetailDeps): SceneFactory { return (signal): Scene | null => { if (signal.type === "openDetail") return makeDetailScene(signal.kw, deps); + if (signal.type === "openShuoguaChapter") { + return new ShuoGuaChapterScene(signal.chapter, signal.op); + } return null; }; } @@ -68,6 +72,9 @@ export function makeJournalFactory(deps: JournalDeps): SceneFactory { return cs; } if (signal.type === "openDetail") return makeDetailScene(signal.kw, deps); + if (signal.type === "openShuoguaChapter") { + return new ShuoGuaChapterScene(signal.chapter, signal.op); + } if (signal.type === "openDictionary") return new BrowseScene(); // `j` from a replayed CastScene inside the journal router → reset to the journal list. if (signal.type === "openJournal") return new JournalScene(deps.entries); diff --git a/apps/cli/src/commands/config.ts b/apps/cli/src/commands/config.ts index edf98f8..6903b1c 100644 --- a/apps/cli/src/commands/config.ts +++ b/apps/cli/src/commands/config.ts @@ -3,19 +3,125 @@ import { resolvePaths, JsonConfigStore } from "@iching/storage"; import type { UserConfig } from "@iching/storage"; import { outputJson, configToJson } from "../output/json.js"; -const CONFIG_SCHEMA: Record = { - theme: { values: ["ink", "bone", "cinnabar", "jade", "river"], description: "Color theme" }, - motion: { values: ["default", "brisk", "deep", "reduced"], description: "Casting animation speed" }, - color: { values: ["auto", "always", "never"], description: "ANSI color mode" }, - timezone: { description: "Timezone (\"system\" or IANA name)" }, - glyphAnim: { values: ["noise", "dots", "radial", "sand"], description: "Glyph reveal animation" }, - glyphFont: { values: ["kaiti", "libian", "heiti"], description: "Glyph font" }, - taijituStyle: { values: ["dots", "dense"], description: "Home-screen taijitu style" }, - castMethod: { values: ["coin", "yarrow"], description: "Cast method (coin or yarrow stalk ritual)" }, - castMode: { values: ["auto", "manual"], description: "Cast mode (auto or operator-guided)" }, +type ConfigEntry = { + values?: readonly string[]; + description: string; + set: (cfg: UserConfig, value: string) => boolean; }; -const VALID_KEYS = Object.keys(CONFIG_SCHEMA); +function isOneOf( + options: T, + value: string, +): value is T[number] { + return options.includes(value as T[number]); +} + +const THEME_VALUES = ["ink", "bone", "cinnabar", "jade", "river"] as const; +const MOTION_VALUES = ["default", "brisk", "deep", "reduced"] as const; +const COLOR_VALUES = ["auto", "always", "never"] as const; +const LANGUAGE_VALUES = ["zh-Hans", "zh-Hant", "en"] as const; +const GLYPH_ANIM_VALUES = ["noise", "dots", "radial", "sand"] as const; +const GLYPH_FONT_VALUES = ["kaiti", "libian", "heiti"] as const; +const TAIJITU_STYLE_VALUES = ["dots", "dense"] as const; +const CAST_METHOD_VALUES = ["coin", "yarrow"] as const; +const CAST_MODE_VALUES = ["auto", "manual"] as const; + +const CONFIG_SCHEMA: Record = { + theme: { + values: THEME_VALUES, + description: "Color theme", + set: (cfg, value) => { + if (!isOneOf(THEME_VALUES, value)) return false; + cfg.theme = value; + return true; + }, + }, + motion: { + values: MOTION_VALUES, + description: "Casting animation speed", + set: (cfg, value) => { + if (!isOneOf(MOTION_VALUES, value)) return false; + cfg.motion = value; + return true; + }, + }, + language: { + values: LANGUAGE_VALUES, + description: "Display language (简, 繁, or English)", + set: (cfg, value) => { + if (!isOneOf(LANGUAGE_VALUES, value)) return false; + cfg.language = value; + return true; + }, + }, + color: { + values: COLOR_VALUES, + description: "ANSI color mode", + set: (cfg, value) => { + if (!isOneOf(COLOR_VALUES, value)) return false; + cfg.color = value; + return true; + }, + }, + timezone: { + description: "Timezone (\"system\" or IANA name)", + set: (cfg, value) => { + cfg.timezone = value; + return true; + }, + }, + glyphAnim: { + values: GLYPH_ANIM_VALUES, + description: "Glyph reveal animation", + set: (cfg, value) => { + if (!isOneOf(GLYPH_ANIM_VALUES, value)) return false; + cfg.glyphAnim = value; + return true; + }, + }, + glyphFont: { + values: GLYPH_FONT_VALUES, + description: "Glyph font", + set: (cfg, value) => { + if (!isOneOf(GLYPH_FONT_VALUES, value)) return false; + cfg.glyphFont = value; + return true; + }, + }, + taijituStyle: { + values: TAIJITU_STYLE_VALUES, + description: "Home-screen taijitu style", + set: (cfg, value) => { + if (!isOneOf(TAIJITU_STYLE_VALUES, value)) return false; + cfg.taijituStyle = value; + return true; + }, + }, + castMethod: { + values: CAST_METHOD_VALUES, + description: "Cast method (coin or yarrow stalk ritual)", + set: (cfg, value) => { + if (!isOneOf(CAST_METHOD_VALUES, value)) return false; + cfg.castMethod = value; + return true; + }, + }, + castMode: { + values: CAST_MODE_VALUES, + description: "Cast mode (auto or operator-guided)", + set: (cfg, value) => { + if (!isOneOf(CAST_MODE_VALUES, value)) return false; + cfg.castMode = value; + return true; + }, + }, +}; + +const VALID_KEYS = Object.keys(CONFIG_SCHEMA) as Array; + +function isConfigKey(key: string): key is keyof typeof CONFIG_SCHEMA { + return key in CONFIG_SCHEMA; +} export function registerConfigCommand(program: Command): void { const config = program @@ -37,7 +143,7 @@ export function registerConfigCommand(program: Command): void { outputJson(cfg); } else { for (const key of VALID_KEYS) { - const value = cfg[key as keyof UserConfig]; + const value = cfg[key]; const schema = CONFIG_SCHEMA[key]; const valid = schema.values ? ` (${schema.values.join("|")})` : ""; console.log(` ${key.padEnd(12)} = ${value}${valid}`); @@ -57,12 +163,12 @@ export function registerConfigCommand(program: Command): void { const store = new JsonConfigStore(paths.config); const cfg = await store.load(); - if (!VALID_KEYS.includes(key)) { + if (!isConfigKey(key)) { console.error(`Unknown key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`); process.exit(1); } - const value = cfg[key as keyof UserConfig]; + const value = cfg[key]; if (globalOpts.json) { outputJson(configToJson(key, value)); } else { @@ -83,7 +189,7 @@ export function registerConfigCommand(program: Command): void { const store = new JsonConfigStore(paths.config); const cfg = await store.load(); - if (!VALID_KEYS.includes(key)) { + if (!isConfigKey(key)) { console.error(`Unknown key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`); process.exit(1); } @@ -95,9 +201,10 @@ export function registerConfigCommand(program: Command): void { process.exit(1); } - // Validated above against CONFIG_SCHEMA + VALID_KEYS, so the assignment is sound; - // the double cast through unknown acknowledges that TS can't track that proof here. - (cfg as unknown as Record)[key] = value; + if (!schema.set(cfg, value)) { + console.error(`Invalid value "${value}" for ${key}. Valid: ${schema.values?.join(", ") ?? "any string"}`); + process.exit(1); + } await store.save(cfg); if (globalOpts.json) { diff --git a/apps/cli/src/commands/hexagram.ts b/apps/cli/src/commands/hexagram.ts index e25758d..52c9154 100644 --- a/apps/cli/src/commands/hexagram.ts +++ b/apps/cli/src/commands/hexagram.ts @@ -4,14 +4,19 @@ import type { Style } from "@iching/core"; import { formatHexagramPlain } from "../output/plain.js"; import { outputJson, hexagramToJson } from "../output/json.js"; -const VALID_STYLES = ["dx", "tu", "en", "te", "w"]; +/** + * Valid --style choices. Mirrors @iching/core STYLES minus "st" (which is + * a synthetic trigram-structure path, not a commentary field) — parity is + * locked by style-union-parity.test.ts. + */ +export const VALID_STYLES = ["dx", "tu", "en", "te", "w", "gc"]; export function registerHexagramCommand(program: Command): void { program .command("hexagram") .description("Look up hexagram by King Wen number (1-64)") .argument("", "hexagram number (1-64)") - .option("--style