Просмотр исходного кода

test(s8): seed watch mapping e2e with test rule

YY968XX 1 месяц назад
Родитель
Сommit
0c684a6263
1 измененных файлов с 270 добавлено и 157 удалено
  1. 270 157
      Web/tests/e2e/s8/g01-watch-mapping.spec.ts

+ 270 - 157
Web/tests/e2e/s8/g01-watch-mapping.spec.ts

@@ -1,41 +1,74 @@
 import { test, expect } from '../fixtures/auth';
 import { STORAGE_STATE_PATH } from '../fixtures/auth';
-import { request as playwrightRequest } from '@playwright/test';
+import { request as playwrightRequest, type APIRequestContext } from '@playwright/test';
 import type { Page } from '@playwright/test';
 import fs from 'node:fs';
 
 /**
- * G-01 非破坏性补强(S2-EV 安全夹具 + S2-EX cleanup 接入):
- *  M1 多次 run-once 的 hit 集合稳定(sourceRuleId / relatedObjectCode / skipped 一致)
- *  M2 命中对象映射闭环:每条 skipped hit 必然对应一条仍未闭环的现存异常
+ * G-01 watch mapping e2e(S2-EY 路径 C 落地):
+ *  M1 多次 run-once 的 TEST_G09_ hit 集合稳定(sourceRuleId / relatedObjectCode 一致)
+ *  M2 命中对象映射闭环:每条 skipped TEST_G09_ hit 必然对应一条仍未闭环的现存异常
  *  M3 现存异常须与命中对象(sourceRuleId + relatedObjectCode)双键一致
  *
- * 安全契约(C1-C5 由 fixture 强制;C6 由 S2-EW cleanup endpoint 提供兜底):
- *  C1 spec 不闭单、不改数据、不调动作类接口。
- *  C2 spec 进入 run-once 前先快照 AUTO_WATCH 未闭环异常计数;
- *     若 baseline 为 0(即 dedup 目标缺失),spec 主动 test.skip(),避免任何写入。
- *  C3 任意一条 hit 出现 created=true 即立即 hard fail,并指明"无清理路径"。
- *  C4 spec 末尾再次快照计数;若 finalCount > baselineCount 立即 hard fail。
+ * 安全契约(C1-C6):
+ *  C1 spec 不闭单、不改业务数据、不调动作类接口;不新建/修改 alert_rule。
+ *  C2 spec 自带 seed 路径:beforeAll 创建一条仅命中 TEST_G09_ 对象的临时 watch rule
+ *     (literal SELECT 常量),与现有 EQ-01 业务规则共享同一 alert rule。
+ *  C3 任何 RelatedObjectCode 不以 TEST_G09_ 开头的 hit 出现 created=true 立即 hard fail
+ *     (表示业务规则被本测试污染)。TEST_G09_ hit 第 1 轮允许 created=true,
+ *     第 2/3 轮必须 skipped=true 且 matchedExceptionId 稳定。
+ *  C4 spec body 仅对 TEST_G09_ hit 做 M1/M2/M3 严格断言;EQ-01 等业务 hit 仅作漂移监控。
  *  C5 双键强制断言(5656a88d 起 DTO 已暴露 sourceRuleId/relatedObjectCode);不允许 fallback。
- *  C6 test.afterAll 调用 DELETE /api/aidop/s8/watch-debug/test-cleanup 兜底软删任何
- *     RelatedObjectCode 以 TEST_G09_ 开头的异常;cleanup 失败立即 hard fail,不吞错。
- *
- * 当前 RUN STATUS:BLOCKED-by-test-seed-gap。
- *  原因:现有 watch rule (id=1) 的 RelatedObjectCode 由 AdoS8DataSource 配置的 SQL
- *  查询结果决定('EQ-01',业务设备数据),后端没有 API 注入 TEST_G09_* 设备行;
- *  无法构造一条只命中 TEST_G09_* 对象的 watch rule。在出现安全 seed 路径前,
- *  即便接入 C6 cleanup,spec 仍会因 baseline 检查(C2)skip 或 fail,**不应当 live 跑**。
- *  C6 兜底依然有价值:未来任何 TEST_G09_ 残留(人工种入或后续 seed 路径上线)会被 afterAll 清理。
- *
- * RUN_ID 仅用于日志诊断,不写入任何 DB 字段(无 seed 路径前禁止主动写入)。
+ *  C6 afterAll:DELETE 临时 watch rule(物理删,无 IsDeleted) → DELETE
+ *     /api/aidop/s8/watch-debug/test-cleanup 软删本轮 TEST_G09_ exception。
+ *     两步任一失败立即 hard fail;finally 顺序保证 cleanup 总会尝试执行。
  */
 
 const PENDING = ['NEW', 'ASSIGNED', 'IN_PROGRESS', 'PENDING_VERIFICATION'];
 const RUNS = 3;
