From 6d4f452f363a4a8ddacb0f5c1d04e1706611ee85 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 7 Oct 2026 01:34:59 +0800 Subject: [PATCH] =?UTF-8?q?M4=20=E5=A4=8D=E6=A0=B8=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=EF=BC=9A=E8=A7=84=E8=8C=83=E5=8C=96=E5=90=8E=E4=B8=8D=E5=90=8C?= =?UTF-8?q?=E7=9A=84=E8=A1=8C=E5=B8=A6=E4=B8=8A=E6=89=80=E5=9C=A8=E7=9A=84?= =?UTF-8?q?=E5=AD=90=E5=8F=A5=EF=BC=8CON=20=E6=8C=82=E5=9C=A8=20JOIN=20?= =?UTF-8?q?=E9=82=A3=E4=B8=80=E8=A1=8C=E7=9A=84=E6=9C=AB=E5=B0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复核发现上一版把 ON 单独排成一行后,「规范化后不同的行」丢了关联条件的上下文:只差一个关联条件时那一栏只剩 「AND refunds.state = 'done'」,有两个 LEFT JOIN 时看不出是哪个 JOIN 的,也看不出是关联条件还是筛选条件;两边 JOIN 的表不同时,两边都有的那一行「ON」被 difflib 当成相同的行吃掉,表名下面直接跟条件;关联子查询换成两张表时也一样。 这几种都只给兜底说明,审核人全靠这一栏逐行对照。 - sql_semantics:ON 挂在 JOIN 那一行的末尾(表名、子查询的右括号后面),条件各占一行缩在下面,仍比 WHERE 的条件 深一级(ON 里和 WHERE 里的同一个条件不当成同一行)。text_diff 里不同的行前面带上它的各级上级行(排在前面、缩进比 它浅的最近一行,一层层数到顶格),以「 」开头、两边相同,只为标出不同的行在哪个 WHERE、哪个 JOIN 的 ON、哪层 子查询里;截到 40 行时末尾落单的上级行不留。只动排版:拿测试里的 SQL 语料(990 对,4 种方言、有无表结构、两个 方向)逐对比过,equivalent、items、fallback、level 和改之前完全一致,有没有不同的行也一致,不再有单独一行的 ON。 - SqlDiff 的约定多一条:「 」开头的是上下文行,只跟着不同的行出现。test_sql_semantics 的 check_shape 原来只认 「- 」「+ 」,改成也认「 」、并要求最后一行是不同的行(374 个用例只因这一行断言变红,判定和说明都没变)。 - 审批卡(DualCheckApproval):两栏各自只放用得着的上级行(这一边不同的行往上数,缩进更浅的最近一行,一层层往上), 只有另一边用得着的不放;上级行不高亮、用淡一级的字色(同原文栏里没标的行),标题后面说明「淡色的行两边相同,标出 所在的子句」,悬停也是这句;这一边没有不同的行时照旧说没有,老工件没有上级行,照旧。lineDiff 注释里「条件并成 一行」的旧说法改正。 - 测试:test_sql_text_diff 里 ON 那条的期望改成带 JOIN 的上级行,补两个 LEFT JOIN 只差一个关联条件(两个方向)、 两边 JOIN 的表不同、关联子查询换成两张表、同一个条件在 ON 和 WHERE 里、顶格的行不带上下文、40 行截断几条;去掉 上级行、去掉「ON 挂在行末」、去掉截断后的清理,各自让对应的用例变红。 - 夹具 dual_check.json 按 test_dual_check_fixture 的办法重新生成(只变 text_diff),另加 join_condition 变体(只差 一个关联条件、只给兜底);check-verify 查两栏的上级行(WHERE)列在不同的行前面、不高亮、标题有说明,join_condition 下原流程一栏是带 ON 的 JOIN 加缩进的条件、盲算一栏不画只有原流程用得着的上级行,深浅色对比度、390px 不溢出。 --- backend/app/data/sql_semantics.py | 93 ++++++++++++++--- backend/tests/test_dual_check_fixture.py | 9 +- backend/tests/test_sql_semantics.py | 4 +- backend/tests/test_sql_text_diff.py | 120 ++++++++++++++++++---- frontend/src/dev/fixtures/dual_check.json | 44 +++++++- frontend/src/lib/terms.ts | 2 + frontend/src/run/DualCheckApproval.tsx | 68 +++++++++--- frontend/src/types.ts | 5 +- scripts/check-verify.mjs | 61 ++++++++++- 9 files changed, 349 insertions(+), 57 deletions(-) diff --git a/backend/app/data/sql_semantics.py b/backend/app/data/sql_semantics.py index a71624b1..a5b0f92c 100644 --- a/backend/app/data/sql_semantics.py +++ b/backend/app/data/sql_semantics.py @@ -149,8 +149,8 @@ WHERE / GROUP BY 里用列别名的),而且只说四类: **兜底。** 规范形式不一致、一条具体说法都没有时,给一条 aspect="other" 的「两条 SQL 的写法不同,未能归纳成具体差异, 请对照原文」;具体说法只覆盖了一部分时追加「另有未能归纳的差异,请对照原文」;fallback 为真。text_diff 给出规范化后 -不同的行(difflib 比两边 sqlglot pretty 的输出,WHERE、ON、HAVING 的合取式每个条件一行,最多 40 行,见 _text_diff); -有一边没有规范形式(保真守卫拦下的)时给原文的逐行对照。 +不同的行(difflib 比两边 sqlglot pretty 的输出,WHERE、ON、HAVING 的合取式每个条件一行,不同的行前面带上标出所在子句的 +上级行,最多 40 行,见 _text_diff);有一边没有规范形式(保真守卫拦下的)时给原文的逐行对照。 仍做不到的(只给兜底说明,或者认不出等价): - 认不出的等价改写:要靠约束才成立的(主键不为空时的 COUNT(主键) 和 COUNT(*)、按主键关联又没用到的 LEFT JOIN、带 @@ -5924,7 +5924,8 @@ class SqlDiff: items:具体差异,等价时为空;不等价时一定不为空——归纳不出具体差异时是一条 aspect="other" 的兜底说明。 fallback:用了兜底说明(一条具体说法都没有,或者具体说法只覆盖了一部分)。 text_diff:规范化之后两条 SQL 不同的行(difflib 比两边 sqlglot pretty 的输出,WHERE、ON、HAVING 的合取式每个条件 - 一行,「- 」开头是 A 的、「+ 」开头是 B 的,最多 40 行,见 _text_diff),给界面高亮;等价时为空。""" + 一行,「- 」开头是 A 的、「+ 」开头是 B 的;不同的行前面带上它的各级上级行,「 」开头、两边相同,只为标出不同的行 + 在哪个子句里;最多 40 行,见 _text_diff),给界面高亮;等价时为空。""" equivalent: bool items: list[FacetDiff] = field(default_factory=list) @@ -7770,31 +7771,80 @@ def _text_diff(a: _Form | None, b: _Form | None, read: str | None) -> tuple[str, 两边按同一套排版写(_diff_lines):WHERE、ON、HAVING 的合取式每个条件一行。sqlglot 的 pretty 按宽度决定换不换行, 两个条件放得下就排在一行、三个就拆开,同一个条件在两边落在不同的行上,就被当成「不同的行」(2026-10 验收:两边完全 相同的日期区间被标成了差异,审核人会以为统计期也不一样)。比的时候不看条件前面的 AND:一边多出来的条件排在最前面 - 时,另一边同一个条件只差在前面有没有 AND。列出来的仍是各自原样的行。只动排版,判不判等价、差异说明都不看这里。""" + 时,另一边同一个条件只差在前面有没有 AND。列出来的仍是各自原样的行。只动排版,判不判等价、差异说明都不看这里。 + + 不同的行前面带上它的各级上级行(_ancestors:排在前面、缩进比它浅的最近一行,一层层数到顶格),两边相同的那几行以 + 「 」开头列出,只为标出不同的行在哪个子句里——哪个 WHERE、哪个 JOIN 的 ON、哪层子查询。只列不同的行时,只差一个 + 关联条件的那一边只剩一行「AND 条件」,看不出是哪个 JOIN 的关联条件、还是筛选条件(2026-10 复核)。上级行在两边只差 + 前面有没有 AND 时,列不带 AND 的写法(两边共同的部分)。上级行本身不同的,照样是「- 」「+ 」行。""" import difflib if a is None or b is None: return () la, lb = _diff_lines(a, read), _diff_lines(b, read) - out: list[str] = [] + ka, kb = [_LEADING_AND.sub(r"\1", x) for x in la], [_LEADING_AND.sub(r"\1", x) for x in lb] + # (标记, A 的行号, B 的行号):ndiff 按顺序走完两边,「 」两边各进一行,「- 」「+ 」各进自己那边一行,「? 」是行内 + # 提示、不占行 + rows: list[tuple[str, int, int]] = [] i = j = 0 - # ndiff 按顺序走完两边:「 」两边各进一行,「- 」「+ 」各进自己那边一行,「? 」是行内提示、不占行 - for line in difflib.ndiff([_LEADING_AND.sub(r"\1", x) for x in la], [_LEADING_AND.sub(r"\1", x) for x in lb]): + for line in difflib.ndiff(ka, kb): tag = line[:2] if tag == " ": + rows.append((tag, i, j)) i, j = i + 1, j + 1 elif tag == "- ": - out.append(f"- {la[i]}") + rows.append((tag, i, -1)) i += 1 elif tag == "+ ": - out.append(f"+ {lb[j]}") + rows.append((tag, -1, j)) j += 1 - return tuple(out[:_MAX_DIFF_LINES]) + need_a = _ancestors(la, [i for tag, i, _ in rows if tag == "- "]) + need_b = _ancestors(lb, [j for tag, _, j in rows if tag == "+ "]) + out: list[str] = [] + for tag, i, j in rows: + if tag == "- ": + out.append(f"- {la[i]}") + elif tag == "+ ": + out.append(f"+ {lb[j]}") + elif i in need_a or j in need_b: + out.append(f" {la[i] if la[i] == lb[j] else ka[i]}") + out = out[:_MAX_DIFF_LINES] + # 截到 40 行时末尾剩下的上级行后面已经没有不同的行,不留:上级行只跟着不同的行出现 + while out and out[-1].startswith(" "): + out.pop() + return tuple(out) + + +def _ancestors(lines: list[str], changed: list[int]) -> set[int]: + """changed 这些行的各级上级行的行号:排在前面、缩进比它浅的最近一行是它的上级,一层层往上数到顶格。sqlglot 的 + pretty 里子句的内容缩在子句名下面(WHERE 下的条件、JOIN … ON 下的关联条件、EXISTS( 下的子查询),所以数出来的就是 + 它所在的各层子句。空白行不当上级。""" + def depth(line: str) -> int: + return len(line) - len(line.lstrip(" ")) + + parent = [-1] * len(lines) + # 往上数时还可能当上级的行:从上往下缩进一行比一行深,比当前行深或一样深的都不是它的上级 + stack: list[int] = [] + for n, line in enumerate(lines): + if not line.strip(): + continue + while stack and depth(lines[stack[-1]]) >= depth(line): + stack.pop() + parent[n] = stack[-1] if stack else -1 + stack.append(n) + out: set[int] = set() + for n in changed: + k = parent[n] + # 已经在里面的,它往上的各级也已经在里面了 + while k >= 0 and k not in out: + out.add(k) + k = parent[k] + return out def _diff_lines(form: _Form, read: str | None) -> list[str]: - """逐行对照用的写法:sqlglot pretty,WHERE、HAVING、ON 的合取式每个条件一行(见 _line_generator)。生成不了就退回 - 普通的 pretty,再不行用规范形式的原文。""" + """逐行对照用的写法:sqlglot pretty,WHERE、HAVING、ON 的合取式每个条件一行,ON 挂在 JOIN 那一行的末尾(见 + _line_generator)。生成不了就退回普通的 pretty,再不行用规范形式的原文。""" try: text = _line_generator(read).generate(form.tree) except Exception: # noqa: BLE001 - 只是给人看的排版,出错不能连累差异说明 @@ -7818,13 +7868,15 @@ def _condition_root(node: Any) -> str | None: def _line_generator(read: str | None) -> Any: - """这种方言的 sqlglot 生成器(pretty、不带注释),只改一处:WHERE、HAVING、ON 的整个条件按 AND 拆开,每个条件一行, - 第二个起前面写 AND(各层子查询里的也一样)。别的照 sqlglot 原样:条件本身太长时照样折行,两边同一个条件折得一样。 + """这种方言的 sqlglot 生成器(pretty、不带注释),只改条件的排法:WHERE、HAVING、ON 的整个条件按 AND 拆开,每个条件 + 一行,第二个起前面写 AND(各层子查询里的也一样),ON 挂在 JOIN 那一行的末尾。别的照 sqlglot 原样:条件本身太长时 + 照样折行,两边同一个条件折得一样。 - 条件本身是 OR(XOR)连起来的一律加括号:和别的条件并列时本来就要加,单独一个时不加的话,同一个条件两边写法不同。 条件外面多余的括号、AND 套 AND 的括号去掉(_conjuncts),不改意思。 - - ON 照 WHERE 的样子单独一行、条件缩进在下面:写在 ON 后面的话,第一个条件和后面的差在「ON」「AND」上,换个先后 - 就对不上;缩进比 WHERE 的条件深一级,ON 里和 WHERE 里的同一个条件不会被当成同一行。 + - ON 挂在 JOIN 那一行的末尾(表名、子查询的右括号后面),条件各占一行缩在下面:写在 ON 后面的话,第一个条件和后面 + 的差在「ON」「AND」上,换个先后就对不上;缩进比 WHERE 的条件深一级,ON 里和 WHERE 里的同一个条件不会被当成同一 + 行。ON 不单独占一行:那样两边 JOIN 的表不同时,两边都有的「ON」被当成相同的行,条件前面就没了 ON(2026-10 复核)。 """ from sqlglot import exp from sqlglot.dialects.dialect import Dialect @@ -7841,9 +7893,16 @@ def _line_generator(read: str | None) -> Any: return super().sql(expression, key, comment) lines = [self._one_condition(c) for c in conds] text = "\n".join([lines[0], *(f"AND {line}" for line in lines[1:])]) - # ON:join_sql 拼成「ON <条件>」并给条件的各行加一级缩进,开头换行就是 ON 单独一行、条件在下面 + # ON:join_sql 拼成「ON <条件>」并给条件的各行加一级缩进,开头换行就是条件都在 ON 下面;单独一行的 ON + # 再由下面的 join_sql 挂到上一行末尾 return "\n" + self.indent(text) if root == "on" else text + def join_sql(self, expression: Any) -> str: + # sqlglot 拼出来是换行、缩进一级、「ON 」再换行(ON 单独一行,条件在下面):把这个 ON 接到上一行(表名、 + # 子查询的右括号)末尾。只认这一层的:子查询里、条件里套着的 JOIN 都缩得更深,对不上 + pad = " " * self.pad + return super().join_sql(expression).replace(f"\n{pad}ON \n", " ON\n", 1) + def _one_condition(self, node: Any) -> str: # 只有一个条件时它就是整个条件,再走 self.sql 又会被认成整个条件、绕不出来:这一层直接用 sqlglot 原来的 # 写法(里面各层子查询的条件照样经过上面的 sql) diff --git a/backend/tests/test_dual_check_fixture.py b/backend/tests/test_dual_check_fixture.py index 51f2ea8e..cbdea294 100644 --- a/backend/tests/test_dual_check_fixture.py +++ b/backend/tests/test_dual_check_fixture.py @@ -12,8 +12,9 @@ check-verify 在夹具上能过,真实数据上却一处都不高亮(2026-10 **变体。** variants 换掉「销售额」的那一对 SQL,挑的是收尾版差异说明(只说时间列、多出来的筛选、聚合与去重、关联 四类,别的只给兜底)下各有代表性的几种:按哪一列筛时间不同(time_column,两列都声明为日期类型,预告写业务日期)、 只有一边按时间筛、一边多出来的筛选(排除无效记录的 filter_extra 预告写有效记录条件,指标自己范围的 filter_scope 不写)、 -差两个筛选条件、关联方式、聚合只差去重(dedup,预告写去重规则)、说不清的(fallback:同一列上的时间范围不同,只给兜底, -审批卡默认展开逐行对照)、一边解析失败。 +差两个筛选条件、关联方式、只差一个关联条件(join_condition:LEFT JOIN 的 ON 里多一个条件,只给兜底,逐行对照里那一行 +条件前面带着它所在的 JOIN)、聚合只差去重(dedup,预告写去重规则)、说不清的(fallback:同一列上的时间范围不同,只给 +兜底,审批卡默认展开逐行对照)、一边解析失败。 **写目录的预告。** 服务端在摘要里另带每个指标的 catalog(采用原流程、采用盲算各会往数据目录里写什么, dual_check_facts.preview,约定之外另加的),审批卡照它说;老服务端没有时卡上按差异的形状有条件地说,两种说法都按 @@ -197,6 +198,7 @@ REPRESENTATIVE = { "filter_extra": (["filter"], {"a": "valid_filter"}), "dedup": (["aggregate"], {"a": "dedup"}), "fallback": (["other"], {}), + "join_condition": (["other"], {}), } @@ -211,6 +213,9 @@ def test_variants_keep_their_representative_shapes(): 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 + # 只差一个关联条件:那一行条件前面带着它所在的 JOIN(两边相同的上级行,审批卡淡色画),检查脚本拿它看上级行怎么画 + on = fx["variants"]["join_condition"]["diff"]["text_diff"] + assert [line[:2] for line in on] == [" ", "- "] and on[0].startswith(" LEFT JOIN ") and on[0].endswith(" ON"), on def test_text_diff_is_not_the_raw_text(): diff --git a/backend/tests/test_sql_semantics.py b/backend/tests/test_sql_semantics.py index 2a5740b9..7a9bbf93 100644 --- a/backend/tests/test_sql_semantics.py +++ b/backend/tests/test_sql_semantics.py @@ -340,7 +340,9 @@ def check_shape(diff: SqlDiff) -> None: assert diff.items == [] and not diff.fallback and diff.text_diff == () return assert diff.items and diff.text_diff and len(diff.text_diff) <= 40 - assert all(line[:2] in ("- ", "+ ") for line in diff.text_diff) + # 「- 」是 A 的行、「+ 」是 B 的行;「 」开头的是两边相同、标出不同的行在哪个子句里的上级行,只跟着不同的行出现 + assert all(line[:2] in ("- ", "+ ", " ") for line in diff.text_diff) + assert diff.text_diff[-1][:2] in ("- ", "+ ") if diff.fallback: assert diff.items[-1].aspect == "other" and diff.items[-1].text in (FALLBACK, FALLBACK_REST) else: diff --git a/backend/tests/test_sql_text_diff.py b/backend/tests/test_sql_text_diff.py index 263c2505..f49f670d 100644 --- a/backend/tests/test_sql_text_diff.py +++ b/backend/tests/test_sql_text_diff.py @@ -1,4 +1,5 @@ -"""explain_sql_diff 的 text_diff(「规范化后不同的行」):两边相同的条件不因为换行的位置不同被标成差异。 +"""explain_sql_diff 的 text_diff(「规范化后不同的行」):两边相同的条件不因为换行的位置不同被标成差异,不同的行看得出 +在哪个子句里。 2026-10 验收:双盲复算审批卡上,两边完全相同的日期区间被标成了差异—— + orders.paid_date >= '2026-09-28' AND orders.paid_date <= '2026-10-04' @@ -12,6 +13,11 @@ SQL 都是单行时原文栏一行都不标,这一栏是唯一逐行指出差 连起来的加上括号;比的时候不看条件前面的 AND(一边多出来的条件排在最前面时,另一边同一个条件只差在有没有 AND 上)。 只动排版:判不判等价、差异说明(items)不变,sql_semantics、sql_equivalence 原有的测试照样全过。 +复核(第 2 轮):ON 单独占一行时,只差一个关联条件的那一栏只剩一行「AND 条件」,看不出是哪个 JOIN 的关联条件、还是 +筛选条件;两边 JOIN 的表不同时,两边都有的那一行「ON」被当成相同的行吃掉,条件前面没了 ON。现在 ON 挂在 JOIN 那一行 +的末尾(条件缩在下面,比 WHERE 的条件深一级);不同的行前面带上它的各级上级行——「 」开头、两边相同、只为标出不同的 +行在哪个子句里(哪个 WHERE、哪个 JOIN 的 ON、哪层子查询),审批卡上淡色显示。 + 表名、列名、日期、取值都是虚构的。 """ from __future__ import annotations @@ -19,40 +25,51 @@ from __future__ import annotations from app.data.sql_semantics import explain_sql_diff LABELS = ("原流程", "盲算") -#: 包裹表、仓库表的结构(虚构):给了表结构才认列,同验收时双盲复算节点的调用 +#: 包裹表、仓库表、快递员表的结构(虚构):给了表结构才认列,同验收时双盲复算节点的调用 SCHEMA = {"tables": { "parcels": {"columns": [{"name": "id", "type": "INTEGER"}, {"name": "fee", "type": "REAL"}, {"name": "sent_on", "type": "TEXT"}, {"name": "state", "type": "TEXT"}, - {"name": "hub", "type": "TEXT"}, {"name": "depot_id", "type": "INTEGER"}]}, + {"name": "hub", "type": "TEXT"}, {"name": "depot_id", "type": "INTEGER"}, + {"name": "courier_id", "type": "INTEGER"}]}, "depots": {"columns": [{"name": "id", "type": "INTEGER"}, {"name": "zone", "type": "TEXT"}, {"name": "kind", "type": "TEXT"}]}, + "couriers": {"columns": [{"name": "id", "type": "INTEGER"}, {"name": "team", "type": "TEXT"}, + {"name": "active", "type": "INTEGER"}]}, }} WEEK = "sent_on BETWEEN '2031-03-03' AND '2031-03-09'" +#: 两个 LEFT JOIN,{d}、{c} 处各可再加一个关联条件 +TWO_JOINS = ("SELECT COUNT(*) FROM parcels p LEFT JOIN depots d ON d.id = p.depot_id{d} " + "LEFT JOIN couriers c ON c.id = p.courier_id{c}") def diff(a: str, b: str): return explain_sql_diff(a, b, "sqlite", schema=SCHEMA, labels=LABELS) +def depth(line: str) -> int: + return len(line) - len(line.lstrip(" ")) + + def test_the_same_date_range_is_not_marked_when_one_side_has_one_more_condition(): - """验收时的形状:原流程比盲算多一个排除作废的条件,两边的日期区间一样——只标多出来的那一个条件。""" + """验收时的形状:原流程比盲算多一个排除作废的条件,两边的日期区间一样——只标多出来的那一个条件,前面带上它所在的 + WHERE(两边相同的上下文行)。""" a = f"SELECT SUM(fee) AS week_fee FROM parcels WHERE {WEEK} AND state <> 'void'" b = f"SELECT SUM(fee) AS total_fee FROM parcels WHERE {WEEK}" got = diff(a, b) - assert got.text_diff == ("- AND parcels.state <> 'void'",) + assert got.text_diff == (" WHERE", "- AND parcels.state <> 'void'") # 只动排版:判定和差异说明照旧 assert not got.equivalent and not got.fallback and got.level == "optimizer" assert [(i.aspect, i.text) for i in got.items] == [("filter", "原流程排除了 state = 'void' 的行和 state 为空的行,盲算没有")] # 反过来比也一样,只是换到「+ 」那一边 - assert diff(b, a).text_diff == ("+ AND parcels.state <> 'void'",) + assert diff(b, a).text_diff == (" WHERE", "+ AND parcels.state <> 'void'") def test_an_extra_condition_sorted_first_does_not_drag_the_shared_ones_along(): """多出来的条件排在最前面:另一边同一个日期条件只差在前面有没有 AND,不算不同。""" a = f"SELECT SUM(fee) FROM parcels WHERE {WEEK} AND hub = 'north'" b = f"SELECT SUM(fee) FROM parcels WHERE {WEEK}" - assert diff(a, b).text_diff == ("- parcels.hub = 'north'",) - assert diff(b, a).text_diff == ("+ parcels.hub = 'north'",) + assert diff(a, b).text_diff == (" WHERE", "- parcels.hub = 'north'") + assert diff(b, a).text_diff == (" WHERE", "+ parcels.hub = 'north'") def test_two_conditions_on_each_side_mark_only_the_one_that_differs(): @@ -61,32 +78,87 @@ def test_two_conditions_on_each_side_mark_only_the_one_that_differs(): b = "SELECT SUM(fee) FROM parcels WHERE sent_on BETWEEN '2031-03-03' AND '2031-03-10'" got = diff(a, b) assert got.fallback - assert got.text_diff == ("- AND parcels.sent_on <= '2031-03-09'", "+ AND parcels.sent_on <= '2031-03-10'") + assert got.text_diff == (" WHERE", "- AND parcels.sent_on <= '2031-03-09'", "+ AND parcels.sent_on <= '2031-03-10'") -def test_on_conditions_get_one_line_each_too(): - """LEFT JOIN 的 ON:ON 单独一行,条件各占一行,多出来的那个条件单独标出来。""" +def test_on_conditions_get_one_line_each_under_their_join(): + """LEFT JOIN 的 ON 挂在 JOIN 那一行的末尾,条件各占一行缩在下面;多出来的那个条件单独标出来,前面带上它所在的 + JOIN(以前 ON 单独一行,标出来的只剩「AND depots.kind = 'cold'」,看不出是关联条件还是筛选条件)。""" a = "SELECT COUNT(*) FROM parcels p LEFT JOIN depots d ON d.id = p.depot_id AND d.kind = 'cold'" b = "SELECT COUNT(*) FROM parcels p LEFT JOIN depots d ON d.id = p.depot_id" - assert diff(a, b).text_diff == ("- AND depots.kind = 'cold'",) - # 关联条件本身不同:只标那一行,ON 那一行两边一样 + assert diff(a, b).text_diff == (" LEFT JOIN depots AS depots ON", "- AND depots.kind = 'cold'") + # 关联条件本身不同:只标那一行,JOIN 那一行两边一样,作为上下文列出 c = "SELECT COUNT(*) FROM parcels p LEFT JOIN depots d ON d.zone = p.hub" - assert diff(b, c).text_diff == ("- depots.id = parcels.depot_id", "+ depots.zone = parcels.hub") + assert diff(b, c).text_diff == (" LEFT JOIN depots AS depots ON", "- depots.id = parcels.depot_id", + "+ depots.zone = parcels.hub") + + +def test_two_left_joins_name_the_join_the_differing_condition_belongs_to(): + """两个 LEFT JOIN 只差其中一个的关联条件:标出来的条件前面带上的是它自己的那个 JOIN,不是另一个,也不是 WHERE。""" + plain = TWO_JOINS.format(d="", c="") + on_courier = TWO_JOINS.format(d="", c=" AND c.active = 1") + assert diff(on_courier, plain).text_diff == (" LEFT JOIN couriers AS couriers ON", "- AND couriers.active = 1") + on_depot = TWO_JOINS.format(d=" AND d.kind = 'cold'", c="") + assert diff(plain, on_depot).text_diff == (" LEFT JOIN depots AS depots ON", "+ AND depots.kind = 'cold'") + + +def test_a_different_join_table_keeps_on_with_each_side(): + """两边 LEFT JOIN 的表不同:每一栏都是带着 ON 的 JOIN 那一行、下面紧跟各自的关联条件(以前两边都有的那一行 ON 被 + 当成相同的行吃掉,表名下面直接跟条件)。""" + a = "SELECT COUNT(*) FROM parcels p LEFT JOIN depots d ON d.id = p.depot_id" + b = "SELECT COUNT(*) FROM parcels p LEFT JOIN couriers c ON c.id = p.courier_id" + assert diff(a, b).text_diff == ("- LEFT JOIN depots AS depots ON", "- depots.id = parcels.depot_id", + "+ LEFT JOIN couriers AS couriers ON", "+ couriers.id = parcels.courier_id") + + +def test_a_join_condition_never_floats_without_its_join(): + """关联的对象换了写法(原流程关联一个子查询,盲算关联两张表):每个关联条件紧跟在它所在的、带着 ON 的那一行下面—— + 子查询的右括号后面也带着 ON,不会出现条件上面是子查询的最后一行、或者上面是别的 JOIN 的情况。""" + a = ("SELECT COUNT(*) FROM parcels p LEFT JOIN (SELECT depot_id AS k, SUM(fee) AS s FROM parcels GROUP BY depot_id) " + "AS sub ON p.depot_id = sub.k") + b = ("SELECT COUNT(*) FROM parcels p LEFT JOIN depots d ON d.id = p.depot_id " + "LEFT JOIN couriers c ON c.id = p.courier_id") + got = diff(a, b).text_diff + assert [line for line in got if line.startswith("+ ")] == [ + "+ LEFT JOIN couriers AS couriers ON", "+ couriers.id = parcels.courier_id", + "+ LEFT JOIN depots AS depots ON", "+ depots.id = parcels.depot_id"] + original = [line for line in got if line.startswith("- ")] + assert original[0] == "- LEFT JOIN (" and original[-2:] == ["- ) AS sub ON", "- parcels.depot_id = sub.c1"], got + # 每一行都只在一边,没有两边相同的上下文行,也没有单独一行的 ON + assert len(original) + 4 == len(got) and not any(line.strip() in ("ON", "- ON", "+ ON") for line in got) + + +def test_the_same_condition_in_on_and_in_where_is_not_one_line(): + """同一个条件一边写在 LEFT JOIN 的 ON 里、一边写在 WHERE 里(意思不同:写在 ON 里不筛掉左表的行):ON 里的条件比 + WHERE 的缩进深一级,不会被当成两边相同的行。""" + a = "SELECT COUNT(*) FROM parcels p LEFT JOIN depots d ON d.id = p.depot_id AND p.hub = 'north'" + b = "SELECT COUNT(*) FROM parcels p LEFT JOIN depots d ON d.id = p.depot_id WHERE p.hub = 'north'" + assert diff(a, b).text_diff == (" LEFT JOIN depots AS depots ON", "+ WHERE", "- AND parcels.hub = 'north'", + "+ parcels.hub = 'north'") + + +def test_a_top_level_line_carries_no_context(): + """不同的行本身顶格(FROM 换了表):没有上级行,不带上下文。""" + assert diff("SELECT COUNT(*) FROM parcels", "SELECT COUNT(*) FROM depots").text_diff == ( + "- FROM parcels AS parcels", "+ FROM depots AS depots") def test_having_conditions_get_one_line_each_too(): a = "SELECT hub, SUM(fee) FROM parcels GROUP BY hub HAVING SUM(fee) > 10 AND COUNT(*) > 2" b = "SELECT hub, SUM(fee) FROM parcels GROUP BY hub HAVING SUM(fee) > 10" - assert diff(a, b).text_diff == ("- AND COUNT(*) > 2",) + assert diff(a, b).text_diff == (" HAVING", "- AND COUNT(*) > 2") def test_conditions_inside_a_subquery_get_one_line_each_too(): + """子查询里的条件也是每个条件一行;上下文一层层带到顶格:外层的 WHERE、EXISTS 那一行、子查询的 WHERE。""" a = ("SELECT COUNT(*) FROM parcels p WHERE p.state <> 'void' " "AND EXISTS (SELECT 1 FROM depots d WHERE d.id = p.depot_id AND d.zone = 'east')") b = ("SELECT COUNT(*) FROM parcels p WHERE p.state <> 'void' " "AND EXISTS (SELECT 1 FROM depots d WHERE d.id = p.depot_id)") - [line] = diff(a, b).text_diff + *context, line = diff(a, b).text_diff assert line.startswith("- AND ") and line.endswith(".zone = 'east'") + assert [c.strip() for c in context] == ["WHERE", "EXISTS(", "WHERE"] and all(c.startswith(" ") for c in context) + assert [depth(c[2:]) for c in context] == [0, 2, 4] def test_an_or_condition_reads_the_same_alone_and_among_others(): @@ -95,7 +167,19 @@ def test_an_or_condition_reads_the_same_alone_and_among_others(): either = "(hub = 'north' OR hub = 'south')" a = f"SELECT COUNT(*) FROM parcels WHERE {either} AND state <> 'void'" b = f"SELECT COUNT(*) FROM parcels WHERE {either}" - assert diff(a, b).text_diff == ("- parcels.state <> 'void'",) + assert diff(a, b).text_diff == (" WHERE", "- parcels.state <> 'void'") c = "SELECT COUNT(*) FROM parcels WHERE (hub = 'north' OR hub = 'west') AND state <> 'void'" - assert diff(a, c).text_diff == ("- AND (parcels.hub = 'north' OR parcels.hub = 'south')", + assert diff(a, c).text_diff == (" WHERE", "- AND (parcels.hub = 'north' OR parcels.hub = 'south')", "+ AND (parcels.hub = 'north' OR parcels.hub = 'west')") + + +def test_context_cut_off_by_the_forty_line_cap_is_dropped(): + """截到 40 行时,最后剩下的上级行后面的不同行被截掉了:这种上级行不留,最后一行总是不同的行。""" + cols = 19 + a = ", ".join(f"SUM(fee * {i}) AS c{i}" for i in range(cols)) + b = ", ".join(f"SUM(fee * {i + 100}) AS c{i}" for i in range(cols)) + got = diff(f"SELECT {a} FROM parcels WHERE fee > 0 AND state <> 'void'", + f"SELECT {b} FROM parcels WHERE fee > 0").text_diff + # 上级行 SELECT、两边各 19 行的选择列共 39 行;第 40 行本该是 WHERE,它下面那一行被截掉了 + assert got[0] == " SELECT" and len(got) == 1 + 2 * cols + assert got[-1][:2] in ("- ", "+ ") and " WHERE" not in got diff --git a/frontend/src/dev/fixtures/dual_check.json b/frontend/src/dev/fixtures/dual_check.json index dd63120c..6024462e 100644 --- a/frontend/src/dev/fixtures/dual_check.json +++ b/frontend/src/dev/fixtures/dual_check.json @@ -247,6 +247,7 @@ ], "fallback": false, "text_diff": [ + " WHERE", "- orders.paid_at >= '2026-09-01'", "+ orders.created_at >= '2026-09-01'", "- AND orders.paid_at < '2026-09-08'", @@ -311,8 +312,10 @@ ], "fallback": true, "text_diff": [ + " WHERE", "- EXISTS(", "+ refunds.order_id IN (", + " SELECT", "- 1", "+ \"orders@1\".id", "- WHERE", @@ -583,6 +586,7 @@ ], "fallback": false, "text_diff": [ + " WHERE", "- orders.created_at >= '2026-09-01'", "+ orders.paid_at >= '2026-09-01'", "- AND orders.created_at < '2026-09-08'", @@ -754,11 +758,10 @@ ], "fallback": false, "text_diff": [ + "- FROM orders AS orders", + "- LEFT JOIN refunds AS refunds ON", "+ FROM orders AS orders, refunds AS refunds", "+ WHERE", - "- FROM orders AS orders", - "- LEFT JOIN refunds AS refunds", - "- ON", "- orders.id = refunds.order_id", "+ orders.id = refunds.order_id" ] @@ -774,6 +777,36 @@ } } }, + "join_condition": { + "sql": [ + "SELECT COUNT(*) AS lines\nFROM orders o\nLEFT JOIN refunds r ON r.order_id = o.id\n AND r.amount > 0", + "SELECT COUNT(*) AS lines\nFROM orders o\nLEFT JOIN refunds r ON r.order_id = o.id" + ], + "diff": { + "equivalent": false, + "items": [ + { + "aspect": "other", + "text": "两条 SQL 的写法不同,未能归纳成具体差异,请对照原文" + } + ], + "fallback": true, + "text_diff": [ + " LEFT JOIN refunds AS refunds ON", + "- AND refunds.amount > 0" + ] + }, + "catalog": { + "a": { + "write": false, + "reason": "指标「销售额」两边的 SQL 不止差一个方面,或有未能归纳的差异,无法确定审核人认可的是哪一处,不写入数据目录" + }, + "b": { + "write": false, + "reason": "指标「销售额」两边的 SQL 不止差一个方面,或有未能归纳的差异,无法确定审核人认可的是哪一处,不写入数据目录" + } + } + }, "dedup": { "sql": [ "SELECT COUNT(DISTINCT user_id) AS buyers\nFROM orders", @@ -789,6 +822,7 @@ ], "fallback": false, "text_diff": [ + " SELECT", "- COUNT(DISTINCT orders.user_id)", "+ COUNT(orders.user_id)" ] @@ -822,6 +856,7 @@ ], "fallback": true, "text_diff": [ + " WHERE", "- AND orders.paid_at < '2026-09-08'", "+ AND orders.paid_at < '2026-09-09'" ] @@ -881,6 +916,7 @@ ], "fallback": false, "text_diff": [ + " WHERE", "- AND orders.paid_at >= '2026-09-01'", "+ AND orders.created_at >= '2026-09-01'", "- AND orders.paid_at < '2026-09-08'", @@ -923,6 +959,7 @@ ], "fallback": false, "text_diff": [ + " SELECT", "- COUNT(DISTINCT orders.id)", "+ COUNT(orders.id)" ] @@ -956,6 +993,7 @@ ], "fallback": false, "text_diff": [ + " WHERE", "- AND orders.status <> 'cancelled'" ] }, diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 285c6ae4..ede79970 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -3865,6 +3865,8 @@ export const DUAL_CHECK_TEXT = { source: (s: string) => `数据源 ${s}`, inputs: '输入取值', textDiff: '规范化后不同的行', + /** 不同的行前面带着淡色的上级行(服务端 text_diff 里「 」开头的行)时,接在 textDiff 后面,也是那几行的悬停说明 */ + textDiffContext: '淡色的行两边相同,标出所在的子句', textDiffNone: '这一边没有不同的行', rationale: '盲算的说明', /** 理由文档里有没有出处的数字时,理由下面那一句提醒 */ diff --git a/frontend/src/run/DualCheckApproval.tsx b/frontend/src/run/DualCheckApproval.tsx index 94a9fd44..9a3f8c13 100644 --- a/frontend/src/run/DualCheckApproval.tsx +++ b/frontend/src/run/DualCheckApproval.tsx @@ -696,7 +696,7 @@ const MAX_LCS_CELLS = 250_000 /** * 两段原文按行比(不看空白、大小写和行尾的逗号分号),返回两边各自不在最长公共子序列里的行号。 * - * 不拿 text_diff 去对原文:服务端的 text_diff 是规范化之后的行(列名带表名、条件并成一行),和原文的行几乎从来 + * 不拿 text_diff 去对原文:服务端的 text_diff 是规范化之后的行(列名带表名、每个条件一行),和原文的行几乎从来 * 对不上。一行都对不上(写法完全不同、各写成一行)时返回 null:整段都标出来没有信息量,差在哪看规范化后的行 */ function lineDiff(a: string[], b: string[]): { a: Set; b: Set } | null { @@ -727,6 +727,36 @@ function lineDiff(a: string[], b: string[]): { a: Set; b: Set } /** SQL 原文按行切(去掉末尾的空白行) */ const linesOf = (sql: unknown): string[] | null => (hasSql(sql) ? (sql as string).replace(/\s+$/, '').split('\n') : null) +/** 规范化后的一行:context 是服务端带上的上级行(「 」开头、两边相同),只为标出不同的行在哪个子句里,不高亮 */ +interface NormLine { text: string; context: boolean } + +/** 行首的空格数:服务端的规范化写法用空格缩进,子句的内容缩在子句名下面 */ +const depthOf = (line: string): number => line.length - line.trimStart().length + +/** + * text_diff 里一栏的行:这一边(「- 」原流程、「+ 」盲算)不同的行,加上它们用得着的上级行。服务端在不同的行前面带上 + * 各级上级行(「 」开头:条件在哪个 WHERE、哪个 JOIN 的 ON、哪层子查询里),两边共用一份;这里只留这一边的不同行 + * 往上数用得着的——排在前面、缩进比它浅的最近一行,一层层往上——另一边才用得着的不放进这一栏(一边多出一个 WHERE 时, + * 另一边关联条件上面那一行 JOIN 和它无关)。这一边没有不同的行时一行都不留,栏里照实说没有 + */ +function normSide(lines: string[], mark: '- ' | '+ '): NormLine[] { + const own = lines.filter((l) => l.startsWith(mark) || l.startsWith(' ')) + .map((l) => ({ text: l.slice(2), context: l.startsWith(' ') })) + if (!own.some((l) => !l.context)) return [] + const keep = new Set() + own.forEach((line, i) => { + if (line.context) return + let depth = depthOf(line.text) + for (let k = i - 1; k >= 0 && depth > 0; k--) { + const above = own[k] + if (!above.text.trim() || depthOf(above.text) >= depth) continue + if (above.context) keep.add(k) + depth = depthOf(above.text) + } + }) + return own.filter((line, i) => !line.context || keep.has(i)) +} + function valueText(v: unknown): string { if (v == null) return NONE if (typeof v === 'number') return Number.isFinite(v) ? v.toLocaleString('en-US', { maximumFractionDigits: 6 }) : String(v) @@ -750,17 +780,16 @@ const CODE_BOX = 'overflow-y-auto whitespace-pre-wrap rounded border bg-panel px /** * 对照两条 SQL:先列每个输入两边各取到多少;再把不同的输入的两条 SQL 原文左右并排(卡片窄于 32rem 时上下排,按卡片 * 自己的宽度算,画布右栏里也是上下排),两段原文按行比出的不同行高亮;最后是规范化后不同的行(服务端的 text_diff), - * 同样分原流程、盲算两栏,每行高亮——原文格式差得太远、按行比不出东西时,差在哪就看这一段 + * 同样分原流程、盲算两栏,不同的行高亮,前面淡色的是它所在子句的那几行(见 normSide)——原文格式差得太远、按行比不出 + * 东西时,差在哪就看这一段 */ function Compare({ id, metric }: { id: string; metric: DualCheckMetric }) { const pairs = useMemo(() => pairsOf(metric), [metric]) const norm = useMemo(() => { const lines = (Array.isArray(metric.diff?.text_diff) ? metric.diff.text_diff : []).filter((l): l is string => typeof l === 'string') - return { - original: lines.filter((l) => l.startsWith('- ')).map((l) => l.slice(2)), - blind: lines.filter((l) => l.startsWith('+ ')).map((l) => l.slice(2)), - } + return { original: normSide(lines, '- '), blind: normSide(lines, '+ ') } }, [metric]) + const withContext = norm.original.some((l) => l.context) || norm.blind.some((l) => l.context) const differing = pairs.filter((p) => p.differs) return (
@@ -784,7 +813,9 @@ function Compare({ id, metric }: { id: string; metric: DualCheckMetric }) { {(differing.length ? differing : pairs).map((p) => )} {(norm.original.length > 0 || norm.blind.length > 0) && (
-
{T.textDiff}
+
+ {T.textDiff}{withContext && {` · ${T.textDiffContext}`}} +
@@ -866,8 +897,11 @@ function SqlPane({ side, label, lines, marks }: { side: 'original' | 'blind'; la ) } -/** 规范化后不同的行的一栏:这一边的「- 」(原流程)或「+ 」(盲算)行,每行高亮;这一边一行都没有时照实说 */ -function NormPane({ side, lines }: { side: 'original' | 'blind'; lines: string[] }) { +/** + * 规范化后不同的行的一栏:这一边的「- 」(原流程)或「+ 」(盲算)行,每行高亮;前面带着的上级行(两边相同,标出在 + * 哪个子句里)不高亮、用淡一级的字色,同原文栏里没标的行;这一边一行都没有时照实说 + */ +function NormPane({ side, lines }: { side: 'original' | 'blind'; lines: NormLine[] }) { const label = side === 'original' ? DUAL_CHECK_TEXT.original : DUAL_CHECK_TEXT.blind return (
@@ -875,11 +909,17 @@ function NormPane({ side, lines }: { side: 'original' | 'blind'; lines: string[]
{lines.length - ? lines.map((line, i) => ( - - {line || ' '} - - )) + ? lines.map((line, i) => (line.context + ? ( + + {line.text || ' '} + + ) + : ( + + {line.text || ' '} + + ))) : {T.textDiffNone}}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index e54005d2..9db241df 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -3656,7 +3656,10 @@ export interface DualCheckDiff { items: { aspect: string; text: string }[] /** 用了兜底说明(一条具体说法都没有,或者具体说法只覆盖了一部分) */ fallback: boolean - /** 规范化之后两条 SQL 不同的行:「- 」开头是原流程的,「+ 」开头是盲算的 */ + /** + * 规范化之后两条 SQL 不同的行:「- 」开头是原流程的,「+ 」开头是盲算的;「 」开头的是两边相同的上级行,只为标出 + * 不同的行在哪个子句里(老工件里没有) + */ text_diff: string[] } diff --git a/scripts/check-verify.mjs b/scripts/check-verify.mjs index 8209bd07..679fd941 100644 --- a/scripts/check-verify.mjs +++ b/scripts/check-verify.mjs @@ -4,7 +4,8 @@ // - 审批卡:两路对不上时,卡上要先按摘要画出来(完整对照还在路上时不能一片空白),再按工件补全;具体差异逐条列, // 只有兜底说明(或只有解析失败)时写明「未能归纳成具体差异」并默认展开对照;对照两条 SQL 时左右并排(窄栏上下排), // 原文按行比出的不同行高亮,规范化后不同的行(text_diff)分两栏高亮——夹具里的差异说明是服务端的真实输出 -// (backend/tests/test_dual_check_fixture.py 核对),规范化的行和原文对不上,高亮不能指望两边逐行相等; +// (backend/tests/test_dual_check_fixture.py 核对),规范化的行和原文对不上,高亮不能指望两边逐行相等;不同的行 +// 前面带着标出所在子句的上级行(两边相同、不高亮,只放进用得着它的那一栏),只差一个关联条件时看得出是哪个 JOIN 的; // 「记入数据目录」不能说得比服务端宽:服务端在摘要里预告了采用哪一边写什么(payload.summary[].catalog)时照它说, // 两边都不写就不提,当前选的那一句加重;同一次裁决里别的指标按当前的选择落到同一张表的同一项上时,照服务端写目录前 // 的归拢说——业务日期、去重规则说法不同的改说都不记,有效记录条件说合成一句;老服务端没有预告时只在可能写的形状下 @@ -309,6 +310,16 @@ await section('审批卡:并排对照与高亮(服务端真实的差异输 check('……盲算一栏是「+ 」行', marks.normAdd.join('|') === real.add.join('|'), marks.normAdd.join('|')) const hl = await gmv.locator('[data-dual-norm] [data-diff-line]').first().evaluate((el) => getComputedStyle(el).backgroundColor) check('……高亮行有底色', !!hl && hl !== 'rgba(0, 0, 0, 0)' && hl !== 'transparent', hl) + // 不同的行前面带着两边相同的上级行(这里是 WHERE:两边不同的条件都在 WHERE 里):两栏各在自己的不同行前面列一遍, + // 不高亮、不算进高亮的行;标题后面说明淡色的行是什么 + const normRows = await gmv.evaluate((el) => ['original', 'blind'].map((s) => [...el.querySelectorAll( + `[data-dual-norm="${s}"] [data-diff-context], [data-dual-norm="${s}"] [data-diff-line]`)] + .map((n) => (n.hasAttribute('data-diff-context') ? `(${n.textContent.trim()})` : n.textContent.trim())))) + check('……两边相同的上级行(WHERE)列在两栏的不同行前面', normRows.every((rows, n) => rows[0] === '(WHERE)' + && rows.slice(1).join('|') === (n ? real.add : real.del).join('|')), JSON.stringify(normRows)) + const ctxStyle = await gmv.locator('[data-dual-norm] [data-diff-context]').first().evaluate((el) => [el.tagName, getComputedStyle(el).backgroundColor]) + check('……上级行不是高亮的 、没有底色', ctxStyle[0] === 'SPAN' && (ctxStyle[1] === 'rgba(0, 0, 0, 0)' || ctxStyle[1] === 'transparent'), ctxStyle.join(' ')) + check('……标题后面说明淡色的行两边相同、标出所在的子句', (await text(gmv.locator('[data-dual-text-diff-hint]'))).includes('淡色的行两边相同,标出所在的子句')) const o = await orig.boundingBox() const b = await blind.boundingBox() check('宽栏里左右并排:同一行、盲算在右边', !!o && !!b && Math.abs(o.y - b.y) < 2 && b.x >= o.x + o.width - 1, JSON.stringify({ o, b })) @@ -351,6 +362,54 @@ await section('审批卡:并排对照与高亮(服务端真实的差异输 check('页面没有报错', errors.length === 0, errors.join(';')) }) +await section('审批卡:规范化后的行带上所在的子句(只差一个关联条件)', async () => { + // 2026-10 复核:ON 单独一行时,只差一个关联条件的那一栏只剩「AND 条件」,看不出是哪个 JOIN 的关联条件、还是筛选条件; + // 两边 JOIN 的表不同时,两边都有的那一行 ON 被当成相同的行吃掉。现在 ON 挂在 JOIN 那一行的末尾,条件前面带着它所在的 + // JOIN(两边相同的上级行,淡一级的字色、不高亮);盲算一边没有不同的行,照实说没有,不把只有原流程用得着的那一行 + // JOIN 画进去。夹具的 join_condition 是服务端对这一对 SQL 的真实输出(test_dual_check_fixture 核对) + const td = fx.variants.join_condition.diff.text_diff + check('(夹具)只差一个关联条件:一行带 ON 的上级行加一行不同的行', td.length === 2 && td[0] === ' LEFT JOIN refunds AS refunds ON' + && td[1].startsWith('- '), td.join(' | ')) + for (const theme of ['dark', 'light']) { + const { page, errors } = await preview('dual-check-approval', { artifact: cardWith('join_condition'), theme }) + await page.waitForSelector('[data-dual-check][data-state="ready"]') + const gmv = metric(page, 'gmv') + // 只给兜底说明时对照默认展开 + const toggle = gmv.locator('[data-dual-compare-toggle]') + if ((await toggle.getAttribute('aria-expanded')) !== 'true') await toggle.click() + const rows = await gmv.locator('[data-dual-norm="original"]').evaluate((el) => [...el.querySelectorAll('[data-diff-context], [data-diff-line]')] + .map((n) => ({ text: n.textContent, context: n.hasAttribute('data-diff-context'), tag: n.tagName }))) + check(`${theme}:原流程一栏先是带 ON 的 JOIN 那一行(上级行),下面是缩进的那一个关联条件(高亮)`, rows.length === 2 + && rows[0].context && rows[0].tag === 'SPAN' && rows[0].text === 'LEFT JOIN refunds AS refunds ON' + && !rows[1].context && rows[1].tag === 'MARK' && rows[1].text === ` ${td[1].slice(2).trim()}`, JSON.stringify(rows)) + const other = gmv.locator('[data-dual-norm="blind"]') + check(`${theme}:盲算一栏照实说没有不同的行,不画只有原流程用得着的上级行`, (await text(other)).includes('这一边没有不同的行') + && await other.locator('[data-diff-context], [data-diff-line]').count() === 0, await text(other)) + // 读得清:上级行的字色叠在代码框的底色上 + const c = await gmv.locator('[data-dual-norm="original"] [data-diff-context]').evaluate((el) => { + let bg = 'rgba(0, 0, 0, 0)' + 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') { bg = v; break } + } + return { fg: getComputedStyle(el).color, bg, base: getComputedStyle(document.body).backgroundColor, title: el.title } + }) + const ratio = contrast(c.fg, c.bg, c.base) + check(`${theme}:上级行的字对比度 ≥ 4.5`, ratio >= 4.5, `${ratio.toFixed(2)}(${c.fg} on ${c.bg})`) + check(`${theme}:悬停上级行说明它两边相同、标出所在的子句`, c.title === '淡色的行两边相同,标出所在的子句', c.title) + await shot(page, `approval-join-context-${theme}`) + check(`${theme}:页面没有报错`, errors.length === 0, errors.join(';')) + } + // 390px:标题后面的说明和带 ON 的 JOIN 那一行折行显示,不横向溢出 + const narrow = await preview('dual-check-approval', { artifact: cardWith('join_condition'), width: 390, height: 844 }) + await narrow.page.waitForSelector('[data-dual-check][data-state="ready"]') + const nt = metric(narrow.page, 'gmv').locator('[data-dual-compare-toggle]') + if ((await nt.getAttribute('aria-expanded')) !== 'true') await nt.click() + await narrow.page.waitForSelector('[data-dual-text-diff-hint]') + const w = await narrow.page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) + check('390px:带着上级行和说明也不横向溢出', w <= 0, `${w}px`) +}) + /** * 把「销售额」换成 variants 里的一对 SQL 及其真实的差异说明(取值照旧不同),别的指标原样;diff 给了就用给的 * (拼几个输入的、兜底的原话)。peer 给了就把「退款率」对不上的那个输入同样换成 peers 里的一对 SQL