diff --git a/apps/web/design.md b/apps/web/design.md index a3b1c08..53a6f34 100644 --- a/apps/web/design.md +++ b/apps/web/design.md @@ -24,3 +24,4 @@ - 2026-08-30 · 목표 선택은 `핵심 1개 · 보조 0~3개`를 명시하고, 보조 목표를 제거하지 않고 핵심 순서만 바꿀 수 있게 한다. 짧은 데스크톱 높이에서는 같은 선택을 native select로 압축하고 시작 행동을 안전한 sticky bar에 둬 4개 목표와 CTA가 겹치지 않게 한다. 모든 조작은 44px 이상이며 토큰·기존 payload 계약은 유지한다. - 2026-08-30 · 모바일은 DOM과 같은 사례 → 학습 → 재연습 순서로 읽고, 시작 CTA는 bottom bar와 safe area 위에 고정한다. 키보드 단축키 도움말과 코치 근거/이력은 열 때 포커스를 받고 Tab 순환·닫은 뒤 호출자 복원을 보장한다. - 2026-08-30 · 공통 셸의 첫 키보드 정지는 `본문으로 건너뛰기`다. 링크는 포커스될 때만 보이고, 어느 역할·화면에서도 식별 가능한 main으로 포커스를 옮긴다. +- 2026-09-12 · 공통 셸은 역할별로 가장 자주 쓰는 3개 목적지를 모바일 하단 내비에 먼저 두고, 나머지 허용 목적지는 명시적 `전체 메뉴` native dialog로 제공한다. 721–1024px에서는 168px 레일로 메뉴 텍스트를 보존하며, 모바일 역할 전환은 현재 역할명이 보이는 native select(접근성 이름: `공간 선택`)로 한다. `Ctrl+W`·`Ctrl+N`·`Ctrl+T`의 브라우저 예약 단축키 가로채기를 제거하고, 기존 Alt 단축키는 보존하며 `Alt+H`는 역할 홈으로 이동한다. 로그인 카피는 계정·저장소 설명 대신 상담 연습과 회고라는 역할 목적을 먼저 말한다. 기존 토큰·권한별 메뉴 필터·라우트 계약은 유지한다. diff --git a/apps/web/e2e/admin.spec.ts b/apps/web/e2e/admin.spec.ts index 21d58fa..9874703 100644 --- a/apps/web/e2e/admin.spec.ts +++ b/apps/web/e2e/admin.spec.ts @@ -824,7 +824,7 @@ test.describe("admin route guards", () => { { path: "/admin/ai", heading: "AI 운영과 DB 계량" }, { path: "/admin/users", heading: "가입 승인과 권한 관리" }, { path: "/admin/access", heading: "역할, 그룹, 접근 범위" }, - { path: "/admin/tickets", heading: "사용자 문제 큐" }, + { path: "/admin/tickets", heading: "지원 요청" }, ] as const; const sessions = [ { diff --git a/apps/web/e2e/alliance-pulse.spec.ts b/apps/web/e2e/alliance-pulse.spec.ts index b366213..cd22aa7 100644 --- a/apps/web/e2e/alliance-pulse.spec.ts +++ b/apps/web/e2e/alliance-pulse.spec.ts @@ -119,6 +119,7 @@ test.describe("G1 치료 동맹 펄스", () => { test("AI 관점을 공개하기 전에 학습자 자기평가를 잠근다", async ({ page, }) => { + await page.setViewportSize({ width: 390, height: 844 }); await routeReviewUser(page, "learner"); let pulse: ReturnType | null = null; let submittedBody: Record | null = null; @@ -156,13 +157,20 @@ test.describe("G1 치료 동맹 펄스", () => { await expect(page.getByText("내담자 관점", { exact: true })).toHaveCount(0); await expect(page.getByText("관찰자 관점", { exact: true })).toHaveCount(0); + const submit = page.getByRole("button", { name: "내 판단 잠그고 관점 비교" }); + await expect(submit).toBeDisabled(); + const axes = page.locator(".ap-axis"); await axes.nth(0).locator(".ap-scale__option").nth(3).click(); await axes.nth(1).locator(".ap-scale__option").nth(2).click(); await axes.nth(2).locator(".ap-scale__option").nth(4).click(); await page.getByText("내 판단의 근거 장면 선택").click(); await page.getByRole("checkbox").first().check(); - await page.getByRole("button", { name: "내 판단 잠그고 관점 비교" }).click(); + await expect(submit).toBeEnabled(); + const submitBox = await submit.boundingBox(); + expect(submitBox).not.toBeNull(); + expect(submitBox!.height).toBeGreaterThanOrEqual(44); + await submit.click(); await expect(page.getByText("내 판단이 잠겼습니다")).toBeVisible(); await expect(page.getByText("내담자 관점", { exact: true })).toHaveCount(0); diff --git a/apps/web/e2e/continuous-improvement-admin.spec.ts b/apps/web/e2e/continuous-improvement-admin.spec.ts index 087c00f..f48da2a 100644 --- a/apps/web/e2e/continuous-improvement-admin.spec.ts +++ b/apps/web/e2e/continuous-improvement-admin.spec.ts @@ -293,14 +293,14 @@ test.describe("continuous improvement admin cockpit", () => { await page.goto("/admin/continuous-improvement"); - await expect(page.getByRole("heading", { name: "승격보다 근거를 먼저 본다" })).toBeVisible(); - await expect(page.getByText("합성 콘텐츠 검증 파이프라인")).toBeVisible(); - await expect(page.getByText("모델 변경 · 릴리스 게이트")).toBeVisible(); - await expect(page.getByText("사건 회귀 DAG")).toBeVisible(); - await expect(page.getByText("모니터 · 롤백 원장")).toBeVisible(); + await expect(page.getByRole("heading", { name: "개선안 검토와 승인" })).toBeVisible(); + await expect(page.getByText("생성 콘텐츠 검토")).toBeVisible(); + await expect(page.getByText("모델 변경과 릴리스 검토")).toBeVisible(); + await expect(page.getByText("문제 재발 방지 기록")).toBeVisible(); + await expect(page.getByText("적용과 복구 이력")).toBeVisible(); await expect(page.getByText("롤백 실행 대기 · 실행기 미구성", { exact: true })).toBeVisible(); await expect(page.getByText("롤백 실행 실패", { exact: true })).toBeVisible(); - await expect(page.getByText("롤백은 실행됐지만 verification 이벤트가 아직 없어")).toBeVisible(); + await expect(page.getByText("복구는 실행됐지만 검증 기록이 아직 없습니다")).toBeVisible(); const contentCandidate = page.locator( '[data-qualification-id="20000000-0000-4000-8000-000000000001"]', ); @@ -314,7 +314,7 @@ test.describe("continuous improvement admin cockpit", () => { await expect(contentCandidate.getByRole("button", { name: "승인 기록" })).toBeDisabled(); await contentReason.fill("합성 경계와 수련 목표를 직접 검수함"); await contentReason.press("Enter"); - await expect(contentCandidate.getByText("콘텐츠 승인 원장 기록됨")).toBeVisible(); + await expect(contentCandidate.getByText("콘텐츠 승인 기록됨")).toBeVisible(); await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); await page.getByTestId("continuous-improvement-cockpit").screenshot({ path: testInfo.outputPath("g8-continuous-improvement-before-approval.png"), @@ -325,7 +325,7 @@ test.describe("continuous improvement admin cockpit", () => { await expect(missingGate.getByRole("radio", { name: "승인", exact: true })).toBeDisabled(); await expect(missingGate.getByRole("radio", { name: "보류", exact: true })).toBeEnabled(); await expect(missingGate.getByRole("radio", { name: "반려", exact: true })).toBeEnabled(); - await expect(missingGate.getByText("필수 증거 4종 미완료로 승인 차단")).toBeVisible(); + await expect(missingGate.getByText("필수 증거 4종이 갖춰지지 않아 승인할 수 없습니다")).toBeVisible(); await missingGate.screenshot({ path: testInfo.outputPath("g8-human-decision-approval-blocked.png"), animations: "disabled", @@ -338,10 +338,10 @@ test.describe("continuous improvement admin cockpit", () => { await modelGate.getByRole("radio", { name: "승인", exact: true }).check(); const approvalButton = modelGate.getByRole("button", { name: "승인 기록" }); await expect(approvalButton).toBeDisabled(); - await expect(modelGate.getByText("승인 사유를 먼저 입력해야 함")).toBeVisible(); + await expect(modelGate.getByText("승인 사유를 먼저 입력해야 합니다")).toBeVisible(); await approvalReason.fill("기준선과 rollback runbook을 독립 검토함"); await expect(approvalButton).toBeEnabled(); - await expect(modelGate.getByText("승인 결정과 사유를 append-only 원장에 기록할 준비됨")).toBeVisible(); + await expect(modelGate.getByText("승인 결정과 사유를 변경 이력에 기록할 준비가 되었습니다")).toBeVisible(); await modelGate.screenshot({ path: testInfo.outputPath("g8-human-decision-approval-ready.png"), animations: "disabled", @@ -351,7 +351,7 @@ test.describe("continuous improvement admin cockpit", () => { await approvalReason.focus(); await approvalReason.press("Enter"); - await expect(modelGate.getByText("승격 승인 원장 기록됨")).toBeVisible(); + await expect(modelGate.getByText("변경 승인 기록됨")).toBeVisible(); expect(mock.approvalBodies).toHaveLength(2); expect(mock.approvalBodies[0]).toMatchObject({ target_kind: "content_qualification", @@ -388,14 +388,14 @@ test.describe("continuous improvement admin cockpit", () => { const releaseGate = page.locator(`[data-gate-id="${RELEASE_GATE_ID}"]`); const releaseApprove = releaseGate.getByRole("radio", { name: "승인", exact: true }); await expect(releaseApprove).toBeDisabled(); - await expect(releaseGate.getByText("필수 증거 4종 미완료로 승인 차단")).toBeVisible(); + await expect(releaseGate.getByText("필수 증거 4종이 갖춰지지 않아 승인할 수 없습니다")).toBeVisible(); await expect(releaseGate.getByRole("radio", { name: "보류", exact: true })).toBeEnabled(); await expect(releaseGate.getByRole("radio", { name: "반려", exact: true })).toBeEnabled(); await releaseGate.getByRole("radio", { name: "보류", exact: true }).check(); await releaseGate.getByLabel("사람 결정 사유").fill("rollback 증거 보강 뒤 다시 검토"); await releaseGate.getByRole("button", { name: "보류 기록" }).click(); - await expect(releaseGate.getByText("보류 원장 기록됨")).toBeVisible(); + await expect(releaseGate.getByText("보류 기록됨")).toBeVisible(); const contentCandidate = page.locator( '[data-qualification-id="20000000-0000-4000-8000-000000000001"]', @@ -403,13 +403,13 @@ test.describe("continuous improvement admin cockpit", () => { await contentCandidate.getByRole("radio", { name: "반려", exact: true }).check(); await contentCandidate.getByLabel("콘텐츠 결정 사유").fill("검수 기준을 충족하지 못한 후보"); await contentCandidate.getByRole("button", { name: "반려 기록" }).click(); - await expect(contentCandidate.getByText("반려 원장 기록됨")).toBeVisible(); + await expect(contentCandidate.getByText("반려 기록됨")).toBeVisible(); const modelGate = page.locator(`[data-gate-id="${MODEL_GATE_ID}"]`); await modelGate.getByRole("radio", { name: "승인", exact: true }).check(); await modelGate.getByLabel("사람 결정 사유").fill("독립 증거 4종과 승격 조건을 확인함"); await modelGate.getByRole("button", { name: "승인 기록" }).click(); - await expect(modelGate.getByText("승격 승인 원장 기록됨")).toBeVisible(); + await expect(modelGate.getByText("변경 승인 기록됨")).toBeVisible(); expect(mock.approvalBodies).toHaveLength(3); expect(mock.approvalBodies[0]).toMatchObject({ @@ -436,16 +436,16 @@ test.describe("continuous improvement admin cockpit", () => { test("shows empty and degraded states without opening an approval action", async ({ page }) => { await installMock(page, { empty: true }); await page.goto("/admin/continuous-improvement"); - await expect(page.getByText("검토할 콘텐츠 후보가 없어")).toBeVisible(); - await expect(page.getByText("대기 중인 게이트가 없어")).toBeVisible(); - await expect(page.getByText("등록된 운영 사건이 없어")).toBeVisible(); + await expect(page.getByText("검토할 콘텐츠 후보가 없습니다")).toBeVisible(); + await expect(page.getByText("대기 중인 변경 검토가 없습니다")).toBeVisible(); + await expect(page.getByText("등록된 운영 문제가 없습니다")).toBeVisible(); await expect(page.getByRole("button", { name: /승인 기록|결정 기록/ })).toHaveCount(0); await page.unroute("**/api/**"); await installMock(page, { status: 503 }); await page.reload(); - await expect(page.getByRole("heading", { name: "개선 원장을 확인할 수 없어" })).toBeVisible(); - await expect(page.getByText(/승인 동작은 닫힌 상태/)).toBeVisible(); + await expect(page.getByRole("heading", { name: "개선 기록을 확인할 수 없습니다" })).toBeVisible(); + await expect(page.getByText(/승인 동작은 사용할 수 없습니다/)).toBeVisible(); await expectNoHorizontalOverflow(page); }); diff --git a/apps/web/e2e/full-sweep-admin-ai.spec.ts b/apps/web/e2e/full-sweep-admin-ai.spec.ts index 376e458..2ec2ef9 100644 --- a/apps/web/e2e/full-sweep-admin-ai.spec.ts +++ b/apps/web/e2e/full-sweep-admin-ai.spec.ts @@ -281,6 +281,31 @@ async function mockAdminAiSession(page: Page, state: AdminAiMockState) { } test.describe("full sweep: admin ai operations", () => { + test("제공자 조회 실패를 표시하고 다시 확인할 수 있다", async ({ page }) => { + await mockAdminAiSession(page, createMockState()); + let attempts = 0; + let recovered = false; + await page.route("**/api/admin/providers", async (route) => { + attempts += 1; + await route.fulfill({ + status: recovered ? 200 : 500, + contentType: "application/json", + body: JSON.stringify(!recovered + ? { detail: "제공자 상태 조회 실패" } + : { providers: [], gateway_sync: { synced: false, detail: "연결 설정 대기" } }), + }); + }); + await page.goto("/admin/ai"); + const panel = page.getByTestId("provider-connections-panel"); + await expect(panel.locator(".aic-runtime")).toHaveText("연결 상태 확인 실패"); + const failedAttempts = attempts; + recovered = true; + await panel.getByRole("button", { name: "다시 확인" }).click(); + await expect(panel.locator(".aic-runtime")).toHaveText("적용 대기"); + await expect(panel.getByRole("alert")).toHaveCount(0); + expect(attempts).toBeGreaterThan(failedAttempts); + }); + // checklist: admin-ai-guard-pending-approval test("redirects a pending-approval admin from /admin/ai to /pending", async ({ page }) => { const state = createMockState({ diff --git a/apps/web/e2e/full-sweep-admin.spec.ts b/apps/web/e2e/full-sweep-admin.spec.ts index b30f756..4f04e0c 100644 --- a/apps/web/e2e/full-sweep-admin.spec.ts +++ b/apps/web/e2e/full-sweep-admin.spec.ts @@ -690,14 +690,14 @@ test.describe("admin console full-sweep (fixtures)", () => { mock.tickets = [makeMockTicket({ ticket_id: "filter-1", subject: "필터 대상 티켓" })]; await page.goto("/admin/tickets"); - await expect(page.getByRole("heading", { name: "사용자 문제 큐" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "지원 요청" })).toBeVisible(); const clearButton = page.getByRole("button", { name: "초기화" }); await expect(clearButton).toBeDisabled(); const searchRequest = page.waitForRequest( isTicketsRequest((params) => params.get("search") === "로그인 오류"), ); - await page.getByLabel("티켓 검색").fill("로그인 오류"); + await page.getByLabel("지원 요청 검색").fill("로그인 오류"); await searchRequest; const statusRequest = page.waitForRequest( @@ -755,7 +755,7 @@ test.describe("admin console full-sweep (fixtures)", () => { ); await clearButton.click(); await resetRequest; - await expect(page.getByLabel("티켓 검색")).toHaveValue(""); + await expect(page.getByLabel("지원 요청 검색")).toHaveValue(""); await expect(page.getByLabel("상태 필터")).toHaveValue("all"); await expect(page.getByLabel("카테고리 필터")).toHaveValue("all"); await expect(page.getByLabel("우선순위 필터")).toHaveValue("all"); @@ -1220,13 +1220,13 @@ test.describe("admin console full-sweep (real API)", () => { await signInAsAdmin(page); await page.goto("/admin/tickets"); - await expect(page.getByRole("heading", { name: "사용자 문제 큐" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "지원 요청" })).toBeVisible(); const searchRequest = page.waitForRequest( isTicketsRequest((params) => params.get("search") === "freeze-probe"), ); // 실 DB 스케일에서는 이 fill에서 렌더러가 프리즈되어 어떤 요청도 나가지 않는다. - await page.getByLabel("티켓 검색").fill("freeze-probe"); + await page.getByLabel("지원 요청 검색").fill("freeze-probe"); await searchRequest; }); }); diff --git a/apps/web/e2e/full-sweep-learner-home.spec.ts b/apps/web/e2e/full-sweep-learner-home.spec.ts index 0065d5a..bd12f43 100644 --- a/apps/web/e2e/full-sweep-learner-home.spec.ts +++ b/apps/web/e2e/full-sweep-learner-home.spec.ts @@ -329,9 +329,12 @@ function jsonRoute(body: unknown, status = 200) { /** 페르소나·회기 목록·대시보드·스포트라이트 상세를 fixture로 고정한다. */ async function installLearnerHomeFixtures( page: Page, - options: { detail?: boolean } = {}, + options: { + detail?: boolean; + dashboard?: typeof DASHBOARD_BODY; + } = {}, ) { - const { detail = true } = options; + const { detail = true, dashboard = DASHBOARD_BODY } = options; await page.route(/\/api\/personas$/, (route) => route.fulfill(jsonRoute(PERSONAS_BODY)), ); @@ -339,7 +342,7 @@ async function installLearnerHomeFixtures( route.fulfill(jsonRoute(SESSIONS_BODY)), ); await page.route(/\/api\/sessions\/dashboard$/, (route) => - route.fulfill(jsonRoute(DASHBOARD_BODY)), + route.fulfill(jsonRoute(dashboard)), ); if (detail) { await page.route(new RegExp(`/api/sessions/${ACT_ID}$`), (route) => @@ -383,6 +386,31 @@ async function installStarterFixtures( } } +async function installLongPersonaFixtures(page: Page) { + const personas = Array.from({ length: 16 }, (_, index) => { + const number = index + 1; + return { + code: `P${number}`, + display_name: number === 4 ? "박새봄" : `긴 호소 내담자 ${number}`, + difficulty: number === 4 ? "easy" : "hard", + theory_target: ["cbt"], + demographics: { age_band: "30대" }, + presenting_summary: + "반복되는 긴 호소 내용으로 인해 실제 목록 카드가 여러 줄로 늘어나는 상황을 검증합니다. 일상과 관계의 어려움을 자세히 설명합니다.", + voice_preset: null, + source: "database", + degraded: false, + }; + }); + await page.route(/\/api\/personas$/, (route) => route.fulfill(jsonRoute(personas))); + await page.route(/\/api\/sessions$/, (route) => + route.fulfill(jsonRoute({ sessions: [], source: "runtime" })), + ); + await page.route(/\/api\/sessions\/dashboard$/, (route) => + route.fulfill(jsonRoute(EMPTY_DASHBOARD_BODY)), + ); +} + test.describe("full-sweep learner home", () => { test.beforeEach(async ({ page }) => { await useRealApi(page); @@ -421,17 +449,18 @@ test.describe("full-sweep learner home", () => { ).toBeVisible(); }); - // checklist: learner-home-head-status-live - test("announces persona and history counts in the aria-live head status", async ({ + // checklist: learner-home-head-action + test("shows the action-focused dashboard header without a global count badge", async ({ page, }) => { await signInAsLearner(page); await page.goto("/learn"); - const status = page.locator(".lh-head__status"); - await expect(status).toHaveAttribute("aria-live", "polite"); - // 실 API: 사용 가능 페르소나 수 + 기록 수(신규 학습자 → 0회)가 상태 문구로 뜬다. - await expect(status).toHaveText(/\d+명 · \d+회/, { timeout: 15_000 }); + await expect( + page.getByRole("heading", { name: "오늘의 상담 연습" }), + ).toBeVisible({ timeout: 15_000 }); + await expect(page.locator(".lh-head__status")).toHaveCount(0); + await expect(page.locator("main")).toHaveCount(1); }); // checklist: learner-home-dash-metric-cards @@ -441,14 +470,15 @@ test.describe("full-sweep learner home", () => { await signInAsLearner(page); await installLearnerHomeFixtures(page); await page.goto("/learn"); + await page.getByRole("tab", { name: "성장 지표" }).click(); - const cards = page.locator(".lh-dashboard-status"); + const cards = page.locator("#lh-panel-growth .lh-dashboard-status"); await expect(cards.getByRole("button")).toHaveCount(4); - await expect(cards.getByRole("button", { name: /진행 회기/ })).toContainText( + await expect(cards.getByRole("button", { name: /전체 회기/ })).toContainText( "4회", ); - await expect(cards.getByRole("button", { name: /진행 회기/ })).toContainText( - "3회 종료 · 1회 진행", + await expect(cards.getByRole("button", { name: /전체 회기/ })).toContainText( + "상담 완료 3회 · 진행 중 1회", ); await expect(cards.getByRole("button", { name: /리뷰 대기/ })).toContainText( "1건", @@ -466,17 +496,51 @@ test.describe("full-sweep learner home", () => { "성하늘 · 3회", ); - await cards.getByRole("button", { name: /진행 회기/ }).click(); + await cards.getByRole("button", { name: /전체 회기/ }).click(); await expect(page).toHaveURL(/\/learn\/history$/); await page.goto("/learn"); + await page.getByRole("tab", { name: "성장 지표" }).click(); await page - .locator(".lh-dashboard-status") + .locator("#lh-panel-growth .lh-dashboard-status") .getByRole("button", { name: /라포 흐름/ }) .click(); await expect(page).toHaveURL(/\/learn\/practice$/); }); + test("labels all sessions separately from completed counseling and empty ended sessions", async ({ + page, + }) => { + await signInAsLearner(page); + await installLearnerHomeFixtures(page, { + dashboard: { + ...DASHBOARD_BODY, + overview: { + ...DASHBOARD_BODY.overview, + total_sessions: 5, + completed_sessions: 2, + active_sessions: 0, + }, + }, + }); + await page.goto("/learn"); + + await expect( + page.locator("#lh-panel-today .lh-dashboard-status"), + ).toHaveCount(0); + await page.getByRole("tab", { name: "성장 지표" }).click(); + const allSessions = page + .locator("#lh-panel-growth .lh-dashboard-status") + .getByRole("button", { name: "전체 회기" }); + await expect(allSessions).toContainText("5회"); + await expect(allSessions).toContainText("상담 완료 2회 · 진행 중 0회"); + await expect(allSessions).toContainText("대화 없이 종료한 회기 포함"); + await expect(allSessions).toHaveAttribute( + "title", + "전체 회기에는 대화 없이 종료한 회기도 포함합니다.", + ); + }); + // checklist: learner-home-dash-recap-card, learner-home-dash-coach-cta test("shows the spotlight recap card and coach CTA for an active session", async ({ page, @@ -547,7 +611,7 @@ test.describe("full-sweep learner home", () => { const recap = page.getByLabel("마지막 세션 리캡"); await expect(recap).toContainText("할 수 있게 되는 건지 잘 모르겠는데요"); await expect(recap).toContainText("소속 기관에서 오라고 했어요"); - await expect(recap).toContainText("익명 내담자에게 연락처로 연락해 볼까요"); + await expect(recap).toContainText("익명 인물에게 연락처로 연락해 볼까요"); for (const token of ["[NAME]", "[ORG]", "[PHONE]"]) { await expect(recap).not.toContainText(token); } @@ -946,4 +1010,210 @@ test.describe("full-sweep learner home", () => { listbox.getByRole("option", { name: /P4/ }), ).toHaveAttribute("aria-selected", "false"); }); + + test("keeps the initially selected P4 visible inside the mobile persona list", async ({ page }, testInfo) => { + await signInAsLearner(page); + await installStarterFixtures(page); + await page.setViewportSize({ width: 320, height: 844 }); + await page.goto("/learn/practice"); + const listbox = page.getByRole("listbox", { name: "연습 페르소나" }); + const selected = listbox.getByRole("option", { name: /P4/ }); + await expect(selected).toHaveAttribute("aria-selected", "true"); + await expect.poll(() => page.evaluate(() => { + const list = document.querySelector(".lh-personas"); + const option = document.querySelector("[data-persona-code='P4']"); + if (!list || !option) return false; + const listRect = list.getBoundingClientRect(); + const optionRect = option.getBoundingClientRect(); + const subpixelTolerance = 1; + return ( + optionRect.left >= listRect.left - subpixelTolerance && + optionRect.right <= listRect.right + subpixelTolerance && + optionRect.top >= listRect.top - subpixelTolerance && + optionRect.bottom <= listRect.bottom + subpixelTolerance + ); + })).toBe(true); + await expect.poll(() => page.locator(".vg-main").evaluate((main) => main.scrollTop)).toBe(0); + await page.screenshot({ + path: testInfo.outputPath("p4-initial-mobile-320.png"), + fullPage: false, + animations: "disabled", + }); + }); + + test("keeps the selected persona visible after a mobile viewport resize", async ({ page }) => { + await signInAsLearner(page); + await installStarterFixtures(page); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/learn/practice"); + const listbox = page.getByRole("listbox", { name: "연습 페르소나" }); + const selected = listbox.getByRole("option", { name: /P4/ }); + await expect(selected).toHaveAttribute("aria-selected", "true"); + + await page.setViewportSize({ width: 320, height: 844 }); + await expect.poll(() => page.evaluate(() => { + const list = document.querySelector(".lh-personas"); + const option = document.querySelector("[data-persona-code='P4']"); + if (!list || !option) return false; + const listRect = list.getBoundingClientRect(); + const optionRect = option.getBoundingClientRect(); + const subpixelTolerance = 1; + return ( + optionRect.left >= listRect.left - subpixelTolerance && + optionRect.right <= listRect.right + subpixelTolerance && + optionRect.top >= listRect.top - subpixelTolerance && + optionRect.bottom <= listRect.bottom + subpixelTolerance + ); + })).toBe(true); + await expect.poll(() => page.locator(".vg-main").evaluate((main) => main.scrollTop)).toBe(0); + }); + + test("contains long persona lists without pushing the selected practice CTA below the list", async ({ page }, testInfo) => { + await signInAsLearner(page); + await installLongPersonaFixtures(page); + + for (const width of [768, 1024, 1440]) { + await page.setViewportSize({ width, height: 900 }); + await page.goto("/learn/practice"); + const listbox = page.getByRole("listbox", { name: "연습 페르소나" }); + const selected = listbox.getByRole("option", { name: /P4/ }); + const longPersona = listbox.getByRole("option", { name: /P12/ }); + const lastPersona = listbox.getByRole("option", { name: /P16/ }); + const startCta = page.getByRole("button", { name: "완전히 새로 시작" }); + await expect(selected).toHaveAttribute("aria-selected", "true"); + await expect(longPersona).toBeVisible(); + await expect.poll(() => page.evaluate(() => { + const list = document.querySelector(".lh-personas"); + const option = document.querySelector("[data-persona-code='P4']"); + if (!list || !option) return false; + const listRect = list.getBoundingClientRect(); + const optionRect = option.getBoundingClientRect(); + const options = [...list.querySelectorAll("[role='option']")]; + const rowsDoNotOverlap = options.every((current, index) => { + const currentRect = current.getBoundingClientRect(); + return options.slice(index + 1).every((other) => { + const otherRect = other.getBoundingClientRect(); + return ( + currentRect.right <= otherRect.left || + otherRect.right <= currentRect.left || + currentRect.bottom <= otherRect.top || + otherRect.bottom <= currentRect.top + ); + }); + }); + const contentFits = options.every((current) => { + const content = current.querySelector(".lh-persona__body"); + return !content || content.getBoundingClientRect().bottom <= current.getBoundingClientRect().bottom; + }); + const hit = document.elementFromPoint( + optionRect.left + optionRect.width / 2, + optionRect.top + optionRect.height / 2, + ); + return ( + list.scrollHeight > list.clientHeight && + getComputedStyle(list).overflowY === "auto" && + optionRect.top >= listRect.top && + optionRect.bottom <= listRect.bottom && + rowsDoNotOverlap && + contentFits && + !!hit && option.contains(hit) + ); + })).toBe(true); + await expect(startCta).toBeInViewport(); + await page.screenshot({ + path: testInfo.outputPath(`long-personas-w${width}-top.png`), + fullPage: false, + animations: "disabled", + }); + await longPersona.scrollIntoViewIfNeeded(); + await expect(longPersona).toBeInViewport(); + await listbox.evaluate((element) => { + element.scrollTop = element.scrollHeight; + }); + await expect(lastPersona).toBeInViewport(); + await expect.poll(() => page.locator(".vg-main").evaluate((main) => main.scrollTop)).toBe(0); + await page.screenshot({ + path: testInfo.outputPath(`long-personas-w${width}-bottom.png`), + fullPage: false, + animations: "disabled", + }); + } + }); + + test("opens zero-record persona rows and keeps the compact history overview two columns", async ({ page }, testInfo) => { + await signInAsLearner(page); + await installStarterFixtures(page); + for (const width of [320, 390]) { + await page.setViewportSize({ width, height: 844 }); + await page.goto("/learn/history"); + const overview = page.locator(".lh-history-overview"); + const practiceCta = page.getByRole("button", { name: "상담 연습으로" }); + const toggle = page.getByRole("button", { name: /미진행 내담자 2명 보기/ }); + await expect(page.getByText("아직 연습 기록이 없습니다.")).toBeVisible(); + await expect(practiceCta).toBeVisible(); + await expect(toggle).toBeVisible(); + const columns = await overview.evaluate((element) => getComputedStyle(element).gridTemplateColumns.split(" ").filter(Boolean).length); + expect(columns).toBe(2); + for (const target of [practiceCta, toggle]) { + const height = await target.evaluate((element) => element.getBoundingClientRect().height); + expect(height).toBeGreaterThanOrEqual(44); + } + await toggle.scrollIntoViewIfNeeded(); + await page.locator(".lh-persona-progress").screenshot({ + path: testInfo.outputPath(`history-empty-bottom-mobile-${width}.png`), + animations: "disabled", + }); + await toggle.click(); + await expect(page.locator(".lh-persona-progress__row")).toHaveCount(2); + await page.getByRole("button", { name: "미진행 내담자 숨기기" }).click(); + await expect(page.locator(".lh-persona-progress__row")).toHaveCount(0); + } + }); + + test("keeps history filters and session actions usable at mobile widths", async ({ page }, testInfo) => { + await signInAsLearner(page); + await installLearnerHomeFixtures(page); + + for (const width of [320, 390]) { + await page.setViewportSize({ width, height: 844 }); + await page.goto("/learn/history"); + const filters = page.locator(".lh-history-filter button"); + const historyActions = page.locator(".lh-session-actions button"); + await expect(filters).toHaveCount(4); + await expect(historyActions).not.toHaveCount(0); + for (const target of [filters, historyActions]) { + const boxes = await target.evaluateAll((elements) => + elements.map((element) => { + const rect = element.getBoundingClientRect(); + return { height: rect.height, width: rect.width }; + }), + ); + expect(boxes.every((box) => box.height >= 44 && box.width >= 44)).toBe(true); + } + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + if (width === 320) { + await page.screenshot({ + path: testInfo.outputPath("history-top-mobile-320.png"), + fullPage: false, + animations: "disabled", + }); + await historyActions.first().locator("..").locator("..").screenshot({ + path: testInfo.outputPath("history-action-row-mobile-320.png"), + animations: "disabled", + }); + } + + await page.goto("/learn/practice"); + const practiceActions = page.locator(".lh-session-actions button"); + await expect(practiceActions).not.toHaveCount(0); + const practiceBoxes = await practiceActions.evaluateAll((elements) => + elements.map((element) => { + const rect = element.getBoundingClientRect(); + return { height: rect.height, width: rect.width }; + }), + ); + expect(practiceBoxes.every((box) => box.height >= 44 && box.width >= 44)).toBe(true); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + } + }); }); diff --git a/apps/web/e2e/full-sweep-persona-studio.spec.ts b/apps/web/e2e/full-sweep-persona-studio.spec.ts index b3158b8..844a200 100644 --- a/apps/web/e2e/full-sweep-persona-studio.spec.ts +++ b/apps/web/e2e/full-sweep-persona-studio.spec.ts @@ -381,6 +381,81 @@ test.describe("persona studio full sweep", () => { await expect(page.locator(".ps-authoring-layout > .ps-rail")).toHaveCount(0); }); + test("keeps authoring steps in one row and exposes the editor at every target width", async ({ + page, + }) => { + await signInAsTeacher(page); + await mockStudio(page, { catalog: [approvedP1], reviews: [] }); + + const viewports = [ + { width: 320, height: 844 }, + { width: 390, height: 844 }, + { width: 1440, height: 900 }, + ]; + for (const viewport of viewports) { + await page.setViewportSize(viewport); + await page.goto("/teach/personas?view=personas&mode=create&step=edit"); + + const stepper = page.getByRole("navigation", { name: "페르소나 작성 단계" }); + const steps = stepper.getByRole("button"); + await expect(steps).toHaveCount(4); + const boxes = await steps.evaluateAll((elements) => + elements.map((element) => { + const rect = element.getBoundingClientRect(); + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; + }), + ); + expect(Math.max(...boxes.map((box) => box.y)) - Math.min(...boxes.map((box) => box.y))).toBeLessThanOrEqual(1); + for (const box of boxes) { + expect(box.height).toBeGreaterThanOrEqual(44); + expect(box.x).toBeGreaterThanOrEqual(0); + expect(box.x + box.width).toBeLessThanOrEqual(viewport.width); + } + await expect(page.locator(".ps-editor__top")).toBeInViewport(); + + if (viewport.width <= 720) { + const undersizedActions = await page + .locator(".ps-root .vg-btn:not(:disabled), .ps-root .ps-tabs button:not(:disabled)") + .evaluateAll((elements) => + elements.flatMap((element) => { + const rect = element.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return []; + return rect.width >= 44 && rect.height >= 44 + ? [] + : [{ + selector: `${element.tagName.toLowerCase()}.${Array.from(element.classList).join(".")}`, + text: element.textContent?.trim(), + width: rect.width, + height: rect.height, + }]; + }), + ); + expect(undersizedActions).toEqual([]); + } + await expectNoHorizontalOverflow(page); + + if (process.env.PERSONA_AUTHORING_CAPTURE_DIR) { + await page.screenshot({ + path: `${process.env.PERSONA_AUTHORING_CAPTURE_DIR}/persona-authoring-${viewport.width}.png`, + fullPage: true, + }); + } + } + + for (const viewport of viewports) { + await page.setViewportSize(viewport); + await page.goto("/teach/personas?view=catalog"); + await expect(page.getByRole("button", { name: "전체 페르소나 보기" })).toBeVisible(); + await expectNoHorizontalOverflow(page); + if (process.env.PERSONA_AUTHORING_CAPTURE_DIR) { + await page.screenshot({ + path: `${process.env.PERSONA_AUTHORING_CAPTURE_DIR}/persona-catalog-${viewport.width}.png`, + fullPage: true, + }); + } + } + }); + // checklist: persona-studio-inspector-review-actions test("edits and approves review drafts from the inspector queue", async ({ page }) => { await signInAsTeacher(page); diff --git a/apps/web/e2e/layout-visual-gate.spec.ts b/apps/web/e2e/layout-visual-gate.spec.ts index 23e1e9a..039d26e 100644 --- a/apps/web/e2e/layout-visual-gate.spec.ts +++ b/apps/web/e2e/layout-visual-gate.spec.ts @@ -145,6 +145,322 @@ interface ClipReport { }>; } +type ScrollOwner = { + id: string; + token: string; + label: string; + clientHeight: number; + clientWidth: number; + maxTop: number; + maxLeft: number; + top: number; + left: number; +}; + +type CaptureManifestRecord = { + capturedAt: string; + screen: string; + viewport: { width: number; height: number; label: string }; + kind: "full-page" | "vertical-segment" | "horizontal-end" | "scroll-reset" | "scroll-excluded"; + owner: Pick; + offset: { top: number; left: number }; + path: string | null; + reason?: string; + ownerToken?: string; + ownerMatchCount?: number; + theoreticalMaxLeft?: number; + actualMaxLeft?: number; +}; + +type ScrollPosition = { + token: string; + matchCount: number; + top: number; + left: number; + clientHeight: number; + clientWidth: number; + scrollHeight: number; + scrollWidth: number; + maxTop: number; + maxLeft: number; + rect: { top: number; bottom: number; left: number; right: number }; +}; + +type HorizontalLimitProbe = { + token: string; + matchCount: number; + originalLeft: number; + theoreticalMaxLeft: number; + actualMaxLeft: number; +}; + +const SCROLL_OWNER_ATTRIBUTE = "data-layout-gate-scroll-owner"; +const MAX_SCROLL_SEGMENTS = 30; +let scrollOwnerGeneration = 0; + +async function writeCaptureManifest(record: CaptureManifestRecord) { + await fs.appendFile( + path.join(SHOT_DIR, "capture-manifest.jsonl"), + `${JSON.stringify(record)}\n`, + "utf8", + ); +} + +async function resetScrollableOwners(page: Page): Promise { + const generation = ++scrollOwnerGeneration; + return page.evaluate(async ({ ownerAttribute, ownerGeneration }) => { + const isVisible = (element: HTMLElement) => { + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return ( + style.display !== "none" && + style.visibility !== "hidden" && + Number(style.opacity) !== 0 && + rect.width > 0 && + rect.height > 0 + ); + }; + for (const previousOwner of Array.from(document.querySelectorAll(`[${ownerAttribute}]`))) { + previousOwner.removeAttribute(ownerAttribute); + } + + const owners = new Map(); + let nextId = 0; + const add = (element: HTMLElement | null, label: string, preferredId?: string) => { + if (!element || owners.has(element)) return; + const id = preferredId ?? `scroll-${nextId++}`; + owners.set(element, { id, token: `owner-${ownerGeneration}-${id}`, label }); + }; + + add(document.scrollingElement as HTMLElement | null, "document", "document"); + add(document.querySelector("#root"), "#root", "root"); + add(document.querySelector(".vg-main"), ".vg-main", "vg-main"); + add(document.querySelector("main"), "main", "main"); + + for (const element of Array.from(document.querySelectorAll("*"))) { + if (!isVisible(element)) continue; + const style = getComputedStyle(element); + const canScrollY = + (style.overflowY === "auto" || style.overflowY === "scroll" || style.overflowY === "overlay") && + element.scrollHeight > element.clientHeight + 1; + const canScrollX = + (style.overflowX === "auto" || style.overflowX === "scroll" || style.overflowX === "overlay") && + element.scrollWidth > element.clientWidth + 1; + if (!canScrollY && !canScrollX) continue; + const tag = element.tagName.toLowerCase(); + const identifier = element.id ? `#${element.id}` : element.classList.length > 0 ? `.${element.classList[0]}` : tag; + add(element, identifier); + } + + for (const [element, owner] of owners) { + element.setAttribute(ownerAttribute, owner.token); + element.scrollTop = 0; + element.scrollLeft = 0; + } + const ownerIds = Array.from(owners.values(), (owner) => owner.id); + const ownerTokens = Array.from(owners.values(), (owner) => owner.token); + if (new Set(ownerIds).size !== ownerIds.length || new Set(ownerTokens).size !== ownerTokens.length) { + throw new Error("Layout capture scroll-owner assignment contains duplicate IDs."); + } + for (const owner of owners.values()) { + const matches = document.querySelectorAll(`[${ownerAttribute}="${owner.token}"]`).length; + if (matches !== 1) { + throw new Error(`Layout capture scroll-owner ${owner.id} resolved to ${matches} nodes after reset.`); + } + } + window.scrollTo(0, 0); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + + return Array.from(owners, ([element, owner]) => ({ + ...owner, + clientHeight: element.clientHeight, + clientWidth: element.clientWidth, + maxTop: Math.max(0, element.scrollHeight - element.clientHeight), + maxLeft: Math.max(0, element.scrollWidth - element.clientWidth), + top: element.scrollTop, + left: element.scrollLeft, + })); + }, { ownerAttribute: SCROLL_OWNER_ATTRIBUTE, ownerGeneration: generation }); +} + +async function scrollOwnerTo(page: Page, owner: ScrollOwner, top: number, left = 0) { + return page.evaluate( + async ({ ownerAttribute, ownerId: requestedOwnerId, ownerToken, top: requestedTop, left: requestedLeft }) => { + const matches = Array.from(document.querySelectorAll(`[${ownerAttribute}="${ownerToken}"]`)); + if (matches.length !== 1) { + throw new Error(`Scroll owner ${requestedOwnerId} expected one node for ${ownerToken}, found ${matches.length}.`); + } + const element = matches[0]; + element.scrollIntoView({ block: "nearest", inline: "nearest" }); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const maxTop = Math.max(0, element.scrollHeight - element.clientHeight); + const maxLeft = Math.max(0, element.scrollWidth - element.clientWidth); + const targetTop = Math.min(requestedTop, maxTop); + const targetLeft = Math.min(requestedLeft, maxLeft); + element.scrollTop = targetTop; + element.scrollLeft = targetLeft; + if (requestedOwnerId === "document") window.scrollTo(targetLeft, targetTop); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const rect = element.getBoundingClientRect(); + return { + token: ownerToken, + matchCount: matches.length, + top: element.scrollTop, + left: element.scrollLeft, + clientHeight: element.clientHeight, + clientWidth: element.clientWidth, + scrollHeight: element.scrollHeight, + scrollWidth: element.scrollWidth, + maxTop: Math.max(0, element.scrollHeight - element.clientHeight), + maxLeft: Math.max(0, element.scrollWidth - element.clientWidth), + rect: { top: rect.top, bottom: rect.bottom, left: rect.left, right: rect.right }, + }; + }, + { ownerAttribute: SCROLL_OWNER_ATTRIBUTE, ownerId: owner.id, ownerToken: owner.token, top, left }, + ); +} + +async function probeNativeMaxLeft(page: Page, owner: ScrollOwner): Promise { + return page.evaluate( + async ({ ownerAttribute, ownerId: requestedOwnerId, ownerToken }) => { + const matches = Array.from(document.querySelectorAll(`[${ownerAttribute}="${ownerToken}"]`)); + if (matches.length !== 1) { + throw new Error(`Scroll owner ${requestedOwnerId} expected one node for ${ownerToken}, found ${matches.length}.`); + } + const element = matches[0]; + const originalLeft = element.scrollLeft; + const theoreticalMaxLeft = Math.max(0, element.scrollWidth - element.clientWidth); + element.scrollLeft = Number.MAX_SAFE_INTEGER; + if (requestedOwnerId === "document") window.scrollTo(Number.MAX_SAFE_INTEGER, window.scrollY); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const actualMaxLeft = element.scrollLeft; + element.scrollLeft = originalLeft; + if (requestedOwnerId === "document") window.scrollTo(originalLeft, window.scrollY); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + return { + token: ownerToken, + matchCount: matches.length, + originalLeft, + theoreticalMaxLeft, + actualMaxLeft, + }; + }, + { ownerAttribute: SCROLL_OWNER_ATTRIBUTE, ownerId: owner.id, ownerToken: owner.token }, + ); +} + +function segmentOffsets(maxOffset: number, clientSize: number, owner: ScrollOwner) { + if (maxOffset <= 0) return []; + const step = Math.max(1, Math.floor(clientSize * 0.8)); + const offsets = [0]; + for (let offset = step; offset < maxOffset; offset += step) offsets.push(offset); + if (offsets[offsets.length - 1] !== maxOffset) offsets.push(maxOffset); + if (offsets.length > MAX_SCROLL_SEGMENTS) { + throw new Error( + `[${owner.label}] needs ${offsets.length} scroll captures; the ${MAX_SCROLL_SEGMENTS}-segment ceiling would omit its bottom.`, + ); + } + return offsets; +} + +async function captureScrollableSegments( + page: Page, + screen: string, + viewport: (typeof GATE_WIDTHS)[number], + owners: ScrollOwner[], +) { + for (const owner of owners) { + const initialPosition = await scrollOwnerTo(page, owner, 0); + expect(initialPosition.matchCount, `[${screen} @ ${viewport.label}] ${owner.label} owner identity`).toBe(1); + const liveOwner = { + ...owner, + clientHeight: initialPosition.clientHeight, + clientWidth: initialPosition.clientWidth, + maxTop: initialPosition.maxTop, + maxLeft: initialPosition.maxLeft, + }; + if (owner.maxTop > 0 && liveOwner.maxTop <= 0) { + await writeCaptureManifest({ + capturedAt: new Date().toISOString(), + screen, + viewport, + kind: "scroll-excluded", + owner: liveOwner, + offset: { top: initialPosition.top, left: initialPosition.left }, + path: null, + reason: "Owner stopped scrolling after it was brought into the capture viewport.", + ownerToken: owner.token, + ownerMatchCount: initialPosition.matchCount, + }); + } + const offsets = segmentOffsets(liveOwner.maxTop, liveOwner.clientHeight, liveOwner); + for (const [segmentIndex, top] of offsets.entries()) { + const offset = await scrollOwnerTo(page, owner, top); + expect(offset.matchCount, `[${screen} @ ${viewport.label}] ${owner.label} owner identity`).toBe(1); + const reachableTarget = Math.min(top, offset.maxTop); + expect( + Math.abs(offset.top - reachableTarget), + `[${screen} @ ${viewport.label}] ${owner.label} vertical capture offset`, + ).toBeLessThanOrEqual(1); + const shotPath = path.join( + SHOT_DIR, + `${screen}__${viewport.label}__${owner.id}__segment-${String(segmentIndex).padStart(2, "0")}.png`, + ); + await page.screenshot({ path: shotPath, fullPage: false }); + await writeCaptureManifest({ + capturedAt: new Date().toISOString(), + screen, + viewport, + kind: "vertical-segment", + owner: { ...liveOwner, maxTop: offset.maxTop, maxLeft: offset.maxLeft }, + offset, + path: path.relative(process.cwd(), shotPath), + ownerToken: owner.token, + ownerMatchCount: offset.matchCount, + }); + } + + if (liveOwner.maxLeft > 0) { + const horizontalProbe = await probeNativeMaxLeft(page, owner); + expect(horizontalProbe.matchCount, `[${screen} @ ${viewport.label}] ${owner.label} owner identity`).toBe(1); + expect(horizontalProbe.theoreticalMaxLeft, `[${screen} @ ${viewport.label}] ${owner.label} theoretical horizontal maximum`).toBe(liveOwner.maxLeft); + const offset = await scrollOwnerTo(page, owner, 0, horizontalProbe.actualMaxLeft); + expect(offset.matchCount, `[${screen} @ ${viewport.label}] ${owner.label} owner identity`).toBe(1); + expect( + Math.abs(offset.left - horizontalProbe.actualMaxLeft), + `[${screen} @ ${viewport.label}] ${owner.label} horizontal capture offset`, + ).toBeLessThanOrEqual(1); + const shotPath = path.join(SHOT_DIR, `${screen}__${viewport.label}__${owner.id}__horizontal-end.png`); + await page.screenshot({ path: shotPath, fullPage: false }); + await writeCaptureManifest({ + capturedAt: new Date().toISOString(), + screen, + viewport, + kind: "horizontal-end", + owner: { ...liveOwner, maxTop: offset.maxTop, maxLeft: offset.maxLeft }, + offset, + path: path.relative(process.cwd(), shotPath), + ownerToken: owner.token, + ownerMatchCount: offset.matchCount, + theoreticalMaxLeft: horizontalProbe.theoreticalMaxLeft, + actualMaxLeft: horizontalProbe.actualMaxLeft, + }); + const afterActualMax = await scrollOwnerTo( + page, + owner, + 0, + horizontalProbe.actualMaxLeft + Math.max(100, offset.clientWidth), + ); + expect(afterActualMax.matchCount, `[${screen} @ ${viewport.label}] ${owner.label} owner identity`).toBe(1); + expect( + Math.abs(afterActualMax.left - horizontalProbe.actualMaxLeft), + `[${screen} @ ${viewport.label}] ${owner.label} native horizontal maximum`, + ).toBeLessThanOrEqual(1); + } + } + await resetScrollableOwners(page); +} + /** * Scans every visible interactive control and prominent text container for * either (a) extending beyond the viewport horizontally, or (b) clipping its own @@ -261,10 +577,18 @@ async function gateScreen( await page.setViewportSize({ width: vp.width, height: vp.height }); await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => r(null)))); await prepareReady(); - await page.evaluate(() => { - window.scrollTo(0, 0); - return new Promise((resolve) => requestAnimationFrame(() => resolve(null))); - }); + const owners = await resetScrollableOwners(page); + for (const owner of owners) { + await writeCaptureManifest({ + capturedAt: new Date().toISOString(), + screen, + viewport: vp, + kind: "scroll-reset", + owner, + offset: { top: owner.top, left: owner.left }, + path: null, + }); + } await expect( page.locator("html"), @@ -306,10 +630,28 @@ async function gateScreen( )}`, ).toEqual([]); + const topShotPath = path.join(SHOT_DIR, `${screen}__${vp.label}.png`); await page.screenshot({ - path: path.join(SHOT_DIR, `${screen}__${vp.label}.png`), + path: topShotPath, fullPage: true, }); + await writeCaptureManifest({ + capturedAt: new Date().toISOString(), + screen, + viewport: vp, + kind: "full-page", + owner: { + id: "document", + label: "document", + clientHeight: page.viewportSize()?.height ?? vp.height, + clientWidth: page.viewportSize()?.width ?? vp.width, + maxTop: 0, + maxLeft: 0, + }, + offset: { top: 0, left: 0 }, + path: path.relative(process.cwd(), topShotPath), + }); + await captureScrollableSegments(page, screen, vp, owners); } } @@ -445,10 +787,11 @@ async function expectSupervisorReviewNoDeadGaps(page: Page) { } test.describe("layout visual gate @single-run", () => { - test.describe.configure({ mode: "serial" }); + test.describe.configure({ mode: "serial", timeout: 90_000 }); test.beforeAll(async () => { await ensureShotDir(); + await fs.writeFile(path.join(SHOT_DIR, "capture-manifest.jsonl"), "", "utf8"); }); // 이 게이트는 다크 표면을 기준으로 레이아웃을 캡처하고, 라이트 검사는 각 테스트가 @@ -499,24 +842,52 @@ test.describe("layout visual gate @single-run", () => { await page.goto("/learn"); await gateScreen(page, "learner-home", async () => { await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 }); - await expect(page.locator(".lh-dashboard-status .lh-metric-card").first()).toBeVisible({ - timeout: 15_000, - }); - await expect(page.locator(".lh-work-cluster")).toBeVisible({ timeout: 15_000 }); - // 대시보드 탭은 모든 폭에서 상시 노출된다 — 탭별 콘텐츠를 확인 후 기본 탭으로 복귀. const dashTabs = page.locator(".lh-tabs"); - if (await dashTabs.isVisible().catch(() => false)) { - await dashTabs.locator("button", { hasText: "기록 · 리뷰" }).click(); - await expect(page.locator(".lh-compact-list li").first()).toBeVisible({ - timeout: 15_000, + await expect(dashTabs).toBeVisible({ timeout: 15_000 }); + + await dashTabs.getByRole("tab", { name: "오늘의 회기", exact: true }).click(); + await expect(page.locator("[data-learner-primary-action]")).toBeVisible({ timeout: 15_000 }); + await expect(page.locator("#lh-panel-today .lh-session-focus")).toBeVisible({ timeout: 15_000 }); + + await dashTabs.getByRole("tab", { name: "성장 지표", exact: true }).click(); + const growthPanel = page.locator("#lh-panel-growth"); + const growthStatus = growthPanel.locator(".lh-dashboard-status"); + const metricCards = growthStatus.locator(".lh-metric-card"); + await expect(growthStatus).toBeVisible({ timeout: 15_000 }); + await expect(metricCards).toHaveCount(4); + await expect(metricCards.first()).toBeVisible(); + const metricGeometry = await growthStatus.evaluate((status) => { + const panel = status.closest("#lh-panel-growth"); + if (!panel) return null; + const statusRect = status.getBoundingClientRect(); + const panelRect = panel.getBoundingClientRect(); + const cards = Array.from(status.querySelectorAll(".lh-metric-card")).map((card) => { + const rect = card.getBoundingClientRect(); + return { width: rect.width, height: rect.height }; }); - await dashTabs.locator("button", { hasText: "오늘의 회기" }).click(); - await expect(page.locator(".lh-work-cluster")).toBeVisible({ timeout: 15_000 }); - } else { - await expect(page.locator(".lh-compact-list li").first()).toBeVisible({ - timeout: 15_000, - }); - } + return { + leftGap: Math.abs(statusRect.left - panelRect.left), + rightGap: Math.abs(panelRect.right - statusRect.right), + cards, + }; + }); + expect(metricGeometry, "growth metrics should stay inside the growth panel").not.toBeNull(); + expect(metricGeometry!.leftGap, "growth metrics should span the panel at every gate width").toBeLessThanOrEqual(1); + expect(metricGeometry!.rightGap, "growth metrics should span the panel at every gate width").toBeLessThanOrEqual(1); + expect(metricGeometry!.cards.every((card) => card.width > 0 && card.height > 0)).toBe(true); + const viewport = page.viewportSize(); + const viewportLabel = GATE_WIDTHS.find( + (candidate) => candidate.width === viewport?.width && candidate.height === viewport?.height, + )?.label; + await page.screenshot({ + path: path.join(SHOT_DIR, `learner-home-growth__${viewportLabel ?? "unknown"}.png`), + fullPage: true, + }); + + await dashTabs.getByRole("tab", { name: "기록 · 리뷰", exact: true }).click(); + await expect(page.locator(".lh-compact-list li").first()).toBeVisible({ timeout: 15_000 }); + await dashTabs.getByRole("tab", { name: "오늘의 회기", exact: true }).click(); + await expect(page.locator(".lh-work-cluster")).toBeVisible({ timeout: 15_000 }); const shellScroll = await page.evaluate(async () => { const nav = document.querySelector(".vg-nav"); @@ -705,9 +1076,13 @@ test.describe("layout visual gate @single-run", () => { await signInAsTeacher(page); await page.goto("/teach"); await gateScreen(page, "professor", async () => { - await expect(page.locator(".pf-panel, .pf-shell, main").first()).toBeVisible({ + await expect(page.locator(".pf-root--console")).toBeVisible({ timeout: 15_000, }); + await expect(page.locator(".pf-triage__copy > span").last()).toContainText("실제 기록", { + timeout: 15_000, + }); + await expect(page.getByRole("button", { name: "새로고침" })).toBeEnabled(); await expectProfessorSummaryGap(page); }); }); @@ -778,6 +1153,8 @@ test.describe("layout visual gate @single-run", () => { await expect(page.locator(".ps-overview")).toBeVisible({ timeout: 15_000 }); await expect(page.getByRole("heading", { name: "페르소나 운영" })).toBeVisible(); await expect(page.getByRole("navigation", { name: "페르소나 관리 영역" })).toBeVisible(); + await expect(page.getByRole("button", { name: "새로고침" })).toBeEnabled({ timeout: 15_000 }); + await expect(page.locator(".ps-error")).toHaveCount(0); }); }); @@ -788,6 +1165,7 @@ test.describe("layout visual gate @single-run", () => { await expect(page.locator(".ps-authoring-layout")).toBeVisible({ timeout: 15_000 }); await expect(page.getByRole("navigation", { name: "페르소나 작성 단계" })).toBeVisible(); await expect(page.getByRole("tab", { name: "개요" })).toBeVisible(); + await expect(page.getByRole("button", { name: "새로고침" })).toBeEnabled({ timeout: 15_000 }); }); }); @@ -798,6 +1176,8 @@ test.describe("layout visual gate @single-run", () => { await page.goto("/admin"); await gateScreen(page, "admin", async () => { await expect(page.locator("main").first()).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole("button", { name: "새로고침" })).toBeEnabled({ timeout: 15_000 }); + await expect(page.locator(".vgops-status > div > span")).not.toHaveText("연결 확인 중"); }); }); @@ -811,6 +1191,12 @@ test.describe("layout visual gate @single-run", () => { timeout: 15_000, }); await expect(page.locator(".aic-grid")).toBeVisible(); + const adminAi = page.getByTestId("admin-ai-page"); + await expect(adminAi).toHaveAttribute("aria-busy", "false", { timeout: 15_000 }); + await expect(adminAi.getByRole("button", { name: "새로고침", exact: true })).toBeEnabled(); + await expect(page.locator(".aic-source b")).not.toHaveText("계량 원천 확인 중"); + await expect(page.getByText("현재 AI 엔진 설정을 불러오는 중입니다.")).toHaveCount(0); + await expect(page.locator('[data-testid="provider-connections-panel"] .aic-runtime')).not.toHaveText("확인 중"); }); }); @@ -829,6 +1215,10 @@ test.describe("layout visual gate @single-run", () => { ]) { await page.setViewportSize(viewport); await expectNoHorizontalOverflow(page); + await expect.poll(() => page.locator('[data-admin-section="users"]').evaluate((element) => { + const rect = element.getBoundingClientRect(); + return rect.left >= -1 && rect.right <= window.innerWidth + 1; + })).toBe(true); const scrollReport = await page.locator(".vgops-user-table-scroll").evaluate((element) => { element.scrollLeft = 0; const report = { @@ -849,6 +1239,7 @@ test.describe("layout visual gate @single-run", () => { await page.locator(".vgops-user-table-scroll").evaluate((element) => { element.scrollLeft = 0; }); + await resetScrollableOwners(page); await page.screenshot({ path: path.join(SHOT_DIR, `admin-users__${viewport.width}-reference-dark.png`), fullPage: true, @@ -871,6 +1262,8 @@ test.describe("layout visual gate @single-run", () => { await page.goto("/settings"); await gateScreen(page, "settings", async () => { await expect(page.locator("main").first()).toBeVisible({ timeout: 15_000 }); + await expect(page.locator(".vg-set")).toHaveAttribute("aria-busy", "false", { timeout: 15_000 }); + await expect(page.locator(".vg-set__callout--warn")).toHaveCount(0); }); await page.setViewportSize({ width: 1280, height: 800 }); await page.getByRole("button", { name: "라이트 모드로" }).click(); diff --git a/apps/web/e2e/public-admin-visual.spec.ts b/apps/web/e2e/public-admin-visual.spec.ts index 921c022..cf70e2f 100644 --- a/apps/web/e2e/public-admin-visual.spec.ts +++ b/apps/web/e2e/public-admin-visual.spec.ts @@ -214,7 +214,7 @@ test.describe("public admin visual @public-auth", () => { { path: "/admin/ai", heading: "AI 운영과 DB 계량", root: "[data-testid='admin-ai-page']" }, { path: "/admin/users", heading: "가입 승인과 권한 관리", root: "[data-vignette-admin-root]" }, { path: "/admin/access", heading: "역할, 그룹, 접근 범위", root: "[data-vignette-admin-root]" }, - { path: "/admin/tickets", heading: "사용자 문제 큐", root: "[data-vignette-admin-root]" }, + { path: "/admin/tickets", heading: "지원 요청", root: "[data-vignette-admin-root]" }, ] as const; for (const section of sections) { @@ -268,7 +268,7 @@ test.describe("public admin visual @public-auth", () => { { path: "/admin/ai", heading: "AI 운영과 DB 계량", root: "[data-testid='admin-ai-page']" }, { path: "/admin/users", heading: "가입 승인과 권한 관리", root: "[data-vignette-admin-root]" }, { path: "/admin/access", heading: "역할, 그룹, 접근 범위", root: "[data-vignette-admin-root]" }, - { path: "/admin/tickets", heading: "사용자 문제 큐", root: "[data-vignette-admin-root]" }, + { path: "/admin/tickets", heading: "지원 요청", root: "[data-vignette-admin-root]" }, ] as const; for (const section of sections) { diff --git a/apps/web/e2e/session-layout.spec.ts b/apps/web/e2e/session-layout.spec.ts index 47c4c43..d418fe9 100644 --- a/apps/web/e2e/session-layout.spec.ts +++ b/apps/web/e2e/session-layout.spec.ts @@ -47,6 +47,111 @@ async function expectNoLocalStageDemoControl(page: Page) { await expect(page.locator(".sx-track__advance")).toHaveCount(0); } +async function expectPrestartSummaryReadableOnMobile(page: Page) { + if (!(await page.evaluate(() => window.matchMedia("(max-width: 720px)").matches))) return; + + await page.evaluate(() => { + const section = document.querySelector(".sx-page--prestart"); + let owner = document.scrollingElement; + for (let node = section?.parentElement; node; node = node.parentElement) { + const style = window.getComputedStyle(node); + if (/(auto|scroll)/.test(style.overflowY) && node.scrollHeight > node.clientHeight + 1) { + owner = node; + break; + } + } + if (!owner) throw new Error("Expected prestart scroll owner"); + owner.scrollTop = Math.round((owner.scrollHeight - owner.clientHeight) / 2); + }); + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(resolve))); + + const metrics = await page.evaluate(() => { + const facts = document.querySelector(".sx-page--prestart .sx-prestart__facts"); + const complaint = facts?.querySelector(":scope > div:first-child"); + const complaintText = complaint?.querySelector("dd"); + const actions = document.querySelector(".sx-page--prestart .sx-prestart__actions"); + if (!facts || !complaint || !complaintText || !actions) return null; + + const complaintRect = complaint.getBoundingClientRect(); + const factsRect = facts.getBoundingClientRect(); + const complaintStyle = window.getComputedStyle(complaintText); + const actionsStyle = window.getComputedStyle(actions); + const actionsRect = actions.getBoundingClientRect(); + const navigation = document.querySelector(".vg-nav"); + const navigationRect = navigation?.getBoundingClientRect(); + const startButton = actions.querySelector(".vg-btn"); + const actionDescription = actions.querySelector("span"); + const alphaMatch = actionsStyle.backgroundColor.match(/^rgba\([^,]+,\s*[^,]+,\s*[^,]+,\s*([0-9.]+)\)$/); + const actionsAlpha = actionsStyle.backgroundColor === "transparent" + ? 0 + : alphaMatch + ? Number.parseFloat(alphaMatch[1]) + : 1; + const hitPoints = [startButton, actionDescription, actions] + .filter((element): element is HTMLElement => Boolean(element)) + .map((element) => { + const rect = element.getBoundingClientRect(); + const x = Math.round(rect.left + rect.width / 2); + const y = Math.round(rect.top + rect.height / 2); + const hit = document.elementFromPoint(x, y); + return { + x, + y, + hitClass: hit instanceof HTMLElement ? hit.className : hit?.nodeName ?? null, + belongsToAction: Boolean(hit && actions.contains(hit)), + }; + }); + + return { + complaintWidth: Math.round(complaintRect.width), + factsWidth: Math.round(factsRect.width), + fontSize: Number.parseFloat(complaintStyle.fontSize), + lineHeight: Number.parseFloat(complaintStyle.lineHeight), + actionsBackground: actionsStyle.backgroundColor, + actionsAlpha, + actionsPosition: actionsStyle.position, + actionsBottom: Math.round(actionsRect.bottom), + navigationTop: navigationRect ? Math.round(navigationRect.top) : null, + hitPoints, + }; + }); + + expect(metrics, "Expected mobile prestart facts and start action").not.toBeNull(); + expect(metrics!.complaintWidth, `Complaint should span the facts row: ${JSON.stringify(metrics)}`).toBeGreaterThanOrEqual( + metrics!.factsWidth - 2, + ); + expect(metrics!.fontSize, `Complaint text is too small: ${JSON.stringify(metrics)}`).toBeGreaterThanOrEqual(14); + expect(metrics!.lineHeight, `Complaint line height is too tight: ${JSON.stringify(metrics)}`).toBeGreaterThanOrEqual(21); + expect(metrics!.actionsAlpha, `Start action must be opaque: ${JSON.stringify(metrics)}`).toBe(1); + expect(metrics!.actionsPosition, `Start action must stay at the mobile bottom: ${JSON.stringify(metrics)}`).toBe("fixed"); + expect(metrics!.navigationTop, `Expected mobile navigation: ${JSON.stringify(metrics)}`).not.toBeNull(); + expect(metrics!.actionsBottom, `Start action must stay above mobile navigation: ${JSON.stringify(metrics)}`).toBeLessThanOrEqual( + metrics!.navigationTop! + 1, + ); + expect(metrics!.hitPoints, `Expected three start action hit points: ${JSON.stringify(metrics)}`).toHaveLength(3); + expect(metrics!.hitPoints.every((point) => point.belongsToAction), `Start action is covered at its controls: ${JSON.stringify(metrics)}`).toBe(true); + + const goals = page.locator(".sx-page--prestart .sx-goals__grid button"); + for (const goal of await goals.all()) { + await goal.scrollIntoViewIfNeeded(); + const geometry = await goal.evaluate((button) => { + const action = document.querySelector(".sx-page--prestart .sx-prestart__actions"); + const buttonRect = button.getBoundingClientRect(); + const actionRect = action?.getBoundingClientRect(); + return { + top: Math.round(buttonRect.top), + bottom: Math.round(buttonRect.bottom), + actionTop: actionRect ? Math.round(actionRect.top) : null, + }; + }); + expect(geometry.top, `Goal is above the viewport: ${JSON.stringify(geometry)}`).toBeGreaterThanOrEqual(0); + expect(geometry.actionTop, `Expected fixed start action: ${JSON.stringify(geometry)}`).not.toBeNull(); + expect(geometry.bottom, `Goal is hidden behind the start action: ${JSON.stringify(geometry)}`).toBeLessThanOrEqual( + geometry.actionTop! - 1, + ); + } +} + async function expectMobileContextIfNarrow(page: Page) { const isNarrow = await page.evaluate(() => window.matchMedia("(max-width: 1180px)").matches, @@ -473,6 +578,7 @@ test.describe("learner session full-screen layout", () => { await expect(page.getByRole("button", { name: "회기 시작" })).toBeVisible(); await expectNoHorizontalOverflow(page); + await expectPrestartSummaryReadableOnMobile(page); await page.getByRole("button", { name: "회기 시작" }).click(); await completeAlliancePreCheckpoint(page); diff --git a/apps/web/e2e/session-review-fixture.ts b/apps/web/e2e/session-review-fixture.ts index 8c9b10b..e1479ba 100644 --- a/apps/web/e2e/session-review-fixture.ts +++ b/apps/web/e2e/session-review-fixture.ts @@ -478,4 +478,35 @@ export async function routeEmptySessionReview( body: JSON.stringify(emptyReviewResponse(sessionId)), }); }); + await page.route(`**/api/sessions/${sessionId}/alliance-pulses`, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ items: [] }), + }); + }); + await page.route( + new RegExp(`/api/sessions/${sessionId}/multimodal-alliance\\?include_derived=true$`), + async (route) => { + await route.fulfill({ + status: 404, + contentType: "application/json", + body: JSON.stringify({ detail: "multimodal alliance ledger not found" }), + }); + }, + ); + await page.route(`**/api/sessions/${sessionId}/outcome-trajectory`, async (route) => { + await route.fulfill({ + status: 404, + contentType: "application/json", + body: JSON.stringify({ detail: "outcome trajectory not found" }), + }); + }); + await page.route(`**/api/sessions/${sessionId}/ruptures`, async (route) => { + await route.fulfill({ + status: 404, + contentType: "application/json", + body: JSON.stringify({ detail: "rupture ledger not found" }), + }); + }); } diff --git a/apps/web/e2e/settings-ui.spec.ts b/apps/web/e2e/settings-ui.spec.ts new file mode 100644 index 0000000..7ebef6c --- /dev/null +++ b/apps/web/e2e/settings-ui.spec.ts @@ -0,0 +1,241 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; + +function jsonRoute(body: unknown, status = 200) { + return { status, contentType: "application/json", body: JSON.stringify(body) }; +} + +const PROFILE = { + user_id: "e2e-settings-ui", + email: "settings.ui@hs.ac.kr", + display_name: "설정 화면 검증 학습자", + legal_name: "설정 화면 검증 학습자", + nickname: "설정검증", + self_introduction: "", + role: "learner", + cohort_ids: [], + affiliation: "한신대학교", + department: "상담심리학과", + grade_level: "3학년", + phone: "010-0000-0000", + contact_address: "경기도 오산시", + avatar_url: "", + onboarding_required: false, + onboarding_completed_at: 1780000000, + terms_version: "terms-draft-2026-06-27", + terms_agreed_at: 1780000000, + privacy_version: "privacy-draft-2026-06-27", + privacy_agreed_at: 1780000000, +}; + +const PREFERENCES = { + theme: "light", + voice_preset_id: "soft-young-fem", + voice_rate: 1, + notifications: { + session_done: true, + safety_signal: true, + learner_progress: true, + account_approval: true, + product_news: false, + }, +}; + +const VOICE_PRESETS = [ + { + id: "soft-young-fem", + name: "차분한 목소리", + voice_id: "ko-soft-01", + desc: "낮은 톤의 안정적인 발화", + persona_hint: "차분한 20대 내담자", + }, + { + id: "bright-mid-fem", + name: "밝은 목소리", + voice_id: "ko-bright-02", + desc: "밝고 빠른 발화", + persona_hint: "활발한 30대 내담자", + }, +]; + +async function mountSettingsFixture(page: Page) { + await page.route("**/api/**", (route) => + route.fulfill(jsonRoute({ detail: "not part of settings UI fixture" }, 404)), + ); + await page.route("**/api/auth/me", (route) => + route.fulfill(jsonRoute({ + user_id: PROFILE.user_id, + email: PROFILE.email, + role: "learner", + admin_access: false, + super_admin: false, + account_status: "approved", + approval_required: false, + cohort_ids: [], + consent_at: 1780000000, + onboarding_completed_at: PROFILE.onboarding_completed_at, + nickname: PROFILE.nickname, + display_name: PROFILE.display_name, + self_introduction: "", + avatar_url: "", + })), + ); + await page.route("**/api/users/me", (route: Route) => { + if (route.request().method() === "GET") return route.fulfill(jsonRoute(PROFILE)); + return route.fulfill(jsonRoute(PROFILE)); + }); + await page.route("**/api/users/me/preferences", (route: Route) => { + if (route.request().method() === "GET") return route.fulfill(jsonRoute(PREFERENCES)); + return route.fulfill(jsonRoute(PREFERENCES)); + }); + await page.route("**/api/users/me/voice-presets", (route) => route.fulfill(jsonRoute(VOICE_PRESETS))); + await page.route("**/api/users/support-tickets", (route) => + route.fulfill(jsonRoute({ source: "fixture", durable: false, generated_at: 1780000000, tickets: [] })), + ); +} + +async function expectNoHorizontalClipping(page: Page, width: number) { + const offenders = await page.locator(".vg-set, .vg-set *").evaluateAll((elements, viewportWidth) => + elements.flatMap((element) => { + const rect = element.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0 || rect.bottom <= 0) return []; + return rect.left < -0.5 || rect.right > Number(viewportWidth) + 0.5 + ? [{ tag: element.tagName, className: element.className, left: rect.left, right: rect.right }] + : []; + }), width, + ); + expect(offenders).toEqual([]); +} + +async function waitForScrollSettled(page: Page) { + await page.evaluate(async () => { + const scroller = document.querySelector(".vg-main"); + await new Promise((resolve) => { + let frame = 0; + let stableFrames = 0; + let previous = scroller?.scrollTop ?? window.scrollY; + const check = () => { + const current = scroller?.scrollTop ?? window.scrollY; + stableFrames = Math.abs(current - previous) < 0.5 ? stableFrames + 1 : 0; + previous = current; + frame += 1; + if (stableFrames >= 8 || frame >= 120) return resolve(); + requestAnimationFrame(check); + }; + requestAnimationFrame(check); + }); + }); +} + +async function expectMobileInteractiveTargets(page: Page) { + const undersized = await page + .locator(".vg-set button:not(:disabled), .vg-set [role='button']:not([aria-disabled='true'])") + .evaluateAll((elements) => + elements.flatMap((element) => { + const rect = element.getBoundingClientRect(); + const selector = element.id + ? `#${element.id}` + : `${element.tagName.toLowerCase()}.${Array.from(element.classList).join(".")}`; + return rect.width >= 44 && rect.height >= 44 + ? [] + : [{ selector, text: element.textContent?.trim(), width: rect.width, height: rect.height }]; + }), + ); + expect(undersized).toEqual([]); + + const voicePreview = await page.locator(".vg-set__voice-play").first().boundingBox(); + expect(voicePreview?.width).toBeGreaterThanOrEqual(44); + expect(voicePreview?.height).toBeGreaterThanOrEqual(44); +} + +test("설정 섹션 내비는 모든 화면 폭에서 라벨·터치 영역·탭 이동을 보장한다", async ({ page }) => { + await mountSettingsFixture(page); + const viewports = [ + { width: 320, height: 844 }, + { width: 390, height: 844 }, + { width: 768, height: 900 }, + { width: 1440, height: 900 }, + ]; + const sections = [ + ["계정", "set-account"], + ["지원 요청", "set-support"], + ["동의", "set-consent"], + ["테마", "set-appearance"], + ["알림", "set-notify"], + ["음성", "set-voice"], + ] as const; + + for (const viewport of viewports) { + await page.setViewportSize(viewport); + await page.goto("/settings"); + await expect(page.getByRole("button", { name: "알림 저장" })).toBeVisible(); + + const nav = page.getByRole("navigation", { name: "설정 섹션" }); + const tabs = nav.getByRole("button"); + await expect(tabs).toHaveCount(6); + await expectNoHorizontalClipping(page, viewport.width); + + for (const [label, sectionId] of sections) { + const tab = nav.getByRole("button", { name: label }); + await expect(tab).toBeVisible(); + const box = await tab.boundingBox(); + expect(box?.height).toBeGreaterThanOrEqual(44); + expect(box?.x).toBeGreaterThanOrEqual(0); + expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual(viewport.width); + + await tab.click(); + await waitForScrollSettled(page); + await expect(tab).toHaveAttribute("aria-current", "location"); + await expect(page.locator(`#${sectionId}`)).toBeInViewport(); + if (viewport.width <= 1080) { + const [navBox, sectionBox] = await Promise.all([ + nav.boundingBox(), + page.locator(`#${sectionId}`).boundingBox(), + ]); + expect(sectionBox?.y).toBeGreaterThanOrEqual((navBox?.y ?? 0) + (navBox?.height ?? 0)); + } + if (viewport.width === 320 && label === "계정") { + const [railBox, accountBox] = await Promise.all([ + page.locator(".vg-set__rail").boundingBox(), + page.locator("#set-account").boundingBox(), + ]); + expect(railBox?.x).toBeCloseTo(accountBox?.x ?? 0, 0); + expect(railBox?.width).toBeCloseTo(accountBox?.width ?? 0, 0); + const railBackground = await page.locator(".vg-set__rail").evaluate( + (element) => getComputedStyle(element).backgroundColor, + ); + expect(railBackground).not.toBe("rgba(0, 0, 0, 0)"); + } + if (viewport.width === 320 && label === "지원 요청") { + const description = await page.locator("#set-support .vg-set__group-desc").boundingBox(); + expect(description?.width).toBeGreaterThanOrEqual(150); + } + if (viewport.width === 320 && label === "동의") { + const [description, action] = await Promise.all([ + page.locator("#set-consent .vg-set__opt-text").first().boundingBox(), + page.getByRole("button", { name: "실습 동의 철회" }).boundingBox(), + ]); + expect(description?.width).toBeGreaterThanOrEqual(150); + expect(action?.height).toBeGreaterThanOrEqual(44); + } + await expectNoHorizontalClipping(page, viewport.width); + if (viewport.width === 320 && process.env.SETTINGS_UI_CAPTURE_DIR) { + await page.screenshot({ + path: `${process.env.SETTINGS_UI_CAPTURE_DIR}/settings-320-${sectionId}.png`, + fullPage: true, + }); + } + } + + const account = nav.getByRole("button", { name: "계정" }); + await account.focus(); + await page.keyboard.press("Tab"); + await expect(nav.getByRole("button", { name: "지원 요청" })).toBeFocused(); + await page.keyboard.press("Enter"); + await expect(nav.getByRole("button", { name: "지원 요청" })).toHaveAttribute("aria-current", "location"); + await expect(page.locator("#set-support")).toBeInViewport(); + + if (viewport.width === 320) { + await expectMobileInteractiveTargets(page); + } + } +}); diff --git a/apps/web/e2e/shell-navigation.spec.ts b/apps/web/e2e/shell-navigation.spec.ts new file mode 100644 index 0000000..0d57602 --- /dev/null +++ b/apps/web/e2e/shell-navigation.spec.ts @@ -0,0 +1,137 @@ +import { expect, test, type Page } from "@playwright/test"; +import { + expectNoHorizontalOverflow, + signInAsAdmin, + signInAsLearner, + signInAsTeacher, +} from "./support"; + +const viewports = [ + { width: 320, height: 568 }, + { width: 390, height: 844 }, + { width: 768, height: 900 }, + { width: 1024, height: 900 }, +]; + +async function expectMinimumTargets(page: Page, selector: string) { + const targets = await page.locator(selector).evaluateAll((elements) => + elements + .filter((element) => window.getComputedStyle(element).display !== "none") + .map((element) => { + const rect = element.getBoundingClientRect(); + return { text: element.textContent?.trim(), width: rect.width, height: rect.height }; + }), + ); + for (const target of targets) { + expect(target.width, `${target.text} width`).toBeGreaterThanOrEqual(44); + expect(target.height, `${target.text} height`).toBeGreaterThanOrEqual(44); + } +} + +test.describe("공통 셸 내비게이션", () => { + test("역할별 하단 목적지와 태블릿 레일은 레이블·조작면·오버플로 계약을 지킨다", async ({ page }) => { + const roles = [ + { + signIn: signInAsLearner, + path: "/learn", + mobile: ["학습자 홈", "상담 연습", "학습 기록"], + desktop: ["학습자 홈", "상담 연습", "학습 기록", "설정"], + }, + { + signIn: signInAsTeacher, + path: "/teach", + mobile: ["교수자 홈", "학생 분석", "감독·연구"], + desktop: ["교수자 홈", "학생 분석", "감독·연구", "내담자 설정", "설정"], + }, + { + signIn: signInAsAdmin, + path: "/admin", + mobile: ["운영 홈", "AI 운영", "사용자 관리"], + desktop: ["운영 홈", "AI 운영", "사용자 관리", "접근 관리", "지원 요청"], + }, + ] as const; + + for (const role of roles) { + await role.signIn(page); + await page.goto(role.path); + await expect(page.locator(".vg-shell")).toBeVisible({ timeout: 15_000 }); + + for (const viewport of viewports) { + await page.setViewportSize(viewport); + await expectNoHorizontalOverflow(page); + + if (viewport.width <= 720) { + const labels = await page.locator(".vg-nav__item--mobile-primary:visible .vg-nav__txt").allTextContents(); + expect(labels).toEqual(role.mobile); + await expect(page.getByRole("button", { name: "전체 메뉴" })).toBeVisible(); + await expectMinimumTargets(page, ".vg-nav__item--mobile-primary:visible, .vg-nav__more:visible"); + } else { + for (const label of role.desktop) { + await expect(page.getByRole("link", { name: label, exact: true })).toBeVisible(); + } + if (viewport.width === 1024 && role.path === "/admin") { + for (const group of ["운영", "교수", "학습", "설정"]) { + await expect(page.locator(".vg-nav__label", { hasText: group })).toBeVisible(); + } + } + } + } + } + }); + + test("전체 메뉴는 키보드로 닫히고 현재 위치를 보존하며 교수자 홈 단축키를 정확히 보낸다", async ({ page }) => { + await signInAsTeacher(page); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/teach/personas"); + await expect(page.locator(".vg-shell")).toBeVisible({ timeout: 15_000 }); + + const menuButton = page.getByRole("button", { name: "전체 메뉴" }); + await expect(menuButton).toHaveClass(/is-active/); + await menuButton.click(); + + const dialog = page.locator("#vg-all-menu-dialog"); + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole("heading", { name: "전체 메뉴" })).toBeVisible(); + await expect(dialog.getByRole("link", { name: "학생 분석" })).toBeVisible(); + await expect(page.getByRole("button", { name: "전체 메뉴 닫기" })).toBeFocused(); + + await page.keyboard.press("Escape"); + await expect(dialog).not.toBeVisible(); + await expect(menuButton).toBeFocused(); + + await menuButton.click(); + await dialog.getByRole("link", { name: "학생 분석" }).click(); + await expect(page).toHaveURL(/\/teach\/analysis$/); + await expect(dialog).not.toBeVisible(); + + await page.goto("/teach/analysis", { waitUntil: "networkidle" }); + await page.keyboard.press("Alt+h"); + await expect(page).toHaveURL(/\/teach$/); + + const browserShortcutCancellation = await page.evaluate(() => { + const results = ["t", "w"].map((key) => { + const event = new KeyboardEvent("keydown", { key, ctrlKey: true, bubbles: true, cancelable: true }); + window.dispatchEvent(event); + return event.defaultPrevented; + }); + return results; + }); + expect(browserShortcutCancellation).toEqual([false, false]); + }); + + test("좁은 화면의 공간 선택은 레이블을 유지하며 44px 조작면을 지킨다", async ({ page }) => { + await signInAsAdmin(page); + await page.setViewportSize({ width: 320, height: 568 }); + await page.goto("/admin"); + await expect(page.locator(".vg-shell")).toBeVisible({ timeout: 15_000 }); + + const select = page.getByRole("combobox", { name: "공간 선택" }); + await expect(select).toBeVisible(); + await expect(select).toHaveValue("admin"); + await expect(select.locator("option")).toHaveText(["학습자", "교수자", "관리자"]); + await expectMinimumTargets(page, ".vg-topbar__switch-select:visible, .vg-iconbtn:visible"); + + await select.selectOption("teacher"); + await expect(page).toHaveURL(/\/teach$/); + }); +}); diff --git a/apps/web/e2e/supervision-research.spec.ts b/apps/web/e2e/supervision-research.spec.ts index 12cd742..b72b05d 100644 --- a/apps/web/e2e/supervision-research.spec.ts +++ b/apps/web/e2e/supervision-research.spec.ts @@ -273,9 +273,13 @@ test.describe("G6 supervision and research OS", () => { await expect( page.getByRole("heading", { - name: "먼저 볼 경로와, 믿을 수 있는 출처를 분리해 본다.", + name: "감독·연구", }), ).toBeVisible(); + await expect(page.getByText("학습 검토", { exact: true })).toBeVisible(); + await expect(page.locator(".g6-hero p")).toHaveText( + "검토가 필요한 학습 회기와 평가 근거를 확인합니다.", + ); await expect(page.getByRole("tab", { name: "교수 감독" })).toHaveAttribute( "aria-selected", "true", @@ -292,11 +296,11 @@ test.describe("G6 supervision and research OS", () => { await page.getByRole("button", { name: /learner-risk/ }).click(); await expect(page.locator(".g6-ledger li")).toHaveCount(3); await expect(page.locator(".g6-drilldown a")).toHaveCount(3); - await expect(page.getByTestId("drilldown-depth")).toContainText("계약 상한 3번"); + await expect(page.getByTestId("drilldown-depth")).toHaveText("선택한 항목의 근거를 확인합니다."); - await expect(page.getByRole("heading", { name: "개인의 순위를 만들지 않는 교육과정 공백" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "반복되는 학습 보완점" })).toBeVisible(); await expect(page.getByText("competency.rupture-repair").first()).toBeVisible(); - await expect(page.getByText("근거 없는 상태를 유지하고 해석하지 않아.")).toBeVisible(); + await expect(page.getByText("근거가 부족한 상태를 유지하고 해석하지 않습니다.")).toBeVisible(); await expect(page.getByText("resolved", { exact: true })).toBeVisible(); await expect(page.getByText("partial", { exact: true })).toBeVisible(); await expect(page.getByText("원문 축어록 제외")).toBeVisible(); @@ -306,12 +310,36 @@ test.describe("G6 supervision and research OS", () => { await expectNoHorizontalOverflow(page); }); + test("wraps a long learner reference without clipping at 320px", async ({ page }) => { + const longLearnerRef = "learner-bc2e3fe5b35e46a8b1c9d2e4f6a7b8c9d0e1f2a3b4c5d6e7f8"; + const supervision = supervisionFixture(); + supervision.attention_items[0] = { + ...supervision.attention_items[0], + learner_ref: longLearnerRef, + }; + + await routeG6(page, "teacher", supervision); + await page.setViewportSize({ width: 320, height: 900 }); + await page.goto("/teach/supervision"); + + const detailReference = page.locator(".g6-case h3"); + await expect(detailReference).toHaveText(longLearnerRef); + await expect(detailReference).toHaveCSS("overflow-wrap", "anywhere"); + expect( + await detailReference.evaluate((element) => element.scrollWidth <= element.clientWidth + 1), + ).toBeTruthy(); + await expectNoHorizontalOverflow(page); + }); + test("research role traces model, prompt, instrument, subgroup, and four-domain provenance", async ({ page, }) => { await page.emulateMedia({ reducedMotion: "reduce" }); await routeG6(page, "admin"); await page.goto("/teach/supervision"); + await expect(page.locator(".g6-hero p")).toHaveText( + "검토가 필요한 학습 회기와 평가 근거를 확인합니다. 연구 품질 탭에서는 평가 버전과 출처를 확인할 수 있습니다.", + ); await page.getByRole("tab", { name: "연구 품질" }).click(); await expect(page.getByRole("heading", { name: "모델·프롬프트·도구·하위집단 드리프트" })).toBeVisible(); @@ -325,7 +353,7 @@ test.describe("G6 supervision and research OS", () => { await expect(page.getByText("synthetic-low-disclosure", { exact: true })).toBeVisible(); await expect(page.getByText("-25%p", { exact: true })).toBeVisible(); - await expect(page.getByText("4/4 provenance 연결", { exact: true })).toBeVisible(); + await expect(page.getByText("4/4 출처 연결", { exact: true })).toBeVisible(); for (const label of ["동맹", "파열·수선", "전이", "보정"]) { await expect(page.getByText(label, { exact: true }).first()).toBeVisible(); } @@ -373,14 +401,14 @@ test.describe("G6 supervision and research OS", () => { await routeG6(page, "admin", emptySupervision, summaryOnlyResearch); await page.goto("/teach/supervision"); - await expect(page.getByText("현재 우선 검토 항목이 없어")).toBeVisible(); - await expect(page.getByText("현재 교육과정 공백이 없어")).toBeVisible(); - await expect(page.getByText("교수자–AI 불일치 메타데이터가 없어")).toBeVisible(); + await expect(page.getByText("현재 우선 검토 항목이 없습니다")).toBeVisible(); + await expect(page.getByText("현재 교육과정 공백이 없습니다")).toBeVisible(); + await expect(page.getByText("교수자–AI 불일치 메타데이터가 없습니다")).toBeVisible(); await page.getByRole("tab", { name: "연구 품질" }).click(); - await expect(page.getByText("버전 드리프트 비교가 없어")).toBeVisible(); - await expect(page.getByText("provenance 저하", { exact: true })).toBeVisible(); - await expect(page.getByText("매니페스트 요약만 도착했어")).toBeVisible(); - await expect(page.getByText("원본 provenance 미제공").first()).toBeVisible(); + await expect(page.getByText("버전 드리프트 비교가 없습니다")).toBeVisible(); + await expect(page.getByText("출처 연결 저하", { exact: true })).toBeVisible(); + await expect(page.getByText("매니페스트 요약만 있습니다")).toBeVisible(); + await expect(page.getByText("원본 출처 미제공").first()).toBeVisible(); await expectNoHorizontalOverflow(page); }); }); diff --git a/apps/web/e2e/teacher-mobile-targets.spec.ts b/apps/web/e2e/teacher-mobile-targets.spec.ts new file mode 100644 index 0000000..1148e9f --- /dev/null +++ b/apps/web/e2e/teacher-mobile-targets.spec.ts @@ -0,0 +1,229 @@ +import { expect, test, type Page } from "@playwright/test"; +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { expectNoHorizontalOverflow, signInAsTeacher } from "./support"; + +const OUTPUT_DIR = path.resolve( + process.cwd(), + "../../outputs/ux-audit-2026-09-12/teacher-targets", +); + +const VIEWPORTS = [ + { width: 320, height: 844, label: "320" }, + { width: 390, height: 844, label: "390" }, + { width: 768, height: 900, label: "768" }, + { width: 1440, height: 900, label: "1440" }, +] as const; + +type TargetMeasurement = { + label: string; + selector: string; + viewport: number; + x: number; + y: number; + width: number; + height: number; + right: number; + scrollWidth: number; + clientWidth: number; + scrollHeight: number; + clientHeight: number; + clipped: boolean; +}; + +type AnalysisMeasurement = { + selector: string; + x: number; + width: number; + right: number; + clipped: boolean; +}; + +async function measureTargets(page: Page, viewport: number): Promise { + return page.locator(".pf-head__actions .vg-btn, .pf-studio-card__actions .vg-btn, .pf-sort-segment button, .pf-learner-row__action .vg-btn, .pf-session--action, .pf-recent-row--action").evaluateAll( + (elements, currentViewport) => + elements + .map((element) => { + const target = element as HTMLElement; + const rect = target.getBoundingClientRect(); + const style = window.getComputedStyle(target); + const visible = + style.display !== "none" && + style.visibility !== "hidden" && + Number(style.opacity) !== 0 && + rect.width > 0 && + rect.height > 0; + if (!visible) return null; + + return { + label: target.textContent?.replace(/\s+/g, " ").trim() ?? "", + selector: target.className, + viewport: currentViewport, + x: Math.round(rect.x), + y: Math.round(rect.y), + width: Math.round(rect.width), + height: Math.round(rect.height), + right: Math.round(rect.right), + scrollWidth: target.scrollWidth, + clientWidth: target.clientWidth, + scrollHeight: target.scrollHeight, + clientHeight: target.clientHeight, + clipped: + rect.left < -1 || + rect.right > window.innerWidth + 1 || + target.scrollWidth > target.clientWidth + 1 || + target.scrollHeight > target.clientHeight + 1, + }; + }) + .filter((measurement): measurement is NonNullable => measurement !== null), + viewport, + ); +} + +async function captureConsole(page: Page, label: string) { + await page.screenshot({ path: path.join(OUTPUT_DIR, `console-top-${label}.png`) }); + + const pending = page.locator('[data-pending-review-row="true"]').first(); + if (await pending.count()) { + await pending.scrollIntoViewIfNeeded(); + await page.screenshot({ path: path.join(OUTPUT_DIR, `console-pending-${label}.png`) }); + } + + const recent = page.locator(".pf-recent-row--action").first(); + if (await recent.count()) { + await recent.scrollIntoViewIfNeeded(); + await page.screenshot({ path: path.join(OUTPUT_DIR, `console-recent-${label}.png`) }); + } +} + +async function measureAnalysisSurface(page: Page): Promise { + return page.locator( + ".pf-root--analysis, .pf-head h1, .pf-head p, .pf-head__actions, .pf-section--learner-overview, .pf-learner-overview, .pf-learner-toolbar, .pf-search-field, .pf-sort-segment, .pf-learner-table", + ).evaluateAll((elements) => + elements.map((element) => { + const target = element as HTMLElement; + const rect = target.getBoundingClientRect(); + return { + selector: target.className || target.tagName.toLowerCase(), + x: Math.round(rect.x), + width: Math.round(rect.width), + right: Math.round(rect.right), + clipped: rect.left < -1 || rect.right > window.innerWidth + 1, + }; + }), + ); +} + +test("교수자 콘솔의 모바일 터치 대상은 44px이며 잘리지 않는다 @single-run", async ({ page }) => { + test.setTimeout(180_000); + await mkdir(OUTPUT_DIR, { recursive: true }); + await signInAsTeacher(page); + + const evidence: TargetMeasurement[] = []; + const analysisEvidence: Record[] = []; + for (const viewport of VIEWPORTS) { + await page.setViewportSize(viewport); + await page.goto("/teach"); + await expect(page.locator(".pf-root--console")).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole("button", { name: "학생 분석" })).toBeVisible(); + await expect(page.getByRole("button", { name: "새로고침" })).toBeVisible(); + await expect(page.locator(".pf-studio-card__actions .vg-btn")).toBeVisible(); + await expectNoHorizontalOverflow(page); + await captureConsole(page, viewport.label); + + const consoleTargets = await measureTargets(page, viewport.width); + evidence.push(...consoleTargets); + const clipped = consoleTargets.filter((target) => target.clipped); + expect(clipped, `교수 콘솔 ${viewport.width}px 잘린 대상`).toEqual([]); + if (viewport.width <= 720) { + const undersized = consoleTargets.filter( + (target) => target.width < 44 || target.height < 44, + ); + expect(undersized, `교수 콘솔 ${viewport.width}px 44px 미만 대상`).toEqual([]); + } + + await page.goto("/teach/analysis"); + await expect(page.locator(".pf-root--analysis")).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole("heading", { name: "학생별 학습 현황" })).toBeVisible(); + await expect(page.getByRole("button", { name: "새로고침" })).toBeVisible(); + await expectNoHorizontalOverflow(page); + await page.screenshot({ path: path.join(OUTPUT_DIR, `analysis-top-${viewport.label}.png`) }); + + const analysisSurface = await measureAnalysisSurface(page); + const outsideViewport = analysisSurface.filter((measurement) => measurement.clipped); + expect(outsideViewport, `학생 분석 ${viewport.width}px 표면 범위`).toEqual([]); + + const table = page.locator(".pf-learner-table"); + await expect(table).toBeVisible(); + await table.evaluate((element) => { + element.scrollLeft = 0; + }); + await page.screenshot({ path: path.join(OUTPUT_DIR, `analysis-table-left-${viewport.label}.png`) }); + const firstColumn = table.locator(".pf-learner-table__head > span").first(); + const tableBox = await table.boundingBox(); + const firstColumnBox = await firstColumn.boundingBox(); + expect(firstColumnBox, `학생 분석 ${viewport.width}px 표 왼쪽 열`).not.toBeNull(); + expect(tableBox, `학생 분석 ${viewport.width}px 표 컨테이너`).not.toBeNull(); + expect(firstColumnBox!.x).toBeGreaterThanOrEqual(tableBox!.x - 1); + const tableBounds = await table.evaluate((element) => ({ + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + scrollLeft: element.scrollLeft, + })); + await table.evaluate((element) => { + element.scrollLeft = element.scrollWidth - element.clientWidth; + }); + await page.screenshot({ path: path.join(OUTPUT_DIR, `analysis-table-right-${viewport.label}.png`) }); + const tableEnd = await table.evaluate((element) => element.scrollLeft); + const lastColumn = table.locator(".pf-learner-table__head > span").last(); + const lastColumnBox = await lastColumn.boundingBox(); + expect(lastColumnBox, `학생 분석 ${viewport.width}px 표 오른쪽 열`).not.toBeNull(); + expect(lastColumnBox!.x + lastColumnBox!.width).toBeLessThanOrEqual( + tableBox!.x + tableBox!.width + 1, + ); + if (tableBounds.scrollWidth > tableBounds.clientWidth) { + expect(tableEnd, `학생 분석 ${viewport.width}px 표 오른쪽 끝`).toBeGreaterThan(0); + } + analysisEvidence.push({ + viewport: viewport.width, + elements: analysisSurface, + table: { ...tableBounds, endScrollLeft: tableEnd }, + }); + + const analysisTargets = await measureTargets(page, viewport.width); + evidence.push(...analysisTargets); + const analysisClipped = analysisTargets.filter((target) => target.clipped); + expect(analysisClipped, `학생 분석 ${viewport.width}px 잘린 대상`).toEqual([]); + if (viewport.width <= 720) { + const undersized = analysisTargets.filter( + (target) => target.width < 44 || target.height < 44, + ); + expect(undersized, `학생 분석 ${viewport.width}px 44px 미만 대상`).toEqual([]); + + const openDetail = page.locator(".pf-learner-row__action .vg-btn").first(); + await expect(openDetail).toBeVisible(); + await openDetail.click(); + const backToList = page.getByRole("button", { name: "전체 목록" }); + await expect(backToList).toBeVisible({ timeout: 15_000 }); + await expectNoHorizontalOverflow(page); + const backToListBox = await backToList.boundingBox(); + expect(backToListBox, `학생 분석 상세 ${viewport.width}px 전체 목록`).not.toBeNull(); + expect(backToListBox!.width).toBeGreaterThanOrEqual(44); + expect(backToListBox!.height).toBeGreaterThanOrEqual(44); + await page.screenshot({ + path: path.join(OUTPUT_DIR, `analysis-detail-${viewport.label}.png`), + }); + } + } + + await writeFile( + path.join(OUTPUT_DIR, "target-measurements.json"), + `${JSON.stringify(evidence, null, 2)}\n`, + "utf8", + ); + await writeFile( + path.join(OUTPUT_DIR, "analysis-measurements.json"), + `${JSON.stringify(analysisEvidence, null, 2)}\n`, + "utf8", + ); +}); diff --git a/apps/web/e2e/uc-a11y-mobile.spec.ts b/apps/web/e2e/uc-a11y-mobile.spec.ts index 00efbd2..6f3cfe6 100644 --- a/apps/web/e2e/uc-a11y-mobile.spec.ts +++ b/apps/web/e2e/uc-a11y-mobile.spec.ts @@ -913,7 +913,7 @@ async function openLearnerHome(page: Page) { await page.goto("/learn"); await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 }); await expect( - page.getByRole("heading", { name: "오늘 이어갈 회기를 먼저 봅니다." }), + page.getByRole("heading", { name: "오늘의 상담 연습" }), ).toBeVisible({ timeout: 15_000 }); } diff --git a/apps/web/e2e/uc-admin-console.spec.ts b/apps/web/e2e/uc-admin-console.spec.ts index 98d56b8..0d1a0dc 100644 --- a/apps/web/e2e/uc-admin-console.spec.ts +++ b/apps/web/e2e/uc-admin-console.spec.ts @@ -18,6 +18,8 @@ * 다룬 시나리오(역할별 라우트 가드, 검색 by 이메일·역할·코호트, 티켓 필터 쿼리, * usage 윈도 전환, 콘텐츠·모델 게이트 승인 플로우)는 반복하지 않는다. */ +import { mkdir } from "node:fs/promises"; +import path from "node:path"; import { expect, test, type Page, type Route } from "@playwright/test"; const NOW = 1_755_400_000; // 2026-08-17 근처 고정 기준 @@ -1442,4 +1444,78 @@ test.describe("관리자 콘솔 유스케이스 여정", () => { } expect((await protocolTab.boundingBox())?.height ?? 0).toBeGreaterThanOrEqual(44); }); + + test("모바일 티켓 새로고침과 해결 처리가 동작한다 @single-run", async ({ + page, + }) => { + const outputDir = path.resolve( + process.cwd(), + "../../outputs/ux-audit-2026-09-12/admin-ticket-targets", + ); + await mkdir(outputDir, { recursive: true }); + await page.setViewportSize({ width: 320, height: 844 }); + await installAdminMock(page, { + tickets: [ + makeTicket({ + ticket_id: "ticket-mobile-active", + subject: "모바일 티켓 처리", + status: "open", + }), + makeTicket({ + ticket_id: "ticket-mobile-history", + subject: "해결된 모바일 티켓", + status: "resolved", + resolved_at: NOW - 600, + }), + ], + }); + + await page.goto("/admin/tickets"); + const refresh = page + .locator('[data-admin-section="tickets"] .vgops-head') + .getByRole("button", { name: "새로고침" }); + const activeCard = page.locator(".vgops-ticket").filter({ + hasText: "모바일 티켓 처리", + }); + await expect(refresh).toBeVisible(); + await expect(activeCard).toBeVisible(); + await page.screenshot({ path: path.join(outputDir, "tickets-top-320.png") }); + + const refreshBox = await refresh.boundingBox(); + expect(refreshBox?.width ?? 0).toBeGreaterThanOrEqual(44); + expect(refreshBox?.height ?? 0).toBeGreaterThanOrEqual(44); + await page.setViewportSize({ width: 710, height: 844 }); + const refreshAt720 = await refresh.boundingBox(); + expect(refreshAt720?.width ?? 0).toBeGreaterThanOrEqual(44); + expect(refreshAt720?.height ?? 0).toBeGreaterThanOrEqual(44); + await page.setViewportSize({ width: 320, height: 844 }); + const reload = page.waitForResponse( + (response) => + response.request().method() === "GET" && + new URL(response.url()).pathname.endsWith("/admin/tickets"), + ); + await refresh.click(); + await reload; + await expect(activeCard).toBeVisible(); + + const patch = page.waitForRequest( + (request) => + request.method() === "PATCH" && + request.url().includes("/admin/tickets/ticket-mobile-active"), + ); + await activeCard.getByRole("button", { name: "해결" }).click(); + await patch; + + const history = page.locator(".vgops-ticket--history").filter({ + hasText: "모바일 티켓 처리", + }); + await expect(history).toBeVisible(); + await history.scrollIntoViewIfNeeded(); + await page.screenshot({ path: path.join(outputDir, "tickets-history-320.png") }); + await expect + .poll(() => + page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), + ) + .toBe(true); + }); }); diff --git a/apps/web/e2e/uc-auth-onboarding.spec.ts b/apps/web/e2e/uc-auth-onboarding.spec.ts index 203872f..c1bcc89 100644 --- a/apps/web/e2e/uc-auth-onboarding.spec.ts +++ b/apps/web/e2e/uc-auth-onboarding.spec.ts @@ -126,13 +126,13 @@ const LEGAL_DOCS = { source_note: "운영 전 초안 문서입니다.", }; -async function routeOnboardingData(page: Page) { +async function routeOnboardingData(page: Page, legalDocs = LEGAL_DOCS) { await page.route("**/api/users/me", (route) => { if (route.request().method() !== "GET") return route.fallback(); return route.fulfill(jsonRoute(FRESH_PROFILE)); }); await page.route("**/api/users/legal-docs", (route) => - route.fulfill(jsonRoute(LEGAL_DOCS)), + route.fulfill(jsonRoute(legalDocs)), ); } @@ -143,6 +143,20 @@ async function setupFreshLearnerOnOnboarding(page: Page) { await routeOnboardingData(page); } +const LONG_LEGAL_DOCS = { + ...LEGAL_DOCS, + terms: { + ...LEGAL_DOCS.terms, + title: "Vignette 서비스 이용약관 초안", + version: "terms-draft-2026-06-27", + }, + privacy: { + ...LEGAL_DOCS.privacy, + title: "Vignette 개인정보 처리방침 초안", + version: "privacy-draft-2026-06-27", + }, +}; + /** 온보딩 폼의 텍스트 필수 입력을 전부 채운다(동의 체크는 호출부 책임). */ async function fillOnboardingTextFields(page: Page, phone = "010-1234-5678") { await page.getByLabel("닉네임").fill("비넷 유스케이스"); @@ -289,6 +303,54 @@ test.describe("uc auth-onboarding", () => { await expect(page.locator(".ob-legal .ob-note")).toHaveText("운영 전 초안 문서입니다."); }); + test("좁은 화면에서도 약관 제목·버전을 모두 읽고 문서를 펼친다", async ({ page }) => { + for (const viewport of [ + { width: 320, height: 844 }, + { width: 390, height: 844 }, + { width: 1440, height: 900 }, + ]) { + await page.setViewportSize(viewport); + await routeUnmockedApi(page); + await routeMe(page, () => makeMe({ onboarding_completed_at: null, nickname: "" })); + await routeOnboardingData(page, LONG_LEGAL_DOCS); + + await page.goto("/onboarding"); + const details = page.locator(".ob-legal details"); + const summaries = details.locator("summary"); + await expect(summaries).toHaveCount(2); + + for (let index = 0; index < 2; index += 1) { + const summary = summaries.nth(index); + const title = summary.locator(".ob-legal__title"); + const version = summary.locator(".ob-legal__version"); + await expect(title).toHaveText(index === 0 ? LONG_LEGAL_DOCS.terms.title : LONG_LEGAL_DOCS.privacy.title); + await expect(version).toHaveText(index === 0 ? LONG_LEGAL_DOCS.terms.version : LONG_LEGAL_DOCS.privacy.version); + + const geometry = await summary.evaluate((element) => { + const title = element.querySelector(".ob-legal__title"); + const version = element.querySelector(".ob-legal__version"); + if (!title || !version) throw new Error("약관 제목 또는 버전 요소가 없습니다."); + const summaryRect = element.getBoundingClientRect(); + const titleRect = title.getBoundingClientRect(); + const versionRect = version.getBoundingClientRect(); + return { + summaryHeight: summaryRect.height, + titleScrollWidth: title.scrollWidth, + titleWidth: title.clientWidth, + versionTop: versionRect.top, + titleBottom: titleRect.bottom, + }; + }); + expect(geometry.summaryHeight).toBeGreaterThanOrEqual(44); + expect(geometry.titleScrollWidth).toBeLessThanOrEqual(geometry.titleWidth); + expect(geometry.versionTop).toBeGreaterThanOrEqual(geometry.titleBottom); + + await summary.click(); + await expect(details.nth(index)).toHaveAttribute("open", ""); + } + } + }); + // usecase: 신규 사용자가 연락처를 전화번호가 아닌 값으로 넣고 저장하려 한다 test("온보딩 연락처 형식이 틀리면 저장하지 않고 형식 오류를 안내한다", async ({ page }) => { await setupFreshLearnerOnOnboarding(page); diff --git a/apps/web/e2e/uc-learner-home-dashboard.spec.ts b/apps/web/e2e/uc-learner-home-dashboard.spec.ts index 4776599..e5a228b 100644 --- a/apps/web/e2e/uc-learner-home-dashboard.spec.ts +++ b/apps/web/e2e/uc-learner-home-dashboard.spec.ts @@ -400,18 +400,19 @@ async function installFixtures(page: Page, options: FixtureOptions = {}) { } test.describe("uc learner home dashboard", () => { - // usecase: 평가가 아직 없는 학습자가 메트릭 카드에서 현황 수치와 빈-평가 문구를 읽는다 - test("평가 전 학습자가 메트릭 카드에서 진행·리뷰·평가·라포 수치를 확인한다", async ({ + // usecase: 평가가 아직 없는 학습자가 성장 지표에서 현황 수치와 빈-평가 문구를 읽는다 + test("평가 전 학습자가 성장 지표에서 전체 회기·리뷰·평가·라포 수치를 확인한다", async ({ page, }) => { await installFixtures(page); await page.goto("/learn"); + await page.getByRole("tab", { name: "성장 지표" }).click(); - const cards = page.locator(".lh-dashboard-status"); + const cards = page.locator("#lh-panel-growth .lh-dashboard-status"); await expect(cards.getByRole("button")).toHaveCount(4); - const progressCard = cards.getByRole("button", { name: /진행 회기/ }); + const progressCard = cards.getByRole("button", { name: /전체 회기/ }); await expect(progressCard).toContainText("2회"); - await expect(progressCard).toContainText("1회 종료 · 1회 진행"); + await expect(progressCard).toContainText("상담 완료 1회 · 진행 중 1회"); const reviewCard = cards.getByRole("button", { name: /리뷰 대기/ }); await expect(reviewCard).toContainText("0건"); await expect(reviewCard).toContainText("대기 중인 리뷰 없음"); @@ -490,9 +491,10 @@ test.describe("uc learner home dashboard", () => { test("리뷰 대기 메트릭 카드를 누르면 기록 화면으로 이동한다", async ({ page }) => { await installFixtures(page); await page.goto("/learn"); + await page.getByRole("tab", { name: "성장 지표" }).click(); await page - .locator(".lh-dashboard-status") + .locator("#lh-panel-growth .lh-dashboard-status") .getByRole("button", { name: /리뷰 대기/ }) .click(); await expect(page).toHaveURL(/\/learn\/history$/); @@ -584,8 +586,8 @@ test.describe("uc learner home dashboard", () => { await expect(page).toHaveURL(new RegExp(`/learn/session/${ACT_ID}$`)); }); - // usecase: 학습자가 내담자를 바꿔 선택한 뒤 '새 회기 시작'으로 새 세션 경로에 진입한다 - test("다른 내담자를 선택하고 '새 회기 시작'을 누르면 해당 페르소나 세션 경로로 이동한다", async ({ + // usecase: 학습자가 내담자를 바꿔 선택한 뒤 새 사례로 세션 시작 화면에 진입한다 + test("다른 내담자를 선택하고 '완전히 새로 시작'을 누르면 새 사례 세션 경로로 이동한다", async ({ page, }) => { await installFixtures(page); @@ -597,8 +599,8 @@ test.describe("uc learner home dashboard", () => { "aria-selected", "true", ); - await page.getByRole("button", { name: "새 회기 시작" }).click(); - await expect(page).toHaveURL(/\/learn\/session\/P2$/); + await page.getByRole("button", { name: "완전히 새로 시작" }).click(); + await expect(page).toHaveURL(/\/learn\/session\/P2\?continuity=fresh$/); }); // usecase: 학습자가 페르소나 카드에서 난이도 라벨과 주호소 설명을 읽고 비교한다 @@ -689,9 +691,10 @@ test.describe("uc learner home dashboard", () => { await expect(focus).toContainText("첫 회기 준비"); await expect(focus.getByRole("heading", { name: "성하늘" })).toBeVisible(); await expect(focus.locator(".lh-session-focus__status")).toHaveText("대기"); - // 진행 회기 메트릭도 0회. + // 전체 회기 메트릭도 0회. + await page.getByRole("tab", { name: "성장 지표" }).click(); await expect( - page.locator(".lh-dashboard-status").getByRole("button", { name: /진행 회기/ }), + page.locator("#lh-panel-growth .lh-dashboard-status").getByRole("button", { name: /전체 회기/ }), ).toContainText("0회"); // 주 CTA가 '학습 대상 선택'으로 연습 화면에 진입한다. const primaryAction = page.locator("[data-learner-primary-action]"); @@ -750,11 +753,11 @@ test.describe("uc learner home dashboard", () => { await expect(growthPanel).toContainText( "첫 회기를 끝내면 라포 누적이 표시됩니다.", ); - await expect(growthPanel).toContainText("첫 회기 이후 표시"); + await expect(growthPanel).toContainText("첫 회기 후 표시"); }); - // usecase: 학습자가 키보드만으로(Tab 이동 → Enter) 새 회기를 시작한다 - test("키보드 Tab 이동과 Enter만으로 새 회기를 시작할 수 있다", async ({ + // usecase: 학습자가 키보드만으로(Tab 이동 → Enter) 새 사례 회기 시작 화면으로 이동한다 + test("키보드 Tab 이동과 Enter만으로 새 사례 회기를 시작할 수 있다", async ({ page, }) => { await installFixtures(page); @@ -763,9 +766,9 @@ test.describe("uc learner home dashboard", () => { const listbox = page.getByRole("listbox", { name: "연습 페르소나" }); const p1 = listbox.getByRole("option", { name: /P1/ }); await expect(p1).toHaveAttribute("aria-selected", "true"); - // 선택된 페르소나 카드에서 시작해 Tab으로 '새 회기 시작' 버튼까지 이동한다. + // 선택된 페르소나 카드에서 시작해 Tab으로 '완전히 새로 시작' 버튼까지 이동한다. await p1.focus(); - const startButton = page.getByRole("button", { name: "새 회기 시작" }); + const startButton = page.getByRole("button", { name: "완전히 새로 시작" }); let reached = false; for (let i = 0; i < 25; i += 1) { await page.keyboard.press("Tab"); @@ -774,9 +777,9 @@ test.describe("uc learner home dashboard", () => { break; } } - expect(reached, "Tab 이동으로 '새 회기 시작' 버튼에 도달해야 한다").toBe(true); + expect(reached, "Tab 이동으로 '완전히 새로 시작' 버튼에 도달해야 한다").toBe(true); await page.keyboard.press("Enter"); - await expect(page).toHaveURL(/\/learn\/session\/P1$/); + await expect(page).toHaveURL(/\/learn\/session\/P1\?continuity=fresh$/); }); // usecase: 학습자가 대시보드 영역 탭을 화살표 키로 오간다 diff --git a/apps/web/e2e/ux-audit-surfaces.spec.ts b/apps/web/e2e/ux-audit-surfaces.spec.ts new file mode 100644 index 0000000..9872977 --- /dev/null +++ b/apps/web/e2e/ux-audit-surfaces.spec.ts @@ -0,0 +1,312 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { expect, test, type Page } from "@playwright/test"; +import { completeOnboarding, signInAsAdmin, signInAsLearner, signInAsTeacher, useRealApi } from "./support"; + +const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:5173"; +const VIEWPORT_HEIGHT = 900; +const VIEWPORT_WIDTHS = [320, 390, 768, 1024, 1440] as const; +const SEGMENT_STEP_RATIO = 0.8; +const MAX_SEGMENTS = 20; +const REPO_ROOT = path.resolve(process.cwd(), "..", ".."); +const OUTPUT_DIR = process.env.UX_AUDIT_OUTPUT_DIR ?? path.join(REPO_ROOT, "outputs", "ux-audit-2026-09-12", "surfaces"); +const MANIFEST_PATH = path.join(OUTPUT_DIR, "manifest.json"); + +type Role = "learner" | "teacher" | "admin" | "fixture"; +type ScrollOwnerKind = "main" | "root" | "document"; + +interface Surface { + route: string; + role: Role; + source: "real-api" | "fixture"; +} + +interface ScrollPlan { + owner: ScrollOwnerKind; + selector: string | null; + offsets: number[]; + incomplete: boolean; + scrollHeight: number; + clientHeight: number; +} + +interface ManifestEntry { + route: string; + role: Role; + source: Surface["source"]; + width: number; + height: number; + scrollowner: ScrollOwnerKind; + offset: number; + file: string; + timestamp: string; + screenheading: string; + consoleerror: string[]; + httperror: string[]; + status: "captured" | "http-error"; + incomplete: boolean; + scrollHeight: number; + clientHeight: number; +} + +const manifest: ManifestEntry[] = []; + +function fileStem(route: string) { + return route.replace(/^\//, "").replaceAll("/", "-") || "home"; +} + +function routePattern(route: string) { + return new RegExp(`${route.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:[?#]|$)`); +} + +function assertLocalAuditBaseUrl() { + const parsed = new URL(BASE_URL); + expect(parsed.protocol, `UI 감사는 HTTPS 운영 서버를 대상으로 실행할 수 없다: ${BASE_URL}`).toBe("http:"); + expect( + ["localhost", "127.0.0.1", "::1"], + `UI 감사는 로컬 호스트에서만 dev 계정을 생성할 수 있다: ${BASE_URL}`, + ).toContain(parsed.hostname); + return parsed.origin; +} + +async function writeManifest(baseURL: string) { + await fs.mkdir(OUTPUT_DIR, { recursive: true }); + await fs.writeFile( + MANIFEST_PATH, + `${JSON.stringify( + { + generatedAt: new Date().toISOString(), + baseURL, + viewportWidths: VIEWPORT_WIDTHS, + viewportHeight: VIEWPORT_HEIGHT, + entries: manifest, + }, + null, + 2, + )}\n`, + "utf8", + ); +} + +async function loginFreshLearner(page: Page, prefix: string) { + const response = await page.request.post("/api/auth/dev-login", { + data: { + email: `${prefix}.${Date.now()}@hs.ac.kr`, + role: "learner", + display_name: "UX Audit Fixture Learner", + }, + }); + expect(response.ok(), await response.text()).toBeTruthy(); +} + +async function loginOnboardedLearner(page: Page, prefix: string) { + await loginFreshLearner(page, prefix); + await completeOnboarding(page, { + legal_name: "UX 감사 학습자", + affiliation: "한신대학교", + department: "상담심리학과", + grade_level: "3학년", + phone: "010-0000-0000", + contact_address: "경기도 오산시 한신대학교", + }); +} + +async function installPendingFixture(page: Page) { + await page.route("**/api/auth/me", async (route) => { + if (route.request().method() !== "GET") { + await route.fallback(); + return; + } + const response = await route.fetch(); + const body = (await response.json()) as Record; + await route.fulfill({ + response, + json: { + ...body, + email: "pending@hs.ac.kr", + account_status: "pending", + approval_required: true, + }, + }); + }); +} + +async function waitForSurfaceReady(page: Page, route: string) { + await expect(page).toHaveURL(routePattern(route), { timeout: 20_000 }); + await expect(page.getByRole("heading").first()).toBeVisible({ timeout: 20_000 }); + await page.waitForLoadState("networkidle", { timeout: 20_000 }); + await expect + .poll( + () => + page.locator( + ".g6-loading, .cic-loading, .lh-loading, .vg-loading, [data-loading='true']", + ).count(), + { timeout: 10_000 }, + ) + .toBe(0); + await page.waitForTimeout(250); +} + +async function readScrollPlan(page: Page): Promise { + return page.evaluate(({ maxSegments, stepRatio }) => { + const candidates: Array<{ owner: ScrollOwnerKind; selector: string | null; element: HTMLElement }> = []; + const main = document.querySelector("#vg-main-content"); + const root = document.querySelector("#root"); + if (main) candidates.push({ owner: "main", selector: "#vg-main-content", element: main }); + if (root) candidates.push({ owner: "root", selector: "#root", element: root }); + const documentScroller = document.scrollingElement as HTMLElement | null; + if (documentScroller) candidates.push({ owner: "document", selector: null, element: documentScroller }); + + const selected = + candidates.find((candidate) => candidate.element.scrollHeight > candidate.element.clientHeight + 1) ?? + candidates.at(-1); + if (!selected) throw new Error("No scroll owner found for UX audit capture"); + + const maxOffset = Math.max(0, selected.element.scrollHeight - selected.element.clientHeight); + const step = Math.max(1, Math.floor(selected.element.clientHeight * stepRatio)); + const allOffsets = [0]; + for (let offset = step; offset < maxOffset; offset += step) allOffsets.push(offset); + if (allOffsets.at(-1) !== maxOffset) allOffsets.push(maxOffset); + + return { + owner: selected.owner, + selector: selected.selector, + offsets: allOffsets.slice(0, maxSegments), + incomplete: allOffsets.length > maxSegments, + scrollHeight: selected.element.scrollHeight, + clientHeight: selected.element.clientHeight, + }; + }, { maxSegments: MAX_SEGMENTS, stepRatio: SEGMENT_STEP_RATIO }); +} + +async function scrollToOffset(page: Page, plan: ScrollPlan, offset: number) { + await page.evaluate( + ({ selector, offset: nextOffset }) => { + const target = selector + ? document.querySelector(selector) + : (document.scrollingElement as HTMLElement | null); + if (!target) throw new Error(`Scroll owner missing: ${selector ?? "document"}`); + target.scrollTop = nextOffset; + target.dispatchEvent(new Event("scroll")); + }, + { selector: plan.selector, offset }, + ); + await page.waitForTimeout(100); +} + +async function captureSurface( + page: Page, + surface: Surface, + baseURL: string, + consoleErrors: string[], + httpErrors: string[], +) { + for (const width of VIEWPORT_WIDTHS) { + const consoleStart = consoleErrors.length; + const httpStart = httpErrors.length; + await page.setViewportSize({ width, height: VIEWPORT_HEIGHT }); + await page.goto(`${baseURL}${surface.route}`, { waitUntil: "domcontentloaded" }); + await waitForSurfaceReady(page, surface.route); + + const horizontalOverflow = await page.evaluate( + () => document.documentElement.scrollWidth - document.documentElement.clientWidth, + ); + expect(horizontalOverflow, `${surface.route} ${width}px 문서 가로 오버플로`).toBeLessThanOrEqual(1); + + const plan = await readScrollPlan(page); + const heading = (await page.getByRole("heading").first().innerText()).replace(/\s+/g, " ").trim(); + for (const [segment, offset] of plan.offsets.entries()) { + await scrollToOffset(page, plan, offset); + const filename = `${fileStem(surface.route)}-${surface.role}-w${width}-s${segment}-y${offset}.png`; + const outputPath = path.join(OUTPUT_DIR, filename); + await page.screenshot({ path: outputPath, fullPage: false, animations: "disabled" }); + manifest.push({ + route: surface.route, + role: surface.role, + source: surface.source, + width, + height: VIEWPORT_HEIGHT, + scrollowner: plan.owner, + offset, + file: path.relative(REPO_ROOT, outputPath).split(path.sep).join("/"), + timestamp: new Date().toISOString(), + screenheading: heading, + consoleerror: consoleErrors.slice(consoleStart), + httperror: httpErrors.slice(httpStart), + status: httpErrors.length > httpStart ? "http-error" : "captured", + incomplete: plan.incomplete, + scrollHeight: plan.scrollHeight, + clientHeight: plan.clientHeight, + }); + await writeManifest(baseURL); + } + } +} + +test.describe.configure({ mode: "serial" }); +test.use({ baseURL: BASE_URL }); + +test("@single-run UI 감사 누락 표면을 실제 상태와 전체 세로 구간으로 보존한다", async ({ page }) => { + test.setTimeout(1_800_000); + await useRealApi(page); + const baseURL = assertLocalAuditBaseUrl(); + await writeManifest(baseURL); + + const consoleErrors: string[] = []; + const httpErrors: string[] = []; + page.on("console", (message) => { + if (message.type() === "error") consoleErrors.push(message.text()); + }); + page.on("response", (response) => { + if (response.status() >= 400) httpErrors.push(`${response.status()} ${response.url()}`); + }); + + await signInAsLearner(page); + for (const surface of [ + { route: "/learn/practice", role: "learner", source: "real-api" }, + { route: "/learn/history", role: "learner", source: "real-api" }, + ] satisfies Surface[]) { + await captureSurface(page, surface, baseURL, consoleErrors, httpErrors); + } + + await signInAsTeacher(page); + await captureSurface( + page, + { route: "/teach/supervision", role: "teacher", source: "real-api" }, + baseURL, + consoleErrors, + httpErrors, + ); + + await signInAsAdmin(page); + for (const surface of [ + { route: "/admin/continuous-improvement", role: "admin", source: "real-api" }, + { route: "/admin/access", role: "admin", source: "real-api" }, + { route: "/admin/tickets", role: "admin", source: "real-api" }, + ] satisfies Surface[]) { + await captureSurface(page, surface, baseURL, consoleErrors, httpErrors); + } + + await loginFreshLearner(page, "ux-audit-onboarding"); + await captureSurface( + page, + { route: "/onboarding", role: "fixture", source: "fixture" }, + baseURL, + consoleErrors, + httpErrors, + ); + + await loginOnboardedLearner(page, "ux-audit-pending"); + await installPendingFixture(page); + await captureSurface( + page, + { route: "/pending", role: "fixture", source: "fixture" }, + baseURL, + consoleErrors, + httpErrors, + ); + await page.unroute("**/api/auth/me"); + + const incomplete = manifest.filter((entry) => entry.incomplete); + expect(incomplete, `20개를 넘는 스크롤 구간은 미완료로 기록한다: ${JSON.stringify(incomplete)}`).toEqual([]); +}); diff --git a/apps/web/src/components/shell/AppShell.tsx b/apps/web/src/components/shell/AppShell.tsx index 82a53be..2f9863a 100644 --- a/apps/web/src/components/shell/AppShell.tsx +++ b/apps/web/src/components/shell/AppShell.tsx @@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect, useRef, type ReactNode } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { Topbar } from "./Topbar"; import { Sidebar } from "./Sidebar"; -import { canAccessRole, designRoleOf, useAuth, type Role } from "../../lib/auth"; +import { canAccessRole, designRoleOf, roleHomePath, useAuth, type Role } from "../../lib/auth"; export interface AppShellProps { children: ReactNode; @@ -84,14 +84,9 @@ export function AppShell({ children, className, contextLabel, hideNav, bleed, wi useEffect(() => { const handleGlobalKeyDown = (e: KeyboardEvent) => { if (e.repeat) return; - const target = e.target instanceof HTMLElement ? e.target : null; - const isInput = target?.closest("input, textarea, select, [contenteditable='true']"); - const isAlt = e.altKey && !e.ctrlKey && !e.metaKey; - const isCtrl = (e.ctrlKey || e.metaKey) && !e.altKey; - - // Ctrl+W 또는 Escape: 활성 모달 닫기 - if ((isCtrl && e.key.toLowerCase() === "w") || e.key === "Escape") { + // native dialog는 Escape와 포커스 복귀를 스스로 처리한다. + if (e.key === "Escape" && !document.querySelector("dialog[open]")) { const openModalCloseBtn = document.querySelector( "[role='dialog'] button:has(svg), [role='dialog'] .sx-panel-close-btn, .vg-dialog__close, [role='dialog'] button[aria-label*='닫기']" ); @@ -104,14 +99,14 @@ export function AppShell({ children, className, contextLabel, hideNav, bleed, wi // Alt+H: 홈으로 이동 if (isAlt && e.key.toLowerCase() === "h") { + if (!shellRole) return; e.preventDefault(); - const homePath = user?.role === "admin" ? "/admin" : user?.role === "teacher" ? "/professor" : "/learn"; - navigate(homePath); + navigate(roleHomePath(shellRole)); return; } - // Alt+N 또는 Ctrl+N 또는 Ctrl+T (입력창 밖): 새 회기 시작 / 페르소나 선택 - if ((isAlt && e.key.toLowerCase() === "n") || (!isInput && isCtrl && (e.key.toLowerCase() === "n" || e.key.toLowerCase() === "t"))) { + // Alt+N: 새 회기 시작 / 페르소나 선택 + if (isAlt && e.key.toLowerCase() === "n") { e.preventDefault(); navigate("/learn"); return; @@ -149,7 +144,7 @@ export function AppShell({ children, className, contextLabel, hideNav, bleed, wi window.addEventListener("keydown", handleGlobalKeyDown); return () => window.removeEventListener("keydown", handleGlobalKeyDown); - }, [navigate, user]); + }, [navigate, shellRole]); return (
= { learner: [ - { to: "/learn", label: "대시보드", icon: "home", end: true }, - { to: "/learn/practice", label: "학습", icon: "session" }, - { to: "/learn/history", label: "기록", icon: "review" }, - { to: "/settings", label: "설정", icon: "settings" }, + { to: "/learn", label: "학습자 홈", icon: "home", end: true, group: "학습" }, + { to: "/learn/practice", label: "상담 연습", icon: "session", group: "학습" }, + { to: "/learn/history", label: "학습 기록", icon: "review", group: "학습" }, + { to: "/settings", label: "설정", icon: "settings", group: "설정" }, ], teacher: [ - { to: "/teach", label: "콘솔", icon: "users", end: true }, - { to: "/teach/analysis", label: "학생 분석", icon: "review" }, - { to: "/teach/supervision", label: "감독·연구", icon: "shield" }, - { to: "/teach/personas", label: "페르소나", icon: "review" }, - { to: "/settings", label: "설정", icon: "settings" }, + { to: "/teach", label: "교수자 홈", icon: "users", end: true, group: "교수" }, + { to: "/teach/analysis", label: "학생 분석", icon: "review", group: "교수" }, + { to: "/teach/supervision", label: "감독·연구", icon: "shield", group: "교수" }, + { to: "/teach/personas", label: "내담자 설정", icon: "review", group: "교수" }, + { to: "/settings", label: "설정", icon: "settings", group: "설정" }, ], admin: [ - { to: "/admin", label: "운영 홈", icon: "shield", end: true }, - { to: "/admin/ai", label: "AI 운영", icon: "session" }, - { to: "/admin/continuous-improvement", label: "지속 개선", icon: "refresh" }, - { to: "/admin/users", label: "사용자", icon: "users" }, - { to: "/admin/access", label: "권한", icon: "settings" }, - { to: "/admin/tickets", label: "티켓", icon: "review" }, - { to: "/teach", label: "교수 콘솔", icon: "users", end: true }, - { to: "/teach/analysis", label: "학생 분석", icon: "review" }, - { to: "/teach/supervision", label: "감독·연구", icon: "shield" }, - { to: "/teach/personas", label: "페르소나", icon: "review" }, - { to: "/learn", label: "학습자 홈", icon: "home", end: true }, - { to: "/learn/practice", label: "학습", icon: "session" }, - { to: "/learn/history", label: "기록", icon: "review" }, - { to: "/settings", label: "설정", icon: "settings" }, + { to: "/admin", label: "운영 홈", icon: "shield", end: true, group: "운영" }, + { to: "/admin/ai", label: "AI 운영", icon: "session", group: "운영" }, + { to: "/admin/continuous-improvement", label: "지속 개선", icon: "refresh", group: "운영" }, + { to: "/admin/users", label: "사용자 관리", icon: "users", group: "운영" }, + { to: "/admin/access", label: "접근 관리", icon: "settings", group: "운영" }, + { to: "/admin/tickets", label: "지원 요청", icon: "review", group: "운영" }, + { to: "/teach", label: "교수자 홈", icon: "users", end: true, group: "교수" }, + { to: "/teach/analysis", label: "학생 분석", icon: "review", group: "교수" }, + { to: "/teach/supervision", label: "감독·연구", icon: "shield", group: "교수" }, + { to: "/teach/personas", label: "내담자 설정", icon: "review", group: "교수" }, + { to: "/learn", label: "학습자 홈", icon: "home", end: true, group: "학습" }, + { to: "/learn/practice", label: "상담 연습", icon: "session", group: "학습" }, + { to: "/learn/history", label: "학습 기록", icon: "review", group: "학습" }, + { to: "/settings", label: "설정", icon: "settings", group: "설정" }, ], }; +const MOBILE_PRIMARY_PATHS: Record = { + learner: ["/learn", "/learn/practice", "/learn/history"], + teacher: ["/teach", "/teach/analysis", "/teach/supervision"], + admin: ["/admin", "/admin/ai", "/admin/users"], +}; + const ADMIN_ENTRY: NavItem = { to: "/admin", - label: "운영 콘솔", + label: "운영 홈", icon: "shield", end: true, + group: "운영", }; export function navItemsFor(role: Role, showAdminEntry = false): NavItem[] { @@ -63,32 +73,161 @@ export interface SidebarProps { role: Role; /** 기본 역할 화면에서도 관리자 권한 사용자가 운영 콘솔로 돌아갈 수 있게 한다. */ showAdminEntry?: boolean; - /** 상단 그룹 라벨 (기본 "메뉴") */ + /** 이전 호출부 호환용 기본 그룹 라벨 */ groupLabel?: string; } -/** 좌측 네비 (240px). 활성 = accent-tint 알약. 강조바 금지. §6.3 */ +function isPathActive(pathname: string, item: NavItem): boolean { + if (item.end) return pathname === item.to; + return pathname === item.to || pathname.startsWith(`${item.to}/`); +} + +function groupsFor(items: NavItem[], fallbackLabel: string): Array<[string, NavItem[]]> { + const groups = new Map(); + for (const item of items) { + const group = item.group ?? fallbackLabel; + groups.set(group, [...(groups.get(group) ?? []), item]); + } + return [...groups.entries()]; +} + +/** 좌측 네비와 좁은 화면의 하단 내비. */ export function Sidebar({ role, showAdminEntry = false, groupLabel = "메뉴" }: SidebarProps) { const items = navItemsFor(role, showAdminEntry); + const groups = groupsFor(items, groupLabel); + const location = useLocation(); + const dialogRef = useRef(null); + const menuButtonRef = useRef(null); + const closeButtonRef = useRef(null); + const [menuOpen, setMenuOpen] = useState(false); + const primaryPaths = MOBILE_PRIMARY_PATHS[role]; + const isMoreActive = !items.some( + (item) => primaryPaths.includes(item.to) && isPathActive(location.pathname, item), + ); + + const closeMenu = () => { + const dialog = dialogRef.current; + if (dialog?.open) dialog.close(); + }; + + const openMenu = () => { + const dialog = dialogRef.current; + if (!dialog || dialog.open) return; + dialog.showModal(); + setMenuOpen(true); + window.requestAnimationFrame(() => closeButtonRef.current?.focus()); + }; + + useEffect(() => { + closeMenu(); + }, [location.pathname]); + + useEffect(() => { + const mobileViewport = window.matchMedia("(max-width: 720px)"); + const closeOutsideMobile = () => { + if (!mobileViewport.matches && dialogRef.current?.open) dialogRef.current.close(); + }; + mobileViewport.addEventListener("change", closeOutsideMobile); + return () => mobileViewport.removeEventListener("change", closeOutsideMobile); + }, []); + return (