회기 무발화 0턴 분리, 자기예측 락 불변식 및 TDD 회귀 검증 완료
Some checks failed
API contract / OpenAPI type drift (push) Failing after 3m27s

This commit is contained in:
Yun Chan 2026-09-08 23:28:06 +09:00
parent a479db7a5a
commit a0311c5957
100 changed files with 4884 additions and 11210 deletions

View file

@ -1,5 +1,5 @@
import path from "node:path";
import { expect, test, type Page, type Route } from "@playwright/test";
import { expect, test, type Page, type Route, type TestInfo } from "@playwright/test";
import type { DeliberatePracticeReadModel } from "../src/pages/session-review/deliberatePracticeApi";
import { parsePracticeLaunchIntent } from "../src/lib/practiceLaunchIntent";
import {
@ -893,17 +893,14 @@ async function expectMobileActiveVisualIntegrity(page: Page) {
expect(result.latestReplyVisible, diagnostic).toBe(true);
}
async function capture(page: Page, projectName: string, stage: string) {
async function capture(page: Page, testInfo: TestInfo, stage: string) {
if (process.env.E2E_SKIP_EVIDENCE_CAPTURE === "1") return;
const safeProject = projectName.replace(/[^a-z0-9-]+/gi, "-").toLowerCase();
const evidenceDirectory = process.env.E2E_EVIDENCE_DIR
? path.resolve(process.env.E2E_EVIDENCE_DIR)
: path.resolve(process.cwd(), "../../docs/ops/evidence");
const safeProject = testInfo.project.name.replace(/[^a-z0-9-]+/gi, "-").toLowerCase();
const evidenceDirectory = process.env.E2E_EVIDENCE_DIR;
await page.screenshot({
path: path.resolve(
evidenceDirectory,
`self-directed-loop-${stage}-${safeProject}-2026-08-07.png`,
),
path: evidenceDirectory
? path.resolve(evidenceDirectory, `self-directed-loop-${stage}-${safeProject}.png`)
: testInfo.outputPath(`self-directed-loop-${stage}.png`),
fullPage: true,
animations: "disabled",
});
@ -1030,11 +1027,11 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi
);
await expectMinimumHitTargets(page, ".lh-tabs [role='tab']", "320px 학습 홈 탭");
await expectNoHorizontalOverflow(page);
await capture(page, testInfo.project.name, "home-320x568");
await capture(page, testInfo, "home-320x568");
await page.setViewportSize({ width: 390, height: 844 });
await page.evaluate(() => new Promise(requestAnimationFrame));
}
await capture(page, testInfo.project.name, "home");
await capture(page, testInfo, "home");
await primaryAction.click();
await expect(page).toHaveURL(/\/learn\/practice$/);
@ -1095,11 +1092,11 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi
await expectMobileActiveVisualIntegrity(page);
await expectCoachNudgeContrast(page);
await expectNoHorizontalOverflow(page);
await capture(page, testInfo.project.name, viewport.stage);
await capture(page, testInfo, viewport.stage);
}
await page.setViewportSize({ width: 390, height: 844 });
} else {
await capture(page, testInfo.project.name, "active-session");
await capture(page, testInfo, "active-session");
}
await page.getByRole("button", { name: "회기 종료" }).click();
@ -1173,12 +1170,12 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi
"320px 처방 연습 시작 행동",
);
await expectNoHorizontalOverflow(page);
await capture(page, testInfo.project.name, "review-prescription-320x568");
await capture(page, testInfo, "review-prescription-320x568");
await page.setViewportSize({ width: 390, height: 844 });
await page.evaluate(() => new Promise(requestAnimationFrame));
}
await practiceCard.scrollIntoViewIfNeeded();
await capture(page, testInfo.project.name, "review-prescription");
await capture(page, testInfo, "review-prescription");
const launch = practiceCard.getByRole("link", {
name: "이 처방으로 연습 시작",
@ -1222,7 +1219,7 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi
await page.evaluate(() => new Promise(requestAnimationFrame));
await expectMinimumHitTargets(page, ".lh-actions .vg-btn", "320px 재연습 대상 행동");
await expectNoHorizontalOverflow(page);
await capture(page, testInfo.project.name, "repractice-home-320x568");
await capture(page, testInfo, "repractice-home-320x568");
await page.setViewportSize({ width: 390, height: 844 });
await page.evaluate(() => new Promise(requestAnimationFrame));
}
@ -1264,11 +1261,11 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi
"320px 처방 재연습 시작 행동",
);
await expectNoHorizontalOverflow(page);
await capture(page, testInfo.project.name, "repractice-prestart-320x568");
await capture(page, testInfo, "repractice-prestart-320x568");
await page.setViewportSize({ width: 390, height: 844 });
await page.evaluate(() => new Promise(requestAnimationFrame));
}
await capture(page, testInfo.project.name, "repractice-prestart");
await capture(page, testInfo, "repractice-prestart");
await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page).toHaveURL(
@ -1368,12 +1365,12 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi
await expect(observation).not.toContainText(RETRY_SESSION_ID.slice(0, 8));
await expect(observation).not.toContainText("[NAME]");
await expectNoHorizontalOverflow(page);
await capture(page, testInfo.project.name, "repractice-observation-320x568");
await capture(page, testInfo, "repractice-observation-320x568");
await page.setViewportSize({ width: 390, height: 844 });
await page.evaluate(() => new Promise(requestAnimationFrame));
}
await observation.scrollIntoViewIfNeeded();
await capture(page, testInfo.project.name, "repractice-observation");
await capture(page, testInfo, "repractice-observation");
await page.reload();
observation = page.locator(".dp-runtime-observation");

