케이스 이어하기 UI와 공통 탭·이미지 복구를 반영
This commit is contained in:
parent
72353ecd82
commit
f1b80676c1
38 changed files with 3581 additions and 455 deletions
373
apps/web/e2e/uc-session-case-launch.spec.ts
Normal file
373
apps/web/e2e/uc-session-case-launch.spec.ts
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
/* =====================================================================
|
||||
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,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue