유스케이스 TDD 16테마 스펙과 접근성 제품 결함 수정

- 지원 티켓 작성 UI 신설(설정)·관리자 해결 노트 입력 신설(Admin): 서버 계약은 있었으나
  웹 진입점이 없던 2결함
- a11y: outline 채널 포커스 링(ui/shell css), 세션바 44px 터치 타깃, 청록 하드코딩
  그라디언트를 테마 토큰으로 교체, 설정 라벨/헤딩/대비·리뷰 44px·모바일 오버플로 수정
- uc-*.spec.ts 16테마 239 시나리오 신규(수집 1109 tests/61 files), 기존 스펙 5종 계약 드리프트 교정
- breakpoint-sweep: widthsFor 솎아내기가 실기기 대표 폭(360/390/1024)을 탈락시키는 테스트 결함 수정
  — keep 시드를 전체 DEVICE_WIDTHS로, 3연폭 예외는 솎아내기 발생 여부 기준으로
- 검증: tsc PASS, 병렬 게이트 1040 passed(데스크톱 밀도 충돌 1건 해소 후 focused 68/68 GREEN),
  직렬 게이트 52/5/4 삼각화 — breakpoint(테스트 결함)·kb(낡은 dev API 재기동)·voice(일시적) 해소,
  교사 재평가 2건은 엔진 구독 한도(resets 3pm)로 skip 후 재검증 대기
- 문서/SSOT: HANDOFF·TODO·대시보드·testing 가이드 동기화, 증거 usecase-tdd-2026-08-18.json,
  SSOT 체커 PASS(59)
This commit is contained in:
Yun Chan 2026-08-18 11:47:58 +09:00
parent 4771b97c3a
commit cf899b7f15
37 changed files with 11989 additions and 67 deletions

View file

