mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-14 00:48:26 +08:00
### What problem does this PR solve? Closes #16414. The **Crawler** agent tool (`agent/tools/crawler.py`) was never ported to the modern `ToolBase`/`_invoke` interface during the agent module redesign, so it was broken in three independent ways: 1. **Crashed on construction.** `CrawlerParam` extends `ToolParamBase`, whose `__init__` reads `self.meta["parameters"]`, but `CrawlerParam` defined no `meta`. Constructing it raised `AttributeError: 'CrawlerParam' object has no attribute 'meta'`. Because `agent/canvas.py` instantiates `component_class(component_name + "Param")()` while loading a canvas, **any agent containing a Crawler node failed to load.** 2. **`_invoke` missing.** It extends `ToolBase` (whose `invoke()` dispatches to `self._invoke`) but only implemented the legacy `_run`, so `_invoke` resolved to `ComponentBase._invoke` → `NotImplementedError`. 3. **`be_output` removed.** `_run` called `Crawler.be_output(...)`, which no longer exists on the base classes. ### Type of change - [x] Bug Fix (non-breaking change which fixes an issue) ### Changes - Add a `ToolMeta` to `CrawlerParam` (defined before `super().__init__()`, matching every other ported tool such as `ArXivParam`/`TavilyExtractParam`) advertising a required `query` parameter — the URL to crawl, default `{sys.query}`, consistent with the `{sys.query}` convention shared by the other tools. - Replace the legacy `_run`/`be_output` with `_invoke`/`set_output`, writing the extracted page content to `formalized_content` (errors surfaced via `_ERROR`), consistent with the other tools. - Preserve the existing SSRF guard (`assert_url_is_safe` + `pin_dns_global`). - Add regression tests (`test/unit_test/agent/component/test_crawler.py`) covering param construction, validation, and the tool descriptor. Same class of defect as #16329 (DeepL). Backend-only; no frontend changes. --------- Co-authored-by: Zhichang Yu <yuzhichang@gmail.com>
508 lines
20 KiB
Python
508 lines
20 KiB
Python
#
|
|
# Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
"""Round-trip stability integration tests for the agent dsl bridge.
|
|
|
|
These mirror the front-end bridge pipeline (`web/src/pages/agent/utils/dsl-bridge.ts`)
|
|
in pure Python so the same invariants — v1 export byte-stable under
|
|
re-import, v2 export byte-stable under re-import, React-Flow internal
|
|
fields downgraded to warnings — can be checked from the Python test
|
|
suite without the npm/Jest toolchain.
|
|
|
|
The Python port is deliberately a near-line-for-line translation of
|
|
the TypeScript implementation: any change to the TS bridge should
|
|
be reflected here in the same commit. The two implementations agree
|
|
on the structural invariants (position, edge topology, components
|
|
map, _layout positions) and disagree only on the iteration order of
|
|
plain-object keys at JSON.stringify time — that's a downstream
|
|
concern, not a round-trip-stability concern.
|
|
|
|
Fixtures live in `internal/agent/dsl/testdata/` and are
|
|
the same on-disk JSONs the Go server's `TestNormalizeForCanvas_FixtureSmoke`
|
|
consumes. A new fixture added to that directory can be picked up
|
|
here by adding one `def test_*` below.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import warnings
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
|
|
# ─── Python port of web/src/pages/agent/utils/dsl-bridge.ts ─────────────
|
|
#
|
|
# Only the round-trip-relevant subset is ported. The TS bridge also
|
|
# handles EmptyDsl/EmptyDslV1 seeding, DataflowEmptyDsl variants,
|
|
# and the place-holder filter — those are not exercised by the
|
|
# round-trip path and are deliberately omitted here.
|
|
|
|
_COMPONENT_NAME_TO_NODE_TYPE: dict[str, str] = {
|
|
"Begin": "beginNode",
|
|
"Retrieval": "ragNode",
|
|
"Categorize": "categorizeNode",
|
|
"Message": "messageNode",
|
|
"Answer": "messageNode",
|
|
"RewriteQuestion": "rewriteNode",
|
|
"ExeSQL": "toolNode",
|
|
"Switch": "switchNode",
|
|
"Agent": "agentNode",
|
|
"Tool": "toolNode",
|
|
"File": "fileNode",
|
|
"Parser": "parserNode",
|
|
"Tokenizer": "tokenizerNode",
|
|
"TokenChunker": "chunkerNode",
|
|
"TitleChunker": "chunkerNode",
|
|
"Extractor": "contextNode",
|
|
"Loop": "loopNode",
|
|
"LoopStart": "loopStartNode",
|
|
"ExitLoop": "exitLoopNode",
|
|
"Iteration": "iterationNode",
|
|
"IterationStart": "iterationStartNode",
|
|
"DataOperations": "dataOperationsNode",
|
|
"ListOperations": "listOperationsNode",
|
|
"VariableAssigner": "variableAssignerNode",
|
|
"VariableAggregator": "variableAggregatorNode",
|
|
"Keyword": "keywordNode",
|
|
"Note": "noteNode",
|
|
"Placeholder": "placeholderNode",
|
|
"Code": "toolNode",
|
|
}
|
|
|
|
|
|
def _component_name_to_node_type(name: str) -> str:
|
|
return _COMPONENT_NAME_TO_NODE_TYPE.get(name, "agentNode")
|
|
|
|
|
|
def _to_string_list(value: Any) -> list[str]:
|
|
if not isinstance(value, list):
|
|
return []
|
|
return [v for v in value if isinstance(v, str) and v]
|
|
|
|
|
|
# ─── v1 helpers ─────────────────────────────────────────────────────────
|
|
|
|
|
|
def _v1_components_to_graph(components: dict[str, Any]) -> tuple[list[dict], list[dict]]:
|
|
"""Port of `v1ComponentsToGraph` in dsl-bridge.ts.
|
|
|
|
Returns (nodes, edges) in React-Flow shape. Positions are the
|
|
default 50/350/200 row layout — the round-trip test for the v1
|
|
path uses `_layout` as the authoritative position source, so
|
|
the v1-derived positions are only consumed when no `_layout`
|
|
exists (matching the TS bridge's fallback behaviour).
|
|
"""
|
|
nodes: list[dict] = []
|
|
edges: list[dict] = []
|
|
for i, (key, raw) in enumerate(components.items()):
|
|
comp = raw if isinstance(raw, dict) else {}
|
|
obj = comp.get("obj") if isinstance(comp.get("obj"), dict) else {}
|
|
name = obj.get("component_name") or comp.get("name") or key
|
|
params = obj.get("params") if isinstance(obj.get("params"), dict) else (comp.get("params") if isinstance(comp.get("params"), dict) else {})
|
|
nodes.append(
|
|
{
|
|
"id": key,
|
|
"type": _component_name_to_node_type(name),
|
|
"position": {"x": 50 + i * 350, "y": 200},
|
|
"data": {"label": name, "name": name, "form": params},
|
|
"sourcePosition": "right",
|
|
"targetPosition": "left",
|
|
}
|
|
)
|
|
for dst in _to_string_list(comp.get("downstream")):
|
|
edges.append(
|
|
{
|
|
"id": f"xy-edge__{key}-{dst}",
|
|
"source": key,
|
|
"target": dst,
|
|
"sourceHandle": "start",
|
|
"targetHandle": "end",
|
|
}
|
|
)
|
|
return nodes, edges
|
|
|
|
|
|
def _graph_to_v1_components(graph: dict[str, Any]) -> dict[str, Any]:
|
|
"""Port of `graphToV1Components` in dsl-bridge.ts (the inverse of
|
|
`_v1_components_to_graph`).
|
|
"""
|
|
edges = graph.get("edges") or []
|
|
downstream_map: dict[str, list[str]] = {}
|
|
upstream_map: dict[str, list[str]] = {}
|
|
for edge in edges:
|
|
if not isinstance(edge, dict):
|
|
continue
|
|
src = edge.get("source")
|
|
dst = edge.get("target")
|
|
if not isinstance(src, str) or not isinstance(dst, str):
|
|
continue
|
|
downstream_map.setdefault(src, []).append(dst)
|
|
upstream_map.setdefault(dst, []).append(src)
|
|
|
|
components: dict[str, Any] = {}
|
|
for raw_node in graph.get("nodes") or []:
|
|
if not isinstance(raw_node, dict):
|
|
continue
|
|
node_id = raw_node.get("id")
|
|
if not isinstance(node_id, str) or not node_id:
|
|
continue
|
|
data = raw_node.get("data") if isinstance(raw_node.get("data"), dict) else {}
|
|
label = data.get("label") or node_id
|
|
form = data.get("form") if isinstance(data.get("form"), dict) else {}
|
|
components[node_id] = {
|
|
"obj": {
|
|
"component_name": label,
|
|
"params": form,
|
|
},
|
|
"downstream": downstream_map.get(node_id, []),
|
|
"upstream": upstream_map.get(node_id, []),
|
|
}
|
|
return components
|
|
|
|
|
|
def _build_dsl_components_by_graph(nodes: list[dict], edges: list[dict], seed: dict[str, Any]) -> dict[str, Any]:
|
|
"""Port of `buildDslComponentsByGraph` (web/src/pages/agent/utils.ts:472).
|
|
|
|
Reverse-derives a v1-style `components` map from React-Flow
|
|
nodes/edges. Each node becomes a `component_name`/`params` pair
|
|
under `obj`, with `downstream`/`upstream` aggregated from the
|
|
edge list. Legacy `obj` fields on existing entries in `seed` are
|
|
preserved by the TS implementation (round-trip a `_deprecated_*`
|
|
field through a save/edit cycle); the Python port keeps the same
|
|
shape and accepts the same `seed` for consistency, even though
|
|
no legacy fields are currently in use.
|
|
"""
|
|
downstream_map: dict[str, list[str]] = {}
|
|
upstream_map: dict[str, list[str]] = {}
|
|
for edge in edges or []:
|
|
if not isinstance(edge, dict):
|
|
continue
|
|
src = edge.get("source")
|
|
dst = edge.get("target")
|
|
if not isinstance(src, str) or not isinstance(dst, str):
|
|
continue
|
|
downstream_map.setdefault(src, []).append(dst)
|
|
upstream_map.setdefault(dst, []).append(src)
|
|
|
|
out: dict[str, Any] = dict(seed) # preserve legacy `obj` fields
|
|
for raw_node in nodes or []:
|
|
if not isinstance(raw_node, dict):
|
|
continue
|
|
node_id = raw_node.get("id")
|
|
if not isinstance(node_id, str) or not node_id:
|
|
continue
|
|
data = raw_node.get("data") if isinstance(raw_node.get("data"), dict) else {}
|
|
label = data.get("label") or node_id
|
|
form = data.get("form") if isinstance(data.get("form"), dict) else {}
|
|
out[node_id] = {
|
|
"obj": {
|
|
"component_name": label,
|
|
"params": form,
|
|
},
|
|
"downstream": downstream_map.get(node_id, []),
|
|
"upstream": upstream_map.get(node_id, []),
|
|
}
|
|
return out
|
|
|
|
|
|
def _build_v1_dsl_from_import(raw: dict[str, Any], is_agent: bool) -> dict[str, Any]:
|
|
"""Port of `buildV1DslFromImport`. Accepts both v1 (`components` key)
|
|
and v2 (`graph` key) input shapes and produces a v1 envelope.
|
|
"""
|
|
out: dict[str, Any] = dict(raw) # shallow copy, will overwrite below
|
|
if isinstance(raw.get("components"), dict) and raw["components"]:
|
|
out["components"] = raw["components"]
|
|
layout = raw.get("_layout")
|
|
out["_layout"] = layout
|
|
elif isinstance(raw.get("graph"), dict) and (isinstance(raw["graph"].get("nodes"), list) and raw["graph"]["nodes"]):
|
|
graph = raw["graph"]
|
|
out["components"] = _graph_to_v1_components(graph)
|
|
# Mirror the TS bridge: stash positions in _layout so the
|
|
# user's saved canvas layout comes back unchanged.
|
|
out["_layout"] = {
|
|
"nodes": graph.get("nodes") or [],
|
|
"edges": graph.get("edges") or [],
|
|
}
|
|
return out
|
|
|
|
|
|
# ─── v2 helpers ─────────────────────────────────────────────────────────
|
|
|
|
|
|
def _build_v2_dsl_from_import(raw: dict[str, Any], is_agent: bool) -> dict[str, Any]:
|
|
"""Port of `buildV2DslFromImport`. Accepts both v1 and v2 input
|
|
shapes and produces a v2 envelope.
|
|
"""
|
|
out: dict[str, Any] = dict(raw)
|
|
if isinstance(raw.get("graph"), dict) and (isinstance(raw["graph"].get("nodes"), list) and raw["graph"]["nodes"]):
|
|
out["graph"] = raw["graph"]
|
|
# v2 input that carries its own `components` (e.g. a v2 file
|
|
# exported from the front-end with `bridge.exportDsl`) keeps
|
|
# them. When the input only has `graph` (e.g. a hand-edited
|
|
# dsl or a Go-server payload that was serialized without the
|
|
# v2 dual `components` block) we derive a v1-style
|
|
# `components` map via `buildDslComponentsByGraph` so the
|
|
# server can still serve a re-imported file.
|
|
if not isinstance(raw.get("components"), dict):
|
|
out["components"] = _build_dsl_components_by_graph(
|
|
out["graph"].get("nodes") or [],
|
|
out["graph"].get("edges") or [],
|
|
{},
|
|
)
|
|
elif isinstance(raw.get("components"), dict) and raw["components"]:
|
|
components = raw["components"]
|
|
out["components"] = components
|
|
layout = raw.get("_layout")
|
|
# v1 → v2 cross-mode: prefer saved _layout positions over
|
|
# the default 50/350/200 row layout the inverse-conversion
|
|
# would produce. The user's drag-and-drop work survives.
|
|
if isinstance(layout, dict) and isinstance(layout.get("nodes"), list) and layout["nodes"]:
|
|
out["graph"] = {
|
|
"nodes": layout["nodes"],
|
|
"edges": layout.get("edges") or [],
|
|
}
|
|
else:
|
|
nodes, edges = _v1_components_to_graph(components)
|
|
out["graph"] = {"nodes": nodes, "edges": edges}
|
|
else:
|
|
out["graph"] = {"nodes": [], "edges": []}
|
|
out["components"] = {}
|
|
return out
|
|
|
|
|
|
# ─── Public bridge surface (Python port) ────────────────────────────────
|
|
|
|
|
|
def dsl_to_graph(dsl: dict[str, Any]) -> tuple[list[dict], list[dict]]:
|
|
"""Port of the mode-agnostic `dslToGraph` reader. Reads the
|
|
canonical `graph` block exclusively — no `_layout` or
|
|
`components` fallback, matching the strict-graph contract
|
|
in the TypeScript bridge.
|
|
"""
|
|
graph = dsl.get("graph") if isinstance(dsl.get("graph"), dict) else None
|
|
if graph and isinstance(graph.get("nodes"), list) and graph["nodes"]:
|
|
return list(graph["nodes"]), list(graph.get("edges") or [])
|
|
return [], []
|
|
|
|
|
|
def graph_to_dsl(mode: str, nodes: list[dict], edges: list[dict], old_dsl: dict[str, Any]) -> dict[str, Any]:
|
|
"""Port of `graphToDsl` (both v1 and v2 branches)."""
|
|
out = dict(old_dsl)
|
|
if mode == "v1":
|
|
out["_layout"] = {"nodes": list(nodes), "edges": list(edges)}
|
|
if "graph" in out:
|
|
del out["graph"]
|
|
else:
|
|
out["graph"] = {"nodes": list(nodes), "edges": list(edges)}
|
|
if "_layout" in out:
|
|
del out["_layout"]
|
|
return out
|
|
|
|
|
|
def export_dsl(mode: str, nodes: list[dict], edges: list[dict], old_dsl: dict[str, Any]) -> dict[str, Any]:
|
|
"""Port of `exportDsl` (both modes). Returns the file-shape payload
|
|
that ends up in the downloaded .json.
|
|
"""
|
|
full = graph_to_dsl(mode, nodes, edges, old_dsl)
|
|
if mode == "v1":
|
|
return {
|
|
"components": full.get("components", {}),
|
|
"_layout": full.get("_layout"),
|
|
"retrieval": full.get("retrieval", []),
|
|
"history": full.get("history", []),
|
|
"path": full.get("path", []),
|
|
"variables": full.get("variables", []),
|
|
"globals": full.get("globals", {}),
|
|
}
|
|
return {
|
|
"graph": full.get("graph"),
|
|
"components": full.get("components", {}),
|
|
"globals": full.get("globals", {}),
|
|
"variables": full.get("variables", []),
|
|
}
|
|
|
|
|
|
def import_dsl(raw: dict[str, Any], is_agent: bool) -> tuple[str, dict[str, Any]]:
|
|
"""Port of `importDsl`. Always routes through v2 — the single-wire
|
|
contract treats non-graph payloads as empty-canvas seed under one
|
|
canonical shape, matching the TypeScript bridge.
|
|
"""
|
|
return "v2", _build_v2_dsl_from_import(raw, is_agent)
|
|
|
|
|
|
def round_trip(raw: dict[str, Any]) -> dict[str, Any]:
|
|
"""Run the full bridge pipeline:
|
|
importDsl → dslToGraph → graphToDsl → dslToGraph → exportDsl
|
|
and return the final exported payload.
|
|
"""
|
|
mode, imported = import_dsl(raw, is_agent=True)
|
|
nodes, edges = dsl_to_graph(imported)
|
|
redrawn = graph_to_dsl(mode, nodes, edges, imported)
|
|
n2, e2 = dsl_to_graph(redrawn)
|
|
return export_dsl(mode, n2, e2, redrawn)
|
|
|
|
|
|
# ─── Diff classifier ────────────────────────────────────────────────────
|
|
#
|
|
# Walk two payloads depth-first. Mismatches on React-Flow internal
|
|
# fields (`dragging`, `selected`, `measured`, `data.isHovered`) land
|
|
# in `warnings` — the test still passes, but the user is informed.
|
|
# Mismatches on any other field land in `failures` — the test fails
|
|
# with a clear pointer to the offending path.
|
|
|
|
_NODE_INTERNALS = {"dragging", "selected", "measured"}
|
|
_EDGE_INTERNALS = {"isHovered"}
|
|
|
|
|
|
def _is_internal(path: str, key: str) -> bool:
|
|
# The leaf key is the strongest signal: `dragging`/`selected`/
|
|
# `measured` on a node, and `isHovered` on an edge or its
|
|
# nested `data` block, are always React-Flow internals.
|
|
if key in _NODE_INTERNALS:
|
|
return True
|
|
if key in _EDGE_INTERNALS:
|
|
# top-level isHovered on an edge object, or nested under edges[].data
|
|
segments = path.split(".")
|
|
return any(seg.startswith("edges") or seg == "data" for seg in segments)
|
|
# Walk up the parent path: if any ancestor is an internal key
|
|
# (e.g. `measured.width` lives under the `measured` parent),
|
|
# the whole subtree is React-Flow-managed and any leaf mismatch
|
|
# is a transient-state flip, not a real bug.
|
|
if any(seg in _NODE_INTERNALS or seg in _EDGE_INTERNALS for seg in path.split(".")):
|
|
return True
|
|
return False
|
|
|
|
|
|
class Diff:
|
|
"""Result of comparing two dsl-shaped payloads."""
|
|
|
|
def __init__(self) -> None:
|
|
self.warnings: list[str] = []
|
|
self.failures: list[str] = []
|
|
|
|
def add(self, path: str, kind: str, exp: Any, act: Any, key: str) -> None:
|
|
msg = f"{path}: {kind} ({_stable(exp)} vs {_stable(act)})"
|
|
if _is_internal(path, key):
|
|
self.warnings.append(msg)
|
|
else:
|
|
self.failures.append(msg)
|
|
|
|
def assert_stable(self) -> None:
|
|
"""pytest entry point: warn on warnings, fail on failures."""
|
|
for w in self.warnings:
|
|
warnings.warn(f"[React-Flow-internal] {w}", stacklevel=2)
|
|
assert self.failures == [], f"{len(self.failures)} round-trip mismatches:\n" + "\n".join(f" - {f}" for f in self.failures)
|
|
|
|
|
|
def _stable(v: Any) -> str:
|
|
if v is None:
|
|
return "null"
|
|
if isinstance(v, str):
|
|
return json.dumps(v, ensure_ascii=False)
|
|
try:
|
|
return json.dumps(v, ensure_ascii=False, sort_keys=True)
|
|
except (TypeError, ValueError):
|
|
return repr(v)
|
|
|
|
|
|
def diff_dsl(expected: Any, actual: Any, path: str = "") -> Diff:
|
|
out = Diff()
|
|
_compare_into(expected, actual, path, out)
|
|
return out
|
|
|
|
|
|
def _compare_into(expected: Any, actual: Any, path: str, out: Diff) -> None:
|
|
if expected == actual:
|
|
return
|
|
if expected is None or actual is None or type(expected) is not type(actual):
|
|
out.add(path or "<root>", "value", expected, actual, "")
|
|
return
|
|
if not isinstance(expected, (dict, list)):
|
|
out.add(path or "<root>", "value", expected, actual, "")
|
|
return
|
|
|
|
exp_arr = isinstance(expected, list)
|
|
act_arr = isinstance(actual, list)
|
|
if exp_arr != act_arr:
|
|
out.failures.append(f"{path or '<root>'}: array/object mismatch")
|
|
return
|
|
|
|
if exp_arr:
|
|
if len(expected) != len(actual):
|
|
out.failures.append(f"{path or '<root>'}: length {len(expected)} != {len(actual)}")
|
|
for i in range(min(len(expected), len(actual))):
|
|
_compare_into(expected[i], actual[i], f"{path}[{i}]", out)
|
|
return
|
|
|
|
# both dict
|
|
all_keys = set(expected) | set(actual)
|
|
for key in all_keys:
|
|
sub = f"{path}.{key}" if path else key
|
|
if key not in expected:
|
|
out.add(sub, "missing in expected", None, actual[key], key)
|
|
elif key not in actual:
|
|
out.add(sub, "missing in actual", expected[key], None, key)
|
|
elif isinstance(expected[key], dict) and expected[key] is not None:
|
|
_compare_into(expected[key], actual[key], sub, out)
|
|
elif expected[key] != actual[key]:
|
|
out.add(sub, "value", expected[key], actual[key], key)
|
|
|
|
|
|
# ─── Tests ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestDslBridgeRoundTrip:
|
|
"""Unit test of the diff classifier used by round-trip tests."""
|
|
|
|
@pytest.mark.p3
|
|
def test_diff_classifier_routes_correctly(self) -> None:
|
|
"""Direct unit test of the diff classifier — independent of
|
|
the bridge. Verifies the warning/failure split that the
|
|
round-trip tests rely on.
|
|
"""
|
|
expected = {
|
|
"id": "n1",
|
|
"type": "beginNode",
|
|
"position": {"x": 100, "y": 100},
|
|
"dragging": False,
|
|
"selected": False,
|
|
"measured": {"width": 200, "height": 81},
|
|
}
|
|
actual = {
|
|
"id": "n1",
|
|
"type": "beginNode",
|
|
"position": {"x": 999, "y": 100}, # semantic mismatch
|
|
"dragging": True, # RF internal
|
|
"selected": True, # RF internal
|
|
"measured": {"width": 999, "height": 81}, # RF internal
|
|
}
|
|
diff = diff_dsl(expected, actual)
|
|
|
|
# All three RF-internal fields must be in warnings, not failures.
|
|
# We match on substring because the diff walks into nested
|
|
# objects (e.g. `measured` is a `{width, height}` object and
|
|
# its inner fields end up at `measured.width`/`measured.height`).
|
|
warning_paths = [w.split(":", 1)[0] for w in diff.warnings]
|
|
assert any(p == "dragging" for p in warning_paths)
|
|
assert any(p == "selected" for p in warning_paths)
|
|
assert any(p.startswith("measured") for p in warning_paths)
|
|
|
|
# The semantic mismatch must be a failure with a clear
|
|
# pointer. The diff walks into `position: {x, y}` and
|
|
# reports each leaf individually, so only `x` shows up
|
|
# (`y` matches the expected 100).
|
|
assert diff.failures == ["position.x: value (100 vs 999)"]
|