gns3-server/tests/agent/mcp/test_locators.py
YueGuobin 7d04d08dba
fix: repair imports lost in the 3.1 rebase and meet the new lint baseline
The rebase onto upstream 3.1 (mypy + ruff I001/F401 waves) left the tree
short of the newly enforced baselines in three ways:

* contextlib / UbridgeError imports dropped as false duplicates during
  conflict resolution left NameErrors in iou_vm and qemu_vm (F821);
  the Dynamips router grabbed the compute-level NIOTAP instead of the
  hypervisor one, breaking TAP anchoring (test failure).
* unused imports (KERNEL_UNSUPPORTED_FILTERS, UbridgeError, List) and
  unsorted blocks left behind where the stack's later commits moved the
  last usage elsewhere — ruff I001/F401/RUF022 now enforce these.
* mypy, now baseline-free: cache dict annotations in tc_probe, the
  locators candidate list, the resolved-params None branch in the MCP
  runner, and validate_bpf_syntax keeps the upstream Dict[str, Any]
  return (the strict Optional[str] was wrong for the "valid" bool entry).

The e2e raw-frame probe resolves its compiler via shutil.which (S607)
and zips with strict= (B905); the remaining subprocess call is S603 by
nature and gets a per-file ignore like other test files.

ruff check/format clean, mypy at upstream parity (no new errors),
full unit suite 2167 passed.
2026-10-06 00:21:11 +08:00

173 lines
6.7 KiB
Python

