vignette/apps/web/e2e/uc-review-rupture-repair.spec.ts
Yun Chan cf899b7f15 유스케이스 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)
2026-08-18 11:47:58 +09:00

654 lines
25 KiB
TypeScript

/**
* uc-review-rupture-repair.spec.ts — 균열·수선(rupture/repair) 리뷰 여정 유스케이스 스펙.
*
* 목적: 학습자·교수자가 세션 리뷰의 "피드백" 탭에서 균열과 수선 원장(RuptureRepairCard)을
* 읽고 행동하는 실제 여정을 검증한다 — 여러 균열 정렬, missed/partial/resolved 상태 뱃지,
* 빠른 경고 vs 깊은 재조정 복구 분기 비교, 장면 재생(해당 턴 점프), 교수자 정정의
* append-only 표시, 균열 없음 빈 상태, 오류 후 재시도.
*
* 근거: apps/web/src/pages/session-review/RuptureRepairCard.tsx 의 실제 한국어 카피·
* role/label 계약과 apps/web/src/pages/session-review/ruptureRepairApi.ts 의 wire DTO.
* fixture 형태는 e2e/rupture-repair.spec.ts 를 따르되, 그 스펙이 이미 커버한 시나리오
* (학습자 전체 뷰, 404 학습자 빈 상태, 정정 제출·idempotent 재시도)는 반복하지 않는다.
*
* 모든 API 는 route fixture 로 목킹한다 — 실제 AI 엔진 턴 생성 없음.
*/
import { expect, test, type Page, type Route } from "@playwright/test";
import type {
RuptureEpisode,
RuptureObservation,
RuptureReconciliation,
RuptureRepairReadModel,
RuptureSafetyReference,
} from "../src/pages/session-review/ruptureRepairApi";
import {
FILLED_REVIEW_SESSION_ID,
filledReviewResponse,
routePrepostMeasures,
} from "./session-review-fixture";
import { expectNoHorizontalOverflow } from "./support";
type ReviewRole = "learner" | "teacher";
const TURN_UUIDS = [
"40000000-0000-4000-8000-000000000001",
"40000000-0000-4000-8000-000000000002",
"40000000-0000-4000-8000-000000000003",
"40000000-0000-4000-8000-000000000004",
"40000000-0000-4000-8000-000000000005",
"40000000-0000-4000-8000-000000000006",
];
const UNKNOWN_TURN_UUID = "49999999-0000-4000-8000-000000000099";
const MODEL_RUN_ID = "43000000-0000-4000-8000-000000000001";
async function fulfillJson(route: Route, body: unknown, status = 200) {
await route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify(body),
});
}
let episodeSeq = 0;
function nextEpisodeId(): string {
episodeSeq += 1;
return `41000000-0000-4000-8000-${String(episodeSeq).padStart(12, "0")}`;
}
let observationSeq = 0;
function makeObservation(
episodeId: string,
overrides: Partial<RuptureObservation> = {},
): RuptureObservation {
observationSeq += 1;
return {
observation_id: `42000000-0000-4000-8000-${String(observationSeq).padStart(12, "0")}`,
episode_id: episodeId,
sequence_no: 1,
event_kind: "rupture.detected",
from_state: null,
to_state: "onset",
rupture_type: "withdrawal",
source_kind: "observed_runtime",
perspective: "runtime_observation",
ai_view: "evaluator",
confidence: 0.8,
uncertainty: 0.2,
evidence_turn_ids: [TURN_UUIDS[1]],
counterevidence: [],
model_run_id: null,
supersedes_observation_id: null,
correction_reason: null,
created_at: "2026-08-06T09:10:00Z",
...overrides,
};
}
function makeReconciliation(
episodeId: string,
overrides: Partial<RuptureReconciliation> = {},
): RuptureReconciliation {
return {
revision_id: `45000000-0000-4000-8000-${String(episodeSeq).padStart(12, "0")}`,
episode_id: episodeId,
revision_no: 1,
supersedes_revision_id: null,
fast_warning_observation_id: `42000000-0000-4000-8000-${String(observationSeq).padStart(12, "0")}`,
deep_observation_id: `42000000-0000-4000-8000-${String(observationSeq).padStart(12, "0")}`,
fast_warning_id: "fast-warning-9",
provisional_status: "missed",
deep_status: "partial",
disposition: "superseded_partial",
uncertainty: 0.22,
evidence_turn_ids: [TURN_UUIDS[2], TURN_UUIDS[3]],
counterevidence: [],
model_run_id: MODEL_RUN_ID,
created_at: "2026-08-06T09:16:00Z",
...overrides,
};
}
function makeSafetyReference(
episodeId: string,
overrides: Partial<RuptureSafetyReference> = {},
): RuptureSafetyReference {
return {
episode_id: episodeId,
safety_event_id: 71,
turn_id: TURN_UUIDS[4],
ko_risk_level: 2,
escalated: true,
created_at: "2026-08-06T09:12:00Z",
...overrides,
};
}
function makeEpisode(overrides: Partial<RuptureEpisode> = {}): RuptureEpisode {
const episodeId = (overrides.episode_id as string | undefined) ?? nextEpisodeId();
return {
episode_id: episodeId,
session_id: FILLED_REVIEW_SESSION_ID,
case_id: "44000000-0000-4000-8000-000000000001",
learner_id: "44000000-0000-4000-8000-000000000002",
episode_key: "withdrawal-after-premature-advice",
created_at: "2026-08-06T09:10:00Z",
rupture_type: "withdrawal",
current_status: "partial",
status_source: "lifecycle_event",
observations: [makeObservation(episodeId)],
reconciliation_revisions: [],
safety_references: [],
...overrides,
...(overrides.episode_id ? {} : { episode_id: episodeId }),
};
}
function readModel(
role: ReviewRole,
episodes: RuptureEpisode[],
): RuptureRepairReadModel {
return {
session_id: FILLED_REVIEW_SESSION_ID,
requested_view: role === "teacher" ? "supervisor" : "counselor",
clinical_claim_allowed: false,
episodes,
};
}
async function routeUnmockedApi(page: Page) {
await page.route("**/api/**", (route) =>
fulfillJson(route, { detail: "not part of this focused fixture" }, 404),
);
}
async function routeReviewUser(page: Page, role: ReviewRole) {
await page.route("**/api/auth/me", (route) =>
fulfillJson(route, {
user_id:
role === "teacher"
? "00000000-0000-0000-0000-000000000202"
: "00000000-0000-0000-0000-000000000101",
email: `${role}@hs.ac.kr`,
role,
display_name: role === "teacher" ? "E2E Teacher" : "E2E Learner",
admin_access: false,
super_admin: false,
account_status: "approved",
approval_required: false,
cohort_ids: ["e2e-hanshin"],
consent_at: 1782820000,
onboarding_completed_at: 1782820001,
nickname: role === "teacher" ? "E2E Teacher" : "E2E Learner",
self_introduction: "",
avatar_url: "",
}),
);
}
async function routeReviewPage(page: Page, role: ReviewRole) {
const review = filledReviewResponse(FILLED_REVIEW_SESSION_ID);
review.turns = review.turns.map((turn, index) => ({
...turn,
turn_id: TURN_UUIDS[index] ?? null,
}));
if (role === "teacher") {
review.teacherReview = {
status: "viewed",
note: "",
reviewedAt: null,
reviewerId: "00000000-0000-0000-0000-000000000202",
worksheetStatus: "pending",
worksheetNote: "",
worksheetReviewedAt: null,
};
}
await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/review`, (route) =>
fulfillJson(route, review),
);
await page.route(
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/alliance-pulses`,
(route) => fulfillJson(route, { items: [] }),
);
await routePrepostMeasures(page);
}
async function prepareReview(page: Page, role: ReviewRole) {
await routeUnmockedApi(page);
await routeReviewUser(page, role);
await routeReviewPage(page, role);
}
async function routeRuptures(
page: Page,
handler: (route: Route) => Promise<void>,
) {
await page.route(
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/ruptures`,
handler,
);
}
async function openFeedback(page: Page, role: ReviewRole) {
const root = role === "teacher" ? "/teach/session" : "/learn/session";
await page.goto(`${root}/${FILLED_REVIEW_SESSION_ID}/review`);
await page.getByRole("tab", { name: "피드백" }).click();
return page.locator(".rr-card");
}
function tripleEpisodes(): RuptureEpisode[] {
return [
makeEpisode({
episode_key: "withdrawal-early",
rupture_type: "withdrawal",
current_status: "missed",
}),
makeEpisode({
episode_key: "confrontation-mid",
rupture_type: "confrontation",
current_status: "partial",
}),
makeEpisode({
episode_key: "goal-mismatch-late",
rupture_type: "goal_mismatch",
current_status: "resolved",
}),
];
}
test.describe("UC 균열·수선 리뷰 여정", () => {
test.beforeEach(() => {
episodeSeq = 0;
observationSeq = 0;
});
// usecase: 학습자가 피드백 탭을 열어 여러 균열 장면이 원장에 온 순서 그대로 나열된 것을 본다
test("여러 균열 에피소드가 fixture 순서대로 나열된다", async ({ page }) => {
await prepareReview(page, "learner");
await routeRuptures(page, (route) =>
fulfillJson(route, readModel("learner", tripleEpisodes())),
);
const card = await openFeedback(page, "learner");
const episodes = card.locator(".rr-episode");
await expect(episodes).toHaveCount(3);
await expect(episodes.nth(0).getByRole("heading", { name: "철수형 균열" })).toBeVisible();
await expect(episodes.nth(1).getByRole("heading", { name: "대립형 균열" })).toBeVisible();
await expect(episodes.nth(2).getByRole("heading", { name: "목표 불일치" })).toBeVisible();
await expect(episodes.nth(0)).toContainText("withdrawal-early");
await expect(episodes.nth(1)).toContainText("confrontation-mid");
await expect(episodes.nth(2)).toContainText("goal-mismatch-late");
await expectNoHorizontalOverflow(page);
});
// usecase: 학습자가 장면마다 붙은 상태 뱃지로 수선 놓침·부분 수선·수선 확인을 구분해 읽는다
test("missed·partial·resolved 상태 뱃지를 장면별로 구분해 보여준다", async ({ page }) => {
await prepareReview(page, "learner");
await routeRuptures(page, (route) =>
fulfillJson(route, readModel("learner", tripleEpisodes())),
);
const card = await openFeedback(page, "learner");
const episodes = card.locator(".rr-episode");
await expect(
episodes.nth(0).locator(".rr-episode__badges").getByText("수선 놓침", { exact: true }),
).toBeVisible();
await expect(
episodes.nth(1).locator(".rr-episode__badges").getByText("부분 수선", { exact: true }),
).toBeVisible();
await expect(
episodes.nth(2).locator(".rr-episode__badges").getByText("수선 확인", { exact: true }),
).toBeVisible();
});
// usecase: 학습자가 아직 유형·상태 판정이 없는 장면을 열어 판정 대기 상태임을 확인한다
test("유형·상태 미판정 에피소드는 판정 대기로 표시된다", async ({ page }) => {
await prepareReview(page, "learner");
const pending = makeEpisode({
rupture_type: null,
current_status: null,
observations: [],
});
await routeRuptures(page, (route) =>
fulfillJson(route, readModel("learner", [pending])),
);
const card = await openFeedback(page, "learner");
await expect(card.getByRole("heading", { name: "유형 판정 대기" })).toBeVisible();
await expect(
card.locator(".rr-episode__badges").getByText("상태 대기", { exact: true }),
).toBeVisible();
await expect(card.locator(".rr-current")).toContainText("판정 대기");
});
// usecase: 학습자가 현재 상태 요약에서 관찰 이력 출처와 불확실성 미기록(자료 없음)을 읽는다
test("관찰 이력 출처 요약에서 불확실성 미기록은 자료 없음으로 보여준다", async ({ page }) => {
await prepareReview(page, "learner");
const episodeId = nextEpisodeId();
const episode = makeEpisode({
episode_id: episodeId,
current_status: "recognized",
status_source: "lifecycle_event",
observations: [
makeObservation(episodeId, {
event_kind: "rupture.recognized",
to_state: "recognized",
uncertainty: null,
confidence: null,
}),
],
});
await routeRuptures(page, (route) =>
fulfillJson(route, readModel("learner", [episode])),
);
const card = await openFeedback(page, "learner");
const current = card.locator(".rr-current");
await expect(current.getByText("균열 인식", { exact: true })).toBeVisible();
await expect(current.getByText("자료 없음", { exact: true })).toBeVisible();
await expect(current.getByText("관찰 이력", { exact: true })).toBeVisible();
});
// usecase: 학습자가 복구 분기 비교 뷰에서 빠른 경고 판정과 깊은 재조정 판정을 나란히 대조한다
test("복구 분기 비교 뷰가 빠른 경고와 깊은 재조정 판정을 나란히 보여준다", async ({ page }) => {
await prepareReview(page, "learner");
const episodeId = nextEpisodeId();
const episode = makeEpisode({
episode_id: episodeId,
current_status: "resolved",
status_source: "deep_reconciliation",
observations: [makeObservation(episodeId)],
reconciliation_revisions: [
makeReconciliation(episodeId, {
provisional_status: "missed",
deep_status: "resolved",
disposition: "superseded_resolved",
fast_warning_id: "fast-warning-9",
}),
],
});
await routeRuptures(page, (route) =>
fulfillJson(route, readModel("learner", [episode])),
);
const card = await openFeedback(page, "learner");
const rail = card.getByLabel("빠른 경고와 깊은 재조정 출처");
await expect(rail.getByText("빠른 경고", { exact: true })).toBeVisible();
await expect(rail).toContainText("수선 놓침 · fast-warning-9");
await expect(rail.getByText("깊은 재조정", { exact: true })).toBeVisible();
await expect(rail).toContainText("수선 확인으로 갱신 · 수선 확인");
await expect(rail.locator("code")).toContainText("run 43000000…000001");
});
// usecase: 학습자가 기각된 빠른 경고를 열어 경고 기각 처분과 판정 제외 상태를 확인한다
test("기각된 빠른 경고는 경고 기각 처분으로 표시된다", async ({ page }) => {
await prepareReview(page, "learner");
const episodeId = nextEpisodeId();
const episode = makeEpisode({
episode_id: episodeId,
current_status: "not_applicable",
status_source: "deep_reconciliation",
observations: [makeObservation(episodeId)],
reconciliation_revisions: [
makeReconciliation(episodeId, {
provisional_status: "onset",
deep_status: "not_applicable",
disposition: "dismissed",
}),
],
});
await routeRuptures(page, (route) =>
fulfillJson(route, readModel("learner", [episode])),
);
const card = await openFeedback(page, "learner");
await expect(
card.locator(".rr-episode__badges").getByText("판정 제외", { exact: true }),
).toBeVisible();
const rail = card.getByLabel("빠른 경고와 깊은 재조정 출처");
await expect(rail).toContainText("경고 기각 · 판정 제외");
});
// usecase: 학습자가 안전 원장의 근거 발화 버튼으로 해당 장면(턴)으로 점프해 다시 본다
test("안전 원장 근거 발화 버튼으로 축어록 해당 턴에 점프한다", async ({ page }) => {
await prepareReview(page, "learner");
const episodeId = nextEpisodeId();
const episode = makeEpisode({
episode_id: episodeId,
observations: [makeObservation(episodeId)],
safety_references: [makeSafetyReference(episodeId, { turn_id: TURN_UUIDS[4] })],
});
await routeRuptures(page, (route) =>
fulfillJson(route, readModel("learner", [episode])),
);
const card = await openFeedback(page, "learner");
const safety = card.locator(".rr-safety");
await expect(safety.getByText("안전 사건 #71")).toBeVisible();
await expect(safety.getByText("상향 확인", { exact: true })).toBeVisible();
await safety.getByRole("button", { name: "근거 발화로 이동" }).click();
await expect(page.getByRole("tab", { name: "축어록" })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(page.locator(".sr-turn--active")).toHaveCount(1);
});
// usecase: 학습자가 축어록과 매칭되지 않는 근거 UUID를 축약된 코드로 확인한다
test("축어록에 없는 근거 UUID는 축약 코드로 표시된다", async ({ page }) => {
await prepareReview(page, "learner");
const episodeId = nextEpisodeId();
const episode = makeEpisode({
episode_id: episodeId,
observations: [
makeObservation(episodeId, {
evidence_turn_ids: [UNKNOWN_TURN_UUID],
}),
],
});
await routeRuptures(page, (route) =>
fulfillJson(route, readModel("learner", [episode])),
);
const card = await openFeedback(page, "learner");
const evidence = card.locator(".rr-evidence-list");
await expect(evidence.locator("code")).toHaveText("49999999…000099");
await expect(
evidence.getByRole("button", { name: /발화로 이동/ }),
).toHaveCount(0);
});
// usecase: 학습자가 교수자 정정이 반영된 장면에서 사람 판정이 최신임과 정정 이유를 읽는다
test("교수자 정정이 append-only로 사람 판정 최신 표시와 이유를 보여준다", async ({ page }) => {
await prepareReview(page, "learner");
const episodeId = nextEpisodeId();
const first = makeObservation(episodeId, { sequence_no: 1 });
const correction = makeObservation(episodeId, {
sequence_no: 2,
event_kind: "human.corrected",
from_state: "partial",
to_state: "resolved",
source_kind: "human_rated",
perspective: "supervisor_human",
ai_view: "supervisor",
confidence: null,
uncertainty: 0.1,
evidence_turn_ids: [TURN_UUIDS[3]],
supersedes_observation_id: first.observation_id,
correction_reason: "후속 반응 근거를 반영해 수선 확인으로 정정했습니다.",
created_at: "2026-08-06T09:18:00Z",
});
const episode = makeEpisode({
episode_id: episodeId,
current_status: "resolved",
status_source: "human_correction",
observations: [first, correction],
});
await routeRuptures(page, (route) =>
fulfillJson(route, readModel("learner", [episode])),
);
const card = await openFeedback(page, "learner");
await expect(card.getByText("사람 판정이 최신", { exact: true })).toBeVisible();
await expect(
card.getByText("후속 반응 근거를 반영해 수선 확인으로 정정했습니다."),
).toBeVisible();
await expect(
card.locator(".rr-episode__badges").getByText("사람 판정", { exact: true }),
).toBeVisible();
await expect(
card.locator(".rr-episode__badges").getByText("수선 확인", { exact: true }),
).toBeVisible();
});
// usecase: 교수자가 정정 폼을 열어 기존 관찰을 고치지 않고 새 기록을 추가한다는 안내와 대상 관찰을 확인한다
test("정정 폼이 보존형(append-only) 안내와 대상 관찰 ID를 보여준다", async ({ page }) => {
await prepareReview(page, "teacher");
const episodeId = nextEpisodeId();
const observation = makeObservation(episodeId, {
observation_id: "42000000-0000-4000-8000-000000000777",
evidence_turn_ids: [TURN_UUIDS[2], TURN_UUIDS[3]],
});
const episode = makeEpisode({
episode_id: episodeId,
current_status: "partial",
observations: [observation],
});
await routeRuptures(page, (route) =>
fulfillJson(route, readModel("teacher", [episode])),
);
const card = await openFeedback(page, "teacher");
await card.getByText("사람 판정으로 정정", { exact: true }).click();
const correction = card.locator(".rr-correction");
await expect(
correction.getByRole("heading", { name: "최신 관찰을 보존형으로 정정" }),
).toBeVisible();
await expect(
correction.getByText("기존 관찰은 수정하지 않고, 그 관찰을 대체하는 새 기록을 추가합니다."),
).toBeVisible();
await expect(
correction.locator(".rr-correction__intro code"),
).toHaveText("42000000…000777");
});
// usecase: 교수자가 정정 이유를 쓰기 전에는 제출할 수 없고, 폼 기본값이 현재 판정·근거로 채워진 것을 본다
test("정정 폼은 현재 판정·근거로 프리필되고 이유 없이는 제출이 막힌다", async ({ page }) => {
await prepareReview(page, "teacher");
const episodeId = nextEpisodeId();
const episode = makeEpisode({
episode_id: episodeId,
current_status: "partial",
observations: [
makeObservation(episodeId, {
evidence_turn_ids: [TURN_UUIDS[2], TURN_UUIDS[3]],
}),
],
});
await routeRuptures(page, (route) =>
fulfillJson(route, readModel("teacher", [episode])),
);
const card = await openFeedback(page, "teacher");
await card.getByText("사람 판정으로 정정", { exact: true }).click();
const form = card.locator(".rr-correction form");
await expect(form.getByLabel("정정 상태")).toHaveValue("partial");
await expect(
form.locator(".rr-correction__evidence input[type=checkbox]:checked"),
).toHaveCount(2);
const submitButton = form.getByRole("button", { name: "정정 기록 추가" });
await expect(submitButton).toBeDisabled();
await form.getByLabel("정정 이유").fill("사람 검토 근거를 반영합니다.");
await expect(submitButton).toBeEnabled();
});
// usecase: 학습자가 균열이 하나도 기록되지 않은 회기의 피드백 탭에서 차분한 빈 상태 안내를 본다
test("에피소드 빈 배열이면 학습자용 빈 상태 안내를 보여준다", async ({ page }) => {
await prepareReview(page, "learner");
await routeRuptures(page, (route) =>
fulfillJson(route, readModel("learner", [])),
);
const card = await openFeedback(page, "learner");
await expect(
card.getByRole("heading", { name: "아직 검토할 관계 장면이 없습니다" }),
).toBeVisible();
await expect(
card.getByText(
"회기에서 관계 균열과 수선 근거가 확인되면, 다시 연습할 장면을 여기서 준비할 수 있습니다.",
),
).toBeVisible();
await expectNoHorizontalOverflow(page);
});
// usecase: 교수자가 원장이 아직 없는 회기(404)에서 역할에 맞는 빈 상태 안내를 본다
test("교수자 404 빈 상태는 역할 허용 관찰 안내 문구를 보여준다", async ({ page }) => {
await prepareReview(page, "teacher");
await routeRuptures(page, (route) =>
fulfillJson(route, { detail: "rupture ledger not found" }, 404),
);
const card = await openFeedback(page, "teacher");
await expect(
card.getByRole("heading", { name: "아직 검토할 관계 장면이 없습니다" }),
).toBeVisible();
await expect(
card.getByText(
"역할에 허용된 균열·수선 관찰이 생기면 근거와 재조정 이력을 여기서 검토할 수 있습니다.",
),
).toBeVisible();
});
// usecase: 학습자가 원장 로딩 실패를 만나면 오류 카드에서 다시 불러오기를 눌러 복구한다
test("원장 로딩 오류 후 다시 불러오기로 복구한다", async ({ page }) => {
await prepareReview(page, "learner");
// StrictMode 이중 마운트에도 결정론적이도록, 테스트가 명시적으로 복구 시점을 제어한다.
let healthy = false;
let attempts = 0;
await routeRuptures(page, async (route) => {
attempts += 1;
if (!healthy) {
await fulfillJson(route, { detail: "temporary ledger failure" }, 500);
return;
}
await fulfillJson(route, readModel("learner", [makeEpisode()]));
});
const card = await openFeedback(page, "learner");
await expect(
card.getByRole("heading", { name: "관계 장면 원장을 불러오지 못했습니다" }),
).toBeVisible();
healthy = true;
await card.getByRole("button", { name: "다시 불러오기" }).click();
await expect(
card.getByRole("heading", { name: "관계가 어긋난 장면과 다시 맞춘 근거를 봅니다" }),
).toBeVisible();
expect(attempts).toBeGreaterThanOrEqual(2);
});
// usecase: 학습자가 근거·반대 근거·안전 사건이 비어 있는 장면에서 각 영역의 빈 안내 문구를 읽는다
test("근거·반대 근거·안전 사건이 없으면 각 영역의 빈 안내를 보여준다", async ({ page }) => {
await prepareReview(page, "learner");
const episodeId = nextEpisodeId();
const episode = makeEpisode({
episode_id: episodeId,
observations: [
makeObservation(episodeId, {
evidence_turn_ids: [],
counterevidence: [],
}),
],
safety_references: [],
});
await routeRuptures(page, (route) =>
fulfillJson(route, readModel("learner", [episode])),
);
const card = await openFeedback(page, "learner");
await expect(card.getByText("연결된 발화 근거가 없습니다.")).toBeVisible();
await expect(card.getByText("현재 원장에 기록된 반대 근거가 없습니다.")).toBeVisible();
await expect(card.getByText("이 에피소드에 연결된 안전 사건이 없습니다.")).toBeVisible();
await expect(
card.locator(".rr-safety").getByText("연결 없음", { exact: true }),
).toBeVisible();
});
});