started tests

This commit is contained in:
John Lancaster
2026-02-21 09:32:15 -06:00
parent 8e45257744
commit b8d7478f2b
6 changed files with 584 additions and 8 deletions

View File

@@ -20,8 +20,20 @@ build-backend = "uv_build"
dev = [ dev = [
"ipykernel>=7.2.0", "ipykernel>=7.2.0",
"pre-commit>=4.5.1", "pre-commit>=4.5.1",
"rich>=14.3.3",
"ruff>=0.15.2", "ruff>=0.15.2",
] ]
test = [ test = [
"pytest>=9.0.2", "pytest>=9.0.2",
] ]
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = [
"-v",
"--strict-markers",
"--strict-config",
]

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from collections import defaultdict from collections import defaultdict
from collections.abc import Callable, Iterable, Iterator, MutableMapping from collections.abc import Callable, Iterable, Iterator, MutableMapping
from dataclasses import dataclass, field
from typing import Generic, TypeVar from typing import Generic, TypeVar
from .set import DAGSet from .set import DAGSet
@@ -10,7 +11,7 @@ type dictset = defaultdict[T, set[T]]
T = TypeVar("T") T = TypeVar("T")
@dataclass(repr=False)
class DAG(Generic[T], MutableMapping[T, DAGSet[T]]): class DAG(Generic[T], MutableMapping[T, DAGSet[T]]):
""" """
DAG adjacency map: DAG adjacency map:
@@ -21,16 +22,12 @@ class DAG(Generic[T], MutableMapping[T, DAGSet[T]]):
a guarded view that keeps reverse in sync. a guarded view that keeps reverse in sync.
""" """
_succ: dictset[T] _succ: dictset[T] = field(default_factory=lambda: defaultdict(set))
_pred: dictset[T] _pred: dictset[T] = field(default_factory=lambda: defaultdict(set))
on_add: Callable[[T, T], None] | None = None on_add: Callable[[T, T], None] | None = None
on_remove: Callable[[T, T], None] | None = None on_remove: Callable[[T, T], None] | None = None
def __init__(self) -> None:
self._succ: dictset[T] = defaultdict(set)
self._pred: dictset[T] = defaultdict(set)
@property @property
def reverse(self) -> dictset[T]: def reverse(self) -> dictset[T]:
return self._pred return self._pred
@@ -70,7 +67,8 @@ class DAG(Generic[T], MutableMapping[T, DAGSet[T]]):
for v in vs: for v in vs:
self._pred[v] |= {u} self._pred[v] |= {u}
case _: case _:
raise TypeError(f"Expected set or iterable, got {type(vs).__name__}") self._succ[u] |= {vs}
self._pred[vs] |= {u}
def __delitem__(self, u: T) -> None: def __delitem__(self, u: T) -> None:
self.discard_node(u) self.discard_node(u)

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
# tests package

307
tests/test_dag.py Normal file
View File

