import { describe, expect, it } from "vitest"; import { edgeVisuals, PATH_PALETTE } from "@/components/admin/tech-tree/edgeVisuals"; import { applyPins, pinsToFreeze } from "@/components/admin/tech-tree/freeze"; import { computeLayout } from "@/components/admin/tech-tree/layout"; import { COLUMN_STEP, type TreeTech } from "@/components/admin/tech-tree/types"; const tech = (over: Partial & { id: number }): TreeTech => ({ name: `Tech ${String(over.id).padStart(2, "0")}`, type: "upgrade", approvalStatus: "in_progress", summary: "", categoryId: null, prereqAnd: [], prereqOr: [], resourceCosts: [], treePosition: null, ...over, }); const positionsOf = (techs: TreeTech[]) => new Map(computeLayout(techs, []).nodes.map((n) => [n.techId, { x: n.x, y: n.y }])); describe("tech tree stability (freeze on data change)", () => { it("pins every card the new layout would move, including the target's dependents", () => { // B (2) already has a dependent C (3). Linking A (1) -> B relocates B, // and C follows B in the ordered layout: both must be pinned in place. const before = [ tech({ id: 1 }), tech({ id: 2 }), tech({ id: 3, prereqAnd: [2] }), tech({ id: 4 }), ]; const current = positionsOf(before); const after = before.map((t) => (t.id === 2 ? { ...t, prereqAnd: [1] } : t)); const pins = pinsToFreeze(current, after, []); expect(pins.has(2)).toBe(true); expect(pins.has(3)).toBe(true); // With the pins applied, NOTHING on screen moves. const frozen = positionsOf(applyPins(after, pins)); for (const [id, pos] of current) { expect(frozen.get(id)).toEqual(pos); } }); it("does not pin cards whose position is unchanged", () => { const before = [tech({ id: 1 }), tech({ id: 2 }), tech({ id: 3 })]; const current = positionsOf(before); const pins = pinsToFreeze(current, before, []); expect(pins.size).toBe(0); }); it("skips technologies that have no on-screen position yet (just created)", () => { const before = [tech({ id: 1 }), tech({ id: 3 })]; const current = positionsOf(before); // A new tech whose name sorts between the others would shift catalog ranks. const after = [...before, tech({ id: 2 })]; const pins = pinsToFreeze(current, after, []); expect(pins.has(2)).toBe(false); const frozen = positionsOf(applyPins(after, pins)); expect(frozen.get(1)).toEqual(current.get(1)); expect(frozen.get(3)).toEqual(current.get(3)); }); }); describe("tech tree edge visuals (path colors + lanes)", () => { it("colors separate progression paths differently and keeps one color per chain", () => { const techs = [ tech({ id: 1 }), tech({ id: 2, prereqAnd: [1] }), tech({ id: 3, prereqAnd: [2] }), tech({ id: 10 }), tech({ id: 11, prereqAnd: [10] }), ]; const edges = [ { id: "a", source: 1, target: 2 }, { id: "b", source: 2, target: 3 }, { id: "c", source: 10, target: 11 }, ]; const visuals = edgeVisuals(techs, edges, positionsOf(techs), COLUMN_STEP); expect(visuals.get("a")!.color).toBe(visuals.get("b")!.color); expect(visuals.get("a")!.color).not.toBe(visuals.get("c")!.color); expect(PATH_PALETTE).toContain(visuals.get("c")!.color); }); it("gives different sources crossing the same gap their own lanes", () => { const techs = [ tech({ id: 1 }), tech({ id: 2 }), tech({ id: 3, prereqAnd: [1] }), tech({ id: 4, prereqAnd: [2] }), ]; const edges = [ { id: "a", source: 1, target: 3 }, { id: "b", source: 2, target: 4 }, ]; const visuals = edgeVisuals(techs, edges, positionsOf(techs), COLUMN_STEP); expect(visuals.get("a")!.stepPosition).not.toBe(visuals.get("b")!.stepPosition); for (const v of visuals.values()) { expect(v.stepPosition).toBeGreaterThanOrEqual(0.2); expect(v.stepPosition).toBeLessThanOrEqual(0.8); } }); it("keeps edges from the same source in one lane (a fan-out bus)", () => { const techs = [ tech({ id: 1 }), tech({ id: 2, prereqAnd: [1] }), tech({ id: 3, prereqAnd: [1] }), ]; const edges = [ { id: "a", source: 1, target: 2 }, { id: "b", source: 1, target: 3 }, ]; const visuals = edgeVisuals(techs, edges, positionsOf(techs), COLUMN_STEP); expect(visuals.get("a")!.stepPosition).toBe(0.5); expect(visuals.get("b")!.stepPosition).toBe(0.5); }); });