tests/plotting.test.js
- import { describe, it, expect, vi } from "vitest";
- import { PLOTTING } from "../src/view/plotting.js";
- import { chordGraph } from "../src/model/theory.js";
- import { FIXTURES, STUBS } from "./api.js";
- const { majorChordGraphWithChromatic } = FIXTURES;
- describe("PLOTTING", () => {
- const {
- wireMidiToPlayer,
- buildResultTable,
- applyVoiceStyles,
- plotChordGraph,
- } = PLOTTING;
-
- it("wireMidiToPlayer sets player.src + visualizer.src to combined data URL, binds visualizer once, and wires per-voice <midi-visualizer> elements via querySelectorAll", () => {
- const player = STUBS.midiPlayer();
- const visualizer = STUBS.midiVisualizer();
- const v1 = STUBS.midiVisualizer();
- const v2 = STUBS.midiVisualizer();
- STUBS.documentPlot(
- { "midi-player": player, "midi-visualizer": visualizer },
- [v1, v2],
- );
- wireMidiToPlayer({ combinedB64: "M", voicesB64: ["a", "b"] });
- expect(player.src).toBe("data:audio/midi;base64,M");
- expect(visualizer.src).toBe("data:audio/midi;base64,M");
- expect(player.addVisualizer).toHaveBeenCalledTimes(1);
- expect(v1.src).toBe("data:audio/midi;base64,a");
- expect(v2.src).toBe("data:audio/midi;base64,b");
- wireMidiToPlayer({ combinedB64: "M2", voicesB64: undefined });
- expect(player.addVisualizer).toHaveBeenCalledTimes(1);
- document.getElementById.mockImplementation(() => null);
- document.querySelectorAll.mockReturnValue([]);
- expect(() =>
- wireMidiToPlayer({ combinedB64: "M3", voicesB64: ["x"] }),
- ).not.toThrow();
- vi.unstubAllGlobals();
- });
-
- it("buildResultTable shapes parts + labelCache + voiceConfig into the rendered result-table data; missing labels fall back to 'Part i', missing voice fields default", () => {
- const voiceConfig = {
- voices: 2,
- voice: [
- { instrument: 0, color: "#abc" },
- { instrument: 1, color: "#def" },
- ],
- };
- const result = {
- parts: [{ label: "A" }, {}],
- labelCache: {
- A: { voicesB64: ["v1A", "v2A"], concreteProgression: "I-V" },
- "Part 2": { voicesB64: ["v1B"] },
- },
- };
- const t = buildResultTable(result, voiceConfig);
- expect(t.headers).toEqual(["A", "Part 2"]);
- expect(t.cells).toEqual([
- ["v1A", "v1B"],
- ["v2A", null],
- ]);
- expect(t.voices).toBe(2);
- expect(t.voiceLabels).toHaveLength(2);
- expect(t.voiceLabels[0]).toMatchObject({ num: 1, color: "#abc" });
- expect(t.partChordNames).toEqual(["I-V", ""]);
- const empty = buildResultTable(
- { parts: [{}], labelCache: {} },
- { voices: 1, voice: [{}] },
- );
- expect(empty.voiceLabels[0].color).toBe("#888888");
- });
-
- it("applyVoiceStyles creates a #voice-colors style element on first call and sets per-voice rect.note rules; reuses the element on repeat", () => {
- const styleEl = STUBS.styleEl();
- STUBS.documentVoiceStyles(styleEl);
- applyVoiceStyles([{ color: "#abc" }, { color: "#def" }]);
- expect(styleEl.id).toBe("voice-colors");
- expect(styleEl.textContent).toContain("#abc");
- expect(styleEl.textContent).toContain("#def");
- expect(styleEl.textContent).toContain('data-instrument="0"');
- applyVoiceStyles([{ color: "#xyz" }]);
- expect(styleEl.textContent).toContain("#xyz");
- expect(styleEl.textContent).not.toContain("#abc");
- vi.unstubAllGlobals();
- });
-
- it("plotChordGraph renders SVG with diatonic nodes in gray when no path is given; adds gradient and a lit self-loop when a path is supplied", () => {
- const g = chordGraph("major");
- const noPath = plotChordGraph(g, null, 0, "major");
- expect(noPath.startsWith("<svg")).toBe(true);
- expect(noPath.endsWith("</svg>")).toBe(true);
- expect(noPath).toContain("<circle");
- expect(noPath).toContain('stroke="#999"');
- expect(noPath).not.toContain("<linearGradient");
- expect(noPath).toMatch(/>I<\/text>/);
- expect(noPath).toMatch(/>C<\/text>/);
- const withPath = plotChordGraph(
- g,
- { nodes: ["V", "I"], edges: new Set(["V->I", "I->I"]) },
- 0,
- "major",
- );
- expect(withPath).toContain("<linearGradient");
- expect(withPath).toContain("url(#eg0)");
- expect(withPath).toContain('opacity="1"');
- });
-
- it("plotChordGraph works in minor mode and accepts costs as a plain Object at the post-orchestrator boundary", () => {
- const g = chordGraph("minor");
- const adj = {
- nodes: g.nodes,
- edges: g.edges,
- costs: Object.fromEntries(g.costs),
- };
- const svg = plotChordGraph(adj, null, 9, "minor");
- expect(svg).toContain("<svg");
- expect(svg).toMatch(/>i<\/text>/);
- expect(svg).toMatch(/>Am<\/text>/);
- });
-
- it("plotChordGraph handles chromatic edge cases by graying paths, drawing self-loop arcs only when present and placing single versus multi chromatics correctly", () => {
- const grayG = majorChordGraphWithChromatic(["bII"], {
- "I->bII": 3,
- "bII->V": 3,
- });
- const graySvg = plotChordGraph(
- grayG,
- { nodes: ["I", "bII", "V"], edges: new Set(["I->bII", "bII->V"]) },
- 0,
- "major",
- );
- expect(graySvg).toContain("rgb(120,120,120)");
- const noLoopAdj = {
- nodes: ["I", "V"],
- edges: [["I", "V"]],
- costs: { "I->V": 2 },
- };
- const noLoopSvg = plotChordGraph(noLoopAdj, null, 0, "major");
- expect(noLoopSvg.match(/marker-end/g) || []).toHaveLength(1);
- const oneSvg = plotChordGraph(
- majorChordGraphWithChromatic(["bII"], { "I->bII": 3 }),
- null,
- 0,
- "major",
- );
- expect(oneSvg).toMatch(/>bII<\/text>/);
- expect(oneSvg).toContain('cx="430"');
- const multiSvg = plotChordGraph(
- majorChordGraphWithChromatic(["bII", "bIII"], {
- "I->bII": 3,
- "I->bIII": 3,
- }),
- null,
- 0,
- "major",
- );
- expect(multiSvg).toMatch(/>bII<\/text>/);
- expect(multiSvg).toMatch(/>bIII<\/text>/);
- });
- });