관리자 감정 추이와 관계 그래프 추가
Some checks failed
API contract / OpenAPI type drift (push) Failing after 43s

This commit is contained in:
Yun Chan 2026-09-23 04:53:42 +09:00
parent 6ab40ff3f4
commit 29d5b6c50b
10 changed files with 1339 additions and 0 deletions

View file

@ -0,0 +1,312 @@
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<Dimension, number | null>;
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<Dimension, number | null>,
traces,
total_traces: traces.length,
has_more: false,
};
}
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((done) => { resolve = done; });
return { promise, resolve };
}
async function mockAffectAdmin(page: Page, options: {
detailBySession?: Record<string, AffectDetail>;
olderDetailBySession?: Record<string, AffectDetail>;
failDetails?: boolean;
detailGate?: (sessionId: string) => Promise<void> | 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" });
});
});