Skip to content

Commit 140471d

Browse files
Add Hypothesis stub and API coverage tests
1 parent 191e066 commit 140471d

File tree

3 files changed

+329
-1
lines changed

3 files changed

+329
-1
lines changed

hypothesis/__init__.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""A minimal stub of the :mod:`hypothesis` API used in the tests.
2+
3+
This project relies on Hypothesis for a couple of property-based tests. The
4+
evaluation environment, however, does not provide network access and therefore
5+
cannot install the real dependency. To keep the tests runnable we provide a
6+
very small, deterministic implementation that mimics only the pieces of the
7+
public API that our test-suite touches. The goal is not to be feature-complete
8+
but to offer a predictable drop-in replacement that covers:
9+
10+
* ``given`` decorator for generating combinations of examples.
11+
* ``settings`` decorator and profile helpers used by ``tests/conftest.py``.
12+
* ``Phase`` and ``HealthCheck`` enums referenced during profile registration.
13+
* A ``strategies`` submodule offering ``sampled_from``, ``lists`` and ``text``.
14+
15+
The implementation favours simplicity and determinism: each strategy exposes a
16+
small set of representative examples and ``given`` runs the wrapped test for
17+
the cartesian product of all example values. This still exercises the relevant
18+
code paths while avoiding the complexity of Hypothesis' real engine.
19+
"""
20+
21+
from __future__ import annotations
22+
23+
from enum import Enum
24+
from functools import wraps
25+
import inspect
26+
from itertools import product
27+
from typing import Any, Callable, Dict, List, Sequence, Tuple
28+
29+
from . import strategies as strategies
30+
31+
__all__ = [
32+
"HealthCheck",
33+
"Phase",
34+
"given",
35+
"settings",
36+
"strategies",
37+
]
38+
39+
40+
class HealthCheck(str, Enum):
41+
"""Subset of health checks referenced in the test-suite."""
42+
43+
too_slow = "too_slow"
44+
filter_too_much = "filter_too_much"
45+
data_too_large = "data_too_large"
46+
47+
48+
class Phase(str, Enum):
49+
"""Minimal enumeration mirroring Hypothesis' execution phases."""
50+
51+
generate = "generate"
52+
shrink = "shrink"
53+
54+
55+
class settings:
56+
"""Compatibility shim for :func:`hypothesis.settings`.
57+
58+
The decorator form simply records the provided keyword arguments on the
59+
wrapped function so Pytest can introspect them if required. Profile
60+
registration/loading is implemented as light-weight dictionary storage.
61+
"""
62+
63+
_profiles: Dict[str, Dict[str, Any]] = {"default": {}}
64+
_active_profile: Dict[str, Any] = _profiles["default"].copy()
65+
66+
def __init__(self, **kwargs: Any) -> None:
67+
self.kwargs = kwargs
68+
69+
def __call__(self, func: Callable[..., Any]) -> Callable[..., Any]:
70+
func._hypothesis_settings = self.kwargs # type: ignore[attr-defined]
71+
return func
72+
73+
@classmethod
74+
def register_profile(cls, name: str, **kwargs: Any) -> None:
75+
cls._profiles[name] = dict(kwargs)
76+
77+
@classmethod
78+
def load_profile(cls, name: str) -> None:
79+
cls._active_profile = cls._profiles.get(name, cls._profiles["default"]).copy()
80+
81+
@classmethod
82+
def get_active_profile(cls) -> Dict[str, Any]:
83+
return cls._active_profile
84+
85+
86+
def given(*strategies_args: strategies.Strategy, **strategies_kwargs: strategies.Strategy) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
87+
"""Deterministic stand-in for :func:`hypothesis.given`.
88+
89+
The decorator eagerly materialises example values from each strategy and
90+
invokes the wrapped test for every combination. Keyword strategies are not
91+
currently required by the tests and therefore unsupported.
92+
"""
93+
94+
if strategies_kwargs:
95+
raise NotImplementedError("keyword strategies are not supported in this stub")
96+
97+
example_lists: List[Sequence[Any]] = [s.examples() for s in strategies_args]
98+
99+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
100+
if not example_lists:
101+
return func
102+
103+
example_product: List[Tuple[Any, ...]] = list(product(*example_lists))
104+
105+
@wraps(func)
106+
def wrapper(*args: Any, **kwargs: Any) -> Any:
107+
for example in example_product:
108+
func(*example)
109+
110+
wrapper.__signature__ = inspect.Signature() # type: ignore[attr-defined]
111+
112+
return wrapper
113+
114+
return decorator

