mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:51:59 +00:00
业务本体与标准实体:跨源键值对齐、对账共变算子、健康证据链与实体助手
- 新增标准业务实体(CanonicalEntity)模型、确定性跨源编码翻译、后台候选实体扫描与文档驱动实体智能助手 - 新增指标本体图谱(factor / reconciles_with / upstream_driver)、DAG 防环校验以及 ontology_neighbors / source_health 内置工具 - 新增跨源对账(analysis_reconcile)与滞后共变(analysis_cochange)分析算子,打通 [[h:H1]] 数据源健康证据链、报告导出与本体消融评测 - 数据目录新增「标准业务实体」视图与候选审阅工作台,口径页集成指标本体拓扑面板与端到端浏览器检查
This commit is contained in:
@@ -101,6 +101,7 @@ const ORDER = [
|
||||
// + 文档更新 / 增量重导入(K11:三方逐格、按工作项身份接受与拒绝、人工对齐、漏项不当删除、
|
||||
// 恢复、旧 L 重算三方、覆盖)+ 一句话改口径(K12:只读预览、澄清、确认之后才落一版、
|
||||
// 输入改过必须重新预览)
|
||||
'ontology', // 业务本体与跨源指标穿透:数据目录标准实体视图、口径本体拓扑与 /links 编辑、[[h:H1]] 源健康证据链与异常升级、ontology_tools 评测归因
|
||||
// 应用平台(docs/design/app-platform-and-look.md §4.5)。check-auth 不在这里:它要另起一套开着本地账号登录的服务,
|
||||
// 由编排者单独跑(关登录时的这些检查照常在这里跑)
|
||||
'apps', // 应用页、印章、点数字看出处、入口校验、实例页
|
||||
@@ -133,6 +134,7 @@ const ESTIMATE = {
|
||||
// 口径工作台(K06)加文档更新与一句话(K11 / K12):2026-11-10 本地实测 60 秒上下(全是伪造接口、不连真数据,
|
||||
// 机器一挤就慢)
|
||||
'caliber-drafts': 65,
|
||||
ontology: 25,
|
||||
// 上面的 evidence、compare、bundle 按分析能力一轮第 2 波之后(2026-10-21,两套服务、八道并行)的实测改过:证据面板、
|
||||
// 运行对比、可验证报告各加了分析结论的几段
|
||||
}
|
||||
|
||||
+39
-1
@@ -582,7 +582,10 @@ await section('入口:使用者选人只出候选池里的人,不在池里
|
||||
await field.waitFor({ timeout: 15000 })
|
||||
const input = field.locator('input.field')
|
||||
await input.fill('费')
|
||||
await page.waitForFunction(() => !!document.querySelector('[data-field="approver"] datalist option'), null, { timeout: 5000 })
|
||||
await page.waitForFunction(() => {
|
||||
const options = [...document.querySelectorAll('[data-field="approver"] datalist option')]
|
||||
return options.length === 1 && options[0].getAttribute('value') === 'fay'
|
||||
}, null, { timeout: 5000 })
|
||||
.catch(() => {})
|
||||
const options = await field.locator('datalist option').evaluateAll((els) => els.map((e) => `${e.getAttribute('value')}|${e.textContent}`))
|
||||
check('边输入边按开头取候选:列表里是候选池给的人,显示名作说明', options.join(',') === 'fay|费伊', options.join(','))
|
||||
@@ -675,6 +678,41 @@ await section('实例页:状态、进度、待办卡、动作状态', async ()
|
||||
check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '')
|
||||
})
|
||||
|
||||
await section('实例页:慢轮询不叠加、离开后不再轮询', async () => {
|
||||
const { page } = await open({ state: { writes: [] } })
|
||||
await page.route('**/api/auth/me', (route) => route.fulfill({ json: {
|
||||
mode: 'local', username: 'tester', display_name: '测试使用者', role: 'user', groups: [], verified: true,
|
||||
} }))
|
||||
await page.route('**/api/health', (route) => route.fulfill({ json: { status: 'ok', service: 'agentlab' } }))
|
||||
let hits = 0
|
||||
let active = 0
|
||||
let maxActive = 0
|
||||
let slow = false
|
||||
await page.route('**/api/apps/app_claim/instances/run_claim_1', async (route) => {
|
||||
hits++
|
||||
active++
|
||||
maxActive = Math.max(maxActive, active)
|
||||
if (slow) await new Promise((resolve) => setTimeout(resolve, 5200))
|
||||
active--
|
||||
await route.fulfill({ json: { ...INSTANCE, status: 'running' } }).catch(() => {})
|
||||
})
|
||||
await page.goto(`${WEB}/apps/app_claim/i/run_claim_1`)
|
||||
await page.locator('[data-instance-follow="poll"]').waitFor({ timeout: 15000 })
|
||||
const initialHits = hits
|
||||
maxActive = 0
|
||||
slow = true
|
||||
// 等一轮耗时超过轮询间隔的请求完成。旧实现会在 8 秒处再发一条,持续作废较早的结果。
|
||||
await page.waitForTimeout(10000)
|
||||
check('慢于轮询周期的请求最多一条在途', maxActive === 1, `最大并发 ${maxActive},共 ${hits} 次`)
|
||||
check('请求结束后才安排下一轮', hits === initialHits + 1, `初次 ${initialHits},总共 ${hits} 次`)
|
||||
await page.locator('nav a[href="/apps"]').first().click()
|
||||
await page.waitForURL(/\/apps$/)
|
||||
const stoppedAt = hits
|
||||
await page.waitForTimeout(4500)
|
||||
check('离开实例页后停止轮询', hits === stoppedAt, `${stoppedAt} → ${hits}`)
|
||||
check('没有未捕获错误', page.errors.length === 0, page.errors[0] ?? '')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 页面上直接改(N6)
|
||||
|
||||
await section('应用页:编辑状态、改文字、隐藏、上下挪、删除、加一张数据卡片、发布', async () => {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
// - 非 GET 请求都带 X-AgentLab-Request 头(服务端开登录时据此防跨站);
|
||||
// - 使用者(user 角色):导航只有「应用」和「设置」,别的地址送回应用首页,⌥ 数字切不到看不见的页;没有署名框,
|
||||
// 账户菜单写显示名、角色、退出登录;待办卡写「以登录身份处理」;
|
||||
// - 搭建者:导航十项都在(第 4 波加了「评测」,搭建者以上可见),账户菜单写搭建者;
|
||||
// - 搭建者:导航十一项都在(第 4 波加了「评测」,搭建者以上可见),账户菜单写搭建者;
|
||||
// - 关登录:/login 直接回首页;导航底部是署名入口,没有账户菜单;应用首页挂「未开启登录」的说明;
|
||||
// - 管理员允许使用者问数据时(D-c):问数据页只取数据源的名字(/api/datasources/names),不取完整列表和表结构;
|
||||
// - 使用者的设置页只剩偏好设置(主题)和通知,换主题只存本机、不写服务端(D-s);
|
||||
@@ -247,7 +247,9 @@ await section('使用者:导航只有应用和设置、账户菜单、没有
|
||||
&& (await page.locator('[data-task-signer]').innerText()).includes('张三(zhangsan)'))
|
||||
await nav(page).locator('[data-account]').click()
|
||||
await page.locator('[data-logout]').click()
|
||||
check('退出登录回到登录页', await page.waitForURL(/\/login/, { timeout: 8000 }).then(() => true, () => false), page.url())
|
||||
const loggedOut = await page.waitForURL(/\/login/, { timeout: 15000, waitUntil: 'domcontentloaded' }).then(() => true, () => false)
|
||||
check('退出登录回到登录页', loggedOut, loggedOut ? page.url() : `${page.url()} ${await page.locator('body').innerText()}`)
|
||||
check('退出没有未捕获的运行时错误', page.errors.length === 0, page.errors.join(';'))
|
||||
check('退出请求带 X-AgentLab-Request 头', state.writes.find((w) => w.path === '/api/auth/logout')?.csrf === '1')
|
||||
})
|
||||
|
||||
@@ -262,12 +264,12 @@ await section('会话中途失效:401 整页跳登录', async () => {
|
||||
check('任何接口回 401 都跳 /login,带上原处', await page.waitForURL(/\/login\?next=/, { timeout: 15000 }).then(() => true, () => false), page.url())
|
||||
})
|
||||
|
||||
await section('搭建者:导航十项、账户菜单', async () => {
|
||||
await section('搭建者:导航十一项、账户菜单', async () => {
|
||||
const state = { mode: 'local', who: 'builder', writes: [] }
|
||||
const page = await open(state)
|
||||
await page.goto(`${WEB}/apps`)
|
||||
await nav(page).waitFor({ timeout: 15000 })
|
||||
check('导航十项都在(含搭建者以上可见的「评测」)', (await navLabels(page)).length === 10 && (await navLabels(page)).includes('评测'),
|
||||
check('导航十一项都在(含搭建者以上可见的「评测」)', (await navLabels(page)).length === 11 && (await navLabels(page)).includes('评测'),
|
||||
(await navLabels(page)).join(','))
|
||||
await nav(page).locator('[data-account]').click()
|
||||
check('账户菜单写搭建者', (await page.locator('[data-account-role]').innerText()) === '搭建者')
|
||||
@@ -293,7 +295,7 @@ await section('关登录:登录页回首页、署名入口、说明行', async
|
||||
await page.goto(`${WEB}/login`)
|
||||
check('/login 回首页', await page.waitForURL(/\/chat/, { timeout: 15000 }).then(() => true, () => false), page.url())
|
||||
await nav(page).waitFor({ timeout: 8000 })
|
||||
check('导航十项', (await navLabels(page)).length === 10)
|
||||
check('导航十一项', (await navLabels(page)).length === 11)
|
||||
check('底部是署名入口', (await nav(page).locator('a[href="/settings/prefs"]').count()) === 1)
|
||||
check('没有账户菜单', (await nav(page).locator('[data-account]').count()) === 0)
|
||||
await page.goto(`${WEB}/apps`)
|
||||
@@ -339,7 +341,7 @@ await section('使用者:个人应用开关关着时没有新建入口,开
|
||||
check('关着:页头没有「新建应用」', await page.locator('[data-app-create]').count() === 0)
|
||||
check('……没有「我的应用」一组', await page.locator('[data-app-group="personal"]').count() === 0)
|
||||
await page.goto(`${WEB}/apps/create`)
|
||||
await page.locator('[data-new-app]').waitFor({ timeout: 8000 })
|
||||
await page.getByText('管理员还没有开放个人应用', { exact: true }).waitFor({ timeout: 8000 })
|
||||
check('……打开新建页:写明管理员还没有开放个人应用,不给起点', (await page.locator('[data-new-app]').getAttribute('data-new-app')) === 'none'
|
||||
&& (await page.locator('main').innerText()).includes('管理员还没有开放个人应用'))
|
||||
await page.goto(`${WEB}/blueprints/bp_mine`)
|
||||
|
||||
@@ -925,7 +925,7 @@ await section('样本页:各种形状', async () => {
|
||||
await phrase.getAttribute('aria-label'))
|
||||
const detail = await detailText(page, phrase)
|
||||
check('analysis:点开判定语:判定语、判定标准、方法与版本(服务端带的叫法)', detail.includes('判定语')
|
||||
&& detail.includes('一段占总变化的一半以上算主要来源') && detail.includes('变化拆解(可加) · decompose.additive@1'),
|
||||
&& detail.includes('一段占总变化的一半以上算主要来源') && detail.includes('变化拆解(可加) · decompose.additive@2'),
|
||||
detail.replace(/\s+/g, ' ').slice(0, 300))
|
||||
// 这三份分析都是 Agent 现场做的(台账条目带 call_id):那一句照旧标「Agent 写的假设」
|
||||
check('analysis:……附带说明、Agent 写的假设', detail.includes('附带说明') && detail.includes('Agent 写的假设')
|
||||
|
||||
@@ -536,6 +536,8 @@ await section('撤销:按保存前的值构造逆操作,带当前版本和
|
||||
check('撤销用一个**新的**请求键', typeof undo.request_id === 'string' && undo.request_id
|
||||
&& undo.request_id !== seen[0].request_id, `${seen[0].request_id} / ${undo.request_id}`)
|
||||
check('撤销不走整份覆盖(没有 metrics)', undo.metrics === undefined)
|
||||
// 收到 PATCH 不等于响应已渲染:等新版本的回执,避免页面较忙时读到上一版。
|
||||
await until(async () => (await textOf(page.locator('[data-draft-rule-result]'))).includes('已保存成 v9'))
|
||||
const after = await textOf(page.locator('[data-draft-rule-result]'))
|
||||
check('撤销后回执写清新版本', after.includes('已保存成 v9'), after)
|
||||
check('撤销记在这次会话里(提示已撤销)', await page.locator('[data-draft-rule-undo]').count() === 0)
|
||||
@@ -686,6 +688,7 @@ await section('验证:独立期望试运行、样例对得上、审阅确认
|
||||
const { page, errors } = await pageOn(state)
|
||||
await expand(page, state.draft)
|
||||
const panel = validationPanel(page)
|
||||
await panel.waitFor({ state: 'visible' })
|
||||
check('验证一块画出来了', await panel.count() === 1)
|
||||
check('没有独立期望的记录如实标成「只记了试运行结果」',
|
||||
await panel.getAttribute('data-draft-validation') === 'observed',
|
||||
@@ -852,6 +855,7 @@ await section('契约防御:试跑响应里的 validation 只有记录那一
|
||||
const trialText = await textOf(trialBlock)
|
||||
check('读不到质量状态时不写「质量检查通过」', !trialText.includes('质量检查通过'), trialText)
|
||||
check('照实说质量结论还没有', !trialText.includes('质量检查未通过') && trialText.includes('尚未完成质量验证'), trialText)
|
||||
await trialBlock.locator('[data-draft-trial-record]').waitFor({ state: 'visible' })
|
||||
check('试运行块里只画这一次的那条记录',
|
||||
await trialBlock.locator('[data-draft-trial-record="matched"]').count() === 1,
|
||||
await trialBlock.locator('[data-draft-trial-record]').count() + ' 条')
|
||||
@@ -1618,6 +1622,7 @@ await section('重复工作项:同一条指标上有两条候选时,接受 /
|
||||
}
|
||||
const { page, errors } = await pageOn(state)
|
||||
await expand(page, state.draft)
|
||||
await until(async () => await page.locator('[data-draft-import-item="m1"]').count() === 2)
|
||||
check('两条候选都摆出来了(同一条指标)', await page.locator('[data-draft-import-item="m1"]').count() === 2,
|
||||
String(await page.locator('[data-draft-import-item="m1"]').count()))
|
||||
// 只对第二条候选的那一格点接受
|
||||
@@ -1860,6 +1865,308 @@ await section('验证反例:页面写的是「还不能算验证通过」,
|
||||
check('页面没有报错', errors.length === 0, errors.slice(0, 2).join(';'))
|
||||
})
|
||||
|
||||
await section('语义问数:常用叫法可输入多个,对象日期绑定按版本保存', async () => {
|
||||
const state = makeState({ draft: draftOf({ draft: {
|
||||
metrics: [{ id: 'm1', name: '日 OEE', grain: '工厂、日单值' }], metric_count: 1,
|
||||
entry_fields: ['start', 'end', 'plant'].map(name => ({ name, type: 'string', required: true })),
|
||||
} }), validations: { records: [], summary: summaryOf({ verdict: 'none', status: null }) } })
|
||||
state.patch = body => {
|
||||
state.draft = { ...state.draft, revision: 8, metrics: [{ ...state.draft.metrics[0], semantic: body.operations[0].value }] }
|
||||
return { json: clone(state.draft) }
|
||||
}
|
||||
const { page, errors } = await pageOn(state)
|
||||
await expand(page, state.draft)
|
||||
const panel = page.locator('[data-query-semantics]')
|
||||
await panel.locator('summary').click()
|
||||
await panel.getByLabel('常用叫法(逗号分隔)').fill('OEE,设备综合效率')
|
||||
await panel.getByLabel('汇总与缺失规则').fill('按实际开班时长加权,缺失不写零')
|
||||
await panel.getByLabel('按日期查询').check()
|
||||
await panel.getByLabel('开始(含)').selectOption('start')
|
||||
await panel.getByLabel('结束(不含)').selectOption('end')
|
||||
await panel.getByLabel('日期规则说明').fill('按已确认的生产日归属')
|
||||
await panel.getByRole('button', { name: '添加对象类型', exact: true }).click()
|
||||
await panel.getByLabel('对象类型', { exact: true }).fill('工厂')
|
||||
await panel.getByLabel('绑定参数').selectOption('plant')
|
||||
await panel.getByRole('button', { name: '添加对象', exact: true }).click()
|
||||
await panel.getByLabel('工厂 value', { exact: true }).fill('1050')
|
||||
await panel.getByLabel('工厂 label', { exact: true }).fill('工厂甲')
|
||||
await panel.getByLabel('已确认上述定义与取数实现一致,发布后启用直接问数').check()
|
||||
// 普通 HTTP 部署中没有 randomUUID,仍要能保存;这不是生成安全凭据。
|
||||
await page.evaluate(() => Object.defineProperty(window.crypto, 'randomUUID', { value: undefined, configurable: true }))
|
||||
await panel.getByRole('button', { name: '保存问数定义' }).click()
|
||||
await until(() => patchBodies(state).length)
|
||||
const body = patchBodies(state)[0]
|
||||
check('单次字段变更带基准版本和目标卡', body?.expected_revision === 7 && body?.target?.card_id === 'card' && !!body?.request_id)
|
||||
check('多个常用叫法均保留', JSON.stringify(body?.operations[0]?.value?.aliases) === JSON.stringify(['OEE', '设备综合效率']))
|
||||
check('实体编号绑定到工厂参数', body?.operations[0]?.value?.entities[0]?.field === 'plant'
|
||||
&& body.operations[0].value.entities[0].members[0].value === '1050')
|
||||
check('默认只允许单日,未暗中启用月汇总', JSON.stringify(body?.operations[0]?.value?.time?.periods) === '["day"]')
|
||||
check('页面没有报错', errors.length === 0, errors.join(';'))
|
||||
})
|
||||
|
||||
await section('语义编辑恢复:保存其他指标保留未保存内容并使用新版本', async () => {
|
||||
const state = makeState({ draft: draftOf({ draft: {
|
||||
metrics: [{ id: 'm1', name: '指标甲' }, { id: 'm2', name: '指标乙' }], metric_count: 2,
|
||||
} }), validations: { records: [], summary: summaryOf({ verdict: 'none', status: null }) } })
|
||||
state.patch = body => {
|
||||
const op = body.operations[0]
|
||||
state.draft = { ...state.draft, revision: state.draft.revision + 1,
|
||||
metrics: state.draft.metrics.map(metric => metric.id === op.metric_id ? { ...metric, semantic: op.value } : metric) }
|
||||
return { json: clone(state.draft) }
|
||||
}
|
||||
const { page, errors } = await pageOn(state)
|
||||
await expand(page, state.draft)
|
||||
const first = page.locator('[data-query-semantics]').nth(0)
|
||||
const second = page.locator('[data-query-semantics]').nth(1)
|
||||
await first.locator('summary').click()
|
||||
await second.locator('summary').click()
|
||||
await first.getByLabel('汇总与缺失规则').fill('甲的汇总规则')
|
||||
await second.getByLabel('汇总与缺失规则').fill('乙尚未保存的规则')
|
||||
await first.getByRole('button', { name: '保存问数定义' }).click()
|
||||
await until(() => state.draft.revision === 8)
|
||||
await until(async () => !await first.getByText('有未保存的修改;保存其他指标不会覆盖这里的内容。').count())
|
||||
check('保存甲后乙仍展开且保留输入', await second.getByLabel('汇总与缺失规则').isVisible()
|
||||
&& await second.getByLabel('汇总与缺失规则').inputValue() === '乙尚未保存的规则')
|
||||
await second.getByRole('button', { name: '保存问数定义' }).click()
|
||||
await until(() => patchBodies(state).length === 2)
|
||||
check('乙按新版本保存原编辑内容', patchBodies(state)[1]?.expected_revision === 8
|
||||
&& patchBodies(state)[1]?.operations[0]?.value?.aggregation === '乙尚未保存的规则')
|
||||
check('页面没有报错', errors.length === 0, errors.join(';'))
|
||||
})
|
||||
|
||||
await section('语义编辑恢复:同指标外部更新阻止覆盖并可载入最新版', async () => {
|
||||
const state = makeState({ draft: draftOf({ draft: {
|
||||
metrics: [{ id: 'm1', name: '指标甲' }, { id: 'm2', name: '指标乙' }], metric_count: 2,
|
||||
} }), validations: { records: [], summary: summaryOf({ verdict: 'none', status: null }) } })
|
||||
state.patch = body => {
|
||||
state.draft = { ...state.draft, revision: 9, metrics: [
|
||||
{ ...state.draft.metrics[0], semantic: body.operations[0].value },
|
||||
{ ...state.draft.metrics[1], semantic: { version: 1, enabled: false, aliases: [], grain: '',
|
||||
aggregation: '其他人更新的规则', time: null, entities: [] } },
|
||||
] }
|
||||
return { json: clone(state.draft) }
|
||||
}
|
||||
const { page, errors } = await pageOn(state)
|
||||
await expand(page, state.draft)
|
||||
const first = page.locator('[data-query-semantics]').nth(0)
|
||||
const second = page.locator('[data-query-semantics]').nth(1)
|
||||
await first.locator('summary').click()
|
||||
await second.locator('summary').click()
|
||||
await second.getByLabel('汇总与缺失规则').fill('乙本地未保存的规则')
|
||||
await first.getByRole('button', { name: '保存问数定义' }).click()
|
||||
const reload = second.getByRole('button', { name: '载入最新配置(放弃本地修改)' })
|
||||
await reload.waitFor({ state: 'visible' })
|
||||
check('同指标发生冲突时保留输入并禁止保存', await second.getByRole('button', { name: '保存问数定义' }).isDisabled()
|
||||
&& await second.getByLabel('汇总与缺失规则').inputValue() === '乙本地未保存的规则')
|
||||
await reload.click()
|
||||
check('明确放弃后载入服务端配置并可继续保存', await second.getByLabel('汇总与缺失规则').inputValue() === '其他人更新的规则'
|
||||
&& await second.getByRole('button', { name: '保存问数定义' }).isEnabled())
|
||||
check('页面没有报错', errors.length === 0, errors.join(';'))
|
||||
})
|
||||
|
||||
await section('语义编辑恢复:关闭重开后旧保存响应不覆盖新详情', async () => {
|
||||
const state = makeState({ draft: draftOf({ draft: {
|
||||
metrics: [{ id: 'm1', name: '指标甲' }], metric_count: 1,
|
||||
} }), validations: { records: [], summary: summaryOf({ verdict: 'none', status: null }) } })
|
||||
let release
|
||||
state.patch = body => new Promise(resolve => { release = () => resolve({ json: {
|
||||
...clone(state.draft), revision: 8,
|
||||
metrics: [{ ...state.draft.metrics[0], semantic: body.operations[0].value }],
|
||||
} }) })
|
||||
const { page, errors } = await pageOn(state)
|
||||
await expand(page, state.draft)
|
||||
const panel = page.locator('[data-query-semantics]')
|
||||
await panel.locator('summary').click()
|
||||
await panel.getByLabel('汇总与缺失规则').fill('旧保存内容')
|
||||
await panel.getByRole('button', { name: '保存问数定义' }).click()
|
||||
await until(() => release)
|
||||
await page.locator(`[data-draft="${state.draft.id}"] [data-draft-toggle]`).click()
|
||||
state.draft = { ...state.draft, revision: 9, metrics: [{ id: 'm1', name: '新版本指标' }] }
|
||||
await expand(page, state.draft)
|
||||
release()
|
||||
await page.waitForTimeout(200)
|
||||
check('旧响应没有覆盖重开的新版本', (await panel.innerText()).includes('新版本指标'))
|
||||
check('页面没有报错', errors.length === 0, errors.join(';'))
|
||||
})
|
||||
|
||||
await section('语义编辑恢复:慢网络起草轮询不并发堆积', async () => {
|
||||
const state = makeState({ draft: draftOf({ draft: { status: 'drafting' } }),
|
||||
validations: { records: [], summary: summaryOf({ verdict: 'none', status: null }) } })
|
||||
const original = state.reply
|
||||
let requests = 0, active = 0, peak = 0
|
||||
state.reply = async (method, path, body, url) => {
|
||||
if (method === 'GET' && path === `/ledger/drafts/${state.draft.id}` && ++requests > 1) {
|
||||
active++; peak = Math.max(peak, active)
|
||||
await new Promise(resolve => setTimeout(resolve, 3000))
|
||||
active--
|
||||
}
|
||||
return original(method, path, body, url)
|
||||
}
|
||||
const { page, errors } = await pageOn(state)
|
||||
await expand(page, state.draft)
|
||||
await page.waitForTimeout(8500)
|
||||
check('两轮以上慢请求仍最多一个在途', requests >= 3 && peak === 1, `requests=${requests}, peak=${peak}`)
|
||||
check('页面没有报错', errors.length === 0, errors.join(';'))
|
||||
})
|
||||
|
||||
await section('语义编辑恢复:补充缺失指标候选可合并回原稿', async () => {
|
||||
const item = id => ({ id, name: id, kind: 'metric', status: 'definition_only', fields: {},
|
||||
dependencies: [], source: { document_id: 'doc', line: 1, quote: id } })
|
||||
const originalDraft = draftOf({ draft: { status: 'questions_open',
|
||||
metrics: [{ id: 'm1', name: '已有指标', source_definition_id: 'def1' }], metric_count: 1,
|
||||
selected_definition_ids: ['def1', 'def2'],
|
||||
semantic_inventory: { version: 1, items: [item('def1'), item('def2')], rules: [], warnings: [] },
|
||||
} })
|
||||
const state = makeState({ draft: clone(originalDraft),
|
||||
validations: { records: [], summary: summaryOf({ verdict: 'none', status: null }) } })
|
||||
const originalReply = state.reply
|
||||
let continued, merged, mergeAttempts = 0
|
||||
state.reply = async (method, path, body, url) => {
|
||||
if (method === 'POST' && path === `/ledger/drafts/${originalDraft.id}/continue`) {
|
||||
continued = body
|
||||
state.draft = { ...clone(originalDraft), id: 'd-candidate', title: '补充指标候选', revision: 3,
|
||||
metrics: [{ id: 'm2', name: '补充指标', source_definition_id: 'def2' }],
|
||||
origin: { continuation_from: { draft_id: originalDraft.id, revision: 7, merge_required: true } } }
|
||||
return { json: { draft_id: state.draft.id, status: state.draft.status, original_draft_id: originalDraft.id, merge_required: true } }
|
||||
}
|
||||
if (method === 'GET' && path === `/ledger/drafts/${originalDraft.id}` && state.draft.id !== originalDraft.id)
|
||||
return { json: { ...clone(originalDraft), revision: 8 } }
|
||||
if (method === 'POST' && path === '/ledger/drafts/d-candidate/merge') {
|
||||
mergeAttempts++
|
||||
if (mergeAttempts === 1) return { status: 409, json: { detail: '原稿已变化,请核对后重试' } }
|
||||
merged = body
|
||||
state.draft = { ...clone(originalDraft), revision: 9, metrics: [...originalDraft.metrics, ...state.draft.metrics] }
|
||||
return { json: { ...clone(state.draft), merged_candidate_id: 'd-candidate' } }
|
||||
}
|
||||
return originalReply(method, path, body, url)
|
||||
}
|
||||
const { page, errors } = await pageOn(state)
|
||||
await expand(page, state.draft)
|
||||
let panel = page.locator('[data-draft-continuation]')
|
||||
await panel.getByLabel('补充说明').fill('使用已核实的产量字段')
|
||||
await panel.getByRole('button', { name: '继续处理未完成指标' }).click()
|
||||
await until(() => continued)
|
||||
check('继续请求包含基准版本和补充说明', continued?.expected_revision === 7 && continued?.guidance === '使用已核实的产量字段')
|
||||
await panel.getByRole('button', { name: '合并补充指标到原稿' }).waitFor({ state: 'visible' })
|
||||
await panel.getByRole('button', { name: '合并补充指标到原稿' }).click()
|
||||
await panel.getByText('原稿已变化,请核对后重试', { exact: false }).first().waitFor({ state: 'visible' })
|
||||
check('版本冲突保留候选和重试入口', state.draft.id === 'd-candidate'
|
||||
&& await panel.getByRole('button', { name: '合并补充指标到原稿' }).isEnabled())
|
||||
await panel.getByRole('button', { name: '合并补充指标到原稿' }).click()
|
||||
await until(() => merged)
|
||||
check('合并采用原稿最新版本及候选版本', merged?.expected_revision === 8 && merged?.candidate_revision === 3)
|
||||
await until(async () => await page.locator('[data-query-semantics]').count() === 2)
|
||||
check('合并后回到保留已有指标的原稿', await page.locator('[data-query-semantics]').count() === 2
|
||||
&& state.draft.metrics[0].name === '已有指标')
|
||||
check('页面没有报错', errors.length === 0, errors.join(';'))
|
||||
})
|
||||
|
||||
await section('语义问数:定义清单保留未实现项,无数据源指标不能被选择', async () => {
|
||||
const item = (id, name, status) => ({ id, name, kind: 'metric', status, section: 'OEE 系列', formula: '原文公式',
|
||||
policy: '缺失不写零', fields: { 指标: name, 公式: '原文公式' }, dependencies: [],
|
||||
source: { document_id: 'doc', line: 8, quote: '| 原文公式 |' } })
|
||||
const state = makeState({ draft: draftOf({ draft: {
|
||||
status: 'path_pending', workflow_id: null, revision: null, card_id: null, metrics: [], entry_fields: [],
|
||||
semantic_inventory: { version: 1, note: '尚未绑定数据', rules: [], items: [
|
||||
item('day', '日 OEE', 'definition_only'), item('missing', '人均效率', 'unavailable'),
|
||||
] },
|
||||
} }), validations: { records: [], summary: summaryOf({ verdict: 'none', status: null }) } })
|
||||
const original = state.reply
|
||||
let implementation
|
||||
state.reply = async (method, p, body, url) => {
|
||||
if (p === '/datasources/names') return { json: [{ id: 'source', name: '合成测试库', enabled: true }] }
|
||||
if (method === 'POST' && p.endsWith('/implement')) {
|
||||
implementation = body
|
||||
return { status: 202, json: { draft_id: state.draft.id, status: 'drafting' } }
|
||||
}
|
||||
return original(method, p, body, url)
|
||||
}
|
||||
const { page, errors } = await pageOn(state)
|
||||
await expand(page, state.draft)
|
||||
const panel = page.locator('[data-semantic-inventory]')
|
||||
check('默认不要求逐项勾选', await panel.locator('input[type="checkbox"]').count() === 0)
|
||||
await panel.getByRole('button', { name: '仅处理部分指标', exact: true }).click()
|
||||
check('无数据源定义保留但禁选', await panel.getByLabel('选择 人均效率', { exact: true }).isDisabled())
|
||||
await panel.getByLabel('选择 日 OEE', { exact: true }).check()
|
||||
await panel.getByLabel('用于实现的数据源').selectOption('source')
|
||||
await panel.getByRole('button', { name: '开始处理选中的 1 项' }).click()
|
||||
await until(() => implementation)
|
||||
check('只提交选中的定义和数据源', JSON.stringify(implementation) === JSON.stringify({ selected: ['day'], datasource_id: 'source' }))
|
||||
check('页面没有报错', errors.length === 0, errors.join(';'))
|
||||
})
|
||||
|
||||
await section('自动处理:默认全部、无需手动分批,持续展示进度和缺口', async () => {
|
||||
const items = Array.from({ length: 13 }, (_, i) => ({ id: `metric-${i}`, name: `指标${i}`, kind: 'metric',
|
||||
status: 'definition_only', fields: {}, dependencies: [], source: { document_id: 'doc', line: i + 1, quote: '原文' } }))
|
||||
items.push({ ...items[0], id: 'missing', name: '无来源指标', status: 'unavailable' })
|
||||
const state = makeState({ draft: draftOf({ draft: {
|
||||
status: 'path_pending', datasource_id: null, workflow_id: null, revision: null, card_id: null,
|
||||
metrics: [], entry_fields: [], semantic_inventory: { version: 1, rules: [], items },
|
||||
} }), validations: { records: [], summary: summaryOf({ verdict: 'none', status: null }) } })
|
||||
const original = state.reply
|
||||
let implementation
|
||||
state.reply = async (method, p, body, url) => {
|
||||
if (p === '/datasources/names') return { json: [{ id: 'source', name: '唯一已授权测试库', enabled: true }] }
|
||||
if (method === 'POST' && p.endsWith('/implement')) {
|
||||
implementation = body
|
||||
state.draft = { ...state.draft, status: 'drafting', selected_definition_ids: items.slice(0, 13).map(i => i.id),
|
||||
implementation_mode: 'all', implementation_progress: { total: 13, completed: 6, pending: 7, blocked: 0, active_names: ['指标6'] },
|
||||
definition_issues: [{ definition_id: 'missing', name: '无来源指标', reason: '原文注明无数据源' }] }
|
||||
return { status: 202, json: { draft_id: state.draft.id, status: 'drafting' } }
|
||||
}
|
||||
return original(method, p, body, url)
|
||||
}
|
||||
const { page, errors } = await pageOn(state)
|
||||
await expand(page, state.draft)
|
||||
const panel = page.locator('[data-semantic-inventory]')
|
||||
check('默认清单没有勾选框', await panel.locator('input[type="checkbox"]').count() === 0)
|
||||
check('预告全部可实现项,排除无来源项', (await panel.innerText()).includes('将自动处理 13 项'))
|
||||
await panel.getByRole('button', { name: '仅处理部分指标', exact: true }).click()
|
||||
for (let i = 0; i < 7; i++) await panel.getByLabel(`选择 指标${i}`, { exact: true }).check()
|
||||
check('可选范围不受内部六项批次限制', await panel.getByRole('button', { name: '开始处理选中的 7 项' }).isEnabled())
|
||||
await panel.getByRole('button', { name: '恢复处理全部指标', exact: true }).click()
|
||||
await panel.getByRole('button', { name: '自动处理全部可实现指标', exact: true }).click()
|
||||
await until(() => implementation)
|
||||
check('默认提交全部模式,没有偷偷沿用手选范围', JSON.stringify(implementation) === '{"datasource_id":"source"}')
|
||||
await panel.locator('[data-implementation-progress]').waitFor()
|
||||
const text = await panel.innerText()
|
||||
check('持续进度说明已生成和待处理数', text.includes('已生成 6 项,待处理 7 项') && text.includes('正在处理:指标6'))
|
||||
check('单独说明待补充来源的指标', text.includes('另有 1 项'))
|
||||
check('不用用户再添加下一批', !text.includes('下一批') && !text.includes('最多选择 6'))
|
||||
check('页面没有报错', errors.length === 0, errors.join(';'))
|
||||
})
|
||||
|
||||
await section('旧口径迁移:显示粒度缺口,按检查版本准备独立草稿', async () => {
|
||||
const state = makeState({ draft: draftOf(), validations: { records: [], summary: summaryOf({ verdict: 'none', status: null }) } })
|
||||
const original = state.reply
|
||||
let migration
|
||||
state.reply = async (method, p, body, url) => {
|
||||
if (p.endsWith('/migration') && method === 'GET') return { json: {
|
||||
revision: 7, metrics_total: 1, metrics_needing_work: 1, can_rebuild: true,
|
||||
note: '这是静态迁移检查,不代表查询结果已验证',
|
||||
metrics: [{ card_id: 'card', metric_id: 'm1', name: '日 OEE', query_count: 1,
|
||||
findings: [{ code: 'scalar_unproven', message: '最终取数未收敛为单行' }], guidance: '比率不能简单平均' }],
|
||||
} }
|
||||
if (p.endsWith('/migration') && method === 'POST') {
|
||||
migration = body
|
||||
return { status: 201, json: { draft_id: state.draft.id, status: 'path_pending' } }
|
||||
}
|
||||
return original(method, p, body, url)
|
||||
}
|
||||
const { page, errors } = await pageOn(state)
|
||||
await expand(page, state.draft)
|
||||
const panel = page.locator('[data-migration-panel]')
|
||||
await panel.getByRole('button', { name: '检查问数与取数粒度' }).click()
|
||||
await panel.getByText('共 1 项,1 项需处理').waitFor()
|
||||
await panel.locator('details summary').click()
|
||||
check('逐项显示粒度问题', await panel.getByText('最终取数未收敛为单行', { exact: true }).isVisible())
|
||||
check('说明静态检查不等于验证通过', (await panel.innerText()).includes('不代表查询结果已验证'))
|
||||
await panel.getByRole('button', { name: '从原文准备独立迁移草稿' }).click()
|
||||
await until(() => migration)
|
||||
check('迁移基于刚检查的版本,未整份覆盖旧图', JSON.stringify(migration) === '{"expected_revision":7}')
|
||||
check('页面没有报错', errors.length === 0, errors.join(';'))
|
||||
})
|
||||
|
||||
check('整个检查没有弹出原生对话框', dialogs.length === 0, dialogs.slice(0, 2).join(';'))
|
||||
check('检查没有写库(兜底拦下的写请求)', states.every((s) => s.stray.length === 0),
|
||||
states.flatMap((s) => s.stray).slice(0, 4).join('、'))
|
||||
|
||||
@@ -549,7 +549,7 @@ await section('导航:评测在设置前面、⌥0;使用者看不到', asyn
|
||||
const nav = builder.page.locator('nav[aria-label="主导航"]')
|
||||
await nav.waitFor()
|
||||
const labels = await nav.locator('li > a[aria-keyshortcuts] > span:not([aria-hidden])').allInnerTexts()
|
||||
check('搭建者:导航里有「评测」,紧挨着排在「设置」前面', labels.at(-1) === '设置' && labels.at(-2) === '评测', labels.join(','))
|
||||
check('搭建者:导航末尾依次为「评测」「口径」「设置」', labels.slice(-3).join(',') === '评测,口径,设置', labels.join(','))
|
||||
const key = await nav.locator('a[href="/evals"]').getAttribute('aria-keyshortcuts')
|
||||
check('「评测」的快捷键是 ⌥0', key === 'Alt+0', key)
|
||||
await builder.page.locator('main').click({ position: { x: 600, y: 300 } }).catch(() => {})
|
||||
|
||||
@@ -520,6 +520,15 @@ await section('使用者看到的入口照服务端的动作:不能出具的
|
||||
check('自动出具过的正式运行:写「按口径自动出具」、哪份口径和档位', issuedText.includes('按口径自动出具')
|
||||
&& issuedText.includes('「订单口径」v1(已发布)') && issuedText.includes('降档出具'), issuedText)
|
||||
check('……使用者不给打开运行的链接,写由搭建者查看', await issued.locator('a').count() === 0 && issuedText.includes('由搭建者在记录页查看'), issuedText)
|
||||
LED.actions = [RECONCILE, { ...RECONCILE }, { ...RECONCILE, query: 'q-another' }]
|
||||
await page.reload({ waitUntil: 'domcontentloaded' })
|
||||
const group = page.locator('[data-ledger-reconcile-group]')
|
||||
await group.waitFor({ state: 'visible' })
|
||||
check('同一口径归组显示,完全重复的入口去重', await group.count() === 1 && (await group.innerText()).includes('2 份取数'))
|
||||
check('多份取数默认收起,不刷屏', !(await group.locator('[data-ledger-turn-reconcile]').first().isVisible()))
|
||||
await group.locator('summary').click()
|
||||
check('展开后仍可分别核对两份取数', await group.locator('[data-ledger-turn-reconcile]').count() === 2
|
||||
&& await group.locator('[data-ledger-turn-reconcile]').last().isVisible())
|
||||
check('没有运行时报错', errors.length === 0, errors.join(' | '))
|
||||
})
|
||||
|
||||
|
||||
@@ -4089,7 +4089,10 @@ await section('数据 · 表格:改配方后回答保留', async () => {
|
||||
await section('知识库', async () => {
|
||||
const { page, sent, natives, errors, close } = await open('/knowledge/kb', {
|
||||
uploadProgress: true,
|
||||
handlers: [[/^POST \/kb\/upload$/, delayed(1200, {
|
||||
// 本节验证从语义向量降级到本地哈希;隔离空库默认已是 local,重复选中会正确禁用。
|
||||
handlers: [[/^GET \/kb\/embedding$/, json({ ...embedding, kind: 'openai', model: 'check-embedding',
|
||||
embedder: 'openai:check-embedding', has_semantics: true, fallback: false })],
|
||||
[/^POST \/kb\/upload$/, delayed(1200, {
|
||||
id: 'fake-doc', collection: 'default', title: 'note.md', source: 'note.md', mime: 'text/markdown',
|
||||
chunk_count: 0, status: 'processing', meta: { progress: { done: 1, total: 4 } },
|
||||
}, 201)]],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+10
-4
@@ -92,6 +92,7 @@ async function open({ theme = 'dark', state }) {
|
||||
// 主题以本地为准,别让沙箱库里存的偏好把这次检查的主题改掉
|
||||
if (url.pathname === '/api/settings') return route.fulfill({ json: { ui: { theme } } })
|
||||
if (url.pathname === '/api/approvals') return route.fulfill({ json: state.approvals })
|
||||
if (url.pathname === '/api/workflows' && state.workflows) return route.fulfill({ json: state.workflows })
|
||||
// 导航上的两个徽标按来源分开数:「记录」数不属于应用的(other),「应用」数应用里的(apps)。这里的待审批都不属于应用。
|
||||
// latest 同服务端:派给我的待处理待办里最新一条的开出时间(数不变、却换了一条时,新待办通知看它)
|
||||
if (url.pathname === '/api/tasks/count') {
|
||||
@@ -194,13 +195,13 @@ await section('导航', '=== 导航 ===', async () => {
|
||||
for (const end = Date.now() + 3000; !(await lit()).startsWith('数据') && Date.now() < end;) await page.waitForTimeout(100)
|
||||
check('数据页点亮「数据」', (await lit()).startsWith('数据'), await lit())
|
||||
|
||||
// 矮窗口:导航按高度收紧,最底下的遥测点和署名不能被切掉(十个导航项:600、520、480 三种高度各量一遍)
|
||||
// 矮窗口:导航按高度收紧,最底下的遥测点和署名不能被切掉(十一个导航项:700、600、520、480 四种高度各量一遍)
|
||||
const bottomFits = () => page.evaluate(() => {
|
||||
const n = document.querySelector('nav[aria-label="主导航"]')
|
||||
const t = document.querySelector('[data-telemetry]').getBoundingClientRect()
|
||||
return { ok: n.scrollHeight <= innerHeight && t.bottom <= innerHeight, tel: Math.round(t.bottom), vh: innerHeight }
|
||||
})
|
||||
for (const h of [600, 520, 480]) {
|
||||
for (const h of [700, 600, 520, 480]) {
|
||||
await page.setViewportSize({ width: 1280, height: h })
|
||||
await page.waitForTimeout(150)
|
||||
const fit = await bottomFits()
|
||||
@@ -404,7 +405,12 @@ await section('标签页标题', '\n=== 标签页标题、favicon、后台提醒
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
await section('⌘K 命令面板', '\n=== ⌘K 命令面板 ===', async () => {
|
||||
const state = { approvals: PENDING, offline: false, writes: [] }
|
||||
// 搜索“设置”时保留第二个匹配结果,才能检出旧选中索引误执行工作流的问题。
|
||||
const realWorkflows = await (await fetch(`${WEB}/api/workflows`)).json().catch(() => [])
|
||||
const state = { approvals: PENDING, offline: false, writes: [], workflows: [
|
||||
...realWorkflows,
|
||||
{ id: 'shell-settings-workflow', name: '设置流程说明', description: '设置相关流程', updated_at: ago(1000) },
|
||||
] }
|
||||
const { ctx, page } = await open({ state })
|
||||
await page.goto(`${WEB}/runs`)
|
||||
await waitNav(page)
|
||||
@@ -428,7 +434,7 @@ await section('⌘K 命令面板', '\n=== ⌘K 命令面板 ===', async () => {
|
||||
|
||||
await input.fill('设置')
|
||||
const first = dialog.locator('[role="option"][aria-selected="true"]')
|
||||
check('搜「设置」第一项就是设置页', (await first.innerText()).startsWith('设置'), (await first.innerText()).split('\n')[0])
|
||||
check('搜「设置」立即选中设置页,不能沿用旧结果索引', await first.getAttribute('data-command') === 'page:/settings', (await first.innerText()).split('\n')[0])
|
||||
await page.keyboard.press('Enter')
|
||||
check('回车跳过去、面板关掉', await page.waitForURL(/\/settings/, { timeout: 5000 }).then(() => true, () => false)
|
||||
&& !(await dialog.isVisible()), page.url())
|
||||
|
||||
@@ -3200,7 +3200,7 @@ await section('分析卡:叫法和服务端契约一字不差(算子、模
|
||||
})
|
||||
const same = (a, b) => JSON.stringify(Object.entries(a).sort()) === JSON.stringify(Object.entries(b).sort())
|
||||
const diff = (a, b) => Object.keys({ ...a, ...b }).filter((k) => a[k] !== b[k]).map((k) => `${k}:${a[k]} ≠ ${b[k]}`).join(';')
|
||||
check('从 contract.py 读出了 OP_LABELS、MODE_LABELS、LABELS、RANGES', Object.keys(dict('OP_LABELS')).length === 5
|
||||
check('从 contract.py 读出了 OP_LABELS、MODE_LABELS、LABELS、RANGES', Object.keys(dict('OP_LABELS')).length === 7
|
||||
&& Object.keys(dict('MODE_LABELS')).length === 6 && Object.keys(dict('LABELS')).length >= 17 && Object.keys(ranges).length >= 17)
|
||||
check('算子的叫法和 OP_LABELS 一字不差', same(t.op, dict('OP_LABELS')), diff(t.op, dict('OP_LABELS')))
|
||||
check('模式的叫法和 MODE_LABELS 一字不差', same(t.mode, dict('MODE_LABELS')), diff(t.mode, dict('MODE_LABELS')))
|
||||
|
||||
Reference in New Issue
Block a user