G0~G8 성과·동맹 측정 OS 작업 일괄 고정
8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본 폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을 git 이력으로 고정하는 것이 목적이다. - contracts/routes/services: measurement, outcome_trajectory, rupture_repair, deliberate_practice, calibration_transfer, supervision_research, multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트 - infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행) - apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가 - docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG - scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트 engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
This commit is contained in:
parent
93dd8f82d7
commit
16e791e044
390 changed files with 243188 additions and 499 deletions
|
|
@ -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);
|
||||
|
|
|
|||
324
apps/web/e2e/alliance-pulse.spec.ts
Normal file
324
apps/web/e2e/alliance-pulse.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
1067
apps/web/e2e/calibration-transfer.spec.ts
Normal file
1067
apps/web/e2e/calibration-transfer.spec.ts
Normal file
File diff suppressed because it is too large
Load diff
366
apps/web/e2e/continuous-improvement-admin.spec.ts
Normal file
366
apps/web/e2e/continuous-improvement-admin.spec.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
518
apps/web/e2e/continuous-improvement-live.spec.ts
Normal file
518
apps/web/e2e/continuous-improvement-live.spec.ts
Normal 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,
|
||||
})}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
882
apps/web/e2e/deliberate-practice.spec.ts
Normal file
882
apps/web/e2e/deliberate-practice.spec.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
477
apps/web/e2e/harness/prepare-returned-practice-db.py
Normal file
477
apps/web/e2e/harness/prepare-returned-practice-db.py
Normal 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()
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
688
apps/web/e2e/multimodal-alliance.spec.ts
Normal file
688
apps/web/e2e/multimodal-alliance.spec.ts
Normal file
|
|
@ -0,0 +1,688 @@
|
|||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
import {
|
||||
FILLED_REVIEW_SESSION_ID,
|
||||
filledReviewResponse,
|
||||
routeFilledSessionReview,
|
||||
routePrepostMeasures,
|
||||
} from "./session-review-fixture";
|
||||
import { expectNoHorizontalOverflow } from "./support";
|
||||
|
||||
type Role = "learner" | "teacher";
|
||||
|
||||
function routeReviewUser(page: Page, role: Role) {
|
||||
return page.route("**/api/auth/me", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
user_id:
|
||||
role === "teacher"
|
||||
? "00000000-0000-0000-0000-000000000202"
|
||||
: "00000000-0000-0000-0000-000000000101",
|
||||
email: `${role}@hs.ac.kr`,
|
||||
role,
|
||||
display_name: role === "teacher" ? "E2E Teacher" : "E2E Learner",
|
||||
admin_access: false,
|
||||
super_admin: false,
|
||||
account_status: "approved",
|
||||
approval_required: false,
|
||||
cohort_ids: ["e2e-hanshin"],
|
||||
consent_at: 1782820000,
|
||||
onboarding_completed_at: 1782820001,
|
||||
nickname: role === "teacher" ? "E2E Teacher" : "E2E Learner",
|
||||
self_introduction: "",
|
||||
avatar_url: "",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||
return route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function measurement(
|
||||
axis: "goal" | "task" | "bond",
|
||||
modality: "text" | "voice",
|
||||
options: { status?: "ready" | "error"; value?: number } = {},
|
||||
) {
|
||||
const status = options.status ?? "ready";
|
||||
return {
|
||||
measurement_id: `oas-g7-measurement-${axis}-${modality}`,
|
||||
axis,
|
||||
modality,
|
||||
status,
|
||||
value: status === "ready" ? (options.value ?? 0.7) : null,
|
||||
confidence: status === "ready" ? 0.78 : null,
|
||||
uncertainty: status === "ready" ? 0.22 : 1,
|
||||
evidence_refs:
|
||||
status === "ready"
|
||||
? [modality === "text" ? "turn:t4" : "oas-g7-event-interruption-1"]
|
||||
: [],
|
||||
model_run_id:
|
||||
status === "ready" ? "00000000-0000-0000-0000-000000000701" : null,
|
||||
instrument_id: `${modality}-alliance-observer`,
|
||||
instrument_version: "1.0.0",
|
||||
model_name: modality === "text" ? "text-observer-v1" : "voice-observer-v1",
|
||||
prompt_version: "g7-v1",
|
||||
source_kind: modality === "text" ? "masked_transcript" : "observed_audio_runtime",
|
||||
error_code: status === "error" ? "voice_runtime_unavailable" : null,
|
||||
created_at: "2026-08-06T09:02:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
function silentWav(durationMs = 1_000): Buffer {
|
||||
const sampleRate = 8_000;
|
||||
const dataLength = Math.floor(sampleRate * durationMs / 1_000) * 2;
|
||||
const wav = Buffer.alloc(44 + dataLength);
|
||||
wav.write("RIFF", 0);
|
||||
wav.writeUInt32LE(36 + dataLength, 4);
|
||||
wav.write("WAVEfmt ", 8);
|
||||
wav.writeUInt32LE(16, 16);
|
||||
wav.writeUInt16LE(1, 20);
|
||||
wav.writeUInt16LE(1, 22);
|
||||
wav.writeUInt32LE(sampleRate, 24);
|
||||
wav.writeUInt32LE(sampleRate * 2, 28);
|
||||
wav.writeUInt16LE(2, 32);
|
||||
wav.writeUInt16LE(16, 34);
|
||||
wav.write("data", 36);
|
||||
wav.writeUInt32LE(dataLength, 40);
|
||||
return wav;
|
||||
}
|
||||
|
||||
function rawAudioFixture() {
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
audio_asset_id: "00000000-0000-0000-0000-000000000740",
|
||||
session_id: FILLED_REVIEW_SESSION_ID,
|
||||
learner_id: "00000000-0000-0000-0000-000000000101",
|
||||
audio_ref: "private://must-not-render",
|
||||
audio_sha256: "b".repeat(64),
|
||||
media_type: "audio/wav",
|
||||
byte_size: 16_044,
|
||||
duration_ms: 120_000,
|
||||
retained_until: "2026-09-05T09:00:00Z",
|
||||
created_at: "2026-08-06T09:01:00Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function multimodalFixture(options: { consent?: "granted" | "withdrawn" | "none" } = {}) {
|
||||
const consent = options.consent ?? "granted";
|
||||
const timelineId = "00000000-0000-0000-0000-000000000710";
|
||||
const event = (
|
||||
eventId: string,
|
||||
eventType: string,
|
||||
startMs: number,
|
||||
endMs: number,
|
||||
actor: string,
|
||||
observedFeature: string,
|
||||
) => ({
|
||||
timeline_id: timelineId,
|
||||
event_id: eventId,
|
||||
event_type: eventType,
|
||||
start_ms: startMs,
|
||||
end_ms: endMs,
|
||||
actor,
|
||||
observed_feature: observedFeature,
|
||||
uncertainty: 0.18,
|
||||
source: "observed_audio_runtime",
|
||||
claim_scope: "interaction_signal",
|
||||
clinical_claim_allowed: false,
|
||||
});
|
||||
return {
|
||||
session_id: FILLED_REVIEW_SESSION_ID,
|
||||
learner_id: "00000000-0000-0000-0000-000000000101",
|
||||
clinical_claim_allowed: false,
|
||||
consent_snapshots:
|
||||
consent === "none"
|
||||
? []
|
||||
: [
|
||||
{
|
||||
consent_snapshot_id: "00000000-0000-0000-0000-000000000711",
|
||||
sequence_no: 1,
|
||||
consent_status: "granted",
|
||||
retain_audio: true,
|
||||
retain_derived_features: true,
|
||||
transcript_retained: true,
|
||||
retention_days: 30,
|
||||
policy_version: "vignette.multimodal-consent.v1",
|
||||
reason_code: null,
|
||||
created_at: "2026-08-06T09:00:00Z",
|
||||
},
|
||||
...(consent === "withdrawn"
|
||||
? [{
|
||||
consent_snapshot_id: "00000000-0000-0000-0000-000000000712",
|
||||
sequence_no: 2,
|
||||
consent_status: "withdrawn",
|
||||
retain_audio: false,
|
||||
retain_derived_features: false,
|
||||
transcript_retained: true,
|
||||
retention_days: null,
|
||||
policy_version: "vignette.multimodal-consent.v1",
|
||||
reason_code: "learner_withdrawal",
|
||||
created_at: "2026-08-06T09:05:00Z",
|
||||
}]
|
||||
: []),
|
||||
],
|
||||
timelines: [
|
||||
{
|
||||
timeline_id: timelineId,
|
||||
audio_duration_ms: 120_000,
|
||||
clock_version: "audio-clock-v1",
|
||||
word_count: 8,
|
||||
event_count: 4,
|
||||
created_at: "2026-08-06T09:01:00Z",
|
||||
derived_features_available: true,
|
||||
},
|
||||
],
|
||||
word_timestamps: Array.from({ length: 8 }, (_, index) => ({
|
||||
timeline_id: timelineId,
|
||||
word_index: index,
|
||||
start_ms: 4_000 + index * 11_000,
|
||||
end_ms: 4_800 + index * 11_000,
|
||||
speaker: index % 2 === 0 ? "client" : "learner",
|
||||
token_hash: "a".repeat(64),
|
||||
})),
|
||||
voice_events: [
|
||||
event(
|
||||
"oas-g7-event-silence-1",
|
||||
"silence",
|
||||
18_000,
|
||||
23_000,
|
||||
"both",
|
||||
"두 발화 사이에 5초 공백이 관찰됨",
|
||||
),
|
||||
event(
|
||||
"oas-g7-event-overlap-1",
|
||||
"overlap",
|
||||
42_000,
|
||||
46_000,
|
||||
"both",
|
||||
"두 화자의 발화 구간이 4초 겹침",
|
||||
),
|
||||
event(
|
||||
"oas-g7-event-interruption-1",
|
||||
"interruption",
|
||||
66_000,
|
||||
68_500,
|
||||
"learner",
|
||||
"학습자 발화 시작이 내담자 발화 종료보다 420ms 빠름",
|
||||
),
|
||||
event(
|
||||
"oas-g7-event-prosody-1",
|
||||
"prosody",
|
||||
88_000,
|
||||
94_000,
|
||||
"client",
|
||||
"감정이 우울증이라고 확정됨",
|
||||
),
|
||||
],
|
||||
measurements: [
|
||||
measurement("goal", "text", { value: 0.72 }),
|
||||
measurement("goal", "voice", { value: 0.7 }),
|
||||
measurement("task", "text", { value: 0.64 }),
|
||||
measurement("task", "voice", { status: "error" }),
|
||||
measurement("bond", "text", { value: 0.76 }),
|
||||
measurement("bond", "voice", { value: 0.82 }),
|
||||
],
|
||||
fusion_decisions: [
|
||||
{
|
||||
fusion_record_id: "00000000-0000-0000-0000-000000000720",
|
||||
axis: "goal",
|
||||
status: "ready",
|
||||
value: 0.72,
|
||||
uncertainty: 0.2,
|
||||
modalities_used: ["text"],
|
||||
measurement_ids: ["oas-g7-measurement-goal-text"],
|
||||
fusion_applied: false,
|
||||
calibration_id: null,
|
||||
benchmark_version: "g7-benchmark-1.0.0",
|
||||
incremental_gain: 0.004,
|
||||
counterevidence: ["voice_incremental_gain_not_demonstrated"],
|
||||
created_at: "2026-08-06T09:03:00Z",
|
||||
},
|
||||
{
|
||||
fusion_record_id: "00000000-0000-0000-0000-000000000721",
|
||||
axis: "task",
|
||||
status: "ready",
|
||||
value: 0.64,
|
||||
uncertainty: 0.28,
|
||||
modalities_used: ["text"],
|
||||
measurement_ids: ["oas-g7-measurement-task-text"],
|
||||
fusion_applied: false,
|
||||
calibration_id: null,
|
||||
benchmark_version: "g7-benchmark-1.0.0",
|
||||
incremental_gain: null,
|
||||
counterevidence: ["voice_measurement_error"],
|
||||
created_at: "2026-08-06T09:03:00Z",
|
||||
},
|
||||
{
|
||||
fusion_record_id: "00000000-0000-0000-0000-000000000722",
|
||||
axis: "bond",
|
||||
status: "ready",
|
||||
value: 0.8,
|
||||
uncertainty: 0.16,
|
||||
modalities_used: ["text", "voice"],
|
||||
measurement_ids: [
|
||||
"oas-g7-measurement-bond-text",
|
||||
"oas-g7-measurement-bond-voice",
|
||||
],
|
||||
fusion_applied: true,
|
||||
calibration_id: "oas-g7-fusion-bond-v1",
|
||||
benchmark_version: "g7-benchmark-1.0.0",
|
||||
incremental_gain: 0.031,
|
||||
counterevidence: [],
|
||||
created_at: "2026-08-06T09:03:00Z",
|
||||
},
|
||||
],
|
||||
deletion_requests: [
|
||||
{
|
||||
deletion_request_id: "00000000-0000-0000-0000-000000000730",
|
||||
scopes: ["derived_features"],
|
||||
request_reason: "learner_request",
|
||||
requested_at: "2026-08-06T09:04:00Z",
|
||||
completed_scopes: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function routeBaseReview(page: Page, role: Role) {
|
||||
await routeReviewUser(page, role);
|
||||
await routeFilledSessionReview(page);
|
||||
await routePrepostMeasures(page);
|
||||
if (role === "teacher") {
|
||||
const review = filledReviewResponse();
|
||||
review.teacherReview = {
|
||||
status: "viewed",
|
||||
note: "",
|
||||
reviewedAt: null,
|
||||
reviewerId: "00000000-0000-0000-0000-000000000202",
|
||||
worksheetStatus: "pending",
|
||||
worksheetNote: "",
|
||||
worksheetReviewedAt: null,
|
||||
};
|
||||
await page.unroute(
|
||||
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/review`,
|
||||
);
|
||||
await page.route(
|
||||
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/review`,
|
||||
(route) => fulfillJson(route, review),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("G7 멀티모달 동맹 오디오 시계", () => {
|
||||
test("학습자가 익명 자막·네 이벤트·독립 측정·no-gain·보존 경계를 본다", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await routeBaseReview(page, "learner");
|
||||
let rawAudioReads = 0;
|
||||
let playbackReads = 0;
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "play", {
|
||||
configurable: true,
|
||||
value() {
|
||||
this.dispatchEvent(new Event("playing"));
|
||||
return Promise.resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.route(
|
||||
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/multimodal-alliance/raw-audio`,
|
||||
(route) => {
|
||||
rawAudioReads += 1;
|
||||
return fulfillJson(route, rawAudioFixture());
|
||||
},
|
||||
);
|
||||
await page.route(
|
||||
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/multimodal-alliance/raw-audio/*`,
|
||||
(route) => {
|
||||
playbackReads += 1;
|
||||
return route.fulfill({ status: 200, contentType: "audio/wav", body: silentWav() });
|
||||
},
|
||||
);
|
||||
await page.route(
|
||||
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/multimodal-alliance`,
|
||||
(route) => fulfillJson(route, multimodalFixture()),
|
||||
);
|
||||
|
||||
await page.goto(
|
||||
`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`,
|
||||
);
|
||||
await page.getByRole("tab", { name: "피드백" }).click();
|
||||
|
||||
const card = page.locator(".mma-card");
|
||||
await expect(
|
||||
card.getByRole("heading", {
|
||||
name: "말의 내용과 오디오 시간을 같은 시계에서 봅니다",
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(card.getByText("내담자 자막", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("학습자 자막", { exact: true })).toBeVisible();
|
||||
await expect(card.getByRole("button", { name: /침묵/ })).toBeVisible();
|
||||
await expect(card.getByRole("button", { name: /발화 겹침/ })).toBeVisible();
|
||||
await expect(card.getByRole("button", { name: /끼어듦/ })).toBeVisible();
|
||||
await expect(card.getByRole("button", { name: /운율 변화/ })).toBeVisible();
|
||||
await expect(card.getByRole("img", { name: /학습자 익명 자막 토큰 2/ })).toBeVisible();
|
||||
await expect(card.getByText("목표 합의", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("텍스트 단독", { exact: true }).first()).toBeVisible();
|
||||
await expect(card.getByText(/음성 추가 이득이 최소 기준을 넘지 않아/)).toBeVisible();
|
||||
await expect(card.getByText("보정 융합", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText(/학습자 본인 계정에서만 접근 가능/)).toBeVisible();
|
||||
const player = card.getByLabel("침묵 장면 원본 음성 플레이어");
|
||||
await expect(player).toBeVisible();
|
||||
await expect(player).toHaveAttribute("controls", "");
|
||||
await expect(card.getByText(/재생 준비가 됐습니다/)).toBeVisible();
|
||||
await expect(card).not.toContainText("private://must-not-render");
|
||||
await expect(card).not.toContainText("a".repeat(64));
|
||||
|
||||
const eventTarget = card.getByRole("button", { name: /침묵/ });
|
||||
const eventBox = await eventTarget.boundingBox();
|
||||
expect(eventBox).not.toBeNull();
|
||||
expect(eventBox?.width ?? 0).toBeGreaterThanOrEqual(24);
|
||||
expect(eventBox?.height ?? 0).toBeGreaterThanOrEqual(24);
|
||||
const clockViewport = card.locator(".mma-clock__viewport");
|
||||
await clockViewport.focus();
|
||||
await expect(clockViewport).toBeFocused();
|
||||
|
||||
await card.getByRole("button", { name: /운율 변화/ }).click();
|
||||
await expect(card.getByText(/비임상 관찰 경계를 벗어난 해석 문구/)).toBeVisible();
|
||||
await expect(card).not.toContainText("우울증이라고 확정");
|
||||
const playScene = card.getByRole("button", { name: "선택 장면 듣기" });
|
||||
await playScene.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(card.getByText("운율 변화 장면을 재생 중입니다.")).toBeVisible();
|
||||
expect(rawAudioReads).toBe(1);
|
||||
expect(playbackReads).toBe(1);
|
||||
await expectNoHorizontalOverflow(page);
|
||||
await card.screenshot({
|
||||
path: testInfo.outputPath("g7-multimodal-alliance-desktop.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
});
|
||||
|
||||
test("교수자는 코호트 메타데이터만 받고 원본 음성·학습자 제어를 요청하지 않는다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeBaseReview(page, "teacher");
|
||||
let rawAudioReads = 0;
|
||||
let playbackReads = 0;
|
||||
await page.route(
|
||||
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/multimodal-alliance/raw-audio`,
|
||||
(route) => {
|
||||
rawAudioReads += 1;
|
||||
return fulfillJson(route, { items: [] });
|
||||
},
|
||||
);
|
||||
await page.route(
|
||||
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/multimodal-alliance/raw-audio/*`,
|
||||
(route) => {
|
||||
playbackReads += 1;
|
||||
return route.fulfill({ status: 403, body: "forbidden" });
|
||||
},
|
||||
);
|
||||
await page.route(
|
||||
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/multimodal-alliance`,
|
||||
(route) => fulfillJson(route, multimodalFixture()),
|
||||
);
|
||||
|
||||
await page.goto(
|
||||
`/teach/session/${FILLED_REVIEW_SESSION_ID}/review`,
|
||||
);
|
||||
await page.getByRole("tab", { name: "피드백" }).click();
|
||||
const card = page.locator(".mma-card");
|
||||
await expect(
|
||||
card.getByRole("heading", { name: "코호트 메타데이터만 봅니다" }),
|
||||
).toBeVisible();
|
||||
await expect(card.getByText("원본 음성 차단", { exact: true })).toBeVisible();
|
||||
await expect(card.getByRole("button", { name: "음성 재연습 시작" })).toHaveCount(0);
|
||||
await expect(card.getByRole("button", { name: /동의 철회/ })).toHaveCount(0);
|
||||
await expect(card.locator("audio")).toHaveCount(0);
|
||||
expect(rawAudioReads).toBe(0);
|
||||
expect(playbackReads).toBe(0);
|
||||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
|
||||
test("모바일·다크·reduced-motion에서 동의를 기록하고 재연습으로 이동한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.emulateMedia({ colorScheme: "dark", reducedMotion: "reduce" });
|
||||
await routeBaseReview(page, "learner");
|
||||
await page.route(/\/api\/personas$/, (route) => fulfillJson(route, [{
|
||||
code: "P1",
|
||||
display_name: "성하늘",
|
||||
difficulty: "easy",
|
||||
theory_target: ["humanistic"],
|
||||
demographics: { age_band: "20대" },
|
||||
presenting_summary: "학업 스트레스와 수면 문제를 호소",
|
||||
voice_preset: "alloy",
|
||||
source: "database",
|
||||
degraded: false,
|
||||
}]));
|
||||
await page.route(/\/api\/sessions$/, (route) => fulfillJson(route, { sessions: [{
|
||||
session_id: FILLED_REVIEW_SESSION_ID,
|
||||
session_no: 4,
|
||||
persona_code: "P1",
|
||||
persona_name: "성하늘",
|
||||
status: "ended",
|
||||
stage: "정리",
|
||||
started_at: "2026-08-06T08:00:00Z",
|
||||
ended_at: "2026-08-06T09:00:00Z",
|
||||
review_ready: true,
|
||||
archived: false,
|
||||
archived_at: null,
|
||||
turn_count: 12,
|
||||
learner_turn_count: 6,
|
||||
client_turn_count: 6,
|
||||
}] }));
|
||||
await page.route(/\/api\/sessions\/dashboard$/, (route) =>
|
||||
fulfillJson(route, { detail: "dashboard unavailable" }, 503),
|
||||
);
|
||||
const consentBodies: Record<string, unknown>[] = [];
|
||||
await page.route(
|
||||
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/multimodal-alliance/consent`,
|
||||
(route) => {
|
||||
const consentBody = route.request().postDataJSON() as Record<string, unknown>;
|
||||
consentBodies.push(consentBody);
|
||||
if (consentBodies.length === 1) {
|
||||
return fulfillJson(route, { detail: "temporary ledger timeout" }, 503);
|
||||
}
|
||||
return fulfillJson(route, {
|
||||
submission_id: consentBody.submission_id,
|
||||
consent_snapshot_id: "00000000-0000-0000-0000-000000000750",
|
||||
consent_status: "granted",
|
||||
deletion_request_id: null,
|
||||
idempotent_replay: false,
|
||||
}, 201);
|
||||
},
|
||||
);
|
||||
await page.route(
|
||||
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/multimodal-alliance`,
|
||||
(route) => fulfillJson(route, multimodalFixture({ consent: "none" })),
|
||||
);
|
||||
|
||||
await page.goto(
|
||||
`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`,
|
||||
);
|
||||
await page.evaluate(() => document.documentElement.setAttribute("data-theme", "dark"));
|
||||
await page.getByRole("tab", { name: "피드백" }).click();
|
||||
const card = page.locator(".mma-card");
|
||||
await card.getByLabel(/축어록은 유지되고/).check();
|
||||
await card.getByRole("button", { name: "음성 분석 동의 기록" }).click();
|
||||
await expect(card.getByText(/API 503: temporary ledger timeout/)).toBeVisible();
|
||||
await card.getByRole("button", { name: "음성 분석 동의 기록" }).click();
|
||||
await expect(card.getByText("음성 분석 동의를 원장에 기록했습니다.")).toBeVisible();
|
||||
expect(consentBodies).toHaveLength(2);
|
||||
expect(consentBodies[1]).toMatchObject({
|
||||
consent_status: "granted",
|
||||
retain_audio: false,
|
||||
retain_derived_features: true,
|
||||
transcript_retained: true,
|
||||
retention_days: 30,
|
||||
policy_version: "vignette.multimodal-consent.v1",
|
||||
});
|
||||
expect(consentBodies[0].submission_id).toBe(consentBodies[1].submission_id);
|
||||
expect(Object.keys(consentBodies[1]).sort()).toEqual([
|
||||
"consent_status",
|
||||
"policy_version",
|
||||
"retain_audio",
|
||||
"retain_derived_features",
|
||||
"retention_days",
|
||||
"submission_id",
|
||||
"transcript_retained",
|
||||
]);
|
||||
await expectNoHorizontalOverflow(page);
|
||||
|
||||
const practiceCta = card.getByRole("button", { name: "음성 재연습 시작" });
|
||||
await practiceCta.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/learn/practice\\?mode=voice&source_session=${FILLED_REVIEW_SESSION_ID}`),
|
||||
);
|
||||
await expect(page.getByRole("heading", { name: "침묵 뒤 응답을 음성으로 다시 연습합니다." })).toBeVisible();
|
||||
await expect(page.getByText("oas-g7-event-silence-1", { exact: true })).toBeVisible();
|
||||
const sourcePersona = page.getByRole("option", { name: /P1/ });
|
||||
await expect(sourcePersona).toHaveAttribute("aria-selected", "true");
|
||||
await expectNoHorizontalOverflow(page);
|
||||
|
||||
await page.getByRole("button", { name: "새 회기 시작" }).click();
|
||||
await expect(page.locator(".sx-page")).toHaveAttribute("data-practice-mode", "voice");
|
||||
await expect(page.getByRole("heading", { name: "음성 장면 재연습" })).toBeVisible();
|
||||
const launchedUrl = new URL(page.url());
|
||||
expect(launchedUrl.pathname).toBe("/learn/session/P1");
|
||||
expect(launchedUrl.searchParams.get("source_session")).toBe(FILLED_REVIEW_SESSION_ID);
|
||||
expect(launchedUrl.searchParams.get("source_scene")).toBe("oas-g7-event-silence-1");
|
||||
expect(launchedUrl.searchParams.get("scene_start_ms")).toBe("18000");
|
||||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
|
||||
test("원본 음성 접근의 403·404·503을 빈 파일로 위장하지 않는다", async ({ page }) => {
|
||||
await routeBaseReview(page, "learner");
|
||||
let rawStatus = 403;
|
||||
await page.route(
|
||||
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/multimodal-alliance/raw-audio`,
|
||||
(route) => fulfillJson(route, { detail: `raw audio ${rawStatus}` }, rawStatus),
|
||||
);
|
||||
await page.route(
|
||||
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/multimodal-alliance`,
|
||||
(route) => fulfillJson(route, multimodalFixture()),
|
||||
);
|
||||
|
||||
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
|
||||
await page.getByRole("tab", { name: "피드백" }).click();
|
||||
for (const statusCode of [403, 404, 503]) {
|
||||
rawStatus = statusCode;
|
||||
if (statusCode !== 403) {
|
||||
await page.reload();
|
||||
await page.getByRole("tab", { name: "피드백" }).click();
|
||||
}
|
||||
const card = page.locator(".mma-card");
|
||||
await expect(card.getByRole("alert")).toContainText(`API ${statusCode}: raw audio ${statusCode}`);
|
||||
await expect(card.locator("audio")).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("원음 스트림 로딩과 재생 오류를 플레이어 안에서 알린다", async ({ page }) => {
|
||||
await routeBaseReview(page, "learner");
|
||||
let releasePlayback: (() => void) | null = null;
|
||||
const playbackGate = new Promise<void>((resolve) => {
|
||||
releasePlayback = resolve;
|
||||
});
|
||||
await page.route(
|
||||
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/multimodal-alliance/raw-audio`,
|
||||
(route) => fulfillJson(route, rawAudioFixture()),
|
||||
);
|
||||
await page.route(
|
||||
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/multimodal-alliance/raw-audio/*`,
|
||||
async (route) => {
|
||||
await playbackGate;
|
||||
await route.fulfill({ status: 503, contentType: "text/plain", body: "audio unavailable" });
|
||||
},
|
||||
);
|
||||
await page.route(
|
||||
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/multimodal-alliance`,
|
||||
(route) => fulfillJson(route, multimodalFixture()),
|
||||
);
|
||||
|
||||
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
|
||||
await page.getByRole("tab", { name: "피드백" }).click();
|
||||
const player = page.getByLabel("침묵 장면 원본 음성 플레이어");
|
||||
await expect(player).toBeVisible();
|
||||
await expect(page.getByText("원본 음성을 불러오는 중입니다.")).toBeVisible();
|
||||
releasePlayback?.();
|
||||
await expect(page.locator(".mma-scene-player").getByRole("alert")).toContainText("원본 음성을 재생하지 못했습니다");
|
||||
});
|
||||
|
||||
test("동의 철회 즉시 플레이어를 제거하고 원음 URL을 다시 요청하지 않는다", async ({ page }) => {
|
||||
await routeBaseReview(page, "learner");
|
||||
let withdrawn = false;
|
||||
let rawAudioReads = 0;
|
||||
await page.route(
|
||||
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/multimodal-alliance/raw-audio`,
|
||||
(route) => {
|
||||
rawAudioReads += 1;
|
||||
return fulfillJson(route, rawAudioFixture());
|
||||
},
|
||||
);
|
||||
await page.route(
|
||||
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/multimodal-alliance/raw-audio/*`,
|
||||
(route) => route.fulfill({ status: 200, contentType: "audio/wav", body: silentWav() }),
|
||||
);
|
||||
await page.route(
|
||||
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/multimodal-alliance/withdraw`,
|
||||
(route) => {
|
||||
withdrawn = true;
|
||||
const body = route.request().postDataJSON() as Record<string, unknown>;
|
||||
return fulfillJson(route, {
|
||||
submission_id: body.submission_id,
|
||||
consent_snapshot_id: "00000000-0000-0000-0000-000000000712",
|
||||
consent_status: "withdrawn",
|
||||
deletion_request_id: "00000000-0000-0000-0000-000000000799",
|
||||
idempotent_replay: false,
|
||||
}, 201);
|
||||
},
|
||||
);
|
||||
await page.route(
|
||||
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/multimodal-alliance`,
|
||||
(route) => fulfillJson(route, multimodalFixture({ consent: withdrawn ? "withdrawn" : "granted" })),
|
||||
);
|
||||
|
||||
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
|
||||
await page.getByRole("tab", { name: "피드백" }).click();
|
||||
const card = page.locator(".mma-card");
|
||||
await expect(card.getByLabel("침묵 장면 원본 음성 플레이어")).toBeVisible();
|
||||
await card.getByLabel(/철회 즉시 새 음성 처리가 중단/).check();
|
||||
await card.getByRole("button", { name: "동의 철회 및 삭제 요청" }).click();
|
||||
await expect(card.getByText("철회 완료", { exact: true })).toBeVisible();
|
||||
await expect(card.locator("audio")).toHaveCount(0);
|
||||
expect(rawAudioReads).toBe(1);
|
||||
});
|
||||
|
||||
test("원장 없음과 API degraded를 거짓 데이터 없이 구분한다", async ({ page }) => {
|
||||
await routeBaseReview(page, "learner");
|
||||
let fail = false;
|
||||
await page.route(
|
||||
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/multimodal-alliance`,
|
||||
(route) => fulfillJson(route, { detail: fail ? "ledger unavailable" : "not found" }, fail ? 503 : 404),
|
||||
);
|
||||
await page.goto(
|
||||
`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`,
|
||||
);
|
||||
await page.getByRole("tab", { name: "피드백" }).click();
|
||||
await expect(page.getByRole("heading", { name: "이 회기에는 음성 원장이 없습니다" })).toBeVisible();
|
||||
|
||||
fail = true;
|
||||
await page.reload();
|
||||
await page.getByRole("tab", { name: "피드백" }).click();
|
||||
await expect(page.getByRole("heading", { name: "음성 근거를 표시할 수 없습니다" })).toBeVisible();
|
||||
await expect(page.getByText(/API 503: ledger unavailable/)).toBeVisible();
|
||||
});
|
||||
});
|
||||
622
apps/web/e2e/outcome-trajectory.spec.ts
Normal file
622
apps/web/e2e/outcome-trajectory.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 기본 모델");
|
||||
|
|
|
|||
387
apps/web/e2e/returned-practice-db-closed-loop.spec.ts
Normal file
387
apps/web/e2e/returned-practice-db-closed-loop.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
364
apps/web/e2e/rupture-repair.spec.ts
Normal file
364
apps/web/e2e/rupture-repair.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
1215
apps/web/e2e/self-directed-learning-loop.spec.ts
Normal file
1215
apps/web/e2e/self-directed-learning-loop.spec.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
386
apps/web/e2e/supervision-research.spec.ts
Normal file
386
apps/web/e2e/supervision-research.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue