Skip to content

Commit fa84845

Browse files
committed
test: cover non-DB transformation layer
1 parent 255fadc commit fa84845

7 files changed

Lines changed: 536 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# Changelog
22

3+
## 1.30.2
4+
- Add unit tests for the rest of the non-DB-touching transformation surface — five files covering `tools.py` (`BaseTool.to_api_schema`, `ToolRegistry`), `agents/llm_client.py` (`resolve_provider`, `_normalize_anthropic_stop`, `_normalize_openai_stop`), `knowledge/__init__.py` (`load`, `load_onboarding_block`, `onboarding_summary` with override and default paths), `records/__init__.py` (`RecordsExpert.ingest` validation paths with mocked storage), and `registry.py` (`schema_fragment` and `_render_deployment_section_seed` with patched `_db_rows` for DB isolation). 47 new tests total; full suite (55 tests) runs in ~0.3s.
5+
36
## 1.30.1
47
- Wire pytest into CI. New `.github/workflows/test.yml` runs `uv run pytest tests/unit/` on every PR and push to `main`, alongside the existing lint workflow. Test failures now block merges.
58

pearscarf/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
__version__ = "1.30.1"
1+
__version__ = "1.30.2"
22

33
# Make installed expert packages importable from the experts/ folder.
44
# Temporary — replaced by the registry-driven discovery in a follow-up.

