1
0
Fork 0
polaris-task-force/tests/int/tech-tree-layout.int.spec.ts
Z8MB1E 5f7fd508a5 test(tech-tree): add integration and e2e coverage
- `tech-tree-layout.int.spec.ts`: pure layout (wrapping, manual overrides,
  category bands, forward-edge warnings, cycles, catalog-stability).
- `tech-tree-prereq-edits.int.spec.ts`: add/remove/switch prerequisites with
  cycle rejection and the pin-on-connect stability contract.
- `tech-tree-stability.int.spec.ts`: freeze pins every relocated card on a
  data change, plus edge visuals (per-path colors, per-source lanes).
- `tech-categories.int.spec.ts`: CRUD, RBAC, division gating, FK set-null,
  position round-trip.
- `tech-tree.e2e.spec.ts`: nav entry, render, preview/panel edits, both
  quick-create paths, drag persistence + Auto-arrange, save positions
  surviving a reload, connect/disconnect, and multi-column wrapping.
2026-10-01 10:04:07 -04:00

245 lines
9.2 KiB
TypeScript

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<TreeTech> & { 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<typeof computeLayout>, 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"]);
});
});