diff --git a/frontend/src/components/ProjectsLayout.accessibility.test.tsx b/frontend/src/components/ProjectsLayout.accessibility.test.tsx new file mode 100644 index 000000000..9bb58aebe --- /dev/null +++ b/frontend/src/components/ProjectsLayout.accessibility.test.tsx @@ -0,0 +1,134 @@ +/* @vitest-environment jsdom */ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const apiClientMock = vi.hoisted(() => ({ + get: vi.fn(), + post: vi.fn(), + getServerSessionClaims: vi.fn(), +})); + +vi.mock("@/lib/api-client", () => ({ apiClient: apiClientMock })); + +vi.mock("lucide-react", () => ({ + CalendarDays: () => , + CheckCircle2: () => , + Clock: () => , + FileText: () => , + FolderOpen: () => , + GitBranch: () => , + ListChecks: () => , + Network: () => , + Search: () => , + User: () => , +})); + +import { ProjectsLayout } from "./ProjectsLayout"; + +const candidate = { + candidate_uid: "project_candidate:alpha", + project_uid: "project_candidate:alpha", + title: "Project: Alpha Checkout", + status_code: "needs_review", + score: 0.87, + object_count: 1, + requirement_count: 1, + issue_count: 0, + milestone_count: 0, + deliverable_count: 0, + participant_count: 0, + source_segment_count: 1, + representative_object_uids: [], + citation_bundle: [], + updated_at: "2026-08-03T00:00:00Z", +}; + +async function flushAsyncWork() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe("ProjectsLayout accessibility", () => { + let root: Root | null = null; + let container: HTMLDivElement | null = null; + + afterEach(() => { + if (root) act(() => root?.unmount()); + root = null; + container?.remove(); + container = null; + vi.clearAllMocks(); + }); + + it("announces candidate confirmation as busy while the request is pending", async () => { + let resolveConfirmation: ((value: typeof candidate) => void) | undefined; + const pendingConfirmation = new Promise((resolve) => { + resolveConfirmation = resolve; + }); + + apiClientMock.get.mockImplementation((path: string) => { + if (path === "/api/webdav/folders") return Promise.resolve([]); + if (path === "/api/tasks") return Promise.resolve([]); + if (path === "/api/projects/candidates") { + return Promise.resolve({ candidates: [candidate] }); + } + if (path === "/api/projects/project_candidate%3Aalpha/traceability") { + return Promise.resolve({ + project_uid: candidate.project_uid, + candidate, + objects: [], + edges: [], + }); + } + return Promise.reject(new Error(`Unexpected GET path: ${path}`)); + }); + apiClientMock.getServerSessionClaims.mockResolvedValue({ + userId: "alice", + organizationId: "org-acme", + workspaceId: "workspace-org-acme", + }); + apiClientMock.post.mockReturnValue(pendingConfirmation); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + await flushAsyncWork(); + await flushAsyncWork(); + + const confirmButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("프로젝트 후보 확정"), + ); + expect(confirmButton).toBeDefined(); + expect(confirmButton?.disabled).toBe(false); + expect(confirmButton?.getAttribute("aria-busy")).toBe("false"); + + await act(async () => { + confirmButton?.click(); + await Promise.resolve(); + }); + + expect(confirmButton?.disabled).toBe(true); + expect(confirmButton?.getAttribute("aria-busy")).toBe("true"); + expect(confirmButton?.textContent).toContain("확정 저장 중"); + expect(apiClientMock.post).toHaveBeenCalledWith( + "/api/projects/candidates/project_candidate%3Aalpha/confirm", + {}, + ); + + await act(async () => { + resolveConfirmation?.({ ...candidate, status_code: "confirmed" }); + await pendingConfirmation; + }); + + expect(confirmButton?.disabled).toBe(true); + expect(confirmButton?.getAttribute("aria-busy")).toBe("false"); + expect(confirmButton?.textContent).toContain("프로젝트 후보 확정됨"); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/ProjectsLayout.tsx b/frontend/src/components/ProjectsLayout.tsx index 20f1f72ee..2f750ffd9 100644 --- a/frontend/src/components/ProjectsLayout.tsx +++ b/frontend/src/components/ProjectsLayout.tsx @@ -681,6 +681,7 @@ export function ProjectsLayout() { type="button" onClick={handleConfirmCandidate} disabled={confirmSubmitting || candidateConfirmed} + aria-busy={confirmSubmitting} className="rounded-md bg-primary px-3 py-1.5 text-primary-foreground hover:bg-primary/90 disabled:cursor-not-allowed disabled:bg-secondary disabled:text-muted-foreground" > {candidateConfirmed ? '프로젝트 후보 확정됨' : confirmSubmitting ? '확정 저장 중' : '프로젝트 후보 확정'}