안전 이벤트 대시보드 보강

This commit is contained in:
Yun Chan 2026-07-01 12:34:46 +09:00
parent fe2796f05a
commit 51053af536
15 changed files with 1081 additions and 423 deletions

View file

@ -559,7 +559,28 @@ test.describe("layout visual gate @single-run", () => {
});
});
test("professor student analysis stays contained across all widths", async ({ page }) => {
test("professor student analysis overview stays contained across all widths", async ({ page }) => {
const fixture = teacherAnalysisFixture();
await page.route("**/api/teacher/dashboard", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(fixture.dashboard),
}),
);
await signInAsTeacher(page);
await page.goto("/teach/analysis");
await gateScreen(page, "professor-analysis", async () => {
await expect(page.locator('[data-learner-overview-table="true"]')).toBeVisible({
timeout: 15_000,
});
await expect(page.locator('[data-learner-overview-row="true"]')).toHaveCount(1);
await expect(page.locator('[data-learner-analysis-panel="true"]')).toHaveCount(0);
await expect(page.getByLabel("학습자 검색")).toBeVisible();
});
});
test("professor learner detail analysis stays contained across all widths", async ({ page }) => {
const fixture = teacherAnalysisFixture();
await page.route("**/api/teacher/dashboard", (route) =>
route.fulfill({
@ -576,12 +597,12 @@ test.describe("layout visual gate @single-run", () => {
}),
);
await signInAsTeacher(page);
await page.goto("/teach/analysis");
await gateScreen(page, "professor-analysis", async () => {
await expect(page.locator(".pf-analysis-shell")).toBeVisible({ timeout: 15_000 });
await page.goto(`/teach/analysis?learner=${fixture.analysis.learner_id}`);
await gateScreen(page, "professor-analysis-detail", async () => {
await expect(page.locator('[data-learner-analysis-panel="true"]')).toBeVisible({
timeout: 15_000,
});
await page.getByRole("tab", { name: /^전체 회기/ }).click();
await expect(page.locator('[data-learner-session-row="true"]')).toHaveCount(5);
});
});

View file

@ -11,6 +11,17 @@ interface HealthResponse {
engine?: boolean;
}
interface TeacherSafetyAlert {
session_id: string;
resource_title: string;
resource_number: string;
}
interface TeacherDashboardResponse {
source: string;
safety_alerts: TeacherSafetyAlert[];
}
interface ReviewTurn {
speaker: string;
text: string;
@ -492,6 +503,72 @@ test.describe("session persistence", () => {
).toBe(true);
});
test("persists crisis safety event into DB-backed teacher dashboard @single-run", async ({
page,
}) => {
test.setTimeout(120_000);
const healthResponse = await page.request.get("/api/health");
await expectResponseOk(healthResponse);
const health = (await healthResponse.json()) as HealthResponse;
test.skip(!health.db, "crisis safety queue persistence requires DB");
await signInAsLearner(page);
const persona = await fetchAvailablePersona(page, 1);
const crisisText = "저 지금 자살하고 싶어요. 도와주세요.";
await page.goto(`/learn/session/${persona.code}`);
await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
await expect(page).toHaveURL(/\/learn\/session\/[0-9a-f-]+$/i);
const sessionId = new URL(page.url()).pathname.split("/").at(-1);
expect(sessionId).toMatch(/^[0-9a-f-]+$/i);
const streamResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return (
response.request().method() === "POST" &&
url.pathname.endsWith(`/api/sessions/${sessionId}/stream`)
);
});
const input = page.getByLabel("학습자 발화 입력");
await input.fill(crisisText);
await page.getByRole("button", { name: "보내기" }).click();
const streamResponse = await streamResponsePromise;
await expectResponseOk(streamResponse);
await expect(page.locator(".sx-crisis-resource")).toContainText("자살예방상담전화 109", {
timeout: 15_000,
});
await expect(page.getByRole("link", { name: "109" })).toBeVisible();
await expect(input).toBeDisabled();
await signInAsTeacher(page);
await expect
.poll(
async () => {
const dashboardResponse = await page.request.get("/api/teacher/dashboard");
await expectResponseOk(dashboardResponse);
const dashboard = (await dashboardResponse.json()) as TeacherDashboardResponse;
expect(dashboard.source).toBe("database");
return dashboard.safety_alerts.some(
(alert) => alert.session_id === sessionId && alert.resource_number === "109",
);
},
{
timeout: 30_000,
intervals: [500, 1_000, 2_000],
message: "crisis safety event should reach the DB-backed teacher dashboard",
},
)
.toBe(true);
await page.goto("/teach");
await expect(page.getByText("109 안전 확인 큐")).toBeVisible({ timeout: 15_000 });
await expect(page.locator(".pf-alert").filter({ hasText: sessionId ?? "" })).toContainText("109");
});
test("persists AI tutor coaching history through reload @single-run", async ({ page }) => {
test.setTimeout(150_000);

View file

@ -640,16 +640,33 @@ test.describe("teacher console", () => {
await page.getByRole("link", { name: "학생 분석" }).click();
await expect(page).toHaveURL(/\/teach\/analysis$/);
const overviewTable = page.locator('[data-learner-overview-table="true"]');
await expect(overviewTable).toBeVisible();
await expect(page.locator('[data-learner-overview-row="true"]')).toHaveCount(2);
await expect(page.locator('[data-learner-analysis-panel="true"]')).toHaveCount(0);
await page.getByLabel("학습자 검색").fill("사용자 B");
const learnerBRow = page
.locator('[data-learner-overview-row="true"]')
.filter({ hasText: "사용자 B" });
await expect(learnerBRow).toHaveCount(1);
await expect(learnerBRow.getByText(learnerB)).toBeVisible();
await learnerBRow.getByRole("button", { name: "사용자 B 상세 분석 열기" }).click();
await expect(page).toHaveURL(new RegExp(`/teach/analysis\\?learner=${learnerB}$`));
const analysisPanel = page.locator('[data-learner-analysis-panel="true"]');
await expect(analysisPanel).toBeVisible();
await expect(analysisPanel.locator('[data-learner-session-row="true"]')).toHaveCount(7);
await expect(analysisPanel.getByText(`${learnerA}-session-7`)).toBeVisible();
await expect(page.getByText("전체 회기 7건")).toBeVisible();
await page.getByRole("button", { name: "사용자 B 사용자별 분석" }).click();
await expect(analysisPanel.locator('[data-learner-session-row="true"]')).toHaveCount(0);
await analysisPanel.getByRole("tab", { name: /^전체 회기/ }).click();
await expect(analysisPanel.locator('[data-learner-session-row="true"]')).toHaveCount(2);
await expect(analysisPanel.getByText(`${learnerB}-session-2`)).toBeVisible();
await expect(analysisPanel.getByText(`${learnerA}-session-7`)).toHaveCount(0);
await expect(page.getByText("전체 회기 2건")).toBeVisible();
await page.getByRole("button", { name: "전체 목록" }).click();
await expect(page).toHaveURL(/\/teach\/analysis$/);
await expect(page.locator('[data-learner-overview-table="true"]')).toBeVisible();
await expect(page.locator('[data-learner-overview-row="true"]')).toHaveCount(1);
await expectNoHorizontalOverflow(page);
});