// @vitest-environment jsdom import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { deriveAsyncState } from "../../src/application/view-models/async-state.js"; import { AsyncSurface } from "../../src/presentation/components/async-surface.jsx"; import { createFailure } from "../../src/contracts/errors.js"; describe("async UI state matrix", () => { it.each([ [{ isInitialLoading: true }, "initial-loading"], [{ data: [{ id: "1" }] }, "success"], [{ data: [] }, "empty"], [ { failure: createFailure("SERVER_FAILURE", "LIST", 0) }, "terminal-error", ], ])("derives base state %#", (signals, expected) => { expect(deriveAsyncState(signals).base).toBe(expected); }); it.each([ [{ data: ["value"], isFetching: true }, "refreshing"], [{ data: ["value"], isStale: true, isDegraded: true }, "stale-degraded"], [{ data: ["value"], isMutationPending: true }, "mutation-pending"], [{ data: ["value"], hasMutationConflict: true }, "mutation-conflict"], ])("derives overlay state %#", (signals, indicator) => { expect(deriveAsyncState(signals).indicator).toBe(indicator); }); it("makes crossed overlay inputs mutually exclusive by priority", () => { const state = deriveAsyncState({ data: ["value"], isFetching: true, isMutationPending: true, hasMutationConflict: true, }); expect(state.indicator).toBe("mutation-conflict"); expect(state.overlay).toMatchObject({ refreshing: false, mutationPending: false, mutationConflict: true, }); }); it("keeps content visible while a non-blocking refresh runs", () => { const state = deriveAsyncState({ data: ["value"], isFetching: true }); render(existing content); expect(screen.getByText("existing content")).toBeVisible(); expect(screen.getByRole("status")).toHaveTextContent( "최신 정보를 확인하고 있습니다.", ); }); it("connects stale retry and conflict resolution to real callbacks", async () => { const user = userEvent.setup(); const retry = vi.fn(); const resolveConflict = vi.fn(); const stale = deriveAsyncState({ data: ["value"], isStale: true, isDegraded: true, }); const view = render( existing content , ); await user.click(screen.getByRole("button", { name: "다시 시도" })); expect(retry).toHaveBeenCalledOnce(); const conflict = deriveAsyncState({ data: ["value"], hasMutationConflict: true, }); view.rerender( existing content , ); await user.click(screen.getByRole("button", { name: "충돌 해결" })); expect(resolveConflict).toHaveBeenCalledOnce(); }); it("renders only safe error vocabulary", () => { const failure = createFailure("SERVER_FAILURE", "LIST", 0, { code: "SERVER_FAILURE", }); const state = deriveAsyncState({ failure }); render(); expect(screen.getByRole("alert")).toHaveTextContent( "요청을 완료하지 못했습니다.", ); expect(screen.getByRole("alert")).toHaveAttribute( "data-message-key", failure.userMessageKey, ); expect(screen.getByRole("button")).toHaveTextContent("다시 시도"); expect(screen.getByRole("alert")).not.toHaveTextContent("stack"); }); it("does not retain a terminal error after usable data is restored", () => { const failed = deriveAsyncState({ failure: createFailure("SERVER_FAILURE", "LIST", 0), }); const recovered = deriveAsyncState({ data: ["value"] }); expect(failed.base).toBe("terminal-error"); expect(recovered.base).toBe("success"); expect(recovered.failure).toBeUndefined(); }); });