Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions packages/react-ghostmaker/src/context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { QueryFunctionContext } from "@tanstack/react-query";
import { expect, test } from "vitest";
import {
forwardQueryContext,
getQueryContext,
ghostFnContext,
} from "./context.ts";

const queryContext = {
queryKey: ["react-ghostmaker", "Target"],
} as unknown as QueryFunctionContext;

test("getQueryContext() returns undefined outside of a query", () => {
expect(getQueryContext()).toBeUndefined();
});

test("getQueryContext() returns the context of the current query", () => {
const result = ghostFnContext.run({ query: queryContext }, () =>
getQueryContext(),
);

expect(result).toBe(queryContext);
});

test("forwardQueryContext() returns the result of the function", () => {
expect(forwardQueryContext(() => "result")).toBe("result");
});

test("forwardQueryContext() provides the query context to the function", () => {
const result = ghostFnContext.run({ query: queryContext }, () =>
forwardQueryContext(() => getQueryContext()),
);

expect(result).toBe(queryContext);
});
20 changes: 20 additions & 0 deletions packages/react-ghostmaker/src/hash.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { expect, test } from "vitest";
import { hashObject } from "./hash.ts";

test("returns equal hashes for objects with equal content", () => {
expect(hashObject({ a: 1, b: [1, 2] })).toBe(hashObject({ a: 1, b: [1, 2] }));
});

test("returns different hashes for objects with different content", () => {
expect(hashObject({ a: 1 })).not.toBe(hashObject({ a: 2 }));
});

test("caches the hash per object instance", () => {
const obj = { a: 1 };
const initialHash = hashObject(obj);

obj.a = 2;

expect(hashObject(obj)).toBe(initialHash);
expect(hashObject({ a: 2 })).not.toBe(initialHash);
});
40 changes: 40 additions & 0 deletions packages/react-ghostmaker/src/invalidate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { QueryClient } from "@tanstack/react-query";
import { beforeEach, expect, test } from "vitest";
import { invalidateGhosts } from "./invalidate.ts";

const queryClient = new QueryClient();

const keys = {
root: ["react-ghostmaker"],
target: ["react-ghostmaker", "Target"],
chain: ["react-ghostmaker", "Target", "getA"],
dependent: ["react-ghostmaker", "Target", "getA", "getB"],
sibling: ["react-ghostmaker", "Target", "getC"],
other: ["react-ghostmaker", "Other"],
};

const isInvalidated = (key: readonly unknown[]) =>
queryClient.getQueryState(key)?.isInvalidated;

beforeEach(() => {
queryClient.clear();
for (const key of Object.values(keys)) {
queryClient.setQueryData(key, "data");
}
});

test("invalidates the ghost, its dependents and its ancestors", async () => {
await invalidateGhosts(queryClient, keys.chain);

expect(isInvalidated(keys.root)).toBe(true);
expect(isInvalidated(keys.target)).toBe(true);
expect(isInvalidated(keys.chain)).toBe(true);
expect(isInvalidated(keys.dependent)).toBe(true);
});

test("does not invalidate siblings or other targets", async () => {
await invalidateGhosts(queryClient, keys.chain);

expect(isInvalidated(keys.sibling)).toBe(false);
expect(isInvalidated(keys.other)).toBe(false);
});
220 changes: 219 additions & 1 deletion packages/react-ghostmaker/src/makeGhost.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { cleanup, render } from "@testing-library/react";
import { act, cleanup, render } from "@testing-library/react";
import { describe, expect, test, vitest, beforeEach, afterEach } from "vitest";
import {
CustomerDetailed,
Expand All @@ -15,6 +15,13 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { invalidateGhosts } from "./invalidate.ts";
import { makeGhost } from "./makeGhost.ts";
import { cleanupTargetHashes } from "./useGhostChain.ts";
import { Component, Suspense, type ReactNode } from "react";

class FailingService {
public async fail(): Promise<string> {
throw new Error("Service failed");
}
}

beforeEach(() => {
vitest.useFakeTimers();
Expand Down Expand Up @@ -110,6 +117,22 @@ describe("Await", () => {
]);
expect(customerName).toBeUndefined();
});
test("rejects when a method rejects", async () => {
await expect(makeGhost(new FailingService()).fail()).rejects.toThrow(
"Service failed",
);
});

test("resolves property access on the target", async () => {
expect(await makeGhost({ name: "Plain" }).name).toBe("Plain");
});

test("converts to a string without resolving", () => {
expect(String(ProjectGhost.ofId("Project A").getDetailed())).toBe(
"ReactGhostmakerFunction",
);
expect(projectMocks.getDetailed).not.toHaveBeenCalled();
});
});

