- 지원 티켓 작성 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)
654 lines
25 KiB
TypeScript
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();
|
|
});
|
|
});
|