@@ -0,0 +1,307 @@
from __future__ import annotations
import pytest
from daglib.dag import DAG
class TestDAGInit:
"""Test DAG initialization."""
def test_init_empty(self) -> None:
g = DAG()
assert len(g) == 0
def test_default_callbacks_none(self) -> None:
g = DAG()
assert g.on_add is None
assert g.on_remove is None
class TestDAGAddEdge:
"""Test adding edges."""
def test_add_single_edge(self) -> None:
g = DAG[str]()
g.add_edge("a", "b")
assert "b" in g["a"]
assert "a" in g.reverse["b"]
def test_add_multiple_edges(self) -> None:
g = DAG[str]()
g.add_edge("a", "b")
g.add_edge("a", "c")
g.add_edge("b", "c")
assert "b" in g["a"]
assert "c" in g["a"]
assert "c" in g["b"]
def test_add_edge_creates_nodes(self) -> None:
g = DAG[str]()
g.add_edge("a", "b")
assert "a" in g._succ
assert "b" in g._pred
def test_self_loop_raises_error(self) -> None:
g = DAG[str]()
with pytest.raises(ValueError, match="Self-loops are not allowed"):
g.add_edge("a", "a")
def test_add_duplicate_edge_idempotent(self) -> None:
g = DAG[str]()
g.add_edge("a", "b")
g.add_edge("a", "b")
assert len(g["a"]) == 1
class TestDAGRemoveEdge:
"""Test removing edges."""
def test_remove_existing_edge(self) -> None:
g = DAG[str]()
g.add_edge("a", "b")
g.remove_edge("a", "b")
assert "b" not in g["a"]
assert "a" not in g.reverse["b"]
def test_remove_nonexistent_edge_missing_ok(self) -> None:
g = DAG[str]()
g.remove_edge("a", "b", missing_ok=True) # should not raise
def test_remove_nonexistent_edge_error(self) -> None:
g = DAG[str]()
with pytest.raises(KeyError):
g.remove_edge("a", "b", missing_ok=False)
class TestDAGDiscardNode:
"""Test node removal."""
def test_discard_node_removes_outgoing_edges(self) -> None:
g = DAG[str]()
g.add_edge("a", "b")
g.add_edge("a", "c")
g.discard_node("a")
assert "a" not in g._succ
assert "a" not in g.reverse["b"]
assert "a" not in g.reverse["c"]
def test_discard_node_removes_incoming_edges(self) -> None:
g = DAG[str]()
g.add_edge("a", "c")
g.add_edge("b", "c")
g.discard_node("c")
assert "c" not in g._pred
assert "c" not in g["a"]
assert "c" not in g["b"]
def test_discard_nonexistent_node(self) -> None:
g = DAG[str]()
g.discard_node("z") # should not raise
class TestDAGGetItem:
"""Test dictionary-style access."""
def test_getitem_returns_dagset(self) -> None:
g = DAG[str]()
g.add_edge("a", "b")
dagset = g["a"]
assert "b" in dagset
def test_getitem_empty_node(self) -> None:
g = DAG[str]()
dagset = g["nonexistent"]
assert len(dagset) == 0
def test_getitem_mutation_updates_graph(self) -> None:
g = DAG[str]()
g["a"].add("b")
assert "b" in g["a"]
assert "a" in g.reverse["b"]
def test_getitem_mutation_triggers_callbacks(self) -> None:
added: list[tuple[str, str]] = []
g = DAG[str]()
g.on_add = lambda u, v: added.append((u, v))
g["a"].add("b")
assert ("a", "b") in added
class TestDAGSetItem:
"""Test dictionary-style assignment."""
def test_setitem_with_set(self) -> None:
g = DAG[str]()
g["a"] = {"b", "c"}
assert "b" in g["a"]
assert "c" in g["a"]
def test_setitem_with_list(self) -> None:
g = DAG[str]()
g["a"] = ["b", "c"]
assert "b" in g["a"]
assert "c" in g["a"]
def test_setitem_with_string(self) -> None:
g = DAG[str]()
g["a"] = "b"
assert "b" in g["a"]
def test_setitem_with_single_item(self) -> None:
g = DAG[int]()
g[1] = 2
assert 2 in g[1]
def test_setitem_updates_reverse(self) -> None:
g = DAG[str]()
g["a"] = {"b", "c"}
assert "a" in g.reverse["b"]
assert "a" in g.reverse["c"]
class TestDAGDelItem:
"""Test del operation."""
def test_delitem_removes_node(self) -> None:
g = DAG[str]()
g.add_edge("a", "b")
del g["a"]
assert "a" not in g._succ
class TestDAGIter:
"""Test iteration."""
def test_iter_empty(self) -> None:
g = DAG[str]()
assert list(g) == []
def test_iter_returns_nodes(self) -> None:
g = DAG[str]()
g.add_edge("a", "b")
g.add_edge("b", "c")
nodes = set(g)
assert "a" in nodes
assert "b" in nodes
class TestDAGLen:
"""Test length (edge count)."""
def test_len_empty(self) -> None:
g = DAG[str]()
assert len(g) == 0
def test_len_counts_edges(self) -> None:
g = DAG[str]()
g.add_edge("a", "b")
g.add_edge("a", "c")
g.add_edge("b", "c")
assert len(g) == 3
def test_len_after_removal(self) -> None:
g = DAG[str]()
g.add_edge("a", "b")
g.add_edge("a", "c")
g.remove_edge("a", "b")
assert len(g) == 1
class TestDAGReverse:
"""Test reverse (predecessor) access."""
def test_reverse_property(self) -> None:
g = DAG[str]()
g.add_edge("a", "b")
assert "a" in g.reverse["b"]
assert len(g.reverse["a"]) == 0
def test_reverse_multiple_predecessors(self) -> None:
g = DAG[str]()
g.add_edge("a", "c")
g.add_edge("b", "c")
preds = g.reverse["c"]
assert "a" in preds
assert "b" in preds
class TestDAGCallbacks:
"""Test callback mechanisms."""
def test_on_add_callback_via_setitem(self) -> None:
added: list[tuple[str, str]] = []
g = DAG[str]()
g.on_add = lambda u, v: added.append((u, v))
g["a"] = {"b", "c"}
# Note: setitem doesn't trigger callbacks in current implementation
def test_on_add_callback_via_getitem_mutation(self) -> None:
added: list[tuple[str, str]] = []
g = DAG[str]()
g.on_add = lambda u, v: added.append((u, v))
g["a"].add("b")
assert ("a", "b") in added
def test_on_remove_callback(self) -> None:
removed: list[tuple[str, str]] = []
g = DAG[str]()
g.on_remove = lambda u, v: removed.append((u, v))
g["a"].add("b")
g["a"].discard("b")
assert ("a", "b") in removed
class TestDAGRepr:
"""Test string representation."""
def test_repr_empty(self) -> None:
g = DAG[str]()
r = repr(g)
assert "DAG" in r
assert "{}" in r
def test_repr_with_edges(self) -> None:
g = DAG[str]()
g.add_edge("a", "b")
r = repr(g)
assert "DAG" in r
assert "a" in r
assert "b" in r
class TestDAGComplexScenarios:
"""Test complex usage patterns."""
def test_chain_graph(self) -> None:
g = DAG[str]()
g["a"] = {"b"}
g["b"] = {"c"}
g["c"] = {"d"}
assert "b" in g["a"]
assert "c" in g["b"]
assert "d" in g["c"]
assert len(g) == 3
def test_diamond_graph(self) -> None:
g = DAG[str]()
g["a"] = {"b", "c"}
g["b"] = {"d"}
g["c"] = {"d"}
assert len(g.reverse["d"]) == 2
def test_batch_operations(self) -> None:
g = DAG[str]()
g["a"] += {"b", "c", "d"}
assert len(g["a"]) == 3
g["a"] -= {"b"}
assert len(g["a"]) == 2
assert "b" not in g["a"]
def test_type_hints_with_ints(self) -> None:
g = DAG[int]()
g[1] = {2, 3}
g[2] = {4}
assert 2 in g[1]
assert 4 in g[2]
assert len(g) == 3

