RemiFabre commited on
Commit
9f9e605
Β·
2 Parent(s): 160dd6eeabe922

Merge branch 'main' of hf.co:spaces/RemiFabre/marionette

Browse files
Files changed (1) hide show
  1. tests/e2e/test_ui.py +307 -0
tests/e2e/test_ui.py CHANGED
@@ -5,7 +5,13 @@ Uses a real Marionette server with temp paths (no robot connection needed for
5
  these tests β€” they only test the web UI behavior).
6
  """
7
 
 
 
8
  import re
 
 
 
 
9
  import pytest
10
  from playwright.sync_api import Page, expect
11
 
@@ -887,6 +893,307 @@ class TestDatasetRootHint:
887
  page.locator("#settings-close").click()
888
 
889
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
890
  class TestWelcomeMessagesUI:
891
  def test_welcome_radio_visible(self, page: Page, base_url: str):
892
  """Settings drawer should show 3 welcome message radio options."""
 
5
  these tests β€” they only test the web UI behavior).
6
  """
7
 
8
+ import io
9
+ import json
10
  import re
11
+ import struct
12
+ import wave
13
+
14
+ import httpx
15
  import pytest
16
  from playwright.sync_api import Page, expect
17
 
 
893
  page.locator("#settings-close").click()
894
 
895
 
