737 lines
30 KiB
TypeScript
737 lines
30 KiB
TypeScript
/**
|
|
* uc-review-alliance-pulse.spec.ts — 치료 동맹 펄스(pre/mid/post · 3축 · 관점차) 유스케이스 여정
|
|
*
|
|
* 목적: 학습자가 회기 전(pre)·중(mid)·후(post) 시점에 goal/task/bond 세 축을 스스로
|
|
* 잠그고, AI 관점(내담자/관찰자)과의 차이를 근거 발화와 함께 확인하는 실제 사용 여정을
|
|
* route fixture만으로 검증한다.
|
|
*
|
|
* 근거:
|
|
* - apps/web/src/pages/session/AllianceCheckpointPrompt.tsx (pre fail-closed 게이트, mid 비차단)
|
|
* - apps/web/src/pages/session-review/AlliancePulseCard.tsx (post 잠금·관점 비교·근거 점프)
|
|
* - apps/web/src/pages/session-review/alliancePulseApi.ts (엔드포인트·응답 타입)
|
|
* - apps/web/src/pages/session-review/alliance-pulse.css (≤360px에서 1~5 척도 3+2 배열)
|
|
* - e2e/session-review-fixture.ts (리뷰 응답·prepost 빌더)
|
|
*
|
|
* 중복 회피: e2e/alliance-pulse.spec.ts 는 post 행복 경로 잠금→공개, 320px 무스크롤·44px
|
|
* 타깃, 관찰자 근거 점프, 교수자 판정을 다룬다. 이 파일은 실패·경합(fail-closed 500,
|
|
* 409 동기화, 422 거절), 미완성 초안의 제출 차단, mid 비차단 접기, 3+2 줄 배열 자체,
|
|
* 공개 후 수정 불가를 다른 각도에서 다룬다.
|
|
*
|
|
* 실제 AI 엔진 턴 생성 없음 — 모든 API·SSE 는 route fixture 로 응답한다.
|
|
*/
|
|
import { expect, test, type Page, type Route } from "@playwright/test";
|
|
import {
|
|
FILLED_REVIEW_SESSION_ID,
|
|
FILLED_REVIEW_TURN_IDS,
|
|
routeFilledSessionReview,
|
|
routePrepostMeasures,
|
|
} from "./session-review-fixture";
|
|
|
|
const SESSION_ID = "44444444-4444-4444-8444-444444444444";
|
|
const LEARNER_TEXT = "요즘 많이 힘들었겠어요. 어떤 마음이 가장 크게 남아 있나요?";
|
|
const CLIENT_REPLY = "괜찮아요. 천천히 말해볼게요.";
|
|
|
|
function jsonRoute(body: unknown, status = 200) {
|
|
return { status, contentType: "application/json", body: JSON.stringify(body) };
|
|
}
|
|
|
|
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
|
await route.fulfill(jsonRoute(body, status));
|
|
}
|
|
|
|
async function routeUnmockedApi(page: Page) {
|
|
await page.route("**/api/**", (route) =>
|
|
route.fulfill(jsonRoute({ detail: "not part of this focused fixture" }, 404)),
|
|
);
|
|
}
|
|
|
|
async function routeAuthedLearner(page: Page) {
|
|
await page.route("**/api/auth/me", (route) =>
|
|
route.fulfill(
|
|
jsonRoute({
|
|
user_id: "00000000-0000-0000-0000-000000000101",
|
|
email: "learner@hs.ac.kr",
|
|
role: "learner",
|
|
display_name: "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: "E2E Learner",
|
|
self_introduction: "",
|
|
avatar_url: "",
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
interface PulseItem {
|
|
pulse_id: string;
|
|
checkpoint: "pre" | "mid" | "post";
|
|
status: string;
|
|
learner_locked_at: string | null;
|
|
revealed_at: string | null;
|
|
error_code: string | null;
|
|
self_scores: Record<"goal" | "task" | "bond", number> | null;
|
|
measurements: unknown[];
|
|
}
|
|
|
|
function lockedPulse(
|
|
checkpoint: "pre" | "mid" | "post",
|
|
scores: Record<"goal" | "task" | "bond", number> = { goal: 0.5, task: 0.5, bond: 0.5 },
|
|
overrides: Partial<PulseItem> = {},
|
|
): PulseItem {
|
|
return {
|
|
pulse_id: `pulse-${checkpoint}-1`,
|
|
checkpoint,
|
|
status: "awaiting_agents",
|
|
learner_locked_at: "2026-08-06T08:59:00Z",
|
|
revealed_at: null,
|
|
error_code: null,
|
|
self_scores: scores,
|
|
measurements: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
interface SessionFixtureOptions {
|
|
initialPulses?: PulseItem[];
|
|
/**
|
|
* true면 restorePulseLedger() 호출 전까지 원장 GET이 500으로 실패한다.
|
|
* (React StrictMode dev 이중 mount가 GET을 한 번 더 보내므로, 1회성 실패
|
|
* 카운트는 사용자에게 보이기 전에 소진된다 — 명시 복구 스위치로 고정한다.)
|
|
*/
|
|
pulseGetFailing?: boolean;
|
|
pulsePostStatus?: number; // default 202
|
|
pulsePostConflictLedger?: PulseItem[]; // ledger returned by GET after a 409 POST
|
|
}
|
|
|
|
async function routeLiveSession(page: Page, options: SessionFixtureOptions = {}) {
|
|
const pulseItems: PulseItem[] = [...(options.initialPulses ?? [])];
|
|
const pulsePosts: Array<{ checkpoint?: string; scores?: Record<string, number> }> = [];
|
|
let pulseGetFailing = options.pulseGetFailing ?? false;
|
|
|
|
await routeUnmockedApi(page);
|
|
await routeAuthedLearner(page);
|
|
|
|
await page.route("**/api/users/me/prepost-measures**", (route) =>
|
|
fulfillJson(route, {
|
|
pilot_id: "phase3-pilot-draft",
|
|
instrument_version: "pilot-prepost-scaffold-2026-06-28",
|
|
measures: [],
|
|
complete_pre_count: 0,
|
|
complete_post_count: 0,
|
|
updated_at: null,
|
|
}),
|
|
);
|
|
|
|
await page.route("**/api/voice/health", (route) =>
|
|
fulfillJson(route, { available: false, reason: "fixture" }),
|
|
);
|
|
|
|
await page.route("**/api/personas", (route) =>
|
|
fulfillJson(route, [
|
|
{
|
|
code: "P1",
|
|
display_name: "민서(청소년 우울)",
|
|
difficulty: "hard",
|
|
theory_target: ["humanistic"],
|
|
demographics: { age_band: "10대" },
|
|
presenting_summary: "자퇴와 무기력감을 둘러싼 상담 연습",
|
|
voice_preset: "soft-young-fem",
|
|
source: "database",
|
|
degraded: false,
|
|
},
|
|
]),
|
|
);
|
|
|
|
await page.route("**/api/sessions", async (route) => {
|
|
if (route.request().method() !== "POST") {
|
|
await route.fallback();
|
|
return;
|
|
}
|
|
await fulfillJson(
|
|
route,
|
|
{
|
|
session_id: SESSION_ID,
|
|
case_id: "uc-alliance-case-001",
|
|
session_no: 1,
|
|
stage: "라포",
|
|
effective_openness: 0.21,
|
|
recall_summary: null,
|
|
degraded: false,
|
|
},
|
|
201,
|
|
);
|
|
});
|
|
|
|
await page.route(`**/api/sessions/${SESSION_ID}`, (route) =>
|
|
fulfillJson(route, {
|
|
session_id: SESSION_ID,
|
|
case_id: "uc-alliance-case-001",
|
|
persona_code: "P1",
|
|
persona_name: "민서",
|
|
session_no: 1,
|
|
status: "active",
|
|
stage: "라포",
|
|
theory_mode: "humanistic",
|
|
effective_openness: 0.21,
|
|
started_at: "2026-08-06T08:00:00Z",
|
|
ended_at: null,
|
|
review_ready: false,
|
|
turns: [],
|
|
}),
|
|
);
|
|
|
|
await page.route(`**/api/sessions/${SESSION_ID}/alliance-pulses`, async (route) => {
|
|
if (route.request().method() === "POST") {
|
|
const body = route.request().postDataJSON() as {
|
|
checkpoint?: "pre" | "mid" | "post";
|
|
scores?: Record<string, number>;
|
|
};
|
|
pulsePosts.push(body);
|
|
const postStatus = options.pulsePostStatus ?? 202;
|
|
if (postStatus === 409) {
|
|
pulseItems.push(...(options.pulsePostConflictLedger ?? []));
|
|
await fulfillJson(route, { detail: "이미 잠긴 자기점검이 있습니다." }, 409);
|
|
return;
|
|
}
|
|
if (postStatus >= 400) {
|
|
await fulfillJson(route, { detail: "checkpoint window closed" }, postStatus);
|
|
return;
|
|
}
|
|
const pulseId = `pulse-${body.checkpoint ?? "pre"}-created`;
|
|
pulseItems.push(
|
|
lockedPulse(body.checkpoint ?? "pre", (body.scores ?? null) as never, {
|
|
pulse_id: pulseId,
|
|
}),
|
|
);
|
|
await fulfillJson(route, { pulse_id: pulseId, status: "awaiting_agents" }, 202);
|
|
return;
|
|
}
|
|
if (pulseGetFailing) {
|
|
await fulfillJson(route, { detail: "alliance ledger unavailable" }, 500);
|
|
return;
|
|
}
|
|
await fulfillJson(route, { items: pulseItems });
|
|
});
|
|
|
|
await page.route(`**/api/sessions/${SESSION_ID}/stream`, (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "text/event-stream",
|
|
body: [
|
|
"event: token",
|
|
`data: ${CLIENT_REPLY}`,
|
|
"",
|
|
"event: done",
|
|
`data: ${JSON.stringify({
|
|
session_id: SESSION_ID,
|
|
stage: "탐색",
|
|
effective_openness: 0.42,
|
|
turn_seq: 1,
|
|
safety_flagged: false,
|
|
})}`,
|
|
"",
|
|
].join("\n"),
|
|
}),
|
|
);
|
|
|
|
return {
|
|
pulsePosts,
|
|
pulseItems,
|
|
restorePulseLedger: () => {
|
|
pulseGetFailing = false;
|
|
},
|
|
};
|
|
}
|
|
|
|
async function startSession(page: Page) {
|
|
await page.goto("/learn/session/P1");
|
|
await page.getByRole("button", { name: "회기 시작" }).click();
|
|
}
|
|
|
|
async function completeOneExchange(page: Page) {
|
|
const composer = page.getByLabel("학습자 발화 입력");
|
|
await expect(composer).toBeEnabled();
|
|
await composer.fill(LEARNER_TEXT);
|
|
await page.getByRole("button", { name: "보내기" }).click();
|
|
await expect(page.locator(".sx-utt").filter({ hasText: CLIENT_REPLY })).toBeVisible();
|
|
}
|
|
|
|
async function pickCheckpointScore(
|
|
page: Page,
|
|
axis: "목표" | "과업" | "유대",
|
|
scoreName: string,
|
|
) {
|
|
const group = page.getByRole("group", { name: new RegExp(`^${axis}`) });
|
|
await group.getByRole("radio", { name: scoreName }).check();
|
|
}
|
|
|
|
// ── 리뷰(post) fixture ─────────────────────────────────────────────
|
|
|
|
type Dimension = "goal" | "task" | "bond";
|
|
type Perspective = "client_agent_report" | "independent_observer";
|
|
|
|
const AI_VALUES: Record<Perspective, Record<Dimension, number>> = {
|
|
client_agent_report: { goal: 0.4, task: 0.6, bond: 0.9 },
|
|
independent_observer: { goal: 0.2, task: 0.55, bond: 0.65 },
|
|
};
|
|
|
|
function revealedMeasurements() {
|
|
return (Object.keys(AI_VALUES) as Perspective[]).flatMap((perspective) =>
|
|
(["goal", "task", "bond"] as Dimension[]).map((dimension) => ({
|
|
measurement_id: `${perspective}-${dimension}`,
|
|
dimension,
|
|
perspective,
|
|
source_kind: "model_inference",
|
|
value: AI_VALUES[perspective][dimension],
|
|
confidence: 0.8,
|
|
status: "ready",
|
|
error_code: null,
|
|
rationale:
|
|
dimension === "bond"
|
|
? "방어가 낮아진 마지막 발화를 근거로 판단했습니다."
|
|
: "목표와 과업을 함께 확인한 발화를 근거로 판단했습니다.",
|
|
evidence: [
|
|
{
|
|
turn_id: FILLED_REVIEW_TURN_IDS[5],
|
|
seq: 6,
|
|
speaker: "client",
|
|
text: "적어도 제가 화난 게 이상한 건 아니라는 말은 좀 기억에 남아요.",
|
|
},
|
|
],
|
|
created_at: "2026-08-06T09:00:00Z",
|
|
})),
|
|
);
|
|
}
|
|
|
|
function revealedPostPulse(): PulseItem {
|
|
return lockedPulse("post", { goal: 0.75, task: 0.5, bond: 1 }, {
|
|
pulse_id: "pulse-post-revealed",
|
|
status: "revealed",
|
|
revealed_at: "2026-08-06T09:00:05Z",
|
|
measurements: revealedMeasurements(),
|
|
});
|
|
}
|
|
|
|
async function routeReviewPage(page: Page) {
|
|
await routeUnmockedApi(page);
|
|
await routeAuthedLearner(page);
|
|
await routeFilledSessionReview(page);
|
|
await routePrepostMeasures(page);
|
|
}
|
|
|
|
/**
|
|
* 리뷰 척도 선택. AlliancePulseCard의 라디오 input은 clip/clip-path로 시각
|
|
* 숨김이라(hit-test에서 제외) input.check()의 포인터가 label에 가로막힌다.
|
|
* 실제 사용자와 같은 계약대로 .ap-scale__option(label)을 클릭하고, 상태
|
|
* 변화(checked)는 그대로 어설션한다.
|
|
*/
|
|
async function pickReviewScore(page: Page, groupName: string, scoreName: string) {
|
|
const group = page.getByRole("radiogroup", { name: groupName });
|
|
const radio = group.getByRole("radio", { name: scoreName });
|
|
await group
|
|
.locator(".ap-scale__option")
|
|
.filter({ has: page.getByRole("radio", { name: scoreName }) })
|
|
.click();
|
|
await expect(radio).toBeChecked();
|
|
}
|
|
|
|
// ── pre: 회기 진입 전 fail-closed 잠금 ─────────────────────────────
|
|
|
|
test.describe("UC 치료 동맹 펄스 — 회기 전 자기점검(pre)", () => {
|
|
// usecase: 학습자가 회기를 시작하면 pre 자기점검을 잠그기 전까지 첫 발화 입력이 닫혀 있다
|
|
test("회기를 시작하면 pre 자기점검을 잠그기 전까지 첫 발화 입력이 닫혀 있다", async ({
|
|
page,
|
|
}) => {
|
|
await routeLiveSession(page);
|
|
await startSession(page);
|
|
|
|
await expect(
|
|
page.getByRole("heading", { name: "첫 발화 전에 내 기준을 잠급니다" }),
|
|
).toBeVisible();
|
|
await expect(page.getByLabel("학습자 발화 입력")).toBeDisabled();
|
|
// 제출 전부터 "수정 불가" 계약을 고지한다.
|
|
await expect(
|
|
page.getByText("제출한 판단은 수정하지 않고, 세 관점은 모두 준비된 뒤 공개합니다."),
|
|
).toBeVisible();
|
|
});
|
|
|
|
// usecase: 학습자가 세 축을 모두 고르기 전에는 pre 잠금 버튼이 눌리지 않는다
|
|
test("세 축을 모두 고르기 전에는 pre 잠금 버튼이 눌리지 않는다", async ({ page }) => {
|
|
await routeLiveSession(page);
|
|
await startSession(page);
|
|
|
|
const progress = page.locator(".sx-alliance-checkpoint__progress");
|
|
await expect(progress).toContainText("0/3");
|
|
const submit = page.getByRole("button", { name: "기준 잠그고 첫 발화 준비" });
|
|
await expect(submit).toBeDisabled();
|
|
|
|
await pickCheckpointScore(page, "목표", "3 보통이다");
|
|
await pickCheckpointScore(page, "과업", "3 보통이다");
|
|
await expect(progress).toContainText("2/3");
|
|
await expect(progress).toContainText("축 선택함");
|
|
await expect(submit).toBeDisabled();
|
|
|
|
await pickCheckpointScore(page, "유대", "4 대체로 그렇다");
|
|
await expect(progress).toContainText("3/3");
|
|
await expect(progress).toContainText("선택 완료");
|
|
await expect(submit).toBeEnabled();
|
|
});
|
|
|
|
// usecase: 자기점검 원장 확인이 실패한 학습자는 첫 발화가 열리지 않고, 다시 확인으로 복구한다
|
|
test("자기점검 원장 확인이 실패하면 첫 발화를 열지 않고 다시 확인으로 복구한다", async ({
|
|
page,
|
|
}) => {
|
|
const api = await routeLiveSession(page, {
|
|
pulseGetFailing: true,
|
|
initialPulses: [lockedPulse("pre")],
|
|
});
|
|
await startSession(page);
|
|
|
|
// fail-closed: 원장 상태를 모르면 발화를 열지 않는다.
|
|
await expect(page.getByText("자기점검 상태를 확인하지 못했습니다")).toBeVisible();
|
|
await expect(page.getByText("첫 발화는 상태 확인 뒤에 열립니다.")).toBeVisible();
|
|
await expect(page.getByLabel("학습자 발화 입력")).toBeDisabled();
|
|
|
|
// 서버가 복구된 뒤 "다시 확인"으로 원장을 재조회하면 게이트가 열린다.
|
|
api.restorePulseLedger();
|
|
await page.getByRole("button", { name: "다시 확인" }).click();
|
|
await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled();
|
|
});
|
|
|
|
// usecase: 이미 pre를 잠근 학습자가 회기에 돌아오면 점검을 다시 요구받지 않는다
|
|
test("이미 pre가 잠긴 회기로 돌아오면 점검을 다시 요구하지 않고 바로 발화를 연다", async ({
|
|
page,
|
|
}) => {
|
|
await routeLiveSession(page, { initialPulses: [lockedPulse("pre")] });
|
|
await startSession(page);
|
|
|
|
await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled();
|
|
await expect(
|
|
page.getByRole("heading", { name: "첫 발화 전에 내 기준을 잠급니다" }),
|
|
).toHaveCount(0);
|
|
// pre 잠금 뒤 mid 이전에는 점검 UI가 회기 본문을 밀어내지 않는다.
|
|
await expect(page.locator(".sx-alliance-checkpoint")).toHaveCount(0);
|
|
});
|
|
|
|
// usecase: 다른 탭에서 이미 잠근 pre와 겹친 학습자는 서버 원장과 동기화되어 첫 발화가 열린다
|
|
test("pre 잠금이 409로 겹치면 서버 원장과 동기화해 첫 발화를 연다", async ({ page }) => {
|
|
const api = await routeLiveSession(page, {
|
|
pulsePostStatus: 409,
|
|
pulsePostConflictLedger: [lockedPulse("pre", { goal: 0.25, task: 0.5, bond: 0.75 })],
|
|
});
|
|
await startSession(page);
|
|
|
|
await pickCheckpointScore(page, "목표", "3 보통이다");
|
|
await pickCheckpointScore(page, "과업", "3 보통이다");
|
|
await pickCheckpointScore(page, "유대", "3 보통이다");
|
|
await page.getByRole("button", { name: "기준 잠그고 첫 발화 준비" }).click();
|
|
|
|
// 409 → 원장 재조회 → 게이트 해제(중복 생성 없음).
|
|
await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled();
|
|
expect(api.pulsePosts.map((item) => item.checkpoint)).toEqual(["pre"]);
|
|
});
|
|
});
|
|
|
|
// ── mid: 비차단 진행 ──────────────────────────────────────────────
|
|
|
|
test.describe("UC 치료 동맹 펄스 — 회기 중 자기점검(mid)", () => {
|
|
// usecase: 대화가 한 왕복 쌓인 학습자에게 30초 점검이 떠도 대화 입력은 계속 열려 있다
|
|
test("한 왕복이 쌓이면 30초 점검 배지가 떠도 대화 입력은 계속 열려 있다", async ({
|
|
page,
|
|
}) => {
|
|
await routeLiveSession(page, { initialPulses: [lockedPulse("pre")] });
|
|
await startSession(page);
|
|
await completeOneExchange(page);
|
|
|
|
await expect(page.getByRole("button", { name: "30초 점검" })).toBeVisible();
|
|
await expect(
|
|
page.getByText("한 번의 대화 왕복이 쌓였습니다. 현재 판단을 남길 수 있어요."),
|
|
).toBeVisible();
|
|
// mid 는 pre 와 달리 비차단: 배지가 떠 있어도 발화 입력이 잠기지 않는다.
|
|
await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled();
|
|
});
|
|
|
|
// usecase: 학습자가 30초 점검을 열었다가 제출 없이 대화 먼저 이어가기로 접는다
|
|
test("30초 점검을 열었다가 제출 없이 대화 먼저 이어가기로 접는다", async ({ page }) => {
|
|
const api = await routeLiveSession(page, { initialPulses: [lockedPulse("pre")] });
|
|
await startSession(page);
|
|
await completeOneExchange(page);
|
|
|
|
await page.getByRole("button", { name: "30초 점검" }).click();
|
|
await expect(
|
|
page.getByRole("heading", { name: "지금의 흐름을 30초 동안 확인합니다" }),
|
|
).toBeVisible();
|
|
|
|
await page.getByRole("button", { name: "대화 먼저 이어가기" }).click();
|
|
await expect(
|
|
page.getByRole("heading", { name: "지금의 흐름을 30초 동안 확인합니다" }),
|
|
).toHaveCount(0);
|
|
await expect(page.getByRole("button", { name: "30초 점검" })).toBeVisible();
|
|
// 접기는 제출이 아니다 — 원장에 아무것도 쓰지 않는다.
|
|
expect(api.pulsePosts).toHaveLength(0);
|
|
await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled();
|
|
});
|
|
|
|
// usecase: 회기 상태가 바뀌어 mid 잠금이 422로 거절된 학습자는 사유를 그대로 본다
|
|
test("회기 상태가 바뀌어 mid 잠금이 422로 거절되면 사유를 그대로 본다", async ({
|
|
page,
|
|
}) => {
|
|
await routeLiveSession(page, {
|
|
initialPulses: [lockedPulse("pre")],
|
|
pulsePostStatus: 422,
|
|
});
|
|
await startSession(page);
|
|
await completeOneExchange(page);
|
|
|
|
await page.getByRole("button", { name: "30초 점검" }).click();
|
|
await pickCheckpointScore(page, "목표", "4 대체로 그렇다");
|
|
await pickCheckpointScore(page, "과업", "4 대체로 그렇다");
|
|
await pickCheckpointScore(page, "유대", "4 대체로 그렇다");
|
|
await page.getByRole("button", { name: "중간 판단 잠그고 이어가기" }).click();
|
|
|
|
await expect(
|
|
page.getByText("회기 진행 상태가 바뀌어 이 시점의 점검을 잠글 수 없습니다."),
|
|
).toBeVisible();
|
|
await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled();
|
|
});
|
|
});
|
|
|
|
// ── post: 리뷰에서 잠금·공개·수정 불가 ─────────────────────────────
|
|
|
|
test.describe("UC 치료 동맹 펄스 — 리뷰(post)", () => {
|
|
test("근거 장면은 화면용 t번호가 아니라 영속 UUID로 잠근다", async ({ page }) => {
|
|
await routeReviewPage(page);
|
|
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,
|
|
{ pulse_id: "pulse-post-uuid", status: "awaiting_agents" },
|
|
202,
|
|
);
|
|
return;
|
|
}
|
|
await fulfillJson(route, { items: [] });
|
|
},
|
|
);
|
|
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
|
|
|
|
await pickReviewScore(page, "목표 합의 평가", "4 대체로 그렇다");
|
|
await pickReviewScore(page, "과업 합의 평가", "3 보통이다");
|
|
await pickReviewScore(page, "정서적 유대 평가", "5 매우 그렇다");
|
|
await page.getByText("내 판단의 근거 장면 선택").click();
|
|
await page.getByRole("checkbox").first().check();
|
|
await page.getByRole("button", { name: "내 판단 잠그고 관점 비교" }).click();
|
|
|
|
await expect.poll(() => submittedBody).not.toBeNull();
|
|
expect(submittedBody).toEqual({
|
|
checkpoint: "post",
|
|
scores: { goal: 0.75, task: 0.5, bond: 1 },
|
|
evidence_turn_ids: [FILLED_REVIEW_TURN_IDS[0]],
|
|
});
|
|
expect(FILLED_REVIEW_TURN_IDS[0]).toMatch(
|
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
|
);
|
|
});
|
|
|
|
// usecase: 리뷰에서 근거 장면 없이 세 축만 고른 학습자는 post 판단을 잠글 수 없다
|
|
test("리뷰에서 근거 장면 없이 세 축만 고르면 post 판단을 잠글 수 없다", async ({
|
|
page,
|
|
}) => {
|
|
await routeReviewPage(page);
|
|
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`);
|
|
|
|
await pickReviewScore(page, "목표 합의 평가", "4 대체로 그렇다");
|
|
await pickReviewScore(page, "과업 합의 평가", "3 보통이다");
|
|
await pickReviewScore(page, "정서적 유대 평가", "5 매우 그렇다");
|
|
|
|
// 저장 발화가 있는 회기에서는 근거 장면 없이 잠글 수 없다.
|
|
const submit = page.getByRole("button", { name: "내 판단 잠그고 관점 비교" });
|
|
await expect(submit).toBeDisabled();
|
|
|
|
await page.getByText("내 판단의 근거 장면 선택").click();
|
|
await page.getByRole("checkbox").first().check();
|
|
await expect(page.getByText("1개 선택")).toBeVisible();
|
|
await expect(submit).toBeEnabled();
|
|
});
|
|
|
|
// usecase: 다른 탭에서 이미 잠근 post와 겹친 학습자는 서버에 저장된 자기평가를 그대로 본다
|
|
test("post 잠금이 409로 겹치면 서버에 저장된 자기평가를 그대로 본다", async ({
|
|
page,
|
|
}) => {
|
|
await routeReviewPage(page);
|
|
let conflicted = false;
|
|
await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/alliance-pulses`, async (route) => {
|
|
if (route.request().method() === "POST") {
|
|
conflicted = true;
|
|
await fulfillJson(route, { detail: "이미 잠긴 자기평가가 있습니다." }, 409);
|
|
return;
|
|
}
|
|
await fulfillJson(route, {
|
|
items: conflicted
|
|
? [lockedPulse("post", { goal: 0.25, task: 0.5, bond: 0.75 })]
|
|
: [],
|
|
});
|
|
});
|
|
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
|
|
|
|
await pickReviewScore(page, "목표 합의 평가", "5 매우 그렇다");
|
|
await pickReviewScore(page, "과업 합의 평가", "5 매우 그렇다");
|
|
await pickReviewScore(page, "정서적 유대 평가", "5 매우 그렇다");
|
|
await page.getByText("내 판단의 근거 장면 선택").click();
|
|
await page.getByRole("checkbox").first().check();
|
|
await page.getByRole("button", { name: "내 판단 잠그고 관점 비교" }).click();
|
|
|
|
// 내가 방금 고른 5·5·5가 아니라 서버 원장의 2·3·4가 진실이다.
|
|
await expect(page.getByText("내 판단이 잠겼습니다")).toBeVisible();
|
|
const locked = page.locator(".ap-locked-scores");
|
|
await expect(locked).toContainText("25점");
|
|
await expect(locked).toContainText("50점");
|
|
await expect(locked).toContainText("75점");
|
|
});
|
|
|
|
// usecase: AI 분석이 실패한 학습자도 자신의 세 축 판단은 보존되고, 지어낸 점수를 보지 않는다
|
|
test("AI 분석이 실패해도 내 세 축 판단은 보존되고 점수를 지어내지 않는다", async ({
|
|
page,
|
|
}) => {
|
|
await routeReviewPage(page);
|
|
const errorPulse = lockedPulse("post", { goal: 0.75, task: 0.5, bond: 1 }, {
|
|
status: "error",
|
|
error_code: "agents_failed",
|
|
});
|
|
await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/alliance-pulses`, (route) =>
|
|
fulfillJson(route, { items: [errorPulse] }),
|
|
);
|
|
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
|
|
|
|
await expect(page.getByText("분석 보류", { exact: true })).toBeVisible();
|
|
await expect(page.getByText("내 판단은 보존됐습니다")).toBeVisible();
|
|
|
|
// goal/task/bond 세 축이 각각 자기 점수로 표시된다.
|
|
const locked = page.locator(".ap-locked-scores");
|
|
await expect(locked.getByText("목표", { exact: true })).toBeVisible();
|
|
await expect(locked.getByText("과업", { exact: true })).toBeVisible();
|
|
await expect(locked.getByText("유대", { exact: true })).toBeVisible();
|
|
await expect(locked).toContainText("75점");
|
|
await expect(locked).toContainText("50점");
|
|
await expect(locked).toContainText("100점");
|
|
|
|
// 준비되지 않은 AI 관점을 추정해 그리지 않는다.
|
|
await expect(page.getByLabel("치료 동맹 관점 비교")).toHaveCount(0);
|
|
await expect(page.locator(".ap-waiting__skeleton")).toHaveCount(0);
|
|
});
|
|
|
|
// usecase: 공개된 비교 뷰에서 자기·관찰자 점수 차이를 확인하고 근거 발화로 점프한다
|
|
test("공개된 비교 뷰에서 자기·관찰자 점수 차이를 보고 근거 발화로 점프한다", async ({
|
|
page,
|
|
}) => {
|
|
await routeReviewPage(page);
|
|
await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/alliance-pulses`, (route) =>
|
|
fulfillJson(route, { items: [revealedPostPulse()] }),
|
|
);
|
|
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
|
|
|
|
const comparison = page.getByLabel("치료 동맹 관점 비교");
|
|
await expect(comparison).toBeVisible();
|
|
|
|
// goal 축: 내 판단 75점 vs 내담자 40점 vs 관찰자 20점 — 관점차가 그대로 병치된다.
|
|
const goalRow = comparison.locator('section[aria-labelledby="ap-dimension-goal"]');
|
|
await expect(goalRow).toContainText("75점");
|
|
await expect(goalRow).toContainText("40점");
|
|
await expect(goalRow).toContainText("20점");
|
|
// 세 축이 각각 독립된 행으로 표시된다.
|
|
await expect(
|
|
comparison.locator('section[aria-labelledby="ap-dimension-task"]'),
|
|
).toContainText("55점");
|
|
await expect(
|
|
comparison.locator('section[aria-labelledby="ap-dimension-bond"]'),
|
|
).toContainText("90점");
|
|
|
|
// bond 축 내담자 관점 근거에서 축어록으로 점프한다.
|
|
const bondRow = comparison.locator('section[aria-labelledby="ap-dimension-bond"]');
|
|
await bondRow.locator(".ap-measurement-evidence").first().getByText("근거 1개").click();
|
|
await bondRow
|
|
.getByRole("button", { name: /6번째 발화/ })
|
|
.first()
|
|
.click();
|
|
await expect(page.getByRole("tab", { name: "축어록" })).toHaveAttribute(
|
|
"aria-selected",
|
|
"true",
|
|
);
|
|
await expect(page.locator(".sr-turn--active")).toContainText("기억에 남아요");
|
|
});
|
|
|
|
// usecase: 관점이 공개된 뒤의 학습자에게는 자기평가를 고칠 입력이 남아 있지 않다
|
|
test("관점 공개 뒤에는 자기평가를 고칠 입력이 남아 있지 않다", async ({ page }) => {
|
|
await routeReviewPage(page);
|
|
await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/alliance-pulses`, (route) =>
|
|
fulfillJson(route, { items: [revealedPostPulse()] }),
|
|
);
|
|
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
|
|
|
|
await expect(page.getByLabel("치료 동맹 관점 비교")).toBeVisible();
|
|
// 학습자 뷰에는 다시 고를 라디오도, 잠금 버튼도 남지 않는다.
|
|
const card = page.locator(".ap-card");
|
|
await expect(card.getByRole("radio")).toHaveCount(0);
|
|
await expect(
|
|
card.getByRole("button", { name: "내 판단 잠그고 관점 비교" }),
|
|
).toHaveCount(0);
|
|
// 내 열은 "잠긴 자기평가" 출처로만 남는다.
|
|
await expect(card.locator("small:visible", { hasText: "잠긴 자기평가" }).first()).toBeVisible();
|
|
await expect(
|
|
card.getByText("먼저 기록한 자기평가").first(),
|
|
).toBeVisible();
|
|
});
|
|
|
|
// usecase: 320px 폰에서 리뷰를 여는 학습자에게 1~5 척도가 3+2 두 줄로 접혀도 다섯 칸이 모두 남는다
|
|
test("320px에서 1~5 척도가 3+2 두 줄로 접혀도 다섯 칸이 모두 남는다", async ({
|
|
page,
|
|
}) => {
|
|
test.skip(
|
|
test.info().project.name !== "chromium-mobile",
|
|
"초소형 폭 배열 검증은 mobile 프로젝트에서만 수행",
|
|
);
|
|
await page.setViewportSize({ width: 320, height: 568 });
|
|
await routeReviewPage(page);
|
|
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();
|
|
const options = scale.locator(".ap-scale__option");
|
|
await expect(options).toHaveCount(5);
|
|
|
|
const boxes = await options.evaluateAll((elements) =>
|
|
elements.map((element) => {
|
|
const rect = element.getBoundingClientRect();
|
|
return { top: rect.top, left: rect.left, right: rect.right };
|
|
}),
|
|
);
|
|
// 첫 줄: 1·2·3 이 같은 행.
|
|
expect(Math.abs(boxes[1].top - boxes[0].top)).toBeLessThanOrEqual(2);
|
|
expect(Math.abs(boxes[2].top - boxes[0].top)).toBeLessThanOrEqual(2);
|
|
// 둘째 줄: 4·5 가 같은 행으로 내려간다.
|
|
expect(boxes[3].top).toBeGreaterThan(boxes[0].top + 10);
|
|
expect(Math.abs(boxes[4].top - boxes[3].top)).toBeLessThanOrEqual(2);
|
|
// 4번 칸은 2번 그리드 컬럼에서 시작해 가운데 정렬된다.
|
|
expect(boxes[3].left).toBeGreaterThan(boxes[0].left + 10);
|
|
// 모든 칸이 화면 안에 남는다.
|
|
for (const box of boxes) {
|
|
expect(box.right).toBeLessThanOrEqual(320);
|
|
}
|
|
});
|
|
});
|