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 }) => (
{children}
);
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 }) => (
{children}
);
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 }) => (
{children}
);
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 }) => (
{children}
);
const { result } = renderHook(() => useStateStore(), { wrapper });
act(() => {
result.current.set("/user/name", "Alice");
});
expect((result.current.state.user as Record).name).toBe(
"Alice",
);
});
it("calls onStateChange callback with changes array on set", () => {
const onStateChange = vi.fn();
const wrapper = ({ children }: { children: React.ReactNode }) => (
{children}
);
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 }) => (
{children}
);
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 }) => (
{children}
);
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 }) => (
{children}
);
const { result } = renderHook(() => useStateValue("/user/name"), {
wrapper,
});
expect(result.current).toBe("John");
});
it("returns undefined for missing path", () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
{children}
);
const { result } = renderHook(() => useStateValue("/missing"), { wrapper });
expect(result.current).toBeUndefined();
});
it("updates when state changes", () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
{children}
);
const { result, rerender } = renderHook(
() => ({
store: useStateStore(),
value: useStateValue("/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 }) => (
{children}
);
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 }) => (
{children}
);
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 }) => (
{children}
);
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 }) => (
{children}
);
const { result } = renderHook(() => useStateValue("/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 }) => (
{children}
);
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 }) => (
{children}
);
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 }) => (
{children}
);
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 }) => (
{children}
);
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 }) => (
{children}
);
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);
});
});