test_walk_dag_async.py
python
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595
fix: typing audit — 0 violations, 0 untyped defs across all…
Sonnet 4.6
minor
⚠ breaking
23 days ago
| 1 | """TDD — Phase 1 of issue #40: walk_dag_async utility. |
| 2 | |
| 3 | W1 Structural — walk_dag_async is an async generator |
| 4 | W2 Behavioural — BFS order: linear chain A→B→C yields A, B, C |
| 5 | W3 Behavioural — exclude set prevents visiting excluded nodes and subtrees |
| 6 | W4 Behavioural — max_nodes cap stops the walk after N nodes |
| 7 | W5 Behavioural — DFS order: linear chain yields depth-first (same for linear, |
| 8 | but diamond graph distinguishes BFS from DFS) |
| 9 | W6 Behavioural — multi-root start seeds from all roots simultaneously |
| 10 | W7 Behavioural — cycles handled: each node yielded at most once |
| 11 | """ |
| 12 | from __future__ import annotations |
| 13 | |
| 14 | import inspect |
| 15 | import pytest |
| 16 | |
| 17 | AdjGraph = dict[str, list[str]] |
| 18 | |
| 19 | # --------------------------------------------------------------------------- |
| 20 | # Helpers |
| 21 | # --------------------------------------------------------------------------- |
| 22 | |
| 23 | async def _adj(graph: AdjGraph) -> None: |
| 24 | """Return an async adjacency function over an in-memory dict.""" |
| 25 | async def adjacency(node: str) -> list[str]: |
| 26 | return graph.get(node, []) |
| 27 | return adjacency |
| 28 | |
| 29 | |
| 30 | # --------------------------------------------------------------------------- |
| 31 | # W1 Structural — walk_dag_async is an async generator |
| 32 | # --------------------------------------------------------------------------- |
| 33 | |
| 34 | def test_w1_walk_dag_async_is_async_generator() -> None: |
| 35 | """walk_dag_async must return an async generator (async iterator).""" |
| 36 | from musehub.graph.walk import walk_dag_async |
| 37 | |
| 38 | graph = {"A": ["B"], "B": []} |
| 39 | result = walk_dag_async("A", lambda n: _noop_adj(n, graph)) |
| 40 | |
| 41 | assert hasattr(result, "__aiter__"), ( |
| 42 | "walk_dag_async must return an object with __aiter__ (async generator)" |
| 43 | ) |
| 44 | assert hasattr(result, "__anext__"), ( |
| 45 | "walk_dag_async must return an object with __anext__ (async generator)" |
| 46 | ) |
| 47 | |
| 48 | |
| 49 | async def _noop_adj(node: str, graph: AdjGraph) -> list[str]: |
| 50 | return graph.get(node, []) |
| 51 | |
| 52 | |
| 53 | # --------------------------------------------------------------------------- |
| 54 | # W2 Behavioural — BFS order: linear chain |
| 55 | # --------------------------------------------------------------------------- |
| 56 | |
| 57 | @pytest.mark.asyncio |
| 58 | async def test_w2_bfs_linear_chain() -> None: |
| 59 | """BFS on A→B→C yields nodes in breadth-first order: A, B, C.""" |
| 60 | from musehub.graph.walk import walk_dag_async |
| 61 | |
| 62 | graph = {"A": ["B"], "B": ["C"], "C": []} |
| 63 | |
| 64 | async def adj(node: str) -> list[str]: |
| 65 | return graph.get(node, []) |
| 66 | |
| 67 | result = [n async for n in walk_dag_async("A", adj)] |
| 68 | assert result == ["A", "B", "C"], f"Expected BFS order A→B→C, got {result}" |
| 69 | |
| 70 | |
| 71 | # --------------------------------------------------------------------------- |
| 72 | # W3 Behavioural — exclude set |
| 73 | # --------------------------------------------------------------------------- |
| 74 | |
| 75 | @pytest.mark.asyncio |
| 76 | async def test_w3_exclude_set() -> None: |
| 77 | """Nodes in exclude are not visited and their subtrees are not explored. |
| 78 | |
| 79 | Graph: A→B→C, A→D. exclude={B}. |
| 80 | Expected: A, D (B and C skipped). |
| 81 | """ |
| 82 | from musehub.graph.walk import walk_dag_async |
| 83 | |
| 84 | graph = {"A": ["B", "D"], "B": ["C"], "C": [], "D": []} |
| 85 | |
| 86 | async def adj(node: str) -> list[str]: |
| 87 | return graph.get(node, []) |
| 88 | |
| 89 | result = [n async for n in walk_dag_async("A", adj, exclude={"B"})] |
| 90 | assert "B" not in result, "B must not appear — it is in exclude" |
| 91 | assert "C" not in result, "C must not appear — its only path goes through excluded B" |
| 92 | assert "A" in result |
| 93 | assert "D" in result |
| 94 | |
| 95 | |
| 96 | # --------------------------------------------------------------------------- |
| 97 | # W4 Behavioural — max_nodes cap |
| 98 | # --------------------------------------------------------------------------- |
| 99 | |
| 100 | @pytest.mark.asyncio |
| 101 | async def test_w4_max_nodes_cap() -> None: |
| 102 | """Walk stops after max_nodes nodes have been yielded.""" |
| 103 | from musehub.graph.walk import walk_dag_async |
| 104 | |
| 105 | # Linear chain of 10 nodes |
| 106 | graph = {str(i): [str(i + 1)] for i in range(9)} |
| 107 | graph["9"] = [] |
| 108 | |
| 109 | async def adj(node: str) -> list[str]: |
| 110 | return graph.get(node, []) |
| 111 | |
| 112 | result = [n async for n in walk_dag_async("0", adj, max_nodes=3)] |
| 113 | assert len(result) == 3, f"Expected exactly 3 nodes, got {len(result)}" |
| 114 | |
| 115 | |
| 116 | # --------------------------------------------------------------------------- |
| 117 | # W5 Behavioural — DFS order (diamond graph distinguishes from BFS) |
| 118 | # --------------------------------------------------------------------------- |
| 119 | |
| 120 | @pytest.mark.asyncio |
| 121 | async def test_w5_dfs_order() -> None: |
| 122 | """DFS on a diamond graph explores depth-first. |
| 123 | |
| 124 | Diamond: A → B, A → C; B → D; C → D. |
| 125 | BFS order: A B C D (level-by-level) |
| 126 | DFS order: A B D C (or A C D B — depends on stack direction) |
| 127 | |
| 128 | We assert the DFS result differs from BFS and D appears before C (or B). |
| 129 | """ |
| 130 | from musehub.graph.walk import walk_dag_async |
| 131 | |
| 132 | graph = {"A": ["B", "C"], "B": ["D"], "C": ["D"], "D": []} |
| 133 | |
| 134 | async def adj(node: str) -> list[str]: |
| 135 | return graph.get(node, []) |
| 136 | |
| 137 | bfs_result = [n async for n in walk_dag_async("A", adj, order="bfs")] |
| 138 | dfs_result = [n async for n in walk_dag_async("A", adj, order="dfs")] |
| 139 | |
| 140 | assert set(bfs_result) == {"A", "B", "C", "D"}, "BFS must visit all nodes" |
| 141 | assert set(dfs_result) == {"A", "B", "C", "D"}, "DFS must visit all nodes" |
| 142 | assert bfs_result != dfs_result, ( |
| 143 | "DFS and BFS must produce different orderings on a diamond graph" |
| 144 | ) |
| 145 | # BFS: A B C D (level-by-level). |
| 146 | # DFS with deque.pop() (LIFO): neighbours are appended in order [B, C], |
| 147 | # so C is popped first → order is A C D B. D must appear before B in DFS. |
| 148 | assert dfs_result.index("D") < dfs_result.index("B"), ( |
| 149 | f"In DFS, D should be reached before B; got {dfs_result}" |
| 150 | ) |
| 151 | |
| 152 | |
| 153 | # --------------------------------------------------------------------------- |
| 154 | # W6 Behavioural — multi-root |
| 155 | # --------------------------------------------------------------------------- |
| 156 | |
| 157 | @pytest.mark.asyncio |
| 158 | async def test_w6_multi_root() -> None: |
| 159 | """Multi-root seeds the BFS from all start nodes simultaneously. |
| 160 | |
| 161 | Two disjoint chains: A→B and X→Y. |
| 162 | Starting from [A, X], all four nodes must be yielded. |
| 163 | """ |
| 164 | from musehub.graph.walk import walk_dag_async |
| 165 | |
| 166 | graph = {"A": ["B"], "B": [], "X": ["Y"], "Y": []} |
| 167 | |
| 168 | async def adj(node: str) -> list[str]: |
| 169 | return graph.get(node, []) |
| 170 | |
| 171 | result = [n async for n in walk_dag_async(["A", "X"], adj)] |
| 172 | assert set(result) == {"A", "B", "X", "Y"}, ( |
| 173 | f"Multi-root must reach all nodes; got {result}" |
| 174 | ) |
| 175 | |
| 176 | |
| 177 | # --------------------------------------------------------------------------- |
| 178 | # W7 Behavioural — cycle safety |
| 179 | # --------------------------------------------------------------------------- |
| 180 | |
| 181 | @pytest.mark.asyncio |
| 182 | async def test_w7_cycle_safety() -> None: |
| 183 | """Each node is yielded at most once even when the graph has cycles. |
| 184 | |
| 185 | Cycle: A→B→C→A. Walk must terminate and yield each node once. |
| 186 | """ |
| 187 | from musehub.graph.walk import walk_dag_async |
| 188 | |
| 189 | graph = {"A": ["B"], "B": ["C"], "C": ["A"]} |
| 190 | |
| 191 | async def adj(node: str) -> list[str]: |
| 192 | return graph.get(node, []) |
| 193 | |
| 194 | result = [n async for n in walk_dag_async("A", adj)] |
| 195 | assert sorted(result) == ["A", "B", "C"], ( |
| 196 | f"Each node must appear exactly once; got {result}" |
| 197 | ) |
| 198 | assert len(result) == len(set(result)), "No node may be yielded twice" |
File History
1 commit
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595
fix: typing audit — 0 violations, 0 untyped defs across all…
Sonnet 4.6
minor
⚠
23 days ago