From 5b69cbb8aeacee82e38da686fe33667e9905291c Mon Sep 17 00:00:00 2001 From: Jon Froehlich Date: Wed, 24 Jun 2026 09:41:27 -0700 Subject: [PATCH 1/3] @ Add pytest unit tests for makelab + gesturerec helpers Adds tests/ covering the deterministic helper functions: signal generation / shift / zero-crossings / remap (makelab.signal), FFT + peak extraction (gesturerec.signalproc), the file-handling helpers incl. the fulldatastream exclusion guard (gesturerec.utility), SensorData/Trial/GestureSet parsing incl. the Windows double-underscore filename quirk (gesturerec.data), and TrialClassificationResult n-best sorting (gesturerec.experiments). 26 tests. A tiny synthetic fixture corpus under tests/fixtures/TestGestures/ keeps the data.py tests off the large real GestureLogs/. Adds a [test] optional-dependency group (pytest + nbmake + pytest-xdist) and pytest config to pyproject.toml. Nothing was added inside the notebooks. Part of #8. Co-Authored-By: Claude Opus 4.8 @ --- pyproject.toml | 15 ++++ .../TestGestures/Midair Zorro _Z__3000_3.csv | 4 + tests/fixtures/TestGestures/Shake_1000_3.csv | 4 + tests/fixtures/TestGestures/Shake_2000_3.csv | 4 + .../armGestureData_fulldatastream_999_9.csv | 3 + tests/test_gesturerec_data.py | 70 +++++++++++++++++ tests/test_gesturerec_experiments.py | 51 +++++++++++++ tests/test_gesturerec_signalproc.py | 52 +++++++++++++ tests/test_gesturerec_utility.py | 35 +++++++++ tests/test_makelab_signal.py | 75 +++++++++++++++++++ 10 files changed, 313 insertions(+) create mode 100644 tests/fixtures/TestGestures/Midair Zorro _Z__3000_3.csv create mode 100644 tests/fixtures/TestGestures/Shake_1000_3.csv create mode 100644 tests/fixtures/TestGestures/Shake_2000_3.csv create mode 100644 tests/fixtures/TestGestures/armGestureData_fulldatastream_999_9.csv create mode 100644 tests/test_gesturerec_data.py create mode 100644 tests/test_gesturerec_experiments.py create mode 100644 tests/test_gesturerec_signalproc.py create mode 100644 tests/test_gesturerec_utility.py create mode 100644 tests/test_makelab_signal.py diff --git a/pyproject.toml b/pyproject.toml index 139065e..e2509b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,15 @@ notebooks = [ "ipykernel>=6.29", "ipympl>=0.9", ] +# Test stack: pytest for the helper-package unit tests, nbmake for headless +# "does every notebook still execute" smoke tests, pytest-xdist to run them in +# parallel. Install with: pip install -e ".[test]" +test = [ + "pytest>=8", + "nbmake>=1.5", + "pytest-xdist>=3.6", + "ipykernel>=6.29", # nbmake needs a kernel to execute the notebooks +] [project.urls] Homepage = "https://makeabilitylab.github.io/physcomp/signals/" @@ -47,3 +56,9 @@ packages = ["makelab", "gesturerec"] [tool.setuptools.package-dir] makelab = "Tutorials/makelab" gesturerec = "Projects/GestureRecognizer/gesturerec" + +# Unit tests live in tests/. The notebook smoke tests are run explicitly by path +# (e.g. `pytest --nbmake Tutorials/`), so testpaths intentionally lists only tests/ +# to keep a bare `pytest` fast and notebook-free. +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/tests/fixtures/TestGestures/Midair Zorro _Z__3000_3.csv b/tests/fixtures/TestGestures/Midair Zorro _Z__3000_3.csv new file mode 100644 index 0000000..cbff052 --- /dev/null +++ b/tests/fixtures/TestGestures/Midair Zorro _Z__3000_3.csv @@ -0,0 +1,4 @@ +timestamp,sensor_timestamp,x,y,z +3000,10,5,0,0 +3100,20,0,5,0 +3200,30,0,0,5 diff --git a/tests/fixtures/TestGestures/Shake_1000_3.csv b/tests/fixtures/TestGestures/Shake_1000_3.csv new file mode 100644 index 0000000..b7f1d50 --- /dev/null +++ b/tests/fixtures/TestGestures/Shake_1000_3.csv @@ -0,0 +1,4 @@ +timestamp,sensor_timestamp,x,y,z +1000,10,3,4,0 +1100,20,0,0,0 +1200,30,1,2,2 diff --git a/tests/fixtures/TestGestures/Shake_2000_3.csv b/tests/fixtures/TestGestures/Shake_2000_3.csv new file mode 100644 index 0000000..9545665 --- /dev/null +++ b/tests/fixtures/TestGestures/Shake_2000_3.csv @@ -0,0 +1,4 @@ +timestamp,sensor_timestamp,x,y,z +2000,10,1,1,1 +2100,20,2,2,2 +2200,30,3,3,3 diff --git a/tests/fixtures/TestGestures/armGestureData_fulldatastream_999_9.csv b/tests/fixtures/TestGestures/armGestureData_fulldatastream_999_9.csv new file mode 100644 index 0000000..ba1875c --- /dev/null +++ b/tests/fixtures/TestGestures/armGestureData_fulldatastream_999_9.csv @@ -0,0 +1,3 @@ +timestamp,sensor_timestamp,x,y,z +1,1,0,0,0 +2,2,0,0,0 diff --git a/tests/test_gesturerec_data.py b/tests/test_gesturerec_data.py new file mode 100644 index 0000000..a047169 --- /dev/null +++ b/tests/test_gesturerec_data.py @@ -0,0 +1,70 @@ +"""Unit tests for gesturerec.data -- SensorData, Trial, and GestureSet. + +Uses a tiny synthetic fixture corpus under tests/fixtures/TestGestures/ rather than +the large real GestureLogs/, so the suite stays fast and self-contained. The fixture +deliberately includes: + - two "Shake" trials (to verify chronological trial ordering by end-time), + - a "Midair Zorro _Z_" file exercising the Windows double-underscore filename quirk, + - a *_fulldatastream_* file that must be excluded from per-trial loading. +""" +from pathlib import Path + +import numpy as np +import pytest + +import gesturerec.data as grdata + +FIXTURE_DIR = Path(__file__).parent / "fixtures" / "TestGestures" + + +def test_sensordata_magnitude_and_rate(): + time = np.array([1000, 1100, 1200]) + sensor_time = np.array([10, 20, 30]) + x = np.array([3, 0, 0]) + y = np.array([4, 0, 0]) + z = np.array([0, 0, 0]) + sd = grdata.SensorData("Accelerometer", time, sensor_time, x, y, z) + + # mag = sqrt(x^2 + y^2 + z^2); first row 3,4,0 -> 5. + assert sd.mag[0] == pytest.approx(5.0) + assert sd.length() == 3 + # length_in_secs = (1200-1000)/1000 = 0.2s; sampling_rate = 3 / 0.2 = 15 Hz. + assert sd.length_in_secs == pytest.approx(0.2) + assert sd.sampling_rate == pytest.approx(15.0) + + +def test_sensordata_casts_time_to_int64(): + sd = grdata.SensorData("Accelerometer", + np.array([1000, 2000]), np.array([1, 2]), + np.array([1, 2]), np.array([1, 2]), np.array([1, 2])) + # int64 cast is deliberate (Windows long is 32-bit) -- keep it. + assert sd.time.dtype == np.int64 + + +def test_trial_parses_csv_in_constructor(): + trial = grdata.Trial("Shake", 0, str(FIXTURE_DIR / "Shake_1000_3.csv")) + assert trial.gesture_name == "Shake" + assert trial.length() == 3 + assert trial.get_start_time() == 1000 + assert trial.get_end_time() == 1200 + # First data row is 3,4,0 -> magnitude 5. + assert trial.accel.mag[0] == pytest.approx(5.0) + + +def test_gestureset_load_orders_trials_and_handles_windows_quirk(): + gs = grdata.GestureSet(str(FIXTURE_DIR)) + gs.load() + + # The *_fulldatastream_* file is excluded -> exactly two gestures. + names = gs.get_gesture_names_sorted() + assert "Shake" in names + # The "Midair Zorro _Z_" filename (Windows replaced ' with _) must decode back + # to the apostrophe form. + assert "Midair Zorro 'Z'" in names + assert gs.get_num_gestures() == 2 + + # Two Shake trials, ordered chronologically by end-time (1000 then 2000). + shake_trials = gs.get_trials("Shake") + assert len(shake_trials) == 2 + assert shake_trials[0].get_end_time() == 1200 + assert shake_trials[1].get_end_time() == 2200 diff --git a/tests/test_gesturerec_experiments.py b/tests/test_gesturerec_experiments.py new file mode 100644 index 0000000..720b6ae --- /dev/null +++ b/tests/test_gesturerec_experiments.py @@ -0,0 +1,51 @@ +"""Unit tests for gesturerec.experiments.TrialClassificationResult. + +Exercises the n-best-list sorting and is_correct contract with lightweight stub +trials, so no real classifier run or data loading is needed. +""" +from gesturerec.experiments import TrialClassificationResult + + +class _StubTrial: + """Minimal stand-in for a Trial: only the attributes the result class touches.""" + + def __init__(self, gesture_name, trial_num=0): + self.gesture_name = gesture_name + self.trial_num = trial_num + + def get_ground_truth_gesture_name(self): + return self.gesture_name + + +def test_nbest_list_sorted_ascending_by_score_and_closest_is_lowest(): + test_trial = _StubTrial("Shake") + good = _StubTrial("Shake") + bad = _StubTrial("Wave") + # Lower score == closer match (the algorithms return distances). + result = TrialClassificationResult(test_trial, [(bad, 9.0), (good, 1.0)]) + + assert result.n_best_list_sorted[0][1] == 1.0 + assert result.closest_trial is good + assert result.score == 1.0 + + +def test_is_correct_true_when_closest_matches_ground_truth(): + test_trial = _StubTrial("Shake") + result = TrialClassificationResult( + test_trial, [(_StubTrial("Shake"), 2.0), (_StubTrial("Wave"), 5.0)]) + assert result.is_correct is True + + +def test_is_correct_false_when_closest_is_wrong_gesture(): + test_trial = _StubTrial("Shake") + result = TrialClassificationResult( + test_trial, [(_StubTrial("Wave"), 0.5), (_StubTrial("Shake"), 5.0)]) + assert result.is_correct is False + + +def test_correct_match_index_in_nbest_list(): + test_trial = _StubTrial("Shake") + # Closest is Wave (0.5); the correct Shake template is next (index 1). + result = TrialClassificationResult( + test_trial, [(_StubTrial("Wave"), 0.5), (_StubTrial("Shake"), 5.0)]) + assert result.get_correct_match_index_nbestlist() == 1 diff --git a/tests/test_gesturerec_signalproc.py b/tests/test_gesturerec_signalproc.py new file mode 100644 index 0000000..f31f70a --- /dev/null +++ b/tests/test_gesturerec_signalproc.py @@ -0,0 +1,52 @@ +"""Unit tests for gesturerec.signalproc -- the shared FFT/peak primitives used by +the feature-based gesture notebooks. +""" +import numpy as np + +import gesturerec.signalproc as sp + + +def _sine(freq, fs, secs): + t = np.arange(fs * secs) / fs + return np.sin(2 * np.pi * freq * t) + + +def test_compute_fft_returns_half_spectrum(): + fs = 100 + s = _sine(10, fs, 1) # 100 samples + freqs, amps = sp.compute_fft(s, fs) + # Only the positive half of the spectrum is returned. + assert len(freqs) == len(s) // 2 + assert len(amps) == len(s) // 2 + + +def test_compute_fft_peak_sits_at_input_frequency(): + fs = 100 + s = _sine(10, fs, 1) # bin spacing = fs/n = 1 Hz, so 10 Hz is an exact bin + freqs, amps = sp.compute_fft(s, fs) + peak_freq = freqs[np.argmax(amps)] + assert peak_freq == 10 + + +def test_compute_fft_amplitude_scaling(): + fs = 100 + s = _sine(10, fs, 1) + _, amps_scaled = sp.compute_fft(s, fs, scale_amplitudes=True) + _, amps_raw = sp.compute_fft(s, fs, scale_amplitudes=False) + # Unit-amplitude sine -> scaled peak ~1.0; raw peak ~ N/2 = 50. + assert np.isclose(np.max(amps_scaled), 1.0, atol=1e-2) + assert np.isclose(np.max(amps_raw), len(s) / 2, atol=1.0) + + +def test_get_top_n_frequency_peaks_sorted_by_amplitude(): + fs = 200 + t = np.arange(fs) / fs # 1 second + # Two tones: 10 Hz at amplitude 1.0, 20 Hz at amplitude 0.5. + s = 1.0 * np.sin(2 * np.pi * 10 * t) + 0.5 * np.sin(2 * np.pi * 20 * t) + freqs, amps = sp.compute_fft(s, fs) + peaks = sp.get_top_n_frequency_peaks(2, freqs, amps) + assert len(peaks) == 2 + # Sorted by amplitude descending -> the 10 Hz tone (stronger) comes first. + assert peaks[0][0] == 10 + assert peaks[1][0] == 20 + assert peaks[0][1] > peaks[1][1] diff --git a/tests/test_gesturerec_utility.py b/tests/test_gesturerec_utility.py new file mode 100644 index 0000000..2af5f3f --- /dev/null +++ b/tests/test_gesturerec_utility.py @@ -0,0 +1,35 @@ +"""Unit tests for gesturerec.utility -- file-handling helpers for loading gesture logs.""" +import gesturerec.utility as util + + +def test_find_csv_filenames_excludes_fulldatastream(tmp_path): + # The deliberate guard: per-trial loading must not pick up the continuous + # full-stream file (see utility.find_csv_filenames docstring). + (tmp_path / "Shake_1000_3.csv").write_text("x") + (tmp_path / "Wave_2000_4.csv").write_text("x") + (tmp_path / "armData_fulldatastream_9_9.csv").write_text("x") + (tmp_path / "notes.txt").write_text("x") + + found = util.find_csv_filenames(str(tmp_path)) + + assert set(found) == {"Shake_1000_3.csv", "Wave_2000_4.csv"} + + +def test_extract_gesture_name_takes_text_before_first_underscore(): + assert util.extract_gesture_name("Shake_1556730840228_206.csv") == "Shake" + + +def test_path_leaf_returns_final_component(): + assert util.path_leaf("/a/b/c.csv") == "c.csv" + # Trailing slash -> the leaf is the final directory name. + assert util.path_leaf("/a/b/") == "b" + + +def test_get_immediate_subdirectories(tmp_path): + (tmp_path / "JonGestures").mkdir() + (tmp_path / "JustinGestures").mkdir() + (tmp_path / "afile.csv").write_text("x") + + subdirs = util.get_immediate_subdirectories(str(tmp_path)) + + assert set(subdirs) == {"JonGestures", "JustinGestures"} diff --git a/tests/test_makelab_signal.py b/tests/test_makelab_signal.py new file mode 100644 index 0000000..c45a299 --- /dev/null +++ b/tests/test_makelab_signal.py @@ -0,0 +1,75 @@ +"""Unit tests for the pure signal-generation/analysis helpers in makelab.signal. + +These pin the numeric contracts the tutorial notebooks rely on (wave generation, +array shifting, zero-crossing counting, value remapping). Plotting helpers are not +tested here -- they render figures rather than return data. +""" +import numpy as np +import pytest + +import makelab.signal as ms + + +def test_create_sine_wave_default_length_is_one_period(): + # With total_time_in_secs=None the helper returns exactly one period: + # one period = 1/freq seconds -> (1/freq) * sampling_rate samples. + s = ms.create_sine_wave(freq=2, sampling_rate=8) # 0.5s * 8Hz = 4 samples + assert len(s) == 4 + + +def test_create_sine_wave_length_and_values(): + fs = 10 + # return_time=True yields (time, sine_wave), in that order. + t, s = ms.create_sine_wave(freq=1, sampling_rate=fs, total_time_in_secs=2, + return_time=True) + assert len(s) == 2 * fs + # Values must match sin(2*pi*f*t) at each sample time. + np.testing.assert_allclose(s, np.sin(2 * np.pi * 1 * t), atol=1e-12) + + +def test_create_cos_wave_starts_at_one(): + s = ms.create_cos_wave(freq=1, sampling_rate=100, total_time_in_secs=1) + assert s[0] == pytest.approx(1.0) + + +def test_shift_array_positive_shifts_right_and_fills_front(): + arr = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + result = ms.shift_array(arr, 2, fill_value=np.nan) + np.testing.assert_array_equal(result, np.array([np.nan, np.nan, 1.0, 2.0, 3.0])) + + +def test_shift_array_negative_shifts_left_and_fills_back(): + arr = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + result = ms.shift_array(arr, -2, fill_value=np.nan) + np.testing.assert_array_equal(result, np.array([3.0, 4.0, 5.0, np.nan, np.nan])) + + +def test_shift_array_zero_is_identity(): + arr = np.array([1.0, 2.0, 3.0]) + np.testing.assert_array_equal(ms.shift_array(arr, 0), arr) + + +def test_map_and_remap_linear_interpolation(): + assert ms.map(5, 0, 10, 0, 100) == pytest.approx(50) + assert ms.remap(0, 0, 10, 0, 100) == pytest.approx(0) + assert ms.remap(10, 0, 10, 0, 100) == pytest.approx(100) + + +def test_get_top_n_frequency_indices_sorted_returns_largest_descending(): + amplitudes = np.array([1.0, 5.0, 2.0, 8.0, 3.0]) + freqs = np.arange(len(amplitudes)) + # Top 2 amplitudes are 8 (idx 3) then 5 (idx 1), in descending order. + ind = ms.get_top_n_frequency_indices_sorted(2, freqs, amplitudes) + assert list(ind) == [3, 1] + + +def test_calc_zero_crossings_alternating_signal(): + # +1,-1,+1,-1 changes sign at indices 1, 2, 3 -> three crossings. + s = np.array([1.0, -1.0, 1.0, -1.0]) + assert ms.calc_zero_crossings(s) == [1, 2, 3] + + +def test_calc_zero_crossings_min_gap_thins_results(): + s = np.array([1.0, -1.0, 1.0, -1.0]) + # A min_gap larger than the spacing should drop the closely-spaced crossings. + assert ms.calc_zero_crossings(s, min_gap=5) == [1] From 2e6ec331ceee9eac824e0d07810e0ce7415034c7 Mon Sep 17 00:00:00 2001 From: Jon Froehlich Date: Wed, 24 Jun 2026 09:41:40 -0700 Subject: [PATCH 2/3] @ Add GitHub Actions CI (unit + nbmake notebook smoke tests) and document testing Two workflows, both executing entirely outside the notebooks: - ci.yml (push to master + PRs): a unit-test job (pytest tests/) and a fast notebook-execution job running nbmake over Tutorials/ + StepTracker/. - notebooks-nightly.yml (schedule + workflow_dispatch): the slow GestureRecognizer notebooks. Both install the pinned requirements.txt + .[test] on Python 3.12 plus libsndfile1 for librosa. nbmake executes each notebook in its own directory (CWD-relative data loads work) and honors the existing raises-exception cell tags, so no notebook edits are needed. This automates the manual Pass 2-4 "Restart & Run All" sweeps. Docs: correct the "no test suite, no CI" line in CLAUDE.md + add a Testing section; add a Testing section to README; add a Pass 5 entry to MODERNIZATION-NOTES.md. Part of #8. Co-Authored-By: Claude Opus 4.8 @ --- .github/workflows/ci.yml | 50 +++++++++++++++++++++++++ .github/workflows/notebooks-nightly.yml | 30 +++++++++++++++ CLAUDE.md | 11 +++++- MODERNIZATION-NOTES.md | 41 ++++++++++++++++++++ README.md | 21 +++++++++++ 5 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/notebooks-nightly.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ae38656 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: CI + +# Runs on every PR and on pushes to master. The heavy gesture notebooks are +# NOT run here (they are slow + data-heavy) -- see notebooks-nightly.yml. +on: + push: + branches: [master] + pull_request: + +jobs: + unit: + name: Unit tests (helper packages) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: requirements.txt + - name: Install pinned env + test extras + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -e ".[test]" + - name: Run pytest + run: pytest tests/ -v + + notebooks-fast: + name: Notebook smoke tests (fast tier) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: requirements.txt + - name: Install audio system lib (librosa/soundfile) + run: sudo apt-get update && sudo apt-get install -y libsndfile1 + - name: Install pinned env + test extras + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -e ".[test]" + # nbmake executes each notebook in its own directory, so the repo-relative + # data loads (./Logs, data/audio/...) resolve. Intentional error cells are + # tagged `raises-exception` in the notebooks and are honored automatically. + - name: Execute fast notebooks (Tutorials + StepTracker) + run: pytest --nbmake --nbmake-timeout=900 -n auto Tutorials/ Projects/StepTracker/ diff --git a/.github/workflows/notebooks-nightly.yml b/.github/workflows/notebooks-nightly.yml new file mode 100644 index 0000000..f3804ae --- /dev/null +++ b/.github/workflows/notebooks-nightly.yml @@ -0,0 +1,30 @@ +name: Notebooks (nightly heavy) + +# The GestureRecognizer notebooks are slow (k-fold cross-validation over the full +# GestureLogs corpus) and produce large outputs, so they are not run on every PR. +# Instead they run nightly and on-demand to catch dependency drift over time. +on: + schedule: + - cron: "0 8 * * *" # 08:00 UTC daily + workflow_dispatch: # manual "Run workflow" button + +jobs: + notebooks-heavy: + name: Execute gesture notebooks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: requirements.txt + - name: Install audio system lib (librosa/soundfile) + run: sudo apt-get update && sudo apt-get install -y libsndfile1 + - name: Install pinned env + test extras + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -e ".[test]" + - name: Execute gesture notebooks + run: pytest --nbmake --nbmake-timeout=1800 -n auto Projects/GestureRecognizer/ diff --git a/CLAUDE.md b/CLAUDE.md index c21cbdc..0d7dd35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What this is -Educational materials for applied signal processing and time-series classification in HCI / ubiquitous computing (a University of Washington course, part of the Makeability Lab "physcomp" curriculum). The artifacts are **Jupyter notebooks** backed by small supporting Python packages. There is no build, no test suite, no CI — work is done interactively in notebooks. +Educational materials for applied signal processing and time-series classification in HCI / ubiquitous computing (a University of Washington course, part of the Makeability Lab "physcomp" curriculum). The artifacts are **Jupyter notebooks** backed by small supporting Python packages. There is no build; work is done interactively in notebooks. Tests do exist (added after the v2 modernization): pytest unit tests for the helper packages plus `nbmake` headless notebook smoke tests, run in GitHub Actions CI — see the **Testing** section below. The tests live entirely outside the `.ipynb` files (nothing was added inside the notebooks). Top-level layout: - `Tutorials/` — standalone teaching notebooks (NumPy, Matplotlib, Python, and signals: sampling/quantization, frequency analysis, comparing signals). Supported by the `makelab/` package. @@ -29,6 +29,15 @@ Pinned dependencies live in `requirements.txt` / `environment.yml`: NumPy, SciPy Notebook files contain non-ASCII characters (arrows, curly quotes). When parsing a `.ipynb` with a script, open as UTF-8 and run Python with `PYTHONUTF8=1` — the default Windows cp1252 codec will raise `UnicodeDecodeError`. +## Testing + +Install the test stack with `pip install -e ".[test]"` (pytest + nbmake + pytest-xdist). Two layers, both **outside** the notebooks: + +- **Unit tests** (`tests/`): pure-function tests for the helper packages — `makelab.signal`, `gesturerec.signalproc`/`utility`/`data`/`experiments`. Run with `pytest tests/`. `tests/fixtures/TestGestures/` is a tiny synthetic gesture corpus (3 trial CSVs + a `*_fulldatastream_*` exclusion file) so the `data.py` parser tests don't depend on the large real `GestureLogs/`. It deliberately includes a `Midair Zorro _Z__*.csv` file to exercise the Windows double-underscore filename quirk. +- **Notebook smoke tests** (`nbmake`): `pytest --nbmake ` executes notebooks headless and fails on any uncaught error. Intentional teaching errors are already tagged `raises-exception` (notebooks 2 and 5) and are honored — no notebook edits needed. nbmake runs each notebook in its own dir, so the CWD-relative data loads work. + +`[tool.pytest.ini_options].testpaths = ["tests"]` keeps a bare `pytest` fast and notebook-free; the notebook sweeps are invoked explicitly by path. CI (`.github/workflows/`) runs units + the fast notebooks (Tutorials + StepTracker) on every push/PR, and the slow gesture notebooks nightly + on-demand. If you add a helper function, add a unit test; if a dependency bump breaks a notebook, the nbmake job is what catches it (this automates the manual Pass 2–4 "Restart & Run All" sweeps). + ## gesturerec architecture (Projects/GestureRecognizer) This package abstracts the data loading and experiment bookkeeping so notebook code can focus on the classification algorithm itself. The data flow is: diff --git a/MODERNIZATION-NOTES.md b/MODERNIZATION-NOTES.md index 5ed9a2b..5a254dd 100644 --- a/MODERNIZATION-NOTES.md +++ b/MODERNIZATION-NOTES.md @@ -475,3 +475,44 @@ one per notebook/group on `signals-v2-pass4`. **Note:** an unrelated stale-stat refresh to FeatureBased (scikit-learn star/commit counts) appeared in the working tree from outside this pass and was reverted — flag for Jon if a stats refresh is wanted. + +--- + +## Pass 5 — Test infrastructure (2026-06-24) + +Tracking issue **#8**. The Pass 2–4 "does it run" verification was done by *manually* +running each notebook headless; this pass automates that and adds real unit coverage for +the helper packages. **Branch `signals-v2-tests` (off `master`). Zero changes inside the +notebooks** — all test code lives in `tests/` + `.github/workflows/`. + +**Decisions (with Jon):** both layers (unit + notebook smoke); GitHub Actions CI; tiered +notebook execution (fast on PR, heavy gesture notebooks nightly); **execute-only** notebook +checks via **nbmake** (not output-diffing — random amplitudes / `random` xzoom / timing make +strict output comparison flaky). + +**Added** +- **Unit tests (`tests/`, pytest):** `test_makelab_signal.py` (wave gen, `shift_array`, + `calc_zero_crossings`, `map`/`remap`, top-N indices), `test_gesturerec_signalproc.py` + (`compute_fft` half-spectrum + peak bin + scaling, `get_top_n_frequency_peaks`), + `test_gesturerec_utility.py` (the `fulldatastream` exclusion guard, `extract_gesture_name`, + `path_leaf`, subdirs), `test_gesturerec_data.py` (`SensorData` mag/rate + int64 cast, `Trial` + CSV parse, `GestureSet.load` ordering **and the Windows `__` double-underscore quirk**), + `test_gesturerec_experiments.py` (`TrialClassificationResult` n-best sort + `is_correct`, + via stub trials). **26 tests, green locally.** Tiny synthetic fixture corpus under + `tests/fixtures/TestGestures/` (avoids depending on the large real `GestureLogs/`). + - *Caught a real contract detail while writing them:* `create_sine_wave(return_time=True)` + returns `(time, sine_wave)` — order matters. +- **nbmake smoke tests:** verified locally that nbmake honors the `raises-exception` tags + (NB2, NB5) and executes notebooks in their own dir (CWD-relative `./Logs` loads work). +- **`pyproject.toml`:** `[project.optional-dependencies].test` (pytest, nbmake, pytest-xdist, + ipykernel) + `[tool.pytest.ini_options].testpaths = ["tests"]` (keeps bare `pytest` fast; + notebook sweeps invoked explicitly by path). +- **CI:** `.github/workflows/ci.yml` (push/PR → `unit` job + `notebooks-fast` job over + Tutorials + StepTracker) and `notebooks-nightly.yml` (`schedule` + `workflow_dispatch` → + the slow GestureRecognizer notebooks). Both install pinned `requirements.txt` + `.[test]` + on Python 3.12 and `libsndfile1` for librosa. +- **Docs:** updated `CLAUDE.md` ("no test suite, no CI" line + a Testing section) and + `README.md` (Testing section + layout). + +**Still to validate post-push:** the GitHub Actions runs themselves (Linux + pinned stack) — +CI can only be confirmed green after the branch lands and a PR triggers it. diff --git a/README.md b/README.md index dc5e594..b0d512b 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,25 @@ import makelab.signal # used by the Tutorials notebooks import gesturerec.data # used by the GestureRecognizer notebooks ``` +## Tests + +The helper packages have unit tests and the notebooks have headless "does it still +execute" smoke tests. None of this lives inside the notebooks. Install the test extras +and run: + +```bash +pip install -e ".[test]" + +pytest tests/ # fast unit tests for makelab + gesturerec +pytest --nbmake Tutorials/ Projects/StepTracker/ # execute the fast notebooks +pytest --nbmake Projects/GestureRecognizer/ # execute the (slow) gesture notebooks +``` + +`nbmake` executes each notebook in its own directory and fails on any uncaught error +(intentional teaching errors are tagged `raises-exception` and allowed). CI runs the +unit tests + fast notebooks on every push/PR, and the slow gesture notebooks nightly — +see [`.github/workflows/`](.github/workflows/). + ## Repository layout ``` @@ -85,6 +104,8 @@ import gesturerec.data # used by the GestureRecognizer notebooks │ ├── gesturerec/ # data structures + experiment scaffolding (package) │ ├── GestureLogs/ # per-participant gesture training data │ └── ADXL335GestureLogs/ # alternate-sensor gesture data +├── tests/ # pytest unit tests for makelab + gesturerec +├── .github/workflows/ # CI: unit + notebook smoke tests ├── pyproject.toml # packaging for makelab + gesturerec ├── requirements.txt # pinned pip environment └── environment.yml # pinned conda environment From e87ae1e86da8ca5014df354a831d826f45f74496 Mon Sep 17 00:00:00 2001 From: Jon Froehlich Date: Wed, 24 Jun 2026 10:01:42 -0700 Subject: [PATCH 3/3] @ Run heavy gesture notebooks on relevant changes + monthly canary, not nightly The top-level deps are pinned, so a nightly rerun of the slow gesture notebooks against an unchanged stack adds nothing. Instead trigger notebooks-heavy.yml on paths-filtered push/PR (Projects/GestureRecognizer/** or the pinned deps) -- i.e. only when a change could actually affect them -- plus a monthly drift canary for unpinned transitive deps / system libs, and the existing manual workflow_dispatch. Renames notebooks-nightly.yml -> notebooks-heavy.yml and updates the doc references in CLAUDE.md, README.md, and MODERNIZATION-NOTES.md. Part of #8. Co-Authored-By: Claude Opus 4.8 @ --- .github/workflows/notebooks-heavy.yml | 53 +++++++++++++++++++++++++ .github/workflows/notebooks-nightly.yml | 30 -------------- CLAUDE.md | 2 +- MODERNIZATION-NOTES.md | 14 ++++--- README.md | 3 +- 5 files changed, 64 insertions(+), 38 deletions(-) create mode 100644 .github/workflows/notebooks-heavy.yml delete mode 100644 .github/workflows/notebooks-nightly.yml diff --git a/.github/workflows/notebooks-heavy.yml b/.github/workflows/notebooks-heavy.yml new file mode 100644 index 0000000..b6fd94f --- /dev/null +++ b/.github/workflows/notebooks-heavy.yml @@ -0,0 +1,53 @@ +name: Notebooks (heavy gesture) + +# The GestureRecognizer notebooks are slow (k-fold cross-validation over the full +# GestureLogs corpus) and produce large outputs, so they are NOT run on every push. +# Triggers: +# - PRs / pushes to master that touch the gesture notebooks, the gesturerec package, +# its data, or the pinned deps (the paths filter below) -- i.e. only when a change +# could actually affect these notebooks; +# - a monthly cron, purely as a drift canary for unpinned transitive deps / system +# libs (the top-level stack is pinned, so day-to-day reruns add nothing); +# - manual "Run workflow" (workflow_dispatch). +# Note: paths filters apply only to push/pull_request; schedule + workflow_dispatch +# always run. +on: + push: + branches: [master] + paths: + - "Projects/GestureRecognizer/**" + - "requirements.txt" + - "environment.yml" + - "pyproject.toml" + - ".github/workflows/notebooks-heavy.yml" + pull_request: + paths: + - "Projects/GestureRecognizer/**" + - "requirements.txt" + - "environment.yml" + - "pyproject.toml" + - ".github/workflows/notebooks-heavy.yml" + schedule: + - cron: "0 8 1 * *" # 08:00 UTC on the 1st of each month + workflow_dispatch: # manual "Run workflow" button + +jobs: + notebooks-heavy: + name: Execute gesture notebooks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: requirements.txt + - name: Install audio system lib (librosa/soundfile) + run: sudo apt-get update && sudo apt-get install -y libsndfile1 + - name: Install pinned env + test extras + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -e ".[test]" + - name: Execute gesture notebooks + run: pytest --nbmake --nbmake-timeout=1800 -n auto Projects/GestureRecognizer/ diff --git a/.github/workflows/notebooks-nightly.yml b/.github/workflows/notebooks-nightly.yml deleted file mode 100644 index f3804ae..0000000 --- a/.github/workflows/notebooks-nightly.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Notebooks (nightly heavy) - -# The GestureRecognizer notebooks are slow (k-fold cross-validation over the full -# GestureLogs corpus) and produce large outputs, so they are not run on every PR. -# Instead they run nightly and on-demand to catch dependency drift over time. -on: - schedule: - - cron: "0 8 * * *" # 08:00 UTC daily - workflow_dispatch: # manual "Run workflow" button - -jobs: - notebooks-heavy: - name: Execute gesture notebooks - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - cache: pip - cache-dependency-path: requirements.txt - - name: Install audio system lib (librosa/soundfile) - run: sudo apt-get update && sudo apt-get install -y libsndfile1 - - name: Install pinned env + test extras - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install -e ".[test]" - - name: Execute gesture notebooks - run: pytest --nbmake --nbmake-timeout=1800 -n auto Projects/GestureRecognizer/ diff --git a/CLAUDE.md b/CLAUDE.md index 0d7dd35..b462b0e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ Install the test stack with `pip install -e ".[test]"` (pytest + nbmake + pytest - **Unit tests** (`tests/`): pure-function tests for the helper packages — `makelab.signal`, `gesturerec.signalproc`/`utility`/`data`/`experiments`. Run with `pytest tests/`. `tests/fixtures/TestGestures/` is a tiny synthetic gesture corpus (3 trial CSVs + a `*_fulldatastream_*` exclusion file) so the `data.py` parser tests don't depend on the large real `GestureLogs/`. It deliberately includes a `Midair Zorro _Z__*.csv` file to exercise the Windows double-underscore filename quirk. - **Notebook smoke tests** (`nbmake`): `pytest --nbmake ` executes notebooks headless and fails on any uncaught error. Intentional teaching errors are already tagged `raises-exception` (notebooks 2 and 5) and are honored — no notebook edits needed. nbmake runs each notebook in its own dir, so the CWD-relative data loads work. -`[tool.pytest.ini_options].testpaths = ["tests"]` keeps a bare `pytest` fast and notebook-free; the notebook sweeps are invoked explicitly by path. CI (`.github/workflows/`) runs units + the fast notebooks (Tutorials + StepTracker) on every push/PR, and the slow gesture notebooks nightly + on-demand. If you add a helper function, add a unit test; if a dependency bump breaks a notebook, the nbmake job is what catches it (this automates the manual Pass 2–4 "Restart & Run All" sweeps). +`[tool.pytest.ini_options].testpaths = ["tests"]` keeps a bare `pytest` fast and notebook-free; the notebook sweeps are invoked explicitly by path. CI (`.github/workflows/`) runs units + the fast notebooks (Tutorials + StepTracker) on every push/PR; the slow gesture notebooks run only when a change touches them (`Projects/GestureRecognizer/**` or the deps), plus a monthly drift-canary cron and on-demand (`workflow_dispatch`). If you add a helper function, add a unit test; if a dependency bump breaks a notebook, the nbmake job is what catches it (this automates the manual Pass 2–4 "Restart & Run All" sweeps). ## gesturerec architecture (Projects/GestureRecognizer) diff --git a/MODERNIZATION-NOTES.md b/MODERNIZATION-NOTES.md index 5a254dd..843dbff 100644 --- a/MODERNIZATION-NOTES.md +++ b/MODERNIZATION-NOTES.md @@ -486,9 +486,10 @@ the helper packages. **Branch `signals-v2-tests` (off `master`). Zero changes in notebooks** — all test code lives in `tests/` + `.github/workflows/`. **Decisions (with Jon):** both layers (unit + notebook smoke); GitHub Actions CI; tiered -notebook execution (fast on PR, heavy gesture notebooks nightly); **execute-only** notebook -checks via **nbmake** (not output-diffing — random amplitudes / `random` xzoom / timing make -strict output comparison flaky). +notebook execution (fast notebooks on every PR; heavy gesture notebooks only when a relevant +change is made — path-filtered — plus a monthly drift canary, since the top-level deps are +pinned so a nightly rerun adds nothing); **execute-only** notebook checks via **nbmake** (not +output-diffing — random amplitudes / `random` xzoom / timing make strict output comparison flaky). **Added** - **Unit tests (`tests/`, pytest):** `test_makelab_signal.py` (wave gen, `shift_array`, @@ -508,9 +509,10 @@ strict output comparison flaky). ipykernel) + `[tool.pytest.ini_options].testpaths = ["tests"]` (keeps bare `pytest` fast; notebook sweeps invoked explicitly by path). - **CI:** `.github/workflows/ci.yml` (push/PR → `unit` job + `notebooks-fast` job over - Tutorials + StepTracker) and `notebooks-nightly.yml` (`schedule` + `workflow_dispatch` → - the slow GestureRecognizer notebooks). Both install pinned `requirements.txt` + `.[test]` - on Python 3.12 and `libsndfile1` for librosa. + Tutorials + StepTracker) and `notebooks-heavy.yml` (the slow GestureRecognizer notebooks, + triggered by a `paths`-filtered push/PR on `Projects/GestureRecognizer/**` or the deps, + plus a monthly `schedule` canary and `workflow_dispatch`). Both install pinned + `requirements.txt` + `.[test]` on Python 3.12 and `libsndfile1` for librosa. - **Docs:** updated `CLAUDE.md` ("no test suite, no CI" line + a Testing section) and `README.md` (Testing section + layout). diff --git a/README.md b/README.md index b0d512b..65d611c 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,8 @@ pytest --nbmake Projects/GestureRecognizer/ # execute the (slow) gestu `nbmake` executes each notebook in its own directory and fails on any uncaught error (intentional teaching errors are tagged `raises-exception` and allowed). CI runs the -unit tests + fast notebooks on every push/PR, and the slow gesture notebooks nightly — +unit tests + fast notebooks on every push/PR, and the slow gesture notebooks only when +a change touches them (plus a monthly canary and on-demand) — see [`.github/workflows/`](.github/workflows/). ## Repository layout