- `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.
121 lines
4.4 KiB
TypeScript
121 lines
4.4 KiB
TypeScript
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<TreeTech> & { 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);
|
|
});
|
|
});
|