diff --git a/example-apps/dashmint-lab/src/App.tsx b/example-apps/dashmint-lab/src/App.tsx index cb3e88d..d0fad4f 100644 --- a/example-apps/dashmint-lab/src/App.tsx +++ b/example-apps/dashmint-lab/src/App.tsx @@ -38,6 +38,12 @@ const SORT_LABELS: Record = { }; const SORT_ORDER: SortKey[] = ["rarity", "name", "owner", "price"]; +function cardPriceValue(card: Card): bigint | null { + const price = card.$price; + if (price === undefined || price === 0 || price === 0n) return null; + return typeof price === "bigint" ? price : BigInt(Math.trunc(price)); +} + function App() { const session = useSession(); const { @@ -154,9 +160,12 @@ function App() { ); } else if (sortKey === "price") { return [...cards].sort((a, b) => { - const pa = a.$price ? Number(a.$price) : -1; - const pb = b.$price ? Number(b.$price) : -1; - return pb - pa; + const pa = cardPriceValue(a); + const pb = cardPriceValue(b); + if (pa === pb) return 0; + if (pa === null) return 1; + if (pb === null) return -1; + return pa > pb ? -1 : 1; }); } return cards; diff --git a/example-apps/dashmint-lab/src/components/CardTile.tsx b/example-apps/dashmint-lab/src/components/CardTile.tsx index 5a42e35..5b5d06a 100644 --- a/example-apps/dashmint-lab/src/components/CardTile.tsx +++ b/example-apps/dashmint-lab/src/components/CardTile.tsx @@ -91,8 +91,8 @@ export function CardTile({ style={{ background: RARITY_RAIL_COLORS[rarity] }} /> - {/* Header: rarity tag + price */} -
+ {/* Header: reserves chip height so listed and unlisted cards align */} +
{hasPrice && ( (null); + const price = card?.$price ?? null; + const insufficientCredits = + session.balance !== null && price !== null && session.balance < price; + useEffect(() => { if (card) { setResult(null); @@ -42,7 +46,8 @@ export function PurchaseModal({ !session.keyManager || !session.contractId || card.$price === undefined || - card.$price === null + card.$price === null || + insufficientCredits ) return; setSubmitting(true); @@ -82,16 +87,41 @@ export function PurchaseModal({
+
+ + Your balance + + + {session.balance === null + ? "—" + : `${formatCredits(session.balance)} credits`} + +
+ + {insufficientCredits && ( +

+ Not enough credits to buy this card. +

+ )} + {result && }
+ + ), +})); + +const cards: Card[] = [ + { + id: "card-1", + ownerId: "owner-1", + data: { name: "Fire Dragon", attack: 9, defense: 8 }, + }, + { + id: "card-2", + ownerId: "owner-2", + data: { name: "Aqua Spirit", attack: 3, defense: 4 }, + }, +]; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("AppShell", () => { + it("shows sidebar login navigation when the session is not authenticated", () => { + const onLoginOpen = vi.fn(); + + render( + +
Collection content
+
, + ); + + const sidebar = screen.getByRole("complementary", { + name: "Main navigation", + }); + fireEvent.click(within(sidebar).getByRole("button", { name: /Login/ })); + + expect(onLoginOpen).toHaveBeenCalledTimes(1); + expect(screen.getByText("Collection content")).toBeTruthy(); + }); + + it("closes the mobile drawer after selecting a navigation item", () => { + const onTabChange = vi.fn(); + + render( + +
Collection content
+
, + ); + + const menuButton = screen.getByRole("button", { name: "Open menu" }); + fireEvent.click(menuButton); + expect(menuButton.getAttribute("aria-expanded")).toBe("true"); + + const sidebar = screen.getByRole("complementary", { + name: "Main navigation", + }); + fireEvent.click(within(sidebar).getByRole("button", { name: /Mint/ })); + + expect(onTabChange).toHaveBeenCalledWith("mint"); + expect(menuButton.getAttribute("aria-expanded")).toBe("false"); + }); + + it("hides login navigation and shows identity details when authenticated", () => { + mockUseDpnsName.mockReturnValue("alice"); + + render( + +
Collection content
+
, + ); + + const sidebar = screen.getByRole("complementary", { + name: "Main navigation", + }); + expect(within(sidebar).queryByRole("button", { name: /Login/ })).toBeNull(); + expect(screen.getByText("Signed in")).toBeTruthy(); + expect(screen.getByText("@alice")).toBeTruthy(); + }); +}); + +describe("SubTabs", () => { + it("hides the Yours tab for browse-only users", () => { + render(); + + expect(screen.queryByRole("button", { name: "Yours" })).toBeNull(); + expect(screen.getByRole("button", { name: "All" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Marketplace" })).toBeTruthy(); + }); + + it("emits tab changes for visible collection tabs", () => { + const onChange = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "All" })); + fireEvent.click(screen.getByRole("button", { name: "Marketplace" })); + fireEvent.click(screen.getByRole("button", { name: "Yours" })); + + expect(onChange).toHaveBeenNthCalledWith(1, "all"); + expect(onChange).toHaveBeenNthCalledWith(2, "marketplace"); + expect(onChange).toHaveBeenNthCalledWith(3, "my"); + }); +}); + +describe("CardGrid", () => { + it("renders the configured empty message when there are no cards", () => { + render( + , + ); + + expect(screen.getByText("Nothing minted yet.")).toBeTruthy(); + }); + + it("renders one CardTile per card and forwards callbacks", () => { + const onTransfer = vi.fn(); + + render( + , + ); + + expect(screen.getByText("Fire Dragon")).toBeTruthy(); + expect(screen.getByText("Aqua Spirit")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Transfer card-2" })); + + expect(onTransfer).toHaveBeenCalledWith(cards[1]); + }); +}); + +describe("HowItWorks", () => { + it("maps the mint operation to the token-paid document create call", () => { + render(); + + expect(screen.getByText("Platform operations at a glance")).toBeTruthy(); + expect(screen.getByText("Mint card")).toBeTruthy(); + expect( + screen.getByText("sdk.documents.create + tokenPaymentInfo"), + ).toBeTruthy(); + }); +}); diff --git a/example-apps/dashmint-lab/test/logger.test.ts b/example-apps/dashmint-lab/test/logger.test.ts new file mode 100644 index 0000000..d009c66 --- /dev/null +++ b/example-apps/dashmint-lab/test/logger.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { errorMessage } from "../src/dash/logger"; + +describe("errorMessage", () => { + it("reads .message from Error instances", () => { + expect(errorMessage(new Error("boom"))).toBe("boom"); + }); + + it("returns string values as-is", () => { + expect(errorMessage("already a string")).toBe("already a string"); + }); + + it("reads .message from plain objects (Evo SDK throws these)", () => { + expect(errorMessage({ message: "wasm failed", code: 7 })).toBe( + "wasm failed", + ); + }); + + it("JSON-stringifies objects without a string message", () => { + expect(errorMessage({ code: 7, detail: "nope" })).toBe( + '{"code":7,"detail":"nope"}', + ); + }); + + it("falls back to String() when JSON.stringify throws (circular refs)", () => { + const circular: Record = {}; + circular.self = circular; + expect(errorMessage(circular)).toBe("[object Object]"); + }); + + it("handles primitives that are neither Error nor string", () => { + expect(errorMessage(42)).toBe("42"); + expect(errorMessage(null)).toBe("null"); + }); +}); diff --git a/example-apps/dashmint-lab/test/queries.test.ts b/example-apps/dashmint-lab/test/queries.test.ts new file mode 100644 index 0000000..10b07f4 --- /dev/null +++ b/example-apps/dashmint-lab/test/queries.test.ts @@ -0,0 +1,148 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + listAllCards, + listMarketplaceCards, + listMyCards, + normalizeCards, +} from "../src/dash/queries"; +import type { DashSdk } from "../src/dash/types"; + +const rawCards = { + "doc-1": { + $ownerId: "owner-1", + name: "Fire Dragon", + description: "Hot", + attack: 9, + defense: 8, + $price: 50n, + }, + "doc-2": { + $ownerId: "owner-2", + name: "Aqua Spirit", + attack: 3, + defense: 4, + }, + "doc-3": { + $ownerId: "owner-3", + name: "Unlisted Golem", + attack: 4, + defense: 9, + $price: 0n, + }, +}; + +function makeSdk(results: unknown): DashSdk { + return { + documents: { + query: vi.fn().mockResolvedValue(results), + }, + } as unknown as DashSdk; +} + +let log: ReturnType; + +beforeEach(() => { + log = vi.fn(); +}); + +describe("card queries", () => { + it("normalizes plain object query results keyed by document id", () => { + expect(normalizeCards(rawCards)).toEqual([ + { + id: "doc-1", + ownerId: "owner-1", + data: { + name: "Fire Dragon", + description: "Hot", + attack: 9, + defense: 8, + }, + $price: 50n, + }, + { + id: "doc-2", + ownerId: "owner-2", + data: { + name: "Aqua Spirit", + description: undefined, + attack: 3, + defense: 4, + }, + $price: undefined, + }, + { + id: "doc-3", + ownerId: "owner-3", + data: { + name: "Unlisted Golem", + description: undefined, + attack: 4, + defense: 9, + }, + $price: 0n, + }, + ]); + }); + + it("listMyCards queries the owner-scoped collection", async () => { + const sdk = makeSdk(rawCards); + + const cards = await listMyCards({ + sdk, + contractId: "contract-1", + identityId: "owner-1", + log, + }); + + expect(sdk.documents.query).toHaveBeenCalledWith({ + dataContractId: "contract-1", + documentTypeName: "card", + where: [["$ownerId", "==", "owner-1"]], + limit: 100, + }); + expect(cards).toHaveLength(3); + expect(log).toHaveBeenCalledWith("Loading your cards…"); + expect(log).toHaveBeenCalledWith("Found 3 card(s)."); + }); + + it("listAllCards uses the caller-provided limit", async () => { + const sdk = makeSdk([]); + + await expect( + listAllCards({ + sdk, + contractId: "contract-1", + limit: 25, + log, + }), + ).resolves.toEqual([]); + + expect(sdk.documents.query).toHaveBeenCalledWith({ + dataContractId: "contract-1", + documentTypeName: "card", + limit: 25, + }); + expect(log).toHaveBeenCalledWith("Loading all cards (any owner)…"); + expect(log).toHaveBeenCalledWith("Found 0 card(s) total."); + }); + + it("listMarketplaceCards returns only cards with a non-zero sale price", async () => { + const sdk = makeSdk(rawCards); + + const cards = await listMarketplaceCards({ + sdk, + contractId: "contract-1", + log, + }); + + expect(sdk.documents.query).toHaveBeenCalledWith({ + dataContractId: "contract-1", + documentTypeName: "card", + limit: 100, + }); + expect(cards.map((card) => card.id)).toEqual(["doc-1"]); + expect(log).toHaveBeenCalledWith("Loading marketplace…"); + expect(log).toHaveBeenCalledWith("Found 1 card(s) for sale."); + }); +}); diff --git a/example-apps/dashmint-lab/test/useDpnsName.test.tsx b/example-apps/dashmint-lab/test/useDpnsName.test.tsx new file mode 100644 index 0000000..c2849d9 --- /dev/null +++ b/example-apps/dashmint-lab/test/useDpnsName.test.tsx @@ -0,0 +1,125 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { useDpnsName } from "../src/hooks/useDpnsName"; +import type { DashSdk } from "../src/dash/types"; + +function makeSdk(username: ReturnType): DashSdk { + return { + dpns: { + username, + resolveName: vi.fn(), + }, + } as unknown as DashSdk; +} + +function Probe({ + sdk, + identityId, +}: { + sdk?: DashSdk | null; + identityId?: string | null; +}) { + const name = useDpnsName(sdk, identityId); + return
{name ?? ""}
; +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("useDpnsName", () => { + it("does not resolve until both sdk and identity id are available", () => { + const username = vi.fn(); + const sdk = makeSdk(username); + + render(); + + expect(screen.getByTestId("name").textContent).toBe(""); + expect(username).not.toHaveBeenCalled(); + }); + + it("resolves an identity id and strips the display-only .dash suffix", async () => { + const username = vi.fn().mockResolvedValue("alice.dash"); + const sdk = makeSdk(username); + + render(); + + expect(screen.getByTestId("name").textContent).toBe(""); + await waitFor(() => { + expect(screen.getByTestId("name").textContent).toBe("alice"); + }); + expect(username).toHaveBeenCalledWith("identity-use-dpns-name-1"); + }); + + it("keeps names that do not end in the display-only .dash suffix", async () => { + const username = vi.fn().mockResolvedValue("alice"); + const sdk = makeSdk(username); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("name").textContent).toBe("alice"); + }); + }); + + it.each([ + ["missing", undefined], + ["numeric", 123], + ["object", { label: "alice.dash" }], + ])("treats %s DPNS responses as no display name", async (_case, value) => { + const username = vi.fn().mockResolvedValue(value); + const sdk = makeSdk(username); + + render(); + + await waitFor(() => { + expect(username).toHaveBeenCalledTimes(1); + }); + expect(screen.getByTestId("name").textContent).toBe(""); + }); + + it("reuses the module-level cache across hook mounts", async () => { + const username = vi.fn().mockResolvedValue("cached-name.dash"); + const sdk = makeSdk(username); + + const first = render( + , + ); + await waitFor(() => { + expect(screen.getByTestId("name").textContent).toBe("cached-name"); + }); + first.unmount(); + + render(); + + expect(screen.getByTestId("name").textContent).toBe("cached-name"); + expect(username).toHaveBeenCalledTimes(1); + }); + + it("shares an in-flight lookup across sibling hook instances", async () => { + const username = vi.fn().mockResolvedValue("shared-name.dash"); + const sdk = makeSdk(username); + + function Pair() { + return ( + <> + + + + ); + } + + render(); + + await waitFor(() => { + expect(screen.getAllByTestId("name").map((el) => el.textContent)).toEqual( + ["shared-name", "shared-name"], + ); + }); + expect(username).toHaveBeenCalledTimes(1); + }); +}); diff --git a/example-apps/dashmint-lab/test/useResolvedRecipient.test.tsx b/example-apps/dashmint-lab/test/useResolvedRecipient.test.tsx new file mode 100644 index 0000000..b9ef7ea --- /dev/null +++ b/example-apps/dashmint-lab/test/useResolvedRecipient.test.tsx @@ -0,0 +1,224 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { DashSdk } from "../src/dash/types"; +import { useResolvedRecipient } from "../src/hooks/useResolvedRecipient"; + +const { mockResolveDpnsName } = vi.hoisted(() => ({ + mockResolveDpnsName: vi.fn(), +})); + +vi.mock("../src/dash/resolveRecipient", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + resolveDpnsName: mockResolveDpnsName, + }; +}); + +const sdk = { + dpns: { + resolveName: vi.fn(), + username: vi.fn(), + }, +} as unknown as DashSdk; + +function Probe({ + currentSdk = sdk, + input, +}: { + currentSdk?: DashSdk | null; + input?: string | null; +}) { + const state = useResolvedRecipient(currentSdk, input); + return
{JSON.stringify(state)}
; +} + +function state() { + return JSON.parse(screen.getByTestId("state").textContent ?? "{}"); +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("useResolvedRecipient", () => { + it("stays idle when there is no sdk or resolvable input", () => { + render(); + expect(state()).toEqual({ status: "idle" }); + expect(mockResolveDpnsName).not.toHaveBeenCalled(); + + cleanup(); + + render(); + expect(state()).toEqual({ status: "idle" }); + expect(mockResolveDpnsName).not.toHaveBeenCalled(); + }); + + it("normalizes a DPNS name, resolves it, and reports the identity id", async () => { + mockResolveDpnsName.mockResolvedValueOnce("identity-recipient-1"); + + render(); + + expect(state()).toEqual({ status: "resolving" }); + await waitFor(() => { + expect(state()).toEqual({ + status: "resolved", + identityId: "identity-recipient-1", + }); + }); + expect(mockResolveDpnsName).toHaveBeenCalledWith(sdk, "alice.dash"); + }); + + it("reports not-found when DPNS resolution completes without an identity", async () => { + mockResolveDpnsName.mockResolvedValueOnce(null); + + render(); + + await waitFor(() => { + expect(state()).toEqual({ status: "not-found" }); + }); + expect(mockResolveDpnsName).toHaveBeenCalledWith( + sdk, + "missing-recipient-name.dash", + ); + }); + + it("reuses cached not-found results across hook mounts", async () => { + mockResolveDpnsName.mockResolvedValueOnce(null); + + const first = render(); + await waitFor(() => { + expect(state()).toEqual({ status: "not-found" }); + }); + first.unmount(); + + render(); + + expect(state()).toEqual({ status: "not-found" }); + expect(mockResolveDpnsName).toHaveBeenCalledTimes(1); + }); + + it("reuses cached resolved names across hook mounts", async () => { + mockResolveDpnsName.mockResolvedValueOnce("identity-recipient-cache"); + + const first = render(); + await waitFor(() => { + expect(state()).toEqual({ + status: "resolved", + identityId: "identity-recipient-cache", + }); + }); + first.unmount(); + + render(); + + expect(state()).toEqual({ + status: "resolved", + identityId: "identity-recipient-cache", + }); + expect(mockResolveDpnsName).toHaveBeenCalledTimes(1); + }); + + it("does not cache transient resolver errors across mounts", async () => { + mockResolveDpnsName + .mockRejectedValueOnce(new Error("temporary network failure")) + .mockResolvedValueOnce("identity-recipient-retry"); + + const first = render(); + await waitFor(() => { + expect(mockResolveDpnsName).toHaveBeenCalledTimes(1); + }); + first.unmount(); + + render(); + + await waitFor(() => { + expect(state()).toEqual({ + status: "resolved", + identityId: "identity-recipient-retry", + }); + }); + expect(mockResolveDpnsName).toHaveBeenCalledTimes(2); + }); + + it("shares one in-flight lookup across sibling hook instances", async () => { + // Hold the lookup pending so the second sibling's effect runs while the + // cache entry is still a Promise, exercising the shared-promise branch. + let resolveLookup: (value: string) => void = () => {}; + mockResolveDpnsName.mockReturnValueOnce( + new Promise((resolve) => { + resolveLookup = resolve; + }), + ); + + function Pair() { + return ( + <> + + + + ); + } + + render(); + + expect(mockResolveDpnsName).toHaveBeenCalledTimes(1); + expect(screen.getAllByTestId("state").map((el) => el.textContent)).toEqual([ + JSON.stringify({ status: "resolving" }), + JSON.stringify({ status: "resolving" }), + ]); + + resolveLookup("identity-recipient-shared"); + + await waitFor(() => { + expect( + screen + .getAllByTestId("state") + .map((el) => JSON.parse(el.textContent ?? "{}")), + ).toEqual([ + { status: "resolved", identityId: "identity-recipient-shared" }, + { status: "resolved", identityId: "identity-recipient-shared" }, + ]); + }); + expect(mockResolveDpnsName).toHaveBeenCalledTimes(1); + }); + + it("propagates a rejected shared lookup to sibling hooks", async () => { + let rejectLookup: (reason: Error) => void = () => {}; + mockResolveDpnsName.mockReturnValueOnce( + new Promise((_resolve, reject) => { + rejectLookup = reject; + }), + ); + + function Pair() { + return ( + <> + + + + ); + } + + render(); + expect(mockResolveDpnsName).toHaveBeenCalledTimes(1); + + rejectLookup(new Error("shared lookup failed")); + + // Both siblings re-render after the rejection; the dropped cache entry + // returns them to "resolving"; a later remount or key/sdk change can retry. + await waitFor(() => { + expect( + screen.getAllByTestId("state").map((el) => el.textContent), + ).toEqual([ + JSON.stringify({ status: "resolving" }), + JSON.stringify({ status: "resolving" }), + ]); + }); + }); +});