222
tests/test_dagset.py Normal file
View File

@@ -0,0 +1,222 @@
from __future__ import annotations
import pytest
from daglib.set import DAGSet
class TestDAGSetInit:
"""Test DAGSet initialization."""
def test_init_empty(self) -> None:
s = DAGSet()
assert len(s) == 0
assert list(s) == []
def test_init_from_set(self) -> None:
s = DAGSet({1, 2, 3})
assert len(s) == 3
assert set(s) == {1, 2, 3}
def test_init_from_list(self) -> None:
s = DAGSet([1, 2, 3, 2])
assert len(s) == 3
assert set(s) == {1, 2, 3}
def test_init_from_tuple(self) -> None:
s = DAGSet((4, 5, 6))
assert len(s) == 3
assert 4 in s and 5 in s and 6 in s
def test_init_none(self) -> None:
s = DAGSet(None)
assert len(s) == 0
def test_init_invalid_type(self) -> None:
with pytest.raises(TypeError):
DAGSet(42) # type: ignore
class TestDAGSetBasicOps:
"""Test basic set operations."""
def test_contains(self) -> None:
s = DAGSet({1, 2, 3})
assert 1 in s
assert 4 not in s
def test_add(self) -> None:
s = DAGSet()
s.add(1)
assert 1 in s
assert len(s) == 1
def test_discard(self) -> None:
s = DAGSet({1, 2, 3})
s.discard(2)
assert 2 not in s
assert len(s) == 2
def test_discard_missing(self) -> None:
s = DAGSet({1, 2, 3})
s.discard(99) # should not raise
assert len(s) == 3
def test_remove(self) -> None:
s = DAGSet({1, 2, 3})
s.remove(2)
assert 2 not in s
def test_remove_missing(self) -> None:
s = DAGSet({1, 2, 3})
with pytest.raises(KeyError):
s.remove(99)
def test_iter(self) -> None:
s = DAGSet({1, 2, 3})
items = set(s)
assert items == {1, 2, 3}
def test_len(self) -> None:
s = DAGSet({1, 2, 3})
assert len(s) == 3
class TestDAGSetCallbacks:
"""Test callback mechanisms."""
def test_on_add_callback(self) -> None:
added: list[int] = []
s = DAGSet({1, 2})
s.on_add = lambda v: added.append(v)
s.add(3)
assert 3 in added
assert len(added) == 1
def test_on_remove_callback(self) -> None:
removed: list[int] = []
s = DAGSet({1, 2, 3})
s.on_remove = lambda v: removed.append(v)
s.discard(2)
assert 2 in removed
assert len(removed) == 1
def test_callbacks_none_by_default(self) -> None:
s = DAGSet()
assert s.on_add is None
assert s.on_remove is None
class TestDAGSetInPlaceOperators:
"""Test in-place set operators."""
def test_ior_with_set(self) -> None:
s = DAGSet({1, 2})
s |= {3, 4}
assert set(s) == {1, 2, 3, 4}
def test_ior_with_list(self) -> None:
s = DAGSet({1})
s |= [2, 3]
assert set(s) == {1, 2, 3}
def test_ior_with_string(self) -> None:
s = DAGSet({"a", "b"})
s |= "c"
assert "c" in s
def test_iadd_with_set(self) -> None:
s = DAGSet({1, 2})
s += {3, 4}
assert set(s) == {1, 2, 3, 4}
def test_iadd_with_list(self) -> None:
s = DAGSet({1})
s += [2, 3]
assert set(s) == {1, 2, 3}
def test_iadd_with_string(self) -> None:
s = DAGSet({"a"})
s += "b"
assert "b" in s
def test_isub_with_set(self) -> None:
s = DAGSet({1, 2, 3, 4})
s -= {2, 3}
assert set(s) == {1, 4}
def test_isub_with_list(self) -> None:
s = DAGSet({1, 2, 3})
s -= [2]
assert set(s) == {1, 3}
def test_isub_with_string(self) -> None:
s = DAGSet({"a", "b", "c"})
s -= "b"
assert set(s) == {"a", "c"}
def test_iand_with_set(self) -> None:
s = DAGSet({1, 2, 3})
s &= {2, 3, 4}
assert set(s) == {2, 3}
def test_iand_with_list(self) -> None:
s = DAGSet({1, 2, 3})
s &= [2, 3]
assert set(s) == {2, 3}
def test_ixor_with_set(self) -> None:
s = DAGSet({1, 2, 3})
s ^= {3, 4, 5}
assert set(s) == {1, 2, 4, 5}
def test_ixor_with_list(self) -> None:
s = DAGSet({1, 2})
s ^= [2, 3]
assert set(s) == {1, 3}
class TestDAGSetCallbacksWithOperators:
"""Test that callbacks fire with in-place operators."""
def test_ior_triggers_callbacks(self) -> None:
added: list[int] = []
s = DAGSet({1})
s.on_add = lambda v: added.append(v)
s |= {2, 3}
assert 2 in added
assert 3 in added
def test_iadd_triggers_callbacks(self) -> None:
added: list[int] = []
s = DAGSet({1})
s.on_add = lambda v: added.append(v)
s += [2, 3]
assert 2 in added
assert 3 in added
def test_isub_triggers_callbacks(self) -> None:
removed: list[int] = []
s = DAGSet({1, 2, 3})
s.on_remove = lambda v: removed.append(v)
s -= {2, 3}
assert 2 in removed
assert 3 in removed
class TestDAGSetRepr:
"""Test string representation."""
def test_repr_empty(self) -> None:
s = DAGSet()
assert repr(s) == "{}"
def test_repr_with_items(self) -> None:
s = DAGSet({1, 2, 3})
r = repr(s)
assert r.startswith("{")
assert r.endswith("}")
# items may be in any order
assert "1" in r
assert "2" in r
assert "3" in r

