test(canvas): cover store/classNames helpers (17% → 100%) (#1815)

[Molecule-Platform-Evolvement-Manager]

Continues the #1815 coverage rollup. classNames.ts was at 17%
in the baseline; this PR brings it to full coverage.

16 cases across 3 helpers:

**appendClass (6):**
- undefined / empty existing → just `cls`
- single-class → "a b" join
- DEDUP: existing already contains `cls` → existing unchanged.
  This is the load-bearing reason classNames.ts exists. Pre-helper
  the call sites inlined `${existing} ${cls}` with no dedup, so a
  tick that fired the same class twice produced "a a" and React
  Flow's className-equality diff saw it as a change every render.
- whitespace normalization (multi-space, leading/trailing)

**removeClass (7):**
- undefined / empty existing → ""
- removes named class
- exact match only ("spawn" must NOT match "spawn-fast")
- removing the only class → ""
- no-op when class absent
- whitespace normalization

**scheduleNodeClassRemoval (3):**
- after delayMs: calls set() with className-removed on target node;
  OTHER nodes untouched (the per-id pruning is the contract — pin
  it so a future refactor that maps over all nodes doesn't silently
  strip classes from siblings)
- does NOT fire before the delay elapses (vi.useFakeTimers + advance)
- SSR safety: when window is undefined, function is a no-op
  (neither get nor set fires)

## Note on test environment

Added `// @vitest-environment jsdom` directive — the file's
default `node` environment leaves `window` undefined, which would
make the SSR-guard happy-path test pass for the wrong reason
(every test would short-circuit). With jsdom, the SSR test
explicitly stubs `window` to undefined to exercise the guard.

## Test plan

- [x] All 16 cases pass locally (~1.1s with jsdom env spin-up)
- [x] No SUT changes
- [ ] CI green

## #1815 progress

- [x] Step 1+2: instrumentation (#2147)
- [x] utils.ts + runtime-names.ts (#2148)
- [x] canvas-actions.ts (#2149)
- [x] store/classNames.ts (this PR)
- [ ] store/canvas.ts (73% — biggest absolute gap; bigger surface,
      separate cycle)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-26 23:50:00 -07:00
parent 34b92c33b7
commit 679e30538a

View File

@ -0,0 +1,168 @@
// @vitest-environment jsdom
/**
* Tests for store/classNames helpers the centralised string
* manipulation for React Flow's space-separated className strings.
*
* Why this is load-bearing: every spawn / parent-pulse / one-shot
* animation flow runs through these helpers. Dedup correctness
* matters because React Flow's diffing treats className identity
* by string equality, so a stray double-class on every render
* thrashes layout repeatedly. Whitespace handling matters because
* upstream class strings sometimes arrive with multiple spaces
* (legacy concat) the helpers must collapse them.
*
* Issue: #1815 follow-up store/classNames.ts was at 17% coverage.
*/
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
} from "vitest";
import {
appendClass,
removeClass,
scheduleNodeClassRemoval,
} from "../classNames";
// ── appendClass ──────────────────────────────────────────────────────────────
describe("appendClass", () => {
it("returns just `cls` when existing is undefined", () => {
expect(appendClass(undefined, "spawn")).toBe("spawn");
});
it("returns just `cls` when existing is empty string", () => {
expect(appendClass("", "spawn")).toBe("spawn");
});
it("appends to a single-class existing", () => {
expect(appendClass("a", "b")).toBe("a b");
});
it("does NOT duplicate when class already present (the dedup contract)", () => {
// The whole reason this lives in classNames.ts: pre-helper, the
// call sites inlined `${existing} ${cls}` with no dedup, so a
// tick that fired the same class twice produced "a a" and
// React Flow treated it as a className change every render
// (string equality fails) → constant re-render thrash.
expect(appendClass("a b spawn", "spawn")).toBe("a b spawn");
expect(appendClass("spawn", "spawn")).toBe("spawn");
});
it("collapses multiple spaces in the input (whitespace normalization)", () => {
// Upstream sometimes arrives with double spaces (legacy concat
// path). Filter+join normalizes regardless of input shape.
expect(appendClass("a b", "c")).toBe("a b c");
});
it("ignores leading/trailing whitespace in existing", () => {
expect(appendClass(" a b ", "c")).toBe("a b c");
});
});
// ── removeClass ──────────────────────────────────────────────────────────────
describe("removeClass", () => {
it("returns empty string when existing is undefined", () => {
expect(removeClass(undefined, "spawn")).toBe("");
});
it("returns empty string when existing is empty", () => {
expect(removeClass("", "spawn")).toBe("");
});
it("removes the named class", () => {
expect(removeClass("a spawn b", "spawn")).toBe("a b");
});
it("removes only exact matches (not substrings)", () => {
// "spawn" must NOT match "spawn-fast". String split on
// whitespace + exact compare gives this for free.
expect(removeClass("spawn spawn-fast", "spawn")).toBe("spawn-fast");
});
it("returns empty string when removing the only class", () => {
expect(removeClass("spawn", "spawn")).toBe("");
});
it("is a no-op when class isn't present", () => {
expect(removeClass("a b c", "missing")).toBe("a b c");
});
it("collapses multiple spaces and removes empty entries", () => {
expect(removeClass("a spawn b", "spawn")).toBe("a b");
});
});
// ── scheduleNodeClassRemoval ─────────────────────────────────────────────────
describe("scheduleNodeClassRemoval", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("calls set() with className-removed nodes after delayMs", () => {
const get = vi.fn(() => ({
nodes: [
{ id: "ws-a", className: "spawn animate-pulse" },
{ id: "ws-b", className: "spawn" },
],
}));
const set = vi.fn();
scheduleNodeClassRemoval("ws-a", "spawn", 200, get, set);
// Timer hasn't fired yet — no set call.
expect(set).not.toHaveBeenCalled();
vi.advanceTimersByTime(200);
expect(set).toHaveBeenCalledTimes(1);
const patch = set.mock.calls[0][0] as {
nodes: Array<{ id: string; className?: string }>;
};
// Target node had `spawn` removed, kept `animate-pulse`.
const wsA = patch.nodes.find((n) => n.id === "ws-a")!;
expect(wsA.className).toBe("animate-pulse");
// Other node UNTOUCHED — class still present, NOT pruned by id mismatch.
const wsB = patch.nodes.find((n) => n.id === "ws-b")!;
expect(wsB.className).toBe("spawn");
});
it("does not fire before the delay elapses", () => {
const get = vi.fn(() => ({ nodes: [{ id: "x", className: "spawn" }] }));
const set = vi.fn();
scheduleNodeClassRemoval("x", "spawn", 500, get, set);
vi.advanceTimersByTime(499);
expect(set).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(set).toHaveBeenCalledTimes(1);
});
it("is a no-op when window is undefined (SSR safety)", () => {
// jsdom defines `window` by default; mock it to undefined for
// this case so the SSR guard is exercised. Don't `vi.useFakeTimers`
// here since we're asserting NO timer was ever scheduled.
vi.useRealTimers();
const originalWindow = globalThis.window;
// @ts-expect-error — deliberately undefining window to simulate SSR.
globalThis.window = undefined;
const get = vi.fn();
const set = vi.fn();
try {
scheduleNodeClassRemoval("x", "spawn", 100, get, set);
} finally {
globalThis.window = originalWindow;
}
expect(get).not.toHaveBeenCalled();
expect(set).not.toHaveBeenCalled();
});
});