Skip to content

Commit fabff3e

Browse files
NikitasT2003claude
andcommitted
test: expand suite to 187 tests / 82% coverage via TDD
Adds 8 new test modules covering previously-untested units and edge cases. Full suite: 187 passed, 1 skipped (symlink escape test needs admin rights on Windows). New tests - test_config.py (23) — provider(), load_env() error paths, models() defaults per provider, make_model() both providers, OpenRouter base URL + attribution headers + prefix stripping, thread id persistence, Paths allowlist/denylist invariants - test_memory.py (10) — SqliteSaver + SqliteStore context mgrs, DB file creation, cross-call persistence, no dangling locks - test_subagents.py (5) — 3 subagents with expected scopes, required prompt content (grounding, citations, no web/shell) - test_ingest_edges.py (28) — _clean, url_to_slug edge cases, _iter_raw_sources filters, DOCX / EPUB via fake modules, run_ingest corrupt-JSON recovery + partial-failure isolation - test_sandbox_edges.py (21, +1 skipped) — literal-allow vs wildcard-deny, nested globs, parent traversal, mixed separators, symlink escape (skip on Windows) - test_cli_helpers.py (22) — _humanise_error parametrised for 401/402/429/timeout/404, _write_env merge preserves unrelated lines, _copy_examples idempotent, _ensure_workspace --force - test_schema.py (33) — all 5 skills ship valid frontmatter, AGENTS.md declares schema + grounding rule + page template, examples layout, pyproject entry point, README mentions both providers + sandbox - test_extra_coverage.py (14) — PDF extractor via fake pypdf, trafilatura URL extractor happy + error paths, ingest __main__ entrypoint, agent-dispatch CLI commands with mocked invoke (verifies prompts contain the right keywords) Coverage by module config.py 100% sandbox.py 100% subagents.py 100% ingest/extract.py 99% ingest/manifest.py 97% ingest/__main__.py 94% memory.py 84% cli.py 74% (interactive-only paths + agent invocation) agent.py 25% (LLM-required; intentionally untested in CI) TOTAL 82% Supporting changes - Skill descriptions reworded to use "Triggers include" / "Triggers" instead of "Triggers:" (avoids YAML-colon parsing edge case caught by schema tests) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 188922c commit fabff3e

11 files changed

Lines changed: 1500 additions & 3 deletions

File tree

skills/citation-linter/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
name: citation-linter
3-
description: Use this skill when the user asks to lint, check, verify, audit, or review citations in thesis chapters. Scans a chapter (or all chapters) for `[src:<filename>]` markers, verifies each target exists under `research/raw/`, flags paragraphs making factual claims without any citation, and reports file+line issues in a short actionable table. Does not auto-edit unless explicitly asked. Triggers: "lint citations", "check citations", "audit grounding", "verify sources", "review chapter".
3+
description: Use this skill when the user asks to lint, check, verify, audit, or review citations in thesis chapters. Scans a chapter (or all chapters) for `[src:<filename>]` markers, verifies each target exists under `research/raw/`, flags paragraphs making factual claims without any citation, and reports file+line issues in a short actionable table. Does not auto-edit unless explicitly asked. Triggers "lint citations", "check citations", "audit grounding", "verify sources", "review chapter".
44
---
55

66
# citation-linter

skills/style-learner/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
name: style-learner
3-
description: Use this skill when the user asks you to learn their writing style, build/update the style guide, or when they have added new samples to `style/samples/`. Reads every file in `style/samples/`, extracts voice and mechanical patterns (sentence length, hedging, jargon density, POV, citation placement), and writes a prescriptive `style/STYLE.md` that the drafter will follow. Triggers: "learn my style", "update style guide", "analyse my writing", "I added samples", "compile style".
3+
description: Use this skill when the user asks you to learn their writing style, build/update the style guide, or when they have added new samples to `style/samples/`. Reads every file in `style/samples/`, extracts voice and mechanical patterns (sentence length, hedging, jargon density, POV, citation placement), and writes a prescriptive `style/STYLE.md` that the drafter will follow. Triggers "learn my style", "update style guide", "analyse my writing", "I added samples", "compile style".
44
---
55

66
# style-learner

