memos/web/tests/space-switcher.test.tsx
amblued 9d2b77ced8 feat(space): add client-defined UIDs and identity cues
- Generate UUID v4 values in the client with validated custom UID
  support.\n- Show immutable UIDs where Space titles need
  disambiguation.\n- Standardize Space surfaces on the Lucide Astroid
  icon.
2026-08-28 00:33:09 +08:00

138 lines
6.1 KiB
TypeScript

import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import SpaceSwitcher from "@/components/AppSidebar/SpaceSwitcher";
const spaceState = vi.hoisted(() => ({
spaces: [
{ name: "spaces/product", title: "Product", description: "" },
{ name: "spaces/research", title: "Research", description: "" },
],
selectedSpace: undefined as { name: string; title: string; description: string } | undefined,
selectedSpaceName: undefined as string | undefined,
selectMemos: vi.fn(),
selectSpace: vi.fn(),
}));
vi.mock("@/components/MemosLogo", () => ({
default: () => <span>Memos</span>,
}));
vi.mock("@/components/CreateSpaceDialog", () => ({
default: ({ open, onCreated }: { open: boolean; onCreated?: (space: (typeof spaceState.spaces)[number]) => void }) =>
open ? (
<div role="dialog">
Create Space dialog
<button type="button" onClick={() => onCreated?.(spaceState.spaces[0])}>
Complete create
</button>
</div>
) : null,
}));
vi.mock("@/contexts/SpaceContext", () => ({
useSpaceContext: () => {
const duplicateSpaceTitles = new Set(
spaceState.spaces
.filter((space, index) => spaceState.spaces.findIndex((candidate) => candidate.title === space.title) !== index)
.map((space) => space.title),
);
return {
...spaceState,
duplicateSpaceTitles,
isLoadingSpaces: false,
isSpacesError: false,
};
},
}));
vi.mock("@/utils/i18n", () => ({
useTranslate: () => (key: string) => key,
}));
describe("SpaceSwitcher", () => {
beforeEach(() => {
spaceState.spaces = [
{ name: "spaces/product", title: "Product", description: "" },
{ name: "spaces/research", title: "Research", description: "" },
];
spaceState.selectedSpace = undefined;
spaceState.selectedSpaceName = undefined;
spaceState.selectMemos.mockClear();
spaceState.selectSpace.mockClear();
});
it("lists Memos, every available Space, and the create entry", async () => {
render(<SpaceSwitcher />);
fireEvent.click(screen.getByRole("button", { name: "space.switch: common.memos" }));
expect(await screen.findByRole("menuitemradio", { name: "Memos" })).toHaveAttribute("aria-checked", "true");
const productRow = screen.getByRole("menuitemradio", { name: "Product" });
expect(productRow).toHaveAttribute("aria-checked", "false");
expect(productRow.querySelector(".lucide-astroid")).not.toBeNull();
expect(screen.getByRole("menuitemradio", { name: "Research" })).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: "space.create" })).toBeInTheDocument();
});
it("marks exactly one context as active", async () => {
spaceState.selectedSpaceName = "spaces/product";
spaceState.selectedSpace = spaceState.spaces[0];
render(<SpaceSwitcher />);
fireEvent.click(screen.getByRole("button", { name: "space.switch: Product" }));
const rows = await screen.findAllByRole("menuitemradio");
expect(rows.map((row) => row.getAttribute("aria-checked"))).toEqual(["false", "true", "false"]);
// The active row carries the fill; the check is its only indicator.
const active = screen.getByRole("menuitemradio", { name: "Product" });
expect(active.className).toContain("bg-accent/60");
expect(active.querySelector(".lucide-check")).not.toBeNull();
expect(screen.getByRole("menuitemradio", { name: "Research" }).querySelector(".lucide-check")).toBeNull();
});
it("shows UIDs only for Spaces whose titles match", async () => {
const uuid = "123e4567-e89b-12d3-a456-426614174000";
spaceState.spaces = [
{ name: "spaces/product-notes", title: "Product", description: "" },
{ name: `spaces/${uuid}`, title: "Product", description: "" },
{ name: "spaces/research-space", title: "Research", description: "" },
];
spaceState.selectedSpaceName = spaceState.spaces[0].name;
spaceState.selectedSpace = spaceState.spaces[0];
render(<SpaceSwitcher />);
const trigger = screen.getByRole("button", { name: "space.switch: Product (product-notes)" });
expect(within(trigger).getByTitle("product-notes")).toHaveTextContent("product-notes");
fireEvent.click(trigger);
const customIdRow = await screen.findByRole("menuitemradio", { name: "Product (product-notes)" });
const uuidRow = screen.getByRole("menuitemradio", { name: `Product (${uuid})` });
expect(within(customIdRow).getByTitle("product-notes")).toHaveTextContent("product-notes");
expect(within(uuidRow).getByTitle(uuid)).toHaveTextContent("123e4567…");
const researchRow = screen.getByRole("menuitemradio", { name: "Research" });
expect(within(researchRow).queryByTitle("research-space")).not.toBeInTheDocument();
expect(researchRow).not.toHaveTextContent("research-space");
});
it("switches context without navigation and opens Space creation", async () => {
render(<SpaceSwitcher />);
fireEvent.click(screen.getByRole("button", { name: "space.switch: common.memos" }));
fireEvent.click(await screen.findByRole("menuitemradio", { name: "Product" }));
expect(spaceState.selectSpace).toHaveBeenCalledWith(spaceState.spaces[0]);
await waitFor(() => expect(screen.queryByRole("menu")).not.toBeInTheDocument());
// Selecting Memos is how a signed-in user gets back to the home feed.
fireEvent.click(screen.getByRole("button", { name: "space.switch: common.memos" }));
fireEvent.click(await screen.findByRole("menuitemradio", { name: "Memos" }));
expect(spaceState.selectMemos).toHaveBeenCalledOnce();
await waitFor(() => expect(screen.queryByRole("menu")).not.toBeInTheDocument());
fireEvent.click(screen.getByRole("button", { name: "space.switch: common.memos" }));
fireEvent.click(await screen.findByRole("menuitem", { name: "space.create" }));
expect(screen.getByRole("dialog", { name: "" })).toHaveTextContent("Create Space dialog");
fireEvent.click(screen.getByRole("button", { name: "Complete create" }));
expect(spaceState.selectSpace).toHaveBeenCalledWith(spaceState.spaces[0]);
});
});