diff --git a/.github/workflows/test-symmetry.yml b/.github/workflows/test-symmetry.yml new file mode 100644 index 0000000..36ade01 --- /dev/null +++ b/.github/workflows/test-symmetry.yml @@ -0,0 +1,139 @@ +name: Test Cross-Language Symmetry + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + symmetry: + name: MATLAB <-> Python symmetry tests + runs-on: ubuntu-latest + + steps: + # -- Checkout both repos ---------------------------------------------- + - name: Check out NDR-python + uses: actions/checkout@v4 + with: + path: NDR-python + + # Check out NDR-matlab at the same branch name as this workflow run + # (e.g. the PR's head branch or the branch being pushed to main). + # Developers are expected to keep matching branch names across the + # two repos when making cross-language changes, the same way the + # symmetry branches work. Falls back to main via the default so + # `main` pushes also work. + - name: Check out NDR-matlab (matching branch) + uses: actions/checkout@v4 + with: + repository: VH-Lab/NDR-matlab + path: NDR-matlab + ref: ${{ github.head_ref || github.ref_name }} + + # -- Runtime setup ---------------------------------------------------- + - name: Start virtual display server + run: | + sudo apt-get install -y xvfb + Xvfb :99 & + echo "DISPLAY=:99" >> $GITHUB_ENV + + - name: Set up MATLAB + uses: matlab-actions/setup-matlab@v2 + with: + release: latest + cache: true + products: | + Signal_Processing_Toolbox + Statistics_and_Machine_Learning_Toolbox + + - name: Install MatBox + uses: ehennestad/matbox-actions/install-matbox@v1 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install NDR-python (with dev extras) + working-directory: NDR-python + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + # -- Stage 1: MATLAB makeArtifacts ------------------------------------ + - name: "Stage 1: MATLAB makeArtifacts" + uses: matlab-actions/run-command@v2 + with: + command: | + % Add NDR-matlab repo root (for +ndr) and tools/tests + % (for +ndr/+symmetry). Note: MATLAB namespace (+foo) + % directories must NOT themselves appear on the path -- + % only their parent directories should. + ndrRoot = fullfile(pwd, "NDR-matlab"); + addpath(ndrRoot); + addpath(fullfile(ndrRoot, "tools", "tests")); + + % Install MATLAB dependencies declared in tools/requirements.txt + % (e.g. vhlab-toolbox-matlab, which provides vlt.string.*). + matbox.installRequirements(fullfile(ndrRoot, "tools")); + + % Skip ndr_Init -- it tries to mkdir filecache paths under + % userpath which are not writable on the CI runner. + % The symmetry tests only use ndr.fun.ndrpath() which is + % self-contained. + + import matlab.unittest.TestSuite + import matlab.unittest.TestRunner + + suite = TestSuite.fromPackage("ndr.symmetry.makeArtifacts", "IncludingSubpackages", true); + fprintf("\n=== MATLAB makeArtifacts: %d test(s) ===\n", numel(suite)); + assert(~isempty(suite), "No MATLAB makeArtifacts tests found.") + + runner = TestRunner.withTextOutput("Verbosity", "Detailed"); + results = runner.run(suite); + + fprintf("\n=== MATLAB makeArtifacts: %d passed, %d failed ===\n", ... + nnz([results.Passed]), nnz([results.Failed])); + assert(all(~[results.Failed]), "MATLAB makeArtifacts failed") + + # -- Stage 2: Python readArtifacts (reads MATLAB artifacts) ----------- + - name: "Stage 2: Python readArtifacts (reads MATLAB artifacts)" + working-directory: NDR-python + run: | + pytest tests/symmetry/read_artifacts/ -v --tb=short + + # -- Stage 3: Python makeArtifacts ------------------------------------ + - name: "Stage 3: Python makeArtifacts" + working-directory: NDR-python + run: | + pytest tests/symmetry/make_artifacts/ -v --tb=short + + # -- Stage 4: MATLAB readArtifacts (reads Python artifacts) ----------- + - name: "Stage 4: MATLAB readArtifacts (reads Python artifacts)" + uses: matlab-actions/run-command@v2 + with: + command: | + ndrRoot = fullfile(pwd, "NDR-matlab"); + addpath(ndrRoot); + addpath(fullfile(ndrRoot, "tools", "tests")); + matbox.installRequirements(fullfile(ndrRoot, "tools")); + + import matlab.unittest.TestSuite + import matlab.unittest.TestRunner + + suite = TestSuite.fromPackage("ndr.symmetry.readArtifacts", "IncludingSubpackages", true); + fprintf("\n=== MATLAB readArtifacts: %d test(s) ===\n", numel(suite)); + assert(~isempty(suite), "No MATLAB readArtifacts tests found.") + + runner = TestRunner.withTextOutput("Verbosity", "Detailed"); + results = runner.run(suite); + + fprintf("\n=== MATLAB readArtifacts: %d passed, %d failed ===\n", ... + nnz([results.Passed]), nnz([results.Failed])); + assert(all(~[results.Failed]), "MATLAB readArtifacts failed") diff --git a/docs/developer_notes/ndr_matlab_python_bridge.yaml b/docs/developer_notes/ndr_matlab_python_bridge.yaml index dfa3985..d8fa97a 100644 --- a/docs/developer_notes/ndr_matlab_python_bridge.yaml +++ b/docs/developer_notes/ndr_matlab_python_bridge.yaml @@ -1,40 +1,153 @@ # The NDR Bridge Protocol: YAML Specification -# + +# ============================================================================= +# 1. File Purpose & Placement +# ============================================================================= # Name: ndr_matlab_python_bridge.yaml # Location: One file per sub-package directory # (e.g., src/ndr/reader/ndr_matlab_python_bridge.yaml). -# Role: Primary Contract. Defines how MATLAB names and types map to Python. +# Role: This is the Primary Contract. It defines how MATLAB names and types +# map to Python. If a function is not here, it does not officially +# exist in the Python port. + +# ============================================================================= +# 2. Standard Header +# ============================================================================= +# Every bridge file must begin with this metadata to orient the agent: project_metadata: bridge_version: "1.1" naming_policy: "Strict MATLAB Mirror" indexing_policy: "Semantic Parity (1-based for user concepts, 0-based for internal data)" -# When porting a function: +# ============================================================================= +# 3. The "Active Maintenance" Instruction +# ============================================================================= +# When an agent or developer works on a function: +# # 1. Check: Does the function/class exist in the YAML? -# 2. Add/Update: If missing or changed, update the YAML first. -# 3. Record Hash: git log -1 --format="%h" -- -# 4. Notify: Tell the user what was added/changed. +# 2. Add/Update: If it is missing or the MATLAB signature has changed, +# update the YAML first. +# 3. Record Hash: Store the short git hash of the MATLAB .m file being +# ported in the `matlab_last_sync_hash` field. Obtain the hash with: +# git log -1 --format="%h" -- +# 4. Notify: The agent MUST explicitly tell the user: +# "I have updated the ndr_matlab_python_bridge.yaml to include +# [Function Name]. Please review the interface contract." +# +# ============================================================================= +# 3a. Detecting Upstream MATLAB Changes +# ============================================================================= +# The `matlab_last_sync_hash` field enables efficient change detection: +# +# 1. For each entry, compare `matlab_last_sync_hash` against the current +# HEAD hash of the corresponding MATLAB .m file: +# git log -1 --format="%h" -- +# 2. If the hashes differ, the MATLAB file has changed since the last sync +# and the Python port may need to be updated. +# 3. After re-syncing, update `matlab_last_sync_hash` to the new hash and +# record the sync date in `decision_log`. + +# ============================================================================= +# 4. Structure for Classes and Functions +# ============================================================================= # --- Example: Class --- -# - name: base +# - name: base # Exact MATLAB Name # type: class # matlab_path: "+ndr/+reader/base.m" # python_path: "ndr/reader/base.py" -# matlab_last_sync_hash: "a4c9e07" +# python_class: "ndr_reader_base" +# matlab_last_sync_hash: "a4c9e07" # Git hash of base.m when last ported/synced +# +# properties: +# - name: MightHaveTimeGaps +# type_matlab: "logical" +# type_python: "bool" +# decision_log: "Mirroring property name exactly." +# # methods: # - name: readchannels_epochsamples # input_arguments: # - name: channeltype +# type_matlab: "char" # type_python: "str" # - name: channel +# type_matlab: "numeric" # type_python: "int | list[int]" -# - name: epoch -# type_python: "str | int" +# - name: epochstreams +# type_matlab: "cell array of char" +# type_python: "list[str]" +# - name: epoch_select +# type_matlab: "numeric" +# type_python: "int" +# decision_log: "Semantic Parity: User provides 1-based ID." # - name: s0 # type_python: "int" # - name: s1 # type_python: "int" # output_arguments: # - name: data +# type_matlab: "double | int16 | int32" # type_python: "numpy.ndarray" + +# --- Example: Standalone Function --- +# - name: ndrpath +# type: function +# matlab_path: "+ndr/+fun/ndrpath.m" +# python_path: "ndr/fun/ndrpath.py" +# matlab_last_sync_hash: "8f3a2b1" +# input_arguments: [] +# output_arguments: +# - name: p +# type_matlab: "char" +# type_python: "str" +# decision_log: "Synchronized with MATLAB main as of 2026-04-11." + +# ============================================================================= +# 5. Building a New Bridge YAML File +# ============================================================================= +# When adding a brand-new sub-package to NDR-python (for example +# `src/ndr/newformat/`), create a fresh `ndr_matlab_python_bridge.yaml` +# next to the Python source using the following procedure: +# +# 1. Create the file at `src/ndr//ndr_matlab_python_bridge.yaml`. +# 2. Start with the Standard Header from Section 2 (copy verbatim). +# 3. For each MATLAB class in `+ndr/+/`, add a `classes:` entry with: +# - `name` (MATLAB class name, e.g. `intan_rhd`) +# - `type: class` +# - `matlab_path` (e.g. `"+ndr/+reader/intan_rhd.m"`) +# - `python_path` (e.g. `"ndr/reader/intan_rhd.py"`) +# - `python_class` (derived using the Class Name Mirror Rule from +# `docs/developer_notes/PYTHON_PORTING_GUIDE.md`, e.g. +# `ndr_reader_intan__rhd`) +# - `inherits` (if applicable) +# - `matlab_last_sync_hash` (from `git log -1 --format="%h" -- `) +# - `properties:` and `methods:` blocks with `input_arguments` / +# `output_arguments`, each argument carrying `name`, `type_matlab`, and +# `type_python`. +# 4. For each standalone MATLAB function, add an entry under `functions:` with +# the same fields (minus `properties` and `methods`). +# 5. Run `python -c "import yaml, pathlib; yaml.safe_load(pathlib.Path('src/ndr//ndr_matlab_python_bridge.yaml').read_text())"` +# to validate the YAML parses. +# 6. Notify the user with a one-line summary of the new contract so they can +# review it before any Python implementation is written. +# +# Refer to `src/ndr/reader/ndr_matlab_python_bridge.yaml` for a concrete, +# non-trivial example covering both `classes:` with inherited methods and +# `static_methods:` blocks. + +# ============================================================================= +# 6. Summary of Field Rules +# ============================================================================= +# Field | Rule +# ---------------------|---------------------------------------------------------- +# name | Strict Match. Case-sensitive match to the MATLAB .m file. +# matlab_last_sync_hash| The short git hash of the MATLAB .m file at the time it +# | was last examined/ported. Used to detect upstream changes. +# input_arguments | Used to generate Pydantic @validate_call. +# output_arguments | Defines the order of the Return Tuple. +# type_python | Use Python 3.10+ syntax (e.g., str | int). +# decision_log | Mandatory for any divergence. Should include the sync +# | date (e.g., "Synchronized with MATLAB main as of +# | 2026-04-11."). diff --git a/docs/developer_notes/symmetry_tests.md b/docs/developer_notes/symmetry_tests.md index fbab568..32bd89d 100644 --- a/docs/developer_notes/symmetry_tests.md +++ b/docs/developer_notes/symmetry_tests.md @@ -1,53 +1,189 @@ # Cross-Language Symmetry Test Framework **Status:** Active -**Scope:** NDR-python <-> NDR-matlab parity +**Scope:** NDR-python ↔ NDR-matlab parity ## Purpose -Symmetry tests verify that data read by one language implementation matches -the other. This ensures the Python and MATLAB NDR stacks remain interoperable. + +Symmetry tests verify that data read by one language implementation matches the +other. This ensures the Python and MATLAB NDR stacks remain interoperable as +both codebases evolve, and mirrors the same framework that NDI-python and +NDI-matlab already use. ## Architecture +The framework has two halves, each existing in both languages: + | Phase | Python location | MATLAB location | |-------|----------------|-----------------| -| **makeArtifacts** | `tests/symmetry/make_artifacts/` | `tests/+ndr/+symmetry/+makeArtifacts/` | -| **readArtifacts** | `tests/symmetry/read_artifacts/` | `tests/+ndr/+symmetry/+readArtifacts/` | +| **makeArtifacts** | `tests/symmetry/make_artifacts/` | `tools/tests/+ndr/+symmetry/+makeArtifacts/` | +| **readArtifacts** | `tests/symmetry/read_artifacts/` | `tools/tests/+ndr/+symmetry/+readArtifacts/` | ### Artifact Directory Layout +All artifacts are written to the OS temporary directory under a fixed path: + ``` /NDR/symmetryTest/ ├── pythonArtifacts/ │ └── /// -│ ├── readData.json # Channel data, timestamps, etc. -│ └── metadata.json # Channel list, sample rates, epoch info +│ ├── metadata.json # Channel list, sample rates, t0/t1, epoch clocks +│ └── readData.json # Short reproducible sample of reader output └── matlabArtifacts/ └── /// └── ... (same structure) ``` +- **``** — the NDR domain being tested (e.g., `reader`). +- **``** — the test class name, in camelCase (e.g., `readData`). +- **``** — the test method name, in camelCase (e.g., `testReadDataArtifacts`). + ### Workflow -1. **makeArtifacts** (Python or MATLAB) reads example data files and writes - JSON artifacts containing: channel lists, sample rates, epoch clocks, - t0/t1 boundaries, and actual data samples. +``` +┌──────────────────────────┐ ┌──────────────────────────┐ +│ Python makeArtifacts │ │ MATLAB makeArtifacts │ +│ pytest tests/symmetry/ │ │ runtests('ndr.symmetry. │ +│ make_artifacts/ -v │ │ makeArtifacts') │ +└──────────┬───────────────┘ └──────────┬───────────────┘ + │ writes │ writes + ▼ ▼ + pythonArtifacts/ matlabArtifacts/ + │ │ + └────────────┬────────────────────┘ + │ reads + ┌────────────┴────────────────────┐ + │ │ + ▼ ▼ +┌──────────────────────────┐ ┌──────────────────────────┐ +│ Python readArtifacts │ │ MATLAB readArtifacts │ +│ pytest tests/symmetry/ │ │ runtests('ndr.symmetry. │ +│ read_artifacts/ -v │ │ readArtifacts') │ +└──────────────────────────┘ └──────────────────────────┘ +``` -2. **readArtifacts** (the other language) loads the same example data files, - reads the same channels/epochs, and compares against the stored artifacts. +Each `readArtifacts` test is parameterized over `{matlabArtifacts, pythonArtifacts}` +so a single test class validates both directions of compatibility. -Each `readArtifacts` test is parameterized over `{matlabArtifacts, pythonArtifacts}`. +## Running the Tests -## Running +### From Python ```bash # Generate artifacts pytest tests/symmetry/make_artifacts/ -v -# Verify artifacts +# Verify artifacts (skips missing sources) pytest tests/symmetry/read_artifacts/ -v + +# Both phases at once +pytest tests/symmetry/ -v +``` + +### From MATLAB + +```matlab +% Generate artifacts +results = runtests('ndr.symmetry.makeArtifacts', 'IncludeSubpackages', true); + +% Verify artifacts +results = runtests('ndr.symmetry.readArtifacts', 'IncludeSubpackages', true); ``` +### Why Separate from Regular Tests? + +Symmetry tests are **excluded from the default `pytest` run** (via +`--ignore=tests/symmetry` in `pyproject.toml`) because: + +1. **readArtifacts** tests will mostly just skip unless the user has previously + run MATLAB's `makeArtifacts` suite on the same machine. +2. **makeArtifacts** tests write to the system temp directory, which is a + side-effect that doesn't belong in routine CI. +3. The full cross-language cycle requires both runtimes and is better suited to + integration / nightly CI pipelines. + ## Writing a New Symmetry Test -See NDI-python's `docs/developer_notes/symmetry_tests.md` for the full template. -Adapt the pattern for NDR's reader-centric API. + +### 1. Choose a namespace + +Pick the NDR domain being tested (e.g., `reader`, `format`, `time`). + +### 2. Create the makeArtifacts test + +**Python:** `tests/symmetry/make_artifacts//test_.py` + +```python +import json +import shutil +from pathlib import Path +import pytest + +from tests.symmetry.conftest import PYTHON_ARTIFACTS + +ARTIFACT_DIR = PYTHON_ARTIFACTS / "" / "" / "" +EXAMPLE_DATA = Path(__file__).parents[4] / "example_data" + + +class TestMyFeature: + def test_my_feature_artifacts(self): + if ARTIFACT_DIR.exists(): + shutil.rmtree(ARTIFACT_DIR) + ARTIFACT_DIR.mkdir(parents=True) + + # ... build reader, collect metadata, dump metadata.json + readData.json +``` + +**MATLAB:** `tools/tests/+ndr/+symmetry/+makeArtifacts/+/.m` + +Follow the `INSTRUCTIONS.md` in the MATLAB `+makeArtifacts` folder. + +### 3. Create the readArtifacts test + +**Python:** `tests/symmetry/read_artifacts//test_.py` + +```python +import json +import pytest +from tests.symmetry.conftest import SOURCE_TYPES, SYMMETRY_BASE + + +@pytest.fixture(params=SOURCE_TYPES) +def source_type(request): + return request.param + + +class TestMyFeature: + def test_my_feature_artifacts(self, source_type): + artifact_dir = SYMMETRY_BASE / source_type / "" / "" / "" + if not artifact_dir.exists(): + pytest.skip(f"No artifacts from {source_type}") + # ... load metadata.json / readData.json and assert against re-read values +``` + +**MATLAB:** `tools/tests/+ndr/+symmetry/+readArtifacts/+/.m` + +Follow the `INSTRUCTIONS.md` in the MATLAB `+readArtifacts` folder. + +### 4. Naming Conventions + +| Concept | Python | MATLAB | +|---------|--------|--------| +| Test directory | `tests/symmetry/make_artifacts/reader/` | `tools/tests/+ndr/+symmetry/+makeArtifacts/+reader/` | +| Test file | `test_read_data.py` | `readData.m` | +| Test class | `TestReadData` | `readData` (classdef) | +| Artifact className | `readData` (camelCase) | `readData` | +| Artifact testName | `testReadDataArtifacts` (camelCase) | `testReadDataArtifacts` | + +Use **camelCase** for the artifact directory components (`className`, `testName`) +so that both languages write to and read from the exact same paths. + +## Related: Building the Bridge YAML Files + +Symmetry tests confirm **behavioural** parity at runtime. The bridge YAML files +under `src/ndr/*/ndr_matlab_python_bridge.yaml` describe **interface** parity at +the signature level. See +`docs/developer_notes/ndr_matlab_python_bridge.yaml` for the full spec, and +Section 5 of that file ("Building a New Bridge YAML File") for the step-by-step +procedure to add or update a contract. The `PYTHON_PORTING_GUIDE.md` also +documents the porting workflow, including how to compute and record +`matlab_last_sync_hash`. diff --git a/tests/symmetry/make_artifacts/INSTRUCTIONS.md b/tests/symmetry/make_artifacts/INSTRUCTIONS.md new file mode 100644 index 0000000..0c8ee0f --- /dev/null +++ b/tests/symmetry/make_artifacts/INSTRUCTIONS.md @@ -0,0 +1,57 @@ +# NDR Symmetry Artifacts Instructions (Python — make_artifacts) + +This folder contains Python tests whose purpose is to generate standard NDR artifacts for symmetry testing with other NDR language ports (e.g., MATLAB). + +## Rules for `make_artifacts` tests: + +1. **Artifact Location**: Tests must store their generated artifacts in the system's temporary directory (`tempfile.gettempdir()`). +2. **Directory Structure**: Inside the temporary directory, artifacts must be placed in a specific nested folder structure: + `NDR/symmetryTest/pythonArtifacts////` + + - ``: The sub-package name under `make_artifacts`. For example, for a test located at `tests/symmetry/make_artifacts/reader/`, the namespace is `reader`. + - ``: The name of the test class (e.g., `readData`), written in camelCase to match MATLAB conventions. + - ``: The specific name of the test method being executed (e.g., `testReadDataArtifacts`), also in camelCase. + +3. **Persistent Teardown**: The generated artifact files **must persist** in the temporary directory so that the MATLAB test suite can read them. Do **not** use `tmp_path` for the artifact output directory — only use it for intermediate scratch state that can be discarded. + +4. **Artifact Contents**: Every `make_artifacts` test should produce at minimum: + - A `metadata.json` file describing the channels, sample rates, `t0`/`t1` boundaries, + and epoch clock types returned by the reader. + - A `readData.json` file containing a small, reproducible sample of data read via + `readchannels_epochsamples(...)` so the MATLAB suite can verify numerical parity. + +5. **Deterministic Input**: Tests should read from the checked-in example data files shared + between NDR-matlab and NDR-python so that both language ports operate on byte-identical + inputs. + +6. **Imports**: Use the shared constant `PYTHON_ARTIFACTS` from `tests/symmetry/conftest.py` + to build the artifact path. The base directory is: + `/NDR/symmetryTest/pythonArtifacts/`. + +## Example: + +For a test class `TestReadData` in `tests/symmetry/make_artifacts/reader/test_read_data.py` +with a test method `test_read_data_artifacts`, the artifacts should be saved to: + +``` +/NDR/symmetryTest/pythonArtifacts/reader/readData/testReadDataArtifacts/ +``` + +## Running + +```bash +# Generate artifacts +pytest tests/symmetry/make_artifacts/ -v +``` + +## Adding a new symmetry test: + +1. Create a sub-package under `make_artifacts/` named after the NDR domain (e.g., `reader/`, + `format/`, `time/`). +2. Add a `test_.py` file with a test class that exercises the reader (or other API) + and writes `metadata.json` and `readData.json` (plus any other JSON blobs you need) to + the artifact path described above. +3. Mirror the directory naming in MATLAB: + `tools/tests/+ndr/+symmetry/+makeArtifacts/+/.m`. +4. Add a corresponding `read_artifacts` test that can verify the generated artifacts (see + `tests/symmetry/read_artifacts/INSTRUCTIONS.md`). diff --git a/tests/symmetry/read_artifacts/INSTRUCTIONS.md b/tests/symmetry/read_artifacts/INSTRUCTIONS.md new file mode 100644 index 0000000..a454060 --- /dev/null +++ b/tests/symmetry/read_artifacts/INSTRUCTIONS.md @@ -0,0 +1,83 @@ +# NDR Read Artifacts Instructions (Python — read_artifacts) + +This folder contains Python tests whose purpose is to read standard NDR artifacts generated by both the Python NDR test suite and the MATLAB test suite, comparing them against expected values. + +## Process overview: + +1. **Artifact Location**: The test suites place their generated artifacts in the system's temporary directory (`tempfile.gettempdir()`). +2. **Directory Structure**: Inside the temporary directory, artifacts can be found in a specific nested folder structure: + `NDR/symmetryTest/////` + + - ``: Either `matlabArtifacts` or `pythonArtifacts`. + - ``: The module/package location of the corresponding test (e.g., `reader`). + - ``: The name of the test class (e.g., `readData`). + - ``: The specific name of the test method that was executed (e.g., `testReadDataArtifacts`). + +3. **Testing Goals**: The Python tests located in this `read_artifacts` package should define assertions that: + - Load the JSON representations created by the target suite (e.g., `metadata.json`, + `readData.json`). + - Re-read the same example data files through the corresponding NDR reader in Python. + - Assert that the numeric values (channel lists, sample rates, `t0`/`t1`, sample data) + match what the target suite stored. + - Run across both `pythonArtifacts` and `matlabArtifacts` using parameterized testing so + a single test verifies parity in *both* directions. + +4. **Skipping**: When the artifact directory for a given `SourceType` does not exist, the + test must **skip** (not fail). This allows the suite to run on machines that only have + one language installed. + +## Example: + +Use pytest's `@pytest.fixture(params=...)` to dynamically pass the `SourceType` to your tests: + +```python +import json +import numpy as np +import pytest +from pathlib import Path + +from tests.symmetry.conftest import SOURCE_TYPES, SYMMETRY_BASE + +EXAMPLE_DATA = Path(__file__).parents[4] / "example_data" + + +@pytest.fixture(params=SOURCE_TYPES) +def source_type(request): + return request.param + + +class TestReadData: + def _artifact_dir(self, source_type): + return SYMMETRY_BASE / source_type / "reader" / "readData" / "testReadDataArtifacts" + + def test_read_data_metadata(self, source_type): + artifact_dir = self._artifact_dir(source_type) + if not artifact_dir.exists(): + pytest.skip(f"No artifacts from {source_type}") + + metadata = json.loads((artifact_dir / "metadata.json").read_text()) + + from ndr.reader.intan_rhd import IntanRHD + reader = IntanRHD() + epochfiles = [str(EXAMPLE_DATA / "example.rhd")] + + actual_sr = reader.samplerate(epochfiles, 1, "ai", 1) + assert actual_sr == metadata["samplerate"] +``` + +## Running + +```bash +# Verify artifacts (skips missing sources) +pytest tests/symmetry/read_artifacts/ -v +``` + +## Adding a new symmetry test: + +1. Create a sub-package under `read_artifacts/` matching the namespace used in + `make_artifacts/` (e.g., `reader/`, `format/`). +2. Add a `test_.py` file with a parameterized test class that reads from both + `matlabArtifacts` and `pythonArtifacts`. +3. Always use `pytest.skip()` when the expected artifact directory does not exist. +4. Mirror the MATLAB counterpart: + `tools/tests/+ndr/+symmetry/+readArtifacts/+/.m`.