Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "uv_build"

[project]
name = "jsonlt-python"
version = "0.1.0a3"
version = "0.1.0a4"
description = "Reference implementation of the JSONLT (JSON Lines Table) specification for Python."
readme = "README.md"
license = "MIT"
Expand Down
65 changes: 65 additions & 0 deletions tests/properties/strategies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Hypothesis strategies for JSONLT property-based testing."""

from hypothesis import strategies as st

from jsonlt._constants import MAX_INTEGER_KEY, MAX_TUPLE_ELEMENTS, MIN_INTEGER_KEY

# Key-related strategies (migrated from test_key_comparison.py)
key_element_strategy = st.one_of(
st.text(),
st.integers(min_value=MIN_INTEGER_KEY, max_value=MAX_INTEGER_KEY),
)

key_strategy = st.one_of(
st.text(),
st.integers(min_value=MIN_INTEGER_KEY, max_value=MAX_INTEGER_KEY),
st.tuples(*[key_element_strategy] * 1),
st.tuples(*[key_element_strategy] * 2),
st.lists(key_element_strategy, min_size=1, max_size=MAX_TUPLE_ELEMENTS).map(tuple),
)

# JSON primitive strategy
json_primitive_strategy = st.one_of(
st.none(),
st.booleans(),
st.integers(),
st.floats(allow_nan=False, allow_infinity=False),
st.text(),
)

# JSON value strategy (recursive, bounded depth)
# Use st.recursive to generate nested structures
json_value_strategy = st.recursive(
json_primitive_strategy,
lambda children: st.one_of(
st.lists(children, max_size=5),
st.dictionaries(st.text(max_size=20), children, max_size=5),
),
max_leaves=50,
)

# JSON object strategy (for records)
json_object_strategy = st.dictionaries(
st.text(max_size=20).filter(
lambda s: not s.startswith("$")
), # No $-prefixed fields
json_value_strategy,
max_size=10,
)

# Field name strategy (no $-prefix for valid records)
field_name_strategy = st.text(min_size=1, max_size=20).filter(
lambda s: not s.startswith("$")
)

# Key specifier strategy
scalar_key_specifier_strategy = field_name_strategy
tuple_key_specifier_strategy = (
st.lists(field_name_strategy, min_size=2, max_size=5)
.filter(lambda fields: len(fields) == len(set(fields)))
.map(tuple)
)
key_specifier_strategy = st.one_of(
scalar_key_specifier_strategy,
tuple_key_specifier_strategy,
)
52 changes: 52 additions & 0 deletions tests/properties/test_json_properties.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Property-based tests for JSON serialization and parsing."""

import json
from typing import TYPE_CHECKING

from hypothesis import given

from jsonlt._json import parse_json_line, serialize_json

from .strategies import json_object_strategy

if TYPE_CHECKING:
from jsonlt._json import JSONObject


class TestSerializationRoundtrip:
"""Serialize then parse produces equivalent data."""

@given(json_object_strategy)
def test_roundtrip_preserves_data(self, obj: "JSONObject") -> None:
"""parse(serialize(obj)) == obj for any valid JSON object."""
serialized = serialize_json(obj)
parsed = parse_json_line(serialized)
assert parsed == obj

@given(json_object_strategy)
def test_serialize_is_deterministic(self, obj: "JSONObject") -> None:
"""serialize(obj) == serialize(obj) always."""
result1 = serialize_json(obj)
result2 = serialize_json(obj)
assert result1 == result2


class TestSerializationProperties:
"""Serialization output format invariants."""

@given(json_object_strategy)
def test_no_extraneous_whitespace(self, obj: "JSONObject") -> None:
"""Output contains no space/newline/tab outside strings."""
serialized = serialize_json(obj)
# Parse to check it's valid JSON
parsed = parse_json_line(serialized)
# Re-serialize and check for equality (no whitespace variation)
reserialized = serialize_json(parsed)
assert serialized == reserialized

@given(json_object_strategy)
def test_valid_json_output(self, obj: "JSONObject") -> None:
"""Output is parseable by standard json.loads."""
serialized = serialize_json(obj)
# Should not raise
json.loads(serialized)
Original file line number Diff line number Diff line change
@@ -1,20 +1,11 @@
"""Property-based tests for key comparison operations."""

from hypothesis import given, strategies as st

from jsonlt._constants import MAX_INTEGER_KEY, MAX_TUPLE_ELEMENTS, MIN_INTEGER_KEY
from jsonlt._keys import compare_keys

key_element_strategy = st.one_of(
st.text(),
st.integers(min_value=MIN_INTEGER_KEY, max_value=MAX_INTEGER_KEY),
)

key_strategy = st.one_of(
st.text(),
st.integers(min_value=MIN_INTEGER_KEY, max_value=MAX_INTEGER_KEY),
st.tuples(*[key_element_strategy] * 1),
st.tuples(*[key_element_strategy] * 2),
st.lists(key_element_strategy, min_size=1, max_size=MAX_TUPLE_ELEMENTS).map(tuple),
)
from .strategies import key_element_strategy, key_strategy


class TestTotalOrderProperties:
Expand Down
111 changes: 111 additions & 0 deletions tests/properties/test_record_properties.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""Property-based tests for record validation."""

from typing import TYPE_CHECKING

from hypothesis import given, strategies as st

from jsonlt._records import build_tombstone, extract_key, is_tombstone, validate_record

from .strategies import (
field_name_strategy,
json_value_strategy,
key_element_strategy,
key_specifier_strategy,
scalar_key_specifier_strategy,
tuple_key_specifier_strategy,
)

if TYPE_CHECKING:
from jsonlt._json import JSONObject


class TestValidRecordProperties:
"""Valid records pass validation without exception."""

@given(
scalar_key_specifier_strategy,
key_element_strategy,
st.dictionaries(field_name_strategy, json_value_strategy, max_size=5),
)
def test_valid_scalar_key_record(
self, key_field: str, key_value: str | int, extra_fields: "JSONObject"
) -> None:
"""Records with valid scalar keys pass validation."""
# Build record with key field and extra data
record: JSONObject = {
key_field: key_value,
**{k: v for k, v in extra_fields.items() if k != key_field},
}
validate_record(record, key_field) # Should not raise

@given(tuple_key_specifier_strategy, st.data())
def test_valid_compound_key_record(
self, key_specifier: tuple[str, ...], data: st.DataObject
) -> None:
"""Records with valid compound keys pass validation."""
# Generate a key value for each field in the specifier
record: JSONObject = {}
for field in key_specifier:
record[field] = data.draw(key_element_strategy)
validate_record(record, key_specifier) # Should not raise


class TestExtractKeyProperties:
"""Key extraction invariants."""

@given(scalar_key_specifier_strategy, key_element_strategy)
def test_extracted_scalar_key_matches_field(
self, key_field: str, key_value: str | int
) -> None:
"""Extracted key equals the key field value."""
record: JSONObject = {key_field: key_value}
extracted = extract_key(record, key_field)
assert extracted == key_value

@given(tuple_key_specifier_strategy, st.data())
def test_extracted_compound_key_matches_fields(
self, key_specifier: tuple[str, ...], data: st.DataObject
) -> None:
"""Extracted compound key is tuple of field values."""
record: JSONObject = {}
expected_elements: list[str | int] = []
for field in key_specifier:
value: str | int = data.draw(key_element_strategy)
record[field] = value
expected_elements.append(value)

extracted = extract_key(record, key_specifier)
assert extracted == tuple(expected_elements)


class TestTombstoneProperties:
"""Tombstone detection and construction."""

@given(key_specifier_strategy, st.data())
def test_tombstone_detected(
self, key_specifier: str | tuple[str, ...], data: st.DataObject
) -> None:
"""is_tombstone returns True for tombstones."""
# Build a valid key
if isinstance(key_specifier, str):
key: str | int | tuple[str | int, ...] = data.draw(key_element_strategy)
else:
key = tuple(data.draw(key_element_strategy) for _ in key_specifier)

tombstone = build_tombstone(key, key_specifier)
assert is_tombstone(tombstone) is True

@given(key_specifier_strategy, st.data())
def test_build_tombstone_roundtrip(
self, key_specifier: str | tuple[str, ...], data: st.DataObject
) -> None:
"""extract_key(build_tombstone(key, specifier), specifier) == key."""
# Build a valid key
if isinstance(key_specifier, str):
key: str | int | tuple[str | int, ...] = data.draw(key_element_strategy)
else:
key = tuple(data.draw(key_element_strategy) for _ in key_specifier)

tombstone = build_tombstone(key, key_specifier)
extracted = extract_key(tombstone, key_specifier)
assert extracted == key
Loading