관리자 증거와 계정 흐름을 정돈
This commit is contained in:
parent
21bbe3f98d
commit
34aff65cb0
9 changed files with 1100 additions and 193 deletions
|
|
@ -45,6 +45,15 @@ interface AdminUsersResponse {
|
|||
users: AdminManagedUser[];
|
||||
}
|
||||
|
||||
type UsageCostBasis =
|
||||
| "provider_estimate"
|
||||
| "provider_reported"
|
||||
| "reference_rate"
|
||||
| "reference_upper_bound"
|
||||
| "partial"
|
||||
| "partial_upper_bound"
|
||||
| "unavailable";
|
||||
|
||||
interface AdminUsageBreakdown {
|
||||
provider: string;
|
||||
model: string;
|
||||
|
|
@ -54,6 +63,11 @@ interface AdminUsageBreakdown {
|
|||
tokens_in: number;
|
||||
tokens_out: number;
|
||||
cost_usd: number;
|
||||
recorded_cost_usd?: number;
|
||||
estimated_cost_usd?: number;
|
||||
cost_basis?: UsageCostBasis;
|
||||
rate_label?: string | null;
|
||||
rate_source_url?: string | null;
|
||||
}
|
||||
|
||||
interface AdminUsageDailyCost {
|
||||
|
|
@ -62,13 +76,15 @@ interface AdminUsageDailyCost {
|
|||
tokens_in: number;
|
||||
tokens_out: number;
|
||||
cost_usd: number;
|
||||
cost_basis?: UsageCostBasis;
|
||||
}
|
||||
|
||||
interface AdminUsageBudget {
|
||||
limit_usd: number;
|
||||
used_ratio: number;
|
||||
remaining_usd: number | null;
|
||||
status: "disabled" | "ok" | "warn" | "exceeded";
|
||||
status: "disabled" | "ok" | "warn" | "exceeded" | "indeterminate";
|
||||
cost_basis?: UsageCostBasis;
|
||||
}
|
||||
|
||||
interface AdminUsageEvaluatorCache {
|
||||
|
|
@ -94,6 +110,9 @@ interface AdminUsageResponse {
|
|||
tokens_in: number;
|
||||
tokens_out: number;
|
||||
cost_usd: number;
|
||||
recorded_cost_usd?: number;
|
||||
estimated_cost_usd?: number;
|
||||
cost_basis?: UsageCostBasis;
|
||||
budget: AdminUsageBudget;
|
||||
evaluator_cache?: AdminUsageEvaluatorCache;
|
||||
by_provider: AdminUsageBreakdown[];
|
||||
|
|
@ -927,20 +946,22 @@ test.describe("admin route guards", () => {
|
|||
durable: true,
|
||||
generated_at: 1_783_990_800,
|
||||
window_days: 30,
|
||||
total_turns: 37,
|
||||
metered_turns: 37,
|
||||
token_metered_turns: 35,
|
||||
total_turns: 53,
|
||||
metered_turns: 53,
|
||||
token_metered_turns: 51,
|
||||
token_unmetered_turns: 2,
|
||||
tokens_in: 160_703,
|
||||
tokens_out: 19_629,
|
||||
cost_usd: 7.023222,
|
||||
tokens_in: 240_950,
|
||||
tokens_out: 22_907,
|
||||
cost_usd: 7.064689,
|
||||
recorded_cost_usd: 6.9612,
|
||||
estimated_cost_usd: 0.062022,
|
||||
estimated_cost_usd: 0.103489,
|
||||
cost_basis: "reference_upper_bound",
|
||||
budget: {
|
||||
limit_usd: 20,
|
||||
used_ratio: 0.3512,
|
||||
remaining_usd: 12.976778,
|
||||
used_ratio: 0.3532,
|
||||
remaining_usd: 12.935311,
|
||||
status: "ok",
|
||||
cost_basis: "reference_upper_bound",
|
||||
},
|
||||
evaluator_cache: {
|
||||
enabled: true,
|
||||
|
|
@ -981,24 +1002,45 @@ test.describe("admin route guards", () => {
|
|||
},
|
||||
{
|
||||
provider: "agy_cli",
|
||||
model: "gemini-3.6-flash-high",
|
||||
turns: 5,
|
||||
token_metered_turns: 5,
|
||||
model: "gemini-3.7-flash-high",
|
||||
turns: 21,
|
||||
token_metered_turns: 21,
|
||||
token_unmetered_turns: 0,
|
||||
tokens_in: 35_703,
|
||||
tokens_out: 1_129,
|
||||
cost_usd: 0.062022,
|
||||
tokens_in: 115_950,
|
||||
tokens_out: 4_407,
|
||||
cost_usd: 0.103489,
|
||||
recorded_cost_usd: 0,
|
||||
estimated_cost_usd: 0.062022,
|
||||
cost_basis: "reference_rate",
|
||||
estimated_cost_usd: 0.103489,
|
||||
cost_basis: "reference_upper_bound",
|
||||
rate_label:
|
||||
"Google Gemini 3.6 Flash 표준 단가 · 입력 $1.50/M · 캐시 $0.15/M · 출력 $7.50/M",
|
||||
"Google Gemini 3.7 Flash 프로모션 표준 단가(2026-12-31까지) · 입력 $0.75/M · 캐시 $0.075/M · 출력 $3.75/M",
|
||||
},
|
||||
],
|
||||
daily_cost: [
|
||||
{ day: "2026-07-13", turns: 8, tokens_in: 32_000, tokens_out: 4_800, cost_usd: 1.42 },
|
||||
{ day: "2026-07-14", turns: 10, tokens_in: 41_000, tokens_out: 6_100, cost_usd: 2.08 },
|
||||
{ day: "2026-07-15", turns: 17, tokens_in: 87_703, tokens_out: 8_729, cost_usd: 3.183222 },
|
||||
{
|
||||
day: "2026-07-13",
|
||||
turns: 8,
|
||||
tokens_in: 32_000,
|
||||
tokens_out: 4_800,
|
||||
cost_usd: 1.42,
|
||||
cost_basis: "provider_reported",
|
||||
},
|
||||
{
|
||||
day: "2026-07-14",
|
||||
turns: 10,
|
||||
tokens_in: 41_000,
|
||||
tokens_out: 6_100,
|
||||
cost_usd: 2.08,
|
||||
cost_basis: "provider_reported",
|
||||
},
|
||||
{
|
||||
day: "2026-07-15",
|
||||
turns: 35,
|
||||
tokens_in: 167_950,
|
||||
tokens_out: 12_007,
|
||||
cost_usd: 3.564689,
|
||||
cost_basis: "reference_upper_bound",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
@ -1014,17 +1056,21 @@ test.describe("admin route guards", () => {
|
|||
);
|
||||
await expect(page.getByText("운영 DB 원장").first()).toBeVisible();
|
||||
// 2026-07-27 D7: 합계 금액은 화면에 소수 2자리로 표시하고 원본 정밀도는 title 로 옮겼다.
|
||||
await expect(page.locator(".aic-ledger")).toContainText("$7.02");
|
||||
await expect(page.locator(".aic-ledger b").first()).toHaveAttribute("title", /7\.023222/);
|
||||
await expect(page.locator(".aic-budget")).toContainText("94.6%");
|
||||
await expect(page.locator(".aic-ledger")).toContainText("≤$7.06");
|
||||
await expect(page.locator(".aic-ledger b").first()).toHaveAttribute("title", /≤\$7\.064689/);
|
||||
await expect(page.locator(".aic-budget")).toContainText("잔여 ≥$12.94 · ≤35.3% 사용");
|
||||
await expect(page.locator(".aic-table")).toContainText("gpt-5-mini");
|
||||
await expect(page.locator(".aic-table")).toContainText("gemini-3.6-flash-high");
|
||||
await expect(page.locator(".aic-table")).toContainText("참조단가");
|
||||
await expect(page.locator(".aic-table")).toContainText("gemini-3.7-flash-high");
|
||||
await expect(page.locator(".aic-table")).toContainText("참조 상한");
|
||||
await expect(page.locator(".aic-table")).toContainText("≤$0.10");
|
||||
await expect(page.locator(".aic-table")).toContainText("≤$0.0049");
|
||||
await expect(page.locator(".aic-table")).toContainText("SDK 추정");
|
||||
await expect(page.locator(".aic-table")).toContainText("미계량");
|
||||
await expect(page.locator(".aic-table")).toContainText("$0.06");
|
||||
await expect(page.locator(".aic-table")).toContainText("0.9%");
|
||||
await expect(page.locator(".aic-ledger")).toContainText("기록 $6.96 · 참조 $0.06");
|
||||
await expect(page.locator(".aic-table")).toContainText("$0.10");
|
||||
await expect(page.locator(".aic-table")).toContainText("$0.0049");
|
||||
await expect(page.locator(".aic-table tbody tr").first().locator("td").nth(6)).toHaveText("—");
|
||||
await expect(page.locator(".aic-ledger")).toContainText("기록 $6.96 · 참조 ≤$0.10 · 참조 상한");
|
||||
await expect(page.locator(".aic-chart__day").last()).toContainText("≤$3.56");
|
||||
await expect(page.locator(".aic-cache-score")).toContainText("80%");
|
||||
await expect(page.getByLabel("AI 기본 모델")).toHaveValue("gateway-default");
|
||||
await expect(page.getByLabel("AI 엔진 공급자").locator("option")).toHaveCount(6);
|
||||
|
|
@ -1072,6 +1118,95 @@ test.describe("admin route guards", () => {
|
|||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
|
||||
test("shows partial AI cost without inventing a per-call average", async ({ page }) => {
|
||||
await mockAdminSession(
|
||||
page,
|
||||
{
|
||||
user_id: "partial-cost-admin",
|
||||
email: "partial-cost-admin@twentyoz.kr",
|
||||
display_name: "Partial Cost Admin",
|
||||
role: "admin",
|
||||
admin_access: true,
|
||||
super_admin: true,
|
||||
onboarding_completed_at: 1_782_900_000,
|
||||
},
|
||||
{
|
||||
adminUsage: {
|
||||
source: "database",
|
||||
durable: true,
|
||||
generated_at: 1_783_990_800,
|
||||
window_days: 30,
|
||||
total_turns: 2,
|
||||
metered_turns: 2,
|
||||
token_metered_turns: 2,
|
||||
token_unmetered_turns: 0,
|
||||
tokens_in: 2_000_000,
|
||||
tokens_out: 2_000_000,
|
||||
cost_usd: 4.5,
|
||||
recorded_cost_usd: 0,
|
||||
estimated_cost_usd: 4.5,
|
||||
cost_basis: "partial",
|
||||
budget: {
|
||||
limit_usd: 20,
|
||||
used_ratio: 0.225,
|
||||
remaining_usd: 15.5,
|
||||
status: "indeterminate",
|
||||
cost_basis: "partial",
|
||||
},
|
||||
evaluator_cache: {
|
||||
enabled: false,
|
||||
entries: 0,
|
||||
hits: 0,
|
||||
misses: 0,
|
||||
stores: 0,
|
||||
evictions: 0,
|
||||
requests: 0,
|
||||
hit_rate: 0,
|
||||
},
|
||||
by_provider: [
|
||||
{
|
||||
provider: "agy_cli",
|
||||
model: "gemini-3.7-flash-high",
|
||||
turns: 2,
|
||||
token_metered_turns: 2,
|
||||
token_unmetered_turns: 0,
|
||||
tokens_in: 2_000_000,
|
||||
tokens_out: 2_000_000,
|
||||
cost_usd: 4.5,
|
||||
recorded_cost_usd: 0,
|
||||
estimated_cost_usd: 4.5,
|
||||
cost_basis: "partial",
|
||||
rate_label: "일부 호출 미산정 · 표시액은 산정 가능분 합계",
|
||||
},
|
||||
],
|
||||
daily_cost: [
|
||||
{
|
||||
day: "2026-08-13",
|
||||
turns: 2,
|
||||
tokens_in: 2_000_000,
|
||||
tokens_out: 2_000_000,
|
||||
cost_usd: 4.5,
|
||||
cost_basis: "partial",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto("/admin/ai");
|
||||
|
||||
const row = page.locator(".aic-table tbody tr").filter({ hasText: "gemini-3.7-flash-high" });
|
||||
await expect(row).toContainText("$4.50+");
|
||||
await expect(row).toContainText("일부 산정");
|
||||
await expect(row.locator("td").nth(5)).toHaveText("—");
|
||||
await expect(row.locator("td").nth(6)).toHaveText("—");
|
||||
await expect(page.locator(".aic-ledger b").first()).toHaveText("$4.50+");
|
||||
await expect(page.locator(".aic-ledger article").last().locator("b")).toHaveText("—");
|
||||
await expect(page.locator(".aic-budget")).toContainText("예산 미확정");
|
||||
await expect(page.locator(".aic-budget")).toContainText("잔여 ≤$15.50 · ≥22.5% 사용");
|
||||
await expect(page.locator(".aic-chart__value")).toHaveText("$4.50+");
|
||||
});
|
||||
|
||||
test("keeps the boot diagnostic overlay off a rendered AI operations page", async ({ page }) => {
|
||||
await mockAdminSession(page, {
|
||||
user_id: "ai-watchdog-admin",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,24 @@ const RELEASE_GATE_ID = "10000000-0000-4000-8000-000000000002";
|
|||
const INCIDENT_ID = "10000000-0000-4000-8000-000000000003";
|
||||
const CREATED_AT = "2026-08-06T08:30:00Z";
|
||||
|
||||
type ApprovalDecision =
|
||||
| "approve_content"
|
||||
| "approve_promotion"
|
||||
| "authorize_rollback"
|
||||
| "keep_quarantine"
|
||||
| "reject";
|
||||
|
||||
type ApprovalBody = {
|
||||
submission_id: string;
|
||||
approval_event_id: string;
|
||||
effect_record_id: string;
|
||||
target_kind: "content_qualification" | "model_change_gate" | "release_gate";
|
||||
target_id: string;
|
||||
decision: ApprovalDecision;
|
||||
reason_code: string;
|
||||
evidence_refs: string[];
|
||||
};
|
||||
|
||||
function artifact(
|
||||
recordId: string,
|
||||
ownerKind: "model_change_gate" | "release_gate",
|
||||
|
|
@ -210,18 +228,18 @@ async function installMock(page: Page, options: MockOptions = {}) {
|
|||
}
|
||||
|
||||
if (request.method() === "POST" && path.endsWith("/continuous-improvement/approvals")) {
|
||||
const body = request.postDataJSON() as Record<string, string>;
|
||||
const body = request.postDataJSON() as ApprovalBody;
|
||||
approvalBodies.push(body);
|
||||
view.approvals.push({
|
||||
approval_event_id: body.approval_event_id,
|
||||
target_kind: body.target_kind as "content_qualification" | "model_change_gate",
|
||||
target_kind: body.target_kind,
|
||||
target_id: body.target_id,
|
||||
decision: body.decision as "approve_content" | "approve_promotion",
|
||||
decision: body.decision,
|
||||
reason_code: body.reason_code,
|
||||
evidence_refs: body.evidence_refs as unknown as string[],
|
||||
evidence_refs: body.evidence_refs,
|
||||
created_at: CREATED_AT,
|
||||
});
|
||||
if (body.target_kind === "content_qualification") {
|
||||
if (body.target_kind === "content_qualification" && body.decision === "approve_content") {
|
||||
view.catalog_entries.unshift({
|
||||
catalog_record_id: body.effect_record_id,
|
||||
qualification_id: body.target_id,
|
||||
|
|
@ -230,14 +248,17 @@ async function installMock(page: Page, options: MockOptions = {}) {
|
|||
clinical_claim_allowed: false,
|
||||
created_at: CREATED_AT,
|
||||
});
|
||||
} else {
|
||||
} else if (
|
||||
body.decision === "approve_promotion" ||
|
||||
body.decision === "authorize_rollback"
|
||||
) {
|
||||
view.lifecycle_events.unshift({
|
||||
lifecycle_event_id: body.effect_record_id,
|
||||
target_kind: body.target_kind as "model_change_gate",
|
||||
target_kind: body.target_kind as "model_change_gate" | "release_gate",
|
||||
target_id: body.target_id,
|
||||
event_type: "promotion",
|
||||
event_status: "approved",
|
||||
evidence_refs: body.evidence_refs as unknown as string[],
|
||||
event_type: body.decision === "authorize_rollback" ? "rollback" : "promotion",
|
||||
event_status: body.decision === "authorize_rollback" ? "requested" : "approved",
|
||||
evidence_refs: body.evidence_refs,
|
||||
created_at: CREATED_AT,
|
||||
});
|
||||
}
|
||||
|
|
@ -287,11 +308,13 @@ test.describe("continuous improvement admin cockpit", () => {
|
|||
await contentCandidate.getByText("검수 payload 펼쳐 보기").click();
|
||||
await expect(contentCandidate.getByText("영향을 방어하지 않고 인정한 뒤")).toBeVisible();
|
||||
await expect(contentCandidate).not.toContainText("hidden_answer");
|
||||
const contentReason = contentCandidate.getByLabel("콘텐츠 승인 사유");
|
||||
await expect(contentCandidate.getByRole("button", { name: "카탈로그 승인" })).toBeDisabled();
|
||||
const contentReason = contentCandidate.getByLabel("콘텐츠 결정 사유");
|
||||
await expect(contentCandidate.getByRole("button", { name: "결정 선택 필요" })).toBeDisabled();
|
||||
await contentCandidate.getByRole("radio", { name: "승인", exact: true }).check();
|
||||
await expect(contentCandidate.getByRole("button", { name: "승인 기록" })).toBeDisabled();
|
||||
await contentReason.fill("합성 경계와 수련 목표를 직접 검수함");
|
||||
await contentReason.press("Enter");
|
||||
await expect(contentCandidate.getByText("카탈로그 승인 원장 기록됨")).toBeVisible();
|
||||
await expect(contentCandidate.getByText("콘텐츠 승인 원장 기록됨")).toBeVisible();
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
|
||||
await page.getByTestId("continuous-improvement-cockpit").screenshot({
|
||||
path: testInfo.outputPath("g8-continuous-improvement-before-approval.png"),
|
||||
|
|
@ -299,23 +322,36 @@ test.describe("continuous improvement admin cockpit", () => {
|
|||
});
|
||||
|
||||
const missingGate = page.locator(`[data-gate-id="${RELEASE_GATE_ID}"]`);
|
||||
await expect(missingGate.getByRole("button", { name: "증거 4종 미완료" })).toBeDisabled();
|
||||
await expect(missingGate.getByText("필수 증거 4종이 모두 있어야 기록 가능")).toBeVisible();
|
||||
await expect(missingGate.getByRole("radio", { name: "승인", exact: true })).toBeDisabled();
|
||||
await expect(missingGate.getByRole("radio", { name: "보류", exact: true })).toBeEnabled();
|
||||
await expect(missingGate.getByRole("radio", { name: "반려", exact: true })).toBeEnabled();
|
||||
await expect(missingGate.getByText("필수 증거 4종 미완료로 승인 차단")).toBeVisible();
|
||||
await missingGate.screenshot({
|
||||
path: testInfo.outputPath("g8-human-decision-approval-blocked.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
|
||||
const modelGate = page.locator(`[data-gate-id="${MODEL_GATE_ID}"]`);
|
||||
const approvalButton = modelGate.getByRole("button", { name: "사람 승인 기록" });
|
||||
const approvalReason = modelGate.getByLabel("사람 승인 사유");
|
||||
const approvalReason = modelGate.getByLabel("사람 결정 사유");
|
||||
await expect(modelGate.getByRole("button", { name: "결정 선택 필요" })).toBeDisabled();
|
||||
await expect(modelGate.getByText("승인·보류·반려 중 하나를 명시적으로 선택해야 함")).toBeVisible();
|
||||
await modelGate.getByRole("radio", { name: "승인", exact: true }).check();
|
||||
const approvalButton = modelGate.getByRole("button", { name: "승인 기록" });
|
||||
await expect(approvalButton).toBeDisabled();
|
||||
await expect(modelGate.getByText("승인 사유를 먼저 입력해야 함")).toBeVisible();
|
||||
await approvalReason.fill("기준선과 rollback runbook을 독립 검토함");
|
||||
await expect(approvalButton).toBeEnabled();
|
||||
await expect(modelGate.getByText("증거 4종과 승인 사유가 준비됨")).toBeVisible();
|
||||
await expect(modelGate.getByText("승인 결정과 사유를 append-only 원장에 기록할 준비됨")).toBeVisible();
|
||||
await modelGate.screenshot({
|
||||
path: testInfo.outputPath("g8-human-decision-approval-ready.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
await approvalButton.focus();
|
||||
await expect(approvalButton).toBeFocused();
|
||||
await approvalReason.focus();
|
||||
await approvalReason.press("Enter");
|
||||
|
||||
await expect(modelGate.getByText("append-only 승인 원장 기록됨")).toBeVisible();
|
||||
await expect(modelGate.getByText("승격 승인 원장 기록됨")).toBeVisible();
|
||||
expect(mock.approvalBodies).toHaveLength(2);
|
||||
expect(mock.approvalBodies[0]).toMatchObject({
|
||||
target_kind: "content_qualification",
|
||||
|
|
@ -332,6 +368,10 @@ test.describe("continuous improvement admin cockpit", () => {
|
|||
expect(mock.approvalBodies[1].evidence_refs).toHaveLength(4);
|
||||
|
||||
await expectNoHorizontalOverflow(page);
|
||||
// index.html watchdog은 3.5초와 8초에 관리자 표면의 canonical marker를 확인한다.
|
||||
// 정상 렌더된 지속 개선 콕핏을 dev asset 이름 때문에 진단 화면으로 오인하면 안 된다.
|
||||
await page.waitForTimeout(8_600);
|
||||
await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("g8-continuous-improvement-cockpit.png"),
|
||||
fullPage: true,
|
||||
|
|
@ -339,6 +379,60 @@ test.describe("continuous improvement admin cockpit", () => {
|
|||
});
|
||||
});
|
||||
|
||||
test("blocks only unmet approval and records hold, reject and approval as explicit decisions", async ({
|
||||
page,
|
||||
}) => {
|
||||
const mock = await installMock(page);
|
||||
await page.goto("/admin/continuous-improvement");
|
||||
|
||||
const releaseGate = page.locator(`[data-gate-id="${RELEASE_GATE_ID}"]`);
|
||||
const releaseApprove = releaseGate.getByRole("radio", { name: "승인", exact: true });
|
||||
await expect(releaseApprove).toBeDisabled();
|
||||
await expect(releaseGate.getByText("필수 증거 4종 미완료로 승인 차단")).toBeVisible();
|
||||
await expect(releaseGate.getByRole("radio", { name: "보류", exact: true })).toBeEnabled();
|
||||
await expect(releaseGate.getByRole("radio", { name: "반려", exact: true })).toBeEnabled();
|
||||
|
||||
await releaseGate.getByRole("radio", { name: "보류", exact: true }).check();
|
||||
await releaseGate.getByLabel("사람 결정 사유").fill("rollback 증거 보강 뒤 다시 검토");
|
||||
await releaseGate.getByRole("button", { name: "보류 기록" }).click();
|
||||
await expect(releaseGate.getByText("보류 원장 기록됨")).toBeVisible();
|
||||
|
||||
const contentCandidate = page.locator(
|
||||
'[data-qualification-id="20000000-0000-4000-8000-000000000001"]',
|
||||
);
|
||||
await contentCandidate.getByRole("radio", { name: "반려", exact: true }).check();
|
||||
await contentCandidate.getByLabel("콘텐츠 결정 사유").fill("검수 기준을 충족하지 못한 후보");
|
||||
await contentCandidate.getByRole("button", { name: "반려 기록" }).click();
|
||||
await expect(contentCandidate.getByText("반려 원장 기록됨")).toBeVisible();
|
||||
|
||||
const modelGate = page.locator(`[data-gate-id="${MODEL_GATE_ID}"]`);
|
||||
await modelGate.getByRole("radio", { name: "승인", exact: true }).check();
|
||||
await modelGate.getByLabel("사람 결정 사유").fill("독립 증거 4종과 승격 조건을 확인함");
|
||||
await modelGate.getByRole("button", { name: "승인 기록" }).click();
|
||||
await expect(modelGate.getByText("승격 승인 원장 기록됨")).toBeVisible();
|
||||
|
||||
expect(mock.approvalBodies).toHaveLength(3);
|
||||
expect(mock.approvalBodies[0]).toMatchObject({
|
||||
target_kind: "release_gate",
|
||||
target_id: RELEASE_GATE_ID,
|
||||
decision: "keep_quarantine",
|
||||
reason_code: "rollback 증거 보강 뒤 다시 검토",
|
||||
});
|
||||
expect(mock.approvalBodies[1]).toMatchObject({
|
||||
target_kind: "content_qualification",
|
||||
target_id: "20000000-0000-4000-8000-000000000001",
|
||||
decision: "reject",
|
||||
reason_code: "검수 기준을 충족하지 못한 후보",
|
||||
});
|
||||
expect(mock.approvalBodies[2]).toMatchObject({
|
||||
target_kind: "model_change_gate",
|
||||
target_id: MODEL_GATE_ID,
|
||||
decision: "approve_promotion",
|
||||
reason_code: "독립 증거 4종과 승격 조건을 확인함",
|
||||
});
|
||||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
|
||||
test("shows empty and degraded states without opening an approval action", async ({ page }) => {
|
||||
await installMock(page, { empty: true });
|
||||
await page.goto("/admin/continuous-improvement");
|
||||
|
|
|
|||
|
|
@ -3,6 +3,15 @@
|
|||
// route fixture로 상태를 고정해 검증한다. 실제 AI 엔진 턴 생성은 하지 않는다.
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
type UsageCostBasis =
|
||||
| "provider_estimate"
|
||||
| "provider_reported"
|
||||
| "reference_rate"
|
||||
| "reference_upper_bound"
|
||||
| "partial"
|
||||
| "partial_upper_bound"
|
||||
| "unavailable";
|
||||
|
||||
interface AdminUsageBreakdown {
|
||||
provider: string;
|
||||
model: string;
|
||||
|
|
@ -14,7 +23,7 @@ interface AdminUsageBreakdown {
|
|||
estimated_cost_usd: number;
|
||||
token_metered_turns: number;
|
||||
token_unmetered_turns: number;
|
||||
cost_basis: "provider_estimate" | "provider_reported" | "reference_rate" | "unavailable";
|
||||
cost_basis: UsageCostBasis;
|
||||
rate_label?: string | null;
|
||||
rate_source_url?: string | null;
|
||||
}
|
||||
|
|
@ -25,6 +34,7 @@ interface AdminUsageDailyCost {
|
|||
tokens_in: number;
|
||||
tokens_out: number;
|
||||
cost_usd: number;
|
||||
cost_basis?: UsageCostBasis;
|
||||
}
|
||||
|
||||
interface AdminUsageEvaluatorCache {
|
||||
|
|
@ -52,11 +62,13 @@ interface AdminUsageResponse {
|
|||
cost_usd: number;
|
||||
recorded_cost_usd: number;
|
||||
estimated_cost_usd: number;
|
||||
cost_basis?: UsageCostBasis;
|
||||
budget: {
|
||||
limit_usd: number;
|
||||
used_ratio: number;
|
||||
remaining_usd: number | null;
|
||||
status: "disabled" | "ok" | "warn" | "exceeded";
|
||||
status: "disabled" | "ok" | "warn" | "exceeded" | "indeterminate";
|
||||
cost_basis?: UsageCostBasis;
|
||||
};
|
||||
evaluator_cache?: AdminUsageEvaluatorCache;
|
||||
by_provider: AdminUsageBreakdown[];
|
||||
|
|
@ -90,10 +102,17 @@ function usageFixture(
|
|||
tokens_in: 0,
|
||||
tokens_out: 0,
|
||||
cost_usd: 0,
|
||||
cost_basis: "provider_reported",
|
||||
// 실 API는 cost_usd = recorded + estimated 로 집계한다. 기본은 전액 기록값으로 둔다.
|
||||
recorded_cost_usd: overrides.cost_usd ?? 0,
|
||||
estimated_cost_usd: 0,
|
||||
budget: { limit_usd: 0, used_ratio: 0, remaining_usd: null, status: "disabled" },
|
||||
budget: {
|
||||
limit_usd: 0,
|
||||
used_ratio: 0,
|
||||
remaining_usd: null,
|
||||
status: "disabled",
|
||||
cost_basis: "provider_reported",
|
||||
},
|
||||
evaluator_cache: {
|
||||
enabled: false,
|
||||
entries: 0,
|
||||
|
|
@ -482,7 +501,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.42",
|
||||
"2026-07-13 $1.42 · Provider 보고",
|
||||
);
|
||||
await expect(chart.locator(".aic-chart__day").first()).toContainText("8턴");
|
||||
|
||||
|
|
@ -521,6 +540,70 @@ test.describe("full sweep: admin ai operations", () => {
|
|||
await expect(page.locator(".aic-table")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("does not present a partial upper bound as an exact cost", async ({ page }) => {
|
||||
const state = createMockState({
|
||||
usageForWindow: (days) =>
|
||||
usageFixture(days, {
|
||||
total_turns: 2,
|
||||
metered_turns: 2,
|
||||
token_metered_turns: 2,
|
||||
tokens_in: 2_000_000,
|
||||
tokens_out: 2_000_000,
|
||||
cost_usd: 4.5,
|
||||
recorded_cost_usd: 0,
|
||||
estimated_cost_usd: 4.5,
|
||||
cost_basis: "partial_upper_bound",
|
||||
budget: {
|
||||
limit_usd: 20,
|
||||
used_ratio: 0.225,
|
||||
remaining_usd: null,
|
||||
status: "indeterminate",
|
||||
cost_basis: "partial_upper_bound",
|
||||
},
|
||||
by_provider: [
|
||||
{
|
||||
provider: "agy_cli",
|
||||
model: "gemini-3.7-flash-high",
|
||||
turns: 2,
|
||||
token_metered_turns: 2,
|
||||
token_unmetered_turns: 0,
|
||||
tokens_in: 2_000_000,
|
||||
tokens_out: 2_000_000,
|
||||
cost_usd: 4.5,
|
||||
recorded_cost_usd: 0,
|
||||
estimated_cost_usd: 4.5,
|
||||
cost_basis: "partial_upper_bound",
|
||||
rate_label: "일부 호출 미산정 · 산정된 부분도 상한 추정",
|
||||
},
|
||||
],
|
||||
daily_cost: [
|
||||
{
|
||||
day: "2026-08-13",
|
||||
turns: 2,
|
||||
tokens_in: 2_000_000,
|
||||
tokens_out: 2_000_000,
|
||||
cost_usd: 4.5,
|
||||
cost_basis: "partial_upper_bound",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
await mockAdminAiSession(page, state);
|
||||
|
||||
await page.goto("/admin/ai");
|
||||
|
||||
await expect(page.locator(".aic-ledger b").first()).toHaveText("미산정");
|
||||
await expect(page.locator(".aic-ledger article").last().locator("b")).toHaveText("—");
|
||||
await expect(page.locator(".aic-budget")).toContainText("예산 미확정");
|
||||
await expect(page.locator(".aic-budget")).toContainText("잔여 미산정 · 사용률 미산정");
|
||||
await expect(page.locator(".aic-chart__value")).toHaveText("미산정");
|
||||
const row = page.locator(".aic-table tbody tr");
|
||||
await expect(row.locator("td").nth(4)).toContainText("미산정");
|
||||
await expect(row.locator("td").nth(4)).toContainText("일부 상한");
|
||||
await expect(row.locator("td").nth(5)).toHaveText("—");
|
||||
await expect(row.locator("td").nth(6)).toHaveText("—");
|
||||
});
|
||||
|
||||
// checklist: admin-ai-engine-config-meta
|
||||
test("shows engine config metadata for durable DB and runtime-only sources", async ({ page }) => {
|
||||
const state = createMockState({
|
||||
|
|
|
|||
|
|
@ -217,6 +217,38 @@ test.describe("uc auth-onboarding", () => {
|
|||
await expect(submit).toBeDisabled();
|
||||
});
|
||||
|
||||
// usecase: 다른 Google 계정을 잘못 선택한 사용자가 현재 이메일을 확인하고 로그인 화면으로 빠져나간다
|
||||
test("온보딩 최상단에서 현재 Google 계정을 확인하고 다른 계정으로 로그인한다", async ({ page }) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeMe(page, () =>
|
||||
makeMe({
|
||||
email: "yunchan8804@gmail.com",
|
||||
onboarding_completed_at: null,
|
||||
nickname: "",
|
||||
}),
|
||||
);
|
||||
await routeOnboardingData(page);
|
||||
await routeAuthConfig(page);
|
||||
|
||||
let logoutRequested = false;
|
||||
await page.route("**/api/auth/logout", (route) => {
|
||||
logoutRequested = route.request().method() === "POST";
|
||||
return route.fulfill(jsonRoute({ ok: true }));
|
||||
});
|
||||
|
||||
await page.goto("/onboarding");
|
||||
|
||||
const account = page.getByLabel("현재 로그인 계정");
|
||||
await expect(account).toBeVisible();
|
||||
await expect(account.getByText("현재 Google 계정", { exact: true })).toBeVisible();
|
||||
await expect(account.getByText("yunchan8804@gmail.com", { exact: true })).toBeVisible();
|
||||
|
||||
await account.getByRole("button", { name: "다른 계정으로 로그인" }).click();
|
||||
await page.waitForURL(/\/login(?:$|[/?#])/);
|
||||
expect(logoutRequested).toBe(true);
|
||||
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible();
|
||||
});
|
||||
|
||||
// usecase: 신규 사용자가 입력을 마쳤지만 약관·개인정보 동의를 빠뜨린 채 제출하려 한다
|
||||
test("온보딩 필수 동의를 모두 체크해야 제출 버튼이 활성화된다", async ({ page }) => {
|
||||
await setupFreshLearnerOnOnboarding(page);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue