관리자 증거와 계정 흐름을 정돈
This commit is contained in:
parent
21bbe3f98d
commit
34aff65cb0
9 changed files with 1100 additions and 193 deletions
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue