77 lines
2.8 KiB
React
77 lines
2.8 KiB
React
// @vitest-environment jsdom
|
|
|
|
import { render, screen } from "@testing-library/react";
|
|
import { describe, expect, it } 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("uses deterministic overlay priority for crossed states", () => {
|
|
const state = deriveAsyncState({
|
|
data: ["value"],
|
|
isFetching: true,
|
|
isMutationPending: true,
|
|
hasMutationConflict: true,
|
|
});
|
|
expect(state.indicator).toBe("mutation-conflict");
|
|
expect(state.overlay).toMatchObject({
|
|
refreshing: true,
|
|
mutationPending: true,
|
|
mutationConflict: true,
|
|
});
|
|
});
|
|
|
|
it("keeps content visible while a non-blocking refresh runs", () => {
|
|
const state = deriveAsyncState({ data: ["value"], isFetching: true });
|
|
render(<AsyncSurface state={state}>existing content</AsyncSurface>);
|
|
|
|
expect(screen.getByText("existing content")).toBeVisible();
|
|
expect(screen.getByRole("status")).toHaveTextContent("refreshing");
|
|
});
|
|
|
|
it("renders only safe error vocabulary", () => {
|
|
const failure = createFailure("SERVER_FAILURE", "LIST", 0, {
|
|
code: "SERVER_FAILURE",
|
|
});
|
|
const state = deriveAsyncState({ failure });
|
|
render(<AsyncSurface state={state} />);
|
|
|
|
expect(screen.getByRole("alert")).toHaveTextContent(failure.userMessageKey);
|
|
expect(screen.getByRole("button")).toHaveTextContent("retry");
|
|
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();
|
|
});
|
|
});
|