M4 复核修正:规范化后不同的行带上所在的子句,ON 挂在 JOIN 那一行的末尾

复核发现上一版把 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 不溢出。
This commit is contained in:
ThearchyHelios
2026-10-07 01:34:59 +08:00
parent 5b18eab310
commit 6d4f452f36
9 changed files with 349 additions and 57 deletions
+76 -17
View File
@@ -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)
+7 -2
View File
@@ -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():
+3 -1
View File
@@ -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:
+102 -18
View File
@@ -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
+41 -3
View File
@@ -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'"
]
},
+2
View File
@@ -3865,6 +3865,8 @@ export const DUAL_CHECK_TEXT = {
source: (s: string) => `数据源 ${s}`,
inputs: '输入取值',
textDiff: '规范化后不同的行',
/** 不同的行前面带着淡色的上级行(服务端 text_diff 里「 」开头的行)时,接在 textDiff 后面,也是那几行的悬停说明 */
textDiffContext: '淡色的行两边相同,标出所在的子句',
textDiffNone: '这一边没有不同的行',
rationale: '盲算的说明',
/** 理由文档里有没有出处的数字时,理由下面那一句提醒 */
+54 -14
View File
@@ -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<number>; b: Set<number> } | null {
@@ -727,6 +727,36 @@ function lineDiff(a: string[], b: string[]): { a: Set<number>; b: Set<number> }
/** 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<number>()
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 (
<div id={id} className="mt-1.5 min-w-0 space-y-2" data-dual-compare="">
@@ -784,7 +813,9 @@ function Compare({ id, metric }: { id: string; metric: DualCheckMetric }) {
{(differing.length ? differing : pairs).map((p) => <PairSql key={p.path} pair={p} />)}
{(norm.original.length > 0 || norm.blind.length > 0) && (
<div data-dual-text-diff="">
<div className="mb-0.5 text-2xs text-faint">{T.textDiff}</div>
<div className="mb-0.5 text-2xs text-faint">
{T.textDiff}{withContext && <span data-dual-text-diff-hint="">{` · ${T.textDiffContext}`}</span>}
</div>
<div className="grid grid-cols-1 gap-2 @lg:grid-cols-2">
<NormPane side="original" lines={norm.original} />
<NormPane side="blind" lines={norm.blind} />
@@ -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 (
<figure className="m-0 min-w-0" data-dual-norm={side}>
@@ -875,11 +909,17 @@ function NormPane({ side, lines }: { side: 'original' | 'blind'; lines: string[]
<div className={clsx('max-h-48', CODE_BOX, lines.length ? 'mono' : 'font-sans text-faint')} tabIndex={0} role="group"
aria-label={`${T.textDiff} · ${label}`}>
{lines.length
? lines.map((line, i) => (
<mark key={i} className="mb-px block rounded-sm px-1 last:mb-0" style={MARK} data-diff-line={side === 'original' ? 'del' : 'add'}>
{line || ' '}
</mark>
))
? lines.map((line, i) => (line.context
? (
<span key={i} className="mb-px block px-1 text-dim last:mb-0" title={T.textDiffContext} data-diff-context="">
{line.text || ' '}
</span>
)
: (
<mark key={i} className="mb-px block rounded-sm px-1 last:mb-0" style={MARK} data-diff-line={side === 'original' ? 'del' : 'add'}>
{line.text || ' '}
</mark>
)))
: <span className="px-1">{T.textDiffNone}</span>}
</div>
</figure>
+4 -1
View File
@@ -3656,7 +3656,10 @@ export interface DualCheckDiff {
items: { aspect: string; text: string }[]
/** 用了兜底说明(一条具体说法都没有,或者具体说法只覆盖了一部分) */
fallback: boolean
/** 规范化之后两条 SQL 不同的行:「- 」开头是原流程的,「+ 」开头是盲算的 */
/**
* 规范化之后两条 SQL 不同的行:「- 」开头是原流程的,「+ 」开头是盲算的;「 」开头的是两边相同的上级行,只为标出
* 不同的行在哪个子句里(老工件里没有)
*/
text_diff: string[]
}
+60 -1
View File
@@ -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('……上级行不是高亮的 <mark>、没有底色', 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