전 저장소 리팩터링과 SSOT 정비

This commit is contained in:
Yun Chan 2026-07-15 21:31:30 +09:00
parent 14ecbd4e7d
commit 3dfddcac6f
173 changed files with 19679 additions and 6952 deletions

View file

@ -48,6 +48,18 @@ $env:E2E_PUBLIC_STORAGE_STATE="./node_modules/.tmp/public-auth.json"
npx playwright test e2e/public-auth-turn.spec.ts --project=chromium-public-auth
```
Public admin visual smoke:
```sh
# Capture storage state after signing in with an admin-entitled Google account.
npx playwright codegen https://vignette.chanpaca.net/login --save-storage=./node_modules/.tmp/public-admin-auth.json
# Verify that https://vignette.chanpaca.net/admin visibly renders the admin console.
$env:E2E_PUBLIC_AUTH="1"
$env:E2E_PUBLIC_STORAGE_STATE="./node_modules/.tmp/public-admin-auth.json"
npx playwright test e2e/public-admin-visual.spec.ts --project=chromium-public-auth
```
Notes:
- `E2E_PUBLIC_AUTH=1` targets the public site and does not start the local Vite web server.

View file

@ -82,6 +82,7 @@ interface AdminUsageEvaluatorCache {
interface AdminUsageResponse {
source: "database" | "server_session_registry";
durable: boolean;
generated_at: number;
window_days: number;
total_turns: number;
metered_turns: number;
@ -94,6 +95,16 @@ interface AdminUsageResponse {
daily_cost?: AdminUsageDailyCost[];
}
interface AdminEngineConfigResponse {
engine_mode: string;
engine_url: string;
model: string;
source: "database" | "runtime_cache" | "runtime_default";
durable: boolean;
updated_by: string | null;
updated_at: number | null;
}
interface AdminUptimeResponse {
source: "database" | "unavailable";
durable: boolean;
@ -161,6 +172,8 @@ async function mockAdminSession(
options: {
adminUsers?: unknown[];
adminTickets?: unknown;
adminUsage?: AdminUsageResponse;
engineConfig?: AdminEngineConfigResponse;
} = {},
) {
const seenAdminEndpoints = new Set<string>();
@ -221,33 +234,70 @@ async function mockAdminSession(
if (method === "GET" && path.endsWith("/admin/usage")) {
seenAdminEndpoints.add("usage");
await fulfillJson(
options.adminUsage ?? {
source: "database",
durable: true,
generated_at: 1_783_990_800,
window_days: Number(url.searchParams.get("window_days") ?? 7),
total_turns: 0,
metered_turns: 0,
tokens_in: 0,
tokens_out: 0,
cost_usd: 0,
budget: {
limit_usd: 0,
used_ratio: 0,
remaining_usd: null,
status: "disabled",
},
evaluator_cache: {
enabled: false,
entries: 0,
hits: 0,
misses: 0,
stores: 0,
evictions: 0,
requests: 0,
hit_rate: 0,
},
by_provider: [],
daily_cost: [],
},
);
return;
}
if (method === "GET" && path.endsWith("/admin/engine-config")) {
seenAdminEndpoints.add("engine-config");
await fulfillJson(
options.engineConfig ?? {
engine_mode: "openai",
engine_url: "http://127.0.0.1:9099",
model: "gateway-default",
source: "database",
durable: true,
updated_by: "admin@twentyoz.kr",
updated_at: 1_783_990_800,
},
);
return;
}
if (method === "PATCH" && path.endsWith("/admin/engine-config")) {
seenAdminEndpoints.add("engine-config-patch");
const body = request.postDataJSON() as Partial<AdminEngineConfigResponse>;
await fulfillJson({
...(options.engineConfig ?? {
engine_mode: "openai",
engine_url: "http://127.0.0.1:9099",
model: "gateway-default",
}),
...body,
source: "database",
durable: true,
window_days: 7,
total_turns: 0,
metered_turns: 0,
tokens_in: 0,
tokens_out: 0,
cost_usd: 0,
budget: {
limit_usd: 0,
used_ratio: 0,
remaining_usd: null,
status: "disabled",
},
evaluator_cache: {
enabled: false,
entries: 0,
hits: 0,
misses: 0,
stores: 0,
evictions: 0,
requests: 0,
hit_rate: 0,
},
by_provider: [],
daily_cost: [],
updated_by: "stale-admin@twentyoz.kr",
updated_at: 1_783_990_900,
});
return;
}
@ -568,7 +618,9 @@ async function expectVisibleButtonsFit(page: Page, selector: string, context: st
buttons
.map((button) => {
const rect = button.getBoundingClientRect();
const owner = button.closest<HTMLElement>(".ad-user,.ad-user-create") ?? button.parentElement;
const owner =
button.closest<HTMLElement>(".ad-user,.ad-user-create,.ad-user-table tbody tr") ??
button.parentElement;
const ownerRect = owner?.getBoundingClientRect();
const style = window.getComputedStyle(button);
const visible =
@ -636,6 +688,99 @@ test.describe("admin route guards", () => {
.toEqual(["health", "tickets", "uptime", "usage", "users"]);
});
test("shows detailed AI metering and saves the real engine configuration", async ({ page }) => {
const seenAdminEndpoints = await mockAdminSession(
page,
{
user_id: "ai-admin",
email: "ai-admin@twentyoz.kr",
display_name: "AI Admin",
role: "admin",
admin_access: true,
super_admin: true,
onboarding_completed_at: 1_782_900_000,
},
{
adminUsage: {
source: "database",
durable: true,
generated_at: 1_783_990_800,
window_days: 30,
total_turns: 32,
metered_turns: 30,
tokens_in: 125_000,
tokens_out: 18_500,
cost_usd: 6.6212,
budget: {
limit_usd: 20,
used_ratio: 0.33106,
remaining_usd: 13.3788,
status: "ok",
},
evaluator_cache: {
enabled: true,
entries: 12,
hits: 8,
misses: 2,
stores: 2,
evictions: 1,
requests: 10,
hit_rate: 0.8,
},
by_provider: [
{
provider: "openai",
model: "gpt-5-mini",
turns: 30,
tokens_in: 125_000,
tokens_out: 18_500,
cost_usd: 6.6212,
},
],
daily_cost: [
{ day: "2026-07-13", turns: 8, tokens_in: 32_000, tokens_out: 4_800, cost_usd: 1.42 },
{ day: "2026-07-14", turns: 10, tokens_in: 41_000, tokens_out: 6_100, cost_usd: 2.08 },
{ day: "2026-07-15", turns: 12, tokens_in: 52_000, tokens_out: 7_600, cost_usd: 3.1212 },
],
},
},
);
await page.goto("/admin/ai");
await expect(page).toHaveURL(/\/admin\/ai$/);
await expect(page.getByRole("heading", { name: "AI 운영과 DB 계량" })).toBeVisible();
await expect(page.locator(".vg-nav").getByRole("link", { name: "AI 운영" })).toHaveAttribute(
"aria-current",
"page",
);
await expect(page.getByText("운영 DB 원장").first()).toBeVisible();
await expect(page.locator(".aic-ledger")).toContainText("$6.6212");
await expect(page.locator(".aic-budget")).toContainText("93.8%");
await expect(page.locator(".aic-table")).toContainText("gpt-5-mini");
await expect(page.locator(".aic-cache-score")).toContainText("80%");
await expect(page.getByLabel("AI 기본 모델")).toHaveValue("gateway-default");
const usageRequest = page.waitForRequest((request) =>
request.url().includes("/api/admin/usage?window_days=7"),
);
await page.getByRole("button", { name: "7일" }).click();
await usageRequest;
await page.getByLabel("AI 기본 모델").fill("gpt-5.1-mini");
const patchRequest = page.waitForRequest((request) =>
request.method() === "PATCH" && request.url().endsWith("/api/admin/engine-config"),
);
await page.getByRole("button", { name: "운영 설정 저장" }).click();
const request = await patchRequest;
expect(request.postDataJSON()).toMatchObject({ model: "gpt-5.1-mini" });
await expect(page.getByText("저장됨")).toBeVisible();
await expect
.poll(() => Array.from(seenAdminEndpoints).sort())
.toEqual(["engine-config", "engine-config-patch", "health", "usage"]);
await expectNoHorizontalOverflow(page);
});
test("resets scroll when moving from long admin pages to access policy", async ({ page }) => {
await mockAdminSession(page, {
user_id: "scroll-admin",
@ -663,7 +808,40 @@ test.describe("admin route guards", () => {
await expect
.poll(() => page.evaluate(() => window.scrollY))
.toBe(0);
await expect(page.getByRole("heading", { name: "역할, 그룹, 접근 범위" })).toBeInViewport();
await expect(page.locator(".ad-root")).toBeInViewport();
await expect(page.getByRole("heading", { name: "역할, 그룹, 접근 범위" })).toBeVisible();
});
test("resets scroll when the browser restores an existing admin tab", async ({ page }) => {
await mockAdminSession(page, {
user_id: "restored-tab-admin",
email: "restored-tab-admin@twentyoz.kr",
display_name: "Restored Tab Admin",
role: "admin",
admin_access: true,
super_admin: true,
onboarding_completed_at: 1_782_900_000,
});
await page.goto("/admin");
await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible();
const scrolled = await page.evaluate(() => {
document.documentElement.style.minHeight = "2200px";
document.body.style.minHeight = "2200px";
window.scrollTo(0, 900);
return window.scrollY;
});
expect(scrolled).toBeGreaterThan(0);
await page.evaluate(() => {
window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: true }));
});
await expect
.poll(() => page.evaluate(() => window.scrollY))
.toBe(0);
await expect(page.locator(".ad-root")).toBeInViewport();
await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible();
});
test("shows admin data diagnostics instead of a blank main pane", async ({ page }) => {
@ -849,6 +1027,7 @@ test.describe("admin route", () => {
await page.goto("/admin");
const nav = page.locator(".vg-nav");
await expect(nav.getByRole("link", { name: "운영 홈" })).toBeVisible();
await expect(nav.getByRole("link", { name: "AI 운영" })).toBeVisible();
await expect(nav.getByRole("link", { name: "사용자" })).toBeVisible();
await expect(nav.getByRole("link", { name: "권한" })).toBeVisible();
await expect(nav.getByRole("link", { name: "티켓" })).toBeVisible();
@ -886,6 +1065,18 @@ test.describe("admin route", () => {
await expect(page.getByText("아직 등록된 사용자가 없습니다.")).toHaveCount(0);
await page.getByLabel("사용자 검색").fill("");
const userTable = page.getByRole("table");
await expect(userTable).toBeVisible();
const sessionHeader = page.getByRole("columnheader", { name: /활성 회기/ });
await sessionHeader.getByRole("button").click();
await expect(sessionHeader).toHaveAttribute("aria-sort", "ascending");
const ascendingSessions = await userTable.locator("tbody tr td:nth-child(7)").allTextContents();
expect(ascendingSessions.map(Number)).toEqual(
ascendingSessions.map(Number).slice().sort((a, b) => a - b),
);
await sessionHeader.getByRole("button").click();
await expect(sessionHeader).toHaveAttribute("aria-sort", "descending");
await page.getByRole("tab", { name: "사용자 등록" }).click();
await page.getByLabel("새 사용자 이메일").fill(email);
await page.getByLabel("새 사용자 표시 이름").fill(displayName);
@ -934,9 +1125,9 @@ test.describe("admin route", () => {
await page.getByRole("tab", { name: "사용자 목록" }).click();
await page.getByLabel("사용자 검색").fill(email);
const card = page.locator(".ad-user").filter({ hasText: email });
const card = page.locator(".ad-user-table tbody tr").filter({ hasText: email });
await expect(card).toBeVisible();
await expect(card).toContainText(displayName);
await expect(card.getByLabel(`${email} 표시 이름`)).toHaveValue(displayName);
await expect(card).toContainText("승인됨");
await expectVisibleButtonsFit(page, ".ad-user__actions .vg-btn", "admin user action buttons");
@ -970,7 +1161,7 @@ test.describe("admin route", () => {
cohort_ids: [nextCohort],
});
await expect(card).toContainText(nextName);
await expect(nameInput).toHaveValue(nextName);
await expect(card).toContainText("교수자");
await expect(cohortInput).toHaveValue(nextCohort);
@ -1136,6 +1327,20 @@ test.describe("admin route", () => {
expect(layout.formColumns).toBe(1);
if (users.users.length > 0) {
await page.getByRole("tab", { name: "사용자 목록" }).click();
const scrollRegion = page.locator(".ad-user-table-scroll");
const scrollContract = await scrollRegion.evaluate((element) => {
const before = element.scrollLeft;
element.scrollLeft = element.scrollWidth;
return {
before,
after: element.scrollLeft,
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
};
});
expect(scrollContract.scrollWidth).toBeGreaterThan(scrollContract.clientWidth);
expect(scrollContract.after).toBeGreaterThan(scrollContract.before);
await expect(page.getByRole("columnheader", { name: /작업/ })).toBeVisible();
await expectVisibleButtonsFit(page, ".ad-user__actions .vg-btn", "mobile admin user actions");
}
});

View file

@ -0,0 +1,117 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { expect, test, type Page } from "@playwright/test";
import { expectNoHorizontalOverflow } from "./support";
const SHOT_DIR = path.join(process.cwd(), "node_modules", ".tmp", "auth-visual");
const THEMES = ["light", "dark"] as const;
const VIEWPORTS = [
{ width: 390, height: 844, label: "mobile" },
{ width: 1280, height: 800, label: "desktop" },
] as const;
async function setTheme(page: Page, theme: (typeof THEMES)[number], pathName: string) {
await page.goto(pathName);
await page.evaluate((nextTheme) => {
localStorage.setItem("vignette.theme", nextTheme);
}, theme);
await page.reload();
}
async function expectGlassSurface(page: Page, selector: string) {
const surface = await page.locator(selector).evaluate((element) => {
const style = getComputedStyle(element);
return {
backgroundImage: style.backgroundImage,
backdropFilter: style.backdropFilter || style.webkitBackdropFilter,
borderColor: style.borderColor,
boxShadow: style.boxShadow,
};
});
expect(surface.backgroundImage.match(/linear-gradient/g)?.length ?? 0).toBeGreaterThanOrEqual(2);
expect(surface.backdropFilter).toContain("blur(");
expect(surface.borderColor).not.toBe("rgba(0, 0, 0, 0)");
expect(surface.boxShadow).not.toBe("none");
}
test("@single-run 인증 화면이 공통 테마와 전체 viewport를 유지한다", async ({ page }) => {
await fs.mkdir(SHOT_DIR, { recursive: true });
const loginRoomBackgrounds = new Map<(typeof THEMES)[number], string>();
for (const theme of THEMES) {
for (const viewport of VIEWPORTS) {
await page.setViewportSize(viewport);
await setTheme(page, theme, "/login");
await expect(page.locator(".lg-panel")).toBeVisible();
await expect(page.locator("html")).toHaveAttribute("data-theme", theme);
await expectNoHorizontalOverflow(page);
await expectGlassSurface(page, ".lg-panel");
const themeColors = await page.locator(".lg-root").evaluate((root) => {
const brand = root.querySelector<HTMLElement>(".lg-brand");
const panel = root.querySelector<HTMLElement>(".lg-panel");
const shellStyle = getComputedStyle(root);
if (!brand || !panel) throw new Error("login theme surfaces are missing");
const luma = (color: string) => {
const [red = 0, green = 0, blue = 0] = color.match(/\d+(?:\.\d+)?/g)?.map(Number) ?? [];
return red * 0.2126 + green * 0.7152 + blue * 0.0722;
};
return {
brandLuma: luma(getComputedStyle(brand).color),
panelLuma: luma(getComputedStyle(panel).color),
roomBackground: shellStyle.backgroundImage,
};
});
if (theme === "light") {
expect(themeColors.brandLuma).toBeLessThan(96);
expect(themeColors.panelLuma).toBeLessThan(96);
} else {
expect(themeColors.brandLuma).toBeGreaterThan(180);
expect(themeColors.panelLuma).toBeGreaterThan(180);
}
loginRoomBackgrounds.set(theme, themeColors.roomBackground);
const canvas = await page.locator(".vg-auth-shell").evaluate((element) => {
const rect = element.getBoundingClientRect();
return {
width: Math.round(rect.width),
minHeight: Math.round(rect.height),
viewportWidth: document.documentElement.clientWidth,
viewportHeight: window.innerHeight,
};
});
expect(canvas.width).toBe(canvas.viewportWidth);
expect(canvas.minHeight).toBeGreaterThanOrEqual(canvas.viewportHeight);
await page.screenshot({
path: path.join(SHOT_DIR, `login-${theme}-${viewport.label}.png`),
fullPage: true,
});
}
}
expect(loginRoomBackgrounds.get("light")).not.toBe(loginRoomBackgrounds.get("dark"));
const login = await page.request.post("/api/auth/dev-login", {
data: {
email: `auth-visual.${Date.now()}@hs.ac.kr`,
role: "learner",
display_name: "인증 화면 검증 학습자",
},
});
expect(login.ok(), await login.text()).toBeTruthy();
for (const theme of THEMES) {
for (const viewport of VIEWPORTS) {
await page.setViewportSize(viewport);
await setTheme(page, theme, "/onboarding");
await expect(page.getByRole("heading", { name: "가입 정보를 입력합니다." })).toBeVisible();
await expect(page.locator("html")).toHaveAttribute("data-theme", theme);
await expectNoHorizontalOverflow(page);
await expectGlassSurface(page, ".ob-shell");
await page.screenshot({
path: path.join(SHOT_DIR, `onboarding-${theme}-${viewport.label}.png`),
fullPage: true,
});
}
}
});

View file

@ -106,7 +106,7 @@ async function mockSessionDetail(
test.describe("persona avatar expression rig", () => {
test.beforeEach(async ({ page }) => {
await page.route("**/api/auth/me", (route) =>
await page.route(/\/(?:api\/)?auth\/me(?:\?.*)?$/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
@ -122,7 +122,7 @@ test.describe("persona avatar expression rig", () => {
}),
);
await page.route("**/api/personas", (route) =>
await page.route(/\/(?:api\/)?personas(?:\?.*)?$/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
@ -137,6 +137,34 @@ test.describe("persona avatar expression rig", () => {
),
}),
);
await page.route(/\/(?:api\/)?voice\/health(?:\?.*)?$/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ available: false, reason: "test fixture" }),
}),
);
await page.route(/\/(?:api\/)?sessions\/[^/]+\/live-coach(?:\?.*)?$/, (route) => {
if (route.request().method() === "GET") {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
session_id: "avatar-expression-session",
events: [],
quota: { used: 0, limit: 3, remaining: 3 },
source: "database",
}),
});
}
return route.fulfill({
status: 503,
contentType: "application/json",
body: JSON.stringify({ detail: "live coach is outside this avatar fixture" }),
});
});
});
test("renders every persona with a distinct baseline expression and at least 20 expressions", async ({
@ -158,17 +186,12 @@ test.describe("persona avatar expression rig", () => {
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(),
rasterCount: el.querySelectorAll(".vg-raster").length,
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");
expect(metrics.live2dModel, `${persona.code} Live2D model`).toBe(persona.expectedModel);
@ -180,15 +203,12 @@ test.describe("persona avatar expression rig", () => {
`expressions/${persona.expectedExpression}.exp3.json`,
);
expect(metrics.live2dExpressionCount, `${persona.code} Live2D expressions`).toBeGreaterThanOrEqual(20);
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();
expect(metrics.renderMode, `${persona.code} render mode`).toBe("svg");
expect(metrics.rasterCount, `${persona.code} raster rig should be disabled`).toBe(0);
expect(metrics.primitiveCount, `${persona.code} SVG primitives`).toBeGreaterThan(10);
expect(metrics.neckBox?.width, `${persona.code} SVG neck`).toBeGreaterThan(0);
expect(metrics.svgBox?.width, `${persona.code} SVG rig`).toBeGreaterThan(0);
expect(metrics.svgBox?.height, `${persona.code} SVG rig`).toBeGreaterThan(0);
}
});
@ -221,8 +241,9 @@ test.describe("persona avatar expression rig", () => {
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-render-mode", "svg");
await expect(activeAvatar.locator(".vg-raster")).toHaveCount(0);
await expect(activeAvatar.locator(".vg-avatar__svg")).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");
@ -275,8 +296,9 @@ test.describe("persona avatar expression rig", () => {
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-render-mode", "svg");
await expect(activeAvatar.locator(".vg-raster")).toHaveCount(0);
await expect(activeAvatar.locator(".vg-avatar__svg")).toBeVisible();
await expect(activeAvatar).toHaveAttribute("data-live2d-motion", "anxious");
await setLearnerUtterance(page, "조금 안정된 것 같아요.");
@ -295,18 +317,15 @@ test.describe("persona avatar expression rig", () => {
await expect(page.locator(".sx-stage__now")).toContainText("온화함");
});
test("uses the PSD v2 crying raster rig for P1 Seoyeon", async ({ page }) => {
test("uses the original SVG parameter 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-render-mode", "svg");
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);
await expect(avatar.locator(".vg-raster")).toHaveCount(0);
await expect(avatar.locator(".vg-avatar__svg")).toBeVisible();
await expect(avatar.locator('[data-avatar-neck="true"]')).toBeVisible();
});
});

View file

@ -260,12 +260,37 @@ async function gateScreen(
await page.setViewportSize({ width: vp.width, height: vp.height });
await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => r(null))));
await prepareReady();
await page.evaluate(() => {
window.scrollTo(0, 0);
return new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
});
await expect(
page.locator("html"),
`[${screen} @ ${vp.label}] layout gate must capture the dark UI surface`,
).toHaveAttribute("data-theme", "dark");
await expectNoHorizontalOverflow(page);
const navGeometry = await page.evaluate(() => {
const nav = document.querySelector<HTMLElement>(".vg-nav");
const label = document.querySelector<HTMLElement>(".vg-nav__label");
const topbar = document.querySelector<HTMLElement>(".vg-topbar");
if (!nav || !label || !topbar || getComputedStyle(label).display === "none") return null;
const navRect = nav.getBoundingClientRect();
return {
innerGap: Math.round(label.getBoundingClientRect().top - navRect.top),
shellGap: Math.round(navRect.top - topbar.getBoundingClientRect().bottom),
};
});
if (navGeometry !== null) {
expect(
navGeometry.innerGap,
`[${screen} @ ${vp.label}] GNB should start near the topbar without a dead top zone`,
).toBeLessThanOrEqual(40);
expect(
Math.abs(navGeometry.shellGap),
`[${screen} @ ${vp.label}] GNB should begin directly below the topbar`,
).toBeLessThanOrEqual(1);
}
const report = await auditClipping(page);
expect(
report.horizontalOverflow,
@ -287,6 +312,14 @@ async function gateScreen(
}
}
/** 리뷰는 모든 폭에서 가로 탭으로 영역을 전환한다. */
async function openReviewTabIfPresent(page: Page, label: string) {
const tab = page.locator(".sr-tabs button", { hasText: label });
if (await tab.isVisible().catch(() => false)) {
await tab.click();
}
}
async function expectEmptyReviewNoDeadThirdColumn(page: Page) {
const report = await page.evaluate(() => {
const root = document.querySelector<HTMLElement>(".sr-root--empty");
@ -312,10 +345,7 @@ async function expectEmptyReviewNoDeadThirdColumn(page: Page) {
});
expect(report.present, "empty review layout should be mounted").toBe(true);
expect(
report.columnCount,
"empty review should collapse the desktop masonry layout instead of leaving a sparse third column",
).toBeLessThanOrEqual(2);
expect(report.columnCount, "empty review should keep the single-pane tab layout").toBe(1);
}
async function expectFilledReviewLearnerWorkbench(page: Page) {
@ -323,52 +353,35 @@ async function expectFilledReviewLearnerWorkbench(page: Page) {
const cols = document.querySelector<HTMLElement>(".sr-cols--learner");
const transcript = document.querySelector<HTMLElement>(".sr-card--transcript");
const overview = document.querySelector<HTMLElement>(".sr-overview");
const rubric = document.querySelector<HTMLElement>(".sr-card--rubric");
const worksheet = document.querySelector<HTMLElement>(".sr-card--worksheet");
const prepost = document.querySelector<HTMLElement>(".sr-card--prepost");
if (!cols || !transcript || !overview || !rubric || !worksheet || !prepost) {
if (!cols || !transcript || !overview) {
return {
present: false,
viewportWidth: window.innerWidth,
columnCount: 0,
overviewRight: 0,
transcriptLeft: 0,
transcriptRight: 0,
rubricLeft: 0,
worksheetLeft: 0,
worksheetRight: 0,
prepostLeft: 0,
overviewBottom: 0,
colsTop: 0,
transcriptWidth: 0,
colsWidth: 0,
};
}
const columnCount = getComputedStyle(cols).gridTemplateColumns.split(" ").filter(Boolean).length;
const colsRect = cols.getBoundingClientRect();
const transcriptRect = transcript.getBoundingClientRect();
const overviewRect = overview.getBoundingClientRect();
const rubricRect = rubric.getBoundingClientRect();
const worksheetRect = worksheet.getBoundingClientRect();
const prepostRect = prepost.getBoundingClientRect();
return {
present: true,
viewportWidth: window.innerWidth,
columnCount,
overviewRight: Math.ceil(overviewRect.right),
transcriptLeft: Math.floor(transcriptRect.left),
transcriptRight: Math.ceil(transcriptRect.right),
rubricLeft: Math.floor(rubricRect.left),
worksheetLeft: Math.floor(worksheetRect.left),
worksheetRight: Math.ceil(worksheetRect.right),
prepostLeft: Math.floor(prepostRect.left),
overviewBottom: Math.ceil(overviewRect.bottom),
colsTop: Math.floor(colsRect.top),
transcriptWidth: Math.round(transcriptRect.width),
colsWidth: Math.round(colsRect.width),
};
});
expect(report.present, "filled learner review layout should be mounted").toBe(true);
if (report.viewportWidth > 1180) {
expect(report.columnCount, "desktop learner review should use a 3-column workbench").toBe(3);
expect(report.overviewRight).toBeLessThanOrEqual(report.transcriptLeft);
expect(report.transcriptRight).toBeLessThanOrEqual(report.rubricLeft);
expect(report.worksheetLeft).toBeLessThan(report.transcriptLeft);
expect(report.worksheetRight).toBeLessThanOrEqual(report.prepostLeft);
}
expect(report.columnCount, "learner review should use one active tab pane").toBe(1);
expect(report.overviewBottom).toBeLessThanOrEqual(report.colsTop + 1);
expect(Math.abs(report.transcriptWidth - report.colsWidth)).toBeLessThanOrEqual(2);
}
async function expectSupervisorReviewNoDeadGaps(page: Page) {
@ -379,7 +392,6 @@ async function expectSupervisorReviewNoDeadGaps(page: Page) {
if (!cols || !left || !right) {
return {
present: false,
viewportWidth: window.innerWidth,
columnCount: 0,
leftDisplay: "",
rightDisplay: "",
@ -388,57 +400,23 @@ async function expectSupervisorReviewNoDeadGaps(page: Page) {
};
}
function maxVerticalGap(container: HTMLElement) {
const intervals = Array.from(container.children)
.map((child) => child.getBoundingClientRect())
.filter((rect) => rect.width > 0 && rect.height > 0)
.map((rect) => ({ top: rect.top, bottom: rect.bottom }))
.sort((a, b) => a.top - b.top || a.bottom - b.bottom);
if (intervals.length < 2) return 0;
let maxGap = 0;
let currentBottom = intervals[0].bottom;
for (let index = 1; index < intervals.length; index += 1) {
const next = intervals[index];
if (next.top <= currentBottom) {
currentBottom = Math.max(currentBottom, next.bottom);
continue;
}
const gap = next.top - currentBottom;
if (gap > maxGap) maxGap = gap;
currentBottom = next.bottom;
}
return Math.round(maxGap);
}
const colsStyle = getComputedStyle(cols);
const leftStyle = getComputedStyle(left);
const rightStyle = getComputedStyle(right);
return {
present: true,
viewportWidth: window.innerWidth,
columnCount: colsStyle.gridTemplateColumns.split(" ").filter(Boolean).length,
leftDisplay: leftStyle.display,
rightDisplay: rightStyle.display,
maxMainGap: maxVerticalGap(left),
maxSideGap: maxVerticalGap(right),
maxMainGap: 0,
maxSideGap: 0,
};
});
expect(report.present, "supervisor review layout should be mounted").toBe(true);
if (report.viewportWidth > 1180) {
expect(report.columnCount, "desktop supervisor review should use main + review rail").toBe(2);
expect(report.leftDisplay).toBe("grid");
expect(report.rightDisplay).toBe("grid");
expect(
report.maxMainGap,
"desktop supervisor review main column should not leave a dead vertical void",
).toBeLessThanOrEqual(40);
expect(
report.maxSideGap,
"desktop supervisor review side rail should not leave a dead vertical void",
).toBeLessThanOrEqual(40);
}
expect(report.columnCount, "supervisor review should use one active tab pane").toBe(1);
expect(report.leftDisplay).toBe("none");
expect(report.rightDisplay).toBe("none");
}
test.describe("layout visual gate @single-run", () => {
@ -484,10 +462,99 @@ test.describe("layout visual gate @single-run", () => {
timeout: 15_000,
});
await expect(page.locator(".lh-work-cluster")).toBeVisible({ timeout: 15_000 });
await expect(page.locator(".lh-compact-list li").first()).toBeVisible({
timeout: 15_000,
// 대시보드 탭은 모든 폭에서 상시 노출된다 — 탭별 콘텐츠를 확인 후 기본 탭으로 복귀.
const dashTabs = page.locator(".lh-tabs");
if (await dashTabs.isVisible().catch(() => false)) {
await dashTabs.locator("button", { hasText: "기록 · 리뷰" }).click();
await expect(page.locator(".lh-compact-list li").first()).toBeVisible({
timeout: 15_000,
});
await dashTabs.locator("button", { hasText: "오늘의 회기" }).click();
await expect(page.locator(".lh-work-cluster")).toBeVisible({ timeout: 15_000 });
} else {
await expect(page.locator(".lh-compact-list li").first()).toBeVisible({
timeout: 15_000,
});
}
const shellScroll = await page.evaluate(async () => {
const nav = document.querySelector<HTMLElement>(".vg-nav");
const main = document.querySelector<HTMLElement>(".vg-main");
if (!nav || !main) return null;
const navTopBefore = nav.getBoundingClientRect().top;
const windowScrollBefore = window.scrollY;
const maxScroll = Math.max(0, main.scrollHeight - main.clientHeight);
main.scrollTop = Math.min(240, maxScroll);
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
const report = {
maxScroll,
mainScrollTop: main.scrollTop,
navTopDelta: Math.abs(nav.getBoundingClientRect().top - navTopBefore),
windowScrollDelta: Math.abs(window.scrollY - windowScrollBefore),
};
main.scrollTop = 0;
return report;
});
expect(shellScroll, "learner shell should include nav and main scroll frame").not.toBeNull();
expect(shellScroll!.maxScroll, "main content should own the vertical overflow").toBeGreaterThan(0);
expect(shellScroll!.mainScrollTop, "main content should scroll independently").toBeGreaterThan(0);
expect(shellScroll!.navTopDelta, "GNB should remain fixed while main content scrolls").toBeLessThanOrEqual(1);
expect(shellScroll!.windowScrollDelta, "document should not be the app scroll owner").toBeLessThanOrEqual(1);
});
await page.setViewportSize({ width: 1200, height: 1320 });
await expect(page.locator(".lh-tabs")).toBeVisible();
await page.screenshot({
path: path.join(SHOT_DIR, "learner-home__1200-reference-dark.png"),
fullPage: true,
});
await page.getByRole("button", { name: "라이트 모드로" }).click();
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
const lightAssets = await page.evaluate(() => {
const card = document.querySelector<HTMLElement>(".lh-metric-card");
const shellBody = document.querySelector<HTMLElement>(".vg-shell--learner-dashboard .vg-shell__body");
const nav = document.querySelector<HTMLElement>(".vg-shell--learner-dashboard .vg-nav");
return {
card: card ? getComputedStyle(card, "::after").backgroundImage : "",
cardSurface: card
? {
backgroundImage: getComputedStyle(card).backgroundImage,
backdropFilter: getComputedStyle(card).backdropFilter,
boxShadow: getComputedStyle(card).boxShadow,
}
: null,
shellBody: shellBody ? getComputedStyle(shellBody).backgroundImage : "",
nav: nav ? getComputedStyle(nav).backgroundImage : "",
};
});
expect(lightAssets.card).toContain("card-leaf-sprig-light.png");
expect(lightAssets.cardSurface).not.toBeNull();
expect(lightAssets.cardSurface!.backgroundImage.match(/linear-gradient/g)?.length ?? 0).toBeGreaterThanOrEqual(2);
expect(lightAssets.cardSurface!.backdropFilter).toContain("blur(");
expect(lightAssets.cardSurface!.boxShadow).not.toBe("none");
expect(lightAssets.shellBody).toContain("background-light-corner.png");
expect(lightAssets.nav).toContain("background-light-sidebar.png");
await page.screenshot({
path: path.join(SHOT_DIR, "learner-home__1200-reference-light.png"),
fullPage: true,
});
await page.setViewportSize({ width: 390, height: 844 });
await page.evaluate(() => window.scrollTo(0, 0));
const lightMobileReport = await auditClipping(page);
expect(lightMobileReport.horizontalOverflow, "[learner-home light @ 390] horizontal overflow").toBeLessThanOrEqual(1);
expect(lightMobileReport.offenders, "[learner-home light @ 390] clipped controls").toEqual([]);
await expect
.poll(() =>
page.locator(".vg-shell--learner-dashboard .vg-shell__body").evaluate((element) => getComputedStyle(element).backgroundImage),
)
.toContain("background-light-corner.png");
await page.screenshot({
path: path.join(SHOT_DIR, "learner-home__390-reference-light.png"),
fullPage: true,
});
await page.getByRole("button", { name: "다크 모드로" }).click();
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
});
test("session prestart stays contained across all widths", async ({ page }) => {
@ -498,7 +565,46 @@ test.describe("layout visual gate @single-run", () => {
await expect(page.getByRole("button", { name: "회기 시작" })).toBeVisible({
timeout: 15_000,
});
const plan = page.locator(".sx-prestart__plan");
await expect(plan).toContainText("시작 과업");
await expect(plan).toContainText("선택 접근");
await expect(plan).toContainText("이번 목표");
await expect(plan).toContainText("운영 기준");
const widths = await page.evaluate(() => {
const head = document.querySelector<HTMLElement>(".sx-page--prestart .sx-head");
const prestart = document.querySelector<HTMLElement>(".sx-page--prestart .sx-prestart");
return {
head: head?.getBoundingClientRect().width ?? 0,
prestart: prestart?.getBoundingClientRect().width ?? 0,
};
});
expect(widths.head).toBeGreaterThan(0);
expect(
Math.abs(widths.head - widths.prestart),
`prestart width ${widths.prestart}px should align with head width ${widths.head}px`,
).toBeLessThanOrEqual(1);
});
await page.setViewportSize({ width: 1280, height: 800 });
await page.getByRole("button", { name: "라이트 모드로" }).click();
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
await expect(page.locator(".sx-prestart")).toHaveClass(/vg-surface--panel/);
await expect(page.locator(".sx-prestart__plan")).toHaveClass(/vg-surface--inset/);
const insetSurface = await page.locator(".sx-prestart__plan").evaluate((element) => {
const style = getComputedStyle(element);
return {
borderTopWidth: style.borderTopWidth,
borderRadius: style.borderRadius,
};
});
expect(insetSurface).toEqual({ borderTopWidth: "0px", borderRadius: "0px" });
await page.screenshot({
path: path.join(SHOT_DIR, "session-prestart__1280-reference-light.png"),
fullPage: true,
});
await page.getByRole("button", { name: "다크 모드로" }).click();
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
});
test("active session keeps controls contained across all widths", async ({ page }) => {
@ -519,7 +625,9 @@ test.describe("layout visual gate @single-run", () => {
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 openReviewTabIfPresent(page, "워크시트");
await expect(page.getByText("사례개념화 워크시트")).toBeVisible();
await openReviewTabIfPresent(page, "축어록");
await expectFilledReviewLearnerWorkbench(page);
});
});
@ -530,7 +638,9 @@ test.describe("layout visual gate @single-run", () => {
await page.goto(`/teach/session/${TEACHER_REVIEW_SESSION_ID}/review`);
await gateScreen(page, "session-review-professor", async () => {
await expect(page.locator(".sr-cols--supervisor")).toBeVisible({ timeout: 15_000 });
await openReviewTabIfPresent(page, "피드백");
await expect(page.getByText("교수자 검토", { exact: true })).toBeVisible();
await openReviewTabIfPresent(page, "축어록");
await expect(page.getByText("세션 트랜스크립트")).toBeVisible();
await expectSupervisorReviewNoDeadGaps(page);
});
@ -544,7 +654,9 @@ test.describe("layout visual gate @single-run", () => {
await gateScreen(page, "session-review-empty", async () => {
await expect(page.locator(".sr-root--empty")).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("축어록 저장 후 생성")).toBeVisible();
await openReviewTabIfPresent(page, "피드백");
await expect(page.getByText("감정 타임라인 대기")).toBeVisible();
await openReviewTabIfPresent(page, "축어록");
await expectEmptyReviewNoDeadThirdColumn(page);
});
});
@ -618,16 +730,23 @@ test.describe("layout visual gate @single-run", () => {
});
});
test("persona studio stays contained across all widths", async ({ page }) => {
test("persona workspace 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();
await page.getByRole("tab", { name: "프롬프트" }).click();
const promptPreview = page.getByLabel("프롬프트 미리보기");
await expect(promptPreview).toBeVisible();
await expect(promptPreview.getByRole("textbox")).toHaveCount(0);
await gateScreen(page, "persona-workspace", async () => {
await expect(page.locator(".ps-overview")).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole("heading", { name: "페르소나 운영" })).toBeVisible();
await expect(page.getByRole("navigation", { name: "페르소나 관리 영역" })).toBeVisible();
});
});
test("persona authoring steps stay contained across all widths", async ({ page }) => {
await signInAsTeacher(page);
await page.goto("/teach/personas?view=personas&mode=create&step=edit");
await gateScreen(page, "persona-authoring", async () => {
await expect(page.locator(".ps-authoring-layout")).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole("navigation", { name: "페르소나 작성 단계" })).toBeVisible();
await expect(page.getByRole("tab", { name: "개요" })).toBeVisible();
});
});
@ -641,6 +760,69 @@ test.describe("layout visual gate @single-run", () => {
});
});
test("admin AI operations 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" },
});
await page.goto("/admin/ai");
await gateScreen(page, "admin-ai", async () => {
await expect(page.getByRole("heading", { name: "AI 운영과 DB 계량" })).toBeVisible({
timeout: 15_000,
});
await expect(page.locator(".aic-grid")).toBeVisible();
});
});
test("admin user table keeps its own horizontal scroll", async ({ page }) => {
test.setTimeout(60_000);
await page.request.post("/api/auth/dev-login", {
data: { email: "admin@twentyoz.kr", role: "admin", display_name: "E2E Admin" },
});
await page.goto("/admin/users");
await page.getByRole("tab", { name: "사용자 목록" }).evaluate((element) => element.click());
await expect(page.getByRole("table")).toBeVisible({ timeout: 15_000 });
for (const viewport of [
{ width: 1280, height: 800, label: "desktop" },
{ width: 390, height: 844, label: "mobile" },
]) {
await page.setViewportSize(viewport);
await expectNoHorizontalOverflow(page);
const scrollReport = await page.locator(".ad-user-table-scroll").evaluate((element) => {
element.scrollLeft = 0;
const report = {
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
initialScroll: element.scrollLeft,
};
element.scrollLeft = element.scrollWidth;
return { ...report, finalScroll: element.scrollLeft };
});
expect(scrollReport.scrollWidth, `[admin users @ ${viewport.label}] table min width`).toBeGreaterThan(
scrollReport.clientWidth,
);
expect(scrollReport.finalScroll, `[admin users @ ${viewport.label}] horizontal scroll`).toBeGreaterThan(
scrollReport.initialScroll,
);
await expect(page.getByRole("columnheader", { name: /작업/ })).toBeVisible();
await page.locator(".ad-user-table-scroll").evaluate((element) => {
element.scrollLeft = 0;
});
await page.screenshot({
path: path.join(SHOT_DIR, `admin-users__${viewport.width}-reference-dark.png`),
fullPage: true,
});
}
await page.setViewportSize({ width: 1280, height: 800 });
await page.getByRole("button", { name: "라이트 모드로" }).click();
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
await page.screenshot({
path: path.join(SHOT_DIR, "admin-users__1280-reference-light.png"),
fullPage: true,
});
});
test("settings 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" },
@ -649,5 +831,15 @@ test.describe("layout visual gate @single-run", () => {
await gateScreen(page, "settings", async () => {
await expect(page.locator("main").first()).toBeVisible({ timeout: 15_000 });
});
await page.setViewportSize({ width: 1280, height: 800 });
await page.getByRole("button", { name: "라이트 모드로" }).click();
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
await expect(page.locator(".vg-set__group").first()).toHaveAttribute("data-surface", "panel");
await page.screenshot({
path: path.join(SHOT_DIR, "settings__1280-reference-light.png"),
fullPage: true,
});
await page.getByRole("button", { name: "다크 모드로" }).click();
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
});
});

View file

@ -228,7 +228,10 @@ test.describe("learner app shell and session launcher", () => {
await expect(page).toHaveURL(new RegExp(`/learn/session/${persona.code}$`));
await expect(page.getByRole("button", { name: "회기 시작" })).toBeVisible();
await expectNoDocumentOverflow(page);
// 프리플라이트(목표 선택 포함)는 좁은 뷰포트에서 세로 스크롤을 허용한다 — 데스크톱만 무스크롤 계약.
if ((page.viewportSize()?.width ?? 0) > 1180) {
await expectNoDocumentOverflow(page);
}
await page.getByRole("button", { name: "회기 시작" }).click();
@ -313,7 +316,9 @@ test.describe("learner app shell and session launcher", () => {
});
await expect(page.getByText("페르소나 P9")).toHaveCount(0);
await expect(page.getByRole("button", { name: "회기 시작" })).toBeDisabled();
await expectNoDocumentOverflow(page);
if ((page.viewportSize()?.width ?? 0) > 1180) {
await expectNoDocumentOverflow(page);
}
await expectNoHorizontalOverflow(page);
});
@ -346,7 +351,9 @@ test.describe("learner app shell and session launcher", () => {
).toBeVisible();
await expect(page.getByText("카탈로그 원본을 확인하지 못해 현재 연습에 사용할 수 없습니다.")).toBeVisible();
await expect(page.getByRole("button", { name: "회기 시작" })).toBeDisabled();
await expectNoDocumentOverflow(page);
if ((page.viewportSize()?.width ?? 0) > 1180) {
await expectNoDocumentOverflow(page);
}
await expectNoHorizontalOverflow(page);
});
});

View file

@ -0,0 +1,223 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { expect, test } from "@playwright/test";
const publicApiBase = process.env.E2E_PUBLIC_API_BASE ?? "https://api-vignette.chanpaca.net";
const SHOT_DIR = path.join(process.cwd(), "node_modules", ".tmp", "public-admin-visual");
interface AuthMeResponse {
email?: string;
role?: string;
admin_access?: boolean;
super_admin?: boolean;
}
async function browserFetchJson<T>(
page: import("@playwright/test").Page,
pathName: string,
) {
return page.evaluate(
async ({ apiBase, apiPath }) => {
const response = await fetch(`${apiBase}${apiPath}`, {
credentials: "include",
headers: { accept: "application/json" },
});
const text = await response.text();
let json: unknown = null;
try {
json = text ? JSON.parse(text) : null;
} catch {
json = null;
}
return { status: response.status, ok: response.ok, text, json };
},
{ apiBase: publicApiBase, apiPath: pathName },
) as Promise<{ status: number; ok: boolean; text: string; json: T | null }>;
}
test.describe("public admin visual @public-auth", () => {
test("renders the public admin home with visible operational content", async ({
page,
}, testInfo) => {
if (!process.env.E2E_PUBLIC_STORAGE_STATE) {
throw new Error(
"Set E2E_PUBLIC_STORAGE_STATE to a storageState file captured after Google admin login.",
);
}
await fs.mkdir(SHOT_DIR, { recursive: true });
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/admin", { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle", { timeout: 15_000 }).catch(() => undefined);
const me = await browserFetchJson<AuthMeResponse>(page, "/auth/me");
expect(me.status, me.text).toBe(200);
expect(
me.json?.role === "admin" || me.json?.admin_access === true || me.json?.super_admin === true,
JSON.stringify(me.json),
).toBeTruthy();
await expect(page).toHaveURL(/\/admin(?:$|[?#])/);
await expect(page.locator(".ad-root")).toBeVisible({ timeout: 15_000 });
await expect(page.locator(".vg-shell")).toBeVisible();
await expect(page.locator(".vg-nav").getByRole("link", { name: "운영 홈" })).toBeVisible();
await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible();
await expect(page.getByRole("heading", { name: "AI 비용" })).toBeVisible();
await expect(page.getByRole("heading", { name: "가용성" })).toBeVisible();
await expect(page.getByRole("heading", { name: "운영 티켓" })).toBeVisible();
await expect(page.locator(".ad-kpi b")).toHaveCount(4);
await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0);
const visualReport = await page.evaluate(() => {
const adminRoot = document.querySelector<HTMLElement>(".ad-root");
const main = document.querySelector<HTMLElement>("main");
const heading = Array.from(document.querySelectorAll<HTMLElement>("h1,h2,h3")).find(
(node) => node.textContent?.includes("현재 서비스 상태"),
);
const visibleNodes = Array.from(
document.querySelectorAll<HTMLElement>(
".ad-root,.ad-head,.ad-kpi,.ad-panel,.ad-service,.vg-shell,.vg-nav,h1,h2,h3,p,a,button",
),
).filter((node) => {
const rect = node.getBoundingClientRect();
const style = getComputedStyle(node);
return (
rect.width > 4 &&
rect.height > 4 &&
style.display !== "none" &&
style.visibility !== "hidden" &&
style.opacity !== "0"
);
});
const rootRect = adminRoot?.getBoundingClientRect();
const mainRect = main?.getBoundingClientRect();
const headingRect = heading?.getBoundingClientRect();
return {
url: location.href,
bodyTextLength: document.body.innerText.trim().length,
visibleNodeCount: visibleNodes.length,
adminRootRect: rootRect
? { x: rootRect.x, y: rootRect.y, width: rootRect.width, height: rootRect.height }
: null,
mainRect: mainRect
? { x: mainRect.x, y: mainRect.y, width: mainRect.width, height: mainRect.height }
: null,
headingRect: headingRect
? { x: headingRect.x, y: headingRect.y, width: headingRect.width, height: headingRect.height }
: null,
horizontalOverflow:
document.documentElement.scrollWidth - document.documentElement.clientWidth,
};
});
expect(visualReport.bodyTextLength, JSON.stringify(visualReport)).toBeGreaterThan(400);
expect(visualReport.visibleNodeCount, JSON.stringify(visualReport)).toBeGreaterThan(20);
expect(visualReport.adminRootRect?.width, JSON.stringify(visualReport)).toBeGreaterThan(900);
expect(visualReport.adminRootRect?.height, JSON.stringify(visualReport)).toBeGreaterThan(500);
expect(visualReport.mainRect?.width, JSON.stringify(visualReport)).toBeGreaterThan(800);
expect(visualReport.headingRect?.y, JSON.stringify(visualReport)).toBeGreaterThanOrEqual(0);
expect(visualReport.headingRect?.y, JSON.stringify(visualReport)).toBeLessThan(900);
expect(visualReport.horizontalOverflow, JSON.stringify(visualReport)).toBeLessThanOrEqual(1);
const shotPath = path.join(SHOT_DIR, `admin-home-${testInfo.project.name}.png`);
await page.screenshot({ path: shotPath, fullPage: true });
const shot = await fs.stat(shotPath);
expect(shot.size, `admin visual screenshot was too small: ${shotPath}`).toBeGreaterThan(
80_000,
);
});
test("renders every public admin section without a silent blank pane", async ({ page }) => {
if (!process.env.E2E_PUBLIC_STORAGE_STATE) {
throw new Error(
"Set E2E_PUBLIC_STORAGE_STATE to a storageState file captured after Google admin login.",
);
}
const pageErrors: string[] = [];
const consoleErrors: string[] = [];
page.on("pageerror", (error) => pageErrors.push(error.message));
page.on("console", (message) => {
if (message.type() === "error") consoleErrors.push(message.text());
});
const sections = [
{ path: "/admin", heading: "현재 서비스 상태" },
{ path: "/admin/users", heading: "가입 승인과 권한 관리" },
{ path: "/admin/access", heading: "역할, 그룹, 접근 범위" },
{ path: "/admin/tickets", heading: "사용자 문제 큐" },
] as const;
for (const section of sections) {
await page.goto(section.path, { waitUntil: "domcontentloaded" });
await expect(page.locator(".ad-root")).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole("heading", { name: section.heading })).toBeVisible();
await expect(page.locator(".ad-diagnostic")).toHaveCount(0);
const report = await page.evaluate(() => {
const root = document.querySelector<HTMLElement>(".ad-root");
const rect = root?.getBoundingClientRect();
const visibleNodes = root
? Array.from(root.querySelectorAll<HTMLElement>("h1,h2,p,button,input,select,article,section"))
.filter((node) => {
const nodeRect = node.getBoundingClientRect();
const style = getComputedStyle(node);
return (
nodeRect.width > 1 &&
nodeRect.height > 1 &&
style.display !== "none" &&
style.visibility !== "hidden" &&
style.opacity !== "0"
);
}).length
: 0;
return {
textLength: root?.innerText.trim().length ?? 0,
visibleNodes,
width: rect?.width ?? 0,
height: rect?.height ?? 0,
scrollY: window.scrollY,
};
});
expect(report.textLength, JSON.stringify({ section, report })).toBeGreaterThan(120);
expect(report.visibleNodes, JSON.stringify({ section, report })).toBeGreaterThan(5);
expect(report.width, JSON.stringify({ section, report })).toBeGreaterThan(300);
expect(report.height, JSON.stringify({ section, report })).toBeGreaterThan(120);
expect(report.scrollY, JSON.stringify({ section, report })).toBe(0);
}
expect(pageErrors, JSON.stringify(pageErrors)).toEqual([]);
expect(consoleErrors, JSON.stringify(consoleErrors)).toEqual([]);
});
test("returns a restored admin tab to visible content after pageshow", async ({ page }) => {
if (!process.env.E2E_PUBLIC_STORAGE_STATE) {
throw new Error(
"Set E2E_PUBLIC_STORAGE_STATE to a storageState file captured after Google admin login.",
);
}
await page.goto("/admin", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible();
await expect(page.locator(".ad-root")).toBeVisible();
const beforeRestore = await page.evaluate(() => {
document.documentElement.style.minHeight = "2200px";
document.body.style.minHeight = "2200px";
window.scrollTo(0, 900);
return window.scrollY;
});
expect(beforeRestore).toBeGreaterThan(0);
await page.evaluate(() => {
window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: true }));
});
await expect
.poll(() => page.evaluate(() => window.scrollY), { timeout: 5_000 })
.toBe(0);
await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeInViewport();
});
});

View file

@ -60,10 +60,16 @@ test.describe("production readiness gates", () => {
const statusRegion = page.getByRole("region", { name: "학습 현황" });
await expect(statusRegion.getByRole("button", { name: /진행 회기 0회/ })).toBeVisible();
await expect(statusRegion.getByRole("button", { name: /리뷰 대기 0건/ })).toBeVisible();
// 중간 폭 이하에서는 기록 패널이 '기록 · 리뷰' 탭 안에 있다.
const dashTabs = page.locator(".lh-tabs");
if (await dashTabs.isVisible().catch(() => false)) {
await dashTabs.locator("button", { hasText: "기록 · 리뷰" }).click();
}
await expect(page.getByText("최근 기록이 없습니다.")).toBeVisible();
await expect(
page.locator(".lh-head").getByRole("button", { name: "학습 대상 선택" }),
).toBeInViewport();
if (await dashTabs.isVisible().catch(() => false)) {
await dashTabs.locator("button", { hasText: "오늘의 회기" }).click();
}
await expect(page.getByRole("button", { name: "학습 대상 선택" })).toBeVisible();
await expectNoHorizontalOverflow(page);
});

View file

@ -669,9 +669,14 @@ test.describe("P1 MVP core loop", () => {
await page.getByRole("button", { name: "종료하고 리뷰 보기" }).click();
await expect(page).toHaveURL(new RegExp(`/learn/session/${sessionId}/review$`));
await expect(page.getByText("내담자가 남긴 것")).toBeVisible();
await expect(page.locator(".sr-feedback").getByText(clientReply)).toBeVisible();
await expect(page.getByText("평가 AI가 저장된 축어록을 분석했습니다.")).toBeVisible();
// ≤1180 폭에서는 리뷰가 가로 탭 — 마지막 내담자 반응은 피드백 탭에 있다.
const feedbackTab = page.locator(".sr-tabs button", { hasText: "피드백" });
if (await feedbackTab.isVisible().catch(() => false)) {
await feedbackTab.click();
}
await expect(page.getByText("마지막 내담자 반응")).toBeVisible();
await expect(page.locator(".sr-feedback").getByText(clientReply)).toBeVisible();
});
test("shows AI tutor coaching in coached mode after a completed turn", async ({ page }) => {

View file

@ -529,6 +529,13 @@ async function evaluationState(page: Page, sessionId: string) {
return `pending:${body.status ?? "none"}:${body.durable ? "durable" : "cache"}:${loop}`;
}
/** 리뷰는 모든 폭에서 가로 탭으로 영역을 전환한다. */
async function openReviewTab(page: Page, label: "축어록" | "피드백" | "워크시트") {
const tabs = page.locator(".sr-tabs");
await tabs.waitFor({ state: "attached", timeout: 20_000 });
await tabs.locator("button", { hasText: label }).click();
}
test.describe("session persistence", () => {
test("persists selected CBT theory mode from session UI into DB-backed detail @single-run", async ({
page,
@ -781,7 +788,7 @@ test.describe("session persistence", () => {
});
test("persists AI tutor coaching history through reload @single-run", async ({ page }) => {
test.setTimeout(150_000);
test.setTimeout(240_000);
const healthResponse = await page.request.get("/api/health");
await expectResponseOk(healthResponse);
@ -890,7 +897,7 @@ test.describe("session persistence", () => {
});
test("persists voice nonverbal metadata into DB-backed review @single-run", async ({ page }) => {
test.setTimeout(120_000);
test.setTimeout(240_000);
const healthResponse = await page.request.get("/api/health");
await expectResponseOk(healthResponse);
@ -937,7 +944,9 @@ test.describe("session persistence", () => {
const timeout = window.setTimeout(() => {
ws.close();
finish(-1);
}, 60_000);
// dev 엔진(Claude CLI 게이트웨이) 음성 턴이 90초를 넘기도 한다 — 게이트웨이
// GENERATE_TURN_TIMEOUT_SECONDS(300초)와 정합하게 150초까지 기다린다.
}, 150_000);
ws.onopen = () => {
ws.send(JSON.stringify({ type: "audio_start", format: "webm" }));
@ -1085,7 +1094,7 @@ test.describe("session persistence", () => {
reply: events.some((event) => event.type === "reply"),
errors: events.filter((event) => event.type === "error" || event.type === "degraded").length,
};
}, { timeout: 60_000 })
}, { timeout: 90_000 })
.toEqual({ transcript: true, reply: true, errors: 0 });
const reviewResponse = await page.request.get(`/api/sessions/${sessionId}/review`);
@ -1166,6 +1175,7 @@ test.describe("session persistence", () => {
).toBeGreaterThanOrEqual(2);
await page.goto(`/learn/session/${sessionId}/review`);
await openReviewTab(page, "워크시트");
const worksheetCard = page.locator(".sr-card--worksheet");
await expect(worksheetCard).toBeVisible({ timeout: 20_000 });
await expect(worksheetCard).toContainText("축어록 기반 자동 초안");
@ -1188,10 +1198,13 @@ test.describe("session persistence", () => {
await signInAsTeacher(page);
await page.goto(`/teach/session/${sessionId}/review`);
await openReviewTab(page, "워크시트");
await expect(worksheetCard).toBeVisible({ timeout: 20_000 });
await expect(worksheetCard).toContainText("학습자 저장본 읽기 전용");
await expect(worksheetCard.locator(".sr-ws-input").first()).toHaveValue(savedWorksheetValue);
// 워크시트 검수 메모·수정요청은 교수자 검토 카드(피드백 탭)에 있다.
await openReviewTab(page, "피드백");
const teacherWorksheetNote = `보호요인 보강 요청 ${Date.now()}`;
await page.getByLabel("워크시트 검수 메모").fill(teacherWorksheetNote);
await page.getByRole("button", { name: "수정요청" }).click();
@ -1226,6 +1239,7 @@ test.describe("session persistence", () => {
const sessionId = await createEndedSessionWithoutTurn(page);
await page.goto(`/learn/session/${sessionId}/review`);
await openReviewTab(page, "피드백");
await expect(page.locator(".sr-card--prepost")).toBeVisible({ timeout: 15_000 });
await expect(page.locator(".sr-prepost__actions")).toContainText("저장된 값 기준");
@ -1272,6 +1286,7 @@ test.describe("session persistence", () => {
expect(byKey.get("training_satisfaction:post")).toBe(Number(targetValues[5]));
await page.reload();
await openReviewTab(page, "피드백");
await expect(page.locator(".sr-card--prepost")).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("3/3 쌍")).toBeVisible();
for (let index = 0; index < targetValues.length; index += 1) {

View file

@ -97,63 +97,79 @@ async function createEndedSession(page: Page) {
return session.session_id;
}
/** 리뷰는 모든 폭에서 가로 탭으로 영역을 전환한다. */
async function openReviewTab(page: Page, label: "축어록" | "피드백" | "워크시트") {
// 탭 바는 리뷰 레이아웃이 로드된 뒤에만 DOM에 존재한다 — attach를 먼저 기다린다.
const tabs = page.locator(".sr-tabs");
await tabs.waitFor({ state: "attached", timeout: 10_000 }).catch(() => {});
const tab = tabs.locator("button", { hasText: label });
if (await tab.isVisible().catch(() => false)) {
await tab.click();
}
}
async function expectReviewRegionsReachable(page: Page) {
await expect(page.locator(".sr-overview")).toBeVisible();
await expect(page.locator(".sr-tabs")).toBeVisible();
// 피드백 탭: 인사이트 흐름 + 평가 레일이 함께 도달 가능해야 한다.
await openReviewTab(page, "피드백");
await expect(page.locator(".sr-feedback")).toBeVisible();
await expect(page.locator(".sr-card--chart")).toBeVisible();
await expect(page.locator(".sr-card--flow")).toBeVisible();
await expect(page.locator(".sr-card--rubric")).toBeVisible();
await expect(page.locator(".sr-card--transcript")).toBeVisible();
await expect(page.locator(".sr-card--prepost")).toBeVisible();
const grid = await page.locator(".sr-cols").evaluate((el) => {
const insightsGrid = await page.locator(".sr-cols").evaluate((el) => {
const style = window.getComputedStyle(el);
return {
display: style.display,
areas: style.gridTemplateAreas,
columns: style.gridTemplateColumns.split(" ").filter(Boolean).length,
};
});
expect(grid.display).toBe("grid");
expect(grid.areas).toContain("overview");
expect(grid.areas).toContain("feedback");
expect(grid.areas).toContain("transcript");
expect(grid.areas).not.toContain("teacher");
expect(insightsGrid.display).toBe("grid");
expect(insightsGrid.areas).toContain("insight");
expect(insightsGrid.areas).toContain("feedback");
expect(insightsGrid.areas).not.toContain("teacher");
// 전 폭 탭 계약: 활성 탭 하나가 전폭 단일 컬럼을 쓴다(인사이트 위 / 평가 아래).
expect(insightsGrid.columns).toBe(1);
const stackGeometry = await page.locator(".sr-cols").evaluate((el) => {
const insight = el.querySelector(".sr-left")?.getBoundingClientRect();
const feedback = el.querySelector(".sr-right")?.getBoundingClientRect();
return {
insightBottom: insight ? Math.ceil(insight.bottom) : 0,
feedbackTop: feedback ? Math.floor(feedback.top) : 0,
};
});
expect(stackGeometry.insightBottom).toBeLessThanOrEqual(stackGeometry.feedbackTop + 1);
// 워크시트 탭.
await openReviewTab(page, "워크시트");
await expect(page.locator(".sr-card--worksheet")).toBeVisible();
// 축어록 탭(기본)으로 복귀 — 축어록은 전폭 단일 컬럼.
await openReviewTab(page, "축어록");
await expect(page.locator(".sr-card--transcript")).toBeVisible();
const transcriptAreas = await page
.locator(".sr-cols")
.evaluate((el) => window.getComputedStyle(el).gridTemplateAreas);
expect(transcriptAreas).toContain("transcript");
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 root = gridEl.closest(".sr-root");
const overview = gridEl.querySelector(".sr-overview")?.getBoundingClientRect();
const transcript = gridEl.querySelector(".sr-card--transcript")?.getBoundingClientRect();
const rubric = gridEl.querySelector(".sr-card--rubric")?.getBoundingClientRect();
const prepost = gridEl.querySelector(".sr-card--prepost")?.getBoundingClientRect();
const worksheet = gridEl.querySelector(".sr-card--worksheet")?.getBoundingClientRect();
const columns = window
.getComputedStyle(gridEl)
.gridTemplateColumns.split(" ")
.filter(Boolean).length;
// 리뷰 요약은 본문 그리드 위의 전폭 밴드다(도킹/스크롤 부유 금지).
const band = await page.locator(".sr-root").evaluate((rootEl) => {
const overview = rootEl.querySelector(".sr-overview")?.getBoundingClientRect();
const gridRect = rootEl.querySelector(".sr-cols")?.getBoundingClientRect();
return {
isEmpty: root?.classList.contains("sr-root--empty") ?? false,
columns,
overviewLeft: overview ? Math.floor(overview.left) : 0,
overviewRight: overview ? Math.ceil(overview.right) : 0,
transcriptLeft: transcript ? Math.floor(transcript.left) : 0,
transcriptRight: transcript ? Math.ceil(transcript.right) : 0,
rubricLeft: rubric ? Math.floor(rubric.left) : 0,
prepostLeft: prepost ? Math.floor(prepost.left) : 0,
worksheetLeft: worksheet ? Math.floor(worksheet.left) : 0,
overviewBottom: overview ? Math.ceil(overview.bottom) : 0,
gridTop: gridRect ? Math.floor(gridRect.top) : 0,
};
});
if (!railGeometry.isEmpty && railGeometry.columns === 2) {
expect(railGeometry.transcriptRight).toBeLessThanOrEqual(railGeometry.overviewLeft);
expect(railGeometry.rubricLeft).toBeGreaterThanOrEqual(railGeometry.overviewLeft);
expect(railGeometry.prepostLeft).toBeGreaterThanOrEqual(railGeometry.overviewLeft);
expect(railGeometry.worksheetLeft).toBeGreaterThanOrEqual(railGeometry.overviewLeft);
} else if (railGeometry.columns >= 3) {
expect(railGeometry.overviewRight).toBeLessThanOrEqual(railGeometry.transcriptLeft);
}
expect(band.overviewBottom).toBeLessThanOrEqual(band.gridTop + 1);
}
test.describe("session review", () => {
@ -186,6 +202,7 @@ test.describe("session review", () => {
await page.goto(`/learn/session/${sessionId}/review`);
await expect(page).toHaveURL(new RegExp(`/teach/session/${sessionId}/review$`));
await openReviewTab(page, "피드백");
await expect(page.getByText("교수자 검토", { exact: true })).toBeVisible();
expect(reviewAttempts).toBeGreaterThanOrEqual(2);
});
@ -221,6 +238,7 @@ test.describe("session review", () => {
await page.goto(`/learn/session/${sessionId}/review`);
await expect(page).toHaveURL(new RegExp(`/teach/session/${sessionId}/review$`));
await openReviewTab(page, "피드백");
await expect(page.getByText("교수자 검토", { exact: true })).toBeVisible();
await expect(page.locator(".sr-card--prepost")).toHaveCount(0);
expect(reviewAttempts).toBeGreaterThanOrEqual(2);
@ -233,6 +251,7 @@ test.describe("session review", () => {
await page.goto(`/teach/session/${sessionId}/review`);
await openReviewTab(page, "피드백");
await expect(page.getByText("AI 평가 범위", { exact: true })).toBeVisible();
await expect(page.getByText("현재 구현", { exact: true })).toBeVisible();
await expect(page.getByText(/fast-loop.*deep-loop/)).toBeVisible();
@ -348,6 +367,7 @@ test.describe("session review", () => {
await page.goto(`/teach/session/${sessionId}/review`);
await openReviewTab(page, "피드백");
await expect(page.getByText("평가 대기", { exact: true })).toBeVisible();
await expect(page.getByText("평가 AI가 저장된 축어록을 분석 중입니다.")).toBeVisible();
await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toHaveCount(0);
@ -413,6 +433,7 @@ test.describe("session review", () => {
});
await page.goto(`/teach/session/${sessionId}/review`);
await openReviewTab(page, "피드백");
await expect(page.getByText("평가 실패")).toBeVisible();
await expect(
page.getByText("저장된 축어록은 확인했지만 deep-loop 평가 AI 산출물을 표시하지 못했습니다. AI 평가 재시도가 필요합니다."),
@ -507,6 +528,7 @@ test.describe("session review", () => {
await expect(page).toHaveURL(new RegExp(`/learn/session/${sessionId}/review$`));
await expect(page.getByText("축어록 없음")).toBeVisible();
await openReviewTab(page, "피드백");
await expect(page.getByText("감정 타임라인 대기")).toBeVisible();
await expect(page.getByText("개선점 대기")).toBeVisible();
await expect(page.getByRole("button", { name: "오디오 다시 듣기" })).toBeDisabled();
@ -547,9 +569,12 @@ test.describe("session review", () => {
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("감정 반영").first()).toBeVisible();
await expect(page.getByText("AI 슈퍼바이저").first()).toBeVisible();
await openReviewTab(page, "피드백");
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("1/3 쌍")).toBeVisible();
@ -569,13 +594,12 @@ test.describe("session review", () => {
"수련만족도 사후 점수",
]);
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 openReviewTab(page, "워크시트");
await expect(page.getByText("사례개념화 워크시트")).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();
@ -624,6 +648,7 @@ test.describe("session review", () => {
"저는 익명 내담자이고 소속 기관에서 상담받고 있어요.",
);
await expect(transcript).not.toContainText("[NAME]");
await openReviewTab(page, "워크시트");
await expect(page.locator(".sr-ws-evidence").filter({ hasText: "[NAME]" })).toBeVisible();
});
@ -633,6 +658,8 @@ test.describe("session review", () => {
await routePrepostMeasures(page);
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
await expect(page.locator(".sr-overview")).toBeVisible({ timeout: 15_000 });
await openReviewTab(page, "피드백");
await expect(page.locator(".sr-card--prepost")).toBeVisible({ timeout: 15_000 });
const prepostInputs = page.locator(".sr-card--prepost input");

View file

@ -184,6 +184,13 @@ function teacherReviewResponse(sessionId: string) {
};
}
/** 리뷰는 모든 폭에서 가로 탭으로 영역을 전환한다. */
async function openReviewTab(page: Page, label: "축어록" | "피드백" | "워크시트") {
const tabs = page.locator(".sr-tabs");
await tabs.waitFor({ state: "attached", timeout: 20_000 });
await tabs.locator("button", { hasText: label }).click();
}
test.describe("teacher console", () => {
test("lets a teacher approve a pending persona review from the console @single-run", async ({
page,
@ -317,7 +324,7 @@ test.describe("teacher console", () => {
});
});
await page.goto("/teach/personas");
await page.goto("/teach/personas?view=personas");
const row = page.locator('[data-approved-persona-row="true"]').filter({ hasText: "P1" });
await expect(row).toBeVisible();
await row.getByRole("button", { name: "수정" }).click();
@ -373,7 +380,7 @@ test.describe("teacher console", () => {
});
});
await page.goto("/teach/personas");
await page.goto("/teach/personas?view=personas&mode=create&step=edit");
await page.getByLabel("표시 이름").fill("항목형 페르소나");
await page.getByRole("tab", { name: "임상" }).click();
@ -387,7 +394,7 @@ test.describe("teacher console", () => {
const forbidden = page.locator(".ps-list-field").filter({ hasText: "상담자 금기" });
await forbidden.getByRole("textbox", { name: "상담자 금기 1", exact: true }).fill("네가 예민한 거라고 단정");
await page.getByRole("button", { name: "초안 저장" }).click();
await page.getByRole("button", { name: "중간 저장" }).click();
await expect(page.getByText("P1 v1 초안을 저장했습니다.")).toBeVisible();
expect(savedPayload).toBeTruthy();
@ -408,7 +415,7 @@ test.describe("teacher console", () => {
return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([]) });
});
await page.goto("/teach/personas");
await page.goto("/teach/personas?view=personas&mode=create&step=edit");
await page.getByLabel("표시 이름").fill("프롬프트 검토 페르소나");
await page.getByRole("tab", { name: "임상" }).click();
const automaticThoughts = page.locator(".ps-list-field").filter({ hasText: "자동사고" });
@ -484,7 +491,7 @@ test.describe("teacher console", () => {
});
});
await page.goto("/teach/personas");
await page.goto("/teach/personas?view=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");
@ -492,11 +499,104 @@ test.describe("teacher console", () => {
await row.getByRole("button", { name: "삭제" }).click();
await expect(page.getByText("P7 페르소나를 보관 처리했습니다.")).toBeVisible();
await expect(page.getByText("공개 목록 없음")).toBeVisible();
await expect(page.getByText("조건에 맞는 페르소나 없음")).toBeVisible();
expect(archived).toBeTruthy();
await expectNoHorizontalOverflow(page);
});
test("navigates persona dashboard, catalog, table, detail, and review queue @single-run", async ({ page }) => {
const approvedPersona = {
persona_id: "00000000-0000-0000-0000-000000000721",
code: "P1",
version: 3,
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 reviewPersona = {
persona_id: "00000000-0000-0000-0000-000000000722",
code: "P12",
version: 1,
status: "review",
display_name: "직장 적응 훈련 페르소나",
difficulty: "moderate",
theory_target: ["cbt"],
source_provenance: "교수자 작성 초안",
is_synthetic: true,
created_at: "2026-07-15T08:00:00Z",
approved_at: 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([approvedPersona]) });
});
await page.route("**/api/personas/review", (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([reviewPersona]) }),
);
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: 1,
learner_growth: [{
learner_id: "00000000-0000-0000-0000-000000000723",
learner_label: "학습자 A",
sessions: 2,
ended_sessions: 1,
latest_at: "2026-07-15T08:20:00Z",
avg_score: 0.75,
avg_rapport: 0.4,
trend: "up",
points: [{ session_id: "persona-session-1", session_no: 1, persona_code: "P1", stage: "탐색", started_at: "2026-07-15T08:00:00Z", ended_at: "2026-07-15T08:20:00Z", score: 0.75, rapport: 0.4, technique_count: 3, watch_count: 0 }],
}],
safety_alerts: [],
pending_reviews: [],
recent_sessions: [{ session_id: "persona-session-2", learner_id: "00000000-0000-0000-0000-000000000723", learner_label: "학습자 A", persona_code: "P1", persona_name: approvedPersona.display_name, session_no: 2, status: "active", stage: "탐색", turn_count: 4, learner_turn_count: 2, client_turn_count: 2, started_at: "2026-07-15T08:30:00Z", ended_at: null }],
message: "페르소나 운영 fixture",
}),
}),
);
await page.goto("/teach/personas");
await expect(page.getByRole("heading", { name: "페르소나 운영" })).toBeVisible();
await expect(page.getByText("상담 수행 점수")).toBeVisible();
await page.getByRole("button", { name: /카탈로그/ }).first().click();
await expect(page.getByRole("heading", { name: "페르소나 카탈로그" })).toBeVisible();
await expect(page.getByText("학습자가 회기를 시작할 때 선택하는 승인본 모음입니다.")).toBeVisible();
await page.getByRole("button", { name: /페르소나/ }).first().click();
await expect(page.getByRole("button", { name: "새로운 페르소나 만들기" })).toBeVisible();
const approvedRow = page.locator('[data-approved-persona-row="true"]').filter({ hasText: "P1" });
await expect(approvedRow).toBeVisible();
await approvedRow.getByRole("button", { name: "상세" }).click();
await expect(page.getByRole("tab", { name: "소개·정보" })).toBeVisible();
await page.getByRole("tab", { name: "학습 현황" }).click();
await expect(page.getByText("75%").first()).toBeVisible();
await page.getByRole("button", { name: "목록으로" }).click();
await page.getByRole("tab", { name: /검수 현황/ }).click();
await expect(page.getByText("직장 적응 훈련 페르소나")).toBeVisible();
await page.getByRole("button", { name: "새로운 페르소나 만들기" }).click();
await expect(page.getByRole("navigation", { name: "페르소나 작성 단계" })).toBeVisible();
await page.getByRole("button", { name: /설정 임상·회기·말투 조정/ }).click();
await expect(page.getByRole("tab", { name: "개요" })).toBeVisible();
await page.getByRole("button", { name: "이전" }).click();
await expect(page).toHaveURL(/step=generate/);
await expectNoHorizontalOverflow(page);
});
test("renders real server sessions from server-owned rows", async ({ page }) => {
const sessionId = await createEndedLearnerSession(page);
await signInAsTeacher(page);
@ -768,9 +868,11 @@ test.describe("teacher console", () => {
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 openReviewTab(page, "워크시트");
await expect(page.getByText("축어록 자동 초안 읽기 전용")).toBeVisible();
await expect(page.getByText("검토 전용")).toBeVisible();
await openReviewTab(page, "피드백");
await expect(page.getByText("교수자 검토", { exact: true })).toBeVisible();
await page.getByLabel("검토 메모").fill("다음 회기에서 감정 반영을 먼저 확인");
await page.getByRole("button", { name: "검토 완료" }).click();
await expect(page.getByText("완료 시각 2026-06-27T10:00:00Z")).toBeVisible();
@ -953,6 +1055,7 @@ test.describe("teacher console", () => {
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 openReviewTab(page, "피드백");
await expect(page.getByText("평가 대기", { exact: true })).toBeVisible();
await expectNoHorizontalOverflow(page);
});
@ -1022,6 +1125,7 @@ test.describe("teacher console", () => {
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 openReviewTab(page, "워크시트");
await expect(page.getByText("검토 전용")).toBeVisible();
await expect(page.getByRole("button", { name: /^저장/ })).toHaveCount(0);
await expectNoHorizontalOverflow(page);