diff --git a/canvas/src/components/tabs/DetailsTab.tsx b/canvas/src/components/tabs/DetailsTab.tsx index 8d659797..36d57850 100644 --- a/canvas/src/components/tabs/DetailsTab.tsx +++ b/canvas/src/components/tabs/DetailsTab.tsx @@ -402,7 +402,7 @@ function Row({ label, value, mono }: { label: string; value: string; mono?: bool ); } -function getSkills(card: Record | null): { id: string; description?: string }[] { +export function getSkills(card: Record | null): { id: string; description?: string }[] { if (!card) return []; const skills = card.skills; if (!Array.isArray(skills)) return []; diff --git a/canvas/src/components/tabs/SkillsTab.tsx b/canvas/src/components/tabs/SkillsTab.tsx index 563aff58..60097625 100644 --- a/canvas/src/components/tabs/SkillsTab.tsx +++ b/canvas/src/components/tabs/SkillsTab.tsx @@ -647,7 +647,7 @@ export function SkillsTab({ workspaceId, data }: Props) { ); } -function extractSkills(agentCard: Record | null): SkillEntry[] { +export function extractSkills(agentCard: Record | null): SkillEntry[] { if (!agentCard) return []; const rawSkills = agentCard.skills; if (!Array.isArray(rawSkills)) return []; diff --git a/canvas/src/components/tabs/__tests__/extractSkills.test.ts b/canvas/src/components/tabs/__tests__/extractSkills.test.ts new file mode 100644 index 00000000..3e9d203d --- /dev/null +++ b/canvas/src/components/tabs/__tests__/extractSkills.test.ts @@ -0,0 +1,140 @@ +// @vitest-environment jsdom +/** + * Unit tests for extractSkills — pure helper from SkillsTab. + * + * Covers: null card, non-array skills, empty skills, full skill entries + * (id, name, description, tags, examples), id-only fallback, name-only + * fallback, string coercion, array coercion for tags/examples, + * filtering entries with no id after coercion, empty string id (filtered). + */ +import { describe, it, expect } from "vitest"; +import { extractSkills } from "../SkillsTab"; + +describe("extractSkills", () => { + it("returns [] for null card", () => { + expect(extractSkills(null)).toEqual([]); + }); + + it("returns [] when card.skills is not an array", () => { + expect(extractSkills({ skills: undefined })).toEqual([]); + expect(extractSkills({ skills: "not-an-array" })).toEqual([]); + expect(extractSkills({ skills: { id: "x" } })).toEqual([]); + }); + + it("returns [] for empty skills array", () => { + expect(extractSkills({ skills: [] })).toEqual([]); + }); + + it("maps a fully-populated skill entry", () => { + const card = { + skills: [ + { + id: "code_search", + name: "Code Search", + description: "Semantic code search", + tags: ["search", "code"], + examples: ["Find unused exports", "Search by AST pattern"], + }, + ], + }; + expect(extractSkills(card)).toEqual([ + { + id: "code_search", + name: "Code Search", + description: "Semantic code search", + tags: ["search", "code"], + examples: ["Find unused exports", "Search by AST pattern"], + }, + ]); + }); + + it("uses name as id when id is absent", () => { + const card = { skills: [{ name: "web_scraper" }] }; + expect(extractSkills(card)).toEqual([ + { id: "web_scraper", name: "web_scraper", description: "", tags: [], examples: [] }, + ]); + }); + + it("uses id as name when name is absent", () => { + const card = { skills: [{ id: "legacy_skill" }] }; + expect(extractSkills(card)).toEqual([ + { id: "legacy_skill", name: "legacy_skill", description: "", tags: [], examples: [] }, + ]); + }); + + it("filters out entries with neither id nor name", () => { + // id: String(undefined || undefined || "") → "" → filtered (id.length = 0) + const card = { skills: [{ description: "orphan entry" }] }; + expect(extractSkills(card)).toEqual([]); + }); + + it("filters out entries with no id after string coercion", () => { + // id resolves to "" after String(undefined || null || {}) + const card = { skills: [{ id: null, name: null }] }; + expect(extractSkills(card)).toEqual([]); + }); + + it("filters out entries with empty-string id", () => { + const card = { skills: [{ id: "", name: "" }] }; + expect(extractSkills(card)).toEqual([]); + }); + + it("coerces numeric tags to strings", () => { + const card = { skills: [{ id: "x", tags: [1, "two", 3] }] }; + expect(extractSkills(card)).toEqual([ + { id: "x", name: "x", description: "", tags: ["1", "two", "3"], examples: [] }, + ]); + }); + + it("coerces non-array tags to empty array", () => { + const card = { skills: [{ id: "x", tags: "not-an-array" }] }; + expect(extractSkills(card)).toEqual([ + { id: "x", name: "x", description: "", tags: [], examples: [] }, + ]); + }); + + it("coerces non-array examples to empty array", () => { + const card = { skills: [{ id: "x", examples: 42 }] }; + expect(extractSkills(card)).toEqual([ + { id: "x", name: "x", description: "", tags: [], examples: [] }, + ]); + }); + + // NOTE: extractSkills uses `String(skill.description || "")` — falsy values + // (0, null, false) fall through to "", NOT to their string form. + it("returns '' for falsy description values (0, null, false)", () => { + const card = { skills: [{ id: "x", description: 0 }] }; + expect(extractSkills(card)).toEqual([ + { id: "x", name: "x", description: "", tags: [], examples: [] }, + ]); + }); + + it("handles mixed valid/invalid entries", () => { + const card = { + skills: [ + { id: "valid_one", name: "One" }, + { name: "named_only" }, + { description: "orphan" }, // filtered — id becomes "" + { id: "valid_two", examples: ["a", "b"] }, + ], + }; + expect(extractSkills(card)).toEqual([ + { id: "valid_one", name: "One", description: "", tags: [], examples: [] }, + { id: "named_only", name: "named_only", description: "", tags: [], examples: [] }, + { id: "valid_two", name: "valid_two", description: "", tags: [], examples: ["a", "b"] }, + ]); + }); + + it("handles a realistic agent card with multiple skills", () => { + const card = { + skills: [ + { id: "web_search", name: "Web Search", description: "Search the web", tags: ["search"], examples: ["Latest news"] }, + { id: "file_read", name: "Read Files", description: "Read from disk", tags: ["io"], examples: [] }, + ], + }; + const result = extractSkills(card); + expect(result).toHaveLength(2); + expect(result[0].id).toBe("web_search"); + expect(result[1].tags).toEqual(["io"]); + }); +}); diff --git a/canvas/src/components/tabs/__tests__/getSkills.test.ts b/canvas/src/components/tabs/__tests__/getSkills.test.ts new file mode 100644 index 00000000..8b27b2bf --- /dev/null +++ b/canvas/src/components/tabs/__tests__/getSkills.test.ts @@ -0,0 +1,95 @@ +// @vitest-environment jsdom +/** + * Unit tests for getSkills — pure helper from DetailsTab. + * + * Covers: null card, non-array skills, empty skills, id-only entries, + * name-only entries (id derives from name), entries with description, + * entries with neither id nor name (filtered out), mixed entries. + */ +import { describe, it, expect } from "vitest"; +import { getSkills } from "../DetailsTab"; + +describe("getSkills", () => { + it("returns [] for null card", () => { + expect(getSkills(null)).toEqual([]); + }); + + it("returns [] when card.skills is not an array", () => { + expect(getSkills({ skills: undefined })).toEqual([]); + expect(getSkills({ skills: "not-an-array" })).toEqual([]); + expect(getSkills({ skills: { id: "x" } })).toEqual([]); + }); + + it("returns [] for empty skills array", () => { + expect(getSkills({ skills: [] })).toEqual([]); + }); + + it("maps skill with id and description", () => { + const card = { skills: [{ id: "code_search", description: "Find code patterns" }] }; + expect(getSkills(card)).toEqual([{ id: "code_search", description: "Find code patterns" }]); + }); + + it("maps skill with id only (description absent)", () => { + const card = { skills: [{ id: "code_search" }] }; + expect(getSkills(card)).toEqual([{ id: "code_search", description: undefined }]); + }); + + it("derives id from name when id is absent", () => { + const card = { skills: [{ name: "web_scraper" }] }; + expect(getSkills(card)).toEqual([{ id: "web_scraper" }]); + }); + + it("maps description when present", () => { + const card = { skills: [{ id: "file_write", description: "Writes files to disk" }] }; + expect(getSkills(card)).toEqual([{ id: "file_write", description: "Writes files to disk" }]); + }); + + it("returns description as undefined when skill has no description", () => { + const card = { skills: [{ id: "noop_skill" }] }; + const result = getSkills(card); + // The map always includes description; it's undefined when absent + expect(result).toEqual([{ id: "noop_skill", description: undefined }]); + }); + + it("filters out skills with neither id nor name", () => { + // id: String(undefined || undefined || "") → "" → filtered + const card = { skills: [{ description: "loner" }] }; + expect(getSkills(card)).toEqual([]); + }); + + it("handles mixed valid/invalid entries", () => { + const card = { + skills: [ + { id: "valid_one" }, + { name: "named_skill" }, + { description: "orphaned" }, // filtered + { id: "valid_two", description: "Has both" }, + ], + }; + expect(getSkills(card)).toEqual([ + { id: "valid_one", description: undefined }, + { id: "named_skill", description: undefined }, + { id: "valid_two", description: "Has both" }, + ]); + }); + + it("handles string coercion for numeric ids/names", () => { + const card = { skills: [{ id: 42, name: "numeric_id" }] }; + expect(getSkills(card)).toEqual([{ id: "42" }]); + }); + + it("uses id over name when both are present", () => { + const card = { skills: [{ id: "priority_id", name: "fallback_name" }] }; + expect(getSkills(card)).toEqual([{ id: "priority_id", description: undefined }]); + }); + + it("omits description when it is falsy (0 is falsy in JS)", () => { + // The implementation uses `s.description ?` — 0 is falsy, so it's treated + // as absent and undefined is returned. Non-zero numbers coerce fine. + const cardZero = { skills: [{ id: "x", description: 0 }] }; + expect(getSkills(cardZero)).toEqual([{ id: "x", description: undefined }]); + + const cardNum = { skills: [{ id: "x", description: 42 }] }; + expect(getSkills(cardNum)).toEqual([{ id: "x", description: "42" }]); + }); +});