import { describe, it, expect } from "vitest"; import { analyzeComposition } from "./composition.js"; import type { CompiledFragment } from "./types.js"; function makeFragment(overrides: Partial & { meta: CompiledFragment["meta"] }): CompiledFragment { return { filePath: "src/components/Test.tsx", usage: { when: [], whenNot: [] }, props: {}, variants: [], ...overrides, }; } const baseFragments: Record = { Button: makeFragment({ meta: { name: "Button", description: "A button", category: "actions", status: "stable" }, relations: [ { component: "IconButton", relationship: "alternative", note: "Use for icon-only actions" }, { component: "ButtonGroup", relationship: "composition", note: "Wrap multiple buttons" }, ], usage: { when: ["User needs to trigger an action"], whenNot: ["Use Link for navigation"], }, }), IconButton: makeFragment({ meta: { name: "IconButton", description: "Icon-only button", category: "actions", status: "stable" }, relations: [ { component: "Button", relationship: "alternative", note: "Use for labeled actions" }, ], }), ButtonGroup: makeFragment({ meta: { name: "ButtonGroup", description: "Groups buttons", category: "actions", status: "stable" }, relations: [ { component: "Button", relationship: "child", note: "Contains buttons" }, ], }), TextField: makeFragment({ meta: { name: "TextField", description: "Text input", category: "forms", status: "stable" }, relations: [ { component: "Form", relationship: "parent", note: "Should be inside a Form" }, { component: "Label", relationship: "sibling", note: "Pair with a Label" }, ], usage: { when: ["User needs to enter text"], whenNot: ["Use TextArea for multiline input"], }, }), Form: makeFragment({ meta: { name: "Form", description: "Form container", category: "forms", status: "stable" }, }), Label: makeFragment({ meta: { name: "Label", description: "Form label", category: "forms", status: "stable" }, }), Alert: makeFragment({ meta: { name: "Alert", description: "Feedback alert", category: "feedback", status: "stable" }, }), Toast: makeFragment({ meta: { name: "Toast", description: "Toast notification", category: "feedback", status: "experimental" }, }), OldButton: makeFragment({ meta: { name: "OldButton", description: "Use Button instead", category: "actions", status: "deprecated" }, }), Link: makeFragment({ meta: { name: "Link", description: "Navigation link", category: "navigation", status: "stable" }, usage: { when: ["User needs to navigate"], whenNot: ["Use Button for actions"], }, }), }; describe("analyzeComposition", () => { describe("name validation", () => { it("should split components into found and unknown", () => { const result = analyzeComposition(baseFragments, ["Button", "NonExistent", "TextField", "AlsoFake"]); expect(result.components).toEqual(["Button", "TextField"]); expect(result.unknown).toEqual(["NonExistent", "AlsoFake"]); }); it("should handle all valid names", () => { const result = analyzeComposition(baseFragments, ["Button", "TextField"]); expect(result.components).toEqual(["Button", "TextField"]); expect(result.unknown).toEqual([]); }); it("should handle all unknown names", () => { const result = analyzeComposition(baseFragments, ["Foo", "Bar"]); expect(result.components).toEqual([]); expect(result.unknown).toEqual(["Foo", "Bar"]); }); it("should handle empty input", () => { const result = analyzeComposition(baseFragments, []); expect(result.components).toEqual([]); expect(result.unknown).toEqual([]); expect(result.warnings).toEqual([]); expect(result.suggestions).toEqual([]); expect(result.guidelines).toEqual([]); }); }); describe("relation checks", () => { it("should warn about missing parent", () => { const result = analyzeComposition(baseFragments, ["TextField"]); const parentWarning = result.warnings.find((w) => w.type === "missing_parent"); expect(parentWarning).toBeDefined(); expect(parentWarning!.component).toBe("TextField"); expect(parentWarning!.relatedComponent).toBe("Form"); }); it("should not warn about parent when parent is selected", () => { const result = analyzeComposition(baseFragments, ["TextField", "Form"]); const parentWarning = result.warnings.find((w) => w.type === "missing_parent"); expect(parentWarning).toBeUndefined(); }); it("should suggest missing children", () => { const result = analyzeComposition(baseFragments, ["ButtonGroup"]); const childSuggestion = result.suggestions.find( (s) => s.component === "Button" && s.relationship === "child" ); expect(childSuggestion).toBeDefined(); expect(childSuggestion!.sourceComponent).toBe("ButtonGroup"); }); it("should warn about missing composition peers", () => { const result = analyzeComposition(baseFragments, ["Button"]); const compWarning = result.warnings.find((w) => w.type === "missing_composition"); expect(compWarning).toBeDefined(); expect(compWarning!.component).toBe("Button"); expect(compWarning!.relatedComponent).toBe("ButtonGroup"); }); it("should not warn when composition peer is selected", () => { const result = analyzeComposition(baseFragments, ["Button", "ButtonGroup"]); const compWarning = result.warnings.find((w) => w.type === "missing_composition"); expect(compWarning).toBeUndefined(); }); it("should suggest missing siblings", () => { const result = analyzeComposition(baseFragments, ["TextField", "Form"]); const siblingSuggestion = result.suggestions.find( (s) => s.component === "Label" && s.relationship === "sibling" ); expect(siblingSuggestion).toBeDefined(); expect(siblingSuggestion!.sourceComponent).toBe("TextField"); }); it("should warn about redundant alternatives", () => { const result = analyzeComposition(baseFragments, ["Button", "IconButton"]); const altWarning = result.warnings.find((w) => w.type === "redundant_alternative"); expect(altWarning).toBeDefined(); expect(altWarning!.component).toBe("Button"); expect(altWarning!.relatedComponent).toBe("IconButton"); }); it("should not warn when only one alternative is selected", () => { const result = analyzeComposition(baseFragments, ["Button"]); const altWarning = result.warnings.find((w) => w.type === "redundant_alternative"); expect(altWarning).toBeUndefined(); }); }); describe("usage conflict checks", () => { it("should emit guideline when whenNot mentions another selected component", () => { const result = analyzeComposition(baseFragments, ["Button", "Link"]); const conflict = result.guidelines.find( (g) => g.component === "Link" && g.guideline.includes("Button") ); expect(conflict).toBeDefined(); }); it("should not emit guideline when no conflict exists", () => { const result = analyzeComposition(baseFragments, ["Button", "Alert"]); expect(result.guidelines).toEqual([]); }); }); describe("status warnings", () => { it("should warn about deprecated components", () => { const result = analyzeComposition(baseFragments, ["OldButton"]); const depWarning = result.warnings.find((w) => w.type === "deprecated"); expect(depWarning).toBeDefined(); expect(depWarning!.component).toBe("OldButton"); expect(depWarning!.message).toContain("deprecated"); }); it("should warn about experimental components", () => { const result = analyzeComposition(baseFragments, ["Toast"]); const expWarning = result.warnings.find((w) => w.type === "experimental"); expect(expWarning).toBeDefined(); expect(expWarning!.component).toBe("Toast"); expect(expWarning!.message).toContain("experimental"); }); it("should not warn about stable components", () => { const result = analyzeComposition(baseFragments, ["Button"]); const statusWarnings = result.warnings.filter( (w) => w.type === "deprecated" || w.type === "experimental" ); expect(statusWarnings).toEqual([]); }); }); describe("category gap analysis", () => { it("should suggest feedback component when forms are selected without feedback", () => { const result = analyzeComposition(baseFragments, ["TextField", "Form"]); const gapSuggestion = result.suggestions.find( (s) => s.relationship === "category_gap" ); expect(gapSuggestion).toBeDefined(); expect(gapSuggestion!.component).toBe("Alert"); expect(gapSuggestion!.reason).toContain("feedback"); }); it("should suggest feedback component when actions are selected without feedback", () => { const result = analyzeComposition(baseFragments, ["Button", "ButtonGroup"]); const gapSuggestion = result.suggestions.find( (s) => s.relationship === "category_gap" ); expect(gapSuggestion).toBeDefined(); expect(gapSuggestion!.component).toBe("Alert"); }); it("should not suggest category gap when feedback is already present", () => { const result = analyzeComposition(baseFragments, ["Button", "ButtonGroup", "Alert"]); const gapSuggestion = result.suggestions.find( (s) => s.relationship === "category_gap" ); expect(gapSuggestion).toBeUndefined(); }); it("should prefer stable components over experimental in category gap suggestions", () => { // Alert is stable, Toast is experimental — should pick Alert const result = analyzeComposition(baseFragments, ["TextField", "Form"]); const gapSuggestion = result.suggestions.find( (s) => s.relationship === "category_gap" ); expect(gapSuggestion?.component).toBe("Alert"); }); }); describe("deduplication", () => { it("should not suggest the same component twice", () => { const result = analyzeComposition(baseFragments, ["ButtonGroup", "Button"]); const buttonSuggestions = result.suggestions.filter( (s) => s.component === "Button" ); // Button is already selected so it shouldn't be suggested expect(buttonSuggestions).toEqual([]); }); }); describe("context parameter", () => { it("should accept optional context without error", () => { const result = analyzeComposition(baseFragments, ["Button"], "building a form page"); expect(result.components).toEqual(["Button"]); }); }); });