vignette/apps/web/e2e/alliance-pulse.spec.ts

333 lines
13 KiB
TypeScript

import { expect, test, type Page, type Route } from "@playwright/test";
import {
FILLED_REVIEW_SESSION_ID,
FILLED_REVIEW_TURN_IDS,
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: FILLED_REVIEW_TURN_IDS[5],
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 page.setViewportSize({ width: 390, height: 844 });
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 submit = page.getByRole("button", { name: "내 판단 잠그고 관점 비교" });
await expect(submit).toBeDisabled();
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 expect(submit).toBeEnabled();
const submitBox = await submit.boundingBox();
expect(submitBox).not.toBeNull();
expect(submitBox!.height).toBeGreaterThanOrEqual(44);
await submit.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: [FILLED_REVIEW_TURN_IDS[0]],
});
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: [FILLED_REVIEW_TURN_IDS[0]],
note: "목표 합의는 안정적이지만 과업 속도는 다음 지도에서 다시 확인합니다.",
});
await expectNoHorizontalOverflow(page);
});
});