- 지원 티켓 작성 UI 신설(설정)·관리자 해결 노트 입력 신설(Admin): 서버 계약은 있었으나 웹 진입점이 없던 2결함 - a11y: outline 채널 포커스 링(ui/shell css), 세션바 44px 터치 타깃, 청록 하드코딩 그라디언트를 테마 토큰으로 교체, 설정 라벨/헤딩/대비·리뷰 44px·모바일 오버플로 수정 - uc-*.spec.ts 16테마 239 시나리오 신규(수집 1109 tests/61 files), 기존 스펙 5종 계약 드리프트 교정 - breakpoint-sweep: widthsFor 솎아내기가 실기기 대표 폭(360/390/1024)을 탈락시키는 테스트 결함 수정 — keep 시드를 전체 DEVICE_WIDTHS로, 3연폭 예외는 솎아내기 발생 여부 기준으로 - 검증: tsc PASS, 병렬 게이트 1040 passed(데스크톱 밀도 충돌 1건 해소 후 focused 68/68 GREEN), 직렬 게이트 52/5/4 삼각화 — breakpoint(테스트 결함)·kb(낡은 dev API 재기동)·voice(일시적) 해소, 교사 재평가 2건은 엔진 구독 한도(resets 3pm)로 skip 후 재검증 대기 - 문서/SSOT: HANDOFF·TODO·대시보드·testing 가이드 동기화, 증거 usecase-tdd-2026-08-18.json, SSOT 체커 PASS(59)
898 lines
42 KiB
TypeScript
898 lines
42 KiB
TypeScript
/* =====================================================================
|
|
breakpoint-sweep.spec.ts — 브레이크포인트 경계 스윕 게이트.
|
|
|
|
왜 필요한가:
|
|
기존 layout-visual-gate.spec.ts 는 고정 7폭(390/720/861/900/1024/1280/1440)에서
|
|
"문서 가로 오버플로 0" 만 검사했다. 그래서 아래 3건은 구조적으로 잡히지 않았다.
|
|
(1) 설정 서브네비 라벨 7개가 721~1080px 구간에서 폭 0 으로 붕괴
|
|
(2) 회기 프리스타트 우측 패널이 1041~1240px 구간에서 195px 잘림
|
|
(3) 티켓 "초기화" 버튼이 1180~1257px 구간에서 화면 밖 이탈
|
|
셋 다 문서 오버플로는 0 이고, 고정 7폭 사이의 "경계 안쪽" 에서만 나타난다.
|
|
|
|
이 스펙이 하는 일:
|
|
1. src 아래 모든 .css 의 @media min-width/max-width px 값을 실행 시점에 파싱한다.
|
|
(하드코딩 없음 — CSS 에 브레이크포인트가 추가되면 자동으로 커버된다)
|
|
2. 각 브레이크포인트 B 마다 B-1 / B / B+1 을 만들고 실기기 대표 폭을 합쳐
|
|
페이지별 테스트 폭 목록을 만든다.
|
|
3. 각 폭에서 겹침 / 잘림 / 폭·높이 붕괴 / 화면 밖 / 문서 가로 오버플로 5종을
|
|
DOM 실측으로 검출한다.
|
|
4. 실패 메시지에 페이지 · 폭 · 결함 종류 · 요소 · 실측 수치를 모두 담는다.
|
|
|
|
실행: npx playwright test e2e/breakpoint-sweep.spec.ts --reporter=list
|
|
(또는 npm run e2e:single-run — @single-run 태그로 별도 프로젝트에서 돈다)
|
|
===================================================================== */
|
|
|
|
import { promises as fs } from "node:fs";
|
|
import path from "node:path";
|
|
import { expect, test, type Page } from "@playwright/test";
|
|
import {
|
|
fetchAvailablePersona,
|
|
signInAsAdmin,
|
|
signInAsLearner,
|
|
signInAsTeacher,
|
|
} from "./support";
|
|
|
|
/* ─────────────────────────────────────────────────────────────────────
|
|
1) CSS 브레이크포인트 추출
|
|
───────────────────────────────────────────────────────────────────── */
|
|
|
|
const SRC_DIR = path.join(process.cwd(), "src");
|
|
|
|
/** 뷰포트 클램프 범위 — 320px 미만/1600px 초과 폭은 지원 대상이 아니다. */
|
|
const MIN_WIDTH = 320;
|
|
const MAX_WIDTH = 1600;
|
|
|
|
/** 실기기 대표 폭. 브레이크포인트 경계와 무관하게 항상 확인한다. */
|
|
const DEVICE_WIDTHS = [320, 360, 375, 390, 414, 768, 820, 1024, 1280, 1366, 1440, 1536];
|
|
|
|
/** 뷰포트 높이 — 폭 스윕이 목적이라 높이는 고정해 결과를 결정적으로 만든다. */
|
|
const SWEEP_HEIGHT = 900;
|
|
|
|
/** 한 라우트에서 확인할 최대 폭 수 — 런타임 상한. */
|
|
const MAX_WIDTHS_PER_ROUTE = 44;
|
|
|
|
/** src 아래 모든 .css 파일을 posix 상대경로로 수집. */
|
|
async function collectCssFiles(dir: string): Promise<string[]> {
|
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
const out: string[] = [];
|
|
for (const entry of entries) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) out.push(...(await collectCssFiles(full)));
|
|
else if (entry.name.endsWith(".css")) out.push(full);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function toKey(fullPath: string) {
|
|
return path.relative(SRC_DIR, fullPath).split(path.sep).join("/");
|
|
}
|
|
|
|
/**
|
|
* @media 프렐류드에서 min-width/max-width 의 px 값을 뽑는다.
|
|
* CSS 주석 안의 예시 표기(settings.css 의 설명 주석 등)가 잡히지 않도록
|
|
* 블록 주석을 먼저 제거한다.
|
|
*/
|
|
function extractBreakpoints(css: string): number[] {
|
|
const stripped = css.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
const found = new Set<number>();
|
|
const mediaRe = /@media([^{]+)\{/g;
|
|
let media: RegExpExecArray | null;
|
|
while ((media = mediaRe.exec(stripped)) !== null) {
|
|
const featureRe = /\((?:min|max)-width:\s*(\d+(?:\.\d+)?)px\)/g;
|
|
let feature: RegExpExecArray | null;
|
|
while ((feature = featureRe.exec(media[1])) !== null) {
|
|
found.add(Math.round(Number(feature[1])));
|
|
}
|
|
}
|
|
return [...found].sort((a, b) => a - b);
|
|
}
|
|
|
|
/* ─────────────────────────────────────────────────────────────────────
|
|
2) CSS 파일 → 페이지 매핑
|
|
───────────────────────────────────────────────────────────────────── */
|
|
|
|
/** 전 페이지 공통으로 취급하는 CSS. 여기 브레이크포인트는 모든 라우트에 적용된다. */
|
|
const COMMON_CSS = new Set([
|
|
"styles/global.css",
|
|
"styles/tokens.css",
|
|
"components/shell/shell.css",
|
|
"components/auth/auth-shell.css",
|
|
"components/ui/ui.css",
|
|
]);
|
|
|
|
/** 페이지 키 → 그 페이지가 소유한 CSS 파일. 여기 없는 CSS 는 공통으로 fallback 한다. */
|
|
const PAGE_CSS: Record<string, string[]> = {
|
|
login: ["pages/login/login.css"],
|
|
onboarding: ["pages/onboarding.css"],
|
|
pending: ["pages/pending-approval.css"],
|
|
"avatar-preview": ["pages/avatar-preview.css", "components/avatar/client-avatar.css"],
|
|
"learner-home": ["pages/learner-home.css"],
|
|
"avatar-lab": ["pages/avatar-expression-lab.css", "components/avatar/client-avatar.css"],
|
|
session: ["pages/session/session.css", "components/avatar/client-avatar.css"],
|
|
"session-review": ["pages/session-review/session-review.css"],
|
|
professor: ["pages/professor.css"],
|
|
"persona-studio": ["pages/persona-studio.css"],
|
|
"admin-console": ["pages/admin/admin-console.css"],
|
|
"admin-ai": ["pages/admin/admin-ai.css"],
|
|
settings: ["pages/settings/settings.css"],
|
|
};
|
|
|
|
interface BreakpointIndex {
|
|
/** 공통 CSS + 매핑되지 않은 CSS 에서 나온 브레이크포인트. */
|
|
common: number[];
|
|
/** 페이지 키별 고유 브레이크포인트. */
|
|
byPage: Record<string, number[]>;
|
|
/** 어떤 페이지에도 매핑되지 않아 공통으로 승격된 CSS(디버깅용). */
|
|
unmapped: string[];
|
|
}
|
|
|
|
async function buildBreakpointIndex(): Promise<BreakpointIndex> {
|
|
const files = await collectCssFiles(SRC_DIR);
|
|
const perFile = new Map<string, number[]>();
|
|
for (const file of files) {
|
|
perFile.set(toKey(file), extractBreakpoints(await fs.readFile(file, "utf8")));
|
|
}
|
|
|
|
const owned = new Set<string>();
|
|
for (const list of Object.values(PAGE_CSS)) for (const key of list) owned.add(key);
|
|
|
|
const common = new Set<number>();
|
|
const unmapped: string[] = [];
|
|
for (const [key, values] of perFile) {
|
|
if (owned.has(key)) continue;
|
|
// 공통 CSS 이거나, 아직 어떤 페이지에도 매핑되지 않은 새 CSS → 전 페이지 공통 취급.
|
|
// (매핑 누락 때문에 커버리지가 조용히 사라지는 것보다 과잉 커버가 안전하다)
|
|
if (!COMMON_CSS.has(key)) unmapped.push(key);
|
|
for (const value of values) common.add(value);
|
|
}
|
|
|
|
const byPage: Record<string, number[]> = {};
|
|
for (const [page, list] of Object.entries(PAGE_CSS)) {
|
|
const set = new Set<number>();
|
|
for (const key of list) {
|
|
const values = perFile.get(key);
|
|
expect(values, `PAGE_CSS 매핑이 가리키는 ${key} 가 src 에 없다`).toBeDefined();
|
|
for (const value of values ?? []) set.add(value);
|
|
}
|
|
byPage[page] = [...set].sort((a, b) => a - b);
|
|
}
|
|
|
|
return { common: [...common].sort((a, b) => a - b), byPage, unmapped };
|
|
}
|
|
|
|
const clampWidth = (value: number) => Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, value));
|
|
|
|
/** 브레이크포인트 목록 → B-1 / B / B+1 + 실기기 폭 (중복 제거, 클램프, 상한 적용). */
|
|
function widthsFor(breakpoints: number[]): number[] {
|
|
const boundary = new Set<number>();
|
|
for (const bp of breakpoints) {
|
|
for (const delta of [-1, 0, 1]) boundary.add(clampWidth(bp + delta));
|
|
}
|
|
const devices = DEVICE_WIDTHS.map(clampWidth).filter((w) => !boundary.has(w));
|
|
const ordered = [...boundary].sort((a, b) => a - b);
|
|
let merged = [...ordered, ...devices];
|
|
|
|
if (merged.length > MAX_WIDTHS_PER_ROUTE) {
|
|
// 상한을 넘으면 실기기 폭을 먼저 유지하고(사용자가 실제로 보는 폭),
|
|
// 경계 폭은 균등 간격으로 솎아 낸다 — 경계 3연폭 세트는 최대한 함께 남긴다.
|
|
// keep 은 "경계에 이미 있다고 제외된 devices" 가 아니라 전체 DEVICE_WIDTHS 로
|
|
// 시드한다. 아니면 브레이크포인트와 겹치는 실기기 폭(예: 360)이 솎아내기에서
|
|
// 빠져 arrayContaining(DEVICE_WIDTHS) 계약이 깨진다.
|
|
const keep = new Set<number>(DEVICE_WIDTHS.map(clampWidth));
|
|
const budget = MAX_WIDTHS_PER_ROUTE - keep.size;
|
|
const step = Math.max(1, Math.ceil(ordered.length / Math.max(1, budget)));
|
|
for (let i = 0; i < ordered.length; i += step) keep.add(ordered[i]);
|
|
merged = [...keep];
|
|
}
|
|
return [...new Set(merged)].sort((a, b) => a - b);
|
|
}
|
|
|
|
/* ─────────────────────────────────────────────────────────────────────
|
|
3) 브라우저에서 도는 결함 검출기
|
|
───────────────────────────────────────────────────────────────────── */
|
|
|
|
interface Finding {
|
|
kind: "overlap" | "clip-x" | "clip-y" | "collapse" | "offscreen" | "doc-overflow";
|
|
element: string;
|
|
detail: string;
|
|
}
|
|
|
|
/**
|
|
* 브라우저 컨텍스트에서 실행되는 레이아웃 결함 스캐너.
|
|
* 5종을 검출하되, 아래 4종 오탐은 명시적으로 제외한다.
|
|
* - -webkit-line-clamp (의도된 줄 자름)
|
|
* - visually-hidden 패턴 (width/height 1px, clip: rect(...))
|
|
* - border-radius >= 40px 원형 마스크
|
|
* - line-height 가 폰트 content-area 보다 작아 생기는 1~2px 세로 오버슛
|
|
*/
|
|
function scanLayoutDefects(): Finding[] {
|
|
const doc = document.documentElement;
|
|
const viewportWidth = doc.clientWidth;
|
|
const findings: Finding[] = [];
|
|
|
|
const all = Array.from(document.querySelectorAll<HTMLElement>("body *"));
|
|
const styles = new Map<Element, CSSStyleDeclaration>();
|
|
const rects = new Map<Element, DOMRect>();
|
|
// body 도 overflow 경계가 될 수 있으므로 캐시에 포함한다(순회 대상은 아니다).
|
|
for (const el of [document.body, ...all]) {
|
|
styles.set(el, window.getComputedStyle(el));
|
|
rects.set(el, el.getBoundingClientRect());
|
|
}
|
|
const cs = (el: Element) => styles.get(el) ?? window.getComputedStyle(el);
|
|
const rc = (el: Element) => rects.get(el) ?? el.getBoundingClientRect();
|
|
|
|
function classOf(el: Element) {
|
|
const raw = (el as HTMLElement).className as unknown;
|
|
const source =
|
|
typeof raw === "string" ? raw : raw && typeof raw === "object" && "baseVal" in raw ? String((raw as SVGAnimatedString).baseVal) : "";
|
|
return source.trim().split(/\s+/).filter(Boolean).slice(0, 3).join(".");
|
|
}
|
|
|
|
/** 요소 식별 문자열. 클래스가 없으면 부모 클래스를 붙여 디버깅 가능한 좌표를 만든다. */
|
|
function describe(el: Element) {
|
|
const cls = classOf(el);
|
|
const tag = el.tagName.toLowerCase();
|
|
const anchor = cls
|
|
? `${tag}.${cls}`
|
|
: `${tag}${el.parentElement && classOf(el.parentElement) ? `@${classOf(el.parentElement)}` : ""}`;
|
|
const text = (el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 40);
|
|
return `${anchor}${text ? ` "${text}"` : ""}`;
|
|
}
|
|
|
|
const visibleCache = new Map<Element, boolean>();
|
|
function isVisible(el: Element): boolean {
|
|
const cached = visibleCache.get(el);
|
|
if (cached !== undefined) return cached;
|
|
const style = cs(el);
|
|
const rect = rc(el);
|
|
let result = true;
|
|
if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) result = false;
|
|
else if (rect.width <= 0 && rect.height <= 0) result = false;
|
|
else if (el.getAttribute("aria-hidden") === "true") result = false;
|
|
else if (el.hasAttribute("hidden") || el.hasAttribute("inert")) result = false;
|
|
else if (el.parentElement && el.parentElement !== document.body && !isVisible(el.parentElement)) result = false;
|
|
visibleCache.set(el, result);
|
|
return result;
|
|
}
|
|
|
|
/** 장식 레이어: 히트테스트 대상이 아니거나 접근성 트리에서 숨겨진 요소. */
|
|
function isDecorative(el: Element) {
|
|
return cs(el).pointerEvents === "none" || el.getAttribute("aria-hidden") === "true";
|
|
}
|
|
|
|
/** 오탐 제외 (2): visually-hidden 패턴 (1px 박스 / clip / clip-path inset(50%)). */
|
|
function isVisuallyHidden(el: Element) {
|
|
const style = cs(el);
|
|
const rect = rc(el);
|
|
if (rect.width <= 2 && rect.height <= 2) return true;
|
|
if (style.clip && style.clip !== "auto") return true;
|
|
if (style.clipPath && style.clipPath.includes("inset(50%")) return true;
|
|
return false;
|
|
}
|
|
|
|
/** 오탐 제외 (1): -webkit-line-clamp 가 걸린 요소는 세로 자름이 의도다. */
|
|
function hasLineClamp(el: Element) {
|
|
const style = cs(el);
|
|
const value =
|
|
style.getPropertyValue("-webkit-line-clamp") ||
|
|
(style as unknown as { webkitLineClamp?: string }).webkitLineClamp ||
|
|
"none";
|
|
return value !== "none" && value !== "" && value !== "0";
|
|
}
|
|
|
|
/** 오탐 제외 (3): border-radius 40px 이상 원형 마스크(아바타 스테이지 등). */
|
|
function hasCircularMask(el: Element) {
|
|
const style = cs(el);
|
|
return (
|
|
["borderTopLeftRadius", "borderTopRightRadius", "borderBottomLeftRadius", "borderBottomRightRadius"] as const
|
|
).some((key) => Number.parseFloat(style[key]) >= 40);
|
|
}
|
|
|
|
/** 오탐 제외 (4): line-height < fontSize * 1.15 → 1~2px 세로 오버슛은 글리프가 안 잘린다. */
|
|
function hasTightLineHeight(el: Element) {
|
|
const style = cs(el);
|
|
const fontSize = Number.parseFloat(style.fontSize) || 16;
|
|
const lineHeight = style.lineHeight === "normal" ? fontSize * 1.2 : Number.parseFloat(style.lineHeight) || fontSize * 1.2;
|
|
return lineHeight < fontSize * 1.15;
|
|
}
|
|
|
|
function hasDirectText(el: Element) {
|
|
for (const node of Array.from(el.childNodes)) {
|
|
if (node.nodeType === Node.TEXT_NODE && (node.textContent ?? "").trim()) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const clipsX = (s: CSSStyleDeclaration) => s.overflowX === "hidden" || s.overflowX === "clip";
|
|
const clipsY = (s: CSSStyleDeclaration) => s.overflowY === "hidden" || s.overflowY === "clip";
|
|
const scrollsX = (s: CSSStyleDeclaration) => s.overflowX === "auto" || s.overflowX === "scroll";
|
|
const scrollsY = (s: CSSStyleDeclaration) => s.overflowY === "auto" || s.overflowY === "scroll";
|
|
const isOverflowBoundary = (s: CSSStyleDeclaration) => clipsX(s) || clipsY(s) || scrollsX(s) || scrollsY(s);
|
|
|
|
/* ── (A) 잘림 ────────────────────────────────────────────────────────
|
|
overflow hidden/clip 컨테이너가 자기 콘텐츠를 잘라내는 경우.
|
|
|
|
scrollWidth/scrollHeight 만 보면 ::after 장식(음수 offset 으로 깔아 둔 배경
|
|
아트, 예: .lh-session-focus::after)까지 "잘림" 으로 잡혀 노이즈가 된다.
|
|
그래서 자식 요소가 있는 컨테이너는 "in-flow 자손 rect vs client box" 로
|
|
판정한다. absolute/fixed 자손과 pointer-events:none 장식은 제외한다.
|
|
|
|
성능: 컨테이너마다 자손을 훑으면 O(n^2) 이라 느리다. 대신 요소마다
|
|
"최근접 overflow 경계 조상" 을 한 번만 계산(O(n))하고, 각 요소를 자기
|
|
경계와만 비교한다. 결과는 동일하고 중복 보고도 자동으로 없어진다. */
|
|
const clipCandidate = (el: Element) => {
|
|
const style = cs(el);
|
|
if (!clipsX(style) && !clipsY(style)) return false;
|
|
if (!isVisible(el) || isVisuallyHidden(el) || hasCircularMask(el)) return false;
|
|
const tag = el.tagName.toLowerCase();
|
|
// 폼 컨트롤은 설계상 자기 값을 스크롤한다(키보드로 전부 도달 가능).
|
|
if (tag === "input" || tag === "textarea" || tag === "select") return false;
|
|
return el.clientWidth > 0 || el.clientHeight > 0;
|
|
};
|
|
|
|
// 최근접 overflow 경계(또는 containing-block 을 바꾸는 absolute/fixed 조상).
|
|
const nearestBoundary = new Map<Element, Element | null>();
|
|
for (const el of all) {
|
|
const parent = el.parentElement;
|
|
if (!parent || !styles.has(parent)) {
|
|
nearestBoundary.set(el, null);
|
|
continue;
|
|
}
|
|
const parentStyle = cs(parent);
|
|
if (isOverflowBoundary(parentStyle) || parentStyle.position === "absolute" || parentStyle.position === "fixed") {
|
|
nearestBoundary.set(el, parent);
|
|
} else {
|
|
nearestBoundary.set(el, nearestBoundary.get(parent) ?? null);
|
|
}
|
|
}
|
|
|
|
// 컨테이너별 최악 오버슛만 남긴다(요소 하나당 한 줄 보고).
|
|
const worstClipX = new Map<Element, { overshoot: number; node: Element }>();
|
|
const worstClipY = new Map<Element, { overshoot: number; node: Element }>();
|
|
const clientBox = (el: Element) => {
|
|
const style = cs(el);
|
|
const rect = rc(el);
|
|
const left = rect.left + (Number.parseFloat(style.borderLeftWidth) || 0);
|
|
const top = rect.top + (Number.parseFloat(style.borderTopWidth) || 0);
|
|
return { left, top, right: left + el.clientWidth, bottom: top + el.clientHeight };
|
|
};
|
|
|
|
for (const el of all) {
|
|
if (!isVisible(el) || isDecorative(el) || isVisuallyHidden(el)) continue;
|
|
const style = cs(el);
|
|
if (style.position === "absolute" || style.position === "fixed") continue;
|
|
const boundary = nearestBoundary.get(el);
|
|
if (!boundary || !clipCandidate(boundary)) continue;
|
|
const rect = rc(el);
|
|
if (rect.width <= 0 || rect.height <= 0) continue;
|
|
|
|
const boundaryStyle = cs(boundary);
|
|
const box = clientBox(boundary);
|
|
const overshootX = Math.max(rect.right - box.right, box.left - rect.left);
|
|
const overshootY = Math.max(rect.bottom - box.bottom, box.top - rect.top);
|
|
|
|
if (clipsX(boundaryStyle) && boundaryStyle.textOverflow !== "ellipsis" && overshootX > 1) {
|
|
const prev = worstClipX.get(boundary);
|
|
if (!prev || overshootX > prev.overshoot) worstClipX.set(boundary, { overshoot: overshootX, node: el });
|
|
}
|
|
if (
|
|
clipsY(boundaryStyle) &&
|
|
!hasLineClamp(boundary) &&
|
|
overshootY > 1 &&
|
|
// 오탐 제외 (4): line-height 가 content-area 보다 작아 생기는 1~2px 오버슛.
|
|
!(overshootY <= 2 && hasTightLineHeight(el))
|
|
) {
|
|
const prev = worstClipY.get(boundary);
|
|
if (!prev || overshootY > prev.overshoot) worstClipY.set(boundary, { overshoot: overshootY, node: el });
|
|
}
|
|
}
|
|
|
|
for (const [boundary, worst] of worstClipX) {
|
|
findings.push({
|
|
kind: "clip-x",
|
|
element: describe(boundary),
|
|
detail: `자식 ${describe(worst.node)} 이(가) 가로로 ${worst.overshoot.toFixed(1)}px 잘림 (clientWidth ${boundary.clientWidth}px)`,
|
|
});
|
|
}
|
|
for (const [boundary, worst] of worstClipY) {
|
|
findings.push({
|
|
kind: "clip-y",
|
|
element: describe(boundary),
|
|
detail: `자식 ${describe(worst.node)} 이(가) 세로로 ${worst.overshoot.toFixed(1)}px 잘림 (clientHeight ${boundary.clientHeight}px)`,
|
|
});
|
|
}
|
|
|
|
// 자식 요소 없이 텍스트만 담은 잎 노드는 rect 비교가 불가능하므로
|
|
// scrollWidth/scrollHeight 로 자기 콘텐츠가 잘렸는지 본다.
|
|
for (const el of all) {
|
|
if (el.children.length > 0 || !clipCandidate(el)) continue;
|
|
const style = cs(el);
|
|
const dx = Math.ceil(el.scrollWidth - el.clientWidth);
|
|
const dy = Math.ceil(el.scrollHeight - el.clientHeight);
|
|
if (clipsX(style) && style.textOverflow !== "ellipsis" && dx > 1) {
|
|
findings.push({
|
|
kind: "clip-x",
|
|
element: describe(el),
|
|
detail: `자기 텍스트가 가로로 ${dx}px 잘림 (scrollWidth ${el.scrollWidth} > clientWidth ${el.clientWidth})`,
|
|
});
|
|
}
|
|
if (clipsY(style) && !hasLineClamp(el) && dy > 1 && !(dy <= 2 && hasTightLineHeight(el))) {
|
|
findings.push({
|
|
kind: "clip-y",
|
|
element: describe(el),
|
|
detail: `자기 텍스트가 세로로 ${dy}px 잘림 (scrollHeight ${el.scrollHeight} > clientHeight ${el.clientHeight})`,
|
|
});
|
|
}
|
|
}
|
|
|
|
/* ── (B) 폭/높이 붕괴 ────────────────────────────────────────────────
|
|
직접 텍스트 자식이 있는데 rect 폭 또는 높이가 1px 미만 → 글자가 사라진다.
|
|
(설정 서브네비 라벨 폭 0 결함이 여기에 잡힌다) */
|
|
for (const el of all) {
|
|
if (!isVisible(el) || !hasDirectText(el) || isVisuallyHidden(el)) continue;
|
|
const rect = rc(el);
|
|
if (rect.width < 1 || rect.height < 1) {
|
|
findings.push({
|
|
kind: "collapse",
|
|
element: describe(el),
|
|
detail: `텍스트가 있는데 박스가 붕괴 (width ${rect.width.toFixed(2)}px, height ${rect.height.toFixed(2)}px)`,
|
|
});
|
|
}
|
|
}
|
|
|
|
/* ── (C) 화면 밖 조작 요소 ───────────────────────────────────────────
|
|
조작 요소가 뷰포트 좌우 밖으로 나감. 단 조상에 실제 가로 스크롤이 있으면
|
|
사용자가 스크롤로 도달할 수 있으므로 정상으로 본다. */
|
|
const interactiveSelector = "button,a[href],input,select,textarea,[role='button'],[role='tab']";
|
|
function hasHorizontalScrollAncestor(el: Element) {
|
|
let node: Element | null = el.parentElement;
|
|
while (node && node !== document.body) {
|
|
const style = cs(node);
|
|
if (scrollsX(style) && node.scrollWidth - node.clientWidth > 1) return true;
|
|
node = node.parentElement;
|
|
}
|
|
return false;
|
|
}
|
|
for (const el of Array.from(document.querySelectorAll<HTMLElement>(interactiveSelector))) {
|
|
if (!styles.has(el) || !isVisible(el) || isVisuallyHidden(el)) continue;
|
|
const rect = rc(el);
|
|
if (rect.width <= 0 || rect.height <= 0) continue;
|
|
if (rect.left >= -1 && rect.right <= viewportWidth + 1) continue;
|
|
if (hasHorizontalScrollAncestor(el)) continue;
|
|
findings.push({
|
|
kind: "offscreen",
|
|
element: describe(el),
|
|
detail: `조작 요소가 뷰포트 밖 (left ${Math.round(rect.left)}px, right ${Math.round(rect.right)}px, viewport ${viewportWidth}px)`,
|
|
});
|
|
}
|
|
|
|
/* ── (D) 문서 가로 오버플로 ──────────────────────────────────────── */
|
|
const documentOverflow = Math.ceil(doc.scrollWidth - viewportWidth);
|
|
if (documentOverflow > 1) {
|
|
findings.push({
|
|
kind: "doc-overflow",
|
|
element: "html",
|
|
detail: `문서가 가로로 ${documentOverflow}px 넘침 (scrollWidth ${doc.scrollWidth} > clientWidth ${viewportWidth})`,
|
|
});
|
|
}
|
|
|
|
/* ── (E) 겹침 ────────────────────────────────────────────────────────
|
|
같은 부모의 in-flow 형제끼리 rect 가 교차. absolute/fixed/sticky,
|
|
pointer-events:none, aria-hidden, float/transform,
|
|
grid-template-areas 를 쓰는 부모(의도적 겹침 레이아웃)는 제외. */
|
|
const parents = new Set<Element>();
|
|
for (const el of all) if (el.parentElement) parents.add(el.parentElement);
|
|
for (const parent of parents) {
|
|
const parentStyle = parent === document.body ? window.getComputedStyle(parent) : cs(parent);
|
|
if (parentStyle.gridTemplateAreas && parentStyle.gridTemplateAreas !== "none") continue;
|
|
const siblings = Array.from(parent.children).filter((el) => {
|
|
if (!styles.has(el) || !isVisible(el)) return false;
|
|
const style = cs(el);
|
|
if (style.position !== "static" && style.position !== "relative") return false;
|
|
if (style.pointerEvents === "none" || style.float !== "none" || style.transform !== "none") return false;
|
|
if (isDecorative(el) || isVisuallyHidden(el)) return false;
|
|
const rect = rc(el);
|
|
return rect.width > 2 && rect.height > 2;
|
|
});
|
|
for (let i = 0; i < siblings.length; i += 1) {
|
|
for (let j = i + 1; j < siblings.length; j += 1) {
|
|
const a = rc(siblings[i]);
|
|
const b = rc(siblings[j]);
|
|
const overlapX = Math.min(a.right, b.right) - Math.max(a.left, b.left);
|
|
const overlapY = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
|
|
if (overlapX > 2 && overlapY > 2) {
|
|
findings.push({
|
|
kind: "overlap",
|
|
element: `${describe(siblings[i])} ∩ ${describe(siblings[j])}`,
|
|
detail: `형제 요소가 ${overlapX.toFixed(1)}x${overlapY.toFixed(1)}px 겹침 (부모 ${describe(parent)})`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return findings;
|
|
}
|
|
|
|
/* ─────────────────────────────────────────────────────────────────────
|
|
4) 라우트 정의 & 스윕 실행
|
|
───────────────────────────────────────────────────────────────────── */
|
|
|
|
interface RouteCase {
|
|
/** 리포트에 찍히는 이름. */
|
|
label: string;
|
|
/** PAGE_CSS 키 — 이 라우트가 상속할 브레이크포인트 집합. */
|
|
page: keyof typeof PAGE_CSS;
|
|
url: string;
|
|
/** 렌더 완료 판정 셀렉터. */
|
|
ready: string;
|
|
}
|
|
|
|
/** 폭 변경 후 레이아웃이 안정될 때까지 대기 (rAF 2회 + 폰트/차트 여유). */
|
|
async function settleLayout(page: Page) {
|
|
await page.evaluate(
|
|
() =>
|
|
new Promise<void>((resolve) => {
|
|
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
|
}),
|
|
);
|
|
await page.waitForTimeout(90);
|
|
}
|
|
|
|
interface WidthFailure {
|
|
width: number;
|
|
finding: Finding;
|
|
}
|
|
|
|
/* ─────────────────────────────────────────────────────────────────────
|
|
4-1) 격리(quarantine) — 검출기 오탐이 아니라 "이미 알려진 앱 CSS 결함"
|
|
─────────────────────────────────────────────────────────────────────
|
|
이 게이트가 처음 돌면서 실제로 찾아낸 결함들이다. CSS 파일은 이 스펙의
|
|
담당 범위가 아니라 여기서 고칠 수 없어, 게이트를 초록으로 유지하되
|
|
"무엇을 눈감아 주고 있는지" 를 코드에 남기고 실행 로그로 계속 노출한다.
|
|
|
|
- 이 목록은 검출기를 무력화하지 않는다. 라우트·결함종류·요소·폭 구간이
|
|
모두 일치할 때만 통과시킨다. 다른 폭이나 다른 요소에서 같은 결함이
|
|
생기면 그대로 실패한다.
|
|
- CSS 가 고쳐지면 해당 항목은 "재현되지 않음" 으로 로그에 찍히므로
|
|
그때 이 배열에서 지우면 된다.
|
|
- 검출기의 검출력 자체는 아래 "검출기 자기검증" 테스트가 매번 보증한다. */
|
|
interface KnownAppDefect {
|
|
route: string;
|
|
kind: Finding["kind"];
|
|
element: RegExp;
|
|
minWidth: number;
|
|
maxWidth: number;
|
|
note: string;
|
|
}
|
|
|
|
/* 2026-07-27: 최초 3건을 전부 CSS 에서 실제로 고쳐 목록을 비웠다.
|
|
(전체 스펙 7테스트 실행에서 3건 모두 "격리 항목 미재현" 으로 찍혔다)
|
|
- learner-home/dashboard · clip-x · .lh-recap__avatar (320~380px)
|
|
→ learner-home.css: 그리드 트랙 명시 + .vg-avatar min-width:0/max-width:100%
|
|
- learner-home/practice · collapse · b@lh-session-card__title (1181~1200px)
|
|
→ learner-home.css: .lh-preview__hero 를 flex-wrap 으로 바꿔 제목 열 폭 확보
|
|
- persona-studio · clip-x · .ps-active-table (761~900px)
|
|
→ persona-studio.css: 트랙 최소치 축소 + .ps-usage-table 영역 가로 스크롤
|
|
목록이 비었으므로 이제 어떤 결함도 곧바로 실패한다. 다시 채우지 말고 CSS 를 고쳐라. */
|
|
const KNOWN_APP_DEFECTS: KnownAppDefect[] = [];
|
|
|
|
function matchKnownDefect(routeLabel: string, width: number, finding: Finding) {
|
|
return KNOWN_APP_DEFECTS.find(
|
|
(known) =>
|
|
known.route === routeLabel &&
|
|
known.kind === finding.kind &&
|
|
known.element.test(finding.element) &&
|
|
width >= known.minWidth &&
|
|
width <= known.maxWidth,
|
|
);
|
|
}
|
|
|
|
/** 격리 항목이 실제로 재현됐는지 추적 — 재현되지 않으면 목록에서 지우라고 알린다. */
|
|
const quarantineHits = new Set<KnownAppDefect>();
|
|
|
|
/** 한 라우트를 모든 폭에서 스윕하고 결함을 모아 반환한다. */
|
|
async function sweepRoute(page: Page, route: RouteCase, widths: number[]): Promise<WidthFailure[]> {
|
|
await page.goto(route.url, { waitUntil: "domcontentloaded" });
|
|
await expect(page.locator(route.ready).first(), `[${route.label}] 렌더 대기 실패: ${route.ready}`).toBeVisible({
|
|
timeout: 20_000,
|
|
});
|
|
await settleLayout(page);
|
|
|
|
const failures: WidthFailure[] = [];
|
|
for (const width of widths) {
|
|
await page.setViewportSize({ width, height: SWEEP_HEIGHT });
|
|
await settleLayout(page);
|
|
await page.evaluate(() => {
|
|
window.scrollTo(0, 0);
|
|
const main = document.querySelector<HTMLElement>(".vg-main");
|
|
if (main) main.scrollTop = 0;
|
|
});
|
|
const findings = await page.evaluate(scanLayoutDefects);
|
|
for (const finding of findings) {
|
|
const known = matchKnownDefect(route.label, width, finding);
|
|
if (known) {
|
|
quarantineHits.add(known);
|
|
console.log(
|
|
`[breakpoint-sweep][격리된 앱 결함] ${route.label} @ ${width}px [${finding.kind}] ${finding.element} → ${finding.detail}`,
|
|
);
|
|
continue;
|
|
}
|
|
failures.push({ width, finding });
|
|
}
|
|
}
|
|
return failures;
|
|
}
|
|
|
|
function formatFailures(route: RouteCase, widths: number[], failures: WidthFailure[]) {
|
|
const lines = failures
|
|
.slice(0, 40)
|
|
.map((f) => ` · ${route.label} @ ${f.width}px [${f.finding.kind}] ${f.finding.element} → ${f.finding.detail}`);
|
|
const more = failures.length > 40 ? `\n · … 외 ${failures.length - 40}건` : "";
|
|
return `[${route.label}] ${widths.length}개 폭(${widths[0]}~${widths[widths.length - 1]}px) 스윕에서 레이아웃 결함 ${failures.length}건\n${lines.join("\n")}${more}`;
|
|
}
|
|
|
|
let index: BreakpointIndex;
|
|
|
|
test.beforeAll(async () => {
|
|
index = await buildBreakpointIndex();
|
|
});
|
|
|
|
/** 페이지 키에 해당하는 최종 폭 목록(공통 + 페이지 고유). */
|
|
function widthsForPage(page: keyof typeof PAGE_CSS) {
|
|
return widthsFor([...new Set([...index.common, ...(index.byPage[page] ?? [])])]);
|
|
}
|
|
|
|
/** 해당 페이지가 폭 상한을 넘어 솎아내기가 일어났는지 — 3연폭 계약의 예외 조건. */
|
|
function isPrunedPage(page: keyof typeof PAGE_CSS) {
|
|
const candidate = new Set<number>(DEVICE_WIDTHS.map(clampWidth));
|
|
for (const bp of [...new Set([...index.common, ...(index.byPage[page] ?? [])])]) {
|
|
for (const delta of [-1, 0, 1]) candidate.add(clampWidth(bp + delta));
|
|
}
|
|
return candidate.size > MAX_WIDTHS_PER_ROUTE;
|
|
}
|
|
|
|
async function runSweep(page: Page, routes: RouteCase[]) {
|
|
const report: string[] = [];
|
|
let total = 0;
|
|
for (const route of routes) {
|
|
const widths = widthsForPage(route.page);
|
|
const failures = await sweepRoute(page, route, widths);
|
|
if (failures.length) {
|
|
total += failures.length;
|
|
report.push(formatFailures(route, widths, failures));
|
|
}
|
|
}
|
|
expect(total, `브레이크포인트 스윕 결함\n\n${report.join("\n\n")}`).toBe(0);
|
|
}
|
|
|
|
test.describe("브레이크포인트 경계 스윕 @single-run", () => {
|
|
test("추출한 브레이크포인트가 CSS 를 실제로 반영한다", async () => {
|
|
// 검출기가 아니라 "무엇을 볼지" 를 정하는 파서/매핑의 회귀 방지.
|
|
// 값 자체는 하드코딩하지 않고 "구조가 살아 있는가" 만 본다.
|
|
expect(index.common.length, "공통 CSS(shell/global/auth-shell 등)에서 브레이크포인트를 하나도 못 찾았다").toBeGreaterThan(0);
|
|
|
|
const distinct = new Set(index.common);
|
|
for (const values of Object.values(index.byPage)) for (const value of values) distinct.add(value);
|
|
expect(distinct.size, "CSS 전체에서 추출한 브레이크포인트가 비정상적으로 적다 — 파서가 깨졌을 수 있다").toBeGreaterThan(10);
|
|
|
|
for (const pageKey of Object.keys(PAGE_CSS) as Array<keyof typeof PAGE_CSS>) {
|
|
const widths = widthsForPage(pageKey);
|
|
expect(Math.min(...widths), `${pageKey}: 클램프 하한 위반`).toBeGreaterThanOrEqual(MIN_WIDTH);
|
|
expect(Math.max(...widths), `${pageKey}: 클램프 상한 위반`).toBeLessThanOrEqual(MAX_WIDTH);
|
|
expect(widths.length, `${pageKey}: 라우트당 폭 상한 초과`).toBeLessThanOrEqual(MAX_WIDTHS_PER_ROUTE);
|
|
// 실기기 대표 폭은 어떤 페이지에서도 빠지면 안 된다.
|
|
expect(widths, `${pageKey}: 실기기 대표 폭 누락`).toEqual(expect.arrayContaining(DEVICE_WIDTHS));
|
|
// 브레이크포인트 B 마다 B-1/B/B+1 이 살아 있는지(클램프 경계 제외).
|
|
for (const bp of index.byPage[pageKey] ?? []) {
|
|
if (bp <= MIN_WIDTH || bp >= MAX_WIDTH) continue;
|
|
if (isPrunedPage(pageKey)) continue; // 상한으로 솎아 낸 경우는 예외
|
|
expect(widths, `${pageKey}: 브레이크포인트 ${bp}px 경계 3연폭 누락`).toEqual(
|
|
expect.arrayContaining([bp - 1, bp, bp + 1]),
|
|
);
|
|
}
|
|
}
|
|
|
|
if (index.unmapped.length) {
|
|
// 실패시키지 않고 로그만 — 새 CSS 는 공통으로 승격돼 이미 전 라우트에서 커버된다.
|
|
console.log(`[breakpoint-sweep] PAGE_CSS 에 없는 CSS(공통 승격): ${index.unmapped.join(", ")}`);
|
|
}
|
|
for (const pageKey of Object.keys(PAGE_CSS) as Array<keyof typeof PAGE_CSS>) {
|
|
console.log(`[breakpoint-sweep] ${pageKey}: ${widthsForPage(pageKey).length}폭`);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 검출기 자기검증.
|
|
*
|
|
* 게이트가 초록이라는 사실만으로는 "검출기가 살아 있다" 를 증명하지 못한다.
|
|
* (오탐 제외를 과하게 넣어 전부 무시해도 초록이 된다.)
|
|
* 그래서 이 스펙이 잡아야 했던 실제 결함 3건을 런타임 CSS 주입으로 되살리고,
|
|
* 검출기가 그 결함을 실제로 보고하는지 매 실행마다 확인한다.
|
|
* (1) 설정 서브네비 라벨 폭 0 붕괴 → collapse
|
|
* (2) 회기 프리스타트 패널 잘림 → clip-x
|
|
* (3) 티켓 "초기화" 버튼 화면 밖 이탈 → offscreen
|
|
*/
|
|
test("검출기가 되살린 실제 결함 3건을 실제로 잡는다", async ({ page }) => {
|
|
test.setTimeout(240_000);
|
|
|
|
async function findingsWith(url: string, ready: string, width: number, css: string) {
|
|
await page.goto(url, { waitUntil: "domcontentloaded" });
|
|
await expect(page.locator(ready).first()).toBeVisible({ timeout: 20_000 });
|
|
await page.setViewportSize({ width, height: SWEEP_HEIGHT });
|
|
await settleLayout(page);
|
|
const clean = await page.evaluate(scanLayoutDefects);
|
|
const handle = await page.addStyleTag({ content: css });
|
|
await settleLayout(page);
|
|
const dirty = await page.evaluate(scanLayoutDefects);
|
|
await handle.evaluate((node) => {
|
|
(node as HTMLStyleElement).remove();
|
|
});
|
|
return { clean, dirty };
|
|
}
|
|
|
|
await signInAsLearner(page);
|
|
const persona = await fetchAvailablePersona(page);
|
|
|
|
// (1) 설정 서브네비 라벨이 721~1080px 구간에서 폭 0 이 되던 결함.
|
|
const railLabels = await findingsWith(
|
|
"/settings",
|
|
".vg-set",
|
|
900,
|
|
".vg-set__rail button span{width:0;overflow:hidden;display:inline-block;}",
|
|
);
|
|
expect(
|
|
railLabels.dirty.filter((f) => f.kind === "collapse").length,
|
|
`설정 서브네비 라벨 붕괴를 못 잡았다: ${JSON.stringify(railLabels.dirty)}`,
|
|
).toBeGreaterThanOrEqual(3);
|
|
|
|
// (2) 회기 프리스타트 우측 패널이 1041~1240px 구간에서 잘리던 결함.
|
|
const prestart = await findingsWith(
|
|
`/learn/session/${persona.code}`,
|
|
".sx-head",
|
|
1100,
|
|
".vg-main__inner{overflow:hidden;} .sx-prestart{width:1400px;}",
|
|
);
|
|
expect(
|
|
prestart.dirty.filter((f) => f.kind === "clip-x").length,
|
|
`프리스타트 패널 잘림을 못 잡았다: ${JSON.stringify(prestart.dirty)}`,
|
|
).toBeGreaterThanOrEqual(1);
|
|
|
|
// (3) 티켓 "초기화" 버튼이 1180~1257px 구간에서 화면 밖으로 나가던 결함.
|
|
await signInAsAdmin(page);
|
|
const ticketButton = await findingsWith(
|
|
"/admin/tickets",
|
|
".vgops-root",
|
|
1220,
|
|
".vgops-ticket-filter .vg-btn:last-child{position:relative;left:400px;}",
|
|
);
|
|
expect(
|
|
ticketButton.dirty.filter((f) => f.kind === "offscreen").length,
|
|
`티켓 버튼 화면 밖 이탈을 못 잡았다: ${JSON.stringify(ticketButton.dirty)}`,
|
|
).toBeGreaterThanOrEqual(1);
|
|
|
|
// 주입 전에는 같은 결함이 없어야 한다 — 오탐 노이즈로 통과하는 것을 막는다.
|
|
expect(railLabels.clean.filter((f) => f.kind === "collapse"), "주입 전 설정 화면에 붕괴 오탐").toEqual([]);
|
|
expect(prestart.clean.filter((f) => f.kind === "clip-x"), "주입 전 프리스타트에 잘림 오탐").toEqual([]);
|
|
expect(ticketButton.clean.filter((f) => f.kind === "offscreen"), "주입 전 티켓 화면에 이탈 오탐").toEqual([]);
|
|
});
|
|
|
|
test("비인증 화면(로그인·아바타 프리뷰)이 모든 경계 폭에서 온전하다", async ({ page }) => {
|
|
test.setTimeout(240_000);
|
|
await runSweep(page, [
|
|
{ label: "login", page: "login", url: "/login", ready: ".lg-root" },
|
|
{ label: "avatar-preview", page: "avatar-preview", url: "/dev/avatar-preview", ready: ".ap" },
|
|
]);
|
|
});
|
|
|
|
test("가입 게이트 화면(온보딩·승인대기)이 모든 경계 폭에서 온전하다", async ({ page }) => {
|
|
test.setTimeout(240_000);
|
|
// 온보딩 미완료 사용자를 만들면 OnboardingGate 가 /onboarding 으로 보낸다.
|
|
const res = await page.request.post("/api/auth/dev-login", {
|
|
data: {
|
|
email: `sweep.onboarding.${Date.now()}@hs.ac.kr`,
|
|
role: "learner",
|
|
display_name: "Sweep Onboarding",
|
|
},
|
|
});
|
|
expect(res.ok(), await res.text()).toBeTruthy();
|
|
await runSweep(page, [
|
|
{ label: "onboarding", page: "onboarding", url: "/onboarding", ready: ".ob-page" },
|
|
]);
|
|
|
|
// 승인 대기 화면은 계정 상태에 의존하므로 /auth/me 응답만 최소로 덮어쓴다.
|
|
// email 도 함께 짧게 바꾼다 — 이 화면은 이메일을 그대로 노출하는데,
|
|
// dev-login 용 타임스탬프 이메일은 실제 사용자보다 훨씬 길어서
|
|
// "브레이크포인트" 가 아니라 "테스트 데이터 길이" 때문에 패널이 넘친다.
|
|
await page.route("**/api/auth/me", async (route) => {
|
|
const response = await route.fetch();
|
|
const body = (await response.json()) as Record<string, unknown>;
|
|
await route.fulfill({
|
|
response,
|
|
json: {
|
|
...body,
|
|
email: "pending@hs.ac.kr",
|
|
account_status: "pending",
|
|
approval_required: true,
|
|
},
|
|
});
|
|
});
|
|
await runSweep(page, [
|
|
{ label: "pending-approval", page: "pending", url: "/pending", ready: ".pa-page" },
|
|
]);
|
|
await page.unroute("**/api/auth/me");
|
|
});
|
|
|
|
test("learner 화면이 모든 경계 폭에서 온전하다", async ({ page }) => {
|
|
test.setTimeout(900_000);
|
|
await signInAsLearner(page);
|
|
const persona = await fetchAvailablePersona(page);
|
|
// 리뷰 화면을 실제로 열려면 종료된 회기가 하나 필요하다(AI 턴 생성은 하지 않는다).
|
|
const created = await page.request.post("/api/sessions", {
|
|
data: { persona_code: persona.code, theory_mode: "humanistic" },
|
|
});
|
|
expect(created.ok(), await created.text()).toBeTruthy();
|
|
const { session_id: endedSessionId } = (await created.json()) as { session_id: string };
|
|
await page.request.post(`/api/sessions/${endedSessionId}/end`);
|
|
|
|
await runSweep(page, [
|
|
{ label: "learner-home/dashboard", page: "learner-home", url: "/learn", ready: ".lh-root" },
|
|
{ label: "learner-home/practice", page: "learner-home", url: "/learn/practice", ready: ".lh-root" },
|
|
{ label: "learner-home/history", page: "learner-home", url: "/learn/history", ready: ".lh-root" },
|
|
{
|
|
label: "session/prestart",
|
|
page: "session",
|
|
url: `/learn/session/${persona.code}`,
|
|
ready: ".sx-head",
|
|
},
|
|
{
|
|
label: "session-review/learner",
|
|
page: "session-review",
|
|
url: `/learn/session/${endedSessionId}/review`,
|
|
ready: ".sr-root",
|
|
},
|
|
{ label: "settings/learner", page: "settings", url: "/settings", ready: ".vg-set" },
|
|
{
|
|
label: "avatar-lab",
|
|
page: "avatar-lab",
|
|
url: "/learn/avatar-expressions",
|
|
ready: ".axl",
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("teacher 화면이 모든 경계 폭에서 온전하다", async ({ page }) => {
|
|
test.setTimeout(420_000);
|
|
await signInAsTeacher(page);
|
|
await runSweep(page, [
|
|
{ label: "professor/dashboard", page: "professor", url: "/teach", ready: ".pf-root" },
|
|
{ label: "professor/analysis", page: "professor", url: "/teach/analysis", ready: ".pf-root" },
|
|
{ label: "persona-studio", page: "persona-studio", url: "/teach/personas", ready: ".ps-root" },
|
|
{ label: "settings/teacher", page: "settings", url: "/settings", ready: ".vg-set" },
|
|
]);
|
|
});
|
|
|
|
test("admin 화면이 모든 경계 폭에서 온전하다", async ({ page }) => {
|
|
test.setTimeout(600_000);
|
|
await signInAsAdmin(page);
|
|
await runSweep(page, [
|
|
{ label: "admin/overview", page: "admin-console", url: "/admin", ready: ".vgops-root" },
|
|
{ label: "admin/users", page: "admin-console", url: "/admin/users", ready: ".vgops-root" },
|
|
{ label: "admin/access", page: "admin-console", url: "/admin/access", ready: ".vgops-root" },
|
|
{ label: "admin/tickets", page: "admin-console", url: "/admin/tickets", ready: ".vgops-root" },
|
|
{ label: "admin/ai", page: "admin-ai", url: "/admin/ai", ready: ".aic" },
|
|
]);
|
|
});
|
|
|
|
test.afterAll(() => {
|
|
// 격리 목록 위생 관리: 재현되지 않은 항목은 CSS 가 고쳐졌다는 뜻이니 지우면 된다.
|
|
// (일부 테스트만 -g 로 돌리면 당연히 미재현으로 찍히므로 실패시키지 않는다)
|
|
for (const known of KNOWN_APP_DEFECTS) {
|
|
if (!quarantineHits.has(known)) {
|
|
console.log(
|
|
`[breakpoint-sweep][격리 항목 미재현] ${known.route} / ${known.kind} / ${known.element} — 고쳐졌다면 KNOWN_APP_DEFECTS 에서 삭제하라`,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
});
|