"""The frozen single-stream type contract: identity, clock, source_dt_s, events. The contract is defined in ``orisys.stream_contract`` and documented in ``docs/stream-contract.zh.md``. These tests pin the four acceptance rules of T03: per-stream identity and clock, an optional-but-never-partial group identity, distinguishability of frames that share a timestamp, and JSON round-trips for ``source_dt_s`` and events. They are pure type tests: nothing here starts a sensor, a pipeline or a session. """ from __future__ import annotations import json import pytest from orisys import ( STREAM_CONTRACT_VERSION, SUPPORTED_CONTRACT_VERSIONS, FrameClock, FrameIdentity, FrameStamp, MeasurementFrame, StreamEvent, ) def _round_trip(value): """JSON round-trip exactly as a recording would read the contract back.""" payload = json.loads(json.dumps(value.to_dict())) return type(value).from_dict(payload) def _stamp(**overrides) -> FrameStamp: fields = { "sensor_id": "FINGER-001", "session_id": "run-7", "frame_id": 12, "source_frame_id": 41, "capture_time_ns": 1_600_000_000_000_000_000, "clock_id": "host_monotonic", "capture_time_basis": "capture_monotonic", } fields.update(overrides) return FrameStamp(**fields) def test_the_contract_version_is_exported_and_explicit() -> None: assert STREAM_CONTRACT_VERSION == "stream-contract/1" # Every frozen type and the version are importable from the package root, # which is the public boundary downstream repositories import. import orisys for name in ( "FrameIdentity", "FrameClock", "FrameStamp", "MeasurementFrame", "StreamEvent", "TIME_BASES", "STREAM_CONTRACT_VERSION", ): assert hasattr(orisys, name), name # --------------------------------------------------------------- identity def test_identity_keeps_a_per_stream_output_number_and_the_source_number() -> None: identity = FrameIdentity( sensor_id="BOX-3", session_id="run-7", frame_id=12, source_frame_id=41, ) assert (identity.sensor_id, identity.session_id) == ("BOX-3", "run-7") assert identity.frame_id == 12 assert identity.source_frame_id == 41 # A source without its own numbering may say so; the output number still exists. identity = FrameIdentity( sensor_id="BOX-3", session_id="run-7", frame_id=0, source_frame_id=None ) assert identity.frame_id == 0 and identity.source_frame_id is None with pytest.raises(ValueError): FrameIdentity( sensor_id="", session_id="run-7", frame_id=0, source_frame_id=None ) with pytest.raises(ValueError): FrameIdentity( sensor_id="BOX-3", session_id="run-7", frame_id=-1, source_frame_id=None ) with pytest.raises(TypeError): FrameIdentity( sensor_id="BOX-3", session_id="run-7", frame_id=True, source_frame_id=None ) def test_group_identity_is_optional_but_never_partial() -> None: without_group = FrameIdentity( sensor_id="BOX-3", session_id="run-7", frame_id=0, source_frame_id=0 ) assert without_group.has_group is False assert without_group.group_identity is None assert without_group.to_dict()["acquisition_id"] is None assert without_group.to_dict()["sync_group_id"] is None grouped = FrameIdentity( sensor_id="BOX-3", session_id="run-7", frame_id=0, source_frame_id=0, acquisition_id="acq-1", sync_group_id="group-1", ) assert grouped.has_group is True assert grouped.group_identity == ("acq-1", "group-1") assert _round_trip(grouped) == grouped # A half group would look like synchronization without an owner. with pytest.raises(ValueError, match="both acquisition_id and sync_group_id"): FrameIdentity( sensor_id="BOX-3", session_id="run-7", frame_id=0, source_frame_id=0, acquisition_id="acq-1", ) with pytest.raises(ValueError, match="both acquisition_id and sync_group_id"): FrameIdentity( sensor_id="BOX-3", session_id="run-7", frame_id=0, source_frame_id=0, sync_group_id="group-1", ) with pytest.raises(ValueError): FrameIdentity( sensor_id="BOX-3", session_id="run-7", frame_id=0, source_frame_id=0, acquisition_id="", sync_group_id="group-1", ) # ------------------------------------------------------------------ clock def test_clock_keeps_the_time_domain_basis_and_the_raw_source_pts() -> None: clock = FrameClock( capture_time_ns=1_600_000_000_000_000_000, clock_id="video:recording-4", capture_time_basis="video_pts", source_pts=90000, ) assert clock.capture_time_basis == "video_pts" assert clock.source_pts == 90000 # A derived file timestamp is explicitly labelled as estimated. assert FrameClock( 0, "source:frame-numbers", "estimated_source" ).capture_time_basis == ("estimated_source") with pytest.raises(ValueError): FrameClock(0, "host", "wall_clock") with pytest.raises(ValueError): FrameClock(-1, "host", "capture_monotonic") with pytest.raises(ValueError): FrameClock(0, "", "capture_monotonic") with pytest.raises(ValueError): FrameClock(0, "host", "capture_monotonic", source_pts=float("nan")) with pytest.raises(TypeError): FrameClock(0, "host", "capture_monotonic", source_pts=True) # ------------------------------------------------------------------ stamp def test_stamp_composes_identity_and_clock_and_round_trips() -> None: identity = FrameIdentity( sensor_id="FINGER-001", session_id="run-7", frame_id=12, source_frame_id=41, acquisition_id="acq-1", sync_group_id="group-1", ) clock = FrameClock( capture_time_ns=1_600_000_000_000_000_000, clock_id="host_monotonic", capture_time_basis="capture_monotonic", source_pts=41.5, ) stamp = FrameStamp.from_parts(identity, clock) assert stamp.identity == identity assert stamp.clock == clock payload = stamp.to_dict() assert payload["schema"] == STREAM_CONTRACT_VERSION assert payload["sensor_id"] == "FINGER-001" assert payload["source_frame_id"] == 41 assert payload["capture_time_ns"] == clock.capture_time_ns assert _round_trip(stamp) == stamp # The flattened fields are the same objects the components report. assert stamp.sensor_id == identity.sensor_id assert stamp.session_id == identity.session_id assert stamp.frame_id == identity.frame_id assert stamp.source_frame_id == identity.source_frame_id assert stamp.capture_time_ns == clock.capture_time_ns assert stamp.clock_id == clock.clock_id assert stamp.capture_time_basis == clock.capture_time_basis assert stamp.source_pts == clock.source_pts assert (stamp.acquisition_id, stamp.sync_group_id) == ("acq-1", "group-1") def test_frames_that_share_a_pts_are_still_distinct_frames() -> None: """Timestamps are not identity: a recording must keep both frames.""" first = _stamp(source_frame_id=41, frame_id=10) second = _stamp(source_frame_id=42, frame_id=11) assert first.capture_time_ns == second.capture_time_ns assert first.source_pts == second.source_pts assert first != second assert first.to_dict() != second.to_dict() assert _round_trip(first) != _round_trip(second) # A source without its own numbering can still emit repeated timestamps; # the contiguous output number keeps the frames apart. first = _stamp(source_frame_id=None, frame_id=10) second = _stamp(source_frame_id=None, frame_id=11) assert first != second assert (first.capture_time_ns, first.source_pts) == ( second.capture_time_ns, second.source_pts, ) # Different sessions are different streams even with identical numbers. other_session = _stamp(session_id="run-8") assert other_session != first and other_session != second # -------------------------------------------------------- measurement frame def test_measurement_frame_carries_source_dt_s_and_round_trips() -> None: stamp = _stamp() first = MeasurementFrame(stamp=stamp, source_dt_s=None) assert first.source_dt_s is None assert first.reference_generation == 0 assert _round_trip(first) == first following = MeasurementFrame( stamp=_stamp(frame_id=13, source_frame_id=42), source_dt_s=0.033, reference_generation=3, ) payload = following.to_dict() assert payload["schema"] == STREAM_CONTRACT_VERSION assert payload["source_dt_s"] == pytest.approx(0.033) assert payload["reference_generation"] == 3 assert _round_trip(following) == following # Equal source timestamps are valid data; they do not collapse frames and do # not produce a negative/None interval. same_time = MeasurementFrame(stamp=_stamp(), source_dt_s=0.0) assert same_time.source_dt_s == 0.0 def test_measurement_frame_rejects_invalid_source_dt_and_generation() -> None: stamp = _stamp() with pytest.raises(ValueError): MeasurementFrame(stamp=stamp, source_dt_s=-0.001) with pytest.raises(ValueError): MeasurementFrame(stamp=stamp, source_dt_s=float("nan")) with pytest.raises(ValueError): MeasurementFrame(stamp=stamp, source_dt_s=float("inf")) with pytest.raises(TypeError): MeasurementFrame(stamp=stamp, source_dt_s=True) with pytest.raises(ValueError): MeasurementFrame(stamp=stamp, source_dt_s=None, reference_generation=-1) with pytest.raises(TypeError): MeasurementFrame(stamp=stamp, source_dt_s=None, reference_generation=1.5) with pytest.raises(TypeError): MeasurementFrame(stamp={}, source_dt_s=None) # ------------------------------------------------------------------ events def test_events_round_trip_through_json_with_a_frozen_payload() -> None: stamp = _stamp() event = StreamEvent( kind="source_gap", stamp=stamp, payload={ "dropped_frames": 3, "source_dt_s": 0.5, "proportion": 0.25, "flags": [True, False, None], "context": {"reason": "overload", "counts": [1, 2]}, }, event_version=1, ) assert event.payload["dropped_frames"] == 3 # The payload is read-only after construction. with pytest.raises(TypeError): event.payload["dropped_frames"] = 4 # type: ignore[index] payload = event.to_dict() assert payload["schema"] == STREAM_CONTRACT_VERSION assert payload["event_version"] == 1 assert _round_trip(event) == event # A lifecycle event may have no frame, and unknown kinds stay additive. event = StreamEvent(kind="stream_started", payload={}) assert event.stamp is None assert _round_trip(event) == event assert _round_trip(StreamEvent(kind="some_future_kind", payload={"x": 1})).kind == ( "some_future_kind" ) def test_events_reject_payloads_that_cannot_round_trip() -> None: with pytest.raises(ValueError): StreamEvent(kind="bad", payload={"nan": float("nan")}) with pytest.raises(ValueError): StreamEvent(kind="bad", payload={"inf": float("inf")}) with pytest.raises(TypeError): StreamEvent(kind="bad", payload={1: "non-string key"}) with pytest.raises(TypeError): StreamEvent(kind="bad", payload={"bytes": b"raw"}) with pytest.raises(TypeError): StreamEvent(kind="bad", payload={"set": {1, 2}}) with pytest.raises(ValueError): StreamEvent(kind="", payload={}) with pytest.raises(ValueError): StreamEvent(kind="bad", payload={}, event_version=0) # -------------------------------------------------------------- read-back @pytest.mark.parametrize( "factory", [ lambda: _stamp(), lambda: _stamp(acquisition_id="acq-1", sync_group_id="group-1"), lambda: MeasurementFrame(_stamp(), None), lambda: MeasurementFrame(_stamp(), 0.033, 2), lambda: StreamEvent("reference_reset", _stamp(), {"from": 1, "to": 2}), ], ) def test_every_frozen_type_round_trips_through_its_dictionary(factory) -> None: value = factory() assert _round_trip(value) == value def test_unknown_fields_and_wrong_schema_are_rejected() -> None: stamp_payload = _stamp().to_dict() # Policy A: no same-version field additions - an unknown field names the # versioning rule instead of being silently dropped. with pytest.raises(ValueError, match="new STREAM_CONTRACT_VERSION"): FrameStamp.from_dict({**stamp_payload, "unexpected": 1}) with pytest.raises(ValueError, match="schema"): FrameStamp.from_dict({**stamp_payload, "schema": "stream-contract/2"}) with pytest.raises(ValueError, match="missing field"): FrameStamp.from_dict( {key: value for key, value in stamp_payload.items() if key != "sensor_id"} ) with pytest.raises(ValueError, match="unknown field"): StreamEvent.from_dict( {"schema": STREAM_CONTRACT_VERSION, "kind": "x", "extra": 1} ) with pytest.raises(ValueError, match="unknown field"): # A nested frame header follows the same policy. MeasurementFrame.from_dict( { "schema": STREAM_CONTRACT_VERSION, "stamp": {**stamp_payload, "unexpected": 1}, "source_dt_s": None, } ) with pytest.raises(ValueError, match="schema"): MeasurementFrame.from_dict( { "schema": "stream-contract/0", "stamp": stamp_payload, "source_dt_s": None, } ) def test_the_reader_lists_the_versions_it_accepts() -> None: """Policy A: strict reading, and only explicitly listed versions are read.""" assert SUPPORTED_CONTRACT_VERSIONS == (STREAM_CONTRACT_VERSION,) payload = _stamp().to_dict() # The listed version is the one this reader was built for and it reads. assert FrameStamp.from_dict(payload) == _stamp() # A different version is a different field contract: it is rejected rather # than guessed at. Bumping STREAM_CONTRACT_VERSION and adding the new value # to SUPPORTED_CONTRACT_VERSIONS is what makes a new version readable. for schema in ("stream-contract/0", "stream-contract/2", "other/1"): with pytest.raises(ValueError, match="must be one of"): FrameStamp.from_dict({**payload, "schema": schema}) with pytest.raises(ValueError, match="must be one of"): MeasurementFrame.from_dict( {"schema": schema, "stamp": payload, "source_dt_s": None} ) with pytest.raises(ValueError, match="must be one of"): StreamEvent.from_dict({"schema": schema, "kind": "x"}) # --------------------------------------------------------------- event extras def test_events_are_hashable_and_equal_events_share_a_hash() -> None: """The frozen event is usable as a dict/set key; equality and hash agree.""" first = StreamEvent( kind="reference_reset", stamp=_stamp(), payload={"from": 1, "to": 2} ) second = StreamEvent( kind="reference_reset", stamp=_stamp(), payload={"to": 2, "from": 1}, ) assert first == second assert hash(first) == hash(second) # A JSON read-back is still the same value, so it must hash the same too. assert _round_trip(first) == first assert hash(_round_trip(first)) == hash(first) # The frozen payload is excluded from the hash, so it cannot make hashing # fail; events differing only in payload may still share a hash bucket. assert hash(StreamEvent(kind="source_gap", stamp=_stamp())) == hash( StreamEvent(kind="source_gap", stamp=_stamp(), payload={"dropped": 1}) ) @pytest.mark.parametrize( "kind", ["NotSnake", "bad kind", "1leading", "with-dash", "_leading", ""], ) def test_event_kind_rejects_names_outside_lower_snake(kind) -> None: with pytest.raises(ValueError): StreamEvent(kind=kind) with pytest.raises(ValueError): StreamEvent.from_dict({"schema": STREAM_CONTRACT_VERSION, "kind": kind}) @pytest.mark.parametrize( "kind", ["a", "source_gap", "reference_reset", "zero2", "a_b_c_1"] ) def test_event_kind_accepts_lower_snake_and_round_trips(kind) -> None: event = StreamEvent(kind=kind, payload={"x": 1}) assert event.kind == kind assert _round_trip(event) == event