Files
visual_debugger/tests/test_py_debug_graph.py
mynameisdeleted 9556aa08a7 Add unit tests for py_debug_graph module
This commit introduces a comprehensive suite of unit tests for the
py_debug_graph module, which is responsible for building debug graphs
in Python. The tests cover various scenarios including linked lists,
cycles, scalar values, and the handling of local and global variables
in the debugging context. Additionally, it verifies the behavior of
the debug_graph function and ensures that the builtins.root patch
works as intended. These tests aim to enhance the reliability and
robustness of the debugging functionality.
2026-07-09 19:31:14 -04:00

272 lines
10 KiB
Python

"""Unit tests for debuggers/py_debug_graph.py -- the debugpy-side walker.
Unlike gdb_debug_graph.py, this module has no external (gdb) dependency,
so it's called directly with plain Python objects; no fake/mocked debugger
is needed.
"""
import sys
import py_debug_graph as pdg
class Node:
def __init__(self, data, next=None):
self.data = data
self.next = next
class LinkedList:
def __init__(self, head=None):
self._head = head
def _fields(node_json):
return {f["name"]: f for f in node_json["fields"]}
def test_simple_chain_produces_node_shaped_nodes_and_next_edges():
lst = LinkedList(Node(30, Node(20, Node(10, None))))
result = pdg.build_graph_json(lst, {}, None)
assert result["kind"] == {"nodeTable": True}
node_shaped = [n for n in result["nodes"] if set(_fields(n)) == {"data", "next"}]
assert len(node_shaped) == 3
next_edges = [e for e in result["edges"] if e["label"] == "next"]
assert len(next_edges) == 2
head_node = next(n for n in result["nodes"] if n["type"] == "LinkedList")
assert _fields(head_node)["_head"]["isPointer"] is True
def test_none_becomes_a_plain_scalar_field_not_an_edge():
lst = LinkedList(Node(10, None))
result = pdg.build_graph_json(lst, {}, None)
tail = next(n for n in result["nodes"] if _fields(n)["data"]["value"] == "10")
next_field = _fields(tail)["next"]
assert next_field["value"] == "None"
assert next_field["isPointer"] is False
assert next_field["typeHint"] == "NoneType"
def test_cycle_is_handled_without_infinite_recursion():
a = Node(1)
b = Node(2)
a.next = b
b.next = a # cycle
result = pdg.build_graph_json(a, {}, None)
assert len(result["nodes"]) == 2
assert len(result["edges"]) == 2 # a->b, b->a; no infinite unrolling
def test_scalar_root_produces_a_bare_root_node():
result = pdg.build_graph_json(42, {}, None)
assert result["nodes"] == [{"id": "root", "fields": [
{"name": "value", "value": "42", "isPointer": False, "typeHint": "int"},
], "type": "int"}]
assert result["roots"] == []
def test_none_root_produces_no_primary_node():
result = pdg.build_graph_json(None, {}, None)
assert result["nodes"] == []
def test_module_or_function_valued_field_is_treated_as_a_plain_scalar_not_describable():
class HasModuleField:
def __init__(self):
self.mod = sys
self.fn = len
result = pdg.build_graph_json(HasModuleField(), {}, None)
fields = _fields(result["nodes"][0])
assert fields["mod"]["isPointer"] is False
assert fields["fn"]["isPointer"] is False
def test_format_scalar_falls_back_to_error_text_when_str_raises():
class Explodes:
# __slots__ with no __dict__ makes this non-"describable" (vars()
# raises TypeError), so it's walked as a scalar via _format_scalar
# rather than recursed into as a struct.
__slots__ = ()
def __str__(self):
raise ValueError("boom")
class Holder:
def __init__(self):
self.bad = Explodes()
result = pdg.build_graph_json(Holder(), {}, None)
fields = _fields(result["nodes"][0])
assert "<error:" in fields["bad"]["value"]
assert "boom" in fields["bad"]["value"]
def test_watched_roots_are_labeled_and_walked():
node = Node(99)
result = pdg.build_graph_json(None, {"favorite": node}, None)
roots_by_name = {r["name"]: r for r in result["roots"]}
assert roots_by_name["favorite"]["kind"] == "watched"
assert roots_by_name["favorite"]["type"] == "Node"
assert len(result["nodes"]) == 1
def test_watched_root_matching_primary_is_not_duplicated():
node = Node(7)
result = pdg.build_graph_json(node, {"same": node}, None)
# primary root's own address is in skip_ids, so the "same" watched
# root -- pointing at the identical object -- should still be skipped
# entirely per _add_named_root's skip_ids check.
assert result["roots"] == []
# --- frame-based local/global auto-discovery -----------------------------
class _FakeFrame:
def __init__(self, f_locals, f_globals):
self.f_locals = f_locals
self.f_globals = f_globals
def test_auto_discovers_locals_and_skips_dunders_and_private_names():
frame = _FakeFrame(
f_locals={"visible_var": 3, "_private": "skip me", "__dunder__": "skip me too"},
f_globals={"__name__": "not_a_real_module"},
)
result = pdg.build_graph_json(None, {}, frame)
names = {r["name"] for r in result["roots"]}
assert names == {"visible_var"}
def test_auto_discovers_globals_via_module_dict_even_when_frame_merges_scopes(monkeypatch):
import types
fake_module = types.ModuleType("fake_module_for_test")
fake_module.SOME_GLOBAL = 123
monkeypatch.setitem(sys.modules, "fake_module_for_test", fake_module)
# Simulate a debugger merging locals+globals into one dict for eval():
# frame.f_locals and frame.f_globals are literally the same object, so
# naive `is`-based separation would misattribute everything as local.
merged = {"__name__": "fake_module_for_test", "SOME_GLOBAL": 123, "a_local": 1}
frame = _FakeFrame(f_locals=merged, f_globals=merged)
result = pdg.build_graph_json(None, {}, frame)
roots_by_name = {r["name"]: r for r in result["roots"]}
assert roots_by_name["SOME_GLOBAL"]["kind"] == "global"
assert roots_by_name["a_local"]["kind"] == "local"
def test_stdlib_describable_globals_are_filtered_as_noise(monkeypatch):
"""A describable (has __dict__) object whose *class* comes from the
standard library -- e.g. __future__'s _Feature, the real-world case
that motivated this filter -- should be dropped as machinery, not
shown as if it were the debuggee's own data."""
import __future__
import types
fake_module = types.ModuleType("fake_module_for_test2")
fake_module.annotations = __future__.annotations
monkeypatch.setitem(sys.modules, "fake_module_for_test2", fake_module)
frame = _FakeFrame(
f_locals={}, f_globals={"__name__": "fake_module_for_test2", "annotations": __future__.annotations},
)
result = pdg.build_graph_json(None, {}, frame)
assert result["roots"] == []
def test_typevar_globals_are_not_filtered_since_they_have_no_dict(monkeypatch):
"""Known wart, asserted explicitly so a future fix is a deliberate
change: typing.TypeVar uses __slots__ (no __dict__), so
_is_describable() is False for it and the stdlib-noise filter (which
only applies to describable objects) never gets a chance to catch it."""
import types
from typing import TypeVar
fake_module = types.ModuleType("fake_module_for_test2b")
fake_module.T = TypeVar("T")
monkeypatch.setitem(sys.modules, "fake_module_for_test2b", fake_module)
frame = _FakeFrame(f_locals={}, f_globals={"__name__": "fake_module_for_test2b", "T": fake_module.T})
result = pdg.build_graph_json(None, {}, frame)
roots_by_name = {r["name"]: r for r in result["roots"]}
assert roots_by_name["T"]["kind"] == "global"
def test_plain_scalar_globals_are_never_filtered_as_noise(monkeypatch):
"""builtins ("int", "str", ...) is itself in sys.stdlib_module_names --
plain scalar globals must not get caught by the describable-only noise
filter (regression check for the bug where ALL scalars were dropped)."""
import types
fake_module = types.ModuleType("fake_module_for_test3")
fake_module.COUNT = 3
monkeypatch.setitem(sys.modules, "fake_module_for_test3", fake_module)
frame = _FakeFrame(f_locals={}, f_globals={"__name__": "fake_module_for_test3", "COUNT": 3})
result = pdg.build_graph_json(None, {}, frame)
roots_by_name = {r["name"]: r for r in result["roots"]}
assert roots_by_name["COUNT"]["kind"] == "global"
assert roots_by_name["COUNT"]["value"] == "3"
def test_bare_top_auto_discovers_sibling_local_list():
"""The Python analog of $debug_graph(top): a scalar primary root with
no graph of its own still surfaces the whole list via a sibling local
('lst') getting auto-discovered from the frame."""
lst = LinkedList(Node(30, Node(20, Node(10, None))))
frame = _FakeFrame(f_locals={"lst": lst, "top": 30}, f_globals={"__name__": None})
result = pdg.build_graph_json(30, {}, frame)
node_shaped = [n for n in result["nodes"] if set(_fields(n)) == {"data", "next"}]
assert len(node_shaped) == 3
roots_by_name = {r["name"]: r for r in result["roots"]}
assert roots_by_name["lst"]["kind"] == "local"
# --- the `globals` special case -------------------------------------------
def test_bare_globals_builtin_uses_the_passed_in_frame_not_this_modules_own():
frame = _FakeFrame(
f_locals={},
f_globals={"__name__": "irrelevant", "SOME_VALUE": 1, "__hidden__": 2},
)
result = pdg.build_graph_json(globals, {}, frame)
primary_node = next(n for n in result["nodes"] if n["id"] == "globals()")
field_names = {f["name"] for f in primary_node["fields"]}
assert "SOME_VALUE" in field_names
assert "__hidden__" not in field_names # filtered like any other global
assert primary_node["type"] == "dict"
# --- the `debug_graph()` public entry point -------------------------------
def test_debug_graph_returns_a_json_string_and_finds_the_real_caller_frame():
import json
def call_it():
lst = LinkedList(Node(1)) # noqa: F841 -- exercised via debug_graph's own frame walk
return pdg.debug_graph(lst)
raw = call_it()
data = json.loads(raw)
assert data["kind"] == {"nodeTable": True}
assert len(data["nodes"]) == 2 # LinkedList + one Node
# --- the `builtins.root` patch (import-time side effect) ------------------
def test_builtins_root_patch_is_installed_and_defaults_to_none():
import builtins
assert hasattr(builtins, "root")
assert builtins.root is None
def test_builtins_root_patch_does_not_clobber_an_existing_binding(monkeypatch):
import builtins
monkeypatch.setattr(builtins, "root", "already set by someone else", raising=False)
# Re-importing shouldn't matter since Python caches modules, but the
# guard itself (`if not hasattr(...)`) is what we're really checking:
# simulate a fresh check the same way the module's own import-time code did.
if not hasattr(builtins, "root"):
builtins.root = None
assert builtins.root == "already set by someone else"