skills/thesis-writer/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
name: thesis-writer
3-
description: Use this skill when the user asks you to draft, write, or expand a thesis section, chapter, paragraph, or the whole thesis. Enforces the grounding rule (every factual claim cites an indexed source), loads the user's writing style from `style/STYLE.md`, navigates the wiki for supporting material, and writes to `thesis/chapters/<NN>.md` matching `thesis/outline.md` numbering. Triggers: "draft", "write section", "expand outline", "write chapter", "flesh out 2.1", "continue writing".
3+
description: Use this skill when the user asks you to draft, write, or expand a thesis section, chapter, paragraph, or the whole thesis. Enforces the grounding rule (every factual claim cites an indexed source), loads the user's writing style from `style/STYLE.md`, navigates the wiki for supporting material, and writes to `thesis/chapters/<NN>.md` matching `thesis/outline.md` numbering. Triggers "draft", "write section", "expand outline", "write chapter", "flesh out 2.1", "continue writing".
44
---
55

66
# thesis-writer

tests/test_cli_helpers.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
"""Unit tests for CLI helper functions (not the full wizard flow)."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
7+
import pytest
8+
9+
from thesis_agent import cli
10+
from thesis_agent.config import paths
11+
12+
# ---------------------------------------------------------------------------
13+
# _humanise_error
14+
# ---------------------------------------------------------------------------
15+
16+
@pytest.mark.parametrize(
17+
"raw,hint",
18+
[
19+
("401 Unauthorized: invalid api key", "rejected"),
20+
("Authentication failed", "rejected"),
21+
("insufficient credit — add a payment method", "credits"),
22+
("429 Too Many Requests", "rate-limited"),
23+
("402 payment required", "credits"),
24+
("Connection timed out", "internet"),
25+
("model 'nope' not found", "not found"),
26+
],
27+
)
28+
def test_humanise_error_translates_common_errors(raw: str, hint: str):
29+
msg = cli._humanise_error(raw)
30+
assert hint in msg.lower()
31+
32+
33+
def test_humanise_error_truncates_unknown():
34+
long_err = "X" * 500
35+
out = cli._humanise_error(long_err)
36+
assert len(out) <= 200
37+
38+
39+
# ---------------------------------------------------------------------------
40+
# _read_env_var / _write_env
41+
# ---------------------------------------------------------------------------
42+
43+
class TestEnvIO:
44+
def test_read_env_var_missing_file_returns_none(self, tmp_path: Path, monkeypatch):
45+
monkeypatch.chdir(tmp_path)
46+
assert cli._read_env_var("ANYTHING") is None
47+
48+
def test_read_env_var_with_quotes_and_spaces(self, tmp_path: Path, monkeypatch):
49+
monkeypatch.chdir(tmp_path)
50+
(tmp_path / ".env").write_text(
51+
'ANTHROPIC_API_KEY="sk-ant-xxx"\nOTHER= value \n',
52+
encoding="utf-8",
53+
)
54+
assert cli._read_env_var("ANTHROPIC_API_KEY") == "sk-ant-xxx"
55+
56+
def test_write_env_adds_new_keys(self, tmp_path: Path, monkeypatch):
57+
monkeypatch.chdir(tmp_path)
58+
cli._write_env({"THESIS_PROVIDER": "anthropic", "ANTHROPIC_API_KEY": "k1"})
59+
env = (tmp_path / ".env").read_text(encoding="utf-8")
60+
assert "THESIS_PROVIDER=anthropic" in env
61+
assert "ANTHROPIC_API_KEY=k1" in env
62+
63+
def test_write_env_replaces_existing_keys(self, tmp_path: Path, monkeypatch):
64+
monkeypatch.chdir(tmp_path)
65+
(tmp_path / ".env").write_text(
66+
"ANTHROPIC_API_KEY=old\nKEEP_ME=yes\n", encoding="utf-8"
67+
)
68+
cli._write_env({"ANTHROPIC_API_KEY": "new"})
69+
env = (tmp_path / ".env").read_text(encoding="utf-8")
70+
assert "ANTHROPIC_API_KEY=new" in env
71+
assert "old" not in env
72+
assert "KEEP_ME=yes" in env # unrelated lines preserved
73+
74+
def test_write_env_empty_values_skipped(self, tmp_path: Path, monkeypatch):
75+
monkeypatch.chdir(tmp_path)
76+
cli._write_env({"A": "hello", "B": "", "C": "world"})
77+
env = (tmp_path / ".env").read_text(encoding="utf-8")
78+
assert "A=hello" in env
79+
assert "C=world" in env
80+
assert "B=" not in env
81+
82+
83+
# ---------------------------------------------------------------------------
84+
# _copy_examples
85+
# ---------------------------------------------------------------------------
86+
87+
class TestCopyExamples:
88+
def test_copy_examples_does_not_overwrite(self, tmp_path: Path, monkeypatch):
89+
monkeypatch.chdir(tmp_path)
90+
p = paths()
91+
p.raw.mkdir(parents=True)
92+
# Pre-existing file — must not be overwritten
93+
pre = p.raw / "example-source.md"
94+
pre.write_text("MINE", encoding="utf-8")
95+
cli._copy_examples()
96+
assert pre.read_text(encoding="utf-8") == "MINE"
97+
98+
def test_copy_examples_returns_count(self, tmp_path: Path, monkeypatch):
99+
monkeypatch.chdir(tmp_path)
100+
p = paths()
101+
for d in (p.raw, p.style_samples, p.thesis_dir):
102+
d.mkdir(parents=True, exist_ok=True)
103+
n = cli._copy_examples()
104+
assert n >= 1 # at least the example source
105+
106+
107+
# ---------------------------------------------------------------------------
108+
# _ensure_workspace
109+
# ---------------------------------------------------------------------------
110+
111+
class TestEnsureWorkspace:
112+
def test_creates_all_required_dirs(self, tmp_path: Path, monkeypatch):
113+
monkeypatch.chdir(tmp_path)
114+
cli._ensure_workspace()
115+
assert (tmp_path / "research" / "raw").is_dir()
116+
assert (tmp_path / "research" / "wiki").is_dir()
117+
assert (tmp_path / "style" / "samples").is_dir()
118+
assert (tmp_path / "thesis" / "chapters").is_dir()
119+
assert (tmp_path / "data").is_dir()
120+
assert (tmp_path / "research" / "raw" / "urls.txt").exists()
121+
assert (tmp_path / "AGENTS.md").exists()
122+
assert (tmp_path / "thesis" / "outline.md").exists()
123+
124+
def test_does_not_overwrite_agents_md_by_default(self, tmp_path: Path, monkeypatch):
125+
monkeypatch.chdir(tmp_path)
126+
(tmp_path / "AGENTS.md").write_text("MINE", encoding="utf-8")
127+
cli._ensure_workspace(force=False)
128+
assert (tmp_path / "AGENTS.md").read_text(encoding="utf-8") == "MINE"
129+
130+
def test_force_overwrites_agents_md(self, tmp_path: Path, monkeypatch):
131+
monkeypatch.chdir(tmp_path)
132+
(tmp_path / "AGENTS.md").write_text("MINE", encoding="utf-8")
133+
cli._ensure_workspace(force=True)
134+
content = (tmp_path / "AGENTS.md").read_text(encoding="utf-8")
135+
assert content != "MINE"
136+
137+
def test_idempotent_second_call_no_crash(self, tmp_path: Path, monkeypatch):
138+
monkeypatch.chdir(tmp_path)
139+
cli._ensure_workspace()
140+
cli._ensure_workspace()
141+
# No assertion needed — second call must not raise.
142+
143+
144+
# ---------------------------------------------------------------------------
145+
# `thesis ingest` command (pure Python, no LLM)
146+
# ---------------------------------------------------------------------------
147+
148+
from typer.testing import CliRunner # noqa: E402
149+
150+
runner = CliRunner()
151+
152+
153+
class TestIngestCommand:
154+
def test_ingest_on_missing_dir_exits_nonzero(self, tmp_path: Path, monkeypatch):
155+
monkeypatch.chdir(tmp_path)
156+
result = runner.invoke(cli.app, ["ingest", str(tmp_path / "nope")])
157+
assert result.exit_code != 0
158+
assert "no such directory" in result.stdout.lower()
159+
160+
def test_ingest_on_empty_raw_exits_zero(self, tmp_path: Path, monkeypatch):
161+
monkeypatch.chdir(tmp_path)
162+
runner.invoke(cli.app, ["init"])
163+
result = runner.invoke(cli.app, ["ingest"])
164+
assert result.exit_code == 0
165+
assert "added 0" in result.stdout.lower() or "no sources" in result.stdout.lower()
166+
167+
def test_ingest_happy_path_on_md_file(self, tmp_path: Path, monkeypatch):
168+
monkeypatch.chdir(tmp_path)
169+
runner.invoke(cli.app, ["init"])
170+
(tmp_path / "research" / "raw" / "note.md").write_text(
171+
"# Title\n\nbody\n", encoding="utf-8"
172+
)
173+
result = runner.invoke(cli.app, ["ingest"])
174+
assert result.exit_code == 0
175+
assert (tmp_path / "research" / "raw" / "note.md.md").exists()
176+
assert (tmp_path / "research" / "raw" / "_index.json").exists()

0 commit comments

Comments
 (0)