diff --git a/backend/tests/test_dual_check_fixture.py b/backend/tests/test_dual_check_fixture.py index d620a077..bd8cb0ea 100644 --- a/backend/tests/test_dual_check_fixture.py +++ b/backend/tests/test_dual_check_fixture.py @@ -9,15 +9,26 @@ check-verify 在夹具上能过,真实数据上却一处都不高亮(2026-10 差异都没有」,delta_pct 是 (盲算值 − 原流程值) ÷ |原流程值| × 100 保留两位。夹具的数据源当 SQLite 比(方言 sqlite), 两边的叫法是「原流程」「盲算」,同节点调用时一样。 -重新生成夹具里的差异说明(只改 diff、摘要的 diff 和 fallback,其余原样): +**写目录的预告。** 服务端在摘要里另带每个指标的 catalog(采用原流程、采用盲算各会往数据目录里写什么, +dual_check_facts.preview,约定之外另加的),审批卡照它说;老服务端没有时卡上按差异的形状有条件地说,两种说法都按 +「服务端到底写不写」核对(2026-10 复核:卡上说会记入、服务端却不写的有好几种)。夹具里的预告(catalog_preview 按指标、 +variants 各自的 catalog)也是服务端的真实输出:按下面的 SCHEMA、空目录、指标自己的范围留空算。双盲复算的服务端合入 +之前没有这个模块,逐项比对那一项跳过,形状和门槛照样查。 + +重新生成夹具里的差异说明和预告(只改 diff、摘要的 diff 和 fallback、预告,其余原样;没有预告的模块时预告不动): cd backend && PYTHONPATH=. python tests/test_dual_check_fixture.py """ from __future__ import annotations +import importlib import json +from collections.abc import Iterator from pathlib import Path from typing import Any +import pytest + +from app.data.catalog import TABLE_FIELD_LABEL from app.data.sql_semantics import explain_sql_diff FIXTURE = Path(__file__).resolve().parents[2] / "frontend" / "src" / "dev" / "fixtures" / "dual_check.json" @@ -25,6 +36,23 @@ DIALECT = "sqlite" LABELS = ("原流程", "盲算") #: 摘要里每个指标最多列几条差异(同双盲复算节点的 SUMMARY_DIFFS) SUMMARY_DIFFS = 3 +#: 预告用的数据源名和表结构(虚构):服务端要认得出表、认得出聚合的列属于哪张表,才会预告写哪一项 +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"]}, + "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"]}, +}} +#: 写目录时认得出的三项(dual_check_facts.FACT_ITEMS) +FACT_ITEMS = ("business_date", "valid_filter", "dedup") +#: 差异里能对上这三项的方面:时间列、筛选条件、聚合 +FACT_ASPECTS = ("time", "filter", "aggregate") +#: 变体换掉的是口径卡里的「销售额」:预告里的指标名照它 +VARIANT_METRIC = "销售额" def _diff(original: str, blind: str) -> dict[str, Any]: @@ -56,6 +84,42 @@ def _load() -> dict[str, Any]: return json.loads(FIXTURE.read_text(encoding="utf-8")) +def _candidate(metric: str, original: str, blind: str) -> dict[str, Any]: + """双盲复算节点交给写目录那一步的候选(少一个 choice):只有一个输入对不上,差异条目带两边的写法和 detail(工件里 + 只留 aspect、text,写目录要看完整的)。指标自己的范围留空:夹具里没有口径说明、分组和运行输入。""" + diff = explain_sql_diff(original, blind, DIALECT, schema=SCHEMA, labels=LABELS).as_dict() + return {"metric": metric, "source": SOURCE, "dialect": DIALECT, "sql": {"a": original, "b": blind}, + "diff": {"equivalent": diff["equivalent"], "fallback": diff["fallback"], "items": diff["items"]}, + "scope": {"texts": [], "columns": []}} + + +def _preview_cases(fx: dict[str, Any]) -> Iterator[tuple[str, str, str, str, dict[str, Any], dict[str, Any]]]: + """夹具里每一份预告:(名字, 指标名, 原流程 SQL, 盲算 SQL, 工件里的差异, 预告)。口径卡的指标按对不上的那个输入, + 变体按它的一对 SQL(检查脚本拿它换掉「销售额」)。""" + for metric in fx["card"]["metrics"]: + (a, b), = _differing(metric) + yield (metric["metric_id"], metric["name"], a["sql"], b["sql"], metric["diff"], + fx["catalog_preview"][metric["metric_id"]]) + for name, variant in fx["variants"].items(): + original, blind = variant["sql"] + yield name, VARIANT_METRIC, original, blind, variant["diff"], variant["catalog"] + + +def _shown(side: dict[str, Any]) -> dict[str, Any]: + """一边的预告里审批卡用得到的:写不写,写的话写到哪、写成什么。不写的原因只在时间线上给人看,措辞不比。""" + if side.get("write") is True: + return {k: side.get(k) for k in ("write", "source", "table", "item", "value")} + return {"write": side.get("write")} + + +def _server_preview() -> Any: + """服务端的预告(dual_check_facts.preview);双盲复算的服务端还没合入时返回 None。""" + try: + return getattr(importlib.import_module("app.engine.dual_check_facts"), "preview", None) + except ImportError: + return None + + def test_card_metrics_carry_the_real_diff(): fx = _load() for metric in fx["card"]["metrics"]: @@ -92,6 +156,37 @@ def test_text_diff_is_not_the_raw_text(): assert shown and not any(line in raw for line in shown) +def test_catalog_previews_have_the_server_shape(): + """每个指标、每个变体都有预告,两边齐全;写的只能是服务端写目录的门槛之内的(两条 SQL 不等价、没有兜底、恰好一条 + 差异,而且是时间列、筛选条件或聚合),写的是三项之一、有表有取值;不写的有原因。不依赖服务端的模块,一直查。""" + fx = _load() + assert set(fx["catalog_preview"]) == {m["metric_id"] for m in fx["card"]["metrics"]} + cases = list(_preview_cases(fx)) + assert len(cases) == len(fx["card"]["metrics"]) + len(fx["variants"]) + for name, _, _, _, diff, preview in cases: + assert set(preview) == {"a", "b"}, name + single = not diff["equivalent"] and not diff["fallback"] and len(diff["items"]) == 1 + for side in ("a", "b"): + said = preview[side] + if said["write"] is True: + assert single and diff["items"][0]["aspect"] in FACT_ASPECTS, (name, side) + assert said["item"] in FACT_ITEMS and said["item"] in TABLE_FIELD_LABEL, (name, side) + assert said["source"] == SOURCE and said["table"] in SCHEMA["tables"] and said["value"], (name, side) + else: + assert said["write"] is False and said["reason"], (name, side) + + +def test_catalog_previews_follow_the_server(): + """预告里审批卡用得到的部分(写不写、写到哪、写成什么)和服务端(dual_check_facts.preview)一致:服务端改了写目录 + 的规则,这里先变红——审批卡上照预告说的、检查脚本按预告核对的有条件说法都要跟着看一遍。""" + preview = _server_preview() + if preview is None: + pytest.skip("双盲复算的服务端(dual_check_facts.preview)还没合入") + for name, metric, original, blind, _, expected in _preview_cases(_load()): + got = preview(_candidate(metric, original, blind), SCHEMA, {}) + assert {s: _shown(got[s]) for s in ("a", "b")} == {s: _shown(expected[s]) for s in ("a", "b")}, name + + def regenerate() -> None: fx = _load() for metric in fx["card"]["metrics"]: @@ -102,6 +197,14 @@ def regenerate() -> None: summary[metric["metric_id"]].update(_summary_of(metric)) for variant in fx["variants"].values(): variant["diff"] = _diff(*variant["sql"]) + if (preview := _server_preview()) is not None: + fx["catalog_preview"] = {} + for metric in fx["card"]["metrics"]: + (a, b), = _differing(metric) + said = preview(_candidate(metric["name"], a["sql"], b["sql"]), SCHEMA, {}) + fx["catalog_preview"][metric["metric_id"]] = said + for variant in fx["variants"].values(): + variant["catalog"] = preview(_candidate(VARIANT_METRIC, *variant["sql"]), SCHEMA, {}) FIXTURE.write_text(json.dumps(fx, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") diff --git a/frontend/src/dev/fixtures/dual_check.json b/frontend/src/dev/fixtures/dual_check.json index 41d52bc5..e7372406 100644 --- a/frontend/src/dev/fixtures/dual_check.json +++ b/frontend/src/dev/fixtures/dual_check.json @@ -1,5 +1,5 @@ { - "_note": "双盲复算的预览用例和 scripts/check-verify.mjs 共用的夹具。表名、数字都是虚构的通用示例,不来自任何真实数据。各处的 diff(差异说明)是服务端 explain_sql_diff 对同一对 SQL 的真实输出(方言 sqlite),由 backend/tests/test_dual_check_fixture.py 核对和重新生成,不要手改。variants 是另几对 SQL 及其差异说明,检查脚本拿来换掉「销售额」那个指标,看不同的差异下卡上怎么说", + "_note": "双盲复算的预览用例和 scripts/check-verify.mjs 共用的夹具。表名、数字都是虚构的通用示例,不来自任何真实数据。各处的 diff(差异说明)是服务端 explain_sql_diff 对同一对 SQL 的真实输出(方言 sqlite),catalog_preview 和 variants 里的 catalog(采用哪一边会往数据目录里写什么)是服务端写目录预告的真实输出,都由 backend/tests/test_dual_check_fixture.py 核对和重新生成,不要手改。variants 是另几对 SQL 及其差异说明,检查脚本拿来换掉「销售额」那个指标,看不同的差异下卡上怎么说", "approval": { "id": "ap-dc-0001", "run_id": "run-dc-0001", @@ -163,6 +163,38 @@ } ] }, + "catalog_preview": { + "gmv": { + "a": { + "write": true, + "source": "shop", + "table": "orders", + "item": "business_date", + "value": { + "column": "paid_at" + } + }, + "b": { + "write": true, + "source": "shop", + "table": "orders", + "item": "business_date", + "value": { + "column": "created_at" + } + } + }, + "refund_rate": { + "a": { + "write": false, + "reason": "指标「退款率」两边的 SQL 不止差一个方面,或有未能归纳的差异,无法确定审核人认可的是哪一处,不写入数据目录" + }, + "b": { + "write": false, + "reason": "指标「退款率」两边的 SQL 不止差一个方面,或有未能归纳的差异,无法确定审核人认可的是哪一处,不写入数据目录" + } + } + }, "graph": { "nodes": [ { @@ -394,6 +426,46 @@ "- 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": "指标「销售额」两边差在时间范围,对不上数据目录里的业务日期、有效记录条件或去重规则,不写入数据目录" + }, + "b": { + "write": false, + "reason": "指标「销售额」两边差在时间范围,对不上数据目录里的业务日期、有效记录条件或去重规则,不写入数据目录" + } + } + }, + "time_one_side": { + "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" + ], + "diff": { + "equivalent": false, + "items": [ + { + "aspect": "time", + "text": "原流程按 paid_at 筛时间,盲算没有按时间筛选" + } + ], + "fallback": false, + "text_diff": [ + "- WHERE", + "- orders.paid_at >= '2026-09-01' AND orders.paid_at < '2026-09-08'" + ] + }, + "catalog": { + "a": { + "write": false, + "reason": "指标「销售额」只有被采纳的原流程按时间筛选,盲算没有:审核人判断的是要不要限定时间,不是按哪一列归属,没有可记的业务日期,不写入数据目录" + }, + "b": { + "write": false, + "reason": "指标「销售额」被采纳的盲算没有按时间筛选,没有可记的业务日期,不写入数据目录" + } } }, "filter_extra": { @@ -414,6 +486,49 @@ "- WHERE", "- orders.status <> 'test'" ] + }, + "catalog": { + "a": { + "write": true, + "source": "shop", + "table": "orders", + "item": "valid_filter", + "value": "status <> 'test'" + }, + "b": { + "write": false, + "reason": "指标「销售额」多出筛选条件的是原流程,被采纳的盲算没有这个条件,不写入数据目录" + } + } + }, + "filter_scope": { + "sql": [ + "SELECT COUNT(*) AS orders\nFROM orders\nWHERE channel = 'web'", + "SELECT COUNT(*) AS orders\nFROM orders" + ], + "diff": { + "equivalent": false, + "items": [ + { + "aspect": "filter", + "text": "原流程只保留 channel = 'web' 的行,盲算没有这个条件" + } + ], + "fallback": false, + "text_diff": [ + "- WHERE", + "- orders.channel = 'web'" + ] + }, + "catalog": { + "a": { + "write": false, + "reason": "指标「销售额」多出的条件「channel = 'web'」不是在排除退款、作废、测试这类无效记录,可能是指标自己的筛选范围(例如只看某个地区、某个渠道),不是整张表的有效记录条件,不写入数据目录" + }, + "b": { + "write": false, + "reason": "指标「销售额」多出筛选条件的是原流程,被采纳的盲算没有这个条件,不写入数据目录" + } } }, "two_filters": { @@ -438,6 +553,16 @@ "- WHERE", "- orders.channel = 'web' AND orders.status <> 'refunded'" ] + }, + "catalog": { + "a": { + "write": false, + "reason": "指标「销售额」两边的 SQL 不止差一个方面,或有未能归纳的差异,无法确定审核人认可的是哪一处,不写入数据目录" + }, + "b": { + "write": false, + "reason": "指标「销售额」两边的 SQL 不止差一个方面,或有未能归纳的差异,无法确定审核人认可的是哪一处,不写入数据目录" + } } }, "join": { @@ -462,6 +587,16 @@ "- ON orders.id = refunds.order_id", "+ orders.id = refunds.order_id" ] + }, + "catalog": { + "a": { + "write": false, + "reason": "指标「销售额」两边差在关联,对不上数据目录里的业务日期、有效记录条件或去重规则,不写入数据目录" + }, + "b": { + "write": false, + "reason": "指标「销售额」两边差在关联,对不上数据目录里的业务日期、有效记录条件或去重规则,不写入数据目录" + } } }, "dedup": { @@ -482,6 +617,19 @@ "- COUNT(DISTINCT orders.user_id)", "+ COUNT(orders.user_id)" ] + }, + "catalog": { + "a": { + "write": true, + "source": "shop", + "table": "orders", + "item": "dedup", + "value": "对 user_id 计数时去重" + }, + "b": { + "write": false, + "reason": "指标「销售额」被采纳的盲算没有去重:不去重是这个指标自己的算法,不是表的去重规则,不写入数据目录" + } } }, "parse": { @@ -499,6 +647,16 @@ ], "fallback": false, "text_diff": [] + }, + "catalog": { + "a": { + "write": false, + "reason": "指标「销售额」两边差在能否解析,对不上数据目录里的业务日期、有效记录条件或去重规则,不写入数据目录" + }, + "b": { + "write": false, + "reason": "指标「销售额」两边差在能否解析,对不上数据目录里的业务日期、有效记录条件或去重规则,不写入数据目录" + } } } } diff --git a/frontend/src/dev/fixtures/dual_check.tsx b/frontend/src/dev/fixtures/dual_check.tsx index 7a45b2ea..72662af6 100644 --- a/frontend/src/dev/fixtures/dual_check.tsx +++ b/frontend/src/dev/fixtures/dual_check.tsx @@ -20,7 +20,14 @@ import data from './dual_check.json' * * 完整对照的工件(GET /api/artifacts/{id})、这次运行的图快照(GET /api/runs/{id}/graph,卡上按它说节点的 * 「未采用原流程时」)、审批回复、设置、校验都由检查脚本用 page.route 伪造,直接打开时卡上照实说无法加载完整对照、 - * 有条件地说后果。提交后 onResolved 只把运行 id 记到 window.__dualResolved,不去接事件流 + * 有条件地说后果。提交后 onResolved 只把运行 id 记到 window.__dualResolved,不去接事件流。 + * + * ?syn=dual-check-approval 另认地址上的几个参数(检查脚本用;不带时 payload 只有约定里的几项,同老服务端): + * - variant=<名字>:「销售额」的摘要换成 variants 里那一对 SQL 的(前 3 条差异的原话、是否只有兜底,同节点的写法), + * 工件由检查脚本按同一个变体伪造 + * - catalog=1:摘要里每个指标带上服务端预告的「采用哪一边会往数据目录里写什么」(catalog_preview;换了变体的 + * 「销售额」用变体的 catalog)。catalog=bad:「销售额」带一份认不出的预告 + * - on_reject=<值>:payload 带上节点实际生效的「未采用原流程时」 */ const approval = (patch: Partial = {}): Approval => ({ ...(data.approval as unknown as Approval), @@ -28,6 +35,27 @@ const approval = (patch: Partial = {}): Approval => ({ ...patch, }) +interface Variant { diff: { items: { aspect: string; text: string }[] }; catalog: unknown } + +function approvalOf(search: string): Approval { + const q = new URLSearchParams(search) + const variant = (data.variants as unknown as Record)[q.get('variant') ?? ''] + const catalog = q.get('catalog') + const onReject = q.get('on_reject') + const previews = data.catalog_preview as unknown as Record + const summary = data.approval.payload.summary.map((s) => { + const swapped = s.metric_id === 'gmv' && variant ? variant : null + const items = swapped?.diff.items + return { + ...s, + ...(items ? { diff: items.slice(0, 3).map((i) => i.text), fallback: !items.some((i) => i.aspect !== 'other' && i.aspect !== 'parse') } : {}), + ...(catalog === '1' ? { catalog: swapped ? swapped.catalog : previews[s.metric_id] } : {}), + ...(catalog === 'bad' && s.metric_id === 'gmv' ? { catalog: { a: { write: true, table: 'orders' }, b: { write: false } } } : {}), + } + }) + return approval({ payload: { ...data.approval.payload, summary, ...(onReject ? { on_reject: onReject } : {}) } }) +} + function Card({ value }: { value: Approval }) { return (
@@ -68,7 +96,7 @@ function InspectorCase() { export const cases: Record JSX.Element> = { 'dual-check-approval': () => ( -
+
), 'dual-check-approval-rail': () => (
diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 8d4a6cdd..80bc6b39 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -3462,15 +3462,31 @@ export const DUAL_CHECK_TEXT = { textDiffNone: '这一边没有不同的行', rationale: '盲算的说明', /** - * 两边只差一处、又可能对得上数据目录里的一项事实时,提交前说清选择会被记下来(门槛同服务端的 dual_check_facts: - * 只有一个输入对不上、恰好一条差异、没有兜底,而且是时间列、筛选条件或聚合)。一边多出条件、只差去不去重、目录里 - * 已有人工确认的说法这几条只有服务端查得了,所以有条件地说,以时间线上的记录为准。卡上是「方面那句 + tail」 + * 服务端没有预告(老服务端,见 catalogPreview)、两边又只差一处时,提交前说清选择可能被记进数据目录。卡上只看得到 + * 差异的方面,看不到服务端写目录的其余条件(dual_check_facts.derive:只有一边按时间筛的不记,多出的条件得在排除 + * 无效记录、又不是指标自己的范围,只差去不去重时得采用去重的一边),所以每句只说必要条件(「仅当……才」), + * 不写的几种常见情况照实说出来,免得审核人为了不让目录被写错而避开本来对的一边。卡上是「方面那句 + tail」 */ catalog: { - time: '两边只差在按哪一列筛时间:采用一边的结果后,这一边筛时间所用的列会作为该表的业务日期', - filter: '两边只差一个筛选条件:若这个条件只有一边有,采用有它的一边时,它会作为该表的有效记录条件', - aggregate: '两边只差在聚合方式:若只差是否去重,采用一边的结果后,它的做法会作为该表的去重规则', - tail: ',以人工确认的事实记入数据目录。选「两边都不对」时不记,数据目录中已有人工确认的不同说法时不覆盖,以时间线上的记录为准', + time: '仅当两边按不同的列筛时间,采用的一边所用的时间列才会作为该表的业务日期记入数据目录;只有一边按时间筛时不记', + filter: '仅当多出的条件只在一边、并且是在排除退款、作废、测试这类无效记录,采用有它的一边时,这个条件才会作为该表的' + + '有效记录条件记入数据目录;只看某个地区、某个渠道这类限定指标范围的条件不记', + aggregate: '仅当两边只差是否去重、并且采用的是去重的一边,去重的做法才会作为该表的去重规则记入数据目录;采用不去重的一边时不记', + tail: '。记为人工确认的事实;选「两边都不对」时不记,数据目录中已有人工确认的不同说法时不覆盖,是否记入以时间线上的记录为准', + }, + /** + * 服务端预告了采用哪一边会往数据目录里写什么(payload.summary[].catalog)时照它说:三个选择各一句,写的说哪张表的 + * 哪一项记成什么,不写的只说不记(原因在裁决后的时间线上)。两边都不写时卡上不提数据目录。卡上是 + * 「head + 三句(;分隔)+ tail」,审核人当前选的那一句加重 + */ + catalogPreview: { + head: '记入数据目录:', + /** where 同时间线(log.catalogItem):「orders」(shop)的业务日期;value 是要写进去的说法 */ + write: (choice: string, where: string, value: string) => `${choice}时,${where}记为「${value}」`, + /** 两边写的是同一张表的同一项、只差取值时,后一边省掉「哪张表的哪一项」 */ + writeSame: (choice: string, value: string) => `${choice}时记为「${value}」`, + skip: (choice: string) => `${choice}时不记`, + tail: '。记为人工确认的事实;以上按发起审批时的数据目录预告,实际写入以时间线上的记录为准', }, choices: { a: '采用原流程的结果', b: '采用盲算的结果', neither: '两边都不对' } as Record<'a' | 'b' | 'neither', string>, choiceGroup: (name: string) => `「${name}」采用哪一边的结果`, diff --git a/frontend/src/run/DualCheckApproval.tsx b/frontend/src/run/DualCheckApproval.tsx index 6771c061..fb461bc7 100644 --- a/frontend/src/run/DualCheckApproval.tsx +++ b/frontend/src/run/DualCheckApproval.tsx @@ -1,4 +1,4 @@ -import { useEffect, useId, useMemo, useState } from 'react' +import { Fragment, useEffect, useId, useMemo, useState } from 'react' import type { CSSProperties } from 'react' import { AlertTriangle, Ban, Check, ChevronRight } from 'lucide-react' import clsx from 'clsx' @@ -7,7 +7,8 @@ import { useCatalog } from '../store/catalog' import { useStudio } from '../store/studio' import { Spinner, useToast } from '../components/ui' import { NONE } from '../lib/format' -import { DUAL_CHECK_TEXT } from '../lib/terms' +import { CATALOG_TABLE_FIELD_LABEL, DUAL_CHECK_TEXT } from '../lib/terms' +import { patchValueText } from './catalogPatch' import type { Approval, DualCheckBlindInput, DualCheckCard, DualCheckChoice, DualCheckInput, DualCheckMetric, DualCheckReply, DualCheckSummaryItem, @@ -51,11 +52,19 @@ interface MetricView { rest: string | null /** 两条 SQL 等价,差在取用的结果上 */ equivalent: boolean - /** 两边只差一处、又可能对得上数据目录的一项事实:审核人的选择可能被记进数据目录(见 catalogOf) */ + /** 服务端没有预告时用:两边只差一处、又可能对得上数据目录的一项事实,审核人的选择可能被记进数据目录(见 catalogOf) */ catalog: CatalogAspect | null + /** 服务端预告的采用哪一边会往数据目录里写什么(见 previewOf);有它就照它说,不再按 catalog 有条件地说 */ + preview: CatalogPreview | null full: DualCheckMetric | null } +/** 服务端预告的一边:写的话写到哪(where 是 「orders」(shop)的业务日期 这样的说法)、写成什么 */ +type PreviewSide = + | { write: true; table: string; source: string | null; item: string; where: string; value: string } + | { write: false } +interface CatalogPreview { a: PreviewSide; b: PreviewSide } + const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined) const num = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) ? v : undefined) const hasSql = (v: unknown): boolean => typeof v === 'string' && !!v.trim() @@ -85,16 +94,46 @@ function notesOf(texts: string[]): { standard: boolean; notes: string[] } { } /** - * 审核人的选择会不会被记进数据目录:照服务端写目录的门槛(dual_check_facts.derive)——两条 SQL 不等价、没用兜底、 - * 差异恰好一条,而且是时间列、筛选条件或聚合。几个输入都对不上时节点把各自的差异合成一份(每个输入至少一条), - * 所以「恰好一条」也就是只有一个输入对不上。后面还有只有服务端查得了的条件(一边多出条件、只差去不去重、目录里 - * 已有人工确认的说法),卡上有条件地说 + * 服务端没有预告时(老服务端),审核人的选择可能被记进数据目录的形状:照服务端写目录的门槛(dual_check_facts.derive) + * ——两条 SQL 不等价、没用兜底、差异恰好一条,而且是时间列、筛选条件或聚合。几个输入都对不上时节点把各自的差异合成 + * 一份(每个输入至少一条),所以「恰好一条」也就是只有一个输入对不上。过了这道门槛也不一定写:只有一边按时间筛、 + * 多出的条件是指标自己的范围、采用了不去重的一边都不写,这些卡上看不出来(差异条目只有方面和原话),所以只说必要 + * 条件,并把不写的几种照实说出来(DUAL_CHECK_TEXT.approval.catalog) */ function catalogOf(metric: DualCheckMetric, items: { aspect: string; text: string }[]): CatalogAspect | null { if (metric.diff?.equivalent === true || metric.diff?.fallback === true || items.length !== 1) return null return CATALOG_ASPECTS.has(items[0].aspect) ? items[0].aspect as CatalogAspect : null } +/** 预告的一边:写的得有表、认得出的目录项和取值;认不出返回 undefined */ +function previewSideOf(raw: unknown): PreviewSide | undefined { + if (!raw || typeof raw !== 'object') return undefined + const s = raw as Record + if (s.write === false) return { write: false } + const table = str(s.table) + const item = str(s.item) + if (s.write !== true || !table || !item || !Object.hasOwn(CATALOG_TABLE_FIELD_LABEL, item) || s.value == null || s.value === '') { + return undefined + } + const source = str(s.source) ?? null + const label = CATALOG_TABLE_FIELD_LABEL[item as keyof typeof CATALOG_TABLE_FIELD_LABEL] + return { + write: true, table, source, item, + where: DUAL_CHECK_TEXT.log.catalogItem(table, source ?? undefined, label), value: patchValueText(item, s.value), + } +} + +/** + * payload.summary[].catalog(约定之外、服务端另带的预告):采用原流程、采用盲算各会往数据目录里写什么,和裁决后真写的 + * 是同一套判断。两边有一边认不出就整份不认,卡上回到按差异形状有条件地说(catalogOf)——宁可少说,不替服务端编 + */ +function previewOf(raw: unknown): CatalogPreview | null { + if (!raw || typeof raw !== 'object') return null + const a = previewSideOf((raw as Record).a) + const b = previewSideOf((raw as Record).b) + return a && b ? { a, b } : null +} + function viewsOf(summary: DualCheckSummaryItem[], card: DualCheckCard | null): MetricView[] { const full = new Map((card?.metrics ?? []).map((m) => [m.metric_id, m])) // 先按摘要的顺序,工件里另有的(约定里不该有,有就照实列出来)跟在后面 @@ -108,6 +147,8 @@ function viewsOf(summary: DualCheckSummaryItem[], card: DualCheckCard | null): M original: str(s?.original_rendered) ?? str(f?.original?.rendered) ?? null, blind: str(s?.blind_rendered) ?? str(f?.blind?.rendered) ?? null, deltaPct: s && 'delta_pct' in s ? num(s.delta_pct) ?? null : f?.delta_pct === null ? null : num(f?.delta_pct), + // 预告跟着摘要来:完整对照还没到、或者取不到时也照它说 + preview: previewOf(s?.catalog), } if (f) { const items = (Array.isArray(f.diff?.items) ? f.diff.items : []) @@ -158,16 +199,17 @@ function deltaText(pct: number | null | undefined): string | null { } /** - * 这次运行里节点的「未采用原流程时」:审批 payload 不带它,从运行的图快照(GET /runs/{id}/graph)里按节点 id 读。 - * 没写、写了认不出的按缺省的 degrade(节点执行时同样这么认);图里找不到这个节点(子工作流里的)、图快照取不到 - * 时是 unknown,卡上有条件地说 + * 这次运行里节点的「未采用原流程时」。服务端在 payload 里带了实际生效的处置(given,约定之外另带的)就照它说; + * 老服务端不带,从运行的图快照(GET /runs/{id}/graph)里按节点 id 读:没写、写了认不出的按缺省的 degrade(节点执行时 + * 同样这么认);图里找不到这个节点(子工作流里的)、图快照取不到时是 unknown,卡上有条件地说 */ type OnReject = 'degrade' | 'fail' | 'unknown' | 'loading' -function useOnReject(runId: string, nodeId: string | undefined): OnReject { +function useOnReject(runId: string, nodeId: string | undefined, given: unknown): OnReject { + const known = given === 'fail' || given === 'degrade' ? given : null const key = `${runId}\n${nodeId ?? ''}` const [got, setGot] = useState<{ key: string; value: OnReject } | null>(null) useEffect(() => { - if (!runId || !nodeId) return + if (known || !runId || !nodeId) return let alive = true api.runs.graph(runId).then( (res) => { @@ -178,7 +220,8 @@ function useOnReject(runId: string, nodeId: string | undefined): OnReject { () => { if (alive) setGot({ key, value: 'unknown' }) }, ) return () => { alive = false } - }, [runId, nodeId, key]) + }, [known, runId, nodeId, key]) + if (known) return known if (!runId || !nodeId) return 'unknown' return got?.key === key ? got.value : 'loading' } @@ -200,7 +243,7 @@ export function DualCheckApproval({ approval, onResolved }: DualCheckApprovalPro const payload = approval.payload ?? {} const summary = useMemo(() => summaryOf(payload.summary), [payload.summary]) const cardId = str(payload.card_artifact) ?? '' - const onReject = useOnReject(approval.run_id, str(payload.node_id) ?? str(approval.node_id)) + const onReject = useOnReject(approval.run_id, str(payload.node_id) ?? str(approval.node_id), payload.on_reject) const [card, setCard] = useState<{ id: string; data?: DualCheckCard | null } | null>(null) useEffect(() => { @@ -380,7 +423,10 @@ function MetricBlock({ view, name, choice, onChoose }: { )} {expanded && view.full && } - {view.catalog && ( + {view.preview ? ( + // 服务端预告了:两边都不写就不提数据目录 + (view.preview.a.write || view.preview.b.write) && + ) : view.catalog && (

{T.catalog[view.catalog]}{T.catalog.tail}

)} @@ -404,6 +450,40 @@ function MetricBlock({ view, name, choice, onChoose }: { ) } +const sameTarget = (a: PreviewSide, b: PreviewSide): boolean => + a.write && b.write && a.table === b.table && a.source === b.source && a.item === b.item + +/** + * 服务端预告的「记入数据目录」:三个选择各一句,审核人当前选的那一句加重——选之前三句都看得到,选了之后一眼看出这一选 + * 会往目录里写什么。两边写的是同一项(只差取值,比如业务日期按哪一列)时,后一句省掉「哪张表的哪一项」 + */ +function CatalogPreviewNote({ preview, choice }: { preview: CatalogPreview; choice?: DualCheckChoice }) { + const P = T.catalogPreview + const said = (side: 'a' | 'b'): string => { + const s = preview[side] + if (!s.write) return P.skip(T.choices[side]) + return side === 'b' && sameTarget(preview.a, s) ? P.writeSame(T.choices.b, s.value) : P.write(T.choices[side], s.where, s.value) + } + const clauses: [DualCheckChoice, string, boolean][] = [ + ['a', said('a'), preview.a.write], ['b', said('b'), preview.b.write], ['neither', P.skip(T.choices.neither), false], + ] + return ( +

+ {P.head} + {clauses.map(([side, text, write], i) => ( + + {i > 0 && ';'} + + {text} + + + ))} + {P.tail} +

+ ) +} + /** 一个输入在两边的取值和 SQL */ interface Pair { path: string diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 4c86beb0..f7557129 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -3501,8 +3501,22 @@ export interface DualCheckSummaryItem { diff: string[] /** 只有兜底说明:两条 SQL 的写法不同,未能归纳成具体差异 */ fallback: boolean + /** + * 约定之外、服务端另带的:采用原流程(a)、采用盲算(b)各会往数据目录里写什么。老服务端没有这一项,卡上按差异的 + * 形状有条件地说 + */ + catalog?: { a: DualCheckCatalogSide; b: DualCheckCatalogSide } } +/** + * 采用一边的结果会往数据目录里写什么:服务端按发起审批时的数据目录预告(dual_check_facts.preview,和裁决后真写的 + * 是同一套判断)。write 为真时 table 的 item(business_date / valid_filter / dedup)会记为 value(有效记录条件是和 + * 目录里已有的条件合起来之后的整句);为假时 reason 说明为什么不写 + */ +export type DualCheckCatalogSide = + | { write: true; source?: string | null; table: string; item: string; value: unknown } + | { write: false; reason?: string | null } + /** payload.kind 为 dual_check 的审批 payload(审批的 mode 是 input) */ export interface DualCheckPayload { kind: 'dual_check' @@ -3511,6 +3525,8 @@ export interface DualCheckPayload { /** 完整对照放在这个工件里(GET /api/artifacts/{id}),内容见 DualCheckCard */ card_artifact: string summary: DualCheckSummaryItem[] + /** 约定之外、服务端另带的:节点实际生效的「未采用原流程时」。没有时卡上从运行的图快照里读 */ + on_reject?: 'degrade' | 'fail' } /** 审核人的回复:POST /approvals/{id}/decide 的 value */ diff --git a/scripts/check-all.mjs b/scripts/check-all.mjs index 8ad2b57a..fba135a7 100644 --- a/scripts/check-all.mjs +++ b/scripts/check-all.mjs @@ -96,7 +96,7 @@ const ESTIMATE = { stream: 173, chat: 160, studio: 149, manage: 145, runs: 112, publish: 92, shell: 87, evidence: 91, versions: 60, catalog: 45, 'ui-kit': 70, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1, // 占位,换成真检查时按实测改 - schedules: 60, compare: 45, verify: 45, inquiry: 1, bundle: 16, whatif: 33, + schedules: 60, compare: 45, verify: 65, inquiry: 1, bundle: 16, whatif: 33, } // 最慢的两项按段拆成几份。用的是它们自己的段过滤:最后一份用 *_SKIP 兜住「其余全部」,以后新加的段 // 不用改这里也跑得到(拆出去的那几份要是一段都没对上,那一份会报「一项都没跑」)。 diff --git a/scripts/check-verify.mjs b/scripts/check-verify.mjs index 3fce11fd..ce037f01 100644 --- a/scripts/check-verify.mjs +++ b/scripts/check-verify.mjs @@ -5,10 +5,14 @@ // 只有兜底说明(或只有解析失败)时写明「未能归纳成具体差异」并默认展开对照;对照两条 SQL 时左右并排(窄栏上下排), // 原文按行比出的不同行高亮,规范化后不同的行(text_diff)分两栏高亮——夹具里的差异说明是服务端的真实输出 // (backend/tests/test_dual_check_fixture.py 核对),规范化的行和原文对不上,高亮不能指望两边逐行相等; -// 「只差一个方面、会记入数据目录」只在服务端真会写目录的形状下说(恰好一条具体差异,且是时间列、筛选条件或聚合); +// 「记入数据目录」不能说得比服务端宽:服务端在摘要里预告了采用哪一边写什么(payload.summary[].catalog)时照它说, +// 两边都不写就不提,当前选的那一句加重;老服务端没有预告时只在可能写的形状下(恰好一条具体差异,且是时间列、筛选 +// 条件或聚合)说必要条件,只有一边按时间筛、多出的是指标自己范围的条件、采用不去重的一边这几种不写的照实说—— +// 夹具里每个变体都带着服务端对这一对 SQL 的真实预告,两种说法都按它核对; // 三个选择缺一个都不能提交,提交的回复是 {choices, note};卡上不能再冒出通用的输入框和「提交 / 驳回并终止」 // (mode 是 input,通用那套会把一个字符串当 value 发出去),认不出指标时另给「驳回并终止」;后果按运行类别和节点的 -// 「未采用原流程时」说(从运行的图快照里读),探索运行不提出具,读不到节点设置时有条件地说; +// 「未采用原流程时」说(payload 带了实际生效的处置就照它说,老服务端从运行的图快照里读),探索运行不提出具,读不到 +// 节点设置时有条件地说; // - 检查器:口径卡、指标、口径说明、盲算模型、相对误差、未采用原流程时、启用,每一项都真的写进节点配置;字段的 // 叫法和服务端校验报错里的(schema.py 的 DUAL_CHECK_FIELD_LABEL,合入后)一字不差;盲算模型不藏在高级选项里 // (时间线「模型相同」那一行要能定位过来);上游有好几张口径卡时写明必须选;「跟随设置」的当前值每次打开都重取; @@ -152,9 +156,9 @@ const browser = await chromium.launch({ executablePath: CHROME }) /** * 开一个预览用例。完整对照的工件按 artifact 答(默认夹具里的那份;给函数可以延迟、出错),这次运行的图快照按 graph * 答(默认夹具里的图:双盲复算节点没写「未采用原流程时」,按缺省的计入缺口),审批回复记下请求体、回一个伪造的运行; - * 审批列表回空;别的非 GET 一律拦掉,别的 GET 放行 + * 审批列表回空;别的非 GET 一律拦掉,别的 GET 放行。query 接在预览页地址后面(用例认的参数见 dual_check.tsx) */ -async function preview(syn, { width = 1280, height = 900, theme = 'dark', artifact = null, graph = null, extra = null } = {}) { +async function preview(syn, { width = 1280, height = 900, theme = 'dark', artifact = null, graph = null, extra = null, query = '' } = {}) { const ctx = await browser.newContext({ viewport: { width, height }, colorScheme: theme, timezoneId: 'Asia/Shanghai' }) opened.add(ctx) ctx.setDefaultTimeout(6000) @@ -189,7 +193,7 @@ async function preview(syn, { width = 1280, height = 900, theme = 'dark', artifa if (url.pathname === '/api/approvals') return route.fulfill({ json: [] }) return route.continue() }) - await page.goto(`${WEB}/preview.html?syn=${syn}&theme=${theme}&actor=张工`, { waitUntil: 'domcontentloaded' }) + await page.goto(`${WEB}/preview.html?syn=${syn}&theme=${theme}&actor=张工${query ? `&${query}` : ''}`, { waitUntil: 'domcontentloaded' }) await page.waitForSelector(`[data-fixture="${syn}"]`) return { ctx, page, sent, errors } } @@ -237,9 +241,11 @@ await section('审批卡:先按摘要画,再取完整对照', async () => { && await rr.locator('[data-dual-compare]').count() === 1) check('……兜底的原话不再列成一条「具体差异」,也不另附一遍', await rr.locator('[data-dual-diff-item]').count() === 0 && await rr.locator('[data-dual-fallback-note]').count() === 0) - // 销售额只差按哪一列筛时间(一条 time):服务端会把采用的一边记成该表的业务日期,卡上有条件地说 + // 销售额只差按哪一列筛时间(一条 time):夹具的 payload 只有约定里的几项(没有服务端的预告),卡上只看得到差异的 + // 方面——服务端会把采用的一边记成该表的业务日期,可同样一条 time 也可能是只有一边按时间筛(服务端不记),只说必要条件 const note = await text(gmv.locator('[data-dual-catalog]')) - check('只差在时间列的指标:说明采用一边后会记成该表的业务日期,有条件地说', note.includes('业务日期') && note.includes('记入数据目录') + check('只差在时间列的指标:说明两边按不同的列筛时间才会记成该表的业务日期,只说必要条件', note.startsWith('仅当') + && note.includes('业务日期') && note.includes('记入数据目录') && note.includes('只有一边按时间筛时不记') && note.includes('两边都不对') && note.includes('不覆盖') && await rr.locator('[data-dual-catalog]').count() === 0, note) await page.waitForSelector('[data-dual-consequence][data-on-reject="degrade"]') const after = await text(page.locator('[data-dual-consequence]')) @@ -334,24 +340,58 @@ function cardWith(name, patch = {}) { return async () => ({ json: { id: CARD_ID, content: card } }) } -await section('审批卡:「记入数据目录」只在服务端真会写的形状下说', async () => { - // dual_check_facts.derive 的门槛:只有一个输入对不上、恰好一条具体差异、没有兜底,而且这一条是时间列、筛选条件或聚合 +/** 预告里写进去的说法在卡上的写法:业务日期写列名,其余原样(同 catalogPatch 的 patchValueText) */ +const previewValue = (v) => (typeof v === 'string' ? v : v?.column) + +await section('审批卡:没有预告时,「记入数据目录」只说必要条件,服务端不写的几种照实说', async () => { + // 老服务端的 payload 没有预告(payload.summary[].catalog),卡上只看得到差异的方面和原话。可能写目录的形状同 + // dual_check_facts.derive 的门槛(只有一个输入对不上、恰好一条具体差异、没有兜底,而且是时间列、筛选条件或聚合), + // 过了门槛服务端也不一定写:只有一边按时间筛、多出的条件是指标自己的范围(不是排除无效记录)、采用了不去重的一边都 + // 不写。卡上不能说得比服务端宽——每句只说必要条件(「仅当」开头),这几种不写的照实说,免得审核人为了不让目录被写错 + // 而避开本来对的一边。变体的 catalog 是服务端对这一对 SQL 的真实预告,按它核对 + const { CATALOG_TABLE_FIELD_LABEL: LABEL } = await import(await load('/src/lib/terms.ts')) + // [变体, 说明, 服务端不写的那一种卡上照实说的那句(只在卡上提数据目录时有)] const cases = [ - ['time_range', null, '只差时间范围(不是换了时间列)'], - ['two_filters', null, '差两个筛选条件'], - ['join', null, '差在关联方式'], - ['parse', null, '一边解析失败'], - ['filter_extra', '有效记录条件', '只差一个筛选条件'], - ['dedup', '去重规则', '只差去不去重'], + ['time_range', '只差时间范围(不是换了时间列)'], + ['two_filters', '差两个筛选条件'], + ['join', '差在关联方式'], + ['parse', '一边解析失败'], + ['time_one_side', '只有一边按时间筛', '只有一边按时间筛时不记'], + ['filter_extra', '只多一个排除测试单的条件', '只看某个地区、某个渠道这类限定指标范围的条件不记'], + ['filter_scope', '只多一个渠道条件', '只看某个地区、某个渠道这类限定指标范围的条件不记'], + ['dedup', '只差去不去重', '采用不去重的一边时不记'], ] - for (const [name, want, what] of cases) { + for (const [name, what, unwritten] of cases) { const { page, errors } = await preview('dual-check-approval', { artifact: cardWith(name) }) await page.waitForSelector('[data-dual-check][data-state="ready"]') const gmv = metric(page, 'gmv') const note = await text(gmv.locator('[data-dual-catalog]')) - check(want ? `${what}:说明会记成该表的${want}` : `${what}:不说会记入数据目录`, want ? note.includes(want) && note.includes('记入数据目录') : !note, - note || '(无)') - if (name === 'filter_extra') check('……筛选条件只在条件只有一边有、且采用了有它的一边时才记,照实说', note.includes('只有一边有') && note.includes('采用有它的一边'), note) + const expect = fx.variants[name].catalog + const writes = ['a', 'b'].filter((s) => expect[s].write) + if (!unwritten) { + // 差异的形状就写不了目录(服务端两边都不写):卡上不提 + check(`${what}:不说会记入数据目录(服务端两边都不写)`, !note && !writes.length, note || '(无)') + } else { + check(`${what}:只说必要条件(「仅当」开头),服务端不写的那一种照实说`, note.startsWith('仅当') && note.includes(unwritten) + && note.includes('记入数据目录') && note.includes('两边都不对') && note.includes('不覆盖') && note.includes('时间线'), note) + // 服务端会写的那一边,卡上的必要条件里得说到写的是哪一项 + const items = writes.map((s) => LABEL[expect[s].item]) + check(`……服务端会写的(${items.join('、') || '无'})卡上说到了`, items.every((i) => note.includes(i)), note) + } + if (name === 'time_one_side') { + // 正上方那条差异说「盲算没有按时间筛选」:卡上不能再说两边只差在按哪一列筛时间 + check('……不和正上方那条差异自相矛盾', (await text(gmv.locator('[data-dual-diff-item]'))).includes('没有按时间筛选') + && !note.includes('两边只差') && !note.includes('按哪一列'), note) + } + if (name === 'filter_extra' || name === 'filter_scope') { + check('……多出的条件得只在一边、是在排除无效记录,采用有它的一边才记', note.includes('只在一边') + && note.includes('排除退款、作废、测试这类无效记录') && note.includes('采用有它的一边'), note) + } + if (name === 'dedup') { + // 服务端:采用去重的原流程记去重规则,采用不去重的盲算不记。两种选择卡上都说清 + check('……采用去重的一边(这里是原流程)才记', expect.a.write && note.includes('采用的是去重的一边'), note) + check('……采用不去重的一边(这里是盲算)不记', !expect.b.write && note.includes('采用不去重的一边时不记'), note) + } if (name === 'two_filters') { check('……两条差异都列出来', await gmv.locator('[data-dual-diff-item]').count() === 2) } @@ -380,6 +420,123 @@ await section('审批卡:「记入数据目录」只在服务端真会写的 check('几个输入都差在同一方面(合并后两条):不说会记入数据目录', await metric(two.page, 'gmv').locator('[data-dual-catalog]').count() === 0) }) +/** 一块里预告的三句:{a, b, neither} → {text, write, current} */ +const previewClauses = (loc) => loc.evaluate((el) => Object.fromEntries([...el.querySelectorAll('[data-dual-catalog="preview"] [data-dual-catalog-side]')] + .map((s) => [s.dataset.dualCatalogSide, { + text: s.textContent.trim(), write: s.dataset.write != null, current: s.dataset.current != null, weight: Number(getComputedStyle(s).fontWeight), + }]))) + +await section('审批卡:服务端预告了写目录时照它说,当前选的那一句加重', async () => { + // payload.summary[].catalog(约定之外、服务端另带的):采用原流程、采用盲算各会往数据目录里写什么,和裁决后真写的 + // 是同一套判断(dual_check_facts.preview)。有它就照它说:两边都不写不提数据目录,写的那一边说清哪张表的哪一项记成 + // 什么。夹具里的预告是服务端对这几对 SQL 的真实输出(test_dual_check_fixture.py 核对) + const { CATALOG_TABLE_FIELD_LABEL: LABEL } = await import(await load('/src/lib/terms.ts')) + + // 口径卡原样:销售额两边换了时间列(选哪边记哪一列),退款率只有兜底(两边都不写)。预告跟着摘要来,完整对照没到也说 + let release + const gate = new Promise((r) => { release = r }) + const base = await preview('dual-check-approval', { + query: 'catalog=1', artifact: async () => { await gate; return { json: { id: CARD_ID, content: fx.card } } }, + }) + await base.page.waitForSelector('[data-dual-metric="gmv"] [data-dual-catalog="preview"]') + const gmv = metric(base.page, 'gmv') + check('完整对照还没到:已照预告说', (await base.page.locator('[data-dual-check]').getAttribute('data-state')) === 'loading') + let c = await previewClauses(gmv) + check('两边换了时间列:采用原流程记原流程那一列', c.a?.text === '采用原流程的结果时,「orders」(shop)的业务日期记为「paid_at」' && c.a.write, c.a?.text) + check('……采用盲算记盲算那一列(同一项,省掉哪张表的哪一项)', c.b?.text === '采用盲算的结果时记为「created_at」' && c.b.write, c.b?.text) + check('……两边都不对时不记', c.neither?.text === '两边都不对时不记' && !c.neither.write, c.neither?.text) + const said = await text(gmv.locator('[data-dual-catalog]')) + check('……开头说记入数据目录,结尾说以时间线为准,不再用有条件的说法', said.startsWith('记入数据目录:') && said.includes('人工确认') + && said.includes('时间线') && !said.includes('仅当'), said) + check('退款率(服务端两边都不写):不提数据目录', await metric(base.page, 'refund_rate').locator('[data-dual-catalog]').count() === 0) + release() + await base.page.waitForSelector('[data-dual-check][data-state="ready"]') + check('完整对照到了:照样按预告说,不换成有条件的说法', await gmv.locator('[data-dual-catalog="preview"]').count() === 1 + && await base.page.locator('[data-dual-catalog="time"]').count() === 0) + check('没选之前三句都不加重', !c.a.current && !c.b.current && !c.neither.current) + for (const [choice, label] of [['a', '采用原流程的结果'], ['b', '采用盲算的结果'], ['neither', '两边都不对']]) { + await gmv.locator('label', { hasText: label }).click() + c = await previewClauses(gmv) + const others = ['a', 'b', 'neither'].filter((s) => s !== choice) + check(`选「${label}」:这一句加重,另两句不加重`, c[choice].current && c[choice].weight >= 500 + && others.every((s) => !c[s].current && c[s].weight < 500), JSON.stringify(c)) + } + await shot(base.page, 'approval-preview-dark') + + // 几个变体:预告说两边都不写的,卡上就不提(没有预告时这几种会有条件地提);写的那一边说清写到哪、写成什么 + for (const [name, what] of [['time_one_side', '只有一边按时间筛'], ['filter_scope', '只多一个渠道条件'], ['filter_extra', '只多一个排除测试单的条件'], + ['dedup', '只差去不去重'], ['time_range', '只差时间范围']]) { + 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 (!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 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.endsWith('时不记'))) + check(`${what}:写的一边说清写到哪、写成什么,不写的一边说不记`, ok, `${got.a?.text} ‖ ${got.b?.text}`) + if (name === 'dedup') { + // 采用去重的原流程记去重规则,采用不去重的盲算不记:两种选择分别看加重的那一句 + await m.locator('label', { hasText: '采用原流程的结果' }).click() + let cur = await previewClauses(m) + check('只差去不去重,选原流程(去重):加重的那一句说记为去重规则', cur.a.current + && cur.a.text === '采用原流程的结果时,「orders」(shop)的去重规则记为「对 user_id 计数时去重」', cur.a.text) + await m.locator('label', { hasText: '采用盲算的结果' }).click() + cur = await previewClauses(m) + check('……选盲算(不去重):加重的那一句说不记', cur.b.current && cur.b.text === '采用盲算的结果时不记' && !cur.a.current, cur.b.text) + } + } + check(`${what}:页面没有报错`, errors.length === 0, errors.join(';')) + } + + // 预告认不出(老版本、写坏了):不照它说,回到按差异形状有条件地说 + const bad = await preview('dual-check-approval', { query: 'catalog=bad' }) + await bad.page.waitForSelector('[data-dual-check][data-state="ready"]') + const fallback = await text(metric(bad.page, 'gmv').locator('[data-dual-catalog]')) + check('预告认不出:回到有条件的说法', (await metric(bad.page, 'gmv').locator('[data-dual-catalog]').getAttribute('data-dual-catalog')) === 'time' + && fallback.startsWith('仅当'), fallback) + + // 完整对照取不到:预告跟着摘要来,照样说 + const lost = await preview('dual-check-approval', { query: 'catalog=1', artifact: async () => ({ status: 404, json: { detail: '工件不存在' } }) }) + await lost.page.waitForSelector('[data-dual-load-failed]') + check('完整对照取不到:照预告说', (await previewClauses(metric(lost.page, 'gmv'))).a?.write === true) + + // 窄屏、深浅色:预告里的条件、取值很长也不撑宽;平常那几句和加重的那一句都读得清 + for (const theme of ['dark', 'light']) { + const narrow = await preview('dual-check-approval', { + query: 'variant=filter_extra&catalog=1', artifact: cardWith('filter_extra'), width: 390, height: 844, theme, + }) + await narrow.page.waitForSelector('[data-dual-check][data-state="ready"]') + await metric(narrow.page, 'gmv').locator('label', { hasText: '采用原流程的结果' }).click() + const w = await narrow.page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) + check(`390px(${theme}):预告不横向溢出`, w <= 0, `${w}px`) + const c = await narrow.page.evaluate(() => { + const base = getComputedStyle(document.body).backgroundColor + const bgOf = (el) => { + for (let n = el; n && n !== document.documentElement; n = n.parentElement) { + const v = getComputedStyle(n).backgroundColor + if (v !== 'rgba(0, 0, 0, 0)' && v !== 'transparent') return v + } + return 'rgba(0, 0, 0, 0)' + } + const pick = (sel) => { const el = document.querySelector(sel); return el && { fg: getComputedStyle(el).color, bg: bgOf(el) } } + return { base, note: pick('[data-dual-catalog="preview"]'), current: pick('[data-dual-catalog-side][data-current]') } + }) + for (const key of ['note', 'current']) { + const v = c[key] + const ratio = v ? contrast(v.fg, v.bg, c.base) : 0 + check(`${theme}:${key === 'note' ? '预告' : '预告里加重的那一句'}对比度 ≥ 4.5`, ratio >= 4.5, `${ratio.toFixed(2)}(${v?.fg} on ${v?.bg})`) + } + await shot(narrow.page, `approval-preview-narrow-${theme}`) + } +}) + await section('审批卡:只有解析失败、兜底原话另有原因时', async () => { // 只有解析失败:和摘要阶段(服务端把它算作兜底)说法一致,默认展开对照,解析的原话附在下面 const { page } = await preview('dual-check-approval', { artifact: cardWith('parse') }) @@ -458,6 +615,25 @@ await section('审批卡:后果和提交后的提示按节点的「未采用 await lost.page.waitForFunction(() => document.querySelector('[role="status"][aria-live="polite"]')?.textContent?.includes('已提交裁决')) const toast = await toastOf(lost.page) check('……提交后的提示不替节点下结论', toast === '已提交裁决', toast) + + // 服务端在 payload 里带了实际生效的处置(约定之外另带的):照它说,不再取图快照——子工作流里的节点图快照里找不到, + // 有了它也说得准 + let asked = 0 + const given = await preview('dual-check-approval', { + query: 'on_reject=fail', graph: async () => { asked++; return { json: { graph: fx.graph, workflow_id: null, version: null } } }, + }) + await given.page.waitForSelector('[data-dual-check][data-state="ready"]') + await given.page.waitForSelector('[data-dual-consequence][data-on-reject="fail"]') + check('payload 带了「判为失败」:照它说(图快照里是缺省的计入缺口)', (await text(given.page.locator('[data-dual-consequence]'))).includes('运行将终止')) + check('……不再取运行的图快照', asked === 0, `${asked} 次`) + const sub = await preview('dual-check-approval', { query: 'on_reject=degrade', graph: async () => ({ json: { graph: { nodes: [], edges: [] } } }) }) + await sub.page.waitForSelector('[data-dual-consequence][data-on-reject="degrade"]') + check('……图快照里找不到节点(子工作流)也照 payload 说,不挂「如节点设为判为失败」那句', + !(await text(sub.page.locator('[data-dual-consequence]'))).includes('如节点')) + // 认不出的值:不照它说,回到图快照 + const odd = await preview('dual-check-approval', { query: 'on_reject=bogus', graph: graphWith('fail') }) + const back = await odd.page.waitForSelector('[data-dual-consequence][data-on-reject="fail"]').then(() => true, () => false) + check('payload 里的值认不出:回到图快照', back, await odd.page.locator('[data-dual-consequence]').getAttribute('data-on-reject')) }) await section('审批卡:认不出任何指标时留一个出口', async () => {