vignette/apps/web/e2e/avatar-expression.spec.ts
Yun Chan 16e791e044 G0~G8 성과·동맹 측정 OS 작업 일괄 고정
8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본
폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을
git 이력으로 고정하는 것이 목적이다.

- contracts/routes/services: measurement, outcome_trajectory, rupture_repair,
  deliberate_practice, calibration_transfer, supervision_research,
  multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트
- infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행)
- apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가
- docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG
- scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트

engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
2026-08-08 01:30:53 +09:00

351 lines
14 KiB
TypeScript

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: [],
}),
});
});
await page.route(`**/api/sessions/${sessionId}/alliance-pulses`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: [
{
pulse_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
checkpoint: "pre",
status: "ready",
learner_locked_at: new Date().toISOString(),
revealed_at: new Date().toISOString(),
error_code: null,
self_scores: { goal: 0.5, task: 0.5, bond: 0.5 },
measurements: [],
},
],
}),
});
});
}
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,
),
),
}),
);
await page.route(/\/(?:api\/)?voice\/health(?:\?.*)?$/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ available: false, reason: "test fixture" }),
}),
);
await page.route(/\/(?:api\/)?sessions\/[^/]+\/live-coach(?:\?.*)?$/, (route) => {
if (route.request().method() === "GET") {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
session_id: "avatar-expression-session",
events: [],
quota: { used: 0, limit: 3, remaining: 3 },
source: "database",
}),
});
}
return route.fulfill({
status: 503,
contentType: "application/json",
body: JSON.stringify({ detail: "live coach is outside this avatar fixture" }),
});
});
});
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"),
rasterCount: el.querySelectorAll(".vg-raster").length,
primitiveCount: el.querySelectorAll("svg path, svg ellipse, svg circle, svg line, svg rect")
.length,
neckBox: el.querySelector<SVGGraphicsElement>('[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.renderMode, `${persona.code} render mode`).toBe("svg");
expect(metrics.rasterCount, `${persona.code} raster rig should be disabled`).toBe(0);
expect(metrics.primitiveCount, `${persona.code} SVG primitives`).toBeGreaterThan(10);
expect(metrics.neckBox?.width, `${persona.code} SVG neck`).toBeGreaterThan(0);
expect(metrics.svgBox?.width, `${persona.code} SVG rig`).toBeGreaterThan(0);
expect(metrics.svgBox?.height, `${persona.code} SVG rig`).toBeGreaterThan(0);
}
});
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", "svg");
await expect(activeAvatar.locator(".vg-raster")).toHaveCount(0);
await expect(activeAvatar.locator(".vg-avatar__svg")).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", "svg");
await expect(activeAvatar.locator(".vg-raster")).toHaveCount(0);
await expect(activeAvatar.locator(".vg-avatar__svg")).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 original SVG parameter 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", "svg");
await expect(avatar).toHaveAttribute("data-affect", "sad");
await expect(avatar.locator(".vg-raster")).toHaveCount(0);
await expect(avatar.locator(".vg-avatar__svg")).toBeVisible();
await expect(avatar.locator('[data-avatar-neck="true"]')).toBeVisible();
});
});