음성 재생과 운영 배포 정리
This commit is contained in:
parent
8ed185ce6c
commit
ac7db95542
1020 changed files with 46863 additions and 2175 deletions
|
|
@ -1,5 +1,19 @@
|
|||
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",
|
||||
|
|
@ -55,6 +69,41 @@ const PERSONAS = [
|
|||
},
|
||||
] 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: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("persona avatar expression rig", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route("**/api/auth/me", (route) =>
|
||||
|
|
@ -67,6 +116,8 @@ test.describe("persona avatar expression rig", () => {
|
|||
display_name: "Avatar Expression Learner",
|
||||
role: "learner",
|
||||
cohort_ids: [],
|
||||
consent_at: Math.floor(Date.now() / 1000),
|
||||
onboarding_completed_at: Math.floor(Date.now() / 1000),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
|
@ -76,7 +127,7 @@ test.describe("persona avatar expression rig", () => {
|
|||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
PERSONAS.map(
|
||||
[P1_PERSONA, ...PERSONAS].map(
|
||||
({
|
||||
expectedExpression: _expectedExpression,
|
||||
expectedModel: _expectedModel,
|
||||
|
|
@ -106,11 +157,17 @@ test.describe("persona avatar expression rig", () => {
|
|||
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"),
|
||||
rasterArtSet: el.querySelector(".vg-raster")?.getAttribute("data-raster-art-set"),
|
||||
rasterMode: el.querySelector(".vg-raster")?.getAttribute("data-raster-mode"),
|
||||
rasterLayerCount: el.querySelectorAll(".vg-raster__layer").length,
|
||||
rasterBox: el.querySelector<HTMLElement>(".vg-raster")?.getBoundingClientRect().toJSON(),
|
||||
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(),
|
||||
}));
|
||||
const expectedArtSet = `${persona.code.toLowerCase()}-live2d-generated`;
|
||||
|
||||
expect(metrics.expressionCount, `${persona.code} expression count`).toBeGreaterThanOrEqual(20);
|
||||
expect(metrics.live2dSchema, `${persona.code} Live2D schema`).toBe("vignette.live2d.v1");
|
||||
|
|
@ -123,15 +180,21 @@ test.describe("persona avatar expression rig", () => {
|
|||
`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);
|
||||
expect(metrics.renderMode, `${persona.code} render mode`).toBe("raster");
|
||||
expect(metrics.rasterArtSet, `${persona.code} generated art set`).toBe(expectedArtSet);
|
||||
expect(metrics.rasterMode, `${persona.code} raster rig mode`).toBe("psb-detailed");
|
||||
expect(metrics.rasterLayerCount, `${persona.code} raster layers`).toBeGreaterThanOrEqual(20);
|
||||
expect(metrics.rasterBox?.width, `${persona.code} raster width`).toBeGreaterThan(0);
|
||||
expect(metrics.rasterBox?.height, `${persona.code} raster height`).toBeGreaterThan(0);
|
||||
expect(metrics.primitiveCount, `${persona.code} fallback SVG primitives`).toBe(0);
|
||||
expect(metrics.neckBox, `${persona.code} SVG neck fallback`).toBeUndefined();
|
||||
expect(metrics.svgBox, `${persona.code} SVG fallback`).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
|
|
@ -154,9 +217,12 @@ test.describe("persona avatar expression rig", () => {
|
|||
|
||||
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", "raster");
|
||||
await expect(activeAvatar.locator('.vg-raster[data-raster-art-set="p4-live2d-generated"]')).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");
|
||||
|
|
@ -168,6 +234,8 @@ test.describe("persona avatar expression rig", () => {
|
|||
});
|
||||
|
||||
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();
|
||||
|
|
@ -177,7 +245,7 @@ test.describe("persona avatar expression rig", () => {
|
|||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
session_id: "22222222-2222-4222-8222-222222222222",
|
||||
session_id: sessionId,
|
||||
case_id: "case-avatar-transition",
|
||||
session_no: 1,
|
||||
stage: "라포",
|
||||
|
|
@ -205,16 +273,40 @@ test.describe("persona avatar expression rig", () => {
|
|||
|
||||
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", "raster");
|
||||
await expect(activeAvatar.locator('.vg-raster[data-raster-art-set="p4-live2d-generated"]')).toBeVisible();
|
||||
await expect(activeAvatar).toHaveAttribute("data-live2d-motion", "anxious");
|
||||
|
||||
await page.getByLabel("학습자 발화 입력").fill("조금 안정된 것 같아요.");
|
||||
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(activeAvatar).toHaveAttribute("data-live2d-transition-progress", "1.00");
|
||||
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 PSD v2 crying raster 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", "raster");
|
||||
await expect(avatar).toHaveAttribute("data-affect", "sad");
|
||||
|
||||
const raster = avatar.locator('.vg-raster[data-raster-art-set="seoyeon-live2d-psd-v2"]');
|
||||
await expect(raster).toBeVisible();
|
||||
await expect(raster).toHaveAttribute("data-raster-variant", "sad");
|
||||
await expect(raster.locator('.vg-raster__layer--tear[src$="/tear-left.png"]')).toHaveCount(1);
|
||||
await expect(raster.locator('.vg-raster__layer--tear[src$="/tear-right.png"]')).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue