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 산출물은 커밋에서 제외했다.
This commit is contained in:
parent
93dd8f82d7
commit
16e791e044
390 changed files with 243188 additions and 499 deletions
688
apps/web/e2e/multimodal-alliance.spec.ts
Normal file
688
apps/web/e2e/multimodal-alliance.spec.ts
Normal file
|
|
@ -0,0 +1,688 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue