음성 재생과 운영 배포 정리

This commit is contained in:
Yun Chan 2026-06-28 12:18:20 +09:00
parent 8ed185ce6c
commit ac7db95542
1020 changed files with 46863 additions and 2175 deletions

View file

@ -1,5 +1,6 @@
import { expect, test, type APIResponse, type Page, type Response, type TestInfo } from "@playwright/test";
import {
completeOnboarding,
expectNoHorizontalOverflow,
signInAsLearner,
useRealApi,
@ -29,8 +30,12 @@ interface AdminManagedUser {
email: string;
display_name: string;
role: "learner" | "teacher" | "admin";
account_status: "pending" | "approved" | "suspended";
affiliation: string;
cohort_ids: string[];
active_sessions: number;
last_seen_at: number;
created_at: number;
}
interface AdminUsersResponse {
@ -68,6 +73,48 @@ interface AdminUsageResponse {
by_provider: AdminUsageBreakdown[];
}
interface AdminUptimeResponse {
source: "database" | "unavailable";
durable: boolean;
window_hours: number;
sample_count: number;
ok_ratio: number;
degraded_events: number;
down_events: number;
last_down_at: number | null;
}
interface AdminTicketReporter {
email: string;
display_name: string;
role: string;
}
interface AdminSupportTicket {
ticket_id: string;
reporter: AdminTicketReporter;
category: string;
priority: "low" | "normal" | "high" | "urgent";
status: "open" | "triaged" | "in_progress" | "resolved" | "closed";
subject: string;
body: string;
source_path: string;
created_at: number;
updated_at: number;
}
interface AdminTicketsResponse {
source: "database" | "unavailable";
durable: boolean;
tickets: AdminSupportTicket[];
summary: {
total: number;
open_count: number;
high_priority_count: number;
stale_count: number;
};
}
async function expectResponseOk(response: APIResponse | Response) {
if (!response.ok()) {
expect(response.ok(), await response.text()).toBeTruthy();
@ -75,34 +122,97 @@ async function expectResponseOk(response: APIResponse | Response) {
}
async function signInAsAdmin(page: Page) {
const res = await page.request.post("/api/auth/dev-login", {
data: {
email: "admin@twentyoz.kr",
role: "admin",
display_name: "E2E Admin",
},
});
let res: APIResponse | null = null;
for (let attempt = 0; attempt < 3; attempt += 1) {
res = await page.request.post("/api/auth/dev-login", {
data: {
email: "admin@twentyoz.kr",
role: "admin",
display_name: "E2E Admin",
},
});
if (res.ok()) break;
await new Promise((resolve) => setTimeout(resolve, 300));
}
if (!res) throw new Error("admin dev-login did not return a response");
await expectResponseOk(res);
await completeOnboarding(page, {
legal_name: "E2E Admin",
affiliation: "한신대학교",
department: "운영",
grade_level: "관리자",
phone: "010-2222-2222",
contact_address: "경기도 오산시 한신대학교",
});
}
function isJsonResponse(response: Response) {
return response.headers()["content-type"]?.includes("application/json") ?? false;
}
function isAdminHealthResponse(response: Response) {
const url = new URL(response.url());
return response.request().method() === "GET" && url.pathname.endsWith("/admin/health");
return (
response.request().method() === "GET" &&
url.pathname.endsWith("/admin/health") &&
isJsonResponse(response)
);
}
function isAdminUsersResponse(response: Response) {
const url = new URL(response.url());
return response.request().method() === "GET" && url.pathname.endsWith("/admin/users");
return (
response.request().method() === "GET" &&
url.pathname.endsWith("/admin/users") &&
isJsonResponse(response)
);
}
function isAdminUsageResponse(response: Response) {
const url = new URL(response.url());
return response.request().method() === "GET" && url.pathname.endsWith("/admin/usage");
return (
response.request().method() === "GET" &&
url.pathname.endsWith("/admin/usage") &&
isJsonResponse(response)
);
}
function isAdminUptimeResponse(response: Response) {
const url = new URL(response.url());
return (
response.request().method() === "GET" &&
url.pathname.endsWith("/admin/uptime") &&
isJsonResponse(response)
);
}
function isAdminTicketsResponse(response: Response) {
const url = new URL(response.url());
return (
response.request().method() === "GET" &&
url.pathname.endsWith("/admin/tickets") &&
isJsonResponse(response)
);
}
function isAdminTicketPatch(ticketId: string) {
return (response: Response) => {
const url = new URL(response.url());
return (
response.request().method() === "PATCH" &&
url.pathname.endsWith(`/admin/tickets/${ticketId}`) &&
isJsonResponse(response)
);
};
}
function isAdminUserCreate(response: Response) {
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname.endsWith("/admin/users");
return (
response.request().method() === "POST" &&
url.pathname.endsWith("/admin/users") &&
isJsonResponse(response)
);
}
function isAdminUserPatch(userId: string) {
@ -110,7 +220,8 @@ function isAdminUserPatch(userId: string) {
const url = new URL(response.url());
return (
response.request().method() === "PATCH" &&
url.pathname.endsWith(`/admin/users/${userId}`)
url.pathname.endsWith(`/admin/users/${userId}`) &&
isJsonResponse(response)
);
};
}
@ -120,7 +231,8 @@ function isAdminUserDelete(userId: string) {
const url = new URL(response.url());
return (
response.request().method() === "DELETE" &&
url.pathname.endsWith(`/admin/users/${userId}`)
url.pathname.endsWith(`/admin/users/${userId}`) &&
isJsonResponse(response)
);
};
}
@ -150,35 +262,59 @@ function costLabel(value: number) {
return `$${value.toFixed(value < 0.01 ? 6 : 4)}`;
}
function isOnline(seconds: number) {
return Number.isFinite(seconds) && seconds > 0 && Date.now() / 1000 - seconds < 15 * 60;
}
async function openAdminAndReadHealth(page: Page) {
const healthResponsePromise = page.waitForResponse(isAdminHealthResponse);
const usageResponsePromise = page.waitForResponse(isAdminUsageResponse);
const usersResponsePromise = page.waitForResponse(isAdminUsersResponse);
const uptimeResponsePromise = page.waitForResponse(isAdminUptimeResponse);
const ticketsResponsePromise = page.waitForResponse(isAdminTicketsResponse);
await page.goto("/admin");
const [healthResponse, usageResponse] = await Promise.all([
const [healthResponse, usageResponse, usersResponse, uptimeResponse, ticketsResponse] = await Promise.all([
healthResponsePromise,
usageResponsePromise,
usersResponsePromise,
uptimeResponsePromise,
ticketsResponsePromise,
]);
await expectResponseOk(healthResponse);
await expectResponseOk(usageResponse);
await expectResponseOk(usersResponse);
await expectResponseOk(uptimeResponse);
await expectResponseOk(ticketsResponse);
const health = (await healthResponse.json()) as AdminHealthResponse;
const usage = (await usageResponse.json()) as AdminUsageResponse;
const users = (await usersResponse.json()) as AdminUsersResponse;
const uptime = (await uptimeResponse.json()) as AdminUptimeResponse;
const tickets = (await ticketsResponse.json()) as AdminTicketsResponse;
expect(health.services.length).toBeGreaterThan(0);
return { health, usage };
return { health, usage, users, uptime, tickets };
}
async function openAdminAndReadUsers(page: Page) {
const usersResponsePromise = page.waitForResponse(isAdminUsersResponse);
await page.goto("/admin");
await page.goto("/admin/users");
const usersResponse = await usersResponsePromise;
await expectResponseOk(usersResponse);
return (await usersResponse.json()) as AdminUsersResponse;
}
async function openAdminTickets(page: Page) {
const ticketsResponsePromise = page.waitForResponse(isAdminTicketsResponse);
await page.goto("/admin/tickets");
const ticketsResponse = await ticketsResponsePromise;
await expectResponseOk(ticketsResponse);
return (await ticketsResponse.json()) as AdminTicketsResponse;
}
async function expectCreateUserControlsFit(page: Page, viewportWidth: number) {
const form = page.locator(".ad-user-create");
await expect(form).toBeVisible();
@ -272,36 +408,36 @@ test.describe("admin route", () => {
});
test("allows an admin to open the live health dashboard", async ({ page }) => {
test.setTimeout(60_000);
await signInAsAdmin(page);
await withGlobalEngineConfigLock("admin-health-dashboard", async () => {
const { health, usage } = await openAdminAndReadHealth(page);
const { health, usage, users, uptime, tickets } = await openAdminAndReadHealth(page);
const serviceCards = page.locator(".ad-service:not(.ad-service--skeleton)");
const counts = {
ok: health.services.filter((service) => service.status === "ok").length,
degraded: health.services.filter((service) => service.status === "degraded").length,
down: health.services.filter((service) => service.status === "down").length,
total: health.services.length,
};
const activeSessions = users.users.reduce(
(sum, user) => sum + Math.max(0, user.active_sessions),
0,
);
const onlineUsers = users.users.filter((user) => isOnline(user.last_seen_at)).length;
await expect(page).toHaveURL(/\/admin$/);
await expect(page.locator(".ad-status")).toContainText(environmentLabel(health.environment));
await expect(page.locator(".ad-status")).toContainText(engineModeLabel(health.engine_mode));
await expect(page.locator(".ad-kpi b")).toHaveText([
String(health.services.length),
String(counts.ok),
String(counts.degraded),
String(counts.down),
countLabel(activeSessions),
countLabel(onlineUsers),
countLabel(users.users.length),
counts.total ? `${counts.ok}/${counts.total}` : "-",
]);
await expect(page.getByRole("heading", { name: "AI 비용 관측" })).toBeVisible();
await expect(page.locator(".ad-usage-kpi b")).toHaveText([
costLabel(usage.cost_usd),
countLabel(usage.tokens_in),
countLabel(usage.tokens_out),
usage.total_turns > 0
? `${Math.round((usage.metered_turns / usage.total_turns) * 100)}%`
: "0%",
]);
await expect(page.locator(".ad-usage-budget")).toContainText(
await expect(page.getByRole("heading", { name: "AI 비용" })).toBeVisible();
await expect(page.locator(".ad-cost")).toContainText(costLabel(usage.cost_usd));
await expect(page.locator(".ad-cost")).toContainText(
usage.budget.status === "disabled"
? "예산 경고 비활성"
: usage.budget.status === "exceeded"
@ -310,16 +446,15 @@ test.describe("admin route", () => {
? "예산 주의"
: "예산 정상",
);
if (usage.by_provider.length > 0) {
await expect(page.locator(".ad-usage-row")).toHaveCount(usage.by_provider.length);
await expect(page.locator(".ad-usage-row").first()).toContainText(
usage.by_provider[0].provider,
);
} else {
await expect(page.locator(".ad-usage-breakdown")).toContainText(
"최근 윈도우에 계량된 AI 턴이 없습니다.",
);
}
await expect(page.getByRole("heading", { name: "가용성" })).toBeVisible();
await expect(page.getByText("샘플 정상률")).toBeVisible();
expect(uptime.ok_ratio).toBeGreaterThanOrEqual(0);
expect(uptime.ok_ratio).toBeLessThanOrEqual(1);
await expect(page.getByRole("heading", { name: "운영 티켓" })).toBeVisible();
expect(tickets.summary.open_count).toBeGreaterThanOrEqual(0);
await expect(page.locator(".ad-panel").filter({ hasText: "운영 티켓" })).toContainText(
/(\d+건 미해결|미해결 티켓이 없습니다)/,
);
await expect(serviceCards).toHaveCount(health.services.length);
for (const service of health.services) {
@ -357,6 +492,7 @@ test.describe("admin route", () => {
});
test("allows an admin to manage real server-known users", async ({ page }, testInfo) => {
test.setTimeout(60_000);
await signInAsAdmin(page);
const slug = `${testInfo.project.name}.${testInfo.workerIndex}.${testInfo.retry}.${Date.now()}`;
@ -366,14 +502,17 @@ test.describe("admin route", () => {
expect(users.source).toBe("database");
expect(users.durable).toBe(true);
await page.getByRole("tab", { name: "사용자 목록" }).click();
await page.getByLabel("사용자 검색").fill(`no-match-${slug}`);
await expect(page.getByText("검색 조건에 맞는 사용자가 없습니다.")).toBeVisible();
await expect(page.getByText("아직 등록된 사용자가 없습니다.")).toHaveCount(0);
await page.getByLabel("사용자 검색").fill("");
await page.getByRole("tab", { name: "사용자 등록" }).click();
await page.getByLabel("새 사용자 이메일").fill(email);
await page.getByLabel("새 사용자 표시 이름").fill(displayName);
await page.getByLabel("새 사용자 역할").selectOption("learner");
await page.getByLabel("새 사용자 승인 상태").selectOption("pending");
await page.getByLabel("새 사용자 코호트").fill(`created-${testInfo.project.name}`);
const createPromise = page.waitForResponse(isAdminUserCreate);
@ -387,13 +526,40 @@ test.describe("admin route", () => {
email,
display_name: displayName,
role: "learner",
account_status: "pending",
cohort_ids: [`created-${testInfo.project.name}`],
});
await expect(page.getByRole("tab", { name: "사용자 목록" })).toHaveAttribute(
"aria-selected",
"true",
);
const approvalTab = page.getByRole("tab", { name: /가입 승인/ });
await approvalTab.click();
await expect(approvalTab).toHaveAttribute("aria-selected", "true");
const approvalCard = page.locator(".ad-approval").filter({ hasText: email });
await expect(approvalCard).toBeVisible();
await expect(approvalCard).toContainText("승인 대기");
await expectVisibleButtonsFit(page, ".ad-approval__actions .vg-btn", "admin approval buttons");
const approvePromise = page.waitForResponse(isAdminUserPatch(created.user_id));
await approvalCard.getByRole("button", { name: "승인" }).click();
const approveResponse = await approvePromise;
await expectResponseOk(approveResponse);
const approved = (await approveResponse.json()) as AdminManagedUser;
expect(approved).toMatchObject({
user_id: created.user_id,
email,
account_status: "approved",
});
await expect(approvalCard).toHaveCount(0);
await page.getByRole("tab", { name: "사용자 목록" }).click();
await page.getByLabel("사용자 검색").fill(email);
const card = page.locator(".ad-user").filter({ hasText: email });
await expect(card).toBeVisible();
await expect(card).toContainText(displayName);
await expect(card).toContainText("승인됨");
await expectVisibleButtonsFit(page, ".ad-user__actions .vg-btn", "admin user action buttons");
const nextName = `교수자 ${testInfo.project.name}`;
@ -422,27 +588,73 @@ test.describe("admin route", () => {
email,
display_name: nextName,
role: "teacher",
account_status: "approved",
cohort_ids: [nextCohort],
});
await expect(card).toContainText(nextName);
await expect(card).toContainText("교수자");
await expect(card).toContainText(nextCohort);
await expect(cohortInput).toHaveValue(nextCohort);
const deletePromise = page.waitForResponse(isAdminUserDelete(created.user_id));
await card.getByRole("button", { name: "비활성화" }).click();
const deleteResponse = await deletePromise;
await expectResponseOk(deleteResponse);
await expect(card).toHaveCount(0);
});
const blockedLogin = await page.request.post("/api/auth/dev-login", {
data: {
email,
role: "teacher",
display_name: nextName,
},
});
expect(blockedLogin.status(), await blockedLogin.text()).toBe(403);
test("shows and resolves user-submitted operation tickets", async ({ page }, testInfo) => {
await signInAsAdmin(page);
const slug = `${testInfo.project.name}.${testInfo.workerIndex}.${testInfo.retry}.${Date.now()}`;
const subject = `[E2E] 운영 티켓 ${slug}`;
let createdTicketId: string | null = null;
try {
const create = await page.request.post("/api/users/support-tickets", {
data: {
category: "session_review",
priority: "high",
subject,
body: "E2E 운영 티켓 처리 흐름 검증입니다. 실제 리뷰 생성 지연이 아닙니다.",
source_path: "/__e2e__/learn/session/test/review",
},
});
await expectResponseOk(create);
const created = (await create.json()) as { ticket_id: string };
createdTicketId = created.ticket_id;
const tickets = await openAdminTickets(page);
expect(tickets.source).toBe("database");
expect(tickets.durable).toBe(true);
expect(tickets.tickets.some((ticket) => ticket.ticket_id === created.ticket_id)).toBeTruthy();
const card = page.locator(".ad-ticket").filter({ hasText: subject });
await expect(card).toBeVisible();
await expect(card).toContainText("높음");
await expect(card).toContainText("미해결");
const patchPromise = page.waitForResponse(isAdminTicketPatch(created.ticket_id));
await card.getByRole("button", { name: "해결" }).click();
const patchResponse = await patchPromise;
await expectResponseOk(patchResponse);
const updated = (await patchResponse.json()) as AdminSupportTicket;
expect(updated.status).toBe("resolved");
await expect(card).toContainText("해결");
await expectNoHorizontalOverflow(page);
} finally {
if (createdTicketId) {
const cleanup = await page.request.patch(`/api/admin/tickets/${createdTicketId}`, {
data: {
status: "resolved",
resolution_note: "E2E cleanup",
},
});
if (!cleanup.ok()) {
console.warn(`E2E ticket cleanup failed: ${cleanup.status()} ${await cleanup.text()}`);
}
}
}
});
test("denies learner access to the admin API and UI", async ({ page }) => {
@ -452,6 +664,10 @@ test.describe("admin route", () => {
expect(denied.status(), await denied.text()).toBe(403);
const deniedUsers = await page.request.get("/api/admin/users");
expect(deniedUsers.status(), await deniedUsers.text()).toBe(403);
const deniedUptime = await page.request.get("/api/admin/uptime");
expect(deniedUptime.status(), await deniedUptime.text()).toBe(403);
const deniedTickets = await page.request.get("/api/admin/tickets");
expect(deniedTickets.status(), await deniedTickets.text()).toBe(403);
await page.goto("/admin");
@ -460,35 +676,37 @@ test.describe("admin route", () => {
});
test("keeps admin controls usable at a mobile viewport", async ({ page }) => {
test.setTimeout(60_000);
await page.setViewportSize({ width: 390, height: 844 });
await signInAsAdmin(page);
const users = await openAdminAndReadUsers(page);
await page.getByRole("tab", { name: "사용자 등록" }).click();
const layout = await page.evaluate(() => {
const workspace = document.querySelector<HTMLElement>(".ad-user-workspace");
const form = document.querySelector<HTMLElement>(".ad-user-create");
if (!workspace || !form) throw new Error("admin user workspace was not rendered");
if (!form) throw new Error("admin user create form was not rendered");
return {
workspaceColumns: window.getComputedStyle(workspace).gridTemplateColumns.split(" ").length,
formColumns: window.getComputedStyle(form).gridTemplateColumns.split(" ").length,
};
});
await expectNoHorizontalOverflow(page);
await expectCreateUserControlsFit(page, 390);
expect(layout.workspaceColumns).toBe(1);
expect(layout.formColumns).toBe(1);
if (users.users.length > 0) {
await page.getByRole("tab", { name: "사용자 목록" }).click();
await expectVisibleButtonsFit(page, ".ad-user__actions .vg-btn", "mobile admin user actions");
}
});
test("keeps the create-user form contained at tablet widths", async ({ page }) => {
test.setTimeout(60_000);
await signInAsAdmin(page);
for (const width of [861, 900, 1024]) {
await page.setViewportSize({ width, height: 900 });
await openAdminAndReadUsers(page);
await page.getByRole("tab", { name: "사용자 등록" }).click();
await expectNoHorizontalOverflow(page);
await expectCreateUserControlsFit(page, width);

View file

@ -112,20 +112,83 @@ test.describe("auth domain policy", () => {
await expect(error).toContainText("오류 코드: provider_error");
});
test("logs in locally with the server dev session and redirects to learner home", async ({
test("logs in locally, completes onboarding, and redirects to learner home", async ({
page,
}) => {
}, testInfo) => {
await page.goto("/login");
await expect(page.locator(".lg-dev")).toBeVisible();
const email = `onboarding.${testInfo.workerIndex}.${Date.now()}@hs.ac.kr`;
const login = await page.request.post("/api/auth/dev-login", {
data: {
email,
role: "learner",
display_name: "온보딩 미완료 학습자",
},
});
expect(login.ok(), await login.text()).toBeTruthy();
await page.goto("/");
await page.waitForURL(/\/onboarding(?:$|[/?#])/);
await expect(page.getByRole("heading", { name: "가입 정보를 입력합니다." })).toBeVisible();
await expect(page.locator(".vg-topbar")).toHaveCount(0);
await expect(page.locator(".vg-nav")).toHaveCount(0);
await expect(page.locator(".ob-card")).toHaveCount(0);
await expect(page.locator(".ob-docs")).toHaveCount(0);
await expect(page.getByText("온보딩 정보를 확인하고 있습니다.")).toHaveCount(0);
await expect(page.locator(".ob-legal")).toContainText("서비스 이용약관");
await expect(page.locator(".ob-legal")).toContainText("개인정보 처리방침");
for (const blockedPath of [
"/learn",
"/settings",
"/admin",
"/dev/avatar-preview",
"/login",
]) {
await page.goto(blockedPath);
await page.waitForURL(/\/onboarding(?:$|[/?#])/);
await expect(page.getByRole("heading", { name: "가입 정보를 입력합니다." })).toBeVisible();
await expect(page.locator(".vg-nav")).toHaveCount(0);
}
await page.getByLabel("닉네임").fill("비넷 학습자");
await page.getByLabel("자기소개").fill("상담 시뮬레이션에서 라포 형성을 집중 연습합니다.");
await page.getByLabel("아바타 이미지").setInputFiles({
name: "avatar.png",
mimeType: "image/png",
buffer: Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lTQvYwAAAABJRU5ErkJggg==",
"base64",
),
});
await expect(page.getByText("다른 이미지 선택")).toBeVisible();
await page.getByLabel("이름").fill("로컬 테스트 학습자");
await page.getByLabel("소속").fill("한신대학교");
await page.getByLabel("학과/부서").fill("상담심리학과");
await page.getByLabel("학년/직위").fill("3학년");
await page.getByLabel("연락처").fill("010-1234-5678");
await page.getByLabel("주소/수령지").fill("경기도 오산시 한신대학교");
await page.getByLabel("서비스 이용약관에 동의합니다.").check();
await page.getByLabel("개인정보 수집 및 이용에 동의합니다.").check();
await Promise.all([
page.waitForURL(/\/learn(?:$|[/?#])/),
page.getByRole("button", { name: "로컬 테스트 계정으로 계속" }).click(),
page.getByRole("button", { name: /가입 설정 완료/ }).click(),
]);
const me = await page.request.get("/api/auth/me");
expect(me.status(), await me.text()).toBe(200);
await expect(page.getByRole("heading", { name: "오늘의 회기를 준비합니다." })).toBeVisible();
const meBody = (await me.json()) as {
onboarding_completed_at?: number | null;
nickname?: string;
self_introduction?: string;
avatar_url?: string;
};
expect(meBody.onboarding_completed_at).toBeTruthy();
expect(meBody.nickname).toBe("비넷 학습자");
expect(meBody.self_introduction).toContain("라포 형성");
expect(meBody.avatar_url).toContain("/uploads/profile-avatars/");
await expect(page.getByRole("heading", { name: "오늘 이어갈 회기를 먼저 봅니다." })).toBeVisible();
});
test("keeps dev login closed for the public API origin", async ({ request }) => {

View file

@ -21,6 +21,7 @@ test.describe("avatar expression lab", () => {
display_name: "Avatar Lab Learner",
role: "learner",
cohort_ids: [],
onboarding_completed_at: Math.floor(Date.now() / 1000),
}),
}),
);

View file

@ -1,5 +1,19 @@
import { expect, test } from "@playwright/test";
const P1_PERSONA = {
code: "P1",
display_name: "서연(가명) · 고2 · 우울/자살사고",
difficulty: "hard",
theory_target: ["humanistic", "cbt"],
demographics: { age_band: "16-18", sex: "female", grade: "고2", status: "재학" },
presenting_summary: "우울감과 자살사고 위험",
voice_preset: null,
source: "database",
degraded: false,
expectedExpression: "sad",
expectedModel: "vignette-p1-live2d",
} as const;
const PERSONAS = [
{
code: "P4",
@ -55,6 +69,41 @@ const PERSONAS = [
},
] as const;
async function setLearnerUtterance(page: import("@playwright/test").Page, text: string) {
const textbox = page.getByLabel("학습자 발화 입력");
await expect(textbox).toBeVisible();
await expect(textbox).toBeEnabled();
await textbox.fill(text);
await expect(textbox).toHaveValue(text);
await expect(page.getByRole("button", { name: "보내기" })).toBeEnabled();
}
async function mockSessionDetail(
page: import("@playwright/test").Page,
sessionId: string,
personaCode = "P4",
) {
await page.route(`**/api/sessions/${sessionId}`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
session_id: sessionId,
case_id: "case-avatar-expression",
persona_code: personaCode,
session_no: 1,
status: "active",
stage: "라포",
effective_openness: 0.2,
started_at: new Date().toISOString(),
ended_at: null,
review_ready: false,
turns: [],
}),
});
});
}
test.describe("persona avatar expression rig", () => {
test.beforeEach(async ({ page }) => {
await page.route("**/api/auth/me", (route) =>
@ -67,6 +116,8 @@ test.describe("persona avatar expression rig", () => {
display_name: "Avatar Expression Learner",
role: "learner",
cohort_ids: [],
consent_at: Math.floor(Date.now() / 1000),
onboarding_completed_at: Math.floor(Date.now() / 1000),
}),
}),
);
@ -76,7 +127,7 @@ test.describe("persona avatar expression rig", () => {
status: 200,
contentType: "application/json",
body: JSON.stringify(
PERSONAS.map(
[P1_PERSONA, ...PERSONAS].map(
({
expectedExpression: _expectedExpression,
expectedModel: _expectedModel,
@ -106,11 +157,17 @@ test.describe("persona avatar expression rig", () => {
live2dMotion: el.getAttribute("data-live2d-motion"),
live2dMotionFile: el.getAttribute("data-live2d-motion-file"),
live2dExpressionCount: Number(el.getAttribute("data-live2d-expression-count")),
renderMode: el.getAttribute("data-render-mode"),
rasterArtSet: el.querySelector(".vg-raster")?.getAttribute("data-raster-art-set"),
rasterMode: el.querySelector(".vg-raster")?.getAttribute("data-raster-mode"),
rasterLayerCount: el.querySelectorAll(".vg-raster__layer").length,
rasterBox: el.querySelector<HTMLElement>(".vg-raster")?.getBoundingClientRect().toJSON(),
primitiveCount: el.querySelectorAll("svg path, svg ellipse, svg circle, svg line, svg rect")
.length,
neckBox: el.querySelector<SVGGraphicsElement>('[data-avatar-neck="true"]')?.getBoundingClientRect().toJSON(),
svgBox: el.querySelector("svg")?.getBoundingClientRect().toJSON(),
}));
const expectedArtSet = `${persona.code.toLowerCase()}-live2d-generated`;
expect(metrics.expressionCount, `${persona.code} expression count`).toBeGreaterThanOrEqual(20);
expect(metrics.live2dSchema, `${persona.code} Live2D schema`).toBe("vignette.live2d.v1");
@ -123,15 +180,21 @@ test.describe("persona avatar expression rig", () => {
`expressions/${persona.expectedExpression}.exp3.json`,
);
expect(metrics.live2dExpressionCount, `${persona.code} Live2D expressions`).toBeGreaterThanOrEqual(20);
expect(metrics.primitiveCount, `${persona.code} avatar SVG primitives`).toBeGreaterThanOrEqual(12);
expect(metrics.neckBox?.width, `${persona.code} visible neck width`).toBeGreaterThan(12);
expect(metrics.neckBox?.height, `${persona.code} visible neck height`).toBeGreaterThan(24);
expect(metrics.svgBox?.width, `${persona.code} avatar SVG width`).toBeGreaterThan(0);
expect(metrics.svgBox?.height, `${persona.code} avatar SVG height`).toBeGreaterThan(0);
expect(metrics.renderMode, `${persona.code} render mode`).toBe("raster");
expect(metrics.rasterArtSet, `${persona.code} generated art set`).toBe(expectedArtSet);
expect(metrics.rasterMode, `${persona.code} raster rig mode`).toBe("psb-detailed");
expect(metrics.rasterLayerCount, `${persona.code} raster layers`).toBeGreaterThanOrEqual(20);
expect(metrics.rasterBox?.width, `${persona.code} raster width`).toBeGreaterThan(0);
expect(metrics.rasterBox?.height, `${persona.code} raster height`).toBeGreaterThan(0);
expect(metrics.primitiveCount, `${persona.code} fallback SVG primitives`).toBe(0);
expect(metrics.neckBox, `${persona.code} SVG neck fallback`).toBeUndefined();
expect(metrics.svgBox, `${persona.code} SVG fallback`).toBeUndefined();
}
});
test("keeps the active session avatar expression wired after session start", async ({ page }) => {
const sessionId = "11111111-1111-4111-8111-111111111111";
await mockSessionDetail(page, sessionId);
await page.route("**/api/sessions", async (route) => {
if (route.request().method() !== "POST") {
await route.fallback();
@ -154,9 +217,12 @@ test.describe("persona avatar expression rig", () => {
await page.goto("/learn/session/P4");
await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page.locator(".sx-page--active")).toBeVisible();
const activeAvatar = page.locator(".sx-page--active .vg-avatar").first();
await expect(activeAvatar).toBeVisible();
await expect(activeAvatar).toHaveAttribute("data-render-mode", "raster");
await expect(activeAvatar.locator('.vg-raster[data-raster-art-set="p4-live2d-generated"]')).toBeVisible();
await expect(activeAvatar).toHaveAttribute("data-expression-count", "28");
await expect(activeAvatar).toHaveAttribute("data-live2d-expression-count", "28");
await expect(activeAvatar).toHaveAttribute("data-live2d-model", "vignette-p4-live2d");
@ -168,6 +234,8 @@ test.describe("persona avatar expression rig", () => {
});
test("animates expression transitions after session openness changes", async ({ page }) => {
const sessionId = "22222222-2222-4222-8222-222222222222";
await mockSessionDetail(page, sessionId);
await page.route("**/api/sessions", async (route) => {
if (route.request().method() !== "POST") {
await route.fallback();
@ -177,7 +245,7 @@ test.describe("persona avatar expression rig", () => {
status: 200,
contentType: "application/json",
body: JSON.stringify({
session_id: "22222222-2222-4222-8222-222222222222",
session_id: sessionId,
case_id: "case-avatar-transition",
session_no: 1,
stage: "라포",
@ -205,16 +273,40 @@ test.describe("persona avatar expression rig", () => {
await page.goto("/learn/session/P4");
await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page.locator(".sx-page--active")).toBeVisible();
const activeAvatar = page.locator(".sx-page--active .vg-avatar").first();
await expect(activeAvatar).toHaveAttribute("data-render-mode", "raster");
await expect(activeAvatar.locator('.vg-raster[data-raster-art-set="p4-live2d-generated"]')).toBeVisible();
await expect(activeAvatar).toHaveAttribute("data-live2d-motion", "anxious");
await page.getByLabel("학습자 발화 입력").fill("조금 안정된 것 같아요.");
await setLearnerUtterance(page, "조금 안정된 것 같아요.");
await page.getByRole("button", { name: "보내기" }).click();
await expect(activeAvatar).toHaveAttribute("data-live2d-motion", "warm");
await expect(activeAvatar).toHaveAttribute("data-live2d-motion-file", "expressions/warm.exp3.json");
await expect(activeAvatar).toHaveAttribute("data-live2d-fade-in-ms", "260");
await expect(activeAvatar).toHaveAttribute("data-live2d-transition-progress", "1.00");
await expect
.poll(
async () =>
Number((await activeAvatar.getAttribute("data-live2d-transition-progress")) ?? "0"),
{ timeout: 15_000 },
)
.toBeGreaterThanOrEqual(0.99);
await expect(page.locator(".sx-stage__now")).toContainText("온화함");
});
test("uses the PSD v2 crying raster rig for P1 Seoyeon", async ({ page }) => {
await page.goto("/learn/session/P1");
const avatar = page.locator('.vg-avatar[data-persona-code="P1"]').first();
await expect(avatar).toBeVisible();
await expect(avatar).toHaveAttribute("data-render-mode", "raster");
await expect(avatar).toHaveAttribute("data-affect", "sad");
const raster = avatar.locator('.vg-raster[data-raster-art-set="seoyeon-live2d-psd-v2"]');
await expect(raster).toBeVisible();
await expect(raster).toHaveAttribute("data-raster-variant", "sad");
await expect(raster.locator('.vg-raster__layer--tear[src$="/tear-left.png"]')).toHaveCount(1);
await expect(raster.locator('.vg-raster__layer--tear[src$="/tear-right.png"]')).toHaveCount(1);
});
});

View file

@ -1,7 +1,9 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { expect, test, type Page } from "@playwright/test";
import { FILLED_REVIEW_SESSION_ID, routeFilledSessionReview } from "./session-review-fixture";
import {
completeOnboarding,
expectNoHorizontalOverflow,
fetchAvailablePersona,
signInAsLearner,
@ -203,8 +205,13 @@ test.describe("layout visual gate @single-run", () => {
display_name: "이름이 아주 길게 표시되는 학습자 케이스 검증용 계정",
},
});
await page.request.post("/api/auth/consent", {
data: { accepted: true },
await completeOnboarding(page, {
legal_name: "이름이 아주 길게 표시되는 학습자 케이스 검증용 계정",
affiliation: "한신대학교",
department: "상담심리학과",
grade_level: "4학년",
phone: "010-3333-3333",
contact_address: "경기도 오산시 한신대학교",
});
// Seed dense history: one active + two ended sessions.
const persona = await fetchAvailablePersona(page);
@ -249,15 +256,11 @@ test.describe("layout visual gate @single-run", () => {
test("session review stays contained across all widths", async ({ page }) => {
await signInAsLearner(page);
const persona = await fetchAvailablePersona(page, 1);
const start = await page.request.post("/api/sessions", {
data: { persona_code: persona.code, theory_mode: "humanistic" },
});
const session = (await start.json()) as { session_id: string };
await page.request.post(`/api/sessions/${session.session_id}/end`);
await page.goto(`/learn/session/${session.session_id}/review`);
await routeFilledSessionReview(page);
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
await gateScreen(page, "session-review", async () => {
await expect(page.locator(".sr-overview")).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("사례개념화 워크시트")).toBeVisible();
});
});
@ -271,6 +274,15 @@ test.describe("layout visual gate @single-run", () => {
});
});
test("persona studio stays contained across all widths", async ({ page }) => {
await signInAsTeacher(page);
await page.goto("/teach/personas");
await gateScreen(page, "persona-studio", async () => {
await expect(page.locator(".ps-layout")).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("내담자 설계·검수 작업면")).toBeVisible();
});
});
test("admin console stays contained across all widths", async ({ page }) => {
await page.request.post("/api/auth/dev-login", {
data: { email: "admin@twentyoz.kr", role: "admin", display_name: "E2E Admin" },

View file

@ -4,6 +4,7 @@ import {
expectNoHorizontalOverflow,
fetchAvailablePersona,
fetchAvailablePersonas,
completeOnboarding,
signInAsLearner,
useRealApi,
} from "./support";
@ -21,10 +22,14 @@ async function signInAsLearnerEmail(
},
});
expect(res.ok(), await res.text()).toBeTruthy();
const consent = await page.request.post("/api/auth/consent", {
data: { accepted: true },
await completeOnboarding(page, {
legal_name: displayName,
affiliation: "한신대학교",
department: "상담심리학과",
grade_level: "3학년",
phone: "010-2222-2222",
contact_address: "경기도 오산시 한신대학교",
});
expect(consent.ok(), await consent.text()).toBeTruthy();
}
async function createPracticeSession(page: import("@playwright/test").Page, personaCode?: string) {
@ -98,7 +103,7 @@ test.describe("learner app shell and session launcher", () => {
});
test("redirects an unauthenticated learner route to login", async ({ page }) => {
await page.goto("/learn");
await page.goto("/learn/practice");
await expect(page).toHaveURL(/\/login$/);
await expect(page.getByRole("button", { name: /학교 Google 계정으로 계속/ })).toBeVisible();
@ -107,7 +112,7 @@ test.describe("learner app shell and session launcher", () => {
test("renders API personas without legacy session rows", async ({ page }) => {
await signInAsLearnerEmail(page, `learner.catalog.${Date.now()}@hs.ac.kr`, "Catalog Learner");
await page.goto("/learn");
await page.goto("/learn/practice");
const personas = await fetchAvailablePersonas(page);
await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 });
@ -121,8 +126,8 @@ test.describe("learner app shell and session launcher", () => {
await expect(page.getByText(/12회/)).toHaveCount(0);
await expect(page.getByText(/최근 연습/)).toHaveCount(0);
await expect(page.locator(".vg-nav")).toHaveCount(0);
await expect(page.locator(".vg-main")).toHaveClass(/(^|\s)vg-main--bleed(\s|$)/);
await expect(page.locator(".vg-nav")).toBeVisible();
await expect(page.locator(".vg-main")).not.toHaveClass(/(^|\s)vg-main--bleed(\s|$)/);
await expect(page.getByText("음성")).toBeVisible();
await page.getByText("음성").scrollIntoViewIfNeeded();
await expect(page.getByText("음성")).toBeInViewport();
@ -136,7 +141,7 @@ test.describe("learner app shell and session launcher", () => {
test("routes the launcher CTA to the selected database persona", async ({ page }) => {
await signInAsLearner(page);
await page.goto("/learn");
await page.goto("/learn/practice");
const persona = await fetchAvailablePersona(page, 1);
const option = page.getByRole("option", { name: new RegExp(persona.code) });
@ -171,18 +176,45 @@ test.describe("learner app shell and session launcher", () => {
await page.goto("/learn");
await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole("heading", { name: "오늘 이어갈 회기를 먼저 봅니다." })).toBeVisible();
await expect(page.getByLabel("학습 현황")).toContainText("진행 회기");
await expect(page.getByLabel("학습 현황")).toContainText("2회");
await expect(page.getByLabel("학습 현황")).toContainText("리뷰 대기");
await expect(page.getByLabel("학습 현황")).toContainText("최근 평가");
await expect(page.getByLabel("최근 피드백")).toContainText("평가 피드백은 회기 종료 후 표시됩니다.");
await expect(learnerActivityHeading(page)).toBeVisible({ timeout: 15_000 });
await page.goto("/learn/history");
await expect(
page.getByRole("heading", { name: "회기 기록을 찾고 정리합니다." }),
).toBeVisible({ timeout: 15_000 });
await expect(page.locator(".lh-history-main")).toBeVisible();
await expect(page.getByLabel("기록 검색")).toBeVisible();
await expect(page.locator(".lh-history-overview")).toContainText("전체");
await expect(page.locator(".lh-history-overview")).toContainText("진행 중");
await expect(page.locator(".lh-history-overview")).toContainText("보관됨");
await expect(page.getByRole("button", { name: "이어하기" })).toBeVisible();
await expect(page.getByRole("button", { name: "기록" })).toBeVisible();
await expect(page.getByRole("button", { name: "기록", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "다시 연습" })).toHaveCount(2);
await expectReachableLearnerHomeLayout(page);
await page.locator(".lh-activity").scrollIntoViewIfNeeded();
await expect(page.locator(".lh-archive-note")).toContainText("읽기 전용");
await expectNoHorizontalOverflow(page);
await page.locator(".lh-history-main").scrollIntoViewIfNeeded();
await expect(page.getByRole("button", { name: "이어하기" })).toBeInViewport();
await expect(page.getByRole("button", { name: "기록" })).toBeInViewport();
await expect(page.getByRole("button", { name: "기록", exact: true })).toBeInViewport();
await expect(page.getByRole("button", { name: "다시 연습" }).first()).toBeInViewport();
await expect(page.locator(".lh-activity__stats")).toContainText("누적 회기");
await expect(page.locator(".lh-activity__stats")).toContainText("2");
await expect(page.locator(".lh-history-main")).toContainText("2개");
await expect(page.locator(".lh-persona-progress")).toContainText(persona.code);
await expect(page.locator(".lh-persona-progress")).toContainText("2회");
await page.getByRole("button", { name: "보관", exact: true }).click();
await expect(page.getByText("회기를 보관했습니다.")).toBeVisible();
await page.locator(".lh-history-filter").getByRole("button", { name: "보관됨" }).click();
await expect(page.locator(".lh-history-main")).toContainText("1개");
await expect(page.getByRole("button", { name: "복원" })).toBeVisible();
await page.getByRole("button", { name: "복원" }).click();
await expect(page.getByText("보관을 해제했습니다.")).toBeVisible();
await expect(page.locator(".lh-history-main")).toContainText("0개");
await page.locator(".lh-history-filter").getByRole("button", { name: "전체" }).click();
await expect(page.locator(".lh-history-main")).toContainText("2개");
await page.getByRole("button", { name: "이어하기" }).click();

View file

@ -1,5 +1,5 @@
import { expect, test, type APIResponse, type Page } from "@playwright/test";
import { expectNoHorizontalOverflow } from "./support";
import { completeOnboarding, expectNoHorizontalOverflow } from "./support";
async function expectResponseOk(response: APIResponse) {
if (!response.ok()) {
@ -17,6 +17,16 @@ async function signIn(
data: { email, role, display_name: displayName },
});
await expectResponseOk(res);
await completeOnboarding(page, {
legal_name: displayName,
affiliation: "한신대학교",
department: role === "admin" ? "운영팀" : "상담심리학과",
grade_level: role === "admin" ? "관리자" : "3학년",
phone: "010-7777-7777",
contact_address: "경기도 오산시 한신대학교",
nickname: displayName,
self_introduction: `${displayName} readiness E2E 사용자입니다.`,
});
}
test.describe("production readiness gates", () => {
@ -46,11 +56,14 @@ test.describe("production readiness gates", () => {
await page.goto("/learn");
await expect(page.getByText("지금까지 12회 연습했어요")).toHaveCount(0);
await expect(page.getByText("최근 8회")).toHaveCount(0);
await expect(page.getByText("기존 회기")).toBeVisible();
await expect(page.locator(".lh-activity__stats")).toContainText("누적 회기");
await expect(page.locator(".lh-activity__stats")).toContainText("0");
await expect(page.getByText("저장된 기존 회기가 없습니다.")).toBeVisible();
await expect(page.getByRole("button", { name: "새 회기 시작" })).toBeInViewport();
await expect(page.getByRole("heading", { name: "오늘 이어갈 회기를 먼저 봅니다." })).toBeVisible();
const statusRegion = page.getByRole("region", { name: "학습 현황" });
await expect(statusRegion.getByRole("button", { name: /진행 회기 0회/ })).toBeVisible();
await expect(statusRegion.getByRole("button", { name: /리뷰 대기 0건/ })).toBeVisible();
await expect(page.getByText("최근 기록이 없습니다.")).toBeVisible();
await expect(
page.locator(".lh-head").getByRole("button", { name: "학습 대상 선택" }),
).toBeInViewport();
await expectNoHorizontalOverflow(page);
});

View file

@ -10,6 +10,7 @@ async function expectSessionPageHeightToMatchViewport(page: Page) {
const metrics = await page.evaluate(() => {
const sessionPage = document.querySelector<HTMLElement>(".sx-page--active");
const topbar = document.querySelector<HTMLElement>(".vg-topbar");
const sessionbar = document.querySelector<HTMLElement>(".sx-sessionbar");
if (!sessionPage) {
return null;
@ -23,11 +24,13 @@ async function expectSessionPageHeightToMatchViewport(page: Page) {
expectedHeight: Math.round(expectedHeight),
delta: Math.abs(pageHeight - expectedHeight),
hasTopbar: Boolean(topbar),
hasSessionbar: Boolean(sessionbar),
};
});
expect(metrics, "Expected active session page to be present").not.toBeNull();
expect(metrics!.hasTopbar, "Active session should hide the global topbar").toBe(false);
expect(metrics!.hasSessionbar, "Active session should show the in-session navigation bar").toBe(true);
expect(
metrics!.delta,
`Expected .sx-page height ${metrics!.pageHeight}px to match viewport ${metrics!.expectedHeight}px`,
@ -76,6 +79,7 @@ async function expectSessionControlsInsideViewport(page: Page) {
".sx-transcript",
".sx-transcript__scroll",
".sx-compose",
".sx-sessionbar",
".sx-controlbar",
];
const result = await page.evaluate((items) => {
@ -421,7 +425,10 @@ test.describe("learner session full-screen layout", () => {
await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
await expect(page).toHaveURL(/\/learn\/session\/[0-9a-f-]+$/i);
await expect(page.locator(".sx-sessionbar")).toBeVisible();
await expect(page.getByRole("button", { name: "기록으로" })).toBeVisible();
await expect(page.locator(".sx-grid")).toBeVisible();
await expect(page.locator(".vg-topbar")).toHaveCount(0);
await expect(page.locator(".vg-nav")).toHaveCount(0);
@ -437,6 +444,12 @@ test.describe("learner session full-screen layout", () => {
await expectNoVisibleSessionPanelOverlap(page);
await expectMainControlsUnclipped(page);
await expectRightPanelDoesNotIntersectSessionCore(page);
await page.reload();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
await expect(page.locator(".sx-sessionbar")).toBeVisible();
await expect(page.getByRole("button", { name: "회기 시작" })).toHaveCount(0);
await expect(page).toHaveURL(/\/learn\/session\/[0-9a-f-]+$/i);
});
test("keeps critical session controls visible across dense viewport sizes", async ({ page }) => {
@ -461,13 +474,13 @@ test.describe("learner session full-screen layout", () => {
await page.setViewportSize(viewports[0]);
await page.goto(`/learn/session/${persona.code}`);
await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
for (const viewport of viewports) {
await page.setViewportSize(viewport);
await page.evaluate(() => new Promise(requestAnimationFrame));
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
await expectNoDocumentOverflow(page);
await expectNoHorizontalOverflow(page);
await expectNoLocalStageDemoControl(page);
@ -498,7 +511,7 @@ test.describe("learner session full-screen layout", () => {
const persona = await fetchAvailablePersona(page, 1);
await page.goto(`/learn/session/${persona.code}`);
await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
await page.route("**/api/sessions/*/stream", async (route) => {
await route.fulfill({
@ -525,7 +538,7 @@ test.describe("learner session full-screen layout", () => {
const persona = await fetchAvailablePersona(page, 1);
await page.goto(`/learn/session/${persona.code}`);
await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
await page.route("**/api/sessions/*/stream", async (route) => {
await route.fulfill({

View file

@ -1,6 +1,6 @@
import { expect, test, type Page } from "@playwright/test";
const sessionId = "mvp-session-001";
const sessionId = "33333333-3333-4333-8333-333333333333";
const learnerText = "요즘 많이 힘들었겠어요. 어떤 마음이 가장 크게 남아 있나요?";
const clientReply = "괜찮아요. 천천히 말해볼게요.";
@ -15,6 +15,11 @@ async function routeMvpApi(page: Page) {
display_name: "MVP Learner",
role: "learner",
cohort_ids: [],
consent_at: Math.floor(Date.now() / 1000),
onboarding_completed_at: Math.floor(Date.now() / 1000),
nickname: "MVP Learner",
self_introduction: "MVP 회기 흐름 검증용 학습자입니다.",
avatar_url: "",
}),
});
});
@ -59,6 +64,28 @@ async function routeMvpApi(page: Page) {
});
});
await page.route(`**/api/sessions/${sessionId}`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
session_id: sessionId,
case_id: "mvp-case-001",
persona_code: "P1",
persona_name: "민서",
session_no: 1,
status: "active",
stage: "라포",
theory_mode: "humanistic",
effective_openness: 0.21,
started_at: new Date().toISOString(),
ended_at: null,
review_ready: false,
turns: [],
}),
});
});
await page.route(`**/api/sessions/${sessionId}/stream`, async (route) => {
await route.fulfill({
status: 200,
@ -176,7 +203,8 @@ test.describe("P1 MVP core loop", () => {
await expect(page.locator(".sx-utt").filter({ hasText: learnerText })).toBeVisible();
await expect(page.locator(".sx-utt").filter({ hasText: clientReply })).toBeVisible();
await page.getByRole("button", { name: /밀어서 회기 종료/ }).press("Enter");
await page.getByRole("button", { name: "회기 종료" }).click();
await page.getByRole("button", { name: "종료하고 리뷰 보기" }).click();
await expect(page).toHaveURL(new RegExp(`/learn/session/${sessionId}/review$`));
await expect(page.getByText("내담자가 남긴 것")).toBeVisible();

View file

@ -0,0 +1,241 @@
import type { Page } from "@playwright/test";
import type { SessionReviewResponse } from "../src/lib/api";
export const FILLED_REVIEW_SESSION_ID = "filled-review-visual";
export function filledReviewResponse(
sessionId: string = FILLED_REVIEW_SESSION_ID,
): SessionReviewResponse {
return {
session_id: sessionId,
client: {
name: "서연",
initial: "서",
persona: "P1 · 고난도 · 인본주의",
},
date: "2026-06-27",
durationLabel: "32분 14초",
durationSeconds: 1934,
reachedPhase: "정리",
sessionSignal: "종료됨",
supervisorState: "평가 완료",
supervisorName: "AI",
summary:
"초반 방어가 강했지만 학습자가 감정 반영과 침묵 허용을 유지하면서 <hl>관계 신호가 안정적으로 회복된 회기</hl>입니다. 다음 회기에서는 비교 경험의 구체 장면을 더 좁혀 다루는 개입이 필요합니다.",
phases: [
{ key: "rapport", label: "라포", weight: 0.22 },
{ key: "explore", label: "탐색", weight: 0.34 },
{ key: "intervene", label: "개입", weight: 0.26 },
{ key: "closing", label: "정리", weight: 0.18 },
],
phaseAxis: ["0:00", "7:30", "18:40", "27:10", "32:14"],
valenceAxis: ["0:00", "8:00", "16:00", "24:00", "32:14"],
clientValence: [
{ t: 0, v: -0.48 },
{ t: 0.18, v: -0.42 },
{ t: 0.38, v: -0.22 },
{ t: 0.62, v: 0.02 },
{ t: 0.82, v: 0.18 },
{ t: 1, v: 0.12 },
],
counselorBaseline: [
{ t: 0, v: -0.1 },
{ t: 0.2, v: -0.05 },
{ t: 0.42, v: 0.03 },
{ t: 0.64, v: 0.08 },
{ t: 0.82, v: 0.1 },
{ t: 1, v: 0.1 },
],
turns: [
{
id: "t1",
ts: "03:12",
speaker: "client",
who: "서연",
text: "엄마가 다른 친구들이랑 비교할 때마다 아무 말도 안 하고 그냥 넘겼어요. 근데 솔직히 계속 제가 이상한 사람 같았어요.",
techniques: [],
nonverbal: [{ kind: "silence", label: "침묵", detail: "4.8초" }],
note: null,
},
{
id: "t2",
ts: "05:04",
speaker: "learner",
who: "나",
text: "그 말을 들을 때마다 서연 씨가 혼자서 버티고 있었다는 느낌이 들어요. 비교가 반복될수록 스스로를 의심하게 됐던 걸까요?",
techniques: [
{ kind: "reflect", label: "감정 반영" },
{ kind: "explore", label: "개방 질문" },
],
nonverbal: [{ kind: "pace", label: "말 속도", detail: "안정" }],
note: {
author: "AI 슈퍼바이저",
tone: "ai",
title: "좋은 연결",
body: "내담자의 경험을 평가하지 않고 정서와 의미를 함께 되짚었습니다.",
quote: "혼자서 버티고 있었다는 느낌",
},
},
{
id: "t3",
ts: "12:41",
speaker: "client",
who: "서연",
text: "네. 그때는 그냥 화가 난다고 말하면 제가 더 나쁜 사람이 될 것 같아서 아무 말도 못 했어요.",
techniques: [],
nonverbal: [{ kind: "audio", label: "음성", detail: "떨림" }],
note: null,
},
{
id: "t4",
ts: "16:05",
speaker: "learner",
who: "나",
text: "화가 났다는 마음 자체가 잘못된 건 아니에요. 그 감정은 서연 씨가 존중받고 싶었다는 신호일 수도 있어요.",
techniques: [
{ kind: "empathy", label: "공감" },
{ kind: "reflect", label: "의미 반영" },
],
nonverbal: [],
note: {
author: "AI 슈퍼바이저",
tone: "ai",
title: "정서 정상화",
body: "방어를 낮추는 데 유효했습니다. 다만 다음 질문에서 장면을 더 구체화하면 평가 근거가 선명해집니다.",
quote: "감정은 존중받고 싶었다는 신호",
},
},
{
id: "t5",
ts: "24:18",
speaker: "learner",
who: "나",
text: "다음에 비슷한 말을 듣는다면 바로 반박하기보다, 먼저 어떤 마음이 올라오는지 알아차리는 연습부터 해볼 수 있을까요?",
techniques: [
{ kind: "explore", label: "협력적 제안" },
{ kind: "confront", label: "부드러운 도전" },
],
nonverbal: [],
note: {
author: "AI 슈퍼바이저",
tone: "warn",
title: "속도 점검",
body: "개입 방향은 적절하지만 내담자의 준비도를 한 번 더 확인하면 좋습니다.",
quote: "연습부터 해볼 수 있을까요",
},
},
{
id: "t6",
ts: "29:52",
speaker: "client",
who: "서연",
text: "바로 괜찮아질지는 모르겠는데, 적어도 제가 화난 게 이상한 건 아니라는 말은 좀 기억에 남아요.",
techniques: [],
nonverbal: [{ kind: "pace", label: "말 속도", detail: "느려짐" }],
note: null,
},
],
rubric: [
{ name: "감정 반영", cluster: "정서 확인", ratio: 0.82, quality: "good", freq: "6회" },
{ name: "개방 질문", cluster: "탐색 촉진", ratio: 0.64, quality: "good", freq: "4회" },
{ name: "정서 정상화", cluster: "방어 완화", ratio: 0.57, quality: "good", freq: "3회" },
{ name: "개입 속도", cluster: "준비도 확인", ratio: 0.35, quality: "watch", freq: "1회" },
],
goodMoments: [
{
title: "방어를 낮춘 감정 반영",
body: "비교 경험을 바로 해석하지 않고 감정과 의미를 먼저 되짚었습니다.",
jumpTo: "t2",
},
{
title: "정서 정상화",
body: "화가 잘못이 아니라 욕구 신호일 수 있다고 재구성했습니다.",
jumpTo: "t4",
},
],
growthPoints: [
{
title: "개입 전 준비도 확인",
body: "행동 제안 전에 내담자가 지금 변화를 다룰 여지가 있는지 확인해야 합니다.",
jumpTo: "t5",
},
{
title: "비교 장면 좁히기",
body: "엄마의 말, 그때 떠오른 생각, 몸 반응을 분리하면 사례개념화 근거가 선명해집니다.",
jumpTo: "t3",
},
],
caseWorksheet: {
status: "draft_from_transcript",
generatedBy: "rule-based transcript extractor",
savedAt: null,
sections: [
{
key: "trigger",
title: "촉발 장면",
items: [
{
key: "comparison",
label: "반복 비교",
value: "엄마가 친구들과 비교하는 말을 반복할 때 자기비난과 분노가 함께 올라옴.",
evidence: [
{
turnId: "t1",
speaker: "client",
quote: "다른 친구들이랑 비교할 때마다",
},
],
confidence: "medium",
emptyReason: null,
},
],
},
{
key: "emotion",
title: "핵심 정서",
items: [
{
key: "anger_shame",
label: "분노와 수치심",
value: "화가 나지만 표현하면 나쁜 사람이 될 것 같아 억제함.",
evidence: [
{
turnId: "t3",
speaker: "client",
quote: "제가 더 나쁜 사람이 될 것 같아서",
},
],
confidence: "medium",
emptyReason: null,
},
],
},
],
limitations: [
"가족관계 정보는 이번 회기 발화에 한정됩니다.",
"위험도 평가는 별도 안전평가 결과와 함께 확인해야 합니다.",
],
},
nextLine:
"그 말을 들은 바로 그 순간, 몸에서는 어떤 반응이 먼저 올라왔나요?",
clientFeedback:
"처음에는 말하기 싫었는데, 화가 이상한 게 아니라는 말은 조금 안심됐어요.",
audioUrl: "/mock/reviews/filled-review-visual.mp3",
pdfExportUrl: "/mock/reviews/filled-review-visual.pdf",
degraded: false,
reviewReady: true,
};
}
export async function routeFilledSessionReview(
page: Page,
sessionId: string = FILLED_REVIEW_SESSION_ID,
) {
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(filledReviewResponse(sessionId)),
});
});
}

View file

@ -1,4 +1,5 @@
import { expect, test, type Page, type Response } from "@playwright/test";
import { FILLED_REVIEW_SESSION_ID, routeFilledSessionReview } from "./session-review-fixture";
import { expectNoHorizontalOverflow, fetchAvailablePersona, signInAsLearner } from "./support";
interface SessionStartResponse {
@ -56,10 +57,28 @@ async function expectReviewRegionsReachable(page: Page) {
expect(grid.areas).toContain("overview");
expect(grid.areas).toContain("feedback");
expect(grid.areas).toContain("transcript");
expect(grid.areas).not.toContain("teacher");
await expect(page.locator(".sr-overview")).toBeInViewport();
await page.locator(".sr-card--transcript").scrollIntoViewIfNeeded();
await expect(page.locator(".sr-card--transcript")).toBeInViewport();
const railGeometry = await page.locator(".sr-cols").evaluate((gridEl) => {
const overview = gridEl.querySelector(".sr-overview")?.getBoundingClientRect();
const transcript = gridEl.querySelector(".sr-card--transcript")?.getBoundingClientRect();
const columns = window
.getComputedStyle(gridEl)
.gridTemplateColumns.split(" ")
.filter(Boolean).length;
return {
columns,
overviewRight: overview ? Math.ceil(overview.right) : 0,
transcriptLeft: transcript ? Math.floor(transcript.left) : 0,
};
});
if (railGeometry.columns >= 3) {
expect(railGeometry.overviewRight).toBeLessThanOrEqual(railGeometry.transcriptLeft);
}
}
test.describe("session review", () => {
@ -101,4 +120,38 @@ test.describe("session review", () => {
);
await expectNoHorizontalOverflow(page);
});
test("renders filled review evidence state without clipping", async ({ page }) => {
await signInAsLearner(page);
await routeFilledSessionReview(page);
const reviewResponsePromise = page.waitForResponse(isReviewResponse(FILLED_REVIEW_SESSION_ID));
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
const reviewResponse = await reviewResponsePromise;
await expectResponseOk(reviewResponse);
const review = await reviewResponse.json();
expect(review.session_id).toBe(FILLED_REVIEW_SESSION_ID);
expect(review.reviewReady).toBe(true);
expect(review.turns).toHaveLength(6);
await expect(page).toHaveURL(new RegExp(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review$`));
await expect(page.getByText("관계 신호가 안정적으로 회복된 회기")).toBeVisible();
await expect(page.getByText("감정 밸런스 타임라인")).toBeVisible();
await expect(page.getByText("회기 흐름")).toBeVisible();
await expect(page.getByText("세션 트랜스크립트")).toBeVisible();
await expect(page.getByText("기법 사용 분포")).toBeVisible();
await expect(page.getByText("사례개념화 워크시트")).toBeVisible();
await expect(page.getByText("감정 반영").first()).toBeVisible();
await expect(page.getByText("AI 슈퍼바이저").first()).toBeVisible();
await expect(page.getByText("다음에 시도할 문장")).toBeVisible();
await expect(page.getByText("방어를 낮춘 감정 반영")).toBeVisible();
await expect(page.getByText("개입 전 준비도 확인")).toBeVisible();
await expect(page.locator(".sr-chart__svg")).toBeVisible();
await expect(page.locator(".sr-ws-input").first()).toBeVisible();
await expect(page.getByRole("button", { name: "오디오 다시 듣기" })).toBeEnabled();
await expect(page.getByRole("button", { name: "PDF 내보내기" })).toBeEnabled();
await expectReviewRegionsReachable(page);
await expectNoHorizontalOverflow(page);
});
});

View file

@ -1,5 +1,10 @@
import { expect, test, type APIResponse, type Page, type Response, type TestInfo } from "@playwright/test";
import { expectNoHorizontalOverflow, useRealApi, withGlobalEngineConfigLock } from "./support";
import {
completeOnboarding,
expectNoHorizontalOverflow,
useRealApi,
withGlobalEngineConfigLock,
} from "./support";
type Role = "learner" | "admin";
@ -379,6 +384,16 @@ async function signInAs(
},
});
await expectResponseOk(res);
await completeOnboarding(page, {
legal_name: displayName,
affiliation: "한신대학교",
department: role === "admin" ? "운영팀" : "상담심리학과",
grade_level: role === "admin" ? "관리자" : "3학년",
phone: role === "admin" ? "010-2222-2222" : "010-3333-3333",
contact_address: "경기도 오산시 한신대학교",
nickname: displayName,
self_introduction: `${displayName} 설정 E2E 사용자입니다.`,
});
return email;
}

View file

@ -69,10 +69,14 @@ export async function signInAsLearner(page: Page) {
},
});
expect(res.ok(), await res.text()).toBeTruthy();
const consent = await page.request.post("/api/auth/consent", {
data: { accepted: true },
await completeOnboarding(page, {
legal_name: "E2E Learner",
affiliation: "한신대학교",
department: "상담심리학과",
grade_level: "3학년",
phone: "010-0000-0000",
contact_address: "경기도 오산시 한신대학교",
});
expect(consent.ok(), await consent.text()).toBeTruthy();
}
export async function signInAsTeacher(page: Page) {
@ -84,6 +88,42 @@ export async function signInAsTeacher(page: Page) {
},
});
expect(res.ok(), await res.text()).toBeTruthy();
await completeOnboarding(page, {
legal_name: "E2E Teacher",
affiliation: "한신대학교",
department: "상담심리학과",
grade_level: "교수",
phone: "010-1111-1111",
contact_address: "경기도 오산시 한신대학교",
});
}
export async function completeOnboarding(
page: Page,
profile: {
legal_name: string;
affiliation: string;
department: string;
grade_level: string;
phone: string;
contact_address: string;
nickname?: string;
self_introduction?: string;
avatar_url?: string;
},
) {
const onboarding = await page.request.post("/api/users/me/onboarding", {
data: {
...profile,
nickname: profile.nickname ?? profile.legal_name,
self_introduction:
profile.self_introduction ?? "상담 시뮬레이션 훈련을 위한 테스트 사용자입니다.",
avatar_url: profile.avatar_url ?? "",
terms_accepted: true,
privacy_accepted: true,
},
});
expect(onboarding.ok(), await onboarding.text()).toBeTruthy();
}
export async function fetchAvailablePersonas(page: Page): Promise<E2EPersona[]> {

View file

@ -94,6 +94,96 @@ function recentSessionFixture(index: number) {
};
}
function teacherReviewResponse(sessionId: string) {
return {
session_id: sessionId,
client: {
name: "서연",
initial: "서",
persona: "P1 · 고난도",
},
date: "2026-06-27",
durationLabel: "32분",
durationSeconds: 1920,
reachedPhase: "정리",
sessionSignal: "종료됨",
supervisorState: "평가 완료",
supervisorName: "AI",
teacherReview: {
status: "pending",
note: "",
reviewerId: null,
reviewedAt: null,
updatedAt: null,
},
summary: "교수자가 검토할 수 있는 종료 회기 리뷰입니다.",
phases: [{ key: "closing", label: "정리", weight: 1 }],
phaseAxis: ["0:00", "32:00"],
valenceAxis: [],
clientValence: [],
counselorBaseline: [],
turns: [
{
id: "t1",
ts: "01:00",
speaker: "learner",
who: "학습자",
text: "오늘은 비교당할 때의 감정을 더 살펴보고 싶습니다.",
techniques: [{ kind: "explore", label: "탐색 질문" }],
nonverbal: [],
note: {
author: "평가 AI",
tone: "good",
title: "검토 가능",
body: "교수자가 읽을 수 있는 자동 평가 노트입니다.",
quote: null,
},
},
{
id: "t2",
ts: "02:10",
speaker: "client",
who: "서연",
text: "말하기는 어렵지만 계속 비교당하는 게 힘들어요.",
techniques: [],
nonverbal: [],
note: null,
},
],
rubric: [],
goodMoments: [],
growthPoints: [],
caseWorksheet: {
status: "draft_from_transcript",
generatedBy: "rule-based transcript extractor",
sections: [
{
key: "trigger",
title: "촉발 장면",
items: [
{
key: "comparison",
label: "비교 경험",
value: "반복 비교 상황에서 감정 탐색이 필요함.",
evidence: [{ turnId: "t2", speaker: "client", quote: "계속 비교당하는 게 힘들어요" }],
confidence: "medium",
emptyReason: null,
},
],
},
],
limitations: ["교수자 검토 화면에서는 학습자 제출물을 수정하지 않습니다."],
savedAt: null,
},
nextLine: null,
clientFeedback: null,
audioUrl: null,
pdfExportUrl: null,
degraded: false,
reviewReady: true,
};
}
test.describe("teacher console", () => {
test("lets a teacher approve a pending persona review from the console @single-run", async ({
page,
@ -168,6 +258,143 @@ test.describe("teacher console", () => {
await expectNoHorizontalOverflow(page);
});
test("lets a teacher revise an approved persona from persona studio @single-run", async ({ page }) => {
const personaId = "00000000-0000-0000-0000-000000000701";
let revisionRequested = false;
const approvedPersona = {
persona_id: personaId,
code: "P1",
version: 1,
status: "approved",
display_name: "서연(가명) · 고2 · 우울/자살사고",
difficulty: "hard",
theory_target: ["humanistic"],
demographics: { age_band: "F-teen" },
presenting_summary: "최근 무기력과 자살사고를 호소합니다.",
source: "database",
degraded: false,
voice_preset: null,
};
const draftDetail = {
...approvedPersona,
version: 2,
status: "draft",
source_provenance: "bootstrap seed migrated to editable catalog",
is_synthetic: true,
created_at: "2026-06-28T00:00:00Z",
approved_at: null,
presenting: { complaint: "최근 무기력과 자살사고를 호소합니다." },
history: { family: "가족 갈등" },
big5: { O: 0.5, C: 0.4, E: 0.3, A: 0.6, N: 0.8 },
resistance: { base_resistance: 0.6, unlock_rate: 0.1, decay_floor: 0.05 },
speech_style: { register: "polite", honorific: true },
affect_baseline: { negative_affect: 0.7, anxiety: 0.4, suicide_ideation_stage: 1 },
ccd: { core_belief: "나는 짐이 된다" },
dsm5_dimensional: { depression: "moderate" },
triggers: { sore_spots: ["평가절하"], forbidden: ["단정"] },
};
await signInAsTeacher(page);
await page.route("**/api/personas", (route) => {
if (route.request().method() !== "GET") return route.fallback();
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([approvedPersona]),
});
});
await page.route("**/api/personas/review", (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([]) }),
);
await page.route(`**/api/personas/${personaId}/revisions`, async (route) => {
revisionRequested = true;
expect(route.request().method()).toBe("POST");
expect(route.request().postDataJSON()).toEqual({ submit_for_review: false });
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify(draftDetail),
});
});
await page.goto("/teach/personas");
const row = page.locator('[data-approved-persona-row="true"]').filter({ hasText: "P1" });
await expect(row).toBeVisible();
await row.getByRole("button", { name: "수정" }).click();
await expect(page.getByText("P1 v2 수정 초안을 불러왔습니다.")).toBeVisible();
await expect(page.getByLabel("표시 이름")).toHaveValue("서연(가명) · 고2 · 우울/자살사고");
await expect(page.getByLabel("코드")).toHaveValue("P1");
expect(revisionRequested).toBeTruthy();
await expectNoHorizontalOverflow(page);
});
test("archives an approved persona from persona studio without layout drift @single-run", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
const personaId = "00000000-0000-0000-0000-000000000702";
let archived = false;
const approvedPersona = {
persona_id: personaId,
code: "P7",
version: 1,
status: "approved",
display_name: "도현(가명) · 고3 · 입시 번아웃/무기력",
difficulty: "moderate",
theory_target: ["humanistic"],
demographics: { age_band: "M-teen" },
presenting_summary: "입시 번아웃과 무기력",
source: "database",
degraded: false,
voice_preset: null,
};
await signInAsTeacher(page);
await page.route("**/api/personas", (route) => {
if (route.request().method() !== "GET") return route.fallback();
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(archived ? [] : [approvedPersona]),
});
});
await page.route("**/api/personas/review", (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([]) }),
);
await page.route(`**/api/personas/${personaId}`, async (route) => {
archived = true;
expect(route.request().method()).toBe("DELETE");
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
persona_id: personaId,
code: "P7",
version: 1,
status: "archived",
display_name: approvedPersona.display_name,
difficulty: approvedPersona.difficulty,
theory_target: approvedPersona.theory_target,
source_provenance: "bootstrap seed migrated to editable catalog",
is_synthetic: true,
created_at: "2026-06-28T00:00:00Z",
approved_at: null,
}),
});
});
await page.goto("/teach/personas");
const row = page.locator('[data-approved-persona-row="true"]').filter({ hasText: "P7" });
await expect(row).toBeVisible();
await expectVisibleButtonsFit(page, ".ps-approved-row__actions .vg-btn", "approved persona actions");
page.once("dialog", (dialog) => dialog.accept());
await row.getByRole("button", { name: "삭제" }).click();
await expect(page.getByText("P7 페르소나를 보관 처리했습니다.")).toBeVisible();
await expect(page.getByText("공개 목록 없음")).toBeVisible();
expect(archived).toBeTruthy();
await expectNoHorizontalOverflow(page);
});
test("renders real server sessions from server-owned rows", async ({ page }) => {
const sessionId = await createEndedLearnerSession(page);
await signInAsTeacher(page);
@ -189,6 +416,262 @@ test.describe("teacher console", () => {
await expectNoHorizontalOverflow(page);
});
test("opens a pending session review from the teacher queue @single-run", async ({ page }) => {
const sessionId = "teacher-review-fixture-session";
await page.route("**/api/teacher/dashboard", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
source: "database",
cohort_label: "E2E cohort",
total_learners: 1,
active_sessions: 0,
ended_sessions: 1,
learner_growth: [],
safety_alerts: [],
pending_reviews: [
{
session_id: sessionId,
learner_id: "00000000-0000-0000-0000-000000000111",
learner_label: "E2E Learner",
persona_code: "P1",
persona_name: "서연",
session_no: 1,
status: "ended",
stage: "정리",
turn_count: 6,
learner_turn_count: 3,
client_turn_count: 3,
started_at: "2026-06-27T07:00:00Z",
ended_at: "2026-06-27T07:32:00Z",
review_status: "pending",
review_note: null,
reviewed_at: null,
},
],
recent_sessions: [],
message: "교수자 검토 대기 회기가 있습니다.",
}),
}),
);
await page.route("**/api/personas/review", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: "[]",
}),
);
await page.route(`**/api/sessions/${sessionId}/review`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(teacherReviewResponse(sessionId)),
}),
);
await page.route(`**/api/teacher/sessions/${sessionId}/review-status`, async (route) => {
const body = route.request().postDataJSON() as { status: string; note: string };
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
session_id: sessionId,
status: body.status,
note: body.note,
reviewer_id: "00000000-0000-0000-0000-000000000901",
reviewed_at: body.status === "closed" ? "2026-06-27T10:00:00Z" : null,
updated_at: "2026-06-27T10:00:00Z",
}),
});
});
await signInAsTeacher(page);
await page.goto("/teach");
await expect(page.locator(".pf-triage")).toBeVisible();
await expect(page.locator(".pf-triage")).toContainText("리뷰 대기");
const row = page.getByRole("button", { name: /E2E Learner P1 회기 상세 검토/ });
await expect(row).toBeVisible();
await row.click();
await expect(page).toHaveURL(new RegExp(`/teach/session/${sessionId}/review$`));
await expect(page.getByText("교수자 검토 화면", { exact: true })).toBeVisible();
await expect(page.getByText("교수자 검토", { exact: true })).toBeVisible();
await expect(page.getByText("축어록 자동 초안 읽기 전용")).toBeVisible();
await expect(page.getByText("검토 전용")).toBeVisible();
await page.getByLabel("검토 메모").fill("다음 회기에서 감정 반영을 먼저 확인");
await page.getByRole("button", { name: "검토 완료" }).click();
await expect(page.getByText("완료 시각 2026-06-27T10:00:00Z")).toBeVisible();
await expect(page.getByRole("button", { name: "검토 완료" })).toBeDisabled();
await expect(page.getByRole("button", { name: "교수 콘솔로" })).toBeVisible();
await expect(page.getByRole("button", { name: /^저장/ })).toHaveCount(0);
await expectNoHorizontalOverflow(page);
});
test("opens recent ended and active sessions from the teacher history @single-run", async ({
page,
}) => {
const endedSession = {
...recentSessionFixture(1),
session_id: "teacher-recent-ended-session",
learner_label: "Recent Ended Learner",
persona_code: "P1",
status: "ended",
ended_at: "2026-06-27T07:32:00Z",
};
const activeSession = {
...recentSessionFixture(2),
session_id: "teacher-recent-active-session",
learner_label: "Recent Active Learner",
persona_code: "P2",
status: "active",
ended_at: null,
};
await page.route("**/api/teacher/dashboard", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
source: "database",
cohort_label: "E2E cohort",
total_learners: 2,
active_sessions: 1,
ended_sessions: 1,
learner_growth: [],
safety_alerts: [],
pending_reviews: [],
recent_sessions: [endedSession, activeSession],
message: "최근 회기 기록 fixture.",
}),
}),
);
await page.route("**/api/personas/review", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: "[]",
}),
);
await page.route(`**/api/sessions/${endedSession.session_id}/review`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(teacherReviewResponse(endedSession.session_id)),
}),
);
await page.route(`**/api/sessions/${activeSession.session_id}/review`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
...teacherReviewResponse(activeSession.session_id),
sessionSignal: "진행 중",
supervisorState: "평가 대기",
summary: "진행 중인 세션의 현재 기록을 교수자가 읽기 전용으로 확인합니다.",
reviewReady: false,
degraded: true,
}),
}),
);
await signInAsTeacher(page);
await page.goto("/teach");
const endedRow = page.getByRole("button", {
name: /Recent Ended Learner P1 상세 리뷰/,
});
await expect(endedRow).toBeVisible();
await endedRow.click();
await expect(page).toHaveURL(new RegExp(`/teach/session/${endedSession.session_id}/review$`));
await expect(page.getByText("교수자 검토 화면", { exact: true })).toBeVisible();
await expect(page.getByText("종료됨", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "교수 콘솔로" }).click();
const activeRow = page.getByRole("button", {
name: /Recent Active Learner P2 진행 기록/,
});
await expect(activeRow).toBeVisible();
await activeRow.click();
await expect(page).toHaveURL(new RegExp(`/teach/session/${activeSession.session_id}/review$`));
await expect(page.getByText("교수자 검토 화면", { exact: true })).toBeVisible();
await expect(page.getByText("진행 중", { exact: true })).toBeVisible();
await expect(page.getByText("평가 대기", { exact: true })).toBeVisible();
await expectNoHorizontalOverflow(page);
});
test("opens a recent session review from the teacher history table @single-run", async ({ page }) => {
const sessionId = "teacher-recent-fixture-session";
await page.route("**/api/teacher/dashboard", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
source: "database",
cohort_label: "E2E cohort",
total_learners: 1,
active_sessions: 1,
ended_sessions: 0,
learner_growth: [],
safety_alerts: [],
pending_reviews: [],
recent_sessions: [
{
session_id: sessionId,
learner_id: "00000000-0000-0000-0000-000000000222",
learner_label: "Recent Learner",
persona_code: "P2",
persona_name: "민재",
session_no: 2,
status: "active",
stage: "탐색",
turn_count: 4,
learner_turn_count: 2,
client_turn_count: 2,
started_at: "2026-06-27T08:00:00Z",
ended_at: null,
},
],
message: "최근 진행 회기가 있습니다.",
}),
}),
);
await page.route("**/api/personas/review", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: "[]",
}),
);
const review = teacherReviewResponse(sessionId);
review.sessionSignal = "진행 중";
review.supervisorState = "평가 대기";
review.reviewReady = false;
await page.route(`**/api/sessions/${sessionId}/review`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(review),
}),
);
await signInAsTeacher(page);
await page.goto("/teach");
const row = page.getByRole("button", { name: /Recent Learner P2 진행 기록/ });
await expect(row).toBeVisible();
await row.press("Enter");
await expect(page).toHaveURL(new RegExp(`/teach/session/${sessionId}/review$`));
await expect(page.getByText("교수자 검토 화면", { exact: true })).toBeVisible();
await expect(page.getByText("진행 중", { exact: true })).toBeVisible();
await expect(page.getByText("검토 전용")).toBeVisible();
await expect(page.getByRole("button", { name: /^저장/ })).toHaveCount(0);
await expectNoHorizontalOverflow(page);
});
test("keeps recent sessions readable without horizontal scrolling across breakpoints", async ({
page,
}) => {
@ -254,8 +737,8 @@ test.describe("teacher console", () => {
if (metrics.viewportWidth <= 860) {
expect(metrics.headerDisplay).toBe("none");
expect(metrics.rowDisplay).toBe("grid");
expect(metrics.gridCellCount).toBe(6);
expect(metrics.labels).toEqual(["페르소나", "상태", "단계", "턴", "시작", "종료"]);
expect(metrics.gridCellCount).toBe(7);
expect(metrics.labels).toEqual(["페르소나", "상태", "단계", "턴", "시작", "종료", "열기"]);
} else {
expect(metrics.headerDisplay).toBe("grid");
expect(metrics.headerPosition).toBe("sticky");

View file

@ -41,6 +41,9 @@ interface VoiceUiProbeState {
recorderStops: number;
trackStops: number;
audioPlays: number;
audioContextResumes: number;
audioBufferStarts: number;
mediaPlayRejections: number;
messages: VoiceUiProbeMessage[];
closeEvents: number[];
}
@ -206,6 +209,7 @@ async function startApi({
ENGINE_CONNECT_TIMEOUT: "2",
OPENAI_API_KEY: "e2e-fake-key",
OPENAI_BASE_URL: `${openAIBaseURL}/v1`,
VIGNETTE_VOICE_POC_SAMPLE_TTS: "false",
FRONTEND_BASE_URL: frontendBaseURL,
CORS_ORIGINS: JSON.stringify([frontendBaseURL]),
},
@ -356,8 +360,11 @@ async function probeVoiceCascade(page: Page, apiBaseURL: string, sessionId: stri
);
}
async function installSyntheticVoiceCapture(page: Page): Promise<void> {
await page.addInitScript(() => {
async function installSyntheticVoiceCapture(
page: Page,
options: { blockMediaElementPlayback?: boolean } = {},
): Promise<void> {
await page.addInitScript((opts) => {
type ProbeMessage = {
direction: "sent" | "received";
kind: "text" | "binary";
@ -370,6 +377,9 @@ async function installSyntheticVoiceCapture(page: Page): Promise<void> {
recorderStops: number;
trackStops: number;
audioPlays: number;
audioContextResumes: number;
audioBufferStarts: number;
mediaPlayRejections: number;
messages: ProbeMessage[];
closeEvents: number[];
};
@ -380,6 +390,9 @@ async function installSyntheticVoiceCapture(page: Page): Promise<void> {
recorderStops: 0,
trackStops: 0,
audioPlays: 0,
audioContextResumes: 0,
audioBufferStarts: 0,
mediaPlayRejections: 0,
messages: [],
closeEvents: [],
};
@ -507,14 +520,93 @@ async function installSyntheticVoiceCapture(page: Page): Promise<void> {
value: ProbeWebSocket,
});
class FakeAnalyser {
fftSize = 1024;
connect() {
return this;
}
disconnect() {
return undefined;
}
getFloatTimeDomainData(buf: Float32Array) {
for (let i = 0; i < buf.length; i += 1) buf[i] = 0;
}
}
class FakeBufferSource {
buffer: unknown = null;
onended: ((event: Event) => void) | null = null;
connect() {
return this;
}
disconnect() {
return undefined;
}
start() {
probe.audioBufferStarts += 1;
window.setTimeout(() => {
this.onended?.(new Event("ended"));
}, 120);
}
stop() {
this.onended = null;
}
}
class FakeAudioContext {
state = "running";
destination = {};
async resume() {
probe.audioContextResumes += 1;
this.state = "running";
}
async close() {
this.state = "closed";
}
async decodeAudioData(_data: ArrayBuffer) {
return { duration: 0.12 };
}
createAnalyser() {
return new FakeAnalyser();
}
createBufferSource() {
return new FakeBufferSource();
}
}
Object.defineProperty(window, "AudioContext", {
configurable: true,
value: FakeAudioContext,
});
Object.defineProperty(window, "webkitAudioContext", {
configurable: true,
value: FakeAudioContext,
});
HTMLMediaElement.prototype.play = function patchedPlay() {
if (opts.blockMediaElementPlayback) {
probe.mediaPlayRejections += 1;
return Promise.reject(new DOMException("Synthetic autoplay block", "NotAllowedError"));
}
probe.audioPlays += 1;
window.setTimeout(() => {
this.dispatchEvent(new Event("ended"));
}, 120);
return Promise.resolve();
};
});
}, options);
}
async function readVoiceUiProbe(page: Page): Promise<VoiceUiProbeState> {
@ -562,6 +654,33 @@ test.describe("voice cascade success path", () => {
if (!login.ok) {
return { ok: false, step: "login", status: login.status, body: loginBody };
}
const onboarding = await fetch(`${apiBase}/users/me/onboarding`, {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({
legal_name: "Voice Success",
affiliation: "한신대학교",
department: "상담심리학과",
grade_level: "3학년",
phone: "010-5555-5555",
contact_address: "경기도 오산시 한신대학교",
nickname: "Voice Success",
self_introduction: "음성 캐스케이드 성공 경로 검증용 사용자입니다.",
avatar_url: "",
terms_accepted: true,
privacy_accepted: true,
}),
});
const onboardingBody = await onboarding.text();
if (!onboarding.ok) {
return {
ok: false,
step: "onboarding",
status: onboarding.status,
body: onboardingBody,
};
}
const me = await fetch(`${apiBase}/auth/me`, { credentials: "include" });
const meBody = await me.text();
if (!me.ok) {
@ -613,7 +732,6 @@ test.describe("voice cascade success path", () => {
expect.objectContaining({ type: "transcript", text: "요즘 잠을 잘 못 자요." }),
expect.objectContaining({ type: "reply", text: "괜찮아요. 천천히 말해볼게요." }),
expect.objectContaining({ type: "state", state: "speaking" }),
expect.objectContaining({ type: "tts_chunk", seq: 0 }),
expect.objectContaining({ type: "tts_end" }),
expect.objectContaining({ type: "state", state: "idle" }),
]),
@ -646,7 +764,7 @@ test.describe("voice cascade success path", () => {
}
});
await installSyntheticVoiceCapture(page);
await installSyntheticVoiceCapture(page, { blockMediaElementPlayback: true });
const openai = await startFakeOpenAI();
const engine = await startFakeEngine();
@ -703,6 +821,33 @@ test.describe("voice cascade success path", () => {
if (!login.ok) {
return { ok: false, step: "login", status: login.status, body: loginBody };
}
const onboarding = await fetch(`${apiBase}/users/me/onboarding`, {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({
legal_name: "Voice UI",
affiliation: "한신대학교",
department: "상담심리학과",
grade_level: "3학년",
phone: "010-6666-6666",
contact_address: "경기도 오산시 한신대학교",
nickname: "Voice UI",
self_introduction: "브라우저 음성 UI 검증용 사용자입니다.",
avatar_url: "",
terms_accepted: true,
privacy_accepted: true,
}),
});
const onboardingBody = await onboarding.text();
if (!onboarding.ok) {
return {
ok: false,
step: "onboarding",
status: onboarding.status,
body: onboardingBody,
};
}
const me = await fetch(`${apiBase}/auth/me`, { credentials: "include" });
const meBody = await me.text();
if (!me.ok) {
@ -794,7 +939,10 @@ test.describe("voice cascade success path", () => {
await expect(page.locator(".sx-utt").filter({ hasText: String(reply) })).toBeVisible();
const probe = await readVoiceUiProbe(page);
expect(probe.audioPlays).toBeGreaterThan(0);
expect(probe.audioContextResumes).toBeGreaterThan(0);
expect(probe.audioBufferStarts).toBeGreaterThan(0);
expect(probe.audioPlays).toBe(0);
expect(probe.mediaPlayRejections).toBe(0);
expect(probe.messages.some((message) => message.direction === "received" && message.kind === "binary")).toBe(
true,
);

View file

@ -1,5 +1,5 @@
import { expect, test, type Page, type TestInfo } from "@playwright/test";
import { fetchAvailablePersona } from "./support";
import { completeOnboarding, fetchAvailablePersona } from "./support";
interface WsResult {
code: number;
@ -15,6 +15,16 @@ async function signInLearner(page: Page, testInfo: TestInfo, label: string) {
},
});
expect(res.ok(), await res.text()).toBeTruthy();
await completeOnboarding(page, {
legal_name: `Voice ${label}`,
affiliation: "한신대학교",
department: "상담심리학과",
grade_level: "3학년",
phone: "010-4444-4444",
contact_address: "경기도 오산시 한신대학교",
nickname: `Voice ${label}`,
self_introduction: "음성 WebSocket 경계 검증용 E2E 사용자입니다.",
});
}
async function openVoiceSocket(page: Page, path: string): Promise<WsResult> {