대시보드 폴드아웃/드릴다운 정리 + 페르소나 역린·misconduct 반응 + 게이트웨이 격리·RAG 비차단 수정

SSOT 대시보드:
- 한신대 기술분석 PDF(19쪽) 정합성 분석 + 이번 세션 발견 섹션 추가
- 섹션 폴드아웃(접기)·상단 목차(드릴다운)·모두 펼치기/접기 — 내용 보존, 레이아웃만 정리

페르소나 반응 강화('저항·반응 조절' 핵심 차별):
- PersonaCard.triggers(역린) 필드 + CCD 핵심상처 파생 역린 블록
- L0에 무례·모욕·조롱 시 현실적 동맹 균열 반응 지침

버그·성능 수정(라이브/E2E로 포착):
- 게이트웨이 페르소나 격리: --append-system-prompt를 --system-prompt(교체)로 + --exclude-dynamic-system-prompt-sections (내담자 캐릭터 붕괴·개발맥락 누출 차단)
- RAG: 임베더 동기 로드(약 7-13초)를 _warm_rag_caches 백그라운드 warm으로(세션 생성 블로킹 회귀 수정)
- voice TTS RMS 데드힌트 제거, init_state OpennessParams 파라미터객체화
- 한국어 PII(날짜·금액·주소) 마스킹 보강
- 레이아웃 시각 게이트: 폼 컨트롤 값 스크롤 오탐 제외(7/7)

검증: 백엔드 84/84, E2E 42(데스크톱 27·모바일 11·아바타 4), 시각 게이트 7/7
This commit is contained in:
Yun Chan 2026-06-27 02:30:46 +09:00
parent cb2aebd76c
commit 085460b5e0
327 changed files with 31226 additions and 1829 deletions

View file

@ -129,6 +129,93 @@ async function openAdminAndReadUsers(page: Page) {
return (await usersResponse.json()) as AdminUsersResponse;
}
async function expectCreateUserControlsFit(page: Page, viewportWidth: number) {
const form = page.locator(".ad-user-create");
await expect(form).toBeVisible();
const clippedControls = await form.evaluate((element) => {
const formRect = element.getBoundingClientRect();
const controls = Array.from(element.querySelectorAll<HTMLElement>("input, select, button"));
return controls
.map((control) => {
const rect = control.getBoundingClientRect();
const style = window.getComputedStyle(control);
const tag = control.tagName.toLowerCase();
const visible =
style.display !== "none" &&
style.visibility !== "hidden" &&
Number(style.opacity) !== 0 &&
rect.width > 0 &&
rect.height > 0;
const outsideForm =
rect.left < formRect.left - 1 ||
rect.right > formRect.right + 1 ||
rect.top < formRect.top - 1 ||
rect.bottom > formRect.bottom + 1;
const contentClipped =
tag === "button" &&
(control.scrollWidth > control.clientWidth + 1 ||
control.scrollHeight > control.clientHeight + 1);
return {
tag,
label: control.getAttribute("aria-label") ?? control.textContent?.replace(/\s+/g, " ").trim(),
left: Math.floor(rect.left),
right: Math.ceil(rect.right),
width: Math.ceil(rect.width),
outsideForm,
contentClipped,
visible,
};
})
.filter((control) => control.visible && (control.outsideForm || control.contentClipped));
});
expect(
clippedControls,
`Create-user controls clipped at ${viewportWidth}px: ${JSON.stringify(clippedControls)}`,
).toEqual([]);
}
async function expectVisibleButtonsFit(page: Page, selector: string, context: string) {
const clippedButtons = await page.locator(selector).evaluateAll((buttons) =>
buttons
.map((button) => {
const rect = button.getBoundingClientRect();
const owner = button.closest<HTMLElement>(".ad-user,.ad-user-create") ?? button.parentElement;
const ownerRect = owner?.getBoundingClientRect();
const style = window.getComputedStyle(button);
const visible =
style.display !== "none" &&
style.visibility !== "hidden" &&
Number(style.opacity) !== 0 &&
rect.width > 0 &&
rect.height > 0;
const contentClipped =
button.scrollWidth > button.clientWidth + 1 ||
button.scrollHeight > button.clientHeight + 1;
const outsideOwner = ownerRect
? rect.left < ownerRect.left - 1 ||
rect.right > ownerRect.right + 1 ||
rect.top < ownerRect.top - 1 ||
rect.bottom > ownerRect.bottom + 1
: false;
return {
text: button.textContent?.replace(/\s+/g, " ").trim(),
width: Math.ceil(rect.width),
contentClipped,
outsideOwner,
visible,
};
})
.filter((button) => button.visible && (button.contentClipped || button.outsideOwner)),
);
expect(clippedButtons, `${context}: ${JSON.stringify(clippedButtons)}`).toEqual([]);
}
test.describe("admin route", () => {
test.beforeEach(async ({ page }) => {
await useRealApi(page);
@ -229,6 +316,7 @@ test.describe("admin route", () => {
const card = page.locator(".ad-user").filter({ hasText: email });
await expect(card).toBeVisible();
await expect(card).toContainText(displayName);
await expectVisibleButtonsFit(page, ".ad-user__actions .vg-btn", "admin user action buttons");
const nextName = `교수자 ${testInfo.project.name}`;
const nameInput = card.getByLabel(`${email} 표시 이름`);
@ -293,12 +381,39 @@ test.describe("admin route", () => {
await expect(page.locator(".ad-root")).toHaveCount(0);
});
test("does not horizontally overflow at a mobile viewport", async ({ page }) => {
test("keeps admin controls usable at a mobile viewport", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await signInAsAdmin(page);
await openAdminAndReadHealth(page);
const users = await openAdminAndReadUsers(page);
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");
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 expectVisibleButtonsFit(page, ".ad-user__actions .vg-btn", "mobile admin user actions");
}
});
test("keeps the create-user form contained at tablet widths", async ({ page }) => {
await signInAsAdmin(page);
for (const width of [861, 900, 1024]) {
await page.setViewportSize({ width, height: 900 });
await openAdminAndReadUsers(page);
await expectNoHorizontalOverflow(page);
await expectCreateUserControlsFit(page, width);
}
});
});

View file

@ -0,0 +1,117 @@
import { expect, test } from "@playwright/test";
const PERSONAS = [
{ code: "P4", model: "vignette-p4-live2d", defaultExpression: "anxious" },
{ code: "P5", model: "vignette-p5-live2d", defaultExpression: "guarded" },
{ code: "P6", model: "vignette-p6-live2d", defaultExpression: "conflicted" },
{ code: "P7", model: "vignette-p7-live2d", defaultExpression: "tired" },
] as const;
const REQUIRED_EXPRESSIONS = ["joy", "sad", "angry", "rage"] as const;
test.describe("avatar expression lab", () => {
test.beforeEach(async ({ page }) => {
await page.route("**/api/auth/me", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
user_id: "avatar-lab-learner",
email: "learner@hs.ac.kr",
display_name: "Avatar Lab Learner",
role: "learner",
cohort_ids: [],
}),
}),
);
});
test("renders every P4-P7 expression motion as a visible first-party avatar preview", async ({
page,
}) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/learn/avatar-expressions");
const lab = page.locator('[data-avatar-expression-lab="true"]');
await expect(lab).toBeVisible();
await expect(lab).toHaveAttribute("data-persona-count", "4");
await expect(lab).toHaveAttribute("data-expression-count", "28");
await expect(lab).toHaveAttribute("data-rendered-avatar-count", "112");
for (const persona of PERSONAS) {
const panel = page.locator(
`[data-persona-expression-panel="true"][data-persona-code="${persona.code}"]`,
);
const code = persona.code.toLowerCase();
await expect(panel).toHaveAttribute("data-expression-count", "28");
await expect(panel).toHaveAttribute("data-live2d-model", persona.model);
await expect(panel).toHaveAttribute(
"data-live2d-model-url",
`/live2d/personas/${code}/${code}.model3.json`,
);
await expect(panel.locator('[data-expression-card="true"]')).toHaveCount(28);
const sectionMetrics = await panel.evaluate((el) => {
const avatars = Array.from(el.querySelectorAll<HTMLElement>(".axl__card .vg-avatar"));
const invalid = avatars
.map((avatar) => {
const neck = avatar.querySelector<SVGGraphicsElement>('[data-avatar-neck="true"]');
const svg = avatar.querySelector("svg");
return {
affect: avatar.getAttribute("data-affect"),
primitives: avatar.querySelectorAll("svg path, svg ellipse, svg circle, svg line, svg rect")
.length,
neckBox: neck?.getBoundingClientRect().toJSON(),
svgBox: svg?.getBoundingClientRect().toJSON(),
};
})
.filter((item) => {
return (
item.primitives < 12 ||
!item.neckBox ||
item.neckBox.width <= 8 ||
item.neckBox.height <= 14 ||
!item.svgBox ||
item.svgBox.width <= 0 ||
item.svgBox.height <= 0
);
});
return {
avatarCount: avatars.length,
animatedCount: avatars.filter((avatar) => avatar.getAttribute("data-avatar-animated") === "true")
.length,
invalid,
};
});
expect(sectionMetrics.avatarCount, `${persona.code} rendered expression avatars`).toBe(28);
expect(sectionMetrics.animatedCount, `${persona.code} QA avatars should be static`).toBe(0);
expect(sectionMetrics.invalid, `${persona.code} visible avatar geometry`).toEqual([]);
const defaultAvatar = panel.locator(".axl__persona-head .vg-avatar").first();
await expect(defaultAvatar).toHaveAttribute("data-affect", persona.defaultExpression);
await expect(defaultAvatar).toHaveAttribute("data-live2d-model", persona.model);
await expect(defaultAvatar).toHaveAttribute("data-live2d-expression-count", "28");
for (const expression of REQUIRED_EXPRESSIONS) {
const card = panel.locator(
`[data-expression-card="true"][data-expression="${expression}"]`,
);
await expect(card).toHaveAttribute("data-motion-file", `expressions/${expression}.exp3.json`);
await expect(card).toHaveAttribute("data-fade-in-ms", "260");
const avatar = card.locator(".vg-avatar").first();
await expect(avatar).toHaveAttribute("data-affect", expression);
await expect(avatar).toHaveAttribute("data-live2d-motion", expression);
await expect(avatar).toHaveAttribute(
"data-live2d-motion-file",
`expressions/${expression}.exp3.json`,
);
await expect(avatar).toHaveAttribute("data-live2d-expression-count", "28");
await expect(avatar).toHaveAttribute("data-avatar-animated", "false");
}
}
});
});

View file

