운영 화면과 회기 리뷰 UI 갱신
This commit is contained in:
parent
e7ebb38177
commit
3a9f70a97b
18 changed files with 2210 additions and 137 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("저장된 값 기준");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue