// @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>; 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 }> >; 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 (
void form.submitForm(event)}> {String(form.dirty)} {form.result} ); } describe("local form facade", () => { it("focuses the first invalid field and performs no command", async () => { const user = userEvent.setup(); const submit = vi.fn(); render(); 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(); 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( ({ 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( ({ 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(); 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(); 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(); 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 ( <> setDirty(true)} /> ); } const router = createMemoryRouter( [ { path: "/", element: }, { path: "/target", element:

Target

}, ], { initialEntries: ["/"] }, ); render(); 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(); }); });