vignette/apps/web/e2e/supervision-research.spec.ts

414 lines
16 KiB
TypeScript
Raw Permalink 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 } from "@playwright/test";
import type {
ResearchViewResponse,
SupervisionViewResponse,
} from "../src/pages/supervisionResearchApi";
import { expectNoHorizontalOverflow } from "./support";
const FORBIDDEN_VERBATIM = "내담자가 실제로 말한 비공개 원문";
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 G6 fixture" }),
}),
);
}
function routeAuth(page: Page, role: "teacher" | "admin") {
return page.route("**/auth/me", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
user_id:
role === "teacher"
? "62000000-0000-0000-0000-000000000002"
: "62000000-0000-0000-0000-000000000003",
email: `${role}@hs.ac.kr`,
role,
display_name: role === "teacher" ? "E2E Teacher" : "E2E Research Admin",
admin_access: role === "admin",
super_admin: false,
account_status: "approved",
approval_required: false,
cohort_ids: ["g6-cohort"],
consent_at: 1782820000,
onboarding_completed_at: 1782820001,
nickname: role === "teacher" ? "E2E Teacher" : "E2E Research Admin",
self_introduction: "",
avatar_url: "",
}),
}),
);
}
function supervisionFixture(): SupervisionViewResponse {
return {
attention_items: [
{
item_id: "g6-attention-risk",
snapshot_id: "g6-snapshot-001",
learner_id: "62000000-0000-0000-0000-000000000101",
learner_ref: "learner-risk",
cohort_id: "g6-cohort",
queue_position: 1,
primary_signal: "deterioration",
oldest_active_sequence: 4,
drilldown_routes: [
"/teach/analysis?learner=learner-risk&tab=outcome",
"/teach/analysis?learner=learner-risk&tab=safety",
"/teach/session/g6-session-risk/review",
],
evidence_pointer_ids: [
"pointer-outcome-risk",
"pointer-safety-risk",
"pointer-alliance-risk",
"pointer-hidden-by-cap",
],
created_at: "2026-08-06T03:00:00Z",
},
{
item_id: "g6-attention-stagnation",
snapshot_id: "g6-snapshot-001",
learner_id: "62000000-0000-0000-0000-000000000102",
learner_ref: "learner-stagnation",
cohort_id: "g6-cohort",
queue_position: 2,
primary_signal: "growth_stagnation",
oldest_active_sequence: 7,
drilldown_routes: ["/teach/analysis?learner=learner-stagnation&tab=practice"],
evidence_pointer_ids: ["pointer-practice-stagnation"],
created_at: "2026-08-06T03:00:00Z",
},
{
item_id: "g6-attention-rupture",
snapshot_id: "g6-snapshot-001",
learner_id: "62000000-0000-0000-0000-000000000103",
learner_ref: "learner-rupture",
cohort_id: "g6-cohort",
queue_position: 3,
primary_signal: "unresolved_rupture",
oldest_active_sequence: 9,
drilldown_routes: ["/teach/analysis?learner=learner-rupture&tab=rupture"],
evidence_pointer_ids: ["pointer-rupture-open"],
created_at: "2026-08-06T03:00:00Z",
},
],
curriculum_gaps: [
{
gap_snapshot_id: "g6-gap-001",
cohort_id: "g6-cohort",
competency_id: "competency.rupture-repair",
gap_kind: "rupture_repair",
status: "observed",
uncertainty: 0.18,
affected_learner_count: 4,
evidence_pointer_ids: ["pointer-gap-1", "pointer-gap-2"],
created_at: "2026-08-06T03:02:00Z",
},
{
gap_snapshot_id: "g6-gap-002",
cohort_id: "g6-cohort",
competency_id: "competency.transfer-context",
gap_kind: "transfer",
status: "insufficient_evidence",
uncertainty: 1,
affected_learner_count: 0,
evidence_pointer_ids: [],
created_at: "2026-08-06T03:02:00Z",
},
],
clinical_claim_allowed: false,
};
}
function researchFixture(): ResearchViewResponse {
return {
calibration_dataset: [
{
dataset_row_id: "g6-dataset-row-001",
row_hash: "a".repeat(64),
disagreement_record_id: "g6-disagreement-001",
case_ref: "synthetic-case-001",
competency_id: "competency.rupture-repair",
ai_label: "resolved",
teacher_label: "partial",
ai_model: "evaluator-v2",
prompt_version: "2.3.0",
instrument_id: "rupture-repair-evaluator",
instrument_version: "1.4.0",
correction_reason_code: "repair_impact_not_confirmed",
evidence_pointer_ids: ["pointer-ai-001", "pointer-teacher-001"],
raw_transcript_included: false,
created_at: "2026-08-06T03:04:00Z",
},
],
drift_reports: [
{
drift_report_id: "g6-drift-001",
cohort_id: "g6-cohort",
matched_count: 12,
status: "drift_flagged",
baseline_accuracy: 0.83,
candidate_accuracy: 0.67,
accuracy_delta: -0.16,
disagreement_case_refs: ["synthetic-case-b2", "synthetic-case-b3"],
alerts: [
"overall_accuracy_regression",
"synthetic_subgroup_regression:synthetic-low-disclosure",
],
evidence_pointer_ids: ["pointer-drift-baseline", "pointer-drift-candidate"],
created_at: "2026-08-06T03:06:00Z",
baseline_model: "evaluator-v1",
candidate_model: "evaluator-v2",
baseline_prompt_version: "1.8.0",
candidate_prompt_version: "2.3.0",
instrument_id: "alliance-evaluation-suite",
baseline_instrument_version: "1.1.0",
candidate_instrument_version: "1.4.0",
subgroup_metrics: [
{
subgroup: "synthetic-low-disclosure",
matched_count: 4,
baseline_accuracy: 0.75,
candidate_accuracy: 0.5,
accuracy_delta: -0.25,
},
{
subgroup: "synthetic-high-resistance",
matched_count: 4,
baseline_accuracy: 0.75,
candidate_accuracy: 0.75,
accuracy_delta: 0,
},
],
},
],
phase3_manifests: [
{
manifest_id: "g6-manifest-001",
cohort_id: "g6-cohort",
schema_version: "vignette.phase3-outcome-evidence-manifest.v1",
artifact_count: 4,
created_at: "2026-08-06T03:08:00Z",
artifacts: [
{
domain: "alliance",
artifact_id: "artifact-alliance-v1",
schema_version: "alliance.v1",
content_sha256: "1".repeat(64),
record_count: 24,
provenance_uri: "db://measurement/alliance",
clinical_claim_allowed: false,
},
{
domain: "rupture",
artifact_id: "artifact-rupture-v1",
schema_version: "rupture.v1",
content_sha256: "2".repeat(64),
record_count: 18,
provenance_uri: "db://measurement/rupture",
clinical_claim_allowed: false,
},
{
domain: "transfer",
artifact_id: "artifact-transfer-v1",
schema_version: "transfer.v1",
content_sha256: "3".repeat(64),
record_count: 16,
provenance_uri: "audit://transfer/suite-v1",
clinical_claim_allowed: false,
},
{
domain: "calibration",
artifact_id: "artifact-calibration-v1",
schema_version: "calibration.v1",
content_sha256: "4".repeat(64),
record_count: 20,
provenance_uri: "repo://evidence/calibration-v1",
clinical_claim_allowed: false,
},
],
},
],
raw_transcript_included: false,
clinical_claim_allowed: false,
};
}
async function routeG6(
page: Page,
role: "teacher" | "admin",
supervision = supervisionFixture(),
research = researchFixture(),
) {
await routeUnmockedApi(page);
await routeAuth(page, role);
await page.route("**/api/supervision-research/supervision-view", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(supervision),
}),
);
await page.route("**/api/supervision-research/research-view", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(research),
}),
);
}
test.describe("G6 supervision and research OS", () => {
test("teacher follows a three-click-bounded evidence queue without research controls", async ({
page,
}) => {
await routeG6(page, "teacher");
await page.goto("/teach/supervision");
await page.evaluate(() => document.documentElement.setAttribute("data-theme", "dark"));
await expect(
page.getByRole("heading", {
name: "감독·연구",
}),
).toBeVisible();
await expect(page.getByText("학습 검토", { exact: true })).toBeVisible();
await expect(page.locator(".g6-hero p")).toHaveText(
"검토가 필요한 학습 회기와 평가 근거를 확인합니다.",
);
await expect(page.getByRole("tab", { name: "교수 감독" })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(page.getByRole("tab", { name: "연구 품질" })).toHaveCount(0);
await expect(page.getByText("위험", { exact: true })).toBeVisible();
await expect(page.getByText("정체", { exact: true })).toBeVisible();
await expect(page.getByText("미해결 관계 사건", { exact: true }).first()).toBeVisible();
const stagnation = page.getByRole("button", { name: /learner-stagnation/ });
await stagnation.focus();
await page.keyboard.press("Enter");
await expect(stagnation).toHaveAttribute("aria-pressed", "true");
await page.getByRole("button", { name: /learner-risk/ }).click();
await expect(page.locator(".g6-ledger li")).toHaveCount(3);
await expect(page.locator(".g6-drilldown a")).toHaveCount(3);
await expect(page.getByTestId("drilldown-depth")).toHaveText("선택한 항목의 근거를 확인합니다.");
await expect(page.getByRole("heading", { name: "반복되는 학습 보완점" })).toBeVisible();
await expect(page.getByText("competency.rupture-repair").first()).toBeVisible();
await expect(page.getByText("근거가 부족한 상태를 유지하고 해석하지 않습니다.")).toBeVisible();
await expect(page.getByText("resolved", { exact: true })).toBeVisible();
await expect(page.getByText("partial", { exact: true })).toBeVisible();
await expect(page.getByText("원문 축어록 제외")).toBeVisible();
await expect(page.getByText(/XP|총점/i)).toHaveCount(0);
await expect(page.getByRole("button", { name: /확정|승인|판정 저장/ })).toHaveCount(0);
await expect(page.getByText(FORBIDDEN_VERBATIM)).toHaveCount(0);
await expectNoHorizontalOverflow(page);
});
test("wraps a long learner reference without clipping at 320px", async ({ page }) => {
const longLearnerRef = "learner-bc2e3fe5b35e46a8b1c9d2e4f6a7b8c9d0e1f2a3b4c5d6e7f8";
const supervision = supervisionFixture();
supervision.attention_items[0] = {
...supervision.attention_items[0],
learner_ref: longLearnerRef,
};
await routeG6(page, "teacher", supervision);
await page.setViewportSize({ width: 320, height: 900 });
await page.goto("/teach/supervision");
const detailReference = page.locator(".g6-case h3");
await expect(detailReference).toHaveText(longLearnerRef);
await expect(detailReference).toHaveCSS("overflow-wrap", "anywhere");
expect(
await detailReference.evaluate((element) => element.scrollWidth <= element.clientWidth + 1),
).toBeTruthy();
await expectNoHorizontalOverflow(page);
});
test("research role traces model, prompt, instrument, subgroup, and four-domain provenance", async ({
page,
}) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await routeG6(page, "admin");
await page.goto("/teach/supervision");
await expect(page.locator(".g6-hero p")).toHaveText(
"검토가 필요한 학습 회기와 평가 근거를 확인합니다. 연구 품질 탭에서는 평가 버전과 출처를 확인할 수 있습니다.",
);
await page.getByRole("tab", { name: "연구 품질" }).click();
await expect(page.getByRole("heading", { name: "모델·프롬프트·도구·하위집단 드리프트" })).toBeVisible();
await expect(page.getByText("evaluator-v1", { exact: true })).toBeVisible();
await expect(
page.getByLabel("모델 프롬프트 도구 버전 출처").getByText("evaluator-v2", { exact: true }),
).toBeVisible();
await expect(page.getByText("prompt 1.8.0", { exact: true })).toBeVisible();
await expect(page.getByText("prompt 2.3.0", { exact: true })).toBeVisible();
await expect(page.getByText("alliance-evaluation-suite", { exact: true })).toBeVisible();
await expect(page.getByText("synthetic-low-disclosure", { exact: true })).toBeVisible();
await expect(page.getByText("-25%p", { exact: true })).toBeVisible();
await expect(page.getByText("4/4 출처 연결", { exact: true })).toBeVisible();
for (const label of ["동맹", "파열·수선", "전이", "보정"]) {
await expect(page.getByText(label, { exact: true }).first()).toBeVisible();
}
await expect(page.getByText("db://measurement/alliance", { exact: true })).toBeVisible();
await expect(page.getByText("audit://transfer/suite-v1", { exact: true })).toBeVisible();
await expect(page.getByText("repo://evidence/calibration-v1", { exact: true })).toBeVisible();
await expect(page.getByText(FORBIDDEN_VERBATIM)).toHaveCount(0);
await expect(page.getByText(/XP|총점/i)).toHaveCount(0);
const activeTab = page.getByRole("tab", { name: "연구 품질" });
await expect(activeTab).toHaveAttribute("tabindex", "0");
expect(
await page.evaluate(() => window.matchMedia("(prefers-reduced-motion: reduce)").matches),
).toBe(true);
const reducedTransitionSeconds = await activeTab.evaluate((element) =>
Number.parseFloat(window.getComputedStyle(element).transitionDuration),
);
expect(reducedTransitionSeconds).toBeLessThanOrEqual(0.00001);
await expectNoHorizontalOverflow(page);
});
test("empty and summary-only contracts stay explicit instead of inventing evidence", async ({
page,
}) => {
const emptySupervision: SupervisionViewResponse = {
attention_items: [],
curriculum_gaps: [],
clinical_claim_allowed: false,
};
const summaryOnlyResearch: ResearchViewResponse = {
calibration_dataset: [],
drift_reports: [],
phase3_manifests: [
{
manifest_id: "g6-manifest-summary-only",
cohort_id: "g6-cohort",
schema_version: "vignette.phase3-outcome-evidence-manifest.v1",
artifact_count: 4,
created_at: "2026-08-06T03:08:00Z",
},
],
raw_transcript_included: false,
clinical_claim_allowed: false,
};
await routeG6(page, "admin", emptySupervision, summaryOnlyResearch);
await page.goto("/teach/supervision");
await expect(page.getByText("현재 우선 검토 항목이 없습니다")).toBeVisible();
await expect(page.getByText("현재 교육과정 공백이 없습니다")).toBeVisible();
await expect(page.getByText("교수자AI 불일치 메타데이터가 없습니다")).toBeVisible();
await page.getByRole("tab", { name: "연구 품질" }).click();
await expect(page.getByText("버전 드리프트 비교가 없습니다")).toBeVisible();
await expect(page.getByText("출처 연결 저하", { exact: true })).toBeVisible();
await expect(page.getByText("매니페스트 요약만 있습니다")).toBeVisible();
await expect(page.getByText("원본 출처 미제공").first()).toBeVisible();
await expectNoHorizontalOverflow(page);
});
});