directives.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497
  1. import { describe, it, expect, vi } from "vitest";
  2. import {
  3. resolvePropValue,
  4. createDirectiveRegistry,
  5. type PropResolutionContext,
  6. } from "@json-render/core";
  7. import { formatDirective } from "./format";
  8. import { mathDirective } from "./math";
  9. import { concatDirective } from "./concat";
  10. import { countDirective } from "./count";
  11. import { truncateDirective } from "./truncate";
  12. import { pluralizeDirective } from "./pluralize";
  13. import { joinDirective } from "./join";
  14. import { createI18nDirective } from "./i18n";
  15. const allDirectives = [
  16. formatDirective,
  17. mathDirective,
  18. concatDirective,
  19. countDirective,
  20. truncateDirective,
  21. pluralizeDirective,
  22. joinDirective,
  23. ];
  24. function makeCtx(
  25. state: Record<string, unknown> = {},
  26. extra: Partial<PropResolutionContext> = {},
  27. ): PropResolutionContext {
  28. return {
  29. stateModel: state,
  30. directives: createDirectiveRegistry(allDirectives),
  31. ...extra,
  32. };
  33. }
  34. // ============================================================================
  35. // $format
  36. // ============================================================================
  37. describe("$format", () => {
  38. it("formats a number", () => {
  39. const ctx = makeCtx();
  40. const result = resolvePropValue({ $format: "number", value: 1234.56 }, ctx);
  41. expect(typeof result).toBe("string");
  42. expect(result).toContain("1");
  43. });
  44. it("formats currency", () => {
  45. const ctx = makeCtx({ total: 42.5 });
  46. const result = resolvePropValue(
  47. { $format: "currency", value: { $state: "/total" }, currency: "USD" },
  48. ctx,
  49. );
  50. expect(typeof result).toBe("string");
  51. expect(result).toContain("42");
  52. });
  53. it("formats percent", () => {
  54. const ctx = makeCtx();
  55. const result = resolvePropValue({ $format: "percent", value: 0.75 }, ctx);
  56. expect(typeof result).toBe("string");
  57. expect(result).toContain("75");
  58. });
  59. it("formats a date", () => {
  60. const ctx = makeCtx();
  61. const result = resolvePropValue(
  62. { $format: "date", value: "2024-01-15" },
  63. ctx,
  64. );
  65. expect(typeof result).toBe("string");
  66. expect(result).toContain("2024");
  67. });
  68. it("formats a relative date with injectable now", () => {
  69. const ctx = makeCtx();
  70. const baseDate = new Date("2024-06-15T12:00:00Z").getTime();
  71. const result = resolvePropValue(
  72. {
  73. $format: "date",
  74. value: "2024-06-15T12:00:00Z",
  75. style: "relative",
  76. now: baseDate + 3 * 60 * 60 * 1000,
  77. },
  78. ctx,
  79. );
  80. expect(result).toBe("3h ago");
  81. });
  82. it("formats a future relative date", () => {
  83. const ctx = makeCtx();
  84. const baseDate = new Date("2024-06-15T12:00:00Z").getTime();
  85. const result = resolvePropValue(
  86. {
  87. $format: "date",
  88. value: "2024-06-15T12:00:00Z",
  89. style: "relative",
  90. now: baseDate - 2 * 60 * 60 * 1000,
  91. },
  92. ctx,
  93. );
  94. expect(result).toBe("2h from now");
  95. });
  96. it("returns 'just now' when date equals now", () => {
  97. const ctx = makeCtx();
  98. const ts = new Date("2024-06-15T12:00:00Z").getTime();
  99. const result = resolvePropValue(
  100. {
  101. $format: "date",
  102. value: "2024-06-15T12:00:00Z",
  103. style: "relative",
  104. now: ts,
  105. },
  106. ctx,
  107. );
  108. expect(result).toBe("just now");
  109. });
  110. });
  111. // ============================================================================
  112. // $math
  113. // ============================================================================
  114. describe("$math", () => {
  115. it("adds two values", () => {
  116. const ctx = makeCtx({ a: 10, b: 5 });
  117. expect(
  118. resolvePropValue(
  119. { $math: "add", a: { $state: "/a" }, b: { $state: "/b" } },
  120. ctx,
  121. ),
  122. ).toBe(15);
  123. });
  124. it("subtracts", () => {
  125. const ctx = makeCtx();
  126. expect(resolvePropValue({ $math: "subtract", a: 10, b: 3 }, ctx)).toBe(7);
  127. });
  128. it("multiplies", () => {
  129. const ctx = makeCtx();
  130. expect(resolvePropValue({ $math: "multiply", a: 4, b: 5 }, ctx)).toBe(20);
  131. });
  132. it("divides", () => {
  133. const ctx = makeCtx();
  134. expect(resolvePropValue({ $math: "divide", a: 10, b: 4 }, ctx)).toBe(2.5);
  135. });
  136. it("handles division by zero", () => {
  137. const ctx = makeCtx();
  138. expect(resolvePropValue({ $math: "divide", a: 10, b: 0 }, ctx)).toBe(0);
  139. });
  140. it("computes modulo", () => {
  141. const ctx = makeCtx();
  142. expect(resolvePropValue({ $math: "mod", a: 10, b: 3 }, ctx)).toBe(1);
  143. });
  144. it("computes min", () => {
  145. const ctx = makeCtx();
  146. expect(resolvePropValue({ $math: "min", a: 10, b: 3 }, ctx)).toBe(3);
  147. });
  148. it("computes max", () => {
  149. const ctx = makeCtx();
  150. expect(resolvePropValue({ $math: "max", a: 10, b: 3 }, ctx)).toBe(10);
  151. });
  152. it("rounds", () => {
  153. const ctx = makeCtx();
  154. expect(resolvePropValue({ $math: "round", a: 3.7 }, ctx)).toBe(4);
  155. });
  156. it("floors", () => {
  157. const ctx = makeCtx();
  158. expect(resolvePropValue({ $math: "floor", a: 3.7 }, ctx)).toBe(3);
  159. });
  160. it("ceils", () => {
  161. const ctx = makeCtx();
  162. expect(resolvePropValue({ $math: "ceil", a: 3.2 }, ctx)).toBe(4);
  163. });
  164. it("computes abs", () => {
  165. const ctx = makeCtx();
  166. expect(resolvePropValue({ $math: "abs", a: -5 }, ctx)).toBe(5);
  167. });
  168. it("defaults missing operand b to 0", () => {
  169. const ctx = makeCtx();
  170. expect(resolvePropValue({ $math: "add", a: 5 }, ctx)).toBe(5);
  171. });
  172. it("defaults missing operand a to 0", () => {
  173. const ctx = makeCtx();
  174. expect(resolvePropValue({ $math: "add", b: 3 }, ctx)).toBe(3);
  175. });
  176. it("warns when a non-numeric value is coerced to 0", () => {
  177. const ctx = makeCtx();
  178. const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
  179. const result = resolvePropValue({ $math: "add", a: "foo", b: 3 }, ctx);
  180. expect(result).toBe(3);
  181. expect(spy).toHaveBeenCalledWith(
  182. "$math: non-numeric value coerced to 0:",
  183. "foo",
  184. );
  185. spy.mockRestore();
  186. });
  187. });
  188. // ============================================================================
  189. // $concat
  190. // ============================================================================
  191. describe("$concat", () => {
  192. it("concatenates strings", () => {
  193. const ctx = makeCtx({ first: "John", last: "Doe" });
  194. expect(
  195. resolvePropValue(
  196. { $concat: [{ $state: "/first" }, " ", { $state: "/last" }] },
  197. ctx,
  198. ),
  199. ).toBe("John Doe");
  200. });
  201. it("handles null values", () => {
  202. const ctx = makeCtx();
  203. expect(resolvePropValue({ $concat: ["hello", null, "world"] }, ctx)).toBe(
  204. "helloworld",
  205. );
  206. });
  207. it("converts non-strings", () => {
  208. const ctx = makeCtx();
  209. expect(resolvePropValue({ $concat: ["count: ", 42] }, ctx)).toBe(
  210. "count: 42",
  211. );
  212. });
  213. });
  214. // ============================================================================
  215. // $count
  216. // ============================================================================
  217. describe("$count", () => {
  218. it("counts array items", () => {
  219. const ctx = makeCtx({ items: [1, 2, 3] });
  220. expect(resolvePropValue({ $count: { $state: "/items" } }, ctx)).toBe(3);
  221. });
  222. it("counts string length", () => {
  223. const ctx = makeCtx();
  224. expect(resolvePropValue({ $count: "hello" }, ctx)).toBe(5);
  225. });
  226. it("returns 0 for non-countable", () => {
  227. const ctx = makeCtx();
  228. expect(resolvePropValue({ $count: 42 }, ctx)).toBe(0);
  229. });
  230. it("returns 0 for empty array", () => {
  231. const ctx = makeCtx({ items: [] });
  232. expect(resolvePropValue({ $count: { $state: "/items" } }, ctx)).toBe(0);
  233. });
  234. });
  235. // ============================================================================
  236. // $truncate
  237. // ============================================================================
  238. describe("$truncate", () => {
  239. it("truncates long text", () => {
  240. const ctx = makeCtx();
  241. const text = "a".repeat(200);
  242. const result = resolvePropValue(
  243. { $truncate: text, length: 10, suffix: "..." },
  244. ctx,
  245. ) as string;
  246. expect(result.length).toBe(13);
  247. expect(result).toBe("a".repeat(10) + "...");
  248. });
  249. it("does not truncate short text", () => {
  250. const ctx = makeCtx();
  251. expect(resolvePropValue({ $truncate: "hello", length: 10 }, ctx)).toBe(
  252. "hello",
  253. );
  254. });
  255. it("uses default length and suffix", () => {
  256. const ctx = makeCtx();
  257. const text = "a".repeat(200);
  258. const result = resolvePropValue({ $truncate: text }, ctx) as string;
  259. expect(result.length).toBe(103); // 100 + "..."
  260. });
  261. it("resolves dynamic values", () => {
  262. const ctx = makeCtx({ body: "hello world this is a test" });
  263. expect(
  264. resolvePropValue(
  265. { $truncate: { $state: "/body" }, length: 11, suffix: "…" },
  266. ctx,
  267. ),
  268. ).toBe("hello world…");
  269. });
  270. });
  271. // ============================================================================
  272. // $pluralize
  273. // ============================================================================
  274. describe("$pluralize", () => {
  275. it("handles singular", () => {
  276. const ctx = makeCtx();
  277. expect(
  278. resolvePropValue({ $pluralize: 1, one: "item", other: "items" }, ctx),
  279. ).toBe("1 item");
  280. });
  281. it("handles plural", () => {
  282. const ctx = makeCtx();
  283. expect(
  284. resolvePropValue({ $pluralize: 5, one: "item", other: "items" }, ctx),
  285. ).toBe("5 items");
  286. });
  287. it("handles zero with zero form", () => {
  288. const ctx = makeCtx();
  289. expect(
  290. resolvePropValue(
  291. { $pluralize: 0, one: "item", other: "items", zero: "no items" },
  292. ctx,
  293. ),
  294. ).toBe("no items");
  295. });
  296. it("handles zero without zero form", () => {
  297. const ctx = makeCtx();
  298. expect(
  299. resolvePropValue({ $pluralize: 0, one: "item", other: "items" }, ctx),
  300. ).toBe("0 items");
  301. });
  302. it("resolves count from state", () => {
  303. const ctx = makeCtx({ count: 3 });
  304. expect(
  305. resolvePropValue(
  306. { $pluralize: { $state: "/count" }, one: "file", other: "files" },
  307. ctx,
  308. ),
  309. ).toBe("3 files");
  310. });
  311. it("coerces string count to number", () => {
  312. const ctx = makeCtx();
  313. expect(
  314. resolvePropValue({ $pluralize: "3", one: "item", other: "items" }, ctx),
  315. ).toBe("3 items");
  316. });
  317. });
  318. // ============================================================================
  319. // $join
  320. // ============================================================================
  321. describe("$join", () => {
  322. it("joins array with default separator", () => {
  323. const ctx = makeCtx({ tags: ["red", "green", "blue"] });
  324. expect(resolvePropValue({ $join: { $state: "/tags" } }, ctx)).toBe(
  325. "red, green, blue",
  326. );
  327. });
  328. it("joins with custom separator", () => {
  329. const ctx = makeCtx({ tags: ["a", "b", "c"] });
  330. expect(
  331. resolvePropValue({ $join: { $state: "/tags" }, separator: " | " }, ctx),
  332. ).toBe("a | b | c");
  333. });
  334. it("handles non-array values", () => {
  335. const ctx = makeCtx();
  336. expect(resolvePropValue({ $join: "hello" }, ctx)).toBe("hello");
  337. });
  338. it("handles null items", () => {
  339. const ctx = makeCtx({ items: ["a", null, "b"] });
  340. expect(
  341. resolvePropValue({ $join: { $state: "/items" }, separator: "-" }, ctx),
  342. ).toBe("a--b");
  343. });
  344. });
  345. // ============================================================================
  346. // $t (i18n)
  347. // ============================================================================
  348. describe("createI18nDirective", () => {
  349. const tDirective = createI18nDirective({
  350. locale: "en",
  351. messages: {
  352. en: {
  353. greeting: "Hello, {{name}}!",
  354. "checkout.submit": "Place Order",
  355. "items.count": "{{count}} items in cart",
  356. },
  357. es: {
  358. greeting: "Hola, {{name}}!",
  359. "checkout.submit": "Realizar Pedido",
  360. },
  361. },
  362. fallbackLocale: "en",
  363. });
  364. function makeI18nCtx(
  365. state: Record<string, unknown> = {},
  366. ): PropResolutionContext {
  367. return {
  368. stateModel: state,
  369. directives: createDirectiveRegistry([tDirective]),
  370. };
  371. }
  372. it("translates a simple key", () => {
  373. const ctx = makeI18nCtx();
  374. expect(resolvePropValue({ $t: "checkout.submit" }, ctx)).toBe(
  375. "Place Order",
  376. );
  377. });
  378. it("interpolates parameters", () => {
  379. const ctx = makeI18nCtx({ name: "Alice" });
  380. expect(
  381. resolvePropValue(
  382. { $t: "greeting", params: { name: { $state: "/name" } } },
  383. ctx,
  384. ),
  385. ).toBe("Hello, Alice!");
  386. });
  387. it("returns key for missing translations", () => {
  388. const ctx = makeI18nCtx();
  389. expect(resolvePropValue({ $t: "missing.key" }, ctx)).toBe("missing.key");
  390. });
  391. it("handles multiple params", () => {
  392. const ctx = makeI18nCtx({ count: 3 });
  393. expect(
  394. resolvePropValue(
  395. { $t: "items.count", params: { count: { $state: "/count" } } },
  396. ctx,
  397. ),
  398. ).toBe("3 items in cart");
  399. });
  400. });
  401. // ============================================================================
  402. // Composition tests
  403. // ============================================================================
  404. describe("directive composition", () => {
  405. it("composes $math inside $format", () => {
  406. const ctx = makeCtx({ price: 10, qty: 3 });
  407. const result = resolvePropValue(
  408. {
  409. $format: "currency",
  410. value: {
  411. $math: "multiply",
  412. a: { $state: "/price" },
  413. b: { $state: "/qty" },
  414. },
  415. currency: "USD",
  416. },
  417. ctx,
  418. );
  419. expect(typeof result).toBe("string");
  420. expect(result).toContain("30");
  421. });
  422. it("composes $count inside $pluralize", () => {
  423. const ctx = makeCtx({ items: [1, 2, 3] });
  424. expect(
  425. resolvePropValue(
  426. {
  427. $pluralize: { $count: { $state: "/items" } },
  428. one: "item",
  429. other: "items",
  430. },
  431. ctx,
  432. ),
  433. ).toBe("3 items");
  434. });
  435. });