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:
Yun Chan 2026-08-08 01:30:53 +09:00
parent 93dd8f82d7
commit 16e791e044
390 changed files with 243188 additions and 499 deletions

View file

@ -48,6 +48,8 @@ interface AdminUsageBreakdown {
provider: string;
model: string;
turns: number;
token_metered_turns: number;
token_unmetered_turns: number;
tokens_in: number;
tokens_out: number;
cost_usd: number;
@ -86,6 +88,8 @@ interface AdminUsageResponse {
window_days: number;
total_turns: number;
metered_turns: number;
token_metered_turns: number;
token_unmetered_turns: number;
tokens_in: number;
tokens_out: number;
cost_usd: number;
@ -250,6 +254,8 @@ async function mockAdminSession(
window_days: Number(url.searchParams.get("window_days") ?? 7),
total_turns: 0,
metered_turns: 0,
token_metered_turns: 0,
token_unmetered_turns: 0,
tokens_in: 0,
tokens_out: 0,
cost_usd: 0,
@ -886,15 +892,19 @@ test.describe("admin route guards", () => {
durable: true,
generated_at: 1_783_990_800,
window_days: 30,
total_turns: 32,
metered_turns: 30,
tokens_in: 125_000,
tokens_out: 18_500,
cost_usd: 6.6212,
total_turns: 37,
metered_turns: 37,
token_metered_turns: 35,
token_unmetered_turns: 2,
tokens_in: 160_703,
tokens_out: 19_629,
cost_usd: 7.023222,
recorded_cost_usd: 6.9612,
estimated_cost_usd: 0.062022,
budget: {
limit_usd: 20,
used_ratio: 0.33106,
remaining_usd: 13.3788,
used_ratio: 0.3512,
remaining_usd: 12.976778,
status: "ok",
},
evaluator_cache: {
@ -912,15 +922,48 @@ test.describe("admin route guards", () => {
provider: "openai",
model: "gpt-5-mini",
turns: 30,
token_metered_turns: 30,
token_unmetered_turns: 0,
tokens_in: 125_000,
tokens_out: 18_500,
cost_usd: 6.6212,
recorded_cost_usd: 6.6212,
estimated_cost_usd: 0,
cost_basis: "provider_reported",
},
{
provider: "claude_cli",
model: "claude-opus-4-8",
turns: 2,
token_metered_turns: 0,
token_unmetered_turns: 2,
tokens_in: 0,
tokens_out: 0,
cost_usd: 0.34,
recorded_cost_usd: 0.34,
estimated_cost_usd: 0,
cost_basis: "provider_estimate",
},
{
provider: "agy_cli",
model: "gemini-3.6-flash-high",
turns: 5,
token_metered_turns: 5,
token_unmetered_turns: 0,
tokens_in: 35_703,
tokens_out: 1_129,
cost_usd: 0.062022,
recorded_cost_usd: 0,
estimated_cost_usd: 0.062022,
cost_basis: "reference_rate",
rate_label:
"Google Gemini 3.6 Flash 표준 단가 · 입력 $1.50/M · 캐시 $0.15/M · 출력 $7.50/M",
},
],
daily_cost: [
{ day: "2026-07-13", turns: 8, tokens_in: 32_000, tokens_out: 4_800, cost_usd: 1.42 },
{ day: "2026-07-14", turns: 10, tokens_in: 41_000, tokens_out: 6_100, cost_usd: 2.08 },
{ day: "2026-07-15", turns: 12, tokens_in: 52_000, tokens_out: 7_600, cost_usd: 3.1212 },
{ day: "2026-07-15", turns: 17, tokens_in: 87_703, tokens_out: 8_729, cost_usd: 3.183222 },
],
},
},
@ -936,10 +979,17 @@ test.describe("admin route guards", () => {
);
await expect(page.getByText("운영 DB 원장").first()).toBeVisible();
// 2026-07-27 D7: 합계 금액은 화면에 소수 2자리로 표시하고 원본 정밀도는 title 로 옮겼다.
await expect(page.locator(".aic-ledger")).toContainText("$6.62");
await expect(page.locator(".aic-ledger b").first()).toHaveAttribute("title", /6\.6212/);
await expect(page.locator(".aic-budget")).toContainText("93.8%");
await expect(page.locator(".aic-ledger")).toContainText("$7.02");
await expect(page.locator(".aic-ledger b").first()).toHaveAttribute("title", /7\.023222/);
await expect(page.locator(".aic-budget")).toContainText("94.6%");
await expect(page.locator(".aic-table")).toContainText("gpt-5-mini");
await expect(page.locator(".aic-table")).toContainText("gemini-3.6-flash-high");
await expect(page.locator(".aic-table")).toContainText("참조단가");
await expect(page.locator(".aic-table")).toContainText("SDK 추정");
await expect(page.locator(".aic-table")).toContainText("미계량");
await expect(page.locator(".aic-table")).toContainText("$0.06");
await expect(page.locator(".aic-table")).toContainText("0.9%");
await expect(page.locator(".aic-ledger")).toContainText("기록 $6.96 · 참조 $0.06");
await expect(page.locator(".aic-cache-score")).toContainText("80%");
await expect(page.getByLabel("AI 기본 모델")).toHaveValue("gateway-default");
await expect(page.getByLabel("AI 엔진 공급자").locator("option")).toHaveCount(6);

View file

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

View file

@ -102,6 +102,26 @@ async function mockSessionDetail(
}),
});
});
await page.route(`**/api/sessions/${sessionId}/alliance-pulses`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: [
{
pulse_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
checkpoint: "pre",
status: "ready",
learner_locked_at: new Date().toISOString(),
revealed_at: new Date().toISOString(),
error_code: null,
self_scores: { goal: 0.5, task: 0.5, bond: 0.5 },
measurements: [],
},
],
}),
});
});
}
test.describe("persona avatar expression rig", () => {

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,366 @@
import { expect, test, type Page } from "@playwright/test";
import { expectNoHorizontalOverflow } from "./support";
const MODEL_GATE_ID = "10000000-0000-4000-8000-000000000001";
const RELEASE_GATE_ID = "10000000-0000-4000-8000-000000000002";
const INCIDENT_ID = "10000000-0000-4000-8000-000000000003";
const CREATED_AT = "2026-08-06T08:30:00Z";
function artifact(
recordId: string,
ownerKind: "model_change_gate" | "release_gate",
ownerId: string,
kind: "baseline" | "threshold" | "provenance" | "rollback",
) {
return {
artifact_record_id: recordId,
owner_kind: ownerKind,
owner_id: ownerId,
artifact_kind: kind,
artifact_id: `oas-g8-${kind}-${ownerId.slice(-4)}`,
content_sha256: kind.slice(0, 1).repeat(64),
provenance_uri: `audit://synthetic/g8/${ownerId}/${kind}`,
created_at: CREATED_AT,
};
}
function baseView() {
return {
content_qualifications: [
{
qualification_id: "20000000-0000-4000-8000-000000000001",
pipeline_id: "20000000-0000-4000-8000-000000000002",
catalog_entry_id: "oas-g8-catalog-synthetic-rupture",
payload_sha256: "a".repeat(64),
content_kind: "rupture" as const,
difficulty_level: 3,
synthetic_identity_id: "synthetic-identity-synthetic-rupture",
source_count: 1,
red_team_review_count: 2,
benchmark_variant_count: 3,
benchmark_pass_rate: 1,
source_provenance_uris: [
"repo://apps/api/app/data/continuous_improvement/synthetic_source_pack.v2.json",
],
draft_payload: {
title: "합성 관계 균열 수선 연습",
synthetic_profile: "실존 인물과 무관한 합성 내담자",
scenario: "상담자가 주제를 너무 빨리 바꿔 합성 내담자가 서두른다고 느낀 상황",
rupture_or_challenge: "상호작용을 명명하고 내담자의 정정을 초대한다.",
learner_task: "영향을 방어하지 않고 인정한 뒤 다음 초점을 공동 결정한다.",
success_criteria: ["상호작용 명명", "정정 초대"],
source_refs: ["oas-g8-source-repo-synthetic-rupture-v2"],
grounded_claims: [
{
claim: "합성 수련 시나리오",
source_ref: "oas-g8-source-repo-synthetic-rupture-v2",
},
],
},
gate_state: "pending_human_approval",
created_at: CREATED_AT,
},
],
model_change_gates: [
{
gate_id: MODEL_GATE_ID,
gate_decision: "promote",
reasons: ["synthetic benchmark threshold passed", "coverage drift stable"],
state: "pending_human_approval",
created_at: CREATED_AT,
},
],
release_gates: [
{
gate_id: RELEASE_GATE_ID,
release_id: "oas-g8-release-2026-08-06",
qualified: true,
state: "pending_human_approval",
created_at: CREATED_AT,
},
],
gate_artifacts: [
artifact("30000000-0000-4000-8000-000000000001", "model_change_gate", MODEL_GATE_ID, "baseline"),
artifact("30000000-0000-4000-8000-000000000002", "model_change_gate", MODEL_GATE_ID, "threshold"),
artifact("30000000-0000-4000-8000-000000000003", "model_change_gate", MODEL_GATE_ID, "provenance"),
artifact("30000000-0000-4000-8000-000000000004", "model_change_gate", MODEL_GATE_ID, "rollback"),
artifact("30000000-0000-4000-8000-000000000005", "release_gate", RELEASE_GATE_ID, "baseline"),
artifact("30000000-0000-4000-8000-000000000006", "release_gate", RELEASE_GATE_ID, "threshold"),
artifact("30000000-0000-4000-8000-000000000007", "release_gate", RELEASE_GATE_ID, "provenance"),
],
approvals: [],
catalog_entries: [],
lifecycle_events: [
{
lifecycle_event_id: "40000000-0000-4000-8000-000000000003",
target_kind: "model_change_gate",
target_id: "40000000-0000-4000-8000-000000000004",
event_type: "rollback",
event_status: "requested",
evidence_refs: ["audit://synthetic/g8/rollback-requested"],
created_at: "2026-08-06T08:20:00Z",
},
{
lifecycle_event_id: "40000000-0000-4000-8000-000000000005",
target_kind: "model_change_gate",
target_id: "40000000-0000-4000-8000-000000000006",
event_type: "rollback",
event_status: "failed",
evidence_refs: ["audit://synthetic/g8/rollback-failed"],
created_at: "2026-08-06T08:10:00Z",
},
{
lifecycle_event_id: "40000000-0000-4000-8000-000000000001",
target_kind: "model_change_gate",
target_id: "40000000-0000-4000-8000-000000000002",
event_type: "rollback",
event_status: "executed",
evidence_refs: ["audit://synthetic/g8/rollback"],
created_at: "2026-08-06T08:00:00Z",
},
],
incidents: [
{
incident_record_id: INCIDENT_ID,
incident_id: "oas-g8-incident-provider-timeout",
error_fingerprint: "e".repeat(64),
affected_contract: "synthetic.replay.provider-timeout",
evidence_refs: ["audit://synthetic/g8/incident"],
pii_included: false,
created_at: CREATED_AT,
},
],
regression_dag_nodes: [
["01", "reproduction_test", "passed"],
["02", "implementation", "passed"],
["03", "e2e", "pending"],
["04", "runtime_proof", "pending"],
].map(([suffix, nodeType, nodeStatus], index, all) => ({
node_record_id: `50000000-0000-4000-8000-0000000000${suffix}`,
incident_record_id: INCIDENT_ID,
node_id: `oas-g8-node-provider-timeout-${nodeType}`,
node_type: nodeType,
depends_on_record_ids:
index === 0 ? [] : [`50000000-0000-4000-8000-0000000000${all[index - 1][0]}`],
evidence_ref: nodeStatus === "passed" ? `audit://synthetic/g8/${nodeType}` : null,
node_status: nodeStatus,
created_at: CREATED_AT,
})),
data_classification: "synthetic_replay_red_team_coverage_drift",
silent_auto_promotion_allowed: false,
raw_transcript_included: false,
pii_included: false,
clinical_claim_allowed: false,
};
}
type MockOptions = {
role?: "admin" | "teacher" | "learner";
status?: number;
empty?: boolean;
};
async function installMock(page: Page, options: MockOptions = {}) {
const view = baseView();
if (options.empty) {
view.content_qualifications = [];
view.model_change_gates = [];
view.release_gates = [];
view.gate_artifacts = [];
view.incidents = [];
view.regression_dag_nodes = [];
view.lifecycle_events = [];
}
const approvalBodies: Record<string, unknown>[] = [];
await page.route("**/api/**", async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname;
const fulfill = (body: unknown, status = 200) =>
route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
if (request.method() === "GET" && path.endsWith("/auth/me")) {
const role = options.role ?? "admin";
await fulfill({
user_id: "60000000-0000-4000-8000-000000000001",
email: `${role}@twentyoz.kr`,
display_name: `CI ${role}`,
role,
admin_access: false,
super_admin: false,
account_status: "approved",
approval_required: false,
cohort_ids: [],
consent_at: 1_754_378_000,
onboarding_completed_at: 1_754_378_000,
nickname: "",
self_introduction: "",
avatar_url: "",
});
return;
}
if (request.method() === "GET" && path.endsWith("/continuous-improvement")) {
if (options.status && options.status !== 200) {
await fulfill({ detail: "continuous improvement read model unavailable" }, options.status);
return;
}
await fulfill(view);
return;
}
if (request.method() === "POST" && path.endsWith("/continuous-improvement/approvals")) {
const body = request.postDataJSON() as Record<string, string>;
approvalBodies.push(body);
view.approvals.push({
approval_event_id: body.approval_event_id,
target_kind: body.target_kind as "content_qualification" | "model_change_gate",
target_id: body.target_id,
decision: body.decision as "approve_content" | "approve_promotion",
reason_code: body.reason_code,
evidence_refs: body.evidence_refs as unknown as string[],
created_at: CREATED_AT,
});
if (body.target_kind === "content_qualification") {
view.catalog_entries.unshift({
catalog_record_id: body.effect_record_id,
qualification_id: body.target_id,
catalog_entry_id: "oas-g8-catalog-synthetic-rupture",
status: "approved",
clinical_claim_allowed: false,
created_at: CREATED_AT,
});
} else {
view.lifecycle_events.unshift({
lifecycle_event_id: body.effect_record_id,
target_kind: body.target_kind as "model_change_gate",
target_id: body.target_id,
event_type: "promotion",
event_status: "approved",
evidence_refs: body.evidence_refs as unknown as string[],
created_at: CREATED_AT,
});
}
await fulfill(
{
submission_id: body.submission_id,
approval_event_id: body.approval_event_id,
target_kind: body.target_kind,
target_id: body.target_id,
decision: body.decision,
effect_record_id: body.effect_record_id,
idempotent_replay: false,
},
201,
);
return;
}
await fulfill({ detail: `unmocked ${request.method()} ${path}` }, 404);
});
return { view, approvalBodies };
}
test.describe("continuous improvement admin cockpit", () => {
test("renders the generated-content, gate, incident and rollback ledgers fail-closed", async ({
page,
}, testInfo) => {
await page.emulateMedia({ colorScheme: "dark", reducedMotion: "reduce" });
await page.addInitScript(() => localStorage.setItem("vignette.theme", "dark"));
const mock = await installMock(page);
await page.goto("/admin/continuous-improvement");
await expect(page.getByRole("heading", { name: "승격보다 근거를 먼저 본다" })).toBeVisible();
await expect(page.getByText("합성 콘텐츠 검증 파이프라인")).toBeVisible();
await expect(page.getByText("모델 변경 · 릴리스 게이트")).toBeVisible();
await expect(page.getByText("사건 회귀 DAG")).toBeVisible();
await expect(page.getByText("모니터 · 롤백 원장")).toBeVisible();
await expect(page.getByText("롤백 실행 대기 · 실행기 미구성", { exact: true })).toBeVisible();
await expect(page.getByText("롤백 실행 실패", { exact: true })).toBeVisible();
await expect(page.getByText("롤백은 실행됐지만 verification 이벤트가 아직 없어")).toBeVisible();
const contentCandidate = page.locator(
'[data-qualification-id="20000000-0000-4000-8000-000000000001"]',
);
await expect(contentCandidate.getByText("합성 관계 균열 수선 연습")).toBeVisible();
await contentCandidate.getByText("검수 payload 펼쳐 보기").click();
await expect(contentCandidate.getByText("영향을 방어하지 않고 인정한 뒤")).toBeVisible();
await expect(contentCandidate).not.toContainText("hidden_answer");
const contentReason = contentCandidate.getByLabel("콘텐츠 승인 사유");
await expect(contentCandidate.getByRole("button", { name: "카탈로그 승인" })).toBeDisabled();
await contentReason.fill("합성 경계와 수련 목표를 직접 검수함");
await contentReason.press("Enter");
await expect(contentCandidate.getByText("카탈로그 승인 원장 기록됨")).toBeVisible();
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
await page.getByTestId("continuous-improvement-cockpit").screenshot({
path: testInfo.outputPath("g8-continuous-improvement-before-approval.png"),
animations: "disabled",
});
const missingGate = page.locator(`[data-gate-id="${RELEASE_GATE_ID}"]`);
await expect(missingGate.getByRole("button", { name: "증거 4종 미완료" })).toBeDisabled();
await expect(missingGate.getByText("필수 증거 4종이 모두 있어야 기록 가능")).toBeVisible();
const modelGate = page.locator(`[data-gate-id="${MODEL_GATE_ID}"]`);
const approvalButton = modelGate.getByRole("button", { name: "사람 승인 기록" });
const approvalReason = modelGate.getByLabel("사람 승인 사유");
await expect(approvalButton).toBeDisabled();
await expect(modelGate.getByText("승인 사유를 먼저 입력해야 함")).toBeVisible();
await approvalReason.fill("기준선과 rollback runbook을 독립 검토함");
await expect(approvalButton).toBeEnabled();
await expect(modelGate.getByText("증거 4종과 승인 사유가 준비됨")).toBeVisible();
await approvalButton.focus();
await expect(approvalButton).toBeFocused();
await approvalReason.focus();
await approvalReason.press("Enter");
await expect(modelGate.getByText("append-only 승인 원장 기록됨")).toBeVisible();
expect(mock.approvalBodies).toHaveLength(2);
expect(mock.approvalBodies[0]).toMatchObject({
target_kind: "content_qualification",
target_id: "20000000-0000-4000-8000-000000000001",
decision: "approve_content",
reason_code: "합성 경계와 수련 목표를 직접 검수함",
});
expect(mock.approvalBodies[1]).toMatchObject({
target_kind: "model_change_gate",
target_id: MODEL_GATE_ID,
decision: "approve_promotion",
reason_code: "기준선과 rollback runbook을 독립 검토함",
});
expect(mock.approvalBodies[1].evidence_refs).toHaveLength(4);
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath("g8-continuous-improvement-cockpit.png"),
fullPage: true,
animations: "disabled",
});
});
test("shows empty and degraded states without opening an approval action", async ({ page }) => {
await installMock(page, { empty: true });
await page.goto("/admin/continuous-improvement");
await expect(page.getByText("검토할 콘텐츠 후보가 없어")).toBeVisible();
await expect(page.getByText("대기 중인 게이트가 없어")).toBeVisible();
await expect(page.getByText("등록된 운영 사건이 없어")).toBeVisible();
await expect(page.getByRole("button", { name: /승인 기록|결정 기록/ })).toHaveCount(0);
await page.unroute("**/api/**");
await installMock(page, { status: 503 });
await page.reload();
await expect(page.getByRole("heading", { name: "개선 원장을 확인할 수 없어" })).toBeVisible();
await expect(page.getByText(/승인 동작은 닫힌 상태/)).toBeVisible();
await expectNoHorizontalOverflow(page);
});
for (const role of ["teacher", "learner"] as const) {
test(`does not expose the admin cockpit to ${role}`, async ({ page }) => {
await installMock(page, { role });
await page.goto("/admin/continuous-improvement");
await expect(page).toHaveURL(role === "teacher" ? /\/teach$/ : /\/learn$/);
await expect(page.getByTestId("continuous-improvement-cockpit")).toHaveCount(0);
});
}
});

View file

