props.test.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664
  1. import { describe, it, expect, vi } from "vitest";
  2. import {
  3. resolvePropValue,
  4. resolveElementProps,
  5. resolveBindings,
  6. resolveActionParam,
  7. _resetWarnedComputedFns,
  8. _resetWarnedTemplatePaths,
  9. } from "./props";
  10. import type { PropResolutionContext } from "./props";
  11. // =============================================================================
  12. // resolvePropValue
  13. // =============================================================================
  14. describe("resolvePropValue", () => {
  15. describe("literals", () => {
  16. it("passes through strings", () => {
  17. const ctx: PropResolutionContext = { stateModel: {} };
  18. expect(resolvePropValue("hello", ctx)).toBe("hello");
  19. });
  20. it("passes through numbers", () => {
  21. const ctx: PropResolutionContext = { stateModel: {} };
  22. expect(resolvePropValue(42, ctx)).toBe(42);
  23. });
  24. it("passes through booleans", () => {
  25. const ctx: PropResolutionContext = { stateModel: {} };
  26. expect(resolvePropValue(true, ctx)).toBe(true);
  27. expect(resolvePropValue(false, ctx)).toBe(false);
  28. });
  29. it("passes through null", () => {
  30. const ctx: PropResolutionContext = { stateModel: {} };
  31. expect(resolvePropValue(null, ctx)).toBeNull();
  32. });
  33. it("passes through undefined", () => {
  34. const ctx: PropResolutionContext = { stateModel: {} };
  35. expect(resolvePropValue(undefined, ctx)).toBeUndefined();
  36. });
  37. });
  38. describe("$state expressions", () => {
  39. it("resolves a state path", () => {
  40. const ctx: PropResolutionContext = {
  41. stateModel: { user: { name: "Alice" } },
  42. };
  43. expect(resolvePropValue({ $state: "/user/name" }, ctx)).toBe("Alice");
  44. });
  45. it("returns undefined for missing state path", () => {
  46. const ctx: PropResolutionContext = { stateModel: {} };
  47. expect(resolvePropValue({ $state: "/missing" }, ctx)).toBeUndefined();
  48. });
  49. it("resolves nested state path", () => {
  50. const ctx: PropResolutionContext = {
  51. stateModel: { a: { b: { c: 42 } } },
  52. };
  53. expect(resolvePropValue({ $state: "/a/b/c" }, ctx)).toBe(42);
  54. });
  55. });
  56. describe("$item expressions", () => {
  57. it("resolves a field from the repeat item", () => {
  58. const ctx: PropResolutionContext = {
  59. stateModel: {},
  60. repeatItem: { title: "Hello", id: "1" },
  61. repeatIndex: 0,
  62. };
  63. expect(resolvePropValue({ $item: "title" }, ctx)).toBe("Hello");
  64. });
  65. it('resolves "/" to the whole item', () => {
  66. const item = { title: "Hello", id: "1" };
  67. const ctx: PropResolutionContext = {
  68. stateModel: {},
  69. repeatItem: item,
  70. repeatIndex: 0,
  71. };
  72. expect(resolvePropValue({ $item: "" }, ctx)).toBe(item);
  73. });
  74. it("resolves nested field from item", () => {
  75. const ctx: PropResolutionContext = {
  76. stateModel: {},
  77. repeatItem: { user: { name: "Bob" } },
  78. repeatIndex: 0,
  79. };
  80. expect(resolvePropValue({ $item: "user/name" }, ctx)).toBe("Bob");
  81. });
  82. it("returns undefined when no repeat item in context", () => {
  83. const ctx: PropResolutionContext = { stateModel: {} };
  84. expect(resolvePropValue({ $item: "title" }, ctx)).toBeUndefined();
  85. });
  86. it("returns undefined for missing field on item", () => {
  87. const ctx: PropResolutionContext = {
  88. stateModel: {},
  89. repeatItem: { title: "Hello" },
  90. repeatIndex: 0,
  91. };
  92. expect(resolvePropValue({ $item: "missing" }, ctx)).toBeUndefined();
  93. });
  94. });
  95. describe("$index expressions", () => {
  96. it("returns the current repeat index", () => {
  97. const ctx: PropResolutionContext = {
  98. stateModel: {},
  99. repeatItem: { id: "1" },
  100. repeatIndex: 3,
  101. };
  102. expect(resolvePropValue({ $index: true }, ctx)).toBe(3);
  103. });
  104. it("returns 0 for first item", () => {
  105. const ctx: PropResolutionContext = {
  106. stateModel: {},
  107. repeatItem: { id: "1" },
  108. repeatIndex: 0,
  109. };
  110. expect(resolvePropValue({ $index: true }, ctx)).toBe(0);
  111. });
  112. it("returns undefined when no repeat index in context", () => {
  113. const ctx: PropResolutionContext = { stateModel: {} };
  114. expect(resolvePropValue({ $index: true }, ctx)).toBeUndefined();
  115. });
  116. });
  117. describe("$cond/$then/$else expressions", () => {
  118. it("returns $then when condition is true", () => {
  119. const ctx: PropResolutionContext = {
  120. stateModel: { active: true },
  121. };
  122. expect(
  123. resolvePropValue(
  124. { $cond: { $state: "/active" }, $then: "blue", $else: "gray" },
  125. ctx,
  126. ),
  127. ).toBe("blue");
  128. });
  129. it("returns $else when condition is false", () => {
  130. const ctx: PropResolutionContext = {
  131. stateModel: { active: false },
  132. };
  133. expect(
  134. resolvePropValue(
  135. { $cond: { $state: "/active" }, $then: "blue", $else: "gray" },
  136. ctx,
  137. ),
  138. ).toBe("gray");
  139. });
  140. it("handles eq condition", () => {
  141. const ctx: PropResolutionContext = {
  142. stateModel: { tab: "home" },
  143. };
  144. expect(
  145. resolvePropValue(
  146. {
  147. $cond: { $state: "/tab", eq: "home" },
  148. $then: "#007AFF",
  149. $else: "#8E8E93",
  150. },
  151. ctx,
  152. ),
  153. ).toBe("#007AFF");
  154. });
  155. it("handles nested expression in $then/$else", () => {
  156. const ctx: PropResolutionContext = {
  157. stateModel: { isAdmin: true, admin: { greeting: "Hello Admin" } },
  158. };
  159. expect(
  160. resolvePropValue(
  161. {
  162. $cond: { $state: "/isAdmin" },
  163. $then: { $state: "/admin/greeting" },
  164. $else: "Welcome",
  165. },
  166. ctx,
  167. ),
  168. ).toBe("Hello Admin");
  169. });
  170. it("handles array condition (implicit AND)", () => {
  171. const ctx: PropResolutionContext = {
  172. stateModel: { isAdmin: true, feature: true },
  173. };
  174. expect(
  175. resolvePropValue(
  176. {
  177. $cond: [{ $state: "/isAdmin" }, { $state: "/feature" }],
  178. $then: "yes",
  179. $else: "no",
  180. },
  181. ctx,
  182. ),
  183. ).toBe("yes");
  184. });
  185. });
  186. describe("nested objects and arrays", () => {
  187. it("resolves expressions inside plain objects", () => {
  188. const ctx: PropResolutionContext = {
  189. stateModel: { color: "red", size: 12 },
  190. };
  191. const result = resolvePropValue(
  192. { fill: { $state: "/color" }, fontSize: { $state: "/size" } },
  193. ctx,
  194. );
  195. expect(result).toEqual({ fill: "red", fontSize: 12 });
  196. });
  197. it("resolves expressions inside arrays", () => {
  198. const ctx: PropResolutionContext = {
  199. stateModel: { a: 1, b: 2 },
  200. };
  201. const result = resolvePropValue(
  202. [{ $state: "/a" }, { $state: "/b" }, 3],
  203. ctx,
  204. );
  205. expect(result).toEqual([1, 2, 3]);
  206. });
  207. it("resolves deeply nested expressions", () => {
  208. const ctx: PropResolutionContext = {
  209. stateModel: { theme: { primary: "#007AFF" } },
  210. };
  211. const result = resolvePropValue(
  212. { style: { color: { $state: "/theme/primary" }, margin: 10 } },
  213. ctx,
  214. );
  215. expect(result).toEqual({ style: { color: "#007AFF", margin: 10 } });
  216. });
  217. });
  218. });
  219. // =============================================================================
  220. // resolveElementProps
  221. // =============================================================================
  222. describe("resolveElementProps", () => {
  223. it("resolves all props in an element", () => {
  224. const ctx: PropResolutionContext = {
  225. stateModel: { user: { name: "Alice", role: "admin" } },
  226. };
  227. const props = {
  228. label: { $state: "/user/name" },
  229. badge: { $state: "/user/role" },
  230. static: "always",
  231. };
  232. expect(resolveElementProps(props, ctx)).toEqual({
  233. label: "Alice",
  234. badge: "admin",
  235. static: "always",
  236. });
  237. });
  238. it("resolves mixed expressions and literals", () => {
  239. const ctx: PropResolutionContext = {
  240. stateModel: { active: true },
  241. repeatItem: { title: "Item 1" },
  242. repeatIndex: 2,
  243. };
  244. const props = {
  245. title: { $item: "title" },
  246. index: { $index: true },
  247. color: {
  248. $cond: { $state: "/active" },
  249. $then: "green",
  250. $else: "gray",
  251. },
  252. width: 100,
  253. };
  254. expect(resolveElementProps(props, ctx)).toEqual({
  255. title: "Item 1",
  256. index: 2,
  257. color: "green",
  258. width: 100,
  259. });
  260. });
  261. it("returns empty object for empty props", () => {
  262. const ctx: PropResolutionContext = { stateModel: {} };
  263. expect(resolveElementProps({}, ctx)).toEqual({});
  264. });
  265. });
  266. // =============================================================================
  267. // $bindState / $bindItem expressions
  268. // =============================================================================
  269. describe("$bindState expressions", () => {
  270. describe("resolvePropValue with $bindState", () => {
  271. it("resolves to the state value at the path", () => {
  272. const ctx: PropResolutionContext = {
  273. stateModel: { form: { email: "alice@example.com" } },
  274. };
  275. expect(resolvePropValue({ $bindState: "/form/email" }, ctx)).toBe(
  276. "alice@example.com",
  277. );
  278. });
  279. it("returns undefined for missing path", () => {
  280. const ctx: PropResolutionContext = { stateModel: {} };
  281. expect(resolvePropValue({ $bindState: "/missing" }, ctx)).toBeUndefined();
  282. });
  283. });
  284. describe("resolvePropValue with $bindItem", () => {
  285. it("resolves item field using repeatBasePath", () => {
  286. const ctx: PropResolutionContext = {
  287. stateModel: { todos: [{ completed: true }, { completed: false }] },
  288. repeatItem: { completed: true },
  289. repeatIndex: 0,
  290. repeatBasePath: "/todos/0",
  291. };
  292. expect(resolvePropValue({ $bindItem: "completed" }, ctx)).toBe(true);
  293. });
  294. it('handles "/" as the full item path', () => {
  295. const ctx: PropResolutionContext = {
  296. stateModel: { items: ["hello", "world"] },
  297. repeatItem: "hello",
  298. repeatIndex: 0,
  299. repeatBasePath: "/items/0",
  300. };
  301. expect(resolvePropValue({ $bindItem: "" }, ctx)).toBe("hello");
  302. });
  303. it("returns undefined when no repeatBasePath", () => {
  304. const ctx: PropResolutionContext = {
  305. stateModel: {},
  306. repeatItem: { completed: true },
  307. repeatIndex: 0,
  308. };
  309. // Without repeatBasePath, the raw item path won't resolve in stateModel
  310. expect(resolvePropValue({ $bindItem: "completed" }, ctx)).toBeUndefined();
  311. });
  312. });
  313. describe("resolveBindings", () => {
  314. it("extracts $bindState paths from props", () => {
  315. const ctx: PropResolutionContext = { stateModel: {} };
  316. const props = {
  317. value: { $bindState: "/form/email" },
  318. label: "Email",
  319. placeholder: "Enter email",
  320. };
  321. expect(resolveBindings(props, ctx)).toEqual({
  322. value: "/form/email",
  323. });
  324. });
  325. it("returns undefined when no bind expressions", () => {
  326. const ctx: PropResolutionContext = { stateModel: {} };
  327. const props = {
  328. label: "Hello",
  329. count: 42,
  330. };
  331. expect(resolveBindings(props, ctx)).toBeUndefined();
  332. });
  333. it("handles multiple $bindState props", () => {
  334. const ctx: PropResolutionContext = { stateModel: {} };
  335. const props = {
  336. value: { $bindState: "/form/name" },
  337. checked: { $bindState: "/form/agree" },
  338. label: "Name",
  339. };
  340. expect(resolveBindings(props, ctx)).toEqual({
  341. value: "/form/name",
  342. checked: "/form/agree",
  343. });
  344. });
  345. it("resolves $bindItem paths using repeatBasePath", () => {
  346. const ctx: PropResolutionContext = {
  347. stateModel: {},
  348. repeatItem: { completed: false },
  349. repeatIndex: 1,
  350. repeatBasePath: "/todos/1",
  351. };
  352. const props = {
  353. checked: { $bindItem: "completed" },
  354. label: { $item: "title" },
  355. };
  356. expect(resolveBindings(props, ctx)).toEqual({
  357. checked: "/todos/1/completed",
  358. });
  359. });
  360. it("ignores non-bind dynamic expressions", () => {
  361. const ctx: PropResolutionContext = { stateModel: {} };
  362. const props = {
  363. title: { $state: "/title" },
  364. index: { $index: true },
  365. name: { $item: "name" },
  366. value: { $bindState: "/path" },
  367. };
  368. expect(resolveBindings(props, ctx)).toEqual({
  369. value: "/path",
  370. });
  371. });
  372. it("handles mixed $bindState and $bindItem props", () => {
  373. const ctx: PropResolutionContext = {
  374. stateModel: {},
  375. repeatItem: { done: false },
  376. repeatIndex: 0,
  377. repeatBasePath: "/todos/0",
  378. };
  379. const props = {
  380. value: { $bindState: "/form/search" },
  381. checked: { $bindItem: "done" },
  382. label: "Task",
  383. };
  384. expect(resolveBindings(props, ctx)).toEqual({
  385. value: "/form/search",
  386. checked: "/todos/0/done",
  387. });
  388. });
  389. });
  390. });
  391. // =============================================================================
  392. // resolveActionParam
  393. // =============================================================================
  394. describe("resolveActionParam", () => {
  395. it("resolves $item to an absolute state path via repeatBasePath", () => {
  396. const ctx: PropResolutionContext = {
  397. stateModel: { todos: [{ title: "Buy milk" }] },
  398. repeatItem: { title: "Buy milk" },
  399. repeatIndex: 0,
  400. repeatBasePath: "/todos/0",
  401. };
  402. expect(resolveActionParam({ $item: "title" }, ctx)).toBe("/todos/0/title");
  403. });
  404. it("resolves $item with empty string to the repeatBasePath itself", () => {
  405. const ctx: PropResolutionContext = {
  406. stateModel: { items: ["a", "b"] },
  407. repeatItem: "a",
  408. repeatIndex: 0,
  409. repeatBasePath: "/items/0",
  410. };
  411. expect(resolveActionParam({ $item: "" }, ctx)).toBe("/items/0");
  412. });
  413. it("returns undefined for $item when no repeatBasePath", () => {
  414. const ctx: PropResolutionContext = {
  415. stateModel: {},
  416. repeatItem: { title: "Hello" },
  417. repeatIndex: 0,
  418. };
  419. expect(resolveActionParam({ $item: "title" }, ctx)).toBeUndefined();
  420. });
  421. it("resolves $index to the current repeat index", () => {
  422. const ctx: PropResolutionContext = {
  423. stateModel: {},
  424. repeatItem: { id: "1" },
  425. repeatIndex: 5,
  426. };
  427. expect(resolveActionParam({ $index: true }, ctx)).toBe(5);
  428. });
  429. it("returns undefined for $index when no repeat context", () => {
  430. const ctx: PropResolutionContext = { stateModel: {} };
  431. expect(resolveActionParam({ $index: true }, ctx)).toBeUndefined();
  432. });
  433. it("delegates $state expressions to resolvePropValue", () => {
  434. const ctx: PropResolutionContext = {
  435. stateModel: { form: { id: "abc-123" } },
  436. };
  437. expect(resolveActionParam({ $state: "/form/id" }, ctx)).toBe("abc-123");
  438. });
  439. it("passes through literal strings", () => {
  440. const ctx: PropResolutionContext = { stateModel: {} };
  441. expect(resolveActionParam("submit", ctx)).toBe("submit");
  442. });
  443. it("passes through literal numbers", () => {
  444. const ctx: PropResolutionContext = { stateModel: {} };
  445. expect(resolveActionParam(42, ctx)).toBe(42);
  446. });
  447. it("passes through null", () => {
  448. const ctx: PropResolutionContext = { stateModel: {} };
  449. expect(resolveActionParam(null, ctx)).toBeNull();
  450. });
  451. });
  452. // =============================================================================
  453. // $computed expressions
  454. // =============================================================================
  455. describe("$computed expressions", () => {
  456. it("calls a registered function with resolved args", () => {
  457. const ctx: PropResolutionContext = {
  458. stateModel: { form: { firstName: "Jane", lastName: "Doe" } },
  459. functions: {
  460. fullName: (args) => `${args.first} ${args.last}`,
  461. },
  462. };
  463. expect(
  464. resolvePropValue(
  465. {
  466. $computed: "fullName",
  467. args: {
  468. first: { $state: "/form/firstName" },
  469. last: { $state: "/form/lastName" },
  470. },
  471. },
  472. ctx,
  473. ),
  474. ).toBe("Jane Doe");
  475. });
  476. it("calls function with no args", () => {
  477. const ctx: PropResolutionContext = {
  478. stateModel: {},
  479. functions: {
  480. timestamp: () => 1234567890,
  481. },
  482. };
  483. expect(resolvePropValue({ $computed: "timestamp" }, ctx)).toBe(1234567890);
  484. });
  485. it("returns undefined for unknown function", () => {
  486. const ctx: PropResolutionContext = {
  487. stateModel: {},
  488. functions: {},
  489. };
  490. expect(resolvePropValue({ $computed: "unknown" }, ctx)).toBeUndefined();
  491. });
  492. it("returns undefined when no functions in context", () => {
  493. const ctx: PropResolutionContext = { stateModel: {} };
  494. expect(resolvePropValue({ $computed: "any" }, ctx)).toBeUndefined();
  495. });
  496. it("deduplicates warnings for the same unknown function", () => {
  497. const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
  498. const ctx: PropResolutionContext = { stateModel: {}, functions: {} };
  499. resolvePropValue({ $computed: "dedupTest" }, ctx);
  500. resolvePropValue({ $computed: "dedupTest" }, ctx);
  501. const calls = warnSpy.mock.calls.filter((c) =>
  502. String(c[0]).includes("dedupTest"),
  503. );
  504. expect(calls).toHaveLength(1);
  505. warnSpy.mockRestore();
  506. });
  507. it("resolves nested expressions in args", () => {
  508. const ctx: PropResolutionContext = {
  509. stateModel: { active: true, values: { a: 10, b: 20 } },
  510. functions: {
  511. conditionalSum: (args) => {
  512. if (args.enabled) return (args.x as number) + (args.y as number);
  513. return 0;
  514. },
  515. },
  516. };
  517. expect(
  518. resolvePropValue(
  519. {
  520. $computed: "conditionalSum",
  521. args: {
  522. enabled: { $state: "/active" },
  523. x: { $state: "/values/a" },
  524. y: { $state: "/values/b" },
  525. },
  526. },
  527. ctx,
  528. ),
  529. ).toBe(30);
  530. });
  531. });
  532. // =============================================================================
  533. // $template expressions
  534. // =============================================================================
  535. describe("$template expressions", () => {
  536. it("interpolates state values into a string", () => {
  537. const ctx: PropResolutionContext = {
  538. stateModel: { user: { name: "Alice" }, count: 3 },
  539. };
  540. expect(
  541. resolvePropValue(
  542. { $template: "Hello, ${/user/name}! You have ${/count} messages." },
  543. ctx,
  544. ),
  545. ).toBe("Hello, Alice! You have 3 messages.");
  546. });
  547. it("replaces missing paths with empty string", () => {
  548. const ctx: PropResolutionContext = { stateModel: {} };
  549. expect(resolvePropValue({ $template: "Hi ${/name}!" }, ctx)).toBe("Hi !");
  550. });
  551. it("handles template with no interpolations", () => {
  552. const ctx: PropResolutionContext = { stateModel: {} };
  553. expect(resolvePropValue({ $template: "No variables here" }, ctx)).toBe(
  554. "No variables here",
  555. );
  556. });
  557. it("handles multiple references to the same path", () => {
  558. const ctx: PropResolutionContext = {
  559. stateModel: { x: "A" },
  560. };
  561. expect(resolvePropValue({ $template: "${/x} and ${/x}" }, ctx)).toBe(
  562. "A and A",
  563. );
  564. });
  565. it("converts non-string values to strings", () => {
  566. const ctx: PropResolutionContext = {
  567. stateModel: { num: 42, bool: true },
  568. };
  569. expect(resolvePropValue({ $template: "${/num} is ${/bool}" }, ctx)).toBe(
  570. "42 is true",
  571. );
  572. });
  573. it("warns when path does not start with /", () => {
  574. _resetWarnedTemplatePaths();
  575. const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
  576. const ctx: PropResolutionContext = { stateModel: { name: "Bob" } };
  577. const result = resolvePropValue({ $template: "Hi ${name}!" }, ctx);
  578. expect(result).toBe("Hi Bob!");
  579. expect(warnSpy).toHaveBeenCalledWith(
  580. expect.stringContaining('$template path "name"'),
  581. );
  582. warnSpy.mockRestore();
  583. _resetWarnedTemplatePaths();
  584. });
  585. it("deduplicates warnings for the same $template path", () => {
  586. _resetWarnedTemplatePaths();
  587. const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
  588. const ctx: PropResolutionContext = { stateModel: { name: "Bob" } };
  589. resolvePropValue({ $template: "Hi ${name}!" }, ctx);
  590. resolvePropValue({ $template: "Hi ${name}!" }, ctx);
  591. const calls = warnSpy.mock.calls.filter((c) =>
  592. String(c[0]).includes('$template path "name"'),
  593. );
  594. expect(calls).toHaveLength(1);
  595. warnSpy.mockRestore();
  596. _resetWarnedTemplatePaths();
  597. });
  598. });