Skip to content

Commit c93f0e1

Browse files
Merge pull request #33 from SaridakisStamatisChristos/codex/add-hypothesis-tests-and-fuzz-script
Add Hypothesis property tests and fuzz harness
2 parents 6ace678 + 90a2550 commit c93f0e1

File tree

6 files changed

+129
-1
lines changed

6 files changed

+129
-1
lines changed

bench/fuzz_quick.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""
2+
Quick manual fuzz: generate and solve a bunch of puzzles.
3+
Run locally: python bench/fuzz_quick.py --n 50
4+
"""
5+
import argparse, random
6+
from sudoku_dlx import generate, solve, count_solutions
7+
8+
9+
def main():
10+
ap = argparse.ArgumentParser()
11+
ap.add_argument("--n", type=int, default=50)
12+
ap.add_argument("--givens", type=int, default=34)
13+
ap.add_argument("--minimal", action="store_true")
14+
ns = ap.parse_args()
15+
rng = random.Random(42)
16+
ok = 0
17+
for i in range(ns.n):
18+
g = generate(
19+
seed=rng.randrange(2**31 - 1),
20+
target_givens=ns.givens,
21+
minimal=ns.minimal,
22+
symmetry="none",
23+
)
24+
# must be at least uniquely solvable
25+
assert count_solutions(g, limit=2) == 1
26+
res = solve([row[:] for row in g])
27+
assert res is not None
28+
ok += 1
29+
print(f"OK: {ok}/{ns.n}")
30+
31+
32+
if __name__ == "__main__":
33+
main()

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ sudoku-dlx = "sudoku_dlx.cli:main"
2727
sudoku-dlx = "sudoku_dlx.cli:main"
2828

2929
[project.optional-dependencies]
30-
dev = ["pytest>=8", "pytest-cov>=5", "ruff>=0.6", "black>=24.8", "pre-commit>=3.7"]
30+
dev = ["pytest>=8", "pytest-cov>=5", "ruff>=0.6", "black>=24.8", "pre-commit>=3.7", "hypothesis>=6.113"]
3131

3232
[project.urls]
3333
Homepage = "https://github.com/SaridakisStamatisChristos/sudoku_dlx"
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from hypothesis import given, settings, strategies as st
2+
from sudoku_dlx import from_string, canonical_form
3+
4+
# Keep canonical check cheap: compare original vs rot180 isomorph
5+
@settings(max_examples=10, deadline=None)
6+
@given(st.lists(st.sampled_from(list(".123456789")), min_size=81, max_size=81))
7+
def test_canonical_invariant_under_rot180(xs):
8+
s = "".join(xs)
9+
g = from_string(s)
10+
# build rot180 string
11+
s2 = "".join(s[::-1])
12+
g2 = from_string(s2)
13+
assert canonical_form(g) == canonical_form(g2)
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from sudoku_dlx import generate, count_solutions
2+
3+
4+
def _is_minimal_strict(g) -> bool:
5+
for r in range(9):
6+
for c in range(9):
7+
if g[r][c] == 0:
8+
continue
9+
keep = g[r][c]
10+
g[r][c] = 0
11+
uniq = count_solutions(g, limit=2) == 1
12+
g[r][c] = keep
13+
if uniq:
14+
return False
15+
return True
16+
17+
18+
def test_generate_minimal_unique_fast_settings():
19+
# modest givens to keep runtime under control in CI
20+
for seed in [5, 9]:
21+
p = generate(seed=seed, target_givens=36, minimal=True, symmetry="none")
22+
assert count_solutions(p, limit=2) == 1
23+
assert _is_minimal_strict(p)

tests/test_prop_parse.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from hypothesis import given, settings, strategies as st
2+
from sudoku_dlx import from_string, to_string
3+
4+
# Allowed characters for parser (blanks + digits)
5+
chars = st.sampled_from(list(".0123456789"))
6+
7+
8+
@settings(max_examples=30, deadline=None)
9+
@given(st.lists(chars, min_size=81, max_size=81))
10+
def test_from_to_string_round_trip_preserves_clues(xs):
11+
s = "".join(xs)
12+
g = from_string(s)
13+
s2 = to_string(g)
14+
assert len(s2) == 81
15+
# any non-blank in input stays same in output at same index
16+
for i, ch in enumerate(s):
17+
if ch in "123456789":
18+
assert s2[i] == ch
19+
else:
20+
assert s2[i] == "." # blanks normalize to '.'
21+
22+
23+
# Mix in arbitrary whitespace; parser should ignore it
24+
ws = st.text(alphabet=st.sampled_from(list(" \t\r\n")), min_size=0, max_size=20)
25+
26+
27+
@settings(max_examples=20, deadline=None)
28+
@given(st.lists(chars, min_size=81, max_size=81), ws, ws)
29+
def test_parser_ignores_whitespace(xs, pre, post):
30+
s = pre + "".join(xs) + post
31+
g = from_string(s)
32+
assert len(to_string(g)) == 81
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from sudoku_dlx import generate, solve
2+
3+
4+
def _is_full_valid(grid):
5+
# rows/cols/boxes each contain digits 1..9
6+
rows = [{grid[r][c] for c in range(9)} for r in range(9)]
7+
cols = [{grid[r][c] for r in range(9)} for c in range(9)]
8+
boxes = [
9+
{grid[r + i][c + j] for i in range(3) for j in range(3)}
10+
for r in (0, 3, 6)
11+
for c in (0, 3, 6)
12+
]
13+
expect = set(range(1, 10))
14+
return all(s == expect for s in rows + cols + boxes)
15+
16+
17+
def test_solve_idempotent_and_valid_small_seedset():
18+
# keep seeds small for CI speed
19+
for seed in [1, 3, 7]:
20+
p = generate(seed=seed, target_givens=34, minimal=False, symmetry="none")
21+
res1 = solve([row[:] for row in p])
22+
assert res1 is not None
23+
assert _is_full_valid(res1.grid)
24+
# solving a solved grid should be a no-op
25+
res2 = solve([row[:] for row in res1.grid])
26+
assert res2 is not None
27+
assert res2.grid == res1.grid

0 commit comments

Comments
 (0)