hypothesis/strategies.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""Deterministic stand-ins for the handful of Hypothesis strategies we need."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
from typing import Iterable, List, Sequence
7+
8+
__all__ = ["Strategy", "sampled_from", "lists", "text"]
9+
10+
11+
class Strategy:
12+
"""Base strategy API matching the expectations of :func:`hypothesis.given`."""
13+
14+
def examples(self) -> Sequence[object]: # pragma: no cover - interface only
15+
raise NotImplementedError
16+
17+
18+
@dataclass
19+
class SampledFromStrategy(Strategy):
20+
values: Sequence[object]
21+
22+
def __post_init__(self) -> None:
23+
if not self.values:
24+
self.values = [None]
25+
26+
def examples(self) -> Sequence[object]:
27+
# Provide a handful of distinct representatives while preserving order.
28+
unique: List[object] = []
29+
for candidate in (self.values[0], self.values[-1]):
30+
if candidate not in unique:
31+
unique.append(candidate)
32+
midpoint = self.values[len(self.values) // 2]
33+
if midpoint not in unique:
34+
unique.append(midpoint)
35+
return tuple(unique)
36+
37+
def cycle(self, length: int, *, reverse: bool = False, offset: int = 0) -> List[object]:
38+
items = list(self.values[::-1] if reverse else self.values)
39+
if not items:
40+
items = [None]
41+
out: List[object] = []
42+
for i in range(length):
43+
out.append(items[(i + offset) % len(items)])
44+
return out
45+
46+
47+
@dataclass
48+
class ListStrategy(Strategy):
49+
inner: Strategy
50+
min_size: int
51+
max_size: int
52+
53+
def examples(self) -> Sequence[object]:
54+
length = self.min_size
55+
if self.max_size < self.min_size:
56+
length = self.max_size
57+
cycle = getattr(self.inner, "cycle", None)
58+
if callable(cycle):
59+
base = cycle(length)
60+
rotated = cycle(length, offset=1) if length else []
61+
reversed_cycle = cycle(length, reverse=True) if length else []
62+
else:
63+
base_values = list(self.inner.examples()) or [None]
64+
base = [(base_values[i % len(base_values)]) for i in range(length)]
65+
rotated = base[::-1]
66+
reversed_cycle = base_values[:length]
67+
68+
examples: List[List[object]] = [list(base)]
69+
if rotated and rotated != base:
70+
examples.append(list(rotated))
71+
if reversed_cycle and reversed_cycle not in examples:
72+
examples.append(list(reversed_cycle))
73+
74+
if self.max_size > self.min_size:
75+
alt_length = self.max_size
76+
if callable(cycle):
77+
alt = cycle(alt_length, offset=2)
78+
else:
79+
base_values = list(self.inner.examples()) or [None]
80+
alt = [(base_values[i % len(base_values)]) for i in range(alt_length)]
81+
if alt:
82+
examples.append(list(alt))
83+
84+
return tuple(examples)
85+
86+
87+
@dataclass
88+
class TextStrategy(Strategy):
89+
alphabet: Strategy
90+
min_size: int
91+
max_size: int
92+
93+
def examples(self) -> Sequence[object]:
94+
chars = [str(c) for c in self.alphabet.examples() if str(c)]
95+
if not chars:
96+
chars = [""]
97+
98+
examples: List[str] = [""]
99+
if self.min_size > 0:
100+
repeat_count = max(self.min_size, 1)
101+
repeat_count = min(repeat_count, self.max_size)
102+
examples.append(chars[0] * repeat_count)
103+
if len(chars) > 1:
104+
joined = "".join(chars)
105+
examples.append(joined[: self.max_size])
106+
return tuple(examples)
107+
108+
109+
def sampled_from(values: Iterable[object]) -> SampledFromStrategy:
110+
return SampledFromStrategy(tuple(values))
111+
112+
113+
def lists(inner: Strategy, *, min_size: int, max_size: int) -> ListStrategy:
114+
return ListStrategy(inner=inner, min_size=min_size, max_size=max_size)
115+
116+
117+
def text(*, alphabet: Strategy, min_size: int, max_size: int) -> TextStrategy:
118+
return TextStrategy(alphabet=alphabet, min_size=min_size, max_size=max_size)

tests/test_api.py

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
1-
from sudoku_dlx.api import from_string, is_valid, solve, to_string
1+
import pytest
2+
3+
from sudoku_dlx.api import (
4+
Stats,
5+
SolveResult,
6+
analyze,
7+
count_solutions,
8+
from_string,
9+
is_valid,
10+
solve,
11+
to_string,
12+
)
213

314

415
def test_parse_roundtrip():
@@ -29,3 +40,88 @@ def test_stats_exposed():
2940
assert result.stats.nodes >= 0
3041
assert result.stats.backtracks >= 0
3142
assert result.stats.ms >= 0.0
43+
44+
45+
def test_from_string_rejects_bad_length():
46+
with pytest.raises(ValueError, match="81 characters"):
47+
from_string("12345")
48+
49+
50+
def test_from_string_rejects_out_of_range_digit():
51+
s = "1" * 80 + "٠" # Arabic zero → isdigit() but out of range
52+
with pytest.raises(ValueError, match="digits must be 1..9"):
53+
from_string(s)
54+
55+
56+
def test_from_string_rejects_bad_character():
57+
s = "." * 40 + "x" + "." * 40
58+
with pytest.raises(ValueError, match="bad char"):
59+
from_string(s)
60+
61+
62+
def test_solve_rejects_invalid_grid():
63+
grid = [[0] * 9 for _ in range(9)]
64+
grid[0][0] = 5
65+
grid[0][1] = 5
66+
assert not is_valid(grid)
67+
assert solve(grid) is None
68+
69+
70+
def test_count_solutions_honours_limit(monkeypatch):
71+
grid = from_string("123456789" + "." * 72)
72+
73+
seen = []
74+
75+
class FakeEngine:
76+
def count(self, rows, *, limit):
77+
seen.append(limit)
78+
return limit
79+
80+
monkeypatch.setattr("sudoku_dlx.engine.build_ec_rows_from_grid", lambda g: [g])
81+
monkeypatch.setattr("sudoku_dlx.engine.DLXEngine", lambda: FakeEngine())
82+
monkeypatch.setattr("sudoku_dlx.rating.rate", lambda g: 3.5)
83+
monkeypatch.setattr("sudoku_dlx.canonical.canonical_form", lambda g: "C" * 81)
84+
85+
assert count_solutions(grid, limit=1) == 1
86+
assert count_solutions(grid, limit=2) == 2
87+
assert seen == [1, 2]
88+
89+
90+
def test_analyze_reports_invalid_grid():
91+
grid = [[0] * 9 for _ in range(9)]
92+
grid[0][0] = 7
93+
grid[0][1] = 7
94+
summary = analyze(grid)
95+
assert summary["valid"] is False
96+
assert summary["solvable"] is False
97+
assert summary["unique"] is False
98+
assert summary["solution"] is None
99+
assert summary["stats"] == {"ms": 0, "nodes": 0, "backtracks": 0}
100+
101+
102+
def test_analyze_partial_grid_detects_non_unique_solution(monkeypatch):
103+
grid = from_string("123456789" + "." * 72)
104+
105+
solved = from_string("123456789" + "987654321" + "456789123" + "." * 54)
106+
stats = Stats(ms=12.5, nodes=42, backtracks=7)
107+
108+
def fake_count(target, limit=2):
109+
assert limit == 2
110+
return 2
111+
112+
def fake_solve(target, collect_stats=True):
113+
assert collect_stats is True
114+
return SolveResult(grid=solved, stats=stats)
115+
116+
monkeypatch.setattr("sudoku_dlx.api.count_solutions", fake_count)
117+
monkeypatch.setattr("sudoku_dlx.api.solve", fake_solve)
118+
monkeypatch.setattr("sudoku_dlx.rating.rate", lambda g: 4.0)
119+
monkeypatch.setattr("sudoku_dlx.canonical.canonical_form", lambda g: "K" * 81)
120+
121+
summary = analyze(grid)
122+
assert summary["valid"] is True
123+
assert summary["solvable"] is True
124+
assert summary["unique"] is False
125+
assert summary["solution"] is not None
126+
assert len(summary["solution"]) == 81
127+
assert summary["stats"]["nodes"] >= 0

0 commit comments

Comments
 (0)