G0~G8 성과·동맹 측정 OS 작업 일괄 고정

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 산출물은 커밋에서 제외했다.
This commit is contained in:
Yun Chan 2026-08-08 01:30:53 +09:00
parent 93dd8f82d7
commit 16e791e044
390 changed files with 243188 additions and 499 deletions

View file

@ -0,0 +1,622 @@
import { expect, test, type Page, type Route } from "@playwright/test";
import type {
AxisTrajectoryAssessment,
OutcomeAxis,
OutcomeObservationSubmissionResponse,
OutcomeTrajectoryResponse,
SyntheticExpectedDistribution,
TrajectoryStatus,
} from "../src/pages/session-review/outcomeTrajectoryApi";
import {
FILLED_REVIEW_SESSION_ID,
filledReviewResponse,
routePrepostMeasures,
} from "./session-review-fixture";
import { expectNoHorizontalOverflow } from "./support";
type ReviewRole = "learner" | "teacher";
const AXES: OutcomeAxis[] = [
"distress_load",
"daily_functioning",
"learning_engagement",
];
const EXPECTED: Record<number, Record<OutcomeAxis, number>> = {
1: { distress_load: 0.7, daily_functioning: 0.3, learning_engagement: 0.4 },
2: { distress_load: 0.62, daily_functioning: 0.4, learning_engagement: 0.48 },
3: { distress_load: 0.54, daily_functioning: 0.5, learning_engagement: 0.56 },
4: { distress_load: 0.46, daily_functioning: 0.6, learning_engagement: 0.64 },
5: { distress_load: 0.38, daily_functioning: 0.68, learning_engagement: 0.72 },
};
const OBSERVED: Record<number, Record<OutcomeAxis, number | null>> = {
1: { distress_load: 0.71, daily_functioning: 0.31, learning_engagement: 0.42 },
2: { distress_load: 0.73, daily_functioning: 0.34, learning_engagement: 0.41 },
3: { distress_load: 0.67, daily_functioning: 0.38, learning_engagement: null },
4: { distress_load: 0.82, daily_functioning: 0.29, learning_engagement: 0.3 },
};
const TURN_UUIDS = [
"10000000-0000-4000-8000-000000000001",
"10000000-0000-4000-8000-000000000002",
"10000000-0000-4000-8000-000000000003",
"10000000-0000-4000-8000-000000000004",
"10000000-0000-4000-8000-000000000005",
"10000000-0000-4000-8000-000000000006",
];
function distributions(): SyntheticExpectedDistribution[] {
return [1, 2, 3, 4, 5].flatMap((sessionNo) =>
AXES.map((axis) => {
const mean = EXPECTED[sessionNo][axis];
return {
session_no: sessionNo,
axis,
mean,
standard_deviation: 0.1,
lower_reference: Math.max(0, mean - 0.2),
upper_reference: Math.min(1, mean + 0.2),
sample_size: 200,
expected_direction:
axis === "distress_load" ? "lower_is_better" : "higher_is_better",
};
}),
);
}
function axisAssessment(
sessionNo: number,
axis: OutcomeAxis,
sessionStatus: TrajectoryStatus,
): AxisTrajectoryAssessment {
const observed = OBSERVED[sessionNo][axis];
const missing = observed == null;
return {
session_no: sessionNo,
axis,
status: missing ? "insufficient_evidence" : sessionStatus,
observed_value: observed,
expected_mean: EXPECTED[sessionNo][axis],
adverse_z: missing ? null : sessionNo === 4 ? 2.1 : 0.7,
adverse_z_change: missing ? null : sessionNo === 4 ? 0.8 : 0.2,
uncertainty: missing ? 1 : sessionNo === 4 ? 0.24 : 0.36,
decision_basis: missing
? ["학습 참여 관측이 저장되지 않아 예상선과 비교하지 않았습니다."]
: [
sessionNo === 4
? "직전 회기보다 불리한 방향의 변화가 두 회기 연속 관찰됐습니다."
: "교육용 예상 범위와 관측 근거를 축별로 비교했습니다.",
],
counterevidence:
sessionNo === 4 && axis === "distress_load"
? ["회기 말에는 감정을 언어로 표현한 장면도 확인됐습니다."]
: [],
evidence_refs: missing ? [] : [axis === "distress_load" ? "t3" : "t6"],
};
}
function learnerCheckinObservations(tag: string) {
const values = {
distress_load: 0.75,
daily_functioning: 0.5,
learning_engagement: 0.5,
} satisfies Record<OutcomeAxis, number>;
return AXES.map((axis) => ({
measurement_id: `learner-${tag}-${axis}`,
session_id: FILLED_REVIEW_SESSION_ID,
session_no: 4,
axis,
status: "observed" as const,
value: values[axis],
raw_value: values[axis],
scale_min: 0,
scale_max: 1,
confidence: 0.66,
source_kind: "learner_reported" as const,
perspective: "learner_self_report" as const,
instrument_id: "outcome-learner-checkin",
instrument_version: "1.0.0",
model_run_id: null,
evidence_refs: ["t3"],
missing_reason: null,
occurred_at: "2026-08-06T09:20:00Z",
}));
}
function trajectoryResponse(): OutcomeTrajectoryResponse {
const sessionStatuses: Record<number, TrajectoryStatus> = {
1: "on_track",
2: "watch",
3: "insufficient_evidence",
4: "deteriorating",
};
return {
session_id: FILLED_REVIEW_SESSION_ID,
revision_id: "00000000-0000-0000-0000-000000000404",
revision_no: 4,
supersedes_revision_id: "00000000-0000-0000-0000-000000000303",
source_fingerprint: "sha256:e2e-outcome-trajectory-revision-4",
recompute_reason: "session_completed",
computed_at: "2026-08-06T09:30:00Z",
notice_ko:
"실제 치료 효과, 임상 규준, 진단 또는 예후를 뜻하지 않는 교육용 합성 비교선입니다.",
expected_arc: {
schema_version: "vignette.synthetic-outcome-arc.v1",
arc_id: "oas-g2-arc-001",
title_ko: "교육용 초기 5회기 기대 궤적",
data_classification: "synthetic_educational",
clinical_claim_allowed: false,
provenance_note:
"교육용 합성 사례의 결정론 테스트 분포이며 실제 내담자, 임상 규준, 치료 효과 또는 진단 예측을 나타내지 않습니다.",
session_count: 5,
distributions: distributions(),
},
assessment: {
schema_version: "vignette.outcome-trajectory-assessment.v1",
expected_arc_id: "oas-g2-arc-001",
data_classification: "synthetic_educational",
clinical_claim_allowed: false,
sessions: [1, 2, 3, 4].map((sessionNo) => ({
session_no: sessionNo,
status: sessionStatuses[sessionNo],
axes: AXES.map((axis) => axisAssessment(sessionNo, axis, sessionStatuses[sessionNo])),
missing_axes: sessionNo === 3 ? ["learning_engagement"] : [],
next_check_questions:
sessionNo === 4
? [
"고통 부담이 커진 구체 장면을 먼저 확인했나요?",
"일상 기능의 변화를 내담자의 말로 다시 확인했나요?",
]
: ["다음 회기에서 같은 축을 같은 시점에 다시 확인했나요?"],
safety_signals: sessionNo === 2 ? [safetySignal()] : [],
})),
},
observations: [1, 2, 3, 4]
.flatMap((sessionNo) =>
AXES.map((axis) => {
const value = OBSERVED[sessionNo][axis];
return {
measurement_id: `measurement-${sessionNo}-${axis}`,
session_id: FILLED_REVIEW_SESSION_ID,
session_no: sessionNo,
axis,
status: value == null ? "missing" : "observed",
value,
raw_value: value,
scale_min: 0,
scale_max: 1,
confidence: value == null ? null : 0.84,
source_kind: "simulated_state",
perspective: "client_simulation",
instrument_id: "g2-synthetic-session-outcome",
instrument_version: "1.0.0",
model_run_id: null,
evidence_refs: value == null ? [] : [axis === "distress_load" ? "t3" : "t6"],
missing_reason:
value == null
? "회기 종료 전 학습 참여 확인 응답이 저장되지 않았습니다."
: null,
occurred_at: `2026-08-0${sessionNo}T09:00:00Z`,
};
}),
)
.concat(learnerCheckinObservations("existing")),
safety_signals: [safetySignal()],
relationship_memory: [
{
event_id: "relationship-goal-1",
session_no: 1,
event_type: "goal_agreement",
summary: "비교 경험에서 올라오는 감정을 먼저 살피기로 합의했습니다.",
evidence_refs: ["t2"],
resolved_by_event_id: null,
},
{
event_id: "relationship-rupture-2",
session_no: 2,
event_type: "unresolved_rupture",
summary: "행동 연습의 속도가 빠르게 느껴졌는지 다음 회기에 다시 확인할 필요가 있습니다.",
evidence_refs: ["t5"],
resolved_by_event_id: "relationship-repair-3",
},
{
event_id: "relationship-repair-3",
session_no: 3,
event_type: "repair_confirmed",
summary: "부담을 다시 확인하고 내담자가 선택한 작은 연습으로 조정했습니다.",
evidence_refs: ["t6"],
resolved_by_event_id: null,
},
{
event_id: "relationship-task-4",
session_no: 4,
event_type: "task_agreement",
summary: "다음 회기에는 일상 기능의 변화를 먼저 확인하기로 합의했습니다.",
evidence_refs: ["t6"],
resolved_by_event_id: null,
},
],
next_questions: [
"고통 부담이 커진 구체 장면을 먼저 확인했나요?",
"일상 기능의 변화를 내담자의 말로 다시 확인했나요?",
"다음 회기에서 같은 축을 같은 시점에 다시 확인했나요?",
],
};
}
function submittedTrajectoryResponse(
submissionId: string,
): OutcomeObservationSubmissionResponse {
const current = trajectoryResponse();
const submittedMeasurementIds = AXES.map((axis) => `learner-new-${axis}`);
return {
...current,
revision_id: "00000000-0000-0000-0000-000000000505",
revision_no: 5,
supersedes_revision_id: current.revision_id,
source_fingerprint: "sha256:e2e-outcome-trajectory-revision-5",
recompute_reason: "learner_outcome_observation",
computed_at: "2026-08-06T09:35:00Z",
observations: [
...current.observations,
...learnerCheckinObservations("new").map((observation, index) => ({
...observation,
measurement_id: submittedMeasurementIds[index],
confidence: 1,
occurred_at: "2026-08-06T09:35:00Z",
})),
],
assessment: {
...current.assessment,
sessions: current.assessment.sessions.map((session) =>
session.session_no === 4
? {
...session,
status: "watch",
axes: session.axes.map((axis) => ({ ...axis, status: "watch" })),
}
: session,
),
},
submission_id: submissionId,
submitted_measurement_ids: submittedMeasurementIds,
};
}
function safetySignal() {
return {
safety_event_id: "safety-ledger-2026-08-06-01",
session_no: 2,
risk_level: "high" as const,
escalated: true,
evidence_refs: ["t3"],
};
}
async function fulfillJson(route: Route, body: unknown, status = 200) {
await route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify(body),
});
}
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 routeTrajectory(page: Page, handler: (route: Route) => Promise<void>) {
await page.route(
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/outcome-trajectory`,
handler,
);
}
async function routeOutcomeSubmission(
page: Page,
handler: (route: Route) => Promise<void>,
) {
await page.route(
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/outcome-observations`,
handler,
);
}
async function completeLearnerCheckin(page: Page) {
const checkin = page.locator(".ot-checkin");
const axes = checkin.locator(".ot-checkin-axis");
await axes.nth(0).locator(".ot-checkin-scale--score label").nth(3).click();
await axes.nth(1).locator(".ot-checkin-scale--score label").nth(2).click();
await axes.nth(2).locator(".ot-checkin-scale--score label").nth(1).click();
await axes.nth(0).locator(".ot-checkin-scale--confidence label").nth(2).click();
await axes.nth(1).locator(".ot-checkin-scale--confidence label").nth(1).click();
await axes.nth(2).locator(".ot-checkin-scale--confidence label").nth(0).click();
return checkin;
}
test.describe("G2 종단 성과 궤적", () => {
test.beforeEach(async ({ page }) => {
await routeReviewUser(page, "learner");
await routeReviewPage(page, "learner");
});
test("축별 궤적, 누락, 안전, 관계 기억과 자기주도 질문을 독립적으로 보여준다", async ({
page,
}, testInfo) => {
await routeTrajectory(page, async (route) => {
await new Promise((resolve) => setTimeout(resolve, 1_500));
await fulfillJson(route, trajectoryResponse());
});
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
await page.getByRole("tab", { name: "피드백" }).click();
await expect(page.getByLabel("종단 성과 궤적 불러오는 중")).toBeVisible();
const card = page.locator(".ot-card");
await expect(card.getByRole("heading", { name: "회기 사이의 변화를 근거와 함께 봅니다" })).toBeVisible();
await expect(card.getByText("synthetic_educational", { exact: true })).toBeVisible();
await expect(card.getByText("clinical_claim_allowed: false", { exact: true })).toBeVisible();
await expect(card.getByText(/총점/)).toHaveCount(0);
await expect(card.getByRole("tab", { name: /4회기 악화 신호/ })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(card.getByRole("heading", { name: "고통 부담", exact: true })).toBeVisible();
await expect(card.getByRole("heading", { name: "일상 기능", exact: true })).toBeVisible();
await expect(card.getByRole("heading", { name: "학습 참여", exact: true })).toBeVisible();
await expect(card.getByRole("heading", { name: "안전 신호" })).toBeVisible();
await expect(card.getByText("성과 궤적과 합산하지 않는 별도 확인 영역입니다.")).toBeVisible();
await expect(card.getByText("미해결 균열", { exact: true })).toBeVisible();
await expect(card.getByText("복구 확인", { exact: true })).toBeVisible();
await expect(card.getByRole("heading", { name: "4회기 학습자 체크인" })).toBeVisible();
await expect(card.getByText("고통 부담은 높을수록 현재 부담이 큽니다.")).toBeVisible();
await expect(card.getByText(/이전 체크인은 수정되지 않습니다/)).toBeVisible();
await card.locator(".ot-checkin-evidence summary").click();
const firstQuestion = card.getByLabel("고통 부담이 커진 구체 장면을 먼저 확인했나요?");
await firstQuestion.check();
await expect(firstQuestion).toBeChecked();
const fourthTab = card.getByRole("tab", { name: /4회기 악화 신호/ });
await fourthTab.focus();
await page.keyboard.press("Home");
await expect(card.getByRole("tab", { name: /1회기 예상 범위/ })).toHaveAttribute(
"aria-selected",
"true",
);
await page.keyboard.press("End");
await expect(card.getByRole("tab", { name: /5회기 자료 대기/ })).toHaveAttribute(
"aria-selected",
"true",
);
await card.getByRole("tab", { name: /3회기 근거 부족/ }).click();
await expect(card.getByRole("heading", { name: /학습자 체크인/ })).toHaveCount(0);
await expect(card.getByText("체크인은 최신 회기에 새 기록으로 추가합니다")).toBeVisible();
const learningAxis = card.locator(".ot-axis", { hasText: "학습 참여" });
await expect(learningAxis.getByText("관측 없음", { exact: true }).first()).toBeVisible();
await expect(learningAxis.getByText(/누락 사유: 회기 종료 전/)).toBeVisible();
await learningAxis.getByText("판정 근거와 반대 근거 보기").click();
await expect(learningAxis.getByText(/예상선과 비교하지 않았습니다/)).toBeVisible();
await card.getByRole("tab", { name: /4회기 악화 신호/ }).click();
await card.locator(".ot-checkin-evidence summary").click();
await card.locator(".ot-contract").screenshot({
path: testInfo.outputPath(`outcome-trajectory-contract-${testInfo.project.name}.png`),
animations: "disabled",
});
await card.locator(".ot-timeline-wrap").screenshot({
path: testInfo.outputPath(`outcome-trajectory-timeline-${testInfo.project.name}.png`),
animations: "disabled",
});
await card.locator(".ot-checkin").screenshot({
path: testInfo.outputPath(`outcome-trajectory-checkin-${testInfo.project.name}.png`),
animations: "disabled",
});
await card.locator(".ot-checkin-axis").last().screenshot({
path: testInfo.outputPath(`outcome-trajectory-checkin-axis-${testInfo.project.name}.png`),
animations: "disabled",
});
await card.locator(".ot-checkin-evidence").screenshot({
path: testInfo.outputPath(`outcome-trajectory-checkin-evidence-${testInfo.project.name}.png`),
animations: "disabled",
});
await card.locator(".ot-checkin__actions").screenshot({
path: testInfo.outputPath(`outcome-trajectory-checkin-actions-${testInfo.project.name}.png`),
animations: "disabled",
});
await card.locator(".ot-session-panel").screenshot({
path: testInfo.outputPath(`outcome-trajectory-axes-${testInfo.project.name}.png`),
animations: "disabled",
});
await card.locator(".ot-support-grid").screenshot({
path: testInfo.outputPath(`outcome-trajectory-support-${testInfo.project.name}.png`),
animations: "disabled",
});
await card.locator(".ot-relationship").screenshot({
path: testInfo.outputPath(`outcome-trajectory-relationship-${testInfo.project.name}.png`),
animations: "disabled",
});
await expectNoHorizontalOverflow(page);
});
test("세 축과 확신도를 독립 제출하고 성공 응답으로 즉시 갱신한다", async ({
page,
}) => {
await routeTrajectory(page, (route) => fulfillJson(route, trajectoryResponse()));
const submissions: Array<Record<string, unknown>> = [];
await routeOutcomeSubmission(page, async (route) => {
const body = route.request().postDataJSON() as Record<string, unknown>;
submissions.push(body);
await new Promise((resolve) => setTimeout(resolve, 280));
await fulfillJson(
route,
submittedTrajectoryResponse(String(body.submission_id)),
201,
);
});
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
await page.getByRole("tab", { name: "피드백" }).click();
const checkin = await completeLearnerCheckin(page);
await checkin.getByText("축어록 장면 선택 (선택)").click();
await checkin.getByRole("checkbox").first().check();
const submit = checkin.locator('button[type="submit"]');
await submit.click();
await expect(submit).toBeDisabled();
await expect(submit).toHaveText("체크인 기록 중");
await expect(checkin.getByText(/append-only 원장에 새 기록으로 추가했습니다/)).toBeVisible();
expect(submissions).toHaveLength(1);
expect(submissions[0]).toMatchObject({
scores: {
distress_load: 0.75,
daily_functioning: 0.5,
learning_engagement: 0.25,
},
confidences: {
distress_load: 1,
daily_functioning: 0.66,
learning_engagement: 0.33,
},
evidence_turn_ids: [TURN_UUIDS[0]],
});
expect(String(submissions[0].submission_id)).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
);
await expect(
page.locator(".ot-card").getByRole("tab", { name: /4회기 다음 회기 확인/ }),
).toHaveAttribute("aria-selected", "true");
await expectNoHorizontalOverflow(page);
});
test("제출 오류 재시도는 같은 submission_id를 보존한다", async ({ page }) => {
await routeTrajectory(page, (route) => fulfillJson(route, trajectoryResponse()));
const submissions: Array<Record<string, unknown>> = [];
await routeOutcomeSubmission(page, async (route) => {
const body = route.request().postDataJSON() as Record<string, unknown>;
submissions.push(body);
if (submissions.length === 1) {
await fulfillJson(route, { detail: "temporary learner check-in failure" }, 503);
return;
}
await fulfillJson(
route,
submittedTrajectoryResponse(String(body.submission_id)),
201,
);
});
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
await page.getByRole("tab", { name: "피드백" }).click();
const checkin = await completeLearnerCheckin(page);
await checkin.getByRole("button", { name: "세 축 체크인 추가" }).click();
await expect(checkin.getByRole("alert")).toContainText("API 503");
await checkin.getByRole("button", { name: "같은 요청 다시 제출" }).click();
await expect(checkin.getByText(/append-only 원장에 새 기록으로 추가했습니다/)).toBeVisible();
expect(submissions).toHaveLength(2);
expect(submissions[1].submission_id).toBe(submissions[0].submission_id);
expect(submissions[1]).toEqual(submissions[0]);
});
test("404는 오류 대신 관측 대기 빈 상태로 안내한다", async ({ page }) => {
await routeTrajectory(page, (route) =>
fulfillJson(route, { detail: "outcome trajectory not found" }, 404),
);
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
await page.getByRole("tab", { name: "피드백" }).click();
const card = page.locator(".ot-card");
await expect(card.getByRole("heading", { name: "아직 이어 볼 회기 자료가 없습니다" })).toBeVisible();
await expect(card.getByRole("button", { name: "다시 불러오기" })).toHaveCount(0);
});
test("일시적 오류에서 같은 카드 안에서 다시 불러온다", async ({ page }) => {
let attempts = 0;
let allowSuccess = false;
await routeTrajectory(page, async (route) => {
attempts += 1;
if (!allowSuccess) {
await fulfillJson(route, { detail: "trajectory temporarily unavailable" }, 503);
return;
}
await fulfillJson(route, trajectoryResponse());
});
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
await page.getByRole("tab", { name: "피드백" }).click();
const card = page.locator(".ot-card");
await expect(card.getByRole("heading", { name: "궤적 자료를 불러오지 못했습니다" })).toBeVisible();
allowSuccess = true;
await card.getByRole("button", { name: "다시 불러오기" }).click();
await expect(card.getByRole("heading", { name: "회기 사이의 변화를 근거와 함께 봅니다" })).toBeVisible();
expect(attempts).toBeGreaterThanOrEqual(2);
});
test("교수자 보기에는 역할 허용 관계 요약만 표시한다", async ({ page }) => {
await page.unroute("**/api/auth/me");
await page.unroute(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/review`);
await routeReviewUser(page, "teacher");
await routeReviewPage(page, "teacher");
await routeTrajectory(page, (route) => fulfillJson(route, trajectoryResponse()));
await page.goto(`/teach/session/${FILLED_REVIEW_SESSION_ID}/review`);
await page.getByRole("tab", { name: "피드백" }).click();
const card = page.locator(".ot-card");
await expect(card.getByText("교수자 역할 보기", { exact: true })).toBeVisible();
await expect(card.getByText("교수자 역할에 허용된 요약만 시간순으로 표시합니다.")).toBeVisible();
await expect(card.getByText(/내담자 내부 상태/)).toHaveCount(0);
await expect(card.getByRole("heading", { name: /학습자 체크인/ })).toHaveCount(0);
await expectNoHorizontalOverflow(page);
});
});