Skip to content

Commit f0b3477

Browse files
tests i guess
1 parent f68747b commit f0b3477

2 files changed

Lines changed: 142 additions & 144 deletions

File tree

improvement/tests/test_improve.py

Lines changed: 127 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,25 @@
11
"""Unit tests for the Oracle's deterministic machinery.
22
3-
No real model: the generator's two model calls (choose a path, grow the code)
4-
are driven by small scripted fakes so the loop's logic — path coercion,
5-
truncation-vs-error classification, the write→continue→fix progression — is
6-
exercised directly and fast. Real generation is covered in test_integration.py.
3+
No real model: the model calls (name a file, grow a bite of code, summarise) are
4+
driven by small scripted fakes so the orchestration — issue shuffling,
5+
issue/source interleaving, the write→continue→fix progression, and accumulating
6+
MULTIPLE files across inspirations — is exercised directly and fast. Real
7+
generation is covered in test_integration.py.
78
"""
89
from __future__ import annotations
910

10-
import ast
11+
import re
1112

1213
from oracle import improve
1314

1415

15-
# A fake model keyed on which step is asking (we control the prompt text, so
16-
# this is test-side dispatch, not production parsing).
17-
def oracle_fake(*, path="src/mechanism.py", write="", cont="", fix="", reason="A bold vision"):
16+
def model(route):
17+
"""Wrap route(messages)->str into a fake generate(), recording calls."""
1818
calls = []
1919

2020
def generate(messages, *, num_predict=None, temperature=None):
2121
calls.append(messages)
22-
u = messages[-1]["content"]
23-
if "Existing Python modules" in u: return path
24-
if "electrifying sentence" in u: return reason
25-
if "continues from exactly where" in u: return cont
26-
if "does not parse" in u: return fix
27-
return write
22+
return route(messages)
2823

2924
generate.calls = calls
3025
return generate
@@ -33,6 +28,33 @@ def generate(messages, *, num_predict=None, temperature=None):
3328
BIG = 10 ** 9 # effectively no deadline for the fast unit loops
3429

3530

31+
# --- issue shuffling: ALL issues surface, in a wandering order ---------------
32+
def test_collect_issue_list_includes_every_issue(monkeypatch):
33+
fake = [{"number": i, "title": f"t{i}", "body": "", "labels": []} for i in range(6)]
34+
monkeypatch.setattr(improve, "_fetch_issues", lambda: fake)
35+
items = improve.collect_issue_list()
36+
assert len(items) == 6
37+
for i in range(6):
38+
assert any(f"#{i} t{i}" in it for it in items) # not just one issue — all of them
39+
40+
41+
def test_collect_issue_list_actually_shuffles(monkeypatch):
42+
fake = [{"number": i, "title": f"t{i}", "body": "", "labels": []} for i in range(6)]
43+
monkeypatch.setattr(improve, "_fetch_issues", lambda: fake)
44+
orders = set()
45+
for seed in range(6):
46+
improve._RNG.seed(seed)
47+
items = improve.collect_issue_list()
48+
orders.add(tuple(int(re.search(r"#(\d+)", it).group(1)) for it in items))
49+
assert len(orders) > 1 # different seeds → different orders → it really shuffles
50+
51+
52+
def test_interleave_alternates_then_trails():
53+
assert improve._interleave([1, 3], [2, 4]) == [1, 2, 3, 4]
54+
assert improve._interleave([1], [2, 4, 6]) == [1, 2, 4, 6]
55+
assert improve._interleave([], [2]) == [2]
56+
57+
3658
# --- path coercion -----------------------------------------------------------
3759
def test_bare_directory_becomes_a_file(scratch):
3860
scratch()
@@ -45,145 +67,133 @@ def test_traversal_is_contained(scratch):
4567
assert improve.coerce_to_src("../../etc/passwd", "data").is_relative_to(improve.SRC_DIR)
4668

4769

48-
def test_absolute_path_is_contained(scratch):
49-
scratch()
50-
assert improve.coerce_to_src("/etc/shadow", "data").is_relative_to(improve.SRC_DIR)
51-
52-
53-
# --- content validation ------------------------------------------------------
54-
def test_valid_python_passes(scratch):
55-
scratch()
56-
assert improve.content_problems(improve.SRC_DIR / "m.py", "def f():\n return 1\n") == []
57-
58-
70+
# --- content validation + code extraction ------------------------------------
5971
def test_broken_python_is_flagged(scratch):
6072
scratch()
6173
probs = improve.content_problems(improve.SRC_DIR / "m.py", "def f():\n return +\n")
6274
assert len(probs) == 1 and "syntax error" in probs[0].lower()
6375

6476

65-
def test_markdown_is_not_syntax_checked(scratch):
66-
scratch()
67-
assert improve.content_problems(improve.SRC_DIR / "notes.md", "def not python (((") == []
68-
69-
70-
# --- code extraction (model wraps in fences despite instructions) ------------
71-
def test_strip_code_fenced():
77+
def test_strip_code_fenced_and_dangling():
7278
assert improve._strip_code("```python\nX = 1\n```") == "X = 1"
73-
74-
75-
def test_strip_code_dangling_open_fence():
7679
assert improve._strip_code("```python\nX = 1") == "X = 1"
77-
78-
79-
def test_strip_code_raw():
8080
assert improve._strip_code("X = 1\n") == "X = 1"
8181

8282

83-
# --- truncation vs. logic error ---------------------------------------------
84-
def test_truncation_detected_for_unclosed_bracket():
85-
code = "x = [1, 2,"
86-
assert improve._is_truncation(code, improve._syntax_error(code))
87-
88-
89-
def test_truncation_detected_for_unterminated_paren_midfile():
90-
code = "def f(start):\n total = (start +"
91-
assert improve._is_truncation(code, improve._syntax_error(code))
92-
83+
def test_truncation_vs_logic_error():
84+
assert improve._is_truncation("x = [1, 2,", improve._syntax_error("x = [1, 2,"))
85+
broke = "def f(:\n return 1"
86+
assert not improve._is_truncation(broke, improve._syntax_error(broke))
9387

94-
def test_logic_error_is_not_truncation():
95-
code = "def f(:\n return 1" # malformed signature, complete file
96-
e = improve._syntax_error(code)
97-
assert e is not None and not improve._is_truncation(code, e)
9888

99-
100-
# --- choose the target file (a separate, constrained call) -------------------
89+
# --- choosing the target (informed by one inspiration) -----------------------
10190
def test_choose_target_extracts_path(scratch):
10291
scratch({"src/mechanism.py": ""})
103-
t = improve.choose_target(lambda m, **k: "I shall forge src/mechanism.py today", "t", "s", "i")
92+
t = improve.choose_target(lambda m, **k: "src/mechanism.py", "tree", ("issue", "#1 do x"), [])
10493
assert t.name == "mechanism.py" and t.is_relative_to(improve.SRC_DIR)
10594

10695

107-
def test_choose_target_can_create_a_new_file(scratch):
96+
def test_choose_target_can_create_new_file(scratch):
10897
scratch({"src/mechanism.py": ""})
109-
t = improve.choose_target(lambda m, **k: "src/orrery.py", "t", "s", "i")
110-
assert t.name == "orrery.py" and not t.exists() # a NEW module, created later by write
111-
assert t.is_relative_to(improve.SRC_DIR)
112-
113-
114-
def test_choose_target_defaults_on_junk(scratch):
115-
scratch()
116-
t = improve.choose_target(lambda m, **k: "no path here!!", "t", "s", "i")
117-
assert t.suffix == ".py" and t.is_relative_to(improve.SRC_DIR)
98+
t = improve.choose_target(lambda m, **k: "src/orrery.py", "tree", ("issue", "#1"), [])
99+
assert t.name == "orrery.py" and not t.exists() and t.is_relative_to(improve.SRC_DIR)
118100

119101

120-
def test_choose_target_forces_py_extension(scratch):
102+
def test_choose_target_forces_py_and_defaults_on_junk(scratch):
121103
scratch()
122-
t = improve.choose_target(lambda m, **k: "src/notes", "t", "s", "i")
123-
assert t.suffix == ".py"
104+
assert improve.choose_target(lambda m, **k: "src/notes", "t", ("issue", "x"), []).suffix == ".py"
105+
assert improve.choose_target(lambda m, **k: "garbage", "t", ("issue", "x"), []).suffix == ".py"
124106

125107

126-
# --- grow the code: write → continue (truncated) / fix (broken) --------------
108+
# --- growing a file: write → continue (truncated) / fix (broken) -------------
127109
def test_grow_file_valid_on_first_write(scratch):
128110
scratch()
129-
target = improve.SRC_DIR / "m.py"
130-
g = oracle_fake(write="VALUE = 42\n")
131-
code, last = improve.grow_file(g, target, "t", "s", "i", deadline=BIG, max_rounds=4)
132-
assert improve.content_problems(target, code) == []
133-
assert len(g.calls) == 1 # no repair needed
111+
g = model(lambda m: "VALUE = 42\n")
112+
code, last = improve.grow_file(g, improve.SRC_DIR / "m.py", "t", ("issue", "x"), "",
113+
deadline=BIG, max_rounds=4)
114+
assert improve.content_problems(improve.SRC_DIR / "m.py", code) == [] and len(g.calls) == 1
134115

135116

136117
def test_grow_file_continues_a_truncated_draft(scratch):
137118
scratch()
138-
target = improve.SRC_DIR / "m.py"
139-
g = oracle_fake(write="def tooth_count(start=13):\n total = (start +",
140-
cont=" 1)\n return total\n")
141-
code, last = improve.grow_file(g, target, "t", "s", "i", deadline=BIG, max_rounds=4)
142-
assert improve.content_problems(target, code) == []
119+
120+
def route(m):
121+
if "continues from exactly where" in m[-1]["content"]:
122+
return " 1)\n return total\n"
123+
return "def tooth_count(start=13):\n total = (start +"
124+
125+
code, _ = improve.grow_file(model(route), improve.SRC_DIR / "m.py", "t", ("issue", "x"), "",
126+
deadline=BIG, max_rounds=4)
127+
assert improve.content_problems(improve.SRC_DIR / "m.py", code) == []
143128
assert "return total" in code
144129

145130

146131
def test_grow_file_fixes_a_broken_draft(scratch):
147132
scratch()
148-
target = improve.SRC_DIR / "m.py"
149-
g = oracle_fake(write="def f(:\n return 1", fix="def f():\n return 1\n")
150-
code, last = improve.grow_file(g, target, "t", "s", "i", deadline=BIG, max_rounds=4)
151-
assert improve.content_problems(target, code) == []
152-
# it took the FIX path (not continue), proving the classification routed correctly
153-
assert any("does not parse" in m[-1]["content"] for m in g.calls)
154133

134+
def route(m):
135+
return "def f():\n return 1\n" if "does not parse" in m[-1]["content"] else "def f(:\n return 1"
155136

