import { describe, expect, it } from "vitest"; import { compileGlobalGovernanceFacts, componentId as makeComponentId, FactIndex, makeGovernanceRuleConfigFact, makeUsageComponentFact, makeUsageNodeFact, makeUsagePropResolvedFact, } from "../facts/index.js"; import type { GovernanceConfig } from "../governance.js"; import { ruleCompositionCardinality, ruleCompositionCoOccurrence } from "./composition-pattern.js"; import { type CompositionAuthoringEntry, type CompositionPattern, lowerCompositionContract, } from "./composition-pattern-schema.js"; const BUTTON_GROUP = makeComponentId("@fragments-sdk/ui", "ButtonGroup"); const BUTTON = makeComponentId("@fragments-sdk/ui", "Button"); const FILE = "src/App.tsx"; // The shared example policies from ACCEPTANCE.md. const POLICY_CARDINALITY: CompositionPattern = { region: { component: "ButtonGroup" }, select: { component: "Button", prop: "variant", value: "primary" }, constraint: { kind: "cardinality", max: 1 }, }; const POLICY_COOCCURRENCE: CompositionPattern = { region: { component: "ButtonGroup" }, select: { component: "Button", prop: "variant", value: "primary" }, constraint: { kind: "co-occurrence", requires: { prop: "variant", value: "secondary" } }, }; interface NodeSpec { path: string; element: string; /** Canonical component the node resolves to (omit for an untracked element). */ component?: ReturnType; /** A `variant` prop and how it resolves (omit for none). */ variant?: { value?: string; resolution?: "static" | "dynamic" }; } function add(ix: FactIndex, spec: NodeSpec): void { const node = makeUsageNodeFact({ file: FILE, nodePath: spec.path, element: spec.element, location: { file: FILE, line: 1, column: 1 }, }); ix.add(node); if (spec.component) { ix.add(makeUsageComponentFact({ nodeId: node.id, componentId: spec.component })); } if (spec.variant) { ix.add( makeUsagePropResolvedFact({ nodeId: node.id, prop: "variant", resolution: spec.variant.resolution ?? "static", value: spec.variant.value, }) ); } } function withPolicy(ix: FactIndex, ruleId: string, patterns: CompositionPattern[]): FactIndex { ix.add(makeGovernanceRuleConfigFact({ ruleId, enabled: true, options: { patterns } })); return ix; } const buttonGroup = (path: string): NodeSpec => ({ path, element: "ButtonGroup", component: BUTTON_GROUP, }); const button = (path: string, variant?: NodeSpec["variant"]): NodeSpec => ({ path, element: "Button", component: BUTTON, variant, }); describe("composition/cardinality", () => { // ACCEPTANCE §3 it("flags a second primary Button in one ButtonGroup", () => { const ix = new FactIndex(); add(ix, buttonGroup("0:0")); add(ix, button("0:0/0", { value: "primary" })); add(ix, button("0:0/1", { value: "primary" })); withPolicy(ix, "composition/cardinality", [POLICY_CARDINALITY]); const findings = ruleCompositionCardinality(ix); expect(findings).toHaveLength(1); expect(findings[0]).toMatchObject({ ruleId: "composition/cardinality", level: "warn", code: "FUI5003", message: "At most one primary Button is allowed per ButtonGroup (found 2).", }); }); // ACCEPTANCE §4 it("does not flag a primary + secondary Button", () => { const ix = new FactIndex(); add(ix, buttonGroup("0:0")); add(ix, button("0:0/0", { value: "primary" })); add(ix, button("0:0/1", { value: "secondary" })); withPolicy(ix, "composition/cardinality", [POLICY_CARDINALITY]); expect(ruleCompositionCardinality(ix)).toHaveLength(0); }); // ACCEPTANCE §5 — per region, not per file. it("counts cardinality per region, not across the file", () => { const ix = new FactIndex(); // <>