Skip to content

Commit b7dce6f

Browse files
committed
Phase 0.3: test suite for config, nonce, run context, report, CLI, and the em dash rule
64 tests. Config coverage includes every fail closed path: missing surfaces, empty surfaces, duplicate names, unknown keys at both levels with the key named in the message, declared entries that are not surface:carrier, and unknown surface types. The run context tests set a canary variable in the parent environment and assert it is absent from both scrubbed run environments, so an inherited variable cannot quietly become an undeclared channel. The em dash meta test scans through git ls-files, including untracked files that are not ignored, so a new file is checked before it is committed. It was verified against a deliberate canary file that it actually fails. Claude-Session: https://claude.ai/code/session_01FchscQuS6Ar9k5Y6VdJ7MY
1 parent 2f75596 commit b7dce6f

6 files changed

Lines changed: 570 additions & 0 deletions

File tree

tests/test_cli.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""CLI tests. Phase 0 has no adapters, so probe never exits 0."""
2+
3+
import json
4+
5+
import pytest
6+
7+
from runprobe import __version__
8+
from runprobe.cli import EXIT_CANNOT_RUN, main
9+
10+
11+
def test_version_flag_prints_the_version(capsys):
12+
with pytest.raises(SystemExit) as exc:
13+
main(["--version"])
14+
assert exc.value.code == 0
15+
assert __version__ in capsys.readouterr().out
16+
17+
18+
def test_no_command_prints_help_and_exits_two(capsys):
19+
assert main([]) == EXIT_CANNOT_RUN
20+
assert "usage: runprobe" in capsys.readouterr().err
21+
22+
23+
def test_probe_with_a_missing_config_exits_two(capsys, tmp_path):
24+
assert main(["probe", "--config", str(tmp_path / "nope.json")]) == EXIT_CANNOT_RUN
25+
assert "config file not found" in capsys.readouterr().err
26+
27+
28+
def test_probe_with_an_unknown_surface_type_exits_two(capsys, tmp_path):
29+
path = tmp_path / "surfaces.json"
30+
path.write_text(json.dumps({"surfaces": [{"name": "a", "type": "filesystem"}]}))
31+
assert main(["probe", "--config", str(path)]) == EXIT_CANNOT_RUN
32+
assert "unknown surface type: filesystem" in capsys.readouterr().err
33+
34+
35+
def test_probe_reports_no_adapters_once_a_type_resolves(capsys, tmp_path, monkeypatch):
36+
"""With a type registered, config validation passes and the probe stops here."""
37+
from runprobe.config import SURFACE_TYPES
38+
39+
monkeypatch.setitem(SURFACE_TYPES, "fake", "tests.fake")
40+
path = tmp_path / "surfaces.json"
41+
path.write_text(json.dumps({"surfaces": [{"name": "a", "type": "fake"}]}))
42+
assert main(["probe", "--config", str(path)]) == EXIT_CANNOT_RUN
43+
assert "no adapters registered" in capsys.readouterr().err
44+
45+
46+
def test_report_path_defaults(tmp_path):
47+
from runprobe.cli import build_parser
48+
49+
args = build_parser().parse_args(["probe", "--config", "surfaces.json"])
50+
assert args.report == "runprobe-report.json"
51+
args = build_parser().parse_args(
52+
["probe", "--config", "surfaces.json", "--report", "out.json"]
53+
)
54+
assert args.report == "out.json"

tests/test_config.py

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
"""Config loader tests. Every case here is a fail closed case except the first."""
2+
3+
import json
4+
5+
import pytest
6+
7+
from runprobe.config import SURFACE_TYPES, Config, ConfigError, load, parse
8+
9+
10+
def write_config(tmp_path, data):
11+
path = tmp_path / "surfaces.json"
12+
path.write_text(json.dumps(data), encoding="utf-8")
13+
return path
14+
15+
16+
@pytest.fixture
17+
def registered_type(monkeypatch):
18+
"""Register a fake surface type so structural tests can reach a valid config.
19+
20+
The real registry is empty in Phase 0, so without this every config would
21+
fail on type resolution and the valid case could not be tested at all.
22+
"""
23+
monkeypatch.setitem(SURFACE_TYPES, "fake", "tests.fake")
24+
return "fake"
25+
26+
27+
def test_valid_minimal_config_loads(tmp_path, registered_type):
28+
path = write_config(tmp_path, {"surfaces": [{"name": "shared_tmp", "type": "fake"}]})
29+
config = load(path)
30+
assert isinstance(config, Config)
31+
assert len(config.surfaces) == 1
32+
assert config.surfaces[0].name == "shared_tmp"
33+
assert config.surfaces[0].type == "fake"
34+
assert config.surfaces[0].params == {}
35+
assert config.declared == []
36+
assert config.source == path
37+
38+
39+
def test_valid_config_with_params_and_declared(tmp_path, registered_type):
40+
path = write_config(
41+
tmp_path,
42+
{
43+
"surfaces": [{"name": "shared_tmp", "type": "fake", "params": {"path": "/tmp"}}],
44+
"declared": ["shared_tmp:file_content"],
45+
},
46+
)
47+
config = load(path)
48+
assert config.surfaces[0].params == {"path": "/tmp"}
49+
assert config.is_declared("shared_tmp", "file_content")
50+
assert not config.is_declared("shared_tmp", "directory_name")
51+
52+
53+
def test_missing_surfaces_errors():
54+
with pytest.raises(ConfigError) as exc:
55+
parse({})
56+
assert "surfaces" in str(exc.value)
57+
58+
59+
def test_empty_surfaces_errors():
60+
with pytest.raises(ConfigError) as exc:
61+
parse({"surfaces": []})
62+
assert "must not be empty" in str(exc.value)
63+
64+
65+
def test_surfaces_not_a_list_errors():
66+
with pytest.raises(ConfigError) as exc:
67+
parse({"surfaces": {"name": "shared_tmp"}})
68+
assert "surfaces must be a list" in str(exc.value)
69+
70+
71+
def test_duplicate_surface_name_errors():
72+
with pytest.raises(ConfigError) as exc:
73+
parse(
74+
{
75+
"surfaces": [
76+
{"name": "shared_tmp", "type": "fake"},
77+
{"name": "shared_tmp", "type": "fake"},
78+
]
79+
}
80+
)
81+
message = str(exc.value)
82+
assert "duplicate surface name" in message
83+
assert "shared_tmp" in message
84+
85+
86+
def test_unknown_top_level_key_errors_and_names_the_key():
87+
with pytest.raises(ConfigError) as exc:
88+
parse({"surfaces": [{"name": "a", "type": "fake"}], "surfacez": []})
89+
message = str(exc.value)
90+
assert "unknown key" in message
91+
assert "surfacez" in message
92+
93+
94+
def test_unknown_key_inside_a_surface_errors_and_names_the_path():
95+
with pytest.raises(ConfigError) as exc:
96+
parse({"surfaces": [{"name": "a", "type": "fake", "parms": {}}]})
97+
message = str(exc.value)
98+
assert "unknown key" in message
99+
assert "parms" in message
100+
assert "surfaces[0].parms" in message
101+
102+
103+
def test_declared_entry_without_a_colon_errors():
104+
with pytest.raises(ConfigError) as exc:
105+
parse({"surfaces": [{"name": "a", "type": "fake"}], "declared": ["a_file_content"]})
106+
message = str(exc.value)
107+
assert "declared[0]" in message
108+
assert "surface_name:carrier_name" in message
109+
110+
111+
def test_declared_entry_with_two_colons_errors():
112+
with pytest.raises(ConfigError) as exc:
113+
parse({"surfaces": [{"name": "a", "type": "fake"}], "declared": ["a:b:c"]})
114+
assert "surface_name:carrier_name" in str(exc.value)
115+
116+
117+
def test_declared_entry_naming_an_undefined_surface_errors():
118+
with pytest.raises(ConfigError) as exc:
119+
parse({"surfaces": [{"name": "a", "type": "fake"}], "declared": ["b:file_content"]})
120+
assert "undefined surface" in str(exc.value)
121+
122+
123+
def test_unknown_surface_type_errors_with_the_type_name():
124+
with pytest.raises(ConfigError) as exc:
125+
parse({"surfaces": [{"name": "a", "type": "s3_bucket"}]})
126+
message = str(exc.value)
127+
assert "unknown surface type" in message
128+
assert "s3_bucket" in message
129+
130+
131+
def test_surface_type_registry_is_empty_in_this_phase():
132+
assert SURFACE_TYPES == {}
133+
134+
135+
def test_missing_surface_name_errors():
136+
with pytest.raises(ConfigError) as exc:
137+
parse({"surfaces": [{"type": "fake"}]})
138+
assert "'name'" in str(exc.value)
139+
140+
141+
def test_missing_surface_type_errors():
142+
with pytest.raises(ConfigError) as exc:
143+
parse({"surfaces": [{"name": "a"}]})
144+
assert "'type'" in str(exc.value)
145+
146+
147+
def test_surface_name_must_be_a_string():
148+
with pytest.raises(ConfigError) as exc:
149+
parse({"surfaces": [{"name": 7, "type": "fake"}]})
150+
assert "surfaces[0].name must be a string" in str(exc.value)
151+
152+
153+
def test_params_must_be_an_object():
154+
with pytest.raises(ConfigError) as exc:
155+
parse({"surfaces": [{"name": "a", "type": "fake", "params": []}]})
156+
assert "surfaces[0].params must be an object" in str(exc.value)
157+
158+
159+
def test_root_must_be_an_object():
160+
with pytest.raises(ConfigError) as exc:
161+
parse([{"name": "a", "type": "fake"}])
162+
assert "config root must be an object" in str(exc.value)
163+
164+
165+
def test_structural_errors_are_reported_before_unknown_type():
166+
"""A config with both a typo and an unbuilt adapter reports the typo."""
167+
with pytest.raises(ConfigError) as exc:
168+
parse({"surfaces": [{"name": "a", "type": "not_built_yet", "parms": {}}]})
169+
assert "unknown key" in str(exc.value)
170+
171+
172+
def test_missing_file_errors(tmp_path):
173+
with pytest.raises(ConfigError) as exc:
174+
load(tmp_path / "does_not_exist.json")
175+
assert "config file not found" in str(exc.value)
176+
177+
178+
def test_malformed_json_errors(tmp_path):
179+
path = tmp_path / "surfaces.json"
180+
path.write_text("{not json", encoding="utf-8")
181+
with pytest.raises(ConfigError) as exc:
182+
load(path)
183+
assert "not valid JSON" in str(exc.value)

tests/test_no_em_dashes.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""Meta test: no em dashes anywhere in the tracked source or docs.
2+
3+
House rule from PLAN.md section 12. Scoped through git ls-files, which honours .gitignore,
4+
so gitignored files (PLAN.md, .prompts/) are never read by the test suite.
5+
Untracked but not ignored files are included too, so a new file is checked
6+
before it is committed rather than after.
7+
"""
8+
9+
import subprocess
10+
from pathlib import Path
11+
12+
import pytest
13+
14+
# Written as an escape so that this file does not itself contain the character.
15+
EM_DASH = "\u2014"
16+
17+
REPO_ROOT = Path(__file__).resolve().parent.parent
18+
19+
SCANNED_PREFIXES = ("src/", "tests/")
20+
21+
22+
def tracked_files():
23+
try:
24+
result = subprocess.run(
25+
["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard"],
26+
cwd=REPO_ROOT,
27+
capture_output=True,
28+
check=True,
29+
text=True,
30+
)
31+
except (OSError, subprocess.CalledProcessError) as exc:
32+
pytest.skip(f"git ls-files unavailable: {exc}")
33+
34+
return [name for name in result.stdout.split("\0") if name]
35+
36+
37+
def scanned_files():
38+
"""Every tracked file under src/ or tests/, plus every tracked Markdown file."""
39+
selected = []
40+
for name in tracked_files():
41+
if name.startswith(SCANNED_PREFIXES) or name.endswith(".md"):
42+
path = REPO_ROOT / name
43+
if path.is_file():
44+
selected.append(name)
45+
return selected
46+
47+
48+
def test_scan_covers_the_expected_files():
49+
"""Guard against the scan silently matching nothing and passing for free."""
50+
names = scanned_files()
51+
assert "README.md" in names
52+
assert any(name.startswith("src/runprobe/") for name in names)
53+
assert any(name.startswith("tests/") for name in names)
54+
55+
56+
def test_no_em_dashes_in_tracked_source_and_docs():
57+
offenders = []
58+
for name in scanned_files():
59+
text = (REPO_ROOT / name).read_text(encoding="utf-8", errors="replace")
60+
for lineno, line in enumerate(text.splitlines(), start=1):
61+
if EM_DASH in line:
62+
offenders.append(f"{name}:{lineno}: {line.strip()}")
63+
64+
assert not offenders, "em dash found (house rule, use commas or colons):\n" + "\n".join(
65+
offenders
66+
)

tests/test_nonce.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""Nonce tests."""
2+
3+
import string
4+
5+
from runprobe.nonce import NONCE_HEX_CHARS, generate
6+
7+
8+
def test_nonce_is_sixteen_characters():
9+
assert len(generate()) == 16
10+
assert NONCE_HEX_CHARS == 16
11+
12+
13+
def test_nonce_is_lowercase_hex_only():
14+
nonce = generate()
15+
allowed = set(string.hexdigits.lower())
16+
assert set(nonce) <= allowed
17+
assert nonce == nonce.lower()
18+
19+
20+
def test_two_calls_differ():
21+
assert generate() != generate()
22+
23+
24+
def test_many_calls_are_unique():
25+
"""A repeat inside a small sample would mean the generator is not random."""
26+
assert len({generate() for _ in range(500)}) == 500

0 commit comments

Comments
 (0)