모바일 메뉴와 주요 화면 UX 개선 및 시각 검수
This commit is contained in:
parent
89b5093ec7
commit
e8b770e4d9
50 changed files with 3189 additions and 457 deletions
|
|
@ -824,7 +824,7 @@ test.describe("admin route guards", () => {
|
|||
{ path: "/admin/ai", heading: "AI 운영과 DB 계량" },
|
||||
{ path: "/admin/users", heading: "가입 승인과 권한 관리" },
|
||||
{ path: "/admin/access", heading: "역할, 그룹, 접근 범위" },
|
||||
{ path: "/admin/tickets", heading: "사용자 문제 큐" },
|
||||
{ path: "/admin/tickets", heading: "지원 요청" },
|
||||
] as const;
|
||||
const sessions = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ test.describe("G1 치료 동맹 펄스", () => {
|
|||
test("AI 관점을 공개하기 전에 학습자 자기평가를 잠근다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await routeReviewUser(page, "learner");
|
||||
let pulse: ReturnType<typeof pulseFixture> | null = null;
|
||||
let submittedBody: Record<string, unknown> | null = null;
|
||||
|
|
@ -156,13 +157,20 @@ test.describe("G1 치료 동맹 펄스", () => {
|
|||
await expect(page.getByText("내담자 관점", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText("관찰자 관점", { exact: true })).toHaveCount(0);
|
||||
|
||||
const submit = page.getByRole("button", { name: "내 판단 잠그고 관점 비교" });
|
||||
await expect(submit).toBeDisabled();
|
||||
|
||||
const axes = page.locator(".ap-axis");
|
||||
await axes.nth(0).locator(".ap-scale__option").nth(3).click();
|
||||
await axes.nth(1).locator(".ap-scale__option").nth(2).click();
|
||||
await axes.nth(2).locator(".ap-scale__option").nth(4).click();
|
||||
await page.getByText("내 판단의 근거 장면 선택").click();
|
||||
await page.getByRole("checkbox").first().check();
|
||||
await page.getByRole("button", { name: "내 판단 잠그고 관점 비교" }).click();
|
||||
await expect(submit).toBeEnabled();
|
||||
const submitBox = await submit.boundingBox();
|
||||
expect(submitBox).not.toBeNull();
|
||||
expect(submitBox!.height).toBeGreaterThanOrEqual(44);
|
||||
await submit.click();
|
||||
|
||||
await expect(page.getByText("내 판단이 잠겼습니다")).toBeVisible();
|
||||
await expect(page.getByText("내담자 관점", { exact: true })).toHaveCount(0);
|
||||
|
|
|
|||
|
|
@ -293,14 +293,14 @@ test.describe("continuous improvement admin cockpit", () => {
|
|||
|
||||
await page.goto("/admin/continuous-improvement");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "승격보다 근거를 먼저 본다" })).toBeVisible();
|
||||
await expect(page.getByText("합성 콘텐츠 검증 파이프라인")).toBeVisible();
|
||||
await expect(page.getByText("모델 변경 · 릴리스 게이트")).toBeVisible();
|
||||
await expect(page.getByText("사건 회귀 DAG")).toBeVisible();
|
||||
await expect(page.getByText("모니터 · 롤백 원장")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "개선안 검토와 승인" })).toBeVisible();
|
||||
await expect(page.getByText("생성 콘텐츠 검토")).toBeVisible();
|
||||
await expect(page.getByText("모델 변경과 릴리스 검토")).toBeVisible();
|
||||
await expect(page.getByText("문제 재발 방지 기록")).toBeVisible();
|
||||
await expect(page.getByText("적용과 복구 이력")).toBeVisible();
|
||||
await expect(page.getByText("롤백 실행 대기 · 실행기 미구성", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("롤백 실행 실패", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("롤백은 실행됐지만 verification 이벤트가 아직 없어")).toBeVisible();
|
||||
await expect(page.getByText("복구는 실행됐지만 검증 기록이 아직 없습니다")).toBeVisible();
|
||||
const contentCandidate = page.locator(
|
||||
'[data-qualification-id="20000000-0000-4000-8000-000000000001"]',
|
||||
);
|
||||
|
|
@ -314,7 +314,7 @@ test.describe("continuous improvement admin cockpit", () => {
|
|||
await expect(contentCandidate.getByRole("button", { name: "승인 기록" })).toBeDisabled();
|
||||
await contentReason.fill("합성 경계와 수련 목표를 직접 검수함");
|
||||
await contentReason.press("Enter");
|
||||
await expect(contentCandidate.getByText("콘텐츠 승인 원장 기록됨")).toBeVisible();
|
||||
await expect(contentCandidate.getByText("콘텐츠 승인 기록됨")).toBeVisible();
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
|
||||
await page.getByTestId("continuous-improvement-cockpit").screenshot({
|
||||
path: testInfo.outputPath("g8-continuous-improvement-before-approval.png"),
|
||||
|
|
@ -325,7 +325,7 @@ test.describe("continuous improvement admin cockpit", () => {
|
|||
await expect(missingGate.getByRole("radio", { name: "승인", exact: true })).toBeDisabled();
|
||||
await expect(missingGate.getByRole("radio", { name: "보류", exact: true })).toBeEnabled();
|
||||
await expect(missingGate.getByRole("radio", { name: "반려", exact: true })).toBeEnabled();
|
||||
await expect(missingGate.getByText("필수 증거 4종 미완료로 승인 차단")).toBeVisible();
|
||||
await expect(missingGate.getByText("필수 증거 4종이 갖춰지지 않아 승인할 수 없습니다")).toBeVisible();
|
||||
await missingGate.screenshot({
|
||||
path: testInfo.outputPath("g8-human-decision-approval-blocked.png"),
|
||||
animations: "disabled",
|
||||
|
|
@ -338,10 +338,10 @@ test.describe("continuous improvement admin cockpit", () => {
|
|||
await modelGate.getByRole("radio", { name: "승인", exact: true }).check();
|
||||
const approvalButton = modelGate.getByRole("button", { name: "승인 기록" });
|
||||
await expect(approvalButton).toBeDisabled();
|
||||
await expect(modelGate.getByText("승인 사유를 먼저 입력해야 함")).toBeVisible();
|
||||
await expect(modelGate.getByText("승인 사유를 먼저 입력해야 합니다")).toBeVisible();
|
||||
await approvalReason.fill("기준선과 rollback runbook을 독립 검토함");
|
||||
await expect(approvalButton).toBeEnabled();
|
||||
await expect(modelGate.getByText("승인 결정과 사유를 append-only 원장에 기록할 준비됨")).toBeVisible();
|
||||
await expect(modelGate.getByText("승인 결정과 사유를 변경 이력에 기록할 준비가 되었습니다")).toBeVisible();
|
||||
await modelGate.screenshot({
|
||||
path: testInfo.outputPath("g8-human-decision-approval-ready.png"),
|
||||
animations: "disabled",
|
||||
|
|
@ -351,7 +351,7 @@ test.describe("continuous improvement admin cockpit", () => {
|
|||
await approvalReason.focus();
|
||||
await approvalReason.press("Enter");
|
||||
|
||||
await expect(modelGate.getByText("승격 승인 원장 기록됨")).toBeVisible();
|
||||
await expect(modelGate.getByText("변경 승인 기록됨")).toBeVisible();
|
||||
expect(mock.approvalBodies).toHaveLength(2);
|
||||
expect(mock.approvalBodies[0]).toMatchObject({
|
||||
target_kind: "content_qualification",
|
||||
|
|
@ -388,14 +388,14 @@ test.describe("continuous improvement admin cockpit", () => {
|
|||
const releaseGate = page.locator(`[data-gate-id="${RELEASE_GATE_ID}"]`);
|
||||
const releaseApprove = releaseGate.getByRole("radio", { name: "승인", exact: true });
|
||||
await expect(releaseApprove).toBeDisabled();
|
||||
await expect(releaseGate.getByText("필수 증거 4종 미완료로 승인 차단")).toBeVisible();
|
||||
await expect(releaseGate.getByText("필수 증거 4종이 갖춰지지 않아 승인할 수 없습니다")).toBeVisible();
|
||||
await expect(releaseGate.getByRole("radio", { name: "보류", exact: true })).toBeEnabled();
|
||||
await expect(releaseGate.getByRole("radio", { name: "반려", exact: true })).toBeEnabled();
|
||||
|
||||
await releaseGate.getByRole("radio", { name: "보류", exact: true }).check();
|
||||
await releaseGate.getByLabel("사람 결정 사유").fill("rollback 증거 보강 뒤 다시 검토");
|
||||
await releaseGate.getByRole("button", { name: "보류 기록" }).click();
|
||||
await expect(releaseGate.getByText("보류 원장 기록됨")).toBeVisible();
|
||||
await expect(releaseGate.getByText("보류 기록됨")).toBeVisible();
|
||||
|
||||
const contentCandidate = page.locator(
|
||||
'[data-qualification-id="20000000-0000-4000-8000-000000000001"]',
|
||||
|
|
@ -403,13 +403,13 @@ test.describe("continuous improvement admin cockpit", () => {
|
|||
await contentCandidate.getByRole("radio", { name: "반려", exact: true }).check();
|
||||
await contentCandidate.getByLabel("콘텐츠 결정 사유").fill("검수 기준을 충족하지 못한 후보");
|
||||
await contentCandidate.getByRole("button", { name: "반려 기록" }).click();
|
||||
await expect(contentCandidate.getByText("반려 원장 기록됨")).toBeVisible();
|
||||
await expect(contentCandidate.getByText("반려 기록됨")).toBeVisible();
|
||||
|
||||
const modelGate = page.locator(`[data-gate-id="${MODEL_GATE_ID}"]`);
|
||||
await modelGate.getByRole("radio", { name: "승인", exact: true }).check();
|
||||
await modelGate.getByLabel("사람 결정 사유").fill("독립 증거 4종과 승격 조건을 확인함");
|
||||
await modelGate.getByRole("button", { name: "승인 기록" }).click();
|
||||
await expect(modelGate.getByText("승격 승인 원장 기록됨")).toBeVisible();
|
||||
await expect(modelGate.getByText("변경 승인 기록됨")).toBeVisible();
|
||||
|
||||
expect(mock.approvalBodies).toHaveLength(3);
|
||||
expect(mock.approvalBodies[0]).toMatchObject({
|
||||
|
|
@ -436,16 +436,16 @@ test.describe("continuous improvement admin cockpit", () => {
|
|||
test("shows empty and degraded states without opening an approval action", async ({ page }) => {
|
||||
await installMock(page, { empty: true });
|
||||
await page.goto("/admin/continuous-improvement");
|
||||
await expect(page.getByText("검토할 콘텐츠 후보가 없어")).toBeVisible();
|
||||
await expect(page.getByText("대기 중인 게이트가 없어")).toBeVisible();
|
||||
await expect(page.getByText("등록된 운영 사건이 없어")).toBeVisible();
|
||||
await expect(page.getByText("검토할 콘텐츠 후보가 없습니다")).toBeVisible();
|
||||
await expect(page.getByText("대기 중인 변경 검토가 없습니다")).toBeVisible();
|
||||
await expect(page.getByText("등록된 운영 문제가 없습니다")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /승인 기록|결정 기록/ })).toHaveCount(0);
|
||||
|
||||
await page.unroute("**/api/**");
|
||||
await installMock(page, { status: 503 });
|
||||
await page.reload();
|
||||
await expect(page.getByRole("heading", { name: "개선 원장을 확인할 수 없어" })).toBeVisible();
|
||||
await expect(page.getByText(/승인 동작은 닫힌 상태/)).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "개선 기록을 확인할 수 없습니다" })).toBeVisible();
|
||||
await expect(page.getByText(/승인 동작은 사용할 수 없습니다/)).toBeVisible();
|
||||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -281,6 +281,31 @@ async function mockAdminAiSession(page: Page, state: AdminAiMockState) {
|
|||
}
|
||||
|
||||
test.describe("full sweep: admin ai operations", () => {
|
||||
test("제공자 조회 실패를 표시하고 다시 확인할 수 있다", async ({ page }) => {
|
||||
await mockAdminAiSession(page, createMockState());
|
||||
let attempts = 0;
|
||||
let recovered = false;
|
||||
await page.route("**/api/admin/providers", async (route) => {
|
||||
attempts += 1;
|
||||
await route.fulfill({
|
||||
status: recovered ? 200 : 500,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(!recovered
|
||||
? { detail: "제공자 상태 조회 실패" }
|
||||
: { providers: [], gateway_sync: { synced: false, detail: "연결 설정 대기" } }),
|
||||
});
|
||||
});
|
||||
await page.goto("/admin/ai");
|
||||
const panel = page.getByTestId("provider-connections-panel");
|
||||
await expect(panel.locator(".aic-runtime")).toHaveText("연결 상태 확인 실패");
|
||||
const failedAttempts = attempts;
|
||||
recovered = true;
|
||||
await panel.getByRole("button", { name: "다시 확인" }).click();
|
||||
await expect(panel.locator(".aic-runtime")).toHaveText("적용 대기");
|
||||
await expect(panel.getByRole("alert")).toHaveCount(0);
|
||||
expect(attempts).toBeGreaterThan(failedAttempts);
|
||||
});
|
||||
|
||||
// checklist: admin-ai-guard-pending-approval
|
||||
test("redirects a pending-approval admin from /admin/ai to /pending", async ({ page }) => {
|
||||
const state = createMockState({
|
||||
|
|
|
|||
|
|
@ -690,14 +690,14 @@ test.describe("admin console full-sweep (fixtures)", () => {
|
|||
mock.tickets = [makeMockTicket({ ticket_id: "filter-1", subject: "필터 대상 티켓" })];
|
||||
|
||||
await page.goto("/admin/tickets");
|
||||
await expect(page.getByRole("heading", { name: "사용자 문제 큐" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "지원 요청" })).toBeVisible();
|
||||
const clearButton = page.getByRole("button", { name: "초기화" });
|
||||
await expect(clearButton).toBeDisabled();
|
||||
|
||||
const searchRequest = page.waitForRequest(
|
||||
isTicketsRequest((params) => params.get("search") === "로그인 오류"),
|
||||
);
|
||||
await page.getByLabel("티켓 검색").fill("로그인 오류");
|
||||
await page.getByLabel("지원 요청 검색").fill("로그인 오류");
|
||||
await searchRequest;
|
||||
|
||||
const statusRequest = page.waitForRequest(
|
||||
|
|
@ -755,7 +755,7 @@ test.describe("admin console full-sweep (fixtures)", () => {
|
|||
);
|
||||
await clearButton.click();
|
||||
await resetRequest;
|
||||
await expect(page.getByLabel("티켓 검색")).toHaveValue("");
|
||||
await expect(page.getByLabel("지원 요청 검색")).toHaveValue("");
|
||||
await expect(page.getByLabel("상태 필터")).toHaveValue("all");
|
||||
await expect(page.getByLabel("카테고리 필터")).toHaveValue("all");
|
||||
await expect(page.getByLabel("우선순위 필터")).toHaveValue("all");
|
||||
|
|
@ -1220,13 +1220,13 @@ test.describe("admin console full-sweep (real API)", () => {
|
|||
await signInAsAdmin(page);
|
||||
|
||||
await page.goto("/admin/tickets");
|
||||
await expect(page.getByRole("heading", { name: "사용자 문제 큐" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "지원 요청" })).toBeVisible();
|
||||
|
||||
const searchRequest = page.waitForRequest(
|
||||
isTicketsRequest((params) => params.get("search") === "freeze-probe"),
|
||||
);
|
||||
// 실 DB 스케일에서는 이 fill에서 렌더러가 프리즈되어 어떤 요청도 나가지 않는다.
|
||||
await page.getByLabel("티켓 검색").fill("freeze-probe");
|
||||
await page.getByLabel("지원 요청 검색").fill("freeze-probe");
|
||||
await searchRequest;
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -329,9 +329,12 @@ function jsonRoute(body: unknown, status = 200) {
|
|||
/** 페르소나·회기 목록·대시보드·스포트라이트 상세를 fixture로 고정한다. */
|
||||
async function installLearnerHomeFixtures(
|
||||
page: Page,
|
||||
options: { detail?: boolean } = {},
|
||||
options: {
|
||||
detail?: boolean;
|
||||
dashboard?: typeof DASHBOARD_BODY;
|
||||
} = {},
|
||||
) {
|
||||
const { detail = true } = options;
|
||||
const { detail = true, dashboard = DASHBOARD_BODY } = options;
|
||||
await page.route(/\/api\/personas$/, (route) =>
|
||||
route.fulfill(jsonRoute(PERSONAS_BODY)),
|
||||
);
|
||||
|
|
@ -339,7 +342,7 @@ async function installLearnerHomeFixtures(
|
|||
route.fulfill(jsonRoute(SESSIONS_BODY)),
|
||||
);
|
||||
await page.route(/\/api\/sessions\/dashboard$/, (route) =>
|
||||
route.fulfill(jsonRoute(DASHBOARD_BODY)),
|
||||
route.fulfill(jsonRoute(dashboard)),
|
||||
);
|
||||
if (detail) {
|
||||
await page.route(new RegExp(`/api/sessions/${ACT_ID}$`), (route) =>
|
||||
|
|
@ -383,6 +386,31 @@ async function installStarterFixtures(
|
|||
}
|
||||
}
|
||||
|
||||
async function installLongPersonaFixtures(page: Page) {
|
||||
const personas = Array.from({ length: 16 }, (_, index) => {
|
||||
const number = index + 1;
|
||||
return {
|
||||
code: `P${number}`,
|
||||
display_name: number === 4 ? "박새봄" : `긴 호소 내담자 ${number}`,
|
||||
difficulty: number === 4 ? "easy" : "hard",
|
||||
theory_target: ["cbt"],
|
||||
demographics: { age_band: "30대" },
|
||||
presenting_summary:
|
||||
"반복되는 긴 호소 내용으로 인해 실제 목록 카드가 여러 줄로 늘어나는 상황을 검증합니다. 일상과 관계의 어려움을 자세히 설명합니다.",
|
||||
voice_preset: null,
|
||||
source: "database",
|
||||
degraded: false,
|
||||
};
|
||||
});
|
||||
await page.route(/\/api\/personas$/, (route) => route.fulfill(jsonRoute(personas)));
|
||||
await page.route(/\/api\/sessions$/, (route) =>
|
||||
route.fulfill(jsonRoute({ sessions: [], source: "runtime" })),
|
||||
);
|
||||
await page.route(/\/api\/sessions\/dashboard$/, (route) =>
|
||||
route.fulfill(jsonRoute(EMPTY_DASHBOARD_BODY)),
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("full-sweep learner home", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await useRealApi(page);
|
||||
|
|
@ -421,17 +449,18 @@ test.describe("full-sweep learner home", () => {
|
|||
).toBeVisible();
|
||||
});
|
||||
|
||||
// checklist: learner-home-head-status-live
|
||||
test("announces persona and history counts in the aria-live head status", async ({
|
||||
// checklist: learner-home-head-action
|
||||
test("shows the action-focused dashboard header without a global count badge", async ({
|
||||
page,
|
||||
}) => {
|
||||
await signInAsLearner(page);
|
||||
await page.goto("/learn");
|
||||
|
||||
const status = page.locator(".lh-head__status");
|
||||
await expect(status).toHaveAttribute("aria-live", "polite");
|
||||
// 실 API: 사용 가능 페르소나 수 + 기록 수(신규 학습자 → 0회)가 상태 문구로 뜬다.
|
||||
await expect(status).toHaveText(/\d+명 · \d+회/, { timeout: 15_000 });
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "오늘의 상담 연습" }),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.locator(".lh-head__status")).toHaveCount(0);
|
||||
await expect(page.locator("main")).toHaveCount(1);
|
||||
});
|
||||
|
||||
// checklist: learner-home-dash-metric-cards
|
||||
|
|
@ -441,14 +470,15 @@ test.describe("full-sweep learner home", () => {
|
|||
await signInAsLearner(page);
|
||||
await installLearnerHomeFixtures(page);
|
||||
await page.goto("/learn");
|
||||
await page.getByRole("tab", { name: "성장 지표" }).click();
|
||||
|
||||
const cards = page.locator(".lh-dashboard-status");
|
||||
const cards = page.locator("#lh-panel-growth .lh-dashboard-status");
|
||||
await expect(cards.getByRole("button")).toHaveCount(4);
|
||||
await expect(cards.getByRole("button", { name: /진행 회기/ })).toContainText(
|
||||
await expect(cards.getByRole("button", { name: /전체 회기/ })).toContainText(
|
||||
"4회",
|
||||
);
|
||||
await expect(cards.getByRole("button", { name: /진행 회기/ })).toContainText(
|
||||
"3회 종료 · 1회 진행",
|
||||
await expect(cards.getByRole("button", { name: /전체 회기/ })).toContainText(
|
||||
"상담 완료 3회 · 진행 중 1회",
|
||||
);
|
||||
await expect(cards.getByRole("button", { name: /리뷰 대기/ })).toContainText(
|
||||
"1건",
|
||||
|
|
@ -466,17 +496,51 @@ test.describe("full-sweep learner home", () => {
|
|||
"성하늘 · 3회",
|
||||
);
|
||||
|
||||
await cards.getByRole("button", { name: /진행 회기/ }).click();
|
||||
await cards.getByRole("button", { name: /전체 회기/ }).click();
|
||||
await expect(page).toHaveURL(/\/learn\/history$/);
|
||||
|
||||
await page.goto("/learn");
|
||||
await page.getByRole("tab", { name: "성장 지표" }).click();
|
||||
await page
|
||||
.locator(".lh-dashboard-status")
|
||||
.locator("#lh-panel-growth .lh-dashboard-status")
|
||||
.getByRole("button", { name: /라포 흐름/ })
|
||||
.click();
|
||||
await expect(page).toHaveURL(/\/learn\/practice$/);
|
||||
});
|
||||
|
||||
test("labels all sessions separately from completed counseling and empty ended sessions", async ({
|
||||
page,
|
||||
}) => {
|
||||
await signInAsLearner(page);
|
||||
await installLearnerHomeFixtures(page, {
|
||||
dashboard: {
|
||||
...DASHBOARD_BODY,
|
||||
overview: {
|
||||
...DASHBOARD_BODY.overview,
|
||||
total_sessions: 5,
|
||||
completed_sessions: 2,
|
||||
active_sessions: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
await page.goto("/learn");
|
||||
|
||||
await expect(
|
||||
page.locator("#lh-panel-today .lh-dashboard-status"),
|
||||
).toHaveCount(0);
|
||||
await page.getByRole("tab", { name: "성장 지표" }).click();
|
||||
const allSessions = page
|
||||
.locator("#lh-panel-growth .lh-dashboard-status")
|
||||
.getByRole("button", { name: "전체 회기" });
|
||||
await expect(allSessions).toContainText("5회");
|
||||
await expect(allSessions).toContainText("상담 완료 2회 · 진행 중 0회");
|
||||
await expect(allSessions).toContainText("대화 없이 종료한 회기 포함");
|
||||
await expect(allSessions).toHaveAttribute(
|
||||
"title",
|
||||
"전체 회기에는 대화 없이 종료한 회기도 포함합니다.",
|
||||
);
|
||||
});
|
||||
|
||||
// checklist: learner-home-dash-recap-card, learner-home-dash-coach-cta
|
||||
test("shows the spotlight recap card and coach CTA for an active session", async ({
|
||||
page,
|
||||
|
|
@ -547,7 +611,7 @@ test.describe("full-sweep learner home", () => {
|
|||
const recap = page.getByLabel("마지막 세션 리캡");
|
||||
await expect(recap).toContainText("할 수 있게 되는 건지 잘 모르겠는데요");
|
||||
await expect(recap).toContainText("소속 기관에서 오라고 했어요");
|
||||
await expect(recap).toContainText("익명 내담자에게 연락처로 연락해 볼까요");
|
||||
await expect(recap).toContainText("익명 인물에게 연락처로 연락해 볼까요");
|
||||
for (const token of ["[NAME]", "[ORG]", "[PHONE]"]) {
|
||||
await expect(recap).not.toContainText(token);
|
||||
}
|
||||
|
|
@ -946,4 +1010,210 @@ test.describe("full-sweep learner home", () => {
|
|||
listbox.getByRole("option", { name: /P4/ }),
|
||||
).toHaveAttribute("aria-selected", "false");
|
||||
});
|
||||
|
||||
test("keeps the initially selected P4 visible inside the mobile persona list", async ({ page }, testInfo) => {
|
||||
await signInAsLearner(page);
|
||||
await installStarterFixtures(page);
|
||||
await page.setViewportSize({ width: 320, height: 844 });
|
||||
await page.goto("/learn/practice");
|
||||
const listbox = page.getByRole("listbox", { name: "연습 페르소나" });
|
||||
const selected = listbox.getByRole("option", { name: /P4/ });
|
||||
await expect(selected).toHaveAttribute("aria-selected", "true");
|
||||
await expect.poll(() => page.evaluate(() => {
|
||||
const list = document.querySelector<HTMLElement>(".lh-personas");
|
||||
const option = document.querySelector<HTMLElement>("[data-persona-code='P4']");
|
||||
if (!list || !option) return false;
|
||||
const listRect = list.getBoundingClientRect();
|
||||
const optionRect = option.getBoundingClientRect();
|
||||
const subpixelTolerance = 1;
|
||||
return (
|
||||
optionRect.left >= listRect.left - subpixelTolerance &&
|
||||
optionRect.right <= listRect.right + subpixelTolerance &&
|
||||
optionRect.top >= listRect.top - subpixelTolerance &&
|
||||
optionRect.bottom <= listRect.bottom + subpixelTolerance
|
||||
);
|
||||
})).toBe(true);
|
||||
await expect.poll(() => page.locator(".vg-main").evaluate((main) => main.scrollTop)).toBe(0);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("p4-initial-mobile-320.png"),
|
||||
fullPage: false,
|
||||
animations: "disabled",
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps the selected persona visible after a mobile viewport resize", async ({ page }) => {
|
||||
await signInAsLearner(page);
|
||||
await installStarterFixtures(page);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/learn/practice");
|
||||
const listbox = page.getByRole("listbox", { name: "연습 페르소나" });
|
||||
const selected = listbox.getByRole("option", { name: /P4/ });
|
||||
await expect(selected).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await page.setViewportSize({ width: 320, height: 844 });
|
||||
await expect.poll(() => page.evaluate(() => {
|
||||
const list = document.querySelector<HTMLElement>(".lh-personas");
|
||||
const option = document.querySelector<HTMLElement>("[data-persona-code='P4']");
|
||||
if (!list || !option) return false;
|
||||
const listRect = list.getBoundingClientRect();
|
||||
const optionRect = option.getBoundingClientRect();
|
||||
const subpixelTolerance = 1;
|
||||
return (
|
||||
optionRect.left >= listRect.left - subpixelTolerance &&
|
||||
optionRect.right <= listRect.right + subpixelTolerance &&
|
||||
optionRect.top >= listRect.top - subpixelTolerance &&
|
||||
optionRect.bottom <= listRect.bottom + subpixelTolerance
|
||||
);
|
||||
})).toBe(true);
|
||||
await expect.poll(() => page.locator(".vg-main").evaluate((main) => main.scrollTop)).toBe(0);
|
||||
});
|
||||
|
||||
test("contains long persona lists without pushing the selected practice CTA below the list", async ({ page }, testInfo) => {
|
||||
await signInAsLearner(page);
|
||||
await installLongPersonaFixtures(page);
|
||||
|
||||
for (const width of [768, 1024, 1440]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.goto("/learn/practice");
|
||||
const listbox = page.getByRole("listbox", { name: "연습 페르소나" });
|
||||
const selected = listbox.getByRole("option", { name: /P4/ });
|
||||
const longPersona = listbox.getByRole("option", { name: /P12/ });
|
||||
const lastPersona = listbox.getByRole("option", { name: /P16/ });
|
||||
const startCta = page.getByRole("button", { name: "완전히 새로 시작" });
|
||||
await expect(selected).toHaveAttribute("aria-selected", "true");
|
||||
await expect(longPersona).toBeVisible();
|
||||
await expect.poll(() => page.evaluate(() => {
|
||||
const list = document.querySelector<HTMLElement>(".lh-personas");
|
||||
const option = document.querySelector<HTMLElement>("[data-persona-code='P4']");
|
||||
if (!list || !option) return false;
|
||||
const listRect = list.getBoundingClientRect();
|
||||
const optionRect = option.getBoundingClientRect();
|
||||
const options = [...list.querySelectorAll<HTMLElement>("[role='option']")];
|
||||
const rowsDoNotOverlap = options.every((current, index) => {
|
||||
const currentRect = current.getBoundingClientRect();
|
||||
return options.slice(index + 1).every((other) => {
|
||||
const otherRect = other.getBoundingClientRect();
|
||||
return (
|
||||
currentRect.right <= otherRect.left ||
|
||||
otherRect.right <= currentRect.left ||
|
||||
currentRect.bottom <= otherRect.top ||
|
||||
otherRect.bottom <= currentRect.top
|
||||
);
|
||||
});
|
||||
});
|
||||
const contentFits = options.every((current) => {
|
||||
const content = current.querySelector<HTMLElement>(".lh-persona__body");
|
||||
return !content || content.getBoundingClientRect().bottom <= current.getBoundingClientRect().bottom;
|
||||
});
|
||||
const hit = document.elementFromPoint(
|
||||
optionRect.left + optionRect.width / 2,
|
||||
optionRect.top + optionRect.height / 2,
|
||||
);
|
||||
return (
|
||||
list.scrollHeight > list.clientHeight &&
|
||||
getComputedStyle(list).overflowY === "auto" &&
|
||||
optionRect.top >= listRect.top &&
|
||||
optionRect.bottom <= listRect.bottom &&
|
||||
rowsDoNotOverlap &&
|
||||
contentFits &&
|
||||
!!hit && option.contains(hit)
|
||||
);
|
||||
})).toBe(true);
|
||||
await expect(startCta).toBeInViewport();
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath(`long-personas-w${width}-top.png`),
|
||||
fullPage: false,
|
||||
animations: "disabled",
|
||||
});
|
||||
await longPersona.scrollIntoViewIfNeeded();
|
||||
await expect(longPersona).toBeInViewport();
|
||||
await listbox.evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
});
|
||||
await expect(lastPersona).toBeInViewport();
|
||||
await expect.poll(() => page.locator(".vg-main").evaluate((main) => main.scrollTop)).toBe(0);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath(`long-personas-w${width}-bottom.png`),
|
||||
fullPage: false,
|
||||
animations: "disabled",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("opens zero-record persona rows and keeps the compact history overview two columns", async ({ page }, testInfo) => {
|
||||
await signInAsLearner(page);
|
||||
await installStarterFixtures(page);
|
||||
for (const width of [320, 390]) {
|
||||
await page.setViewportSize({ width, height: 844 });
|
||||
await page.goto("/learn/history");
|
||||
const overview = page.locator(".lh-history-overview");
|
||||
const practiceCta = page.getByRole("button", { name: "상담 연습으로" });
|
||||
const toggle = page.getByRole("button", { name: /미진행 내담자 2명 보기/ });
|
||||
await expect(page.getByText("아직 연습 기록이 없습니다.")).toBeVisible();
|
||||
await expect(practiceCta).toBeVisible();
|
||||
await expect(toggle).toBeVisible();
|
||||
const columns = await overview.evaluate((element) => getComputedStyle(element).gridTemplateColumns.split(" ").filter(Boolean).length);
|
||||
expect(columns).toBe(2);
|
||||
for (const target of [practiceCta, toggle]) {
|
||||
const height = await target.evaluate((element) => element.getBoundingClientRect().height);
|
||||
expect(height).toBeGreaterThanOrEqual(44);
|
||||
}
|
||||
await toggle.scrollIntoViewIfNeeded();
|
||||
await page.locator(".lh-persona-progress").screenshot({
|
||||
path: testInfo.outputPath(`history-empty-bottom-mobile-${width}.png`),
|
||||
animations: "disabled",
|
||||
});
|
||||
await toggle.click();
|
||||
await expect(page.locator(".lh-persona-progress__row")).toHaveCount(2);
|
||||
await page.getByRole("button", { name: "미진행 내담자 숨기기" }).click();
|
||||
await expect(page.locator(".lh-persona-progress__row")).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps history filters and session actions usable at mobile widths", async ({ page }, testInfo) => {
|
||||
await signInAsLearner(page);
|
||||
await installLearnerHomeFixtures(page);
|
||||
|
||||
for (const width of [320, 390]) {
|
||||
await page.setViewportSize({ width, height: 844 });
|
||||
await page.goto("/learn/history");
|
||||
const filters = page.locator(".lh-history-filter button");
|
||||
const historyActions = page.locator(".lh-session-actions button");
|
||||
await expect(filters).toHaveCount(4);
|
||||
await expect(historyActions).not.toHaveCount(0);
|
||||
for (const target of [filters, historyActions]) {
|
||||
const boxes = await target.evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { height: rect.height, width: rect.width };
|
||||
}),
|
||||
);
|
||||
expect(boxes.every((box) => box.height >= 44 && box.width >= 44)).toBe(true);
|
||||
}
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
if (width === 320) {
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("history-top-mobile-320.png"),
|
||||
fullPage: false,
|
||||
animations: "disabled",
|
||||
});
|
||||
await historyActions.first().locator("..").locator("..").screenshot({
|
||||
path: testInfo.outputPath("history-action-row-mobile-320.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
}
|
||||
|
||||
await page.goto("/learn/practice");
|
||||
const practiceActions = page.locator(".lh-session-actions button");
|
||||
await expect(practiceActions).not.toHaveCount(0);
|
||||
const practiceBoxes = await practiceActions.evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { height: rect.height, width: rect.width };
|
||||
}),
|
||||
);
|
||||
expect(practiceBoxes.every((box) => box.height >= 44 && box.width >= 44)).toBe(true);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -381,6 +381,81 @@ test.describe("persona studio full sweep", () => {
|
|||
await expect(page.locator(".ps-authoring-layout > .ps-rail")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("keeps authoring steps in one row and exposes the editor at every target width", async ({
|
||||
page,
|
||||
}) => {
|
||||
await signInAsTeacher(page);
|
||||
await mockStudio(page, { catalog: [approvedP1], reviews: [] });
|
||||
|
||||
const viewports = [
|
||||
{ width: 320, height: 844 },
|
||||
{ width: 390, height: 844 },
|
||||
{ width: 1440, height: 900 },
|
||||
];
|
||||
for (const viewport of viewports) {
|
||||
await page.setViewportSize(viewport);
|
||||
await page.goto("/teach/personas?view=personas&mode=create&step=edit");
|
||||
|
||||
const stepper = page.getByRole("navigation", { name: "페르소나 작성 단계" });
|
||||
const steps = stepper.getByRole("button");
|
||||
await expect(steps).toHaveCount(4);
|
||||
const boxes = await steps.evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||
}),
|
||||
);
|
||||
expect(Math.max(...boxes.map((box) => box.y)) - Math.min(...boxes.map((box) => box.y))).toBeLessThanOrEqual(1);
|
||||
for (const box of boxes) {
|
||||
expect(box.height).toBeGreaterThanOrEqual(44);
|
||||
expect(box.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box.x + box.width).toBeLessThanOrEqual(viewport.width);
|
||||
}
|
||||
await expect(page.locator(".ps-editor__top")).toBeInViewport();
|
||||
|
||||
if (viewport.width <= 720) {
|
||||
const undersizedActions = await page
|
||||
.locator(".ps-root .vg-btn:not(:disabled), .ps-root .ps-tabs button:not(:disabled)")
|
||||
.evaluateAll((elements) =>
|
||||
elements.flatMap((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) return [];
|
||||
return rect.width >= 44 && rect.height >= 44
|
||||
? []
|
||||
: [{
|
||||
selector: `${element.tagName.toLowerCase()}.${Array.from(element.classList).join(".")}`,
|
||||
text: element.textContent?.trim(),
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
}];
|
||||
}),
|
||||
);
|
||||
expect(undersizedActions).toEqual([]);
|
||||
}
|
||||
await expectNoHorizontalOverflow(page);
|
||||
|
||||
if (process.env.PERSONA_AUTHORING_CAPTURE_DIR) {
|
||||
await page.screenshot({
|
||||
path: `${process.env.PERSONA_AUTHORING_CAPTURE_DIR}/persona-authoring-${viewport.width}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const viewport of viewports) {
|
||||
await page.setViewportSize(viewport);
|
||||
await page.goto("/teach/personas?view=catalog");
|
||||
await expect(page.getByRole("button", { name: "전체 페르소나 보기" })).toBeVisible();
|
||||
await expectNoHorizontalOverflow(page);
|
||||
if (process.env.PERSONA_AUTHORING_CAPTURE_DIR) {
|
||||
await page.screenshot({
|
||||
path: `${process.env.PERSONA_AUTHORING_CAPTURE_DIR}/persona-catalog-${viewport.width}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// checklist: persona-studio-inspector-review-actions
|
||||
test("edits and approves review drafts from the inspector queue", async ({ page }) => {
|
||||
await signInAsTeacher(page);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -214,7 +214,7 @@ test.describe("public admin visual @public-auth", () => {
|
|||
{ path: "/admin/ai", heading: "AI 운영과 DB 계량", root: "[data-testid='admin-ai-page']" },
|
||||
{ path: "/admin/users", heading: "가입 승인과 권한 관리", root: "[data-vignette-admin-root]" },
|
||||
{ path: "/admin/access", heading: "역할, 그룹, 접근 범위", root: "[data-vignette-admin-root]" },
|
||||
{ path: "/admin/tickets", heading: "사용자 문제 큐", root: "[data-vignette-admin-root]" },
|
||||
{ path: "/admin/tickets", heading: "지원 요청", root: "[data-vignette-admin-root]" },
|
||||
] as const;
|
||||
|
||||
for (const section of sections) {
|
||||
|
|
@ -268,7 +268,7 @@ test.describe("public admin visual @public-auth", () => {
|
|||
{ path: "/admin/ai", heading: "AI 운영과 DB 계량", root: "[data-testid='admin-ai-page']" },
|
||||
{ path: "/admin/users", heading: "가입 승인과 권한 관리", root: "[data-vignette-admin-root]" },
|
||||
{ path: "/admin/access", heading: "역할, 그룹, 접근 범위", root: "[data-vignette-admin-root]" },
|
||||
{ path: "/admin/tickets", heading: "사용자 문제 큐", root: "[data-vignette-admin-root]" },
|
||||
{ path: "/admin/tickets", heading: "지원 요청", root: "[data-vignette-admin-root]" },
|
||||
] as const;
|
||||
|
||||
for (const section of sections) {
|
||||
|
|
|
|||
|
|
@ -47,6 +47,111 @@ async function expectNoLocalStageDemoControl(page: Page) {
|
|||
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,
|
||||
|
|
@ -473,6 +578,7 @@ test.describe("learner session full-screen layout", () => {
|
|||
|
||||
await expect(page.getByRole("button", { name: "회기 시작" })).toBeVisible();
|
||||
await expectNoHorizontalOverflow(page);
|
||||
await expectPrestartSummaryReadableOnMobile(page);
|
||||
|
||||
await page.getByRole("button", { name: "회기 시작" }).click();
|
||||
await completeAlliancePreCheckpoint(page);
|
||||
|
|
|
|||
|
|
@ -478,4 +478,35 @@ export async function routeEmptySessionReview(
|
|||
body: JSON.stringify(emptyReviewResponse(sessionId)),
|
||||
});
|
||||
});
|
||||
await page.route(`**/api/sessions/${sessionId}/alliance-pulses`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: [] }),
|
||||
});
|
||||
});
|
||||
await page.route(
|
||||
new RegExp(`/api/sessions/${sessionId}/multimodal-alliance\\?include_derived=true$`),
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ detail: "multimodal alliance ledger not found" }),
|
||||
});
|
||||
},
|
||||
);
|
||||
await page.route(`**/api/sessions/${sessionId}/outcome-trajectory`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ detail: "outcome trajectory not found" }),
|
||||
});
|
||||
});
|
||||
await page.route(`**/api/sessions/${sessionId}/ruptures`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ detail: "rupture ledger not found" }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
241
apps/web/e2e/settings-ui.spec.ts
Normal file
241
apps/web/e2e/settings-ui.spec.ts
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
|
||||
function jsonRoute(body: unknown, status = 200) {
|
||||
return { status, contentType: "application/json", body: JSON.stringify(body) };
|
||||
}
|
||||
|
||||
const PROFILE = {
|
||||
user_id: "e2e-settings-ui",
|
||||
email: "settings.ui@hs.ac.kr",
|
||||
display_name: "설정 화면 검증 학습자",
|
||||
legal_name: "설정 화면 검증 학습자",
|
||||
nickname: "설정검증",
|
||||
self_introduction: "",
|
||||
role: "learner",
|
||||
cohort_ids: [],
|
||||
affiliation: "한신대학교",
|
||||
department: "상담심리학과",
|
||||
grade_level: "3학년",
|
||||
phone: "010-0000-0000",
|
||||
contact_address: "경기도 오산시",
|
||||
avatar_url: "",
|
||||
onboarding_required: false,
|
||||
onboarding_completed_at: 1780000000,
|
||||
terms_version: "terms-draft-2026-06-27",
|
||||
terms_agreed_at: 1780000000,
|
||||
privacy_version: "privacy-draft-2026-06-27",
|
||||
privacy_agreed_at: 1780000000,
|
||||
};
|
||||
|
||||
const PREFERENCES = {
|
||||
theme: "light",
|
||||
voice_preset_id: "soft-young-fem",
|
||||
voice_rate: 1,
|
||||
notifications: {
|
||||
session_done: true,
|
||||
safety_signal: true,
|
||||
learner_progress: true,
|
||||
account_approval: true,
|
||||
product_news: false,
|
||||
},
|
||||
};
|
||||
|
||||
const VOICE_PRESETS = [
|
||||
{
|
||||
id: "soft-young-fem",
|
||||
name: "차분한 목소리",
|
||||
voice_id: "ko-soft-01",
|
||||
desc: "낮은 톤의 안정적인 발화",
|
||||
persona_hint: "차분한 20대 내담자",
|
||||
},
|
||||
{
|
||||
id: "bright-mid-fem",
|
||||
name: "밝은 목소리",
|
||||
voice_id: "ko-bright-02",
|
||||
desc: "밝고 빠른 발화",
|
||||
persona_hint: "활발한 30대 내담자",
|
||||
},
|
||||
];
|
||||
|
||||
async function mountSettingsFixture(page: Page) {
|
||||
await page.route("**/api/**", (route) =>
|
||||
route.fulfill(jsonRoute({ detail: "not part of settings UI fixture" }, 404)),
|
||||
);
|
||||
await page.route("**/api/auth/me", (route) =>
|
||||
route.fulfill(jsonRoute({
|
||||
user_id: PROFILE.user_id,
|
||||
email: PROFILE.email,
|
||||
role: "learner",
|
||||
admin_access: false,
|
||||
super_admin: false,
|
||||
account_status: "approved",
|
||||
approval_required: false,
|
||||
cohort_ids: [],
|
||||
consent_at: 1780000000,
|
||||
onboarding_completed_at: PROFILE.onboarding_completed_at,
|
||||
nickname: PROFILE.nickname,
|
||||
display_name: PROFILE.display_name,
|
||||
self_introduction: "",
|
||||
avatar_url: "",
|
||||
})),
|
||||
);
|
||||
await page.route("**/api/users/me", (route: Route) => {
|
||||
if (route.request().method() === "GET") return route.fulfill(jsonRoute(PROFILE));
|
||||
return route.fulfill(jsonRoute(PROFILE));
|
||||
});
|
||||
await page.route("**/api/users/me/preferences", (route: Route) => {
|
||||
if (route.request().method() === "GET") return route.fulfill(jsonRoute(PREFERENCES));
|
||||
return route.fulfill(jsonRoute(PREFERENCES));
|
||||
});
|
||||
await page.route("**/api/users/me/voice-presets", (route) => route.fulfill(jsonRoute(VOICE_PRESETS)));
|
||||
await page.route("**/api/users/support-tickets", (route) =>
|
||||
route.fulfill(jsonRoute({ source: "fixture", durable: false, generated_at: 1780000000, tickets: [] })),
|
||||
);
|
||||
}
|
||||
|
||||
async function expectNoHorizontalClipping(page: Page, width: number) {
|
||||
const offenders = await page.locator(".vg-set, .vg-set *").evaluateAll((elements, viewportWidth) =>
|
||||
elements.flatMap((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0 || rect.bottom <= 0) return [];
|
||||
return rect.left < -0.5 || rect.right > Number(viewportWidth) + 0.5
|
||||
? [{ tag: element.tagName, className: element.className, left: rect.left, right: rect.right }]
|
||||
: [];
|
||||
}), width,
|
||||
);
|
||||
expect(offenders).toEqual([]);
|
||||
}
|
||||
|
||||
async function waitForScrollSettled(page: Page) {
|
||||
await page.evaluate(async () => {
|
||||
const scroller = document.querySelector(".vg-main");
|
||||
await new Promise<void>((resolve) => {
|
||||
let frame = 0;
|
||||
let stableFrames = 0;
|
||||
let previous = scroller?.scrollTop ?? window.scrollY;
|
||||
const check = () => {
|
||||
const current = scroller?.scrollTop ?? window.scrollY;
|
||||
stableFrames = Math.abs(current - previous) < 0.5 ? stableFrames + 1 : 0;
|
||||
previous = current;
|
||||
frame += 1;
|
||||
if (stableFrames >= 8 || frame >= 120) return resolve();
|
||||
requestAnimationFrame(check);
|
||||
};
|
||||
requestAnimationFrame(check);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function expectMobileInteractiveTargets(page: Page) {
|
||||
const undersized = await page
|
||||
.locator(".vg-set button:not(:disabled), .vg-set [role='button']:not([aria-disabled='true'])")
|
||||
.evaluateAll((elements) =>
|
||||
elements.flatMap((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const selector = element.id
|
||||
? `#${element.id}`
|
||||
: `${element.tagName.toLowerCase()}.${Array.from(element.classList).join(".")}`;
|
||||
return rect.width >= 44 && rect.height >= 44
|
||||
? []
|
||||
: [{ selector, text: element.textContent?.trim(), width: rect.width, height: rect.height }];
|
||||
}),
|
||||
);
|
||||
expect(undersized).toEqual([]);
|
||||
|
||||
const voicePreview = await page.locator(".vg-set__voice-play").first().boundingBox();
|
||||
expect(voicePreview?.width).toBeGreaterThanOrEqual(44);
|
||||
expect(voicePreview?.height).toBeGreaterThanOrEqual(44);
|
||||
}
|
||||
|
||||
test("설정 섹션 내비는 모든 화면 폭에서 라벨·터치 영역·탭 이동을 보장한다", async ({ page }) => {
|
||||
await mountSettingsFixture(page);
|
||||
const viewports = [
|
||||
{ width: 320, height: 844 },
|
||||
{ width: 390, height: 844 },
|
||||
{ width: 768, height: 900 },
|
||||
{ width: 1440, height: 900 },
|
||||
];
|
||||
const sections = [
|
||||
["계정", "set-account"],
|
||||
["지원 요청", "set-support"],
|
||||
["동의", "set-consent"],
|
||||
["테마", "set-appearance"],
|
||||
["알림", "set-notify"],
|
||||
["음성", "set-voice"],
|
||||
] as const;
|
||||
|
||||
for (const viewport of viewports) {
|
||||
await page.setViewportSize(viewport);
|
||||
await page.goto("/settings");
|
||||
await expect(page.getByRole("button", { name: "알림 저장" })).toBeVisible();
|
||||
|
||||
const nav = page.getByRole("navigation", { name: "설정 섹션" });
|
||||
const tabs = nav.getByRole("button");
|
||||
await expect(tabs).toHaveCount(6);
|
||||
await expectNoHorizontalClipping(page, viewport.width);
|
||||
|
||||
for (const [label, sectionId] of sections) {
|
||||
const tab = nav.getByRole("button", { name: label });
|
||||
await expect(tab).toBeVisible();
|
||||
const box = await tab.boundingBox();
|
||||
expect(box?.height).toBeGreaterThanOrEqual(44);
|
||||
expect(box?.x).toBeGreaterThanOrEqual(0);
|
||||
expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual(viewport.width);
|
||||
|
||||
await tab.click();
|
||||
await waitForScrollSettled(page);
|
||||
await expect(tab).toHaveAttribute("aria-current", "location");
|
||||
await expect(page.locator(`#${sectionId}`)).toBeInViewport();
|
||||
if (viewport.width <= 1080) {
|
||||
const [navBox, sectionBox] = await Promise.all([
|
||||
nav.boundingBox(),
|
||||
page.locator(`#${sectionId}`).boundingBox(),
|
||||
]);
|
||||
expect(sectionBox?.y).toBeGreaterThanOrEqual((navBox?.y ?? 0) + (navBox?.height ?? 0));
|
||||
}
|
||||
if (viewport.width === 320 && label === "계정") {
|
||||
const [railBox, accountBox] = await Promise.all([
|
||||
page.locator(".vg-set__rail").boundingBox(),
|
||||
page.locator("#set-account").boundingBox(),
|
||||
]);
|
||||
expect(railBox?.x).toBeCloseTo(accountBox?.x ?? 0, 0);
|
||||
expect(railBox?.width).toBeCloseTo(accountBox?.width ?? 0, 0);
|
||||
const railBackground = await page.locator(".vg-set__rail").evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
expect(railBackground).not.toBe("rgba(0, 0, 0, 0)");
|
||||
}
|
||||
if (viewport.width === 320 && label === "지원 요청") {
|
||||
const description = await page.locator("#set-support .vg-set__group-desc").boundingBox();
|
||||
expect(description?.width).toBeGreaterThanOrEqual(150);
|
||||
}
|
||||
if (viewport.width === 320 && label === "동의") {
|
||||
const [description, action] = await Promise.all([
|
||||
page.locator("#set-consent .vg-set__opt-text").first().boundingBox(),
|
||||
page.getByRole("button", { name: "실습 동의 철회" }).boundingBox(),
|
||||
]);
|
||||
expect(description?.width).toBeGreaterThanOrEqual(150);
|
||||
expect(action?.height).toBeGreaterThanOrEqual(44);
|
||||
}
|
||||
await expectNoHorizontalClipping(page, viewport.width);
|
||||
if (viewport.width === 320 && process.env.SETTINGS_UI_CAPTURE_DIR) {
|
||||
await page.screenshot({
|
||||
path: `${process.env.SETTINGS_UI_CAPTURE_DIR}/settings-320-${sectionId}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const account = nav.getByRole("button", { name: "계정" });
|
||||
await account.focus();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(nav.getByRole("button", { name: "지원 요청" })).toBeFocused();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(nav.getByRole("button", { name: "지원 요청" })).toHaveAttribute("aria-current", "location");
|
||||
await expect(page.locator("#set-support")).toBeInViewport();
|
||||
|
||||
if (viewport.width === 320) {
|
||||
await expectMobileInteractiveTargets(page);
|
||||
}
|
||||
}
|
||||
});
|
||||
137
apps/web/e2e/shell-navigation.spec.ts
Normal file
137
apps/web/e2e/shell-navigation.spec.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { expect, test, type Page } from "@playwright/test";
|
||||
import {
|
||||
expectNoHorizontalOverflow,
|
||||
signInAsAdmin,
|
||||
signInAsLearner,
|
||||
signInAsTeacher,
|
||||
} from "./support";
|
||||
|
||||
const viewports = [
|
||||
{ width: 320, height: 568 },
|
||||
{ width: 390, height: 844 },
|
||||
{ width: 768, height: 900 },
|
||||
{ width: 1024, height: 900 },
|
||||
];
|
||||
|
||||
async function expectMinimumTargets(page: Page, selector: string) {
|
||||
const targets = await page.locator(selector).evaluateAll((elements) =>
|
||||
elements
|
||||
.filter((element) => window.getComputedStyle(element).display !== "none")
|
||||
.map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { text: element.textContent?.trim(), width: rect.width, height: rect.height };
|
||||
}),
|
||||
);
|
||||
for (const target of targets) {
|
||||
expect(target.width, `${target.text} width`).toBeGreaterThanOrEqual(44);
|
||||
expect(target.height, `${target.text} height`).toBeGreaterThanOrEqual(44);
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("공통 셸 내비게이션", () => {
|
||||
test("역할별 하단 목적지와 태블릿 레일은 레이블·조작면·오버플로 계약을 지킨다", async ({ page }) => {
|
||||
const roles = [
|
||||
{
|
||||
signIn: signInAsLearner,
|
||||
path: "/learn",
|
||||
mobile: ["학습자 홈", "상담 연습", "학습 기록"],
|
||||
desktop: ["학습자 홈", "상담 연습", "학습 기록", "설정"],
|
||||
},
|
||||
{
|
||||
signIn: signInAsTeacher,
|
||||
path: "/teach",
|
||||
mobile: ["교수자 홈", "학생 분석", "감독·연구"],
|
||||
desktop: ["교수자 홈", "학생 분석", "감독·연구", "내담자 설정", "설정"],
|
||||
},
|
||||
{
|
||||
signIn: signInAsAdmin,
|
||||
path: "/admin",
|
||||
mobile: ["운영 홈", "AI 운영", "사용자 관리"],
|
||||
desktop: ["운영 홈", "AI 운영", "사용자 관리", "접근 관리", "지원 요청"],
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const role of roles) {
|
||||
await role.signIn(page);
|
||||
await page.goto(role.path);
|
||||
await expect(page.locator(".vg-shell")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
for (const viewport of viewports) {
|
||||
await page.setViewportSize(viewport);
|
||||
await expectNoHorizontalOverflow(page);
|
||||
|
||||
if (viewport.width <= 720) {
|
||||
const labels = await page.locator(".vg-nav__item--mobile-primary:visible .vg-nav__txt").allTextContents();
|
||||
expect(labels).toEqual(role.mobile);
|
||||
await expect(page.getByRole("button", { name: "전체 메뉴" })).toBeVisible();
|
||||
await expectMinimumTargets(page, ".vg-nav__item--mobile-primary:visible, .vg-nav__more:visible");
|
||||
} else {
|
||||
for (const label of role.desktop) {
|
||||
await expect(page.getByRole("link", { name: label, exact: true })).toBeVisible();
|
||||
}
|
||||
if (viewport.width === 1024 && role.path === "/admin") {
|
||||
for (const group of ["운영", "교수", "학습", "설정"]) {
|
||||
await expect(page.locator(".vg-nav__label", { hasText: group })).toBeVisible();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("전체 메뉴는 키보드로 닫히고 현재 위치를 보존하며 교수자 홈 단축키를 정확히 보낸다", async ({ page }) => {
|
||||
await signInAsTeacher(page);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/teach/personas");
|
||||
await expect(page.locator(".vg-shell")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const menuButton = page.getByRole("button", { name: "전체 메뉴" });
|
||||
await expect(menuButton).toHaveClass(/is-active/);
|
||||
await menuButton.click();
|
||||
|
||||
const dialog = page.locator("#vg-all-menu-dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByRole("heading", { name: "전체 메뉴" })).toBeVisible();
|
||||
await expect(dialog.getByRole("link", { name: "학생 분석" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "전체 메뉴 닫기" })).toBeFocused();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await expect(menuButton).toBeFocused();
|
||||
|
||||
await menuButton.click();
|
||||
await dialog.getByRole("link", { name: "학생 분석" }).click();
|
||||
await expect(page).toHaveURL(/\/teach\/analysis$/);
|
||||
await expect(dialog).not.toBeVisible();
|
||||
|
||||
await page.goto("/teach/analysis", { waitUntil: "networkidle" });
|
||||
await page.keyboard.press("Alt+h");
|
||||
await expect(page).toHaveURL(/\/teach$/);
|
||||
|
||||
const browserShortcutCancellation = await page.evaluate(() => {
|
||||
const results = ["t", "w"].map((key) => {
|
||||
const event = new KeyboardEvent("keydown", { key, ctrlKey: true, bubbles: true, cancelable: true });
|
||||
window.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
});
|
||||
return results;
|
||||
});
|
||||
expect(browserShortcutCancellation).toEqual([false, false]);
|
||||
});
|
||||
|
||||
test("좁은 화면의 공간 선택은 레이블을 유지하며 44px 조작면을 지킨다", async ({ page }) => {
|
||||
await signInAsAdmin(page);
|
||||
await page.setViewportSize({ width: 320, height: 568 });
|
||||
await page.goto("/admin");
|
||||
await expect(page.locator(".vg-shell")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const select = page.getByRole("combobox", { name: "공간 선택" });
|
||||
await expect(select).toBeVisible();
|
||||
await expect(select).toHaveValue("admin");
|
||||
await expect(select.locator("option")).toHaveText(["학습자", "교수자", "관리자"]);
|
||||
await expectMinimumTargets(page, ".vg-topbar__switch-select:visible, .vg-iconbtn:visible");
|
||||
|
||||
await select.selectOption("teacher");
|
||||
await expect(page).toHaveURL(/\/teach$/);
|
||||
});
|
||||
});
|
||||
|
|
@ -273,9 +273,13 @@ test.describe("G6 supervision and research OS", () => {
|
|||
|
||||
await expect(
|
||||
page.getByRole("heading", {
|
||||
name: "먼저 볼 경로와, 믿을 수 있는 출처를 분리해 본다.",
|
||||
name: "감독·연구",
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("학습 검토", { exact: true })).toBeVisible();
|
||||
await expect(page.locator(".g6-hero p")).toHaveText(
|
||||
"검토가 필요한 학습 회기와 평가 근거를 확인합니다.",
|
||||
);
|
||||
await expect(page.getByRole("tab", { name: "교수 감독" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
|
|
@ -292,11 +296,11 @@ test.describe("G6 supervision and research OS", () => {
|
|||
await page.getByRole("button", { name: /learner-risk/ }).click();
|
||||
await expect(page.locator(".g6-ledger li")).toHaveCount(3);
|
||||
await expect(page.locator(".g6-drilldown a")).toHaveCount(3);
|
||||
await expect(page.getByTestId("drilldown-depth")).toContainText("계약 상한 3번");
|
||||
await expect(page.getByTestId("drilldown-depth")).toHaveText("선택한 항목의 근거를 확인합니다.");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "개인의 순위를 만들지 않는 교육과정 공백" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "반복되는 학습 보완점" })).toBeVisible();
|
||||
await expect(page.getByText("competency.rupture-repair").first()).toBeVisible();
|
||||
await expect(page.getByText("근거 없는 상태를 유지하고 해석하지 않아.")).toBeVisible();
|
||||
await expect(page.getByText("근거가 부족한 상태를 유지하고 해석하지 않습니다.")).toBeVisible();
|
||||
await expect(page.getByText("resolved", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("partial", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("원문 축어록 제외")).toBeVisible();
|
||||
|
|
@ -306,12 +310,36 @@ test.describe("G6 supervision and research OS", () => {
|
|||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
|
||||
test("wraps a long learner reference without clipping at 320px", async ({ page }) => {
|
||||
const longLearnerRef = "learner-bc2e3fe5b35e46a8b1c9d2e4f6a7b8c9d0e1f2a3b4c5d6e7f8";
|
||||
const supervision = supervisionFixture();
|
||||
supervision.attention_items[0] = {
|
||||
...supervision.attention_items[0],
|
||||
learner_ref: longLearnerRef,
|
||||
};
|
||||
|
||||
await routeG6(page, "teacher", supervision);
|
||||
await page.setViewportSize({ width: 320, height: 900 });
|
||||
await page.goto("/teach/supervision");
|
||||
|
||||
const detailReference = page.locator(".g6-case h3");
|
||||
await expect(detailReference).toHaveText(longLearnerRef);
|
||||
await expect(detailReference).toHaveCSS("overflow-wrap", "anywhere");
|
||||
expect(
|
||||
await detailReference.evaluate((element) => element.scrollWidth <= element.clientWidth + 1),
|
||||
).toBeTruthy();
|
||||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
|
||||
test("research role traces model, prompt, instrument, subgroup, and four-domain provenance", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await routeG6(page, "admin");
|
||||
await page.goto("/teach/supervision");
|
||||
await expect(page.locator(".g6-hero p")).toHaveText(
|
||||
"검토가 필요한 학습 회기와 평가 근거를 확인합니다. 연구 품질 탭에서는 평가 버전과 출처를 확인할 수 있습니다.",
|
||||
);
|
||||
await page.getByRole("tab", { name: "연구 품질" }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "모델·프롬프트·도구·하위집단 드리프트" })).toBeVisible();
|
||||
|
|
@ -325,7 +353,7 @@ test.describe("G6 supervision and research OS", () => {
|
|||
await expect(page.getByText("synthetic-low-disclosure", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("-25%p", { exact: true })).toBeVisible();
|
||||
|
||||
await expect(page.getByText("4/4 provenance 연결", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("4/4 출처 연결", { exact: true })).toBeVisible();
|
||||
for (const label of ["동맹", "파열·수선", "전이", "보정"]) {
|
||||
await expect(page.getByText(label, { exact: true }).first()).toBeVisible();
|
||||
}
|
||||
|
|
@ -373,14 +401,14 @@ test.describe("G6 supervision and research OS", () => {
|
|||
await routeG6(page, "admin", emptySupervision, summaryOnlyResearch);
|
||||
await page.goto("/teach/supervision");
|
||||
|
||||
await expect(page.getByText("현재 우선 검토 항목이 없어")).toBeVisible();
|
||||
await expect(page.getByText("현재 교육과정 공백이 없어")).toBeVisible();
|
||||
await expect(page.getByText("교수자–AI 불일치 메타데이터가 없어")).toBeVisible();
|
||||
await expect(page.getByText("현재 우선 검토 항목이 없습니다")).toBeVisible();
|
||||
await expect(page.getByText("현재 교육과정 공백이 없습니다")).toBeVisible();
|
||||
await expect(page.getByText("교수자–AI 불일치 메타데이터가 없습니다")).toBeVisible();
|
||||
await page.getByRole("tab", { name: "연구 품질" }).click();
|
||||
await expect(page.getByText("버전 드리프트 비교가 없어")).toBeVisible();
|
||||
await expect(page.getByText("provenance 저하", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("매니페스트 요약만 도착했어")).toBeVisible();
|
||||
await expect(page.getByText("원본 provenance 미제공").first()).toBeVisible();
|
||||
await expect(page.getByText("버전 드리프트 비교가 없습니다")).toBeVisible();
|
||||
await expect(page.getByText("출처 연결 저하", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("매니페스트 요약만 있습니다")).toBeVisible();
|
||||
await expect(page.getByText("원본 출처 미제공").first()).toBeVisible();
|
||||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
229
apps/web/e2e/teacher-mobile-targets.spec.ts
Normal file
229
apps/web/e2e/teacher-mobile-targets.spec.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expectNoHorizontalOverflow, signInAsTeacher } from "./support";
|
||||
|
||||
const OUTPUT_DIR = path.resolve(
|
||||
process.cwd(),
|
||||
"../../outputs/ux-audit-2026-09-12/teacher-targets",
|
||||
);
|
||||
|
||||
const VIEWPORTS = [
|
||||
{ width: 320, height: 844, label: "320" },
|
||||
{ width: 390, height: 844, label: "390" },
|
||||
{ width: 768, height: 900, label: "768" },
|
||||
{ width: 1440, height: 900, label: "1440" },
|
||||
] as const;
|
||||
|
||||
type TargetMeasurement = {
|
||||
label: string;
|
||||
selector: string;
|
||||
viewport: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
right: number;
|
||||
scrollWidth: number;
|
||||
clientWidth: number;
|
||||
scrollHeight: number;
|
||||
clientHeight: number;
|
||||
clipped: boolean;
|
||||
};
|
||||
|
||||
type AnalysisMeasurement = {
|
||||
selector: string;
|
||||
x: number;
|
||||
width: number;
|
||||
right: number;
|
||||
clipped: boolean;
|
||||
};
|
||||
|
||||
async function measureTargets(page: Page, viewport: number): Promise<TargetMeasurement[]> {
|
||||
return page.locator(".pf-head__actions .vg-btn, .pf-studio-card__actions .vg-btn, .pf-sort-segment button, .pf-learner-row__action .vg-btn, .pf-session--action, .pf-recent-row--action").evaluateAll(
|
||||
(elements, currentViewport) =>
|
||||
elements
|
||||
.map((element) => {
|
||||
const target = element as HTMLElement;
|
||||
const rect = target.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(target);
|
||||
const visible =
|
||||
style.display !== "none" &&
|
||||
style.visibility !== "hidden" &&
|
||||
Number(style.opacity) !== 0 &&
|
||||
rect.width > 0 &&
|
||||
rect.height > 0;
|
||||
if (!visible) return null;
|
||||
|
||||
return {
|
||||
label: target.textContent?.replace(/\s+/g, " ").trim() ?? "",
|
||||
selector: target.className,
|
||||
viewport: currentViewport,
|
||||
x: Math.round(rect.x),
|
||||
y: Math.round(rect.y),
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height),
|
||||
right: Math.round(rect.right),
|
||||
scrollWidth: target.scrollWidth,
|
||||
clientWidth: target.clientWidth,
|
||||
scrollHeight: target.scrollHeight,
|
||||
clientHeight: target.clientHeight,
|
||||
clipped:
|
||||
rect.left < -1 ||
|
||||
rect.right > window.innerWidth + 1 ||
|
||||
target.scrollWidth > target.clientWidth + 1 ||
|
||||
target.scrollHeight > target.clientHeight + 1,
|
||||
};
|
||||
})
|
||||
.filter((measurement): measurement is NonNullable<typeof measurement> => measurement !== null),
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
|
||||
async function captureConsole(page: Page, label: string) {
|
||||
await page.screenshot({ path: path.join(OUTPUT_DIR, `console-top-${label}.png`) });
|
||||
|
||||
const pending = page.locator('[data-pending-review-row="true"]').first();
|
||||
if (await pending.count()) {
|
||||
await pending.scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: path.join(OUTPUT_DIR, `console-pending-${label}.png`) });
|
||||
}
|
||||
|
||||
const recent = page.locator(".pf-recent-row--action").first();
|
||||
if (await recent.count()) {
|
||||
await recent.scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: path.join(OUTPUT_DIR, `console-recent-${label}.png`) });
|
||||
}
|
||||
}
|
||||
|
||||
async function measureAnalysisSurface(page: Page): Promise<AnalysisMeasurement[]> {
|
||||
return page.locator(
|
||||
".pf-root--analysis, .pf-head h1, .pf-head p, .pf-head__actions, .pf-section--learner-overview, .pf-learner-overview, .pf-learner-toolbar, .pf-search-field, .pf-sort-segment, .pf-learner-table",
|
||||
).evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const target = element as HTMLElement;
|
||||
const rect = target.getBoundingClientRect();
|
||||
return {
|
||||
selector: target.className || target.tagName.toLowerCase(),
|
||||
x: Math.round(rect.x),
|
||||
width: Math.round(rect.width),
|
||||
right: Math.round(rect.right),
|
||||
clipped: rect.left < -1 || rect.right > window.innerWidth + 1,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test("교수자 콘솔의 모바일 터치 대상은 44px이며 잘리지 않는다 @single-run", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
await mkdir(OUTPUT_DIR, { recursive: true });
|
||||
await signInAsTeacher(page);
|
||||
|
||||
const evidence: TargetMeasurement[] = [];
|
||||
const analysisEvidence: Record<string, unknown>[] = [];
|
||||
for (const viewport of VIEWPORTS) {
|
||||
await page.setViewportSize(viewport);
|
||||
await page.goto("/teach");
|
||||
await expect(page.locator(".pf-root--console")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByRole("button", { name: "학생 분석" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "새로고침" })).toBeVisible();
|
||||
await expect(page.locator(".pf-studio-card__actions .vg-btn")).toBeVisible();
|
||||
await expectNoHorizontalOverflow(page);
|
||||
await captureConsole(page, viewport.label);
|
||||
|
||||
const consoleTargets = await measureTargets(page, viewport.width);
|
||||
evidence.push(...consoleTargets);
|
||||
const clipped = consoleTargets.filter((target) => target.clipped);
|
||||
expect(clipped, `교수 콘솔 ${viewport.width}px 잘린 대상`).toEqual([]);
|
||||
if (viewport.width <= 720) {
|
||||
const undersized = consoleTargets.filter(
|
||||
(target) => target.width < 44 || target.height < 44,
|
||||
);
|
||||
expect(undersized, `교수 콘솔 ${viewport.width}px 44px 미만 대상`).toEqual([]);
|
||||
}
|
||||
|
||||
await page.goto("/teach/analysis");
|
||||
await expect(page.locator(".pf-root--analysis")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByRole("heading", { name: "학생별 학습 현황" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "새로고침" })).toBeVisible();
|
||||
await expectNoHorizontalOverflow(page);
|
||||
await page.screenshot({ path: path.join(OUTPUT_DIR, `analysis-top-${viewport.label}.png`) });
|
||||
|
||||
const analysisSurface = await measureAnalysisSurface(page);
|
||||
const outsideViewport = analysisSurface.filter((measurement) => measurement.clipped);
|
||||
expect(outsideViewport, `학생 분석 ${viewport.width}px 표면 범위`).toEqual([]);
|
||||
|
||||
const table = page.locator(".pf-learner-table");
|
||||
await expect(table).toBeVisible();
|
||||
await table.evaluate((element) => {
|
||||
element.scrollLeft = 0;
|
||||
});
|
||||
await page.screenshot({ path: path.join(OUTPUT_DIR, `analysis-table-left-${viewport.label}.png`) });
|
||||
const firstColumn = table.locator(".pf-learner-table__head > span").first();
|
||||
const tableBox = await table.boundingBox();
|
||||
const firstColumnBox = await firstColumn.boundingBox();
|
||||
expect(firstColumnBox, `학생 분석 ${viewport.width}px 표 왼쪽 열`).not.toBeNull();
|
||||
expect(tableBox, `학생 분석 ${viewport.width}px 표 컨테이너`).not.toBeNull();
|
||||
expect(firstColumnBox!.x).toBeGreaterThanOrEqual(tableBox!.x - 1);
|
||||
const tableBounds = await table.evaluate((element) => ({
|
||||
clientWidth: element.clientWidth,
|
||||
scrollWidth: element.scrollWidth,
|
||||
scrollLeft: element.scrollLeft,
|
||||
}));
|
||||
await table.evaluate((element) => {
|
||||
element.scrollLeft = element.scrollWidth - element.clientWidth;
|
||||
});
|
||||
await page.screenshot({ path: path.join(OUTPUT_DIR, `analysis-table-right-${viewport.label}.png`) });
|
||||
const tableEnd = await table.evaluate((element) => element.scrollLeft);
|
||||
const lastColumn = table.locator(".pf-learner-table__head > span").last();
|
||||
const lastColumnBox = await lastColumn.boundingBox();
|
||||
expect(lastColumnBox, `학생 분석 ${viewport.width}px 표 오른쪽 열`).not.toBeNull();
|
||||
expect(lastColumnBox!.x + lastColumnBox!.width).toBeLessThanOrEqual(
|
||||
tableBox!.x + tableBox!.width + 1,
|
||||
);
|
||||
if (tableBounds.scrollWidth > tableBounds.clientWidth) {
|
||||
expect(tableEnd, `학생 분석 ${viewport.width}px 표 오른쪽 끝`).toBeGreaterThan(0);
|
||||
}
|
||||
analysisEvidence.push({
|
||||
viewport: viewport.width,
|
||||
elements: analysisSurface,
|
||||
table: { ...tableBounds, endScrollLeft: tableEnd },
|
||||
});
|
||||
|
||||
const analysisTargets = await measureTargets(page, viewport.width);
|
||||
evidence.push(...analysisTargets);
|
||||
const analysisClipped = analysisTargets.filter((target) => target.clipped);
|
||||
expect(analysisClipped, `학생 분석 ${viewport.width}px 잘린 대상`).toEqual([]);
|
||||
if (viewport.width <= 720) {
|
||||
const undersized = analysisTargets.filter(
|
||||
(target) => target.width < 44 || target.height < 44,
|
||||
);
|
||||
expect(undersized, `학생 분석 ${viewport.width}px 44px 미만 대상`).toEqual([]);
|
||||
|
||||
const openDetail = page.locator(".pf-learner-row__action .vg-btn").first();
|
||||
await expect(openDetail).toBeVisible();
|
||||
await openDetail.click();
|
||||
const backToList = page.getByRole("button", { name: "전체 목록" });
|
||||
await expect(backToList).toBeVisible({ timeout: 15_000 });
|
||||
await expectNoHorizontalOverflow(page);
|
||||
const backToListBox = await backToList.boundingBox();
|
||||
expect(backToListBox, `학생 분석 상세 ${viewport.width}px 전체 목록`).not.toBeNull();
|
||||
expect(backToListBox!.width).toBeGreaterThanOrEqual(44);
|
||||
expect(backToListBox!.height).toBeGreaterThanOrEqual(44);
|
||||
await page.screenshot({
|
||||
path: path.join(OUTPUT_DIR, `analysis-detail-${viewport.label}.png`),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await writeFile(
|
||||
path.join(OUTPUT_DIR, "target-measurements.json"),
|
||||
`${JSON.stringify(evidence, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
path.join(OUTPUT_DIR, "analysis-measurements.json"),
|
||||
`${JSON.stringify(analysisEvidence, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
});
|
||||
|
|
@ -913,7 +913,7 @@ async function openLearnerHome(page: Page) {
|
|||
await page.goto("/learn");
|
||||
await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "오늘 이어갈 회기를 먼저 봅니다." }),
|
||||
page.getByRole("heading", { name: "오늘의 상담 연습" }),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@
|
|||
* 다룬 시나리오(역할별 라우트 가드, 검색 by 이메일·역할·코호트, 티켓 필터 쿼리,
|
||||
* usage 윈도 전환, 콘텐츠·모델 게이트 승인 플로우)는 반복하지 않는다.
|
||||
*/
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
|
||||
const NOW = 1_755_400_000; // 2026-08-17 근처 고정 기준
|
||||
|
|
@ -1442,4 +1444,78 @@ test.describe("관리자 콘솔 유스케이스 여정", () => {
|
|||
}
|
||||
expect((await protocolTab.boundingBox())?.height ?? 0).toBeGreaterThanOrEqual(44);
|
||||
});
|
||||
|
||||
test("모바일 티켓 새로고침과 해결 처리가 동작한다 @single-run", async ({
|
||||
page,
|
||||
}) => {
|
||||
const outputDir = path.resolve(
|
||||
process.cwd(),
|
||||
"../../outputs/ux-audit-2026-09-12/admin-ticket-targets",
|
||||
);
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
await page.setViewportSize({ width: 320, height: 844 });
|
||||
await installAdminMock(page, {
|
||||
tickets: [
|
||||
makeTicket({
|
||||
ticket_id: "ticket-mobile-active",
|
||||
subject: "모바일 티켓 처리",
|
||||
status: "open",
|
||||
}),
|
||||
makeTicket({
|
||||
ticket_id: "ticket-mobile-history",
|
||||
subject: "해결된 모바일 티켓",
|
||||
status: "resolved",
|
||||
resolved_at: NOW - 600,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await page.goto("/admin/tickets");
|
||||
const refresh = page
|
||||
.locator('[data-admin-section="tickets"] .vgops-head')
|
||||
.getByRole("button", { name: "새로고침" });
|
||||
const activeCard = page.locator(".vgops-ticket").filter({
|
||||
hasText: "모바일 티켓 처리",
|
||||
});
|
||||
await expect(refresh).toBeVisible();
|
||||
await expect(activeCard).toBeVisible();
|
||||
await page.screenshot({ path: path.join(outputDir, "tickets-top-320.png") });
|
||||
|
||||
const refreshBox = await refresh.boundingBox();
|
||||
expect(refreshBox?.width ?? 0).toBeGreaterThanOrEqual(44);
|
||||
expect(refreshBox?.height ?? 0).toBeGreaterThanOrEqual(44);
|
||||
await page.setViewportSize({ width: 710, height: 844 });
|
||||
const refreshAt720 = await refresh.boundingBox();
|
||||
expect(refreshAt720?.width ?? 0).toBeGreaterThanOrEqual(44);
|
||||
expect(refreshAt720?.height ?? 0).toBeGreaterThanOrEqual(44);
|
||||
await page.setViewportSize({ width: 320, height: 844 });
|
||||
const reload = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "GET" &&
|
||||
new URL(response.url()).pathname.endsWith("/admin/tickets"),
|
||||
);
|
||||
await refresh.click();
|
||||
await reload;
|
||||
await expect(activeCard).toBeVisible();
|
||||
|
||||
const patch = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "PATCH" &&
|
||||
request.url().includes("/admin/tickets/ticket-mobile-active"),
|
||||
);
|
||||
await activeCard.getByRole("button", { name: "해결" }).click();
|
||||
await patch;
|
||||
|
||||
const history = page.locator(".vgops-ticket--history").filter({
|
||||
hasText: "모바일 티켓 처리",
|
||||
});
|
||||
await expect(history).toBeVisible();
|
||||
await history.scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: path.join(outputDir, "tickets-history-320.png") });
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
|
||||
)
|
||||
.toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -126,13 +126,13 @@ const LEGAL_DOCS = {
|
|||
source_note: "운영 전 초안 문서입니다.",
|
||||
};
|
||||
|
||||
async function routeOnboardingData(page: Page) {
|
||||
async function routeOnboardingData(page: Page, legalDocs = LEGAL_DOCS) {
|
||||
await page.route("**/api/users/me", (route) => {
|
||||
if (route.request().method() !== "GET") return route.fallback();
|
||||
return route.fulfill(jsonRoute(FRESH_PROFILE));
|
||||
});
|
||||
await page.route("**/api/users/legal-docs", (route) =>
|
||||
route.fulfill(jsonRoute(LEGAL_DOCS)),
|
||||
route.fulfill(jsonRoute(legalDocs)),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -143,6 +143,20 @@ async function setupFreshLearnerOnOnboarding(page: Page) {
|
|||
await routeOnboardingData(page);
|
||||
}
|
||||
|
||||
const LONG_LEGAL_DOCS = {
|
||||
...LEGAL_DOCS,
|
||||
terms: {
|
||||
...LEGAL_DOCS.terms,
|
||||
title: "Vignette 서비스 이용약관 초안",
|
||||
version: "terms-draft-2026-06-27",
|
||||
},
|
||||
privacy: {
|
||||
...LEGAL_DOCS.privacy,
|
||||
title: "Vignette 개인정보 처리방침 초안",
|
||||
version: "privacy-draft-2026-06-27",
|
||||
},
|
||||
};
|
||||
|
||||
/** 온보딩 폼의 텍스트 필수 입력을 전부 채운다(동의 체크는 호출부 책임). */
|
||||
async function fillOnboardingTextFields(page: Page, phone = "010-1234-5678") {
|
||||
await page.getByLabel("닉네임").fill("비넷 유스케이스");
|
||||
|
|
@ -289,6 +303,54 @@ test.describe("uc auth-onboarding", () => {
|
|||
await expect(page.locator(".ob-legal .ob-note")).toHaveText("운영 전 초안 문서입니다.");
|
||||
});
|
||||
|
||||
test("좁은 화면에서도 약관 제목·버전을 모두 읽고 문서를 펼친다", async ({ page }) => {
|
||||
for (const viewport of [
|
||||
{ width: 320, height: 844 },
|
||||
{ width: 390, height: 844 },
|
||||
{ width: 1440, height: 900 },
|
||||
]) {
|
||||
await page.setViewportSize(viewport);
|
||||
await routeUnmockedApi(page);
|
||||
await routeMe(page, () => makeMe({ onboarding_completed_at: null, nickname: "" }));
|
||||
await routeOnboardingData(page, LONG_LEGAL_DOCS);
|
||||
|
||||
await page.goto("/onboarding");
|
||||
const details = page.locator(".ob-legal details");
|
||||
const summaries = details.locator("summary");
|
||||
await expect(summaries).toHaveCount(2);
|
||||
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
const summary = summaries.nth(index);
|
||||
const title = summary.locator(".ob-legal__title");
|
||||
const version = summary.locator(".ob-legal__version");
|
||||
await expect(title).toHaveText(index === 0 ? LONG_LEGAL_DOCS.terms.title : LONG_LEGAL_DOCS.privacy.title);
|
||||
await expect(version).toHaveText(index === 0 ? LONG_LEGAL_DOCS.terms.version : LONG_LEGAL_DOCS.privacy.version);
|
||||
|
||||
const geometry = await summary.evaluate((element) => {
|
||||
const title = element.querySelector<HTMLElement>(".ob-legal__title");
|
||||
const version = element.querySelector<HTMLElement>(".ob-legal__version");
|
||||
if (!title || !version) throw new Error("약관 제목 또는 버전 요소가 없습니다.");
|
||||
const summaryRect = element.getBoundingClientRect();
|
||||
const titleRect = title.getBoundingClientRect();
|
||||
const versionRect = version.getBoundingClientRect();
|
||||
return {
|
||||
summaryHeight: summaryRect.height,
|
||||
titleScrollWidth: title.scrollWidth,
|
||||
titleWidth: title.clientWidth,
|
||||
versionTop: versionRect.top,
|
||||
titleBottom: titleRect.bottom,
|
||||
};
|
||||
});
|
||||
expect(geometry.summaryHeight).toBeGreaterThanOrEqual(44);
|
||||
expect(geometry.titleScrollWidth).toBeLessThanOrEqual(geometry.titleWidth);
|
||||
expect(geometry.versionTop).toBeGreaterThanOrEqual(geometry.titleBottom);
|
||||
|
||||
await summary.click();
|
||||
await expect(details.nth(index)).toHaveAttribute("open", "");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// usecase: 신규 사용자가 연락처를 전화번호가 아닌 값으로 넣고 저장하려 한다
|
||||
test("온보딩 연락처 형식이 틀리면 저장하지 않고 형식 오류를 안내한다", async ({ page }) => {
|
||||
await setupFreshLearnerOnOnboarding(page);
|
||||
|
|
|
|||
|
|
@ -400,18 +400,19 @@ async function installFixtures(page: Page, options: FixtureOptions = {}) {
|
|||
}
|
||||
|
||||
test.describe("uc learner home dashboard", () => {
|
||||
// usecase: 평가가 아직 없는 학습자가 메트릭 카드에서 현황 수치와 빈-평가 문구를 읽는다
|
||||
test("평가 전 학습자가 메트릭 카드에서 진행·리뷰·평가·라포 수치를 확인한다", async ({
|
||||
// usecase: 평가가 아직 없는 학습자가 성장 지표에서 현황 수치와 빈-평가 문구를 읽는다
|
||||
test("평가 전 학습자가 성장 지표에서 전체 회기·리뷰·평가·라포 수치를 확인한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installFixtures(page);
|
||||
await page.goto("/learn");
|
||||
await page.getByRole("tab", { name: "성장 지표" }).click();
|
||||
|
||||
const cards = page.locator(".lh-dashboard-status");
|
||||
const cards = page.locator("#lh-panel-growth .lh-dashboard-status");
|
||||
await expect(cards.getByRole("button")).toHaveCount(4);
|
||||
const progressCard = cards.getByRole("button", { name: /진행 회기/ });
|
||||
const progressCard = cards.getByRole("button", { name: /전체 회기/ });
|
||||
await expect(progressCard).toContainText("2회");
|
||||
await expect(progressCard).toContainText("1회 종료 · 1회 진행");
|
||||
await expect(progressCard).toContainText("상담 완료 1회 · 진행 중 1회");
|
||||
const reviewCard = cards.getByRole("button", { name: /리뷰 대기/ });
|
||||
await expect(reviewCard).toContainText("0건");
|
||||
await expect(reviewCard).toContainText("대기 중인 리뷰 없음");
|
||||
|
|
@ -490,9 +491,10 @@ test.describe("uc learner home dashboard", () => {
|
|||
test("리뷰 대기 메트릭 카드를 누르면 기록 화면으로 이동한다", async ({ page }) => {
|
||||
await installFixtures(page);
|
||||
await page.goto("/learn");
|
||||
await page.getByRole("tab", { name: "성장 지표" }).click();
|
||||
|
||||
await page
|
||||
.locator(".lh-dashboard-status")
|
||||
.locator("#lh-panel-growth .lh-dashboard-status")
|
||||
.getByRole("button", { name: /리뷰 대기/ })
|
||||
.click();
|
||||
await expect(page).toHaveURL(/\/learn\/history$/);
|
||||
|
|
@ -584,8 +586,8 @@ test.describe("uc learner home dashboard", () => {
|
|||
await expect(page).toHaveURL(new RegExp(`/learn/session/${ACT_ID}$`));
|
||||
});
|
||||
|
||||
// usecase: 학습자가 내담자를 바꿔 선택한 뒤 '새 회기 시작'으로 새 세션 경로에 진입한다
|
||||
test("다른 내담자를 선택하고 '새 회기 시작'을 누르면 해당 페르소나 세션 경로로 이동한다", async ({
|
||||
// usecase: 학습자가 내담자를 바꿔 선택한 뒤 새 사례로 세션 시작 화면에 진입한다
|
||||
test("다른 내담자를 선택하고 '완전히 새로 시작'을 누르면 새 사례 세션 경로로 이동한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installFixtures(page);
|
||||
|
|
@ -597,8 +599,8 @@ test.describe("uc learner home dashboard", () => {
|
|||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await page.getByRole("button", { name: "새 회기 시작" }).click();
|
||||
await expect(page).toHaveURL(/\/learn\/session\/P2$/);
|
||||
await page.getByRole("button", { name: "완전히 새로 시작" }).click();
|
||||
await expect(page).toHaveURL(/\/learn\/session\/P2\?continuity=fresh$/);
|
||||
});
|
||||
|
||||
// usecase: 학습자가 페르소나 카드에서 난이도 라벨과 주호소 설명을 읽고 비교한다
|
||||
|
|
@ -689,9 +691,10 @@ test.describe("uc learner home dashboard", () => {
|
|||
await expect(focus).toContainText("첫 회기 준비");
|
||||
await expect(focus.getByRole("heading", { name: "성하늘" })).toBeVisible();
|
||||
await expect(focus.locator(".lh-session-focus__status")).toHaveText("대기");
|
||||
// 진행 회기 메트릭도 0회.
|
||||
// 전체 회기 메트릭도 0회.
|
||||
await page.getByRole("tab", { name: "성장 지표" }).click();
|
||||
await expect(
|
||||
page.locator(".lh-dashboard-status").getByRole("button", { name: /진행 회기/ }),
|
||||
page.locator("#lh-panel-growth .lh-dashboard-status").getByRole("button", { name: /전체 회기/ }),
|
||||
).toContainText("0회");
|
||||
// 주 CTA가 '학습 대상 선택'으로 연습 화면에 진입한다.
|
||||
const primaryAction = page.locator("[data-learner-primary-action]");
|
||||
|
|
@ -750,11 +753,11 @@ test.describe("uc learner home dashboard", () => {
|
|||
await expect(growthPanel).toContainText(
|
||||
"첫 회기를 끝내면 라포 누적이 표시됩니다.",
|
||||
);
|
||||
await expect(growthPanel).toContainText("첫 회기 이후 표시");
|
||||
await expect(growthPanel).toContainText("첫 회기 후 표시");
|
||||
});
|
||||
|
||||
// usecase: 학습자가 키보드만으로(Tab 이동 → Enter) 새 회기를 시작한다
|
||||
test("키보드 Tab 이동과 Enter만으로 새 회기를 시작할 수 있다", async ({
|
||||
// usecase: 학습자가 키보드만으로(Tab 이동 → Enter) 새 사례 회기 시작 화면으로 이동한다
|
||||
test("키보드 Tab 이동과 Enter만으로 새 사례 회기를 시작할 수 있다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installFixtures(page);
|
||||
|
|
@ -763,9 +766,9 @@ test.describe("uc learner home dashboard", () => {
|
|||
const listbox = page.getByRole("listbox", { name: "연습 페르소나" });
|
||||
const p1 = listbox.getByRole("option", { name: /P1/ });
|
||||
await expect(p1).toHaveAttribute("aria-selected", "true");
|
||||
// 선택된 페르소나 카드에서 시작해 Tab으로 '새 회기 시작' 버튼까지 이동한다.
|
||||
// 선택된 페르소나 카드에서 시작해 Tab으로 '완전히 새로 시작' 버튼까지 이동한다.
|
||||
await p1.focus();
|
||||
const startButton = page.getByRole("button", { name: "새 회기 시작" });
|
||||
const startButton = page.getByRole("button", { name: "완전히 새로 시작" });
|
||||
let reached = false;
|
||||
for (let i = 0; i < 25; i += 1) {
|
||||
await page.keyboard.press("Tab");
|
||||
|
|
@ -774,9 +777,9 @@ test.describe("uc learner home dashboard", () => {
|
|||
break;
|
||||
}
|
||||
}
|
||||
expect(reached, "Tab 이동으로 '새 회기 시작' 버튼에 도달해야 한다").toBe(true);
|
||||
expect(reached, "Tab 이동으로 '완전히 새로 시작' 버튼에 도달해야 한다").toBe(true);
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page).toHaveURL(/\/learn\/session\/P1$/);
|
||||
await expect(page).toHaveURL(/\/learn\/session\/P1\?continuity=fresh$/);
|
||||
});
|
||||
|
||||
// usecase: 학습자가 대시보드 영역 탭을 화살표 키로 오간다
|
||||
|
|
|
|||
312
apps/web/e2e/ux-audit-surfaces.spec.ts
Normal file
312
apps/web/e2e/ux-audit-surfaces.spec.ts
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { completeOnboarding, signInAsAdmin, signInAsLearner, signInAsTeacher, useRealApi } from "./support";
|
||||
|
||||
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:5173";
|
||||
const VIEWPORT_HEIGHT = 900;
|
||||
const VIEWPORT_WIDTHS = [320, 390, 768, 1024, 1440] as const;
|
||||
const SEGMENT_STEP_RATIO = 0.8;
|
||||
const MAX_SEGMENTS = 20;
|
||||
const REPO_ROOT = path.resolve(process.cwd(), "..", "..");
|
||||
const OUTPUT_DIR = process.env.UX_AUDIT_OUTPUT_DIR ?? path.join(REPO_ROOT, "outputs", "ux-audit-2026-09-12", "surfaces");
|
||||
const MANIFEST_PATH = path.join(OUTPUT_DIR, "manifest.json");
|
||||
|
||||
type Role = "learner" | "teacher" | "admin" | "fixture";
|
||||
type ScrollOwnerKind = "main" | "root" | "document";
|
||||
|
||||
interface Surface {
|
||||
route: string;
|
||||
role: Role;
|
||||
source: "real-api" | "fixture";
|
||||
}
|
||||
|
||||
interface ScrollPlan {
|
||||
owner: ScrollOwnerKind;
|
||||
selector: string | null;
|
||||
offsets: number[];
|
||||
incomplete: boolean;
|
||||
scrollHeight: number;
|
||||
clientHeight: number;
|
||||
}
|
||||
|
||||
interface ManifestEntry {
|
||||
route: string;
|
||||
role: Role;
|
||||
source: Surface["source"];
|
||||
width: number;
|
||||
height: number;
|
||||
scrollowner: ScrollOwnerKind;
|
||||
offset: number;
|
||||
file: string;
|
||||
timestamp: string;
|
||||
screenheading: string;
|
||||
consoleerror: string[];
|
||||
httperror: string[];
|
||||
status: "captured" | "http-error";
|
||||
incomplete: boolean;
|
||||
scrollHeight: number;
|
||||
clientHeight: number;
|
||||
}
|
||||
|
||||
const manifest: ManifestEntry[] = [];
|
||||
|
||||
function fileStem(route: string) {
|
||||
return route.replace(/^\//, "").replaceAll("/", "-") || "home";
|
||||
}
|
||||
|
||||
function routePattern(route: string) {
|
||||
return new RegExp(`${route.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:[?#]|$)`);
|
||||
}
|
||||
|
||||
function assertLocalAuditBaseUrl() {
|
||||
const parsed = new URL(BASE_URL);
|
||||
expect(parsed.protocol, `UI 감사는 HTTPS 운영 서버를 대상으로 실행할 수 없다: ${BASE_URL}`).toBe("http:");
|
||||
expect(
|
||||
["localhost", "127.0.0.1", "::1"],
|
||||
`UI 감사는 로컬 호스트에서만 dev 계정을 생성할 수 있다: ${BASE_URL}`,
|
||||
).toContain(parsed.hostname);
|
||||
return parsed.origin;
|
||||
}
|
||||
|
||||
async function writeManifest(baseURL: string) {
|
||||
await fs.mkdir(OUTPUT_DIR, { recursive: true });
|
||||
await fs.writeFile(
|
||||
MANIFEST_PATH,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
generatedAt: new Date().toISOString(),
|
||||
baseURL,
|
||||
viewportWidths: VIEWPORT_WIDTHS,
|
||||
viewportHeight: VIEWPORT_HEIGHT,
|
||||
entries: manifest,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
async function loginFreshLearner(page: Page, prefix: string) {
|
||||
const response = await page.request.post("/api/auth/dev-login", {
|
||||
data: {
|
||||
email: `${prefix}.${Date.now()}@hs.ac.kr`,
|
||||
role: "learner",
|
||||
display_name: "UX Audit Fixture Learner",
|
||||
},
|
||||
});
|
||||
expect(response.ok(), await response.text()).toBeTruthy();
|
||||
}
|
||||
|
||||
async function loginOnboardedLearner(page: Page, prefix: string) {
|
||||
await loginFreshLearner(page, prefix);
|
||||
await completeOnboarding(page, {
|
||||
legal_name: "UX 감사 학습자",
|
||||
affiliation: "한신대학교",
|
||||
department: "상담심리학과",
|
||||
grade_level: "3학년",
|
||||
phone: "010-0000-0000",
|
||||
contact_address: "경기도 오산시 한신대학교",
|
||||
});
|
||||
}
|
||||
|
||||
async function installPendingFixture(page: Page) {
|
||||
await page.route("**/api/auth/me", async (route) => {
|
||||
if (route.request().method() !== "GET") {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
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,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForSurfaceReady(page: Page, route: string) {
|
||||
await expect(page).toHaveURL(routePattern(route), { timeout: 20_000 });
|
||||
await expect(page.getByRole("heading").first()).toBeVisible({ timeout: 20_000 });
|
||||
await page.waitForLoadState("networkidle", { timeout: 20_000 });
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.locator(
|
||||
".g6-loading, .cic-loading, .lh-loading, .vg-loading, [data-loading='true']",
|
||||
).count(),
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toBe(0);
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
|
||||
async function readScrollPlan(page: Page): Promise<ScrollPlan> {
|
||||
return page.evaluate(({ maxSegments, stepRatio }) => {
|
||||
const candidates: Array<{ owner: ScrollOwnerKind; selector: string | null; element: HTMLElement }> = [];
|
||||
const main = document.querySelector<HTMLElement>("#vg-main-content");
|
||||
const root = document.querySelector<HTMLElement>("#root");
|
||||
if (main) candidates.push({ owner: "main", selector: "#vg-main-content", element: main });
|
||||
if (root) candidates.push({ owner: "root", selector: "#root", element: root });
|
||||
const documentScroller = document.scrollingElement as HTMLElement | null;
|
||||
if (documentScroller) candidates.push({ owner: "document", selector: null, element: documentScroller });
|
||||
|
||||
const selected =
|
||||
candidates.find((candidate) => candidate.element.scrollHeight > candidate.element.clientHeight + 1) ??
|
||||
candidates.at(-1);
|
||||
if (!selected) throw new Error("No scroll owner found for UX audit capture");
|
||||
|
||||
const maxOffset = Math.max(0, selected.element.scrollHeight - selected.element.clientHeight);
|
||||
const step = Math.max(1, Math.floor(selected.element.clientHeight * stepRatio));
|
||||
const allOffsets = [0];
|
||||
for (let offset = step; offset < maxOffset; offset += step) allOffsets.push(offset);
|
||||
if (allOffsets.at(-1) !== maxOffset) allOffsets.push(maxOffset);
|
||||
|
||||
return {
|
||||
owner: selected.owner,
|
||||
selector: selected.selector,
|
||||
offsets: allOffsets.slice(0, maxSegments),
|
||||
incomplete: allOffsets.length > maxSegments,
|
||||
scrollHeight: selected.element.scrollHeight,
|
||||
clientHeight: selected.element.clientHeight,
|
||||
};
|
||||
}, { maxSegments: MAX_SEGMENTS, stepRatio: SEGMENT_STEP_RATIO });
|
||||
}
|
||||
|
||||
async function scrollToOffset(page: Page, plan: ScrollPlan, offset: number) {
|
||||
await page.evaluate(
|
||||
({ selector, offset: nextOffset }) => {
|
||||
const target = selector
|
||||
? document.querySelector<HTMLElement>(selector)
|
||||
: (document.scrollingElement as HTMLElement | null);
|
||||
if (!target) throw new Error(`Scroll owner missing: ${selector ?? "document"}`);
|
||||
target.scrollTop = nextOffset;
|
||||
target.dispatchEvent(new Event("scroll"));
|
||||
},
|
||||
{ selector: plan.selector, offset },
|
||||
);
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
|
||||
async function captureSurface(
|
||||
page: Page,
|
||||
surface: Surface,
|
||||
baseURL: string,
|
||||
consoleErrors: string[],
|
||||
httpErrors: string[],
|
||||
) {
|
||||
for (const width of VIEWPORT_WIDTHS) {
|
||||
const consoleStart = consoleErrors.length;
|
||||
const httpStart = httpErrors.length;
|
||||
await page.setViewportSize({ width, height: VIEWPORT_HEIGHT });
|
||||
await page.goto(`${baseURL}${surface.route}`, { waitUntil: "domcontentloaded" });
|
||||
await waitForSurfaceReady(page, surface.route);
|
||||
|
||||
const horizontalOverflow = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
);
|
||||
expect(horizontalOverflow, `${surface.route} ${width}px 문서 가로 오버플로`).toBeLessThanOrEqual(1);
|
||||
|
||||
const plan = await readScrollPlan(page);
|
||||
const heading = (await page.getByRole("heading").first().innerText()).replace(/\s+/g, " ").trim();
|
||||
for (const [segment, offset] of plan.offsets.entries()) {
|
||||
await scrollToOffset(page, plan, offset);
|
||||
const filename = `${fileStem(surface.route)}-${surface.role}-w${width}-s${segment}-y${offset}.png`;
|
||||
const outputPath = path.join(OUTPUT_DIR, filename);
|
||||
await page.screenshot({ path: outputPath, fullPage: false, animations: "disabled" });
|
||||
manifest.push({
|
||||
route: surface.route,
|
||||
role: surface.role,
|
||||
source: surface.source,
|
||||
width,
|
||||
height: VIEWPORT_HEIGHT,
|
||||
scrollowner: plan.owner,
|
||||
offset,
|
||||
file: path.relative(REPO_ROOT, outputPath).split(path.sep).join("/"),
|
||||
timestamp: new Date().toISOString(),
|
||||
screenheading: heading,
|
||||
consoleerror: consoleErrors.slice(consoleStart),
|
||||
httperror: httpErrors.slice(httpStart),
|
||||
status: httpErrors.length > httpStart ? "http-error" : "captured",
|
||||
incomplete: plan.incomplete,
|
||||
scrollHeight: plan.scrollHeight,
|
||||
clientHeight: plan.clientHeight,
|
||||
});
|
||||
await writeManifest(baseURL);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
test.use({ baseURL: BASE_URL });
|
||||
|
||||
test("@single-run UI 감사 누락 표면을 실제 상태와 전체 세로 구간으로 보존한다", async ({ page }) => {
|
||||
test.setTimeout(1_800_000);
|
||||
await useRealApi(page);
|
||||
const baseURL = assertLocalAuditBaseUrl();
|
||||
await writeManifest(baseURL);
|
||||
|
||||
const consoleErrors: string[] = [];
|
||||
const httpErrors: string[] = [];
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") consoleErrors.push(message.text());
|
||||
});
|
||||
page.on("response", (response) => {
|
||||
if (response.status() >= 400) httpErrors.push(`${response.status()} ${response.url()}`);
|
||||
});
|
||||
|
||||
await signInAsLearner(page);
|
||||
for (const surface of [
|
||||
{ route: "/learn/practice", role: "learner", source: "real-api" },
|
||||
{ route: "/learn/history", role: "learner", source: "real-api" },
|
||||
] satisfies Surface[]) {
|
||||
await captureSurface(page, surface, baseURL, consoleErrors, httpErrors);
|
||||
}
|
||||
|
||||
await signInAsTeacher(page);
|
||||
await captureSurface(
|
||||
page,
|
||||
{ route: "/teach/supervision", role: "teacher", source: "real-api" },
|
||||
baseURL,
|
||||
consoleErrors,
|
||||
httpErrors,
|
||||
);
|
||||
|
||||
await signInAsAdmin(page);
|
||||
for (const surface of [
|
||||
{ route: "/admin/continuous-improvement", role: "admin", source: "real-api" },
|
||||
{ route: "/admin/access", role: "admin", source: "real-api" },
|
||||
{ route: "/admin/tickets", role: "admin", source: "real-api" },
|
||||
] satisfies Surface[]) {
|
||||
await captureSurface(page, surface, baseURL, consoleErrors, httpErrors);
|
||||
}
|
||||
|
||||
await loginFreshLearner(page, "ux-audit-onboarding");
|
||||
await captureSurface(
|
||||
page,
|
||||
{ route: "/onboarding", role: "fixture", source: "fixture" },
|
||||
baseURL,
|
||||
consoleErrors,
|
||||
httpErrors,
|
||||
);
|
||||
|
||||
await loginOnboardedLearner(page, "ux-audit-pending");
|
||||
await installPendingFixture(page);
|
||||
await captureSurface(
|
||||
page,
|
||||
{ route: "/pending", role: "fixture", source: "fixture" },
|
||||
baseURL,
|
||||
consoleErrors,
|
||||
httpErrors,
|
||||
);
|
||||
await page.unroute("**/api/auth/me");
|
||||
|
||||
const incomplete = manifest.filter((entry) => entry.incomplete);
|
||||
expect(incomplete, `20개를 넘는 스크롤 구간은 미완료로 기록한다: ${JSON.stringify(incomplete)}`).toEqual([]);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue