import { expect, test, type Page } from "@playwright/test"; import { pearsonCorrelation, type ChangeObservation } from "../src/pages/admin-affect/affectMath"; const DIMENSIONS = [ "anxiety", "sadness", "anger", "shame", "guilt", "loneliness", "relief", "hope", "trust", ] as const; type Dimension = (typeof DIMENSIONS)[number]; type AffectTrace = { schema_version: 1; provider: string; model: string; latency_ms: number; input_tokens: number; output_tokens: number; cost_usd: number | null; turn_seq: number; policy: { version: "jev-affect-v1"; min_confidence: number; accepted_alpha: number; accepted_cap: number; tentative_alpha: number; tentative_cap: number; tentative_confidence_floor: number; adjacent_probability_threshold: number; }; context: { stage: string; resistance: number; effective_openness: number; rapport_credit: number }; dimensions: Array<{ key: Dimension; before: number; target: number | null; after: number; confidence: number | null; probabilities: number[] | null; decision: "accepted" | "tentative" | "held"; }>; }; type AffectDetail = { session_id: string; persona_code: string; current_emotions: Record; traces: Array<{ turn_id: string; seq: number; created_at: string; trace: AffectTrace }>; total_traces: number; has_more: boolean; }; function trace(seq: number, round: number, anxietyChange: number, sadnessChange: number): AffectDetail["traces"][number] { return { turn_id: `turn-${seq}`, seq, created_at: `2026-09-23T0${Math.min(round, 9)}:00:00Z`, trace: { schema_version: 1, provider: "openai", model: "jev-test-model", latency_ms: 124, input_tokens: 302, output_tokens: 71, cost_usd: 0.0012, turn_seq: round, policy: { version: "jev-affect-v1", min_confidence: 0.35, accepted_alpha: 0.35, accepted_cap: 0.15, tentative_alpha: 0.15, tentative_cap: 0.075, tentative_confidence_floor: 0.35, adjacent_probability_threshold: 0.8, }, context: { stage: "working", resistance: 0.2, effective_openness: 0.7, rapport_credit: 0.4 }, dimensions: DIMENSIONS.map((key) => { const change = key === "anxiety" ? anxietyChange : key === "sadness" ? sadnessChange : 0; return { key, before: 0.5, target: key === "trust" ? null : 0.5 + change, after: 0.5 + change, confidence: key === "trust" ? null : 0.72, probabilities: key === "trust" ? null : [0.02, 0.08, 0.6, 0.25, 0.05], decision: key === "trust" ? "held" : key === "sadness" ? "tentative" : "accepted", }; }), }, }; } function detail(sessionId: string, personaCode: string, traces: AffectDetail["traces"][]): AffectDetail { return { session_id: sessionId, persona_code: personaCode, current_emotions: Object.fromEntries(DIMENSIONS.map((key) => [key, key === "trust" ? null : 0.5])) as Record, traces, total_traces: traces.length, has_more: false, }; } function deferred() { let resolve!: () => void; const promise = new Promise((done) => { resolve = done; }); return { promise, resolve }; } async function mockAffectAdmin(page: Page, options: { detailBySession?: Record; olderDetailBySession?: Record; failDetails?: boolean; detailGate?: (sessionId: string) => Promise | undefined; } = {}) { const firstTraces = [ trace(2, 1, 0.01, -0.01), trace(4, 2, 0.02, -0.02), trace(6, 3, 0.03, -0.03), trace(8, 4, 0.04, -0.04), trace(12, 6, 0.05, -0.05), trace(14, 7, 0.06, -0.06), ]; const details = options.detailBySession ?? { "session-alpha": detail("session-alpha", "P1", firstTraces), "session-beta": detail("session-beta", "P2", []), }; await page.route("**/api/**", async (route) => { const request = route.request(); const url = new URL(request.url()); const fulfill = (body: unknown, status = 200) => route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); if (request.method() === "GET" && url.pathname.endsWith("/auth/me")) { await fulfill({ user_id: "affect-admin", email: "admin@example.invalid", display_name: "관리자", role: "admin", admin_access: true, super_admin: true, account_status: "approved", approval_required: false, cohort_ids: [], consent_at: null, onboarding_completed_at: 1, nickname: "", self_introduction: "", avatar_url: "" }); return; } if (request.method() === "GET" && url.pathname.endsWith("/admin/affect/sessions")) { await fulfill({ runtime: { enabled: true, configured: true, provider: "openai", model: "runtime-model" }, sessions: [ { session_id: "session-alpha", persona_code: "P1", started_at: "2026-09-23T01:00:00Z", ended: true, trace_count: firstTraces.length }, { session_id: "session-beta", persona_code: "P2", started_at: "2026-09-22T01:00:00Z", ended: false, trace_count: 0 }, ], total: 2, limit: 30, offset: 0 }); return; } if (request.method() === "GET" && url.pathname.includes("/admin/affect/sessions/")) { const sessionId = url.pathname.split("/").at(-1) ?? ""; const gate = options.detailGate?.(sessionId); if (gate) await gate; if (options.failDetails) { await fulfill({ detail: "감정 기록 저장소를 읽을 수 없습니다." }, 503); return; } const response = url.searchParams.has("before_seq") ? options.olderDetailBySession?.[sessionId] : details[sessionId]; await fulfill(response ?? { detail: "회기가 없습니다." }, response ? 200 : 404); return; } await fulfill({ detail: `unmocked API: ${request.method()} ${url.pathname}` }, 404); }); } test.describe("관리자 감정 관측", () => { test("9축 추이와 누락된 왕복 간격, 판단 상세와 관계를 표시한다", async ({ page }) => { await mockAffectAdmin(page); await page.setViewportSize({ width: 1440, height: 900 }); await page.goto("/admin/emotions"); await expect(page.getByRole("heading", { name: "가상 내담자 감정 관측" })).toBeVisible(); await expect(page.getByText("시뮬레이션 상태이며 실제 사람의 감정 측정이나 진단이 아닙니다.")).toBeVisible(); await expect(page.locator("[data-vignette-admin-root]")).toBeVisible(); await page.waitForTimeout(3600); await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0); await page.waitForTimeout(4500); await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0); await expect(page.getByLabel("관측 회기 선택")).toHaveValue("session-alpha"); await expect(page.getByText("왕복 1").first()).toBeVisible(); await expect(page.getByText("왕복 7").first()).toBeVisible(); await expect(page.locator(".aac-chart path").first()).toHaveAttribute("d", /M.*M/); await expect(page.getByRole("cell", { name: "r -1.00" }).first()).toBeVisible(); await expect(page.getByText("변화 없음").first()).toBeVisible(); await page.getByLabel("상세 턴 선택").selectOption("12"); await expect(page).toHaveURL(/turn=12/); await expect(page.getByText("잠정반영").first()).toBeVisible(); await expect(page.getByText("정답 확률이 아닙니다")).toBeVisible(); await page.getByText("제공자 · 모델 · 정책 · 당시 상태 · 사용량 모두 보기").click(); await expect(page.getByText("jev-test-model")).toBeVisible(); await page.screenshot({ path: "node_modules/.tmp/admin-affect-focused-1440.png", fullPage: true }); await page.keyboard.press("2"); await expect(page.getByRole("table").filter({ has: page.getByRole("columnheader", { name: "불안" }) }).first()).toBeVisible(); await page.keyboard.press("1"); await expect(page.locator(".aac-charts")).toBeVisible(); await page.locator(".aac-grid").scrollIntoViewIfNeeded(); await page.screenshot({ path: "node_modules/.tmp/admin-affect-1440-overview.png" }); await page.locator(".aac-charts").scrollIntoViewIfNeeded(); await page.screenshot({ path: "node_modules/.tmp/admin-affect-1440-trends.png" }); await page.locator(".aac-correlation__matrix").scrollIntoViewIfNeeded(); await page.screenshot({ path: "node_modules/.tmp/admin-affect-1440-correlation.png" }); await page.getByRole("button", { name: "다크 모드로" }).click(); await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); await page.locator(".aac-correlation__matrix").scrollIntoViewIfNeeded(); await page.screenshot({ path: "node_modules/.tmp/admin-affect-1440-dark-correlation.png" }); await page.emulateMedia({ media: "print", reducedMotion: "reduce" }); await expect.poll(() => page.evaluate(() => matchMedia("(prefers-reduced-motion: reduce)").matches)).toBe(true); await page.locator(".aac-charts").scrollIntoViewIfNeeded(); await page.screenshot({ path: "node_modules/.tmp/admin-affect-1440-print-trends.png" }); }); test("판단 기록이 없는 기존 회기를 역산하지 않고 명시한다", async ({ page }) => { await mockAffectAdmin(page); await page.goto("/admin/emotions?session=session-beta"); await expect(page.getByText("이 회기에는 새 판단 기록이 없습니다. 기존 감정 상태는 과거 턴별 판단으로 역산하지 않습니다.")).toBeVisible(); await expect(page.getByText("기록 없음").first()).toBeVisible(); }); test("저장소 오류를 alert로 보이고 다시 조회한다", async ({ page }) => { const state = { fail: true }; await mockAffectAdmin(page, { get failDetails() { return state.fail; } }); await page.goto("/admin/emotions"); await expect(page.getByRole("alert")).toContainText("감정 기록 저장소를 읽을 수 없습니다."); state.fail = false; await page.getByRole("button", { name: "새로고침" }).click(); await expect(page.getByRole("alert")).toHaveCount(0); await expect(page.getByText("턴별 추이")).toBeVisible(); }); test("늦은 이전 회기 응답이 새 회기 화면을 덮지 않는다", async ({ page }) => { const alphaGate = deferred(); await mockAffectAdmin(page, { detailGate: (sessionId) => sessionId === "session-alpha" ? alphaGate.promise : undefined }); await page.goto("/admin/emotions"); await expect(page.getByLabel("관측 회기 선택")).toHaveValue("session-alpha"); await page.getByRole("button", { name: "다음 회기" }).click(); await expect(page.getByLabel("관측 회기 선택")).toHaveValue("session-beta"); await expect(page.getByText("이 회기에는 새 판단 기록이 없습니다. 기존 감정 상태는 과거 턴별 판단으로 역산하지 않습니다.")).toBeVisible(); alphaGate.resolve(); await expect(page.getByLabel("관측 회기 선택")).toHaveValue("session-beta"); await expect(page.getByText("턴별 추이")).toHaveCount(1); }); test("이전 판단 기록을 불러와 현재 목록 앞에 합친다", async ({ page }) => { const newest = detail("session-alpha", "P1", [ trace(4, 2, 0.02, -0.02), trace(6, 3, 0.03, -0.03), ]); newest.total_traces = 3; newest.has_more = true; const older = detail("session-alpha", "P1", [trace(2, 1, 0.01, -0.01)]); older.total_traces = 3; await mockAffectAdmin(page, { detailBySession: { "session-alpha": newest, "session-beta": detail("session-beta", "P2", []) }, olderDetailBySession: { "session-alpha": older }, }); await page.goto("/admin/emotions"); await page.getByRole("button", { name: "이전 기록 더 불러오기" }).click(); await expect(page.getByLabel("상세 턴 선택")).toHaveText(/턴 2/); await expect(page.getByRole("button", { name: "이전 기록 더 불러오기" })).toHaveCount(0); }); test("390px에서 회기 선택 초점과 표 보기 키보드 조작을 유지한다", async ({ page }) => { await mockAffectAdmin(page); await page.setViewportSize({ width: 390, height: 844 }); await page.goto("/admin/emotions"); await expect(page.getByLabel("관측 회기 선택")).toBeVisible(); await page.getByLabel("관측 회기 선택").focus(); await expect.poll(() => page.evaluate(() => document.activeElement?.getAttribute("aria-label"))).toBe("관측 회기 선택"); await page.getByRole("button", { name: /표로 보기/ }).focus(); await page.keyboard.press("Enter"); await expect(page.getByRole("table").filter({ has: page.getByRole("columnheader", { name: "불안" }) }).first()).toBeVisible(); }); test("320px에서 문서 폭과 44px 조작 높이를 유지한다", async ({ page }) => { await mockAffectAdmin(page); await page.setViewportSize({ width: 320, height: 740 }); await page.goto("/admin/emotions"); await expect(page.getByLabel("관측 회기 선택")).toBeVisible(); await expect(page.getByRole("button", { name: /차트로 보기/ })).toHaveCSS("min-height", "44px"); await expect.poll(() => page.evaluate(() => ({ clientWidth: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth, }))).toEqual({ clientWidth: 320, scrollWidth: 320 }); await expect.poll(() => page.locator(".vg-main").evaluate((element) => element.scrollWidth === element.clientWidth)).toBe(true); await page.locator(".aac-charts").scrollIntoViewIfNeeded(); await page.screenshot({ path: "node_modules/.tmp/admin-affect-320-trends.png" }); await page.screenshot({ path: "node_modules/.tmp/admin-affect-focused-320.png", fullPage: true }); }); test("관계 계산은 양음 상관, 변화 없음, 표본 부족을 정직하게 표시한다", () => { const observations: ChangeObservation[] = [1, 2, 3, 4, 5].map((seq) => ({ seq, values: { anxiety: seq, sadness: -seq, anger: 1 }, })); expect(pearsonCorrelation(observations, "anxiety", "anxiety")).toMatchObject({ count: 5, value: 1, reason: null }); expect(pearsonCorrelation(observations, "anxiety", "sadness")).toMatchObject({ count: 5, value: -1, reason: null }); expect(pearsonCorrelation(observations, "anxiety", "anger")).toMatchObject({ count: 5, value: null, reason: "zero_variance" }); expect(pearsonCorrelation(observations.slice(0, 4), "anxiety", "sadness")).toMatchObject({ count: 4, value: null, reason: "insufficient" }); }); });