-const AUTO_WATCH_SOURCE = 'AUTO_WATCH';
-const RUN_ID = `TEST_G09_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+const TENANT_ID = 1;
+const FACTORY_ID = 1;
+const SCENE_CODE = 'S2S6_PRODUCTION';
+const DATA_SOURCE_ID = 1; // 复用现有 G01_TEST_DS(aidopdev 自指)
+const TEST_PREFIX = 'TEST_G09_';
+const RUN_ID = `${TEST_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+const TEST_RULE_CODE = `TEST_G09_RUN_${RUN_ID.slice(TEST_PREFIX.length)}`;
+const TEST_OBJECT_CODE = `TEST_G09_OBJ_${RUN_ID.slice(TEST_PREFIX.length)}`;
+
+let testRuleId: number | null = null;
+let createdSceneId: number | null = null; // 仅当 beforeAll 自己创建了 S2S6_PRODUCTION scene_config 时记录,afterAll 才删
+
+function tokenFromStorageState(): string {
+  if (!fs.existsSync(STORAGE_STATE_PATH)) {
+    throw new Error(`storage-state.json not found at ${STORAGE_STATE_PATH}; global-setup must run first.`);
+  }
+  const raw = fs.readFileSync(STORAGE_STATE_PATH, 'utf8');
+  const state = JSON.parse(raw) as { origins?: Array<{ localStorage?: Array<{ name: string; value: string }> }> };
+  for (const origin of state.origins ?? []) {
+    for (const item of origin.localStorage ?? []) {
+      if (/access-token$/i.test(item.name) && !/x-access-token$/i.test(item.name)) {
+        try {
+          const parsed = JSON.parse(item.value);
+          return typeof parsed === 'string' ? parsed : (parsed?.value ?? item.value);
+        } catch {
+          return item.value;
+        }
+      }
+    }
+  }
+  throw new Error('access-token not found in storage-state.json');
+}
+
+function makeApiContext(): Promise<APIRequestContext> {
+  const baseURL = (process.env.AIDOP_E2E_BASE_URL ?? 'http://localhost:8888').replace(/\/+$/, '');
+  const token = tokenFromStorageState();
+  return playwrightRequest.newContext({
+    baseURL,
+    extraHTTPHeaders: { Authorization: `Bearer ${token}` },
+  });
+}
 
-async function token(page: Page) {
+async function pageToken(page: Page): Promise<string> {
   return page.evaluate(() => {
     for (let i = 0; i < localStorage.length; i++) {
       const k = localStorage.key(i)!;
@@ -49,203 +82,283 @@ async function token(page: Page) {
         }
       }
     }
-    return null;
+    return null as unknown as string;
   });
 }
 
 async function postRunOnce(page: Page) {
-  const t = await token(page);
+  const t = await pageToken(page);
   const base = new URL(page.url()).origin;
   const r = await page.request.post(
-    `${base}/api/aidop/s8/watch-debug/run-once?tenantId=1&factoryId=1`,
-    { headers: { Authorization: `Bearer ${t!}` } },
+    `${base}/api/aidop/s8/watch-debug/run-once?tenantId=${TENANT_ID}&factoryId=${FACTORY_ID}`,
+    { headers: { Authorization: `Bearer ${t}` } },
   );
   expect(r.status()).toBe(200);
   return (await r.json()) as { count: number; results: any[] };
 }
 
-/**
- * 从 storage-state.json 读取 superAdmin access-token,用于不依赖 Page 的 hooks(afterAll)。
- * storage-state.json 由 global-setup 在测试运行前生成;不存在则视为 setup 未跑,hook 主动 throw。
- */
-function tokenFromStorageState(): string {
-  if (!fs.existsSync(STORAGE_STATE_PATH)) {
-    throw new Error(`storage-state.json not found at ${STORAGE_STATE_PATH}; global-setup must run first.`);
-  }
-  const raw = fs.readFileSync(STORAGE_STATE_PATH, 'utf8');
-  const state = JSON.parse(raw) as { origins?: Array<{ localStorage?: Array<{ name: string; value: string }> }> };
-  for (const origin of state.origins ?? []) {
-    for (const item of origin.localStorage ?? []) {
-      if (/access-token$/i.test(item.name) && !/x-access-token$/i.test(item.name)) {
-        try {
-          const parsed = JSON.parse(item.value);
-          return typeof parsed === 'string' ? parsed : (parsed?.value ?? item.value);
-        } catch {
-          return item.value;
-        }
-      }
-    }
-  }
-  throw new Error('access-token not found in storage-state.json');
-}
-
 async function getException(page: Page, id: string | number) {
-  const t = await token(page);
+  const t = await pageToken(page);
   const base = new URL(page.url()).origin;
   const r = await page.request.get(
-    `${base}/api/aidop/s8/exceptions/${id}?tenantId=1&factoryId=1`,
-    { headers: { Authorization: `Bearer ${t!}` } },
+    `${base}/api/aidop/s8/exceptions/${id}?tenantId=${TENANT_ID}&factoryId=${FACTORY_ID}`,
+    { headers: { Authorization: `Bearer ${t}` } },
   );
   if (!r.ok()) return null;
   const j = (await r.json()) as any;
   return j?.data ?? j;
 }
 
-/**
- * 统计当前 AUTO_WATCH 来源、未闭环状态的异常数量(含 (sourceRuleId, relatedObjectCode) 集合)。
- * 用于 fixture 的安全契约 C2/C4:进入 run-once 前后做基线对照。
- */
-async function snapshotAutoWatchPending(page: Page): Promise<{ count: number; keys: string[] }> {
-  const t = await token(page);
-  const base = new URL(page.url()).origin;
-  const keys: string[] = [];
-  let count = 0;
-  // 分页保护:最多扫 5 页(500 条),dev 库 AUTO_WATCH 数量很小,足够。
+/** 列出当前 TEST_G09_ 前缀的未闭环异常;用于 C2 baseline 与 finalCount。 */
+async function listTestG09Pending(ctxOrPage: APIRequestContext | Page): Promise<any[]> {
+  const items: any[] = [];
   for (let pageNo = 1; pageNo <= 5; pageNo++) {
-    const r = await page.request.get(
-      `${base}/api/aidop/s8/exceptions?tenantId=1&factoryId=1&statusBucket=pending&page=${pageNo}&pageSize=100`,
-      { headers: { Authorization: `Bearer ${t!}` } },
-    );
+    const url = `/api/aidop/s8/exceptions?tenantId=${TENANT_ID}&factoryId=${FACTORY_ID}&statusBucket=pending&relatedObjectCode=&page=${pageNo}&pageSize=100`;
+    const r = 'request' in ctxOrPage
+      ? await ctxOrPage.request.get(url)
+      : await ctxOrPage.get(url);
     if (!r.ok()) break;
     const body = (await r.json()) as any;
     const list: any[] = body?.list ?? body?.data?.list ?? [];
     if (list.length === 0) break;
     for (const item of list) {
-      if (String(item.sourceType ?? item.SourceType ?? '') !== AUTO_WATCH_SOURCE) continue;
-      count += 1;
-      keys.push(`${item.sourceRuleId ?? ''}|${item.relatedObjectCode ?? ''}|${item.id}`);
+      const code = String(item.relatedObjectCode ?? item.RelatedObjectCode ?? '');
+      if (code.startsWith(TEST_PREFIX)) items.push(item);
     }
     if (list.length < 100) break;
   }
-  return { count, keys };
+  return items;
 }
 
-test('S8-G01-MAPPING 多轮 run-once + 去重映射闭环', async ({ authedPage }) => {
-  await authedPage.goto('/#/aidop/s8/exceptions', { waitUntil: 'domcontentloaded' });
+async function callCleanupEndpoint(ctx: APIRequestContext): Promise<number> {
+  const r = await ctx.delete(`/api/aidop/s8/watch-debug/test-cleanup?tenantId=${TENANT_ID}&factoryId=${FACTORY_ID}`);
+  expect(r.status(), `cleanup status=${r.status()}`).toBe(200);
+  const body = (await r.json()) as { deletedCount?: number };
+  expect(typeof body.deletedCount === 'number' && body.deletedCount >= 0, `bad deletedCount: ${JSON.stringify(body)}`).toBeTruthy();
+  return body.deletedCount as number;
+}
+
+/**
+ * S8WatchRuleService.ValidateAsync 要求 scene_config 中存在匹配 (tenantId, factoryId, sceneCode) 行;
+ * dev 库 ado_s8_scene_config 默认无 'S2S6_PRODUCTION' 行(现有规则 id=1 是 SeedData 直插绕过)。
+ * 此处 idempotent ensure:列表中找不到则创建;返回 createdId(若是本次创建)以便 afterAll 撤销。
+ */
+/**
+ * 后端 InsertAsync 不回填 IDENTITY,响应里的 id 总为 "0";改为 POST 后回查 list 拿真实 id。
+ * 业务键:sceneCode + (tenantId, factoryId) 唯一。
+ */
+async function fetchSceneIdByCode(ctx: APIRequestContext, sceneCode: string): Promise<number | null> {
+  const list = await ctx.get(`/api/aidop/s8/config/scenes?tenantId=${TENANT_ID}&factoryId=${FACTORY_ID}`);
+  expect(list.status(), `list-scenes status=${list.status()}`).toBe(200);
+  const body = (await list.json()) as any;
+  const items: any[] = Array.isArray(body) ? body : (body?.data ?? body?.list ?? []);
+  const found = items.find((s) => String(s.sceneCode ?? s.SceneCode) === sceneCode);
+  if (!found) return null;
+  const id = Number(found.id ?? found.Id);
+  return Number.isFinite(id) && id > 0 ? id : null;
+}
 
-  // C2 安全契约:进入 run-once 前先取基线。无 pending dedup 目标即跳过,避免 run-once 写入。
-  const baseline = await snapshotAutoWatchPending(authedPage);
-  test.info().annotations.push({
-    type: 'fixture-baseline',
-    description: `runId=${RUN_ID} baselineCount=${baseline.count} keys=[${baseline.keys.join(',')}]`,
+async function ensureSceneConfig(ctx: APIRequestContext): Promise<number | null> {
+  const existingId = await fetchSceneIdByCode(ctx, SCENE_CODE);
+  if (existingId !== null) return null;
+  const r = await ctx.post('/api/aidop/s8/config/scenes', {
+    data: {
+      tenantId: TENANT_ID,
+      factoryId: FACTORY_ID,
+      sceneCode: SCENE_CODE,
+      sceneName: 'TEST G09 临时场景(spec 自建)',
+      enabled: true,
+      sortNo: 9999,
+    },
   });
-  test.skip(
-    baseline.count === 0,
-    `[${RUN_ID}] AUTO_WATCH pending baseline=0;当前后端无 hard-delete 测试接口,run-once 会创建无清理路径的异常行,主动跳过。`,
-  );
+  expect(r.status(), `create-scene status=${r.status()} body=${await r.text()}`).toBe(200);
+  const newId = await fetchSceneIdByCode(ctx, SCENE_CODE);
+  expect(newId, `scene id not found after create`).toBeTruthy();
+  return newId as number;
+}
+
+async function deleteSceneConfig(ctx: APIRequestContext, sceneId: number): Promise<void> {
+  const r = await ctx.delete(`/api/aidop/s8/config/scenes/${sceneId}`);
+  expect(r.status(), `delete-scene status=${r.status()}`).toBe(200);
+}
+
+async function fetchWatchRuleIdByCode(ctx: APIRequestContext, ruleCode: string): Promise<number | null> {
+  const r = await ctx.get(`/api/aidop/s8/config/watch-rules?tenantId=${TENANT_ID}&factoryId=${FACTORY_ID}`);
+  expect(r.status(), `list-rules status=${r.status()}`).toBe(200);
+  const body = (await r.json()) as any;
+  const items: any[] = Array.isArray(body) ? body : (body?.data ?? body?.list ?? []);
+  const found = items.find((x) => String(x.ruleCode ?? x.RuleCode) === ruleCode);
+  if (!found) return null;
+  const id = Number(found.id ?? found.Id);
+  return Number.isFinite(id) && id > 0 ? id : null;
+}
+
+async function createTestWatchRule(ctx: APIRequestContext): Promise<number> {
+  const expression = `SELECT '${TEST_OBJECT_CODE}' AS related_object_code, 999 AS current_value`;
+  const r = await ctx.post('/api/aidop/s8/config/watch-rules', {
+    data: {
+      tenantId: TENANT_ID,
+      factoryId: FACTORY_ID,
+      ruleCode: TEST_RULE_CODE,
+      sceneCode: SCENE_CODE,
+      dataSourceId: DATA_SOURCE_ID,
+      watchObjectType: 'DEVICE',
+      expression,
+      severity: 'HIGH',
+      pollIntervalSeconds: 300,
+      enabled: true,
+    },
+  });
+  expect(r.status(), `create-rule status=${r.status()} body=${await r.text()}`).toBe(200);
+  // InsertAsync 不回填 IDENTITY;列表回查拿真实 id
+  const id = await fetchWatchRuleIdByCode(ctx, TEST_RULE_CODE);
+  expect(id, `rule id not found after create (ruleCode=${TEST_RULE_CODE})`).toBeTruthy();
+  return id as number;
+}
+
+async function deleteTestWatchRule(ctx: APIRequestContext, ruleId: number): Promise<void> {
+  const r = await ctx.delete(`/api/aidop/s8/config/watch-rules/${ruleId}`);
+  expect(r.status(), `delete-rule status=${r.status()}`).toBe(200);
+}
+
+test.beforeAll(async () => {
+  const ctx = await makeApiContext();
+  try {
+    // 1. 预清理:兜底任何历史 TEST_G09_ 残留(dev 库通常为 0)
+    const pre = await callCleanupEndpoint(ctx);
+    test.info().annotations.push({ type: 'pre-cleanup', description: `runId=${RUN_ID} pre.deletedCount=${pre}` });
+
+    // 2. ensure scene_config 行存在(不存在则创建临时;afterAll 决定是否回收)
+    createdSceneId = await ensureSceneConfig(ctx);
+    test.info().annotations.push({
+      type: 'seed-scene',
+      description: `runId=${RUN_ID} createdSceneId=${createdSceneId ?? 'reused-existing'}`,
+    });
+
+    // 3. 创建本轮 TEST_G09_ watch rule(literal SELECT,命中常量 TEST_OBJECT_CODE)
+    testRuleId = await createTestWatchRule(ctx);
+    test.info().annotations.push({
+      type: 'seed-rule',
+      description: `runId=${RUN_ID} testRuleId=${testRuleId} ruleCode=${TEST_RULE_CODE} obj=${TEST_OBJECT_CODE}`,
+    });
+  } finally {
+    await ctx.dispose();
+  }
+});
+
+test('S8-G01-MAPPING 多轮 run-once + TEST_G09_ 去重映射闭环', async ({ authedPage }) => {
+  expect(testRuleId, 'beforeAll 未成功创建 TEST_G09_ watch rule').toBeTruthy();
+  await authedPage.goto('/#/aidop/s8/exceptions', { waitUntil: 'domcontentloaded' });
+
+  // C2: 进入 run-once 前先确认本轮 TEST_G09_ pending 为 0(pre-cleanup 已清空)
+  const baselineTest = await listTestG09Pending(authedPage);
+  expect(baselineTest.length, `[${RUN_ID}] pre-cleanup 后仍存在 TEST_G09_ pending:${JSON.stringify(baselineTest)}`).toBe(0);
 
   // M1:连跑 N 轮,收集结果
   const runs: Array<Awaited<ReturnType<typeof postRunOnce>>> = [];
   for (let i = 0; i < RUNS; i++) runs.push(await postRunOnce(authedPage));
 
-  // C3 安全契约:任意一条 created=true 都视为污染(无清理路径)。立即 hard fail。
+  // C3: 任意一条 RelatedObjectCode 非 TEST_G09_ 的 created=true 必须 hard fail
   for (let i = 0; i < runs.length; i++) {
-    const created = runs[i].results.filter((r) => r.created === true);
+    const polluted = runs[i].results.filter(
+      (r) => r.created === true && !String(r.relatedObjectCode ?? '').startsWith(TEST_PREFIX),
+    );
     expect(
-      created.length,
-      `[${RUN_ID}] 第 ${i + 1} 轮 run-once 创建了 ${created.length} 条新异常但 fixture 无清理机制;` +
-        `请在 backend 增加 TEST_ 前缀 hard-delete 接口后再重跑。created=${JSON.stringify(created)}`,
+      polluted.length,
+      `[${RUN_ID}] 第 ${i + 1} 轮 run-once 在非 TEST_G09_ 对象上创建了 ${polluted.length} 条新异常(污染业务):${JSON.stringify(polluted)}`,
     ).toBe(0);
   }
 
-  const fingerprint = (run: { results: any[] }) =>
-    run.results
-      .map(
-        (r) =>
-          `${r.sourceRuleId}|${r.relatedObjectCode}|created=${r.created}|skipped=${r.skipped}|m=${r.matchedExceptionId ?? ''}`,
-      )
+  // C3: TEST_G09_ hit 第 1 轮允许 created=true,2/3 轮必须 skipped=true
+  const testHitsByRun = runs.map((r) =>
+    r.results.filter((h) => String(h.relatedObjectCode ?? '').startsWith(TEST_PREFIX)),
+  );
+  expect(
+    testHitsByRun[0].length,
+    `[${RUN_ID}] 第 1 轮未观测到 TEST_G09_ hit;可能 watch rule 未生效或 alert rule 阈值不匹配`,
+  ).toBeGreaterThan(0);
+  for (const h of testHitsByRun[0]) {
+    expect(h.created === true || h.skipped === true, `[${RUN_ID}] 第 1 轮 TEST_G09_ hit 既未 created 也未 skipped: ${JSON.stringify(h)}`).toBeTruthy();
+  }
+  for (let i = 1; i < runs.length; i++) {
+    for (const h of testHitsByRun[i]) {
+      expect(h.skipped, `[${RUN_ID}] 第 ${i + 1} 轮 TEST_G09_ hit 应 skipped=true(dedup 失效):${JSON.stringify(h)}`).toBeTruthy();
+      expect(h.matchedExceptionId, `[${RUN_ID}] 第 ${i + 1} 轮 skipped 必须携带 matchedExceptionId`).toBeTruthy();
+    }
+  }
+
+  // M1: 仅对 TEST_G09_ hit 做指纹稳定性断言(业务 EQ-01 hit 不参与)。
+  const fpOf = (hits: any[]) =>
+    hits
+      .map((h) => `${h.sourceRuleId}|${h.relatedObjectCode}|skipped=${h.skipped}|m=${h.matchedExceptionId ?? ''}`)
       .sort()
       .join(';');
-
-  const fps = runs.map(fingerprint);
-  test.info().annotations.push({
-    type: 'stability',
-    description: fps.map((f, i) => `R${i + 1}: ${f}`).join(' || '),
-  });
-  for (let i = 1; i < fps.length; i++) {
-    expect(fps[i], `第 ${i + 1} 轮指纹与第 1 轮不一致`).toBe(fps[0]);
+  // 第 2/3 轮 TEST_G09_ 指纹必须一致(已稳定 dedup)
+  if (runs.length >= 3) {
+    expect(fpOf(testHitsByRun[2]), `[${RUN_ID}] 第 3 轮 TEST_G09_ 指纹与第 2 轮不一致`).toBe(fpOf(testHitsByRun[1]));
   }
 
-  // M2 + M3:命中映射闭环
-  const sample = runs[0];
-  expect(sample.results.length, '至少应有一条命中以验证映射').toBeGreaterThan(0);
+  // M2 + M3 + C5: TEST_G09_ hit(取第 2 轮稳定态)每条都映射到 pending 异常 + 双键一致
+  const stableTestHits = testHitsByRun[runs.length - 1];
+  expect(stableTestHits.length, `[${RUN_ID}] 末轮无 TEST_G09_ hit`).toBeGreaterThan(0);
   const mappingNotes: string[] = [];
-  for (const hit of sample.results) {
-    // C3 已保证不会出现 created=true;此处要求每条都是 skipped。
-    expect(hit.skipped, '命中未走 skipped 分支—与安全契约 C3 不一致').toBeTruthy();
+  for (const hit of stableTestHits) {
+    expect(hit.skipped, '末轮 TEST_G09_ hit 必须为 skipped').toBeTruthy();
     expect(hit.matchedExceptionId, 'skipped 必须携带 matchedExceptionId').toBeTruthy();
+    expect(String(hit.relatedObjectCode), 'TEST_G09_ hit relatedObjectCode 必须等于 TEST_OBJECT_CODE').toBe(TEST_OBJECT_CODE);
+    expect(Number(hit.sourceRuleId), 'TEST_G09_ hit sourceRuleId 必须等于 testRuleId').toBe(testRuleId);
+
     const ex = await getException(authedPage, hit.matchedExceptionId);
     expect(ex, `matchedExceptionId=${hit.matchedExceptionId} 详情不可达`).toBeTruthy();
     expect(PENDING).toContain(String(ex.status));
-    // 双键强制一致:5656a88d 起 ExceptionDetailDto 必须暴露 sourceRuleId / relatedObjectCode。
-    expect(
-      ex.sourceRuleId,
-      `ex#${ex.id} DTO 未暴露 sourceRuleId(参见 5656a88d)`,
-    ).not.toBeUndefined();
-    expect(
-      ex.relatedObjectCode,
-      `ex#${ex.id} DTO 未暴露 relatedObjectCode(参见 5656a88d)`,
-    ).not.toBeUndefined();
-    expect(String(ex.sourceRuleId ?? ''), 'sourceRuleId 不一致').toBe(
-      String(hit.sourceRuleId ?? ''),
-    );
-    expect(String(ex.relatedObjectCode ?? ''), 'relatedObjectCode 不一致').toBe(
-      String(hit.relatedObjectCode ?? ''),
-    );
+    // C5 双键强制断言(不允许 fallback)
+    expect(ex.sourceRuleId, `ex#${ex.id} DTO 未暴露 sourceRuleId(参见 5656a88d)`).not.toBeUndefined();
+    expect(ex.relatedObjectCode, `ex#${ex.id} DTO 未暴露 relatedObjectCode(参见 5656a88d)`).not.toBeUndefined();
+    expect(String(ex.sourceRuleId ?? ''), 'sourceRuleId 不一致').toBe(String(hit.sourceRuleId ?? ''));
+    expect(String(ex.relatedObjectCode ?? ''), 'relatedObjectCode 不一致').toBe(String(hit.relatedObjectCode ?? ''));
     mappingNotes.push(
       `hit{rule=${hit.sourceRuleId},obj=${hit.relatedObjectCode}} → ex#${ex.id}(${ex.status}, code=${ex.code ?? ex.exceptionCode})`,
     );
   }
   test.info().annotations.push({ type: 'mapping', description: mappingNotes.join(' || ') });
-
-  // C4 安全契约:spec 末尾再次快照,确认计数没有变化。任何写入都必须立即被发现。
-  const after = await snapshotAutoWatchPending(authedPage);
-  test.info().annotations.push({
-    type: 'fixture-final',
-    description: `runId=${RUN_ID} finalCount=${after.count}(baseline=${baseline.count})`,
-  });
-  expect(
-    after.count,
-    `[${RUN_ID}] AUTO_WATCH pending 计数从 ${baseline.count} 漂移到 ${after.count};可能存在未清理的写入。`,
-  ).toBe(baseline.count);
 });
 
 /**
- * C6 安全契约:测试套件结束后调用 S2-EW cleanup endpoint 兜底软删 TEST_G09_ 残留。
- * 走独立的 request context(不依赖 authedPage 的 per-test 生命周期),从 storage-state.json
- * 取 token;接口 404 / 401 / 500 一律 hard fail,不吞错。tenantId/factoryId=1/1 与本仓库
- * dev e2e 约定一致;如未来上下文允许动态获取,应替换。
+ * C6 安全契约:先删除测试 watch rule(物理删;AdoS8WatchRule 无 IsDeleted),
+ * 再调 cleanup endpoint 软删 TEST_G09_ exception。
+ * finally 链保证 cleanup 总会执行;任一失败立即 hard fail,不吞错。
  */
 test.afterAll(async () => {
-  const baseURL = process.env.AIDOP_E2E_BASE_URL ?? 'http://localhost:8888';
-  const apiOrigin = baseURL.replace(/\/+$/, '');
-  const token = tokenFromStorageState();
-  const ctx = await playwrightRequest.newContext({
-    baseURL: apiOrigin,
-    extraHTTPHeaders: { Authorization: `Bearer ${token}` },
-  });
+  const ctx = await makeApiContext();
+  let firstError: unknown = null;
   try {
-    const r = await ctx.delete('/api/aidop/s8/watch-debug/test-cleanup?tenantId=1&factoryId=1');
-    expect(
-      r.status(),
-      `[${RUN_ID}] cleanup endpoint 返回非 200(status=${r.status()});可能 debug endpoint 未启用或后端未升级。`,
-    ).toBe(200);
-    const body = (await r.json()) as { deletedCount?: number };
-    expect(
-      typeof body.deletedCount === 'number' && body.deletedCount >= 0,
-      `[${RUN_ID}] cleanup 响应 deletedCount 字段缺失或非数字:${JSON.stringify(body)}`,
-    ).toBeTruthy();
+    if (testRuleId !== null) {
+      try {
+        await deleteTestWatchRule(ctx, testRuleId);
+      } catch (e) {
+        firstError = e;
+      }
+    }
+    if (createdSceneId !== null) {
+      try {
+        await deleteSceneConfig(ctx, createdSceneId);
+      } catch (e) {
+        if (firstError === null) firstError = e;
+      }
+    }
+    try {
+      const deleted = await callCleanupEndpoint(ctx);
+      const post = await listTestG09Pending(ctx);
+      test.info().annotations.push({
+        type: 'cleanup',
+        description: `runId=${RUN_ID} ruleId=${testRuleId} sceneId=${createdSceneId} deletedCount=${deleted} residue=${post.length}`,
+      });
+      expect(post.length, `[${RUN_ID}] cleanup 后仍残留 TEST_G09_ pending:${JSON.stringify(post.map((x) => x.id))}`).toBe(0);
+    } catch (e) {
+      if (firstError === null) firstError = e;
+    }
   } finally {
     await ctx.dispose();
   }
+  if (firstError !== null) throw firstError;
 });