896
+ # ──────── Helper: create a minimal WAV file ──────────────────────────
897
+
898
+
899
+ def _make_wav_bytes(filename: str = "test-audio.wav", duration_s: float = 0.01,
900
+ sample_rate: int = 16000) -> tuple[str, bytes]:
901
+ """Return (filename, wav_bytes) for a minimal valid WAV."""
902
+ buf = io.BytesIO()
903
+ n_samples = int(sample_rate * duration_s)
904
+ with wave.open(buf, "wb") as wf:
905
+ wf.setnchannels(1)
906
+ wf.setsampwidth(2)
907
+ wf.setframerate(sample_rate)
908
+ wf.writeframes(struct.pack(f"<{n_samples}h", *([0] * n_samples)))
909
+ return filename, buf.getvalue()
910
+
911
+
912
+ # ──────── Audio selection status tests ────────────────────────────────
913
+
914
+
915
+ class TestAudioSelectionStatus:
916
+ """Tests for the 'Audio: ...' indicator that shows current audio selection."""
917
+
918
+ def test_silent_mode_shows_silent(self, page: Page, base_url: str):
919
+ """When 'silent' radio selected, indicator shows 'Audio: Silent'."""
920
+ page.goto(base_url)
921
+ page.wait_for_timeout(2000)
922
+ page.locator('input[name="audio-src"][value="silent"]').check()
923
+ page.wait_for_timeout(500)
924
+ expect(page.locator("#audio-selection-status")).to_contain_text("Silent")
925
+
926
+ def test_upload_mode_no_file_shows_none(self, page: Page, base_url: str):
927
+ """Upload mode with no file shows 'Audio: None selected'."""
928
+ page.goto(base_url)
929
+ page.wait_for_timeout(2000)
930
+ page.locator('input[name="audio-src"][value="upload"]').check()
931
+ page.wait_for_timeout(500)
932
+ expect(page.locator("#audio-selection-status")).to_contain_text("None selected")
933
+
934
+ def test_switch_back_to_silent_updates_indicator(self, page: Page, base_url: str):
935
+ """Switching from upload back to silent updates the indicator."""
936
+ page.goto(base_url)
937
+ page.wait_for_timeout(2000)
938
+ page.locator('input[name="audio-src"][value="upload"]').check()
939
+ page.wait_for_timeout(500)
940
+ expect(page.locator("#audio-selection-status")).to_contain_text("None selected")
941
+
942
+ page.locator('input[name="audio-src"][value="silent"]').check()
943
+ page.wait_for_timeout(500)
944
+ expect(page.locator("#audio-selection-status")).to_contain_text("Silent")
945
+
946
+ def test_upload_file_shows_filename(self, page: Page, base_url: str, test_marionette):
947
+ """After uploading a WAV, indicator shows the filename."""
948
+ page.goto(base_url)
949
+ page.wait_for_timeout(2000)
950
+ page.locator('input[name="audio-src"][value="upload"]').check()
951
+ page.wait_for_timeout(500)
952
+
953
+ fname, wav_data = _make_wav_bytes("my-sound.wav")
954
+ page.locator("#audio-file-input").set_input_files(
955
+ {"name": fname, "mimeType": "audio/wav", "buffer": wav_data}
956
+ )
957
+ page.wait_for_timeout(2000)
958
+ expect(page.locator("#audio-selection-status")).to_contain_text("my-sound.wav")
959
+
960
+
961
+ # ──────── Dataset dropdown order tests ────────────────────────────────
962
+
963
+
964
+ class TestDatasetDropdownOrder:
965
+ """Verify datasets appear sorted alphabetically in the dropdown."""
966
+
967
+ def test_datasets_sorted_alphabetically(self, page: Page, base_url: str, test_marionette):
968
+ """Dataset dropdown options are sorted alphabetically by label."""
969
+ # Create datasets in non-alphabetical order
970
+ httpx.post(f"{base_url}/api/datasets", json={"name": "Zebra-ds"}, timeout=5)
971
+ httpx.post(f"{base_url}/api/datasets", json={"name": "Alpha-ds"}, timeout=5)
972
+ httpx.post(f"{base_url}/api/datasets", json={"name": "Middle-ds"}, timeout=5)
973
+
974
+ page.goto(base_url)
975
+ page.wait_for_timeout(2000)
976
+
977
+ options = page.locator("#dataset-select option").all_text_contents()
978
+ # Filter out empty options
979
+ labels = [o for o in options if o.strip()]
980
+ assert len(labels) >= 3, f"Expected at least 3 datasets, got {labels}"
981
+ assert labels == sorted(labels, key=str.lower), (
982
+ f"Dataset dropdown not sorted: {labels}"
983
+ )
984
+
985
+
986
+ # ──────── Auto-fill recording name from WAV upload tests ─────────────
987
+
988
+
989
+ class TestAutoFillName:
990
+ """Tests for auto-filling the recording name from uploaded WAV filename."""
991
+
992
+ def test_upload_wav_fills_empty_name(self, page: Page, base_url: str, test_marionette):
993
+ """Uploading WAV when name field is empty auto-fills with filename (minus extension)."""
994
+ page.goto(base_url)
995
+ page.wait_for_timeout(2000)
996
+
997
+ # Select upload radio
998
+ page.locator('input[name="audio-src"][value="upload"]').check()
999
+ page.wait_for_timeout(500)
1000
+
1001
+ # Clear name field
1002
+ page.locator("#rec-name").fill("")
1003
+
1004
+ # Upload a file
1005
+ fname, wav_data = _make_wav_bytes("test-audio.wav")
1006
+ page.locator("#audio-file-input").set_input_files(
1007
+ {"name": fname, "mimeType": "audio/wav", "buffer": wav_data}
1008
+ )
1009
+ page.wait_for_timeout(2000)
1010
+
1011
+ assert page.locator("#rec-name").input_value() == "test-audio"
1012
+
1013
+ def test_upload_wav_does_not_overwrite_existing_name(self, page: Page, base_url: str,
1014
+ test_marionette):
1015
+ """Uploading WAV when name field already has text does NOT overwrite."""
1016
+ page.goto(base_url)
1017
+ page.wait_for_timeout(2000)
1018
+
1019
+ # Select upload radio
1020
+ page.locator('input[name="audio-src"][value="upload"]').check()
1021
+ page.wait_for_timeout(500)
1022
+
1023
+ # Fill name field first
1024
+ page.locator("#rec-name").fill("my-custom-name")
1025
+
1026
+ # Upload a file
1027
+ fname, wav_data = _make_wav_bytes("other-audio.wav")
1028
+ page.locator("#audio-file-input").set_input_files(
1029
+ {"name": fname, "mimeType": "audio/wav", "buffer": wav_data}
1030
+ )
1031
+ page.wait_for_timeout(2000)
1032
+
1033
+ # Name should NOT have changed
1034
+ assert page.locator("#rec-name").input_value() == "my-custom-name"
1035
+
1036
+ def test_upload_fills_duration_from_wav(self, page: Page, base_url: str, test_marionette):
1037
+ """Uploading WAV also sets the duration field from the file duration."""
1038
+ page.goto(base_url)
1039
+ page.wait_for_timeout(2000)
1040
+
1041
+ page.locator('input[name="audio-src"][value="upload"]').check()
1042
+ page.wait_for_timeout(500)
1043
+
1044
+ # Create a WAV with a known duration (0.5s)
1045
+ fname, wav_data = _make_wav_bytes("duration-test.wav", duration_s=0.5)
1046
+ page.locator("#audio-file-input").set_input_files(
1047
+ {"name": fname, "mimeType": "audio/wav", "buffer": wav_data}
1048
+ )
1049
+ page.wait_for_timeout(2000)
1050
+
1051
+ dur_val = page.locator("#rec-duration").input_value()
1052
+ assert dur_val == "0.5", f"Expected duration 0.5, got {dur_val}"
1053
+
1054
+
1055
+ # ──────── Moves list refresh after busyβ†’idle tests ────────────────────
1056
+
1057
+
1058
+ class TestMovesListRefresh:
1059
+ """Tests that moves list refreshes after recording completes (busy→idle)."""
1060
+
1061
+ def _inject_move(self, test_marionette, move_id):
1062
+ data_dir = test_marionette._dataset_dir
1063
+ move_data = {
1064
+ "description": "injected for refresh test",
1065
+ "time": [0.0, 0.01, 0.02],
1066
+ "set_target_data": [
1067
+ {"head": [[1,0,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]],
1068
+ "antennas": [0, 0], "body_yaw": 0.0}
1069
+ for _ in range(3)
1070
+ ],
1071
+ }
1072
+ (data_dir / f"{move_id}.json").write_text(json.dumps(move_data))
1073
+ return data_dir / f"{move_id}.json"
1074
+
1075
+ def test_moves_list_refreshes_after_busy_to_idle(self, page: Page, base_url: str,
1076
+ test_marionette):
1077
+ """After busy→idle transition, newly injected moves appear in the list."""
1078
+ page.goto(base_url)
1079
+ page.wait_for_timeout(2000)
1080
+
1081
+ # Simulate a truly busy mode (recording) β€” "queued" doesn't count as
1082
+ # busy in the frontend (busy = !['idle','queued'].includes(mode)).
1083
+ test_marionette._set_state(mode="recording")
1084
+ # Wait for at least one poll to register busy=true
1085
+ page.wait_for_timeout(2000)
1086
+
1087
+ # While busy, inject a move server-side (simulates recording completion)
1088
+ path = self._inject_move(test_marionette, "post-record-move")
1089
+ test_marionette._refresh_recordings()
1090
+
1091
+ # Return to idle β€” frontend detects wasBusy && !busy β†’ movesListDirty=true
1092
+ test_marionette._set_idle_state()
1093
+
1094
+ # Wait for poll cycle to pick up idle + dirty flag
1095
+ page.wait_for_timeout(3000)
1096
+
1097
+ moves_text = page.locator("#moves-list").text_content() or ""
1098
+ assert "post-record-move" in moves_text
1099
+
1100
+ # Cleanup
1101
+ path.unlink(missing_ok=True)
1102
+ test_marionette._refresh_recordings()
1103
+
1104
+
1105
+ # ──────── Playback stop interaction tests ─────────────────────────────
1106
+
1107
+
1108
+ class TestPlaybackStopInteraction:
1109
+ """Tests for the play/stop button interaction on move cards."""
1110
+
1111
+ def _inject_move(self, test_marionette, move_id="e2e-stop-test"):
1112
+ data_dir = test_marionette._dataset_dir
1113
+ move_data = {
1114
+ "description": "stop interaction test",
1115
+ "time": [i * 0.01 for i in range(100)],
1116
+ "set_target_data": [
1117
+ {"head": [[1,0,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]],
1118
+ "antennas": [0, 0], "body_yaw": 0.0}
1119
+ for _ in range(100)
1120
+ ],
1121
+ }
1122
+ path = data_dir / f"{move_id}.json"
1123
+ path.write_text(json.dumps(move_data))
1124
+ test_marionette._refresh_recordings()
1125
+ return path
1126
+
1127
+ def test_play_button_text_changes_to_stop(self, page: Page, base_url: str,
1128
+ test_marionette):
1129
+ """Clicking Play queues playback, and the button text changes to 'Stop'."""
1130
+ path = self._inject_move(test_marionette)
1131
+ page.goto(base_url)
1132
+ page.wait_for_timeout(2000)
1133
+
1134
+ play_btn = page.locator(".play-action").first
1135
+ expect(play_btn).to_contain_text("Play")
1136
+
1137
+ play_btn.click()
1138
+ page.wait_for_timeout(2000)
1139
+
1140
+ # Without a real robot, mode becomes queued; verify button or badge changed
1141
+ badge_text = page.locator("#mode-badge").text_content() or ""
1142
+ assert "queued" in badge_text.lower() or "playing" in badge_text.lower()
1143
+
1144
+ # Cleanup
1145
+ test_marionette._set_idle_state()
1146
+ test_marionette._pending_playback = None
1147
+ path.unlink(missing_ok=True)
1148
+ test_marionette._refresh_recordings()
1149
+
1150
+ def test_stop_via_api_returns_to_idle(self, page: Page, base_url: str,
1151
+ test_marionette):
1152
+ """Stopping playback via API returns the badge to idle."""
1153
+ path = self._inject_move(test_marionette)
1154
+ page.goto(base_url)
1155
+ page.wait_for_timeout(2000)
1156
+
1157
+ page.locator(".play-action").first.click()
1158
+ page.wait_for_timeout(2000)
1159
+
1160
+ # Stop via API
1161
+ httpx.post(f"{base_url}/api/record/stop", timeout=5)
1162
+ test_marionette._set_idle_state()
1163
+ test_marionette._pending_playback = None
1164
+
1165
+ page.wait_for_timeout(2000)
1166
+ badge_text = page.locator("#mode-badge").text_content() or ""
1167
+ assert "idle" in badge_text.lower()
1168
+
1169
+ path.unlink(missing_ok=True)
1170
+ test_marionette._refresh_recordings()
1171
+
1172
+ def test_is_playing_class_on_move_card(self, page: Page, base_url: str,
1173
+ test_marionette):
1174
+ """When mode=playing and active_move matches, the card gets 'is-playing' class."""
1175
+ path = self._inject_move(test_marionette, "e2e-playing-card")
1176
+ page.goto(base_url)
1177
+ page.wait_for_timeout(2000)
1178
+
1179
+ # Simulate playing mode server-side (bypasses robot requirement)
1180
+ test_marionette._set_state(mode="playing", active_move="e2e-playing-card")
1181
+ page.wait_for_timeout(2000)
1182
+
1183
+ card = page.locator('.move-card[data-move-id="e2e-playing-card"]')
1184
+ expect(card).to_have_class(re.compile("is-playing"))
1185
+
1186
+ # The play button should now say "Stop"
1187
+ play_btn = card.locator(".play-action")
1188
+ expect(play_btn).to_contain_text("Stop")
1189
+
1190
+ # Cleanup
1191
+ test_marionette._set_idle_state()
1192
+ test_marionette._pending_playback = None
1193
+ path.unlink(missing_ok=True)
1194
+ test_marionette._refresh_recordings()
1195
+
1196
+
1197
  class TestWelcomeMessagesUI:
1198
  def test_welcome_radio_visible(self, page: Page, base_url: str):
1199
  """Settings drawer should show 3 welcome message radio options."""