import { expect, test, type Page, type Route } from "@playwright/test"; import { FILLED_REVIEW_SESSION_ID, FILLED_REVIEW_TURN_IDS, filledReviewResponse, routeFilledSessionReview, routePrepostMeasures, } from "./session-review-fixture"; import { expectNoHorizontalOverflow } from "./support"; type Role = "learner" | "teacher"; interface PulseMeasurement { measurement_id: string; dimension: "goal" | "task" | "bond"; perspective: "client_agent_report" | "independent_observer" | "supervisor_human"; source_kind: string; value: number | null; confidence: number | null; status: string; error_code: string | null; rationale: string | null; evidence: Array<{ turn_id: string; seq: number; speaker: string; text: string; }>; created_at: string; } function routeReviewUser(page: Page, role: Role) { return page.route("**/api/auth/me", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ user_id: role === "teacher" ? "00000000-0000-0000-0000-000000000202" : "00000000-0000-0000-0000-000000000101", email: `${role}@hs.ac.kr`, role, display_name: role === "teacher" ? "E2E Teacher" : "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: role === "teacher" ? "E2E Teacher" : "E2E Learner", self_introduction: "", avatar_url: "", }), }); }); } function measurements(): PulseMeasurement[] { const evidence = { turn_id: FILLED_REVIEW_TURN_IDS[5], seq: 6, speaker: "client", text: "적어도 제가 화난 게 이상한 건 아니라는 말은 좀 기억에 남아요.", }; const values = { client_agent_report: { goal: 0.68, task: 0.61, bond: 0.83 }, independent_observer: { goal: 0.72, task: 0.57, bond: 0.76 }, } as const; return (["client_agent_report", "independent_observer"] as const).flatMap((perspective) => (["goal", "task", "bond"] as const).map((dimension) => ({ measurement_id: `${perspective}-${dimension}`, dimension, perspective, source_kind: "model_inference", value: values[perspective][dimension], confidence: 0.78, status: "recorded", error_code: null, rationale: dimension === "bond" ? "정서 정상화 이후 내담자의 방어가 낮아진 발화를 근거로 판단했습니다." : "회기 목표와 다음 과업을 함께 확인한 발화를 근거로 판단했습니다.", evidence: [evidence], created_at: "2026-08-06T09:00:00Z", })), ); } function pulseFixture(options: { revealed: boolean; includeEarlyMeasurements?: boolean }) { return { pulse_id: "pulse-post-1", checkpoint: "post", status: options.revealed ? "revealed" : "awaiting_agents", learner_locked_at: "2026-08-06T08:59:00Z", revealed_at: options.revealed ? "2026-08-06T09:00:05Z" : null, error_code: null, self_scores: { goal: 0.75, task: 0.5, bond: 1 }, measurements: options.revealed || options.includeEarlyMeasurements ? measurements() : [], }; } async function fulfillJson(route: Route, body: unknown, status = 200) { await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body), }); } test.describe("G1 치료 동맹 펄스", () => { test.beforeEach(async ({ page }) => { await routeFilledSessionReview(page); await routePrepostMeasures(page); }); 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; let postSubmitReads = 0; await page.route( `**/api/sessions/${FILLED_REVIEW_SESSION_ID}/alliance-pulses`, async (route) => { if (route.request().method() === "POST") { submittedBody = route.request().postDataJSON() as Record; pulse = pulseFixture({ revealed: false, includeEarlyMeasurements: true }); await fulfillJson(route, { pulse_id: pulse.pulse_id, status: "awaiting_agents", }, 202); return; } if (pulse) { postSubmitReads += 1; const responsePulse = postSubmitReads >= 2 ? pulseFixture({ revealed: true }) : pulse; await fulfillJson(route, { items: [responsePulse] }); return; } await fulfillJson(route, { items: [] }); }, ); await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`); await expect( page.getByRole("heading", { name: "목표, 과업, 유대를 따로 봅니다" }), ).toBeVisible(); 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 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); await expect(page.getByText("관찰자 관점", { exact: true })).toHaveCount(0); await expect(page.getByText("정서 정상화 이후 내담자의 방어가 낮아진 발화를 근거로 판단했습니다.")).toHaveCount(0); expect(submittedBody).toEqual({ checkpoint: "post", scores: { goal: 0.75, task: 0.5, bond: 1 }, evidence_turn_ids: [FILLED_REVIEW_TURN_IDS[0]], }); await expect(page.getByLabel("치료 동맹 관점 비교")).toBeVisible({ timeout: 5_000, }); await expect(page.locator("b:visible", { hasText: "내담자 관점" }).first()).toBeVisible(); await expect(page.locator("b:visible", { hasText: "관찰자 관점" }).first()).toBeVisible(); }); test("320px에서도 1~5 자기평가 척도를 스크롤 없이 모두 노출한다", async ({ page, }, testInfo) => { await page.setViewportSize({ width: 320, height: 568 }); await routeReviewUser(page, "learner"); 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(); await expect(scale.locator(".ap-scale__option")).toHaveCount(5); const scaleMetrics = await scale.evaluate((element) => ({ clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, })); expect(scaleMetrics.scrollWidth).toBeLessThanOrEqual(scaleMetrics.clientWidth + 1); const scaleBox = await scale.boundingBox(); expect(scaleBox).not.toBeNull(); if (scaleBox) { const optionBoxes = await scale.locator(".ap-scale__option").evaluateAll((elements) => elements.map((element) => { const rect = element.getBoundingClientRect(); return { left: rect.left, right: rect.right, width: rect.width, height: rect.height, }; }), ); for (const optionBox of optionBoxes) { expect(optionBox.left).toBeGreaterThanOrEqual(scaleBox.x - 1); expect(optionBox.right).toBeLessThanOrEqual(scaleBox.x + scaleBox.width + 1); expect(optionBox.right).toBeLessThanOrEqual(320); expect(optionBox.width).toBeGreaterThanOrEqual(44); expect(optionBox.height).toBeGreaterThanOrEqual(44); } } await expectNoHorizontalOverflow(page); await scale.screenshot({ path: testInfo.outputPath("alliance-pulse-scale-320x568.png"), animations: "disabled", }); }); test("독립 세 축과 출처를 공개하고 근거 발화로 이동한다", async ({ page, }, testInfo) => { await routeReviewUser(page, "learner"); const pulse = pulseFixture({ revealed: true }); await page.route( `**/api/sessions/${FILLED_REVIEW_SESSION_ID}/alliance-pulses`, (route) => fulfillJson(route, { items: [pulse] }), ); await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`); const comparison = page.getByLabel("치료 동맹 관점 비교"); await expect(comparison).toBeVisible(); await expect(comparison.getByText("목표 합의", { exact: true })).toBeVisible(); await expect(comparison.getByText("과업 합의", { exact: true })).toBeVisible(); await expect(comparison.getByText("정서적 유대", { exact: true })).toBeVisible(); await expect(comparison.locator("small:visible", { hasText: "잠긴 자기평가" }).first()).toBeVisible(); await expect(comparison.locator("small:visible", { hasText: "AI 역할 추론" }).first()).toBeVisible(); await expect(comparison.locator("small:visible", { hasText: "AI 축어록 추론" }).first()).toBeVisible(); await expect(comparison.getByText(/총점과 평균은 만들지 않습니다/)).toBeVisible(); await expect(comparison.getByText(/총점\s*:/)).toHaveCount(0); await comparison.locator(".ap-comparison__row").first().screenshot({ path: testInfo.outputPath("alliance-pulse-goal-axis.png"), animations: "disabled", }); await comparison.locator(".ap-measurement-evidence").first().getByText("근거 1개").click(); await comparison .getByRole("button", { name: /6번째 발화.*적어도 제가 화난 게 이상한 건 아니라는 말/ }) .first() .click(); await expect(page.getByRole("tab", { name: "축어록" })).toHaveAttribute( "aria-selected", "true", ); await expect(page.locator(".sr-turn--active")).toContainText("기억에 남아요"); await expectNoHorizontalOverflow(page); }); test("교수자가 근거를 연결한 판정을 추가한다", async ({ page }) => { await routeReviewUser(page, "teacher"); const review = filledReviewResponse(FILLED_REVIEW_SESSION_ID); review.teacherReview = { status: "viewed", note: "", reviewedAt: null, reviewerId: "00000000-0000-0000-0000-000000000202", worksheetStatus: "pending", worksheetNote: "", worksheetReviewedAt: null, }; await page.unroute(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/review`); await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/review`, (route) => fulfillJson(route, review), ); const pulse = pulseFixture({ revealed: true }); 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, { status: "recorded" }, 201); return; } await fulfillJson(route, { items: [pulse] }); }, ); await page.goto(`/teach/session/${FILLED_REVIEW_SESSION_ID}/review`); const supervisor = page.locator(".ap-supervisor"); await expect(supervisor).toBeVisible(); const axes = supervisor.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(3).click(); await supervisor.getByText("교수자 판정 근거 장면 선택").click(); await supervisor.getByRole("checkbox").first().check(); await supervisor.getByLabel("판정 메모").fill("목표 합의는 안정적이지만 과업 속도는 다음 지도에서 다시 확인합니다."); await supervisor.getByRole("button", { name: "근거와 함께 판정 추가" }).click(); await expect(supervisor.getByText("교수자 판정을 원장에 추가했습니다.")).toBeVisible(); expect(submittedBody).toEqual({ scores: { goal: 0.75, task: 0.5, bond: 0.75 }, evidence_turn_ids: [FILLED_REVIEW_TURN_IDS[0]], note: "목표 합의는 안정적이지만 과업 속도는 다음 지도에서 다시 확인합니다.", }); await expectNoHorizontalOverflow(page); }); });