合入期 5 验收修复 M4:单个工作流接口的 run_count 与列表一致;触发历史的翻页游标改成 Z 结尾、放进地址不用编码,老写法照样认;db_schema 认写成一个字符串的 JSON 数组;双盲复算「规范化后不同的行」改成每个条件一行,两边相同的条件不再因换行位置不同被标成差异

This commit is contained in:
ThearchyHelios
2026-10-12 11:42:05 +08:00
17 changed files with 938 additions and 69 deletions
+28 -3
View File
@@ -7,6 +7,7 @@
"""
from __future__ import annotations
import re
from datetime import datetime, timedelta
from typing import Any
@@ -42,6 +43,29 @@ def _iso(moment: datetime | None) -> str | None:
return service.as_utc(moment).isoformat() if moment is not None else None
#: 查询串把「+」解成空格之后,时间末尾的时区偏移:「…03.376320 00:00」
_SPACED_OFFSET = re.compile(r"(.*\d) (\d{2}:?\d{2})")
def _cursor(moment: datetime) -> str:
"""触发历史的翻页游标:UTC 写成 Z 结尾、精确到微秒。写成 +00:00 时照接口说明原样拼进地址,「+」在查询串里会被
解成空格,回 422(2026-10 验收);Z 结尾的放进地址不用编码,和运行列表的游标一个写法。"""
return service.as_utc(moment).isoformat().replace("+00:00", "Z")
def _cursor_time(before: str) -> datetime:
"""before → 时间:Z、+00:00 都认,也认「+」被查询串解成空格的「 00:00」(老的游标照原样拼进地址时就是这样)。
不是时间的照样抛 ValueError。"""
text = before.strip()
try:
return datetime.fromisoformat(text.replace("Z", "+00:00"))
except ValueError:
spaced = _SPACED_OFFSET.fullmatch(text)
if spaced is None:
raise
return datetime.fromisoformat(f"{spaced[1]}+{spaced[2]}")
async def _body(request: Request) -> dict[str, Any]:
try:
data = await request.json()
@@ -579,7 +603,8 @@ async def run_now(schedule_id: str, session: AsyncSession = Depends(get_session)
@router.get("/{schedule_id}/fires")
async def list_fires(schedule_id: str, limit: str | None = None, before: str | None = None,
session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""触发历史,新的在前:{items: [Fire], next_before}。翻页把 next_before 原样作为 before 传回来;没有更多时为空。"""
"""触发历史,新的在前:{items: [Fire], next_before}。翻页把 next_before 原样作为 before 传回来(Z 结尾,拼进地址
不用编码);没有更多时为空。"""
await _get(session, schedule_id)
size = 20
if limit is not None:
@@ -589,7 +614,7 @@ async def list_fires(schedule_id: str, limit: str | None = None, before: str | N
stmt = select(ScheduleFire).where(ScheduleFire.schedule_id == schedule_id)
if before is not None:
try:
cursor = datetime.fromisoformat(before.strip().replace("Z", "+00:00"))
cursor = _cursor_time(before)
except ValueError as e:
raise _invalid(f"翻页位置需要是接口返回的 next_before,当前为「{before}」", "before") from e
stmt = stmt.where(ScheduleFire.created_at < service.as_utc(cursor))
@@ -597,7 +622,7 @@ async def list_fires(schedule_id: str, limit: str | None = None, before: str | N
stmt.order_by(ScheduleFire.created_at.desc(), ScheduleFire.id.desc()).limit(size + 1))).scalars())
items = rows[:size]
return {"items": await _fire_outs(session, items),
"next_before": _iso(items[-1].created_at) if len(rows) > size else None}
"next_before": _cursor(items[-1].created_at) if len(rows) > size else None}
@router.post("/{schedule_id}/webhook/test")
+27 -13
View File
@@ -65,14 +65,28 @@ class WorkflowOut(BaseModel):
model_config = {"from_attributes": True}
async def _run_counts(session: AsyncSession, ids: list[str] | None = None) -> dict[str, int]:
"""各工作流的运行次数 {工作流 id: 次数},ids 为 None 时算全部。列表和单个工作流的接口都按它算:以前只有列表
计数,详情、保存、恢复的返回落在 WorkflowOut 的默认值 0 上,同一个工作流两处说的次数不一样。"""
stmt = select(Run.workflow_id, func.count(Run.id)).group_by(Run.workflow_id)
if ids is not None:
stmt = stmt.where(Run.workflow_id.in_(ids))
return dict((await session.execute(stmt)).all())
async def _workflow_out(session: AsyncSession, workflow: Workflow) -> WorkflowOut:
"""返回单个工作流的接口都走这里,run_count 和列表一致。"""
item = WorkflowOut.model_validate(workflow)
item.run_count = (await _run_counts(session, [workflow.id])).get(workflow.id, 0)
return item
@router.get("", response_model=list[WorkflowOut])
async def list_workflows(session: AsyncSession = Depends(get_session)) -> list[WorkflowOut]:
rows = list(
(await session.execute(select(Workflow).order_by(Workflow.updated_at.desc()))).scalars()
)
counts = dict(
(await session.execute(select(Run.workflow_id, func.count(Run.id)).group_by(Run.workflow_id))).all()
)
counts = await _run_counts(session)
out: list[WorkflowOut] = []
for row in rows:
item = WorkflowOut.model_validate(row)
@@ -84,7 +98,7 @@ async def list_workflows(session: AsyncSession = Depends(get_session)) -> list[W
@router.post("", response_model=WorkflowOut, status_code=201)
async def create_workflow(
payload: WorkflowIn, session: AsyncSession = Depends(get_session)
) -> Workflow:
) -> WorkflowOut:
workflow = Workflow(
name=payload.name,
description=payload.description,
@@ -99,23 +113,23 @@ async def create_workflow(
)
await session.commit()
await session.refresh(workflow)
return workflow
return await _workflow_out(session, workflow)
@router.get("/{workflow_id}", response_model=WorkflowOut)
async def get_workflow(
workflow_id: str, session: AsyncSession = Depends(get_session)
) -> Workflow:
) -> WorkflowOut:
workflow = await session.get(Workflow, workflow_id)
if not workflow:
raise HTTPException(404, _GONE)
return workflow
return await _workflow_out(session, workflow)
@router.patch("/{workflow_id}", response_model=WorkflowOut)
async def update_workflow(
workflow_id: str, payload: WorkflowPatch, session: AsyncSession = Depends(get_session)
) -> Workflow:
) -> WorkflowOut:
workflow = await session.get(Workflow, workflow_id)
if not workflow:
raise HTTPException(404, _GONE)
@@ -146,7 +160,7 @@ async def update_workflow(
workflow.status = "draft"
await session.commit()
await session.refresh(workflow)
return workflow
return await _workflow_out(session, workflow)
@router.delete("/{workflow_id}", status_code=204)
@@ -172,7 +186,7 @@ async def delete_workflow(
@router.post("/{workflow_id}/duplicate", response_model=WorkflowOut, status_code=201)
async def duplicate_workflow(
workflow_id: str, session: AsyncSession = Depends(get_session)
) -> Workflow:
) -> WorkflowOut:
source = await session.get(Workflow, workflow_id)
if not source:
raise HTTPException(404, _GONE)
@@ -185,7 +199,7 @@ async def duplicate_workflow(
session.add(copy)
await session.commit()
await session.refresh(copy)
return copy
return await _workflow_out(session, copy)
class VersionOut(BaseModel):
@@ -267,7 +281,7 @@ async def get_version(
@router.post("/{workflow_id}/versions/{version}/restore", response_model=WorkflowOut)
async def restore_version(
workflow_id: str, version: int, session: AsyncSession = Depends(get_session)
) -> Workflow:
) -> WorkflowOut:
workflow = await session.get(Workflow, workflow_id)
if not workflow:
raise HTTPException(404, _GONE)
@@ -298,7 +312,7 @@ async def restore_version(
workflow.status = "draft"
await session.commit()
await session.refresh(workflow)
return workflow
return await _workflow_out(session, workflow)
class PublishIn(BaseModel):
+154 -13
View File
@@ -149,8 +149,8 @@ WHERE / GROUP BY 里用列别名的),而且只说四类:
**兜底。** 规范形式不一致、一条具体说法都没有时,给一条 aspect="other" 的「两条 SQL 的写法不同,未能归纳成具体差异,
请对照原文」;具体说法只覆盖了一部分时追加「另有未能归纳的差异,请对照原文」;fallback 为真。text_diff 给出规范化后
不同的行(difflib 比两边 sqlglot pretty 的输出,最多 40 行);有一边没有规范形式(保真守卫拦下的)时给原文的逐行
对照。
不同的行(difflib 比两边 sqlglot pretty 的输出,WHERE、ON、HAVING 的合取式每个条件一行,不同的行前面带上标出所在子句的
上级行,最多 40 行,见 _text_diff);有一边没有规范形式(保真守卫拦下的)时给原文的逐行对照。
仍做不到的(只给兜底说明,或者认不出等价):
- 认不出的等价改写:要靠约束才成立的(主键不为空时的 COUNT(主键) 和 COUNT(*)、按主键关联又没用到的 LEFT JOIN、带
@@ -5923,8 +5923,9 @@ class SqlDiff:
equivalent:两边经过只保持语义的变换之后,规范形式一字不差(level 是哪一级的规范形式,见 _canonical_forms)。
items:具体差异,等价时为空;不等价时一定不为空——归纳不出具体差异时是一条 aspect="other" 的兜底说明。
fallback:用了兜底说明(一条具体说法都没有,或者具体说法只覆盖了一部分)。
text_diff:规范化之后两条 SQL 不同的行(difflib 比两边 sqlglot pretty 的输出,「- 」开头是 A 的、「+ 」开头是
B 的,最多 40 行),给界面高亮;等价时为空。"""
text_diff:规范化之后两条 SQL 不同的行(difflib 比两边 sqlglot pretty 的输出,WHERE、ON、HAVING 的合取式每个条件
一行,「- 」开头是 A 的、「+ 」开头是 B 的;不同的行前面带上它的各级上级行,「 」开头、两边相同,只为标出不同的行
在哪个子句里;最多 40 行,见 _text_diff),给界面高亮;等价时为空。"""
equivalent: bool
items: list[FacetDiff] = field(default_factory=list)
@@ -5942,6 +5943,10 @@ _FALLBACK = "两条 SQL 的写法不同,未能归纳成具体差异,请对
_FALLBACK_REST = "另有未能归纳的差异,请对照原文"
#: text_diff 最多列几行
_MAX_DIFF_LINES = 40
#: 逐行比较时不看的、条件前面的 AND(见 _text_diff)
_LEADING_AND = re.compile(r"^(\s*)AND ")
#: 各方言「每个条件一行」的生成器类,按 sqlglot 的生成器类缓存(见 _line_generator)
_LINE_GENERATORS: dict[type, type] = {}
_CJK_PUNCT = frozenset(",。;:、!?「」『』()《》【】…—")
#: 聚合函数的说法
@@ -7761,19 +7766,155 @@ def _by_bound(conds: list[Any]) -> list[Any]:
def _text_diff(a: _Form | None, b: _Form | None, read: str | None) -> tuple[str, ...]:
"""规范化之后两条 SQL 不同的行:difflib 比两边 sqlglot pretty 的输出,「- 」是 A 的、「+ 」是 B 的。只有一边有这一级
的规范形式时不比(只列一边的行读起来像另一边是空的),返回空的,调用方改给原文的逐行对照(见 _raw_diff)。"""
import difflib
的规范形式时不比(只列一边的行读起来像另一边是空的),返回空的,调用方改给原文的逐行对照(见 _raw_diff)。
def lines(form: _Form) -> list[str]:
try:
return form.tree.sql(dialect=read, pretty=True, comments=False).splitlines()
except Exception: # noqa: BLE001
return form.text.splitlines()
两边按同一套排版写(_diff_lines):WHERE、ON、HAVING 的合取式每个条件一行。sqlglot 的 pretty 按宽度决定换不换行,
两个条件放得下就排在一行、三个就拆开,同一个条件在两边落在不同的行上,就被当成「不同的行」(2026-10 验收:两边完全
相同的日期区间被标成了差异,审核人会以为统计期也不一样)。比的时候不看条件前面的 AND:一边多出来的条件排在最前面
时,另一边同一个条件只差在前面有没有 AND。列出来的仍是各自原样的行。只动排版,判不判等价、差异说明都不看这里。
不同的行前面带上它的各级上级行(_ancestors:排在前面、缩进比它浅的最近一行,一层层数到顶格),两边相同的那几行以
「 」开头列出,只为标出不同的行在哪个子句里——哪个 WHERE、哪个 JOIN 的 ON、哪层子查询。只列不同的行时,只差一个
关联条件的那一边只剩一行「AND 条件」,看不出是哪个 JOIN 的关联条件、还是筛选条件(2026-10 复核)。上级行在两边只差
前面有没有 AND 时,列不带 AND 的写法(两边共同的部分)。上级行本身不同的,照样是「- 」「+ 」行。"""
import difflib
if a is None or b is None:
return ()
out = [line for line in difflib.ndiff(lines(a), lines(b)) if line[:2] in ("- ", "+ ")]
return tuple(out[:_MAX_DIFF_LINES])
la, lb = _diff_lines(a, read), _diff_lines(b, read)
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
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 == "- ":
rows.append((tag, i, -1))
i += 1
elif tag == "+ ":
rows.append((tag, -1, j))
j += 1
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 的合取式每个条件一行,ON 挂在 JOIN 那一行的末尾(见
_line_generator)。生成不了就退回普通的 pretty,再不行用规范形式的原文。"""
try:
text = _line_generator(read).generate(form.tree)
except Exception: # noqa: BLE001 - 只是给人看的排版,出错不能连累差异说明
try:
text = form.tree.sql(dialect=read, pretty=True, comments=False)
except Exception: # noqa: BLE001
text = form.text
return [line.rstrip() for line in text.splitlines()]
def _condition_root(node: Any) -> str | None:
"""node 是 WHERE、HAVING 的整个条件("clause")、JOIN 的 ON 条件("on"),还是别处的(None)。"""
from sqlglot import exp
parent = node.parent
if isinstance(parent, (exp.Where, exp.Having)) and node.arg_key == "this":
return "clause"
if isinstance(parent, exp.Join) and node.arg_key == "on":
return "on"
return None
def _line_generator(read: str | None) -> Any:
"""这种方言的 sqlglot 生成器(pretty、不带注释),只改条件的排法:WHERE、HAVING、ON 的整个条件按 AND 拆开,每个条件
一行,第二个起前面写 AND(各层子查询里的也一样),ON 挂在 JOIN 那一行的末尾。别的照 sqlglot 原样:条件本身太长时
照样折行,两边同一个条件折得一样。
- 条件本身是 OR(XOR)连起来的一律加括号:和别的条件并列时本来就要加,单独一个时不加的话,同一个条件两边写法不同。
条件外面多余的括号、AND 套 AND 的括号去掉(_conjuncts),不改意思。
- ON 挂在 JOIN 那一行的末尾(表名、子查询的右括号后面),条件各占一行缩在下面:写在 ON 后面的话,第一个条件和后面
的差在「ON」「AND」上,换个先后就对不上;缩进比 WHERE 的条件深一级,ON 里和 WHERE 里的同一个条件不会被当成同一
行。ON 不单独占一行:那样两边 JOIN 的表不同时,两边都有的「ON」被当成相同的行,条件前面就没了 ON(2026-10 复核)。
"""
from sqlglot import exp
from sqlglot.dialects.dialect import Dialect
dialect = Dialect.get_or_raise(read)
base = dialect.generator_class
cls = _LINE_GENERATORS.get(base)
if cls is None:
class _ConditionLines(base): # type: ignore[misc, valid-type]
def sql(self, expression: Any, key: str | None = None, comment: bool = True) -> str:
root = _condition_root(expression) if key is None and isinstance(expression, exp.Expr) else None
conds = _conjuncts(expression) if root else []
if not conds:
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 挂到上一行末尾
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)
text = super().sql(node)
if not isinstance(node, exp.Connector):
return text
if "\n" not in text:
return f"({text})"
return f"({self.seg(self.indent(text), sep='')}{self.seg(')', sep='')}"
cls = _LINE_GENERATORS[base] = _ConditionLines
return cls(dialect=dialect, pretty=True, comments=False)
def _raw_diff(a: str, b: str) -> tuple[str, ...]:
+22 -4
View File
@@ -644,6 +644,27 @@ async def _store_schema(source: Any, ctx: ToolContext, *,
return None
def _schema_tables(table: str | list[str] | None) -> list[str]:
"""db_schema 的 table 参数 → 要看的表名。"user, roles"、["user","roles"]、整个写成一个字符串的 JSON 数组
'["user", "roles"]' 是同一个意思,模型三种都会写,为此拒绝一次调用纯属浪费一步。最后那种只按逗号拆就成了
「没有 ["user"」「没有 "roles"]」,两张表明明都在(2026-10 验收:盲算、反方审稿的核查 agent 因此白白多调了几轮
模型)。只认元素都是字符串的 JSON 数组,别的写法照旧按逗号拆。"""
wanted = [table] if isinstance(table, str) else list(table or [])
names: list[str] = []
for one in wanted:
text = str(one).strip()
if text.startswith("[") and text.endswith("]"):
try:
parsed = json.loads(text)
except ValueError:
parsed = None
if isinstance(parsed, list) and all(isinstance(t, str) for t in parsed):
names += parsed
continue
names.append(str(one))
return [t.strip() for one in names for t in one.split(",") if t.strip()]
def _make_schema_tool(source: Any, *, fixed: bool = False,
catalog: Mapping[str, data_catalog.CatalogEntry] | None = None) -> StructuredTool:
"""source 同 _make_query_tool:上传源的表结构取绑定快照里冻结的那份。
@@ -655,10 +676,7 @@ def _make_schema_tool(source: Any, *, fixed: bool = False,
async def _run(table: str | list[str] | None = None) -> str:
if gone := _vanished(source, fixed):
return f"{DATA_UNAVAILABLE}{gone}"
wanted = [table] if isinstance(table, str) else list(table or [])
# 逗号分隔也认:"user, roles" 和 ["user","roles"] 是同一个意思,
# 而模型两种都会写。为此拒绝一次调用,纯属浪费一步
wanted = [t.strip() for one in wanted for t in str(one).split(",") if t.strip()]
wanted = _schema_tables(table)
if wanted:
return "\n\n".join(introspect.describe_table(source, t, catalog=notes) for t in wanted)
tables = introspect.table_names(source)
+8 -3
View File
@@ -1,7 +1,7 @@
"""双盲复算审批卡的前端夹具(frontend/src/dev/fixtures/dual_check.json)要和服务端的真实输出对得上。
审批卡上「对照两条 SQL」的高亮、「只差一个方面会记入数据目录」的提示,都是照 explain_sql_diff 的输出画的。它的
text_diff 是规范化之后的行(sqlglot pretty:列名带表名、条件并成一行),和原文的行对不上;夹具要是照原文手写,
text_diff 是规范化之后的行(sqlglot pretty:列名带表名、每个条件一行),和原文的行对不上;夹具要是照原文手写,
check-verify 在夹具上能过,真实数据上却一处都不高亮(2026-10 复核时发现)。所以这里按夹具里的两条 SQL 重新算一遍
差异说明,逐个指标、逐个变体和夹具里写的比。sql_semantics 改了说法或规范形式,这里先变红,按下面的办法重新生成。
@@ -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():
@@ -0,0 +1,85 @@
"""触发历史的翻页游标(GET /api/schedules/{id}/fires 的 next_before)照原样拼进查询串也能翻页。
2026-10 验收:next_before 是「2026-10-06T14:37:03.376320+00:00」这样带 +00:00 的时间,照接口说明「原样传回」拼进
地址(不做百分号编码),「+」在查询串里被解成空格,回 422「翻页位置需要是接口返回的 next_before」。界面用
URLSearchParams 编码,不受影响;写脚本、做集成的照说明写就会撞上。现在 next_before 写成 Z 结尾(放进地址不用编码,
和运行列表的游标一个写法),解析时 Z、+00:00、被解成空格的「 00:00」都认:老的游标、编码过的照样能用。
定时任务、工作流、触发记录都是虚构的。
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
import pytest
from httpx import ASGITransport, AsyncClient
from app.db.base import SessionLocal
from app.db.models import Schedule, ScheduleFire, Workflow
from app.main import app
UTC = timezone.utc
#: 五次触发,记录时间各差一天、带微秒(游标得精确到微秒,翻页才不重不漏)
CREATED = [datetime(2031, 3, 1 + i, 1, 0, 1, 250_000 + i, tzinfo=UTC) for i in range(5)]
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
@pytest.fixture
async def schedule_id() -> str:
async with SessionLocal() as session:
wf = Workflow(name=f"灯塔周报-{uuid.uuid4().hex[:6]}", graph={"nodes": [], "edges": []})
session.add(wf)
await session.flush()
row = Schedule(name="灯塔周报 · 每周", workflow_id=wf.id, workflow_name=wf.name, cron="0 9 * * 1",
timezone="Asia/Shanghai", run_class="exploratory", input_template={}, enabled=False)
session.add(row)
await session.flush()
for i, at in enumerate(CREATED):
session.add(ScheduleFire(schedule_id=row.id, fire_at=at.replace(second=0, microsecond=0), kind="scheduled",
status="skipped_overlap", reason=f"第 {i} 次", created_at=at))
await session.commit()
return row.id
def _reasons(body: dict) -> list[str]:
return [f["reason"] for f in body["items"]]
async def test_next_before_needs_no_encoding(client, schedule_id):
first = (await client.get(f"/api/schedules/{schedule_id}/fires?limit=2")).json()
assert _reasons(first) == ["第 4 次", "第 3 次"]
cursor = first["next_before"]
# UTC 写成 Z 结尾、精确到微秒:没有「+」,放进地址不用编码
assert cursor == "2031-03-04T01:00:01.250003Z"
# 照原样拼进查询串(不做百分号编码)
second = await client.get(f"/api/schedules/{schedule_id}/fires?limit=2&before={cursor}")
assert second.status_code == 200, second.text
assert _reasons(second.json()) == ["第 2 次", "第 1 次"]
last = await client.get(f"/api/schedules/{schedule_id}/fires?limit=2&before={second.json()['next_before']}")
assert last.status_code == 200 and _reasons(last.json()) == ["第 0 次"] and last.json()["next_before"] is None
@pytest.mark.parametrize("written", [
"2031-03-04T01:00:01.250003+00:00", # 老的游标照原样拼进地址:「+」被解成空格
"2031-03-04T01:00:01.250003%2B00:00", # 老的游标编码过
"2031-03-04T01:00:01.250003Z",
"2031-03-04T09:00:01.250003%2B08:00", # 别的时区的同一时刻
"2031-03-04T09:00:01.250003+08:00", # ……照原样拼进地址
])
async def test_every_way_the_cursor_arrives_reads_the_same_page(client, schedule_id, written):
got = await client.get(f"/api/schedules/{schedule_id}/fires?limit=2&before={written}")
assert got.status_code == 200, got.text
assert _reasons(got.json()) == ["第 2 次", "第 1 次"]
@pytest.mark.parametrize("written", ["昨天", "2031-03-04T01:00:01 昨天", "2031-03-04T01:00:01 00:00:00"])
async def test_a_cursor_that_is_not_a_time_is_still_refused_in_chinese(client, schedule_id, written):
got = await client.get(f"/api/schedules/{schedule_id}/fires", params={"before": written})
assert got.status_code == 422 and got.json()["field"] == "before"
assert got.json()["detail"].startswith("翻页位置需要是接口返回的 next_before")
+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:
+185
View File
@@ -0,0 +1,185 @@
"""explain_sql_diff 的 text_diff(「规范化后不同的行」):两边相同的条件不因为换行的位置不同被标成差异,不同的行看得出
在哪个子句里。
2026-10 验收:双盲复算审批卡上,两边完全相同的日期区间被标成了差异——
+ orders.paid_date >= '2026-09-28' AND orders.paid_date <= '2026-10-04'
- orders.paid_date >= '2026-09-28'
- AND orders.paid_date <= '2026-10-04'
- AND orders.status <> 'refunded'
sqlglot 的 pretty 按宽度决定换不换行:两个条件放得下就排在一行,三个就拆成三行,同一个条件在两边落在不同的行上。原文
SQL 都是单行时原文栏一行都不标,这一栏是唯一逐行指出差在哪的地方,审核人会以为统计期也不一样。
现在逐行比之前两边按同一套排版写:WHERE、ON、HAVING 的合取式每个条件一行(嵌在子查询里的也一样),条件本身是 OR
连起来的加上括号;比的时候不看条件前面的 AND(一边多出来的条件排在最前面时,另一边同一个条件只差在有没有 AND 上)。
只动排版:判不判等价、差异说明(items)不变,sql_semantics、sql_equivalence 原有的测试照样全过。
复核(第 2 轮):ON 单独占一行时,只差一个关联条件的那一栏只剩一行「AND 条件」,看不出是哪个 JOIN 的关联条件、还是
筛选条件;两边 JOIN 的表不同时,两边都有的那一行「ON」被当成相同的行吃掉,条件前面没了 ON。现在 ON 挂在 JOIN 那一行
的末尾(条件缩在下面,比 WHERE 的条件深一级);不同的行前面带上它的各级上级行——「 」开头、两边相同、只为标出不同的
行在哪个子句里(哪个 WHERE、哪个 JOIN 的 ON、哪层子查询),审批卡上淡色显示。
表名、列名、日期、取值都是虚构的。
"""
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": "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 == (" 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 == (" 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 == (" 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():
"""同一列上的区间只差结束日期(这种只给兜底说明,审核人全靠逐行对照):开始日期那一行两边一样,不标。"""
a = f"SELECT SUM(fee) FROM parcels WHERE {WEEK}"
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 == (" WHERE", "- AND parcels.sent_on <= '2031-03-09'", "+ AND parcels.sent_on <= '2031-03-10'")
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 == (" 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 == (" 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 == (" 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)")
*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():
"""OR 连起来的条件和别的条件并列时要加括号,单独一个时以前不加:同一个条件两边写法不同,就被标成了差异。现在条件
本身是 OR 的一律加括号,两边写法一样;加括号不改意思。"""
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 == (" WHERE", "- parcels.state <> 'void'")
c = "SELECT COUNT(*) FROM parcels WHERE (hub = 'north' OR hub = 'west') AND state <> 'void'"
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
+46
View File
@@ -334,6 +334,52 @@ async def test_schema_tool_still_lists_everything_when_asked_for_nothing(source)
assert "张表" in out and "orders" in out
@pytest.fixture
async def depots(tmp_path):
"""两张表的库(虚构的仓储):parcels、depots,结构已探查。"""
from app.data import introspect
path = tmp_path / "depots.db"
db = sqlite3.connect(path)
db.executescript("CREATE TABLE parcels (id INTEGER PRIMARY KEY, depot_id INTEGER, fee REAL);"
"CREATE TABLE depots (id INTEGER PRIMARY KEY, zone TEXT);")
db.commit()
db.close()
src = SimpleNamespace(id="tool-args-depots-src", name="depots", kind="sqlite", database=str(path), host=None,
port=None, username=None, password=None, options={}, readonly=True, description="测试库",
schema_cache={})
src.schema_cache = await introspect.introspect(src)
return src
@pytest.mark.parametrize("written", ['["parcels", "depots"]', '["parcels","depots"]', ' [ "parcels" , "depots" ] ',
['["parcels", "depots"]'], ['["parcels"]', "depots"]])
async def test_schema_tool_reads_a_json_array_written_as_one_string(depots, written) -> None:
"""2026-10 验收:模型把 table 写成一整个字符串 '["orders", "stores"]'(参数说明里的 ["user","roles"] 引得它这么写),
只按逗号拆就成了「没有 ["orders"」「没有 "stores"]」——两张都在,模型却白白多调了几轮。写成字符串的 JSON 数组
和逗号分隔一样认。"""
out = await _make_schema_tool(depots).coroutine(table=written)
assert out.count("表 parcels") == 1 and out.count("表 depots") == 1, out
assert "没有" not in out
async def test_schema_tool_reads_a_one_table_json_array(depots) -> None:
out = await _make_schema_tool(depots).coroutine(table='["parcels"]')
assert out.startswith("表 parcels") and "depots" not in out and "没有" not in out
# 空数组和不传一样:列出所有表
assert "共 2 张表" in await _make_schema_tool(depots).coroutine(table="[]")
@pytest.mark.parametrize("written,missing", [
("[parcels]", "[parcels]"), # 不是 JSON(名字没加引号):照旧整个当表名
("['parcels']", "['parcels']"), # 单引号也不是 JSON
("[1, 2]", "[1"), # 数组里不是表名:照旧按逗号拆
])
async def test_schema_tool_leaves_other_bracketed_text_as_before(depots, written, missing) -> None:
out = await _make_schema_tool(depots).coroutine(table=written)
assert f"中没有 {missing}。" in out
def test_copilot_is_told_not_to_pin_max_steps() -> None:
"""Copilot 把 max_steps: 8 写进了生成的图,后端默认值根本轮不上。"""
from app.api.copilot import NODE_REFERENCE
@@ -0,0 +1,87 @@
"""核查 agent(engine/verify_agent.py)的 db_schema 认「整个写成一个字符串的 JSON 数组」的表名。
2026-10 验收:盲算、反方审稿的模型把 table 写成 '["orders", "stores"]'(一个字符串),工具只按逗号拆,回「中没有
["orders"」「中没有 "stores"]」,两张表明明都在;模型照原样重试、再失败,到第三轮才改成不传 table,白白多调了几轮
模型。现在第一次调用就拿到两张表的结构(tools/datasource.py 的 _schema_tables),模型看到的工具结果、时间线上的
tool.end 都是表结构。
模型是 MockChatModel 剧本,数据源是现造的 SQLite 文件,表名、数字都是虚构的。
"""
from __future__ import annotations
import json
import sqlite3
import uuid
import pytest
from langchain_core.messages import AIMessage, ToolMessage
from app.core.events import EventType
from app.data import introspect
from app.data.engine import engines
from app.db.base import SessionLocal
from app.db.models import DataSource
from app.engine import verify_agent
from app.engine.verify_agent import run_verify_agent
from app.providers.factory import ModelSpec
from app.providers.mock_model import MockChatModel
from app.tools.registry import Versions
SCHEMA = {"type": "object", "properties": {"value": {"type": "number", "description": "包裹运费合计"}},
"required": ["value"]}
@pytest.fixture
async def depots(tmp_path):
"""两张表:包裹(运费 12、8、5)和仓库。返回源名。"""
path = tmp_path / f"{uuid.uuid4().hex[:8]}.db"
db = sqlite3.connect(path)
db.executescript("CREATE TABLE parcels (id INTEGER PRIMARY KEY, depot_id INTEGER, fee INTEGER);"
"CREATE TABLE depots (id INTEGER PRIMARY KEY, zone TEXT);"
"INSERT INTO parcels VALUES (1, 1, 12), (2, 1, 8), (3, 2, 5);"
"INSERT INTO depots VALUES (1, '东区'), (2, '西区');")
db.commit()
db.close()
name = f"depots_{uuid.uuid4().hex[:8]}"
async with SessionLocal() as session:
row = DataSource(name=name, kind="sqlite", database=str(path), readonly=True, enabled=True)
session.add(row)
await session.flush()
row.schema_cache = await introspect.introspect(row)
await session.commit()
source_id = row.id
yield name
await engines.invalidate(source_id)
async def test_a_json_array_written_as_one_string_gets_both_tables_at_once(monkeypatch, depots):
seen: list[list] = []
steps = [[(f"db_schema__{depots}", {"table": '["parcels", "depots"]'}, "s1")],
[(f"db_query__{depots}", {"sql": "SELECT SUM(fee) AS total FROM parcels"}, "c1")]]
def decide(model, messages):
seen.append(list(messages))
if model.response_format:
return AIMessage(content=json.dumps({"value": {"alias": "Q1", "row": 0, "column": "total"},
"rationale": "运费合计见 [[see:Q1]]。"}, ensure_ascii=False))
if not steps:
return AIMessage(content="查完了。")
return AIMessage(content="查一下。", tool_calls=[{"name": n, "args": a, "id": i} for n, a, i in steps.pop(0)])
async def fake_model(session, spec):
return MockChatModel(model_name="mock-fast"), "mock-fast"
monkeypatch.setattr(MockChatModel, "_decide", decide)
monkeypatch.setattr(verify_agent, "get_chat_model", fake_model)
events: list[tuple] = []
out = await run_verify_agent(spec=ModelSpec(model="mock-fast"), system="核对包裹运费。", task="算一遍包裹运费合计。",
sources=[depots], output_schema=SCHEMA, data_versions=Versions.CURRENT,
emit=lambda kind, **data: events.append((kind, data)), run_id="run-m4", node_id="dual")
assert out.error is None and out.result["value"] == 25
# 模型在循环里看到的那次工具结果:两张表的结构,没有「中没有」
[answer] = [str(m.content) for messages in seen for m in messages
if isinstance(m, ToolMessage) and m.tool_call_id == "s1"][:1]
assert "表 parcels" in answer and "表 depots" in answer and "没有" not in answer
# 时间线上那一条 tool.end 也是表结构
[end] = [d for kind, d in events if kind == EventType.TOOL_END and d.get("call_id") == "s1"]
assert "表 parcels" in end["preview"] and "没有" not in end["preview"]
+84
View File
@@ -0,0 +1,84 @@
"""单个工作流接口的 run_count 和列表接口按同一个函数算。
2026-10 验收:GET /api/workflows/{id} 的 run_count 恒为 0,同一时刻列表接口(GET /api/workflows)里这个工作流是
2 次、运行列表按 workflow_id 筛出来也是 2 条——只有列表接口按 Run.workflow_id 计数,详情、保存(PATCH)、恢复旧版
直接返回数据库对象,WorkflowOut.run_count 落在默认值 0 上。现在凡是返回工作流的接口都按同一个函数算,次数和列表一致。
工作流名、图、运行都是虚构的。
"""
from __future__ import annotations
import pytest
from httpx import ASGITransport, AsyncClient
from app.db.base import SessionLocal
from app.db.models import Run
from app.main import app
GRAPH = {
"nodes": [
{"id": "start", "type": "input", "position": {"x": 0, "y": 0},
"data": {"label": "输入", "config": {"fields": [{"name": "week"}]}}},
{"id": "out", "type": "output", "position": {"x": 300, "y": 0},
"data": {"label": "成果", "config": {"fields": [{"name": "r", "value": "{{ input.week }}"}]}}},
],
"edges": [{"source": "start", "target": "out"}],
}
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
async def _add_runs(workflow_id: str | None, n: int, status: str = "succeeded") -> None:
async with SessionLocal() as session:
for _ in range(n):
session.add(Run(workflow_id=workflow_id, workflow_name="灯塔周报", graph=GRAPH, input={}, status=status))
await session.commit()
async def _listed(client, workflow_id: str) -> int:
[row] = [w for w in (await client.get("/api/workflows")).json() if w["id"] == workflow_id]
return row["run_count"]
async def test_the_single_workflow_counts_its_runs_like_the_list(client):
made = (await client.post("/api/workflows", json={"name": "灯塔周报", "graph": GRAPH})).json()
other = (await client.post("/api/workflows", json={"name": "潮汐日报", "graph": GRAPH})).json()
await _add_runs(made["id"], 3)
await _add_runs(made["id"], 1, status="failed")
# 别的工作流、没挂在工作流上的运行不算进来
await _add_runs(other["id"], 2)
await _add_runs(None, 1)
assert await _listed(client, made["id"]) == 4
got = await client.get(f"/api/workflows/{made['id']}")
assert got.status_code == 200 and got.json()["run_count"] == 4
assert (await client.get(f"/api/workflows/{other['id']}")).json()["run_count"] == await _listed(client, other["id"]) == 2
async def test_save_and_restore_answer_with_the_same_count(client):
"""保存、恢复旧版的返回也是这个工作流:界面拿它替换手里的那一份,次数不能变回 0。"""
made = (await client.post("/api/workflows", json={"name": "灯塔周报", "graph": GRAPH})).json()
await _add_runs(made["id"], 2)
renamed = await client.patch(f"/api/workflows/{made['id']}", json={"name": "灯塔周报(新)"})
assert renamed.status_code == 200 and renamed.json()["run_count"] == 2
changed = {**GRAPH, "nodes": [*GRAPH["nodes"][:1], {**GRAPH["nodes"][1], "position": {"x": 320, "y": 0}}]}
saved = await client.patch(f"/api/workflows/{made['id']}", json={"graph": changed, "note": "挪了一下"})
assert saved.status_code == 200 and saved.json()["version"] == 2 and saved.json()["run_count"] == 2
restored = await client.post(f"/api/workflows/{made['id']}/versions/1/restore")
assert restored.status_code == 200 and restored.json()["run_count"] == 2
assert await _listed(client, made["id"]) == 2
async def test_new_and_copied_workflows_have_no_runs_yet(client):
"""新建、复制出来的是新工作流,还没有运行;复制不带上原来那个的次数。"""
made = (await client.post("/api/workflows", json={"name": "灯塔周报", "graph": GRAPH})).json()
assert made["run_count"] == 0
await _add_runs(made["id"], 3)
copy = await client.post(f"/api/workflows/{made['id']}/duplicate")
assert copy.status_code == 201 and copy.json()["run_count"] == 0
assert (await client.get(f"/api/workflows/{made['id']}")).json()["run_count"] == 3
+58 -13
View File
@@ -247,8 +247,11 @@
],
"fallback": false,
"text_diff": [
"- orders.paid_at >= '2026-09-01' AND orders.paid_at < '2026-09-08'",
"+ orders.created_at >= '2026-09-01' AND orders.created_at < '2026-09-08'"
" WHERE",
"- orders.paid_at >= '2026-09-01'",
"+ orders.created_at >= '2026-09-01'",
"- AND orders.paid_at < '2026-09-08'",
"+ AND orders.created_at < '2026-09-08'"
]
}
},
@@ -309,12 +312,15 @@
],
"fallback": true,
"text_diff": [
" WHERE",
"- EXISTS(",
"+ refunds.order_id IN (",
" SELECT",
"- 1",
"+ \"orders@1\".id",
"- WHERE",
"- \"orders@1\".id = refunds.order_id AND \"orders@1\".status <> 'test'"
"- \"orders@1\".id = refunds.order_id",
"- AND \"orders@1\".status <> 'test'"
]
}
}
@@ -580,8 +586,11 @@
],
"fallback": false,
"text_diff": [
"- orders.created_at >= '2026-09-01' AND orders.created_at < '2026-09-08'",
"+ orders.paid_at >= '2026-09-01' AND orders.paid_at < '2026-09-08'"
" WHERE",
"- orders.created_at >= '2026-09-01'",
"+ orders.paid_at >= '2026-09-01'",
"- AND orders.created_at < '2026-09-08'",
"+ AND orders.paid_at < '2026-09-08'"
]
},
"catalog": {
@@ -621,7 +630,8 @@
"fallback": false,
"text_diff": [
"- WHERE",
"- 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-08'"
]
},
"catalog": {
@@ -718,7 +728,8 @@
"fallback": false,
"text_diff": [
"- WHERE",
"- orders.channel = 'web' AND orders.status <> 'refunded'"
"- orders.channel = 'web'",
"- AND orders.status <> 'refunded'"
]
},
"catalog": {
@@ -748,10 +759,10 @@
"fallback": false,
"text_diff": [
"- FROM orders AS orders",
"- LEFT JOIN refunds AS refunds",
"- LEFT JOIN refunds AS refunds ON",
"+ FROM orders AS orders, refunds AS refunds",
"+ WHERE",
"- ON orders.id = refunds.order_id",
"- orders.id = refunds.order_id",
"+ orders.id = refunds.order_id"
]
},
@@ -766,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",
@@ -781,6 +822,7 @@
],
"fallback": false,
"text_diff": [
" SELECT",
"- COUNT(DISTINCT orders.user_id)",
"+ COUNT(orders.user_id)"
]
@@ -814,8 +856,9 @@
],
"fallback": true,
"text_diff": [
"- orders.paid_at >= '2026-09-01' AND orders.paid_at < '2026-09-08'",
"+ orders.paid_at >= '2026-09-01' AND orders.paid_at < '2026-09-09'"
" WHERE",
"- AND orders.paid_at < '2026-09-08'",
"+ AND orders.paid_at < '2026-09-09'"
]
},
"catalog": {
@@ -873,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'",
@@ -915,6 +959,7 @@
],
"fallback": false,
"text_diff": [
" SELECT",
"- COUNT(DISTINCT orders.id)",
"+ COUNT(orders.id)"
]
@@ -948,8 +993,8 @@
],
"fallback": false,
"text_diff": [
"- orders.id = refunds.order_id AND orders.status <> 'cancelled'",
"+ orders.id = refunds.order_id"
" WHERE",
"- AND orders.status <> 'cancelled'"
]
},
"catalog": {
+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[]
}
+18 -2
View File
@@ -578,12 +578,15 @@ async function reply(state, method, path, url, body) {
return { json: { fire: f, run_id: 'run-new-1' } }
}
if (method === 'GET' && rest === '/fires') {
// 游标照服务端的写法:最后一条的记录时间,UTC 写成 Z 结尾;legacyCursor 时写成老服务端的 +00:00(「+」不编码
// 放进查询串会被解成空格,页面得照样翻得过去)
const cursor = (f) => (state.legacyCursor ? f.created_at.replace(/Z$/, '+00:00') : f.created_at)
const limit = Number(url.searchParams.get('limit') ?? 20)
const before = url.searchParams.get('before')
const start = before ? state.fires.findIndex((f) => f.fire_at === before) + 1 : 0
const start = before ? state.fires.findIndex((f) => cursor(f) === before) + 1 : 0
const items = state.fires.slice(start, start + limit)
const more = start + limit < state.fires.length
return { json: { items, next_before: more ? items.at(-1).fire_at : null } }
return { json: { items, next_before: more ? cursor(items.at(-1)) : null } }
}
if (method === 'POST' && rest === '/webhook/test') return { json: state.webhookTest ?? WEBHOOK_TEST_OK }
}
@@ -1439,6 +1442,19 @@ await section('详情:触发历史分页', async () => {
.some((x) => x.key === 'POST /schedules/preview'))
check('没有页面报错', !errors.length, errors.join(' | '))
// 老服务端给的游标带 +00:00:页面照样编码成 %2B 传回去,第二页接得上、不重复
const legacy = await open('/schedules/s-weekly?tab=history', { state: freshState({ legacyCursor: true }) })
await legacy.page.locator('[data-fire-id]').first().waitFor()
await legacy.page.locator('[data-fire-more]').click()
await until(async () => (await legacy.page.locator('[data-fire-id]').count()) === 25)
const old = legacy.sent.filter((s) => s.key === 'GET /schedules/s-weekly/fires').at(-1)
const want = makeFires()[19].created_at.replace(/Z$/, '+00:00')
check('老服务端的 +00:00 游标:编码后原样传回(「+」写成 %2B)', (old?.search ?? '').includes('%2B')
&& new URLSearchParams(old?.search ?? '').get('before') === want, old?.search ?? '')
check('……接在后面,共 25 条、没有重复的行', new Set(await legacy.page.locator('[data-fire-id]')
.evaluateAll((els) => els.map((e) => e.getAttribute('data-fire-id')))).size === 25)
check('……没有页面报错', !legacy.errors.length, legacy.errors.join(' | '))
const none = freshState({ fires: [] })
const e = await open('/schedules/s-stock?tab=history', { state: none })
await e.page.locator('[data-fire-empty]').waitFor()
+73 -2
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)时照它说,
// 两边都不写就不提,当前选的那一句加重;同一次裁决里别的指标按当前的选择落到同一张表的同一项上时,照服务端写目录前
// 的归拢说——业务日期、去重规则说法不同的改说都不记,有效记录条件说合成一句;老服务端没有预告时只在可能写的形状下
@@ -297,7 +298,7 @@ await section('审批卡:并排对照与高亮(服务端真实的差异输
const blind = gmv.locator('[data-dual-sql="blind"]')
check('两条 SQL 都在,各标是哪一边', (await text(orig)).includes('paid_at') && (await text(blind)).includes('created_at')
&& (await text(gmv.locator('[data-dual-pair]'))).includes('原流程的 SQL') && (await text(gmv.locator('[data-dual-pair]'))).includes('盲算的 SQL'))
// 夹具的 text_diff 是 explain_sql_diff 的真实输出:规范化后列名带表名、两个条件并成一行,和原文的任何一行都对不上
// 夹具的 text_diff 是 explain_sql_diff 的真实输出:规范化后列名带表名、每个条件一行,和原文的任何一行都对不上
const real = sidesOf(fx.card.metrics[0].diff)
const rawLines = new Set(fx.card.metrics[0].original.inputs[0].sql.split('\n').map((l) => l.trim()))
check('(夹具)text_diff 是规范化后的行,和原文逐行对不上', real.del.length > 0 && !real.del.some((l) => rawLines.has(l)), real.del.join(' | '))
@@ -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
@@ -462,6 +521,18 @@ await section('审批卡:没有预告时,「记入数据目录」只说必
if (!real.add.length) {
check('……一边没有不同的行时照实说', (await text(gmv.locator('[data-dual-norm="blind"]'))).includes('没有不同的行'))
}
// 规范化后每个条件一行(2026-10 验收:两个条件排在一行、三个拆成三行,两边相同的日期条件因为换行的位置不同被
// 标成了差异)。只差结束日期时,两边一样的开始日期不标,只标结束日期那一行
if (name === 'fallback') {
const marked = [...m.normDel, ...m.normAdd]
check('……两边相同的条件(开始日期)不标,只标不同的那一行', m.normDel.length === 1 && m.normAdd.length === 1
&& !marked.some((l) => l.includes(">= '2026-09-01'"))
&& marked.every((l) => l.includes("orders.paid_at < '2026-09-")), marked.join(' ‖ '))
}
if (name === 'two_filters') {
check('……一边多出的两个条件各占一行', m.normDel.includes("orders.channel = 'web'")
&& m.normDel.includes("AND orders.status <> 'refunded'"), m.normDel.join(' | '))
}
}
check(`${what}:页面没有报错`, errors.length === 0, errors.join(';'))
}