vignette/apps/web/e2e/session-layout.spec.ts
Yun Chan 613bcb603e
Some checks failed
API contract / OpenAPI type drift (push) Has been cancelled
아바타 v3 세션 연결 — P1 서연 리노컷 아바타와 음성 립싱크
- ClientAvatar가 리그 레지스트리(v3/rigs)로 P1을 판정해 그림 영역을 ClientAvatarV3로 그린다(로드 중·실패 시 기존 SVG, VITE_AVATAR_V3=0이면 끔)
- 발화 구동 공용 모듈 speechDriver: Lab과 세션이 함께 쓰고, TTS AudioBuffer를 오디오 시계로 표본해 립싱크·발화 동반층·지문 cue를 돌린다
- Session: TTS 재생 시작 시 발화(원문·버퍼·시작 시각) 전달, 음성 실패 시 텍스트 타이밍 발화, 정지 경로에서 해제, 개방도·겉표정 강도 전달
- 정지 화면(시작 전·썸네일)도 한 프레임을 그리고, 박스 실측 폭으로 상반신/얼굴 크롭을 고른다
- 세션 발화 E2E(오디오·실패 경로), P1 레이아웃 게이트, P1 표정 시험을 v3 기대로 갱신
2026-10-01 16:12:24 +09:00

787 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { expect, test, type Page } from "@playwright/test";
import {
completeAlliancePreCheckpoint,
expectNoDocumentOverflow,
expectNoHorizontalOverflow,
fetchAvailablePersona,
signInAsLearner,
} from "./support";
async function expectSessionPageHeightToMatchViewport(page: Page) {
const metrics = await page.evaluate(() => {
const sessionPage = document.querySelector<HTMLElement>(".sx-page--active");
const topbar = document.querySelector<HTMLElement>(".vg-topbar");
const sessionbar = document.querySelector<HTMLElement>(".sx-sessionbar");
if (!sessionPage) {
return null;
}
const pageHeight = sessionPage.getBoundingClientRect().height;
const expectedHeight = window.innerHeight;
return {
pageHeight: Math.round(pageHeight),
expectedHeight: Math.round(expectedHeight),
delta: Math.abs(pageHeight - expectedHeight),
hasTopbar: Boolean(topbar),
hasSessionbar: Boolean(sessionbar),
};
});
expect(metrics, "Expected active session page to be present").not.toBeNull();
expect(metrics!.hasTopbar, "Active session should hide the global topbar").toBe(false);
expect(metrics!.hasSessionbar, "Active session should show the in-session navigation bar").toBe(true);
expect(
metrics!.delta,
`Expected .sx-page height ${metrics!.pageHeight}px to match viewport ${metrics!.expectedHeight}px`,
).toBeLessThanOrEqual(1);
}
async function expectNoSessionInternalCopy(page: Page) {
await expect(page.getByText(/API|GET \/|OPENAI_API_KEY|API와 엔진/)).toHaveCount(0);
}
async function expectNoLocalStageDemoControl(page: Page) {
await expect(page.getByRole("button", { name: /다음 단계로/ })).toHaveCount(0);
await expect(page.locator(".sx-track__advance")).toHaveCount(0);
}
async function expectPrestartSummaryReadableOnMobile(page: Page) {
if (!(await page.evaluate(() => window.matchMedia("(max-width: 720px)").matches))) return;
await page.evaluate(() => {
const section = document.querySelector(".sx-page--prestart");
let owner = document.scrollingElement;
for (let node = section?.parentElement; node; node = node.parentElement) {
const style = window.getComputedStyle(node);
if (/(auto|scroll)/.test(style.overflowY) && node.scrollHeight > node.clientHeight + 1) {
owner = node;
break;
}
}
if (!owner) throw new Error("Expected prestart scroll owner");
owner.scrollTop = Math.round((owner.scrollHeight - owner.clientHeight) / 2);
});
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(resolve)));
const metrics = await page.evaluate(() => {
const facts = document.querySelector<HTMLElement>(".sx-page--prestart .sx-prestart__facts");
const complaint = facts?.querySelector<HTMLElement>(":scope > div:first-child");
const complaintText = complaint?.querySelector<HTMLElement>("dd");
const actions = document.querySelector<HTMLElement>(".sx-page--prestart .sx-prestart__actions");
if (!facts || !complaint || !complaintText || !actions) return null;
const complaintRect = complaint.getBoundingClientRect();
const factsRect = facts.getBoundingClientRect();
const complaintStyle = window.getComputedStyle(complaintText);
const actionsStyle = window.getComputedStyle(actions);
const actionsRect = actions.getBoundingClientRect();
const navigation = document.querySelector<HTMLElement>(".vg-nav");
const navigationRect = navigation?.getBoundingClientRect();
const startButton = actions.querySelector<HTMLElement>(".vg-btn");
const actionDescription = actions.querySelector<HTMLElement>("span");
const alphaMatch = actionsStyle.backgroundColor.match(/^rgba\([^,]+,\s*[^,]+,\s*[^,]+,\s*([0-9.]+)\)$/);
const actionsAlpha = actionsStyle.backgroundColor === "transparent"
? 0
: alphaMatch
? Number.parseFloat(alphaMatch[1])
: 1;
const hitPoints = [startButton, actionDescription, actions]
.filter((element): element is HTMLElement => Boolean(element))
.map((element) => {
const rect = element.getBoundingClientRect();
const x = Math.round(rect.left + rect.width / 2);
const y = Math.round(rect.top + rect.height / 2);
const hit = document.elementFromPoint(x, y);
return {
x,
y,
hitClass: hit instanceof HTMLElement ? hit.className : hit?.nodeName ?? null,
belongsToAction: Boolean(hit && actions.contains(hit)),
};
});
return {
complaintWidth: Math.round(complaintRect.width),
factsWidth: Math.round(factsRect.width),
fontSize: Number.parseFloat(complaintStyle.fontSize),
lineHeight: Number.parseFloat(complaintStyle.lineHeight),
actionsBackground: actionsStyle.backgroundColor,
actionsAlpha,
actionsPosition: actionsStyle.position,
actionsBottom: Math.round(actionsRect.bottom),
navigationTop: navigationRect ? Math.round(navigationRect.top) : null,
hitPoints,
};
});
expect(metrics, "Expected mobile prestart facts and start action").not.toBeNull();
expect(metrics!.complaintWidth, `Complaint should span the facts row: ${JSON.stringify(metrics)}`).toBeGreaterThanOrEqual(
metrics!.factsWidth - 2,
);
expect(metrics!.fontSize, `Complaint text is too small: ${JSON.stringify(metrics)}`).toBeGreaterThanOrEqual(14);
expect(metrics!.lineHeight, `Complaint line height is too tight: ${JSON.stringify(metrics)}`).toBeGreaterThanOrEqual(21);
expect(metrics!.actionsAlpha, `Start action must be opaque: ${JSON.stringify(metrics)}`).toBe(1);
expect(metrics!.actionsPosition, `Start action must stay at the mobile bottom: ${JSON.stringify(metrics)}`).toBe("fixed");
expect(metrics!.navigationTop, `Expected mobile navigation: ${JSON.stringify(metrics)}`).not.toBeNull();
expect(metrics!.actionsBottom, `Start action must stay above mobile navigation: ${JSON.stringify(metrics)}`).toBeLessThanOrEqual(
metrics!.navigationTop! + 1,
);
expect(metrics!.hitPoints, `Expected three start action hit points: ${JSON.stringify(metrics)}`).toHaveLength(3);
expect(metrics!.hitPoints.every((point) => point.belongsToAction), `Start action is covered at its controls: ${JSON.stringify(metrics)}`).toBe(true);
const goals = page.locator(".sx-page--prestart .sx-goals__grid button");
for (const goal of await goals.all()) {
await goal.scrollIntoViewIfNeeded();
const geometry = await goal.evaluate((button) => {
const action = document.querySelector<HTMLElement>(".sx-page--prestart .sx-prestart__actions");
const buttonRect = button.getBoundingClientRect();
const actionRect = action?.getBoundingClientRect();
return {
top: Math.round(buttonRect.top),
bottom: Math.round(buttonRect.bottom),
actionTop: actionRect ? Math.round(actionRect.top) : null,
};
});
expect(geometry.top, `Goal is above the viewport: ${JSON.stringify(geometry)}`).toBeGreaterThanOrEqual(0);
expect(geometry.actionTop, `Expected fixed start action: ${JSON.stringify(geometry)}`).not.toBeNull();
expect(geometry.bottom, `Goal is hidden behind the start action: ${JSON.stringify(geometry)}`).toBeLessThanOrEqual(
geometry.actionTop! - 1,
);
}
}
async function expectMobileContextIfNarrow(page: Page) {
const isNarrow = await page.evaluate(() =>
window.matchMedia("(max-width: 1180px)").matches,
);
if (!isNarrow) {
return;
}
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();
} else {
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("마이크");
}
async function expectSessionControlsInsideViewport(page: Page) {
const selectors = [
".sx-grid",
".sx-stage",
".sx-transcript",
".sx-transcript__scroll",
".sx-compose",
".sx-sessionbar",
".sx-controlbar",
];
const result = await page.evaluate((items) => {
const viewport = { width: window.innerWidth, height: window.innerHeight };
const checks = items.map((selector) => {
const el = document.querySelector<HTMLElement>(selector);
if (!el) return { selector, ok: false, reason: "missing" };
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;
const ok =
visible &&
rect.top >= -1 &&
rect.left >= -1 &&
rect.right <= viewport.width + 1 &&
rect.bottom <= viewport.height + 1;
return {
selector,
ok,
reason: visible ? "out-of-viewport" : "not-visible",
rect: {
top: Math.round(rect.top),
left: Math.round(rect.left),
right: Math.round(rect.right),
bottom: Math.round(rect.bottom),
width: Math.round(rect.width),
height: Math.round(rect.height),
},
};
});
return { viewport, checks };
}, selectors);
const failures = result.checks.filter((check) => !check.ok);
expect(
failures,
`Viewport ${result.viewport.width}x${result.viewport.height} clipped session controls: ${JSON.stringify(failures)}`,
).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", minTouchSize: 44 },
{ selector: ".sx-controlbar .sx-segmented", parent: ".sx-controlbar" },
{ selector: ".sx-controlbar .sx-pause", parent: ".sx-controlbar", minTouchSize: 44 },
{ selector: ".sx-controlbar .sx-end-button", parent: ".sx-controlbar", minTouchSize: 44 },
];
return controls.map(({ selector, parent, minTouchSize }) => {
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;
const touchTargetOk =
minTouchSize === undefined || (rect.width >= minTouchSize && rect.height >= minTouchSize);
return {
selector,
ok: visible && insideParent && !textClipped && touchTargetOk,
reason: !visible
? "not-visible"
: !insideParent
? "outside-parent"
: textClipped
? "text-clipped"
: !touchTargetOk
? "touch-target-under-44px"
: "",
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 expectComposeControlsBottomAligned(page: Page) {
const result = await page.evaluate(() => {
const selectors = [
".sx-compose textarea",
".sx-coach-trigger-btn",
".sx-compose .vg-btn",
];
const controls = selectors.map((selector) => {
const element = document.querySelector<HTMLElement>(selector);
if (!element) return null;
const rect = element.getBoundingClientRect();
return {
selector,
top: rect.top,
bottom: rect.bottom,
height: rect.height,
};
});
if (controls.some((control) => control === null)) return null;
const resolvedControls = controls as Array<NonNullable<(typeof controls)[number]>>;
const bottoms = resolvedControls.map((control) => control.bottom);
return {
delta: Math.max(...bottoms) - Math.min(...bottoms),
controls: resolvedControls.map((control) => ({
selector: control.selector,
top: Math.round(control.top),
bottom: Math.round(control.bottom),
height: Math.round(control.height),
})),
};
});
expect(result, "Expected text composer controls to be present").not.toBeNull();
expect(
result!.delta,
`Expected text composer control bottoms to align: ${JSON.stringify(result!.controls)}`,
).toBeLessThanOrEqual(1);
}
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");
const center = document.querySelector<HTMLElement>(".sx-page--active .sx-col-center");
const stage = document.querySelector<HTMLElement>(".sx-page--active .sx-stage");
const transcript = document.querySelector<HTMLElement>(".sx-page--active .sx-transcript");
const scroll = document.querySelector<HTMLElement>(".sx-page--active .sx-transcript__scroll");
const compose = document.querySelector<HTMLElement>(".sx-page--active .sx-compose");
const status = document.querySelector<HTMLElement>(".sx-page--active .sx-stage__status");
const timer = document.querySelector<HTMLElement>(".sx-page--active .sx-stage__timer");
if (!grid || !center || !stage || !transcript || !scroll || !compose || !status || !timer) {
return { ok: false, reason: "missing" };
}
const gridRect = grid.getBoundingClientRect();
const centerRect = center.getBoundingClientRect();
const stageRect = stage.getBoundingClientRect();
const transcriptRect = transcript.getBoundingClientRect();
const composeRect = compose.getBoundingClientRect();
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,
stageBottom: Math.round(stageRect.bottom),
transcriptTop: Math.round(transcriptRect.top),
transcriptBottom: Math.round(transcriptRect.bottom),
composeTop: Math.round(composeRect.top),
statusText: status.textContent ?? "",
timerText: timer.textContent ?? "",
};
});
expect(result.ok, `Expected active session layout elements: ${JSON.stringify(result)}`).toBeTruthy();
if ("phone" in result && result.phone) {
expect(
Math.abs(result.gridWidth - result.centerWidth),
`Phone center column should use full grid width: ${JSON.stringify(result)}`,
).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);
expect(result.composeTop, `Compose overlaps transcript bounds: ${JSON.stringify(result)}`).toBeLessThan(result.transcriptBottom);
expect(result.statusText, `Missing visible running status: ${JSON.stringify(result)}`).toContain("회기");
expect(result.timerText, `Missing visible timer: ${JSON.stringify(result)}`).toMatch(/\d/);
}
test.describe("learner session full-screen layout", () => {
test("keeps the prestart and active session routes inside the viewport", async ({ page }) => {
await signInAsLearner(page);
const persona = await fetchAvailablePersona(page, 1);
await page.goto(`/learn/session/${persona.code}`);
await expect(page.getByRole("button", { name: "회기 시작" })).toBeVisible();
await expectNoHorizontalOverflow(page);
await expectPrestartSummaryReadableOnMobile(page);
await page.getByRole("button", { name: "회기 시작" }).click();
await completeAlliancePreCheckpoint(page);
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
await expect(page).toHaveURL(/\/learn\/session\/[0-9a-f-]+$/i);
await expect(page.locator(".sx-sessionbar")).toBeVisible();
await expect(page.getByRole("button", { name: "기록으로" })).toBeVisible();
await expect(page.locator(".sx-grid")).toBeVisible();
await expect(page.locator(".vg-topbar")).toHaveCount(0);
await expect(page.locator(".vg-nav")).toHaveCount(0);
await expect(page.locator(".vg-main")).toHaveClass(/(^|\s)vg-main--bleed(\s|$)/);
await expect(page.locator(".vg-shell__body")).toHaveClass(/(^|\s)vg-shell__body--bare(\s|$)/);
await expectNoSessionInternalCopy(page);
await expectNoLocalStageDemoControl(page);
await expectNoDocumentOverflow(page);
await expectSessionPageHeightToMatchViewport(page);
await expectMobileContextIfNarrow(page);
await expectSessionControlsInsideViewport(page);
await expectNoVisibleSessionPanelOverlap(page);
await expectMainControlsUnclipped(page);
await expectRightPanelDoesNotIntersectSessionCore(page);
await page.reload();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
await expect(page.locator(".sx-sessionbar")).toBeVisible();
await expect(page.getByRole("button", { name: "회기 시작" })).toHaveCount(0);
await expect(page).toHaveURL(/\/learn\/session\/[0-9a-f-]+$/i);
});
test("keeps critical session controls visible across dense viewport sizes", async ({ page }) => {
await signInAsLearner(page);
const persona = await fetchAvailablePersona(page, 1);
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 },
{ width: 320, height: 568 },
];
await page.setViewportSize(viewports[0]);
await page.goto(`/learn/session/${persona.code}`);
await page.getByRole("button", { name: "회기 시작" }).click();
await completeAlliancePreCheckpoint(page);
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
for (const viewport of viewports) {
await page.setViewportSize(viewport);
await page.evaluate(() => new Promise(requestAnimationFrame));
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
await expectNoDocumentOverflow(page);
await expectNoHorizontalOverflow(page);
await expectNoLocalStageDemoControl(page);
await expectSessionControlsInsideViewport(page);
await expectNoVisibleSessionPanelOverlap(page);
await expectMainControlsUnclipped(page);
await expectComposeControlsBottomAligned(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();
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();
}
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();
}
}
});
// P1 서연은 v3(리노컷) 아바타로 그린다(결정문 §8.5). 위 "dense viewport" 스윕을 P1로도
// 돌려 v3 래퍼(절대 위치·원형 클립)가 기존 SVG와 같은 레이아웃 게이트를 통과하는지 본다.
test("keeps critical session controls visible across dense viewport sizes for the v3 avatar (P1)", async ({
page,
}) => {
await signInAsLearner(page);
const viewports = [
{ width: 1366, height: 768 },
{ width: 1180, height: 768 },
{ width: 1024, height: 768 },
{ width: 881, height: 768 },
{ width: 820, height: 1180 },
{ width: 390, height: 844 },
{ width: 320, height: 568 },
];
await page.setViewportSize(viewports[0]);
await page.goto("/learn/session/P1");
await page.getByRole("button", { name: "회기 시작" }).click();
await completeAlliancePreCheckpoint(page);
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
for (const viewport of viewports) {
await page.setViewportSize(viewport);
await page.evaluate(() => new Promise(requestAnimationFrame));
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
await expectNoDocumentOverflow(page);
await expectNoHorizontalOverflow(page);
await expectSessionControlsInsideViewport(page);
await expectNoVisibleSessionPanelOverlap(page);
await expectMainControlsUnclipped(page);
await expectSessionPageHeightToMatchViewport(page);
await expectActiveSessionUsableLayout(page);
/* 320×568 저높이 폰은 session.css가 아바타 오브(.sx-orb-wrap)를 숨긴다(기존 SVG도 같다).
보일 때는 v3 래퍼가 스테이지 원 자리를 정확히 차지해야 한다. */
const orbShown = !(viewport.width === 320 && viewport.height === 568);
const stage = page.locator(".sx-page--active .vg-avatar__stage").first();
if (orbShown) {
await expect(stage).toBeVisible();
const v3 = stage.locator('[data-avatar-renderer="linocut"]');
await expect(v3).toHaveAttribute("data-load-state", "ready", { timeout: 15_000 });
const [stageBox, v3Box] = await Promise.all([stage.boundingBox(), v3.boundingBox()]);
expect(stageBox, "아바타 스테이지 좌표").not.toBeNull();
expect(v3Box, "v3 래퍼 좌표").not.toBeNull();
for (const key of ["x", "y", "width", "height"] as const) {
expect(Math.abs(stageBox![key] - v3Box![key]), `v3 래퍼 ${key}`).toBeLessThanOrEqual(1);
}
} else {
await expect(page.locator(".sx-page--active .sx-orb-wrap")).toBeHidden();
}
}
});
test("does not leave an unsaved local transcript when a text turn is rejected", 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 completeAlliancePreCheckpoint(page);
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
await page.route("**/api/sessions/*/stream", async (route) => {
await route.fulfill({
status: 503,
contentType: "application/json",
body: JSON.stringify({ detail: "engine unavailable: e2e rejection" }),
});
});
const learnerText = "오늘은 너무 힘들었어요";
const input = page.getByLabel("학습자 발화 입력");
await input.fill(learnerText);
await page.getByRole("button", { name: "보내기" }).click();
await expect(page.getByRole("alert")).toContainText("AI 엔진이 응답하지 않습니다");
await expect(input).toHaveValue(learnerText);
await expect(page.locator(".sx-utt")).toHaveCount(0);
await expect(page.locator(".sx-utt").filter({ hasText: learnerText })).toHaveCount(0);
await expect(page.getByText("내담자 응답 없음")).toHaveCount(0);
});
test("removes pending transcript when an accepted stream later errors", 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 completeAlliancePreCheckpoint(page);
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
await page.route("**/api/sessions/*/stream", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: [
"event: token",
'data: {"text":"부분 응답"}',
"",
"event: error",
'data: {"detail":"engine unavailable: e2e stream error"}',
"",
].join("\n"),
});
});
const learnerText = "스트림 중간에 실패하면 남기지 말아 주세요";
const input = page.getByLabel("학습자 발화 입력");
await input.fill(learnerText);
await page.getByRole("button", { name: "보내기" }).click();
await expect(page.getByRole("alert")).toContainText("AI 엔진이 응답하지 않습니다");
await expect(input).toHaveValue(learnerText);
await expect(page.locator(".sx-utt")).toHaveCount(0);
await expect(page.locator(".sx-utt").filter({ hasText: learnerText })).toHaveCount(0);
await expect(page.getByText("부분 응답")).toHaveCount(0);
});
});