/*
Unit tests for eligibility covering normalizeSegment; matchesRecord; countMatching. It also guards the documented public contract and regression-sensitive edge cases.
- Not a contract source of truth — the module under test owns the authoritative behavior; fixtures are minimal scaffolding only.
- RFC-1311: Compass header sweep — declares the eligibility suite's contract for test-file scaffold coverage.
*/
import { test, expect, describe } from "vitest";
import {
normalizeSegment,
matchesRecord,
countMatching,
buildRecordIndex,
countMatchingIndexed,
pathKey,
enumerateCandidateTuples,
buildEligibilityMatrix,
nearestLiveAncestor,
liveChildrenOf,
liveSiblingsOf,
type AxisFieldMap,
} from "../eligibility.ts";
import type { SurfaceAxis, SurfaceRecord, EligibilityPolicy } from "../types.ts";
const SEGMENT_PATTERN = /^[a-z0-9-]+$/;
const defaultPolicy: EligibilityPolicy = {
minRecordsPerDepth: { 0: 1, 1: 1, 2: 1 },
noindexBelowPerDepth: {},
redirectPolicy: "nearest-ancestor",
trailingSlash: true,
segmentPattern: SEGMENT_PATTERN,
};
const axes: SurfaceAxis[] = [
{ id: "trade", universe: ["elektriker", "friseur"] },
{ id: "region", universe: ["berlin", "hamburg"] },
];
const axisFieldMap: AxisFieldMap = { trade: "trade", region: "region" };
const records: SurfaceRecord[] = [
{ trade: "elektriker", region: "berlin", status: "active" },
{ trade: "elektriker", region: "hamburg", status: "active" },
{ trade: "friseur", region: "berlin", status: "active" },
{ trade: "friseur", region: "hamburg", status: "archived" },
];
describe("normalizeSegment", () => {
test("lowercases and converts underscores to dashes", () => {
expect(normalizeSegment("Hello_World", SEGMENT_PATTERN)).toBe("hello-world");
});
test("trims whitespace", () => {
expect(normalizeSegment(" berlin ", SEGMENT_PATTERN)).toBe("berlin");
});
test("throws on invalid characters", () => {
expect(() => normalizeSegment("hello world!", SEGMENT_PATTERN)).toThrow();
});
test("passes through already-normalized segment", () => {
expect(normalizeSegment("elektriker", SEGMENT_PATTERN)).toBe("elektriker");
});
});
describe("matchesRecord", () => {
test("matches when all constrained axes match", () => {
expect(
matchesRecord(records[0]!, { trade: "elektriker", region: "berlin" }, axisFieldMap),
).toBe(true);
});
test("does not match when one axis differs", () => {
expect(matchesRecord(records[0]!, { trade: "friseur", region: "berlin" }, axisFieldMap)).toBe(
false,
);
});
test("matches when tuple is empty (no constraints)", () => {
expect(matchesRecord(records[0]!, {}, axisFieldMap)).toBe(true);
});
test("matches when only one axis is constrained", () => {
expect(matchesRecord(records[0]!, { trade: "elektriker" }, axisFieldMap)).toBe(true);
expect(matchesRecord(records[0]!, { trade: "friseur" }, axisFieldMap)).toBe(false);
});
test("returns false when axis field is missing from map", () => {
expect(matchesRecord(records[0]!, { unknown: "x" }, axisFieldMap)).toBe(false);
});
test("matches array field values", () => {
const rec: SurfaceRecord = { tags: ["a", "b"], status: "active" };
expect(matchesRecord(rec, { tags: "a" }, { tags: "tags" })).toBe(true);
expect(matchesRecord(rec, { tags: "c" }, { tags: "tags" })).toBe(false);
});
});
describe("countMatching", () => {
test("counts all active records for empty tuple", () => {
expect(countMatching(records, {}, axisFieldMap)).toBe(3);
});
test("counts matching records for a specific tuple", () => {
expect(countMatching(records, { trade: "elektriker" }, axisFieldMap)).toBe(2);
expect(countMatching(records, { trade: "friseur" }, axisFieldMap)).toBe(1);
});
test("excludes archived records", () => {
expect(countMatching(records, { trade: "friseur", region: "hamburg" }, axisFieldMap)).toBe(0);
});
});
describe("buildRecordIndex + countMatchingIndexed", () => {
const index = buildRecordIndex(records, axisFieldMap);
test("index count matches linear count for empty tuple", () => {
expect(countMatchingIndexed({}, axisFieldMap, index)).toBe(3);
});
test("index count matches linear count for specific tuple", () => {
expect(countMatchingIndexed({ trade: "elektriker" }, axisFieldMap, index)).toBe(2);
expect(countMatchingIndexed({ trade: "friseur", region: "berlin" }, axisFieldMap, index)).toBe(
1,
);
});
test("returns 0 for non-existent axis", () => {
expect(countMatchingIndexed({ unknown: "x" }, axisFieldMap, index)).toBe(0);
});
test("returns 0 for non-existent value", () => {
expect(countMatchingIndexed({ trade: "nonexistent" }, axisFieldMap, index)).toBe(0);
});
});
describe("pathKey", () => {
test("depth 0 produces just '0'", () => {
expect(pathKey(0, {}, ["trade", "region"])).toBe("0");
});
test("depth 1 includes first axis value", () => {
expect(pathKey(1, { trade: "elektriker" }, ["trade", "region"])).toBe("1|elektriker");
});
test("depth 2 includes both axis values", () => {
expect(pathKey(2, { trade: "elektriker", region: "berlin" }, ["trade", "region"])).toBe(
"2|elektriker|berlin",
);
});
test("missing axis value becomes empty string", () => {
expect(pathKey(2, { trade: "elektriker" }, ["trade", "region"])).toBe("2|elektriker|");
});
});
describe("enumerateCandidateTuples", () => {
test("depth 0 yields single empty tuple", () => {
const tuples = [...enumerateCandidateTuples(axes, 0)];
expect(tuples).toEqual([{}]);
});
test("depth 1 yields all first-axis values", () => {
const tuples = [...enumerateCandidateTuples(axes, 1)];
expect(tuples).toHaveLength(2);
expect(tuples[0]).toEqual({ trade: "elektriker" });
expect(tuples[1]).toEqual({ trade: "friseur" });
});
test("depth 2 yields cartesian product", () => {
const tuples = [...enumerateCandidateTuples(axes, 2)];
expect(tuples).toHaveLength(4);
expect(tuples.map((t) => `${t.trade}/${t.region}`).sort()).toEqual([
"elektriker/berlin",
"elektriker/hamburg",
"friseur/berlin",
"friseur/hamburg",
]);
});
test("empty universe yields nothing", () => {
const emptyAxes: SurfaceAxis[] = [{ id: "trade", universe: [] }];
expect([...enumerateCandidateTuples(emptyAxes, 1)]).toHaveLength(0);
});
});
describe("buildEligibilityMatrix", () => {
const matrix = buildEligibilityMatrix(axes, axisFieldMap, records, defaultPolicy);
test("has correct axis order", () => {
expect(matrix.axisOrder).toEqual(["trade", "region"]);
});
test("root entry (depth 0) is live with 3 records", () => {
const root = matrix.byKey.get("0");
expect(root).toBeDefined();
expect(root!.recordCount).toBe(3);
expect(root!.indexable).toBe(true);
});
test("elektriker depth 1 is live with 2 records", () => {
const entry = matrix.byKey.get("1|elektriker");
expect(entry).toBeDefined();
expect(entry!.recordCount).toBe(2);
expect(entry!.indexable).toBe(true);
});
test("friseur depth 1 is live with 1 record", () => {
const entry = matrix.byKey.get("1|friseur");
expect(entry).toBeDefined();
expect(entry!.recordCount).toBe(1);
expect(entry!.indexable).toBe(true);
});
test("friseur/hamburg depth 2 is not live (archived only)", () => {
const entry = matrix.byKey.get("2|friseur|hamburg");
expect(entry).toBeUndefined();
});
test("elektriker/berlin depth 2 is live", () => {
const entry = matrix.byKey.get("2|elektriker|berlin");
expect(entry).toBeDefined();
expect(entry!.indexable).toBe(true);
});
test("forceNonIndexableDepths gates a depth as regional-gated", () => {
const matrix2 = buildEligibilityMatrix(axes, axisFieldMap, records, defaultPolicy, {
forceNonIndexableDepths: [2],
maxStubDepth: 2,
});
const entry = matrix2.byKey.get("2|elektriker|berlin");
expect(entry).toBeDefined();
expect(entry!.indexable).toBe(false);
expect(entry!.decision.reason).toBe("regional-gated");
});
});
describe("nearestLiveAncestor", () => {
const matrix = buildEligibilityMatrix(axes, axisFieldMap, records, defaultPolicy);
test("returns root for depth 1 entry", () => {
const entry = matrix.byKey.get("1|elektriker")!;
const ancestor = nearestLiveAncestor(entry, matrix, defaultPolicy);
expect(ancestor?.depth).toBe(0);
});
test("returns nearest live ancestor for depth 2 entry", () => {
const entry = matrix.byKey.get("2|elektriker|berlin")!;
const ancestor = nearestLiveAncestor(entry, matrix, defaultPolicy);
expect(ancestor?.depth).toBe(1);
expect(ancestor?.tuple.trade).toBe("elektriker");
});
test("redirectPolicy root always returns root", () => {
const rootPolicy = { ...defaultPolicy, redirectPolicy: "root" as const };
const entry = matrix.byKey.get("2|elektriker|berlin")!;
const ancestor = nearestLiveAncestor(entry, matrix, rootPolicy);
expect(ancestor?.depth).toBe(0);
});
});
describe("liveChildrenOf", () => {
const matrix = buildEligibilityMatrix(axes, axisFieldMap, records, defaultPolicy);
test("root has live children at depth 1", () => {
const root = matrix.byKey.get("0")!;
const children = liveChildrenOf(root, matrix);
expect(children).toHaveLength(2);
expect(children.map((c) => c.tuple.trade).sort()).toEqual(["elektriker", "friseur"]);
});
test("elektriker depth 1 has live children at depth 2", () => {
const parent = matrix.byKey.get("1|elektriker")!;
const children = liveChildrenOf(parent, matrix);
expect(children).toHaveLength(2);
expect(children.map((c) => c.tuple.region).sort()).toEqual(["berlin", "hamburg"]);
});
test("friseur depth 1 has one live child (berlin)", () => {
const parent = matrix.byKey.get("1|friseur")!;
const children = liveChildrenOf(parent, matrix);
expect(children).toHaveLength(1);
expect(children[0]!.tuple.region).toBe("berlin");
});
});
describe("liveSiblingsOf", () => {
const matrix = buildEligibilityMatrix(axes, axisFieldMap, records, defaultPolicy);
test("depth 0 has no siblings", () => {
const root = matrix.byKey.get("0")!;
expect(liveSiblingsOf(root, matrix)).toHaveLength(0);
});
test("elektriker at depth 1 has friseur as sibling", () => {
const entry = matrix.byKey.get("1|elektriker")!;
const siblings = liveSiblingsOf(entry, matrix);
expect(siblings).toHaveLength(1);
expect(siblings[0]!.tuple.trade).toBe("friseur");
});
test("elektriker/berlin at depth 2 has elektriker/hamburg as sibling", () => {
const entry = matrix.byKey.get("2|elektriker|berlin")!;
const siblings = liveSiblingsOf(entry, matrix);
expect(siblings).toHaveLength(1);
expect(siblings[0]!.tuple.region).toBe("hamburg");
});
});