index.test.tsx 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. import { describe, it, expect, vi } from "vitest";
  2. import React from "react";
  3. import { renderHook, act } from "@testing-library/react";
  4. import { createStateStore } from "@json-render/core";
  5. import {
  6. StateProvider,
  7. useStateStore,
  8. useStateValue,
  9. useStateBinding,
  10. } from "./index";
  11. // ============================================================================
  12. // Uncontrolled mode (default)
  13. // ============================================================================
  14. describe("StateProvider (uncontrolled)", () => {
  15. it("provides initial state to children", () => {
  16. const wrapper = ({ children }: { children: React.ReactNode }) => (
  17. <StateProvider initialState={{ user: { name: "John" } }}>
  18. {children}
  19. </StateProvider>
  20. );
  21. const { result } = renderHook(() => useStateStore(), { wrapper });
  22. expect(result.current.state).toEqual({ user: { name: "John" } });
  23. });
  24. it("provides empty object when no initial state", () => {
  25. const wrapper = ({ children }: { children: React.ReactNode }) => (
  26. <StateProvider>{children}</StateProvider>
  27. );
  28. const { result } = renderHook(() => useStateStore(), { wrapper });
  29. expect(result.current.state).toEqual({});
  30. });
  31. });
  32. describe("useStateStore (uncontrolled)", () => {
  33. it("provides get function to retrieve values", () => {
  34. const wrapper = ({ children }: { children: React.ReactNode }) => (
  35. <StateProvider initialState={{ user: { name: "John" } }}>
  36. {children}
  37. </StateProvider>
  38. );
  39. const { result } = renderHook(() => useStateStore(), { wrapper });
  40. expect(result.current.get("/user/name")).toBe("John");
  41. });
  42. it("allows setting state at path with set function", () => {
  43. const wrapper = ({ children }: { children: React.ReactNode }) => (
  44. <StateProvider initialState={{}}>{children}</StateProvider>
  45. );
  46. const { result } = renderHook(() => useStateStore(), { wrapper });
  47. act(() => {
  48. result.current.set("/user/name", "Alice");
  49. });
  50. expect((result.current.state.user as Record<string, unknown>).name).toBe(
  51. "Alice",
  52. );
  53. });
  54. it("calls onStateChange callback with changes array on set", () => {
  55. const onStateChange = vi.fn();
  56. const wrapper = ({ children }: { children: React.ReactNode }) => (
  57. <StateProvider initialState={{}} onStateChange={onStateChange}>
  58. {children}
  59. </StateProvider>
  60. );
  61. const { result } = renderHook(() => useStateStore(), { wrapper });
  62. act(() => {
  63. result.current.set("/count", 42);
  64. });
  65. expect(onStateChange).toHaveBeenCalledTimes(1);
  66. expect(onStateChange).toHaveBeenCalledWith([{ path: "/count", value: 42 }]);
  67. });
  68. it("calls onStateChange callback once with all changes on update", () => {
  69. const onStateChange = vi.fn();
  70. const wrapper = ({ children }: { children: React.ReactNode }) => (
  71. <StateProvider initialState={{}} onStateChange={onStateChange}>
  72. {children}
  73. </StateProvider>
  74. );
  75. const { result } = renderHook(() => useStateStore(), { wrapper });
  76. act(() => {
  77. result.current.update({ "/name": "John", "/age": 30 });
  78. });
  79. expect(onStateChange).toHaveBeenCalledTimes(1);
  80. expect(onStateChange).toHaveBeenCalledWith([
  81. { path: "/name", value: "John" },
  82. { path: "/age", value: 30 },
  83. ]);
  84. });
  85. it("allows updating multiple values with update function", () => {
  86. const wrapper = ({ children }: { children: React.ReactNode }) => (
  87. <StateProvider initialState={{}}>{children}</StateProvider>
  88. );
  89. const { result } = renderHook(() => useStateStore(), { wrapper });
  90. act(() => {
  91. result.current.update({
  92. "/name": "John",
  93. "/age": 30,
  94. });
  95. });
  96. expect(result.current.state.name).toBe("John");
  97. expect(result.current.state.age).toBe(30);
  98. });
  99. });
  100. describe("useStateValue", () => {
  101. it("returns value at specified path", () => {
  102. const wrapper = ({ children }: { children: React.ReactNode }) => (
  103. <StateProvider initialState={{ user: { name: "John", age: 30 } }}>
  104. {children}
  105. </StateProvider>
  106. );
  107. const { result } = renderHook(() => useStateValue("/user/name"), {
  108. wrapper,
  109. });
  110. expect(result.current).toBe("John");
  111. });
  112. it("returns undefined for missing path", () => {
  113. const wrapper = ({ children }: { children: React.ReactNode }) => (
  114. <StateProvider initialState={{}}>{children}</StateProvider>
  115. );
  116. const { result } = renderHook(() => useStateValue("/missing"), { wrapper });
  117. expect(result.current).toBeUndefined();
  118. });
  119. it("updates when state changes", () => {
  120. const wrapper = ({ children }: { children: React.ReactNode }) => (
  121. <StateProvider initialState={{ count: 0 }}>{children}</StateProvider>
  122. );
  123. const { result, rerender } = renderHook(
  124. () => ({
  125. store: useStateStore(),
  126. value: useStateValue<number>("/count"),
  127. }),
  128. { wrapper },
  129. );
  130. expect(result.current.value).toBe(0);
  131. act(() => {
  132. result.current.store.set("/count", 5);
  133. });
  134. rerender();
  135. expect(result.current.value).toBe(5);
  136. });
  137. });
  138. describe("useStateBinding", () => {
  139. it("returns tuple with value and setter for path", () => {
  140. const wrapper = ({ children }: { children: React.ReactNode }) => (
  141. <StateProvider initialState={{ name: "John" }}>{children}</StateProvider>
  142. );
  143. const { result } = renderHook(() => useStateBinding("/name"), { wrapper });
  144. const [value, setValue] = result.current;
  145. expect(value).toBe("John");
  146. expect(typeof setValue).toBe("function");
  147. });
  148. it("setter updates the value", () => {
  149. const wrapper = ({ children }: { children: React.ReactNode }) => (
  150. <StateProvider initialState={{ name: "John" }}>{children}</StateProvider>
  151. );
  152. const { result, rerender } = renderHook(() => useStateBinding("/name"), {
  153. wrapper,
  154. });
  155. act(() => {
  156. const [, setValue] = result.current;
  157. setValue("Alice");
  158. });
  159. rerender();
  160. const [value] = result.current;
  161. expect(value).toBe("Alice");
  162. });
  163. });
  164. // ============================================================================
  165. // Controlled mode (external store)
  166. // ============================================================================
  167. describe("StateProvider (controlled mode)", () => {
  168. it("reads initial state from the external store", () => {
  169. const store = createStateStore({ count: 7 });
  170. const wrapper = ({ children }: { children: React.ReactNode }) => (
  171. <StateProvider store={store}>{children}</StateProvider>
  172. );
  173. const { result } = renderHook(() => useStateStore(), { wrapper });
  174. expect(result.current.state).toEqual({ count: 7 });
  175. expect(result.current.get("/count")).toBe(7);
  176. });
  177. it("re-renders when the external store updates", () => {
  178. const store = createStateStore({ count: 0 });
  179. const wrapper = ({ children }: { children: React.ReactNode }) => (
  180. <StateProvider store={store}>{children}</StateProvider>
  181. );
  182. const { result } = renderHook(() => useStateValue<number>("/count"), {
  183. wrapper,
  184. });
  185. expect(result.current).toBe(0);
  186. act(() => {
  187. store.set("/count", 42);
  188. });
  189. expect(result.current).toBe(42);
  190. });
  191. it("set() writes through to the external store", () => {
  192. const store = createStateStore({});
  193. const wrapper = ({ children }: { children: React.ReactNode }) => (
  194. <StateProvider store={store}>{children}</StateProvider>
  195. );
  196. const { result } = renderHook(() => useStateStore(), { wrapper });
  197. act(() => {
  198. result.current.set("/name", "Alice");
  199. });
  200. expect(store.getSnapshot().name).toBe("Alice");
  201. expect(result.current.state.name).toBe("Alice");
  202. });
  203. it("update() writes multiple values through to the external store", () => {
  204. const store = createStateStore({});
  205. const wrapper = ({ children }: { children: React.ReactNode }) => (
  206. <StateProvider store={store}>{children}</StateProvider>
  207. );
  208. const { result } = renderHook(() => useStateStore(), { wrapper });
  209. act(() => {
  210. result.current.update({ "/a": 1, "/b": 2 });
  211. });
  212. expect(store.getSnapshot().a).toBe(1);
  213. expect(store.getSnapshot().b).toBe(2);
  214. });
  215. it("does NOT call onStateChange when using an external store", () => {
  216. const store = createStateStore({});
  217. const onStateChange = vi.fn();
  218. const wrapper = ({ children }: { children: React.ReactNode }) => (
  219. <StateProvider store={store} onStateChange={onStateChange}>
  220. {children}
  221. </StateProvider>
  222. );
  223. const { result } = renderHook(() => useStateStore(), { wrapper });
  224. act(() => {
  225. result.current.set("/x", 99);
  226. });
  227. expect(onStateChange).not.toHaveBeenCalled();
  228. });
  229. it("ignores initialState when an external store is provided", () => {
  230. const store = createStateStore({ fromStore: true });
  231. const wrapper = ({ children }: { children: React.ReactNode }) => (
  232. <StateProvider store={store} initialState={{ fromProp: true }}>
  233. {children}
  234. </StateProvider>
  235. );
  236. const { result } = renderHook(() => useStateStore(), { wrapper });
  237. expect(result.current.state).toEqual({ fromStore: true });
  238. expect(result.current.get("/fromProp")).toBeUndefined();
  239. });
  240. });
  241. // ============================================================================
  242. // initialState sync fast-path
  243. // ============================================================================
  244. describe("StateProvider initialState sync", () => {
  245. it("does not re-flatten when initialState reference is unchanged", () => {
  246. const initialState = { count: 0 };
  247. const wrapper = ({ children }: { children: React.ReactNode }) => (
  248. <StateProvider initialState={initialState}>{children}</StateProvider>
  249. );
  250. const { result, rerender } = renderHook(() => useStateStore(), { wrapper });
  251. expect(result.current.state).toEqual({ count: 0 });
  252. act(() => {
  253. result.current.set("/count", 5);
  254. });
  255. rerender();
  256. expect(result.current.state.count).toBe(5);
  257. });
  258. });