vignette/apps/web/e2e/full-sweep-persona-studio.spec.ts

887 lines
36 KiB
TypeScript

/**
* 전수 순회(2026-07-27) — 페르소나 스튜디오(/teach/personas) 신규 spec.
*
* docs/ops/e2e-full-sweep-2026-07-27.md 6장에서 "검증: 신규 spec 필요"로 표기된
* 체크리스트 항목을 검증한다. AI 엔진 호출 없이 route fixture로 상태를 고정하고,
* 인증만 실 dev-login API를 사용한다. 각 test() 위 주석에 검증하는 checklist id를 적는다.
*/
import { expect, test, type Page } from "@playwright/test";
import {
expectNoHorizontalOverflow,
signInAsLearner,
signInAsTeacher,
} from "./support";
const APPROVED_ID = "00000000-0000-0000-0000-000000000801";
const DRAFT_ID = "00000000-0000-0000-0000-000000000802";
const REVIEW_ID = "00000000-0000-0000-0000-000000000803";
const approvedP1 = {
persona_id: APPROVED_ID,
code: "P1",
version: 3,
status: "approved",
display_name: "서연(가명) · 고2 · 우울/자살사고",
difficulty: "hard",
theory_target: ["humanistic"],
demographics: { age_band: "F-teen" },
presenting_summary: "최근 무기력과 관계 단절을 호소합니다.",
source: "database",
degraded: false,
voice_preset: null,
};
const reviewP12 = {
persona_id: REVIEW_ID,
code: "P12",
version: 1,
status: "review",
display_name: "직장 적응 훈련 페르소나",
difficulty: "moderate",
theory_target: ["cbt"],
source_provenance: "교수자 작성 초안",
is_synthetic: true,
created_at: "2026-07-15T08:00:00Z",
approved_at: null,
};
const draftP13 = {
persona_id: DRAFT_ID,
code: "P13",
version: 1,
status: "review",
display_name: "발표 불안 훈련 초안",
difficulty: "moderate",
theory_target: ["cbt"],
source_provenance: "교수 상담 기록 2024",
is_synthetic: false,
created_at: "2026-07-20T02:00:00Z",
approved_at: null,
};
function draftDetail(base: typeof draftP13) {
return {
...base,
demographics: { age_band: "20대", sex: "여", role: "대학생", context: "실습" },
presenting: { complaint: "발표 상황에서 심한 불안을 호소합니다." },
history: { family: "성취 압박이 강한 가정" },
big5: { O: 0.5, C: 0.4, E: 0.3, A: 0.6, N: 0.8 },
resistance: {
base_resistance: 0.6,
unlock_rate: 0.1,
decay_floor: 0.05,
silence_prob: 0.15,
deflection_prob: 0.25,
},
speech_style: { register: "polite", honorific: true },
affect_baseline: {
negative_affect: 0.7,
anxiety: 0.6,
hopelessness: 0.2,
anhedonia: 0.2,
sleep: 0.2,
suicide_ideation_stage: 1,
},
ccd: { core_belief: "나는 부족하다" },
dsm5_dimensional: { note: "평가 상황에서 불안 상승" },
triggers: { sore_spots: ["평가", "비교"], forbidden: ["단정"] },
};
}
function dashboardFixture(overrides: Record<string, unknown> = {}) {
return {
source: "database",
cohort_label: "E2E cohort",
total_learners: 0,
active_sessions: 0,
ended_sessions: 0,
learner_growth: [],
safety_alerts: [],
pending_reviews: [],
recent_sessions: [],
message: "persona studio full sweep fixture",
...overrides,
};
}
async function mockStudio(
page: Page,
options: {
catalog?: unknown[];
reviews?: unknown[];
dashboard?: Record<string, unknown> | null;
} = {},
) {
await page.route("**/api/personas", (route) => {
if (route.request().method() !== "GET") return route.fallback();
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(options.catalog ?? []),
});
});
await page.route("**/api/personas/review", (route) => {
if (route.request().method() !== "GET") return route.fallback();
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(options.reviews ?? []),
});
});
await page.route("**/api/teacher/dashboard", (route) => {
if (options.dashboard === null) {
return route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ detail: "dashboard unavailable" }),
});
}
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(options.dashboard ?? dashboardFixture()),
});
});
}
function meFixture(overrides: Record<string, unknown> = {}) {
return {
user_id: "00000000-0000-0000-0000-000000000901",
email: "guard-check@hs.ac.kr",
role: "teacher",
display_name: "Guard Check Teacher",
admin_access: false,
super_admin: false,
account_status: "approved",
approval_required: false,
cohort_ids: [],
consent_at: 1782820000,
onboarding_completed_at: 1782820001,
nickname: "Guard Check",
self_introduction: "",
avatar_url: "",
...overrides,
};
}
test.describe("persona studio full sweep", () => {
// checklist: persona-studio-guard-require-auth, persona-studio-guard-pending-approval,
// persona-studio-guard-onboarding
test("redirects unauthenticated, learner, pending, and un-onboarded users away", async ({
page,
}) => {
// 미인증 → /login
await page.goto("/teach/personas");
await expect(page).toHaveURL(/\/login$/);
// learner 역할 → 자기 역할 홈(/learn)
await signInAsLearner(page);
await page.goto("/teach/personas");
await expect(page).toHaveURL(/\/learn$/);
// 미승인(teacher, pending) → /pending
await page.route("**/api/auth/me", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
meFixture({ account_status: "pending", onboarding_completed_at: null }),
),
}),
);
await page.goto("/teach/personas");
await expect(page).toHaveURL(/\/pending$/);
// 승인됐지만 온보딩 미완료 → /onboarding
await page.unroute("**/api/auth/me");
await page.route("**/api/auth/me", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(meFixture({ onboarding_completed_at: null })),
}),
);
await page.goto("/teach/personas");
await expect(page).toHaveURL(/\/onboarding$/);
});
// checklist: persona-studio-dash-usage-row, persona-studio-detail-learning-empty
test("opens approved detail from a dashboard usage row and shows empty learning state", async ({
page,
}) => {
await signInAsTeacher(page);
await mockStudio(page, { catalog: [approvedP1], reviews: [] });
await page.goto("/teach/personas");
await expect(page.getByRole("heading", { name: "페르소나 운영" })).toBeVisible();
const usageRow = page.locator(".ps-usage-row").filter({ hasText: "P1" });
await expect(usageRow).toBeVisible();
await usageRow.click();
await expect(page).toHaveURL(/view=personas/);
await expect(page).toHaveURL(/persona=approved%3AP1/);
await expect(page.getByRole("heading", { name: "페르소나 상세" })).toBeVisible();
// 평가된 회기 궤적이 없으면 학습 현황 탭은 빈 상태를 표시한다.
await page.getByRole("tab", { name: "학습 현황" }).click();
await expect(page.getByText("학습 기록 없음")).toBeVisible();
await expectNoHorizontalOverflow(page);
});
// checklist: persona-studio-dash-usage-empty, persona-studio-catalog-empty-distribution,
// persona-studio-review-empty
test("shows empty states on dashboard, catalog distribution, and review queue", async ({
page,
}) => {
await signInAsTeacher(page);
await mockStudio(page, { catalog: [], reviews: [] });
await page.goto("/teach/personas");
await expect(page.getByText("공개 페르소나 없음")).toBeVisible();
await page.getByRole("button", { name: /카탈로그/ }).first().click();
await expect(page.getByRole("heading", { name: "페르소나 카탈로그" })).toBeVisible();
await expect(page.getByText("분포 자료 없음")).toBeVisible();
await page.getByRole("button", { name: /페르소나/ }).first().click();
await page.getByRole("tab", { name: /검수 현황/ }).click();
await expect(page.getByText("검수 대기 없음")).toBeVisible();
});
// checklist: persona-studio-list-search, persona-studio-list-origin-filter,
// persona-studio-list-empty
test("filters the persona list by search text and origin select", async ({ page }) => {
await signInAsTeacher(page);
await mockStudio(page, { catalog: [approvedP1], reviews: [reviewP12] });
await page.goto("/teach/personas?view=personas");
const rows = page.locator(".ps-persona-row");
await expect(rows).toHaveCount(2);
// 이름 부분 일치 검색
await page.getByLabel("페르소나 검색").fill("직장");
await expect(rows).toHaveCount(1);
await expect(rows.first()).toContainText("P12");
// 상태 라벨로도 검색된다 (검수 대기)
await page.getByLabel("페르소나 검색").fill("검수 대기");
await expect(rows).toHaveCount(1);
await expect(rows.first()).toContainText("P12");
await page.getByLabel("페르소나 검색").fill("");
await expect(rows).toHaveCount(2);
// 구분 필터: P1~P11은 시스템, 그 외는 커스텀
await page.getByLabel("구분").selectOption("system");
await expect(rows).toHaveCount(1);
await expect(rows.first()).toContainText("P1");
await page.getByLabel("구분").selectOption("custom");
await expect(rows).toHaveCount(1);
await expect(rows.first()).toContainText("P12");
// 결과 없음 빈 상태
await page.getByLabel("구분").selectOption("all");
await page.getByLabel("페르소나 검색").fill("존재하지않는페르소나");
await expect(page.getByText("조건에 맞는 페르소나 없음")).toBeVisible();
});
// checklist: persona-studio-review-card-reject, persona-studio-review-card-edit
test("rejects a pending review card and opens the draft editor from a card", async ({
page,
}) => {
await signInAsTeacher(page);
const draftStatusPersona = { ...draftP13, status: "draft" };
await mockStudio(page, { catalog: [], reviews: [reviewP12, draftStatusPersona] });
let rejectPayload: unknown = null;
await page.route(`**/api/personas/review/${REVIEW_ID}`, async (route) => {
expect(route.request().method()).toBe("POST");
rejectPayload = route.request().postDataJSON();
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ ...reviewP12, status: "draft" }),
});
});
await page.route(`**/api/personas/drafts/${REVIEW_ID}`, (route) => {
if (route.request().method() !== "GET") return route.fallback();
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(draftDetail({ ...draftP13, ...reviewP12 })),
});
});
await page.goto("/teach/personas?view=personas&queue=review");
// status=draft 카드는 승인·반려가 비활성화된다.
const draftCard = page.locator(".ps-review-card").filter({ hasText: "P13" });
await expect(draftCard).toBeVisible();
await expect(draftCard.getByRole("button", { name: "반려" })).toBeDisabled();
await expect(draftCard.getByRole("button", { name: "승인" })).toBeDisabled();
// status=review 카드 반려 → decide API 호출과 결과 메시지
const reviewCard = page.locator(".ps-review-card").filter({ hasText: "P12" });
await reviewCard.getByRole("button", { name: "반려" }).click();
await expect(page.getByText("P12 v1 반려했습니다.")).toBeVisible();
expect(rejectPayload).toEqual({ action: "reject" });
// 검수 카드 수정 → mode=edit 편집기로 이동해 초안이 자동 로드된다.
await page
.locator(".ps-review-card")
.filter({ hasText: "P12" })
.getByRole("button", { name: "수정" })
.click();
await expect(page).toHaveURL(/mode=edit/);
await expect(page).toHaveURL(new RegExp(`draft=${REVIEW_ID}`));
await expect(page.getByLabel("표시 이름")).toHaveValue("직장 적응 훈련 페르소나");
});
// checklist: persona-studio-draft-autoload-param
test("auto-loads the draft named by the ?draft= query param into the editor", async ({
page,
}) => {
await signInAsTeacher(page);
await mockStudio(page, { catalog: [], reviews: [draftP13] });
await page.route(`**/api/personas/drafts/${DRAFT_ID}`, (route) => {
if (route.request().method() !== "GET") return route.fallback();
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(draftDetail(draftP13)),
});
});
await page.goto(
`/teach/personas?view=personas&mode=edit&draft=${DRAFT_ID}&step=edit`,
);
await expect(page.getByText("P13 v1 초안을 불러왔습니다.")).toBeVisible();
await expect(page.getByLabel("표시 이름")).toHaveValue("발표 불안 훈련 초안");
await expect(page.getByLabel("코드")).toHaveValue("P13");
});
// checklist: persona-studio-author-rail-draft-load, persona-studio-author-rail-approved-actions
// 저작 모드는 4단계 스테퍼+인스펙터 중심의 포커스 화면을 쓰고, 좌측 레일(.ps-rail)
// 마크업은 렌더하지 않는다. 초안 로드는 목록 뷰·검수 큐·직접 URL로 진입한다.
// 항상 숨겨지던 죽은 DOM이었던 레일은 2026-07-27 소유자 결정으로 정리 완료
// (체크리스트 결함 로그 #14).
test("keeps the authoring screen focused without rendering the left rail", async ({
page,
}) => {
await signInAsTeacher(page);
await mockStudio(page, { catalog: [approvedP1], reviews: [draftP13] });
await page.goto("/teach/personas?view=personas&mode=create&step=edit");
await expect(page.getByRole("navigation", { name: "페르소나 작성 단계" })).toBeVisible();
// 레일은 DOM 자체가 존재하지 않아야 한다. 초안 진입 경로(목록 뷰 행 클릭·검수
// 큐·직접 URL)는 이 파일의 다른 테스트가 검증한다.
await expect(page.locator(".ps-authoring-layout > .ps-rail")).toHaveCount(0);
});
test("keeps authoring steps in one row and exposes the editor at every target width", async ({
page,
}) => {
await signInAsTeacher(page);
await mockStudio(page, { catalog: [approvedP1], reviews: [] });
const viewports = [
{ width: 320, height: 844 },
{ width: 390, height: 844 },
{ width: 1440, height: 900 },
];
for (const viewport of viewports) {
await page.setViewportSize(viewport);
await page.goto("/teach/personas?view=personas&mode=create&step=edit");
const stepper = page.getByRole("navigation", { name: "페르소나 작성 단계" });
const steps = stepper.getByRole("button");
await expect(steps).toHaveCount(4);
const boxes = await steps.evaluateAll((elements) =>
elements.map((element) => {
const rect = element.getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
}),
);
expect(Math.max(...boxes.map((box) => box.y)) - Math.min(...boxes.map((box) => box.y))).toBeLessThanOrEqual(1);
for (const box of boxes) {
expect(box.height).toBeGreaterThanOrEqual(44);
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.x + box.width).toBeLessThanOrEqual(viewport.width);
}
await expect(page.locator(".ps-editor__top")).toBeInViewport();
if (viewport.width <= 720) {
const undersizedActions = await page
.locator(".ps-root .vg-btn:not(:disabled), .ps-root .ps-tabs button:not(:disabled)")
.evaluateAll((elements) =>
elements.flatMap((element) => {
const rect = element.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return [];
return rect.width >= 44 && rect.height >= 44
? []
: [{
selector: `${element.tagName.toLowerCase()}.${Array.from(element.classList).join(".")}`,
text: element.textContent?.trim(),
width: rect.width,
height: rect.height,
}];
}),
);
expect(undersizedActions).toEqual([]);
}
await expectNoHorizontalOverflow(page);
if (process.env.PERSONA_AUTHORING_CAPTURE_DIR) {
await page.screenshot({
path: `${process.env.PERSONA_AUTHORING_CAPTURE_DIR}/persona-authoring-${viewport.width}.png`,
fullPage: true,
});
}
}
for (const viewport of viewports) {
await page.setViewportSize(viewport);
await page.goto("/teach/personas?view=catalog");
await expect(page.getByRole("button", { name: "전체 페르소나 보기" })).toBeVisible();
await expectNoHorizontalOverflow(page);
if (process.env.PERSONA_AUTHORING_CAPTURE_DIR) {
await page.screenshot({
path: `${process.env.PERSONA_AUTHORING_CAPTURE_DIR}/persona-catalog-${viewport.width}.png`,
fullPage: true,
});
}
}
});
// checklist: persona-studio-inspector-review-actions
test("edits and approves review drafts from the inspector queue", async ({ page }) => {
await signInAsTeacher(page);
// 편집 대상(P13)과 승인 대상(P12)을 분리한다. 현재 편집 중인 초안을 승인하면
// resetDraft가 성공 메시지를 지우는 별개 동작이 있어 결정 메시지 검증이 섞이지 않게 한다.
await mockStudio(page, { catalog: [], reviews: [draftP13, reviewP12] });
await page.route(`**/api/personas/drafts/${DRAFT_ID}`, (route) => {
if (route.request().method() !== "GET") return route.fallback();
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(draftDetail(draftP13)),
});
});
let decidePayload: unknown = null;
await page.route(`**/api/personas/review/${REVIEW_ID}`, async (route) => {
expect(route.request().method()).toBe("POST");
decidePayload = route.request().postDataJSON();
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
...reviewP12,
status: "approved",
approved_at: "2026-07-27T00:00:00Z",
}),
});
});
await page.goto("/teach/personas?view=personas&mode=create&step=edit");
const editCard = page.locator(".ps-inspector .ps-review").filter({ hasText: "P13" });
await expect(editCard).toBeVisible();
await editCard.getByRole("button", { name: "편집" }).click();
await expect(page.getByText("P13 v1 초안을 불러왔습니다.")).toBeVisible();
await expect(page.getByLabel("표시 이름")).toHaveValue("발표 불안 훈련 초안");
const approveCard = page
.locator(".ps-inspector .ps-review")
.filter({ hasText: "P12" });
await approveCard.getByRole("button", { name: "승인" }).click();
await expect(page.getByText("P12 v1 승인했습니다.")).toBeVisible();
expect(decidePayload).toEqual({ action: "approve" });
});
// checklist: persona-studio-author-empty-states, persona-studio-inspector-validation,
// persona-studio-field-provenance
test("shows inspector empty states and live validation checklist while authoring", async ({
page,
}) => {
await signInAsTeacher(page);
await mockStudio(page, { catalog: [], reviews: [] });
await page.goto("/teach/personas?view=personas&mode=create&step=edit");
const inspector = page.locator(".ps-inspector");
// 인스펙터 빈 상태 (생성 근거 없음 / 결정 대기 없음)
await expect(
inspector.getByText("자료 기반 생성 결과가 아직 없습니다."),
).toBeVisible();
await expect(inspector.getByText("결정 대기 없음")).toBeVisible();
// 검증 체크리스트: 필수 항목 오류가 실시간으로 나열된다.
await expect(inspector.getByText("표시 이름이 비어 있습니다.")).toBeVisible();
await expect(inspector.getByText("주호소가 비어 있습니다.")).toBeVisible();
await expect(inspector.getByText("CCD 핵심신념이 비어 있습니다.")).toBeVisible();
await expect(inspector.getByText("역린 민감 영역이 비어 있습니다.")).toBeVisible();
// 값을 채우면 해당 오류가 사라진다.
await page.getByLabel("표시 이름").fill("검증 확인 페르소나");
await expect(inspector.getByText("표시 이름이 비어 있습니다.")).toHaveCount(0);
// 출처를 비우면 경고가 나타난다.
await page.getByLabel("출처").fill("");
await expect(inspector.getByText("출처/작성 근거가 비어 있습니다.")).toBeVisible();
await page.getByLabel("출처").fill("교수 자체 작성");
await expect(inspector.getByText("출처/작성 근거가 비어 있습니다.")).toHaveCount(0);
});
// checklist: persona-studio-theory-toggle, persona-studio-fields-resistance-numbers
test("toggles theory targets and edits the 16 resistance numeric fields", async ({
page,
}) => {
await signInAsTeacher(page);
await mockStudio(page, { catalog: [], reviews: [] });
await page.goto("/teach/personas?view=personas&mode=create&step=edit");
const segments = page.locator(".ps-segments");
const humanistic = segments.getByRole("button", { name: "humanistic", exact: true });
const cbt = segments.getByRole("button", { name: "cbt", exact: true });
await expect(humanistic).toHaveClass(/is-active/);
await cbt.click();
await expect(cbt).toHaveClass(/is-active/);
await expect(humanistic).toHaveClass(/is-active/);
await humanistic.click();
await expect(humanistic).not.toHaveClass(/is-active/);
// 마지막 남은 이론(cbt)을 해제하면 humanistic으로 되돌아간다.
await cbt.click();
await expect(cbt).not.toHaveClass(/is-active/);
await expect(humanistic).toHaveClass(/is-active/);
// 저항 탭: 저항 5 + Big5 5 + 정서 기저선 5 + 자살사고 단계 = 16개 숫자 입력
await page.getByRole("tab", { name: "저항" }).click();
await expect(page.locator('.ps-number-grid input[type="number"]')).toHaveCount(16);
await page.getByLabel("초기 저항").fill("0.8");
await expect(page.getByLabel("초기 저항")).toHaveValue("0.8");
await page.getByLabel("자살사고 단계").fill("3");
await expect(page.getByLabel("자살사고 단계")).toHaveValue("3");
await expect(page.getByLabel("자살사고 단계")).toHaveAttribute("max", "5");
});
// checklist: persona-studio-source-dropzone, persona-studio-source-file-picker,
// persona-studio-source-kind-select, persona-studio-source-name-input,
// persona-studio-generation-goal-input, persona-studio-source-text-area,
// persona-studio-generate-draft, persona-studio-inspector-evidence,
// persona-studio-status-message-line
test("registers pasted source as KB evidence and generates a draft", async ({ page }) => {
await signInAsTeacher(page);
await mockStudio(page, { catalog: [], reviews: [] });
const sourceDoc = {
source_id: "src-e2e-1",
doc_id: "doc-e2e-9",
chunk_count: 3,
content_hash: "abcdef1234567890",
title: "사례-정리.md",
source_kind: "textbook_guide",
pii_entities_masked: ["이름"],
external_llm_ok: true,
degraded: false,
};
const generatedDraft = {
code: "P5",
display_name: "생성된 페르소나",
difficulty: "moderate",
theory_target: ["humanistic"],
demographics: {},
presenting: { complaint: "발표 상황 회피" },
history: {},
speech_style: {},
ccd: {},
dsm5_dimensional: {},
triggers: {},
source_provenance: "상담기록 근거",
is_synthetic: false,
};
let sourcePayload: Record<string, unknown> | null = null;
let generatePayload: Record<string, unknown> | null = null;
await page.route("**/api/personas/sources", async (route) => {
sourcePayload = route.request().postDataJSON() as Record<string, unknown>;
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify(sourceDoc),
});
});
await page.route("**/api/personas/drafts/generate", async (route) => {
generatePayload = route.request().postDataJSON() as Record<string, unknown>;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
draft: generatedDraft,
source_summary: "근거 청크 3개로 초안을 생성했습니다.",
warnings: ["표면화 조건 근거가 부족합니다."],
pii_entities_masked: ["연락처"],
source_references: [sourceDoc],
evidence_chunks: [
{
source_id: "src-e2e-1",
chunk_id: "c-1",
score: 0.912,
heading_path: "1회기 요약",
excerpt: "내담자는 발표 상황에서 심한 긴장을 보고했다.",
},
],
}),
});
});
await page.goto("/teach/personas?view=personas&mode=create&step=generate");
// 자료 없이 생성 → role=alert 오류 문구 (상태 메시지 라인 오류 분기)
await page.getByRole("button", { name: "KB 등록 후 생성" }).click();
const errorLine = page.locator(".ps-status.is-error");
await expect(errorLine).toBeVisible();
await expect(errorLine).toHaveAttribute("role", "alert");
await expect(errorLine).toContainText("20자 이상");
// 파일 선택 → 텍스트 파일은 브라우저에서 읽어 본문에 채운다.
await page.locator(".ps-file-input").setInputFiles({
name: "상담기록-1회기.txt",
mimeType: "text/plain",
buffer: Buffer.from("내담자는 발표를 앞두고 반복적으로 불안을 호소했다.", "utf-8"),
});
await expect(page.getByLabel("파일/자료명")).toHaveValue("상담기록-1회기.txt");
await expect(page.getByLabel("자료 본문")).toHaveValue(/반복적으로 불안을 호소/);
await expect(page.getByText(/첨부 내용을 불러왔습니다/)).toBeVisible();
// 드래그&드롭 → 첫 파일을 읽어 본문을 교체한다.
const dataTransfer = await page.evaluateHandle(() => {
const dt = new DataTransfer();
dt.items.add(
new File(
["드롭한 사례 기록: 평가 상황에서 회피 행동이 반복적으로 관찰되었다."],
"드롭-사례.md",
{ type: "text/markdown" },
),
);
return dt;
});
await page.locator(".ps-upload-card").dispatchEvent("drop", { dataTransfer });
await expect(page.getByLabel("파일/자료명")).toHaveValue("드롭-사례.md");
await expect(page.getByLabel("자료 본문")).toHaveValue(/드롭한 사례 기록/);
// 자료 종류·자료명·생성 목표 입력이 생성 요청에 함께 전달된다.
await page.getByLabel("자료 종류").selectOption("textbook_guide");
await page.getByLabel("파일/자료명").fill("사례-정리.md");
await page.getByLabel("생성 목표").fill("인간중심 초심 훈련");
await page.getByRole("button", { name: "KB 등록 후 생성" }).click();
await expect(
page.getByText("첨부 자료를 RAG 근거 문서로 등록하고 초안을 생성했습니다.", {
exact: false,
}),
).toBeVisible();
expect(sourcePayload).toMatchObject({
filename: "사례-정리.md",
source_kind: "textbook_guide",
source_note: "인간중심 초심 훈련",
title: "사례-정리.md",
});
expect(String((sourcePayload as Record<string, unknown> | null)?.text)).toContain(
"드롭한 사례 기록",
);
expect(generatePayload).toMatchObject({
source_ids: ["src-e2e-1"],
source_kind: "textbook_guide",
generation_goal: "인간중심 초심 훈련",
});
// 인스펙터 생성 근거 패널: 요약·마스킹·경고·근거 문서·근거 청크
const inspector = page.locator(".ps-inspector");
await expect(
inspector.getByText("근거 청크 3개로 초안을 생성했습니다."),
).toBeVisible();
await expect(inspector.getByText("이름 마스킹됨")).toBeVisible();
await expect(inspector.getByText("연락처 마스킹됨")).toBeVisible();
await expect(inspector.getByText("표면화 조건 근거가 부족합니다.")).toBeVisible();
await expect(inspector.getByText("사례-정리.md")).toBeVisible();
await expect(inspector.getByText("chunk c-1 · 0.912")).toBeVisible();
await expect(
inspector.getByText(/내담자는 발표 상황에서 심한 긴장을 보고했다\./),
).toBeVisible();
// 생성 결과가 편집 폼에 반영됐는지 설정 단계에서 확인
await page
.getByRole("navigation", { name: "페르소나 작성 단계" })
.getByRole("button", { name: /설정/ })
.click();
await expect(page.getByLabel("표시 이름")).toHaveValue("생성된 페르소나");
await expect(page.getByLabel("코드")).toHaveValue("P5");
});
// checklist: persona-studio-save-progress, persona-studio-loading-busy-states
test("saves progress locally first and to the server with busy labels", async ({
page,
}) => {
await signInAsTeacher(page);
await mockStudio(page, { catalog: [], reviews: [] });
let draftPosted = false;
await page.route("**/api/personas/drafts", async (route) => {
if (route.request().method() !== "POST") return route.fallback();
draftPosted = true;
const payload = route.request().postDataJSON() as { display_name: string };
await new Promise((resolve) => setTimeout(resolve, 500));
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({
persona_id: "00000000-0000-0000-0000-000000000899",
code: "P1",
version: 1,
status: "draft",
display_name: payload.display_name,
difficulty: "moderate",
theory_target: ["humanistic"],
source_provenance: "clinical draft",
is_synthetic: true,
created_at: "2026-07-27T00:00:00Z",
approved_at: null,
}),
});
});
await page.goto("/teach/personas?view=personas&mode=create&step=edit");
// 최소 검증(표시 이름) 미달 → localStorage 임시 저장만 수행
await page.getByRole("button", { name: "중간 저장" }).click();
await expect(
page.getByText("작성 중 내용을 이 브라우저에 임시 저장했습니다.", { exact: false }),
).toBeVisible();
expect(draftPosted).toBe(false);
const stored = await page.evaluate(() =>
window.localStorage.getItem("vignette:persona-studio:progress:v1"),
);
expect(stored).not.toBeNull();
// 표시 이름을 채우면 서버 초안 생성까지 수행하고, 진행 중엔 버튼이 비활성 라벨로 바뀐다.
await page.getByLabel("표시 이름").fill("임시 저장 페르소나");
await page.getByRole("button", { name: "중간 저장" }).click();
const savingButton = page.getByRole("button", { name: "저장 중" });
await expect(savingButton).toBeVisible();
await expect(savingButton).toBeDisabled();
await expect(page.getByText("P1 v1 초안을 저장했습니다.")).toBeVisible();
expect(draftPosted).toBe(true);
});
// checklist: persona-studio-load-error-banner, persona-studio-refresh-button,
// persona-studio-dashboard-degraded-note
test("surfaces catalog load failure and recovers via refresh with a degraded note", async ({
page,
}) => {
await signInAsTeacher(page);
// 1) 카탈로그 로드 실패 → role=alert 오류 배너
await page.route("**/api/personas", (route) => {
if (route.request().method() !== "GET") return route.fallback();
return route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ detail: "catalog unavailable" }),
});
});
await page.route("**/api/personas/review", (route) =>
route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ detail: "review unavailable" }),
}),
);
await page.route("**/api/teacher/dashboard", (route) =>
route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ detail: "dashboard unavailable" }),
}),
);
await page.goto("/teach/personas");
const errorBanner = page.locator(".ps-error");
await expect(errorBanner).toBeVisible();
await expect(errorBanner).toHaveAttribute("role", "alert");
await expect(errorBanner).toContainText("catalog unavailable");
// 2) 카탈로그·검수 큐는 복구, 대시보드만 계속 실패 → 새로고침
await page.unroute("**/api/personas");
await page.unroute("**/api/personas/review");
await page.route("**/api/personas", async (route) => {
if (route.request().method() !== "GET") return route.fallback();
await new Promise((resolve) => setTimeout(resolve, 500));
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
});
});
await page.route("**/api/personas/review", (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "[]" }),
);
const refreshButton = page.locator(".ps-head__actions button").first();
await expect(refreshButton).toHaveText(/새로고침/);
await refreshButton.click();
// 로딩 중에는 '확인 중' 라벨로 비활성화된다.
await expect(refreshButton).toHaveText(/확인 중/);
await expect(refreshButton).toBeDisabled();
// 카탈로그가 복구되면 배너는 사라지고, 대시보드 실패는 경고 노트로만 남는다.
await expect(errorBanner).toHaveCount(0);
const degradedNote = page.locator(".ps-inline-note.is-warn");
await expect(degradedNote).toBeVisible();
await expect(degradedNote).toHaveAttribute("role", "status");
await expect(degradedNote).toContainText("학습 지표를 불러오지 못했습니다.");
await expect(degradedNote).toContainText(
"카탈로그와 저작 기능은 계속 사용할 수 있습니다.",
);
});
// checklist: persona-studio-detail-draft-fetch, persona-studio-detail-notfound
test("fetches draft detail for a review draft and shows the not-found state", async ({
page,
}) => {
await signInAsTeacher(page);
await mockStudio(page, { catalog: [], reviews: [draftP13] });
await page.route(`**/api/personas/drafts/${DRAFT_ID}`, (route) => {
if (route.request().method() !== "GET") return route.fallback();
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(draftDetail(draftP13)),
});
});
await page.goto(`/teach/personas?view=personas&persona=draft:${DRAFT_ID}`);
await expect(page.getByRole("heading", { name: "페르소나 상세" })).toBeVisible();
await expect(
page.getByText("발표 상황에서 심한 불안을 호소합니다."),
).toBeVisible();
// 초안 상세 자동 조회 결과: 출처·핵심 신념·민감 영역
await expect(page.getByText("교수 상담 기록 2024")).toBeVisible();
await expect(page.getByText("나는 부족하다")).toBeVisible();
await expect(page.getByText("평가, 비교")).toBeVisible();
// 목록에 없는 키로 진입하면 미존재 빈 상태를 표시한다.
await page.goto("/teach/personas?view=personas&persona=approved:NOPE");
await expect(page.getByText("페르소나를 찾지 못했습니다")).toBeVisible();
});
});