Spaces:
Running
Running
Merge branch 'main' of hf.co:spaces/RemiFabre/marionette
Browse files- 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."""
|