@ -0,0 +1,518 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type APIResponse, type Page } from "@playwright/test";
import { expectNoHorizontalOverflow, useRealApi } from "./support";
const DATA_CLASSIFICATION = "synthetic_replay_red_team_coverage_drift";
const INTERNAL_HEADER = "X-Vignette-Continuous-Improvement-Token";
const SOURCE_PATH = path.resolve(
process.cwd(),
"../api/app/data/continuous_improvement/synthetic_source_pack.v2.json",
);
type Role = "admin" | "teacher" | "learner";
interface RepoSourceSpec {
data_classification: typeof DATA_CLASSIFICATION;
content_kind: "case" | "rupture" | "practice" | "benchmark";
difficulty_level: number;
variant_count: number;
prompt_version: string;
source_packs: Array<{
artifact: {
source_id: string;
version: string;
content_sha256: string;
provenance_uri: string;
usage_status: "approved";
citation_label: string;
};
content: string;
}>;
}
interface AgenticPipelineResponse {
submission_id: string;
pipeline_id: string;
qualification_id: string;
candidate_catalog_entry_id: string;
state: "pending_human_approval";
human_approval_required: true;
catalog_promoted: false;
idempotent_replay: boolean;
clinical_claim_allowed: false;
agent_calls_executed: number;
}
interface ContinuousImprovementView {
content_qualifications: Array<{
qualification_id: string;
catalog_entry_id: string;
source_provenance_uris: string[];
draft_payload: Record<string, unknown> | null;
}>;
approvals: Array<{
approval_event_id: string;
target_kind: string;
target_id: string;
decision: string;
}>;
catalog_entries: Array<{
catalog_record_id: string;
qualification_id: string;
catalog_entry_id: string;
status: "approved";
clinical_claim_allowed: false;
}>;
data_classification: typeof DATA_CLASSIFICATION;
silent_auto_promotion_allowed: false;
raw_transcript_included: false;
pii_included: false;
clinical_claim_allowed: false;
}
interface ApprovedCatalogResponse {
entries: Array<{
catalog_record_id: string;
qualification_id: string;
catalog_entry_id: string;
payload: Record<string, unknown>;
status: "approved";
clinical_claim_allowed: false;
}>;
data_classification: typeof DATA_CLASSIFICATION;
human_approval_required: true;
raw_transcript_included: false;
pii_included: false;
clinical_claim_allowed: false;
}
async function expectOk(response: APIResponse) {
expect(response.ok(), await response.text()).toBeTruthy();
}
function suffixFor(testInfo: { workerIndex: number; retry: number }) {
return `live-${Date.now().toString(36)}-${testInfo.workerIndex}-${testInfo.retry}`;
}
async function signIn(page: Page, role: Role, suffix: string) {
const domain = role === "admin" ? "twentyoz.kr" : "hs.ac.kr";
const login = await page.request.post("/api/auth/dev-login", {
data: {
email: `g8-${role}-${suffix}@${domain}`,
role,
display_name: `G8 ${role}`,
cohort_ids: ["e2e-hanshin"],
},
});
await expectOk(login);
const onboarding = await page.request.post("/api/users/me/onboarding", {
data: {
legal_name: `G8 ${role}`,
affiliation: "한신대학교",
department: role === "admin" ? "운영" : "상담심리학과",
grade_level: role === "admin" ? "관리자" : role === "teacher" ? "교수" : "3학년",
phone: "010-0000-0000",
contact_address: "경기도 오산시 한신대학교",
nickname: `G8 ${role}`,
self_introduction: "합성 지속 개선 게이트 검증 계정입니다.",
avatar_url: "",
terms_accepted: true,
privacy_accepted: true,
},
});
await expectOk(onboarding);
}
function matching<T extends { qualification_id: string }>(
rows: T[],
qualificationId: string,
): T[] {
return rows.filter((row) => row.qualification_id === qualificationId);
}
function assertNoSensitivePayload(value: unknown, location = "response") {
if (Array.isArray(value)) {
value.forEach((item, index) => assertNoSensitivePayload(item, `${location}[${index}]`));
return;
}
if (!value || typeof value !== "object") return;
const record = value as Record<string, unknown>;
const forbidden = [
"hidden_answer",
"raw_transcript",
"transcript",
"utterance_text",
"clinical_diagnosis",
"treatment_plan",
];
for (const key of forbidden) {
expect(record, `${location} exposed ${key}`).not.toHaveProperty(key);
}
for (const key of ["raw_transcript_included", "pii_included", "clinical_claim_allowed"]) {
if (key in record) expect(record[key], `${location}.${key}`).toBe(false);
}
for (const [key, child] of Object.entries(record)) {
assertNoSensitivePayload(child, `${location}.${key}`);
}
}
test.describe("continuous improvement human catalog gate (real API/DB)", () => {
test.beforeEach(async ({ page }) => {
await useRealApi(page);
});
test("reviews a repo-approved agentic payload in the admin browser and exposes exactly one approved catalog entry @single-run", async ({
page,
}, testInfo) => {
test.setTimeout(8 * 60_000);
test.skip(
process.env.E2E_G8_AGENTIC_APPROVAL !== "1",
"Set E2E_G8_AGENTIC_APPROVAL=1 for the explicit real-engine approval gate.",
);
const internalToken = process.env.E2E_CONTINUOUS_IMPROVEMENT_INTERNAL_TOKEN ?? "";
expect(internalToken.length, "G8 internal token must be injected without logging it").toBeGreaterThanOrEqual(
32,
);
const healthResponse = await page.request.get("/api/health");
await expectOk(healthResponse);
const health = (await healthResponse.json()) as { db: boolean; engine: boolean };
expect(health).toMatchObject({ db: true, engine: true });
const sourceSpec = JSON.parse(await readFile(SOURCE_PATH, "utf8")) as RepoSourceSpec;
expect(sourceSpec.data_classification).toBe(DATA_CLASSIFICATION);
expect(sourceSpec.source_packs).toHaveLength(1);
expect(sourceSpec.source_packs[0].artifact).toMatchObject({
usage_status: "approved",
provenance_uri:
"repo://apps/api/app/data/continuous_improvement/synthetic_source_pack.v2.json",
});
const suffix = suffixFor(testInfo);
const requestBody = {
submission_id: crypto.randomUUID(),
pipeline_id: crypto.randomUUID(),
benchmark_record_id: crypto.randomUUID(),
qualification_id: crypto.randomUUID(),
data_classification: sourceSpec.data_classification,
source_packs: sourceSpec.source_packs,
content_kind: sourceSpec.content_kind,
difficulty_level: sourceSpec.difficulty_level,
variant_count: sourceSpec.variant_count,
prompt_version: sourceSpec.prompt_version,
};
const agenticPath = "/api/internal/continuous-improvement/agentic-content-pipelines";
const internalHeaders = { [INTERNAL_HEADER]: internalToken };
const create = await page.request.post(agenticPath, {
data: requestBody,
headers: internalHeaders,
timeout: 5 * 60_000,
});
await expectOk(create);
expect(create.status()).toBe(201);
const candidate = (await create.json()) as AgenticPipelineResponse;
expect(candidate).toMatchObject({
submission_id: requestBody.submission_id,
qualification_id: requestBody.qualification_id,
state: "pending_human_approval",
human_approval_required: true,
catalog_promoted: false,
idempotent_replay: false,
clinical_claim_allowed: false,
});
expect(candidate.agent_calls_executed).toBeGreaterThanOrEqual(7);
const agenticReplay = await page.request.post(agenticPath, {
data: requestBody,
headers: internalHeaders,
timeout: 60_000,
});
await expectOk(agenticReplay);
const replayedCandidate = (await agenticReplay.json()) as AgenticPipelineResponse;
expect(replayedCandidate).toMatchObject({
qualification_id: candidate.qualification_id,
candidate_catalog_entry_id: candidate.candidate_catalog_entry_id,
idempotent_replay: true,
agent_calls_executed: 0,
});
const changedAgentic = await page.request.post(agenticPath, {
data: { ...requestBody, difficulty_level: requestBody.difficulty_level === 5 ? 4 : 5 },
headers: internalHeaders,
timeout: 60_000,
});
expect(changedAgentic.status(), await changedAgentic.text()).toBe(409);
const approvalBody = {
submission_id: crypto.randomUUID(),
approval_event_id: crypto.randomUUID(),
effect_record_id: crypto.randomUUID(),
target_kind: "content_qualification",
target_id: candidate.qualification_id,
decision: "approve_content",
reason_code: "repo-approved synthetic payload의 경계와 수련 목표를 브라우저에서 검수함",
evidence_refs: [
sourceSpec.source_packs[0].artifact.provenance_uri,
`audit://continuous-improvement/human-review/${candidate.qualification_id}`,
],
};
for (const role of ["learner", "teacher"] as const) {
await signIn(page, role, `${suffix}-${role}`);
const blockedRead = await page.request.get("/api/continuous-improvement");
expect(blockedRead.status(), await blockedRead.text()).toBe(403);
const blockedCatalog = await page.request.get("/api/continuous-improvement/catalog");
expect(blockedCatalog.status(), await blockedCatalog.text()).toBe(403);
const blockedApproval = await page.request.post("/api/continuous-improvement/approvals", {
data: approvalBody,
});
expect(blockedApproval.status(), await blockedApproval.text()).toBe(403);
}
await signIn(page, "admin", `${suffix}-admin`);
const beforeViewResponse = await page.request.get("/api/continuous-improvement");
await expectOk(beforeViewResponse);
const beforeView = (await beforeViewResponse.json()) as ContinuousImprovementView;
assertNoSensitivePayload(beforeView);
expect(matching(beforeView.catalog_entries, candidate.qualification_id)).toHaveLength(0);
const qualification = beforeView.content_qualifications.find(
(item) => item.qualification_id === candidate.qualification_id,
);
expect(qualification).toBeTruthy();
expect(qualification?.draft_payload).not.toBeNull();
expect(qualification?.source_provenance_uris).toContain(
sourceSpec.source_packs[0].artifact.provenance_uri,
);
const beforeCatalogResponse = await page.request.get("/api/continuous-improvement/catalog");
await expectOk(beforeCatalogResponse);
const beforeCatalog = (await beforeCatalogResponse.json()) as ApprovedCatalogResponse;
assertNoSensitivePayload(beforeCatalog);
expect(matching(beforeCatalog.entries, candidate.qualification_id)).toHaveLength(0);
await page.goto("/admin/continuous-improvement");
await expect(page.getByRole("heading", { name: "승격보다 근거를 먼저 본다" })).toBeVisible();
const card = page.locator(`[data-qualification-id="${candidate.qualification_id}"]`);
await expect(card).toBeVisible();
await expect(card.getByText(candidate.candidate_catalog_entry_id)).toBeVisible();
await card.getByText("검수 payload 펼쳐 보기").click();
await expect(card.getByRole("heading", { name: "상황과 도전" })).toBeVisible();
await expect(card).not.toContainText("hidden_answer");
await expect(card).not.toContainText("raw_transcript");
const reason = card.getByLabel("콘텐츠 승인 사유");
const approveButton = card.getByRole("button", { name: "카탈로그 승인" });
await expect(approveButton).toBeDisabled();
await reason.fill(approvalBody.reason_code);
await expect(approveButton).toBeEnabled();
const approvalResponsePromise = page.waitForResponse((response) => {
if (response.request().method() !== "POST") return false;
const url = new URL(response.url());
if (!url.pathname.endsWith("/continuous-improvement/approvals")) return false;
const body = response.request().postDataJSON() as { target_id?: string };
return body.target_id === candidate.qualification_id;
});
await approveButton.focus();
await expect(approveButton).toBeFocused();
await approveButton.press("Enter");
const approvalResponse = await approvalResponsePromise;
await expectOk(approvalResponse);
const browserApprovalBody = approvalResponse.request().postDataJSON() as typeof approvalBody;
await expect(card.getByText("카탈로그 승인 원장 기록됨")).toBeVisible({ timeout: 15_000 });
await expectNoHorizontalOverflow(page);
const afterCatalogResponse = await page.request.get("/api/continuous-improvement/catalog");
await expectOk(afterCatalogResponse);
const afterCatalog = (await afterCatalogResponse.json()) as ApprovedCatalogResponse;
assertNoSensitivePayload(afterCatalog);
const consumed = matching(afterCatalog.entries, candidate.qualification_id);
expect(consumed).toHaveLength(1);
expect(consumed[0]).toMatchObject({
catalog_entry_id: candidate.candidate_catalog_entry_id,
catalog_record_id: browserApprovalBody.effect_record_id,
status: "approved",
clinical_claim_allowed: false,
});
const approvalReplay = await page.request.post("/api/continuous-improvement/approvals", {
data: browserApprovalBody,
});
await expectOk(approvalReplay);
expect(approvalReplay.status()).toBe(201);
expect(await approvalReplay.json()).toMatchObject({ idempotent_replay: true });
const changedApproval = await page.request.post("/api/continuous-improvement/approvals", {
data: { ...browserApprovalBody, reason_code: `${browserApprovalBody.reason_code} 변경` },
});
expect(changedApproval.status(), await changedApproval.text()).toBe(409);
const finalViewResponse = await page.request.get("/api/continuous-improvement");
await expectOk(finalViewResponse);
const finalView = (await finalViewResponse.json()) as ContinuousImprovementView;
assertNoSensitivePayload(finalView);
expect(
finalView.approvals.filter(
(item) =>
item.target_kind === "content_qualification" &&
item.target_id === candidate.qualification_id,
),
).toHaveLength(1);
expect(matching(finalView.catalog_entries, candidate.qualification_id)).toHaveLength(1);
await testInfo.attach("g8-approved-catalog", {
body: await page.screenshot({ fullPage: true }),
contentType: "image/png",
});
console.log(
`G8_LIVE_EVIDENCE ${JSON.stringify({
qualification_id: candidate.qualification_id,
catalog_entry_id: candidate.candidate_catalog_entry_id,
catalog_record_id: browserApprovalBody.effect_record_id,
approval_event_id: browserApprovalBody.approval_event_id,
agent_calls_executed: candidate.agent_calls_executed,
catalog_before: 0,
catalog_after: consumed.length,
final_approval_events: finalView.approvals.filter(
(item) =>
item.target_kind === "content_qualification" &&
item.target_id === candidate.qualification_id,
).length,
})}`,
);
});
test("approves an existing Claude-qualified pending payload without another model call @single-run", async ({
page,
}, testInfo) => {
test.setTimeout(2 * 60_000);
const qualificationId = process.env.E2E_G8_EXISTING_QUALIFICATION_ID ?? "";
test.skip(!qualificationId, "Set E2E_G8_EXISTING_QUALIFICATION_ID to review an existing candidate.");
const healthResponse = await page.request.get("/api/health");
await expectOk(healthResponse);
expect((await healthResponse.json()) as { db: boolean; engine: boolean }).toMatchObject({
db: true,
engine: true,
});
const suffix = suffixFor(testInfo);
for (const role of ["learner", "teacher"] as const) {
await signIn(page, role, `${suffix}-existing-${role}`);
const blockedRead = await page.request.get("/api/continuous-improvement");
expect(blockedRead.status(), await blockedRead.text()).toBe(403);
const blockedCatalog = await page.request.get("/api/continuous-improvement/catalog");
expect(blockedCatalog.status(), await blockedCatalog.text()).toBe(403);
}
await signIn(page, "admin", `${suffix}-existing-admin`);
const beforeViewResponse = await page.request.get("/api/continuous-improvement");
await expectOk(beforeViewResponse);
const beforeView = (await beforeViewResponse.json()) as ContinuousImprovementView;
assertNoSensitivePayload(beforeView);
const pending = beforeView.content_qualifications.find(
(item) => item.qualification_id === qualificationId,
);
expect(pending, "the requested existing qualification must still be pending").toBeTruthy();
expect(pending?.draft_payload).not.toBeNull();
expect(pending?.source_provenance_uris.length).toBeGreaterThan(0);
expect(pending?.source_provenance_uris.every((uri) => uri.startsWith("repo://"))).toBeTruthy();
expect(matching(beforeView.catalog_entries, qualificationId)).toHaveLength(0);
const beforeCatalogResponse = await page.request.get("/api/continuous-improvement/catalog");
await expectOk(beforeCatalogResponse);
const beforeCatalog = (await beforeCatalogResponse.json()) as ApprovedCatalogResponse;
assertNoSensitivePayload(beforeCatalog);
expect(matching(beforeCatalog.entries, qualificationId)).toHaveLength(0);
await page.goto("/admin/continuous-improvement");
const card = page.locator(`[data-qualification-id="${qualificationId}"]`);
await expect(card).toBeVisible();
await card.getByText("검수 payload 펼쳐 보기").click();
await expect(card.getByRole("heading", { name: "상황과 도전" })).toBeVisible();
await expect(card).not.toContainText("hidden_answer");
await expect(card).not.toContainText("raw_transcript");
const reasonText = "기존 실제 Claude 합성 후보의 visible payload와 repo 근거를 관리자 브라우저에서 검수함";
const reason = card.getByLabel("콘텐츠 승인 사유");
const approveButton = card.getByRole("button", { name: "카탈로그 승인" });
await expect(approveButton).toBeDisabled();
await reason.fill(reasonText);
await expect(approveButton).toBeEnabled();
const approvalResponsePromise = page.waitForResponse((response) => {
if (response.request().method() !== "POST") return false;
const url = new URL(response.url());
if (!url.pathname.endsWith("/continuous-improvement/approvals")) return false;
const body = response.request().postDataJSON() as { target_id?: string };
return body.target_id === qualificationId;
});
await approveButton.focus();
await expect(approveButton).toBeFocused();
await approveButton.press("Enter");
const approvalResponse = await approvalResponsePromise;
await expectOk(approvalResponse);
const approvalBody = approvalResponse.request().postDataJSON() as {
submission_id: string;
approval_event_id: string;
effect_record_id: string;
target_kind: "content_qualification";
target_id: string;
decision: "approve_content";
reason_code: string;
evidence_refs: string[];
};
expect(approvalBody.reason_code).toBe(reasonText);
await expect(card.getByText("카탈로그 승인 원장 기록됨")).toBeVisible({ timeout: 15_000 });
await expectNoHorizontalOverflow(page);
const afterCatalogResponse = await page.request.get("/api/continuous-improvement/catalog");
await expectOk(afterCatalogResponse);
const afterCatalog = (await afterCatalogResponse.json()) as ApprovedCatalogResponse;
assertNoSensitivePayload(afterCatalog);
const consumed = matching(afterCatalog.entries, qualificationId);
expect(consumed).toHaveLength(1);
expect(consumed[0]).toMatchObject({
catalog_entry_id: pending?.catalog_entry_id,
catalog_record_id: approvalBody.effect_record_id,
status: "approved",
clinical_claim_allowed: false,
});
const approvalReplay = await page.request.post("/api/continuous-improvement/approvals", {
data: approvalBody,
});
await expectOk(approvalReplay);
expect(await approvalReplay.json()).toMatchObject({ idempotent_replay: true });
const changedApproval = await page.request.post("/api/continuous-improvement/approvals", {
data: { ...approvalBody, reason_code: `${reasonText} 변경` },
});
expect(changedApproval.status(), await changedApproval.text()).toBe(409);
const finalViewResponse = await page.request.get("/api/continuous-improvement");
await expectOk(finalViewResponse);
const finalView = (await finalViewResponse.json()) as ContinuousImprovementView;
assertNoSensitivePayload(finalView);
const approvalCount = finalView.approvals.filter(
(item) => item.target_kind === "content_qualification" && item.target_id === qualificationId,
).length;
expect(approvalCount).toBe(1);
expect(matching(finalView.catalog_entries, qualificationId)).toHaveLength(1);
await testInfo.attach("g8-existing-approved-catalog", {
body: await page.screenshot({ fullPage: true }),
contentType: "image/png",
});
console.log(
`G8_EXISTING_LIVE_EVIDENCE ${JSON.stringify({
qualification_id: qualificationId,
catalog_entry_id: pending?.catalog_entry_id,
catalog_record_id: approvalBody.effect_record_id,
approval_event_id: approvalBody.approval_event_id,
catalog_before: 0,
catalog_after: consumed.length,
final_approval_events: approvalCount,
new_model_calls: 0,
})}`,
);
});
});

View file

