import { expect, test } from "@playwright/test"; const P1_PERSONA = { code: "P1", display_name: "서연(가명) · 고2 · 우울/자살사고", difficulty: "hard", theory_target: ["humanistic", "cbt"], demographics: { age_band: "16-18", sex: "female", grade: "고2", status: "재학" }, presenting_summary: "우울감과 자살사고 위험", voice_preset: null, source: "database", degraded: false, expectedExpression: "sad", expectedModel: "vignette-p1-live2d", } as const; const PERSONAS = [ { code: "P4", display_name: "하늘(가명) · 고2 · 학업/시험 불안", difficulty: "easy", theory_target: ["cbt", "humanistic"], demographics: { age_band: "16-18", sex: "female", grade: "고2", status: "재학" }, presenting_summary: "시험 불안과 완벽주의 부담", voice_preset: null, source: "database", degraded: false, expectedExpression: "anxious", expectedModel: "vignette-p4-live2d", }, { code: "P5", display_name: "도윤(가명) · 중3 · 또래관계 갈등/소외감", difficulty: "moderate", theory_target: ["humanistic", "cbt"], demographics: { age_band: "14-16", sex: "male", grade: "중3", status: "재학" }, presenting_summary: "또래관계 갈등과 소외감", voice_preset: null, source: "database", degraded: false, expectedExpression: "guarded", expectedModel: "vignette-p5-live2d", }, { code: "P6", display_name: "하린(가명) · 고3 · 진로갈등", difficulty: "moderate", theory_target: ["humanistic", "cbt"], demographics: { age_band: "16-18", sex: "female", grade: "고3", status: "진로갈등" }, presenting_summary: "부모 기대와 본인 욕구 사이의 진로갈등", voice_preset: null, source: "database", degraded: false, expectedExpression: "conflicted", expectedModel: "vignette-p6-live2d", }, { code: "P7", display_name: "도현(가명) · 고3 · 입시 번아웃/무기력", difficulty: "hard", theory_target: ["humanistic", "cbt"], demographics: { age_band: "16-18", sex: "male", grade: "고3", status: "정시 준비" }, presenting_summary: "입시 번아웃과 무기력", voice_preset: null, source: "database", degraded: false, expectedExpression: "tired", expectedModel: "vignette-p7-live2d", }, ] as const; async function setLearnerUtterance(page: import("@playwright/test").Page, text: string) { const textbox = page.getByLabel("학습자 발화 입력"); await expect(textbox).toBeVisible(); await expect(textbox).toBeEnabled(); await textbox.fill(text); await expect(textbox).toHaveValue(text); await expect(page.getByRole("button", { name: "보내기" })).toBeEnabled(); } async function mockSessionDetail( page: import("@playwright/test").Page, sessionId: string, personaCode = "P4", ) { await page.route(`**/api/sessions/${sessionId}`, async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ session_id: sessionId, case_id: "case-avatar-expression", persona_code: personaCode, session_no: 1, status: "active", stage: "라포", effective_openness: 0.2, started_at: new Date().toISOString(), ended_at: null, review_ready: false, turns: [], }), }); }); } test.describe("persona avatar expression rig", () => { test.beforeEach(async ({ page }) => { await page.route(/\/(?:api\/)?auth\/me(?:\?.*)?$/, (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ user_id: "avatar-expression-learner", email: "learner@hs.ac.kr", display_name: "Avatar Expression Learner", role: "learner", cohort_ids: [], consent_at: Math.floor(Date.now() / 1000), onboarding_completed_at: Math.floor(Date.now() / 1000), }), }), ); await page.route(/\/(?:api\/)?personas(?:\?.*)?$/, (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify( [P1_PERSONA, ...PERSONAS].map( ({ expectedExpression: _expectedExpression, expectedModel: _expectedModel, ...persona }) => persona, ), ), }), ); await page.route(/\/(?:api\/)?voice\/health(?:\?.*)?$/, (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ available: false, reason: "test fixture" }), }), ); await page.route(/\/(?:api\/)?sessions\/[^/]+\/live-coach(?:\?.*)?$/, (route) => { if (route.request().method() === "GET") { return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ session_id: "avatar-expression-session", events: [], quota: { used: 0, limit: 3, remaining: 3 }, source: "database", }), }); } return route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ detail: "live coach is outside this avatar fixture" }), }); }); }); test("renders every persona with a distinct baseline expression and at least 20 expressions", async ({ page, }) => { for (const persona of PERSONAS) { await page.goto(`/learn/session/${persona.code}`); const avatar = page.locator(`.vg-avatar[data-persona-code="${persona.code}"]`).first(); await expect(avatar).toBeVisible(); await expect(avatar).toHaveAttribute("data-affect", persona.expectedExpression); const metrics = await avatar.evaluate((el) => ({ expressionCount: Number(el.getAttribute("data-expression-count")), live2dSchema: el.getAttribute("data-live2d-schema"), live2dModel: el.getAttribute("data-live2d-model"), live2dModelUrl: el.getAttribute("data-live2d-model-url"), live2dMotion: el.getAttribute("data-live2d-motion"), live2dMotionFile: el.getAttribute("data-live2d-motion-file"), live2dExpressionCount: Number(el.getAttribute("data-live2d-expression-count")), renderMode: el.getAttribute("data-render-mode"), rasterCount: el.querySelectorAll(".vg-raster").length, primitiveCount: el.querySelectorAll("svg path, svg ellipse, svg circle, svg line, svg rect") .length, neckBox: el.querySelector('[data-avatar-neck="true"]')?.getBoundingClientRect().toJSON(), svgBox: el.querySelector("svg")?.getBoundingClientRect().toJSON(), })); expect(metrics.expressionCount, `${persona.code} expression count`).toBeGreaterThanOrEqual(20); expect(metrics.live2dSchema, `${persona.code} Live2D schema`).toBe("vignette.live2d.v1"); expect(metrics.live2dModel, `${persona.code} Live2D model`).toBe(persona.expectedModel); expect(metrics.live2dModelUrl, `${persona.code} Live2D model URL`).toBe( `/live2d/personas/${persona.code.toLowerCase()}/${persona.code.toLowerCase()}.model3.json`, ); expect(metrics.live2dMotion, `${persona.code} Live2D motion`).toBe(persona.expectedExpression); expect(metrics.live2dMotionFile, `${persona.code} Live2D motion file`).toBe( `expressions/${persona.expectedExpression}.exp3.json`, ); expect(metrics.live2dExpressionCount, `${persona.code} Live2D expressions`).toBeGreaterThanOrEqual(20); expect(metrics.renderMode, `${persona.code} render mode`).toBe("svg"); expect(metrics.rasterCount, `${persona.code} raster rig should be disabled`).toBe(0); expect(metrics.primitiveCount, `${persona.code} SVG primitives`).toBeGreaterThan(10); expect(metrics.neckBox?.width, `${persona.code} SVG neck`).toBeGreaterThan(0); expect(metrics.svgBox?.width, `${persona.code} SVG rig`).toBeGreaterThan(0); expect(metrics.svgBox?.height, `${persona.code} SVG rig`).toBeGreaterThan(0); } }); test("keeps the active session avatar expression wired after session start", async ({ page }) => { const sessionId = "11111111-1111-4111-8111-111111111111"; await mockSessionDetail(page, sessionId); await page.route("**/api/sessions", async (route) => { if (route.request().method() !== "POST") { await route.fallback(); return; } await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ session_id: "11111111-1111-4111-8111-111111111111", case_id: "case-avatar-expression", session_no: 1, stage: "라포", effective_openness: 0.2, recall_summary: null, degraded: false, }), }); }); await page.goto("/learn/session/P4"); await page.getByRole("button", { name: "회기 시작" }).click(); await expect(page.locator(".sx-page--active")).toBeVisible(); const activeAvatar = page.locator(".sx-page--active .vg-avatar").first(); await expect(activeAvatar).toBeVisible(); await expect(activeAvatar).toHaveAttribute("data-render-mode", "svg"); await expect(activeAvatar.locator(".vg-raster")).toHaveCount(0); await expect(activeAvatar.locator(".vg-avatar__svg")).toBeVisible(); await expect(activeAvatar).toHaveAttribute("data-expression-count", "28"); await expect(activeAvatar).toHaveAttribute("data-live2d-expression-count", "28"); await expect(activeAvatar).toHaveAttribute("data-live2d-model", "vignette-p4-live2d"); await expect(activeAvatar).toHaveAttribute("data-live2d-model-url", "/live2d/personas/p4/p4.model3.json"); await expect(activeAvatar).toHaveAttribute("data-live2d-fade-in-ms", "260"); await expect(activeAvatar).toHaveAttribute("data-live2d-motion-file", "expressions/anxious.exp3.json"); await expect(activeAvatar).toHaveAttribute("data-affect", "anxious"); await expect(page.locator(".sx-stage__now")).toContainText("불안"); }); test("animates expression transitions after session openness changes", async ({ page }) => { const sessionId = "22222222-2222-4222-8222-222222222222"; await mockSessionDetail(page, sessionId); await page.route("**/api/sessions", async (route) => { if (route.request().method() !== "POST") { await route.fallback(); return; } await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ session_id: sessionId, case_id: "case-avatar-transition", session_no: 1, stage: "라포", effective_openness: 0.2, recall_summary: null, degraded: false, }), }); }); await page.route("**/api/sessions/*/stream", async (route) => { await route.fulfill({ status: 200, contentType: "text/event-stream", body: [ "event: token", "data: 조금은 괜찮아진 것 같아요.", "", "event: done", 'data: {"session_id":"22222222-2222-4222-8222-222222222222","stage":"정리","effective_openness":0.86,"safety_flagged":false}', "", ].join("\n"), }); }); await page.goto("/learn/session/P4"); await page.getByRole("button", { name: "회기 시작" }).click(); await expect(page.locator(".sx-page--active")).toBeVisible(); const activeAvatar = page.locator(".sx-page--active .vg-avatar").first(); await expect(activeAvatar).toHaveAttribute("data-render-mode", "svg"); await expect(activeAvatar.locator(".vg-raster")).toHaveCount(0); await expect(activeAvatar.locator(".vg-avatar__svg")).toBeVisible(); await expect(activeAvatar).toHaveAttribute("data-live2d-motion", "anxious"); await setLearnerUtterance(page, "조금 안정된 것 같아요."); await page.getByRole("button", { name: "보내기" }).click(); await expect(activeAvatar).toHaveAttribute("data-live2d-motion", "warm"); await expect(activeAvatar).toHaveAttribute("data-live2d-motion-file", "expressions/warm.exp3.json"); await expect(activeAvatar).toHaveAttribute("data-live2d-fade-in-ms", "260"); await expect .poll( async () => Number((await activeAvatar.getAttribute("data-live2d-transition-progress")) ?? "0"), { timeout: 15_000 }, ) .toBeGreaterThanOrEqual(0.99); await expect(page.locator(".sx-stage__now")).toContainText("온화함"); }); test("uses the original SVG parameter rig for P1 Seoyeon", async ({ page }) => { await page.goto("/learn/session/P1"); const avatar = page.locator('.vg-avatar[data-persona-code="P1"]').first(); await expect(avatar).toBeVisible(); await expect(avatar).toHaveAttribute("data-render-mode", "svg"); await expect(avatar).toHaveAttribute("data-affect", "sad"); await expect(avatar.locator(".vg-raster")).toHaveCount(0); await expect(avatar.locator(".vg-avatar__svg")).toBeVisible(); await expect(avatar.locator('[data-avatar-neck="true"]')).toBeVisible(); }); });