瀏覽代碼

test(s8): add regression coverage for b02 and g01

YY968XX 1 月之前
父節點
當前提交
f7534e37a3

+ 107 - 0
Web/tests/e2e/s8/b02-legacy-full-recheck.spec.ts

@@ -0,0 +1,107 @@
+import { test, expect } from '../fixtures/auth';
+import type { Page } from '@playwright/test';
+
+/**
+ * B-02 遗留现场扩展复测(阶段 4 / N-2 前置):
+ * 在已验证"详情 API + 详情页可加载"之上,补齐 列表 / 时间线 / 状态一致性 三项。
+ */
+
+const SCENES = [
+  { id: 4, flow: '796353842065477', code: 'EX-20260414-0002', label: '阶段2 Approve 半提交' },
+  { id: 29, flow: '796458502344773', code: 'EX-20260414-0003-RJ', label: '阶段3 Reject 半提交' },
+] as const;
+
+async function token(page: Page) {
+  return page.evaluate(() => {
+    for (let i = 0; i < localStorage.length; i++) {
+      const k = localStorage.key(i)!;
+      if (/access-token$/i.test(k) && !/x-access-token$/i.test(k)) {
+        const raw = localStorage.getItem(k)!;
+        try {
+          const v = JSON.parse(raw);
+          return typeof v === 'string' ? v : v?.value ?? raw;
+        } catch {
+          return raw;
+        }
+      }
+    }
+    return null;
+  });
+}
+
+async function api(page: Page, url: string) {
+  const t = await token(page);
+  const base = new URL(page.url()).origin;
+  const resp = await page.request.get(`${base}${url}`, {
+    headers: { Authorization: `Bearer ${t!}` },
+  });
+  return { status: resp.status(), json: resp.ok() ? ((await resp.json()) as any) : null };
+}
+
+test.describe('B-02 遗留现场扩展复测', () => {
+  for (const s of SCENES) {
+    test(`id=${s.id} 详情/时间线/列表三侧一致`, async ({ authedPage }) => {
+      await authedPage.goto('/#/aidop/s8/exceptions', { waitUntil: 'domcontentloaded' });
+      await authedPage.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
+
+      // 1) 详情 API
+      const detail = await api(
+        authedPage,
+        `/api/aidop/s8/exceptions/${s.id}?tenantId=1&factoryId=1`,
+      );
+      expect(detail.status).toBe(200);
+      const d = detail.json?.data ?? detail.json;
+      test.info().annotations.push({
+        type: 'detail',
+        description: JSON.stringify({
+          code: d.code ?? d.exceptionCode,
+          status: d.status,
+          activeFlow: d.activeFlowInstanceId ?? d.ActiveFlowInstanceId,
+        }),
+      });
+      expect(String(d.activeFlowInstanceId ?? d.ActiveFlowInstanceId ?? '')).toBe(s.flow);
+      expect(String(d.status ?? '')).not.toMatch(/CLOSED|CANCELED|已关闭|已取消/);
+
+      // 2) 时间线 API
+      const tl = await api(authedPage, `/api/aidop/s8/exceptions/${s.id}/timeline`);
+      expect(tl.status, '时间线接口应返回 200').toBe(200);
+      const tlData = tl.json?.data ?? tl.json;
+      const tlArr = Array.isArray(tlData) ? tlData : (tlData?.items ?? tlData?.list ?? []);
+      test.info().annotations.push({
+        type: 'timeline',
+        description: `length=${tlArr.length}; first=${JSON.stringify(tlArr[0] ?? null).slice(0, 200)}`,
+      });
+      expect(tlArr.length, '时间线至少应有一条记录').toBeGreaterThan(0);
+
+      // 3) 列表接口:按 code 过滤定位行
+      const listResp = await api(
+        authedPage,
+        `/api/aidop/s8/exceptions?page=1&pageSize=50&tenantId=1&factoryId=1`,
+      );
+      expect(listResp.status).toBe(200);
+      const listData = listResp.json?.data ?? listResp.json;
+      const rows: any[] = listData?.list ?? listData?.items ?? listData?.rows ?? [];
+      const hit = rows.find(
+        (r) => String(r.id) === String(s.id) || r.code === s.code || r.exceptionCode === s.code,
+      );
+      test.info().annotations.push({
+        type: 'list',
+        description: hit
+          ? `found code=${hit.code ?? hit.exceptionCode} status=${hit.status}`
+          : `total=${rows.length}, not in first page`,
+      });
+      // 不强断言必须在第 1 页(数据量变化可能让它翻页),仅要求接口可达
+      if (hit) {
+        expect(String(hit.status ?? '')).toBe(String(d.status ?? ''));
+      }
+
+      // 4) 详情页 URL 可达
+      await authedPage.goto(`/#/aidop/s8/exceptions/${s.id}`, {
+        waitUntil: 'domcontentloaded',
+      });
+      await authedPage.waitForLoadState('networkidle', { timeout: 12_000 }).catch(() => {});
+      const errBanner = authedPage.locator('text=/页面加载失败|未找到|Error:/').first();
+      expect(await errBanner.isVisible().catch(() => false)).toBeFalsy();
+    });
+  }
+});

