diff --git a/backend/tests/test_dual_check_fixture.py b/backend/tests/test_dual_check_fixture.py index 9b44c95d..9f7c0a0b 100644 --- a/backend/tests/test_dual_check_fixture.py +++ b/backend/tests/test_dual_check_fixture.py @@ -9,11 +9,18 @@ check-verify 在夹具上能过,真实数据上却一处都不高亮(2026-10 差异都没有」,delta_pct 是 (盲算值 − 原流程值) ÷ |原流程值| × 100 保留两位。夹具的数据源当 SQLite 比(方言 sqlite), 两边的叫法是「原流程」「盲算」,同节点调用时一样。 +**变体。** variants 换掉「销售额」的那一对 SQL,挑的是收尾版差异说明(只说时间列、多出来的筛选、聚合与去重、关联 +四类,别的只给兜底)下各有代表性的几种:按哪一列筛时间不同(time_column,两列都声明为日期类型,预告写业务日期)、 +只有一边按时间筛、一边多出来的筛选(排除无效记录的 filter_extra 预告写有效记录条件,指标自己范围的 filter_scope 不写)、 +差两个筛选条件、关联方式、聚合只差去重(dedup,预告写去重规则)、说不清的(fallback:同一列上的时间范围不同,只给兜底, +审批卡默认展开逐行对照)、一边解析失败。 + **写目录的预告。** 服务端在摘要里另带每个指标的 catalog(采用原流程、采用盲算各会往数据目录里写什么, dual_check_facts.preview,约定之外另加的),审批卡照它说;老服务端没有时卡上按差异的形状有条件地说,两种说法都按 「服务端到底写不写」核对(2026-10 复核:卡上说会记入、服务端却不写的有好几种)。夹具里的预告(catalog_preview 按指标、 -variants 和 peers 各自的 catalog)也是服务端的真实输出:按下面的 SCHEMA、空目录、指标自己的范围留空算。双盲复算的 -服务端合入之前没有这个模块,逐项比对那几项跳过,形状和门槛照样查。 +variants 和 peers 各自的 catalog)也是服务端的真实输出:按下面的 SCHEMA、空目录、指标自己的范围留空算。SCHEMA 里 +paid_at、created_at 声明为 TIMESTAMP:业务日期只在两边的时间列都声明为日期 / 时间类型时才写。双盲复算的服务端合入之前 +没有这个模块,逐项比对那几项跳过,形状和门槛照样查。 **同一次裁决里几个指标落到同一项上。** 预告是每个指标单独算的,服务端写目录前却要把同一次裁决里落到同一张表同一项上的 说法归拢(业务日期、去重规则说法不同的一项都不写,有效记录条件合成一句写,驳回过的拿出来不写)。服务端把每种组合按 @@ -54,16 +61,17 @@ DIALECT = "sqlite" LABELS = ("原流程", "盲算") #: 摘要里每个指标最多列几条差异(同双盲复算节点的 SUMMARY_DIFFS) SUMMARY_DIFFS = 3 -#: 预告用的数据源名和表结构(虚构):服务端要认得出表、认得出聚合的列属于哪张表,才会预告写哪一项 +#: 预告用的数据源名和表结构(虚构):服务端要认得出表、认得出聚合的列属于哪张表,才会预告写哪一项;时间列声明为 +#: TIMESTAMP,业务日期才会预告写(文本、整数的时间列不记) SOURCE = "shop" SCHEMA: dict[str, Any] = {"tables": { "orders": {"name": "orders", "columns": [ {"name": "id", "type": "INTEGER"}, {"name": "user_id", "type": "INTEGER"}, {"name": "amount", "type": "REAL"}, - {"name": "status", "type": "TEXT"}, {"name": "channel", "type": "TEXT"}, {"name": "paid_at", "type": "TEXT"}, - {"name": "created_at", "type": "TEXT"}], "primary_key": ["id"]}, + {"name": "status", "type": "TEXT"}, {"name": "channel", "type": "TEXT"}, {"name": "paid_at", "type": "TIMESTAMP"}, + {"name": "created_at", "type": "TIMESTAMP"}], "primary_key": ["id"]}, "refunds": {"name": "refunds", "columns": [ {"name": "id", "type": "INTEGER"}, {"name": "order_id", "type": "INTEGER"}, {"name": "amount", "type": "REAL"}, - {"name": "created_at", "type": "TEXT"}], "primary_key": ["id"]}, + {"name": "created_at", "type": "TIMESTAMP"}], "primary_key": ["id"]}, }} #: 写目录时认得出的三项(dual_check_facts.FACT_ITEMS) FACT_ITEMS = ("business_date", "valid_filter", "dedup") @@ -183,6 +191,28 @@ def test_variants_carry_the_real_diff(): assert swap["diff"] == _diff(original, blind), f"{group}.{name}" +#: 变体里各有代表性的几种(见模块说明「变体」):名字 → (差异条目的方面, 预告会写的边和项) +REPRESENTATIVE = { + "time_column": (["time"], {"a": "business_date", "b": "business_date"}), + "filter_extra": (["filter"], {"a": "valid_filter"}), + "dedup": (["aggregate"], {"a": "dedup"}), + "fallback": (["other"], {}), +} + + +def test_variants_keep_their_representative_shapes(): + """检查脚本拿这几种变体看卡上怎么说。差异说明或写目录的规则一改,变体可能悄悄变成别的形状(换了时间列却只给兜底、 + 预告不再写业务日期),那几项检查就什么也没测:这里守住每种的方面和预告会写的项,说不清的那种还得有逐行对照。不依赖 + 服务端的模块,一直查。""" + fx = _load() + for name, (aspects, writes) in REPRESENTATIVE.items(): + swap = fx["variants"][name] + assert [i["aspect"] for i in swap["diff"]["items"]] == aspects, name + assert {side: said["item"] for side, said in swap["catalog"].items() if said["write"]} == writes, name + fallback = fx["variants"]["fallback"]["diff"] + assert fallback["fallback"] is True and fallback["text_diff"], fallback + + def test_text_diff_is_not_the_raw_text(): """守住这次的教训:至少有一处规范化之后的行和原文的任何一行都不一样(真实输出就是这样),夹具不再照原文手写。""" fx = _load() diff --git a/frontend/src/dev/fixtures/dual_check.json b/frontend/src/dev/fixtures/dual_check.json index 6967faf3..f39233a5 100644 --- a/frontend/src/dev/fixtures/dual_check.json +++ b/frontend/src/dev/fixtures/dual_check.json @@ -565,33 +565,43 @@ "daily_max_usd": 2.0 }, "variants": { - "time_range": { + "time_column": { "sql": [ - "SELECT SUM(amount) AS gmv\nFROM orders\nWHERE paid_at >= '2026-09-01'\n AND paid_at < '2026-09-08'", - "SELECT SUM(amount) AS gmv\nFROM orders\nWHERE paid_at >= '2026-09-01'\n AND paid_at < '2026-09-09'" + "SELECT SUM(amount) AS gmv\nFROM orders\nWHERE created_at >= '2026-09-01'\n AND created_at < '2026-09-08'", + "SELECT SUM(amount) AS gmv\nFROM orders\nWHERE paid_at >= '2026-09-01'\n AND paid_at < '2026-09-08'" ], "diff": { "equivalent": false, "items": [ { - "aspect": "time_range", - "text": "原流程的时间范围是 paid_at >= '2026-09-01' AND paid_at < '2026-09-08',盲算是 paid_at >= '2026-09-01' AND paid_at < '2026-09-09'" + "aspect": "time", + "text": "原流程按 created_at 筛时间,盲算按 paid_at" } ], "fallback": false, "text_diff": [ - "- orders.paid_at >= '2026-09-01' AND orders.paid_at < '2026-09-08'", - "+ orders.paid_at >= '2026-09-01' AND orders.paid_at < '2026-09-09'" + "- orders.created_at >= '2026-09-01' AND orders.created_at < '2026-09-08'", + "+ orders.paid_at >= '2026-09-01' AND orders.paid_at < '2026-09-08'" ] }, "catalog": { "a": { - "write": false, - "reason": "指标「销售额」两边差在时间范围,对不上数据目录里的业务日期、有效记录条件或去重规则,不写入数据目录" + "write": true, + "source": "shop", + "table": "orders", + "item": "business_date", + "value": { + "column": "created_at" + } }, "b": { - "write": false, - "reason": "指标「销售额」两边差在时间范围,对不上数据目录里的业务日期、有效记录条件或去重规则,不写入数据目录" + "write": true, + "source": "shop", + "table": "orders", + "item": "business_date", + "value": { + "column": "paid_at" + } } } }, @@ -635,7 +645,7 @@ "items": [ { "aspect": "filter", - "text": "原流程排除了 status = 'test' 的行,盲算没有" + "text": "原流程排除了 status = 'test' 的行和 status 为空的行,盲算没有" } ], "fallback": false, @@ -702,7 +712,7 @@ }, { "aspect": "filter", - "text": "原流程排除了 status = 'refunded' 的行,盲算没有" + "text": "原流程排除了 status = 'refunded' 的行和 status 为空的行,盲算没有" } ], "fallback": false, @@ -789,6 +799,36 @@ } } }, + "fallback": { + "sql": [ + "SELECT SUM(amount) AS gmv\nFROM orders\nWHERE paid_at >= '2026-09-01'\n AND paid_at < '2026-09-08'", + "SELECT SUM(amount) AS gmv\nFROM orders\nWHERE paid_at >= '2026-09-01'\n AND paid_at < '2026-09-09'" + ], + "diff": { + "equivalent": false, + "items": [ + { + "aspect": "other", + "text": "两条 SQL 的写法不同,未能归纳成具体差异,请对照原文" + } + ], + "fallback": true, + "text_diff": [ + "- orders.paid_at >= '2026-09-01' AND orders.paid_at < '2026-09-08'", + "+ orders.paid_at >= '2026-09-01' AND orders.paid_at < '2026-09-09'" + ] + }, + "catalog": { + "a": { + "write": false, + "reason": "指标「销售额」两边的 SQL 不止差一个方面,或有未能归纳的差异,无法确定审核人认可的是哪一处,不写入数据目录" + }, + "b": { + "write": false, + "reason": "指标「销售额」两边的 SQL 不止差一个方面,或有未能归纳的差异,无法确定审核人认可的是哪一处,不写入数据目录" + } + } + }, "parse": { "sql": [ "SELECT SUM(amount) AS gmv\nFROM orders", @@ -903,7 +943,7 @@ "items": [ { "aspect": "filter", - "text": "原流程排除了 orders.status = 'cancelled' 的行,盲算没有" + "text": "原流程排除了 orders.status = 'cancelled' 的行和 orders.status 为空的行,盲算没有" } ], "fallback": false, diff --git a/scripts/check-verify.mjs b/scripts/check-verify.mjs index 7cb95b43..9a5b139f 100644 --- a/scripts/check-verify.mjs +++ b/scripts/check-verify.mjs @@ -382,12 +382,14 @@ await section('审批卡:没有预告时,「记入数据目录」只说必 // 不写。卡上不能说得比服务端宽——每句只说必要条件(「仅当」开头),这几种不写的照实说,免得审核人为了不让目录被写错 // 而避开本来对的一边。变体的 catalog 是服务端对这一对 SQL 的真实预告,按它核对 const { CATALOG_TABLE_FIELD_LABEL: LABEL } = await import(await load('/src/lib/terms.ts')) - // [变体, 说明, 服务端不写的那一种卡上照实说的那句(只在卡上提数据目录时有)] + // [变体, 说明, 服务端不写的那一种卡上照实说的那句(只在卡上提数据目录时有)]。变体挑的是收尾版差异说明下各有代表性 + // 的几种(backend/tests/test_dual_check_fixture.py 的模块说明):同一列上的时间范围不同只给兜底(fallback) const cases = [ - ['time_range', '只差时间范围(不是换了时间列)'], + ['fallback', '说不清的(同一列上的时间范围不同,只给兜底)'], ['two_filters', '差两个筛选条件'], ['join', '差在关联方式'], ['parse', '一边解析失败'], + ['time_column', '换了时间列(两列都声明为日期类型)', '只有一边按时间筛时不记'], ['time_one_side', '只有一边按时间筛', '只有一边按时间筛时不记'], ['filter_extra', '只多一个排除测试单的条件', '只看某个地区、某个渠道这类限定指标范围的条件不记'], ['filter_scope', '只多一个渠道条件', '只看某个地区、某个渠道这类限定指标范围的条件不记'], @@ -412,11 +414,22 @@ await section('审批卡:没有预告时,「记入数据目录」只说必 // 「不记」后面紧跟一句「记为人工确认的事实」,读起来像在说不记的那一项 check('……「人工确认」说的是写入的项', note.includes('写入的项记为人工确认的事实') && !note.includes('不记。记为'), note) } - if (name === 'time_one_side' || name === 'dedup') { + if (name === 'time_column' || name === 'time_one_side' || name === 'dedup') { // 同一次裁决里几个指标给同一张表记的业务日期、去重规则不同时,服务端一项都不写(有效记录条件是合成一句写)。 // 没有预告时卡上看不出别的指标落到哪一项上,照实说这一条 check('……同一次裁决里几个指标给同一张表记的说法不同时都不记', note.includes('同一次裁决里') && note.includes('不同时都不记'), note) } + if (name === 'time_column') { + // 服务端两边都会写(各记被采纳那一边的列):卡上的必要条件说的就是按不同的列筛时间 + check('……两边按不同的列筛时间,采用的一边所用的时间列才记为业务日期', writes.length === 2 + && note.includes('按不同的列筛时间') && note.includes('采用的一边所用的时间列'), note) + } + if (name === 'fallback') { + // 收尾版差异说明归纳不了同一列上的时间范围:卡上写那句固定的兜底说明,默认展开对照,审核人逐行对照两条 SQL + check('……写那句固定的兜底说明,不当成一条具体差异', (await text(gmv.locator('[data-dual-fallback]'))) === '两条 SQL 的写法不同,未能归纳成具体差异' + && await gmv.locator('[data-dual-diff-item]').count() === 0, await text(gmv.locator('[data-dual-fallback]'))) + check('……默认展开对照', (await gmv.locator('[data-dual-compare-toggle]').getAttribute('aria-expanded')) === 'true') + } if (name === 'time_one_side') { // 正上方那条差异说「盲算没有按时间筛选」:卡上不能再说两边只差在按哪一列筛时间 check('……不和正上方那条差异自相矛盾', (await text(gmv.locator('[data-dual-diff-item]'))).includes('没有按时间筛选') @@ -436,7 +449,9 @@ await section('审批卡:没有预告时,「记入数据目录」只说必 } const diff = fx.variants[name].diff if (diff.text_diff.length) { - await gmv.locator('[data-dual-compare-toggle]').click() + // 只有兜底说明时对照默认就展开着:再点一下就收起来了 + const toggle = gmv.locator('[data-dual-compare-toggle]') + if ((await toggle.getAttribute('aria-expanded')) !== 'true') await toggle.click() const m = await marksIn(gmv) const real = sidesOf(diff) check(`……规范化后的行按真实输出分两栏高亮(${real.del.length} + ${real.add.length} 行)`, @@ -449,7 +464,7 @@ await section('审批卡:没有预告时,「记入数据目录」只说必 } // 几个输入都差在时间列:节点把两处的差异合成一份(每条前面写明是哪个输入),不止一条,不写目录 - const t = fx.variants.time_range.diff.items[0] + const t = fx.variants.time_column.diff.items[0] const merged = { equivalent: false, fallback: false, text_diff: fx.card.metrics[0].diff.text_diff, items: [{ aspect: 'time', text: `输入「gmv_web」:${fx.card.metrics[0].diff.items[0].text}` }, { aspect: 'time', text: `输入「gmv_app」:${t.text}` }], @@ -520,23 +535,39 @@ await section('审批卡:服务端预告了写目录时照它说,当前选 await shot(base.page, 'approval-preview-dark') // 几个变体:预告说两边都不写的,卡上就不提(没有预告时这几种会有条件地提);写的那一边说清写到哪、写成什么 - for (const [name, what] of [['time_one_side', '只有一边按时间筛'], ['filter_scope', '只多一个渠道条件'], ['filter_extra', '只多一个排除测试单的条件'], - ['dedup', '只差去不去重'], ['time_range', '只差时间范围']]) { + for (const [name, what] of [['time_column', '换了时间列'], ['time_one_side', '只有一边按时间筛'], ['filter_scope', '只多一个渠道条件'], + ['filter_extra', '只多一个排除测试单的条件'], ['dedup', '只差去不去重'], ['fallback', '说不清的(只给兜底)']]) { const { page, errors } = await preview('dual-check-approval', { query: `variant=${name}&catalog=1`, artifact: cardWith(name) }) await page.waitForSelector('[data-dual-check][data-state="ready"]') const m = metric(page, 'gmv') const expect = fx.variants[name].catalog const writes = ['a', 'b'].filter((s) => expect[s].write) - check(`${what}(变体):摘要换成这一对 SQL 的差异`, (await text(m.locator('[data-dual-diff-item]'))) === fx.variants[name].diff.items[0].text, - await text(m.locator('[data-dual-diff-item]'))) + if (fx.variants[name].diff.items.some((i) => i.aspect !== 'other' && i.aspect !== 'parse')) { + check(`${what}(变体):摘要换成这一对 SQL 的差异`, (await text(m.locator('[data-dual-diff-item]'))) === fx.variants[name].diff.items[0].text, + await text(m.locator('[data-dual-diff-item]'))) + } else { + check(`${what}(变体):摘要换成兜底说明,不当成一条具体差异`, (await text(m.locator('[data-dual-fallback]'))) === '两条 SQL 的写法不同,未能归纳成具体差异' + && await m.locator('[data-dual-diff-item]').count() === 0, await text(m.locator('[data-dual-fallback]'))) + } if (!writes.length) { check(`${what}:服务端两边都不写,卡上不提数据目录`, await m.locator('[data-dual-catalog]').count() === 0, await text(m.locator('[data-dual-catalog]'))) } else { const got = await previewClauses(m) + // 两边都写、写的是同一张表的同一项(换了时间列:同一张表的业务日期)时,后一条省掉哪张表的哪一项 + const same = writes.length === 2 && ['source', 'table', 'item'].every((k) => expect.a[k] === expect.b[k]) const ok = ['a', 'b'].every((s) => (expect[s].write - ? got[s]?.write && got[s].text.includes(LABEL[expect[s].item]) && got[s].text.includes(`会记为「${previewValue(expect[s].value)}」`) + ? got[s]?.write && got[s].text.includes(`会记为「${previewValue(expect[s].value)}」`) + && (s === 'b' && same ? got[s].text === `${CHOICE_LABEL.b}时会记为「${previewValue(expect.b.value)}」` : got[s].text.includes(LABEL[expect[s].item])) : !got[s]?.write && got[s]?.reason && got[s].text === blockedText(CHOICE_LABEL[s], expect[s].reason))) check(`${what}:写的一边说清写到哪、会记为什么,不写的一边照服务端的原话说不会记入的原因`, ok, `${got.a?.text} ‖ ${got.b?.text}`) + if (name === 'time_column') { + // 两列都声明为日期类型:选哪一边都记被采纳那一边的列,加重的那一条跟着选择走 + await m.locator('label', { hasText: '采用盲算的结果' }).click() + const cur = await previewClauses(m) + check('换了时间列,选盲算:加重的那一条说业务日期会记为盲算那一列', cur.b?.current && cur.b.write && !cur.a.current + && cur.b.text === `采用盲算的结果时会记为「${previewValue(expect.b.value)}」` + && cur.a.text === `采用原流程的结果时,「orders」(shop)的业务日期会记为「${previewValue(expect.a.value)}」`, `${cur.a?.text} ‖ ${cur.b?.text}`) + } if (name === 'dedup') { // 采用去重的原流程记去重规则,采用不去重的盲算不记:两种选择分别看加重的那一句 await m.locator('label', { hasText: '采用原流程的结果' }).click()