diff --git a/.github/workflows/harness.yml b/.github/workflows/harness.yml new file mode 100644 index 000000000..94191ac4c --- /dev/null +++ b/.github/workflows/harness.yml @@ -0,0 +1,64 @@ +name: "Harness" + +# Render/drive the binary-editor and dialog-editor webview harnesses in headless Chromium as a regression +# suite (scripts/test-harness.sh). Each driver mounts the real webview bundle and asserts through PASS/FAIL +# gates - catching render/mount/CSP/layout regressions the node/jsdom vitest suites cannot see. +# +# Kept in its OWN job rather than the main "Build" gate so the Chromium download (~150MB) and the browser +# runs add no wall-clock to every other check. See the harness READMEs and docs/architecture.md. + +on: + pull_request: + push: + branches: + - "**" + +# Cancel superseded runs on the same ref (branch pushes and PR updates). +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + harness: + name: harness + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Install pnpm + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + run_install: false + + - name: Install Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # The dialog-editor edit drivers parse real .d through the weidu-d tree-sitter WASM (via the fake host), + # which is a gitignored build artifact. + - name: Build tree-sitter grammar + run: pnpm build:grammar + + # playwright is a pinned devDep, but pnpm's build-script gate skips its browser postinstall, so pull the + # one browser the harnesses launch here. --with-deps installs the OS libraries Chromium needs on the runner. + - name: Install Chromium for Playwright + run: pnpm exec playwright install --with-deps chromium + + # Several binary-editor drivers read real .itm/.cre/.map files from the gitignored-but-reproducible + # external/ trees (the dialog drivers and the deep-jump driver use committed testFixture/ and need none). + - name: Reset external fixture repos + run: ./scripts/reset-external.sh + + - name: Run render/drive harnesses + run: ./scripts/test-harness.sh diff --git a/.gitignore b/.gitignore index b21cbaa58..a9acfcf5b 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ bgforge-mls-kate*/ .reports/ .vscode-test/ .dev/ +.playwright-mcp/ client/node_modules client/out diff --git a/.oxfmtrc.json b/.oxfmtrc.json index d2283fd6a..9d29e61c5 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -32,6 +32,8 @@ "themes/vs-seti-icon-theme.json", "themes/bgforge-monokai.json", "themes/bgforge-icon-theme.json", - "binary-editor/test/harness/*.html" + "binary-editor/test/harness/*.html", + "client/src/dialog-editor/test/harness/app.html", + "client/src/dialog-editor/test/harness/real-model.ts" ] } diff --git a/.oxlintrc.json b/.oxlintrc.json index 612826754..b7d166615 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -266,11 +266,23 @@ // `document.getElementById` is clearer and faster than // `.querySelector('#id')`; the rule's premise doesn't hold. // Webview scripts are the only DOM consumers in the codebase. - "files": ["client/src/**/*-webview*.ts"], + "files": ["client/src/**/*-webview*.ts", "client/src/dialog-editor/webview/**"], "rules": { "unicorn/prefer-query-selector": "off" } }, + { + // Svelte 5 runes require `let`: `let x = $state(...)` and + // `let { ... } = $props()` are reassigned by the compiler's reactivity, + // which oxlint cannot see - it reports them as never-reassigned. `sort-vars` + // likewise fights the rune/prop declaration order. Scope: the dialog editor's + // Svelte webview components. + "files": ["client/src/dialog-editor/webview/**/*.svelte"], + "rules": { + "prefer-const": "off", + "sort-vars": "off" + } + }, { // VS Code's `webview.postMessage()` (extension side), // `acquireVsCodeApi().postMessage()` (webview side), and @@ -278,9 +290,9 @@ // `window.postMessage`; none take a `targetOrigin`. "files": [ "client/src/editors/**", - "client/src/dialog-tree/**", "client/src/webview-utils.ts", - "client/src/binary-editor/**" + "client/src/binary-editor/**", + "client/src/dialog-editor/**" ], "rules": { "unicorn/require-post-message-target-origin": "off" @@ -366,6 +378,7 @@ "**/out/**", "**/*.d.ts", "server/src/user-messages.ts", - "binary-editor/test/harness/**" + "binary-editor/test/harness/**", + "client/src/dialog-editor/test/harness/**" ] } diff --git a/AGENTS.md b/AGENTS.md index 968302c38..606e485fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ Dependabot version-update PRs are intentionally disabled - there is no `.github/ - **User-facing messages:** Never call `connection.window.showInformationMessage/showWarningMessage/showErrorMessage` directly in server code. Use `showInfo()`, `showWarning()`, `showError()`, or `showErrorWithActions()` from `user-messages.ts` - they auto-decode `file://` URIs to readable paths. An oxlint rule enforces this. - **Temporary artifacts:** Put transient test/build files under the repo-level `tmp/` directory (or `os.tmpdir()` when system temp is required). Do not create ad hoc temp directories under source trees like `server/test/`, `binary/test/`, or `scripts/**`. - **Webview CSP - styles need `cspSource`, not a bare nonce.** A webview's `style-src` must include `{{cspSource}}`. VS Code's wrapped webview silently drops a `style-src 'nonce-...'` stylesheet that lacks `cspSource` (raw Chromium honors it, so headless/standalone renders pass while the real panel renders fully unstyled). Load CSS as `webview.asWebviewUri()` `` elements with `style-src {{cspSource}}` (keep the nonce for `script-src` only); add each CSS dir to `localResourceRoots`. See `docs/architecture.md` (Webview CSP); guarded by `client/test/webview-csp.test.ts`. -- **Binary-editor webview changes can be rendered - don't fall back to a sketch without checking.** A headless Playwright harness in `binary-editor/test/harness/` loads the real `App.svelte` bundle in Chromium and writes per-format PNG screenshots (e.g. `render-pro-eff.mts` -> `shot-pro.png` + `shot-eff.png`, `render-itm.mts`, `render-spl.mts`, `render-cre.mts`, `render-map.mts`, `render-primitives.mts`). For any visual/CSS/layout change to the binary editor, render and inspect the screenshot rather than reasoning about the cascade blind. Run order: `cd binary && pnpm build` (only if `binary/src` changed), then `pnpm exec tsx binary-editor/test/harness/build.mts` (rebuilds `app.html` after any webview/Svelte/`styles.css` edit), then a driver `pnpm exec tsx binary-editor/test/harness/render-pro-eff.mts`. Prereqs (environment, not repo deps): Playwright + a Chromium browser on `PATH`. It is e2e-tier and intentionally excluded from `pnpm test`/`pnpm test:all`. See `binary-editor/test/harness/README.md` for the harness, and **`docs/binary-editor-ui-guidelines.md`** for the project's UI conventions and the review brief - it indexes the co-located writer-facing rules (`client/src/binary-editor/webview/AGENTS.md` for the render layer, `binary/src/AGENTS.md` for the layout schema), which auto-load when you edit those dirs. Review rendered screenshots against the guidelines. +- **Binary-editor webview changes can be rendered - don't fall back to a sketch without checking.** A headless Playwright harness in `binary-editor/test/harness/` loads the real `App.svelte` bundle in Chromium and writes per-format PNG screenshots (e.g. `render-pro-eff.mts` -> `shot-pro.png` + `shot-eff.png`, `render-itm.mts`, `render-spl.mts`, `render-cre.mts`, `render-map.mts`, `render-primitives.mts`). For any visual/CSS/layout change to the binary editor, render and inspect the screenshot rather than reasoning about the cascade blind. Run order: `cd binary && pnpm build` (only if `binary/src` changed), then `pnpm exec tsx binary-editor/test/harness/build.mts` (rebuilds `app.html` after any webview/Svelte/`styles.css` edit), then a driver `pnpm exec tsx binary-editor/test/harness/render-pro-eff.mts`. `playwright` is a pinned devDep; the Chromium browser it drives is the environment prereq (`pnpm exec playwright install chromium`). The drivers assert through PASS/FAIL gates and now run in CI as a regression suite via the separate `Harness` workflow (`scripts/test-harness.sh` - both editors' harnesses); they stay excluded from `pnpm test`/`pnpm test:all`, which run browserless. See `binary-editor/test/harness/README.md` for the harness, and **`docs/binary-editor-ui-guidelines.md`** for the project's UI conventions and the review brief - it indexes the co-located writer-facing rules (`client/src/binary-editor/webview/AGENTS.md` for the render layer, `binary/src/AGENTS.md` for the layout schema), which auto-load when you edit those dirs. Review rendered screenshots against the guidelines. - **To run the whole extension in a real VS Code instance, use `pnpm dev:web`.** It starts [code-server](https://github.com/coder/code-server) (VS Code in a browser) with this repo loaded as an unpacked extension, so the LSP server and binary custom editor actually run - the screenshot harness above only renders the webview in isolation. Default serves plain HTTP on `0.0.0.0:8080` (override with `CODE_SERVER_PORT`/`CODE_SERVER_HOST`); it bootstraps a pinned code-server into the gitignored `.dev/` on first run, then builds via `build:dev`. The command runs a long-lived server in the foreground - background it (or run it as a separate task) if you need the shell, bind to the port your environment exposes, and confirm it is up before reporting a URL. The binary editor is a webview, so the browser must reach it over a secure context (`http://localhost` via a port-forward, or a trusted cert) or the editor renders blank. Full details and configuration: **`scripts/dev-web.md`**. - **Use `pnpm` exclusively. Never use `npx`.** This is a hard requirement. Every time you reach for `npx`, `npm`, or any npm-series command, stop and use `pnpm exec` instead. Example: `pnpm exec playwright` not `npx playwright`. This applies to all contexts including one-off commands, scripts, subagents, and delegated tasks. If pnpm is not available in a context, install it first or use the workspace package.json scripts. - **Milestone close-out commands (scope to what you touched).** Binary changes -> `pnpm exec vitest run --config binary/vitest.config.ts` (run from the repo root, not `cd binary` - several tests resolve `external/` and `client/testFixture/` fixtures relative to cwd); webview/Svelte changes -> the client tests plus the binary-editor render harness. Reserve `pnpm build:all` + `pnpm test:all` (~9 min) for changes that span subsystems or touch shared build infra, grammars, transpilers, or the server. @@ -120,6 +120,8 @@ cd grammars/weidu-tp2 && pnpm test # or any grammars/*/ ``` +**Testing against real external files.** The `external/` mod trees are gitignored but REPRODUCIBLE - `pnpm test:external` (via `scripts/reset-external.sh` + `scripts/external-repos-lib.sh`) clones/checks them out at pinned refs. So real-corpus coverage belongs in a committed test, not a throwaway: tests that exercise real external files live under `server/test/integration/**` (run by `pnpm test:integration`, config `server/vitest.integration.config.ts`), using `test/integration/test-helpers.ts` (`FALLOUT_FIXTURES` = `external/fallout`, `IE_FIXTURES` = `external/infinity-engine`, `loadFixture`/`loadFixtures`). Gate a corpus sweep with `describe.skipIf(files.length === 0)` so it skips cleanly when the corpus is not checked out. Before placing any real-file test, read a sibling there (`integration/weidu-d.test.ts`, `fallout-ssl/rename.test.ts`) for the fixture/init conventions. Do NOT commit copies of gitignored `external/` files as fixtures, and do NOT hand-run a one-off script where this suite is the home. + ## Publishing & Release Releases are tag-driven via GitHub Actions. `docs/releasing.md` is the canonical reference: the tag scheme (which tag form triggers which workflow), and the per-stream release procedures for the extension, the three libraries, and the reusable Action - including the root/`server` version-identity invariant. The server and VSIX bundle their `@bgforge/*` libraries rather than depending on them at runtime, so the extension and the libraries release in any order. Consult it before cutting any release. See `docs/architecture.md` for packaging mechanics. @@ -132,6 +134,7 @@ Releases are tag-driven via GitHub Actions. `docs/releasing.md` is the canonical - `ts-morph` is pinned to `^27.0.2` across the server runtime and all transpiler subpackages so the bundled TypeScript matches the project's own `typescript ^5.9.3` pin. ts-morph bundles its TypeScript dependency rather than treating it as a peer, so the ts-morph version effectively chooses the TS compiler that runs against transpiler ASTs regardless of the workspace `typescript` pin. ts-morph 28 bundles TS 6.0 - TS 6 is on a 6.0.x line with no 6.1 published, fails the standard "matured target major" upgrade rule, and ts-morph 28 is therefore deferred until TS 6.1 ships. ts-morph 27.0.x is technically also on a 0-minor line, but the TS 5.9 alignment is the deciding factor. Bump in lockstep with the project's `typescript` pin: when `typescript` moves to 6.x, ts-morph moves to whichever ts-morph major bundles that TS line. - `@types/node` tracks the latest LTS Node major, currently `^24.x`. The extension targets LTS Node only (minimum supported is 20), so do not bump `@types/node` to odd-numbered "Current" majors (e.g. 25.x) - that would expose type definitions for APIs not present at the supported runtime floor. Move it forward only when a new even-numbered Node release reaches LTS. - `ini` (runtime dep of `@bgforge/format`) is held at `^6.x`; `7.0.0` is a major with potential parse/stringify behavior changes that need a changelog review before adoption. +- `playwright` (devDep) is pinned to an EXACT version (no caret) because the webview harnesses launch a browser from Playwright's version-keyed cache: the devbox and the `Harness` CI job download that browser via `playwright install`, and a caret drift to a version whose browser revision is not cached would break the harness run until a re-download. Bump the pin and re-run `playwright install` together. Its browser postinstall is skipped by pnpm's build-script gate (not in `onlyBuiltDependencies`), so a plain `pnpm install` never pulls ~150MB of browser - the harness paths install Chromium explicitly. - `sslc-emscripten-noderawfs` (the built-in SSL compiler WASM, `server/package.json`) is an HTTPS GitHub-release tarball, and its `pnpm-lock.yaml` entry carries a hand-maintained `integrity` field. pnpm 11.4+ fails closed (`ERR_PNPM_MISSING_TARBALL_INTEGRITY`) on any tarball lockfile entry that lacks integrity, and URL-tarball resolvers only learn the hash on download - so when the package is reused from the store, the field is never emitted. Practical rule: **evolve the lockfile incrementally** (`pnpm install` / `pnpm update`); pnpm 11.5.0+ preserves the existing integrity across those, so `pnpm update` keeps working. Do **not** `rm pnpm-lock.yaml` to regenerate from scratch - that drops the field and breaks `pnpm update` until it is re-added. The release asset is immutable, so the hash is stable; if a full regen is ever unavoidable, restore the line `resolution: {integrity: sha512-oQEOCRLmCodC1MlcIVAqGyTb3aWLpJ7c9tQm4CF8hrf9feZoxcIvSfwKpkcq0FykigqVGdpFpkbwQBS8yqo28Q==, tarball: }`. The same dep is also listed under `minimumReleaseAgeExclude` in `pnpm-workspace.yaml` (a non-registry tarball has no publish timestamp for the 24h maturity window to check). ## Architecture diff --git a/binary-editor/test/harness/README.md b/binary-editor/test/harness/README.md index 7d45f8dd5..c38f31120 100644 --- a/binary-editor/test/harness/README.md +++ b/binary-editor/test/harness/README.md @@ -74,17 +74,14 @@ It is type-checked via `test/harness/tsconfig.json`, which includes the DOM lib ## Prerequisites -- **Playwright + a Chromium browser** must be available on `PATH` in whatever environment runs the harness. - Install Playwright and its browsers globally: +- **Playwright** is a pinned devDependency (`pnpm install` provides it), so `pnpm exec tsx ` resolves + `import { chromium } from "playwright"` with no global install. Its browser postinstall is skipped by pnpm's + build-script gate, so install the one browser the drivers launch: ``` - npm install -g playwright - playwright install chromium + pnpm exec playwright install chromium ``` - Playwright is intentionally NOT listed in any `package.json` in this repo. It is an environment - prerequisite, not a repo dependency. - - **Node 20+** (matched to the project's minimum supported runtime). - **`tsx`** - available via the repo's dev dependencies (`pnpm exec tsx ...`). diff --git a/binary-editor/test/harness/out-dir.ts b/binary-editor/test/harness/out-dir.ts new file mode 100644 index 000000000..985de2b56 --- /dev/null +++ b/binary-editor/test/harness/out-dir.ts @@ -0,0 +1,14 @@ +import { mkdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +// Repo-level tmp/ for harness screenshots, keeping the source tree clean (project convention). +// This module lives in binary-editor/test/harness/, so the repo root is three levels up. +const here = path.dirname(fileURLToPath(import.meta.url)); +export const SHOT_DIR = path.resolve(here, "../../../tmp"); +mkdirSync(SHOT_DIR, { recursive: true }); + +/** Absolute path for a harness screenshot, under the repo tmp/ dir. */ +export function shotPath(name: string): string { + return path.join(SHOT_DIR, name); +} diff --git a/binary-editor/test/harness/render-clip-sweep.mts b/binary-editor/test/harness/render-clip-sweep.mts index decc075e9..ed2fc03bb 100644 --- a/binary-editor/test/harness/render-clip-sweep.mts +++ b/binary-editor/test/harness/render-clip-sweep.mts @@ -99,7 +99,6 @@ async function runFormat(browser: Browser, label: string, uri: string, bytes: Ui }); await page.goto("file://" + path.join(here, "app.html")); await page.waitForSelector(".layout-root", { timeout: 5000 }); - await page.waitForTimeout(200); const found: ClipViolation[] = []; // Check whatever a freshly-opened detail/list selection renders too: select the first list row if one is @@ -109,7 +108,11 @@ async function runFormat(browser: Browser, label: string, uri: string, bytes: Ui const firstRow = page.locator(".master .vlist .vrow").first(); if (await firstRow.count()) { await firstRow.click().catch(() => undefined); - await page.waitForTimeout(150); + await page + .waitForFunction(() => document.querySelector(".detail .layout-root .field") !== null, undefined, { + timeout: 5000, + }) + .catch(() => undefined); found.push(...(await collectClipViolations(page, ctx + " (detail)"))); } }; @@ -123,7 +126,18 @@ async function runFormat(browser: Browser, label: string, uri: string, bytes: Ui const tab = tabs.nth(i); const name = ((await tab.textContent()) ?? `tab${i}`).replace(/\s*\(\d.*\)\s*$/, "").trim(); await tab.click(); - await page.waitForTimeout(200); + await page + .waitForFunction( + (expectedLabel) => { + const active = document.querySelector( + '.bb-tabs.primary button[role="tab"][aria-selected="true"]', + ); + return !!active && (active.textContent ?? "").includes(expectedLabel); + }, + name, + { timeout: 5000 }, + ) + .catch(() => undefined); await sweepCurrentView(`${label} > ${name}`); } } diff --git a/binary-editor/test/harness/render-cre-v1.mts b/binary-editor/test/harness/render-cre-v1.mts index 62d14c1e5..23e8e1d02 100644 --- a/binary-editor/test/harness/render-cre-v1.mts +++ b/binary-editor/test/harness/render-cre-v1.mts @@ -17,6 +17,7 @@ import { fileURLToPath } from "node:url"; import { dispatch } from "../../src/index"; import type { HostToWebview, WebviewToHost } from "../../../client/src/binary-editor/webview/messages"; import { installCspGate } from "./csp-gate"; +import { shotPath } from "./out-dir"; import { creParser } from "../../../binary/src/cre/index"; import { getCreCanonicalDocument, rebuildCreCanonicalDocument } from "../../../binary/src/cre/canonical-reader"; import { serializeCreCanonicalDocument } from "../../../binary/src/cre/canonical-writer"; @@ -98,17 +99,19 @@ await page.exposeFunction("__hostUp", async (m: WebviewToHost) => { }); await page.goto("file://" + path.join(here, "app.html")); await page.waitForSelector(".layout-root .bb-tabs", { timeout: 5000 }); -await page.waitForTimeout(200); async function clickTab(label: string): Promise { await page.locator('.bb-tabs.primary button[role="tab"]').filter({ hasText: label }).first().click(); - await page.waitForTimeout(200); + await page + .locator('.bb-tabs.primary button[role="tab"][aria-selected="true"]') + .filter({ hasText: label }) + .first() + .waitFor({ timeout: 5000 }); } const effectsPanel = page.locator(".panel").filter({ has: page.locator("h3", { hasText: /^Effects$/ }) }); async function selectRow(scope: Locator, idx: number): Promise { await scope.locator(".vlist .vrow").nth(idx).click(); await scope.locator(".row-actions").first().waitFor({ timeout: 3000 }); - await page.waitForTimeout(100); } await clickTab("Effects"); @@ -142,7 +145,7 @@ check( const opcodeCombobox = await effectsPanel.locator(".detail .bb-combobox-input").count(); check("v1: opcode detail field is a searchable combobox", opcodeCombobox >= 1, `count=${opcodeCombobox}`); -await page.screenshot({ path: path.join(here, "shot-cre-effects-v1.png"), fullPage: true }); +await page.screenshot({ path: shotPath("shot-cre-effects-v1.png"), fullPage: true }); await browser.close(); diff --git a/binary-editor/test/harness/render-cre.mts b/binary-editor/test/harness/render-cre.mts index 8f415654b..f86b69cbc 100644 --- a/binary-editor/test/harness/render-cre.mts +++ b/binary-editor/test/harness/render-cre.mts @@ -28,6 +28,7 @@ import { fileURLToPath } from "node:url"; import { dispatch } from "../../src/index"; import type { HostToWebview, WebviewToHost } from "../../../client/src/binary-editor/webview/messages"; import { installCspGate } from "./csp-gate"; +import { shotPath } from "./out-dir"; import { creParser } from "../../../binary/src/cre/index"; const here = path.dirname(fileURLToPath(import.meta.url)); @@ -100,6 +101,8 @@ async function doUndo(): Promise { const r = dispatch({ type: "undo", sessionId }); if (r.type === "structure") postToWebview({ type: "changeSet", changeSet: r.result.changeSet }); else postToWebview({ type: "invalidated" }); + // The refresh touches several independent DOM regions (fields, tab badges, lists) with no single + // DOM-observable completion signal generic across every call site - bounded settle, not a condition poll. await activePage?.waitForTimeout(150); } @@ -119,10 +122,10 @@ await page.exposeFunction("__hostUp", async (m: WebviewToHost) => { }); await page.goto("file://" + path.join(here, "app.html")); await page.waitForSelector(".layout-root .bb-tabs", { timeout: 5000 }); -await page.waitForTimeout(200); +await page.waitForSelector(".layout-root .panel h3", { timeout: 5000 }); // CRE is tabbed; capture the default (General) tab immediately so the screenshot exists regardless of // the later structure-op steps (which navigate into the Spells / Effects tabs). -await page.screenshot({ path: path.join(here, "shot-cre.png"), fullPage: true }); +await page.screenshot({ path: shotPath("shot-cre.png"), fullPage: true }); // ---- Dropdown width guard (binary-editor UI guidelines: dropdowns are sized to their OWN longest option on a // dedicated dd-{1..5} ch scale, decoupled from the text-input tiers - so a dropdown sharing a column with a @@ -140,7 +143,9 @@ await page.screenshot({ path: path.join(here, "shot-cre.png"), fullPage: true }) alignClass, ); await alignFc.locator(".bb-combobox-input").click(); // focusing the input opens the list (chevron is decorative) - await page.waitForTimeout(150); + await page + .waitForFunction(() => document.querySelectorAll(".bb-popup-item").length > 0, undefined, { timeout: 5000 }) + .catch(() => undefined); // The OPEN list must render real items: a visible row height (a collapsed ~6px row clips the label) and a // non-empty label on every option, with an item highlighted (on open it is the current value's row; after a // filter bits-ui highlights the first match - covered by the primitives probe). @@ -159,7 +164,16 @@ await page.screenshot({ path: path.join(here, "shot-cre.png"), fullPage: true }) JSON.stringify(listInfo), ); await page.locator(".bb-popup-item", { hasText: "Chaotic neutral" }).first().click(); - await page.waitForTimeout(150); + await page + .waitForFunction( + () => { + const el = document.querySelector('.bb-combobox-input[aria-label="Alignment"]'); + return !!el && (el as HTMLInputElement).value.includes("Chaotic neutral"); + }, + undefined, + { timeout: 5000 }, + ) + .catch(() => undefined); const alignClip = await alignFc .locator(".bb-combobox-input") .evaluate((el: HTMLInputElement) => ({ text: el.value, clipped: el.scrollWidth > el.clientWidth + 1 })); @@ -171,9 +185,21 @@ await page.screenshot({ path: path.join(here, "shot-cre.png"), fullPage: true }) // Re-picking the CURRENT value must keep it and close: bits-ui's single-select toggles the selection OFF on a // re-pick (value -> ""), which would blank an enum and leave the list open. "Chaotic neutral" is selected now. await alignFc.locator(".bb-combobox-input").click(); - await page.waitForTimeout(120); await page.locator(".bb-popup-item", { hasText: "Chaotic neutral" }).first().click(); - await page.waitForTimeout(120); + await page + .waitForFunction( + () => { + const el = document.querySelector('.bb-combobox-input[aria-label="Alignment"]'); + return ( + !!el && + (el as HTMLInputElement).value.includes("Chaotic neutral") && + document.querySelectorAll(".bb-combobox-content").length === 0 + ); + }, + undefined, + { timeout: 5000 }, + ) + .catch(() => undefined); const rePick = await alignFc.locator(".bb-combobox-input").evaluate((el: HTMLInputElement) => el.value); const rePickOpen = await page.locator(".bb-combobox-content").count(); check( @@ -185,7 +211,11 @@ await page.screenshot({ path: path.join(here, "shot-cre.png"), fullPage: true }) async function clickTab(label: string): Promise { await page.locator('.bb-tabs.primary button[role="tab"]').filter({ hasText: label }).first().click(); - await page.waitForTimeout(200); + await page + .locator('.bb-tabs.primary button[role="tab"][aria-selected="true"]') + .filter({ hasText: label }) + .first() + .waitFor({ timeout: 5000 }); } const effectsPanel = page.locator(".panel").filter({ has: page.locator("h3", { hasText: /^Effects$/ }) }); @@ -193,11 +223,26 @@ const effectsPanel = page.locator(".panel").filter({ has: page.locator("h3", { h async function selectRow(scope: Locator, idx: number): Promise { await scope.locator(".vlist .vrow").nth(idx).click(); await scope.locator(".row-actions").first().waitFor({ timeout: 3000 }); - await page.waitForTimeout(100); } +// Effects RowActions (Add above/Duplicate/Delete) drive a real structureOp round trip (webview -> host -> dispatch +// -> changeSet reply), so the row count is the settle signal - callers always check it via a Node-side +// sectionCount() that only reflects the mutation once the reply has landed. clickAction/clickDelete are only ever +// called against the Effects panel in this file, so the row list is queried directly rather than threaded through. async function clickAction(scope: Locator, ariaLabel: string): Promise { + const before = await scope.locator(".vlist .vrow").count(); await scope.locator(`.row-actions button[aria-label="${ariaLabel}"]`).first().click(); - await page.waitForTimeout(200); + await page + .waitForFunction( + (b) => { + const panel = Array.from(document.querySelectorAll(".panel")).find( + (p) => (p.querySelector("h3")?.textContent ?? "").trim() === "Effects", + ); + return !!panel && panel.querySelectorAll(".vlist .vrow").length !== b; + }, + before, + { timeout: 5000 }, + ) + .catch(() => undefined); } async function clickDelete(scope: Locator): Promise { // Delete fires immediately (no confirm step) - a single click on the Delete button removes the entry. @@ -357,7 +402,7 @@ const memorizedTotal = (): number => { // ============================================================ await clickTab("Spells"); await page.waitForSelector(".spellbook", { timeout: 3000 }); -await page.screenshot({ path: path.join(here, "shot-cre-spells.png"), fullPage: true }); +await page.screenshot({ path: shotPath("shot-cre-spells.png"), fullPage: true }); const spellbookTypeTabs = await page.locator(".spellbook .bb-tabs button[role='tab']").allInnerTexts(); check( "spells: spellbook renders a spell-type subtab (Wizard for the mage fixture)", @@ -375,7 +420,17 @@ const readSpellsTab = () => }); const spellsTabBefore = await readSpellsTab(); await firstLevelCard.locator("button.sb-add", { hasText: "memorize" }).first().click(); -await page.waitForTimeout(250); +await page + .waitForFunction( + (before) => { + const tabs = Array.from(document.querySelectorAll('.bb-tabs.primary button[role="tab"]')); + const tab = tabs.find((b) => (b.textContent ?? "").trim().startsWith("Spells")); + return !!tab && (tab.textContent ?? "").trim() !== before; + }, + spellsTabBefore, + { timeout: 5000 }, + ) + .catch(() => undefined); const spellsTabAfter = await readSpellsTab(); check( "spells: + memorize adds a memorized spell (count +1)", @@ -403,13 +458,11 @@ check( // ---- Spellbook card layout: cards must be a uniform width (no flex-grow drift where a trailing odd card on a // partial row hits max-width and is wider), and a level's Known and Memorized entries must align row-for-row. ---- await page.setViewportSize({ width: 900, height: 1500 }); -await page.waitForTimeout(150); const cardWidths = await page.evaluate(() => Array.from(document.querySelectorAll(".spellbook .sb-level"), (el) => Math.round(el.getBoundingClientRect().width)), ); check("spells: all level cards have equal width", new Set(cardWidths).size === 1, JSON.stringify(cardWidths)); await page.setViewportSize({ width: 1280, height: 1000 }); -await page.waitForTimeout(150); const entryAlign = await page.evaluate(() => { for (const c of Array.from(document.querySelectorAll(".spellbook .sb-level"))) { const cols = c.querySelectorAll(".sb-col"); @@ -545,7 +598,7 @@ check( await clickTab("Effects"); await selectRow(effectsPanel, 0); await effectsPanel.locator(".detail .layout-root .field").first().waitFor({ timeout: 3000 }); -await page.screenshot({ path: path.join(here, "shot-cre-effects.png"), fullPage: true }); +await page.screenshot({ path: shotPath("shot-cre-effects.png"), fullPage: true }); await browser.close(); diff --git a/binary-editor/test/harness/render-itm.mts b/binary-editor/test/harness/render-itm.mts index d87298621..c46d81f50 100644 --- a/binary-editor/test/harness/render-itm.mts +++ b/binary-editor/test/harness/render-itm.mts @@ -25,6 +25,7 @@ import { fileURLToPath } from "node:url"; import { dispatch } from "../../src/index"; import type { HostToWebview, WebviewToHost } from "../../../client/src/binary-editor/webview/messages"; import { installCspGate } from "./csp-gate"; +import { shotPath } from "./out-dir"; import { itmParser } from "../../../binary/src/itm/index"; import { getItmCanonicalDocument, rebuildItmCanonicalDocument } from "../../../binary/src/itm/canonical-reader"; import { serializeItmCanonicalDocument } from "../../../binary/src/itm/canonical-writer"; @@ -118,18 +119,25 @@ await page.exposeFunction("__hostUp", async (m: WebviewToHost) => { }); await page.goto("file://" + path.join(here, "app.html")); await page.waitForSelector(".layout-root .bb-tabs", { timeout: 5000 }); -await page.waitForTimeout(200); +await page.waitForSelector(".layout-root .panel h3", { timeout: 5000 }); // Default tab is General; capture it, then navigate to the tree tab below. -await page.screenshot({ path: path.join(here, "shot-itm.png"), fullPage: true }); +await page.screenshot({ path: shotPath("shot-itm.png"), fullPage: true }); async function clickTab(label: string): Promise { await page.locator('.bb-tabs.primary button[role="tab"]').filter({ hasText: label }).first().click(); - await page.waitForTimeout(200); + await page + .locator('.bb-tabs.primary button[role="tab"][aria-selected="true"]') + .filter({ hasText: label }) + .first() + .waitFor({ timeout: 5000 }); } // Undo a structure op and refresh the webview (the host pushes the op onto the undo stack; "invalidated" // makes the tree re-fetch), so each op test runs from the same baseline. async function doUndo(): Promise { dispatch({ type: "undo", sessionId }); await page.evaluate((rr) => window.postMessage(rr, "*"), { type: "invalidated" } as HostToWebview); + // The invalidation triggers an async version-bump -> re-fetch round trip inside the webview with no single + // DOM-observable completion signal generic across every call site (dispatch-level checks, tree re-renders, + // and RowActions targets all key off it differently) - bounded settle, not a condition poll. await page.waitForTimeout(200); } @@ -227,7 +235,19 @@ const boxCounts = async (): Promise<{ total: number; checked: number }> => ({ checked: await unusablePanel.locator('[role="checkbox"][aria-checked="true"]').count(), }); await unusablePanel.getByRole("button", { name: "Select all", exact: true }).click(); -await page.waitForTimeout(150); +await page + .waitForFunction( + () => { + const panel = Array.from(document.querySelectorAll(".panel")).find( + (p) => p.querySelector("h3")?.textContent === "Unusable By", + ); + const boxes = panel?.querySelectorAll('[role="checkbox"]') ?? []; + return boxes.length > 0 && Array.from(boxes).every((b) => b.getAttribute("aria-checked") === "true"); + }, + undefined, + { timeout: 5000 }, + ) + .catch(() => undefined); const afterSelect = await boxCounts(); check( "bulk: Select all checks every flag in the panel", @@ -235,7 +255,19 @@ check( `${afterSelect.checked}/${afterSelect.total}`, ); await unusablePanel.getByRole("button", { name: "Deselect all", exact: true }).click(); -await page.waitForTimeout(150); +await page + .waitForFunction( + () => { + const panel = Array.from(document.querySelectorAll(".panel")).find( + (p) => p.querySelector("h3")?.textContent === "Unusable By", + ); + const boxes = panel?.querySelectorAll('[role="checkbox"]') ?? []; + return boxes.length > 0 && Array.from(boxes).every((b) => b.getAttribute("aria-checked") !== "true"); + }, + undefined, + { timeout: 5000 }, + ) + .catch(() => undefined); check("bulk: Deselect all clears every flag in the panel", (await boxCounts()).checked === 0, ""); // ============================================================ @@ -249,7 +281,16 @@ check("baseline: 3 effects", sectionKids(effectsNodeId).total === 3, `total=${se // ============================================================ await clickTab("Abilities & Effects"); await page.waitForSelector(".eff-tree .eff-tree-vrow", { timeout: 5000 }); -await page.waitForTimeout(200); +await page + .waitForFunction( + () => { + const tab = document.querySelector('.bb-tabs.primary button[role="tab"][aria-selected="true"]'); + return !!tab && (tab.textContent ?? "").includes("2/3"); + }, + undefined, + { timeout: 5000 }, + ) + .catch(() => undefined); const treeTabText = ( (await page.locator('.bb-tabs.primary button[role="tab"][aria-selected="true"]').textContent()) ?? "" @@ -307,7 +348,18 @@ const ab2Head = page .locator(".eff-tree-head") .filter({ has: page.locator(".eff-tree-head-label", { hasText: "Ability 2" }) }); await ab2Head.locator(".eff-tree-head-label").hover(); -await page.waitForTimeout(120); +await page + .waitForFunction( + () => { + const labels = Array.from(document.querySelectorAll(".eff-tree-head-label")); + const label = labels.find((l) => (l.textContent ?? "").includes("Ability 2")); + const head = label?.closest(".eff-tree-head") as HTMLElement | null; + return !!head && getComputedStyle(head).backgroundColor !== "rgba(0, 0, 0, 0)"; + }, + undefined, + { timeout: 5000 }, + ) + .catch(() => undefined); const hoverColors = await ab2Head.evaluate((head) => ({ row: getComputedStyle(head).backgroundColor, label: getComputedStyle(head.querySelector(".eff-tree-head-label")!).backgroundColor, @@ -400,7 +452,6 @@ check( // at a deliberately narrow viewport (the 1280 default has enough slack to hide the bug) that no col-2 label // whose row-band intersects the combobox has its left edge under the combobox. await page.setViewportSize({ width: 1000, height: 900 }); -await page.waitForTimeout(120); const narrowOverlap = await page.evaluate(() => { const combo = document.querySelector(".eff-tree .detail .bb-combobox-input") as HTMLElement | null; if (!combo) return { ok: false, detail: "no combobox" }; @@ -419,14 +470,22 @@ check( narrowOverlap.detail, ); await page.setViewportSize({ width: 1280, height: 900 }); -await page.waitForTimeout(120); // Stable-columns guard: the col-2 "Timing" control's left edge must coincide across all three effects // (different opcodes relabel parameter1/parameter2, but the fixed label column keeps the values put). const timingLefts: number[] = []; for (let i = 0; i < 3; i++) { await page.locator(".eff-tree-effect").nth(i).click(); - await page.waitForTimeout(80); + await page + .waitForFunction( + (idx) => { + const rows = Array.from(document.querySelectorAll(".eff-tree-effect")); + return rows[idx]?.classList.contains("eff-tree-selected") ?? false; + }, + i, + { timeout: 5000 }, + ) + .catch(() => undefined); const left = await page .locator('.eff-tree .detail .bb-combobox-input[aria-label="Timing"]') .first() @@ -504,14 +563,23 @@ check( ddWidths.attack!.w < ddWidths.damage!.w, JSON.stringify(ddWidths), ); -await page.screenshot({ path: path.join(here, "shot-itm-tree.png"), fullPage: true }); +await page.screenshot({ path: shotPath("shot-itm-tree.png"), fullPage: true }); // ============================================================ // Structure ops via the tree (full parity with the dropped Abilities/Effects tabs) // ============================================================ // + ability (section-level add) await page.locator(".eff-tree-toolbar .eff-tree-toolbtn").click(); -await page.waitForTimeout(200); +await page + .waitForFunction( + () => + Array.from(document.querySelectorAll(".eff-tree-head-label")).filter((l) => + (l.textContent ?? "").startsWith("Ability"), + ).length === 3, + undefined, + { timeout: 5000 }, + ) + .catch(() => undefined); check( "ops: + ability adds an ability", sectionKids(abilitiesNodeId).total === 3, @@ -529,7 +597,9 @@ const ability1Head = page .locator(".eff-tree-head") .filter({ has: page.locator(".eff-tree-head-label", { hasText: "Ability 1" }) }); await ability1Head.locator(".eff-tree-add").click(); -await page.waitForTimeout(200); +await page + .waitForFunction(() => document.querySelectorAll(".eff-tree-effect").length === 4, undefined, { timeout: 5000 }) + .catch(() => undefined); check( "ops: + effect on Ability 1 adds an effect", sectionKids(effectsNodeId).total === 4, @@ -542,7 +612,9 @@ check("ops: undo restores 3 effects", sectionKids(effectsNodeId).total === 3, `$ await page.locator(".eff-tree-effect").first().click(); await page.locator(".eff-tree .detail .row-actions").first().waitFor({ timeout: 3000 }); await page.locator('.eff-tree .detail .row-actions button[aria-label="Delete"]').click(); -await page.waitForTimeout(200); +await page + .waitForFunction(() => document.querySelectorAll(".eff-tree-effect").length === 2, undefined, { timeout: 5000 }) + .catch(() => undefined); check( "ops: delete effect via RowActions removes it", sectionKids(effectsNodeId).total === 2, @@ -553,7 +625,16 @@ await doUndo(); await page.locator(".eff-tree-head-label", { hasText: "Ability 1" }).first().click(); await page.locator(".eff-tree .detail .row-actions").first().waitFor({ timeout: 3000 }); await page.locator('.eff-tree .detail .row-actions button[aria-label="Add below"]').click(); -await page.waitForTimeout(200); +await page + .waitForFunction( + () => + Array.from(document.querySelectorAll(".eff-tree-head-label")).filter((l) => + (l.textContent ?? "").startsWith("Ability"), + ).length === 3, + undefined, + { timeout: 5000 }, + ) + .catch(() => undefined); check( "ops: ability RowActions Add below inserts an ability", sectionKids(abilitiesNodeId).total === 3, @@ -563,7 +644,9 @@ await doUndo(); // Add global (equipping) effect: the Global group's + appends to the equipping range (section-level add). await page.locator('.eff-tree-add[aria-label="Add global effect"]').click(); -await page.waitForTimeout(200); +await page + .waitForFunction(() => document.querySelectorAll(".eff-tree-effect").length === 4, undefined, { timeout: 5000 }) + .catch(() => undefined); check( "ops: + global effect grows the effect count", sectionKids(effectsNodeId).total === 4, @@ -576,7 +659,11 @@ check("ops: undo restores 3 effects", sectionKids(effectsNodeId).total === 3, `$ // Filter: typing narrows the tree to matching effects (+ their owning headers), forcing groups expanded. // ============================================================ await page.locator(".eff-tree-toolbar input.list-filter-input").fill("op 21"); -await page.waitForTimeout(200); +await page + .waitForFunction(() => document.querySelectorAll(".eff-tree-effect-label").length === 1, undefined, { + timeout: 5000, + }) + .catch(() => undefined); const filtered = await page.evaluate(() => ({ effects: Array.from(document.querySelectorAll(".eff-tree-effect-label")).map((e) => (e.textContent ?? "").trim()), heads: Array.from(document.querySelectorAll(".eff-tree-head-label")).map((e) => (e.textContent ?? "").trim()), @@ -590,7 +677,9 @@ check( JSON.stringify(filtered), ); await page.locator(".eff-tree-toolbar .list-filter-clear").click(); -await page.waitForTimeout(150); +await page + .waitForFunction(() => document.querySelectorAll(".eff-tree-effect").length === 3, undefined, { timeout: 5000 }) + .catch(() => undefined); check( "filter: clearing restores all three effects", (await page.locator(".eff-tree-effect").count()) === 3, @@ -599,14 +688,18 @@ check( // Collapse all / expand all: collapsing hides every nested effect row (headers remain); expanding restores them. await page.locator('.eff-tree-iconbtn[aria-label="Collapse all"]').click(); -await page.waitForTimeout(150); +await page + .waitForFunction(() => document.querySelectorAll(".eff-tree-effect").length === 0, undefined, { timeout: 5000 }) + .catch(() => undefined); check( "tree: collapse all hides every effect row", (await page.locator(".eff-tree-effect").count()) === 0, `${await page.locator(".eff-tree-effect").count()}`, ); await page.locator('.eff-tree-iconbtn[aria-label="Expand all"]').click(); -await page.waitForTimeout(150); +await page + .waitForFunction(() => document.querySelectorAll(".eff-tree-effect").length === 3, undefined, { timeout: 5000 }) + .catch(() => undefined); check( "tree: expand all restores every effect row", (await page.locator(".eff-tree-effect").count()) === 3, @@ -629,7 +722,16 @@ check( } else { sessionId = r.result.sessionId; await page.evaluate((rr) => window.postMessage(rr, "*"), { type: "init", open: r.result } as HostToWebview); - await page.waitForTimeout(300); + await page + .waitForFunction( + () => { + const spacer = document.querySelector(".eff-tree-spacer") as HTMLElement | null; + return !!spacer && spacer.getBoundingClientRect().height > 3000; + }, + undefined, + { timeout: 5000 }, + ) + .catch(() => undefined); const mounted = await page.locator(".eff-tree-vrow").count(); const spacerH = await page .locator(".eff-tree-spacer") diff --git a/binary-editor/test/harness/render-map-banner.mts b/binary-editor/test/harness/render-map-banner.mts index a16797da8..c00a98df8 100644 --- a/binary-editor/test/harness/render-map-banner.mts +++ b/binary-editor/test/harness/render-map-banner.mts @@ -13,6 +13,7 @@ import { fileURLToPath } from "node:url"; import { dispatch } from "../../src/index"; import type { HostToWebview, WebviewToHost } from "../../../client/src/binary-editor/webview/messages"; import { installCspGate } from "./csp-gate"; +import { shotPath } from "./out-dir"; const here = path.dirname(fileURLToPath(import.meta.url)); const FIXTURE = path.join(here, "../../../client/testFixture/maps/arcaves.map"); @@ -51,7 +52,9 @@ await page.exposeFunction("__hostUp", async (m: WebviewToHost) => { await page.goto("file://" + path.join(here, "app.html")); await page.waitForSelector(".layout-root", { timeout: 5000 }); -await page.waitForTimeout(150); +await page + .waitForFunction(() => document.querySelector(".banner.warning") !== null, undefined, { timeout: 5000 }) + .catch(() => undefined); const banner = await page.evaluate(() => { const b = document.querySelector(".banner.warning"); @@ -72,7 +75,7 @@ if (banner) { ); } -await page.screenshot({ path: path.join(here, "shot-map-banner.png"), fullPage: false }); +await page.screenshot({ path: shotPath("shot-map-banner.png"), fullPage: false }); await browser.close(); console.log("\n=== MAP banner harness results ==="); diff --git a/binary-editor/test/harness/render-map-elevations.mts b/binary-editor/test/harness/render-map-elevations.mts index 36b1de04a..2e90d0e5a 100644 --- a/binary-editor/test/harness/render-map-elevations.mts +++ b/binary-editor/test/harness/render-map-elevations.mts @@ -13,6 +13,7 @@ import { fileURLToPath } from "node:url"; import { dispatch } from "../../src/index"; import type { HostToWebview, WebviewToHost } from "../../../client/src/binary-editor/webview/messages"; import { installCspGate } from "./csp-gate"; +import { shotPath } from "./out-dir"; const here = path.dirname(fileURLToPath(import.meta.url)); const FIXTURE = path.join(here, "../../../client/testFixture/maps/artemple.map"); @@ -52,7 +53,13 @@ await page.exposeFunction("__hostUp", async (m: WebviewToHost) => { await page.goto("file://" + path.join(here, "app.html")); await page.waitForSelector(".layout-root .bb-tabs", { timeout: 5000 }); await page.locator('.bb-tabs.primary button[role="tab"]').filter({ hasText: "Objects" }).first().click(); -await page.waitForTimeout(150); +await page + .waitForFunction( + () => document.querySelectorAll('.layout-root .bb-tabs.secondary button[role="tab"]').length >= 3, + undefined, + { timeout: 5000 }, + ) + .catch(() => undefined); // Read the elevation subtab strip: label + disabled state. const subs = await page.evaluate(() => @@ -76,7 +83,7 @@ check( JSON.stringify(byLabel("Elevation 2")), ); -await page.screenshot({ path: path.join(here, "shot-map-elevations.png"), fullPage: true }); +await page.screenshot({ path: shotPath("shot-map-elevations.png"), fullPage: true }); await browser.close(); console.log("\n=== MAP elevation-tab harness results ==="); diff --git a/binary-editor/test/harness/render-map-jump-deep.mts b/binary-editor/test/harness/render-map-jump-deep.mts index b6f0e7251..913ea3e58 100644 --- a/binary-editor/test/harness/render-map-jump-deep.mts +++ b/binary-editor/test/harness/render-map-jump-deep.mts @@ -18,6 +18,7 @@ import { dispatch } from "../../src/index"; import type { FlatNode } from "../../src/model"; import type { HostToWebview, WebviewToHost } from "../../../client/src/binary-editor/webview/messages"; import { installCspGate } from "./csp-gate"; +import { shotPath } from "./out-dir"; const here = path.dirname(fileURLToPath(import.meta.url)); const FIXTURE = path.join(here, "../../../client/testFixture/maps/denbus1.map"); @@ -122,13 +123,21 @@ const fieldHex = (label: string) => // Open the linked script: Scripts tab -> its type subtab -> filter to its exact label -> click it. await page.locator('.bb-tabs.primary button[role="tab"]').filter({ hasText: "Scripts" }).first().click(); -await page.waitForTimeout(150); await page.locator('.layout-root .bb-tabs button[role="tab"]').filter({ hasText: deep.scriptType }).first().click(); -await page.waitForTimeout(150); await page.locator(".layout-root .list-filter-input").first().fill(deep.scriptLabel); -await page.waitForTimeout(200); await page.locator(".layout-root .vlist .vrow").filter({ hasText: deep.scriptLabel }).first().click(); -await page.waitForTimeout(200); +await page + .waitForFunction( + () => { + const field = Array.from(document.querySelectorAll(".layout-root .field")).find( + (f) => f.querySelector(".label")?.textContent?.trim() === "SID", + ); + return field !== undefined && field.querySelector(".jump-link") !== null; + }, + undefined, + { timeout: 5000 }, + ) + .catch(() => undefined); const sidChip = page .locator(".layout-root .field") @@ -137,7 +146,24 @@ const sidChip = page .locator(".jump-link"); check("the deep-linked script exposes a SID jump chip", (await sidChip.count()) > 0, deep.scriptLabel); await sidChip.first().click(); -await page.waitForTimeout(300); + +// A deep jump is two async round-trips: locateEntry fetches the full object list to resolve the selection +// (the target sits past the bounded window), THEN the detail pane fetches and renders the landed object's +// fields. Poll for the object's SID field to render rather than racing a fixed sleep - the old 300ms sleep +// read the still-empty detail pane and saw null even though the jump had landed correctly. +await page + .waitForFunction( + () => { + const field = Array.from(document.querySelectorAll(".layout-root .field")).find( + (f) => f.querySelector(".label")?.textContent?.trim() === "SID", + ); + const input = field?.querySelector(".hex-input input") as HTMLInputElement | null; + return input !== null && input.value.length > 0; + }, + undefined, + { timeout: 5000 }, + ) + .catch(() => undefined); // let the assertions below report the concrete failure rather than a poll timeout const tabAfter = (await activePrimaryTab()).trim(); check("jump switches to the Objects tab", tabAfter.startsWith("Objects"), `active="${tabAfter}"`); @@ -148,7 +174,7 @@ check( landedSid !== null && (parseInt(landedSid, 16) | 0) === (deep.objSid | 0), `landed=0x${landedSid} expected=0x${objSidHex} targetIdx=${deep.objIndex}`, ); -await page.screenshot({ path: path.join(here, "shot-map-jump-deep.png"), fullPage: true }); +await page.screenshot({ path: shotPath("shot-map-jump-deep.png"), fullPage: true }); await browser.close(); console.log("\n=== MAP deep-jump harness results ==="); diff --git a/binary-editor/test/harness/render-map-jump.mts b/binary-editor/test/harness/render-map-jump.mts index 086a48d09..a669c0d1e 100644 --- a/binary-editor/test/harness/render-map-jump.mts +++ b/binary-editor/test/harness/render-map-jump.mts @@ -14,6 +14,7 @@ import { buildFileDerivedParseOptions } from "@bgforge/binary"; import { dispatch } from "../../src/index"; import type { HostToWebview, WebviewToHost } from "../../../client/src/binary-editor/webview/messages"; import { installCspGate } from "./csp-gate"; +import { shotPath } from "./out-dir"; const here = path.dirname(fileURLToPath(import.meta.url)); const FIXTURE = path.join(here, "../../../client/testFixture/maps/denbus1.map"); @@ -69,7 +70,18 @@ const fieldHex = (label: string) => // --- Find a script whose SID links to its object. Only object-owned scripts (item/critter) are referenced by // an object, so scan each script subtab's first rows until one exposes a SID chip. --- await page.locator('.bb-tabs.primary button[role="tab"]').filter({ hasText: "Scripts" }).first().click(); -await page.waitForTimeout(150); +await page + .waitForFunction( + () => + Array.from(document.querySelectorAll('.layout-root .bb-tabs button[role="tab"]')).some((b) => + ["System", "Spatial", "Timer", "Item", "Critter"].some((t) => + (b.textContent ?? "").trim().startsWith(t), + ), + ), + undefined, + { timeout: 5000 }, + ) + .catch(() => undefined); const sidField = () => page .locator(".layout-root .field") @@ -82,12 +94,25 @@ for (let s = 0; s < (await subtabs.count()) && sidHex === null; s++) { const label = (await subtabs.nth(s).innerText()).trim(); if (!["System", "Spatial", "Timer", "Item", "Critter"].some((t) => label.startsWith(t))) continue; await subtabs.nth(s).click(); - await page.waitForTimeout(120); + await page + .waitForFunction(() => document.querySelectorAll(".layout-root .vlist .vrow").length > 0, undefined, { + timeout: 5000, + }) + .catch(() => undefined); const rows = page.locator(".layout-root .vlist .vrow"); const rowCount = Math.min(await rows.count(), 20); for (let i = 0; i < rowCount; i++) { await rows.nth(i).click(); - await page.waitForTimeout(70); + await page + .waitForFunction( + (idx) => { + const el = document.querySelectorAll(".layout-root .vlist .vrow")[idx]; + return el !== undefined && el.classList.contains("selected"); + }, + i, + { timeout: 5000 }, + ) + .catch(() => undefined); if ((await sidField().locator(".jump-link").count()) > 0) { sidHex = await fieldHex("SID"); jumpLabel = (await sidField().locator(".jump-link").first().innerText()).trim(); @@ -100,7 +125,19 @@ check("found a script whose SID links to its object", sidHex !== null, `sid=0x${ if (sidHex !== null) { const sidVal = parseInt(sidHex, 16) | 0; await sidField().locator(".jump-link").first().click(); - await page.waitForTimeout(200); + await page + .waitForFunction( + () => { + const field = Array.from(document.querySelectorAll(".layout-root .field")).find( + (f) => f.querySelector(".label")?.textContent?.trim() === "SID", + ); + const input = field?.querySelector(".hex-input input") as HTMLInputElement | null; + return input !== null && input.value.length > 0; + }, + undefined, + { timeout: 5000 }, + ) + .catch(() => undefined); const tabAfter = (await activePrimaryTab()).trim(); check("the script SID jump switches to the Objects tab", tabAfter.startsWith("Objects"), `active="${tabAfter}"`); @@ -129,7 +166,7 @@ if (sidHex !== null) { JSON.stringify(visible), ); - await page.screenshot({ path: path.join(here, "shot-map-jump.png"), fullPage: true }); + await page.screenshot({ path: shotPath("shot-map-jump.png"), fullPage: true }); } // The reverse direction (object SID -> script) uses the identical navigate() primitive with the script // section key; the link resolution both ways is covered by binary-editor/test/map-cross-links.test.ts. diff --git a/binary-editor/test/harness/render-map-scripts.mts b/binary-editor/test/harness/render-map-scripts.mts index e83344f43..a505c581f 100644 --- a/binary-editor/test/harness/render-map-scripts.mts +++ b/binary-editor/test/harness/render-map-scripts.mts @@ -14,6 +14,7 @@ import { fileURLToPath } from "node:url"; import { dispatch } from "../../src/index"; import type { HostToWebview, WebviewToHost } from "../../../client/src/binary-editor/webview/messages"; import { installCspGate } from "./csp-gate"; +import { shotPath } from "./out-dir"; const here = path.dirname(fileURLToPath(import.meta.url)); const FIXTURE = path.join(here, "../../../client/testFixture/maps/newr2.map"); @@ -55,7 +56,18 @@ await page.waitForSelector(".layout-root .bb-tabs", { timeout: 5000 }); // Primary tab -> Scripts. await page.locator('.bb-tabs.primary button[role="tab"]').filter({ hasText: "Scripts" }).first().click(); -await page.waitForTimeout(150); +await page + .waitForFunction( + () => + Array.from(document.querySelectorAll('.layout-root .bb-tabs button[role="tab"]')).some((b) => + ["System", "Spatial", "Timer", "Item", "Critter"].some((t) => + (b.textContent ?? "").trim().startsWith(t), + ), + ), + undefined, + { timeout: 5000 }, + ) + .catch(() => undefined); // Pick the first script subtab that has list rows. const subtabs = page.locator('.layout-root .bb-tabs button[role="tab"]'); @@ -66,7 +78,11 @@ for (let i = 0; i < subCount; i++) { const label = (await subtabs.nth(i).innerText()).trim(); if (!["System", "Spatial", "Timer", "Item", "Critter"].some((s) => label.startsWith(s))) continue; await subtabs.nth(i).click(); - await page.waitForTimeout(150); + await page + .waitForFunction(() => document.querySelectorAll(".layout-root .vlist .vrow").length > 0, undefined, { + timeout: 5000, + }) + .catch(() => undefined); const rows = page.locator(".layout-root .vlist .vrow"); if ((await rows.count()) > 0) { selected = rows; @@ -85,7 +101,16 @@ if (selected) { ); await selected.first().click(); - await page.waitForTimeout(150); + await page + .waitForFunction( + () => + Array.from(document.querySelectorAll(".layout-root .field")).some( + (f) => f.querySelector(".label")?.textContent?.trim() === "SID", + ), + undefined, + { timeout: 5000 }, + ) + .catch(() => undefined); // SID field in the selected script's detail: a hex control (`.hex-input` = static "0x" prefix + digit // input) whose digits are not clipped. The label is the stripped "SID" (no "Entry N" prefix). @@ -116,7 +141,7 @@ if (selected) { ); check("extent paging fields are hidden", extentLabels === 0, `count=${extentLabels}`); - await page.screenshot({ path: path.join(here, "shot-map-scripts.png"), fullPage: true }); + await page.screenshot({ path: shotPath("shot-map-scripts.png"), fullPage: true }); } await browser.close(); diff --git a/binary-editor/test/harness/render-map-vars.mts b/binary-editor/test/harness/render-map-vars.mts index 4727accc1..49d757bb5 100644 --- a/binary-editor/test/harness/render-map-vars.mts +++ b/binary-editor/test/harness/render-map-vars.mts @@ -15,6 +15,7 @@ import { fileURLToPath } from "node:url"; import { dispatch } from "../../src/index"; import type { HostToWebview, WebviewToHost } from "../../../client/src/binary-editor/webview/messages"; import { installCspGate } from "./csp-gate"; +import { shotPath } from "./out-dir"; const here = path.dirname(fileURLToPath(import.meta.url)); const FIXTURE = path.join(here, "../../../client/testFixture/maps/arcaves.map"); @@ -69,7 +70,18 @@ check( ); await varTabBtn.click(); -await page.waitForTimeout(200); +await page + .waitForFunction( + () => { + const btn = Array.from(document.querySelectorAll('.bb-tabs.primary button[role="tab"]')).find((b) => + (b.textContent ?? "").includes("Variables"), + ); + return btn !== undefined && /Variables\s*\(\s*21\s*\)/.test(btn.textContent ?? ""); + }, + undefined, + { timeout: 5000 }, + ) + .catch(() => undefined); // Variables tab button should show a count badge (21 from arcaves). const varTabLabel = await varTabBtn.innerText(); @@ -92,7 +104,7 @@ const rows = page.locator(".layout-root .vlist .vrow"); const rowCount = await rows.count(); check("Global subtab shows variable list rows (>0)", rowCount > 0, `rows=${rowCount}`); -await page.screenshot({ path: path.join(here, "shot-map-vars.png"), fullPage: true }); +await page.screenshot({ path: shotPath("shot-map-vars.png"), fullPage: true }); await browser.close(); console.log("\n=== MAP variables tab harness results ==="); diff --git a/binary-editor/test/harness/render-map.mts b/binary-editor/test/harness/render-map.mts index 346a02d6e..f3046bc54 100644 --- a/binary-editor/test/harness/render-map.mts +++ b/binary-editor/test/harness/render-map.mts @@ -27,6 +27,7 @@ import { fileURLToPath } from "node:url"; import { dispatch } from "../../src/index"; import type { HostToWebview, WebviewToHost } from "../../../client/src/binary-editor/webview/messages"; import { installCspGate } from "./csp-gate"; +import { shotPath } from "./out-dir"; import { mapParser } from "../../../binary/src/map/index"; const here = path.dirname(fileURLToPath(import.meta.url)); @@ -101,10 +102,21 @@ await page.exposeFunction("__hostUp", async (m: WebviewToHost) => { }); await page.goto("file://" + path.join(here, "app.html")); await page.waitForSelector(".layout-root .bb-tabs", { timeout: 5000 }); -await page.waitForTimeout(200); +await page + .waitForFunction(() => document.querySelector(".layout-root .panel > h3") !== null, undefined, { timeout: 5000 }) + .catch(() => undefined); async function clickTab(label: string): Promise { await page.locator('.bb-tabs.primary button[role="tab"]').filter({ hasText: label }).first().click(); - await page.waitForTimeout(200); + await page + .waitForFunction( + (lbl) => { + const active = document.querySelector('.bb-tabs.primary button[role="tab"][aria-selected="true"]'); + return active !== null && (active.textContent ?? "").includes(lbl); + }, + label, + { timeout: 5000 }, + ) + .catch(() => undefined); } // Guard against the "captured the wrong tab" regression: each per-tab screenshot must be taken while that // tab is actually the selected one. Returns the active primary tab's label (includes its count badge text). @@ -118,7 +130,7 @@ check( (await activeTabLabel()).includes("Header"), await activeTabLabel(), ); -await page.screenshot({ path: path.join(here, "shot-map.png"), fullPage: true }); +await page.screenshot({ path: shotPath("shot-map.png"), fullPage: true }); // ============================================================ // Layout assertions @@ -184,13 +196,17 @@ check("layout: objects tab renders an elevation object list", elevMd >= 1, `coun // Select the first object so the detail pane renders (otherwise the shot is just an empty "Select an entry." pane). await objectsMd.locator(".vlist .vrow").first().waitFor({ timeout: 5000 }); await objectsMd.locator(".vlist .vrow").first().click(); -await page.waitForTimeout(200); +await page + .waitForFunction(() => document.querySelector(".master-detail .detail .detail-tabs") !== null, undefined, { + timeout: 5000, + }) + .catch(() => undefined); check( "screenshot: Objects tab active for shot-map-objects", (await activeTabLabel()).includes("Objects"), await activeTabLabel(), ); -await page.screenshot({ path: path.join(here, "shot-map-objects.png"), fullPage: true }); +await page.screenshot({ path: shotPath("shot-map-objects.png"), fullPage: true }); // The object detail splits into "Details" + "Inventory" tabs; the engine Inventory Header bookkeeping group is // hidden, so it must NOT appear as a form sub-tab. Open the Inventory tab to reach the mini-list. @@ -208,7 +224,9 @@ check( "", ); await detailTabs.locator('button[role="tab"]').filter({ hasText: "Inventory" }).first().click(); -await page.waitForTimeout(150); +await page + .waitForFunction(() => document.querySelector(".child-list") !== null, undefined, { timeout: 5000 }) + .catch(() => undefined); // Inventory mini-list (interactive): cave6's first object has no inventory, so the list opens on its empty // state. Add two items through the "+ add item" button (real addChild dispatch -> changeSet refresh), expand @@ -224,9 +242,12 @@ check( .catch(() => "")) ?? "", ); await inventoryList.locator(".child-list-add").click(); -await page.waitForTimeout(150); await inventoryList.locator(".child-list-add").click(); -await page.waitForTimeout(150); +await page + .waitForFunction(() => document.querySelectorAll(".child-list .child-row").length >= 2, undefined, { + timeout: 5000, + }) + .catch(() => undefined); const invRows = await inventoryList.locator(".child-row").count(); check("inventory: + add item grew the list to two rows", invRows === 2, `rows=${invRows}`); const invTabText = @@ -242,7 +263,11 @@ check( `label="${invTabText.trim()}"`, ); await inventoryList.locator(".child-row .child-row-label").first().click(); -await page.waitForTimeout(200); +await page + .waitForFunction(() => document.querySelector(".child-list .child-row-detail .form") !== null, undefined, { + timeout: 5000, + }) + .catch(() => undefined); // The row label is the item's identity (PID) + quantity, not the bare "Inventory Entry N" group name. const firstRowLabel = (await inventoryList.locator(".child-row .child-row-label").first().textContent()) ?? ""; check( @@ -255,7 +280,7 @@ check( (await inventoryList.locator(".child-row-detail .form").count()) >= 1, "", ); -await page.screenshot({ path: path.join(here, "shot-map-inventory.png"), fullPage: true }); +await page.screenshot({ path: shotPath("shot-map-inventory.png"), fullPage: true }); // ============================================================ // Baseline + structure ops (dispatch-level; the interactive DOM path is covered by ITM/CRE) diff --git a/binary-editor/test/harness/render-primitives.mts b/binary-editor/test/harness/render-primitives.mts index 5df870cb3..df95ff866 100644 --- a/binary-editor/test/harness/render-primitives.mts +++ b/binary-editor/test/harness/render-primitives.mts @@ -28,6 +28,7 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { installCspGate } from "./csp-gate"; +import { shotPath } from "./out-dir"; import { THEME_VARS } from "./theme-vars"; const here = path.dirname(fileURLToPath(import.meta.url)); @@ -97,15 +98,18 @@ const comboboxAllCount = await page.locator(".bb-combobox-item").count(); // SelectInputState picks up (setting inputValue.current) and our handleInput handler picks up (updating our // local inputValue state, driving $derived visibleOptions). The dropdown is already open from ArrowDown. await page.locator(".bb-combobox-input").pressSequentially("fireball"); -// Give Svelte reactive updates a moment to re-render the filtered list. -await page.waitForTimeout(200); +// Wait for Svelte reactive updates to re-render the filtered list (item count drops below the unfiltered total). +await page + .waitForFunction((all) => document.querySelectorAll(".bb-combobox-item").length < all, comboboxAllCount, { + timeout: 5000, + }) + .catch(() => undefined); const comboboxFilteredCount = await page.locator(".bb-combobox-item").count(); // Close the combobox dropdown (still open from the filter step) before interacting with the Checkbox. // Wait for the content to detach (bits-ui resets body.style.pointerEvents only after close animation). await page.keyboard.press("Escape"); await page.waitForSelector(".bb-combobox-content", { state: "detached", timeout: 3000 }).catch(() => {}); -await page.waitForTimeout(150); // ---- Exercise Checkbox: click checkbox-a (unchecked -> checked) and assert data-state changes ---- // bits-ui sets data-state="checked"|"unchecked" on Checkbox.Root (role="checkbox"). We locate it inside @@ -114,18 +118,33 @@ await page.waitForSelector("#checkbox-a [role='checkbox']", { timeout: 5000 }); const checkboxABefore = await page.locator("#checkbox-a [role='checkbox']").getAttribute("data-state"); await page.locator("#checkbox-a [role='checkbox']").click(); -await page.waitForTimeout(100); +await page + .waitForFunction( + (before) => document.querySelector("#checkbox-a [role='checkbox']")?.getAttribute("data-state") !== before, + checkboxABefore, + { timeout: 5000 }, + ) + .catch(() => undefined); const checkboxAAfter = await page.locator("#checkbox-a [role='checkbox']").getAttribute("data-state"); // Also confirm checkbox-b starts checked and can be untoggled. const checkboxBBefore = await page.locator("#checkbox-b [role='checkbox']").getAttribute("data-state"); await page.locator("#checkbox-b [role='checkbox']").click(); -await page.waitForTimeout(100); +await page + .waitForFunction( + (before) => document.querySelector("#checkbox-b [role='checkbox']")?.getAttribute("data-state") !== before, + checkboxBBefore, + { timeout: 5000 }, + ) + .catch(() => undefined); const checkboxBAfter = await page.locator("#checkbox-b [role='checkbox']").getAttribute("data-state"); // Disabled checkbox must not change on click. const checkboxDisabledBefore = await page.locator("#checkbox-disabled [role='checkbox']").getAttribute("data-state"); await page.locator("#checkbox-disabled [role='checkbox']").click({ force: true }); +// No state-change event to poll for here: correct behavior is "nothing happens" on a disabled control, so there +// is no becomes-true condition to wait on. Keep a bounded settle so a real bug (an incorrect state flip) has +// time to land before the read below. await page.waitForTimeout(100); const checkboxDisabledAfter = await page.locator("#checkbox-disabled [role='checkbox']").getAttribute("data-state"); @@ -146,7 +165,9 @@ const checkboxDisabledNamedCorrectly = // does not affect item label text or role - assert on labels and roles only, not icon visuals. // Menu.svelte passes preventScroll={false} so it does not set body.style.pointerEvents:none (unlike the // prior combobox). Ensure the combobox scroll-lock (if any) is cleared before clicking. -await page.waitForFunction(() => document.body.style.pointerEvents !== "none", { timeout: 500 }).catch(() => {}); +await page + .waitForFunction(() => document.body.style.pointerEvents !== "none", undefined, { timeout: 500 }) + .catch(() => {}); await page.waitForSelector("#menu-showcase .bb-menu-trigger", { timeout: 5000 }); await page.locator("#menu-showcase .bb-menu-trigger").click(); // Wait for at least one menu item to appear (the content is portalled to document body). @@ -157,7 +178,13 @@ const menuItemCount = await page.locator(".bb-menu-item").count(); // Assert by label text (not icon) so missing codicon glyphs don't interfere. const addAboveItem = page.locator(".bb-menu-item", { hasText: "Add above" }); await addAboveItem.click(); -await page.waitForTimeout(100); +await page + .waitForFunction( + () => document.querySelector("#menu-selected")?.getAttribute("data-value") === "add-above", + undefined, + { timeout: 5000 }, + ) + .catch(() => undefined); const menuSelectedAfterAdd = await page.locator("#menu-selected").getAttribute("data-value"); // Re-open the menu to test the disabled item. Menu.svelte uses preventScroll={false} so no scroll-lock @@ -170,12 +197,14 @@ const disabledItem = page.locator(".bb-menu-item[data-disabled]"); const disabledItemCount = await disabledItem.count(); // Click the disabled item - it must not fire onselect (the selected value must not change). await disabledItem.click({ force: true }); +// No state-change event to poll for here: correct behavior is "nothing happens" on a disabled item, so there +// is no becomes-true condition to wait on. Keep a bounded settle so a real bug (an incorrect onselect fire) has +// time to land before the read below. await page.waitForTimeout(100); const menuSelectedAfterDisabled = await page.locator("#menu-selected").getAttribute("data-value"); // Close the menu before the screenshot. await page.keyboard.press("Escape"); -await page.waitForTimeout(150); // ---- Exercise Tabs (horizontal): assert initial active, click a different tab, assert selection moves ---- // The showcase reflects active tab id into #tabs-h-active[data-value] so we can assert without reading @@ -185,12 +214,24 @@ const tabsHInitial = await page.locator("#tabs-h-active").getAttribute("data-val // Click the "Abilities" tab (second tab, id="abilities"). const tabsHAbilities = page.locator("#tabs-h-showcase [role='tab'][aria-selected]", { hasText: "Abilities" }); await tabsHAbilities.click(); -await page.waitForTimeout(100); +await page + .waitForFunction( + () => document.querySelector("#tabs-h-active")?.getAttribute("data-value") === "abilities", + undefined, + { timeout: 5000 }, + ) + .catch(() => undefined); const tabsHAfterClick = await page.locator("#tabs-h-active").getAttribute("data-value"); // Arrow-key nav: with "Abilities" now active, press ArrowRight to move to "Effects". await page.keyboard.press("ArrowRight"); -await page.waitForTimeout(100); +await page + .waitForFunction( + () => document.querySelector("#tabs-h-active")?.getAttribute("data-value") === "effects", + undefined, + { timeout: 5000 }, + ) + .catch(() => undefined); const tabsHAfterArrow = await page.locator("#tabs-h-active").getAttribute("data-value"); // ---- Exercise Tabs (vertical): assert initial active, click a different tab, assert selection moves ---- @@ -199,12 +240,24 @@ const tabsVInitial = await page.locator("#tabs-v-active").getAttribute("data-val // Click the "Effects" tab (third tab, id="effects"). const tabsVEffects = page.locator("#tabs-v-showcase [role='tab']", { hasText: "Effects" }); await tabsVEffects.click(); -await page.waitForTimeout(100); +await page + .waitForFunction( + () => document.querySelector("#tabs-v-active")?.getAttribute("data-value") === "effects", + undefined, + { timeout: 5000 }, + ) + .catch(() => undefined); const tabsVAfterClick = await page.locator("#tabs-v-active").getAttribute("data-value"); // Arrow-key nav: with "Effects" now active, press ArrowUp to move to "Abilities" (vertical orientation). await page.keyboard.press("ArrowUp"); -await page.waitForTimeout(100); +await page + .waitForFunction( + () => document.querySelector("#tabs-v-active")?.getAttribute("data-value") === "abilities", + undefined, + { timeout: 5000 }, + ) + .catch(() => undefined); const tabsVAfterArrow = await page.locator("#tabs-v-active").getAttribute("data-value"); // ---- Exercise compact RowActions: kebab-only layout + Menu->Delete fires immediately ---- @@ -220,10 +273,17 @@ const compactDirectButtons = await page await page.locator("#rowactions-compact .bb-menu-trigger").click(); await page.waitForSelector(".bb-menu-item", { timeout: 5000 }); await page.locator(".bb-menu-item", { hasText: "Delete" }).click(); -await page.waitForTimeout(150); +await page + .waitForFunction( + () => + (document.querySelector("#rowactions-last-op")?.getAttribute("data-value") ?? "").includes('"op":"remove"'), + undefined, + { timeout: 5000 }, + ) + .catch(() => undefined); const opAfterMenuDelete = await page.locator("#rowactions-last-op").getAttribute("data-value"); -await page.screenshot({ path: path.join(here, "shot-primitives.png") }); +await page.screenshot({ path: shotPath("shot-primitives.png") }); // Diagnostic: enumerate elements carrying a style attribute and any injected +
`; + +fs.writeFileSync(path.join(here, "app.html"), html); +console.log("wrote app.html (" + (html.length / 1024).toFixed(0) + " kb)"); diff --git a/client/src/dialog-editor/test/harness/driver-util.ts b/client/src/dialog-editor/test/harness/driver-util.ts new file mode 100644 index 000000000..e0be1f9e4 --- /dev/null +++ b/client/src/dialog-editor/test/harness/driver-util.ts @@ -0,0 +1,81 @@ +/** + * Shared plumbing for the harness driver scripts (render.mts / render-search.mts / edit-behavior.mts): + * app.html resolution with a fail-loud missing-bundle guard, the repo-level tmp/ output dir, and the + * PASS/FAIL check accumulator + end-of-run report each driver previously carried as its own copy. + */ + +import { existsSync, mkdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Resolve the generated app.html and the screenshot output dir for a driver. app.html is a build + * artifact (gitignored, not committed), so a fresh checkout must build it before driving; failing + * here with the build command beats Playwright's opaque net::ERR_FILE_NOT_FOUND. + */ +export function harnessPaths(importMetaUrl: string): { appHtml: string; outDir: string } { + const here = path.dirname(fileURLToPath(importMetaUrl)); + const appHtml = path.join(here, "app.html"); + if (!existsSync(appHtml)) { + console.error( + "app.html not found - it is a generated bundle (gitignored). Build it first:\n" + + " pnpm exec tsx client/src/dialog-editor/test/harness/build.mts", + ); + process.exit(1); + } + // Runtime artefacts go under the repo-level tmp/, never the source tree (project convention). + const outDir = path.resolve(here, "../../../../../tmp"); + mkdirSync(outDir, { recursive: true }); + return { appHtml, outDir }; +} + +/** Parse a WeiDU `.tra` (`@N = ~text~`, optional trailing `[SOUND]`) into a {N: text} map. */ +export function parseTra(text: string): Record { + const messages: Record = {}; + const re = /@(\d+)\s*=\s*~([\s\S]*?)~/g; + let m: RegExpExecArray | null; + while ((m = re.exec(text)) !== null) { + messages[m[1]!] = m[2]!.trim(); + } + return messages; +} + +/** + * Poll `cond` until it returns true or `timeoutMs` elapses; returns the final outcome instead of + * throwing so a driver can feed it straight into a PASS/FAIL check line. + */ +export async function pollUntil(cond: () => boolean, timeoutMs = 5000, stepMs = 50): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (cond()) return true; + await new Promise((r) => setTimeout(r, stepMs)); + } + return cond(); +} + +/** + * PASS/FAIL accumulator shared by the drivers. `finish` prints every check line, appends any + * collected page errors, and exits non-zero when anything failed. + */ +export function makeChecker(): { + check: (label: string, ok: boolean, detail?: string) => void; + finish: (pageErrors?: string[], note?: string) => never; +} { + const results: Array<{ line: string; ok: boolean }> = []; + function check(label: string, ok: boolean, detail = ""): void { + results.push({ line: `${ok ? "PASS" : "FAIL"} ${label}${detail ? " " + detail : ""}`, ok }); + } + function finish(pageErrors: string[] = [], note = ""): never { + for (const r of results) console.log(r.line); + if (pageErrors.length > 0) { + console.log("\nPAGE ERRORS:"); + for (const e of pageErrors) console.log(" " + e); + } + const failed = results.filter((r) => !r.ok).length + pageErrors.length; + console.log( + `\n${failed === 0 ? "OK" : "FAILED"}: ${results.length} checks, ${failed} problem(s).${note ? " " + note : ""}`, + ); + process.exit(failed === 0 ? 0 : 1); + } + return { check, finish }; +} diff --git a/client/src/dialog-editor/test/harness/edit-behavior.mts b/client/src/dialog-editor/test/harness/edit-behavior.mts new file mode 100644 index 000000000..a1f580458 --- /dev/null +++ b/client/src/dialog-editor/test/harness/edit-behavior.mts @@ -0,0 +1,162 @@ +/** + * Selection / add / edit behavior driver: exercises the unified `select()` primitive and the shared + * add/remove paths in the PRODUCTION webview (real App via postMessage, driven in Chromium) - the behaviors + * that drifted before they were routed through one setter (DialogGraph.svelte). SSR can't drive these (they + * are interaction + component state), so this is their guard. D REAL_MODEL is flat + editable; branch-scoped + * add (if/else bundle) shares the identical `select({on: "option-edit"})` path and is exercised live on SSL. + * e2e-tier: not part of `pnpm test`. Prereqs: Playwright + a Chromium browser on PATH. + * + * pnpm exec tsx client/src/dialog-editor/test/harness/edit-behavior.mts + */ +import { chromium } from "playwright"; +import { REAL_MODEL } from "./real-model"; +import { harnessPaths, makeChecker } from "./driver-util"; + +const { appHtml } = harnessPaths(import.meta.url); + +const { check, finish } = makeChecker(); + +const browser = await chromium.launch(); +const page = await browser.newPage({ viewport: { width: 1300, height: 900 } }); +const errs: string[] = []; +page.on("pageerror", (e) => errs.push(String(e))); + +async function fresh(): Promise { + await page.goto("file://" + appHtml); + await page.evaluate((m) => window.postMessage({ type: "model", model: m }, "*"), REAL_MODEL); + await page.waitForSelector('[role="treeitem"]', { timeout: 10_000 }); +} +const state = (): Promise<{ stsel: number; repsel: number; editing: number; rename: number; tgt: number }> => + page.evaluate(() => ({ + stsel: document.querySelectorAll(".st.sel").length, + repsel: document.querySelectorAll(".rep.repsel").length, + editing: document.querySelectorAll(".rtext.rtextedit, input.rtextedit").length, + rename: document.querySelectorAll("input.nameedit").length, + tgt: document.querySelectorAll(".lf.tgt").length, + })); + +await fresh(); +check("inline-target options always show a target label", (await state()).tgt > 0, `tgt=${(await state()).tgt}`); + +// add-child "+" selects the NEW STATE (not the connecting option). +await fresh(); +const nodesBefore = await page.locator(".st[data-sid]").count(); +await page.locator(".st[data-sid]").first().hover(); // reveal the hover-only add button +await page.locator('button[title^="Add a follow-up node"]').first().click({ force: true }); +// Wait for the add to land: a new state node exists and it is the selected one (not an option in edit). +await page + .waitForFunction( + (n) => + document.querySelectorAll(".st[data-sid]").length === n && + document.querySelectorAll(".st.sel").length === 1, + nodesBefore + 1, + { timeout: 5000 }, + ) + .catch(() => undefined); +const e = await state(); +check( + "add-child selects the NEW STATE, not an option in edit", + (await page.locator(".st[data-sid]").count()) === nodesBefore + 1 && e.stsel === 1 && e.editing === 0, + `stsel=${e.stsel} editing=${e.editing}`, +); + +// context-menu "Add option" selects + edits the new option (shared add path). +await fresh(); +await page.locator(".st[data-sid]").first().click({ button: "right" }); +await page.waitForSelector(".ctxitem", { timeout: 5000 }); +await page.locator(".ctxitem", { hasText: "Add option" }).first().click(); +await page + .waitForFunction( + () => + document.querySelectorAll(".rep.repsel").length === 1 && + document.querySelectorAll(".rtext.rtextedit, input.rtextedit").length === 1, + undefined, + { timeout: 5000 }, + ) + .catch(() => undefined); +const g = await state(); +check( + "context-menu Add option selects + edits the new option", + g.repsel === 1 && g.editing === 1, + `repsel=${g.repsel} editing=${g.editing}`, +); + +// removing the SELECTED option drops selection back to the owner state (no stale choiceId). +await fresh(); +const opt = page.locator(".rep.reprow").first(); +await opt.click(); +await page + .waitForFunction(() => document.querySelectorAll(".rep.repsel").length >= 1, undefined, { timeout: 5000 }) + .catch(() => undefined); +const before = await state(); +await opt.hover(); +await page.locator(".delopt").first().click(); +await page + .waitForFunction(() => document.querySelectorAll(".rep.repsel").length === 0, undefined, { timeout: 5000 }) + .catch(() => undefined); +check( + "removing the selected option leaves no stale option selection", + before.repsel >= 1 && (await state()).repsel === 0, + `repsel ${before.repsel}->${(await state()).repsel}`, +); + +// select() clears the rename mode when selection moves to another node. +await fresh(); +await page.locator(".nodeid.nodeidbtn").first().dblclick(); +await page + .waitForFunction(() => document.querySelectorAll("input.nameedit").length === 1, undefined, { timeout: 5000 }) + .catch(() => undefined); +const renaming = await state(); +await page.locator(".st[data-sid]").nth(1).click(); +await page + .waitForFunction(() => document.querySelectorAll("input.nameedit").length === 0, undefined, { timeout: 5000 }) + .catch(() => undefined); +check("starting a rename shows the rename input", renaming.rename === 1); +check("selecting another node clears the rename mode (no stale rename input)", (await state()).rename === 0); + +// inline option-text edit persists via the shared writeText path. +await fresh(); +await page.locator(".rep.reprow").first().dblclick(); +const input = page.locator("input.rtextedit").first(); +await input.waitFor({ timeout: 5000 }).catch(() => undefined); +if (await input.count()) { + await input.fill("EDIT BEHAVIOR CHECK"); + await input.press("Enter"); + await page + .waitForFunction(() => document.body.textContent?.includes("EDIT BEHAVIOR CHECK") ?? false, undefined, { + timeout: 5000, + }) + .catch(() => undefined); + check( + "inline option-text edit persists (writeText)", + await page.evaluate(() => document.body.textContent?.includes("EDIT BEHAVIOR CHECK") ?? false), + ); +} else { + check("inline option-text edit persists (writeText)", false, "no edit input appeared"); +} + +// An external text-side edit (host re-posts the SAME file's model through the `model` prop) is adopted IN PLACE +// and KEEPS the selection on its node - it no longer resets selection to null. Simulate it: select a state, then +// re-post the same model, and assert the state stays selected. (This is the reset effect's new same-file branch, +// the routing the live self-edit adopt shares.) +await fresh(); +await page.locator(".st[data-sid]").nth(1).click(); +await page + .waitForFunction(() => document.querySelector(".st.sel") !== null, undefined, { timeout: 5000 }) + .catch(() => undefined); +const beforeRepost = await page.evaluate(() => document.querySelector(".st.sel")?.getAttribute("data-sid") ?? null); +await page.evaluate((m) => window.postMessage({ type: "model", model: m }, "*"), REAL_MODEL); +// A same-file re-post carries the identical model, so it produces no distinct DOM signal to poll for; a bounded +// settle lets the adopt effect run before we re-read the selection (a condition poll here would no-op - the +// selected node is present both before and after). +await page.waitForTimeout(300); +const afterRepost = await page.evaluate(() => document.querySelector(".st.sel")?.getAttribute("data-sid") ?? null); +check( + "an external same-file re-post keeps the selection on its node (not reset to null)", + beforeRepost !== null && afterRepost === beforeRepost, + `selected ${beforeRepost} -> ${afterRepost}`, +); + +await browser.close(); + +finish(errs); diff --git a/client/src/dialog-editor/test/harness/edit-roundtrip.mts b/client/src/dialog-editor/test/harness/edit-roundtrip.mts new file mode 100644 index 000000000..1f62826d6 --- /dev/null +++ b/client/src/dialog-editor/test/harness/edit-roundtrip.mts @@ -0,0 +1,173 @@ +/** + * Edit round-trip driver: the PRODUCTION webview (real App via the real postMessage channel, driven in + * Chromium) wired to the real host session core (DialogHostCore via fake-host.ts) over an in-memory + * document - the full emit -> splice -> reparse -> adopt (+ editing overlay) -> .tra flush protocol. + * This is the only automated tier that exercises that protocol at all (the render/edit-behavior drivers + * run hostless), and it pins the once-live-only regression chain: a pending option's committed text must + * reach the .d splice (fresh-parse range anchoring), its emit must never be swallowed, + * and its minted `@N` must be APPENDED to the .tra, not just rewritten over existing entries. + * e2e-tier: not part of `pnpm test`. Prereqs: Playwright + a Chromium browser on PATH. + * + * pnpm exec tsx client/src/dialog-editor/test/harness/edit-roundtrip.mts + */ +import { chromium } from "playwright"; +import type { DialogModel } from "../../../../../shared/dialog-model"; +import { createFakeHost, currentModel } from "./fake-host"; +import { harnessPaths, makeChecker, pollUntil } from "./driver-util"; + +const { appHtml } = harnessPaths(import.meta.url); +const { check, finish } = makeChecker(); + +// A minimal but real D dialog: one state, one exit option, with the say text resolved from the .tra. +const D_SOURCE = `BEGIN ~roundtrip~ + +IF ~~ THEN BEGIN start + SAY @0 + IF ~~ THEN EXIT +END +`; +const TRA_SOURCE = `@0 = ~Hello there.~\n`; + +const browser = await chromium.launch(); +const page = await browser.newPage({ viewport: { width: 1300, height: 900 } }); +const pageErrors: string[] = []; +page.on("pageerror", (e) => pageErrors.push(String(e))); + +const host = await createFakeHost({ + documentPath: "/roundtrip.d", + docText: D_SOURCE, + traText: TRA_SOURCE, + postToWebview: (msg) => void page.evaluate((m) => window.postMessage(m, "*"), msg), +}); + +// Count of webview->host "edit" messages, to assert emit hygiene (no echo storm) after settling. +let editCount = 0; + +// The webview's outbound channel: host.ts captures acquireVsCodeApi at bundle init, so the init script +// (which runs before page scripts) provides it, forwarding into the Node-side core via the binding. +await page.exposeFunction("__hostPost", (msg: unknown) => { + const m = msg as { type?: string; model?: DialogModel; seq?: number }; + if (m?.type === "ready") host.core.handleReady(); + else if (m?.type === "edit" && m.model) { + editCount++; + host.core.handleEdit(m.model, m.seq ?? 0); + } + // revealSource/notify are vscode-surface concerns; the protocol under test does not need them. +}); +// A string, not a function: tsx's esbuild transform decorates a serialized function with a `__name` +// helper that does not exist inside the page, so the injected acquireVsCodeApi would THROW on first +// call - and host.ts's try/catch turns that into a silent hasHost()===false (no ready, no emits). +await page.addInitScript("window.acquireVsCodeApi = () => ({ postMessage: (m) => window.__hostPost(m) });"); + +await page.goto("file://" + appHtml); + +// The ready handshake itself is under test: main.ts posts {type:"ready"}, the core parses and posts the +// model, App mounts the tree with the .tra text joined. +await page.waitForSelector('[role="treeitem"]', { timeout: 10_000 }); +check( + "ready handshake: model arrives and the .tra say text renders", + await page.evaluate(() => document.body.textContent?.includes("Hello there.") ?? false), +); + +/** Add an option on the first state via the context menu and commit `text` in the inline editor. */ +async function addOptionWithText(text: string): Promise { + await page.locator(".st[data-sid]").first().click({ button: "right" }); + await page.waitForSelector(".ctxitem", { timeout: 5000 }); + await page.locator(".ctxitem", { hasText: "Add option" }).first().click(); + const input = page.locator("input.rtextedit").first(); + await input.waitFor({ timeout: 5000 }); + await input.fill(text); + await input.press("Enter"); +} + +// --- The regression chain: + option -> type -> Enter -> the .d gains the spliced option and the .tra +// gains the minted entry. The structural emit fires while the inline editor is OPEN, the text commit +// fires after - the exact live sequence that once lost the text. +await addOptionWithText("Round trip works."); +check( + "committed option is spliced into the .d with a minted @N", + await pollUntil(() => /\+\+ @1 EXIT/.test(host.doc.text)), + JSON.stringify(host.doc.text), +); +check( + "minted @N text is APPENDED to the .tra by the debounced flush", + await pollUntil(() => host.tra.text.includes("@1 = ~Round trip works.~")), + JSON.stringify(host.tra.text), +); +check("existing .tra entries survive the flush byte-for-byte", host.tra.text.startsWith(TRA_SOURCE)); + +// --- Multi-invocation: a second option against the state the first sequence just mutated. The carried-over +// state (fresh ids, new ranges from the reparse) is exactly where stale-range anchoring used to break. +await addOptionWithText("Second option here."); +check( + "second option splices with the next id against the mutated document", + await pollUntil(() => /\+\+ @2 EXIT/.test(host.doc.text)), + JSON.stringify(host.doc.text), +); +check( + "second minted entry appends too", + await pollUntil(() => host.tra.text.includes("@2 = ~Second option here.~")), + JSON.stringify(host.tra.text), +); +check( + "first option is spliced exactly once (no pending re-splice duplicate)", + (host.doc.text.match(/\+\+ @1 EXIT/g) ?? []).length === 1, + JSON.stringify(host.doc.text), +); + +// --- The editing overlay: an adopt lands while the inline editor is open with an uncommitted draft. +// The pending option has EMPTY model text, so its structural emit is a deliberate no-op splice (the +// writer defers empty pending options - no `++ ~~ EXIT` husk) and the adopted parse cannot contain it: +// the webview must carry the row AND the DOM draft across the adopt (the job the old reconcile branch +// existed for). The adopt is driven the external-edit way (a plain same-file model post through App). +const emitsBeforeOverlay = editCount; +await page.locator(".st[data-sid]").first().click({ button: "right" }); +await page.waitForSelector(".ctxitem", { timeout: 5000 }); +await page.locator(".ctxitem", { hasText: "Add option" }).first().click(); +const overlayInput = page.locator("input.rtextedit").first(); +await overlayInput.waitFor({ timeout: 5000 }); +await overlayInput.fill("Overlay draft"); +// Wait for the structural emit to fire (debounced) and round-trip through the host, then assert it did NOT +// splice a husk - tie the wait to the emit actually landing rather than guessing a debounce+round-trip budget. +await pollUntil(() => editCount > emitsBeforeOverlay); +check("an empty pending option does not splice a `++ ~~` husk", !host.doc.text.includes("~~ EXIT"), host.doc.text); +// External same-file adopt while the draft is open (what a text-side edit does in production). The adopt +// carries the identical model, so it yields no distinct DOM signal to poll for (the input is present before +// AND after); a bounded settle lets the adopt effect run before we assert the draft survived it. +await page.evaluate((m) => window.postMessage({ type: "model", model: m }, "*"), currentModel(host, "roundtrip")); +await new Promise((r) => setTimeout(r, 400)); +check( + "inline editor survives the adopt with its draft intact", + (await page.locator("input.rtextedit").count()) === 1 && + (await page.locator("input.rtextedit").inputValue()) === "Overlay draft", + `value=${JSON.stringify( + await page + .locator("input.rtextedit") + .inputValue() + .catch(() => "(gone)"), + )}`, +); +check( + "the draft input keeps focus across the adopt", + await page.evaluate(() => document.activeElement?.classList.contains("rtextedit") ?? false), +); +await page.locator("input.rtextedit").first().press("End"); +await page.keyboard.type(" survives."); +await page.locator("input.rtextedit").first().press("Enter"); +check( + "the draft commits after the adopt: .d gains the option, .tra its text", + (await pollUntil(() => /\+\+ @3 EXIT/.test(host.doc.text))) && + (await pollUntil(() => host.tra.text.includes("@3 = ~Overlay draft survives.~"))), + JSON.stringify(host.tra.text), +); + +// --- Emit hygiene: once everything settles, no further edits may fire (an echo loop would keep the +// count climbing: model post -> emit -> reparse -> emit -> ...). +await host.core.drainEdits(); +const settledCount = editCount; +await new Promise((r) => setTimeout(r, 900)); // > EMIT_DEBOUNCE_MS + flush debounce +check("no echo loop: edit count stable after settling", editCount === settledCount, `edits=${editCount}`); +check("no host-side errors surfaced", host.errors.length === 0, host.errors.join(" | ")); + +await browser.close(); +finish(pageErrors); diff --git a/client/src/dialog-editor/test/harness/fake-host.ts b/client/src/dialog-editor/test/harness/fake-host.ts new file mode 100644 index 000000000..269be6959 --- /dev/null +++ b/client/src/dialog-editor/test/harness/fake-host.ts @@ -0,0 +1,77 @@ +/** + * In-memory host for the round-trip harness driver (edit-roundtrip.mts): the REAL DialogHostCore + * (the session logic panel.ts runs in production) bound to an in-memory document and .tra instead + * of the VS Code runtime and the LSP server. Parsing goes through the real server-side D parser, + * splicing through the real computeDialogSourceEdit (inside the core), and the .tra write mirrors + * the server's - so the emit -> splice -> reparse -> adopt -> flush protocol runs whole + * under automated tests, which no other tier covers (the render drivers have no host at all). + */ + +import { DialogHostCore, type DialogHostIO } from "../../host-core"; +import { initParser } from "../../../../../shared/parsers/weidu-d"; +import { parseDDialog } from "../../../../../server/src/weidu-d/dialog"; +import { appendTraEntries, rewriteTraEntries } from "../../../../../shared/dialog-tra-edit"; +import { modelFromD, type DialogMessages, type DialogModel } from "../../../../../shared/dialog-model"; +import { parseTra } from "./driver-util"; + +export interface FakeHost { + core: DialogHostCore; + /** The in-memory dialog source document (what a WorkspaceEdit would splice in production). */ + doc: { text: string }; + /** The in-memory .tra the debounced message flush writes through to. */ + tra: { text: string }; + /** Every showError surfaced by the core - a clean run ends with this empty. */ + errors: string[]; + /** Every message the core posted to the webview (model/reparse/error), for protocol assertions. */ + posted: unknown[]; +} + +export async function createFakeHost(opts: { + documentPath: string; + docText: string; + traText: string; + /** Deliver a host->webview message into the page (window.postMessage in the driver). */ + postToWebview: (msg: unknown) => void; +}): Promise { + await initParser(); + const doc = { text: opts.docText }; + const tra = { text: opts.traText }; + const errors: string[] = []; + const posted: unknown[] = []; + const io: DialogHostIO = { + getText: () => doc.text, + // Mirrors the LSP_COMMAND_PARSE_DIALOG D arm (server/src/handlers/execute-command.ts): the parsed + // dialog data plus the resolved messages. The harness joins the in-memory .tra directly instead of + // running the server's Translation resolution, which needs a workspace it does not have here. + requestParse: async () => ({ data: { ...parseDDialog(doc.text), messages: parseTra(tra.text) } }), + replaceText: async (newText) => { + doc.text = newText; + return true; + }, + postToWebview: (msg) => { + posted.push(msg); + opts.postToWebview(msg); + }, + showError: (message) => { + errors.push(message); + }, + // Mirrors the .tra arm of Translation.writeMessages (server/src/translation.ts): rewrite the + // entries that exist, then append the newly-minted ids. + saveMessages: async (messages: DialogMessages) => { + tra.text = appendTraEntries(rewriteTraEntries(tra.text, messages), messages); + }, + }; + return { core: new DialogHostCore(io, opts.documentPath), doc, tra, errors, posted }; +} + +/** + * The DialogModel a plain (non-reparse) host post would carry for the CURRENT in-memory document - what + * the production host sends on an external text-side edit. `sourceName` must match the open model's for + * the webview's same-file adopt branch to engage (the core derives it from the document path). + */ +export function currentModel(host: FakeHost, sourceName: string): DialogModel { + const model = { ...modelFromD(parseDDialog(host.doc.text)), sourceLang: "d" as const }; + model.messages = { ...model.messages, ...parseTra(host.tra.text) }; + model.sourceName = sourceName; + return model; +} diff --git a/client/src/dialog-editor/test/harness/gen-real-model.ts b/client/src/dialog-editor/test/harness/gen-real-model.ts new file mode 100644 index 000000000..194da1462 --- /dev/null +++ b/client/src/dialog-editor/test/harness/gen-real-model.ts @@ -0,0 +1,34 @@ +// Harness helper: parse a real WeiDU D fixture into a DialogModel and emit it as +// real-model.ts for the render harness. Run from the repo root: +// pnpm exec tsx client/src/dialog-editor/test/harness/gen-real-model.ts +// Not part of any test target; regenerate when the fixture or adapter changes. +import fs from "node:fs"; +import path from "node:path"; +import { initParser } from "../../../../../shared/parsers/weidu-d"; +import { parseDDialog } from "../../../../../server/src/weidu-d/dialog"; +import { modelFromD } from "../../../../../shared/dialog-model"; +import { parseTra } from "./driver-util"; + +const here = __dirname; +const repo = path.resolve(here, "../../../../.."); +const fixture = path.join(repo, "external/infinity-engine/BG1NPC/bg1npc/phase2/dlg/x#bri.d"); +const traFile = path.join(repo, "external/infinity-engine/BG1NPC/bg1npc/tra/english/x#bri.tra"); + +async function main(): Promise { + await initParser(); + const text = fs.readFileSync(fixture, "utf8"); + const model = modelFromD(parseDDialog(text)); + // Resolution normally happens in the client (panel.ts) before render; the harness + // joins the fixture's own .tra so the review sees real dialogue, not @N tokens. + model.messages = parseTra(fs.readFileSync(traFile, "utf8")); + const banner = `// Auto-generated by gen-real-model.ts from ${path.relative(repo, fixture)}. Do not hand-edit.\n`; + const body = `import type { DialogModel } from "../../../../../shared/dialog-model";\n\nexport const REAL_MODEL: DialogModel = ${JSON.stringify(model, null, 4)};\n`; + fs.writeFileSync(path.join(here, "real-model.ts"), banner + body); + const roots = model.roots.map((r) => `${r.kind}:${r.label}(${r.states.length})`).join(", "); + console.log(`wrote real-model.ts: ${model.roots.length} roots [${roots}]`); +} + +main().catch((e: unknown) => { + console.error(e); + process.exit(1); +}); diff --git a/client/src/dialog-editor/test/harness/harness-main.ts b/client/src/dialog-editor/test/harness/harness-main.ts new file mode 100644 index 000000000..d707be3c9 --- /dev/null +++ b/client/src/dialog-editor/test/harness/harness-main.ts @@ -0,0 +1,9 @@ +// Run the PRODUCTION webview entry (main.ts): mount the root App.svelte and post the "ready" +// handshake. App holds the posted model in a Svelte $state proxy and passes that proxy down to +// DialogGraph - the exact path the live webview takes. The render drivers deliver the model through +// the real `window.postMessage` channel App listens on (see render.mts) with no host attached +// (postToHost no-ops), while edit-roundtrip.mts injects acquireVsCodeApi so the ready/emit protocol +// runs against the fake host too. Mounting DialogGraph with a raw object (the old harness) skipped +// all of that and gave false-positive screenshots while the live editor was stuck on "Parsing +// dialog...". +import "../../webview/main"; diff --git a/client/src/dialog-editor/test/harness/real-model.ts b/client/src/dialog-editor/test/harness/real-model.ts new file mode 100644 index 000000000..0e46918ed --- /dev/null +++ b/client/src/dialog-editor/test/harness/real-model.ts @@ -0,0 +1,1184 @@ +// Auto-generated by gen-real-model.ts from external/infinity-engine/BG1NPC/bg1npc/phase2/dlg/x#bri.d. Do not hand-edit. +import type { DialogModel } from "../../../../../shared/dialog-model"; + +export const REAL_MODEL: DialogModel = { + sourceLang: "d", + editable: true, + roots: [ + { + id: "dialog:%tutu_var%BRIELB", + label: "%tutu_var%BRIELB", + kind: "dialog", + states: [ + { + id: "16", + speaker: "%tutu_var%BRIELB", + text: "@2", + trigger: 'Global("HelpBrielbara","GLOBAL",1)', + choices: [ + { + id: "16#0", + condition: + 'Global("P#CoranBaby","GLOBAL",2) Global("P#Briel_Stay","GLOBAL",1) InParty("coran")', + action: 'SetGlobal("X#BriFinale","GLOBAL",1)', + target: { + kind: "state", + stateId: "returnBriel", + }, + }, + { + id: "16#1", + condition: + 'GlobalLT("P#CoranBaby","GLOBAL",2) Global("P#Briel_Stay","GLOBAL",1) InParty("coran")', + action: 'SetGlobal("X#BriFinale","GLOBAL",1)', + target: { + kind: "state", + stateId: "NamaraCor", + }, + }, + { + id: "16#2", + condition: 'Global("P#Briel_Stay","GLOBAL",2) InParty("coran")', + action: 'SetGlobal("X#BriFinale","GLOBAL",1)', + target: { + kind: "state", + stateId: "BrielCurse", + }, + }, + { + id: "16#3", + condition: 'Global("P#Briel_Stay","GLOBAL",3)', + action: 'SetGlobal("X#BriFinale","GLOBAL",1)', + target: { + kind: "state", + stateId: "CoranStay", + }, + }, + { + id: "16#4", + condition: 'Global("P#Briel_Stay","GLOBAL",1) !InParty("coran")', + action: 'SetGlobal("X#BriFinale","GLOBAL",1)', + target: { + kind: "exit", + }, + }, + { + id: "16#5", + condition: '!Global("P#Briel_Stay","GLOBAL",1) !InParty("coran")', + action: 'SetGlobal("X#BriFinale","GLOBAL",1)', + target: { + kind: "exit", + }, + }, + { + id: "16#6", + condition: 'GlobalGT("P#Briel_Stay","GLOBAL",1)', + action: 'SetGlobal("X#BriFinale","GLOBAL",1)', + target: { + kind: "exit", + }, + }, + ], + sourceRange: { + start: 374, + end: 1278, + }, + }, + { + id: "returnBriel", + speaker: "%tutu_var%BRIELB", + text: "@31", + trigger: + '%BGT_VAR% Global("P#Briel_Stay","GLOBAL",1) InParty("coran") InMyArea("coran") !StateCheck("coran",CD_STATE_NOTVALID)', + choices: [ + { + id: "returnBriel#0", + target: { + kind: "external", + label: "~%CORAN_JOINED%~:CBaby_Chain", + resolved: false, + }, + }, + ], + sourceRange: { + start: 6862, + end: 7066, + }, + }, + { + id: "BRIEL_PC", + speaker: "%tutu_var%BRIELB", + text: "@32", + choices: [ + { + id: "BRIEL_PC#0", + text: "@33", + action: 'SetGlobal("P#Briel_Stay","GLOBAL",4) GiveItemCreate("X#CBABY","coran",1,0,0) GiveItemCreate("X#CBOOK","coran",1,0,0) GiveItemCreate("X#COBAG","coran",1,0,0) GiveItemCreate("X#CMILK","coran",1,0,0)', + target: { + kind: "external", + label: "~%CORAN_JOINED%~:CoranRun", + resolved: false, + }, + }, + { + id: "BRIEL_PC#1", + text: "@34", + action: 'SetGlobal("P#Briel_Stay","GLOBAL",4) GiveItemCreate("X#CBABY","coran",1,0,0) GiveItemCreate("X#CBOOK","coran",1,0,0) GiveItemCreate("X#COBAG","coran",1,0,0) GiveItemCreate("X#CMILK","coran",1,0,0)', + target: { + kind: "external", + label: "~%CORAN_JOINED%~:CoranRun", + resolved: false, + }, + }, + { + id: "BRIEL_PC#2", + text: "@35", + action: 'SetGlobal("P#Briel_Stay","GLOBAL",4) GiveItemCreate("X#CBABY","coran",1,0,0) GiveItemCreate("X#CBOOK","coran",1,0,0) GiveItemCreate("X#COBAG","coran",1,0,0) GiveItemCreate("X#CMILK","coran",1,0,0)', + target: { + kind: "external", + label: "~%CORAN_JOINED%~:Grumbles", + resolved: false, + }, + }, + { + id: "BRIEL_PC#3", + text: "@36", + condition: 'Global("P#CoranRomancePath","GLOBAL",1)', + action: 'SetGlobal("P#Briel_Stay","GLOBAL",4) GiveItemCreate("X#CBABY","coran",1,0,0) GiveItemCreate("X#CBOOK","coran",1,0,0) GiveItemCreate("X#COBAG","coran",1,0,0) GiveItemCreate("X#CMILK","coran",1,0,0)', + target: { + kind: "external", + label: "~%CORAN_JOINED%~:Lover", + resolved: false, + }, + }, + { + id: "BRIEL_PC#4", + text: "@37", + action: "GiveGoldForce(-10000)", + target: { + kind: "state", + stateId: "OhMine", + }, + }, + ], + sourceRange: { + start: 7068, + end: 8235, + }, + }, + { + id: "OhMine", + speaker: "%tutu_var%BRIELB", + text: "@38", + choices: [ + { + id: "OhMine#0", + action: 'SetGlobal("P#Briel_Stay","GLOBAL",5) EscapeArea()', + target: { + kind: "exit", + }, + }, + ], + sourceRange: { + start: 8237, + end: 8338, + }, + }, + { + id: "NamaraCor", + speaker: "%tutu_var%BRIELB", + text: "@40", + choices: [ + { + id: "NamaraCor#0", + target: { + kind: "external", + label: "~%CORAN_JOINED%~:CoranMumbles", + resolved: false, + }, + }, + ], + sourceRange: { + start: 8340, + end: 8426, + }, + }, + { + id: "BrielCurse", + speaker: "%tutu_var%BRIELB", + text: "@41", + choices: [ + { + id: "BrielCurse#0", + target: { + kind: "external", + label: "~%CORAN_JOINED%~:CoranSigh", + resolved: false, + }, + }, + ], + sourceRange: { + start: 8428, + end: 8512, + }, + }, + { + id: "CoranStay", + speaker: "%tutu_var%BRIELB", + text: "@42", + choices: [ + { + id: "CoranStay#0", + target: { + kind: "exit", + }, + }, + ], + sourceRange: { + start: 8514, + end: 8568, + }, + }, + ], + }, + { + id: "dialog:%CORAN_JOINED%", + label: "%CORAN_JOINED%", + kind: "dialog", + states: [ + { + id: "CNRBabeBack", + speaker: "%CORAN_JOINED%", + text: "@3", + trigger: '%BGT_VAR% Global("P#CNRBabeBack","GLOBAL",1)', + weight: -2, + choices: [ + { + id: "CNRBabeBack#0", + action: 'LeaveParty() SetGlobal("%KICKED_OUT%","LOCALS",1) SetLeavePartyDialogFile() ChangeAIScript("",DEFAULT) SetGlobal("P#CNRBabeBack","GLOBAL",2) SetGlobal("P#CoranMoves","GLOBAL",4) SetGlobalTimer("P#Alimony","GLOBAL",604800)', + target: { + kind: "exit", + }, + }, + ], + sourceRange: { + start: 1933, + end: 2326, + }, + }, + { + id: "CORANBABY", + speaker: "%CORAN_JOINED%", + text: "@6", + trigger: '%BGT_VAR% Global("P#CoranBaby","GLOBAL",1)', + weight: -2, + choices: [ + { + id: "CORANBABY#0", + text: "@7", + action: 'SetGlobal("P#CoranBaby","GLOBAL",2) RealSetGlobalTimer("P#CFriendTalkTime","GLOBAL",1800)', + target: { + kind: "state", + stateId: "CB_1.1", + }, + }, + { + id: "CORANBABY#1", + text: "@8", + action: 'SetGlobal("P#CoranBaby","GLOBAL",2) SetGlobal("P#CoranRepeat","GLOBAL",1) RealSetGlobalTimer("P#CFriendTalkTime","GLOBAL",1800)', + target: { + kind: "state", + stateId: "CB_1.2", + }, + }, + { + id: "CORANBABY#2", + text: "@9", + action: 'SetGlobal("P#CoranBaby","GLOBAL",2) RealSetGlobalTimer("P#CFriendTalkTime","GLOBAL",1800)', + target: { + kind: "state", + stateId: "CB_1.3", + }, + }, + ], + sourceRange: { + start: 2612, + end: 3122, + }, + }, + { + id: "CB_1.1", + speaker: "%CORAN_JOINED%", + text: "@10", + choices: [ + { + id: "CB_1.1#0", + text: "@11", + target: { + kind: "state", + stateId: "CB_1.4", + }, + }, + { + id: "CB_1.1#1", + text: "@12", + target: { + kind: "state", + stateId: "CB_1.5", + }, + }, + { + id: "CB_1.1#2", + text: "@13", + target: { + kind: "state", + stateId: "CB_1.6", + }, + }, + ], + sourceRange: { + start: 3124, + end: 3258, + }, + }, + { + id: "CB_1.3", + speaker: "%CORAN_JOINED%", + text: "@14", + choices: [ + { + id: "CB_1.3#0", + text: "@11", + target: { + kind: "state", + stateId: "CB_1.4", + }, + }, + { + id: "CB_1.3#1", + text: "@12", + target: { + kind: "state", + stateId: "CB_1.5", + }, + }, + { + id: "CB_1.3#2", + text: "@15", + target: { + kind: "state", + stateId: "CB_1.6", + }, + }, + ], + sourceRange: { + start: 3260, + end: 3394, + }, + }, + { + id: "CB_1.2", + speaker: "%CORAN_JOINED%", + text: "@16", + choices: [ + { + id: "CB_1.2#0", + action: 'SetGlobal("P#CoranRepeat","GLOBAL",1)', + target: { + kind: "exit", + }, + }, + ], + sourceRange: { + start: 3396, + end: 3490, + }, + }, + { + id: "CB_1.4", + speaker: "%CORAN_JOINED%", + text: "@17", + choices: [ + { + id: "CB_1.4#0", + action: 'SetGlobal("P#CoranWillComplain","GLOBAL",1)', + target: { + kind: "exit", + }, + }, + ], + sourceRange: { + start: 3492, + end: 3592, + }, + }, + { + id: "CB_1.5", + speaker: "%CORAN_JOINED%", + text: "@18", + choices: [ + { + id: "CB_1.5#0", + action: 'ReputationInc(-1) ChangeAlignment("coran",CHAOTIC_NEUTRAL) SetGlobal("P#Briel_Stay","GLOBAL",2) SetGlobal("P#Briel_Stay","GLOBAL",2) SetGlobal("P#CoranRomancePath","GLOBAL",3)', + target: { + kind: "exit", + }, + }, + ], + sourceRange: { + start: 3594, + end: 3826, + }, + }, + { + id: "CB_1.6", + speaker: "%CORAN_JOINED%", + text: "@19", + choices: [ + { + id: "CB_1.6#0", + action: 'SetGlobal("P#Briel_Stay","GLOBAL",3) ReputationInc(-1) LeaveParty() SetLeavePartyDialogFile() ChangeAIScript("",DEFAULT) EscapeArea()', + target: { + kind: "exit", + }, + }, + ], + sourceRange: { + start: 3828, + end: 4018, + }, + }, + { + id: "CBABY_SECOND", + speaker: "%CORAN_JOINED%", + text: "@20", + trigger: '%BGT_VAR% Global("P#CBABY_AGAIN","GLOBAL",1)', + weight: -2, + choices: [ + { + id: "CBABY_SECOND#0", + text: "@7", + action: 'SetGlobal("P#CBABY_AGAIN","GLOBAL",2)', + target: { + kind: "state", + stateId: "DCB_1.1", + }, + }, + { + id: "CBABY_SECOND#1", + text: "@9", + action: 'SetGlobal("P#CBABY_AGAIN","GLOBAL",2)', + target: { + kind: "state", + stateId: "DCB_1.3", + }, + }, + ], + sourceRange: { + start: 4020, + end: 4269, + }, + }, + { + id: "DCB_1.1", + speaker: "%CORAN_JOINED%", + text: "@10", + choices: [ + { + id: "DCB_1.1#0", + text: "@11", + target: { + kind: "state", + stateId: "DCB_1.4", + }, + }, + { + id: "DCB_1.1#1", + text: "@12", + target: { + kind: "state", + stateId: "DCB_1.5", + }, + }, + { + id: "DCB_1.1#2", + text: "@21", + target: { + kind: "state", + stateId: "DCB_1.6", + }, + }, + ], + sourceRange: { + start: 4271, + end: 4409, + }, + }, + { + id: "DCB_1.3", + speaker: "%CORAN_JOINED%", + text: "@14", + choices: [ + { + id: "DCB_1.3#0", + text: "@11", + target: { + kind: "state", + stateId: "DCB_1.4", + }, + }, + { + id: "DCB_1.3#1", + text: "@12", + target: { + kind: "state", + stateId: "DCB_1.5", + }, + }, + { + id: "DCB_1.3#2", + text: "@15", + target: { + kind: "state", + stateId: "DCB_1.6", + }, + }, + ], + sourceRange: { + start: 4411, + end: 4549, + }, + }, + { + id: "DCB_1.4", + speaker: "%CORAN_JOINED%", + text: "@17", + choices: [ + { + id: "DCB_1.4#0", + action: 'SetGlobal("P#CoranWillComplain","GLOBAL",1)', + target: { + kind: "exit", + }, + }, + ], + sourceRange: { + start: 4551, + end: 4652, + }, + }, + { + id: "DCB_1.5", + speaker: "%CORAN_JOINED%", + text: "@18", + choices: [ + { + id: "DCB_1.5#0", + action: 'ReputationInc(-1) ChangeAlignment("coran",CHAOTIC_NEUTRAL) SetGlobal("P#Briel_Stay","GLOBAL",2) SetGlobal("P#CoranRomancePath","GLOBAL",3)', + target: { + kind: "exit", + }, + }, + ], + sourceRange: { + start: 4654, + end: 4850, + }, + }, + { + id: "DCB_1.6", + speaker: "%CORAN_JOINED%", + text: "@19", + choices: [ + { + id: "DCB_1.6#0", + action: 'SetGlobal("P#Briel_Stay","GLOBAL",3) ReputationInc(-1) LeaveParty() SetLeavePartyDialogFile() ChangeAIScript("",DEFAULT) EscapeArea()', + target: { + kind: "exit", + }, + }, + ], + sourceRange: { + start: 4852, + end: 5043, + }, + }, + { + id: "CB_COMPLAINT", + speaker: "%CORAN_JOINED%", + text: "@22", + trigger: '%BGT_VAR% Global("P#Briel_Stay","GLOBAL",1) Global("P#CB_COMPLAINS","GLOBAL",1)', + weight: -2, + choices: [ + { + id: "CB_COMPLAINT#0", + text: "@23", + action: 'SetGlobal("P#CB_COMPLAINS","GLOBAL",2)', + target: { + kind: "state", + stateId: "CB_2.1", + }, + }, + { + id: "CB_COMPLAINT#1", + text: "@24", + action: 'SetGlobal("P#CB_COMPLAINS","GLOBAL",2)', + target: { + kind: "state", + stateId: "CB_2.2", + }, + }, + { + id: "CB_COMPLAINT#2", + text: "@21", + action: 'SetGlobal("P#CB_COMPLAINS","GLOBAL",2)', + target: { + kind: "state", + stateId: "CB_2.3", + }, + }, + ], + sourceRange: { + start: 5045, + end: 5408, + }, + }, + { + id: "CB_2.1", + speaker: "%CORAN_JOINED%", + text: "@17", + choices: [ + { + id: "CB_2.1#0", + action: 'SetGlobal("P#CoranWillQuit","GLOBAL",1)', + target: { + kind: "exit", + }, + }, + ], + sourceRange: { + start: 5410, + end: 5506, + }, + }, + { + id: "CB_2.2", + speaker: "%CORAN_JOINED%", + text: "@18", + choices: [ + { + id: "CB_2.2#0", + action: 'ReputationInc(-1) ChangeAlignment("coran",CHAOTIC_NEUTRAL) SetGlobal("P#Briel_Stay","GLOBAL",2) SetGlobal("P#Briel_Stay","GLOBAL",2) SetGlobal("P#CoranRomancePath","GLOBAL",3)', + target: { + kind: "exit", + }, + }, + ], + sourceRange: { + start: 5508, + end: 5740, + }, + }, + { + id: "CB_2.3", + speaker: "%CORAN_JOINED%", + text: "@21", + choices: [ + { + id: "CB_2.3#0", + action: 'SetGlobal("P#Briel_Stay","GLOBAL",3) ReputationInc(-1) LeaveParty() SetLeavePartyDialogFile() ChangeAIScript("",DEFAULT) EscapeArea()', + target: { + kind: "exit", + }, + }, + ], + sourceRange: { + start: 5742, + end: 5932, + }, + }, + { + id: "CB_QUITS", + speaker: "%CORAN_JOINED%", + text: "@25", + trigger: '%BGT_VAR% Global("P#CB_QUITS","GLOBAL",1)', + weight: -2, + choices: [ + { + id: "CB_QUITS#0", + action: 'SetGlobal("P#CB_QUITS","GLOBAL",2) SetGlobal("P#Briel_Stay","GLOBAL",3) LeaveParty() SetLeavePartyDialogFile() ChangeAIScript("",DEFAULT) EscapeArea()', + target: { + kind: "exit", + }, + }, + ], + sourceRange: { + start: 5934, + end: 6196, + }, + }, + { + id: "CoranRun", + speaker: "%CORAN_JOINED%", + text: "@26", + choices: [ + { + id: "CoranRun#0", + action: 'SetGlobal("P#CoranHasBaby","GLOBAL",1) SetGlobalTimer("P#ReturnNamara","GLOBAL",151500)', + target: { + kind: "exit", + }, + }, + ], + sourceRange: { + start: 6198, + end: 6344, + }, + }, + { + id: "Grumbles", + speaker: "%CORAN_JOINED%", + text: "@27", + choices: [ + { + id: "Grumbles#0", + action: 'SetGlobal("P#CoranHasBaby","GLOBAL",1) SetGlobalTimer("P#ReturnNamara","GLOBAL",151500)', + target: { + kind: "exit", + }, + }, + ], + sourceRange: { + start: 6346, + end: 6492, + }, + }, + { + id: "Lover", + speaker: "%CORAN_JOINED%", + text: "@28", + choices: [ + { + id: "Lover#0", + action: 'SetGlobal("P#CoranHasBaby","GLOBAL",1) SetGlobalTimer("P#ReturnNamara","GLOBAL",151500)', + target: { + kind: "exit", + }, + }, + ], + sourceRange: { + start: 6494, + end: 6637, + }, + }, + { + id: "CoranMumbles", + speaker: "%CORAN_JOINED%", + text: "@29", + choices: [ + { + id: "CoranMumbles#0", + action: 'SetGlobal("P#CoranPrompted","GLOBAL",1)', + target: { + kind: "external", + label: "~%tutu_var%BRIELB~:BrielCurse", + resolved: false, + }, + }, + ], + sourceRange: { + start: 6639, + end: 6773, + }, + }, + { + id: "CoranSigh", + speaker: "%CORAN_JOINED%", + text: "@30", + choices: [ + { + id: "CoranSigh#0", + target: { + kind: "exit", + }, + }, + ], + sourceRange: { + start: 6775, + end: 6829, + }, + }, + { + id: "CBaby_Chain", + speaker: "%CORAN_JOINED%", + text: "@43", + choices: [ + { + id: "CBaby_Chain#0", + target: { + kind: "state", + stateId: "CBaby_Chain_1", + }, + }, + ], + derivedFrom: "CHAIN", + }, + { + id: "CBaby_Chain_1", + speaker: "%tutu_var%BRIELB", + text: "@44", + choices: [ + { + id: "CBaby_Chain_1#0", + target: { + kind: "state", + stateId: "CBaby_Chain_2", + }, + }, + ], + derivedFrom: "CHAIN", + }, + { + id: "CBaby_Chain_2", + speaker: "%CORAN_JOINED%", + text: "@45", + choices: [ + { + id: "CBaby_Chain_2#0", + target: { + kind: "state", + stateId: "CBaby_Chain_3", + }, + }, + ], + derivedFrom: "CHAIN", + }, + { + id: "CBaby_Chain_3", + speaker: "%tutu_var%BRIELB", + text: "@46", + choices: [ + { + id: "CBaby_Chain_3#0", + target: { + kind: "state", + stateId: "CBaby_Chain_4", + }, + }, + ], + derivedFrom: "CHAIN", + }, + { + id: "CBaby_Chain_4", + speaker: "%CORAN_JOINED%", + text: "@47", + choices: [ + { + id: "CBaby_Chain_4#0", + target: { + kind: "state", + stateId: "CBaby_Chain_5", + }, + }, + ], + derivedFrom: "CHAIN", + }, + { + id: "CBaby_Chain_5", + speaker: "%tutu_var%BRIELB", + text: "@48", + choices: [ + { + id: "CBaby_Chain_5#0", + target: { + kind: "state", + stateId: "CBaby_Chain_6", + }, + }, + ], + derivedFrom: "CHAIN", + }, + { + id: "CBaby_Chain_6", + speaker: "%CORAN_JOINED%", + text: "@49", + choices: [ + { + id: "CBaby_Chain_6#0", + target: { + kind: "state", + stateId: "CBaby_Chain_7", + }, + }, + ], + derivedFrom: "CHAIN", + }, + { + id: "CBaby_Chain_7", + speaker: "%tutu_var%BRIELB", + text: "@50", + choices: [ + { + id: "CBaby_Chain_7#0", + target: { + kind: "state", + stateId: "CBaby_Chain_8", + }, + }, + ], + derivedFrom: "CHAIN", + }, + { + id: "CBaby_Chain_8", + speaker: "%CORAN_JOINED%", + text: "@51", + choices: [ + { + id: "CBaby_Chain_8#0", + target: { + kind: "state", + stateId: "CBaby_Chain_9", + }, + }, + ], + derivedFrom: "CHAIN", + }, + { + id: "CBaby_Chain_9", + speaker: "%tutu_var%BRIELB", + text: "@52", + choices: [ + { + id: "CBaby_Chain_9#0", + target: { + kind: "state", + stateId: "CBaby_Chain_10", + }, + }, + ], + derivedFrom: "CHAIN", + }, + { + id: "CBaby_Chain_10", + speaker: "%CORAN_JOINED%", + text: "@53", + choices: [ + { + id: "CBaby_Chain_10#0", + target: { + kind: "state", + stateId: "CBaby_Chain_11", + }, + }, + ], + derivedFrom: "CHAIN", + }, + { + id: "CBaby_Chain_11", + speaker: "%tutu_var%BRIELB", + text: "@54", + choices: [ + { + id: "CBaby_Chain_11#0", + target: { + kind: "state", + stateId: "CBaby_Chain_12", + }, + }, + ], + derivedFrom: "CHAIN", + }, + { + id: "CBaby_Chain_12", + speaker: "%CORAN_JOINED%", + text: "@55", + choices: [ + { + id: "CBaby_Chain_12#0", + target: { + kind: "state", + stateId: "CBaby_Chain_13", + }, + }, + ], + derivedFrom: "CHAIN", + }, + { + id: "CBaby_Chain_13", + speaker: "%tutu_var%BRIELB", + text: "@56", + choices: [ + { + id: "CBaby_Chain_13#0", + target: { + kind: "state", + stateId: "CBaby_Chain_14", + }, + }, + ], + derivedFrom: "CHAIN", + }, + { + id: "CBaby_Chain_14", + speaker: "%CORAN_JOINED%", + text: "@57", + choices: [ + { + id: "CBaby_Chain_14#0", + target: { + kind: "state", + stateId: "CBaby_Chain_15", + }, + }, + ], + derivedFrom: "CHAIN", + }, + { + id: "CBaby_Chain_15", + speaker: "%tutu_var%BRIELB", + text: "@58", + choices: [ + { + id: "CBaby_Chain_15#0", + target: { + kind: "state", + stateId: "CBaby_Chain_16", + }, + }, + ], + derivedFrom: "CHAIN", + }, + { + id: "CBaby_Chain_16", + speaker: "%CORAN_JOINED%", + text: "@59", + choices: [ + { + id: "CBaby_Chain_16#0", + target: { + kind: "state", + stateId: "CBaby_Chain_17", + }, + }, + ], + derivedFrom: "CHAIN", + }, + { + id: "CBaby_Chain_17", + speaker: "%tutu_var%BRIELB", + text: "@60", + choices: [], + derivedFrom: "CHAIN", + }, + ], + }, + { + id: "dialog:%CORAN_POST%", + label: "%CORAN_POST%", + kind: "dialog", + states: [ + { + id: "CNRBack", + speaker: "%CORAN_POST%", + text: "@5", + trigger: '%BGT_VAR% Global("P#CoranMoves","GLOBAL",6)', + weight: -100, + choices: [ + { + id: "CNRBack#0", + action: 'SetGlobal("P#CoranMoves","GLOBAL",7) JoinParty()', + target: { + kind: "exit", + }, + }, + ], + sourceRange: { + start: 2420, + end: 2581, + }, + }, + ], + }, + { + id: "patch#0", + label: "REPLACE_SAY %CORAN_JOINED%", + kind: "patch", + states: [], + }, + { + id: "patch#1", + label: "REPLACE_SAY %tutu_var%BRIELB", + kind: "patch", + states: [], + }, + { + id: "patch#2", + label: "PATCH %tutu_var%BRIELB", + kind: "patch", + states: [], + }, + ], + messages: { + "0": "I'm sorry, Briel, but we... we have been over this already... and you have said, that...", + "1": "That you are an idiot. Go, if you want, but maybe someday you'll figure out what is it you really want, elf.", + "2": "I must thank you again for the service you have done.", + "3": ", it's becoming too dangerous to keep Namara around. Not to mention all the bad habits she picks up from our rag-tag band... I'll go talk to Briel.", + "4": 'I hope that I still have some influence over her... *grins* so it should not take me long to persuade her to take money and visits instead of me babysitting Namara on battlefields. Meet me back in the "Sturgeon", .', + "5": "Ah, here you are. Back to adventure!", + "6": "May I have a word with you, ?", + "7": "Of course, Coran, what is it?", + "8": "Yes, but not now.", + "9": "Coran... frankly, I have no desire to talk to you, after what you have done...", + "10": "I... I would like very much to go back and talk to Briel again. The girl might need help, and I feel that I have wronged her... Can we go meet with her?", + "11": "Coran, I am glad that you have decided to sort it out. All this made me feel awkward. We should go visit Briel as soon as we are able.", + "12": "Coran, are you kidding? The girl is blackmailing you! I mean, you never wanted a child, did you? Why do you need to waste your time? She wanted it, so she can take care of the whelp herself!", + "13": "Well, I am afraid that in this case we should part our company.", + "14": "I understand that, . I am unhappy with what I have said to Briel myself. The girl might need help, and I feel that I have wronged her... I... I would like very much to go back and talk to Briel again. Can we go meet with her?", + "15": "Well, I am afraid that in this case we should part our company.", + "16": "We will talk later then.", + "17": "Thank you, . I feel much better now.", + "18": "Indeed... I have never wanted a child, and a half-breed, at that. Half-bloods are always raised by their mothers. Besides, I just don't have the time or inclination to care for my... I mean, Briel's child.", + "19": "*sighs* You are right. See you in 20 years or so...", + "20": "I'd like to talk about Briel, .", + "21": "Well, I am afraid that in this case we should part our company. See you in 20 years or so...", + "22": "Ahem, , I know we are rather busy, but I really need to talk to Briel... Can we go see her now?", + "23": "We should go visit Briel as soon as we are able.", + "24": "Coran, I like this idea less and less. The girl is just blackmailing you! I mean you never wanted a child, did you? Why do you need to waste your time? She wanted it, so she can take care of the whelp herself.", + "25": "Briel is not going to wait forever! It is apparent that you have things more important to attend to than the fate of my daughter. I do not. See you in 20 years or so...", + "26": "Thank you, . I am no coward, but when a baby's life is at stake... I will run for it.", + "27": "Erm... . It cannot be that bad, can it?", + "28": "I... I am glad that you see it that way. It is a bastard child, after all.", + "29": "I... I... Briel... I do not know.", + "30": "*sighs* I am sorry, beautiful, but there is really nothing I can do for you or your child.", + "31": "Coran?", + "32": "Thank you, again, .", + "33": "Brielbara, I promise you, that if it gets hot, Coran will run and save the child.", + "34": "You are a mad woman, Brielbara, but we'll try to keep Namara safe and well.", + "35": "Great, just great. That's *just* what we need.", + "36": "You have done well, my love.", + "37": "That would not do at all. Brielbara, Coran is my sworn sword, and as his liege , I accept the responsibility for his past misdeeds. As he has no inclination to marry you, and I do not wish to force him in this matter, I would like to pay you 10,000 gold as a compensation for his dishonoring you. I trust that it is enough for you to live comfortably until you've arranged your affairs.", + "38": "I... I am... This is very generous, my ....", + "39": "Coran, I wish... I wish it could have been different, but I can't make you love me, not without my sorcery. *smiles softly* And who needs love instigated by magic?", + "40": "Coran? Do not you even want to see Namara? She has your eyes... elven eyes.", + "41": "I curse the day I laid my eyes on you, Coran.", + "42": "And... thank you for sending that no good thief my way. *sighs dreamily* He is not *that* bad, actually.", + "43": "Ahem, Briel... I did a lot of thinking and... I've come to apologize, Briel...", + "44": "Apologize? What do I need an apology for? Would it put food on Namara's plate and clothes on her back? Or should I sing your apology as a lullaby for her, when she will not stop crying for her father?", + "45": "Eh, Briel, slow down! I came to offer my help as well. I know, that we disagree about my... hmm... lifestyle, and that you are too noble to be a thief's mate...", + "46": "Coran, why must you be so incredibly stubborn about it? You are going to end up in prison or hanged for a bandit, silly Elf. I know you have a better heart than that... why would not you...", + "47": "Briel, Briel... Please, let us spare the further details of our favorite argument. The City of Baldur's Gate needs something newer than that to listen to. I am not cut out to become a merchant or some other respectable member of society.", + "48": "A pity...", + "49": "Maybe. Briel, I promise you that every time I come into money, Namara and you will have...", + "50": "I see. Coran, I feel that you are sincere, and that you do want to help. However, I am afraid that will not be enough, not with your peculiar notion on what responsibility is. I have an idea, though, on how you can help.", + "51": "What would you have me do, Briel? I agree to anything, except for settling down, of course.", + "52": "It has been rough going for us lately with Namara sick and my husband plotting against us... He took it all, Coran! House, money, everything. I can hardly get by, and I am so tired... Would you take Namara with you and take care of her for a while to give me some time to get back on my feet?", + "53": "Huh, Briel... are you sure? I lack knowledge on baby care. I doubt that I can... you know... feed the baby and... and do whatever else you have to do. Actually, I know nothing of babies at all.", + "54": "Then it is past time that you learnt. She is now at an age when she does not require breast feeding, and I have here a book of helpful hints...", + "55": "Briel! Not that I refuse, but won't it be dangerous for a child to travel with me? We are constantly fighting for our lives and...", + "56": "It is no more dangerous for her in your company than in that place in the bowels of this city I keep her now... I never know that she won't be sold to some slavers or killed out of spite by some lowlife while I am away... Coran, desperate people cannot be choosy.", + "57": "Would you want to travel with me then? I am sure ...", + "58": "No, Coran, I will not be a part of a mercenary group, and I would not go about robbing people with you. My reputation is ruined as is. If you want to help - take care of Namara for a bit...", + "59": "Well, if that's what you want me to do, I will do it.", + "60": "Good. Thank you, Coran. And do not look so sour, you might come to enjoy it, after all. Here is the book, and I will bring Namara in a moment.", + }, +}; diff --git a/client/src/dialog-editor/test/harness/render-search.mts b/client/src/dialog-editor/test/harness/render-search.mts new file mode 100644 index 000000000..43a6482cd --- /dev/null +++ b/client/src/dialog-editor/test/harness/render-search.mts @@ -0,0 +1,190 @@ +/** + * Find-bar driver: exercises the tree search feature in the PRODUCTION webview (real App.svelte mounted via + * postMessage, driven interactively in Chromium), the path unit/SSR tests can't reach - the always-visible + * find-bar, find-as-you-type highlighting + match count, Enter/Shift+Enter navigation with wraparound, Ctrl+F + * focusing the box, Escape clearing the query, and reveal of a collapsed match. Search is pure webview (no + * host/LSP/save), so the harness (which has no host) covers it fully. e2e-tier: not part of `pnpm test`. + * + * pnpm exec tsx client/src/dialog-editor/test/harness/render-search.mts [out.png] + * + * Prereqs are environmental, not repo deps: Playwright + a Chromium browser on PATH. + */ +import { chromium } from "playwright"; +import path from "node:path"; +import { REAL_MODEL } from "./real-model"; +import { harnessPaths, makeChecker } from "./driver-util"; + +const { appHtml, outDir } = harnessPaths(import.meta.url); +const shot = process.argv[2] ?? path.join(outDir, "dialog-harness-search.png"); + +const { check, finish } = makeChecker(); + +const browser = await chromium.launch(); +const page = await browser.newPage({ viewport: { width: 1100, height: 700 } }); +const pageErrors: string[] = []; +page.on("pageerror", (e) => pageErrors.push(String(e))); + +async function postModel(): Promise { + await page.evaluate((model) => window.postMessage({ type: "model", model }, "*"), REAL_MODEL); +} +const countText = (): Promise => page.locator(".findcount").innerText(); + +await page.goto("file://" + appHtml); +await postModel(); +await page.waitForSelector('[role="treeitem"]', { timeout: 10_000 }); + +// --- 1. The find-bar is always visible in tree view; find-as-you-type highlights matches + shows a count --- +check("find-bar is present without any toggle (always visible)", (await page.locator(".findinput").count()) === 1); + +// "Coran" appears in several lines/options of the real dialogue - enough to test navigation. +await page.locator(".findinput").click(); +await page.locator(".findinput").pressSequentially("Coran", { delay: 20 }); +await page + .waitForFunction( + () => { + const t = document.querySelector(".findcount")?.textContent ?? ""; + const m = /^1\/(\d+)$/.exec(t); + return !!m && Number(m[1]) > 1; + }, + undefined, + { timeout: 5000 }, + ) + .catch(() => undefined); +const count1 = await countText(); +const total = Number(count1.split("/")[1] ?? "0"); +check( + 'typing "Coran" shows a match count with more than one match', + /^1\/\d+$/.test(count1) && total > 1, + `count=${count1}`, +); +// The displayed input value must stay in sync with what was typed. This caught a real bug: find-as-you-type +// moves the tree selection, and Tree's focus-follows-selection effect yanked DOM focus onto the match row, so +// every character after the first was lost (input showed "C" for "Coran"). Fixed by gating that effect with +// searchActive; this pins that focus stays in the field and the whole query registers. +const shown = await page.locator(".findinput").inputValue(); +const active = await page.evaluate(() => document.activeElement?.classList.contains("findinput") ?? false); +check( + "the input keeps focus + the full typed text (no dropped characters)", + shown === "Coran" && active, + `shown="${shown}" focused=${active}`, +); +const hits = await page.locator(".searchhit").count(); +const current = await page.locator(".searchcurrent").count(); +check( + "all matches are highlighted (searchhit) with exactly one current (searchcurrent)", + hits >= total && current === 1, + `hits=${hits} current=${current} total=${total}`, +); +// Capture at match 1/N (a top row) so the amber hit-wash + current-match outline are both visible in frame. +await page.screenshot({ path: shot }); + +// The current match is also the selected row (selection stays coupled to the current match). +const currentIsSelected = await page.evaluate(() => { + const el = document.querySelector(".searchcurrent"); + return Boolean(el && (el.classList.contains("sel") || el.classList.contains("repsel"))); +}); +check("the current match is also the selected row (focus + selection coupled)", currentIsSelected); + +// --- 2. Enter advances; Shift+Enter wraps back to the last ---------------------------------------------- +const keyOf = (): Promise => + page.evaluate( + () => + document.querySelector(".searchcurrent")?.getAttribute("data-sid") ?? + document.querySelector(".searchcurrent")?.getAttribute("data-choice") ?? + null, + ); +const firstKey = await keyOf(); +await page.locator(".findinput").press("Enter"); +await page + .waitForFunction((exp) => (document.querySelector(".findcount")?.textContent ?? "") === exp, `2/${total}`, { + timeout: 5000, + }) + .catch(() => undefined); +const count2 = await countText(); +const secondKey = await keyOf(); +check( + "Enter advances to the next match (2/N) on a different row", + count2 === `2/${total}` && secondKey !== firstKey, + `count=${count2}`, +); + +await page.locator(".findinput").press("Shift+Enter"); // back to 1 +await page.locator(".findinput").press("Shift+Enter"); // wrap to last (N) +await page + .waitForFunction((exp) => (document.querySelector(".findcount")?.textContent ?? "") === exp, `${total}/${total}`, { + timeout: 5000, + }) + .catch(() => undefined); +const count3 = await countText(); +check( + "Shift+Enter wraps backward past the first to the last match (N/N)", + count3 === `${total}/${total}`, + `count=${count3}`, +); + +// --- 3. Escape clears the query (the bar stays visible) -------------------------------------------------- +await page.locator(".findinput").press("Escape"); +await page + .waitForFunction(() => (document.querySelector(".findinput") as HTMLInputElement | null)?.value === "", undefined, { + timeout: 5000, + }) + .catch(() => undefined); +check( + "Escape clears the query but keeps the always-visible bar", + (await page.locator(".findinput").inputValue()) === "" && (await page.locator(".findinput").count()) === 1, +); +check("clearing the query drops all match highlights", (await page.locator(".searchhit").count()) === 0); + +// --- 4. Ctrl+F focuses the always-visible bar (webview key path, not the browser's own find) ------------- +await page.goto("file://" + appHtml); +await postModel(); +await page.waitForSelector('[role="treeitem"]', { timeout: 10_000 }); +await page.locator('[role="treeitem"]').first().click(); // move focus into the tree first +await page.keyboard.press("Control+f"); +await page + .waitForFunction(() => document.activeElement?.classList.contains("findinput") ?? false, undefined, { + timeout: 5000, + }) + .catch(() => undefined); +check( + "Ctrl+F focuses the find input", + await page.evaluate(() => document.activeElement?.classList.contains("findinput") ?? false), +); + +// --- 5. A collapsed match is revealed (ancestors un-collapsed) when navigated to ------------------------ +// Collapse the whole tree, then search + navigate: the current match's row must render (reveal ran). +await page.getByRole("button", { name: "Collapse all" }).click(); +await page.locator(".findinput").click(); +await page.locator(".findinput").pressSequentially("Coran", { delay: 20 }); +await page + .waitForFunction( + () => { + const el = document.querySelector(".searchcurrent") as HTMLElement | null; + if (!el) return false; + const r = el.getBoundingClientRect(); + const cs = getComputedStyle(el); + return r.width > 0 && r.height > 0 && cs.visibility !== "hidden" && cs.display !== "none"; + }, + undefined, + { timeout: 5000 }, + ) + .catch(() => undefined); +const revealedVisible = await page.locator(".searchcurrent").first().isVisible(); +check("navigating to a match inside a collapsed subtree reveals it (row is in the DOM and visible)", revealedVisible); + +// --- 6. Tree-render invariant: node ids stay DIMMED, incl. the editable + + + {#if editModel.editable || editModel.sourceLang === "ssl" || editModel.sourceLang === "tssl" || editModel.sourceLang === "td"} + + {/if} + {#if inGraph} + + + {/if} + {#if editModel.sourceLang === "d"} + + {/if} + +{/snippet} + +{#snippet sourceBox()} +
{sourceText}
+{/snippet} + +{#snippet issuesBox()} +
+ {#if issues.length === 0} +
No issues found
+ {/if} + {#each issues as iss, i (i)} +
{iss}
+ {/each} +
+{/snippet} + +{#snippet inspectorBox(s: DialogState)} + +{/snippet} + + + +
+ {#if tabs.length > 1} +
+ {#each tabs as t (t.id)} + + {/each} +
+ {/if} + +
+ +
+ +
+ + Beta. Send feedback to + https://github.com/BGforgeNet/BGforge-MLS/issues + +
+ +
+ {@render toolbar(viewMode === "graph")} + {#if viewMode === "tree"} + + + + {/if} + {#if editModel.editable || editModel.sourceLang === "ssl" || editModel.sourceLang === "tssl" || editModel.sourceLang === "td"} + + {/if} +
+ {#if viewMode === "tree"} + +
+ + (searchInputFocused = true)} + onblur={() => (searchInputFocused = false)} + onkeydown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + if (e.shiftKey) prevMatch(); + else nextMatch(); + } else if (e.key === "Escape") { + e.preventDefault(); + clearSearch(); + } + }} + /> + + {#if searchQuery.trim() === ""} +   + {:else if searchMatches.length === 0} + No matches + {:else} + {searchIndex + 1}/{searchMatches.length} + {/if} + + + + +
+ {/if} +
+ {#if viewMode === "tree"} + +
+ + Up/Down move + Left/Right fold + Enter/E edit line + F2 rename node + G go to target + F4 source + Ctrl+F find + Del delete + +
+ {/if} +
+ {#if unresolvedRefs > 0} + +
+ {unresolvedRefs} message ref{unresolvedRefs === 1 ? "" : "s"} show as @N - translations aren't resolved. + Point the {traHint.pathWord} path in .bgforge.yml (mls.translation.directory, e.g. {traHint.dirExample}) + or add a /** @tra name.{traHint.ext} */ comment as the source file's first line. +
+ {/if} +
+
+ {#if viewMode === "graph"} + + + + + + + + + + + {#if containerW >= MINIMAP_MIN_W} + + {/if} + + +
+ player reply + continue + exit + {renderFamily(editModel.sourceLang) === "fallout-ssl" ? "unresolved" : "extern"} +
+
+
+ {:else} +
+
+ {#if treeData.roots.length === 0} +
No states in this dialog file.
+ {/if} + +
+ {#if ctxMenu} + + + {/if} +
+ {/if} +
+ + {#if selected || showSource || showIssues} + + {/if} +
+ {#if confirmDelete} + +
+
+ Delete {confirmDelete.state.id}? {confirmDelete.refCount} + transition{confirmDelete.refCount === 1 ? "" : "s"} pointing here will be redirected to + EXIT. +
+
+ + +
+
+ {/if} + {#if nameModal} + + + + {/if} +
+ + diff --git a/client/src/dialog-editor/webview/Inspector.svelte b/client/src/dialog-editor/webview/Inspector.svelte new file mode 100644 index 000000000..ede0774d0 --- /dev/null +++ b/client/src/dialog-editor/webview/Inspector.svelte @@ -0,0 +1,1043 @@ + + +
+ {#if focusedChoice} + +
+ + + option #{focusedIndex + 1} +
+ {:else} + +
{stateHeadLabel(state, sourceName)}
+ + {#if state.derivedFrom} +
+ Read-only - this state is expanded from a {state.derivedFrom} block. It has no + standalone source to edit here; change it in the {state.derivedFrom} source directly. +
+ {:else if ssl && structuralEditable && state.branches} +
+ Text edits save to the {textFile}; each branch's condition, option retarget, + and add/remove/reorder options write back to the source file. + Branch side-effects are source-only - edit the source file for those. +
+ {:else if ssl && structuralEditable} +
+ Text edits save to the {textFile}; structure - rename, retarget, + reorder, add/remove options - writes back to the source file. + A condition is editable here when it belongs to one option; a condition shared by + several options is source-only (edit the source file). +
+ {:else if ssl && state.approximate} + +
+ Approximate view. This node uses control flow (a loop or switch) the editor can't fully + model, so the tree shown is a simplification - not everything here is represented. Read the + source file for the full logic. Text edits still save to the {textFile}. +
+ {:else if ssl} +
+ Text edits save to the {textFile}. The dialog structure (options, targets, + conditions) is read-only - this node is not simple enough to edit safely from the graph; + edit the source file for that. +
+ {:else if !structuralEditable} + +
+ Text edits save to the {textFile}. The dialog structure is read-only - this state uses a + conditional branch (an if/else) the editor can't fully model yet; edit the + source file for that. +
+ {/if} + + +
{ssl ? "State" : !structuralEditable && readOnly ? "State label (read-only)" : "State label (jump target)"}
+ actions.rename(e.currentTarget.value)} /> + + {#if !state.branches && !state.block} + +
NPC line
+ {@const npc = textEdit(null)} + + + {#if state.sayTexts && state.sayTexts.length > 1} + {#each state.sayTexts.slice(1) as _line, idx (idx)} + {@const i = idx + 1} + {@const sl = sayLineEditability({ text: state.sayTexts[i], messages, ssl, textRO: Boolean(state.derivedFrom), derivedFrom: state.derivedFrom })} +
NPC line (cont. {i + 1})
+ + {/each} + {/if} + {/if} + + {#if ssl} + + {#if !state.branches && !state.block} + +
Condition
+ + + {#if state.sideEffects?.length} +
Side effects
+
{state.sideEffects.join(", ")}
+ {/if} + {/if} + {:else} +
+
+
Trigger
+ (state.trigger = e.currentTarget.value.trim() === "" ? undefined : e.currentTarget.value)} /> +
+
+
Weight
+ setWeight(e.currentTarget.value)} /> +
+
+ {/if} + + +
Options ({state.choices.length})
+ {/if} + + {#snippet choiceRow(c: DialogChoice, i: number, bi?: number, branchLen?: number, labeled?: boolean)} + {@const oe = textEdit(c)} + +
+
+ #{i + 1} + {#if structuralEditable || !readOnly} + + {#if bi !== undefined} + + {#if structuralEditable} + + + + {/if} + {:else} + + + {#if structuralEditable && !state.branches} + + + {/if} + {#if !readOnly} + + {:else if structuralEditable && !state.branches} + + + {/if} + {/if} + + {/if} +
+ {#if labeled}
Option text
{/if} + + + {#if !state.branches} + {#if labeled}
Condition
{/if} + + + + {#if ssl && c.conditionEditable === false && !state.structured && !state.approximate} +
shared by other options - edit in source file
+ {/if} + {/if} + {#if !ssl && !state.branches} + {#if labeled}
Action
{/if} + + {/if} + + {#if labeled}
Target
{/if} + + {#if ssl && c.reaction !== undefined} + +
+ Reaction + + +
+ {/if} +
+ {/snippet} + + {#if focusedChoice} + {@render choiceRow(focusedChoice, focusedIndex, undefined, undefined, true)} + {:else if state.branches} + {#each state.branches as b, bi (bi)} + {@const bcs = branchChoices(b)} +
+ {#if b.kind === "else"} +
+ [else] + {#if structuralEditable} + + + {/if} +
+ {:else} +
+ [if] + (b.condition = e.currentTarget.value.trim() === "" ? undefined : e.currentTarget.value)} + /> + {#if structuralEditable} + + {/if} +
+ {/if} + {#if b.replies.length > 0} +
NPC
+ {#each b.replies as r} +
{resolveText(r.text, messages) || "(no line)"}
+ {/each} + {/if} + {#each bcs as c, bci (c.id)} + {@render choiceRow(c, bci, bi, bcs.length)} + {/each} + {#if structuralEditable && b.insertAnchor} + + {/if} + {#if b.opaque.length > 0} +
side effects ({b.opaque.length}) + {#each b.opaque as line}
{line}
{/each} +
+ {/if} +
+ {/each} + {#if structuralEditable} + +
+ + +
+ + {#if state.branches.length === 1 && state.branches[0]?.kind === "if"} + + {/if} + {/if} + {:else if ssl && state.block} + + {#each inspectorBranches as br (br.key)} +
+
+ {br.kind === "else" ? "[else]" : br.kind === "if" ? "[if]" : "(always)"} + {#if br.condition}{br.condition}{/if} +
+ {#if br.npc}
{br.npc}
{/if} + {#each br.choices as c (c.id)} +
+ {resolveText(c.text, messages) || "(continue)"} + → {targetLabel(c.target)} + {#if c.condition}
{c.condition}
{/if} +
+ {/each} +
+ {/each} + {:else} + {#each state.choices as c, i (c.id)} + {@render choiceRow(c, i)} + {/each} + {#if structuralEditable} + + {/if} + {/if} + + + {#if !focusedChoice} +
Referenced by ({callers.length})
+ {#if callers.length === 0} +
Nothing in this file reaches this state (an orphan / unreachable node).
+ {:else} +
+ {#each callers as ref, i (i)} + {#if ref.fromStateId} + + {:else} +
{ref.label}
+ {/if} + {/each} +
+ {/if} + + {#if !readOnly} + +
+ + {#if deletable}{/if} +
+ {:else if ssl && structuralEditable} + +
+ + {#if deletable}{/if} +
+ {/if} + {/if} +
+ + diff --git a/client/src/dialog-editor/webview/LowIntChip.svelte b/client/src/dialog-editor/webview/LowIntChip.svelte new file mode 100644 index 000000000..a7071c9f6 --- /dev/null +++ b/client/src/dialog-editor/webview/LowIntChip.svelte @@ -0,0 +1,24 @@ + + +{#if lowIq}INT-{/if} + + diff --git a/client/src/dialog-editor/webview/Node.svelte b/client/src/dialog-editor/webview/Node.svelte new file mode 100644 index 000000000..5ec7d001c --- /dev/null +++ b/client/src/dialog-editor/webview/Node.svelte @@ -0,0 +1,294 @@ + + +{#if type === "card" && data.state} + {@const sb = stateBadges(data.state)} + {@const pending = isPendingState(data.state)} +
+ +
+ {stateHeadLabel(data.state, data.sourceName)} + + + {#if data.reachability === "orphan"}dead{/if} + {#if data.reachability === "external-entry"}entry{/if} + {#if data.state.isEntry}start{/if} + {#if data.sharedText}shared{/if} + {#if pending}unsaved{/if} + {#if data.state.weight != null}W{data.state.weight}{/if} +
+
+ {resolveText(data.state.text, data.messages) || "(no line)"} + + {#if data.state.sayTexts && data.state.sayTexts.length > 1}+{data.state.sayTexts.length - 1} more{/if} +
+ {#each data.state.choices as c (c.id)} + {@const cb = choiceBadges(c)} +
+ + {#if cb.length}{/if} + {resolveText(c.text, data.messages) || + (c.target.kind === "exit" ? "(exit)" : "(continue)")} + + +
+ {/each} +
+{:else if type === "external"} +
+ ↷ {data.label}{#if data.jumpTo}{/if} +
+{:else if type === "combat"} + +
COMBAT
+{:else} +
EXIT
+{/if} + + diff --git a/client/src/dialog-editor/webview/ReconnectEdge.svelte b/client/src/dialog-editor/webview/ReconnectEdge.svelte new file mode 100644 index 000000000..a1479a075 --- /dev/null +++ b/client/src/dialog-editor/webview/ReconnectEdge.svelte @@ -0,0 +1,23 @@ + + + +{#if !locked} + +{/if} diff --git a/client/src/dialog-editor/webview/Tree.svelte b/client/src/dialog-editor/webview/Tree.svelte new file mode 100644 index 000000000..4f112af69 --- /dev/null +++ b/client/src/dialog-editor/webview/Tree.svelte @@ -0,0 +1,1226 @@ + + +
+ {#each tree.roots as st (st.id)} + {@render stateBlock(st, 0)} + {/each} +
+ +{#snippet stateBlock(st: ConvState, depth: number)} + {@const hasChildren = st.replies.length > 0 || (st.branches?.length ?? 0) > 0 || (st.block?.length ?? 0) > 0} + +
onSelect(st.id)} + oncontextmenu={(e) => (e.preventDefault(), onContext(st.id, e.clientX, e.clientY))} + onkeydown={(e) => onRowKeydown(e, st)} + > + {#if hasChildren} + + {:else} + + {/if} + + {#if st.id === renamingStateId} + e.stopPropagation()} + ondblclick={(e) => e.stopPropagation()} + onkeydown={(e) => (e.stopPropagation(), onEditKeydown(e))} + onblur={(e) => onRenameBlur(st.id, e)} + /> + {:else if editableStateIds.has(st.id)} + + {:else} + {st.id} + {/if} + + {#if st.speaker}{st.speaker}{/if} + {#if st.derivedFrom}{/if} + + {#if st.approximate}{/if} + + {#if st.trigger && !st.block && !st.branches}[if]{/if} + + {#if !st.branches && (!st.block || st.block[0]?.kind === "line")} + {#if st.id === editingStateId && st.textEditable} + e.stopPropagation()} + ondblclick={(e) => e.stopPropagation()} + onkeydown={(e) => (e.stopPropagation(), onEditKeydown(e))} + onblur={(e) => onStateEditBlur(st.id, e)} + /> + {:else if st.textEditable} + + + {:else} + {st.text || "(no line)"} + {/if} + {:else} + + if / else + {/if} + + {#if editableStateIds.has(st.id)} + {@const canDel = deletableStateIds.has(st.id)} + + + {#if !st.branches && !st.block} + + {/if} + + + {/if} +
+ {#if !collapsed.has(st.id)} + + {#if st.sayLines} + {#each st.sayLines as line, li (li)} +
{line || "(no line)"}
+ {/each} + {/if} + {#if st.block} + + {@render convBlock(st.block[0]?.kind === "line" ? st.block.slice(1) : st.block, depth, st.id)} + {:else if st.branches} + {#each st.branches as b, bi (bi)} + {@render branchBlock(b, depth, st.id)} + {/each} + {:else} + {#each st.replies as r, i (r.id)} + {@render replyRow(r, depth, st.id, i, st.replies.length, false)} + {/each} + + {#if editableStateIds.has(st.id)} +
+ +
+ {/if} + {/if} + {/if} +{/snippet} + +{#snippet branchBlock(b: ConvBranch, depth: number, ownerId: string)} + +
+ {#if b.condition}{b.kind === "else" ? "[else]" : "[if]"}{/if} + + +
+ {#each b.replies as r, i (r.id)} + {@render replyRow(r, depth, ownerId, i, b.replies.length, true)} + {/each} +{/snippet} + + +{#snippet convBlock(block: ConvBlock, depth: number, ownerId: string)} + {#each block as it, i (i)} + {#if it.kind === "line"} +
+ + {#if it.condition}{it.isElse ? "[else]" : "[if]"}{/if} + + +
+ {:else if it.kind === "reply"} + {@render replyRow(it.reply, depth, ownerId, i, block.length, true)} + {:else if it.kind === "group"} + {@render convBlock(it.thenBlock, depth, ownerId)} + {#if it.elseBlock} + {@render convBlock(it.elseBlock, depth, ownerId)} + {/if} + {/if} + {/each} +{/snippet} + +{#snippet replyRow(r: ConvReply, depth: number, ownerId: string, index: number, count: number, branchReadonly: boolean)} + + +
onSelectReply(ownerId, r.id)} + ondblclick={() => r.textEditable && onBeginEditReply(ownerId, r.id)} + onkeydown={(e) => onReplyRowKeydown(e, ownerId, r)} + oncontextmenu={branchReadonly + ? undefined + : (e) => (e.preventDefault(), onReplyContext(ownerId, r.id, index, count, e.clientX, e.clientY))} + > + + + + {#if r.condition}[if]{/if} + {#if r.id === editingChoiceId && r.textEditable} + + e.stopPropagation()} + ondblclick={(e) => e.stopPropagation()} + onkeydown={(e) => (e.stopPropagation(), onEditKeydown(e))} + onblur={(e) => onEditBlur(ownerId, r.id, e)} + /> + {:else} + + {r.hasText ? r.text || "(empty option)" : "(continue)"} + {/if} + {#if r.action}[do]{/if} + {@render leaf(r)} + + {#if !branchReadonly && editableStateIds.has(ownerId)} + {@const blocked = ssl && Boolean(r.condition)} + + {/if} +
+ {#if r.target.kind === "state"} + {@render stateBlock(r.target.node, depth + 1)} + {/if} +{/snippet} + +{#snippet leaf(r: ConvReply)} + {#if r.target.kind === "exit"} + {@const t = r.target} + EXIT + {:else if r.target.kind === "combat"} + {@const t = r.target} + COMBAT + {:else if r.target.kind === "ref"} + + {:else if r.target.kind === "external"} + {@const t = r.target} + {#if t.jump} + + {:else} + ↗ {t.label} + {/if} + {:else if r.target.kind === "state"} + + {@const t = r.target} + + {/if} +{/snippet} + + diff --git a/client/src/dialog-editor/webview/app-messages.ts b/client/src/dialog-editor/webview/app-messages.ts new file mode 100644 index 000000000..49424b8b5 --- /dev/null +++ b/client/src/dialog-editor/webview/app-messages.ts @@ -0,0 +1,34 @@ +/** + * Pure message-handling kernel for App.svelte (the webview root). + * + * App holds {model, error, timedOut} as reactive $state and is otherwise DOM wiring; this + * module holds the branching that decides what each host message does, so it is unit-tested + * without a Svelte runtime (dialog-app-messages.test.ts). These are the fail-loud/never-hang + * decisions: a model arrives and we render (clearing any error), an error arrives and we show + * it (keeping the last model), a malformed message changes nothing, and "neither arrived yet" + * is the timeout condition. `timedOut` itself is a timer concern that stays in the component. + */ + +import type { DialogModel } from "../../../../shared/dialog-model"; + +export interface DialogView { + model: DialogModel | null; + error: string | null; +} + +/** Next view-state for an incoming `window.message` payload; unchanged on anything unrecognized. */ +export function reduceDialogView(prev: DialogView, data: unknown): DialogView { + const msg = data as { type?: string; reparse?: boolean; model?: DialogModel; message?: string } | null; + // A `reparse:true` post is the host adopting a self-edit's faithful parse: DialogGraph handles it directly + // (it must preserve the current selection / an in-progress inline edit), so the root must NOT route it + // through the model prop - that would reset the view. Only a plain `{type:"model"}` (initial load / external + // text-side edit) updates the root's model. + if (msg?.type === "model" && msg.model && !msg.reparse) return { model: msg.model, error: null }; + if (msg?.type === "error" && msg.message) return { model: prev.model, error: msg.message }; + return prev; +} + +/** True when the host round-trip has produced neither a model nor an error - the spinner is stuck. */ +export function shouldTimeOut(view: DialogView): boolean { + return !view.model && !view.error; +} diff --git a/client/src/dialog-editor/webview/autosize.ts b/client/src/dialog-editor/webview/autosize.ts new file mode 100644 index 000000000..ed8f032da --- /dev/null +++ b/client/src/dialog-editor/webview/autosize.ts @@ -0,0 +1,30 @@ +/** + * Svelte action: grow a ` + * + * TIMING RULE (why `update` defers to a microtask): an action or effect that MEASURES laid-out DOM + * (`scrollHeight`, `getBoundingClientRect`, `offsetWidth`, ...) after a reactive change must never assume the + * element already holds the new value. Svelte can run the action's `update` BEFORE it writes the bound `value` + * to the element, so a synchronous measure reads the STALE content and the size lags one change behind (a + * one-line value rendering in a two-line box with an empty line). Deferring the fit to a microtask runs it after + * the DOM value has settled. The general form: defer any DOM-reading action/effect to a microtask/tick, or + * derive the measurement from the reactive value - do not measure the DOM synchronously at update time. + */ +import type { Action } from "svelte/action"; + +// The action parameter is the display VALUE (a reactivity trigger so a value change re-fits, not just +// keystrokes); the action reads it only through Svelte's change detection, not directly. Typed via `Action` +// so `use:autosize={value}` type-checks (a bare function signature would report the parameter as an extra arg). +export const autosize: Action = (el) => { + const fit = (): void => { + el.style.height = "auto"; + el.style.height = `${el.scrollHeight}px`; + }; + fit(); + el.addEventListener("input", fit); + return { update: () => queueMicrotask(fit), destroy: () => el.removeEventListener("input", fit) }; +}; diff --git a/client/src/dialog-editor/webview/conversation-tree.ts b/client/src/dialog-editor/webview/conversation-tree.ts new file mode 100644 index 000000000..62f8da536 --- /dev/null +++ b/client/src/dialog-editor/webview/conversation-tree.ts @@ -0,0 +1,376 @@ +/** + * Build a conversation-flow tree for one dialog file (root). + * + * The graph view shows the FSM as a node graph; the tree view shows it the way + * the dialog actually plays: rooted at the entry state(s), an NPC line expands + * into its player replies, each reply into the next NPC state, recursively. + * + * Cycles and shared sub-trees are collapsed by "first expansion wins": a state + * is fully expanded the first time it is reached; every later reference to it + * becomes a `ref` leaf (a clickable link to the expanded copy). That keeps the + * tree finite (no infinite loop on a cycle) and compact (no exponential + * re-expansion of a hub reached from many places). + * + * Pure and presentation-free: text is resolved for display, but the cross-file + * jump resolution is injected so this module stays decoupled from the editor's + * root maps (and stays trivially testable). + */ +import { + resolveText, + sslTerminalKind, + type DialogBlock, + type DialogChoice, + type DialogReaction, + type DialogRoot, + type DialogState, +} from "../../../../shared/dialog-model"; +import { isPendingChoice, isPendingState, textEditability } from "./inspector-edit"; +import type { JumpTarget } from "./jump-resolve"; + +export type ConvTarget = + /** First expansion of a same-file state: its sub-tree renders inline. */ + | { kind: "state"; node: ConvState } + /** A same-file state already expanded elsewhere: a link back to it. */ + | { kind: "ref"; stateId: string } + /** Terminal exit. `nodeId` is set (SSL) when this is an option reaching the Node999 end node, shown as a tooltip. */ + | { kind: "exit"; nodeId?: string } + /** Terminal combat (SSL Node998); `nodeId` (always "Node998") is shown as a tooltip. */ + | { kind: "combat"; nodeId: string } + /** Cross-file target; `jump` is set when it resolves to another tab. */ + | { kind: "external"; label: string; jump?: JumpTarget }; + +export interface ConvReply { + /** Choice id (stable key). */ + id: string; + /** Resolved player reply text; empty for a direct continue/call. */ + text: string; + /** Whether this is a player reply (has text) vs. a silent continue. */ + hasText: boolean; + condition?: string; + action?: string; + /** SSL option reaction (G/N/B) and low-INT variant, for the same chip the graph card shows. */ + reaction?: DialogReaction; + lowIq?: boolean; + /** Whether this option's text can be edited inline in the tree - the same gate the inspector's text + field uses (textEditability): false for a locked SSL @N or a read-only/derived node. */ + textEditable: boolean; + /** True for an option that exists in the webview's optimistic model but is not yet in the source parse - a + just-added option before the reparse adopts it, or an empty option deferred until its text commits. The + tree/card render it as an unsaved draft. Absent (not false) for a committed option. */ + pending?: boolean; + target: ConvTarget; + /** Byte offset of this option's statement in the source (SSL `callRange`/`stmtRange`, or the first call + site for a `call` transition; WeiDU D `sourceRange`), for "go to source". Absent for a pending/synthetic + option. */ + sourceOffset?: number; + /** Path key of the branch this option sits in (set for options inside an if/else node - see + stampBranchKeys). Drives the tree's branch highlight: clicking a branch line highlights every row whose + branchKey starts with the clicked branch's key. Absent for a flat (unbranched) node's options. */ + branchKey?: string; +} + +/** + * One item in a recursive conversation block (the `structured` tier - see `DialogBlock`). Unlike the flat + * `replies`/`branches`, a block mirrors the source `if`/`else` nesting: a `line` is an NPC reply line for its + * scope, a `reply` is a player option/transition row, a `group` is a nested condition shown once at its own + * level (the reader composes an option's full gate from the groups above it). Opaque side-effect statements + * are dropped from the tree (surfaced via the node's side-effect badge). Recursive via `group`. + */ +export type ConvBlockItem = + // `isElse` marks a branch's OPENING line that runs on the negation of its `if` (the else branch), so the + // tree can label it `[else]` rather than `[if]`; `condition` still carries the full `not (...)` for the tooltip. + // `branchKey` is the path key of the branch this line/reply belongs to (see stampBranchKeys) - clicking a + // branch line highlights every row whose branchKey is under (starts with) that key. + | { kind: "line"; npc: string; npcHasText: boolean; condition?: string; isElse?: boolean; branchKey?: string } + | { kind: "reply"; reply: ConvReply } + | { kind: "group"; condition: string; thenBlock: ConvBlock; elseBlock?: ConvBlock }; + +export type ConvBlock = ConvBlockItem[]; + +/** One condition-branch of a bundle (if/else) state: its own NPC line and the replies it shows. */ +export interface ConvBranch { + kind: "if" | "else"; + /** Present for an `if` branch; absent for the `else`. */ + condition?: string; + /** Resolved NPC line shown when this branch is active. */ + npc: string; + npcHasText: boolean; + replies: ConvReply[]; + /** Path key identifying this branch, for the tree's branch highlight (see stampBranchKeys). */ + branchKey?: string; +} + +export interface ConvState { + id: string; + /** Real speaker name (WeiDU D character). Absent for SSL - the row then shows only the (dimmed) id. + The tree does NOT use the SSL file-name fallback the card/inspector do: down a single-file tree the + base name repeats on every row (one SSL script is one NPC), so it is redundant noise there. */ + speaker?: string; + /** Resolved NPC line (the first SAY line of a multisay state). */ + text: string; + /** The CONTINUATION SAY lines of a WeiDU D multisay state (`SAY @a = @b = @c`), resolved, in source order - + lines 2..N, since line 1 is `text`. Absent for a single-say state. The tree renders them as a sequence + after `text` so a monologue is not truncated to its first line (the pre-existing display gap). */ + sayLines?: string[]; + trigger?: string; + /** Set for a CHAIN/INTERJECT/EXTEND-derived (read-only) state. */ + derivedFrom?: string; + /** Set for an SSL `approximate` node: its flat render is a lossy simplification (control flow the block + model can't represent), so the row carries an "approx" warning badge. */ + approximate?: boolean; + /** Flat replies; empty when `branches` is set (a bundle node groups its replies per branch). */ + replies: ConvReply[]; + /** Set for an SSL if/else bundle node: each branch carries its own NPC line + replies, so the + tree reflects the branch structure instead of flattening to the if-branch line + all options. */ + branches?: ConvBranch[]; + /** Set for an SSL `structured` node (arbitrarily nested if/else): the recursive block the tree renders + instead of the flat replies, so each condition shows once at its own nesting level. Read-only. */ + block?: ConvBlock; + /** True for a state that exists in the webview's optimistic model but is not yet in the source parse (a + just-added node before the reparse adopts it). The tree/card render it as an unsaved draft. Absent (not + false) for a committed state. */ + pending?: boolean; + /** True for a top-level state (no incoming same-file transition). */ + isEntry: boolean; + /** Whether this state's NPC line can be edited inline in the tree - the same gate the inspector's NPC + field uses (textEditability over the state's own text): false for a locked SSL @N or a read-only/ + derived node. Mirrors ConvReply.textEditable for the option text. */ + textEditable: boolean; + /** Byte offset of this state's source (SSL `procRange`, or D `sourceRange`), for "go to source". + Absent for a synthetic/derived state or a pending new node with no source span. */ + sourceOffset?: number; +} + +export interface ConversationTree { + roots: ConvState[]; +} + +export type ResolveJump = (label: string) => JumpTarget | undefined; + +/** + * Stamp a branch path key onto every line/reply of a structured node's block, so the tree can highlight a + * whole branch on click. Rows at the node's top level (not inside any if/else) stay unkeyed. Each group's + * then/else block gets a distinct key (`#Nif` / `#Nelse`, nested as `...if.Melse`), and rows inherit + * the key of the innermost branch they sit in. Because a nested branch's key STARTS WITH its parent's, a + * prefix-match on the clicked key highlights the branch AND everything nested under it. Ids never contain + * `#`/`.`, so the prefix test cannot cross between sibling branches. + */ +function stampBranchKeys(block: ConvBlock, base: string, branch?: string): void { + let gi = 0; + for (const it of block) { + if (branch) { + if (it.kind === "line") it.branchKey = branch; + else if (it.kind === "reply") it.reply.branchKey = branch; + } + if (it.kind === "group") { + const thenKey = branch ? `${branch}.${gi}if` : `${base}#${gi}if`; + const elseKey = branch ? `${branch}.${gi}else` : `${base}#${gi}else`; + stampBranchKeys(it.thenBlock, base, thenKey); + if (it.elseBlock) stampBranchKeys(it.elseBlock, base, elseKey); + gi++; + } + } +} + +export function buildConversationTree( + root: DialogRoot, + messages: Record | undefined, + resolveJump: ResolveJump, + // Edit-gating context for each reply's `textEditable`, feeding the same `textEditability` gate the inspector uses. + // `fieldEditable` is the SAME per-state predicate the inspector and graph use - a `.td` state can be + // field-editable even though the model's blanket `editable` is false, so consuming it here makes the tree's + // text lock match the inspector instead of diverging on the model-level flag (the old `editable` boolean). + // Optional (defaults to an editable file) so the pure-projection tests can stay 3-arg; the editor always + // passes real values. Destructured with per-key defaults rather than an object-literal default param (which + // oxlint's no-object-as-default-parameter rightly flags - a shared mutable default). + opts?: { ssl: boolean; fieldEditable: (s: DialogState) => boolean }, +): ConversationTree { + const { ssl = false, fieldEditable = () => true } = opts ?? {}; + // SSL convention: Node998/Node999 are terminal Combat/Exit targets, not drawn conversation nodes + // (SSL_TERMINAL_NODES). Exclude them from the states the tree draws; buildTarget maps an option targeting + // them to a terminal chip instead. Non-SSL formats keep every state. + const drawn = ssl ? root.states.filter((s) => !sslTerminalKind(s.id)) : root.states; + const byId = new Map(drawn.map((s) => [s.id, s])); + + // A state is an entry if no same-file transition targets it. + const targeted = new Set(); + for (const s of drawn) { + for (const c of s.choices) { + if (c.target.kind === "state" && byId.has(c.target.stateId)) targeted.add(c.target.stateId); + } + } + + const shown = new Set(); + + const buildTarget = (c: DialogChoice): ConvTarget => { + if (c.target.kind === "state" && ssl) { + // Map a target to a reserved support node to its terminal chip (Node999 -> Exit, Node998 -> Combat), + // carrying the underlying id as a tooltip. Checked before the byId lookup below (the support node is + // not in `byId`, so it would otherwise fall through to the cross-file "external" branch). + const terminal = sslTerminalKind(c.target.stateId); + if (terminal === "exit") return { kind: "exit", nodeId: c.target.stateId }; + if (terminal === "combat") return { kind: "combat", nodeId: c.target.stateId }; + } + if (c.target.kind === "exit") return { kind: "exit" }; + if (c.target.kind === "external") + return { kind: "external", label: c.target.label, jump: resolveJump(c.target.label) }; + // kind === "state" + const stateId = c.target.stateId; + if (!byId.has(stateId)) { + // A goto whose target is not in this file - a cross-file link, like EXTERN. + return { kind: "external", label: stateId, jump: resolveJump(stateId) }; + } + if (shown.has(stateId)) return { kind: "ref", stateId }; + return { kind: "state", node: expand(byId.get(stateId)!.id) }; + }; + + const buildReply = (c: DialogChoice, textRO: boolean, owner: DialogState): ConvReply => ({ + id: c.id, + text: resolveText(c.text, messages), + hasText: Boolean(c.text), + condition: c.condition, + action: c.action, + reaction: c.reaction, + lowIq: c.lowIq, + textEditable: textEditability({ state: owner, choice: c, messages, ssl, textRO }).editable, + ...(isPendingChoice(c) ? { pending: true } : {}), + target: buildTarget(c), + // SSL spans first (callRange/stmtRange/callSite); WeiDU D carries its whole-transition span in + // `sourceRange` (the SSL fields are absent for D), so F4 resolves on a D option too - parity with the + // state case below, which already falls back to `sourceRange`. + sourceOffset: + c.callRange?.start ?? c.stmtRange?.start ?? c.callSites?.[0]?.stmtRange.start ?? c.sourceRange?.start, + }); + + function expand(id: string): ConvState { + const s = byId.get(id)!; + shown.add(id); + // This state's text-read-only flag, fed to the shared `textEditability` gate. A derived state is fully + // read-only; the tree ALSO locks a non-field-editable D-family state (view-only D, or an unfaithful TD + // node). NOTE: the inspector's textRO is only `Boolean(derivedFrom)` - it leaves that unfaithful-TD text + // editable (a .tra edit is structure-independent). That divergence is intentional-until-decided: whether + // the D/TD writer actually persists a .tra edit on an unfaithful state is the open question that would + // settle which view is right (see textEditability's doc). SSL text persists to the .msg, so it stays + // editable subject to the per-@N resolvability gate inside the shared decision. + const textRO = Boolean(s.derivedFrom) || (!fieldEditable(s) && !ssl); + // A bundle (if/else) node groups its replies per branch, each with its own NPC line. Build the + // branch structure instead of the flat choice list so the tree mirrors the graph/inspector + // (otherwise the else branch's NPC line and the per-branch grouping are lost). Each choice is + // expanded exactly once - through the branch path here, never also as a flat reply - so a + // target is not double-marked "shown". + // A `structured` node (arbitrarily nested if/else) renders as a recursive block instead: build it from + // the model's block, resolving each choice reference to a ConvReply. Like the branch path, every choice + // is expanded exactly once here (source order, depth-first, then before else), never also as a flat + // reply, so a target is not double-marked "shown". + let replies: ConvReply[] = []; + let branches: ConvBranch[] | undefined; + let block: ConvBlock | undefined; + if (s.block && s.block.length > 0) { + const choiceById = new Map(s.choices.map((c) => [c.id, c])); + const buildBlk = (blk: DialogBlock): ConvBlock => + blk.flatMap((it): ConvBlockItem[] => { + if (it.kind === "line") + return [{ kind: "line", npc: resolveText(it.text, messages), npcHasText: Boolean(it.text) }]; + if (it.kind === "choice") { + const c = choiceById.get(it.choiceId); + return c ? [{ kind: "reply", reply: buildReply(c, textRO, s) }] : []; + } + if (it.kind === "group") { + const thenBlock = buildBlk(it.thenBlock); + const elseBlock = it.elseBlock ? buildBlk(it.elseBlock) : undefined; + // Mark each branch's OPENING NPC line with its gate: the if-branch line reads `[if]`, the + // else-branch line reads `[else]` (isElse), both carrying the full condition in the tooltip. + if (thenBlock[0]?.kind === "line") thenBlock[0] = { ...thenBlock[0], condition: it.condition }; + if (elseBlock?.[0]?.kind === "line") + elseBlock[0] = { ...elseBlock[0], condition: `not ${it.condition}`, isElse: true }; + return [ + { kind: "group", condition: it.condition, thenBlock, ...(elseBlock ? { elseBlock } : {}) }, + ]; + } + return []; // opaque - not rendered in the tree (surfaced via the side-effect badge) + }); + block = buildBlk(s.block); + stampBranchKeys(block, s.id); + } else if (s.branches && s.branches.length > 0) { + const choiceById = new Map(s.choices.map((c) => [c.id, c])); + // An `else` branch runs on the negation of its matching `if` (the immediately preceding branch, per + // the parser's if-then-else emission order). Carry that inverted condition so the tree renders it as + // a normal `[if] not(...)` gate instead of a bare, context-free `[else]`. SSL negation is `not (...)` + // (these branches are SSL-only). The `if` condition is already parenthesized, so `not (X)` is valid. + branches = s.branches.map((b, i) => { + const ifCond = b.kind === "else" ? s.branches![i - 1]?.condition : b.condition; + const branchKey = `${s.id}#branch${i}`; + return { + kind: b.kind, + condition: b.kind === "else" && ifCond ? `not ${ifCond}` : b.condition, + npc: resolveText(b.replies[0]?.text, messages), + npcHasText: Boolean(b.replies[0]?.text), + branchKey, + replies: b.choiceIds + .map((cid) => choiceById.get(cid)) + .filter((c): c is DialogChoice => c !== undefined) + .map((c) => ({ ...buildReply(c, textRO, s), branchKey })), + }; + }); + } else { + replies = s.choices.map((c) => buildReply(c, textRO, s)); + } + return { + id: s.id, + speaker: s.speaker, + text: resolveText(s.text, messages), + // A multisay state carries every SAY alternate in `sayTexts` (line 0 == `text`); surface lines 2..N + // as resolved continuation lines so the tree shows the whole monologue, not just the first line. + ...(s.sayTexts && s.sayTexts.length > 1 + ? { sayLines: s.sayTexts.slice(1).map((t) => resolveText(t, messages)) } + : {}), + trigger: s.trigger, + derivedFrom: s.derivedFrom, + ...(s.approximate ? { approximate: true } : {}), + replies, + ...(branches ? { branches } : {}), + ...(block ? { block } : {}), + isEntry: !targeted.has(s.id), + ...(isPendingState(s) ? { pending: true } : {}), + textEditable: textEditability({ state: s, choice: null, messages, ssl, textRO }).editable, + sourceOffset: s.procRange?.start ?? s.sourceRange?.start, + }; + } + + const roots: ConvState[] = []; + // Entries first, in source order, then any state not yet reached (states only + // inside a cycle, or otherwise unreachable from an entry) so every state of the + // file appears exactly once - parity with the graph, which shows them all. + for (const s of drawn) if (!targeted.has(s.id) && !shown.has(s.id)) roots.push(expand(s.id)); + for (const s of drawn) if (!shown.has(s.id)) roots.push(expand(s.id)); + return { roots }; +} + +/** + * The child ConvStates a node expands into - its replies' first-expansion `state` targets - across ALL reply + * shapes: flat replies, per-branch replies, and structured-block replies. A node populates exactly one of + * `replies`/`branches`/`block`, so this yields that one source's targets in render order (branch/flat, then + * block). The single traversal every tree walk shares (reveal-ancestors, collapse-all, find-in-tree) so none of + * them silently omits a branch or block node's children - the divergence that let those walks miss structured + * SSL nodes when each hand-rolled its own child collection. + */ +export function childStates(s: ConvState): ConvState[] { + const kids: ConvState[] = []; + if (s.branches) + for (const b of s.branches) for (const r of b.replies) if (r.target.kind === "state") kids.push(r.target.node); + for (const r of s.replies) if (r.target.kind === "state") kids.push(r.target.node); + if (s.block) collectBlockTargets(s.block, kids); + return kids; +} + +/** Child states reached by a structured node's block replies, in block order (a helper for `childStates`). */ +function collectBlockTargets(block: ConvBlock, out: ConvState[]): void { + for (const item of block) { + if (item.kind === "reply") { + if (item.reply.target.kind === "state") out.push(item.reply.target.node); + } else if (item.kind === "group") { + collectBlockTargets(item.thenBlock, out); + if (item.elseBlock) collectBlockTargets(item.elseBlock, out); + } + } +} diff --git a/client/src/dialog-editor/webview/dialog-actions.ts b/client/src/dialog-editor/webview/dialog-actions.ts new file mode 100644 index 000000000..1d3ba0f86 --- /dev/null +++ b/client/src/dialog-editor/webview/dialog-actions.ts @@ -0,0 +1,26 @@ +/** + * The structural-edit surface DialogGraph exposes to the inspector (and its own context menus): every + * mutation that is not a direct field write on the reactive model goes through one of these. One shared + * contract type so the builder (DialogGraph.svelte `actions`) and the consumer (Inspector.svelte `actions` + * prop) cannot drift - they previously each carried their own structurally-matching inline type literal. + */ + +import type { DialogReaction, DialogTarget } from "../../../../shared/dialog-model"; + +export interface DialogActions { + rename: (newId: string) => void; + addReply: () => void; + removeReply: (choiceId: string) => void; + moveReply: (choiceId: string, dir: -1 | 1) => void; + setTarget: (choiceId: string, target: DialogTarget) => void; + setReaction: (choiceId: string, reaction: DialogReaction) => void; + setLowIq: (choiceId: string, on: boolean) => void; + deleteState: () => void; + duplicateState: () => void; + addReplyToBranch: (branchIndex: number) => void; + removeReplyInBranch: (branchIndex: number, choiceId: string) => void; + moveReplyInBranch: (branchIndex: number, choiceId: string, dir: -1 | 1) => void; + addBranch: (condition: string) => void; + addElse: () => void; + removeBranch: (branchIndex: number) => void; +} diff --git a/client/src/dialog-editor/webview/dialog-issues.ts b/client/src/dialog-editor/webview/dialog-issues.ts new file mode 100644 index 000000000..e4adc6b30 --- /dev/null +++ b/client/src/dialog-editor/webview/dialog-issues.ts @@ -0,0 +1,44 @@ +/** + * Inline validation surfaced in the editor's Issues panel: the errors that would break a SAVED dialog. + * + * Kept pure (a model in, a string list out) so it is unit-tested without the Svelte runtime - the + * derived-state and per-root scoping below are exactly the parts that regress into false positives. + */ +import type { DialogModel } from "../../../../shared/dialog-model"; + +/** + * Two error classes matter for a saved .d/.ssl: + * + * - **Duplicate state label** - two states with the same label in the SAME dialog (root) collide on save. + * Scoped per root (labels are unique per DLG, not globally) and to SOURCE-authored states only: an + * editor-synthesized CHAIN/INTERJECT/EXTEND-derived id is a projection artifact, and two chains that + * converge on the same terminal legitimately produce the same derived id without being a real duplicate + * (the x#viconia.d VISK1 case). Flagging those is a false positive. + * - **Dangling transition** - a `state` target whose id is not present anywhere in the file. Checked + * against every state id (derived included), since a transition may legitimately target a derived state. + */ +export function dialogIssues(model: DialogModel): string[] { + const out: string[] = []; + const allIds = new Set(model.roots.flatMap((r) => r.states.map((s) => s.id))); + + for (const r of model.roots) { + const seen = new Set(); + for (const s of r.states) { + if (s.derivedFrom) continue; // synthesized id; a collision here is not a real source duplicate + if (seen.has(s.id)) out.push(`Duplicate state label: ${s.id}`); + seen.add(s.id); + } + } + + for (const r of model.roots) { + for (const s of r.states) { + for (const c of s.choices) { + if (c.target.kind === "state" && !allIds.has(c.target.stateId)) { + out.push(`${s.id}: transition points to missing state "${c.target.stateId}"`); + } + } + } + } + + return out; +} diff --git a/client/src/dialog-editor/webview/find-callers.ts b/client/src/dialog-editor/webview/find-callers.ts new file mode 100644 index 000000000..09a9af5f5 --- /dev/null +++ b/client/src/dialog-editor/webview/find-callers.ts @@ -0,0 +1,58 @@ +/** + * Reverse-references ("who reaches this state?"), computed from the model. + * + * A modder editing a node needs to know what points at it before touching it - the single highest-value + * cross-reference the raw-text workflow does with a project grep. This enumerates the inbound references the + * model already records: option targets (an `NOption`/`GOption`/... whose target is this state), bare `call` + * transitions (a choice carrying `callSites` rather than a `callRange`), the `talk_p_proc` entry call, and an + * external entry (a node reached by `force_dialog_start`/`start_dialog_at_node`, which the model flags in + * `entryIds` without a reachable call site). + * + * Scope: the whole parsed model (all roots) - for Fallout SSL that is the entire file. Cross-FILE callers + * (another `.ssl`/`.d`) are not represented in a single-file model and are out of scope here. + */ +import type { DialogModel } from "../../../../shared/dialog-model"; + +export interface Caller { + /** option: a player option targeting the node; call: a bare `call` transition; entry: talk_p_proc; + external-entry: force_dialog_start / start_dialog_at_node (no reachable call site). */ + kind: "option" | "call" | "entry" | "external-entry"; + /** The state the reference lives in (option/call only). */ + fromStateId?: string; + /** The referencing choice id (option/call only) - lets the UI highlight the exact option. */ + choiceId?: string; +} + +/** A caller resolved to a ready-to-display row (the display text is built where the model is in scope). */ +export interface CallerRow { + kind: Caller["kind"]; + /** The referencing state (option/call only); undefined for entries. Used to navigate on click. */ + fromStateId?: string; + /** Ready-to-display label. */ + label: string; +} + +export function findCallers(model: DialogModel, stateId: string): Caller[] { + const callers: Caller[] = []; + + // Entry first: a talk_p_proc `call ` is a reachable entry; a node in entryIds with no such call is + // reached only by force_dialog_start/start_dialog_at_node from a non-dialog procedure (an external entry). + if ((model.entryCalls ?? []).some((ec) => ec.name === stateId)) { + callers.push({ kind: "entry" }); + } else if ((model.entryIds ?? []).includes(stateId)) { + callers.push({ kind: "external-entry" }); + } + + for (const root of model.roots) { + for (const s of root.states) { + for (const c of s.choices) { + if (c.target.kind !== "state" || c.target.stateId !== stateId) continue; + // A `call` transition carries callSites and no callRange; everything else is a player option. + const kind = !c.callRange && c.callSites && c.callSites.length > 0 ? "call" : "option"; + callers.push({ kind, fromStateId: s.id, choiceId: c.id }); + } + } + } + + return callers; +} diff --git a/client/src/dialog-editor/webview/host.ts b/client/src/dialog-editor/webview/host.ts new file mode 100644 index 000000000..42ea22459 --- /dev/null +++ b/client/src/dialog-editor/webview/host.ts @@ -0,0 +1,23 @@ +/** + * Single channel to the extension host. `acquireVsCodeApi()` may be called only + * once per webview, so it is acquired here and shared. In the render harness (no + * VS Code runtime) it is absent, and posting is a no-op. + */ + +declare function acquireVsCodeApi(): { postMessage(msg: unknown): void }; + +let api: { postMessage(msg: unknown): void } | undefined; +try { + api = typeof acquireVsCodeApi === "function" ? acquireVsCodeApi() : undefined; +} catch { + api = undefined; +} + +/** True when running inside the VS Code webview host (vs the standalone harness). */ +export function hasHost(): boolean { + return api !== undefined; +} + +export function postToHost(msg: unknown): void { + api?.postMessage(msg); +} diff --git a/client/src/dialog-editor/webview/inspector-edit.ts b/client/src/dialog-editor/webview/inspector-edit.ts new file mode 100644 index 000000000..6e698a2dc --- /dev/null +++ b/client/src/dialog-editor/webview/inspector-edit.ts @@ -0,0 +1,227 @@ +/** + * Pure edit-gating helpers for the dialog Inspector. Kept out of the Svelte component so the + * rules that decide whether a text field may be edited are unit-testable (the component itself + * has no unit-test seam). + */ + +import type { DialogChoice, DialogMessages, DialogState } from "../../../../shared/dialog-model"; + +/** Parse a bare `@N` line to its numeric id, or null for a literal / non-`@N` text. */ +export function msgRef(text: string | undefined): string | null { + const m = /^@(\d+)$/.exec((text ?? "").trim()); + return m ? m[1]! : null; +} + +/** + * The single text write-back path for every display line - the tree's inline NPC/option edits and the + * Inspector's NPC/option fields alike. A resolvable `@N` line writes the new text to its `.msg`/`.tra` entry + * (localization preserved - the project decision); anything else (a literal, or a just-added item still + * pending its `@id`) updates the value's own `text` field in place. `target` is the DialogState or + * DialogChoice that owns the text. (Was copy-pasted at four sites - see coding.md *Share, don't duplicate*.) + */ +export function writeText(target: { text?: string }, messages: DialogMessages | undefined, value: string): void { + // A `.msg`/`.tra` entry is single-line by format, but the inspector's NPC/option fields are textareas, so + // Enter (or a multi-line paste) can put a newline into the value. Fold every CR/LF/CRLF to ONE space so the + // stored line never breaks the file (BUG D). Newlines only - NOT a trim: writeText runs on every keystroke, + // and trimming would strip a trailing space the moment the user types it. Enter-to-commit on the fields + // keeps a stray newline from arising in the first place; this is the write-side guard for a paste. + const single = value.replaceAll(/\r\n?|\n/g, " "); + const ref = msgRef(target.text); + if (ref !== null && messages) messages[ref] = single; + else target.text = single; +} + +/** + * A choice the user just added (pending insert) - it has no source span of any kind yet: no SSL `callRange` + * or `stmtRange` (an existing option), no `callSites` (a `call` transition), and no WeiDU D `sourceRange` + * (an existing D option). Its text field must stay editable so the user can type the initial line; + * `allocateOptionIds` turns it into an `@id` at save. + */ +export function isPendingChoice(c: DialogChoice): boolean { + // `sourceRange` is D's span (the SSL fields are always absent for D): without it an existing D option would + // read as pending, which makes textFieldLocked treat it as `isNew` (always editable) - so a D option backed + // by an UNRESOLVED @tra ref would wrongly stay editable instead of locking, exactly the drop the D gate now + // guards. Gate on `sourceRange` so that can't happen. A spliced option never lingers span-less in the copy: the + // webview adopts the host's re-parse after every splice, which assigns the real span. + return ( + c.callRange === undefined && c.stmtRange === undefined && !c.callSites?.length && c.sourceRange === undefined + ); +} + +/** A state the user just added (pending insert): no source span yet - neither SSL `procRange` nor D `sourceRange`. */ +export function isPendingState(s: DialogState): boolean { + return s.procRange === undefined && s.sourceRange === undefined; +} + +/** + * Whether a node's NPC line has no `.msg` entry yet but may be authored fresh - the `isNew` input to + * `textFieldLocked` for the node-level reply field. True for a just-added (pending) node, AND for a faithful + * SSL node adopted from source that carries no Reply (`replyless`): once a pending node is spliced and the + * webview adopts the re-parse it gains a `procRange` (so `isPendingState` flips false), but its reply is still + * empty and the save path will allocate an `@N` + splice `Reply(@N)` - so the line must stay editable for the + * user to type it. Keying on `replyless` (set at parse from the empty source reply) rather than `text === ""` + * keeps it editable through typing, when `text` has become an unsaved literal. + */ +export function npcLineAuthorable(s: DialogState): boolean { + return isPendingState(s) || s.replyless === true; +} + +/** + * Whether a dialog text field (NPC line or player reply) must render read-only. + * + * - A read-only state (`textRO`) locks everything. + * - A PENDING-NEW field (`isNew`) - a just-added option or node - has no `.msg`/`.tra` entry yet by + * definition, so it stays editable for the user to type the initial line (allocated an `@id` at save). + * Without this, add-option / add-node are unusable. A read-only state (`textRO`) still wins over it. + * - A LITERAL line (no `@N`): WeiDU D persists it via the `.d` splice (editable); SSL save only rewrites + * `.msg` entries, so an SSL literal has nowhere to write and is locked. + * - A bare `@N` line: editable only when its `.msg`/`.tra` entry actually LOADED (there is a line to + * rewrite). An `@N` whose entry never loaded (translation dir misconfigured, or indexing not done) has + * nowhere to write, so an edit would be silently dropped on save - lock it for BOTH families and fail + * visibly (a disabled field) rather than accept an edit that vanishes (BUG E: the D family previously + * left it editable, so the tab read "saved" while nothing reached disk). An entry that resolved to an + * empty string is still a real entry and stays editable. + */ +export function textFieldLocked(opts: { + text: string | undefined; + messages: Record | undefined; + ssl: boolean; + textRO: boolean; + isNew?: boolean; +}): boolean { + const { text, messages, ssl, textRO, isNew } = opts; + if (textRO) return true; + if (isNew) return false; + const ref = msgRef(text); + if (ref === null) return ssl; // literal: SSL can't persist it (locked); D splices it into the .d (editable) + return messages?.[ref] === undefined; // @N: editable iff its entry loaded, for D and SSL alike +} + +// ----------------------------------------------------------------------------- +// Disabled-reason helpers +// +// Every disabled control in the Inspector/Tree carries a concrete, actionable tooltip explaining WHY the edit +// is unavailable and where to make it instead - never a bare disabled control. These pure helpers are the +// single source of that wording so the same gate reads identically everywhere (and stays unit-testable). Each +// returns "" when the control is in fact editable (the caller can then fall back to an action tooltip). +// ----------------------------------------------------------------------------- + +/** Read-only because the state is derived (CHAIN/INTERJECT/EXTEND) or the whole dialog is view-only. */ +export function stateReadOnlyReason(derivedFrom?: string): string { + if (derivedFrom) + return `This state is generated from a ${derivedFrom} block and has no standalone source to edit here - change it in the ${derivedFrom} source.`; + return "This dialog is open read-only."; +} + +/** + * Why structural editing (rename, retarget, reorder, add/remove option, reaction, low-INT) is unavailable. + * Call only when the control is disabled (i.e. structuralEditable is false); returns "" if it is actually + * editable. `editable` is the whole-file flag (D); `ssl` selects Fallout-SSL wording. + */ +export function structuralLockReason(state: DialogState, ssl: boolean, editable: boolean): string { + if (state.derivedFrom) return stateReadOnlyReason(state.derivedFrom); + if (!ssl) return editable ? "" : stateReadOnlyReason(); + // SSL: structuralEditable is true only on a faithful/bundle node, so a disabled control here means the + // node is structured (nested), approximate (loop/switch), or otherwise not round-trippable on save. + if (state.approximate) + return "This node uses control flow the editor can't model (a loop or switch), so its structure is read-only - edit the source file."; + if (state.structured) + // The structured tier is entered by a nested if/else OR by a preserved non-dialog statement (a var set, + // a side-effect call) the graph keeps byte-exact but can't model - so the reason names both, not if/else + // alone (which misdescribed a node gated only by a trailing set_*_var). + return "This node mixes dialog with structure the graph can't rewrite safely (a nested if/else, or a non-dialog statement like a variable set), so its structure is read-only - edit the source file."; + return "This node isn't simple enough to edit structurally from the graph - edit the source file."; +} + +/** + * Why a text field (NPC line or option text) is locked, mirroring `textFieldLocked`. Returns "" when editable. + * `derivedFrom` is the owning state's, for the derived-state wording. + */ +export function textLockReason(opts: { + text: string | undefined; + messages: Record | undefined; + ssl: boolean; + textRO: boolean; + isNew?: boolean; + derivedFrom?: string; +}): string { + if (!textFieldLocked(opts)) return ""; + const { text, ssl, textRO, derivedFrom } = opts; + if (textRO) return stateReadOnlyReason(derivedFrom); + const ref = msgRef(text); + if (ref === null) + // Only reachable for SSL (a D literal is editable): a literal/computed SSL line has no .msg entry. + return "This line has no plain @N message id (it's a literal or computed value), so there's no .msg entry to edit here - change it in the source file."; + // An unresolved @N ref, for either family. The backing file differs: SSL reads .msg, the D family reads .tra. + const kind = ssl ? "message" : "string"; + const file = ssl ? ".msg" : ".tra"; + return `This line's @${ref} ${kind} isn't loaded, so there's no entry to edit. Point mls.translation.directory in .bgforge.yml at the folder holding this ${file} (or edit the ${file} directly).`; +} + +/** + * The ONE decision for whether a dialog text field (a node's NPC line, or an option's reply text) may be edited, + * returning the lock AND its reason together. Both views - the Inspector's fields and the tree's inline edits - + * call this instead of each assembling the `isNew` "authorable" proxy at the call site: that per-site assembly + * was where the wrong proxy (`isPendingState`, which flips false the moment a new node is adopted from a + * re-parse) locked a just-added node's still-empty NPC line (the +State bug). Centralizing the authorable/`@N` + * decision makes any text field, either view, decide it identically. + * + * The `authorable` proxy differs by owner and is resolved here: an option keys on `isPendingChoice`, a node's + * NPC line on `npcLineAuthorable` (pending OR a faithful reply-less node whose Reply the save path allocates). + * `textRO` is the caller's, NOT re-derived here: the Inspector locks only a derived state's text, while the tree + * additionally locks a non-field-editable D-family state - a separate policy question (whether the D/TD writer + * persists a `.tra` edit on an unfaithful state) this decision does not adjudicate. Composes the primitives + * above rather than re-deriving, so it can never disagree with them; a `null` choice selects the NPC line. + */ +export function textEditability(opts: { + state: DialogState; + choice: DialogChoice | null; + messages: DialogMessages | undefined; + ssl: boolean; + textRO: boolean; +}): { editable: boolean; reason: string } { + const { state, choice, messages, ssl, textRO } = opts; + const text = (choice ?? state).text; + const isNew = choice ? isPendingChoice(choice) : npcLineAuthorable(state); + const base = { text, messages, ssl, textRO, isNew }; + const locked = textFieldLocked(base); + return { editable: !locked, reason: locked ? textLockReason({ ...base, derivedFrom: state.derivedFrom }) : "" }; +} + +/** + * Editability of ONE SAY line of a WeiDU D multisay state (`SAY @a = @b = @c`), given its raw text - the same + * lock+reason decision `textEditability` returns, but for a continuation line addressed by value rather than by + * owner. A continuation line is always existing source (never a pending item), so `isNew` is false: it is gated + * exactly like any other line by the @N-resolvability / literal rules. `textRO` locks every line (a derived + * state); `derivedFrom` selects the read-only wording. Composes the same primitives, so it can't disagree. + */ +export function sayLineEditability(opts: { + text: string | undefined; + messages: DialogMessages | undefined; + ssl: boolean; + textRO: boolean; + derivedFrom?: string; +}): { editable: boolean; reason: string } { + const { text, messages, ssl, textRO, derivedFrom } = opts; + const base = { text, messages, ssl, textRO, isNew: false }; + const locked = textFieldLocked(base); + return { editable: !locked, reason: locked ? textLockReason({ ...base, derivedFrom }) : "" }; +} + +/** + * Why an option's condition field is read-only. Returns "" when editable. `editable` is the whole-file flag + * (D); for SSL the per-option `conditionEditable` decides, and the reason distinguishes a read-only node + * structure from a condition shared across several options. + */ +export function conditionLockReason(state: DialogState, choice: DialogChoice, ssl: boolean, editable: boolean): string { + if (!ssl) return editable && !state.derivedFrom ? "" : stateReadOnlyReason(state.derivedFrom); + if (choice.conditionEditable !== false) return ""; + if (state.approximate || state.structured) + return "This node's structure is read-only - its nested/composite condition can't round-trip to a single if, so edit it in the source file."; + return "This condition gates more than just this option - a reply line, another option, or a side-effect shares the same if, so the graph can't edit it. Change it in the source file."; +} + +/** Why a faithful SSL option's Remove is disabled: it sits in its own `if` the save path won't rewrite. */ +export function optionRemoveLockReason(): string { + return "This option sits inside an `if` the graph won't rewrite on save - remove it in the source file."; +} diff --git a/client/src/dialog-editor/webview/jump-resolve.ts b/client/src/dialog-editor/webview/jump-resolve.ts new file mode 100644 index 000000000..47eb5ce43 --- /dev/null +++ b/client/src/dialog-editor/webview/jump-resolve.ts @@ -0,0 +1,42 @@ +/** + * Resolve an external-stub label to the dialog tab + state it points at. + * + * A transition can leave the active dialog file two ways: a WeiDU `EXTERN` + * (label shaped `~%file%~:state`) or a `goto` whose target state lives in + * another file (label is just the bare state id). Both render as external + * stubs; this turns a stub into a jump when the destination is one of the + * dialog files this `.d` defines. A stub pointing at a dialog this file does + * not touch stays dead (returns undefined). + * + * Shared by the graph (stub nodes) and the tree (external leaves) so the two + * views resolve cross-file links identically. + */ +export interface JumpTarget { + /** Owning root id (the tab to switch to). */ + file: string; + /** State id within that root. */ + stateId: string; +} + +export function resolveJumpTarget( + label: string, + stateToRoot: Map, + fileToRoot: Map, +): JumpTarget | undefined { + // A bare state id owned by some root (cross-file goto, or a label that is + // itself a state in another file). + const ownRoot = stateToRoot.get(label); + if (ownRoot) return { file: ownRoot, stateId: label }; + + // `file:state` - the file part has no colon (`%var%NAME`) but EXTERN wraps it + // in tildes (`~%CORAN_JOINED%~:CoranRun`), while root labels carry none, so + // strip the tildes before matching. + const ci = label.indexOf(":"); + if (ci > 0) { + const file = label.slice(0, ci).replaceAll(/^~+|~+$/g, ""); + const state = label.slice(ci + 1); + const rootId = fileToRoot.get(file); + if (rootId && stateToRoot.get(state) === rootId) return { file: rootId, stateId: state }; + } + return undefined; +} diff --git a/client/src/dialog-editor/webview/layout.ts b/client/src/dialog-editor/webview/layout.ts new file mode 100644 index 000000000..7109e7b29 --- /dev/null +++ b/client/src/dialog-editor/webview/layout.ts @@ -0,0 +1,90 @@ +/** + * Assign positions to flow nodes with elkjs (layered algorithm, left-to-right). + * A multi-root forest shares one layering (components stack vertically) so every + * starting state lands in the same aligned first column - see layoutFlow. + */ + +// elk.bundled.js ships its web-worker inline; safe in a webview/headless context. +import ELK from "elkjs/lib/elk.bundled.js"; +import type { FlowGraph } from "./model-to-flow"; + +const elk = new ELK(); + +export async function layoutFlow(graph: FlowGraph): Promise { + // A starting state is a card no transition points at (no inbound edge) - the entry + // point of a conversation thread. Pin every one to elk's first layer so they share a + // single aligned left column. Component separation packs each component's layering + // independently (their layer-0 nodes would land at different x); keep it off so the + // whole forest shares one layering and all FIRST nodes align at the same x. + const targeted = new Set(graph.edges.map((e) => e.target)); + const isStart = (id: string, type: string): boolean => type === "card" && !targeted.has(id); + + // Per-source handle ids in choice order (top-to-bottom down the card's right edge). + // Modeling each choice handle as a fixed-order elk port lets crossing-minimization see + // the real per-reply source positions; without ports elk treats all of a card's edges + // as leaving one point, finds zero crossings for any target order, picks arbitrarily, + // and the fixed render handles then cross. graph.edges is already in choice order per + // source (model-to-flow pushes a state's choice edges consecutively). + const portsBySource = new Map(); + for (const e of graph.edges) { + if (!e.sourceHandle) continue; + const arr = portsBySource.get(e.source); + if (arr) arr.push(e.sourceHandle); + else portsBySource.set(e.source, [e.sourceHandle]); + } + + interface ElkChild { + id: string; + width: number; + height: number; + layoutOptions?: Record; + ports?: Array<{ id: string; layoutOptions: Record }>; + } + + const elkGraph = { + id: "root", + layoutOptions: { + "elk.algorithm": "layered", + "elk.direction": "RIGHT", + "elk.spacing.nodeNode": "40", + "elk.layered.spacing.nodeNodeBetweenLayers": "90", + "elk.spacing.componentComponent": "60", + // No wrapping: every construct keeps its full layer depth as one unbroken + // left-to-right line, so a path reads straight across instead of folding into + // stacked rows. The diagram can grow very wide - pan/scroll, not fit-to-screen. + "elk.separateConnectedComponents": "false", + // Use input order (edges are in reply order) as the secondary objective after + // crossing count, so a card's targets follow its reply order where the primary + // crossing-minimization is otherwise indifferent - further cutting crossings. + "elk.layered.considerModelOrder.strategy": "NODES_AND_EDGES", + }, + children: graph.nodes.map((n) => { + const child: ElkChild = { id: n.id, width: n.width, height: n.height }; + const layoutOptions: Record = {}; + if (isStart(n.id, n.type)) layoutOptions["elk.layered.layering.layerConstraint"] = "FIRST"; + const handles = portsBySource.get(n.id); + if (handles && handles.length > 0) { + layoutOptions["elk.portConstraints"] = "FIXED_ORDER"; + // EAST side; elk's clockwise port index increases top-to-bottom on the + // right edge, so the choice-order index maps straight to render order. + child.ports = handles.map((id, i) => ({ + id, + layoutOptions: { "elk.port.side": "EAST", "elk.port.index": String(i) }, + })); + } + if (Object.keys(layoutOptions).length > 0) child.layoutOptions = layoutOptions; + return child; + }), + // Source an edge from its choice port so elk orders the target column to match. + edges: graph.edges.map((e) => ({ id: e.id, sources: [e.sourceHandle ?? e.source], targets: [e.target] })), + }; + + const res = await elk.layout(elkGraph); + const byId = new Map(graph.nodes.map((n) => [n.id, n])); + for (const c of res.children ?? []) { + const node = byId.get(c.id); + if (node && typeof c.x === "number" && typeof c.y === "number") { + node.position = { x: c.x, y: c.y }; + } + } +} diff --git a/client/src/dialog-editor/webview/main.ts b/client/src/dialog-editor/webview/main.ts new file mode 100644 index 000000000..ec43bd024 --- /dev/null +++ b/client/src/dialog-editor/webview/main.ts @@ -0,0 +1,10 @@ +import { mount } from "svelte"; +import App from "./App.svelte"; +import { postToHost } from "./host"; + +const target = document.getElementById("app"); +if (target) { + mount(App, { target }); + // Tell the host the webview is ready to receive the model. + postToHost({ type: "ready" }); +} diff --git a/client/src/dialog-editor/webview/model-to-flow.ts b/client/src/dialog-editor/webview/model-to-flow.ts new file mode 100644 index 000000000..09c2d948b --- /dev/null +++ b/client/src/dialog-editor/webview/model-to-flow.ts @@ -0,0 +1,250 @@ +/** + * Convert a format-neutral DialogModel into Svelte Flow nodes + edges. + * + * Pure and position-free: layout (elkjs) assigns coordinates afterwards. Each + * dialog state becomes a card node; each choice becomes an edge from a per-choice + * source handle. External anchors and the EXIT terminal are deduplicated synthetic + * nodes so no edge dangles. + */ + +import { nodeEditable } from "../../../../shared/dialog-editability"; +import { + isFlaggedNode, + renderFamily, + resolveText, + sslTerminalKind, + type DialogModel, + type DialogState, +} from "../../../../shared/dialog-model"; +import { classifyReachability } from "../../../../shared/dialog-reachability"; +import { msgRef } from "./inspector-edit"; + +/** The distinct `@N` message refs a state uses across its own line and its options. */ +function stateRefs(s: DialogState): string[] { + const refs = new Set(); + const line = msgRef(s.text); + if (line) refs.add(line); + for (const c of s.choices) { + const r = msgRef(c.text); + if (r) refs.add(r); + } + return [...refs]; +} + +/** + * States that share a `@N` ref with at least one OTHER state - editing such text (line or reply) here + * rewrites the one shared `.msg`/`.tra` entry and changes every state that uses it. Duplicating a node + * keeps the original's refs, so both the copy and the original land in this set; an authored shared ref + * does too. Pure projection over the model; the renderer marks these nodes so the coupling is not silent. + */ +function sharedTextStates(model: DialogModel): (s: DialogState) => boolean { + const refUsers = new Map(); + for (const root of model.roots) + for (const s of root.states) for (const r of stateRefs(s)) refUsers.set(r, (refUsers.get(r) ?? 0) + 1); + return (s: DialogState) => stateRefs(s).some((r) => (refUsers.get(r) ?? 0) > 1); +} + +export interface FlowNode { + id: string; + type: "card" | "external" | "exit" | "combat"; + position: { x: number; y: number }; + width: number; + height: number; + data: Record; +} + +export type EdgeCategory = "player" | "continue" | "exit" | "combat" | "external"; + +export interface FlowEdge { + id: string; + source: string; + target: string; + sourceHandle?: string; + /** "back" marks an edge the layout routes as a returning/cyclic edge. */ + kind: "forward" | "back"; + /** Transition type, used to color the edge. */ + category: EdgeCategory; + dashed: boolean; + label?: string; +} + +const NODE_WIDTH = 200; +const HEADER_H = 26; +const ROW_H = 20; +const BODY_LINE_H = 16; +// Conservative chars-per-line for the ~184px text column at the card font size: the NPC +// line wraps, so the card height grows with it. Under-counting chars (over-reserving +// height) is deliberate - it keeps the layout from overlapping when text wraps long. +const BODY_CHARS_PER_LINE = 24; + +export function stateNodeSize(state: DialogState, displayTextLen = 0): { width: number; height: number } { + const bodyLines = Math.max(1, Math.ceil(displayTextLen / BODY_CHARS_PER_LINE)); + const bodyH = bodyLines * BODY_LINE_H + 8; + return { width: NODE_WIDTH, height: HEADER_H + bodyH + Math.max(state.choices.length, 0) * ROW_H + 8 }; +} + +export interface FlowGraph { + nodes: FlowNode[]; + edges: FlowEdge[]; +} + +export function modelToFlow(model: DialogModel): FlowGraph { + const nodes: FlowNode[] = []; + const edges: FlowEdge[] = []; + const stateIds = new Set(); + const synthetic = new Map(); // dedup external/exit/combat nodes by id + + // SSL convention: Node998/Node999 are terminal Combat/Exit targets, not drawn cards (SSL_TERMINAL_NODES). + // Skip their cards and route an option targeting them to the matching terminal instead (mirrors the tree). + const isSSL = renderFamily(model.sourceLang) === "fallout-ssl"; + const terminalKindOf = (id: string): "exit" | "combat" | undefined => (isSSL ? sslTerminalKind(id) : undefined); + + for (const root of model.roots) { + for (const s of root.states) if (!terminalKindOf(s.id)) stateIds.add(s.id); + } + + const ensureSynthetic = (id: string, type: "external" | "exit" | "combat", label: string, title?: string): void => { + if (synthetic.has(id)) return; + const n: FlowNode = { + id, + type, + position: { x: 0, y: 0 }, + width: type === "external" ? 150 : type === "combat" ? 90 : 70, + height: 36, + data: { label, ...(title ? { title } : {}) }, + }; + synthetic.set(id, n); + }; + + const messages = model.messages; + // Reachability is a pure projection over the whole model; compute once and tag each + // card so the renderer can flag dead states (orphan) and EXTERN entries. + const reach = classifyReachability(model); + const isShared = sharedTextStates(model); + // A root can carry the same state label twice - two CHAIN blocks whose terminal state shares a label + // (VISK1 in x#viconia.d). Svelte Flow keys nodes (and edges) by id, so a second card/edge with a + // repeated id throws each_key_duplicate and crashes the whole graph render. Emit one card per DISTINCT + // id (and skip the duplicate's edges), matching the tree, which already merges these states. Deeper + // faithful representation of a doubly-defined label is a separate concern (routing-layer work). + const emittedCardIds = new Set(); + for (const root of model.roots) { + for (const s of root.states) { + // A reserved terminal node (SSL Node998/Node999) is never drawn as a card - it renders only as the + // Combat/Exit terminal that options route to (below). + if (terminalKindOf(s.id)) continue; + if (emittedCardIds.has(s.id)) continue; + emittedCardIds.add(s.id); + const { width, height } = stateNodeSize(s, resolveText(s.text, messages).length); + // messages travels in node data so the card can resolve @N at render time + // while the raw refs stay on the state (needed for .tra write-back). + nodes.push({ + id: s.id, + type: "card", + position: { x: 0, y: 0 }, + width, + height, + // `flagged` drives the spotlight overlay: true iff the node carries any + // badge (state- or choice-level). Computed here so a toggle just flips a + // CSS class - no model-to-flow rebuild on toggle. + data: { + state: s, + // File base name -> speaker fallback for the card header (see stateHeadLabel). + sourceName: model.sourceName, + messages, + reachability: reach.get(s.id), + flagged: isFlaggedNode(s), + // True when this node's line or a reply shares a .msg/.tra ref with another node + // (e.g. a duplicated node) - editing the text here also changes the other node. + sharedText: isShared(s), + // Editability (drives output-handle connectability for RETARGET) via the ONE shared predicate + // the inspector and graph gate on, so the card can't be dragged when they say read-only - in + // particular an unfaithful TD state (faithful === false) is not drag-retargetable. + fieldEditable: nodeEditable(model, s), + }, + }); + + s.choices.forEach((c) => { + // SSL Node998/Node999 targets route to the Combat/Exit terminal, not a card. The exit terminal + // is shared with plain `{kind:"exit"}` options, so it carries no id tooltip; combat is only ever + // Node998, so it does (mirrors the tree's per-chip tooltip where it can be precise). + const terminal = c.target.kind === "state" ? terminalKindOf(c.target.stateId) : undefined; + let targetId: string; + if (terminal === "exit") { + targetId = "exit"; + ensureSynthetic("exit", "exit", "EXIT"); + } else if (terminal === "combat") { + targetId = "combat"; + ensureSynthetic("combat", "combat", "COMBAT", "Node998"); + } else if (c.target.kind === "state") { + targetId = c.target.stateId; + // A goto/call to a state not present in this file - keep the edge from dangling. + if (!stateIds.has(targetId)) { + targetId = `ext:${c.target.stateId}`; + ensureSynthetic(targetId, "external", c.target.stateId); + } + } else if (c.target.kind === "external") { + targetId = `ext:${c.target.label}`; + ensureSynthetic(targetId, "external", c.target.label); + } else { + targetId = "exit"; + ensureSynthetic("exit", "exit", "EXIT"); + } + const category: EdgeCategory = + terminal === "combat" + ? "combat" + : terminal === "exit" || c.target.kind === "exit" + ? "exit" + : c.target.kind === "external" + ? "external" + : c.text + ? "player" + : "continue"; + edges.push({ + id: c.id, + source: s.id, + target: targetId, + sourceHandle: c.id, + kind: "forward", + category, + dashed: c.target.kind === "external" || Boolean(c.condition), + // No edge label: the reply text lives on the source card's choice row + // and in the inspector. A resolved sentence here renders as a large + // white pill over the edge (the old `@N` token was incidentally tiny). + }); + }); + } + } + + nodes.push(...synthetic.values()); + markBackEdges(nodes, edges); + return { nodes, edges }; +} + +/** + * Mark edges whose target was already seen on the path from a root as "back" + * edges (cycles), so the renderer can style them distinctly. Simple DFS over the + * card subgraph; synthetic terminals never start cycles. + */ +function markBackEdges(nodes: FlowNode[], edges: FlowEdge[]): void { + const out = new Map(); + for (const e of edges) (out.get(e.source) ?? out.set(e.source, []).get(e.source)!).push(e); + const cardIds = new Set(nodes.filter((n) => n.type === "card").map((n) => n.id)); + const onStack = new Set(); + const done = new Set(); + + const dfs = (id: string): void => { + onStack.add(id); + for (const e of out.get(id) ?? []) { + if (onStack.has(e.target)) { + e.kind = "back"; + e.dashed = true; + } else if (!done.has(e.target) && cardIds.has(e.target)) { + dfs(e.target); + } + } + onStack.delete(id); + done.add(id); + }; + + for (const id of cardIds) if (!done.has(id)) dfs(id); +} diff --git a/client/src/dialog-editor/webview/reparse-decision.ts b/client/src/dialog-editor/webview/reparse-decision.ts new file mode 100644 index 000000000..d69d94a7d --- /dev/null +++ b/client/src/dialog-editor/webview/reparse-decision.ts @@ -0,0 +1,50 @@ +/** + * Pure decision kernel for the host's re-parse messages (DialogGraph's `onReparse` listener). + * + * After the host splices a self-edit into the source it posts the faithful parse back (`reparse: true`, + * stamped with the emit's seq). The branching lives here, unit-tested without a Svelte runtime + * (dialog-reparse-decision.test.ts) - the same split app-messages.ts makes for the root: + * - a non-reparse or malformed message is not this listener's business (App routes plain models); + * - a stale parse (seq behind the latest emit) is dropped - a newer optimistic edit supersedes it, + * and adopting it would clobber what the user just typed; + * - otherwise the faithful parse is adopted wholesale. An open inline edit does NOT block the adopt: + * the listener carries the input's live draft and caret across the model replacement as a small + * overlay (see adoptModel), which replaced the old "reconcile the optimistic model in place" + * branch and the allocation-stamping machinery it needed. + * + * Why an optimistic model exists at all (rather than a pure projection of the document - a + * "text-authoritative" editor holding no client-side model): the parse is an async LSP round-trip, not a + * synchronous call. A pure projection would have to either block the tree on that round-trip after every + * commit (per-edit latency) or hold an optimistic local projection to bridge the latency window - which is + * a client-side model again. So the model is kept but made self-correcting: every accepted reparse + * overwrites it wholesale, so it cannot drift from the document, and the rival-copy bug class (the retired + * reconcile/merge path) is gone by construction. VS Code's shared TextDocument stays the single source of + * truth across tabs/editors; an external same-file edit adopts through this same path. Going fully + * text-authoritative is deferred, not rejected on merit - it wins only if the parse becomes + * synchronous/client-side (e.g. tree-sitter in the webview), which removes the optimistic model's + * justification, or if a divergence the draft overlay cannot hold proves adopt-wholesale insufficient. + * Absent either it trades latency-or-equal-complexity for a parse-failure UX burden this design avoids by + * keeping a last-good model to fall back on. + */ + +import type { DialogModel } from "../../../../shared/dialog-model"; + +export interface ReparseMessage { + type?: string; + reparse?: boolean; + model?: DialogModel; + seq?: number; + allocations?: Record; + messages?: Record; +} + +export type ReparseDecision = + | { kind: "ignore" } + | { kind: "adopt"; model: DialogModel; allocations?: Record; messages?: Record }; + +/** Classify one incoming `window.message` payload for the re-parse listener. */ +export function decideReparse(data: ReparseMessage | null | undefined, localSeq: number): ReparseDecision { + if (data?.type !== "model" || !data.reparse || !data.model) return { kind: "ignore" }; + if (data.seq !== localSeq) return { kind: "ignore" }; // stale: a newer optimistic edit already superseded it + return { kind: "adopt", model: data.model, allocations: data.allocations, messages: data.messages }; +} diff --git a/client/src/dialog-editor/webview/state-lookup.ts b/client/src/dialog-editor/webview/state-lookup.ts new file mode 100644 index 000000000..be49de275 --- /dev/null +++ b/client/src/dialog-editor/webview/state-lookup.ts @@ -0,0 +1,62 @@ +/** + * Resolve a state by id, preferring the ACTIVE root. + * + * State ids are unique within a dialog (root) but NOT across roots: a WeiDU D file can hold several DLGs + * (roots) that reuse the same state label, and CHAIN/INTERJECT expansion can even repeat an id within a + * root. The editor's tree/graph and its selection all operate on the active tab, so a lookup must resolve + * within the active root first. A first-match-across-all-roots search returned the wrong instance for a + * duplicated id, which made "Set target" act on a state that does not own the choice - the target silently + * never changed and selection jumped to the wrong state. Falls back to the other roots only when the id is + * genuinely absent from the active root. + */ +import type { DialogRoot, DialogState } from "../../../../shared/dialog-model"; + +export function findStateInRoots( + roots: DialogRoot[], + activeRootId: string | undefined, + stateId: string, +): DialogState | null { + const active = roots.find((r) => r.id === activeRootId); + const inActive = active?.states.find((x) => x.id === stateId); + if (inActive) return inActive; + for (const r of roots) { + const s = r.states.find((x) => x.id === stateId); + if (s) return s; + } + return null; +} + +/** + * Distinct state ids of one root, first-occurrence order preserved. + * + * A root can carry the same state label more than once (two CHAIN blocks with the same terminal label; see + * the duplicate-id note above). The GOTO-target dropdown renders these ids as a keyed `{#each}`, so a raw + * `states.map(s => s.id)` with a repeat produces a duplicate Svelte key (svelte.dev/e/each_key_duplicate) and + * a render error. A jump target is addressed by label, so listing each distinct label once is both correct + * and what the dropdown needs. + */ +export function distinctStateIds(states: DialogState[]): string[] { + return [...new Set(states.map((s) => s.id))]; +} + +/** + * Re-resolve a previously-selected option's id against a freshly-parsed state, for the "adopt the faithful + * re-parse but keep the selection" path (DialogGraph.adoptModel). + * + * An EXISTING option keeps its positional id across the parse, so it resolves directly. A JUST-ADDED option + * does not: the webview names it `#reply` while pending, but once spliced and re-parsed it becomes the + * positional `#opt` the parser assigns - a different string. The host reports each such item's + * allocated `@N` text in `allocations` (keyed by the OLD pending id), so we match the pending option to its + * re-parsed self by that `@N`. Returns null when neither resolves (e.g. the option was removed in the source), + * so the caller can fall back to a whole-state selection. + */ +export function remapChoiceId( + keptChoiceId: string, + state: DialogState, + allocations: Record | undefined, +): string | null { + if (state.choices.some((c) => c.id === keptChoiceId)) return keptChoiceId; + const ref = allocations?.[keptChoiceId]; + if (ref === undefined) return null; + return state.choices.find((c) => c.text === ref)?.id ?? null; +} diff --git a/client/src/dialog-editor/webview/translation-status.ts b/client/src/dialog-editor/webview/translation-status.ts new file mode 100644 index 000000000..0936e0cba --- /dev/null +++ b/client/src/dialog-editor/webview/translation-status.ts @@ -0,0 +1,59 @@ +/** + * Translation-resolution status for the open dialog. + * + * The dialog model's `messages` come from the server's shared translation resolver (getMessages -> + * resolveTraFileKey): a `@tra` first-line directive, else an `auto_tra` basename match under the + * configured `.bgforge.yml` `translation.directory` (default `tra`). A nested `tra//` layout + * (e.g. BG1NPC) does NOT auto-resolve - the basename key never matches the language-subdir path - so + * `getMessages` returns nothing and every `@N` renders as its raw ref. That failure is otherwise silent; + * this count drives the editor's banner that tells the author how to point the translation path (with + * `translationHint` supplying the family-specific words - Fallout SSL `.msg` vs WeiDU D `.tra`). + */ +import { msgRef } from "./inspector-edit"; +import type { DialogModel } from "../../../../shared/dialog-model"; + +/** + * How many `@N` refs (NPC lines and option text) the model could not resolve to real message text. + * + * Only a BARE `@N` counts (a literal line is not a ref). A just-added state/option needs no special case: + * until save allocates its `@N` its text is empty or a literal (so `msgRef` is null and it is not counted), + * and once allocated the entry is in `messages` (so it resolves). Do NOT gate on `isPendingState`/procRange + * here - `procRange` is an SSL-only span, absent on every WeiDU D state, so a procRange gate would skip all + * D states and the count would always be zero for D. + */ +export function unresolvedRefCount(model: DialogModel): number { + const messages = model.messages ?? {}; + let count = 0; + for (const root of model.roots) { + for (const s of root.states) { + for (const text of [s.text, ...s.choices.map((c) => c.text)]) { + const key = msgRef(text); + if (key !== null && messages[key] === undefined) count++; + } + } + } + return count; +} + +/** Family-specific vocabulary for the unresolved-refs banner. Fallout SSL and WeiDU D resolve translations + * from different file kinds and default directories, so the guidance must never use the other family's words. */ +export interface TranslationHint { + /** The word for the translation path in the banner ("the {pathWord} path"). */ + pathWord: string; + /** An example `translation.directory` value for `.bgforge.yml`. */ + dirExample: string; + /** The translation-file extension a `@tra` directive would name. */ + ext: string; +} + +/** + * The banner text that tells an author how to point the translation path is family-specific: Fallout SSL reads + * `.msg` files under the engine dialog path (`server/src/translation.ts` `DEFAULT_SSL_DIALOG_DIR`), while WeiDU D + * reads `.tra` files under the plain `tra` default. Showing D's `.tra`/`tra/english` vocabulary on a Fallout file + * (or vice versa) is wrong terminology; this centralizes the family words so the banner never mixes them. + */ +export function translationHint(isSSL: boolean): TranslationHint { + return isSSL + ? { pathWord: "message", dirExample: "data/text/english/dialog", ext: "msg" } + : { pathWord: "tra", dirExample: "tra/english", ext: "tra" }; +} diff --git a/client/src/dialog-editor/webview/tree-search.ts b/client/src/dialog-editor/webview/tree-search.ts new file mode 100644 index 000000000..3f3a59e12 --- /dev/null +++ b/client/src/dialog-editor/webview/tree-search.ts @@ -0,0 +1,87 @@ +/** + * Find-in-tree: collect the conversation rows whose visible text (or node id) matches a query, in the order + * they appear top-to-bottom in the outline, so a find-bar can walk them with next/prev. + * + * Pure and presentation-free (mirrors conversation-tree.ts): it reads the already-resolved display text off + * the ConversationTree the tree view renders, so a match is exactly a visible row - no re-resolution, no + * source access. Each match carries the same selection coordinates the tree's click handlers use, so + * navigating a match reuses the existing select + reveal + scroll path. + */ +import { childStates, type ConvBlock, type ConvState, type ConversationTree } from "./conversation-tree"; + +export interface SearchMatch { + /** Row key for the tree highlight: a state id (node/flat-line match), a choice id (option match), or a + branch key (if/else branch-line match). The three namespaces never collide - a choice id is + `#opt*`/`#call*`, a branch key `#*if`/`#*else`/`#branch*`, a state id has no `#`. */ + key: string; + /** Owner state to select and reveal. */ + stateId: string; + /** Set for an option match - selects the option (highlights it + focuses its Inspector field). */ + choiceId?: string; + /** Set for an if/else branch-line match - selects the owner state and highlights that branch's run. */ + branchKey?: string; +} + +function hit(haystack: string | undefined, needle: string): boolean { + return haystack !== undefined && haystack.toLowerCase().includes(needle); +} + +/** + * Every match for `rawQuery`, in outline order. Empty for a blank query. Matches node ids, NPC line text + * (flat, branch, and nested block), and player option text - the same content the tree shows. + */ +export function collectMatches(tree: ConversationTree, rawQuery: string): SearchMatch[] { + const q = rawQuery.trim().toLowerCase(); + if (!q) return []; + const out: SearchMatch[] = []; + // A state is fully expanded once (conversation-tree's first-expansion-wins), so the "state" targets form a + // DAG and this walk terminates; `seen` is belt-and-suspenders against any future non-DAG shape. + const seen = new Set(); + + // A nested block (structured node): a top-level line (no branchKey) is the node's own line and selects the + // state; a line inside an if/else carries a branchKey and selects that branch. Groups only nest - the + // condition itself is not a searchable line (it renders as a heading, its text is code, not dialogue). + const walkBlock = (block: ConvBlock, stateId: string): void => { + for (const item of block) { + if (item.kind === "line") { + if (hit(item.npc, q)) { + if (item.branchKey) out.push({ key: item.branchKey, stateId, branchKey: item.branchKey }); + else out.push({ key: stateId, stateId }); + } + } else if (item.kind === "reply") { + if (hit(item.reply.text, q)) out.push({ key: item.reply.id, stateId, choiceId: item.reply.id }); + } else { + walkBlock(item.thenBlock, stateId); + if (item.elseBlock) walkBlock(item.elseBlock, stateId); + } + } + }; + + const walkState = (s: ConvState): void => { + if (seen.has(s)) return; + seen.add(s); + + // Node id, or the flat node's own line text, selects the whole state. Bundle/structured nodes keep + // their line text in `branches`/`block` (matched below), so only match `text` for a plain flat node. + if (hit(s.id, q) || (!s.branches && !s.block && hit(s.text, q))) out.push({ key: s.id, stateId: s.id }); + + if (s.branches) { + for (const b of s.branches) { + if (hit(b.npc, q)) out.push({ key: b.branchKey ?? s.id, stateId: s.id, branchKey: b.branchKey }); + for (const r of b.replies) if (hit(r.text, q)) out.push({ key: r.id, stateId: s.id, choiceId: r.id }); + } + } else if (s.block) { + walkBlock(s.block, s.id); + } else { + for (const r of s.replies) if (hit(r.text, q)) out.push({ key: r.id, stateId: s.id, choiceId: r.id }); + } + + // Recurse into child states (first-expansion `state` targets), in render order: flat/branch replies, + // then block replies. Uses the shared `childStates` so matches follow the same visible layout every + // tree walk (reveal, collapse-all) traverses. + for (const k of childStates(s)) walkState(k); + }; + + for (const root of tree.roots) walkState(root); + return out; +} diff --git a/client/src/dialog-tree/dialogTree-d.ts b/client/src/dialog-tree/dialogTree-d.ts deleted file mode 100644 index ad37c7e06..000000000 --- a/client/src/dialog-tree/dialogTree-d.ts +++ /dev/null @@ -1,299 +0,0 @@ -/** - * D dialog tree builder and registration for WeiDU D and TD dialog preview. - * Handles both .d files (parsed via tree-sitter) and .td files (transpiled to D - * then parsed). Uses shared panel infrastructure from ./shared.ts. - */ - -import type * as vscode from "vscode"; -import type { LanguageClient } from "vscode-languageclient/node"; -import { escapeHtml, registerDialogPanel, type DialogPreviewController } from "./shared"; -import type { - DDialogData, - DDialogBlock, - DDialogBlockKind, - DDialogState, - DDialogTransition, - DDialogTarget, -} from "../../../shared/dialog-types"; - -// --------------------------------------------------------------------------- -// D-specific helpers -// --------------------------------------------------------------------------- - -/** Resolve @123 tra refs, return raw text (not escaped). */ -export function getResolvedText(text: string, messages: Record): string { - const traMatch = /^@(\d+)$/.exec(text); - if (traMatch && traMatch[1]) { - const resolved = messages[traMatch[1]]; - if (resolved) { - return resolved; - } - } - return text; -} - -/** Returns { plain, html } -- plain is raw (must be escaped before insertion into attributes), html is pre-escaped for display. */ -export function getTransitionText( - t: DDialogTransition, - messages: Record, -): { plain: string; html: string } { - if (t.replyText) { - const raw = getResolvedText(t.replyText, messages); - return { plain: raw, html: escapeHtml(raw) }; - } - // Silent transitions: show filter icon with trigger as tooltip instead of inline text - if (t.trigger) { - return { - plain: `[${t.trigger}]`, - html: ``, - }; - } - return { plain: "(auto)", html: `(auto)` }; -} - -export function renderTargetHtml(target: DDialogTarget): string { - switch (target.kind) { - case "goto": - return ` ${escapeHtml(target.label)}`; - case "extern": - return ` [EXTERN] ${escapeHtml(target.file)}:${escapeHtml(target.label)}`; - case "exit": - return ` EXIT`; - case "copy_trans": - return ` [COPY_TRANS] ${escapeHtml(target.file)}:${escapeHtml(target.label)}`; - } -} - -const STRUCTURAL_KINDS = new Set(["begin", "append", "chain", "extend", "interject", "replace"]); - -// --------------------------------------------------------------------------- -// Tree builder -// --------------------------------------------------------------------------- - -export function buildDTreeHtml(data: DDialogData): string { - const messages = data.messages ?? {}; - const stateMap = new Map(data.states.map((s) => [s.label, s])); - const rendered = new Set(); - const minDepth = new Map(); - - function computeDepths(label: string, depth: number, visited: Set): void { - if (visited.has(label)) return; - const currentMin = minDepth.get(label); - if (currentMin !== undefined && currentMin <= depth) return; - minDepth.set(label, depth); - const state = stateMap.get(label); - if (!state) return; - visited.add(label); - for (const t of state.transitions) { - if (t.target.kind === "goto") { - computeDepths(t.target.label, depth + 1, visited); - } - } - visited.delete(label); - } - - function renderState(state: DDialogState, currentDepth: number, defaultSpeaker?: string): string { - const stateMinDepth = minDepth.get(state.label); - - if (rendered.has(state.label) || (stateMinDepth !== undefined && stateMinDepth < currentDepth)) { - return ``; - } - - rendered.add(state.label); - - // Separate raw text (for attribute, escaped at insertion) from HTML (for display) - const sayRaw = state.sayText ? getResolvedText(state.sayText, messages) : ""; - const sayHtml = state.sayText ? escapeHtml(sayRaw) : ""; - // Only show speaker when it differs from the block's file (useful in CHAINs with alternating speakers) - const speaker = state.speaker; - const speakerHtml = - speaker !== undefined && speaker !== defaultSpeaker - ? ` [${escapeHtml(speaker)}]` - : ""; - const sayDisplay = sayHtml - ? ` ${sayHtml}` - : ""; - - const transitionParts: string[] = []; - for (const t of state.transitions) { - const { plain: textPlain, html: textHtml } = getTransitionText(t, messages); - const targetHtml = renderTargetHtml(t.target); - const triggerHtml = - t.trigger && t.replyText - ? ` ` - : ""; - const actionHtml = t.action - ? ` ` - : ""; - - if (t.target.kind === "goto") { - const targetState = stateMap.get(t.target.label); - const targetMinDepth = minDepth.get(t.target.label); - const shouldExpand = - targetState && !rendered.has(t.target.label) && targetMinDepth === currentDepth + 1; - - if (shouldExpand) { - const childHtml = renderState(targetState, currentDepth + 1, defaultSpeaker); - transitionParts.push(`
- ${triggerHtml} ${textHtml}${actionHtml}${targetHtml} -
${childHtml}
-
`); - } else { - transitionParts.push( - `
${triggerHtml} ${textHtml}${actionHtml}${targetHtml}
`, - ); - } - } else { - const icon = t.target.kind === "exit" ? "stop-circle" : "arrow-right"; - transitionParts.push( - `
${triggerHtml} ${textHtml}${actionHtml}${targetHtml}
`, - ); - } - } - - const childrenHtml = transitionParts.join(""); - - if (!childrenHtml) { - return `
${escapeHtml(state.label)}${speakerHtml}${sayDisplay}
`; - } - - return `
- ${escapeHtml(state.label)}${speakerHtml}${sayDisplay} -
${childrenHtml}
-
`; - } - - function renderStructuralBlock(block: DDialogBlock, blockStates: DDialogState[]): string { - const blockKind = block.kind.toUpperCase(); - const blockTitle = block.label ? `${blockKind} ${escapeHtml(block.label)}` : blockKind; - - for (const state of blockStates) { - computeDepths(state.label, 1, new Set()); - } - - const stateHtmlParts: string[] = []; - for (const state of blockStates) { - if (!rendered.has(state.label)) { - stateHtmlParts.push(renderState(state, 1, block.file)); - } - } - - if (stateHtmlParts.length === 0) { - return ""; - } - - return `
- ${blockTitle} -
${stateHtmlParts.join("")}
-
`; - } - - function renderModifyBlock(block: DDialogBlock): string { - const actionName = block.actionName ?? "MODIFY"; - const refsHtml = - block.stateRefs && block.stateRefs.length > 0 - ? ` ${block.stateRefs.map((ref) => `${escapeHtml(ref)}`).join(", ")}` - : ""; - const desc = block.description ? ` ${escapeHtml(block.description)}` : ""; - return `
${escapeHtml(actionName)}${refsHtml}${desc}
`; - } - - if (data.blocks.length === 0) { - return "

No dialog data found

"; - } - - // Group blocks by target file - const fileOrder: string[] = []; - const fileBlocks = new Map(); - for (const block of data.blocks) { - const existing = fileBlocks.get(block.file); - if (existing) { - existing.push(block); - } else { - fileOrder.push(block.file); - fileBlocks.set(block.file, [block]); - } - } - - const fileParts: string[] = []; - - for (const file of fileOrder) { - const blocks = fileBlocks.get(file)!; - - // Partition: structural first, modify second - const structuralBlocks = blocks.filter((b) => STRUCTURAL_KINDS.has(b.kind)); - const modifyBlocks = blocks.filter((b) => b.kind === "modify"); - - const innerParts: string[] = []; - - // Render structural blocks with their states - for (const block of structuralBlocks) { - const blockStates = getBlockStates(block, data.states); - const html = renderStructuralBlock(block, blockStates); - if (html) { - innerParts.push(html); - } - } - - // Render modify blocks as compact entries - if (modifyBlocks.length > 0) { - const modifyHtml = modifyBlocks.map((block) => renderModifyBlock(block)).join(""); - innerParts.push(`
- Modifications (${modifyBlocks.length}) -
${modifyHtml}
-
`); - } - - if (innerParts.length === 0) { - continue; - } - - // Skip file-level grouping when there's only one target file - if (fileOrder.length === 1) { - fileParts.push(...innerParts); - } else { - fileParts.push(`
- ${escapeHtml(file)} -
${innerParts.join("")}
-
`); - } - } - - if (fileParts.length === 0) { - return "

No dialog states found

"; - } - - return fileParts.join(""); -} - -// --------------------------------------------------------------------------- -// Block-to-state matching -// --------------------------------------------------------------------------- - -export function getBlockStates(block: DDialogBlock, states: DDialogState[]): DDialogState[] { - if (block.kind === "chain" || block.kind === "interject") { - return states.filter((s) => s.blockLabel === block.label); - } - if (block.kind === "extend") { - // EXTEND pseudo-states are tagged with blockLabel "extend_{line}" - const tag = `extend_${block.line}`; - return states.filter((s) => s.blockLabel === tag); - } - // For begin/append/replace: match by speaker and no blockLabel - return states.filter((s) => s.speaker === block.file && !s.blockLabel); -} - -// --------------------------------------------------------------------------- -// Registration -// --------------------------------------------------------------------------- - -export function registerDDialogTree(context: vscode.ExtensionContext, client: LanguageClient): DialogPreviewController { - return registerDialogPanel(context, client, { - matchDocument: (doc) => doc.languageId === "weidu-d" || doc.fileName.toLowerCase().endsWith(".td"), - warningMessage: "Open a D or TD file to preview dialog", - translationLangId: "weidu-tra", - buildTreeHtml: (data) => buildDTreeHtml(data), - hasData: (data) => data.blocks.length > 0, - tabIconPath: "themes/icons/weidu.svg", - }); -} diff --git a/client/src/dialog-tree/dialogTree-webview.ts b/client/src/dialog-tree/dialogTree-webview.ts deleted file mode 100644 index 3927bfb04..000000000 --- a/client/src/dialog-tree/dialogTree-webview.ts +++ /dev/null @@ -1,403 +0,0 @@ -// Dialog tree webview script (SSL, D, TD, TSSL). -// Wrapped in IIFE to avoid global scope conflicts with other webview scripts. -import { escapeHtml } from "../utils"; -import { installFatalErrorHandler, setSearchPlaceholder, debounce } from "../webview-utils"; - -(function () { - // @ts-expect-error -- acquireVsCodeApi is injected by VSCode webview runtime - const vscode = acquireVsCodeApi(); - - // Align .msg-text widths within each sibling group so action icons and targets line up. - // Sets flex-basis to the widest sibling's natural width (scrollWidth). - // flex-basis (not min-width) allows graceful shrinking when the container is narrow. - // Uses three-pass approach (reset -> measure -> apply) to avoid layout thrashing. - function alignSiblingMsgTexts(): void { - // Collect groups: each group is the sibling .msg-text elements within one .children container - const groups: HTMLElement[][] = []; - document.querySelectorAll(".children").forEach((container) => { - const msgTexts: HTMLElement[] = []; - for (const child of container.children) { - let msgText: HTMLElement | null = null; - if (child.classList.contains("item")) { - msgText = child.querySelector(".msg-text"); - } else if (child.tagName === "DETAILS") { - const summary = child.querySelector(":scope > summary"); - if (summary) { - msgText = summary.querySelector(".msg-text"); - } - } - if (msgText) { - msgTexts.push(msgText); - } - } - if (msgTexts.length >= 2) { - groups.push(msgTexts); - } - }); - - // Pass 1: Reset all flex-basis (batch writes) - for (const group of groups) { - for (const el of group) { - el.style.flexBasis = ""; - } - } - - // Pass 2: Measure max width per group (batch reads -- single reflow) - const maxWidths: number[] = []; - for (const group of groups) { - let maxWidth = 0; - for (const el of group) { - maxWidth = Math.max(maxWidth, el.scrollWidth); - } - maxWidths.push(maxWidth); - } - - // Pass 3: Apply flex-basis per group (batch writes) - for (let i = 0; i < groups.length; i++) { - // Safe: groups and maxWidths are built in lockstep - const width = maxWidths[i]!; - for (const el of groups[i]!) { - el.style.flexBasis = width + "px"; - } - } - } - - // Update tooltips only for overflowing text - function updateOverflowTooltips(): void { - document.querySelectorAll(".msg-text[data-fulltext]").forEach((el) => { - const htmlEl = el as HTMLElement; - if (htmlEl.scrollWidth > htmlEl.clientWidth) { - htmlEl.title = htmlEl.dataset.fulltext || ""; - } else { - htmlEl.removeAttribute("title"); - } - }); - } - - // Select an element - removes previous selection and highlights new one - function selectElement(el: Element): void { - document.querySelectorAll(".selected").forEach((e) => e.classList.remove("selected")); - el.classList.add("selected"); - } - - // Get required DOM elements (null check ensures they exist before cast) - const searchInputEl = document.getElementById("search"); - const searchResultsEl = document.querySelector(".search-results"); - const treeEl = document.querySelector(".tree"); - const expandAllBtn = document.getElementById("expandAll"); - const collapseAllBtn = document.getElementById("collapseAll"); - - if ( - searchInputEl === null || - searchResultsEl === null || - treeEl === null || - expandAllBtn === null || - collapseAllBtn === null - ) { - throw new Error("Required DOM elements not found"); - } - const searchInput = searchInputEl as HTMLInputElement; - const searchResults = searchResultsEl as HTMLElement; - const tree = treeEl as HTMLElement; - const sidebar = document.querySelector(".sidebar"); - - installFatalErrorHandler({ - vscode, - label: "Dialog preview", - render: (detail) => { - const existing = document.querySelector(".errors"); - existing?.remove(); - - const wrapper = document.createElement("div"); - wrapper.className = "errors"; - const line = document.createElement("div"); - line.textContent = detail; - wrapper.append(line); - - tree.replaceChildren(); - tree.classList.remove("hidden"); - tree.parentElement?.insertBefore(wrapper, tree); - searchResults.classList.add("hidden"); - searchResults.innerHTML = ""; - sidebar?.classList.add("hidden"); - }, - }); - - // Set platform-aware search placeholder - setSearchPlaceholder(searchInput); - - interface SearchResult { - type: "node" | "item"; - name?: string; - id?: string; - text?: string; - msgText?: string; - parentName?: string; - parentId?: string; - } - - // Search functionality - shows flat results list, hides tree - function filterTree(query: string): void { - const lowerQuery = query.toLowerCase().trim(); - - // If empty query, show tree, hide results - if (!lowerQuery) { - searchResults.classList.add("hidden"); - searchResults.innerHTML = ""; - tree.classList.remove("hidden"); - return; - } - - // Hide tree, show results - tree.classList.add("hidden"); - searchResults.classList.remove("hidden"); - - const results: SearchResult[] = []; - - // Find matching node names (first occurrences have id="node-XXX") - document.querySelectorAll('[id^="node-"]').forEach((nodeEl) => { - const nodeName = nodeEl.id.replace("node-", ""); - if (nodeName.toLowerCase().includes(lowerQuery)) { - results.push({ type: "node", name: nodeName, id: nodeEl.id }); - } - }); - - // Find matching replies/options by text content and attributes - document.querySelectorAll(".item.reply, .item.option, summary.option").forEach((itemEl) => { - // Element.textContent is always string at runtime (never null for elements) - const textContent = itemEl.textContent!; - // Also search in title attributes (contains type/msgId like "NLowOption(873)") - const titles = [...itemEl.querySelectorAll("[title]")] - .map((el) => el.getAttribute("title") || "") - .join(" "); - const fullText = textContent + " " + titles; - if (fullText.toLowerCase().includes(lowerQuery)) { - // Find parent node name - const parentNode = itemEl.closest('[id^="node-"]'); - const parentName = parentNode ? parentNode.id.replace("node-", "") : ""; - // Store msg-text for navigation matching - const msgTextEl = itemEl.querySelector(".msg-text"); - const msgText = msgTextEl ? msgTextEl.textContent!.trim() : ""; - // Build display text including the type from title - const typeTitle = itemEl.querySelector(".codicon[title]")?.getAttribute("title") || ""; - const displayText = typeTitle ? `${typeTitle}: ${textContent.trim()}` : textContent.trim(); - results.push({ - type: "item", - text: displayText, - parentName: parentName, - parentId: parentNode?.id, - msgText, - }); - } - }); - - // Find matching inline text on node summaries (first reply/say text shown inline with the node name). - // Uses direct child selector (>) to avoid hitting option summaries already covered above. - document.querySelectorAll("details.node > summary .msg-text[data-fulltext]").forEach((msgTextEl) => { - const htmlEl = msgTextEl as HTMLElement; - const fulltext = htmlEl.dataset.fulltext || ""; - if (!fulltext.toLowerCase().includes(lowerQuery)) return; - - const nodeEl = msgTextEl.closest('[id^="node-"]'); - const nodeName = nodeEl ? nodeEl.id.replace("node-", "") : ""; - const summary = msgTextEl.closest("summary"); - if (!summary) return; // Structurally guaranteed by selector, but guard defensively - const typeTitle = summary.querySelector(".codicon[title]")?.getAttribute("title") || ""; - // For D dialog say text (no typeTitle), check .reply class to preserve reply color in results - const isReply = msgTextEl.classList.contains("reply"); - const displayText = typeTitle ? `${typeTitle}: ${fulltext}` : isReply ? `Reply: ${fulltext}` : fulltext; - - results.push({ - type: "item", - text: displayText, - parentName: nodeName, - parentId: nodeEl?.id, - msgText: htmlEl.textContent!.trim(), - }); - }); - - // Render flat results - store item text in data-text for finding the specific item. - // All values are re-escaped because DOM properties (.id, .textContent) decode HTML entities. - searchResults.innerHTML = results - .map((r) => { - if (r.type === "node") { - return ( - `
` + - ` ` + - `${escapeHtml(r.name ?? "")}
` - ); - } else { - const prefix = r.parentName ? `${escapeHtml(r.parentName)}: ` : ""; - const text = r.text ?? ""; - // Determine color class from option type prefix (G=good, B=bad, N=neutral) - let cls = "option"; - if (text.startsWith("Reply")) { - cls = "reply"; - } else if (text.startsWith("G")) { - cls = "option-good"; - } else if (text.startsWith("B")) { - cls = "option-bad"; - } else { - cls = "option-neutral"; - } - return ( - `
` + - `${prefix}${escapeHtml(text)}
` - ); - } - }) - .join(""); - } - - function clearSearch(scrollTarget?: Element): void { - if (searchInput.value) { - searchInput.value = ""; - filterTree(""); - // Scroll to target after restoring tree - if (scrollTarget) { - scrollTarget.scrollIntoView({ block: "center" }); - } - } - } - - // Navigate to a node: expand parents, scroll, select - // If itemText is provided, find and select the specific reply/option within the node - function navigateToNode(targetId: string, itemText?: string): void { - const targetEl = document.getElementById(targetId); - if (targetEl) { - // Expand all parent
elements to make target visible - let parent = targetEl.parentElement; - while (parent) { - if (parent.tagName === "DETAILS") { - (parent as HTMLDetailsElement).open = true; - } - parent = parent.parentElement; - } - // Also expand the target node itself - if (targetEl.tagName === "DETAILS") { - (targetEl as HTMLDetailsElement).open = true; - } - - // Find the specific item within the node if itemText is provided - let elementToSelect: Element | null = null; - if (itemText) { - // Search for the specific reply/option by message text content - const items = [...targetEl.querySelectorAll(".item.reply, .item.option, summary.option")]; - for (const item of items) { - const msgTextEl = item.querySelector(".msg-text"); - const msgText = msgTextEl ? msgTextEl.textContent!.trim() : undefined; - if (msgText === itemText) { - elementToSelect = item; - break; - } - } - } - - // Fall back to the node summary, or the node itself if inline - if (elementToSelect === null) { - elementToSelect = targetEl.querySelector("summary") ?? targetEl; - } - - elementToSelect.scrollIntoView({ block: "center" }); - selectElement(elementToSelect); - } - } - - document.body.addEventListener("click", (e) => { - const target = e.target as Element; - - // Handle clicks on "(see above)" links that reference other nodes - const link = target.closest(".node-link") as HTMLElement | null; - if (link) { - e.preventDefault(); - // stopPropagation prevents VSCode webview from jumping to top on link click - e.stopPropagation(); - clearSearch(); - - const targetId = "node-" + link.dataset.target; - navigateToNode(targetId); - return; - } - - // Handle clicks on search results - navigate to node/item in tree - const result = target.closest(".search-results .result") as HTMLElement | null; - if (result) { - const targetId = result.dataset.target; - const itemText = result.dataset.text; // Text of specific reply/option to select - // Clear search first to show tree - searchInput.value = ""; - filterTree(""); - if (targetId) { - navigateToNode(targetId, itemText); - } - return; - } - - // Handle regular clicks on tree items - select and clear search - const item = target.closest("summary, .item"); - if (item) { - selectElement(item); - clearSearch(item); - } - }); - - // Toolbar buttons - expandAllBtn.addEventListener("click", () => { - document.querySelectorAll("details").forEach((d) => (d.open = true)); - alignSiblingMsgTexts(); - updateOverflowTooltips(); - }); - - collapseAllBtn.addEventListener("click", () => { - document.querySelectorAll("details").forEach((d) => (d.open = false)); - }); - - // Debounced search to avoid lag on large dialogs - const debouncedFilter = debounce(() => filterTree(searchInput.value), 150); - searchInput.addEventListener("input", debouncedFilter); - - // Update alignment and tooltips when details are toggled - document.addEventListener( - "toggle", - (e) => { - if ((e.target as HTMLElement).tagName === "DETAILS") { - alignSiblingMsgTexts(); - updateOverflowTooltips(); - } - }, - true, - ); - - // Ctrl+F or / focuses search, Escape clears - document.addEventListener("keydown", (e) => { - if ((e.ctrlKey || e.metaKey) && e.key === "f") { - e.preventDefault(); - searchInput.focus(); - searchInput.select(); - } - // "/" focuses search (vim-style) when not already typing in an input/textarea - if ( - e.key === "/" && - !( - document.activeElement instanceof HTMLInputElement || - document.activeElement instanceof HTMLTextAreaElement - ) - ) { - e.preventDefault(); - searchInput.focus(); - searchInput.select(); - } - if (e.key === "Escape" && document.activeElement === searchInput) { - clearSearch(); - searchInput.blur(); - } - }); - - // Align text and update tooltips on load and resize - alignSiblingMsgTexts(); - updateOverflowTooltips(); - window.addEventListener("resize", () => { - alignSiblingMsgTexts(); - updateOverflowTooltips(); - }); -})(); diff --git a/client/src/dialog-tree/dialogTree.css b/client/src/dialog-tree/dialogTree.css deleted file mode 100644 index a84211dea..000000000 --- a/client/src/dialog-tree/dialogTree.css +++ /dev/null @@ -1,264 +0,0 @@ -/** - * Dialog tree preview styles (SSL, D, TD, TSSL). - * Loaded after webview-common.css which provides shared layout - * (header, sidebar, toolbar, search-box, hidden). - * See also: ../webview-common.css - */ - -body { - --dialog-node-name: #c586c0; - --dialog-reply: #9cdcfe; - --dialog-option-neutral: #4ec9b0; - --dialog-option-good: #6a9955; - --dialog-option-bad: #f14c4c; - --dialog-msg-text: #dcdcaa; - --dialog-target-arrow: #888; - --dialog-trigger: #4ec9b0; - --dialog-action: #d16969; - - font-family: var(--vscode-font-family, sans-serif); - font-size: var(--vscode-font-size, 13px); - color: var(--vscode-foreground); - background-color: var(--vscode-editor-background); - padding: 10px 20px; - margin: 0; - /* clip (not hidden) prevents horizontal scrollbar from .selected negative-margin trick - without creating a scroll container that would break position:sticky on the sidebar */ - overflow-x: clip; -} - -/* Override common h1: dialog body uses UI font, so h1 needs explicit editor font/color/size */ -.header h1 { - font-family: var(--vscode-editor-font-family, monospace); - /* Use calc to match .pro viewer: 1.4x the editor font size, independent of body font-size */ - font-size: calc(var(--vscode-editor-font-size, 13px) * 1.4); - color: var(--vscode-editor-foreground); -} - -.header .path { - color: var(--vscode-descriptionForeground); - font-size: 0.9em; - margin-bottom: 4px; - display: flex; - align-items: center; -} - -.breadcrumb-sep { - font-size: 10px; - opacity: 0.7; -} - -.breadcrumb-icon { - width: 16px; - height: 16px; - vertical-align: middle; -} - -.tree-container { - min-width: 0; - flex: 1; -} - -details { - margin: 2px 0; -} - -summary { - cursor: pointer; - padding: 3px 0; - list-style: none; - display: flex; - align-items: center; - gap: 6px; -} - -summary::-webkit-details-marker { - display: none; -} - -summary::before { - content: "\eab6"; - font-family: codicon; - transition: transform 0.1s; -} - -details[open] > summary::before { - transform: rotate(90deg); -} - -.children { - margin-left: 32px; -} - -.item { - padding: 3px 0; - display: flex; - align-items: center; - gap: 6px; -} - -.desc { - opacity: 0.7; - margin-left: 8px; -} - -.codicon { - font-size: 14px; -} - -.node-name { - color: var(--dialog-node-name); -} - -.reply, -.reply .msg-text, -.reply.msg-text { - color: var(--dialog-reply); -} - -.option-neutral { - color: var(--dialog-option-neutral); -} - -.option-good { - color: var(--dialog-option-good); -} - -.option-bad { - color: var(--dialog-option-bad); -} - -.option-detail { - margin: 2px 0; -} - -.target { - color: #888; -} - -.target-arrow { - color: var(--dialog-target-arrow); - vertical-align: middle; -} - -.node-ref { - opacity: 0.7; - font-style: italic; -} - -.node-transition, -.node-inline { - opacity: 0.8; - padding-left: 20px; /* Align with expandable nodes (chevron width + gap) */ -} - -.node-transition.selected, -.node-inline.selected { - padding-left: 1020px; /* 1000px base + 20px indent */ -} - -.msg-text { - color: var(--dialog-msg-text); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - min-width: 0; -} - -.target-link { - flex-shrink: 0; - white-space: nowrap; - display: inline-flex; - align-items: center; - gap: 4px; -} - -.node-link { - color: var(--vscode-textLink-foreground, #3794ff); - text-decoration: none; -} - -.node-link:hover { - text-decoration: underline; -} - -.selected { - background: rgba(255, 255, 255, 0.1); - outline: 1px solid var(--vscode-focusBorder, #007fd4); - margin-left: -1000px; - margin-right: -1000px; - padding-left: 1000px; - padding-right: 1000px; -} - -summary:hover, -.item:hover { - background: var(--vscode-list-hoverBackground, rgba(255, 255, 255, 0.1)); -} - -.search-results { - margin-bottom: 10px; -} - -.search-results .result { - padding: 3px 0; - display: flex; - align-items: center; - gap: 6px; - cursor: pointer; -} - -.search-results .result:hover { - background: var(--vscode-list-hoverBackground, rgba(255, 255, 255, 0.1)); -} - -/* D dialog-specific styles */ - -.extern-marker { - color: #888; - font-style: italic; -} - -.exit-marker { - color: var(--dialog-option-bad); -} - -.speaker-label { - color: #888; - font-size: 12px; -} - -.block-header { - color: var(--dialog-node-name); - font-weight: bold; -} - -.silent-transition { - color: var(--dialog-trigger); - font-style: italic; -} - -.modify-entry { - opacity: 0.8; -} - -.modify-action { - color: var(--dialog-node-name); - font-weight: bold; - font-size: 12px; -} - -.trigger-detail { - color: var(--dialog-trigger); - font-size: 12px; -} - -.action-detail { - color: var(--dialog-action); - font-size: 12px; -} - -.trigger-detail .codicon, -.action-detail .codicon { - opacity: 0.6; -} diff --git a/client/src/dialog-tree/dialogTree.html b/client/src/dialog-tree/dialogTree.html deleted file mode 100644 index 326735eaf..000000000 --- a/client/src/dialog-tree/dialogTree.html +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - - -
-
{{filePath}}
-

{{fileName}}

-
Dialog Preview
-
-
-
- -
{{treeContent}}
-
- -
- - - diff --git a/client/src/dialog-tree/dialogTree.ts b/client/src/dialog-tree/dialogTree.ts deleted file mode 100644 index 1d671aa8c..000000000 --- a/client/src/dialog-tree/dialogTree.ts +++ /dev/null @@ -1,246 +0,0 @@ -/** - * SSL dialog tree builder and registration for Fallout SSL dialog preview. - * Uses shared panel infrastructure from ./shared.ts. - */ - -import type * as vscode from "vscode"; -import type { LanguageClient } from "vscode-languageclient/node"; -import type { SSLDialogData, SSLDialogNode, SSLDialogOption } from "../../../shared/dialog-types"; -import { escapeHtml, registerDialogPanel, type DialogPreviewController } from "./shared"; - -// --------------------------------------------------------------------------- -// Data model - shared with server/src/dialog.ts via shared/dialog-types.ts. -// Re-export under shorter local names so the rest of this module reads naturally. -// --------------------------------------------------------------------------- - -export type DialogOption = SSLDialogOption; -export type DialogNode = SSLDialogNode; -/** Webview-side narrowing: `messages` is required after the client has resolved translations. */ -export type DialogData = SSLDialogData & { messages: Record }; - -// --------------------------------------------------------------------------- -// SSL-specific helpers -// --------------------------------------------------------------------------- - -/** Resolve msgId to raw text (not escaped). Used for attribute values. */ -export function getMsgTextRaw(msgId: number | string, messages: Record): string { - if (typeof msgId === "string") { - return msgId; - } - const text = messages[String(msgId)]; - return text ?? `(${msgId})`; -} - -/** Resolve msgId to HTML-escaped text. Used for element content. */ -export function getMsgText(msgId: number | string, messages: Record): string { - return escapeHtml(getMsgTextRaw(msgId, messages)); -} - -interface OptionMeta { - colorClass: string; - tooltip: string; - lowEmoji: string; - icon: string; -} - -export function getOptionMeta(o: DialogOption): OptionMeta { - const isMessage = o.type.endsWith("Message"); - const colorClass = o.type.startsWith("G") - ? "option-good" - : o.type.startsWith("B") - ? "option-bad" - : "option-neutral"; - const tooltip = escapeHtml(`${o.type}(${o.msgId})`); - const isLow = o.type.includes("Low"); - const lowEmoji = isLow ? `🤪` : ""; - const icon = isMessage ? "stop-circle" : "arrow-right"; - return { colorClass, tooltip, lowEmoji, icon }; -} - -// --------------------------------------------------------------------------- -// Tree builder -// --------------------------------------------------------------------------- - -export function buildTreeHtml(data: DialogData): string { - const nodeMap = new Map(data.nodes.map((n) => [n.name, n])); - const messages = data.messages; - - // Entry points called from talk_p_proc - const entryPointNames = data.entryPoints.filter((name) => name.startsWith("Node")); - const entries = entryPointNames.map((name) => nodeMap.get(name)).filter((n): n is DialogNode => n !== undefined); - - // First pass: compute minimum depth for each node (closest to root wins) - const minDepth = new Map(); - - function computeDepths(nodeName: string, depth: number, path: Set): void { - if (path.has(nodeName)) return; // cycle detection - - const currentMin = minDepth.get(nodeName); - if (currentMin !== undefined && currentMin <= depth) return; - - minDepth.set(nodeName, depth); - - const node = nodeMap.get(nodeName); - if (!node) return; - - path.add(nodeName); - - for (const o of node.options) { - if (o.target && nodeMap.has(o.target)) { - computeDepths(o.target, depth + 1, path); - } - } - for (const t of node.callTargets) { - if (nodeMap.has(t)) { - computeDepths(t, depth + 1, path); - } - } - - path.delete(nodeName); - } - - // Compute depths starting from entry points at depth 1 - for (const name of entryPointNames) { - computeDepths(name, 1, new Set()); - } - - // Second pass: render tree - const rendered = new Set(); - - const renderNode = (node: DialogNode, currentDepth: number): string => { - const nodeMinDepth = minDepth.get(node.name); - - // Show link if: already rendered, or this node should appear at a shallower level - if (rendered.has(node.name) || (nodeMinDepth !== undefined && nodeMinDepth < currentDepth)) { - return ``; - } - - rendered.add(node.name); - - // If node only has call targets (no replies/options), show inline - if (node.replies.length === 0 && node.options.length === 0 && node.callTargets.length > 0) { - const targets = node.callTargets - .map((t) => { - const escaped = escapeHtml(t); - const targetNode = nodeMap.get(t); - return targetNode - ? `${escaped}` - : `${escaped}`; - }) - .join(", "); - return `
${escapeHtml(node.name)} ${targets}
`; - } - - // Find first item to show inline (reply or terminal message) - let inlineHtml = ""; - let skipFirstReply = false; - let skipFirstTerminalOption = -1; // index of terminal option to skip - - if (node.replies.length > 0) { - // First reply goes inline - // Safe: length check above guarantees index 0 exists - const r = node.replies[0]!; - const attr = escapeHtml(getMsgTextRaw(r.msgId, messages)); - const text = getMsgText(r.msgId, messages); - inlineHtml = ` ${text}`; - skipFirstReply = true; - } else { - // Check for terminal message (option without target) - const terminalIdx = node.options.findIndex((o) => !o.target); - if (terminalIdx !== -1) { - // Safe: findIndex returned valid index - const o = node.options[terminalIdx]!; - const { colorClass, tooltip, lowEmoji } = getOptionMeta(o); - const attr = escapeHtml(getMsgTextRaw(o.msgId, messages)); - const text = getMsgText(o.msgId, messages); - inlineHtml = `${lowEmoji} ${text}`; - skipFirstTerminalOption = terminalIdx; - } - } - - // Build remaining replies (skip first if shown inline) - const replies = node.replies - .slice(skipFirstReply ? 1 : 0) - .map((r) => { - const attr = escapeHtml(getMsgTextRaw(r.msgId, messages)); - const text = getMsgText(r.msgId, messages); - return `
${text}
`; - }) - .join(""); - - // Build options (skip terminal if shown inline) - const optionParts: string[] = []; - node.options.forEach((o, i) => { - if (i === skipFirstTerminalOption) return; - - const { colorClass, tooltip, lowEmoji, icon } = getOptionMeta(o); - const attr = escapeHtml(getMsgTextRaw(o.msgId, messages)); - const text = getMsgText(o.msgId, messages); - - if (o.target) { - const targetNode = nodeMap.get(o.target); - const targetMinDepth = minDepth.get(o.target); - const shouldRenderChild = targetNode && !rendered.has(o.target) && targetMinDepth === currentDepth + 1; - const escapedTarget = escapeHtml(o.target); - const targetHtml = targetNode - ? `${escapedTarget}` - : `${escapedTarget}`; - - if (shouldRenderChild) { - // Render option as expandable with target as child - const childHtml = renderNode(targetNode, currentDepth + 1); - optionParts.push(`
- ${lowEmoji} ${text} ${targetHtml} -
${childHtml}
-
`); - } else { - // Just a link, no nested content - optionParts.push( - `
${lowEmoji} ${text} ${targetHtml}
`, - ); - } - } else { - optionParts.push( - `
${lowEmoji} ${text}
`, - ); - } - }); - const options = optionParts.join(""); - - const children = replies + options; - - // If no children remain, render as single line - if (!children) { - return `
${escapeHtml(node.name)} ${inlineHtml}
`; - } - - return `
- ${escapeHtml(node.name)} ${inlineHtml} -
${children}
-
`; - }; - - if (entries.length === 0) return "

No dialog nodes found

"; - - const entryHtml = entries.map((node) => renderNode(node, 1)).join(""); - - return `
- talk_p_proc -
${entryHtml}
-
`; -} - -// --------------------------------------------------------------------------- -// Registration -// --------------------------------------------------------------------------- - -export function registerDialogTree(context: vscode.ExtensionContext, client: LanguageClient): DialogPreviewController { - return registerDialogPanel(context, client, { - matchDocument: (doc) => doc.languageId === "fallout-ssl" || doc.fileName.toLowerCase().endsWith(".tssl"), - warningMessage: "Open a Fallout SSL or TSSL file to preview dialog", - translationLangId: "fallout-msg", - buildTreeHtml: (data) => buildTreeHtml(data), - hasData: (data) => data.nodes.length > 0, - tabIconPath: "themes/icons/fallout-ssl.svg", - }); -} diff --git a/client/src/dialog-tree/shared.ts b/client/src/dialog-tree/shared.ts deleted file mode 100644 index bac127917..000000000 --- a/client/src/dialog-tree/shared.ts +++ /dev/null @@ -1,358 +0,0 @@ -/** - * Shared infrastructure for dialog tree preview panels. - * Asset caching, HTML assembly, and panel lifecycle management - * shared between SSL, D, and TD dialog previews. - * escapeHtml is re-exported from ../utils.ts (single source of truth). - * CSS is loaded from ../webview-common.css + ./dialogTree.css (shared with binaryEditor). - */ - -import * as vscode from "vscode"; -import * as path from "path"; -import { type LanguageClient, type ExecuteCommandParams, ExecuteCommandRequest } from "vscode-languageclient/node"; -import { conlog } from "../logging"; -import { escapeHtml } from "../utils"; -import { getCachedCssAsset, getCachedHtmlAsset, getCachedJsAsset, generateNonce } from "../webview-assets"; -import { LSP_COMMAND_PARSE_DIALOG } from "../../../shared/protocol"; -import { surfaceWebviewRuntimeError } from "../webview-error"; - -function getHtmlTemplate(extensionPath: string): string { - return getCachedHtmlAsset( - "dialog-tree", - extensionPath, - path.join("client", "src", "dialog-tree", "dialogTree.html"), - ); -} - -function getCss(extensionPath: string): string { - return getCachedCssAsset("dialog-tree", extensionPath, [ - path.join("client", "src", "webview-common.css"), - path.join("client", "src", "dialog-tree", "dialogTree.css"), - ]); -} - -function getJs(extensionPath: string): string { - return getCachedJsAsset( - "dialog-tree", - extensionPath, - path.join("client", "out", "dialog-tree", "dialogTree-webview.js"), - ); -} - -// Re-export so dialog tree builders (dialogTree.ts, dialogTree-d.ts) can import from "./shared" -export { escapeHtml }; - -// --------------------------------------------------------------------------- -// Refresh-failure tracking -// --------------------------------------------------------------------------- - -/** - * Tracks consecutive refresh failures and surfaces a user-visible error after a - * threshold is reached. Transient failures during typing are common (the LSP - * parser may be mid-update); a low-threshold escalation would spam the user - * while a never-surfacing one would let real breakage hide as a frozen preview. - * - * Surfaces exactly once per failure streak: after the Nth consecutive failure - * fires `onSurface`, further failures stay silent until a `recordSuccess` - * re-arms the tracker. - */ -interface RefreshFailureTracker { - recordFailure(err: unknown): void; - recordSuccess(): void; -} - -export function createRefreshFailureTracker(options: { - threshold: number; - onSurface: (message: string) => void; -}): RefreshFailureTracker { - let consecutive = 0; - let surfaced = false; - return { - recordFailure(err: unknown): void { - consecutive++; - if (!surfaced && consecutive >= options.threshold) { - surfaced = true; - const message = err instanceof Error ? err.message : String(err); - options.onSurface(message); - } - }, - recordSuccess(): void { - consecutive = 0; - surfaced = false; - }, - }; -} - -// --------------------------------------------------------------------------- -// HTML assembly -// --------------------------------------------------------------------------- - -/** Convert "a/b/c.ssl" to breadcrumb HTML: "a > b > icon c.ssl" with chevron separators and file icon on the last segment. */ -function buildBreadcrumbHtml(filePath: string, iconUri: string): string { - const segments = filePath.split(/[/\\]/).filter(Boolean); - if (segments.length === 0) return ""; - const separator = ' '; - return segments - .map((s, i) => { - const icon = - i === segments.length - 1 ? ` ` : ""; - return `${icon}${escapeHtml(s)}`; - }) - .join(separator); -} - -function getDialogPreviewHtml( - treeContent: string, - codiconsUri: string, - cspSource: string, - extensionPath: string, - fileName: string, - filePath: string, - iconUri: string, -): string { - const nonce = generateNonce(); - // Function replacers prevent $-pattern interpretation in replacement strings - // ($&, $', $` are special even with string search patterns). - return getHtmlTemplate(extensionPath) - .replace("{{codiconsUri}}", () => codiconsUri) - .replace("{{cssUri}}", () => "") - .replace("{{scriptUri}}", () => "") - .replace('', () => ``) - .replace('', () => ``) - .replace("{{filePath}}", () => buildBreadcrumbHtml(filePath, iconUri)) - .replace("{{fileName}}", () => escapeHtml(fileName)) - .replace("{{treeContent}}", () => treeContent) - .replaceAll("{{cspSource}}", cspSource) - .replaceAll("{{nonce}}", nonce); -} - -// --------------------------------------------------------------------------- -// Panel lifecycle -// --------------------------------------------------------------------------- - -interface DialogPanelConfig { - /** Check whether a document should use this panel. */ - matchDocument: (doc: vscode.TextDocument) => boolean; - /** Warning message shown when no matching file is open */ - warningMessage: string; - /** Language ID of translation files that trigger refresh on save */ - translationLangId: string; - /** Build the tree HTML from server response data */ - buildTreeHtml: (data: TData) => string; - /** Check if data is non-empty (to decide whether to show "no data" warning) */ - hasData: (data: TData) => boolean; - /** Relative path within extension to the tab icon (e.g. "themes/icons/fallout-ssl.svg") */ - tabIconPath: string; -} - -interface DialogTreeRuntimeErrorMessage { - readonly type: "runtimeError"; - readonly message: string; - readonly stack?: string; -} - -export interface DialogPreviewController { - matchesDocument: (doc: vscode.TextDocument) => boolean; - openPreview: () => Promise; -} - -/** - * Register a dialog preview panel with shared lifecycle management. - * Handles panel creation, debounced refresh, document change watching, - * save watching, and command registration. - */ -export function registerDialogPanel( - context: vscode.ExtensionContext, - client: LanguageClient, - config: DialogPanelConfig, -): DialogPreviewController { - let dialogPanel: vscode.WebviewPanel | undefined; - let currentDocumentUri: string | undefined; - let currentFileName: string | undefined; - let currentFilePath: string | undefined; - let refreshTimeout: NodeJS.Timeout | undefined; - - // Surface a user-visible error after several consecutive refresh failures. - // A single failure during typing is normal (the parser may be mid-update); - // sustained failures mean the preview is frozen at the last good state and - // the user has no other signal that something is wrong. - const failureTracker = createRefreshFailureTracker({ - threshold: 3, - onSurface: (message) => { - void vscode.window.showErrorMessage( - `Dialog preview refresh failing for ${currentFileName ?? "dialog"}: ${message}`, - ); - }, - }); - - async function refreshPreview() { - if (!dialogPanel || !currentDocumentUri) return; - - const params: ExecuteCommandParams = { - command: LSP_COMMAND_PARSE_DIALOG, - arguments: [{ uri: currentDocumentUri }], - }; - - try { - // cast: LSP executeCommand returns unknown; the call sites pin TData per - // language (DialogData for SSL, DDialogData for D). config.hasData - // guards against malformed/empty payloads before buildTreeHtml runs. - const data = (await client.sendRequest(ExecuteCommandRequest.type, params)) as TData | null; - // The panel can be disposed (synchronously nulling `dialogPanel` via - // onDidDispose at line 309) while sendRequest is in flight. Re-check - // before touching `.webview`; the pre-await guard above does not - // cover this window. - if (!dialogPanel) return; - if (data == null || !config.hasData(data)) return; - - const panel = dialogPanel; - const treeContent = config.buildTreeHtml(data); - const codiconsUri = panel.webview.asWebviewUri( - vscode.Uri.joinPath(context.extensionUri, "client", "out", "codicons", "codicon.css"), - ); - const iconUri = panel.webview.asWebviewUri(vscode.Uri.joinPath(context.extensionUri, config.tabIconPath)); - panel.webview.html = getDialogPreviewHtml( - treeContent, - codiconsUri.toString(), - panel.webview.cspSource, - context.extensionUri.fsPath, - currentFileName || "dialog", - currentFilePath || "", - iconUri.toString(), - ); - failureTracker.recordSuccess(); - } catch (error) { - conlog( - `Dialog preview refresh failed for ${currentFileName ?? ""}: ${error instanceof Error ? error.message : String(error)}`, - "warn", - ); - failureTracker.recordFailure(error); - } - } - - function scheduleRefresh() { - if (refreshTimeout) { - clearTimeout(refreshTimeout); - } - refreshTimeout = setTimeout(refreshPreview, 300); - } - - // Watch for changes while editing - context.subscriptions.push( - vscode.workspace.onDidChangeTextDocument((e) => { - if (dialogPanel && e.document.uri.toString() === currentDocumentUri) { - scheduleRefresh(); - } - }), - ); - - // Refresh on save (source file or translation file) - context.subscriptions.push( - vscode.workspace.onDidSaveTextDocument((doc) => { - if (!dialogPanel) return; - if (doc.uri.toString() === currentDocumentUri || doc.languageId === config.translationLangId) { - void refreshPreview(); - } - }), - ); - - async function openPreview() { - const editor = vscode.window.activeTextEditor; - if (!editor || !config.matchDocument(editor.document)) { - vscode.window.showWarningMessage(config.warningMessage); - return; - } - - currentDocumentUri = editor.document.uri.toString(); - - const params: ExecuteCommandParams = { - command: LSP_COMMAND_PARSE_DIALOG, - arguments: [{ uri: currentDocumentUri }], - }; - - try { - // cast: same trust boundary as the refreshPreview cast above; per-language - // TData is pinned at the call site (DialogData / DDialogData). - const data = (await client.sendRequest(ExecuteCommandRequest.type, params)) as TData | null; - - if (data == null || !config.hasData(data)) { - vscode.window.showWarningMessage("No dialog data found"); - return; - } - - const fileName = editor.document.fileName.split(/[/\\]/).pop() || "dialog"; - const workspaceFolder = vscode.workspace.getWorkspaceFolder(editor.document.uri); - const filePath = workspaceFolder - ? path.relative(workspaceFolder.uri.fsPath, editor.document.fileName) - : fileName; - currentFileName = fileName; - currentFilePath = filePath; - - if (dialogPanel) { - dialogPanel.reveal(vscode.ViewColumn.Active); - } else { - dialogPanel = vscode.window.createWebviewPanel( - "bgforgeDialogPreview", - `Dialog: ${fileName}`, - vscode.ViewColumn.Active, - { - enableScripts: true, - localResourceRoots: [ - vscode.Uri.joinPath(context.extensionUri, "client", "out", "codicons"), - vscode.Uri.joinPath(context.extensionUri, path.dirname(config.tabIconPath)), - ], - }, - ); - dialogPanel.iconPath = vscode.Uri.joinPath(context.extensionUri, config.tabIconPath); - dialogPanel.webview.onDidReceiveMessage((message: DialogTreeRuntimeErrorMessage) => { - if (message.type !== "runtimeError") { - return; - } - surfaceWebviewRuntimeError({ - label: `Dialog preview for ${currentFilePath ?? fileName}`, - userFacingFile: fileName, - message: message.message, - stack: message.stack, - }); - }); - dialogPanel.onDidDispose(() => { - dialogPanel = undefined; - currentDocumentUri = undefined; - currentFileName = undefined; - currentFilePath = undefined; - if (refreshTimeout) { - clearTimeout(refreshTimeout); - } - }); - } - - const treeContent = config.buildTreeHtml(data); - const codiconsUri = dialogPanel.webview.asWebviewUri( - vscode.Uri.joinPath(context.extensionUri, "client", "out", "codicons", "codicon.css"), - ); - const iconUri = dialogPanel.webview.asWebviewUri( - vscode.Uri.joinPath(context.extensionUri, config.tabIconPath), - ); - - dialogPanel.title = `Dialog: ${fileName}`; - dialogPanel.webview.html = getDialogPreviewHtml( - treeContent, - codiconsUri.toString(), - dialogPanel.webview.cspSource, - context.extensionUri.fsPath, - fileName, - filePath, - iconUri.toString(), - ); - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - // Log full stack trace to Developer Tools for debugging (showErrorMessage only gets the message) - console.error("Dialog preview error:", error); - vscode.window.showErrorMessage(`Failed to generate dialog preview: ${msg}`); - } - } - - return { - matchesDocument: config.matchDocument, - openPreview, - }; -} diff --git a/client/src/extension.ts b/client/src/extension.ts index cc1b4bab1..688e28df0 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -13,20 +13,17 @@ import { type ExecuteCommandParams, ExecuteCommandRequest } from "vscode-languag import { LSP_COMMAND_COMPILE, VSCODE_COMMAND_COMPILE, - VSCODE_COMMAND_DIALOG_PREVIEW, WORKSPACE_SYMBOL_SCOPED_LANGUAGES, type WorkspaceSymbolScopedLanguage, lspWorkspaceSymbolsCommand, } from "../../shared/protocol"; import { registerBinaryEditor } from "./binary-editor/register"; -import { registerDialogTree } from "./dialog-tree/dialogTree"; -import { registerDDialogTree } from "./dialog-tree/dialogTree-d"; +import { registerDialogEditor } from "./dialog-editor/panel"; import { conlog, initOutputChannel, setDebugLogging } from "./logging"; // Initialized in activate(), undefined until then let client: LanguageClient | undefined; const cmd_compile = VSCODE_COMMAND_COMPILE; -const cmd_dialogPreview = VSCODE_COMMAND_DIALOG_PREVIEW; function getWorkspaceSymbolScopeLanguageId(): WorkspaceSymbolScopedLanguage | undefined { const document = vscode.window.activeTextEditor?.document; @@ -129,25 +126,7 @@ export async function activate(context: ExtensionContext) { await client.start(); conlog("BGforge MLS client started"); - const sslDialogPreview = registerDialogTree(context, client); - const dDialogPreview = registerDDialogTree(context, client); - context.subscriptions.push( - vscode.commands.registerCommand(cmd_dialogPreview, async () => { - const document = vscode.window.activeTextEditor?.document; - if (!document) { - return; - } - if (sslDialogPreview.matchesDocument(document)) { - await sslDialogPreview.openPreview(); - return; - } - if (dDialogPreview.matchesDocument(document)) { - await dDialogPreview.openPreview(); - return; - } - vscode.window.showWarningMessage("Open a Fallout SSL, TSSL, D, or TD file to preview dialog"); - }), - ); + context.subscriptions.push(registerDialogEditor(context, client)); } export async function deactivate(): Promise { diff --git a/client/src/utils.ts b/client/src/utils.ts deleted file mode 100644 index 8f71a30d0..000000000 --- a/client/src/utils.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Shared utility functions for client extension code. - */ - -/** - * Escape HTML special characters to prevent XSS. - * Imported by extension host code (dialog-tree/shared.ts) and by the - * dialog-tree webview bundle (esbuild inlines the import). - */ -export function escapeHtml(text: string): string { - return text - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); -} diff --git a/client/src/webview-common.css b/client/src/webview-common.css index f761445d7..354cc50b0 100644 --- a/client/src/webview-common.css +++ b/client/src/webview-common.css @@ -1,9 +1,6 @@ /** - * Shared styles for the dialog tree preview webview panel. + * Shared styles for extension webview panels. * Concatenated at load time with panel-specific CSS by the panel's asset loader. - * - * Panel-specific overrides: - * - dialog-tree/dialogTree.css (dialog tree preview -- body, h1, breadcrumb, tree nodes) */ /* Always reserve scrollbar space so content doesn't shift when scrollbar appears/disappears */ diff --git a/client/src/webview-utils.ts b/client/src/webview-utils.ts index c980f642f..e5e6538e9 100644 --- a/client/src/webview-utils.ts +++ b/client/src/webview-utils.ts @@ -1,8 +1,8 @@ /** - * Shared helpers for the in-webview bundles (dialog-tree preview + binary - * editor). These run in the webview's browser context, so the module must stay + * Shared helpers for the in-webview bundles (binary editor + dialog editor). + * These run in the webview's browser context, so the module must stay * free of Node and vscode-host APIs; esbuild inlines it into each webview - * bundle (same as the `escapeHtml` import from `./utils`). + * bundle. */ /** Minimal view of the `acquireVsCodeApi()` handle the helpers here need. */ diff --git a/client/test/conversation-tree.test.ts b/client/test/conversation-tree.test.ts new file mode 100644 index 000000000..4ad472fa0 --- /dev/null +++ b/client/test/conversation-tree.test.ts @@ -0,0 +1,556 @@ +/** + * Unit tests for the conversation-flow tree builder and the shared cross-file + * jump resolver that the graph and tree views both use. + */ +import { describe, expect, it } from "vitest"; +import { + buildConversationTree, + childStates, + type ConvState, + type ConvTarget, +} from "../src/dialog-editor/webview/conversation-tree"; +import { resolveJumpTarget } from "../src/dialog-editor/webview/jump-resolve"; +import type { DialogChoice, DialogRoot, DialogState, DialogTarget } from "../../shared/dialog-model"; + +function st(id: string, text: string, choices: DialogChoice[], extra: Partial = {}): DialogState { + return { id, speaker: "NPC", text, choices, ...extra }; +} +function ch(id: string, target: DialogTarget, extra: Partial = {}): DialogChoice { + return { id, target, ...extra }; +} +function root(states: DialogState[]): DialogRoot { + return { id: "dialog:NPC", label: "NPC", kind: "dialog", states }; +} +const noJump = (): undefined => undefined; + +describe("resolveJumpTarget", () => { + const stateToRoot = new Map([ + ["Run", "dialog:CORAN"], + ["Start", "dialog:NPC"], + ]); + const fileToRoot = new Map([["%CORAN_JOINED%", "dialog:CORAN"]]); + + it("resolves a bare state id owned by a root", () => { + expect(resolveJumpTarget("Run", stateToRoot, fileToRoot)).toEqual({ file: "dialog:CORAN", stateId: "Run" }); + }); + it("resolves a tilde-wrapped file:state EXTERN label", () => { + expect(resolveJumpTarget("~%CORAN_JOINED%~:Run", stateToRoot, fileToRoot)).toEqual({ + file: "dialog:CORAN", + stateId: "Run", + }); + }); + it("returns undefined when the state is not in the named file", () => { + // Start exists, but under dialog:NPC, not the named %CORAN_JOINED% root. + expect(resolveJumpTarget("~%CORAN_JOINED%~:Start", stateToRoot, fileToRoot)).toBeUndefined(); + }); + it("returns undefined for an unknown label with no colon", () => { + expect(resolveJumpTarget("Nope", stateToRoot, fileToRoot)).toBeUndefined(); + }); + it("returns undefined when the file part is unknown", () => { + expect(resolveJumpTarget("%OTHER%:Run", stateToRoot, fileToRoot)).toBeUndefined(); + }); +}); + +describe("buildConversationTree", () => { + it("expands a linear chain rooted at the entry, with exit leaf", () => { + const r = root([ + st("A", "hi", [ch("A#0", { kind: "state", stateId: "B" }, { text: "go" })]), + st("B", "bye", [ch("B#0", { kind: "exit" })]), + ]); + const { roots } = buildConversationTree(r, undefined, noJump); + expect(roots).toHaveLength(1); + expect(roots[0]!.id).toBe("A"); + expect(roots[0]!.isEntry).toBe(true); + const b = roots[0]!.replies[0]!.target as Extract; + expect(b.kind).toBe("state"); + expect(b.node.id).toBe("B"); + expect(b.node.isEntry).toBe(false); + expect(b.node.replies[0]!.target.kind).toBe("exit"); + }); + + it("collapses a cycle: the back-reference becomes a ref leaf, not infinite recursion", () => { + const r = root([ + st("A", "a", [ch("A#0", { kind: "state", stateId: "B" }, { text: "to b" })]), + st("B", "b", [ch("B#0", { kind: "state", stateId: "A" }, { text: "back to a" })]), + ]); + const { roots } = buildConversationTree(r, undefined, noJump); + const b = roots[0]!.replies[0]!.target as Extract; + const backToA = b.node.replies[0]!.target; + expect(backToA).toEqual({ kind: "ref", stateId: "A" }); + }); + + it("shows a state reached from two places once (first-expansion-wins), the rest as refs", () => { + const r = root([ + st("A", "a", [ch("A#0", { kind: "state", stateId: "C" }), ch("A#1", { kind: "state", stateId: "B" })]), + st("B", "b", [ch("B#0", { kind: "state", stateId: "C" })]), + st("C", "c", [ch("C#0", { kind: "exit" })]), + ]); + const { roots } = buildConversationTree(r, undefined, noJump); + // A is the only entry (B and C are both targeted). + expect(roots).toHaveLength(1); + const a = roots[0]!; + // A#0 -> C expands first (full node); A#1 -> B; B#0 -> C is now a ref. + expect((a.replies[0]!.target as { kind: string }).kind).toBe("state"); + const b = a.replies[1]!.target as Extract; + expect(b.node.replies[0]!.target).toEqual({ kind: "ref", stateId: "C" }); + }); + + it("treats a goto to a state outside this file as an external leaf, resolving its jump", () => { + const r = root([st("A", "a", [ch("A#0", { kind: "state", stateId: "Faraway" }, { text: "leave" })])]); + const { roots } = buildConversationTree(r, undefined, (label) => + label === "Faraway" ? { file: "dialog:OTHER", stateId: "Faraway" } : undefined, + ); + expect(roots[0]!.replies[0]!.target).toEqual({ + kind: "external", + label: "Faraway", + jump: { file: "dialog:OTHER", stateId: "Faraway" }, + }); + }); + + it("carries external EXTERN targets with their resolved jump", () => { + const r = root([ + st("A", "a", [ch("A#0", { kind: "external", label: "~%X%~:Y", resolved: true }, { text: "x" })]), + ]); + const { roots } = buildConversationTree(r, undefined, () => ({ file: "dialog:X", stateId: "Y" })); + const t = roots[0]!.replies[0]!.target as Extract; + expect(t.kind).toBe("external"); + expect(t.label).toBe("~%X%~:Y"); + expect(t.jump).toEqual({ file: "dialog:X", stateId: "Y" }); + }); + + it("resolves @N refs in NPC line and reply text via messages", () => { + const r = root([st("A", "@10", [ch("A#0", { kind: "exit" }, { text: "@20" })])]); + const { roots } = buildConversationTree(r, { "10": "Hello there", "20": "Goodbye" }, noJump); + expect(roots[0]!.text).toBe("Hello there"); + expect(roots[0]!.replies[0]!.text).toBe("Goodbye"); + expect(roots[0]!.replies[0]!.hasText).toBe(true); + }); + + it("carries every SAY line of a multisay state (continuation lines resolved), so none are hidden", () => { + // A WeiDU D `SAY @10 = @20 = @30` monologue: the model keeps all three in sayTexts. The tree must + // surface lines 2..N (they were invisible - shown first-line-only) as `sayLines`, line 0 stays `text`. + const r = root([st("A", "@10", [ch("A#0", { kind: "exit" })], { sayTexts: ["@10", "@20", "@30"] })]); + const { roots } = buildConversationTree( + r, + { "10": "First line.", "20": "Second line.", "30": "Third." }, + noJump, + ); + expect(roots[0]!.text).toBe("First line."); + expect(roots[0]!.sayLines).toEqual(["Second line.", "Third."]); + }); + + it("flags a pending (not-yet-in-source) state and option, and leaves a committed one unflagged", () => { + // A pending item is in the webview's optimistic model but has no source span yet (a just-added node/option + // before the reparse adopts it, or an empty option deferred until its text commits). The view marks it as + // an unsaved draft. A committed item carries a source span (procRange/sourceRange, callRange). + const r = root([ + st( + "A", + "hi", + [ch("A#0", { kind: "state", stateId: "New" }, { text: "go", callRange: { start: 0, end: 1 } })], + { + sourceRange: { start: 0, end: 10 }, + }, + ), + st("New", "a new line", [ch("New#0", { kind: "exit" }, { text: "" })]), // no source span -> pending + ]); + const { roots } = buildConversationTree(r, undefined, noJump); + const a = roots[0]!; + expect(a.pending).toBeUndefined(); // committed (has sourceRange) + expect(a.replies[0]!.pending).toBeUndefined(); // committed option (has callRange) + const newState = (a.replies[0]!.target as Extract).node; + expect(newState.pending).toBe(true); // pending state (no source span) + expect(newState.replies[0]!.pending).toBe(true); // pending option (no source span) + }); + + it("leaves sayLines absent for a single-say state", () => { + const r = root([st("A", "@10", [ch("A#0", { kind: "exit" })])]); + const { roots } = buildConversationTree(r, { "10": "Only line." }, noJump); + expect(roots[0]!.sayLines).toBeUndefined(); + }); + + it("marks a textless transition as a silent continue", () => { + const r = root([st("A", "a", [ch("A#0", { kind: "exit" })])]); + const { roots } = buildConversationTree(r, undefined, noJump); + expect(roots[0]!.replies[0]!.hasText).toBe(false); + expect(roots[0]!.replies[0]!.text).toBe(""); + }); + + // textEditable drives whether the tree offers inline text editing on an option; it mirrors the + // inspector's textFieldLocked gate (SSL @N resolvability, read-only/derived states, pending-new). + it("marks a D literal option's text as editable", () => { + const r = root([st("A", "a", [ch("A#0", { kind: "exit" }, { text: "hi" })])]); + const { roots } = buildConversationTree(r, undefined, noJump, { ssl: false, fieldEditable: () => true }); + expect(roots[0]!.replies[0]!.textEditable).toBe(true); + }); + + it("locks option text on a derived (read-only) state", () => { + const r = root([st("A", "a", [ch("A#0", { kind: "exit" }, { text: "hi" })], { derivedFrom: "CHAIN" })]); + const { roots } = buildConversationTree(r, undefined, noJump, { ssl: false, fieldEditable: () => true }); + expect(roots[0]!.replies[0]!.textEditable).toBe(false); + }); + + it("locks option text in a view-only (non-editable) D file", () => { + const r = root([st("A", "a", [ch("A#0", { kind: "exit" }, { text: "hi" })])]); + const { roots } = buildConversationTree(r, undefined, noJump, { ssl: false, fieldEditable: () => false }); + expect(roots[0]!.replies[0]!.textEditable).toBe(false); + }); + + // Parity fix: the tree honors the PER-STATE fieldEditable predicate, not the model-level flag. A .td file + // sets model.editable=false but each non-derived state is field-editable, so the inspector treats its text + // as editable; the tree used to lock it (it consumed the model-level `editable`), diverging from the + // inspector. With the per-state predicate the two agree. + it("honors a per-state field-editable predicate (a .td state editable in the inspector is editable in the tree)", () => { + const r = root([ + st("A", "hi", [ch("A#0", { kind: "exit" }, { text: "reply" })]), // field-editable per the predicate + st("B", "bye", [ch("B#0", { kind: "exit" }, { text: "reply" })], { derivedFrom: "CHAIN" }), // derived: still locked + ]); + const { roots } = buildConversationTree(r, undefined, noJump, { + ssl: false, + fieldEditable: (s) => !s.derivedFrom, // the .td gate: every non-derived state is field-editable + }); + const byId = new Map(roots.map((n) => [n.id, n])); + expect(byId.get("A")!.textEditable).toBe(true); // was false under the model-level flag - the bug + expect(byId.get("A")!.replies[0]!.textEditable).toBe(true); + expect(byId.get("B")!.textEditable).toBe(false); // derived stays read-only + }); + + it("SSL: an option backed by a resolvable @N is editable; a non-resolvable one is locked", () => { + const r = root([ + st("A", "a", [ + // stmtRange marks these existing-in-source (not pending-new), so the @N-resolvability + // gate applies rather than the pending-new exemption. + ch("A#0", { kind: "exit" }, { text: "@10", stmtRange: { start: 0, end: 1 } }), // resolves in messages + ch("A#1", { kind: "exit" }, { text: "@99", stmtRange: { start: 2, end: 3 } }), // no .msg entry - nowhere to write + ]), + ]); + const { roots } = buildConversationTree(r, { "10": "Hi" }, noJump, { ssl: true, fieldEditable: () => false }); + expect(roots[0]!.replies[0]!.textEditable).toBe(true); + expect(roots[0]!.replies[1]!.textEditable).toBe(false); + }); + + it("SSL: a just-added (pending) option is editable even before it has an @N", () => { + const r = root([st("A", "a", [ch("A#0", { kind: "exit" }, { text: "" })])]); + const { roots } = buildConversationTree(r, {}, noJump, { ssl: true, fieldEditable: () => false }); + expect(roots[0]!.replies[0]!.textEditable).toBe(true); + }); + + // ConvState.textEditable mirrors ConvReply.textEditable for the NPC line: it applies the inspector's + // textFieldLocked gate to the state's OWN text, driving inline NPC-line editing in the tree. + it("marks a D literal NPC line as editable", () => { + const r = root([st("A", "hi", [])]); + const { roots } = buildConversationTree(r, undefined, noJump, { ssl: false, fieldEditable: () => true }); + expect(roots[0]!.textEditable).toBe(true); + }); + + it("locks the NPC line on a derived (read-only) state", () => { + const r = root([st("A", "hi", [], { derivedFrom: "CHAIN" })]); + const { roots } = buildConversationTree(r, undefined, noJump, { ssl: false, fieldEditable: () => true }); + expect(roots[0]!.textEditable).toBe(false); + }); + + it("locks the NPC line in a view-only (non-editable) D file", () => { + const r = root([st("A", "hi", [])]); + const { roots } = buildConversationTree(r, undefined, noJump, { ssl: false, fieldEditable: () => false }); + expect(roots[0]!.textEditable).toBe(false); + }); + + it("SSL: an NPC line backed by a resolvable @N is editable; a non-resolvable one is locked", () => { + const r = root([ + // procRange marks these existing-in-source (not pending-new), so the @N-resolvability gate applies. + st("Node001", "@10", [], { procRange: { start: 0, end: 1 } }), // resolves in messages + st("Node002", "@99", [], { procRange: { start: 2, end: 3 } }), // no .msg entry - nowhere to write + ]); + const { roots } = buildConversationTree(r, { "10": "Hi" }, noJump, { ssl: true, fieldEditable: () => false }); + const byId = new Map(roots.map((n) => [n.id, n])); + expect(byId.get("Node001")!.textEditable).toBe(true); + expect(byId.get("Node002")!.textEditable).toBe(false); + }); + + it("SSL: a just-added (pending) state's NPC line is editable even before it has an @N", () => { + const r = root([st("A", "", [])]); + const { roots } = buildConversationTree(r, {}, noJump, { ssl: true, fieldEditable: () => false }); + expect(roots[0]!.textEditable).toBe(true); + }); + + it("passes through condition, action, trigger, and derivedFrom", () => { + const r = root([ + st("A", "a", [ch("A#0", { kind: "exit" }, { condition: "IF x", action: "DO y" })], { + trigger: "Global", + derivedFrom: "CHAIN", + }), + ]); + const { roots } = buildConversationTree(r, undefined, noJump); + expect(roots[0]!.trigger).toBe("Global"); + expect(roots[0]!.derivedFrom).toBe("CHAIN"); + expect(roots[0]!.replies[0]!.condition).toBe("IF x"); + expect(roots[0]!.replies[0]!.action).toBe("DO y"); + }); + + it("surfaces a state reachable only inside a cycle as an additional root so every state appears", () => { + // B <-> C form a cycle with no entry into them; A is the lone entry and does + // not reach them. The sweep must still surface B (and C via B) once. + const r = root([ + st("A", "a", [ch("A#0", { kind: "exit" })]), + st("B", "b", [ch("B#0", { kind: "state", stateId: "C" })]), + st("C", "c", [ch("C#0", { kind: "state", stateId: "B" })]), + ]); + const { roots } = buildConversationTree(r, undefined, noJump); + const ids = roots.map((s: ConvState) => s.id); + expect(ids).toContain("A"); + expect(ids).toContain("B"); + // C is expanded under B; B->C is a state node, C->B is a ref. + const bRoot = roots.find((s: ConvState) => s.id === "B")!; + expect((bRoot.replies[0]!.target as { kind: string }).kind).toBe("state"); + }); + + it("carries the real speaker (D) but not the SSL file-name fallback; the id is always present (shown dimmed)", () => { + const ssl: DialogState = { id: "Node001", text: "hi", choices: [] }; + const sslRow = buildConversationTree(root([ssl]), undefined, noJump).roots[0]!; + expect(sslRow.speaker).toBeUndefined(); // SSL: no speaker -> tree shows only the dimmed id + expect(sslRow.id).toBe("Node001"); + const d: DialogState = { id: "VISK1", speaker: "Viconia", text: "hi", choices: [] }; + expect(buildConversationTree(root([d]), undefined, noJump).roots[0]!.speaker).toBe("Viconia"); + }); + + it("returns no roots for an empty dialog", () => { + expect(buildConversationTree(root([]), undefined, noJump).roots).toHaveLength(0); + }); + + // Go-to-source (F4): the option row carries the byte offset of its source statement. SSL options carry + // callRange/stmtRange/callSite spans; a WeiDU D option carries only `sourceRange`. Both must resolve, and a + // pending (just-added) option - which has no span yet - stays undefined so F4 is a no-op on it. + describe("option sourceOffset (go to source)", () => { + const offsetOf = (choice: DialogChoice): number | undefined => + buildConversationTree(root([st("A", "@0", [choice])]), undefined, noJump).roots[0]!.replies[0]! + .sourceOffset; + + it("resolves a WeiDU D option from its sourceRange (the SSL span fields are absent for D)", () => { + expect(offsetOf(ch("A#0", { kind: "exit" }, { sourceRange: { start: 128, end: 160 } }))).toBe(128); + }); + it("prefers the SSL callRange over sourceRange when both somehow exist", () => { + const c = ch( + "A#opt0", + { kind: "exit" }, + { callRange: { start: 40, end: 70 }, sourceRange: { start: 128, end: 160 } }, + ); + expect(offsetOf(c)).toBe(40); + }); + it("falls back through stmtRange then the first call site", () => { + expect(offsetOf(ch("A#opt0", { kind: "exit" }, { stmtRange: { start: 55, end: 80 } }))).toBe(55); + const call = ch( + "A#call0", + { kind: "state", stateId: "B" }, + { callSites: [{ stmtRange: { start: 12, end: 30 }, topLevel: true }] }, + ); + expect(offsetOf(call)).toBe(12); + }); + it("is undefined for a pending (just-added) option with no source span yet", () => { + expect(offsetOf(ch("A#reply", { kind: "exit" }, { text: "@5" }))).toBeUndefined(); + }); + }); + + it("represents a bundle (if/else) node as branches, each with its own NPC line and replies", () => { + const r = root([ + st( + "Hub", + "@10", // the if-branch line; the else line must NOT be lost + [ + ch("Hub#opt0", { kind: "state", stateId: "A" }, { text: "ask", condition: "X==0" }), + ch("Hub#opt1", { kind: "exit" }, { text: "leave", condition: "!(X==0)" }), + ], + { + branches: [ + { + kind: "if", + condition: "X==0", + replies: [{ text: "@10" }], + choiceIds: ["Hub#opt0"], + opaque: [], + }, + { kind: "else", replies: [{ text: "@20" }], choiceIds: ["Hub#opt1"], opaque: [] }, + ], + }, + ), + st("A", "@30", [ch("A#0", { kind: "exit" })]), + ]); + const messages = { "10": "First-time line", "20": "Later line", "30": "A line" }; + const hub = buildConversationTree(r, messages, noJump).roots[0]!; + expect(hub.branches).toHaveLength(2); + expect(hub.branches![0]!.kind).toBe("if"); + expect(hub.branches![0]!.condition).toBe("X==0"); + expect(hub.branches![0]!.npc).toBe("First-time line"); + expect(hub.branches![0]!.replies.map((rp) => rp.id)).toEqual(["Hub#opt0"]); + expect(hub.branches![1]!.kind).toBe("else"); + expect(hub.branches![1]!.npc).toBe("Later line"); // the else NPC line is preserved, not dropped + expect(hub.branches![1]!.replies.map((rp) => rp.id)).toEqual(["Hub#opt1"]); + // Replies live per branch, so the flat list is empty (no double-expansion). + expect(hub.replies).toHaveLength(0); + // A branch option still expands its target sub-tree. + const aTarget = hub.branches![0]!.replies[0]!.target as Extract; + expect(aTarget.node.id).toBe("A"); + }); +}); + +// SSL convention: Node999 is the end/leave node (Exit), Node998 the combat node. The tree presents an option +// targeting them as a terminal chip - not a link to a drawn node - and carries the underlying id as a tooltip. +// The model target stays state->Node99x (round-trips), so this is a pure presentation fold, SSL-only. +describe("buildConversationTree - Node998/Node999 as Combat/Exit terminals (SSL)", () => { + it("folds a Node999 target to Exit and a Node998 target to Combat, each with its id tooltip, and draws neither node", () => { + const r = root([ + st("Node001", "Hello.", [ + ch("Node001#0", { kind: "state", stateId: "Node999" }, { text: "Leave." }), + ch("Node001#1", { kind: "state", stateId: "Node998" }, { text: "Attack!" }), + ]), + st("Node998", "", []), + st("Node999", "", []), + ]); + const { roots } = buildConversationTree(r, undefined, noJump, { ssl: true, fieldEditable: () => false }); + // Only the real node is drawn; the two support nodes are terminals, not conversation nodes. + expect(roots.map((n) => n.id)).toEqual(["Node001"]); + expect(roots[0]!.replies[0]!.target).toEqual({ kind: "exit", nodeId: "Node999" }); + expect(roots[0]!.replies[1]!.target).toEqual({ kind: "combat", nodeId: "Node998" }); + }); + + it("leaves a Node999 target a normal state link for non-SSL formats (the convention is SSL-only)", () => { + const r = root([ + st("A", "Hello.", [ch("A#0", { kind: "state", stateId: "Node999" }, { text: "Leave." })]), + st("Node999", "end", []), + ]); + const { roots } = buildConversationTree(r, undefined, noJump, { ssl: false, fieldEditable: () => true }); + const t = roots[0]!.replies[0]!.target; + expect(t.kind).toBe("state"); + expect((t as Extract).node.id).toBe("Node999"); + }); + + // A `structured` SSL node (arbitrarily nested if/else): the builder mirrors the source nesting as a + // recursive ConvBlock, with each condition shown once at its own group level and the else line preserved - + // instead of the flat projection that dropped outer gates (dialog-nested-flatten-bug-class). + it("builds a recursive ConvBlock from a structured node's block", () => { + const r = root([ + st( + "N1", + "@1", + [ + ch("N1#opt0", { kind: "state", stateId: "B" }, { text: "@10", condition: "(OUTER)" }), + ch("N1#opt1", { kind: "state", stateId: "C" }, { text: "@11", condition: "(OUTER) and (INNER)" }), + ch("N1#opt2", { kind: "state", stateId: "D" }, { text: "@12", condition: "not (OUTER)" }), + ch("N1#opt3", { kind: "exit" }, { text: "@13" }), + ], + { + structured: true, + block: [ + { + kind: "group", + condition: "(OUTER)", + thenBlock: [ + { kind: "line", text: "@100" }, + { kind: "choice", choiceId: "N1#opt0" }, + { + kind: "group", + condition: "(INNER)", + thenBlock: [{ kind: "choice", choiceId: "N1#opt1" }], + }, + ], + elseBlock: [ + { kind: "line", text: "@200" }, + { kind: "choice", choiceId: "N1#opt2" }, + ], + }, + { kind: "choice", choiceId: "N1#opt3" }, + ], + }, + ), + st("B", "@2", []), + st("C", "@3", []), + st("D", "@4", []), + ]); + const { roots } = buildConversationTree(r, undefined, noJump, { ssl: true, fieldEditable: () => false }); + const n1 = roots.find((s) => s.id === "N1")!; + const block = n1.block!; + expect(block).toBeDefined(); + + // Top level: the if/else group, then the unconditional trailing option. + const group = block[0] as Extract<(typeof block)[number], { kind: "group" }>; + expect(group.kind).toBe("group"); + expect(group.condition).toBe("(OUTER)"); + expect(block[1]).toMatchObject({ kind: "reply" }); + + // then-branch: its own NPC line, an option, and a NESTED group (each condition once at its level). + expect(group.thenBlock[0]).toMatchObject({ kind: "line", npc: "@100" }); + const inner = group.thenBlock.find((item) => item.kind === "group") as Extract< + (typeof group.thenBlock)[number], + { kind: "group" } + >; + expect(inner.condition).toBe("(INNER)"); + expect(inner.thenBlock).toHaveLength(1); + expect(inner.thenBlock[0]).toMatchObject({ kind: "reply" }); + + // else-branch carries its OWN NPC line (the flat projection dropped it - symptom 3), tagged `isElse` + // (so the tree labels it `[else]`, not `[if]`) with the negated condition in its tooltip. The if-branch + // line is NOT tagged. + expect(group.elseBlock).toBeDefined(); + expect(group.elseBlock![0]).toMatchObject({ + kind: "line", + npc: "@200", + isElse: true, + condition: "not (OUTER)", + }); + expect(group.thenBlock[0]).toMatchObject({ kind: "line", npc: "@100" }); + expect((group.thenBlock[0] as { isElse?: boolean }).isElse).toBeUndefined(); + + // Every choice is expanded exactly once through the block (no flat replies duplicating them). + expect(n1.replies).toHaveLength(0); + + // Branch keys drive the tree's branch highlight: each row carries the key of the innermost branch it + // sits in, a nested branch's key STARTS WITH its parent's, and a top-level (unbranched) row is unkeyed. + const thenLine = group.thenBlock[0] as { branchKey?: string }; + const elseLine = group.elseBlock![0] as { branchKey?: string }; + expect(thenLine.branchKey).toBe("N1#0if"); + expect(elseLine.branchKey).toBe("N1#0else"); + // opt2 sits directly in the else branch; opt1 sits in a nested if UNDER the then branch. + const elseOpt = group.elseBlock!.find((item) => item.kind === "reply")! as { reply: { branchKey?: string } }; + expect(elseOpt.reply.branchKey).toBe("N1#0else"); + const nestedGroup = group.thenBlock.find((item) => item.kind === "group")! as { + thenBlock: { kind: string; reply?: { branchKey?: string } }[]; + }; + const nestedOpt = nestedGroup.thenBlock.find((item) => item.kind === "reply")!; + expect(nestedOpt.reply!.branchKey).toBe("N1#0if.0if"); + expect(nestedOpt.reply!.branchKey!.startsWith("N1#0if")).toBe(true); // covered by highlighting the then branch + // The unconditional trailing option (block[1]) is not in any branch -> unkeyed, so a branch highlight + // never covers it. + expect((block[1] as { reply: { branchKey?: string } }).reply.branchKey).toBeUndefined(); + }); + + // An approximate node (control flow the block can't model) renders flat but must carry the flag through + // to ConvState so the tree shows the loud "approx" warning badge (dialog-nested-flatten-bug-class dec. 3). + it("carries the approximate flag onto ConvState", () => { + const r = root([st("A", "@1", [ch("A#0", { kind: "exit" }, { text: "@2" })], { approximate: true })]); + const { roots } = buildConversationTree(r, undefined, noJump, { ssl: true, fieldEditable: () => false }); + expect(roots[0]!.approximate).toBe(true); + // A normal node does not get the flag. + const r2 = root([st("B", "@1", [ch("B#0", { kind: "exit" })])]); + expect( + buildConversationTree(r2, undefined, noJump, { ssl: true, fieldEditable: () => false }).roots[0]! + .approximate, + ).toBeUndefined(); + }); +}); + +describe("childStates (shared tree traversal)", () => { + it("finds a branch node's reply target, which a flat-only walk misses", () => { + // A bundle (if/else) node keeps its replies per branch, so its flat `replies` is empty. A traversal that + // only walked flat replies (the old reveal/collapse-all walks) missed the branch reply's child state. + const r = root([ + st("Hub", "@10", [ch("Hub#opt0", { kind: "state", stateId: "A" }, { text: "ask", condition: "X==0" })], { + branches: [ + { kind: "if", condition: "X==0", replies: [{ text: "@10" }], choiceIds: ["Hub#opt0"], opaque: [] }, + ], + }), + st("A", "@30", [ch("A#0", { kind: "exit" })]), + ]); + const hub = buildConversationTree(r, { "10": "line", "30": "a" }, noJump).roots[0]!; + expect(hub.replies).toHaveLength(0); // branch node: no flat replies for a flat-only walk to find + expect(childStates(hub).map((c) => c.id)).toEqual(["A"]); + }); +}); diff --git a/client/test/dialog-app-messages.test.ts b/client/test/dialog-app-messages.test.ts new file mode 100644 index 000000000..df0c43830 --- /dev/null +++ b/client/test/dialog-app-messages.test.ts @@ -0,0 +1,63 @@ +/** + * Tests for the App.svelte message-handling kernel. App holds {model, error, timedOut} + * reactive state and is otherwise DOM wiring (covered by the e2e harness); the branching + * logic - which host message updates what, the reset-on-model, the timeout predicate - + * lives in this pure module so it gates in pnpm test. These are the bug-3 (fail-loud / + * never-hang) decisions: a malformed message must not clear a shown error, and "neither + * model nor error yet" is exactly the timeout condition. + */ + +import { describe, expect, test } from "vitest"; +import { reduceDialogView, shouldTimeOut, type DialogView } from "../src/dialog-editor/webview/app-messages"; +import type { DialogModel } from "../../shared/dialog-model"; + +const MODEL = { sourceLang: "d", editable: true, roots: [] } as DialogModel; +const EMPTY: DialogView = { model: null, error: null }; + +describe("reduceDialogView", () => { + test("a model message sets the model and clears any prior error", () => { + const next = reduceDialogView({ model: null, error: "stale" }, { type: "model", model: MODEL }); + expect(next).toEqual({ model: MODEL, error: null }); + }); + + test("an error message sets the error and keeps the existing model", () => { + const next = reduceDialogView({ model: MODEL, error: null }, { type: "error", message: "boom" }); + expect(next).toEqual({ model: MODEL, error: "boom" }); + }); + + test("a later model message recovers from an error state", () => { + const afterErr = reduceDialogView(EMPTY, { type: "error", message: "boom" }); + const afterModel = reduceDialogView(afterErr, { type: "model", model: MODEL }); + expect(afterModel).toEqual({ model: MODEL, error: null }); + }); + + test("a self-edit re-parse post (reparse:true) is left for DialogGraph, not routed through the prop", () => { + // The host tags a self-edit's faithful parse `reparse:true`; the root must ignore it so it does not + // reset the view - DialogGraph adopts it directly, preserving selection / an in-progress inline edit. + const prev: DialogView = { model: MODEL, error: null }; + const other = { sourceLang: "d", editable: true, roots: [{ id: "x" }] } as unknown as DialogModel; + expect(reduceDialogView(prev, { type: "model", reparse: true, model: other, seq: 1 })).toEqual(prev); + }); + + test.each([ + ["no type", {}], + ["unknown type", { type: "ready" }], + ["model type without a model", { type: "model" }], + ["error type without a message", { type: "error" }], + ["not an object", "nope"], + ["null", null], + ])("a malformed message (%s) leaves the view unchanged", (_label, data) => { + const prev: DialogView = { model: MODEL, error: "keep me" }; + // Identity: a junk message must not clear a shown error or model (the never-hang/ + // never-flicker guarantee). + expect(reduceDialogView(prev, data)).toEqual(prev); + }); +}); + +describe("shouldTimeOut", () => { + test("times out only when neither a model nor an error has arrived", () => { + expect(shouldTimeOut(EMPTY)).toBe(true); + expect(shouldTimeOut({ model: MODEL, error: null })).toBe(false); + expect(shouldTimeOut({ model: null, error: "boom" })).toBe(false); + }); +}); diff --git a/client/test/dialog-edit-origin.test.ts b/client/test/dialog-edit-origin.test.ts new file mode 100644 index 000000000..a04b252bb --- /dev/null +++ b/client/test/dialog-edit-origin.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import { EchoGuard } from "../src/dialog-editor/edit-origin"; + +describe("EchoGuard", () => { + it("suppresses one change per self-edit, then re-projects", () => { + const g = new EchoGuard(); + g.markSelfEdit(); + expect(g.shouldReproject()).toBe(false); // our own edit echoes back - skip + expect(g.shouldReproject()).toBe(true); // a later external edit re-projects + }); + + it("re-projects an external change with no pending self-edit", () => { + const g = new EchoGuard(); + expect(g.shouldReproject()).toBe(true); + }); + + it("balances multiple queued self-edits", () => { + const g = new EchoGuard(); + g.markSelfEdit(); + g.markSelfEdit(); + expect(g.shouldReproject()).toBe(false); + expect(g.shouldReproject()).toBe(false); + expect(g.shouldReproject()).toBe(true); + }); + + it("unmarkSelfEdit rolls back a markSelfEdit that never got its change event", () => { + const g = new EchoGuard(); + g.markSelfEdit(); + g.unmarkSelfEdit(); + expect(g.shouldReproject()).toBe(true); // mark rolled back - no pending self-edit left to suppress it + }); + + it("unmarkSelfEdit on a zero counter is a safe no-op", () => { + const g = new EchoGuard(); + g.unmarkSelfEdit(); // must not go negative + g.markSelfEdit(); + expect(g.shouldReproject()).toBe(false); // exactly one self-edit is suppressed + expect(g.shouldReproject()).toBe(true); + }); +}); diff --git a/client/test/dialog-host-core.test.ts b/client/test/dialog-host-core.test.ts new file mode 100644 index 000000000..de38ea83d --- /dev/null +++ b/client/test/dialog-host-core.test.ts @@ -0,0 +1,307 @@ +/** + * Unit tests for DialogHostCore - the host-agnostic session logic panel.ts binds to the VS Code runtime + * and the round-trip harness binds to an in-memory document. The vscode-free IO seam is the point of the + * extraction: every branch (parse failures, splice vs no-op, echo-guard bookkeeping, rejected edits, the + * debounced message flush, dispose-mid-flight) is exercised here with a stub IO, while the composition + * with a real webview runs in the e2e-tier edit-roundtrip driver and the vscode wiring in + * dialog-panel.test.ts. + */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { DialogHostCore, errorMessage, type DialogHostIO } from "../src/dialog-editor/host-core"; +import type { DialogMessages, DialogModel } from "../../shared/dialog-model"; + +/** An empty-but-valid D parse payload: toModel keys off `blocks`, yielding an empty (non-null) model. */ +const EMPTY_D = { blocks: [], states: [] }; + +function makeIO(overrides: Partial = {}) { + const posted: Array> = []; + const errors: string[] = []; + const saved: DialogMessages[] = []; + const replaced: string[] = []; + const io: DialogHostIO = { + getText: () => "", + requestParse: async () => ({ data: EMPTY_D }), + replaceText: async (t) => { + replaced.push(t); + return true; + }, + postToWebview: (m) => posted.push(m as Record), + showError: (m) => errors.push(m), + saveMessages: async (m) => { + saved.push(m); + }, + ...overrides, + }; + return { io, posted, errors, saved, replaced }; +} + +const flush = (): Promise => + new Promise((r) => { + setTimeout(r, 0); + }); + +/** An edited webview model carrying one brand-new state: splices non-trivially against an empty original. */ +function newStateModel(): DialogModel { + return { + sourceLang: "d", + editable: true, + sourceName: "x", + roots: [ + { + id: "test", + label: "test", + kind: "dialog", + states: [ + { + id: "fresh", + text: "A new line.", + choices: [{ id: "fresh#0", text: "ok", target: { kind: "exit" } }], + }, + ], + }, + ], + messages: {}, + }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("errorMessage", () => { + it("unwraps an Error's message and stringifies anything else", () => { + expect(errorMessage(new Error("boom"))).toBe("boom"); + expect(errorMessage("plain")).toBe("plain"); + }); +}); + +describe("DialogHostCore.postModel (via handleReady)", () => { + it("posts the parse-request failure as a webview error", async () => { + const { io, posted } = makeIO({ requestParse: async () => ({ error: "socket down" }) }); + new DialogHostCore(io, "/x.d").handleReady(); + await flush(); + expect(posted).toEqual([{ type: "error", message: "Dialog parse request failed: socket down" }]); + }); + + it("posts a distinct error for a null payload (server threw) vs an uninterpretable one", async () => { + const { io, posted } = makeIO({ requestParse: async () => ({ data: null }) }); + new DialogHostCore(io, "/x.d").handleReady(); + await flush(); + expect(posted[0]!.message as string).toContain("returned no dialog data"); + + const bad = makeIO({ requestParse: async () => ({ data: { nonsense: true } }) }); + new DialogHostCore(bad.io, "/x.d").handleReady(); + await flush(); + expect(bad.posted[0]!.message as string).toContain("could not be interpreted"); + }); + + it("enriches the model with sourceName from the document path", async () => { + const { io, posted } = makeIO(); + new DialogHostCore(io, "/dir/greeter.d").handleReady(); + await flush(); + const model = posted[0]!.model as DialogModel; + expect(posted[0]!.type).toBe("model"); + expect(model.sourceName).toBe("greeter"); + }); + + it("refines a .td/.tssl document to its transpiler sourceLang with blanket-editable off", async () => { + const td = makeIO(); + new DialogHostCore(td.io, "/a/thing.TD").handleReady(); + await flush(); + expect((td.posted[0]!.model as DialogModel).sourceLang).toBe("td"); + expect((td.posted[0]!.model as DialogModel).editable).toBe(false); + + const tssl = makeIO({ requestParse: async () => ({ data: { nodes: [], entryPoints: [] } }) }); + new DialogHostCore(tssl.io, "/a/thing.tssl").handleReady(); + await flush(); + expect((tssl.posted[0]!.model as DialogModel).sourceLang).toBe("tssl"); + }); + + it("does not post to a webview disposed while the parse was in flight", async () => { + let resolve!: (v: { data: unknown }) => void; + const { io, posted } = makeIO({ + requestParse: () => + new Promise((r) => { + resolve = r; + }), + }); + const core = new DialogHostCore(io, "/x.d"); + core.handleReady(); + core.dispose(); + resolve({ data: EMPTY_D }); + await flush(); + expect(posted).toEqual([]); + }); +}); + +describe("DialogHostCore.handleEdit", () => { + it("surfaces a parse-request failure as a toast, not a silent drop", async () => { + const { io, errors, replaced } = makeIO({ requestParse: async () => ({ error: "gone" }) }); + const core = new DialogHostCore(io, "/x.d"); + core.handleEdit(newStateModel(), 1); + await core.drainEdits(); + expect(errors).toEqual(["Dialog edit failed: gone"]); + expect(replaced).toEqual([]); + }); + + it("surfaces a null re-parse of an open document instead of splicing against nothing", async () => { + const { io, errors, replaced } = makeIO({ requestParse: async () => ({ data: null }) }); + const core = new DialogHostCore(io, "/x.d"); + core.handleEdit(newStateModel(), 1); + await core.drainEdits(); + expect(errors[0]).toContain("Dialog edit not saved"); + expect(replaced).toEqual([]); + }); + + it("a text-only edit (no structural change) applies no document edit but schedules the flush", async () => { + vi.useFakeTimers(); + const { io, replaced, saved } = makeIO(); + const core = new DialogHostCore(io, "/x.d"); + const edited: DialogModel = { sourceLang: "d", editable: true, roots: [], messages: { "0": "typed" } }; + core.handleEdit(edited, 1); + await core.drainEdits(); + expect(replaced).toEqual([]); // nothing spliced + await vi.advanceTimersByTimeAsync(400); // the debounced write-through + expect(saved).toEqual([{ "0": "typed" }]); + }); + + it("splices a structural edit, then posts the faithful re-parse tagged with the emit's seq", async () => { + const { io, posted, replaced } = makeIO(); + const core = new DialogHostCore(io, "/x.d"); + core.handleEdit(newStateModel(), 7); + await core.drainEdits(); + // The new state was serialized into the (empty) document... + expect(replaced).toHaveLength(1); + expect(replaced[0]).toContain("BEGIN fresh"); + // ...and the re-parse post carries the seq plus the minted allocations/messages for the remap. + const reparse = posted.find((p) => p.reparse === true)!; + expect(reparse.seq).toBe(7); + expect(Object.keys(reparse.allocations as Record)).toEqual(["fresh", "fresh#0"]); + expect(Object.values(reparse.messages as Record)).toContain("A new line."); + }); + + it("a rejected WorkspaceEdit (false) toasts, skips the re-parse post, and unmarks the echo guard", async () => { + const { io, posted, errors } = makeIO({ replaceText: async () => false }); + const core = new DialogHostCore(io, "/x.d"); + core.handleEdit(newStateModel(), 1); + await core.drainEdits(); + expect(errors).toEqual(["Dialog edit could not be applied to the document."]); + expect(posted.filter((p) => p.reparse === true)).toEqual([]); + // The self-edit token was rolled back: the next change event reads as EXTERNAL and re-projects. + core.handleDocumentChanged(1); + await flush(); + expect(posted.filter((p) => p.type === "model")).toHaveLength(1); + }); + + it("a throwing applyEdit unmarks the guard and reports through the queue's error path", async () => { + const { io, errors, posted } = makeIO({ + replaceText: async () => { + throw new Error("host exploded"); + }, + }); + const core = new DialogHostCore(io, "/x.d"); + core.handleEdit(newStateModel(), 1); + await core.drainEdits(); + expect(errors).toEqual(["Dialog edit failed: host exploded"]); + core.handleDocumentChanged(1); // guard unmarked on throw -> external re-project still works + await flush(); + expect(posted.filter((p) => p.type === "model")).toHaveLength(1); + }); + + it("does not touch the document when disposed while the edit's parse was in flight", async () => { + let resolve!: (v: { data: unknown }) => void; + const { io, replaced } = makeIO({ + requestParse: () => + new Promise((r) => { + resolve = r; + }), + }); + const core = new DialogHostCore(io, "/x.d"); + core.handleEdit(newStateModel(), 1); + await flush(); + core.dispose(); + resolve({ data: EMPTY_D }); + await core.drainEdits(); + expect(replaced).toEqual([]); + }); +}); + +describe("DialogHostCore.handleDocumentChanged (echo guard)", () => { + it("skips metadata-only events, consumes one self-edit, then re-projects external edits", async () => { + const { io, posted } = makeIO(); + const core = new DialogHostCore(io, "/x.d"); + core.handleEdit(newStateModel(), 1); // marks one self-edit and posts one reparse + await core.drainEdits(); + const before = posted.length; + core.handleDocumentChanged(0); // metadata-only: never consults the guard + core.handleDocumentChanged(1); // the self-edit's own change event: consumed, no re-project + await flush(); + expect(posted).toHaveLength(before); + core.handleDocumentChanged(1); // a genuine external edit + await flush(); + expect(posted).toHaveLength(before + 1); + }); +}); + +describe("DialogHostCore message flush", () => { + it("flushes immediately on save, and surfaces a failing write", async () => { + const { io, saved, errors } = makeIO({ + saveMessages: async (m) => { + saved.push(m); + throw new Error("disk full"); + }, + }); + const core = new DialogHostCore(io, "/x.d"); + core.handleEdit({ sourceLang: "d", editable: true, roots: [], messages: { "1": "line" } }, 1); + await core.drainEdits(); + core.handleDocumentSaved(); + await flush(); + expect(saved).toEqual([{ "1": "line" }]); + expect(errors[0]).toContain("Saving dialog message text failed: disk full"); + }); + + it("is a no-op with no messages to write", async () => { + const { io, saved } = makeIO(); + const core = new DialogHostCore(io, "/x.d"); + core.handleDocumentSaved(); + await flush(); + expect(saved).toEqual([]); + }); + + it("rapid edits collapse to ONE debounced flush carrying the latest messages", async () => { + vi.useFakeTimers(); + const { io, saved } = makeIO(); + const core = new DialogHostCore(io, "/x.d"); + core.handleEdit({ sourceLang: "d", editable: true, roots: [], messages: { "1": "draft" } }, 1); + await core.drainEdits(); + await vi.advanceTimersByTimeAsync(200); // inside the debounce window - the second edit resets it + core.handleEdit({ sourceLang: "d", editable: true, roots: [], messages: { "1": "final" } }, 2); + await core.drainEdits(); + await vi.advanceTimersByTimeAsync(400); + expect(saved).toEqual([{ "1": "final" }]); + }); +}); + +describe("DialogHostCore edge branches", () => { + it("a model of an unhandled sourceLang skips the splice machinery but still records/flushes", async () => { + vi.useFakeTimers(); + const { io, replaced, saved } = makeIO(); + const core = new DialogHostCore(io, "/x.d"); + // Only reachable via a cast (the union is exhaustive); the guard must skip the parse/splice + // block rather than crash, and the message write-through still runs. + const alien = { sourceLang: "bogus", editable: false, roots: [], messages: { "9": "x" } }; + core.handleEdit(alien as unknown as DialogModel, 1); + await core.drainEdits(); + expect(replaced).toEqual([]); + await vi.advanceTimersByTimeAsync(400); + expect(saved).toEqual([{ "9": "x" }]); + }); + + it("a pathless document yields no sourceName rather than an empty-string label", async () => { + const { io, posted } = makeIO(); + new DialogHostCore(io, "").handleReady(); + await flush(); + expect((posted[0]!.model as DialogModel).sourceName).toBeUndefined(); + }); +}); diff --git a/client/test/dialog-inspector-edit.test.ts b/client/test/dialog-inspector-edit.test.ts new file mode 100644 index 000000000..1d7c8d030 --- /dev/null +++ b/client/test/dialog-inspector-edit.test.ts @@ -0,0 +1,486 @@ +import { describe, expect, it } from "vitest"; +import { + conditionLockReason, + isPendingChoice, + isPendingState, + msgRef, + npcLineAuthorable, + optionRemoveLockReason, + stateReadOnlyReason, + structuralLockReason, + sayLineEditability, + textEditability, + textFieldLocked, + textLockReason, + writeText, +} from "../src/dialog-editor/webview/inspector-edit"; +import { modelFromSSL, type DialogChoice, type DialogState } from "../../shared/dialog-model"; +import type { SSLDialogData } from "../../shared/dialog-types"; + +describe("writeText (single-line normalization)", () => { + it("replaces baked newlines with a space so the .msg/.tra line stays single-line (both @N and literal paths)", () => { + // BUG D: the inspector NPC field is a textarea, so Enter (or a multi-line paste) put a newline into the + // value that the write-through persisted - the .msg/.tra line is single-line by format, so the write + // must fold newlines out. Each CR/LF/CRLF becomes ONE space (never a line break in the stored value). + const messages: Record = { "104": "old" }; + writeText({ text: "@104" }, messages, "line one\nline two"); + expect(messages["104"]).toBe("line one line two"); + const literal = { text: "seed" }; + writeText(literal, undefined, "a\r\nb\rc"); + expect(literal.text).toBe("a b c"); + }); + + it("preserves other whitespace (writeText runs on every keystroke; trimming would fight live typing)", () => { + const literal = { text: "seed" }; + writeText(literal, undefined, "hello "); + expect(literal.text).toBe("hello "); // a trailing space the user is mid-typing survives + }); +}); + +describe("sayLineEditability (a multisay continuation line)", () => { + const messages = { "20": "Second line." }; + it("editable for a resolved @N, locked for an unresolved one (with a reason), never pending", () => { + expect(sayLineEditability({ text: "@20", messages, ssl: false, textRO: false }).editable).toBe(true); + const locked = sayLineEditability({ text: "@99", messages, ssl: false, textRO: false }); + expect(locked.editable).toBe(false); + expect(locked.reason).toContain("@99"); + }); + it("locks every line of a read-only (derived) state", () => { + const r = sayLineEditability({ text: "@20", messages, ssl: false, textRO: true, derivedFrom: "CHAIN" }); + expect(r.editable).toBe(false); + expect(r.reason).toContain("CHAIN"); + }); +}); + +describe("msgRef", () => { + it("parses a bare @N line to its numeric id", () => { + expect(msgRef("@200")).toBe("200"); + expect(msgRef(" @201 ")).toBe("201"); // surrounding whitespace tolerated + }); + it("returns null for literal or non-@N text", () => { + expect(msgRef("The town is quiet")).toBeNull(); + expect(msgRef("@abc")).toBeNull(); + expect(msgRef(undefined)).toBeNull(); + expect(msgRef("")).toBeNull(); + }); +}); + +describe("textFieldLocked", () => { + const messages = { "200": "The town is quiet these days.", "201": "" }; + + it("locks any field of a read-only state", () => { + expect(textFieldLocked({ text: "@200", messages, ssl: true, textRO: true })).toBe(true); + expect(textFieldLocked({ text: "@200", messages, ssl: false, textRO: true })).toBe(true); + }); + + it("D: a literal text field is editable (D persists literals via the .d splice)", () => { + expect(textFieldLocked({ text: "Some literal line", messages, ssl: false, textRO: false })).toBe(false); + expect(textFieldLocked({ text: "@200", messages, ssl: false, textRO: false })).toBe(false); + }); + + it("D: an @N field whose .tra entry did NOT load is locked (BUG E: would silently drop the edit)", () => { + // The bug: D short-circuited to editable, but an unresolved @tra ref has no entry to write - the edit + // updated only the in-memory value, the tab read "saved", and nothing reached disk. Lock it like SSL. + expect(textFieldLocked({ text: "@999", messages, ssl: false, textRO: false })).toBe(true); + expect(textFieldLocked({ text: "@200", messages: {}, ssl: false, textRO: false })).toBe(true); + expect(textFieldLocked({ text: "@200", messages: undefined, ssl: false, textRO: false })).toBe(true); + }); + + it("SSL: an @N field whose .msg entry resolved is editable", () => { + expect(textFieldLocked({ text: "@200", messages, ssl: true, textRO: false })).toBe(false); + // An empty-string entry is still a resolved entry - editable. + expect(textFieldLocked({ text: "@201", messages, ssl: true, textRO: false })).toBe(false); + }); + + it("SSL: an @N field whose .msg entry did NOT load is locked (would silently lose the edit)", () => { + // The bug: ref is non-null so the old guard left it editable, but there is no .msg line to write. + expect(textFieldLocked({ text: "@999", messages, ssl: true, textRO: false })).toBe(true); + expect(textFieldLocked({ text: "@200", messages: undefined, ssl: true, textRO: false })).toBe(true); + expect(textFieldLocked({ text: "@200", messages: {}, ssl: true, textRO: false })).toBe(true); + }); + + it("SSL: a literal (non-@N) field is locked - SSL save only writes resolvable .msg entries", () => { + expect(textFieldLocked({ text: "raw literal", messages, ssl: true, textRO: false })).toBe(true); + }); + + it("SSL: a PENDING-NEW field is editable so the user can type its initial text (allocated an @id at save)", () => { + // A just-added option/node starts with empty (or literal) text and no .msg entry; locking it would + // make add-option / add-node unusable for SSL. textRO still wins. + expect(textFieldLocked({ text: "", messages, ssl: true, textRO: false, isNew: true })).toBe(false); + expect(textFieldLocked({ text: "typed literal", messages, ssl: true, textRO: false, isNew: true })).toBe(false); + expect(textFieldLocked({ text: "", messages, ssl: true, textRO: true, isNew: true })).toBe(true); + }); + + it("isNew defaults to false - an existing unresolvable @N stays locked", () => { + expect(textFieldLocked({ text: "@999", messages, ssl: true, textRO: false })).toBe(true); + }); +}); + +describe("isPendingChoice", () => { + it("a choice with no source span of any kind is pending-new", () => { + expect(isPendingChoice({ id: "x", text: "", target: { kind: "exit" } })).toBe(true); + }); + it("an existing option (callRange or stmtRange) is not pending", () => { + expect( + isPendingChoice({ id: "x", text: "@1", target: { kind: "exit" }, callRange: { start: 0, end: 1 } }), + ).toBe(false); + expect( + isPendingChoice({ id: "x", text: "@1", target: { kind: "exit" }, stmtRange: { start: 0, end: 1 } }), + ).toBe(false); + }); + it("a call transition (callSites) is not pending", () => { + expect( + isPendingChoice({ + id: "x", + target: { kind: "state", stateId: "N" }, + callSites: [{ stmtRange: { start: 0, end: 1 }, topLevel: true }], + }), + ).toBe(false); + }); + it("an existing WeiDU D option (sourceRange, no SSL spans) is not pending", () => { + // Without the sourceRange gate this reads as pending (D never sets the SSL span fields), which is a + // latent trap for any consumer other than the D-short-circuiting textFieldLocked. + expect( + isPendingChoice({ id: "x", text: "@1", target: { kind: "exit" }, sourceRange: { start: 0, end: 1 } }), + ).toBe(false); + }); +}); + +describe("isPendingState", () => { + it("a state with no source span is pending-new; with a procRange (SSL) it is not", () => { + expect(isPendingState({ id: "N", text: "", choices: [] })).toBe(true); + expect(isPendingState({ id: "N", text: "", choices: [], procRange: { start: 0, end: 1 } })).toBe(false); + }); + it("an existing WeiDU D state (sourceRange, no procRange) is not pending", () => { + expect(isPendingState({ id: "N", text: "", choices: [], sourceRange: { start: 0, end: 1 } })).toBe(false); + }); +}); + +describe("npcLineAuthorable", () => { + it("a pending-new node (no source span) is authorable", () => { + expect(npcLineAuthorable({ id: "N", text: "", choices: [] })).toBe(true); + }); + it("a faithful reply-less node adopted from source (procRange + replyless) stays authorable after the pending window closes", () => { + // The +State regression: after the splice the webview adopts the re-parse, so the node has a procRange + // and is no longer pending - but its Reply is still empty and the save path will allocate it, so the + // NPC line must stay editable. `replyless` survives the user typing the first line (text becomes a literal). + const adopted: DialogState = { + id: "N", + text: "", + choices: [], + procRange: { start: 0, end: 9 }, + replyless: true, + }; + expect(isPendingState(adopted)).toBe(false); // no longer pending post-adopt + expect(npcLineAuthorable(adopted)).toBe(true); + // Still authorable once the user has typed a not-yet-saved literal into it. + expect(npcLineAuthorable({ ...adopted, text: "Hello there." })).toBe(true); + }); + it("a node whose reply already resolves to @N is NOT authorable (edited via the resolvable-@N path, not allocation)", () => { + expect(npcLineAuthorable({ id: "N", text: "@200", choices: [], procRange: { start: 0, end: 9 } })).toBe(false); + }); + it("a non-replyless literal node is NOT authorable (a genuine literal has nowhere to write on SSL save)", () => { + expect(npcLineAuthorable({ id: "N", text: "raw literal", choices: [], procRange: { start: 0, end: 9 } })).toBe( + false, + ); + }); +}); + +describe("replyless projection + gate (the +State NPC-line regression)", () => { + // An ADOPTED node (post-splice re-parse) carries a `procRange`, so it is not pending - isolating the + // `replyless` arm of the gate from the pending-new arm. + const faithlessNode = (over: Partial): SSLDialogData["nodes"][number] => ({ + name: "Node001", + line: 1, + callTargets: [], + replies: [], + options: [], + procRange: { start: 0, end: 30 }, + ...over, + }); + + const anchor = { offset: 42, indent: " " }; + + it("modelFromSSL marks a faithful reply-less node with a splice anchor `replyless`, so its empty NPC line is editable", () => { + const model = modelFromSSL({ + entryPoints: ["Node001"], + nodes: [faithlessNode({ faithful: true, insertAnchor: anchor })], + }); + const state = model.roots[0]!.states[0]!; + expect(state.text).toBe(""); // reply-less -> empty NPC line + expect(state.replyless).toBe(true); + // The composed inspector gate: an empty faithful reply-less SSL line is NOT locked. + expect( + textFieldLocked({ + text: state.text, + messages: model.messages, + ssl: true, + textRO: false, + isNew: npcLineAuthorable(state), + }), + ).toBe(false); + }); + + it("a node WITH a reply is not `replyless` (its @N line uses the resolvable path)", () => { + const model = modelFromSSL({ + entryPoints: ["Node001"], + nodes: [faithlessNode({ faithful: true, insertAnchor: anchor, replies: [{ msgId: 200, line: 2 }] })], + }); + expect(model.roots[0]!.states[0]!.replyless).toBeUndefined(); + }); + + it("a NON-faithful reply-less node is not `replyless` (the writer won't splice a reply into it)", () => { + const model = modelFromSSL({ + entryPoints: ["Node001"], + nodes: [faithlessNode({ faithful: false, insertAnchor: anchor })], + }); + expect(model.roots[0]!.states[0]!.replyless).toBeUndefined(); + }); + + it("a faithful reply-less node with NO node-level insertAnchor (the TSSL shape) is not `replyless` - replyOps can't splice, so the line stays locked", () => { + const model = modelFromSSL({ entryPoints: ["Node001"], nodes: [faithlessNode({ faithful: true })] }); + const state = model.roots[0]!.states[0]!; + expect(state.replyless).toBeUndefined(); + expect( + textFieldLocked({ + text: state.text, + messages: model.messages, + ssl: true, + textRO: false, + isNew: npcLineAuthorable(state), + }), + ).toBe(true); + }); +}); + +describe("textEditability (unified text gate - one decision both views consume)", () => { + const messages = { "200": "The town is quiet." }; + const st = (over: Partial = {}): DialogState => ({ id: "N", text: "@200", choices: [], ...over }); + const ch = (over: Partial = {}): DialogChoice => ({ id: "c0", target: { kind: "exit" }, ...over }); + + it("returns both the lock AND the reason from one call, so the two views can't assemble it differently", () => { + // Editable -> empty reason. + expect( + textEditability({ + state: st({ procRange: { start: 0, end: 9 } }), + choice: null, + messages, + ssl: true, + textRO: false, + }), + ).toEqual({ + editable: true, + reason: "", + }); + // Locked -> a concrete reason string. + const literal = textEditability({ + state: st({ text: "raw", procRange: { start: 0, end: 9 } }), + choice: null, + messages, + ssl: true, + textRO: false, + }); + expect(literal.editable).toBe(false); + expect(literal.reason).toMatch(/no plain @N/); + }); + + it("NPC line of a faithful reply-less adopted SSL node is editable (the +State regression, decided in one place)", () => { + const state = st({ text: "", procRange: { start: 0, end: 9 }, replyless: true }); + expect(textEditability({ state, choice: null, messages, ssl: true, textRO: false })).toEqual({ + editable: true, + reason: "", + }); + }); + + it("NPC line of a read-only (derived) state is locked with the derived-construct reason", () => { + const r = textEditability({ + state: st({ derivedFrom: "CHAIN" }), + choice: null, + messages, + ssl: true, + textRO: true, + }); + expect(r.editable).toBe(false); + expect(r.reason).toContain("CHAIN"); + }); + + it("option text: a pending choice is editable; an unresolved @N is locked", () => { + expect(textEditability({ state: st(), choice: ch({ text: "" }), messages, ssl: true, textRO: false })).toEqual({ + editable: true, + reason: "", + }); + const unresolved = textEditability({ + state: st(), + choice: ch({ text: "@999", callRange: { start: 0, end: 1 } }), + messages, + ssl: true, + textRO: false, + }); + expect(unresolved.editable).toBe(false); + expect(unresolved.reason).toContain("@999"); + }); + + it("D-family literal text is editable (persisted via the .d splice) when the caller's textRO is false", () => { + expect( + textEditability({ + state: st({ text: "a literal D line" }), + choice: null, + messages, + ssl: false, + textRO: false, + }), + ).toEqual({ + editable: true, + reason: "", + }); + }); + + it("D-family unresolved @N is locked with a .tra reason (BUG E: no silent 'saved' on a dropped write)", () => { + const r = textEditability({ + state: st({ text: "@999", sourceRange: { start: 0, end: 9 } }), + choice: null, + messages, + ssl: false, + textRO: false, + }); + expect(r.editable).toBe(false); + expect(r.reason).toContain("@999"); + expect(r.reason).toContain(".tra"); + }); + + it("passes the caller's textRO straight through (the tree can lock text the inspector leaves open)", () => { + // Same D-family literal, but the caller (e.g. the tree, for a non-field-editable state) says textRO -> locked. + const r = textEditability({ + state: st({ text: "a literal D line" }), + choice: null, + messages, + ssl: false, + textRO: true, + }); + expect(r.editable).toBe(false); + expect(r.reason).toBe("This dialog is open read-only."); + }); + + it("agrees with the underlying primitives (it composes them, not a second derivation)", () => { + const state = st({ text: "", procRange: { start: 0, end: 9 }, replyless: true }); + const unified = textEditability({ state, choice: null, messages, ssl: true, textRO: false }); + const isNew = npcLineAuthorable(state); + expect(unified.editable).toBe( + !textFieldLocked({ text: state.text, messages, ssl: true, textRO: false, isNew }), + ); + expect(unified.reason).toBe(textLockReason({ text: state.text, messages, ssl: true, textRO: false, isNew })); + }); +}); + +describe("disabled-reason helpers", () => { + const st = (over: Partial = {}): DialogState => ({ + id: "Node001", + text: "@200", + choices: [], + ...over, + }); + const ch = (over: Partial = {}): DialogChoice => ({ id: "c0", target: { kind: "exit" }, ...over }); + const messages = { "200": "The town is quiet." }; + + it("stateReadOnlyReason names the derived construct, else says read-only", () => { + expect(stateReadOnlyReason("CHAIN")).toContain("CHAIN"); + expect(stateReadOnlyReason("CHAIN")).toMatch(/CHAIN source/); + expect(stateReadOnlyReason(undefined)).toBe("This dialog is open read-only."); + }); + + it("structuralLockReason distinguishes derived, approximate, structured, and generic SSL nodes", () => { + expect(structuralLockReason(st({ derivedFrom: "INTERJECT" }), true, false)).toContain("INTERJECT"); + expect(structuralLockReason(st({ approximate: true }), true, false)).toMatch(/loop or switch/); + // The structured tier covers a nested if/else AND a preserved non-dialog statement (e.g. a var set), so + // the reason must not claim if/else EXCLUSIVELY (it misdescribed a trailing-side-effect node before). + expect(structuralLockReason(st({ structured: true }), true, false)).toMatch(/non-dialog statement/); + expect(structuralLockReason(st(), true, false)).toMatch(/isn't simple enough/); + // Non-SSL (D): editable file -> no reason; view-only -> read-only. + expect(structuralLockReason(st(), false, true)).toBe(""); + expect(structuralLockReason(st(), false, false)).toBe("This dialog is open read-only."); + // Each reason points the user at the source generically (never a specific ext - a .tssl has no .ssl). + const r = structuralLockReason(st({ structured: true }), true, false); + expect(r).toMatch(/edit the source file/); + expect(r).not.toMatch(/\.ssl/); + }); + + it("textLockReason explains an unresolved @N vs a literal, and is empty when editable", () => { + // Editable resolvable @N -> no reason. + expect(textLockReason({ text: "@200", messages, ssl: true, textRO: false })).toBe(""); + // Unresolved @N -> names the id and points at translation.directory. + const unresolved = textLockReason({ text: "@999", messages, ssl: true, textRO: false }); + expect(unresolved).toContain("@999"); + expect(unresolved).toMatch(/translation\.directory/); + // Literal (no @N) -> says there's no .msg entry, pointing at the source file generically (no .ssl). + const literal = textLockReason({ text: "raw", messages, ssl: true, textRO: false }); + expect(literal).toMatch(/no plain @N.*source file/s); + expect(literal).not.toMatch(/\.ssl/); + // Read-only derived state -> derived wording. + expect(textLockReason({ text: "@200", messages, ssl: true, textRO: true, derivedFrom: "EXTEND" })).toContain( + "EXTEND", + ); + }); + + it("conditionLockReason distinguishes a read-only structure from a shared condition", () => { + expect(conditionLockReason(st({ structured: true }), ch({ conditionEditable: false }), true, false)).toMatch( + /can't round-trip/, + ); + const shared = conditionLockReason(st(), ch({ conditionEditable: false }), true, false); + expect(shared).toMatch(/gates more than just this option/); + expect(shared).toMatch(/source file/); + expect(shared).not.toMatch(/\.ssl/); + // Editable condition -> no reason. + expect(conditionLockReason(st(), ch({ conditionEditable: true }), true, false)).toBe(""); + }); + + it("optionRemoveLockReason points at the source file generically", () => { + const r = optionRemoveLockReason(); + expect(r).toMatch(/remove it in the source file/); + expect(r).not.toMatch(/\.ssl/); + }); +}); + +it("derives conditionEditable from ifPure (gates the option alone) and absence of a condition", () => { + const data: SSLDialogData = { + entryPoints: ["Node001"], + nodes: [ + { + name: "Node001", + line: 1, + callTargets: [], + replies: [], + faithful: true, + options: [ + { type: "NOption", msgId: 101, target: "Node002", line: 2 }, // unconditional + { + type: "NOption", + msgId: 102, + target: "Node003", + line: 3, + conditional: "(x)", + condRange: { start: 0, end: 3 }, + ifRange: { start: 0, end: 9 }, + ifPure: true, + }, + { + type: "NOption", + msgId: 104, + target: "Node004", + line: 4, + conditional: "(y)", + condRange: { start: 10, end: 13 }, + ifRange: { start: 10, end: 19 }, + ifPure: false, + }, + ], + }, + ], + }; + const model = modelFromSSL(data); + const choices = model.roots[0]!.states[0]!.choices; + expect(choices[0]!.conditionEditable).toBe(true); // unconditional + expect(choices[1]!.conditionEditable).toBe(true); // pure if (gates this option alone) + expect(choices[2]!.conditionEditable).toBe(false); // shared/impure if + expect(choices[1]!.condRange).toEqual({ start: 0, end: 3 }); + expect(choices[1]!.ifRange).toEqual({ start: 0, end: 9 }); +}); diff --git a/client/test/dialog-issues.test.ts b/client/test/dialog-issues.test.ts new file mode 100644 index 000000000..346401732 --- /dev/null +++ b/client/test/dialog-issues.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { dialogIssues } from "../src/dialog-editor/webview/dialog-issues"; +import type { DialogModel, DialogState } from "../../shared/dialog-model"; + +const st = (over: Partial & { id: string }): DialogState => ({ text: "", choices: [], ...over }); + +function model(states: DialogState[], sourceLang: DialogModel["sourceLang"] = "d"): DialogModel { + return { sourceLang, editable: true, roots: [{ id: "d", label: "d", kind: "dialog", states }] }; +} + +describe("dialogIssues", () => { + it("does NOT flag CHAIN-derived states that legitimately share an id (the x#viconia.d VISK1 case)", () => { + // Two chains converging on the same terminal produce derived states with the same synthesized id. + // That is a projection artifact, not a duplicate label that would break a saved .d. + const m = model([ + st({ id: "VISK1", derivedFrom: "CHAIN" }), + st({ id: "VISK1", derivedFrom: "CHAIN" }), + st({ id: "VISK1_1", derivedFrom: "CHAIN" }), + st({ id: "VISK1_1", derivedFrom: "CHAIN" }), + ]); + expect(dialogIssues(m)).toEqual([]); + }); + + it("flags a real duplicate label among source-authored states in one dialog", () => { + const m = model([st({ id: "hello" }), st({ id: "hello" })]); + expect(dialogIssues(m)).toEqual(["Duplicate state label: hello"]); + }); + + it("does not flag the same label across different dialogs (labels are unique per DLG, not globally)", () => { + const m: DialogModel = { + sourceLang: "d", + editable: true, + roots: [ + { id: "a", label: "a", kind: "dialog", states: [st({ id: "hello" })] }, + { id: "b", label: "b", kind: "dialog", states: [st({ id: "hello" })] }, + ], + }; + expect(dialogIssues(m)).toEqual([]); + }); + + it("flags a transition to a missing state, but not one to a real (or derived) state", () => { + const m = model([ + st({ id: "A", choices: [{ id: "A#0", text: "", target: { kind: "state", stateId: "gone" } }] }), + st({ id: "B", choices: [{ id: "B#0", text: "", target: { kind: "state", stateId: "Deriv" } }] }), + st({ id: "Deriv", derivedFrom: "CHAIN" }), + ]); + expect(dialogIssues(m)).toEqual([`A: transition points to missing state "gone"`]); + }); +}); diff --git a/client/test/dialog-layout.test.ts b/client/test/dialog-layout.test.ts new file mode 100644 index 000000000..fd8584870 --- /dev/null +++ b/client/test/dialog-layout.test.ts @@ -0,0 +1,84 @@ +/** + * Tests for layoutFlow: the elkjs layered layout that assigns node positions for the + * graph render. elkjs's bundled build runs inline (no real web worker), so it executes + * under node/vitest. Asserts the two contracts that matter for the render: every node + * gets a position, and start states (no inbound edge) share the leftmost column. + */ + +import { describe, expect, test } from "vitest"; +import { layoutFlow } from "../src/dialog-editor/webview/layout"; +import type { FlowGraph } from "../src/dialog-editor/webview/model-to-flow"; + +function card(id: string): FlowGraph["nodes"][number] { + return { id, type: "card", position: { x: 0, y: 0 }, width: 200, height: 80, data: {} }; +} +function edge(id: string, source: string, target: string): FlowGraph["edges"][number] { + return { id, source, target, sourceHandle: id, kind: "forward", category: "player", dashed: false }; +} + +describe("layoutFlow", () => { + test("assigns a position to every node", async () => { + const graph: FlowGraph = { + nodes: [card("a"), card("b"), card("c")], + edges: [edge("a#0", "a", "b"), edge("b#0", "b", "c")], + }; + await layoutFlow(graph); + // A laid-out chain spreads out: not every node can remain at the origin. + const distinctX = new Set(graph.nodes.map((n) => n.position.x)); + expect(distinctX.size).toBeGreaterThan(1); + for (const n of graph.nodes) { + expect(Number.isFinite(n.position.x)).toBe(true); + expect(Number.isFinite(n.position.y)).toBe(true); + } + }); + + test("RIGHT layout places successors to the right of their source", async () => { + const graph: FlowGraph = { + nodes: [card("a"), card("b"), card("c")], + edges: [edge("a#0", "a", "b"), edge("b#0", "b", "c")], + }; + await layoutFlow(graph); + const x = (id: string) => graph.nodes.find((n) => n.id === id)!.position.x; + expect(x("a")).toBeLessThan(x("b")); + expect(x("b")).toBeLessThan(x("c")); + }); + + test("every start state (no inbound edge) lands in the same leftmost column", async () => { + // Two independent threads: a->b and c->d. Both starts (a, c) are pinned to layer 0, + // so they share the minimum x; the targets sit to their right. + const graph: FlowGraph = { + nodes: [card("a"), card("b"), card("c"), card("d")], + edges: [edge("a#0", "a", "b"), edge("c#0", "c", "d")], + }; + await layoutFlow(graph); + const x = (id: string) => graph.nodes.find((n) => n.id === id)!.position.x; + const minX = Math.min(...graph.nodes.map((n) => n.position.x)); + expect(x("a")).toBe(minX); + expect(x("c")).toBe(minX); + expect(x("b")).toBeGreaterThan(minX); + expect(x("d")).toBeGreaterThan(minX); + }); + + test("assigns a finite position to a single node with no edges", async () => { + const graph: FlowGraph = { nodes: [card("solo")], edges: [] }; + await layoutFlow(graph); + expect(Number.isFinite(graph.nodes[0]!.position.x)).toBe(true); + expect(Number.isFinite(graph.nodes[0]!.position.y)).toBe(true); + }); + + test("lays out a cycle (back edge) without hanging, every node finite and distinct", async () => { + // a -> b -> a: a back edge. elk's layered algorithm breaks cycles internally; the + // contract we depend on is only that it terminates and positions every node finitely. + const graph: FlowGraph = { + nodes: [card("a"), card("b")], + edges: [edge("a#0", "a", "b"), edge("b#0", "b", "a")], + }; + await layoutFlow(graph); + for (const n of graph.nodes) { + expect(Number.isFinite(n.position.x)).toBe(true); + expect(Number.isFinite(n.position.y)).toBe(true); + } + const x = (id: string) => graph.nodes.find((n) => n.id === id)!.position.x; + expect(x("a")).not.toBe(x("b")); + }); +}); diff --git a/client/test/dialog-manifest.test.ts b/client/test/dialog-manifest.test.ts new file mode 100644 index 000000000..12c1007a8 --- /dev/null +++ b/client/test/dialog-manifest.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from "vitest"; +import pkg from "../../package.json"; + +describe("dialog editor manifest", () => { + it("registers the dialog custom editor as an opt-in option for every dialog source language", () => { + const editors = (pkg.contributes.customEditors ?? []) as Array<{ + viewType: string; + priority?: string; + selector: Array<{ filenamePattern: string }>; + }>; + const dialog = editors.find((e) => e.viewType === "bgforge.dialogEditor"); + expect(dialog, "bgforge.dialogEditor custom editor").toBeDefined(); + expect(dialog!.priority).toBe("option"); + const patterns = dialog!.selector.map((s) => s.filenamePattern).sort(); + // The two runtime dialog formats (.d/.ssl) plus their transpiler source languages (.td/.tssl), + // all editable in the dialog graph. + expect(patterns).toEqual(["*.d", "*.ssl", "*.td", "*.tssl"]); + }); +}); diff --git a/client/test/dialog-model-to-flow.test.ts b/client/test/dialog-model-to-flow.test.ts new file mode 100644 index 000000000..c0f6b2240 --- /dev/null +++ b/client/test/dialog-model-to-flow.test.ts @@ -0,0 +1,343 @@ +/** + * Tests for modelToFlow: the pure DialogModel -> Svelte Flow (nodes + edges) transform + * that feeds the graph render path. Driven by the hand-built SAMPLE (targeted cases: + * cycle, conditional, exit, external) and REAL_MODEL (frozen output of the real + * modelFromD producer) for a structural end-to-end sanity check. + */ + +import { describe, expect, test } from "vitest"; +import { modelToFlow, stateNodeSize } from "../src/dialog-editor/webview/model-to-flow"; +import { stateBadges, type DialogModel, type DialogState } from "../../shared/dialog-model"; +import { SAMPLE } from "../src/dialog-editor/test/harness/sample-model"; +import { REAL_MODEL } from "../src/dialog-editor/test/harness/real-model"; + +const allStates = (m: DialogModel) => m.roots.flatMap((r) => r.states); + +describe("modelToFlow - cards and edges", () => { + test("emits one card node per state, keyed by state id", () => { + const { nodes } = modelToFlow(SAMPLE); + const cards = nodes.filter((n) => n.type === "card"); + expect(cards.map((n) => n.id).sort()).toEqual(["hello", "more"]); + for (const c of cards) expect(c.data.state).toBeDefined(); + }); + + test("emits one edge per choice, sourced from the choice's own handle", () => { + const { edges } = modelToFlow(SAMPLE); + const hello0 = edges.find((e) => e.id === "hello#0"); + expect(hello0).toMatchObject({ source: "hello", target: "more", sourceHandle: "hello#0", kind: "forward" }); + }); + + test("a choice with reply text is a 'player' edge; a bare continue is 'continue'", () => { + const model: DialogModel = { + sourceLang: "d", + editable: true, + roots: [ + { + id: "d", + label: "d", + kind: "dialog", + states: [ + { + id: "s", + speaker: "X", + text: "hi", + choices: [ + { id: "s#0", text: "pick me", target: { kind: "state", stateId: "s" } }, + { id: "s#1", target: { kind: "state", stateId: "s" } }, + ], + }, + ], + }, + ], + }; + const { edges } = modelToFlow(model); + expect(edges.find((e) => e.id === "s#0")?.category).toBe("player"); + expect(edges.find((e) => e.id === "s#1")?.category).toBe("continue"); + }); +}); + +describe("modelToFlow - duplicate state ids (shared CHAIN label)", () => { + test("collapses states sharing an id to one card so svelte-flow node and edge keys stay unique", () => { + // A WeiDU D root can carry the same state label twice - two CHAIN blocks whose terminal state is + // `VISK1` (x#viconia.d, lines 372 & 383). Emitting a card per raw state hands svelte-flow two nodes + // with id "VISK1"; its internal keyed {#each} then throws each_key_duplicate and the graph render + // crashes. One card per DISTINCT id keeps node (and edge) keys unique - matching the tree, which + // already merges these states. + const model: DialogModel = { + sourceLang: "d", + editable: true, + roots: [ + { + id: "d", + label: "d", + kind: "dialog", + states: [ + { + id: "Talk", + text: "hi", + choices: [{ id: "Talk#0", text: "go", target: { kind: "state", stateId: "VISK1" } }], + }, + { id: "VISK1", text: "one", choices: [{ id: "VISK1#0", target: { kind: "exit" } }] }, + { id: "VISK1", text: "two", choices: [{ id: "VISK1#0", target: { kind: "exit" } }] }, + ], + }, + ], + }; + const { nodes, edges } = modelToFlow(model); + const nodeIds = nodes.map((n) => n.id); + expect(new Set(nodeIds).size).toBe(nodeIds.length); // unique node keys: no each_key_duplicate + expect(nodeIds.filter((id) => id === "VISK1")).toHaveLength(1); + const edgeIds = edges.map((e) => e.id); + expect(new Set(edgeIds).size).toBe(edgeIds.length); // and unique edge keys + // The edge into VISK1 still resolves to the (single) VISK1 card - no dangling. + expect(edges.find((e) => e.id === "Talk#0")?.target).toBe("VISK1"); + }); +}); + +describe("modelToFlow - synthetic terminals and stubs", () => { + test("an exit choice points at a single shared 'exit' node", () => { + const { nodes, edges } = modelToFlow(SAMPLE); + const exitNodes = nodes.filter((n) => n.type === "exit"); + expect(exitNodes).toHaveLength(1); + expect(exitNodes[0]?.id).toBe("exit"); + const exitEdge = edges.find((e) => e.id === "hello#1"); + expect(exitEdge).toMatchObject({ target: "exit", category: "exit" }); + }); + + test("a conditional choice is dashed", () => { + // hello#1 carries condition "Reputation<5". + const { edges } = modelToFlow(SAMPLE); + expect(edges.find((e) => e.id === "hello#1")?.dashed).toBe(true); + }); + + test("an external target becomes a deduplicated 'ext:' stub and a dashed external edge", () => { + const { nodes, edges } = modelToFlow(SAMPLE); + const stub = nodes.find((n) => n.type === "external"); + expect(stub?.id).toBe("ext:%AJ_POST%:0"); + const extEdge = edges.find((e) => e.id === "more#1"); + expect(extEdge).toMatchObject({ target: "ext:%AJ_POST%:0", category: "external", dashed: true }); + }); + + test("a goto to a state absent from the model is kept as an external stub, not a dangling edge", () => { + const model: DialogModel = { + sourceLang: "d", + editable: true, + roots: [ + { + id: "d", + label: "d", + kind: "dialog", + states: [ + { + id: "a", + speaker: "X", + text: "t", + choices: [{ id: "a#0", target: { kind: "state", stateId: "ghost" } }], + }, + ], + }, + ], + }; + const { nodes, edges } = modelToFlow(model); + expect(edges.find((e) => e.id === "a#0")?.target).toBe("ext:ghost"); + expect(nodes.find((n) => n.id === "ext:ghost")?.type).toBe("external"); + }); +}); + +describe("modelToFlow - cycles", () => { + test("marks a returning edge in a two-state cycle as a back edge", () => { + // SAMPLE: hello -> more -> hello is a cycle; exactly one of the two is the back edge. + const { edges } = modelToFlow(SAMPLE); + const cycleEdges = edges.filter((e) => e.id === "hello#0" || e.id === "more#0"); + const backs = cycleEdges.filter((e) => e.kind === "back"); + expect(backs).toHaveLength(1); + expect(backs[0]?.dashed).toBe(true); + }); +}); + +describe("modelToFlow - real producer output (no dangling edges)", () => { + test("every state yields a card and every edge resolves to a real node", () => { + const { nodes, edges } = modelToFlow(REAL_MODEL); + const cardIds = new Set(nodes.filter((n) => n.type === "card").map((n) => n.id)); + for (const s of allStates(REAL_MODEL)) expect(cardIds.has(s.id)).toBe(true); + const nodeIds = new Set(nodes.map((n) => n.id)); + for (const e of edges) expect(nodeIds.has(e.target)).toBe(true); + }); +}); + +describe("modelToFlow - spotlight flag", () => { + test("tags each card with whether it is flagged (carries a badge)", () => { + const model: DialogModel = { + sourceLang: "d", + editable: true, + roots: [ + { + id: "d", + label: "d", + kind: "dialog", + states: [ + { + id: "plain", + speaker: "X", + text: "hi", + choices: [{ id: "plain#0", text: "ok", target: { kind: "exit" } }], + }, + { id: "flagged", speaker: "X", text: "hi", trigger: "x", choices: [] }, + ], + }, + ], + }; + const { nodes } = modelToFlow(model); + expect(nodes.find((n) => n.id === "plain")?.data.flagged).toBe(false); + expect(nodes.find((n) => n.id === "flagged")?.data.flagged).toBe(true); + }); + + test("carries an SSL side-effect node's signal to the card the renderer badges", () => { + // The card renderer (Node.svelte) reads stateBadges(card.data.state); a side-effect + // node must reach the card with enough state for that to include "side-effect", and + // be flagged for the spotlight. Guards against data.state being narrowed to a subset. + const model: DialogModel = { + sourceLang: "ssl", + editable: false, + roots: [ + { + id: "d", + label: "d", + kind: "dialog", + states: [{ id: "Node001", text: "100", choices: [], sideEffects: ["set_global_var"] }], + }, + ], + }; + const card = modelToFlow(model).nodes.find((n) => n.id === "Node001")!; + expect(card.data.flagged).toBe(true); + // FlowNode.data is a loose Record; the card branch sets data.state to the DialogState + // (model-to-flow card node), so narrow it to call the same stateBadges Node.svelte renders. + const cardState = card.data.state as DialogState; + expect(stateBadges(cardState)).toContain("side-effect"); + }); + + test("carries field editability onto each card (drives handle connectability)", () => { + // Node.svelte gates drag-to-retarget on data.fieldEditable; an editable (D) model marks its + // cards editable, while a view-only SSL model with no faithful node marks them non-editable so + // structure can't be dragged. Guards the field the card actually reads (the SSL faithful/ + // non-faithful split is covered by the dedicated test below). + const ssl: DialogModel = { + sourceLang: "ssl", + editable: false, + // A parsed non-faithful SSL node carries a procRange; without one it would read as a locally-new node + // (editable). This fixture is a real parsed read-only node, so its handle is non-connectable. + roots: [ + { + id: "d", + label: "d", + kind: "dialog", + states: [{ id: "N", text: "@1", choices: [], procRange: { start: 0, end: 10 } }], + }, + ], + }; + expect(modelToFlow(ssl).nodes.find((n) => n.id === "N")?.data.fieldEditable).toBe(false); + expect(modelToFlow(SAMPLE).nodes.find((n) => n.type === "card")?.data.fieldEditable).toBe(true); + }); + + test("a faithful SSL node is field-editable; a non-faithful one is not", () => { + // fieldEditable promotes the model-level `editable` to per-node: a view-only SSL model still + // lets faithful nodes be edited (retarget), while complex ones stay locked. + const ssl = (faithful: boolean): DialogModel => ({ + sourceLang: "ssl", + editable: false, + // procRange present -> a parsed node, so faithful=false reads as read-only (not a locally-new node). + roots: [ + { + id: "d", + label: "d", + kind: "dialog", + states: [{ id: "N", text: "@1", choices: [], faithful, procRange: { start: 0, end: 10 } }], + }, + ], + }); + expect(modelToFlow(ssl(true)).nodes.find((n) => n.id === "N")?.data.fieldEditable).toBe(true); + expect(modelToFlow(ssl(false)).nodes.find((n) => n.id === "N")?.data.fieldEditable).toBe(false); + }); +}); + +describe("modelToFlow - shared-text coupling", () => { + test("flags every state that shares a @N ref (line or option) with another state", () => { + const model: DialogModel = { + sourceLang: "ssl", + editable: false, + roots: [ + { + id: "d", + label: "d", + kind: "dialog", + states: [ + { id: "A", text: "@100", choices: [] }, + { id: "B", text: "@100", choices: [] }, // shares its line @100 with A + { id: "C", text: "@200", choices: [{ id: "C#0", text: "@300", target: { kind: "exit" } }] }, + { id: "D", text: "@400", choices: [{ id: "D#0", text: "@300", target: { kind: "exit" } }] }, // shares option @300 with C + { id: "E", text: "@500", choices: [] }, // fully unique + ], + }, + ], + }; + const byId = Object.fromEntries( + modelToFlow(model) + .nodes.filter((n) => n.type === "card") + .map((n) => [n.id, n.data.sharedText]), + ); + expect(byId).toEqual({ A: true, B: true, C: true, D: true, E: false }); + }); +}); + +describe("stateNodeSize", () => { + test("grows the card height as the resolved text wraps to more lines", () => { + const state = { id: "s", speaker: "X", text: "t", choices: [] }; + const oneLine = stateNodeSize(state, 10); + const manyLines = stateNodeSize(state, 240); + expect(manyLines.height).toBeGreaterThan(oneLine.height); + expect(manyLines.width).toBe(oneLine.width); // width is fixed; only height tracks text + }); +}); + +// SSL convention (graph consumer): Node998/Node999 are not drawn as cards - an option targeting them routes +// to a Combat/Exit synthetic terminal instead (mirrors the tree). The combat terminal carries the Node998 id +// as a tooltip; the exit terminal is shared with plain exits so it does not. +describe("modelToFlow - Node998/Node999 as Combat/Exit terminals (SSL)", () => { + const sslModel: DialogModel = { + sourceLang: "ssl", + editable: false, + roots: [ + { + id: "d", + label: "d", + kind: "dialog", + states: [ + { + id: "Node001", + text: "@1", + faithful: true, + procRange: { start: 0, end: 1 }, + choices: [ + { id: "Node001#0", text: "@2", target: { kind: "state", stateId: "Node999" } }, + { id: "Node001#1", text: "@3", target: { kind: "state", stateId: "Node998" } }, + ], + }, + { id: "Node998", text: "", procRange: { start: 2, end: 3 }, choices: [] }, + { id: "Node999", text: "", procRange: { start: 4, end: 5 }, choices: [] }, + ], + }, + ], + }; + + test("draws no card for the support nodes; routes their edges to combat/exit terminals", () => { + const { nodes, edges } = modelToFlow(sslModel); + expect(nodes.filter((n) => n.type === "card").map((n) => n.id)).toEqual(["Node001"]); + expect(nodes.find((n) => n.type === "exit")).toBeDefined(); + const combat = nodes.find((n) => n.type === "combat"); + expect(combat).toBeDefined(); + expect(combat!.data.title).toBe("Node998"); // tooltip reveals the underlying support node + + expect(edges.find((e) => e.id === "Node001#0")).toMatchObject({ target: "exit", category: "exit" }); + expect(edges.find((e) => e.id === "Node001#1")).toMatchObject({ target: "combat", category: "combat" }); + }); +}); diff --git a/client/test/dialog-panel-html.test.ts b/client/test/dialog-panel-html.test.ts new file mode 100644 index 000000000..b30c7431a --- /dev/null +++ b/client/test/dialog-panel-html.test.ts @@ -0,0 +1,72 @@ +/** + * Production-path guard for the dialog editor's webview HTML construction. + * + * The "stuck on Parsing dialog..." debugging saga began with the panel serving the + * bundle as an EXTERNAL `' }); - const result = getTransitionText(t, {}); - expect(result.html).toContain("<script>"); - expect(result.html).not.toContain("' }; - const html = renderTargetHtml(target); - expect(html).toContain("<script>"); - expect(html).not.toContain("', messages)).toBe(''); - }); -}); - -// --------------------------------------------------------------------------- -// getMsgText -// --------------------------------------------------------------------------- - -describe("getMsgText", () => { - const messages: Record = { "100": 'He said "hi" & waved' }; - - it("returns escaped text for numeric msgId", () => { - expect(getMsgText(100, messages)).toBe("He said "hi" & waved"); - }); - - it("escapes string msgId", () => { - expect(getMsgText("bold", {})).toBe("<b>bold</b>"); - }); - - it("escapes fallback for unknown msgId", () => { - expect(getMsgText(999, {})).toBe("(999)"); - }); -}); - -// --------------------------------------------------------------------------- -// getOptionMeta -// --------------------------------------------------------------------------- - -describe("getOptionMeta", () => { - function makeOption(type: DialogOption["type"], msgId: number | string = 100, target = "Node001"): DialogOption { - return { type, msgId, target, line: 1 }; - } - - it("returns option-good for G-prefixed types", () => { - const meta = getOptionMeta(makeOption("GOption")); - expect(meta.colorClass).toBe("option-good"); - }); - - it("returns option-bad for B-prefixed types", () => { - const meta = getOptionMeta(makeOption("BOption")); - expect(meta.colorClass).toBe("option-bad"); - }); - - it("returns option-neutral for N-prefixed types", () => { - const meta = getOptionMeta(makeOption("NOption")); - expect(meta.colorClass).toBe("option-neutral"); - }); - - it("returns stop-circle icon for message types", () => { - const meta = getOptionMeta(makeOption("NMessage")); - expect(meta.icon).toBe("stop-circle"); - }); - - it("returns arrow-right icon for non-message types", () => { - const meta = getOptionMeta(makeOption("NOption")); - expect(meta.icon).toBe("arrow-right"); - }); - - it("includes low emoji for Low types", () => { - const meta = getOptionMeta(makeOption("NLowOption")); - expect(meta.lowEmoji).toContain("🤪"); - }); - - it("has empty lowEmoji for non-Low types", () => { - const meta = getOptionMeta(makeOption("NOption")); - expect(meta.lowEmoji).toBe(""); - }); - - it("escapes tooltip", () => { - const meta = getOptionMeta(makeOption("GOption", 'a"b')); - expect(meta.tooltip).toContain("""); - }); -}); - -// --------------------------------------------------------------------------- -// buildTreeHtml -// --------------------------------------------------------------------------- - -describe("buildTreeHtml", () => { - it("returns 'no data' message for empty nodes", () => { - const data: DialogData = { nodes: [], entryPoints: [], messages: {} }; - expect(buildTreeHtml(data)).toContain("No dialog nodes found"); - }); - - it("renders a simple dialog node", () => { - const data: DialogData = { - nodes: [ - { - name: "Node001", - line: 1, - replies: [{ msgId: 100, line: 2 }], - options: [{ msgId: 200, target: "", type: "NMessage", line: 3 }], - callTargets: [], - }, - ], - entryPoints: ["Node001"], - messages: { "100": "Hello there", "200": "Goodbye" }, - }; - const html = buildTreeHtml(data); - expect(html).toContain("Node001"); - expect(html).toContain("Hello there"); - expect(html).toContain("Goodbye"); - expect(html).toContain("talk_p_proc"); - }); - - it("escapes HTML in message text", () => { - const data: DialogData = { - nodes: [ - { - name: "Node001", - line: 1, - replies: [{ msgId: "inline ", line: 2 }], - options: [], - callTargets: [], - }, - ], - entryPoints: ["Node001"], - messages: {}, - }; - const html = buildTreeHtml(data); - expect(html).not.toContain("