+ 101 - 0
Web/tests/e2e/s8/b02-legacy-scenes.spec.ts

@@ -0,0 +1,101 @@
+import { test, expect } from '../fixtures/auth';
+import type { Page } from '@playwright/test';
+
+/**
+ * B-02 遗留现场单页断言
+ * 目标:验证 aidopdev 测试库中两条半提交现场依旧保留,详情页可加载,
+ * 且核心不变量(ActiveFlowInstanceId、状态非已关闭)仍然成立。
+ *
+ * 不变量来源:memory/project_s8_b02_status.md(2026-04-20 快照)
+ *  - id=4:阶段 2 Approve 半提交 → ActiveFlowInstanceId=796353842065477
+ *  - id=29:阶段 3 Reject 半提交 → ActiveFlowInstanceId=796458502344773(EX-20260414-0003-RJ)
+ */
+
+const SCENES = [
+  { id: 4, expectedFlow: '796353842065477', label: 'B-02 阶段 2 Approve 半提交' },
+  { id: 29, expectedFlow: '796458502344773', expectedCode: 'EX-20260414-0003-RJ', label: 'B-02 阶段 3 Reject 半提交' },
+] as const;
+
+async function getToken(page: Page): Promise<string | null> {
+  return page.evaluate(() => {
+    for (let i = 0; i < localStorage.length; i++) {
+      const k = localStorage.key(i)!;
+      if (/access-token$/i.test(k) && !/x-access-token$/i.test(k)) {
+        const raw = localStorage.getItem(k)!;
+        try {
+          const v = JSON.parse(raw);
+          return typeof v === 'string' ? v : v?.value ?? raw;
+        } catch {
+          return raw;
+        }
+      }
+    }
+    return null;
+  });
+}
+
+async function fetchDetail(page: Page, id: number) {
+  const token = await getToken(page);
+  expect(token, '未取到 access-token').toBeTruthy();
+  const baseURL = new URL(page.url()).origin;
+  const resp = await page.request.get(
+    `${baseURL}/api/aidop/s8/exceptions/${id}?tenantId=1&factoryId=1`,
+    { headers: { Authorization: `Bearer ${token}` } },
+  );
+  return { status: resp.status(), body: resp.ok() ? await resp.json() : null };
+}
+
+test.describe('S8-B02-LEGACY 遗留现场单页断言', () => {
+  for (const scene of SCENES) {
+    test(`id=${scene.id} ${scene.label}`, async ({ authedPage }) => {
+      // 1) API 层:记录依然存在 + 关键不变量
+      await authedPage.goto('/#/aidop/s8/exceptions', { waitUntil: 'domcontentloaded' });
+      await authedPage.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
+
+      const { status, body } = await fetchDetail(authedPage, scene.id);
+      test.info().annotations.push({
+        type: 'api',
+        description: `GET /exceptions/${scene.id} → HTTP ${status}`,
+      });
+      expect(status, `详情接口非 200,记录可能已被清理`).toBe(200);
+      const data = body?.data ?? body;
+      expect(data, '详情响应缺少数据').toBeTruthy();
+
+      const activeFlow =
+        data.activeFlowInstanceId ?? data.ActiveFlowInstanceId ?? data.flowInstanceId;
+      const code = data.code ?? data.exceptionCode;
+      const status8 = data.status ?? data.statusName;
+
+      test.info().annotations.push({
+        type: 'snapshot',
+        description: JSON.stringify({ code, status: status8, activeFlow }),
+      });
+
+      expect(String(activeFlow ?? ''), '半提交现场必须仍持有 ActiveFlowInstanceId').toBe(
+        scene.expectedFlow,
+      );
+      expect(String(status8 ?? ''), '半提交现场不应处于已关闭状态').not.toMatch(/已关闭|已取消/);
+      if ('expectedCode' in scene && scene.expectedCode) {
+        expect(String(code ?? '')).toBe(scene.expectedCode);
+      }
+
+      // 2) UI 层:详情页可正常打开
+      await authedPage.goto(`/#/aidop/s8/exceptions/${scene.id}`, {
+        waitUntil: 'domcontentloaded',
+      });
+      await authedPage.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
+      const errorBoundary = authedPage.locator('text=/页面加载失败|Error|未找到/').first();
+      const errorVisible = await errorBoundary.isVisible().catch(() => false);
+      expect(errorVisible, '详情页出现错误提示').toBeFalsy();
+
+      const titleArea = authedPage.locator(
+        'h1, h2, h3, .el-page-header, .el-card__header, .header',
+      );
+      await expect(titleArea.first(), '详情页应渲染标题区').toBeVisible({ timeout: 10_000 });
+      await authedPage.screenshot({
+        path: `test-results/s8-b02-id-${scene.id}.png`,
+        fullPage: true,
+      });
+    });
+  }
+});

+ 277 - 0
Web/tests/e2e/s8/exceptions-list-refresh.spec.ts

@@ -0,0 +1,277 @@
+import { test, expect } from '../fixtures/auth';
+import type { Page, Request } from '@playwright/test';
+
+/**
+ * S8-LIST-001 回归:详情操作后返回列表,列表状态应自动刷新且仅触发一次列表查询。
+ * 不绑定固定单据:A 用例挑"新建"行,B 用例挑"已指派"行,C 用例任取一行。
+ */
+
+const LIST_URL = '/#/aidop/s8/exceptions';
+const LIST_API_RE = /\/api\/aidop\/s8\/exceptions(?:\?|$)/;
+const DETAIL_URL_RE = /#\/aidop\/s8\/exceptions\/[\w-]+/;
+
+type Counter = { readonly count: number; dispose: () => void };
+
+function trackListCalls(page: Page): Counter {
+  let count = 0;
+  const handler = (req: Request) => {
+    if (req.method() !== 'GET') return;
+    const url = req.url();
+    // 仅统计列表查询,不算 /:id/* 详情子接口
+    if (LIST_API_RE.test(url) && !/\/exceptions\/[\w-]+(?:\?|$|\/)/.test(url)) {
+      count += 1;
+    }
+  };
+  page.on('request', handler);
+  return {
+    get count() {
+      return count;
+    },
+    dispose: () => page.off('request', handler),
+  };
+}
+
+async function gotoList(page: Page) {
+  if (/#\/aidop\/s8\/exceptions(?!\/)/.test(page.url())) {
+    await page.reload({ waitUntil: 'domcontentloaded' });
+  } else {
+    await page.goto(LIST_URL, { waitUntil: 'domcontentloaded' });
+  }
+  await page
+    .waitForResponse((r) => LIST_API_RE.test(r.url()) && r.request().method() === 'GET', {
+      timeout: 30_000,
+    })
+    .catch(() => {});
+  await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
+  await expect(page.locator('table tbody tr').first()).toBeVisible({ timeout: 15_000 });
+}
+
+async function findRowByStatus(page: Page, status: RegExp) {
+  const rows = page.locator('table tbody tr');
+  const n = await rows.count();
+  for (let i = 0; i < n; i++) {
+    const text = (await rows.nth(i).innerText()).replace(/\s+/g, ' ');
+    if (status.test(text)) {
+      const codeMatch = text.match(/EX-[\w-]+/);
+      if (!codeMatch) continue;
+      return { index: i, text, code: codeMatch[0], locator: rows.nth(i) };
+    }
+  }
+  return null;
+}
+
+/**
+ * 通过主动提报 API 种一条"新建"状态的异常,返回单据编号 EX-...。
+ * 复用 page 上下文的鉴权,避免单独维护 token。
+ */
+async function seedNewException(page: Page): Promise<string | null> {
+  const baseURL = new URL(page.url()).origin;
+  // 应用使用 Bearer,token 存在 localStorage["access-token"],外加 Local.ts 自带前缀
+  const token = await page.evaluate(() => {
+    for (let i = 0; i < localStorage.length; i++) {
+      const k = localStorage.key(i)!;
+      if (/access-token$/i.test(k) && !/x-access-token$/i.test(k)) {
+        try {
+          const raw = localStorage.getItem(k)!;
+          const v = JSON.parse(raw);
+          return typeof v === 'string' ? v : v?.value ?? raw;
+        } catch {
+          return localStorage.getItem(k);
+        }
+      }
+    }
+    return null;
+  });
+  if (!token) return null;
+  const headers = { Authorization: `Bearer ${token}` };
+  const optsResp = await page.request.get(
+    `${baseURL}/api/aidop/s8/reports/form-options?tenantId=1&factoryId=1`,
+    { headers },
+  );
+  if (!optsResp.ok()) return null;
+  const optsJson = (await optsResp.json()) as any;
+  const opts = optsJson.data ?? optsJson;
+  const sceneCode = opts.scenes?.[0]?.value ?? opts.scenes?.[0]?.code;
+  const deptId = opts.departments?.[0]?.value ?? opts.departments?.[0]?.id;
+  if (!sceneCode || !deptId) return null;
+
+  const createResp = await page.request.post(`${baseURL}/api/aidop/s8/reports`, {
+    headers,
+    data: {
+      tenantId: 1,
+      factoryId: 1,
+      title: `E2E 自动种子 ${new Date().toISOString()}`,
+      description: 'created by playwright for REG-S8-LIST-001-A',
+      sceneCode,
+      severity: 'P3',
+      occurrenceDeptId: deptId,
+      responsibleDeptId: deptId,
+    },
+  });
+  if (!createResp.ok()) return null;
+  const created = (await createResp.json()) as any;
+  const code =
+    created?.exceptionCode ??
+    created?.data?.exceptionCode ??
+    created?.data?.code ??
+    created?.code ??
+    null;
+  return code;
+}
+
+async function rowTextByCode(page: Page, code: string): Promise<string> {
+  const row = page.locator('table tbody tr', { hasText: code }).first();
+  await expect(row).toBeVisible({ timeout: 10_000 });
+  return (await row.innerText()).replace(/\s+/g, ' ');
+}
+
+async function openDetailOfRow(page: Page, rowIndex: number) {
+  const row = page.locator('table tbody tr').nth(rowIndex);
+  const detailWait = page.waitForURL(DETAIL_URL_RE, { timeout: 15_000 });
+  const link = row.getByRole('link').first();
+  if (await link.isVisible().catch(() => false)) {
+    await link.click();
+  } else {
+    await row.locator('td').first().click();
+  }
+  await detailWait;
+  await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
+}
+
+async function clickAndWait(
+  page: Page,
+  name: RegExp,
+  apiRe: RegExp,
+  opts: { needAssignee?: boolean } = {},
+) {
+  const btn = page.getByRole('button', { name }).first();
+  await expect(btn).toBeEnabled({ timeout: 10_000 });
+  await btn.click();
+
+  // 操作面板按钮会打开 el-dialog,需要在弹窗内填表 + 确定
+  const dialog = page.locator('.el-dialog:visible').first();
+  await expect(dialog).toBeVisible({ timeout: 10_000 });
+
+  if (opts.needAssignee) {
+    const select = dialog.locator('.el-select').first();
+    await select.locator('.el-select__wrapper').click({ force: true });
+    const option = page
+      .locator('.el-select-dropdown:visible .el-select-dropdown__item:not(.is-disabled)')
+      .first();
+    await expect(option).toBeVisible({ timeout: 10_000 });
+    await option.click();
+  }
+
+  const respPromise = page.waitForResponse(
+    (r) => apiRe.test(r.url()) && r.request().method() === 'POST',
+    { timeout: 15_000 },
+  );
+  await dialog.getByRole('button', { name: /确\s*定/ }).click();
+  const resp = await respPromise;
+  expect(resp.ok(), `${name} 接口返回非 2xx`).toBeTruthy();
+  await expect(dialog).toBeHidden({ timeout: 10_000 });
+  await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
+}
+
+async function backToList(page: Page) {
+  const back = page.getByRole('button', { name: /返\s*回/ }).first();
+  if (await back.isVisible().catch(() => false)) {
+    await back.click();
+  } else {
+    await page.goBack();
+  }
+  await page.waitForURL(/#\/aidop\/s8\/exceptions(?!\/)/, { timeout: 15_000 }).catch(() => {});
+  await expect(page.locator('table tbody tr').first()).toBeVisible({ timeout: 15_000 });
+}
+
+test.describe.configure({ mode: 'serial' });
+
+test.describe('S8-LIST-001 详情操作返回列表自动刷新', () => {
+  test('REG-S8-LIST-001-A 认领后返回列表,目标行状态变为已指派', async ({ authedPage }) => {
+    await gotoList(authedPage);
+    let target = await findRowByStatus(authedPage, /新建/);
+    if (!target) {
+      const seeded = await seedNewException(authedPage);
+      test.skip(!seeded, '无可用"新建"记录,且通过主动提报 API 种子失败');
+      await gotoList(authedPage);
+      target = await findRowByStatus(authedPage, /新建/);
+      test.skip(!target, '种子已创建但列表未刷出"新建"行');
+      test.info().annotations.push({ type: 'seeded', description: seeded! });
+    }
+    test.info().annotations.push({ type: 'before', description: target!.text });
+
+    await openDetailOfRow(authedPage, target!.index);
+    await clickAndWait(authedPage, /^认\s*领$/, /\/exceptions\/[\w-]+\/claim/, { needAssignee: true });
+
+    const counter = trackListCalls(authedPage);
+    const listResp = authedPage.waitForResponse(
+      (r) =>
+        LIST_API_RE.test(r.url()) &&
+        r.request().method() === 'GET' &&
+        !/\/exceptions\/[\w-]+(?:\?|$|\/)/.test(r.url()),
+      { timeout: 15_000 },
+    );
+    await backToList(authedPage);
+    await listResp.catch(() => {});
+    await authedPage.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => {});
+
+    const row = authedPage.locator('table tbody tr', { hasText: target!.code }).first();
+    await expect(row, '返回列表后该行应在合理时间内变为"已指派"').toContainText(/已指派/, {
+      timeout: 8_000,
+    });
+    const after = (await row.innerText()).replace(/\s+/g, ' ');
+    counter.dispose();
+    test.info().annotations.push({ type: 'after', description: after });
+    test.info().annotations.push({ type: 'list-calls', description: String(counter.count) });
+    expect(counter.count, '返回列表应仅触发 1 次列表查询').toBe(1);
+  });
+
+  test('REG-S8-LIST-001-B 开始处理后返回列表,目标行状态变为处理中', async ({ authedPage }) => {
+    await gotoList(authedPage);
+    const target = await findRowByStatus(authedPage, /已指派/);
+    test.skip(!target, '当前列表无"已指派"状态记录可供 B 用例使用');
+    test.info().annotations.push({ type: 'before', description: target!.text });
+
+    await openDetailOfRow(authedPage, target!.index);
+    await clickAndWait(authedPage, /^开始处理$/, /\/exceptions\/[\w-]+\/start-progress/);
+
+    const counter = trackListCalls(authedPage);
+    const listResp = authedPage.waitForResponse(
+      (r) =>
+        LIST_API_RE.test(r.url()) &&
+        r.request().method() === 'GET' &&
+        !/\/exceptions\/[\w-]+(?:\?|$|\/)/.test(r.url()),
+      { timeout: 15_000 },
+    );
+    await backToList(authedPage);
+    await listResp.catch(() => {});
+    await authedPage.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => {});
+
+    const after = await rowTextByCode(authedPage, target!.code);
+    counter.dispose();
+    test.info().annotations.push({ type: 'after', description: after });
+    test.info().annotations.push({ type: 'list-calls', description: String(counter.count) });
+
+    expect(after, '返回列表后行状态应包含"处理中"').toMatch(/处理中/);
+    expect(counter.count, '返回列表应仅触发 1 次列表查询').toBe(1);
+  });
+
+  test('REG-S8-LIST-001-C 空操作返回列表不应造成请求风暴', async ({ authedPage }) => {
+    await gotoList(authedPage);
+    await openDetailOfRow(authedPage, 0);
+    const counter = trackListCalls(authedPage);
+    const listResp = authedPage.waitForResponse(
+      (r) =>
+        LIST_API_RE.test(r.url()) &&
+        r.request().method() === 'GET' &&
+        !/\/exceptions\/[\w-]+(?:\?|$|\/)/.test(r.url()),
+      { timeout: 15_000 },
+    );
+    await backToList(authedPage);
+    await listResp.catch(() => {});
+    await authedPage.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => {});
+    counter.dispose();
+    test.info().annotations.push({ type: 'list-calls', description: String(counter.count) });
+    expect(counter.count, '空操作返回列表,列表查询次数应 ≤ 1').toBeLessThanOrEqual(1);
+  });
+});

+ 110 - 0
Web/tests/e2e/s8/g01-watch-mapping.spec.ts

@@ -0,0 +1,110 @@
+import { test, expect } from '../fixtures/auth';
+import type { Page } from '@playwright/test';
+
+/**
+ * G-01 非破坏性补强:
+ *  M1 多次 run-once 的 hit 集合稳定(sourceRuleId / relatedObjectCode / skipped 一致)
+ *  M2 命中对象映射闭环:每条 skipped hit 必然对应一条仍未闭环的现存异常
+ *  M3 现存异常须与命中对象(sourceRuleId + relatedObjectCode)双键一致
+ * 不闭单、不改数据、不调用任何动作类接口。
+ */
+
+const PENDING = ['NEW', 'ASSIGNED', 'IN_PROGRESS', 'PENDING_VERIFICATION'];
+const RUNS = 3;
+
+async function token(page: Page) {
+  return page.evaluate(() => {
+    for (let i = 0; i < localStorage.length; i++) {
+      const k = localStorage.key(i)!;
+      if (/access-token$/i.test(k) && !/x-access-token$/i.test(k)) {
+        const raw = localStorage.getItem(k)!;
+        try {
+          const v = JSON.parse(raw);
+          return typeof v === 'string' ? v : v?.value ?? raw;
+        } catch {
+          return raw;
+        }
+      }
+    }
+    return null;
+  });
+}
+
+async function postRunOnce(page: Page) {
+  const t = await token(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!}` } },
+  );
+  expect(r.status()).toBe(200);
+  return (await r.json()) as { count: number; results: any[] };
+}
+
+async function getException(page: Page, id: string | number) {
+  const t = await token(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!}` } },
+  );
+  if (!r.ok()) return null;
+  const j = (await r.json()) as any;
+  return j?.data ?? j;
+}
+
+test('S8-G01-MAPPING 多轮 run-once + 去重映射闭环', async ({ authedPage }) => {
+  await authedPage.goto('/#/aidop/s8/exceptions', { waitUntil: 'domcontentloaded' });
+
+  // M1:连跑 N 轮,收集结果
+  const runs: Array<Awaited<ReturnType<typeof postRunOnce>>> = [];
+  for (let i = 0; i < RUNS; i++) runs.push(await postRunOnce(authedPage));
+
+  const fingerprint = (run: { results: any[] }) =>
+    run.results
+      .map(
+        (r) =>
+          `${r.sourceRuleId}|${r.relatedObjectCode}|created=${r.created}|skipped=${r.skipped}|m=${r.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]);
+  }
+
+  // M2 + M3:命中映射闭环
+  const sample = runs[0];
+  expect(sample.results.length, '至少应有一条命中以验证映射').toBeGreaterThan(0);
+  const mappingNotes: string[] = [];
+  for (const hit of sample.results) {
+    expect(hit.skipped || hit.created, '命中既未创建也未跳过—状态机异常').toBeTruthy();
+    if (!hit.skipped) continue;
+    expect(hit.matchedExceptionId, 'skipped 必须携带 matchedExceptionId').toBeTruthy();
+    const ex = await getException(authedPage, hit.matchedExceptionId);
+    expect(ex, `matchedExceptionId=${hit.matchedExceptionId} 详情不可达`).toBeTruthy();
+    expect(PENDING).toContain(String(ex.status));
+    // 双键一致:DTO 可能未暴露 sourceRuleId / relatedObjectCode(注释字段),
+    // 缺字段时只记录"DTO 未暴露",不视为失败;存在则严格相等。
+    const exRule =
+      ex.sourceRuleId ?? ex.SourceRuleId ?? ex.sourceRuleID ?? ex.ruleId ?? null;
+    const exObj =
+      ex.relatedObjectCode ?? ex.RelatedObjectCode ?? ex.relatedObject ?? ex.objectCode ?? null;
+    if (exRule != null && exRule !== '') {
+      expect(String(exRule), 'sourceRuleId 不一致').toBe(String(hit.sourceRuleId ?? ''));
+    }
+    if (exObj != null && exObj !== '') {
+      expect(String(exObj), 'relatedObjectCode 不一致').toBe(String(hit.relatedObjectCode ?? ''));
+    }
+    const dtoFlag = exRule == null && exObj == null ? '[DTO 未暴露双键]' : '';
+    mappingNotes.push(
+      `hit{rule=${hit.sourceRuleId},obj=${hit.relatedObjectCode}} → ex#${ex.id}(${ex.status}, code=${ex.code ?? ex.exceptionCode})${dtoFlag}`,
+    );
+  }
+  test.info().annotations.push({ type: 'mapping', description: mappingNotes.join(' || ') });
+});

+ 127 - 0
Web/tests/e2e/s8/g01-watch-runonce.spec.ts

@@ -0,0 +1,127 @@
+import { test, expect } from '../fixtures/auth';
+import type { Page } from '@playwright/test';
+
+/**
+ * G-01 自动监控主链单页验证(手动触发分支)
+ * 触发模型已澄清:定时(30 min)+ 手动 debug API 并存。本 spec 仅走 debug API。
+ *
+ * 主链断言(与 memory/project_s8_g01_mvp.md 对齐):
+ *  - 入口 POST /api/aidop/s8/watch-debug/run-once 返回 200 + 结构化结果
+ *  - 命中产出的新单:SourceType=AUTO_WATCH / SceneCode=S2S6_PRODUCTION / ExceptionType=EQUIP_FAULT / ModuleCode 为空
+ *  - 立即第二次 run-once:相同命中应进入"未闭环去重"分支(skipped 比例上升 / created=0),证明 EvaluateDedupAsync 生效
+ */
+
+async function getToken(page: Page): Promise<string | null> {
+  return page.evaluate(() => {
+    for (let i = 0; i < localStorage.length; i++) {
+      const k = localStorage.key(i)!;
+      if (/access-token$/i.test(k) && !/x-access-token$/i.test(k)) {
+        const raw = localStorage.getItem(k)!;
+        try {
+          const v = JSON.parse(raw);
+          return typeof v === 'string' ? v : v?.value ?? raw;
+        } catch {
+          return raw;
+        }
+      }
+    }
+    return null;
+  });
+}
+
+async function runOnce(page: Page) {
+  const token = await getToken(page);
+  expect(token, '未取到 access-token').toBeTruthy();
+  const baseURL = new URL(page.url()).origin;
+  const resp = await page.request.post(
+    `${baseURL}/api/aidop/s8/watch-debug/run-once?tenantId=1&factoryId=1`,
+    { headers: { Authorization: `Bearer ${token}` } },
+  );
+  return { status: resp.status(), body: resp.ok() ? ((await resp.json()) as any) : null };
+}
+
+async function fetchException(page: Page, id: number) {
+  const token = await getToken(page);
+  const baseURL = new URL(page.url()).origin;
+  const resp = await page.request.get(
+    `${baseURL}/api/aidop/s8/exceptions/${id}?tenantId=1&factoryId=1`,
+    { headers: { Authorization: `Bearer ${token!}` } },
+  );
+  expect(resp.ok(), `详情接口非 200`).toBeTruthy();
+  const j = (await resp.json()) as any;
+  return j?.data ?? j;
+}
+
+test.describe.configure({ mode: 'serial' });
+
+test.describe('S8-G01 自动监控主链(手动触发)', () => {
+  let firstRun: any | null = null;
+
+  test('G01-RUN-1 调用 run-once,主链返回结构化结果', async ({ authedPage }) => {
+    await authedPage.goto('/#/aidop/s8/exceptions', { waitUntil: 'domcontentloaded' });
+    const { status, body } = await runOnce(authedPage);
+    test.info().annotations.push({
+      type: 'run1',
+      description: `HTTP ${status}; count=${body?.count}; created=${
+        body?.results?.filter((r: any) => r.created).length ?? 0
+      }; skipped=${body?.results?.filter((r: any) => r.skipped).length ?? 0}`,
+    });
+    expect(status).toBe(200);
+    expect(body, 'run-once 应返回 JSON').toBeTruthy();
+    expect(typeof body.count, 'count 字段缺失').toBe('number');
+    expect(Array.isArray(body.results), 'results 应为数组').toBeTruthy();
+    for (const r of body.results) {
+      expect(r).toHaveProperty('created');
+      expect(r).toHaveProperty('skipped');
+      expect(r).toHaveProperty('sourceRuleId');
+      expect(r).toHaveProperty('relatedObjectCode');
+    }
+    firstRun = body;
+  });
+
+  test('G01-RUN-1-VERIFY 新建单据满足 G-01 口径', async ({ authedPage }) => {
+    test.skip(!firstRun, '依赖 RUN-1 结果');
+    const created = firstRun.results.filter((r: any) => r.created && r.createdExceptionId);
+    test.skip(
+      created.length === 0,
+      '本轮 run-once 未产生新建(可能数据无命中或全部已去重),口径验证只能在有创建样本时进行',
+    );
+    const sample = created[0];
+    const detail = await fetchException(authedPage, Number(sample.createdExceptionId));
+    test.info().annotations.push({
+      type: 'sample',
+      description: JSON.stringify({
+        id: detail.id,
+        code: detail.code ?? detail.exceptionCode,
+        sourceType: detail.sourceType,
+        sceneCode: detail.sceneCode,
+        exceptionType: detail.exceptionType,
+        moduleCode: detail.moduleCode,
+      }),
+    });
+    expect(String(detail.sourceType ?? '')).toBe('AUTO_WATCH');
+    expect(String(detail.sceneCode ?? '')).toMatch(/S2S6_PRODUCTION|S2S6Production/);
+    expect(String(detail.exceptionType ?? '')).toBe('EQUIP_FAULT');
+    // ModuleCode 首版置空
+    expect(detail.moduleCode == null || detail.moduleCode === '').toBeTruthy();
+  });
+
+  test('G01-RUN-2 紧随其后再触发,应命中未闭环去重', async ({ authedPage }) => {
+    test.skip(!firstRun, '依赖 RUN-1 结果');
+    const { status, body } = await runOnce(authedPage);
+    test.info().annotations.push({
+      type: 'run2',
+      description: `HTTP ${status}; count=${body?.count}; created=${
+        body?.results?.filter((r: any) => r.created).length ?? 0
+      }; skipped=${body?.results?.filter((r: any) => r.skipped).length ?? 0}`,
+    });
+    expect(status).toBe(200);
+    const created2 = body.results.filter((r: any) => r.created).length;
+    const skipped2 = body.results.filter((r: any) => r.skipped).length;
+    // 两次之间没有人为闭单 → 第二轮 created 应为 0;如有规则首次运行,至少 skipped 应 ≥ 第一次的 created 数
+    expect(
+      created2 === 0 || skipped2 >= firstRun.results.filter((r: any) => r.created).length,
+      `第二次 run-once 仍有大量创建,去重未生效:created=${created2} skipped=${skipped2}`,
+    ).toBeTruthy();
+  });
+});