import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import PagedMemoList from "@/components/PagedMemoList"; import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; const view = vi.hoisted(() => ({ maxColumns: 1 as 0 | 1 | 2 | 3, compactMode: false })); const feed = vi.hoisted(() => ({ memos: [] as unknown[], hasNextPage: false, isLoading: false, fetchNextPage: vi.fn(async () => undefined), })); const readiness = vi.hoisted(() => ({ auth: true, instance: true })); vi.mock("@/hooks/useMemoQueries", () => ({ useInfiniteMemos: () => ({ data: { pages: [{ memos: feed.memos, nextPageToken: "" }] }, fetchNextPage: feed.fetchNextPage, hasNextPage: feed.hasNextPage, isFetchingNextPage: false, isLoading: feed.isLoading, }), })); vi.mock("@/contexts/MemoFilterContext", () => ({ useMemoFilterContext: () => ({ filters: [] }), })); vi.mock("@/contexts/AuthContext", () => ({ useAuth: () => ({ isInitialized: readiness.auth }), })); vi.mock("@/contexts/InstanceContext", () => ({ useInstance: () => ({ isInitialized: readiness.instance }), })); vi.mock("@/contexts/ViewContext", () => ({ useView: () => view, })); vi.mock("@/utils/i18n", () => ({ useTranslate: () => (key: string) => (key === "message.no-data" ? "No data found." : key), })); vi.mock("@/components/MemoContent/MentionResolutionContext", () => ({ MentionResolutionProvider: ({ children }: { children: React.ReactNode }) => <>{children}, })); vi.mock("@/components/MemoFilters", () => ({ default: () =>
, })); const memo = { name: "memos/1", content: "hello", updateTime: undefined } as unknown as Memo; const renderList = ( renderer: (memo: Memo, options: { compact: boolean }) => React.ReactElement = () =>
, options: { leading?: React.ReactNode } = {}, ) => render( options.leading : undefined} /> , ); describe("", () => { beforeEach(() => { view.maxColumns = 1; view.compactMode = false; feed.memos = []; feed.hasNextPage = false; feed.isLoading = false; feed.fetchNextPage.mockClear(); readiness.auth = true; readiness.instance = true; }); it("does not render fetched memo content before display settings settle", () => { feed.memos = [memo]; readiness.auth = false; const renderer = vi.fn((m: Memo) =>
{m.content}
); renderList(renderer); expect(renderer).not.toHaveBeenCalled(); expect(screen.queryByText("hello")).not.toBeInTheDocument(); }); it("does not auto-fetch more pages while display settings are pending", async () => { vi.useFakeTimers(); try { feed.memos = [memo]; feed.hasNextPage = true; readiness.auth = false; renderList(); await act(async () => vi.advanceTimersByTimeAsync(1000)); expect(feed.fetchNextPage).not.toHaveBeenCalled(); } finally { vi.useRealTimers(); } }); it("delays the initial loading spinner to avoid flashing on fast loads", async () => { vi.useFakeTimers(); try { feed.isLoading = true; const { container } = renderList(); expect(container.querySelector(".animate-spin")).not.toBeInTheDocument(); await act(async () => vi.advanceTimersByTimeAsync(249)); expect(container.querySelector(".animate-spin")).not.toBeInTheDocument(); await act(async () => vi.advanceTimersByTimeAsync(1)); expect(container.querySelector(".animate-spin")).toBeInTheDocument(); } finally { vi.useRealTimers(); } }); it("uses the tile sprite Placeholder for the empty state", () => { renderList(); expect(screen.getByText("No data found.")).toBeInTheDocument(); expect(screen.getByTestId("placeholder-sprite")).toBeInTheDocument(); }); it("shows the empty state below route-owned leading content", () => { renderList(undefined, { leading:
}); expect(screen.getByTestId("leading-content")).toBeInTheDocument(); expect(screen.getByText("No data found.")).toBeInTheDocument(); expect(screen.getByTestId("placeholder-sprite")).toBeInTheDocument(); }); it("places leading content and the empty state in the first grid column", () => { view.maxColumns = 0; const widthSpy = vi.spyOn(Element.prototype, "clientWidth", "get").mockReturnValue(1200); try { renderList(undefined, { leading:
}); const leadingTile = screen.getByText("No data found.").closest(".absolute"); expect(leadingTile).not.toBeNull(); expect(leadingTile).toContainElement(screen.getByTestId("leading-content")); } finally { widthSpy.mockRestore(); } }); describe("compact policy", () => { beforeEach(() => { feed.memos = [memo]; }); it("threads compact=false at one column with compact mode off", () => { const renderer = vi.fn((m: Memo) =>
); renderList(renderer); expect(renderer).toHaveBeenCalledWith(expect.objectContaining({ name: "memos/1" }), { compact: false }); }); it("threads compact=true at one column with compact mode on", () => { view.compactMode = true; const renderer = vi.fn((m: Memo) =>
); renderList(renderer); expect(renderer).toHaveBeenCalledWith(expect.objectContaining({ name: "memos/1" }), { compact: true }); }); it("respects the compact setting in the narrow-width fallback even when columns are allowed", () => { // jsdom measures 0px, so the flow fallback renders and behaves exactly like maxColumns = 1. view.maxColumns = 0; const renderer = vi.fn((m: Memo) =>
); renderList(renderer); expect(renderer).toHaveBeenCalledWith(expect.objectContaining({ name: "memos/1" }), { compact: false }); }); it("forces compact once the width fits the grid", () => { view.maxColumns = 0; const widthSpy = vi.spyOn(Element.prototype, "clientWidth", "get").mockReturnValue(1200); try { const renderer = vi.fn((m: Memo) =>
); renderList(renderer); expect(renderer).toHaveBeenCalledWith(expect.objectContaining({ name: "memos/1" }), { compact: true }); } finally { widthSpy.mockRestore(); } }); }); });