36
uv.lock generated
View File

@@ -113,6 +113,7 @@ source = { editable = "." }
dev = [ dev = [
{ name = "ipykernel" }, { name = "ipykernel" },
{ name = "pre-commit" }, { name = "pre-commit" },
{ name = "rich" },
{ name = "ruff" }, { name = "ruff" },
] ]
test = [ test = [
@@ -125,6 +126,7 @@ test = [
dev = [ dev = [
{ name = "ipykernel", specifier = ">=7.2.0" }, { name = "ipykernel", specifier = ">=7.2.0" },
{ name = "pre-commit", specifier = ">=4.5.1" }, { name = "pre-commit", specifier = ">=4.5.1" },
{ name = "rich", specifier = ">=14.3.3" },
{ name = "ruff", specifier = ">=0.15.2" }, { name = "ruff", specifier = ">=0.15.2" },
] ]
test = [{ name = "pytest", specifier = ">=9.0.2" }] test = [{ name = "pytest", specifier = ">=9.0.2" }]
@@ -302,6 +304,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" },
] ]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[[package]] [[package]]
name = "matplotlib-inline" name = "matplotlib-inline"
version = "0.2.1" version = "0.2.1"
@@ -314,6 +328,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" },
] ]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]] [[package]]
name = "nest-asyncio" name = "nest-asyncio"
version = "1.6.0" version = "1.6.0"
@@ -589,6 +612,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" }, { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" },
] ]
[[package]]
name = "rich"
version = "14.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
]
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.15.2" version = "0.15.2"