1
0
Fork 0
polaris-task-force/tests/int/tech-tree-stability.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

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);
});
});