import { TestCase } from "./framework/test_case.js"; import { TECHNOLOGIES } from "../shared/data.js"; import { romanNumeral, layoutTechnologies, chamferPath, unmetPrerequisites, TECH_LAYOUT, } from "../client/js/ui/tech_tree.js"; const indexOf = (id) => TECHNOLOGIES.findIndex((t) => t.id === id); // Every axis-aligned run of every link, as {x1,y1,x2,y2,label,port}. The first // and last run are the port stubs, which several links sharing a card port are // allowed to overlap (they fan out of, or converge into, one point). function segments(layout) { const runs = []; for (const e of layout.edges) { const label = `${TECHNOLOGIES[e.from].id}->${TECHNOLOGIES[e.to].id}`; for (let i = 1; i < e.points.length; i++) { const [x1, y1] = e.points[i - 1]; const [x2, y2] = e.points[i]; if (Math.hypot(x2 - x1, y2 - y1) < 0.5) continue; const port = i === 1 || i === e.points.length - 1; runs.push({ x1, y1, x2, y2, label, port }); } } return runs; } const horizontal = (s) => Math.abs(s.y1 - s.y2) < 0.01; const vertical = (s) => Math.abs(s.x1 - s.x2) < 0.01; export class TechTreeTest extends TestCase { test_repeatable_levels_are_roman_up_to_ten() { this.assertEqual(romanNumeral(1), "I"); this.assertEqual(romanNumeral(4), "IV"); this.assertEqual(romanNumeral(9), "IX"); this.assertEqual(romanNumeral(10), "X"); // Beyond the catalogue's practical reach a plain number is used. this.assertEqual(romanNumeral(11), "11"); } test_column_is_the_distance_from_a_root() { const layout = layoutTechnologies(TECHNOLOGIES); this.assertEqual(layout.placed.get(indexOf("industrial_automation")).col, 0, "a root opens column 0"); this.assertEqual(layout.placed.get(indexOf("quantum_computing")).col, 0); this.assertEqual(layout.placed.get(indexOf("space_program")).col, 1, "one step from a root"); this.assertEqual(layout.placed.get(indexOf("fusion_power")).col, 2, "two steps from a root"); // Every technology of a tier shares its column. for (const [index, pos] of layout.placed) { const roots = TECHNOLOGIES[index].prerequisites; if (roots.length) { const parents = roots.map((id) => layout.placed.get(indexOf(id)).col); this.assertEqual(pos.col, Math.max(...parents) + 1, `${TECHNOLOGIES[index].id} sits one past its deepest parent`); } else { this.assertEqual(pos.col, 0, `${TECHNOLOGIES[index].id} is a root`); } } } test_every_prerequisite_sits_to_the_left() { const layout = layoutTechnologies(TECHNOLOGIES); for (const [index, pos] of layout.placed) { for (const id of TECHNOLOGIES[index].prerequisites) { const parent = layout.placed.get(indexOf(id)); this.assertTrue(parent.x < pos.x, `${TECHNOLOGIES[index].id} follows ${id}`); } } } test_every_prerequisite_has_a_routed_link() { const layout = layoutTechnologies(TECHNOLOGIES); let expected = 0; for (const tech of TECHNOLOGIES) expected += tech.prerequisites.length; this.assertSize(layout.edges, expected, "one link per prerequisite"); for (const e of layout.edges) { const parent = layout.placed.get(e.from); const child = layout.placed.get(e.to); this.assertEqual(e.points[0][0], parent.x + TECH_LAYOUT.W, "the link leaves the parent's right edge"); const last = e.points[e.points.length - 1]; this.assertEqual(last[0], child.x, "the link enters the child's left edge"); for (let i = 1; i < e.points.length; i++) { const a = e.points[i - 1]; const b = e.points[i]; this.assertTrue( Math.abs(a[0] - b[0]) < 0.5 || Math.abs(a[1] - b[1]) < 0.5, "every run is axis-aligned" ); } } } test_links_are_chamfered_without_curves() { const layout = layoutTechnologies(TECHNOLOGIES); const edge = layout.edges.find( (e) => TECHNOLOGIES[e.from].id === "quantum_computing" && TECHNOLOGIES[e.to].id === "space_program" ); const d = chamferPath(edge.points); this.assertTrue(d.startsWith("M "), "the path starts with a move"); this.assertFalse(d.includes("NaN"), "no coordinate is missing"); this.assertFalse(d.includes("C "), "there is no bezier curve"); } // The whole point of the lane routing: distinct dependencies must never be // drawn on top of each other. The short stubs that fan out of a shared card // port are the one exception, allowed to lie along the same line. test_no_two_links_overlap() { const layout = layoutTechnologies(TECHNOLOGIES); const runs = segments(layout); for (let i = 0; i < runs.length; i++) { for (let j = i + 1; j < runs.length; j++) { const a = runs[i]; const b = runs[j]; if (a.port || b.port) continue; if (horizontal(a) && horizontal(b) && Math.abs(a.y1 - b.y1) < 0.01) { const lo = Math.max(Math.min(a.x1, a.x2), Math.min(b.x1, b.x2)); const hi = Math.min(Math.max(a.x1, a.x2), Math.max(b.x1, b.x2)); this.assertTrue(hi - lo < 0.5, `${a.label} overlaps ${b.label}`); } if (vertical(a) && vertical(b) && Math.abs(a.x1 - b.x1) < 0.01) { const lo = Math.max(Math.min(a.y1, a.y2), Math.min(b.y1, b.y2)); const hi = Math.min(Math.max(a.y1, a.y2), Math.max(b.y1, b.y2)); this.assertTrue(hi - lo < 0.5, `${a.label} overlaps ${b.label}`); } } } } test_every_card_has_a_single_port_each_way() { const layout = layoutTechnologies(TECHNOLOGIES); const { W, H } = TECH_LAYOUT; for (const e of layout.edges) { const parent = layout.placed.get(e.from); const child = layout.placed.get(e.to); const start = e.points[0]; const end = e.points[e.points.length - 1]; this.assertEqual(start[0], parent.x + W, "the link leaves the parent's right edge"); this.assertEqual(start[1], parent.y + H / 2, "every outgoing link leaves the middle of the right edge"); this.assertEqual(end[0], child.x, "the link enters the child's left edge"); this.assertEqual(end[1], child.y + H / 2, "every incoming link arrives at the middle of the left edge"); } } test_no_link_passes_behind_a_card() { const layout = layoutTechnologies(TECHNOLOGIES); const { W, H } = TECH_LAYOUT; for (const run of segments(layout)) { for (const [index, pos] of layout.placed) { const left = pos.x, right = pos.x + W; const top = pos.y, bottom = pos.y + H; if (horizontal(run)) { const xlo = Math.min(run.x1, run.x2); const xhi = Math.max(run.x1, run.x2); const crosses = run.y1 > top + 0.5 && run.y1 < bottom - 0.5 && xhi > left + 0.5 && xlo < right - 0.5; this.assertFalse(crosses, `${run.label} crosses ${TECHNOLOGIES[index].id}`); } else { const ylo = Math.min(run.y1, run.y2); const yhi = Math.max(run.y1, run.y2); const crosses = run.x1 > left + 0.5 && run.x1 < right - 0.5 && yhi > top + 0.5 && ylo < bottom - 0.5; this.assertFalse(crosses, `${run.label} crosses ${TECHNOLOGIES[index].id}`); } } } } test_unmet_prerequisites_recurse_to_the_roots() { const hasBuilding = () => false; const deep = unmetPrerequisites(indexOf("fusion_power"), TECHNOLOGIES, new Set(), hasBuilding); this.assertTrue(deep.has(indexOf("space_program"))); this.assertTrue(deep.has(indexOf("quantum_computing"))); this.assertTrue(deep.has(indexOf("industrial_automation")), "the chain is followed to the roots"); // A met prerequisite stops the walk: its own requirements are satisfied. const shallow = unmetPrerequisites( indexOf("fusion_power"), TECHNOLOGIES, new Set([indexOf("industrial_automation")]), hasBuilding ); this.assertFalse(shallow.has(indexOf("industrial_automation"))); this.assertTrue(shallow.has(indexOf("space_program"))); this.assertTrue(shallow.has(indexOf("quantum_computing"))); } }