| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430 |
- import { describe, it, expect } from "vitest";
- import { flatToTree, buildSpecFromParts, getTextFromParts } from "./hooks";
- describe("flatToTree", () => {
- it("converts array of elements to tree structure", () => {
- const elements = [
- { key: "container", type: "stack", props: {}, parentKey: null },
- {
- key: "text1",
- type: "text",
- props: { content: "Hello" },
- parentKey: "container",
- },
- {
- key: "text2",
- type: "text",
- props: { content: "World" },
- parentKey: "container",
- },
- ];
- const tree = flatToTree(elements);
- expect(tree.root).toBe("container");
- expect(Object.keys(tree.elements)).toHaveLength(3);
- expect(tree.elements["container"]).toBeDefined();
- expect(tree.elements["text1"]).toBeDefined();
- expect(tree.elements["text2"]).toBeDefined();
- });
- it("builds parent-child relationships", () => {
- const elements = [
- { key: "root", type: "stack", props: {}, parentKey: null },
- { key: "child1", type: "text", props: {}, parentKey: "root" },
- { key: "child2", type: "text", props: {}, parentKey: "root" },
- ];
- const tree = flatToTree(elements);
- expect(tree.elements["root"]?.children).toHaveLength(2);
- expect(tree.elements["root"]?.children).toContain("child1");
- expect(tree.elements["root"]?.children).toContain("child2");
- });
- it("handles single root element", () => {
- const elements = [
- {
- key: "only",
- type: "text",
- props: { content: "Single" },
- parentKey: null,
- },
- ];
- const tree = flatToTree(elements);
- expect(tree.root).toBe("only");
- expect(Object.keys(tree.elements)).toHaveLength(1);
- });
- it("handles deeply nested elements", () => {
- const elements = [
- { key: "level0", type: "stack", props: {}, parentKey: null },
- { key: "level1", type: "stack", props: {}, parentKey: "level0" },
- { key: "level2", type: "stack", props: {}, parentKey: "level1" },
- { key: "level3", type: "text", props: {}, parentKey: "level2" },
- ];
- const tree = flatToTree(elements);
- expect(tree.root).toBe("level0");
- expect(tree.elements["level0"]?.children).toContain("level1");
- expect(tree.elements["level1"]?.children).toContain("level2");
- expect(tree.elements["level2"]?.children).toContain("level3");
- });
- it("preserves element props", () => {
- const elements = [
- {
- key: "btn",
- type: "button",
- props: { label: "Click me", variant: "primary" },
- parentKey: null,
- },
- ];
- const tree = flatToTree(elements);
- expect(tree.elements["btn"]?.props).toEqual({
- label: "Click me",
- variant: "primary",
- });
- });
- it("preserves visibility conditions", () => {
- const elements = [
- {
- key: "conditional",
- type: "text",
- props: {},
- parentKey: null,
- visible: { $state: "/isVisible" },
- },
- ];
- const tree = flatToTree(elements);
- expect(tree.elements["conditional"]?.visible).toEqual({
- $state: "/isVisible",
- });
- });
- it("handles elements with undefined parentKey as root", () => {
- const elements = [
- { key: "root", type: "stack", props: {} } as {
- key: string;
- type: string;
- props: Record<string, unknown>;
- parentKey?: string | null;
- },
- ];
- const tree = flatToTree(elements);
- // Elements without parentKey should not become root (only null parentKey)
- // This tests the edge case
- expect(tree.elements["root"]).toBeDefined();
- });
- it("handles empty elements array", () => {
- const tree = flatToTree([]);
- expect(tree.root).toBe("");
- expect(Object.keys(tree.elements)).toHaveLength(0);
- });
- it("handles multiple children correctly", () => {
- const elements = [
- { key: "parent", type: "grid", props: {}, parentKey: null },
- { key: "a", type: "card", props: {}, parentKey: "parent" },
- { key: "b", type: "card", props: {}, parentKey: "parent" },
- { key: "c", type: "card", props: {}, parentKey: "parent" },
- { key: "d", type: "card", props: {}, parentKey: "parent" },
- ];
- const tree = flatToTree(elements);
- expect(tree.elements["parent"]?.children).toHaveLength(4);
- expect(tree.elements["parent"]?.children).toEqual(["a", "b", "c", "d"]);
- });
- });
- // =============================================================================
- // buildSpecFromParts
- // =============================================================================
- describe("buildSpecFromParts", () => {
- it("returns null when no data-spec parts are present", () => {
- const parts = [
- { type: "text", text: "Hello there" },
- { type: "text", text: "How can I help?" },
- ];
- expect(buildSpecFromParts(parts)).toBeNull();
- });
- it("builds a spec from patch parts", () => {
- const parts = [
- {
- type: "data-spec",
- data: {
- type: "patch",
- patch: { op: "add", path: "/root", value: "main" },
- },
- },
- {
- type: "data-spec",
- data: {
- type: "patch",
- patch: {
- op: "add",
- path: "/elements/main",
- value: { type: "Card", props: { title: "Hello" }, children: [] },
- },
- },
- },
- ];
- const spec = buildSpecFromParts(parts);
- expect(spec).not.toBeNull();
- expect(spec!.root).toBe("main");
- expect(spec!.elements.main).toEqual({
- type: "Card",
- props: { title: "Hello" },
- children: [],
- });
- });
- it("handles flat spec parts", () => {
- const parts = [
- {
- type: "data-spec",
- data: {
- type: "flat",
- spec: {
- root: "card-1",
- elements: {
- "card-1": { type: "Card", props: {}, children: [] },
- },
- },
- },
- },
- ];
- const spec = buildSpecFromParts(parts);
- expect(spec).not.toBeNull();
- expect(spec!.root).toBe("card-1");
- expect(spec!.elements["card-1"]).toBeDefined();
- });
- it("ignores non-spec parts", () => {
- const parts = [
- { type: "text", text: "Some text" },
- {
- type: "data-spec",
- data: {
- type: "patch",
- patch: { op: "add", path: "/root", value: "main" },
- },
- },
- { type: "tool-invocation", data: { toolName: "search" } },
- ];
- const spec = buildSpecFromParts(parts);
- expect(spec).not.toBeNull();
- expect(spec!.root).toBe("main");
- });
- it("applies patches incrementally", () => {
- const parts = [
- {
- type: "data-spec",
- data: {
- type: "patch",
- patch: { op: "add", path: "/root", value: "main" },
- },
- },
- {
- type: "data-spec",
- data: {
- type: "patch",
- patch: {
- op: "add",
- path: "/elements/main",
- value: { type: "Stack", props: {}, children: ["child"] },
- },
- },
- },
- {
- type: "data-spec",
- data: {
- type: "patch",
- patch: {
- op: "add",
- path: "/elements/child",
- value: { type: "Text", props: { content: "Hi" }, children: [] },
- },
- },
- },
- ];
- const spec = buildSpecFromParts(parts);
- expect(spec).not.toBeNull();
- expect(Object.keys(spec!.elements)).toHaveLength(2);
- expect(spec!.elements.child!.props.content).toBe("Hi");
- });
- it("handles nested spec parts via nestedToFlat", () => {
- const parts = [
- {
- type: "data-spec",
- data: {
- type: "nested",
- spec: {
- type: "Card",
- props: { title: "Nested" },
- children: [
- { type: "Text", props: { content: "Child" }, children: [] },
- ],
- },
- },
- },
- ];
- const spec = buildSpecFromParts(parts);
- expect(spec).not.toBeNull();
- expect(spec!.root).toBeTruthy();
- // nestedToFlat generates keys like el-0, el-1
- const elementKeys = Object.keys(spec!.elements);
- expect(elementKeys.length).toBe(2);
- const rootEl = spec?.elements[spec.root];
- expect(rootEl).toBeDefined();
- expect(rootEl?.type).toBe("Card");
- expect(rootEl?.props.title).toBe("Nested");
- expect(rootEl?.children).toHaveLength(1);
- const childKey = rootEl?.children?.[0];
- const childEl = childKey ? spec?.elements[childKey] : undefined;
- expect(childEl).toBeDefined();
- expect(childEl?.type).toBe("Text");
- expect(childEl?.props.content).toBe("Child");
- });
- it("handles mixed patch + flat + nested parts in sequence", () => {
- const parts = [
- // Start with a patch
- {
- type: "data-spec",
- data: {
- type: "patch",
- patch: { op: "add", path: "/root", value: "main" },
- },
- },
- {
- type: "data-spec",
- data: {
- type: "patch",
- patch: {
- op: "add",
- path: "/elements/main",
- value: { type: "Stack", props: {}, children: [] },
- },
- },
- },
- // Then a flat spec overwrites everything
- {
- type: "data-spec",
- data: {
- type: "flat",
- spec: {
- root: "card-1",
- elements: {
- "card-1": {
- type: "Card",
- props: { title: "Flat" },
- children: [],
- },
- },
- },
- },
- },
- ];
- const spec = buildSpecFromParts(parts);
- expect(spec).not.toBeNull();
- // Flat overwrites root and elements
- expect(spec!.root).toBe("card-1");
- expect(spec!.elements["card-1"]).toBeDefined();
- expect(spec!.elements["card-1"]!.type).toBe("Card");
- });
- it("returns empty elements map from empty parts list", () => {
- const spec = buildSpecFromParts([]);
- expect(spec).toBeNull();
- });
- });
- // =============================================================================
- // getTextFromParts
- // =============================================================================
- describe("getTextFromParts", () => {
- it("extracts text from text parts", () => {
- const parts = [
- { type: "text", text: "Hello" },
- { type: "text", text: "World" },
- ];
- expect(getTextFromParts(parts)).toBe("Hello\n\nWorld");
- });
- it("returns empty string when no text parts", () => {
- const parts = [
- {
- type: "data-spec",
- data: {
- type: "patch",
- patch: { op: "add", path: "/root", value: "x" },
- },
- },
- ];
- expect(getTextFromParts(parts)).toBe("");
- });
- it("ignores non-text parts", () => {
- const parts = [
- { type: "text", text: "Before" },
- { type: "data-spec", data: {} },
- { type: "tool-invocation", data: {} },
- { type: "text", text: "After" },
- ];
- expect(getTextFromParts(parts)).toBe("Before\n\nAfter");
- });
- it("trims whitespace from text parts", () => {
- const parts = [
- { type: "text", text: " Hello " },
- { type: "text", text: " World " },
- ];
- expect(getTextFromParts(parts)).toBe("Hello\n\nWorld");
- });
- it("skips empty text parts", () => {
- const parts = [
- { type: "text", text: "Hello" },
- { type: "text", text: " " },
- { type: "text", text: "World" },
- ];
- expect(getTextFromParts(parts)).toBe("Hello\n\nWorld");
- });
- it("ignores text parts with non-string text field", () => {
- const parts = [
- { type: "text", text: "Valid" },
- { type: "text", text: undefined as unknown as string },
- { type: "text", text: 42 as unknown as string },
- { type: "text", text: "Also valid" },
- ];
- expect(getTextFromParts(parts)).toBe("Valid\n\nAlso valid");
- });
- });
|