Files
tech-log-frontend/tests/component/form-foundation.test.tsx
T

281 lines
10 KiB
TypeScript

// @vitest-environment jsdom
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { createMemoryRouter, RouterProvider, useNavigate } from "react-router-dom";
import { z } from "zod";
import { describe, expect, it, vi } from "vitest";
import { createFailure } from "../../src/contracts/errors.ts";
import { Button } from "../../src/presentation/components/ui/button.ts";
import {
DirtyNavigationDialog,
ErrorSummary,
Form,
FormField,
useAppForm,
useDirtyNavigationGuard,
} from "../../src/presentation/forms/index.ts";
type Values = Readonly<Record<"name" | "note", string>>;
const schema = z
.object({
name: z.string().trim().min(2),
note: z.string().trim().default(""),
})
.strict();
const defaults: Values = { name: "", note: "" };
function FormHarness(props: Readonly<{
submit(command: Readonly<{ name: string; note?: string }>): Promise<
| Readonly<{ ok: true; value: string }>
| Readonly<{ ok: false; error: ReturnType<typeof createFailure> }>
>;
resetOnSuccess?: boolean;
}>) {
const form = useAppForm({
schema,
defaultValues: defaults,
allowedServerFields: ["name", "note"],
mapToCommand(values) {
return {
name: values.name,
...(values.note ? { note: values.note } : {}),
};
},
submit: props.submit,
resetOnSuccess: props.resetOnSuccess,
});
return (
<Form pending={form.pending} onSubmit={(event) => void form.submitForm(event)}>
<ErrorSummary
fieldErrors={form.fieldErrors}
formErrors={form.formErrors}
fieldLabels={{ name: "Name", note: "Note" }}
fieldId={form.fieldId}
onFocusField={form.focusField}
/>
<FormField {...form.field("name")} label="Name" required />
<FormField {...form.field("note")} label="Note" />
<Button type="submit" disabled={form.pending}>
{form.pending ? "Pending" : "Submit"}
</Button>
<Button onClick={() => form.reset()} disabled={!form.dirty}>
Reset
</Button>
<Button onClick={() => form.settleApplied()}>Confirm applied</Button>
<Button onClick={() => form.settleNotApplied()}>
Confirm not applied
</Button>
<output data-testid="dirty">{String(form.dirty)}</output>
<output data-testid="result">{form.result}</output>
</Form>
);
}
describe("local form facade", () => {
it("focuses the first invalid field and performs no command", async () => {
const user = userEvent.setup();
const submit = vi.fn();
render(<FormHarness submit={submit} />);
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(submit).not.toHaveBeenCalled();
expect(screen.getByRole("textbox", { name: /Name/ })).toHaveFocus();
expect(screen.getByRole("alert")).toHaveTextContent("Name");
});
it("submits transformed data once and clears dirty state after success", async () => {
const user = userEvent.setup();
let finish: ((value: { ok: true; value: string }) => void) | undefined;
const submit = vi.fn(
() =>
new Promise<{ ok: true; value: string }>((resolve) => {
finish = resolve;
}),
);
render(<FormHarness submit={submit} />);
await user.type(screen.getByRole("textbox", { name: /Name/ }), " Ready ");
await user.type(screen.getByRole("textbox", { name: "Note" }), " Safe ");
await user.dblClick(screen.getByRole("button", { name: "Submit" }));
await waitFor(() => expect(submit).toHaveBeenCalledOnce());
expect(submit).toHaveBeenCalledWith({ name: "Ready", note: "Safe" });
expect(screen.getByRole("button", { name: "Pending" })).toBeDisabled();
finish?.({ ok: true, value: "saved" });
await waitFor(() => expect(screen.getByTestId("dirty")).toHaveTextContent("false"));
expect(screen.getByTestId("result")).toHaveTextContent("success");
});
it("maps only approved 422 fields and never renders backend copy", async () => {
const user = userEvent.setup();
const failure = createFailure(
"VALIDATION_REJECTED",
"CREATE_ENTITY",
0,
{
validationIssues: [
{ path: "name", code: "REQUIRED" },
{ path: "serverOnly", code: "raw-secret-message" },
],
},
);
render(
<FormHarness submit={async () => ({ ok: false, error: failure })} />,
);
await user.type(screen.getByRole("textbox", { name: /Name/ }), "Valid");
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(await screen.findByRole("alert")).toHaveTextContent("필수 입력값입니다.");
expect(screen.getByRole("alert")).toHaveTextContent(
"서버가 확인하지 못한 입력 항목",
);
expect(document.body).not.toHaveTextContent("raw-secret-message");
});
it("keeps conflict input out of URL and storage", async () => {
const user = userEvent.setup();
localStorage.clear();
window.history.replaceState({}, "", "/form-test");
render(
<FormHarness
submit={async () => ({
ok: false,
error: createFailure("CONFLICT", "CREATE_ENTITY", 0),
})}
/>,
);
const secretLike = "token-like-do-not-copy";
await user.type(screen.getByRole("textbox", { name: /Name/ }), secretLike);
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(await screen.findByText(/다른 변경과 충돌했습니다/)).toBeVisible();
expect(screen.getByRole("textbox", { name: /Name/ })).toHaveValue(secretLike);
expect(window.location.href).not.toContain(secretLike);
expect(JSON.stringify(localStorage)).not.toContain(secretLike);
});
it("blocks a second submit while the prior effect remains unknown", async () => {
const user = userEvent.setup();
const submit = vi.fn(async () => ({
ok: false as const,
error: createFailure("SERVER_FAILURE", "CREATE_ENTITY", 0, {
effect: "MAYBE_APPLIED",
}),
}));
render(<FormHarness submit={submit} resetOnSuccess={false} />);
const name = screen.getByRole("textbox", { name: /Name/ });
await user.type(name, "Alpha");
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(await screen.findByTestId("result")).toHaveTextContent(
"effect-unknown",
);
await user.clear(name);
await user.type(name, "Beta");
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(submit).toHaveBeenCalledOnce();
expect(screen.getByTestId("result")).toHaveTextContent("effect-unknown");
});
it("settles the submitted unknown snapshot without accepting later edits", async () => {
const user = userEvent.setup();
const submit = vi.fn(async () => ({
ok: false as const,
error: createFailure("SERVER_FAILURE", "CREATE_ENTITY", 0, {
effect: "MAYBE_APPLIED",
}),
}));
render(<FormHarness submit={submit} resetOnSuccess={false} />);
const name = screen.getByRole("textbox", { name: /Name/ });
await user.type(name, "Alpha");
await user.click(screen.getByRole("button", { name: "Submit" }));
await screen.findByText("effect-unknown");
await user.clear(name);
await user.type(name, "Beta");
await user.click(screen.getByRole("button", { name: "Confirm applied" }));
expect(name).toHaveValue("Beta");
expect(screen.getByTestId("result")).toHaveTextContent("success");
expect(screen.getByTestId("dirty")).toHaveTextContent("true");
await user.clear(name);
await user.type(name, "Alpha");
expect(screen.getByTestId("dirty")).toHaveTextContent("false");
});
it("releases an unknown submission only after explicit not-applied settlement", async () => {
const user = userEvent.setup();
const submit = vi
.fn()
.mockResolvedValueOnce({
ok: false as const,
error: createFailure("SERVER_FAILURE", "CREATE_ENTITY", 0, {
effect: "MAYBE_APPLIED",
}),
})
.mockResolvedValueOnce({ ok: true as const, value: "saved" });
render(<FormHarness submit={submit} resetOnSuccess={false} />);
await user.type(screen.getByRole("textbox", { name: /Name/ }), "Alpha");
await user.click(screen.getByRole("button", { name: "Submit" }));
await screen.findByText("effect-unknown");
await user.click(
screen.getByRole("button", { name: "Confirm not applied" }),
);
expect(screen.getByTestId("result")).toHaveTextContent("idle");
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(submit).toHaveBeenCalledTimes(2);
});
});
describe("dirty navigation guard", () => {
it("blocks navigation, restores focus on stay and proceeds explicitly", async () => {
const user = userEvent.setup();
function GuardedPage() {
const navigate = useNavigate();
const [dirty, setDirty] = useState(false);
const guard = useDirtyNavigationGuard(dirty);
return (
<>
<label htmlFor="guard-field">Guard field</label>
<input
id="guard-field"
onChange={() => setDirty(true)}
/>
<Button onClick={() => navigate("/target")}>Leave</Button>
<DirtyNavigationDialog guard={guard} />
</>
);
}
const router = createMemoryRouter(
[
{ path: "/", element: <GuardedPage /> },
{ path: "/target", element: <h1>Target</h1> },
],
{ initialEntries: ["/"] },
);
render(<RouterProvider router={router} />);
await user.type(screen.getByRole("textbox", { name: "Guard field" }), "x");
const leave = screen.getByRole("button", { name: "Leave" });
await user.click(leave);
expect(
screen.getByRole("dialog", { name: "저장하지 않은 변경이 있습니다." }),
).toHaveAttribute("open");
await user.click(screen.getByRole("button", { name: "계속 작성" }));
await waitFor(() => expect(leave).toHaveFocus());
await user.click(leave);
await user.click(screen.getByRole("button", { name: "변경 버리고 이동" }));
expect(await screen.findByRole("heading", { name: "Target" })).toBeVisible();
});
});