View file

@ -1,4 +1,3 @@
import path from "node:path";
import { expect, test, type Page } from "@playwright/test";
import { parseVoicePracticeContext } from "../src/lib/voicePracticeContext";
import { expectNoHorizontalOverflow } from "./support";
@ -424,6 +423,7 @@ async function routeMvpApi(page: Page, options: RouteMvpOptions = {}) {
growthPoints: [{ title: "탐색 확장", body: "다음 턴에서 구체 상황을 더 묻습니다." }],
nextLine: "그 말을 꺼내는 것도 쉽지 않았을 것 같아요.",
clientFeedback: clientReply,
learnerFeedbackEnabled: true,
audioUrl: null,
pdfExportUrl: null,
degraded: false,
@ -773,7 +773,6 @@ async function installVoiceLifecycleFixture(page: Page) {
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" });
},
@ -877,11 +876,12 @@ async function installVoiceLifecycleFixture(page: Page) {
}
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 });
}
if (this.connectionNumber > 2) return;
this.emitJson({
type: "transcript",
text: this.connectionNumber === 1 ? firstInterim : secondInterim,
final: false,
});
this.emitJson({
type: "eot",
ready: false,
@ -1116,7 +1116,7 @@ test.describe("P1 MVP core loop", () => {
await expect(
page.getByRole("heading", { name: "원본 회기 정보를 확인할 수 없습니다." }),
).toBeVisible();
await expect(page.getByRole("button", { name: "새 회기 시작" })).toBeDisabled();
await expect(page.getByRole("button", { name: "완전히 새로 시작" })).toBeDisabled();
await expectNoHorizontalOverflow(page);
await page.goto(`/learn/session/P1?${malformedQuery}`);
@ -1153,11 +1153,7 @@ test.describe("P1 MVP core loop", () => {
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`,
),
path: testInfo.outputPath("g1-alliance-pre.png"),
fullPage: true,
});
await selectAllianceScore(page, "목표", "3 보통이다");
@ -1176,11 +1172,7 @@ test.describe("P1 MVP core loop", () => {
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`,
),
path: testInfo.outputPath("g1-alliance-mid.png"),
fullPage: true,
});
await selectAllianceScore(page, "목표", "4 대체로 그렇다");
@ -1210,7 +1202,8 @@ test.describe("P1 MVP core loop", () => {
page.getByRole("button", { name: "회기 시작" }),
diagnostics.join("\n") || (await page.locator("#root").innerText().catch(() => "")),
).toBeVisible();
await page.getByRole("button", { name: /CBT/ }).click();
await page.locator("details.sx-prestart__settings > summary").click();
await page.getByRole("button", { name: /^CBT/ }).click();
await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
@ -1253,7 +1246,7 @@ test.describe("P1 MVP core loop", () => {
await page.goto("/learn/session/P1");
await page.getByRole("button", { name: "회기 시작" }).click();
await page.getByRole("button", { name: "코칭" }).click();
await page.getByRole("button", { name: /^코칭 모드, 남은 기회 \d+개$/ }).click();
await page.getByLabel("학습자 발화 입력").fill(learnerText);
await page.getByRole("button", { name: "보내기" }).click();
@ -1292,7 +1285,7 @@ test.describe("P1 MVP core loop", () => {
await page.goto("/learn/session/P1");
await page.getByRole("button", { name: "회기 시작" }).click();
await page.getByRole("button", { name: "코칭" }).click();
await page.getByRole("button", { name: /^코칭 모드, 남은 기회 \d+개$/ }).click();
await page.getByLabel("학습자 발화 입력").fill(learnerText);
await page.getByRole("button", { name: "보내기" }).click();
@ -1317,7 +1310,7 @@ test.describe("P1 MVP core loop", () => {
await page.goto("/learn/session/P1");
await page.getByRole("button", { name: "회기 시작" }).click();
await page.getByRole("button", { name: "코칭" }).click();
await page.getByRole("button", { name: /^코칭 모드, 남은 기회 \d+개$/ }).click();
await page.getByLabel("학습자 발화 입력").fill(learnerText);
await page.getByRole("button", { name: "보내기" }).click();
@ -1341,7 +1334,7 @@ test.describe("P1 MVP core loop", () => {
await page.goto("/learn/session/P1");
await page.getByRole("button", { name: "회기 시작" }).click();
await page.getByRole("button", { name: "코칭" }).click();
await page.getByRole("button", { name: /^코칭 모드, 남은 기회 \d+개$/ }).click();
await page.getByLabel("학습자 발화 입력").fill(learnerText);
await page.getByRole("button", { name: "보내기" }).click();
@ -1367,7 +1360,7 @@ test.describe("P1 MVP core loop", () => {
await page.goto("/learn/session/P1");
await page.getByRole("button", { name: "회기 시작" }).click();
await page.getByRole("button", { name: "코칭" }).click();
await page.getByRole("button", { name: /^코칭 모드, 남은 기회 \d+개$/ }).click();
await page.getByLabel("학습자 발화 입력").fill(learnerText);
await page.getByRole("button", { name: "보내기" }).click();
@ -1505,6 +1498,14 @@ test.describe("P1 MVP core loop", () => {
await page.getByRole("button", { name: "음성 다시 연결" }).click();
await page.getByRole("button", { name: "발화 보내기" }).click();
await expect.poll(async () =>
page.evaluate(
() =>
window.__voiceLifecycleFixture?.sent.filter((payload) =>
payload.includes('"audio_end"'),
).length ?? 0,
),
).toBe(3);
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("음성을 인식하지 못했습니다");
@ -1519,7 +1520,7 @@ test.describe("P1 MVP core loop", () => {
await page.goto("/learn/session/P1");
await page.getByRole("button", { name: "회기 시작" }).click();
await page.getByRole("button", { name: "코칭" }).click();
await page.getByRole("button", { name: /^코칭 모드, 남은 기회 \d+개$/ }).click();
const micButton = page.getByRole("button", { name: "마이크 켜기" });
await expect(micButton).toBeEnabled();
await micButton.click();
@ -1548,7 +1549,7 @@ test.describe("P1 MVP core loop", () => {
await page.goto("/learn/session/P1");
await page.getByRole("button", { name: "회기 시작" }).click();
await page.getByRole("button", { name: "코칭" }).click();
await page.getByRole("button", { name: /^코칭 모드, 남은 기회 \d+개$/ }).click();
await page.getByLabel("학습자 발화 입력").fill(learnerText);
await page.getByRole("button", { name: "보내기" }).click();
@ -1583,7 +1584,7 @@ test.describe("P1 MVP core loop", () => {
await page.goto("/learn/session/P1");
await page.getByRole("button", { name: "회기 시작" }).click();
await page.getByRole("button", { name: "코칭" }).click();
await page.getByRole("button", { name: /^코칭 모드, 남은 기회 \d+개$/ }).click();
await page.getByLabel("학습자 발화 입력").fill("저 지금 자살하고 싶어요. 도와주세요.");
await page.getByRole("button", { name: "보내기" }).click();

View file

@ -1117,10 +1117,10 @@ test.describe("voice cascade success path", () => {
await expect(consentDialog).toBeVisible();
if (process.env.CAPTURE_G7_CONSENT === "1") {
const desktopViewport = page.viewportSize() ?? { width: 1440, height: 900 };
await page.screenshot({ path: "test-results/g7-voice-consent-desktop.png" });
await page.screenshot({ path: testInfo.outputPath("g7-voice-consent-desktop.png") });
await page.setViewportSize({ width: 390, height: 844 });
await expect(consentDialog).toBeVisible();
await page.screenshot({ path: "test-results/g7-voice-consent-mobile.png" });
await page.screenshot({ path: testInfo.outputPath("g7-voice-consent-mobile.png") });
await page.setViewportSize(desktopViewport);
}
await expect.poll(async () => (await readVoiceUiProbe(page)).getUserMediaCalls).toBe(0);