모바일 메뉴와 주요 화면 UX 개선 및 시각 검수
This commit is contained in:
parent
89b5093ec7
commit
e8b770e4d9
50 changed files with 3189 additions and 457 deletions
|
|
@ -145,6 +145,322 @@ interface ClipReport {
|
|||
}>;
|
||||
}
|
||||
|
||||
type ScrollOwner = {
|
||||
id: string;
|
||||
token: string;
|
||||
label: string;
|
||||
clientHeight: number;
|
||||
clientWidth: number;
|
||||
maxTop: number;
|
||||
maxLeft: number;
|
||||
top: number;
|
||||
left: number;
|
||||
};
|
||||
|
||||
type CaptureManifestRecord = {
|
||||
capturedAt: string;
|
||||
screen: string;
|
||||
viewport: { width: number; height: number; label: string };
|
||||
kind: "full-page" | "vertical-segment" | "horizontal-end" | "scroll-reset" | "scroll-excluded";
|
||||
owner: Pick<ScrollOwner, "id" | "label" | "clientHeight" | "clientWidth" | "maxTop" | "maxLeft">;
|
||||
offset: { top: number; left: number };
|
||||
path: string | null;
|
||||
reason?: string;
|
||||
ownerToken?: string;
|
||||
ownerMatchCount?: number;
|
||||
theoreticalMaxLeft?: number;
|
||||
actualMaxLeft?: number;
|
||||
};
|
||||
|
||||
type ScrollPosition = {
|
||||
token: string;
|
||||
matchCount: number;
|
||||
top: number;
|
||||
left: number;
|
||||
clientHeight: number;
|
||||
clientWidth: number;
|
||||
scrollHeight: number;
|
||||
scrollWidth: number;
|
||||
maxTop: number;
|
||||
maxLeft: number;
|
||||
rect: { top: number; bottom: number; left: number; right: number };
|
||||
};
|
||||
|
||||
type HorizontalLimitProbe = {
|
||||
token: string;
|
||||
matchCount: number;
|
||||
originalLeft: number;
|
||||
theoreticalMaxLeft: number;
|
||||
actualMaxLeft: number;
|
||||
};
|
||||
|
||||
const SCROLL_OWNER_ATTRIBUTE = "data-layout-gate-scroll-owner";
|
||||
const MAX_SCROLL_SEGMENTS = 30;
|
||||
let scrollOwnerGeneration = 0;
|
||||
|
||||
async function writeCaptureManifest(record: CaptureManifestRecord) {
|
||||
await fs.appendFile(
|
||||
path.join(SHOT_DIR, "capture-manifest.jsonl"),
|
||||
`${JSON.stringify(record)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
async function resetScrollableOwners(page: Page): Promise<ScrollOwner[]> {
|
||||
const generation = ++scrollOwnerGeneration;
|
||||
return page.evaluate(async ({ ownerAttribute, ownerGeneration }) => {
|
||||
const isVisible = (element: HTMLElement) => {
|
||||
const style = getComputedStyle(element);
|
||||
const rect = element.getBoundingClientRect();
|
||||
return (
|
||||
style.display !== "none" &&
|
||||
style.visibility !== "hidden" &&
|
||||
Number(style.opacity) !== 0 &&
|
||||
rect.width > 0 &&
|
||||
rect.height > 0
|
||||
);
|
||||
};
|
||||
for (const previousOwner of Array.from(document.querySelectorAll<HTMLElement>(`[${ownerAttribute}]`))) {
|
||||
previousOwner.removeAttribute(ownerAttribute);
|
||||
}
|
||||
|
||||
const owners = new Map<HTMLElement, { id: string; token: string; label: string }>();
|
||||
let nextId = 0;
|
||||
const add = (element: HTMLElement | null, label: string, preferredId?: string) => {
|
||||
if (!element || owners.has(element)) return;
|
||||
const id = preferredId ?? `scroll-${nextId++}`;
|
||||
owners.set(element, { id, token: `owner-${ownerGeneration}-${id}`, label });
|
||||
};
|
||||
|
||||
add(document.scrollingElement as HTMLElement | null, "document", "document");
|
||||
add(document.querySelector<HTMLElement>("#root"), "#root", "root");
|
||||
add(document.querySelector<HTMLElement>(".vg-main"), ".vg-main", "vg-main");
|
||||
add(document.querySelector<HTMLElement>("main"), "main", "main");
|
||||
|
||||
for (const element of Array.from(document.querySelectorAll<HTMLElement>("*"))) {
|
||||
if (!isVisible(element)) continue;
|
||||
const style = getComputedStyle(element);
|
||||
const canScrollY =
|
||||
(style.overflowY === "auto" || style.overflowY === "scroll" || style.overflowY === "overlay") &&
|
||||
element.scrollHeight > element.clientHeight + 1;
|
||||
const canScrollX =
|
||||
(style.overflowX === "auto" || style.overflowX === "scroll" || style.overflowX === "overlay") &&
|
||||
element.scrollWidth > element.clientWidth + 1;
|
||||
if (!canScrollY && !canScrollX) continue;
|
||||
const tag = element.tagName.toLowerCase();
|
||||
const identifier = element.id ? `#${element.id}` : element.classList.length > 0 ? `.${element.classList[0]}` : tag;
|
||||
add(element, identifier);
|
||||
}
|
||||
|
||||
for (const [element, owner] of owners) {
|
||||
element.setAttribute(ownerAttribute, owner.token);
|
||||
element.scrollTop = 0;
|
||||
element.scrollLeft = 0;
|
||||
}
|
||||
const ownerIds = Array.from(owners.values(), (owner) => owner.id);
|
||||
const ownerTokens = Array.from(owners.values(), (owner) => owner.token);
|
||||
if (new Set(ownerIds).size !== ownerIds.length || new Set(ownerTokens).size !== ownerTokens.length) {
|
||||
throw new Error("Layout capture scroll-owner assignment contains duplicate IDs.");
|
||||
}
|
||||
for (const owner of owners.values()) {
|
||||
const matches = document.querySelectorAll(`[${ownerAttribute}="${owner.token}"]`).length;
|
||||
if (matches !== 1) {
|
||||
throw new Error(`Layout capture scroll-owner ${owner.id} resolved to ${matches} nodes after reset.`);
|
||||
}
|
||||
}
|
||||
window.scrollTo(0, 0);
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
|
||||
return Array.from(owners, ([element, owner]) => ({
|
||||
...owner,
|
||||
clientHeight: element.clientHeight,
|
||||
clientWidth: element.clientWidth,
|
||||
maxTop: Math.max(0, element.scrollHeight - element.clientHeight),
|
||||
maxLeft: Math.max(0, element.scrollWidth - element.clientWidth),
|
||||
top: element.scrollTop,
|
||||
left: element.scrollLeft,
|
||||
}));
|
||||
}, { ownerAttribute: SCROLL_OWNER_ATTRIBUTE, ownerGeneration: generation });
|
||||
}
|
||||
|
||||
async function scrollOwnerTo(page: Page, owner: ScrollOwner, top: number, left = 0) {
|
||||
return page.evaluate(
|
||||
async ({ ownerAttribute, ownerId: requestedOwnerId, ownerToken, top: requestedTop, left: requestedLeft }) => {
|
||||
const matches = Array.from(document.querySelectorAll<HTMLElement>(`[${ownerAttribute}="${ownerToken}"]`));
|
||||
if (matches.length !== 1) {
|
||||
throw new Error(`Scroll owner ${requestedOwnerId} expected one node for ${ownerToken}, found ${matches.length}.`);
|
||||
}
|
||||
const element = matches[0];
|
||||
element.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const maxTop = Math.max(0, element.scrollHeight - element.clientHeight);
|
||||
const maxLeft = Math.max(0, element.scrollWidth - element.clientWidth);
|
||||
const targetTop = Math.min(requestedTop, maxTop);
|
||||
const targetLeft = Math.min(requestedLeft, maxLeft);
|
||||
element.scrollTop = targetTop;
|
||||
element.scrollLeft = targetLeft;
|
||||
if (requestedOwnerId === "document") window.scrollTo(targetLeft, targetTop);
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
token: ownerToken,
|
||||
matchCount: matches.length,
|
||||
top: element.scrollTop,
|
||||
left: element.scrollLeft,
|
||||
clientHeight: element.clientHeight,
|
||||
clientWidth: element.clientWidth,
|
||||
scrollHeight: element.scrollHeight,
|
||||
scrollWidth: element.scrollWidth,
|
||||
maxTop: Math.max(0, element.scrollHeight - element.clientHeight),
|
||||
maxLeft: Math.max(0, element.scrollWidth - element.clientWidth),
|
||||
rect: { top: rect.top, bottom: rect.bottom, left: rect.left, right: rect.right },
|
||||
};
|
||||
},
|
||||
{ ownerAttribute: SCROLL_OWNER_ATTRIBUTE, ownerId: owner.id, ownerToken: owner.token, top, left },
|
||||
);
|
||||
}
|
||||
|
||||
async function probeNativeMaxLeft(page: Page, owner: ScrollOwner): Promise<HorizontalLimitProbe> {
|
||||
return page.evaluate(
|
||||
async ({ ownerAttribute, ownerId: requestedOwnerId, ownerToken }) => {
|
||||
const matches = Array.from(document.querySelectorAll<HTMLElement>(`[${ownerAttribute}="${ownerToken}"]`));
|
||||
if (matches.length !== 1) {
|
||||
throw new Error(`Scroll owner ${requestedOwnerId} expected one node for ${ownerToken}, found ${matches.length}.`);
|
||||
}
|
||||
const element = matches[0];
|
||||
const originalLeft = element.scrollLeft;
|
||||
const theoreticalMaxLeft = Math.max(0, element.scrollWidth - element.clientWidth);
|
||||
element.scrollLeft = Number.MAX_SAFE_INTEGER;
|
||||
if (requestedOwnerId === "document") window.scrollTo(Number.MAX_SAFE_INTEGER, window.scrollY);
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const actualMaxLeft = element.scrollLeft;
|
||||
element.scrollLeft = originalLeft;
|
||||
if (requestedOwnerId === "document") window.scrollTo(originalLeft, window.scrollY);
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
return {
|
||||
token: ownerToken,
|
||||
matchCount: matches.length,
|
||||
originalLeft,
|
||||
theoreticalMaxLeft,
|
||||
actualMaxLeft,
|
||||
};
|
||||
},
|
||||
{ ownerAttribute: SCROLL_OWNER_ATTRIBUTE, ownerId: owner.id, ownerToken: owner.token },
|
||||
);
|
||||
}
|
||||
|
||||
function segmentOffsets(maxOffset: number, clientSize: number, owner: ScrollOwner) {
|
||||
if (maxOffset <= 0) return [];
|
||||
const step = Math.max(1, Math.floor(clientSize * 0.8));
|
||||
const offsets = [0];
|
||||
for (let offset = step; offset < maxOffset; offset += step) offsets.push(offset);
|
||||
if (offsets[offsets.length - 1] !== maxOffset) offsets.push(maxOffset);
|
||||
if (offsets.length > MAX_SCROLL_SEGMENTS) {
|
||||
throw new Error(
|
||||
`[${owner.label}] needs ${offsets.length} scroll captures; the ${MAX_SCROLL_SEGMENTS}-segment ceiling would omit its bottom.`,
|
||||
);
|
||||
}
|
||||
return offsets;
|
||||
}
|
||||
|
||||
async function captureScrollableSegments(
|
||||
page: Page,
|
||||
screen: string,
|
||||
viewport: (typeof GATE_WIDTHS)[number],
|
||||
owners: ScrollOwner[],
|
||||
) {
|
||||
for (const owner of owners) {
|
||||
const initialPosition = await scrollOwnerTo(page, owner, 0);
|
||||
expect(initialPosition.matchCount, `[${screen} @ ${viewport.label}] ${owner.label} owner identity`).toBe(1);
|
||||
const liveOwner = {
|
||||
...owner,
|
||||
clientHeight: initialPosition.clientHeight,
|
||||
clientWidth: initialPosition.clientWidth,
|
||||
maxTop: initialPosition.maxTop,
|
||||
maxLeft: initialPosition.maxLeft,
|
||||
};
|
||||
if (owner.maxTop > 0 && liveOwner.maxTop <= 0) {
|
||||
await writeCaptureManifest({
|
||||
capturedAt: new Date().toISOString(),
|
||||
screen,
|
||||
viewport,
|
||||
kind: "scroll-excluded",
|
||||
owner: liveOwner,
|
||||
offset: { top: initialPosition.top, left: initialPosition.left },
|
||||
path: null,
|
||||
reason: "Owner stopped scrolling after it was brought into the capture viewport.",
|
||||
ownerToken: owner.token,
|
||||
ownerMatchCount: initialPosition.matchCount,
|
||||
});
|
||||
}
|
||||
const offsets = segmentOffsets(liveOwner.maxTop, liveOwner.clientHeight, liveOwner);
|
||||
for (const [segmentIndex, top] of offsets.entries()) {
|
||||
const offset = await scrollOwnerTo(page, owner, top);
|
||||
expect(offset.matchCount, `[${screen} @ ${viewport.label}] ${owner.label} owner identity`).toBe(1);
|
||||
const reachableTarget = Math.min(top, offset.maxTop);
|
||||
expect(
|
||||
Math.abs(offset.top - reachableTarget),
|
||||
`[${screen} @ ${viewport.label}] ${owner.label} vertical capture offset`,
|
||||
).toBeLessThanOrEqual(1);
|
||||
const shotPath = path.join(
|
||||
SHOT_DIR,
|
||||
`${screen}__${viewport.label}__${owner.id}__segment-${String(segmentIndex).padStart(2, "0")}.png`,
|
||||
);
|
||||
await page.screenshot({ path: shotPath, fullPage: false });
|
||||
await writeCaptureManifest({
|
||||
capturedAt: new Date().toISOString(),
|
||||
screen,
|
||||
viewport,
|
||||
kind: "vertical-segment",
|
||||
owner: { ...liveOwner, maxTop: offset.maxTop, maxLeft: offset.maxLeft },
|
||||
offset,
|
||||
path: path.relative(process.cwd(), shotPath),
|
||||
ownerToken: owner.token,
|
||||
ownerMatchCount: offset.matchCount,
|
||||
});
|
||||
}
|
||||
|
||||
if (liveOwner.maxLeft > 0) {
|
||||
const horizontalProbe = await probeNativeMaxLeft(page, owner);
|
||||
expect(horizontalProbe.matchCount, `[${screen} @ ${viewport.label}] ${owner.label} owner identity`).toBe(1);
|
||||
expect(horizontalProbe.theoreticalMaxLeft, `[${screen} @ ${viewport.label}] ${owner.label} theoretical horizontal maximum`).toBe(liveOwner.maxLeft);
|
||||
const offset = await scrollOwnerTo(page, owner, 0, horizontalProbe.actualMaxLeft);
|
||||
expect(offset.matchCount, `[${screen} @ ${viewport.label}] ${owner.label} owner identity`).toBe(1);
|
||||
expect(
|
||||
Math.abs(offset.left - horizontalProbe.actualMaxLeft),
|
||||
`[${screen} @ ${viewport.label}] ${owner.label} horizontal capture offset`,
|
||||
).toBeLessThanOrEqual(1);
|
||||
const shotPath = path.join(SHOT_DIR, `${screen}__${viewport.label}__${owner.id}__horizontal-end.png`);
|
||||
await page.screenshot({ path: shotPath, fullPage: false });
|
||||
await writeCaptureManifest({
|
||||
capturedAt: new Date().toISOString(),
|
||||
screen,
|
||||
viewport,
|
||||
kind: "horizontal-end",
|
||||
owner: { ...liveOwner, maxTop: offset.maxTop, maxLeft: offset.maxLeft },
|
||||
offset,
|
||||
path: path.relative(process.cwd(), shotPath),
|
||||
ownerToken: owner.token,
|
||||
ownerMatchCount: offset.matchCount,
|
||||
theoreticalMaxLeft: horizontalProbe.theoreticalMaxLeft,
|
||||
actualMaxLeft: horizontalProbe.actualMaxLeft,
|
||||
});
|
||||
const afterActualMax = await scrollOwnerTo(
|
||||
page,
|
||||
owner,
|
||||
0,
|
||||
horizontalProbe.actualMaxLeft + Math.max(100, offset.clientWidth),
|
||||
);
|
||||
expect(afterActualMax.matchCount, `[${screen} @ ${viewport.label}] ${owner.label} owner identity`).toBe(1);
|
||||
expect(
|
||||
Math.abs(afterActualMax.left - horizontalProbe.actualMaxLeft),
|
||||
`[${screen} @ ${viewport.label}] ${owner.label} native horizontal maximum`,
|
||||
).toBeLessThanOrEqual(1);
|
||||
}
|
||||
}
|
||||
await resetScrollableOwners(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans every visible interactive control and prominent text container for
|
||||
* either (a) extending beyond the viewport horizontally, or (b) clipping its own
|
||||
|
|
@ -261,10 +577,18 @@ async function gateScreen(
|
|||
await page.setViewportSize({ width: vp.width, height: vp.height });
|
||||
await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => r(null))));
|
||||
await prepareReady();
|
||||
await page.evaluate(() => {
|
||||
window.scrollTo(0, 0);
|
||||
return new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
|
||||
});
|
||||
const owners = await resetScrollableOwners(page);
|
||||
for (const owner of owners) {
|
||||
await writeCaptureManifest({
|
||||
capturedAt: new Date().toISOString(),
|
||||
screen,
|
||||
viewport: vp,
|
||||
kind: "scroll-reset",
|
||||
owner,
|
||||
offset: { top: owner.top, left: owner.left },
|
||||
path: null,
|
||||
});
|
||||
}
|
||||
|
||||
await expect(
|
||||
page.locator("html"),
|
||||
|
|
@ -306,10 +630,28 @@ async function gateScreen(
|
|||
)}`,
|
||||
).toEqual([]);
|
||||
|
||||
const topShotPath = path.join(SHOT_DIR, `${screen}__${vp.label}.png`);
|
||||
await page.screenshot({
|
||||
path: path.join(SHOT_DIR, `${screen}__${vp.label}.png`),
|
||||
path: topShotPath,
|
||||
fullPage: true,
|
||||
});
|
||||
await writeCaptureManifest({
|
||||
capturedAt: new Date().toISOString(),
|
||||
screen,
|
||||
viewport: vp,
|
||||
kind: "full-page",
|
||||
owner: {
|
||||
id: "document",
|
||||
label: "document",
|
||||
clientHeight: page.viewportSize()?.height ?? vp.height,
|
||||
clientWidth: page.viewportSize()?.width ?? vp.width,
|
||||
maxTop: 0,
|
||||
maxLeft: 0,
|
||||
},
|
||||
offset: { top: 0, left: 0 },
|
||||
path: path.relative(process.cwd(), topShotPath),
|
||||
});
|
||||
await captureScrollableSegments(page, screen, vp, owners);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -445,10 +787,11 @@ async function expectSupervisorReviewNoDeadGaps(page: Page) {
|
|||
}
|
||||
|
||||
test.describe("layout visual gate @single-run", () => {
|
||||
test.describe.configure({ mode: "serial" });
|
||||
test.describe.configure({ mode: "serial", timeout: 90_000 });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await ensureShotDir();
|
||||
await fs.writeFile(path.join(SHOT_DIR, "capture-manifest.jsonl"), "", "utf8");
|
||||
});
|
||||
|
||||
// 이 게이트는 다크 표면을 기준으로 레이아웃을 캡처하고, 라이트 검사는 각 테스트가
|
||||
|
|
@ -499,24 +842,52 @@ test.describe("layout visual gate @single-run", () => {
|
|||
await page.goto("/learn");
|
||||
await gateScreen(page, "learner-home", async () => {
|
||||
await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.locator(".lh-dashboard-status .lh-metric-card").first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.locator(".lh-work-cluster")).toBeVisible({ timeout: 15_000 });
|
||||
// 대시보드 탭은 모든 폭에서 상시 노출된다 — 탭별 콘텐츠를 확인 후 기본 탭으로 복귀.
|
||||
const dashTabs = page.locator(".lh-tabs");
|
||||
if (await dashTabs.isVisible().catch(() => false)) {
|
||||
await dashTabs.locator("button", { hasText: "기록 · 리뷰" }).click();
|
||||
await expect(page.locator(".lh-compact-list li").first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
await expect(dashTabs).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await dashTabs.getByRole("tab", { name: "오늘의 회기", exact: true }).click();
|
||||
await expect(page.locator("[data-learner-primary-action]")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.locator("#lh-panel-today .lh-session-focus")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await dashTabs.getByRole("tab", { name: "성장 지표", exact: true }).click();
|
||||
const growthPanel = page.locator("#lh-panel-growth");
|
||||
const growthStatus = growthPanel.locator(".lh-dashboard-status");
|
||||
const metricCards = growthStatus.locator(".lh-metric-card");
|
||||
await expect(growthStatus).toBeVisible({ timeout: 15_000 });
|
||||
await expect(metricCards).toHaveCount(4);
|
||||
await expect(metricCards.first()).toBeVisible();
|
||||
const metricGeometry = await growthStatus.evaluate((status) => {
|
||||
const panel = status.closest<HTMLElement>("#lh-panel-growth");
|
||||
if (!panel) return null;
|
||||
const statusRect = status.getBoundingClientRect();
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
const cards = Array.from(status.querySelectorAll<HTMLElement>(".lh-metric-card")).map((card) => {
|
||||
const rect = card.getBoundingClientRect();
|
||||
return { width: rect.width, height: rect.height };
|
||||
});
|
||||
await dashTabs.locator("button", { hasText: "오늘의 회기" }).click();
|
||||
await expect(page.locator(".lh-work-cluster")).toBeVisible({ timeout: 15_000 });
|
||||
} else {
|
||||
await expect(page.locator(".lh-compact-list li").first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
return {
|
||||
leftGap: Math.abs(statusRect.left - panelRect.left),
|
||||
rightGap: Math.abs(panelRect.right - statusRect.right),
|
||||
cards,
|
||||
};
|
||||
});
|
||||
expect(metricGeometry, "growth metrics should stay inside the growth panel").not.toBeNull();
|
||||
expect(metricGeometry!.leftGap, "growth metrics should span the panel at every gate width").toBeLessThanOrEqual(1);
|
||||
expect(metricGeometry!.rightGap, "growth metrics should span the panel at every gate width").toBeLessThanOrEqual(1);
|
||||
expect(metricGeometry!.cards.every((card) => card.width > 0 && card.height > 0)).toBe(true);
|
||||
const viewport = page.viewportSize();
|
||||
const viewportLabel = GATE_WIDTHS.find(
|
||||
(candidate) => candidate.width === viewport?.width && candidate.height === viewport?.height,
|
||||
)?.label;
|
||||
await page.screenshot({
|
||||
path: path.join(SHOT_DIR, `learner-home-growth__${viewportLabel ?? "unknown"}.png`),
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
await dashTabs.getByRole("tab", { name: "기록 · 리뷰", exact: true }).click();
|
||||
await expect(page.locator(".lh-compact-list li").first()).toBeVisible({ timeout: 15_000 });
|
||||
await dashTabs.getByRole("tab", { name: "오늘의 회기", exact: true }).click();
|
||||
await expect(page.locator(".lh-work-cluster")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const shellScroll = await page.evaluate(async () => {
|
||||
const nav = document.querySelector<HTMLElement>(".vg-nav");
|
||||
|
|
@ -705,9 +1076,13 @@ test.describe("layout visual gate @single-run", () => {
|
|||
await signInAsTeacher(page);
|
||||
await page.goto("/teach");
|
||||
await gateScreen(page, "professor", async () => {
|
||||
await expect(page.locator(".pf-panel, .pf-shell, main").first()).toBeVisible({
|
||||
await expect(page.locator(".pf-root--console")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.locator(".pf-triage__copy > span").last()).toContainText("실제 기록", {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.getByRole("button", { name: "새로고침" })).toBeEnabled();
|
||||
await expectProfessorSummaryGap(page);
|
||||
});
|
||||
});
|
||||
|
|
@ -778,6 +1153,8 @@ test.describe("layout visual gate @single-run", () => {
|
|||
await expect(page.locator(".ps-overview")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByRole("heading", { name: "페르소나 운영" })).toBeVisible();
|
||||
await expect(page.getByRole("navigation", { name: "페르소나 관리 영역" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "새로고침" })).toBeEnabled({ timeout: 15_000 });
|
||||
await expect(page.locator(".ps-error")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -788,6 +1165,7 @@ test.describe("layout visual gate @single-run", () => {
|
|||
await expect(page.locator(".ps-authoring-layout")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByRole("navigation", { name: "페르소나 작성 단계" })).toBeVisible();
|
||||
await expect(page.getByRole("tab", { name: "개요" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "새로고침" })).toBeEnabled({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -798,6 +1176,8 @@ test.describe("layout visual gate @single-run", () => {
|
|||
await page.goto("/admin");
|
||||
await gateScreen(page, "admin", async () => {
|
||||
await expect(page.locator("main").first()).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByRole("button", { name: "새로고침" })).toBeEnabled({ timeout: 15_000 });
|
||||
await expect(page.locator(".vgops-status > div > span")).not.toHaveText("연결 확인 중");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -811,6 +1191,12 @@ test.describe("layout visual gate @single-run", () => {
|
|||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.locator(".aic-grid")).toBeVisible();
|
||||
const adminAi = page.getByTestId("admin-ai-page");
|
||||
await expect(adminAi).toHaveAttribute("aria-busy", "false", { timeout: 15_000 });
|
||||
await expect(adminAi.getByRole("button", { name: "새로고침", exact: true })).toBeEnabled();
|
||||
await expect(page.locator(".aic-source b")).not.toHaveText("계량 원천 확인 중");
|
||||
await expect(page.getByText("현재 AI 엔진 설정을 불러오는 중입니다.")).toHaveCount(0);
|
||||
await expect(page.locator('[data-testid="provider-connections-panel"] .aic-runtime')).not.toHaveText("확인 중");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -829,6 +1215,10 @@ test.describe("layout visual gate @single-run", () => {
|
|||
]) {
|
||||
await page.setViewportSize(viewport);
|
||||
await expectNoHorizontalOverflow(page);
|
||||
await expect.poll(() => page.locator('[data-admin-section="users"]').evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.left >= -1 && rect.right <= window.innerWidth + 1;
|
||||
})).toBe(true);
|
||||
const scrollReport = await page.locator(".vgops-user-table-scroll").evaluate((element) => {
|
||||
element.scrollLeft = 0;
|
||||
const report = {
|
||||
|
|
@ -849,6 +1239,7 @@ test.describe("layout visual gate @single-run", () => {
|
|||
await page.locator(".vgops-user-table-scroll").evaluate((element) => {
|
||||
element.scrollLeft = 0;
|
||||
});
|
||||
await resetScrollableOwners(page);
|
||||
await page.screenshot({
|
||||
path: path.join(SHOT_DIR, `admin-users__${viewport.width}-reference-dark.png`),
|
||||
fullPage: true,
|
||||
|
|
@ -871,6 +1262,8 @@ test.describe("layout visual gate @single-run", () => {
|
|||
await page.goto("/settings");
|
||||
await gateScreen(page, "settings", async () => {
|
||||
await expect(page.locator("main").first()).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.locator(".vg-set")).toHaveAttribute("aria-busy", "false", { timeout: 15_000 });
|
||||
await expect(page.locator(".vg-set__callout--warn")).toHaveCount(0);
|
||||
});
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await page.getByRole("button", { name: "라이트 모드로" }).click();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue