mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 02:41:58 +00:00
1566 lines
126 KiB
JavaScript
1566 lines
126 KiB
JavaScript
// 基础组件(components/ui.tsx)、前端工具库(lib/*)、连接状态(store/catalog.ts)
|
||
// 和 api/client 的上传进度的回归检查。管理页共用件(页头、连通胶囊、单选组、删除
|
||
// 后撤销)和失败说明(lib/explain)也在这里:它们从页面挪进了公共层,页面级检查
|
||
// 只走得到其中一两种情况。
|
||
//
|
||
// 这几样是全站的地基,坏了不会在哪一页上报错,只会悄悄变样:弹窗的焦点漏到页面
|
||
// 上、组字时按 Esc 把半屏草稿关掉、toast 压住工具栏按钮、后端一抖整站说「还没有
|
||
// 工作流」、恢复以后页面自己的列表还停在假的空态上。页面级的 check-ui 喂的都是
|
||
// 正常路径,对这些一个字都不会说。
|
||
//
|
||
// 跑在预览页 /ui-harness.html 上(只有 dev server 有,不进生产包)。lib 用的是
|
||
// 预览页挂在 window.__ui.lib 上的那一份:和组件同一个模块实例,instanceof ApiError
|
||
// 才靠得住——从页面里另外 import 一次,vite 热更新过的模块会带 ?t= 成为第二个实例。
|
||
// 断网用 page.route 拦 /api,不停后端;非 GET 一律拦掉,不写库。
|
||
// 时间格式按 Asia/Shanghai 断言,浏览器上下文钉了这个时区,和本机设置无关。
|
||
//
|
||
// 跑之前前后端都得起着(./scripts/dev.sh),默认连 5273 / 8000。对别的实例(比如一份
|
||
// 沙箱拷贝)跑时带上地址:
|
||
// AGENTLAB_WEB=http://localhost:<前端端口> AGENTLAB_API=http://localhost:<后端端口>/api node scripts/check-ui-kit.mjs
|
||
import { readFileSync } from 'node:fs'
|
||
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
|
||
|
||
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
|
||
const API = process.env.AGENTLAB_API ?? 'http://localhost:8000/api'
|
||
// 不用 playwright install:它既下不动也会动到已有缓存。系统 Chrome 就够了。
|
||
const CHROME = process.env.CHROME_PATH
|
||
?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
|
||
|
||
let failed = 0
|
||
const check = (name, cond, detail = '') => {
|
||
console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`)
|
||
if (!cond) failed++
|
||
}
|
||
|
||
/**
|
||
* 一节一节地跑:某一节里元素找不到、等待超时,只记成这一节失败,接着跑下一节,
|
||
* 不让一处卡住把后面的检查一起吞掉。页面是各节共用的:
|
||
* 出错那一节停在哪,下一节就从哪接着
|
||
*/
|
||
async function section(name, fn) {
|
||
console.log(`\n=== ${name} ===`)
|
||
try {
|
||
await fn()
|
||
} catch (e) {
|
||
check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0])
|
||
}
|
||
}
|
||
|
||
const browser = await chromium.launch({ executablePath: CHROME })
|
||
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, timezoneId: 'Asia/Shanghai' })
|
||
const page = await ctx.newPage()
|
||
const isApi = (u) => new URL(u).pathname.startsWith('/api/')
|
||
await page.route(isApi, (r) => (r.request().method() === 'GET' ? r.continue() : r.abort()))
|
||
const errors = []
|
||
page.on('pageerror', (e) => errors.push(e.message))
|
||
|
||
try {
|
||
await page.goto(`${WEB}/ui-harness.html`, { waitUntil: 'networkidle' })
|
||
await page.waitForSelector('#badges-color', { timeout: 8000 })
|
||
} catch (e) {
|
||
console.error(`✗ 打不开预览页(${WEB}/ui-harness.html)——前端没起?先跑 ./scripts/dev.sh\n ${e.message}`)
|
||
await browser.close()
|
||
process.exit(1)
|
||
}
|
||
|
||
// 别的改动让 vite 整页刷新时,预览页的状态(心跳、catalog)全没了,后面的断言会
|
||
// 莫名其妙地挂。记下来,失败时说清楚是这个原因
|
||
let reloads = 0
|
||
page.on('load', () => { reloads++ })
|
||
|
||
const active = () => page.evaluate(() => {
|
||
const el = document.activeElement
|
||
return el ? `${el.tagName}${el.id ? '#' + el.id : ''}:${(el.textContent || el.getAttribute('placeholder') || '').trim().slice(0, 12)}` : 'null'
|
||
})
|
||
const dialogOpen = () => page.locator('[role="dialog"]').count()
|
||
const asking = () => page.getByText('有未保存的修改').count()
|
||
const state = () => page.evaluate(() => {
|
||
const s = window.__ui.useCatalog.getState()
|
||
return { backend: s.backend, err: s.backendError, retryIn: s.retryAt ? s.retryAt - Date.now() : null, reconnects: s.reconnects }
|
||
})
|
||
|
||
await section('lib:格式、状态、快捷键、术语、报错', async () => {
|
||
for (const [name, ok, detail] of await page.evaluate(() => {
|
||
const { format: f, status: st, keys: k, terms: t, errors: e } = window.__ui.lib
|
||
const { ApiError } = window.__ui
|
||
const out = []
|
||
const eq = (name, got, want) => out.push([name, got === want, got === want ? '' : `得到 ${JSON.stringify(got)},应为 ${JSON.stringify(want)}`])
|
||
const ok = (name, cond, detail = '') => out.push([name, !!cond, cond ? '' : detail])
|
||
|
||
// 耗时:先按目标精度取整再判档(旧实现 119600 显示成「1m60s」)
|
||
eq('820 ms', f.formatDuration(820), '820 ms')
|
||
eq('999.6 ms 进位到秒档', f.formatDuration(999.6), '1.0 s')
|
||
eq('7.6 s', f.formatDuration(7600), '7.6 s')
|
||
eq('59.96 s 进位到分钟档', f.formatDuration(59_960), '1 分 00 秒')
|
||
eq('1 分 14 秒', f.formatDuration(74_000), '1 分 14 秒')
|
||
eq('119600 → 2 分 00 秒', f.formatDuration(119_600), '2 分 00 秒')
|
||
eq('1 小时 02 分', f.formatDuration(3_720_000), '1 小时 02 分')
|
||
// 跨天的跨度和相对时刻:记录页、时间轴、HUD 共用这一份(以前在 pages/runs/model 里)
|
||
eq('formatSpan 一天以内同 formatDuration', f.formatSpan(74_000), '1 分 14 秒')
|
||
eq('formatSpan 跨天:8 天 23 小时', f.formatSpan(8 * 86_400_000 + 23 * 3_600_000 + 5 * 60_000), '8 天 23 小时')
|
||
eq('formatSpan 整天不写 00 小时', f.formatSpan(2 * 86_400_000 + 1000), '2 天')
|
||
eq('formatSpan 粗粒度:8 天', f.formatSpan(8 * 86_400_000 + 23 * 3_600_000, { coarse: true }), '8 天')
|
||
eq('formatSpan 粗粒度:3 小时', f.formatSpan(3 * 3_600_000 + 59 * 60_000, { coarse: true }), '3 小时')
|
||
eq('formatSpan 粗粒度:不到 1 分钟', f.formatSpan(40_000, { coarse: true }), '不到 1 分钟')
|
||
eq('formatSpan 拿不到写 —', f.formatSpan(null), '—')
|
||
// 已等、等人的共用写法:一小时以内是钟面,一小时以上按 formatSpan(3C REQ-19)
|
||
eq('formatLapse 一小时以内是钟面', f.formatLapse?.(1_500_300), '25:00.3')
|
||
eq('formatLapse 不要十分位', f.formatLapse?.(1_500_300, false), '25:00')
|
||
eq('formatLapse 过了一小时按时长写', f.formatLapse?.(3 * 3_600_000 + 5 * 60_000), '3 小时 05 分')
|
||
eq('formatLapse 跨天:9 天 03 小时', f.formatLapse?.(9 * 86_400_000 + 3 * 3_600_000 + 34 * 60_000), '9 天 03 小时')
|
||
eq('formatLapse 拿不到写 —', f.formatLapse?.(Number.NaN), '—')
|
||
eq('formatOffset 一天以内是秒表读数', f.formatOffset(303_400), '05:03.4')
|
||
eq('formatOffset 跨天:9 天 00:38:26', f.formatOffset(9 * 86_400_000 + 38 * 60_000 + 26_200), '9 天 00:38:26')
|
||
eq('formatOffset 负数写 —', f.formatOffset(-1), '—')
|
||
eq('拿不到的耗时写 —', f.formatDuration(undefined), '—')
|
||
eq('0 是 0 ms 不是 —', f.formatDuration(0), '0 ms')
|
||
eq('计时器 mm:ss.s', f.formatClock(74_310), '01:14.3')
|
||
eq('计时器过小时', f.formatClock(3_723_400), '1:02:03.4')
|
||
eq('token 千分位', f.formatTokens(56034), '56,034 token')
|
||
eq('token 紧凑', f.formatTokens(56034, { compact: true }), '56.0k token')
|
||
eq('token 紧凑进位到 M', f.formatTokens(999_950, { compact: true }), '1.0M token')
|
||
eq('成本', f.formatCost(0.0312), '$0.031')
|
||
eq('成本不足 0.001', f.formatCost(0.0004), '<$0.001')
|
||
eq('成本为 0', f.formatCost(0), '$0')
|
||
eq('成本拿不到', f.formatCost(null), '—')
|
||
|
||
// 时间:不带时区的服务器时间按 UTC
|
||
eq('不带时区按 UTC', f.parseServerTime('2026-09-26T01:11:19.891698')?.toISOString(), '2026-09-26T01:11:19.891Z')
|
||
eq('空格分隔也按 UTC', f.parseServerTime('2026-09-26 01:11:19')?.toISOString(), '2026-09-26T01:11:19.000Z')
|
||
eq('小于 1e12 的数当秒', f.parseServerTime(1790385079.89)?.toISOString(), '2026-09-26T01:11:19.890Z')
|
||
eq('解析不了是 null', f.parseServerTime('garbage'), null)
|
||
const now = new Date('2026-09-26T02:00:00Z') // 上海 10:00
|
||
eq('今天只写时分', f.formatTime('2026-09-26T01:11:19', now), '09:11')
|
||
eq('更早写月/日', f.formatTime('2026-09-25T04:52:00Z', now), '9/25 12:52')
|
||
eq('跨年带年份', f.formatTime('2025-09-25T04:52:00Z', now), '2025/9/25 12:52')
|
||
eq('完整时间带时区', f.formatDateTime('2026-09-26T01:11:19.891698'), '2026-09-26 09:11:19 (UTC+08:00)')
|
||
eq('今天 / 昨天', `${f.formatDay('2026-09-26T01:11:19Z', now)}/${f.formatDay('2026-09-25T01:11:19Z', now)}`, '今天/昨天')
|
||
const ago = (ms) => f.formatRelative(new Date(now.getTime() - ms), now)
|
||
eq('相对时间:刚刚', ago(30_000), '刚刚')
|
||
eq('相对时间:52 分钟前', ago(52 * 60_000), '52 分钟前')
|
||
eq('相对时间:59.6 分钟进位到小时档', ago(59.6 * 60_000), '1 小时前')
|
||
ok('相对时间:23.6 小时不写「24 小时前」', !/24 小时前/.test(ago(23.6 * 3_600_000)), ago(23.6 * 3_600_000))
|
||
eq('短 id', f.shortId('66a5a6ff0011'), '#66a5a6')
|
||
eq('文件大小:字节', f.formatBytes(820), '820 B')
|
||
eq('文件大小:KB', f.formatBytes(12_700), '12.4 KB')
|
||
eq('文件大小:MB', f.formatBytes(3.1 * 1024 * 1024), '3.1 MB')
|
||
eq('文件大小拿不到写 —', f.formatBytes(undefined), '—')
|
||
eq('短名去掉括号里的补充', f.shortLabel('OpenAI 兼容(DeepSeek、通义…)'), 'OpenAI 兼容')
|
||
|
||
// 状态
|
||
eq('succeeded = 已完成', st.statusLabel('succeeded'), '已完成')
|
||
eq('interrupted + 有审批 = 等待审批', st.statusLabel('interrupted', { pendingApproval: true }), '等待审批')
|
||
eq('interrupted + 没审批 = 已挂起', st.statusLabel('interrupted', { pendingApproval: false }), '已挂起 · 可继续运行')
|
||
eq('suspended', st.statusLabel('suspended'), '已中断(服务重启)')
|
||
eq('未知状态原样写', st.statusLabel('weird'), 'weird')
|
||
const shapes = new Set(['running', 'queued', 'done', 'waiting', 'failed', 'skipped', 'cancelled', 'blocked', 'unreached'].map((s) => st.statusMeta(s).shape))
|
||
eq('九种剪影各不相同', shapes.size, 9)
|
||
// 显示码不能直接当查询参数:waiting / held 在后端都是 interrupted
|
||
eq('waiting → interrupted', st.serverStatusOf('waiting'), 'interrupted')
|
||
eq('held → interrupted', st.serverStatusOf('held'), 'interrupted')
|
||
eq('done → succeeded', st.serverStatusOf('done'), 'succeeded')
|
||
eq('节点状态不是运行状态', st.serverStatusOf('skipped'), null)
|
||
ok('筛选分段的每一档都查得到', st.RUN_STATUS_ORDER.every((c) => st.serverStatusOf(c)),
|
||
st.RUN_STATUS_ORDER.filter((c) => !st.serverStatusOf(c)).join(','))
|
||
|
||
// 快捷键:按本机平台断言
|
||
const mac = k.isMac
|
||
eq('Mod+Enter 的显示', k.formatShortcut('Mod+Enter'), mac ? '⌘⏎' : 'Ctrl+Enter')
|
||
eq('Alt+V 的显示', k.formatShortcut('Alt+V'), mac ? '⌥V' : 'Alt+V')
|
||
eq('aria-keyshortcuts:Mod 按平台', k.ariaShortcut('Mod+K'), mac ? 'Meta+K' : 'Control+K')
|
||
eq('aria-keyshortcuts:Ctrl 写成 Control', k.ariaShortcut('Ctrl+Shift+Enter'), 'Control+Shift+Enter')
|
||
const ev = (o) => ({ key: '', code: '', metaKey: false, ctrlKey: false, altKey: false, shiftKey: false, ...o })
|
||
ok('⌥V 的 e.key 是 √ 也认', k.matchShortcut(ev({ key: '√', code: 'KeyV', altKey: true }), 'Alt+V'))
|
||
ok('多了 Shift 不算', !k.matchShortcut(ev({ key: 's', code: 'KeyS', [k.modKey]: true, shiftKey: true }), 'Mod+S'))
|
||
ok('? 不强求 Shift 一致', k.matchShortcut(ev({ key: '?', code: 'Slash', shiftKey: true }), '?'))
|
||
|
||
// 术语
|
||
eq('human = 人工审批', t.nodeTypeLabel('human'), '人工审批')
|
||
eq('正式运行 v3', t.runClassLabel('formal', 3), '正式运行 v3')
|
||
eq('完整出具', t.issuanceLabel('formal'), '完整出具')
|
||
// 没挂在工作流上的运行:老数据叫「临时图」,界面上一律「未保存的工作流」(3C 返工 D3)
|
||
eq('老数据的「临时图」叫未保存的工作流', t.runName?.({ workflow_id: null, workflow_name: '临时图' }), '未保存的工作流')
|
||
eq('新后端的名字原样认得', t.isUnsaved?.({ workflow_id: null, workflow_name: '未保存的工作流' }), true)
|
||
eq('没有名字也算未保存', t.runName?.({ workflow_id: null, workflow_name: '' }), '未保存的工作流')
|
||
eq('工作流删了留下的运行:还叫原来的名字', t.runName?.({ workflow_id: null, workflow_name: 'sales_daily' }), 'sales_daily')
|
||
eq('挂在工作流上的,名字叫临时图也照写', t.runName?.({ workflow_id: 'w1', workflow_name: '临时图' }), '临时图')
|
||
|
||
// 报错
|
||
const net = e.humanizeError(new ApiError(0, '无法连接服务(服务端可能未启动或正在重启),请稍后重试', { kind: 'network', raw: 'TypeError: Failed to fetch' }))
|
||
ok('网络失败:无法连接服务 + 怎么办', net.title === '无法连接服务' && net.kind === 'network' && net.action, JSON.stringify(net))
|
||
eq('TypeError: Failed to fetch 翻成人话', e.humanizeError(new TypeError('Failed to fetch')).title, '无法连接服务')
|
||
ok('errorMessage 不露 Failed to fetch', !e.errorMessage(new TypeError('Failed to fetch')).includes('Failed to fetch'))
|
||
eq('exit undefined', e.humanizeError('✕ 失败 (exit undefined)').title, '请求未到达沙箱')
|
||
const pyd = e.humanizeError("2 validation errors for GraphSpec\nnodes.1.type\n Input should be 'input'")
|
||
ok('pydantic 原文翻成人话,原文进 raw', pyd.title === '生成的工作流包含无法识别的节点类型' && pyd.raw.includes('GraphSpec'), pyd.title)
|
||
ok('Python 异常类名不进标题', !e.humanizeError("KeyError: 'rows'").title.includes('KeyError'))
|
||
// 老后端的原话(库里的老记录还是这样写的),留着
|
||
const obj = e.humanizeError({ ok: false, error: '连不上对方的服务', hint: '核对地址和端口', detail: 'ConnectError: [Errno 61]' })
|
||
ok('{error, hint, detail}', obj.title === '连不上对方的服务' && obj.action === '核对地址和端口' && obj.raw === 'ConnectError: [Errno 61]', JSON.stringify(obj))
|
||
// 后端现在的原话(core/errors.py 的 NETWORK 一类)
|
||
const objNow = e.humanizeError({ ok: false, error: '无法连接服务:网络不通或服务未启动', hint: '请检查地址和端口,确认服务已启动且本机可以访问', detail: 'ConnectError: [Errno 61]' })
|
||
ok('{error, hint, detail}:后端现在的说法', objNow.title === '无法连接服务:网络不通或服务未启动' && objNow.action === '请检查地址和端口,确认服务已启动且本机可以访问'
|
||
&& objNow.raw === 'ConnectError: [Errno 61]' && objNow.kind !== 'network', JSON.stringify(objNow))
|
||
eq('超时和连不上分开说', e.humanizeError(new ApiError(0, 'x', { kind: 'network', timeoutMs: 5000 })).title, '服务端无响应')
|
||
eq('取消不是错误', e.humanizeError(new DOMException('aborted', 'AbortError')).title, '已取消')
|
||
// 后端回了话,话里提到 fetch failed,说的是它够不着的下游,不是我们连不上它
|
||
const down = e.humanizeError(new ApiError(502, '模型服务连不上:httpx.ConnectError: fetch failed'))
|
||
ok('HTTP 错误里的网络字眼不算连不上服务端', down.kind === 'http' && down.title !== '无法连接服务' && down.status === 502, JSON.stringify(down))
|
||
ok('isNetworkError 对 HTTP 错误为假', !e.isNetworkError(new ApiError(500, 'NetworkError when attempting to fetch resource')))
|
||
ok('测连接结果里的 ECONNREFUSED 不算连不上服务端',
|
||
e.humanizeError({ ok: false, error: 'connect ECONNREFUSED 10.0.0.5:5432' }).title !== '无法连接服务')
|
||
// 已经是「标题:原因」的中文人话,不再套一个「操作超时」、把整句当原因(标题说两遍)
|
||
const zh = e.humanizeError(new ApiError(504, '数据库查询超时:超过 30 秒没有返回,已中断。缩小查询范围后重试。'))
|
||
ok('中文人话里的「超时」不套「操作超时」', zh.title !== '操作超时' && !(zh.reason ?? '').includes('操作超时')
|
||
&& !(zh.reason && zh.reason.includes(zh.title)), JSON.stringify(zh))
|
||
eq('英文 timeout 原文照样翻成「操作超时」', e.humanizeError('ReadTimeout: timed out').title, '操作超时')
|
||
ok('……原因里不带异常类名', !/ReadTimeout/.test(e.humanizeError('ReadTimeout: timed out').reason ?? ''))
|
||
|
||
// 失败说明(lib/explain):记录页、助手流、问数据页共用一份
|
||
const x = window.__ui.lib.explain
|
||
// 下面喂给 explain 的后端原文分两组:老说法是「老运行」样本(库里历史运行的 run.error 存的是它们),
|
||
// 前端要继续认得;紧跟着的「后端现在的说法」取自后端源码当前的原文,两组都得认出来
|
||
const leak = x.explainRunError('模型输出了工具调用的原始标记,但没有真正调用工具。常见原因:节点没有绑定工具,或者模型、服务不支持工具调用。')
|
||
ok('工具调用标记泄漏:说人话、不给接着跑、指到画布', leak.title === '模型未实际调用工具' && !leak.continuable && leak.fix === 'canvas', JSON.stringify(leak))
|
||
// 后端现在的说法(engine/toolcalls.py TOOL_MARKUP_ERROR)
|
||
const leakNow = x.explainRunError('模型以文本形式输出了工具调用的原始标记,未实际调用工具,这一步没有查询到任何数据。'
|
||
+ '常见原因:节点未绑定工具,或模型、服务不支持工具调用。请在画布中为该节点绑定所需工具;如已绑定仍出现此问题,请换用支持工具调用的模型')
|
||
ok('工具调用标记泄漏(后端现在的说法):同样认得', leakNow.title === '模型未实际调用工具' && !leakNow.continuable && leakNow.fix === 'canvas', JSON.stringify(leakNow))
|
||
const tired = x.explainRunError('NodeError: 协作团队用完 4 轮仍未完成:还缺汇总员的结论')
|
||
ok('团队轮数用完:标题带轮数,原因是调度者最后的理由', tired.title === '协作团队用完 4 轮仍未完成'
|
||
&& tired.reason === '还缺汇总员的结论' && !tired.continuable, JSON.stringify(tired))
|
||
const made = x.explainRunError('校验修复无效:修复时出现了原文没有的值:total_count=0')
|
||
ok('校验修复编造数值:点出是哪个值、不给接着跑', made.reason.includes('total_count=0') && !made.continuable, JSON.stringify(made))
|
||
eq('提示词点名的工具没绑定', x.explainRunError('提示词要求用 db_query__shop,但节点没有绑定它').title,
|
||
'提示词要求使用「db_query__shop」,但节点未绑定此工具')
|
||
// 后端现在的说法(engine/schema.py:带「」、成员一种另说)
|
||
eq('提示词点名的工具没绑定(后端现在的说法)', x.explainRunError('提示词要求用「db_query__shop」,但节点没有绑定它').title,
|
||
'提示词要求使用「db_query__shop」,但节点未绑定此工具')
|
||
eq('……成员的提示词点名的工具没绑定', x.explainRunError('成员「研究员」的提示词要求用「db_query__shop」,但没有给这个成员绑定它').title,
|
||
'成员「研究员」的提示词要求使用「db_query__shop」,但该成员未绑定此工具')
|
||
eq('老运行的鉴权失败', x.explainRunError('NodeError: AuthenticationError: Error code: 401 - invalid_api_key').title, '模型鉴权失败')
|
||
// 后端现在的说法(core/errors.py 的 AUTH 一类)
|
||
const authNow = x.explainRunError('鉴权失败(401):服务方拒绝了当前 API Key')
|
||
ok('鉴权失败(后端现在的说法):同样认得、给设置入口', authNow.title === '模型鉴权失败' && authNow.fix === 'settings' && authNow.continuable, JSON.stringify(authNow))
|
||
const miss = x.explainRunError('缺少必填输入:region')
|
||
ok('缺必填输入:给出是哪一项、重新发起', miss.missingInput === 'region' && miss.fix === 'rerun' && !miss.continuable)
|
||
eq('没留原因', x.explainRunError(null).title, '运行失败,未记录原因')
|
||
ok('兜底:句中的异常类名也去掉', !/ValueError/.test(x.explainRunError('结构不对:ValueError: bad').title))
|
||
// 团队轮数用完:后端原话把「先看成员…调大最多轮数」也写进了冒号后面,原因只要理由和
|
||
// 没派到的成员,建议交给 action——否则同样的话在报错里说两遍
|
||
const tiredFull = x.explainRunError('NodeError: 协作团队用完 4 轮仍未完成:还没有查到任何订单数据。一次都没被派到的成员:汇总员。'
|
||
+ '先看成员有没有绑定要用的工具,再调大「最多轮数」;也可以把「用完轮数时」改成降档交付')
|
||
eq('团队轮数用完:原因在「。先看成员」处截断', tiredFull.reason, '还没有查到任何订单数据。一次都没被派到的成员:汇总员')
|
||
ok('……action 里有最多轮数,原因里没有', /最多轮数/.test(tiredFull.action) && !/最多轮数|降档交付/.test(tiredFull.reason), JSON.stringify(tiredFull))
|
||
eq('团队按降档交付的原话:原因在「。按降档交付」处截断',
|
||
x.explainRunError('协作团队用完 3 轮仍未完成:还缺结论。按降档交付:成果是成员最后的原话,不是调度者认可的结论').reason, '还缺结论')
|
||
// 后端现在的说法(engine/nodes/multi.py:「。先看成员」「。按降档交付」两个锚点没变,后半句改了)
|
||
const tiredNow = x.explainRunError('NodeError: 协作团队用完 4 轮仍未完成:还没有查到任何订单数据。一次都没被派到的成员:汇总员。'
|
||
+ '先看成员是否绑定了所需工具,再调大「最多轮数」;也可将「用完轮数时」改为「降档交付」')
|
||
eq('团队轮数用完(后端现在的说法):原因在「。先看成员」处截断', tiredNow.reason, '还没有查到任何订单数据。一次都没被派到的成员:汇总员')
|
||
ok('……action 里有最多轮数,原因里没有', /最多轮数/.test(tiredNow.action) && !/最多轮数|降档交付/.test(tiredNow.reason) && !tiredNow.continuable,
|
||
JSON.stringify(tiredNow))
|
||
eq('团队按降档交付(后端现在的说法):原因在「。按降档交付」处截断',
|
||
x.explainRunError('协作团队用完 3 轮仍未完成:还缺结论。按降档交付:成果取自成员最后的回复,并非调度者认可的结论').reason, '还缺结论')
|
||
// 超时:后端已经给了具体原因和建议的中文原话,不能换成笼统的「下游服务没在限定时间内响应」
|
||
const qto = x.explainRunError('KeyError: 查询超时,数据库没有在 30 秒内返回')
|
||
ok('中文超时原话:原因照原样留下', qto.reason === '查询超时,数据库没有在 30 秒内返回'
|
||
&& !/下游服务/.test(`${qto.reason}${qto.action}`), JSON.stringify(qto))
|
||
const qlong = x.explainRunError('查询超过 30s 没有返回,已放弃等待(数据库那边可能还在跑,连接会在后台收回)。加上 WHERE 条件或 LIMIT 缩小范围再查')
|
||
ok('中文超时原话:后端的建议进 action,原因里不再重复', /WHERE 条件或 LIMIT/.test(qlong.action ?? '')
|
||
&& /查询超过 30s 没有返回/.test(qlong.reason ?? '') && !/WHERE/.test(qlong.reason ?? ''), JSON.stringify(qlong))
|
||
// 后端现在的说法(engine/toolcalls.py ToolTimeout)
|
||
const qlongNow = x.explainRunError('查询超过 30 秒没有返回,已停止等待(数据库可能仍在执行,连接会在后台回收)。请添加 WHERE 条件或 LIMIT 缩小范围后重试')
|
||
ok('中文超时原话(后端现在的说法):建议进 action,原因里不再重复', /WHERE 条件或 LIMIT/.test(qlongNow.action ?? '')
|
||
&& /查询超过 30 秒没有返回/.test(qlongNow.reason ?? '') && !/WHERE/.test(qlongNow.reason ?? '') && qlongNow.continuable, JSON.stringify(qlongNow))
|
||
const unified = x.explainRunError('等待超时:对方没有在限定时间内响应')
|
||
ok('「标题:原因」式的中文超时:拆成标题和原因', unified.title === '等待超时' && unified.reason === '对方没有在限定时间内响应'
|
||
&& unified.continuable, JSON.stringify(unified))
|
||
// 后端现在的说法(core/errors.py 的 TIMEOUT 一类)
|
||
const unifiedNow = x.explainRunError('等待超时:服务方未在限定时间内响应')
|
||
ok('「标题:原因」式的中文超时(后端现在的说法):拆成标题和原因', unifiedNow.title === '等待超时' && unifiedNow.reason === '服务方未在限定时间内响应'
|
||
&& unifiedNow.continuable, JSON.stringify(unifiedNow))
|
||
const en = x.explainRunError('ReadTimeout: timed out')
|
||
ok('英文超时原文照旧给通用说法', en.title === '等待超时' && /限定时间/.test(en.reason ?? '') && !/ReadTimeout/.test(en.reason ?? ''), JSON.stringify(en))
|
||
// 自定义工具的参数定义写坏了:先去工具页改好(直达那一项),改好之后接着跑能过
|
||
const broken = x.explainRunError('自定义工具「lookup_order」的参数定义格式不对:参数 store 要写成 {"type": "string"} 这样的对象,'
|
||
+ '不能直接写 "string"。到「工具」页把它的参数定义改好再运行')
|
||
ok('坏参数定义:点名是哪个工具、fix 指到工具页', broken.fix === 'tools' && broken.title.includes('lookup_order')
|
||
&& /参数 store/.test(broken.reason ?? ''), JSON.stringify(broken))
|
||
ok('……主按钮先去修(fixFirst),直达这一项的编辑', broken.fixFirst === true && broken.continuable
|
||
&& broken.fixTo === '/tools/custom?edit=lookup_order', JSON.stringify(broken))
|
||
ok('……不和「找不到工具」混为一谈', !/找不到/.test(broken.title))
|
||
const brokenObj = x.explainRunError('自定义工具「lookup_order」的参数定义要是一个 JSON 对象。到「工具」页把它的参数定义改好再运行')
|
||
ok('坏参数定义(不是对象)也认得', brokenObj.fix === 'tools' && brokenObj.fixFirst === true, JSON.stringify(brokenObj))
|
||
// 后端把几个坏工具用「;」连成一句(tools/custom.build_tools);单个工具的原因里自己也可能带「;」
|
||
const typeProblem = '参数定义格式不对:参数 n 的类型「int」认不出来,是不是想写 integer;只能是 string、integer、number、boolean、array、object 里的一个'
|
||
const brokenOne = x.explainRunError(`自定义工具「sales_daily_api」的${typeProblem}。到「工具」页把它的参数定义改好再运行`)
|
||
ok('坏参数定义:原因里自带的「;」不把后半句切丢', brokenOne.title.includes('sales_daily_api') && /只能是 string/.test(brokenOne.reason ?? ''),
|
||
JSON.stringify(brokenOne))
|
||
const brokenTwo = x.explainRunError(`ToolBuildError: 自定义工具「lookup_order」的参数定义要是一个 JSON 对象。到「工具」页把它的参数定义改好再运行;`
|
||
+ `自定义工具「sales_daily_api」的${typeProblem}。到「工具」页把它的参数定义改好再运行`)
|
||
ok('两个坏工具:标题说有几个,不只认第一个', brokenTwo.title === '2 个自定义工具的参数定义有误', JSON.stringify(brokenTwo))
|
||
ok('……原因里两个都点名,各自的原因都在', /lookup_order/.test(brokenTwo.reason ?? '') && /sales_daily_api/.test(brokenTwo.reason ?? '')
|
||
&& /JSON 对象/.test(brokenTwo.reason ?? '') && /只能是 string/.test(brokenTwo.reason ?? '') && !/到「工具」页/.test(brokenTwo.reason ?? ''),
|
||
JSON.stringify(brokenTwo))
|
||
ok('……怎么办里也点名两个,直达第一个的编辑', /lookup_order/.test(brokenTwo.action ?? '') && /sales_daily_api/.test(brokenTwo.action ?? '')
|
||
&& brokenTwo.fixTo === '/tools/custom?edit=lookup_order' && brokenTwo.fix === 'tools' && brokenTwo.fixFirst === true && brokenTwo.continuable,
|
||
JSON.stringify(brokenTwo))
|
||
// 后端现在的说法(tools/custom.py:前缀「格式有误」、结尾「到「工具」页修改参数定义后再运行」,几个坏工具仍用「;」连成一句)
|
||
const brokenNow = x.explainRunError('自定义工具「lookup_order」的参数定义格式有误:参数 store 应写成 {"type": "string"} 这样的对象,'
|
||
+ '不能直接写 "string"。到「工具」页修改参数定义后再运行')
|
||
ok('坏参数定义(后端现在的说法):点名是哪个工具、fix 指到工具页、主按钮先去修', brokenNow.fix === 'tools' && brokenNow.title === '自定义工具「lookup_order」的参数定义有误'
|
||
&& /参数 store/.test(brokenNow.reason ?? '') && !/到「工具」页/.test(brokenNow.reason ?? '') && brokenNow.fixFirst === true && brokenNow.continuable
|
||
&& brokenNow.fixTo === '/tools/custom?edit=lookup_order', JSON.stringify(brokenNow))
|
||
const typeProblemNow = '参数定义格式有误:无法识别参数 n 的类型「int」,是否应为 integer;只能是 string、integer、number、boolean、array、object 中的一个'
|
||
const brokenTwoNow = x.explainRunError('ToolBuildError: 自定义工具「lookup_order」的参数定义必须是一个 JSON 对象。到「工具」页修改参数定义后再运行;'
|
||
+ `自定义工具「sales_daily_api」的${typeProblemNow}。到「工具」页修改参数定义后再运行`)
|
||
ok('两个坏工具(后端现在的说法):标题说有几个,两个的原因都在、原因里自带的「;」不切丢', brokenTwoNow.title === '2 个自定义工具的参数定义有误'
|
||
&& /lookup_order/.test(brokenTwoNow.reason ?? '') && /sales_daily_api/.test(brokenTwoNow.reason ?? '') && /JSON 对象/.test(brokenTwoNow.reason ?? '')
|
||
&& /只能是 string/.test(brokenTwoNow.reason ?? '') && !/到「工具」页/.test(brokenTwoNow.reason ?? '')
|
||
&& brokenTwoNow.fixTo === '/tools/custom?edit=lookup_order' && brokenTwoNow.fixFirst === true, JSON.stringify(brokenTwoNow))
|
||
// 超时原话按「;」拆开时,原因的末尾不能挂着半个分号
|
||
const semi = x.explainRunError('调用模型超时(60 秒),对方没有响应;换个时段再试')
|
||
ok('中文超时按「;」拆:原因以句号收尾,不挂「;」', semi.reason === '调用模型超时(60 秒),对方没有响应。' && semi.action === '换个时段再试。',
|
||
JSON.stringify(semi))
|
||
// 3b 起查询的时限归数据源管(查询时限 query_timeout_s),不在画布的节点上
|
||
ok('查询超时、后端没给建议:指到「数据」页的查询时限,不再说调画布里节点的超时',
|
||
/查询时限/.test(qto.action ?? '') && /「数据」页/.test(qto.action ?? '') && /WHERE/.test(qto.action ?? '') && !/画布/.test(qto.action ?? ''),
|
||
JSON.stringify(qto))
|
||
return out
|
||
})) check(name, ok, detail)
|
||
})
|
||
|
||
await section('按后端原文匹配的地方:失败归类认得新旧两种原文,给模型的 for_model 不进界面(文案整改第二轮)', async () => {
|
||
// lib/explain 按 run.error 的原文把失败归类(run.error 没有错误码)。开发库里历史运行存的是整改前的文字,
|
||
// 新运行是整改后的:两种各喂一遍,归类(标题、能不能继续运行、修复入口)要一样。
|
||
// 「新」样本和 backend/app 现在的写法一致(先核对源码片段);「旧」样本是整改前(提交 68fb353)的原话,
|
||
// 只用来证明旧文字仍认得,不要跟着后端改
|
||
const src = (f) => readFileSync(new URL(`../backend/app/${f}`, import.meta.url), 'utf8')
|
||
const SOURCE = [
|
||
['engine/launch.py', 'f"绑定的工具不存在:{lines}{more}。{fix}"'],
|
||
['engine/toolcalls.py', '秒没有返回,已停止等待'],
|
||
['data/engine.py', '秒被中断。请添加 WHERE 条件或 LIMIT 缩小范围。'],
|
||
['core/errors.py', '"等待超时:服务方未在限定时间内响应"'],
|
||
['tools/custom.py', '参数定义格式有误:'],
|
||
['tools/custom.py', '。到「工具」页修改参数定义后再运行'],
|
||
['engine/nodes/io.py', '输出的不是合法 JSON({where}附近),{reason};{fix}'],
|
||
['engine/nodes/io.py', '是{what},放入 JSON 时请写成 {{{{ {expr} | json }}}}(外面不要再加引号)'],
|
||
['engine/toolcalls.py', '模型以文本形式输出了工具调用的原始标记,未实际调用工具'],
|
||
['engine/nodes/multi.py', '协作团队用完 {max_rounds} 轮仍未完成:'],
|
||
['engine/nodes/human.py', '修复时出现了原文没有的值:'],
|
||
['engine/nodes/human.py', 'f"人工驳回:'],
|
||
['core/errors.py', '鉴权失败{code}:服务方拒绝了当前 API Key'],
|
||
['core/errors.py', '"请求过于频繁或额度已用完(429)"'],
|
||
['core/errors.py', '"无法连接服务:网络不通或服务未启动"'],
|
||
['core/errors.py', 'f"服务方内部错误(HTTP {status})"'],
|
||
['core/errors.py', 'f"参数不匹配:不接受名为「{m.group(1)}」的参数"'],
|
||
['tools/registry.py', 'f"找不到工具 {name}"'],
|
||
['engine/schema.py', '至少需要配置一个分支条件'],
|
||
['engine/runner.py', '"服务重启,运行已挂起,可从断点继续运行"'],
|
||
]
|
||
const gone = SOURCE.filter(([f, frag]) => !src(f).includes(frag))
|
||
check('「新」样本的片段在后端源码里都还在(后端改了说法就同步这里的样本和 lib/explain 的正则)', gone.length === 0,
|
||
gone.map(([f, frag]) => `${f}:${frag}`).join(' | '))
|
||
|
||
// [说明, 后端现在的原文, 整改前的原文, 期望标题, 能否继续运行, 修复入口]
|
||
const PAIRS = [
|
||
['绑定的工具不存在(发起时就被拒)',
|
||
'绑定的工具不存在:「取数」绑定的 db_query__erp。请到「数据」页接入,或在节点中重新选择',
|
||
'绑定的工具在本机不存在:「取数」绑的 db_query__erp。去数据页接入,或在节点里重新选',
|
||
'绑定的工具「db_query__erp」不存在', false, 'tools'],
|
||
['查询到点放弃',
|
||
'查询超过 30 秒没有返回,已停止等待(数据库可能仍在执行,连接会在后台回收)。请添加 WHERE 条件或 LIMIT 缩小范围后重试',
|
||
'查询超过 30s 没有返回,已放弃等待(数据库那边可能还在跑,连接会在后台收回)。加上 WHERE 条件或 LIMIT 缩小范围再查',
|
||
'查询超时', true, undefined],
|
||
['数据库按时限停下语句',
|
||
'查询超过 30 秒被中断。请添加 WHERE 条件或 LIMIT 缩小范围。',
|
||
'查询超过 30s 被中断。加上 WHERE 条件或 LIMIT 缩小范围。',
|
||
'查询超时', true, undefined],
|
||
['等待超时(统一的翻译层)', '等待超时:服务方未在限定时间内响应', '等待超时:对方没有在限定时间内响应', '等待超时', true, undefined],
|
||
['自定义工具的参数定义有误',
|
||
'自定义工具「lookup_order」的参数定义格式有误:参数 store 应写成 {"type": "string"} 这样的对象,不能直接写 "string"。到「工具」页修改参数定义后再运行',
|
||
'自定义工具「lookup_order」的参数定义格式不对:参数 store 要写成 {"type": "string"} 这样的对象,不能直接写 "string"。到「工具」页把它的参数定义改好再运行',
|
||
'自定义工具「lookup_order」的参数定义有误', true, 'tools'],
|
||
['上游模型写的 JSON 解析失败',
|
||
'上游「写摘要」输出的不是合法 JSON(第 12 列附近),模型写的文字中包含未转义的英文引号;如需模型输出结构化数据,请配置「结构化输出 Schema」并开启「按出处核对字段」,不要用「数据整形」节点解析模型写的文字',
|
||
'上游「写摘要」输出的不是合法 JSON(第 12 列附近),它写的文字里有没转义的英文引号;要模型给结构化数据就配「结构化输出 Schema」并打开「按出处核对字段」,别用整形节点解析模型写的文字',
|
||
'上游「写摘要」输出的不是合法 JSON(第 12 列附近)', false, 'canvas'],
|
||
['模板渲染出的 JSON 有误、后端点名了那一处',
|
||
'模板渲染出来的不是合法 JSON(第 1 行第 12 列附近)。模板里 {{ nodes.a.text }} 是模型写的文字,放入 JSON 时请写成 {{ nodes.a.text | json }}(外面不要再加引号)',
|
||
'模板渲染出来的不是合法 JSON(第 1 行第 12 列附近)。模板里 {{ nodes.a.text }} 是模型写的文字,放进 JSON 要写成 {{ nodes.a.text | json }}(外面不要再加引号)',
|
||
'模板渲染出来的不是合法 JSON(第 1 行第 12 列附近)', false, 'canvas'],
|
||
['模型没真调工具',
|
||
'模型以文本形式输出了工具调用的原始标记,未实际调用工具,这一步没有查询到任何数据。常见原因:节点未绑定工具,或模型、服务不支持工具调用。',
|
||
'模型输出了工具调用的原始标记,但没有真正调用工具,这一步一次都没查到数据。常见原因:节点没有绑定工具,或者模型、服务不支持工具调用。',
|
||
'模型未实际调用工具', false, 'canvas'],
|
||
['协作团队用完轮数',
|
||
'协作团队用完 3 轮仍未完成:调度者未给出理由。一次都没被派到的成员:汇总员。先看成员是否绑定了所需工具,再调大「最多轮数」;也可将「用完轮数时」改为「降档交付」',
|
||
'协作团队用完 3 轮仍未完成:调度者没有给出理由。一次都没被派到的成员:汇总员。先看成员有没有绑定要用的工具,再调大「最多轮数」;也可以把「用完轮数时」改成降档交付',
|
||
'协作团队用完 3 轮仍未完成', false, 'canvas'],
|
||
['校验修复编出了原文没有的值',
|
||
'结构化校验未通过:修复时出现了原文没有的值:total_count=0', '结构化校验没通过:修复时出现了原文没有的值:total_count=0',
|
||
'校验修复未采用:修复结果中出现了原文没有的值', false, 'canvas'],
|
||
['人工驳回(整改前有一条路径写「人工拒绝」)', '人工驳回:数字对不上', '人工拒绝:数字对不上', '人工审批驳回,运行终止', false, undefined],
|
||
['模型鉴权失败', '鉴权失败(401):服务方拒绝了当前 API Key', '鉴权没通过(401):对方拒绝了这把密钥', '模型鉴权失败', true, 'settings'],
|
||
['限流或额度用完', '请求过于频繁或额度已用完(429)', '请求太频繁,或者额度用完了(429)', '请求过于频繁,或额度已用完', true, undefined],
|
||
['连不上外部服务', '无法连接服务:网络不通或服务未启动', '连不上对方的服务:网络不通,或者服务没有启动', '无法连接外部服务', true, undefined],
|
||
['外部服务 5xx', '服务方内部错误(HTTP 502)', '对方服务出错了(HTTP 502)', '外部服务返回错误', true, undefined],
|
||
['工具参数对不上', '参数不匹配:不接受名为「store」的参数', '参数对不上:它不接受名为「store」的参数', '工具参数不匹配', true, 'canvas'],
|
||
['找不到工具', '找不到工具 lookup_order', "找不到工具 'lookup_order'", '找不到工具「lookup_order」', true, 'tools'],
|
||
['节点配置不完整', '「条件分支」至少需要配置一个分支条件', '「条件分支」至少要配一个分支条件', '节点配置不完整', false, 'canvas'],
|
||
['服务重启挂起', '服务重启,运行已挂起,可从断点继续运行', '服务重启,运行已挂起,可从断点恢复', '服务重启中断了本次运行', true, undefined],
|
||
]
|
||
const got = await page.evaluate(async (pairs) => {
|
||
const x = window.__ui.lib.explain
|
||
const pick = (t) => { const r = x.explainRunError(t); return [r.title, r.continuable, r.fix ?? null] }
|
||
const at = (path) => performance.getEntriesByType('resource').map((e) => e.name)
|
||
.find((n) => { try { return new URL(n).pathname === path } catch { return false } }) ?? path
|
||
const client = await import(at('/src/api/client.ts'))
|
||
const body = client.parseBody(JSON.stringify({
|
||
type: 'report.checked',
|
||
data: { violations: [{ code: 'uncited_number', message: '数字「30」没有出处:报告中直接写出了数字,系统无法核对',
|
||
for_model: '数字「30」没有出处:要写成引用标记(比如 [[m:指标id]]),不能直接写数字' }] },
|
||
issues: [{ level: 'error', message: '报告撰写的上游没有可引用数字的证据来源', for_model: '给模型的改法' }],
|
||
}))
|
||
return {
|
||
pairs: pairs.map(([, now, before]) => [pick(now), pick(before)]),
|
||
restart: pick('这次运行没有可恢复的断点,可能在服务重启前尚未开始执行。请重新发起运行。'),
|
||
pointed: [
|
||
'模板渲染出来的不是合法 JSON(第 1 行第 12 列附近)。模板里 {{ nodes.a.text }} 是模型写的文字,放入 JSON 时请写成 {{ nodes.a.text | json }}(外面不要再加引号)',
|
||
'模板渲染出来的不是合法 JSON(第 1 行第 12 列附近)。模板中的 {{ nodes.a.text }} 位于 JSON 引号内:请改写为 {{ nodes.a.text | json }}(外面不要再加引号)',
|
||
].map((t) => x.explainRunError(t).action),
|
||
body: JSON.stringify(body),
|
||
plain: JSON.stringify(client.parseBody('{"a":[1,{"b":"c"}]}')),
|
||
}
|
||
}, PAIRS.map(([what, now, before]) => [what, now, before])).catch((e) => ({ error: String(e) }))
|
||
if (got.error) { check('lib/explain、api/client 拿得到', false, got.error); return }
|
||
PAIRS.forEach(([what, , , title, continuable, fix], i) => {
|
||
const want = JSON.stringify([title, continuable, fix ?? null])
|
||
const [now, before] = got.pairs[i].map((r) => JSON.stringify(r))
|
||
check(`失败归类:${what}(后端现在的原文)`, now === want, now)
|
||
check(`失败归类:${what}(整改前的原文)`, before === want, before)
|
||
})
|
||
check('模板 JSON 有误、后端点名了那一处(运行时「模板里 {{」、校验「模板中的 {{」):照后端说,不再追加改用结构化输出那句',
|
||
got.pointed.every((a) => a.endsWith('修改后重新运行。') && !a.includes('结构化输出 Schema')), JSON.stringify(got.pointed))
|
||
check('「没有可恢复的断点」虽然提到服务重启,但不归到「可从断点继续运行」', got.restart[0] !== '服务重启中断了本次运行',
|
||
JSON.stringify(got.restart))
|
||
check('响应里给模型的 for_model 在进前端时就去掉,给人看的 message 留着', !got.body.includes('for_model')
|
||
&& !got.body.includes('给模型的改法') && got.body.includes('报告中直接写出了数字,系统无法核对') && got.body.includes('证据来源'), got.body)
|
||
check('……没有 for_model 的响应原样解析', got.plain === '{"a":[1,{"b":"c"}]}', got.plain)
|
||
})
|
||
|
||
await section('lib/evidence:证据状态的四通道元数据、报告节点的叫法、复核守卫', async () => {
|
||
for (const [name, ok, detail] of await page.evaluate(() => {
|
||
const { evidence: ev, terms: t } = window.__ui.lib
|
||
const out = []
|
||
const eq = (name, got, want) => out.push([name, got === want, got === want ? '' : `得到 ${JSON.stringify(got)},应为 ${JSON.stringify(want)}`])
|
||
const ok = (name, cond, detail = '') => out.push([name, !!cond, cond ? '' : detail])
|
||
const codes = ev.EVIDENCE_STATES
|
||
// 方案 6.1 的八种(确定性、概率性四种、无证据、连接性、旧运行候选),加三期无证据的两个变体:
|
||
// 可疑实体(疑似不存在的名称)、无法核实(表结构快照不全),加裁判拆档的两种:证据相矛盾、证据不足
|
||
ok('十二种状态一种不少', ['deterministic', 'supported', 'partial', 'contradicted', 'insufficient', 'unsupported', 'unjudged', 'none',
|
||
'connective', 'candidate', 'suspect', 'unverified'].every((c) => codes.includes(c)) && codes.length === 12, codes.join(','))
|
||
eq('可疑实体:点状线(同无证据)、?!、疑似不存在的名称', `${ev.EVIDENCE_STATE.suspect.line}/${ev.EVIDENCE_STATE.suspect.glyph}/${ev.EVIDENCE_STATE.suspect.label}`,
|
||
'dotted/?!/疑似不存在的名称')
|
||
ok('可疑实体醒目(进 n / N),核对不了只是标注', ev.EVIDENCE_STATE.suspect.alert && !ev.EVIDENCE_STATE.unverified.alert)
|
||
eq('片段状态:issue 为 unknown_entity 的是可疑实体', ev.segmentState({ kind: 'entity', state: 'none', issue: 'unknown_entity' }), 'suspect')
|
||
eq('片段状态:[[t:编造]] 的 cite 带 unknown 也是可疑实体', ev.segmentState({ kind: 'entity', state: 'none', cite: { unknown: true } }), 'suspect')
|
||
eq('片段状态:unverified_entity 是核对不了', ev.segmentState({ kind: 'entity', state: 'none', issue: 'unverified_entity' }), 'unverified')
|
||
eq('片段状态:原话对不上的引文仍是无证据', ev.segmentState({ kind: 'quote', state: 'none', issue: 'unresolved_ref' }), 'none')
|
||
ok('实体、引文另有字形和说法,线型颜色同「有出处」', ev.EVIDENCE_KIND_STYLE.entity.line === 'solid' && ev.EVIDENCE_KIND_STYLE.quote.line === 'solid'
|
||
&& ev.EVIDENCE_KIND_STYLE.quote.glyph === '“' && ev.EVIDENCE_KIND_STYLE.entity.color === ev.EVIDENCE_STATE.deterministic.color
|
||
&& /^var\(--st-[\w-]+\)$/.test(ev.EVIDENCE_KIND_STYLE.quote.color))
|
||
eq('章的两个数:引用 = 有出处的数字、值、实体、引文;无证据含可疑实体,require_citation 时加没挂依据的结论句',
|
||
JSON.stringify(ev.stampCounts({ numbers: 4, numbers_cited: 3, values: 1, entities: 5, quotes: 1, uncited_numbers: 1, unresolved: 1,
|
||
unknown_entities: 2, uncited_claims: 1 }, 'require_citation')), JSON.stringify({ cited: 10, none: 5 }))
|
||
eq('……结论句策略是 off 时不算没挂依据的结论句', ev.stampCounts({ numbers: 4, numbers_cited: 3, uncited_numbers: 1, unresolved: 0,
|
||
uncited_claims: 3 }, 'off')?.none, 1)
|
||
eq('……judge 在挂依据上和 require_citation 一样严:没挂依据的结论句也算', ev.stampCounts({ numbers: 4, numbers_cited: 3,
|
||
uncited_numbers: 1, unresolved: 0, uncited_claims: 3 }, 'judge')?.none, 4)
|
||
eq('可疑实体的提示:目录键、{name, table} 都写成去掉前缀的名字,最多 3 个',
|
||
ev.closestNames(['c:orders.amount', { alias: 't:refunds' }, { name: 'region', table: 'orders', kind: 'column' }, 'c:x']).join(','),
|
||
'orders.amount,refunds,orders.region')
|
||
const lines = new Set(['solid', 'dotted', 'badge', 'none'])
|
||
const bad = codes.filter((c) => {
|
||
const m = ev.EVIDENCE_STATE[c]
|
||
return !m || m.code !== c || !m.label || !lines.has(m.line) || typeof m.glyph !== 'string'
|
||
|| !/^var\(--st-[\w-]+\)$/.test(m.color) || !m.decoration || !/^var\(--st-[\w-]+\)$/.test(m.soft) || !m.hint
|
||
})
|
||
ok('每种都有线型、字形、颜色、文字(颜色只用 --st-*)', !bad.length, bad.join(','))
|
||
ok('下划线颜色也只取 --st-*(或透明)', codes.every((c) => {
|
||
const d = ev.EVIDENCE_STATE[c].decoration
|
||
return d === 'transparent' || /var\(--st-[\w-]+\)/.test(d)
|
||
}), codes.map((c) => ev.EVIDENCE_STATE[c].decoration).join(' | '))
|
||
// 裁判拆档:旧取值「证据不支持」按规格和证据相矛盾长得一样(同一类判定,拆档前后的叫法不同,文字仍不同),
|
||
// 除它之外线型 + 字形两两不同
|
||
const pairOf = (c) => `${ev.EVIDENCE_STATE[c].line}/${ev.EVIDENCE_STATE[c].glyph}`
|
||
const pairs = codes.filter((c) => c !== 'unsupported').map(pairOf)
|
||
ok('去掉颜色也分得开:线型 + 字形两两不同(旧的证据不支持同证据相矛盾)', new Set(pairs).size === codes.length - 1
|
||
&& pairOf('unsupported') === pairOf('contradicted'), codes.map(pairOf).join(' '))
|
||
const labels = codes.map((c) => ev.EVIDENCE_STATE[c].label)
|
||
ok('文字两两不同', new Set(labels).size === codes.length, labels.join(' '))
|
||
eq('确定性:细实线、有出处', `${ev.EVIDENCE_STATE.deterministic.line}/${ev.EVIDENCE_STATE.deterministic.label}`, 'solid/有出处')
|
||
eq('无证据:点状线、?、无证据', `${ev.EVIDENCE_STATE.none.line}/${ev.EVIDENCE_STATE.none.glyph}/${ev.EVIDENCE_STATE.none.label}`, 'dotted/?/无证据')
|
||
ok('概率性的「有依据」不用确定性的绿', ev.EVIDENCE_STATE.supported.color !== ev.EVIDENCE_STATE.deterministic.color)
|
||
eq('本期只会出现确定性和无证据', codes.filter((c) => ev.EVIDENCE_STATE[c].phase === 1).join(','), 'deterministic,none')
|
||
ok('只有异常态醒目(进 n / N 的跳转)', !ev.EVIDENCE_STATE.deterministic.alert && ev.EVIDENCE_STATE.none.alert)
|
||
eq('片段状态:deterministic', ev.segmentState({ kind: 'number', state: 'deterministic' }), 'deterministic')
|
||
eq('片段状态:结构片段不画', ev.segmentState({ kind: 'structural', state: 'none' }), null)
|
||
eq('片段状态:文字不画', ev.segmentState({ kind: 'text', state: 'neutral' }), null)
|
||
eq('片段状态:probabilistic 裁判前按未裁判画', ev.segmentState({ kind: 'text', state: 'probabilistic' }), 'unjudged')
|
||
|
||
eq('报告节点叫「报告撰写」', t.nodeTypeLabel('report'), '报告撰写')
|
||
eq('计数的说法:无证据 = 总数 − 有出处,算得平', t.evidenceTally(7, 12), '7/12 数字有出处 · 无证据 5')
|
||
eq('不是数字的无法解析的引用另起一句', t.evidenceTally(7, 12, 1), '7/12 数字有出处 · 无证据 5 · 另有 1 处引用无法解析')
|
||
eq('数字全有出处', t.evidenceTally(1234, 1234, 0), '1,234 个数字都有出处')
|
||
eq('数字全有出处、另有引用无法解析', t.evidenceTally(3, 3, 2), '3 个数字都有出处 · 另有 2 处引用无法解析')
|
||
eq('一个数字都没有、只有无法解析的引用', t.evidenceTally(0, 0, 1), '1 处引用无法解析')
|
||
eq('什么都没有时给空串', t.evidenceTally(0, 0, 0), '')
|
||
|
||
const out1 = { 周报: 'x', _evidence: { report_node: 'write', doc_artifact: 'abc', fields: ['周报'],
|
||
others: [{ report_node: 'w2', doc_artifact: 'def', fields: ['附录'] }] } }
|
||
const f = ev.evidenceFields(out1)
|
||
ok('_evidence 的字段和 others 都认', f.get('周报')?.artifact === 'abc' && f.get('附录')?.report === 'w2', JSON.stringify([...f]))
|
||
eq('缺 doc_artifact 的标注当没有', ev.evidenceFields({ _evidence: { fields: ['a'] } }).size, 0)
|
||
const review = { verdict: 'rewritten', note: '有缺口', answer: '改写后的', original: '原文', retry: false, severity: 'degraded', signals: [] }
|
||
const guarded = ev.guardReview(review, out1)
|
||
ok('成果带证据:复核只加说明,不改写', guarded.answer === null && guarded.original === null && guarded.verdict === 'annotated'
|
||
&& guarded.note === '有缺口', JSON.stringify(guarded))
|
||
ok('成果不带证据:复核照旧可以改写', ev.guardReview(review, { answer: 'x' }).answer === '改写后的')
|
||
eq('出处不唯一的说法', ev.matchedSource({ token: '0', metric: null, candidates: ['a', 'b'], ambiguous: true }).text, '出处不唯一:候选 a、b')
|
||
eq('出处唯一的照写指标', ev.matchedSource({ token: '12', metric: 'gmv' }).text, 'gmv')
|
||
const cp = ev.codePointIndex('📦 15 单')
|
||
eq('码点偏移换成 UTF-16:emoji 后面差一位', cp(2), 3)
|
||
eq('没有 emoji 时原样', ev.codePointIndex('abc')(2), 2)
|
||
const doc = window.__ui.evidenceFixture.doc
|
||
const tally = ev.docTally(doc)
|
||
const k = (x) => `${x.cited}/${x.total}/${x.none}/${x.other}/${x.hidden}/${x.structural}/${x.noSegment}`
|
||
eq('文档计数(有出处/总数/无证据/非数字引用/画不了线/其中结构/其中没对应字)', k(tally), '7/12/5/1/1/1/0')
|
||
const { stats: _s, ...bare } = doc
|
||
eq('没有 stats 时从片段和违规清单数,口径一样', k(ev.docTally(bare)), '7/12/5/1/1/1/0')
|
||
const { violations: _v, ...noList } = doc
|
||
eq('有 stats、没有违规清单:非数字引用由 stats 反推', `${ev.docTally(noList).none}/${ev.docTally(noList).other}`, '5/1')
|
||
eq('只有 stats(report.checked 的载荷):同一种算法', JSON.stringify(ev.statsTally(doc.stats)),
|
||
JSON.stringify({ total: 12, cited: 7, none: 5, other: 1 }))
|
||
eq('载荷缺字段:不给计数', ev.statsTally({ uncited_numbers: 1 }), null)
|
||
// 句末依据里写错的引用:违规不指任何片段,和列表序号里的数字分开数、分开说
|
||
const withSee = { ...doc, stats: { ...doc.stats, unresolved: 4, violations: 7 },
|
||
violations: [...doc.violations, { code: 'unresolved_ref', message: '依据 [[see:m:nope]] 无法解析', ref: 'm:nope', unit: 'u1' }] }
|
||
const t2 = ev.docTally(withSee)
|
||
eq('句末依据的引用:算进非数字引用、算进「没对应字」', k(t2), '7/12/5/2/2/1/1')
|
||
const sum = ev.tallySummary(t2, true)
|
||
ok('读屏摘要:两种画不了线的分开说在哪', sum.includes('其中 1 个数字位于列表序号') && sum.includes('1 处在正文中没有对应文本')
|
||
&& sum.includes('句末依据'), sum)
|
||
ok('读屏摘要:只有结构片段时不提「句末依据」', !ev.tallySummary(tally, true).includes('句末依据'), ev.tallySummary(tally, true))
|
||
|
||
// 封存那一行:顺序是 正文不是封存那份 → 没拿到 → 没封存 → 核对失败 → 不在范围 → 通过
|
||
const sv = (seal, opts) => { const v = ev.sealVerdict(seal, opts); return `${v.status}:${v.label}` }
|
||
eq('封存被改过(covered 必然也是 false):失败,不是「不在封存范围内」', sv({ sealed: true, ok: false, covered: false }),
|
||
'failed:已封存 · 核对不一致')
|
||
eq('没封存:灰「尚未封存」', sv({ sealed: false, ok: null, covered: false }), 'idle:尚未封存')
|
||
eq('封存完好、这件不在台账里:琥珀', sv({ sealed: true, ok: true, covered: false }), 'waiting:不在封存范围内')
|
||
eq('都过了:绿', sv({ sealed: true, ok: true, covered: true }), 'done:已封存 · 核对一致')
|
||
ok('正文不是封存的那份:再完好的封存也判失败', sv({ sealed: true, ok: true, covered: true }, { foreign: true }).startsWith('failed:'))
|
||
ok('没有证据的片段:落到报告文档上', sv({ sealed: true, ok: true, covered: true }, { docOnly: true }).includes('报告文档已封存'))
|
||
ok('链没取到、封存状态从证据图查的:只说文档封存了', sv({ sealed: true, ok: true }, { viaGraph: true }).includes('未能获取本段的证据链'))
|
||
eq('逐项复核:没封存时不报「不在封存范围内」', ev.integrityFailures({ sealed: false, hash_ok: true }, { sealed: false }).join(','), '')
|
||
eq('逐项复核:封存被改过时也不报', ev.integrityFailures({ sealed: false }, { sealed: true, ok: false }).join(','), '')
|
||
eq('逐项复核:封存完好时照报', ev.integrityFailures({ sealed: false, hash_ok: false }, { sealed: true, ok: true }).join(','), 'hash,sealed')
|
||
|
||
// 证据接口答的是不是正文这份
|
||
const seg = { id: 's8', text: '8.7%' }
|
||
const mine = { artifact: 'aaa', node: 'write', seg }
|
||
eq('同一份:不算外来', ev.docForeign({ report: { doc_artifact: 'aaa', node_id: 'write' }, segment: { id: 's8', text: '8.7%' } }, mine), null)
|
||
ok('文档工件 id 不同:外来', !!ev.docForeign({ report: { doc_artifact: 'bbb' }, segment: { id: 's8', text: '8.7%' } }, mine))
|
||
eq('这个位置的字不同:外来,并记下封存那份写的是什么',
|
||
ev.docForeign({ report: { doc_artifact: 'aaa' }, segment: { id: 's8', text: '9.9%' } }, mine)?.sealedText, '9.9%')
|
||
eq('接口没报文档 id(老后端):只比字', ev.docForeign({ segment: { text: '8.7%' } }, mine), null)
|
||
eq('证据图兜底:封存的报告里有正文这份', ev.graphDoc({ reports: [{ doc_artifact: 'aaa' }] }, 'aaa'), 'ok')
|
||
eq('证据图兜底:没有正文这份', ev.graphDoc({ reports: [{ doc_artifact: 'bbb' }] }, 'aaa'), 'foreign')
|
||
eq('证据图兜底:有,但哈希对不上', ev.graphDoc({ reports: [{ doc_artifact: 'aaa', hash_ok: false }] }, 'aaa'), 'tampered')
|
||
return out
|
||
})) check(name, ok, detail)
|
||
})
|
||
|
||
await section('lib/evidence:结论句裁判(四期)——判定的徽标、计数、答复形状、改写一次', async () => {
|
||
for (const [name, ok, detail] of await page.evaluate(() => {
|
||
const { evidence: ev, terms: t } = window.__ui.lib
|
||
const fj = window.__ui.evidenceJudge
|
||
const out = []
|
||
const eq = (name, got, want) => out.push([name, got === want, got === want ? '' : `得到 ${JSON.stringify(got)},应为 ${JSON.stringify(want)}`])
|
||
const ok = (name, cond, detail = '') => out.push([name, !!cond, cond ? '' : detail])
|
||
const S = ev.EVIDENCE_STATE
|
||
ok('概率性四种都挂在句末徽标上(line=badge),字形 ◆ ◇ ! ?', ['supported', 'partial', 'unsupported', 'unjudged']
|
||
.map((c) => `${S[c].line}/${S[c].glyph}`).join(' ') === 'badge/◆ badge/◇ badge/! badge/?',
|
||
['supported', 'partial', 'unsupported', 'unjudged'].map((c) => `${S[c].line}/${S[c].glyph}`).join(' '))
|
||
// 颜色令牌照方案 6.1:支持用强调色(--st-running 就是 --accent)、部分用 --st-waiting、不支持用 --st-failed、
|
||
// 未裁判用暗字色(--st-cancelled 就是 --text-dim)。四种都不是确定性的绿
|
||
eq('四种的颜色令牌', ['supported', 'partial', 'unsupported', 'unjudged'].map((c) => S[c].color).join(' '),
|
||
'var(--st-running) var(--st-waiting) var(--st-failed) var(--st-cancelled)')
|
||
ok('概率性四种都不用确定性的绿(--st-done)', ['supported', 'partial', 'unsupported', 'unjudged']
|
||
.every((c) => !S[c].color.includes('--st-done') && !S[c].soft.includes('--st-done')))
|
||
ok('证据不支持、部分支持醒目(进 n / N),有依据、未裁判安静', S.unsupported.alert && S.partial.alert && !S.supported.alert && !S.unjudged.alert)
|
||
eq('未裁判的叫法不说「有引用」(没挂依据的结论句也会没判)', S.unjudged.label, '未裁判')
|
||
eq('判定 → 徽标:不是结论句不挂', [ev.verdictState({ status: 'supported' }), ev.verdictState({ status: 'not_a_claim' }),
|
||
ev.verdictState({ status: 'unjudged' }), ev.verdictState({ status: 'maybe' }), ev.verdictState(undefined)].join(','),
|
||
'supported,,unjudged,,')
|
||
eq('封存后追加的判定盖过文档里的「未裁判 · 按需」', ev.unitVerdict({ id: 'u2', verdict: { status: 'unjudged', reason: 'on_demand' } },
|
||
{ u2: { status: 'unsupported', post_seal: true } }).status, 'unsupported')
|
||
eq('没有追加的用文档里的', ev.unitVerdict({ id: 'u2', verdict: { status: 'partial' } }, { u9: { status: 'supported' } }).status, 'partial')
|
||
eq('到上限没判的认得出是哪个上限', ev.limitOf({ status: 'unjudged', reason: 'daily_max_usd' }), 'daily_max_usd')
|
||
eq('按需、出错不是上限', `${ev.limitOf({ status: 'unjudged', reason: 'on_demand' })}|${ev.limitOf({ status: 'unjudged', reason: 'error' })}`, 'null|null')
|
||
ok('判过的四种(不含未裁判)', ev.isJudged({ status: 'not_a_claim' }) && ev.isJudged({ status: 'partial' }) && !ev.isJudged({ status: 'unjudged' }))
|
||
const k = (c) => (c ? `${c.total}:${c.supported}/${c.partial}/${c.unsupported}/${c.unjudged}/${c.uncited}` : 'null')
|
||
eq('正式运行(节点里判好的):不是结论句不算,4 句', k(ev.claimCounts(fj.formal.doc)), '4:1/1/1/1/0')
|
||
eq('探索运行按需:5 句都未裁判', k(ev.claimCounts(fj.explore.doc)), '5:0/0/0/5/0')
|
||
eq('没开裁判、也没按需判过:不数(横幅照旧)', k(ev.claimCounts(fj.plain.doc)), 'null')
|
||
// 开了裁判的文档里预筛放掉的结论句(没有判定、列在 judge.screened 里)不算进结论句,也不算「无证据」
|
||
eq('开了裁判:预筛放掉的结论句不数', k(ev.claimCounts({ judge: { mode: 'inline', screened: ['u9'] }, blocks: [{ type: 'paragraph',
|
||
units: [{ id: 'u9', kind: 'claim', cites: [], segments: [] }, { id: 'u1', kind: 'claim', cites: ['Q1'], segments: [],
|
||
verdict: { status: 'supported' } }] }] })), '1:1/0/0/0/0')
|
||
eq('没开裁判、按需判过一句:挂了依据的算未裁判,没挂的算无证据', k(ev.claimCounts(fj.plain.doc,
|
||
{ [fj.plain.units.bad]: { status: 'unsupported', post_seal: true } })), '5:0/0/1/3/1')
|
||
eq('docTally 带上结论句计数', k(ev.docTally(fj.formal.doc).claims), '4:1/1/1/1/0')
|
||
// 问数据页、画布右栏的横幅走的是这条路:EvidenceField 把判定叠进文档(withVerdicts)交出去,Output 再
|
||
// docTally——这时没有单独的 overlay,判定写在 unit.verdict 上,也得认得出「按需判过」
|
||
eq('没开裁判、按需判过一句:叠好判定的文档交给 docTally 照样数(横幅的路径)',
|
||
k(ev.docTally(ev.withVerdicts(fj.plain.doc, { [fj.plain.units.bad]: { status: 'unsupported', post_seal: true } })).claims),
|
||
'5:0/0/1/3/1')
|
||
eq('叠上判定给横幅数:只换判过的那几句', k(ev.claimCounts(ev.withVerdicts(fj.explore.doc,
|
||
{ [fj.explore.units.bad]: { status: 'unsupported', post_seal: true } }))), '5:0/0/1/4/0')
|
||
ok('叠上判定不改原文档', ev.withVerdicts(fj.explore.doc, { [fj.explore.units.bad]: { status: 'unsupported' } }) !== fj.explore.doc
|
||
&& fj.explore.doc.blocks.flatMap((b) => b.units).find((u) => u.id === fj.explore.units.bad).verdict.status === 'unjudged')
|
||
eq('计数的说法:和数字那一段同一种', t.claimTally({ total: 4, supported: 1, partial: 1, unsupported: 1, unjudged: 1, uncited: 0 }),
|
||
'结论 4 句(有依据 1 · 部分有依据 1 · 证据不支持 1 · 未裁判 1)')
|
||
eq('……方案的例子', t.claimTally({ total: 4, supported: 3, partial: 0, unsupported: 0, unjudged: 0, uncited: 1 }), '结论 4 句(有依据 3 · 无证据 1)')
|
||
eq('……没有结论句给空串', t.claimTally({ total: 0, supported: 0, partial: 0, unsupported: 0, unjudged: 0, uncited: 0 }), '')
|
||
const unit = fj.formal.doc.blocks.flatMap((b) => b.units).find((u) => u.id === fj.formal.units.bad)
|
||
eq('句末徽标的 aria-label:句子、判定写全', ev.claimLabel(unit, unit.verdict), '结论句「增长主要来自新客首单,老客复购持平。」,模型判断:证据不支持')
|
||
ok('……封存后追加的另说', ev.claimLabel(unit, { ...unit.verdict, post_seal: true }).endsWith(',封存后追加'))
|
||
ok('……到上限没判的说「已达上限」', ev.claimLabel(unit, { status: 'unjudged', reason: 'max_cost_usd' }).endsWith('未裁判:已达上限'))
|
||
// 按需裁判接口的答复:evidence.judged 的载荷({verdicts: {u: …}}),也认列表;缺 post_seal 的记 true,缺 judge 的补模型
|
||
const a = ev.judgedVerdicts({ verdicts: { u4: { status: 'supported', rationale: '对' } }, model: 'jm' })
|
||
ok('答复 {verdicts: {u: …}}:补上模型、记封存后追加', a.u4?.status === 'supported' && a.u4.judge === 'jm' && a.u4.post_seal === true,
|
||
JSON.stringify(a))
|
||
const b = ev.judgedVerdicts({ verdicts: [{ unit: 'u2', verdict: 'partial', judge: 'x', post_seal: false }, { unit: 'u3' }] })
|
||
ok('答复是列表、判定写在 verdict 里:照认;没有判定的丢掉;接口给了 post_seal 就照它', b.u2?.status === 'partial' && b.u2.post_seal === false
|
||
&& !('u3' in b), JSON.stringify(b))
|
||
eq('认不出的答复给空表', Object.keys(ev.judgedVerdicts('oops')).length + Object.keys(ev.judgedVerdicts({ verdicts: 3 })).length, 0)
|
||
const g = ev.graphVerdicts({ reports: [{ node_id: 'w1', post_seal_verdicts: { u1: { status: 'supported' } } },
|
||
{ node_id: 'w2', post_seal_verdicts: { u1: { status: 'unsupported' } } }] }, 'w2')
|
||
ok('证据图里按报告取封存后追加的判定(reports[].post_seal_verdicts)', g.u1?.status === 'unsupported' && g.u1.post_seal === true, JSON.stringify(g))
|
||
const applied = { judge: { rewrite: { applied: true, units: ['u1', null], sentences: ['原句甲', '原句乙'], changed: ['u5'], reason: null } } }
|
||
ok('改写一次采用了:changed 里的句子给出交回改写的原句(units 为 null 的那几句)',
|
||
JSON.stringify(ev.rewriteOf(applied, 'u5')) === JSON.stringify({ kind: 'changed', sentences: ['原句乙'] }), JSON.stringify(ev.rewriteOf(applied, 'u5')))
|
||
eq('……没改的句子不标', ev.rewriteOf(applied, 'u1'), null)
|
||
const rejected = { judge: { rewrite: { applied: false, units: ['u2'], sentences: ['原句'], changed: [], reason: '改写稿新增了 1 处原稿没有的问题(无出处数字),未予采用' } } }
|
||
eq('改写稿没采用:交回过的句子写明原因', ev.rewriteOf(rejected, 'u2')?.kind, 'rejected')
|
||
eq('……没交回的不标', ev.rewriteOf(rejected, 'u3'), null)
|
||
// 设置页:设成不限时写明还受什么约束;别的也不限时照实少说
|
||
const all = { claims: true, cost: true, timeout: true, click: true, daily: true }
|
||
eq('不限的说法:每日', t.judgeUnlimitedText('daily', all), '不设上限,每日费用仅受每份报告的金额上限和每次点击的金额上限约束')
|
||
eq('……每份报告的金额', t.judgeUnlimitedText('cost', all), '不设上限,费用仅受句数上限、时长上限、每日上限约束')
|
||
ok('……每日、每份、每次都不限:说到底只受调用次数约束', t.judgeUnlimitedText('daily', { ...all, cost: false, click: false }).includes('调用次数'))
|
||
ok('……每日不限时每份报告的金额只剩句数、时长', !t.judgeUnlimitedText('cost', { ...all, daily: false }).includes('每日'))
|
||
return out
|
||
})) check(name, ok, detail)
|
||
})
|
||
|
||
await section('lib/evidence:裁判拆档(JR)——证据相矛盾、证据不足、计数、跳转轮次、模型提醒、审计行、讲方法的句子', async () => {
|
||
for (const [name, ok, detail] of await page.evaluate(() => {
|
||
const { evidence: ev, terms: t } = window.__ui.lib
|
||
const fv = window.__ui.evidenceVerdict
|
||
const out = []
|
||
const eq = (name, got, want) => out.push([name, got === want, got === want ? '' : `得到 ${JSON.stringify(got)},应为 ${JSON.stringify(want)}`])
|
||
const ok = (name, cond, detail = '') => out.push([name, !!cond, cond ? '' : detail])
|
||
const S = ev.EVIDENCE_STATE
|
||
eq('证据相矛盾、证据不足:句末徽标、字形 ! ○', ['contradicted', 'insufficient'].map((c) => `${S[c].line}/${S[c].glyph}`).join(' '),
|
||
'badge/! badge/○')
|
||
eq('……颜色令牌:证据相矛盾同原来的证据不支持(--st-failed),证据不足中性(--st-cancelled)',
|
||
['contradicted', 'insufficient', 'unsupported'].map((c) => S[c].color).join(' '), 'var(--st-failed) var(--st-cancelled) var(--st-failed)')
|
||
ok('……证据不足不用红、不用提醒色,字形不和未裁判的 ? 撞', !/--st-(failed|waiting|done)/.test(S.insufficient.color + S.insufficient.soft)
|
||
&& S.insufficient.glyph !== S.unjudged.glyph, JSON.stringify(S.insufficient))
|
||
eq('叫法:证据相矛盾、证据不足;旧取值照旧叫证据不支持', [S.contradicted.label, S.insufficient.label, S.unsupported.label].join(' / '),
|
||
'模型判断:证据相矛盾 / 模型判断:证据不足 / 模型判断:证据不支持')
|
||
eq('判定 → 徽标:新取值各是各的', [ev.verdictState({ status: 'contradicted' }), ev.verdictState({ status: 'insufficient' }),
|
||
ev.verdictState({ status: 'unsupported' })].join(','), 'contradicted,insufficient,unsupported')
|
||
ok('判过的算上新取值(不再给「请模型判断」)', ev.isJudged({ status: 'contradicted' }) && ev.isJudged({ status: 'insufficient' }))
|
||
eq('n / N 的轮次:证据相矛盾、旧的证据不支持、部分有依据、无证据在第 1 轮,证据不足在第 2 轮,有依据、未裁判不进',
|
||
['contradicted', 'unsupported', 'partial', 'none', 'suspect', 'insufficient', 'supported', 'unjudged'].map((c) => ev.alertTier(c)).join(''),
|
||
'11111200')
|
||
ok('整句铺浅底的是证据相矛盾和旧的证据不支持', ev.conflicting('contradicted') && ev.conflicting('unsupported')
|
||
&& !ev.conflicting('insufficient') && !ev.conflicting('partial'))
|
||
eq('证据不足缺什么:只认 insufficient 的 missing', [ev.verdictMissing({ status: 'insufficient', missing: ' SQL ' }),
|
||
ev.verdictMissing({ status: 'contradicted', missing: 'SQL' }), ev.verdictMissing({ status: 'insufficient' })].join('|'), 'SQL||')
|
||
const k = (c) => (c ? `${c.total}:${c.supported}/${c.partial}/${c.contradicted}/${c.unsupported}/${c.insufficient}/${c.unjudged}/${c.uncited}` : 'null')
|
||
eq('文档计数:证据相矛盾 2、证据不足 1,不是结论句不算', k(ev.claimCounts(fv.formal.doc)), '6:1/1/2/0/1/1/0')
|
||
eq('……叠上封存后追加的判定(旧取值 unsupported 照样计入)', k(ev.claimCounts(fv.explore.doc,
|
||
ev.graphVerdicts(fv.explore.graph, 'write'))), '7:0/0/0/1/1/5/0')
|
||
eq('stats / counts → 计数(report.checked、报告节点的章):旧数据的 unsupported 照样计入,不是结论句不算',
|
||
k(ev.claimCountsOf({ supported: 1, partial: 1, contradicted: 2, insufficient: 1, unsupported: 1, not_a_claim: 3, unjudged: 1,
|
||
numbers: 9 })), '7:1/1/2/1/1/1/0')
|
||
eq('……缺键、不是数的当 0', k(ev.claimCountsOf({ supported: 2, unsupported: 'x' })), '2:2/0/0/0/0/0/0')
|
||
eq('计数的说法:证据相矛盾、证据不支持、证据不足各写各的', t.claimTally(ev.claimCountsOf({ supported: 1, contradicted: 2, unsupported: 1,
|
||
insufficient: 1, unjudged: 1 })), '结论 6 句(有依据 1 · 证据相矛盾 2 · 证据不支持 1 · 证据不足 1 · 未裁判 1)')
|
||
eq('……旧的计数对象(没有新键)照旧能说', t.claimTally({ total: 2, supported: 1, partial: 0, unsupported: 1, unjudged: 0, uncited: 0 }),
|
||
'结论 2 句(有依据 1 · 证据不支持 1)')
|
||
eq('有问题的句数(横幅、证据条的「定位下一处」):证据相矛盾、旧的证据不支持、部分有依据、证据不足', ev.claimProblems(ev.claimCountsOf(
|
||
{ supported: 5, contradicted: 1, unsupported: 1, partial: 1, insufficient: 1, unjudged: 3 })), 4)
|
||
const unit = fv.formal.doc.blocks.flatMap((b) => b.units).find((u) => u.id === fv.formal.units.insuff)
|
||
eq('读屏名:证据不足接着说缺什么', ev.claimLabel(unit, unit.verdict),
|
||
`结论句「增长主要来自新客首单,老客复购持平。」,模型判断:证据不足,缺少:${fv.missing}`)
|
||
const sealed = fv.formal.doc.blocks.flatMap((b) => b.units).find((u) => u.id === fv.formal.units.contra).verdict
|
||
const f1 = ev.judgeFlags(sealed, { doc: fv.formal.doc.judge })
|
||
ok('模型提醒:节点里当场判的看报告文档的 judge(same_model 真、priced 假)', f1.sameModel && f1.unpriced && f1.writer === fv.model, JSON.stringify(f1))
|
||
const late = { status: 'insufficient', post_seal: true, judge: 'm' }
|
||
const f2 = ev.judgeFlags(late, { doc: { same_model: true, priced: false }, meta: { same_model: false, priced: true } })
|
||
ok('……封存后追加的不看报告文档,看证据图里最近一次按需裁判(judge_meta)', !f2.sameModel && !f2.unpriced, JSON.stringify(f2))
|
||
const f3 = ev.judgeFlags(late, { reply: { same_model: true, priced: false }, meta: { same_model: false, priced: true } })
|
||
ok('……这一次的答复盖过证据图', f3.sameModel && f3.unpriced, JSON.stringify(f3))
|
||
const f4 = ev.judgeFlags({ ...late, same_model: false }, { reply: { same_model: true, priced: false } })
|
||
ok('……判定自己带着的盖过答复', !f4.sameModel && f4.unpriced, JSON.stringify(f4))
|
||
const f5 = ev.judgeFlags({ status: 'unjudged', reason: 'max_claims' }, { doc: { same_model: true, priced: false } })
|
||
ok('……没判的句子不提醒;priced 为 null(不知道)也不提醒', !f5.sameModel && !f5.unpriced
|
||
&& !ev.judgeFlags(sealed, { doc: { priced: null } }).unpriced, JSON.stringify(f5))
|
||
const rows = ev.auditFromApi(fv.formal.audit)
|
||
const claim = (issue) => rows.find((r) => r.kind === 'claim' && fv.formal.audit.groups.flatMap((g) => g.rows)
|
||
.some((x) => x.issue === issue && x.unit === r.unit))
|
||
const c1 = claim('contradicted_claim')
|
||
const c2 = claim('insufficient_claim')
|
||
ok('审计行:issue 为 contradicted_claim 的结论句认成证据相矛盾,insufficient_claim 认成证据不足,带着是哪一句',
|
||
c1?.state === 'contradicted' && c2?.state === 'insufficient' && c2.unit === fv.formal.units.insuff && c1.group === 'none',
|
||
JSON.stringify([c1, c2].map((r) => r && { state: r.state, unit: r.unit, group: r.group })))
|
||
ok('……旧取值 unsupported_claim 认成证据不支持;没挂依据的结论句照旧是无证据',
|
||
ev.auditFromApi({ groups: [{ key: 'none', rows: [{ kind: 'claim', issue: 'unsupported_claim', unit: 'u2', text: 'x', group: 'none' },
|
||
{ kind: 'claim', issue: 'uncited_claim', unit: 'u7', text: 'y', group: 'none', state: 'none' }] }] }).map((r) => r.state).join(',')
|
||
=== 'unsupported,none')
|
||
const doc = fv.formal.doc
|
||
const units = Object.fromEntries(doc.blocks.flatMap((b) => b.units).map((u) => [u.id, u]))
|
||
const b = ev.claimBasis(units[fv.formal.units.method], doc)
|
||
ok('讲方法的句子(只挂了表名、字段名、整份查询):列表、字段、要列 SQL 的查询(直接挂的在前,再补表名出现过的)',
|
||
!!b && b.tables.map((x) => `${x.alias}=${x.name}@${x.seg}`).join() === `t:orders=orders@${fv.formal.segs.orders}`
|
||
&& b.columns.map((x) => x.name).join() === 'orders.status,orders.created_at' && b.queries.join() === 'Q1,Q2' && b.more === 0, JSON.stringify(b))
|
||
eq('……只挂了整份查询的句子也算', ev.claimBasis(units[fv.formal.units.insuff], doc)?.queries.join(), 'Q1')
|
||
eq('……挂了数字(单元格)的句子不算', ev.claimBasis(units[fv.formal.units.ok], doc), null)
|
||
eq('……挂了运行输入的句子不算', ev.claimBasis(units[fv.formal.units.not], doc), null)
|
||
eq('……一个依据都没挂的不算', ev.claimBasis(units[fv.formal.units.limit], doc), null)
|
||
const many = { catalog: { 't:a': { kind: 'table', name: 'a', queries: ['Q1', 'Q2', 'Q3', 'Q4', 'Q5'] },
|
||
...Object.fromEntries(['Q1', 'Q2', 'Q3', 'Q4', 'Q5'].map((q) => [q, { kind: 'query' }])) }, blocks: [] }
|
||
const bm = ev.claimBasis({ cites: ['t:a'], segments: [] }, many)
|
||
ok('……查询多于 3 次:只列前 3 次的 SQL,其余写个数', bm?.queries.join() === 'Q1,Q2,Q3' && bm.more === 2, JSON.stringify(bm))
|
||
eq('查询快照里的 SQL:快照本身、工具调用快照的 args / result 都认', [ev.sqlOf({ sql: 'A' }), ev.sqlOf({ tool: 'x', args: { sql: 'B' }, result: {} }),
|
||
ev.sqlOf({ result: { sql: 'C' } }), ev.sqlOf({ rows: [] }), ev.sqlOf(null)].join('|'), 'A|B|C||')
|
||
return out
|
||
})) check(name, ok, detail)
|
||
})
|
||
|
||
await section('lib/actor:本机署名', async () => {
|
||
for (const [name, ok, detail] of await page.evaluate(() => {
|
||
const a = window.__ui.lib.actor
|
||
const out = []
|
||
const ok = (name, cond, detail = '') => out.push([name, !!cond, cond ? '' : detail])
|
||
const saved = localStorage.getItem(a.ACTOR_KEY)
|
||
let heard = 0
|
||
const stop = a.subscribeActor(() => { heard++ })
|
||
try {
|
||
localStorage.removeItem(a.ACTOR_KEY)
|
||
ok('没署名是 null', a.localActor() === null, JSON.stringify(a.localActor()))
|
||
localStorage.setItem(a.ACTOR_KEY, ' ')
|
||
ok('只有空白也算没署名', a.localActor() === null, JSON.stringify(a.localActor()))
|
||
a.setLocalActor(' 张工 ')
|
||
ok('写入时去掉首尾空白,读回来一致', a.localActor() === '张工' && localStorage.getItem(a.ACTOR_KEY) === '张工')
|
||
ok('写入会通知同一标签页的订阅者', heard === 1, `通知了 ${heard} 次`)
|
||
a.setLocalActor('')
|
||
ok('写空串就是清掉', a.localActor() === null && localStorage.getItem(a.ACTOR_KEY) === null)
|
||
} finally {
|
||
stop()
|
||
if (saved == null) localStorage.removeItem(a.ACTOR_KEY); else localStorage.setItem(a.ACTOR_KEY, saved)
|
||
}
|
||
return out
|
||
})) check(name, ok, detail)
|
||
|
||
if (process.env.UI_KIT_ONLY === 'lib') {
|
||
await browser.close()
|
||
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ lib 全部通过(UI_KIT_ONLY=lib,只跑了这一段)')
|
||
process.exit(failed ? 1 : 0)
|
||
}
|
||
})
|
||
|
||
const fireKey = (loc, init) => loc.evaluate((el, init) =>
|
||
el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init })), init)
|
||
// 点遮罩:按下和松开都在遮罩上才算。两个方向的拖动,click 都会派给公共祖先(遮罩)
|
||
const drag = async (from, to) => {
|
||
await page.mouse.move(from.x, from.y)
|
||
await page.mouse.down()
|
||
await page.mouse.move(to.x, to.y, { steps: 4 })
|
||
await page.mouse.up()
|
||
await page.waitForTimeout(100)
|
||
}
|
||
const onBackdrop = { x: 10, y: 10 }
|
||
await section('Modal:焦点、Esc、dirty、遮罩', async () => {
|
||
await page.click('#open-dirty')
|
||
await page.waitForSelector('[role="dialog"]')
|
||
await page.waitForTimeout(80)
|
||
const dlg = page.locator('[role="dialog"]')
|
||
check('role=dialog + aria-modal', (await dlg.getAttribute('aria-modal')) === 'true')
|
||
const labelledby = await dlg.getAttribute('aria-labelledby')
|
||
check('aria-labelledby 指向标题', !!labelledby && (await page.locator(`[id="${labelledby}"]`).innerText()) === '编辑 Skill')
|
||
check('打开时聚焦第一个输入框', (await active()).startsWith('INPUT'), await active())
|
||
check('× 按钮有名字', (await page.locator('[role="dialog"] button[aria-label="关闭"]').count()) === 1)
|
||
let escaped = false
|
||
for (const key of [...Array(12).fill('Tab'), ...Array(6).fill('Shift+Tab')]) {
|
||
await page.keyboard.press(key)
|
||
if (!(await page.evaluate(() => !!document.activeElement?.closest('[role="dialog"]')))) escaped = true
|
||
}
|
||
check('Tab / Shift+Tab 焦点困在弹窗里', !escaped)
|
||
await page.keyboard.press('Escape')
|
||
await page.waitForTimeout(80)
|
||
check('没改动时 Esc 直接关', (await dialogOpen()) === 0)
|
||
check('关闭后焦点回到打开按钮', (await active()).startsWith('BUTTON#open-dirty'), await active())
|
||
|
||
await page.click('#open-dirty')
|
||
await page.waitForSelector('[role="dialog"]')
|
||
const ta = page.locator('[role="dialog"] textarea')
|
||
await ta.fill('多行指令\n第二行')
|
||
await ta.focus()
|
||
await fireKey(ta, { key: 'Escape', isComposing: true })
|
||
await page.waitForTimeout(100)
|
||
check('组字中的 Esc(isComposing)不关也不问', (await dialogOpen()) === 1 && (await asking()) === 0)
|
||
await fireKey(ta, { key: 'Escape', keyCode: 229 })
|
||
await page.waitForTimeout(100)
|
||
check('Safari 式 keyCode=229 的 Esc 不关也不问', (await dialogOpen()) === 1 && (await asking()) === 0)
|
||
await page.keyboard.press('Escape')
|
||
await page.waitForTimeout(100)
|
||
check('dirty 时 Esc 先问', (await dialogOpen()) === 1 && (await asking()) === 1)
|
||
check('询问时焦点在「继续编辑」', (await active()).includes('继续编辑'), await active())
|
||
await page.keyboard.press('Escape')
|
||
await page.waitForTimeout(120)
|
||
check('再按 Esc = 继续编辑', (await asking()) === 0 && (await dialogOpen()) === 1)
|
||
check('焦点回到刚才的输入框,内容还在', (await active()).startsWith('TEXTAREA') && (await ta.inputValue()) === '多行指令\n第二行', await active())
|
||
|
||
const panelBox = await dlg.boundingBox()
|
||
const inPanel = { x: panelBox.x + panelBox.width / 2, y: panelBox.y + 20 }
|
||
const taBox = await ta.boundingBox()
|
||
await drag({ x: taBox.x + 20, y: taBox.y + 10 }, onBackdrop)
|
||
check('从输入框拖到遮罩松开:不关不问', (await dialogOpen()) === 1 && (await asking()) === 0)
|
||
await drag(onBackdrop, inPanel)
|
||
check('从遮罩按下、拖进面板松开:不关不问', (await dialogOpen()) === 1 && (await asking()) === 0)
|
||
await page.mouse.click(onBackdrop.x, onBackdrop.y)
|
||
await page.waitForTimeout(100)
|
||
check('dirty 时点遮罩先问', (await asking()) === 1)
|
||
await page.getByRole('button', { name: '继续编辑' }).click()
|
||
await page.locator('[role="dialog"] button[aria-label="关闭"]').click()
|
||
await page.waitForTimeout(100)
|
||
check('dirty 时点 × 先问', (await asking()) === 1)
|
||
await page.getByRole('button', { name: '放弃修改' }).click()
|
||
await page.waitForTimeout(100)
|
||
check('放弃修改后关闭,焦点回到打开按钮', (await dialogOpen()) === 0 && (await active()).startsWith('BUTTON#open-dirty'), await active())
|
||
})
|
||
|
||
await section('confirmDialog / promptDialog', async () => {
|
||
const result = () => page.locator('#dialog-result').innerText()
|
||
await page.click('#open-danger')
|
||
await page.waitForSelector('[role="dialog"]')
|
||
await page.waitForTimeout(80)
|
||
check('危险确认:初始焦点在「取消」', (await active()).includes('取消'), await active())
|
||
check('危险确认:列出后果', (await page.getByText('连同 4 条运行记录一起删除').count()) === 1)
|
||
await page.keyboard.press('Enter')
|
||
await page.waitForTimeout(100)
|
||
check('危险确认:回车 = 取消', (await result()) === 'false')
|
||
check('关闭后焦点回到触发按钮', (await active()).startsWith('BUTTON#open-danger'), await active())
|
||
|
||
await page.click('#open-confirm')
|
||
await page.waitForSelector('[role="dialog"]')
|
||
await page.waitForTimeout(80)
|
||
check('普通确认:初始焦点在确认按钮', (await active()).includes('发布 v4'), await active())
|
||
const confirmBox = await page.locator('[role="dialog"]').boundingBox()
|
||
await drag(onBackdrop, { x: confirmBox.x + confirmBox.width / 2, y: confirmBox.y + 20 })
|
||
check('普通确认:从遮罩拖进面板松开不关', (await dialogOpen()) === 1)
|
||
await page.keyboard.press('Enter')
|
||
await page.waitForTimeout(100)
|
||
check('回车确认', (await result()) === 'true')
|
||
|
||
await page.click('#open-require')
|
||
await page.waitForSelector('[role="dialog"]')
|
||
await page.waitForTimeout(80)
|
||
const requireBtn = page.getByRole('button', { name: '删除凭证' })
|
||
check('照抄确认:没输入时禁用、焦点在输入框', (await requireBtn.isDisabled()) && (await active()).startsWith('INPUT'), await active())
|
||
await page.keyboard.type('a3f9c')
|
||
check('照抄确认:输错仍禁用', await requireBtn.isDisabled())
|
||
await page.keyboard.type('2')
|
||
await page.keyboard.press('Enter')
|
||
await page.waitForTimeout(100)
|
||
check('照抄确认:抄对后回车确认', (await result()) === 'true')
|
||
|
||
await page.click('#open-prompt')
|
||
await page.waitForSelector('[role="dialog"]')
|
||
await page.waitForTimeout(80)
|
||
const input = page.locator('[role="dialog"] input')
|
||
check('输入框:初值带上并全选', (await input.inputValue()) === '未命名工作流 09-26 10:05'
|
||
&& await input.evaluate((el) => el.selectionStart === 0 && el.selectionEnd === el.value.length))
|
||
await input.fill('新工作流')
|
||
await page.waitForTimeout(50)
|
||
check('validate 不通过时说原因并禁用', (await page.getByText('已经有一个同名的了').count()) === 1
|
||
&& await page.getByRole('button', { name: '新建', exact: true }).isDisabled())
|
||
await input.fill(' 月度经营分析 ')
|
||
await fireKey(input, { key: 'Enter', isComposing: true })
|
||
await fireKey(input, { key: 'Escape', keyCode: 229 })
|
||
await page.waitForTimeout(100)
|
||
check('组字中的回车、Esc 都不动', (await dialogOpen()) === 1)
|
||
await input.press('Enter')
|
||
await page.waitForTimeout(100)
|
||
check('回车提交,返回去掉首尾空白的名字', (await result()) === '"月度经营分析"')
|
||
|
||
// 空输入:默认(allowEmpty 关)报「不能为空」、确认禁用、回车不提交;打开 allowEmpty(版本页「启用」收可选理由)
|
||
// 时空输入和只有空白都能确认,返回空字符串,非空时 validate 照旧生效
|
||
const dialogText = (t) => page.locator('[role="dialog"]').getByText(t, { exact: true }).count()
|
||
await page.click('#open-prompt')
|
||
await page.waitForSelector('[role="dialog"]')
|
||
await page.waitForTimeout(80)
|
||
const createBtn = page.getByRole('button', { name: '新建', exact: true })
|
||
await input.fill(' ')
|
||
await page.waitForTimeout(50)
|
||
check('默认不允许空:只有空白时报「不能为空」并禁用确认', (await dialogText('不能为空')) === 1 && await createBtn.isDisabled())
|
||
await input.fill('')
|
||
await input.press('Enter')
|
||
await page.waitForTimeout(100)
|
||
check('……清空后回车不提交,确认仍禁用', (await dialogOpen()) === 1 && await createBtn.isDisabled())
|
||
await page.getByRole('button', { name: '取消' }).click()
|
||
await page.waitForTimeout(100)
|
||
check('……取消返回 null', (await result()) === 'null')
|
||
|
||
const openOptional = () => page.evaluate(() => {
|
||
window.__optional = undefined
|
||
void window.__ui.promptDialog({
|
||
title: '启用这个版本?', label: '理由(可选)', confirmLabel: '启用', allowEmpty: true,
|
||
validate: (v) => (v.length > 6 ? '理由太长' : null),
|
||
}).then((v) => { window.__optional = v })
|
||
})
|
||
const optional = () => page.evaluate(() => window.__optional)
|
||
const enableBtn = page.getByRole('button', { name: '启用', exact: true })
|
||
await openOptional()
|
||
await page.waitForSelector('[role="dialog"]')
|
||
await page.waitForTimeout(80)
|
||
check('allowEmpty:空输入时确认可用,不报「不能为空」', !(await enableBtn.isDisabled()) && (await dialogText('不能为空')) === 0)
|
||
await input.fill(' ')
|
||
await page.waitForTimeout(50)
|
||
check('……只有空白也可用,不报「不能为空」', !(await enableBtn.isDisabled()) && (await dialogText('不能为空')) === 0)
|
||
await input.fill('这段理由写得太长了')
|
||
await page.waitForTimeout(50)
|
||
check('……填了内容时 validate 照旧生效(报原因、禁用)', (await dialogText('理由太长')) === 1 && await enableBtn.isDisabled())
|
||
await input.fill(' ')
|
||
await input.press('Enter')
|
||
await page.waitForTimeout(100)
|
||
check('……空着回车确认,返回空字符串(不是 null)', (await dialogOpen()) === 0 && (await optional()) === '', JSON.stringify(await optional()))
|
||
await openOptional()
|
||
await page.waitForSelector('[role="dialog"]')
|
||
await page.waitForTimeout(80)
|
||
await input.fill(' 传错了 ')
|
||
await enableBtn.click()
|
||
await page.waitForTimeout(100)
|
||
check('……填了就返回去掉首尾空白的文字', (await optional()) === '传错了', JSON.stringify(await optional()))
|
||
await openOptional()
|
||
await page.waitForSelector('[role="dialog"]')
|
||
await page.waitForTimeout(80)
|
||
await page.getByRole('button', { name: '取消' }).click()
|
||
await page.waitForTimeout(100)
|
||
check('……取消仍返回 null', (await optional()) === null, JSON.stringify(await optional()))
|
||
|
||
// 嵌套:弹窗里再弹确认,Esc 只关一层
|
||
await page.click('#open-dirty')
|
||
await page.waitForSelector('[role="dialog"]')
|
||
await page.locator('[role="dialog"] textarea').fill('x')
|
||
await page.evaluate(() => { void window.__ui.confirmDialog({ title: '里面那层', danger: true }).then((v) => { window.__inner = v }) })
|
||
await page.waitForTimeout(150)
|
||
await page.keyboard.press('Escape')
|
||
await page.waitForTimeout(120)
|
||
check('嵌套时 Esc 只关上面那层,下面那层没被问', (await dialogOpen()) === 1
|
||
&& (await page.evaluate(() => window.__inner)) === false && (await asking()) === 0)
|
||
await page.getByRole('button', { name: '取消' }).click()
|
||
await page.waitForTimeout(80)
|
||
})
|
||
|
||
await section('toast', async () => {
|
||
await page.evaluate(() => window.__ui.toast.dismiss())
|
||
await page.click('#toast-error')
|
||
await page.getByRole('button', { name: '普通' }).click()
|
||
await page.waitForTimeout(100)
|
||
check('出错的在 assertive 区', (await page.locator('[role="alert"][aria-live="assertive"]').innerText()).includes('保存失败'))
|
||
check('普通的在 polite 区', (await page.locator('[role="status"][aria-live="polite"]').first().innerText()).includes('已复制到剪贴板'))
|
||
check('出错的能复制', (await page.getByRole('button', { name: '复制详情' }).count()) >= 1)
|
||
// 位置:内容区(让出 64px 的导航,App 里的 w-16)居中,顶边在 48px 工具栏之下
|
||
const NAV_W = 64
|
||
const stackBox = await page.locator('[role="alert"][aria-live="assertive"]').boundingBox()
|
||
const vw = page.viewportSize().width
|
||
check('toast 在工具栏下沿之下(≥ 56px)', stackBox.y >= 56, `top=${Math.round(stackBox.y)}`)
|
||
check('toast 在内容区里居中', Math.abs(stackBox.x + stackBox.width / 2 - (NAV_W + vw) / 2) < 2,
|
||
`中线 ${Math.round(stackBox.x + stackBox.width / 2)},内容区中线 ${(NAV_W + vw) / 2}`)
|
||
const infoCard = page.getByText('已复制到剪贴板')
|
||
await infoCard.hover()
|
||
await page.waitForTimeout(4600)
|
||
check('悬停时不消失', (await infoCard.count()) === 1)
|
||
await page.mouse.move(640, 880)
|
||
await page.waitForTimeout(4400)
|
||
check('移开后约 4 秒消失', (await infoCard.count()) === 0)
|
||
check('出错的常驻', (await page.getByText('保存失败:工作流名称不能为空').count()) === 1)
|
||
await page.click('#toast-error')
|
||
await page.waitForTimeout(80)
|
||
check('同样的内容不叠,计数 ×2', (await page.getByText('保存失败:工作流名称不能为空').count()) === 1 && (await page.getByText('×2').count()) === 1)
|
||
await page.getByRole('button', { name: '关闭提示' }).first().click()
|
||
await page.waitForTimeout(80)
|
||
check('× 关掉', (await page.getByText('保存失败').count()) === 0)
|
||
await page.click('#toast-network')
|
||
await page.waitForTimeout(80)
|
||
const netText = await page.locator('[role="alert"][aria-live="assertive"]').innerText()
|
||
check('网络错误不露 Failed to fetch', netText.includes('无法连接服务') && !netText.includes('Failed to fetch'), netText.replace(/\n/g, ' | '))
|
||
await page.getByRole('button', { name: '叠 6 条' }).click()
|
||
await page.waitForTimeout(80)
|
||
check('最多露 3 条,其余收起', (await page.getByText(/还有 \d+ 条/).count()) === 1)
|
||
await page.evaluate(() => window.__ui.toast.dismiss())
|
||
})
|
||
|
||
await section('离线:横幅、空态', async () => {
|
||
const emptyBlock = page.locator('[data-block="空态 EmptyState(模拟离线时会变)"]')
|
||
await page.click('#toggle-offline')
|
||
await page.waitForTimeout(100)
|
||
check('横幅出现', (await page.getByText('服务端未连接').count()) === 1)
|
||
// 横幅早就挂着(连着时什么都不画),计时器是这一刻才走起来的:头一个数得按此刻算
|
||
const firstSecs = Number((await page.getByText(/\d+ 秒后自动重试/).innerText()).match(/\d+/)?.[0])
|
||
check('刚断开时倒计时从此刻算(8 秒)', firstSecs === 8 || firstSecs === 7, `横幅 ${firstSecs} 秒`)
|
||
check('空态改说无法获取数据', (await emptyBlock.getByText('暂时无法获取数据').count()) >= 1)
|
||
check('离线空态不许诺「会自动刷新」', (await emptyBlock.getByText(/自动刷新/).count()) === 0)
|
||
check('离线时收起「新建」', (await page.getByRole('button', { name: '新建工作流' }).count()) === 0)
|
||
check('本地空态不受影响', (await page.getByText('没有匹配的工具').count()) === 1)
|
||
// role=alert 整块重念:倒计时一秒一跳,放在里面读屏就一秒念一遍整条横幅
|
||
const bannerAlert = page.locator('[data-offline-banner] [role="alert"]')
|
||
check('横幅外层不是 role=alert', (await page.locator('[data-offline-banner]').getAttribute('role')) === null)
|
||
check('横幅的播报区只有不变的那几句', (await bannerAlert.count()) === 1
|
||
&& (await bannerAlert.innerText()).includes('服务端未连接') && !/秒后自动重试/.test(await bannerAlert.innerText()),
|
||
await bannerAlert.innerText().catch(() => ''))
|
||
check('倒计时对读屏隐藏', (await page.getByText(/\d+ 秒后自动重试/).getAttribute('aria-hidden')) === 'true')
|
||
// 横幅把工具栏往下推了多少,toast 就跟着让多少
|
||
await page.click('#toast-error')
|
||
await page.waitForTimeout(80)
|
||
const bannerH = (await page.locator('[data-offline-banner]').boundingBox()).height
|
||
const offTop = (await page.locator('[role="alert"][aria-live="assertive"]').boundingBox()).y
|
||
check('离线时 toast 再让出横幅的高度', Math.abs(offTop - (56 + bannerH)) < 2, `top=${Math.round(offTop)},横幅 ${Math.round(bannerH)}px`)
|
||
await page.evaluate(() => window.__ui.toast.dismiss())
|
||
await page.click('#toggle-offline')
|
||
await page.waitForTimeout(80)
|
||
check('恢复后横幅高度变量清掉', (await page.evaluate(() => document.documentElement.style.getPropertyValue('--offline-banner-h'))) === '')
|
||
})
|
||
|
||
await section('Tabs · Field · IconButton', async () => {
|
||
await page.getByRole('tab', { name: '模型接入' }).focus()
|
||
await page.keyboard.press('ArrowRight')
|
||
check('→ 切到下一个标签', (await page.getByRole('tab', { name: /数据源/ }).getAttribute('aria-selected')) === 'true')
|
||
check('tabpanel 与 tab 互相指认', (await page.locator('[role="tabpanel"]').getAttribute('aria-labelledby')) === 'demo-tab-b')
|
||
check('Field:label 关联输入框', (await page.getByLabel('Base URL').count()) === 1 && (await page.getByLabel('标识').count()) === 1)
|
||
const wantAria = await page.evaluate(() => window.__ui.lib.keys.ariaShortcut('Mod+R'))
|
||
const gotAria = await page.getByRole('button', { name: '刷新列表' }).getAttribute('aria-keyshortcuts')
|
||
check('IconButton 的 aria-keyshortcuts 按平台写', gotAria === wantAria && !/Ctrl|Mod/.test(gotAria), gotAria)
|
||
})
|
||
|
||
await section('管理页共用件:页头、连通胶囊、单选组、删除后撤销', async () => {
|
||
check('页头 48px 高', Math.round((await page.locator('#page-header-demo header').boundingBox()).height) === 48)
|
||
const pills = await page.locator('#health-demo [data-health]').evaluateAll((els) => els.map((el) => el.textContent))
|
||
// 「3 分钟前」是从预览页挂载时算的,跑到这里可能已经过了一分钟
|
||
check('连通胶囊五种说法', pills[0] === '未测试' && /^正在测试 · /.test(pills[1]) && /^连接成功 · 42 ms · [34] 分钟前测试$/.test(pills[2])
|
||
&& pills[3] === '连接失败 · 1 小时前测试' && /连接成功 · 380 ms.*配置已修改/.test(pills[4]), pills.join(' | '))
|
||
check('不知道测的时刻:不留一个悬空的「 · 」', pills[5] === '连接失败', JSON.stringify(pills[5]))
|
||
const spoken = await page.locator('#health-demo [role="status"]').evaluateAll((els) => els.map((el) => el.textContent))
|
||
check('读屏那一句不带跳动的计时和相对时间', spoken[1] === '正在测试连接' && /^连接成功 42 ms,测试于 \d\d:\d\d$/.test(spoken[2]), spoken.join(' | '))
|
||
|
||
const radios = page.locator('#radio-demo [role="radio"]')
|
||
check('单选组只占一个 Tab 位', JSON.stringify(await radios.evaluateAll((els) => els.map((el) => el.tabIndex))) === '[0,-1,-1]')
|
||
const checkedRadio = () => page.locator('#radio-demo [role="radio"][aria-checked="true"]').innerText()
|
||
const focusedRadio = () => page.evaluate(() => document.activeElement?.textContent)
|
||
await radios.first().focus()
|
||
await page.keyboard.press('ArrowRight')
|
||
check('→ 选中下一项并聚焦', (await checkedRadio()) === 'sid' && (await focusedRadio()) === 'sid')
|
||
await page.keyboard.press('End')
|
||
check('End 到最后一项', (await checkedRadio()) === 'dsn')
|
||
await page.keyboard.press('ArrowDown')
|
||
check('↓ 首尾相接', (await checkedRadio()) === 'service_name')
|
||
await page.keyboard.press('ArrowUp')
|
||
await page.keyboard.press('Home')
|
||
check('↑ 往回、Home 到第一项', (await checkedRadio()) === 'service_name' && (await focusedRadio()) === 'service_name')
|
||
await page.keyboard.press('Alt+ArrowRight')
|
||
check('带 Alt 的方向键不接(那是浏览器后退 / 前进)', (await checkedRadio()) === 'service_name')
|
||
await page.getByRole('tab', { name: '模型接入' }).focus()
|
||
await page.keyboard.press('End')
|
||
check('标签页:End 到最后一个', (await page.getByRole('tab', { name: '偏好' }).getAttribute('aria-selected')) === 'true')
|
||
await page.keyboard.press('Home')
|
||
|
||
let deletes = 0
|
||
page.on('request', (r) => { if (r.method() === 'DELETE') deletes++ })
|
||
const rowShown = (id) => page.locator(`#defer-demo [data-row="${id}"]`).count()
|
||
const undoBtn = page.locator('[role="status"][aria-live="polite"]').getByRole('button', { name: '撤销' })
|
||
await page.getByRole('button', { name: '删除 orders' }).click()
|
||
await page.waitForTimeout(80)
|
||
check('删除:行先拿掉,toast 给「撤销」', (await rowShown('orders')) === 0
|
||
&& (await page.getByText('已删除「orders」').count()) === 1 && (await undoBtn.count()) === 1)
|
||
await page.click('#defer-reload')
|
||
await page.waitForTimeout(50)
|
||
check('撤销窗口里重拉列表,删掉的行不回来', (await rowShown('orders')) === 0)
|
||
await undoBtn.click()
|
||
await page.waitForTimeout(80)
|
||
check('撤销:行回来了,一个 DELETE 都没发', (await rowShown('orders')) === 1 && deletes === 0)
|
||
await page.evaluate(() => window.__ui.toast.dismiss())
|
||
})
|
||
|
||
await section('提示条 Notice、回执块(期 3 共用件:导入向导、修复面板、版本页都用这一份)', async () => {
|
||
await page.locator('#notice-demo').scrollIntoViewIfNeeded()
|
||
const notices = page.locator('#notice-demo [data-notice]')
|
||
check('Notice:三种语气各一条', (await notices.count()) === 3)
|
||
const tones = await notices.evaluateAll((els) => els.map((el) => ({
|
||
tone: el.getAttribute('data-notice'), demo: el.getAttribute('data-demo-notice'), role: el.getAttribute('role'),
|
||
icons: el.querySelectorAll(':scope > svg[aria-hidden="true"]').length,
|
||
border: getComputedStyle(el).borderTopColor, cls: el.className,
|
||
})))
|
||
const sameNode = tones.every((t) => t.tone === t.demo)
|
||
check('attr 原样挂在根节点上(与 data-notice 同一个元素)', sameNode, sameNode ? '' : JSON.stringify(tones.map((t) => [t.tone, t.demo])))
|
||
check('info 是 status(礼貌播报),warn、err 是 alert', tones.map((t) => `${t.tone}:${t.role}`).join(',') === 'info:status,warn:alert,err:alert')
|
||
check('每条一个图标,不进读屏', tones.every((t) => t.icons === 1))
|
||
const distinct = new Set(tones.map((t) => t.border)).size === 3
|
||
check('三种语气的边框颜色互不相同', distinct, distinct ? '' : tones.map((t) => t.border).join(' | '))
|
||
const merged = /\brounded-lg\b/.test(tones[2].cls) && /\bmt-1\b/.test(tones[2].cls)
|
||
check('className 追加在默认样式之后', merged, merged ? '' : tones[2].cls)
|
||
check('children 原样渲染(第二行的细节在提示条里)', (await page.locator('#notice-demo [data-notice="err"] #notice-detail').count()) === 1)
|
||
|
||
const sum = page.locator('#receipt-demo [data-receipt-summary]')
|
||
await sum.scrollIntoViewIfNeeded()
|
||
check('回执摘要:非空单元格总数和「全部有去处」', /非空单元格 771 个,全部有去处/.test(await sum.locator('[data-summary-ledger]').innerText()))
|
||
check('回执摘要:表与行数', (await sum.locator('[data-summary-table="日客流"]').getAttribute('data-rows')) === '30')
|
||
check('回执摘要:占位符、规范写法', (await sum.locator('[data-summary-placeholders]').innerText()).includes('「·」30 格')
|
||
&& (await sum.locator('[data-summary-canonicalized]').getAttribute('data-summary-canonicalized')) === '17')
|
||
check('排除的行:逐项列出', (await sum.locator('[data-excluded-reason]').count()) === 3
|
||
&& (await sum.locator('[data-summary-excluded]').getAttribute('data-summary-excluded')) === '3')
|
||
const text = await sum.innerText()
|
||
const reasons = !/ignored_rows|total_not_kept|mystery_reason/.test(text)
|
||
&& text.includes('按配方忽略的行') && text.includes('只核对、不另存的合计行') && text.includes('其他原因')
|
||
check('排除的行:原因写中文,不露枚举值;认不出的写「其他原因」', reasons, reasons ? '' : text.replace(/\s+/g, ' ').slice(0, 200))
|
||
check('排除的行:锚点原文、行号区间、格数', text.includes('「补录(人次)」') && text.includes('第 32 行')
|
||
&& text.includes('第 28–30 行') && text.includes('93 格'))
|
||
check('排除的行的根节点同时带 data-rows-excluded 和规格里的旧名 data-rows-dropped',
|
||
(await sum.locator('[data-rows-excluded][data-rows-dropped]').count()) === 1)
|
||
await sum.locator('[data-excluded-rows="32-32"]').click()
|
||
check('点行号定位到那一行(工作表!A行号)', (await page.locator('#receipt-focus').innerText()) === '客流汇总!A32')
|
||
const unrecorded = page.locator('#receipt-unrecorded [data-summary-excluded]')
|
||
check('回执里没有 rows_excluded(期 3 之前的导入):写「未记录」,不当作「没有」',
|
||
(await unrecorded.getAttribute('data-summary-excluded')) === 'unrecorded'
|
||
&& (await unrecorded.innerText()).includes('这次导入未记录排除的行'))
|
||
})
|
||
|
||
await section('空态:catalog 的表没取回来时不说「还没有」', async () => {
|
||
const sourceEmpty = page.locator('#empty-source-demo')
|
||
await page.evaluate(() => window.__ui.useCatalog.getState().refresh())
|
||
check('取回来了:照常说「还没有工作流」、给新建', (await sourceEmpty.getByText('还没有工作流').count()) === 1
|
||
&& (await sourceEmpty.locator('#empty-source-action').count()) === 1)
|
||
const realCatalog = await page.evaluate(() => {
|
||
const s = window.__ui.useCatalog.getState()
|
||
return { loadedAt: s.loadedAt, checks: s.checks }
|
||
})
|
||
await page.evaluate(() => window.__ui.useCatalog.setState({
|
||
loadedAt: {}, checks: [{ key: 'workflows', label: '工作流', state: 'pending' }],
|
||
}))
|
||
await page.waitForTimeout(50)
|
||
check('从没取回来过、还在路上:说「正在读取工作流」、收起新建', (await sourceEmpty.getByText('正在读取工作流').count()) === 1
|
||
&& (await sourceEmpty.locator('#empty-source-action').count()) === 0
|
||
&& (await sourceEmpty.locator('[data-empty-unknown="loading"]').getAttribute('role')) === 'status')
|
||
await page.evaluate(() => window.__ui.useCatalog.setState({
|
||
checks: [{ key: 'workflows', label: '工作流', state: 'error', error: '服务端 15 秒未响应,请稍后重试' }],
|
||
}))
|
||
await page.waitForTimeout(50)
|
||
check('超时或报错:说「工作流加载失败」和原因、给重新读取', (await sourceEmpty.getByText('工作流加载失败').count()) === 1
|
||
&& (await sourceEmpty.getByText(/15 秒未响应.*此处显示为空不代表没有数据/).count()) === 1
|
||
&& (await sourceEmpty.getByRole('button', { name: '重新读取' }).count()) === 1
|
||
&& (await sourceEmpty.locator('#empty-source-action').count()) === 0)
|
||
await page.evaluate((c) => window.__ui.useCatalog.setState(c), realCatalog)
|
||
})
|
||
|
||
let cat
|
||
await section('catalog:一张表卡住不拖累别的表', async () => {
|
||
// /tools 连得上但不回(MCP 服务不应答时就是这样):以前 Promise.all 等它,工作流也一直空着
|
||
const toolsBefore = await page.evaluate(() => window.__ui.useCatalog.getState().tools.length)
|
||
// unroute 要拿同一个函数引用才摘得掉
|
||
const isTools = (u) => new URL(u).pathname === '/api/tools'
|
||
await page.route(isTools, () => { /* 永远不回 */ })
|
||
const expectWf = await fetch(`${API}/workflows`).then((r) => r.json()).then((l) => l.length, () => null)
|
||
await page.evaluate(() => window.__ui.useCatalog.setState({ workflows: [] }))
|
||
const tHang = Date.now()
|
||
let settled = false
|
||
const hung = page.evaluate(() => window.__ui.useCatalog.getState().refresh()).then(() => { settled = true })
|
||
await page.waitForFunction(() => window.__ui.useCatalog.getState().workflows.length > 0, null, { timeout: 8000 }).catch(() => {})
|
||
cat = await page.evaluate(() => {
|
||
const s = window.__ui.useCatalog.getState()
|
||
return { wf: s.workflows.length, wfAt: s.loadedAt.workflows, tools: s.checks.find((c) => c.key === 'tools')?.state }
|
||
})
|
||
if (expectWf) {
|
||
check('工作流先回来先填,不等卡住的工具', cat.wf === expectWf && cat.wfAt >= tHang && cat.tools === 'pending',
|
||
`${JSON.stringify(cat)},后端有 ${expectWf} 个工作流`)
|
||
}
|
||
// 最多等 20 秒:没有超时的话 refresh 永远不落定,检查自己也不能跟着挂死
|
||
await Promise.race([hung, new Promise((r) => setTimeout(r, 20_000))])
|
||
const hangMs = Date.now() - tHang
|
||
check('refresh 在超时之后落定', settled, settled ? `${hangMs}ms` : `${hangMs}ms 还没落定`)
|
||
cat = await page.evaluate(() => {
|
||
const s = window.__ui.useCatalog.getState()
|
||
const c = s.checks.find((x) => x.key === 'tools')
|
||
return { state: c?.state, error: c?.error, backend: s.backend, tools: s.tools.length, loaded: s.loaded }
|
||
})
|
||
check('卡住的那张 15 秒超时,记成出错并说清', cat.state === 'error' && /15 秒/.test(cat.error ?? '') && hangMs > 14_500 && hangMs < 18_000,
|
||
`${cat.state} ${cat.error} ${hangMs}ms`)
|
||
check('一张表超时不算后端断开', cat.backend === 'ok', cat.backend)
|
||
check('超时不清空手上已有的那份', cat.tools === toolsBefore, `${toolsBefore} → ${cat.tools}`)
|
||
await page.unroute(isTools)
|
||
})
|
||
|
||
await section('catalog:数据源目录与单表重拉', async () => {
|
||
// 数据源的查询工具不在 /api/tools 里:检查器挑工具、问数据的范围、助手的数据源提示都从
|
||
// catalog 的 datasources 取一份。数据页改了它就 reload('datasources'),别处立刻跟上
|
||
const isSources = (u) => new URL(u).pathname === '/api/datasources'
|
||
const fakeSource = (n) => ({ id: `ui-kit-${n}`, name: `demo_${n}`, kind: 'sqlite', host: null, port: null, database: '/tmp/demo.db',
|
||
username: null, options: {}, readonly: true, description: '', enabled: true, tools: [`db_query__demo_${n}`, `db_schema__demo_${n}`] })
|
||
let sourceRows = [fakeSource(1)]
|
||
let sourceHits = 0
|
||
await page.route(isSources, (r) => { sourceHits++; return r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(sourceRows) }) })
|
||
await page.evaluate(() => window.__ui.useCatalog.getState().refresh())
|
||
cat = await page.evaluate(() => {
|
||
const s = window.__ui.useCatalog.getState()
|
||
return { n: s.datasources.length, at: s.loadedAt.datasources, check: s.checks.find((c) => c.key === 'datasources')?.label }
|
||
})
|
||
check('refresh 顺带取回数据源,启动清单里有「数据源」一项', cat.n === 1 && cat.at > 0 && cat.check === '数据源', JSON.stringify(cat))
|
||
sourceRows = [fakeSource(1), fakeSource(2)]
|
||
const hitsBefore = sourceHits
|
||
await page.evaluate(() => window.__ui.useCatalog.getState().reload('datasources'))
|
||
cat = await page.evaluate(() => {
|
||
const s = window.__ui.useCatalog.getState()
|
||
return { names: s.datasources.map((d) => d.name).join(','), checks: s.checks.length }
|
||
})
|
||
check('reload 只重拉这一张表', cat.names === 'demo_1,demo_2' && sourceHits === hitsBefore + 1, `${cat.names} · 请求 ${sourceHits - hitsBefore} 次`)
|
||
await page.unroute(isSources)
|
||
await page.route(isSources, (r) => r.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ detail: '数据库锁住了' }) }))
|
||
await page.evaluate(() => window.__ui.useCatalog.getState().reload('datasources'))
|
||
cat = await page.evaluate(() => {
|
||
const s = window.__ui.useCatalog.getState()
|
||
return { n: s.datasources.length, backend: s.backend }
|
||
})
|
||
check('reload 失败时列表保持原样,也不判后端断开', cat.n === 2 && cat.backend === 'ok', JSON.stringify(cat))
|
||
// 交叠:先发的慢请求后到,不能盖掉后发、先到的新结果
|
||
await page.unroute(isSources)
|
||
let slowFirst = true
|
||
await page.route(isSources, async (r) => {
|
||
const slow = slowFirst
|
||
slowFirst = false
|
||
if (slow) await new Promise((res) => setTimeout(res, 600))
|
||
return r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(slow ? [fakeSource(9)] : [fakeSource(3)]) })
|
||
})
|
||
await page.evaluate(() => {
|
||
const s = window.__ui.useCatalog.getState()
|
||
window.__slowReload = s.reload('datasources')
|
||
return new Promise((r) => setTimeout(r, 50)).then(() => s.reload('datasources'))
|
||
})
|
||
await page.evaluate(() => window.__slowReload)
|
||
cat = await page.evaluate(() => window.__ui.useCatalog.getState().datasources.map((d) => d.name).join(','))
|
||
check('两次 reload 交叠:后发的结果不被先发、后到的盖掉', cat === 'demo_3', cat)
|
||
await page.unroute(isSources)
|
||
// useDatasources(检查器、问数据页的输入框挂它):过期才拉,同一时刻几处挂载只发一个请求;
|
||
// 启动的 refresh 落定前不拉,落定时这张表没取回来就补拉一次——以前只看 maxAge,要等重新挂载
|
||
let hookHits = 0
|
||
await page.route(isSources, (r) => { hookHits++; return r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([fakeSource(1)]) }) })
|
||
const mountSources = async (n) => { await page.evaluate((n) => window.__ui.mountDatasources(n), n); await page.waitForTimeout(400) }
|
||
await page.evaluate(() => {
|
||
const c = window.__ui.useCatalog
|
||
c.setState({ loaded: true, loadedAt: { ...c.getState().loadedAt, datasources: Date.now() - 60_000 } })
|
||
})
|
||
hookHits = 0
|
||
await mountSources(2)
|
||
check('useDatasources:目录过期时两处同时挂载,只发一个请求', hookHits === 1, `${hookHits} 次`)
|
||
await mountSources(0)
|
||
hookHits = 0
|
||
await mountSources(2)
|
||
check('……刚取过、没过期:挂载不拉', hookHits === 0, `${hookHits} 次`)
|
||
await mountSources(0)
|
||
await page.evaluate(() => {
|
||
const c = window.__ui.useCatalog
|
||
const { datasources: _, ...rest } = c.getState().loadedAt
|
||
c.setState({ loaded: false, loadedAt: rest })
|
||
})
|
||
hookHits = 0
|
||
await mountSources(2)
|
||
const hitsBeforeLoaded = hookHits
|
||
await page.evaluate(() => window.__ui.useCatalog.setState({ loaded: true }))
|
||
await page.waitForTimeout(400)
|
||
check('……启动的 refresh 落定前不拉;落定时这张表没取回来,已挂着的补拉一次',
|
||
hitsBeforeLoaded === 0 && hookHits === 1, `落定前 ${hitsBeforeLoaded} 次,落定后共 ${hookHits} 次`)
|
||
await mountSources(0)
|
||
await page.unroute(isSources)
|
||
await page.evaluate(() => window.__ui.useCatalog.getState().reload('datasources'))
|
||
})
|
||
|
||
await section('后端的机读码跟着 ApiError 走', async () => {
|
||
// {detail, code}:detail 是给人看的话、随时会改写,按种类分支要认 code(3C REQ-20)。伪造响应,不碰后端
|
||
const isGen = (u) => new URL(u).pathname === '/api/copilot/generate'
|
||
await page.route(isGen, (r) => r.fulfill({ status: 400, contentType: 'application/json',
|
||
body: JSON.stringify({ detail: '限定的数据源都已不可用,可能已被删除或停用。请取消限定后重试,或到「数据」页确认数据源存在且已启用', code: 'datasource_scope_empty' }) }))
|
||
const coded = await page.evaluate(async () => {
|
||
try { await window.__ui.api.copilot.generate({ instruction: '探针', datasource_ids: ['__gone__'] }); return null }
|
||
catch (e) { return { isApi: e instanceof window.__ui.ApiError, status: e.status, code: e.code ?? null, message: e.message } }
|
||
})
|
||
check('ApiError 带上后端的 code,message 仍是那句话', coded?.isApi && coded.status === 400 && coded.code === 'datasource_scope_empty'
|
||
&& coded.message === '限定的数据源都已不可用,可能已被删除或停用。请取消限定后重试,或到「数据」页确认数据源存在且已启用', JSON.stringify(coded))
|
||
await page.unroute(isGen)
|
||
})
|
||
|
||
await section('FastAPI 422:校验错误说中文,英文原文收进详情', async () => {
|
||
// pydantic 的 msg 是英文、loc 是内部键名,原样拼出来就是「name:Field required」(验收 NEW)。
|
||
// 样例照沙箱后端真实回的 422 摘的,伪造响应,不碰后端
|
||
const detailOf = {
|
||
'/api/providers': [{ type: 'missing', loc: ['body', 'name'], msg: 'Field required', input: {} }],
|
||
'/api/datasources': [
|
||
{ type: 'string_pattern_mismatch', loc: ['body', 'name'], msg: "String should match pattern '^[a-z][a-z0-9_]{0,40}$'", input: 'X', ctx: { pattern: '^[a-z][a-z0-9_]{0,40}$' } },
|
||
{ type: 'int_parsing', loc: ['body', 'port'], msg: 'Input should be a valid integer, unable to parse string as an integer', input: 'abc' },
|
||
],
|
||
'/api/memory': [{ type: 'less_than_equal', loc: ['body', 'importance'], msg: 'Input should be less than or equal to 1', input: 3, ctx: { le: 1.0 } }],
|
||
'/api/conversations': [{ type: 'literal_error', loc: ['body', 'kind'], msg: "Input should be 'chat' or 'canvas'", input: 'x', ctx: { expected: "'chat' or 'canvas'" } }],
|
||
'/api/workflows': [{ type: 'some_future_type', loc: ['body', 'graph', 'nodes', 0, 'shape'], msg: 'Something went wrong in English', input: 1 }],
|
||
'/api/copilot/generate-stream': [{ type: 'string_too_short', loc: ['body', 'instruction'], msg: 'String should have at least 1 character', input: '', ctx: { min_length: 1 } }],
|
||
}
|
||
const is422 = (u) => Object.hasOwn(detailOf, new URL(u).pathname)
|
||
await page.route(is422, (r) => r.fulfill({ status: 422, contentType: 'application/json',
|
||
body: JSON.stringify({ detail: detailOf[new URL(r.request().url()).pathname] }) }))
|
||
const got = await page.evaluate(async () => {
|
||
const { api, ApiError, streamCopilot } = window.__ui
|
||
const { errors } = window.__ui.lib
|
||
const call = async (fn) => {
|
||
try { await fn(); return null } catch (e) {
|
||
const h = errors.humanizeError(e)
|
||
return { isApi: e instanceof ApiError, status: e.status, message: e.message, raw: e.raw ?? '', arr: Array.isArray(e.detail),
|
||
title: h.title, reason: h.reason ?? '', hraw: h.raw ?? '' }
|
||
}
|
||
}
|
||
const stream = await new Promise((resolve) => {
|
||
if (!streamCopilot) return resolve(null)
|
||
streamCopilot({ instruction: '' }, () => {}, (error, info) => resolve({ error: error ?? '', status: info?.status }))
|
||
})
|
||
return {
|
||
provider: await call(() => api.providers.create({})),
|
||
source: await call(() => api.datasources.create({ name: 'X', kind: 'sqlite', port: 'abc' })),
|
||
memory: await call(() => api.memory.add({ content: 'x', importance: 3 })),
|
||
conv: await call(() => api.conversations.create({ kind: 'x' })),
|
||
wf: await call(() => api.workflows.create({ name: 'x' })),
|
||
stream,
|
||
}
|
||
})
|
||
await page.unroute(is422)
|
||
// 英文原文里的词(Field required、String should…、Input should…)一个都不能出现在给人看的话里
|
||
const english = /Field required|should|Input|valid|String|Something|went wrong/
|
||
const p = got.provider
|
||
check('缺必填:字段按表单叫法、说「为必填项」', p?.message === '提交的内容不符合要求:「名称」为必填项', JSON.stringify(p?.message))
|
||
check('……humanizeError 拆成标题和原因,英文原文进 raw(「详情」里看得到)',
|
||
p?.title === '提交的内容不符合要求' && p.reason === '「名称」为必填项' && /Field required/.test(p.hraw) && p.arr, JSON.stringify(p))
|
||
const s = got.source
|
||
check('同一个键在不同表单上叫法不同:数据源的 name 是「标识」;格式不正确、应为整数逐条说',
|
||
s?.message === '提交的内容不符合要求:「标识」格式不正确;「端口」应为整数', JSON.stringify(s?.message))
|
||
check('数值上限:不能大于 1(1.0 不写成 1.0)', got.memory?.message === '提交的内容不符合要求:「重要度」不能大于 1', JSON.stringify(got.memory?.message))
|
||
check('只能取几个值之一:把可选值列出来', got.conv?.message === '提交的内容不符合要求:「类型」只能取 chat 或 canvas', JSON.stringify(got.conv?.message))
|
||
const w = got.wf
|
||
check('没见过的错误类型、查不到叫法的嵌套字段:写字段路径 + 笼统说法,不露英文',
|
||
/「graph\.nodes\.0\.shape」不符合要求/.test(w?.message ?? '') && !english.test(w?.message ?? ''), JSON.stringify(w?.message))
|
||
check('……英文原文还在 raw 里', /Something went wrong in English/.test(w?.raw ?? ''), (w?.raw ?? '').slice(0, 80))
|
||
for (const [k, v] of Object.entries(got)) {
|
||
const said = k === 'stream' ? v?.error : `${v?.message ?? ''} ${v?.title ?? ''} ${v?.reason ?? ''}`
|
||
check(`${k}:给人看的话里没有英文原文`, !!said && !english.test(said), said)
|
||
}
|
||
check('Copilot 流式接口开流前被 422 拒:onEnd 的话同样说中文', got.stream?.status === 422
|
||
&& got.stream.error === '提交的内容不符合要求:「输入的内容」不能为空', JSON.stringify(got.stream))
|
||
|
||
const unit = await page.evaluate(() => {
|
||
const v = window.__ui.lib.validation
|
||
if (!v) return null
|
||
const d = (type, loc, extra = {}) => ({ type, loc, msg: 'English original', input: null, ...extra })
|
||
const one = (item, path) => v.describeValidation([item], path)[0]
|
||
return {
|
||
query: one(d('int_parsing', ['query', 'limit']), '/runs'),
|
||
pathParam: one(d('missing', ['path', 'run_id']), '/runs/x'),
|
||
json: one(d('json_invalid', ['body', 1], { ctx: { error: 'Expecting value' } }), '/providers'),
|
||
bodyMissing: one(d('missing', ['body']), '/providers'),
|
||
zhValue: one(d('value_error', ['body', 'graph'], { msg: 'Value error, 节点 ID 只能包含字母、数字、下划线和连字符,当前为「node 1」' }), '/workflows'),
|
||
enValue: one(d('value_error', ['body', 'name'], { msg: 'Value error, bad name' }), '/providers'),
|
||
tooLong: one(d('string_too_long', ['body', 'name'], { ctx: { max_length: 100 } }), '/skills'),
|
||
listItem: one(d('dict_type', ['body', 'examples', 0]), '/skills'),
|
||
gt: one(d('greater_than', ['body', 'importance'], { ctx: { gt: 0 } }), '/memory'),
|
||
manyChoices: one(d('literal_error', ['body', 'run_class'], { ctx: { expected: "'a', 'b', 'c', 'd', 'e', 'f', 'g' or 'h'" } }), '/runs'),
|
||
enumInts: one(d('enum', ['body', 'level'], { ctx: { expected: '1, 2 or 3' } }), '/x'),
|
||
bool: one(d('bool_parsing', ['body', 'readonly']), '/datasources'),
|
||
unknownPath: one(d('missing', ['body', 'foo_bar']), '/nowhere'),
|
||
}
|
||
})
|
||
check('lib/validation 挂在预览页上', !!unit)
|
||
if (unit) {
|
||
const want = {
|
||
query: '参数「limit」应为整数',
|
||
pathParam: '地址里的「run_id」为必填项',
|
||
json: '提交的内容不是合法的 JSON',
|
||
bodyMissing: '未收到提交的内容',
|
||
zhValue: '「工作流」:节点 ID 只能包含字母、数字、下划线和连字符,当前为「node 1」',
|
||
enValue: '「名称」取值无效',
|
||
tooLong: '「名称」过长,最多 100 个字符',
|
||
listItem: '「示例」第 1 项应为键值对象',
|
||
gt: '「重要度」应大于 0',
|
||
manyChoices: '「运行类别」不在允许的取值范围内',
|
||
enumInts: '「level」只能取 1、2 或 3',
|
||
bool: '「只读」只能是「是」或「否」',
|
||
unknownPath: '「foo_bar」为必填项',
|
||
}
|
||
for (const [k, w] of Object.entries(want)) {
|
||
check(`describeValidation · ${k}:${w}`, unit[k] === w, unit[k] === w ? '' : `得到 ${JSON.stringify(unit[k])}`)
|
||
}
|
||
}
|
||
})
|
||
|
||
await section('上传进度(kb.upload 走 XHR)', async () => {
|
||
const isUpload = (u) => new URL(u).pathname === '/api/kb/upload'
|
||
const uploadVia = async (handler) => {
|
||
await page.unroute(isUpload)
|
||
await page.route(isUpload, handler)
|
||
}
|
||
const tryUpload = (size) => page.evaluate(async (size) => {
|
||
const events = []
|
||
try {
|
||
const doc = await window.__ui.api.kb.upload(new File([new Uint8Array(size)], 'orders.csv', { type: 'text/csv' }), 'default',
|
||
{ onProgress: (p) => events.push(p) })
|
||
return { events, doc }
|
||
} catch (e) {
|
||
return { events, err: { isApi: e instanceof window.__ui.ApiError, status: e.status, kind: e.kind, message: e.message } }
|
||
}
|
||
}, size)
|
||
// 上传进度只有真的走网络才有(page.route 直接 fulfill 时浏览器不发进度事件)。
|
||
// 转给 dev server 上一个不存在的地址:字节真发出去,回 404,什么都不写
|
||
await uploadVia((r) => r.continue({ url: `${WEB}/__upload_probe__` }))
|
||
let up = await tryUpload(300_000)
|
||
const lastUp = up.events.at(-1)
|
||
check('字节发完报 sent,已发 = 总数,且不小于文件大小', !!lastUp?.sent && lastUp.loaded === lastUp.total && lastUp.total >= 300_000,
|
||
JSON.stringify(up.events.slice(-2)))
|
||
check('sent 只在最后', up.events.filter((p) => p.sent).length === 1 && !up.events.slice(0, -1).some((p) => p.sent))
|
||
check('非 JSON 的错误响应:http 类的 ApiError', up.err?.isApi && up.err.kind === 'http' && up.err.status === 404, JSON.stringify(up.err))
|
||
await uploadVia((r) => r.fulfill({
|
||
status: 200, contentType: 'application/json',
|
||
body: JSON.stringify({ id: 'doc_demo', collection: 'default', title: 'orders.csv', source: 'upload', mime: 'text/csv', chunk_count: 0, status: 'processing' }),
|
||
}))
|
||
up = await tryUpload(10)
|
||
check('成功:返回值照常解析', up.doc?.id === 'doc_demo', JSON.stringify(up.err ?? up.doc))
|
||
await uploadVia((r) => r.fulfill({
|
||
status: 409, contentType: 'application/json', body: JSON.stringify({ detail: '同名文档已经在处理:等它处理完再传' }),
|
||
}))
|
||
up = await tryUpload(10)
|
||
check('后端拒绝:和 fetch 那条路一样的 ApiError,message 是 detail', up.err?.isApi && up.err.status === 409
|
||
&& up.err.kind === 'http' && up.err.message === '同名文档已经在处理:等它处理完再传', JSON.stringify(up.err))
|
||
await uploadVia((r) => r.abort('connectionrefused'))
|
||
up = await tryUpload(10)
|
||
check('连不上:network 类的 ApiError', up.err?.isApi && up.err.kind === 'network' && up.err.status === 0, JSON.stringify(up.err))
|
||
await page.unroute(isUpload)
|
||
// 网络失败会顺手探一次活,等它落定再往下,免得下一段的探活计数多一次
|
||
await page.evaluate(() => window.__ui.useCatalog.getState().checkBackend())
|
||
})
|
||
|
||
const harnessReloaded = reloads > 0
|
||
await section('连接状态:心跳、退避、恢复', async () => {
|
||
const expectRuns = await fetch(`${API}/runs?limit=3`).then((r) => r.json()).then((r) => r.length, () => null)
|
||
await page.evaluate(() => window.__ui.useCatalog.getState().refresh())
|
||
let s = await page.evaluate(() => { const s = window.__ui.useCatalog.getState(); return { backend: s.backend, latency: s.latencyMs, checks: s.checks.map((c) => c.state) } })
|
||
check('refresh 成功:backend=ok、测到延迟、7 项清单都 ok', s.backend === 'ok' && s.latency != null && s.checks.length === 7 && s.checks.every((c) => c === 'ok'), JSON.stringify(s))
|
||
const reconnects0 = (await state()).reconnects
|
||
const loads = async () => Number(await page.locator('#local-list-loads').innerText())
|
||
const count = () => page.locator('#local-list-count').innerText()
|
||
|
||
let probes = 0
|
||
await page.unroute(isApi)
|
||
await page.route(isApi, (r) => {
|
||
if (new URL(r.request().url()).pathname === '/api/health') probes++
|
||
return r.abort('connectionrefused')
|
||
})
|
||
await page.evaluate(() => { window.__stopHeartbeat = window.__ui.useCatalog.getState().startHeartbeat() })
|
||
// 第一次断开:refresh 七个全失败,第一个失败已经起了一次探活——只能记一笔
|
||
await page.evaluate(() => window.__ui.useCatalog.getState().refresh())
|
||
s = await state()
|
||
check('断开判成 down,原因是人话', s.backend === 'down' && s.err && !/failed to fetch/i.test(s.err), JSON.stringify(s))
|
||
check('第一次断开只探活一次', probes === 1, `探了 ${probes} 次`)
|
||
check('第一次重试在 4 秒后(不是 8 秒)', s.retryIn > 3000 && s.retryIn <= 4100, `${s.retryIn}ms`)
|
||
// 页面自己拉的列表在断开期间拿到 []
|
||
await page.click('#local-list-reload')
|
||
await page.waitForTimeout(300)
|
||
check('断开期间页面自己的列表是空的', (await count()) === '0')
|
||
await page.waitForTimeout(s.retryIn + 700)
|
||
s = await state()
|
||
check('第二次重试在 8 秒后', s.backend === 'down' && s.retryIn > 5500 && s.retryIn <= 8100 && probes === 2, `${s.retryIn}ms,探了 ${probes} 次`)
|
||
// 横幅每秒刷一次,读到的可能是上一秒的数
|
||
const shown = Number((await page.getByText(/\d+ 秒后自动重试/).innerText()).match(/\d+/)?.[0])
|
||
const secs = Math.ceil(s.retryIn / 1000)
|
||
check('横幅倒计时和实际重试一致', shown === secs || shown === secs + 1, `横幅 ${shown} 秒,实际 ${secs} 秒`)
|
||
await page.waitForTimeout(s.retryIn + 700)
|
||
s = await state()
|
||
check('第三次重试在 16 秒后', s.backend === 'down' && s.retryIn > 12000 && s.retryIn <= 16100 && probes === 3, `${s.retryIn}ms,探了 ${probes} 次`)
|
||
|
||
await page.unroute(isApi)
|
||
await page.route(isApi, (r) => (r.request().method() === 'GET' ? r.continue() : r.abort()))
|
||
const loadsBefore = await loads()
|
||
await page.getByRole('button', { name: '立即重试' }).first().click()
|
||
await page.waitForTimeout(1500)
|
||
s = await state()
|
||
const checks = await page.evaluate(() => window.__ui.useCatalog.getState().checks.map((c) => c.state))
|
||
check('恢复后 backend=ok,catalog 自动 refresh', s.backend === 'ok' && checks.length === 7 && checks.every((c) => c === 'ok'), JSON.stringify(checks))
|
||
check('恢复算一次重连', s.reconnects === reconnects0 + 1, `${reconnects0} → ${s.reconnects}`)
|
||
check('useOnReconnect:页面自己的列表重拉了一次', (await loads()) === loadsBefore + 1, `${loadsBefore} → ${await loads()}`)
|
||
if (expectRuns != null) {
|
||
check('页面自己的列表不再是假的空', (await count()) === String(expectRuns), `${await count()} 条,后端有 ${expectRuns} 条`)
|
||
}
|
||
check('横幅消失', (await page.getByText('服务端未连接').count()) === 0)
|
||
await page.evaluate(() => window.__ui.useCatalog.getState().refresh())
|
||
check('连着时再 refresh 不算重连', (await state()).reconnects === reconnects0 + 1)
|
||
|
||
// 后端卡死:连得上但不回
|
||
await page.route((u) => new URL(u).pathname === '/api/health', () => { /* 永远不回 */ })
|
||
await page.route((u) => new URL(u).pathname.startsWith('/api/approvals'), (r) => r.abort('connectionrefused'))
|
||
const t0 = Date.now()
|
||
await page.evaluate(() => window.__ui.useCatalog.getState().refreshApprovals())
|
||
s = await state()
|
||
check('健康检查卡住时 5 秒超时判 down', s.backend === 'down' && Date.now() - t0 < 8000, `${JSON.stringify(s)} ${Date.now() - t0}ms`)
|
||
await page.evaluate(() => window.__stopHeartbeat())
|
||
|
||
})
|
||
await section('真实页面:toast 不压工具栏', async () => {
|
||
// 工具栏是各页自己的,谁把它加高了,常驻的出错 toast 就会盖住按钮
|
||
await page.unrouteAll({ behavior: 'ignoreErrors' })
|
||
await page.route(isApi, (r) => (r.request().method() === 'GET' ? r.continue() : r.abort()))
|
||
const wf = await fetch(`${API}/workflows`).then((r) => r.json()).then((l) => l[0]?.id, () => null)
|
||
for (const width of [1280, 1180]) {
|
||
await page.setViewportSize({ width, height: 800 })
|
||
for (const path of ['/chat', wf ? `/studio/${wf}` : '/studio', '/runs', '/tools', '/knowledge', '/settings']) {
|
||
await page.goto(`${WEB}${path}`, { waitUntil: 'networkidle' })
|
||
await page.waitForTimeout(300)
|
||
const r = await page.evaluate(() => {
|
||
const stack = document.querySelector('[role="alert"][aria-live="assertive"]')?.parentElement
|
||
if (!stack) return null
|
||
const box = stack.getBoundingClientRect()
|
||
// 从顶部 48px 那一条里起头的可点元素 = 工具栏。下面列表的行被盖住一角是
|
||
// 浮层的本分,不算
|
||
const hits = [...document.querySelectorAll('main button, main a, main input, main select, main [role="tab"]')]
|
||
.map((el) => ({ el, b: el.getBoundingClientRect() }))
|
||
.filter(({ b }) => b.width && b.height && b.top < 48 && b.bottom > box.top && b.right > box.left && b.left < box.right)
|
||
.map(({ el }) => (el.getAttribute('aria-label') || el.textContent || el.tagName).trim().slice(0, 16))
|
||
return { top: Math.round(box.top), hits }
|
||
})
|
||
check(`${width}px ${path.replace(/[0-9a-f]{32}/, ':id')}:toast 区不压工具栏`, r && r.hits.length === 0, r ? (r.hits.join('、') || `top=${r.top}`) : '页面上没有 ToastHost')
|
||
}
|
||
}
|
||
|
||
check('没有未捕获的运行时错误', errors.length === 0, errors[0] ?? '')
|
||
if (harnessReloaded) console.log('\n!预览页中途被 vite 整页刷新过(有别的文件在改),上面的失败可能是它引起的,重跑一次')
|
||
})
|
||
await browser.close()
|
||
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 基础组件全部通过')
|
||
process.exit(failed ? 1 : 0)
|