test(canvas): cover utils.cn + runtime-names.runtimeDisplayName (0% → 100%) (#1815)

[Molecule-Platform-Evolvement-Manager]

Closes two of the 0%-coverage files surfaced by the baseline run in
PR #2147 (vitest coverage instrumentation). Both files are tiny
utility helpers with high-touch read paths.

## utils.cn (8 cases)

Wraps `twMerge(clsx(inputs))` — every conditionally-styled component
flows through here. The load-bearing case is the **last-wins
Tailwind dedup**: `cn("p-2", "p-4")` → "p-4". A regression that lost
twMerge would silently double-apply utilities (cosmetically broken,
breaks `:where()` rules + theme overrides).

Cases:
  - single class unchanged
  - multiple positional classes joined
  - array input flattening (clsx)
  - object syntax with truthy/falsy keys
  - last-wins dedup on conflicting Tailwind utilities (the
    regression-locked guarantee)
  - non-conflicting utilities both survive (p-2 + m-4)
  - mixed input shapes (string + array + object + string)
  - nullish / empty inputs don't throw

## runtime-names.runtimeDisplayName (4 it.each cases + 3 it())

Friendly-name lookup that surfaces the workspace runtime in the chat
indicator, details tab, and a few component labels.

Cases:
  - known runtimes map to display strings
    (claude-code → Claude Code, langgraph → LangGraph, etc.)
  - unknown runtime falls back to input string verbatim
    (a NEW runtime not yet in the lookup still renders something
    operator-debuggable rather than a generic placeholder)
  - empty string falls back to "agent" (final default)
  - case-sensitivity pinned: "Claude-Code" / "LANGGRAPH" miss the
    lookup. The upstream slug is already normalized lowercase, so a
    future refactor that lowercases input "for safety" would
    silently change behavior — pinning the contract here.

## Test plan

- [x] All 17 cases pass locally (~129ms)
- [x] No SUT changes — pure additive coverage
- [ ] CI green

## #1815 progress

- [x] Step 1+2: coverage instrumentation + script (#2147)
- [x] 0%-file gaps utils.ts + runtime-names.ts (this PR)
- [ ] More 0%/low-coverage files: lib/canvas-actions.ts (25%),
      store/classNames.ts (17%) — separate PRs
- [ ] Step 3b: thresholds + CI gate once baseline catches up

🤖 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:45:51 -07:00
parent 34b92c33b7
commit bfbbe57610
2 changed files with 122 additions and 0 deletions

View File

@ -0,0 +1,48 @@
/**
* Tests for `runtimeDisplayName` the friendly-name lookup that
* surfaces the workspace runtime in the chat indicator, details
* tab, and a few component labels. Tiny but high-touch: every
* surface that shows "this workspace runs on X" goes through here.
*
* Issue: #1815 follow-up `src/lib/runtime-names.ts` was at 0%
* coverage despite being read by 3+ rendering paths.
*/
import { describe, it, expect } from "vitest";
import { runtimeDisplayName } from "../runtime-names";
describe("runtimeDisplayName", () => {
it.each([
["claude-code", "Claude Code"],
["langgraph", "LangGraph"],
["deepagents", "DeepAgents"],
["openclaw", "OpenClaw"],
["crewai", "CrewAI"],
["autogen", "AutoGen"],
])("known runtime %q maps to %q", (input, expected) => {
expect(runtimeDisplayName(input)).toBe(expected);
});
it("unknown runtime falls back to the input string verbatim", () => {
// A future runtime not yet in the lookup map should render with
// its own id — better than a generic placeholder for ops debugging.
expect(runtimeDisplayName("hermes")).toBe("hermes");
expect(runtimeDisplayName("custom-runtime-9000")).toBe(
"custom-runtime-9000",
);
});
it("empty string falls back to 'agent' (final default)", () => {
// Any code path that loses the runtime field still renders SOMETHING;
// the chat indicator never shows a blank label.
expect(runtimeDisplayName("")).toBe("agent");
});
it("is case-sensitive — uppercase variants miss the lookup", () => {
// The lookup keys are lowercase by convention. Pin the case
// sensitivity explicitly so a future refactor that lowercases
// the input "for safety" doesn't silently change behavior — the
// upstream slug is already normalized lowercase.
expect(runtimeDisplayName("Claude-Code")).toBe("Claude-Code");
expect(runtimeDisplayName("LANGGRAPH")).toBe("LANGGRAPH");
});
});

View File

@ -0,0 +1,74 @@
/**
* Tests for `cn` the canvas's className-merging helper. Wraps
* `twMerge(clsx(inputs))` so callers can:
* 1. Combine class strings + arrays + objects (clsx),
* 2. Resolve Tailwind conflicts so the LAST value wins on the same
* utility (twMerge e.g. `cn("p-2", "p-4")` "p-4").
*
* Tiny surface but load-bearing every component that conditionally
* styles uses this. A regression that loses Tailwind-merge dedup would
* show as silent class duplication in the rendered DOM (cosmetic, but
* accumulates and breaks `:where()` rules + theme overrides).
*
* Issue: #1815 follow-up `src/lib/utils.ts` was at 0% coverage.
*/
import { describe, it, expect } from "vitest";
import { cn } from "../utils";
describe("cn", () => {
it("returns a single class unchanged", () => {
expect(cn("text-red-500")).toBe("text-red-500");
});
it("joins multiple positional classes", () => {
expect(cn("text-red-500", "bg-zinc-900")).toBe("text-red-500 bg-zinc-900");
});
it("flattens array inputs (clsx-style)", () => {
expect(cn(["text-red-500", "bg-zinc-900"])).toBe(
"text-red-500 bg-zinc-900",
);
});
it("respects truthy / falsy conditional object syntax", () => {
expect(
cn({ "text-red-500": true, "text-blue-500": false, "bg-zinc-900": true }),
).toBe("text-red-500 bg-zinc-900");
});
it("dedups conflicting Tailwind utilities — last wins", () => {
// The single load-bearing reason for twMerge over plain clsx —
// a regression here would silently double-apply padding tokens
// and confuse the visible style.
expect(cn("p-2", "p-4")).toBe("p-4");
expect(cn("text-red-500", "text-blue-500")).toBe("text-blue-500");
});
it("keeps non-conflicting Tailwind utilities", () => {
// Make sure the dedup is keyed on utility group, not blanket
// merge. p-2 and m-2 don't conflict; both must survive.
expect(cn("p-2", "m-4")).toBe("p-2 m-4");
});
it("handles a mix of all input shapes", () => {
expect(
cn(
"base-class",
["array-class-1", "array-class-2"],
{ "object-true": true, "object-false": false },
"trailing-class",
),
).toBe(
"base-class array-class-1 array-class-2 object-true trailing-class",
);
});
it("handles empty / nullish inputs without throwing", () => {
expect(cn()).toBe("");
expect(cn("")).toBe("");
expect(cn(null, undefined, false)).toBe("");
expect(cn("active", null, "highlighted", undefined)).toBe(
"active highlighted",
);
});
});