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

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);
}
});
});