#
# Copyright (C) 2026 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
Name locators: name->UUID resolution and the UUID-typo "Did you mean" hint.
"""
from gns3server.agent.mcp import locators
from gns3server.agent.mcp.locators import add_did_you_mean, looks_like_uuid, resolve_locators
PROJECT_ID = "d1ab2349-2e53-4e41-a9ae-b5ffcd912408"
NODE_ID = "b78a2630-2c9e-4b43-9397-c855c8053d1a"
NODE_ID_TYPO = "b78a2630-3b9e-4b43-9397-c855c8053d1a" # 2c9e -> 3b9e (distance 2)
def _run_factory(projects=None, nodes=None):
"""A run() callback answering the two list handlers from canned data."""
calls = []
def run(handler, params):
calls.append((handler, dict(params)))
if handler is locators.list_projects_handler:
if isinstance(projects, Exception):
raise projects
return {"projects": projects if projects is not None else []}
if handler is locators.get_nodes_handler:
if isinstance(nodes, Exception):
raise nodes
return nodes if nodes is not None else []
raise AssertionError(f"unexpected handler {handler}")
return run, calls
def test_looks_like_uuid():
assert looks_like_uuid(PROJECT_ID) is True
assert looks_like_uuid(PROJECT_ID.upper()) is True
assert looks_like_uuid("FRRDocker-4") is False
assert looks_like_uuid("") is False
assert looks_like_uuid(None) is False
assert looks_like_uuid(1234) is False
def test_uuid_params_pass_through_without_calls():
"""UUID-shaped values never trigger resolution lookups."""
run, calls = _run_factory(projects=[{"project_id": PROJECT_ID, "name": "lab"}])
params, error = resolve_locators({"project_id": PROJECT_ID, "node_id": NODE_ID}, run)
assert error is None
assert params == {"project_id": PROJECT_ID, "node_id": NODE_ID}
assert calls == []
def test_project_name_resolved():
"""A name-shaped project_id becomes the project's UUID."""
run, calls = _run_factory(
projects=[
{"project_id": "11111111-1111-1111-1111-111111111111", "name": "other"},
{"project_id": PROJECT_ID, "name": "my-lab"},
]
)
params, error = resolve_locators({"project_id": "my-lab"}, run)
assert error is None
assert params["project_id"] == PROJECT_ID
assert len(calls) == 1 # only the project list
def test_project_name_not_found_lists_known_names():
run, _calls = _run_factory(projects=[{"project_id": PROJECT_ID, "name": "my-lab"}])
params, error = resolve_locators({"project_id": "no-such-lab"}, run)
assert params is None
assert "not found" in error["error"]
assert "my-lab" in error["error"]
def test_node_name_resolved_within_project():
run, calls = _run_factory(
projects=[{"project_id": PROJECT_ID, "name": "my-lab"}], nodes=[{"node_id": NODE_ID, "name": "FRRDocker-4"}]
)
params, error = resolve_locators({"project_id": "my-lab", "node_id": "FRRDocker-4"}, run)
assert error is None
assert params["project_id"] == PROJECT_ID
assert params["node_id"] == NODE_ID
# node lookup went to the already-resolved project
assert calls[1] == (locators.get_nodes_handler, {"project_id": PROJECT_ID})
def test_node_name_case_insensitive_unique_match():
run, _calls = _run_factory(nodes=[{"node_id": NODE_ID, "name": "FRRDocker-4"}])
params, error = resolve_locators({"project_id": PROJECT_ID, "node_id": "frrdocker-4"}, run)
assert error is None
assert params["node_id"] == NODE_ID
def test_node_name_not_found_lists_known_nodes():
run, _calls = _run_factory(nodes=[{"node_id": NODE_ID, "name": "FRRDocker-4"}])
params, error = resolve_locators({"project_id": PROJECT_ID, "node_id": "vpcs-1"}, run)
assert params is None
assert "FRRDocker-4" in error["error"]
def test_handler_failure_during_resolution_is_reported():
run, _calls = _run_factory(nodes={"error": "boom"})
params, error = resolve_locators({"project_id": PROJECT_ID, "node_id": "FRRDocker-4"}, run)
assert params is None
assert "Could not resolve node" in error["error"]
def test_did_you_mean_appended_for_transcription_slip():
"""The 2c9e -> 3b9e class of typo earns exactly one hint."""
run, _calls = _run_factory(
projects=[{"project_id": PROJECT_ID, "name": "my-lab"}], nodes=[{"node_id": NODE_ID, "name": "FRRDocker-4"}]
)
result = add_did_you_mean({"error": f"Node ID {NODE_ID_TYPO} doesn't exist"}, {"project_id": PROJECT_ID}, run)
assert f"Did you mean node 'FRRDocker-4' ({NODE_ID})" in result["error"]
# original message preserved
assert NODE_ID_TYPO in result["error"]
def test_no_hint_when_too_far_or_ambiguous():
far = "b78a2630-9999-4b43-9397-c855c8053d1a" # distance 3
run, _calls = _run_factory(nodes=[{"node_id": NODE_ID, "name": "n1"}])
result = add_did_you_mean({"error": f"Node ID {far} doesn't exist"}, {"project_id": PROJECT_ID}, run)
assert "Did you mean" not in result["error"]
two = [NODE_ID, "b78a2630-2b9e-4b43-9397-c855c8053d1a"] # both within distance 2 of the typo
run2, _calls2 = _run_factory(nodes=[{"node_id": i, "name": f"n{i}"} for i in two])
result2 = add_did_you_mean({"error": f"Node ID {NODE_ID_TYPO} doesn't exist"}, {"project_id": PROJECT_ID}, run2)
assert "Did you mean" not in result2["error"]
def test_non_error_results_untouched():
run, _calls = _run_factory(nodes=[{"node_id": NODE_ID, "name": "n1"}])
payload = [{"node_id": NODE_ID}]
assert add_did_you_mean(payload, {"project_id": PROJECT_ID}, run) is payload
assert add_did_you_mean({"filters": {}}, {"project_id": PROJECT_ID}, run) == {"filters": {}}
# errors without an id carry nothing to hint against
result = add_did_you_mean({"error": "Node not found: no id here"}, {"project_id": PROJECT_ID}, run)
assert result == {"error": "Node not found: no id here"}
def test_edit_distance_at_most():
assert locators._edit_distance_at_most("abcd", "abcd", 2)
assert locators._edit_distance_at_most("abcd", "abce", 2)
assert locators._edit_distance_at_most("2c9e", "3b9e", 2)
assert not locators._edit_distance_at_most("abcd", "dcba", 2)
assert not locators._edit_distance_at_most("abc", "abcdef", 2)