156-
# --- orchestration -----------------------------------------------------------
157-
def test_generate_improvement_end_to_end(scratch):
158-
scratch({"src/mechanism.py": ""})
159-
g = oracle_fake(path="src/mechanism.py", write="VALUE = 42\n", reason="Forge the back dial")
160-
reason, files, last, valid = improve.generate_improvement(g, "t", "s", "i", deadline_seconds=999)
161-
assert valid and len(files) == 1
162-
target, code = files[0]
163-
assert target.name == "mechanism.py" and code.strip() == "VALUE = 42"
164-
assert reason == "Forge the back dial"
165-
written = improve.write_blocks(files)
166-
ast.parse((improve.SRC_DIR / "mechanism.py").read_text())
167-
improve.write_pr_outputs(reason, written, valid=valid)
168-
assert improve.PR_TITLE_PATH.read_text().strip()
169-
assert "Vision" in improve.PR_BODY_PATH.read_text()
170-
171-
172-
def test_generate_improvement_reports_invalid_when_unrepairable(scratch):
173-
scratch()
174-
# always-broken write, and continue/fix also broken → never parses
175-
g = oracle_fake(write="def f(:", fix="def f(:", cont="def f(:")
137+
g = model(route)
138+
code, _ = improve.grow_file(g, improve.SRC_DIR / "m.py", "t", ("issue", "x"), "",
139+
deadline=BIG, max_rounds=4)
140+
assert improve.content_problems(improve.SRC_DIR / "m.py", code) == []
141+
assert any("does not parse" in m[-1]["content"] for m in g.calls) # took the FIX path
142+
143+
144+
# --- orchestration: MANY files across MANY inspirations ----------------------
145+
def test_generates_multiple_files_across_inspirations(scratch):
146+
scratch({"src/mech.py": "X = 1\n"})
147+
148+
def route(m):
149+
u = m[-1]["content"]
150+
if "electrifying sentence" in u:
151+
return "A vision spanning several files"
152+
if "Pick the ONE file" in u:
153+
return "src/from_issue.py" if "An open issue" in u else "src/from_source.py"
154+
return "VALUE = 1\n" # every write/fix produces valid code
155+
156+
g = model(route)
176157
reason, files, last, valid = improve.generate_improvement(
177-
g, "t", "s", "i", deadline_seconds=999, max_rounds=3)
178-
assert files and valid is False # ships best effort, flagged not-valid
158+
g, "tree", [("src/mech.py", "X = 1\n")], ["#7 forge a gear", "#9 wind the dial"],
159+
deadline_seconds=999, max_files=5)
160+
rels = {t.name for t, _ in files}
161+
assert {"from_issue.py", "from_source.py"} <= rels # both issue- and source-driven files
162+
assert len(files) >= 2 and valid
163+
assert reason == "A vision spanning several files"
164+
165+
166+
def test_respects_max_files(scratch):
167+
scratch()
168+
route = lambda m: ("one" if "electrifying" in m[-1]["content"]
169+
else f"src/f{len(m)}.py" if "Pick the ONE file" in m[-1]["content"]
170+
else "V = 1\n")
171+
# many inspirations, but cap at 1 file
172+
_, files, _, _ = improve.generate_improvement(
173+
model(route), "t", [], ["#1", "#2", "#3", "#4"], deadline_seconds=999, max_files=1)
174+
assert len(files) == 1
175+
176+
177+
def test_reports_invalid_when_unrepairable(scratch):
178+
scratch()
179+
180+
def route(m):
181+
u = m[-1]["content"]
182+
if "Pick the ONE file" in u: return "src/broken.py"
183+
if "electrifying sentence" in u: return "a flawed vision"
184+
return "def f(:" # never parses, in any mode
185+
186+
_, files, _, valid = improve.generate_improvement(
187+
model(route), "t", [], ["#1"], deadline_seconds=999, max_files=1, max_rounds=3)
188+
assert files and valid is False # ships best effort, flagged invalid
179189

180190

181191
def test_deadline_guard_makes_no_model_calls():
182192
def boom(*a, **k):
183193
raise AssertionError("model called past deadline")
184-
clock = iter([0, 100]) # now()=0 sets deadline=50; next now()=100 is past it
185-
reason, files, last, valid = improve.generate_improvement(
186-
boom, "t", "s", "i", deadline_seconds=50, now=lambda: next(clock))
194+
clock = iter([0, 100]) # now()=0 sets deadline=50; next now()=100 is past it
195+
_, files, _, valid = improve.generate_improvement(
196+
boom, "t", [("src/a.py", "x")], ["#1"], deadline_seconds=50, now=lambda: next(clock))
187197
assert files == [] and valid is False
188198

189199

