import { expect, test } from "@playwright/test"; 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; 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: [], }), }), ); await page.route("**/api/personas", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify( 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")), 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.primitiveCount, `${persona.code} avatar SVG primitives`).toBeGreaterThanOrEqual(12); expect(metrics.neckBox?.width, `${persona.code} visible neck width`).toBeGreaterThan(12); expect(metrics.neckBox?.height, `${persona.code} visible neck height`).toBeGreaterThan(24); expect(metrics.svgBox?.width, `${persona.code} avatar SVG width`).toBeGreaterThan(0); expect(metrics.svgBox?.height, `${persona.code} avatar SVG height`).toBeGreaterThan(0); } }); test("keeps the active session avatar expression wired after session start", async ({ page }) => { 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(); const activeAvatar = page.locator(".sx-page--active .vg-avatar").first(); await expect(activeAvatar).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 }) => { 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: "22222222-2222-4222-8222-222222222222", 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(); const activeAvatar = page.locator(".sx-page--active .vg-avatar").first(); await expect(activeAvatar).toHaveAttribute("data-live2d-motion", "anxious"); await page.getByLabel("학습자 발화 입력").fill("조금 안정된 것 같아요."); 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(activeAvatar).toHaveAttribute("data-live2d-transition-progress", "1.00"); await expect(page.locator(".sx-stage__now")).toContainText("온화함"); }); });