feat: 운영 안정성과 세션 음성 경험 개선

This commit is contained in:
Yun Chan 2026-07-31 00:13:08 +09:00
parent facc4ad2d9
commit c788343467
95 changed files with 8431 additions and 1785 deletions

View file

@ -56,6 +56,7 @@ interface AdminEngineConfigResponse {
engine_mode: string;
engine_url: string;
model: string;
reasoning_effort: string | null;
source: "database" | "runtime_cache" | "runtime_default";
durable: boolean;
updated_by: string | null;
@ -100,6 +101,7 @@ function engineConfigFixture(
engine_mode: "openai",
engine_url: "http://127.0.0.1:9099",
model: "gateway-default",
reasoning_effort: "medium",
source: "database",
durable: true,
updated_by: "admin@twentyoz.kr",
@ -109,7 +111,7 @@ function engineConfigFixture(
}
interface AdminAiMockState {
counts: { usage: number; health: number; engine: number };
counts: { usage: number; health: number; engine: number; capabilities: number };
authUser: {
account_status?: "pending" | "approved" | "suspended";
onboarding_completed_at?: number | null;
@ -124,7 +126,7 @@ interface AdminAiMockState {
function createMockState(overrides: Partial<AdminAiMockState> = {}): AdminAiMockState {
return {
counts: { usage: 0, health: 0, engine: 0 },
counts: { usage: 0, health: 0, engine: 0, capabilities: 0 },
authUser: {},
usageForWindow: (days) => usageFixture(days),
engineConfig: engineConfigFixture(),
@ -198,6 +200,31 @@ async function mockAdminAiSession(page: Page, state: AdminAiMockState) {
return;
}
if (method === "GET" && path.endsWith("/admin/engine-capabilities")) {
state.counts.capabilities += 1;
const provider = url.searchParams.get("engine_mode") ?? state.engineConfig.engine_mode;
await fulfillJson({
provider,
available: true,
source: "live_cli",
models: [
{
id: "gateway-default",
label: "게이트웨이 기본 모델",
description: "테스트 기본 모델",
reasoning_efforts: ["medium"],
default_reasoning_effort: "medium",
is_default: true,
},
],
default_model: "gateway-default",
default_reasoning_effort: "medium",
detail: "테스트 모델 목록",
fetched_at: 1_785_142_800,
});
return;
}
if (method === "GET" && path.endsWith("/admin/engine-config")) {
state.counts.engine += 1;
if (state.engineConfigStatus) {
@ -235,11 +262,11 @@ test.describe("full sweep: admin ai operations", () => {
).toBeVisible();
await expect(page.locator('[data-testid="admin-ai-page"]')).toHaveCount(0);
// 승인 대기 계정은 관리자 데이터 API를 호출하지 않아야 한다.
expect(state.counts).toEqual({ usage: 0, health: 0, engine: 0 });
expect(state.counts).toEqual({ usage: 0, health: 0, engine: 0, capabilities: 0 });
});
// checklist: admin-ai-aria-busy, admin-ai-refresh-button
test("marks the page aria-busy while loading and reloads all three data sets on refresh", async ({
test("marks the page aria-busy while loading and reloads all operations data on refresh", async ({
page,
}) => {
let gate: ReturnType<typeof deferred> | null = deferred();
@ -251,7 +278,7 @@ test.describe("full sweep: admin ai operations", () => {
await page.goto("/admin/ai");
const container = page.locator('[data-testid="admin-ai-page"]');
const refreshButton = page.getByRole("button", { name: "새로고침" });
const refreshButton = page.getByRole("button", { name: "새로고침", exact: true });
// usage 응답이 보류된 동안 aria-busy=true, 새로고침 버튼 비활성.
await expect(container).toHaveAttribute("aria-busy", "true");
@ -278,7 +305,12 @@ test.describe("full sweep: admin ai operations", () => {
await expect(refreshButton).toBeEnabled();
await expect
.poll(() => state.counts)
.toEqual({ usage: base.usage + 1, health: base.health + 1, engine: base.engine + 1 });
.toEqual({
usage: base.usage + 1,
health: base.health + 1,
engine: base.engine + 1,
capabilities: base.capabilities + 1,
});
});
// checklist: admin-ai-error-alert, admin-ai-engine-loading-empty
@ -302,7 +334,7 @@ test.describe("full sweep: admin ai operations", () => {
state.engineConfigStatus = undefined;
let gate: ReturnType<typeof deferred> | null = deferred();
state.usageGate = () => gate?.promise;
await page.getByRole("button", { name: "새로고침" }).click();
await page.getByRole("button", { name: "새로고침", exact: true }).click();
await expect(alert).toHaveCount(0);
gate.resolve();
@ -331,7 +363,7 @@ test.describe("full sweep: admin ai operations", () => {
await page.goto("/admin/ai");
const ledger = page.locator(".aic-ledger");
await expect(ledger).toContainText("$3.3300");
await expect(ledger).toContainText("$3.33");
// React dev StrictMode가 mount 효과를 이중 실행해 usage가 1~2회일 수 있어 기준값을 캡처한다.
const base = { ...state.counts };
expect(base.health).toBe(1);
@ -348,19 +380,20 @@ test.describe("full sweep: admin ai operations", () => {
);
await page.getByRole("button", { name: "7일" }).click();
await expect(page.getByRole("button", { name: "7일" })).toHaveAttribute("aria-pressed", "true");
await expect(ledger).toContainText("$1.1100");
await expect(ledger).toContainText("$1.11");
await expect(ledger).toContainText("7일 DB 집계");
// 늦게 도착한 90일 응답은 active 플래그로 무시되어야 한다.
slowWindow.resolve();
await staleResponse;
await expect(ledger).toContainText("$1.1100");
await expect(ledger).not.toContainText("$9.9900");
await expect(ledger).toContainText("$1.11");
await expect(ledger).not.toContainText("$9.99");
// 기간 변경은 usage만 재조회한다(health·engine-config는 초기 1회 그대로).
await expect.poll(() => state.counts.usage).toBe(base.usage + 2);
expect(state.counts.health).toBe(1);
expect(state.counts.engine).toBe(1);
expect(state.counts.capabilities).toBe(1);
});
// checklist: admin-ai-coverage-track, admin-ai-daily-chart, admin-ai-cache-panel
@ -423,7 +456,7 @@ test.describe("full sweep: admin ai operations", () => {
await expect(chart.locator(".aic-chart__day")).toHaveCount(3);
await expect(chart.locator(".aic-chart__day").first()).toHaveAttribute(
"title",
"2026-07-13 $1.4200",
"2026-07-13 $1.42",
);
await expect(chart.locator(".aic-chart__day").first()).toContainText("8턴");
@ -492,7 +525,7 @@ test.describe("full sweep: admin ai operations", () => {
updated_by: null,
updated_at: null,
});
await page.getByRole("button", { name: "새로고침" }).click();
await page.getByRole("button", { name: "새로고침", exact: true }).click();
await expect(rowValue("저장 원천")).toHaveText("런타임 적용");
await expect(rowValue("현재 소스")).toHaveText("runtime_default");
await expect(rowValue("최근 변경")).toHaveText("기록 없음");