RemiFabre commited on
Commit
7659b58
·
1 Parent(s): 3f99919

Expand test suite to ~170 tests across 3 tiers

Browse files

Unit tests (65 → 95): Add 7 new test classes covering upload-audio
endpoint, motion model endpoint, community datasets, corrupt data
recovery, concurrent state rejection, duration edge cases, and sync
dataset validation.

E2E tests (18 → 44): Add 8 new test classes covering recording
lifecycle, playback lifecycle, delete with confirm/cancel dialogs,
dataset creation/switching, audio source radios, settings panel,
and form edge cases including localStorage persistence.

Hardware tests (24 → 31): Add 4 new test classes for existing
recording playback, record-then-playback fidelity and timing,
antenna/body_yaw data verification, and synthetic antenna playback.

Also:
- Add make_wav_bytes() helper to conftest.py for upload tests
- Create tests/REMOTE_TESTING.md documenting SSH/rsync workflow
- Update run_tests.py TEST_CLASS_DESCRIPTIONS with all new classes
- Update TESTING.md tables with new test counts
- Fix smart quotes (U+201D) in index.html that broke CSS selectors

TESTING.md CHANGED
@@ -146,7 +146,7 @@ pytest tests/ --browser chromium
146
  | TestStartingUpMode | 3 | Starting-up mode rejects commands |
147
  | TestRecordEndpoint | 9 | POST /api/record validation and state transitions |
148
  | TestPlayEndpoint | 3 | POST /api/play validation |
149
- | TestStopEndpoints | 2 | POST /api/play/stop and /api/record/stop |
150
  | TestMoveDelete | 4 | DELETE /api/moves/:id and file cleanup |
151
  | TestDatasets | 11 | Dataset create, select, root change, origin |
152
  | TestRegistryPersistence | 3 | Registry file survival across restarts |
@@ -154,6 +154,13 @@ pytest tests/ --browser chromium
154
  | TestExperiments | 4 | Feature toggles and experimental settings |
155
  | TestHfAutoLogin | 5 | HF auto-login detection and caching |
156
  | TestSensorData | 1 | Sensor data dummy endpoint |
 
 
 
 
 
 
 
157
 
158
  ### E2E browser tests (`tests/e2e/test_ui.py`)
159
 
@@ -165,6 +172,14 @@ pytest tests/ --browser chromium
165
  | TestFormValidation | 5 | Form fields and HTML5 validation |
166
  | TestRecordingSubmission | 1 | Recording submission changes mode |
167
  | TestDatasetUI | 2 | Dataset UI elements visible |
 
 
 
 
 
 
 
 
168
 
169
  ### Hardware tests (`tests/test_hardware.py`)
170
 
@@ -177,6 +192,10 @@ pytest tests/ --browser chromium
177
  | TestMotionAccuracy | 3 | Synthetic playback accuracy — reference vs observed poses |
178
  | TestMultiDuration | 7 | Recording and playback across 1s/3s/5s/10s durations |
179
  | TestPerformance | 3 | Startup, recording, and playback latency benchmarks |
 
 
 
 
180
  | TestHardwareAudio | 3 | Audio recording and playback (may skip on mic issues) |
181
 
182
  ## View the matrix without running tests
@@ -246,7 +265,7 @@ git push
246
 
247
  ```
248
  tests/
249
- ├── conftest.py # Shared fixtures (TestClient, temp paths)
250
  ├── test_api.py # Unit tests — backend API
251
  ├── test_hardware.py # Hardware integration tests (real robot)
252
  ├── pose_utils.py # Trajectory comparison utilities
@@ -255,7 +274,11 @@ tests/
255
  │ └── test_ui.py # E2E browser tests
256
  ├── run_tests.py # Test runner + matrix logger + web export
257
  ├── run_on_robot.py # Remote test runner (SSH + rsync to robot)
 
 
258
  ├── show_matrix.py # Matrix viewer
 
 
259
  └── test_results.json # Auto-generated results log (git-ignored)
260
 
261
  marionette/static/
 
146
  | TestStartingUpMode | 3 | Starting-up mode rejects commands |
147
  | TestRecordEndpoint | 9 | POST /api/record validation and state transitions |
148
  | TestPlayEndpoint | 3 | POST /api/play validation |
149
+ | TestStopEndpoints | 3 | POST /api/play/stop and /api/record/stop |
150
  | TestMoveDelete | 4 | DELETE /api/moves/:id and file cleanup |
151
  | TestDatasets | 11 | Dataset create, select, root change, origin |
152
  | TestRegistryPersistence | 3 | Registry file survival across restarts |
 
154
  | TestExperiments | 4 | Feature toggles and experimental settings |
155
  | TestHfAutoLogin | 5 | HF auto-login detection and caching |
156
  | TestSensorData | 1 | Sensor data dummy endpoint |
157
+ | TestUploadAudio | 7 | POST /api/upload-audio validation and integration |
158
+ | TestMotionModelEndpoint | 4 | POST /api/motion-model enable, set, persist |
159
+ | TestCommunityDatasets | 3 | Community dataset listing and download validation |
160
+ | TestCorruptData | 4 | Malformed JSON and corrupt registry recovery |
161
+ | TestConcurrentStateChanges | 4 | Concurrent operations rejected when busy |
162
+ | TestDurationEdgeCases | 3 | Duration boundary validation (gt=0.5, le=300) |
163
+ | TestSyncDatasetExtended | 4 | Sync endpoint edge cases and validation |
164
 
165
  ### E2E browser tests (`tests/e2e/test_ui.py`)
166
 
 
172
  | TestFormValidation | 5 | Form fields and HTML5 validation |
173
  | TestRecordingSubmission | 1 | Recording submission changes mode |
174
  | TestDatasetUI | 2 | Dataset UI elements visible |
175
+ | TestRecordingLifecycle | 4 | Recording submit, stop, and injected move visibility |
176
+ | TestPlaybackLifecycle | 3 | Play button, queued playback, and move metadata |
177
+ | TestDeleteMove | 3 | Delete button, confirm/cancel dialog handling |
178
+ | TestCreateDataset | 4 | New dataset form show/hide/create/validate |
179
+ | TestSwitchDataset | 2 | Dataset switching and dropdown population |
180
+ | TestAudioSourceRadios | 3 | Audio source radio buttons and upload area toggle |
181
+ | TestSettingsPanel | 3 | Settings expand, experimental toggle, root display |
182
+ | TestFormEdgeCases | 4 | Empty/long/special labels and localStorage persistence |
183
 
184
  ### Hardware tests (`tests/test_hardware.py`)
185
 
 
192
  | TestMotionAccuracy | 3 | Synthetic playback accuracy — reference vs observed poses |
193
  | TestMultiDuration | 7 | Recording and playback across 1s/3s/5s/10s durations |
194
  | TestPerformance | 3 | Startup, recording, and playback latency benchmarks |
195
+ | TestExistingRecordingPlayback | 2 | Play back existing audio and silent recordings |
196
+ | TestRecordingRoundTrip | 2 | Record → playback fidelity and timing verification |
197
+ | TestAntennaAndBodyYaw | 2 | Verify antenna and body_yaw data in recordings |
198
+ | TestPlaybackAntennas | 1 | Synthetic antenna oscillation playback |
199
  | TestHardwareAudio | 3 | Audio recording and playback (may skip on mic issues) |
200
 
201
  ## View the matrix without running tests
 
265
 
266
  ```
267
  tests/
268
+ ├── conftest.py # Shared fixtures (TestClient, temp paths, make_wav_bytes)
269
  ├── test_api.py # Unit tests — backend API
270
  ├── test_hardware.py # Hardware integration tests (real robot)
271
  ├── pose_utils.py # Trajectory comparison utilities
 
274
  │ └── test_ui.py # E2E browser tests
275
  ├── run_tests.py # Test runner + matrix logger + web export
276
  ├── run_on_robot.py # Remote test runner (SSH + rsync to robot)
277
+ ├── run_smoke_on_robot.py # Smoke test runner (SSH + rsync)
278
+ ├── smoke_test_on_robot.py # Smoke test script (runs on robot)
279
  ├── show_matrix.py # Matrix viewer
280
+ ├── REMOTE_TESTING.md # SSH/rsync workflow documentation
281
+ ├── HARDWARE_TEST_PLAN.md # Hardware test plan
282
  └── test_results.json # Auto-generated results log (git-ignored)
283
 
284
  marionette/static/
marionette/static/index.html CHANGED
@@ -158,44 +158,44 @@
158
  </details>
159
  </section>
160
 
161
- <section class=panel config-panel>
162
- <details id=config-details>
163
  <summary>Settings</summary>
164
- <div class=config-content>
165
- <form id=dataset-root-form class=settings-row>
166
- <div class=field-group style=flex:1>
167
- <label for=dataset-root-input>Datasets folder</label>
168
  <input
169
- id=dataset-root-input
170
- type=text
171
- placeholder=/home/user/datasets
172
  required
173
  />
174
- <p class=hint>All datasets live under <code id=dataset-root-inline>local_dataset/</code>.</p>
175
  </div>
176
- <button type=submit class=ghost-btn>Update</button>
177
  </form>
178
 
179
- <div class=experimental-block id=experimental-panel hidden>
180
- <details id=experimental-details>
181
  <summary>Experimental</summary>
182
- <div class=experimental-content>
183
- <p class=hint>Prototype features. May change or disappear without notice.</p>
184
- <div class=field-group>
185
- <label class=toggle>
186
- <input id=feature-motion-models type=checkbox />
187
  <span>Enable motion compensation models</span>
188
  </label>
189
- <p class=hint small>
190
  Replay moves through predictive filters to compensate P-controller lag.
191
  </p>
192
  </div>
193
- <div class=field-group id=motion-model-group hidden>
194
- <label for=motion-model-select>Motion model</label>
195
- <select id=motion-model-select></select>
196
- <p class=hint id=motion-model-hint></p>
197
  </div>
198
- <p class=hint id=experimental-status></p>
199
  </div>
200
  </details>
201
  </div>
 
158
  </details>
159
  </section>
160
 
161
+ <section class="panel config-panel">
162
+ <details id="config-details">
163
  <summary>Settings</summary>
164
+ <div class="config-content">
165
+ <form id="dataset-root-form" class="settings-row">
166
+ <div class="field-group" style="flex:1">
167
+ <label for="dataset-root-input">Datasets folder</label>
168
  <input
169
+ id="dataset-root-input"
170
+ type="text"
171
+ placeholder="/home/user/datasets"
172
  required
173
  />
174
+ <p class="hint">All datasets live under <code id="dataset-root-inline">local_dataset/</code>.</p>
175
  </div>
176
+ <button type="submit" class="ghost-btn">Update</button>
177
  </form>
178
 
179
+ <div class="experimental-block" id="experimental-panel" hidden>
180
+ <details id="experimental-details">
181
  <summary>Experimental</summary>
182
+ <div class="experimental-content">
183
+ <p class="hint">Prototype features. May change or disappear without notice.</p>
184
+ <div class="field-group">
185
+ <label class="toggle">
186
+ <input id="feature-motion-models" type="checkbox" />
187
  <span>Enable motion compensation models</span>
188
  </label>
189
+ <p class="hint small">
190
  Replay moves through predictive filters to compensate P-controller lag.
191
  </p>
192
  </div>
193
+ <div class="field-group" id="motion-model-group" hidden>
194
+ <label for="motion-model-select">Motion model</label>
195
+ <select id="motion-model-select"></select>
196
+ <p class="hint" id="motion-model-hint"></p>
197
  </div>
198
+ <p class="hint" id="experimental-status"></p>
199
  </div>
200
  </details>
201
  </div>
tests/REMOTE_TESTING.md ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Remote Testing on Reachy Mini
2
+
3
+ Run hardware tests directly on the robot via SSH + rsync. This avoids
4
+ network-latency artefacts and ensures audio playback uses the robot's
5
+ local speakers (WAV files are silently dropped over WebRTC).
6
+
7
+ ## Robot Models
8
+
9
+ | Model | Default hostname | User | Notes |
10
+ |-------|-----------------|------|-------|
11
+ | **Wireless** | `reachy-mini.local` | `pollen` | Wi-Fi, mDNS discovery |
12
+ | **Lite** | `reachy-mini.local` | `pollen` | Same procedure, may differ in hostname/IP |
13
+
14
+ Both models use the same venv on-robot:
15
+
16
+ ```
17
+ /venvs/apps_venv/bin/python
18
+ ```
19
+
20
+ ## One-Time SSH Key Setup
21
+
22
+ Identical across Linux, macOS, and Windows (WSL2/Git Bash):
23
+
24
+ ```bash
25
+ # Generate a key if you don't already have one
26
+ ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519
27
+
28
+ # Copy it to the robot (password: pollen)
29
+ ssh-copy-id pollen@reachy-mini.local
30
+
31
+ # Verify passwordless access
32
+ ssh pollen@reachy-mini.local echo OK
33
+ ```
34
+
35
+ ## Network Discovery
36
+
37
+ The robot advertises itself via mDNS as `reachy-mini.local`.
38
+
39
+ | Platform | mDNS support | Notes |
40
+ |----------|-------------|-------|
41
+ | **Linux (Ubuntu)** | Requires `avahi-daemon` | Usually pre-installed; `sudo apt install avahi-daemon` if not |
42
+ | **macOS** | Built-in (Bonjour) | Works out of the box |
43
+ | **Windows** | Not native | Use WSL2 or Git Bash; fall back to `--host <IP>` |
44
+
45
+ If mDNS doesn't resolve, find the robot's IP from your router or use:
46
+
47
+ ```bash
48
+ python tests/run_on_robot.py --host 192.168.1.42
49
+ ```
50
+
51
+ ## What Gets Synced
52
+
53
+ The `run_on_robot.py` script uses `rsync -avz --delete` to sync three
54
+ items to `/tmp/marionette_test/` on the robot:
55
+
56
+ | Local path | Remote path | Contents |
57
+ |-----------|------------|----------|
58
+ | `marionette/` | `/tmp/marionette_test/marionette/` | Python package |
59
+ | `tests/` | `/tmp/marionette_test/tests/` | Test files |
60
+ | `pyproject.toml` | `/tmp/marionette_test/pyproject.toml` | Package metadata |
61
+
62
+ ### Excluded from sync
63
+
64
+ - `__pycache__/` — bytecode cache
65
+ - `*.egg-info/` — build artefacts
66
+ - `build/` — build output
67
+ - `local_dataset/` — user data
68
+ - `.pytest_cache/` — pytest cache
69
+ - `dataset_registry.json` — local state
70
+ - `temp_uploads/` — uploaded audio files
71
+ - `tests/e2e/` — browser tests (no browser on robot)
72
+ - `tests/test_results.json` — local results log
73
+ - `.git/` — version control
74
+
75
+ ## Commands
76
+
77
+ ### Run hardware tests on the robot
78
+
79
+ ```bash
80
+ cd marionette
81
+ python tests/run_on_robot.py
82
+ ```
83
+
84
+ This will:
85
+ 1. Create `/tmp/marionette_test/` on the robot
86
+ 2. rsync the code (excluding E2E tests, caches, user data)
87
+ 3. Install missing dev deps (`pytest`, `httpx`, `pytest-json-report`, `scipy`)
88
+ 4. Run `pytest -m hardware -v` on the robot, streaming output
89
+ 5. Fetch the JSON report and print a summary
90
+
91
+ **Options:**
92
+
93
+ ```bash
94
+ python tests/run_on_robot.py --dry-run # show what would be synced
95
+ python tests/run_on_robot.py --host 192.168.1.42 # custom host/IP
96
+ python tests/run_on_robot.py --user pollen # custom SSH user
97
+ python tests/run_on_robot.py -k test_playback # extra pytest args
98
+ ```
99
+
100
+ ### Run smoke test on the robot
101
+
102
+ A quick check that the robot moves and audio plays:
103
+
104
+ ```bash
105
+ cd marionette
106
+ python tests/run_smoke_on_robot.py
107
+ ```
108
+
109
+ ### Via the main test runner
110
+
111
+ ```bash
112
+ cd marionette
113
+ python tests/run_tests.py --on-robot
114
+ python tests/run_tests.py --on-robot --host 192.168.1.42
115
+ ```
116
+
117
+ ## Platform Notes
118
+
119
+ ### Ubuntu (Linux)
120
+
121
+ - Ensure `avahi-daemon` is running for `.local` resolution
122
+ - If the default system mic is the robot's mic, audio recording tests
123
+ may hang — use an external mic or skip audio tests with `-k "not Audio"`
124
+ - Tested with Python 3.10+
125
+
126
+ ### macOS
127
+
128
+ - Bonjour handles `.local` resolution automatically
129
+ - No known issues with SSH/rsync workflow
130
+
131
+ ### Windows
132
+
133
+ - Use WSL2 or Git Bash for SSH/rsync
134
+ - mDNS (`.local`) doesn't work natively — use `--host <IP>` instead
135
+ - Or install Bonjour Print Services for mDNS support
136
+
137
+ ## For AI Agents
138
+
139
+ ### Prerequisites
140
+
141
+ - **Sandbox must be disabled** — the robot SDK uses Zenoh on `tcp/localhost:7447`
142
+ which requires real network access. Use `/sandbox` → disable in Claude Code.
143
+ - **Bash permissions** — must be allowed for running SSH commands
144
+
145
+ ### Detecting the robot
146
+
147
+ ```bash
148
+ # Quick connectivity check (exits 0 if robot is reachable)
149
+ cd marionette
150
+ python tests/check_robot.py
151
+ ```
152
+
153
+ This imports `ReachyMini()` and checks for a response. Times out after
154
+ 15 seconds if the robot is unreachable.
155
+
156
+ ### What's pre-installed on the robot
157
+
158
+ The robot runs Ubuntu with Python 3.10 in `/venvs/apps_venv/`. The
159
+ following are already available:
160
+
161
+ - `reachy_mini` SDK
162
+ - `numpy`, `scipy`
163
+ - `soundfile` (for audio)
164
+ - Standard system tools: `rsync`, `ssh`, `python3`
165
+
166
+ Dev dependencies (`pytest`, `httpx`, `pytest-json-report`) are installed
167
+ automatically by `run_on_robot.py` if missing.
168
+
169
+ ### Running tests programmatically
170
+
171
+ ```python
172
+ import subprocess
173
+ result = subprocess.run(
174
+ ["python", "tests/run_on_robot.py", "--host", "reachy-mini.local"],
175
+ cwd="/path/to/marionette",
176
+ capture_output=True, text=True,
177
+ )
178
+ print(result.stdout)
179
+ ```
180
+
181
+ ## Troubleshooting
182
+
183
+ | Problem | Solution |
184
+ |---------|----------|
185
+ | `ssh: Could not resolve hostname reachy-mini.local` | Install avahi-daemon (Linux) or use `--host <IP>` |
186
+ | `Permission denied (publickey)` | Run `ssh-copy-id pollen@reachy-mini.local` |
187
+ | `rsync: connection unexpectedly closed` | Check robot is powered on and on the same network |
188
+ | `ReachyMini() connection timed out` | Robot may be starting up — wait 30s and retry |
189
+ | Audio tests hang | System mic may be the robot's mic; use external mic or skip with `-k "not Audio"` |
190
+ | `ModuleNotFoundError: No module named 'marionette'` | Ensure `PYTHONPATH` includes the sync dir (handled by `run_on_robot.py`) |
tests/conftest.py CHANGED
@@ -1,6 +1,9 @@
1
  """Shared fixtures for Marionette tests."""
2
 
 
3
  import json
 
 
4
  from pathlib import Path
5
 
6
  import pytest
@@ -9,6 +12,22 @@ from fastapi.testclient import TestClient
9
  from marionette.main import Marionette, create_app
10
 
11
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  def pytest_collection_modifyitems(config, items):
13
  """Auto-skip @pytest.mark.hardware tests unless -m hardware is given."""
14
  marker_expr = config.getoption("-m", default="")
 
1
  """Shared fixtures for Marionette tests."""
2
 
3
+ import io
4
  import json
5
+ import struct
6
+ import wave
7
  from pathlib import Path
8
 
9
  import pytest
 
12
  from marionette.main import Marionette, create_app
13
 
14
 
15
+ def make_wav_bytes(duration: float = 1.0, sample_rate: int = 44100) -> bytes:
16
+ """Build a valid WAV file in memory using only stdlib.
17
+
18
+ Returns raw bytes suitable for uploading via TestClient.
19
+ """
20
+ n_frames = int(duration * sample_rate)
21
+ buf = io.BytesIO()
22
+ with wave.open(buf, "wb") as wf:
23
+ wf.setnchannels(1)
24
+ wf.setsampwidth(2) # 16-bit
25
+ wf.setframerate(sample_rate)
26
+ # Write silence (all zeros)
27
+ wf.writeframes(struct.pack(f"<{n_frames}h", *([0] * n_frames)))
28
+ return buf.getvalue()
29
+
30
+
31
  def pytest_collection_modifyitems(config, items):
32
  """Auto-skip @pytest.mark.hardware tests unless -m hardware is given."""
33
  marker_expr = config.getoption("-m", default="")
tests/e2e/test_ui.py CHANGED
@@ -167,3 +167,472 @@ class TestDatasetUI:
167
  def test_new_dataset_button_exists(self, page: Page, base_url: str):
168
  page.goto(base_url)
169
  expect(page.locator("#new-dataset-btn")).to_be_visible()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  def test_new_dataset_button_exists(self, page: Page, base_url: str):
168
  page.goto(base_url)
169
  expect(page.locator("#new-dataset-btn")).to_be_visible()
170
+
171
+
172
+ # ──────── Recording lifecycle tests ──────────────────────────────────
173
+
174
+
175
+ class TestRecordingLifecycle:
176
+ def test_submit_recording_shows_queued(self, page: Page, base_url: str, test_marionette):
177
+ page.goto(base_url)
178
+ page.wait_for_timeout(2000)
179
+
180
+ page.locator("#record-options summary").click()
181
+ page.locator("#audio-source-none").check()
182
+ page.locator("#duration").fill("2")
183
+ page.locator("#label").fill("lifecycle-test")
184
+ page.locator("#record-btn").click()
185
+ page.wait_for_timeout(2000)
186
+
187
+ pill_text = page.locator("#mode-pill").text_content()
188
+ assert pill_text is not None
189
+ assert "queued" in pill_text.lower() or "countdown" in pill_text.lower()
190
+
191
+ test_marionette._set_idle_state()
192
+ test_marionette._pending_recording = None
193
+
194
+ def test_stop_button_visible_when_queued(self, page: Page, base_url: str, test_marionette):
195
+ page.goto(base_url)
196
+ page.wait_for_timeout(2000)
197
+
198
+ page.locator("#record-options summary").click()
199
+ page.locator("#audio-source-none").check()
200
+ page.locator("#duration").fill("2")
201
+ page.locator("#record-btn").click()
202
+ page.wait_for_timeout(2000)
203
+
204
+ # Stop recording button should be visible in queued/countdown/recording mode
205
+ # Note: in queued mode, the stop-recording-btn may not be shown by the JS
206
+ # (JS only shows it in recording/countdown). Check either is visible.
207
+ stop_rec = page.locator("#stop-recording-btn")
208
+ # It may be hidden if mode is just "queued" — the UI only shows stop in recording/countdown
209
+ # The important thing is the mode changed from idle
210
+ pill = page.locator("#mode-pill").text_content() or ""
211
+ assert "idle" not in pill.lower()
212
+
213
+ test_marionette._set_idle_state()
214
+ test_marionette._pending_recording = None
215
+
216
+ def test_stop_recording_returns_to_idle(self, page: Page, base_url: str, test_marionette):
217
+ page.goto(base_url)
218
+ page.wait_for_timeout(2000)
219
+
220
+ page.locator("#record-options summary").click()
221
+ page.locator("#audio-source-none").check()
222
+ page.locator("#duration").fill("2")
223
+ page.locator("#record-btn").click()
224
+ page.wait_for_timeout(2000)
225
+
226
+ # Stop via API (simpler than clicking a potentially hidden button)
227
+ import httpx
228
+ httpx.post(f"{base_url}/api/record/stop", timeout=5)
229
+ page.wait_for_timeout(2000)
230
+
231
+ pill_text = page.locator("#mode-pill").text_content() or ""
232
+ assert "idle" in pill_text.lower()
233
+
234
+ test_marionette._set_idle_state()
235
+ test_marionette._pending_recording = None
236
+
237
+ def test_injected_move_appears_in_list(self, page: Page, base_url: str, test_marionette):
238
+ import json
239
+ data_dir = test_marionette._dataset_dir
240
+ move_data = {
241
+ "description": "injected",
242
+ "time": [0.0, 0.01, 0.02],
243
+ "set_target_data": [
244
+ {"head": [[1,0,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]], "antennas": [0,0], "body_yaw": 0.0}
245
+ for _ in range(3)
246
+ ],
247
+ }
248
+ (data_dir / "e2e-injected.json").write_text(json.dumps(move_data))
249
+ test_marionette._refresh_recordings()
250
+
251
+ page.goto(base_url)
252
+ page.wait_for_timeout(2000)
253
+
254
+ moves_text = page.locator("#moves-list").text_content() or ""
255
+ assert "e2e-injected" in moves_text
256
+
257
+ # Cleanup
258
+ (data_dir / "e2e-injected.json").unlink(missing_ok=True)
259
+ test_marionette._refresh_recordings()
260
+
261
+
262
+ # ──────── Playback lifecycle tests ───────────────────────────────────
263
+
264
+
265
+ class TestPlaybackLifecycle:
266
+ def _inject_move(self, test_marionette, move_id="e2e-play-test"):
267
+ import json
268
+ data_dir = test_marionette._dataset_dir
269
+ move_data = {
270
+ "description": "playback test",
271
+ "time": [i * 0.01 for i in range(100)],
272
+ "set_target_data": [
273
+ {"head": [[1,0,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]], "antennas": [0,0], "body_yaw": 0.0}
274
+ for _ in range(100)
275
+ ],
276
+ }
277
+ (data_dir / f"{move_id}.json").write_text(json.dumps(move_data))
278
+ test_marionette._refresh_recordings()
279
+ return data_dir / f"{move_id}.json"
280
+
281
+ def test_play_button_exists_for_move(self, page: Page, base_url: str, test_marionette):
282
+ self._inject_move(test_marionette)
283
+ page.goto(base_url)
284
+ page.wait_for_timeout(2000)
285
+
286
+ play_btns = page.locator(".play-btn")
287
+ assert play_btns.count() > 0
288
+
289
+ # Cleanup
290
+ (test_marionette._dataset_dir / "e2e-play-test.json").unlink(missing_ok=True)
291
+ test_marionette._refresh_recordings()
292
+
293
+ def test_click_play_queues_playback(self, page: Page, base_url: str, test_marionette):
294
+ self._inject_move(test_marionette)
295
+ page.goto(base_url)
296
+ page.wait_for_timeout(2000)
297
+
298
+ page.locator(".play-btn").first.click()
299
+ page.wait_for_timeout(2000)
300
+
301
+ pill_text = page.locator("#mode-pill").text_content() or ""
302
+ assert "queued" in pill_text.lower() or "playing" in pill_text.lower()
303
+
304
+ test_marionette._set_idle_state()
305
+ test_marionette._pending_playback = None
306
+ (test_marionette._dataset_dir / "e2e-play-test.json").unlink(missing_ok=True)
307
+ test_marionette._refresh_recordings()
308
+
309
+ def test_move_metadata_displayed(self, page: Page, base_url: str, test_marionette):
310
+ self._inject_move(test_marionette)
311
+ page.goto(base_url)
312
+ page.wait_for_timeout(2000)
313
+
314
+ # Check move label is visible
315
+ label_el = page.locator(".move-label").first
316
+ expect(label_el).to_be_visible()
317
+ # Check move info (duration, etc.) is visible
318
+ info_el = page.locator(".move-info").first
319
+ expect(info_el).to_be_visible()
320
+
321
+ (test_marionette._dataset_dir / "e2e-play-test.json").unlink(missing_ok=True)
322
+ test_marionette._refresh_recordings()
323
+
324
+
325
+ # ──────── Delete move tests ──────────────────────────────────────────
326
+
327
+
328
+ class TestDeleteMove:
329
+ def _inject_move(self, test_marionette, move_id="e2e-delete-test"):
330
+ import json
331
+ data_dir = test_marionette._dataset_dir
332
+ move_data = {
333
+ "description": "delete test",
334
+ "time": [0.0, 0.01],
335
+ "set_target_data": [
336
+ {"head": [[1,0,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]], "antennas": [0,0], "body_yaw": 0.0}
337
+ for _ in range(2)
338
+ ],
339
+ }
340
+ path = data_dir / f"{move_id}.json"
341
+ path.write_text(json.dumps(move_data))
342
+ test_marionette._refresh_recordings()
343
+ return path
344
+
345
+ def test_delete_button_exists(self, page: Page, base_url: str, test_marionette):
346
+ self._inject_move(test_marionette)
347
+ page.goto(base_url)
348
+ page.wait_for_timeout(2000)
349
+
350
+ delete_btns = page.locator(".play-btn.danger")
351
+ assert delete_btns.count() > 0
352
+
353
+ (test_marionette._dataset_dir / "e2e-delete-test.json").unlink(missing_ok=True)
354
+ test_marionette._refresh_recordings()
355
+
356
+ def test_delete_confirm_removes_move(self, page: Page, base_url: str, test_marionette):
357
+ self._inject_move(test_marionette)
358
+ page.goto(base_url)
359
+ page.wait_for_timeout(2000)
360
+
361
+ # Register dialog handler BEFORE clicking
362
+ page.once("dialog", lambda dialog: dialog.accept())
363
+ page.locator(".play-btn.danger").first.click()
364
+ page.wait_for_timeout(3000)
365
+
366
+ moves_text = page.locator("#moves-list").text_content() or ""
367
+ assert "e2e-delete-test" not in moves_text
368
+
369
+ def test_delete_cancel_keeps_move(self, page: Page, base_url: str, test_marionette):
370
+ self._inject_move(test_marionette)
371
+ page.goto(base_url)
372
+ page.wait_for_timeout(2000)
373
+
374
+ # Register dialog handler BEFORE clicking
375
+ page.once("dialog", lambda dialog: dialog.dismiss())
376
+ page.locator(".play-btn.danger").first.click()
377
+ page.wait_for_timeout(2000)
378
+
379
+ moves_text = page.locator("#moves-list").text_content() or ""
380
+ assert "e2e-delete-test" in moves_text
381
+
382
+ (test_marionette._dataset_dir / "e2e-delete-test.json").unlink(missing_ok=True)
383
+ test_marionette._refresh_recordings()
384
+
385
+
386
+ # ──────── Create dataset tests ───────────────────────────────────────
387
+
388
+
389
+ class TestCreateDataset:
390
+ def test_new_dataset_button_shows_form(self, page: Page, base_url: str):
391
+ page.goto(base_url)
392
+ page.wait_for_timeout(2000)
393
+
394
+ page.locator("#new-dataset-btn").click()
395
+ expect(page.locator("#inline-dataset-form")).to_be_visible()
396
+
397
+ def test_cancel_hides_form(self, page: Page, base_url: str):
398
+ page.goto(base_url)
399
+ page.wait_for_timeout(2000)
400
+
401
+ page.locator("#new-dataset-btn").click()
402
+ expect(page.locator("#inline-dataset-form")).to_be_visible()
403
+
404
+ page.locator("#inline-dataset-cancel").click()
405
+ expect(page.locator("#inline-dataset-form")).to_be_hidden()
406
+
407
+ def test_create_dataset_appears_in_dropdown(self, page: Page, base_url: str, test_marionette):
408
+ page.goto(base_url)
409
+ page.wait_for_timeout(2000)
410
+
411
+ page.locator("#new-dataset-btn").click()
412
+ page.locator("#inline-dataset-name").fill("e2e-test-ds")
413
+ page.locator("#inline-dataset-create").click()
414
+ page.wait_for_timeout(2000)
415
+
416
+ # Check the dropdown contains the new dataset
417
+ options_text = page.locator("#dataset-select").text_content() or ""
418
+ assert "e2e-test-ds" in options_text.lower()
419
+
420
+ def test_empty_name_not_submitted(self, page: Page, base_url: str):
421
+ page.goto(base_url)
422
+ page.wait_for_timeout(2000)
423
+
424
+ page.locator("#new-dataset-btn").click()
425
+ page.locator("#inline-dataset-name").fill("")
426
+ page.locator("#inline-dataset-create").click()
427
+ page.wait_for_timeout(500)
428
+
429
+ # Form should still be visible (validation prevented submission)
430
+ expect(page.locator("#inline-dataset-form")).to_be_visible()
431
+
432
+
433
+ # ──────── Switch dataset tests ───────────────────────────────────────
434
+
435
+
436
+ class TestSwitchDataset:
437
+ def test_switch_dataset_changes_moves(self, page: Page, base_url: str, test_marionette):
438
+ import json, httpx
439
+
440
+ # Create a second dataset via API
441
+ resp = httpx.post(f"{base_url}/api/datasets", json={"name": "e2e-switch-a"}, timeout=5)
442
+ assert resp.status_code == 200
443
+
444
+ # Inject a move into it
445
+ data_dir = test_marionette._dataset_dir
446
+ move_data = {
447
+ "description": "switch test",
448
+ "time": [0.0, 0.01],
449
+ "set_target_data": [
450
+ {"head": [[1,0,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]], "antennas": [0,0], "body_yaw": 0.0}
451
+ for _ in range(2)
452
+ ],
453
+ }
454
+ (data_dir / "switch-move.json").write_text(json.dumps(move_data))
455
+ test_marionette._refresh_recordings()
456
+
457
+ # Get dataset list
458
+ state = httpx.get(f"{base_url}/api/state", timeout=5).json()
459
+ entries = state["datasets"]["entries"]
460
+ other_ds = [e for e in entries if e["id"] != state["datasets"]["active_id"]]
461
+
462
+ page.goto(base_url)
463
+ page.wait_for_timeout(2000)
464
+
465
+ # Verify the injected move is visible
466
+ assert "switch-move" in (page.locator("#moves-list").text_content() or "")
467
+
468
+ if other_ds:
469
+ # Switch to a different dataset
470
+ page.locator("#dataset-select").select_option(other_ds[0]["id"])
471
+ page.wait_for_timeout(2000)
472
+
473
+ # The moves list should change (no "switch-move" in the other dataset)
474
+ moves_text = page.locator("#moves-list").text_content() or ""
475
+ assert "switch-move" not in moves_text
476
+
477
+ # Cleanup
478
+ (data_dir / "switch-move.json").unlink(missing_ok=True)
479
+ test_marionette._refresh_recordings()
480
+
481
+ def test_dropdown_lists_all_datasets(self, page: Page, base_url: str):
482
+ import httpx
483
+
484
+ # Create a dataset via API
485
+ httpx.post(f"{base_url}/api/datasets", json={"name": "e2e-dropdown-check"}, timeout=5)
486
+
487
+ page.goto(base_url)
488
+ page.wait_for_timeout(2000)
489
+
490
+ options = page.locator("#dataset-select option")
491
+ assert options.count() >= 2 # at least default + newly created
492
+
493
+
494
+ # ──────── Audio source radio tests ───────────────────────────────────
495
+
496
+
497
+ class TestAudioSourceRadios:
498
+ def test_default_audio_source(self, page: Page, base_url: str):
499
+ page.goto(base_url)
500
+ page.locator("#record-options summary").click()
501
+ # Default may be mic (if audio available) or none
502
+ mic = page.locator("#audio-source-mic")
503
+ none = page.locator("#audio-source-none")
504
+ # At least one should be checked
505
+ mic_checked = mic.is_checked()
506
+ none_checked = none.is_checked()
507
+ assert mic_checked or none_checked
508
+
509
+ def test_upload_radio_shows_upload_area(self, page: Page, base_url: str):
510
+ page.goto(base_url)
511
+ page.locator("#record-options summary").click()
512
+ page.locator("#audio-source-upload").check()
513
+ page.wait_for_timeout(500)
514
+ expect(page.locator("#audio-upload-group")).to_be_visible()
515
+
516
+ def test_none_radio_hides_upload_area(self, page: Page, base_url: str):
517
+ page.goto(base_url)
518
+ page.locator("#record-options summary").click()
519
+ page.locator("#audio-source-upload").check()
520
+ page.wait_for_timeout(500)
521
+ expect(page.locator("#audio-upload-group")).to_be_visible()
522
+
523
+ page.locator("#audio-source-none").check()
524
+ page.wait_for_timeout(500)
525
+ expect(page.locator("#audio-upload-group")).to_be_hidden()
526
+
527
+
528
+ # ──────── Settings panel tests ───────────────────────────────────────
529
+
530
+
531
+ class TestSettingsPanel:
532
+ def test_settings_expands(self, page: Page, base_url: str):
533
+ page.goto(base_url)
534
+ page.wait_for_timeout(2000)
535
+
536
+ # Use direct child selector to avoid matching nested summary
537
+ summary = page.locator("#config-details > summary")
538
+ summary.scroll_into_view_if_needed()
539
+ summary.click()
540
+ expect(page.locator("#dataset-root-form")).to_be_visible()
541
+
542
+ def test_experimental_toggle(self, page: Page, base_url: str):
543
+ import httpx
544
+ # Enable motion_models via API first
545
+ httpx.post(f"{base_url}/api/experiments", json={"motion_models": True}, timeout=5)
546
+
547
+ page.goto(base_url)
548
+ # Wait for poll to update the UI (experimental panel hidden until first poll)
549
+ page.wait_for_timeout(3000)
550
+
551
+ # After poll, the experimental panel should be visible and checkbox attached
552
+ checkbox = page.locator("#feature-motion-models")
553
+ expect(checkbox).to_be_attached()
554
+
555
+ def test_dataset_root_displayed(self, page: Page, base_url: str):
556
+ page.goto(base_url)
557
+ page.wait_for_timeout(2000)
558
+
559
+ summary = page.locator("#config-details > summary")
560
+ summary.scroll_into_view_if_needed()
561
+ summary.click()
562
+ root_input = page.locator("#dataset-root-input")
563
+ value = root_input.input_value()
564
+ assert len(value) > 0
565
+
566
+
567
+ # ──────── Form edge case tests ───────────────────────────────────────
568
+
569
+
570
+ class TestFormEdgeCases:
571
+ def test_empty_label_accepted(self, page: Page, base_url: str, test_marionette):
572
+ page.goto(base_url)
573
+ page.wait_for_timeout(2000)
574
+
575
+ page.locator("#record-options summary").click()
576
+ page.locator("#audio-source-none").check()
577
+ page.locator("#label").fill("")
578
+ page.locator("#duration").fill("2")
579
+ page.locator("#record-btn").click()
580
+ page.wait_for_timeout(2000)
581
+
582
+ pill = page.locator("#mode-pill").text_content() or ""
583
+ assert "queued" in pill.lower() or "countdown" in pill.lower()
584
+
585
+ test_marionette._set_idle_state()
586
+ test_marionette._pending_recording = None
587
+
588
+ def test_long_label_accepted(self, page: Page, base_url: str, test_marionette):
589
+ page.goto(base_url)
590
+ page.wait_for_timeout(2000)
591
+
592
+ page.locator("#record-options summary").click()
593
+ page.locator("#audio-source-none").check()
594
+ long_label = "a" * 80
595
+ page.locator("#label").fill(long_label)
596
+ page.locator("#duration").fill("2")
597
+ page.locator("#record-btn").click()
598
+ page.wait_for_timeout(2000)
599
+
600
+ pill = page.locator("#mode-pill").text_content() or ""
601
+ assert "queued" in pill.lower() or "countdown" in pill.lower()
602
+
603
+ test_marionette._set_idle_state()
604
+ test_marionette._pending_recording = None
605
+
606
+ def test_special_chars_in_label(self, page: Page, base_url: str, test_marionette):
607
+ page.goto(base_url)
608
+ page.wait_for_timeout(2000)
609
+
610
+ page.locator("#record-options summary").click()
611
+ page.locator("#audio-source-none").check()
612
+ page.locator("#label").fill("my move @#$!")
613
+ page.locator("#duration").fill("2")
614
+ page.locator("#record-btn").click()
615
+ page.wait_for_timeout(2000)
616
+
617
+ pill = page.locator("#mode-pill").text_content() or ""
618
+ assert "queued" in pill.lower() or "countdown" in pill.lower()
619
+
620
+ test_marionette._set_idle_state()
621
+ test_marionette._pending_recording = None
622
+
623
+ def test_label_persists_in_localstorage(self, page: Page, base_url: str):
624
+ page.goto(base_url)
625
+ page.wait_for_timeout(2000)
626
+
627
+ label_input = page.locator("#label")
628
+ label_input.fill("persistent-label")
629
+ # Trigger the change event so JS stores it
630
+ label_input.dispatch_event("change")
631
+ page.wait_for_timeout(500)
632
+
633
+ # Reload and check
634
+ page.reload()
635
+ page.wait_for_timeout(2000)
636
+
637
+ restored = page.locator("#label").input_value()
638
+ assert restored == "persistent-label"
tests/run_tests.py CHANGED
@@ -48,6 +48,13 @@ TEST_CLASS_DESCRIPTIONS: dict[str, tuple[str, str]] = {
48
  "TestExperiments": ("unit", "Feature toggles and experimental settings"),
49
  "TestHfAutoLogin": ("unit", "HF auto-login detection and caching"),
50
  "TestSensorData": ("unit", "Sensor data dummy endpoint"),
 
 
 
 
 
 
 
51
  # E2E
52
  "TestPageLoad": ("e2e", "Page loads and main sections visible"),
53
  "TestIdleState": ("e2e", "Idle state display and controls"),
@@ -55,6 +62,14 @@ TEST_CLASS_DESCRIPTIONS: dict[str, tuple[str, str]] = {
55
  "TestFormValidation": ("e2e", "Form fields and HTML5 validation"),
56
  "TestRecordingSubmission": ("e2e", "Recording submission changes mode"),
57
  "TestDatasetUI": ("e2e", "Dataset UI elements visible"),
 
 
 
 
 
 
 
 
58
  # Hardware
59
  "TestHardwareStartup": ("hardware", "Robot reaches idle after startup"),
60
  "TestHardwareRecording": ("hardware", "Record and verify motion capture"),
@@ -64,6 +79,10 @@ TEST_CLASS_DESCRIPTIONS: dict[str, tuple[str, str]] = {
64
  "TestMultiDuration": ("hardware", "Recording and playback across 1s/3s/5s/10s durations"),
65
  "TestPerformance": ("hardware", "Startup, recording, and playback latency benchmarks"),
66
  "TestHardwareAudio": ("hardware", "Audio recording and playback (may skip on mic issues)"),
 
 
 
 
67
  }
68
 
69
 
 
48
  "TestExperiments": ("unit", "Feature toggles and experimental settings"),
49
  "TestHfAutoLogin": ("unit", "HF auto-login detection and caching"),
50
  "TestSensorData": ("unit", "Sensor data dummy endpoint"),
51
+ "TestUploadAudio": ("unit", "POST /api/upload-audio validation and integration"),
52
+ "TestMotionModelEndpoint": ("unit", "POST /api/motion-model enable, set, persist"),
53
+ "TestCommunityDatasets": ("unit", "Community dataset listing and download validation"),
54
+ "TestCorruptData": ("unit", "Malformed JSON and corrupt registry recovery"),
55
+ "TestConcurrentStateChanges": ("unit", "Concurrent operations rejected when busy"),
56
+ "TestDurationEdgeCases": ("unit", "Duration boundary validation (gt=0.5, le=300)"),
57
+ "TestSyncDatasetExtended": ("unit", "Sync endpoint edge cases and validation"),
58
  # E2E
59
  "TestPageLoad": ("e2e", "Page loads and main sections visible"),
60
  "TestIdleState": ("e2e", "Idle state display and controls"),
 
62
  "TestFormValidation": ("e2e", "Form fields and HTML5 validation"),
63
  "TestRecordingSubmission": ("e2e", "Recording submission changes mode"),
64
  "TestDatasetUI": ("e2e", "Dataset UI elements visible"),
65
+ "TestRecordingLifecycle": ("e2e", "Recording submit, stop, and injected move visibility"),
66
+ "TestPlaybackLifecycle": ("e2e", "Play button, queued playback, and move metadata"),
67
+ "TestDeleteMove": ("e2e", "Delete button, confirm/cancel dialog handling"),
68
+ "TestCreateDataset": ("e2e", "New dataset form show/hide/create/validate"),
69
+ "TestSwitchDataset": ("e2e", "Dataset switching and dropdown population"),
70
+ "TestAudioSourceRadios": ("e2e", "Audio source radio buttons and upload area toggle"),
71
+ "TestSettingsPanel": ("e2e", "Settings expand, experimental toggle, root display"),
72
+ "TestFormEdgeCases": ("e2e", "Empty/long/special labels and localStorage persistence"),
73
  # Hardware
74
  "TestHardwareStartup": ("hardware", "Robot reaches idle after startup"),
75
  "TestHardwareRecording": ("hardware", "Record and verify motion capture"),
 
79
  "TestMultiDuration": ("hardware", "Recording and playback across 1s/3s/5s/10s durations"),
80
  "TestPerformance": ("hardware", "Startup, recording, and playback latency benchmarks"),
81
  "TestHardwareAudio": ("hardware", "Audio recording and playback (may skip on mic issues)"),
82
+ "TestExistingRecordingPlayback": ("hardware", "Play back existing audio and silent recordings"),
83
+ "TestRecordingRoundTrip": ("hardware", "Record → playback fidelity and timing verification"),
84
+ "TestAntennaAndBodyYaw": ("hardware", "Verify antenna and body_yaw data in recordings"),
85
+ "TestPlaybackAntennas": ("hardware", "Synthetic antenna oscillation playback"),
86
  }
87
 
88
 
tests/test_api.py CHANGED
@@ -615,3 +615,305 @@ class TestSensorData:
615
  resp = client.get("/sensor_data")
616
  assert resp.status_code == 200
617
  assert resp.json() == {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
615
  resp = client.get("/sensor_data")
616
  assert resp.status_code == 200
617
  assert resp.json() == {}
618
+
619
+
620
+ # ──────── Upload audio tests ─────────────────────────────────────────
621
+
622
+
623
+ class TestUploadAudio:
624
+ def test_upload_wav_returns_upload_id(self, client: TestClient):
625
+ from conftest import make_wav_bytes
626
+
627
+ wav = make_wav_bytes(1.0)
628
+ resp = client.post(
629
+ "/api/upload-audio",
630
+ files={"file": ("test.wav", BytesIO(wav), "audio/wav")},
631
+ )
632
+ assert resp.status_code == 200
633
+ data = resp.json()
634
+ assert "upload_id" in data
635
+ assert data["filename"] == "test.wav"
636
+
637
+ def test_upload_wav_duration_extracted(self, client: TestClient):
638
+ from conftest import make_wav_bytes
639
+
640
+ wav = make_wav_bytes(2.0)
641
+ resp = client.post(
642
+ "/api/upload-audio",
643
+ files={"file": ("two-sec.wav", BytesIO(wav), "audio/wav")},
644
+ )
645
+ assert resp.status_code == 200
646
+ duration = resp.json().get("duration")
647
+ # Duration may be None if soundfile is unavailable, skip check in that case
648
+ if duration is not None:
649
+ assert abs(duration - 2.0) < 0.5
650
+
651
+ def test_upload_rejects_unsupported_format(self, client: TestClient):
652
+ resp = client.post(
653
+ "/api/upload-audio",
654
+ files={"file": ("notes.txt", BytesIO(b"hello"), "text/plain")},
655
+ )
656
+ assert resp.status_code == 400
657
+
658
+ def test_upload_rejects_empty_filename(self, client: TestClient):
659
+ resp = client.post(
660
+ "/api/upload-audio",
661
+ files={"file": ("", BytesIO(b"data"), "audio/wav")},
662
+ )
663
+ assert resp.status_code in (400, 422)
664
+
665
+ def test_upload_mp3_accepted(self, client: TestClient):
666
+ # A minimal fake MP3 — server accepts based on extension
667
+ resp = client.post(
668
+ "/api/upload-audio",
669
+ files={"file": ("song.mp3", BytesIO(b"\xff\xfb\x90\x00" + b"\x00" * 100), "audio/mpeg")},
670
+ )
671
+ # Accepted (200) or 500 if soundfile can't parse — never 400 for extension
672
+ assert resp.status_code in (200, 500)
673
+
674
+ def test_uploaded_audio_id_usable_in_record(self, client: TestClient, marionette: Marionette):
675
+ from conftest import make_wav_bytes
676
+
677
+ wav = make_wav_bytes(1.0)
678
+ upload_resp = client.post(
679
+ "/api/upload-audio",
680
+ files={"file": ("rec.wav", BytesIO(wav), "audio/wav")},
681
+ )
682
+ assert upload_resp.status_code == 200
683
+ upload_id = upload_resp.json()["upload_id"]
684
+
685
+ resp = client.post("/api/record", json={
686
+ "duration": 3.0,
687
+ "record_audio": False,
688
+ "uploaded_audio_id": upload_id,
689
+ })
690
+ assert resp.status_code == 200
691
+ assert resp.json()["accepted"] is True
692
+
693
+ def test_record_with_invalid_upload_id(self, client: TestClient):
694
+ resp = client.post("/api/record", json={
695
+ "duration": 3.0,
696
+ "record_audio": False,
697
+ "uploaded_audio_id": "nonexistent-uuid",
698
+ })
699
+ assert resp.status_code == 400
700
+
701
+
702
+ # ──────── Motion model endpoint tests ────────────────────────────────
703
+
704
+
705
+ class TestMotionModelEndpoint:
706
+ def test_motion_model_rejected_when_disabled(self, client: TestClient):
707
+ resp = client.post("/api/motion-model", json={"name": "no_model"})
708
+ assert resp.status_code == 400
709
+
710
+ def test_enable_then_set_model(self, client: TestClient):
711
+ client.post("/api/experiments", json={"motion_models": True})
712
+ resp = client.post("/api/motion-model", json={"name": "no_model"})
713
+ assert resp.status_code == 200
714
+ assert resp.json()["active"] == "no_model"
715
+
716
+ def test_set_unknown_model_returns_404(self, client: TestClient):
717
+ client.post("/api/experiments", json={"motion_models": True})
718
+ resp = client.post("/api/motion-model", json={"name": "totally_fake_model"})
719
+ assert resp.status_code == 404
720
+
721
+ def test_model_persisted_in_registry(
722
+ self, tmp_registry: Path, tmp_dataset_root: Path
723
+ ):
724
+ app1, m1 = create_app(registry_path=tmp_registry, dataset_root=tmp_dataset_root)
725
+ c1 = TestClient(app1)
726
+ c1.post("/api/experiments", json={"motion_models": True})
727
+ c1.post("/api/motion-model", json={"name": "no_model"})
728
+
729
+ _, m2 = create_app(registry_path=tmp_registry, dataset_root=tmp_dataset_root)
730
+ assert m2._motion_model_registry.active == "no_model"
731
+
732
+
733
+ # ──────── Community datasets tests ───────────────────────────────────
734
+
735
+
736
+ class TestCommunityDatasets:
737
+ def test_community_returns_empty_list(self, client: TestClient, marionette: Marionette):
738
+ """When HTTP fetch returns empty, endpoint returns an empty list."""
739
+ original = marionette._fetch_community_datasets_http
740
+ marionette._fetch_community_datasets_http = lambda: []
741
+ try:
742
+ resp = client.get("/api/datasets/community")
743
+ assert resp.status_code == 200
744
+ # May be empty list (HfApi also not available in test env)
745
+ assert isinstance(resp.json()["datasets"], list)
746
+ finally:
747
+ marionette._fetch_community_datasets_http = original
748
+
749
+ def test_download_rejects_invalid_repo_id(self, client: TestClient):
750
+ resp = client.post("/api/datasets/download", json={"repo_id": "no-slash"})
751
+ assert resp.status_code == 400
752
+
753
+ def test_download_rejects_duplicate_folder(self, client: TestClient, marionette: Marionette):
754
+ # Create a dataset first
755
+ client.post("/api/datasets", json={"name": "existing-ds"})
756
+ # Try to download with the same folder name
757
+ resp = client.post("/api/datasets/download", json={
758
+ "repo_id": "someone/existing-ds",
759
+ "name": "existing-ds",
760
+ })
761
+ assert resp.status_code == 409
762
+
763
+
764
+ # ──────── Corrupt data tests ────────────────────────────────────────
765
+
766
+
767
+ class TestCorruptData:
768
+ def test_malformed_json_skipped(self, marionette: Marionette):
769
+ data_dir = marionette._dataset_dir
770
+ (data_dir / "bad-file.json").write_text("{{{", encoding="utf-8")
771
+ marionette._refresh_recordings()
772
+ assert "bad-file" not in marionette._recordings
773
+
774
+ def test_json_missing_time_key(self, marionette: Marionette):
775
+ data_dir = marionette._dataset_dir
776
+ (data_dir / "no-time.json").write_text(
777
+ json.dumps({"description": "test"}), encoding="utf-8"
778
+ )
779
+ marionette._refresh_recordings()
780
+ # File is loaded but with duration 0 (empty timestamps)
781
+ if "no-time" in marionette._recordings:
782
+ assert marionette._recordings["no-time"].duration == 0.0
783
+
784
+ def test_json_empty_time_array(self, marionette: Marionette):
785
+ data_dir = marionette._dataset_dir
786
+ (data_dir / "empty-time.json").write_text(
787
+ json.dumps({"time": [], "set_target_data": []}), encoding="utf-8"
788
+ )
789
+ marionette._refresh_recordings()
790
+ if "empty-time" in marionette._recordings:
791
+ assert marionette._recordings["empty-time"].duration == 0.0
792
+
793
+ def test_corrupt_registry_recovers(self, tmp_path: Path):
794
+ reg = tmp_path / "corrupt_reg.json"
795
+ reg.write_text("NOT VALID JSON {{{", encoding="utf-8")
796
+ ds_root = tmp_path / "ds"
797
+ ds_root.mkdir()
798
+ app, m = create_app(registry_path=reg, dataset_root=ds_root)
799
+ # Should have recovered with defaults
800
+ assert m._active_dataset_id is not None
801
+ assert len(m._datasets) >= 1
802
+
803
+
804
+ # ──────── Concurrent state change tests ─────────────────────────────
805
+
806
+
807
+ class TestConcurrentStateChanges:
808
+ def test_play_while_queued_rejected(
809
+ self, client: TestClient, marionette: Marionette, sample_move_json: dict
810
+ ):
811
+ data_dir = marionette._dataset_dir
812
+ (data_dir / "play-test.json").write_text(json.dumps(sample_move_json))
813
+ marionette._refresh_recordings()
814
+
815
+ # Submit a recording to enter queued state
816
+ client.post("/api/record", json={"duration": 3.0, "record_audio": False})
817
+ assert client.get("/api/state").json()["mode"] == "queued"
818
+
819
+ resp = client.post("/api/play", json={"move_id": "play-test"})
820
+ assert resp.status_code == 409
821
+
822
+ def test_record_while_playing_rejected(
823
+ self, client: TestClient, marionette: Marionette
824
+ ):
825
+ marionette._set_state(mode="playing", message="Playing…", active_move="x")
826
+ resp = client.post("/api/record", json={"duration": 3.0, "record_audio": False})
827
+ assert resp.status_code == 409
828
+ marionette._set_idle_state()
829
+
830
+ def test_sync_while_busy_rejected(
831
+ self, client: TestClient, marionette: Marionette
832
+ ):
833
+ marionette._set_state(mode="recording", message="Recording…", active_move=None)
834
+ resp = client.post("/api/datasets/sync", json={"move_ids": ["x"]})
835
+ assert resp.status_code == 409
836
+ marionette._set_idle_state()
837
+
838
+ def test_dataset_root_change_while_busy(
839
+ self, client: TestClient, marionette: Marionette, tmp_path: Path
840
+ ):
841
+ marionette._set_state(mode="recording", message="Recording…", active_move=None)
842
+ resp = client.post("/api/datasets/root", json={"path": str(tmp_path)})
843
+ assert resp.status_code == 409
844
+ marionette._set_idle_state()
845
+
846
+
847
+ # ──────── Duration edge case tests ──────────────────────────────────
848
+
849
+
850
+ class TestDurationEdgeCases:
851
+ def test_duration_just_above_minimum(self, client: TestClient):
852
+ resp = client.post("/api/record", json={"duration": 0.51, "record_audio": False})
853
+ assert resp.status_code == 200
854
+
855
+ def test_duration_at_maximum(self, client: TestClient, marionette: Marionette):
856
+ resp = client.post("/api/record", json={"duration": 300.0, "record_audio": False})
857
+ assert resp.status_code == 200
858
+
859
+ def test_duration_at_minimum_boundary_rejected(self, client: TestClient):
860
+ """Pydantic field has gt=0.5, so exactly 0.5 should be rejected."""
861
+ resp = client.post("/api/record", json={"duration": 0.5, "record_audio": False})
862
+ assert resp.status_code == 422
863
+
864
+
865
+ # ──────── Sync dataset extended tests ───────────────────────────────
866
+
867
+
868
+ class TestSyncDatasetExtended:
869
+ def test_sync_empty_move_ids_rejected(self, client: TestClient):
870
+ """Pydantic min_items=1 should reject empty move_ids."""
871
+ resp = client.post("/api/datasets/sync", json={"move_ids": []})
872
+ assert resp.status_code == 422
873
+
874
+ def test_sync_nonexistent_moves(self, client: TestClient, marionette: Marionette):
875
+ import marionette.main as mm
876
+ original_whoami = mm.hf_whoami
877
+ marionette._hf_checked = False
878
+ marionette._hf_username = None
879
+ mm.hf_whoami = lambda: {"name": "testuser"}
880
+ try:
881
+ resp = client.post("/api/datasets/sync", json={
882
+ "move_ids": ["fake-move-id"],
883
+ })
884
+ assert resp.status_code == 404
885
+ finally:
886
+ mm.hf_whoami = original_whoami
887
+
888
+ def test_sync_no_active_dataset(self, client: TestClient, marionette: Marionette):
889
+ import marionette.main as mm
890
+ original_whoami = mm.hf_whoami
891
+ mm.hf_whoami = lambda: {"name": "testuser"}
892
+ marionette._hf_checked = False
893
+ marionette._hf_username = None
894
+
895
+ # Save and clear active dataset
896
+ old_id = marionette._active_dataset_id
897
+ marionette._active_dataset_id = None
898
+ # Clear recordings to avoid "move not found" before "no active dataset"
899
+ marionette._recordings = {}
900
+ try:
901
+ resp = client.post("/api/datasets/sync", json={
902
+ "move_ids": ["any-move"],
903
+ })
904
+ # Should be 404 for the move not found (since recordings is empty)
905
+ assert resp.status_code == 404
906
+ finally:
907
+ marionette._active_dataset_id = old_id
908
+ mm.hf_whoami = original_whoami
909
+
910
+ def test_record_on_downloaded_dataset_rejected(
911
+ self, client: TestClient, marionette: Marionette
912
+ ):
913
+ entry = marionette._create_dataset_internal("dl-sync-test", "DL Sync Test", origin="downloaded")
914
+ marionette._select_dataset(entry.dataset_id)
915
+ marionette._refresh_recordings()
916
+
917
+ resp = client.post("/api/record", json={"duration": 3.0, "record_audio": False})
918
+ assert resp.status_code == 409
919
+ assert "downloaded" in resp.json()["detail"].lower()
tests/test_hardware.py CHANGED
@@ -967,6 +967,250 @@ class TestPerformance:
967
  assert rate > 50, f"Pose read rate too low: {rate:.0f}/s (need >50 for 100Hz recording)"
968
 
969
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
970
  class TestHardwareAudio:
971
  """Audio recording/playback tests — run last.
972
 
 
967
  assert rate > 50, f"Pose read rate too low: {rate:.0f}/s (need >50 for 100Hz recording)"
968
 
969
 
970
+ class TestExistingRecordingPlayback:
971
+ """Play back existing recordings from the dataset."""
972
+
973
+ def test_playback_real_audio_recording(self, base_url: str):
974
+ """Find a move with audio and play it back."""
975
+ import httpx
976
+
977
+ _ensure_idle(base_url)
978
+ state = httpx.get(f"{base_url}/api/state", timeout=5).json()
979
+ move = next((m for m in state["moves"] if m["has_audio"]), None)
980
+ if move is None:
981
+ pytest.skip("No audio moves available for playback test")
982
+
983
+ resp = httpx.post(
984
+ f"{base_url}/api/play",
985
+ json={"move_id": move["id"]},
986
+ timeout=5,
987
+ )
988
+ assert resp.status_code == 200
989
+ _wait_for_mode(base_url, "idle", timeout=move["duration"] + 15)
990
+
991
+ def test_playback_real_silent_recording(self, base_url: str):
992
+ """Find a silent move and play it back."""
993
+ import httpx
994
+
995
+ _ensure_idle(base_url)
996
+ state = httpx.get(f"{base_url}/api/state", timeout=5).json()
997
+ move = next((m for m in state["moves"] if not m["has_audio"]), None)
998
+ if move is None:
999
+ pytest.skip("No silent moves available for playback test")
1000
+
1001
+ resp = httpx.post(
1002
+ f"{base_url}/api/play",
1003
+ json={"move_id": move["id"]},
1004
+ timeout=5,
1005
+ )
1006
+ assert resp.status_code == 200
1007
+ _wait_for_mode(base_url, "idle", timeout=move["duration"] + 15)
1008
+
1009
+
1010
+ class TestRecordingRoundTrip:
1011
+ """Record, read JSON, play back, and verify timing and fidelity."""
1012
+
1013
+ def test_record_then_playback_fidelity(
1014
+ self, base_url: str, hw_marionette, hw_reachy,
1015
+ ):
1016
+ """Record 3s, play back, observe poses, compare trajectories."""
1017
+ import httpx
1018
+ from pose_utils import compare_trajectories
1019
+
1020
+ duration = 3.0
1021
+ _ensure_idle(base_url)
1022
+
1023
+ resp = httpx.post(
1024
+ f"{base_url}/api/record",
1025
+ json={"duration": duration, "record_audio": False, "label": "roundtrip-fidelity"},
1026
+ timeout=5,
1027
+ )
1028
+ assert resp.status_code == 200
1029
+ move_id = resp.json()["move_id"]
1030
+ _wait_for_mode(base_url, "idle", timeout=COUNTDOWN_SECONDS + duration + 10)
1031
+
1032
+ # Read the recorded JSON
1033
+ json_path = hw_marionette._dataset_dir / f"{move_id}.json"
1034
+ assert json_path.exists()
1035
+ data = json.loads(json_path.read_text())
1036
+ ref_times = data["time"]
1037
+ ref_frames = data["set_target_data"]
1038
+
1039
+ # Play back and observe
1040
+ _ensure_idle(base_url)
1041
+ resp = httpx.post(
1042
+ f"{base_url}/api/play",
1043
+ json={"move_id": move_id},
1044
+ timeout=5,
1045
+ )
1046
+ assert resp.status_code == 200
1047
+
1048
+ observed_times, observed_frames = _observe_playback(
1049
+ base_url, hw_reachy, duration,
1050
+ )
1051
+
1052
+ assert len(observed_frames) > 50, (
1053
+ f"Too few observed frames: {len(observed_frames)}"
1054
+ )
1055
+
1056
+ metrics = compare_trajectories(
1057
+ ref_times, ref_frames, observed_times, observed_frames,
1058
+ )
1059
+ print(f"\nRound-trip fidelity ({len(observed_frames)} frames):")
1060
+ print(metrics.summary())
1061
+
1062
+ assert metrics.magic_mean < 50, (
1063
+ f"Mean magic distance too high: {metrics.magic_mean:.1f}\n"
1064
+ f"{metrics.summary()}"
1065
+ )
1066
+
1067
+ # Cleanup
1068
+ httpx.delete(f"{base_url}/api/moves/{move_id}", timeout=5)
1069
+
1070
+ def test_record_playback_timing(self, base_url: str, hw_marionette):
1071
+ """Record 3s, play back, verify wall-clock duration within 20%."""
1072
+ import httpx
1073
+
1074
+ duration = 3.0
1075
+ _ensure_idle(base_url)
1076
+
1077
+ resp = httpx.post(
1078
+ f"{base_url}/api/record",
1079
+ json={"duration": duration, "record_audio": False, "label": "roundtrip-timing"},
1080
+ timeout=5,
1081
+ )
1082
+ assert resp.status_code == 200
1083
+ move_id = resp.json()["move_id"]
1084
+ _wait_for_mode(base_url, "idle", timeout=COUNTDOWN_SECONDS + duration + 10)
1085
+
1086
+ # Play back and measure
1087
+ _ensure_idle(base_url)
1088
+ t0 = time.time()
1089
+ resp = httpx.post(
1090
+ f"{base_url}/api/play",
1091
+ json={"move_id": move_id},
1092
+ timeout=5,
1093
+ )
1094
+ assert resp.status_code == 200
1095
+ _wait_for_mode(base_url, "idle", timeout=duration + 30)
1096
+ playback_time = time.time() - t0
1097
+
1098
+ # Should be within 20% of the recording duration (plus overhead)
1099
+ assert playback_time > duration * 0.8, (
1100
+ f"Playback too fast: {playback_time:.2f}s (expected ~{duration}s)"
1101
+ )
1102
+ assert playback_time < duration * 1.2 + 10, (
1103
+ f"Playback too slow: {playback_time:.2f}s (expected ~{duration}s + overhead)"
1104
+ )
1105
+ print(f"\nRound-trip timing: playback took {playback_time:.2f}s for {duration}s recording")
1106
+
1107
+ # Cleanup
1108
+ httpx.delete(f"{base_url}/api/moves/{move_id}", timeout=5)
1109
+
1110
+
1111
+ class TestAntennaAndBodyYaw:
1112
+ """Verify recording JSON contains antenna and body_yaw data."""
1113
+
1114
+ def test_recording_has_antenna_data(self, base_url: str, hw_marionette):
1115
+ """Every frame should have 'antennas' (2-element list)."""
1116
+ import httpx
1117
+
1118
+ _ensure_idle(base_url)
1119
+ resp = httpx.post(
1120
+ f"{base_url}/api/record",
1121
+ json={"duration": 2.0, "record_audio": False, "label": "antenna-check"},
1122
+ timeout=5,
1123
+ )
1124
+ assert resp.status_code == 200
1125
+ move_id = resp.json()["move_id"]
1126
+ _wait_for_mode(base_url, "idle", timeout=COUNTDOWN_SECONDS + 2 + 10)
1127
+
1128
+ json_path = hw_marionette._dataset_dir / f"{move_id}.json"
1129
+ data = json.loads(json_path.read_text())
1130
+ for frame in data["set_target_data"]:
1131
+ assert "antennas" in frame, "Frame missing 'antennas' key"
1132
+ assert len(frame["antennas"]) == 2, f"Expected 2 antenna values, got {len(frame['antennas'])}"
1133
+
1134
+ httpx.delete(f"{base_url}/api/moves/{move_id}", timeout=5)
1135
+
1136
+ def test_recording_has_body_yaw(self, base_url: str, hw_marionette):
1137
+ """Every frame should have 'body_yaw' (float)."""
1138
+ import httpx
1139
+
1140
+ _ensure_idle(base_url)
1141
+ resp = httpx.post(
1142
+ f"{base_url}/api/record",
1143
+ json={"duration": 2.0, "record_audio": False, "label": "bodyyaw-check"},
1144
+ timeout=5,
1145
+ )
1146
+ assert resp.status_code == 200
1147
+ move_id = resp.json()["move_id"]
1148
+ _wait_for_mode(base_url, "idle", timeout=COUNTDOWN_SECONDS + 2 + 10)
1149
+
1150
+ json_path = hw_marionette._dataset_dir / f"{move_id}.json"
1151
+ data = json.loads(json_path.read_text())
1152
+ for frame in data["set_target_data"]:
1153
+ assert "body_yaw" in frame, "Frame missing 'body_yaw' key"
1154
+ assert isinstance(frame["body_yaw"], (int, float)), f"body_yaw should be numeric, got {type(frame['body_yaw'])}"
1155
+
1156
+ httpx.delete(f"{base_url}/api/moves/{move_id}", timeout=5)
1157
+
1158
+
1159
+ class TestPlaybackAntennas:
1160
+ """Test synthetic moves with antenna data play back correctly."""
1161
+
1162
+ def test_synthetic_antenna_motion_completes(
1163
+ self, base_url: str, hw_marionette, hw_reachy,
1164
+ ):
1165
+ """Create synthetic move with antenna oscillation, play back."""
1166
+ import httpx
1167
+ import numpy as np
1168
+
1169
+ duration = 3.0
1170
+ _ensure_idle(base_url)
1171
+
1172
+ # Create synthetic move with antenna oscillation
1173
+ dt = 1.0 / MOTION_SAMPLE_RATE
1174
+ n = int(duration * MOTION_SAMPLE_RATE)
1175
+ timestamps = [i * dt for i in range(n)]
1176
+ frames = []
1177
+ for i in range(n):
1178
+ t = i * dt
1179
+ antenna_val = 0.3 * np.sin(2 * np.pi * 0.5 * t)
1180
+ frames.append({
1181
+ "head": np.eye(4).tolist(),
1182
+ "antennas": [antenna_val, -antenna_val],
1183
+ "body_yaw": 0.0,
1184
+ "check_collision": False,
1185
+ })
1186
+
1187
+ move_id = "synth-antenna-osc"
1188
+ data = {
1189
+ "description": "Synthetic antenna oscillation",
1190
+ "time": timestamps,
1191
+ "set_target_data": frames,
1192
+ }
1193
+ json_path = hw_marionette._dataset_dir / f"{move_id}.json"
1194
+ json_path.write_text(json.dumps(data), encoding="utf-8")
1195
+ hw_marionette._refresh_recordings()
1196
+
1197
+ # Play back
1198
+ resp = httpx.post(
1199
+ f"{base_url}/api/play",
1200
+ json={"move_id": move_id},
1201
+ timeout=5,
1202
+ )
1203
+ assert resp.status_code == 200
1204
+
1205
+ _wait_for_mode(base_url, "idle", timeout=duration + 15)
1206
+
1207
+ state = httpx.get(f"{base_url}/api/state", timeout=5).json()
1208
+ assert state["mode"] == "idle"
1209
+
1210
+ # Cleanup
1211
+ httpx.delete(f"{base_url}/api/moves/{move_id}", timeout=5)
1212
+
1213
+
1214
  class TestHardwareAudio:
1215
  """Audio recording/playback tests — run last.
1216