/* ===================================================================== avatar-lab.spec.ts — 아바타 v3 표현 엔진 dev Lab(/dev/avatar-lab) 검증 스펙. 무인증 dev 페이지라 auth mock이 필요 없다(AvatarPreview 패턴과 동일). ===================================================================== */ import { expect, test } from "@playwright/test"; function channelCell(page: import("@playwright/test").Page, channel: string) { return page.locator(`tr[data-channel="${channel}"] td[data-value]`); } test.describe("avatar v3 lab", () => { /* 이 페이지는 무인증 dev 라우트지만, AuthProvider가 마운트 시 항상 /auth/me를 호출한다. 실 백엔드에 세션이 없으면 401이 그대로 콘솔 에러로 찍혀 "콘솔 error 0" 판정을 이 페이지와 무관한 원인으로 깨뜨린다 (avatar-expression-lab.spec.ts / admin-affect.spec.ts와 같은 mock 패턴). */ 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("콘솔 에러 없이 로드된다", async ({ page }) => { const errors: string[] = []; page.on("console", (msg) => { if (msg.type() === "error") errors.push(msg.text()); }); await page.goto("/dev/avatar-lab?seed=1"); await expect(page.locator('[data-avatar-lab="true"]')).toBeVisible(); await page.waitForTimeout(300); expect(errors).toEqual([]); }); test("표정 sad 강도 1 선택 후 채널이 뚜렷해진다", async ({ page }) => { await page.goto("/dev/avatar-lab?seed=1"); await page.locator('[data-testid="expression-sad"]').click(); const intensitySlider = page.locator(".al__intensity input[type='range']"); await intensitySlider.focus(); await intensitySlider.press("End"); const browLAngleCell = channelCell(page, "browLAngle"); const mouthFormCell = channelCell(page, "mouthForm"); await expect.poll(async () => Number(await browLAngleCell.innerText()), { timeout: 3000 }).toBeGreaterThan(0.3); await expect.poll(async () => Number(await mouthFormCell.innerText()), { timeout: 3000 }).toBeLessThan(-0.2); }); test("nod_small 클릭 시 headPitch가 일시적으로 낮아진다", async ({ page }) => { await page.goto("/dev/avatar-lab?seed=1"); const headPitchCell = channelCell(page, "headPitch"); const before = Number(await headPitchCell.innerText()); await page.locator('[data-testid="clip-nod_small"]').click(); await expect .poll(async () => before - Number(await headPitchCell.innerText()), { timeout: 1500, intervals: [50] }) .toBeGreaterThanOrEqual(0.05); }); test("괄호 지문이 cue로 파싱되고 미대응 지문이 표시된다", async ({ page }) => { await page.goto("/dev/avatar-lab?seed=1"); await page.locator('[data-testid="speech-text"]').fill("(한숨) 그냥요. (작은 목소리로)"); await page.locator('[data-testid="play-speech"]').click(); const firstCue = page.locator('[data-testid="parsed-cues"] li').first(); await expect(firstCue).toHaveAttribute("data-clip", "sigh"); await expect(firstCue).toHaveAttribute("data-anchor", "pre"); await expect(firstCue).toHaveAttribute("data-source", "direction"); await expect(page.locator('[data-testid="unmatched-directions"] li')).toHaveText("작은 목소리로"); }); test("누설 테스트 시 browLAngle이 일시적으로 커졌다가 다시 작아진다", async ({ page }) => { await page.goto("/dev/avatar-lab?seed=1"); const browLAngleCell = channelCell(page, "browLAngle"); const before = Number(await browLAngleCell.innerText()); await page.locator('[data-testid="play-leak-test"]').click(); await expect .poll(async () => Number(await browLAngleCell.innerText()) - before, { timeout: 1500, intervals: [50] }) .toBeGreaterThanOrEqual(0.15); await page.waitForTimeout(1500); const after = Number(await browLAngleCell.innerText()); expect(after - before).toBeLessThan(0.15); }); test("reduced motion 토글 시 반응 클립이 비활성화된다", async ({ page }) => { await page.goto("/dev/avatar-lab?seed=1"); await page.locator('[data-testid="reduced-motion-toggle"]').click(); await page.waitForTimeout(150); await expect(page.locator('[data-testid="active-clips"] li')).toHaveCount(0); await page.locator('[data-testid="clip-nod_small"]').click(); await page.waitForTimeout(200); await expect(page.locator('[data-testid="active-clips"] li')).toHaveCount(0); }); /* 발화층(립싱크) — 결정문 §5.4. 비짐별 입 모양이 채널에 실제로 합성되는지 채널 표로 확인한다. */ test("'우우우우' 발화 중 mouthRound이 커진다", async ({ page }) => { await page.goto("/dev/avatar-lab?seed=1"); await page.locator('[data-testid="speech-text"]').fill("우우우우"); await page.locator('[data-testid="play-speech"]').click(); const mouthRoundCell = channelCell(page, "mouthRound"); await expect .poll(async () => Number(await mouthRoundCell.innerText()), { timeout: 3000, intervals: [50] }) .toBeGreaterThan(0.4); }); test("'아아아아' 발화 중 mouthOpen이 커진다", async ({ page }) => { await page.goto("/dev/avatar-lab?seed=1"); await page.locator('[data-testid="speech-text"]').fill("아아아아"); await page.locator('[data-testid="play-speech"]').click(); const mouthOpenCell = channelCell(page, "mouthOpen"); await expect .poll(async () => Number(await mouthOpenCell.innerText()), { timeout: 3000, intervals: [50] }) .toBeGreaterThan(0.3); }); test("'음 음 음' 발화 중 mouthPress가 커진다", async ({ page }) => { await page.goto("/dev/avatar-lab?seed=1"); await page.locator('[data-testid="speech-text"]').fill("음 음 음"); await page.locator('[data-testid="play-speech"]').click(); const mouthPressCell = channelCell(page, "mouthPress"); await expect .poll(async () => Number(await mouthPressCell.innerText()), { timeout: 3000, intervals: [50] }) .toBeGreaterThan(0.2); }); /* 발화 동반층(§5.5) — 말할 때 머리·몸·눈썹·시선·깜빡임. */ test("'정말 그렇게 생각하세요?' 발화 중 headRoll 절댓값이 0.03을 넘는 순간이 있다", async ({ page }) => { await page.goto("/dev/avatar-lab?seed=1"); await page.locator('[data-testid="speech-text"]').fill("정말 그렇게 생각하세요?"); await page.locator('[data-testid="play-speech"]').click(); const headRollCell = channelCell(page, "headRoll"); await expect .poll(async () => Math.abs(Number(await headRollCell.innerText())), { timeout: 4000, intervals: [50] }) .toBeGreaterThan(0.03); }); test("평서문 발화 중 headPitch가 -0.05 아래로 내려가는 순간이 있다", async ({ page }) => { await page.goto("/dev/avatar-lab?seed=1"); await page.locator('[data-testid="speech-text"]').fill("그냥요."); await page.locator('[data-testid="play-speech"]').click(); const headPitchCell = channelCell(page, "headPitch"); await expect .poll(async () => Number(await headPitchCell.innerText()), { timeout: 4000, intervals: [50] }) .toBeLessThan(-0.05); }); test("reduced motion에서는 발화 중 headYaw가 기본 자세에서 벗어나지 않는다", async ({ page }) => { await page.goto("/dev/avatar-lab?seed=1"); const headYawCell = channelCell(page, "headYaw"); const baseline = Number(await headYawCell.innerText()); await page.locator('[data-testid="reduced-motion-toggle"]').click(); await page.locator('[data-testid="speech-text"]').fill("정말 그렇게 생각하세요?"); await page.locator('[data-testid="play-speech"]').click(); await page.waitForTimeout(500); expect(Number(await headYawCell.innerText())).toBeCloseTo(baseline, 2); await page.waitForTimeout(800); expect(Number(await headYawCell.innerText())).toBeCloseTo(baseline, 2); }); /* 2단계-B — 리노컷 렌더러(§8.4). 기본 렌더러는 linocut(P1)이라 위 6개는 그대로 통과한다(채널 미터·클립·지문 파서는 렌더러와 무관하게 동작한다). */ test("linocut 메인 루트가 ready가 되고 레이어 이미지 요청이 전부 200이다", async ({ page }) => { const imageStatuses: number[] = []; page.on("response", (res) => { if (/\/avatar\/v3\/p1\/.*\.webp$/.test(res.url())) imageStatuses.push(res.status()); }); await page.goto("/dev/avatar-lab?seed=1"); const mainSvg = page.locator('.al__linocut-main svg[data-render-mode="linocut-v3"]'); await expect(mainSvg).toHaveAttribute("data-load-state", "ready", { timeout: 5000 }); expect(imageStatuses.length).toBeGreaterThanOrEqual(3); expect(imageStatuses.every((s) => s === 200)).toBe(true); }); test("joy 전환 후 mouth-line d가 바뀌고 grief에서 tear 부위가 보인다", async ({ page }) => { await page.goto("/dev/avatar-lab?seed=1"); const mainSvg = page.locator('.al__linocut-main svg[data-render-mode="linocut-v3"]'); await expect(mainSvg).toHaveAttribute("data-load-state", "ready", { timeout: 5000 }); const mouthLine = mainSvg.locator('[data-part="mouth-line"]'); const neutralD = await mouthLine.getAttribute("d"); await page.locator('[data-testid="expression-joy"]').click(); const intensitySlider = page.locator(".al__intensity input[type='range']"); await intensitySlider.focus(); await intensitySlider.press("End"); await expect.poll(async () => mouthLine.getAttribute("d"), { timeout: 2000 }).not.toBe(neutralD); await page.locator('[data-testid="expression-grief"]').click(); await expect .poll(async () => Number(await mainSvg.locator('[data-part="tear-left"]').getAttribute("opacity")), { timeout: 2000, }) .toBeGreaterThan(0); }); test("sad 선택 시 배경·모티프 그룹이 negative가 된다", async ({ page }) => { await page.goto("/dev/avatar-lab?seed=1"); const mainSvg = page.locator('.al__linocut-main svg[data-render-mode="linocut-v3"]'); await expect(mainSvg).toHaveAttribute("data-load-state", "ready", { timeout: 5000 }); await page.locator('[data-testid="expression-sad"]').click(); await expect(mainSvg).toHaveAttribute("data-backdrop-group", "negative"); await expect(page.locator('[data-motif="bud-weather"]')).toHaveAttribute("data-motif-weather", "negative"); }); test("피드백 모드에 따라 모티프 봉오리 개방도가 바뀐다", async ({ page }) => { await page.goto("/dev/avatar-lab?seed=1"); const motif = page.locator('[data-motif="bud-weather"]'); const feedbackSelect = page.locator('[data-testid="feedback-mode"]'); await feedbackSelect.selectOption("immersive"); await expect(motif).toHaveAttribute("data-motif-bud", "static"); await feedbackSelect.selectOption("coached"); const opennessSlider = page.locator('[data-testid="openness-slider"]'); await opennessSlider.focus(); await opennessSlider.press("End"); await expect(motif).toHaveAttribute("data-motif-bud", "1.00"); }); test("공연을 연달아 재생하면 이전 공연의 미발동 cue가 취소된다", async ({ page }) => { await page.goto("/dev/avatar-lab?seed=1"); await page.locator('[data-testid="pause-toggle"]').click(); await page.locator('[data-testid="speech-text"]').fill("(한숨) 첫 번째 문장이다"); await page.locator('[data-testid="play-speech"]').click(); await page.waitForTimeout(150); const firstScheduled = await page.locator('[data-testid="scheduled-cues"] li').allTextContents(); expect(firstScheduled.some((t) => t.includes("sigh"))).toBe(true); await page.locator('[data-testid="speech-text"]').fill("(시선 피함) 두 번째는 다른 지문이다"); await page.locator('[data-testid="play-speech"]').click(); await page.waitForTimeout(150); const secondScheduled = await page.locator('[data-testid="scheduled-cues"] li').allTextContents(); expect(secondScheduled.some((t) => t.includes("sigh"))).toBe(false); expect(secondScheduled.some((t) => t.includes("look_away_side"))).toBe(true); }); test("reduced motion에서도 linocut 루트는 ready를 유지하고 모티프 애니메이션이 멈춘다", async ({ page }) => { await page.goto("/dev/avatar-lab?seed=1"); const mainSvg = page.locator('.al__linocut-main svg[data-render-mode="linocut-v3"]'); await expect(mainSvg).toHaveAttribute("data-load-state", "ready", { timeout: 5000 }); await page.locator('[data-testid="expression-sad"]').click(); await expect(page.locator('[data-motif="bud-weather"]')).toHaveAttribute("data-motif-weather", "negative"); await page.locator('[data-testid="reduced-motion-toggle"]').click(); await page.waitForTimeout(200); await expect(mainSvg).toHaveAttribute("data-load-state", "ready"); const rainSprite = page.locator('[data-motif="bud-weather"] .linocut-motif__weather-negative').first(); const animationName = await rainSprite.evaluate((el) => getComputedStyle(el).animationName); expect(animationName === "none" || animationName === "").toBe(true); }); test("sad + immersive는 봉오리가 숙이고, coached + 개방도 0.8 + sad는 곧게 선다", async ({ page }) => { await page.goto("/dev/avatar-lab?seed=1"); const motif = page.locator('[data-motif="bud-weather"]'); const feedbackSelect = page.locator('[data-testid="feedback-mode"]'); await feedbackSelect.selectOption("immersive"); await page.locator('[data-testid="expression-sad"]').click(); await expect(motif).toHaveAttribute("data-motif-pose", "droop"); await feedbackSelect.selectOption("coached"); const opennessSlider = page.locator('[data-testid="openness-slider"]'); await opennessSlider.evaluate((el) => { const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!; setter.call(el, "0.8"); el.dispatchEvent(new Event("input", { bubbles: true })); }); await expect(motif).toHaveAttribute("data-motif-pose", "upright"); }); });