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 산출물은 커밋에서 제외했다.
622 lines
25 KiB
TypeScript
622 lines
25 KiB
TypeScript
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);
|
|
});
|
|
});
|