import { expect, test } from "@playwright/test"; const PERSONAS = [ { code: "P4", model: "vignette-p4-live2d", defaultExpression: "anxious" }, { code: "P5", model: "vignette-p5-live2d", defaultExpression: "guarded" }, { code: "P6", model: "vignette-p6-live2d", defaultExpression: "conflicted" }, { code: "P7", model: "vignette-p7-live2d", defaultExpression: "tired" }, ] as const; const REQUIRED_EXPRESSIONS = ["joy", "sad", "angry", "rage"] as const; test.describe("avatar expression lab", () => { test.beforeEach(async ({ page }) => { await page.route("**/api/auth/me", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ user_id: "avatar-lab-learner", email: "learner@hs.ac.kr", display_name: "Avatar Lab Learner", role: "learner", cohort_ids: [], onboarding_completed_at: Math.floor(Date.now() / 1000), }), }), ); }); test("renders every P4-P7 expression motion as a visible first-party avatar preview", async ({ page, }) => { await page.emulateMedia({ reducedMotion: "reduce" }); await page.goto("/learn/avatar-expressions"); const lab = page.locator('[data-avatar-expression-lab="true"]'); await expect(lab).toBeVisible(); await expect(lab).toHaveAttribute("data-persona-count", "4"); await expect(lab).toHaveAttribute("data-expression-count", "28"); await expect(lab).toHaveAttribute("data-rendered-avatar-count", "112"); for (const persona of PERSONAS) { const panel = page.locator( `[data-persona-expression-panel="true"][data-persona-code="${persona.code}"]`, ); const code = persona.code.toLowerCase(); await expect(panel).toHaveAttribute("data-expression-count", "28"); await expect(panel).toHaveAttribute("data-live2d-model", persona.model); await expect(panel).toHaveAttribute( "data-live2d-model-url", `/live2d/personas/${code}/${code}.model3.json`, ); await expect(panel.locator('[data-expression-card="true"]')).toHaveCount(28); const sectionMetrics = await panel.evaluate((el) => { const avatars = Array.from(el.querySelectorAll(".axl__card .vg-avatar")); const invalid = avatars .map((avatar) => { const neck = avatar.querySelector('[data-avatar-neck="true"]'); const svg = avatar.querySelector("svg"); return { affect: avatar.getAttribute("data-affect"), primitives: avatar.querySelectorAll("svg path, svg ellipse, svg circle, svg line, svg rect") .length, neckBox: neck?.getBoundingClientRect().toJSON(), svgBox: svg?.getBoundingClientRect().toJSON(), }; }) .filter((item) => { return ( item.primitives < 12 || !item.neckBox || item.neckBox.width <= 8 || item.neckBox.height <= 14 || !item.svgBox || item.svgBox.width <= 0 || item.svgBox.height <= 0 ); }); return { avatarCount: avatars.length, animatedCount: avatars.filter((avatar) => avatar.getAttribute("data-avatar-animated") === "true") .length, invalid, }; }); expect(sectionMetrics.avatarCount, `${persona.code} rendered expression avatars`).toBe(28); expect(sectionMetrics.animatedCount, `${persona.code} QA avatars should be static`).toBe(0); expect(sectionMetrics.invalid, `${persona.code} visible avatar geometry`).toEqual([]); const defaultAvatar = panel.locator(".axl__persona-head .vg-avatar").first(); await expect(defaultAvatar).toHaveAttribute("data-affect", persona.defaultExpression); await expect(defaultAvatar).toHaveAttribute("data-live2d-model", persona.model); await expect(defaultAvatar).toHaveAttribute("data-live2d-expression-count", "28"); for (const expression of REQUIRED_EXPRESSIONS) { const card = panel.locator( `[data-expression-card="true"][data-expression="${expression}"]`, ); await expect(card).toHaveAttribute("data-motion-file", `expressions/${expression}.exp3.json`); await expect(card).toHaveAttribute("data-fade-in-ms", "260"); const avatar = card.locator(".vg-avatar").first(); await expect(avatar).toHaveAttribute("data-affect", expression); await expect(avatar).toHaveAttribute("data-live2d-motion", expression); await expect(avatar).toHaveAttribute( "data-live2d-motion-file", `expressions/${expression}.exp3.json`, ); await expect(avatar).toHaveAttribute("data-live2d-expression-count", "28"); await expect(avatar).toHaveAttribute("data-avatar-animated", "false"); } } }); });