diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c66e40fe..5e1269e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -87,7 +87,7 @@ jobs: - name: Prepare publish manifest run: | set -euo pipefail - cp README.md LICENSE publish/ + cp README.md LICENSE TEXT_SOURCES.md publish/ VERSION=$(node -p "require('./package.json').version") cat > publish/package.json <=18", "bun": ">=1.0.0" }, "keywords": ["iching", "i-ching", "yijing", "tui", "terminal", "divination", "node", "bun"], diff --git a/README.md b/README.md index c94cc0c8..8a6c678a 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,10 @@ Set an intention. Cast a hexagram. Sit with what shows up. (大象傳, 彖傳), English image and judgment, and Wilhelm-inspired notes. - **Journal** every cast (with timestamp and intention) in append-only JSONL — your own divination history. +- **Observe** quiet patterns over that history (`p` in the journal, or + `iching journal patterns`) — a field of all 64 lit by what you've drawn, + where movement falls, the yin/yang balance, the phase of day each was cast. + Observation, never prediction. - **Hook into LLMs** the assistant can read your cast for further interpretation. Raw ANSI, five hand-tuned themes (ink, bone, cinnabar, jade, river), @@ -57,16 +61,42 @@ bun run build # builds dist/iching for your platform iching # interactive TUI iching cast # one-shot cast (plain text) iching cast "should I ship?" # with a question +iching cast --bound "ship it?" # bind the cast to the question and moment iching cast --json # structured output +iching today # today's reading (cast in the TUI) +iching today --json # full reading payload for scripts/assistants iching journal list # recent readings +iching journal list --hexagram 29 # readings where hexagram 29 appears iching journal show today # today's reading -iching hexagram 1 # look up hexagram by number +iching journal note "..." # attach a reflection note to the latest reading +iching journal patterns # quiet observation over every past reading +iching journal patterns --since 2026-01-01 --until 2026-03-31 # bound it to one season +iching journal patterns --json # the same derivation, structured for scripts +iching hexagram 1 # look up hexagram by number or name +iching dict water # open the dictionary on a search iching dict # browse all 64 in TUI iching config theme cinnabar # set theme ``` -Press `c` to cast, `j` for journal, `d` for dictionary, `s` for -settings, `q` to quit. +Press `c` to cast, `t` to return to today's reading, `j` for journal, +`d` for dictionary, `s` for settings, `q` to quit. + +### JSON for scripts and assistants + +`cast --json`, `today --json`, and `journal patterns --json` emit stable, +self-describing payloads. In `journal patterns --json`, mind one contract +(the output states it in a `basis` field): **descriptive counts tally every +reading; chance figures live in `comparison` blocks computed only over the +method-marked subset** (coin/yarrow casts, whose line probabilities are known — +legacy entries have none). Never divide an all-readings `count` by an `expected` +from a `comparison` block; use that block's own `count`/`expected`, which share +a basis. (Every chance figure — including `topTrigrams` — lives in a +`comparison` block over the method-marked subset; uniform trigram geometry +still needs P(yang)=½, a property of the method, so it rests on the same basis +as the rest.) One further basis: `timeOfDay` rests on the **timestamped** +subset — its phase +counts sum to `timeOfDay.timestamped`, not `total`, since readings without a +recorded local hour are omitted rather than guessed. ## Storage @@ -76,6 +106,7 @@ Files follow the [XDG Base Directory](https://specifications.freedesktop.org/bas |------|------|---------| | Cache | `~/.cache/iching/daily-cache.json` | Most recent cast | | Journal | `~/.local/state/iching/history.jsonl` | All casts | +| Notes | `~/.local/state/iching/notes.jsonl` | Reflection notes (sidecar to the journal) | | Config | `~/.config/iching/config.json` | Theme, motion, glyph settings | Override with `ICHING_HOME` env var or `--data-dir` flag. @@ -94,9 +125,10 @@ Bun workspace monorepo. Four packages: ## Development ```bash -bun test # run tests (382 tests, ~100ms) +bun test # run the test suite bun run typecheck # tsc --noEmit -bun run smoke # end-to-end smoke test +bun run build # compile the binary for your platform +bun run smoke # end-to-end smoke test of the built binary ``` Vision notes and the current scope spec are mapped in diff --git a/README.zh-CN.md b/README.zh-CN.md index 48e6ba5f..12e90b4b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -18,6 +18,8 @@ 英译象辞与卦辞、以及卫礼贤风格的注解。 - **日志** 自动记录每一次起卦(含时间戳与问意), 以追加式 JSONL 保存 —— 你自己的卜筮史。 +- **观象** 对这段卜筮史作静观(日志中按 `p`,或 `iching journal patterns`)—— + 六十四卦之野依所占点亮、动爻所在、阴阳之衡。只是观照,从不预言。 - **接入大语言模型**:助手可读取你的卦象作进一步释义。 原始 ANSI 渲染、五种手调主题(ink、bone、cinnabar、jade、river)、 @@ -56,15 +58,32 @@ bun run build # 为当前平台构建 dist/iching iching # 进入交互式 TUI iching cast # 一次性起卦(纯文本) iching cast "该不该上线?" # 带问意起卦 +iching cast --bound "该不该上线?" # 将此卦与问意及当下时刻绑定 iching cast --json # 结构化输出 +iching today # 今日卦象(在 TUI 中起卦) +iching today --json # 供脚本与助手读取的完整卦象数据 iching journal list # 近期记录 +iching journal list --hexagram 29 # 筛选出现第 29 卦的记录 iching journal show today # 今日卦象 -iching hexagram 1 # 按卦序查阅 +iching journal note "……" # 给最近一卦附上注记 +iching journal patterns # 观象:对历来卦记的静观 +iching journal patterns --json # 同一推演,结构化输出供脚本读取 +iching hexagram 1 # 按卦序或卦名查阅 +iching dict water # 以检索词打开卦典 iching dict # 在 TUI 中浏览六十四卦 iching config theme cinnabar # 设置主题 ``` -按 `c` 起卦,`j` 查日志,`d` 查卦典,`s` 设置,`q` 退出。 +按 `c` 起卦,`t` 回看今日,`j` 查日志,`d` 查卦典,`s` 设置,`q` 退出。 + +### 供脚本与助手的 JSON + +`cast --json`、`today --json` 与 `journal patterns --json` 输出稳定且自描述的结构。 +`journal patterns --json` 有一条约定(输出中以 `basis` 字段写明):**描述性计数统计所有卦记; +理数(chance)数值则置于 `comparison` 块内,只就已注法的子集计算**(铜钱/蓍草占法,其爻变概率已知—— +旧占记没有)。切勿用全量的 `count` 去除以某个 `comparison` 块里的 `expected`;应取该块自带的 +`count`/`expected`,二者同基。(`topTrigrams.expected` 是唯一在 `comparison` 块之外的 `expected`—— +属无关占法的几何期望,与其 `count` 同为全量基准。) ## 数据存储 @@ -74,6 +93,7 @@ iching config theme cinnabar # 设置主题 |------|------|------| | 缓存 | `~/.cache/iching/daily-cache.json` | 最近一卦 | | 日志 | `~/.local/state/iching/history.jsonl` | 全部卦象 | +| 注记 | `~/.local/state/iching/notes.jsonl` | 回看注记(日志的伴生文件) | | 配置 | `~/.config/iching/config.json` | 主题、动画、字符设置 | 可用 `ICHING_HOME` 环境变量或 `--data-dir` 参数覆盖。 @@ -92,9 +112,10 @@ Bun 工作区单仓多包。共四个包: ## 开发 ```bash -bun test # 运行测试(382 项,约 100ms) +bun test # 运行测试 bun run typecheck # tsc --noEmit -bun run smoke # 端到端冒烟测试 +bun run build # 构建当前平台的二进制 +bun run smoke # 对构建产物做端到端冒烟测试 ``` ## 缘起 diff --git a/SPEC.md b/SPEC.md index bde867e6..d05d30bf 100644 --- a/SPEC.md +++ b/SPEC.md @@ -8,6 +8,8 @@ document in the repository. `iching` is a local-first terminal I Ching application: - one-shot CLI commands for casting, journal, config, paths, doctor, and lookup +- a daily-reading anchor: the day's cast is cached and recallable without + recasting (`iching today`, TUI `t`) - fullscreen TUI for daily casting, journal review, settings, and dictionary - pure domain package for casts, derivations, search, and hexagram data - JSON/JSONL storage using XDG-compatible local paths @@ -24,8 +26,8 @@ old checkbox. |---|---|---| | Workspace monorepo | Implemented | `apps/cli`, `packages/core`, `packages/storage`, `packages/terminal` | | Core domain | Implemented | casting, derivation, lookup, detail, search, formatters, exhaustive tests | -| Storage | Implemented | paths, JSON/JSONL stores, config store, legacy discovery, atomic-write tests | -| CLI commands | Implemented | `cast`, `journal`, `hexagram`, `config`, `paths`, `doctor`, `dict` | +| Storage | Implemented | paths, JSON/JSONL stores, config store, atomic-write tests | +| CLI commands | Implemented | `cast`, `today`, `journal`, `hexagram`, `config`, `paths`, `doctor`, `dict` | | Terminal primitives | Implemented | cell buffer, diff renderer, ANSI, key parser, raw input, session lifecycle | | Animation engine | Implemented | timeline DSL, runner, easing, presets, scene loop | | Casting scenes | Implemented | cast scene, coin/line/morph/reveal renderers, timeline builder | @@ -33,25 +35,11 @@ old checkbox. | Large glyph support | Implemented | glyph data, glyph animation modes, cast/detail integration | | Yarrow casting | Implemented (2026-05-30) | `core/casting/yarrow.ts`, `YarrowScene` auto, `YarrowManualScene` 18-cut full-manual, ritual-chrome parity with coin | | Distribution | Infrastructure present | build script, smoke script, CI workflow, release workflow | -| Entropy source evolution | Planned | see `docs/vision/entropy-sources-vision.md` | +| Entropy binding (`bound`) | Implemented (2026-06-10) | `core/random.ts` `BoundRandomSource` (SHA-256 length-prefixed binding of fresh crypto bytes + intention + timestamp + process nonce, hash-counter DRBG); `entropy` config key (default `crypto`); `cast --bound`; `rng` provenance on journal/cache/`cast --json`; Settings row; `bound-random.test.ts` | +| Entropy source evolution | In progress | `bound` shipped (row above); `embodied` and `quantum-remote` remain planned — see `docs/vision/entropy-sources-vision.md` | ## Active Product Questions -### Entropy Binding - -Next likely scope: add a `bound` entropy path that mixes local crypto entropy -with intention/session context. - -Constraints: - -- keep `crypto` as the default -- use intention as salt/context, never as the sole seed -- preserve explicit deterministic `--seed` -- record provenance in JSON and journal output if the source becomes user-visible -- do not add network entropy in this step - -Source: [Entropy Sources Vision](docs/vision/entropy-sources-vision.md). - ### Embodied Entropy Future scope: collect local timing/jitter during an existing ritual window and diff --git a/TEXT_SOURCES.md b/TEXT_SOURCES.md new file mode 100644 index 00000000..5fb5085d --- /dev/null +++ b/TEXT_SOURCES.md @@ -0,0 +1,42 @@ +# Text Sources & Attribution + +`@pro-vi/iching` bundles several layers of I Ching text. This file records each +source, its rights status, and what the project's MIT license does and does not +cover. + +## Classical Chinese (public domain) + +The hexagram judgments (卦辭), line statements (爻辭), the 小象傳, and the +序卦傳 / 雜卦傳 belong to the ancient *Zhouyi* / *Yijing* corpus. The Chinese +source text is in the public domain. + +## English — Legge (`gcEn`, public domain) + +The `gcEn` judgment field is the English translation by **James Legge**, +*The Yî King* (Sacred Books of the East, Vol. XVI; Oxford, 1882, rev. 1899). +Legge's translation is in the public domain. + +## English — Wilhelm register (`gcEnW`, `yaoEn`) — original interpretive text + +The displayed English judgments (`gcEnW`) and line readings (`yaoEn`) are +**original interpretive renderings** written for this project in the +Wilhelm–Baynes *register* (cadence and vocabulary), **distilled from +public-domain sources** — the classical Chinese 卦辭 / 爻辭, Legge's translation, +and the 彖傳 / 小象傳 commentaries. + +They are **not quotations of, and are not derived from, the copyrighted +Richard Wilhelm / Cary F. Baynes English translation** (*The I Ching or Book of +Changes*, Bollingen Series XIX; 1950, rev. 1967), which remains under copyright. +This is the project's standing **AC-010** policy: ship original wording from +public-domain sources, never licensed third-party translation text. + +If you believe any specific string reproduces protected expression, please open +an issue and it will be revised. + +## License scope + +The project's [MIT LICENSE](./LICENSE) covers the project's **code** and its +**original textual material** (including the `gcEnW` / `yaoEn` renderings). It +does **not** purport to grant rights in any third-party text. The classical +Chinese source and Legge's translation are in the public domain; no third-party +copyrighted translation is included. diff --git a/apps/cli/package.json b/apps/cli/package.json index 6ed60e45..64b1ea4f 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@iching/cli", - "version": "0.2.0", + "version": "0.5.0", "private": true, "type": "module", "exports": { diff --git a/apps/cli/src/__tests__/cast.test.ts b/apps/cli/src/__tests__/cast.test.ts index 72adec66..3787c3f3 100644 --- a/apps/cli/src/__tests__/cast.test.ts +++ b/apps/cli/src/__tests__/cast.test.ts @@ -1,12 +1,14 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test"; -import { mkdtemp, rm, readFile, readdir } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { runCli as spawnCli } from "../testing.ts"; +import { rm, readFile, readdir } from "node:fs/promises"; +import { freshTempDir } from "../testing.ts"; import { join } from "node:path"; import { castHexagram, buildStructure, SeededRandomSource, GUA, + readingTexts, } from "@iching/core"; import { JsonDailyCacheStore, JsonlJournalStore } from "@iching/storage"; import { castToJson } from "../output/json.js"; @@ -15,14 +17,14 @@ import { formatCastPlain } from "../output/plain.js"; let dataDir: string; beforeEach(async () => { - dataDir = await mkdtemp(join(tmpdir(), "iching-cast-test-")); + dataDir = await freshTempDir("iching-cast-test"); }); afterEach(async () => { await rm(dataDir, { recursive: true, force: true }); }); -describe("cast command", () => { +describe("cast — casting, formatters & storage round-trip", () => { test("cast with --seed produces deterministic output", () => { const source1 = new SeededRandomSource(42); const cast1 = castHexagram(source1); @@ -112,3 +114,303 @@ describe("cast command", () => { expect(text).toContain("Commentary:"); }); }); + +describe("cast output oracle texts", () => { + /** Seed 7: hexagram 47 → 62, changing lines [2, 3, 5]. */ + function makeSeededCast() { + const cast = castHexagram(new SeededRandomSource(7)); + const primary = GUA[cast.primary - 1]; + const becoming = cast.becoming !== null ? GUA[cast.becoming - 1] : null; + return { cast, primary, becoming }; + } + + test("castToJson includes the judgment for primary and becoming", () => { + const { cast, primary, becoming } = makeSeededCast(); + const json = castToJson(cast, primary, becoming); + + const p = json.primary as Record; + expect(p.ename).toBe(primary.ename); + expect(p.judgment).toEqual({ gc: primary.gc, gcEn: primary.gcEn }); + const b = json.becoming as Record; + expect(b.judgment).toEqual({ gc: becoming!.gc, gcEn: becoming!.gcEn }); + }); + + test("castToJson includes the changing lines' texts with positions", () => { + const { cast, primary, becoming } = makeSeededCast(); + const json = castToJson(cast, primary, becoming); + + const changing = json.changingLines as Array>; + expect(changing).toHaveLength(cast.changingPositions.length); + for (let i = 0; i < changing.length; i++) { + const pos = cast.changingPositions[i]; + expect(changing[i]).toEqual({ + position: pos, + yao: primary.yao[pos - 1], + yaoEn: primary.yaoEn[pos - 1], + }); + } + }); + + test("castToJson extra is null unless all six lines move on hex 1/2", () => { + const { cast, primary, becoming } = makeSeededCast(); + const json = castToJson(cast, primary, becoming); + expect(json.extra).toBeNull(); + + // Synthesize the all-moving 乾 cast: 用九 governs + const allMoving = { + ...cast, + primary: 1, + becoming: 2, + changingPositions: [1, 2, 3, 4, 5, 6], + lines: Array.from({ length: 6 }, () => ({ + value: 9 as const, + isYang: true, + isChanging: true, + })), + }; + const yongJson = castToJson(allMoving, GUA[0], GUA[1]); + expect(yongJson.extra).toEqual({ + name: "用九", + text: "見群龍無首,吉。", + textEn: GUA[0].extra!.textEn, + }); + }); + + test("formatCastPlain shows the judgment and the 啟蒙 reading", () => { + const { cast, primary } = makeSeededCast(); + const structure = buildStructure(cast); + const text = formatCastPlain(cast, primary, structure); + + // The hexagram's own 卦辭 always appears in its reference block. + expect(text).toContain(`Judgment (gc): ${primary.gc}`); + expect(text).toContain(`Judgment (gcEn): ${primary.gcEn}`); + + // The reading itself follows the shared 啟蒙 rule (readingTexts), NOT the raw + // moving lines — seed 7 moves three lines, so the reading is both judgments + // (本卦 47 and 之卦 62), never the 爻辭 of lines 2/3/5. + expect(text).toContain("Reading (啟蒙):"); + for (const part of readingTexts(cast)) { + const g = GUA[part.kw - 1]; + if (part.kind === "judgment") { + // The reading speaks Wilhelm (gcEnW), the displayed register — not the + // Legge anchor (gcEn), which only the standalone reference block carries. + expect(text).toContain(` ${g.u} ${g.n} 卦辭: ${g.gc}`); + expect(text).toContain(` ${g.gcEnW}`); + } else if (part.kind === "line") { + expect(text).toContain(` ${g.u} ${g.n} 爻${part.position}: ${g.yao[part.position - 1]}`); + expect(text).toContain(` ${g.yaoEn[part.position - 1]}`); + } else if (g.extra) { + expect(text).toContain(` ${g.extra.name}: ${g.extra.text}`); + expect(text).toContain(` ${g.extra.textEn}`); + } + } + }); + + test("formatCastPlain singularizes 'line' when exactly one moves", () => { + // ~36% of moving casts have exactly one changing line; "[lines 6]" reads + // wrong there (cf. the journal's "1 active day" fix). The reading header is + // now the rule-neutral "Reading (啟蒙):", so only the inline Becoming + // indicator carries the singular/plural distinction. + const { cast, primary } = makeSeededCast(); + const oneMoving = { + ...cast, + becoming: 47, + changingPositions: [6], + lines: cast.lines.map((l, i) => + i === 5 + ? { value: 9 as const, isYang: true, isChanging: true } + : { ...l, isChanging: false, value: (l.isYang ? 7 : 8) as 7 | 8 }, + ), + }; + const text = formatCastPlain(oneMoving, primary, buildStructure(oneMoving)); + expect(text).toContain("[line 6]"); // singular inline indicator… + expect(text).not.toContain("[lines 6]"); + // …and at one moving line the reading turns on that line's 爻辭, not a judgment. + expect(text).toContain(`爻6: ${primary.yao[5]}`); + }); + + test("formatCastPlain omits the changing-lines block when none move", () => { + // Force an unchanging cast by stripping the changing flags + const { cast, primary } = makeSeededCast(); + const unchanging = { + ...cast, + becoming: null, + changingPositions: [], + lines: cast.lines.map((l) => ({ + ...l, + isChanging: false, + value: (l.isYang ? 7 : 8) as 7 | 8, + })), + }; + const text = formatCastPlain(unchanging, primary, buildStructure(unchanging)); + expect(text).not.toContain("Changing lines:"); + expect(text).toContain("Judgment (gc):"); + }); +}); + +describe("cast entropy provenance output", () => { + function makeSeededCast() { + const cast = castHexagram(new SeededRandomSource(7)); + const primary = GUA[cast.primary - 1]; + const becoming = cast.becoming !== null ? GUA[cast.becoming - 1] : null; + return { cast, primary, becoming }; + } + + test("castToJson carries the rng block (bound)", () => { + const { cast, primary, becoming } = makeSeededCast(); + const json = castToJson(cast, primary, becoming, "q", { + source: "bound", + intentionBound: true, + }); + expect(json.rng).toEqual({ source: "bound", intentionBound: true }); + }); + + test("castToJson carries the rng block (crypto)", () => { + const { cast, primary, becoming } = makeSeededCast(); + const json = castToJson(cast, primary, becoming, undefined, { + source: "crypto", + intentionBound: false, + }); + expect(json.rng).toEqual({ source: "crypto", intentionBound: false }); + }); + + test("castToJson echoes the seed for deterministic replays", () => { + const { cast, primary, becoming } = makeSeededCast(); + const json = castToJson( + cast, + primary, + becoming, + undefined, + { source: "seed", intentionBound: false }, + 42, + ); + expect(json.rng).toEqual({ source: "seed", intentionBound: false, seed: 42 }); + }); + + test("castToJson rng is null when no provenance is supplied (back-compat)", () => { + const { cast, primary, becoming } = makeSeededCast(); + const json = castToJson(cast, primary, becoming); + expect(json.rng).toBeNull(); + }); + + test("formatCastPlain prints the quiet bound line only when bound", () => { + const { cast, primary } = makeSeededCast(); + const structure = buildStructure(cast); + + const bound = formatCastPlain(cast, primary, structure, "q", { + source: "bound", + intentionBound: true, + }); + expect(bound).toContain( + "Entropy: local machine entropy, bound to the intention and moment.", + ); + + const boundNoIntention = formatCastPlain(cast, primary, structure, undefined, { + source: "bound", + intentionBound: false, + }); + expect(boundNoIntention).toContain( + "Entropy: local machine entropy, bound to the moment.", + ); + + // Plain crypto is the unremarkable default — silence is calmer. + const crypto = formatCastPlain(cast, primary, structure, undefined, { + source: "crypto", + intentionBound: false, + }); + expect(crypto).not.toContain("Entropy:"); + }); + + test("formatCastPlain names the seed for deterministic replays", () => { + const { cast, primary } = makeSeededCast(); + const structure = buildStructure(cast); + const text = formatCastPlain( + cast, + primary, + structure, + undefined, + { source: "seed", intentionBound: false }, + 42, + ); + expect(text).toContain("Entropy: deterministic replay from seed 42."); + }); +}); + +// Regression: Number("abc") is NaN and NaN|0 collapsed the PRNG to a constant +// state — `cast --seed abc` exited 0 with the same plausible-looking cast +// forever. Non-numeric seeds must fail loudly. +describe("cast --seed validation (subprocess)", () => { + + const runCast = (seed: string) => spawnCli(["--seed", seed, "cast"]); + + test("non-numeric seed errors on stderr and exits 1", async () => { + const { exitCode, stdout, stderr } = await runCast("abc"); + expect(exitCode).toBe(1); + expect(stderr).toContain('Invalid --seed "abc"'); + expect(stdout).toBe(""); + }, 20_000); + + test("empty seed is rejected (Number('') would silently become 0)", async () => { + const { exitCode, stderr } = await runCast(""); + expect(exitCode).toBe(1); + expect(stderr).toContain("Invalid --seed"); + }, 20_000); + + test("numeric seed still works and stays deterministic", async () => { + const a = await runCast("42"); + const b = await runCast("42"); + expect(a.exitCode).toBe(0); + expect(a.stdout).toBe(b.stdout); + }, 20_000); +}); + +// One-shot `iching cast` honors the entropy config and the --bound flag, +// binding the question argument as the intention. Provenance lands in the +// JSON rng block; --seed remains its own deterministic path. +describe("cast --bound / entropy config (subprocess)", () => { + + const runCli = (args: string[]) => spawnCli(args, { dataDir }); + + test("default cast reports crypto provenance in --json", async () => { + const { exitCode, stdout } = await runCli(["--json", "cast"]); + expect(exitCode).toBe(0); + const json = JSON.parse(stdout); + expect(json.rng).toEqual({ source: "crypto", intentionBound: false }); + }, 20_000); + + test("--bound with a question reports intention-bound provenance", async () => { + const { exitCode, stdout } = await runCli(["--json", "cast", "should I?", "--bound"]); + expect(exitCode).toBe(0); + const json = JSON.parse(stdout); + expect(json.rng).toEqual({ source: "bound", intentionBound: true }); + expect(json.question).toBe("should I?"); + }, 20_000); + + test("--bound without a question binds the moment only", async () => { + const { exitCode, stdout } = await runCli(["--json", "cast", "--bound"]); + expect(exitCode).toBe(0); + const json = JSON.parse(stdout); + expect(json.rng).toEqual({ source: "bound", intentionBound: false }); + }, 20_000); + + test("entropy=bound in config binds without the flag; plain output gets the quiet line", async () => { + const set = await runCli(["config", "set", "entropy", "bound"]); + expect(set.exitCode).toBe(0); + + const json = await runCli(["--json", "cast", "still water?"]); + expect(JSON.parse(json.stdout).rng).toEqual({ source: "bound", intentionBound: true }); + + const plain = await runCli(["cast", "still water?"]); + expect(plain.stdout).toContain( + "Entropy: local machine entropy, bound to the intention and moment.", + ); + }, 20_000); + + test("--seed overrides the bound config — deterministic replay provenance", async () => { + const set = await runCli(["config", "set", "entropy", "bound"]); + expect(set.exitCode).toBe(0); + const { exitCode, stdout } = await runCli(["--json", "--seed", "42", "cast", "q"]); + expect(exitCode).toBe(0); + expect(JSON.parse(stdout).rng).toEqual({ source: "seed", intentionBound: false, seed: 42 }); + }, 20_000); +}); diff --git a/apps/cli/src/__tests__/config-command.test.ts b/apps/cli/src/__tests__/config-command.test.ts index 39c9466d..f8399dd3 100644 --- a/apps/cli/src/__tests__/config-command.test.ts +++ b/apps/cli/src/__tests__/config-command.test.ts @@ -9,44 +9,19 @@ // (coin|yarrow) × castMode (auto|manual); both must be reachable. import { describe, test, expect, beforeEach, afterEach } from "bun:test"; -import { mkdtemp, rm, readFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; - -const REPO_ROOT = resolve(import.meta.dir, "..", "..", "..", ".."); -const MAIN_TS = resolve(REPO_ROOT, "apps/cli/src/main.ts"); - -interface RunResult { - exitCode: number; - stdout: string; - stderr: string; -} - -async function runCli(dataDir: string, args: string[]): Promise { - const proc = Bun.spawn( - ["bun", MAIN_TS, "--data-dir", dataDir, ...args], - { - cwd: REPO_ROOT, - stdin: "pipe", - stdout: "pipe", - stderr: "pipe", - env: { ...process.env, NO_COLOR: "1" }, - }, - ); - proc.stdin.end(); - const [stdout, stderr] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - ]); - const exitCode = await proc.exited; - return { exitCode, stdout, stderr }; -} +import { runCli as spawnCli, type RunResult } from "../testing.ts"; +import { rm, readFile } from "node:fs/promises"; +import { freshTempDir } from "../testing.ts"; +import { join } from "node:path"; + +const runCli = (dataDir: string, args: string[]): Promise => + spawnCli(args, { dataDir }); describe("config command", () => { let dataDir: string; beforeEach(async () => { - dataDir = await mkdtemp(join(tmpdir(), "iching-config-cmd-test-")); + dataDir = await freshTempDir("iching-config-cmd-test"); }); afterEach(async () => { @@ -68,6 +43,7 @@ describe("config command", () => { "taijituStyle", "castMethod", "castMode", + "entropy", ]) { expect(stdout).toContain(key); } @@ -163,6 +139,26 @@ describe("config command", () => { expect(stderr.toLowerCase()).toContain("invalid value"); }, 20_000); + test("get entropy returns the default value (crypto)", async () => { + const { exitCode, stdout } = await runCli(dataDir, ["config", "get", "entropy"]); + expect(exitCode).toBe(0); + expect(stdout.trim()).toBe("crypto"); + }, 20_000); + + test("set entropy bound persists, reload reads bound", async () => { + const setResult = await runCli(dataDir, ["config", "set", "entropy", "bound"]); + expect(setResult.exitCode).toBe(0); + const getResult = await runCli(dataDir, ["config", "get", "entropy"]); + expect(getResult.exitCode).toBe(0); + expect(getResult.stdout.trim()).toBe("bound"); + }, 20_000); + + test("set entropy rejects invalid value", async () => { + const { exitCode, stderr } = await runCli(dataDir, ["config", "set", "entropy", "quantum"]); + expect(exitCode).not.toBe(0); + expect(stderr.toLowerCase()).toContain("invalid value"); + }, 20_000); + test("set taijituStyle dense persists", async () => { const setResult = await runCli(dataDir, ["config", "set", "taijituStyle", "dense"]); expect(setResult.exitCode).toBe(0); @@ -210,16 +206,9 @@ describe("config command", () => { // seed+freeze the display language on first boot like the main TUI — not use a // pure load() that launches English. It seeds before the (non-TTY) render exits. test("`iching dict` seeds the display language on first boot", async () => { - const proc = Bun.spawn(["bun", MAIN_TS, "--data-dir", dataDir, "dict"], { - cwd: REPO_ROOT, - stdin: "pipe", - stdout: "pipe", - stderr: "pipe", - // clear inherited LANGUAGE/LC_MESSAGES so LC_ALL deterministically wins - env: { ...process.env, NO_COLOR: "1", LC_ALL: "zh_CN.UTF-8", LANG: "zh_CN.UTF-8", LC_MESSAGES: "", LANGUAGE: "" }, - }); - proc.stdin.end(); - await proc.exited; // exits on its own (no TTY) after loadOrSeed has persisted + // clear inherited LANGUAGE/LC_MESSAGES so LC_ALL deterministically wins; the + // (non-TTY) dict render exits on its own after loadOrSeed has persisted. + await spawnCli(["dict"], { dataDir, env: { LC_ALL: "zh_CN.UTF-8", LANG: "zh_CN.UTF-8", LC_MESSAGES: "", LANGUAGE: "" } }); const cfg = JSON.parse(await readFile(join(dataDir, "config.json"), "utf-8")); expect(cfg.language).toBe("zh-Hans"); // seeded from the locale, not default "en" }, 20_000); @@ -227,15 +216,7 @@ describe("config command", () => { // Regression (review P2): `config set` WRITES, so on first boot it must seed // the language — not persist the defaulted "en" and permanently freeze the seed. test("`config set` on first boot seeds the language (does not freeze en)", async () => { - const proc = Bun.spawn(["bun", MAIN_TS, "--data-dir", dataDir, "config", "set", "theme", "ink"], { - cwd: REPO_ROOT, - stdin: "pipe", - stdout: "pipe", - stderr: "pipe", - env: { ...process.env, NO_COLOR: "1", LC_ALL: "zh_CN.UTF-8", LANG: "zh_CN.UTF-8", LC_MESSAGES: "", LANGUAGE: "" }, - }); - proc.stdin.end(); - await proc.exited; + await spawnCli(["config", "set", "theme", "ink"], { dataDir, env: { LC_ALL: "zh_CN.UTF-8", LANG: "zh_CN.UTF-8", LC_MESSAGES: "", LANGUAGE: "" } }); const cfg = JSON.parse(await readFile(join(dataDir, "config.json"), "utf-8")); expect(cfg.theme).toBe("ink"); // the set applied expect(cfg.language).toBe("zh-Hans"); // …AND the locale was seeded, not frozen to en @@ -255,4 +236,56 @@ describe("config command", () => { } expect(await Bun.file(join(dataDir, "config.json")).exists()).toBe(false); }, 20_000); + + // Git-style positional shorthand: `config ` reads, `config + // ` writes — same validated schema as get/set. This is also the + // syntax the README advertises (`iching config theme cinnabar`). + describe("positional shorthand", () => { + test("`config theme cinnabar` sets, `config theme` gets", async () => { + const setResult = await runCli(dataDir, ["config", "theme", "cinnabar"]); + expect(setResult.exitCode).toBe(0); + expect(setResult.stdout.trim()).toBe("theme = cinnabar"); + + const getResult = await runCli(dataDir, ["config", "theme"]); + expect(getResult.exitCode).toBe(0); + expect(getResult.stdout.trim()).toBe("cinnabar"); + + // The explicit subcommand sees the same persisted value + const subResult = await runCli(dataDir, ["config", "get", "theme"]); + expect(subResult.stdout.trim()).toBe("cinnabar"); + }, 20_000); + + test("bare `config` lists all values like `config list`", async () => { + const bare = await runCli(dataDir, ["config"]); + const list = await runCli(dataDir, ["config", "list"]); + expect(bare.exitCode).toBe(0); + expect(bare.stdout).toBe(list.stdout); + }, 20_000); + + test("shorthand rejects unknown keys with exit 1", async () => { + const { exitCode, stderr } = await runCli(dataDir, ["config", "notARealKey"]); + expect(exitCode).toBe(1); + expect(stderr.toLowerCase()).toContain("unknown key"); + }, 20_000); + + test("shorthand set goes through the validated schema", async () => { + const { exitCode, stderr } = await runCli(dataDir, ["config", "theme", "outline"]); + expect(exitCode).toBe(1); + expect(stderr.toLowerCase()).toContain("invalid value"); + // The rejected write must not create a config file (cf. set ordering) + expect(await Bun.file(join(dataDir, "config.json")).exists()).toBe(false); + }, 20_000); + + test("shorthand normalizes language labels like `config set` does", async () => { + const setResult = await runCli(dataDir, ["config", "language", "繁"]); + expect(setResult.exitCode).toBe(0); + expect(setResult.stdout.trim()).toBe("language = zh-Hant"); + }, 20_000); + + test("subcommands still win over shorthand (path)", async () => { + const { exitCode, stdout } = await runCli(dataDir, ["config", "path"]); + expect(exitCode).toBe(0); + expect(stdout.trim()).toBe(join(dataDir, "config.json")); + }, 20_000); + }); }); diff --git a/apps/cli/src/__tests__/deferred-diagnostics.test.ts b/apps/cli/src/__tests__/deferred-diagnostics.test.ts new file mode 100644 index 00000000..9b4642b6 --- /dev/null +++ b/apps/cli/src/__tests__/deferred-diagnostics.test.ts @@ -0,0 +1,92 @@ +// Deferred diagnostics — warnings console.error'd while the persistent alt +// screen is up (corrupt-cache quarantine notes, settings save failures) are +// repainted over within a frame and lost on exit. The buffer captures them +// and replays them to the real stderr after session.exit(). +// +// The module binds the "real" console.error at import time, so the recorder +// is installed BEFORE the dynamic import — everything the buffer replays +// lands in `recorded`, never on the test runner's actual stderr. + +import { describe, test, expect, beforeAll, beforeEach, afterAll } from "bun:test"; + +const recorded: string[] = []; +const originalConsoleError = console.error; + +let deferDiagnostics: () => void; +let flushDiagnostics: () => void; + +beforeAll(async () => { + console.error = (...args: unknown[]) => { + recorded.push(args.map(String).join(" ")); + }; + const mod = await import("../util/deferred-diagnostics.js"); + deferDiagnostics = mod.deferDiagnostics; + flushDiagnostics = mod.flushDiagnostics; +}); + +beforeEach(() => { + // Each test starts restored and with an empty buffer. + flushDiagnostics(); + recorded.length = 0; +}); + +afterAll(() => { + console.error = originalConsoleError; +}); + +describe("deferred diagnostics", () => { + test("without deferral, console.error reaches stderr immediately", () => { + console.error("plain warning"); + expect(recorded).toEqual(["plain warning"]); + }); + + test("deferred messages are invisible until flush, then replay in order", () => { + deferDiagnostics(); + console.error("iching: daily cache is unreadable — starting fresh."); + console.error("iching: couldn't save settings."); + // Nothing reaches stderr while the alt screen would be up. + expect(recorded).toEqual([]); + + flushDiagnostics(); + expect(recorded).toEqual([ + "iching: daily cache is unreadable — starting fresh.", + "iching: couldn't save settings.", + ]); + }); + + test("flush restores console.error for whatever comes after (fatal path)", () => { + deferDiagnostics(); + console.error("deferred note"); + flushDiagnostics(); + // The fatal handler flushes first, THEN prints the error — it must land + // directly on stderr, after the replayed diagnostics. + console.error("Error: something escaped the scene stack"); + expect(recorded).toEqual([ + "deferred note", + "Error: something escaped the scene stack", + ]); + }); + + test("the buffer drains on flush — no duplicate replay", () => { + deferDiagnostics(); + console.error("once only"); + flushDiagnostics(); + flushDiagnostics(); + expect(recorded).toEqual(["once only"]); + }); + + test("Error instances keep their stack in the capture", () => { + deferDiagnostics(); + const err = new Error("boom"); + console.error(err); + flushDiagnostics(); + expect(recorded).toHaveLength(1); + expect(recorded[0]).toContain("boom"); + }); + + test("a flush with nothing buffered emits nothing", () => { + deferDiagnostics(); + flushDiagnostics(); + expect(recorded).toEqual([]); + }); +}); diff --git a/apps/cli/src/__tests__/doctor.test.ts b/apps/cli/src/__tests__/doctor.test.ts index 0514b8b6..15319942 100644 --- a/apps/cli/src/__tests__/doctor.test.ts +++ b/apps/cli/src/__tests__/doctor.test.ts @@ -1,6 +1,32 @@ -import { describe, test, expect } from "bun:test"; +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { runCli as spawnCli, REPO_ROOT, MAIN_TS } from "../testing.ts"; +import { rm, writeFile, mkdir } from "node:fs/promises"; +import { freshTempDir } from "../testing.ts"; +import { join } from "node:path"; import { GUA, BINARY_TO_KW, TRIGRAMS } from "@iching/core"; +async function runDoctorEnv(env: Record): Promise { + // Spawn the real doctor with a CLEAN, controlled env so checkColor() actually + // runs. runCli hard-defaults NO_COLOR=1, and the doctor treats NO_COLOR as set + // when it is merely DEFINED — so to reach the truecolor/256 branches the var + // must be absent. Strip the inherited color vars first, then apply `env`. + const base: Record = { ...process.env }; + delete base.NO_COLOR; + delete base.COLORTERM; + delete base.TERM; + delete base.FORCE_COLOR; + const proc = Bun.spawn(["bun", MAIN_TS, "doctor"], { + cwd: REPO_ROOT, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + env: { ...base, ...env }, + }); + const out = await new Response(proc.stdout).text(); + await proc.exited; + return out; +} + describe("doctor checks", () => { test("glyph test: all trigram symbols present", () => { const expected = ["☰", "☱", "☲", "☳", "☴", "☵", "☶", "☷"]; @@ -31,22 +57,19 @@ describe("doctor checks", () => { expect(unique.size).toBe(64); }); - test("color detection: reports correct support", () => { - // Basic test — the detection logic is deterministic given env vars - const colorterm = process.env.COLORTERM ?? ""; - const term = process.env.TERM ?? ""; - const noColor = process.env.NO_COLOR; - - if (noColor !== undefined) { - // NO_COLOR overrides everything - expect(typeof noColor).toBe("string"); - } else if (colorterm === "truecolor" || colorterm === "24bit") { - expect(colorterm).toMatch(/truecolor|24bit/); - } else { - // At minimum we can detect the env vars exist or not - expect(typeof colorterm).toBe("string"); - expect(typeof term).toBe("string"); - } + // These spawn the real doctor under a controlled env and assert the rendered + // color line. The old test re-read process.env ITSELF and asserted typeof — + // it never invoked checkColor(), so a wrong or blank level passed green. + test("color check reports truecolor from COLORTERM (subprocess)", async () => { + expect(await runDoctorEnv({ COLORTERM: "truecolor" })).toContain("truecolor (24-bit)"); + }); + + test("color check reports 256-color from TERM (subprocess)", async () => { + expect(await runDoctorEnv({ TERM: "xterm-256color" })).toContain("256-color"); + }); + + test("color check reports NO_COLOR when it is set (subprocess)", async () => { + expect(await runDoctorEnv({ NO_COLOR: "1" })).toContain("NO_COLOR is set"); }); test("all GUA entries have required fields", () => { @@ -64,3 +87,140 @@ describe("doctor checks", () => { } }); }); + +// Journal integrity check — doctor must stream the journal, not just stat it: +// torn lines surface as a WARN with a count, never a hard failure. +describe("doctor journal check (subprocess)", () => { + + let dataDir: string; + + beforeEach(async () => { + dataDir = await freshTempDir("iching-doctor-test"); + }); + + afterEach(async () => { + await rm(dataDir, { recursive: true, force: true }); + }); + + const runDoctor = () => spawnCli(["doctor"], { dataDir }); + + const GOOD_LINE = JSON.stringify({ + date: "2025-01-15", + cast: { + lines: [ + { value: 7, isYang: true, isChanging: false }, + { value: 7, isYang: true, isChanging: false }, + { value: 7, isYang: true, isChanging: false }, + { value: 7, isYang: true, isChanging: false }, + { value: 7, isYang: true, isChanging: false }, + { value: 7, isYang: true, isChanging: false }, + ], + primary: 1, + becoming: null, + changingPositions: [], + nuclear: 1, + polarity: 2, + mirror: 1, + diagonal: 2, + }, + }); + + test("reports entry count for a clean journal", async () => { + await writeFile( + join(dataDir, "history.jsonl"), + `${GOOD_LINE}\n${GOOD_LINE}\n`, + "utf-8", + ); + + const { exitCode, stdout } = await runDoctor(); + expect(exitCode).toBe(0); + expect(stdout).toContain("[OK] Journal: 2 reading(s) recorded"); + }, 20_000); + + test("warns (not fails) on unreadable journal lines", async () => { + await writeFile( + join(dataDir, "history.jsonl"), + `${GOOD_LINE}\n{"date":"2025-01-1\n${GOOD_LINE}\n`, + "utf-8", + ); + + const { exitCode, stdout } = await runDoctor(); + expect(exitCode).toBe(0); // warn, never a hard failure + expect(stdout).toContain( + "[WARN] Journal: 2 reading(s) recorded, 1 unreadable line(s) skipped", + ); + }, 20_000); + + test("passes calmly when no journal exists yet", async () => { + const { exitCode, stdout } = await runDoctor(); + expect(exitCode).toBe(0); + expect(stdout).toContain("[OK] Journal: no journal yet"); + }, 20_000); + + test("fails (reports, never crashes) when the journal exists but can't be read", async () => { + // A directory at the journal path (→ EISDIR; in the wild a root-owned file + // → EACCES). The diagnostic must REPORT the read failure as a failed check — + // crashing on the very problem it exists to surface is the worst outcome. + await mkdir(join(dataDir, "history.jsonl")); + const { exitCode, stdout } = await runDoctor(); + expect(exitCode).not.toBe(0); // a real failure, surfaced + expect(stdout).toContain("[FAIL] Journal: exists but can't be read"); // reported, not crashed + }, 20_000); + + test("--json exits non-zero on a failed check, so scripts can branch on it", async () => { + // The human path exits 1 on failure; --json must too, or a CI/script + // consumer reads exit 0 and treats a broken environment as healthy. The + // JSON payload still streams in full (exitCode, not a hard exit()). + await mkdir(join(dataDir, "history.jsonl")); // → a failed Journal check + const { exitCode, stdout } = await spawnCli(["--json", "doctor"], { dataDir }); + expect(exitCode).not.toBe(0); // failure surfaced through the exit code… + const checks = JSON.parse(stdout); // …and the JSON still parses in full + expect(checks.some((c: { status: string }) => c.status === "fail")).toBe(true); + }, 20_000); + + test("warns on a corrupt daily cache, not just reports its existence", async () => { + // The cache/config were only existence-checked, so a torn write read as + // healthy ("[exists]"). doctor must surface it; it self-heals on next use + // (the store quarantines + resets), so a warning, not a hard failure. + await writeFile(join(dataDir, "daily-cache.json"), "{not valid json", "utf-8"); + const { exitCode, stdout } = await runDoctor(); + expect(exitCode).toBe(0); // self-healing → warn, not fail + expect(stdout).toContain("[WARN] Cache: corrupt JSON"); + }, 20_000); + + test("warns on a corrupt config too", async () => { + await writeFile(join(dataDir, "config.json"), "}also broken{", "utf-8"); + const { stdout } = await runDoctor(); + expect(stdout).toContain("[WARN] Config: corrupt JSON"); + }, 20_000); + + test("reports a valid config and cache as OK", async () => { + // A complete cache record (date + shown + structure + a shaped cast) is what + // the store accepts; config is permissive (any parseable object loads). + const validCache = JSON.stringify({ + date: "2026-01-01", + cast: JSON.parse(GOOD_LINE).cast, + shown: true, + structure: { + upper: { sym: "☰", n: "乾", img: "heaven" }, + lower: { sym: "☰", n: "乾", img: "heaven" }, + becoming: null, + }, + }); + await writeFile(join(dataDir, "config.json"), '{"theme":"ink"}', "utf-8"); + await writeFile(join(dataDir, "daily-cache.json"), validCache, "utf-8"); + const { stdout } = await runDoctor(); + expect(stdout).toContain("[OK] Config: valid"); + expect(stdout).toContain("[OK] Cache: valid"); + }, 20_000); + + test("warns on a parseable cache that isn't a usable record (would reset)", async () => { + // The P3 gap: `{"date":…}` parses but lacks cast/shown/structure, so the + // store quarantines and resets it on next use. doctor must not call that + // doomed cache "valid" — a user investigating a reset deserves to see why. + await writeFile(join(dataDir, "daily-cache.json"), '{"date":"2026-01-01"}', "utf-8"); + const { stdout } = await runDoctor(); + expect(stdout).toContain("[WARN] Cache: valid JSON but not a usable record"); + expect(stdout).not.toContain("[OK] Cache: valid"); + }, 20_000); +}); diff --git a/apps/cli/src/__tests__/hexagram-command.test.ts b/apps/cli/src/__tests__/hexagram-command.test.ts new file mode 100644 index 00000000..df8f7177 --- /dev/null +++ b/apps/cli/src/__tests__/hexagram-command.test.ts @@ -0,0 +1,54 @@ +// Subprocess coverage for the `hexagram` command — number/name lookup, --json, +// --style, and the error/exit-code paths. The resolver (resolveHexagramQuery) +// and the formatter are unit-tested elsewhere; this pins the command WIRING: +// arg parsing, the global --json flag, and the exact exit codes. +import { describe, test, expect } from "bun:test"; +import { runCli } from "../testing.ts"; + +describe("hexagram command", () => { + test("by King Wen number prints the hexagram", async () => { + const { exitCode, stdout } = await runCli(["hexagram", "1"], { env: { TZ: "UTC" } }); + expect(exitCode).toBe(0); + expect(stdout).toContain("乾"); // hexagram 1 + }, 20_000); + + test("--json by number emits the structured payload", async () => { + const { exitCode, stdout } = await runCli(["--json", "hexagram", "1"], { env: { TZ: "UTC" } }); + expect(exitCode).toBe(0); + const hex = JSON.parse(stdout); + expect(hex.number).toBe(1); + expect(hex.name).toBe("乾"); + expect(typeof hex.pinyin).toBe("string"); + expect(hex.commentary).toBeTruthy(); + }, 20_000); + + test("by Chinese name resolves to the right hexagram", async () => { + const { exitCode, stdout } = await runCli(["--json", "hexagram", "坤"], { env: { TZ: "UTC" } }); + expect(exitCode).toBe(0); + // A name query may resolve to a unique hexagram or a shortlist; either way + // it must surface hexagram 2 (坤). Handle both JSON shapes. + const out = JSON.parse(stdout); + const numbers = out.number != null ? [out.number] : out.matches.map((m: { number: number }) => m.number); + expect(numbers).toContain(2); + }, 20_000); + + test("an out-of-range number fails loudly with exit 1", async () => { + const { exitCode, stdout, stderr } = await runCli(["hexagram", "99"], { env: { TZ: "UTC" } }); + expect(exitCode).toBe(1); + expect(stderr).toContain("Hexagram number must be an integer from 1 to 64."); + expect(stdout).toBe(""); + }, 20_000); + + test("a query that matches nothing fails with exit 1", async () => { + const { exitCode, stderr } = await runCli(["hexagram", "zzzznotahexagram"], { env: { TZ: "UTC" } }); + expect(exitCode).toBe(1); + expect(stderr).toContain('No hexagram matches "zzzznotahexagram".'); + }, 20_000); + + test("an invalid --style fails with exit 1 and lists the valid styles", async () => { + const { exitCode, stderr } = await runCli(["hexagram", "1", "--style", "bogus"], { env: { TZ: "UTC" } }); + expect(exitCode).toBe(1); + expect(stderr).toContain('Invalid style "bogus"'); + expect(stderr).toContain("dx, tu, en, te, w"); + }, 20_000); +}); diff --git a/apps/cli/src/__tests__/hexagram-output.test.ts b/apps/cli/src/__tests__/hexagram-output.test.ts new file mode 100644 index 00000000..18085660 --- /dev/null +++ b/apps/cli/src/__tests__/hexagram-output.test.ts @@ -0,0 +1,62 @@ +// hexagram command output — oracle texts in JSON and plain formats + +import { describe, test, expect } from "bun:test"; +import { GUA } from "@iching/core"; +import { hexagramToJson } from "../output/json.js"; +import { formatHexagramPlain } from "../output/plain.js"; + +describe("hexagramToJson", () => { + test("includes ename, judgment, and all six line texts with 小象", () => { + const hex = GUA[0]; + const json = hexagramToJson(1, hex); + + expect(json.ename).toBe("The Creative"); + expect(json.judgment).toEqual({ gc: hex.gc, gcEn: hex.gcEn }); + + const lineTexts = json.lineTexts as Array>; + expect(lineTexts).toHaveLength(6); + expect(lineTexts[0]).toEqual({ + position: 1, + yao: hex.yao[0], + yaoEn: hex.yaoEn[0], + yaoXiao: hex.yaoXiao[0], + }); + expect(lineTexts[5].position).toBe(6); + }); + + test("extra is present for hexagrams 1-2 and null elsewhere", () => { + expect((hexagramToJson(1, GUA[0]).extra as Record).name).toBe("用九"); + expect((hexagramToJson(2, GUA[1]).extra as Record).name).toBe("用六"); + expect(hexagramToJson(3, GUA[2]).extra).toBeNull(); + }); + + test("existing commentary block is unchanged", () => { + const hex = GUA[20]; + const json = hexagramToJson(21, hex); + expect(json.commentary).toEqual({ + dx: hex.dx, + tu: hex.tu, + en: hex.en, + te: hex.te, + w: hex.w, + }); + }); +}); + +describe("formatHexagramPlain", () => { + test("all-styles output shows the judgment first", () => { + const hex = GUA[0]; + const text = formatHexagramPlain(1, hex); + expect(text).toContain(`Judgment (gc): ${hex.gc}`); + expect(text).toContain(`Judgment (gcEn): ${hex.gcEn}`); + // 卦辭 precedes the wing commentary + expect(text.indexOf("Judgment (gc):")).toBeLessThan(text.indexOf("大象 (dx):")); + }); + + test("single-style output stays style-only (no judgment block)", () => { + const hex = GUA[0]; + const text = formatHexagramPlain(1, hex, "dx"); + expect(text).toContain(hex.dx); + expect(text).not.toContain("Judgment (gc):"); + }); +}); diff --git a/apps/cli/src/__tests__/hexagram-resolve.test.ts b/apps/cli/src/__tests__/hexagram-resolve.test.ts new file mode 100644 index 00000000..25648832 --- /dev/null +++ b/apps/cli/src/__tests__/hexagram-resolve.test.ts @@ -0,0 +1,83 @@ +// resolveHexagramQuery — the shared hexagram-argument resolution used by +// `iching hexagram ` and `iching dict `: King Wen numbers pass +// through, names/pinyin/trigram grammar resolve via core searchHexagrams. + +import { describe, test, expect } from "bun:test"; +import { GUA } from "@iching/core"; +import { resolveHexagramQuery } from "../commands/hexagram.js"; + +describe("resolveHexagramQuery", () => { + test("integer in range resolves directly", () => { + expect(resolveHexagramQuery("11")).toEqual({ kind: "kw", kw: 11 }); + expect(resolveHexagramQuery(" 64 ")).toEqual({ kind: "kw", kw: 64 }); + }); + + test("integer out of range is invalid (keeps the classic range error)", () => { + expect(resolveHexagramQuery("0")).toEqual({ kind: "invalid" }); + expect(resolveHexagramQuery("65")).toEqual({ kind: "invalid" }); + expect(resolveHexagramQuery("99")).toEqual({ kind: "invalid" }); + }); + + test("Chinese name resolves uniquely (泰 → 11)", () => { + expect(resolveHexagramQuery("泰")).toEqual({ kind: "kw", kw: 11 }); + }); + + test("Simplified name resolves uniquely (剥 → 23)", () => { + expect(resolveHexagramQuery("剥")).toEqual({ kind: "kw", kw: 23 }); + }); + + test("pinyin resolves uniquely when only one hexagram answers (tai → 11)", () => { + expect(resolveHexagramQuery("tai")).toEqual({ kind: "kw", kw: 11 }); + }); + + test("English name resolves uniquely (the receptive → 2)", () => { + expect(resolveHexagramQuery("the receptive")).toEqual({ kind: "kw", kw: 2 }); + }); + + test("trigram pair grammar resolves uniquely (山風 → 18)", () => { + expect(resolveHexagramQuery("山風")).toEqual({ kind: "kw", kw: 18 }); + expect(resolveHexagramQuery("water over mountain")).toEqual({ kind: "kw", kw: 39 }); + }); + + test("a hexagram whose name IS a trigram token resolves to its KW, not a shortlist", () => { + // The single-trigram family scoring gives score 3 to every hexagram sharing a + // queried trigram, but an exact (score-0) hit must still win — otherwise the 8 + // doubled-trigram hexagrams' OWN name returned a family shortlist (15 hits for + // 乾) instead of opening the hexagram (the most canonical lookups in the app). + const byName: Array<[string, number]> = [ + ["乾", 1], ["坤", 2], ["坎", 29], ["離", 30], ["震", 51], ["艮", 52], ["巽", 57], ["兌", 58], + ]; + for (const [name, kw] of byName) expect(resolveHexagramQuery(name)).toEqual({ kind: "kw", kw }); + // A doubled symbol/char is a trigram PAIR (also score-0) → its one hexagram. + expect(resolveHexagramQuery("☰☰")).toEqual({ kind: "kw", kw: 1 }); + expect(resolveHexagramQuery("☷☷")).toEqual({ kind: "kw", kw: 2 }); + }); + + test("tone-stripped pinyin that collides stays a shortlist (qian → 乾 + 謙)", () => { + // Diacritic-insensitive matching merges qián (乾/1) and qiān (謙/15): two exact + // score-0 hits, so it's genuinely ambiguous (pre-existing on main, not a + // family-scoring regression). The resolver must NOT pick one arbitrarily. + const result = resolveHexagramQuery("qian"); + expect(result.kind).toBe("matches"); + if (result.kind === "matches") { + const kws = result.matches.map((g) => GUA.indexOf(g) + 1); + expect(kws).toContain(1); + expect(kws).toContain(15); + } + }); + + test("a single-trigram family query stays a shortlist (genuinely ambiguous)", () => { + // "fire" is the 離 trigram, in many hexagrams with no exact name hit — so it + // legitimately returns the family, unlike an exact name/pinyin above. + const result = resolveHexagramQuery("fire"); + expect(result.kind).toBe("matches"); + if (result.kind === "matches") { + expect(result.matches.length).toBeGreaterThan(1); + expect(result.matches.map((g) => GUA.indexOf(g) + 1)).toContain(30); // 離 itself among them + } + }); + + test("no match returns none", () => { + expect(resolveHexagramQuery("zzzzzz")).toEqual({ kind: "none" }); + }); +}); diff --git a/apps/cli/src/__tests__/hook-adapter.test.ts b/apps/cli/src/__tests__/hook-adapter.test.ts index 19f10ab0..436fa55d 100644 --- a/apps/cli/src/__tests__/hook-adapter.test.ts +++ b/apps/cli/src/__tests__/hook-adapter.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { runCli as spawnCli } from "../testing.ts"; +import { rm, readFile, writeFile, mkdir } from "node:fs/promises"; +import { freshTempDir, utcToday } from "../testing.ts"; import { join } from "node:path"; import { castHexagram, @@ -16,13 +17,30 @@ import { let dataDir: string; beforeEach(async () => { - dataDir = await mkdtemp(join(tmpdir(), "iching-hook-test-")); + dataDir = await freshTempDir("iching-hook-test"); }); afterEach(async () => { await rm(dataDir, { recursive: true, force: true }); }); +/** + * "Today" as the SUBPROCESS will stamp it. The bun test runner forces UTC + * while a spawned child runs in the system timezone, so the two can disagree + * on the date — runHook pins the child to TZ=UTC to keep them aligned. + */ +/** Run bare `iching` with piped stdin (hook mode) against an ICHING_HOME dir. */ +async function runHook(home: string): Promise { + // The adapter resolves paths without a --data-dir override; collapse all + // storage into the temp dir via ICHING_HOME. TZ=UTC: see utcToday. Hook mode + // reads a JSON event ("{}") from stdin. + const { exitCode } = await spawnCli([], { + env: { ICHING_HOME: home, TZ: "UTC" }, + stdin: "{}", + }); + return exitCode; +} + describe("hook adapter", () => { test("simulated hook input produces output and saves state", async () => { // Simulate the hook adapter logic directly (without piping to process) @@ -84,4 +102,103 @@ describe("hook adapter", () => { expect(cached!.shown).toBe(true); expect(cached!.cast.primary).toBe(cast.primary); }); + + // End-to-end: bare `iching` with piped stdin enters hook mode (main.ts). + // A fresh hook cast must record coin provenance in the journal and cache — + // including rng provenance (default entropy config → crypto). + test("hook mode records method 'coin' and crypto rng in journal and cache", async () => { + const exitCode = await runHook(dataDir); + expect(exitCode).toBe(0); + + const journalLine = (await readFile(join(dataDir, "history.jsonl"), "utf-8")).trim(); + const entry = JSON.parse(journalLine); + expect(entry.method).toBe("coin"); + expect(entry.rng).toEqual({ source: "crypto", intentionBound: false }); + + const cache = JSON.parse(await readFile(join(dataDir, "daily-cache.json"), "utf-8")); + expect(cache.method).toBe("coin"); + expect(cache.rng).toEqual({ source: "crypto", intentionBound: false }); + }, 20_000); + + test("a lost cache after a hook cast does not duplicate the journal row", async () => { + // The hook journals first, then writes the cache. If the cache write fails + // (here: the file is lost afterward), the next prompt would see no valid + // cache and — without the journal-recovery guard — recast and append a + // SECOND row, then a third, on every prompt. The journal is the durable + // record; a cache-less re-run must recover today's reading from it. + expect(await runHook(dataDir)).toBe(0); // fresh cast: one row + cache + const firstLine = (await readFile(join(dataDir, "history.jsonl"), "utf-8")).trim(); + await rm(join(dataDir, "daily-cache.json"), { force: true }); // simulate the failed/lost cache + + expect(await runHook(dataDir)).toBe(0); // recovers from the journal, no recast + + const lines = (await readFile(join(dataDir, "history.jsonl"), "utf-8")).trim().split("\n"); + expect(lines).toHaveLength(1); // still exactly one reading for today — no duplicate + expect(lines[0]).toBe(firstLine); // and it's the same reading, untouched + // The cache is rebuilt from the recovered reading, restoring the daily anchor. + const cache = JSON.parse(await readFile(join(dataDir, "daily-cache.json"), "utf-8")); + expect(cache.date).toBe(utcToday()); + }, 20_000); + + test("a blocked data dir doesn't crash the hook — the reading still displays", async () => { + // The hook persists (journal + cache) BEFORE it displays the reading. A + // directory at the journal path (→ EISDIR; in the wild a read-only/full dir + // → EROFS/ENOSPC) must not crash the hook before showing the reading — the + // display is the hook's whole job. Best-effort persist: show, never crash. + await mkdir(join(dataDir, "history.jsonl")); // block the journal write + const { exitCode, stdout } = await spawnCli([], { + env: { ICHING_HOME: dataDir, TZ: "UTC" }, + stdin: "{}", + }); + expect(exitCode).toBe(0); // best-effort persist — no crash… + expect(stdout.trim().length).toBeGreaterThan(0); // …the reading still displayed + }, 20_000); + + // A fresh hook cast must honor the saved entropy config (cf. commands/cast.ts) + // instead of always reaching for CryptoRandomSource. The hook carries no + // intention, so a bound cast records intentionBound:false. + test("hook mode honors entropy=bound config on a fresh cast", async () => { + await writeFile( + join(dataDir, "config.json"), + JSON.stringify({ entropy: "bound" }), + "utf-8", + ); + + const exitCode = await runHook(dataDir); + expect(exitCode).toBe(0); + + const journalLine = (await readFile(join(dataDir, "history.jsonl"), "utf-8")).trim(); + const entry = JSON.parse(journalLine); + expect(entry.rng).toEqual({ source: "bound", intentionBound: false }); + + const cache = JSON.parse(await readFile(join(dataDir, "daily-cache.json"), "utf-8")); + expect(cache.rng).toEqual({ source: "bound", intentionBound: false }); + }, 20_000); + + // Provenance survival: a hook run AFTER a bound/seeded TUI cast rewrites the + // cache (shown:true) — it must carry the day's rng provenance through, not + // strip it. + test("hook mode preserves cached rng provenance on rewrite", async () => { + const source = new CryptoRandomSource(); + const cast = castHexagram(source); + const structure = buildStructure(cast); + const cacheStore = new JsonDailyCacheStore(join(dataDir, "daily-cache.json")); + await cacheStore.write({ + date: utcToday(), + cast, + shown: true, + structure, + intention: "will it hold?", + method: "yarrow", + rng: { source: "bound", intentionBound: true }, + }); + + const exitCode = await runHook(dataDir); + expect(exitCode).toBe(0); + + const cache = JSON.parse(await readFile(join(dataDir, "daily-cache.json"), "utf-8")); + expect(cache.rng).toEqual({ source: "bound", intentionBound: true }); + expect(cache.method).toBe("yarrow"); + expect(cache.intention).toBe("will it hold?"); + }, 20_000); }); diff --git a/apps/cli/src/__tests__/journal-command.test.ts b/apps/cli/src/__tests__/journal-command.test.ts new file mode 100644 index 00000000..72344423 --- /dev/null +++ b/apps/cli/src/__tests__/journal-command.test.ts @@ -0,0 +1,1054 @@ +// Subprocess tests for the `iching journal` command — hexagram filtering, +// name-enriched JSON output, method provenance notes, and torn-line survival. + +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { runCli as spawnCli, type RunResult } from "../testing.ts"; +import { rm, appendFile, mkdir, readFile } from "node:fs/promises"; +import { freshTempDir, seedJournal } from "../testing.ts"; +import { join } from "node:path"; +import type { HistoryEntry } from "@iching/core"; +import { GUA, castHexagram, SeededRandomSource } from "@iching/core"; +import { castOf } from "@iching/core/testing"; +import { localToday } from "../util/today.ts"; + +async function runCli(dataDir: string, args: string[]): Promise { + // Pin the subprocess clock to UTC so the test and the spawned CLI agree on + // localToday() — without it the daily-anchor tests flake between UTC midnight + // and local midnight. + return spawnCli(args, { dataDir, env: { TZ: "UTC" } }); +} + +// A genuine cast OF `primary` that becomes `becoming` (castOf flips exactly the +// lines where they differ, so primary/becoming/derived are all consistent and +// the record survives isCastShaped on read). Changing positions follow the real +// diff: 3→8 moves [1], 3→39 moves [1,3]. +function makeCast(primary: number, becoming: number | null) { + return becoming === null ? castOf(primary) : castOf(primary, { becoming }); +} + +function makeEntry( + date: string, + primary: number, + becoming: number | null, + method?: HistoryEntry["method"], +): HistoryEntry { + return { date, cast: makeCast(primary, becoming), timestamp: `${date}T09:00:00.000Z`, method }; +} + +describe("journal command", () => { + let dataDir: string; + + beforeEach(async () => { + dataDir = await freshTempDir("iching-journal-cmd-test"); + }); + + afterEach(async () => { + await rm(dataDir, { recursive: true, force: true }); + }); + + test("list --hexagram matches primary OR becoming", async () => { + await seedJournal(dataDir, [ + makeEntry("2026-01-01", 39, null), // primary match + makeEntry("2026-01-02", 5, null), // no match + makeEntry("2026-01-03", 3, 39), // becoming match + ]); + + const { exitCode, stdout } = await runCli(dataDir, ["journal", "list", "--hexagram", "39"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("2026-01-01"); + expect(stdout).not.toContain("2026-01-02"); + expect(stdout).toContain("2026-01-03"); + }, 20_000); + + test("list --hexagram rejects non-numeric and out-of-range values", async () => { + await seedJournal(dataDir, [makeEntry("2026-01-01", 1, null)]); + for (const bad of ["abc", "0", "65", "3.5"]) { + const { exitCode, stderr } = await runCli(dataDir, ["journal", "list", "--hexagram", bad]); + expect(exitCode).toBe(1); + expect(stderr).toContain("Invalid --hexagram"); + } + }, 20_000); + + // Regression: Number("abc") is NaN, slice(0, NaN) is [] — garbage --limit + // used to print "No readings found." (exit 0) with data present, and + // --limit -1 silently dropped the oldest entry. + test("list --limit rejects non-numeric, zero, negative, and fractional values", async () => { + await seedJournal(dataDir, [makeEntry("2026-01-01", 1, null)]); + for (const bad of ["abc", "0", "-1", "3.5"]) { + const { exitCode, stdout, stderr } = await runCli(dataDir, [ + "journal", "list", "--limit", bad, + ]); + expect(exitCode).toBe(1); + expect(stderr).toContain(`Invalid --limit "${bad}"`); + expect(stdout).not.toContain("No readings found."); + } + }, 20_000); + + // Regression (review #5): --all discards --limit, so a bad --limit alongside + // --all must NOT reject a flag the command ignores. + test("list --all ignores --limit and does not reject a bad value", async () => { + await seedJournal(dataDir, [ + makeEntry("2026-01-01", 1, null), + makeEntry("2026-01-02", 2, null), + ]); + const { exitCode, stdout, stderr } = await runCli(dataDir, [ + "journal", "list", "--all", "--limit", "abc", + ]); + expect(exitCode).toBe(0); + expect(stderr).not.toContain("Invalid --limit"); + expect(stdout).toContain("2026-01-01"); // --all keeps every reading + expect(stdout).toContain("2026-01-02"); + }, 20_000); + + test("list --limit still truncates to the most recent N", async () => { + await seedJournal(dataDir, [ + makeEntry("2026-01-01", 1, null), + makeEntry("2026-01-02", 2, null), + makeEntry("2026-01-03", 3, null), + ]); + const { exitCode, stdout } = await runCli(dataDir, ["journal", "list", "--limit", "2"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("2026-01-03"); + expect(stdout).toContain("2026-01-02"); + expect(stdout).not.toContain("2026-01-01"); + }, 20_000); + + test("list --limit takes the latest-DATED, not the last-recorded (out-of-order)", async () => { + // An old reading recorded AFTER newer ones (imported/merged). A plain + // reverse() would float it to the top and into --limit; ordering by the + // pane's time-key must not — matching the TUI list and the ◉ recency accent. + await seedJournal(dataDir, [ + makeEntry("2026-02-01", 1, null), // newer + makeEntry("2026-02-02", 2, null), // newest by date + makeEntry("2026-01-10", 3, null), // OLD, recorded last (the import) + ]); + const { exitCode, stdout } = await runCli(dataDir, ["journal", "list", "--limit", "2"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("2026-02-02"); // the two latest-dated… + expect(stdout).toContain("2026-02-01"); + expect(stdout).not.toContain("2026-01-10"); // …not the late old import + }, 20_000); + + // Regression: --since was never format-validated — the lexicographic + // compare against "notadate" filtered every entry out (exit 0). + test("list --since rejects non-YYYY-MM-DD values", async () => { + await seedJournal(dataDir, [makeEntry("2026-01-01", 1, null)]); + for (const bad of ["notadate", "2026/01/01", "yesterday", "2026-1-1"]) { + const { exitCode, stdout, stderr } = await runCli(dataDir, [ + "journal", "list", "--since", bad, + ]); + expect(exitCode).toBe(1); + expect(stderr).toContain(`Invalid --since "${bad}"`); + expect(stdout).not.toContain("No readings found."); + } + }, 20_000); + + test("list --since with a valid date filters older entries", async () => { + await seedJournal(dataDir, [ + makeEntry("2026-01-01", 1, null), + makeEntry("2026-01-03", 3, null), + ]); + const { exitCode, stdout } = await runCli(dataDir, [ + "journal", "list", "--since", "2026-01-02", + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("2026-01-03"); + expect(stdout).not.toContain("2026-01-01"); + }, 20_000); + + test("list --until rejects non-YYYY-MM-DD values", async () => { + await seedJournal(dataDir, [makeEntry("2026-01-01", 1, null)]); + for (const bad of ["notadate", "2026/01/01", "2026-1-1"]) { + const { exitCode, stderr } = await runCli(dataDir, ["journal", "list", "--until", bad]); + expect(exitCode).toBe(1); + expect(stderr).toContain(`Invalid --until "${bad}"`); + } + }, 20_000); + + test("list --since/--until bound a window (both inclusive)", async () => { + await seedJournal(dataDir, [ + makeEntry("2026-01-01", 1, null), + makeEntry("2026-02-15", 2, null), // 賁-era, inside the window + makeEntry("2026-04-01", 3, null), + ]); + const { exitCode, stdout } = await runCli(dataDir, [ + "journal", "list", "--since", "2026-02-01", "--until", "2026-03-01", + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("2026-02-15"); // inside the window + expect(stdout).not.toContain("2026-01-01"); // before --since + expect(stdout).not.toContain("2026-04-01"); // after --until + }, 20_000); + + test("an inverted --since/--until window fails loudly, not as an empty result", async () => { + await seedJournal(dataDir, [makeEntry("2026-02-15", 1, null)]); + // list: clear error instead of "No readings found." + const list = await runCli(dataDir, [ + "journal", "list", "--since", "2026-05-01", "--until", "2026-03-01", + ]); + expect(list.exitCode).toBe(1); + expect(list.stderr).toContain('Invalid range: --since "2026-05-01" is after --until "2026-03-01"'); + expect(list.stdout).not.toContain("No readings found."); + // patterns: clear error instead of the calm "No readings to observe yet." + const pat = await runCli(dataDir, [ + "journal", "patterns", "--since", "2026-05-01", "--until", "2026-03-01", + ]); + expect(pat.exitCode).toBe(1); + expect(pat.stderr).toContain("Invalid range:"); + expect(pat.stdout).not.toContain("No readings to observe yet"); + }, 20_000); + + test("a bounded patterns report discloses its window and drops now-relative copy", async () => { + await seedJournal(dataDir, [ + makeEntry("2023-02-10", 1, null), + makeEntry("2023-02-20", 2, null), + ]); + // Historical window (ends in the past): disclose the period, drop "this month". + const hist = await runCli(dataDir, [ + "journal", "patterns", "--since", "2023-01-01", "--until", "2023-12-31", + ]); + expect(hist.exitCode).toBe(0); + expect(hist.stdout).toContain("Observing 2023-01-01 through 2023-12-31"); + expect(hist.stdout).not.toContain("this month"); // now-relative, out of frame + + // Open-ended --since (window includes now): disclose, but "this month" stays. + const open = await runCli(dataDir, ["journal", "patterns", "--since", "2023-01-01"]); + expect(open.exitCode).toBe(0); + expect(open.stdout).toContain("Observing 2023-01-01 through now"); + expect(open.stdout).toContain("this month"); + + // No window: no disclosure line at all. + const all = await runCli(dataDir, ["journal", "patterns"]); + expect(all.stdout).not.toContain("Observing"); + }, 20_000); + + test("the plain digest pluralizes 'active day' to match its sibling count", async () => { + // A first-day journal (every reading on one date) has activeDays === 1, and + // cadence renders from a single dated day. The a1 line already pluralizes + // "reading" by count; "active day(s)" must match it — never "1 active days". + await seedJournal(dataDir, [ + makeEntry("2026-03-10", 1, null), + makeEntry("2026-03-10", 2, null), + ]); + const oneDay = await runCli(dataDir, ["journal", "patterns"]); + expect(oneDay.exitCode).toBe(0); + expect(oneDay.stdout).toContain("1 active day"); + expect(oneDay.stdout).not.toContain("1 active days"); // the guard: never the plural at count 1 + + // Two distinct days → the plural stands. + await seedJournal(dataDir, [ + makeEntry("2026-03-10", 1, null), + makeEntry("2026-03-12", 2, null), + ]); + const twoDays = await runCli(dataDir, ["journal", "patterns"]); + expect(twoDays.exitCode).toBe(0); + expect(twoDays.stdout).toContain("2 active days"); + }, 20_000); + + test("patterns --until bounds the observation to a past period", async () => { + await seedJournal(dataDir, [ + makeEntry("2026-01-10", 1, null), + makeEntry("2026-01-20", 2, null), + makeEntry("2026-06-01", 3, null), // outside — must not count + ]); + const { exitCode, stdout } = await runCli(dataDir, [ + "--json", "journal", "patterns", "--until", "2026-02-01", + ]); + expect(exitCode).toBe(0); + expect(JSON.parse(stdout).total).toBe(2); // only the two within the window + }, 20_000); + + test("patterns --until measures cadence as of the window end, not real today", async () => { + // A retrospective's cadence must read AS OF the window's close: the last + // reading inside the window is 2026-01-20, so with --until 2026-02-01 the + // idle span is 12 days (to the window end), not the years since to real now. + await seedJournal(dataDir, [ + makeEntry("2026-01-10", 1, null), + makeEntry("2026-01-20", 2, null), + makeEntry("2026-06-01", 3, null), // outside the window + ]); + const { exitCode, stdout } = await runCli(dataDir, [ + "--json", "journal", "patterns", "--until", "2026-02-01", + ]); + expect(exitCode).toBe(0); + const p = JSON.parse(stdout); + expect(p.total).toBe(2); + expect(p.cadence.idleDays).toBe(12); // 2026-01-20 → 2026-02-01, not "now" + }, 20_000); + + test("list --json enriches entries with resolved names, raw fields intact", async () => { + await seedJournal(dataDir, [makeEntry("2026-01-03", 3, 39, "yarrow")]); + + const { exitCode, stdout } = await runCli(dataDir, ["--json", "journal", "list"]); + expect(exitCode).toBe(0); + const entries = JSON.parse(stdout); + expect(entries).toHaveLength(1); + const entry = entries[0]; + // Raw HistoryEntry fields preserved (backward-compatible) + expect(entry.date).toBe("2026-01-03"); + expect(entry.cast.primary).toBe(3); + expect(entry.method).toBe("yarrow"); + // Additive name blocks so scripts don't need the data table + expect(entry.primary.kw).toBe(3); + expect(entry.primary.n).toBe("屯"); + expect(typeof entry.primary.p).toBe("string"); + expect(typeof entry.primary.ename).toBe("string"); + expect(typeof entry.primary.u).toBe("string"); + expect(entry.becoming.kw).toBe(39); + expect(entry.becoming.n).toBe("蹇"); + }, 20_000); + + test("list --json becoming is null for unchanging casts", async () => { + await seedJournal(dataDir, [makeEntry("2026-01-01", 1, null)]); + const { stdout } = await runCli(dataDir, ["--json", "journal", "list"]); + const entries = JSON.parse(stdout); + expect(entries[0].becoming).toBeNull(); + }, 20_000); + + test("show --json is enriched too", async () => { + await seedJournal(dataDir, [makeEntry("2026-01-03", 3, 39)]); + const { exitCode, stdout } = await runCli(dataDir, ["--json", "journal", "show", "latest"]); + expect(exitCode).toBe(0); + const entry = JSON.parse(stdout); + expect(entry.primary.kw).toBe(3); + expect(entry.becoming.kw).toBe(39); + }, 20_000); + + test("plain list notes non-coin methods quietly; coin stays unmarked", async () => { + await seedJournal(dataDir, [ + makeEntry("2026-01-01", 1, null, "coin"), + makeEntry("2026-01-02", 2, null, "yarrow"), + ]); + + const { stdout } = await runCli(dataDir, ["journal", "list"]); + const lines = stdout.trimEnd().split("\n"); + const coinLine = lines.find((l) => l.includes("2026-01-01"))!; + const yarrowLine = lines.find((l) => l.includes("2026-01-02"))!; + expect(coinLine).not.toContain("coins"); + expect(yarrowLine).toContain("· yarrow stalks"); + }, 20_000); + + test("plain list names which lines moved on a changing reading — TUI list parity", async () => { + // makeCast marks line 1 moving when becoming !== null. The list shows the + // becoming hexagram AND the moving position inline, so a CLI scan reveals + // what turned each reading (matching the TUI list) without `journal show`. + await seedJournal(dataDir, [ + makeEntry("2026-01-01", 3, 8), // changing → "[1]" + makeEntry("2026-01-02", 2, null), // static → no brackets + ]); + const { stdout } = await runCli(dataDir, ["journal", "list"]); + const lines = stdout.trimEnd().split("\n"); + const changing = lines.find((l) => l.includes("2026-01-01"))!; + const stat = lines.find((l) => l.includes("2026-01-02"))!; + expect(changing).toContain("→"); // becoming shown + expect(changing).toContain("[1]"); // and which line moved + expect(stat).not.toContain("["); // a static reading carries no positions + }, 20_000); + + test("an unknown method value shows its name, never 'undefined' (forward-compat)", async () => { + // A reading written by a newer version with a cast method this build does + // not know must show the raw method name, not "undefined" — parity with + // parseLine tolerating unknown record kinds. methodLabel had no default case. + const entry = { ...makeEntry("2026-01-01", 1, null), method: "plumblossom" } as unknown as HistoryEntry; + await seedJournal(dataDir, [entry]); + + const list = await runCli(dataDir, ["journal", "list"]); + expect(list.exitCode).toBe(0); + expect(list.stdout).toContain("· plumblossom"); // the raw method name… + expect(list.stdout).not.toContain("undefined"); // …never "undefined" + + const show = await runCli(dataDir, ["journal", "show", "latest"]); + expect(show.stdout).toContain("Method: plumblossom"); + expect(show.stdout).not.toContain("undefined"); + }, 20_000); + + test("plain show carries a Method line when provenance exists", async () => { + await seedJournal(dataDir, [makeEntry("2026-01-02", 2, null, "yarrow-manual")]); + const { stdout } = await runCli(dataDir, ["journal", "show", "2026-01-02"]); + expect(stdout).toContain("Method: yarrow stalks, by hand"); + }, 20_000); + + test("plain show omits the Method line for legacy entries", async () => { + await seedJournal(dataDir, [makeEntry("2026-01-02", 2, null)]); + const { stdout } = await runCli(dataDir, ["journal", "show", "2026-01-02"]); + expect(stdout).not.toContain("Method:"); + }, 20_000); + + test("plain show surfaces the 啟蒙 reading — the crux of a journalled reading", async () => { + // Regression: formatCastPlain prints the reading at cast time, but + // formatJournalShowPlain dropped it — so revisiting a reading via + // `journal show` lost the very texts you contemplate. They now share + // readingPlainLines, so a reading reads the same fresh and revisited. + // 3 → 8 differs only at line 1, so this is a one-moving cast: the reading is + // that one line's 爻辭. + await seedJournal(dataDir, [makeEntry("2026-02-02", 3, 8)]); // primary 3, becoming 8, [line 1] + const { exitCode, stdout } = await runCli(dataDir, ["journal", "show", "2026-02-02"]); + expect(exitCode).toBe(0); + // The becoming line still records which positions moved… + expect(stdout).toContain("Hexagram 8 [line 1]"); + // …and the reading itself surfaces that line's 爻辭, not merely the position. + expect(stdout).toContain("Reading (啟蒙):"); + expect(stdout).toContain(`爻1: ${GUA[2].yao[0]}`); // hexagram 3, line 1 yao (zh) + expect(stdout).toContain(GUA[2].yaoEn[0]); // and its English + }, 20_000); + + test("plain show reads both judgments at 3 moving lines — not the raw 爻辭", async () => { + // The parity case the shared readingPlainLines fixes: at 3 moving lines the + // 啟蒙 reading is BOTH judgments (本卦 + 之卦), not the moving lines' 爻辭. A + // raw moving-line dump would diverge from the fresh cast here. Seed 7 casts + // 47 → 62 with lines 2/3/5 moving — a real, self-consistent 3-moving cast. + const cast = castHexagram(new SeededRandomSource(7)); + expect(cast.changingPositions).toEqual([2, 3, 5]); // guard the fixture + const primary = GUA[cast.primary - 1]; + const becoming = GUA[cast.becoming! - 1]; + await seedJournal(dataDir, [{ date: "2026-03-03", cast, timestamp: "2026-03-03T09:00:00.000Z" }]); + const { exitCode, stdout } = await runCli(dataDir, ["journal", "show", "2026-03-03"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Reading (啟蒙):"); + // Both judgments, primary then becoming… + expect(stdout).toContain(`${primary.u} ${primary.n} 卦辭: ${primary.gc}`); + expect(stdout).toContain(`${becoming.u} ${becoming.n} 卦辭: ${becoming.gc}`); + // …and NOT the moving lines' 爻辭 (which a raw dump would have shown). + expect(stdout).not.toContain(`爻2: ${primary.yao[1]}`); + expect(stdout).not.toContain(`爻5: ${primary.yao[4]}`); + }, 20_000); + + test("plain show surfaces the 卦辭 as the reading for a static reading", async () => { + // A static cast turns on its 卦辭 — the fresh cast prints it (Judgment block) + // and the TUI panel shows it, so `journal show` must too (it has no separate + // Judgment block, so the reading section carries it). Regression guard for + // the fresh-vs-revisited parity at 0 moving lines. + await seedJournal(dataDir, [makeEntry("2026-02-03", 2, null)]); // no moving lines + const { stdout } = await runCli(dataDir, ["journal", "show", "2026-02-03"]); + expect(stdout).toContain("Reading (啟蒙):"); + expect(stdout).toContain(`卦辭: ${GUA[1].gc}`); // hexagram 2 (坤) judgment + expect(stdout).not.toContain("[line"); // …but no moving-line bracket + }, 20_000); + + test("plain list/show strip terminal control sequences from a stored intention", async () => { + // The CLI/TUI input paths sanitize what a user types, but a journal file of + // external provenance (synced, restored, imported, hand-edited) can carry + // escapes the app never wrote. Plain output goes straight to the terminal + // with no cell-buffer backstop, so an unstripped intention would inject + // (set the title, clear the screen). The note line already strips; the + // intention must too. Payload: ESC + BEL between two words. + const ESC = String.fromCharCode(0x1b); + const BEL = String.fromCharCode(0x07); + const evil = { ...makeEntry("2026-05-01", 1, null), intention: `calm${ESC}[2J${BEL}mind` }; + await seedJournal(dataDir, [evil]); + + for (const argv of [["journal", "list"], ["journal", "show", "2026-05-01"]]) { + const { exitCode, stdout } = await runCli(dataDir, argv); + expect(exitCode).toBe(0); + expect(stdout).not.toContain(ESC); // no raw ESC reaches the terminal + expect(stdout).not.toContain(BEL); // no raw BEL either + expect(stdout).toContain("calm"); // the words survive, only the controls are gone + expect(stdout).toContain("mind"); + } + }, 20_000); + + test("show surfaces the day's LATEST reading, even appended out of order", async () => { + // Three readings on one day, appended out of chronological order (as an + // imported journal can be). `show` must surface the latest by time-key, not + // whichever was appended last. + await seedJournal(dataDir, [ + { date: "2026-02-01", timestamp: "2026-02-01T20:00:00.000Z", cast: makeCast(29, null), method: "coin" }, + { date: "2026-02-01", timestamp: "2026-02-01T08:00:00.000Z", cast: makeCast(1, null), method: "coin" }, + { date: "2026-02-01", timestamp: "2026-02-01T14:00:00.000Z", cast: makeCast(11, null), method: "coin" }, + ]); + const { exitCode, stdout } = await runCli(dataDir, ["journal", "show", "2026-02-01"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Hexagram 29"); // 坎, the 20:00 reading — the latest… + expect(stdout).not.toContain("Hexagram 11"); // …not the 14:00 one appended last + }, 20_000); + + test("show today surfaces today's reading — the CLI daily anchor", async () => { + // `show today` resolves the "today" keyword to localToday() and surfaces + // that day's reading. Every other show test uses a literal date, so the + // keyword path — the CLI side of the daily anchor — was never exercised. + const today = localToday(); + await seedJournal(dataDir, [ + makeEntry("2025-12-25", 5, null), // an older reading on another day + makeEntry(today, 39, null), // today's reading + ]); + const { exitCode, stdout } = await runCli(dataDir, ["journal", "show", "today"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Hexagram 39"); // today's reading… + expect(stdout).not.toContain("Hexagram 5"); // …not the older one + }, 20_000); + + test("show today errors calmly when today holds no reading", async () => { + // Only an older reading exists; the empty day is reported with its resolved + // date — yesterday's reading is never surfaced as today's. + await seedJournal(dataDir, [makeEntry("2025-12-25", 5, null)]); + const { exitCode, stderr } = await runCli(dataDir, ["journal", "show", "today"]); + expect(exitCode).toBe(1); + expect(stderr).toContain("No reading found for today"); + }, 20_000); + + test("plain show carries the quiet entropy line only for bound entries", async () => { + const bound: HistoryEntry = { + ...makeEntry("2026-01-03", 3, null, "coin"), + rng: { source: "bound", intentionBound: true }, + }; + const crypto: HistoryEntry = { + ...makeEntry("2026-01-04", 4, null, "coin"), + rng: { source: "crypto", intentionBound: false }, + }; + await seedJournal(dataDir, [bound, crypto]); + + const boundShow = await runCli(dataDir, ["journal", "show", "2026-01-03"]); + expect(boundShow.stdout).toContain( + "Entropy: local machine entropy, bound to the intention and moment.", + ); + + // Plain crypto (and legacy entries with no rng) stay silent. + const cryptoShow = await runCli(dataDir, ["journal", "show", "2026-01-04"]); + expect(cryptoShow.stdout).not.toContain("Entropy:"); + }, 20_000); + + test("show --json carries the rng block through unchanged", async () => { + const bound: HistoryEntry = { + ...makeEntry("2026-01-05", 5, null, "coin"), + rng: { source: "bound", intentionBound: false }, + }; + await seedJournal(dataDir, [bound]); + const { exitCode, stdout } = await runCli(dataDir, ["--json", "journal", "show", "latest"]); + expect(exitCode).toBe(0); + expect(JSON.parse(stdout).rng).toEqual({ source: "bound", intentionBound: false }); + }, 20_000); + + // Regression: a torn line used to make `journal list` and `journal show` + // throw a SyntaxError forever. Readers now skip the damage. + test("list and show survive a torn trailing line", async () => { + await seedJournal(dataDir, [makeEntry("2026-01-01", 1, null)]); + await appendFile(join(dataDir, "history.jsonl"), '{"date":"2026-01-0', "utf-8"); + + const list = await runCli(dataDir, ["journal", "list"]); + expect(list.exitCode).toBe(0); + expect(list.stdout).toContain("2026-01-01"); + + const show = await runCli(dataDir, ["journal", "show", "latest"]); + expect(show.exitCode).toBe(0); + expect(show.stdout).toContain("2026-01-01"); + }, 20_000); + + // Torn-line damage is surfaced, not hidden: a one-line stderr note after + // list/show says how many lines were skipped. Clean journals stay silent. + test("list and show note skipped unreadable lines on stderr", async () => { + await seedJournal(dataDir, [makeEntry("2026-01-01", 1, null)]); + await appendFile(join(dataDir, "history.jsonl"), '{"date":"2026-01-0', "utf-8"); + + const list = await runCli(dataDir, ["journal", "list"]); + expect(list.exitCode).toBe(0); + expect(list.stderr).toContain("note: 1 unreadable journal line(s) skipped"); + + const show = await runCli(dataDir, ["journal", "show", "latest"]); + expect(show.exitCode).toBe(0); + expect(show.stderr).toContain("note: 1 unreadable journal line(s) skipped"); + + // --json keeps stdout parseable; the note stays on stderr. + const json = await runCli(dataDir, ["--json", "journal", "list"]); + expect(JSON.parse(json.stdout)).toHaveLength(1); + expect(json.stderr).toContain("unreadable journal line(s) skipped"); + }, 20_000); + + test("list and show stay silent on stderr for a clean journal", async () => { + await seedJournal(dataDir, [makeEntry("2026-01-01", 1, null)]); + + const list = await runCli(dataDir, ["journal", "list"]); + expect(list.exitCode).toBe(0); + expect(list.stderr).toBe(""); + + const show = await runCli(dataDir, ["journal", "show", "latest"]); + expect(show.exitCode).toBe(0); + expect(show.stderr).toBe(""); + }, 20_000); + + test("a whole-file read failure is a calm message, not a raw EISDIR", async () => { + // Torn LINES are skipped with a quiet note; a whole-file read failure (here + // a directory left at the journal path → EISDIR; in the wild a root-owned + // file → EACCES) must read as a clear message, not the raw Node error. + await mkdir(join(dataDir, "history.jsonl")); + + const list = await runCli(dataDir, ["journal", "list"]); + expect(list.exitCode).toBe(1); + expect(list.stderr).toMatch(/couldn't read your journal/i); // calm, contextual… + expect(list.stderr).not.toMatch(/EISDIR/); // …never the raw Node error + expect(list.stdout).toBe(""); // and nothing printed as if it were empty + + const show = await runCli(dataDir, ["journal", "show", "today"]); + expect(show.exitCode).toBe(1); + expect(show.stderr).toMatch(/couldn't read your journal/i); + }, 20_000); + + // Regression: a syntactically valid record with an empty cast object used + // to pass the per-line guard and crash `journal list` resolving + // GUA[entry.cast.primary - 1] / entry.cast.becoming names. Malformed + // records now count as skipped lines, like torn ones. + test("list and show survive a record whose cast is an empty object", async () => { + await seedJournal(dataDir, [makeEntry("2026-01-01", 1, null)]); + await appendFile( + join(dataDir, "history.jsonl"), + '{"date":"2026-01-02","cast":{}}\n', + "utf-8", + ); + + const list = await runCli(dataDir, ["journal", "list"]); + expect(list.exitCode).toBe(0); + expect(list.stdout).toContain("2026-01-01"); + expect(list.stderr).toContain("note: 1 unreadable journal line(s) skipped"); + + const show = await runCli(dataDir, ["journal", "show", "latest"]); + expect(show.exitCode).toBe(0); + expect(show.stdout).toContain("2026-01-01"); + + const json = await runCli(dataDir, ["--json", "journal", "list"]); + expect(json.exitCode).toBe(0); + expect(JSON.parse(json.stdout)).toHaveLength(1); + }, 20_000); + + test("patterns digests the journal in plain text and resolved-name JSON", async () => { + await seedJournal(dataDir, [ + makeEntry("2026-02-01", 3, 8, "coin"), + makeEntry("2026-02-05", 3, 39, "yarrow"), + makeEntry("2026-02-10", 2, null, "coin"), + ]); + + const plain = await runCli(dataDir, ["journal", "patterns"]); + expect(plain.exitCode).toBe(0); + expect(plain.stdout).toContain("3 readings"); + expect(plain.stdout).toContain("Most seen:"); + expect(plain.stdout).toContain("屯"); // KW3, the twice-seen primary + expect(plain.stdout).toContain("Two modes (兩儀)"); + + const json = await runCli(dataDir, ["--json", "journal", "patterns"]); + expect(json.exitCode).toBe(0); + const p = JSON.parse(json.stdout); + expect(p.total).toBe(3); + // A basis note tells a script not to cross descriptive counts with the + // method-marked comparison block. + expect(p.basis).toContain("method-marked"); + // kw references resolve to name blocks for a caller without the data table. + expect(p.field.recent).toMatchObject({ kw: 2, n: "坤" }); // latest is 02-10 + // Descriptive count at the top; the chance comparison namespaced + same-basis. + expect(p.topHexagrams[0]).toMatchObject({ kw: 3, n: "屯", count: 2 }); + expect(p.topHexagrams[0]).not.toHaveProperty("expected"); // not adjacent to count + expect(p.topHexagrams[0].comparison).toMatchObject({ basis: "method-marked", count: 2 }); + // The same comparison discipline holds for moving-line surfaces: a + // descriptive count, the chance figure namespaced (never adjacent). + expect(p.movingLines[0]).not.toHaveProperty("expected"); + expect(p.movingLines[0]).toHaveProperty("comparison"); + expect(p.movingLineCounts[0]).not.toHaveProperty("expected"); + expect(p.movingLineCounts[0]).toHaveProperty("comparison"); + // trigrams follow the SAME discipline now (no longer an all-readings + // exception): descriptive count + a method-marked comparison block. + expect(p.topTrigrams[0]).not.toHaveProperty("expected"); + expect(p.topTrigrams[0]).not.toHaveProperty("lift"); + expect(p.topTrigrams[0]).toHaveProperty("count"); + expect(p.topTrigrams[0].comparison).toMatchObject({ basis: "method-marked" }); + // diversity: descriptive spread at top, the distinct/repeats comparison + // namespaced — distinctHexagrams (all) is never adjacent to a method-only + // expectedDistinctHexagrams. + expect(p.diversity).toHaveProperty("distinctHexagrams"); + expect(p.diversity).not.toHaveProperty("expectedDistinctHexagrams"); + expect(p.diversity.comparison).toMatchObject({ basis: "method-marked" }); + // baseline: descriptive method counts; every method-marked chance figure + // (per-hexagram expectation + old-line comparisons) namespaced together, so + // no loose method-marked scalar sits where field.counts could be divided by it. + expect(p.baseline).not.toHaveProperty("primaryExpectedPerHexagram"); + expect(p.baseline.comparison).toMatchObject({ basis: "method-marked" }); + expect(p.baseline.comparison).toHaveProperty("expectedPerHexagram"); + // Uniform comparison shape — count/expected/lift everywhere (the README's + // "use the block's own count" holds for baseline too), not observed/residual. + expect(p.baseline.comparison.oldYang).toHaveProperty("count"); + expect(p.baseline.comparison.oldYang).not.toHaveProperty("observed"); + expect(p.baseline.comparison.oldYang).not.toHaveProperty("residual"); + expect(p.lineBalance).toHaveProperty("yang"); + expect(p.lineBalance).toHaveProperty("yin"); + }, 20_000); + + test("JSON diversity comparison rests on the method-marked subset, not all readings", async () => { + // Contract-boundary lock for the basis discipline (GPT-Pro review: the + // observed side of a chance comparison must share the method-marked basis + // of its expectation, never cross an all-readings count with a method-only + // expectation). The core derivation is tested with mixed methods in + // journal-patterns.test.ts; this guards the json.ts MAPPING — that it routes + // the method-marked distinct into the comparison block, not the descriptive + // one. Earlier all-marked fixtures couldn't catch a swapped field because + // both counts coincide; here unknown-method readings make them diverge. + await seedJournal(dataDir, [ + // 8 method-marked readings, all hexagram 1 → method-marked distinct = 1. + ...Array.from({ length: 8 }, (_, i) => + makeEntry(`2026-02-${String(i + 1).padStart(2, "0")}`, 1, null, "coin"), + ), + // 4 unknown-method readings over NEW hexagrams → descriptive distinct += 4. + makeEntry("2026-02-20", 20, null), + makeEntry("2026-02-21", 21, null), + makeEntry("2026-02-22", 22, null), + makeEntry("2026-02-23", 23, null), + ]); + const json = await runCli(dataDir, ["--json", "journal", "patterns"]); + expect(json.exitCode).toBe(0); + const p = JSON.parse(json.stdout); + expect(p.total).toBe(12); + // Descriptive spread spans ALL readings: {1, 20, 21, 22, 23} = 5 distinct. + expect(p.diversity.distinctHexagrams).toBe(5); + // The comparison's OBSERVED distinct is the method-marked subset only (the + // 8 hexagram-1 casts) = 1 — never the descriptive 5. + expect(p.diversity.comparison.distinctHexagrams).toBe(1); + // …and its EXPECTED rests on the method-marked count n=8, not n=12: + // 64·(1−(63/64)^8) ≈ 7.56 (n=12 would give ≈11.0), so 7 < x < 8 proves the + // expectation used the known subset, sharing the observed side's basis. + expect(p.diversity.comparison.expectedDistinctHexagrams).toBeGreaterThan(7); + expect(p.diversity.comparison.expectedDistinctHexagrams).toBeLessThan(8); + }, 20_000); + + test("patterns plain text says '1 reading' (singular) for a lone entry", async () => { + await seedJournal(dataDir, [makeEntry("2026-02-01", 3, 8, "coin")]); + const plain = await runCli(dataDir, ["journal", "patterns"]); + expect(plain.exitCode).toBe(0); + expect(plain.stdout).toContain("1 reading ·"); // singular unit, not "1 readings" + expect(plain.stdout).not.toContain("1 readings"); + }, 20_000); + + test("patterns surfaces the 時 phase-of-day shape once enough readings are timed", async () => { + // makeEntry stamps every record with a real timestamp, so five readings + // clear the timestamped floor. Buckets are local (tz-dependent), so assert + // the tz-free facts: the line is present, names the honest population, and + // its four phase counts sum to that population. + await seedJournal(dataDir, [ + makeEntry("2026-02-01", 1, null), + makeEntry("2026-02-02", 2, null), + makeEntry("2026-02-03", 3, null), + makeEntry("2026-02-04", 4, null), + makeEntry("2026-02-05", 5, null), + ]); + + const plain = await runCli(dataDir, ["journal", "patterns"]); + expect(plain.exitCode).toBe(0); + expect(plain.stdout).toContain("Phase of day (over 5 timed):"); + + const json = await runCli(dataDir, ["--json", "journal", "patterns"]); + const p = JSON.parse(json.stdout); + expect(p.timeOfDay).not.toBeNull(); + expect(p.timeOfDay.timestamped).toBe(5); + const sum = p.timeOfDay.dawn + p.timeOfDay.midday + p.timeOfDay.dusk + p.timeOfDay.night; + expect(sum).toBe(5); + }, 20_000); + + test("patterns on an empty journal is a calm state, not an error", async () => { + await seedJournal(dataDir, []); + const plain = await runCli(dataDir, ["journal", "patterns"]); + expect(plain.exitCode).toBe(0); + expect(plain.stdout).toContain("No readings to observe yet"); + + const json = await runCli(dataDir, ["--json", "journal", "patterns"]); + expect(json.exitCode).toBe(0); + expect(JSON.parse(json.stdout).total).toBe(0); + }, 20_000); + + test("patterns rejects a malformed --since", async () => { + await seedJournal(dataDir, [makeEntry("2026-02-01", 1, null, "coin")]); + const { exitCode, stderr } = await runCli(dataDir, ["journal", "patterns", "--since", "nope"]); + expect(exitCode).toBe(1); + expect(stderr).toContain("Invalid --since"); + }, 20_000); +}); + +// Reflection notes — `journal note` appends a kind:"note" line; `journal show` +// prints notes beneath the reading; JSON stays additive. +describe("journal note command", () => { + let dataDir: string; + + beforeEach(async () => { + dataDir = await freshTempDir("iching-journal-note-test"); + }); + + afterEach(async () => { + await rm(dataDir, { recursive: true, force: true }); + }); + + test("note annotates the latest reading and show prints it beneath", async () => { + await seedJournal(dataDir, [ + makeEntry("2026-01-01", 1, null), + makeEntry("2026-01-02", 39, null), + ]); + + const noted = await runCli(dataDir, ["journal", "note", "it resolved itself"]); + expect(noted.exitCode).toBe(0); + expect(noted.stdout).toContain("Note added to 2026-01-02"); + expect(noted.stdout).toContain("蹇"); + + const show = await runCli(dataDir, ["journal", "show", "2026-01-02"]); + expect(show.exitCode).toBe(0); + expect(show.stdout).toContain("Notes:"); + expect(show.stdout).toContain("it resolved itself"); + + // The other reading stays unannotated + const other = await runCli(dataDir, ["journal", "show", "2026-01-01"]); + expect(other.stdout).not.toContain("Notes:"); + }, 20_000); + + test("note --date annotates a past day's reading", async () => { + await seedJournal(dataDir, [ + makeEntry("2026-01-01", 1, null), + makeEntry("2026-01-02", 2, null), + ]); + + const noted = await runCli(dataDir, [ + "journal", "note", "what happened after", "--date", "2026-01-01", + ]); + expect(noted.exitCode).toBe(0); + expect(noted.stdout).toContain("Note added to 2026-01-01"); + + const show = await runCli(dataDir, ["journal", "show", "2026-01-01"]); + expect(show.stdout).toContain("what happened after"); + }, 20_000); + + test("note --date annotates the day's LATEST cast, so show surfaces it (out-of-order)", async () => { + // Two readings on the same day, written out of chronological order: the + // 15:00 泰 is recorded FIRST, the 09:00 坤 LAST. `note --date` must annotate + // the same reading `journal show` displays — the latest by time-key (泰), + // not the last appended (坤) — or the note lands on a reading show never + // surfaces and is silently invisible. + const latest = { ...makeEntry("2026-01-01", 11, null), timestamp: "2026-01-01T15:00:00.000Z" }; + const earlier = { ...makeEntry("2026-01-01", 2, null), timestamp: "2026-01-01T09:00:00.000Z" }; + await seedJournal(dataDir, [latest, earlier]); + + const noted = await runCli(dataDir, [ + "journal", "note", "after the storm", "--date", "2026-01-01", + ]); + expect(noted.exitCode).toBe(0); + // The note attaches to 泰 (the 15:00 reading), not 坤 (the last appended). + expect(noted.stdout).toContain("泰"); + expect(noted.stdout).not.toContain("坤"); + + // …and `journal show` — which selects the same latest cast — surfaces it. + const show = await runCli(dataDir, ["journal", "show", "2026-01-01"]); + expect(show.stdout).toContain("泰"); + expect(show.stdout).toContain("Notes:"); + expect(show.stdout).toContain("after the storm"); + }, 20_000); + + test("note --date on a legacy multi-reading day writes a PRECISE ref the TUI re-resolves", async () => { + // Two TIMESTAMP-LESS readings on one day (legacy/import) share a date-key. + // `note --date` selects the comparator's pick (蹇, higher primary), but a + // bare-date ref resolves — in the TUI's loadEntriesWithNotes — to the day's + // LAST-appended cast (屯). The note would then surface on a DIFFERENT reading + // than the CLI reported. The precise content ref (entryNoteRef) re-finds 蹇. + const jian = { date: "2026-01-01", cast: makeCast(39, null) }; // 蹇 — comparator pick + const zhun = { date: "2026-01-01", cast: makeCast(3, null) }; // 屯 — appended last + await seedJournal(dataDir, [jian, zhun]); + + const noted = await runCli(dataDir, [ + "journal", "note", "the precise one", "--date", "2026-01-01", + ]); + expect(noted.exitCode).toBe(0); + expect(noted.stdout).toContain("蹇"); // reported against the comparator pick… + expect(noted.stdout).not.toContain("屯"); // …not the last-appended cast + + // The written ref is the precise content key, NOT the bare date — a bare date + // would re-resolve to the day's last cast (屯) in the TUI. + const raw = await readFile(join(dataDir, "notes.jsonl"), "utf-8"); + const note = JSON.parse(raw.trim().split("\n").pop()!); + expect(note.ref).toBe("2026-01-01#39.0."); // entryNoteRef(蹇): date#primary.becoming.changing + expect(note.ref).not.toBe("2026-01-01"); + + // End-to-end: the TUI's loader attaches the note to 蹇 (the reported reading), + // never to 屯 (the day's last cast a bare ref would have picked). + const { JsonlJournalStore, loadEntriesWithNotes } = await import("@iching/storage"); + const store = new JsonlJournalStore(join(dataDir, "history.jsonl")); + const annotated = await loadEntriesWithNotes(store); + const noteHolder = annotated.find((e) => e.notes.length > 0)!; + expect(noteHolder.cast.primary).toBe(39); // 蹇 carries the note, not 屯 (3) + expect(noteHolder.notes[0].text).toBe("the precise one"); + }, 20_000); + + test("note record on disk matches the schema shape", async () => { + await seedJournal(dataDir, [makeEntry("2026-01-01", 1, null)]); + await runCli(dataDir, ["journal", "note", "shape check"]); + + // Notes live in the notes.jsonl sidecar so pre-note binaries reading + // history.jsonl never meet a record shape they cannot parse. + const raw = await readFile(join(dataDir, "notes.jsonl"), "utf-8"); + const lines = raw.trim().split("\n"); + const note = JSON.parse(lines[lines.length - 1]); + expect(Object.keys(note).sort()).toEqual(["date", "kind", "ref", "text", "timestamp"]); + expect(note.kind).toBe("note"); + expect(note.ref).toBe("2026-01-01T09:00:00.000Z"); + expect(note.text).toBe("shape check"); + + // And history.jsonl still holds only plain readings (no kind records). + const history = await readFile(join(dataDir, "history.jsonl"), "utf-8"); + for (const line of history.trim().split("\n")) { + expect(JSON.parse(line).kind).toBeUndefined(); + } + }, 20_000); + + test("note errors calmly when there is nothing to annotate", async () => { + const empty = await runCli(dataDir, ["journal", "note", "into the void"]); + expect(empty.exitCode).toBe(1); + expect(empty.stderr).toContain("No reading found to annotate."); + + await seedJournal(dataDir, [makeEntry("2026-01-01", 1, null)]); + const missing = await runCli(dataDir, [ + "journal", "note", "wrong day", "--date", "2026-02-02", + ]); + expect(missing.exitCode).toBe(1); + expect(missing.stderr).toContain("No reading found for 2026-02-02"); + + const blank = await runCli(dataDir, ["journal", "note", " "]); + expect(blank.exitCode).toBe(1); + expect(blank.stderr).toContain("Note text is empty."); + }, 20_000); + + test("show and note --date reject a malformed date loudly, matching list/patterns", async () => { + // A format typo must read as the format error it is — not the misleading + // "No reading found", which implies the day genuinely holds no reading. The + // same YYYY-MM-DD guard list/patterns apply to --since/--until. + await seedJournal(dataDir, [makeEntry("2026-01-01", 1, null)]); + for (const bad of ["2026-1-1", "01/01/2026", "not-a-date"]) { + const show = await runCli(dataDir, ["journal", "show", bad]); + expect(show.exitCode).toBe(1); + expect(show.stderr).toContain(`Invalid date "${bad}"`); + expect(show.stderr).toContain("YYYY-MM-DD"); + expect(show.stderr).not.toContain("No reading found"); + + const note = await runCli(dataDir, ["journal", "note", "x", "--date", bad]); + expect(note.exitCode).toBe(1); + expect(note.stderr).toContain(`Invalid --date "${bad}"`); + expect(note.stderr).not.toContain("No reading found"); + } + + // The "today"/"latest" keywords are NOT dates and stay exempt from the guard. + const latest = await runCli(dataDir, ["journal", "show", "latest"]); + expect(latest.exitCode).toBe(0); + expect(latest.stdout).not.toContain("Invalid"); + }, 20_000); + + test("note errors calmly on a write failure, not a raw EISDIR/EROFS", async () => { + // History readable, but the notes sidecar can't be written (a directory at + // notes.jsonl; in the wild a read-only or full dir). The read commands + // already degrade calmly — the note WRITE must too, not a raw Node error. + await seedJournal(dataDir, [makeEntry("2026-01-01", 1, null)]); + await mkdir(join(dataDir, "notes.jsonl")); // block the note write + + const { exitCode, stderr } = await runCli(dataDir, ["journal", "note", "a reflection"]); + expect(exitCode).toBe(1); + expect(stderr).toMatch(/couldn't save your note/i); // calm, contextual… + expect(stderr).not.toMatch(/EISDIR|EROFS/); // …never the raw Node error + }, 20_000); + + test("show --json carries notes additively; list stays note-free", async () => { + await seedJournal(dataDir, [makeEntry("2026-01-01", 1, null)]); + await runCli(dataDir, ["journal", "note", "json check"]); + + const show = await runCli(dataDir, ["--json", "journal", "show", "latest"]); + expect(show.exitCode).toBe(0); + const entry = JSON.parse(show.stdout); + // Raw fields intact + expect(entry.date).toBe("2026-01-01"); + expect(entry.cast.primary).toBe(1); + // Additive notes array + expect(entry.notes).toHaveLength(1); + expect(entry.notes[0].text).toBe("json check"); + expect(entry.notes[0].ref).toBe("2026-01-01T09:00:00.000Z"); + expect(typeof entry.notes[0].date).toBe("string"); + expect(typeof entry.notes[0].timestamp).toBe("string"); + + // list JSON keeps its existing shape (no notes key) + const list = await runCli(dataDir, ["--json", "journal", "list"]); + const entries = JSON.parse(list.stdout); + expect(entries).toHaveLength(1); + expect("notes" in entries[0]).toBe(false); + }, 20_000); + + test("note --json reports the note and the annotated reading", async () => { + await seedJournal(dataDir, [makeEntry("2026-01-02", 39, null)]); + const noted = await runCli(dataDir, ["--json", "journal", "note", "machine readable"]); + expect(noted.exitCode).toBe(0); + const out = JSON.parse(noted.stdout); + expect(out.noted.text).toBe("machine readable"); + expect(out.noted.ref).toBe("2026-01-02T09:00:00.000Z"); + expect(out.reading.date).toBe("2026-01-02"); + expect(out.reading.primary.n).toBe("蹇"); + }, 20_000); + + // Control-injection regression: persisted note text is replayed raw to the + // terminal on every `journal show` — ESC/OSC bytes in a note would replay + // as live control sequences (window retitle, cursor moves). Stripped at + // BOTH ends: write time (new records clean at rest) and render time + // (legacy / hand-edited records replay safely too). + test("note strips terminal control sequences before persisting", async () => { + const ESC = String.fromCharCode(0x1b); + const BEL = String.fromCharCode(0x07); + await seedJournal(dataDir, [makeEntry("2026-01-01", 1, null)]); + + const noted = await runCli(dataDir, [ + "journal", "note", `${ESC}]0;pwned${BEL}genuine reflection`, + ]); + expect(noted.exitCode).toBe(0); + + const raw = await readFile(join(dataDir, "notes.jsonl"), "utf-8"); + const record = JSON.parse(raw.trim()); + // Control bytes never reach disk; the printable residue stays. + expect(record.text).toBe("]0;pwnedgenuine reflection"); + expect(record.text).not.toContain(ESC); + }, 20_000); + + test("show strips control bytes from legacy hand-edited note records", async () => { + const ESC = String.fromCharCode(0x1b); + const BEL = String.fromCharCode(0x07); + await seedJournal(dataDir, [makeEntry("2026-01-01", 1, null)]); + // A note written around the CLI (hand-edit / older binary): raw ESC at rest. + await appendFile( + join(dataDir, "notes.jsonl"), + JSON.stringify({ + kind: "note", + ref: "2026-01-01T09:00:00.000Z", + date: "2026-01-02", + timestamp: "2026-01-02T21:00:00.000Z", + text: `${ESC}]0;pwned${BEL}still readable`, + }) + "\n", + "utf-8", + ); + + const show = await runCli(dataDir, ["journal", "show", "2026-01-01"]); + expect(show.exitCode).toBe(0); + expect(show.stdout).toContain("still readable"); + expect(show.stdout).not.toContain(ESC); + expect(show.stdout).not.toContain(BEL); + }, 20_000); + + test("an all-control note is rejected as empty", async () => { + const ESC = String.fromCharCode(0x1b); + await seedJournal(dataDir, [makeEntry("2026-01-01", 1, null)]); + const noted = await runCli(dataDir, ["journal", "note", `${ESC}${ESC}[2J`]); + // "[2J" survives the strip — only pure-control text collapses to empty. + expect(noted.exitCode).toBe(0); + + const blank = await runCli(dataDir, ["journal", "note", `${ESC}`]); + expect(blank.exitCode).toBe(1); + expect(blank.stderr).toContain("Note text is empty."); + }, 20_000); + + test("readings written after a note still stream and show correctly", async () => { + await seedJournal(dataDir, [makeEntry("2026-01-01", 1, null)]); + await runCli(dataDir, ["journal", "note", "interleaved"]); + await appendFile( + join(dataDir, "history.jsonl"), + JSON.stringify(makeEntry("2026-01-03", 2, null)) + "\n", + "utf-8", + ); + + const list = await runCli(dataDir, ["journal", "list"]); + expect(list.exitCode).toBe(0); + expect(list.stdout).toContain("2026-01-01"); + expect(list.stdout).toContain("2026-01-03"); + expect(list.stdout).not.toContain("interleaved"); + + const latest = await runCli(dataDir, ["journal", "show", "latest"]); + expect(latest.stdout).toContain("2026-01-03"); + }, 20_000); +}); diff --git a/apps/cli/src/__tests__/main-mode-routing.test.ts b/apps/cli/src/__tests__/main-mode-routing.test.ts index 9c0f7d5f..f58b0f01 100644 --- a/apps/cli/src/__tests__/main-mode-routing.test.ts +++ b/apps/cli/src/__tests__/main-mode-routing.test.ts @@ -6,49 +6,25 @@ // because no unit test exercises the entrypoint. import { describe, test, expect, beforeEach, afterEach } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; - -const REPO_ROOT = resolve(import.meta.dir, "..", "..", "..", ".."); -const MAIN_TS = resolve(REPO_ROOT, "apps/cli/src/main.ts"); - -interface RunResult { - exitCode: number; - stdout: string; - stderr: string; -} +import { existsSync } from "node:fs"; +import { runCli as spawnCli, type RunResult } from "../testing.ts"; +import { rm } from "node:fs/promises"; +import { freshTempDir } from "../testing.ts"; +import { join } from "node:path"; let dataDir: string; beforeEach(async () => { - dataDir = await mkdtemp(join(tmpdir(), "iching-mode-routing-test-")); + dataDir = await freshTempDir("iching-mode-routing-test"); }); afterEach(async () => { await rm(dataDir, { recursive: true, force: true }); }); -async function runCli(args: string[]): Promise { - // Always pass --data-dir to an ephemeral temp dir so a regression that - // routes routing-mode args into hook/TUI mode can't write to the user's - // real ~/.local/state/iching journal or cache. - const proc = Bun.spawn(["bun", MAIN_TS, "--data-dir", dataDir, ...args], { - cwd: REPO_ROOT, - stdin: "pipe", - stdout: "pipe", - stderr: "pipe", - env: { ...process.env, NO_COLOR: "1" }, - }); - // Close stdin immediately so any reader (e.g. hook mode) sees EOF. - proc.stdin.end(); - const [stdout, stderr] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - ]); - const exitCode = await proc.exited; - return { exitCode, stdout, stderr }; -} +// Always route through an ephemeral --data-dir so a routing-mode regression +// can't write to the user's real ~/.local/state/iching journal or cache. +const runCli = (args: string[]): Promise => spawnCli(args, { dataDir }); describe("main.ts mode routing", () => { test("--help reaches Commander, not hook/TUI mode", async () => { @@ -86,4 +62,24 @@ describe("main.ts mode routing", () => { expect(exitCode).toBe(0); expect(stdout.trim()).toMatch(/^\d+\.\d+\.\d+/); }, 20_000); + + test("a garbage --seed exits 1 before any no-subcommand mode runs", async () => { + // Without the up-front guard, `iching --seed abc` fell through into + // hook/TUI mode: Number("abc") is NaN, NaN|0 collapses the seeded PRNG + // to a constant cast (always KW 2), and the result was persisted to the + // daily cache while skipping the journal. The seed must be refused + // loudly before either mode touches storage or the alt screen. + // ICHING_HOME pins ALL storage (hook mode ignores --data-dir) to the + // ephemeral temp dir so even a regression can't touch real user data. + const { exitCode, stdout, stderr } = await spawnCli(["--seed", "abc"], { + env: { ICHING_HOME: dataDir }, + }); + + expect(exitCode).toBe(1); + expect(stderr).toContain('Invalid --seed "abc"'); + expect(stdout).toBe(""); // no hook cast output + // …and nothing was persisted on the invalid path. + expect(existsSync(join(dataDir, "daily-cache.json"))).toBe(false); + expect(existsSync(join(dataDir, "history.jsonl"))).toBe(false); + }, 20_000); }); diff --git a/apps/cli/src/__tests__/parse-seed.test.ts b/apps/cli/src/__tests__/parse-seed.test.ts new file mode 100644 index 00000000..32f26c81 --- /dev/null +++ b/apps/cli/src/__tests__/parse-seed.test.ts @@ -0,0 +1,24 @@ +// parseSeed — the shared --seed guard. The invalid path (stderr + exit 1) is +// pinned by subprocess tests in cast.test.ts and main-mode-routing.test.ts; +// here we pin the valid-path parsing the TUI and cast command both rely on. + +import { describe, test, expect } from "bun:test"; +import { parseSeed } from "../util/parse-seed.ts"; + +describe("parseSeed (valid paths)", () => { + test("undefined stays undefined (no --seed given)", () => { + expect(parseSeed(undefined)).toBeUndefined(); + }); + + test("numeric strings parse to numbers", () => { + expect(parseSeed("42")).toBe(42); + expect(parseSeed("-7")).toBe(-7); + expect(parseSeed("3.5")).toBe(3.5); + }); + + test('"0" is a real seed, not a falsy skip', () => { + // The old inline TUI check (`opts.seed ? … : undefined`) silently + // dropped --seed 0 back to crypto entropy; the cast command kept it. + expect(parseSeed("0")).toBe(0); + }); +}); diff --git a/apps/cli/src/__tests__/paths-command.test.ts b/apps/cli/src/__tests__/paths-command.test.ts new file mode 100644 index 00000000..1aca48f4 --- /dev/null +++ b/apps/cli/src/__tests__/paths-command.test.ts @@ -0,0 +1,40 @@ +// Subprocess coverage for the `paths` command — it resolves and prints the +// data-file locations, honoring --data-dir and --json. Command-wiring was +// otherwise untested. +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { runCli } from "../testing.ts"; +import { rm } from "node:fs/promises"; +import { freshTempDir } from "../testing.ts"; +import { join } from "node:path"; + +let dataDir: string; +beforeEach(async () => { + dataDir = await freshTempDir("iching-paths-test"); +}); +afterEach(async () => { + await rm(dataDir, { recursive: true, force: true }); +}); + +describe("paths command", () => { + test("prints the resolved locations under the --data-dir override", async () => { + const { exitCode, stdout } = await runCli(["--data-dir", dataDir, "paths"], { env: { TZ: "UTC" } }); + expect(exitCode).toBe(0); + expect(stdout).toContain("Config:"); + expect(stdout).toContain("State:"); + expect(stdout).toContain("Cache:"); + // Each line resolves UNDER the override, proving --data-dir is honored. + for (const line of stdout.trim().split("\n")) { + expect(line).toContain(dataDir); + } + }, 20_000); + + test("--json emits the resolved paths as a parseable object under the override", async () => { + const { exitCode, stdout } = await runCli(["--data-dir", dataDir, "--json", "paths"], { env: { TZ: "UTC" } }); + expect(exitCode).toBe(0); + const paths = JSON.parse(stdout); + for (const key of ["config", "state", "cache"]) { + expect(typeof paths[key]).toBe("string"); + expect(paths[key]).toContain(dataDir); + } + }, 20_000); +}); diff --git a/apps/cli/src/__tests__/program-meta.test.ts b/apps/cli/src/__tests__/program-meta.test.ts new file mode 100644 index 00000000..15250aa7 --- /dev/null +++ b/apps/cli/src/__tests__/program-meta.test.ts @@ -0,0 +1,26 @@ +// Subprocess tests for program metadata — the `iching` identity surface. +// Regression: the program name derived from the workspace package name, so +// `--help` printed "Usage: @iching/cli", and apps/cli/package.json lagged at +// 0.2.0 so `--version` reported a stale release. + +import { describe, test, expect } from "bun:test"; +import cliPkg from "../../package.json" with { type: "json" }; +import rootPkg from "../../../../package.json" with { type: "json" }; +import { runCli } from "../testing.ts"; + +describe("program metadata", () => { + test("--help header says 'Usage: iching', never the workspace package name", async () => { + const { exitCode, stdout } = await runCli(["--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Usage: iching"); + expect(stdout).not.toContain("@iching/cli"); + }, 20_000); + + test("--version derives from apps/cli/package.json and tracks the root release", async () => { + const { exitCode, stdout } = await runCli(["--version"]); + expect(exitCode).toBe(0); + expect(stdout.trim()).toBe(cliPkg.version); + // Release versions move together — a root bump must not strand the CLI. + expect(cliPkg.version).toBe(rootPkg.version); + }, 20_000); +}); diff --git a/apps/cli/src/__tests__/reading-flow.test.ts b/apps/cli/src/__tests__/reading-flow.test.ts index 577e2502..70e95415 100644 --- a/apps/cli/src/__tests__/reading-flow.test.ts +++ b/apps/cli/src/__tests__/reading-flow.test.ts @@ -1,11 +1,12 @@ // Integration tests for the yarrow branch of the reading flow. import { describe, test, expect, beforeEach, afterEach } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { rm, writeFile } from "node:fs/promises"; +import { freshTempDir } from "../testing.ts"; import { join } from "node:path"; import type { Cast } from "@iching/core"; -import { resolvePaths, JsonDailyCacheStore } from "@iching/storage"; +import { castOf } from "@iching/core/testing"; +import { resolvePaths, JsonDailyCacheStore, JsonlJournalStore } from "@iching/storage"; import { IntentionScene, CastScene, @@ -24,7 +25,7 @@ function makeDeps(dataDir: string, run: RunImpl): ReadingFlowDeps { runRouter: async () => ({ shouldExit: false }), paths, cacheStore: new JsonDailyCacheStore(paths.cache), - today: "2026-05-20", + today: () => "2026-05-20", session: { cols: 80, rows: 24 }, glyphConfig: { glyphAnim: "dots", glyphFont: "kaiti" }, language: "zh-Hant", @@ -36,7 +37,7 @@ describe("runReadingFlow — yarrow source", () => { let dataDir: string; beforeEach(async () => { - dataDir = await mkdtemp(join(tmpdir(), "iching-yarrow-flow-")); + dataDir = await freshTempDir("iching-yarrow-flow"); }); afterEach(async () => { @@ -79,6 +80,120 @@ describe("runReadingFlow — yarrow source", () => { const cache = await deps.cacheStore.read(); expect(cache?.cast).toEqual(yarrowCast); expect(cache?.shown).toBe(true); + // Cast-method provenance is recorded at persist time + expect(cache?.method).toBe("yarrow"); + const journal = new JsonlJournalStore(deps.paths.state); + const entry = await journal.latest(); + expect(entry?.method).toBe("yarrow"); + // The journal and the daily cache record the SAME cast — `iching today` + // (cache) and the journal must agree on the day's reading. They share one + // `cast` at persist time; lock that they don't drift apart. + expect(entry?.cast).toEqual(yarrowCast); + expect(entry?.cast).toEqual(cache?.cast); + }); + + test("auto (coin) casts record method provenance too", async () => { + const run: RunImpl = async (scene) => { + if (scene instanceof IntentionScene) return { type: "intentionConfirmed" }; + if (scene instanceof CastScene) return { type: "home" }; + }; + + const deps = makeDeps(dataDir, run); + const result = await runReadingFlow(deps, { + purpose: "cast", + source: { type: "auto" }, + }); + + expect(result.shouldExit).toBe(false); + const cache = await deps.cacheStore.read(); + expect(cache?.method).toBe("coin"); + const journal = new JsonlJournalStore(deps.paths.state); + const entry = await journal.latest(); + expect(entry?.method).toBe("coin"); + }); + + test("crypto (default) casts record rng provenance as plain crypto", async () => { + const run: RunImpl = async (scene) => { + if (scene instanceof IntentionScene) return { type: "intentionConfirmed" }; + if (scene instanceof CastScene) return { type: "home" }; + }; + + const deps = makeDeps(dataDir, run); // no entropy key → crypto + await runReadingFlow(deps, { purpose: "cast", source: { type: "auto" } }); + + const cache = await deps.cacheStore.read(); + expect(cache?.rng).toEqual({ source: "crypto", intentionBound: false }); + const journal = new JsonlJournalStore(deps.paths.state); + const entry = await journal.latest(); + expect(entry?.rng).toEqual({ source: "crypto", intentionBound: false }); + }); + + test("bound entropy with an intention records intentionBound provenance", async () => { + const ctx = { cols: 80, rows: 24, done: false, colorSupport: "none" as const }; + const run: RunImpl = async (scene) => { + if (scene instanceof IntentionScene) { + for (const ch of "will it rain?") scene.handleKey({ type: "char", char: ch }, ctx); + return scene.handleKey({ type: "enter" }, ctx) ?? undefined; + } + if (scene instanceof CastScene) return { type: "home" }; + }; + + const deps = { ...makeDeps(dataDir, run), entropy: "bound" as const }; + await runReadingFlow(deps, { purpose: "cast", source: { type: "auto" } }); + + const cache = await deps.cacheStore.read(); + expect(cache?.rng).toEqual({ source: "bound", intentionBound: true }); + expect(cache?.intention).toBe("will it rain?"); + const journal = new JsonlJournalStore(deps.paths.state); + const entry = await journal.latest(); + expect(entry?.rng).toEqual({ source: "bound", intentionBound: true }); + }); + + test("bound entropy without an intention is bound to the moment only", async () => { + const ctx = { cols: 80, rows: 24, done: false, colorSupport: "none" as const }; + const run: RunImpl = async (scene) => { + if (scene instanceof IntentionScene) { + return scene.handleKey({ type: "enter" }, ctx) ?? undefined; // empty intention + } + if (scene instanceof CastScene) return { type: "home" }; + }; + + const deps = { ...makeDeps(dataDir, run), entropy: "bound" as const }; + await runReadingFlow(deps, { purpose: "cast", source: { type: "auto" } }); + + const cache = await deps.cacheStore.read(); + expect(cache?.rng).toEqual({ source: "bound", intentionBound: false }); + }); + + test("seeded auto casts record seed provenance in the cache (journal skipped)", async () => { + const run: RunImpl = async (scene) => { + if (scene instanceof IntentionScene) return { type: "intentionConfirmed" }; + if (scene instanceof CastScene) return { type: "home" }; + }; + + const deps = { ...makeDeps(dataDir, run), entropy: "bound" as const }; + await runReadingFlow(deps, { purpose: "cast", source: { type: "auto", seed: 42 } }); + + const cache = await deps.cacheStore.read(); + // --seed stays its own deterministic path — never reported as bound. + expect(cache?.rng).toEqual({ source: "seed", intentionBound: false }); + const journal = new JsonlJournalStore(deps.paths.state); + expect(await journal.latest()).toBeNull(); // seeded casts never reach the journal + }); + + test("replaying an existing reading reveals it without re-appending to the journal", async () => { + // A replay (source 'existing') shows a past cast but persists nothing — + // otherwise reopening a reading (from the journal, or `iching today`) would + // silently duplicate it. Guards the !isReplay half of the persist gate. + const run: RunImpl = async (scene) => { + if (scene instanceof CastScene) return { type: "home" }; + }; + const deps = makeDeps(dataDir, run); + const journal = new JsonlJournalStore(deps.paths.state); + expect(await journal.latest()).toBeNull(); // empty to start + const cast = castOf(1); // 乾, all young yang — a static cast + await runReadingFlow(deps, { purpose: "cast", source: { type: "existing", cast } }); + expect(await journal.latest()).toBeNull(); // the replay added nothing }); test("quitting the ritual early persists nothing and does not reveal", async () => { @@ -110,4 +225,39 @@ describe("runReadingFlow — yarrow source", () => { const cache = await deps.cacheStore.read(); expect(cache?.cast).toBeUndefined(); }); + + test("a read-only data dir does not crash the cast — it still reveals, with a warning", async () => { + // Persistence runs BEFORE the reveal. Unguarded, a throw here (read-only or + // full data dir) would lose the reading AND never show it — a crash at the + // moment of revelation. Best-effort: warn and reveal anyway. Block the + // journal path by making its parent a FILE, so mkdir(parent) → ENOTDIR. + const scenesRun: string[] = []; + const run: RunImpl = async (scene) => { + if (scene instanceof IntentionScene) return { type: "intentionConfirmed" }; + if (scene instanceof CastScene) { + scenesRun.push("cast"); + return { type: "home" }; + } + }; + const deps = makeDeps(dataDir, run); + const blocker = join(dataDir, "blocker"); + await writeFile(blocker, "x", "utf-8"); + deps.paths = { ...deps.paths, state: join(blocker, "nope", "history.jsonl") }; + + const errors: string[] = []; + const origErr = console.error; + console.error = (...a: unknown[]) => { + errors.push(a.map(String).join(" ")); + }; + let result; + try { + result = await runReadingFlow(deps, { purpose: "cast", source: { type: "auto" } }); + } finally { + console.error = origErr; + } + + expect(result.shouldExit).toBe(false); + expect(scenesRun).toContain("cast"); // the reading still revealed… + expect(errors.join("\n")).toMatch(/couldn't save this reading/i); // …with the calm warning + }); }); diff --git a/apps/cli/src/__tests__/rng-provenance.test.ts b/apps/cli/src/__tests__/rng-provenance.test.ts new file mode 100644 index 00000000..c11feed5 --- /dev/null +++ b/apps/cli/src/__tests__/rng-provenance.test.ts @@ -0,0 +1,49 @@ +// rngProvenanceFor — the shared seed/bound/crypto provenance rule recorded by +// both the CLI `cast` command and the interactive reading flow. The full input +// table, so a future drift in either surface (or the helper) is caught here. + +import { describe, test, expect } from "bun:test"; +import { rngProvenanceFor } from "../util/rng-provenance.js"; + +describe("rngProvenanceFor", () => { + test("an explicit seed is its own deterministic path — and wins over bound", () => { + expect(rngProvenanceFor({ seeded: true, bound: false })).toEqual({ + source: "seed", + intentionBound: false, + }); + expect(rngProvenanceFor({ seeded: true, bound: true, boundText: "x" })).toEqual({ + source: "seed", + intentionBound: false, + }); + }); + + test("bound is intentionBound only when it actually carried text", () => { + expect(rngProvenanceFor({ seeded: false, bound: true, boundText: "a question" })).toEqual({ + source: "bound", + intentionBound: true, + }); + expect(rngProvenanceFor({ seeded: false, bound: true, boundText: "" })).toEqual({ + source: "bound", + intentionBound: false, + }); + expect(rngProvenanceFor({ seeded: false, bound: true, boundText: undefined })).toEqual({ + source: "bound", + intentionBound: false, + }); + expect(rngProvenanceFor({ seeded: false, bound: true })).toEqual({ + source: "bound", + intentionBound: false, + }); + }); + + test("neither seed nor bound is plain crypto (text without bound is ignored)", () => { + expect(rngProvenanceFor({ seeded: false, bound: false })).toEqual({ + source: "crypto", + intentionBound: false, + }); + expect(rngProvenanceFor({ seeded: false, bound: false, boundText: "ignored" })).toEqual({ + source: "crypto", + intentionBound: false, + }); + }); +}); diff --git a/apps/cli/src/__tests__/scene-factories.test.ts b/apps/cli/src/__tests__/scene-factories.test.ts index 9f778a17..2f41006b 100644 --- a/apps/cli/src/__tests__/scene-factories.test.ts +++ b/apps/cli/src/__tests__/scene-factories.test.ts @@ -3,23 +3,48 @@ // factory that would crash at runtime once SceneRouter started passing typed // SceneSignal objects instead of dotted strings. -import { describe, test, expect, beforeEach } from "bun:test"; -import { mkdtemp } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { describe, test, expect, beforeEach, spyOn } from "bun:test"; +import { mkdir, writeFile } from "node:fs/promises"; +import { freshTempDir } from "../testing.ts"; import { join } from "node:path"; +import { castOf } from "@iching/core/testing"; +import type { ReflectionNote } from "@iching/core"; import { JsonlJournalStore } from "@iching/storage"; -import { BrowseScene, DetailScene, JournalScene } from "@iching/terminal"; import { + BrowseScene, + CastScene, + CellBuffer, + DetailScene, + JournalScene, + type JournalEntryView, + type SceneContext, + SceneRouter, +} from "@iching/terminal"; +import { + loadJournalEntries, makeBrowseFactory, + makeDetailScene, makeJournalFactory, + makeJournalScene, } from "../app/scene-factories.ts"; +/** Minimal static journal entry for replay/navigation tests. assembleCast + * derives a consistent primary/derived from the lines (hexagram 63, all young), + * so an entry round-tripped through the store passes isCastShaped. */ +function makeReplayEntry(date: string, timestamp: string): JournalEntryView { + return { + date, + timestamp, + cast: castOf(63), // 既濟, all young — a static cast + }; +} + describe("makeBrowseFactory", () => { let dir: string; let journal: JsonlJournalStore; beforeEach(async () => { - dir = await mkdtemp(join(tmpdir(), "browse-factory-test-")); + dir = await freshTempDir("browse-factory-test"); journal = new JsonlJournalStore(join(dir, "history.jsonl")); }); @@ -42,6 +67,121 @@ describe("makeBrowseFactory", () => { expect(factory({ type: "openJournal" })).toBeNull(); expect(factory({ type: "openSettings" })).toBeNull(); }); + + test("the King Wen walk scans the journal ONCE, not per ←/→ keystroke", async () => { + // ←/→ emits openDetail{replace} per keystroke → a fresh DetailScene each. + // Before the memo, every step re-streamed + re-parsed the whole history.jsonl + // (a MAJOR per-keypress cost). The factory now shares one scan across the walk. + await journal.append({ date: "2026-01-05", cast: castOf(1), timestamp: "2026-01-05T09:00:00.000Z" }); + await journal.append({ date: "2026-02-20", cast: castOf(1), timestamp: "2026-02-20T09:00:00.000Z" }); + await journal.append({ date: "2026-03-28", cast: castOf(1), timestamp: "2026-03-28T09:00:00.000Z" }); + await journal.append({ date: "2026-03-30", cast: castOf(2), timestamp: "2026-03-30T09:00:00.000Z" }); + + const streamSpy = spyOn(journal, "stream"); + const factory = makeBrowseFactory({ journal }); + const scene1 = factory({ type: "openDetail", kw: 1 }) as DetailScene; // step 1 + factory({ type: "openDetail", kw: 2 }); // → (walk forward) + factory({ type: "openDetail", kw: 1 }); // ← (walk back) + + for (let i = 0; i < 50 && scene1.getModel().castCount === 0; i++) { + await new Promise((r) => setTimeout(r, 10)); + } + // Hydration is still correct off the shared scan… + expect(scene1.getModel().castCount).toBe(3); + expect(scene1.getModel().lastCastDate).toBe("2026-03-28"); + // …and the three-step walk read the journal exactly once. + expect(streamSpy).toHaveBeenCalledTimes(1); + streamSpy.mockRestore(); + }); +}); + +describe("makeDetailScene — journal history hydration", () => { + const castOf = (primary: number) => ({ + lines: [1, 2, 3, 4, 5, 6].map(() => ({ + value: 7 as const, + isYang: true, + isChanging: false, + })), + primary, + becoming: null, + changingPositions: [], + nuclear: 1, + polarity: 2, + mirror: 1, + diagonal: 2, + }); + + test("hydrates a hexagram's cast count and last date from the journal", async () => { + // makeDetailScene fires loadHexagramHistory → setHistory so opening a + // hexagram ([g] from a reading, or the dictionary) shows "Cast N times + // (last …)". Only the crash path was pinned; this locks the happy path, + // so a broken wiring (wrong kw, dropped .then) can't silently blank it. + const dir = await freshTempDir("detail-history-test"); + const journal = new JsonlJournalStore(join(dir, "history.jsonl")); + // Three casts of hexagram 1 across different days, plus an unrelated cast. + await journal.append({ date: "2026-01-05", cast: castOf(1), timestamp: "2026-01-05T09:00:00.000Z" }); + await journal.append({ date: "2026-02-20", cast: castOf(1), timestamp: "2026-02-20T09:00:00.000Z" }); + await journal.append({ date: "2026-03-28", cast: castOf(1), timestamp: "2026-03-28T09:00:00.000Z" }); + await journal.append({ date: "2026-03-30", cast: castOf(2), timestamp: "2026-03-30T09:00:00.000Z" }); + + const scene = makeDetailScene(1, { journal }); + // Hydration is async; poll until setHistory lands. + for (let i = 0; i < 50 && scene.getModel().castCount === 0; i++) { + await new Promise((r) => setTimeout(r, 10)); + } + expect(scene.getModel().castCount).toBe(3); // counts hexagram 1 only, not the 卦 2 cast… + expect(scene.getModel().lastCastDate).toBe("2026-03-28"); // …and its most recent day + }); + + test("a corrupt journal line never escapes as an unhandled rejection", async () => { + const dir = await freshTempDir("detail-hydration-test"); + const path = join(dir, "history.jsonl"); + await writeFile(path, "this is not json\n", "utf-8"); + const journal = new JsonlJournalStore(path); + + // Without the .catch on the hydration promise this rejection would kill + // the process outside runScene's restore path (terminal left raw). + let unhandled: unknown = null; + const onUnhandled = (err: unknown) => { + unhandled = err; + }; + process.on("unhandledRejection", onUnhandled); + try { + const scene = makeDetailScene(1, { journal }); + expect(scene).toBeInstanceOf(DetailScene); + await new Promise((resolve) => setTimeout(resolve, 25)); + expect(unhandled).toBeNull(); + } finally { + process.off("unhandledRejection", onUnhandled); + } + }); +}); + +describe("loadJournalEntries — read-failure safety", () => { + test("opens empty and warns when the journal is unreadable, never crashes", async () => { + // A torn line is skipped per-line inside the stream; a whole-file READ + // failure (here a directory left at the history path → EISDIR; in the wild + // a root-owned file → EACCES) would otherwise throw straight through the + // TUI's openJournal and kill the session. It must open empty with a warning. + const dir = await freshTempDir("journal-readfail-test"); + const histPath = join(dir, "history.jsonl"); + await mkdir(histPath); // a DIRECTORY where the journal file should be + const unreadable = new JsonlJournalStore(histPath); + + const errors: string[] = []; + const origErr = console.error; + console.error = (...a: unknown[]) => { + errors.push(a.map(String).join(" ")); + }; + let entries: Awaited> | undefined; + try { + entries = await loadJournalEntries(unreadable); + } finally { + console.error = origErr; + } + expect(entries).toEqual([]); // opened empty, did not throw… + expect(errors.join("\n")).toMatch(/couldn't read your journal/i); // …warned honestly + }); }); describe("makeJournalFactory", () => { @@ -49,7 +189,7 @@ describe("makeJournalFactory", () => { let journal: JsonlJournalStore; beforeEach(async () => { - dir = await mkdtemp(join(tmpdir(), "journal-factory-test-")); + dir = await freshTempDir("journal-factory-test"); journal = new JsonlJournalStore(join(dir, "history.jsonl")); }); @@ -82,4 +222,300 @@ describe("makeJournalFactory", () => { const scene = factory({ type: "openJournal" }); expect(scene).toBeInstanceOf(JournalScene); }); + + test("home is not handled — it bubbles out for the home loop to dispatch", () => { + // The replayed CastScene now emits `back` for esc/q (exitSignal), so no + // scene inside the journal router emits `home`. Mapping it to a fresh + // journal list would trap a genuine home signal inside the router. + const factory = makeJournalFactory({ + journal, + entries: [], + session: { cols: 80, rows: 24 }, + }); + expect(factory({ type: "home" })).toBeNull(); + }); + + test("a replayed reading emits back on esc/q — the router pops to the list", () => { + const entries = [makeReplayEntry("2026-01-02", "2026-01-02T09:00:00.000Z")]; + const factory = makeJournalFactory({ + journal, + entries, + session: { cols: 80, rows: 24 }, + }); + const scene = factory({ type: "openJournalReading", entry: entries[0] }); + expect(scene).toBeInstanceOf(CastScene); + + const ctx = { cols: 80, rows: 24, colorSupport: "truecolor", done: false } as const; + // Esc from the replay pops the router stack (back), it does NOT emit + // home — home would unwind the whole journal router and lose the list. + expect((scene as CastScene).handleKey({ type: "escape" }, ctx)).toEqual({ type: "back" }); + expect((scene as CastScene).handleKey({ type: "char", char: "q" }, ctx)).toEqual({ + type: "back", + }); + }); + + test("journal → replay → esc lands on the SAME list (cursor intact) → esc → out", () => { + const entries = [ + makeReplayEntry("2026-01-01", "2026-01-01T09:00:00.000Z"), + makeReplayEntry("2026-01-02", "2026-01-02T09:00:00.000Z"), + makeReplayEntry("2026-01-03", "2026-01-03T09:00:00.000Z"), + ]; + const deps = { journal, entries, session: { cols: 80, rows: 24 } }; + const list = makeJournalScene(deps); + const factory = makeJournalFactory(deps); + const router = new SceneRouter(list, factory); + const ctx = { cols: 80, rows: 24, colorSupport: "truecolor", done: false } as const; + list.enter(ctx); + + // Move the cursor (newest first: index 1 = 2026-01-02) and open the replay. + list.handleKey({ type: "char", char: "j" }, ctx); + const open = list.handleKey({ type: "enter" }, ctx); + expect(open).toEqual({ + type: "openJournalReading", + entry: entries[1], // newest-first index 1 = 2026-01-02, by reference + }); + + // The router pushes the factory's replay scene… + const replay = factory(open!); + expect(replay).toBeInstanceOf(CastScene); + router.push(replay!); + + // …and esc pops back to the ORIGINAL list instance, cursor untouched. + expect(replay!.handleKey!({ type: "escape" }, ctx)).toEqual({ type: "back" }); + router.pop(); + expect(router.current()).toBe(list); + expect(list.handleKey({ type: "enter" }, ctx)).toEqual({ + type: "openJournalReading", + entry: entries[1], + }); + + // Esc on the list itself pops the router bottom — the home loop resumes. + expect(list.handleKey({ type: "escape" }, ctx)).toEqual({ type: "back" }); + }); + + test("a replayed reading opens settled, not re-animating the cast ritual", () => { + // The factory calls skipToComplete(false) so reopening a past reading shows + // it at rest at once — not redrawing line by line the ritual you already + // watched when you first cast it. Dropping that call still satisfies the + // scene-type and exit tests above, so pin the settled render directly: the + // resting footer ([enter] detail) shows, the cast animation's controls + // ([s] skip / [space] pause) do not. + const entry: JournalEntryView = { + date: "2026-01-02", + timestamp: "2026-01-02T09:00:00.000Z", + cast: { + lines: [1, 2, 3, 4, 5, 6].map((p) => + p === 1 + ? { value: 9, isYang: true, isChanging: true } + : { value: 7, isYang: true, isChanging: false }, + ), + primary: 1, + becoming: 44, + changingPositions: [1], + nuclear: 1, + polarity: 2, + mirror: 1, + diagonal: 2, + }, + }; + const factory = makeJournalFactory({ + journal, + entries: [entry], + session: { cols: 80, rows: 24 }, + }); + const scene = factory({ type: "openJournalReading", entry }) as CastScene; + const ctx = { cols: 80, rows: 24, colorSupport: "truecolor", done: false } as const; + const buf = CellBuffer.create(80, 24); + scene.render(buf, ctx); + const text = Array.from({ length: 24 }, (_, r) => + buf.getRow(r).map((c) => c.char).join(""), + ).join("\n"); + expect(text).toContain("detail"); // the settled reading's footer… + expect(text).not.toContain("[s] skip"); // …not the cast animation controls + expect(text).not.toContain("[space] pause"); + }); +}); + +describe("cast context (changedPositions) pass-through", () => { + let dir: string; + let journal: JsonlJournalStore; + + beforeEach(async () => { + dir = await freshTempDir("cast-context-factory-test"); + journal = new JsonlJournalStore(join(dir, "history.jsonl")); + }); + + test("openDetail with changedPositions marks the DetailScene model", () => { + const factory = makeBrowseFactory({ journal }); + const scene = factory({ type: "openDetail", kw: 21, changedPositions: [1, 4] }); + expect(scene).toBeInstanceOf(DetailScene); + expect((scene as DetailScene).getModel().changedPositions).toEqual([1, 4]); + }); + + test("openDetail without changedPositions yields an unmarked model", () => { + const factory = makeBrowseFactory({ journal }); + const scene = factory({ type: "openDetail", kw: 21 }); + expect((scene as DetailScene).getModel().changedPositions).toEqual([]); + }); +}); + +describe("makeJournalScene — reflection-note persistence wiring", () => { + let dir: string; + let journal: JsonlJournalStore; + + beforeEach(async () => { + dir = await freshTempDir("journal-scene-factory-test"); + journal = new JsonlJournalStore(join(dir, "history.jsonl")); + }); + + test("a note committed in the scene lands in the JSONL as kind:note", async () => { + await journal.append(makeReplayEntry("2026-01-02", "2026-01-02T09:00:00.000Z")); + const entries = await loadJournalEntries(journal); + const scene = makeJournalScene({ + journal, + entries, + session: { cols: 80, rows: 24 }, + }); + const ctx = { cols: 80, rows: 24, colorSupport: "truecolor", done: false } as const; + scene.enter(ctx); + + scene.handleKey({ type: "char", char: "n" }, ctx); + for (const ch of "noted at night") { + scene.handleKey({ type: "char", char: ch }, ctx); + } + scene.handleKey({ type: "enter" }, ctx); + + // The append is fire-and-forget; poll the store until it lands. + let texts: string[] = []; + for (let i = 0; i < 40 && texts.length === 0; i++) { + texts = []; + for await (const note of journal.streamNotes()) texts.push(note.text); + if (texts.length === 0) await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect(texts).toEqual(["noted at night"]); + + // And a reload sees the note attached to its reading. + const reloaded = await loadJournalEntries(journal); + expect(reloaded[0].notes.map((n) => n.text)).toEqual(["noted at night"]); + expect(reloaded[0].notes[0].ref).toBe("2026-01-02T09:00:00.000Z"); + }); + + test("the note commit is await-able — exit() flushes the append to disk", async () => { + // onNote returns the append promise, so the scene can be drained + // deterministically (no polling): scene teardown awaits in-flight + // writes before the process can move on. + const entries = [makeReplayEntry("2026-01-02", "2026-01-02T09:00:00.000Z")]; + const scene = makeJournalScene({ + journal, + entries, + session: { cols: 80, rows: 24 }, + }); + const ctx = { cols: 80, rows: 24, colorSupport: "truecolor", done: false } as const; + scene.enter(ctx); + + scene.handleKey({ type: "char", char: "n" }, ctx); + for (const ch of "kept through teardown") { + scene.handleKey({ type: "char", char: ch }, ctx); + } + scene.handleKey({ type: "enter" }, ctx); + await scene.exit(); + + const texts: string[] = []; + for await (const note of journal.streamNotes()) texts.push(note.text); + expect(texts).toEqual(["kept through teardown"]); + }); + + test("a note attaches to the reading under the cursor, not the top row", async () => { + // Three readings on distinct days. The scene sorts most-recent-first, so + // the cursor's top row is the NEWEST. A note must land on whatever reading + // the cursor actually sits on — here the OLDEST, two rows down — not + // silently on the first row. With one entry the wiring can't tell + // `filtered[cursor]` from `filtered[0]`; this seam needs ≥2 to pin it. + const days = [ + "2026-01-01T09:00:00.000Z", + "2026-01-02T09:00:00.000Z", + "2026-01-03T09:00:00.000Z", + ]; + for (const ts of days) { + await journal.append(makeReplayEntry(ts.slice(0, 10), ts)); + } + const entries = await loadJournalEntries(journal); + const scene = makeJournalScene({ + journal, + entries, + session: { cols: 80, rows: 24 }, + }); + const ctx = { cols: 80, rows: 24, colorSupport: "truecolor", done: false } as const; + scene.enter(ctx); + + // Walk the cursor down from the newest (top row) to the oldest reading. + scene.handleKey({ type: "arrow", direction: "down" }, ctx); + scene.handleKey({ type: "arrow", direction: "down" }, ctx); + + scene.handleKey({ type: "char", char: "n" }, ctx); + for (const ch of "for the oldest") { + scene.handleKey({ type: "char", char: ch }, ctx); + } + scene.handleKey({ type: "enter" }, ctx); + await scene.exit(); + + // The note's ref points at the oldest reading (the cursor target), not the + // newest one rendered on the top row. + const notes = []; + for await (const note of journal.streamNotes()) notes.push(note); + expect(notes.map((n) => n.text)).toEqual(["for the oldest"]); + expect(notes[0].ref).toBe("2026-01-01T09:00:00.000Z"); + expect(notes[0].ref).not.toBe("2026-01-03T09:00:00.000Z"); + + // And on reload the note hangs off the oldest reading alone. + const reloaded = await loadJournalEntries(journal); + const byTs = (ts: string) => reloaded.find((e) => e.timestamp === ts); + expect(byTs("2026-01-01T09:00:00.000Z")?.notes.map((n) => n.text)).toEqual([ + "for the oldest", + ]); + expect(byTs("2026-01-03T09:00:00.000Z")?.notes ?? []).toEqual([]); + }); +}); + +describe("makeJournalScene — reflection-note date follows the configured clock", () => { + test("a committed note is persisted with deps.today, not machine-local", async () => { + // The post-cast j → journal path (reading-flow) and Home → Journal (main) + // both thread a timezone-aware `today`; makeJournalScene must stamp the + // persisted note with it, or a note saved near a timezone boundary lands on + // the wrong local day and disagrees with the daily anchor. deps.today returns + // an obviously-non-machine date here. + let saved: ReflectionNote | undefined; + const journalStub = { + skippedLines: 0, + append: async () => {}, + appendNote: async (n: ReflectionNote) => { + saved = n; + }, + stream: async function* () {}, + streamNotes: async function* () {}, + latest: async () => null, + } as unknown as JsonlJournalStore; + + const scene = makeJournalScene({ + journal: journalStub, + entries: [makeReplayEntry("2026-03-01", "2026-03-01T08:00:00.000Z")], + session: { cols: 80, rows: 24 }, + today: () => "2099-12-31", + }); + const ctx = { + cols: 80, + rows: 24, + done: false, + colorSupport: "none", + language: "en", + } as unknown as SceneContext; + scene.enter(ctx); + scene.handleKey({ type: "char", char: "n" }, ctx); // open the note input + for (const ch of "noted") scene.handleKey({ type: "char", char: ch }, ctx); + scene.handleKey({ type: "enter" }, ctx); // commit + await scene.exit(); // drain the in-flight note append + + expect(saved?.date).toBe("2099-12-31"); // the configured today, not machine-local + expect(saved?.text).toBe("noted"); + }); }); diff --git a/apps/cli/src/__tests__/today-cache.test.ts b/apps/cli/src/__tests__/today-cache.test.ts new file mode 100644 index 00000000..97fc9994 --- /dev/null +++ b/apps/cli/src/__tests__/today-cache.test.ts @@ -0,0 +1,168 @@ +// resolveTodayReading — the shared "today's reading" resolver behind the home +// [t] reopen, `iching today`, and (conceptually) the hook. It re-reads the clock +// and stores at call time (the home loop blocks across midnight; deciding from +// pre-block snapshots replayed yesterday's reading as today's), and honors the +// durable-recovery invariant: the daily cache is a fast mirror, so when it is +// missing/stale/quarantined the journal — the durable record — is consulted. + +import { describe, test, expect } from "bun:test"; +import { buildStructure } from "@iching/core"; +import type { DailyCache, HistoryEntry } from "@iching/core"; +import type { DailyCacheStore, JournalStore } from "@iching/storage"; +import { castOf } from "@iching/core/testing"; +import { resolveTodayReading } from "../util/today-cache.ts"; + +function makeCache(date: string, intention?: string): DailyCache { + // Hexagram 63 (既濟), all young; castOf derives a consistent + // primary/becoming/derived so the cache passes isCastShaped on read. + const cast = castOf(63); + return { date, cast, shown: true, structure: buildStructure(cast), intention }; +} + +/** In-memory cache store — read() always reflects the current record. */ +function memoryStore(initial: DailyCache | null): DailyCacheStore & { record: DailyCache | null } { + return { + record: initial, + async read() { + return this.record; + }, + async write(record: DailyCache) { + this.record = record; + }, + }; +} + +function makeEntry(date: string, intention?: string): HistoryEntry { + return { date, cast: castOf(63), timestamp: `${date}T08:00:00.000Z`, intention, method: "coin" }; +} + +/** Journal stub whose latest() returns the given entry (the only method used). */ +function journalWith(latestEntry: HistoryEntry | null): JournalStore { + return { + skippedLines: 0, + async append() {}, + async appendNote() {}, + async *stream() {}, + async *streamNotes() {}, + async latest() { + return latestEntry; + }, + }; +} + +const noJournal = () => journalWith(null); + +describe("resolveTodayReading — cache resolution", () => { + test("returns the cached record when its date is (the current) today", async () => { + const store = memoryStore(makeCache("2026-06-10", "morning question")); + const result = await resolveTodayReading(store, noJournal(), () => "2026-06-10"); + expect(result?.date).toBe("2026-06-10"); + expect(result?.intention).toBe("morning question"); + }); + + test("returns null for a stale cache (yesterday's reading) with no journal recovery", async () => { + const store = memoryStore(makeCache("2026-06-09")); + expect(await resolveTodayReading(store, noJournal(), () => "2026-06-10")).toBeNull(); + }); + + test("returns null when neither cache nor journal exists", async () => { + expect(await resolveTodayReading(memoryStore(null), noJournal(), () => "2026-06-10")).toBeNull(); + }); + + test("re-reads the clock at call time — a midnight rollover invalidates the replay", async () => { + const store = memoryStore(makeCache("2026-06-09")); + let now = "2026-06-09"; + const today = () => now; + + // Before midnight the cached reading is replayable… + expect((await resolveTodayReading(store, noJournal(), today))?.date).toBe("2026-06-09"); + + // …after midnight the same call says no (the old code compared two + // pre-midnight snapshots and replayed yesterday's reading as today's). + now = "2026-06-10"; + expect(await resolveTodayReading(store, noJournal(), today)).toBeNull(); + }); + + test("re-reads the store at call time — a cache written mid-session is picked up", async () => { + const store = memoryStore(null); + const today = () => "2026-06-10"; + expect(await resolveTodayReading(store, noJournal(), today)).toBeNull(); + + // e.g. the Claude Code hook cast while the home scene sat open + await store.write(makeCache("2026-06-10", "written elsewhere")); + expect((await resolveTodayReading(store, noJournal(), today))?.intention).toBe("written elsewhere"); + }); +}); + +describe("resolveTodayReading — durable journal recovery", () => { + test("recovers today's reading from the journal when the cache is missing", async () => { + const result = await resolveTodayReading( + memoryStore(null), + journalWith(makeEntry("2026-06-10", "in the journal only")), + () => "2026-06-10", + ); + expect(result?.date).toBe("2026-06-10"); + expect(result?.intention).toBe("in the journal only"); + expect(result?.shown).toBe(true); // reconstructed as shown — it was cast today + expect(result?.structure).toBeDefined(); // structure rebuilt from the cast + }); + + test("recovers from the journal when the cache is stale (cache loss after a rollover)", async () => { + const result = await resolveTodayReading( + memoryStore(makeCache("2026-06-09")), // yesterday's cache lingers… + journalWith(makeEntry("2026-06-10")), // …but the journal holds today + () => "2026-06-10", + ); + expect(result?.date).toBe("2026-06-10"); + }); + + test("the cache wins when it holds today — the journal is not consulted", async () => { + const result = await resolveTodayReading( + memoryStore(makeCache("2026-06-10", "from cache")), + journalWith(makeEntry("2026-06-10", "from journal")), + () => "2026-06-10", + ); + expect(result?.intention).toBe("from cache"); + }); + + test("a journal whose latest is NOT today does not satisfy recovery", async () => { + const result = await resolveTodayReading( + memoryStore(null), + journalWith(makeEntry("2026-06-09")), // yesterday's reading is the latest + () => "2026-06-10", + ); + expect(result).toBeNull(); + }); + + test("recovery re-warms the cache so the next read hits it (no second journal scan)", async () => { + const cache = memoryStore(null); + let latestCalls = 0; + const journal: JournalStore = { + ...journalWith(makeEntry("2026-06-10", "from journal")), + async latest() { + latestCalls++; + return makeEntry("2026-06-10", "from journal"); + }, + }; + const r1 = await resolveTodayReading(cache, journal, () => "2026-06-10"); + expect(r1?.date).toBe("2026-06-10"); + expect(cache.record?.date).toBe("2026-06-10"); // cache healed from the journal… + expect(cache.record?.shown).toBe(true); + expect(latestCalls).toBe(1); + + // …so the next resolve is served from the now-warm cache, not a fresh scan. + const r2 = await resolveTodayReading(cache, journal, () => "2026-06-10"); + expect(r2?.intention).toBe("from journal"); + expect(latestCalls).toBe(1); // still 1 — the journal was not consulted again + }); + + test("a journal read failure degrades to null, never throws", async () => { + const throwing: JournalStore = { + ...journalWith(null), + async latest() { + throw new Error("blocked data dir"); + }, + }; + expect(await resolveTodayReading(memoryStore(null), throwing, () => "2026-06-10")).toBeNull(); + }); +}); diff --git a/apps/cli/src/__tests__/today-command.test.ts b/apps/cli/src/__tests__/today-command.test.ts new file mode 100644 index 00000000..b8f8aee8 --- /dev/null +++ b/apps/cli/src/__tests__/today-command.test.ts @@ -0,0 +1,196 @@ +// Subprocess tests for `iching today` — the one-shot daily anchor. Reads the +// daily cache: prints the full reading when today's cast exists, and a calm +// invitation (exit 0 — a state, not an error) when it doesn't. `--json` is the +// LLM/scripting integration surface: castToJson payload + date/intention/method. + +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { runCli } from "../testing.ts"; +import { rm, writeFile } from "node:fs/promises"; +import { freshTempDir, seedJournal, utcToday } from "../testing.ts"; +import { join } from "node:path"; +import { buildStructure } from "@iching/core"; +import type { DailyCache } from "@iching/core"; +import { castOf } from "@iching/core/testing"; + +/** + * The subprocess is pinned to TZ=UTC (via the env on each runCli call) so the CLI's localToday() agrees + * with this UTC formula — bun test itself defaults to UTC, but the machine's + * shell TZ would otherwise leak into the spawned CLI and skew the date near + * midnight boundaries. + */ +/** KW3 屯 with line 1 moving → becoming KW8 比 (water over earth). The lines + * draw 屯; assembleCast derives primary 3, becoming 8, and the four hexagrams + * consistently, so the record passes isCastShaped's reconstruct-and-compare. */ +function makeCast() { + return castOf(3, { changing: [1] }); // 屯 with line 1 moving → becoming 比 (8) +} + +function makeCache(date: string): DailyCache { + const cast = makeCast(); + return { + date, + cast, + shown: true, + structure: buildStructure(cast), + intention: "what needs patience?", + method: "yarrow", + }; +} + +async function seedCache(dataDir: string, cache: DailyCache): Promise { + await writeFile( + join(dataDir, "daily-cache.json"), + JSON.stringify(cache), + "utf-8", + ); +} + +/** A single today reading in history.jsonl — the durable record the cache mirrors. */ +function todayEntry(): Record { + return { + date: utcToday(), + cast: makeCast(), // 屯 with line 1 moving → 比 + timestamp: `${utcToday()}T09:00:00.000Z`, + method: "yarrow", + intention: "recovered from history", + }; +} + +describe("today command", () => { + let dataDir: string; + + beforeEach(async () => { + dataDir = await freshTempDir("iching-today-cmd-test"); + }); + + afterEach(async () => { + await rm(dataDir, { recursive: true, force: true }); + }); + + test("prints the full reading when today's cast exists", async () => { + await seedCache(dataDir, makeCache(utcToday())); + const { exitCode, stdout } = await runCli(["today"], { dataDir, env: { TZ: "UTC" } }); + expect(exitCode).toBe(0); + // Day context + expect(stdout).toContain(`Date: ${utcToday()}`); + expect(stdout).toContain("Intention: what needs patience?"); + expect(stdout).toContain("Method: yarrow stalks"); + // Primary + becoming + expect(stdout).toContain("屯"); + expect(stdout).toContain("Hexagram 3"); + expect(stdout).toContain("Becoming:"); + expect(stdout).toContain("比"); + // Judgment + the 啟蒙 reading the cast turns on. This cast moves exactly one + // line, so the reading is that line's 爻辭 under the rule-neutral header. + expect(stdout).toContain("Judgment (gc):"); + expect(stdout).toContain("Reading (啟蒙):"); + expect(stdout).toContain("磐桓"); // KW3 line 1 爻辭 + }, 20_000); + + test("--json emits the castToJson payload plus date/intention/method", async () => { + await seedCache(dataDir, makeCache(utcToday())); + const { exitCode, stdout } = await runCli(["--json", "today"], { dataDir, env: { TZ: "UTC" } }); + expect(exitCode).toBe(0); + const payload = JSON.parse(stdout); + expect(payload.date).toBe(utcToday()); + expect(payload.intention).toBe("what needs patience?"); + expect(payload.method).toBe("yarrow"); + expect(payload.primary.number).toBe(3); + expect(payload.primary.name).toBe("屯"); + expect(payload.primary.judgment.gc).toBeTruthy(); + expect(payload.becoming.number).toBe(8); + expect(payload.changingLines).toHaveLength(1); + expect(payload.changingLines[0].position).toBe(1); + expect(typeof payload.changingLines[0].yao).toBe("string"); + expect(typeof payload.changingLines[0].yaoEn).toBe("string"); + expect(payload.commentary.w).toBeTruthy(); + }, 20_000); + + test("no cache yet: calm invitation on stdout, exit 0", async () => { + const { exitCode, stdout, stderr } = await runCli(["today"], { dataDir, env: { TZ: "UTC" } }); + expect(exitCode).toBe(0); + expect(stdout).toContain("no reading yet today — run `iching` to cast"); + expect(stderr).toBe(""); + }, 20_000); + + test("stale cache (yesterday) counts as no reading today", async () => { + await seedCache(dataDir, makeCache("2001-01-01")); + const { exitCode, stdout } = await runCli(["today"], { dataDir, env: { TZ: "UTC" } }); + expect(exitCode).toBe(0); + expect(stdout).toContain("no reading yet today"); + expect(stdout).not.toContain("Hexagram 3"); + }, 20_000); + + test("--json with no cast: stable null payload, exit 0", async () => { + const { exitCode, stdout } = await runCli(["--json", "today"], { dataDir, env: { TZ: "UTC" } }); + expect(exitCode).toBe(0); + const payload = JSON.parse(stdout); + expect(payload.date).toBe(utcToday()); + expect(payload.primary).toBeNull(); + expect(payload.becoming).toBeNull(); + expect(payload.intention).toBeNull(); + expect(payload.method).toBeNull(); + // List-shaped keys are empty arrays so scripts can iterate either state. + expect(payload.changingPositions).toEqual([]); + expect(payload.changingLines).toEqual([]); + expect(payload.extra).toBeNull(); + expect(payload.derived).toBeNull(); + expect(payload.commentary).toBeNull(); + expect(payload.rng).toBeNull(); + }, 20_000); + + // Regression: the empty state used to expose only 6 of the 12 keys — + // consumers branching on key presence broke. Both states must expose the + // IDENTICAL key set; only the values differ. + test("--json key set is identical with and without a cast", async () => { + const empty = await runCli(["--json", "today"], { dataDir, env: { TZ: "UTC" } }); + expect(empty.exitCode).toBe(0); + const emptyKeys = Object.keys(JSON.parse(empty.stdout)).sort(); + + await seedCache(dataDir, makeCache(utcToday())); + const full = await runCli(["--json", "today"], { dataDir, env: { TZ: "UTC" } }); + expect(full.exitCode).toBe(0); + const fullKeys = Object.keys(JSON.parse(full.stdout)).sort(); + + expect(emptyKeys).toEqual(fullKeys); + }, 20_000); + + // Durable recovery: a successful journal append can outlive its cache write + // (cache never written, or quarantined as corrupt). The journal is the durable + // record — the hook recovers from it the same way — so `today` must surface + // today's reading from history.jsonl rather than claim "no reading yet". + test("recovers today's reading from the journal when the cache is missing", async () => { + await seedJournal(dataDir, [todayEntry()]); // no daily-cache.json written + const { exitCode, stdout } = await runCli(["today"], { dataDir, env: { TZ: "UTC" } }); + expect(exitCode).toBe(0); + expect(stdout).not.toContain("no reading yet today"); + expect(stdout).toContain(`Date: ${utcToday()}`); + expect(stdout).toContain("Intention: recovered from history"); + expect(stdout).toContain("屯"); + expect(stdout).toContain("Hexagram 3"); + expect(stdout).toContain("Becoming:"); + expect(stdout).toContain("比"); + expect(stdout).toContain("Reading (啟蒙):"); // structure rebuilt from the cast + }, 20_000); + + test("a stale cache does not hide today's journal reading", async () => { + await seedCache(dataDir, makeCache("2001-01-01")); // yesterday's cache lingers… + await seedJournal(dataDir, [todayEntry()]); // …but history holds today + const { exitCode, stdout } = await runCli(["today"], { dataDir, env: { TZ: "UTC" } }); + expect(exitCode).toBe(0); + expect(stdout).not.toContain("no reading yet today"); + expect(stdout).toContain("Hexagram 3"); + }, 20_000); + + test("--json recovers today's reading from the journal", async () => { + await seedJournal(dataDir, [todayEntry()]); + const { exitCode, stdout } = await runCli(["--json", "today"], { dataDir, env: { TZ: "UTC" } }); + expect(exitCode).toBe(0); + const payload = JSON.parse(stdout); + expect(payload.date).toBe(utcToday()); + expect(payload.primary.number).toBe(3); + expect(payload.becoming.number).toBe(8); + expect(payload.method).toBe("yarrow"); + expect(payload.intention).toBe("recovered from history"); + }, 20_000); +}); diff --git a/apps/cli/src/app/reading-flow.ts b/apps/cli/src/app/reading-flow.ts index 8293395b..34fa9e1d 100644 --- a/apps/cli/src/app/reading-flow.ts +++ b/apps/cli/src/app/reading-flow.ts @@ -7,11 +7,15 @@ // rejoin post-cast nav). import { + BoundRandomSource, buildStructure, castHexagram, type Cast, + type CastMethod, CryptoRandomSource, type DisplayLanguage, + type RandomSource, + type RngProvenance, SeededRandomSource, } from "@iching/core"; import { @@ -24,7 +28,6 @@ import { CastScene, type CastGlyphInput, IntentionScene, - JournalScene, type MotionPreset, type Scene, SceneRouter, @@ -38,8 +41,10 @@ import { makeBrowseFactory, makeDetailScene, makeJournalFactory, + makeJournalScene, type SessionDims, } from "./scene-factories.ts"; +import { rngProvenanceFor } from "../util/rng-provenance.js"; export type ReadingSource = | { type: "auto"; seed?: number } @@ -50,16 +55,39 @@ export type ReadingSource = export type ReadingPurpose = "cast" | "play" | "replay"; +// Cast-method provenance recorded at persist time — replays ("existing") +// never persist, so they carry no method. +const METHOD_BY_SOURCE: Record, CastMethod> = { + "auto": "coin", + "manual": "coin-manual", + "yarrow": "yarrow", + "yarrow-manual": "yarrow-manual", +}; + export interface ReadingFlowDeps { run: (scene: Scene) => Promise; runRouter: (router: SceneRouter) => Promise<{ shouldExit: boolean }>; paths: ResolvedPaths; cacheStore: JsonDailyCacheStore; - today: string; + /** + * Returns today's local date (YYYY-MM-DD). Called at persist time — not at + * flow start — so a reading that crosses midnight is stamped with the day + * it actually completed. + */ + today: () => string; + /** config.timezone — threaded to the journal scene's patterns binning. */ + timeZone?: string; session: SessionDims; glyphConfig: CastGlyphInput; language: DisplayLanguage; motion: MotionPreset; + /** + * Live entropy mode for machine-driven sources (auto coins, both yarrow + * rituals): "crypto" is plain local machine entropy; "bound" mixes the + * intention and moment into the seed as salt — chance stays primary. + * Defaults to "crypto". Deterministic seeds are their own path. + */ + entropy?: "crypto" | "bound"; } /** @@ -88,7 +116,13 @@ export async function runReadingFlow( intention = intentionScene.getIntention(); } - // 2. Obtain — branch on source; produces a Cast or returns early + // 2. Obtain — branch on source; produces a Cast or returns early. + // Live entropy is built per cast: a BoundRandomSource binds THIS intention + // and THIS moment into its seed, so it can never be hoisted or reused. + const liveSource = (): RandomSource => + deps.entropy === "bound" + ? new BoundRandomSource(intention ?? "") + : new CryptoRandomSource(); let cast: Cast; let usedSeed = false; if (opts.source.type === "manual") { @@ -98,13 +132,13 @@ export async function runReadingFlow( if (tossSignal?.type !== "tossCompleted") return { shouldExit: false }; // user quit before 6 lines cast = tossSignal.cast; } else if (opts.source.type === "yarrow") { - const yarrowScene = new YarrowScene(deps.motion, undefined, deps.language); + const yarrowScene = new YarrowScene(deps.motion, liveSource(), deps.language); const yarrowSignal = await deps.run(yarrowScene); if (yarrowSignal?.type === "exit") return { shouldExit: true }; if (yarrowSignal?.type !== "yarrowCompleted") return { shouldExit: false }; // user quit mid-ritual cast = yarrowSignal.cast; } else if (opts.source.type === "yarrow-manual") { - const yarrowManualScene = new YarrowManualScene(deps.motion, undefined, deps.language); + const yarrowManualScene = new YarrowManualScene(deps.motion, liveSource(), deps.language); const yarrowSignal = await deps.run(yarrowManualScene); if (yarrowSignal?.type === "exit") return { shouldExit: true }; if (yarrowSignal?.type !== "yarrowCompleted") return { shouldExit: false }; // user quit mid-ritual @@ -113,7 +147,7 @@ export async function runReadingFlow( usedSeed = opts.source.seed !== undefined; const source = usedSeed ? new SeededRandomSource(opts.source.seed!) - : new CryptoRandomSource(); + : liveSource(); cast = castHexagram(source); } else { cast = opts.source.cast; @@ -123,17 +157,50 @@ export async function runReadingFlow( if (!isPlay && !isReplay) { const structure = buildStructure(cast); const timestamp = new Date().toISOString(); - if (!usedSeed) { - const journal = new JsonlJournalStore(deps.paths.state); - await journal.append({ date: deps.today, cast, intention, timestamp }); + // One call so journal and cache agree even at a midnight boundary. + const date = deps.today(); + const method = + opts.source.type === "existing" ? undefined : METHOD_BY_SOURCE[opts.source.type]; + // Entropy provenance — the honest record of where the bytes came from. + // The manual coin toss draws its line values inside TossScene from its + // own CryptoRandomSource (keypresses only trigger the toss), so it is + // recorded as plain crypto regardless of the entropy setting. + // The manual coin toss is always plain crypto (TossScene draws its own bytes + // regardless of the entropy setting); the seed / bound / crypto rule is the + // shared one the CLI `cast` command also records. + const rng: RngProvenance = + opts.source.type === "manual" + ? { source: "crypto", intentionBound: false } + : rngProvenanceFor({ + seeded: usedSeed, + bound: deps.entropy === "bound", + boundText: intention, + }); + // Best-effort persist: a read-only or full data dir must not swallow the + // reading the user just cast. Persisting happens BEFORE the reveal, so an + // unguarded throw here would lose the reading AND never show it — crashing + // at the moment of revelation. Warn instead (deferred under the alt screen, + // flushed on exit) and still reveal; seeing the reading matters more than + // recording it. The same grace the settings save and reflection notes give. + try { + if (!usedSeed) { + const journal = new JsonlJournalStore(deps.paths.state); + await journal.append({ date, cast, intention, timestamp, method, rng }); + } + await deps.cacheStore.write({ + date, + cast, + shown: true, + structure, + intention, + method, + rng, + }); + } catch { + console.error( + "iching: couldn't save this reading (read-only or full data dir?); it's shown but not recorded.", + ); } - await deps.cacheStore.write({ - date: deps.today, - cast, - shown: true, - structure, - intention, - }); } // 4. Reveal. @@ -188,9 +255,14 @@ async function runPostCastNavigation( journal, entries, session: deps.session, + // Thread the configured clock so reflection-note dates stamped here match + // the daily anchor (not machine-local), and the 觀象 pane bins by the same + // zone — parity with the Home → Journal path in main.ts. + today: deps.today, + timeZone: deps.timeZone, }; const router = new SceneRouter( - new JournalScene(entries), + makeJournalScene(factoryDeps), makeJournalFactory(factoryDeps), ); return await deps.runRouter(router); @@ -203,7 +275,7 @@ async function runPostCastNavigation( journal, }; const startScene: Scene = signal.type === "openDetail" - ? makeDetailScene(signal.kw, factoryDeps) + ? makeDetailScene(signal.kw, factoryDeps, signal.changedPositions) : new BrowseScene(); const router = new SceneRouter(startScene, makeBrowseFactory(factoryDeps)); return await deps.runRouter(router); diff --git a/apps/cli/src/app/scene-factories.ts b/apps/cli/src/app/scene-factories.ts index b2f4f3ab..131840d3 100644 --- a/apps/cli/src/app/scene-factories.ts +++ b/apps/cli/src/app/scene-factories.ts @@ -1,19 +1,29 @@ // Scene-construction helpers shared by main.ts and reading-flow. // These centralize the wiring patterns that previously lived inline -// (DetailScene + getHexagramHistory hydration, plus the SceneRouter +// (DetailScene + loadHexagramHistory hydration, plus the SceneRouter // factories used by browse/journal navigation). -import type { DisplayLanguage, HistoryEntry } from "@iching/core"; -import { getHexagramHistory, type JsonlJournalStore } from "@iching/storage"; +import type { DisplayLanguage, ReflectionNote } from "@iching/core"; +import { + entryNoteRef, + loadHexagramHistory, + loadHexagramHistories, + loadEntriesWithNotes, + type AnnotatedEntry, + type HexagramHistory, + type JsonlJournalStore, +} from "@iching/storage"; import { BrowseScene, CastScene, type CastGlyphInput, DetailScene, JournalScene, + type JournalEntryView, type Scene, type SceneFactory, } from "@iching/terminal"; +import { localToday } from "../util/today.js"; export interface SessionDims { cols: number; @@ -28,35 +38,99 @@ export interface DetailDeps { } export interface JournalDeps extends DetailDeps { - entries: HistoryEntry[]; + entries: JournalEntryView[]; session: SessionDims; + /** Timezone-aware daily anchor (defaults to machine-local localToday). */ + today?: () => string; + /** config.timezone — the 時 phase binning projects timestamps into it. */ + timeZone?: string; } /** Construct DetailScene + kick off async history hydration. */ -export function makeDetailScene(kw: number, deps: DetailDeps): DetailScene { - const scene = new DetailScene(kw, deps.glyphConfig, deps.language); - getHexagramHistory(deps.journal, kw).then((h) => - scene.setHistory(h.castCount, h.lastCastDate), - ); +export function makeDetailScene( + kw: number, + deps: DetailDeps, + changedPositions?: number[], + histories?: Promise>, +): DetailScene { + const scene = new DetailScene(kw, deps.glyphConfig, deps.language, changedPositions); + // Resolve this hexagram's cast history. Walking the King Wen sequence (←/→) + // opens a fresh DetailScene per keystroke; when the factory passes a memoized + // one-scan map, each step reads in O(1) instead of re-streaming the whole + // journal. Standalone callers (no memo) fall back to a single-hexagram scan. + const load: Promise = histories + ? histories.then((m) => m.get(kw) ?? { castCount: 0, lastCastDate: null, dates: [] }) + : loadHexagramHistory(deps.journal, kw); + load + .then((h) => scene.setHistory(h.castCount, h.lastCastDate)) + .catch(() => { + // A corrupt journal must not surface as an unhandled rejection (which + // would kill the process outside runScene's restore path) — the detail + // scene simply renders without cast history. + }); return scene; } +/** Open detail scenes that share one memoized journal-history scan across the King + * Wen walk — without it, every ←/→ keystroke re-reads and re-parses history.jsonl. + * Each factory makes its own opener, so the memo is per dictionary/journal session. */ +function makeDetailOpener( + deps: DetailDeps, +): (kw: number, changedPositions?: number[]) => DetailScene { + let histories: Promise> | null = null; + return (kw, changedPositions) => { + if (!histories) histories = loadHexagramHistories(deps.journal); + return makeDetailScene(kw, deps, changedPositions, histories); + }; +} + /** SceneRouter factory for the dictionary path: handles openDetail, falls back through. */ export function makeBrowseFactory(deps: DetailDeps): SceneFactory { - return (signal): Scene | null => { - if (signal.type === "openDetail") return makeDetailScene(signal.kw, deps); - return null; - }; + const openDetail = makeDetailOpener(deps); + return (signal): Scene | null => + signal.type === "openDetail" + ? openDetail(signal.kw, signal.changedPositions) + : null; +} + +/** + * Construct the journal list scene with reflection-note persistence wired in. + * Committed notes are appended to the journal JSONL; the append promise is + * returned so the scene can track each note honestly (pending → saved, or a + * calm failed line when the bytes never land) and await in-flight writes on + * exit. The scene attaches its own settle handlers, so a write failure never + * crashes the scene loop or escapes as an unhandled rejection. + */ +export function makeJournalScene(deps: JournalDeps): JournalScene { + const today = deps.today ?? localToday; + return new JournalScene(deps.entries, { + today, + timeZone: deps.timeZone, + onNote: (entry, text) => { + const note: ReflectionNote = { + kind: "note", + // Precise pointer to THIS reading: its timestamp, or a content key for a + // legacy timestamp-less entry — so a note on one of several same-day + // legacy casts re-attaches to the one annotated, not the day's last. + ref: entryNoteRef(entry), + date: today(), + timestamp: new Date().toISOString(), + text, + }; + return deps.journal.appendNote(note); + }, + }); } /** SceneRouter factory for the journal path: handles openJournalReading, openDetail, openDictionary, openJournal. */ export function makeJournalFactory(deps: JournalDeps): SceneFactory { + const openDetail = makeDetailOpener(deps); return (signal): Scene | null => { if (signal.type === "openJournalReading") { - const entry = deps.entries.find( - (e) => e.timestamp === signal.key || e.date === signal.key, - ); - if (!entry) return new JournalScene(deps.entries); + // The selected entry rides the signal by reference — no date/timestamp + // lookup that could resolve the wrong reading when a day holds more than + // one (legacy readings without timestamps). + const entry = signal.entry; const cs = new CastScene( entry.cast, "reduced", @@ -64,26 +138,43 @@ export function makeJournalFactory(deps: JournalDeps): SceneFactory { deps.glyphConfig, deps.session.rows, entry.intention, - { language: deps.language }, + // exitSignal "back": esc/q from a replayed reading pop the router + // stack to the ORIGINAL journal list (cursor and search intact) + // instead of unwinding the whole router to Home. + { language: deps.language, exitSignal: "back" }, ); cs.skipToComplete(false); return cs; } - if (signal.type === "openDetail") return makeDetailScene(signal.kw, deps); + if (signal.type === "openDetail") { + return openDetail(signal.kw, signal.changedPositions); + } 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); + if (signal.type === "openJournal") return makeJournalScene(deps); + // No scene inside this router emits `home` anymore (the replayed + // CastScene's esc/q are `back` now) — anything else bubbles out so the + // router exits gracefully and the home loop dispatches it. return null; }; } -/** Drain the journal stream into an array of entries. */ +/** Drain the journal stream into an array of entries, notes attached. */ export async function loadJournalEntries( journal: JsonlJournalStore, -): Promise { - const entries: HistoryEntry[] = []; - for await (const entry of journal.stream()) { - entries.push(entry); +): Promise { + try { + return await loadEntriesWithNotes(journal); + } catch { + // A torn LINE is tolerated per-line inside the stream; a whole-file READ + // failure (the history is unreadable — owned by root after a sudo run, a + // directory left at the path, a failing mount) is different and would + // otherwise crash the whole TUI session the moment the journal opens. Warn + // honestly (deferred under the alt screen, flushed on exit) and open empty + // rather than die — the readings aren't lost, just unreadable right now. + console.error( + "iching: couldn't read your journal (permission denied?); it opened empty, but your readings are not lost.", + ); + return []; } - return entries; } diff --git a/apps/cli/src/commands/cast.ts b/apps/cli/src/commands/cast.ts index 2bb5e8e1..890944cc 100644 --- a/apps/cli/src/commands/cast.ts +++ b/apps/cli/src/commands/cast.ts @@ -1,32 +1,51 @@ import { Command } from "commander"; import { + BoundRandomSource, castHexagram, buildStructure, CryptoRandomSource, + type RandomSource, SeededRandomSource, GUA, } from "@iching/core"; -import { - resolvePaths, - JsonDailyCacheStore, - JsonlJournalStore, -} from "@iching/storage"; +import { JsonConfigStore } from "@iching/storage"; +import { resolvePathsFor } from "../util/paths.js"; import { formatCastPlain } from "../output/plain.js"; import { outputJson, castToJson } from "../output/json.js"; -import { localToday } from "../util/today.js"; +import { parseSeed } from "../util/parse-seed.js"; +import { rngProvenanceFor } from "../util/rng-provenance.js"; export function registerCastCommand(program: Command): void { program .command("cast") .description("Perform an I Ching casting") .argument("[question]", "question for the oracle") - .action(async (question: string | undefined) => { + .option("--bound", "bind the cast to the question and moment (local entropy)") + .action(async (question: string | undefined, cmdOpts: { bound?: boolean }) => { const opts = program.opts(); - const seed = opts.seed ? Number(opts.seed) : undefined; - const source = - seed !== undefined - ? new SeededRandomSource(seed) - : new CryptoRandomSource(); + // --seed validation (stderr + exit 1 on garbage) lives in the shared + // util/parse-seed.ts helper so the TUI entry refuses a bad seed the + // same way this command does. + const seed = parseSeed(opts.seed as string | undefined); + + // Entropy: an explicit --seed is its own deterministic path; otherwise + // --bound (or the saved entropy config) mixes the question and moment + // into local machine entropy — chance stays primary either way. + let bound = cmdOpts.bound === true; + if (!bound && seed === undefined) { + const paths = resolvePathsFor(opts.dataDir); + const cfg = await new JsonConfigStore(paths.config).load(); + bound = cfg.entropy === "bound"; + } + let source: RandomSource; + if (seed !== undefined) { + source = new SeededRandomSource(seed); + } else if (bound) { + source = new BoundRandomSource(question ?? ""); + } else { + source = new CryptoRandomSource(); + } + const rng = rngProvenanceFor({ seeded: seed !== undefined, bound, boundText: question }); const cast = castHexagram(source); const primary = GUA[cast.primary - 1]; @@ -39,9 +58,9 @@ export function registerCastCommand(program: Command): void { // Output if (opts.json) { - outputJson(castToJson(cast, primary, becoming, question)); + outputJson(castToJson(cast, primary, becoming, question, rng, seed)); } else { - console.log(formatCastPlain(cast, primary, structure, question)); + console.log(formatCastPlain(cast, primary, structure, question, rng, seed)); } }); } diff --git a/apps/cli/src/commands/config.ts b/apps/cli/src/commands/config.ts index 9cf9c933..28696d3b 100644 --- a/apps/cli/src/commands/config.ts +++ b/apps/cli/src/commands/config.ts @@ -1,5 +1,8 @@ import { Command } from "commander"; -import { resolvePaths, JsonConfigStore, canonicalLanguage } from "@iching/storage"; +import { die } from "../util/die.js"; +import { isOneOf } from "@iching/core"; +import { JsonConfigStore, canonicalLanguage } from "@iching/storage"; +import { resolvePathsFor } from "../util/paths.js"; import type { UserConfig } from "@iching/storage"; import { outputJson, configToJson } from "../output/json.js"; @@ -11,12 +14,6 @@ type ConfigEntry = { set: (cfg: UserConfig, value: string) => boolean; }; -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; @@ -27,6 +24,7 @@ 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 ENTROPY_VALUES = ["crypto", "bound"] as const; const CONFIG_SCHEMA: Record = { theme: { @@ -119,6 +117,15 @@ const CONFIG_SCHEMA: Record = { return true; }, }, + entropy: { + values: ENTROPY_VALUES, + description: "Entropy source (machine entropy, or bound to the intention and moment)", + set: (cfg, value) => { + if (!isOneOf(ENTROPY_VALUES, value)) return false; + cfg.entropy = value; + return true; + }, + }, }; const VALID_KEYS = Object.keys(CONFIG_SCHEMA) as Array; @@ -130,122 +137,137 @@ function isConfigKey(key: string): key is keyof typeof CONFIG_SCHEMA { return Object.hasOwn(CONFIG_SCHEMA, key); } +/** Narrow `key` to a config key, or die with the standard unknown-key message. + * `config get` and `config set` both validate this way before touching state. */ +function assertConfigKey(key: string): asserts key is keyof typeof CONFIG_SCHEMA { + if (!isConfigKey(key)) { + die(`Unknown key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`); + } +} + export function registerConfigCommand(program: Command): void { + // Shared action bodies — each subcommand AND the git-style positional + // shorthand (`config [value]`) route through these, so both surfaces + // stay behaviorally identical (validation, exit codes, --json shapes). + const runList = async (): Promise => { + const globalOpts = program.opts(); + const paths = resolvePathsFor(globalOpts.dataDir); + const store = new JsonConfigStore(paths.config); + const cfg = await store.load(); + + if (globalOpts.json) { + outputJson(cfg); + } else { + for (const key of VALID_KEYS) { + const value = cfg[key]; + const schema = CONFIG_SCHEMA[key]; + const valid = schema.values ? ` (${schema.values.join("|")})` : ""; + console.log(` ${key.padEnd(12)} = ${value}${valid}`); + } + } + }; + + const runGet = async (key: string): Promise => { + const globalOpts = program.opts(); + const paths = resolvePathsFor(globalOpts.dataDir); + const store = new JsonConfigStore(paths.config); + const cfg = await store.load(); + + assertConfigKey(key); + + const value = cfg[key]; + if (globalOpts.json) { + outputJson(configToJson(key, value)); + } else { + console.log(value); + } + }; + + const runSet = async (key: string, value: string): Promise => { + const globalOpts = program.opts(); + const paths = resolvePathsFor(globalOpts.dataDir); + const store = new JsonConfigStore(paths.config); + + // Validate BEFORE touching the store: loadOrSeed() persists a seeded + // config on first boot, and a rejected command must not leave that side + // effect (or freeze the locale seed) behind. + assertConfigKey(key); + + // Canonicalize the raw value (e.g. the 繁/简/EN labels the Settings UI + // shows for `language`), then validate against allowed values. + const schema = CONFIG_SCHEMA[key]; + const resolved = schema.normalize ? schema.normalize(value) : value; + if (schema.values && !schema.values.includes(resolved)) { + die(`Invalid value "${value}" for ${key}. Valid: ${schema.values.join(", ")}`); + } + + // `set` WRITES the config, so on first boot it must seed the display + // language — otherwise persisting the defaulted "en" would freeze the + // locale seed before the user ever launches the TUI. (get/list stay + // pure load() — they don't write.) + const cfg = await store.loadOrSeed(); + + if (!schema.set(cfg, resolved)) { + die(`Invalid value "${value}" for ${key}. Valid: ${schema.values?.join(", ") ?? "any string"}`); + } + try { + await store.save(cfg); + } catch { + // A write command must fail when it can't persist — but cleanly, not + // with a raw EACCES stack trace (cf. the TUI settings-save hardening). + die(`Couldn't write config to ${paths.config} (read-only or full data dir?).`); + } + + if (globalOpts.json) { + outputJson(configToJson(key, resolved)); + } else { + console.log(`${key} = ${resolved}`); + } + }; + + // Git-style shorthand: `config` lists, `config ` reads, `config + // ` writes. Subcommand names win — Commander dispatches list / get / + // set / path before this action sees the operands (no config key collides + // with a subcommand name). const config = program .command("config") - .description("Manage configuration"); + .description("Manage configuration") + .argument("[key]", "config key (shorthand for get; with a value, for set)") + .argument("[value]", "config value (shorthand for set)") + .action(async (key: string | undefined, value: string | undefined) => { + if (key === undefined) { + await runList(); + } else if (value === undefined) { + await runGet(key); + } else { + await runSet(key, value); + } + }); config .command("list") .description("Show all configuration values") - .action(async () => { - const globalOpts = program.opts(); - const paths = resolvePaths( - globalOpts.dataDir ? { dataDir: globalOpts.dataDir } : undefined, - ); - const store = new JsonConfigStore(paths.config); - const cfg = await store.load(); - - if (globalOpts.json) { - outputJson(cfg); - } else { - for (const key of VALID_KEYS) { - const value = cfg[key]; - const schema = CONFIG_SCHEMA[key]; - const valid = schema.values ? ` (${schema.values.join("|")})` : ""; - console.log(` ${key.padEnd(12)} = ${value}${valid}`); - } - } - }); + .action(runList); config .command("get") .description("Read a config value") .argument("", `config key (${VALID_KEYS.join(", ")})`) - .action(async (key: string) => { - const globalOpts = program.opts(); - const paths = resolvePaths( - globalOpts.dataDir ? { dataDir: globalOpts.dataDir } : undefined, - ); - const store = new JsonConfigStore(paths.config); - const cfg = await store.load(); - - if (!isConfigKey(key)) { - console.error(`Unknown key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`); - process.exit(1); - } - - const value = cfg[key]; - if (globalOpts.json) { - outputJson(configToJson(key, value)); - } else { - console.log(value); - } - }); + .action(runGet); config .command("set") .description("Write a config value") .argument("", "config key") .argument("", "config value") - .action(async (key: string, value: string) => { - const globalOpts = program.opts(); - const paths = resolvePaths( - globalOpts.dataDir ? { dataDir: globalOpts.dataDir } : undefined, - ); - const store = new JsonConfigStore(paths.config); - - // Validate BEFORE touching the store: loadOrSeed() persists a seeded - // config on first boot, and a rejected command must not leave that side - // effect (or freeze the locale seed) behind. - if (!isConfigKey(key)) { - console.error(`Unknown key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`); - process.exit(1); - } - - // Canonicalize the raw value (e.g. the 繁/简/EN labels the Settings UI - // shows for `language`), then validate against allowed values. - const schema = CONFIG_SCHEMA[key]; - const resolved = schema.normalize ? schema.normalize(value) : value; - if (schema.values && !schema.values.includes(resolved)) { - console.error(`Invalid value "${value}" for ${key}. Valid: ${schema.values.join(", ")}`); - process.exit(1); - } - - // `set` WRITES the config, so on first boot it must seed the display - // language — otherwise persisting the defaulted "en" would freeze the - // locale seed before the user ever launches the TUI. (get/list stay - // pure load() — they don't write.) - const cfg = await store.loadOrSeed(); - - if (!schema.set(cfg, resolved)) { - console.error(`Invalid value "${value}" for ${key}. Valid: ${schema.values?.join(", ") ?? "any string"}`); - process.exit(1); - } - try { - await store.save(cfg); - } catch { - // A write command must fail when it can't persist — but cleanly, not - // with a raw EACCES stack trace (cf. the TUI settings-save hardening). - console.error(`Couldn't write config to ${paths.config} (read-only or full data dir?).`); - process.exit(1); - } - - if (globalOpts.json) { - outputJson(configToJson(key, resolved)); - } else { - console.log(`${key} = ${resolved}`); - } - }); + .action(runSet); config .command("path") .description("Show config file location") .action(() => { const globalOpts = program.opts(); - const paths = resolvePaths( - globalOpts.dataDir ? { dataDir: globalOpts.dataDir } : undefined, - ); + const paths = resolvePathsFor(globalOpts.dataDir); if (globalOpts.json) { outputJson({ path: paths.config }); } else { diff --git a/apps/cli/src/commands/dict.ts b/apps/cli/src/commands/dict.ts index 3d58482d..c36c7457 100644 --- a/apps/cli/src/commands/dict.ts +++ b/apps/cli/src/commands/dict.ts @@ -1,13 +1,16 @@ // dict command — launch dictionary browse/detail scene import { Command } from "commander"; +import { die } from "../util/die.js"; +import { resolveHexagramQuery } from "./hexagram.js"; +import { formatError } from "../util/format-error.js"; export function registerDictCommand(program: Command): void { program .command("dict") .description("Browse the I Ching dictionary") - .argument("[n]", "hexagram number (1-64) to view directly") - .action(async (n?: string) => { + .argument("[query]", "hexagram number (1-64), name, pinyin, or search query") + .action(async (query?: string) => { const { BrowseScene, SceneRouter, @@ -31,24 +34,47 @@ export function registerDictCommand(program: Command): void { const journal = new JsonlJournalStore(paths.state); const factoryDeps = { journal, language: config.language }; - // Determine initial scene + // Determine initial scene. A number or unique name opens the detail + // directly; anything else opens browse with the search prefilled (a + // no-match query lands on the quiet empty hint, not an error). let initial; - if (n) { - const num = Number(n); - if (!Number.isInteger(num) || num < 1 || num > 64) { - console.error("Hexagram number must be an integer from 1 to 64."); - process.exit(1); + if (query) { + const resolution = resolveHexagramQuery(query); + if (resolution.kind === "invalid") { + die("Hexagram number must be an integer from 1 to 64."); } - initial = makeDetailScene(num, factoryDeps); + initial = + resolution.kind === "kw" + ? makeDetailScene(resolution.kw, factoryDeps) + : new BrowseScene(query); } else { initial = new BrowseScene(); } const session = new TerminalSession(); const router = new SceneRouter(initial, makeBrowseFactory(factoryDeps)); - // Thread config.language into the router so the BROWSE scene honors it too - // (not just detail via factoryDeps) — P1-b fix. - await router.run(session, new RealClock(), detectColorSupport(), false, config.language); + // Crash safety: a floating rejection or a callback throw that escapes the + // awaited router.run (a scene timer, an un-awaited promise) would kill the + // process with the terminal still in raw mode on the alt screen. Restore + // before dying — the same guard the home TUI holds (main.ts); this + // interactive session was the uneven twin that lacked it. + const onFatal = (err: unknown) => { + session.exit(); + die(formatError(err)); + }; + process.once("uncaughtException", onFatal); + process.once("unhandledRejection", onFatal); + // Hold one alt-screen session across the router run — scene hops repaint + // in place instead of flashing the user's shell (runScene is + // ownership-aware and leaves an outer-held session alone). + session.enter(); + try { + // Thread config.language into the router so the BROWSE scene honors it + // too (not just detail via factoryDeps) — P1-b fix. + await router.run(session, new RealClock(), detectColorSupport(), false, config.language); + } finally { + session.exit(); + } process.exit(0); }); } diff --git a/apps/cli/src/commands/doctor.ts b/apps/cli/src/commands/doctor.ts index a6e84006..f73e9a85 100644 --- a/apps/cli/src/commands/doctor.ts +++ b/apps/cli/src/commands/doctor.ts @@ -1,7 +1,9 @@ import { Command } from "commander"; import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; import { GUA, BINARY_TO_KW, TRIGRAMS } from "@iching/core"; -import { resolvePaths } from "@iching/storage"; +import { JsonlJournalStore, isCacheShaped } from "@iching/storage"; +import { resolvePathsFor } from "../util/paths.js"; import { outputJson } from "../output/json.js"; interface CheckResult { @@ -95,7 +97,7 @@ function checkTerminal(): CheckResult { } function checkPaths(dataDir?: string): CheckResult { - const paths = resolvePaths(dataDir ? { dataDir } : undefined); + const paths = resolvePathsFor(dataDir); const configExists = existsSync(paths.config); const stateExists = existsSync(paths.state); const cacheExists = existsSync(paths.cache); @@ -113,6 +115,104 @@ function checkPaths(dataDir?: string): CheckResult { }; } +async function checkJournal(dataDir?: string): Promise { + const paths = resolvePathsFor(dataDir); + if (!existsSync(paths.state)) { + return { + name: "Journal", + status: "pass", + detail: "no journal yet — cast in the TUI to begin", + }; + } + + // Stream the whole journal so torn/malformed lines surface as a count — + // path existence alone says nothing about whether the entries still read. + const journal = new JsonlJournalStore(paths.state); + let entryCount = 0; + try { + for await (const _entry of journal.stream()) { + entryCount++; + } + } catch { + // The journal exists but can't be read at all — a directory at the path, + // permission denied. The diagnostic must REPORT that as a failed check, not + // crash on the very read failure it exists to surface. (Torn LINES are a + // warn below; a whole-file failure is a fail.) + return { + name: "Journal", + status: "fail", + detail: "exists but can't be read (permission denied, or not a file?)", + }; + } + + const skipped = journal.skippedLines; + const counts = `${entryCount} reading(s) recorded`; + if (skipped > 0) { + // Damage is a warning, not a failure: the surrounding readings remain + // intact and every reader skips torn lines without crashing. + return { + name: "Journal", + status: "warn", + detail: `${counts}, ${skipped} unreadable line(s) skipped`, + }; + } + return { name: "Journal", status: "pass", detail: counts }; +} + +/** + * Validity check for a JSON data file (config, daily cache) — the diagnostic + * twin of checkJournal for the single-object stores. The journal is stream- + * validated; these were only existence-checked, so a corrupt config/cache read + * as "[exists]" (healthy) when it would actually reset on next use. NON-MUTATING + * on purpose: a raw read + JSON.parse, never the store's read() (which would + * quarantine/seed as a side effect — a diagnostic must inspect, not repair). + * An optional isShaped predicate catches the subtler corruption: a file that + * parses cleanly yet isn't a usable record, which the store would quarantine + * and reset. Passed for the cache; config is permissive and takes none. + */ +async function checkJsonFile( + name: string, + path: string, + isShaped?: (parsed: unknown) => boolean, +): Promise { + if (!existsSync(path)) { + return { name, status: "pass", detail: "not present yet — uses defaults" }; + } + let raw: string; + try { + raw = await readFile(path, "utf-8"); + } catch { + return { name, status: "fail", detail: "exists but can't be read (permission denied, or not a file?)" }; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + // Self-healing (the store quarantines and starts fresh), so a warning, not a + // failure — but a user running doctor to understand a reset deserves to see it. + return { name, status: "warn", detail: "corrupt JSON — resets to defaults on next use" }; + } + if (isShaped && !isShaped(parsed)) { + // Parseable but not a usable record — the store will quarantine and reset it + // on next use, exactly like corrupt bytes, so surface it the same calm way. + return { name, status: "warn", detail: "valid JSON but not a usable record — resets to defaults on next use" }; + } + return { name, status: "pass", detail: "valid" }; +} + +async function checkConfig(dataDir?: string): Promise { + const paths = resolvePathsFor(dataDir); + return checkJsonFile("Config", paths.config); +} + +async function checkCache(dataDir?: string): Promise { + const paths = resolvePathsFor(dataDir); + // Pass the store's own shape predicate: a parseable but non-record cache (e.g. + // just `{"date":…}`) would be quarantined and reset, so it is not "valid". + // Config takes none — its loader merges known keys onto defaults, never resets. + return checkJsonFile("Cache", paths.cache, isCacheShaped); +} + const STATUS_ICONS: Record = { pass: "OK", warn: "WARN", @@ -123,7 +223,7 @@ export function registerDoctorCommand(program: Command): void { program .command("doctor") .description("Verify environment and configuration") - .action(() => { + .action(async () => { const globalOpts = program.opts(); const checks: CheckResult[] = [ checkGlyphs(), @@ -131,10 +231,17 @@ export function registerDoctorCommand(program: Command): void { checkColor(), checkTerminal(), checkPaths(globalOpts.dataDir), + await checkJournal(globalOpts.dataDir), + await checkConfig(globalOpts.dataDir), + await checkCache(globalOpts.dataDir), ]; if (globalOpts.json) { outputJson(checks); + // Parity with the human path's exit(1): a script reading --json must be + // able to branch on the exit code, not re-derive failure from the + // payload. Set exitCode (not exit()) so the JSON flushes first. + if (checks.some((c) => c.status === "fail")) process.exitCode = 1; return; } diff --git a/apps/cli/src/commands/hexagram.ts b/apps/cli/src/commands/hexagram.ts index e25758db..312df211 100644 --- a/apps/cli/src/commands/hexagram.ts +++ b/apps/cli/src/commands/hexagram.ts @@ -1,33 +1,91 @@ import { Command } from "commander"; -import { GUA } from "@iching/core"; -import type { Style } from "@iching/core"; +import { die } from "../util/die.js"; +import { GUA, kwOf, searchHexagramsScored } from "@iching/core"; +import type { Hexagram, Style } from "@iching/core"; import { formatHexagramPlain } from "../output/plain.js"; -import { outputJson, hexagramToJson } from "../output/json.js"; +import { outputJson, hexagramToJson, hexagramSummary } from "../output/json.js"; const VALID_STYLES = ["dx", "tu", "en", "te", "w"]; +/** + * Resolution of a hexagram CLI argument. Shared by `hexagram` and `dict`: + * - "kw": a usable King Wen number (integer in range, or a unique search hit) + * - "matches": several hexagrams answer the query + * - "none": nothing answers + * - "invalid": an integer outside 1-64 (kept distinct so the classic range + * error still fires instead of a futile search) + */ +export type HexagramQueryResolution = + | { kind: "kw"; kw: number } + | { kind: "matches"; matches: Hexagram[] } + | { kind: "none" } + | { kind: "invalid" }; + +/** + * Resolve a hexagram argument: a King Wen number, or anything core + * searchHexagrams understands (Chinese name, pinyin, English name, + * trigram tokens / "X over Y" pairs). + */ +export function resolveHexagramQuery(arg: string): HexagramQueryResolution { + const trimmed = arg.trim(); + if (/^\d+$/.test(trimmed)) { + const num = Number(trimmed); + if (num < 1 || num > 64) return { kind: "invalid" }; + return { kind: "kw", kw: num }; + } + const scored = searchHexagramsScored(trimmed); + if (scored.length === 0) return { kind: "none" }; + // An exact (score-0) hit IS the answer. The single-trigram family scoring gives + // score 3 to every hexagram sharing a queried trigram, so the 8 hexagrams whose + // name equals a trigram token (乾/坤/坎/離/震/艮/巽/兌 — and their pinyin / English + // image word / symbol / image char) would otherwise return a shortlist instead + // of opening the hexagram. A unique score-0 match resolves straight to its KW; + // weaker family matches only matter when nothing answered exactly. + const exact = scored.filter((s) => s.score === 0); + if (exact.length === 1) return { kind: "kw", kw: exact[0].kw }; + if (scored.length === 1) return { kind: "kw", kw: scored[0].kw }; + return { kind: "matches", matches: scored.map((s) => s.gua) }; +} + export function registerHexagramCommand(program: Command): void { program .command("hexagram") - .description("Look up hexagram by King Wen number (1-64)") - .argument("", "hexagram number (1-64)") + .description("Look up hexagram by King Wen number, name, pinyin, or English name") + .argument("", "hexagram number (1-64), name, pinyin, or English name") .option("--style