/** * @vitest-environment jsdom */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, render, screen, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { setApiInstance } from "@multica/core/api"; import type { ApiClient } from "@multica/core/api/client"; import { pruneIssueSurfaceViewStates } from "@multica/core/issues/stores/surface-view-store"; import type { AgentTask, Issue, ListIssuesParams, ListIssuesResponse, } from "@multica/core/types"; import { IssueSurface } from "./issue-surface"; // Mutable so tests can simulate a workspace switch — the workspace layout // does not remount its children on switch, so the surface must handle the // wsId change itself. const mockWsId = vi.hoisted(() => ({ current: "ws-1" })); vi.mock("@multica/core/hooks", () => ({ useWorkspaceId: () => mockWsId.current, })); const mockAuthUser = { id: "user-1", email: "test@test.com", name: "Test User" }; vi.mock("@multica/core/auth", () => ({ useAuthStore: Object.assign( (selector?: (state: unknown) => unknown) => { const state = { user: mockAuthUser, isAuthenticated: true }; return selector ? selector(state) : state; }, { getState: () => ({ user: mockAuthUser, isAuthenticated: true }) }, ), registerAuthStore: vi.fn(), createAuthStore: vi.fn(), })); vi.mock("../../i18n", () => ({ useT: () => ({ t: () => "translated" }), useTimeAgo: () => () => "now", })); vi.mock("../../navigation", () => ({ AppLink: ({ children, href, ...props }: React.ComponentProps<"a">) => ( {children} ), useNavigation: () => ({ push: vi.fn(), pathname: "/" }), })); vi.mock("@multica/core/paths", async () => { const actual = await vi.importActual( "@multica/core/paths", ); return { ...actual, useCurrentWorkspace: () => ({ id: "ws-1", name: "Test WS", slug: "test" }), useWorkspacePaths: () => actual.paths.workspace("test"), }; }); function makeIssue(id: string, title: string, projectId: string): Issue { return { id, workspace_id: "ws-1", number: 1, identifier: `MUL-${id}`, title, description: null, status: "todo", priority: "none", assignee_type: null, assignee_id: null, creator_type: "member", creator_id: "user-1", parent_issue_id: null, project_id: projectId, position: 1, stage: null, start_date: null, due_date: null, labels: [], metadata: {}, created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", }; } function never() { return new Promise(() => {}); } function projectSurface(projectId: string) { return ( null} renderLoading={() =>
} batchToolbar="never" /> ); } describe("IssueSurface — scope switch loading semantics", () => { let qc: QueryClient; beforeEach(() => { mockWsId.current = "ws-1"; qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); // p1 answers immediately with one issue; p2 stays in flight forever so // the test can observe the in-between state after switching. const listIssues = vi.fn((params?: ListIssuesParams) => { if (params?.project_id === "p2") return never(); const issues = params?.status === "todo" ? [makeIssue("i1", "P1 issue", "p1")] : []; return Promise.resolve({ issues, total: issues.length }); }); setApiInstance({ listIssues, listGroupedIssues: vi.fn(() => never()), listProjects: vi.fn(() => never()), getAgentTaskSnapshot: vi.fn(() => never()), getChildIssueProgress: vi.fn(() => never()), } as unknown as ApiClient); pruneIssueSurfaceViewStates([]); }); afterEach(() => { cleanup(); qc.clear(); pruneIssueSurfaceViewStates([]); vi.restoreAllMocks(); }); it("shows loading — not the previous project's issues — while the next project is fetching", async () => { // Regression: the list queries use `placeholderData: keepPreviousData` to // keep sort/filter changes flicker-free WITHIN one surface. Without a // scope-keyed remount, that placeholder leaks ACROSS surfaces: switching // pinned projects kept rendering project A's cards (isLoading=false, so // no skeleton either) until project B's response landed — the "click does // nothing, then it snaps" bug. const { rerender } = render( {projectSurface("p1")}, ); await screen.findByText("P1 issue"); rerender( {projectSurface("p2")}, ); // The switch must be honest: p2 has no data yet, so the surface is // loading — p1's cards must not impersonate p2. expect(screen.getByTestId("surface-loading")).toBeInTheDocument(); expect(screen.queryByText("P1 issue")).not.toBeInTheDocument(); }); it("shows a cached project instantly on switch-back (no loading flash)", async () => { const { rerender } = render( {projectSurface("p1")}, ); await screen.findByText("P1 issue"); rerender( {projectSurface("p2")}, ); expect(screen.getByTestId("surface-loading")).toBeInTheDocument(); // Back to p1: its cache is warm, so the list renders immediately from // cache — remounting must not degrade the instant-switch path. rerender( {projectSurface("p1")}, ); await waitFor(() => expect(screen.getByText("P1 issue")).toBeInTheDocument(), ); expect(screen.queryByTestId("surface-loading")).not.toBeInTheDocument(); }); it("shows loading on a workspace switch even though the scope key is identical", async () => { // The workspace layout does NOT remount children on switch, and two // workspaces share the same scope key (e.g. "workspace:all") — so the // remount key must include wsId, or workspace A's issues impersonate // workspace B's while B is still fetching. // // A fresh element per render — reusing one element reference would let // React bail out of re-rendering the subtree entirely, and the wsId // change would never propagate. const workspaceSurface = () => ( null} renderLoading={() =>
} batchToolbar="never" /> ); const listIssues = vi.fn((params?: ListIssuesParams) => { const issues = params?.status === "todo" ? [makeIssue("i1", "WS1 issue", "p1")] : []; return Promise.resolve({ issues, total: issues.length }); }); setApiInstance({ listIssues, listGroupedIssues: vi.fn(() => never()), listProjects: vi.fn(() => never()), getAgentTaskSnapshot: vi.fn(() => never()), getChildIssueProgress: vi.fn(() => never()), } as unknown as ApiClient); const { rerender } = render( {workspaceSurface()}, ); await screen.findByText("WS1 issue"); // Switch workspace: same scope, new wsId, and the new workspace's // fetches hang so the in-between state is observable. listIssues.mockImplementation(() => never()); mockWsId.current = "ws-2"; rerender( {workspaceSurface()}, ); expect(screen.getByTestId("surface-loading")).toBeInTheDocument(); expect(screen.queryByText("WS1 issue")).not.toBeInTheDocument(); }); });