음성 재생과 운영 배포 정리
This commit is contained in:
parent
8ed185ce6c
commit
ac7db95542
1020 changed files with 46863 additions and 2175 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
241
apps/web/e2e/session-review-fixture.ts
Normal file
241
apps/web/e2e/session-review-fixture.ts
Normal 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)),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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[]> {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue