SSOT 대시보드:
- 한신대 기술분석 PDF(19쪽) 정합성 분석 + 이번 세션 발견 섹션 추가
- 섹션 폴드아웃(접기)·상단 목차(드릴다운)·모두 펼치기/접기 — 내용 보존, 레이아웃만 정리
페르소나 반응 강화('저항·반응 조절' 핵심 차별):
- PersonaCard.triggers(역린) 필드 + CCD 핵심상처 파생 역린 블록
- L0에 무례·모욕·조롱 시 현실적 동맹 균열 반응 지침
버그·성능 수정(라이브/E2E로 포착):
- 게이트웨이 페르소나 격리: --append-system-prompt를 --system-prompt(교체)로 + --exclude-dynamic-system-prompt-sections (내담자 캐릭터 붕괴·개발맥락 누출 차단)
- RAG: 임베더 동기 로드(약 7-13초)를 _warm_rag_caches 백그라운드 warm으로(세션 생성 블로킹 회귀 수정)
- voice TTS RMS 데드힌트 제거, init_state OpennessParams 파라미터객체화
- 한국어 PII(날짜·금액·주소) 마스킹 보강
- 레이아웃 시각 게이트: 폼 컨트롤 값 스크롤 오탐 제외(7/7)
검증: 백엔드 84/84, E2E 42(데스크톱 27·모바일 11·아바타 4), 시각 게이트 7/7
220 lines
8.9 KiB
TypeScript
220 lines
8.9 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
|
|
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;
|
|
|
|
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: [],
|
|
}),
|
|
}),
|
|
);
|
|
|
|
await page.route("**/api/personas", (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(
|
|
PERSONAS.map(
|
|
({
|
|
expectedExpression: _expectedExpression,
|
|
expectedModel: _expectedModel,
|
|
...persona
|
|
}) => persona,
|
|
),
|
|
),
|
|
}),
|
|
);
|
|
});
|
|
|
|
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")),
|
|
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.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);
|
|
}
|
|
});
|
|
|
|
test("keeps the active session avatar expression wired after session start", async ({ page }) => {
|
|
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();
|
|
|
|
const activeAvatar = page.locator(".sx-page--active .vg-avatar").first();
|
|
await expect(activeAvatar).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 }) => {
|
|
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: "22222222-2222-4222-8222-222222222222",
|
|
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();
|
|
const activeAvatar = page.locator(".sx-page--active .vg-avatar").first();
|
|
await expect(activeAvatar).toHaveAttribute("data-live2d-motion", "anxious");
|
|
|
|
await page.getByLabel("학습자 발화 입력").fill("조금 안정된 것 같아요.");
|
|
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(page.locator(".sx-stage__now")).toContainText("온화함");
|
|
});
|
|
});
|