케이스 이어하기 UI와 공통 탭·이미지 복구를 반영
This commit is contained in:
parent
72353ecd82
commit
f1b80676c1
38 changed files with 3581 additions and 455 deletions
157
apps/web/e2e/image-resilience.spec.ts
Normal file
157
apps/web/e2e/image-resilience.spec.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const PNG_1X1_BASE64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lTQvYwAAAABJRU5ErkJggg==";
|
||||
|
||||
function jsonRoute(body: unknown, status = 200) {
|
||||
return { status, contentType: "application/json", body: JSON.stringify(body) };
|
||||
}
|
||||
|
||||
async function installOnboardingFixtures(page: Page, avatarUrl: string) {
|
||||
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: "e2e-image-resilience",
|
||||
email: "avatar.fixture@hs.ac.kr",
|
||||
role: "learner",
|
||||
display_name: "온보딩 사용자",
|
||||
admin_access: false,
|
||||
super_admin: false,
|
||||
account_status: "approved",
|
||||
approval_required: false,
|
||||
cohort_ids: [],
|
||||
consent_at: null,
|
||||
onboarding_completed_at: null,
|
||||
nickname: "온보딩 사용자",
|
||||
self_introduction: "",
|
||||
avatar_url: avatarUrl,
|
||||
}),
|
||||
),
|
||||
);
|
||||
await page.route("**/api/users/me", (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
user_id: "e2e-image-resilience",
|
||||
email: "avatar.fixture@hs.ac.kr",
|
||||
role: "learner",
|
||||
display_name: "온보딩 사용자",
|
||||
legal_name: "",
|
||||
affiliation: "한신대학교",
|
||||
department: "",
|
||||
grade_level: "",
|
||||
phone: "",
|
||||
contact_address: "",
|
||||
nickname: "온보딩 사용자",
|
||||
self_introduction: "",
|
||||
avatar_url: avatarUrl,
|
||||
cohort_ids: [],
|
||||
}),
|
||||
),
|
||||
);
|
||||
await page.route("**/api/users/legal-docs", (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
terms: {
|
||||
kind: "terms",
|
||||
status: "draft",
|
||||
title: "서비스 이용약관",
|
||||
version: "image-resilience-fixture",
|
||||
body: "테스트 약관",
|
||||
},
|
||||
privacy: {
|
||||
kind: "privacy",
|
||||
status: "draft",
|
||||
title: "개인정보 처리방침",
|
||||
version: "image-resilience-fixture",
|
||||
body: "테스트 개인정보 처리방침",
|
||||
},
|
||||
source_note: "이미지 회복성 전용 fixture",
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("사용자 이미지 회복성", () => {
|
||||
test("대문자 외부 HTTPS URL도 안전하게 정규화해 실제 이미지로 전환한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
const externalAvatar = "HTTPS://images.example.test/profile/avatar.png";
|
||||
await installOnboardingFixtures(page, externalAvatar);
|
||||
await page.route("https://images.example.test/profile/avatar.png", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "image/png",
|
||||
body: Buffer.from(PNG_1X1_BASE64, "base64"),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/onboarding");
|
||||
|
||||
const preview = page.locator(".ob-avatar__preview");
|
||||
const image = preview.locator("img");
|
||||
await expect(image).toBeVisible();
|
||||
await expect(image).toHaveAttribute(
|
||||
"src",
|
||||
"https://images.example.test/profile/avatar.png",
|
||||
);
|
||||
await expect(preview.locator("[data-image-fallback]")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("온보딩 아바타 파일이 404여도 깨진 아이콘 대신 닉네임 이니셜을 표시한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
const staleAvatar = "/uploads/profile-avatars/stale-onboarding-avatar.jpg";
|
||||
await installOnboardingFixtures(page, staleAvatar);
|
||||
await page.route("**/api/uploads/profile-avatars/stale-onboarding-avatar.jpg", (route) =>
|
||||
route.fulfill(jsonRoute({ detail: "Not Found" }, 404)),
|
||||
);
|
||||
|
||||
await page.goto("/onboarding");
|
||||
await expect(page.getByRole("heading", { name: "가입 정보를 입력합니다." })).toBeVisible();
|
||||
|
||||
const preview = page.locator(".ob-avatar__preview");
|
||||
await expect(preview.locator("[data-image-fallback='failed']")).toHaveText("온");
|
||||
await expect(preview.locator("img")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("지연된 이미지 응답 중에도 브라우저의 깨진 이미지 박스를 노출하지 않는다", async ({
|
||||
page,
|
||||
}) => {
|
||||
const delayedAvatar = "/uploads/profile-avatars/delayed-onboarding-avatar.jpg";
|
||||
let releaseImage: (() => void) | undefined;
|
||||
const imageGate = new Promise<void>((resolve) => {
|
||||
releaseImage = resolve;
|
||||
});
|
||||
|
||||
await installOnboardingFixtures(page, delayedAvatar);
|
||||
await page.route("**/api/uploads/profile-avatars/delayed-onboarding-avatar.jpg", async (route) => {
|
||||
await imageGate;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "image/png",
|
||||
body: Buffer.from(PNG_1X1_BASE64, "base64"),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/onboarding", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const preview = page.locator(".ob-avatar__preview");
|
||||
await expect(preview.locator("[data-image-fallback='loading']")).toHaveText("온");
|
||||
const pendingImage = preview.locator("img");
|
||||
await expect(pendingImage).toHaveAttribute("data-image-state", "loading");
|
||||
await expect(pendingImage).toBeHidden();
|
||||
await expect(pendingImage).toHaveCSS("display", "none");
|
||||
expect(await pendingImage.evaluate((image) => image.getBoundingClientRect().toJSON())).toMatchObject({
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
|
||||
releaseImage?.();
|
||||
await expect(pendingImage).toBeVisible();
|
||||
await expect(pendingImage).toHaveAttribute("data-image-state", "ready");
|
||||
await expect(preview.locator("[data-image-fallback]")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -605,10 +605,10 @@ test.describe("layout visual gate @single-run", () => {
|
|||
timeout: 15_000,
|
||||
});
|
||||
const plan = page.locator(".sx-prestart__plan");
|
||||
await expect(plan).toContainText("시작 과업");
|
||||
await expect(plan).toContainText("선택 접근");
|
||||
await expect(plan).toContainText("이번 목표");
|
||||
await expect(plan).toContainText("운영 기준");
|
||||
await expect(plan).toContainText("준비");
|
||||
await expect(plan).toContainText("대화");
|
||||
await expect(plan).toContainText("리뷰·재연습");
|
||||
await expect(plan).toContainText("위험 신호는 대화보다 안전 확인을 우선합니다.");
|
||||
|
||||
const widths = await page.evaluate(() => {
|
||||
const head = document.querySelector<HTMLElement>(".sx-page--prestart .sx-head");
|
||||
|
|
|
|||
|
|
@ -1053,8 +1053,9 @@ test.describe("학습자 자기주도 전체 루프 — 실제 src UI / route fi
|
|||
);
|
||||
await expect(goals.getByRole("button", { name: /탐색/ })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
"false",
|
||||
);
|
||||
await goals.getByRole("button", { name: /탐색/ }).click();
|
||||
await goals.getByRole("button", { name: /개입/ }).click();
|
||||
await expect(goals.getByRole("button", { name: /개입/ })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
|
|
|
|||
|
|
@ -279,6 +279,46 @@ async function expectMainControlsUnclipped(page: Page) {
|
|||
expect(failures, `Main session controls are clipped: ${JSON.stringify(failures)}`).toEqual([]);
|
||||
}
|
||||
|
||||
async function expectComposeControlsBottomAligned(page: Page) {
|
||||
const result = await page.evaluate(() => {
|
||||
const selectors = [
|
||||
".sx-compose textarea",
|
||||
".sx-coach-trigger-btn",
|
||||
".sx-compose .vg-btn",
|
||||
];
|
||||
const controls = selectors.map((selector) => {
|
||||
const element = document.querySelector<HTMLElement>(selector);
|
||||
if (!element) return null;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
selector,
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
height: rect.height,
|
||||
};
|
||||
});
|
||||
if (controls.some((control) => control === null)) return null;
|
||||
|
||||
const resolvedControls = controls as Array<NonNullable<(typeof controls)[number]>>;
|
||||
const bottoms = resolvedControls.map((control) => control.bottom);
|
||||
return {
|
||||
delta: Math.max(...bottoms) - Math.min(...bottoms),
|
||||
controls: resolvedControls.map((control) => ({
|
||||
selector: control.selector,
|
||||
top: Math.round(control.top),
|
||||
bottom: Math.round(control.bottom),
|
||||
height: Math.round(control.height),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
expect(result, "Expected text composer controls to be present").not.toBeNull();
|
||||
expect(
|
||||
result!.delta,
|
||||
`Expected text composer control bottoms to align: ${JSON.stringify(result!.controls)}`,
|
||||
).toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
async function expectRightPanelDoesNotIntersectSessionCore(page: Page) {
|
||||
const result = await page.evaluate(() => {
|
||||
const right = document.querySelector<HTMLElement>(".sx-page--active .sx-col-right");
|
||||
|
|
@ -500,6 +540,7 @@ test.describe("learner session full-screen layout", () => {
|
|||
await expectSessionControlsInsideViewport(page);
|
||||
await expectNoVisibleSessionPanelOverlap(page);
|
||||
await expectMainControlsUnclipped(page);
|
||||
await expectComposeControlsBottomAligned(page);
|
||||
await expectSessionPageHeightToMatchViewport(page);
|
||||
await expectActiveSessionUsableLayout(page);
|
||||
await expectRightPanelDoesNotIntersectSessionCore(page);
|
||||
|
|
|
|||
|
|
@ -689,6 +689,7 @@ test.describe("session persistence", () => {
|
|||
const persona = await fetchAvailablePersona(page, 1);
|
||||
|
||||
await page.goto(`/learn/session/${persona.code}`);
|
||||
await page.locator("details.sx-prestart__settings > summary").click();
|
||||
const cbtButton = page.locator(".sx-theory").getByRole("button", { name: /CBT/ });
|
||||
await expect(cbtButton).toBeVisible();
|
||||
await cbtButton.click();
|
||||
|
|
|
|||
|
|
@ -394,6 +394,10 @@ test.describe("session review", () => {
|
|||
growthPoints: [],
|
||||
degraded: true,
|
||||
reviewReady: false,
|
||||
evaluationFailure: {
|
||||
code: "timeout",
|
||||
retryable: true,
|
||||
},
|
||||
teacherReview: {
|
||||
status: "pending",
|
||||
note: "",
|
||||
|
|
@ -440,6 +444,9 @@ test.describe("session review", () => {
|
|||
page.getByText("저장된 축어록은 확인했지만 deep-loop 평가 AI 산출물을 표시하지 못했습니다. AI 평가 재시도가 필요합니다."),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("engine_error: evaluator timeout")).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByText("평가 생성이 제한 시간 안에 끝나지 않았습니다. 축어록은 보존됐으며 다시 시도할 수 있습니다."),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "검토 완료" })).toBeDisabled();
|
||||
|
||||
|
|
@ -474,6 +481,10 @@ test.describe("session review", () => {
|
|||
growthPoints: [],
|
||||
degraded: true,
|
||||
reviewReady: false,
|
||||
evaluationFailure: {
|
||||
code: "timeout",
|
||||
retryable: true,
|
||||
},
|
||||
teacherReview: {
|
||||
status: "pending",
|
||||
note: "",
|
||||
|
|
@ -503,13 +514,110 @@ test.describe("session review", () => {
|
|||
await page.getByRole("button", { name: "AI 평가 재시도" }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText("AI 평가 재시도를 완료하지 못했습니다. 잠시 뒤 다시 실행해 주세요."),
|
||||
page.getByText("AI 평가가 제한 시간 안에 끝나지 않았습니다. 최신 상태를 다시 불러왔습니다."),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("engine_error: retry timeout")).toHaveCount(0);
|
||||
await expect(page.getByText("평가 실패")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toBeEnabled();
|
||||
expect(reevaluateRequests).toBe(1);
|
||||
expect(reviewRequests).toBe(reviewRequestsBeforeRetry);
|
||||
await expect.poll(() => reviewRequests).toBeGreaterThan(reviewRequestsBeforeRetry);
|
||||
});
|
||||
|
||||
test("does not offer a futile retry when the evaluation input is too large", async ({ page }) => {
|
||||
await routeSupervisorCapableLearner(page);
|
||||
await routePrepostMeasures(page);
|
||||
|
||||
const sessionId = "review-failed-input-too-large";
|
||||
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
|
||||
const ready = filledReviewResponse(sessionId);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
...ready,
|
||||
supervisorState: "평가 실패",
|
||||
summary:
|
||||
"저장된 축어록은 확인했지만 deep-loop 평가 AI 산출물을 표시하지 못했습니다. 평가 입력 경로를 조정한 뒤 다시 생성해야 합니다.",
|
||||
rubric: [],
|
||||
goodMoments: [],
|
||||
growthPoints: [],
|
||||
degraded: true,
|
||||
reviewReady: false,
|
||||
evaluationFailure: {
|
||||
code: "prompt_too_large",
|
||||
retryable: false,
|
||||
},
|
||||
teacherReview: {
|
||||
status: "pending",
|
||||
note: "",
|
||||
reviewedAt: null,
|
||||
reviewerId: null,
|
||||
updatedAt: null,
|
||||
worksheetStatus: "pending",
|
||||
worksheetNote: "",
|
||||
worksheetReviewedAt: null,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`/teach/session/${sessionId}/review`);
|
||||
await openReviewTab(page, "피드백");
|
||||
|
||||
await expect(page.getByText("평가 실패")).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("평가 입력이 허용 크기를 넘어섰습니다. 같은 재시도 대신 입력 경로를 조정해야 합니다."),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("offers recovery retry for a legacy Windows argv-limit evaluation", async ({ page }) => {
|
||||
await routeSupervisorCapableLearner(page);
|
||||
await routePrepostMeasures(page);
|
||||
|
||||
const sessionId = "review-legacy-windows-argv-limit";
|
||||
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
|
||||
const ready = filledReviewResponse(sessionId);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
...ready,
|
||||
supervisorState: "평가 실패",
|
||||
summary:
|
||||
"저장된 축어록은 확인했지만 deep-loop 평가 AI 산출물을 표시하지 못했습니다. AI 평가 재시도가 필요합니다.",
|
||||
rubric: [],
|
||||
goodMoments: [],
|
||||
growthPoints: [],
|
||||
degraded: true,
|
||||
reviewReady: false,
|
||||
evaluationFailure: {
|
||||
code: "legacy_argv_limit",
|
||||
retryable: true,
|
||||
},
|
||||
teacherReview: {
|
||||
status: "pending",
|
||||
note: "",
|
||||
reviewedAt: null,
|
||||
reviewerId: null,
|
||||
updatedAt: null,
|
||||
worksheetStatus: "pending",
|
||||
worksheetNote: "",
|
||||
worksheetReviewedAt: null,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`/teach/session/${sessionId}/review`);
|
||||
await openReviewTab(page, "피드백");
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
"이전 Windows 입력 한도에 걸린 평가입니다. 축어록은 보존됐으며 현재 입력 경로로 다시 시도할 수 있습니다.",
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("renders server review data without legacy transcript fixtures", async ({ page }) => {
|
||||
|
|
|
|||
138
apps/web/e2e/tabs-behavior.spec.ts
Normal file
138
apps/web/e2e/tabs-behavior.spec.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { expect, test, type Locator, type Page } from "@playwright/test";
|
||||
import { signInAsAdmin, signInAsTeacher } from "./support";
|
||||
|
||||
async function expectConnectedTabPanel(tab: Locator, panel: Locator) {
|
||||
const tabId = await tab.getAttribute("id");
|
||||
const panelId = await panel.getAttribute("id");
|
||||
expect(tabId).toBeTruthy();
|
||||
expect(panelId).toBeTruthy();
|
||||
await expect(tab).toHaveAttribute("aria-controls", panelId!);
|
||||
await expect(panel).toHaveAttribute("aria-labelledby", tabId!);
|
||||
}
|
||||
|
||||
async function expectKeyboardSelection(
|
||||
page: Page,
|
||||
tablistName: string,
|
||||
startName: string,
|
||||
key: "ArrowRight" | "ArrowLeft" | "Home" | "End",
|
||||
expectedName: string,
|
||||
) {
|
||||
const tablist = page.getByRole("tablist", { name: tablistName });
|
||||
const start = tablist.getByRole("tab", { name: startName });
|
||||
const expected = tablist.getByRole("tab", { name: expectedName });
|
||||
const panel = page.getByRole("tabpanel");
|
||||
|
||||
await start.focus();
|
||||
await start.press(key);
|
||||
await expect(expected).toBeFocused();
|
||||
await expect(expected).toHaveAttribute("aria-selected", "true");
|
||||
await expect(expected).toHaveAttribute("tabindex", "0");
|
||||
await expect(start).toHaveAttribute("tabindex", startName === expectedName ? "0" : "-1");
|
||||
await expectConnectedTabPanel(expected, panel);
|
||||
}
|
||||
|
||||
test.describe("공통 Tabs behavior", () => {
|
||||
test("관리자 사용자·접근 권한 탭이 패널 연결과 roving keyboard를 공유한다 @single-run", async ({
|
||||
page,
|
||||
}) => {
|
||||
await signInAsAdmin(page);
|
||||
|
||||
await page.goto("/admin/users");
|
||||
const userTabs = page.getByRole("tablist", { name: "사용자 관리 탭" });
|
||||
const approval = userTabs.getByRole("tab", { name: /가입 승인/ });
|
||||
const userPanel = page.getByRole("tabpanel");
|
||||
await expect(userTabs).toBeVisible();
|
||||
await expect(userTabs).toHaveAttribute("id", "admin-users-tabs");
|
||||
await expect(userPanel).toHaveAttribute("id", "admin-users-tabs-panel");
|
||||
await expect(userTabs).not.toHaveClass(/vg-surface/);
|
||||
await expectConnectedTabPanel(approval, userPanel);
|
||||
|
||||
await expectKeyboardSelection(
|
||||
page,
|
||||
"사용자 관리 탭",
|
||||
"가입 승인",
|
||||
"ArrowRight",
|
||||
"사용자 목록",
|
||||
);
|
||||
await expectKeyboardSelection(
|
||||
page,
|
||||
"사용자 관리 탭",
|
||||
"사용자 목록",
|
||||
"End",
|
||||
"활동 요약",
|
||||
);
|
||||
await expectKeyboardSelection(
|
||||
page,
|
||||
"사용자 관리 탭",
|
||||
"활동 요약",
|
||||
"Home",
|
||||
"가입 승인",
|
||||
);
|
||||
|
||||
await page.goto("/admin/access");
|
||||
const accessTabs = page.getByRole("tablist", { name: "접근 권한 탭" });
|
||||
const roles = accessTabs.getByRole("tab", { name: "역할", exact: true });
|
||||
const accessPanel = page.getByRole("tabpanel");
|
||||
await expect(accessTabs).toBeVisible();
|
||||
await expect(accessTabs).toHaveAttribute("id", "admin-access-tabs");
|
||||
await expect(accessPanel).toHaveAttribute("id", "admin-access-tabs-panel");
|
||||
await expect(accessTabs).not.toHaveClass(/vg-surface/);
|
||||
await expectConnectedTabPanel(roles, accessPanel);
|
||||
await expectKeyboardSelection(
|
||||
page,
|
||||
"접근 권한 탭",
|
||||
"역할",
|
||||
"End",
|
||||
"상담 프로토콜",
|
||||
);
|
||||
await expectKeyboardSelection(
|
||||
page,
|
||||
"접근 권한 탭",
|
||||
"상담 프로토콜",
|
||||
"Home",
|
||||
"역할",
|
||||
);
|
||||
});
|
||||
|
||||
test("페르소나 저작 탭이 같은 패널 연결과 방향키 계약을 사용한다 @single-run", async ({
|
||||
page,
|
||||
}) => {
|
||||
await signInAsTeacher(page);
|
||||
await page.route("**/api/personas", async (route) => {
|
||||
if (route.request().method() !== "GET") return route.fallback();
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([]),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/personas/review", async (route) => {
|
||||
if (route.request().method() !== "GET") return route.fallback();
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([]),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/teach/personas?view=personas&mode=create&step=edit");
|
||||
const tablist = page.getByRole("tablist", { name: "저작 섹션" });
|
||||
const overview = tablist.getByRole("tab", { name: "개요", exact: true });
|
||||
const panel = page.getByRole("tabpanel");
|
||||
await expect(tablist).toBeVisible();
|
||||
await expect(tablist).toHaveAttribute("id", "persona-authoring-tabs");
|
||||
await expect(panel).toHaveAttribute("id", "persona-authoring-tabs-panel");
|
||||
await expect(tablist).not.toHaveClass(/vg-surface/);
|
||||
await expectConnectedTabPanel(overview, panel);
|
||||
|
||||
await expectKeyboardSelection(
|
||||
page,
|
||||
"저작 섹션",
|
||||
"개요",
|
||||
"ArrowRight",
|
||||
"임상",
|
||||
);
|
||||
await expectKeyboardSelection(page, "저작 섹션", "임상", "End", "프롬프트");
|
||||
await expectKeyboardSelection(page, "저작 섹션", "프롬프트", "Home", "개요");
|
||||
});
|
||||
});
|
||||
|
|
@ -401,13 +401,23 @@ test.describe("uc learner practice/history", () => {
|
|||
card.getByRole("button", { name: item.action, exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
// 진행 중 카드에는 보관 버튼이 없고, 종료 카드에는 있다.
|
||||
await expect(
|
||||
cards.nth(0).getByRole("button", { name: "보관", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
cards.nth(2).getByRole("button", { name: "보관", exact: true }),
|
||||
).toBeVisible();
|
||||
// 진행 중 카드에는 보관·다음 회기 버튼이 없고, 종료 카드에는 있다.
|
||||
await expect(
|
||||
cards.nth(0).getByRole("button", { name: "보관", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
cards
|
||||
.nth(0)
|
||||
.getByRole("button", { name: "다음 회기 이어가기", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
cards.nth(2).getByRole("button", { name: "보관", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
cards
|
||||
.nth(2)
|
||||
.getByRole("button", { name: "다음 회기 이어가기", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
cards.nth(3).getByRole("button", { name: "복원", exact: true }),
|
||||
).toBeVisible();
|
||||
|
|
@ -428,8 +438,8 @@ test.describe("uc learner practice/history", () => {
|
|||
await expect(page).toHaveURL(new RegExp(`/learn/session/${REV_ID}/review$`));
|
||||
});
|
||||
|
||||
// usecase: 종료된 기록 카드에서 '다시 연습'을 눌러 같은 내담자로 새 회기를 연다.
|
||||
test("기록 카드의 다시 연습 버튼이 같은 내담자 새 회기로 이동한다", async ({
|
||||
// usecase: 종료된 기록 카드에서 다음 회기를 열어 같은 내담자의 맥락을 이어간다.
|
||||
test("종료 기록의 다음 회기 이어가기 버튼이 같은 내담자 새 회기로 이동한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installFixtures(page);
|
||||
|
|
@ -438,7 +448,7 @@ test.describe("uc learner practice/history", () => {
|
|||
const endCard = page.locator(".lh-session-card", { hasText: "김도윤" });
|
||||
await expect(endCard).toHaveCount(1);
|
||||
await endCard
|
||||
.getByRole("button", { name: "다시 연습", exact: true })
|
||||
.getByRole("button", { name: "다음 회기 이어가기", exact: true })
|
||||
.click();
|
||||
await expect(page).toHaveURL(/\/learn\/session\/P2$/);
|
||||
});
|
||||
|
|
|
|||
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,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
135
apps/web/e2e/uc-session-continuity-guard.spec.ts
Normal file
135
apps/web/e2e/uc-session-continuity-guard.spec.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
/* =====================================================================
|
||||
uc-session-continuity-guard.spec.ts — 같은 내담자 활성 회기 보호
|
||||
|
||||
목적: 종료된 회기가 아닌 같은 내담자의 진행 중 회기가 있을 때 새 회기를
|
||||
중복 생성하지 않고, 서버가 돌려준 기존 회기로 학습자를 이동시킨다.
|
||||
모든 응답은 route fixture이며 실제 AI 엔진/DB를 쓰지 않는다.
|
||||
===================================================================== */
|
||||
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const ACTIVE_SESSION_ID = "77777777-7777-4777-8777-777777777777";
|
||||
|
||||
function jsonRoute(body: unknown, status = 200) {
|
||||
return {
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
}
|
||||
|
||||
async function installFixture(page: Page) {
|
||||
let startAttempts = 0;
|
||||
|
||||
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: "00000000-0000-0000-0000-00000uccg301",
|
||||
email: "continuity.guard@hs.ac.kr",
|
||||
display_name: "연속성 검증 학습자",
|
||||
role: "learner",
|
||||
admin_access: false,
|
||||
super_admin: false,
|
||||
account_status: "approved",
|
||||
approval_required: false,
|
||||
cohort_ids: [],
|
||||
consent_at: Math.floor(Date.now() / 1000),
|
||||
onboarding_completed_at: Math.floor(Date.now() / 1000),
|
||||
nickname: "연속성 검증 학습자",
|
||||
self_introduction: "",
|
||||
avatar_url: "",
|
||||
}),
|
||||
),
|
||||
);
|
||||
await page.route("**/api/personas", (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute([
|
||||
{
|
||||
code: "P1",
|
||||
display_name: "민서(청소년 우울)",
|
||||
difficulty: "hard",
|
||||
theory_target: ["humanistic"],
|
||||
demographics: { age_band: "10대" },
|
||||
presenting_summary: "자퇴와 무기력감을 둘러싼 상담 연습",
|
||||
voice_preset: "soft-young-fem",
|
||||
source: "database",
|
||||
degraded: false,
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
await page.route("**/api/users/me/prepost-measures**", (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
pilot_id: "phase3-pilot-draft",
|
||||
instrument_version: "test",
|
||||
measures: [],
|
||||
complete_pre_count: 0,
|
||||
complete_post_count: 0,
|
||||
updated_at: null,
|
||||
}),
|
||||
),
|
||||
);
|
||||
await page.route("**/api/sessions", async (route) => {
|
||||
if (route.request().method() !== "POST") {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
startAttempts += 1;
|
||||
await route.fulfill(
|
||||
jsonRoute(
|
||||
{
|
||||
detail: {
|
||||
code: "active_session_exists",
|
||||
session_id: ACTIVE_SESSION_ID,
|
||||
},
|
||||
},
|
||||
409,
|
||||
),
|
||||
);
|
||||
});
|
||||
await page.route(`**/api/sessions/${ACTIVE_SESSION_ID}`, (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
session_id: ACTIVE_SESSION_ID,
|
||||
case_id: "uc-continuity-case-001",
|
||||
persona_code: "P1",
|
||||
persona_name: "민서",
|
||||
session_no: 1,
|
||||
status: "active",
|
||||
stage: "라포",
|
||||
theory_mode: "humanistic",
|
||||
effective_openness: 0.21,
|
||||
started_at: new Date().toISOString(),
|
||||
ended_at: null,
|
||||
review_ready: false,
|
||||
turns: [],
|
||||
goal_stages: ["라포"],
|
||||
progress: null,
|
||||
duration_limit_seconds: 3600,
|
||||
warning_before_end_seconds: 600,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return { startAttempts: () => startAttempts };
|
||||
}
|
||||
|
||||
test.describe("uc session continuity guard", () => {
|
||||
test("같은 내담자의 활성 회기 충돌은 기존 회기로 이어간다", async ({ page }) => {
|
||||
const fixture = await installFixture(page);
|
||||
await page.goto("/learn/session/P1");
|
||||
await expect(page.locator(".sx-page--prestart")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "회기 시작" }).click();
|
||||
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/learn/session/${ACTIVE_SESSION_ID}$`),
|
||||
);
|
||||
await expect(page.getByText("민서").first()).toBeVisible();
|
||||
expect(fixture.startAttempts()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -471,6 +471,34 @@ test.describe("회기 대화 — 텍스트 턴과 SSE 스트림", () => {
|
|||
).toBeVisible();
|
||||
expect(stream.texts[0]).toHaveLength(2000);
|
||||
expect(stream.texts[0]).toBe(longText);
|
||||
|
||||
const bubbleMetrics = await learnerUtterance(page)
|
||||
.locator(".sx-utt__line")
|
||||
.evaluate((element) => {
|
||||
const bubble = element as HTMLElement;
|
||||
const utterance = bubble.closest<HTMLElement>(".sx-utt");
|
||||
const body = bubble.closest<HTMLElement>(".sx-utt__body");
|
||||
if (!utterance || !body) return null;
|
||||
|
||||
const controls = Array.from(body.children).filter((child) => child !== bubble);
|
||||
const controlWidth = controls.reduce(
|
||||
(total, child) => total + (child as HTMLElement).getBoundingClientRect().width,
|
||||
0,
|
||||
);
|
||||
const gap = Number.parseFloat(getComputedStyle(body).gap) || 0;
|
||||
|
||||
return {
|
||||
availableWidth:
|
||||
utterance.getBoundingClientRect().width - controlWidth - gap * controls.length,
|
||||
bubbleWidth: bubble.getBoundingClientRect().width,
|
||||
bubbleClientWidth: bubble.clientWidth,
|
||||
bubbleScrollWidth: bubble.scrollWidth,
|
||||
};
|
||||
});
|
||||
|
||||
expect(bubbleMetrics).not.toBeNull();
|
||||
expect(bubbleMetrics!.bubbleWidth).toBeGreaterThanOrEqual(bubbleMetrics!.availableWidth - 1);
|
||||
expect(bubbleMetrics!.bubbleScrollWidth).toBeLessThanOrEqual(bubbleMetrics!.bubbleClientWidth + 1);
|
||||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -255,69 +255,75 @@ test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => {
|
|||
}
|
||||
});
|
||||
|
||||
// usecase: 학습자가 기본 선택된 회기 설정 요약(이론모드·목표)을 읽는다
|
||||
test("기본 회기 설정 요약이 인간중심 이론과 라포·탐색 목표를 보여준다", async ({
|
||||
// usecase: 학습자가 기본 회기 설정 요약과 첫 발화의 한 가지 초점을 읽는다
|
||||
test("기본 회기 설정 요약이 인간중심 이론과 라포 핵심 초점을 보여준다", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
}) => {
|
||||
await routePrestartFixture(page);
|
||||
await gotoPrestart(page);
|
||||
|
||||
const settingsSummary = page.locator("details.sx-prestart__settings > summary");
|
||||
await expect(settingsSummary).toContainText("인간중심, 라포 · 탐색");
|
||||
await expect(settingsSummary).toContainText("인간중심 · 라포");
|
||||
await expect(page.locator("details.sx-prestart__settings")).not.toHaveAttribute("open", "");
|
||||
|
||||
const goals = goalsGroup(page);
|
||||
await expect(goals.getByRole("button", { name: /라포/ })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
await expect(goals.getByRole("button", { name: /탐색/ })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
await expect(goals.getByRole("button", { name: /탐색/ })).toHaveAttribute("aria-pressed", "false");
|
||||
await expect(goals.getByRole("button", { name: /개입/ })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"false",
|
||||
);
|
||||
|
||||
if (!testInfo.project.name.includes("mobile")) {
|
||||
const plan = page.locator(".sx-prestart__plan");
|
||||
await expect(plan).toContainText("2개 선택");
|
||||
await expect(plan).toContainText("라포 · 탐색");
|
||||
await expect(plan).toContainText("60분 회기");
|
||||
await expect(plan).toContainText("종료 10분 전 알림");
|
||||
}
|
||||
const plan = page.locator(".sx-prestart__plan");
|
||||
await expect(plan).toContainText("준비");
|
||||
await expect(plan).toContainText("대화");
|
||||
await expect(plan).toContainText("리뷰·재연습");
|
||||
await expect(plan).toContainText("라포에 집중");
|
||||
});
|
||||
|
||||
// usecase: 키보드 사용자는 전역 탐색을 지나 곧바로 회기 준비 본문으로 이동한다.
|
||||
test("첫 Tab의 본문 건너뛰기가 회기 준비 본문에 포커스를 둔다", async ({ page }) => {
|
||||
await routePrestartFixture(page);
|
||||
await gotoPrestart(page);
|
||||
|
||||
const skipLink = page.getByRole("link", { name: "본문으로 건너뛰기" });
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(skipLink).toBeFocused();
|
||||
await expect(skipLink).toBeVisible();
|
||||
|
||||
await skipLink.press("Enter");
|
||||
await expect(page.locator("main#vg-main-content")).toBeFocused();
|
||||
});
|
||||
|
||||
// usecase: 학습자가 목표를 전부 해제해 보면 시작 버튼이 잠기고 안내를 받는다
|
||||
test("목표를 모두 해제하면 회기 시작이 차단되고 선택 안내가 남는다", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
}) => {
|
||||
await routePrestartFixture(page);
|
||||
await gotoPrestart(page);
|
||||
|
||||
const goals = goalsGroup(page);
|
||||
await expect(startButton(page)).toBeEnabled();
|
||||
await goals.getByRole("button", { name: /라포/ }).click();
|
||||
await goals.getByRole("button", { name: /탐색/ }).click();
|
||||
|
||||
await expect(goals.getByRole("button", { name: /라포/ })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"false",
|
||||
);
|
||||
await expect(goals.getByRole("button", { name: /탐색/ })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"false",
|
||||
);
|
||||
await expect(startButton(page)).toBeDisabled();
|
||||
await expect(
|
||||
page.locator("details.sx-prestart__settings > summary"),
|
||||
).toContainText("목표 선택 필요");
|
||||
if (!testInfo.project.name.includes("mobile")) {
|
||||
await expect(
|
||||
page.getByText("이번 회기 목표를 1개 이상 선택하면 시작할 수 있어요."),
|
||||
).toBeVisible();
|
||||
await expect(page.locator(".sx-prestart__plan")).toContainText("선택 필요");
|
||||
}
|
||||
).toContainText("핵심 초점 선택 필요");
|
||||
await expect(page.getByRole("heading", { name: "초점 선택 필요" })).toBeVisible();
|
||||
await expect(page.locator(".sx-prestart__plan")).toContainText(
|
||||
"목표 하나를 고르면 첫 발화의 기준이 여기에 표시됩니다.",
|
||||
);
|
||||
await expect(
|
||||
page.getByText("오늘의 핵심 초점을 1개 이상 선택하면 시작할 수 있어요."),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// usecase: 목표 0개 상태에서 학습자가 목표 하나를 고르면 다시 시작할 수 있다
|
||||
|
|
@ -327,7 +333,6 @@ test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => {
|
|||
|
||||
const goals = goalsGroup(page);
|
||||
await goals.getByRole("button", { name: /라포/ }).click();
|
||||
await goals.getByRole("button", { name: /탐색/ }).click();
|
||||
await expect(startButton(page)).toBeDisabled();
|
||||
|
||||
await goals.getByRole("button", { name: /개입/ }).click();
|
||||
|
|
@ -338,11 +343,11 @@ test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => {
|
|||
await expect(startButton(page)).toBeEnabled();
|
||||
await expect(
|
||||
page.locator("details.sx-prestart__settings > summary"),
|
||||
).toContainText("인간중심, 개입");
|
||||
).toContainText("인간중심 · 개입");
|
||||
});
|
||||
|
||||
// usecase: 학습자가 네 단계 목표를 전부 선택한다 — 선택지는 4개가 전부라 초과 선택은 불가능하다
|
||||
test("목표 4개를 모두 선택할 수 있고 선택지는 4개가 전부다", async ({ page }, testInfo) => {
|
||||
test("목표 4개를 모두 선택할 수 있고 선택지는 4개가 전부다", async ({ page }) => {
|
||||
await routePrestartFixture(page);
|
||||
await gotoPrestart(page);
|
||||
|
||||
|
|
@ -351,6 +356,7 @@ test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => {
|
|||
// 회기 단계는 라포·탐색·개입·정리 4개뿐 — 4개 초과 선택 자체가 불가능한 UI 계약.
|
||||
await expect(goalButtons).toHaveCount(4);
|
||||
|
||||
await goals.getByRole("button", { name: /탐색/ }).click();
|
||||
await goals.getByRole("button", { name: /개입/ }).click();
|
||||
await goals.getByRole("button", { name: /정리/ }).click();
|
||||
for (const name of [/라포/, /탐색/, /개입/, /정리/]) {
|
||||
|
|
@ -363,9 +369,114 @@ test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => {
|
|||
page.locator("details.sx-prestart__settings > summary"),
|
||||
).toContainText("라포 · 탐색 · 개입 · 정리");
|
||||
await expect(startButton(page)).toBeEnabled();
|
||||
if (!testInfo.project.name.includes("mobile")) {
|
||||
await expect(page.locator(".sx-prestart__plan")).toContainText("4개 선택");
|
||||
}
|
||||
});
|
||||
|
||||
// usecase: 보조 목표가 생겨도 '먼저 선택한 것'에 묶이지 않고, 학습자가 핵심 초점을 명시적으로 바꾼다.
|
||||
test("보조 목표를 핵심 초점으로 바꾸면 저장 순서도 함께 바뀐다", async ({ page }) => {
|
||||
const fixture = await routePrestartFixture(page);
|
||||
await gotoPrestart(page);
|
||||
|
||||
const goals = goalsGroup(page);
|
||||
await goals.getByRole("button", { name: /탐색/ }).click();
|
||||
|
||||
const primarySwitch = page.getByRole("group", { name: "핵심 초점 바꾸기" });
|
||||
await expect(page.locator(".sx-prestart__primary-switch")).toContainText("보조 목표");
|
||||
await primarySwitch.getByRole("button", { name: "탐색을 핵심 초점으로 설정" }).click();
|
||||
|
||||
await expect(page.locator(".sx-prestart__focus").getByRole("heading")).toHaveText("탐색");
|
||||
await expect(
|
||||
page.locator("details.sx-prestart__settings > summary"),
|
||||
).toContainText("인간중심 · 탐색 · 라포");
|
||||
|
||||
await startButton(page).click();
|
||||
expect(fixture.startRequests).toHaveLength(1);
|
||||
expect(fixture.startRequests[0]).toMatchObject({ goal_stages: ["탐색", "라포"] });
|
||||
});
|
||||
|
||||
// usecase: 높이가 짧은 데스크톱에서도 1~4개 목표의 핵심 전환과 시작 행동이 서로 가려지지 않는다.
|
||||
test("짧은 데스크톱에서 4개 목표와 회기 시작 행동이 겹치지 않는다", async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name.includes("mobile"), "데스크톱 중앙 열의 짧은 높이 회귀를 검증한다.");
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
const fixture = await routePrestartFixture(page);
|
||||
await gotoPrestart(page);
|
||||
|
||||
const goals = goalsGroup(page);
|
||||
await goals.getByRole("button", { name: /탐색/ }).click();
|
||||
await goals.getByRole("button", { name: /개입/ }).click();
|
||||
await goals.getByRole("button", { name: /정리/ }).click();
|
||||
|
||||
// 짧은 높이에서는 세 개의 큰 보조 버튼 대신 같은 선택지를 가진 native select를 쓴다.
|
||||
const compactPrimarySelect = page.locator(".sx-prestart__primary-select select");
|
||||
await expect(compactPrimarySelect).toBeVisible();
|
||||
await compactPrimarySelect.selectOption("탐색");
|
||||
await expect(page.locator(".sx-prestart__focus").getByRole("heading")).toHaveText("탐색");
|
||||
|
||||
const geometry = await page.evaluate(() => {
|
||||
const main = document.querySelector<HTMLElement>(".vg-main");
|
||||
const actions = document.querySelector<HTMLElement>(".sx-prestart__actions");
|
||||
const cta = actions?.querySelector<HTMLElement>(".vg-btn");
|
||||
const primaryControl = document.querySelector<HTMLElement>(".sx-prestart__primary-select select");
|
||||
if (!main || !actions || !cta || !primaryControl) throw new Error("prestart geometry targets missing");
|
||||
main.scrollTo({ top: 0 });
|
||||
const rect = (element: HTMLElement) => {
|
||||
const { top, bottom } = element.getBoundingClientRect();
|
||||
return { top, bottom };
|
||||
};
|
||||
return {
|
||||
main: rect(main),
|
||||
actions: rect(actions),
|
||||
cta: rect(cta),
|
||||
primaryControl: rect(primaryControl),
|
||||
hasHorizontalOverflow: document.documentElement.scrollWidth > window.innerWidth,
|
||||
};
|
||||
});
|
||||
expect(geometry.cta.bottom).toBeLessThanOrEqual(geometry.main.bottom);
|
||||
expect(geometry.primaryControl.bottom).toBeLessThanOrEqual(geometry.actions.top);
|
||||
expect(geometry.hasHorizontalOverflow).toBe(false);
|
||||
|
||||
await startButton(page).click();
|
||||
expect(fixture.startRequests).toHaveLength(1);
|
||||
expect(fixture.startRequests[0]).toMatchObject({
|
||||
goal_stages: ["탐색", "라포", "개입", "정리"],
|
||||
});
|
||||
});
|
||||
|
||||
// usecase: 모바일에서는 읽는 순서와 bottom bar 위의 시작 행동을 동시에 보장한다.
|
||||
test("모바일에서 사례·학습·재연습 순서와 시작 CTA 안전 간격을 지킨다", async ({ page }, testInfo) => {
|
||||
test.skip(!testInfo.project.name.includes("mobile"), "모바일 문서 순서와 bottom bar 관계를 검증한다.");
|
||||
await routePrestartFixture(page);
|
||||
await gotoPrestart(page);
|
||||
|
||||
const geometry = await page.evaluate(() => {
|
||||
const main = document.querySelector<HTMLElement>(".vg-main");
|
||||
const caseCard = document.querySelector<HTMLElement>(".sx-prestart__visual");
|
||||
const learning = document.querySelector<HTMLElement>(".sx-prestart__learning");
|
||||
const map = document.querySelector<HTMLElement>(".sx-prestart__plan");
|
||||
const actions = document.querySelector<HTMLElement>(".sx-prestart__actions");
|
||||
const cta = actions?.querySelector<HTMLElement>(".vg-btn");
|
||||
const bottomNav = document.querySelector<HTMLElement>('nav[aria-label="주 메뉴"]');
|
||||
if (!main || !caseCard || !learning || !map || !actions || !cta || !bottomNav) {
|
||||
throw new Error("mobile prestart geometry targets missing");
|
||||
}
|
||||
main.scrollTo({ top: 0 });
|
||||
const rect = (element: HTMLElement) => {
|
||||
const { top, bottom } = element.getBoundingClientRect();
|
||||
return { top, bottom };
|
||||
};
|
||||
return {
|
||||
caseCard: rect(caseCard),
|
||||
learning: rect(learning),
|
||||
map: rect(map),
|
||||
actions: rect(actions),
|
||||
cta: rect(cta),
|
||||
bottomNav: rect(bottomNav),
|
||||
hasHorizontalOverflow: document.documentElement.scrollWidth > window.innerWidth,
|
||||
};
|
||||
});
|
||||
expect(geometry.caseCard.top).toBeLessThan(geometry.learning.top);
|
||||
expect(geometry.learning.top).toBeLessThan(geometry.map.top);
|
||||
expect(geometry.cta.bottom).toBeLessThanOrEqual(geometry.bottomNav.top - 8);
|
||||
expect(geometry.hasHorizontalOverflow).toBe(false);
|
||||
});
|
||||
|
||||
// usecase: 학습자가 선택돼 있던 목표 하나를 다시 눌러 해제한다
|
||||
|
|
@ -375,13 +486,16 @@ test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => {
|
|||
|
||||
const goals = goalsGroup(page);
|
||||
const exploreGoal = goals.getByRole("button", { name: /탐색/ });
|
||||
await expect(exploreGoal).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
await exploreGoal.click();
|
||||
await expect(exploreGoal).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
await exploreGoal.click();
|
||||
await expect(exploreGoal).toHaveAttribute("aria-pressed", "false");
|
||||
await expect(
|
||||
page.locator("details.sx-prestart__settings > summary"),
|
||||
).toContainText("인간중심, 라포");
|
||||
).toContainText("인간중심 · 라포");
|
||||
// 나머지 기본 목표는 그대로 유지된다.
|
||||
await expect(goals.getByRole("button", { name: /라포/ })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
|
|
@ -393,10 +507,11 @@ test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => {
|
|||
// usecase: 학습자가 이론모드를 CBT로 바꿔 회기 접근을 조정한다
|
||||
test("이론모드를 CBT로 바꾸면 선택 상태와 진행 초점이 갱신된다", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
}) => {
|
||||
await routePrestartFixture(page);
|
||||
await gotoPrestart(page);
|
||||
|
||||
await page.locator("details.sx-prestart__settings > summary").click();
|
||||
const theory = page.getByRole("group", { name: "이론모드 선택" });
|
||||
await expect(theory.getByRole("button", { name: /인간중심/ })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
|
|
@ -414,12 +529,10 @@ test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => {
|
|||
);
|
||||
await expect(
|
||||
page.locator("details.sx-prestart__settings > summary"),
|
||||
).toContainText("CBT, 라포 · 탐색");
|
||||
if (!testInfo.project.name.includes("mobile")) {
|
||||
await expect(page.locator(".sx-prestart__plan")).toContainText(
|
||||
"상황·생각·감정·행동의 연결을 한 단계씩 확인합니다.",
|
||||
);
|
||||
}
|
||||
).toContainText("CBT · 라포");
|
||||
await expect(page.locator(".sx-prestart__settings-note")).toContainText(
|
||||
"상황·생각·감정·행동의 연결을 한 단계씩 확인합니다.",
|
||||
);
|
||||
});
|
||||
|
||||
// usecase: 학습자가 회기 시작을 눌러 active 회기로 넘어간다 — 고른 목표·이론이 요청에 실린다
|
||||
|
|
@ -429,8 +542,7 @@ test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => {
|
|||
const fixture = await routePrestartFixture(page);
|
||||
await gotoPrestart(page);
|
||||
|
||||
// 기본 선택에서 탐색을 해제해 라포 1개만 목표로 남긴다(자유 선택 검증).
|
||||
await goalsGroup(page).getByRole("button", { name: /탐색/ }).click();
|
||||
// 기본 선택인 라포 한 가지 초점으로 시작한다.
|
||||
await startButton(page).click();
|
||||
|
||||
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
|
||||
|
|
@ -465,6 +577,29 @@ test.describe("usecase — 회기 사전 설정(목표 선택) 여정", () => {
|
|||
await expect(startButton(page)).toBeEnabled();
|
||||
});
|
||||
|
||||
// usecase: 단축키 도움말은 모달 선언과 같은 포커스 규칙을 지키며, 닫으면 호출자로 돌아간다.
|
||||
test("단축키 도움말은 포커스를 가두고 닫으면 시작 행동으로 돌려준다", async ({ page }) => {
|
||||
await routePrestartFixture(page);
|
||||
await gotoPrestart(page);
|
||||
|
||||
const start = startButton(page);
|
||||
await start.focus();
|
||||
await expect(start).toBeFocused();
|
||||
// Playwright에서는 punctuation chord보다 명시한 Ctrl+/ 계약이 키보드 레이아웃에 독립적이다.
|
||||
await start.press("Control+/");
|
||||
|
||||
const dialog = page.getByRole("dialog", { name: "키보드 단축키 안내" });
|
||||
const confirm = dialog.getByRole("button", { name: "확인 (Esc)" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(confirm).toBeFocused();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(confirm).toBeFocused();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(start).toBeFocused();
|
||||
});
|
||||
|
||||
// usecase: 리뷰 처방(G4 deliberate)에서 넘어온 학습자가 처방 맥락을 프리스타트에서 확인한다
|
||||
test("G4 처방 연습 launch intent가 프리스타트에 난도 사다리 맥락으로 표시된다", async ({
|
||||
page,
|
||||
|
|
|
|||
|
|
@ -141,6 +141,14 @@ async function mountSettingsFixtures(
|
|||
|
||||
await routeUnmockedApi(page);
|
||||
|
||||
await page.route("**/api/static/avatars/*.png", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "image/png",
|
||||
body: Buffer.from(PNG_1X1_BASE64, "base64"),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.route("**/api/auth/me", (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
|
|
@ -471,6 +479,44 @@ test.describe("설정·동의 관리 유스케이스", () => {
|
|||
await expect(account.locator(".vg-set__saved")).toHaveCount(0);
|
||||
});
|
||||
|
||||
// usecase: DB에 남은 아바타 경로의 실제 파일이 사라져 404여도 깨진 이미지 아이콘 대신 이니셜을 유지한다
|
||||
test("아바타 파일 404 시 톱바와 설정 미리보기 모두 이니셜 폴백을 유지한다", async ({ page }) => {
|
||||
const staleAvatar = "/uploads/profile-avatars/stale-avatar.jpg";
|
||||
await mountSettingsFixtures(page, { avatarUrl: staleAvatar });
|
||||
await page.route("**/api/uploads/profile-avatars/stale-avatar.jpg", (route) =>
|
||||
route.fulfill(jsonRoute({ detail: "Not Found" }, 404)),
|
||||
);
|
||||
await openLoadedSettings(page);
|
||||
|
||||
const avatarFrames = page.locator(
|
||||
".vg-topbar__avatar, .vg-set__rail-avatar, .vg-set__avatar",
|
||||
);
|
||||
await expect(avatarFrames).toHaveCount(3);
|
||||
for (const frame of await avatarFrames.all()) {
|
||||
await expect(frame.locator("[data-image-fallback='failed']")).toHaveText("설");
|
||||
await expect(frame.locator("img")).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
// usecase: 저장값이 웹 이미지가 아닌 scheme이면 요청하지 않고 즉시 안전한 이니셜 상태로 닫힌다
|
||||
test("빈 URL과 비웹 scheme은 네트워크 요청 없이 이니셜 폴백으로 정규화한다", async ({ page }) => {
|
||||
let unsafeRequestCount = 0;
|
||||
page.on("request", (request) => {
|
||||
if (/javascript:|data:|file:/i.test(request.url())) unsafeRequestCount += 1;
|
||||
});
|
||||
await mountSettingsFixtures(page, { avatarUrl: " javascript:alert(1) " });
|
||||
await openLoadedSettings(page);
|
||||
|
||||
const avatarFrames = page.locator(
|
||||
".vg-topbar__avatar, .vg-set__rail-avatar, .vg-set__avatar",
|
||||
);
|
||||
for (const frame of await avatarFrames.all()) {
|
||||
await expect(frame.locator("[data-image-fallback='empty']")).toHaveText("설");
|
||||
await expect(frame.locator("img")).toHaveCount(0);
|
||||
}
|
||||
expect(unsafeRequestCount).toBe(0);
|
||||
});
|
||||
|
||||
// usecase: 학습자가 표시 이름을 빈값으로 저장 시도했다가 서버 거절을 받는다
|
||||
test("표시 이름을 빈값으로 저장하면 거짓 저장됨 표시 없이 입력 상태가 유지된다", async ({
|
||||
page,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue