import { expect, test, type Page, type Route } from "@playwright/test"; import { FILLED_REVIEW_SESSION_ID, filledReviewResponse, routeFilledSessionReview, routePrepostMeasures, } from "./session-review-fixture"; import { expectNoHorizontalOverflow } from "./support"; type Role = "learner" | "teacher"; function routeReviewUser(page: Page, role: Role) { return page.route("**/api/auth/me", (route) => 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 fulfillJson(route: Route, body: unknown, status = 200) { return route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body), }); } function measurement( axis: "goal" | "task" | "bond", modality: "text" | "voice", options: { status?: "ready" | "error"; value?: number } = {}, ) { const status = options.status ?? "ready"; return { measurement_id: `oas-g7-measurement-${axis}-${modality}`, axis, modality, status, value: status === "ready" ? (options.value ?? 0.7) : null, confidence: status === "ready" ? 0.78 : null, uncertainty: status === "ready" ? 0.22 : 1, evidence_refs: status === "ready" ? [modality === "text" ? "turn:t4" : "oas-g7-event-interruption-1"] : [], model_run_id: status === "ready" ? "00000000-0000-0000-0000-000000000701" : null, instrument_id: `${modality}-alliance-observer`, instrument_version: "1.0.0", model_name: modality === "text" ? "text-observer-v1" : "voice-observer-v1", prompt_version: "g7-v1", source_kind: modality === "text" ? "masked_transcript" : "observed_audio_runtime", error_code: status === "error" ? "voice_runtime_unavailable" : null, created_at: "2026-08-06T09:02:00Z", }; } function silentWav(durationMs = 1_000): Buffer { const sampleRate = 8_000; const dataLength = Math.floor(sampleRate * durationMs / 1_000) * 2; const wav = Buffer.alloc(44 + dataLength); wav.write("RIFF", 0); wav.writeUInt32LE(36 + dataLength, 4); wav.write("WAVEfmt ", 8); wav.writeUInt32LE(16, 16); wav.writeUInt16LE(1, 20); wav.writeUInt16LE(1, 22); wav.writeUInt32LE(sampleRate, 24); wav.writeUInt32LE(sampleRate * 2, 28); wav.writeUInt16LE(2, 32); wav.writeUInt16LE(16, 34); wav.write("data", 36); wav.writeUInt32LE(dataLength, 40); return wav; } function rawAudioFixture() { return { items: [ { audio_asset_id: "00000000-0000-0000-0000-000000000740", session_id: FILLED_REVIEW_SESSION_ID, learner_id: "00000000-0000-0000-0000-000000000101", audio_ref: "private://must-not-render", audio_sha256: "b".repeat(64), media_type: "audio/wav", byte_size: 16_044, duration_ms: 120_000, retained_until: "2026-09-05T09:00:00Z", created_at: "2026-08-06T09:01:00Z", }, ], }; } function multimodalFixture(options: { consent?: "granted" | "withdrawn" | "none" } = {}) { const consent = options.consent ?? "granted"; const timelineId = "00000000-0000-0000-0000-000000000710"; const event = ( eventId: string, eventType: string, startMs: number, endMs: number, actor: string, observedFeature: string, ) => ({ timeline_id: timelineId, event_id: eventId, event_type: eventType, start_ms: startMs, end_ms: endMs, actor, observed_feature: observedFeature, uncertainty: 0.18, source: "observed_audio_runtime", claim_scope: "interaction_signal", clinical_claim_allowed: false, }); return { session_id: FILLED_REVIEW_SESSION_ID, learner_id: "00000000-0000-0000-0000-000000000101", clinical_claim_allowed: false, consent_snapshots: consent === "none" ? [] : [ { consent_snapshot_id: "00000000-0000-0000-0000-000000000711", sequence_no: 1, consent_status: "granted", retain_audio: true, retain_derived_features: true, transcript_retained: true, retention_days: 30, policy_version: "vignette.multimodal-consent.v1", reason_code: null, created_at: "2026-08-06T09:00:00Z", }, ...(consent === "withdrawn" ? [{ consent_snapshot_id: "00000000-0000-0000-0000-000000000712", sequence_no: 2, consent_status: "withdrawn", retain_audio: false, retain_derived_features: false, transcript_retained: true, retention_days: null, policy_version: "vignette.multimodal-consent.v1", reason_code: "learner_withdrawal", created_at: "2026-08-06T09:05:00Z", }] : []), ], timelines: [ { timeline_id: timelineId, audio_duration_ms: 120_000, clock_version: "audio-clock-v1", word_count: 8, event_count: 4, created_at: "2026-08-06T09:01:00Z", derived_features_available: true, }, ], word_timestamps: Array.from({ length: 8 }, (_, index) => ({ timeline_id: timelineId, word_index: index, start_ms: 4_000 + index * 11_000, end_ms: 4_800 + index * 11_000, speaker: index % 2 === 0 ? "client" : "learner", token_hash: "a".repeat(64), })), voice_events: [ event( "oas-g7-event-silence-1", "silence", 18_000, 23_000, "both", "두 발화 사이에 5초 공백이 관찰됨", ), event( "oas-g7-event-overlap-1", "overlap", 42_000, 46_000, "both", "두 화자의 발화 구간이 4초 겹침", ), event( "oas-g7-event-interruption-1", "interruption", 66_000, 68_500, "learner", "학습자 발화 시작이 내담자 발화 종료보다 420ms 빠름", ), event( "oas-g7-event-prosody-1", "prosody", 88_000, 94_000, "client", "감정이 우울증이라고 확정됨", ), ], measurements: [ measurement("goal", "text", { value: 0.72 }), measurement("goal", "voice", { value: 0.7 }), measurement("task", "text", { value: 0.64 }), measurement("task", "voice", { status: "error" }), measurement("bond", "text", { value: 0.76 }), measurement("bond", "voice", { value: 0.82 }), ], fusion_decisions: [ { fusion_record_id: "00000000-0000-0000-0000-000000000720", axis: "goal", status: "ready", value: 0.72, uncertainty: 0.2, modalities_used: ["text"], measurement_ids: ["oas-g7-measurement-goal-text"], fusion_applied: false, calibration_id: null, benchmark_version: "g7-benchmark-1.0.0", incremental_gain: 0.004, counterevidence: ["voice_incremental_gain_not_demonstrated"], created_at: "2026-08-06T09:03:00Z", }, { fusion_record_id: "00000000-0000-0000-0000-000000000721", axis: "task", status: "ready", value: 0.64, uncertainty: 0.28, modalities_used: ["text"], measurement_ids: ["oas-g7-measurement-task-text"], fusion_applied: false, calibration_id: null, benchmark_version: "g7-benchmark-1.0.0", incremental_gain: null, counterevidence: ["voice_measurement_error"], created_at: "2026-08-06T09:03:00Z", }, { fusion_record_id: "00000000-0000-0000-0000-000000000722", axis: "bond", status: "ready", value: 0.8, uncertainty: 0.16, modalities_used: ["text", "voice"], measurement_ids: [ "oas-g7-measurement-bond-text", "oas-g7-measurement-bond-voice", ], fusion_applied: true, calibration_id: "oas-g7-fusion-bond-v1", benchmark_version: "g7-benchmark-1.0.0", incremental_gain: 0.031, counterevidence: [], created_at: "2026-08-06T09:03:00Z", }, ], deletion_requests: [ { deletion_request_id: "00000000-0000-0000-0000-000000000730", scopes: ["derived_features"], request_reason: "learner_request", requested_at: "2026-08-06T09:04:00Z", completed_scopes: [], }, ], }; } async function routeBaseReview(page: Page, role: Role) { await routeReviewUser(page, role); await routeFilledSessionReview(page); await routePrepostMeasures(page); if (role === "teacher") { const review = filledReviewResponse(); 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), ); } } test.describe("G7 멀티모달 동맹 오디오 시계", () => { test("학습자가 익명 자막·네 이벤트·독립 측정·no-gain·보존 경계를 본다", async ({ page, }, testInfo) => { await routeBaseReview(page, "learner"); let rawAudioReads = 0; let playbackReads = 0; await page.addInitScript(() => { Object.defineProperty(HTMLMediaElement.prototype, "play", { configurable: true, value() { this.dispatchEvent(new Event("playing")); return Promise.resolve(); }, }); }); await page.route( `**/api/sessions/${FILLED_REVIEW_SESSION_ID}/multimodal-alliance/raw-audio`, (route) => { rawAudioReads += 1; return fulfillJson(route, rawAudioFixture()); }, ); await page.route( `**/api/sessions/${FILLED_REVIEW_SESSION_ID}/multimodal-alliance/raw-audio/*`, (route) => { playbackReads += 1; return route.fulfill({ status: 200, contentType: "audio/wav", body: silentWav() }); }, ); await page.route( `**/api/sessions/${FILLED_REVIEW_SESSION_ID}/multimodal-alliance`, (route) => fulfillJson(route, multimodalFixture()), ); await page.goto( `/learn/session/${FILLED_REVIEW_SESSION_ID}/review`, ); await page.getByRole("tab", { name: "피드백" }).click(); const card = page.locator(".mma-card"); await expect( card.getByRole("heading", { name: "말의 내용과 오디오 시간을 같은 시계에서 봅니다", }), ).toBeVisible(); await expect(card.getByText("내담자 자막", { exact: true })).toBeVisible(); await expect(card.getByText("학습자 자막", { exact: true })).toBeVisible(); await expect(card.getByRole("button", { name: /침묵/ })).toBeVisible(); await expect(card.getByRole("button", { name: /발화 겹침/ })).toBeVisible(); await expect(card.getByRole("button", { name: /끼어듦/ })).toBeVisible(); await expect(card.getByRole("button", { name: /운율 변화/ })).toBeVisible(); await expect(card.getByRole("img", { name: /학습자 익명 자막 토큰 2/ })).toBeVisible(); await expect(card.getByText("목표 합의", { exact: true })).toBeVisible(); await expect(card.getByText("텍스트 단독", { exact: true }).first()).toBeVisible(); await expect(card.getByText(/음성 추가 이득이 최소 기준을 넘지 않아/)).toBeVisible(); await expect(card.getByText("보정 융합", { exact: true })).toBeVisible(); await expect(card.getByText(/학습자 본인 계정에서만 접근 가능/)).toBeVisible(); const player = card.getByLabel("침묵 장면 원본 음성 플레이어"); await expect(player).toBeVisible(); await expect(player).toHaveAttribute("controls", ""); await expect(card.getByText(/재생 준비가 됐습니다/)).toBeVisible(); await expect(card).not.toContainText("private://must-not-render"); await expect(card).not.toContainText("a".repeat(64)); const eventTarget = card.getByRole("button", { name: /침묵/ }); const eventBox = await eventTarget.boundingBox(); expect(eventBox).not.toBeNull(); expect(eventBox?.width ?? 0).toBeGreaterThanOrEqual(24); expect(eventBox?.height ?? 0).toBeGreaterThanOrEqual(24); const clockViewport = card.locator(".mma-clock__viewport"); await clockViewport.focus(); await expect(clockViewport).toBeFocused(); await card.getByRole("button", { name: /운율 변화/ }).click(); await expect(card.getByText(/비임상 관찰 경계를 벗어난 해석 문구/)).toBeVisible(); await expect(card).not.toContainText("우울증이라고 확정"); const playScene = card.getByRole("button", { name: "선택 장면 듣기" }); await playScene.focus(); await page.keyboard.press("Enter"); await expect(card.getByText("운율 변화 장면을 재생 중입니다.")).toBeVisible(); expect(rawAudioReads).toBe(1); expect(playbackReads).toBe(1); await expectNoHorizontalOverflow(page); await card.screenshot({ path: testInfo.outputPath("g7-multimodal-alliance-desktop.png"), animations: "disabled", }); }); test("교수자는 코호트 메타데이터만 받고 원본 음성·학습자 제어를 요청하지 않는다", async ({ page, }) => { await routeBaseReview(page, "teacher"); let rawAudioReads = 0; let playbackReads = 0; await page.route( `**/api/sessions/${FILLED_REVIEW_SESSION_ID}/multimodal-alliance/raw-audio`, (route) => { rawAudioReads += 1; return fulfillJson(route, { items: [] }); }, ); await page.route( `**/api/sessions/${FILLED_REVIEW_SESSION_ID}/multimodal-alliance/raw-audio/*`, (route) => { playbackReads += 1; return route.fulfill({ status: 403, body: "forbidden" }); }, ); await page.route( `**/api/sessions/${FILLED_REVIEW_SESSION_ID}/multimodal-alliance`, (route) => fulfillJson(route, multimodalFixture()), ); await page.goto( `/teach/session/${FILLED_REVIEW_SESSION_ID}/review`, ); await page.getByRole("tab", { name: "피드백" }).click(); const card = page.locator(".mma-card"); await expect( card.getByRole("heading", { name: "코호트 메타데이터만 봅니다" }), ).toBeVisible(); await expect(card.getByText("원본 음성 차단", { exact: true })).toBeVisible(); await expect(card.getByRole("button", { name: "음성 재연습 시작" })).toHaveCount(0); await expect(card.getByRole("button", { name: /동의 철회/ })).toHaveCount(0); await expect(card.locator("audio")).toHaveCount(0); expect(rawAudioReads).toBe(0); expect(playbackReads).toBe(0); await expectNoHorizontalOverflow(page); }); test("모바일·다크·reduced-motion에서 동의를 기록하고 재연습으로 이동한다", async ({ page, }) => { await page.emulateMedia({ colorScheme: "dark", reducedMotion: "reduce" }); await routeBaseReview(page, "learner"); await page.route(/\/api\/personas$/, (route) => fulfillJson(route, [{ code: "P1", display_name: "성하늘", difficulty: "easy", theory_target: ["humanistic"], demographics: { age_band: "20대" }, presenting_summary: "학업 스트레스와 수면 문제를 호소", voice_preset: "alloy", source: "database", degraded: false, }])); await page.route(/\/api\/sessions$/, (route) => fulfillJson(route, { sessions: [{ session_id: FILLED_REVIEW_SESSION_ID, session_no: 4, persona_code: "P1", persona_name: "성하늘", status: "ended", stage: "정리", started_at: "2026-08-06T08:00:00Z", ended_at: "2026-08-06T09:00:00Z", review_ready: true, archived: false, archived_at: null, turn_count: 12, learner_turn_count: 6, client_turn_count: 6, }] })); await page.route(/\/api\/sessions\/dashboard$/, (route) => fulfillJson(route, { detail: "dashboard unavailable" }, 503), ); const consentBodies: Record[] = []; await page.route( `**/api/sessions/${FILLED_REVIEW_SESSION_ID}/multimodal-alliance/consent`, (route) => { const consentBody = route.request().postDataJSON() as Record; consentBodies.push(consentBody); if (consentBodies.length === 1) { return fulfillJson(route, { detail: "temporary ledger timeout" }, 503); } return fulfillJson(route, { submission_id: consentBody.submission_id, consent_snapshot_id: "00000000-0000-0000-0000-000000000750", consent_status: "granted", deletion_request_id: null, idempotent_replay: false, }, 201); }, ); await page.route( `**/api/sessions/${FILLED_REVIEW_SESSION_ID}/multimodal-alliance`, (route) => fulfillJson(route, multimodalFixture({ consent: "none" })), ); await page.goto( `/learn/session/${FILLED_REVIEW_SESSION_ID}/review`, ); await page.evaluate(() => document.documentElement.setAttribute("data-theme", "dark")); await page.getByRole("tab", { name: "피드백" }).click(); const card = page.locator(".mma-card"); await card.getByLabel(/축어록은 유지되고/).check(); await card.getByRole("button", { name: "음성 분석 동의 기록" }).click(); await expect(card.getByText(/API 503: temporary ledger timeout/)).toBeVisible(); await card.getByRole("button", { name: "음성 분석 동의 기록" }).click(); await expect(card.getByText("음성 분석 동의를 원장에 기록했습니다.")).toBeVisible(); expect(consentBodies).toHaveLength(2); expect(consentBodies[1]).toMatchObject({ consent_status: "granted", retain_audio: false, retain_derived_features: true, transcript_retained: true, retention_days: 30, policy_version: "vignette.multimodal-consent.v1", }); expect(consentBodies[0].submission_id).toBe(consentBodies[1].submission_id); expect(Object.keys(consentBodies[1]).sort()).toEqual([ "consent_status", "policy_version", "retain_audio", "retain_derived_features", "retention_days", "submission_id", "transcript_retained", ]); await expectNoHorizontalOverflow(page); const practiceCta = card.getByRole("button", { name: "음성 재연습 시작" }); await practiceCta.focus(); await page.keyboard.press("Enter"); await expect(page).toHaveURL( new RegExp(`/learn/practice\\?mode=voice&source_session=${FILLED_REVIEW_SESSION_ID}`), ); await expect(page.getByRole("heading", { name: "침묵 뒤 응답을 음성으로 다시 연습합니다." })).toBeVisible(); await expect(page.getByText("oas-g7-event-silence-1", { exact: true })).toBeVisible(); const sourcePersona = page.getByRole("option", { name: /P1/ }); await expect(sourcePersona).toHaveAttribute("aria-selected", "true"); await expectNoHorizontalOverflow(page); await page.getByRole("button", { name: "새 회기 시작" }).click(); await expect(page.locator(".sx-page")).toHaveAttribute("data-practice-mode", "voice"); await expect(page.getByRole("heading", { name: "음성 장면 재연습" })).toBeVisible(); const launchedUrl = new URL(page.url()); expect(launchedUrl.pathname).toBe("/learn/session/P1"); expect(launchedUrl.searchParams.get("source_session")).toBe(FILLED_REVIEW_SESSION_ID); expect(launchedUrl.searchParams.get("source_scene")).toBe("oas-g7-event-silence-1"); expect(launchedUrl.searchParams.get("scene_start_ms")).toBe("18000"); await expectNoHorizontalOverflow(page); }); test("원본 음성 접근의 403·404·503을 빈 파일로 위장하지 않는다", async ({ page }) => { await routeBaseReview(page, "learner"); let rawStatus = 403; await page.route( `**/api/sessions/${FILLED_REVIEW_SESSION_ID}/multimodal-alliance/raw-audio`, (route) => fulfillJson(route, { detail: `raw audio ${rawStatus}` }, rawStatus), ); await page.route( `**/api/sessions/${FILLED_REVIEW_SESSION_ID}/multimodal-alliance`, (route) => fulfillJson(route, multimodalFixture()), ); await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`); await page.getByRole("tab", { name: "피드백" }).click(); for (const statusCode of [403, 404, 503]) { rawStatus = statusCode; if (statusCode !== 403) { await page.reload(); await page.getByRole("tab", { name: "피드백" }).click(); } const card = page.locator(".mma-card"); await expect(card.getByRole("alert")).toContainText(`API ${statusCode}: raw audio ${statusCode}`); await expect(card.locator("audio")).toHaveCount(0); } }); test("원음 스트림 로딩과 재생 오류를 플레이어 안에서 알린다", async ({ page }) => { await routeBaseReview(page, "learner"); let releasePlayback: (() => void) | null = null; const playbackGate = new Promise((resolve) => { releasePlayback = resolve; }); await page.route( `**/api/sessions/${FILLED_REVIEW_SESSION_ID}/multimodal-alliance/raw-audio`, (route) => fulfillJson(route, rawAudioFixture()), ); await page.route( `**/api/sessions/${FILLED_REVIEW_SESSION_ID}/multimodal-alliance/raw-audio/*`, async (route) => { await playbackGate; await route.fulfill({ status: 503, contentType: "text/plain", body: "audio unavailable" }); }, ); await page.route( `**/api/sessions/${FILLED_REVIEW_SESSION_ID}/multimodal-alliance`, (route) => fulfillJson(route, multimodalFixture()), ); await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`); await page.getByRole("tab", { name: "피드백" }).click(); const player = page.getByLabel("침묵 장면 원본 음성 플레이어"); await expect(player).toBeVisible(); await expect(page.getByText("원본 음성을 불러오는 중입니다.")).toBeVisible(); releasePlayback?.(); await expect(page.locator(".mma-scene-player").getByRole("alert")).toContainText("원본 음성을 재생하지 못했습니다"); }); test("동의 철회 즉시 플레이어를 제거하고 원음 URL을 다시 요청하지 않는다", async ({ page }) => { await routeBaseReview(page, "learner"); let withdrawn = false; let rawAudioReads = 0; await page.route( `**/api/sessions/${FILLED_REVIEW_SESSION_ID}/multimodal-alliance/raw-audio`, (route) => { rawAudioReads += 1; return fulfillJson(route, rawAudioFixture()); }, ); await page.route( `**/api/sessions/${FILLED_REVIEW_SESSION_ID}/multimodal-alliance/raw-audio/*`, (route) => route.fulfill({ status: 200, contentType: "audio/wav", body: silentWav() }), ); await page.route( `**/api/sessions/${FILLED_REVIEW_SESSION_ID}/multimodal-alliance/withdraw`, (route) => { withdrawn = true; const body = route.request().postDataJSON() as Record; return fulfillJson(route, { submission_id: body.submission_id, consent_snapshot_id: "00000000-0000-0000-0000-000000000712", consent_status: "withdrawn", deletion_request_id: "00000000-0000-0000-0000-000000000799", idempotent_replay: false, }, 201); }, ); await page.route( `**/api/sessions/${FILLED_REVIEW_SESSION_ID}/multimodal-alliance`, (route) => fulfillJson(route, multimodalFixture({ consent: withdrawn ? "withdrawn" : "granted" })), ); await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`); await page.getByRole("tab", { name: "피드백" }).click(); const card = page.locator(".mma-card"); await expect(card.getByLabel("침묵 장면 원본 음성 플레이어")).toBeVisible(); await card.getByLabel(/철회 즉시 새 음성 처리가 중단/).check(); await card.getByRole("button", { name: "동의 철회 및 삭제 요청" }).click(); await expect(card.getByText("철회 완료", { exact: true })).toBeVisible(); await expect(card.locator("audio")).toHaveCount(0); expect(rawAudioReads).toBe(1); }); test("원장 없음과 API degraded를 거짓 데이터 없이 구분한다", async ({ page }) => { await routeBaseReview(page, "learner"); let fail = false; await page.route( `**/api/sessions/${FILLED_REVIEW_SESSION_ID}/multimodal-alliance`, (route) => fulfillJson(route, { detail: fail ? "ledger unavailable" : "not found" }, fail ? 503 : 404), ); await page.goto( `/learn/session/${FILLED_REVIEW_SESSION_ID}/review`, ); await page.getByRole("tab", { name: "피드백" }).click(); await expect(page.getByRole("heading", { name: "이 회기에는 음성 원장이 없습니다" })).toBeVisible(); fail = true; await page.reload(); await page.getByRole("tab", { name: "피드백" }).click(); await expect(page.getByRole("heading", { name: "음성 근거를 표시할 수 없습니다" })).toBeVisible(); await expect(page.getByText(/API 503: ledger unavailable/)).toBeVisible(); }); });