Files
orisys-sdk/tests/test_stream_contract.py
T
ykcheungab f6f3c45285 feat: 新增单路流类型合同 stream-contract/1
冻结逐帧身份、采集时钟、测量头和事件信封:FrameIdentity、FrameClock、FrameStamp、MeasurementFrame、StreamEvent,并从包根导出。采用严格读取:未知字段和未列出的版本一律拒绝,任何字段变更都升版本,读取方在 SUPPORTED_CONTRACT_VERSIONS 中列出可读版本;未知事件 kind 仍兼容。事件可哈希,kind 为 lower_snake。合同写入 docs/stream-contract.zh.md;不接入现有运行时。窄套件 139 项、lint 通过。
2026-10-21 11:03:53 +08:00

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