@ -0,0 +1,882 @@
import { expect, test, type Page, type Route } from "@playwright/test";
import type {
DeliberatePracticeReadModel,
PracticeEpisodeItem,
PracticePrescriptionItem,
} from "../src/pages/session-review/deliberatePracticeApi";
import {
parsePracticeLaunchIntent,
practiceLaunchSearch,
} from "../src/lib/practiceLaunchIntent";
import {
FILLED_REVIEW_SESSION_ID,
filledReviewResponse,
routePrepostMeasures,
} from "./session-review-fixture";
import { expectNoHorizontalOverflow } from "./support";
type ReviewRole = "learner" | "teacher";
type PracticeMode = PracticePrescriptionItem["activity_mode"];
const LEARNER_ID = "51000000-0000-4000-8000-000000000001";
const TURN_UUIDS = [
"52000000-0000-4000-8000-000000000001",
"52000000-0000-4000-8000-000000000002",
"52000000-0000-4000-8000-000000000003",
"52000000-0000-4000-8000-000000000004",
"52000000-0000-4000-8000-000000000005",
"52000000-0000-4000-8000-000000000006",
];
async function fulfillJson(route: Route, body: unknown, status = 200) {
await route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify(body),
});
}
function activityFor(mode: PracticeMode, index: number) {
const common = {
scenario_variant_id: `variant-${mode}-${index}`,
scenario_novelty: "familiar" as const,
difficulty_level: index,
};
if (mode === "replay") {
return {
...common,
mode,
launch_intent: "practice.replay.launch" as const,
pause_at_evidence_ref: TURN_UUIDS[1],
};
}
if (mode === "branch") {
return {
...common,
mode,
launch_intent: "practice.branch.launch" as const,
branch_options: ["감정 확인", "의미 확인"],
client_responses_hidden: true as const,
};
}
if (mode === "constrained_response") {
return {
...common,
mode,
launch_intent: "practice.constrained-response.launch" as const,
required_moves: ["정서 반영", "준비도 확인"],
max_words: 24,
};
}
if (mode === "voice_retry") {
return {
...common,
mode,
launch_intent: "practice.voice-retry.launch" as const,
acoustic_focus: ["쉼", "말 속도"],
max_seconds: 20,
};
}
return {
...common,
mode,
launch_intent: "practice.difficulty-ladder.launch" as const,
steps: [
{
level: 1,
scenario_variant_id: "ladder-familiar",
scenario_novelty: "familiar" as const,
variation: "내담자가 짧게 답하는 장면",
},
{
level: 2,
scenario_variant_id: "ladder-unseen",
scenario_novelty: "unseen_transfer" as const,
variation: "내담자가 개입 의도를 되묻는 장면",
},
],
};
}
function prescription(
mode: PracticeMode,
index: number,
): PracticePrescriptionItem {
const competencyId =
mode === "replay" ? "competency.pacing" : `competency.${mode}`;
const prescriptionId = `oas-g4-practice-${mode}`;
const criterionId = `criterion.${mode}`;
const observable =
mode === "replay"
? "조언을 제시하기 전에 내담자의 준비도를 한 문장으로 확인한다."
: `${mode} 장면에서 내담자의 반응을 확인하는 한 행동을 수행한다.`;
return {
prescription_record_id: `53000000-0000-4000-8000-00000000000${index}`,
prescription_key: prescriptionId,
session_id: FILLED_REVIEW_SESSION_ID,
competency_id: competencyId,
criterion_id: criterionId,
observable_behavior: observable,
activity_mode: mode,
scenario_variant_id: `variant-${mode}-${index}`,
scenario_novelty: "familiar",
difficulty_level: index,
prescription_payload: {
schema_version: "vignette.practice-prescription.v1",
event_name: "practice.prescribed",
prescription_id: prescriptionId,
coaching_card_id: `oas-g4-card-${mode}`,
scene_id: `scene-${mode}`,
competency_id: competencyId,
criterion_id: criterionId,
observable_behavior: observable,
activity: activityFor(mode, index),
can_launch: true,
evidence_refs: [
{
ref_id: TURN_UUIDS[1],
scene_id: "review-scene",
turn_index: 2,
actor: "learner",
kind: "learner_behavior",
},
{
ref_id: TURN_UUIDS[2],
scene_id: "review-scene",
turn_index: 3,
actor: "client",
kind: "client_response",
},
],
source_refs: ["synthetic:g4-e2e:v1"],
uncertainty: mode === "replay" ? 0.28 : 0.4,
counterevidence:
mode === "replay"
? ["회기 말에는 내담자가 개입 제안에 스스로 답한 장면도 있습니다."]
: [],
},
coach_claim:
mode === "replay"
? "개입 방향은 적절했지만 내담자의 준비도를 확인하기 전에 제안이 먼저 나왔습니다."
: "다른 반응 조건에서도 같은 행동이 유지되는지 확인합니다.",
card_key: `oas-g4-card-${mode}`,
evidence_turn_ids: [TURN_UUIDS[1], TURN_UUIDS[2]],
source_refs: ["synthetic:g4-e2e:v1"],
uncertainty: mode === "replay" ? 0.28 : 0.4,
counterevidence:
mode === "replay"
? ["회기 말에는 내담자가 개입 제안에 스스로 답한 장면도 있습니다."]
: [],
created_at: "2026-08-06T10:00:00Z",
};
}
function episode(): PracticeEpisodeItem {
return {
episode_submission_id: "54000000-0000-4000-8000-000000000001",
episode_key: "episode-replay-familiar",
session_id: FILLED_REVIEW_SESSION_ID,
progress: "transfer_pending",
mastery_allowed: false,
mastery_blockers: ["unseen_transfer_not_verified"],
uncertainty: 0.28,
evidence_turn_ids: [TURN_UUIDS[1], TURN_UUIDS[2]],
counterevidence: ["unseen_transfer_not_verified"],
assessment_payload: {
prescription_id: "oas-g4-practice-replay",
competency_id: "competency.pacing",
} as unknown as PracticeEpisodeItem["assessment_payload"],
created_at: "2026-08-06T10:05:00Z",
attempts: [
{
attempt_record_id: "55000000-0000-4000-8000-000000000001",
attempt_key: "attempt-replay-1",
episode_submission_id: "54000000-0000-4000-8000-000000000001",
sequence_no: 1,
scenario_variant_id: "variant-replay-1",
scenario_novelty: "familiar",
difficulty_level: 1,
criterion_status: "observed",
client_response: "engaged",
outcome: "passed",
utterance_template_id: "utterance-sha256:e2e-familiar",
learner_claimed_success: true,
uncertainty: 0.28,
evidence_turn_ids: [TURN_UUIDS[1], TURN_UUIDS[2]],
counterevidence: [],
attempt_payload: {},
created_at: "2026-08-06T10:05:00Z",
corrections: [],
},
],
};
}
function practiceReadModel(): DeliberatePracticeReadModel {
const modes: PracticeMode[] = [
"replay",
"branch",
"constrained_response",
"voice_retry",
"difficulty_ladder",
];
return {
learner_id: LEARNER_ID,
clinical_claim_allowed: false,
prescriptions: modes.map((mode, index) => prescription(mode, index + 1)),
episodes: [episode()],
competency_graph: {
schema_version: "vignette.competency-graph.v1",
definitions: modes.map((mode) => ({
competency_id:
mode === "replay" ? "competency.pacing" : `competency.${mode}`,
label_ko: mode === "replay" ? "개입 전 준비도 확인" : `${mode} 역량`,
description:
mode === "replay"
? "제안보다 먼저 내담자가 지금 다룰 준비가 되었는지 확인하는 역량입니다."
: "다른 장면에서 하나의 행동을 유지하는 연습 역량입니다.",
prerequisite_ids: [],
})),
states: modes.map((mode, index) => ({
competency_id:
mode === "replay" ? "competency.pacing" : `competency.${mode}`,
band: mode === "replay" ? "fragile" : "developing",
forgetting_risk: mode === "replay" ? 0.82 : 0.45 - index * 0.04,
uncertainty: mode === "replay" ? 0.28 : 0.4,
attempt_count: mode === "replay" ? 1 : 0,
familiar_demonstrations: mode === "replay" ? 1 : 0,
unseen_transfer_demonstrations: 0,
highest_familiar_difficulty: mode === "replay" ? 1 : 0,
evidence_refs: [],
counterevidence: [],
})),
},
snapshot_id: "56000000-0000-4000-8000-000000000001",
snapshot_no: 2,
next_practice: {
schema_version: "vignette.curriculum-decision.v1",
selected_prescription_id: "oas-g4-practice-replay",
competency_id: "competency.pacing",
competency_band: "fragile",
forgetting_risk: 0.82,
mode: "replay",
selection_basis: [
"weakest_available_band:fragile",
"forgetting_risk:0.820",
"uncertainty:0.280",
"scenario_novelty:familiar",
],
deferred_prescription_ids: modes
.slice(1)
.map((mode) => `oas-g4-practice-${mode}`),
blocked_prescription_reasons: [],
},
decision_id: "57000000-0000-4000-8000-000000000001",
};
}
async function routeUser(page: Page, role: ReviewRole) {
await page.route("**/api/auth/me", (route) =>
fulfillJson(route, {
user_id:
role === "teacher"
? "50000000-0000-4000-8000-000000000202"
: LEARNER_ID,
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: "",
}),
);
}
async function routeReviewShell(page: Page, role: ReviewRole) {
const review = filledReviewResponse(FILLED_REVIEW_SESSION_ID);
review.turns = review.turns.map((turn, index) => ({
...turn,
turn_id: TURN_UUIDS[index],
}));
if (role === "teacher") {
review.teacherReview = {
status: "viewed",
note: "",
reviewedAt: null,
reviewerId: "50000000-0000-4000-8000-000000000202",
worksheetStatus: "pending",
worksheetNote: "",
worksheetReviewedAt: null,
};
}
await page.route(
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/review`,
(route) => fulfillJson(route, review),
);
await page.route(
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/alliance-pulses`,
(route) => fulfillJson(route, { items: [] }),
);
await page.route(
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/outcome-trajectory`,
(route) => fulfillJson(route, { detail: "not found" }, 404),
);
await page.route(
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/ruptures`,
(route) => fulfillJson(route, { detail: "not found" }, 404),
);
await routePrepostMeasures(page);
}
async function routePracticeRead(page: Page, role: ReviewRole) {
if (role === "teacher") {
await page.route("**/api/teacher/dashboard", (route) =>
fulfillJson(route, {
active_sessions: 0,
ended_sessions: 1,
total_learners: 1,
cohort_label: "E2E 한신대",
message: "",
source: "database",
pending_reviews: [
{
session_id: FILLED_REVIEW_SESSION_ID,
learner_id: LEARNER_ID,
},
],
recent_sessions: [],
learner_growth: [],
safety_alerts: [],
}),
);
await page.route(`**/api/practice/learners/${LEARNER_ID}`, (route) =>
fulfillJson(route, practiceReadModel()),
);
return;
}
await page.route("**/api/practice/learners/me", (route) =>
fulfillJson(route, practiceReadModel()),
);
}
async function prepare(page: Page, role: ReviewRole) {
await routeUser(page, role);
await routeReviewShell(page, role);
await routePracticeRead(page, role);
}
async function openPractice(page: Page, role: ReviewRole, search = "") {
const root = role === "teacher" ? "/teach/session" : "/learn/session";
await page.goto(`${root}/${FILLED_REVIEW_SESSION_ID}/review${search}`);
await page.getByRole("tab", { name: "피드백" }).click();
const card = page.locator(".dp-card");
await expect(card).toBeVisible();
return card;
}
test.describe("G4 숙의 연습", () => {
test("학습자는 최약 역량, 다섯 방식, 원자 행동과 전이 게이트를 보고 같은 UUID로 안전하게 재시도한다", async ({
page,
}, testInfo) => {
await prepare(page, "learner");
const submitted: Array<Record<string, unknown>> = [];
await page.route(
"**/api/practice/oas-g4-practice-replay/attempts",
async (route) => {
submitted.push(
route.request().postDataJSON() as Record<string, unknown>,
);
if (submitted.length === 1) {
await fulfillJson(
route,
{ detail: "연습 원장 저장소를 사용할 수 없습니다." },
503,
);
return;
}
await fulfillJson(
route,
{
submission_id: submitted[1].submission_id,
progress: "transfer_pending",
mastery_allowed: false,
snapshot_id: "58000000-0000-4000-8000-000000000001",
decision_id: "59000000-0000-4000-8000-000000000001",
next_prescription_id: "oas-g4-practice-replay",
idempotent_replay: true,
},
201,
);
},
);
const card = await openPractice(page, "learner");
await expect(
card.getByRole("heading", {
name: "다음 한 행동을 근거 장면에서 다시 연습합니다",
}),
).toBeVisible();
await expect(
card.getByLabel("숙의 연습 방식 다섯 가지").locator("li"),
).toHaveCount(5);
for (const label of ["되감기", "분기", "제약 응답", "음성", "난도 단계"]) {
await expect(
card.getByText(label, { exact: true }).first(),
).toBeVisible();
}
await expect(
card.getByText("개입 전 준비도 확인", { exact: true }),
).toBeVisible();
await expect(card.getByText("망각 위험").first()).toBeVisible();
await expect(card.getByText("82%", { exact: true })).toBeVisible();
await expect(card.getByText("판정 불확실성").first()).toBeVisible();
await expect(card.getByText("28%", { exact: true }).first()).toBeVisible();
await expect(
card.getByText(
"조언을 제시하기 전에 내담자의 준비도를 한 문장으로 확인한다.",
),
).toBeVisible();
await expect(
card.getByText("새 장면 확인 대기", { exact: true }),
).toBeVisible();
await expect(
card.getByText(/익숙한 장면은 확인됐지만 전이는 아직/),
).toBeVisible();
await expect(
card.getByText("반대 근거와 제한", { exact: true }),
).toBeVisible();
await expect(
card.getByText(/총점\s*[:·]\s*\d|XP\s*\d|경험치\s*\d|보상\s*\d/),
).toHaveCount(0);
const form = card.locator(".dp-attempt-form");
await form.getByLabel("목표 행동 관찰").selectOption("observed");
await form.getByLabel("직후 내담자 반응").selectOption("engaged");
await form
.getByLabel("실제로 사용한 한 문장")
.fill("지금 이 이야기를 조금 더 다뤄도 괜찮을까요?");
await form.getByLabel("내가 보기에는 목표 행동을 실행했습니다").check();
await form.getByRole("button", { name: "근거와 함께 시도 추가" }).click();
await expect(form.getByRole("alert")).toContainText("사용할 수 없습니다");
await form.getByRole("button", { name: "근거와 함께 시도 추가" }).click();
await expect(form.getByRole("status")).toContainText(
"새 장면 전이가 남아 있습니다",
);
expect(submitted).toHaveLength(2);
expect(submitted[0]).toEqual(submitted[1]);
expect(String(submitted[0].submission_id)).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
);
const episodeBody = submitted[0].episode as {
episode_id: string;
attempts: Array<{ attempt_id: string }>;
};
expect(episodeBody.episode_id).toMatch(/^oas-g4-episode-[a-f0-9-]+$/);
expect(episodeBody.attempts[0].attempt_id).toMatch(
/^oas-g4-attempt-[a-f0-9-]+$/,
);
await card.getByRole("button", { name: /05:04.*발화로 이동/ }).click();
await expect(page.getByRole("tab", { name: "축어록" })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(page.locator(".sr-turn--active")).toContainText(
"혼자서 버티고",
);
await page.getByRole("tab", { name: "피드백" }).click();
await card.screenshot({
path: testInfo.outputPath("deliberate-practice-learner-desktop.png"),
animations: "disabled",
});
await expectNoHorizontalOverflow(page);
});
test("교수자는 대상 학습자 원장을 읽고 기존 시도 변경 없이 정정만 덧붙인다", async ({
page,
}) => {
await prepare(page, "teacher");
let runtimeObservationRequests = 0;
await page.route(
"**/api/practice/*/attempts/from-session/*",
(route) => {
runtimeObservationRequests += 1;
return fulfillJson(route, { detail: "learner role required" }, 403);
},
);
let correctionBody: Record<string, unknown> | null = null;
await page.route(
"**/api/practice/attempts/55000000-0000-4000-8000-000000000001/correction",
async (route) => {
correctionBody = route.request().postDataJSON() as Record<
string,
unknown
>;
await fulfillJson(
route,
{
submission_id: correctionBody.submission_id,
correction_id: "5a000000-0000-4000-8000-000000000001",
correction_no: 1,
idempotent_replay: false,
},
201,
);
},
);
const launchSearch = practiceLaunchSearch({
kind: "deliberate",
prescriptionId: "oas-g4-practice-replay",
suiteId: null,
trialId: null,
sourceSessionId: FILLED_REVIEW_SESSION_ID,
criterionId: "criterion.replay",
novelty: "familiar",
mode: "replay",
});
const card = await openPractice(page, "teacher", `?${launchSearch}`);
await expect(card.getByText("교수자 보기", { exact: true })).toBeVisible();
await expect(
card.getByText("읽기 + 정정 추가만 가능", { exact: true }),
).toBeVisible();
await expect(card.locator(".dp-attempt-form")).toHaveCount(0);
await expect(card.getByRole("button", { name: /시도 추가/ })).toHaveCount(
0,
);
await expect(card.locator(".dp-runtime-observation")).toHaveCount(0);
await expect(
card.getByRole("button", { name: /독립 관찰|평가 상태 다시 확인/ }),
).toHaveCount(0);
expect(runtimeObservationRequests).toBe(0);
await card.getByText("교수자 근거로 정정 추가", { exact: true }).click();
await card.getByLabel("정정 판정").selectOption("needs_retry");
await card
.getByLabel("정정 사유")
.fill(
"후속 발화에서 준비도 확인이 유지되지 않아 다시 확인이 필요합니다.",
);
await card
.getByLabel("반대 근거")
.fill("내담자가 짧게 동의했지만 과업 합의는 명시되지 않았습니다.");
await card.getByRole("button", { name: "정정 원장에 추가" }).click();
await expect(card.getByRole("status")).toContainText(
"기존 판정을 바꾸지 않고",
);
expect(correctionBody).not.toBeNull();
expect(correctionBody).toMatchObject({
corrected_outcome: "needs_retry",
evidence_turn_ids: [TURN_UUIDS[1], TURN_UUIDS[2]],
counterevidence: [
"내담자가 짧게 동의했지만 과업 합의는 명시되지 않았습니다.",
],
});
expect(String(correctionBody!.submission_id)).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
);
await expectNoHorizontalOverflow(page);
});
test("G5 전이 intent는 G4 독립 관찰 endpoint로 잘못 제출하지 않는다", async ({
page,
}) => {
await prepare(page, "learner");
let runtimeObservationRequests = 0;
let calibrationReadRequests = 0;
await page.route(
"**/api/practice/*/attempts/from-session/*",
(route) => {
runtimeObservationRequests += 1;
return fulfillJson(route, { detail: "wrong execution endpoint" }, 409);
},
);
await page.route("**/api/calibration/learners/me", (route) => {
calibrationReadRequests += 1;
return fulfillJson(route, { detail: "g5 routing fixture" }, 503);
});
const transferPrescriptionId =
"5b000000-0000-4000-8000-000000000001";
const transferSourceSessionId =
"5b000000-0000-4000-8000-000000000004";
const transferSearch = practiceLaunchSearch({
kind: "transfer",
prescriptionId: transferPrescriptionId,
suiteId: "5b000000-0000-4000-8000-000000000002",
trialId: "5b000000-0000-4000-8000-000000000003",
sourceSessionId: transferSourceSessionId,
criterionId: "competency.pacing",
novelty: "unseen_transfer",
mode: "evidence_recall",
});
const card = await openPractice(page, "learner", `?${transferSearch}`);
const transferCard = page.locator(".ct-card--error");
await expect(transferCard).toContainText("예측 원장을 표시할 수 없어");
await expect(transferCard).toContainText("API 503: g5 routing fixture");
await expect(card.locator(".dp-runtime-observation")).toHaveCount(0);
await expect(
card.getByRole("button", { name: /독립 관찰|반영/ }),
).toHaveCount(0);
await expect(card).not.toContainText(transferPrescriptionId);
expect(calibrationReadRequests).toBeGreaterThan(0);
expect(runtimeObservationRequests).toBe(0);
});
test("다섯 처방 모드는 타입 안전한 실행 계약으로 보존되고 키보드로 연습 화면에 진입한다", async ({
page,
}) => {
await prepare(page, "learner");
const launchedSessionId = "5b000000-0000-4000-8000-000000000001";
await page.route("**/api/personas", (route) =>
fulfillJson(route, [
{
code: "P1",
display_name: "민서(가명) · 17세 · 학교 적응 어려움",
difficulty: "hard",
theory_target: ["humanistic"],
demographics: { age_band: "10대" },
presenting_summary: "학교 적응과 무기력을 둘러싼 상담 연습",
voice_preset: "soft-young-fem",
source: "database",
degraded: false,
},
]),
);
await page.route("**/api/sessions/dashboard", (route) =>
fulfillJson(route, { detail: "dashboard fixture omitted" }, 503),
);
await page.route("**/api/sessions", (route) => {
if (route.request().method() === "POST") {
return fulfillJson(
route,
{
session_id: launchedSessionId,
case_id: "g4-launch-case",
session_no: 2,
stage: "라포",
effective_openness: 0.24,
recall_summary: null,
degraded: false,
},
201,
);
}
return fulfillJson(route, {
source: "database",
sessions: [
{
session_id: FILLED_REVIEW_SESSION_ID,
persona_code: "P1",
persona_name: "민서",
session_no: 1,
status: "ended",
stage: "정리",
started_at: "2026-08-06T09:00:00Z",
ended_at: "2026-08-06T10:00:00Z",
review_ready: true,
turn_count: 6,
learner_turn_count: 3,
client_turn_count: 3,
archived: false,
archived_at: null,
},
],
});
});
await page.route(`**/api/sessions/${launchedSessionId}/alliance-pulses`, (route) =>
fulfillJson(route, {
items: [
{
pulse_id: "5c000000-0000-4000-8000-000000000001",
checkpoint: "pre",
status: "ready",
learner_locked_at: "2026-08-07T00:00:00Z",
revealed_at: "2026-08-07T00:00:01Z",
error_code: null,
self_scores: { goal: 0.5, task: 0.5, bond: 0.5 },
measurements: [],
},
],
}),
);
await page.route("**/api/voice/health", (route) =>
fulfillJson(route, { available: true, reason: null }),
);
const card = await openPractice(page, "learner");
const primary = card.getByRole("link", { name: "이 처방으로 연습 시작" });
await expect(primary).toBeVisible();
const primaryHref = await primary.getAttribute("href");
expect(primaryHref).not.toBeNull();
const primaryUrl = new URL(primaryHref!, "http://127.0.0.1");
expect(parsePracticeLaunchIntent(primaryUrl.searchParams)).toEqual({
kind: "deliberate",
prescriptionId: "oas-g4-practice-replay",
suiteId: null,
trialId: null,
sourceSessionId: FILLED_REVIEW_SESSION_ID,
criterionId: "criterion.replay",
novelty: "familiar",
mode: "replay",
});
await card.getByText("뒤에 대기 중인 연습 4개").click();
const queued = card.getByRole("link", { name: /연습 열기/ });
await expect(queued).toHaveCount(4);
const queuedModes = new Set<string>();
for (let index = 0; index < 4; index += 1) {
const href = await queued.nth(index).getAttribute("href");
const parsed = parsePracticeLaunchIntent(
new URL(href!, "http://127.0.0.1").searchParams,
);
expect(parsed?.kind).toBe("deliberate");
if (parsed?.kind === "deliberate") queuedModes.add(parsed.mode);
}
expect(queuedModes).toEqual(
new Set(["branch", "constrained_response", "voice_retry", "difficulty_ladder"]),
);
await primary.focus();
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/\/learn\/practice\?/);
const launched = new URL(page.url());
expect(launched.searchParams.get("prescription")).toBe(
"oas-g4-practice-replay",
);
expect(launched.searchParams.get("source_session")).toBe(
FILLED_REVIEW_SESSION_ID,
);
expect(launched.searchParams.get("criterion")).toBe("criterion.replay");
expect(launched.searchParams.get("novelty")).toBe("familiar");
expect(launched.searchParams.get("mode")).toBe("replay");
await expect(
page.getByRole("heading", { name: "장면 다시 보기 처방을 이어받았습니다." }),
).toBeVisible();
await expect(page.locator(".lh-practice-launch-intent")).toContainText(
"원본 회기의 내담자를 우선 선택했으며",
);
await page.getByRole("button", { name: "새 회기 시작" }).click();
await expect(
page.getByRole("heading", { name: "처방 연습 · 장면 다시 보기" }),
).toBeVisible();
await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page).toHaveURL(new RegExp(`/learn/session/${launchedSessionId}\\?`));
const persisted = new URL(page.url());
expect(parsePracticeLaunchIntent(persisted.searchParams)).toEqual({
kind: "deliberate",
prescriptionId: "oas-g4-practice-replay",
suiteId: null,
trialId: null,
sourceSessionId: FILLED_REVIEW_SESSION_ID,
criterionId: "criterion.replay",
novelty: "familiar",
mode: "replay",
});
});
test("연습 API 불가를 정상 또는 빈 원장으로 위장하지 않는다", async ({
page,
}) => {
let releaseRequest = () => {};
const pendingResponse = new Promise<void>((resolve) => {
releaseRequest = resolve;
});
await routeUser(page, "learner");
await routeReviewShell(page, "learner");
await page.route("**/api/practice/learners/me", async (route) => {
await pendingResponse;
await fulfillJson(
route,
{ detail: "숙의 연습 API 연결이 준비되지 않았습니다." },
503,
);
});
const card = await openPractice(page, "learner");
await expect(
card.getByRole("heading", { name: "숙의 연습 원장을 불러오는 중" }),
).toBeVisible();
await expect(card).toHaveAttribute("aria-busy", "true");
releaseRequest();
await expect(
card.getByRole("heading", {
name: "숙의 연습 원장을 표시할 수 없습니다",
}),
).toBeVisible();
await expect(card).toContainText("준비되지 않았습니다");
await expect(
card.getByRole("button", { name: "다시 불러오기" }),
).toBeVisible();
await expect(
card.getByText("아직 연결된 숙의 연습이 없습니다"),
).toHaveCount(0);
});
test("손상된 처방 식별자는 일반 연습으로 축약하지 않고 실행 인계를 보류한다", async ({
page,
}) => {
await routeUser(page, "learner");
await routeReviewShell(page, "learner");
const degraded = practiceReadModel();
degraded.prescriptions[0].prescription_payload.prescription_id = "invalid prescription id";
await page.route("**/api/practice/learners/me", (route) =>
fulfillJson(route, degraded),
);
const card = await openPractice(page, "learner");
await expect(card.locator(".dp-launch-degraded")).toContainText(
"실행 인계 식별자가 불완전",
);
await expect(
card.getByRole("link", { name: "이 처방으로 연습 시작" }),
).toHaveCount(0);
});
test("모바일 다크모드에서 가로 넘침 없이 키보드와 스크린리더 이름을 유지한다", async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.addInitScript(() => {
localStorage.setItem("vignette.theme", "dark");
});
await prepare(page, "learner");
const card = await openPractice(page, "learner");
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
await expect(card.getByLabel("숙의 연습 방식 다섯 가지")).toBeVisible();
await expect(
card.getByLabel("숙의 연습 방식 다섯 가지").locator("li"),
).toHaveCount(5);
await expect(card.getByLabel("목표 행동 관찰")).toBeVisible();
await expect(card.getByLabel("직후 내담자 반응")).toBeVisible();
const launch = card.getByRole("link", { name: "이 처방으로 연습 시작" });
await expect(launch).toBeVisible();
const launchBox = await launch.boundingBox();
expect(launchBox?.height ?? 0).toBeGreaterThanOrEqual(44);
const evidence = card.getByRole("button", { name: /05:04.*발화로 이동/ });
await evidence.focus();
await page.keyboard.press("Enter");
await expect(page.getByRole("tab", { name: "축어록" })).toHaveAttribute(
"aria-selected",
"true",
);
await page.getByRole("tab", { name: "피드백" }).click();
await expectNoHorizontalOverflow(page);
await card.screenshot({
path: testInfo.outputPath("deliberate-practice-mobile-dark.png"),
animations: "disabled",
});
});
});

View file

