아바타 v3 표현 엔진·리노컷 렌더러·립싱크와 Lab
- 표현 엔진: 채널 34개(mouthRound 추가), 표정 28종·강도 곡선·반응 클립 26종·지문 cue, 미세표정 누설 - 발화층: 한글 자모 비짐 9종, TTS 오디오 선분석 정렬, 60ms 앞당김·모음 간 비닫힘 - 발화 동반층: 억양·강세에 맞춘 고개 표류·끄덕임·질문 갸웃·들숨·눈썹 박·시선 회피·쉼 깜빡임 - P1 서연 리노컷 렌더러: 원화 픽셀 입술 띠 변형, 하관 띠 변형(턱·코 들썩), 볼 불룩, 작은 크기 선 보정, 모티프·배경 색면 - /dev/avatar-lab, check:avatar-presets·check:avatar-lipsync, avatar-lab E2E 19개
This commit is contained in:
parent
b7bd24f016
commit
85bd079d18
47 changed files with 8310 additions and 0 deletions
316
apps/web/e2e/avatar-lab.spec.ts
Normal file
316
apps/web/e2e/avatar-lab.spec.ts
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
/* =====================================================================
|
||||
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");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue