f6f3c45285
冻结逐帧身份、采集时钟、测量头和事件信封:FrameIdentity、FrameClock、FrameStamp、MeasurementFrame、StreamEvent,并从包根导出。采用严格读取:未知字段和未列出的版本一律拒绝,任何字段变更都升版本,读取方在 SUPPORTED_CONTRACT_VERSIONS 中列出可读版本;未知事件 kind 仍兼容。事件可哈希,kind 为 lower_snake。合同写入 docs/stream-contract.zh.md;不接入现有运行时。窄套件 139 项、lint 通过。
442 lines
16 KiB
Python
442 lines
16 KiB
Python
"""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
|