import { test, expect, describe } from "bun:test"; import { canFollow, cardinalityMessage, FAMILY_CARDINALITY, FAMILY_FOLLOWS, FAMILY_ORDER, sortIntoBandOrder, } from "../../src/level-blocks/composition.ts"; import type { BlockFamily } from "../../src/level-blocks/registry.ts"; const FAMILIES: readonly BlockFamily[] = [ "constraint", "scorer", "search", "collector", ]; describe("canFollow — the family grammar", () => { // The full truth table, mirroring connections.ts NEXT_ALLOWED. When a // vendor sync deletes its copy, this table is the contract it inherits. const EXPECTED: Record> = { constraint: { constraint: true, scorer: true, search: true, collector: false, }, scorer: { constraint: false, scorer: true, search: true, collector: false }, search: { constraint: false, scorer: false, search: false, collector: true, }, collector: { constraint: false, scorer: false, search: false, collector: true, }, }; for (const above of FAMILIES) { for (const below of FAMILIES) { test(`${below} may ${EXPECTED[above][below] ? "" : "not "}follow ${above}`, () => { expect(canFollow(above, below)).toBe(EXPECTED[above][below]); }); } } test("FAMILY_FOLLOWS agrees with canFollow", () => { for (const above of FAMILIES) { for (const below of FAMILIES) { expect(FAMILY_FOLLOWS[above].includes(below)).toBe( canFollow(above, below), ); } } }); test("a scorer physically cannot sit above a constraint", () => { expect(canFollow("scorer", "constraint")).toBe(false); }); test("only collectors may follow the search block", () => { expect(FAMILY_FOLLOWS.search).toEqual(["collector"]); }); }); describe("FAMILY_CARDINALITY", () => { test("exactly one search, everything else open", () => { expect(FAMILY_CARDINALITY.search).toEqual({ min: 1, max: 1 }); expect(FAMILY_CARDINALITY.constraint).toEqual({ min: 0, max: null }); expect(FAMILY_CARDINALITY.scorer).toEqual({ min: 0, max: null }); expect(FAMILY_CARDINALITY.collector).toEqual({ min: 0, max: null }); }); test("cardinalityMessage preserves the validateGraph search wording", () => { expect(cardinalityMessage("search", FAMILY_CARDINALITY.search, 0)).toBe( "Expected exactly one search-family block, found 0", ); expect(cardinalityMessage("search", FAMILY_CARDINALITY.search, 2)).toBe( "Expected exactly one search-family block, found 2", ); }); test("cardinalityMessage phrasing generalizes to other bounds", () => { expect(cardinalityMessage("scorer", { min: 1, max: null }, 0)).toBe( "Expected at least 1 scorer-family blocks, found 0", ); expect(cardinalityMessage("collector", { min: 0, max: 2 }, 3)).toBe( "Expected between 0 and 2 collector-family blocks, found 3", ); }); }); describe("sortIntoBandOrder", () => { const familyOf = (item: { family: BlockFamily | undefined; }): BlockFamily | undefined => item.family; test("sorts a shuffled set into canonical band order", () => { const items = [ { id: "collector", family: "collector" as const }, { id: "search", family: "search" as const }, { id: "scorer", family: "scorer" as const }, { id: "constraint", family: "constraint" as const }, ]; expect(sortIntoBandOrder(items, familyOf).map((i) => i.id)).toEqual([ "constraint", "scorer", "search", "collector", ]); }); test("is stable within a band", () => { const items = [ { id: "s2", family: "scorer" as const }, { id: "c1", family: "constraint" as const }, { id: "s1", family: "scorer" as const }, ]; expect(sortIntoBandOrder(items, familyOf).map((i) => i.id)).toEqual([ "c1", "s2", "s1", ]); }); test("unknown families sink to the end", () => { const items = [ { id: "unknown", family: undefined }, { id: "constraint", family: "constraint" as const }, ]; expect(sortIntoBandOrder(items, familyOf).map((i) => i.id)).toEqual([ "constraint", "unknown", ]); }); test("every adjacent pair of a sorted single-search set satisfies canFollow", () => { const items = FAMILY_ORDER.map((family) => ({ family })); const sorted = sortIntoBandOrder(items, (i) => i.family); for (let i = 1; i < sorted.length; i++) { expect(canFollow(sorted[i - 1]!.family, sorted[i]!.family)).toBe(true); } }); });