@ -0,0 +1,220 @@
import { expect, test } from "@playwright/test";
const PERSONAS = [
{
code: "P4",
display_name: "하늘(가명) · 고2 · 학업/시험 불안",
difficulty: "easy",
theory_target: ["cbt", "humanistic"],
demographics: { age_band: "16-18", sex: "female", grade: "고2", status: "재학" },
presenting_summary: "시험 불안과 완벽주의 부담",
voice_preset: null,
source: "database",
degraded: false,
expectedExpression: "anxious",
expectedModel: "vignette-p4-live2d",
},
{
code: "P5",
display_name: "도윤(가명) · 중3 · 또래관계 갈등/소외감",
difficulty: "moderate",
theory_target: ["humanistic", "cbt"],
demographics: { age_band: "14-16", sex: "male", grade: "중3", status: "재학" },
presenting_summary: "또래관계 갈등과 소외감",
voice_preset: null,
source: "database",
degraded: false,
expectedExpression: "guarded",
expectedModel: "vignette-p5-live2d",
},
{
code: "P6",
display_name: "하린(가명) · 고3 · 진로갈등",
difficulty: "moderate",
theory_target: ["humanistic", "cbt"],
demographics: { age_band: "16-18", sex: "female", grade: "고3", status: "진로갈등" },
presenting_summary: "부모 기대와 본인 욕구 사이의 진로갈등",
voice_preset: null,
source: "database",
degraded: false,
expectedExpression: "conflicted",
expectedModel: "vignette-p6-live2d",
},
{
code: "P7",
display_name: "도현(가명) · 고3 · 입시 번아웃/무기력",
difficulty: "hard",
theory_target: ["humanistic", "cbt"],
demographics: { age_band: "16-18", sex: "male", grade: "고3", status: "정시 준비" },
presenting_summary: "입시 번아웃과 무기력",
voice_preset: null,
source: "database",
degraded: false,
expectedExpression: "tired",
expectedModel: "vignette-p7-live2d",
},
] as const;
test.describe("persona avatar expression rig", () => {
test.beforeEach(async ({ page }) => {
await page.route("**/api/auth/me", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
user_id: "avatar-expression-learner",
email: "learner@hs.ac.kr",
display_name: "Avatar Expression Learner",
role: "learner",
cohort_ids: [],
}),
}),
);
await page.route("**/api/personas", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
PERSONAS.map(
({
expectedExpression: _expectedExpression,
expectedModel: _expectedModel,
...persona
}) => persona,
),
),
}),
);
});
test("renders every persona with a distinct baseline expression and at least 20 expressions", async ({
page,
}) => {
for (const persona of PERSONAS) {
await page.goto(`/learn/session/${persona.code}`);
const avatar = page.locator(`.vg-avatar[data-persona-code="${persona.code}"]`).first();
await expect(avatar).toBeVisible();
await expect(avatar).toHaveAttribute("data-affect", persona.expectedExpression);
const metrics = await avatar.evaluate((el) => ({
expressionCount: Number(el.getAttribute("data-expression-count")),
live2dSchema: el.getAttribute("data-live2d-schema"),
live2dModel: el.getAttribute("data-live2d-model"),
live2dModelUrl: el.getAttribute("data-live2d-model-url"),
live2dMotion: el.getAttribute("data-live2d-motion"),
live2dMotionFile: el.getAttribute("data-live2d-motion-file"),
live2dExpressionCount: Number(el.getAttribute("data-live2d-expression-count")),
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(),
}));
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);
expect(metrics.live2dModelUrl, `${persona.code} Live2D model URL`).toBe(
`/live2d/personas/${persona.code.toLowerCase()}/${persona.code.toLowerCase()}.model3.json`,
);
expect(metrics.live2dMotion, `${persona.code} Live2D motion`).toBe(persona.expectedExpression);
expect(metrics.live2dMotionFile, `${persona.code} Live2D motion file`).toBe(
`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);
}
});
test("keeps the active session avatar expression wired after session start", async ({ page }) => {
await page.route("**/api/sessions", async (route) => {
if (route.request().method() !== "POST") {
await route.fallback();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
session_id: "11111111-1111-4111-8111-111111111111",
case_id: "case-avatar-expression",
session_no: 1,
stage: "라포",
effective_openness: 0.2,
recall_summary: null,
degraded: false,
}),
});
});
await page.goto("/learn/session/P4");
await page.getByRole("button", { name: "회기 시작" }).click();
const activeAvatar = page.locator(".sx-page--active .vg-avatar").first();
await expect(activeAvatar).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");
await expect(activeAvatar).toHaveAttribute("data-live2d-model-url", "/live2d/personas/p4/p4.model3.json");
await expect(activeAvatar).toHaveAttribute("data-live2d-fade-in-ms", "260");
await expect(activeAvatar).toHaveAttribute("data-live2d-motion-file", "expressions/anxious.exp3.json");
await expect(activeAvatar).toHaveAttribute("data-affect", "anxious");
await expect(page.locator(".sx-stage__now")).toContainText("불안");
});
test("animates expression transitions after session openness changes", async ({ page }) => {
await page.route("**/api/sessions", async (route) => {
if (route.request().method() !== "POST") {
await route.fallback();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
session_id: "22222222-2222-4222-8222-222222222222",
case_id: "case-avatar-transition",
session_no: 1,
stage: "라포",
effective_openness: 0.2,
recall_summary: null,
degraded: false,
}),
});
});
await page.route("**/api/sessions/*/stream", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: [
"event: token",
"data: 조금은 괜찮아진 것 같아요.",
"",
"event: done",
'data: {"session_id":"22222222-2222-4222-8222-222222222222","stage":"정리","effective_openness":0.86,"safety_flagged":false}',
"",
].join("\n"),
});
});
await page.goto("/learn/session/P4");
await page.getByRole("button", { name: "회기 시작" }).click();
const activeAvatar = page.locator(".sx-page--active .vg-avatar").first();
await expect(activeAvatar).toHaveAttribute("data-live2d-motion", "anxious");
await page.getByLabel("학습자 발화 입력").fill("조금 안정된 것 같아요.");
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(page.locator(".sx-stage__now")).toContainText("온화함");
});
});

View file

@ -0,0 +1,290 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { expect, test, type Page } from "@playwright/test";
import {
expectNoHorizontalOverflow,
fetchAvailablePersona,
signInAsLearner,
signInAsTeacher,
} from "./support";
/**
* Strict visual layout gate.
*
* The layout-redesign handoff (docs/ops/layout-redesign-handoff-2026-06-26.md)
* required a human-style visual acceptance pass across the redesigned screens at
* the suggested breakpoints. This spec hardens that pass into an automated gate:
* every redesigned screen is rendered at every required width, asserted free of
* horizontal overflow and clipped primary controls, and captured as a full-page
* screenshot artifact for review. Any single failure fails the whole gate.
*/
const GATE_WIDTHS = [
{ width: 390, height: 844, label: "390-mobile" },
{ width: 720, height: 900, label: "720-phablet" },
{ width: 861, height: 900, label: "861-tablet-min" },
{ width: 900, height: 900, label: "900-tablet" },
{ width: 1024, height: 768, label: "1024-tablet-land" },
{ width: 1280, height: 800, label: "1280-laptop" },
{ width: 1440, height: 900, label: "1440-desktop" },
] as const;
const SHOT_DIR = path.join(process.cwd(), "node_modules", ".tmp", "layout-gate");
async function ensureShotDir() {
await fs.mkdir(SHOT_DIR, { recursive: true });
}
interface ClipReport {
viewport: { width: number; height: number };
horizontalOverflow: number;
offenders: Array<{
tag: string;
role: string;
className: string;
text: string;
reason: string;
left: number;
right: number;
}>;
}
/**
* Scans every visible interactive control and prominent text container for
* either (a) extending beyond the viewport horizontally, or (b) clipping its own
* content (scrollWidth/scrollHeight exceeding the client box) the two failure
* modes the redesign was meant to eliminate.
*/
async function auditClipping(page: Page): Promise<ClipReport> {
return page.evaluate(() => {
const doc = document.documentElement;
const viewport = { width: doc.clientWidth, height: window.innerHeight };
const selector = [
"button",
"a[href]",
"input",
"select",
"textarea",
"[role='tab']",
"[role='button']",
"[role='option']",
"h1",
"h2",
"h3",
".vg-btn",
].join(",");
// Walks ancestors to find the nearest box that clips overflow. Returns the
// clipping rect when that ancestor is NOT scrollable (i.e. content cut off,
// not reachable by scrolling). A scrollable carousel (overflow auto/scroll)
// legitimately holds off-screen children, so it is treated as non-clipping.
// X-axis only: offender detection compares horizontal edges, so only the
// horizontal overflow behaviour of ancestors matters. A horizontal carousel
// (overflow-x auto/scroll) holds reachable off-screen children and is fine;
// overflow-x hidden genuinely cuts content off.
function nearestHardClip(el: HTMLElement): DOMRect | null {
let node: HTMLElement | null = el.parentElement;
while (node && node !== document.body && node !== document.documentElement) {
const ox = window.getComputedStyle(node).overflowX;
if (ox === "auto" || ox === "scroll") return null; // reachable by scroll
if (ox === "hidden" || ox === "clip") return node.getBoundingClientRect();
node = node.parentElement;
}
return null;
}
const offenders: ClipReport["offenders"] = [];
const nodes = Array.from(document.querySelectorAll<HTMLElement>(selector));
for (const el of nodes) {
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
const visible =
style.display !== "none" &&
style.visibility !== "hidden" &&
Number(style.opacity) !== 0 &&
rect.width > 0 &&
rect.height > 0;
if (!visible) continue;
// Clipped by a hard (non-scrollable) overflow ancestor: content is cut off.
const clipRect = nearestHardClip(el);
const clippedByAncestor =
!!clipRect && (rect.right > clipRect.right + 1 || rect.left < clipRect.left - 1);
// Content clipping: the element cannot show its own text/children — but
// intentional truncation affordances (ellipsis, -webkit-line-clamp) are
// design choices the redesign uses for dense data, not defects.
const clipsX = style.overflowX === "hidden" || style.overflowX === "clip";
const clipsY = style.overflowY === "hidden" || style.overflowY === "clip";
const lineClamp =
style.getPropertyValue("-webkit-line-clamp") || (style as unknown as { webkitLineClamp?: string }).webkitLineClamp || "none";
const hasLineClamp = lineClamp !== "none" && lineClamp !== "" && lineClamp !== "0";
const hasEllipsis = style.textOverflow === "ellipsis";
// 폼 컨트롤(input/textarea/select)은 자기 값을 *설계상* 스크롤한다(커서/키보드로 전부
// 도달 가능). 박스보다 긴 값은 잘린 결함이 아니라 정상 스크롤 UX → ellipsis/line-clamp
// 와 같은 의도된 어포던스로 보고 text-clip 판정에서 제외(clipped-by-ancestor·가로 overflow는 유지).
const tagName = el.tagName.toLowerCase();
const isFormControl =
tagName === "input" || tagName === "textarea" || tagName === "select";
const textClippedX =
clipsX && !hasEllipsis && !isFormControl && Math.ceil(el.scrollWidth - el.clientWidth) > 1;
const textClippedY =
clipsY && !hasLineClamp && !isFormControl && Math.ceil(el.scrollHeight - el.clientHeight) > 1;
if (clippedByAncestor || textClippedX || textClippedY) {
const reasons: string[] = [];
if (clippedByAncestor) reasons.push("clipped-by-ancestor");
if (textClippedX) reasons.push("text-clipped-x");
if (textClippedY) reasons.push("text-clipped-y");
offenders.push({
tag: el.tagName.toLowerCase(),
role: el.getAttribute("role") ?? "",
className: String(el.className || "").slice(0, 80),
text: (el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 60),
reason: reasons.join(","),
left: Math.floor(rect.left),
right: Math.ceil(rect.right),
});
}
if (offenders.length >= 16) break;
}
return {
viewport,
horizontalOverflow: Math.ceil(doc.scrollWidth - doc.clientWidth),
offenders,
};
});
}
async function gateScreen(
page: Page,
screen: string,
prepareReady: () => Promise<void>,
) {
for (const vp of GATE_WIDTHS) {
await page.setViewportSize({ width: vp.width, height: vp.height });
await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => r(null))));
await prepareReady();
await expectNoHorizontalOverflow(page);
const report = await auditClipping(page);
expect(
report.horizontalOverflow,
`[${screen} @ ${vp.label}] horizontal overflow ${report.horizontalOverflow}px`,
).toBeLessThanOrEqual(1);
expect(
report.offenders,
`[${screen} @ ${vp.label}] clipped/overflowing controls: ${JSON.stringify(
report.offenders,
null,
2,
)}`,
).toEqual([]);
await page.screenshot({
path: path.join(SHOT_DIR, `${screen}__${vp.label}.png`),
fullPage: true,
});
}
}
test.describe("layout visual gate @single-run", () => {
test.describe.configure({ mode: "serial" });
test.beforeAll(async () => {
await ensureShotDir();
});
test("learner home stays contained and legible across all widths", async ({ page }) => {
await page.request.post("/api/auth/dev-login", {
data: {
email: `gate.learner.${Date.now()}@hs.ac.kr`,
role: "learner",
display_name: "이름이 아주 길게 표시되는 학습자 케이스 검증용 계정",
},
});
// Seed dense history: one active + two ended sessions.
const persona = await fetchAvailablePersona(page);
const made: string[] = [];
for (let i = 0; i < 3; i += 1) {
const res = await page.request.post("/api/sessions", {
data: { persona_code: persona.code, theory_mode: "humanistic" },
});
const body = (await res.json()) as { session_id: string };
made.push(body.session_id);
}
await page.request.post(`/api/sessions/${made[1]}/end`);
await page.request.post(`/api/sessions/${made[2]}/end`);
await page.goto("/learn");
await gateScreen(page, "learner-home", async () => {
await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 });
});
});
test("session prestart stays contained across all widths", async ({ page }) => {
await signInAsLearner(page);
const persona = await fetchAvailablePersona(page, 1);
await page.goto(`/learn/session/${persona.code}`);
await gateScreen(page, "session-prestart", async () => {
await expect(page.getByRole("button", { name: "회기 시작" })).toBeVisible({
timeout: 15_000,
});
});
});
test("active session keeps controls contained across all widths", async ({ page }) => {
await signInAsLearner(page);
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--active")).toBeVisible({ timeout: 15_000 });
await gateScreen(page, "session-active", async () => {
await expect(page.locator(".sx-page--active")).toBeVisible();
});
});
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 gateScreen(page, "session-review", async () => {
await expect(page.locator(".sr-overview")).toBeVisible({ timeout: 15_000 });
});
});
test("professor console stays contained across all widths", async ({ page }) => {
await signInAsTeacher(page);
await page.goto("/teach");
await gateScreen(page, "professor", async () => {
await expect(page.locator(".pf-panel, .pf-shell, main").first()).toBeVisible({
timeout: 15_000,
});
});
});
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" },
});
await page.goto("/admin");
await gateScreen(page, "admin", async () => {
await expect(page.locator("main").first()).toBeVisible({ timeout: 15_000 });
});
});
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" },
});
await page.goto("/settings");
await gateScreen(page, "settings", async () => {
await expect(page.locator("main").first()).toBeVisible({ timeout: 15_000 });
});
});
});

View file

@ -69,6 +69,25 @@ async function expectVisibleResumeLoadedSignal(page: import("@playwright/test").
).toBeTruthy();
}
async function expectReachableLearnerHomeLayout(page: import("@playwright/test").Page) {
await expect(page.locator(".lh-preview__main")).toBeVisible();
await expect(page.locator(".lh-activity")).toBeVisible();
await expect(page.getByRole("button", { name: "새 회기 시작" })).toBeInViewport();
const rootOverflow = await page.locator(".lh-root").evaluate((root) => {
const style = window.getComputedStyle(root);
return { overflow: style.overflow, overflowY: style.overflowY };
});
expect(rootOverflow.overflow).not.toBe("hidden");
expect(rootOverflow.overflowY).not.toBe("hidden");
await expectNoHorizontalOverflow(page);
}
function learnerActivityHeading(page: import("@playwright/test").Page) {
return page.locator(".lh-activity__head").getByText("기존 회기", { exact: true });
}
test.describe("learner app shell and session launcher", () => {
test.beforeEach(async ({ page }) => {
await useRealApi(page);
@ -86,10 +105,11 @@ test.describe("learner app shell and session launcher", () => {
await signInAsLearnerEmail(page, `learner.catalog.${Date.now()}@hs.ac.kr`, "Catalog Learner");
await page.goto("/learn");
const personas = await fetchAvailablePersonas(page);
await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 });
const launcher = page.getByRole("listbox", { name: "연습 페르소나" });
await expect(launcher).toBeVisible();
await expect(launcher.getByRole("option")).toHaveCount(personas.length);
await expect(launcher.getByRole("option")).toHaveCount(personas.length, { timeout: 15_000 });
for (const persona of personas) {
await expect(launcher.getByRole("option", { name: new RegExp(persona.code) })).toBeVisible();
await expect(launcher.getByRole("option", { name: new RegExp(persona.display_name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) })).toBeVisible();
@ -99,11 +119,15 @@ test.describe("learner app shell and session launcher", () => {
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.getByText("음성")).toBeVisible();
await page.getByText("음성").scrollIntoViewIfNeeded();
await expect(page.getByText("음성")).toBeInViewport();
await expect(page.getByRole("button", { name: "새 회기 시작" })).toBeInViewport();
await page.getByRole("button", { name: "새 회기 시작" }).scrollIntoViewIfNeeded();
await expectReachableLearnerHomeLayout(page);
const activityHeading = learnerActivityHeading(page);
await activityHeading.scrollIntoViewIfNeeded();
await expect(activityHeading).toBeInViewport();
await expectNoLearnerInternalCopy(page);
await expectNoDocumentOverflow(page);
await expectNoHorizontalOverflow(page);
});
test("routes the launcher CTA to the selected database persona", async ({ page }) => {
@ -142,12 +166,14 @@ test.describe("learner app shell and session launcher", () => {
expect(endResponse.ok(), await endResponse.text()).toBeTruthy();
await page.goto("/learn");
await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("기존 회기")).toBeVisible();
await expect(learnerActivityHeading(page)).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole("button", { name: "이어하기" })).toBeVisible();
await expect(page.getByRole("button", { name: "기록" })).toBeVisible();
await expect(page.getByRole("button", { name: "다시 연습" })).toHaveCount(2);
await expect(page.getByRole("button", { name: "새 회기 시작" })).toBeInViewport();
await expectReachableLearnerHomeLayout(page);
await page.locator(".lh-activity").scrollIntoViewIfNeeded();
await expect(page.getByRole("button", { name: "이어하기" })).toBeInViewport();
await expect(page.getByRole("button", { name: "기록" })).toBeInViewport();
await expect(page.getByRole("button", { name: "다시 연습" }).first()).toBeInViewport();
@ -167,11 +193,13 @@ test.describe("learner app shell and session launcher", () => {
await signInAsLearner(page);
await page.goto("/learn/session/P9");
await expect(page.getByText(/P9 페르소나는 현재 연습 목록에 없습니다/)).toBeVisible();
await expect(page.getByText("페르소나 P9")).toHaveCount(0);
await expect(
page.getByRole("heading", { name: "연습 대상 정보를 확인하고 있습니다." }),
).toBeVisible();
).toBeVisible({ timeout: 15_000 });
await expect(page.getByText(/P9 페르소나는 현재 연습 목록에 없습니다/)).toBeVisible({
timeout: 15_000,
});
await expect(page.getByText("페르소나 P9")).toHaveCount(0);
await expect(page.getByRole("button", { name: "회기 시작" })).toBeDisabled();
await expectNoDocumentOverflow(page);
await expectNoHorizontalOverflow(page);

View file

@ -0,0 +1,101 @@
import path from "node:path";
import { pathToFileURL } from "node:url";
import { expect, test } from "@playwright/test";
const PERSONA_CODES = ["p4", "p5", "p6", "p7"] as const;
test.describe("first-party Live2D assets", () => {
test("serves model3 manifests and exp3 expressions for every persona", async ({ request }) => {
const indexResponse = await request.get("/live2d/personas/index.json");
expect(indexResponse.ok(), await indexResponse.text()).toBeTruthy();
const index = (await indexResponse.json()) as {
personas: { code: string; model3: string; expressionCount: number }[];
};
expect(index.personas).toHaveLength(4);
for (const code of PERSONA_CODES) {
const modelResponse = await request.get(`/live2d/personas/${code}/${code}.model3.json`);
expect(modelResponse.ok(), await modelResponse.text()).toBeTruthy();
const model = (await modelResponse.json()) as {
Version: number;
Vignette: { ModelId: string; PersonaCode: string };
FileReferences: { Expressions: { Name: string; File: string }[] };
};
expect(model.Version).toBe(3);
expect(model.Vignette.ModelId).toBe(`vignette-${code}-live2d`);
expect(model.Vignette.PersonaCode.toLowerCase()).toBe(code);
expect(model.FileReferences.Expressions.length).toBeGreaterThanOrEqual(20);
expect(model.FileReferences.Expressions.map((expression) => expression.Name)).toEqual(
expect.arrayContaining(["joy", "sad", "angry", "rage"]),
);
const rage = model.FileReferences.Expressions.find((expression) => expression.Name === "rage");
expect(rage, `${code} rage expression`).toBeTruthy();
const expressionResponse = await request.get(`/live2d/personas/${code}/${rage!.File}`);
expect(expressionResponse.ok(), await expressionResponse.text()).toBeTruthy();
const expression = (await expressionResponse.json()) as {
Type: string;
Version: number;
FadeInTime: number;
FadeOutTime: number;
Parameters: { Id: string; Value: number; Blend: string }[];
};
expect(expression.Type).toBe("Live2D Expression");
expect(expression.Version).toBe(3);
expect(expression.FadeInTime).toBe(0.26);
expect(expression.FadeOutTime).toBe(0.32);
expect(expression.Parameters.length).toBeGreaterThanOrEqual(12);
expect(expression.Parameters.map((parameter) => parameter.Id)).toEqual(
expect.arrayContaining(["ParamMouthOpenY", "ParamMouthForm", "ParamEyeLOpen"]),
);
}
});
test("keeps legacy demo paths blocked while allowing first-party persona assets at the Pages function", async () => {
const functionPath = path.resolve("functions", "live2d", "[[path]].js");
const { onRequest } = (await import(pathToFileURL(functionPath).href)) as {
onRequest: (context: {
request: Request;
params: { path: string[] };
env: { ASSETS: { fetch: (request: Request) => Promise<Response> } };
}) => Promise<Response>;
};
const allowed = await onRequest({
request: new Request("https://vignette.example/live2d/personas/p4/p4.model3.json"),
params: { path: ["personas", "p4", "p4.model3.json"] },
env: {
ASSETS: {
fetch: async () => new Response("{}", { status: 200, headers: { "content-type": "application/json" } }),
},
},
});
expect(allowed.status).toBe(200);
expect(allowed.headers.get("x-robots-tag")).toBe("noindex");
const legacy = await onRequest({
request: new Request("https://vignette.example/live2d/mao/Mao.model3.json"),
params: { path: ["mao", "Mao.model3.json"] },
env: {
ASSETS: {
fetch: async () => new Response("should not be called", { status: 200 }),
},
},
});
expect(legacy.status).toBe(404);
const traversal = await onRequest({
request: new Request("https://vignette.example/live2d/personas/p4/../mao/Mao.model3.json"),
params: { path: ["personas", "p4", "..", "mao", "Mao.model3.json"] },
env: {
ASSETS: {
fetch: async () => new Response("should not be called", { status: 200 }),
},
},
});
expect(traversal.status).toBe(404);
});
});

View file

@ -55,16 +55,15 @@ async function expectMobileContextIfNarrow(page: Page) {
const isPhoneLayout = await page.evaluate(() =>
window.matchMedia("(max-width: 880px)").matches,
);
await expect(page.locator(".sx-page--active .sx-col-right")).toBeHidden();
if (isPhoneLayout) {
await expect(page.locator(".sx-page--active .sx-col-left")).toBeHidden();
await expect(page.locator(".sx-page--active .sx-col-right")).toBeHidden();
} else {
const feedbackSurface = page.locator(".sx-page--active .sx-col-right");
await expect(feedbackSurface).toBeVisible();
await expect(feedbackSurface).toBeInViewport();
await expect(page.locator(".sx-page--active .sx-col-left")).toBeVisible();
}
const mobileContext = page.getByLabel("현재 회기 요약");
await expect(mobileContext).toBeVisible();
await expect(page.locator(".sx-page--active .sx-mobile-context__brief")).toBeVisible();
await expect(mobileContext).toContainText("조용히 표시");
await expect(mobileContext).toContainText("내담자");
await expect(mobileContext).toContainText("마이크");
@ -122,6 +121,225 @@ async function expectSessionControlsInsideViewport(page: Page) {
).toEqual([]);
}
async function expectNoVisibleSessionPanelOverlap(page: Page) {
const result = await page.evaluate(() => {
const selectors = [
".sx-page--active .sx-mobile-context",
".sx-page--active .sx-col-left",
".sx-page--active .sx-stage",
".sx-page--active .sx-transcript",
".sx-page--active .sx-col-right",
".sx-page--active .sx-controlbar",
];
const panels = selectors
.map((selector) => {
const el = document.querySelector<HTMLElement>(selector);
if (!el) return null;
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
const visible =
style.display !== "none" &&
style.visibility !== "hidden" &&
Number(style.opacity) !== 0 &&
rect.width > 0 &&
rect.height > 0;
if (!visible) return null;
return {
selector,
rect: {
top: rect.top,
right: rect.right,
bottom: rect.bottom,
left: rect.left,
width: rect.width,
height: rect.height,
},
};
})
.filter((item): item is NonNullable<typeof item> => Boolean(item));
const overlaps: Array<{ a: string; b: string; area: number }> = [];
for (let i = 0; i < panels.length; i += 1) {
for (let j = i + 1; j < panels.length; j += 1) {
const a = panels[i];
const b = panels[j];
const width = Math.min(a.rect.right, b.rect.right) - Math.max(a.rect.left, b.rect.left);
const height = Math.min(a.rect.bottom, b.rect.bottom) - Math.max(a.rect.top, b.rect.top);
const area = Math.max(0, width) * Math.max(0, height);
if (area > 1) {
overlaps.push({ a: a.selector, b: b.selector, area: Math.round(area) });
}
}
}
return {
viewport: { width: window.innerWidth, height: window.innerHeight },
panels: panels.map((panel) => ({
selector: panel.selector,
rect: {
top: Math.round(panel.rect.top),
right: Math.round(panel.rect.right),
bottom: Math.round(panel.rect.bottom),
left: Math.round(panel.rect.left),
width: Math.round(panel.rect.width),
height: Math.round(panel.rect.height),
},
})),
overlaps,
};
});
expect(
result.overlaps,
`Visible session panels overlap at ${result.viewport.width}x${result.viewport.height}: ${JSON.stringify(result)}`,
).toEqual([]);
}
async function expectMainControlsUnclipped(page: Page) {
const result = await page.evaluate(() => {
const controls = [
{ selector: ".sx-compose textarea", parent: ".sx-compose" },
{ selector: ".sx-compose .vg-btn", parent: ".sx-compose" },
{ selector: ".sx-controlbar .sx-mic", parent: ".sx-controlbar" },
{ selector: ".sx-controlbar .sx-segmented", parent: ".sx-controlbar" },
{ selector: ".sx-controlbar .sx-pause", parent: ".sx-controlbar" },
{ selector: ".sx-controlbar .sx-slide-end", parent: ".sx-controlbar" },
];
return controls.map(({ selector, parent }) => {
const el = document.querySelector<HTMLElement>(selector);
const parentEl = document.querySelector<HTMLElement>(parent);
if (!el || !parentEl) return { selector, ok: false, reason: "missing" };
const rect = el.getBoundingClientRect();
const parentRect = parentEl.getBoundingClientRect();
const style = window.getComputedStyle(el);
const visible =
style.display !== "none" &&
style.visibility !== "hidden" &&
Number(style.opacity) !== 0 &&
rect.width > 0 &&
rect.height > 0;
const textCanClip = Number.parseFloat(style.fontSize) > 0;
const textClipped =
textCanClip &&
(Math.ceil(el.scrollWidth - el.clientWidth) > 1 ||
Math.ceil(el.scrollHeight - el.clientHeight) > 1);
const insideParent =
rect.top >= parentRect.top - 1 &&
rect.left >= parentRect.left - 1 &&
rect.right <= parentRect.right + 1 &&
rect.bottom <= parentRect.bottom + 1;
return {
selector,
ok: visible && insideParent && !textClipped,
reason: !visible ? "not-visible" : !insideParent ? "outside-parent" : textClipped ? "text-clipped" : "",
rect: {
top: Math.round(rect.top),
right: Math.round(rect.right),
bottom: Math.round(rect.bottom),
left: Math.round(rect.left),
width: Math.round(rect.width),
height: Math.round(rect.height),
},
parentRect: {
top: Math.round(parentRect.top),
right: Math.round(parentRect.right),
bottom: Math.round(parentRect.bottom),
left: Math.round(parentRect.left),
width: Math.round(parentRect.width),
height: Math.round(parentRect.height),
},
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth,
scrollHeight: el.scrollHeight,
clientHeight: el.clientHeight,
};
});
});
const failures = result.filter((check) => !check.ok);
expect(failures, `Main session controls are clipped: ${JSON.stringify(failures)}`).toEqual([]);
}
async function expectRightPanelDoesNotIntersectSessionCore(page: Page) {
const result = await page.evaluate(() => {
const right = document.querySelector<HTMLElement>(".sx-page--active .sx-col-right");
const coreSelectors = [
".sx-page--active .sx-col-center",
".sx-page--active .sx-stage",
".sx-page--active .sx-transcript",
".sx-page--active .sx-controlbar",
];
const toSnapshot = (rect: DOMRect) => ({
top: Math.round(rect.top),
right: Math.round(rect.right),
bottom: Math.round(rect.bottom),
left: Math.round(rect.left),
width: Math.round(rect.width),
height: Math.round(rect.height),
});
if (!right) {
return { ok: false, reason: "missing-right-panel" };
}
const rightRect = right.getBoundingClientRect();
const rightStyle = window.getComputedStyle(right);
const rightVisible =
rightStyle.display !== "none" &&
rightStyle.visibility !== "hidden" &&
Number(rightStyle.opacity) !== 0 &&
rightRect.width > 0 &&
rightRect.height > 0;
const missing: string[] = [];
const intersections: Array<{ selector: string; rect: ReturnType<typeof toSnapshot> }> = [];
for (const selector of coreSelectors) {
const el = document.querySelector<HTMLElement>(selector);
if (!el) {
missing.push(selector);
continue;
}
const rect = el.getBoundingClientRect();
const intersects =
rightVisible &&
rightRect.left < rect.right - 1 &&
rightRect.right > rect.left + 1 &&
rightRect.top < rect.bottom - 1 &&
rightRect.bottom > rect.top + 1;
if (intersects) {
intersections.push({ selector, rect: toSnapshot(rect) });
}
}
return {
ok: missing.length === 0 && intersections.length === 0,
viewport: { width: window.innerWidth, height: window.innerHeight },
narrow: window.matchMedia("(max-width: 1180px)").matches,
rightVisible,
rightRect: toSnapshot(rightRect),
missing,
intersections,
};
});
expect(
result.ok,
`Right feedback panel intersects session core: ${JSON.stringify(result)}`,
).toBeTruthy();
if ("narrow" in result && result.narrow) {
expect(
result.rightVisible,
`Right feedback panel should be hidden at <=1180px: ${JSON.stringify(result)}`,
).toBe(false);
}
}
async function expectActiveSessionUsableLayout(page: Page) {
const result = await page.evaluate(() => {
const grid = document.querySelector<HTMLElement>(".sx-page--active .sx-grid");
@ -144,12 +362,18 @@ async function expectActiveSessionUsableLayout(page: Page) {
const stageOverflow = stage.scrollHeight - stage.clientHeight;
const transcriptOverflow = transcript.scrollHeight - transcript.clientHeight;
const phone = window.matchMedia("(max-width: 880px)").matches;
const centerHeight = centerRect.height;
const stageHeight = stageRect.height;
const transcriptHeight = transcriptRect.height;
return {
ok: true,
phone,
gridWidth: Math.round(gridRect.width),
centerWidth: Math.round(centerRect.width),
centerHeight: Math.round(centerHeight),
stageHeight: Math.round(stageHeight),
transcriptHeight: Math.round(transcriptHeight),
scrollHeight: Math.round(scroll.getBoundingClientRect().height),
stageOverflow,
transcriptOverflow,
@ -170,6 +394,14 @@ async function expectActiveSessionUsableLayout(page: Page) {
).toBeLessThanOrEqual(2);
}
expect(result.scrollHeight, `Transcript viewport too small: ${JSON.stringify(result)}`).toBeGreaterThanOrEqual(110);
expect(
result.transcriptHeight,
`Transcript should be the dominant practice area: ${JSON.stringify(result)}`,
).toBeGreaterThanOrEqual(result.stageHeight);
expect(
result.transcriptHeight / result.centerHeight,
`Transcript is using too little of the center column: ${JSON.stringify(result)}`,
).toBeGreaterThanOrEqual(0.52);
expect(result.stageOverflow, `Stage content clipped: ${JSON.stringify(result)}`).toBeLessThanOrEqual(4);
expect(result.transcriptOverflow, `Transcript chrome clipped: ${JSON.stringify(result)}`).toBeLessThanOrEqual(4);
expect(result.stageBottom, `Stage overlaps transcript: ${JSON.stringify(result)}`).toBeLessThanOrEqual(result.transcriptTop);
@ -202,6 +434,9 @@ test.describe("learner session full-screen layout", () => {
await expectSessionPageHeightToMatchViewport(page);
await expectMobileContextIfNarrow(page);
await expectSessionControlsInsideViewport(page);
await expectNoVisibleSessionPanelOverlap(page);
await expectMainControlsUnclipped(page);
await expectRightPanelDoesNotIntersectSessionCore(page);
});
test("keeps critical session controls visible across dense viewport sizes", async ({ page }) => {
@ -211,8 +446,12 @@ test.describe("learner session full-screen layout", () => {
const viewports = [
{ width: 1366, height: 768 },
{ width: 1366, height: 720 },
{ width: 1180, height: 768 },
{ width: 1100, height: 768 },
{ width: 1024, height: 768 },
{ width: 1024, height: 640 },
{ width: 900, height: 768 },
{ width: 881, height: 768 },
{ width: 820, height: 1180 },
{ width: 390, height: 844 },
{ width: 375, height: 667 },
@ -233,19 +472,23 @@ test.describe("learner session full-screen layout", () => {
await expectNoHorizontalOverflow(page);
await expectNoLocalStageDemoControl(page);
await expectSessionControlsInsideViewport(page);
await expectNoVisibleSessionPanelOverlap(page);
await expectMainControlsUnclipped(page);
await expectSessionPageHeightToMatchViewport(page);
await expectActiveSessionUsableLayout(page);
await expectRightPanelDoesNotIntersectSessionCore(page);
if (viewport.width <= 1180) {
await expect(page.locator(".sx-page--active .sx-mobile-context")).toBeVisible();
}
if (viewport.width > 880 && viewport.width <= 1180) {
await expect(page.locator(".sx-page--active .sx-col-right")).toBeHidden();
} else {
await expect(page.locator(".sx-page--active .sx-col-right")).toBeVisible();
await expect(page.locator(".sx-page--active .sx-col-right")).toBeInViewport();
await expect(page.locator(".sx-page--active .sx-col-left")).toBeVisible();
}
if (viewport.width <= 880) {
await expect(page.locator(".sx-page--active .sx-col-right")).toBeHidden();
if (viewport.width > 880 && viewport.width <= 1180) {
await expect(page.locator(".sx-page--active .sx-col-left")).toBeVisible();
} else if (viewport.width <= 880) {
await expect(page.locator(".sx-page--active .sx-col-left")).toBeHidden();
}
}
});

View file

@ -0,0 +1,186 @@
import { expect, test, type Page } from "@playwright/test";
const sessionId = "mvp-session-001";
const learnerText = "요즘 많이 힘들었겠어요. 어떤 마음이 가장 크게 남아 있나요?";
const clientReply = "괜찮아요. 천천히 말해볼게요.";
async function routeMvpApi(page: Page) {
await page.route("**/api/auth/me", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
user_id: "00000000-0000-0000-0000-00000000e2e1",
email: "mvp.learner@hs.ac.kr",
display_name: "MVP Learner",
role: "learner",
cohort_ids: [],
}),
});
});
await page.route("**/api/personas", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{
code: "P1",
display_name: "민서(청소년 우울)",
difficulty: "hard",
theory_target: ["humanistic"],
demographics: { age_band: "10대" },
presenting_summary: "자퇴와 무기력감을 둘러싼 상담 연습",
voice_preset: "soft-young-fem",
source: "database",
degraded: false,
},
]),
});
});
await page.route("**/api/sessions", async (route) => {
if (route.request().method() !== "POST") {
await route.fallback();
return;
}
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({
session_id: sessionId,
case_id: "mvp-case-001",
session_no: 1,
stage: "라포",
effective_openness: 0.21,
recall_summary: null,
degraded: false,
}),
});
});
await page.route(`**/api/sessions/${sessionId}/stream`, async (route) => {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: [
"event: token",
`data: ${clientReply}`,
"",
"event: done",
`data: ${JSON.stringify({
session_id: sessionId,
stage: "탐색",
effective_openness: 0.42,
turn_seq: 1,
safety_flagged: false,
})}`,
"",
].join("\n"),
});
});
await page.route(`**/api/sessions/${sessionId}/end`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
session_id: sessionId,
session_no: 1,
digest_pending: true,
end_state: { stage: "탐색", turn_seq: 1, effective_openness: 0.42 },
}),
});
});
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
session_id: sessionId,
client: {
name: "민서",
initial: "민",
persona: "P1 · hard",
},
date: "2026-06-26",
durationLabel: "1분 02초",
durationSeconds: 62,
reachedPhase: "탐색",
sessionSignal: "종료됨",
supervisorState: "평가 완료",
supervisorName: "AI",
summary: "평가 AI가 저장된 축어록을 분석했습니다.",
phases: [{ key: "explore", label: "탐색", weight: 1 }],
phaseAxis: ["0:00", "1:02"],
valenceAxis: ["0:00", "1:02"],
clientValence: [],
counselorBaseline: [],
turns: [
{
id: "t1",
ts: "0:01",
speaker: "learner",
who: "학습자",
text: learnerText,
techniques: [],
note: null,
},
{
id: "t2",
ts: "0:04",
speaker: "client",
who: "민서",
text: clientReply,
techniques: [],
note: null,
},
],
rubric: [],
goodMoments: [{ title: "반영", body: "학습자가 정서를 먼저 반영했습니다." }],
growthPoints: [{ title: "탐색 확장", body: "다음 턴에서 구체 상황을 더 묻습니다." }],
nextLine: "그 말을 꺼내는 것도 쉽지 않았을 것 같아요.",
clientFeedback: clientReply,
audioUrl: null,
pdfExportUrl: null,
degraded: false,
reviewReady: true,
}),
});
});
}
test.describe("P1 MVP core loop", () => {
test("runs login, P1 text stream, session end, and review feedback @single-run", async ({
page,
}) => {
const diagnostics: string[] = [];
page.on("pageerror", (error) => diagnostics.push(`pageerror: ${error.message}`));
page.on("console", (message) => {
if (message.type() === "error") diagnostics.push(`console: ${message.text()}`);
});
await routeMvpApi(page);
await page.goto("/learn/session/P1");
await expect(
page.getByRole("button", { name: "회기 시작" }),
diagnostics.join("\n") || (await page.locator("#root").innerText().catch(() => "")),
).toBeVisible();
await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
await page.getByLabel("학습자 발화 입력").fill(learnerText);
await page.getByRole("button", { name: "보내기" }).click();
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 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();
});
});

View file

@ -37,6 +37,31 @@ async function createEndedSession(page: Page) {
return session.session_id;
}
async function expectReviewRegionsReachable(page: Page) {
await expect(page.locator(".sr-overview")).toBeVisible();
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();
const grid = await page.locator(".sr-cols").evaluate((el) => {
const style = window.getComputedStyle(el);
return {
display: style.display,
areas: style.gridTemplateAreas,
};
});
expect(grid.display).toBe("grid");
expect(grid.areas).toContain("overview");
expect(grid.areas).toContain("feedback");
expect(grid.areas).toContain("transcript");
await expect(page.locator(".sr-overview")).toBeInViewport();
await page.locator(".sr-card--transcript").scrollIntoViewIfNeeded();
await expect(page.locator(".sr-card--transcript")).toBeInViewport();
}
test.describe("session review", () => {
test("renders server review data without legacy transcript fixtures", async ({ page }) => {
await signInAsLearner(page);
@ -60,6 +85,7 @@ test.describe("session review", () => {
await expect(page.getByRole("button", { name: "PDF 내보내기" })).toBeDisabled();
await expect(page.getByText("32분 14초")).toHaveCount(0);
await expect(page.getByText("시연")).toHaveCount(0);
await expectReviewRegionsReachable(page);
const filterMetrics = await page.locator(".sr-chip-toggle").evaluateAll((buttons) =>
buttons.map((button) => {
const rect = button.getBoundingClientRect();

View file

@ -112,6 +112,226 @@ function hasEngineConfigRequestBody(engineMode: string, model: string) {
};
}
async function expectNoEngineSegmentClipping(page: Page) {
await expect
.poll(async () =>
page.evaluate(() => {
const issues: Array<{
target: string;
text: string;
inlineOverflow: number;
blockOverflow: number;
leftOverflow: number;
rightOverflow: number;
width: number;
height: number;
}> = [];
const segment = document.querySelector<HTMLElement>("#set-engine .vg-set__seg");
if (!segment) {
return [
{
target: "segment",
text: "missing",
inlineOverflow: 0,
blockOverflow: 0,
leftOverflow: 0,
rightOverflow: 0,
width: 0,
height: 0,
},
];
}
const segmentRect = segment.getBoundingClientRect();
const segmentInlineOverflow = Math.ceil(segment.scrollWidth - segment.clientWidth);
const segmentBlockOverflow = Math.ceil(segment.scrollHeight - segment.clientHeight);
if (segmentInlineOverflow > 1 || segmentBlockOverflow > 1) {
issues.push({
target: "segment",
text: "",
inlineOverflow: segmentInlineOverflow,
blockOverflow: segmentBlockOverflow,
leftOverflow: 0,
rightOverflow: 0,
width: Math.ceil(segmentRect.width),
height: Math.ceil(segmentRect.height),
});
}
for (const button of Array.from(
segment.querySelectorAll<HTMLElement>(".vg-set__seg-btn"),
)) {
const rect = button.getBoundingClientRect();
const inlineOverflow = Math.ceil(button.scrollWidth - button.clientWidth);
const blockOverflow = Math.ceil(button.scrollHeight - button.clientHeight);
const leftOverflow = Math.ceil(segmentRect.left - rect.left);
const rightOverflow = Math.ceil(rect.right - segmentRect.right);
if (
inlineOverflow > 1 ||
blockOverflow > 1 ||
leftOverflow > 1 ||
rightOverflow > 1
) {
issues.push({
target: "button",
text: (button.textContent ?? "").replace(/\s+/g, " ").trim(),
inlineOverflow,
blockOverflow,
leftOverflow,
rightOverflow,
width: Math.ceil(rect.width),
height: Math.ceil(rect.height),
});
}
}
return issues;
}),
)
.toEqual([]);
}
async function expectNoSettingsControlClipping(page: Page) {
await expect
.poll(async () =>
page.evaluate(() => {
const selector = [
".vg-set",
".vg-set__rail",
".vg-set__rail-card",
".vg-set__nav",
".vg-set__nav-item",
".vg-set__forms",
".vg-set__group",
".vg-set__profile",
".vg-set__field",
".vg-set__control-block",
".vg-set__opt",
".vg-set__voice",
".vg-set__range-row",
".vg-set__seg",
".vg-set__seg-btn",
".vg-btn",
".vg-toggle",
].join(",");
const issues: Array<{
target: string;
text: string;
inlineOverflow: number;
blockOverflow: number;
width: number;
height: number;
}> = [];
for (const element of Array.from(document.querySelectorAll<HTMLElement>(selector))) {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
const visible =
style.display !== "none" &&
style.visibility !== "hidden" &&
Number(style.opacity) !== 0 &&
rect.width > 0 &&
rect.height > 0;
if (!visible) continue;
const inlineOverflow = Math.ceil(element.scrollWidth - element.clientWidth);
const blockOverflow = Math.ceil(element.scrollHeight - element.clientHeight);
const allowsInlineScroll = element.classList.contains("vg-set__nav");
if ((!allowsInlineScroll && inlineOverflow > 1) || blockOverflow > 1) {
issues.push({
target: `.${Array.from(element.classList).join(".") || element.tagName.toLowerCase()}`,
text: (element.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 80),
inlineOverflow,
blockOverflow,
width: Math.ceil(rect.width),
height: Math.ceil(rect.height),
});
}
}
return issues.slice(0, 8);
}),
)
.toEqual([]);
}
async function expectPracticalSettingsLayout(page: Page, mode: "desktop" | "mobile") {
await expect
.poll(async () =>
page.evaluate((layoutMode) => {
const countColumns = (template: string) =>
template === "none" ? 0 : template.split(" ").filter(Boolean).length;
const root = document.querySelector<HTMLElement>(".vg-set");
const forms = document.querySelector<HTMLElement>(".vg-set__forms");
const railCard = document.querySelector<HTMLElement>(".vg-set__rail-card");
const nav = document.querySelector<HTMLElement>(".vg-set__nav");
const account = document.querySelector<HTMLElement>("#set-account");
const appearance = document.querySelector<HTMLElement>("#set-appearance");
const notify = document.querySelector<HTMLElement>("#set-notify");
const voice = document.querySelector<HTMLElement>("#set-voice");
if (!root || !forms || !railCard || !nav || !account || !appearance || !notify || !voice) {
return { ready: false };
}
const rootColumns = countColumns(getComputedStyle(root).gridTemplateColumns);
const formColumns = countColumns(getComputedStyle(forms).gridTemplateColumns);
const railCardRect = railCard.getBoundingClientRect();
const navRect = nav.getBoundingClientRect();
const accountStyle = getComputedStyle(account);
const appearanceRect = appearance.getBoundingClientRect();
const notifyRect = notify.getBoundingClientRect();
const voiceRect = voice.getBoundingClientRect();
if (layoutMode === "desktop") {
return {
ready: true,
rootColumns,
formColumns,
railCardVisible: railCardRect.height > 24,
shortPanelsShareRow:
Math.abs(appearanceRect.top - notifyRect.top) <= 4 &&
appearanceRect.left < notifyRect.left,
voiceBelowShortPanels:
voiceRect.top > appearanceRect.top && voiceRect.top > notifyRect.top,
};
}
return {
ready: true,
rootColumns,
formColumns,
railCardHidden: railCardRect.height === 0,
navSingleLine: navRect.height <= 54,
compactPanelPadding: Number.parseFloat(accountStyle.paddingTop) <= 14,
};
}, mode),
)
.toEqual(
mode === "desktop"
? {
ready: true,
rootColumns: 2,
formColumns: 2,
railCardVisible: true,
shortPanelsShareRow: true,
voiceBelowShortPanels: true,
}
: {
ready: true,
rootColumns: 1,
formColumns: 1,
railCardHidden: true,
navSingleLine: true,
compactPanelPadding: true,
},
);
}
function slugFor(testInfo: TestInfo) {
let hash = 0;
for (const char of testInfo.title) {
@ -396,6 +616,20 @@ test.describe("settings page", () => {
});
});
test("admin settings use a compact clipping-safe control layout", async ({
page,
}, testInfo) => {
const isMobile = testInfo.project.name.includes("mobile");
const displayName = `Settings Layout Admin ${testInfo.project.name}`;
await signInAs(page, "admin", testInfo, displayName);
await openSettings(page, { admin: true });
await expectPracticalSettingsLayout(page, isMobile ? "mobile" : "desktop");
await expectNoSettingsControlClipping(page);
await expectNoHorizontalOverflow(page);
});
test("admin engine settings panel stays readable at a mobile viewport", async ({
page,
}, testInfo) => {
@ -409,11 +643,13 @@ test.describe("settings page", () => {
const engine = page.locator("#set-engine");
await expect(engine).toBeVisible();
await expect(engine.locator("[data-engine-mode]")).toHaveCount(4);
await expectNoEngineSegmentClipping(page);
await expect(engine.locator("input").nth(0)).toBeVisible();
await expect(engine.locator("input").nth(0)).toHaveValue(engineConfig!.engine_url);
await expect(engine.locator("input").nth(1)).toBeVisible();
await expect(engine.locator("input").nth(1)).toHaveValue(engineConfig!.model);
await expectNoSettingsControlClipping(page);
await expectNoHorizontalOverflow(page);
});
@ -423,6 +659,7 @@ test.describe("settings page", () => {
await openSettings(page, { admin: true });
await expectNoSettingsControlClipping(page);
await expectNoHorizontalOverflow(page);
});
});

View file

@ -16,6 +16,44 @@ async function expectResponseOk(response: { ok: () => boolean; text: () => Promi
}
}
async function expectVisibleButtonsFit(page: Page, selector: string, context: string) {
const clippedButtons = await page.locator(selector).evaluateAll((buttons) =>
buttons
.map((button) => {
const rect = button.getBoundingClientRect();
const owner = button.closest<HTMLElement>(".pf-persona,.pf-panel") ?? button.parentElement;
const ownerRect = owner?.getBoundingClientRect();
const style = window.getComputedStyle(button);
const visible =
style.display !== "none" &&
style.visibility !== "hidden" &&
Number(style.opacity) !== 0 &&
rect.width > 0 &&
rect.height > 0;
const contentClipped =
button.scrollWidth > button.clientWidth + 1 ||
button.scrollHeight > button.clientHeight + 1;
const outsideOwner = ownerRect
? rect.left < ownerRect.left - 1 ||
rect.right > ownerRect.right + 1 ||
rect.top < ownerRect.top - 1 ||
rect.bottom > ownerRect.bottom + 1
: false;
return {
text: button.textContent?.replace(/\s+/g, " ").trim(),
width: Math.ceil(rect.width),
contentClipped,
outsideOwner,
visible,
};
})
.filter((button) => button.visible && (button.contentClipped || button.outsideOwner)),
);
expect(clippedButtons, `${context}: ${JSON.stringify(clippedButtons)}`).toEqual([]);
}
function isTeacherDashboardResponse(response: Response) {
const url = new URL(response.url());
return response.request().method() === "GET" && url.pathname.endsWith("/teacher/dashboard");
@ -37,7 +75,99 @@ async function createEndedLearnerSession(page: Page) {
return session.session_id;
}
function recentSessionFixture(index: number) {
const padded = String(index).padStart(2, "0");
return {
session_id: `mobile-readable-session-${padded}-00000000-0000-4000-9000-${padded}${padded}${padded}${padded}${padded}${padded}`,
learner_id: `learner-${padded}`,
learner_label: `E2E Learner ${padded}`,
persona_code: `P-MOBILE-${padded}`,
persona_name: `Responsive persona ${padded}`,
session_no: index,
status: index % 2 === 0 ? "active" : "ended",
stage: index % 2 === 0 ? "intervention-planning" : "rapport-and-assessment",
turn_count: 12 + index,
learner_turn_count: 6 + index,
client_turn_count: 6,
started_at: `2026-06-26T0${index}:10:00Z`,
ended_at: index % 2 === 0 ? null : `2026-06-26T0${index}:45:00Z`,
};
}
test.describe("teacher console", () => {
test("lets a teacher approve a pending persona review from the console @single-run", async ({
page,
}) => {
await signInAsTeacher(page);
const personaId = "00000000-0000-0000-0000-000000009901";
const pendingPersona = {
persona_id: personaId,
code: "P2",
version: 3,
status: "review",
display_name: "검수 대기 페르소나",
difficulty: "moderate",
theory_target: ["humanistic"],
source_provenance: "faculty import",
is_synthetic: true,
created_at: "2026-06-26T07:00:00Z",
approved_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: 0,
active_sessions: 0,
ended_sessions: 0,
pending_reviews: [],
recent_sessions: [],
message: "검토할 실제 회기가 없습니다.",
}),
}),
);
await page.route("**/api/personas/review", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([pendingPersona]),
}),
);
await page.route(`**/api/personas/review/${personaId}`, async (route) => {
expect(route.request().method()).toBe("POST");
expect(route.request().postDataJSON()).toEqual({ action: "approve" });
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
...pendingPersona,
status: "approved",
approved_at: "2026-06-26T07:01:00Z",
}),
});
});
await page.goto("/teach");
const row = page.locator('[data-persona-review-row="true"]').filter({
hasText: "검수 대기 페르소나",
});
await expect(row).toBeVisible();
await expect(row.getByText("검수 대기", { exact: true })).toBeVisible();
await expectVisibleButtonsFit(page, ".pf-persona__actions .vg-btn", "persona review actions");
await row.getByRole("button", { name: "승인" }).click();
await expect(row).toHaveCount(0);
await expect(page.getByText("검수 대기 페르소나 없음")).toBeVisible();
await expectNoHorizontalOverflow(page);
});
test("renders real server sessions from server-owned rows", async ({ page }) => {
const sessionId = await createEndedLearnerSession(page);
await signInAsTeacher(page);
@ -59,23 +189,98 @@ test.describe("teacher console", () => {
await expectNoHorizontalOverflow(page);
});
test("keeps recent sessions readable without horizontal scrolling across breakpoints", async ({
page,
}) => {
const recentSessions = [1, 2, 3].map(recentSessionFixture);
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: recentSessions.length,
active_sessions: 1,
ended_sessions: 2,
pending_reviews: [],
recent_sessions: recentSessions,
message: "Fixture-backed recent session layout check.",
}),
}),
);
await page.route("**/api/personas/review", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: "[]",
}),
);
await signInAsTeacher(page);
await page.goto("/teach");
await expect(page.locator(".pf-recent-list")).toBeVisible();
await expect(page.locator('[data-recent-session-row="true"]')).toHaveCount(
recentSessions.length,
);
await expectNoHorizontalOverflow(page);
const metrics = await page.evaluate(() => {
const doc = document.documentElement;
const list = document.querySelector<HTMLElement>(".pf-recent-list");
const header = document.querySelector<HTMLElement>(".pf-recent-head");
const row = document.querySelector<HTMLElement>('[data-recent-session-row="true"]');
const cells = Array.from(row?.querySelectorAll<HTMLElement>(".pf-recent__cell") ?? []);
if (!list || !header || !row || cells.length === 0) {
throw new Error("recent sessions list was not rendered");
}
const listRect = list.getBoundingClientRect();
const rowRect = row.getBoundingClientRect();
return {
viewportWidth: doc.clientWidth,
listOverflowX: Math.ceil(list.scrollWidth - list.clientWidth),
headerDisplay: window.getComputedStyle(header).display,
headerPosition: window.getComputedStyle(header).position,
rowDisplay: window.getComputedStyle(row).display,
gridCellCount: cells.filter((cell) => window.getComputedStyle(cell).display === "grid").length,
labels: cells.map((cell) => cell.getAttribute("data-label")),
rowRight: Math.ceil(rowRect.right),
listRight: Math.ceil(listRect.right),
};
});
expect(metrics.listOverflowX).toBeLessThanOrEqual(1);
expect(metrics.rowRight).toBeLessThanOrEqual(metrics.listRight + 1);
if (metrics.viewportWidth <= 860) {
expect(metrics.headerDisplay).toBe("none");
expect(metrics.rowDisplay).toBe("grid");
expect(metrics.gridCellCount).toBe(6);
expect(metrics.labels).toEqual(["페르소나", "상태", "단계", "턴", "시작", "종료"]);
} else {
expect(metrics.headerDisplay).toBe("grid");
expect(metrics.headerPosition).toBe("sticky");
expect(metrics.rowDisplay).toBe("grid");
expect(metrics.gridCellCount).toBe(0);
}
});
test("keeps long teacher lists in bounded panels", async ({ page }) => {
await createEndedLearnerSession(page);
await signInAsTeacher(page);
await page.goto("/teach");
await expect(page.locator(".pf-list")).toBeVisible();
await expect(page.locator(".pf-tablewrap")).toBeVisible();
await expect(page.locator(".pf-recent-list")).toBeVisible();
const metrics = await page.evaluate(() => {
const list = document.querySelector<HTMLElement>(".pf-list");
const table = document.querySelector<HTMLElement>(".pf-tablewrap");
const header = document.querySelector<HTMLElement>(".pf-table th");
if (!list || !table || !header) {
const recent = document.querySelector<HTMLElement>(".pf-recent-list");
const header = document.querySelector<HTMLElement>(".pf-recent-head");
if (!list || !recent || !header) {
throw new Error("teacher list panels were not rendered");
}
const listStyle = window.getComputedStyle(list);
const tableStyle = window.getComputedStyle(table);
const recentStyle = window.getComputedStyle(recent);
const headerStyle = window.getComputedStyle(header);
const doc = document.documentElement;
return {
@ -83,21 +288,68 @@ test.describe("teacher console", () => {
viewportHeight: doc.clientHeight,
listMaxHeight: listStyle.maxHeight,
listOverflowY: listStyle.overflowY,
tableMaxHeight: tableStyle.maxHeight,
tableOverflowY: tableStyle.overflowY,
recentMaxHeight: recentStyle.maxHeight,
recentOverflowY: recentStyle.overflowY,
headerPosition: headerStyle.position,
};
});
expect(metrics.listMaxHeight).not.toBe("none");
expect(metrics.tableMaxHeight).not.toBe("none");
expect(metrics.recentMaxHeight).not.toBe("none");
expect(["auto", "scroll"]).toContain(metrics.listOverflowY);
expect(["auto", "scroll"]).toContain(metrics.tableOverflowY);
expect(["auto", "scroll"]).toContain(metrics.recentOverflowY);
expect(metrics.headerPosition).toBe("sticky");
expect(metrics.docHeight - metrics.viewportHeight).toBeLessThanOrEqual(2200);
await expectNoHorizontalOverflow(page);
});
test("keeps persona review actions contained on mobile", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
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: 0,
pending_reviews: [],
recent_sessions: [],
message: "Mobile review action layout check.",
}),
}),
);
await page.route("**/api/personas/review", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{
persona_id: "00000000-0000-0000-0000-000000009902",
code: "P-LONG-MOBILE",
version: 11,
status: "review",
display_name: "아주 긴 이름의 모바일 검수 대상 페르소나",
difficulty: "advanced",
theory_target: ["humanistic", "cognitive-behavioral"],
source_provenance: "mobile action clipping fixture",
is_synthetic: true,
created_at: "2026-06-26T07:00:00Z",
approved_at: null,
},
]),
}),
);
await signInAsTeacher(page);
await page.goto("/teach");
await expect(page.locator('[data-persona-review-row="true"]')).toBeVisible();
await expectNoHorizontalOverflow(page);
await expectVisibleButtonsFit(page, ".pf-persona__actions .vg-btn", "mobile persona review actions");
});
test("denies learner access to the teacher dashboard API and UI", async ({ page }) => {
await signInAsLearner(page);

View file

@ -16,12 +16,35 @@ interface SpawnedApi {
stop: () => Promise<void>;
}
interface SpawnedWeb {
baseURL: string;
logs: () => string;
stop: () => Promise<void>;
}
interface VoiceProbe {
code: number;
messages: string[];
binaryChunks: number;
}
interface VoiceUiProbeMessage {
direction: "sent" | "received";
kind: "text" | "binary";
data?: string;
byteLength?: number;
}
interface VoiceUiProbeState {
getUserMediaCalls: number;
recorderStarts: number;
recorderStops: number;
trackStops: number;
audioPlays: number;
messages: VoiceUiProbeMessage[];
closeEvents: number[];
}
// This fixture intentionally starts a DB-offline API with ALLOW_SEED_PERSONA_FALLBACK=true
// so the voice provider cascade can be exercised without a Postgres dependency.
const SEEDED_VOICE_PERSONA_CODE = "P1";
@ -137,9 +160,11 @@ async function waitForApi(baseURL: string, proc: ChildProcessWithoutNullStreams)
async function startApi({
engineURL,
openAIBaseURL,
frontendBaseURL = "http://localhost:5173",
}: {
engineURL: string;
openAIBaseURL: string;
frontendBaseURL?: string;
}): Promise<SpawnedApi> {
const port = await freePort();
const baseURL = `http://127.0.0.1:${port}`;
@ -181,8 +206,8 @@ async function startApi({
ENGINE_CONNECT_TIMEOUT: "2",
OPENAI_API_KEY: "e2e-fake-key",
OPENAI_BASE_URL: `${openAIBaseURL}/v1`,
FRONTEND_BASE_URL: "http://localhost:5173",
CORS_ORIGINS: '["http://localhost:5173"]',
FRONTEND_BASE_URL: frontendBaseURL,
CORS_ORIGINS: JSON.stringify([frontendBaseURL]),
},
windowsHide: true,
},
@ -215,6 +240,73 @@ async function startApi({
};
}
async function waitForWeb(baseURL: string, proc: ChildProcessWithoutNullStreams): Promise<void> {
const started = Date.now();
let lastError = "";
while (Date.now() - started < 30_000) {
if (proc.exitCode !== null) {
throw new Error(`Web exited early with code ${proc.exitCode}: ${lastError}`);
}
try {
const response = await fetch(baseURL);
if (response.ok) return;
lastError = await response.text();
} catch (err) {
lastError = err instanceof Error ? err.message : String(err);
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(`Timed out waiting for web ${baseURL}: ${lastError}`);
}
async function startWeb({
apiBaseURL,
port,
}: {
apiBaseURL: string;
port: number;
}): Promise<SpawnedWeb> {
const baseURL = `http://127.0.0.1:${port}`;
const proc = spawn(
process.execPath,
["node_modules/vite/bin/vite.js", "--host", "127.0.0.1", "--port", String(port)],
{
cwd: ".",
env: {
...process.env,
VITE_API_BASE: apiBaseURL,
},
windowsHide: true,
},
);
let logs = "";
proc.stdout.on("data", (chunk) => {
logs += String(chunk).slice(-4000);
});
proc.stderr.on("data", (chunk) => {
logs += String(chunk).slice(-4000);
});
await waitForWeb(baseURL, proc).catch((err) => {
proc.kill();
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${logs}`);
});
return {
baseURL,
logs: () => logs,
stop: async () => {
if (proc.exitCode === null) proc.kill();
await new Promise<void>((resolve) => {
if (proc.exitCode !== null) {
resolve();
return;
}
proc.once("exit", () => resolve());
setTimeout(resolve, 3000);
});
},
};
}
async function probeVoiceCascade(page: Page, apiBaseURL: string, sessionId: string): Promise<VoiceProbe> {
return page.evaluate(
({ apiBase, sid }) =>
@ -264,6 +356,182 @@ async function probeVoiceCascade(page: Page, apiBaseURL: string, sessionId: stri
);
}
async function installSyntheticVoiceCapture(page: Page): Promise<void> {
await page.addInitScript(() => {
type ProbeMessage = {
direction: "sent" | "received";
kind: "text" | "binary";
data?: string;
byteLength?: number;
};
type ProbeState = {
getUserMediaCalls: number;
recorderStarts: number;
recorderStops: number;
trackStops: number;
audioPlays: number;
messages: ProbeMessage[];
closeEvents: number[];
};
const w = window as Window & { __voiceUiProbe?: ProbeState };
const probe: ProbeState = {
getUserMediaCalls: 0,
recorderStarts: 0,
recorderStops: 0,
trackStops: 0,
audioPlays: 0,
messages: [],
closeEvents: [],
};
w.__voiceUiProbe = probe;
const fakeTrack = {
kind: "audio",
readyState: "live",
stop() {
probe.trackStops += 1;
this.readyState = "ended";
},
};
const fakeStream = {
id: "synthetic-voice-ui-stream",
active: true,
getTracks: () => [fakeTrack],
getAudioTracks: () => [fakeTrack],
};
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: {
getUserMedia: async () => {
probe.getUserMediaCalls += 1;
return fakeStream;
},
},
});
class FakeMediaRecorder extends EventTarget {
static isTypeSupported() {
return true;
}
state = "inactive";
mimeType: string;
private timer: number | null = null;
ondataavailable: ((event: Event & { data: Blob }) => void) | null = null;
onstop: ((event: Event) => void) | null = null;
constructor(_stream: unknown, options?: { mimeType?: string }) {
super();
this.mimeType = options?.mimeType ?? "audio/webm";
}
start(timeslice?: number) {
this.state = "recording";
probe.recorderStarts += 1;
const emit = () => {
if (this.state !== "recording") return;
const data = new Blob([new Uint8Array([1, 2, 3, 4, 5, 6])], {
type: this.mimeType || "audio/webm",
});
const event = new Event("dataavailable") as Event & { data: Blob };
Object.defineProperty(event, "data", { value: data });
this.ondataavailable?.(event);
this.dispatchEvent(event);
};
window.setTimeout(emit, 25);
if (timeslice && timeslice > 0) {
this.timer = window.setInterval(emit, timeslice);
}
}
stop() {
if (this.state === "inactive") return;
this.state = "inactive";
if (this.timer !== null) {
window.clearInterval(this.timer);
this.timer = null;
}
probe.recorderStops += 1;
const event = new Event("stop");
this.onstop?.(event);
this.dispatchEvent(event);
}
}
Object.defineProperty(window, "MediaRecorder", {
configurable: true,
value: FakeMediaRecorder,
});
const NativeWebSocket = window.WebSocket;
const sizeOf = (data: unknown) => {
if (typeof data === "string") return data.length;
if (data instanceof Blob) return data.size;
if (data instanceof ArrayBuffer) return data.byteLength;
if (ArrayBuffer.isView(data)) return data.byteLength;
return 0;
};
class ProbeWebSocket extends NativeWebSocket {
constructor(url: string | URL, protocols?: string | string[]) {
if (protocols === undefined) super(url);
else super(url, protocols);
this.addEventListener("message", (event) => {
if (typeof event.data === "string") {
probe.messages.push({ direction: "received", kind: "text", data: event.data });
} else {
probe.messages.push({
direction: "received",
kind: "binary",
byteLength: sizeOf(event.data),
});
}
});
this.addEventListener("close", (event) => {
probe.closeEvents.push(event.code);
});
}
send(data: string | ArrayBufferLike | Blob | ArrayBufferView) {
if (typeof data === "string") {
probe.messages.push({ direction: "sent", kind: "text", data });
} else {
probe.messages.push({ direction: "sent", kind: "binary", byteLength: sizeOf(data) });
}
return super.send(data);
}
}
for (const key of ["CONNECTING", "OPEN", "CLOSING", "CLOSED"] as const) {
Object.defineProperty(ProbeWebSocket, key, { value: NativeWebSocket[key] });
}
Object.defineProperty(window, "WebSocket", {
configurable: true,
value: ProbeWebSocket,
});
HTMLMediaElement.prototype.play = function patchedPlay() {
probe.audioPlays += 1;
window.setTimeout(() => {
this.dispatchEvent(new Event("ended"));
}, 120);
return Promise.resolve();
};
});
}
async function readVoiceUiProbe(page: Page): Promise<VoiceUiProbeState> {
return page.evaluate(() => {
const probe = (window as Window & { __voiceUiProbe?: VoiceUiProbeState }).__voiceUiProbe;
if (!probe) throw new Error("voice UI probe was not installed");
return probe;
});
}
async function parsedVoiceUiEvents(page: Page): Promise<Array<{ type?: string; [key: string]: unknown }>> {
const probe = await readVoiceUiProbe(page);
return probe.messages
.filter((message) => message.direction === "received" && message.kind === "text" && message.data)
.map((message) => JSON.parse(message.data ?? "{}") as { type?: string; [key: string]: unknown });
}
test.describe("voice cascade success path", () => {
test("runs STT, client turn, TTS, and audio chunks against controlled providers @single-run", async ({
page,
@ -357,4 +625,188 @@ test.describe("voice cascade success path", () => {
await openai.close();
}
});
test("drives one voice turn through the Session mic UI with synthetic browser audio @single-run", async ({
page,
}, testInfo) => {
test.setTimeout(90_000);
const diagnostics: string[] = [];
page.on("pageerror", (error) => diagnostics.push(`pageerror: ${error.message}`));
page.on("console", (message) => {
if (message.type() === "error") diagnostics.push(`console: ${message.text()}`);
});
page.on("requestfailed", (request) => {
diagnostics.push(`requestfailed: ${request.method()} ${request.url()} ${request.failure()?.errorText ?? ""}`);
});
page.on("response", (response) => {
const url = response.url();
if (response.status() >= 400 && (url.includes("/sessions") || url.includes("/voice/ws"))) {
diagnostics.push(`response: ${response.status()} ${url}`);
}
});
await installSyntheticVoiceCapture(page);
const openai = await startFakeOpenAI();
const engine = await startFakeEngine();
const webPort = await freePort();
const webBaseURL = `http://127.0.0.1:${webPort}`;
const api = await startApi({
engineURL: engine.url,
openAIBaseURL: openai.url,
frontendBaseURL: webBaseURL,
});
const web = await startWeb({ apiBaseURL: api.baseURL, port: webPort });
try {
await page.route("**/personas", async (route) => {
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
headers: {
"access-control-allow-origin": web.baseURL,
"access-control-allow-credentials": "true",
},
body: JSON.stringify([
{
code: SEEDED_VOICE_PERSONA_CODE,
display_name: "Voice UI fixture",
difficulty: "hard",
theory_target: ["humanistic"],
demographics: { age_band: "teen" },
presenting_summary: "Synthetic browser audio UI proof",
voice_preset: "soft-young-fem",
source: "database",
degraded: false,
},
]),
});
});
await page.goto(`${web.baseURL}/login`, { waitUntil: "domcontentloaded" });
const browserLogin = await page.evaluate(async ({ apiBase, workerIndex }) => {
const login = await fetch(`${apiBase}/auth/dev-login`, {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({
email: `voice-ui.${workerIndex}@hs.ac.kr`,
role: "learner",
display_name: "Voice UI",
}),
});
const loginBody = await login.text();
if (!login.ok) {
return { ok: false, step: "login", status: login.status, body: loginBody };
}
const me = await fetch(`${apiBase}/auth/me`, { credentials: "include" });
const meBody = await me.text();
if (!me.ok) {
return { ok: false, step: "me", status: me.status, body: meBody };
}
return { ok: true, me: JSON.parse(meBody) as unknown };
}, {
apiBase: api.baseURL,
workerIndex: testInfo.workerIndex,
});
expect(browserLogin, api.logs()).toMatchObject({ ok: true });
await page.goto(`${web.baseURL}/learn/session/${SEEDED_VOICE_PERSONA_CODE}`);
await expect(
page.locator(".sx-prestart__actions button").first(),
diagnostics.join("\n") || (await page.locator("#root").innerText().catch(() => "")),
).toBeVisible();
await page.locator(".sx-prestart__actions button").first().click();
await expect(
page.locator(".sx-page.sx-page--active"),
[
...diagnostics,
`apiLogs=${api.logs()}`,
`pageText=${await page.locator("#root").innerText().catch(() => "")}`,
].join("\n\n"),
).toBeVisible({ timeout: 20_000 });
const mic = page.locator(".sx-mic");
await expect(mic).toBeEnabled();
await mic.click();
await expect
.poll(async () => (await readVoiceUiProbe(page)).getUserMediaCalls, { timeout: 10_000 })
.toBeGreaterThan(0);
await expect
.poll(async () => (await readVoiceUiProbe(page)).recorderStarts, { timeout: 10_000 })
.toBeGreaterThan(0);
await expect
.poll(async () => {
const probe = await readVoiceUiProbe(page);
return probe.messages.some(
(message) =>
message.direction === "sent" &&
message.kind === "text" &&
message.data?.includes('"audio_start"'),
);
}, { timeout: 10_000 })
.toBeTruthy();
await expect
.poll(async () => {
const probe = await readVoiceUiProbe(page);
return probe.messages.some(
(message) => message.direction === "sent" && message.kind === "binary",
);
}, { timeout: 10_000 })
.toBeTruthy();
await mic.click();
await expect
.poll(async () => {
const probe = await readVoiceUiProbe(page);
return probe.messages.some(
(message) =>
message.direction === "sent" &&
message.kind === "text" &&
message.data?.includes('"audio_end"'),
);
}, { timeout: 10_000 })
.toBeTruthy();
await expect
.poll(async () => {
const events = await parsedVoiceUiEvents(page);
return {
transcript: events.some((event) => event.type === "transcript"),
reply: events.some((event) => event.type === "reply"),
ttsEnd: events.some((event) => event.type === "tts_end"),
errors: events.filter((event) => event.type === "error" || event.type === "degraded"),
};
}, { timeout: 30_000 })
.toEqual({ transcript: true, reply: true, ttsEnd: true, errors: [] });
const events = await parsedVoiceUiEvents(page);
const transcript = events.find((event) => event.type === "transcript")?.text;
const reply = events.find((event) => event.type === "reply")?.text;
expect(typeof transcript).toBe("string");
expect(typeof reply).toBe("string");
await expect(page.locator(".sx-utt").filter({ hasText: String(transcript) })).toBeVisible();
await expect(page.locator(".sx-utt").filter({ hasText: String(reply) })).toBeVisible();
const probe = await readVoiceUiProbe(page);
expect(probe.audioPlays).toBeGreaterThan(0);
expect(probe.messages.some((message) => message.direction === "received" && message.kind === "binary")).toBe(
true,
);
expect(openai.requests()).toEqual(
expect.arrayContaining(["POST /v1/audio/transcriptions", "POST /v1/audio/speech"]),
);
expect(engine.requests()).toContain("POST /v1/generate");
} finally {
await web.stop();
await api.stop();
await engine.close();
await openai.close();
}
});
});