props.test.ts 20 KB

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