@ -0,0 +1,799 @@
/* =====================================================================
uc-error-resilience-shell.spec.ts 유스케이스: 오류 ··
목적: 서버가 (), (), 5xx/401 ,
· / /
· ·
· ·raw JSON·raw UUID·[NAME]
.
( ):
- src/App.tsx: AuthRestoreGate(auth-restore-retrying/failed/retry testid),
RouteErrorBoundary + RouteErrorFallback("화면을 표시하지 못했습니다",
route-error-reload), BootScreen("불러오는 중…"), RequireAuth.
- src/lib/auth.tsx: restoreCurrentUser (0/400/1000ms), AUTH_EXPIRED_EVENT
user를 /login으로 .
- src/lib/api.ts: ApiError , 401 notifyAuthExpired .
- src/lib/piiDisplay.ts: displayPiiSafeText [NAME] .
- src/pages/LearnerHome.tsx + learner-home/model.ts: 로딩 ,
"…불러오지 못했습니다" , (role=status) ,
toggleArchiveSession(archiveBusyId , notice),
displayPiiSafeText .
- src/pages/Session.tsx: personaLoadState missing/error
(sx-prestart__note), .
- src/components/shell/Topbar.tsx: 오류 .
:
- full-sweep-shell.spec.ts: suspense fallback·stale chunk 1 reload·
unknown route· /login ·pending .
- uc-session-conversation.spec.ts: 500 · ·
[NAME] ( ).
- uc-settings-consent.spec.ts: 설정 · .
- uc-session-review-core.spec.ts: 리뷰 raw UUID·
( ).
route fixture (mocked-auth , catch-all )
AI .
===================================================================== */
import { expect, test, type Page } from "@playwright/test";
const NOW = Date.now();
const HOUR = 60 * 60 * 1000;
const iso = (msAgo: number) => new Date(NOW - msAgo).toISOString();
// raw UUID 비노출 검증용 — 화면 어디에도 그대로 보여선 안 되는 식별자.
const ACTIVE_UUID = "9c8d7e6f-5a4b-4c3d-8e2f-1a0b9c8d7e6f";
const ENDED_UUID = "3f2b8a9e-6c1d-4e5f-9a7b-2c8d4e6f1a3b";
function jsonRoute(body: unknown, status = 200) {
return {
status,
contentType: "application/json",
body: JSON.stringify(body),
};
}
/** catch-all: fixture로 지정하지 않은 API는 404. 반드시 가장 먼저 등록. */
async function routeUnmockedApi(page: Page) {
await page.route("**/api/**", (route) =>
route.fulfill(jsonRoute({ detail: "not part of this focused fixture" }, 404)),
);
}
/** userFromMe(auth.tsx)가 읽는 필드를 빠짐없이 채운 승인 완료 학습자. */
const ME_LEARNER = {
user_id: "e2e-uc-resilience-1",
email: "learner@hs.ac.kr",
role: "learner",
display_name: "학습자",
admin_access: false,
super_admin: false,
account_status: "approved",
approval_required: false,
cohort_ids: [],
consent_at: 1782820000,
onboarding_completed_at: 1782820001,
nickname: "학습자",
self_introduction: "",
avatar_url: "",
};
async function routeAuthedLearner(page: Page) {
await page.route("**/api/auth/me", (route) =>
route.fulfill(jsonRoute(ME_LEARNER)),
);
}
const PERSONAS_BODY = [
{
code: "P1",
display_name: "성하늘",
difficulty: "easy",
theory_target: ["humanistic"],
demographics: { age_band: "20대" },
presenting_summary: "학업 스트레스와 수면 문제를 호소",
voice_preset: null,
source: "database",
degraded: false,
},
{
code: "P2",
display_name: "김도윤",
difficulty: "moderate",
theory_target: ["cbt"],
demographics: { age_band: "30대" },
presenting_summary: "직장 내 대인관계 갈등을 호소",
voice_preset: null,
source: "database",
degraded: false,
},
];
function activeSession(sessionId: string) {
return {
session_id: sessionId,
session_no: 2,
persona_code: "P1",
persona_name: "성하늘",
status: "active",
stage: "탐색",
started_at: iso(1 * HOUR),
ended_at: null,
review_ready: false,
archived: false,
archived_at: null,
turn_count: 6,
learner_turn_count: 3,
client_turn_count: 3,
};
}
function endedSession(sessionId: string, archived = false) {
return {
session_id: sessionId,
session_no: 1,
persona_code: "P2",
persona_name: "김도윤",
status: "ended",
stage: "라포",
started_at: iso(5 * HOUR),
ended_at: iso(4.5 * HOUR),
review_ready: false,
archived,
archived_at: archived ? iso(0.1 * HOUR) : null,
turn_count: 4,
learner_turn_count: 2,
client_turn_count: 2,
};
}
const DASHBOARD_BODY = {
message: "학습 현황 요약",
source: "runtime",
overview: {
total_sessions: 2,
active_sessions: 1,
review_ready_sessions: 0,
completed_sessions: 1,
archived_sessions: 0,
learner_turns: 5,
client_turns: 5,
last_practiced_at: iso(1 * HOUR),
},
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: [],
};
const EMPTY_DASHBOARD = {
...DASHBOARD_BODY,
overview: {
...DASHBOARD_BODY.overview,
total_sessions: 0,
active_sessions: 0,
completed_sessions: 0,
learner_turns: 0,
client_turns: 0,
last_practiced_at: null,
},
};
/** 학습자 홈 정상 데이터 fixture 일괄 등록 (catch-all·auth 포함). */
async function installLearnerFixtures(
page: Page,
options: {
sessions?: () => unknown[];
dashboard?: unknown;
} = {},
) {
const { sessions, dashboard = DASHBOARD_BODY } = options;
await routeUnmockedApi(page);
await routeAuthedLearner(page);
await page.route(/\/api\/personas$/, (route) =>
route.fulfill(jsonRoute(PERSONAS_BODY)),
);
await page.route(/\/api\/sessions$/, (route) =>
route.fulfill(
jsonRoute({
sessions: sessions
? sessions()
: [activeSession("ucres-act1"), endedSession("ucres-end1")],
source: "runtime",
}),
),
);
await page.route(/\/api\/sessions\/dashboard$/, (route) =>
route.fulfill(jsonRoute(dashboard)),
);
}
/** Login.tsx가 GET /auth/config로 읽는 OAuth 준비 fixture. */
const READY_AUTH_CONFIG = {
google_oauth_configured: true,
saml_configured: false,
providers: [
{
provider: "google",
configured: true,
enabled: true,
login_path: "/auth/login?provider=google",
},
],
allowed_email_domains: ["hs.ac.kr", "twentyoz.kr"],
redirect_uri: "http://127.0.0.1:8000/auth/callback",
dev_login_enabled: false,
};
test.describe("uc error-resilience-shell", () => {
// usecase: 서버가 느릴 때 학습자는 빈 화면·오류 대신 중립 로딩 화면을 보다가, 응답이 오면 그대로 홈에 진입한다
test("인증 부트스트랩이 지연되는 동안 중립 로딩을 유지하고 응답 후 정상 진입한다", async ({
page,
}) => {
let releaseMe!: () => void;
const meGate = new Promise<void>((resolve) => {
releaseMe = resolve;
});
await routeUnmockedApi(page);
await page.route("**/api/auth/me", async (route) => {
await meGate;
await route.fulfill(jsonRoute(ME_LEARNER));
});
await page.route(/\/api\/personas$/, (route) =>
route.fulfill(jsonRoute(PERSONAS_BODY)),
);
await page.route(/\/api\/sessions$/, (route) =>
route.fulfill(
jsonRoute({
sessions: [activeSession("ucres-act1"), endedSession("ucres-end1")],
source: "runtime",
}),
),
);
await page.route(/\/api\/sessions\/dashboard$/, (route) =>
route.fulfill(jsonRoute(DASHBOARD_BODY)),
);
await page.goto("/learn");
// 응답 전: 중립 BootScreen만 보이고 오류·로그인 회수가 없다.
await expect(page.getByText("불러오는 중…")).toBeVisible();
await expect(page).toHaveURL(/\/learn$/);
await expect(page.getByTestId("auth-restore-failed")).toHaveCount(0);
releaseMe();
// 응답 후: 같은 주소에서 학습자 홈이 그대로 열린다.
await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 });
await expect(page).toHaveURL(/\/learn$/);
});
// usecase: 서버 재구동 직후처럼 연결이 끊겼을 때 학습자는 세션을 잃었다는 오해 대신 재연결 안내와 복구 실패 진단을 본다
test("부트스트랩 연결 단절 시 재연결 안내를 거쳐 진단 화면과 재연결 버튼을 보여준다", async ({
page,
}) => {
// 1·2번째 시도는 즉시 실패, 3번째 시도는 게이트로 붙잡아
// '재연결 중' 화면을 결정론적으로 관찰한 뒤 실패를 확정한다.
let attempts = 0;
let releaseFinal!: () => void;
const finalGate = new Promise<void>((resolve) => {
releaseFinal = resolve;
});
await routeUnmockedApi(page);
await page.route("**/api/auth/me", async (route) => {
attempts += 1;
if (attempts >= 3) await finalGate;
await route.abort().catch(() => {});
});
await page.goto("/learn");
// 재시도 동안: 세션을 버리지 않는다는 재연결 안내(role=status).
const retrying = page.getByTestId("auth-restore-retrying");
await expect(retrying).toBeVisible();
await expect(retrying).toContainText("서버 재연결");
await expect(retrying).toContainText("로그인 권한을 다시 확인하고 있습니다.");
releaseFinal();
// 재시도 소진 후: 권한 상실이 아님을 못박는 진단 화면 + 재연결 버튼.
const failed = page.getByTestId("auth-restore-failed");
await expect(failed).toBeVisible();
await expect(failed).toContainText("서버 연결 진단");
await expect(failed).toContainText("관리자 권한이 사라진 것이 아닙니다.");
await expect(page.getByTestId("auth-restore-retry")).toBeVisible();
// 이 화면은 로그인으로 튕기지 않고 같은 주소에서 복구를 기다린다.
await expect(page).toHaveURL(/\/learn$/);
expect(attempts).toBe(3);
});
// usecase: 서버가 정상화된 뒤 학습자가 '서버에 다시 연결'을 눌러 재로그인 없이 같은 세션으로 복귀한다
test("복구 실패 화면에서 서버 정상화 후 재연결 버튼으로 같은 세션에 복귀한다", async ({
page,
}) => {
let apiUp = false;
await routeUnmockedApi(page);
await page.route("**/api/auth/me", async (route) => {
if (!apiUp) {
await route.abort().catch(() => {});
return;
}
await route.fulfill(jsonRoute(ME_LEARNER));
});
await page.route(/\/api\/personas$/, (route) =>
route.fulfill(jsonRoute(PERSONAS_BODY)),
);
await page.route(/\/api\/sessions$/, (route) =>
route.fulfill(
jsonRoute({
sessions: [activeSession("ucres-act1"), endedSession("ucres-end1")],
source: "runtime",
}),
),
);
await page.route(/\/api\/sessions\/dashboard$/, (route) =>
route.fulfill(jsonRoute(DASHBOARD_BODY)),
);
await page.goto("/learn");
await expect(page.getByTestId("auth-restore-failed")).toBeVisible({
timeout: 15_000,
});
apiUp = true;
await page.getByTestId("auth-restore-retry").click();
// 재로그인(/login) 없이 같은 주소에서 학습자 홈이 복원된다.
await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 });
await expect(page).toHaveURL(/\/learn$/);
await expect(page.locator(".vg-topbar__uname")).toHaveText("학습자");
});
// usecase: 데이터 API가 느릴 때 학습자는 연습 화면에서 스켈레톤·확인 문구를 보다가 응답이 오면 목록으로 전환된다
test("연습 화면 데이터 지연 동안 스켈레톤을 보이다가 응답 후 목록으로 전환된다", async ({
page,
}) => {
let releaseData!: () => void;
const dataGate = new Promise<void>((resolve) => {
releaseData = resolve;
});
await routeUnmockedApi(page);
await routeAuthedLearner(page);
await page.route(/\/api\/personas$/, async (route) => {
await dataGate;
await route.fulfill(jsonRoute(PERSONAS_BODY));
});
await page.route(/\/api\/sessions$/, async (route) => {
await dataGate;
await route.fulfill(
jsonRoute({
sessions: [activeSession("ucres-act1"), endedSession("ucres-end1")],
source: "runtime",
}),
);
});
await page.route(/\/api\/sessions\/dashboard$/, (route) =>
route.fulfill(jsonRoute(DASHBOARD_BODY)),
);
await page.goto("/learn/practice");
// 지연 동안: 페르소나 스켈레톤 3개 + 기록 확인 문구, 오류 문구는 없다.
await expect(page.locator(".lh-persona.is-skel")).toHaveCount(3);
await expect(
page.getByText("저장된 연습 기록을 확인하고 있습니다."),
).toBeVisible();
await expect(page.locator("body")).not.toContainText("불러오지 못했습니다");
releaseData();
// 응답 후: 실제 페르소나 목록과 기존 회기 액션으로 전환된다.
const listbox = page.getByRole("listbox", { name: "연습 페르소나" });
await expect(listbox.getByRole("option", { name: /P1/ })).toBeVisible();
await expect(page.locator(".lh-persona.is-skel")).toHaveCount(0);
await expect(
page.locator(".lh-activity").getByRole("button", { name: "이어하기" }),
).toBeVisible();
});
// usecase: 네트워크가 끊겨도 학습자는 셸(톱바) 안에서 자연어 오류를 보고, 오류가 빈 기록 상태로 위장되지 않는다
test("네트워크 단절 시 자연어 오류를 표시하고 셸을 유지하며 빈 상태와 구분한다", async ({
page,
}) => {
await routeUnmockedApi(page);
await routeAuthedLearner(page);
await page.route(/\/api\/personas$/, (route) => route.abort());
await page.route(/\/api\/sessions$/, (route) => route.abort());
await page.route(/\/api\/sessions\/dashboard$/, (route) => route.abort());
await page.goto("/learn/practice");
// 페르소나 목록 실패: role=alert 자연어 안내.
const personaError = page.locator(".lh-error");
await expect(personaError).toBeVisible();
await expect(personaError).toContainText("내담자 목록을 불러오지 못했습니다.");
await expect(personaError).toContainText("잠시 뒤 다시 시도해 주세요.");
// 기록 실패: 인라인 자연어 안내.
await expect(
page.getByText("연습 기록을 불러오지 못했습니다."),
).toBeVisible();
// 실패를 '기록 없음' 빈 상태로 위장하지 않는다.
await expect(page.locator("body")).not.toContainText(
"저장된 기존 회기가 없습니다.",
);
// 공통 셸은 살아 있어 로그아웃 등 탈출 경로가 유지된다.
await expect(page.locator(".vg-topbar")).toBeVisible();
await expect(page.getByRole("button", { name: "로그아웃" })).toBeVisible();
});
// usecase: 서버가 스택트레이스가 담긴 5xx를 돌려줘도 학습자는 자연어 안내만 보고 내부 오류 본문은 보지 않는다
test("5xx 응답의 스택트레이스·raw JSON이 화면에 노출되지 않는다", async ({
page,
}) => {
const scaryBody = {
detail:
'Traceback (most recent call last):\n File "app/routes/sessions.py", line 42, in list_sessions\nKeyError: \'session_rows\'',
};
await routeUnmockedApi(page);
await routeAuthedLearner(page);
await page.route(/\/api\/personas$/, (route) =>
route.fulfill(jsonRoute(scaryBody, 500)),
);
await page.route(/\/api\/sessions$/, (route) =>
route.fulfill(jsonRoute(scaryBody, 500)),
);
await page.route(/\/api\/sessions\/dashboard$/, (route) =>
route.fulfill(jsonRoute(scaryBody, 500)),
);
await page.goto("/learn");
// 자연어 오류 표면만 보인다.
await expect(
page.getByRole("heading", {
name: "코칭을 만들 기록을 불러오지 못했습니다.",
}),
).toBeVisible();
await expect(
page.getByText("최근 평가 피드백을 불러오지 못했습니다."),
).toBeVisible();
await expect(page.locator(".lh-dashboard-feedback")).toContainText(
"불러오기 실패",
);
// 내부 오류 본문·상태 표현은 어디에도 없다.
const body = page.locator("body");
await expect(body).not.toContainText("Traceback");
await expect(body).not.toContainText("KeyError");
await expect(body).not.toContainText('"detail"');
await expect(body).not.toContainText("API 500");
});
// usecase: 사용 중 세션이 만료(401)되면 진행하던 화면 대신 로그인 화면으로 안전하게 회수된다
test("사용 중 401이 발생하면 로그인 화면으로 복귀한다", async ({ page }) => {
await installLearnerFixtures(page);
await page.route("**/api/auth/config", (route) =>
route.fulfill(jsonRoute(READY_AUTH_CONFIG)),
);
await page.route("**/api/sessions/ucres-end1/archive", (route) =>
route.fulfill(jsonRoute({ detail: "session expired" }, 401)),
);
await page.goto("/learn/history");
const cards = page.locator(".lh-session-list");
await expect(cards).toBeVisible();
// 보관 액션이 401을 받으면 전역 인증 만료로 처리되어 /login으로 회수된다.
await cards.getByRole("button", { name: "보관", exact: true }).click();
await expect(page).toHaveURL(/\/login$/);
await expect(
page.getByRole("button", { name: /학교 Google 계정으로 계속/ }),
).toBeVisible();
});
// usecase: 보관이 서버 오류로 실패해도 학습자의 검색어와 기록 상태는 사라지지 않고 실패 안내를 받는다
test("보관 실패(500) 시 실패 안내가 뜨고 검색어·기록 상태가 보존된다", async ({
page,
}) => {
await installLearnerFixtures(page);
await page.route("**/api/sessions/ucres-end1/archive", (route) =>
route.fulfill(jsonRoute({ detail: "internal error" }, 500)),
);
await page.goto("/learn/history");
const search = page.getByRole("textbox", { name: "기록 검색" });
await search.fill("김도윤");
const list = page.locator(".lh-session-list");
await expect(list.locator(".lh-session-card")).toHaveCount(1);
const archiveButton = list.getByRole("button", { name: "보관", exact: true });
await archiveButton.click();
// 실패 안내(role=status)와 함께 상태가 그대로 남는다.
await expect(
page.getByText("보관 상태를 바꾸지 못했습니다."),
).toBeVisible();
await expect(search).toHaveValue("김도윤");
await expect(archiveButton).toBeVisible();
await expect(archiveButton).toBeEnabled();
await expect(list.locator(".lh-session-card")).toHaveCount(1);
// 실패인데 성공 문구로 위장하지 않는다.
await expect(page.locator("body")).not.toContainText("회기를 보관했습니다.");
});
// usecase: 학습자가 보관을 조급하게 연타해도 요청은 한 번만 나가고, 처리 중에는 버튼이 잠긴다
test("보관 더블클릭 시 요청이 1회만 전송되고 처리 중 버튼이 잠긴다", async ({
page,
}) => {
let archiveCalls = 0;
let archivedNow = false;
let releaseArchive!: () => void;
const archiveGate = new Promise<void>((resolve) => {
releaseArchive = resolve;
});
await installLearnerFixtures(page, {
sessions: () => [
activeSession("ucres-act1"),
endedSession("ucres-end1", archivedNow),
],
});
await page.route("**/api/sessions/ucres-end1/archive", async (route) => {
archiveCalls += 1;
await archiveGate;
archivedNow = true;
await route.fulfill(
jsonRoute({
session_id: "ucres-end1",
archived: true,
archived_at: iso(0),
}),
);
});
await page.goto("/learn/history");
const list = page.locator(".lh-session-list");
const archiveButton = list.getByRole("button", { name: "보관", exact: true });
await expect(archiveButton).toBeVisible();
await archiveButton.dblclick();
// 처리 중: 같은 자리 버튼이 '처리 중'으로 잠긴다.
const busyButton = list.getByRole("button", { name: "처리 중" });
await expect(busyButton).toBeVisible();
await expect(busyButton).toBeDisabled();
releaseArchive();
// 성공 안내 후 재조회된 목록에서 카드가 '복원' 액션으로 바뀐다.
await expect(page.getByText("회기를 보관했습니다.")).toBeVisible();
await expect(
list.getByRole("button", { name: "복원", exact: true }),
).toBeVisible();
expect(archiveCalls).toBe(1);
});
// usecase: 저장 마스킹된 [NAME] 토큰이 대시보드 리캡에서 자연스러운 한국어 문장으로 보인다
test("대시보드 리캡의 [NAME] 마스킹이 조사까지 자연어로 표시된다", async ({
page,
}) => {
await installLearnerFixtures(page);
await page.route(`**/api/sessions/ucres-act1`, (route) =>
route.fulfill(
jsonRoute({
case_id: "case-ucres",
session_id: "ucres-act1",
persona_code: "P1",
persona_name: "성하늘",
status: "active",
stage: "탐색",
started_at: iso(1 * HOUR),
ended_at: null,
review_ready: false,
theory_mode: "humanistic",
goal_stages: ["라포", "탐색"],
effective_openness: 0.4,
duration_limit_seconds: 1200,
warning_before_end_seconds: 600,
progress: null,
turns: [
{
turn_seq: 1,
speaker: "learner",
stage: "라포",
text: "[NAME]은 지난주에 어떻게 지내셨어요?",
created_at: iso(0.9 * HOUR),
},
{
turn_seq: 2,
speaker: "client",
stage: "라포",
text: "요즘 [NAME]과 이야기할 때마다 마음이 무거워요.",
created_at: iso(0.8 * HOUR),
},
],
}),
),
);
await page.goto("/learn");
await expect(page.locator(".lh-recap")).toBeVisible();
// 내담자 인용: [NAME]과 → 익명 내담자와 (받침 없는 라벨의 조사 선택).
await expect(page.locator(".lh-recap__quote p")).toContainText(
"익명 내담자와 이야기할 때마다 마음이 무거워요.",
);
// 학습자 발화: [NAME]은 → 익명 내담자는.
await expect(page.locator(".lh-recap__learner p")).toContainText(
"익명 내담자는 지난주에 어떻게 지내셨어요?",
);
// 마스킹 토큰 원문은 화면 어디에도 남지 않는다.
await expect(page.locator("body")).not.toContainText("[NAME]");
});
// usecase: 기록 카드에는 내담자 이름·상태만 보이고 내부 세션 UUID는 노출되지 않는다
test("기록 화면에 raw 세션 UUID가 노출되지 않는다", async ({ page }) => {
await installLearnerFixtures(page, {
sessions: () => [activeSession(ACTIVE_UUID), endedSession(ENDED_UUID)],
});
await page.goto("/learn/history");
const cards = page.locator(".lh-session-list .lh-session-card");
await expect(cards).toHaveCount(2);
await expect(cards.first()).toContainText("성하늘");
await expect(cards.last()).toContainText("김도윤");
// 사람이 읽는 표면에 내부 UUID가 그대로 보이지 않는다.
const body = page.locator("body");
await expect(body).not.toContainText(ACTIVE_UUID);
await expect(body).not.toContainText(ENDED_UUID);
});
// usecase: 기록이 정말 0건일 때는 오류가 아니라 빈 상태 안내로 첫 회기를 이끈다
test("기록 0건은 오류가 아닌 빈 상태(role=status)로 안내한다", async ({
page,
}) => {
await installLearnerFixtures(page, {
sessions: () => [],
dashboard: EMPTY_DASHBOARD,
});
await page.goto("/learn/history");
const empty = page.locator(".lh-activity__empty");
await expect(empty).toBeVisible();
await expect(empty).toContainText("저장된 기존 회기가 없습니다.");
await expect(empty).toContainText(
"첫 회기를 마치면 실제 저장 기록이 이곳에 쌓입니다.",
);
await expect(page.locator(".lh-history-main__head")).toContainText("0개");
// 빈 상태를 오류 문구로 표현하지 않는다.
await expect(page.locator("body")).not.toContainText("불러오지 못했습니다");
});
// usecase: 존재하지 않는 내담자 딥링크로 들어온 학습자는 '목록에 없음' 안내를 받고 회기 시작이 잠긴다
test("없는 페르소나 딥링크는 '목록에 없음' 안내와 함께 시작을 차단한다", async ({
page,
}) => {
await routeUnmockedApi(page);
await routeAuthedLearner(page);
await page.route(/\/api\/personas$/, (route) =>
route.fulfill(jsonRoute(PERSONAS_BODY)),
);
await page.route("**/api/voice/health", (route) =>
route.fulfill(jsonRoute({ available: false, reason: null })),
);
await page.route("**/api/users/me/prepost-measures**", (route) =>
route.fulfill(
jsonRoute({
pilot_id: "phase3-pilot-draft",
instrument_version: "pilot-prepost-scaffold-2026-06-28",
measures: [],
complete_pre_count: 0,
complete_post_count: 0,
updated_at: null,
}),
),
);
await page.goto("/learn/session/ZZ99");
await expect(page.locator(".sx-page--prestart")).toBeVisible();
// 존재하지 않음(missing)은 서버 오류와 구분되는 전용 문구로 안내된다.
await expect(page.locator(".sx-prestart__note")).toHaveText(
"ZZ99 페르소나는 현재 연습 목록에 없습니다. 학습자 홈에서 사용 가능한 페르소나를 선택해 주세요.",
);
await expect(
page.getByRole("button", { name: "회기 시작" }),
).toBeDisabled();
});
// usecase: 연습 목록 조회가 실패하면 '없음'이 아니라 '불러오지 못함'으로 구분해 안내하고 시작을 잠근다
test("페르소나 목록 로드 실패는 missing과 구분되는 오류 문구로 안내한다", async ({
page,
}) => {
await routeUnmockedApi(page);
await routeAuthedLearner(page);
await page.route(/\/api\/personas$/, (route) => route.abort());
await page.route("**/api/voice/health", (route) =>
route.fulfill(jsonRoute({ available: false, reason: null })),
);
await page.route("**/api/users/me/prepost-measures**", (route) =>
route.fulfill(
jsonRoute({
pilot_id: "phase3-pilot-draft",
instrument_version: "pilot-prepost-scaffold-2026-06-28",
measures: [],
complete_pre_count: 0,
complete_post_count: 0,
updated_at: null,
}),
),
);
await page.goto("/learn/session/P1");
await expect(page.locator(".sx-page--prestart")).toBeVisible();
await expect(page.locator(".sx-prestart__note")).toHaveText(
"연습 목록을 불러오지 못했습니다. 잠시 뒤 다시 시도해 주세요.",
);
await expect(
page.getByRole("button", { name: "회기 시작" }),
).toBeDisabled();
// 로드 실패를 '목록에 없음'으로 단정하지 않는다.
await expect(page.locator("body")).not.toContainText(
"페르소나는 현재 연습 목록에 없습니다",
);
});
// usecase: 라우트 렌더가 예외로 죽으면 빈 화면 대신 진단 화면을 보고, 복구 버튼으로 정상 화면에 돌아온다
test("라우트 렌더 실패 시 오류 경계 진단 화면이 뜨고 다시 불러오기로 복구된다", async ({
page,
}) => {
let chunkDown = true;
await installLearnerFixtures(page);
// dev 모듈 URL 차단으로 lazy 라우트 렌더 실패를 재현한다.
await page.route("**/src/pages/LearnerHome.tsx*", async (route) => {
if (chunkDown) {
await route.abort().catch(() => {});
return;
}
await route.continue();
});
await page.goto("/learn");
// RouteErrorBoundary 진단 화면: 제목·경로·복구 액션.
// (index.html 부트 워치독 패널도 role=alert를 잠깐 띄울 수 있어 문구로 한정)
const fallback = page
.getByRole("alert")
.filter({ hasText: "화면을 표시하지 못했습니다" });
await expect(fallback).toBeVisible({ timeout: 15_000 });
await expect(
fallback.getByRole("heading", { name: "화면을 표시하지 못했습니다" }),
).toBeVisible();
await expect(fallback).toContainText("화면 진단");
await expect(fallback).toContainText("/learn");
await expect(
fallback.getByRole("link", { name: "홈으로 이동" }),
).toBeVisible();
// 모듈이 다시 제공되면 '최신 버전 다시 불러오기'로 정상 화면에 복귀한다.
chunkDown = false;
await page.getByTestId("route-error-reload").click();
await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 });
await expect(page).toHaveURL(/\/learn$/);
});
});