diff --git a/.github/workflows/test-symmetry.yml b/.github/workflows/test-symmetry.yml index 6b5dcad..4fa8da8 100644 --- a/.github/workflows/test-symmetry.yml +++ b/.github/workflows/test-symmetry.yml @@ -96,6 +96,17 @@ jobs: symDir = fullfile(pwd, "tests", "+ndi", "+symmetry"); matbox.installRequirements(symDir); + % Persist the fully-resolved search path (NDI dirs + the matbox + % dependencies just installed) so Stage 3 can reuse this one + % installation instead of running installRequirements a second + % time. A second install re-validates the already-installed repos + % and would fail any dependency that lacks a LICENSE/README. + savedPathFile = fullfile(getenv("GITHUB_WORKSPACE"), "ndi_symmetry_matlabpath.txt"); + fid = fopen(savedPathFile, "w"); + assert(fid > 0, "Could not write saved MATLAB path file."); + fprintf(fid, "%s", path()); + fclose(fid); + import matlab.unittest.TestSuite import matlab.unittest.TestRunner @@ -123,17 +134,23 @@ jobs: pytest tests/symmetry/read_artifacts/ -v --tb=short # ── Stage 3: MATLAB readArtifacts ────────────────────────────────── + # Reuse the installation from Stage 1: restore the saved search path + # instead of re-running installRequirements. The dependency files + # persist on the runner between steps; only the path is lost when the + # fresh MATLAB session starts. This avoids a redundant install and the + # re-validation that install would perform on the existing repos. - name: "Stage 3: MATLAB readArtifacts (reads Python artifacts)" uses: matlab-actions/run-command@v2 with: command: | cd("NDI-matlab"); - addpath(genpath("src")); - addpath(genpath("tests")); - addpath(genpath("tools")); + + savedPathFile = fullfile(getenv("GITHUB_WORKSPACE"), "ndi_symmetry_matlabpath.txt"); + assert(isfile(savedPathFile), ... + "Saved MATLAB path from Stage 1 is missing; cannot reuse install."); + path(fileread(savedPathFile)); symDir = fullfile(pwd, "tests", "+ndi", "+symmetry"); - matbox.installRequirements(symDir); import matlab.unittest.TestSuite import matlab.unittest.TestRunner diff --git a/src/ndi/cloud/api/compute.py b/src/ndi/cloud/api/compute.py index 8201ce2..22018a1 100644 --- a/src/ndi/cloud/api/compute.py +++ b/src/ndi/cloud/api/compute.py @@ -59,10 +59,14 @@ def triggerStage( @_auto_client @validate_call(config=VALIDATE_CONFIG) -def finalizeSession(session_id: NonEmptyStr, *, client: _Client = None) -> dict[str, Any]: - """POST /compute/{sessionId}/finalize""" +def advanceSession(session_id: NonEmptyStr, *, client: _Client = None) -> dict[str, Any]: + """POST /compute/{sessionId}/advance -- Advance a session to the next stage. + + Advancing past the last stage finalizes the session; the cloud API exposes + no separate ``/finalize`` endpoint. + """ return client.post( - "/compute/{sessionId}/finalize", + "/compute/{sessionId}/advance", sessionId=session_id, ) diff --git a/src/ndi/cloud/api/ndi_matlab_python_bridge.yaml b/src/ndi/cloud/api/ndi_matlab_python_bridge.yaml index 750b5b0..2cfb5db 100644 --- a/src/ndi/cloud/api/ndi_matlab_python_bridge.yaml +++ b/src/ndi/cloud/api/ndi_matlab_python_bridge.yaml @@ -1261,8 +1261,8 @@ functions: type_python: "dict[str, Any]" decision_log: "Exact match." - - name: finalizeSession - matlab_path: "+ndi/+cloud/+api/+compute/finalizeSession.m" + - name: advanceSession + matlab_path: "+ndi/+cloud/+api/+compute/advanceSession.m" matlab_last_sync_hash: "c5718fde" python_path: "ndi/cloud/api/compute.py" input_arguments: diff --git a/src/ndi/time/syncgraph.py b/src/ndi/time/syncgraph.py index 1ce0a31..a36741a 100644 --- a/src/ndi/time/syncgraph.py +++ b/src/ndi/time/syncgraph.py @@ -415,6 +415,16 @@ def time_convert( """ from .timereference import ndi_time_timereference + # Same-referent fast path: when the input and output referents are the + # same object, the conversion can be resolved from the referent's own + # epochtable without consulting the syncgraph (no DAQ readers, no graph + # construction). This mirrors ndi.time.syncgraph/time_convert in + # NDI-matlab, which is the symmetry reference. + if self._is_same_referent(timeref_in.referent, referent_out) and hasattr( + timeref_in.referent, "epochtable" + ): + return self._same_referent_convert(timeref_in, t_in, referent_out, clocktype_out) + # Get graph info ginfo = self.graphinfo() @@ -479,6 +489,129 @@ def time_convert( return t_out, timeref_out, "" + @staticmethod + def _is_same_referent(referent_a: Any, referent_b: Any) -> bool: + """Return True if two referents denote the same object. + + Uses identity first, then falls back to the referent's own equality + (``==``) so that two live objects describing the same element compare + equal, mirroring the MATLAB ``timeref_in.referent == referent_out`` + check. + """ + if referent_a is referent_b: + return True + try: + return bool(referent_a == referent_b) + except Exception: + return False + + @staticmethod + def _normalize_epochtable(referent: Any) -> list[dict[str, Any]]: + """Return a referent's epoch table as a list of entries. + + ``ndi.epoch.epochset.epochtable`` returns a ``(table, hash)`` tuple, + while lightweight referents may return the list directly; accept both. + """ + et = referent.epochtable() + if isinstance(et, tuple): + et = et[0] + return list(et) if et is not None else [] + + @staticmethod + def _clock_index(entry: dict[str, Any], clocktype: ndi_time_clocktype) -> int | None: + """Find the index of CLOCKTYPE within an epoch table entry's clocks.""" + clocks = entry.get("epoch_clock", []) + for k, clk in enumerate(clocks): + if clk == clocktype: + return k + return None + + def _same_referent_convert( + self, + timeref_in: ndi_time_timereference, + t_in: float, + referent_out: Any, + clocktype_out: ndi_time_clocktype, + ) -> tuple[float | None, ndi_time_timereference | None, str]: + """Resolve a time conversion from a single referent's epoch table. + + Counterpart of the same-referent branch in MATLAB + ``ndi.time.syncgraph/time_convert``. All inputs share one referent, so + the epoch (and hence the linear rescaling between two clocks) is read + straight from the referent's epoch table. + """ + from .timereference import ndi_time_timereference + + t0 = timeref_in.time or 0.0 + et = self._normalize_epochtable(timeref_in.referent) + + # Step 0: resolve the input epoch id. + in_epochid = timeref_in.epoch + if isinstance(in_epochid, (int, np.integer)): + # Numeric epoch -> id (1-indexed, matching MATLAB epochid()). + if 1 <= int(in_epochid) <= len(et): + in_epochid = et[int(in_epochid) - 1].get("epoch_id") + else: + return None, None, "ERROR:epochOutOfRange" + + if in_epochid is None or in_epochid == "": + # Only resolvable for a global clock: find the epoch whose range for + # this clock contains the requested time. + if not clocktype_out.is_global() or not timeref_in.clocktype.is_global(): + return None, None, "ERROR:noEpochForLocalClock" + target = t0 + t_in + in_epochid = None + for entry in et: + k = self._clock_index(entry, timeref_in.clocktype) + if k is None: + continue + lo, hi = entry["t0_t1"][k] + if lo <= target <= hi: + in_epochid = entry.get("epoch_id") + break + if in_epochid is None: + return None, None, "ERROR:noParentEpoch" + + # Locate the matching epoch entry. + match = next((e for e in et if e.get("epoch_id") == in_epochid), None) + if match is None: + return None, None, "ERROR:missingEpoch" + + # Same clock: identity conversion. + if timeref_in.clocktype == clocktype_out: + timeref_out = ndi_time_timereference( + referent=referent_out, + clocktype=clocktype_out, + epoch=in_epochid, + time=t0, + ) + return t_in, timeref_out, "" + + # Different clocks within the epoch: linear rescale between ranges. + j1 = self._clock_index(match, timeref_in.clocktype) + j2 = self._clock_index(match, clocktype_out) + if j1 is None: + return None, None, "ERROR:noInputClock" + if j2 is None: + return None, None, "ERROR:noOutputClock" + + in_lo, in_hi = match["t0_t1"][j1] + out_lo, out_hi = match["t0_t1"][j2] + # Correct the input range for the reference time, then rescale (noclip). + corrected_lo, corrected_hi = in_lo - t0, in_hi - t0 + span = corrected_hi - corrected_lo + if span == 0: + return None, None, "ERROR:degenerateRange" + t_out = out_lo + (t_in - corrected_lo) * (out_hi - out_lo) / span + + timeref_out = ndi_time_timereference( + referent=referent_out, + clocktype=clocktype_out, + epoch=in_epochid, + time=0, + ) + return t_out, timeref_out, "" + def _find_epoch_node( self, nodes: list[ndi_time_epochnode], diff --git a/tests/matlab_tests/test_cloud_compute.py b/tests/matlab_tests/test_cloud_compute.py index 51c9414..222bc54 100644 --- a/tests/matlab_tests/test_cloud_compute.py +++ b/tests/matlab_tests/test_cloud_compute.py @@ -128,15 +128,18 @@ def test_triggerStage_mocked(self): result = triggerStage("session-abc-123", "stage-1", client=client) assert result["status"] == "triggered" - def test_finalizeSession_mocked(self): - """finalizeSession calls the correct endpoint (mocked).""" - from ndi.cloud.api.compute import finalizeSession + def test_advanceSession_mocked(self): + """advanceSession calls the correct endpoint (mocked).""" + from ndi.cloud.api.compute import advanceSession client = MagicMock() - client.post.return_value = {"status": "finalized"} + client.post.return_value = {"status": "advanced"} - result = finalizeSession("session-abc-123", client=client) - assert result["status"] == "finalized" + result = advanceSession("session-abc-123", client=client) + assert result["status"] == "advanced" + client.post.assert_called_once_with( + "/compute/{sessionId}/advance", sessionId="session-abc-123" + ) # ---- Live tests ---- @@ -149,11 +152,11 @@ def test_hello_world_flow_live(self): 3. List sessions (verify new session in list) 4. Abort session (cleanup) 5. triggerStage (expect possible error, just verify no crash) - 6. finalizeSession (expect possible error, just verify no crash) + 6. advanceSession (expect possible error, just verify no crash) """ from ndi.cloud.api.compute import ( abortSession, - finalizeSession, + advanceSession, getSessionStatus, listSessions, startSession, @@ -198,9 +201,9 @@ def test_hello_world_flow_live(self): except Exception: pass # Expected: session may be gone - # 6. finalizeSession — just verify no crash + # 6. advanceSession — just verify no crash try: - finalizeSession(session_id, client=client) + advanceSession(session_id, client=client) except Exception: pass # Expected: session may be gone diff --git a/tests/symmetry/_time_scenario.py b/tests/symmetry/_time_scenario.py new file mode 100644 index 0000000..01b207e --- /dev/null +++ b/tests/symmetry/_time_scenario.py @@ -0,0 +1,200 @@ +"""Shared, self-describing time/syncgraph symmetry scenario (Python side). + +Python counterpart of NDI-matlab's ``tests/+ndi/+symmetry/+time/scenario.m``. +Both language ports build a referent from the same ``SCENARIO`` (a referent with +two multi-clock epochs), run the same ``CASES`` through their real +``ndi.time.syncgraph.time_convert``, and must agree on the recorded outputs +(``out_time`` / ``out_epoch`` / ``msg``) written to ``timeConvertCases.json``. + +Every case is same-referent (``in_ref == out_ref == "probeA"``), so +``time_convert`` resolves it from the referent's epoch table alone (the +same-referent fast path) -- no syncgraph construction, no DAQ readers. + +MATLAB is the symmetry reference: :func:`expected` lists the authoritative +outputs and the makeArtifacts test asserts each case matches before writing the +artifact. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from ndi.time.clocktype import ndi_time_clocktype +from ndi.time.syncgraph import ndi_time_syncgraph +from ndi.time.timereference import ndi_time_timereference + +# The data the referent + the JSON "scenario" block are built from. +# Mirrors the MATLAB scenario.scenarioStruct(). +SCENARIO: dict[str, Any] = { + "referents": [ + { + "name": "probeA", + "id": "id_probeA", + "epochs": [ + { + "epoch_id": "ep1", + "clocks": ["dev_local_time", "exp_global_time"], + "t0_t1": [[0, 10], [100, 110]], + }, + { + "epoch_id": "ep2", + "clocks": ["dev_local_time", "exp_global_time"], + "t0_t1": [[0, 5], [200, 205]], + }, + ], + } + ] +} + +# Session id used by the throwaway referent/timereferences. The same-referent +# fast path never consults the syncgraph, so the value only needs to be stable. +_SESSION_ID = "symref_time_scenario" + + +@dataclass +class _SpecRef: + """A minimal referent built straight from ``SCENARIO``. + + Provides exactly the contract the same-referent ``time_convert`` path needs: + a name (``epochsetname``), a session id, an ``epochtable`` of multi-clock + epochs, and ``==`` so ``timeref_in.referent == referent_out`` holds. This is + the Python analogue of the real ``ndi.element`` the MATLAB scenario builds. + """ + + name: str + id: str + session_id: str = _SESSION_ID + _epochs: list[dict[str, Any]] = field(default_factory=list) + + def epochsetname(self) -> str: + return self.name + + def epochtable(self) -> list[dict[str, Any]]: + # Each entry mirrors ndi.epoch.epochset epochtable rows: clocks become + # ndi_time_clocktype objects, t0_t1 a parallel list of (t0, t1) tuples. + table = [] + for e in self._epochs: + table.append( + { + "epoch_id": e["epoch_id"], + "epoch_clock": [ndi_time_clocktype.from_string(c) for c in e["clocks"]], + "t0_t1": [tuple(float(x) for x in r) for r in e["t0_t1"]], + } + ) + return table + + def __eq__(self, other: object) -> bool: + if not isinstance(other, _SpecRef): + return NotImplemented + return self.name == other.name and self.id == other.id + + def __hash__(self) -> int: + return hash((self.name, self.id)) + + +def build_referent() -> _SpecRef: + """Construct the scenario referent from ``SCENARIO`` (mirrors buildReferent).""" + r = SCENARIO["referents"][0] + return _SpecRef(name=r["name"], id=r["id"], _epochs=r["epochs"]) + + +def case_defs() -> list[dict[str, Any]]: + """The 7 input cases. ``out_*`` are placeholders filled by :func:`run_cases`. + + Mirrors the MATLAB scenario.caseDefs(); missing epochs are ``None`` (JSON + null) and missing times are ``None`` (NaN on the MATLAB side). + """ + + def mk(in_ref, in_clock, in_epoch, in_time, out_ref, out_clock): + return { + "in_ref": in_ref, + "in_clock": in_clock, + "in_epoch": in_epoch, + "in_time": in_time, + "out_ref": out_ref, + "out_clock": out_clock, + "out_time": None, + "out_epoch": None, + "msg": "", + } + + return [ + mk("probeA", "dev_local_time", "ep1", 5.0, "probeA", "dev_local_time"), + mk("probeA", "dev_local_time", "ep1", 5.0, "probeA", "exp_global_time"), + mk("probeA", "dev_local_time", "ep1", 0.0, "probeA", "exp_global_time"), + mk("probeA", "dev_local_time", "ep1", 10.0, "probeA", "exp_global_time"), + mk("probeA", "dev_local_time", "ep2", 2.5, "probeA", "exp_global_time"), + mk("probeA", "exp_global_time", None, 105.0, "probeA", "exp_global_time"), + mk("probeA", "exp_global_time", None, 202.5, "probeA", "exp_global_time"), + ] + + +def run_cases() -> list[dict[str, Any]]: + """Run every case through the real ``time_convert``; return filled cases. + + Errors are recorded as data (``msg = "ERROR:..."``) rather than raised, + mirroring the MATLAB scenario.runCases(). + """ + defs = case_defs() + referent = build_referent() + sg = ndi_time_syncgraph(session=None) # graph unused on the same-referent path + results = [] + for c in defs: + out = dict(c) + ct_in = ndi_time_clocktype.from_string(c["in_clock"]) + ct_out = ndi_time_clocktype.from_string(c["out_clock"]) + try: + tref_in = ndi_time_timereference( + referent=referent, + clocktype=ct_in, + epoch=c["in_epoch"], + time=0, + ) + t_out, tref_out, msg = sg.time_convert(tref_in, c["in_time"], referent, ct_out) + if msg and msg.startswith("ERROR:"): + out["out_time"] = None + out["out_epoch"] = None + out["msg"] = msg + else: + out["out_time"] = None if t_out is None else round(float(t_out), 9) + out["out_epoch"] = tref_out.epoch if tref_out is not None else None + out["msg"] = msg or "" + except Exception as exc: # record errors as data, like MATLAB + out["out_time"] = None + out["out_epoch"] = None + out["msg"] = f"ERROR:{type(exc).__name__}" + results.append(out) + return results + + +def expected() -> list[dict[str, Any]]: + """MATLAB-authoritative correct outputs for :func:`case_defs`, in order. + + Within an epoch, time rescales linearly between the two clocks' ranges, and + same-clock conversions are the identity. + """ + return [ + {"exp_time": 5.0, "exp_epoch": "ep1"}, # dev_local identity + {"exp_time": 105.0, "exp_epoch": "ep1"}, # [0 10]->[100 110]: 5->105 + {"exp_time": 100.0, "exp_epoch": "ep1"}, # 0->100 + {"exp_time": 110.0, "exp_epoch": "ep1"}, # 10->110 + {"exp_time": 202.5, "exp_epoch": "ep2"}, # ep2 [0 5]->[200 205]: 2.5->202.5 + {"exp_time": 105.0, "exp_epoch": "ep1"}, # exp_global identity (in ep1) + {"exp_time": 202.5, "exp_epoch": "ep2"}, # exp_global identity (in ep2) + ] + + +def verify_expected(results: list[dict[str, Any]]) -> None: + """Assert computed RESULTS match the expected (reference) outputs.""" + exp = expected() + assert len(results) == len(exp), f"Number of results ({len(results)}) != expected ({len(exp)})." + for i, (r, e) in enumerate(zip(results, exp)): + assert r["msg"] == "", f"Case {i} produced error: {r['msg']!r}" + assert r["out_time"] is not None, f"Case {i} out_time is null." + assert ( + abs(float(r["out_time"]) - e["exp_time"]) < 1e-9 + ), f"Case {i} out_time mismatch: {r['out_time']} != {e['exp_time']}" + assert ( + r["out_epoch"] == e["exp_epoch"] + ), f"Case {i} out_epoch mismatch: {r['out_epoch']} != {e['exp_epoch']}" diff --git a/tests/symmetry/make_artifacts/time/__init__.py b/tests/symmetry/make_artifacts/time/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/symmetry/make_artifacts/time/test_time_convert.py b/tests/symmetry/make_artifacts/time/test_time_convert.py new file mode 100644 index 0000000..7ea4546 --- /dev/null +++ b/tests/symmetry/make_artifacts/time/test_time_convert.py @@ -0,0 +1,59 @@ +"""Generate the time_convert symmetry artifact for cross-language comparison. + +Python equivalent of: + tests/+ndi/+symmetry/+makeArtifacts/+time/timeConvert.m + +Runs the shared :mod:`tests.symmetry._time_scenario` battery through the real +``ndi.time.syncgraph.time_convert`` and writes the scenario + computed outputs +to: + + /NDI/symmetryTest/pythonArtifacts/time/timeConvert/ + testTimeConvertArtifacts/timeConvertCases.json + +The MATLAB counterpart writes the same structure under ``matlabArtifacts/``; +the readArtifacts tests on both sides compare them. + +MATLAB is the symmetry reference: this test ASSERTS that every scenario case +converts without error and equals the expected reference output +(:func:`tests.symmetry._time_scenario.expected`) before writing the artifact, so +a time_convert regression fails here loudly rather than silently writing a +divergent artifact. +""" + +import json +import shutil + +from tests.symmetry import _time_scenario as scenario +from tests.symmetry.conftest import PYTHON_ARTIFACTS + +ARTIFACT_DIR = PYTHON_ARTIFACTS / "time" / "timeConvert" / "testTimeConvertArtifacts" + + +class TestTimeConvertMakeArtifacts: + """Mirror of ndi.symmetry.makeArtifacts.time.timeConvert.""" + + def test_time_convert_artifacts(self): + results = scenario.run_cases() + + # MATLAB is the reference: assert every case converted without error AND + # produced the expected (reference) value before writing the artifact. + msgs = [r["msg"] for r in results if r["msg"]] + assert not msgs, "time_convert produced error rows: " + "; ".join(msgs) + scenario.verify_expected(results) + + artifact_dir = ARTIFACT_DIR + if artifact_dir.exists(): + shutil.rmtree(artifact_dir) + artifact_dir.mkdir(parents=True, exist_ok=True) + + payload = { + "description": "syncgraph time_convert symmetry cases", + "scenario": scenario.SCENARIO, + "cases": results, + } + + out_file = artifact_dir / "timeConvertCases.json" + out_file.write_text(json.dumps(payload, indent=2), encoding="utf-8") + + assert out_file.is_file(), "Artifact file was not written." + assert len(results) == 7, "Expected 7 recorded cases." diff --git a/tests/symmetry/read_artifacts/time/__init__.py b/tests/symmetry/read_artifacts/time/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/symmetry/read_artifacts/time/test_time_convert.py b/tests/symmetry/read_artifacts/time/test_time_convert.py new file mode 100644 index 0000000..759e975 --- /dev/null +++ b/tests/symmetry/read_artifacts/time/test_time_convert.py @@ -0,0 +1,124 @@ +"""Read and verify the time_convert symmetry artifacts written by both languages. + +Python equivalent of: + tests/+ndi/+symmetry/+readArtifacts/+time/timeConvert.m + +Two checks, each skipping (not failing) when the required artifact is absent: + + * ``test_artifacts_reproduce`` - re-run the scenario with the current Python + ``time_convert`` and confirm it reproduces the recorded outputs for the given + source (a regression guard for ``pythonArtifacts``; a cross-language + symmetry assertion for ``matlabArtifacts``). + * ``test_matlab_python_symmetry`` - assert MATLAB's recorded ``out_*`` values + match Python's ``pythonArtifacts`` for the same cases. + +MATLAB is the symmetry reference; Python must match it. +""" + +import json +import math + +import pytest + +from tests.symmetry import _time_scenario as scenario +from tests.symmetry.conftest import SOURCE_TYPES, SYMMETRY_BASE + +REL_PATH = ("time", "timeConvert", "testTimeConvertArtifacts", "timeConvertCases.json") + + +def _artifact_file(source_type: str): + return SYMMETRY_BASE.joinpath(source_type, *REL_PATH) + + +def _norm_txt(v) -> str: + """Collapse empty/null text (None, "", missing) to a single token. + + Mirrors the MATLAB ``txt`` helper so an empty msg/epoch compares equal + regardless of how each language rendered it across the JSON round-trip. + """ + if v is None: + return "" + s = str(v) + return "" if s == "" else s + + +def _num_val(v) -> float: + """Coerce a recorded out_time (number or null) to float, null -> NaN.""" + if v is None: + return math.nan + return float(v) + + +def _case_key(c: dict) -> str: + """Build the order-independent comparison key for a case (matches MATLAB).""" + return "|".join( + [ + _norm_txt(c.get("in_ref")), + _norm_txt(c.get("in_clock")), + _norm_txt(c.get("in_epoch")), + f"{float(c['in_time']):.9g}", + _norm_txt(c.get("out_ref")), + _norm_txt(c.get("out_clock")), + ] + ) + + +def _load_cases(file) -> dict: + """Load a timeConvertCases.json into a map of caseKey -> case dict.""" + payload = json.loads(file.read_text(encoding="utf-8")) + return {_case_key(c): c for c in payload["cases"]} + + +def _index_cases(results: list) -> dict: + return {_case_key(c): c for c in results} + + +def _compare_maps(a: dict, b: dict, label: str) -> None: + assert sorted(a.keys()) == sorted(b.keys()), f"{label}: case sets differ." + for key, ca in a.items(): + cb = b[key] + ta, tb = _num_val(ca.get("out_time")), _num_val(cb.get("out_time")) + if math.isnan(ta) or math.isnan(tb): + assert math.isnan(ta) and math.isnan(tb), f"{label} out_time null mismatch for {key}" + else: + assert abs(ta - tb) < 1e-6, f"{label} out_time mismatch for {key}" + assert _norm_txt(ca.get("out_epoch")) == _norm_txt( + cb.get("out_epoch") + ), f"{label} out_epoch mismatch for {key}" + assert _norm_txt(ca.get("msg")) == _norm_txt( + cb.get("msg") + ), f"{label} msg mismatch for {key}" + + +@pytest.fixture(params=SOURCE_TYPES) +def source_type(request): + """Parameterize over matlabArtifacts / pythonArtifacts.""" + return request.param + + +class TestTimeConvertReadArtifacts: + """Mirror of ndi.symmetry.readArtifacts.time.timeConvert.""" + + def test_artifacts_reproduce(self, source_type): + artifact_file = _artifact_file(source_type) + if not artifact_file.is_file(): + pytest.skip( + f"{source_type} time_convert artifact missing. " + f"Run the corresponding makeArtifacts suite first." + ) + + recorded = _load_cases(artifact_file) + fresh = _index_cases(scenario.run_cases()) + _compare_maps(recorded, fresh, f"{source_type} recorded vs fresh Python") + + def test_matlab_python_symmetry(self): + ml_file = _artifact_file("matlabArtifacts") + py_file = _artifact_file("pythonArtifacts") + if not ml_file.is_file(): + pytest.skip("matlabArtifacts time_convert artifact missing.") + if not py_file.is_file(): + pytest.skip("pythonArtifacts time_convert artifact missing.") + + ml = _load_cases(ml_file) + py = _load_cases(py_file) + _compare_maps(ml, py, "MATLAB vs Python")