From b8d7478f2b95c4cd884e17051d973a86a645acc0 Mon Sep 17 00:00:00 2001 From: John Lancaster <32917998+jsl12@users.noreply.github.com> Date: Sat, 21 Feb 2026 09:32:15 -0600 Subject: [PATCH] started tests --- pyproject.toml | 12 ++ src/daglib/dag.py | 14 +- tests/__init__.py | 1 + tests/test_dag.py | 307 +++++++++++++++++++++++++++++++++++++++++++ tests/test_dagset.py | 222 +++++++++++++++++++++++++++++++ uv.lock | 36 +++++ 6 files changed, 584 insertions(+), 8 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/test_dag.py create mode 100644 tests/test_dagset.py diff --git a/pyproject.toml b/pyproject.toml index b750293..fdfbbda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,8 +20,20 @@ build-backend = "uv_build" dev = [ "ipykernel>=7.2.0", "pre-commit>=4.5.1", + "rich>=14.3.3", "ruff>=0.15.2", ] test = [ "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", +] diff --git a/src/daglib/dag.py b/src/daglib/dag.py index 9befa0b..b0e3ecd 100644 --- a/src/daglib/dag.py +++ b/src/daglib/dag.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections import defaultdict from collections.abc import Callable, Iterable, Iterator, MutableMapping +from dataclasses import dataclass, field from typing import Generic, TypeVar from .set import DAGSet @@ -10,7 +11,7 @@ type dictset = defaultdict[T, set[T]] T = TypeVar("T") - +@dataclass(repr=False) class DAG(Generic[T], MutableMapping[T, DAGSet[T]]): """ DAG adjacency map: @@ -21,16 +22,12 @@ class DAG(Generic[T], MutableMapping[T, DAGSet[T]]): a guarded view that keeps reverse in sync. """ - _succ: dictset[T] - _pred: dictset[T] + _succ: dictset[T] = field(default_factory=lambda: defaultdict(set)) + _pred: dictset[T] = field(default_factory=lambda: defaultdict(set)) on_add: 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 def reverse(self) -> dictset[T]: return self._pred @@ -70,7 +67,8 @@ class DAG(Generic[T], MutableMapping[T, DAGSet[T]]): for v in vs: self._pred[v] |= {u} 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: self.discard_node(u) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..65140f2 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# tests package diff --git a/tests/test_dag.py b/tests/test_dag.py new file mode 100644 index 0000000..415a89a --- /dev/null +++ b/tests/test_dag.py @@ -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 diff --git a/tests/test_dagset.py b/tests/test_dagset.py new file mode 100644 index 0000000..add5a0e --- /dev/null +++ b/tests/test_dagset.py @@ -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 diff --git a/uv.lock b/uv.lock index 1122bb1..680a83e 100644 --- a/uv.lock +++ b/uv.lock @@ -113,6 +113,7 @@ source = { editable = "." } dev = [ { name = "ipykernel" }, { name = "pre-commit" }, + { name = "rich" }, { name = "ruff" }, ] test = [ @@ -125,6 +126,7 @@ test = [ dev = [ { name = "ipykernel", specifier = ">=7.2.0" }, { name = "pre-commit", specifier = ">=4.5.1" }, + { name = "rich", specifier = ">=14.3.3" }, { name = "ruff", specifier = ">=0.15.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" }, ] +[[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]] name = "matplotlib-inline" 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" }, ] +[[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]] name = "nest-asyncio" 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" }, ] +[[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]] name = "ruff" version = "0.15.2"