운영 화면과 회기 리뷰 UI 갱신

This commit is contained in:
Yun Chan 2026-06-28 20:13:21 +09:00
parent e7ebb38177
commit 3a9f70a97b
18 changed files with 2210 additions and 137 deletions

View file

@ -53,6 +53,14 @@ interface AdminUsageBreakdown {
cost_usd: number;
}
interface AdminUsageDailyCost {
day: string;
turns: number;
tokens_in: number;
tokens_out: number;
cost_usd: number;
}
interface AdminUsageBudget {
limit_usd: number;
used_ratio: number;
@ -60,6 +68,17 @@ interface AdminUsageBudget {
status: "disabled" | "ok" | "warn" | "exceeded";
}
interface AdminUsageEvaluatorCache {
enabled: boolean;
entries: number;
hits: number;
misses: number;
stores: number;
evictions: number;
requests: number;
hit_rate: number;
}
interface AdminUsageResponse {
source: "database" | "server_session_registry";
durable: boolean;
@ -70,7 +89,9 @@ interface AdminUsageResponse {
tokens_out: number;
cost_usd: number;
budget: AdminUsageBudget;
evaluator_cache?: AdminUsageEvaluatorCache;
by_provider: AdminUsageBreakdown[];
daily_cost?: AdminUsageDailyCost[];
}
interface AdminUptimeResponse {
@ -99,6 +120,10 @@ interface AdminSupportTicket {
subject: string;
body: string;
source_path: string;
parent_ticket_id?: string | null;
duplicate_count?: number;
duplicate_parent_candidate_id?: string | null;
child_ticket_count?: number;
created_at: number;
updated_at: number;
}
@ -262,6 +287,30 @@ function costLabel(value: number) {
return `$${value.toFixed(value < 0.01 ? 6 : 4)}`;
}
function rateLabel(value: number) {
if (!Number.isFinite(value) || value <= 0) return "0%";
return `${Math.round(value * 1000) / 10}%`;
}
function evaluatorCache(response: AdminUsageResponse): AdminUsageEvaluatorCache {
return (
response.evaluator_cache ?? {
enabled: false,
entries: 0,
hits: 0,
misses: 0,
stores: 0,
evictions: 0,
requests: 0,
hit_rate: 0,
}
);
}
function usageDailyCost(response: AdminUsageResponse): AdminUsageDailyCost[] {
return response.daily_cost ?? [];
}
function isOnline(seconds: number) {
return Number.isFinite(seconds) && seconds > 0 && Date.now() / 1000 - seconds < 15 * 60;
}
@ -446,6 +495,20 @@ test.describe("admin route", () => {
? "예산 주의"
: "예산 정상",
);
await expect(page.locator(".ad-panel").filter({ hasText: "AI 비용" })).toContainText(
"평가 캐시 hit-rate",
);
const cache = evaluatorCache(usage);
await expect(page.locator(".ad-panel").filter({ hasText: "AI 비용" })).toContainText(
cache.enabled
? `${rateLabel(cache.hit_rate)} hit`
: "캐시 비활성",
);
if (usageDailyCost(usage).length > 0) {
await expect(page.locator(".ad-panel").filter({ hasText: "AI 비용" })).toContainText(
"일별 비용 추이",
);
}
await expect(page.getByRole("heading", { name: "가용성" })).toBeVisible();
await expect(page.getByText("샘플 정상률")).toBeVisible();
expect(uptime.ok_ratio).toBeGreaterThanOrEqual(0);
@ -657,6 +720,69 @@ test.describe("admin route", () => {
}
});
test("manually links duplicate operation tickets", async ({ page }, testInfo) => {
await signInAsAdmin(page);
const slug = `${testInfo.project.name}.${testInfo.workerIndex}.${testInfo.retry}.${Date.now()}`;
const subject = `[E2E] 중복 운영 티켓 ${slug}`;
const createdTicketIds: string[] = [];
try {
for (let i = 0; i < 2; i += 1) {
const create = await page.request.post("/api/users/support-tickets", {
data: {
category: "voice_browser",
priority: "normal",
subject,
body: "E2E 중복 티켓 수동 연결 검증입니다. 실제 음성 장애가 아닙니다.",
source_path: "/__e2e__/learn/session/duplicate",
},
});
await expectResponseOk(create);
const created = (await create.json()) as { ticket_id: string };
createdTicketIds.push(created.ticket_id);
}
const tickets = await openAdminTickets(page);
const createdTickets = tickets.tickets.filter((ticket) => createdTicketIds.includes(ticket.ticket_id));
expect(createdTickets).toHaveLength(2);
expect(createdTickets.some((ticket) => (ticket.duplicate_count ?? 0) > 0)).toBeTruthy();
const linkableCard = page
.locator(".ad-ticket")
.filter({ hasText: subject })
.filter({ has: page.getByRole("button", { name: "연결" }) });
await expect(linkableCard).toBeVisible();
const patchPromise = page.waitForResponse(
(response) =>
response.request().method() === "PATCH" &&
createdTicketIds.some((ticketId) => response.url().endsWith(`/api/admin/tickets/${ticketId}`)),
);
await linkableCard.getByRole("button", { name: "연결" }).click();
const patchResponse = await patchPromise;
await expectResponseOk(patchResponse);
const updated = (await patchResponse.json()) as AdminSupportTicket;
expect(updated.parent_ticket_id).toBeTruthy();
await expect(page.locator(".ad-ticket").filter({ hasText: subject }).filter({ hasText: "중복 연결" })).toBeVisible();
await expectNoHorizontalOverflow(page);
} finally {
for (const ticketId of createdTicketIds) {
const cleanup = await page.request.patch(`/api/admin/tickets/${ticketId}`, {
data: {
status: "resolved",
parent_ticket_id: "",
resolution_note: "E2E cleanup",
},
});
if (!cleanup.ok()) {
console.warn(`E2E duplicate ticket cleanup failed: ${cleanup.status()} ${await cleanup.text()}`);
}
}
}
});
test("denies learner access to the admin API and UI", async ({ page }) => {
await signInAsLearner(page);

View file

@ -1,7 +1,13 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { expect, test, type Page } from "@playwright/test";
import { FILLED_REVIEW_SESSION_ID, routeFilledSessionReview } from "./session-review-fixture";
import {
EMPTY_REVIEW_SESSION_ID,
FILLED_REVIEW_SESSION_ID,
routeEmptySessionReview,
routeFilledSessionReview,
routePrepostMeasures,
} from "./session-review-fixture";
import {
completeOnboarding,
expectNoHorizontalOverflow,
@ -190,6 +196,81 @@ async function gateScreen(
}
}
async function expectEmptyReviewNoDeadThirdColumn(page: Page) {
const report = await page.evaluate(() => {
const root = document.querySelector<HTMLElement>(".sr-root--empty");
const cols = document.querySelector<HTMLElement>(".sr-root--empty .sr-cols");
const transcript = document.querySelector<HTMLElement>(".sr-card--transcript");
if (!root || !cols || !transcript) {
return {
present: false,
columnCount: 0,
};
}
const gridTemplate = getComputedStyle(cols).gridTemplateColumns;
const columnCount = gridTemplate.split(" ").filter(Boolean).length;
const transcriptRect = transcript.getBoundingClientRect();
return {
present: true,
columnCount,
transcriptWidth: Math.round(transcriptRect.width),
mainColumnWidth: Math.round(cols.getBoundingClientRect().width),
};
});
expect(report.present, "empty review layout should be mounted").toBe(true);
expect(
report.columnCount,
"empty review should collapse the desktop masonry layout instead of leaving a sparse third column",
).toBeLessThanOrEqual(2);
}
async function expectFilledReviewLearnerTwoColumn(page: Page) {
const report = await page.evaluate(() => {
const cols = document.querySelector<HTMLElement>(".sr-cols--learner");
const transcript = document.querySelector<HTMLElement>(".sr-card--transcript");
const overview = document.querySelector<HTMLElement>(".sr-overview");
const rubric = document.querySelector<HTMLElement>(".sr-card--rubric");
const worksheet = document.querySelector<HTMLElement>(".sr-card--worksheet");
if (!cols || !transcript || !overview || !rubric || !worksheet) {
return {
present: false,
viewportWidth: window.innerWidth,
columnCount: 0,
transcriptRight: 0,
overviewLeft: 0,
rubricLeft: 0,
worksheetLeft: 0,
};
}
const columnCount = getComputedStyle(cols).gridTemplateColumns.split(" ").filter(Boolean).length;
const transcriptRect = transcript.getBoundingClientRect();
const overviewRect = overview.getBoundingClientRect();
const rubricRect = rubric.getBoundingClientRect();
const worksheetRect = worksheet.getBoundingClientRect();
return {
present: true,
viewportWidth: window.innerWidth,
columnCount,
transcriptRight: Math.ceil(transcriptRect.right),
overviewLeft: Math.floor(overviewRect.left),
rubricLeft: Math.floor(rubricRect.left),
worksheetLeft: Math.floor(worksheetRect.left),
};
});
expect(report.present, "filled learner review layout should be mounted").toBe(true);
if (report.viewportWidth > 1180) {
expect(report.columnCount, "desktop learner review should use transcript + work column").toBe(2);
expect(report.transcriptRight).toBeLessThanOrEqual(report.overviewLeft);
expect(report.rubricLeft).toBeGreaterThanOrEqual(report.overviewLeft);
expect(report.worksheetLeft).toBeGreaterThanOrEqual(report.overviewLeft);
}
}
test.describe("layout visual gate @single-run", () => {
test.describe.configure({ mode: "serial" });
@ -257,10 +338,25 @@ test.describe("layout visual gate @single-run", () => {
test("session review stays contained across all widths", async ({ page }) => {
await signInAsLearner(page);
await routeFilledSessionReview(page);
await routePrepostMeasures(page);
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
await gateScreen(page, "session-review", async () => {
await expect(page.locator(".sr-overview")).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("사례개념화 워크시트")).toBeVisible();
await expectFilledReviewLearnerTwoColumn(page);
});
});
test("empty session review avoids sparse column gaps across all widths", async ({ page }) => {
await signInAsLearner(page);
await routeEmptySessionReview(page);
await routePrepostMeasures(page);
await page.goto(`/learn/session/${EMPTY_REVIEW_SESSION_ID}/review`);
await gateScreen(page, "session-review-empty", async () => {
await expect(page.locator(".sr-root--empty")).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("축어록 저장 후 생성")).toBeVisible();
await expect(page.getByText("감정 타임라인 대기")).toBeVisible();
await expectEmptyReviewNoDeadThirdColumn(page);
});
});

View file

@ -4,7 +4,19 @@ const sessionId = "33333333-3333-4333-8333-333333333333";
const learnerText = "요즘 많이 힘들었겠어요. 어떤 마음이 가장 크게 남아 있나요?";
const clientReply = "괜찮아요. 천천히 말해볼게요.";
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((next) => {
resolve = next;
});
return { promise, resolve };
}
async function routeMvpApi(page: Page) {
const sessionStartRequests: unknown[] = [];
const streamSeen = deferred();
const streamGate = deferred();
await page.route("**/api/auth/me", async (route) => {
await route.fulfill({
status: 200,
@ -49,6 +61,7 @@ async function routeMvpApi(page: Page) {
await route.fallback();
return;
}
sessionStartRequests.push(route.request().postDataJSON());
await route.fulfill({
status: 201,
contentType: "application/json",
@ -76,7 +89,7 @@ async function routeMvpApi(page: Page) {
session_no: 1,
status: "active",
stage: "라포",
theory_mode: "humanistic",
theory_mode: "cbt",
effective_openness: 0.21,
started_at: new Date().toISOString(),
ended_at: null,
@ -87,6 +100,8 @@ async function routeMvpApi(page: Page) {
});
await page.route(`**/api/sessions/${sessionId}/stream`, async (route) => {
streamSeen.resolve();
await streamGate.promise;
await route.fulfill({
status: 200,
contentType: "text/event-stream",
@ -176,6 +191,8 @@ async function routeMvpApi(page: Page) {
}),
});
});
return { sessionStartRequests, streamGate, streamSeen };
}
test.describe("P1 MVP core loop", () => {
@ -187,21 +204,32 @@ test.describe("P1 MVP core loop", () => {
page.on("console", (message) => {
if (message.type() === "error") diagnostics.push(`console: ${message.text()}`);
});
await routeMvpApi(page);
const api = await routeMvpApi(page);
await page.goto("/learn/session/P1");
await expect(
page.getByRole("button", { name: "회기 시작" }),
diagnostics.join("\n") || (await page.locator("#root").innerText().catch(() => "")),
).toBeVisible();
await page.getByRole("button", { name: /CBT/ }).click();
await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
expect(api.sessionStartRequests[0]).toMatchObject({
persona_code: "P1",
theory_mode: "cbt",
});
await page.getByLabel("학습자 발화 입력").fill(learnerText);
await page.getByRole("button", { name: "보내기" }).click();
await api.streamSeen.promise;
await expect(page.locator(".sx-utt").filter({ hasText: learnerText })).toBeVisible();
await expect(page.locator(".sx-utt.is-thinking").filter({ hasText: "답변을 준비 중입니다." })).toBeVisible();
await expect(page.getByLabel("학습자 발화 입력")).toBeDisabled();
api.streamGate.resolve();
await expect(page.locator(".sx-utt").filter({ hasText: clientReply })).toBeVisible();
await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled();
await page.getByRole("button", { name: "회기 종료" }).click();
await page.getByRole("button", { name: "종료하고 리뷰 보기" }).click();

View file

@ -1,7 +1,12 @@
import type { Page } from "@playwright/test";
import type { SessionReviewResponse } from "../src/lib/api";
import type {
SessionReviewResponse,
UserPrepostMeasureItem,
UserPrepostMeasuresResponse,
} from "../src/lib/api";
export const FILLED_REVIEW_SESSION_ID = "filled-review-visual";
export const EMPTY_REVIEW_SESSION_ID = "empty-review-visual";
export function filledReviewResponse(
sessionId: string = FILLED_REVIEW_SESSION_ID,
@ -67,7 +72,10 @@ export function filledReviewResponse(
{ kind: "reflect", label: "감정 반영" },
{ kind: "explore", label: "개방 질문" },
],
nonverbal: [{ kind: "pace", label: "말 속도", detail: "안정" }],
nonverbal: [
{ kind: "pace", label: "말 속도", detail: "안정" },
{ kind: "paralinguistic", label: "음성 단서", detail: "한숨 감지 · 신뢰도 82%" },
],
note: {
author: "AI 슈퍼바이저",
tone: "ai",
@ -239,3 +247,176 @@ export async function routeFilledSessionReview(
});
});
}
export function prepostMeasuresResponse(
measures: UserPrepostMeasureItem[] = [
{
measure_id: "prepost-self-efficacy-pre",
pilot_id: "phase3-pilot-draft",
measure_name: "self_efficacy",
timepoint: "pre",
raw_score: 3,
min_score: 1,
max_score: 5,
normalized_score: 0.5,
instrument_version: "pilot-prepost-scaffold-2026-06-28",
item_count: 1,
collected_at: 1782662400,
updated_at: 1782662400,
},
{
measure_id: "prepost-self-efficacy-post",
pilot_id: "phase3-pilot-draft",
measure_name: "self_efficacy",
timepoint: "post",
raw_score: 4,
min_score: 1,
max_score: 5,
normalized_score: 0.75,
instrument_version: "pilot-prepost-scaffold-2026-06-28",
item_count: 1,
collected_at: 1782662400,
updated_at: 1782662400,
},
],
): UserPrepostMeasuresResponse {
const completePairs = new Set<string>();
for (const measureName of ["self_efficacy", "skill_proficiency", "training_satisfaction"]) {
const hasPre = measures.some(
(item) => item.measure_name === measureName && item.timepoint === "pre",
);
const hasPost = measures.some(
(item) => item.measure_name === measureName && item.timepoint === "post",
);
if (hasPre && hasPost) completePairs.add(measureName);
}
return {
source: "database",
durable: true,
pilot_id: "phase3-pilot-draft",
required_measure_names: [
"self_efficacy",
"skill_proficiency",
"training_satisfaction",
],
required_timepoints: ["pre", "post"],
complete_measure_pairs: completePairs.size,
measures,
generated_at: 1782662400,
};
}
export async function routePrepostMeasures(page: Page) {
let measures = prepostMeasuresResponse().measures;
await page.route("**/api/users/me/prepost-measures**", async (route) => {
const request = route.request();
if (request.method() === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(prepostMeasuresResponse(measures)),
});
return;
}
if (request.method() === "PUT") {
const body = request.postDataJSON() as {
measure_name: UserPrepostMeasureItem["measure_name"];
timepoint: UserPrepostMeasureItem["timepoint"];
raw_score: number;
min_score?: number;
max_score?: number;
instrument_version?: string;
item_count?: number;
pilot_id?: string;
};
const now = 1782666000;
const next: UserPrepostMeasureItem = {
measure_id: `prepost-${body.measure_name}-${body.timepoint}`,
pilot_id: body.pilot_id ?? "phase3-pilot-draft",
measure_name: body.measure_name,
timepoint: body.timepoint,
raw_score: body.raw_score,
min_score: body.min_score ?? 1,
max_score: body.max_score ?? 5,
normalized_score:
(body.raw_score - (body.min_score ?? 1)) / ((body.max_score ?? 5) - (body.min_score ?? 1)),
instrument_version: body.instrument_version ?? "pilot-prepost-scaffold-2026-06-28",
item_count: body.item_count ?? 1,
collected_at: now,
updated_at: now,
};
measures = measures
.filter(
(item) =>
!(
item.measure_name === next.measure_name &&
item.timepoint === next.timepoint &&
item.instrument_version === next.instrument_version
),
)
.concat(next);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(next),
});
return;
}
await route.fallback();
});
}
export function emptyReviewResponse(
sessionId: string = EMPTY_REVIEW_SESSION_ID,
): SessionReviewResponse {
const response = filledReviewResponse(sessionId);
return {
...response,
date: "2026-06-28",
durationLabel: "0분",
durationSeconds: 0,
reachedPhase: "라포",
sessionSignal: "종료됨",
supervisorState: "평가 대기",
summary:
"축어록이 아직 저장되지 않아 평가 근거를 생성하지 않았습니다. 빈 상태에서는 임의의 강점이나 개선점을 표시하지 않습니다.",
phases: [],
phaseAxis: [],
valenceAxis: [],
clientValence: [],
counselorBaseline: [],
turns: [],
rubric: [],
goodMoments: [],
growthPoints: [],
caseWorksheet: {
status: "empty",
generatedBy: "rule-based transcript extractor",
savedAt: null,
sections: [],
limitations: ["축어록이 없어 사례개념화 워크시트를 생성하지 않았습니다."],
},
nextLine: null,
clientFeedback: null,
audioUrl: null,
pdfExportUrl: null,
degraded: false,
reviewReady: false,
};
}
export async function routeEmptySessionReview(
page: Page,
sessionId: string = EMPTY_REVIEW_SESSION_ID,
) {
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(emptyReviewResponse(sessionId)),
});
});
}

View file

@ -1,5 +1,9 @@
import { expect, test, type Page, type Response } from "@playwright/test";
import { FILLED_REVIEW_SESSION_ID, routeFilledSessionReview } from "./session-review-fixture";
import {
FILLED_REVIEW_SESSION_ID,
routeFilledSessionReview,
routePrepostMeasures,
} from "./session-review-fixture";
import { expectNoHorizontalOverflow, fetchAvailablePersona, signInAsLearner } from "./support";
interface SessionStartResponse {
@ -45,6 +49,7 @@ async function expectReviewRegionsReachable(page: Page) {
await expect(page.locator(".sr-card--flow")).toBeVisible();
await expect(page.locator(".sr-card--rubric")).toBeVisible();
await expect(page.locator(".sr-card--transcript")).toBeVisible();
await expect(page.locator(".sr-card--prepost")).toBeVisible();
const grid = await page.locator(".sr-cols").evaluate((el) => {
const style = window.getComputedStyle(el);
@ -64,19 +69,34 @@ async function expectReviewRegionsReachable(page: Page) {
await expect(page.locator(".sr-card--transcript")).toBeInViewport();
const railGeometry = await page.locator(".sr-cols").evaluate((gridEl) => {
const root = gridEl.closest(".sr-root");
const overview = gridEl.querySelector(".sr-overview")?.getBoundingClientRect();
const transcript = gridEl.querySelector(".sr-card--transcript")?.getBoundingClientRect();
const rubric = gridEl.querySelector(".sr-card--rubric")?.getBoundingClientRect();
const prepost = gridEl.querySelector(".sr-card--prepost")?.getBoundingClientRect();
const worksheet = gridEl.querySelector(".sr-card--worksheet")?.getBoundingClientRect();
const columns = window
.getComputedStyle(gridEl)
.gridTemplateColumns.split(" ")
.filter(Boolean).length;
return {
isEmpty: root?.classList.contains("sr-root--empty") ?? false,
columns,
overviewLeft: overview ? Math.floor(overview.left) : 0,
overviewRight: overview ? Math.ceil(overview.right) : 0,
transcriptLeft: transcript ? Math.floor(transcript.left) : 0,
transcriptRight: transcript ? Math.ceil(transcript.right) : 0,
rubricLeft: rubric ? Math.floor(rubric.left) : 0,
prepostLeft: prepost ? Math.floor(prepost.left) : 0,
worksheetLeft: worksheet ? Math.floor(worksheet.left) : 0,
};
});
if (railGeometry.columns >= 3) {
if (!railGeometry.isEmpty && railGeometry.columns === 2) {
expect(railGeometry.transcriptRight).toBeLessThanOrEqual(railGeometry.overviewLeft);
expect(railGeometry.rubricLeft).toBeGreaterThanOrEqual(railGeometry.overviewLeft);
expect(railGeometry.prepostLeft).toBeGreaterThanOrEqual(railGeometry.overviewLeft);
expect(railGeometry.worksheetLeft).toBeGreaterThanOrEqual(railGeometry.overviewLeft);
} else if (railGeometry.columns >= 3) {
expect(railGeometry.overviewRight).toBeLessThanOrEqual(railGeometry.transcriptLeft);
}
}
@ -84,6 +104,7 @@ async function expectReviewRegionsReachable(page: Page) {
test.describe("session review", () => {
test("renders server review data without legacy transcript fixtures", async ({ page }) => {
await signInAsLearner(page);
await routePrepostMeasures(page);
const sessionId = await createEndedSession(page);
const reviewResponsePromise = page.waitForResponse(isReviewResponse(sessionId));
@ -124,6 +145,7 @@ test.describe("session review", () => {
test("renders filled review evidence state without clipping", async ({ page }) => {
await signInAsLearner(page);
await routeFilledSessionReview(page);
await routePrepostMeasures(page);
const reviewResponsePromise = page.waitForResponse(isReviewResponse(FILLED_REVIEW_SESSION_ID));
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
@ -141,6 +163,24 @@ test.describe("session review", () => {
await expect(page.getByText("회기 흐름")).toBeVisible();
await expect(page.getByText("세션 트랜스크립트")).toBeVisible();
await expect(page.getByText("기법 사용 분포")).toBeVisible();
await expect(page.getByText("파일럿 증거 원장")).toBeVisible();
await expect(page.getByText("1/3 쌍")).toBeVisible();
const prepostInputs = page.locator(".sr-card--prepost input");
await expect(prepostInputs).toHaveCount(6);
await expect(prepostInputs.nth(0)).toHaveValue("3");
await expect(prepostInputs.nth(1)).toHaveValue("4");
const prepostLabels = await prepostInputs.evaluateAll((inputs) =>
inputs.map((input) => input.getAttribute("aria-label")),
);
expect(prepostLabels).toEqual([
"자기효능감 사전 점수",
"자기효능감 사후 점수",
"기술숙련도 사전 점수",
"기술숙련도 사후 점수",
"수련만족도 사전 점수",
"수련만족도 사후 점수",
]);
await expect(page.getByText("효과 판정과 통계 검정은 평가설계 확정 후 별도 산출합니다.")).toBeVisible();
await expect(page.getByText("사례개념화 워크시트")).toBeVisible();
await expect(page.getByText("감정 반영").first()).toBeVisible();
await expect(page.getByText("AI 슈퍼바이저").first()).toBeVisible();
@ -154,4 +194,27 @@ test.describe("session review", () => {
await expectReviewRegionsReachable(page);
await expectNoHorizontalOverflow(page);
});
test("stores learner pre/post pilot evidence without effect claims", async ({ page }) => {
await signInAsLearner(page);
await routeFilledSessionReview(page);
await routePrepostMeasures(page);
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
await expect(page.locator(".sr-card--prepost")).toBeVisible({ timeout: 15_000 });
const prepostInputs = page.locator(".sr-card--prepost input");
await expect(prepostInputs).toHaveCount(6);
await expect(page.getByText("1/3 쌍")).toBeVisible();
await expect(page.getByText("효과 판정과 통계 검정은 평가설계 확정 후 별도 산출합니다.")).toBeVisible();
await prepostInputs.nth(2).fill("2.5");
await prepostInputs.nth(3).fill("3.5");
await expect(prepostInputs.nth(2)).toHaveValue("2.5");
await expect(prepostInputs.nth(3)).toHaveValue("3.5");
await page.getByRole("button", { name: "점수 저장" }).click();
await expect(page.getByText("2/3 쌍")).toBeVisible();
await expect(page.locator(".sr-prepost__actions")).toContainText("저장된 값 기준");
});
});

View file

@ -29,6 +29,17 @@ interface UserPreferencesResponse {
};
}
interface UserSupportTicketsResponse {
source: "database";
durable: boolean;
tickets: Array<{
ticket_id: string;
status: string;
subject: string;
resolution_note: string;
}>;
}
interface AdminEngineConfigResponse {
engine_mode: string;
engine_url: string;
@ -214,6 +225,9 @@ async function expectNoSettingsControlClipping(page: Page) {
".vg-set__field",
".vg-set__control-block",
".vg-set__opt",
".vg-set__tickets",
".vg-set__ticket",
".vg-set__ticket-note",
".vg-set__voice",
".vg-set__range-row",
".vg-set__seg",
@ -405,27 +419,39 @@ async function openSettings(page: Page, options: { admin?: boolean } = {}) {
const voicePresetsPromise = page.waitForResponse(
isApiResponse("GET", "/users/me/voice-presets"),
);
const supportTicketsPromise = page.waitForResponse(
isApiResponse("GET", "/users/support-tickets"),
);
const engineConfigPromise = options.admin
? page.waitForResponse(isApiResponse("GET", "/admin/engine-config"))
: null;
await page.goto("/settings");
const [profileResponse, preferencesResponse, voicePresetsResponse, engineConfigResponse] =
const [
profileResponse,
preferencesResponse,
voicePresetsResponse,
supportTicketsResponse,
engineConfigResponse,
] =
await Promise.all([
profilePromise,
preferencesPromise,
voicePresetsPromise,
supportTicketsPromise,
engineConfigPromise,
]);
await expectResponseOk(profileResponse);
await expectResponseOk(preferencesResponse);
await expectResponseOk(voicePresetsResponse);
await expectResponseOk(supportTicketsResponse);
if (engineConfigResponse) await expectResponseOk(engineConfigResponse);
const profile = (await profileResponse.json()) as UserProfileResponse;
const preferences = (await preferencesResponse.json()) as UserPreferencesResponse;
const supportTickets = (await supportTicketsResponse.json()) as UserSupportTicketsResponse;
const engineConfig = engineConfigResponse
? ((await engineConfigResponse.json()) as AdminEngineConfigResponse)
: null;
@ -433,7 +459,7 @@ async function openSettings(page: Page, options: { admin?: boolean } = {}) {
await expect(page).toHaveURL(/\/settings$/);
await expect(page.locator(".vg-set")).toBeVisible();
return { profile, preferences, engineConfig };
return { profile, preferences, supportTickets, engineConfig };
}
test.describe("settings page", () => {
@ -580,6 +606,55 @@ test.describe("settings page", () => {
expect(preferences.notifications.product_news).toBe(nextProductNews);
});
test("learner sees only own support ticket status and resolution note", async ({
page,
}, testInfo) => {
const learnerName = `Settings Ticket Learner ${testInfo.project.name}`;
const learnerEmail = await signInAs(page, "learner", testInfo, learnerName);
const subject = `설정 티켓 상태 ${slugFor(testInfo)}`;
const hiddenBody = `설정 화면에는 보이면 안 되는 문의 본문 ${slugFor(testInfo)}`;
const create = await page.request.post("/api/users/support-tickets", {
data: {
category: "session_review",
priority: "high",
subject,
body: hiddenBody,
source_path: "/settings",
},
});
await expectResponseOk(create);
const created = (await create.json()) as { ticket_id: string };
await signInAs(
page,
"admin",
testInfo,
`Settings Ticket Admin ${testInfo.project.name}`,
`settings.ticket.admin.${slugFor(testInfo)}@twentyoz.kr`,
);
const resolutionNote = `재처리 완료 ${slugFor(testInfo)}`;
const patch = await page.request.patch(`/api/admin/tickets/${created.ticket_id}`, {
data: {
status: "resolved",
assigned_group: "서비스 운영자",
resolution_note: resolutionNote,
},
});
await expectResponseOk(patch);
await signInAs(page, "learner", testInfo, learnerName, learnerEmail);
const { supportTickets } = await openSettings(page);
expect(supportTickets.tickets.some((ticket) => ticket.ticket_id === created.ticket_id)).toBe(
true,
);
const support = page.locator("#set-support");
await expect(support).toContainText(subject);
await expect(support).toContainText("해결");
await expect(support).toContainText(resolutionNote);
await expect(support).not.toContainText(hiddenBody);
});
test("admin sees and updates the AI engine settings panel @single-run", async ({ page }, testInfo) => {
const displayName = `Settings Admin ${testInfo.project.name}`;
const email = await signInAs(page, "admin", testInfo, displayName);