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