tests/unit/test_knowledge.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""Tests for `pearscarf.knowledge` — prompt loader + onboarding block resolution."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
7+
import pytest
8+
9+
import pearscarf.knowledge as knowledge
10+
11+
12+
@pytest.fixture(autouse=True)
13+
def _reset_onboarding_cache() -> None:
14+
"""Drop the module-level onboarding cache before each test."""
15+
knowledge._onboarding_block = None
16+
knowledge._onboarding_source = None
17+
18+
19+
# ---- load(name) ----
20+
21+
22+
def test_load_named_prompt_reads_default(monkeypatch: pytest.MonkeyPatch) -> None:
23+
monkeypatch.delenv("ONBOARDING_PROMPT_PATH", raising=False)
24+
content = knowledge.load("onboarding")
25+
assert content # non-empty default ships with the repo
26+
27+
28+
def test_load_with_override_env_var(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
29+
override = tmp_path / "custom_onboarding.md"
30+
override.write_text("CUSTOM ONBOARDING TEXT")
31+
monkeypatch.setenv("ONBOARDING_PROMPT_PATH", str(override))
32+
33+
assert knowledge.load("onboarding") == "CUSTOM ONBOARDING TEXT"
34+
35+
36+
def test_load_with_override_pointing_to_missing_file_raises(
37+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
38+
) -> None:
39+
missing = tmp_path / "nonexistent.md"
40+
monkeypatch.setenv("ONBOARDING_PROMPT_PATH", str(missing))
41+
42+
with pytest.raises(FileNotFoundError, match="ONBOARDING_PROMPT_PATH"):
43+
knowledge.load("onboarding")
44+
45+
46+
def test_load_unknown_name_raises_key_error() -> None:
47+
with pytest.raises(KeyError):
48+
knowledge.load("does_not_exist")
49+
50+
51+
# ---- load_onboarding_block ----
52+
53+
54+
def test_load_onboarding_block_frames_default_content(
55+
monkeypatch: pytest.MonkeyPatch,
56+
) -> None:
57+
monkeypatch.delenv("ONBOARDING_PROMPT_PATH", raising=False)
58+
block = knowledge.load_onboarding_block()
59+
assert block.startswith("## Onboarding\n\n")
60+
assert block.endswith("\n\n---\n\n")
61+
62+
63+
def test_load_onboarding_block_uses_override_content(
64+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
65+
) -> None:
66+
override = tmp_path / "custom.md"
67+
override.write_text("INNER")
68+
monkeypatch.setenv("ONBOARDING_PROMPT_PATH", str(override))
69+
70+
assert knowledge.load_onboarding_block() == "## Onboarding\n\nINNER\n\n---\n\n"
71+
72+
73+
def test_load_onboarding_block_returns_empty_when_content_blank(
74+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
75+
) -> None:
76+
override = tmp_path / "blank.md"
77+
override.write_text(" \n \n")
78+
monkeypatch.setenv("ONBOARDING_PROMPT_PATH", str(override))
79+
80+
assert knowledge.load_onboarding_block() == ""
81+
82+
83+
# ---- onboarding_summary ----
84+
85+
86+
def test_onboarding_summary_default_returns_label_and_count(
87+
monkeypatch: pytest.MonkeyPatch,
88+
) -> None:
89+
monkeypatch.delenv("ONBOARDING_PROMPT_PATH", raising=False)
90+
label, count = knowledge.onboarding_summary()
91+
assert "default" in label
92+
assert count > 0
93+
94+
95+
def test_onboarding_summary_with_override_label_includes_override(
96+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
97+
) -> None:
98+
override = tmp_path / "custom.md"
99+
override.write_text("HELLO")
100+
monkeypatch.setenv("ONBOARDING_PROMPT_PATH", str(override))
101+
102+
label, count = knowledge.onboarding_summary()
103+
assert "override" in label
104+
assert str(override) in label
105+
assert count > 0
106+
107+
108+
def test_onboarding_summary_blank_content_marks_empty(
109+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
110+
) -> None:
111+
override = tmp_path / "blank.md"
112+
override.write_text("")
113+
monkeypatch.setenv("ONBOARDING_PROMPT_PATH", str(override))
114+
115+
label, count = knowledge.onboarding_summary()
116+
assert "(empty)" in label
117+
assert count == 0

tests/unit/test_llm_client.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""Tests for pure helpers in `pearscarf.agents.llm_client`:
2+
`resolve_provider`, `_normalize_anthropic_stop`, `_normalize_openai_stop`.
3+
"""
4+
5+
from __future__ import annotations
6+
7+
import pytest
8+
9+
from pearscarf.agents.llm_client import (
10+
_normalize_anthropic_stop,
11+
_normalize_openai_stop,
12+
resolve_provider,
13+
)
14+
15+
# ---- resolve_provider ----
16+
17+
18+
def test_resolve_provider_claude_prefix_routes_to_anthropic() -> None:
19+
assert resolve_provider("claude-opus-4-7") == "anthropic"
20+
21+
22+
def test_resolve_provider_gpt_prefix_routes_to_openai() -> None:
23+
assert resolve_provider("gpt-5-turbo") == "openai"
24+
25+
26+
def test_resolve_provider_o_series_routes_to_openai() -> None:
27+
assert resolve_provider("o1-preview") == "openai"
28+
assert resolve_provider("o3-mini") == "openai"
29+
assert resolve_provider("o4-mini") == "openai"
30+
31+
32+
def test_resolve_provider_explicit_overrides_model_prefix() -> None:
33+
assert resolve_provider("claude-opus-4-7", "openai") == "openai"
34+
assert resolve_provider("gpt-5", "anthropic") == "anthropic"
35+
36+
37+
def test_resolve_provider_unknown_explicit_raises() -> None:
38+
with pytest.raises(ValueError, match="not supported"):
39+
resolve_provider("claude-opus-4-7", "azure")
40+
41+
42+
def test_resolve_provider_unknown_model_prefix_raises() -> None:
43+
with pytest.raises(ValueError, match="Cannot infer provider"):
44+
resolve_provider("llama-3-70b")
45+
46+
47+
# ---- _normalize_anthropic_stop ----
48+
49+
50+
def test_normalize_anthropic_passes_through_canonical_values() -> None:
51+
assert _normalize_anthropic_stop("end_turn") == "end_turn"
52+
assert _normalize_anthropic_stop("tool_use") == "tool_use"
53+
assert _normalize_anthropic_stop("max_tokens") == "max_tokens"
54+
55+
56+
def test_normalize_anthropic_none_becomes_unknown() -> None:
57+
assert _normalize_anthropic_stop(None) == "unknown"
58+
59+
60+
def test_normalize_anthropic_passes_through_unrecognized_strings() -> None:
61+
assert _normalize_anthropic_stop("weird_stop") == "weird_stop"
62+
63+
64+
# ---- _normalize_openai_stop ----
65+
66+
67+
def test_normalize_openai_maps_stop_to_end_turn() -> None:
68+
assert _normalize_openai_stop("stop") == "end_turn"
69+
70+
71+
def test_normalize_openai_maps_tool_calls_to_tool_use() -> None:
72+
assert _normalize_openai_stop("tool_calls") == "tool_use"
73+
74+
75+
def test_normalize_openai_maps_length_to_max_tokens() -> None:
76+
assert _normalize_openai_stop("length") == "max_tokens"
77+
78+
79+
def test_normalize_openai_none_becomes_unknown() -> None:
80+
assert _normalize_openai_stop(None) == "unknown"
81+
82+
83+
def test_normalize_openai_unmapped_passes_through() -> None:
84+
assert _normalize_openai_stop("content_filter") == "content_filter"
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""Tests for `pearscarf.records.RecordsExpert.ingest` validation paths.
2+
3+
The ingest path validates body/url/op_area then delegates to
4+
`ctx.storage.save_record`. These tests cover the validation branches with a
5+
mocked context — no DB involved.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from typing import cast
11+
from unittest.mock import MagicMock
12+
13+
import pytest
14+
15+
from pearscarf.expert_context import ExpertContext
16+
from pearscarf.records import RecordsExpert, RecordSubmissionError
17+
18+
VALID_BODY = """\
19+
Title: Test record
20+
21+
Id: test-record-20260510
22+
Date: 2026-05-10
23+
24+
Anchor.
25+
26+
## For humans
27+
28+
Some prose.
29+
30+
## For agents
31+
32+
```yaml
33+
facts:
34+
- Some fact about something.
35+
```
36+
"""
37+
38+
39+
def _expert_with_mock_storage() -> tuple[RecordsExpert, MagicMock]:
40+
"""Return a RecordsExpert wired to a Mock storage."""
41+
ctx = MagicMock()
42+
ctx.storage.save_record.return_value = "record_test123"
43+
return RecordsExpert(cast("ExpertContext", ctx)), ctx.storage.save_record
44+
45+
46+
# ---- validation rejections ----
47+
48+
49+
def test_empty_body_raises() -> None:
50+
expert, _ = _expert_with_mock_storage()
51+
with pytest.raises(RecordSubmissionError, match="body is empty"):
52+
expert.ingest("", "https://example.com/x", "reality")
53+
54+
55+
def test_whitespace_only_body_raises() -> None:
56+
expert, _ = _expert_with_mock_storage()
57+
with pytest.raises(RecordSubmissionError, match="body is empty"):
58+
expert.ingest(" \n\n ", "https://example.com/x", "reality")
59+
60+
61+
def test_empty_url_raises() -> None:
62+
expert, _ = _expert_with_mock_storage()
63+
with pytest.raises(RecordSubmissionError, match="url is required"):
64+
expert.ingest(VALID_BODY, "", "reality")
65+
66+
67+
def test_invalid_op_area_raises() -> None:
68+
expert, _ = _expert_with_mock_storage()
69+
with pytest.raises(RecordSubmissionError, match="op_area must be one of"):
70+
expert.ingest(VALID_BODY, "https://example.com/x", "bogus")
71+
72+
73+
def test_body_missing_id_raises() -> None:
74+
expert, _ = _expert_with_mock_storage()
75+
body = "Title: x\n\nDate: 2026-05-10\n\nAnchor."
76+
with pytest.raises(RecordSubmissionError, match=r"missing required `Id:` line"):
77+
expert.ingest(body, "https://example.com/x", "reality")
78+
79+
80+
def test_body_missing_date_raises() -> None:
81+
expert, _ = _expert_with_mock_storage()
82+
body = "Title: x\n\nId: x123\n\nAnchor."
83+
with pytest.raises(RecordSubmissionError, match=r"missing required `Date:` line"):
84+
expert.ingest(body, "https://example.com/x", "reality")
85+
86+
87+
# ---- happy path ----
88+
89+
90+
def test_valid_input_calls_storage_with_correct_args() -> None:
91+
expert, save_record = _expert_with_mock_storage()
92+
result = expert.ingest(VALID_BODY, "https://example.com/x", "reality")
93+
assert result == "record_test123"
94+
95+
save_record.assert_called_once_with(
96+
record_type="record",
97+
raw=VALID_BODY,
98+
content=VALID_BODY,
99+
metadata={"op_area": "reality", "source_url": "https://example.com/x"},
100+
dedup_key="test-record-20260510",
101+
)
102+
103+
104+
def test_valid_input_with_intention_op_area() -> None:
105+
expert, save_record = _expert_with_mock_storage()
106+
expert.ingest(VALID_BODY, "https://example.com/x", "intention")
107+
assert save_record.call_args.kwargs["metadata"]["op_area"] == "intention"

0 commit comments

Comments
 (0)