diff --git a/backend/app/api/schedules.py b/backend/app/api/schedules.py index 5ca0a2d1..9b160db5 100644 --- a/backend/app/api/schedules.py +++ b/backend/app/api/schedules.py @@ -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") diff --git a/backend/app/api/workflows.py b/backend/app/api/workflows.py index 69703cf3..fd25b882 100644 --- a/backend/app/api/workflows.py +++ b/backend/app/api/workflows.py @@ -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): diff --git a/backend/app/data/sql_semantics.py b/backend/app/data/sql_semantics.py index 13f99203..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 的输出,最多 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, ...]: diff --git a/backend/app/tools/datasource.py b/backend/app/tools/datasource.py index 1cf26969..0043a726 100644 --- a/backend/app/tools/datasource.py +++ b/backend/app/tools/datasource.py @@ -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) diff --git a/backend/tests/test_dual_check_fixture.py b/backend/tests/test_dual_check_fixture.py index 0ee341c9..cbdea294 100644 --- a/backend/tests/test_dual_check_fixture.py +++ b/backend/tests/test_dual_check_fixture.py @@ -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(): diff --git a/backend/tests/test_schedule_fire_cursor.py b/backend/tests/test_schedule_fire_cursor.py new file mode 100644 index 00000000..4eff5191 --- /dev/null +++ b/backend/tests/test_schedule_fire_cursor.py @@ -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") 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 new file mode 100644 index 00000000..f49f670d --- /dev/null +++ b/backend/tests/test_sql_text_diff.py @@ -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 diff --git a/backend/tests/test_tool_args.py b/backend/tests/test_tool_args.py index b310098c..1dd6dba7 100644 --- a/backend/tests/test_tool_args.py +++ b/backend/tests/test_tool_args.py @@ -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 diff --git a/backend/tests/test_verify_schema_table_arg.py b/backend/tests/test_verify_schema_table_arg.py new file mode 100644 index 00000000..f9f7df4f --- /dev/null +++ b/backend/tests/test_verify_schema_table_arg.py @@ -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"] diff --git a/backend/tests/test_workflow_run_count.py b/backend/tests/test_workflow_run_count.py new file mode 100644 index 00000000..e6ad7071 --- /dev/null +++ b/backend/tests/test_workflow_run_count.py @@ -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 diff --git a/frontend/src/dev/fixtures/dual_check.json b/frontend/src/dev/fixtures/dual_check.json index f39233a5..6024462e 100644 --- a/frontend/src/dev/fixtures/dual_check.json +++ b/frontend/src/dev/fixtures/dual_check.json @@ -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": { 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-schedules.mjs b/scripts/check-schedules.mjs index ec7a7e48..bc67671b 100644 --- a/scripts/check-schedules.mjs +++ b/scripts/check-schedules.mjs @@ -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() diff --git a/scripts/check-verify.mjs b/scripts/check-verify.mjs index 667a7b93..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)时照它说, // 两边都不写就不提,当前选的那一句加重;同一次裁决里别的指标按当前的选择落到同一张表的同一项上时,照服务端写目录前 // 的归拢说——业务日期、去重规则说法不同的改说都不记,有效记录条件说合成一句;老服务端没有预告时只在可能写的形状下 @@ -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('……上级行不是高亮的 、没有底色', 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(';')) }