actions.test.tsx 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. import { describe, it, expect, vi } from "vitest";
  2. import { render } from "@solidjs/testing-library";
  3. import type { JSX } from "solid-js";
  4. import { StateProvider, useStateStore } from "./state";
  5. import {
  6. ValidationProvider,
  7. useValidation,
  8. useFieldValidation,
  9. } from "./validation";
  10. import { ActionProvider, useActions, useAction } from "./actions";
  11. /**
  12. * Build the full provider tree inline so that Solid's eager JSX evaluation
  13. * always runs ActionProvider/ValidationProvider INSIDE StateProvider's scope.
  14. *
  15. * IMPORTANT: Do NOT pre-build JSX fragments as variables — Solid evaluates
  16. * JSX eagerly, so `<ActionProvider>` would call `useStateStore()` before the
  17. * StateProvider context is available.
  18. */
  19. function withProviders<T>(
  20. hook: () => T,
  21. options: {
  22. handlers?: Record<
  23. string,
  24. (params: Record<string, unknown>) => Promise<void> | void
  25. >;
  26. initialState?: Record<string, unknown>;
  27. withValidation?: boolean;
  28. } = {},
  29. ): T {
  30. let result!: T;
  31. function TestComponent(): JSX.Element {
  32. result = hook();
  33. return (<div />) as JSX.Element;
  34. }
  35. if (options.withValidation) {
  36. render(() => (
  37. <StateProvider initialState={options.initialState ?? {}}>
  38. <ValidationProvider>
  39. <ActionProvider handlers={options.handlers}>
  40. <TestComponent />
  41. </ActionProvider>
  42. </ValidationProvider>
  43. </StateProvider>
  44. ));
  45. } else {
  46. render(() => (
  47. <StateProvider initialState={options.initialState ?? {}}>
  48. <ActionProvider handlers={options.handlers}>
  49. <TestComponent />
  50. </ActionProvider>
  51. </StateProvider>
  52. ));
  53. }
  54. return result;
  55. }
  56. function withFullProviders(options: {
  57. handlers?: Record<
  58. string,
  59. (params: Record<string, unknown>) => Promise<void> | void
  60. >;
  61. initialState?: Record<string, unknown>;
  62. withValidation?: boolean;
  63. }): {
  64. stateCtx: ReturnType<typeof useStateStore>;
  65. actionsCtx: ReturnType<typeof useActions>;
  66. validationCtx?: ReturnType<typeof useValidation>;
  67. } {
  68. let stateCtx!: ReturnType<typeof useStateStore>;
  69. let actionsCtx!: ReturnType<typeof useActions>;
  70. let validationCtx: ReturnType<typeof useValidation> | undefined;
  71. function TestComponent(): JSX.Element {
  72. stateCtx = useStateStore();
  73. actionsCtx = useActions();
  74. if (options.withValidation) {
  75. validationCtx = useValidation();
  76. }
  77. return (<div />) as JSX.Element;
  78. }
  79. if (options.withValidation) {
  80. render(() => (
  81. <StateProvider initialState={options.initialState ?? {}}>
  82. <ValidationProvider>
  83. <ActionProvider handlers={options.handlers}>
  84. <TestComponent />
  85. </ActionProvider>
  86. </ValidationProvider>
  87. </StateProvider>
  88. ));
  89. } else {
  90. render(() => (
  91. <StateProvider initialState={options.initialState ?? {}}>
  92. <ActionProvider handlers={options.handlers}>
  93. <TestComponent />
  94. </ActionProvider>
  95. </StateProvider>
  96. ));
  97. }
  98. return { stateCtx, actionsCtx, validationCtx };
  99. }
  100. describe("ActionProvider — provide/inject", () => {
  101. it("useActions() throws outside a provider", () => {
  102. expect(() => useActions()).toThrow(
  103. "useActions must be used within an ActionProvider",
  104. );
  105. });
  106. });
  107. describe("ActionProvider — built-in setState", () => {
  108. it("executes setState and updates state", async () => {
  109. const { stateCtx, actionsCtx } = withFullProviders({
  110. initialState: { count: 0 },
  111. });
  112. await actionsCtx.execute({
  113. action: "setState",
  114. params: { statePath: "/count", value: 5 },
  115. });
  116. expect(stateCtx.get("/count")).toBe(5);
  117. });
  118. });
  119. describe("ActionProvider — built-in pushState", () => {
  120. it("appends to existing array", async () => {
  121. const { stateCtx, actionsCtx } = withFullProviders({
  122. initialState: { items: ["a", "b"] },
  123. });
  124. await actionsCtx.execute({
  125. action: "pushState",
  126. params: { statePath: "/items", value: "c" },
  127. });
  128. expect(stateCtx.get("/items")).toEqual(["a", "b", "c"]);
  129. });
  130. it("creates array if path does not exist", async () => {
  131. const { stateCtx, actionsCtx } = withFullProviders({
  132. initialState: {},
  133. });
  134. await actionsCtx.execute({
  135. action: "pushState",
  136. params: { statePath: "/newList", value: "first" },
  137. });
  138. expect(stateCtx.get("/newList")).toEqual(["first"]);
  139. });
  140. });
  141. describe("ActionProvider — built-in removeState", () => {
  142. it("removes item by index", async () => {
  143. const { stateCtx, actionsCtx } = withFullProviders({
  144. initialState: { items: ["a", "b", "c"] },
  145. });
  146. await actionsCtx.execute({
  147. action: "removeState",
  148. params: { statePath: "/items", index: 1 },
  149. });
  150. expect(stateCtx.get("/items")).toEqual(["a", "c"]);
  151. });
  152. });
  153. describe("ActionProvider — built-in push/pop navigation", () => {
  154. it("push sets /currentScreen and /navStack", async () => {
  155. const { stateCtx, actionsCtx } = withFullProviders({
  156. initialState: { currentScreen: "home" },
  157. });
  158. await actionsCtx.execute({
  159. action: "push",
  160. params: { screen: "settings" },
  161. });
  162. expect(stateCtx.get("/currentScreen")).toBe("settings");
  163. expect(stateCtx.get("/navStack")).toEqual(["home"]);
  164. });
  165. it("pop restores previous screen from /navStack", async () => {
  166. const { stateCtx, actionsCtx } = withFullProviders({
  167. initialState: { currentScreen: "settings", navStack: ["home"] },
  168. });
  169. await actionsCtx.execute({ action: "pop" });
  170. expect(stateCtx.get("/currentScreen")).toBe("home");
  171. expect(stateCtx.get("/navStack")).toEqual([]);
  172. });
  173. });
  174. describe("ActionProvider — custom handlers", () => {
  175. it("executes custom handler with params", async () => {
  176. const customHandler = vi.fn().mockResolvedValue(undefined);
  177. const { actionsCtx } = withFullProviders({
  178. handlers: { myAction: customHandler },
  179. });
  180. await actionsCtx.execute({
  181. action: "myAction",
  182. params: { foo: "bar" },
  183. });
  184. expect(customHandler).toHaveBeenCalledWith({ foo: "bar" });
  185. });
  186. it("console.warn for unknown action", async () => {
  187. const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
  188. const { actionsCtx } = withFullProviders({});
  189. await actionsCtx.execute({ action: "unknownAction" });
  190. expect(warnSpy).toHaveBeenCalledWith(
  191. expect.stringContaining("unknownAction"),
  192. );
  193. warnSpy.mockRestore();
  194. });
  195. it("tracks loading state during async action execution", async () => {
  196. let resolveHandler!: () => void;
  197. const slowHandler = vi.fn(
  198. () =>
  199. new Promise<void>((resolve) => {
  200. resolveHandler = resolve;
  201. }),
  202. );
  203. let executeFn!: ReturnType<typeof useActions>["execute"];
  204. const { findByTestId } = render(() => {
  205. function Inner(): JSX.Element {
  206. const actions = useActions();
  207. executeFn = actions.execute;
  208. return (
  209. <span data-testid="loading">
  210. {actions.loadingActions.has("slowAction") ? "true" : "false"}
  211. </span>
  212. );
  213. }
  214. return (
  215. <StateProvider initialState={{}}>
  216. <ActionProvider handlers={{ slowAction: slowHandler }}>
  217. <Inner />
  218. </ActionProvider>
  219. </StateProvider>
  220. );
  221. });
  222. // Before execution — not loading
  223. const el = await findByTestId("loading");
  224. expect(el.textContent).toBe("false");
  225. const executePromise = executeFn({ action: "slowAction" });
  226. // During execution — loading (need to wait for Solid's reactivity to flush)
  227. await vi.waitFor(() => {
  228. expect(el.textContent).toBe("true");
  229. });
  230. resolveHandler();
  231. await executePromise;
  232. // After execution — no longer loading
  233. await vi.waitFor(() => {
  234. expect(el.textContent).toBe("false");
  235. });
  236. });
  237. it("registerHandler allows dynamic handler registration", async () => {
  238. const dynamicHandler = vi.fn().mockResolvedValue(undefined);
  239. const { actionsCtx } = withFullProviders({});
  240. actionsCtx.registerHandler("dynamicAction", dynamicHandler);
  241. await actionsCtx.execute({
  242. action: "dynamicAction",
  243. params: { x: 1 },
  244. });
  245. expect(dynamicHandler).toHaveBeenCalledWith({ x: 1 });
  246. });
  247. it("handler receives resolved params object", async () => {
  248. const handler = vi.fn().mockResolvedValue(undefined);
  249. const { actionsCtx } = withFullProviders({
  250. handlers: { myAction: handler },
  251. });
  252. await actionsCtx.execute({
  253. action: "myAction",
  254. params: { x: 1, y: "hello" },
  255. });
  256. expect(handler).toHaveBeenCalledWith({ x: 1, y: "hello" });
  257. });
  258. });
  259. describe("ActionProvider — validateForm", () => {
  260. it("writes { valid, errors } to state", async () => {
  261. const { stateCtx, actionsCtx, validationCtx } = withFullProviders({
  262. initialState: {},
  263. withValidation: true,
  264. });
  265. validationCtx!.registerField("/form/email", {
  266. checks: [{ type: "required", message: "Required" }],
  267. });
  268. await actionsCtx.execute({ action: "validateForm" });
  269. expect(stateCtx.get("/formValidation")).toEqual({
  270. valid: false,
  271. errors: { "/form/email": ["Required"] },
  272. });
  273. });
  274. it("warns without ValidationProvider", async () => {
  275. const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
  276. const { actionsCtx } = withFullProviders({
  277. withValidation: false,
  278. });
  279. await actionsCtx.execute({ action: "validateForm" });
  280. expect(warnSpy).toHaveBeenCalledWith(
  281. expect.stringContaining("validateForm action was dispatched"),
  282. );
  283. warnSpy.mockRestore();
  284. });
  285. });
  286. describe("useAction", () => {
  287. it("returns { execute, isLoading: false } before execution", () => {
  288. const result = withProviders(() => useAction({ action: "myAction" }), {
  289. handlers: { myAction: vi.fn() },
  290. });
  291. expect(typeof result.execute).toBe("function");
  292. expect(result.isLoading).toBe(false);
  293. });
  294. });