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 산출물은 커밋에서 제외했다.
This commit is contained in:
parent
93dd8f82d7
commit
16e791e044
390 changed files with 243188 additions and 499 deletions
324
apps/web/e2e/alliance-pulse.spec.ts
Normal file
324
apps/web/e2e/alliance-pulse.spec.ts
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
import {
|
||||
FILLED_REVIEW_SESSION_ID,
|
||||
filledReviewResponse,
|
||||
routeFilledSessionReview,
|
||||
routePrepostMeasures,
|
||||
} from "./session-review-fixture";
|
||||
import { expectNoHorizontalOverflow } from "./support";
|
||||
|
||||
type Role = "learner" | "teacher";
|
||||
|
||||
interface PulseMeasurement {
|
||||
measurement_id: string;
|
||||
dimension: "goal" | "task" | "bond";
|
||||
perspective: "client_agent_report" | "independent_observer" | "supervisor_human";
|
||||
source_kind: string;
|
||||
value: number | null;
|
||||
confidence: number | null;
|
||||
status: string;
|
||||
error_code: string | null;
|
||||
rationale: string | null;
|
||||
evidence: Array<{
|
||||
turn_id: string;
|
||||
seq: number;
|
||||
speaker: string;
|
||||
text: string;
|
||||
}>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
function routeReviewUser(page: Page, role: Role) {
|
||||
return page.route("**/api/auth/me", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
user_id:
|
||||
role === "teacher"
|
||||
? "00000000-0000-0000-0000-000000000202"
|
||||
: "00000000-0000-0000-0000-000000000101",
|
||||
email: `${role}@hs.ac.kr`,
|
||||
role,
|
||||
display_name: role === "teacher" ? "E2E Teacher" : "E2E Learner",
|
||||
admin_access: false,
|
||||
super_admin: false,
|
||||
account_status: "approved",
|
||||
approval_required: false,
|
||||
cohort_ids: ["e2e-hanshin"],
|
||||
consent_at: 1782820000,
|
||||
onboarding_completed_at: 1782820001,
|
||||
nickname: role === "teacher" ? "E2E Teacher" : "E2E Learner",
|
||||
self_introduction: "",
|
||||
avatar_url: "",
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function measurements(): PulseMeasurement[] {
|
||||
const evidence = {
|
||||
turn_id: "t6",
|
||||
seq: 6,
|
||||
speaker: "client",
|
||||
text: "적어도 제가 화난 게 이상한 건 아니라는 말은 좀 기억에 남아요.",
|
||||
};
|
||||
const values = {
|
||||
client_agent_report: { goal: 0.68, task: 0.61, bond: 0.83 },
|
||||
independent_observer: { goal: 0.72, task: 0.57, bond: 0.76 },
|
||||
} as const;
|
||||
return (["client_agent_report", "independent_observer"] as const).flatMap((perspective) =>
|
||||
(["goal", "task", "bond"] as const).map((dimension) => ({
|
||||
measurement_id: `${perspective}-${dimension}`,
|
||||
dimension,
|
||||
perspective,
|
||||
source_kind: "model_inference",
|
||||
value: values[perspective][dimension],
|
||||
confidence: 0.78,
|
||||
status: "recorded",
|
||||
error_code: null,
|
||||
rationale:
|
||||
dimension === "bond"
|
||||
? "정서 정상화 이후 내담자의 방어가 낮아진 발화를 근거로 판단했습니다."
|
||||
: "회기 목표와 다음 과업을 함께 확인한 발화를 근거로 판단했습니다.",
|
||||
evidence: [evidence],
|
||||
created_at: "2026-08-06T09:00:00Z",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function pulseFixture(options: { revealed: boolean; includeEarlyMeasurements?: boolean }) {
|
||||
return {
|
||||
pulse_id: "pulse-post-1",
|
||||
checkpoint: "post",
|
||||
status: options.revealed ? "revealed" : "awaiting_agents",
|
||||
learner_locked_at: "2026-08-06T08:59:00Z",
|
||||
revealed_at: options.revealed ? "2026-08-06T09:00:05Z" : null,
|
||||
error_code: null,
|
||||
self_scores: { goal: 0.75, task: 0.5, bond: 1 },
|
||||
measurements:
|
||||
options.revealed || options.includeEarlyMeasurements ? measurements() : [],
|
||||
};
|
||||
}
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||
await route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("G1 치료 동맹 펄스", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await routeFilledSessionReview(page);
|
||||
await routePrepostMeasures(page);
|
||||
});
|
||||
|
||||
test("AI 관점을 공개하기 전에 학습자 자기평가를 잠근다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeReviewUser(page, "learner");
|
||||
let pulse: ReturnType<typeof pulseFixture> | null = null;
|
||||
let submittedBody: Record<string, unknown> | null = null;
|
||||
let postSubmitReads = 0;
|
||||
|
||||
await page.route(
|
||||
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/alliance-pulses`,
|
||||
async (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
submittedBody = route.request().postDataJSON() as Record<string, unknown>;
|
||||
pulse = pulseFixture({ revealed: false, includeEarlyMeasurements: true });
|
||||
await fulfillJson(route, {
|
||||
pulse_id: pulse.pulse_id,
|
||||
status: "awaiting_agents",
|
||||
}, 202);
|
||||
return;
|
||||
}
|
||||
if (pulse) {
|
||||
postSubmitReads += 1;
|
||||
const responsePulse =
|
||||
postSubmitReads >= 2
|
||||
? pulseFixture({ revealed: true })
|
||||
: pulse;
|
||||
await fulfillJson(route, { items: [responsePulse] });
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, { items: [] });
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "목표, 과업, 유대를 따로 봅니다" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("내담자 관점", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText("관찰자 관점", { exact: true })).toHaveCount(0);
|
||||
|
||||
const axes = page.locator(".ap-axis");
|
||||
await axes.nth(0).locator(".ap-scale__option").nth(3).click();
|
||||
await axes.nth(1).locator(".ap-scale__option").nth(2).click();
|
||||
await axes.nth(2).locator(".ap-scale__option").nth(4).click();
|
||||
await page.getByText("내 판단의 근거 장면 선택").click();
|
||||
await page.getByRole("checkbox").first().check();
|
||||
await page.getByRole("button", { name: "내 판단 잠그고 관점 비교" }).click();
|
||||
|
||||
await expect(page.getByText("내 판단이 잠겼습니다")).toBeVisible();
|
||||
await expect(page.getByText("내담자 관점", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText("관찰자 관점", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText("정서 정상화 이후 내담자의 방어가 낮아진 발화를 근거로 판단했습니다.")).toHaveCount(0);
|
||||
expect(submittedBody).toEqual({
|
||||
checkpoint: "post",
|
||||
scores: { goal: 0.75, task: 0.5, bond: 1 },
|
||||
evidence_turn_ids: ["t1"],
|
||||
});
|
||||
await expect(page.getByLabel("치료 동맹 관점 비교")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(page.locator("b:visible", { hasText: "내담자 관점" }).first()).toBeVisible();
|
||||
await expect(page.locator("b:visible", { hasText: "관찰자 관점" }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("320px에서도 1~5 자기평가 척도를 스크롤 없이 모두 노출한다", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await page.setViewportSize({ width: 320, height: 568 });
|
||||
await routeReviewUser(page, "learner");
|
||||
await page.route(
|
||||
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/alliance-pulses`,
|
||||
(route) => fulfillJson(route, { items: [] }),
|
||||
);
|
||||
|
||||
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
|
||||
|
||||
const scale = page.locator(".ap-scale").first();
|
||||
await expect(scale).toBeVisible();
|
||||
await expect(scale.locator(".ap-scale__option")).toHaveCount(5);
|
||||
|
||||
const scaleMetrics = await scale.evaluate((element) => ({
|
||||
clientWidth: element.clientWidth,
|
||||
scrollWidth: element.scrollWidth,
|
||||
}));
|
||||
expect(scaleMetrics.scrollWidth).toBeLessThanOrEqual(scaleMetrics.clientWidth + 1);
|
||||
|
||||
const scaleBox = await scale.boundingBox();
|
||||
expect(scaleBox).not.toBeNull();
|
||||
if (scaleBox) {
|
||||
const optionBoxes = await scale.locator(".ap-scale__option").evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
for (const optionBox of optionBoxes) {
|
||||
expect(optionBox.left).toBeGreaterThanOrEqual(scaleBox.x - 1);
|
||||
expect(optionBox.right).toBeLessThanOrEqual(scaleBox.x + scaleBox.width + 1);
|
||||
expect(optionBox.right).toBeLessThanOrEqual(320);
|
||||
expect(optionBox.width).toBeGreaterThanOrEqual(44);
|
||||
expect(optionBox.height).toBeGreaterThanOrEqual(44);
|
||||
}
|
||||
}
|
||||
|
||||
await expectNoHorizontalOverflow(page);
|
||||
await scale.screenshot({
|
||||
path: testInfo.outputPath("alliance-pulse-scale-320x568.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
});
|
||||
|
||||
test("독립 세 축과 출처를 공개하고 근거 발화로 이동한다", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await routeReviewUser(page, "learner");
|
||||
const pulse = pulseFixture({ revealed: true });
|
||||
await page.route(
|
||||
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/alliance-pulses`,
|
||||
(route) => fulfillJson(route, { items: [pulse] }),
|
||||
);
|
||||
|
||||
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
|
||||
const comparison = page.getByLabel("치료 동맹 관점 비교");
|
||||
await expect(comparison).toBeVisible();
|
||||
await expect(comparison.getByText("목표 합의", { exact: true })).toBeVisible();
|
||||
await expect(comparison.getByText("과업 합의", { exact: true })).toBeVisible();
|
||||
await expect(comparison.getByText("정서적 유대", { exact: true })).toBeVisible();
|
||||
await expect(comparison.locator("small:visible", { hasText: "잠긴 자기평가" }).first()).toBeVisible();
|
||||
await expect(comparison.locator("small:visible", { hasText: "AI 역할 추론" }).first()).toBeVisible();
|
||||
await expect(comparison.locator("small:visible", { hasText: "AI 축어록 추론" }).first()).toBeVisible();
|
||||
await expect(comparison.getByText(/총점과 평균은 만들지 않습니다/)).toBeVisible();
|
||||
await expect(comparison.getByText(/총점\s*:/)).toHaveCount(0);
|
||||
await comparison.locator(".ap-comparison__row").first().screenshot({
|
||||
path: testInfo.outputPath("alliance-pulse-goal-axis.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
|
||||
await comparison.locator(".ap-measurement-evidence").first().getByText("근거 1개").click();
|
||||
await comparison
|
||||
.getByRole("button", { name: /6번째 발화.*적어도 제가 화난 게 이상한 건 아니라는 말/ })
|
||||
.first()
|
||||
.click();
|
||||
await expect(page.getByRole("tab", { name: "축어록" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(page.locator(".sr-turn--active")).toContainText("기억에 남아요");
|
||||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
|
||||
test("교수자가 근거를 연결한 판정을 추가한다", async ({ page }) => {
|
||||
await routeReviewUser(page, "teacher");
|
||||
const review = filledReviewResponse(FILLED_REVIEW_SESSION_ID);
|
||||
review.teacherReview = {
|
||||
status: "viewed",
|
||||
note: "",
|
||||
reviewedAt: null,
|
||||
reviewerId: "00000000-0000-0000-0000-000000000202",
|
||||
worksheetStatus: "pending",
|
||||
worksheetNote: "",
|
||||
worksheetReviewedAt: null,
|
||||
};
|
||||
await page.unroute(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/review`);
|
||||
await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/review`, (route) =>
|
||||
fulfillJson(route, review),
|
||||
);
|
||||
const pulse = pulseFixture({ revealed: true });
|
||||
let submittedBody: Record<string, unknown> | null = null;
|
||||
await page.route(
|
||||
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/alliance-pulses**`,
|
||||
async (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
submittedBody = route.request().postDataJSON() as Record<string, unknown>;
|
||||
await fulfillJson(route, { status: "recorded" }, 201);
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, { items: [pulse] });
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto(`/teach/session/${FILLED_REVIEW_SESSION_ID}/review`);
|
||||
const supervisor = page.locator(".ap-supervisor");
|
||||
await expect(supervisor).toBeVisible();
|
||||
const axes = supervisor.locator(".ap-axis");
|
||||
await axes.nth(0).locator(".ap-scale__option").nth(3).click();
|
||||
await axes.nth(1).locator(".ap-scale__option").nth(2).click();
|
||||
await axes.nth(2).locator(".ap-scale__option").nth(3).click();
|
||||
await supervisor.getByText("교수자 판정 근거 장면 선택").click();
|
||||
await supervisor.getByRole("checkbox").first().check();
|
||||
await supervisor.getByLabel("판정 메모").fill("목표 합의는 안정적이지만 과업 속도는 다음 지도에서 다시 확인합니다.");
|
||||
await supervisor.getByRole("button", { name: "근거와 함께 판정 추가" }).click();
|
||||
|
||||
await expect(supervisor.getByText("교수자 판정을 원장에 추가했습니다.")).toBeVisible();
|
||||
expect(submittedBody).toEqual({
|
||||
scores: { goal: 0.75, task: 0.5, bond: 0.75 },
|
||||
evidence_turn_ids: ["t1"],
|
||||
note: "목표 합의는 안정적이지만 과업 속도는 다음 지도에서 다시 확인합니다.",
|
||||
});
|
||||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue