From cf899b7f1594e3ec522817be7ece7af3c8e17c96 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Tue, 18 Aug 2026 11:47:58 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9C=A0=EC=8A=A4=EC=BC=80=EC=9D=B4=EC=8A=A4?= =?UTF-8?q?=20TDD=2016=ED=85=8C=EB=A7=88=20=EC=8A=A4=ED=8E=99=EA=B3=BC=20?= =?UTF-8?q?=EC=A0=91=EA=B7=BC=EC=84=B1=20=EC=A0=9C=ED=92=88=20=EA=B2=B0?= =?UTF-8?q?=ED=95=A8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 지원 티켓 작성 UI 신설(설정)·관리자 해결 노트 입력 신설(Admin): 서버 계약은 있었으나 웹 진입점이 없던 2결함 - a11y: outline 채널 포커스 링(ui/shell css), 세션바 44px 터치 타깃, 청록 하드코딩 그라디언트를 테마 토큰으로 교체, 설정 라벨/헤딩/대비·리뷰 44px·모바일 오버플로 수정 - uc-*.spec.ts 16테마 239 시나리오 신규(수집 1109 tests/61 files), 기존 스펙 5종 계약 드리프트 교정 - breakpoint-sweep: widthsFor 솎아내기가 실기기 대표 폭(360/390/1024)을 탈락시키는 테스트 결함 수정 — keep 시드를 전체 DEVICE_WIDTHS로, 3연폭 예외는 솎아내기 발생 여부 기준으로 - 검증: tsc PASS, 병렬 게이트 1040 passed(데스크톱 밀도 충돌 1건 해소 후 focused 68/68 GREEN), 직렬 게이트 52/5/4 삼각화 — breakpoint(테스트 결함)·kb(낡은 dev API 재기동)·voice(일시적) 해소, 교사 재평가 2건은 엔진 구독 한도(resets 3pm)로 skip 후 재검증 대기 - 문서/SSOT: HANDOFF·TODO·대시보드·testing 가이드 동기화, 증거 usecase-tdd-2026-08-18.json, SSOT 체커 PASS(59) --- apps/web/e2e/breakpoint-sweep.spec.ts | 16 +- apps/web/e2e/full-sweep-admin-ai.spec.ts | 38 +- .../web/e2e/full-sweep-session-review.spec.ts | 13 +- apps/web/e2e/full-sweep-shell.spec.ts | 28 +- apps/web/e2e/readiness.spec.ts | 74 +- apps/web/e2e/teacher.spec.ts | 17 + apps/web/e2e/uc-a11y-mobile.spec.ts | 1135 +++++++++++++++++ apps/web/e2e/uc-admin-console.spec.ts | 1044 +++++++++++++++ apps/web/e2e/uc-auth-onboarding.spec.ts | 416 ++++++ .../web/e2e/uc-error-resilience-shell.spec.ts | 799 ++++++++++++ .../web/e2e/uc-learner-home-dashboard.spec.ts | 655 ++++++++++ .../e2e/uc-learner-practice-history.spec.ts | 610 +++++++++ apps/web/e2e/uc-practice-g4-g5.spec.ts | 970 ++++++++++++++ apps/web/e2e/uc-review-alliance-pulse.spec.ts | 698 ++++++++++ apps/web/e2e/uc-review-rupture-repair.spec.ts | 654 ++++++++++ apps/web/e2e/uc-session-coach-voice.spec.ts | 422 ++++++ apps/web/e2e/uc-session-conversation.spec.ts | 733 +++++++++++ apps/web/e2e/uc-session-prestart.spec.ts | 575 +++++++++ apps/web/e2e/uc-session-review-core.spec.ts | 801 ++++++++++++ apps/web/e2e/uc-session-timer.spec.ts | 453 +++++++ apps/web/e2e/uc-settings-consent.spec.ts | 667 ++++++++++ apps/web/e2e/uc-teacher-console.spec.ts | 927 ++++++++++++++ apps/web/src/components/shell/shell.css | 6 + apps/web/src/components/ui/ui.css | 6 + apps/web/src/lib/api.ts | 4 + apps/web/src/pages/Admin.tsx | 25 +- apps/web/src/pages/SessionReview.tsx | 3 +- apps/web/src/pages/Settings.tsx | 111 +- apps/web/src/pages/admin/admin-console.css | 20 + .../pages/session-review/session-review.css | 3 + apps/web/src/pages/session/session.css | 8 +- apps/web/src/pages/settings/settings.css | 28 + docs/HANDOFF.md | 26 +- docs/TODO.md | 5 + docs/dev_dashboard.html | 8 +- docs/guides/testing.md | 7 +- docs/ops/evidence/usecase-tdd-2026-08-18.json | 51 + 37 files changed, 11989 insertions(+), 67 deletions(-) create mode 100644 apps/web/e2e/uc-a11y-mobile.spec.ts create mode 100644 apps/web/e2e/uc-admin-console.spec.ts create mode 100644 apps/web/e2e/uc-auth-onboarding.spec.ts create mode 100644 apps/web/e2e/uc-error-resilience-shell.spec.ts create mode 100644 apps/web/e2e/uc-learner-home-dashboard.spec.ts create mode 100644 apps/web/e2e/uc-learner-practice-history.spec.ts create mode 100644 apps/web/e2e/uc-practice-g4-g5.spec.ts create mode 100644 apps/web/e2e/uc-review-alliance-pulse.spec.ts create mode 100644 apps/web/e2e/uc-review-rupture-repair.spec.ts create mode 100644 apps/web/e2e/uc-session-coach-voice.spec.ts create mode 100644 apps/web/e2e/uc-session-conversation.spec.ts create mode 100644 apps/web/e2e/uc-session-prestart.spec.ts create mode 100644 apps/web/e2e/uc-session-review-core.spec.ts create mode 100644 apps/web/e2e/uc-session-timer.spec.ts create mode 100644 apps/web/e2e/uc-settings-consent.spec.ts create mode 100644 apps/web/e2e/uc-teacher-console.spec.ts create mode 100644 docs/ops/evidence/usecase-tdd-2026-08-18.json diff --git a/apps/web/e2e/breakpoint-sweep.spec.ts b/apps/web/e2e/breakpoint-sweep.spec.ts index 50b4677..861ae66 100644 --- a/apps/web/e2e/breakpoint-sweep.spec.ts +++ b/apps/web/e2e/breakpoint-sweep.spec.ts @@ -175,7 +175,10 @@ function widthsFor(breakpoints: number[]): number[] { if (merged.length > MAX_WIDTHS_PER_ROUTE) { // 상한을 넘으면 실기기 폭을 먼저 유지하고(사용자가 실제로 보는 폭), // 경계 폭은 균등 간격으로 솎아 낸다 — 경계 3연폭 세트는 최대한 함께 남긴다. - const keep = new Set(devices); + // keep 은 "경계에 이미 있다고 제외된 devices" 가 아니라 전체 DEVICE_WIDTHS 로 + // 시드한다. 아니면 브레이크포인트와 겹치는 실기기 폭(예: 360)이 솎아내기에서 + // 빠져 arrayContaining(DEVICE_WIDTHS) 계약이 깨진다. + const keep = new Set(DEVICE_WIDTHS.map(clampWidth)); const budget = MAX_WIDTHS_PER_ROUTE - keep.size; const step = Math.max(1, Math.ceil(ordered.length / Math.max(1, budget))); for (let i = 0; i < ordered.length; i += step) keep.add(ordered[i]); @@ -640,6 +643,15 @@ function widthsForPage(page: keyof typeof PAGE_CSS) { return widthsFor([...new Set([...index.common, ...(index.byPage[page] ?? [])])]); } +/** 해당 페이지가 폭 상한을 넘어 솎아내기가 일어났는지 — 3연폭 계약의 예외 조건. */ +function isPrunedPage(page: keyof typeof PAGE_CSS) { + const candidate = new Set(DEVICE_WIDTHS.map(clampWidth)); + for (const bp of [...new Set([...index.common, ...(index.byPage[page] ?? [])])]) { + for (const delta of [-1, 0, 1]) candidate.add(clampWidth(bp + delta)); + } + return candidate.size > MAX_WIDTHS_PER_ROUTE; +} + async function runSweep(page: Page, routes: RouteCase[]) { const report: string[] = []; let total = 0; @@ -674,7 +686,7 @@ test.describe("브레이크포인트 경계 스윕 @single-run", () => { // 브레이크포인트 B 마다 B-1/B/B+1 이 살아 있는지(클램프 경계 제외). for (const bp of index.byPage[pageKey] ?? []) { if (bp <= MIN_WIDTH || bp >= MAX_WIDTH) continue; - if (widths.length >= MAX_WIDTHS_PER_ROUTE) continue; // 상한으로 솎아 낸 경우는 예외 + if (isPrunedPage(pageKey)) continue; // 상한으로 솎아 낸 경우는 예외 expect(widths, `${pageKey}: 브레이크포인트 ${bp}px 경계 3연폭 누락`).toEqual( expect.arrayContaining([bp - 1, bp, bp + 1]), ); diff --git a/apps/web/e2e/full-sweep-admin-ai.spec.ts b/apps/web/e2e/full-sweep-admin-ai.spec.ts index f55c536..490362b 100644 --- a/apps/web/e2e/full-sweep-admin-ai.spec.ts +++ b/apps/web/e2e/full-sweep-admin-ai.spec.ts @@ -10,6 +10,13 @@ interface AdminUsageBreakdown { tokens_in: number; tokens_out: number; cost_usd: number; + recorded_cost_usd: number; + estimated_cost_usd: number; + token_metered_turns: number; + token_unmetered_turns: number; + cost_basis: "provider_estimate" | "provider_reported" | "reference_rate" | "unavailable"; + rate_label?: string | null; + rate_source_url?: string | null; } interface AdminUsageDailyCost { @@ -38,9 +45,13 @@ interface AdminUsageResponse { window_days: number; total_turns: number; metered_turns: number; + token_metered_turns: number; + token_unmetered_turns: number; tokens_in: number; tokens_out: number; cost_usd: number; + recorded_cost_usd: number; + estimated_cost_usd: number; budget: { limit_usd: number; used_ratio: number; @@ -74,9 +85,14 @@ function usageFixture( window_days: days, total_turns: 0, metered_turns: 0, + token_metered_turns: 0, + token_unmetered_turns: 0, tokens_in: 0, tokens_out: 0, cost_usd: 0, + // 실 API는 cost_usd = recorded + estimated 로 집계한다. 기본은 전액 기록값으로 둔다. + recorded_cost_usd: overrides.cost_usd ?? 0, + estimated_cost_usd: 0, budget: { limit_usd: 0, used_ratio: 0, remaining_usd: null, status: "disabled" }, evaluator_cache: { enabled: false, @@ -381,7 +397,8 @@ test.describe("full sweep: admin ai operations", () => { await page.getByRole("button", { name: "7일" }).click(); await expect(page.getByRole("button", { name: "7일" })).toHaveAttribute("aria-pressed", "true"); await expect(ledger).toContainText("$1.11"); - await expect(ledger).toContainText("7일 DB 집계"); + // 재설계된 원장 카드: 데이터 로드 후에는 "N일 DB 집계" 대신 기록·참조 분해를 표시한다. + await expect(ledger).toContainText("기록 $1.11 · 참조 $0"); // 늦게 도착한 90일 응답은 active 플래그로 무시되어야 한다. slowWindow.resolve(); @@ -405,6 +422,8 @@ test.describe("full sweep: admin ai operations", () => { usageFixture(days, { total_turns: 40, metered_turns: 30, + token_metered_turns: 24, + token_unmetered_turns: 6, tokens_in: 125_000, tokens_out: 18_500, cost_usd: 6.6212, @@ -424,9 +443,15 @@ test.describe("full sweep: admin ai operations", () => { provider: "openai", model: "gpt-5-mini", turns: 30, + token_metered_turns: 24, + token_unmetered_turns: 6, tokens_in: 125_000, tokens_out: 18_500, cost_usd: 6.6212, + recorded_cost_usd: 6.6212, + estimated_cost_usd: 0, + cost_basis: "provider_reported", + rate_label: null, }, ], daily_cost: [ @@ -440,14 +465,15 @@ test.describe("full sweep: admin ai operations", () => { await page.goto("/admin/ai"); - // DB 계량 커버리지 트랙: metered/total 비율과 미계량 턴 안내. + // 토큰 계량 커버리지 트랙: token_metered/metered 비율과 토큰 미수집·DB 원장 미연결 안내. const budgetPanel = page.locator(".aic-budget"); - await expect(budgetPanel).toContainText("DB 계량 커버리지"); - await expect(budgetPanel).toContainText("75%"); - await expect(budgetPanel.locator('[aria-label="DB 계량 커버리지 75%"]')).toHaveCount(1); + await expect(budgetPanel).toContainText("토큰 계량 커버리지"); + await expect(budgetPanel).toContainText("80%"); + await expect(budgetPanel.locator('[aria-label="토큰 계량 커버리지 80%"]')).toHaveCount(1); await expect(budgetPanel).toContainText( - "10개 턴은 provider·model·token·cost 계량 필드가 없습니다.", + "6개 원장 호출은 과거 토큰 미수집 건이라 복원 없이 미계량으로 남깁니다.", ); + await expect(budgetPanel).toContainText("DB 원장 미연결 10턴"); // 일별 비용 차트: role=img aria-label, 날짜·비용 툴팁, 막대 개수. const chart = page.locator(".aic-chart[role='img']"); diff --git a/apps/web/e2e/full-sweep-session-review.spec.ts b/apps/web/e2e/full-sweep-session-review.spec.ts index 13d6a25..08b6224 100644 --- a/apps/web/e2e/full-sweep-session-review.spec.ts +++ b/apps/web/e2e/full-sweep-session-review.spec.ts @@ -245,19 +245,26 @@ test.describe("full-sweep session review", () => { const filters = page.locator(".sr-tx__filters"); const allChip = filters.getByRole("button", { name: "전체" }); const mineChip = filters.getByRole("button", { name: "내 발화만" }); + // 동맹 펄스 카드(AlliancePulseCard)의 근거 장면 선택기가 같은 발화를 인용하므로 + // 발화 본문 확인은 축어록 탭패널 안으로 한정한다(strict mode 중복 방지). + const transcript = page.locator("#sr-panel-transcript"); await expect(allChip).toHaveAttribute("aria-pressed", "true"); await expect(mineChip).toHaveAttribute("aria-pressed", "false"); await expect(page.locator(".sr-turn")).toHaveCount(6); - await expect(page.getByText("계속 제가 이상한 사람 같았어요.", { exact: false })).toBeVisible(); + await expect( + transcript.getByText("계속 제가 이상한 사람 같았어요.", { exact: false }), + ).toBeVisible(); await mineChip.click(); await expect(mineChip).toHaveAttribute("aria-pressed", "true"); await expect(allChip).toHaveAttribute("aria-pressed", "false"); await expect(page.locator(".sr-turn")).toHaveCount(3); - await expect(page.getByText("계속 제가 이상한 사람 같았어요.", { exact: false })).toBeHidden(); await expect( - page.getByText("스스로를 의심하게 됐던 걸까요?", { exact: false }), + transcript.getByText("계속 제가 이상한 사람 같았어요.", { exact: false }), + ).toBeHidden(); + await expect( + transcript.getByText("스스로를 의심하게 됐던 걸까요?", { exact: false }), ).toBeVisible(); // 축어록이 없으면 두 필터 칩 모두 비활성화된다. diff --git a/apps/web/e2e/full-sweep-shell.spec.ts b/apps/web/e2e/full-sweep-shell.spec.ts index 512379d..7fac8dc 100644 --- a/apps/web/e2e/full-sweep-shell.spec.ts +++ b/apps/web/e2e/full-sweep-shell.spec.ts @@ -119,35 +119,21 @@ test.describe("full sweep — shared shell, GNB, and routing guards", () => { // checklist: shell-appshell-role-theming // 역할별 셸 진입 시 body[data-role]이 learner/instructor/admin으로 설정된다. test("applies body[data-role] theming for each role shell", async ({ page }) => { - let currentUser = meResponse(); - await page.route("**/api/auth/me", async (route) => { - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify(currentUser), - }); - }); - + // /auth/me만 fixture로 고정하면 나머지 실 API 호출(/api/sessions 등)이 쿠키 없이 + // 401을 돌려주고, api 클라이언트의 AUTH_EXPIRED 이벤트가 AuthProvider의 user를 + // 비워 body[data-role]을 제거한다(실제 세션 만료 계약). 역할 테마는 역할별 실 + // dev-login 세션으로 검증한다. + await signInAsLearner(page); await page.goto("/learn"); await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 }); await expect(page.locator("body")).toHaveAttribute("data-role", "learner"); - currentUser = meResponse({ - role: "teacher", - email: "shell.teacher@hs.ac.kr", - display_name: "Shell Teacher", - }); + await signInAsTeacher(page); await page.goto("/teach"); await expect(page.locator(".vg-topbar")).toBeVisible({ timeout: 15_000 }); await expect(page.locator("body")).toHaveAttribute("data-role", "instructor"); - currentUser = meResponse({ - role: "admin", - email: "shell.admin@twentyoz.kr", - display_name: "Shell Admin", - admin_access: true, - super_admin: true, - }); + await signInAsAdmin(page); await page.goto("/admin"); await expect(page.locator(".vg-topbar")).toBeVisible({ timeout: 15_000 }); await expect(page.locator("body")).toHaveAttribute("data-role", "admin"); diff --git a/apps/web/e2e/readiness.spec.ts b/apps/web/e2e/readiness.spec.ts index df37e07..eab3c51 100644 --- a/apps/web/e2e/readiness.spec.ts +++ b/apps/web/e2e/readiness.spec.ts @@ -1,5 +1,9 @@ import { expect, test, type APIResponse, type Page } from "@playwright/test"; -import { completeOnboarding, expectNoHorizontalOverflow } from "./support"; +import { + completeOnboarding, + expectNoHorizontalOverflow, + withGlobalEngineConfigLock, +} from "./support"; async function expectResponseOk(response: APIResponse) { if (!response.ok()) { @@ -74,6 +78,7 @@ test.describe("production readiness gates", () => { }); test("admin-owned runtime controls are durable database state", async ({ page }) => { + test.setTimeout(60_000); // 엔진 설정 전역 락 대기 여유 포함 await signIn(page, "admin", "readiness-admin@twentyoz.kr", "Readiness Admin"); const usersResponse = await page.request.get("/api/admin/users"); @@ -81,21 +86,56 @@ test.describe("production readiness gates", () => { const users = (await usersResponse.json()) as { source: string; durable: boolean }; expect(users).toMatchObject({ source: "database", durable: true }); - const engineResponse = await page.request.get("/api/admin/engine-config"); - await expectResponseOk(engineResponse); - const engine = (await engineResponse.json()) as { - durable: boolean; - source: string; - engine_mode: string; - engine_url: string; - model: string; - reasoning_effort: string | null; - }; - expect(engine).toMatchObject({ durable: true, source: "database" }); - expect(["claude_cli", "claude_api", "codex_cli", "agy_cli", "openai", "solar"]).toContain( - engine.engine_mode, - ); - expect(engine.engine_url).toMatch(/^https?:\/\//); - expect(engine.model.trim().length).toBeGreaterThan(0); + // 엔진 설정의 durable 계약은 "관리자가 저장한 설정이 DB 행으로 남는다"이다. + // 시드 없는 격리 DB에서는 저장 전 GET이 runtime_default(durable=false)를 + // 돌려주는 것이 정상 계약이므로, 실제 게이트웨이 검증을 거친 저장 round-trip + // 후의 durable/database 상태를 확인한다. (support.ts withGlobalEngineConfigLock 계약) + await withGlobalEngineConfigLock("readiness-admin-engine", async () => { + type EngineConfig = { + durable: boolean; + source: string; + engine_mode: string; + engine_url: string; + model: string; + reasoning_effort: string | null; + }; + const currentResponse = await page.request.get("/api/admin/engine-config"); + await expectResponseOk(currentResponse); + const current = (await currentResponse.json()) as EngineConfig; + + const capabilityResponse = await page.request.get( + `/api/admin/engine-capabilities?engine_mode=${encodeURIComponent(current.engine_mode)}`, + ); + await expectResponseOk(capabilityResponse); + const capability = (await capabilityResponse.json()) as { + models: Array<{ id: string; default_reasoning_effort?: string | null }>; + }; + const selected = + capability.models.find((model) => model.id === current.model) ?? capability.models[0]; + if (!selected) throw new Error("engine capability returned no selectable model"); + + const patchResponse = await page.request.patch("/api/admin/engine-config", { + data: { + engine_mode: current.engine_mode, + engine_url: current.engine_url, + model: selected.id, + reasoning_effort: + selected.id === current.model + ? current.reasoning_effort + : (selected.default_reasoning_effort ?? null), + }, + }); + await expectResponseOk(patchResponse); + + const engineResponse = await page.request.get("/api/admin/engine-config"); + await expectResponseOk(engineResponse); + const engine = (await engineResponse.json()) as EngineConfig; + expect(engine).toMatchObject({ durable: true, source: "database" }); + expect(["claude_cli", "claude_api", "codex_cli", "agy_cli", "openai", "solar"]).toContain( + engine.engine_mode, + ); + expect(engine.engine_url).toMatch(/^https?:\/\//); + expect(engine.model.trim().length).toBeGreaterThan(0); + }); }); }); diff --git a/apps/web/e2e/teacher.spec.ts b/apps/web/e2e/teacher.spec.ts index c2efb82..3a116d5 100644 --- a/apps/web/e2e/teacher.spec.ts +++ b/apps/web/e2e/teacher.spec.ts @@ -70,6 +70,21 @@ async function createEndedLearnerSession(page: Page) { }); await expectResponseOk(start); const session = (await start.json()) as SessionStartResponse; + // 0턴 종료 회기는 서버 계약상 검토 대상이 아니다(teacher.py _needs_teacher_review: + // turn_count > 0 또는 평가 ready/error). 검토 큐에 실제로 잡히는 종료 회기를 만들기 + // 위해 실 엔진으로 학습자 발화 1턴을 기록한 뒤 종료한다. + let turn = await page.request.post(`/api/sessions/${session.session_id}/turn`, { + data: { text: "오늘 많이 버거웠겠어요. 지금 가장 크게 남아 있는 마음은 어떤 건가요?" }, + timeout: 120_000, + }); + for (let attempt = 0; attempt < 2 && !turn.ok(); attempt += 1) { + await page.waitForTimeout(1000); + turn = await page.request.post(`/api/sessions/${session.session_id}/turn`, { + data: { text: "조금 천천히 이야기해도 괜찮습니다. 지금 마음에 남는 장면이 있나요?" }, + timeout: 120_000, + }); + } + await expectResponseOk(turn); const ended = await page.request.post(`/api/sessions/${session.session_id}/end`); await expectResponseOk(ended); return session.session_id; @@ -599,6 +614,7 @@ test.describe("teacher console", () => { }); test("renders real server sessions from server-owned rows", async ({ page }) => { + test.setTimeout(120_000); // 실 엔진 1턴 생성 포함 const sessionId = await createEndedLearnerSession(page); await signInAsTeacher(page); @@ -1208,6 +1224,7 @@ test.describe("teacher console", () => { }); test("keeps long teacher lists in bounded panels", async ({ page }) => { + test.setTimeout(120_000); // 실 엔진 1턴 생성 포함 await createEndedLearnerSession(page); await signInAsTeacher(page); diff --git a/apps/web/e2e/uc-a11y-mobile.spec.ts b/apps/web/e2e/uc-a11y-mobile.spec.ts new file mode 100644 index 0000000..873484a --- /dev/null +++ b/apps/web/e2e/uc-a11y-mobile.spec.ts @@ -0,0 +1,1135 @@ +/* ===================================================================== + uc-a11y-mobile.spec.ts — 접근성·모바일 레이아웃 유스케이스 감사. + + 목적: + 주요 6화면(login, learn 홈, session active, session review, settings, + teach 홈)을 실제 사용자 관점의 접근성 검사 6종으로 감사한다. + (1) 가로 오버플로 0 (support.expectNoHorizontalOverflow) + (2) 보이는 인터랙티브 요소 44×44px 이상 — 위반 목록을 실패 메시지로 리포트 + (3) h1 헤딩 존재 + 헤딩 레벨 논리 순서(단계 건너뜀 없음) + (4) 폼 컨트롤 라벨 연결(getByLabel 성립) + (5) Tab 순회 시 :focus-visible 스타일 변화(포커스 표시) + (6) 주요 CTA 색 대비 4.5:1 스팟체크(getComputedStyle 산술) + + 근거: + - 각 화면의 실제 소스(src/pages/Login.tsx·LearnerHome.tsx·Session.tsx· + SessionReview.tsx·Settings.tsx·Professor.tsx)의 role/aria-label/한국어 문구. + - WCAG 2.1 1.4.3(대비), 2.4.6/1.3.1(헤딩·라벨), 2.4.7(포커스 가시성), + 모바일 터치 타깃 44×44(WCAG 2.5.5 / 플랫폼 HIG). + - fixture 형태는 full-sweep-session.spec.ts / full-sweep-learner-home.spec.ts / + layout-visual-gate.spec.ts / e2e/session-review-fixture.ts 에서 차용. + + 중복 회피: + breakpoint-sweep.spec.ts(@single-run)는 겹침·잘림·붕괴·화면밖·문서오버플로를 + 경계 폭 전수로, layout-visual-gate.spec.ts(@single-run)는 고정 7폭에서 + 오버플로·잘림을 검사한다. 이 스펙은 두 게이트가 다루지 않는 접근성 축 + (타깃 크기, 헤딩 구조, 라벨, 포커스 링, 색 대비)을 두 실행 뷰포트 + (chromium-desktop 1440×900 / chromium-mobile Pixel 5)에서 검사하고, + 오버플로는 mocked-auth fixture 상태(두 게이트는 dev-login 실계정)라는 + 다른 각도에서만 재확인한다. + + 실 API 없이 route fixture 로만 동작하며, 실제 AI 엔진 턴 생성은 없다. + ===================================================================== */ + +import { expect, test, type Locator, type Page } from "@playwright/test"; +import { + FILLED_REVIEW_SESSION_ID, + routeFilledSessionReview, + routePrepostMeasures, +} from "./session-review-fixture"; +import { expectNoHorizontalOverflow } from "./support"; + +/* ───────────────────────────────────────────────────────────────────── + 공통 fixture — mocked-auth 패턴 (catch-all 먼저, 개별 fixture 나중) + ───────────────────────────────────────────────────────────────────── */ + +function jsonRoute(body: unknown, status = 200) { + return { status, contentType: "application/json", body: JSON.stringify(body) }; +} + +async function routeUnmockedApi(page: Page) { + await page.route("**/api/**", (route) => + route.fulfill(jsonRoute({ detail: "not part of this focused fixture" }, 404)), + ); +} + +/** App.tsx 게이트(approved + consent + onboarding)가 보는 필드를 빠짐없이 채운다. */ +function meBody(role: "learner" | "teacher") { + return { + user_id: role === "learner" ? "e2e-a11y-learner-1" : "e2e-a11y-teacher-1", + email: `${role}@hs.ac.kr`, + role, + display_name: role === "learner" ? "학습자" : "교수자", + admin_access: false, + super_admin: false, + account_status: "approved", + approval_required: false, + cohort_ids: ["e2e-a11y-cohort"], + // /auth/me 계약(api.gen.ts MeResponse)은 epoch 초(number|null)다 — ISO 문자열을 + // 보내면 Settings 의 formatConsentDate(value * 1000)가 Invalid time value 로 죽는다. + consent_at: Date.UTC(2026, 7, 1) / 1000, + onboarding_completed_at: Date.UTC(2026, 7, 1) / 1000, + nickname: role === "learner" ? "학습자" : "교수자", + self_introduction: "", + avatar_url: "", + }; +} + +async function routeAuthed(page: Page, role: "learner" | "teacher") { + await page.route("**/auth/me", (route) => route.fulfill(jsonRoute(meBody(role)))); +} + +/** 미인증 상태 + 로그인 설정 fixture — 로그인 화면 전용. */ +async function routeLoginScreen(page: Page) { + await routeUnmockedApi(page); + await page.route("**/auth/me", (route) => + route.fulfill(jsonRoute({ detail: "not authenticated" }, 401)), + ); + await page.route("**/auth/config", (route) => + route.fulfill( + jsonRoute({ + allowed_email_domains: ["hs.ac.kr"], + dev_login_enabled: false, + google_oauth_configured: true, + providers: [ + { provider: "google", configured: true, enabled: true, login_path: "/auth/login" }, + ], + redirect_uri: "http://localhost:8000/api/auth/callback", + saml_configured: false, + }), + ), + ); +} + +/* ── learn 홈 fixture — full-sweep-learner-home.spec.ts 형태 차용 ────── */ + +const NOW = Date.UTC(2026, 7, 18, 9, 0, 0); +const HOUR = 60 * 60 * 1000; +const iso = (msAgo: number) => new Date(NOW - msAgo).toISOString(); + +const LEARN_ACT_ID = "uca11yact01"; + +const LEARN_PERSONAS = [ + { + code: "P1", + display_name: "성하늘", + difficulty: "easy", + theory_target: ["humanistic"], + demographics: { age_band: "20대" }, + presenting_summary: "학업 스트레스와 수면 문제를 호소", + voice_preset: null, + source: "database", + degraded: false, + }, + { + code: "P2", + display_name: "김도윤", + difficulty: "moderate", + theory_target: ["cbt"], + demographics: { age_band: "30대" }, + presenting_summary: "직장 내 대인관계 갈등을 호소", + voice_preset: null, + source: "database", + degraded: false, + }, +]; + +const LEARN_S_ACT = { + session_id: LEARN_ACT_ID, + session_no: 2, + persona_code: "P1", + persona_name: "성하늘", + status: "active", + stage: "탐색", + started_at: iso(1 * HOUR), + ended_at: null, + review_ready: false, + archived: false, + archived_at: null, + turn_count: 6, + learner_turn_count: 3, + client_turn_count: 3, +}; + +const LEARN_S_REV = { + session_id: "uca11yrev01", + session_no: 1, + persona_code: "P2", + persona_name: "김도윤", + status: "ended", + stage: "정리", + started_at: iso(3 * HOUR), + ended_at: iso(2.5 * HOUR), + review_ready: true, + archived: false, + archived_at: null, + turn_count: 10, + learner_turn_count: 5, + client_turn_count: 5, +}; + +const LEARN_DASHBOARD = { + message: "학습 현황 요약", + source: "runtime", + overview: { + total_sessions: 2, + active_sessions: 1, + review_ready_sessions: 1, + completed_sessions: 1, + archived_sessions: 0, + learner_turns: 8, + client_turns: 8, + last_practiced_at: iso(1 * HOUR), + }, + growth: { + trend: "up", + avg_rapport: 0.4, + avg_score: 0.6, + evaluated_sessions: 1, + latest_score: 0.6, + first_score: 0.6, + score_delta: 0, + top_techniques: ["감정 반영"], + points: [ + { + session_id: LEARN_S_REV.session_id, + session_no: 1, + persona_code: "P2", + stage: "정리", + started_at: LEARN_S_REV.started_at, + ended_at: LEARN_S_REV.ended_at, + score: 0.6, + rapport: 0.4, + technique_count: 3, + watch_count: 1, + }, + ], + }, + recent_feedback: [ + { + session_id: LEARN_S_REV.session_id, + session_no: 1, + persona_code: "P2", + persona_name: "김도윤", + stage: "정리", + score: 0.6, + rapport: 0.4, + note: "감정 반영은 좋았지만 탐색 질문 전에 요약이 필요합니다.", + created_at: LEARN_S_REV.ended_at, + turn_seq: 6, + techniques: ["감정 반영"], + }, + ], + persona_progress: [ + { + persona_code: "P1", + persona_name: "성하늘", + sessions: 1, + active_sessions: 1, + completed_sessions: 0, + review_ready_sessions: 0, + rapport_percent: 52, + trend: "insufficient", + latest_stage: "탐색", + latest_score: null, + latest_at: iso(1 * HOUR), + }, + ], + achievements: [ + { id: "first-session", label: "첫 회기 완료", detail: "첫 회기를 끝냈습니다.", state: "done" }, + ], +}; + +const LEARN_DETAIL = { + case_id: "case-uca11y", + session_id: LEARN_ACT_ID, + persona_code: "P1", + persona_name: "성하늘", + status: "active", + stage: "탐색", + started_at: iso(1 * HOUR), + ended_at: null, + review_ready: false, + theory_mode: "humanistic", + goal_stages: ["라포", "탐색"], + effective_openness: 0.4, + duration_limit_seconds: 1200, + warning_before_end_seconds: 600, + progress: null, + turns: [ + { + turn_seq: 1, + speaker: "learner", + stage: "라포", + text: "안녕하세요, 오늘은 어떤 이야기를 나누고 싶으세요?", + created_at: iso(0.9 * HOUR), + }, + { + turn_seq: 2, + speaker: "client", + stage: "라포", + text: "요즘은 잠이 잘 안 와요.", + created_at: iso(0.8 * HOUR), + }, + ], +}; + +async function routeLearnerHome(page: Page) { + await routeUnmockedApi(page); + await routeAuthed(page, "learner"); + await page.route(/\/api\/personas$/, (route) => route.fulfill(jsonRoute(LEARN_PERSONAS))); + await page.route(/\/api\/sessions$/, (route) => + route.fulfill(jsonRoute({ sessions: [LEARN_S_ACT, LEARN_S_REV], source: "runtime" })), + ); + await page.route(/\/api\/sessions\/dashboard$/, (route) => + route.fulfill(jsonRoute(LEARN_DASHBOARD)), + ); + await page.route(new RegExp(`/api/sessions/${LEARN_ACT_ID}$`), (route) => + route.fulfill(jsonRoute(LEARN_DETAIL)), + ); +} + +/* ── session active fixture — full-sweep-session.spec.ts 형태 차용 ──── */ + +const SESSION_ID = "44444444-4444-4444-8444-4444a11e0001"; + +async function routeActiveSession(page: Page) { + await routeUnmockedApi(page); + await routeAuthed(page, "learner"); + await page.route("**/api/personas", (route) => + route.fulfill( + jsonRoute([ + { + code: "P1", + display_name: "민서(청소년 우울)", + difficulty: "hard", + theory_target: ["humanistic"], + demographics: { age_band: "10대" }, + presenting_summary: "자퇴와 무기력감을 둘러싼 상담 연습", + voice_preset: "soft-young-fem", + source: "database", + degraded: false, + }, + ]), + ), + ); + await page.route("**/api/voice/health", (route) => + route.fulfill(jsonRoute({ available: true, reason: null })), + ); + await page.route("**/api/users/me/prepost-measures**", (route) => + route.fulfill( + jsonRoute({ + pilot_id: "phase3-pilot-draft", + instrument_version: "pilot-prepost-scaffold-2026-06-28", + measures: [], + complete_pre_count: 0, + complete_post_count: 0, + updated_at: null, + }), + ), + ); + await page.route("**/api/sessions", async (route) => { + if (route.request().method() !== "POST") { + await route.fallback(); + return; + } + await route.fulfill( + jsonRoute( + { + session_id: SESSION_ID, + case_id: "uc-a11y-case-001", + session_no: 1, + stage: "라포", + effective_openness: 0.21, + recall_summary: null, + degraded: false, + goal_stages: ["라포", "탐색"], + duration_limit_seconds: 3600, + warning_before_end_seconds: 600, + }, + 201, + ), + ); + }); + await page.route(`**/api/sessions/${SESSION_ID}`, (route) => + route.fulfill( + jsonRoute({ + session_id: SESSION_ID, + case_id: "uc-a11y-case-001", + persona_code: "P1", + persona_name: "민서", + session_no: 1, + status: "active", + stage: "라포", + theory_mode: "humanistic", + effective_openness: 0.21, + started_at: new Date().toISOString(), + ended_at: null, + review_ready: false, + turns: [], + goal_stages: ["라포", "탐색"], + progress: null, + duration_limit_seconds: 3600, + warning_before_end_seconds: 600, + }), + ), + ); + // pre 체크포인트가 이미 잠긴 상태로 응답해 알리안스 게이트를 fixture로 통과시킨다. + await page.route(`**/api/sessions/${SESSION_ID}/alliance-pulses`, (route) => + route.fulfill( + jsonRoute({ + items: [ + { + pulse_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + checkpoint: "pre", + status: "ready", + learner_locked_at: new Date().toISOString(), + revealed_at: new Date().toISOString(), + error_code: null, + self_scores: { goal: 0.5, task: 0.5, bond: 0.5 }, + measurements: [], + }, + ], + }), + ), + ); + await page.route(`**/api/sessions/${SESSION_ID}/live-coach`, (route) => + route.fulfill( + jsonRoute({ + source: "database", + quota: { remaining: 3, max: 3 }, + credit_events: [], + events: [], + }), + ), + ); +} + +async function openActiveSession(page: Page) { + await page.goto("/learn/session/P1"); + const startButton = page.getByRole("button", { name: "회기 시작" }); + await expect(startButton).toBeEnabled({ timeout: 15_000 }); + await startButton.click(); + await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 }); +} + +/* ── session review / settings / teach 홈 fixture ────────────────────── */ + +async function routeLearnerReview(page: Page) { + await routeUnmockedApi(page); + await routeAuthed(page, "learner"); + // 나중 등록이 우선 — 리뷰·알리안스·사전사후 fixture 는 catch-all 뒤에 깐다. + await routeFilledSessionReview(page); + await routePrepostMeasures(page); +} + +async function routeSettings(page: Page) { + await routeUnmockedApi(page); + await routeAuthed(page, "learner"); + await page.route("**/api/users/me", (route) => + route.fulfill( + jsonRoute({ + user_id: "e2e-a11y-learner-1", + email: "learner@hs.ac.kr", + role: "learner", + display_name: "학습자", + legal_name: "학습자", + nickname: "학습자", + affiliation: "한신대학교", + department: "상담심리학과", + grade_level: "3학년", + phone: "010-0000-0000", + contact_address: "경기도 오산시 한신대학교", + self_introduction: "", + avatar_url: "", + cohort_ids: ["e2e-a11y-cohort"], + onboarding_required: false, + onboarding_completed_at: 1782820000, + terms_agreed_at: 1782820000, + terms_version: "2026-06", + privacy_agreed_at: 1782820000, + privacy_version: "2026-06", + }), + ), + ); + await page.route("**/api/users/me/preferences", (route) => + route.fulfill( + jsonRoute({ + theme: "dark", + voice_preset_id: "soft-young-fem", + voice_rate: 1, + notifications: { + account_approval: true, + learner_progress: false, + product_news: false, + safety_signal: true, + session_done: true, + }, + }), + ), + ); + await page.route("**/api/users/me/voice-presets", (route) => + route.fulfill( + jsonRoute([ + { + id: "soft-young-fem", + name: "차분한 목소리", + desc: "기본 내담자 음성", + persona_hint: "P1", + voice_id: "voice-1", + }, + ]), + ), + ); + await page.route("**/api/users/support-tickets", (route) => + route.fulfill( + jsonRoute({ source: "database", durable: true, generated_at: 1782820000, tickets: [] }), + ), + ); +} + +/** teach 홈 대시보드 — layout-visual-gate.spec.ts teacherAnalysisFixture 축약 차용. */ +function teacherDashboardBody() { + const learnerId = "uc-a11y-analysis-learner"; + const sessions = Array.from({ length: 3 }, (_unused, index) => { + const sessionNo = index + 1; + return { + session_id: `${learnerId}-session-${sessionNo}`, + learner_id: learnerId, + learner_label: "감사 검증 학습자", + persona_code: "P1", + persona_name: "서연", + session_no: sessionNo, + status: "ended", + stage: sessionNo >= 3 ? "개입" : "탐색", + turn_count: 8 + sessionNo, + learner_turn_count: 4 + sessionNo, + client_turn_count: 4, + started_at: `2026-08-${String(10 + sessionNo).padStart(2, "0")}T09:00:00Z`, + ended_at: `2026-08-${String(10 + sessionNo).padStart(2, "0")}T09:30:00Z`, + review_status: "pending", + review_note: null, + reviewed_at: null, + }; + }); + const points = sessions.map((session) => ({ + session_id: session.session_id, + session_no: session.session_no, + persona_code: session.persona_code, + stage: session.stage, + started_at: session.started_at, + ended_at: session.ended_at, + score: session.session_no >= 3 ? 0.8 : 0.5, + rapport: Math.min(0.8, session.session_no * 0.2), + technique_count: session.session_no + 1, + watch_count: session.session_no < 3 ? 1 : 0, + })); + const summary = { + learner_id: learnerId, + learner_label: "감사 검증 학습자", + sessions: sessions.length, + ended_sessions: sessions.length, + latest_at: sessions[sessions.length - 1].ended_at ?? "", + first_score: points[0].score, + latest_score: points[points.length - 1].score, + score_delta: points[points.length - 1].score - points[0].score, + avg_score: 0.6, + avg_rapport: 0.4, + trend: "up", + top_techniques: ["reflection"], + points, + }; + return { + source: "database", + cohort_label: "a11y cohort", + total_learners: 1, + active_sessions: 0, + ended_sessions: sessions.length, + learner_growth: [summary], + safety_alerts: [], + pending_reviews: [], + recent_sessions: sessions.slice(-2), + message: "접근성 감사 fixture.", + }; +} + +async function routeTeachHome(page: Page) { + await routeUnmockedApi(page); + await routeAuthed(page, "teacher"); + await page.route("**/api/teacher/dashboard", (route) => + route.fulfill(jsonRoute(teacherDashboardBody())), + ); +} + +/* ───────────────────────────────────────────────────────────────────── + 접근성 검사 헬퍼 (브라우저 컨텍스트 실측) + ───────────────────────────────────────────────────────────────────── */ + +interface TapTargetViolation { + element: string; + width: number; + height: number; +} + +/** 보이는 인터랙티브 요소 중 44×44px 미만을 수집한다(비활성 컨트롤 제외). */ +async function collectSmallTapTargets(page: Page): Promise { + return page.evaluate(() => { + const MIN = 44; + const selector = + "button, a[href], input:not([type='hidden']), select, textarea, " + + "[role='button'], [role='tab'], [role='radio'], [role='checkbox']"; + const out: Array<{ element: string; width: number; height: number }> = []; + const seen = new Set(); + for (const el of Array.from(document.querySelectorAll(selector))) { + if (seen.has(el)) continue; + seen.add(el); + // 체크박스·라디오가