1002 lines
36 KiB
TypeScript
1002 lines
36 KiB
TypeScript
import { expect, test, type Page } from "@playwright/test";
|
|
import { fetchAvailablePersona, signInAsLearner, signInAsTeacher } from "./support";
|
|
|
|
interface SessionStartResponse {
|
|
session_id: string;
|
|
degraded: boolean;
|
|
}
|
|
|
|
interface HealthResponse {
|
|
db?: boolean;
|
|
engine?: boolean;
|
|
}
|
|
|
|
interface ReviewTurn {
|
|
speaker: string;
|
|
text: string;
|
|
nonverbal?: Array<{
|
|
kind: string;
|
|
label: string;
|
|
detail: string;
|
|
}>;
|
|
}
|
|
|
|
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 LiveCoachEvent {
|
|
turn_seq: number;
|
|
learner_text_excerpt?: string | null;
|
|
suggestion: {
|
|
title: string;
|
|
message: string;
|
|
};
|
|
}
|
|
|
|
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<string, unknown> | null;
|
|
}
|
|
|
|
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<string> }) {
|
|
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<VoiceUiProbeState> {
|
|
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 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}`;
|
|
}
|
|
|
|
test.describe("session persistence", () => {
|
|
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("persists AI tutor coaching history through reload @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, "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"];
|
|
await expect(page.locator(".sx-coach-card").getByText(coachSuggestion.title)).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
|
|
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");
|
|
expect(
|
|
history.events.some(
|
|
(event) =>
|
|
event.turn_seq === 1 &&
|
|
event.learner_text_excerpt?.includes("가장 버거운 감정") &&
|
|
event.suggestion.title === coachSuggestion.title,
|
|
),
|
|
).toBe(true);
|
|
|
|
await page.goto(`/learn/session/${sessionId}`);
|
|
await expect(page.locator(".sx-utt.is-learner").filter({ hasText: learnerText })).toBeVisible({
|
|
timeout: 15_000,
|
|
});
|
|
await expect(page.locator(".sx-utt.is-learner .sx-utt__coach-mark")).toBeVisible({
|
|
timeout: 15_000,
|
|
});
|
|
});
|
|
|
|
test("persists voice nonverbal metadata 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;
|
|
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<VoiceWsProbe>((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);
|
|
}, 60_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: 60_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`);
|
|
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 expect(worksheetCard).toBeVisible({ timeout: 20_000 });
|
|
await expect(worksheetCard).toContainText("학습자 저장본 읽기 전용");
|
|
await expect(worksheetCard.locator(".sr-ws-input").first()).toHaveValue(savedWorksheetValue);
|
|
|
|
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 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 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");
|
|
|
|
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);
|
|
});
|
|
});
|