Spaces:
Running
Running
RemiFabre commited on
Commit Β·
25ea24a
1
Parent(s): c1ac039
Add AGC, API contract, and E2E tests for refactoring protection
Browse filesUnit tests (+18):
- TestMicAgcConfig (9): disable/restore AGC with mock USB device,
graceful handling of missing device, import errors, USB failures
- TestApiContracts (9): verify exact response shapes for /api/state,
config, moves, datasets, record, create-dataset, experiments β
protects against accidental field renames during frontend refactoring
E2E tests (+7):
- TestFormEdgeCases: duration persists in localStorage
- TestCommunitySection (3): section exists, expands, fetch button works
- TestHfUploadSection (3): username field and sync button presence
Total: 113 unit + 51 E2E + 37 hardware = 201 tests
- TESTING.md +5 -1
- tests/e2e/test_ui.py +70 -0
- tests/run_tests.py +4 -0
- tests/test_api.py +190 -0
TESTING.md
CHANGED
|
@@ -161,6 +161,8 @@ pytest tests/ --browser chromium
|
|
| 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 |
|
|
@@ -179,7 +181,9 @@ pytest tests/ --browser chromium
|
|
| 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 |
|
|
|
|
|
|
|
| 183 |
|
| 184 |
### Hardware tests (`tests/test_hardware.py`)
|
| 185 |
|
|
|
|
| 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 |
+
| TestMicAgcConfig | 9 | Mic AGC disable/restore with mock USB device |
|
| 165 |
+
| TestApiContracts | 9 | API response shapes (refactoring protection) |
|
| 166 |
|
| 167 |
### E2E browser tests (`tests/e2e/test_ui.py`)
|
| 168 |
|
|
|
|
| 181 |
| TestSwitchDataset | 2 | Dataset switching and dropdown population |
|
| 182 |
| TestAudioSourceRadios | 3 | Audio source radio buttons and upload area toggle |
|
| 183 |
| TestSettingsPanel | 3 | Settings expand, experimental toggle, root display |
|
| 184 |
+
| TestFormEdgeCases | 5 | Empty/long/special labels and localStorage persistence |
|
| 185 |
+
| TestCommunitySection | 3 | Community datasets section expand and fetch button |
|
| 186 |
+
| TestHfUploadSection | 3 | HF username field and sync button presence |
|
| 187 |
|
| 188 |
### Hardware tests (`tests/test_hardware.py`)
|
| 189 |
|
tests/e2e/test_ui.py
CHANGED
|
@@ -636,3 +636,73 @@ class TestFormEdgeCases:
|
|
| 636 |
|
| 637 |
restored = page.locator("#label").input_value()
|
| 638 |
assert restored == "persistent-label"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 636 |
|
| 637 |
restored = page.locator("#label").input_value()
|
| 638 |
assert restored == "persistent-label"
|
| 639 |
+
|
| 640 |
+
def test_duration_persists_across_reload(self, page: Page, base_url: str):
|
| 641 |
+
"""Duration is saved to the server (preferred_duration) and restored on reload."""
|
| 642 |
+
page.goto(base_url)
|
| 643 |
+
page.wait_for_timeout(2000)
|
| 644 |
+
|
| 645 |
+
dur_input = page.locator("#duration")
|
| 646 |
+
dur_input.fill("7.5")
|
| 647 |
+
dur_input.dispatch_event("change")
|
| 648 |
+
# Wait for debounced POST /api/experiments to complete
|
| 649 |
+
page.wait_for_timeout(2000)
|
| 650 |
+
|
| 651 |
+
page.reload()
|
| 652 |
+
page.wait_for_timeout(2000)
|
| 653 |
+
|
| 654 |
+
restored = page.locator("#duration").input_value()
|
| 655 |
+
assert restored == "7.5"
|
| 656 |
+
|
| 657 |
+
|
| 658 |
+
# ββββββββ Community section tests βββββββββββββββββββββββββββββββββ
|
| 659 |
+
|
| 660 |
+
|
| 661 |
+
class TestCommunitySection:
|
| 662 |
+
def test_community_section_exists(self, page: Page, base_url: str):
|
| 663 |
+
page.goto(base_url)
|
| 664 |
+
page.wait_for_timeout(2000)
|
| 665 |
+
section = page.locator("#community-details")
|
| 666 |
+
expect(section).to_be_attached()
|
| 667 |
+
|
| 668 |
+
def test_community_section_expands(self, page: Page, base_url: str):
|
| 669 |
+
page.goto(base_url)
|
| 670 |
+
page.wait_for_timeout(2000)
|
| 671 |
+
summary = page.locator("#community-details > summary")
|
| 672 |
+
summary.scroll_into_view_if_needed()
|
| 673 |
+
summary.click()
|
| 674 |
+
page.wait_for_timeout(500)
|
| 675 |
+
fetch_btn = page.locator("#fetch-community-btn")
|
| 676 |
+
expect(fetch_btn).to_be_visible()
|
| 677 |
+
|
| 678 |
+
def test_fetch_community_button_clickable(self, page: Page, base_url: str):
|
| 679 |
+
page.goto(base_url)
|
| 680 |
+
page.wait_for_timeout(2000)
|
| 681 |
+
summary = page.locator("#community-details > summary")
|
| 682 |
+
summary.scroll_into_view_if_needed()
|
| 683 |
+
summary.click()
|
| 684 |
+
fetch_btn = page.locator("#fetch-community-btn")
|
| 685 |
+
expect(fetch_btn).to_be_enabled()
|
| 686 |
+
|
| 687 |
+
|
| 688 |
+
# ββββββββ HF upload section tests βββββββββββββββββββββββββββββββββ
|
| 689 |
+
|
| 690 |
+
|
| 691 |
+
class TestHfUploadSection:
|
| 692 |
+
def test_hf_username_field_exists(self, page: Page, base_url: str):
|
| 693 |
+
page.goto(base_url)
|
| 694 |
+
page.wait_for_timeout(2000)
|
| 695 |
+
username = page.locator("#hf-username")
|
| 696 |
+
expect(username).to_be_attached()
|
| 697 |
+
|
| 698 |
+
def test_sync_button_exists(self, page: Page, base_url: str):
|
| 699 |
+
page.goto(base_url)
|
| 700 |
+
page.wait_for_timeout(2000)
|
| 701 |
+
sync_btn = page.locator("#sync-btn")
|
| 702 |
+
expect(sync_btn).to_be_attached()
|
| 703 |
+
|
| 704 |
+
def test_sync_button_disabled_without_selection(self, page: Page, base_url: str):
|
| 705 |
+
page.goto(base_url)
|
| 706 |
+
page.wait_for_timeout(2000)
|
| 707 |
+
sync_btn = page.locator("#sync-btn")
|
| 708 |
+
expect(sync_btn).to_be_disabled()
|
tests/run_tests.py
CHANGED
|
@@ -55,6 +55,8 @@ TEST_CLASS_DESCRIPTIONS: dict[str, tuple[str, str]] = {
|
|
| 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"),
|
|
@@ -70,6 +72,8 @@ TEST_CLASS_DESCRIPTIONS: dict[str, tuple[str, str]] = {
|
|
| 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"),
|
|
|
|
| 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 |
+
"TestMicAgcConfig": ("unit", "Mic AGC disable/restore with mock USB device"),
|
| 59 |
+
"TestApiContracts": ("unit", "API response shapes (refactoring protection)"),
|
| 60 |
# E2E
|
| 61 |
"TestPageLoad": ("e2e", "Page loads and main sections visible"),
|
| 62 |
"TestIdleState": ("e2e", "Idle state display and controls"),
|
|
|
|
| 72 |
"TestAudioSourceRadios": ("e2e", "Audio source radio buttons and upload area toggle"),
|
| 73 |
"TestSettingsPanel": ("e2e", "Settings expand, experimental toggle, root display"),
|
| 74 |
"TestFormEdgeCases": ("e2e", "Empty/long/special labels and localStorage persistence"),
|
| 75 |
+
"TestCommunitySection": ("e2e", "Community datasets section expand and fetch button"),
|
| 76 |
+
"TestHfUploadSection": ("e2e", "HF username field and sync button presence"),
|
| 77 |
# Hardware
|
| 78 |
"TestHardwareStartup": ("hardware", "Robot reaches idle after startup"),
|
| 79 |
"TestHardwareRecording": ("hardware", "Record and verify motion capture"),
|
tests/test_api.py
CHANGED
|
@@ -7,6 +7,7 @@ Tests the HTTP API layer, state machine, data validation, and persistence.
|
|
| 7 |
import json
|
| 8 |
from io import BytesIO
|
| 9 |
from pathlib import Path
|
|
|
|
| 10 |
|
| 11 |
import pytest
|
| 12 |
from fastapi.testclient import TestClient
|
|
@@ -917,3 +918,192 @@ class TestSyncDatasetExtended:
|
|
| 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()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
import json
|
| 8 |
from io import BytesIO
|
| 9 |
from pathlib import Path
|
| 10 |
+
from unittest.mock import MagicMock, patch
|
| 11 |
|
| 12 |
import pytest
|
| 13 |
from fastapi.testclient import TestClient
|
|
|
|
| 918 |
resp = client.post("/api/record", json={"duration": 3.0, "record_audio": False})
|
| 919 |
assert resp.status_code == 409
|
| 920 |
assert "downloaded" in resp.json()["detail"].lower()
|
| 921 |
+
|
| 922 |
+
|
| 923 |
+
# ββββββββ Mic AGC config tests ββββββββββββββββββββββββββββββββββββ
|
| 924 |
+
|
| 925 |
+
|
| 926 |
+
class TestMicAgcConfig:
|
| 927 |
+
"""Tests for _disable_mic_agc / _restore_mic_agc."""
|
| 928 |
+
|
| 929 |
+
AGC_PATCH = "reachy_mini.media.audio_control_utils.init_respeaker_usb"
|
| 930 |
+
|
| 931 |
+
def _make_mock_respeaker(self, agc_value=None):
|
| 932 |
+
"""Create a mock ReSpeaker device."""
|
| 933 |
+
mock = MagicMock()
|
| 934 |
+
mock.read.return_value = agc_value if agc_value is not None else [1]
|
| 935 |
+
return mock
|
| 936 |
+
|
| 937 |
+
def test_disable_agc_writes_zero(self, marionette: Marionette):
|
| 938 |
+
mock_dev = self._make_mock_respeaker(agc_value=[1])
|
| 939 |
+
with patch(self.AGC_PATCH, return_value=mock_dev):
|
| 940 |
+
marionette._disable_mic_agc()
|
| 941 |
+
mock_dev.read.assert_called_once_with("PP_AGCONOFF")
|
| 942 |
+
mock_dev.write.assert_called_once_with("PP_AGCONOFF", [0])
|
| 943 |
+
mock_dev.close.assert_called_once()
|
| 944 |
+
assert marionette._original_agc == [1]
|
| 945 |
+
|
| 946 |
+
def test_disable_agc_saves_original_value(self, marionette: Marionette):
|
| 947 |
+
mock_dev = self._make_mock_respeaker(agc_value=[0])
|
| 948 |
+
with patch(self.AGC_PATCH, return_value=mock_dev):
|
| 949 |
+
marionette._disable_mic_agc()
|
| 950 |
+
assert marionette._original_agc == [0]
|
| 951 |
+
|
| 952 |
+
def test_disable_agc_no_device(self, marionette: Marionette):
|
| 953 |
+
"""Gracefully skips when no USB device is found."""
|
| 954 |
+
with patch(self.AGC_PATCH, return_value=None):
|
| 955 |
+
marionette._disable_mic_agc() # Should not raise
|
| 956 |
+
assert not hasattr(marionette, "_original_agc") or marionette._original_agc is None
|
| 957 |
+
|
| 958 |
+
def test_disable_agc_import_error(self, marionette: Marionette):
|
| 959 |
+
"""Gracefully skips when audio_control_utils is not importable."""
|
| 960 |
+
with patch.dict("sys.modules", {"reachy_mini.media.audio_control_utils": None}):
|
| 961 |
+
marionette._disable_mic_agc() # Should not raise
|
| 962 |
+
|
| 963 |
+
def test_disable_agc_usb_error(self, marionette: Marionette):
|
| 964 |
+
"""Gracefully handles USB communication errors."""
|
| 965 |
+
mock_dev = self._make_mock_respeaker()
|
| 966 |
+
mock_dev.read.side_effect = OSError("USB transfer failed")
|
| 967 |
+
with patch(self.AGC_PATCH, return_value=mock_dev):
|
| 968 |
+
marionette._disable_mic_agc() # Should not raise
|
| 969 |
+
|
| 970 |
+
def test_restore_agc_writes_original(self, marionette: Marionette):
|
| 971 |
+
marionette._original_agc = [1]
|
| 972 |
+
mock_dev = self._make_mock_respeaker()
|
| 973 |
+
with patch(self.AGC_PATCH, return_value=mock_dev):
|
| 974 |
+
marionette._restore_mic_agc()
|
| 975 |
+
mock_dev.write.assert_called_once_with("PP_AGCONOFF", [1])
|
| 976 |
+
mock_dev.close.assert_called_once()
|
| 977 |
+
|
| 978 |
+
def test_restore_agc_skips_when_no_original(self, marionette: Marionette):
|
| 979 |
+
"""Skips restore when AGC was never disabled (no _original_agc)."""
|
| 980 |
+
mock_dev = self._make_mock_respeaker()
|
| 981 |
+
with patch(self.AGC_PATCH, return_value=mock_dev):
|
| 982 |
+
marionette._restore_mic_agc() # Should not raise
|
| 983 |
+
mock_dev.write.assert_not_called()
|
| 984 |
+
|
| 985 |
+
def test_restore_agc_skips_when_original_is_none(self, marionette: Marionette):
|
| 986 |
+
marionette._original_agc = None
|
| 987 |
+
mock_dev = self._make_mock_respeaker()
|
| 988 |
+
with patch(self.AGC_PATCH, return_value=mock_dev):
|
| 989 |
+
marionette._restore_mic_agc()
|
| 990 |
+
mock_dev.write.assert_not_called()
|
| 991 |
+
|
| 992 |
+
def test_restore_agc_device_gone(self, marionette: Marionette):
|
| 993 |
+
"""Gracefully handles device disconnected at restore time."""
|
| 994 |
+
marionette._original_agc = [1]
|
| 995 |
+
with patch(self.AGC_PATCH, return_value=None):
|
| 996 |
+
marionette._restore_mic_agc() # Should not raise
|
| 997 |
+
|
| 998 |
+
|
| 999 |
+
# ββββββββ API contract tests (refactoring protection) ββββββββββββββ
|
| 1000 |
+
|
| 1001 |
+
|
| 1002 |
+
class TestApiContracts:
|
| 1003 |
+
"""Verify exact response shapes that the frontend depends on.
|
| 1004 |
+
|
| 1005 |
+
These tests protect against accidental field renames or removals
|
| 1006 |
+
during frontend refactoring.
|
| 1007 |
+
"""
|
| 1008 |
+
|
| 1009 |
+
def test_state_top_level_keys(self, client: TestClient):
|
| 1010 |
+
data = client.get("/api/state").json()
|
| 1011 |
+
required = {
|
| 1012 |
+
"server_time", "mode", "message", "active_move",
|
| 1013 |
+
"phase_start_at", "phase_end_at",
|
| 1014 |
+
"countdown_ends_at",
|
| 1015 |
+
"recording_started_at", "recording_duration", "recording_stats",
|
| 1016 |
+
"pending_recording", "pending_playback",
|
| 1017 |
+
"moves", "config", "datasets",
|
| 1018 |
+
}
|
| 1019 |
+
assert required == required.intersection(data.keys()), (
|
| 1020 |
+
f"Missing keys: {required - data.keys()}"
|
| 1021 |
+
)
|
| 1022 |
+
|
| 1023 |
+
def test_config_keys(self, client: TestClient):
|
| 1024 |
+
config = client.get("/api/state").json()["config"]
|
| 1025 |
+
required = {
|
| 1026 |
+
"default_duration", "preferred_duration", "countdown_seconds",
|
| 1027 |
+
"motion_sample_rate", "audio_available",
|
| 1028 |
+
"active_dataset_path", "dataset_root_path",
|
| 1029 |
+
"hf_username", "features", "feature_support",
|
| 1030 |
+
}
|
| 1031 |
+
assert required.issubset(config.keys()), (
|
| 1032 |
+
f"Missing config keys: {required - config.keys()}"
|
| 1033 |
+
)
|
| 1034 |
+
|
| 1035 |
+
def test_datasets_payload_shape(self, client: TestClient):
|
| 1036 |
+
datasets = client.get("/api/state").json()["datasets"]
|
| 1037 |
+
assert "active_id" in datasets
|
| 1038 |
+
assert "root_path" in datasets
|
| 1039 |
+
assert "entries" in datasets
|
| 1040 |
+
assert isinstance(datasets["entries"], list)
|
| 1041 |
+
|
| 1042 |
+
def test_dataset_entry_keys(self, client: TestClient):
|
| 1043 |
+
entries = client.get("/api/state").json()["datasets"]["entries"]
|
| 1044 |
+
assert len(entries) >= 1, "Should have at least one default dataset"
|
| 1045 |
+
entry = entries[0]
|
| 1046 |
+
required = {"id", "label", "path", "folder", "origin"}
|
| 1047 |
+
assert required.issubset(entry.keys()), (
|
| 1048 |
+
f"Missing dataset entry keys: {required - entry.keys()}"
|
| 1049 |
+
)
|
| 1050 |
+
|
| 1051 |
+
def test_move_payload_keys(
|
| 1052 |
+
self, client: TestClient, marionette: Marionette, sample_move_json: dict
|
| 1053 |
+
):
|
| 1054 |
+
data_dir = marionette._dataset_dir
|
| 1055 |
+
(data_dir / "contract-test.json").write_text(json.dumps(sample_move_json))
|
| 1056 |
+
marionette._refresh_recordings()
|
| 1057 |
+
|
| 1058 |
+
moves = client.get("/api/state").json()["moves"]
|
| 1059 |
+
assert len(moves) >= 1
|
| 1060 |
+
move = moves[0]
|
| 1061 |
+
required = {
|
| 1062 |
+
"id", "label", "duration", "created_at",
|
| 1063 |
+
"has_audio", "description", "is_uploaded",
|
| 1064 |
+
}
|
| 1065 |
+
assert required.issubset(move.keys()), (
|
| 1066 |
+
f"Missing move keys: {required - move.keys()}"
|
| 1067 |
+
)
|
| 1068 |
+
|
| 1069 |
+
def test_error_responses_have_detail(self, client: TestClient):
|
| 1070 |
+
"""All 4xx responses should include a 'detail' field."""
|
| 1071 |
+
# 409: record while busy
|
| 1072 |
+
from marionette.main import Marionette as M
|
| 1073 |
+
# 422: invalid payload
|
| 1074 |
+
resp = client.post("/api/record", json={"duration": -1})
|
| 1075 |
+
assert resp.status_code == 422
|
| 1076 |
+
assert "detail" in resp.json()
|
| 1077 |
+
|
| 1078 |
+
# 400: play without move_id
|
| 1079 |
+
resp = client.post("/api/play", json={})
|
| 1080 |
+
assert resp.status_code == 422
|
| 1081 |
+
assert "detail" in resp.json()
|
| 1082 |
+
|
| 1083 |
+
def test_record_response_shape(self, client: TestClient):
|
| 1084 |
+
resp = client.post("/api/record", json={
|
| 1085 |
+
"duration": 2.0, "record_audio": False,
|
| 1086 |
+
})
|
| 1087 |
+
assert resp.status_code == 200
|
| 1088 |
+
data = resp.json()
|
| 1089 |
+
assert "accepted" in data
|
| 1090 |
+
assert "move_id" in data
|
| 1091 |
+
assert "label" in data
|
| 1092 |
+
|
| 1093 |
+
def test_create_dataset_response_shape(self, client: TestClient):
|
| 1094 |
+
resp = client.post("/api/datasets", json={"name": "contract-ds"})
|
| 1095 |
+
assert resp.status_code == 200
|
| 1096 |
+
data = resp.json()
|
| 1097 |
+
assert "status" in data
|
| 1098 |
+
assert "dataset" in data
|
| 1099 |
+
ds = data["dataset"]
|
| 1100 |
+
assert "id" in ds
|
| 1101 |
+
assert "label" in ds
|
| 1102 |
+
assert "folder" in ds
|
| 1103 |
+
|
| 1104 |
+
def test_experiments_response_shape(self, client: TestClient):
|
| 1105 |
+
resp = client.post("/api/experiments", json={"motion_models": True})
|
| 1106 |
+
assert resp.status_code == 200
|
| 1107 |
+
data = resp.json()
|
| 1108 |
+
assert "status" in data
|
| 1109 |
+
assert "features" in data
|