@ -192,6 +192,27 @@ async function routeSessionFixtureApi(page: Page, options: SessionFixtureOptions
});
});
await page.route(`**/api/sessions/${fixtureSessionId}/alliance-pulses`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: [
{
pulse_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
checkpoint: "pre",
status: "ready",
learner_locked_at: new Date().toISOString(),
revealed_at: new Date().toISOString(),
error_code: null,
self_scores: { goal: 0.5, task: 0.5, bond: 0.5 },
measurements: [],
},
],
}),
});
});
await page.route(`**/api/sessions/${fixtureSessionId}/stream`, async (route) => {
if (options.crisisStream) {
const crisisResource = {

View file

@ -0,0 +1,477 @@
"""Prepare one live DB fixture for the returned-practice browser closed loop.
This harness owns setup only. The two authoritative learner writes are left for
Playwright:
* POST /practice/{prescription}/attempts/from-session/{practice_session}
* POST /calibration/transfer-executions
The output contains opaque fixture anchors and must stay in a disposable temp
directory. It is not a shareable evidence artifact.
"""
from __future__ import annotations
import argparse
import asyncio
import copy
import importlib.util
import json
import secrets
import sys
import time
from pathlib import Path
from types import ModuleType
from typing import Any
from uuid import uuid4
REPO_ROOT = Path(__file__).resolve().parents[4]
SCRIPTS_DIR = REPO_ROOT / "scripts"
BENCHMARK_PATH = (
REPO_ROOT
/ "apps"
/ "api"
/ "app"
/ "data"
/ "deliberate_practice_benchmark_g4.v1.json"
)
COHORT_ID = "e2e-hanshin"
class FixtureError(RuntimeError):
pass
def _load_smoke_helper(filename: str, module_name: str) -> ModuleType:
path = SCRIPTS_DIR / filename
spec = importlib.util.spec_from_file_location(module_name, path)
if spec is None or spec.loader is None:
raise FixtureError(f"cannot load smoke helper: {filename}")
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module
def _initial_runtime_count(read_model: dict[str, Any], practice_session_id: str) -> int:
return sum(
len(item.get("attempts") or [])
for item in read_model.get("episodes") or []
if str(item.get("session_id")) == practice_session_id
)
def _initial_transfer_count(
read_model: dict[str, Any],
*,
trial_record_id: str,
practice_session_id: str,
) -> int:
return sum(
1
for item in read_model.get("actual_executions") or []
if str(item.get("original_transfer_trial_record_id")) == trial_record_id
and str(item.get("practice_session_id")) == practice_session_id
)
async def _find_resumable_source(dsn: str) -> dict[str, str] | None:
"""Find the one scratch-only source that already passed session evaluation."""
import asyncpg
conn = await asyncpg.connect(dsn)
try:
row = await conn.fetchrow(
"""
SELECT u.email, u.user_id::text, s.id::text AS session_id,
s.persona_code, p.prescription_key
FROM app.app_user u
JOIN app.sessions s ON s.learner_id = u.user_id
JOIN app.session_evaluation e ON e.session_id = s.id
JOIN app.practice_prescription p ON p.session_id = s.id
WHERE u.external_id LIKE 'dev:%returned-practice%'
AND e.status = 'ready'
AND p.prescription_key = 'oas-g4-practice-reward-replay'
ORDER BY e.created_at DESC
LIMIT 1
"""
)
finally:
await conn.close()
if row is None:
return None
return {key: str(row[key]) for key in row.keys()}
def run(args: argparse.Namespace) -> dict[str, Any]:
if len(args.practice_internal_token) < 32:
raise FixtureError("practice internal token must contain at least 32 characters")
if len(args.transfer_internal_token) < 32:
raise FixtureError("transfer internal token must contain at least 32 characters")
g4 = _load_smoke_helper(
"smoke-deliberate-practice-api.py", "vignette_g4_smoke_helper"
)
g5 = _load_smoke_helper(
"smoke-calibration-transfer-api.py", "vignette_g5_smoke_helper"
)
client = g4.ApiClient(args.api_base_url, args.request_timeout)
health = client.request("GET", "/health")
if not health.body.get("db") or not health.body.get("engine"):
raise FixtureError("API health is not DB+engine ready")
resumable = (
asyncio.run(
_find_resumable_source(args.database_admin_url or args.database_url)
)
if args.resume_ready_source
else None
)
if args.resume_ready_source and resumable is None:
raise FixtureError("no review-ready scratch source is available to resume")
suffix = f"{int(time.time())}.{secrets.token_hex(4)}"
email = (
resumable["email"]
if resumable
else f"dev.e2e.returned-practice.{suffix}@hs.ac.kr"
)
login = {
"email": email,
"role": "learner",
"display_name": "Returned Practice Learner",
"cohort_ids": [COHORT_ID],
}
client.request("POST", "/auth/dev-login", login)
client.request(
"POST",
"/users/me/onboarding",
{
"legal_name": "Returned Practice Learner",
"affiliation": "한신대학교",
"department": "상담심리학과",
"grade_level": "통합검증",
"phone": "010-0000-0000",
"contact_address": "경기도 오산시 한신대학교",
"nickname": "Returned Practice Learner",
"self_introduction": "브라우저 원장 폐루프 검증 fixture입니다.",
"avatar_url": "",
"terms_accepted": True,
"privacy_accepted": True,
},
)
me = client.request("GET", "/auth/me")
learner_id = str(me.body.get("user_id") or "")
if not learner_id:
raise FixtureError("dev-login omitted learner id")
if resumable and learner_id != resumable["user_id"]:
raise FixtureError("resumed login did not resolve to the scratch source owner")
source_persona, practice_persona = g4._choose_distinct_personas(client)
if resumable:
source_persona = resumable["persona_code"]
if practice_persona == source_persona:
catalog = client.request("GET", "/personas").body
practice_persona = next(
str(item["code"])
for item in catalog
if isinstance(item, dict)
and item.get("source") == "database"
and not item.get("degraded")
and item.get("code") != source_persona
)
source_session_id = resumable["session_id"]
review_response = client.request(
"GET", f"/sessions/{source_session_id}/review"
)
if review_response.body.get("reviewReady") is not True:
raise FixtureError("resumed source review is no longer ready")
source_review = {"poll_count": 0, "review": review_response.body}
else:
source_started = client.request(
"POST",
"/sessions",
{
"persona_code": source_persona,
"theory_mode": "humanistic",
"goal_stages": ["라포", "탐색"],
},
expected={201},
)
source_session_id = str(source_started.body["session_id"])
client.request(
"POST",
f"/sessions/{source_session_id}/turn",
{
"text": (
"지금 느끼는 막막함을 제가 제대로 이해했는지 "
"먼저 확인해도 괜찮을까요?"
)
},
)
client.request("POST", f"/sessions/{source_session_id}/end")
source_review = g4._wait_for_session_review(
client,
source_session_id,
timeout=args.review_poll_timeout,
interval=args.review_poll_interval,
)
source_turn_ids = g4._durable_turn_ids(source_review["review"])
# G4: prepare an authoritative prescription, but leave the completed-session
# observation absent so the browser owns the first write.
live_case = g4._load_live_case(BENCHMARK_PATH, source_turn_ids)
if resumable:
prescription_id = resumable["prescription_key"]
else:
practice_internal = g4.ApiClient(args.api_base_url, args.request_timeout)
practice_headers = {
"X-Vignette-Practice-Token": args.practice_internal_token
}
prescription_submission = {
"submission_id": str(uuid4()),
"coaching_cards": live_case["coaching_cards"],
"competency_graph": live_case["graph"],
"evidence_turn_ids": source_turn_ids,
}
prescription_path = (
f"/internal/sessions/{source_session_id}/practice/prescriptions"
)
prescription_created = practice_internal.request(
"POST",
prescription_path,
prescription_submission,
expected={201},
headers=practice_headers,
)
prescription_retried = practice_internal.request(
"POST",
prescription_path,
prescription_submission,
expected={201},
headers=practice_headers,
)
if prescription_retried.body.get("idempotent_replay") is not True:
raise FixtureError("G4 prescription setup retry was not idempotent")
prescription_id = str(prescription_created.body["next_prescription_id"])
g4_target = live_case["coaching_cards"][0]["targets"][0]
# G5: establish prediction -> lock -> independent observation -> suite.
# The actual transfer execution remains absent for the browser.
history_id = str(uuid4())
revision_id = str(uuid4())
fixture_suffix = secrets.token_hex(5)
revision = {
"submission_id": str(uuid4()),
"prediction_revision_id": revision_id,
"history_id": history_id,
"session_id": source_session_id,
"competency_id": "competency.empathic_attunement",
"practice_block_id": f"oas-g5-block-browser-{fixture_suffix}",
"scenario_variant_id": f"browser-scenario-{fixture_suffix}",
"phrase_family_id": f"browser-phrase-{fixture_suffix}",
"revision_no": 1,
"supersedes_prediction_revision_id": None,
"predicted_success_probability": 0.72,
"confidence": 0.80,
"recorded_sequence": 1,
"revision_reason": "외부평가 전에 장면 근거로 성공 가능성을 예측함",
"instrument_id": g5.INSTRUMENT_ID,
"instrument_version": g5.INSTRUMENT_VERSION,
"evidence_turn_ids": source_turn_ids,
}
client.request(
"POST", "/calibration/predictions/revisions", revision, expected={201}
)
client.request(
"POST",
f"/calibration/predictions/{history_id}/lock",
{
"submission_id": str(uuid4()),
"lock_id": str(uuid4()),
"prediction_revision_id": revision_id,
"locked_sequence": 1,
},
expected={201},
)
transfer_internal = g4.ApiClient(args.api_base_url, args.request_timeout)
transfer_headers = {
"X-Vignette-Calibration-Transfer-Token": args.transfer_internal_token
}
transfer_internal.request(
"POST",
"/internal/calibration/performance-observations",
{
"submission_id": str(uuid4()),
"observation_id": str(uuid4()),
"history_id": history_id,
"status": "passed",
"source_kind": "observed_runtime",
"perspective": "runtime_observation",
"model_run_id": None,
"instrument_id": g5.INSTRUMENT_ID,
"instrument_version": g5.INSTRUMENT_VERSION,
"uncertainty": 0.18,
"evidence_turn_ids": source_turn_ids,
"counterevidence": ["single_scene_transfer_not_yet_verified"],
"revealed_sequence": 2,
},
expected={201},
headers=transfer_headers,
)
suite_model_run_id = asyncio.run(
g5._create_transfer_suite_model_run(
args.database_url,
learner_id=learner_id,
source_session_id=source_session_id,
evidence_turn_ids=source_turn_ids,
)
)
transfer_suite = g5._build_transfer_suite(
fixture_suffix=fixture_suffix,
evidence_turn_ids=source_turn_ids,
)
transfer_suite_record_id = str(uuid4())
suite_created = transfer_internal.request(
"POST",
f"/internal/sessions/{source_session_id}/calibration/transfer-suites",
{
"submission_id": str(uuid4()),
"transfer_suite_record_id": transfer_suite_record_id,
"suite": copy.deepcopy(transfer_suite),
"model_run_id": suite_model_run_id,
"instrument_id": g5.TRANSFER_INSTRUMENT_ID,
"instrument_version": g5.INSTRUMENT_VERSION,
},
expected={201},
headers=transfer_headers,
)
if suite_created.body.get("trial_count") != 1:
raise FixtureError("G5 suite setup omitted its authoritative trial")
calibration_read = client.request("GET", "/calibration/learners/me").body
suite_projection = next(
(
item
for item in calibration_read.get("transfer_suites") or []
if str(item.get("transfer_suite_record_id"))
== transfer_suite_record_id
),
None,
)
if suite_projection is None or len(suite_projection.get("trials") or []) != 1:
raise FixtureError("G5 read model omitted authoritative suite trial")
trial_record_id = str(
suite_projection["trials"][0]["transfer_trial_record_id"]
)
# One distinct-persona, completed follow-up session is shared by G4 and G5.
practice_started = client.request(
"POST",
"/sessions",
{
"persona_code": practice_persona,
"theory_mode": "humanistic",
"goal_stages": ["라포", "탐색"],
},
expected={201},
)
practice_session_id = str(practice_started.body["session_id"])
client.request(
"POST",
f"/sessions/{practice_session_id}/turn",
{
"text": (
"그 말을 꺼내기까지 많이 외롭고 조심스러웠던 것 같아요. "
"제가 이해한 마음이 맞는지 함께 확인해도 괜찮을까요?"
)
},
)
client.request("POST", f"/sessions/{practice_session_id}/end")
practice_review = g4._wait_for_session_review(
client,
practice_session_id,
timeout=args.review_poll_timeout,
interval=args.review_poll_interval,
)
practice_turn_ids = g4._durable_turn_ids(practice_review["review"])
practice_read = client.request("GET", "/practice/learners/me").body
calibration_read = client.request("GET", "/calibration/learners/me").body
runtime_count = _initial_runtime_count(practice_read, practice_session_id)
transfer_count = _initial_transfer_count(
calibration_read,
trial_record_id=trial_record_id,
practice_session_id=practice_session_id,
)
if runtime_count != 0 or transfer_count != 0:
raise FixtureError("browser-owned closed-loop writes already exist")
return {
"schema_version": "vignette.returned-practice-browser-fixture.v1",
"login": login,
"source_session_id": source_session_id,
"practice_session_id": practice_session_id,
"deliberate": {
"prescription_id": prescription_id,
"criterion_id": str(g4_target["criterion_id"]),
"novelty": str(g4_target["activity"]["scenario_novelty"]),
"mode": str(g4_target["activity"]["mode"]),
},
"transfer": {
"prescription_id": str(transfer_suite["suite_id"]),
"suite_id": transfer_suite_record_id,
"trial_id": trial_record_id,
"criterion_id": str(
suite_projection["trials"][0]["competency_id"]
),
"novelty": "unseen_transfer",
"mode": "counterevidence_forecast",
},
"setup_proof": {
"source_review_ready": True,
"follow_up_review_ready": True,
"source_turn_count": len(source_turn_ids),
"follow_up_turn_count": len(practice_turn_ids),
"distinct_persona": source_persona != practice_persona,
"initial_runtime_observation_count": runtime_count,
"initial_actual_transfer_execution_count": transfer_count,
},
}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--api-base-url", required=True)
parser.add_argument("--database-url", required=True)
parser.add_argument("--database-admin-url", default="")
parser.add_argument("--practice-internal-token", required=True)
parser.add_argument("--transfer-internal-token", required=True)
parser.add_argument("--out", required=True)
parser.add_argument("--request-timeout", type=float, default=240.0)
parser.add_argument("--review-poll-timeout", type=float, default=240.0)
parser.add_argument("--review-poll-interval", type=float, default=0.5)
parser.add_argument("--resume-ready-source", action="store_true")
args = parser.parse_args()
result = run(args)
output = Path(args.out).resolve()
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(
json.dumps(result, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
# Keep stdout free of fixture identifiers and account data.
print(
json.dumps(
{
"ok": True,
"schema_version": result["schema_version"],
"setup_proof": result["setup_proof"],
},
ensure_ascii=False,
)
)
if __name__ == "__main__":
main()

View file

@ -9,6 +9,7 @@ import {
routePrepostMeasures,
} from "./session-review-fixture";
import {
completeAlliancePreCheckpoint,
completeOnboarding,
expectNoHorizontalOverflow,
fetchAvailablePersona,
@ -628,6 +629,7 @@ test.describe("layout visual gate @single-run", () => {
const persona = await fetchAvailablePersona(page, 1);
await page.goto(`/learn/session/${persona.code}`);
await page.getByRole("button", { name: "회기 시작" }).click();
await completeAlliancePreCheckpoint(page);
await expect(page.locator(".sx-page--active")).toBeVisible({ timeout: 15_000 });
await gateScreen(page, "session-active", async () => {
await expect(page.locator(".sx-page--active")).toBeVisible();

View file

@ -1,5 +1,6 @@
import { expect, test } from "@playwright/test";
import {
completeAlliancePreCheckpoint,
expectNoDocumentOverflow,
expectNoHorizontalOverflow,
fetchAvailablePersona,
@ -52,7 +53,7 @@ async function expectVisibleResumeLoadedSignal(page: import("@playwright/test").
const result = await page.evaluate(() => {
const candidates = Array.from(
document.querySelectorAll<HTMLElement>(
".sx-page--active .sx-mobile-context__resume, .sx-page--active .sx-mic-block__h",
".sx-page--active .sx-sessionbar__meta b, .sx-page--active .sx-mobile-context__resume, .sx-page--active .sx-mic-block__h",
),
);
return candidates.map((el) => {
@ -240,6 +241,7 @@ test.describe("learner app shell and session launcher", () => {
}
await page.getByRole("button", { name: "회기 시작" }).click();
await completeAlliancePreCheckpoint(page);
await expect(page.locator(".sx-grid")).toBeVisible();
await expectNoLearnerInternalCopy(page);

View 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();
});
});

View file

@ -0,0 +1,622 @@
import { expect, test, type Page, type Route } from "@playwright/test";
import type {
AxisTrajectoryAssessment,
OutcomeAxis,
OutcomeObservationSubmissionResponse,
OutcomeTrajectoryResponse,
SyntheticExpectedDistribution,
TrajectoryStatus,
} from "../src/pages/session-review/outcomeTrajectoryApi";
import {
FILLED_REVIEW_SESSION_ID,
filledReviewResponse,
routePrepostMeasures,
} from "./session-review-fixture";
import { expectNoHorizontalOverflow } from "./support";
type ReviewRole = "learner" | "teacher";
const AXES: OutcomeAxis[] = [
"distress_load",
"daily_functioning",
"learning_engagement",
];
const EXPECTED: Record<number, Record<OutcomeAxis, number>> = {
1: { distress_load: 0.7, daily_functioning: 0.3, learning_engagement: 0.4 },
2: { distress_load: 0.62, daily_functioning: 0.4, learning_engagement: 0.48 },
3: { distress_load: 0.54, daily_functioning: 0.5, learning_engagement: 0.56 },
4: { distress_load: 0.46, daily_functioning: 0.6, learning_engagement: 0.64 },
5: { distress_load: 0.38, daily_functioning: 0.68, learning_engagement: 0.72 },
};
const OBSERVED: Record<number, Record<OutcomeAxis, number | null>> = {
1: { distress_load: 0.71, daily_functioning: 0.31, learning_engagement: 0.42 },
2: { distress_load: 0.73, daily_functioning: 0.34, learning_engagement: 0.41 },
3: { distress_load: 0.67, daily_functioning: 0.38, learning_engagement: null },
4: { distress_load: 0.82, daily_functioning: 0.29, learning_engagement: 0.3 },
};
const TURN_UUIDS = [
"10000000-0000-4000-8000-000000000001",
"10000000-0000-4000-8000-000000000002",
"10000000-0000-4000-8000-000000000003",
"10000000-0000-4000-8000-000000000004",
"10000000-0000-4000-8000-000000000005",
"10000000-0000-4000-8000-000000000006",
];
function distributions(): SyntheticExpectedDistribution[] {
return [1, 2, 3, 4, 5].flatMap((sessionNo) =>
AXES.map((axis) => {
const mean = EXPECTED[sessionNo][axis];
return {
session_no: sessionNo,
axis,
mean,
standard_deviation: 0.1,
lower_reference: Math.max(0, mean - 0.2),
upper_reference: Math.min(1, mean + 0.2),
sample_size: 200,
expected_direction:
axis === "distress_load" ? "lower_is_better" : "higher_is_better",
};
}),
);
}
function axisAssessment(
sessionNo: number,
axis: OutcomeAxis,
sessionStatus: TrajectoryStatus,
): AxisTrajectoryAssessment {
const observed = OBSERVED[sessionNo][axis];
const missing = observed == null;
return {
session_no: sessionNo,
axis,
status: missing ? "insufficient_evidence" : sessionStatus,
observed_value: observed,
expected_mean: EXPECTED[sessionNo][axis],
adverse_z: missing ? null : sessionNo === 4 ? 2.1 : 0.7,
adverse_z_change: missing ? null : sessionNo === 4 ? 0.8 : 0.2,
uncertainty: missing ? 1 : sessionNo === 4 ? 0.24 : 0.36,
decision_basis: missing
? ["학습 참여 관측이 저장되지 않아 예상선과 비교하지 않았습니다."]
: [
sessionNo === 4
? "직전 회기보다 불리한 방향의 변화가 두 회기 연속 관찰됐습니다."
: "교육용 예상 범위와 관측 근거를 축별로 비교했습니다.",
],
counterevidence:
sessionNo === 4 && axis === "distress_load"
? ["회기 말에는 감정을 언어로 표현한 장면도 확인됐습니다."]
: [],
evidence_refs: missing ? [] : [axis === "distress_load" ? "t3" : "t6"],
};
}
function learnerCheckinObservations(tag: string) {
const values = {
distress_load: 0.75,
daily_functioning: 0.5,
learning_engagement: 0.5,
} satisfies Record<OutcomeAxis, number>;
return AXES.map((axis) => ({
measurement_id: `learner-${tag}-${axis}`,
session_id: FILLED_REVIEW_SESSION_ID,
session_no: 4,
axis,
status: "observed" as const,
value: values[axis],
raw_value: values[axis],
scale_min: 0,
scale_max: 1,
confidence: 0.66,
source_kind: "learner_reported" as const,
perspective: "learner_self_report" as const,
instrument_id: "outcome-learner-checkin",
instrument_version: "1.0.0",
model_run_id: null,
evidence_refs: ["t3"],
missing_reason: null,
occurred_at: "2026-08-06T09:20:00Z",
}));
}
function trajectoryResponse(): OutcomeTrajectoryResponse {
const sessionStatuses: Record<number, TrajectoryStatus> = {
1: "on_track",
2: "watch",
3: "insufficient_evidence",
4: "deteriorating",
};
return {
session_id: FILLED_REVIEW_SESSION_ID,
revision_id: "00000000-0000-0000-0000-000000000404",
revision_no: 4,
supersedes_revision_id: "00000000-0000-0000-0000-000000000303",
source_fingerprint: "sha256:e2e-outcome-trajectory-revision-4",
recompute_reason: "session_completed",
computed_at: "2026-08-06T09:30:00Z",
notice_ko:
"실제 치료 효과, 임상 규준, 진단 또는 예후를 뜻하지 않는 교육용 합성 비교선입니다.",
expected_arc: {
schema_version: "vignette.synthetic-outcome-arc.v1",
arc_id: "oas-g2-arc-001",
title_ko: "교육용 초기 5회기 기대 궤적",
data_classification: "synthetic_educational",
clinical_claim_allowed: false,
provenance_note:
"교육용 합성 사례의 결정론 테스트 분포이며 실제 내담자, 임상 규준, 치료 효과 또는 진단 예측을 나타내지 않습니다.",
session_count: 5,
distributions: distributions(),
},
assessment: {
schema_version: "vignette.outcome-trajectory-assessment.v1",
expected_arc_id: "oas-g2-arc-001",
data_classification: "synthetic_educational",
clinical_claim_allowed: false,
sessions: [1, 2, 3, 4].map((sessionNo) => ({
session_no: sessionNo,
status: sessionStatuses[sessionNo],
axes: AXES.map((axis) => axisAssessment(sessionNo, axis, sessionStatuses[sessionNo])),
missing_axes: sessionNo === 3 ? ["learning_engagement"] : [],
next_check_questions:
sessionNo === 4
? [
"고통 부담이 커진 구체 장면을 먼저 확인했나요?",
"일상 기능의 변화를 내담자의 말로 다시 확인했나요?",
]
: ["다음 회기에서 같은 축을 같은 시점에 다시 확인했나요?"],
safety_signals: sessionNo === 2 ? [safetySignal()] : [],
})),
},
observations: [1, 2, 3, 4]
.flatMap((sessionNo) =>
AXES.map((axis) => {
const value = OBSERVED[sessionNo][axis];
return {
measurement_id: `measurement-${sessionNo}-${axis}`,
session_id: FILLED_REVIEW_SESSION_ID,
session_no: sessionNo,
axis,
status: value == null ? "missing" : "observed",
value,
raw_value: value,
scale_min: 0,
scale_max: 1,
confidence: value == null ? null : 0.84,
source_kind: "simulated_state",
perspective: "client_simulation",
instrument_id: "g2-synthetic-session-outcome",
instrument_version: "1.0.0",
model_run_id: null,
evidence_refs: value == null ? [] : [axis === "distress_load" ? "t3" : "t6"],
missing_reason:
value == null
? "회기 종료 전 학습 참여 확인 응답이 저장되지 않았습니다."
: null,
occurred_at: `2026-08-0${sessionNo}T09:00:00Z`,
};
}),
)
.concat(learnerCheckinObservations("existing")),
safety_signals: [safetySignal()],
relationship_memory: [
{
event_id: "relationship-goal-1",
session_no: 1,
event_type: "goal_agreement",
summary: "비교 경험에서 올라오는 감정을 먼저 살피기로 합의했습니다.",
evidence_refs: ["t2"],
resolved_by_event_id: null,
},
{
event_id: "relationship-rupture-2",
session_no: 2,
event_type: "unresolved_rupture",
summary: "행동 연습의 속도가 빠르게 느껴졌는지 다음 회기에 다시 확인할 필요가 있습니다.",
evidence_refs: ["t5"],
resolved_by_event_id: "relationship-repair-3",
},
{
event_id: "relationship-repair-3",
session_no: 3,
event_type: "repair_confirmed",
summary: "부담을 다시 확인하고 내담자가 선택한 작은 연습으로 조정했습니다.",
evidence_refs: ["t6"],
resolved_by_event_id: null,
},
{
event_id: "relationship-task-4",
session_no: 4,
event_type: "task_agreement",
summary: "다음 회기에는 일상 기능의 변화를 먼저 확인하기로 합의했습니다.",
evidence_refs: ["t6"],
resolved_by_event_id: null,
},
],
next_questions: [
"고통 부담이 커진 구체 장면을 먼저 확인했나요?",
"일상 기능의 변화를 내담자의 말로 다시 확인했나요?",
"다음 회기에서 같은 축을 같은 시점에 다시 확인했나요?",
],
};
}
function submittedTrajectoryResponse(
submissionId: string,
): OutcomeObservationSubmissionResponse {
const current = trajectoryResponse();
const submittedMeasurementIds = AXES.map((axis) => `learner-new-${axis}`);
return {
...current,
revision_id: "00000000-0000-0000-0000-000000000505",
revision_no: 5,
supersedes_revision_id: current.revision_id,
source_fingerprint: "sha256:e2e-outcome-trajectory-revision-5",
recompute_reason: "learner_outcome_observation",
computed_at: "2026-08-06T09:35:00Z",
observations: [
...current.observations,
...learnerCheckinObservations("new").map((observation, index) => ({
...observation,
measurement_id: submittedMeasurementIds[index],
confidence: 1,
occurred_at: "2026-08-06T09:35:00Z",
})),
],
assessment: {
...current.assessment,
sessions: current.assessment.sessions.map((session) =>
session.session_no === 4
? {
...session,
status: "watch",
axes: session.axes.map((axis) => ({ ...axis, status: "watch" })),
}
: session,
),
},
submission_id: submissionId,
submitted_measurement_ids: submittedMeasurementIds,
};
}
function safetySignal() {
return {
safety_event_id: "safety-ledger-2026-08-06-01",
session_no: 2,
risk_level: "high" as const,
escalated: true,
evidence_refs: ["t3"],
};
}
async function fulfillJson(route: Route, body: unknown, status = 200) {
await route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify(body),
});
}
async function routeReviewUser(page: Page, role: ReviewRole) {
await page.route("**/api/auth/me", (route) =>
fulfillJson(route, {
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: "",
}),
);
}
async function routeReviewPage(page: Page, role: ReviewRole) {
const review = filledReviewResponse(FILLED_REVIEW_SESSION_ID);
review.turns = review.turns.map((turn, index) => ({
...turn,
turn_id: TURN_UUIDS[index] ?? null,
}));
if (role === "teacher") {
review.teacherReview = {
status: "viewed",
note: "",
reviewedAt: null,
reviewerId: "00000000-0000-0000-0000-000000000202",
worksheetStatus: "pending",
worksheetNote: "",
worksheetReviewedAt: null,
};
}
await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/review`, (route) =>
fulfillJson(route, review),
);
await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/alliance-pulses`, (route) =>
fulfillJson(route, { items: [] }),
);
await routePrepostMeasures(page);
}
async function routeTrajectory(page: Page, handler: (route: Route) => Promise<void>) {
await page.route(
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/outcome-trajectory`,
handler,
);
}
async function routeOutcomeSubmission(
page: Page,
handler: (route: Route) => Promise<void>,
) {
await page.route(
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/outcome-observations`,
handler,
);
}
async function completeLearnerCheckin(page: Page) {
const checkin = page.locator(".ot-checkin");
const axes = checkin.locator(".ot-checkin-axis");
await axes.nth(0).locator(".ot-checkin-scale--score label").nth(3).click();
await axes.nth(1).locator(".ot-checkin-scale--score label").nth(2).click();
await axes.nth(2).locator(".ot-checkin-scale--score label").nth(1).click();
await axes.nth(0).locator(".ot-checkin-scale--confidence label").nth(2).click();
await axes.nth(1).locator(".ot-checkin-scale--confidence label").nth(1).click();
await axes.nth(2).locator(".ot-checkin-scale--confidence label").nth(0).click();
return checkin;
}
test.describe("G2 종단 성과 궤적", () => {
test.beforeEach(async ({ page }) => {
await routeReviewUser(page, "learner");
await routeReviewPage(page, "learner");
});
test("축별 궤적, 누락, 안전, 관계 기억과 자기주도 질문을 독립적으로 보여준다", async ({
page,
}, testInfo) => {
await routeTrajectory(page, async (route) => {
await new Promise((resolve) => setTimeout(resolve, 1_500));
await fulfillJson(route, trajectoryResponse());
});
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
await page.getByRole("tab", { name: "피드백" }).click();
await expect(page.getByLabel("종단 성과 궤적 불러오는 중")).toBeVisible();
const card = page.locator(".ot-card");
await expect(card.getByRole("heading", { name: "회기 사이의 변화를 근거와 함께 봅니다" })).toBeVisible();
await expect(card.getByText("synthetic_educational", { exact: true })).toBeVisible();
await expect(card.getByText("clinical_claim_allowed: false", { exact: true })).toBeVisible();
await expect(card.getByText(/총점/)).toHaveCount(0);
await expect(card.getByRole("tab", { name: /4회기 악화 신호/ })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(card.getByRole("heading", { name: "고통 부담", exact: true })).toBeVisible();
await expect(card.getByRole("heading", { name: "일상 기능", exact: true })).toBeVisible();
await expect(card.getByRole("heading", { name: "학습 참여", exact: true })).toBeVisible();
await expect(card.getByRole("heading", { name: "안전 신호" })).toBeVisible();
await expect(card.getByText("성과 궤적과 합산하지 않는 별도 확인 영역입니다.")).toBeVisible();
await expect(card.getByText("미해결 균열", { exact: true })).toBeVisible();
await expect(card.getByText("복구 확인", { exact: true })).toBeVisible();
await expect(card.getByRole("heading", { name: "4회기 학습자 체크인" })).toBeVisible();
await expect(card.getByText("고통 부담은 높을수록 현재 부담이 큽니다.")).toBeVisible();
await expect(card.getByText(/이전 체크인은 수정되지 않습니다/)).toBeVisible();
await card.locator(".ot-checkin-evidence summary").click();
const firstQuestion = card.getByLabel("고통 부담이 커진 구체 장면을 먼저 확인했나요?");
await firstQuestion.check();
await expect(firstQuestion).toBeChecked();
const fourthTab = card.getByRole("tab", { name: /4회기 악화 신호/ });
await fourthTab.focus();
await page.keyboard.press("Home");
await expect(card.getByRole("tab", { name: /1회기 예상 범위/ })).toHaveAttribute(
"aria-selected",
"true",
);
await page.keyboard.press("End");
await expect(card.getByRole("tab", { name: /5회기 자료 대기/ })).toHaveAttribute(
"aria-selected",
"true",
);
await card.getByRole("tab", { name: /3회기 근거 부족/ }).click();
await expect(card.getByRole("heading", { name: /학습자 체크인/ })).toHaveCount(0);
await expect(card.getByText("체크인은 최신 회기에 새 기록으로 추가합니다")).toBeVisible();
const learningAxis = card.locator(".ot-axis", { hasText: "학습 참여" });
await expect(learningAxis.getByText("관측 없음", { exact: true }).first()).toBeVisible();
await expect(learningAxis.getByText(/누락 사유: 회기 종료 전/)).toBeVisible();
await learningAxis.getByText("판정 근거와 반대 근거 보기").click();
await expect(learningAxis.getByText(/예상선과 비교하지 않았습니다/)).toBeVisible();
await card.getByRole("tab", { name: /4회기 악화 신호/ }).click();
await card.locator(".ot-checkin-evidence summary").click();
await card.locator(".ot-contract").screenshot({
path: testInfo.outputPath(`outcome-trajectory-contract-${testInfo.project.name}.png`),
animations: "disabled",
});
await card.locator(".ot-timeline-wrap").screenshot({
path: testInfo.outputPath(`outcome-trajectory-timeline-${testInfo.project.name}.png`),
animations: "disabled",
});
await card.locator(".ot-checkin").screenshot({
path: testInfo.outputPath(`outcome-trajectory-checkin-${testInfo.project.name}.png`),
animations: "disabled",
});
await card.locator(".ot-checkin-axis").last().screenshot({
path: testInfo.outputPath(`outcome-trajectory-checkin-axis-${testInfo.project.name}.png`),
animations: "disabled",
});
await card.locator(".ot-checkin-evidence").screenshot({
path: testInfo.outputPath(`outcome-trajectory-checkin-evidence-${testInfo.project.name}.png`),
animations: "disabled",
});
await card.locator(".ot-checkin__actions").screenshot({
path: testInfo.outputPath(`outcome-trajectory-checkin-actions-${testInfo.project.name}.png`),
animations: "disabled",
});
await card.locator(".ot-session-panel").screenshot({
path: testInfo.outputPath(`outcome-trajectory-axes-${testInfo.project.name}.png`),
animations: "disabled",
});
await card.locator(".ot-support-grid").screenshot({
path: testInfo.outputPath(`outcome-trajectory-support-${testInfo.project.name}.png`),
animations: "disabled",
});
await card.locator(".ot-relationship").screenshot({
path: testInfo.outputPath(`outcome-trajectory-relationship-${testInfo.project.name}.png`),
animations: "disabled",
});
await expectNoHorizontalOverflow(page);
});
test("세 축과 확신도를 독립 제출하고 성공 응답으로 즉시 갱신한다", async ({
page,
}) => {
await routeTrajectory(page, (route) => fulfillJson(route, trajectoryResponse()));
const submissions: Array<Record<string, unknown>> = [];
await routeOutcomeSubmission(page, async (route) => {
const body = route.request().postDataJSON() as Record<string, unknown>;
submissions.push(body);
await new Promise((resolve) => setTimeout(resolve, 280));
await fulfillJson(
route,
submittedTrajectoryResponse(String(body.submission_id)),
201,
);
});
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
await page.getByRole("tab", { name: "피드백" }).click();
const checkin = await completeLearnerCheckin(page);
await checkin.getByText("축어록 장면 선택 (선택)").click();
await checkin.getByRole("checkbox").first().check();
const submit = checkin.locator('button[type="submit"]');
await submit.click();
await expect(submit).toBeDisabled();
await expect(submit).toHaveText("체크인 기록 중");
await expect(checkin.getByText(/append-only 원장에 새 기록으로 추가했습니다/)).toBeVisible();
expect(submissions).toHaveLength(1);
expect(submissions[0]).toMatchObject({
scores: {
distress_load: 0.75,
daily_functioning: 0.5,
learning_engagement: 0.25,
},
confidences: {
distress_load: 1,
daily_functioning: 0.66,
learning_engagement: 0.33,
},
evidence_turn_ids: [TURN_UUIDS[0]],
});
expect(String(submissions[0].submission_id)).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
);
await expect(
page.locator(".ot-card").getByRole("tab", { name: /4회기 다음 회기 확인/ }),
).toHaveAttribute("aria-selected", "true");
await expectNoHorizontalOverflow(page);
});
test("제출 오류 재시도는 같은 submission_id를 보존한다", async ({ page }) => {
await routeTrajectory(page, (route) => fulfillJson(route, trajectoryResponse()));
const submissions: Array<Record<string, unknown>> = [];
await routeOutcomeSubmission(page, async (route) => {
const body = route.request().postDataJSON() as Record<string, unknown>;
submissions.push(body);
if (submissions.length === 1) {
await fulfillJson(route, { detail: "temporary learner check-in failure" }, 503);
return;
}
await fulfillJson(
route,
submittedTrajectoryResponse(String(body.submission_id)),
201,
);
});
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
await page.getByRole("tab", { name: "피드백" }).click();
const checkin = await completeLearnerCheckin(page);
await checkin.getByRole("button", { name: "세 축 체크인 추가" }).click();
await expect(checkin.getByRole("alert")).toContainText("API 503");
await checkin.getByRole("button", { name: "같은 요청 다시 제출" }).click();
await expect(checkin.getByText(/append-only 원장에 새 기록으로 추가했습니다/)).toBeVisible();
expect(submissions).toHaveLength(2);
expect(submissions[1].submission_id).toBe(submissions[0].submission_id);
expect(submissions[1]).toEqual(submissions[0]);
});
test("404는 오류 대신 관측 대기 빈 상태로 안내한다", async ({ page }) => {
await routeTrajectory(page, (route) =>
fulfillJson(route, { detail: "outcome trajectory not found" }, 404),
);
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
await page.getByRole("tab", { name: "피드백" }).click();
const card = page.locator(".ot-card");
await expect(card.getByRole("heading", { name: "아직 이어 볼 회기 자료가 없습니다" })).toBeVisible();
await expect(card.getByRole("button", { name: "다시 불러오기" })).toHaveCount(0);
});
test("일시적 오류에서 같은 카드 안에서 다시 불러온다", async ({ page }) => {
let attempts = 0;
let allowSuccess = false;
await routeTrajectory(page, async (route) => {
attempts += 1;
if (!allowSuccess) {
await fulfillJson(route, { detail: "trajectory temporarily unavailable" }, 503);
return;
}
await fulfillJson(route, trajectoryResponse());
});
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
await page.getByRole("tab", { name: "피드백" }).click();
const card = page.locator(".ot-card");
await expect(card.getByRole("heading", { name: "궤적 자료를 불러오지 못했습니다" })).toBeVisible();
allowSuccess = true;
await card.getByRole("button", { name: "다시 불러오기" }).click();
await expect(card.getByRole("heading", { name: "회기 사이의 변화를 근거와 함께 봅니다" })).toBeVisible();
expect(attempts).toBeGreaterThanOrEqual(2);
});
test("교수자 보기에는 역할 허용 관계 요약만 표시한다", async ({ page }) => {
await page.unroute("**/api/auth/me");
await page.unroute(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/review`);
await routeReviewUser(page, "teacher");
await routeReviewPage(page, "teacher");
await routeTrajectory(page, (route) => fulfillJson(route, trajectoryResponse()));
await page.goto(`/teach/session/${FILLED_REVIEW_SESSION_ID}/review`);
await page.getByRole("tab", { name: "피드백" }).click();
const card = page.locator(".ot-card");
await expect(card.getByText("교수자 역할 보기", { exact: true })).toBeVisible();
await expect(card.getByText("교수자 역할에 허용된 요약만 시간순으로 표시합니다.")).toBeVisible();
await expect(card.getByText(/내담자 내부 상태/)).toHaveCount(0);
await expect(card.getByRole("heading", { name: /학습자 체크인/ })).toHaveCount(0);
await expectNoHorizontalOverflow(page);
});
});

View file

@ -144,6 +144,13 @@ test.describe("public admin visual @public-auth", () => {
await page.goto("/admin/ai", { waitUntil: "domcontentloaded" });
await expect(page.locator('[data-testid="admin-ai-page"]')).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("토큰 계량 커버리지")).toBeVisible();
await expect(page.locator(".aic-table")).toContainText("claude-opus-4-8");
await expect(page.locator(".aic-table")).toContainText("SDK 추정");
const claudeRow = page.locator(".aic-table tbody tr").filter({ hasText: "claude-opus-4-8" });
await expect(claudeRow.locator(".aic-token-cell").first()).not.toHaveText("미계량");
await expect(claudeRow.locator(".aic-token-cell small").first()).toContainText(/\d+\/\d+회/);
await expect(page.getByText(/원장 호출은 과거 토큰 미수집 건/)).toBeVisible();
const provider = page.getByLabel("AI 엔진 공급자");
const model = page.getByLabel("AI 기본 모델");

View file

@ -0,0 +1,387 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import {
expect,
test,
type APIResponse,
type Page,
type TestInfo,
} from "@playwright/test";
import { expectNoHorizontalOverflow, useRealApi } from "./support";
interface ReturnedPracticeFixture {
schema_version: "vignette.returned-practice-browser-fixture.v1";
login: {
email: string;
role: "learner";
display_name: string;
cohort_ids: string[];
};
source_session_id: string;
practice_session_id: string;
deliberate: {
prescription_id: string;
criterion_id: string;
novelty: "familiar" | "unseen_transfer";
mode:
| "replay"
| "branch"
| "constrained_response"
| "voice_retry"
| "difficulty_ladder";
};
transfer: {
prescription_id: string;
suite_id: string;
trial_id: string;
criterion_id: string;
novelty: "unseen_transfer";
mode:
| "counterevidence_forecast"
| "evidence_recall"
| "uncertainty_range"
| "collect_more_evidence";
};
}
interface DeliberateSubmission {
idempotent_replay: boolean;
progress: string;
}
interface DeliberateReadModel {
episodes: Array<{
session_id?: string | null;
attempts?: unknown[];
}>;
}
interface TransferSubmission {
idempotent_replay: boolean;
assessment: {
execution_count: number;
independent_execution_count: number;
observed_execution_count: number;
};
}
interface TransferReadModel {
actual_executions: Array<{
original_transfer_trial_record_id: string;
practice_session_id: string;
}>;
}
const LIVE_GATE = process.env.E2E_RETURNED_PRACTICE_DB_CLOSED_LOOP === "1";
const FIXTURE_PATH = process.env.E2E_RETURNED_PRACTICE_FIXTURE ?? "";
function loadFixture(): ReturnedPracticeFixture {
if (!FIXTURE_PATH) {
throw new Error("E2E_RETURNED_PRACTICE_FIXTURE is required for the live gate");
}
const resolved = path.resolve(FIXTURE_PATH);
return JSON.parse(readFileSync(resolved, "utf8")) as ReturnedPracticeFixture;
}
function launchSearch(
fixture: ReturnedPracticeFixture,
kind: "deliberate" | "transfer",
): string {
const source = kind === "deliberate" ? fixture.deliberate : fixture.transfer;
const search = new URLSearchParams({
launch: kind,
prescription: source.prescription_id,
source_session: fixture.source_session_id,
criterion: source.criterion_id,
novelty: source.novelty,
mode: source.mode,
});
if (kind === "transfer") {
search.set("suite", fixture.transfer.suite_id);
search.set("trial", fixture.transfer.trial_id);
}
return search.toString();
}
async function expectOk(response: APIResponse) {
expect(response.ok(), await response.text()).toBeTruthy();
}
async function signInExistingFixture(page: Page, fixture: ReturnedPracticeFixture) {
const login = await page.request.post("/api/auth/dev-login", {
data: fixture.login,
});
await expectOk(login);
const me = await page.request.get("/api/auth/me");
await expectOk(me);
}
async function installMediaProbe(page: Page) {
await page.addInitScript(() => {
const state = { getUserMedia: 0 };
Object.defineProperty(window, "__returnedPracticeMediaProbe", {
value: state,
configurable: false,
});
const devices = navigator.mediaDevices;
if (!devices?.getUserMedia) return;
Object.defineProperty(devices, "getUserMedia", {
configurable: true,
value: (..._args: unknown[]) => {
state.getUserMedia += 1;
return Promise.reject(new Error("unexpected getUserMedia in review gate"));
},
});
});
}
async function expectNoOpaqueIdsOnScreen(
page: Page,
fixture: ReturnedPracticeFixture,
) {
const text = await page.locator("body").innerText();
const opaqueValues = [
fixture.source_session_id,
fixture.practice_session_id,
fixture.deliberate.prescription_id,
fixture.transfer.prescription_id,
fixture.transfer.suite_id,
fixture.transfer.trial_id,
];
for (const value of opaqueValues) expect(text).not.toContain(value);
expect(text).not.toMatch(
/\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/i,
);
}
async function expectMediaProbeUntouched(page: Page) {
const calls = await page.evaluate(() => {
const target = window as typeof window & {
__returnedPracticeMediaProbe?: { getUserMedia: number };
};
return target.__returnedPracticeMediaProbe?.getUserMedia ?? -1;
});
expect(calls).toBe(0);
}
function isDesktop(testInfo: TestInfo): boolean {
return testInfo.project.name === "chromium-desktop";
}
function runtimeAttemptCount(
readModel: DeliberateReadModel,
practiceSessionId: string,
): number {
return readModel.episodes
.filter((episode) => episode.session_id === practiceSessionId)
.reduce((count, episode) => count + (episode.attempts?.length ?? 0), 0);
}
function actualExecutionCount(
readModel: TransferReadModel,
fixture: ReturnedPracticeFixture,
): number {
return readModel.actual_executions.filter(
(execution) =>
execution.original_transfer_trial_record_id === fixture.transfer.trial_id &&
execution.practice_session_id === fixture.practice_session_id,
).length;
}
async function openReturnedReview(
page: Page,
fixture: ReturnedPracticeFixture,
kind: "deliberate" | "transfer",
) {
await page.goto(
`/learn/session/${fixture.practice_session_id}/review?${launchSearch(fixture, kind)}`,
);
await expect(page.locator("#sr-tab-insights")).toHaveAttribute(
"aria-selected",
"true",
);
const panel = page.locator("#sr-panel-insights");
const selector = kind === "deliberate" ? ".dp-runtime-observation" : ".ct-actual-transfer";
await expect(panel.locator(selector)).toBeVisible({ timeout: 30_000 });
expect(
await panel.evaluate((element, target) => {
const first = element.firstElementChild;
return Boolean(first?.matches(target) || first?.querySelector(target));
}, selector),
).toBe(true);
await expectNoOpaqueIdsOnScreen(page, fixture);
await expectNoHorizontalOverflow(page);
return panel.locator(selector);
}
test.describe("returned-practice browser-only DB closed loop", () => {
test.describe.configure({ mode: "serial" });
test.skip(!LIVE_GATE, "Explicit disposable DB gate only");
let fixture: ReturnedPracticeFixture;
test.beforeEach(async ({ page }) => {
fixture = loadFixture();
expect(fixture.schema_version).toBe(
"vignette.returned-practice-browser-fixture.v1",
);
await useRealApi(page);
await installMediaProbe(page);
await signInExistingFixture(page, fixture);
});
test("returned G4 card performs the real POST, reload, progress comparison, and idempotent replay", async ({
page,
}, testInfo) => {
test.setTimeout(6 * 60_000);
const card = await openReturnedReview(page, fixture, "deliberate");
const firstButton = card.getByRole("button", {
name: isDesktop(testInfo)
? "이번 회기를 독립 관찰로 반영"
: "이번 회기를 독립 관찰로 반영",
});
const postUrl = `/api/practice/${encodeURIComponent(
fixture.deliberate.prescription_id,
)}/attempts/from-session/${fixture.practice_session_id}`;
const firstPost = page.waitForResponse(
(response) =>
response.request().method() === "POST" && response.url().endsWith(postUrl),
{ timeout: 5 * 60_000 },
);
const firstReload = page.waitForResponse(
(response) =>
response.request().method() === "GET" &&
response.url().endsWith("/api/practice/learners/me"),
{ timeout: 5 * 60_000 },
);
await firstButton.click();
const firstResponse = await firstPost;
await expectOk(firstResponse);
const firstBody = (await firstResponse.json()) as DeliberateSubmission;
expect(firstBody.idempotent_replay).toBe(!isDesktop(testInfo));
expect(firstBody.progress).toBeTruthy();
const firstReadResponse = await firstReload;
await expectOk(firstReadResponse);
const firstRead = (await firstReadResponse.json()) as DeliberateReadModel;
const firstCount = runtimeAttemptCount(firstRead, fixture.practice_session_id);
expect(firstCount).toBeGreaterThan(0);
await expect(card.getByText("반영 전", { exact: true })).toBeVisible();
await expect(card.getByText("반영 후", { exact: true })).toBeVisible();
await expect(
card.getByText(
isDesktop(testInfo)
? "새 관찰 근거를 원장에 반영하고 최신 진행 상태를 다시 불러왔습니다."
: "같은 회기 근거를 중복 없이 확인했습니다.",
{ exact: true },
),
).toBeVisible();
const replayPost = page.waitForResponse(
(response) =>
response.request().method() === "POST" && response.url().endsWith(postUrl),
{ timeout: 5 * 60_000 },
);
const replayReload = page.waitForResponse(
(response) =>
response.request().method() === "GET" &&
response.url().endsWith("/api/practice/learners/me"),
{ timeout: 5 * 60_000 },
);
await card.getByRole("button", { name: "반영 상태 다시 확인" }).click();
const replayResponse = await replayPost;
await expectOk(replayResponse);
const replayBody = (await replayResponse.json()) as DeliberateSubmission;
expect(replayBody.idempotent_replay).toBe(true);
const replayReadResponse = await replayReload;
await expectOk(replayReadResponse);
const replayRead = (await replayReadResponse.json()) as DeliberateReadModel;
expect(runtimeAttemptCount(replayRead, fixture.practice_session_id)).toBe(
firstCount,
);
await expect(
card.getByText("같은 회기 근거를 중복 없이 확인했습니다.", {
exact: true,
}),
).toBeVisible();
await expectNoOpaqueIdsOnScreen(page, fixture);
await expectMediaProbeUntouched(page);
});
test("returned G5 card performs the real POST, reload, before-after ledger, and idempotent replay", async ({
page,
}, testInfo) => {
test.setTimeout(6 * 60_000);
const card = await openReturnedReview(page, fixture, "transfer");
const initialAction = isDesktop(testInfo)
? "이 회기를 전이 근거로 확인"
: "같은 회기 기록 다시 확인";
const postUrl = "/api/calibration/transfer-executions";
const firstPost = page.waitForResponse(
(response) =>
response.request().method() === "POST" && response.url().endsWith(postUrl),
{ timeout: 5 * 60_000 },
);
const firstReload = page.waitForResponse(
(response) =>
response.request().method() === "GET" &&
response.url().endsWith("/api/calibration/learners/me"),
{ timeout: 5 * 60_000 },
);
await card.getByRole("button", { name: initialAction }).click();
const firstResponse = await firstPost;
await expectOk(firstResponse);
const firstBody = (await firstResponse.json()) as TransferSubmission;
expect(firstBody.idempotent_replay).toBe(!isDesktop(testInfo));
expect(firstBody.assessment.execution_count).toBe(1);
expect(firstBody.assessment.independent_execution_count).toBe(1);
const firstReadResponse = await firstReload;
await expectOk(firstReadResponse);
const firstRead = (await firstReadResponse.json()) as TransferReadModel;
const firstCount = actualExecutionCount(firstRead, fixture);
expect(firstCount).toBe(1);
await expect(card.getByLabel("실제 전이 근거 변화")).toContainText(
isDesktop(testInfo) ? "0 → 1회" : "1 → 1회",
);
await expect(
card.getByText(
isDesktop(testInfo)
? "이번 완료 회기를 실제 전이 근거로 기록했어."
: "이미 기록된 같은 회기 근거와 일치해. 중복 기록은 만들지 않았어.",
{ exact: true },
),
).toBeVisible();
await expect(card.getByText("최신 원장과 다시 맞춰 봤어", { exact: true })).toBeVisible();
const replayPost = page.waitForResponse(
(response) =>
response.request().method() === "POST" && response.url().endsWith(postUrl),
{ timeout: 5 * 60_000 },
);
const replayReload = page.waitForResponse(
(response) =>
response.request().method() === "GET" &&
response.url().endsWith("/api/calibration/learners/me"),
{ timeout: 5 * 60_000 },
);
await card.getByRole("button", { name: "같은 회기 기록 다시 확인" }).click();
const replayResponse = await replayPost;
await expectOk(replayResponse);
const replayBody = (await replayResponse.json()) as TransferSubmission;
expect(replayBody.idempotent_replay).toBe(true);
const replayReadResponse = await replayReload;
await expectOk(replayReadResponse);
const replayRead = (await replayReadResponse.json()) as TransferReadModel;
expect(actualExecutionCount(replayRead, fixture)).toBe(firstCount);
await expect(
card.getByText(
"이미 기록된 같은 회기 근거와 일치해. 중복 기록은 만들지 않았어.",
{ exact: true },
),
).toBeVisible();
await expectNoOpaqueIdsOnScreen(page, fixture);
await expectMediaProbeUntouched(page);
});
});

View file

@ -0,0 +1,364 @@
import { expect, test, type Page, type Route } from "@playwright/test";
import type {
RuptureEpisode,
RuptureObservation,
RuptureRepairReadModel,
} from "../src/pages/session-review/ruptureRepairApi";
import {
FILLED_REVIEW_SESSION_ID,
filledReviewResponse,
routePrepostMeasures,
} from "./session-review-fixture";
import { expectNoHorizontalOverflow } from "./support";
type ReviewRole = "learner" | "teacher";
const TURN_UUIDS = [
"30000000-0000-4000-8000-000000000001",
"30000000-0000-4000-8000-000000000002",
"30000000-0000-4000-8000-000000000003",
"30000000-0000-4000-8000-000000000004",
"30000000-0000-4000-8000-000000000005",
"30000000-0000-4000-8000-000000000006",
];
const EPISODE_ID = "31000000-0000-4000-8000-000000000001";
const OBSERVATION_ONE_ID = "32000000-0000-4000-8000-000000000001";
const OBSERVATION_TWO_ID = "32000000-0000-4000-8000-000000000002";
const CORRECTION_ID = "32000000-0000-4000-8000-000000000003";
async function fulfillJson(route: Route, body: unknown, status = 200) {
await route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify(body),
});
}
function observations(): RuptureObservation[] {
return [
{
observation_id: OBSERVATION_ONE_ID,
episode_id: EPISODE_ID,
sequence_no: 1,
event_kind: "rupture.detected",
from_state: null,
to_state: "onset",
rupture_type: "withdrawal",
source_kind: "observed_runtime",
perspective: "runtime_observation",
ai_view: "evaluator",
confidence: 0.81,
uncertainty: 0.19,
evidence_turn_ids: [TURN_UUIDS[1]],
counterevidence: [],
model_run_id: null,
supersedes_observation_id: null,
correction_reason: null,
created_at: "2026-08-06T09:10:00Z",
},
{
observation_id: OBSERVATION_TWO_ID,
episode_id: EPISODE_ID,
sequence_no: 2,
event_kind: "repair.partial",
from_state: "repair_attempted",
to_state: "partial",
rupture_type: "withdrawal",
source_kind: "model_inferred",
perspective: "independent_observer",
ai_view: "evaluator",
confidence: 0.78,
uncertainty: 0.22,
evidence_turn_ids: [TURN_UUIDS[2], TURN_UUIDS[3]],
counterevidence: ["회기 말에는 내담자가 다시 감정을 설명했습니다."],
model_run_id: "33000000-0000-4000-8000-000000000001",
supersedes_observation_id: null,
correction_reason: null,
created_at: "2026-08-06T09:14:00Z",
},
];
}
function episode(corrected = false): RuptureEpisode {
const baseObservations = observations();
const correction: RuptureObservation = {
observation_id: CORRECTION_ID,
episode_id: EPISODE_ID,
sequence_no: 3,
event_kind: "human.corrected",
from_state: "partial",
to_state: "resolved",
rupture_type: "withdrawal",
source_kind: "human_rated",
perspective: "supervisor_human",
ai_view: "supervisor",
confidence: null,
uncertainty: 0.1,
evidence_turn_ids: [TURN_UUIDS[3]],
counterevidence: ["내담자의 후속 반응이 안정적으로 이어졌습니다."],
model_run_id: null,
supersedes_observation_id: OBSERVATION_TWO_ID,
correction_reason: "후속 반응 근거를 반영해 수선 확인으로 정정했습니다.",
created_at: "2026-08-06T09:18:00Z",
};
return {
episode_id: EPISODE_ID,
session_id: FILLED_REVIEW_SESSION_ID,
case_id: "34000000-0000-4000-8000-000000000001",
learner_id: "34000000-0000-4000-8000-000000000002",
episode_key: "withdrawal-after-premature-advice",
created_at: "2026-08-06T09:10:00Z",
rupture_type: "withdrawal",
current_status: corrected ? "resolved" : "partial",
status_source: corrected ? "human_correction" : "deep_reconciliation",
observations: corrected ? [...baseObservations, correction] : baseObservations,
reconciliation_revisions: [
{
revision_id: "35000000-0000-4000-8000-000000000001",
episode_id: EPISODE_ID,
revision_no: 1,
supersedes_revision_id: null,
fast_warning_observation_id: OBSERVATION_ONE_ID,
deep_observation_id: OBSERVATION_TWO_ID,
fast_warning_id: "fast-warning-17",
provisional_status: "missed",
deep_status: "partial",
disposition: "superseded_partial",
uncertainty: 0.22,
evidence_turn_ids: [TURN_UUIDS[2], TURN_UUIDS[3]],
counterevidence: ["회기 말에는 내담자가 다시 감정을 설명했습니다."],
model_run_id: "33000000-0000-4000-8000-000000000001",
created_at: "2026-08-06T09:16:00Z",
},
],
safety_references: [
{
episode_id: EPISODE_ID,
safety_event_id: 71,
turn_id: TURN_UUIDS[4],
ko_risk_level: 2,
escalated: true,
created_at: "2026-08-06T09:12:00Z",
},
],
};
}
function readModel(role: ReviewRole, corrected = false): RuptureRepairReadModel {
return {
session_id: FILLED_REVIEW_SESSION_ID,
requested_view: role === "teacher" ? "supervisor" : "counselor",
clinical_claim_allowed: false,
episodes: [episode(corrected)],
};
}
async function routeReviewUser(page: Page, role: ReviewRole) {
await page.route("**/api/auth/me", (route) =>
fulfillJson(route, {
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: "",
}),
);
}
async function routeReviewPage(page: Page, role: ReviewRole) {
const review = filledReviewResponse(FILLED_REVIEW_SESSION_ID);
review.turns = review.turns.map((turn, index) => ({
...turn,
turn_id: TURN_UUIDS[index] ?? null,
}));
if (role === "teacher") {
review.teacherReview = {
status: "viewed",
note: "",
reviewedAt: null,
reviewerId: "00000000-0000-0000-0000-000000000202",
worksheetStatus: "pending",
worksheetNote: "",
worksheetReviewedAt: null,
};
}
await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/review`, (route) =>
fulfillJson(route, review),
);
await page.route(
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/alliance-pulses`,
(route) => fulfillJson(route, { items: [] }),
);
await page.route(
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/outcome-trajectory`,
(route) => fulfillJson(route, { detail: "not found" }, 404),
);
await routePrepostMeasures(page);
}
async function prepareReview(page: Page, role: ReviewRole) {
await routeReviewUser(page, role);
await routeReviewPage(page, role);
}
async function openFeedback(page: Page, role: ReviewRole) {
const root = role === "teacher" ? "/teach/session" : "/learn/session";
await page.goto(`${root}/${FILLED_REVIEW_SESSION_ID}/review`);
await page.getByRole("tab", { name: "피드백" }).click();
return page.locator(".rr-card");
}
test.describe("G3 균열과 수선 원장", () => {
test("학습자에게 현재 판정, 출처, 근거, 안전 원장과 로컬 연습 준비를 보여준다", async ({
page,
}, testInfo) => {
await prepareReview(page, "learner");
await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/ruptures`, (route) =>
fulfillJson(route, readModel("learner")),
);
const card = await openFeedback(page, "learner");
await expect(card.getByRole("heading", { name: "관계가 어긋난 장면과 다시 맞춘 근거를 봅니다" })).toBeVisible();
await expect(card.getByText("학습자 역할 보기", { exact: true })).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("22%", { exact: true })).toBeVisible();
await expect(card.getByText("빠른 경고", { exact: true })).toBeVisible();
await expect(
card.getByLabel("빠른 경고와 깊은 재조정 출처").getByText("깊은 재조정", { exact: true }),
).toBeVisible();
await expect(card.getByText("부분 수선으로 갱신", { exact: false })).toBeVisible();
await expect(card.getByRole("heading", { name: "현재 판정 근거" })).toBeVisible();
await expect(card.getByRole("heading", { name: "반대 근거" })).toBeVisible();
await expect(card.getByRole("heading", { name: "안전 원장" })).toBeVisible();
await expect(card.getByText("균열·수선 판정과 합산하지 않는 별도 확인 영역입니다.")).toBeVisible();
await expect(card.getByText(/총점|평균/)).toHaveCount(0);
await expect(card.getByText("사람 판정으로 정정")).toHaveCount(0);
const practice = card.locator(".rr-practice");
const firstItem = practice.getByLabel("균열이 시작된 발화를 다시 확인하기");
await firstItem.check();
await expect(firstItem).toBeChecked();
await page.reload();
await page.getByRole("tab", { name: "피드백" }).click();
await expect(page.locator(".rr-practice").getByLabel("균열이 시작된 발화를 다시 확인하기")).toBeChecked();
const refreshedCard = page.locator(".rr-card");
await refreshedCard.getByRole("button", { name: /발화로 이동/ }).first().click();
await expect(page.getByRole("tab", { name: "축어록" })).toHaveAttribute("aria-selected", "true");
await expect(page.locator(".sr-turn--active")).toHaveCount(1);
await page.getByRole("tab", { name: "피드백" }).click();
await refreshedCard.locator(".rr-provenance").screenshot({
path: testInfo.outputPath(`rupture-provenance-${testInfo.project.name}.png`),
animations: "disabled",
});
await refreshedCard.locator(".rr-evidence-grid").screenshot({
path: testInfo.outputPath(`rupture-evidence-${testInfo.project.name}.png`),
animations: "disabled",
});
await refreshedCard.locator(".rr-practice").screenshot({
path: testInfo.outputPath(`rupture-practice-${testInfo.project.name}.png`),
animations: "disabled",
});
await expectNoHorizontalOverflow(page);
});
test("404와 역할별 빈 원장은 차분한 빈 상태로 처리한다", async ({ page }) => {
await prepareReview(page, "learner");
await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/ruptures`, (route) =>
fulfillJson(route, { detail: "rupture ledger not found" }, 404),
);
const card = await openFeedback(page, "learner");
await expect(card.getByRole("heading", { name: "아직 검토할 관계 장면이 없습니다" })).toBeVisible();
await expect(card.getByRole("button", { name: "다시 불러오기" })).toHaveCount(0);
await expectNoHorizontalOverflow(page);
});
test("교수자는 최신 관찰을 supersede하고 성공 뒤 read model을 다시 불러온다", async ({
page,
}, testInfo) => {
await prepareReview(page, "teacher");
let corrected = false;
let getCount = 0;
const submissions: Array<Record<string, unknown>> = [];
await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/ruptures`, async (route) => {
getCount += 1;
await fulfillJson(route, readModel("teacher", corrected));
});
await page.route(
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/ruptures/${EPISODE_ID}/corrections`,
async (route) => {
submissions.push(route.request().postDataJSON() as Record<string, unknown>);
corrected = true;
await fulfillJson(route, { episode_id: EPISODE_ID, observation_id: CORRECTION_ID }, 201);
},
);
const card = await openFeedback(page, "teacher");
await expect(card.getByText("교수자 역할 보기", { exact: true })).toBeVisible();
await expect(card.locator(".rr-practice")).toHaveCount(0);
await card.getByText("사람 판정으로 정정", { exact: true }).click();
const form = card.locator(".rr-correction form");
await form.getByLabel("정정 상태").selectOption("resolved");
await form.getByLabel("정정 이유").fill("후속 반응이 안정적으로 이어진 근거를 반영합니다.");
await form.getByRole("button", { name: "정정 기록 추가" }).click();
await expect(form.getByText("사람 판정을 새 관찰로 추가하고 최신 원장을 다시 불러왔습니다.")).toBeVisible();
await expect(card.getByText("사람 판정이 최신", { exact: true })).toBeVisible();
await expect(card.getByText("수선 확인", { exact: true }).first()).toBeVisible();
expect(getCount).toBeGreaterThanOrEqual(2);
expect(submissions).toHaveLength(1);
expect(submissions[0].supersedes_observation_id).toBe(OBSERVATION_TWO_ID);
expect(submissions[0].evidence_turn_ids).toEqual([TURN_UUIDS[2], TURN_UUIDS[3]]);
await card.locator(".rr-correction").screenshot({
path: testInfo.outputPath(`rupture-correction-${testInfo.project.name}.png`),
animations: "disabled",
});
await expectNoHorizontalOverflow(page);
});
test("정정 오류 재시도는 동일 idempotency UUID와 본문을 보존한다", async ({ page }) => {
await prepareReview(page, "teacher");
await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/ruptures`, (route) =>
fulfillJson(route, readModel("teacher")),
);
const submissions: Array<Record<string, unknown>> = [];
await page.route(
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/ruptures/${EPISODE_ID}/corrections`,
async (route) => {
submissions.push(route.request().postDataJSON() as Record<string, unknown>);
if (submissions.length === 1) {
await fulfillJson(route, { detail: "temporary correction failure" }, 503);
return;
}
await fulfillJson(route, { episode_id: EPISODE_ID, observation_id: CORRECTION_ID }, 201);
},
);
const card = await openFeedback(page, "teacher");
await card.getByText("사람 판정으로 정정", { exact: true }).click();
const form = card.locator(".rr-correction form");
await form.getByLabel("정정 이유").fill("사람 검토 근거를 반영합니다.");
await form.getByRole("button", { name: "정정 기록 추가" }).click();
await expect(form.getByRole("alert")).toContainText("API 503");
await form.getByRole("button", { name: "같은 요청 다시 제출" }).click();
await expect(form.getByText(/최신 원장을 다시 불러왔습니다/)).toBeVisible();
expect(submissions).toHaveLength(2);
expect(submissions[1]).toEqual(submissions[0]);
expect(submissions[1].idempotency_key).toBe(submissions[0].idempotency_key);
});
});

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,6 @@
import { expect, test, type Page } from "@playwright/test";
import {
completeAlliancePreCheckpoint,
expectNoDocumentOverflow,
expectNoHorizontalOverflow,
fetchAvailablePersona,
@ -424,6 +425,7 @@ test.describe("learner session full-screen layout", () => {
await expectNoHorizontalOverflow(page);
await page.getByRole("button", { name: "회기 시작" }).click();
await completeAlliancePreCheckpoint(page);
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
await expect(page).toHaveURL(/\/learn\/session\/[0-9a-f-]+$/i);
@ -474,6 +476,7 @@ test.describe("learner session full-screen layout", () => {
await page.setViewportSize(viewports[0]);
await page.goto(`/learn/session/${persona.code}`);
await page.getByRole("button", { name: "회기 시작" }).click();
await completeAlliancePreCheckpoint(page);
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
for (const viewport of viewports) {
@ -511,6 +514,7 @@ test.describe("learner session full-screen layout", () => {
const persona = await fetchAvailablePersona(page, 1);
await page.goto(`/learn/session/${persona.code}`);
await page.getByRole("button", { name: "회기 시작" }).click();
await completeAlliancePreCheckpoint(page);
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
await page.route("**/api/sessions/*/stream", async (route) => {
@ -538,6 +542,7 @@ test.describe("learner session full-screen layout", () => {
const persona = await fetchAvailablePersona(page, 1);
await page.goto(`/learn/session/${persona.code}`);
await page.getByRole("button", { name: "회기 시작" }).click();
await completeAlliancePreCheckpoint(page);
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
await page.route("**/api/sessions/*/stream", async (route) => {

View file

@ -1,4 +1,7 @@
import path from "node:path";
import { expect, test, type Page } from "@playwright/test";
import { parseVoicePracticeContext } from "../src/lib/voicePracticeContext";
import { expectNoHorizontalOverflow } from "./support";
const sessionId = "33333333-3333-4333-8333-333333333333";
const learnerText = "요즘 많이 힘들었겠어요. 어떤 마음이 가장 크게 남아 있나요?";
@ -13,6 +16,14 @@ declare global {
__voiceCrisisFixture?: {
sent: string[];
};
__voiceLifecycleFixture?: {
connections: number;
sent: string[];
releaseFinal: () => void;
dropBeforeReply: () => void;
releaseSavedReplyDegraded: () => void;
releaseEmptyFinal: () => void;
};
}
}
@ -34,16 +45,32 @@ interface RouteMvpOptions {
liveCoachPersistenceSource?: "database" | "runtime";
liveCoachHistoryQuotas?: Array<{ remaining: number; max: number }>;
liveCoachSuggestionQuota?: { remaining: number; max: number };
alliancePreLocked?: boolean;
}
async function routeMvpApi(page: Page, options: RouteMvpOptions = {}) {
const sessionStartRequests: unknown[] = [];
const liveCoachRequests: unknown[] = [];
const alliancePulseRequests: Array<{ checkpoint?: string }> = [];
const alliancePulseItems: Array<Record<string, unknown>> = [];
let liveCoachHistoryRequests = 0;
let deliveredCoachSuggestion: Record<string, unknown> | null = null;
const streamSeen = deferred();
const streamGate = deferred();
if (options.alliancePreLocked !== false) {
alliancePulseItems.push({
pulse_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
checkpoint: "pre",
status: "ready",
learner_locked_at: new Date().toISOString(),
revealed_at: new Date().toISOString(),
error_code: null,
self_scores: { goal: 0.5, task: 0.5, bond: 0.5 },
measurements: [],
});
}
await page.route("**/api/auth/me", async (route) => {
await route.fulfill({
status: 200,
@ -163,6 +190,45 @@ async function routeMvpApi(page: Page, options: RouteMvpOptions = {}) {
});
});
// 이 파일은 코칭/위기/종료 회귀를 검증한다. 새 회기 전 펄스 자체는
// alliance-checkpoint 전용 시나리오에서 다루고, 여기서는 이미 잠긴 원장을 제공한다.
await page.route(`**/api/sessions/${sessionId}/alliance-pulses`, async (route) => {
if (route.request().method() === "POST") {
const body = route.request().postDataJSON() as {
checkpoint?: "pre" | "mid" | "post";
scores?: Record<string, number>;
};
alliancePulseRequests.push(body);
const pulseId =
body.checkpoint === "mid"
? "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
alliancePulseItems.push({
pulse_id: pulseId,
checkpoint: body.checkpoint,
status: "awaiting_agents",
learner_locked_at: new Date().toISOString(),
revealed_at: null,
error_code: null,
self_scores: body.scores,
measurements: [],
});
await route.fulfill({
status: 202,
contentType: "application/json",
body: JSON.stringify({ pulse_id: pulseId, status: "awaiting_agents" }),
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: alliancePulseItems,
}),
});
});
await page.route(`**/api/sessions/${sessionId}/stream`, async (route) => {
streamSeen.resolve();
await streamGate.promise;
@ -369,6 +435,7 @@ async function routeMvpApi(page: Page, options: RouteMvpOptions = {}) {
return {
sessionStartRequests,
liveCoachRequests,
alliancePulseRequests,
streamGate,
streamSeen,
get liveCoachHistoryRequests() {
@ -639,7 +706,468 @@ async function installVoiceCrisisFixture(page: Page, transcriptText: string) {
}, transcriptText);
}
async function installVoiceLifecycleFixture(page: Page) {
await page.addInitScript(() => {
const firstInterim = "요즘 잠을";
const firstFinal = "요즘 잠을 잘 못 자요.";
const secondInterim = "오늘은 조금";
const secondFinal = "오늘은 조금 더 천천히 말해볼게요.";
const savedReply = "그렇게 말해주시니 조금 안심돼요.";
const sockets: FixtureWebSocket[] = [];
const fixture = {
connections: 0,
sent: [] as string[],
releaseFinal() {
const socket = sockets[0];
if (!socket || socket.readyState !== FixtureWebSocket.OPEN) return;
socket.emitJson({ type: "state", state: "thinking" });
socket.emitJson({ type: "transcript", text: firstFinal, final: true });
},
dropBeforeReply() {
const socket = sockets[0];
if (!socket || socket.readyState !== FixtureWebSocket.OPEN) return;
socket.close(1011);
},
releaseSavedReplyDegraded() {
const socket = sockets[1];
if (!socket || socket.readyState !== FixtureWebSocket.OPEN) return;
socket.emitJson({ type: "state", state: "thinking" });
socket.emitJson({ type: "transcript", text: secondFinal, final: true });
socket.emitJson({
type: "reply",
text: savedReply,
turn_seq: 2,
stage: "탐색",
effective_openness: 0.52,
});
socket.emitJson({ type: "state", state: "speaking" });
socket.emitJson({ type: "degraded", reason: "TTS failed: provider disconnected" });
},
releaseEmptyFinal() {
const socket = sockets[2];
if (!socket || socket.readyState !== FixtureWebSocket.OPEN) return;
socket.emitJson({ type: "state", state: "thinking" });
socket.emitJson({ type: "transcript", text: "", final: true });
socket.emitJson({ type: "state", state: "idle" });
},
};
window.__voiceLifecycleFixture = fixture;
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: {
getUserMedia: async () => {
const fakeTrack = {
kind: "audio",
readyState: "live",
stop() {
this.readyState = "ended";
},
};
return {
active: true,
getTracks: () => [fakeTrack],
getAudioTracks: () => [fakeTrack],
};
},
},
});
Object.defineProperty(window, "AudioContext", { configurable: true, value: undefined });
class FakeMediaRecorder extends EventTarget {
static isTypeSupported() {
return true;
}
state = "inactive";
mimeType = "audio/webm";
ondataavailable: ((event: Event & { data: Blob }) => void) | null = null;
onstop: ((event: Event) => void) | null = null;
constructor(_stream: unknown, options?: { mimeType?: string }) {
super();
this.mimeType = options?.mimeType ?? "audio/webm";
}
start() {
this.state = "recording";
}
stop() {
if (this.state === "inactive") return;
this.state = "inactive";
const event = new Event("stop");
this.onstop?.(event);
this.dispatchEvent(event);
}
}
Object.defineProperty(window, "MediaRecorder", {
configurable: true,
value: FakeMediaRecorder,
});
class FixtureWebSocket {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
readonly connectionNumber: number;
readonly isVoiceSocket: boolean;
readyState = FixtureWebSocket.CONNECTING;
binaryType: BinaryType = "blob";
onopen: ((event: Event) => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
onclose: ((event: CloseEvent) => void) | null = null;
constructor(url: string | URL) {
this.isVoiceSocket = String(url).includes("/voice/ws");
if (!this.isVoiceSocket) {
this.connectionNumber = 0;
return;
}
fixture.connections += 1;
this.connectionNumber = fixture.connections;
sockets.push(this);
window.setTimeout(() => {
if (this.readyState !== FixtureWebSocket.CONNECTING) return;
this.readyState = FixtureWebSocket.OPEN;
this.onopen?.(new Event("open"));
this.emitJson({ type: "ready", state: "idle" });
}, 0);
}
send(data: string | ArrayBufferLike | Blob | ArrayBufferView) {
if (!this.isVoiceSocket) return;
if (typeof data !== "string") return;
fixture.sent.push(data);
let payload: { type?: string } = {};
try {
payload = JSON.parse(data) as { type?: string };
} catch {
return;
}
if (payload.type !== "audio_end") return;
window.setTimeout(() => {
if (this.connectionNumber === 1) {
this.emitJson({ type: "transcript", text: firstInterim, final: false });
} else if (this.connectionNumber === 2) {
this.emitJson({ type: "transcript", text: secondInterim, final: false });
}
this.emitJson({
type: "eot",
ready: false,
reason: "insufficient_silence",
silence_ms: 240,
threshold_ms: 700,
});
this.emitJson({ type: "state", state: "listening" });
}, 0);
}
close(code = 1000) {
if (this.readyState === FixtureWebSocket.CLOSED) return;
this.readyState = FixtureWebSocket.CLOSED;
const event = new Event("close") as CloseEvent;
Object.defineProperty(event, "code", { value: code });
this.onclose?.(event);
}
emitJson(payload: unknown) {
if (this.readyState === FixtureWebSocket.CLOSED) return;
this.onmessage?.(new MessageEvent("message", { data: JSON.stringify(payload) }));
}
}
Object.defineProperty(window, "WebSocket", {
configurable: true,
value: FixtureWebSocket as unknown as typeof WebSocket,
});
});
}
async function selectAllianceScore(
page: Page,
axis: "목표" | "과업" | "유대",
scoreName: "3 보통이다" | "4 대체로 그렇다",
) {
const group = page.getByRole("group", { name: new RegExp(`^${axis}`) });
await group.getByRole("radio", { name: scoreName }).check();
}
test.describe("P1 MVP core loop", () => {
test("discloses the synthetic client voice before and during use on desktop and mobile", async ({
page,
}, testInfo) => {
const disclosure = "내담자 음성은 AI가 생성한 합성 음성이며 사람의 목소리가 아닙니다.";
await page.setViewportSize({ width: 1440, height: 900 });
await routeMvpApi(page);
await page.goto("/learn/session/P1");
const prestartDisclosure = page.locator(".sx-prestart__voice-disclosure");
await expect(prestartDisclosure).toBeVisible();
await expect(prestartDisclosure).toHaveText(disclosure);
await expect(prestartDisclosure).toHaveAttribute("role", "note");
await expectNoHorizontalOverflow(page);
await page.setViewportSize({ width: 390, height: 844 });
await expect(prestartDisclosure).toBeVisible();
await expectNoHorizontalOverflow(page);
await prestartDisclosure.scrollIntoViewIfNeeded();
await page.screenshot({
path: testInfo.outputPath("ai-voice-disclosure-prestart-mobile.png"),
animations: "disabled",
});
await page.getByRole("button", { name: "회기 시작" }).click();
const mobileDisclosure = page.locator(".sx-controlbar__voice-disclosure");
await expect(mobileDisclosure).toBeVisible();
await expect(mobileDisclosure).toHaveText(disclosure);
await expect(page.locator(".sx-mic-block__disclosure")).toBeHidden();
await expectNoHorizontalOverflow(page);
await mobileDisclosure.scrollIntoViewIfNeeded();
await page.screenshot({
path: testInfo.outputPath("ai-voice-disclosure-active-mobile.png"),
animations: "disabled",
});
await page.setViewportSize({ width: 1440, height: 900 });
const desktopDisclosure = page.locator(".sx-mic-block__disclosure");
await expect(desktopDisclosure).toBeVisible();
await expect(desktopDisclosure).toHaveText(disclosure);
await expect(mobileDisclosure).toBeHidden();
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath("ai-voice-disclosure-active-desktop.png"),
animations: "disabled",
fullPage: true,
});
});
test("preserves a transfer prescription through session creation and review", async ({
page,
}, testInfo) => {
await routeMvpApi(page);
const query = new URLSearchParams({
launch: "transfer",
prescription: "transfer-prescription-01",
suite: "transfer-suite-01",
trial: "transfer-trial-01",
source_session: "source-session-01",
criterion: "competency.empathic_reflection",
novelty: "unseen_transfer",
mode: "counterevidence_forecast",
});
await page.goto(`/learn/session/P1?${query}`);
await expect(
page.getByRole("heading", { name: "전이 검증 · 반대근거 예측" }),
).toBeVisible();
await expect(page.locator(".sx-practice-launch-context")).toContainText(
"처음 보는 장면",
);
await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page).toHaveURL(new RegExp(`/learn/session/${sessionId}\\?`));
const persisted = new URL(page.url());
expect(persisted.searchParams.get("launch")).toBe("transfer");
expect(persisted.searchParams.get("prescription")).toBe(
"transfer-prescription-01",
);
expect(persisted.searchParams.get("suite")).toBe("transfer-suite-01");
expect(persisted.searchParams.get("trial")).toBe("transfer-trial-01");
expect(persisted.searchParams.get("source_session")).toBe(
"source-session-01",
);
expect(persisted.searchParams.get("novelty")).toBe("unseen_transfer");
expect(persisted.searchParams.get("mode")).toBe(
"counterevidence_forecast",
);
await page.getByRole("button", { name: "회기 종료" }).click();
await page.getByRole("button", { name: "종료하고 리뷰 보기" }).click();
await expect(page).toHaveURL(new RegExp(`/learn/session/${sessionId}/review\\?`));
const reviewUrl = new URL(page.url());
expect(reviewUrl.searchParams.get("launch")).toBe("transfer");
expect(reviewUrl.searchParams.get("prescription")).toBe(
"transfer-prescription-01",
);
expect(reviewUrl.searchParams.get("suite")).toBe("transfer-suite-01");
expect(reviewUrl.searchParams.get("trial")).toBe("transfer-trial-01");
await expect(
page.getByRole("heading", {
name: "반대근거 예측 수행 회기의 리뷰입니다.",
}),
).toBeVisible();
const retry = page.getByRole("button", {
name: "같은 전이 과제로 다시 연습",
});
await expect(retry).toBeVisible();
expect((await retry.boundingBox())?.height ?? 0).toBeGreaterThanOrEqual(44);
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath("practice-return-review-desktop.png"),
animations: "disabled",
fullPage: true,
});
await retry.focus();
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/\/learn\/practice\?/);
const retryUrl = new URL(page.url());
expect(retryUrl.searchParams.get("launch")).toBe("transfer");
expect(retryUrl.searchParams.get("trial")).toBe("transfer-trial-01");
});
test("keeps voice provenance legible on mobile and blocks malformed handoffs", async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 });
await routeMvpApi(page);
await page.route("**/api/sessions/dashboard", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({}),
}),
);
await page.route("**/api/sessions", (route) => {
if (route.request().method() !== "GET") return route.fallback();
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ sessions: [] }),
});
});
const voiceQuery = new URLSearchParams({
mode: "voice",
source_session: "source-session-01",
source_scene: "oas-g7-event-silence-1",
scene_type: "silence",
scene_start_ms: "18000",
scene_end_ms: "26400",
});
await page.goto(`/learn/session/${sessionId}/review?${voiceQuery}`);
await expect(
page.getByRole("heading", {
name: "침묵 뒤 응답 수행 회기의 리뷰입니다.",
}),
).toBeVisible();
const retry = page.getByRole("button", { name: "같은 장면을 다시 연습" });
await expect(retry).toBeVisible();
expect((await retry.boundingBox())?.height ?? 0).toBeGreaterThanOrEqual(44);
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath("voice-practice-return-review-mobile.png"),
animations: "disabled",
fullPage: true,
});
await retry.focus();
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/\/learn\/practice\?/);
expect(parseVoicePracticeContext(new URL(page.url()).searchParams)).toEqual({
mode: "voice",
sourceSessionId: "source-session-01",
sourceSceneId: "oas-g7-event-silence-1",
sceneType: "silence",
sceneStartMs: 18000,
sceneEndMs: 26400,
});
const malformedQuery = new URLSearchParams({
mode: "voice",
source_session: "source-session-01",
source_scene: "oas-g7-event-silence-1",
scene_type: "silence",
scene_start_ms: "18000",
});
expect(parseVoicePracticeContext(malformedQuery)).toBeNull();
await page.goto(`/learn/practice?${malformedQuery}`);
await expect(
page.getByRole("heading", { name: "원본 회기 정보를 확인할 수 없습니다." }),
).toBeVisible();
await expect(page.getByRole("button", { name: "새 회기 시작" })).toBeDisabled();
await expectNoHorizontalOverflow(page);
await page.goto(`/learn/session/P1?${malformedQuery}`);
await expect(
page.getByRole("heading", { name: "음성 재연습의 출처를 다시 확인해 주세요." }),
).toBeVisible();
await expect(page.getByRole("button", { name: "회기 시작" })).toBeDisabled();
await expectNoHorizontalOverflow(page);
await page.goto(`/learn/session/${sessionId}/review?${malformedQuery}`);
await expect(
page.getByRole("heading", {
name: "이 리뷰의 연습 출처를 검증할 수 없습니다.",
}),
).toBeVisible();
const recover = page.getByRole("button", { name: "피드백에서 다시 선택" });
await recover.focus();
await page.keyboard.press("Enter");
await expect(page.getByRole("tab", { name: "피드백" })).toHaveAttribute(
"aria-selected",
"true",
);
await expectNoHorizontalOverflow(page);
});
test("locks pre before the first turn and offers a persistent mid-session pulse", async ({
page,
}, testInfo) => {
const api = await routeMvpApi(page, { alliancePreLocked: false });
await page.goto("/learn/session/P1");
await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page.getByRole("heading", { name: "첫 발화 전에 내 기준을 잠급니다" })).toBeVisible();
await expect(page.getByLabel("학습자 발화 입력")).toBeDisabled();
await page.screenshot({
path: path.resolve(
process.cwd(),
"../../docs/ops/evidence",
`g1-alliance-pre-${testInfo.project.name}-2026-08-07.png`,
),
fullPage: true,
});
await selectAllianceScore(page, "목표", "3 보통이다");
await selectAllianceScore(page, "과업", "3 보통이다");
await selectAllianceScore(page, "유대", "3 보통이다");
await page.getByRole("button", { name: "기준 잠그고 첫 발화 준비" }).click();
await expect.poll(() => api.alliancePulseRequests.map((item) => item.checkpoint)).toEqual(["pre"]);
await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled();
await page.getByLabel("학습자 발화 입력").fill(learnerText);
await page.getByRole("button", { name: "보내기" }).click();
await api.streamSeen.promise;
api.streamGate.resolve();
await expect(page.getByRole("button", { name: "30초 점검" })).toBeVisible();
await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled();
await page.getByRole("button", { name: "30초 점검" }).click();
await page.screenshot({
path: path.resolve(
process.cwd(),
"../../docs/ops/evidence",
`g1-alliance-mid-${testInfo.project.name}-2026-08-07.png`,
),
fullPage: true,
});
await selectAllianceScore(page, "목표", "4 대체로 그렇다");
await selectAllianceScore(page, "과업", "4 대체로 그렇다");
await selectAllianceScore(page, "유대", "4 대체로 그렇다");
await page.getByRole("button", { name: "중간 판단 잠그고 이어가기" }).click();
await expect.poll(() => api.alliancePulseRequests.map((item) => item.checkpoint)).toEqual([
"pre",
"mid",
]);
await expect(page.getByText("회기 전·중 판단이 원장에 잠겼습니다.")).toBeVisible();
});
test("runs login, P1 text stream, session end, and review feedback @single-run", async ({
page,
}) => {
@ -863,6 +1391,98 @@ test.describe("P1 MVP core loop", () => {
await expect(page.locator(".sx-mic-block__l")).toContainText("마이크 오류");
});
test("keeps provider transcript lifecycle visible and offers keyboard-safe voice recovery @single-run", async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.emulateMedia({ reducedMotion: "reduce", colorScheme: "light" });
await routeMvpApi(page);
await installVoiceLifecycleFixture(page);
await page.goto("/learn/session/P1");
await page.getByRole("button", { name: "회기 시작" }).click();
const pauseButton = page.getByRole("button", { name: "일시정지" });
await pauseButton.focus();
await pauseButton.press("Space");
await expect(page.getByRole("button", { name: "이어가기" })).toBeVisible();
expect(await page.evaluate(() => window.__voiceLifecycleFixture?.connections)).toBe(0);
await page.getByRole("button", { name: "이어가기" }).click();
await page.getByRole("button", { name: "마이크 켜기" }).click();
await page.getByRole("button", { name: "발화 보내기" }).click();
const transcriptLog = page.getByRole("log", { name: "실시간 상담 축어록" });
await expect(transcriptLog).toHaveAttribute("aria-live", "polite");
const firstLearnerBubble = transcriptLog.locator(".sx-utt.is-learner").filter({
hasText: "요즘 잠을",
});
await expect(firstLearnerBubble).toHaveCount(1);
await expect(firstLearnerBubble).toHaveClass(/is-partial/);
await expect(firstLearnerBubble.getByText("실시간 전사")).toBeVisible();
await expect(page.locator(".sx-mic-block__h")).toContainText("발화 종료를 확인하지 못했습니다");
await page.evaluate(() => window.__voiceLifecycleFixture?.releaseFinal());
await expect(firstLearnerBubble).toContainText("요즘 잠을 잘 못 자요.");
await expect(firstLearnerBubble.getByText("전사 확정, 응답 연결 중")).toBeVisible();
await expect(transcriptLog.locator(".sx-utt.is-thinking")).toContainText("답변을 준비 중입니다.");
await page.evaluate(() => window.__voiceLifecycleFixture?.dropBeforeReply());
await expect(firstLearnerBubble).toHaveClass(/is-failed/);
await expect(firstLearnerBubble).not.toHaveClass(/is-partial/);
await expect(page.getByRole("alert")).toContainText("음성 연결이 종료되어 발화를 저장하지 못했습니다");
const retryButton = page.getByRole("button", { name: "음성 다시 연결" });
await expect(retryButton).toBeVisible();
const retryBox = await retryButton.boundingBox();
expect(retryBox?.height ?? 0).toBeGreaterThanOrEqual(44);
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath("voice-recovery-before-reply-mobile-light.png"),
fullPage: true,
});
await retryButton.focus();
await retryButton.press("Space");
await expect(page.getByRole("button", { name: "발화 보내기" })).toBeVisible();
expect(await page.evaluate(() => window.__voiceLifecycleFixture?.connections)).toBe(2);
await page.getByRole("button", { name: "발화 보내기" }).click();
const secondLearnerBubble = transcriptLog.locator(".sx-utt.is-learner").filter({
hasText: "오늘은 조금",
});
await expect(secondLearnerBubble).toHaveCount(1);
await expect(secondLearnerBubble.getByText("실시간 전사")).toBeVisible();
await page.evaluate(() => window.__voiceLifecycleFixture?.releaseSavedReplyDegraded());
await expect(secondLearnerBubble).toContainText("오늘은 조금 더 천천히 말해볼게요.");
await expect(secondLearnerBubble).not.toHaveClass(/is-partial/);
await expect(secondLearnerBubble).not.toHaveClass(/is-failed/);
await expect(transcriptLog.getByText("그렇게 말해주시니 조금 안심돼요.")).toBeVisible();
await expect(page.getByRole("alert")).toHaveCount(0);
await expect(page.getByRole("button", { name: "음성 다시 연결" })).toBeVisible();
await expect(page.locator(".sx-mic-block__h")).toContainText("내담자 응답은 저장됐지만");
await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled();
await page.evaluate(() => {
document.documentElement.setAttribute("data-theme", "dark");
localStorage.setItem("vignette.theme", "dark");
});
await expectNoHorizontalOverflow(page);
await page.screenshot({
path: testInfo.outputPath("voice-recovery-after-reply-mobile-dark.png"),
fullPage: true,
});
await page.getByRole("button", { name: "음성 다시 연결" }).click();
await page.getByRole("button", { name: "발화 보내기" }).click();
await page.evaluate(() => window.__voiceLifecycleFixture?.releaseEmptyFinal());
await expect(transcriptLog.locator(".sx-utt.is-learner")).toHaveCount(2);
await expect(page.locator(".sx-mic-block__h")).toContainText("음성을 인식하지 못했습니다");
await expect(transcriptLog.locator(".sx-utt.is-thinking")).toHaveCount(0);
await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled();
});
test("keeps crisis safety gate visible for a voice conversation stop", async ({ page }) => {
const crisisText = "죽고 싶다는 생각이 자꾸 들어요.";
const api = await routeMvpApi(page);

View file

@ -1,5 +1,6 @@
import { expect, test, type Page } from "@playwright/test";
import {
completeAlliancePreCheckpoint,
fetchAvailablePersona,
signInAsAdmin,
signInAsLearner,
@ -176,6 +177,15 @@ async function expectResponseOk(response: { ok: () => boolean; text: () => Promi
}
}
async function expectTextTurnSettled(page: Page) {
const input = page.getByLabel("학습자 발화 입력");
await input.fill("후속 발화 준비 확인");
await expect(page.getByRole("button", { name: "보내기" })).toBeEnabled({
timeout: 90_000,
});
await input.fill("");
}
async function setupSyntheticVoiceUiProbe(page: Page, transcript: string) {
await page.addInitScript((text) => {
type ProbeMessage = {
@ -596,6 +606,7 @@ test.describe("session persistence", () => {
await page.goto(`/learn/session/${persona.code}`);
await page.getByRole("button", { name: "회기 시작" }).click();
await completeAlliancePreCheckpoint(page);
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
await expect(page).toHaveURL(/\/learn\/session\/[0-9a-f-]+$/i);
const sessionId = new URL(page.url()).pathname.split("/").at(-1);
@ -622,7 +633,30 @@ test.describe("session persistence", () => {
const clientUtterance = page.locator(".sx-utt.is-client").first();
await expect(clientUtterance).toBeVisible();
await expect(clientUtterance).not.toContainText("답변을 준비 중입니다.");
await expect(input).toBeEnabled({ timeout: 90_000 });
await expectTextTurnSettled(page);
await expect
.poll(
async () => {
const response = await page.request.get(`/api/sessions/${sessionId}/review`);
await expectResponseOk(response);
const candidate = (await response.json()) as SessionReviewResponse;
return {
learner: candidate.turns.some(
(turn) => turn.speaker === "learner" && turn.text === learnerText,
),
client: candidate.turns.some(
(turn) => turn.speaker === "client" && turn.text.trim().length > 0,
),
};
},
{
timeout: 15_000,
intervals: [100, 250, 500, 1_000],
message: "SSE done 뒤 양쪽 발화가 DB-backed review에 보여야 한다",
},
)
.toEqual({ learner: true, client: true });
const reviewResponse = await page.request.get(`/api/sessions/${sessionId}/review`);
await expectResponseOk(reviewResponse);
@ -654,6 +688,7 @@ test.describe("session persistence", () => {
await page.goto(`/learn/session/${persona.code}`);
await page.getByRole("button", { name: "회기 시작" }).click();
await completeAlliancePreCheckpoint(page);
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
await expect(page).toHaveURL(/\/learn\/session\/[0-9a-f-]+$/i);
const sessionId = new URL(page.url()).pathname.split("/").at(-1);
@ -679,7 +714,7 @@ test.describe("session persistence", () => {
const clientUtterance = page.locator(".sx-utt.is-client").first();
await expect(clientUtterance).toBeVisible();
await expect(clientUtterance).not.toContainText("답변을 준비 중입니다.");
await expect(input).toBeEnabled({ timeout: 90_000 });
await expectTextTurnSettled(page);
const detailResponse = await page.request.get(`/api/sessions/${sessionId}`);
await expectResponseOk(detailResponse);
@ -723,6 +758,7 @@ test.describe("session persistence", () => {
await page.goto(`/learn/session/${persona.code}`);
await page.getByRole("button", { name: "회기 시작" }).click();
await completeAlliancePreCheckpoint(page);
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
await expect(page).toHaveURL(/\/learn\/session\/[0-9a-f-]+$/i);
const sessionId = new URL(page.url()).pathname.split("/").at(-1);
@ -803,6 +839,7 @@ test.describe("session persistence", () => {
await page.goto(`/learn/session/${persona.code}`);
await page.getByRole("button", { name: "회기 시작" }).click();
await completeAlliancePreCheckpoint(page);
await page.getByRole("button", { name: "코칭" }).click();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
await expect(page).toHaveURL(/\/learn\/session\/[0-9a-f-]+$/i);
@ -833,7 +870,7 @@ test.describe("session persistence", () => {
await expect(page.locator(".sx-utt.is-client.is-thinking")).toHaveCount(0, {
timeout: 90_000,
});
await expect(input).toBeEnabled({ timeout: 90_000 });
await expectTextTurnSettled(page);
const coachResponse = await coachResponsePromise;
await expectResponseOk(coachResponse);
@ -1034,6 +1071,7 @@ test.describe("session persistence", () => {
await page.goto(`/learn/session/${persona.code}`);
await page.getByRole("button", { name: "회기 시작" }).click();
await completeAlliancePreCheckpoint(page);
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
await expect(page).toHaveURL(/\/learn\/session\/[0-9a-f-]+$/i);
const sessionId = new URL(page.url()).pathname.split("/").at(-1);
@ -1042,6 +1080,11 @@ test.describe("session persistence", () => {
const mic = page.locator(".sx-mic");
await expect(mic).toBeEnabled();
await mic.click();
const voiceConsentDialog = page.getByRole("dialog", { name: "음성 입력을 사용하기 전에" });
await expect(voiceConsentDialog).toBeVisible();
await voiceConsentDialog
.getByRole("button", { name: "동의하고 마이크 켜기" })
.click();
await expect
.poll(async () => (await readVoiceUiProbe(page)).getUserMediaCalls, { timeout: 10_000 })
.toBeGreaterThan(0);

View file

@ -0,0 +1,386 @@
import { expect, test, type Page } from "@playwright/test";
import type {
ResearchViewResponse,
SupervisionViewResponse,
} from "../src/pages/supervisionResearchApi";
import { expectNoHorizontalOverflow } from "./support";
const FORBIDDEN_VERBATIM = "내담자가 실제로 말한 비공개 원문";
function routeUnmockedApi(page: Page) {
return page.route("**/api/**", (route) =>
route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ detail: "not part of the focused G6 fixture" }),
}),
);
}
function routeAuth(page: Page, role: "teacher" | "admin") {
return page.route("**/auth/me", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
user_id:
role === "teacher"
? "62000000-0000-0000-0000-000000000002"
: "62000000-0000-0000-0000-000000000003",
email: `${role}@hs.ac.kr`,
role,
display_name: role === "teacher" ? "E2E Teacher" : "E2E Research Admin",
admin_access: role === "admin",
super_admin: false,
account_status: "approved",
approval_required: false,
cohort_ids: ["g6-cohort"],
consent_at: 1782820000,
onboarding_completed_at: 1782820001,
nickname: role === "teacher" ? "E2E Teacher" : "E2E Research Admin",
self_introduction: "",
avatar_url: "",
}),
}),
);
}
function supervisionFixture(): SupervisionViewResponse {
return {
attention_items: [
{
item_id: "g6-attention-risk",
snapshot_id: "g6-snapshot-001",
learner_id: "62000000-0000-0000-0000-000000000101",
learner_ref: "learner-risk",
cohort_id: "g6-cohort",
queue_position: 1,
primary_signal: "deterioration",
oldest_active_sequence: 4,
drilldown_routes: [
"/teach/analysis?learner=learner-risk&tab=outcome",
"/teach/analysis?learner=learner-risk&tab=safety",
"/teach/session/g6-session-risk/review",
],
evidence_pointer_ids: [
"pointer-outcome-risk",
"pointer-safety-risk",
"pointer-alliance-risk",
"pointer-hidden-by-cap",
],
created_at: "2026-08-06T03:00:00Z",
},
{
item_id: "g6-attention-stagnation",
snapshot_id: "g6-snapshot-001",
learner_id: "62000000-0000-0000-0000-000000000102",
learner_ref: "learner-stagnation",
cohort_id: "g6-cohort",
queue_position: 2,
primary_signal: "growth_stagnation",
oldest_active_sequence: 7,
drilldown_routes: ["/teach/analysis?learner=learner-stagnation&tab=practice"],
evidence_pointer_ids: ["pointer-practice-stagnation"],
created_at: "2026-08-06T03:00:00Z",
},
{
item_id: "g6-attention-rupture",
snapshot_id: "g6-snapshot-001",
learner_id: "62000000-0000-0000-0000-000000000103",
learner_ref: "learner-rupture",
cohort_id: "g6-cohort",
queue_position: 3,
primary_signal: "unresolved_rupture",
oldest_active_sequence: 9,
drilldown_routes: ["/teach/analysis?learner=learner-rupture&tab=rupture"],
evidence_pointer_ids: ["pointer-rupture-open"],
created_at: "2026-08-06T03:00:00Z",
},
],
curriculum_gaps: [
{
gap_snapshot_id: "g6-gap-001",
cohort_id: "g6-cohort",
competency_id: "competency.rupture-repair",
gap_kind: "rupture_repair",
status: "observed",
uncertainty: 0.18,
affected_learner_count: 4,
evidence_pointer_ids: ["pointer-gap-1", "pointer-gap-2"],
created_at: "2026-08-06T03:02:00Z",
},
{
gap_snapshot_id: "g6-gap-002",
cohort_id: "g6-cohort",
competency_id: "competency.transfer-context",
gap_kind: "transfer",
status: "insufficient_evidence",
uncertainty: 1,
affected_learner_count: 0,
evidence_pointer_ids: [],
created_at: "2026-08-06T03:02:00Z",
},
],
clinical_claim_allowed: false,
};
}
function researchFixture(): ResearchViewResponse {
return {
calibration_dataset: [
{
dataset_row_id: "g6-dataset-row-001",
row_hash: "a".repeat(64),
disagreement_record_id: "g6-disagreement-001",
case_ref: "synthetic-case-001",
competency_id: "competency.rupture-repair",
ai_label: "resolved",
teacher_label: "partial",
ai_model: "evaluator-v2",
prompt_version: "2.3.0",
instrument_id: "rupture-repair-evaluator",
instrument_version: "1.4.0",
correction_reason_code: "repair_impact_not_confirmed",
evidence_pointer_ids: ["pointer-ai-001", "pointer-teacher-001"],
raw_transcript_included: false,
created_at: "2026-08-06T03:04:00Z",
},
],
drift_reports: [
{
drift_report_id: "g6-drift-001",
cohort_id: "g6-cohort",
matched_count: 12,
status: "drift_flagged",
baseline_accuracy: 0.83,
candidate_accuracy: 0.67,
accuracy_delta: -0.16,
disagreement_case_refs: ["synthetic-case-b2", "synthetic-case-b3"],
alerts: [
"overall_accuracy_regression",
"synthetic_subgroup_regression:synthetic-low-disclosure",
],
evidence_pointer_ids: ["pointer-drift-baseline", "pointer-drift-candidate"],
created_at: "2026-08-06T03:06:00Z",
baseline_model: "evaluator-v1",
candidate_model: "evaluator-v2",
baseline_prompt_version: "1.8.0",
candidate_prompt_version: "2.3.0",
instrument_id: "alliance-evaluation-suite",
baseline_instrument_version: "1.1.0",
candidate_instrument_version: "1.4.0",
subgroup_metrics: [
{
subgroup: "synthetic-low-disclosure",
matched_count: 4,
baseline_accuracy: 0.75,
candidate_accuracy: 0.5,
accuracy_delta: -0.25,
},
{
subgroup: "synthetic-high-resistance",
matched_count: 4,
baseline_accuracy: 0.75,
candidate_accuracy: 0.75,
accuracy_delta: 0,
},
],
},
],
phase3_manifests: [
{
manifest_id: "g6-manifest-001",
cohort_id: "g6-cohort",
schema_version: "vignette.phase3-outcome-evidence-manifest.v1",
artifact_count: 4,
created_at: "2026-08-06T03:08:00Z",
artifacts: [
{
domain: "alliance",
artifact_id: "artifact-alliance-v1",
schema_version: "alliance.v1",
content_sha256: "1".repeat(64),
record_count: 24,
provenance_uri: "db://measurement/alliance",
clinical_claim_allowed: false,
},
{
domain: "rupture",
artifact_id: "artifact-rupture-v1",
schema_version: "rupture.v1",
content_sha256: "2".repeat(64),
record_count: 18,
provenance_uri: "db://measurement/rupture",
clinical_claim_allowed: false,
},
{
domain: "transfer",
artifact_id: "artifact-transfer-v1",
schema_version: "transfer.v1",
content_sha256: "3".repeat(64),
record_count: 16,
provenance_uri: "audit://transfer/suite-v1",
clinical_claim_allowed: false,
},
{
domain: "calibration",
artifact_id: "artifact-calibration-v1",
schema_version: "calibration.v1",
content_sha256: "4".repeat(64),
record_count: 20,
provenance_uri: "repo://evidence/calibration-v1",
clinical_claim_allowed: false,
},
],
},
],
raw_transcript_included: false,
clinical_claim_allowed: false,
};
}
async function routeG6(
page: Page,
role: "teacher" | "admin",
supervision = supervisionFixture(),
research = researchFixture(),
) {
await routeUnmockedApi(page);
await routeAuth(page, role);
await page.route("**/api/supervision-research/supervision-view", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(supervision),
}),
);
await page.route("**/api/supervision-research/research-view", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(research),
}),
);
}
test.describe("G6 supervision and research OS", () => {
test("teacher follows a three-click-bounded evidence queue without research controls", async ({
page,
}) => {
await routeG6(page, "teacher");
await page.goto("/teach/supervision");
await page.evaluate(() => document.documentElement.setAttribute("data-theme", "dark"));
await expect(
page.getByRole("heading", {
name: "먼저 볼 경로와, 믿을 수 있는 출처를 분리해 본다.",
}),
).toBeVisible();
await expect(page.getByRole("tab", { name: "교수 감독" })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(page.getByRole("tab", { name: "연구 품질" })).toHaveCount(0);
await expect(page.getByText("위험", { exact: true })).toBeVisible();
await expect(page.getByText("정체", { exact: true })).toBeVisible();
await expect(page.getByText("미해결 관계 사건", { exact: true }).first()).toBeVisible();
const stagnation = page.getByRole("button", { name: /learner-stagnation/ });
await stagnation.focus();
await page.keyboard.press("Enter");
await expect(stagnation).toHaveAttribute("aria-pressed", "true");
await page.getByRole("button", { name: /learner-risk/ }).click();
await expect(page.locator(".g6-ledger li")).toHaveCount(3);
await expect(page.locator(".g6-drilldown a")).toHaveCount(3);
await expect(page.getByTestId("drilldown-depth")).toContainText("계약 상한 3번");
await expect(page.getByRole("heading", { name: "개인의 순위를 만들지 않는 교육과정 공백" })).toBeVisible();
await expect(page.getByText("competency.rupture-repair").first()).toBeVisible();
await expect(page.getByText("근거 없는 상태를 유지하고 해석하지 않아.")).toBeVisible();
await expect(page.getByText("resolved", { exact: true })).toBeVisible();
await expect(page.getByText("partial", { exact: true })).toBeVisible();
await expect(page.getByText("원문 축어록 제외")).toBeVisible();
await expect(page.getByText(/XP|총점/i)).toHaveCount(0);
await expect(page.getByRole("button", { name: /확정|승인|판정 저장/ })).toHaveCount(0);
await expect(page.getByText(FORBIDDEN_VERBATIM)).toHaveCount(0);
await expectNoHorizontalOverflow(page);
});
test("research role traces model, prompt, instrument, subgroup, and four-domain provenance", async ({
page,
}) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await routeG6(page, "admin");
await page.goto("/teach/supervision");
await page.getByRole("tab", { name: "연구 품질" }).click();
await expect(page.getByRole("heading", { name: "모델·프롬프트·도구·하위집단 드리프트" })).toBeVisible();
await expect(page.getByText("evaluator-v1", { exact: true })).toBeVisible();
await expect(
page.getByLabel("모델 프롬프트 도구 버전 출처").getByText("evaluator-v2", { exact: true }),
).toBeVisible();
await expect(page.getByText("prompt 1.8.0", { exact: true })).toBeVisible();
await expect(page.getByText("prompt 2.3.0", { exact: true })).toBeVisible();
await expect(page.getByText("alliance-evaluation-suite", { exact: true })).toBeVisible();
await expect(page.getByText("synthetic-low-disclosure", { exact: true })).toBeVisible();
await expect(page.getByText("-25%p", { exact: true })).toBeVisible();
await expect(page.getByText("4/4 provenance 연결", { exact: true })).toBeVisible();
for (const label of ["동맹", "파열·수선", "전이", "보정"]) {
await expect(page.getByText(label, { exact: true }).first()).toBeVisible();
}
await expect(page.getByText("db://measurement/alliance", { exact: true })).toBeVisible();
await expect(page.getByText("audit://transfer/suite-v1", { exact: true })).toBeVisible();
await expect(page.getByText("repo://evidence/calibration-v1", { exact: true })).toBeVisible();
await expect(page.getByText(FORBIDDEN_VERBATIM)).toHaveCount(0);
await expect(page.getByText(/XP|총점/i)).toHaveCount(0);
const activeTab = page.getByRole("tab", { name: "연구 품질" });
await expect(activeTab).toHaveAttribute("tabindex", "0");
expect(
await page.evaluate(() => window.matchMedia("(prefers-reduced-motion: reduce)").matches),
).toBe(true);
const reducedTransitionSeconds = await activeTab.evaluate((element) =>
Number.parseFloat(window.getComputedStyle(element).transitionDuration),
);
expect(reducedTransitionSeconds).toBeLessThanOrEqual(0.00001);
await expectNoHorizontalOverflow(page);
});
test("empty and summary-only contracts stay explicit instead of inventing evidence", async ({
page,
}) => {
const emptySupervision: SupervisionViewResponse = {
attention_items: [],
curriculum_gaps: [],
clinical_claim_allowed: false,
};
const summaryOnlyResearch: ResearchViewResponse = {
calibration_dataset: [],
drift_reports: [],
phase3_manifests: [
{
manifest_id: "g6-manifest-summary-only",
cohort_id: "g6-cohort",
schema_version: "vignette.phase3-outcome-evidence-manifest.v1",
artifact_count: 4,
created_at: "2026-08-06T03:08:00Z",
},
],
raw_transcript_included: false,
clinical_claim_allowed: false,
};
await routeG6(page, "admin", emptySupervision, summaryOnlyResearch);
await page.goto("/teach/supervision");
await expect(page.getByText("현재 우선 검토 항목이 없어")).toBeVisible();
await expect(page.getByText("현재 교육과정 공백이 없어")).toBeVisible();
await expect(page.getByText("교수자AI 불일치 메타데이터가 없어")).toBeVisible();
await page.getByRole("tab", { name: "연구 품질" }).click();
await expect(page.getByText("버전 드리프트 비교가 없어")).toBeVisible();
await expect(page.getByText("provenance 저하", { exact: true })).toBeVisible();
await expect(page.getByText("매니페스트 요약만 도착했어")).toBeVisible();
await expect(page.getByText("원본 provenance 미제공").first()).toBeVisible();
await expectNoHorizontalOverflow(page);
});
});

View file

@ -68,12 +68,15 @@ export async function withGlobalEngineConfigLock<T>(
}
}
const E2E_COHORT_ID = "e2e-hanshin";
export async function signInAsLearner(page: Page) {
const res = await page.request.post("/api/auth/dev-login", {
data: {
email: uniqueE2EEmail("learner", "hs.ac.kr"),
role: "learner",
display_name: "E2E Learner",
cohort_ids: [E2E_COHORT_ID],
},
});
expect(res.ok(), await res.text()).toBeTruthy();
@ -93,6 +96,7 @@ export async function signInAsTeacher(page: Page) {
email: uniqueE2EEmail("teacher", "hs.ac.kr"),
role: "teacher",
display_name: "E2E Teacher",
cohort_ids: [E2E_COHORT_ID],
},
});
expect(res.ok(), await res.text()).toBeTruthy();
@ -167,6 +171,75 @@ export async function fetchAvailablePersona(page: Page, index = 0): Promise<E2EP
return personas[index] ?? personas[0];
}
/**
* append-only Alliance pre .
* API를 E2E가 fail-closed .
*/
export async function completeAlliancePreCheckpoint(page: Page) {
const heading = page.getByRole("heading", {
name: "첫 발화 전에 내 기준을 잠급니다",
});
await expect(heading).toBeVisible({ timeout: 15_000 });
for (const axis of ["목표", "과업", "유대"] as const) {
/* elapsed/HMR checkpoint subtree . poll
label , DOM의 checked .
input property를 gate를 . */
await expect
.poll(
async () => {
const group = page.getByRole("group", { name: new RegExp(`^${axis}`) });
const selected = group.getByRole("radio", { name: "3 보통이다" });
if (await selected.isChecked().catch(() => false)) return true;
const label = selected.locator("xpath=ancestor::label[1]");
const box = await label.boundingBox().catch(() => null);
if (!box) return false;
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
return page
.getByRole("group", { name: new RegExp(`^${axis}`) })
.getByRole("radio", { name: "3 보통이다" })
.isChecked()
.catch(() => false);
},
{
message: `${axis} 동맹 기준이 실제 포인터 입력으로 선택되어야 한다`,
timeout: 15_000,
intervals: [50, 100, 200, 400],
},
)
.toBe(true);
}
const saved = page.waitForResponse((response) => {
const url = new URL(response.url());
return (
response.request().method() === "POST" &&
url.pathname.includes("/sessions/") &&
url.pathname.includes("/alliance-pulses")
);
}, { timeout: 15_000 });
const submit = page.getByRole("button", {
name: "기준 잠그고 첫 발화 준비",
});
await submit.scrollIntoViewIfNeeded();
const currentSubmit = page.getByRole("button", {
name: "기준 잠그고 첫 발화 준비",
});
await expect(currentSubmit).toBeEnabled();
const submitBox = await currentSubmit.boundingBox();
expect(submitBox, "Alliance pre 저장 버튼 좌표").not.toBeNull();
await page.mouse.click(
(submitBox?.x ?? 0) + (submitBox?.width ?? 0) / 2,
(submitBox?.y ?? 0) + (submitBox?.height ?? 0) / 2,
);
const response = await saved;
expect(response.ok(), await response.text()).toBeTruthy();
await expect(
page.locator('.sx-page--active textarea[aria-label="학습자 발화 입력"]'),
).toBeEnabled({ timeout: 15_000 });
}
export async function expectNoHorizontalOverflow(page: Page) {
await expect
.poll(async () => {

View file

@ -3,6 +3,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { existsSync } from "node:fs";
import http, { type IncomingMessage, type ServerResponse } from "node:http";
import net from "node:net";
import { completeAlliancePreCheckpoint } from "./support";
interface TestServer {
url: string;
@ -37,6 +38,7 @@ interface VoiceUiProbeMessage {
interface VoiceUiProbeState {
getUserMediaCalls: number;
webSocketConstructs: number;
recorderStarts: number;
recorderStops: number;
workletModuleLoads: number;
@ -51,8 +53,8 @@ interface VoiceUiProbeState {
closeEvents: number[];
}
// This fixture intentionally starts a DB-offline API with ALLOW_SEED_PERSONA_FALLBACK=true
// so the voice provider cascade can be exercised without a Postgres dependency.
// The provider servers are controlled, while session, consent, and alliance records use the
// configured development Postgres so production fail-closed persistence remains exercised.
const SEEDED_VOICE_PERSONA_CODE = "P1";
function readBody(req: IncomingMessage): Promise<Buffer> {
@ -222,9 +224,8 @@ async function startApi({
AUTH_DEV_LOGIN_ENABLED: "true",
AUTH_ALLOWED_EMAIL_DOMAINS: '["hs.ac.kr","twentyoz.kr"]',
ALLOW_SEED_PERSONA_FALLBACK: "true",
DATABASE_URL: "postgresql://user:pass@127.0.0.1:1/vignette",
DB_POOL_MIN_SIZE: "0",
DB_COMMAND_TIMEOUT: "1",
DB_POOL_MIN_SIZE: "1",
DB_COMMAND_TIMEOUT: "10",
ENGINE_URL: engineURL,
ENGINE_MODE: "claude_api",
ENGINE_TIMEOUT: "10",
@ -266,6 +267,67 @@ async function startApi({
};
}
async function completeControlledVoicePreflight(
page: Page,
apiBaseURL: string,
sessionId: string,
): Promise<void> {
const result = await page.evaluate(
async ({ apiBase, session }) => {
const alliance = await fetch(`${apiBase}/sessions/${session}/alliance-pulses`, {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({
checkpoint: "pre",
scores: { goal: 0.5, task: 0.5, bond: 0.5 },
evidence_turn_ids: [],
}),
});
const allianceBody = await alliance.text();
if (!alliance.ok && alliance.status !== 409) {
return {
ok: false,
step: "alliance",
status: alliance.status,
body: allianceBody,
};
}
const consent = await fetch(
`${apiBase}/sessions/${session}/multimodal-alliance/consent`,
{
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({
submission_id: crypto.randomUUID(),
consent_status: "granted",
retain_audio: false,
retain_derived_features: true,
transcript_retained: true,
retention_days: 1,
policy_version: "voice-provider-e2e-v1",
reason_code: "controlled_provider_test",
}),
},
);
const consentBody = await consent.text();
if (!consent.ok) {
return {
ok: false,
step: "consent",
status: consent.status,
body: consentBody,
};
}
return { ok: true };
},
{ apiBase: apiBaseURL, session: sessionId },
);
expect(result).toMatchObject({ ok: true });
}
async function waitForWeb(baseURL: string, proc: ChildProcessWithoutNullStreams): Promise<void> {
const started = Date.now();
let lastError = "";
@ -384,7 +446,7 @@ async function probeVoiceCascade(page: Page, apiBaseURL: string, sessionId: stri
async function installSyntheticVoiceCapture(
page: Page,
options: { blockMediaElementPlayback?: boolean } = {},
options: { blockMediaElementPlayback?: boolean; getUserMediaDelayMs?: number } = {},
): Promise<void> {
await page.addInitScript((opts) => {
type ProbeMessage = {
@ -395,6 +457,7 @@ async function installSyntheticVoiceCapture(
};
type ProbeState = {
getUserMediaCalls: number;
webSocketConstructs: number;
recorderStarts: number;
recorderStops: number;
workletModuleLoads: number;
@ -411,6 +474,7 @@ async function installSyntheticVoiceCapture(
const w = window as Window & { __voiceUiProbe?: ProbeState };
const probe: ProbeState = {
getUserMediaCalls: 0,
webSocketConstructs: 0,
recorderStarts: 0,
recorderStops: 0,
workletModuleLoads: 0,
@ -445,6 +509,9 @@ async function installSyntheticVoiceCapture(
value: {
getUserMedia: async () => {
probe.getUserMediaCalls += 1;
if ((opts.getUserMediaDelayMs ?? 0) > 0) {
await new Promise((resolve) => window.setTimeout(resolve, opts.getUserMediaDelayMs));
}
return fakeStream;
},
},
@ -515,6 +582,7 @@ async function installSyntheticVoiceCapture(
constructor(url: string | URL, protocols?: string | string[]) {
if (protocols === undefined) super(url);
else super(url, protocols);
if (String(url).includes("/voice/ws")) probe.webSocketConstructs += 1;
this.addEventListener("message", (event) => {
if (typeof event.data === "string") {
probe.messages.push({ direction: "received", kind: "text", data: event.data });
@ -817,6 +885,7 @@ test.describe("voice cascade success path", () => {
expect(browserSetup, api.logs()).toMatchObject({ ok: true });
if (!browserSetup.ok) throw new Error(JSON.stringify(browserSetup));
const started = browserSetup.started;
await completeControlledVoicePreflight(page, api.baseURL, started.session_id);
const result = await probeVoiceCascade(page, api.baseURL, started.session_id);
const events = result.messages.map((message) => JSON.parse(message) as { type: string; [key: string]: unknown });
@ -875,7 +944,10 @@ test.describe("voice cascade success path", () => {
}
});
await installSyntheticVoiceCapture(page, { blockMediaElementPlayback: true });
await installSyntheticVoiceCapture(page, {
blockMediaElementPlayback: true,
getUserMediaDelayMs: 400,
});
const openai = await startFakeOpenAI();
const engine = await startFakeEngine();
@ -985,6 +1057,7 @@ test.describe("voice cascade success path", () => {
`pageText=${await page.locator("#root").innerText().catch(() => "")}`,
].join("\n\n"),
).toBeVisible({ timeout: 20_000 });
await completeAlliancePreCheckpoint(page);
const textInput = page.getByLabel("학습자 발화 입력");
const sendButton = page.getByRole("button", { name: "보내기", exact: true });
@ -1016,16 +1089,64 @@ test.describe("voice cascade success path", () => {
}, { apiBase: api.baseURL, personaCode: SEEDED_VOICE_PERSONA_CODE });
expect(freshVoiceSession, api.logs()).toMatchObject({ ok: true });
expect(typeof freshVoiceSession.body.session_id).toBe("string");
await completeControlledVoicePreflight(
page,
api.baseURL,
freshVoiceSession.body.session_id ?? "",
);
let uiConsentLedgerWrites = 0;
await page.route("**/sessions/*/multimodal-alliance/consent", async (route) => {
if (route.request().method() === "POST") {
uiConsentLedgerWrites += 1;
await new Promise((resolve) => setTimeout(resolve, 300));
}
await route.continue();
});
await page.goto(`${web.baseURL}/learn/session/${freshVoiceSession.body.session_id}`);
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 20_000 });
const mic = page.locator(".sx-mic");
await expect(mic).toBeEnabled();
await page.keyboard.press("Space");
await expect.poll(async () => (await readVoiceUiProbe(page)).getUserMediaCalls).toBe(0);
await expect.poll(async () => (await readVoiceUiProbe(page)).webSocketConstructs).toBe(0);
await mic.click();
const consentDialog = page.getByRole("dialog", { name: "음성 입력을 사용하기 전에" });
await expect(consentDialog).toBeVisible();
if (process.env.CAPTURE_G7_CONSENT === "1") {
const desktopViewport = page.viewportSize() ?? { width: 1440, height: 900 };
await page.screenshot({ path: "test-results/g7-voice-consent-desktop.png" });
await page.setViewportSize({ width: 390, height: 844 });
await expect(consentDialog).toBeVisible();
await page.screenshot({ path: "test-results/g7-voice-consent-mobile.png" });
await page.setViewportSize(desktopViewport);
}
await expect.poll(async () => (await readVoiceUiProbe(page)).getUserMediaCalls).toBe(0);
await expect.poll(async () => (await readVoiceUiProbe(page)).webSocketConstructs).toBe(0);
await consentDialog.getByRole("button", { name: "텍스트로 계속" }).click();
await expect(consentDialog).toBeHidden();
await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled();
await expect.poll(async () => (await readVoiceUiProbe(page)).getUserMediaCalls).toBe(0);
await expect.poll(async () => (await readVoiceUiProbe(page)).webSocketConstructs).toBe(0);
await page.keyboard.press("Alt+m");
await expect(consentDialog).toBeVisible();
await expect.poll(async () => (await readVoiceUiProbe(page)).getUserMediaCalls).toBe(0);
await expect.poll(async () => (await readVoiceUiProbe(page)).webSocketConstructs).toBe(0);
await consentDialog.getByRole("button", { name: "동의하고 마이크 켜기" }).click();
await expect(consentDialog.getByRole("button", { name: "동의 기록 중…" })).toBeVisible();
await expect.poll(async () => (await readVoiceUiProbe(page)).getUserMediaCalls).toBe(0);
await expect.poll(async () => (await readVoiceUiProbe(page)).webSocketConstructs).toBe(0);
await expect
.poll(async () => (await readVoiceUiProbe(page)).getUserMediaCalls, { timeout: 10_000 })
.toBeGreaterThan(0);
.toBe(1);
await expect
.poll(async () => (await readVoiceUiProbe(page)).webSocketConstructs, { timeout: 10_000 })
.toBe(1);
expect(uiConsentLedgerWrites).toBe(1);
await expect
.poll(async () => (await readVoiceUiProbe(page)).workletModuleLoads, { timeout: 10_000 })
.toBeGreaterThan(0);
@ -1136,6 +1257,42 @@ test.describe("voice cascade success path", () => {
expect.arrayContaining(["POST /v1/audio/transcriptions", "POST /v1/audio/speech"]),
);
expect(engine.requests()).toContain("POST /v1/generate");
const stalePermissionSession = await page.evaluate(async ({ apiBase, personaCode }) => {
const response = await fetch(`${apiBase}/sessions`, {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({ persona_code: personaCode, theory_mode: "humanistic" }),
});
return {
ok: response.ok,
status: response.status,
body: await response.json() as { session_id?: string },
};
}, { apiBase: api.baseURL, personaCode: SEEDED_VOICE_PERSONA_CODE });
expect(stalePermissionSession, api.logs()).toMatchObject({ ok: true });
const staleSessionId = stalePermissionSession.body.session_id ?? "";
await completeControlledVoicePreflight(page, api.baseURL, staleSessionId);
await page.goto(`${web.baseURL}/learn/session/${staleSessionId}`);
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 20_000 });
const beforeStaleAttempt = await readVoiceUiProbe(page);
await page.keyboard.press("Alt+m");
const staleConsentDialog = page.getByRole("dialog", { name: "음성 입력을 사용하기 전에" });
await expect(staleConsentDialog).toBeVisible();
await staleConsentDialog.getByRole("button", { name: "동의하고 마이크 켜기" }).click();
await expect
.poll(async () => (await readVoiceUiProbe(page)).getUserMediaCalls)
.toBe(beforeStaleAttempt.getUserMediaCalls + 1);
await page.keyboard.press("p");
await expect(page.getByRole("button", { name: "이어가기" })).toBeVisible();
await expect
.poll(async () => (await readVoiceUiProbe(page)).trackStops, { timeout: 5_000 })
.toBe(beforeStaleAttempt.trackStops + 1);
await expect.poll(async () => (await readVoiceUiProbe(page)).webSocketConstructs).toBe(
beforeStaleAttempt.webSocketConstructs,
);
} finally {
await web.stop();
await api.stop();