8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본 폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을 git 이력으로 고정하는 것이 목적이다. - contracts/routes/services: measurement, outcome_trajectory, rupture_repair, deliberate_practice, calibration_transfer, supervision_research, multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트 - infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행) - apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가 - docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG - scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트 engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
364 lines
15 KiB
TypeScript
364 lines
15 KiB
TypeScript
import { expect, test, type Page, type Route } from "@playwright/test";
|
|
import type {
|
|
RuptureEpisode,
|
|
RuptureObservation,
|
|
RuptureRepairReadModel,
|
|
} 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 = [
|
|
"30000000-0000-4000-8000-000000000001",
|
|
"30000000-0000-4000-8000-000000000002",
|
|
"30000000-0000-4000-8000-000000000003",
|
|
"30000000-0000-4000-8000-000000000004",
|
|
"30000000-0000-4000-8000-000000000005",
|
|
"30000000-0000-4000-8000-000000000006",
|
|
];
|
|
const EPISODE_ID = "31000000-0000-4000-8000-000000000001";
|
|
const OBSERVATION_ONE_ID = "32000000-0000-4000-8000-000000000001";
|
|
const OBSERVATION_TWO_ID = "32000000-0000-4000-8000-000000000002";
|
|
const CORRECTION_ID = "32000000-0000-4000-8000-000000000003";
|
|
|
|
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
|
await route.fulfill({
|
|
status,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
function observations(): RuptureObservation[] {
|
|
return [
|
|
{
|
|
observation_id: OBSERVATION_ONE_ID,
|
|
episode_id: EPISODE_ID,
|
|
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.81,
|
|
uncertainty: 0.19,
|
|
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",
|
|
},
|
|
{
|
|
observation_id: OBSERVATION_TWO_ID,
|
|
episode_id: EPISODE_ID,
|
|
sequence_no: 2,
|
|
event_kind: "repair.partial",
|
|
from_state: "repair_attempted",
|
|
to_state: "partial",
|
|
rupture_type: "withdrawal",
|
|
source_kind: "model_inferred",
|
|
perspective: "independent_observer",
|
|
ai_view: "evaluator",
|
|
confidence: 0.78,
|
|
uncertainty: 0.22,
|
|
evidence_turn_ids: [TURN_UUIDS[2], TURN_UUIDS[3]],
|
|
counterevidence: ["회기 말에는 내담자가 다시 감정을 설명했습니다."],
|
|
model_run_id: "33000000-0000-4000-8000-000000000001",
|
|
supersedes_observation_id: null,
|
|
correction_reason: null,
|
|
created_at: "2026-08-06T09:14:00Z",
|
|
},
|
|
];
|
|
}
|
|
|
|
function episode(corrected = false): RuptureEpisode {
|
|
const baseObservations = observations();
|
|
const correction: RuptureObservation = {
|
|
observation_id: CORRECTION_ID,
|
|
episode_id: EPISODE_ID,
|
|
sequence_no: 3,
|
|
event_kind: "human.corrected",
|
|
from_state: "partial",
|
|
to_state: "resolved",
|
|
rupture_type: "withdrawal",
|
|
source_kind: "human_rated",
|
|
perspective: "supervisor_human",
|
|
ai_view: "supervisor",
|
|
confidence: null,
|
|
uncertainty: 0.1,
|
|
evidence_turn_ids: [TURN_UUIDS[3]],
|
|
counterevidence: ["내담자의 후속 반응이 안정적으로 이어졌습니다."],
|
|
model_run_id: null,
|
|
supersedes_observation_id: OBSERVATION_TWO_ID,
|
|
correction_reason: "후속 반응 근거를 반영해 수선 확인으로 정정했습니다.",
|
|
created_at: "2026-08-06T09:18:00Z",
|
|
};
|
|
return {
|
|
episode_id: EPISODE_ID,
|
|
session_id: FILLED_REVIEW_SESSION_ID,
|
|
case_id: "34000000-0000-4000-8000-000000000001",
|
|
learner_id: "34000000-0000-4000-8000-000000000002",
|
|
episode_key: "withdrawal-after-premature-advice",
|
|
created_at: "2026-08-06T09:10:00Z",
|
|
rupture_type: "withdrawal",
|
|
current_status: corrected ? "resolved" : "partial",
|
|
status_source: corrected ? "human_correction" : "deep_reconciliation",
|
|
observations: corrected ? [...baseObservations, correction] : baseObservations,
|
|
reconciliation_revisions: [
|
|
{
|
|
revision_id: "35000000-0000-4000-8000-000000000001",
|
|
episode_id: EPISODE_ID,
|
|
revision_no: 1,
|
|
supersedes_revision_id: null,
|
|
fast_warning_observation_id: OBSERVATION_ONE_ID,
|
|
deep_observation_id: OBSERVATION_TWO_ID,
|
|
fast_warning_id: "fast-warning-17",
|
|
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: "33000000-0000-4000-8000-000000000001",
|
|
created_at: "2026-08-06T09:16:00Z",
|
|
},
|
|
],
|
|
safety_references: [
|
|
{
|
|
episode_id: EPISODE_ID,
|
|
safety_event_id: 71,
|
|
turn_id: TURN_UUIDS[4],
|
|
ko_risk_level: 2,
|
|
escalated: true,
|
|
created_at: "2026-08-06T09:12:00Z",
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function readModel(role: ReviewRole, corrected = false): RuptureRepairReadModel {
|
|
return {
|
|
session_id: FILLED_REVIEW_SESSION_ID,
|
|
requested_view: role === "teacher" ? "supervisor" : "counselor",
|
|
clinical_claim_allowed: false,
|
|
episodes: [episode(corrected)],
|
|
};
|
|
}
|
|
|
|
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 page.route(
|
|
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/outcome-trajectory`,
|
|
(route) => fulfillJson(route, { detail: "not found" }, 404),
|
|
);
|
|
await routePrepostMeasures(page);
|
|
}
|
|
|
|
async function prepareReview(page: Page, role: ReviewRole) {
|
|
await routeReviewUser(page, role);
|
|
await routeReviewPage(page, role);
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
test.describe("G3 균열과 수선 원장", () => {
|
|
test("학습자에게 현재 판정, 출처, 근거, 안전 원장과 로컬 연습 준비를 보여준다", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
await prepareReview(page, "learner");
|
|
await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/ruptures`, (route) =>
|
|
fulfillJson(route, readModel("learner")),
|
|
);
|
|
|
|
const card = await openFeedback(page, "learner");
|
|
await expect(card.getByRole("heading", { name: "관계가 어긋난 장면과 다시 맞춘 근거를 봅니다" })).toBeVisible();
|
|
await expect(card.getByText("학습자 역할 보기", { exact: true })).toBeVisible();
|
|
await expect(card.getByText("철수형 균열", { exact: true })).toBeVisible();
|
|
await expect(card.getByText("부분 수선", { exact: true }).first()).toBeVisible();
|
|
await expect(card.getByText("불확실성")).toBeVisible();
|
|
await expect(card.getByText("22%", { exact: true })).toBeVisible();
|
|
await expect(card.getByText("빠른 경고", { exact: true })).toBeVisible();
|
|
await expect(
|
|
card.getByLabel("빠른 경고와 깊은 재조정 출처").getByText("깊은 재조정", { exact: true }),
|
|
).toBeVisible();
|
|
await expect(card.getByText("부분 수선으로 갱신", { exact: false })).toBeVisible();
|
|
await expect(card.getByRole("heading", { name: "현재 판정 근거" })).toBeVisible();
|
|
await expect(card.getByRole("heading", { name: "반대 근거" })).toBeVisible();
|
|
await expect(card.getByRole("heading", { name: "안전 원장" })).toBeVisible();
|
|
await expect(card.getByText("균열·수선 판정과 합산하지 않는 별도 확인 영역입니다.")).toBeVisible();
|
|
await expect(card.getByText(/총점|평균/)).toHaveCount(0);
|
|
await expect(card.getByText("사람 판정으로 정정")).toHaveCount(0);
|
|
|
|
const practice = card.locator(".rr-practice");
|
|
const firstItem = practice.getByLabel("균열이 시작된 발화를 다시 확인하기");
|
|
await firstItem.check();
|
|
await expect(firstItem).toBeChecked();
|
|
await page.reload();
|
|
await page.getByRole("tab", { name: "피드백" }).click();
|
|
await expect(page.locator(".rr-practice").getByLabel("균열이 시작된 발화를 다시 확인하기")).toBeChecked();
|
|
|
|
const refreshedCard = page.locator(".rr-card");
|
|
await refreshedCard.getByRole("button", { name: /발화로 이동/ }).first().click();
|
|
await expect(page.getByRole("tab", { name: "축어록" })).toHaveAttribute("aria-selected", "true");
|
|
await expect(page.locator(".sr-turn--active")).toHaveCount(1);
|
|
|
|
await page.getByRole("tab", { name: "피드백" }).click();
|
|
await refreshedCard.locator(".rr-provenance").screenshot({
|
|
path: testInfo.outputPath(`rupture-provenance-${testInfo.project.name}.png`),
|
|
animations: "disabled",
|
|
});
|
|
await refreshedCard.locator(".rr-evidence-grid").screenshot({
|
|
path: testInfo.outputPath(`rupture-evidence-${testInfo.project.name}.png`),
|
|
animations: "disabled",
|
|
});
|
|
await refreshedCard.locator(".rr-practice").screenshot({
|
|
path: testInfo.outputPath(`rupture-practice-${testInfo.project.name}.png`),
|
|
animations: "disabled",
|
|
});
|
|
await expectNoHorizontalOverflow(page);
|
|
});
|
|
|
|
test("404와 역할별 빈 원장은 차분한 빈 상태로 처리한다", async ({ page }) => {
|
|
await prepareReview(page, "learner");
|
|
await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/ruptures`, (route) =>
|
|
fulfillJson(route, { detail: "rupture ledger not found" }, 404),
|
|
);
|
|
const card = await openFeedback(page, "learner");
|
|
await expect(card.getByRole("heading", { name: "아직 검토할 관계 장면이 없습니다" })).toBeVisible();
|
|
await expect(card.getByRole("button", { name: "다시 불러오기" })).toHaveCount(0);
|
|
await expectNoHorizontalOverflow(page);
|
|
});
|
|
|
|
test("교수자는 최신 관찰을 supersede하고 성공 뒤 read model을 다시 불러온다", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
await prepareReview(page, "teacher");
|
|
let corrected = false;
|
|
let getCount = 0;
|
|
const submissions: Array<Record<string, unknown>> = [];
|
|
await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/ruptures`, async (route) => {
|
|
getCount += 1;
|
|
await fulfillJson(route, readModel("teacher", corrected));
|
|
});
|
|
await page.route(
|
|
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/ruptures/${EPISODE_ID}/corrections`,
|
|
async (route) => {
|
|
submissions.push(route.request().postDataJSON() as Record<string, unknown>);
|
|
corrected = true;
|
|
await fulfillJson(route, { episode_id: EPISODE_ID, observation_id: CORRECTION_ID }, 201);
|
|
},
|
|
);
|
|
|
|
const card = await openFeedback(page, "teacher");
|
|
await expect(card.getByText("교수자 역할 보기", { exact: true })).toBeVisible();
|
|
await expect(card.locator(".rr-practice")).toHaveCount(0);
|
|
await card.getByText("사람 판정으로 정정", { exact: true }).click();
|
|
const form = card.locator(".rr-correction form");
|
|
await form.getByLabel("정정 상태").selectOption("resolved");
|
|
await form.getByLabel("정정 이유").fill("후속 반응이 안정적으로 이어진 근거를 반영합니다.");
|
|
await form.getByRole("button", { name: "정정 기록 추가" }).click();
|
|
await expect(form.getByText("사람 판정을 새 관찰로 추가하고 최신 원장을 다시 불러왔습니다.")).toBeVisible();
|
|
await expect(card.getByText("사람 판정이 최신", { exact: true })).toBeVisible();
|
|
await expect(card.getByText("수선 확인", { exact: true }).first()).toBeVisible();
|
|
expect(getCount).toBeGreaterThanOrEqual(2);
|
|
expect(submissions).toHaveLength(1);
|
|
expect(submissions[0].supersedes_observation_id).toBe(OBSERVATION_TWO_ID);
|
|
expect(submissions[0].evidence_turn_ids).toEqual([TURN_UUIDS[2], TURN_UUIDS[3]]);
|
|
|
|
await card.locator(".rr-correction").screenshot({
|
|
path: testInfo.outputPath(`rupture-correction-${testInfo.project.name}.png`),
|
|
animations: "disabled",
|
|
});
|
|
await expectNoHorizontalOverflow(page);
|
|
});
|
|
|
|
test("정정 오류 재시도는 동일 idempotency UUID와 본문을 보존한다", async ({ page }) => {
|
|
await prepareReview(page, "teacher");
|
|
await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/ruptures`, (route) =>
|
|
fulfillJson(route, readModel("teacher")),
|
|
);
|
|
const submissions: Array<Record<string, unknown>> = [];
|
|
await page.route(
|
|
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/ruptures/${EPISODE_ID}/corrections`,
|
|
async (route) => {
|
|
submissions.push(route.request().postDataJSON() as Record<string, unknown>);
|
|
if (submissions.length === 1) {
|
|
await fulfillJson(route, { detail: "temporary correction failure" }, 503);
|
|
return;
|
|
}
|
|
await fulfillJson(route, { episode_id: EPISODE_ID, observation_id: CORRECTION_ID }, 201);
|
|
},
|
|
);
|
|
|
|
const card = await openFeedback(page, "teacher");
|
|
await card.getByText("사람 판정으로 정정", { exact: true }).click();
|
|
const form = card.locator(".rr-correction form");
|
|
await form.getByLabel("정정 이유").fill("사람 검토 근거를 반영합니다.");
|
|
await form.getByRole("button", { name: "정정 기록 추가" }).click();
|
|
await expect(form.getByRole("alert")).toContainText("API 503");
|
|
await form.getByRole("button", { name: "같은 요청 다시 제출" }).click();
|
|
await expect(form.getByText(/최신 원장을 다시 불러왔습니다/)).toBeVisible();
|
|
expect(submissions).toHaveLength(2);
|
|
expect(submissions[1]).toEqual(submissions[0]);
|
|
expect(submissions[1].idempotency_key).toBe(submissions[0].idempotency_key);
|
|
});
|
|
});
|