spec-validator.test.ts 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. import { describe, it, expect } from "vitest";
  2. import type { Spec } from "./types";
  3. import { validateSpec, autoFixSpec } from "./spec-validator";
  4. // =============================================================================
  5. // validateSpec
  6. // =============================================================================
  7. describe("validateSpec", () => {
  8. it("returns valid for a correct spec", () => {
  9. const spec: Spec = {
  10. root: "root",
  11. elements: {
  12. root: { type: "Stack", props: {}, children: ["child1"] },
  13. child1: { type: "Text", props: { text: "hello" }, children: [] },
  14. },
  15. };
  16. const result = validateSpec(spec);
  17. expect(result.valid).toBe(true);
  18. expect(result.issues).toHaveLength(0);
  19. });
  20. it("detects missing root", () => {
  21. const spec = {
  22. root: "",
  23. elements: { a: { type: "T", props: {}, children: [] } },
  24. } as Spec;
  25. const result = validateSpec(spec);
  26. expect(result.valid).toBe(false);
  27. expect(result.issues.some((i) => i.code === "missing_root")).toBe(true);
  28. });
  29. it("detects root_not_found", () => {
  30. const spec: Spec = {
  31. root: "missing",
  32. elements: { a: { type: "T", props: {}, children: [] } },
  33. };
  34. const result = validateSpec(spec);
  35. expect(result.valid).toBe(false);
  36. expect(result.issues.some((i) => i.code === "root_not_found")).toBe(true);
  37. });
  38. it("detects empty spec", () => {
  39. const spec: Spec = { root: "r", elements: {} };
  40. const result = validateSpec(spec);
  41. expect(result.valid).toBe(false);
  42. expect(result.issues.some((i) => i.code === "empty_spec")).toBe(true);
  43. });
  44. it("detects missing_child", () => {
  45. const spec: Spec = {
  46. root: "root",
  47. elements: {
  48. root: { type: "Stack", props: {}, children: ["nonexistent"] },
  49. },
  50. };
  51. const result = validateSpec(spec);
  52. expect(result.valid).toBe(false);
  53. expect(result.issues.some((i) => i.code === "missing_child")).toBe(true);
  54. });
  55. it("detects visible_in_props", () => {
  56. const spec: Spec = {
  57. root: "root",
  58. elements: {
  59. root: {
  60. type: "Text",
  61. props: { visible: { $state: "/show" } },
  62. children: [],
  63. },
  64. },
  65. };
  66. const result = validateSpec(spec);
  67. expect(result.valid).toBe(false);
  68. expect(result.issues.some((i) => i.code === "visible_in_props")).toBe(true);
  69. });
  70. it("detects on_in_props", () => {
  71. const spec: Spec = {
  72. root: "root",
  73. elements: {
  74. root: {
  75. type: "Button",
  76. props: { on: { press: { action: "doSomething" } } },
  77. children: [],
  78. },
  79. },
  80. };
  81. const result = validateSpec(spec);
  82. expect(result.valid).toBe(false);
  83. expect(result.issues.some((i) => i.code === "on_in_props")).toBe(true);
  84. });
  85. it("detects repeat_in_props", () => {
  86. const spec: Spec = {
  87. root: "root",
  88. elements: {
  89. root: {
  90. type: "Stack",
  91. props: { repeat: { statePath: "/items" } },
  92. children: [],
  93. },
  94. },
  95. };
  96. const result = validateSpec(spec);
  97. expect(result.valid).toBe(false);
  98. expect(result.issues.some((i) => i.code === "repeat_in_props")).toBe(true);
  99. });
  100. it("detects watch_in_props", () => {
  101. const spec: Spec = {
  102. root: "root",
  103. elements: {
  104. root: {
  105. type: "Select",
  106. props: {
  107. watch: {
  108. "/form/country": { action: "loadCities" },
  109. },
  110. },
  111. children: [],
  112. },
  113. },
  114. };
  115. const result = validateSpec(spec);
  116. expect(result.valid).toBe(false);
  117. const watchIssue = result.issues.find((i) => i.code === "watch_in_props");
  118. expect(watchIssue).toBeDefined();
  119. expect(watchIssue!.elementKey).toBe("root");
  120. });
  121. it("detects orphaned elements when checkOrphans is true", () => {
  122. const spec: Spec = {
  123. root: "root",
  124. elements: {
  125. root: { type: "Stack", props: {}, children: [] },
  126. orphan: { type: "Text", props: {}, children: [] },
  127. },
  128. };
  129. const result = validateSpec(spec, { checkOrphans: true });
  130. expect(result.valid).toBe(true);
  131. expect(result.issues.some((i) => i.code === "orphaned_element")).toBe(true);
  132. });
  133. });
  134. // =============================================================================
  135. // autoFixSpec
  136. // =============================================================================
  137. describe("autoFixSpec", () => {
  138. it("moves visible from props to element level", () => {
  139. const spec: Spec = {
  140. root: "root",
  141. elements: {
  142. root: {
  143. type: "Text",
  144. props: { text: "hi", visible: { $state: "/show" } },
  145. children: [],
  146. },
  147. },
  148. };
  149. const { spec: fixed, fixes } = autoFixSpec(spec);
  150. expect(
  151. (fixed.elements.root.props as Record<string, unknown>).visible,
  152. ).toBeUndefined();
  153. expect(fixed.elements.root.visible).toEqual({ $state: "/show" });
  154. expect(fixes.some((f) => f.includes("visible"))).toBe(true);
  155. });
  156. it("moves on from props to element level", () => {
  157. const spec: Spec = {
  158. root: "root",
  159. elements: {
  160. root: {
  161. type: "Button",
  162. props: { label: "OK", on: { press: { action: "submit" } } },
  163. children: [],
  164. },
  165. },
  166. };
  167. const { spec: fixed, fixes } = autoFixSpec(spec);
  168. expect(
  169. (fixed.elements.root.props as Record<string, unknown>).on,
  170. ).toBeUndefined();
  171. expect(fixed.elements.root.on).toEqual({ press: { action: "submit" } });
  172. expect(fixes.some((f) => f.includes('"on"'))).toBe(true);
  173. });
  174. it("moves repeat from props to element level", () => {
  175. const spec: Spec = {
  176. root: "root",
  177. elements: {
  178. root: {
  179. type: "Stack",
  180. props: { repeat: { statePath: "/items" } },
  181. children: ["child"],
  182. },
  183. child: { type: "Text", props: {}, children: [] },
  184. },
  185. };
  186. const { spec: fixed, fixes } = autoFixSpec(spec);
  187. expect(
  188. (fixed.elements.root.props as Record<string, unknown>).repeat,
  189. ).toBeUndefined();
  190. expect(fixed.elements.root.repeat).toEqual({ statePath: "/items" });
  191. expect(fixes.some((f) => f.includes('"repeat"'))).toBe(true);
  192. });
  193. it("moves watch from props to element level", () => {
  194. const spec: Spec = {
  195. root: "root",
  196. elements: {
  197. root: {
  198. type: "Select",
  199. props: {
  200. label: "Country",
  201. watch: {
  202. "/form/country": { action: "loadCities" },
  203. },
  204. },
  205. children: [],
  206. },
  207. },
  208. };
  209. const { spec: fixed, fixes } = autoFixSpec(spec);
  210. expect(
  211. (fixed.elements.root.props as Record<string, unknown>).watch,
  212. ).toBeUndefined();
  213. expect(fixed.elements.root.watch).toEqual({
  214. "/form/country": { action: "loadCities" },
  215. });
  216. expect(fixes.some((f) => f.includes('"watch"'))).toBe(true);
  217. });
  218. it("returns no fixes for a correct spec", () => {
  219. const spec: Spec = {
  220. root: "root",
  221. elements: {
  222. root: {
  223. type: "Stack",
  224. props: { direction: "vertical" },
  225. children: [],
  226. watch: { "/x": { action: "y" } },
  227. },
  228. },
  229. };
  230. const { fixes } = autoFixSpec(spec);
  231. expect(fixes).toHaveLength(0);
  232. });
  233. });