vignette/apps/web/e2e/multimodal-alliance.spec.ts
Yun Chan 16e791e044 G0~G8 성과·동맹 측정 OS 작업 일괄 고정
8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본
폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을
git 이력으로 고정하는 것이 목적이다.

- contracts/routes/services: measurement, outcome_trajectory, rupture_repair,
  deliberate_practice, calibration_transfer, supervision_research,
  multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트
- infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행)
- apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가
- docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG
- scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트

engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
2026-08-08 01:30:53 +09:00

688 lines
26 KiB
TypeScript

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<string, unknown>[] = [];
await page.route(
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/multimodal-alliance/consent`,
(route) => {
const consentBody = route.request().postDataJSON() as Record<string, unknown>;
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<void>((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<string, unknown>;
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();
});
});