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
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue