From 1612862d940bece94e5f5906e63f95556a9f0624 Mon Sep 17 00:00:00 2001 From: Marco Falkenberg Date: Mon, 28 Sep 2026 16:20:59 +0200 Subject: [PATCH 1/2] test: add missing unit tests Cover query key generation, meta data lookup (incl. dynamic models and name conflicts), ghost chain resolution, invalidation/reset filters, hashing, query context, render(), reset(), query options, transform dependencies and error propagation. The former queries.test.ts tested metaData.ts and is renamed accordingly. Co-Authored-By: Claude Opus 5.5 --- packages/react-ghostmaker/src/context.test.ts | 35 +++ packages/react-ghostmaker/src/hash.test.ts | 20 ++ .../react-ghostmaker/src/invalidate.test.ts | 40 ++++ .../react-ghostmaker/src/makeGhost.test.tsx | 220 +++++++++++++++++- .../src/maybeGhost/asGhost.test.ts | 27 ++- .../react-ghostmaker/src/metaData.test.ts | 199 ++++++++++++++++ packages/react-ghostmaker/src/queries.test.ts | 210 +++++++++++++++-- packages/react-ghostmaker/src/reset.test.ts | 37 +++ .../src/resolveGhostChain.test.ts | 108 +++++++++ packages/react-ghostmaker/src/types.test.ts | 21 ++ .../src/withEachGhost.test.ts | 38 +++ 11 files changed, 932 insertions(+), 23 deletions(-) create mode 100644 packages/react-ghostmaker/src/context.test.ts create mode 100644 packages/react-ghostmaker/src/hash.test.ts create mode 100644 packages/react-ghostmaker/src/invalidate.test.ts create mode 100644 packages/react-ghostmaker/src/metaData.test.ts create mode 100644 packages/react-ghostmaker/src/reset.test.ts create mode 100644 packages/react-ghostmaker/src/resolveGhostChain.test.ts create mode 100644 packages/react-ghostmaker/src/types.test.ts create mode 100644 packages/react-ghostmaker/src/withEachGhost.test.ts diff --git a/packages/react-ghostmaker/src/context.test.ts b/packages/react-ghostmaker/src/context.test.ts new file mode 100644 index 0000000..d999188 --- /dev/null +++ b/packages/react-ghostmaker/src/context.test.ts @@ -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); +}); diff --git a/packages/react-ghostmaker/src/hash.test.ts b/packages/react-ghostmaker/src/hash.test.ts new file mode 100644 index 0000000..56ab383 --- /dev/null +++ b/packages/react-ghostmaker/src/hash.test.ts @@ -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); +}); diff --git a/packages/react-ghostmaker/src/invalidate.test.ts b/packages/react-ghostmaker/src/invalidate.test.ts new file mode 100644 index 0000000..0358754 --- /dev/null +++ b/packages/react-ghostmaker/src/invalidate.test.ts @@ -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); +}); diff --git a/packages/react-ghostmaker/src/makeGhost.test.tsx b/packages/react-ghostmaker/src/makeGhost.test.tsx index e206dc7..49a949d 100644 --- a/packages/react-ghostmaker/src/makeGhost.test.tsx +++ b/packages/react-ghostmaker/src/makeGhost.test.tsx @@ -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, @@ -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 { + throw new Error("Service failed"); + } +} beforeEach(() => { vitest.useFakeTimers(); @@ -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", () => { @@ -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( + + + + + , + ); + + await Promise.all([ + ui.findByText("Error: Service failed"), + vitest.runOnlyPendingTimersAsync(), + ]); + + consoleError.mockRestore(); + }); + + describe("Render", () => { + async function renderWithSuspense(node: ReactNode) { + const ui = render( + + {node} + , + ); + 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) =>

{project.name.toUpperCase()}

), + ); + + 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(() => diff --git a/packages/react-ghostmaker/src/maybeGhost/asGhost.test.ts b/packages/react-ghostmaker/src/maybeGhost/asGhost.test.ts index c169375..0778222 100644 --- a/packages/react-ghostmaker/src/maybeGhost/asGhost.test.ts +++ b/packages/react-ghostmaker/src/maybeGhost/asGhost.test.ts @@ -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"; @@ -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({ + first: ghost as unknown as string, + third: "!", + }); + + expect(ghostProps.firstGhost).toBe(ghost); +}); diff --git a/packages/react-ghostmaker/src/metaData.test.ts b/packages/react-ghostmaker/src/metaData.test.ts new file mode 100644 index 0000000..93e3279 --- /dev/null +++ b/packages/react-ghostmaker/src/metaData.test.ts @@ -0,0 +1,199 @@ +import { + getMetaData, + getModelId, + getModelName, + GhostMakerModel, + ghostMakerModel, +} from "./metaData.ts"; +import type * as MetaDataModule from "./metaData.ts"; +import { afterEach, describe, expect, test, vitest } from "vitest"; + +@GhostMakerModel({ + name: "TheGrandParent", + getId: (grandParent) => grandParent.id, +}) +class GrandParent { + public readonly id: string; + + public constructor(id: string) { + this.id = id; + } +} + +@GhostMakerModel({ + name: "TheParent", +}) +class Parent extends GrandParent {} + +@GhostMakerModel({ + name: "TheChild", +}) +class Child extends Parent {} + +@GhostMakerModel({ + name: "OnlyName", +}) +class OnlyName {} + +@GhostMakerModel({ + name: "OverridingId", + getId: (model) => `overridden-${model.id}`, +}) +class OverridingId extends GrandParent {} + +class Undecorated {} + +class UndecoratedChild extends Parent {} + +afterEach(() => { + vitest.restoreAllMocks(); +}); + +test("getMetaData returns correct names", () => { + expect(getMetaData(new Child("id"))?.name).toBe("TheChild"); + expect(getMetaData(new Parent("id"))?.name).toBe("TheParent"); + expect(getMetaData(Parent)?.name).toBe("TheParent"); + expect(getMetaData(Child)?.name).toBe("TheChild"); +}); + +test("getMetaData returns getId function", () => { + expect(getMetaData(new Child("id"))?.getId).toBeDefined(); + expect(getMetaData(new Parent("id"))?.getId).toBeDefined(); + expect(getMetaData(Parent)?.getId).toBeDefined(); + expect(getMetaData(Child)?.getId).toBeDefined(); +}); + +describe("getModelName()", () => { + test("returns the name of a decorated model", () => { + expect(getModelName(new GrandParent("id"))).toBe("TheGrandParent"); + expect(getModelName(GrandParent)).toBe("TheGrandParent"); + }); + + test("returns the name of a model without getId", () => { + expect(getModelName(new OnlyName())).toBe("OnlyName"); + }); + + test("returns the nearest name of an undecorated subclass", () => { + expect(getModelName(new UndecoratedChild("id"))).toBe("TheParent"); + }); + + test("returns undefined for undecorated values", () => { + expect(getModelName(new Undecorated())).toBeUndefined(); + expect(getModelName(Undecorated)).toBeUndefined(); + expect(getModelName({})).toBeUndefined(); + expect(getModelName("string")).toBeUndefined(); + expect(getModelName(42)).toBeUndefined(); + expect(getModelName(null)).toBeUndefined(); + expect(getModelName(undefined)).toBeUndefined(); + }); +}); + +describe("getModelId()", () => { + test("returns the id of a decorated model", () => { + expect(getModelId(new GrandParent("gp"))).toBe("gp"); + }); + + test("uses getId inherited from a parent class", () => { + expect(getModelId(new Child("child"))).toBe("child"); + expect(getModelId(new UndecoratedChild("undecorated"))).toBe("undecorated"); + }); + + test("prefers getId of the subclass over the parent class", () => { + expect(getModelId(new OverridingId("id"))).toBe("overridden-id"); + }); + + test("returns undefined for models without getId", () => { + expect(getModelId(new OnlyName())).toBeUndefined(); + }); + + test("returns undefined for undecorated values", () => { + expect(getModelId(new Undecorated())).toBeUndefined(); + expect(getModelId({ id: "id" })).toBeUndefined(); + expect(getModelId("string")).toBeUndefined(); + expect(getModelId(undefined)).toBeUndefined(); + }); +}); + +describe("ghostMakerModel() (dynamic models)", () => { + interface DynamicThing { + kind: "dynamic-thing"; + key: string; + } + + const isDynamicThing = (something: unknown): something is DynamicThing => + typeof something === "object" && + something !== null && + "kind" in something && + something.kind === "dynamic-thing"; + + ghostMakerModel((something) => { + if (isDynamicThing(something)) { + return { + name: "DynamicThing", + getId: (thing) => (thing as unknown as DynamicThing).key, + }; + } + }); + + ghostMakerModel((something) => { + if (something instanceof GrandParent) { + return { name: "NeverUsedForDecoratedModels" }; + } + }); + + test("provides meta data for plain objects", () => { + const thing: DynamicThing = { kind: "dynamic-thing", key: "k1" }; + + expect(getModelName(thing)).toBe("DynamicThing"); + expect(getModelId(thing)).toBe("k1"); + }); + + test("is not used when the value does not match", () => { + expect(getModelName({ kind: "other" })).toBeUndefined(); + }); + + test("decorator meta data takes precedence over dynamic models", () => { + expect(getModelName(new GrandParent("id"))).toBe("TheGrandParent"); + }); +}); + +describe("name conflicts", () => { + test("warns in development when a name is registered twice", () => { + const warn = vitest.spyOn(console, "warn").mockImplementation(() => { + // silence + }); + + @GhostMakerModel({ name: "Conflicting" }) + class First {} + + expect(warn).not.toHaveBeenCalled(); + + @GhostMakerModel({ name: "Conflicting" }) + class Second {} + + expect(warn).toHaveBeenCalledExactlyOnceWith( + expect.stringContaining('"Conflicting" is already registered'), + ); + expect(getModelName(new First())).toBe("Conflicting"); + expect(getModelName(new Second())).toBe("Conflicting"); + }); + + test("throws in production when a name is registered twice", async () => { + vitest.stubEnv("NODE_ENV", "production"); + vitest.resetModules(); + + try { + const { GhostMakerModel: ProdGhostMakerModel } = + await vitest.importActual("./metaData.ts"); + + const decorate = ProdGhostMakerModel({ name: "ProdConflicting" }); + decorate(class First {}); + + expect(() => decorate(class Second {})).toThrow( + '"ProdConflicting" is already registered', + ); + } finally { + vitest.unstubAllEnvs(); + } + }); +}); diff --git a/packages/react-ghostmaker/src/queries.test.ts b/packages/react-ghostmaker/src/queries.test.ts index 9a85e02..4ca871d 100644 --- a/packages/react-ghostmaker/src/queries.test.ts +++ b/packages/react-ghostmaker/src/queries.test.ts @@ -1,11 +1,13 @@ -import { getMetaData, GhostMakerModel } from "./metaData.ts"; -import { expect, test } from "vitest"; +import { describe, expect, test } from "vitest"; +import { queries } from "./queries.ts"; +import { GhostMakerModel } from "./metaData.ts"; +import { registerModelIdentifier } from "./modelIdentifier.ts"; @GhostMakerModel({ - name: "TheGrandParent", - getId: (grandParent) => grandParent.id, + name: "QueryModel", + getId: (model) => model.id, }) -class GrandParent { +class QueryModel { public readonly id: string; public constructor(id: string) { @@ -14,25 +16,191 @@ class GrandParent { } @GhostMakerModel({ - name: "TheParent", + name: "NamedOnly", }) -class Parent extends GrandParent {} +class NamedOnly {} -@GhostMakerModel({ - name: "TheChild", -}) -class Child extends Parent {} +class PlainClass {} + +class LegacyModel { + public readonly legacyId: string; + + public constructor(legacyId: string) { + this.legacyId = legacyId; + } +} + +registerModelIdentifier((model) => + model instanceof LegacyModel ? `legacy-${model.legacyId}` : undefined, +); + +registerModelIdentifier((model) => + model instanceof LegacyModel ? "second" : undefined, +); -test("getMetaData returns correct names", () => { - expect(getMetaData(new Child("id"))?.name).toBe("TheChild"); - expect(getMetaData(new Parent("id"))?.name).toBe("TheParent"); - expect(getMetaData(Parent)?.name).toBe("TheParent"); - expect(getMetaData(Child)?.name).toBe("TheChild"); +describe("queries.ghostmaker()", () => { + test("returns the root query key", () => { + expect(queries.ghostmaker()).toEqual(["react-ghostmaker"]); + }); }); -test("getMetaData returns getId function", () => { - expect(getMetaData(new Child("id"))?.getId).toBeDefined(); - expect(getMetaData(new Parent("id"))?.getId).toBeDefined(); - expect(getMetaData(Parent)?.getId).toBeDefined(); - expect(getMetaData(Child)?.getId).toBeDefined(); +describe("queries.target()", () => { + test.each([ + ["string", "string"], + [42, "42"], + [true, "true"], + [null, "null"], + [undefined, "undefined"], + ])("uses primitive %s as is", (target, expected) => { + expect(queries.target(target)).toEqual(["react-ghostmaker", expected]); + }); + + test("uses model name and id of decorated models", () => { + expect(queries.target(new QueryModel("m1"))).toEqual([ + "react-ghostmaker", + "QueryModel@m1", + ]); + }); + + test("uses model name of decorated model classes", () => { + expect(queries.target(QueryModel)).toEqual([ + "react-ghostmaker", + "QueryModel", + ]); + }); + + test("uses model name of decorated models without id", () => { + expect(queries.target(new NamedOnly())).toEqual([ + "react-ghostmaker", + "NamedOnly", + ]); + }); + + test("uses class name of undecorated classes and instances", () => { + expect(queries.target(PlainClass)).toEqual([ + "react-ghostmaker", + "PlainClass", + ]); + expect(queries.target(new PlainClass())).toEqual([ + "react-ghostmaker", + "PlainClass", + ]); + }); + + test("uses function name of functions", () => { + function someFunction() { + // noop + } + expect(queries.target(someFunction)).toEqual([ + "react-ghostmaker", + "someFunction", + ]); + }); + + test("uses Object for plain objects", () => { + expect(queries.target({ a: 1 })).toEqual(["react-ghostmaker", "Object"]); + }); + + test("uses all matching registered model identifiers", () => { + expect(queries.target(new LegacyModel("l1"))).toEqual([ + "react-ghostmaker", + "LegacyModel@legacy-l1.second", + ]); + }); +}); + +describe("queries.chainItem()", () => { + const prev = ["react-ghostmaker", "Target"]; + + test("appends the property name for property access", () => { + expect(queries.chainItem(prev, { propName: "name" })).toEqual([ + ...prev, + "name", + ]); + }); + + test("appends only the property name for calls without args", () => { + expect(queries.chainItem(prev, { propName: "getAll", args: [] })).toEqual([ + ...prev, + "getAll", + ]); + }); + + test("appends primitive args as strings", () => { + expect( + queries.chainItem(prev, { + propName: "find", + args: ["id", 1, false, null, undefined], + }), + ).toEqual([...prev, "find", "id", "1", "false", "null", "undefined"]); + }); + + test("appends object args as name and hash", () => { + const [key] = queries + .chainItem(prev, { propName: "find", args: [{ filter: "a" }] }) + .slice(-1); + + expect(key).toMatch(/^Object\(hash:-?\d+\)$/); + }); + + test("uses the model name for model args", () => { + const [key] = queries + .chainItem(prev, { propName: "find", args: [new QueryModel("m1")] }) + .slice(-1); + + expect(key).toMatch(/^QueryModel\(hash:-?\d+\)$/); + }); + + test("object args with equal content produce equal keys", () => { + const keyA = queries.chainItem(prev, { + propName: "find", + args: [{ filter: "a", page: 1 }], + }); + const keyB = queries.chainItem(prev, { + propName: "find", + args: [{ filter: "a", page: 1 }], + }); + + expect(keyA).toEqual(keyB); + }); + + test("object args with different content produce different keys", () => { + const keyA = queries.chainItem(prev, { + propName: "find", + args: [{ filter: "a" }], + }); + const keyB = queries.chainItem(prev, { + propName: "find", + args: [{ filter: "b" }], + }); + + expect(keyA).not.toEqual(keyB); + }); +}); + +describe("queries.ghostChain()", () => { + test("returns the target key for an empty chain", () => { + expect(queries.ghostChain(QueryModel, [])).toEqual( + queries.target(QueryModel), + ); + }); + + test("builds the key of the whole chain", () => { + expect( + queries.ghostChain(QueryModel, [ + { propName: "ofId", args: ["m1"] }, + { propName: "getDetailed", args: [] }, + { propName: "customer" }, + { propName: "getName", args: [] }, + ]), + ).toEqual([ + "react-ghostmaker", + "QueryModel", + "ofId", + "m1", + "getDetailed", + "customer", + "getName", + ]); + }); }); diff --git a/packages/react-ghostmaker/src/reset.test.ts b/packages/react-ghostmaker/src/reset.test.ts new file mode 100644 index 0000000..c4d4a9c --- /dev/null +++ b/packages/react-ghostmaker/src/reset.test.ts @@ -0,0 +1,37 @@ +import { QueryClient } from "@tanstack/react-query"; +import { beforeEach, expect, test } from "vitest"; +import { resetGhosts } from "./reset.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"], +}; + +beforeEach(() => { + queryClient.clear(); + for (const key of Object.values(keys)) { + queryClient.setQueryData(key, "data"); + } +}); + +test("resets the ghost, its dependents and its ancestors", async () => { + await resetGhosts(queryClient, keys.chain); + + expect(queryClient.getQueryData(keys.root)).toBeUndefined(); + expect(queryClient.getQueryData(keys.target)).toBeUndefined(); + expect(queryClient.getQueryData(keys.chain)).toBeUndefined(); + expect(queryClient.getQueryData(keys.dependent)).toBeUndefined(); +}); + +test("does not reset siblings or other targets", async () => { + await resetGhosts(queryClient, keys.chain); + + expect(queryClient.getQueryData(keys.sibling)).toBe("data"); + expect(queryClient.getQueryData(keys.other)).toBe("data"); +}); diff --git a/packages/react-ghostmaker/src/resolveGhostChain.test.ts b/packages/react-ghostmaker/src/resolveGhostChain.test.ts new file mode 100644 index 0000000..f0038bc --- /dev/null +++ b/packages/react-ghostmaker/src/resolveGhostChain.test.ts @@ -0,0 +1,108 @@ +import { expect, test, vitest } from "vitest"; +import { resolveGhostChain } from "./resolveGhostChain.ts"; +import { transformFnProp } from "./types.ts"; + +class Counter { + public readonly start: number; + + public constructor(start: number) { + this.start = start; + } + + public add(value: number) { + return new Counter(this.start + value); + } + + public async addAsync(value: number) { + return this.add(value); + } + + public async fail(): Promise { + throw new Error("failed"); + } + + public nothing(): Counter | undefined { + return undefined; + } +} + +test("returns the target for an empty chain", async () => { + const target = new Counter(1); + expect(await resolveGhostChain(target, [])).toBe(target); +}); + +test("resolves property access", async () => { + expect(await resolveGhostChain(new Counter(1), [{ propName: "start" }])).toBe( + 1, + ); +}); + +test("calls methods with args bound to their target", async () => { + expect( + await resolveGhostChain(new Counter(1), [ + { propName: "add", args: [2] }, + { propName: "addAsync", args: [3] }, + { propName: "start" }, + ]), + ).toBe(6); +}); + +test("resolves to undefined after a nullish result", async () => { + expect( + await resolveGhostChain(new Counter(1), [ + { propName: "nothing", args: [] }, + { propName: "add", args: [1] }, + { propName: "start" }, + ]), + ).toBeUndefined(); +}); + +test("applies transform functions", async () => { + const transform = vitest.fn((counter: Counter) => counter.start * 10); + + expect( + await resolveGhostChain(new Counter(1), [ + { propName: "add", args: [1] }, + { propName: transformFnProp, args: [transform] }, + ]), + ).toBe(20); + expect(transform).toHaveBeenCalledOnce(); +}); + +test("applies transform functions to nullish results", async () => { + const transform = vitest.fn(() => "fallback"); + + expect( + await resolveGhostChain(new Counter(1), [ + { propName: "nothing", args: [] }, + { propName: transformFnProp, args: [transform] }, + ]), + ).toBe("fallback"); + expect(transform).toHaveBeenCalledWith(undefined); +}); + +test("rejects when a transform has no mapping function", async () => { + await expect( + resolveGhostChain(new Counter(1), [ + { propName: transformFnProp, args: [] }, + ]), + ).rejects.toThrow("transform requires a mapping function"); +}); + +test("rejects when calling a property that is not a function", async () => { + await expect( + resolveGhostChain(new Counter(1), [{ propName: "start", args: [] }]), + ).rejects.toThrow(TypeError); +}); + +test("rejects when accessing a property of a primitive", async () => { + await expect( + resolveGhostChain("string", [{ propName: "length" }]), + ).rejects.toThrow(TypeError); +}); + +test("rejects when a method rejects", async () => { + await expect( + resolveGhostChain(new Counter(1), [{ propName: "fail", args: [] }]), + ).rejects.toThrow("failed"); +}); diff --git a/packages/react-ghostmaker/src/types.test.ts b/packages/react-ghostmaker/src/types.test.ts new file mode 100644 index 0000000..91b42a6 --- /dev/null +++ b/packages/react-ghostmaker/src/types.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from "vitest"; +import { makeGhost } from "./makeGhost.ts"; +import { isReactGhost } from "./types.ts"; + +test("isReactGhost() detects ghosts", () => { + const ghost = makeGhost({ foo: () => "bar" }); + + expect(isReactGhost(ghost)).toBe(true); + expect(isReactGhost(ghost.foo)).toBe(true); + expect(isReactGhost(ghost.foo())).toBe(true); +}); + +test.each([ + ["a function", () => true], + ["an object", { ___ghostMarker: true }], + ["a string", "ghost"], + ["null", null], + ["undefined", undefined], +])("isReactGhost() returns false for %s", (_, something) => { + expect(isReactGhost(something)).toBe(false); +}); diff --git a/packages/react-ghostmaker/src/withEachGhost.test.ts b/packages/react-ghostmaker/src/withEachGhost.test.ts new file mode 100644 index 0000000..8fe9869 --- /dev/null +++ b/packages/react-ghostmaker/src/withEachGhost.test.ts @@ -0,0 +1,38 @@ +import { expect, test, vitest } from "vitest"; +import { withEachGhost } from "./withEachGhost.ts"; + +test("calls the callback for each partial query key", async () => { + const callback = vitest.fn(async () => { + // noop + }); + + await withEachGhost(["a", "b", "c"], callback); + + expect(callback.mock.calls).toEqual([ + [{ queryKey: ["a"], exact: true }], + [{ queryKey: ["a", "b"], exact: true }], + [{ queryKey: ["a", "b", "c"], exact: false }], + ]); +}); + +test("awaits each callback before calling the next one", async () => { + const events: string[] = []; + + await withEachGhost(["a", "b"], async ({ queryKey }) => { + events.push(`start ${queryKey?.join(".")}`); + await Promise.resolve(); + events.push(`end ${queryKey?.join(".")}`); + }); + + expect(events).toEqual(["start a", "end a", "start a.b", "end a.b"]); +}); + +test("does not call the callback for an empty query key", async () => { + const callback = vitest.fn(async () => { + // noop + }); + + await withEachGhost([], callback); + + expect(callback).not.toHaveBeenCalled(); +}); From 9e0ab23cead3d8538f180a6442a9e2c14071ffb6 Mon Sep 17 00:00:00 2001 From: Marco Falkenberg Date: Mon, 28 Sep 2026 16:20:59 +0200 Subject: [PATCH 2/2] fix: merge meta data of all parent classes With the ./polytype entry point, getPrototypeListOf(Object.prototype) returns [] instead of [null], so incomplete meta data (only name or only getId) was dropped and getMetaData() returned undefined. Meta data of multiple parent classes was never merged either, as the lookup stopped after the first parent branch. The lookup now walks all parent classes depth-first and merges their meta data: the class itself wins over its parents, earlier parents (including their ancestors) win over later ones. Co-Authored-By: Claude Opus 5.5 --- packages/react-ghostmaker/src/metaData.ts | 42 +++---- .../react-ghostmaker/src/polytype.test.ts | 115 ++++++++++++++++++ 2 files changed, 135 insertions(+), 22 deletions(-) create mode 100644 packages/react-ghostmaker/src/polytype.test.ts diff --git a/packages/react-ghostmaker/src/metaData.ts b/packages/react-ghostmaker/src/metaData.ts index 5b7f617..5cb0ce0 100644 --- a/packages/react-ghostmaker/src/metaData.ts +++ b/packages/react-ghostmaker/src/metaData.ts @@ -61,7 +61,7 @@ export const getMetaData = ( something: unknown, ): GhostMakerModelMeta | undefined => { return ( - getMetaDataRecursive(something, undefined, getClass(something)) ?? + getMetaDataRecursive(getClass(something), undefined) ?? getDynamicMetaData(something) ); }; @@ -79,35 +79,33 @@ export const getModelId = (something: unknown): string | undefined => { } }; +const isCompleteMetaData = ( + meta: GhostMakerModelMeta | undefined, +) => !!meta && "getId" in meta && "name" in meta; + +/** + * Collects meta data depth-first along the prototype chain(s). Meta data of the + * class itself wins over its parents, earlier parents (including their + * ancestors) win over later ones. + */ const getMetaDataRecursive = ( - something: unknown, - collectedMeta?: Partial>, - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - klass?: Function, + klass: unknown, + collectedMeta: GhostMakerModelMeta | undefined, ): GhostMakerModelMeta | undefined => { - const currentClass = getClass(something); + if (isCompleteMetaData(collectedMeta) || is.nullOrUndefined(klass)) { + return collectedMeta; + } - const meta = store.get(klass ?? currentClass); + const meta = store.get(klass); - const mergedMeta = + let mergedMeta = meta || collectedMeta ? { ...meta, ...collectedMeta } : undefined; - if (mergedMeta && "getId" in mergedMeta && "name" in mergedMeta) { - return mergedMeta; + for (const proto of getProtoypes.current(klass)) { + mergedMeta = getMetaDataRecursive(proto, mergedMeta); } - if (!klass) { - return mergedMeta; - } - - const prototypes = getProtoypes.current(klass); - - for (const proto of prototypes) { - const result = getMetaDataRecursive(something, mergedMeta, proto); - if (result) { - return result; - } - } + return mergedMeta; }; function isClass(value: unknown): value is Class { diff --git a/packages/react-ghostmaker/src/polytype.test.ts b/packages/react-ghostmaker/src/polytype.test.ts new file mode 100644 index 0000000..a2bac82 --- /dev/null +++ b/packages/react-ghostmaker/src/polytype.test.ts @@ -0,0 +1,115 @@ +import { classes } from "polytype"; +import { expect, test } from "vitest"; +import { GhostMakerModel } from "./polytype.ts"; +import { getModelId, getModelName } from "./metaData.ts"; + +@GhostMakerModel({ + name: "Named", +}) +class Named {} + +@GhostMakerModel({ + name: "Identified", + getId: (model) => model.id, +}) +class Identified { + public readonly id: string; + + public constructor(id: string) { + this.id = id; + } +} + +@GhostMakerModel({ + getId: (model) => `id-only-${model.id}`, +}) +class IdOnly { + public readonly id: string; + + public constructor(id: string) { + this.id = id; + } +} + +@GhostMakerModel({ + name: "NamedBase", +}) +class NamedBase {} + +class NamedSub extends NamedBase {} + +class MergedParents extends classes(Named, IdOnly) { + public constructor(id: string) { + super([], [id]); + } +} + +class ConflictingParents extends classes(Named, NamedSub, Identified) { + public constructor(id: string) { + super([], [], [id]); + } +} + +class DeepFirstParent extends classes(NamedSub, Named) {} + +@GhostMakerModel({ + name: "Multi", +}) +class Multi extends classes(Named, Identified) { + public constructor(id: string) { + super([], [id]); + } +} + +@GhostMakerModel({ + name: "MultiWithId", + getId: (model) => `multi-${model.id}`, +}) +class MultiWithId extends classes(Named, Identified) { + public constructor(id: string) { + super([], [id]); + } +} + +test("uses meta data of the class itself", () => { + expect(getModelName(new MultiWithId("m1"))).toBe("MultiWithId"); + expect(getModelId(new MultiWithId("m1"))).toBe("multi-m1"); +}); + +test("finds getId in any of multiple parent classes", () => { + expect(getModelName(new Multi("m1"))).toBe("Multi"); + expect(getModelId(new Multi("m1"))).toBe("m1"); +}); + +test("keeps working for models with complete meta data", () => { + expect(getModelName(new Identified("i1"))).toBe("Identified"); + expect(getModelId(new Identified("i1"))).toBe("i1"); +}); + +test("returns partial meta data of models without getId", () => { + expect(getModelName(new Named())).toBe("Named"); + expect(getModelId(new Named())).toBeUndefined(); +}); + +test("returns partial meta data of models without name", () => { + expect(getModelName(new IdOnly("i1"))).toBeUndefined(); + expect(getModelId(new IdOnly("i1"))).toBe("id-only-i1"); +}); + +test("inherits partial meta data from a single parent class", () => { + expect(getModelName(new NamedSub())).toBe("NamedBase"); +}); + +test("merges meta data of multiple parent classes", () => { + expect(getModelName(new MergedParents("m1"))).toBe("Named"); + expect(getModelId(new MergedParents("m1"))).toBe("id-only-m1"); +}); + +test("prefers meta data of earlier parent classes", () => { + expect(getModelName(new ConflictingParents("c1"))).toBe("Named"); + expect(getModelId(new ConflictingParents("c1"))).toBe("c1"); +}); + +test("searches ancestors of earlier parent classes first", () => { + expect(getModelName(new DeepFirstParent())).toBe("NamedBase"); +});