vignette/apps/web/e2e/readiness.spec.ts
Yun Chan cf899b7f15 유스케이스 TDD 16테마 스펙과 접근성 제품 결함 수정
- 지원 티켓 작성 UI 신설(설정)·관리자 해결 노트 입력 신설(Admin): 서버 계약은 있었으나
  웹 진입점이 없던 2결함
- a11y: outline 채널 포커스 링(ui/shell css), 세션바 44px 터치 타깃, 청록 하드코딩
  그라디언트를 테마 토큰으로 교체, 설정 라벨/헤딩/대비·리뷰 44px·모바일 오버플로 수정
- uc-*.spec.ts 16테마 239 시나리오 신규(수집 1109 tests/61 files), 기존 스펙 5종 계약 드리프트 교정
- breakpoint-sweep: widthsFor 솎아내기가 실기기 대표 폭(360/390/1024)을 탈락시키는 테스트 결함 수정
  — keep 시드를 전체 DEVICE_WIDTHS로, 3연폭 예외는 솎아내기 발생 여부 기준으로
- 검증: tsc PASS, 병렬 게이트 1040 passed(데스크톱 밀도 충돌 1건 해소 후 focused 68/68 GREEN),
  직렬 게이트 52/5/4 삼각화 — breakpoint(테스트 결함)·kb(낡은 dev API 재기동)·voice(일시적) 해소,
  교사 재평가 2건은 엔진 구독 한도(resets 3pm)로 skip 후 재검증 대기
- 문서/SSOT: HANDOFF·TODO·대시보드·testing 가이드 동기화, 증거 usecase-tdd-2026-08-18.json,
  SSOT 체커 PASS(59)
2026-08-18 11:47:58 +09:00

141 lines
6.2 KiB
TypeScript

import { expect, test, type APIResponse, type Page } from "@playwright/test";
import {
completeOnboarding,
expectNoHorizontalOverflow,
withGlobalEngineConfigLock,
} from "./support";
async function expectResponseOk(response: APIResponse) {
if (!response.ok()) {
expect(response.ok(), await response.text()).toBeTruthy();
}
}
async function signIn(
page: Page,
role: "learner" | "admin",
email: string,
displayName: string,
) {
const res = await page.request.post("/api/auth/dev-login", {
data: { email, role, display_name: displayName },
});
await expectResponseOk(res);
await completeOnboarding(page, {
legal_name: displayName,
affiliation: "한신대학교",
department: role === "admin" ? "운영팀" : "상담심리학과",
grade_level: role === "admin" ? "관리자" : "3학년",
phone: "010-7777-7777",
contact_address: "경기도 오산시 한신대학교",
nickname: displayName,
self_introduction: `${displayName} readiness E2E 사용자입니다.`,
});
}
test.describe("production readiness gates", () => {
test("learner-facing data comes from server persistence, not browser or seed mocks", async ({
page,
}, testInfo) => {
const email = `readiness.${testInfo.project.name}.${testInfo.workerIndex}.${Date.now()}@hs.ac.kr`;
await signIn(page, "learner", email.toLowerCase(), "Readiness Learner");
const personaResponse = await page.request.get("/api/personas");
await expectResponseOk(personaResponse);
expect(personaResponse.headers()["x-vignette-catalog-source"]).toBe("database");
const personas = (await personaResponse.json()) as Array<{
source: string;
degraded: boolean;
}>;
expect(personas.length).toBeGreaterThan(0);
expect(personas.every((persona) => persona.source === "database" && !persona.degraded)).toBe(
true,
);
const sessionsResponse = await page.request.get("/api/sessions");
await expectResponseOk(sessionsResponse);
const sessions = (await sessionsResponse.json()) as { source: string; sessions: unknown[] };
expect(sessions).toMatchObject({ source: "database", sessions: [] });
await page.goto("/learn");
await expect(page.getByText("지금까지 12회 연습했어요")).toHaveCount(0);
await expect(page.getByText("최근 8회")).toHaveCount(0);
await expect(page.getByRole("heading", { name: "오늘 이어갈 회기를 먼저 봅니다." })).toBeVisible();
const statusRegion = page.getByRole("region", { name: "학습 현황" });
await expect(statusRegion.getByRole("button", { name: /진행 회기 0회/ })).toBeVisible();
await expect(statusRegion.getByRole("button", { name: /리뷰 대기 0건/ })).toBeVisible();
// 중간 폭 이하에서는 기록 패널이 '기록 · 리뷰' 탭 안에 있다.
const dashTabs = page.locator(".lh-tabs");
if (await dashTabs.isVisible().catch(() => false)) {
await dashTabs.locator("button", { hasText: "기록 · 리뷰" }).click();
}
await expect(page.getByText("최근 기록이 없습니다.")).toBeVisible();
if (await dashTabs.isVisible().catch(() => false)) {
await dashTabs.locator("button", { hasText: "오늘의 회기" }).click();
}
await expect(page.getByRole("button", { name: "학습 대상 선택" })).toBeVisible();
await expectNoHorizontalOverflow(page);
});
test("admin-owned runtime controls are durable database state", async ({ page }) => {
test.setTimeout(60_000); // 엔진 설정 전역 락 대기 여유 포함
await signIn(page, "admin", "readiness-admin@twentyoz.kr", "Readiness Admin");
const usersResponse = await page.request.get("/api/admin/users");
await expectResponseOk(usersResponse);
const users = (await usersResponse.json()) as { source: string; durable: boolean };
expect(users).toMatchObject({ source: "database", durable: true });
// 엔진 설정의 durable 계약은 "관리자가 저장한 설정이 DB 행으로 남는다"이다.
// 시드 없는 격리 DB에서는 저장 전 GET이 runtime_default(durable=false)를
// 돌려주는 것이 정상 계약이므로, 실제 게이트웨이 검증을 거친 저장 round-trip
// 후의 durable/database 상태를 확인한다. (support.ts withGlobalEngineConfigLock 계약)
await withGlobalEngineConfigLock("readiness-admin-engine", async () => {
type EngineConfig = {
durable: boolean;
source: string;
engine_mode: string;
engine_url: string;
model: string;
reasoning_effort: string | null;
};
const currentResponse = await page.request.get("/api/admin/engine-config");
await expectResponseOk(currentResponse);
const current = (await currentResponse.json()) as EngineConfig;
const capabilityResponse = await page.request.get(
`/api/admin/engine-capabilities?engine_mode=${encodeURIComponent(current.engine_mode)}`,
);
await expectResponseOk(capabilityResponse);
const capability = (await capabilityResponse.json()) as {
models: Array<{ id: string; default_reasoning_effort?: string | null }>;
};
const selected =
capability.models.find((model) => model.id === current.model) ?? capability.models[0];
if (!selected) throw new Error("engine capability returned no selectable model");
const patchResponse = await page.request.patch("/api/admin/engine-config", {
data: {
engine_mode: current.engine_mode,
engine_url: current.engine_url,
model: selected.id,
reasoning_effort:
selected.id === current.model
? current.reasoning_effort
: (selected.default_reasoning_effort ?? null),
},
});
await expectResponseOk(patchResponse);
const engineResponse = await page.request.get("/api/admin/engine-config");
await expectResponseOk(engineResponse);
const engine = (await engineResponse.json()) as EngineConfig;
expect(engine).toMatchObject({ durable: true, source: "database" });
expect(["claude_cli", "claude_api", "codex_cli", "agy_cli", "openai", "solar"]).toContain(
engine.engine_mode,
);
expect(engine.engine_url).toMatch(/^https?:\/\//);
expect(engine.model.trim().length).toBeGreaterThan(0);
});
});
});