대시보드 폴드아웃/드릴다운 정리 + 페르소나 역린·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:
parent
cb2aebd76c
commit
085460b5e0
327 changed files with 31226 additions and 1829 deletions
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
117
apps/web/e2e/avatar-expression-lab.spec.ts
Normal file
117
apps/web/e2e/avatar-expression-lab.spec.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
220
apps/web/e2e/avatar-expression.spec.ts
Normal file
220
apps/web/e2e/avatar-expression.spec.ts
Normal 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("온화함");
|
||||
});
|
||||
});
|
||||
290
apps/web/e2e/layout-visual-gate.spec.ts
Normal file
290
apps/web/e2e/layout-visual-gate.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
101
apps/web/e2e/live2d-assets.spec.ts
Normal file
101
apps/web/e2e/live2d-assets.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
186
apps/web/e2e/session-mvp.spec.ts
Normal file
186
apps/web/e2e/session-mvp.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue