325 lines
13 KiB
TypeScript
325 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 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: [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);
|
|
});
|
|
});
|