hooks.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  1. import { describe, it, expect } from "vitest";
  2. import { flatToTree, buildSpecFromParts, getTextFromParts } from "./hooks";
  3. describe("flatToTree", () => {
  4. it("converts array of elements to tree structure", () => {
  5. const elements = [
  6. { key: "container", type: "stack", props: {}, parentKey: null },
  7. {
  8. key: "text1",
  9. type: "text",
  10. props: { content: "Hello" },
  11. parentKey: "container",
  12. },
  13. {
  14. key: "text2",
  15. type: "text",
  16. props: { content: "World" },
  17. parentKey: "container",
  18. },
  19. ];
  20. const tree = flatToTree(elements);
  21. expect(tree.root).toBe("container");
  22. expect(Object.keys(tree.elements)).toHaveLength(3);
  23. expect(tree.elements["container"]).toBeDefined();
  24. expect(tree.elements["text1"]).toBeDefined();
  25. expect(tree.elements["text2"]).toBeDefined();
  26. });
  27. it("builds parent-child relationships", () => {
  28. const elements = [
  29. { key: "root", type: "stack", props: {}, parentKey: null },
  30. { key: "child1", type: "text", props: {}, parentKey: "root" },
  31. { key: "child2", type: "text", props: {}, parentKey: "root" },
  32. ];
  33. const tree = flatToTree(elements);
  34. expect(tree.elements["root"].children).toHaveLength(2);
  35. expect(tree.elements["root"].children).toContain("child1");
  36. expect(tree.elements["root"].children).toContain("child2");
  37. });
  38. it("handles single root element", () => {
  39. const elements = [
  40. {
  41. key: "only",
  42. type: "text",
  43. props: { content: "Single" },
  44. parentKey: null,
  45. },
  46. ];
  47. const tree = flatToTree(elements);
  48. expect(tree.root).toBe("only");
  49. expect(Object.keys(tree.elements)).toHaveLength(1);
  50. });
  51. it("handles deeply nested elements", () => {
  52. const elements = [
  53. { key: "level0", type: "stack", props: {}, parentKey: null },
  54. { key: "level1", type: "stack", props: {}, parentKey: "level0" },
  55. { key: "level2", type: "stack", props: {}, parentKey: "level1" },
  56. { key: "level3", type: "text", props: {}, parentKey: "level2" },
  57. ];
  58. const tree = flatToTree(elements);
  59. expect(tree.root).toBe("level0");
  60. expect(tree.elements["level0"].children).toContain("level1");
  61. expect(tree.elements["level1"].children).toContain("level2");
  62. expect(tree.elements["level2"].children).toContain("level3");
  63. });
  64. it("preserves element props", () => {
  65. const elements = [
  66. {
  67. key: "btn",
  68. type: "button",
  69. props: { label: "Click me", variant: "primary" },
  70. parentKey: null,
  71. },
  72. ];
  73. const tree = flatToTree(elements);
  74. expect(tree.elements["btn"].props).toEqual({
  75. label: "Click me",
  76. variant: "primary",
  77. });
  78. });
  79. it("preserves visibility conditions", () => {
  80. const elements = [
  81. {
  82. key: "conditional",
  83. type: "text",
  84. props: {},
  85. parentKey: null,
  86. visible: { $state: "/isVisible" },
  87. },
  88. ];
  89. const tree = flatToTree(elements);
  90. expect(tree.elements["conditional"].visible).toEqual({
  91. $state: "/isVisible",
  92. });
  93. });
  94. it("handles elements with undefined parentKey as root", () => {
  95. const elements = [
  96. { key: "root", type: "stack", props: {} } as {
  97. key: string;
  98. type: string;
  99. props: Record<string, unknown>;
  100. parentKey?: string | null;
  101. },
  102. ];
  103. const tree = flatToTree(elements);
  104. // Elements without parentKey should not become root (only null parentKey)
  105. // This tests the edge case
  106. expect(tree.elements["root"]).toBeDefined();
  107. });
  108. it("handles empty elements array", () => {
  109. const tree = flatToTree([]);
  110. expect(tree.root).toBe("");
  111. expect(Object.keys(tree.elements)).toHaveLength(0);
  112. });
  113. it("handles multiple children correctly", () => {
  114. const elements = [
  115. { key: "parent", type: "grid", props: {}, parentKey: null },
  116. { key: "a", type: "card", props: {}, parentKey: "parent" },
  117. { key: "b", type: "card", props: {}, parentKey: "parent" },
  118. { key: "c", type: "card", props: {}, parentKey: "parent" },
  119. { key: "d", type: "card", props: {}, parentKey: "parent" },
  120. ];
  121. const tree = flatToTree(elements);
  122. expect(tree.elements["parent"].children).toHaveLength(4);
  123. expect(tree.elements["parent"].children).toEqual(["a", "b", "c", "d"]);
  124. });
  125. });
  126. // =============================================================================
  127. // buildSpecFromParts
  128. // =============================================================================
  129. describe("buildSpecFromParts", () => {
  130. it("returns null when no data-spec parts are present", () => {
  131. const parts = [
  132. { type: "text", text: "Hello there" },
  133. { type: "text", text: "How can I help?" },
  134. ];
  135. expect(buildSpecFromParts(parts)).toBeNull();
  136. });
  137. it("builds a spec from patch parts", () => {
  138. const parts = [
  139. {
  140. type: "data-spec",
  141. data: {
  142. type: "patch",
  143. patch: { op: "add", path: "/root", value: "main" },
  144. },
  145. },
  146. {
  147. type: "data-spec",
  148. data: {
  149. type: "patch",
  150. patch: {
  151. op: "add",
  152. path: "/elements/main",
  153. value: { type: "Card", props: { title: "Hello" }, children: [] },
  154. },
  155. },
  156. },
  157. ];
  158. const spec = buildSpecFromParts(parts);
  159. expect(spec).not.toBeNull();
  160. expect(spec!.root).toBe("main");
  161. expect(spec!.elements.main).toEqual({
  162. type: "Card",
  163. props: { title: "Hello" },
  164. children: [],
  165. });
  166. });
  167. it("handles flat spec parts", () => {
  168. const parts = [
  169. {
  170. type: "data-spec",
  171. data: {
  172. type: "flat",
  173. spec: {
  174. root: "card-1",
  175. elements: {
  176. "card-1": { type: "Card", props: {}, children: [] },
  177. },
  178. },
  179. },
  180. },
  181. ];
  182. const spec = buildSpecFromParts(parts);
  183. expect(spec).not.toBeNull();
  184. expect(spec!.root).toBe("card-1");
  185. expect(spec!.elements["card-1"]).toBeDefined();
  186. });
  187. it("ignores non-spec parts", () => {
  188. const parts = [
  189. { type: "text", text: "Some text" },
  190. {
  191. type: "data-spec",
  192. data: {
  193. type: "patch",
  194. patch: { op: "add", path: "/root", value: "main" },
  195. },
  196. },
  197. { type: "tool-invocation", data: { toolName: "search" } },
  198. ];
  199. const spec = buildSpecFromParts(parts);
  200. expect(spec).not.toBeNull();
  201. expect(spec!.root).toBe("main");
  202. });
  203. it("applies patches incrementally", () => {
  204. const parts = [
  205. {
  206. type: "data-spec",
  207. data: {
  208. type: "patch",
  209. patch: { op: "add", path: "/root", value: "main" },
  210. },
  211. },
  212. {
  213. type: "data-spec",
  214. data: {
  215. type: "patch",
  216. patch: {
  217. op: "add",
  218. path: "/elements/main",
  219. value: { type: "Stack", props: {}, children: ["child"] },
  220. },
  221. },
  222. },
  223. {
  224. type: "data-spec",
  225. data: {
  226. type: "patch",
  227. patch: {
  228. op: "add",
  229. path: "/elements/child",
  230. value: { type: "Text", props: { content: "Hi" }, children: [] },
  231. },
  232. },
  233. },
  234. ];
  235. const spec = buildSpecFromParts(parts);
  236. expect(spec).not.toBeNull();
  237. expect(Object.keys(spec!.elements)).toHaveLength(2);
  238. expect(spec!.elements.child!.props.content).toBe("Hi");
  239. });
  240. it("handles nested spec parts via nestedToFlat", () => {
  241. const parts = [
  242. {
  243. type: "data-spec",
  244. data: {
  245. type: "nested",
  246. spec: {
  247. type: "Card",
  248. props: { title: "Nested" },
  249. children: [
  250. { type: "Text", props: { content: "Child" }, children: [] },
  251. ],
  252. },
  253. },
  254. },
  255. ];
  256. const spec = buildSpecFromParts(parts);
  257. expect(spec).not.toBeNull();
  258. expect(spec!.root).toBeTruthy();
  259. // nestedToFlat generates keys like el-0, el-1
  260. const elementKeys = Object.keys(spec!.elements);
  261. expect(elementKeys.length).toBe(2);
  262. const rootEl = spec!.elements[spec!.root];
  263. expect(rootEl).toBeDefined();
  264. expect(rootEl!.type).toBe("Card");
  265. expect(rootEl!.props.title).toBe("Nested");
  266. expect(rootEl!.children).toHaveLength(1);
  267. const childKey = rootEl!.children[0]!;
  268. const childEl = spec!.elements[childKey];
  269. expect(childEl).toBeDefined();
  270. expect(childEl!.type).toBe("Text");
  271. expect(childEl!.props.content).toBe("Child");
  272. });
  273. it("handles mixed patch + flat + nested parts in sequence", () => {
  274. const parts = [
  275. // Start with a patch
  276. {
  277. type: "data-spec",
  278. data: {
  279. type: "patch",
  280. patch: { op: "add", path: "/root", value: "main" },
  281. },
  282. },
  283. {
  284. type: "data-spec",
  285. data: {
  286. type: "patch",
  287. patch: {
  288. op: "add",
  289. path: "/elements/main",
  290. value: { type: "Stack", props: {}, children: [] },
  291. },
  292. },
  293. },
  294. // Then a flat spec overwrites everything
  295. {
  296. type: "data-spec",
  297. data: {
  298. type: "flat",
  299. spec: {
  300. root: "card-1",
  301. elements: {
  302. "card-1": {
  303. type: "Card",
  304. props: { title: "Flat" },
  305. children: [],
  306. },
  307. },
  308. },
  309. },
  310. },
  311. ];
  312. const spec = buildSpecFromParts(parts);
  313. expect(spec).not.toBeNull();
  314. // Flat overwrites root and elements
  315. expect(spec!.root).toBe("card-1");
  316. expect(spec!.elements["card-1"]).toBeDefined();
  317. expect(spec!.elements["card-1"]!.type).toBe("Card");
  318. });
  319. it("returns empty elements map from empty parts list", () => {
  320. const spec = buildSpecFromParts([]);
  321. expect(spec).toBeNull();
  322. });
  323. });
  324. // =============================================================================
  325. // getTextFromParts
  326. // =============================================================================
  327. describe("getTextFromParts", () => {
  328. it("extracts text from text parts", () => {
  329. const parts = [
  330. { type: "text", text: "Hello" },
  331. { type: "text", text: "World" },
  332. ];
  333. expect(getTextFromParts(parts)).toBe("Hello\n\nWorld");
  334. });
  335. it("returns empty string when no text parts", () => {
  336. const parts = [
  337. {
  338. type: "data-spec",
  339. data: {
  340. type: "patch",
  341. patch: { op: "add", path: "/root", value: "x" },
  342. },
  343. },
  344. ];
  345. expect(getTextFromParts(parts)).toBe("");
  346. });
  347. it("ignores non-text parts", () => {
  348. const parts = [
  349. { type: "text", text: "Before" },
  350. { type: "data-spec", data: {} },
  351. { type: "tool-invocation", data: {} },
  352. { type: "text", text: "After" },
  353. ];
  354. expect(getTextFromParts(parts)).toBe("Before\n\nAfter");
  355. });
  356. it("trims whitespace from text parts", () => {
  357. const parts = [
  358. { type: "text", text: " Hello " },
  359. { type: "text", text: " World " },
  360. ];
  361. expect(getTextFromParts(parts)).toBe("Hello\n\nWorld");
  362. });
  363. it("skips empty text parts", () => {
  364. const parts = [
  365. { type: "text", text: "Hello" },
  366. { type: "text", text: " " },
  367. { type: "text", text: "World" },
  368. ];
  369. expect(getTextFromParts(parts)).toBe("Hello\n\nWorld");
  370. });
  371. it("ignores text parts with non-string text field", () => {
  372. const parts = [
  373. { type: "text", text: "Valid" },
  374. { type: "text", text: undefined as unknown as string },
  375. { type: "text", text: 42 as unknown as string },
  376. { type: "text", text: "Also valid" },
  377. ];
  378. expect(getTextFromParts(parts)).toBe("Valid\n\nAlso valid");
  379. });
  380. });