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, ), ), }), ); }); 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"), rasterArtSet: el.querySelector(".vg-raster")?.getAttribute("data-raster-art-set"), rasterMode: el.querySelector(".vg-raster")?.getAttribute("data-raster-mode"), rasterLayerCount: el.querySelectorAll(".vg-raster__layer").length, rasterBox: el.querySelector(".vg-raster")?.getBoundingClientRect().toJSON(), 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(), })); const expectedArtSet = `${persona.code.toLowerCase()}-live2d-generated`; 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("raster"); expect(metrics.rasterArtSet, `${persona.code} generated art set`).toBe(expectedArtSet); expect(metrics.rasterMode, `${persona.code} raster rig mode`).toBe("psb-detailed"); expect(metrics.rasterLayerCount, `${persona.code} raster layers`).toBeGreaterThanOrEqual(20); expect(metrics.rasterBox?.width, `${persona.code} raster width`).toBeGreaterThan(0); expect(metrics.rasterBox?.height, `${persona.code} raster height`).toBeGreaterThan(0); expect(metrics.primitiveCount, `${persona.code} fallback SVG primitives`).toBe(0); expect(metrics.neckBox, `${persona.code} SVG neck fallback`).toBeUndefined(); expect(metrics.svgBox, `${persona.code} SVG fallback`).toBeUndefined(); } }); 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", "raster"); await expect(activeAvatar.locator('.vg-raster[data-raster-art-set="p4-live2d-generated"]')).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", "raster"); await expect(activeAvatar.locator('.vg-raster[data-raster-art-set="p4-live2d-generated"]')).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 PSD v2 crying raster 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", "raster"); await expect(avatar).toHaveAttribute("data-affect", "sad"); const raster = avatar.locator('.vg-raster[data-raster-art-set="seoyeon-live2d-psd-v2"]'); await expect(raster).toBeVisible(); await expect(raster).toHaveAttribute("data-raster-variant", "sad"); await expect(raster.locator('.vg-raster__layer--tear[src$="/tear-left.png"]')).toHaveCount(1); await expect(raster.locator('.vg-raster__layer--tear[src$="/tear-right.png"]')).toHaveCount(1); }); });