describe("Hooks", () => {
Expand Down Expand Up @@ -232,6 +255,201 @@ describe("Hooks", () => {
expect(transform).toHaveBeenCalledWith(undefined);
});

test("generates query keys from the ghost chain", async () => {
await renderHookWithSuspense(() =>
ProjectGhost.ofId("Project A")
.getDetailed()
.customer.getDetailed()
.getName()
.use(),
);

const projectKey = ["react-ghostmaker", "Project", "ofId", "Project A"];
const queryKeys = queryClient
.getQueryCache()
.findAll()
.map((query) => query.queryKey);

expect(queryKeys).toHaveLength(4);
expect(queryKeys).toEqual(
expect.arrayContaining([
projectKey,
[...projectKey, "getDetailed"],
[...projectKey, "getDetailed", "customer", "getDetailed"],
[...projectKey, "getDetailed", "customer", "getDetailed", "getName"],
]),
);
});

test("passes query options to all queries of the chain", async () => {
await renderHookWithSuspense(() =>
ProjectGhost.ofId("Project A")
.getDetailed()
.getName()
.use({ meta: { source: "test" } }),
);

const queries = queryClient.getQueryCache().findAll();

expect(queries).toHaveLength(3);
for (const query of queries) {
expect(query.meta).toEqual({ source: "test" });
}
});

test("re-runs transform only when its dependencies change", async () => {
const transform = vitest.fn((name: string) => name);
let dependency = 1;

const { rerender } = await renderHookWithSuspense(() =>
ProjectGhost.ofId("Project A")
.getDetailed()
.name.transform(transform, [dependency])
.use(),
);
expect(transform).toHaveBeenCalledTimes(1);

rerender();
expect(transform).toHaveBeenCalledTimes(1);

dependency = 2;
rerender();
expect(transform).toHaveBeenCalledTimes(2);
});

test("throws errors to the next error boundary", async () => {
const consoleError = vitest
.spyOn(console, "error")
.mockImplementation(() => {
// silence React error logging
});

class ErrorBoundary extends Component<
{ children: ReactNode },
{ error?: Error }
> {
public state: { error?: Error } = {};

public static getDerivedStateFromError(error: Error) {
return { error };
}

public render() {
return this.state.error
? `Error: ${this.state.error.message}`
: this.props.children;
}
}

const FailingComponent = () => (
<>{makeGhost(new FailingService()).fail().use({ retry: false })}</>
);

const ui = render(
<QueryClientProvider client={queryClient}>
<ErrorBoundary>
<FailingComponent />
</ErrorBoundary>
</QueryClientProvider>,
);

await Promise.all([
ui.findByText("Error: Service failed"),
vitest.runOnlyPendingTimersAsync(),
]);

consoleError.mockRestore();
});

describe("Render", () => {
async function renderWithSuspense(node: ReactNode) {
const ui = render(
<QueryClientProvider client={queryClient}>
<Suspense fallback="loading">{node}</Suspense>
</QueryClientProvider>,
);
expect(ui.container.textContent).toBe("loading");

// each query of the chain starts its sleep timer after the previous one
for (let i = 0; i < 3; i++) {
await act(() => vitest.runOnlyPendingTimersAsync());
}

return ui;
}

test("ghost.render() renders the resolved value", async () => {
const ui = await renderWithSuspense(
ProjectGhost.ofId("Project A").getDetailed().name.render(),
);

expect(ui.container.textContent).toBe("Project Project A");
});

test("ghost.render() renders the transformed value", async () => {
const ui = await renderWithSuspense(
ProjectGhost.ofId("Project A")
.getDetailed()
.render((project) => <h1>{project.name.toUpperCase()}</h1>),
);

expect(ui.container.querySelector("h1")?.textContent).toBe(
"PROJECT PROJECT A",
);
});

test("ghost.render() is lazy", () => {
ProjectGhost.ofId("Project A").getDetailed().name.render();
expect(projectMocks.getDetailed).not.toHaveBeenCalled();
});
});

describe("Reset", () => {
test("useGhost.reset() triggers re-execution of all async methods", async () => {
const { result } = await renderHookWithSuspense(() =>
ProjectGhost.ofId("Project A")
.getDetailed()
.customer.getDetailed()
.getName()
.useGhost(),
);
expect(projectMocks.getDetailed).toHaveBeenCalledTimes(1);
expect(customerMocks.getDetailed).toHaveBeenCalledTimes(1);
expect(customerMocks.getName).toHaveBeenCalledTimes(1);

result.current?.reset();
await vitest.runOnlyPendingTimersAsync();

expect(projectMocks.getDetailed).toHaveBeenCalledTimes(2);
expect(customerMocks.getDetailed).toHaveBeenCalledTimes(2);
expect(customerMocks.getName).toHaveBeenCalledTimes(2);
});

test("ghost.reset() removes cached data of the ghost chain", async () => {
const ghost = ProjectGhost.ofId("Project A").getDetailed();
const unrelatedGhost = CustomerGhost.ofId("Customer A");

await renderHookWithSuspense(() => [ghost.use(), unrelatedGhost.use()]);

const projectKey = ["react-ghostmaker", "Project", "ofId", "Project A"];
const customerKey = [
"react-ghostmaker",
"Customer",
"ofId",
"Customer A",
];
cleanup();

await ghost.reset(queryClient);

expect(queryClient.getQueryData(projectKey)).toBeUndefined();
expect(
queryClient.getQueryData([...projectKey, "getDetailed"]),
).toBeUndefined();
expect(queryClient.getQueryData(customerKey)).toBeDefined();
});
});

describe("Invalidation", () => {
test("useGhost.invalidate() triggers re-execution of all async methods", async () => {
const { result } = await renderHookWithSuspense(() =>
Expand Down
27 changes: 26 additions & 1 deletion packages/react-ghostmaker/src/maybeGhost/asGhost.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { asGhostProps } from "../maybeGhost/index.ts";
import { asGhost, asGhostProps } from "../maybeGhost/index.ts";
import { makeGhost } from "../makeGhost.ts";
import { isReactGhost } from "../types.ts";
import { expect, test } from "vitest";

Expand Down Expand Up @@ -57,3 +58,27 @@ test("does not make Ghosts of excluded props", () => {
expect(ghostProps.secondGhost).not.toSatisfy(isReactGhost);
expect(ghostProps.thirdGhost).not.toSatisfy(isReactGhost);
});

test("asGhost() returns ghosts as they are", () => {
const ghost = makeGhost({ value: "hello" });

expect(asGhost(ghost)).toBe(ghost);
});

test("asGhost() makes a ghost of values", async () => {
const ghost = asGhost({ value: "hello" });

expect(ghost).toSatisfy(isReactGhost);
expect(await ghost.value).toBe("hello");
});

test("asGhostProps() keeps ghost props as they are", () => {
const ghost = makeGhost("hello");

const ghostProps = asGhostProps<Props>({
first: ghost as unknown as string,
third: "!",
});

expect(ghostProps.firstGhost).toBe(ghost);
});
Loading
Loading