props.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500
  1. import { describe, it, expect } from "vitest";
  2. import {
  3. resolvePropValue,
  4. resolveElementProps,
  5. resolveBindings,
  6. resolveActionParam,
  7. } from "./props";
  8. import type { PropResolutionContext } from "./props";
  9. // =============================================================================
  10. // resolvePropValue
  11. // =============================================================================
  12. describe("resolvePropValue", () => {
  13. describe("literals", () => {
  14. it("passes through strings", () => {
  15. const ctx: PropResolutionContext = { stateModel: {} };
  16. expect(resolvePropValue("hello", ctx)).toBe("hello");
  17. });
  18. it("passes through numbers", () => {
  19. const ctx: PropResolutionContext = { stateModel: {} };
  20. expect(resolvePropValue(42, ctx)).toBe(42);
  21. });
  22. it("passes through booleans", () => {
  23. const ctx: PropResolutionContext = { stateModel: {} };
  24. expect(resolvePropValue(true, ctx)).toBe(true);
  25. expect(resolvePropValue(false, ctx)).toBe(false);
  26. });
  27. it("passes through null", () => {
  28. const ctx: PropResolutionContext = { stateModel: {} };
  29. expect(resolvePropValue(null, ctx)).toBeNull();
  30. });
  31. it("passes through undefined", () => {
  32. const ctx: PropResolutionContext = { stateModel: {} };
  33. expect(resolvePropValue(undefined, ctx)).toBeUndefined();
  34. });
  35. });
  36. describe("$state expressions", () => {
  37. it("resolves a state path", () => {
  38. const ctx: PropResolutionContext = {
  39. stateModel: { user: { name: "Alice" } },
  40. };
  41. expect(resolvePropValue({ $state: "/user/name" }, ctx)).toBe("Alice");
  42. });
  43. it("returns undefined for missing state path", () => {
  44. const ctx: PropResolutionContext = { stateModel: {} };
  45. expect(resolvePropValue({ $state: "/missing" }, ctx)).toBeUndefined();
  46. });
  47. it("resolves nested state path", () => {
  48. const ctx: PropResolutionContext = {
  49. stateModel: { a: { b: { c: 42 } } },
  50. };
  51. expect(resolvePropValue({ $state: "/a/b/c" }, ctx)).toBe(42);
  52. });
  53. });
  54. describe("$item expressions", () => {
  55. it("resolves a field from the repeat item", () => {
  56. const ctx: PropResolutionContext = {
  57. stateModel: {},
  58. repeatItem: { title: "Hello", id: "1" },
  59. repeatIndex: 0,
  60. };
  61. expect(resolvePropValue({ $item: "title" }, ctx)).toBe("Hello");
  62. });
  63. it('resolves "/" to the whole item', () => {
  64. const item = { title: "Hello", id: "1" };
  65. const ctx: PropResolutionContext = {
  66. stateModel: {},
  67. repeatItem: item,
  68. repeatIndex: 0,
  69. };
  70. expect(resolvePropValue({ $item: "" }, ctx)).toBe(item);
  71. });
  72. it("resolves nested field from item", () => {
  73. const ctx: PropResolutionContext = {
  74. stateModel: {},
  75. repeatItem: { user: { name: "Bob" } },
  76. repeatIndex: 0,
  77. };
  78. expect(resolvePropValue({ $item: "user/name" }, ctx)).toBe("Bob");
  79. });
  80. it("returns undefined when no repeat item in context", () => {
  81. const ctx: PropResolutionContext = { stateModel: {} };
  82. expect(resolvePropValue({ $item: "title" }, ctx)).toBeUndefined();
  83. });
  84. it("returns undefined for missing field on item", () => {
  85. const ctx: PropResolutionContext = {
  86. stateModel: {},
  87. repeatItem: { title: "Hello" },
  88. repeatIndex: 0,
  89. };
  90. expect(resolvePropValue({ $item: "missing" }, ctx)).toBeUndefined();
  91. });
  92. });
  93. describe("$index expressions", () => {
  94. it("returns the current repeat index", () => {
  95. const ctx: PropResolutionContext = {
  96. stateModel: {},
  97. repeatItem: { id: "1" },
  98. repeatIndex: 3,
  99. };
  100. expect(resolvePropValue({ $index: true }, ctx)).toBe(3);
  101. });
  102. it("returns 0 for first item", () => {
  103. const ctx: PropResolutionContext = {
  104. stateModel: {},
  105. repeatItem: { id: "1" },
  106. repeatIndex: 0,
  107. };
  108. expect(resolvePropValue({ $index: true }, ctx)).toBe(0);
  109. });
  110. it("returns undefined when no repeat index in context", () => {
  111. const ctx: PropResolutionContext = { stateModel: {} };
  112. expect(resolvePropValue({ $index: true }, ctx)).toBeUndefined();
  113. });
  114. });
  115. describe("$cond/$then/$else expressions", () => {
  116. it("returns $then when condition is true", () => {
  117. const ctx: PropResolutionContext = {
  118. stateModel: { active: true },
  119. };
  120. expect(
  121. resolvePropValue(
  122. { $cond: { $state: "/active" }, $then: "blue", $else: "gray" },
  123. ctx,
  124. ),
  125. ).toBe("blue");
  126. });
  127. it("returns $else when condition is false", () => {
  128. const ctx: PropResolutionContext = {
  129. stateModel: { active: false },
  130. };
  131. expect(
  132. resolvePropValue(
  133. { $cond: { $state: "/active" }, $then: "blue", $else: "gray" },
  134. ctx,
  135. ),
  136. ).toBe("gray");
  137. });
  138. it("handles eq condition", () => {
  139. const ctx: PropResolutionContext = {
  140. stateModel: { tab: "home" },
  141. };
  142. expect(
  143. resolvePropValue(
  144. {
  145. $cond: { $state: "/tab", eq: "home" },
  146. $then: "#007AFF",
  147. $else: "#8E8E93",
  148. },
  149. ctx,
  150. ),
  151. ).toBe("#007AFF");
  152. });
  153. it("handles nested expression in $then/$else", () => {
  154. const ctx: PropResolutionContext = {
  155. stateModel: { isAdmin: true, admin: { greeting: "Hello Admin" } },
  156. };
  157. expect(
  158. resolvePropValue(
  159. {
  160. $cond: { $state: "/isAdmin" },
  161. $then: { $state: "/admin/greeting" },
  162. $else: "Welcome",
  163. },
  164. ctx,
  165. ),
  166. ).toBe("Hello Admin");
  167. });
  168. it("handles array condition (implicit AND)", () => {
  169. const ctx: PropResolutionContext = {
  170. stateModel: { isAdmin: true, feature: true },
  171. };
  172. expect(
  173. resolvePropValue(
  174. {
  175. $cond: [{ $state: "/isAdmin" }, { $state: "/feature" }],
  176. $then: "yes",
  177. $else: "no",
  178. },
  179. ctx,
  180. ),
  181. ).toBe("yes");
  182. });
  183. });
  184. describe("nested objects and arrays", () => {
  185. it("resolves expressions inside plain objects", () => {
  186. const ctx: PropResolutionContext = {
  187. stateModel: { color: "red", size: 12 },
  188. };
  189. const result = resolvePropValue(
  190. { fill: { $state: "/color" }, fontSize: { $state: "/size" } },
  191. ctx,
  192. );
  193. expect(result).toEqual({ fill: "red", fontSize: 12 });
  194. });
  195. it("resolves expressions inside arrays", () => {
  196. const ctx: PropResolutionContext = {
  197. stateModel: { a: 1, b: 2 },
  198. };
  199. const result = resolvePropValue(
  200. [{ $state: "/a" }, { $state: "/b" }, 3],
  201. ctx,
  202. );
  203. expect(result).toEqual([1, 2, 3]);
  204. });
  205. it("resolves deeply nested expressions", () => {
  206. const ctx: PropResolutionContext = {
  207. stateModel: { theme: { primary: "#007AFF" } },
  208. };
  209. const result = resolvePropValue(
  210. { style: { color: { $state: "/theme/primary" }, margin: 10 } },
  211. ctx,
  212. );
  213. expect(result).toEqual({ style: { color: "#007AFF", margin: 10 } });
  214. });
  215. });
  216. });
  217. // =============================================================================
  218. // resolveElementProps
  219. // =============================================================================
  220. describe("resolveElementProps", () => {
  221. it("resolves all props in an element", () => {
  222. const ctx: PropResolutionContext = {
  223. stateModel: { user: { name: "Alice", role: "admin" } },
  224. };
  225. const props = {
  226. label: { $state: "/user/name" },
  227. badge: { $state: "/user/role" },
  228. static: "always",
  229. };
  230. expect(resolveElementProps(props, ctx)).toEqual({
  231. label: "Alice",
  232. badge: "admin",
  233. static: "always",
  234. });
  235. });
  236. it("resolves mixed expressions and literals", () => {
  237. const ctx: PropResolutionContext = {
  238. stateModel: { active: true },
  239. repeatItem: { title: "Item 1" },
  240. repeatIndex: 2,
  241. };
  242. const props = {
  243. title: { $item: "title" },
  244. index: { $index: true },
  245. color: {
  246. $cond: { $state: "/active" },
  247. $then: "green",
  248. $else: "gray",
  249. },
  250. width: 100,
  251. };
  252. expect(resolveElementProps(props, ctx)).toEqual({
  253. title: "Item 1",
  254. index: 2,
  255. color: "green",
  256. width: 100,
  257. });
  258. });
  259. it("returns empty object for empty props", () => {
  260. const ctx: PropResolutionContext = { stateModel: {} };
  261. expect(resolveElementProps({}, ctx)).toEqual({});
  262. });
  263. });
  264. // =============================================================================
  265. // $bindState / $bindItem expressions
  266. // =============================================================================
  267. describe("$bindState expressions", () => {
  268. describe("resolvePropValue with $bindState", () => {
  269. it("resolves to the state value at the path", () => {
  270. const ctx: PropResolutionContext = {
  271. stateModel: { form: { email: "alice@example.com" } },
  272. };
  273. expect(resolvePropValue({ $bindState: "/form/email" }, ctx)).toBe(
  274. "alice@example.com",
  275. );
  276. });
  277. it("returns undefined for missing path", () => {
  278. const ctx: PropResolutionContext = { stateModel: {} };
  279. expect(resolvePropValue({ $bindState: "/missing" }, ctx)).toBeUndefined();
  280. });
  281. });
  282. describe("resolvePropValue with $bindItem", () => {
  283. it("resolves item field using repeatBasePath", () => {
  284. const ctx: PropResolutionContext = {
  285. stateModel: { todos: [{ completed: true }, { completed: false }] },
  286. repeatItem: { completed: true },
  287. repeatIndex: 0,
  288. repeatBasePath: "/todos/0",
  289. };
  290. expect(resolvePropValue({ $bindItem: "completed" }, ctx)).toBe(true);
  291. });
  292. it('handles "/" as the full item path', () => {
  293. const ctx: PropResolutionContext = {
  294. stateModel: { items: ["hello", "world"] },
  295. repeatItem: "hello",
  296. repeatIndex: 0,
  297. repeatBasePath: "/items/0",
  298. };
  299. expect(resolvePropValue({ $bindItem: "" }, ctx)).toBe("hello");
  300. });
  301. it("returns undefined when no repeatBasePath", () => {
  302. const ctx: PropResolutionContext = {
  303. stateModel: {},
  304. repeatItem: { completed: true },
  305. repeatIndex: 0,
  306. };
  307. // Without repeatBasePath, the raw item path won't resolve in stateModel
  308. expect(resolvePropValue({ $bindItem: "completed" }, ctx)).toBeUndefined();
  309. });
  310. });
  311. describe("resolveBindings", () => {
  312. it("extracts $bindState paths from props", () => {
  313. const ctx: PropResolutionContext = { stateModel: {} };
  314. const props = {
  315. value: { $bindState: "/form/email" },
  316. label: "Email",
  317. placeholder: "Enter email",
  318. };
  319. expect(resolveBindings(props, ctx)).toEqual({
  320. value: "/form/email",
  321. });
  322. });
  323. it("returns undefined when no bind expressions", () => {
  324. const ctx: PropResolutionContext = { stateModel: {} };
  325. const props = {
  326. label: "Hello",
  327. count: 42,
  328. };
  329. expect(resolveBindings(props, ctx)).toBeUndefined();
  330. });
  331. it("handles multiple $bindState props", () => {
  332. const ctx: PropResolutionContext = { stateModel: {} };
  333. const props = {
  334. value: { $bindState: "/form/name" },
  335. checked: { $bindState: "/form/agree" },
  336. label: "Name",
  337. };
  338. expect(resolveBindings(props, ctx)).toEqual({
  339. value: "/form/name",
  340. checked: "/form/agree",
  341. });
  342. });
  343. it("resolves $bindItem paths using repeatBasePath", () => {
  344. const ctx: PropResolutionContext = {
  345. stateModel: {},
  346. repeatItem: { completed: false },
  347. repeatIndex: 1,
  348. repeatBasePath: "/todos/1",
  349. };
  350. const props = {
  351. checked: { $bindItem: "completed" },
  352. label: { $item: "title" },
  353. };
  354. expect(resolveBindings(props, ctx)).toEqual({
  355. checked: "/todos/1/completed",
  356. });
  357. });
  358. it("ignores non-bind dynamic expressions", () => {
  359. const ctx: PropResolutionContext = { stateModel: {} };
  360. const props = {
  361. title: { $state: "/title" },
  362. index: { $index: true },
  363. name: { $item: "name" },
  364. value: { $bindState: "/path" },
  365. };
  366. expect(resolveBindings(props, ctx)).toEqual({
  367. value: "/path",
  368. });
  369. });
  370. it("handles mixed $bindState and $bindItem props", () => {
  371. const ctx: PropResolutionContext = {
  372. stateModel: {},
  373. repeatItem: { done: false },
  374. repeatIndex: 0,
  375. repeatBasePath: "/todos/0",
  376. };
  377. const props = {
  378. value: { $bindState: "/form/search" },
  379. checked: { $bindItem: "done" },
  380. label: "Task",
  381. };
  382. expect(resolveBindings(props, ctx)).toEqual({
  383. value: "/form/search",
  384. checked: "/todos/0/done",
  385. });
  386. });
  387. });
  388. });
  389. // =============================================================================
  390. // resolveActionParam
  391. // =============================================================================
  392. describe("resolveActionParam", () => {
  393. it("resolves $item to an absolute state path via repeatBasePath", () => {
  394. const ctx: PropResolutionContext = {
  395. stateModel: { todos: [{ title: "Buy milk" }] },
  396. repeatItem: { title: "Buy milk" },
  397. repeatIndex: 0,
  398. repeatBasePath: "/todos/0",
  399. };
  400. expect(resolveActionParam({ $item: "title" }, ctx)).toBe("/todos/0/title");
  401. });
  402. it("resolves $item with empty string to the repeatBasePath itself", () => {
  403. const ctx: PropResolutionContext = {
  404. stateModel: { items: ["a", "b"] },
  405. repeatItem: "a",
  406. repeatIndex: 0,
  407. repeatBasePath: "/items/0",
  408. };
  409. expect(resolveActionParam({ $item: "" }, ctx)).toBe("/items/0");
  410. });
  411. it("returns undefined for $item when no repeatBasePath", () => {
  412. const ctx: PropResolutionContext = {
  413. stateModel: {},
  414. repeatItem: { title: "Hello" },
  415. repeatIndex: 0,
  416. };
  417. expect(resolveActionParam({ $item: "title" }, ctx)).toBeUndefined();
  418. });
  419. it("resolves $index to the current repeat index", () => {
  420. const ctx: PropResolutionContext = {
  421. stateModel: {},
  422. repeatItem: { id: "1" },
  423. repeatIndex: 5,
  424. };
  425. expect(resolveActionParam({ $index: true }, ctx)).toBe(5);
  426. });
  427. it("returns undefined for $index when no repeat context", () => {
  428. const ctx: PropResolutionContext = { stateModel: {} };
  429. expect(resolveActionParam({ $index: true }, ctx)).toBeUndefined();
  430. });
  431. it("delegates $state expressions to resolvePropValue", () => {
  432. const ctx: PropResolutionContext = {
  433. stateModel: { form: { id: "abc-123" } },
  434. };
  435. expect(resolveActionParam({ $state: "/form/id" }, ctx)).toBe("abc-123");
  436. });
  437. it("passes through literal strings", () => {
  438. const ctx: PropResolutionContext = { stateModel: {} };
  439. expect(resolveActionParam("submit", ctx)).toBe("submit");
  440. });
  441. it("passes through literal numbers", () => {
  442. const ctx: PropResolutionContext = { stateModel: {} };
  443. expect(resolveActionParam(42, ctx)).toBe(42);
  444. });
  445. it("passes through null", () => {
  446. const ctx: PropResolutionContext = { stateModel: {} };
  447. expect(resolveActionParam(null, ctx)).toBeNull();
  448. });
  449. });