@@ -195,15 +205,10 @@ def test_ollama_generate_builds_request_and_parses(monkeypatch):
195205
class FakeResp:
196206
def __enter__(self): return self
197207
def __exit__(self, *a): return False
198-
def read(self): return json.dumps({"message": {"content": "the gear turns"}}).encode()
199-
200-
def fake_urlopen(req, timeout=None):
201-
captured["url"] = req.full_url
202-
captured["body"] = json.loads(req.data)
203-
return FakeResp()
204-
205-
monkeypatch.setattr(improve.urllib.request, "urlopen", fake_urlopen)
206-
out = improve.ollama_generate([{"role": "user", "content": "hi"}], temperature=0.4, num_predict=7)
207-
assert out == "the gear turns"
208-
assert captured["url"].endswith("/api/chat")
209-
assert captured["body"]["options"]["num_predict"] == 7
208+
def read(self): return json.dumps({"message": {"content": "ok"}}).encode()
209+
210+
monkeypatch.setattr(improve.urllib.request, "urlopen",
211+
lambda req, timeout=None: captured.update(body=json.loads(req.data),
212+
url=req.full_url) or FakeResp())
213+
assert improve.ollama_generate([{"role": "user", "content": "hi"}], num_predict=7) == "ok"
214+
assert captured["url"].endswith("/api/chat") and captured["body"]["options"]["num_predict"] == 7
Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
"""Integration tests that drive the ACTUAL local Ollama model.
22
3-
Kept fast and bounded: tiny ``num_predict`` (the file is grown iteratively, so
4-
small steps are fine), a short internal deadline, and a hard 60s per-test
5-
timeout. The ``model`` fixture pulls the model if it isn't present; if no server
6-
is reachable these skip.
3+
Kept fast and bounded: tiny ``num_predict`` (the file is grown iteratively),
4+
a short internal deadline, ``max_files=1`` so a test touches one file, and a
5+
hard 60s per-test timeout. The multi-file / interleave / shuffle orchestration
6+
is covered deterministically in test_improve.py. The ``model`` fixture pulls the
7+
model if absent; if no server is reachable these skip.
78
89
pdm run pytest -m integration -v
910
"""
@@ -20,39 +21,31 @@
2021
DEADLINE = 45 # internal soft budget — stays under the 60s hard timeout
2122

2223

23-
def _ctx():
24-
return improve.repo_tree(), improve.collect_source(), "(no open issues)"
25-
26-
2724
def test_real_choose_target_returns_a_py_path(model, quick_budget, scratch):
2825
scratch({"src/mechanism.py": "", "src/back_dial.py": ""})
29-
target = improve.choose_target(improve.ollama_generate, *_ctx())
26+
target = improve.choose_target(
27+
improve.ollama_generate, improve.repo_tree(), ("issue", "#1 add a gear counter"), [])
3028
assert target.suffix == ".py" and target.is_relative_to(improve.SRC_DIR)
3129

3230

3331
def test_real_generates_valid_python(model, quick_budget, scratch):
3432
scratch({"src/mechanism.py": "", "src/main.py": ""})
35-
tree, source, issues = _ctx()
3633
reason, files, last, valid = improve.generate_improvement(
37-
improve.ollama_generate, tree, source, issues, deadline_seconds=DEADLINE)
34+
improve.ollama_generate, improve.repo_tree(), improve.source_files(), [],
35+
deadline_seconds=DEADLINE, max_files=1)
3836
assert files, f"no file produced; last response:\n{last[:400]}"
3937
target, code = files[0]
4038
assert target.suffix == ".py" and target.is_relative_to(improve.SRC_DIR)
4139
assert valid, f"{target.name} did not parse:\n{code[:400]}"
4240
ast.parse(code)
4341

4442

45-
def test_real_writes_code_despite_a_distracting_recipe(model, quick_budget, scratch):
46-
"""The regression empty-repo tests missed: an existing recipe used to derail
47-
the model into copying it. It must still produce valid Python."""
48-
scratch({
49-
"src/recipes/banana_pudding.md": "# Banana Pudding\n\nEggs, butter, bananas, milk...\n",
50-
"src/mechanism.py": "",
51-
})
52-
tree, source, issues = _ctx()
43+
def test_real_answers_an_issue_with_valid_python(model, quick_budget, scratch):
44+
scratch({"src/mechanism.py": ""})
5345
reason, files, last, valid = improve.generate_improvement(
54-
improve.ollama_generate, tree, source, issues, deadline_seconds=DEADLINE)
46+
improve.ollama_generate, improve.repo_tree(), improve.source_files(),
47+
["#1 Add a function that counts the teeth on a gear"],
48+
deadline_seconds=DEADLINE, max_files=1)
5549
assert files, f"no file produced; last:\n{last[:400]}"
5650
target, code = files[0]
57-
assert target.suffix == ".py", f"wrote {target.name}, not Python"
58-
assert valid, f"{target.name} did not parse:\n{code[:400]}"
51+
assert target.suffix == ".py" and valid, f"{target.name} invalid:\n{code[:400]}"

0 commit comments

Comments
 (0)