vignette/apps/web/e2e/uc-session-case-launch.spec.ts

373 lines
13 KiB
TypeScript

/* =====================================================================
uc-session-case-launch.spec.ts — 새 사례 / 이어서 진행 시작 계약
목적: 같은 NPC라도 학습자가 완전히 새 사례를 시작할지, 기존 사례를
이어갈지 명시적으로 선택한다. 이어가기는 누적 회기·턴·시간을 먼저
보여 주고, 기억 요약은 접힌 상태에서 사용자가 열 때만 요청한다.
전부 route fixture 기반이다. 실제 DB·AI 엔진의 사례 기억 생성은
검증하지 않으며, Home의 URL launch intent와 Session의 POST 계약만
고정한다.
===================================================================== */
import { expect, test, type Page } from "@playwright/test";
const CONTINUE_CASE_ID = "11111111-1111-4111-8111-111111111111";
const PREVIOUS_CASE_ID = "55555555-5555-4555-8555-555555555555";
const FRESH_CASE_ID = "22222222-2222-4222-8222-222222222222";
const CONTINUE_SESSION_ID = "33333333-3333-4333-8333-333333333333";
const FRESH_SESSION_ID = "44444444-4444-4444-8444-444444444444";
function jsonRoute(body: unknown, status = 200) {
return {
status,
contentType: "application/json",
body: JSON.stringify(body),
};
}
const PERSONA = {
code: "P1",
display_name: "민서(청소년 우울)",
difficulty: "hard",
theory_target: ["humanistic"],
demographics: { age_band: "10대" },
presenting_summary: "자퇴와 무기력감을 둘러싼 상담 연습",
voice_preset: "soft-young-fem",
source: "database",
degraded: false,
};
function dashboardFixture() {
return {
message: "학습 현황 요약",
source: "runtime",
overview: {
total_sessions: 3,
active_sessions: 0,
review_ready_sessions: 0,
completed_sessions: 3,
archived_sessions: 0,
learner_turns: 9,
client_turns: 9,
last_practiced_at: "2026-08-31T09:00:00+09:00",
},
growth: {
trend: "insufficient",
avg_rapport: null,
avg_score: null,
evaluated_sessions: 0,
latest_score: null,
first_score: null,
score_delta: null,
top_techniques: [],
points: [],
},
recent_feedback: [],
persona_progress: [],
achievements: [],
};
}
function currentCaseFixture() {
return {
cases: [
{
case_id: CONTINUE_CASE_ID,
persona_code: "P1",
persona_name: "민서",
last_session_no: 3,
progress: {
total_sessions: 3,
completed_sessions: 3,
total_turns: 18,
total_duration_seconds: 5_400,
active_session_id: null,
active_session_no: null,
active_started_at: null,
last_activity_at: "2026-08-31T09:00:00+09:00",
},
},
{
case_id: PREVIOUS_CASE_ID,
persona_code: "P1",
persona_name: "민서",
last_session_no: 2,
progress: {
total_sessions: 2,
completed_sessions: 2,
total_turns: 10,
total_duration_seconds: 3_600,
active_session_id: null,
active_session_no: null,
active_started_at: null,
last_activity_at: "2026-08-20T09:00:00+09:00",
},
},
],
};
}
function memoryFixture() {
return {
case_id: CONTINUE_CASE_ID,
memory_available: true,
latest_session_digest: "지난 회기에서 수면 문제를 먼저 다뤘습니다.",
case_digest: "학업 부담과 무기력의 연결을 탐색 중입니다.",
open_threads: ["수면 리듬을 어떻게 조절할지"],
pinned_facts: ["기말고사 기간에는 불안이 커집니다."],
};
}
interface LaunchFixture {
memoryRequestCount: () => number;
startRequests: Array<Record<string, unknown>>;
}
/** catch-all을 먼저 등록하고, 실제 fixture endpoint를 뒤에 두어 LIFO 우선순위를 고정한다. */
async function installLaunchFixture(page: Page): Promise<LaunchFixture> {
let memoryRequests = 0;
const startRequests: Array<Record<string, unknown>> = [];
await page.route("**/api/**", (route) =>
route.fulfill(jsonRoute({ detail: "not part of this focused fixture" }, 404)),
);
await page.route("**/api/auth/me", (route) =>
route.fulfill(
jsonRoute({
user_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
email: "case.launch@hs.ac.kr",
display_name: "사례 시작 검증 학습자",
role: "learner",
admin_access: false,
super_admin: false,
account_status: "approved",
approval_required: false,
cohort_ids: [],
consent_at: "2026-08-01T00:00:00+09:00",
onboarding_completed_at: "2026-08-01T00:00:00+09:00",
nickname: "사례 시작 검증 학습자",
self_introduction: "",
avatar_url: "",
}),
),
);
await page.route("**/api/personas", (route) => route.fulfill(jsonRoute([PERSONA])));
await page.route("**/api/sessions", async (route) => {
if (route.request().method() !== "POST") {
await route.fallback();
return;
}
const request = (route.request().postDataJSON() ?? {}) as Record<string, unknown>;
startRequests.push(request);
const isFresh = request.start_mode === "fresh";
await route.fulfill(
jsonRoute(
{
session_id: isFresh ? FRESH_SESSION_ID : CONTINUE_SESSION_ID,
case_id: isFresh ? FRESH_CASE_ID : CONTINUE_CASE_ID,
persona_id: "persona-p1",
persona_version: 1,
session_no: isFresh ? 1 : 4,
stage: "라포",
effective_openness: 0.2,
recall_summary: isFresh ? null : "압축된 기존 사례 기억",
degraded: false,
started_at: "2026-08-31T10:00:00+09:00",
goal_stages: request.goal_stages ?? ["라포"],
duration_limit_seconds: 3_600,
warning_before_end_seconds: 600,
learner_feedback_enabled: true,
start_mode: isFresh ? "fresh" : "continue",
},
201,
),
);
});
await page.route("**/api/sessions/dashboard", (route) =>
route.fulfill(jsonRoute(dashboardFixture())),
);
await page.route("**/api/sessions/cases?persona_code=P1", (route) =>
route.fulfill(jsonRoute(currentCaseFixture())),
);
await page.route(`**/api/sessions/cases/${CONTINUE_CASE_ID}/memory`, (route) => {
memoryRequests += 1;
return route.fulfill(jsonRoute(memoryFixture()));
});
await page.route("**/api/users/me/prepost-measures**", (route) =>
route.fulfill(
jsonRoute({
pilot_id: "phase3-pilot-draft",
instrument_version: "fixture",
measures: [],
complete_pre_count: 0,
complete_post_count: 0,
updated_at: null,
}),
),
);
await page.route("**/api/voice/health", (route) =>
route.fulfill(jsonRoute({ available: true, reason: null })),
);
await page.route(`**/api/sessions/${CONTINUE_SESSION_ID}`, (route) =>
route.fulfill(sessionDetailFixture(CONTINUE_SESSION_ID, CONTINUE_CASE_ID, 4)),
);
await page.route(`**/api/sessions/${FRESH_SESSION_ID}`, (route) =>
route.fulfill(sessionDetailFixture(FRESH_SESSION_ID, FRESH_CASE_ID, 1)),
);
await page.route(`**/api/sessions/${CONTINUE_SESSION_ID}/alliance-pulses`, (route) =>
route.fulfill(jsonRoute({ items: [] })),
);
await page.route(`**/api/sessions/${FRESH_SESSION_ID}/alliance-pulses`, (route) =>
route.fulfill(jsonRoute({ items: [] })),
);
await page.route("**/api/sessions/*/live-coach", (route) =>
route.fulfill(
jsonRoute({
source: "database",
quota: { remaining: 3, max: 3 },
credit_events: [],
events: [],
}),
),
);
return { memoryRequestCount: () => memoryRequests, startRequests };
}
function sessionDetailFixture(sessionId: string, caseId: string, sessionNo: number) {
return jsonRoute({
session_id: sessionId,
case_id: caseId,
persona_id: "persona-p1",
persona_version: 1,
persona_code: "P1",
persona_name: "민서",
session_no: sessionNo,
status: "active",
stage: "라포",
theory_mode: "humanistic",
effective_openness: 0.2,
started_at: "2026-08-31T10:00:00+09:00",
ended_at: null,
review_ready: false,
turns: [],
goal_stages: ["라포"],
duration_limit_seconds: 3_600,
warning_before_end_seconds: 600,
progress: null,
});
}
function launchButton(page: Page, label: string) {
return page
.locator(".lh-actions")
.getByRole("button", { name: label, exact: true })
.last();
}
function launchModeLabel(page: Page, label: string) {
return page.locator(".lh-launch-mode label").filter({ hasText: label });
}
test.describe("uc session case launch", () => {
test("이어가기 통계와 접힌 기억을 확인하고 두 방식의 launch URL을 구분한다", async ({
page,
}) => {
const fixture = await installLaunchFixture(page);
await page.goto("/learn/practice");
const freshMode = page.getByRole("radio", { name: "완전히 새로 시작" });
const continueMode = page.getByRole("radio", { name: "이어서 진행" });
const continuity = page.locator(".lh-continuity");
// 기본은 새 사례다. 기존 기억 endpoint는 foldout을 열기 전까지 요청하지 않는다.
await expect(freshMode).toBeChecked();
await expect(continuity).toContainText("새 사례");
await expect(continuity.locator("details.lh-continuity__memory")).toHaveCount(0);
expect(fixture.memoryRequestCount()).toBe(0);
await expect(continueMode).toBeEnabled();
await launchModeLabel(page, "이어서 진행").click();
await expect(continueMode).toBeChecked();
await expect(continuity).toContainText("다음 4회기");
await expect(continuity).toContainText("3회기");
await expect(continuity).toContainText("18턴");
await expect(continuity).toContainText("1시간 30분");
const memory = continuity.locator("details.lh-continuity__memory");
await expect(memory).not.toHaveAttribute("open", "");
expect(fixture.memoryRequestCount()).toBe(0);
await memory.locator("summary").click();
await expect(memory).toHaveAttribute("open", "");
await expect(memory).toContainText("지난 회기에서 수면 문제를 먼저 다뤘습니다.");
await expect(memory).toContainText("수면 리듬을 어떻게 조절할지");
await expect.poll(fixture.memoryRequestCount).toBe(1);
await launchButton(page, "이어서 진행").click();
await expect(page).toHaveURL(
new RegExp(`/learn/session/P1\\?continuity=continue&case=${CONTINUE_CASE_ID}$`),
);
await page.goBack();
await expect(page.locator(".lh-continuity")).toBeVisible();
await launchModeLabel(page, "완전히 새로 시작").click();
await expect(freshMode).toBeChecked();
await expect(continuity.locator("details.lh-continuity__memory")).toHaveCount(0);
await launchButton(page, "완전히 새로 시작").click();
await expect(page).toHaveURL(/\/learn\/session\/P1\?continuity=fresh$/);
});
test("복수의 종료 사례에서는 고른 사례의 통계와 exact case URL을 사용한다", async ({
page,
}) => {
await installLaunchFixture(page);
await page.goto("/learn/practice");
await launchModeLabel(page, "이어서 진행").click();
const continuity = page.locator(".lh-continuity");
const caseSelect = continuity.getByRole("combobox", {
name: "이어서 진행할 사례",
});
await expect(caseSelect).toHaveValue(CONTINUE_CASE_ID);
await caseSelect.selectOption(PREVIOUS_CASE_ID);
await expect(caseSelect).toHaveValue(PREVIOUS_CASE_ID);
await expect(continuity).toContainText("다음 3회기");
await expect(continuity).toContainText("2회기");
await expect(continuity).toContainText("10턴");
await expect(continuity).toContainText("1시간");
await launchButton(page, "이어서 진행").click();
await expect(page).toHaveURL(
new RegExp(`/learn/session/P1\\?continuity=continue&case=${PREVIOUS_CASE_ID}$`),
);
});
for (const launch of [
{
label: "새 사례",
search: "?continuity=fresh",
expected: { start_mode: "fresh", case_id: null },
},
{
label: "이어서 진행",
search: `?continuity=continue&case=${CONTINUE_CASE_ID}`,
expected: { start_mode: "continue", case_id: CONTINUE_CASE_ID },
},
]) {
test(`${launch.label} 선택값을 회기 시작 POST 본문으로 전달한다`, async ({ page }) => {
const fixture = await installLaunchFixture(page);
await page.goto(`/learn/session/P1${launch.search}`);
await expect(page.locator(".sx-page--prestart")).toBeVisible();
await page.getByRole("button", { name: "회기 시작", exact: true }).click();
await expect.poll(() => fixture.startRequests.length).toBe(1);
expect(fixture.startRequests[0]).toMatchObject({
persona_code: "P1",
...launch.expected,
});
});
}
});