vignette/apps/web/e2e/calibration-transfer.spec.ts
Yun Chan 16e791e044 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 산출물은 커밋에서 제외했다.
2026-08-08 01:30:53 +09:00

1067 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { expect, test, type Page, type Route } from "@playwright/test";
import type {
ActualTransferExecutionRequest,
ActualTransferExecutionResponse,
CalibrationTransferReadModelResponse,
PredictionLockRequest,
PredictionRevisionRequest,
TeacherCorrectionRequest,
} from "../src/pages/session-review/calibrationTransferApi";
import { parsePracticeLaunchIntent } from "../src/lib/practiceLaunchIntent";
import { filledReviewResponse } from "./session-review-fixture";
import { expectNoHorizontalOverflow } from "./support";
const SESSION_ID = "51000000-0000-0000-0000-000000000110";
const PRACTICE_SESSION_ID = "51000000-0000-0000-0000-000000000111";
const LEARNER_ID = "51000000-0000-0000-0000-000000000001";
const HISTORY_ID = "51000000-0000-0000-0000-000000000210";
const REVISION_ID = "51000000-0000-0000-0000-000000000211";
const ASSESSMENT_ID = "51000000-0000-0000-0000-000000000410";
const TRANSFER_SUITE_ID = "51000000-0000-0000-0000-000000000510";
const TRANSFER_ASSESSMENT_ID = "51000000-0000-0000-0000-000000000520";
const DRIFT_ID = "51000000-0000-0000-0000-000000000530";
function routeAuth(page: Page, role: "learner" | "teacher") {
return page.route("**/auth/me", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
user_id:
role === "learner"
? LEARNER_ID
: "51000000-0000-0000-0000-000000000002",
email: `${role}@hs.ac.kr`,
role,
display_name: role === "learner" ? "E2E Learner" : "E2E Teacher",
admin_access: false,
super_admin: false,
account_status: "approved",
approval_required: false,
cohort_ids: ["g5-cohort"],
consent_at: 1782820000,
onboarding_completed_at: 1782820001,
nickname: role === "learner" ? "E2E Learner" : "E2E Teacher",
self_introduction: "",
avatar_url: "",
}),
}),
);
}
function routeUnmockedApi(page: Page) {
return page.route("**/api/**", (route) =>
route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ detail: "not part of the focused G5 fixture" }),
}),
);
}
async function routeReviewShell(page: Page, reviewSessionId = SESSION_ID) {
await page.route(`**/api/sessions/${reviewSessionId}/review`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(filledReviewResponse(reviewSessionId)),
}),
);
await page.route("**/api/users/me/prepost-measures", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: [] }),
}),
);
await page.route(`**/api/sessions/${reviewSessionId}/alliance-pulses`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: [] }),
}),
);
for (const suffix of ["outcome-trajectory", "ruptures"]) {
await page.route(`**/api/sessions/${reviewSessionId}/${suffix}`, (route) =>
route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ detail: "fixture owns only the G5 surface" }),
}),
);
}
await page.route("**/practice/learners/**", (route) =>
route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ detail: "fixture owns only the G5 surface" }),
}),
);
}
function initialReadModel(
requestedView: "learner" | "supervisor" = "learner",
): CalibrationTransferReadModelResponse {
return {
learner_id: LEARNER_ID,
requested_view: requestedView,
clinical_claim_allowed: false,
prediction_histories: [
{
history_id: HISTORY_ID,
session_id: SESSION_ID,
competency_id: "competency.empathic_reflection",
practice_block_id: "oas-g5-block-session-review",
scenario_variant_id: "unseen-school-refusal",
phrase_family_id: "learner-reflection-a",
created_at: "2026-08-06T01:00:00Z",
revisions: [
{
prediction_revision_id: REVISION_ID,
submission_id: "51000000-0000-0000-0000-000000000212",
history_id: HISTORY_ID,
revision_no: 1,
supersedes_prediction_revision_id: null,
predicted_success_probability: 0.6,
confidence: 0.55,
recorded_sequence: 1,
revision_reason: "반영 뒤 내담자 반응이 조금 열렸기 때문",
source_kind: "learner_reported",
perspective: "learner_self_report",
instrument_id: "vignette.calibration-self-prediction",
instrument_version: "1.0.0",
evidence_turn_ids: [],
created_at: "2026-08-06T01:00:00Z",
},
],
lock: null,
external_observation: null,
},
],
calibration_assessments: [],
transfer_suites: [],
teacher_reviews: [],
actual_executions: [],
actual_transfer_assessments: [],
};
}
function actualTransferResponse(
idempotentReplay: boolean,
): ActualTransferExecutionResponse {
return {
execution: {
execution_event_id: "51000000-0000-0000-0000-000000000611",
original_transfer_trial_record_id:
"51000000-0000-0000-0000-000000000515",
practice_session_id: PRACTICE_SESSION_ID,
competency_id: "competency.empathic_reflection",
scenario_variant_id: "family-conflict-confrontational",
scenario_novelty: "unseen_transfer",
variation: {
context_variant: "family-conflict",
relationship_style: "confrontational",
difficulty_level: 5,
expression_variant: "direct-anger",
synthetic_subgroup: "synthetic-family-b",
scenario_family_id: "family-conflict",
phrase_family_id: "novel-reflection-c",
},
training_phrase_collision: false,
status: "passed",
uncertainty: 0.18,
evidence_turn_ids: ["51000000-0000-0000-0000-000000000612"],
normalized_evaluator_labels: {
technique_codes: ["reflection.feeling"],
client_state_codes: ["engaged"],
appropriateness: ["pos"],
intent_deviation_dimensions: [],
evaluator_error_count: 0,
},
counterevidence: [],
model_run_id: "51000000-0000-0000-0000-000000000613",
created_at: "2026-08-06T02:00:00Z",
},
assessment: {
evidence_source: "actual_practice_execution",
competency_id: "competency.empathic_reflection",
execution_count: 1,
independent_execution_count: 1,
observed_execution_count: 1,
success_rate: 1,
success_interval: { method: "wilson_95", lower: 0.21, upper: 1 },
coverage: {
contexts: 1,
relationship_styles: 1,
difficulty_levels: 1,
expression_variants: 1,
scenario_families: 1,
phrase_families: 1,
},
phrase_family_collision_count: 0,
eligible: false,
actual_transfer_status: "insufficient_evidence",
blockers: [
"actual_independent_phrase_families_below_minimum:1/6",
"actual_observed_executions_below_minimum:1/6",
"actual_transfer_coverage_missing:contexts",
],
source_execution_event_ids: [
"51000000-0000-0000-0000-000000000611",
],
evidence_turn_ids: ["51000000-0000-0000-0000-000000000612"],
},
idempotent_replay: idempotentReplay,
};
}
function revealAssessment(readModel: CalibrationTransferReadModelResponse) {
const history = readModel.prediction_histories[0];
const latest = history.revisions.at(-1)!;
history.lock = {
lock_id: "51000000-0000-0000-0000-000000000310",
submission_id: "51000000-0000-0000-0000-000000000311",
history_id: HISTORY_ID,
prediction_revision_id: latest.prediction_revision_id,
locked_sequence: latest.recorded_sequence + 1,
created_at: "2026-08-06T01:02:00Z",
};
history.external_observation = {
observation_id: "51000000-0000-0000-0000-000000000320",
submission_id: "51000000-0000-0000-0000-000000000321",
history_id: HISTORY_ID,
status: "failed",
source_kind: "model_inferred",
perspective: "independent_observer",
model_run_id: "51000000-0000-0000-0000-000000000322",
instrument_id: "vignette.performance-observer",
instrument_version: "1.0.0",
uncertainty: 0.18,
evidence_turn_ids: ["51000000-0000-0000-0000-000000000323"],
counterevidence: ["reflection_did_not_change_response"],
revealed_sequence: latest.recorded_sequence + 2,
created_at: "2026-08-06T01:03:00Z",
};
readModel.calibration_assessments = [
{
assessment_snapshot_id: ASSESSMENT_ID,
submission_id: "51000000-0000-0000-0000-000000000411",
session_id: SESSION_ID,
competency_id: "competency.empathic_reflection",
snapshot_no: 1,
supersedes_assessment_snapshot_id: null,
source_observation_ids: [history.external_observation.observation_id],
assessment_payload: {
competency_id: "competency.empathic_reflection",
pair_count: 4,
mean_absolute_error: 0.24,
mean_signed_error: 0.21,
error_interval: {
method: "normal_95_bounded",
lower: 0.14,
upper: 0.34,
},
bias: "overconfident",
baseline_error: 0.3,
recent_error: 0.24,
improvement: "improved",
pairs: [
{
practice_block_id: "oas-g5-block-001",
prediction_id: "oas-g5-prediction-001",
observation_id: "oas-g5-observation-001",
predicted_success_probability: 0.3,
observed_success: false,
signed_error: 0.3,
absolute_error: 0.3,
confidence: 0.8,
evidence_refs: ["51000000-0000-0000-0000-000000000421"],
},
{
practice_block_id: "oas-g5-block-002",
prediction_id: "oas-g5-prediction-002",
observation_id: "oas-g5-observation-002",
predicted_success_probability: 0.25,
observed_success: false,
signed_error: 0.25,
absolute_error: 0.25,
confidence: 0.6,
evidence_refs: ["51000000-0000-0000-0000-000000000422"],
},
{
practice_block_id: "oas-g5-block-003",
prediction_id: "oas-g5-prediction-003",
observation_id: "oas-g5-observation-003",
predicted_success_probability: 0.35,
observed_success: false,
signed_error: 0.35,
absolute_error: 0.35,
confidence: 0.7,
evidence_refs: ["51000000-0000-0000-0000-000000000423"],
},
{
practice_block_id: "oas-g5-block-004",
prediction_id: "oas-g5-prediction-004",
observation_id: "oas-g5-observation-004",
predicted_success_probability: 0.94,
observed_success: true,
signed_error: -0.06,
absolute_error: 0.06,
confidence: 0.75,
evidence_refs: ["51000000-0000-0000-0000-000000000424"],
},
],
excluded_block_ids: [],
counterevidence: ["recent_prediction_still_above_observation"],
},
model_run_id: "51000000-0000-0000-0000-000000000412",
instrument_id: "vignette.calibration-assessment",
instrument_version: "1.0.0",
evidence_turn_ids: ["51000000-0000-0000-0000-000000000323"],
created_at: "2026-08-06T01:04:00Z",
prescription_id: "51000000-0000-0000-0000-000000000413",
prescription_payload: {
competency_id: "competency.empathic_reflection",
bias: "overconfident",
practice_mode: "counterevidence_forecast",
instruction_ko:
"다음 예측 전에 실패할 수 있는 장면 근거 두 가지를 먼저 적고 성공 가능성을 다시 잠가.",
completion_evidence: [
"two_counterevidence_refs",
"revised_probability_range_before_reveal",
],
},
},
];
readModel.transfer_suites = [
{
transfer_suite_record_id: TRANSFER_SUITE_ID,
submission_id: "51000000-0000-0000-0000-000000000511",
suite_key: "oas-g5-suite-school-context",
session_id: SESSION_ID,
training_phrase_family_ids: ["memorized-reflection-a"],
model_run_id: "51000000-0000-0000-0000-000000000512",
instrument_id: "vignette.unseen-transfer",
instrument_version: "1.0.0",
data_classification: "synthetic_educational",
clinical_claim_allowed: false,
created_at: "2026-08-06T01:05:00Z",
trials: [
{
transfer_trial_record_id: "51000000-0000-0000-0000-000000000513",
transfer_suite_record_id: TRANSFER_SUITE_ID,
trial_key: "oas-g5-transfer-001",
competency_id: "competency.empathic_reflection",
scenario_variant_id: "school-refusal-withdrawn",
scenario_novelty: "unseen_transfer",
context_variant: "school-refusal",
relationship_style: "withdrawn",
difficulty_level: 4,
expression_variant: "indirect-shame",
synthetic_subgroup: "synthetic-school-a",
scenario_family_id: "school-refusal",
phrase_family_id: "memorized-reflection-a",
status: "passed",
uncertainty: 0.2,
evidence_turn_ids: ["51000000-0000-0000-0000-000000000514"],
counterevidence: [],
model_run_id: "51000000-0000-0000-0000-000000000512",
instrument_id: "vignette.unseen-transfer",
instrument_version: "1.0.0",
created_at: "2026-08-06T01:05:00Z",
},
{
transfer_trial_record_id: "51000000-0000-0000-0000-000000000515",
transfer_suite_record_id: TRANSFER_SUITE_ID,
trial_key: "oas-g5-transfer-002",
competency_id: "competency.empathic_reflection",
scenario_variant_id: "family-conflict-confrontational",
scenario_novelty: "unseen_transfer",
context_variant: "family-conflict",
relationship_style: "confrontational",
difficulty_level: 5,
expression_variant: "direct-anger",
synthetic_subgroup: "synthetic-family-b",
scenario_family_id: "family-conflict",
phrase_family_id: "novel-reflection-b",
status: "failed",
uncertainty: 0.22,
evidence_turn_ids: ["51000000-0000-0000-0000-000000000516"],
counterevidence: ["client_rejected_reflection"],
model_run_id: "51000000-0000-0000-0000-000000000512",
instrument_id: "vignette.unseen-transfer",
instrument_version: "1.0.0",
created_at: "2026-08-06T01:06:00Z",
},
],
assessments: [
{
transfer_assessment_id: TRANSFER_ASSESSMENT_ID,
transfer_suite_record_id: TRANSFER_SUITE_ID,
competency_id: "competency.empathic_reflection",
source_trial_ids: [
"51000000-0000-0000-0000-000000000513",
"51000000-0000-0000-0000-000000000515",
],
assessment_payload: {
competency_id: "competency.empathic_reflection",
trial_count: 2,
observed_trial_count: 2,
success_rate: 0.5,
success_interval: { method: "wilson_95", lower: 0.09, upper: 0.91 },
coverage: {
contexts: 2,
relationship_styles: 2,
difficulty_levels: 2,
expression_variants: 2,
scenario_families: 2,
synthetic_subgroups: 2,
},
eligible: false,
transfer_verified: false,
blockers: [
"observed_trials_below_minimum:2/6",
"memorized_training_phrase_reused",
],
evidence_refs: [
"51000000-0000-0000-0000-000000000514",
"51000000-0000-0000-0000-000000000516",
],
counterevidence: ["client_rejected_reflection"],
},
evidence_turn_ids: [
"51000000-0000-0000-0000-000000000514",
"51000000-0000-0000-0000-000000000516",
],
model_run_id: "51000000-0000-0000-0000-000000000512",
instrument_id: "vignette.unseen-transfer",
instrument_version: "1.0.0",
created_at: "2026-08-06T01:07:00Z",
},
],
drift_reports: [
{
drift_report_id: DRIFT_ID,
transfer_suite_record_id: TRANSFER_SUITE_ID,
competency_id: "competency.empathic_reflection",
source_trial_ids: [
"51000000-0000-0000-0000-000000000513",
"51000000-0000-0000-0000-000000000515",
],
report_payload: {
competency_id: "competency.empathic_reflection",
status: "insufficient_evidence",
max_rate_gap: null,
compared_subgroups: [],
subgroup_results: [],
threshold: 0.2,
notice_ko:
"교육용 합성 subgroup별 관측이 부족해 실제 집단 차이를 주장하지 않는다.",
},
model_run_id: "51000000-0000-0000-0000-000000000512",
instrument_id: "vignette.unseen-transfer",
instrument_version: "1.0.0",
data_classification: "synthetic_educational",
clinical_claim_allowed: false,
created_at: "2026-08-06T01:08:00Z",
},
],
},
];
}
async function fulfillReadModel(route: Route, readModel: CalibrationTransferReadModelResponse) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(readModel),
});
}
async function expectAtMainScrollStart(
page: Page,
selector: string,
label: string,
) {
const main = page.locator(".vg-main");
await main.evaluate((element) => {
element.scrollTop = 0;
});
const result = await page.locator(selector).evaluate((element) => {
const mainElement = document.querySelector<HTMLElement>(".vg-main");
if (!mainElement) throw new Error(".vg-main not found");
const mainRect = mainElement.getBoundingClientRect();
const rect = element.getBoundingClientRect();
return {
scrollTop: mainElement.scrollTop,
top: rect.top,
bottom: rect.bottom,
mainTop: mainRect.top,
mainBottom: mainRect.bottom,
height: rect.height,
};
});
expect(result.scrollTop, `${label} main scroll start`).toBe(0);
expect(result.top, `${label} top`).toBeGreaterThanOrEqual(result.mainTop);
expect(result.bottom, `${label} bottom`).toBeLessThanOrEqual(result.mainBottom);
expect(result.height, `${label} touch target`).toBeGreaterThanOrEqual(44);
}
test.describe("G5 calibration mirror and unseen transfer", () => {
test("learner revises then explicitly locks before role-safe reveal", async ({ page }) => {
const readModel = initialReadModel();
let revisionRequests = 0;
let lockRequests = 0;
await routeUnmockedApi(page);
await routeAuth(page, "learner");
await routeReviewShell(page);
await page.route("**/api/calibration/learners/me", (route) =>
fulfillReadModel(route, readModel),
);
await page.route("**/api/calibration/predictions/revisions", async (route) => {
revisionRequests += 1;
const body = route.request().postDataJSON() as PredictionRevisionRequest;
expect(body.revision_no).toBe(2);
expect(body.supersedes_prediction_revision_id).toBe(REVISION_ID);
expect(body).not.toHaveProperty("total_score");
readModel.prediction_histories[0].revisions.push({
...body,
source_kind: "learner_reported",
perspective: "learner_self_report",
created_at: "2026-08-06T01:01:00Z",
});
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({
submission_id: body.submission_id,
history_id: body.history_id,
prediction_revision_id: body.prediction_revision_id,
revision_no: body.revision_no,
idempotent_replay: false,
}),
});
});
await page.route(`**/api/calibration/predictions/${HISTORY_ID}/lock`, async (route) => {
lockRequests += 1;
const body = route.request().postDataJSON() as PredictionLockRequest;
expect(body.prediction_revision_id).toBe(
readModel.prediction_histories[0].revisions.at(-1)?.prediction_revision_id,
);
revealAssessment(readModel);
readModel.prediction_histories[0].lock = {
...readModel.prediction_histories[0].lock!,
submission_id: body.submission_id,
lock_id: body.lock_id,
locked_sequence: body.locked_sequence,
};
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({
submission_id: body.submission_id,
history_id: HISTORY_ID,
lock_id: body.lock_id,
idempotent_replay: false,
}),
});
});
await page.goto(`/learn/session/${SESSION_ID}/review`);
await page.getByRole("tab", { name: "피드백" }).click();
const card = page.locator(".ct-card");
await expect(card.getByRole("heading", { name: "먼저 예측하고, 잠근 뒤, 근거로 교정하기" })).toBeVisible();
await expect(card.getByText("아직 공개하지 않음")).toBeVisible();
const probability = card.getByRole("slider", { name: /성공 가능성/ });
await probability.focus();
await page.keyboard.press("ArrowRight");
await expect(probability).toHaveValue("65");
await card.getByLabel("그렇게 예상한 이유").fill("새 장면에서도 반영의 속도를 유지할 수 있을 것 같아");
await card.getByRole("button", { name: "예측 수정 기록" }).click();
await expect(card.getByText("revision 2", { exact: false }).first()).toBeVisible();
const lockButton = card.getByRole("button", { name: "이 예측 잠그기" });
await expect(lockButton).toBeDisabled();
await card.getByLabel("잠근 뒤 수정할 수 없음을 확인했어").check();
await expect(lockButton).toBeEnabled();
await lockButton.click();
await expect(card.getByText("잠근 예측은 수정할 수 없어", { exact: false })).toBeVisible();
await expect(card.getByText("독립 관찰", { exact: true })).toBeVisible();
await expect(card.getByText("과신 경향")).toBeVisible();
await expect(card.getByText("평균 예측 오차")).toBeVisible();
await expect(card.getByText("14%34%")).toBeVisible();
await expect(card.getByText("다음에 직접 할 일")).toBeVisible();
await expect(card.getByText("암기 문장 재사용 감지 · 전이 인정 차단")).toBeVisible();
await expect(card.getByText("상황 맥락", { exact: true })).toBeVisible();
await expect(card.getByText("관계 스타일", { exact: true })).toBeVisible();
await expect(
card.locator(".ct-coverage-grid").getByText("난도", { exact: true }),
).toBeVisible();
await expect(card.getByText("표현군", { exact: true })).toBeVisible();
await expect(card.getByText("실제 인구집단·진단·임상 결과에 대한 주장이 아니야", { exact: false })).toBeVisible();
await expect(card.getByText(/XP|총점/i)).toHaveCount(0);
expect(revisionRequests).toBe(1);
expect(lockRequests).toBe(1);
await expectNoHorizontalOverflow(page);
});
test("teacher gets cohort read and append-only correction only", async ({ page }) => {
const readModel = initialReadModel("supervisor");
revealAssessment(readModel);
let correctionBody: TeacherCorrectionRequest | null = null;
let executionRequests = 0;
await routeUnmockedApi(page);
await routeAuth(page, "teacher");
await routeReviewShell(page);
await page.route("**/api/teacher/dashboard", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
pending_reviews: [],
recent_sessions: [{ session_id: SESSION_ID, learner_id: LEARNER_ID }],
}),
}),
);
await page.route(`**/api/calibration/learners/${LEARNER_ID}`, (route) =>
fulfillReadModel(route, readModel),
);
await page.route("**/api/calibration/transfer-executions", async (route) => {
executionRequests += 1;
await route.fulfill({ status: 500, body: "must not be called" });
});
await page.route("**/api/calibration/reviews", async (route) => {
correctionBody = route.request().postDataJSON() as TeacherCorrectionRequest;
expect(correctionBody.disposition).toBe("corrected");
expect(correctionBody.correction_payload).toEqual({
teacher_note: "과신 판정에 반대 근거 장면을 한 개 더 연결해야 해",
});
expect(correctionBody).not.toHaveProperty("prediction_revision_id");
readModel.teacher_reviews.push({
...correctionBody,
review_no: 1,
supersedes_review_id: null,
created_by_uid: "51000000-0000-0000-0000-000000000002",
created_by_role: "instructor",
created_at: "2026-08-06T01:09:00Z",
});
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({
submission_id: correctionBody.submission_id,
review_id: correctionBody.review_id,
review_no: 1,
idempotent_replay: false,
}),
});
});
await page.goto(`/teach/session/${SESSION_ID}/review`);
await page.getByRole("tab", { name: "피드백" }).click();
await page.evaluate(() => document.documentElement.setAttribute("data-theme", "dark"));
const card = page.locator(".ct-card");
await expect(card.getByRole("heading", { name: "읽기와 교정만 가능" })).toBeVisible();
await expect(card.getByRole("button", { name: /예측.*기록|예측.*잠그기/ })).toHaveCount(0);
await expect(card.getByRole("button", { name: /확정|승인/ })).toHaveCount(0);
await expect(card.getByRole("link", { name: /미지 전이 과제로 연습/ })).toHaveCount(0);
await card.getByLabel("교정 이유 (발화 원문 제외)").fill(
"과신 판정에 반대 근거 장면을 한 개 더 연결해야 해",
);
await card.getByRole("button", { name: "교정 revision 추가" }).click();
await expect(card.getByText("교수자 교정을 append-only 원장에 추가했어.")).toBeVisible();
expect(correctionBody).not.toBeNull();
expect(executionRequests).toBe(0);
await expectNoHorizontalOverflow(page);
});
test("실패한 미지 trial과 자기보정 처방을 키보드 실행 계약으로 온전히 인계한다", async ({
page,
}) => {
const readModel = initialReadModel();
revealAssessment(readModel);
await routeUnmockedApi(page);
await routeAuth(page, "learner");
await routeReviewShell(page);
await page.route("**/api/calibration/learners/me", (route) =>
fulfillReadModel(route, readModel),
);
await page.goto(`/learn/session/${SESSION_ID}/review`);
await page.getByRole("tab", { name: "피드백" }).click();
const card = page.locator(".ct-card");
const launch = card.getByRole("link", {
name: "이 미지 전이 과제로 연습",
});
await expect(launch).toBeVisible();
await expect(card.getByText("family-conflict · 대립")).toBeVisible();
const href = await launch.getAttribute("href");
expect(href).not.toBeNull();
const parsed = parsePracticeLaunchIntent(
new URL(href!, "http://127.0.0.1").searchParams,
);
expect(parsed).toEqual({
kind: "transfer",
prescriptionId: "51000000-0000-0000-0000-000000000413",
suiteId: TRANSFER_SUITE_ID,
trialId: "51000000-0000-0000-0000-000000000515",
sourceSessionId: SESSION_ID,
criterionId: "competency.empathic_reflection",
novelty: "unseen_transfer",
mode: "counterevidence_forecast",
});
await launch.focus();
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/\/learn\/practice\?/);
const launched = new URL(page.url());
expect(launched.searchParams.get("prescription")).toBe(
"51000000-0000-0000-0000-000000000413",
);
expect(launched.searchParams.get("suite")).toBe(TRANSFER_SUITE_ID);
expect(launched.searchParams.get("trial")).toBe(
"51000000-0000-0000-0000-000000000515",
);
expect(launched.searchParams.get("source_session")).toBe(SESSION_ID);
expect(launched.searchParams.get("criterion")).toBe(
"competency.empathic_reflection",
);
expect(launched.searchParams.get("novelty")).toBe("unseen_transfer");
expect(launched.searchParams.get("mode")).toBe(
"counterevidence_forecast",
);
await expect(
page.getByRole("heading", {
name: "반대근거 예측 처방을 이어받았습니다.",
}),
).toBeVisible();
await expect(page.locator(".lh-practice-launch-intent")).toContainText(
"처음 보는 장면",
);
await expect(page.locator(".lh-practice-launch-intent")).toContainText(
"공감적 반영",
);
await expect(page.locator(".lh-practice-launch-intent")).toContainText(
"처음 보는 장면 실행",
);
await expect(page.locator(".lh-practice-launch-intent")).not.toContainText(
"51000000-0000-0000-0000-000000000515",
);
await expect(page.locator(".lh-practice-launch-intent")).not.toContainText(
SESSION_ID,
);
});
test("종료된 후속 전이 회기는 평가 대기 후 실제 근거를 멱등 기록하고 원장을 다시 읽는다", async ({
page,
}) => {
const readModel = initialReadModel();
revealAssessment(readModel);
let executionRequests = 0;
let readRequests = 0;
const unexpectedWrites: string[] = [];
await page.addInitScript(() => {
const audit = { getUserMedia: 0, enumerateDevices: 0 };
Object.defineProperty(globalThis, "__g5MediaAudit", {
configurable: true,
value: audit,
});
if (!navigator.mediaDevices) {
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: {},
});
}
Object.defineProperty(navigator.mediaDevices, "getUserMedia", {
configurable: true,
value: async () => {
audit.getUserMedia += 1;
throw new DOMException("G5 fixture blocks physical media", "NotAllowedError");
},
});
Object.defineProperty(navigator.mediaDevices, "enumerateDevices", {
configurable: true,
value: async () => {
audit.enumerateDevices += 1;
throw new DOMException("G5 fixture blocks physical media", "NotAllowedError");
},
});
});
page.on("request", (request) => {
if (
request.method() === "POST" &&
!request.url().endsWith("/api/calibration/transfer-executions")
) {
unexpectedWrites.push(request.url());
}
});
await routeUnmockedApi(page);
await routeAuth(page, "learner");
await routeReviewShell(page, PRACTICE_SESSION_ID);
await page.route("**/api/calibration/learners/me", async (route) => {
readRequests += 1;
await fulfillReadModel(route, readModel);
});
await page.route("**/api/calibration/transfer-executions", async (route) => {
executionRequests += 1;
const body = route.request().postDataJSON() as ActualTransferExecutionRequest;
expect(body).toEqual({
original_transfer_trial_record_id:
"51000000-0000-0000-0000-000000000515",
practice_session_id: PRACTICE_SESSION_ID,
});
if (executionRequests === 1) {
await route.fulfill({
status: 503,
contentType: "application/json",
body: JSON.stringify({ detail: "temporary ledger failure" }),
});
return;
}
if (executionRequests === 2) {
await route.fulfill({
status: 422,
contentType: "application/json",
body: JSON.stringify({
detail:
"actual transfer practice evaluation must be ready at session_end",
}),
});
return;
}
const response = actualTransferResponse(executionRequests > 3);
readModel.actual_executions = [response.execution];
readModel.actual_transfer_assessments = [response.assessment];
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify(response),
});
});
const search = new URLSearchParams({
launch: "transfer",
prescription: "51000000-0000-0000-0000-000000000413",
suite: TRANSFER_SUITE_ID,
trial: "51000000-0000-0000-0000-000000000515",
source_session: SESSION_ID,
criterion: "competency.empathic_reflection",
novelty: "unseen_transfer",
mode: "counterevidence_forecast",
});
await page.goto(
`/learn/session/${PRACTICE_SESSION_ID}/review?${search.toString()}`,
);
const feedbackTab = page.getByRole("tab", { name: "피드백" });
await expect(feedbackTab).toHaveAttribute("aria-selected", "true");
await expectAtMainScrollStart(
page,
".sr-practice-result-jump.is-transfer",
"G5 전이 근거 진입 행동",
);
await page
.getByRole("button", { name: "이번 회기 전이 근거 확인" })
.click();
const observation = page.locator(".ct-actual-transfer");
await expect(observation).toBeVisible();
await expect(
observation.getByRole("heading", {
name: "이번 연습을 실제 전이 근거로 연결",
}),
).toBeVisible();
await expect(observation).not.toContainText(PRACTICE_SESSION_ID);
await expect(observation).not.toContainText(
"51000000-0000-0000-0000-000000000515",
);
expect(executionRequests).toBe(0);
await observation
.getByRole("button", { name: "이 회기를 전이 근거로 확인" })
.click();
await expect(observation).toContainText(
"이번 회기를 전이 근거로 연결하지 못했어",
);
await observation
.getByRole("button", { name: "전이 근거 연결 다시 시도" })
.click();
await expect(observation).toContainText("평가 정리가 아직 진행 중");
await observation
.getByRole("button", { name: "평가 완료 여부 다시 확인" })
.click();
await expect(observation).toContainText("이번 완료 회기를 실제 전이 근거로 기록했어");
await expect(observation).toContainText("0 → 1회");
await expect(observation).toContainText("0 → 1종");
await expect(observation).toContainText("목표 행동이 관찰됨");
await expect.poll(() => readRequests).toBeGreaterThan(1);
await expect(observation).toContainText("최신 원장과 다시 맞춰 봤어");
await observation
.getByRole("button", { name: "같은 회기 기록 다시 확인" })
.click();
await expect(observation).toContainText("중복 기록은 만들지 않았어");
expect(executionRequests).toBe(4);
expect(unexpectedWrites).toEqual([]);
expect(
await page.evaluate(
() =>
(
globalThis as typeof globalThis & {
__g5MediaAudit?: {
getUserMedia: number;
enumerateDevices: number;
};
}
).__g5MediaAudit,
),
).toEqual({ getUserMedia: 0, enumerateDevices: 0 });
await expectNoHorizontalOverflow(page);
});
test("교수자·잘못된 전이 출처·다른 연습 intent는 실제 전이 쓰기를 열지 않는다", async ({
page,
}) => {
const readModel = initialReadModel();
revealAssessment(readModel);
let executionRequests = 0;
await routeUnmockedApi(page);
await routeAuth(page, "learner");
await routeReviewShell(page, PRACTICE_SESSION_ID);
await routeReviewShell(page, SESSION_ID);
await page.route("**/api/calibration/learners/me", (route) =>
fulfillReadModel(route, readModel),
);
await page.route("**/api/calibration/transfer-executions", async (route) => {
executionRequests += 1;
await route.fulfill({ status: 500, body: "must not be called" });
});
const validTransfer = new URLSearchParams({
launch: "transfer",
prescription: "51000000-0000-0000-0000-000000000413",
suite: TRANSFER_SUITE_ID,
trial: "51000000-0000-0000-0000-000000000515",
source_session: SESSION_ID,
criterion: "competency.empathic_reflection",
novelty: "unseen_transfer",
mode: "counterevidence_forecast",
});
await page.goto(`/learn/session/${SESSION_ID}/review?${validTransfer.toString()}`);
await expect(page.locator(".ct-actual-transfer.is-degraded")).toContainText(
"원본 과제와 분리된 후속 연습 회기에서만",
);
await expect(
page.locator(".ct-actual-transfer").getByRole("button"),
).toHaveCount(0);
const invalidTransfer = new URLSearchParams(validTransfer);
invalidTransfer.set("trial", "51000000-0000-0000-0000-000000000599");
await page.goto(
`/learn/session/${PRACTICE_SESSION_ID}/review?${invalidTransfer.toString()}`,
);
await expect(page.locator(".ct-actual-transfer.is-degraded")).toContainText(
"현재 연결로는 기록 요청을 보내지 않아",
);
await expect(
page.locator(".ct-actual-transfer").getByRole("button"),
).toHaveCount(0);
const deliberate = new URLSearchParams({
launch: "deliberate",
prescription: "oas-g4-deliberate",
source_session: SESSION_ID,
criterion: "criterion.reflect-and-check",
novelty: "familiar",
mode: "replay",
});
await page.goto(
`/learn/session/${PRACTICE_SESSION_ID}/review?${deliberate.toString()}`,
);
await expect(page.locator(".ct-actual-transfer")).toHaveCount(0);
expect(executionRequests).toBe(0);
});
test("모바일에서 전이 실행 티켓은 44px 터치 목표와 무가로넘침을 유지한다", async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 });
const readModel = initialReadModel();
revealAssessment(readModel);
await routeUnmockedApi(page);
await routeAuth(page, "learner");
await routeReviewShell(page);
await page.route("**/api/calibration/learners/me", (route) =>
fulfillReadModel(route, readModel),
);
await page.goto(`/learn/session/${SESSION_ID}/review`);
await page.getByRole("tab", { name: "피드백" }).click();
const launch = page.getByRole("link", {
name: "이 미지 전이 과제로 연습",
});
await expect(launch).toBeVisible();
const box = await launch.boundingBox();
expect(box?.height ?? 0).toBeGreaterThanOrEqual(44);
await expectNoHorizontalOverflow(page);
});
test("loading·API error·계약 degraded를 성공 상태로 위장하지 않는다", async ({
page,
}) => {
const degraded = initialReadModel();
revealAssessment(degraded);
degraded.calibration_assessments = [];
let responseMode: "pending" | "error" | "degraded" = "pending";
let releaseRequest = () => {};
const firstResponse = new Promise<void>((resolve) => {
releaseRequest = resolve;
});
await routeUnmockedApi(page);
await routeAuth(page, "learner");
await routeReviewShell(page);
await page.route("**/api/calibration/learners/me", async (route) => {
if (responseMode === "pending") {
await firstResponse;
}
if (responseMode === "error") {
await route.fulfill({
status: 503,
contentType: "application/json",
body: JSON.stringify({ detail: "캘리브레이션 원장 연결 지연" }),
});
return;
}
await fulfillReadModel(route, degraded);
});
await page.goto(`/learn/session/${SESSION_ID}/review`);
await page.getByRole("tab", { name: "피드백" }).click();
const card = page.locator(".ct-card");
await expect(card).toHaveAttribute("aria-busy", "true");
await expect(card).toHaveAttribute(
"aria-label",
"캘리브레이션과 전이 원장 불러오는 중",
);
responseMode = "error";
releaseRequest();
await expect(
card.getByRole("heading", { name: "예측 원장을 표시할 수 없어" }),
).toBeVisible();
await expect(card).toContainText("API 503");
responseMode = "degraded";
await card.getByRole("button", { name: "다시 불러오기" }).click();
await expect(card.locator(".ct-transfer-launch-degraded")).toContainText(
"전이 trial과 자기보정 처방의 연결을 확인할 수 없어",
);
await expect(
card.getByRole("link", { name: "이 미지 전이 과제로 연습" }),
).toHaveCount(0);
});
});