RemiFabre commited on
Commit
25ea24a
Β·
1 Parent(s): c1ac039

Add AGC, API contract, and E2E tests for refactoring protection

Browse files

Unit 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

Files changed (4) hide show
  1. TESTING.md +5 -1
  2. tests/e2e/test_ui.py +70 -0
  3. tests/run_tests.py +4 -0
  4. 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 | 4 | Empty/long/special labels and localStorage persistence |
 
 
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