Files
codex-py/tests/graph/test_cli.py
Tarik Moussa fd51b70000 feat(F-15): citation graph — PageRank, coupling, co-citation, CLI
- codex/graph.py: build_citation_graph (DiGraph from DB), citation_pagerank
  (graceful uniform fallback < 5 nodes), find_related (bibliographic coupling),
  find_co_cited, dangling_citations
- codex/config.py: graph_cite_boost_alpha=0.3, graph_min_corpus_size=15
- codex/cli.py: graph report [--top-n] [--json], graph related <paper-id>
  [--min-shared]; search paper --cite-boost (PageRank score weighting)
- tests/graph/: 39 tests (graph functions + CLI) — 326 total green

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 03:31:36 +02:00

177 lines
5.9 KiB
Python

"""CLI tests for `codex graph` sub-commands (F-15)."""
from __future__ import annotations
from contextlib import contextmanager
from unittest.mock import MagicMock, patch
import networkx as nx
import numpy as np
from typer.testing import CliRunner
from codex.cli import app
runner = CliRunner()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_conn_cm(conn: MagicMock):
@contextmanager
def _cm():
yield conn
return _cm
def _make_conn_with_paper_ids(*paper_ids: str) -> MagicMock:
"""Mock conn that returns paper rows for SELECT id FROM papers."""
conn = MagicMock()
conn.execute.return_value.fetchall.return_value = [{"id": pid} for pid in paper_ids]
return conn
def _sample_graph() -> nx.DiGraph:
"""A→B, A→C, B→C, B→D, C→D — 5 nodes, D is dangling (not in papers)."""
g = nx.DiGraph()
g.add_edges_from([("A", "B"), ("A", "C"), ("B", "C"), ("B", "D"), ("C", "D")])
return g
# ---------------------------------------------------------------------------
# codex graph report
# ---------------------------------------------------------------------------
class TestGraphReport:
def _run(self, graph=None, known_ids=("A", "B", "C"), *extra_args):
if graph is None:
graph = _sample_graph()
conn = _make_conn_with_paper_ids(*known_ids)
with (
patch("codex.db.get_conn", side_effect=_make_conn_cm(conn)),
patch("codex.graph.build_citation_graph", return_value=graph),
):
return runner.invoke(app, ["graph", "report"])
def test_exit_zero(self):
assert self._run().exit_code == 0
def test_shows_hub_section(self):
assert "HUB" in self._run().output.upper()
def test_shows_dangling_section(self):
assert "DANGLING" in self._run().output.upper()
def test_dangling_node_in_output(self):
# D is cited but not in known_ids
assert "D" in self._run().output
def test_json_output_valid(self):
import json
graph = _sample_graph()
conn = _make_conn_with_paper_ids("A", "B", "C")
with (
patch("codex.db.get_conn", side_effect=_make_conn_cm(conn)),
patch("codex.graph.build_citation_graph", return_value=graph),
):
result = runner.invoke(app, ["graph", "report", "--json"])
assert result.exit_code == 0
data = json.loads(result.output)
assert "hubs" in data
assert "dangling" in data
assert "nodes" in data
assert data["dangling_count"] == 1
def test_empty_graph_message(self):
conn = _make_conn_with_paper_ids()
with (
patch("codex.db.get_conn", side_effect=_make_conn_cm(conn)),
patch("codex.graph.build_citation_graph", return_value=nx.DiGraph()),
):
result = runner.invoke(app, ["graph", "report"])
assert result.exit_code == 0
assert "empty" in result.output.lower()
def test_top_n_respected(self):
# Build graph with 8 nodes, ask for top 3
g = nx.DiGraph()
for i in range(8):
g.add_edge(f"P{i}", "HUB")
conn = _make_conn_with_paper_ids(*[f"P{i}" for i in range(8)], "HUB")
with (
patch("codex.db.get_conn", side_effect=_make_conn_cm(conn)),
patch("codex.graph.build_citation_graph", return_value=g),
):
result = runner.invoke(app, ["graph", "report", "--top-n", "3"])
assert result.exit_code == 0
# ---------------------------------------------------------------------------
# codex graph related
# ---------------------------------------------------------------------------
class TestGraphRelated:
def _run(self, paper_id: str, graph=None, *extra_args):
if graph is None:
graph = _sample_graph()
conn = MagicMock()
with (
patch("codex.db.get_conn", side_effect=_make_conn_cm(conn)),
patch("codex.graph.build_citation_graph", return_value=graph),
):
return runner.invoke(app, ["graph", "related", paper_id, *extra_args])
def test_exit_zero_known_paper(self):
assert self._run("A").exit_code == 0
def test_unknown_paper_graceful(self):
result = self._run("UNKNOWN_ID")
assert result.exit_code == 0
assert "No papers" in result.output
def test_no_results_message_when_min_shared_high(self):
result = self._run("A", _sample_graph(), "--min-shared", "99")
assert "No papers" in result.output
def test_related_papers_shown(self):
# A cites {B,C}; B also cites C → shared=1 with A
result = self._run("A", _sample_graph(), "--min-shared", "1")
assert result.exit_code == 0
# B shares C with A; should appear
assert "B" in result.output
# ---------------------------------------------------------------------------
# codex search paper --cite-boost
# ---------------------------------------------------------------------------
class TestSearchCiteBoost:
def test_cite_boost_exits_zero(self):
conn = MagicMock()
paper_rows = [
{"id": "A", "title": "Paper A", "year": 2020, "distance": 0.2},
]
graph = _sample_graph()
conn.execute.return_value.fetchall.return_value = paper_rows
def _fake_encode(texts):
return np.zeros((len(texts), 1024), dtype="float32")
with (
patch("codex.db.get_conn", side_effect=_make_conn_cm(conn)),
patch("codex.embed.get_embedder") as mock_emb,
patch("codex.graph.build_citation_graph", return_value=graph),
):
mock_emb.return_value.encode_dense.side_effect = _fake_encode
result = runner.invoke(app, ["search", "paper", "hodge star", "--cite-boost"])
assert result.exit_code == 0