Files
ar9av__obsidian-wiki/tests/test_batch.py
Arnav ea1633a8d4 fix(tests): isolate CLI subprocess tests, and actually run the suite in CI (#174)
* fix(tests): isolate CLI subprocess tests, and run the suite in CI

12 tests failed on a clean checkout. Neither was a product bug — both were
test-harness isolation gaps, and CI never caught them because the suite was
never run there.

1. Wrong package under test. The 18 test files that spawn
   `python -m obsidian_wiki.cli` each build their own env from
   os.environ.copy() and never set PYTHONPATH, so sys.path[0] was the
   subprocess cwd. Any test running from a temp dir imported whatever
   obsidian_wiki happened to be pip-installed instead of the working tree —
   here a stale build predating `code-understand`, so all 8
   test_code_understand_cli tests died at argparse with "invalid choice",
   plus 4 in test_context_pack_cli. One autouse session fixture in
   conftest.py now pins PYTHONPATH for every helper at once.

2. Developer .env leaking in. test_lint's `_run` and test_context_pack's
   `run_cli` inherited pytest's cwd (the repo), so config resolution walked
   up and found a developer's own untracked .env, pointing the run at their
   real vault instead of the fixture. Both now run from the fake HOME.

Verified the fix isn't masking anything: sabotaging the config resolver makes
the relevant tests fail, and the suite passes in a venv with no obsidian_wiki
installed at all — so PYTHONPATH is genuinely doing the work.

Adds .github/workflows/tests.yml to run pytest on PRs and main. The workflows
only ran setup.sh, wheel publish, and the README-sync checker, so nothing
executed tests/ — the real reason this drifted.

Also drops the stale "RED by design / command does not exist yet" docstring
in test_code_understand_cli.py; the wiring landed in fd30720.

566 passed, 1 skipped (was 554 passed / 12 failed).

* fix(tests): make three test modules importable on Python 3.9

test_batch, test_graph_analysis and test_graphrag annotate helpers with
`int | None` / `list[str] | None` but lack `from __future__ import
annotations`, so 3.9 evaluates the union at import time and collection dies
with "unsupported operand type(s) for |". Every module under obsidian_wiki/
already has the future import; only these three tests were missing it.

Surfaced by the new pytest CI job — the 3.9 leg (the requires-python floor)
failed at collection while 3.12 passed.
2026-08-16 21:14:55 -07:00

293 lines
10 KiB
Python

"""Tests for the batch planning module."""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
import pytest
from obsidian_wiki.batch import (
_classify,
_make_batches,
discover_sources,
plan_batches,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def vault(tmp_path):
v = tmp_path / "vault"
v.mkdir()
return v
@pytest.fixture
def source_dir(tmp_path):
d = tmp_path / "sources"
d.mkdir()
return d
def _write(path: Path, content: str = "x", size: int | None = None) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
if size is not None:
path.write_bytes(b"x" * size)
else:
path.write_text(content)
return path
# ---------------------------------------------------------------------------
# _classify
# ---------------------------------------------------------------------------
class TestClassify:
def test_markdown(self, tmp_path):
assert _classify(tmp_path / "foo.md") == "text"
def test_pdf(self, tmp_path):
assert _classify(tmp_path / "paper.pdf") == "pdf"
def test_image(self, tmp_path):
assert _classify(tmp_path / "shot.png") == "image"
def test_python_code(self, tmp_path):
assert _classify(tmp_path / "main.py") == "code"
def test_skip_binary(self, tmp_path):
assert _classify(tmp_path / "lib.so") == "skip"
def test_skip_lockfile(self, tmp_path):
assert _classify(tmp_path / "poetry.lock") == "skip"
# ---------------------------------------------------------------------------
# discover_sources
# ---------------------------------------------------------------------------
class TestDiscoverSources:
def test_finds_markdown(self, source_dir, vault):
_write(source_dir / "a.md")
_write(source_dir / "b.md")
files = discover_sources(source_dir, vault=vault)
kinds = [f["kind"] for f in files]
assert kinds.count("text") == 2
def test_excludes_code_by_default(self, source_dir, vault):
_write(source_dir / "main.py")
_write(source_dir / "notes.md")
files = discover_sources(source_dir, vault=vault)
assert all(f["kind"] != "code" for f in files)
def test_includes_code_when_flag_set(self, source_dir, vault):
_write(source_dir / "main.py")
files = discover_sources(source_dir, vault=vault, include_code=True)
assert any(f["kind"] == "code" for f in files)
def test_skips_binary(self, source_dir, vault):
_write(source_dir / "lib.so")
files = discover_sources(source_dir, vault=vault)
assert files == []
def test_skips_hidden_dirs(self, source_dir, vault):
_write(source_dir / ".git" / "config", "gitconfig")
_write(source_dir / "doc.md")
files = discover_sources(source_dir, vault=vault)
paths = [f["path"] for f in files]
assert not any(".git" in p for p in paths)
def test_skips_node_modules(self, source_dir, vault):
_write(source_dir / "node_modules" / "foo.md")
_write(source_dir / "readme.md")
files = discover_sources(source_dir, vault=vault)
assert len(files) == 1
def test_returns_size(self, source_dir, vault):
p = _write(source_dir / "doc.md", size=512)
files = discover_sources(source_dir, vault=vault)
assert files[0]["size_bytes"] == 512
# ---------------------------------------------------------------------------
# discover_sources — git repo awareness
# ---------------------------------------------------------------------------
def _git(repo_dir: Path, *args: str) -> None:
subprocess.run(["git", "-C", str(repo_dir), *args], check=True,
capture_output=True)
@pytest.fixture
def git_repo(source_dir):
_git(source_dir, "init", "-q")
_git(source_dir, "config", "user.email", "test@example.com")
_git(source_dir, "config", "user.name", "Test")
return source_dir
class TestDiscoverSourcesGitAware:
def test_respects_gitignore_for_custom_dirs(self, git_repo, vault):
# A dir name that isn't in the hardcoded SKIP_DIRS list, but is
# ignored by this repo's own .gitignore.
_write(git_repo / ".gitignore", "vendor_stuff/\n")
_write(git_repo / "vendor_stuff" / "generated.md")
_write(git_repo / "readme.md")
files = discover_sources(git_repo, vault=vault)
paths = [f["path"] for f in files]
assert not any("vendor_stuff" in p for p in paths)
assert any("readme.md" in p for p in paths)
def test_includes_untracked_uncommitted_files(self, git_repo, vault):
_write(git_repo / "draft.md")
files = discover_sources(git_repo, vault=vault)
assert any("draft.md" in f["path"] for f in files)
def test_still_skips_dot_git_dir(self, git_repo, vault):
_write(git_repo / "readme.md")
files = discover_sources(git_repo, vault=vault)
paths = [f["path"] for f in files]
assert not any(".git" in p for p in paths)
def test_non_git_dir_falls_back_to_walk(self, source_dir, vault):
_write(source_dir / "doc.md")
files = discover_sources(source_dir, vault=vault)
assert len(files) == 1
# ---------------------------------------------------------------------------
# _make_batches
# ---------------------------------------------------------------------------
class TestMakeBatches:
def _files(self, sizes: list[int]) -> list[dict]:
return [{"path": f"f{i}.md", "kind": "text", "size_bytes": s}
for i, s in enumerate(sizes)]
def test_single_batch_small(self):
files = self._files([100, 200, 300])
batches = _make_batches(files, max_batch_bytes=10_000, max_batch_files=20)
assert len(batches) == 1
assert len(batches[0]) == 3
def test_splits_on_byte_limit(self):
files = self._files([600_000, 600_000, 600_000]) # each 0.6 MB, limit 1 MB
batches = _make_batches(files, max_batch_bytes=1_000_000, max_batch_files=20)
assert len(batches) >= 2
def test_splits_on_file_count(self):
files = self._files([10] * 25)
batches = _make_batches(files, max_batch_bytes=10_000_000, max_batch_files=10)
assert len(batches) == 3 # 10 + 10 + 5
def test_empty_input(self):
assert _make_batches([], max_batch_bytes=1_000_000, max_batch_files=20) == []
def test_all_files_present_in_batches(self):
files = self._files([100] * 47)
batches = _make_batches(files, max_batch_bytes=10_000_000, max_batch_files=10)
total = sum(len(b) for b in batches)
assert total == 47
# ---------------------------------------------------------------------------
# plan_batches
# ---------------------------------------------------------------------------
class TestPlanBatches:
def test_returns_required_keys(self, source_dir, vault):
_write(source_dir / "a.md")
result = plan_batches(source_dir, vault)
assert "batches" in result
assert "stats" in result
assert "merge_hint" in result
def test_empty_dir_gives_zero_batches(self, source_dir, vault):
result = plan_batches(source_dir, vault)
assert result["stats"]["batch_count"] == 0
def test_single_file_single_batch(self, source_dir, vault):
_write(source_dir / "doc.md")
result = plan_batches(source_dir, vault)
assert result["stats"]["batch_count"] == 1
assert len(result["batches"][0]["files"]) == 1
def test_batch_kinds_tallied(self, source_dir, vault):
_write(source_dir / "a.md")
_write(source_dir / "b.md")
result = plan_batches(source_dir, vault)
kinds = result["batches"][0]["kinds"]
assert kinds.get("text", 0) == 2
def test_skips_unchanged_after_cache_update(self, source_dir, vault):
f = _write(source_dir / "doc.md", "some content")
# Mark it as ingested
from obsidian_wiki.cache import update_source
update_source(vault, f)
result = plan_batches(source_dir, vault)
assert result["stats"]["skipped_unchanged"] == 1
assert result["stats"]["to_ingest"] == 0
def test_no_cache_flag_includes_unchanged(self, source_dir, vault):
f = _write(source_dir / "doc.md", "some content")
from obsidian_wiki.cache import update_source
update_source(vault, f)
result = plan_batches(source_dir, vault, skip_unchanged=False)
assert result["stats"]["to_ingest"] == 1
def test_batch_total_bytes_correct(self, source_dir, vault):
_write(source_dir / "a.md", size=1000)
_write(source_dir / "b.md", size=2000)
result = plan_batches(source_dir, vault, skip_unchanged=False)
total = result["batches"][0]["total_bytes"]
assert total == 3000
def test_json_serialisable(self, source_dir, vault):
_write(source_dir / "doc.md")
result = plan_batches(source_dir, vault)
json.dumps(result) # must not raise
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
class TestBatchPlanCLI:
def _run(self, *args):
return subprocess.run(
[sys.executable, "-m", "obsidian_wiki.cli", *args],
capture_output=True, text=True,
)
def test_outputs_json(self, source_dir, vault):
(source_dir / "doc.md").write_text("content")
proc = self._run("batch-plan", str(vault), str(source_dir))
assert proc.returncode == 0
data = json.loads(proc.stdout)
assert "batches" in data
def test_pretty_flag(self, source_dir, vault):
(source_dir / "doc.md").write_text("content")
proc = self._run("batch-plan", str(vault), str(source_dir), "--pretty")
assert proc.returncode == 0
assert "\n " in proc.stdout
def test_missing_source_dir_exits_nonzero(self, vault, tmp_path):
proc = self._run("batch-plan", str(vault), str(tmp_path / "nope"))
assert proc.returncode != 0
def test_max_files_respected(self, source_dir, vault):
for i in range(5):
(source_dir / f"doc{i}.md").write_text(f"content {i}")
proc = self._run("batch-plan", str(vault), str(source_dir), "--max-files", "2")
assert proc.returncode == 0
data = json.loads(proc.stdout)
# 5 files with max 2 per batch → 3 batches
assert data["stats"]["batch_count"] == 3