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; confidence: number | null; decision: string }; type ExpressionChoiceTraceItem = { choice: string; probabilities: Record; 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; 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, 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; 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" }); }); });