vignette/apps/web/e2e/calibration-transfer.spec.ts
Yun Chan 1306c524c1
Some checks failed
API contract / OpenAPI type drift (push) Failing after 4m46s
회기 집계와 기록 복귀 및 감독 근거 링크 수정
2026-09-09 14:41:54 +09:00

1084 lines
41 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 routeLearnerReadModel(
page: Page,
expectedSessionIds: readonly string[],
handler: (route: Route) => void | Promise<void>,
) {
return page.route(
(url) =>
url.pathname === "/api/calibration/learners/me" &&
url.searchParams.has("session_id"),
async (route) => {
const sessionId = new URL(route.request().url()).searchParams.get("session_id");
expect(expectedSessionIds).toContain(sessionId);
await handler(route);
},
);
}
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 routeLearnerReadModel(page, [SESSION_ID], (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 routeLearnerReadModel(page, [SESSION_ID], (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 routeLearnerReadModel(page, [PRACTICE_SESSION_ID], 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 routeLearnerReadModel(page, [SESSION_ID, PRACTICE_SESSION_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" });
});
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 routeLearnerReadModel(page, [SESSION_ID], (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 routeLearnerReadModel(page, [SESSION_ID], 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);
});
});