Files
socraticode/tests/unit/viewer-app.test.ts
Giancarlo Erra e4da76979e feat(visualize): symbol view as focus graph; UX polish & stats consistency
Symbol view (full rebuild)
──────────────────────────
Replaces the unusable "show all symbols at once" mode with a focus
graph (SourceTrail / IntelliJ pattern):

- Landing state shows the alphabetical list of all symbols in the
  sidebar; canvas shows a "pick a symbol" overlay constrained to the
  canvas area (right: 340px) so the list stays visible.
- Three entry paths to seed: list click, search bar, or symbol click
  from a file's sidebar in Files view.
- Once seeded, canvas shows the symbol + its 2-hop callers/callees
  neighbourhood (auto-falls back to depth 1 if > 60 nodes).
- Clicking any neighbour re-centres on it. Seed has a distinctive
  orange ring + always-visible label so the anchor is obvious.
- "← Back to symbol list" link in the sidebar restores the empty
  state — explicit way out instead of relying on accidental
  empty-canvas clicks.
- Light file-grouping: each symbol's border colour is a stable hash
  of its file path; symbols from the same file share a colour.

Layout dropdown clean-up
────────────────────────
Removed Force-directed (cose). Force-directed is the wrong algorithm
for code dependency graphs — hub clusters collapse, orphans fly off,
labels overlap, no tuning fixes the underlying shape mismatch. Tried
fcose as a substitute; same fundamental problem on dense code graphs.
Default is now Dagre TB; remaining options are Concentric, Breadth-
first, Grid, Circle — all deterministic.

UX polish
─────────
- autoungrabify: true — disables single-click drag, fixes "node jumps
  on click" (trackpad clicks always have some motion which Cytoscape's
  default interprets as a node grab).
- Tap-to-highlight neighbourhood — clicking any node highlights its
  direct neighbours and fades the rest. Less aggressive than the
  transitive blast-radius / call-flow buttons in the sidebar.
- Zoom-bound label visibility — labels hidden below zoom 0.55 so 100+
  node graphs aren't a soup of overlapping text. Selected / highlighted
  nodes always show their label.
- Layout-position persistence — switching Files ↔ Symbols and back
  preserves Files-view positions instead of re-running the layout.

TDZ regression test
───────────────────
New tests/unit/viewer-app.test.ts runs the bundled viewer-app.js in a
sandboxed node:vm context with mocked DOM + Cytoscape, triggering
every function-typed style closure on a fake element. Catches TDZ
("Cannot access X before initialization") and other reference errors
at unit-test time. Would have caught both the prior `cy` and
`LABEL_ZOOM_THRESHOLD` ordering bugs before they reached the browser.

Stats-line consistency
──────────────────────
graph-visualize-html.ts — top bar now displays embedded counts
(sym.symbols.length / sym.symbolEdges.length) instead of meta counts.
Previously the top bar said "630 symbols" while the sidebar's "All
symbols" said "722" because meta excludes synthetic <module>
placeholders. Capped mode keeps meta counts with a "(capped)"
qualifier since the embedded set is empty there.

Quality gates
─────────────
- Biome lint: clean
- TypeScript (tsc): clean
- Unit tests: 687/687 (incl. the new viewer-app.js evaluation test)
2026-04-27 17:46:51 +01:00

125 lines
4.6 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Giancarlo Erra - Altaire Limited
/**
* Regression test for the bundled viewer-app.js — runs it in a mocked
* DOM/Cytoscape environment and asserts it loads without throwing.
*
* This catches TDZ ("Cannot access 'X' before initialization") errors
* introduced by declaring a `const`/`let` after it's used inside a style
* closure that Cytoscape evaluates during the `cy = cytoscape({...})`
* constructor call. The standard unit tests on `graph-visualize-html.ts`
* check the generated HTML's structure but never execute the script, so
* runtime errors like that only surface when a browser loads the page.
*
* Uses Node's `vm` module for a sandboxed evaluation — the viewer source
* is trusted (we ship it ourselves) but isolating the globals keeps the
* test safe and deterministic.
*/
import { readFileSync } from "node:fs";
import path from "node:path";
import { runInNewContext } from "node:vm";
import { describe, expect, it } from "vitest";
const VIEWER_JS_PATH = path.resolve(__dirname, "../../src/assets/viewer-app.js");
describe("viewer-app.js", () => {
it("executes top-to-bottom without TDZ or reference errors", () => {
const src = readFileSync(VIEWER_JS_PATH, "utf-8");
// ── Mock DOM ────────────────────────────────────────────────────
const makeEl = (id: string) => ({
id,
className: "",
classList: { add: () => {}, remove: () => {}, toggle: () => {} },
style: {},
disabled: false,
title: "",
textContent: "",
firstChild: null as null | object,
dataset: {} as Record<string, string>,
addEventListener: () => {},
appendChild: () => {},
removeChild: () => {},
setAttribute: () => {},
removeAttribute: () => {},
});
const elements = new Map<string, ReturnType<typeof makeEl>>();
const doc = {
createElement: () => makeEl("created"),
createTextNode: () => ({}),
getElementById: (id: string) => {
if (!elements.has(id)) elements.set(id, makeEl(id));
return elements.get(id);
},
querySelectorAll: () => [] as unknown[],
};
// ── Mock Cytoscape — exercises every style closure on a fake ele ──
const closureErrors: string[] = [];
const makeFakeCy = () => ({
on: () => {},
zoom: () => 1,
width: () => 800,
height: () => 600,
elements: () => ({
remove: () => {},
addClass: () => {},
removeClass: () => {},
difference: () => ({
addClass: () => {},
nodes: () => ({ difference: () => ({ addClass: () => {} }) }),
edges: () => ({ addClass: () => {} }),
}),
}),
nodes: () => ({ forEach: () => {}, length: 0, filter: () => [] as unknown[] }),
getElementById: () => ({ empty: () => true }),
add: () => {},
fit: () => {},
style: () => ({ update: () => {} }),
layout: () => ({ one: () => {}, run: () => {} }),
batch: (fn: () => void) => fn(),
png: () => "data:,",
animate: () => {},
});
const fakeCy = (config: { style?: Array<{ selector: string; style?: Record<string, unknown> }> }) => {
const instance = makeFakeCy();
const fakeEle = { data: () => "x", hasClass: () => false, cy: () => instance };
// Exercise every function-typed style value. Any TDZ or reference
// error surfaces here as a collected closureErrors entry.
for (const rule of config.style ?? []) {
for (const [key, val] of Object.entries(rule.style ?? {})) {
if (typeof val === "function") {
try { (val as (ele: unknown) => unknown)(fakeEle); }
catch (e) { closureErrors.push(`${rule.selector}${key}: ${(e as Error).message}`); }
}
}
}
return instance;
};
const data = {
project: { name: "smoke" },
files: [{ id: "a.ts", label: "a.ts", language: "typescript", deps: 0, dependents: 0, symbolCount: 0 }],
fileEdges: [],
symbols: [],
symbolEdges: [],
symbolsByFile: {},
symbolMode: "full" as const,
};
const ctx = {
document: doc,
cytoscape: fakeCy,
window: { __SOCRATICODE_DATA__: data, cytoscapeDagre: () => {} },
console: { log: () => {}, warn: () => {}, error: () => {} },
};
// Throws if the viewer hits TDZ or any other top-level error.
expect(() => runInNewContext(src, ctx, { filename: "viewer-app.js" })).not.toThrow();
expect(closureErrors).toEqual([]);
});
});