유스케이스 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:
parent
4771b97c3a
commit
cf899b7f15
37 changed files with 11989 additions and 67 deletions
453
apps/web/e2e/uc-session-timer.spec.ts
Normal file
453
apps/web/e2e/uc-session-timer.spec.ts
Normal file
|
|
@ -0,0 +1,453 @@
|
|||
/* =====================================================================
|
||||
uc-session-timer.spec.ts — 회기 시간 수명주기(60분·10분 전 알람·만료·유예) 유스케이스 E2E
|
||||
|
||||
목적: 시간 기반 회기(회의 P1)의 사용자 여정을 화면 계약 기준으로 검증한다.
|
||||
(경과 타이머, 10분 전 경고 바, 만료 정리 다이얼로그, 유예 계속 진행,
|
||||
유예 종료 후 발화 거부 안내, 종료 확정→리뷰 이동, 종료 실패 처리)
|
||||
|
||||
근거: apps/web/src/pages/Session.tsx —
|
||||
sessionLimitSeconds/sessionWarningSeconds(743~766), 알람 바(2845~2869),
|
||||
만료 다이얼로그(4069~4122, 포커스 트랩 2247~2281),
|
||||
session_time_over 거부(1440~1444), handleEnd(2284~2314).
|
||||
detail.duration_limit_seconds / warning_before_end_seconds /
|
||||
elapsedFromSession(195)의 restore 경로(1055~1073)로 시간 상태를 만든다.
|
||||
|
||||
주의: 모든 API 는 route fixture 로 목킹한다 — 실제 AI 엔진 턴 생성 없음.
|
||||
중복 회피: session-layout.spec.ts(뷰포트 유지), session-persistence.spec.ts
|
||||
(@single-run 실 DB), uc-session-conversation.spec.ts(대화 왕복)와 각도 분리.
|
||||
===================================================================== */
|
||||
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const SESSION_ID = "55555555-5555-4555-8555-555555555555";
|
||||
|
||||
function jsonRoute(body: unknown, status = 200) {
|
||||
return { status, contentType: "application/json", body: JSON.stringify(body) };
|
||||
}
|
||||
|
||||
interface TimerFixtureOptions {
|
||||
/** 회기 시작이 지금으로부터 몇 초 전인가 (restore 경로의 시간 상태를 결정) */
|
||||
startedAgoSeconds: number;
|
||||
durationLimitSeconds?: number;
|
||||
warningBeforeEndSeconds?: number;
|
||||
status?: "active" | "ended";
|
||||
/** POST /sessions/{id}/end 응답 상태코드 (기본 200) */
|
||||
endStatus?: number;
|
||||
}
|
||||
|
||||
interface EndCallTracker {
|
||||
readonly calls: number;
|
||||
}
|
||||
|
||||
async function routeTimedSession(
|
||||
page: Page,
|
||||
options: TimerFixtureOptions,
|
||||
): Promise<EndCallTracker> {
|
||||
const {
|
||||
startedAgoSeconds,
|
||||
durationLimitSeconds = 3600,
|
||||
warningBeforeEndSeconds = 600,
|
||||
status = "active",
|
||||
endStatus = 200,
|
||||
} = options;
|
||||
const startedAt = new Date(Date.now() - startedAgoSeconds * 1000);
|
||||
const endedAt =
|
||||
status === "ended"
|
||||
? new Date(startedAt.getTime() + durationLimitSeconds * 1000)
|
||||
: null;
|
||||
|
||||
// catch-all 을 먼저 — 나중 등록이 우선이라 아래 개별 fixture 가 이긴다.
|
||||
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-0000000uct01",
|
||||
email: "uc.timer@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/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.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/voice/health", (route) =>
|
||||
route.fulfill(jsonRoute({ available: false, reason: "provider key not configured" })),
|
||||
);
|
||||
|
||||
await page.route(`**/api/sessions/${SESSION_ID}`, (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
session_id: SESSION_ID,
|
||||
case_id: "uc-timer-case-001",
|
||||
persona_code: "P1",
|
||||
persona_name: "민서",
|
||||
session_no: 1,
|
||||
status,
|
||||
stage: "라포",
|
||||
theory_mode: "humanistic",
|
||||
effective_openness: 0.34,
|
||||
started_at: startedAt.toISOString(),
|
||||
ended_at: endedAt ? endedAt.toISOString() : null,
|
||||
review_ready: status === "ended",
|
||||
duration_limit_seconds: durationLimitSeconds,
|
||||
warning_before_end_seconds: warningBeforeEndSeconds,
|
||||
goal_stages: [],
|
||||
turns: [
|
||||
{
|
||||
speaker: "learner",
|
||||
text: "오늘 이야기 나눠 주셔서 고마워요. 지금 마음은 어떠세요?",
|
||||
turn_seq: 1,
|
||||
created_at: new Date(startedAt.getTime() + 10_000).toISOString(),
|
||||
},
|
||||
{
|
||||
speaker: "client",
|
||||
text: "조금 지치긴 했는데, 말하고 나니 마음이 정리되는 것 같아요.",
|
||||
turn_seq: 2,
|
||||
created_at: new Date(startedAt.getTime() + 20_000).toISOString(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await page.route(`**/api/sessions/${SESSION_ID}/alliance-pulses`, async (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
await route.fulfill(
|
||||
jsonRoute(
|
||||
{ pulse_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", status: "awaiting_agents" },
|
||||
202,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await route.fulfill(
|
||||
jsonRoute({
|
||||
items: [
|
||||
{
|
||||
pulse_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
checkpoint: "pre",
|
||||
status: "ready",
|
||||
learner_locked_at: startedAt.toISOString(),
|
||||
revealed_at: startedAt.toISOString(),
|
||||
error_code: null,
|
||||
self_scores: { goal: 0.5, task: 0.5, bond: 0.5 },
|
||||
measurements: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await page.route(`**/api/sessions/${SESSION_ID}/live-coach`, (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
source: "database",
|
||||
quota: { remaining: 3, max: 3 },
|
||||
credit_events: [],
|
||||
events: [],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const endState = { calls: 0 };
|
||||
await page.route(`**/api/sessions/${SESSION_ID}/end`, async (route) => {
|
||||
endState.calls += 1;
|
||||
if (endStatus >= 400) {
|
||||
await route.fulfill(jsonRoute({ detail: "평가 저장소 연결 실패" }, endStatus));
|
||||
return;
|
||||
}
|
||||
await route.fulfill(
|
||||
jsonRoute({ session_id: SESSION_ID, status: "ended", review_ready: false }),
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
get calls() {
|
||||
return endState.calls;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function openSession(page: Page) {
|
||||
await page.goto(`/learn/session/${SESSION_ID}`);
|
||||
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
|
||||
}
|
||||
|
||||
const timebar = (page: Page) => page.locator(".sx-timebar");
|
||||
const endDialog = (page: Page) => page.getByRole("dialog", { name: /회기/ });
|
||||
const keepTalkingButton = (page: Page) =>
|
||||
page.getByRole("button", { name: "마무리 인사 나누기" });
|
||||
const confirmEndButton = (page: Page) =>
|
||||
page.getByRole("button", { name: "종료하고 리뷰 보기" }).last();
|
||||
|
||||
test.describe("회기 시간 수명주기 — 알람·만료·유예·종료", () => {
|
||||
// usecase: 회기 초반에는 경고 없이 경과 시간만 흐른다
|
||||
test("회기 초반에는 시간 알람 바가 노출되지 않는다", async ({ page }) => {
|
||||
await routeTimedSession(page, { startedAgoSeconds: 30 });
|
||||
await openSession(page);
|
||||
|
||||
await expect(timebar(page)).toHaveCount(0);
|
||||
await expect(endDialog(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
// usecase: 경고 시점 이전(잔여 시간이 경고 창 밖)에는 아직 경고가 뜨지 않는다.
|
||||
// 주의: 화면 타이머는 restore 후에도 1초씩 실시간 진행한다(Session.tsx 657~661) —
|
||||
// 경계 1초 차 fixture 는 페이지 로딩 중에 경계를 넘어 버리므로 여유를 둔다.
|
||||
test("경고 창 경계 이전에는 알람이 조기 노출되지 않는다", async ({ page }) => {
|
||||
await routeTimedSession(page, {
|
||||
startedAgoSeconds: 3600 - 660,
|
||||
durationLimitSeconds: 3600,
|
||||
warningBeforeEndSeconds: 600,
|
||||
});
|
||||
await openSession(page);
|
||||
|
||||
await expect(timebar(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
// usecase: 종료 10분 전이 되면 정리를 유도하는 경고가 강제 노출된다
|
||||
test("종료 10분 전 경고 바가 role=alert 로 나타난다", async ({ page }) => {
|
||||
await routeTimedSession(page, {
|
||||
startedAgoSeconds: 3600 - 540,
|
||||
durationLimitSeconds: 3600,
|
||||
warningBeforeEndSeconds: 600,
|
||||
});
|
||||
await openSession(page);
|
||||
|
||||
const bar = timebar(page);
|
||||
await expect(bar).toBeVisible();
|
||||
await expect(bar).toHaveAttribute("role", "alert");
|
||||
await expect(bar).toContainText("종료 9분 전");
|
||||
await expect(bar).toContainText("남은 시간 동안 오늘 나눈 이야기를 정리해 보세요.");
|
||||
// 만료 전이므로 종료 바로가기 버튼은 아직 없다.
|
||||
await expect(bar.getByRole("button", { name: "종료하고 리뷰 보기" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
// usecase: 잔여 시간 분 표기는 올림으로 계산된다 — 잔여 100초(1.67분)는 "2분 전".
|
||||
// 주의: 타이머가 실시간 진행하므로(위와 동일) 61초 같은 경계값은 로딩 중 60초
|
||||
// 이하로 떨어져 "1분 전"이 된다. 올림 계약(Math.ceil, Session.tsx 2852)은
|
||||
// 분 경계에서 충분히 떨어진 잔여 100초로 검증한다.
|
||||
test("경고 분 표기는 잔여 시간을 올림해 표시한다", async ({ page }) => {
|
||||
await routeTimedSession(page, {
|
||||
startedAgoSeconds: 3600 - 100,
|
||||
durationLimitSeconds: 3600,
|
||||
warningBeforeEndSeconds: 600,
|
||||
});
|
||||
await openSession(page);
|
||||
|
||||
await expect(timebar(page)).toContainText("종료 2분 전");
|
||||
});
|
||||
|
||||
// usecase: 시간이 다 되면 정리 유도 다이얼로그가 자동으로 열린다
|
||||
test("시간 만료 시 정리 다이얼로그가 자동으로 열린다", async ({ page }) => {
|
||||
await routeTimedSession(page, {
|
||||
startedAgoSeconds: 3700,
|
||||
durationLimitSeconds: 3600,
|
||||
});
|
||||
await openSession(page);
|
||||
|
||||
const dialog = endDialog(page);
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog).toContainText("회기 시간이 끝났어요");
|
||||
await expect(dialog).toContainText("실제 상담처럼 시간이 회기를 마무리합니다.");
|
||||
});
|
||||
|
||||
// usecase: 만료 다이얼로그가 열리면 첫 포커스는 계속 진행(취소) 버튼에 놓인다
|
||||
test("만료 다이얼로그의 첫 포커스는 '마무리 인사 나누기'다", async ({ page }) => {
|
||||
await routeTimedSession(page, {
|
||||
startedAgoSeconds: 3700,
|
||||
durationLimitSeconds: 3600,
|
||||
});
|
||||
await openSession(page);
|
||||
|
||||
await expect(endDialog(page)).toBeVisible();
|
||||
await expect(keepTalkingButton(page)).toBeFocused();
|
||||
});
|
||||
|
||||
// usecase: 키보드 사용자는 Tab 이동이 다이얼로그 두 버튼 밖으로 새지 않는다
|
||||
test("만료 다이얼로그 Tab 순환이 두 버튼 사이에 갇힌다", async ({ page }) => {
|
||||
await routeTimedSession(page, {
|
||||
startedAgoSeconds: 3700,
|
||||
durationLimitSeconds: 3600,
|
||||
});
|
||||
await openSession(page);
|
||||
await expect(keepTalkingButton(page)).toBeFocused();
|
||||
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(confirmEndButton(page)).toBeFocused();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(keepTalkingButton(page)).toBeFocused();
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await expect(confirmEndButton(page)).toBeFocused();
|
||||
});
|
||||
|
||||
// usecase: 마지막 인사를 나누고 싶으면 다이얼로그를 닫고 계속 이야기할 수 있다(유예)
|
||||
test("'마무리 인사 나누기'로 다이얼로그를 닫고 입력을 계속 쓸 수 있다", async ({ page }) => {
|
||||
await routeTimedSession(page, {
|
||||
startedAgoSeconds: 3700,
|
||||
durationLimitSeconds: 3600,
|
||||
});
|
||||
await openSession(page);
|
||||
|
||||
await keepTalkingButton(page).click();
|
||||
await expect(endDialog(page)).toHaveCount(0);
|
||||
await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled();
|
||||
|
||||
// 만료 알람 바는 유예 중에도 종료를 계속 안내한다.
|
||||
const bar = timebar(page);
|
||||
await expect(bar).toContainText("회기 시간이 끝났어요");
|
||||
await expect(bar).toContainText("정리 시간이 지나면 새 발화가 제한됩니다.");
|
||||
await expect(bar.getByRole("button", { name: "종료하고 리뷰 보기" })).toBeVisible();
|
||||
});
|
||||
|
||||
// usecase: 유예 중에도 언제든 알람 바에서 종료 절차를 다시 시작할 수 있다
|
||||
test("알람 바의 종료 버튼이 만료 다이얼로그를 다시 연다", async ({ page }) => {
|
||||
await routeTimedSession(page, {
|
||||
startedAgoSeconds: 3700,
|
||||
durationLimitSeconds: 3600,
|
||||
});
|
||||
await openSession(page);
|
||||
|
||||
await keepTalkingButton(page).click();
|
||||
await expect(endDialog(page)).toHaveCount(0);
|
||||
|
||||
await timebar(page).getByRole("button", { name: "종료하고 리뷰 보기" }).click();
|
||||
await expect(endDialog(page)).toBeVisible();
|
||||
});
|
||||
|
||||
// usecase: Escape 키로도 다이얼로그를 닫아 유예를 선택할 수 있다
|
||||
test("Escape 키가 만료 다이얼로그를 닫는다", async ({ page }) => {
|
||||
await routeTimedSession(page, {
|
||||
startedAgoSeconds: 3700,
|
||||
durationLimitSeconds: 3600,
|
||||
});
|
||||
await openSession(page);
|
||||
|
||||
await expect(endDialog(page)).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(endDialog(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
// usecase: 정리 유예까지 끝난 뒤의 발화는 서버가 거부하고 화면은 이유를 정직하게 알린다
|
||||
test("유예 종료 후 발화는 session_time_over 안내로 거부된다", async ({ page }) => {
|
||||
await routeTimedSession(page, {
|
||||
startedAgoSeconds: 3700,
|
||||
durationLimitSeconds: 3600,
|
||||
});
|
||||
await page.route(`**/api/sessions/${SESSION_ID}/stream`, (route) =>
|
||||
route.fulfill(jsonRoute({ detail: "session_time_over" }, 409)),
|
||||
);
|
||||
await openSession(page);
|
||||
await keepTalkingButton(page).click();
|
||||
|
||||
const composer = page.getByLabel("학습자 발화 입력");
|
||||
await composer.fill("마지막으로 한 가지만 더 여쭤봐도 될까요?");
|
||||
await page.getByRole("button", { name: "보내기" }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText("회기 시간이 모두 지나 새 발화를 보낼 수 없습니다", { exact: false }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// usecase: 종료를 확정하면 축어록이 저장되고 리뷰 화면으로 이동한다
|
||||
test("종료 확정 시 end 호출 후 리뷰 경로로 이동한다", async ({ page }) => {
|
||||
const endCalls = await routeTimedSession(page, {
|
||||
startedAgoSeconds: 3700,
|
||||
durationLimitSeconds: 3600,
|
||||
});
|
||||
await openSession(page);
|
||||
|
||||
await confirmEndButton(page).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/learn/session/${SESSION_ID}/review$`));
|
||||
expect(endCalls.calls).toBe(1);
|
||||
});
|
||||
|
||||
// usecase: 종료 저장이 실패하면 리뷰로 넘어가지 않고 실패 사실을 알린다
|
||||
test("종료 실패 시 리뷰 이동 없이 오류를 안내한다", async ({ page }) => {
|
||||
const endCalls = await routeTimedSession(page, {
|
||||
startedAgoSeconds: 3700,
|
||||
durationLimitSeconds: 3600,
|
||||
endStatus: 500,
|
||||
});
|
||||
await openSession(page);
|
||||
|
||||
await confirmEndButton(page).click();
|
||||
await expect(
|
||||
page.getByText("회기 종료에 실패했습니다", { exact: false }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(new RegExp(`/learn/session/${SESSION_ID}$`));
|
||||
expect(endCalls.calls).toBe(1);
|
||||
});
|
||||
|
||||
// usecase: 이미 종료된 회기를 다시 열면 만료 경고나 다이얼로그가 반복되지 않는다
|
||||
test("종료된 회기 재열람 시 알람 바와 다이얼로그가 없다", async ({ page }) => {
|
||||
await routeTimedSession(page, {
|
||||
startedAgoSeconds: 4000,
|
||||
durationLimitSeconds: 3600,
|
||||
status: "ended",
|
||||
});
|
||||
await page.goto(`/learn/session/${SESSION_ID}`);
|
||||
// 종료 상태 화면이 완전히 그려질 때까지 축어록 존재로 대기한다.
|
||||
await expect(page.locator(".sx-utt").first()).toBeVisible();
|
||||
|
||||
await expect(timebar(page)).toHaveCount(0);
|
||||
await expect(endDialog(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
// usecase: 시간이 남은 회기에서 학습자가 직접 종료를 선택하면 일반 확인 문구가 뜬다
|
||||
test("시간 내 수동 종료 다이얼로그는 일반 확인 문구를 쓴다", async ({ page }) => {
|
||||
await routeTimedSession(page, { startedAgoSeconds: 600 });
|
||||
await openSession(page);
|
||||
|
||||
await page.getByRole("button", { name: "회기 종료" }).first().click();
|
||||
const dialog = endDialog(page);
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog).toContainText("회기를 종료할까요?");
|
||||
await expect(dialog).toContainText(
|
||||
"종료하면 현재 축어록을 저장하고 바로 회기 리뷰 화면으로 이동합니다.",
|
||||
);
|
||||
await expect(page.getByRole("button", { name: "계속 진행" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue