feat: 为每个 Sensor 绑定只读 SensorContext
构造时一次绑定 SN、配置、资产和当前生效的标定:只接受显式给出的 SN,不做猜测;标定路径只取当前生效的标定,清除后为 None;换标定时整体替换。帧身份复用 stream-contract 的 FrameIdentity。load_config 改为复用 resolve_config_source,使记录的配置来源与实际读取的文件一致,并补一致性测试。已发布 Sensor 数值和资产包解析不变。回归 275 项、lint 通过。
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
"""``resolve_config_source`` and ``load_config`` must agree on what is read.
|
||||
|
||||
``SensorContext.config_source`` promises the JSON file that was actually loaded.
|
||||
That promise only holds while the recorded lookup and the loading lookup are the
|
||||
same implementation, so this module checks it against every installed config
|
||||
name, the alias table, an explicit path and the legacy ``"name"`` field, and
|
||||
pins the preserved error types/messages for a missing or non-JSON input.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys import configs
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
SENSORS = REPO_ROOT / "sensors"
|
||||
|
||||
|
||||
def _read(path: str | Path) -> dict:
|
||||
return json.loads(Path(path).read_text(encoding="utf-8-sig"))
|
||||
|
||||
|
||||
def test_every_listed_name_resolves_to_the_file_load_config_reads() -> None:
|
||||
names = configs.list_config()
|
||||
assert names
|
||||
for name in names:
|
||||
source = configs.resolve_config_source(name)
|
||||
assert source is not None, name
|
||||
assert _read(source) == configs.load_config(name), name
|
||||
# The recorded source can be loaded directly with the same result.
|
||||
assert configs.load_config(source) == configs.load_config(name), name
|
||||
|
||||
|
||||
def test_aliases_resolve_like_loading() -> None:
|
||||
aliases = dict(configs.CONFIG_ALIASES)
|
||||
assert aliases
|
||||
for alias, canonical in aliases.items():
|
||||
source = configs.resolve_config_source(alias)
|
||||
assert source is not None, alias
|
||||
assert _read(source) == configs.load_config(alias), alias
|
||||
canonical_source = configs.resolve_config_source(canonical)
|
||||
if canonical_source is not None:
|
||||
assert source == canonical_source, alias
|
||||
|
||||
|
||||
def test_explicit_paths_resolve_to_themselves(tmp_path: Path) -> None:
|
||||
bundle_config = SENSORS / "box" / "sensor.json"
|
||||
assert configs.resolve_config_source(bundle_config) == bundle_config.resolve()
|
||||
assert configs.load_config(bundle_config) == configs.load_config("box")
|
||||
|
||||
copied = tmp_path / "copied-config.json"
|
||||
copied.write_text(bundle_config.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
assert configs.resolve_config_source(copied) == copied.resolve()
|
||||
assert configs.load_config(copied) == configs.load_config(bundle_config)
|
||||
|
||||
|
||||
def test_legacy_name_field_scan_agrees_with_loading() -> None:
|
||||
"""A config's JSON ``"name"`` may differ from its file stem (``prototype``)."""
|
||||
found = False
|
||||
for name in configs.list_config():
|
||||
source = configs.resolve_config_source(name)
|
||||
if source is None:
|
||||
continue
|
||||
field = _read(source).get("name")
|
||||
if (
|
||||
isinstance(field, str)
|
||||
and field
|
||||
and field not in configs.list_config()
|
||||
):
|
||||
resolved = configs.resolve_config_source(field)
|
||||
assert resolved is not None, field
|
||||
assert _read(resolved) == configs.load_config(field), field
|
||||
found = True
|
||||
assert found, "expected at least one legacy name field in the installed configs"
|
||||
|
||||
|
||||
def test_missing_and_non_json_inputs_keep_their_error_contract(tmp_path: Path) -> None:
|
||||
assert configs.resolve_config_source("no-such-config-name") is None
|
||||
with pytest.raises(FileNotFoundError, match="Input config file not found"):
|
||||
configs.load_config("no-such-config-name")
|
||||
|
||||
absolute_missing = tmp_path / "missing.json"
|
||||
assert configs.resolve_config_source(absolute_missing) is None
|
||||
with pytest.raises(FileNotFoundError, match="not found at absolute path"):
|
||||
configs.load_config(absolute_missing)
|
||||
|
||||
not_json = tmp_path / "config.txt"
|
||||
not_json.write_text("{}", encoding="utf-8")
|
||||
# The source lookup names the existing file; loading still refuses a non-JSON
|
||||
# file with the historical ValueError instead of silently reading it.
|
||||
assert configs.resolve_config_source(not_json) == not_json.resolve()
|
||||
with pytest.raises(ValueError, match="must be a JSON file"):
|
||||
configs.load_config(not_json)
|
||||
@@ -0,0 +1,292 @@
|
||||
"""Read-only SensorContext (T04): SN/config/assets/cal_path bound once per instance.
|
||||
|
||||
The context is the stage-1 answer to "which physical sensor is this, which
|
||||
configuration and assets does it run, and which force calibration was selected".
|
||||
These tests protect the two properties the task names: the binding happens once
|
||||
from explicit inputs (no SN guessed from enumeration order), and two Sensor
|
||||
instances do not share or overwrite each other's context. The identity itself is
|
||||
built through the frozen T03 ``FrameIdentity``, not a second shape.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys import Sensor, configs
|
||||
from orisys.sensors import SensorContext, build_sensor_context
|
||||
from orisys.stream_contract import FrameIdentity
|
||||
|
||||
# The shipped box calibration has no declared identity; the device tests add one,
|
||||
# exactly like an operator-managed <product>/<sn>.json file.
|
||||
BOX_CALIBRATION = Path(configs.resolve_sensor_bundle("box").force_calibration_json)
|
||||
BOX_CONFIG = Path(configs.resolve_sensor_bundle("box").sensor_json)
|
||||
|
||||
|
||||
def _device_calibration(root: Path, sn: str, *, family: str = "box") -> Path:
|
||||
"""Write a device calibration whose declared SN is ``sn``."""
|
||||
document = json.loads(BOX_CALIBRATION.read_text(encoding="utf-8-sig"))
|
||||
document["identity"] = {"sensor_type": family, "sn": sn}
|
||||
path = root / family / f"{sn}.json"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(document), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def _external_sensor(**kwargs) -> Sensor:
|
||||
options = {"verbose": False, "backend": "cpu"}
|
||||
options.update(kwargs)
|
||||
return Sensor("external", **options)
|
||||
|
||||
|
||||
# ------------------------------------------------------------ explicit binding
|
||||
def test_context_binds_the_explicit_sn_config_and_calibration_path(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
root = tmp_path / "calibrations"
|
||||
calibration = _device_calibration(root, "BOX-001")
|
||||
sensor = _external_sensor(
|
||||
config_name="box", calibration_dir=root, device_id="BOX-001"
|
||||
)
|
||||
try:
|
||||
context = sensor.context
|
||||
assert isinstance(context, SensorContext)
|
||||
assert context.sensor_id == "BOX-001"
|
||||
assert sensor.device_id == "BOX-001"
|
||||
assert context.product_family == "box"
|
||||
assert context.config_name == "box"
|
||||
assert context.config_source == str(BOX_CONFIG.resolve())
|
||||
assert len(context.config_fingerprint) == 64
|
||||
assert context.bundle_root == str(BOX_CONFIG.parent.resolve())
|
||||
assert context.calibration_path == str(calibration.resolve())
|
||||
assert context.calibration_fingerprint == sensor.calibration.fingerprint
|
||||
assert context.diagnostics == ()
|
||||
# The published attributes still say what they said before T04.
|
||||
assert sensor.calibration.source_path == calibration.resolve()
|
||||
assert sensor.force_unit == "N"
|
||||
finally:
|
||||
sensor.disconnect()
|
||||
|
||||
|
||||
def test_context_never_guesses_an_sn_from_enumeration_order(tmp_path: Path) -> None:
|
||||
"""No explicit SN means no identity; the stream header refuses to invent one."""
|
||||
first = _external_sensor(config_name="box")
|
||||
second = _external_sensor(config_name="box")
|
||||
try:
|
||||
assert first.context.sensor_id is None
|
||||
assert second.context.sensor_id is None
|
||||
# Same configuration and bundle, so the non-identity binding is stable.
|
||||
assert first.context.config_fingerprint == second.context.config_fingerprint
|
||||
assert first.context.bundle_root == second.context.bundle_root
|
||||
with pytest.raises(ValueError, match="explicit sensor_id"):
|
||||
first.context.frame_identity(
|
||||
session_id="run-1", frame_id=0, source_frame_id=None
|
||||
)
|
||||
finally:
|
||||
first.disconnect()
|
||||
second.disconnect()
|
||||
|
||||
|
||||
def test_two_instances_keep_separate_contexts(tmp_path: Path) -> None:
|
||||
root_one = tmp_path / "one"
|
||||
root_two = tmp_path / "two"
|
||||
_device_calibration(root_one, "BOX-001")
|
||||
_device_calibration(root_two, "BOX-002")
|
||||
first = _external_sensor(
|
||||
config_name="box", calibration_dir=root_one, device_id="BOX-001"
|
||||
)
|
||||
second = _external_sensor(
|
||||
config_name="box", calibration_dir=root_two, device_id="BOX-002"
|
||||
)
|
||||
try:
|
||||
first_context = first.context
|
||||
first_identity = first_context.frame_identity(
|
||||
session_id="run-1", frame_id=0, source_frame_id=None
|
||||
)
|
||||
second_context = second.context
|
||||
second_identity = second_context.frame_identity(
|
||||
session_id="run-1", frame_id=0, source_frame_id=None
|
||||
)
|
||||
assert first_context != second_context
|
||||
assert first_identity != second_identity
|
||||
assert first_identity.sensor_id == "BOX-001"
|
||||
assert second_identity.sensor_id == "BOX-002"
|
||||
# Building the second Sensor did not mutate the first context.
|
||||
assert first.context == first_context
|
||||
assert first.context.calibration_path != second_context.calibration_path
|
||||
assert isinstance(first_identity, FrameIdentity)
|
||||
finally:
|
||||
first.disconnect()
|
||||
second.disconnect()
|
||||
|
||||
|
||||
def test_context_is_frozen_read_only_value_data(tmp_path: Path) -> None:
|
||||
root = tmp_path / "calibrations"
|
||||
_device_calibration(root, "BOX-001")
|
||||
sensor = _external_sensor(
|
||||
config_name="box", calibration_dir=root, device_id="BOX-001"
|
||||
)
|
||||
try:
|
||||
context = sensor.context
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
context.sensor_id = "BOX-999" # type: ignore[misc]
|
||||
payload = context.as_dict()
|
||||
assert payload["sensor_id"] == "BOX-001"
|
||||
assert isinstance(payload["assets"], list)
|
||||
finally:
|
||||
sensor.disconnect()
|
||||
|
||||
|
||||
def test_calibration_swap_replaces_the_context_atomically(tmp_path: Path) -> None:
|
||||
root = tmp_path / "calibrations"
|
||||
first_cal = _device_calibration(root, "BOX-001")
|
||||
# The same device SN, a different calibration document: a live save always
|
||||
# keeps the device identity, which is what a real recalibration does.
|
||||
variant = root / "box" / "BOX-001-variant.json"
|
||||
document = json.loads(BOX_CALIBRATION.read_text(encoding="utf-8-sig"))
|
||||
document["identity"] = {"sensor_type": "box", "sn": "BOX-001"}
|
||||
# A real recalibration is a different document; changing one point keeps the
|
||||
# curve valid while making the content fingerprint different.
|
||||
document["channels"]["normal"]["points"][1][1] = 0.3771
|
||||
variant.write_text(json.dumps(document), encoding="utf-8")
|
||||
sensor = _external_sensor(
|
||||
config_name="box", calibration_dir=root, device_id="BOX-001"
|
||||
)
|
||||
try:
|
||||
before = sensor.context
|
||||
assert before.calibration_path == str(first_cal.resolve())
|
||||
|
||||
# The published live-swap entry stays available; the calibration identity
|
||||
# must still match the bound SN.
|
||||
sensor.set_calibration(variant)
|
||||
after = sensor.context
|
||||
assert after is not before
|
||||
assert after.sensor_id == before.sensor_id == "BOX-001"
|
||||
assert after.config_fingerprint == before.config_fingerprint
|
||||
assert after.bundle_root == before.bundle_root
|
||||
assert after.calibration_path == str(variant.resolve())
|
||||
assert after.calibration_fingerprint == sensor.calibration.fingerprint
|
||||
assert after.calibration_fingerprint != before.calibration_fingerprint
|
||||
|
||||
sensor.set_calibration(None)
|
||||
assert sensor.context.calibration_path is None
|
||||
assert sensor.context.calibration_fingerprint == "none"
|
||||
assert sensor.force_unit == "raw"
|
||||
finally:
|
||||
sensor.disconnect()
|
||||
|
||||
|
||||
def test_clearing_a_calibration_loaded_from_a_path_reports_no_path() -> None:
|
||||
"""The context describes the calibration in effect, not the path ever requested.
|
||||
|
||||
Constructing with an explicit ``cal_path`` then clearing the calibration must
|
||||
leave no calibration path behind: the old path would otherwise be recorded as
|
||||
if that calibration still shaped the measurements.
|
||||
"""
|
||||
sensor = _external_sensor(config_name="box", cal_path=str(BOX_CALIBRATION))
|
||||
try:
|
||||
assert sensor.context.calibration_path == str(BOX_CALIBRATION.resolve())
|
||||
assert sensor.force_unit == "N"
|
||||
|
||||
sensor.set_calibration(None)
|
||||
assert sensor.calibration is None
|
||||
assert sensor.context.calibration_path is None
|
||||
assert sensor.context.calibration_fingerprint == "none"
|
||||
assert sensor.force_unit == "raw"
|
||||
finally:
|
||||
sensor.disconnect()
|
||||
|
||||
|
||||
def test_binding_failure_is_reported_without_breaking_construction(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""A context problem must not turn a previously constructible Sensor into an error."""
|
||||
import orisys.sensors.context as context_module
|
||||
|
||||
def broken(**kwargs):
|
||||
raise RuntimeError("bundle exploded")
|
||||
|
||||
monkeypatch.setattr(context_module, "build_sensor_context", broken)
|
||||
sensor = _external_sensor(config_name="box")
|
||||
try:
|
||||
assert sensor.context.sensor_id is None
|
||||
assert sensor.context.config_name == "box"
|
||||
assert sensor.context.diagnostics == (
|
||||
"context binding failed: RuntimeError: bundle exploded",
|
||||
)
|
||||
assert sensor.context.calibration_fingerprint == "none"
|
||||
finally:
|
||||
sensor.disconnect()
|
||||
|
||||
|
||||
# --------------------------------------------------- configuration by path/name
|
||||
def test_config_binding_follows_the_actual_loaded_file(tmp_path: Path) -> None:
|
||||
custom = tmp_path / "custom-box.json"
|
||||
custom.write_text(BOX_CONFIG.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
sensor = _external_sensor(config_name=str(custom))
|
||||
try:
|
||||
context = sensor.context
|
||||
assert context.config_source == str(custom.resolve())
|
||||
assert context.config_name == str(custom)
|
||||
assert context.product_family is None
|
||||
assert context.bundle_root is None
|
||||
assert context.assets == ()
|
||||
assert any("no additive sensor bundle" in line for line in context.diagnostics)
|
||||
finally:
|
||||
sensor.disconnect()
|
||||
|
||||
# The recorded source is the file load_config() actually reads.
|
||||
assert configs.resolve_config_source("box") == BOX_CONFIG.resolve()
|
||||
assert configs.resolve_config_source(custom) == custom.resolve()
|
||||
assert configs.resolve_config_source("no-such-config-name") is None
|
||||
|
||||
|
||||
def test_same_configuration_has_the_same_config_fingerprint(tmp_path: Path) -> None:
|
||||
root = tmp_path / "calibrations"
|
||||
_device_calibration(root, "BOX-001")
|
||||
first = _external_sensor(config_name="box")
|
||||
second = _external_sensor(
|
||||
config_name="box", calibration_dir=root, device_id="BOX-001"
|
||||
)
|
||||
try:
|
||||
assert first.context.config_fingerprint == second.context.config_fingerprint
|
||||
assert (
|
||||
first.context.processing_backend
|
||||
== second.context.processing_backend
|
||||
== "compound_eye"
|
||||
)
|
||||
finally:
|
||||
first.disconnect()
|
||||
second.disconnect()
|
||||
|
||||
|
||||
# --------------------------------------------------------------- asset binding
|
||||
def test_bundle_assets_are_bound_as_relative_paths_once() -> None:
|
||||
finger_config = configs.load_config("finger")
|
||||
context = build_sensor_context(
|
||||
config_name="finger",
|
||||
config=finger_config,
|
||||
device_id="FINGER-001",
|
||||
bundle_candidates=["finger"],
|
||||
)
|
||||
assert context.sensor_id == "FINGER-001"
|
||||
assert context.product_family == "finger"
|
||||
assert context.bundle_root is not None
|
||||
assert context.manifest_digest is not None and len(context.manifest_digest) == 64
|
||||
roles = {asset.role for asset in context.assets}
|
||||
assert "assets_dir" in roles
|
||||
root = Path(context.bundle_root)
|
||||
for asset in context.assets:
|
||||
assert not Path(asset.path).is_absolute()
|
||||
assert (root / asset.path).parent.is_dir()
|
||||
|
||||
# Two bindings of the same bundle describe the same immutable assets.
|
||||
again = build_sensor_context(
|
||||
config_name="finger", config=finger_config, bundle_candidates=["finger"]
|
||||
)
|
||||
assert again.assets == context.assets
|
||||
assert again.manifest_digest == context.manifest_digest
|
||||
assert again.config_fingerprint == context.config_fingerprint
|
||||
Reference in New Issue
Block a user