887 lines
36 KiB
TypeScript
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();
|
|
});
|
|
});
|