312 lines
14 KiB
TypeScript
312 lines
14 KiB
TypeScript
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" });
|
|
});
|
|
});
|