373 lines
13 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
}
|
|
});
|