/** * uc-review-alliance-pulse.spec.ts — 치료 동맹 펄스(pre/mid/post · 3축 · 관점차) 유스케이스 여정 * * 목적: 학습자가 회기 전(pre)·중(mid)·후(post) 시점에 goal/task/bond 세 축을 스스로 * 잠그고, AI 관점(내담자/관찰자)과의 차이를 근거 발화와 함께 확인하는 실제 사용 여정을 * route fixture만으로 검증한다. * * 근거: * - apps/web/src/pages/session/AllianceCheckpointPrompt.tsx (pre fail-closed 게이트, mid 비차단) * - apps/web/src/pages/session-review/AlliancePulseCard.tsx (post 잠금·관점 비교·근거 점프) * - apps/web/src/pages/session-review/alliancePulseApi.ts (엔드포인트·응답 타입) * - apps/web/src/pages/session-review/alliance-pulse.css (≤360px에서 1~5 척도 3+2 배열) * - e2e/session-review-fixture.ts (리뷰 응답·prepost 빌더) * * 중복 회피: e2e/alliance-pulse.spec.ts 는 post 행복 경로 잠금→공개, 320px 무스크롤·44px * 타깃, 관찰자 근거 점프, 교수자 판정을 다룬다. 이 파일은 실패·경합(fail-closed 500, * 409 동기화, 422 거절), 미완성 초안의 제출 차단, mid 비차단 접기, 3+2 줄 배열 자체, * 공개 후 수정 불가를 다른 각도에서 다룬다. * * 실제 AI 엔진 턴 생성 없음 — 모든 API·SSE 는 route fixture 로 응답한다. */ import { expect, test, type Page, type Route } from "@playwright/test"; import { FILLED_REVIEW_SESSION_ID, FILLED_REVIEW_TURN_IDS, routeFilledSessionReview, routePrepostMeasures, } from "./session-review-fixture"; const SESSION_ID = "44444444-4444-4444-8444-444444444444"; const LEARNER_TEXT = "요즘 많이 힘들었겠어요. 어떤 마음이 가장 크게 남아 있나요?"; const CLIENT_REPLY = "괜찮아요. 천천히 말해볼게요."; function jsonRoute(body: unknown, status = 200) { return { status, contentType: "application/json", body: JSON.stringify(body) }; } async function fulfillJson(route: Route, body: unknown, status = 200) { await route.fulfill(jsonRoute(body, status)); } async function routeUnmockedApi(page: Page) { await page.route("**/api/**", (route) => route.fulfill(jsonRoute({ detail: "not part of this focused fixture" }, 404)), ); } async function routeAuthedLearner(page: Page) { await page.route("**/api/auth/me", (route) => route.fulfill( jsonRoute({ user_id: "00000000-0000-0000-0000-000000000101", email: "learner@hs.ac.kr", role: "learner", display_name: "E2E Learner", admin_access: false, super_admin: false, account_status: "approved", approval_required: false, cohort_ids: ["e2e-hanshin"], consent_at: 1782820000, onboarding_completed_at: 1782820001, nickname: "E2E Learner", self_introduction: "", avatar_url: "", }), ), ); } interface PulseItem { pulse_id: string; checkpoint: "pre" | "mid" | "post"; status: string; learner_locked_at: string | null; revealed_at: string | null; error_code: string | null; self_scores: Record<"goal" | "task" | "bond", number> | null; measurements: unknown[]; } function lockedPulse( checkpoint: "pre" | "mid" | "post", scores: Record<"goal" | "task" | "bond", number> = { goal: 0.5, task: 0.5, bond: 0.5 }, overrides: Partial = {}, ): PulseItem { return { pulse_id: `pulse-${checkpoint}-1`, checkpoint, status: "awaiting_agents", learner_locked_at: "2026-08-06T08:59:00Z", revealed_at: null, error_code: null, self_scores: scores, measurements: [], ...overrides, }; } interface SessionFixtureOptions { initialPulses?: PulseItem[]; /** * true면 restorePulseLedger() 호출 전까지 원장 GET이 500으로 실패한다. * (React StrictMode dev 이중 mount가 GET을 한 번 더 보내므로, 1회성 실패 * 카운트는 사용자에게 보이기 전에 소진된다 — 명시 복구 스위치로 고정한다.) */ pulseGetFailing?: boolean; pulsePostStatus?: number; // default 202 pulsePostConflictLedger?: PulseItem[]; // ledger returned by GET after a 409 POST } async function routeLiveSession(page: Page, options: SessionFixtureOptions = {}) { const pulseItems: PulseItem[] = [...(options.initialPulses ?? [])]; const pulsePosts: Array<{ checkpoint?: string; scores?: Record }> = []; let pulseGetFailing = options.pulseGetFailing ?? false; await routeUnmockedApi(page); await routeAuthedLearner(page); await page.route("**/api/users/me/prepost-measures**", (route) => fulfillJson(route, { pilot_id: "phase3-pilot-draft", instrument_version: "pilot-prepost-scaffold-2026-06-28", measures: [], complete_pre_count: 0, complete_post_count: 0, updated_at: null, }), ); await page.route("**/api/voice/health", (route) => fulfillJson(route, { available: false, reason: "fixture" }), ); await page.route("**/api/personas", (route) => fulfillJson(route, [ { code: "P1", display_name: "민서(청소년 우울)", difficulty: "hard", theory_target: ["humanistic"], demographics: { age_band: "10대" }, presenting_summary: "자퇴와 무기력감을 둘러싼 상담 연습", voice_preset: "soft-young-fem", source: "database", degraded: false, }, ]), ); await page.route("**/api/sessions", async (route) => { if (route.request().method() !== "POST") { await route.fallback(); return; } await fulfillJson( route, { session_id: SESSION_ID, case_id: "uc-alliance-case-001", session_no: 1, stage: "라포", effective_openness: 0.21, recall_summary: null, degraded: false, }, 201, ); }); await page.route(`**/api/sessions/${SESSION_ID}`, (route) => fulfillJson(route, { session_id: SESSION_ID, case_id: "uc-alliance-case-001", persona_code: "P1", persona_name: "민서", session_no: 1, status: "active", stage: "라포", theory_mode: "humanistic", effective_openness: 0.21, started_at: "2026-08-06T08:00:00Z", ended_at: null, review_ready: false, turns: [], }), ); await page.route(`**/api/sessions/${SESSION_ID}/alliance-pulses`, async (route) => { if (route.request().method() === "POST") { const body = route.request().postDataJSON() as { checkpoint?: "pre" | "mid" | "post"; scores?: Record; }; pulsePosts.push(body); const postStatus = options.pulsePostStatus ?? 202; if (postStatus === 409) { pulseItems.push(...(options.pulsePostConflictLedger ?? [])); await fulfillJson(route, { detail: "이미 잠긴 자기점검이 있습니다." }, 409); return; } if (postStatus >= 400) { await fulfillJson(route, { detail: "checkpoint window closed" }, postStatus); return; } const pulseId = `pulse-${body.checkpoint ?? "pre"}-created`; pulseItems.push( lockedPulse(body.checkpoint ?? "pre", (body.scores ?? null) as never, { pulse_id: pulseId, }), ); await fulfillJson(route, { pulse_id: pulseId, status: "awaiting_agents" }, 202); return; } if (pulseGetFailing) { await fulfillJson(route, { detail: "alliance ledger unavailable" }, 500); return; } await fulfillJson(route, { items: pulseItems }); }); await page.route(`**/api/sessions/${SESSION_ID}/stream`, (route) => route.fulfill({ status: 200, contentType: "text/event-stream", body: [ "event: token", `data: ${CLIENT_REPLY}`, "", "event: done", `data: ${JSON.stringify({ session_id: SESSION_ID, stage: "탐색", effective_openness: 0.42, turn_seq: 1, safety_flagged: false, })}`, "", ].join("\n"), }), ); return { pulsePosts, pulseItems, restorePulseLedger: () => { pulseGetFailing = false; }, }; } async function startSession(page: Page) { await page.goto("/learn/session/P1"); await page.getByRole("button", { name: "회기 시작" }).click(); } async function completeOneExchange(page: Page) { const composer = page.getByLabel("학습자 발화 입력"); await expect(composer).toBeEnabled(); await composer.fill(LEARNER_TEXT); await page.getByRole("button", { name: "보내기" }).click(); await expect(page.locator(".sx-utt").filter({ hasText: CLIENT_REPLY })).toBeVisible(); } async function pickCheckpointScore( page: Page, axis: "목표" | "과업" | "유대", scoreName: string, ) { const group = page.getByRole("group", { name: new RegExp(`^${axis}`) }); await group.getByRole("radio", { name: scoreName }).check(); } // ── 리뷰(post) fixture ───────────────────────────────────────────── type Dimension = "goal" | "task" | "bond"; type Perspective = "client_agent_report" | "independent_observer"; const AI_VALUES: Record> = { client_agent_report: { goal: 0.4, task: 0.6, bond: 0.9 }, independent_observer: { goal: 0.2, task: 0.55, bond: 0.65 }, }; function revealedMeasurements() { return (Object.keys(AI_VALUES) as Perspective[]).flatMap((perspective) => (["goal", "task", "bond"] as Dimension[]).map((dimension) => ({ measurement_id: `${perspective}-${dimension}`, dimension, perspective, source_kind: "model_inference", value: AI_VALUES[perspective][dimension], confidence: 0.8, status: "ready", error_code: null, rationale: dimension === "bond" ? "방어가 낮아진 마지막 발화를 근거로 판단했습니다." : "목표와 과업을 함께 확인한 발화를 근거로 판단했습니다.", evidence: [ { turn_id: FILLED_REVIEW_TURN_IDS[5], seq: 6, speaker: "client", text: "적어도 제가 화난 게 이상한 건 아니라는 말은 좀 기억에 남아요.", }, ], created_at: "2026-08-06T09:00:00Z", })), ); } function revealedPostPulse(): PulseItem { return lockedPulse("post", { goal: 0.75, task: 0.5, bond: 1 }, { pulse_id: "pulse-post-revealed", status: "revealed", revealed_at: "2026-08-06T09:00:05Z", measurements: revealedMeasurements(), }); } async function routeReviewPage(page: Page) { await routeUnmockedApi(page); await routeAuthedLearner(page); await routeFilledSessionReview(page); await routePrepostMeasures(page); } /** * 리뷰 척도 선택. AlliancePulseCard의 라디오 input은 clip/clip-path로 시각 * 숨김이라(hit-test에서 제외) input.check()의 포인터가 label에 가로막힌다. * 실제 사용자와 같은 계약대로 .ap-scale__option(label)을 클릭하고, 상태 * 변화(checked)는 그대로 어설션한다. */ async function pickReviewScore(page: Page, groupName: string, scoreName: string) { const group = page.getByRole("radiogroup", { name: groupName }); const radio = group.getByRole("radio", { name: scoreName }); await group .locator(".ap-scale__option") .filter({ has: page.getByRole("radio", { name: scoreName }) }) .click(); await expect(radio).toBeChecked(); } // ── pre: 회기 진입 전 fail-closed 잠금 ───────────────────────────── test.describe("UC 치료 동맹 펄스 — 회기 전 자기점검(pre)", () => { // usecase: 학습자가 회기를 시작하면 pre 자기점검을 잠그기 전까지 첫 발화 입력이 닫혀 있다 test("회기를 시작하면 pre 자기점검을 잠그기 전까지 첫 발화 입력이 닫혀 있다", async ({ page, }) => { await routeLiveSession(page); await startSession(page); await expect( page.getByRole("heading", { name: "첫 발화 전에 내 기준을 잠급니다" }), ).toBeVisible(); await expect(page.getByLabel("학습자 발화 입력")).toBeDisabled(); // 제출 전부터 "수정 불가" 계약을 고지한다. await expect( page.getByText("제출한 판단은 수정하지 않고, 세 관점은 모두 준비된 뒤 공개합니다."), ).toBeVisible(); }); // usecase: 학습자가 세 축을 모두 고르기 전에는 pre 잠금 버튼이 눌리지 않는다 test("세 축을 모두 고르기 전에는 pre 잠금 버튼이 눌리지 않는다", async ({ page }) => { await routeLiveSession(page); await startSession(page); const progress = page.locator(".sx-alliance-checkpoint__progress"); await expect(progress).toContainText("0/3"); const submit = page.getByRole("button", { name: "기준 잠그고 첫 발화 준비" }); await expect(submit).toBeDisabled(); await pickCheckpointScore(page, "목표", "3 보통이다"); await pickCheckpointScore(page, "과업", "3 보통이다"); await expect(progress).toContainText("2/3"); await expect(progress).toContainText("축 선택함"); await expect(submit).toBeDisabled(); await pickCheckpointScore(page, "유대", "4 대체로 그렇다"); await expect(progress).toContainText("3/3"); await expect(progress).toContainText("선택 완료"); await expect(submit).toBeEnabled(); }); // usecase: 자기점검 원장 확인이 실패한 학습자는 첫 발화가 열리지 않고, 다시 확인으로 복구한다 test("자기점검 원장 확인이 실패하면 첫 발화를 열지 않고 다시 확인으로 복구한다", async ({ page, }) => { const api = await routeLiveSession(page, { pulseGetFailing: true, initialPulses: [lockedPulse("pre")], }); await startSession(page); // fail-closed: 원장 상태를 모르면 발화를 열지 않는다. await expect(page.getByText("자기점검 상태를 확인하지 못했습니다")).toBeVisible(); await expect(page.getByText("첫 발화는 상태 확인 뒤에 열립니다.")).toBeVisible(); await expect(page.getByLabel("학습자 발화 입력")).toBeDisabled(); // 서버가 복구된 뒤 "다시 확인"으로 원장을 재조회하면 게이트가 열린다. api.restorePulseLedger(); await page.getByRole("button", { name: "다시 확인" }).click(); await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled(); }); // usecase: 이미 pre를 잠근 학습자가 회기에 돌아오면 점검을 다시 요구받지 않는다 test("이미 pre가 잠긴 회기로 돌아오면 점검을 다시 요구하지 않고 바로 발화를 연다", async ({ page, }) => { await routeLiveSession(page, { initialPulses: [lockedPulse("pre")] }); await startSession(page); await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled(); await expect( page.getByRole("heading", { name: "첫 발화 전에 내 기준을 잠급니다" }), ).toHaveCount(0); // pre 잠금 뒤 mid 이전에는 점검 UI가 회기 본문을 밀어내지 않는다. await expect(page.locator(".sx-alliance-checkpoint")).toHaveCount(0); }); // usecase: 다른 탭에서 이미 잠근 pre와 겹친 학습자는 서버 원장과 동기화되어 첫 발화가 열린다 test("pre 잠금이 409로 겹치면 서버 원장과 동기화해 첫 발화를 연다", async ({ page }) => { const api = await routeLiveSession(page, { pulsePostStatus: 409, pulsePostConflictLedger: [lockedPulse("pre", { goal: 0.25, task: 0.5, bond: 0.75 })], }); await startSession(page); await pickCheckpointScore(page, "목표", "3 보통이다"); await pickCheckpointScore(page, "과업", "3 보통이다"); await pickCheckpointScore(page, "유대", "3 보통이다"); await page.getByRole("button", { name: "기준 잠그고 첫 발화 준비" }).click(); // 409 → 원장 재조회 → 게이트 해제(중복 생성 없음). await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled(); expect(api.pulsePosts.map((item) => item.checkpoint)).toEqual(["pre"]); }); }); // ── mid: 비차단 진행 ────────────────────────────────────────────── test.describe("UC 치료 동맹 펄스 — 회기 중 자기점검(mid)", () => { // usecase: 대화가 한 왕복 쌓인 학습자에게 30초 점검이 떠도 대화 입력은 계속 열려 있다 test("한 왕복이 쌓이면 30초 점검 배지가 떠도 대화 입력은 계속 열려 있다", async ({ page, }) => { await routeLiveSession(page, { initialPulses: [lockedPulse("pre")] }); await startSession(page); await completeOneExchange(page); await expect(page.getByRole("button", { name: "30초 점검" })).toBeVisible(); await expect( page.getByText("한 번의 대화 왕복이 쌓였습니다. 현재 판단을 남길 수 있어요."), ).toBeVisible(); // mid 는 pre 와 달리 비차단: 배지가 떠 있어도 발화 입력이 잠기지 않는다. await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled(); }); // usecase: 학습자가 30초 점검을 열었다가 제출 없이 대화 먼저 이어가기로 접는다 test("30초 점검을 열었다가 제출 없이 대화 먼저 이어가기로 접는다", async ({ page }) => { const api = await routeLiveSession(page, { initialPulses: [lockedPulse("pre")] }); await startSession(page); await completeOneExchange(page); await page.getByRole("button", { name: "30초 점검" }).click(); await expect( page.getByRole("heading", { name: "지금의 흐름을 30초 동안 확인합니다" }), ).toBeVisible(); await page.getByRole("button", { name: "대화 먼저 이어가기" }).click(); await expect( page.getByRole("heading", { name: "지금의 흐름을 30초 동안 확인합니다" }), ).toHaveCount(0); await expect(page.getByRole("button", { name: "30초 점검" })).toBeVisible(); // 접기는 제출이 아니다 — 원장에 아무것도 쓰지 않는다. expect(api.pulsePosts).toHaveLength(0); await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled(); }); // usecase: 회기 상태가 바뀌어 mid 잠금이 422로 거절된 학습자는 사유를 그대로 본다 test("회기 상태가 바뀌어 mid 잠금이 422로 거절되면 사유를 그대로 본다", async ({ page, }) => { await routeLiveSession(page, { initialPulses: [lockedPulse("pre")], pulsePostStatus: 422, }); await startSession(page); await completeOneExchange(page); await page.getByRole("button", { name: "30초 점검" }).click(); await pickCheckpointScore(page, "목표", "4 대체로 그렇다"); await pickCheckpointScore(page, "과업", "4 대체로 그렇다"); await pickCheckpointScore(page, "유대", "4 대체로 그렇다"); await page.getByRole("button", { name: "중간 판단 잠그고 이어가기" }).click(); await expect( page.getByText("회기 진행 상태가 바뀌어 이 시점의 점검을 잠글 수 없습니다."), ).toBeVisible(); await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled(); }); }); // ── post: 리뷰에서 잠금·공개·수정 불가 ───────────────────────────── test.describe("UC 치료 동맹 펄스 — 리뷰(post)", () => { test("근거 장면은 화면용 t번호가 아니라 영속 UUID로 잠근다", async ({ page }) => { await routeReviewPage(page); let submittedBody: Record | null = null; await page.route( `**/api/sessions/${FILLED_REVIEW_SESSION_ID}/alliance-pulses`, async (route) => { if (route.request().method() === "POST") { submittedBody = route.request().postDataJSON() as Record; await fulfillJson( route, { pulse_id: "pulse-post-uuid", status: "awaiting_agents" }, 202, ); return; } await fulfillJson(route, { items: [] }); }, ); await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`); await pickReviewScore(page, "목표 합의 평가", "4 대체로 그렇다"); await pickReviewScore(page, "과업 합의 평가", "3 보통이다"); await pickReviewScore(page, "정서적 유대 평가", "5 매우 그렇다"); await page.getByText("내 판단의 근거 장면 선택").click(); await page.getByRole("checkbox").first().check(); await page.getByRole("button", { name: "내 판단 잠그고 관점 비교" }).click(); await expect.poll(() => submittedBody).not.toBeNull(); expect(submittedBody).toEqual({ checkpoint: "post", scores: { goal: 0.75, task: 0.5, bond: 1 }, evidence_turn_ids: [FILLED_REVIEW_TURN_IDS[0]], }); expect(FILLED_REVIEW_TURN_IDS[0]).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, ); }); // usecase: 리뷰에서 근거 장면 없이 세 축만 고른 학습자는 post 판단을 잠글 수 없다 test("리뷰에서 근거 장면 없이 세 축만 고르면 post 판단을 잠글 수 없다", async ({ page, }) => { await routeReviewPage(page); await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/alliance-pulses`, (route) => fulfillJson(route, { items: [] }), ); await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`); await pickReviewScore(page, "목표 합의 평가", "4 대체로 그렇다"); await pickReviewScore(page, "과업 합의 평가", "3 보통이다"); await pickReviewScore(page, "정서적 유대 평가", "5 매우 그렇다"); // 저장 발화가 있는 회기에서는 근거 장면 없이 잠글 수 없다. const submit = page.getByRole("button", { name: "내 판단 잠그고 관점 비교" }); await expect(submit).toBeDisabled(); await page.getByText("내 판단의 근거 장면 선택").click(); await page.getByRole("checkbox").first().check(); await expect(page.getByText("1개 선택")).toBeVisible(); await expect(submit).toBeEnabled(); }); // usecase: 다른 탭에서 이미 잠근 post와 겹친 학습자는 서버에 저장된 자기평가를 그대로 본다 test("post 잠금이 409로 겹치면 서버에 저장된 자기평가를 그대로 본다", async ({ page, }) => { await routeReviewPage(page); let conflicted = false; await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/alliance-pulses`, async (route) => { if (route.request().method() === "POST") { conflicted = true; await fulfillJson(route, { detail: "이미 잠긴 자기평가가 있습니다." }, 409); return; } await fulfillJson(route, { items: conflicted ? [lockedPulse("post", { goal: 0.25, task: 0.5, bond: 0.75 })] : [], }); }); await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`); await pickReviewScore(page, "목표 합의 평가", "5 매우 그렇다"); await pickReviewScore(page, "과업 합의 평가", "5 매우 그렇다"); await pickReviewScore(page, "정서적 유대 평가", "5 매우 그렇다"); await page.getByText("내 판단의 근거 장면 선택").click(); await page.getByRole("checkbox").first().check(); await page.getByRole("button", { name: "내 판단 잠그고 관점 비교" }).click(); // 내가 방금 고른 5·5·5가 아니라 서버 원장의 2·3·4가 진실이다. await expect(page.getByText("내 판단이 잠겼습니다")).toBeVisible(); const locked = page.locator(".ap-locked-scores"); await expect(locked).toContainText("25점"); await expect(locked).toContainText("50점"); await expect(locked).toContainText("75점"); }); // usecase: AI 분석이 실패한 학습자도 자신의 세 축 판단은 보존되고, 지어낸 점수를 보지 않는다 test("AI 분석이 실패해도 내 세 축 판단은 보존되고 점수를 지어내지 않는다", async ({ page, }) => { await routeReviewPage(page); const errorPulse = lockedPulse("post", { goal: 0.75, task: 0.5, bond: 1 }, { status: "error", error_code: "agents_failed", }); await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/alliance-pulses`, (route) => fulfillJson(route, { items: [errorPulse] }), ); await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`); await expect(page.getByText("분석 보류", { exact: true })).toBeVisible(); await expect(page.getByText("내 판단은 보존됐습니다")).toBeVisible(); // goal/task/bond 세 축이 각각 자기 점수로 표시된다. const locked = page.locator(".ap-locked-scores"); await expect(locked.getByText("목표", { exact: true })).toBeVisible(); await expect(locked.getByText("과업", { exact: true })).toBeVisible(); await expect(locked.getByText("유대", { exact: true })).toBeVisible(); await expect(locked).toContainText("75점"); await expect(locked).toContainText("50점"); await expect(locked).toContainText("100점"); // 준비되지 않은 AI 관점을 추정해 그리지 않는다. await expect(page.getByLabel("치료 동맹 관점 비교")).toHaveCount(0); await expect(page.locator(".ap-waiting__skeleton")).toHaveCount(0); }); // usecase: 공개된 비교 뷰에서 자기·관찰자 점수 차이를 확인하고 근거 발화로 점프한다 test("공개된 비교 뷰에서 자기·관찰자 점수 차이를 보고 근거 발화로 점프한다", async ({ page, }) => { await routeReviewPage(page); await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/alliance-pulses`, (route) => fulfillJson(route, { items: [revealedPostPulse()] }), ); await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`); const comparison = page.getByLabel("치료 동맹 관점 비교"); await expect(comparison).toBeVisible(); // goal 축: 내 판단 75점 vs 내담자 40점 vs 관찰자 20점 — 관점차가 그대로 병치된다. const goalRow = comparison.locator('section[aria-labelledby="ap-dimension-goal"]'); await expect(goalRow).toContainText("75점"); await expect(goalRow).toContainText("40점"); await expect(goalRow).toContainText("20점"); // 세 축이 각각 독립된 행으로 표시된다. await expect( comparison.locator('section[aria-labelledby="ap-dimension-task"]'), ).toContainText("55점"); await expect( comparison.locator('section[aria-labelledby="ap-dimension-bond"]'), ).toContainText("90점"); // bond 축 내담자 관점 근거에서 축어록으로 점프한다. const bondRow = comparison.locator('section[aria-labelledby="ap-dimension-bond"]'); await bondRow.locator(".ap-measurement-evidence").first().getByText("근거 1개").click(); await bondRow .getByRole("button", { name: /6번째 발화/ }) .first() .click(); await expect(page.getByRole("tab", { name: "축어록" })).toHaveAttribute( "aria-selected", "true", ); await expect(page.locator(".sr-turn--active")).toContainText("기억에 남아요"); }); // usecase: 관점이 공개된 뒤의 학습자에게는 자기평가를 고칠 입력이 남아 있지 않다 test("관점 공개 뒤에는 자기평가를 고칠 입력이 남아 있지 않다", async ({ page }) => { await routeReviewPage(page); await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/alliance-pulses`, (route) => fulfillJson(route, { items: [revealedPostPulse()] }), ); await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`); await expect(page.getByLabel("치료 동맹 관점 비교")).toBeVisible(); // 학습자 뷰에는 다시 고를 라디오도, 잠금 버튼도 남지 않는다. const card = page.locator(".ap-card"); await expect(card.getByRole("radio")).toHaveCount(0); await expect( card.getByRole("button", { name: "내 판단 잠그고 관점 비교" }), ).toHaveCount(0); // 내 열은 "잠긴 자기평가" 출처로만 남는다. await expect(card.locator("small:visible", { hasText: "잠긴 자기평가" }).first()).toBeVisible(); await expect( card.getByText("먼저 기록한 자기평가").first(), ).toBeVisible(); }); // usecase: 320px 폰에서 리뷰를 여는 학습자에게 1~5 척도가 3+2 두 줄로 접혀도 다섯 칸이 모두 남는다 test("320px에서 1~5 척도가 3+2 두 줄로 접혀도 다섯 칸이 모두 남는다", async ({ page, }) => { test.skip( test.info().project.name !== "chromium-mobile", "초소형 폭 배열 검증은 mobile 프로젝트에서만 수행", ); await page.setViewportSize({ width: 320, height: 568 }); await routeReviewPage(page); await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/alliance-pulses`, (route) => fulfillJson(route, { items: [] }), ); await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`); const scale = page.locator(".ap-scale").first(); await expect(scale).toBeVisible(); const options = scale.locator(".ap-scale__option"); await expect(options).toHaveCount(5); const boxes = await options.evaluateAll((elements) => elements.map((element) => { const rect = element.getBoundingClientRect(); return { top: rect.top, left: rect.left, right: rect.right }; }), ); // 첫 줄: 1·2·3 이 같은 행. expect(Math.abs(boxes[1].top - boxes[0].top)).toBeLessThanOrEqual(2); expect(Math.abs(boxes[2].top - boxes[0].top)).toBeLessThanOrEqual(2); // 둘째 줄: 4·5 가 같은 행으로 내려간다. expect(boxes[3].top).toBeGreaterThan(boxes[0].top + 10); expect(Math.abs(boxes[4].top - boxes[3].top)).toBeLessThanOrEqual(2); // 4번 칸은 2번 그리드 컬럼에서 시작해 가운데 정렬된다. expect(boxes[3].left).toBeGreaterThan(boxes[0].left + 10); // 모든 칸이 화면 안에 남는다. for (const box of boxes) { expect(box.right).toBeLessThanOrEqual(320); } }); });