/** * 전수 순회(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 = {}) { 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 | 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 = {}) { 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); }); // 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 | null = null; let generatePayload: Record | null = null; await page.route("**/api/personas/sources", async (route) => { sourcePayload = route.request().postDataJSON() as Record; 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; 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 | 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(); }); });