관리자 증거와 계정 흐름을 정돈

This commit is contained in:
Yun Chan 2026-08-30 00:01:17 +09:00
parent 21bbe3f98d
commit 34aff65cb0
9 changed files with 1100 additions and 193 deletions

View file

@ -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",

View file

@ -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");

View file

@ -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({

View file

@ -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);