전수 E2E 순회·소유자 결정 6건 구현·디자인 감사 반영

- 전수 순회: IA 전 라우트 412개 기능 인벤토리를 체크리스트 백로그로 관리
  (docs/ops/e2e-full-sweep-2026-07-27.md), 신규 full-sweep 스펙 10파일 추가.
  RED→GREEN으로 결함 12건 수정: 관리자 무한 렌더 프리즈(TanStack autoReset
  루프), 복수 코호트 저장 유실, 페르소나 보관 503(SQL 컬럼 모호성), PII 과잉
  마스킹, /admin/ai watchdog 오탐 오버레이, 모바일 겹침 2건, 설정 스크롤
  스파이, 온보딩 전화번호 무검증, 리뷰 조사·난도 라벨, pending 피드백 등.
- 소유자 결정 구현: 설정 아바타 변경, 동의 철회·재동의 전체 흐름, 신규
  학습자 기초 우선 추천, 학생 분석 테이블 가상화(@tanstack/react-virtual),
  저작 모드 죽은 레일 정리, 감정 밸런스 타임라인 차트(deep turn_valence +
  결정론 파생 폴백).
- 디자인 감사(142차): 라이트 팔레트 AA 대비, 다크 토큰 별칭 통일, 미정의
  CSS 변수 정리, 한글 keep-all 전역화, 탭 타깃 24px, LCP preconnect.
- 검증: npm run e2e 병렬 432 수집 GREEN + 직렬 49/49 exit 0, 백엔드 pytest
  421, gateway 29, typecheck/build/design-ssot/dead-code/중복 게이트 통과,
  layout-visual-gate 15/15, session-layout 8/8. 상세는 SSOT 대시보드
  142~144차 노트.
This commit is contained in:
Yun Chan 2026-07-27 14:25:24 +09:00
parent 0ae94499f2
commit 4511383cd9
55 changed files with 9333 additions and 573 deletions

View file

@ -0,0 +1,495 @@
/* =====================================================================
full-sweep-settings.spec.ts 2026-07-27 E2E , (/settings) .
docs/ops/e2e-full-sweep-2026-07-27.md §9 "검증: 신규 spec 필요"
. test() checklist id를 .
- API(dev-login, P1~P7 ) , ·
page.route() fixture를 . AI .
===================================================================== */
import { expect, test, type Page, type Response, type TestInfo } from "@playwright/test";
import { completeOnboarding, useRealApi } from "./support";
/** 1x1 PNG — 아바타 업로드 e2e용 최소 픽셀. */
const PNG_1X1_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lTQvYwAAAABJRU5ErkJggg==";
function slugFor(testInfo: TestInfo) {
let hash = 0;
for (const char of testInfo.title) {
hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
}
const project = testInfo.project.name.includes("mobile") ? "mob" : "desk";
return `${project}.${testInfo.workerIndex}.${testInfo.retry}.${hash.toString(36)}`;
}
function isApiResponse(method: string, pathnameSuffix: string) {
return (response: Response) => {
const url = new URL(response.url());
return response.request().method() === method && url.pathname.endsWith(pathnameSuffix);
};
}
async function devLogin(
page: Page,
role: "learner" | "admin",
testInfo: TestInfo,
displayName: string,
options: { onboarding?: boolean } = {},
) {
const domain = role === "admin" ? "twentyoz.kr" : "hs.ac.kr";
const email = `sweep.settings.${role}.${slugFor(testInfo)}@${domain}`;
const res = await page.request.post("/api/auth/dev-login", {
data: { email, role, display_name: displayName },
});
expect(res.ok(), await res.text()).toBeTruthy();
if (options.onboarding !== false) {
await completeOnboarding(page, {
legal_name: displayName,
affiliation: "한신대학교",
department: role === "admin" ? "운영팀" : "상담심리학과",
grade_level: role === "admin" ? "관리자" : "3학년",
phone: "010-4444-4444",
contact_address: "경기도 오산시 한신대학교",
nickname: displayName,
self_introduction: `${displayName} 설정 전수 순회 E2E 사용자입니다.`,
});
}
return email;
}
/** /settings 진입 후 프로필·환경설정이 도착해 저장 버튼들이 렌더될 때까지 대기. */
async function openLoadedSettings(page: Page) {
await page.goto("/settings");
await expect(page).toHaveURL(/\/settings$/);
await expect(page.locator(".vg-set")).toBeVisible();
// preferences 도착 → 알림 저장 버튼 렌더, profile 도착 → 계정 저장 버튼 활성화
await expect(page.getByRole("button", { name: "알림 저장" })).toBeVisible({ timeout: 10_000 });
await expect(
page.locator("#set-account .vg-set__foot .vg-btn"),
).toBeEnabled({ timeout: 10_000 });
}
function pendingMeResponse(overrides: Record<string, unknown> = {}) {
return {
user_id: "e2e-pending-user",
email: "sweep.settings.pending@hs.ac.kr",
display_name: "승인 대기 학습자",
role: "learner",
admin_access: false,
super_admin: false,
account_status: "pending",
approval_required: true,
avatar_url: "",
cohort_ids: [],
consent_at: null,
nickname: "승인대기",
self_introduction: "",
onboarding_completed_at: null,
...overrides,
};
}
test.describe("full-sweep settings", () => {
test.beforeEach(async ({ page }) => {
await useRealApi(page);
});
// checklist: settings-guard-require-auth
test("redirects an unauthenticated visitor from /settings to /login", async ({ page }) => {
await page.goto("/settings");
await expect(page).toHaveURL(/\/login$/);
// 설정 화면 본문이 렌더되지 않아야 한다.
await expect(page.locator(".vg-set")).toHaveCount(0);
});
// checklist: settings-guard-pending-approval
test("redirects a pending-approval user from /settings to /pending", async ({ page }) => {
await page.route("**/api/auth/me", async (route) => {
await route.fulfill({ json: pendingMeResponse() });
});
await page.goto("/settings");
await expect(page).toHaveURL(/\/pending$/);
await expect(page.locator(".pa-kicker")).toHaveText("승인 대기");
await expect(page.locator(".pa-status")).toContainText("접속 권한 확인 중");
await expect(page.locator(".vg-set")).toHaveCount(0);
});
// checklist: settings-guard-onboarding
test("redirects a signed-in user without onboarding from /settings to /onboarding", async ({
page,
}, testInfo) => {
await devLogin(page, "learner", testInfo, "Sweep Onboarding Gate", { onboarding: false });
await page.goto("/settings");
await expect(page).toHaveURL(/\/onboarding$/);
await expect(page.locator(".vg-set")).toHaveCount(0);
});
// checklist: settings-load-error-callout
test("shows a role=alert warning callout when the settings load fails", async ({
page,
}, testInfo) => {
await devLogin(page, "learner", testInfo, "Sweep Load Error");
await page.route("**/api/users/me/preferences", async (route) => {
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ detail: "preferences unavailable (e2e fixture)" }),
});
});
await page.goto("/settings");
const callout = page.locator(".vg-set__callout--warn");
await expect(callout).toBeVisible({ timeout: 10_000 });
await expect(callout).toHaveAttribute("role", "alert");
await expect(callout.locator(".vg-set__callout-text")).not.toHaveText("");
});
// checklist: settings-support-error-state
test("keeps the page usable with an inline alert when only support tickets fail", async ({
page,
}, testInfo) => {
await devLogin(page, "learner", testInfo, "Sweep Ticket Error");
await page.route("**/api/users/support-tickets", async (route) => {
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ detail: "support tickets unavailable (e2e fixture)" }),
});
});
await openLoadedSettings(page);
// 지원 요청 섹션 안에만 role=alert 경고가 뜨고, 페이지 전체 오류 콜아웃은 없다.
const supportAlert = page.locator("#set-support .vg-set__state--warn");
await expect(supportAlert).toBeVisible();
await expect(supportAlert).toHaveAttribute("role", "alert");
await expect(page.locator(".vg-set__callout--warn")).toHaveCount(0);
// 다른 섹션은 살아 있다: 계정 저장·알림 저장 동작 가능 상태.
await expect(page.locator("#set-account .vg-set__foot .vg-btn")).toBeEnabled();
await expect(page.getByRole("button", { name: "알림 저장" })).toBeEnabled();
});
// checklist: settings-support-empty-state
test("shows distinct support empty states for zero tickets and a null payload", async ({
page,
}, testInfo) => {
await devLogin(page, "learner", testInfo, "Sweep Ticket Empty");
// 1) 실 API — 방금 만든 학습자는 접수 티켓 0건.
await openLoadedSettings(page);
const emptyState = page.getByTestId("settings-support-empty");
await expect(emptyState).toBeVisible();
await expect(emptyState).toContainText("접수한 지원 요청이 없습니다");
// 2) fixture — 저장소가 null 페이로드를 반환하면 별도 문구를 보여준다.
await page.route("**/api/users/support-tickets", async (route) => {
await route.fulfill({ status: 200, contentType: "application/json", body: "null" });
});
await openLoadedSettings(page);
await expect(emptyState).toBeVisible();
await expect(emptyState).toContainText("지원 요청 저장소에 표시할 항목이 없습니다");
});
// checklist: settings-rail-nav-buttons
test("rail nav buttons scroll to their section and expose aria-current", async ({
page,
}, testInfo) => {
await devLogin(page, "learner", testInfo, "Sweep Rail Nav");
await openLoadedSettings(page);
const nav = page.getByRole("navigation", { name: "설정 섹션" });
// 학습자에게는 관리자 전용 AI 운영 항목이 없다(계정·지원 요청·동의·테마·알림·음성 6개).
await expect(nav.getByRole("button")).toHaveCount(6);
await expect(nav.getByRole("button", { name: "AI 운영" })).toHaveCount(0);
const accountItem = nav.getByRole("button", { name: "계정" });
const voiceItem = nav.getByRole("button", { name: "음성" });
await expect(accountItem).toHaveAttribute("aria-current", "location");
await voiceItem.click();
await expect(page.locator("#set-voice")).toBeInViewport();
await expect(voiceItem).toHaveAttribute("aria-current", "location");
await expect(accountItem).not.toHaveAttribute("aria-current", "location");
await accountItem.click();
await expect(page.locator("#set-account")).toBeInViewport();
await expect(accountItem).toHaveAttribute("aria-current", "location");
});
// checklist: settings-rail-nav-scrollspy
test("scroll spy syncs the active rail item to the section scrolled into view", async ({
page,
}, testInfo) => {
await devLogin(page, "learner", testInfo, "Sweep Scrollspy");
await openLoadedSettings(page);
const nav = page.getByRole("navigation", { name: "설정 섹션" });
await expect(nav.getByRole("button", { name: "계정" })).toHaveAttribute(
"aria-current",
"location",
);
// 클릭 없이 스크롤만으로 IntersectionObserver가 활성 섹션을 동기화해야 한다.
await page.evaluate(() => {
document.getElementById("set-voice")?.scrollIntoView({ block: "start" });
});
await expect(nav.getByRole("button", { name: "음성" })).toHaveAttribute(
"aria-current",
"location",
{ timeout: 10_000 },
);
await expect(nav.getByRole("button", { name: "계정" })).not.toHaveAttribute(
"aria-current",
"location",
);
});
// checklist: settings-theme-dark-toggle
test("dark mode switch flips data-theme immediately and persists to localStorage", async ({
page,
}, testInfo) => {
await devLogin(page, "learner", testInfo, "Sweep Theme Toggle");
await openLoadedSettings(page);
const readTheme = () =>
page.evaluate(() => ({
applied: document.documentElement.getAttribute("data-theme"),
stored: window.localStorage.getItem("vignette.theme"),
}));
const initialDark = (await readTheme()).applied === "dark";
const darkSwitch = page.locator("#set-appearance").getByRole("switch", { name: "다크 모드" });
await expect(darkSwitch).toHaveAttribute("aria-checked", String(initialDark));
await darkSwitch.click();
const flipped = !initialDark;
await expect(darkSwitch).toHaveAttribute("aria-checked", String(flipped));
await expect.poll(readTheme).toEqual({
applied: flipped ? "dark" : "light",
stored: flipped ? "dark" : "light",
});
// 되돌리기도 즉시 반영된다.
await darkSwitch.click();
await expect(darkSwitch).toHaveAttribute("aria-checked", String(initialDark));
await expect.poll(readTheme).toEqual({
applied: initialDark ? "dark" : "light",
stored: initialDark ? "dark" : "light",
});
});
// checklist: settings-theme-save-button, settings-saved-flash-indicator
test("theme save button PATCHes the current theme and flashes then clears 저장됨", async ({
page,
}, testInfo) => {
await devLogin(page, "learner", testInfo, "Sweep Theme Save");
await openLoadedSettings(page);
const appearance = page.locator("#set-appearance");
const darkSwitch = appearance.getByRole("switch", { name: "다크 모드" });
const isDark = (await darkSwitch.getAttribute("aria-checked")) === "true";
// 한 번 토글해 저장할 값을 명시적으로 만든다.
await darkSwitch.click();
const nextTheme = !isDark ? "dark" : "light";
await expect(darkSwitch).toHaveAttribute("aria-checked", String(!isDark));
const patchPromise = page.waitForResponse(
isApiResponse("PATCH", "/users/me/preferences"),
{ timeout: 10_000 },
);
await appearance.locator(".vg-set__foot .vg-btn").click();
const patchResponse = await patchPromise;
expect(patchResponse.ok(), await patchResponse.text()).toBeTruthy();
const requestBody = patchResponse.request().postDataJSON() as { theme?: string };
expect(requestBody.theme).toBe(nextTheme);
const saved = (await patchResponse.json()) as { theme: string };
expect(saved.theme).toBe(nextTheme);
// '저장됨' 플래시: 표시됐다가 2.4초 뒤 자동으로 사라진다.
const savedFlash = appearance.locator(".vg-set__saved");
await expect(savedFlash).toBeVisible();
await expect(savedFlash).toContainText("저장됨");
await expect(savedFlash).toHaveCount(0, { timeout: 6_000 });
});
// checklist: settings-voice-empty-state
test("voice section shows an empty state and hides slider/save without presets", async ({
page,
}, testInfo) => {
await devLogin(page, "learner", testInfo, "Sweep Voice Empty");
await page.route("**/api/users/me/voice-presets", async (route) => {
await route.fulfill({ json: [] });
});
await openLoadedSettings(page);
const voiceEmpty = page.getByTestId("settings-voice-empty");
await expect(voiceEmpty).toBeVisible();
await expect(voiceEmpty).toContainText("서버에 등록된 음성 프리셋이 없습니다");
await expect(page.getByLabel("말하기 속도")).toHaveCount(0);
await expect(page.getByRole("button", { name: "음성 저장" })).toHaveCount(0);
await expect(page.locator("#set-voice").getByRole("radio")).toHaveCount(0);
});
// checklist: settings-topbar-theme-toggle
test("topbar theme button drives the same global store as the settings switch", async ({
page,
}, testInfo) => {
await devLogin(page, "learner", testInfo, "Sweep Topbar Theme");
await openLoadedSettings(page);
const darkSwitch = page.locator("#set-appearance").getByRole("switch", { name: "다크 모드" });
const initialDark =
(await page.evaluate(() => document.documentElement.getAttribute("data-theme"))) === "dark";
await expect(darkSwitch).toHaveAttribute("aria-checked", String(initialDark));
const topbarToggle = page.getByRole("button", {
name: initialDark ? "라이트 모드로" : "다크 모드로",
});
await topbarToggle.click();
const flipped = !initialDark;
await expect
.poll(() => page.evaluate(() => document.documentElement.getAttribute("data-theme")))
.toBe(flipped ? "dark" : "light");
// 설정 섹션의 스위치가 같은 스토어를 구독해 함께 뒤집힌다.
await expect(darkSwitch).toHaveAttribute("aria-checked", String(flipped));
// 버튼 라벨도 반대 방향으로 갱신된다.
await expect(
page.getByRole("button", { name: flipped ? "라이트 모드로" : "다크 모드로" }),
).toBeVisible();
});
// checklist: settings-topbar-logout
test("topbar logout ends the server session and returns to /login", async ({
page,
}, testInfo) => {
await devLogin(page, "learner", testInfo, "Sweep Logout");
await openLoadedSettings(page);
const logoutPromise = page.waitForResponse(isApiResponse("POST", "/auth/logout"), {
timeout: 10_000,
});
await page.getByRole("button", { name: "로그아웃" }).click();
const logoutResponse = await logoutPromise;
expect(logoutResponse.ok(), await logoutResponse.text()).toBeTruthy();
await expect(page).toHaveURL(/\/login$/);
// 서버 세션이 실제로 종료됐는지 확인 — 쿠키로 /auth/me 재조회 시 401.
const meAfter = await page.request.get("/api/auth/me");
expect(meAfter.status()).toBe(401);
});
// checklist: settings-avatar-upload-missing
// 소유자 결정 D1: 계정 섹션에서 온보딩과 같은 업로드 컨트롤로 아바타를 교체한다.
// 업로드 성공 → PATCH /users/me 저장 → 미리보기·레일 아바타가 이미지로 전환된다.
test("settings exposes an avatar upload control (briefing requirement)", async ({
page,
}, testInfo) => {
await devLogin(page, "learner", testInfo, "Sweep Avatar Upload");
await openLoadedSettings(page);
// 업로드 전에는 이니셜 폴백만 렌더된다.
const account = page.locator("#set-account");
await expect(account.locator(".vg-set__avatar img")).toHaveCount(0);
const fileInput = account.locator("input[type='file']");
await expect(fileInput).toHaveAttribute("accept", "image/png,image/jpeg,image/webp");
const uploadPromise = page.waitForResponse(isApiResponse("POST", "/users/me/avatar"), {
timeout: 10_000,
});
const patchPromise = page.waitForResponse(isApiResponse("PATCH", "/users/me"), {
timeout: 10_000,
});
await fileInput.setInputFiles({
name: "sweep-settings-avatar.png",
mimeType: "image/png",
buffer: Buffer.from(PNG_1X1_BASE64, "base64"),
});
const uploadResponse = await uploadPromise;
expect(uploadResponse.ok(), await uploadResponse.text()).toBeTruthy();
const uploaded = (await uploadResponse.json()) as { avatar_url: string };
expect(uploaded.avatar_url.length).toBeGreaterThan(0);
// 업로드 성공 직후 avatar_url 이 프로필 PATCH 로 저장된다.
const patchResponse = await patchPromise;
expect(patchResponse.ok(), await patchResponse.text()).toBeTruthy();
// 계정 요약 미리보기가 업로드된 이미지를 가리키고, 레일 아바타도 이미지로 전환된다.
const preview = account.locator(".vg-set__avatar img");
await expect(preview).toBeVisible();
expect(await preview.getAttribute("src")).toContain(uploaded.avatar_url);
await expect(page.locator(".vg-set__rail-avatar img")).toHaveCount(1);
// 서버 프로필 재조회로 avatar_url 영속 저장을 확인한다.
const meRes = await page.request.get("/api/users/me");
expect(meRes.ok(), await meRes.text()).toBeTruthy();
const me = (await meRes.json()) as { avatar_url: string };
expect(me.avatar_url).toBe(uploaded.avatar_url);
});
// checklist: settings-consent-management-missing
// 소유자 결정 D2: 동의 섹션 — 실습 동의 상태·약관 버전 표시, learner 철회(2단계 확인)
// → 차단 안내 → 재동의 복원까지 전체 흐름을 검증한다.
test("settings exposes consent management and withdrawal UI (briefing requirement)", async ({
page,
}, testInfo) => {
// 주의: 공유 learner@hs.ac.kr 의 동의 상태를 파괴하지 않도록 반드시 테스트가
// 새로 만든 고유 dev-login 계정으로만 철회·재동의를 수행한다.
await devLogin(page, "learner", testInfo, "Sweep Consent Mgmt");
const consentSeed = await page.request.post("/api/auth/consent", {
data: { accepted: true },
});
expect(consentSeed.ok(), await consentSeed.text()).toBeTruthy();
await openLoadedSettings(page);
// 레일 내비에 동의 항목이 있고 클릭하면 섹션으로 스크롤된다.
const nav = page.getByRole("navigation", { name: "설정 섹션" });
await nav.getByRole("button", { name: "동의" }).click();
const consent = page.locator("#set-consent");
await expect(consent).toBeInViewport();
// 동의됨 상태: 실습 동의 시각 + 약관·개인정보 동의 버전이 표시된다.
await expect(consent.getByTestId("settings-consent-practice")).toContainText("동의 시각");
await expect(consent.getByText("이용약관")).toBeVisible();
await expect(consent.getByText("개인정보 처리방침")).toBeVisible();
// 철회는 2단계: 첫 클릭은 인라인 확인만 띄우고, 확정 클릭에서만 DELETE 가 나간다.
await consent.getByRole("button", { name: "실습 동의 철회" }).click();
const confirmButton = consent.getByRole("button", { name: "철회 확정" });
await expect(confirmButton).toBeVisible();
const withdrawPromise = page.waitForResponse(isApiResponse("DELETE", "/auth/consent"), {
timeout: 10_000,
});
await confirmButton.click();
const withdrawResponse = await withdrawPromise;
expect(withdrawResponse.ok(), await withdrawResponse.text()).toBeTruthy();
// 차단 안내가 뜨고 서버 consent_at 이 null 로 비워진다.
await expect(
consent.getByText("새 회기 시작과 음성 연습이 차단됩니다."),
).toBeVisible();
const meAfterWithdraw = await page.request.get("/api/auth/me");
expect(meAfterWithdraw.ok(), await meAfterWithdraw.text()).toBeTruthy();
expect(
((await meAfterWithdraw.json()) as { consent_at: number | null }).consent_at,
).toBeNull();
// 다시 동의 → consent_at 복원 + 화면도 동의 시각 표기로 되돌아간다.
const reacceptPromise = page.waitForResponse(isApiResponse("POST", "/auth/consent"), {
timeout: 10_000,
});
await consent.getByRole("button", { name: "다시 동의" }).click();
const reacceptResponse = await reacceptPromise;
expect(reacceptResponse.ok(), await reacceptResponse.text()).toBeTruthy();
await expect(consent.getByTestId("settings-consent-practice")).toContainText("동의 시각");
const meAfterAccept = await page.request.get("/api/auth/me");
expect(meAfterAccept.ok(), await meAfterAccept.text()).toBeTruthy();
expect(
((await meAfterAccept.json()) as { consent_at: number | null }).consent_at,
).not.toBeNull();
});
});