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 산출물은 커밋에서 제외했다.
688 lines
26 KiB
TypeScript
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();
|
|
});
|
|
});
|