| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332 |
- import { describe, it, expect, vi } from "vitest";
- import React from "react";
- import { renderHook, act } from "@testing-library/react";
- import { createStateStore } from "@json-render/core";
- import {
- StateProvider,
- useStateStore,
- useStateValue,
- useStateBinding,
- } from "./index";
- // ============================================================================
- // Uncontrolled mode (default)
- // ============================================================================
- describe("StateProvider (uncontrolled)", () => {
- it("provides initial state to children", () => {
- const wrapper = ({ children }: { children: React.ReactNode }) => (
- <StateProvider initialState={{ user: { name: "John" } }}>
- {children}
- </StateProvider>
- );
- const { result } = renderHook(() => useStateStore(), { wrapper });
- expect(result.current.state).toEqual({ user: { name: "John" } });
- });
- it("provides empty object when no initial state", () => {
- const wrapper = ({ children }: { children: React.ReactNode }) => (
- <StateProvider>{children}</StateProvider>
- );
- const { result } = renderHook(() => useStateStore(), { wrapper });
- expect(result.current.state).toEqual({});
- });
- });
- describe("useStateStore (uncontrolled)", () => {
- it("provides get function to retrieve values", () => {
- const wrapper = ({ children }: { children: React.ReactNode }) => (
- <StateProvider initialState={{ user: { name: "John" } }}>
- {children}
- </StateProvider>
- );
- const { result } = renderHook(() => useStateStore(), { wrapper });
- expect(result.current.get("/user/name")).toBe("John");
- });
- it("allows setting state at path with set function", () => {
- const wrapper = ({ children }: { children: React.ReactNode }) => (
- <StateProvider initialState={{}}>{children}</StateProvider>
- );
- const { result } = renderHook(() => useStateStore(), { wrapper });
- act(() => {
- result.current.set("/user/name", "Alice");
- });
- expect((result.current.state.user as Record<string, unknown>).name).toBe(
- "Alice",
- );
- });
- it("calls onStateChange callback with changes array on set", () => {
- const onStateChange = vi.fn();
- const wrapper = ({ children }: { children: React.ReactNode }) => (
- <StateProvider initialState={{}} onStateChange={onStateChange}>
- {children}
- </StateProvider>
- );
- const { result } = renderHook(() => useStateStore(), { wrapper });
- act(() => {
- result.current.set("/count", 42);
- });
- expect(onStateChange).toHaveBeenCalledTimes(1);
- expect(onStateChange).toHaveBeenCalledWith([{ path: "/count", value: 42 }]);
- });
- it("calls onStateChange callback once with all changes on update", () => {
- const onStateChange = vi.fn();
- const wrapper = ({ children }: { children: React.ReactNode }) => (
- <StateProvider initialState={{}} onStateChange={onStateChange}>
- {children}
- </StateProvider>
- );
- const { result } = renderHook(() => useStateStore(), { wrapper });
- act(() => {
- result.current.update({ "/name": "John", "/age": 30 });
- });
- expect(onStateChange).toHaveBeenCalledTimes(1);
- expect(onStateChange).toHaveBeenCalledWith([
- { path: "/name", value: "John" },
- { path: "/age", value: 30 },
- ]);
- });
- it("allows updating multiple values with update function", () => {
- const wrapper = ({ children }: { children: React.ReactNode }) => (
- <StateProvider initialState={{}}>{children}</StateProvider>
- );
- const { result } = renderHook(() => useStateStore(), { wrapper });
- act(() => {
- result.current.update({
- "/name": "John",
- "/age": 30,
- });
- });
- expect(result.current.state.name).toBe("John");
- expect(result.current.state.age).toBe(30);
- });
- });
- describe("useStateValue", () => {
- it("returns value at specified path", () => {
- const wrapper = ({ children }: { children: React.ReactNode }) => (
- <StateProvider initialState={{ user: { name: "John", age: 30 } }}>
- {children}
- </StateProvider>
- );
- const { result } = renderHook(() => useStateValue("/user/name"), {
- wrapper,
- });
- expect(result.current).toBe("John");
- });
- it("returns undefined for missing path", () => {
- const wrapper = ({ children }: { children: React.ReactNode }) => (
- <StateProvider initialState={{}}>{children}</StateProvider>
- );
- const { result } = renderHook(() => useStateValue("/missing"), { wrapper });
- expect(result.current).toBeUndefined();
- });
- it("updates when state changes", () => {
- const wrapper = ({ children }: { children: React.ReactNode }) => (
- <StateProvider initialState={{ count: 0 }}>{children}</StateProvider>
- );
- const { result, rerender } = renderHook(
- () => ({
- store: useStateStore(),
- value: useStateValue<number>("/count"),
- }),
- { wrapper },
- );
- expect(result.current.value).toBe(0);
- act(() => {
- result.current.store.set("/count", 5);
- });
- rerender();
- expect(result.current.value).toBe(5);
- });
- });
- describe("useStateBinding", () => {
- it("returns tuple with value and setter for path", () => {
- const wrapper = ({ children }: { children: React.ReactNode }) => (
- <StateProvider initialState={{ name: "John" }}>{children}</StateProvider>
- );
- const { result } = renderHook(() => useStateBinding("/name"), { wrapper });
- const [value, setValue] = result.current;
- expect(value).toBe("John");
- expect(typeof setValue).toBe("function");
- });
- it("setter updates the value", () => {
- const wrapper = ({ children }: { children: React.ReactNode }) => (
- <StateProvider initialState={{ name: "John" }}>{children}</StateProvider>
- );
- const { result, rerender } = renderHook(() => useStateBinding("/name"), {
- wrapper,
- });
- act(() => {
- const [, setValue] = result.current;
- setValue("Alice");
- });
- rerender();
- const [value] = result.current;
- expect(value).toBe("Alice");
- });
- });
- // ============================================================================
- // Controlled mode (external store)
- // ============================================================================
- describe("StateProvider (controlled mode)", () => {
- it("reads initial state from the external store", () => {
- const store = createStateStore({ count: 7 });
- const wrapper = ({ children }: { children: React.ReactNode }) => (
- <StateProvider store={store}>{children}</StateProvider>
- );
- const { result } = renderHook(() => useStateStore(), { wrapper });
- expect(result.current.state).toEqual({ count: 7 });
- expect(result.current.get("/count")).toBe(7);
- });
- it("re-renders when the external store updates", () => {
- const store = createStateStore({ count: 0 });
- const wrapper = ({ children }: { children: React.ReactNode }) => (
- <StateProvider store={store}>{children}</StateProvider>
- );
- const { result } = renderHook(() => useStateValue<number>("/count"), {
- wrapper,
- });
- expect(result.current).toBe(0);
- act(() => {
- store.set("/count", 42);
- });
- expect(result.current).toBe(42);
- });
- it("set() writes through to the external store", () => {
- const store = createStateStore({});
- const wrapper = ({ children }: { children: React.ReactNode }) => (
- <StateProvider store={store}>{children}</StateProvider>
- );
- const { result } = renderHook(() => useStateStore(), { wrapper });
- act(() => {
- result.current.set("/name", "Alice");
- });
- expect(store.getSnapshot().name).toBe("Alice");
- expect(result.current.state.name).toBe("Alice");
- });
- it("update() writes multiple values through to the external store", () => {
- const store = createStateStore({});
- const wrapper = ({ children }: { children: React.ReactNode }) => (
- <StateProvider store={store}>{children}</StateProvider>
- );
- const { result } = renderHook(() => useStateStore(), { wrapper });
- act(() => {
- result.current.update({ "/a": 1, "/b": 2 });
- });
- expect(store.getSnapshot().a).toBe(1);
- expect(store.getSnapshot().b).toBe(2);
- });
- it("does NOT call onStateChange when using an external store", () => {
- const store = createStateStore({});
- const onStateChange = vi.fn();
- const wrapper = ({ children }: { children: React.ReactNode }) => (
- <StateProvider store={store} onStateChange={onStateChange}>
- {children}
- </StateProvider>
- );
- const { result } = renderHook(() => useStateStore(), { wrapper });
- act(() => {
- result.current.set("/x", 99);
- });
- expect(onStateChange).not.toHaveBeenCalled();
- });
- it("ignores initialState when an external store is provided", () => {
- const store = createStateStore({ fromStore: true });
- const wrapper = ({ children }: { children: React.ReactNode }) => (
- <StateProvider store={store} initialState={{ fromProp: true }}>
- {children}
- </StateProvider>
- );
- const { result } = renderHook(() => useStateStore(), { wrapper });
- expect(result.current.state).toEqual({ fromStore: true });
- expect(result.current.get("/fromProp")).toBeUndefined();
- });
- });
- // ============================================================================
- // initialState sync fast-path
- // ============================================================================
- describe("StateProvider initialState sync", () => {
- it("does not re-flatten when initialState reference is unchanged", () => {
- const initialState = { count: 0 };
- const wrapper = ({ children }: { children: React.ReactNode }) => (
- <StateProvider initialState={initialState}>{children}</StateProvider>
- );
- const { result, rerender } = renderHook(() => useStateStore(), { wrapper });
- expect(result.current.state).toEqual({ count: 0 });
- act(() => {
- result.current.set("/count", 5);
- });
- rerender();
- expect(result.current.state.count).toBe(5);
- });
- });
|