chained-actions.test.tsx 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. import { describe, it, expect } from "vitest";
  2. import { render, screen, fireEvent, waitFor } from "@solidjs/testing-library";
  3. import type { Spec } from "@json-render/core";
  4. import {
  5. JSONUIProvider,
  6. Renderer,
  7. type ComponentRenderProps,
  8. } from "./renderer";
  9. import { useStateStore } from "./contexts/state";
  10. // DO NOT destructure props — Solid requires proxy access
  11. function Button(props: ComponentRenderProps<{ label: string }>) {
  12. return (
  13. <button data-testid="btn" onClick={() => props.emit("press")}>
  14. {props.element.props.label}
  15. </button>
  16. );
  17. }
  18. function Text(props: ComponentRenderProps<{ text: unknown }>) {
  19. const display = () => {
  20. const v = props.element.props.text;
  21. return typeof v === "string" ? v : JSON.stringify(v);
  22. };
  23. return <span data-testid={`text-${props.element.type}`}>{display()}</span>;
  24. }
  25. let probeGetSnapshot: (() => Record<string, unknown>) | undefined;
  26. function StateProbe() {
  27. const ctx = useStateStore();
  28. probeGetSnapshot = ctx.getSnapshot;
  29. return <div data-testid="state-probe" />;
  30. }
  31. const registry = {
  32. Button,
  33. Text,
  34. };
  35. function getProbeState(): Record<string, unknown> {
  36. return probeGetSnapshot!();
  37. }
  38. describe("chained actions: live $state resolution (#141)", () => {
  39. it("setState after pushState sees the post-push value via $state", async () => {
  40. probeGetSnapshot = undefined;
  41. const spec: Spec = {
  42. state: { items: ["initial"], observed: "not yet set" },
  43. root: "main",
  44. elements: {
  45. main: {
  46. type: "Button",
  47. props: { label: "Add Item" },
  48. on: {
  49. press: [
  50. {
  51. action: "pushState",
  52. params: { statePath: "/items", value: "new-item" },
  53. },
  54. {
  55. action: "setState",
  56. params: {
  57. statePath: "/observed",
  58. value: { $state: "/items" },
  59. },
  60. },
  61. ],
  62. },
  63. },
  64. },
  65. };
  66. function App() {
  67. return (
  68. <JSONUIProvider registry={registry} initialState={spec.state}>
  69. <Renderer spec={spec} registry={registry} />
  70. <StateProbe />
  71. </JSONUIProvider>
  72. );
  73. }
  74. render(() => <App />);
  75. fireEvent.click(screen.getByTestId("btn"));
  76. await waitFor(() => {
  77. const state = getProbeState();
  78. expect(state.items).toEqual(["initial", "new-item"]);
  79. expect(state.observed).toEqual(["initial", "new-item"]);
  80. });
  81. });
  82. it("multiple pushState + setState chain resolves correctly", async () => {
  83. probeGetSnapshot = undefined;
  84. const spec: Spec = {
  85. state: { items: [], snapshot: null },
  86. root: "main",
  87. elements: {
  88. main: {
  89. type: "Button",
  90. props: { label: "Go" },
  91. on: {
  92. press: [
  93. {
  94. action: "pushState",
  95. params: { statePath: "/items", value: "a" },
  96. },
  97. {
  98. action: "pushState",
  99. params: { statePath: "/items", value: "b" },
  100. },
  101. {
  102. action: "setState",
  103. params: {
  104. statePath: "/snapshot",
  105. value: { $state: "/items" },
  106. },
  107. },
  108. ],
  109. },
  110. },
  111. },
  112. };
  113. function App() {
  114. return (
  115. <JSONUIProvider registry={registry} initialState={spec.state}>
  116. <Renderer spec={spec} registry={registry} />
  117. <StateProbe />
  118. </JSONUIProvider>
  119. );
  120. }
  121. render(() => <App />);
  122. fireEvent.click(screen.getByTestId("btn"));
  123. await waitFor(() => {
  124. const state = getProbeState();
  125. expect(state.items).toEqual(["a", "b"]);
  126. expect(state.snapshot).toEqual(["a", "b"]);
  127. });
  128. });
  129. it("setState reading a path mutated by an earlier setState sees fresh value", async () => {
  130. probeGetSnapshot = undefined;
  131. const spec: Spec = {
  132. state: { counter: 0, counterCopy: -1 },
  133. root: "main",
  134. elements: {
  135. main: {
  136. type: "Button",
  137. props: { label: "Go" },
  138. on: {
  139. press: [
  140. {
  141. action: "setState",
  142. params: { statePath: "/counter", value: 42 },
  143. },
  144. {
  145. action: "setState",
  146. params: {
  147. statePath: "/counterCopy",
  148. value: { $state: "/counter" },
  149. },
  150. },
  151. ],
  152. },
  153. },
  154. },
  155. };
  156. function App() {
  157. return (
  158. <JSONUIProvider registry={registry} initialState={spec.state}>
  159. <Renderer spec={spec} registry={registry} />
  160. <StateProbe />
  161. </JSONUIProvider>
  162. );
  163. }
  164. render(() => <App />);
  165. fireEvent.click(screen.getByTestId("btn"));
  166. await waitFor(() => {
  167. const state = getProbeState();
  168. expect(state.counter).toBe(42);
  169. expect(state.counterCopy).toBe(42);
  170. });
  171. });
  172. });