상담자 발화 판정(A)·감정(B)·표현(C) 20문항 질문 세트, 감쇠 없는 이번 턴 반응과 비대칭 기분 전이, 개방도 게이트로 생성 지시를 만들고 ccd.coping_strategy 전달 누락을 고친다. trace v2와 고정 문구 속마음 요약(migration 24, AI 경로 차단 RLS)을 같은 트랜잭션에 저장하고 피드백 정책이 켜진 경우에만 done·TurnResponse·음성 reply·리뷰로 노출한다. 회기 화면 속마음 보기 토글, 리뷰 접힘 블록, 관리자 감정 관측 v2 표시를 추가한다.
560 lines
26 KiB
TypeScript
560 lines
26 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 AppraisalTraceItem =
|
|
| { key: string; kind: "noul"; probability: number; confidence: number | null; decision: string }
|
|
| { key: string; kind: "choice"; choice: string; probabilities: Record<string, number>; confidence: number | null; decision: string };
|
|
|
|
type ExpressionChoiceTraceItem = { choice: string; probabilities: Record<string, number>; confidence: number | null; decision: string };
|
|
|
|
type AffectTraceV2 = {
|
|
schema_version: 2;
|
|
provider: string;
|
|
model: string;
|
|
latency_ms: number;
|
|
input_tokens: number;
|
|
output_tokens: number;
|
|
cost_usd: number | null;
|
|
turn_seq: number;
|
|
policy: {
|
|
version: "jev-affect-v2";
|
|
min_confidence: number;
|
|
tentative_confidence_floor: number;
|
|
adjacent_probability_threshold: number;
|
|
worsening_accepted_alpha: number;
|
|
worsening_accepted_cap: number;
|
|
worsening_tentative_alpha: number;
|
|
worsening_tentative_cap: number;
|
|
recovery_accepted_alpha: number;
|
|
recovery_accepted_cap: number;
|
|
recovery_tentative_alpha: number;
|
|
recovery_tentative_cap: number;
|
|
};
|
|
context: { stage: string; resistance: number; effective_openness: number; rapport_credit: number };
|
|
dimensions: AffectTrace["dimensions"];
|
|
appraisal: AppraisalTraceItem[];
|
|
reaction: Array<{ key: Dimension; value: number | null; included: boolean }>;
|
|
expression: {
|
|
behavior: ExpressionChoiceTraceItem;
|
|
gated_behavior: string;
|
|
gate_reason: "openness_closed" | "openness_guarded" | null;
|
|
stance: "engage" | "cautious" | "pull_back" | "push_back" | null;
|
|
display: ExpressionChoiceTraceItem;
|
|
disclose_ready: { probability: number; confidence: number | null; decision: string };
|
|
hidden_gap: boolean;
|
|
};
|
|
sore_spot_count: number;
|
|
};
|
|
|
|
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 | AffectTraceV2 }>;
|
|
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 traceV2(seq: number, round: number): AffectDetail["traces"][number] {
|
|
const dimensions = DIMENSIONS.map((key) => ({
|
|
key,
|
|
before: 0.4,
|
|
target: key === "trust" ? null : 0.55,
|
|
after: 0.5,
|
|
confidence: key === "trust" ? null : 0.7,
|
|
probabilities: key === "trust" ? null : [0.02, 0.08, 0.6, 0.25, 0.05],
|
|
decision: key === "trust" ? "held" : "accepted",
|
|
}));
|
|
const reaction = DIMENSIONS.map((key) => ({
|
|
key,
|
|
value: key === "trust" ? null : 0.42,
|
|
included: key !== "trust",
|
|
}));
|
|
const trace: AffectTraceV2 = {
|
|
schema_version: 2,
|
|
provider: "typesafe",
|
|
model: "jev-1.13-test",
|
|
latency_ms: 231,
|
|
input_tokens: 512,
|
|
output_tokens: 96,
|
|
cost_usd: 0.00021,
|
|
turn_seq: round,
|
|
policy: {
|
|
version: "jev-affect-v2",
|
|
min_confidence: 0.65,
|
|
tentative_confidence_floor: 0.35,
|
|
adjacent_probability_threshold: 0.8,
|
|
worsening_accepted_alpha: 0.35,
|
|
worsening_accepted_cap: 0.15,
|
|
worsening_tentative_alpha: 0.15,
|
|
worsening_tentative_cap: 0.075,
|
|
recovery_accepted_alpha: 0.2,
|
|
recovery_accepted_cap: 0.08,
|
|
recovery_tentative_alpha: 0.08,
|
|
recovery_tentative_cap: 0.04,
|
|
},
|
|
context: { stage: "탐색", resistance: 0.3, effective_openness: 0.55, rapport_credit: 0.3 },
|
|
dimensions,
|
|
appraisal: [
|
|
{ key: "a_understood", kind: "noul", probability: 0.72, confidence: null, decision: "true" },
|
|
{ key: "a_judged", kind: "noul", probability: 0.3, confidence: null, decision: "false" },
|
|
{ key: "a_autonomy", kind: "noul", probability: 0.5, confidence: null, decision: "uncertain" },
|
|
{
|
|
key: "a_coping",
|
|
kind: "choice",
|
|
choice: "stretch",
|
|
probabilities: { nothing_asked: 0.05, manageable: 0.15, stretch: 0.6, overwhelming: 0.2 },
|
|
confidence: 0.58,
|
|
decision: "stretch",
|
|
},
|
|
{
|
|
key: "a_sore_spot",
|
|
kind: "choice",
|
|
choice: "spot_2",
|
|
probabilities: { none: 0.1, spot_1: 0.2, spot_2: 0.65, spot_3: 0.05 },
|
|
confidence: 0.61,
|
|
decision: "spot_2",
|
|
},
|
|
{
|
|
key: "a_move",
|
|
kind: "choice",
|
|
choice: "mystery_move",
|
|
probabilities: { mystery_move: 0.9, other: 0.1 },
|
|
confidence: 0.66,
|
|
decision: "mystery_move",
|
|
},
|
|
],
|
|
reaction,
|
|
expression: {
|
|
behavior: {
|
|
choice: "hold_core",
|
|
probabilities: { hold_core: 0.55, disclose_more: 0.2, ask_back: 0.25 },
|
|
confidence: 0.5,
|
|
decision: "hold_core",
|
|
},
|
|
gated_behavior: "minimal_response",
|
|
gate_reason: "openness_closed",
|
|
stance: "cautious",
|
|
display: {
|
|
choice: "covered_by_agreement",
|
|
probabilities: { covered_by_agreement: 0.6, as_felt: 0.4 },
|
|
confidence: 0.52,
|
|
decision: "covered_by_agreement",
|
|
},
|
|
disclose_ready: { probability: 0.22, confidence: null, decision: "false" },
|
|
hidden_gap: true,
|
|
},
|
|
sore_spot_count: 3,
|
|
};
|
|
return {
|
|
turn_id: `turn-v2-${seq}`,
|
|
seq,
|
|
created_at: `2026-09-29T0${Math.min(round, 9)}:00:00Z`,
|
|
trace,
|
|
};
|
|
}
|
|
|
|
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;
|
|
sessions?: Array<{ session_id: string; persona_code: string; started_at: string; ended: boolean | null; trace_count: number }>;
|
|
} = {}) {
|
|
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", []),
|
|
};
|
|
const sessions = options.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 },
|
|
];
|
|
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, total: sessions.length, 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("v2 trace의 판정·표현 계획·반응을 한글 라벨로 표시하고 모르는 코드는 알 수 없는 값으로 표시한다", async ({ page }) => {
|
|
await mockAffectAdmin(page, {
|
|
sessions: [
|
|
{ session_id: "session-v2", persona_code: "P9", started_at: "2026-09-29T02:00:00Z", ended: true, trace_count: 1 },
|
|
],
|
|
detailBySession: {
|
|
"session-v2": detail("session-v2", "P9", [traceV2(3, 2)]),
|
|
},
|
|
});
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
await page.goto("/admin/emotions?session=session-v2");
|
|
|
|
await expect(page.getByRole("heading", { name: "상담자 발화 판정" })).toBeVisible();
|
|
await expect(page.getByText("판정 확률은 정답 확률이 아닙니다.")).toBeVisible();
|
|
|
|
const appraisalTable = page.locator(".aac-appraisal-table");
|
|
const understoodRow = appraisalTable.getByRole("row", { name: /이해받았다고 느낌/ });
|
|
await expect(understoodRow.locator("td").nth(1)).toHaveText("예");
|
|
await expect(understoodRow.locator("td").nth(2)).toContainText("예 확률 72%");
|
|
// a_understood·a_judged·a_autonomy는 noul이며 confidence가 항상 null이다.
|
|
await expect(understoodRow.locator("td").nth(3)).toHaveText("기록 없음");
|
|
await expect(appraisalTable.getByRole("row", { name: /평가·비난받는 느낌/ }).locator("td").nth(1)).toHaveText("아니오");
|
|
await expect(appraisalTable.getByRole("row", { name: /선택을 강요받는 느낌/ }).locator("td").nth(1)).toHaveText("판단 보류");
|
|
const copingRow = appraisalTable.getByRole("row", { name: /제안의 감당 가능성/ });
|
|
await expect(copingRow.locator("td").nth(1)).toHaveText("부담스러움");
|
|
await expect(copingRow.locator("td").nth(2)).toContainText("60%");
|
|
const soreSpotRow = appraisalTable.getByRole("row", { name: /민감 지점 자극/ });
|
|
await expect(soreSpotRow.locator("td").nth(1)).toHaveText("민감 지점 2");
|
|
await expect(soreSpotRow.locator("td").nth(2)).toContainText("해당 없음");
|
|
await expect(soreSpotRow.locator("td").nth(2)).toContainText("민감 지점 1");
|
|
await expect(soreSpotRow.locator("td").nth(2)).toContainText("민감 지점 3");
|
|
const moveRow = appraisalTable.getByRole("row", { name: /상담자 발화 유형/ });
|
|
await expect(moveRow.locator("td").nth(1)).toHaveText("알 수 없는 값");
|
|
await expect(moveRow.locator("td").nth(2)).toContainText("알 수 없는 값");
|
|
await expect(moveRow.locator("td").nth(2)).toContainText("기타");
|
|
|
|
await expect(page.getByText("행동(원 판정)")).toBeVisible();
|
|
const expressionSection = page.locator(".aac-kv").first();
|
|
await expect(expressionSection).toContainText("핵심은 보류");
|
|
await expect(expressionSection).toContainText("최소 반응");
|
|
await expect(expressionSection).toContainText("매우 닫힘으로 조정");
|
|
await expect(expressionSection).toContainText("조심스러운 거리");
|
|
await expect(expressionSection).toContainText("수긍으로 덮음");
|
|
await expect(expressionSection).toContainText("아니오");
|
|
await expect(expressionSection).toContainText("예");
|
|
|
|
await expect(page.getByRole("heading", { name: "이번 턴 반응(감쇠 전)" })).toBeVisible();
|
|
const reactionTable = page.locator(".aac-v2 table").last();
|
|
await expect(reactionTable.getByRole("row", { name: /불안/ })).toContainText("포함");
|
|
await expect(reactionTable.getByRole("row", { name: /신뢰/ })).toContainText("확신도 부족으로 제외");
|
|
|
|
await page.getByText("제공자 · 모델 · 정책 · 당시 상태 · 사용량 모두 보기").click();
|
|
await expect(page.getByText("악화 확정 비율 · 변화량 상한")).toBeVisible();
|
|
await expect(page.getByText("회복 잠정 비율 · 변화량 상한")).toBeVisible();
|
|
await expect(page.getByText("반영 비율 · 변화량 상한", { exact: true })).toHaveCount(0);
|
|
|
|
await page.locator(".aac-v2").scrollIntoViewIfNeeded();
|
|
await page.screenshot({ path: "node_modules/.tmp/admin-affect-v2-detail-1440.png", fullPage: true });
|
|
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await expect.poll(() => page.evaluate(() => ({
|
|
clientWidth: document.documentElement.clientWidth,
|
|
scrollWidth: document.documentElement.scrollWidth,
|
|
}))).toEqual({ clientWidth: 390, scrollWidth: 390 });
|
|
await page.locator(".aac-v2").scrollIntoViewIfNeeded();
|
|
await page.screenshot({ path: "node_modules/.tmp/admin-affect-v2-detail-390.png", fullPage: true });
|
|
|
|
await page.setViewportSize({ width: 320, height: 740 });
|
|
await expect.poll(() => page.evaluate(() => ({
|
|
clientWidth: document.documentElement.clientWidth,
|
|
scrollWidth: document.documentElement.scrollWidth,
|
|
}))).toEqual({ clientWidth: 320, scrollWidth: 320 });
|
|
});
|
|
|
|
test("v1·v2가 섞인 회기에서 v1 턴은 기존 표시를 유지하고 v2 턴만 새 영역을 보여준다", async ({ page }) => {
|
|
await mockAffectAdmin(page, {
|
|
sessions: [
|
|
{ session_id: "session-mixed", persona_code: "P7", started_at: "2026-09-29T02:00:00Z", ended: true, trace_count: 2 },
|
|
],
|
|
detailBySession: {
|
|
"session-mixed": detail("session-mixed", "P7", [trace(2, 1, 0.01, -0.01), traceV2(6, 3)]),
|
|
},
|
|
});
|
|
await page.goto("/admin/emotions?session=session-mixed&turn=2");
|
|
|
|
await expect(page.getByRole("heading", { name: "턴 상세" })).toBeVisible();
|
|
await expect(page.getByRole("heading", { name: "상담자 발화 판정" })).toHaveCount(0);
|
|
await page.getByText("제공자 · 모델 · 정책 · 당시 상태 · 사용량 모두 보기").click();
|
|
await expect(page.getByText("반영 비율 · 변화량 상한", { exact: true })).toBeVisible();
|
|
await expect(page.getByText("악화 확정 비율 · 변화량 상한")).toHaveCount(0);
|
|
|
|
const firstChart = page.locator(".aac-chart").first();
|
|
await expect(firstChart.locator("path")).toHaveAttribute("d", /M/);
|
|
await expect(firstChart.locator("circle")).toHaveCount(2);
|
|
|
|
await page.getByLabel("상세 턴 선택").selectOption("6");
|
|
await expect(page).toHaveURL(/turn=6/);
|
|
await expect(page.getByRole("heading", { name: "상담자 발화 판정" })).toBeVisible();
|
|
await expect(page.getByText("악화 확정 비율 · 변화량 상한")).toBeVisible();
|
|
await expect(page.getByText("반영 비율 · 변화량 상한", { exact: true })).toHaveCount(0);
|
|
});
|
|
|
|
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" });
|
|
});
|
|
});
|