import { expect, test, type Page } from "@playwright/test"; import { fetchAvailablePersona, signInAsAdmin, signInAsLearner, signInAsTeacher, withGlobalEngineConfigLock, } from "./support"; interface SessionStartResponse { session_id: string; degraded: boolean; } interface HealthResponse { db?: boolean; engine?: boolean; } interface TeacherSafetyAlert { session_id: string; resource_title: string; resource_number: string; } interface TeacherDashboardResponse { source: string; safety_alerts: TeacherSafetyAlert[]; pending_reviews?: TeacherSessionSummary[]; recent_sessions?: TeacherSessionSummary[]; } interface TeacherSessionSummary { session_id: string; turn_count: number; evaluation_status?: "pending" | "ready" | "error"; review_ready?: boolean; supervisor_state?: string; } interface ReviewTurn { speaker: string; text: string; techniques?: Array<{ kind?: string; label: string; }>; nonverbal?: Array<{ kind: string; label: string; detail: string; }>; note?: { tone?: string; title: string; body: string; } | null; } interface SessionReviewResponse { session_id: string; turns: ReviewTurn[]; degraded?: boolean; reviewReady?: boolean; supervisorState?: string; summary?: string; caseWorksheet?: { status?: string; sections?: Array<{ key: string; items?: Array<{ key: string; value?: string | null; }>; }>; }; teacherReview?: { worksheetStatus?: string | null; worksheetNote?: string | null; } | null; } interface SessionDetailResponse { session_id: string; theory_mode: string; status: "active" | "ended"; turns?: Array<{ speaker: "learner" | "client"; text: string; }>; } interface LiveCoachEvent { turn_seq: number; learner_text_excerpt?: string | null; suggestion: { status?: "ready" | "degraded"; title: string; message: string; latency_ms?: number; sources?: Array<{ source_id: string; title: string; locator?: string | null; kb_kind?: string | null; version?: string | null; citation?: string | null; }>; }; } interface LiveCoachHistoryResponse { source: string; events: LiveCoachEvent[]; } interface VoiceHealthResponse { available?: boolean; } interface VoiceWsProbe { code: number; events: Array<{ type?: string; state?: string; [key: string]: unknown }>; binaryChunks: number; } interface VoiceUiProbeMessage { direction: "sent" | "received"; kind: "text" | "binary"; data?: string; byteLength?: number; } interface VoiceUiProbeState { getUserMediaCalls: number; workletModuleLoads: number; workletNodes: number; workletChunks: number; trackStops: number; messages: VoiceUiProbeMessage[]; } interface EvaluationSummaryResponse { session_id: string; status?: string | null; error?: string | null; durable?: boolean; deep?: Record | null; } interface AdminEngineConfigResponse { engine_mode: string; engine_url: string; model: string; } interface PrepostMeasureItem { measure_name: string; timepoint: string; raw_score: number; normalized_score: number; } interface PrepostMeasuresResponse { source: string; durable: boolean; pilot_id: string; complete_measure_pairs: number; measures: PrepostMeasureItem[]; } async function expectResponseOk(response: { ok: () => boolean; text: () => Promise }) { if (!response.ok()) { expect(response.ok(), await response.text()).toBeTruthy(); } } async function setupSyntheticVoiceUiProbe(page: Page, transcript: string) { await page.addInitScript((text) => { type ProbeMessage = { direction: "sent" | "received"; kind: "text" | "binary"; data?: string; byteLength?: number; }; type ProbeState = { getUserMediaCalls: number; workletModuleLoads: number; workletNodes: number; workletChunks: number; trackStops: number; messages: ProbeMessage[]; }; const probe: ProbeState = { getUserMediaCalls: 0, workletModuleLoads: 0, workletNodes: 0, workletChunks: 0, trackStops: 0, messages: [], }; (window as Window & { __voiceUiProbe?: ProbeState }).__voiceUiProbe = probe; const fakeTrack = { kind: "audio", enabled: true, readyState: "live", stop: () => { probe.trackStops += 1; fakeTrack.readyState = "ended"; }, }; const fakeStream = { active: true, getTracks: () => [fakeTrack], getAudioTracks: () => [fakeTrack], }; Object.defineProperty(navigator, "mediaDevices", { configurable: true, value: { getUserMedia: async () => { probe.getUserMediaCalls += 1; return fakeStream; }, }, }); const NativeWebSocket = window.WebSocket; const sizeOf = (data: unknown) => { if (typeof data === "string") return data.length; if (data instanceof Blob) return data.size; if (data instanceof ArrayBuffer) return data.byteLength; if (ArrayBuffer.isView(data)) return data.byteLength; return 0; }; class ProbeWebSocket extends NativeWebSocket { constructor(url: string | URL, protocols?: string | string[]) { if (protocols === undefined) super(url); else super(url, protocols); this.addEventListener("message", (event) => { if (typeof event.data === "string") { probe.messages.push({ direction: "received", kind: "text", data: event.data }); } else { probe.messages.push({ direction: "received", kind: "binary", byteLength: sizeOf(event.data), }); } }); } send(data: string | ArrayBufferLike | Blob | ArrayBufferView) { if (typeof data === "string") { probe.messages.push({ direction: "sent", kind: "text", data }); try { const parsed = JSON.parse(data) as { type?: string; silence_ms?: number; barge_in?: boolean; provider_events?: unknown[]; }; if (parsed.type === "audio_end") { super.send( JSON.stringify({ type: "stt_result", text, final: true, silence_ms: parsed.silence_ms, barge_in: parsed.barge_in, provider_events: parsed.provider_events, }), ); return; } } catch { // Keep non-JSON WebSocket traffic unchanged. } } else { probe.messages.push({ direction: "sent", kind: "binary", byteLength: sizeOf(data) }); } super.send(data); } } Object.defineProperty(window, "WebSocket", { configurable: true, value: ProbeWebSocket, }); class FakeWorkletPort { onmessage: ((event: MessageEvent) => void) | null = null; closed = false; postMessage(message: { type?: string }) { if (message?.type === "flush") this.emitChunk(); } close() { this.closed = true; } emitChunk() { if (this.closed) return; probe.workletChunks += 1; const pcm = new Int16Array([0, 4096, -4096, 0]); this.onmessage?.({ data: { type: "chunk", pcm: pcm.buffer, metrics: { durationMs: 1600, voiceMs: 360, silenceMs: 1240, trailingSilenceMs: 1240, rms: 0.1, peak: 0.72, }, }, } as MessageEvent); } } class FakeAudioWorkletNode { port = new FakeWorkletPort(); constructor(_ctx: unknown, _name: string, _options?: unknown) { probe.workletNodes += 1; } connect() { return this; } disconnect() { return undefined; } __start() { window.setTimeout(() => this.port.emitChunk(), 25); } } class FakeMediaStreamSource { connect(node: { __start?: () => void }) { node.__start?.(); return node; } disconnect() { return undefined; } } class FakeAudioContext { state = "running"; destination = {}; sampleRate = 16000; audioWorklet = { addModule: async (_url: string) => { probe.workletModuleLoads += 1; }, }; async resume() { this.state = "running"; } async close() { this.state = "closed"; } async decodeAudioData(_data: ArrayBuffer) { return { duration: 0.12 }; } createAnalyser() { return { fftSize: 0, frequencyBinCount: 4, getByteTimeDomainData: (array: Uint8Array) => array.fill(128), connect: () => undefined, disconnect: () => undefined, }; } createBufferSource() { return { buffer: null, connect: () => undefined, disconnect: () => undefined, start: () => undefined, stop: () => undefined, onended: null as (() => void) | null, }; } createMediaStreamSource(_stream: unknown) { return new FakeMediaStreamSource(); } } Object.defineProperty(window, "AudioWorkletNode", { configurable: true, value: FakeAudioWorkletNode, }); Object.defineProperty(window, "AudioContext", { configurable: true, value: FakeAudioContext, }); Object.defineProperty(window, "webkitAudioContext", { configurable: true, value: FakeAudioContext, }); }, transcript); } async function readVoiceUiProbe(page: Page): Promise { return page.evaluate(() => { const probe = (window as Window & { __voiceUiProbe?: VoiceUiProbeState }).__voiceUiProbe; if (!probe) throw new Error("voice UI probe was not installed"); return { ...probe, messages: [...probe.messages], }; }); } async function createSessionWithTurn(page: Page) { const persona = await fetchAvailablePersona(page); const startedResponse = await page.request.post("/api/sessions", { data: { persona_code: persona.code, theory_mode: "humanistic", }, }); await expectResponseOk(startedResponse); const started = (await startedResponse.json()) as SessionStartResponse; expect(started.degraded).toBe(false); let turnResponse = await page.request.post(`/api/sessions/${started.session_id}/turn`, { data: { text: "오늘 많이 버거웠겠어요. 지금 가장 크게 남아 있는 마음은 어떤 건가요?", }, }); for (let attempt = 0; attempt < 2 && !turnResponse.ok(); attempt += 1) { await page.waitForTimeout(1000); turnResponse = await page.request.post(`/api/sessions/${started.session_id}/turn`, { data: { text: "조금 천천히 이야기해도 괜찮습니다. 지금 마음에 남는 장면이 있나요?", }, }); } let expectedMinTurns = 2; if (!turnResponse.ok()) { expect(turnResponse.status(), await turnResponse.text()).toBe(503); expectedMinTurns = 1; } const endedResponse = await page.request.post(`/api/sessions/${started.session_id}/end`); await expectResponseOk(endedResponse); return { sessionId: started.session_id, expectedMinTurns }; } async function createActiveSessionWithTurn(page: Page) { const persona = await fetchAvailablePersona(page); const startedResponse = await page.request.post("/api/sessions", { data: { persona_code: persona.code, theory_mode: "humanistic", }, }); await expectResponseOk(startedResponse); const started = (await startedResponse.json()) as SessionStartResponse; expect(started.degraded).toBe(false); let turnResponse = await page.request.post(`/api/sessions/${started.session_id}/turn`, { data: { text: "제가 이해한 바로는 그 일이 꽤 오래 마음에 남아 있었던 것 같습니다.", }, }); for (let attempt = 0; attempt < 2 && !turnResponse.ok(); attempt += 1) { await page.waitForTimeout(1000); turnResponse = await page.request.post(`/api/sessions/${started.session_id}/turn`, { data: { text: "그 장면을 떠올릴 때 몸이나 마음에서 먼저 느껴지는 반응이 있나요?", }, }); } await expectResponseOk(turnResponse); return started.session_id; } async function createEndedSessionWithoutTurn(page: Page) { const persona = await fetchAvailablePersona(page); const startedResponse = await page.request.post("/api/sessions", { data: { persona_code: persona.code, theory_mode: "humanistic", }, }); await expectResponseOk(startedResponse); const started = (await startedResponse.json()) as SessionStartResponse; expect(started.degraded).toBe(false); const endedResponse = await page.request.post(`/api/sessions/${started.session_id}/end`); await expectResponseOk(endedResponse); return started.session_id; } async function evaluationState(page: Page, sessionId: string) { const response = await page.request.get(`/api/eval/sessions/${sessionId}/evaluation`); if (!response.ok()) { return `http:${response.status()}:${(await response.text()).slice(0, 120)}`; } const body = (await response.json()) as EvaluationSummaryResponse; const loop = typeof body.deep?.loop === "string" ? body.deep.loop : "none"; const turnsEvaluated = typeof body.deep?.turns_evaluated === "number" ? body.deep.turns_evaluated : 0; if (body.status === "ready" && body.durable === true && loop === "deep") { return `ready:${turnsEvaluated}`; } if (body.status === "error") { return `error:${body.error || "empty-error"}:${body.durable ? "durable" : "cache"}`; } return `pending:${body.status ?? "none"}:${body.durable ? "durable" : "cache"}:${loop}`; } /** 리뷰는 모든 폭에서 가로 탭으로 영역을 전환한다. */ async function openReviewTab(page: Page, label: "축어록" | "피드백" | "워크시트") { const tabs = page.locator(".sr-tabs"); await tabs.waitFor({ state: "attached", timeout: 20_000 }); await tabs.locator("button", { hasText: label }).click(); } test.describe("session persistence", () => { test("persists selected CBT theory mode from session UI into DB-backed detail @single-run", async ({ page, }) => { test.setTimeout(60_000); const healthResponse = await page.request.get("/api/health"); await expectResponseOk(healthResponse); const health = (await healthResponse.json()) as HealthResponse; test.skip(!health.db, "theory mode persistence requires DB"); await signInAsLearner(page); const persona = await fetchAvailablePersona(page, 1); await page.goto(`/learn/session/${persona.code}`); const cbtButton = page.locator(".sx-theory").getByRole("button", { name: /CBT/ }); await expect(cbtButton).toBeVisible(); await cbtButton.click(); await expect(cbtButton).toHaveAttribute("aria-pressed", "true"); const startResponsePromise = page.waitForResponse((response) => { const url = new URL(response.url()); return response.request().method() === "POST" && url.pathname.endsWith("/api/sessions"); }); await page.getByRole("button", { name: "회기 시작" }).click(); const startResponse = await startResponsePromise; await expectResponseOk(startResponse); expect(startResponse.request().postDataJSON()).toMatchObject({ persona_code: persona.code, theory_mode: "cbt", }); await expect(page).toHaveURL(/\/learn\/session\/[0-9a-f-]+$/i); const sessionId = new URL(page.url()).pathname.split("/").at(-1); expect(sessionId).toMatch(/^[0-9a-f-]+$/i); const detailResponse = await page.request.get(`/api/sessions/${sessionId}`); await expectResponseOk(detailResponse); const detail = (await detailResponse.json()) as SessionDetailResponse; expect(detail.session_id).toBe(sessionId); expect(detail.theory_mode).toBe("cbt"); expect(detail.status).toBe("active"); }); test("persists the browser SSE stream into DB-backed review @single-run", async ({ page }) => { test.setTimeout(120_000); const healthResponse = await page.request.get("/api/health"); await expectResponseOk(healthResponse); const health = (await healthResponse.json()) as HealthResponse; test.skip(!health.db || !health.engine, "real stream persistence requires DB and engine"); await signInAsLearner(page); const persona = await fetchAvailablePersona(page, 1); const learnerText = "지금 많이 버거웠겠어요. 오늘 제일 크게 남아 있는 감정은 무엇인가요?"; await page.goto(`/learn/session/${persona.code}`); 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); const sessionId = new URL(page.url()).pathname.split("/").at(-1); expect(sessionId).toMatch(/^[0-9a-f-]+$/i); const streamResponsePromise = page.waitForResponse((response) => { const url = new URL(response.url()); return ( response.request().method() === "POST" && url.pathname.endsWith(`/api/sessions/${sessionId}/stream`) ); }); const input = page.getByLabel("학습자 발화 입력"); await input.fill(learnerText); await page.getByRole("button", { name: "보내기" }).click(); const streamResponse = await streamResponsePromise; await expectResponseOk(streamResponse); await expect(page.locator(".sx-utt.is-learner").filter({ hasText: learnerText })).toBeVisible(); await expect(page.locator(".sx-utt.is-client.is-thinking")).toHaveCount(0, { timeout: 90_000, }); 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 }); const reviewResponse = await page.request.get(`/api/sessions/${sessionId}/review`); await expectResponseOk(reviewResponse); const review = (await reviewResponse.json()) as SessionReviewResponse; expect(review.session_id).toBe(sessionId); expect(review.turns.some((turn) => turn.speaker === "learner" && turn.text === learnerText)).toBe( true, ); expect( review.turns.some((turn) => turn.speaker === "client" && turn.text.trim().length > 0), ).toBe(true); }); test("masks Korean PII through the browser stream into DB-backed review @single-run", async ({ page, }) => { test.setTimeout(120_000); const healthResponse = await page.request.get("/api/health"); await expectResponseOk(healthResponse); const health = (await healthResponse.json()) as HealthResponse; test.skip(!health.db || !health.engine, "PII stream masking requires DB and engine"); await signInAsLearner(page); const persona = await fetchAvailablePersona(page, 1); const piiText = "제 이름은 김서연이고 한신대학교 상담센터에서 010-1234-5678로 연락을 받았어요."; const forbiddenRaw = ["김서연", "한신대학교", "상담센터", "010-1234-5678"]; await page.goto(`/learn/session/${persona.code}`); 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); const sessionId = new URL(page.url()).pathname.split("/").at(-1); expect(sessionId).toMatch(/^[0-9a-f-]+$/i); const streamResponsePromise = page.waitForResponse((response) => { const url = new URL(response.url()); return ( response.request().method() === "POST" && url.pathname.endsWith(`/api/sessions/${sessionId}/stream`) ); }); const input = page.getByLabel("학습자 발화 입력"); await input.fill(piiText); await page.getByRole("button", { name: "보내기" }).click(); const streamResponse = await streamResponsePromise; await expectResponseOk(streamResponse); await expect(page.locator(".sx-utt.is-client.is-thinking")).toHaveCount(0, { timeout: 90_000, }); 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 }); const detailResponse = await page.request.get(`/api/sessions/${sessionId}`); await expectResponseOk(detailResponse); const detail = (await detailResponse.json()) as SessionDetailResponse; expect(detail.session_id).toBe(sessionId); const detailBlob = JSON.stringify(detail); for (const raw of forbiddenRaw) { expect(detailBlob).not.toContain(raw); } const detailLearnerTurn = detail.turns?.find((turn) => turn.speaker === "learner"); expect(detailLearnerTurn?.text).toContain("[NAME]"); expect(detailLearnerTurn?.text).toContain("[ORG]"); expect(detailLearnerTurn?.text).toContain("[PHONE]"); const reviewResponse = await page.request.get(`/api/sessions/${sessionId}/review`); await expectResponseOk(reviewResponse); const review = (await reviewResponse.json()) as SessionReviewResponse; const reviewBlob = JSON.stringify(review); for (const raw of forbiddenRaw) { expect(reviewBlob).not.toContain(raw); } const reviewLearnerTurn = review.turns.find((turn) => turn.speaker === "learner"); expect(reviewLearnerTurn?.text).toContain("[NAME]"); expect(reviewLearnerTurn?.text).toContain("[ORG]"); expect(reviewLearnerTurn?.text).toContain("[PHONE]"); }); test("persists crisis safety event into DB-backed teacher dashboard @single-run", async ({ page, }) => { test.setTimeout(120_000); const healthResponse = await page.request.get("/api/health"); await expectResponseOk(healthResponse); const health = (await healthResponse.json()) as HealthResponse; test.skip(!health.db, "crisis safety queue persistence requires DB"); await signInAsLearner(page); const persona = await fetchAvailablePersona(page, 1); const crisisText = "저 지금 자살하고 싶어요. 도와주세요."; await page.goto(`/learn/session/${persona.code}`); 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); const sessionId = new URL(page.url()).pathname.split("/").at(-1); expect(sessionId).toMatch(/^[0-9a-f-]+$/i); const streamResponsePromise = page.waitForResponse((response) => { const url = new URL(response.url()); return ( response.request().method() === "POST" && url.pathname.endsWith(`/api/sessions/${sessionId}/stream`) ); }); const input = page.getByLabel("학습자 발화 입력"); await input.fill(crisisText); await page.getByRole("button", { name: "보내기" }).click(); const streamResponse = await streamResponsePromise; await expectResponseOk(streamResponse); await expect(page.locator(".sx-crisis-resource")).toContainText("자살예방상담전화 109", { timeout: 15_000, }); await expect(page.getByRole("link", { name: "109" })).toBeVisible(); await expect(input).toBeDisabled(); const reviewResponse = await page.request.get(`/api/sessions/${sessionId}/review`); await expectResponseOk(reviewResponse); const review = (await reviewResponse.json()) as SessionReviewResponse; expect(review.session_id).toBe(sessionId); expect( review.turns.some((turn) => turn.speaker === "learner" && turn.text === crisisText), "the crisis utterance itself should be persisted for supervisor review", ).toBe(true); expect( review.turns.some((turn) => turn.speaker === "client" && turn.text.trim().length > 0), "real crisis escalation must stop before a client AI reply is stored", ).toBe(false); await signInAsTeacher(page); await expect .poll( async () => { const dashboardResponse = await page.request.get("/api/teacher/dashboard"); await expectResponseOk(dashboardResponse); const dashboard = (await dashboardResponse.json()) as TeacherDashboardResponse; expect(dashboard.source).toBe("database"); return dashboard.safety_alerts.some( (alert) => alert.session_id === sessionId && alert.resource_number === "109", ); }, { timeout: 30_000, intervals: [500, 1_000, 2_000], message: "crisis safety event should reach the DB-backed teacher dashboard", }, ) .toBe(true); await page.goto("/teach"); await expect(page.getByText("109 안전 확인 큐")).toBeVisible({ timeout: 15_000 }); await expect(page.locator(".pf-alert").filter({ hasText: sessionId ?? "" })).toContainText("109", { timeout: 30_000, }); }); test("persists AI tutor coaching history through reload @single-run", async ({ page }) => { test.setTimeout(240_000); const healthResponse = await page.request.get("/api/health"); await expectResponseOk(healthResponse); const health = (await healthResponse.json()) as HealthResponse; test.skip(!health.db || !health.engine, "real live-coach persistence requires DB and engine"); await signInAsLearner(page); const persona = await fetchAvailablePersona(page, 1); const learnerText = "그 말을 꺼내는 것도 쉽지 않았겠어요. 지금 가장 버거운 감정부터 천천히 말해볼까요?"; await page.goto(`/learn/session/${persona.code}`); await page.getByRole("button", { name: "회기 시작" }).click(); 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); const sessionId = new URL(page.url()).pathname.split("/").at(-1); expect(sessionId).toMatch(/^[0-9a-f-]+$/i); const streamResponsePromise = page.waitForResponse((response) => { const url = new URL(response.url()); return ( response.request().method() === "POST" && url.pathname.endsWith(`/api/sessions/${sessionId}/stream`) ); }); const coachResponsePromise = page.waitForResponse((response) => { const url = new URL(response.url()); return ( response.request().method() === "POST" && url.pathname.endsWith(`/api/sessions/${sessionId}/live-coach`) ); }); const input = page.getByLabel("학습자 발화 입력"); await input.fill(learnerText); await page.getByRole("button", { name: "보내기" }).click(); const streamResponse = await streamResponsePromise; await expectResponseOk(streamResponse); await expect(page.locator(".sx-utt.is-client.is-thinking")).toHaveCount(0, { timeout: 90_000, }); await expect(input).toBeEnabled({ timeout: 90_000 }); const coachResponse = await coachResponsePromise; await expectResponseOk(coachResponse); const coachSuggestion = (await coachResponse.json()) as LiveCoachEvent["suggestion"]; expect(coachSuggestion.status).toBe("ready"); expect(coachSuggestion.latency_ms ?? 0).toBeGreaterThan(0); const workbookSource = coachSuggestion.sources?.find( (source) => source.source_id === "workbook_0615_case_conceptualization", ); expect(workbookSource, "live coach response should include the licensed workbook source pack").toBeTruthy(); expect(workbookSource?.version).toBe("2026-06-15"); expect(workbookSource?.citation ?? "").toContain("0615"); await expect(page.locator(".sx-coach-card").getByText(coachSuggestion.title)).toBeVisible({ timeout: 30_000, }); await page.locator(".sx-coach-card").getByRole("button", { name: "근거 보기" }).click(); const evidenceDialog = page.locator(".sx-coach-modal [role='dialog']"); await expect(evidenceDialog).toBeVisible({ timeout: 15_000 }); await expect(evidenceDialog).toContainText(workbookSource!.title); await expect(evidenceDialog).toContainText("2026-06-15"); await expect(evidenceDialog).toContainText("0615"); await evidenceDialog.getByRole("button", { name: "닫기" }).click(); await expect(evidenceDialog).toHaveCount(0); const historyResponse = await page.request.get(`/api/sessions/${sessionId}/live-coach`); await expectResponseOk(historyResponse); const history = (await historyResponse.json()) as LiveCoachHistoryResponse; expect(history.source).toBe("database"); const persistedCoachEvent = history.events.find( (event) => event.turn_seq === 1 && event.learner_text_excerpt?.includes("가장 버거운 감정") && event.suggestion.title === coachSuggestion.title, ); expect(persistedCoachEvent, "DB-backed live coach history should include the delivered coaching event").toBeTruthy(); expect(persistedCoachEvent?.suggestion.status).toBe("ready"); expect(persistedCoachEvent?.suggestion.latency_ms ?? 0).toBeGreaterThan(0); expect( persistedCoachEvent?.suggestion.sources?.some( (source) => source.source_id === workbookSource!.source_id && source.version === workbookSource!.version && (source.citation ?? "").includes("0615"), ), "DB-backed live coach history should preserve source pack metadata", ).toBe(true); await page.goto(`/learn/session/${sessionId}`); await expect(page.locator(".sx-utt.is-learner").filter({ hasText: learnerText })).toBeVisible({ timeout: 15_000, }); const coachMark = page.locator(".sx-utt.is-learner .sx-utt__coach-mark"); await expect(coachMark).toBeVisible({ timeout: 15_000, }); await coachMark.click(); const historyDialog = page.locator(".sx-coach-history [role='dialog']"); await expect(historyDialog).toBeVisible({ timeout: 15_000 }); await expect(historyDialog).toContainText(workbookSource!.title); await expect(historyDialog).toContainText("2026-06-15"); await expect(historyDialog).toContainText("0615"); }); test("persists voice nonverbal metadata into DB-backed review @single-run", async ({ page }) => { test.setTimeout(240_000); const healthResponse = await page.request.get("/api/health"); await expectResponseOk(healthResponse); const health = (await healthResponse.json()) as HealthResponse; const voiceHealthResponse = await page.request.get("/api/voice/health"); await expectResponseOk(voiceHealthResponse); const voiceHealth = (await voiceHealthResponse.json()) as VoiceHealthResponse; test.skip( !health.db || !health.engine || !voiceHealth.available, "voice nonverbal persistence requires DB, engine, and voice service", ); await signInAsLearner(page); const persona = await fetchAvailablePersona(page, 1); const startedResponse = await page.request.post("/api/sessions", { data: { persona_code: persona.code, theory_mode: "humanistic", }, }); await expectResponseOk(startedResponse); const started = (await startedResponse.json()) as SessionStartResponse; expect(started.degraded).toBe(false); await page.goto(`/learn/session/${started.session_id}`); const learnerText = "요즘 잠을 잘 못 자요."; const probe = await page.evaluate( ({ sessionId, text }) => new Promise((resolve) => { const wsUrl = new URL( `/api/voice/ws?session_id=${encodeURIComponent(sessionId)}`, window.location.href, ); wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:"; const ws = new WebSocket(wsUrl.href); ws.binaryType = "arraybuffer"; const events: VoiceWsProbe["events"] = []; let binaryChunks = 0; let sawReply = false; const finish = (code: number) => { window.clearTimeout(timeout); resolve({ code, events, binaryChunks }); }; const timeout = window.setTimeout(() => { ws.close(); finish(-1); // dev 엔진(Claude CLI 게이트웨이) 음성 턴이 90초를 넘기도 한다 — 게이트웨이 // GENERATE_TURN_TIMEOUT_SECONDS(300초)와 정합하게 150초까지 기다린다. }, 150_000); ws.onopen = () => { ws.send(JSON.stringify({ type: "audio_start", format: "webm" })); window.setTimeout(() => { ws.send( JSON.stringify({ type: "stt_result", text, final: true, silence_ms: 1500, barge_in: true, provider_events: [{ type: "sigh", confidence: 0.82, text: "drop raw" }], }), ); }, 150); }; ws.onmessage = (event) => { if (typeof event.data === "string") { const parsed = JSON.parse(event.data) as { type?: string; state?: string }; events.push(parsed); if (parsed.type === "reply") sawReply = true; if (sawReply && parsed.type === "state" && parsed.state === "idle") { ws.send(JSON.stringify({ type: "close" })); } return; } binaryChunks += 1; }; ws.onerror = () => { events.push({ type: "error", detail: "browser websocket error" }); }; ws.onclose = (event) => finish(event.code); }), { sessionId: started.session_id, text: learnerText }, ); expect(probe.code, JSON.stringify(probe, null, 2)).toBe(1000); expect(probe.events.some((event) => event.type === "error" || event.type === "degraded")).toBe( false, ); expect(probe.events).toEqual( expect.arrayContaining([ expect.objectContaining({ type: "eot", ready: true }), expect.objectContaining({ type: "transcript", text: learnerText }), expect.objectContaining({ type: "reply" }), ]), ); const reviewResponse = await page.request.get(`/api/sessions/${started.session_id}/review`); await expectResponseOk(reviewResponse); const review = (await reviewResponse.json()) as SessionReviewResponse; const learnerTurn = review.turns.find( (turn) => turn.speaker === "learner" && turn.text === learnerText, ); expect(learnerTurn).toBeTruthy(); const nonverbalKinds = learnerTurn?.nonverbal?.map((event) => event.kind) ?? []; expect(nonverbalKinds).toEqual( expect.arrayContaining(["silence", "pace", "barge_in", "paralinguistic"]), ); expect(learnerTurn?.nonverbal?.some((event) => event.label === "음성 단서")).toBe(true); expect(learnerTurn?.nonverbal?.map((event) => event.detail).join(" ")).not.toContain( "drop raw", ); }); test("persists browser mic UI nonverbal metadata into DB-backed review @single-run", async ({ page }) => { test.setTimeout(150_000); const healthResponse = await page.request.get("/api/health"); await expectResponseOk(healthResponse); const health = (await healthResponse.json()) as HealthResponse; const voiceHealthResponse = await page.request.get("/api/voice/health"); await expectResponseOk(voiceHealthResponse); const voiceHealth = (await voiceHealthResponse.json()) as VoiceHealthResponse; test.skip( !health.db || !health.engine || !voiceHealth.available, "browser mic UI voice persistence requires DB, engine, and voice service", ); await signInAsLearner(page); const persona = await fetchAvailablePersona(page, 1); const learnerText = "요즘 잠을 잘 못 자요."; await setupSyntheticVoiceUiProbe(page, learnerText); await page.goto(`/learn/session/${persona.code}`); 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); const sessionId = new URL(page.url()).pathname.split("/").at(-1); expect(sessionId).toMatch(/^[0-9a-f-]+$/i); const mic = page.locator(".sx-mic"); await expect(mic).toBeEnabled(); await mic.click(); await expect .poll(async () => (await readVoiceUiProbe(page)).getUserMediaCalls, { timeout: 10_000 }) .toBeGreaterThan(0); await expect .poll(async () => (await readVoiceUiProbe(page)).workletChunks, { timeout: 10_000 }) .toBeGreaterThan(0); await expect .poll(async () => { const probe = await readVoiceUiProbe(page); return probe.messages.some( (message) => message.direction === "sent" && message.kind === "text" && message.data?.includes('"audio_start"') && message.data?.includes('"format":"pcm"'), ); }, { timeout: 10_000 }) .toBeTruthy(); await mic.click(); await expect .poll(async () => { const probe = await readVoiceUiProbe(page); return probe.messages.some( (message) => message.direction === "sent" && message.kind === "binary", ); }, { timeout: 10_000 }) .toBeTruthy(); await expect .poll(async () => { const probe = await readVoiceUiProbe(page); return probe.messages.some( (message) => message.direction === "sent" && message.kind === "text" && message.data?.includes('"audio_end"') && message.data?.includes('"silence_ms":1240') && message.data?.includes('"browser_audio_worklet"'), ); }, { timeout: 10_000 }) .toBeTruthy(); await expect .poll(async () => { const probe = await readVoiceUiProbe(page); const events = probe.messages .filter((message) => message.direction === "received" && message.kind === "text") .map((message) => JSON.parse(message.data ?? "{}") as { type?: string; text?: string }); return { transcript: events.some((event) => event.type === "transcript" && event.text === learnerText), reply: events.some((event) => event.type === "reply"), errors: events.filter((event) => event.type === "error" || event.type === "degraded").length, }; }, { timeout: 90_000 }) .toEqual({ transcript: true, reply: true, errors: 0 }); const reviewResponse = await page.request.get(`/api/sessions/${sessionId}/review`); await expectResponseOk(reviewResponse); const review = (await reviewResponse.json()) as SessionReviewResponse; const learnerTurn = review.turns.find( (turn) => turn.speaker === "learner" && turn.text === learnerText, ); expect(learnerTurn).toBeTruthy(); const nonverbal = learnerTurn?.nonverbal ?? []; expect(nonverbal.map((event) => event.kind)).toEqual(expect.arrayContaining(["silence", "pace"])); expect(nonverbal.filter((event) => event.kind === "silence")).toHaveLength(1); expect(nonverbal.some((event) => event.label === "침묵" && event.detail === "1.2초")).toBe( true, ); expect(nonverbal.map((event) => event.detail).join(" ")).not.toContain("browser_audio_worklet"); }); test("persists learner turns into DB-backed review and teacher dashboard @single-run", async ({ page }) => { test.setTimeout(120_000); await signInAsLearner(page); const { sessionId, expectedMinTurns } = await createSessionWithTurn(page); const reviewResponse = await page.request.get(`/api/sessions/${sessionId}/review`); await expectResponseOk(reviewResponse); const review = await reviewResponse.json(); expect(review.session_id).toBe(sessionId); expect(review.turns.length).toBeGreaterThanOrEqual(expectedMinTurns); expect(review.turns[0].speaker).toBe("learner"); if (review.reviewReady) { expect(review.degraded).toBe(false); expect(review.summary).toContain("평가 AI"); } else { expect(review.degraded).toBe(true); expect(review.rubric).toHaveLength(0); expect(review.goodMoments).toHaveLength(0); expect(review.growthPoints).toHaveLength(0); expect(review.summary).not.toContain("잘한 구체적"); } const hasClientTurn = review.turns.some( (turn: { speaker: string }) => turn.speaker === "client", ); if (hasClientTurn) { expect(review.clientFeedback).toEqual(expect.any(String)); expect(review.clientFeedback.length).toBeGreaterThan(0); } await signInAsTeacher(page); const dashboardResponse = await page.request.get("/api/teacher/dashboard"); await expectResponseOk(dashboardResponse); const dashboard = await dashboardResponse.json(); expect(dashboard.source).toBe("database"); expect( dashboard.recent_sessions.some( (session: { session_id: string; turn_count: number }) => session.session_id === sessionId && session.turn_count >= expectedMinTurns, ), ).toBe(true); }); test("persists learner case worksheet and teacher worksheet review through DB @single-run", async ({ page, }) => { test.setTimeout(150_000); const healthResponse = await page.request.get("/api/health"); await expectResponseOk(healthResponse); const health = (await healthResponse.json()) as HealthResponse; test.skip(!health.db || !health.engine, "case worksheet E2E requires DB and engine-backed turn"); await signInAsLearner(page); const { sessionId, expectedMinTurns } = await createSessionWithTurn(page); expect( expectedMinTurns, "case worksheet E2E requires a persisted learner/client transcript", ).toBeGreaterThanOrEqual(2); await page.goto(`/learn/session/${sessionId}/review`); await openReviewTab(page, "워크시트"); const worksheetCard = page.locator(".sr-card--worksheet"); await expect(worksheetCard).toBeVisible({ timeout: 20_000 }); await expect(worksheetCard).toContainText("축어록 기반 자동 초안"); const worksheetInputs = worksheetCard.locator(".sr-ws-input"); await expect(worksheetInputs.first()).toBeVisible(); const savedWorksheetValue = `E2E 저장 사례개념화 ${Date.now()}`; await worksheetInputs.first().fill(savedWorksheetValue); await worksheetCard.getByRole("button", { name: "저장" }).click(); await expect(worksheetCard).toContainText("저장된 학습자 제출본", { timeout: 15_000 }); const learnerReviewResponse = await page.request.get(`/api/sessions/${sessionId}/review`); await expectResponseOk(learnerReviewResponse); const learnerReview = (await learnerReviewResponse.json()) as SessionReviewResponse; expect(learnerReview.caseWorksheet?.status).toBe("saved_by_learner"); const learnerWorksheetValues = (learnerReview.caseWorksheet?.sections ?? []).flatMap((section) => (section.items ?? []).map((item) => item.value ?? ""), ); expect(learnerWorksheetValues).toContain(savedWorksheetValue); await signInAsTeacher(page); await page.goto(`/teach/session/${sessionId}/review`); await openReviewTab(page, "워크시트"); await expect(worksheetCard).toBeVisible({ timeout: 20_000 }); await expect(worksheetCard).toContainText("학습자 저장본 읽기 전용"); await expect(worksheetCard.locator(".sr-ws-input").first()).toHaveValue(savedWorksheetValue); // 워크시트 검수 메모·수정요청은 교수자 검토 카드(피드백 탭)에 있다. await openReviewTab(page, "피드백"); const teacherWorksheetNote = `보호요인 보강 요청 ${Date.now()}`; await page.getByLabel("워크시트 검수 메모").fill(teacherWorksheetNote); await page.getByRole("button", { name: "수정요청" }).click(); await expect(page.locator(".sr-teacher-review__worksheet")).toContainText("수정요청", { timeout: 15_000, }); await expect(page.locator(".sr-teacher-review__worksheet")).toContainText("워크시트 검수 시각"); const teacherReviewResponse = await page.request.get(`/api/sessions/${sessionId}/review`); await expectResponseOk(teacherReviewResponse); const teacherReview = (await teacherReviewResponse.json()) as SessionReviewResponse; expect(teacherReview.caseWorksheet?.status).toBe("saved_by_learner"); expect(teacherReview.teacherReview?.worksheetStatus).toBe("changes_requested"); expect(teacherReview.teacherReview?.worksheetNote).toBe(teacherWorksheetNote); const teacherWorksheetValues = (teacherReview.caseWorksheet?.sections ?? []).flatMap((section) => (section.items ?? []).map((item) => item.value ?? ""), ); expect(teacherWorksheetValues).toContain(savedWorksheetValue); }); test("persists Phase 3 pre/post pilot scores through the DB-backed review UI @single-run", async ({ page, }) => { test.setTimeout(90_000); const healthResponse = await page.request.get("/api/health"); await expectResponseOk(healthResponse); const health = (await healthResponse.json()) as HealthResponse; test.skip(!health.db, "pre/post pilot score persistence requires DB"); await signInAsLearner(page); const sessionId = await createEndedSessionWithoutTurn(page); await page.goto(`/learn/session/${sessionId}/review`); await openReviewTab(page, "피드백"); await expect(page.locator(".sr-card--prepost")).toBeVisible({ timeout: 15_000 }); await expect(page.locator(".sr-prepost__actions")).toContainText("저장된 값 기준"); const inputs = page.locator(".sr-card--prepost input"); await expect(inputs).toHaveCount(6); const current = await inputs.evaluateAll((nodes) => nodes.map((node) => (node as HTMLInputElement).value), ); const primaryValues = ["4.9", "4.7", "3.9", "4.1", "2.9", "3.8"]; const alternateValues = ["4.8", "4.6", "3.8", "4.2", "2.8", "3.7"]; const targetValues = current.join("|") === primaryValues.join("|") ? alternateValues : primaryValues; for (let index = 0; index < targetValues.length; index += 1) { await inputs.nth(index).fill(targetValues[index]); } await expect(page.locator(".sr-prepost__actions")).toContainText("6개 변경"); await page.getByRole("button", { name: "점수 저장" }).click(); await expect(page.getByText("3/3 쌍")).toBeVisible({ timeout: 15_000 }); await expect(page.locator(".sr-prepost__actions")).toContainText("저장된 값 기준"); await expect( page.getByText("효과 판정과 통계 검정은 평가설계 확정 후 별도 산출합니다."), ).toBeVisible(); const prepostResponse = await page.request.get( "/api/users/me/prepost-measures?pilot_id=phase3-pilot-draft", ); await expectResponseOk(prepostResponse); const prepost = (await prepostResponse.json()) as PrepostMeasuresResponse; expect(prepost.source).toBe("database"); expect(prepost.durable).toBe(true); expect(prepost.complete_measure_pairs).toBe(3); const byKey = new Map( prepost.measures.map((measure) => [ `${measure.measure_name}:${measure.timepoint}`, measure.raw_score, ]), ); expect(byKey.get("self_efficacy:pre")).toBe(Number(targetValues[0])); expect(byKey.get("self_efficacy:post")).toBe(Number(targetValues[1])); expect(byKey.get("skill_proficiency:pre")).toBe(Number(targetValues[2])); expect(byKey.get("skill_proficiency:post")).toBe(Number(targetValues[3])); expect(byKey.get("training_satisfaction:pre")).toBe(Number(targetValues[4])); expect(byKey.get("training_satisfaction:post")).toBe(Number(targetValues[5])); await page.reload(); await openReviewTab(page, "피드백"); await expect(page.locator(".sr-card--prepost")).toBeVisible({ timeout: 15_000 }); await expect(page.getByText("3/3 쌍")).toBeVisible(); for (let index = 0; index < targetValues.length; index += 1) { await expect(inputs.nth(index)).toHaveValue(targetValues[index]); } }); test("finishes session end evaluation into a durable DB-backed teacher review @single-run", async ({ page, }) => { test.setTimeout(240_000); const healthResponse = await page.request.get("/api/health"); await expectResponseOk(healthResponse); const health = (await healthResponse.json()) as HealthResponse; test.skip(!health.db || !health.engine, "session end evaluation requires DB and engine"); await signInAsLearner(page); const { sessionId, expectedMinTurns } = await createSessionWithTurn(page); expect( expectedMinTurns, "session end evaluation smoke requires a completed learner/client turn", ).toBeGreaterThanOrEqual(2); await signInAsTeacher(page); await expect .poll(async () => evaluationState(page, sessionId), { timeout: 180_000, intervals: [1_000, 2_000, 5_000, 10_000], message: "session end background evaluation should create a durable ready row", }) .toMatch(/^ready:[1-9]\d*$/); const reviewResponse = await page.request.get(`/api/sessions/${sessionId}/review`); await expectResponseOk(reviewResponse); const review = (await reviewResponse.json()) as SessionReviewResponse; expect(review.reviewReady).toBe(true); expect(review.degraded).toBe(false); expect(review.supervisorState).toBe("평가 완료"); expect(review.summary ?? "").toContain("평가 AI"); const dashboardResponse = await page.request.get("/api/teacher/dashboard"); await expectResponseOk(dashboardResponse); const dashboard = (await dashboardResponse.json()) as TeacherDashboardResponse; expect(dashboard.source).toBe("database"); const dashboardSession = (dashboard.recent_sessions ?? []).find( (session) => session.session_id === sessionId, ); expect(dashboardSession, "teacher dashboard should expose the same persisted session").toBeTruthy(); expect(dashboardSession?.turn_count ?? 0).toBeGreaterThanOrEqual(expectedMinTurns); expect(dashboardSession?.evaluation_status).toBe("ready"); expect(dashboardSession?.review_ready).toBe(true); expect(dashboardSession?.supervisor_state).toBe("평가 완료"); await page.goto(`/teach/session/${sessionId}/review`); await expect(page.locator(".sr-head__stats")).toContainText("평가 완료", { timeout: 15_000, }); await expect(page.locator('[aria-label="리뷰 생성 상태"]')).toContainText("준비됨"); await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toHaveCount(0); }); test("persists explicit teacher session reevaluation into durable DB state @single-run", async ({ page, }) => { test.setTimeout(180_000); const healthResponse = await page.request.get("/api/health"); await expectResponseOk(healthResponse); const health = (await healthResponse.json()) as HealthResponse; test.skip(!health.db || !health.engine, "teacher session reevaluation requires DB and engine"); await signInAsLearner(page); const sessionId = await createActiveSessionWithTurn(page); await signInAsTeacher(page); const reevaluateResponse = await page.request.post(`/api/eval/sessions/${sessionId}/reevaluate`, { data: { scope: "stage_transition" }, }); await expectResponseOk(reevaluateResponse); const reevaluation = (await reevaluateResponse.json()) as { scope?: string; error?: string | null; turns_evaluated?: number; }; expect(reevaluation.scope).toBe("stage_transition"); expect(reevaluation.error ?? "").toBe(""); expect(reevaluation.turns_evaluated ?? 0).toBeGreaterThan(0); const evaluationResponse = await page.request.get(`/api/eval/sessions/${sessionId}/evaluation`); await expectResponseOk(evaluationResponse); const evaluation = (await evaluationResponse.json()) as EvaluationSummaryResponse; expect(evaluation.status).toBe("ready"); expect(evaluation.durable).toBe(true); expect(evaluation.deep?.scope).toBe("stage_transition"); expect(evaluation.deep?.turns_evaluated).toBe(reevaluation.turns_evaluated); const reviewResponse = await page.request.get(`/api/sessions/${sessionId}/review`); await expectResponseOk(reviewResponse); const review = (await reviewResponse.json()) as SessionReviewResponse; expect(review.reviewReady).toBe(true); expect(review.supervisorState).toBe("평가 완료"); }); test("persists explicit teacher turn reevaluation into DB-backed review state @single-run", async ({ page, }) => { test.setTimeout(120_000); const healthResponse = await page.request.get("/api/health"); await expectResponseOk(healthResponse); const health = (await healthResponse.json()) as HealthResponse; test.skip(!health.db || !health.engine, "teacher turn reevaluation requires DB and engine"); await signInAsLearner(page); const sessionId = await createActiveSessionWithTurn(page); await signInAsTeacher(page); const turnReevaluationResponse = await page.request.post(`/api/eval/sessions/${sessionId}/turn`, { data: { turn_seq: 1 }, }); await expectResponseOk(turnReevaluationResponse); const turnReevaluation = (await turnReevaluationResponse.json()) as { error?: string | null; techniques?: Array<{ label_ko?: string; code?: string }>; }; expect(turnReevaluation.error ?? "").toBe(""); const expectedLabels = (turnReevaluation.techniques ?? []) .map((technique) => technique.label_ko || technique.code || "") .filter((label) => label.length > 0); test.skip(expectedLabels.length === 0, "evaluator returned no technique labels to verify in review"); const reviewResponse = await page.request.get(`/api/sessions/${sessionId}/review`); await expectResponseOk(reviewResponse); const review = (await reviewResponse.json()) as SessionReviewResponse; const learnerTurn = review.turns.find((turn) => turn.speaker === "learner"); expect(learnerTurn, "review should still expose the original learner turn").toBeTruthy(); const reviewLabels = (learnerTurn?.techniques ?? []).map((technique) => technique.label); for (const label of expectedLabels) { expect(reviewLabels).toContain(label); } }); test("runs manual AI evaluation retry from teacher review UI into durable DB state @single-run", async ({ page, }) => { test.setTimeout(240_000); const healthResponse = await page.request.get("/api/health"); await expectResponseOk(healthResponse); const health = (await healthResponse.json()) as HealthResponse; test.skip(!health.db || !health.engine, "manual evaluation retry requires DB and engine"); await signInAsLearner(page); const sessionId = await createActiveSessionWithTurn(page); const endedResponse = await page.request.post(`/api/sessions/${sessionId}/end`); await expectResponseOk(endedResponse); await withGlobalEngineConfigLock("manual-evaluation-retry-ui", async () => { await signInAsAdmin(page); const engineResponse = await page.request.get("/api/admin/engine-config"); await expectResponseOk(engineResponse); const currentEngine = (await engineResponse.json()) as AdminEngineConfigResponse; try { const brokenEnginePatch = await page.request.patch("/api/admin/engine-config", { data: { engine_mode: currentEngine.engine_mode, engine_url: "http://127.0.0.1:9", model: currentEngine.model, }, }); await expectResponseOk(brokenEnginePatch); await signInAsTeacher(page); const failedReevaluationResponse = await page.request.post( `/api/eval/sessions/${sessionId}/reevaluate`, { data: { scope: "session_end" } }, ); expect( failedReevaluationResponse.ok(), "broken engine should create a failed durable evaluation seed", ).toBe(false); await signInAsAdmin(page); const restoreResponse = await page.request.patch("/api/admin/engine-config", { data: { engine_mode: currentEngine.engine_mode, engine_url: currentEngine.engine_url, model: currentEngine.model, }, }); await expectResponseOk(restoreResponse); await signInAsTeacher(page); await page.goto(`/teach/session/${sessionId}/review`); await expect(page.getByText("평가 실패")).toBeVisible({ timeout: 15_000 }); const retryButton = page.getByRole("button", { name: "AI 평가 재시도" }); await expect(retryButton).toBeVisible(); const reevaluateResponsePromise = page.waitForResponse((response) => { const url = new URL(response.url()); return ( response.request().method() === "POST" && url.pathname.endsWith(`/eval/sessions/${sessionId}/reevaluate`) ); }); await retryButton.click(); const reevaluateResponse = await reevaluateResponsePromise; await expectResponseOk(reevaluateResponse); const reevaluation = (await reevaluateResponse.json()) as { scope?: string; error?: string | null; turns_evaluated?: number; }; expect(reevaluation.scope).toBe("session_end"); expect(reevaluation.error ?? "").toBe(""); expect(reevaluation.turns_evaluated ?? 0).toBeGreaterThan(0); await expect(page.locator(".sr-head__stats")).toContainText("평가 완료", { timeout: 30_000, }); await expect(page.locator('[aria-label="리뷰 생성 상태"]')).toContainText("준비됨"); await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toHaveCount(0); const evaluationResponse = await page.request.get(`/api/eval/sessions/${sessionId}/evaluation`); await expectResponseOk(evaluationResponse); const evaluation = (await evaluationResponse.json()) as EvaluationSummaryResponse; expect(evaluation.status).toBe("ready"); expect(evaluation.durable).toBe(true); expect(evaluation.deep?.scope).toBe("session_end"); expect(evaluation.deep?.turns_evaluated).toBe(reevaluation.turns_evaluated); const reviewResponse = await page.request.get(`/api/sessions/${sessionId}/review`); await expectResponseOk(reviewResponse); const review = (await reviewResponse.json()) as SessionReviewResponse; expect(review.reviewReady).toBe(true); expect(review.supervisorState).toBe("평가 완료"); const dashboardResponse = await page.request.get("/api/teacher/dashboard"); await expectResponseOk(dashboardResponse); const dashboard = (await dashboardResponse.json()) as TeacherDashboardResponse; expect(dashboard.source).toBe("database"); const dashboardSession = [...(dashboard.pending_reviews ?? []), ...(dashboard.recent_sessions ?? [])].find( (session) => session.session_id === sessionId, ); expect(dashboardSession, "teacher dashboard should expose the retried session").toBeTruthy(); expect(dashboardSession?.evaluation_status).toBe("ready"); expect(dashboardSession?.review_ready).toBe(true); expect(dashboardSession?.supervisor_state).toBe("평가 완료"); } finally { await signInAsAdmin(page); const restoreResponse = await page.request.patch("/api/admin/engine-config", { data: { engine_mode: currentEngine.engine_mode, engine_url: currentEngine.engine_url, model: currentEngine.model, }, }); await expectResponseOk(restoreResponse); } }); }); });