import { describe, expect, it } from "vitest"; import { MAX_ROWS_PER_COLUMN, computeLayout, } from "@/components/admin/tech-tree/layout"; import { COLUMN_STEP, NODE_WIDTH, ROW_STEP, type TreeCategory, type TreeTech, } from "@/components/admin/tech-tree/types"; const tech = (over: Partial & { id: number }): TreeTech => ({ name: `Tech ${over.id}`, type: "upgrade", approvalStatus: "in_progress", summary: "", categoryId: null, prereqAnd: [], prereqOr: [], resourceCosts: [], treePosition: null, ...over, }); const cat = (id: number, name: string, position: number): TreeCategory => ({ id, name, position, color: null, }); describe("tech tree layout (pure)", () => { it("wraps a long uncategorized column into multiple physical columns", () => { const techs = Array.from({ length: 25 }, (_, i) => tech({ id: i + 1 })); const layout = computeLayout(techs, []); const xs = [...new Set(layout.nodes.map((n) => n.x))].sort((a, b) => a - b); expect(xs).toEqual([0, COLUMN_STEP, COLUMN_STEP * 2]); expect(layout.columns).toBe(3); // First column holds MAX_ROWS_PER_COLUMN nodes, rows 0..N-1. const firstColumnRows = layout.nodes.filter((n) => n.x === 0).map((n) => n.y / ROW_STEP); expect(firstColumnRows.sort((a, b) => a - b)).toEqual( Array.from({ length: MAX_ROWS_PER_COLUMN }, (_, i) => i), ); // The final column holds the remainder. const lastColumnCount = layout.nodes.filter((n) => n.x === COLUMN_STEP * 2).length; expect(lastColumnCount).toBe(25 - MAX_ROWS_PER_COLUMN * 2); }); it("orders category columns by explicit position, then name", () => { const categories = [cat(10, "Tier 2", 2), cat(11, "Tier 1", 1), cat(12, "Alpha", 2)]; const techs = [ tech({ id: 1, categoryId: 10 }), tech({ id: 2, categoryId: 11 }), tech({ id: 3, categoryId: 12 }), ]; const layout = computeLayout(techs, categories); const xOf = (id: number) => layout.nodes.find((n) => n.techId === id)!.x; // Tier 1 (position 1) is leftmost. expect(xOf(2)).toBe(0); // Position 2 ties break alphabetically: "Alpha" before "Tier 2". expect(xOf(3)).toBe(COLUMN_STEP); expect(xOf(1)).toBe(COLUMN_STEP * 2); }); it("places uncategorized techs in a trailing zone after all categories", () => { const categories = [cat(1, "Tier 1", 1), cat(2, "Tier 2", 2)]; const techs = [ tech({ id: 1, categoryId: 1 }), tech({ id: 2, categoryId: 2 }), tech({ id: 3 }), ]; const layout = computeLayout(techs, categories); const xOf = (id: number) => layout.nodes.find((n) => n.techId === id)!.x; expect(xOf(3)).toBe(COLUMN_STEP * 2); // after both category columns }); it("places a dependent one column right of its parent, at the parent's row", () => { const techs = [tech({ id: 1 }), tech({ id: 2, prereqAnd: [1] })]; const layout = computeLayout(techs, []); const parent = layout.nodes.find((n) => n.techId === 1)!; const child = layout.nodes.find((n) => n.techId === 2)!; expect(child.column).toBe(parent.column + 1); expect(child.row).toBe(parent.row); // Short, local edge: exactly one column apart. expect(child.x - parent.x).toBe(COLUMN_STEP); }); it("keeps the chain propagating rightwards for grandchildren", () => { const techs = [ tech({ id: 1 }), tech({ id: 2, prereqAnd: [1] }), tech({ id: 3, prereqAnd: [2] }), ]; const layout = computeLayout(techs, []); const col = (id: number) => layout.nodes.find((n) => n.techId === id)!.column; expect(col(2)).toBe(col(1) + 1); expect(col(3)).toBe(col(2) + 1); }); it("links stay beside their parent even when the root grid fills its columns", () => { // 25 roots fill three 10-row columns; a dependent of the FIRST root must // still land immediately to its right, not after the whole grid. const roots = Array.from({ length: 25 }, (_, i) => tech({ id: i + 1 })); const techs = [...roots, tech({ id: 100, prereqAnd: [1] })]; const layout = computeLayout(techs, []); const parent = layout.nodes.find((n) => n.techId === 1)!; const child = layout.nodes.find((n) => n.techId === 100)!; // Adjacent column, and right next to the parent vertically (it may grow // upwards into a free slot rather than being pushed to a distant column). expect(child.column).toBe(parent.column + 1); expect(Math.abs(child.row - parent.row)).toBeLessThanOrEqual(1); }); it("keeps the catalog grid stable when a root becomes a dependent", () => { const roots = ["Alpha", "Bravo", "Charlie", "Delta", "Echo", "Foxtrot"].map((name, i) => tech({ id: i + 1, name }), ); const before = computeLayout(roots, []); const posBefore = (layout: ReturnType, id: number) => layout.nodes.find((n) => n.techId === id)!; // Foxtrot (id 6) becomes Charlie's prerequisite: Charlie (id 3) leaves the // catalog and becomes a dependent. const after = computeLayout( roots.map((t) => (t.id === 3 ? { ...t, prereqAnd: [6] } : t)), [], ); // Every other root keeps its exact slot: no re-shuffling on connect. for (const id of [1, 2, 4, 5, 6]) { expect(after.nodes.find((n) => n.techId === id)!.x).toBe(posBefore(before, id).x); expect(after.nodes.find((n) => n.techId === id)!.y).toBe(posBefore(before, id).y); } // Charlie's catalog slot is now empty, and it sits right of its prerequisite. const foxtrot = after.nodes.find((n) => n.techId === 6)!; const charlie = after.nodes.find((n) => n.techId === 3)!; expect(charlie.column).toBe(foxtrot.column + 1); expect(charlie.row).toBe(foxtrot.row); }); it("keeps a pinned dependent exactly where it was placed", () => { const techs = [ tech({ id: 1 }), tech({ id: 2, prereqAnd: [1], treePosition: { x: 1234, y: 567 } }), ]; const layout = computeLayout(techs, []); const pinned = layout.nodes.find((n) => n.techId === 2)!; expect(pinned.x).toBe(1234); expect(pinned.y).toBe(567); }); it("uses the average parent row for multi-parent dependents", () => { const techs = [ tech({ id: 1 }), tech({ id: 2 }), tech({ id: 3 }), tech({ id: 4 }), tech({ id: 5 }), tech({ id: 6, prereqAnd: [1, 5] }), ]; const layout = computeLayout(techs, []); const row = (id: number) => layout.nodes.find((n) => n.techId === id)!.row; const child = layout.nodes.find((n) => n.techId === 6)!; expect(child.row).toBe(Math.round((row(1) + row(5)) / 2)); expect(child.column).toBe(1); }); it("stacks siblings without overlapping them", () => { const techs = [ tech({ id: 1 }), tech({ id: 2, prereqAnd: [1] }), tech({ id: 3, prereqAnd: [1] }), tech({ id: 4, prereqAnd: [1] }), ]; const layout = computeLayout(techs, []); const siblings = layout.nodes.filter((n) => [2, 3, 4].includes(n.techId)); expect(new Set(siblings.map((n) => n.column)).size).toBe(1); expect(new Set(siblings.map((n) => n.row)).size).toBe(3); }); it("honors manual treePosition overrides and grows the band to contain them", () => { const categories = [cat(1, "Tier 1", 1)]; const techs = [ tech({ id: 1, categoryId: 1 }), tech({ id: 2, categoryId: 1, treePosition: { x: 900, y: 400 } }), ]; const layout = computeLayout(techs, categories); const manual = layout.nodes.find((n) => n.techId === 2)!; expect(manual.x).toBe(900); expect(manual.y).toBe(400); const band = layout.bands.find((b) => b.key === "cat-1")!; // Band spans from the auto-positioned node to the dragged node. expect(band.x + band.width).toBeGreaterThanOrEqual(900 + NODE_WIDTH); }); it("flags a prerequisite that sits in a later column", () => { const categories = [cat(1, "Tier 1", 1), cat(2, "Tier 2", 2)]; const techs = [ tech({ id: 1, categoryId: 1, prereqAnd: [2] }), // depends on a Tier 2 tech tech({ id: 2, categoryId: 2 }), ]; const layout = computeLayout(techs, categories); expect(layout.warnings.map((w) => w.techId)).toContain(1); }); it("does not flag forward-pointing prerequisites", () => { const categories = [cat(1, "Tier 1", 1), cat(2, "Tier 2", 2)]; const techs = [ tech({ id: 1, categoryId: 1 }), tech({ id: 2, categoryId: 2, prereqAnd: [1] }), ]; const layout = computeLayout(techs, categories); expect(layout.warnings).toEqual([]); }); it("survives prerequisite cycles", () => { const techs = [ tech({ id: 1, prereqAnd: [2] }), tech({ id: 2, prereqAnd: [1] }), ]; const layout = computeLayout(techs, []); expect(layout.nodes).toHaveLength(2); }); it("omits bands for categories with no members", () => { const categories = [cat(1, "Tier 1", 1), cat(2, "Empty Tier", 2)]; const techs = [tech({ id: 1, categoryId: 1 })]; const layout = computeLayout(techs, categories); expect(layout.bands.map((b) => b.key)).toEqual(["cat-1"]); }); it("ignores dangling category references by treating them as uncategorized", () => { const techs = [tech({ id: 1, categoryId: 999 })]; const layout = computeLayout(techs, [cat(1, "Tier 1", 1)]); expect(layout.bands.map((b) => b.key)).toEqual(["uncategorized"]); }); });