mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-08-16 13:53:53 +08:00
241 lines
8.9 KiB
Python
241 lines
8.9 KiB
Python
import math
|
|
import os
|
|
import random
|
|
import secrets
|
|
import struct
|
|
from collections.abc import Callable, Sequence
|
|
from functools import wraps
|
|
from typing import Any, TypeVar
|
|
|
|
import capnp
|
|
|
|
|
|
T = TypeVar("T")
|
|
|
|
_EDGE_SLOTS = 16
|
|
_MINIMAL_EXAMPLES = 10
|
|
_INTEGER_RANGES = {
|
|
"int8": (-2**7, 2**7 - 1),
|
|
"int16": (-2**15, 2**15 - 1),
|
|
"int32": (-2**31, 2**31 - 1),
|
|
"int64": (-2**63, 2**63 - 1),
|
|
"uint8": (0, 2**8 - 1),
|
|
"uint16": (0, 2**16 - 1),
|
|
"uint32": (0, 2**32 - 1),
|
|
"uint64": (0, 2**64 - 1),
|
|
}
|
|
|
|
# One seed is shared by the whole test process. Individual tests derive their seed
|
|
# from their unittest ID, so FUZZ_SEED is reproducible under the parallel runner too.
|
|
FUZZ_SEED = int(os.environ.get("FUZZ_SEED", secrets.randbits(64)))
|
|
|
|
|
|
class Fuzzy:
|
|
"""Fast, deterministic data generator with systematic boundary coverage."""
|
|
|
|
def __init__(self, seed: int | str, example_index: int):
|
|
self.example_index = example_index
|
|
self._random = random.Random(seed)
|
|
self._draw_index = 0
|
|
|
|
def _draw(self, edges: Sequence[T], random_value: Callable[[], T]) -> T:
|
|
draw_index = self._draw_index
|
|
self._draw_index += 1
|
|
|
|
# Preserve the cheap minimal prefix Hypothesis produced, then interleave
|
|
# systematic boundaries and random values at every draw site.
|
|
if self.example_index < _MINIMAL_EXAMPLES:
|
|
return edges[0]
|
|
search_example = self.example_index - _MINIMAL_EXAMPLES
|
|
if search_example < _EDGE_SLOTS * 2 and search_example % 2 == 0:
|
|
return edges[(search_example // 2 + draw_index) % len(edges)]
|
|
if self._random.randrange(4) == 0:
|
|
return self._random.choice(edges)
|
|
return random_value()
|
|
|
|
def boolean(self) -> bool:
|
|
return self._draw((False, True), lambda: bool(self._random.getrandbits(1)))
|
|
|
|
def choice(self, values: Sequence[T]) -> T:
|
|
if not values:
|
|
raise ValueError("cannot choose from an empty sequence")
|
|
return self._draw(values, lambda: self._random.choice(values))
|
|
|
|
def integer(self, min_value: int, max_value: int) -> int:
|
|
if min_value > max_value:
|
|
raise ValueError(f"{min_value=} must not exceed {max_value=}")
|
|
|
|
edges = [
|
|
0, 1, -1, min_value, max_value,
|
|
min_value + 1, max_value - 1,
|
|
]
|
|
edges.extend(1 << bit for bit in range(max_value.bit_length()))
|
|
edges.extend(-(1 << bit) for bit in range((-min_value).bit_length()))
|
|
valid_edges = tuple(dict.fromkeys(v for v in edges if min_value <= v <= max_value))
|
|
return self._draw(valid_edges, lambda: self._random.randint(min_value, max_value))
|
|
|
|
def floating(self, width: int = 64, *, allow_nan: bool = True, allow_infinity: bool = True) -> float:
|
|
if width not in (32, 64):
|
|
raise ValueError("float width must be 32 or 64")
|
|
|
|
if width == 32:
|
|
unpack_format = "!f"
|
|
finite_edges = (
|
|
0.0, -0.0, 1.0, -1.0,
|
|
struct.unpack(unpack_format, b"\x00\x00\x00\x01")[0],
|
|
struct.unpack(unpack_format, b"\x80\x00\x00\x01")[0],
|
|
struct.unpack(unpack_format, b"\x7f\x7f\xff\xff")[0],
|
|
struct.unpack(unpack_format, b"\xff\x7f\xff\xff")[0],
|
|
struct.unpack(unpack_format, b"\x00\x80\x00\x00")[0],
|
|
struct.unpack(unpack_format, b"\x80\x80\x00\x00")[0],
|
|
)
|
|
else:
|
|
unpack_format = "!d"
|
|
finite_edges = (
|
|
0.0, -0.0, 1.0, -1.0,
|
|
math.ulp(0.0), -math.ulp(0.0),
|
|
float.fromhex("0x1.fffffffffffffp+1023"), -float.fromhex("0x1.fffffffffffffp+1023"),
|
|
float.fromhex("0x1p-1022"), -float.fromhex("0x1p-1022"),
|
|
)
|
|
|
|
edges = list(finite_edges)
|
|
if allow_infinity:
|
|
edges.extend((math.inf, -math.inf))
|
|
if allow_nan:
|
|
edges.append(math.nan)
|
|
|
|
def random_float() -> float:
|
|
while True:
|
|
value = struct.unpack(unpack_format, self._random.randbytes(width // 8))[0]
|
|
if (allow_nan or not math.isnan(value)) and (allow_infinity or not math.isinf(value)):
|
|
return value
|
|
|
|
return self._draw(tuple(edges), random_float)
|
|
|
|
def _length(self, min_length: int, max_length: int | None) -> int:
|
|
if min_length < 0:
|
|
raise ValueError("minimum length must be non-negative")
|
|
if max_length is not None and min_length > max_length:
|
|
raise ValueError(f"{min_length=} must not exceed {max_length=}")
|
|
if max_length == min_length:
|
|
return min_length
|
|
|
|
offsets = (0, 1, 2, 4, 8, 16, 32)
|
|
edges = tuple(min_length + offset for offset in offsets if max_length is None or min_length + offset <= max_length)
|
|
|
|
def random_length() -> int:
|
|
# A geometric tail keeps ordinary examples small without placing an
|
|
# artificial ceiling on an unbounded list.
|
|
length = min_length
|
|
while max_length is None or length < max_length:
|
|
if self._random.randrange(8) == 0:
|
|
break
|
|
length += 1
|
|
return length
|
|
|
|
return self._draw(edges, random_length)
|
|
|
|
def binary(self, min_size: int = 0, max_size: int | None = None) -> bytes:
|
|
size = self._length(min_size, max_size)
|
|
patterns = (
|
|
bytes(size),
|
|
b"\xff" * size,
|
|
(b"\xaa\x55" * ((size + 1) // 2))[:size],
|
|
bytes(i & 0xff for i in range(size)),
|
|
)
|
|
return self._draw(patterns, lambda: self._random.randbytes(size))
|
|
|
|
def text(self, min_size: int = 0, max_size: int | None = None) -> str:
|
|
size = self._length(min_size, max_size)
|
|
|
|
def scalar() -> str:
|
|
value = self._random.randrange(0x110000 - 0x800)
|
|
if value >= 0xd800:
|
|
value += 0x800
|
|
return chr(value)
|
|
|
|
patterns = (
|
|
"",
|
|
"a" * size,
|
|
"\0" * size,
|
|
"\U0010ffff" * size,
|
|
)
|
|
valid_patterns = tuple(value for value in patterns if len(value) == size)
|
|
return self._draw(valid_patterns, lambda: "".join(scalar() for _ in range(size)))
|
|
|
|
def list(self, generate: Callable[[], T], min_size: int = 0, max_size: int | None = None) -> list[T]:
|
|
return [generate() for _ in range(self._length(min_size, max_size))]
|
|
|
|
|
|
def fuzzy_test(max_examples: int) -> Callable[[Callable[..., None]], Callable[..., None]]:
|
|
"""Run a unittest method repeatedly with independent, reproducible fuzzy data."""
|
|
max_examples = int(os.environ.get("MAX_EXAMPLES", max_examples))
|
|
assert max_examples >= 1
|
|
|
|
def decorator(fn: Callable[..., None]) -> Callable[..., None]:
|
|
@wraps(fn)
|
|
def wrapper(*args: Any, **kwargs: Any) -> None:
|
|
test_seed = f"{FUZZ_SEED}:{args[0].id()}"
|
|
selected_example = os.environ.get("FUZZ_EXAMPLE")
|
|
examples = [int(selected_example, 0)] if selected_example is not None else range(max_examples)
|
|
|
|
for example_index in examples:
|
|
if not 0 <= example_index < max_examples:
|
|
raise ValueError(f"FUZZ_EXAMPLE={example_index} is outside [0, {max_examples})")
|
|
try:
|
|
fn(*args, **kwargs, fuzzy=Fuzzy(f"{test_seed}:{example_index}", example_index))
|
|
except Exception as exc:
|
|
exc.add_note(f"reproduce with FUZZ_SEED={FUZZ_SEED} FUZZ_EXAMPLE={example_index}")
|
|
raise
|
|
|
|
return wrapper
|
|
return decorator
|
|
|
|
|
|
def capnp_random_dict(fuzzy: Fuzzy, schema: Any, event: str | None = None, *, real_floats: bool = False) -> dict[str, Any]:
|
|
"""Generate a dictionary accepted by a pycapnp struct constructor."""
|
|
|
|
def native(type_name: str) -> bool | int | float | str | bytes:
|
|
if type_name == "bool":
|
|
return fuzzy.boolean()
|
|
if type_name in _INTEGER_RANGES:
|
|
return fuzzy.integer(*_INTEGER_RANGES[type_name])
|
|
if type_name in ("float32", "float64"):
|
|
return fuzzy.floating(width=int(type_name[-2:]), allow_nan=not real_floats, allow_infinity=not real_floats)
|
|
if type_name == "text":
|
|
return fuzzy.text(max_size=1000)
|
|
if type_name == "anyPointer":
|
|
return fuzzy.text()
|
|
if type_name == "data":
|
|
return fuzzy.binary(max_size=1000)
|
|
raise NotImplementedError(f"invalid Cap'n Proto type: {type_name}")
|
|
|
|
def generate_field(field: Any) -> Any:
|
|
def rec(field_type: Any, base_type: str) -> Any:
|
|
type_name = field_type.which()
|
|
if type_name == "struct":
|
|
struct_schema = field.schema.elementType if base_type == "list" else field.schema
|
|
return capnp_random_dict(fuzzy, struct_schema, real_floats=real_floats)
|
|
if type_name == "list":
|
|
return fuzzy.list(lambda: rec(field_type.list.elementType, "list"))
|
|
if type_name == "enum":
|
|
enum_schema = field.schema.elementType if base_type == "list" else field.schema
|
|
return fuzzy.choice(tuple(enum_schema.enumerants))
|
|
return native(type_name)
|
|
|
|
try:
|
|
if hasattr(field.proto, "slot"):
|
|
slot_type = field.proto.slot.type
|
|
return rec(slot_type, slot_type.which())
|
|
return capnp_random_dict(fuzzy, field.schema, real_floats=real_floats)
|
|
except capnp.lib.capnp.KjException:
|
|
return capnp_random_dict(fuzzy, field.schema, real_floats=real_floats)
|
|
|
|
union_field = event or (fuzzy.choice(tuple(schema.union_fields)) if schema.union_fields else None)
|
|
fields = schema.non_union_fields + ((union_field,) if union_field else ())
|
|
return {
|
|
field_name: generate_field(schema.fields[field_name])
|
|
for field_name in fields
|
|
if not field_name.endswith("DEPRECATED") and field_name != "deprecated"
|
|
}
|