aboutsummaryrefslogtreecommitdiffstats
path: root/tests/test_graph.py
diff options
context:
space:
mode:
authorblasty <peter@haxx.in>2026-08-21 12:14:46 +0200
committerblasty <peter@haxx.in>2026-08-21 12:15:15 +0200
commit02d02417800184fb76cd0245cdaa94c437aa4081 (patch)
tree7589e6e2e8426bb7370fc56eee52d1559c9d7e57 /tests/test_graph.py
parentadopt ruff: pinned formatter + import sorting, opt-in pre-commit hook (diff)
downloadida-tui-02d02417800184fb76cd0245cdaa94c437aa4081.tar.gz
ida-tui-02d02417800184fb76cd0245cdaa94c437aa4081.tar.xz
ida-tui-02d02417800184fb76cd0245cdaa94c437aa4081.zip
reformat: ruff format + import sort, mechanically (see ruff.toml)
No behavior. Listed in .git-blame-ignore-revs (next commit).
Diffstat (limited to 'tests/test_graph.py')
-rw-r--r--tests/test_graph.py191
1 files changed, 137 insertions, 54 deletions
diff --git a/tests/test_graph.py b/tests/test_graph.py
index a75ff68..e3b9143 100644
--- a/tests/test_graph.py
+++ b/tests/test_graph.py
@@ -9,6 +9,7 @@ suite runs anywhere, but when present it is the interesting half: real functions
are where the degenerate shapes (switch fan-out, irreducible loops, 400-block
monsters) actually live.
"""
+
from __future__ import annotations
#: the layout engine is pure: no IDA, no Textual.
@@ -55,17 +56,29 @@ def mk(edges: dict[int, list[tuple[int, str]]], n: int | None = None) -> list[G.
ids = set(edges) | {d for v in edges.values() for d, _ in v}
if n:
ids |= set(range(n))
- return [G.Block(id=i, start=0x1000 + i * 0x10, end=0x1000 + i * 0x10 + 8,
- succs=list(edges.get(i, []))) for i in sorted(ids)]
+ return [
+ G.Block(
+ id=i,
+ start=0x1000 + i * 0x10,
+ end=0x1000 + i * 0x10 + 8,
+ succs=list(edges.get(i, [])),
+ )
+ for i in sorted(ids)
+ ]
# ------------------------------------------------------------ invariants
+
def no_box_overlap(lay: G.Layout) -> bool:
for i, a in enumerate(lay.nodes):
- for b in lay.nodes[i + 1:]:
- if (a.x <= b.right and b.x <= a.right
- and a.y <= b.y + b.h - 1 and b.y <= a.y + a.h - 1):
+ for b in lay.nodes[i + 1 :]:
+ if (
+ a.x <= b.right
+ and b.x <= a.right
+ and a.y <= b.y + b.h - 1
+ and b.y <= a.y + a.h - 1
+ ):
return False
return True
@@ -97,13 +110,16 @@ def all_edges_drawn(lay: G.Layout) -> bool:
def invariants(lay: G.Layout, name: str) -> None:
check(no_box_overlap(lay), f"{name}: boxes must not overlap")
check(no_edge_through_box(lay) == 0, f"{name}: no edge may cross a box")
- check(all(n.x >= 0 and n.y >= 0 for n in lay.nodes),
- f"{name}: no negative coordinates")
+ check(
+ all(n.x >= 0 and n.y >= 0 for n in lay.nodes),
+ f"{name}: no negative coordinates",
+ )
check(lay.width > 0 and lay.height > 0, f"{name}: canvas has extent")
# ---------------------------------------------------------------- cases
+
def t_linear() -> None:
lay = layout(mk({0: [(1, "uncond")], 1: [(2, "uncond")]}), sizer)
invariants(lay, "linear")
@@ -113,8 +129,10 @@ def t_linear() -> None:
def t_diamond() -> None:
- lay = layout(mk({0: [(1, "jump"), (2, "fall")],
- 1: [(3, "uncond")], 2: [(3, "uncond")]}), sizer)
+ lay = layout(
+ mk({0: [(1, "jump"), (2, "fall")], 1: [(3, "uncond")], 2: [(3, "uncond")]}),
+ sizer,
+ )
invariants(lay, "diamond")
check(lay.by_id[3].rank == 2, "diamond: join sits below both arms")
check(lay.by_id[1].rank == lay.by_id[2].rank, "diamond: arms share a rank")
@@ -125,8 +143,10 @@ def t_diamond() -> None:
def t_selfloop() -> None:
"""A self-loop must not stall the ranking — the bug that collapsed a whole
function into three layers and made the graph 280 columns wide."""
- lay = layout(mk({0: [(1, "uncond")], 1: [(1, "jump"), (2, "fall")],
- 2: [(3, "uncond")]}), sizer)
+ lay = layout(
+ mk({0: [(1, "uncond")], 1: [(1, "jump"), (2, "fall")], 2: [(3, "uncond")]}),
+ sizer,
+ )
invariants(lay, "selfloop")
ranks = [lay.by_id[i].rank for i in (0, 1, 2, 3)]
check(ranks == [0, 1, 2, 3], f"selfloop: ranking still stacks ({ranks})")
@@ -134,23 +154,36 @@ def t_selfloop() -> None:
def t_loop() -> None:
- lay = layout(mk({0: [(1, "uncond")], 1: [(2, "jump"), (3, "fall")],
- 2: [(1, "uncond")]}), sizer)
+ lay = layout(
+ mk({0: [(1, "uncond")], 1: [(2, "jump"), (3, "fall")], 2: [(1, "uncond")]}),
+ sizer,
+ )
invariants(lay, "loop")
check(any(e.back for e in lay.edges), "loop: a back edge is detected")
check(lay.by_id[1].rank < lay.by_id[2].rank, "loop: header above the body")
back = [e for e in lay.edges if e.back][0]
- check((2, G.E_BACK) in [(a, s) for a, s in lay.pred[1]]
- or (1, G.E_BACK) in [(a, s) for a, s in lay.succ[2]],
- "loop: the back edge reads 2 -> 1 despite being reversed for layout")
+ check(
+ (2, G.E_BACK) in [(a, s) for a, s in lay.pred[1]]
+ or (1, G.E_BACK) in [(a, s) for a, s in lay.succ[2]],
+ "loop: the back edge reads 2 -> 1 despite being reversed for layout",
+ )
def t_switch() -> None:
- lay = layout(mk({0: [(i, "switch") for i in range(1, 9)],
- **{i: [(9, "uncond")] for i in range(1, 9)}}), sizer)
+ lay = layout(
+ mk(
+ {
+ 0: [(i, "switch") for i in range(1, 9)],
+ **{i: [(9, "uncond")] for i in range(1, 9)},
+ }
+ ),
+ sizer,
+ )
invariants(lay, "switch")
- check(len({lay.by_id[i].rank for i in range(1, 9)}) == 1,
- "switch: all cases share a rank")
+ check(
+ len({lay.by_id[i].rank for i in range(1, 9)}) == 1,
+ "switch: all cases share a rank",
+ )
check(lay.by_id[9].rank == 2, "switch: the join is below the cases")
@@ -172,14 +205,26 @@ def t_unreachable_entry() -> None:
from, so the engine must never be handed one.
"""
# entry 0 is a sink; 2 and 3 jump INTO it; 1 and 6 self-loop.
- lay = layout(mk({0: [], 1: [(5, "switch"), (1, "fall"), (4, "switch")],
- 2: [(5, "jump"), (0, "uncond")], 3: [(0, "switch")],
- 4: [], 5: [(4, "jump")],
- 6: [(2, "jump"), (6, "switch"), (3, "jump")]}), entry=0)
+ lay = layout(
+ mk(
+ {
+ 0: [],
+ 1: [(5, "switch"), (1, "fall"), (4, "switch")],
+ 2: [(5, "jump"), (0, "uncond")],
+ 3: [(0, "switch")],
+ 4: [],
+ 5: [(4, "jump")],
+ 6: [(2, "jump"), (6, "switch"), (3, "jump")],
+ }
+ ),
+ entry=0,
+ )
invariants(lay, "unreachable_entry")
check(len(lay.nodes) == 7, "unreachable_entry: every block is placed")
- check(lay.stats.get("engine_error") is None,
- f"unreachable_entry: no fallback ({lay.stats.get('engine_error')})")
+ check(
+ lay.stats.get("engine_error") is None,
+ f"unreachable_entry: no fallback ({lay.stats.get('engine_error')})",
+ )
# An entry that reaches nothing at all, with everything hanging off nodes
# it cannot see, is the degenerate version of the same thing.
@@ -198,8 +243,10 @@ def t_long_edge() -> None:
# long edge. Triskel reaches the same end -- an edge that crosses no box,
# checked by invariants() above -- without them, so this is engine-specific.
if ENGINE == "native":
- check(lay.stats["dummies"] >= 4,
- f"long_edge: the skip edge is padded ({lay.stats['dummies']} dummies)")
+ check(
+ lay.stats["dummies"] >= 4,
+ f"long_edge: the skip edge is padded ({lay.stats['dummies']} dummies)",
+ )
check(all_edges_drawn(lay), "long_edge: the long edge is drawn")
@@ -212,14 +259,18 @@ def t_empty() -> None:
def t_row_query() -> None:
"""cells_at_row must be windowed: asking for a slice returns only that
slice, which is what keeps a 13M-cell graph renderable."""
- lay = layout(mk({0: [(1, "jump"), (2, "fall")],
- 1: [(3, "uncond")], 2: [(3, "uncond")]}), sizer)
+ lay = layout(
+ mk({0: [(1, "jump"), (2, "fall")], 1: [(3, "uncond")], 2: [(3, "uncond")]}),
+ sizer,
+ )
for row in range(lay.height):
full = lay.painting.cells_at_row(row, 0, lay.width)
part = lay.painting.cells_at_row(row, 5, 12)
check(all(5 <= c < 12 for c in part), f"row {row}: window respected")
- check(all(full.get(c) == v for c, v in part.items()),
- f"row {row}: window agrees with the full row")
+ check(
+ all(full.get(c) == v for c, v in part.items()),
+ f"row {row}: window agrees with the full row",
+ )
def t_hit_test() -> None:
@@ -233,6 +284,7 @@ def t_hit_test() -> None:
# ---------------------------------------------------------------- corpus
+
def t_corpus(path: str) -> None:
recs = json.load(open(path))
print(f"\ncorpus: {len(recs)} functions from {path}")
@@ -243,9 +295,15 @@ def t_corpus(path: str) -> None:
worst_any_name = ""
t0 = time.perf_counter()
for rec in recs:
- blocks = [G.Block(id=b["id"], start=b["start"], end=b["end"],
- succs=[(d, k) for d, k in b["succs"]])
- for b in rec["blocks"]]
+ blocks = [
+ G.Block(
+ id=b["id"],
+ start=b["start"],
+ end=b["end"],
+ succs=[(d, k) for d, k in b["succs"]],
+ )
+ for b in rec["blocks"]
+ ]
lay = layout(blocks, sizer)
# A SILENT fallback is the failure mode that matters here: the engine
# under test quietly stops being the engine under test, and every
@@ -258,46 +316,61 @@ def t_corpus(path: str) -> None:
# not that it never happens.
if ENGINE != "auto" and lay.stats["engine"] != ENGINE:
fellback.append((rec["name"], lay.stats.get("engine_error")))
- check(bool(lay.stats.get("engine_error")),
- f"corpus {rec['name']}: a fallback must record its reason")
+ check(
+ bool(lay.stats.get("engine_error")),
+ f"corpus {rec['name']}: a fallback must record its reason",
+ )
# Time the engine only on the functions it would actually be ASKED for.
# `auto` hands anything over AUTO_TRISKEL_MAX_BLOCKS to native, and the
# view refuses to draw past 400 blocks at all, so a forced triskel run
# on a 495-block monster times a call the app cannot make.
- reachable = (ENGINE != "triskel"
- or len(blocks) <= G.AUTO_TRISKEL_MAX_BLOCKS)
+ reachable = ENGINE != "triskel" or len(blocks) <= G.AUTO_TRISKEL_MAX_BLOCKS
if reachable and lay.stats["ms"] > worst_ms:
worst_ms, worst_name = lay.stats["ms"], rec["name"]
if lay.stats["ms"] > worst_any_ms:
worst_any_ms, worst_any_name = lay.stats["ms"], rec["name"]
check(no_box_overlap(lay), f"corpus {rec['name']}: boxes must not overlap")
- check(len(lay.nodes) == len(blocks),
- f"corpus {rec['name']}: every block is placed")
+ check(
+ len(lay.nodes) == len(blocks),
+ f"corpus {rec['name']}: every block is placed",
+ )
# The full cell sweep is O(canvas); only affordable on the small ones,
# but that is where a routing bug would show up anyway.
if lay.width * lay.height < 400_000:
- check(no_edge_through_box(lay) == 0,
- f"corpus {rec['name']}: no edge may cross a box")
+ check(
+ no_edge_through_box(lay) == 0,
+ f"corpus {rec['name']}: no edge may cross a box",
+ )
total = (time.perf_counter() - t0) * 1000
- print(f" laid out {len(recs)} functions in {total:.0f} ms "
- f"(worst {worst_ms:.0f} ms: {worst_name})")
+ print(
+ f" laid out {len(recs)} functions in {total:.0f} ms "
+ f"(worst {worst_ms:.0f} ms: {worst_name})"
+ )
if fellback:
print(f" {len(fellback)} fell back to native:")
for name, why in fellback:
print(f" {name}: {why}")
- check(len(fellback) <= max(2, len(recs) // 20),
- f"corpus: {ENGINE} fell back on {len(fellback)}/{len(recs)} functions")
- check(worst_ms < 2000, f"corpus: worst REACHABLE layout under 2s "
- f"({worst_ms:.0f} ms: {worst_name})")
+ check(
+ len(fellback) <= max(2, len(recs) // 20),
+ f"corpus: {ENGINE} fell back on {len(fellback)}/{len(recs)} functions",
+ )
+ check(
+ worst_ms < 2000,
+ f"corpus: worst REACHABLE layout under 2s ({worst_ms:.0f} ms: {worst_name})",
+ )
# Nothing may blow up quadratically even when forced past its own limits.
- check(worst_any_ms < 5000, f"corpus: worst layout at any size under 5s "
- f"({worst_any_ms:.0f} ms: {worst_any_name})")
+ check(
+ worst_any_ms < 5000,
+ f"corpus: worst layout at any size under 5s "
+ f"({worst_any_ms:.0f} ms: {worst_any_name})",
+ )
def main() -> int:
global ENGINE
print("idatui.graph layout tests")
from idatui import graph_triskel
+
engines = ["native"]
if graph_triskel.available():
engines.append("triskel")
@@ -306,9 +379,19 @@ def main() -> int:
for engine in engines:
ENGINE = engine
print(f"\nengine: {engine}")
- for fn in (t_linear, t_diamond, t_selfloop, t_loop, t_switch,
- t_unreachable, t_unreachable_entry, t_long_edge, t_empty,
- t_row_query, t_hit_test):
+ for fn in (
+ t_linear,
+ t_diamond,
+ t_selfloop,
+ t_loop,
+ t_switch,
+ t_unreachable,
+ t_unreachable_entry,
+ t_long_edge,
+ t_empty,
+ t_row_query,
+ t_hit_test,
+ ):
print(f" {fn.__name__}")
fn()
for path in sys.argv[1:]: