Discover sensor bundles from the SDK catalog, not a product list
The picker listed a hard-coded product list and treated any *.json beside sensor.json as a configuration, so a folder of stray JSON could be offered as a sensor. Discovery now scans the shipped root and a writable user root beside it, keeps the SDK's verdict for every entry (listing rejected ones with their reason) and flags duplicate profile_ids instead of shadowing them; verified by shared/tests and apps/studio/tests.
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
"""The sensor configuration catalog: discovery, validity and duplicate profile ids.
|
||||
|
||||
The catalog replaced two rules that were wrong in the same direction — a hard-coded product
|
||||
list, and "any ``*.json`` beside a ``sensor.json`` is another configuration". These tests build
|
||||
a tree containing exactly the shapes those rules mishandled: a descriptor bundle, a legacy
|
||||
``sensor.json`` bundle, a folder holding only ``force_calibration.json``, a malformed
|
||||
``manifest.json``, a bundle whose declared asset is missing, and two bundles sharing one
|
||||
``profile_id``.
|
||||
|
||||
The tree is built here, in the test's own temporary directory: a committed fixture would be a
|
||||
second copy of the bundle format that could drift from the real one without anyone noticing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from orisys_app_shared.sensors import (
|
||||
FAMILY_UNIDENTIFIED,
|
||||
ORIGIN_FALLBACK,
|
||||
ORIGIN_SHIPPED,
|
||||
ORIGIN_USER,
|
||||
ROOT_KIND_FALLBACK,
|
||||
ROOT_KIND_SIBLING,
|
||||
STATE_UNREADABLE,
|
||||
resolve_user_root,
|
||||
scan_sensor_catalog,
|
||||
)
|
||||
|
||||
CAMERA_OK = {"resolution_raw": [640, 480], "fps": 30, "resolution_stitch": [100, 100]}
|
||||
TRACKER_OK = {"algorithm": "dis"}
|
||||
|
||||
|
||||
def _config(
|
||||
*, declared_type: str | None = None, stitcher: bool = False
|
||||
) -> dict[str, Any]:
|
||||
data: dict[str, Any] = {
|
||||
"name": "fixture",
|
||||
"camera": CAMERA_OK,
|
||||
"tracker": TRACKER_OK,
|
||||
}
|
||||
if declared_type:
|
||||
data["type"] = declared_type
|
||||
if stitcher:
|
||||
data["stitcher"] = {"n": [1, 1]}
|
||||
return data
|
||||
|
||||
|
||||
def _descriptor(profile_id: str, **overrides: Any) -> dict[str, Any]:
|
||||
data: dict[str, Any] = {
|
||||
"schema_version": 1,
|
||||
"profile_id": profile_id,
|
||||
"display_name": profile_id,
|
||||
"product_family": "mini",
|
||||
"processing_backend": "single_camera",
|
||||
"sensor_json": "sensor.json",
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
|
||||
def _write(path: Path, payload: object) -> Path:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
text = payload if isinstance(payload, str) else json.dumps(payload, indent=2)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def trees(tmp_path):
|
||||
"""One shipped root and one user root covering every shape the catalog must handle."""
|
||||
shipped = tmp_path / "shipped"
|
||||
user = tmp_path / "user"
|
||||
# A legacy bundle: a built-in folder name, sensor.json, and no manifest. The config carries
|
||||
# a stitcher block, which is what the SDK's own rule reads to reach compound_eye.
|
||||
_write(shipped / "box" / "sensor.json", _config(stitcher=True))
|
||||
# Arbitrary JSON only: not a configuration.
|
||||
_write(shipped / "calib-only" / "force_calibration.json", {"axes": ["fn", "fx"]})
|
||||
# A descriptor bundle.
|
||||
_write(user / "mini-lab" / "sensor.json", _config())
|
||||
_write(user / "mini-lab" / "manifest.json", _descriptor("orisys.mini.lab-r1"))
|
||||
# A manifest that is not JSON at all.
|
||||
_write(
|
||||
user / "broken-manifest" / "manifest.json",
|
||||
'{"schema_version": 1, "profile_id":',
|
||||
)
|
||||
# A descriptor whose declared computational asset is absent.
|
||||
_write(user / "missing-assets" / "sensor.json", _config())
|
||||
_write(
|
||||
user / "missing-assets" / "manifest.json",
|
||||
_descriptor(
|
||||
"orisys.finger.missing-r1",
|
||||
product_family="finger",
|
||||
processing_backend="uv_unroll",
|
||||
computational_assets={"unroll_map": "assets/not-here.npz"},
|
||||
),
|
||||
)
|
||||
# Two bundles declaring one profile_id.
|
||||
for name in ("dup-a", "dup-b"):
|
||||
_write(user / name / "sensor.json", _config())
|
||||
_write(user / name / "manifest.json", _descriptor("orisys.mini.duplicate-r1"))
|
||||
# No JSON document at all: a folder, not a bundle.
|
||||
_write(user / "notes" / "readme.txt", "not a bundle")
|
||||
return scan_sensor_catalog(shipped_root=shipped, user_root=user)
|
||||
|
||||
|
||||
def test_every_bundle_shape_is_discovered_with_its_origin(trees):
|
||||
refs = {entry.ref for entry in trees.entries}
|
||||
assert refs == {
|
||||
"shipped/box",
|
||||
"shipped/calib-only",
|
||||
"user/mini-lab",
|
||||
"user/broken-manifest",
|
||||
"user/missing-assets",
|
||||
"user/dup-a",
|
||||
"user/dup-b",
|
||||
}, f"a folder with no JSON document is not a bundle candidate: {sorted(refs)}"
|
||||
assert trees.by_ref("shipped/box").origin == ORIGIN_SHIPPED
|
||||
assert trees.by_ref("user/mini-lab").origin == ORIGIN_USER
|
||||
|
||||
|
||||
def test_a_calibration_document_is_not_offered_as_a_configuration(trees):
|
||||
entry = trees.by_ref("shipped/calib-only")
|
||||
assert entry is not None, "the folder is retained, so its reason can be read"
|
||||
assert not entry.valid
|
||||
assert "force_calibration.json" in entry.reason
|
||||
assert "not offered as one" in entry.reason
|
||||
# Nothing about it may be presented as a product identity.
|
||||
assert entry.product_family == ""
|
||||
assert entry.family == FAMILY_UNIDENTIFIED
|
||||
assert entry.profile_id == ""
|
||||
assert entry.start_blocked_reason == entry.reason
|
||||
|
||||
|
||||
def test_a_malformed_manifest_is_invalid_with_the_sdks_own_reason(trees):
|
||||
entry = trees.by_ref("user/broken-manifest")
|
||||
assert entry is not None and not entry.valid
|
||||
assert entry.state == STATE_UNREADABLE
|
||||
assert "unreadable descriptor" in entry.reason, entry.reason
|
||||
|
||||
|
||||
def test_the_two_invalid_shapes_carry_distinct_reasons(trees):
|
||||
calibration_only = trees.by_ref("shipped/calib-only").reason
|
||||
malformed = trees.by_ref("user/broken-manifest").reason
|
||||
assert calibration_only != malformed
|
||||
assert "force_calibration.json" in calibration_only
|
||||
assert "force_calibration.json" not in malformed
|
||||
assert "unreadable descriptor" in malformed
|
||||
assert "unreadable descriptor" not in calibration_only
|
||||
|
||||
|
||||
def test_a_descriptor_and_a_legacy_bundle_both_resolve(trees):
|
||||
descriptor = trees.by_ref("user/mini-lab")
|
||||
assert descriptor.valid and descriptor.profile is not None
|
||||
assert descriptor.profile_id == "orisys.mini.lab-r1"
|
||||
assert descriptor.product_family == "mini"
|
||||
assert descriptor.processing_backend == "single_camera"
|
||||
assert descriptor.sensor_json_path == descriptor.bundle_path / "sensor.json"
|
||||
assert descriptor.start_blocked_reason is None
|
||||
|
||||
legacy = trees.by_ref("shipped/box")
|
||||
assert legacy.valid and legacy.profile is not None
|
||||
# The identity is the SDK's built-in one for that folder ...
|
||||
assert legacy.profile_id == "orisys.box.legacy"
|
||||
assert legacy.product_family == "box"
|
||||
# ... and the backend is the configuration's (the stitcher block), decided by the SDK.
|
||||
assert legacy.processing_backend == "compound_eye"
|
||||
assert legacy.sensor_json_path == legacy.bundle_path / "sensor.json"
|
||||
|
||||
|
||||
def test_a_legacy_config_that_contradicts_the_builtin_table_is_listed_with_the_sdks_reason(
|
||||
tmp_path,
|
||||
):
|
||||
"""A folder name identifies a legacy bundle; it must not decide its backend.
|
||||
|
||||
The configuration wins — the SDK says so and this asserts it — but the SDK then validates
|
||||
the backend requirements against the *descriptor's* pre-override backend, so a legacy
|
||||
bundle whose config contradicts the built-in table fails a check that does not apply to the
|
||||
backend it actually uses. That is an SDK-side finding, reported rather than worked around
|
||||
here: the catalog's job is to pass the verdict on, not to second-guess it. If this test ever
|
||||
fails because the entry became valid, the SDK fixed it: delete the ``not entry.valid``
|
||||
assertion and record where.
|
||||
"""
|
||||
shipped = tmp_path / "shipped"
|
||||
_write(shipped / "box" / "sensor.json", _config(declared_type="single_camera"))
|
||||
|
||||
entry = scan_sensor_catalog(
|
||||
shipped_root=shipped, user_root=tmp_path / "user"
|
||||
).by_ref("shipped/box")
|
||||
assert entry.profile is not None
|
||||
assert entry.profile.processing_backend == "single_camera", (
|
||||
"the config decides the backend"
|
||||
)
|
||||
assert entry.product_family == "box", "the folder name decides the legacy identity"
|
||||
assert not entry.valid
|
||||
assert "stitcher" in entry.reason
|
||||
assert entry.start_blocked_reason == entry.reason
|
||||
|
||||
|
||||
def test_a_shared_profile_id_is_flagged_and_both_entries_stay_listed(trees):
|
||||
first = trees.by_ref("user/dup-a")
|
||||
second = trees.by_ref("user/dup-b")
|
||||
assert first.valid and second.valid, "sharing a profile_id is not itself a failure"
|
||||
assert first.profile_id == second.profile_id == "orisys.mini.duplicate-r1"
|
||||
assert first.duplicates == ("user/dup-b",)
|
||||
assert second.duplicates == ("user/dup-a",)
|
||||
assert first.duplicate and second.duplicate
|
||||
assert "user/dup-b" in first.start_blocked_reason
|
||||
assert "user/dup-a" in second.start_blocked_reason
|
||||
# Nothing is shadowed: both are still offered under their family, and by key.
|
||||
offered = {entry.key for entry in trees.entries_for_family("mini")}
|
||||
assert {first.key, second.key} <= offered
|
||||
assert trees.start_blocked(first.key) == first.start_blocked_reason
|
||||
assert trees.start_blocked(second.key) == second.start_blocked_reason
|
||||
|
||||
|
||||
def test_a_missing_computational_asset_is_reported_and_keeps_its_identity(trees):
|
||||
entry = trees.by_ref("user/missing-assets")
|
||||
assert not entry.valid
|
||||
assert entry.state == "failed", "the SDK's own state, not the catalog's"
|
||||
assert entry.profile_id == "orisys.finger.missing-r1"
|
||||
assert entry.product_family == "finger"
|
||||
assert "unroll_map" in entry.reason
|
||||
assert entry.start_blocked_reason
|
||||
|
||||
|
||||
def test_an_entry_the_sdk_rejects_is_never_reported_as_startable(trees):
|
||||
for entry in trees.entries:
|
||||
expected = entry.valid and not entry.duplicate
|
||||
assert (entry.start_blocked_reason is None) == expected, entry.ref
|
||||
|
||||
|
||||
def test_the_label_states_family_profile_origin_and_validity(trees):
|
||||
entry = trees.by_ref("user/missing-assets")
|
||||
label = entry.label()
|
||||
assert "finger" in label
|
||||
assert entry.profile_id in label
|
||||
assert ORIGIN_USER in label
|
||||
assert entry.state in label
|
||||
|
||||
|
||||
def test_the_root_decision_names_the_root_in_use():
|
||||
decision = resolve_user_root()
|
||||
assert decision.kind in (ROOT_KIND_SIBLING, ROOT_KIND_FALLBACK)
|
||||
assert str(decision.path) in decision.reason
|
||||
|
||||
|
||||
def test_an_unwritable_sibling_falls_back_to_the_per_user_root(tmp_path, monkeypatch):
|
||||
from orisys_app_shared.sensors import bundle_catalog
|
||||
|
||||
sibling = tmp_path / "no" / "such" / "sensors-user"
|
||||
fallback = tmp_path / "per-user"
|
||||
monkeypatch.setattr(bundle_catalog, "user_sensors_dir", lambda: sibling)
|
||||
monkeypatch.setattr(bundle_catalog, "fallback_user_sensors_dir", lambda: fallback)
|
||||
|
||||
decision = bundle_catalog.resolve_user_root()
|
||||
assert decision.kind == ROOT_KIND_FALLBACK
|
||||
assert decision.path == fallback
|
||||
assert decision.writable is True
|
||||
assert str(sibling) in decision.reason
|
||||
|
||||
|
||||
def test_entries_under_the_fallback_root_keep_that_origin(tmp_path, monkeypatch):
|
||||
from orisys_app_shared.sensors import bundle_catalog
|
||||
|
||||
shipped = tmp_path / "shipped"
|
||||
fallback = tmp_path / "per-user"
|
||||
_write(fallback / "mini-lab" / "sensor.json", _config())
|
||||
_write(fallback / "mini-lab" / "manifest.json", _descriptor("orisys.mini.lab-r1"))
|
||||
monkeypatch.setattr(
|
||||
bundle_catalog, "user_sensors_dir", lambda: tmp_path / "no" / "such"
|
||||
)
|
||||
monkeypatch.setattr(bundle_catalog, "fallback_user_sensors_dir", lambda: fallback)
|
||||
|
||||
catalog = bundle_catalog.scan_sensor_catalog(shipped_root=shipped)
|
||||
assert [entry.ref for entry in catalog.entries_for_origin(ORIGIN_FALLBACK)] == [
|
||||
"fallback/mini-lab"
|
||||
]
|
||||
assert catalog.user_root_kind == ROOT_KIND_FALLBACK
|
||||
|
||||
|
||||
def test_an_absent_root_is_not_an_error(tmp_path):
|
||||
catalog = scan_sensor_catalog(
|
||||
shipped_root=tmp_path / "gone", user_root=tmp_path / "also-gone"
|
||||
)
|
||||
assert catalog.entries == ()
|
||||
assert catalog.roots[0].exists is False
|
||||
assert catalog.user_root_exists is False
|
||||
assert catalog.families() == []
|
||||
Reference in New Issue
Block a user