/
/
/
1"""Tests for the Sendspin player interactive pairing setup flow (run_setup_flow)."""
2
3from __future__ import annotations
4
5import asyncio
6import time
7from collections import deque
8from types import SimpleNamespace
9from typing import TYPE_CHECKING, Any, cast
10from unittest import mock
11
12import pytest
13from aiosendspin.models.core import PairMethodDescriptor
14from aiosendspin.models.types import PairAbortReason, PairMethod
15from aiosendspin.noise.pairing import RemotePairingAbortError
16from aiosendspin.noise.trust_store import PskCategory
17from music_assistant_models.enums import FlowStepType
18
19from music_assistant.models.setup_flow import (
20 AbortFlow,
21 SetupFlowContext,
22 SetupSession,
23 StepExpiredError,
24)
25from music_assistant.providers.sendspin import player as player_module
26from music_assistant.providers.sendspin.constants import (
27 CONF_PAIRING_METHOD,
28 CONF_PAIRING_PIN,
29 CONF_PAIRING_TOKEN,
30 PAIR_METHOD_PIN,
31 PAIR_METHOD_TOKEN,
32 PAIR_METHOD_UNPAIRED,
33)
34from music_assistant.providers.sendspin.helpers import SecurityActionError
35from music_assistant.providers.sendspin.player import SendspinBasePlayer
36from tests.common import collect_loop_errors
37
38if TYPE_CHECKING:
39 from aiosendspin.server.client import SendspinClient
40 from music_assistant_models.setup_flow import SetupFlowStep
41
42 from music_assistant.providers.sendspin.provider import SendspinProvider
43
44
45def _desc(method: PairMethod, *, locked_out: bool = False) -> PairMethodDescriptor:
46 return PairMethodDescriptor(method=method, locked_out=locked_out)
47
48
49class _FakePinSession:
50 """Minimal PinPairingSession stand-in the fake provider hands back to the flow."""
51
52 def __init__(self, *, awaiting_gesture: bool = False, verify: bool = False) -> None:
53 self.pin_request_event = asyncio.Event()
54 if not awaiting_gesture:
55 self.pin_request_event.set()
56 self.awaiting_pin = True
57 self.finished = False
58 self.error: Exception | None = None
59 self.can_retry = False
60 self.verify = verify
61 # None so the flow's post-submit "confirming" wait is skipped in tests.
62 self.task: asyncio.Task[None] | None = None
63
64 @property
65 def awaiting_gesture(self) -> bool:
66 return not self.pin_request_event.is_set()
67
68
69class _FakeApi:
70 """Fake SendspinClient exposing just what the flow reads (hello + security + roles)."""
71
72 def __init__(
73 self,
74 methods: list[PairMethodDescriptor],
75 *,
76 active_roles: tuple[str, ...] = (),
77 psk_category: PskCategory = PskCategory.SENTINEL,
78 unpaired_access: bool = False,
79 ):
80 self.info_or_none = SimpleNamespace(
81 supported_pair_methods=list(methods),
82 unpaired_access=SimpleNamespace(enabled=unpaired_access),
83 )
84 self.connection_security: Any = SimpleNamespace(psk_category=psk_category)
85 self.active_roles = active_roles
86
87
88class _FakePairingStore:
89 """Pairing-store stand-in serving a scripted record and unpaired-trust state."""
90
91 def __init__(self, record: Any = None, trusted: Any = None) -> None:
92 self.record = record
93 self.trusted = trusted
94
95 async def record_by_client_id(self, client_id: str) -> Any:
96 return self.record
97
98 async def trusted_unpaired(self, client_id: str) -> Any:
99 return self.trusted
100
101
102class _FakeProvider:
103 """Scripts the pairing primitives run_setup_flow drives, recording every call."""
104
105 def __init__(
106 self,
107 api: _FakeApi,
108 *,
109 gesture: bool = False,
110 submit_outcomes: list[str] | None = None,
111 token_errors: list[Exception] | None = None,
112 record: Any = None,
113 trusted: Any = None,
114 ) -> None:
115 self.api = api
116 self.server_api = SimpleNamespace(pairing_store=_FakePairingStore(record, trusted))
117 self.session: _FakePinSession | None = None
118 self.start_calls = 0
119 self.static: bool | None = None
120 self.verify: bool | None = None
121 self.submitted_pins: list[str] = []
122 self.tokens: list[str] = []
123 self.cancel_calls = 0
124 self.clear_calls = 0
125 self.trust_calls: list[bool] = []
126 self._gesture = gesture
127 self._submit_outcomes = deque(submit_outcomes or [])
128 self._token_errors = deque(token_errors or [])
129
130 def pairing_config_snapshot(self, client_id: str) -> None:
131 return None
132
133 def get_pin_session(self, client_id: str) -> _FakePinSession | None:
134 return self.session
135
136 def clear_pin_session(self, client_id: str) -> None:
137 self.clear_calls += 1
138 if self.session is not None and self.session.finished:
139 self.session = None
140
141 async def start_pin_pairing(
142 self, client_id: str, *, verify: bool = False, static: bool = False
143 ) -> _FakePinSession:
144 self.start_calls += 1
145 self.static = static
146 self.verify = verify
147 if self.session is not None and self.session.can_retry:
148 # A retryable session resumes in place, past the gesture, awaiting a PIN again.
149 self.session.can_retry = False
150 self.session.error = None
151 self.session.awaiting_pin = True
152 self.session.pin_request_event.set()
153 return self.session
154 self.session = _FakePinSession(awaiting_gesture=self._gesture, verify=verify)
155 return self.session
156
157 def submit_pin(self, client_id: str, pin: str) -> None:
158 assert self.session is not None
159 self.submitted_pins.append(pin)
160 outcome = self._submit_outcomes.popleft() if self._submit_outcomes else "success"
161 if outcome == "success":
162 self.session.finished = True
163 self.session.error = None
164 self.session.awaiting_pin = False
165 self.api.active_roles = ("player",)
166 elif outcome == "retry":
167 self.session.can_retry = True
168 self.session.error = RemotePairingAbortError(PairAbortReason.PIN_MISMATCH)
169 elif outcome == "session_lost":
170 self.session = None
171 raise SecurityActionError("pairing_error_no_pin_session")
172
173 async def cancel_pin_pairing(self, client_id: str) -> None:
174 self.cancel_calls += 1
175 self.session = None
176
177 async def set_trusted_unpaired(self, client_id: str, enabled: bool) -> None:
178 self.trust_calls.append(enabled)
179 if enabled:
180 self.server_api.pairing_store.trusted = object()
181
182 async def pair_with_token(self, client_id: str, token: str) -> None:
183 self.tokens.append(token)
184 if self._token_errors:
185 raise self._token_errors.popleft()
186 self.api.active_roles = ("player",)
187
188
189def _make_player(api: _FakeApi, provider: _FakeProvider) -> SendspinBasePlayer:
190 player = SendspinBasePlayer.__new__(SendspinBasePlayer)
191 player._player_id = "client-1"
192 player._provider = cast("SendspinProvider", provider)
193 player.api = cast("SendspinClient", api)
194 return player
195
196
197def _make_session(finish_handler: Any) -> tuple[SetupSession, mock.Mock]:
198 mass = mock.Mock()
199 context = SetupFlowContext(kind="setup", reason="user", domain="sendspin", player_id="client-1")
200 return SetupSession(mass, "flow-test", context, finish_handler), mass
201
202
203async def _ok_finish(_session: SetupSession, _values: dict[str, Any]) -> dict[str, str]:
204 """Finish handler stand-in that accepts any values and reports the player id."""
205 return {"player_id": "client-1"}
206
207
208def _published_steps(mass: mock.Mock) -> list[Any]:
209 return [call.kwargs["data"] for call in mass.signal_event.call_args_list]
210
211
212async def _wait_for(predicate: Any, timeout: float = 5.0) -> Any:
213 deadline = time.monotonic() + timeout
214 while time.monotonic() < deadline:
215 if result := predicate():
216 return result
217 await asyncio.sleep(0.01)
218 raise AssertionError("condition not met within timeout")
219
220
221async def _wait_step(
222 session: SetupSession,
223 *,
224 step_type: FlowStepType | None = None,
225 step_id: str | None = None,
226 with_errors: bool = False,
227) -> SetupFlowStep:
228 def _match() -> SetupFlowStep | None:
229 step = session.current_step
230 if step is None:
231 return None
232 if step_type is not None and step.type != step_type:
233 return None
234 if step_id is not None and step.step_id != step_id:
235 return None
236 if with_errors and not step.errors:
237 return None
238 return step
239
240 return cast("SetupFlowStep", await _wait_for(_match))
241
242
243async def test_select_method_pin_gesture_submit_success() -> None:
244 """Select PIN, wait through the gesture, submit the PIN, succeed, and finish with {}."""
245 collected: dict[str, Any] = {}
246
247 async def finish(_s: SetupSession, values: dict[str, Any]) -> dict[str, str]:
248 collected["values"] = values
249 return {"player_id": "client-1"}
250
251 api = _FakeApi([_desc(PairMethod.DYNAMIC_PIN), _desc(PairMethod.PAIRING_PSK)])
252 provider = _FakeProvider(api, gesture=True)
253 session, mass = _make_session(finish)
254 player = _make_player(api, provider)
255
256 task = asyncio.create_task(player.run_setup_flow(session))
257 step = await _wait_step(session, step_type=FlowStepType.FORM, step_id="select_method")
258 assert {option.value for option in step.entries[0].options} == {
259 PAIR_METHOD_PIN,
260 PAIR_METHOD_TOKEN,
261 }
262 session.handle_submit({CONF_PAIRING_METHOD: PAIR_METHOD_PIN})
263
264 await _wait_step(session, step_type=FlowStepType.PROGRESS, step_id="awaiting_gesture")
265 assert provider.session is not None
266 provider.session.pin_request_event.set()
267
268 await _wait_step(session, step_type=FlowStepType.FORM, step_id="enter_pin")
269 session.handle_submit({CONF_PAIRING_PIN: "123456"})
270
271 await _wait_for(lambda: session.finished)
272 await task
273
274 assert collected["values"] == {}
275 assert provider.submitted_pins == ["123456"]
276 assert provider.static is False
277 assert provider.verify is False
278 assert provider.cancel_calls == 0
279 assert provider.clear_calls == 1
280 steps = _published_steps(mass)
281 assert [s.step_id for s in steps if s.type == FlowStepType.PROGRESS] == ["awaiting_gesture"]
282 assert steps[-1].type == FlowStepType.FINISH
283
284
285async def test_confirming_wait_failure_after_deadline_logs_no_loop_error(
286 monkeypatch: pytest.MonkeyPatch,
287) -> None:
288 """A pairing attempt failing after the confirming step expired is not reported to the loop."""
289 release = asyncio.Event()
290
291 async def _failing_attempt() -> None:
292 await release.wait()
293 raise RuntimeError("refreshing the player failed")
294
295 monkeypatch.setattr(player_module, "PAIR_CONFIRM_TIMEOUT", 0.01)
296 api = _FakeApi([_desc(PairMethod.DYNAMIC_PIN)])
297 provider = _FakeProvider(api)
298 session, _mass = _make_session(_ok_finish)
299 player = _make_player(api, provider)
300
301 with collect_loop_errors() as reported:
302 flow = asyncio.create_task(player.run_setup_flow(session))
303 await _wait_step(session, step_type=FlowStepType.FORM, step_id="enter_pin")
304 assert provider.session is not None
305 attempt = asyncio.create_task(_failing_attempt())
306 provider.session.task = attempt
307 session.handle_submit({CONF_PAIRING_PIN: "123456"})
308
309 # let the attempt fail only once the confirming step has expired and the flow has
310 # moved on, so the failure reliably lands after the flow stopped waiting for it
311 await _wait_for(lambda: session.finished)
312 await flow
313 release.set()
314 with pytest.raises(RuntimeError, match="refreshing the player failed"):
315 await attempt
316
317 assert reported == []
318
319
320async def test_single_pin_method_skips_select() -> None:
321 """A device offering one usable PIN method goes straight to the PIN form, no method select."""
322 api = _FakeApi([_desc(PairMethod.DYNAMIC_PIN)])
323 provider = _FakeProvider(api)
324 session, mass = _make_session(_ok_finish)
325 player = _make_player(api, provider)
326
327 task = asyncio.create_task(player.run_setup_flow(session))
328 await _wait_step(session, step_type=FlowStepType.FORM, step_id="enter_pin")
329 assert not any(s.step_id == "select_method" for s in _published_steps(mass))
330 session.handle_submit({CONF_PAIRING_PIN: "123456"})
331 await _wait_for(lambda: session.finished)
332 await task
333 assert provider.submitted_pins == ["123456"]
334
335
336async def test_pin_mismatch_retries_in_place_then_succeeds() -> None:
337 """A mismatch re-renders the PIN form with a base error; the retry resumes and succeeds."""
338 api = _FakeApi([_desc(PairMethod.DYNAMIC_PIN)])
339 provider = _FakeProvider(api, submit_outcomes=["retry", "success"])
340 session, _mass = _make_session(_ok_finish)
341 player = _make_player(api, provider)
342
343 task = asyncio.create_task(player.run_setup_flow(session))
344 await _wait_step(session, step_type=FlowStepType.FORM, step_id="enter_pin")
345 session.handle_submit({CONF_PAIRING_PIN: "000000"})
346
347 error_step = await _wait_step(
348 session, step_type=FlowStepType.FORM, step_id="enter_pin", with_errors=True
349 )
350 assert error_step.errors == {"base": "pairing_error_pin_mismatch"}
351 session.handle_submit({CONF_PAIRING_PIN: "123456"})
352
353 await _wait_for(lambda: session.finished)
354 await task
355 assert provider.submitted_pins == ["000000", "123456"]
356 assert provider.start_calls == 2
357 assert provider.cancel_calls == 0
358
359
360async def test_trusted_unpaired_pin_mismatch_still_retries() -> None:
361 """With unpaired access already trusted, a mismatch must not be misreported as success."""
362 api = _FakeApi(
363 [_desc(PairMethod.DYNAMIC_PIN)],
364 active_roles=("player",),
365 unpaired_access=True,
366 )
367 provider = _FakeProvider(api, submit_outcomes=["retry", "success"], trusted=object())
368 session, mass = _make_session(_ok_finish)
369 player = _make_player(api, provider)
370
371 task = asyncio.create_task(player.run_setup_flow(session))
372 # trusted-unpaired devices are not offered the unpaired option again
373 await _wait_step(session, step_type=FlowStepType.FORM, step_id="enter_pin")
374 assert not any(s.step_id == "select_method" for s in _published_steps(mass))
375 session.handle_submit({CONF_PAIRING_PIN: "000000"})
376
377 error_step = await _wait_step(
378 session, step_type=FlowStepType.FORM, step_id="enter_pin", with_errors=True
379 )
380 assert error_step.errors == {"base": "pairing_error_pin_mismatch"}
381 session.handle_submit({CONF_PAIRING_PIN: "123456"})
382
383 await _wait_for(lambda: session.finished)
384 await task
385 assert provider.submitted_pins == ["000000", "123456"]
386 assert provider.trust_calls == []
387
388
389async def test_unpaired_option_grants_trust() -> None:
390 """Picking the unpaired option grants unpaired playback and finishes without pairing."""
391 api = _FakeApi([_desc(PairMethod.DYNAMIC_PIN)], unpaired_access=True)
392 provider = _FakeProvider(api)
393 session, _mass = _make_session(_ok_finish)
394 player = _make_player(api, provider)
395
396 task = asyncio.create_task(player.run_setup_flow(session))
397 step = await _wait_step(session, step_type=FlowStepType.FORM, step_id="select_method")
398 assert {option.value for option in step.entries[0].options} == {
399 PAIR_METHOD_PIN,
400 PAIR_METHOD_UNPAIRED,
401 }
402 session.handle_submit({CONF_PAIRING_METHOD: PAIR_METHOD_UNPAIRED})
403
404 await _wait_for(lambda: session.finished)
405 await task
406 assert provider.trust_calls == [True]
407 assert provider.start_calls == 0
408 assert provider.tokens == []
409
410
411async def test_lone_unpaired_option_still_asks() -> None:
412 """A lone unpaired-access option is never auto-picked: the grant needs an explicit choice."""
413 api = _FakeApi([], unpaired_access=True)
414 provider = _FakeProvider(api)
415 session, _mass = _make_session(_ok_finish)
416 player = _make_player(api, provider)
417
418 task = asyncio.create_task(player.run_setup_flow(session))
419 step = await _wait_step(session, step_type=FlowStepType.FORM, step_id="select_method")
420 assert [option.value for option in step.entries[0].options] == [PAIR_METHOD_UNPAIRED]
421 session.handle_submit({CONF_PAIRING_METHOD: PAIR_METHOD_UNPAIRED})
422
423 await _wait_for(lambda: session.finished)
424 await task
425 assert provider.trust_calls == [True]
426
427
428async def test_verify_presence_on_paired_device() -> None:
429 """Re-running the flow on a paired device runs the dynamic-PIN presence verification."""
430 api = _FakeApi([_desc(PairMethod.DYNAMIC_PIN)], psk_category=PskCategory.LONG_TERM)
431 record = SimpleNamespace(pair_methods=[PairMethod.STATIC_PIN])
432 provider = _FakeProvider(api, record=record)
433 session, _mass = _make_session(_ok_finish)
434 player = _make_player(api, provider)
435
436 task = asyncio.create_task(player.run_setup_flow(session))
437 await _wait_step(session, step_type=FlowStepType.FORM, step_id="verify_pin")
438 assert provider.verify is True
439 assert provider.static is False
440 session.handle_submit({CONF_PAIRING_PIN: "123456"})
441
442 await _wait_for(lambda: session.finished)
443 await task
444 assert provider.submitted_pins == ["123456"]
445
446
447async def test_paired_device_without_verification_aborts() -> None:
448 """A paired device whose presence verification would add nothing aborts as already paired."""
449 api = _FakeApi([_desc(PairMethod.DYNAMIC_PIN)], psk_category=PskCategory.LONG_TERM)
450 record = SimpleNamespace(pair_methods=[PairMethod.DYNAMIC_PIN])
451 provider = _FakeProvider(api, record=record)
452 session, _mass = _make_session(_ok_finish)
453 player = _make_player(api, provider)
454
455 with pytest.raises(AbortFlow) as excinfo:
456 await player.run_setup_flow(session)
457 assert excinfo.value.reason == "already_paired"
458 assert provider.start_calls == 0
459
460
461async def test_submit_pin_session_lost_rerenders() -> None:
462 """A session that ends underneath the submit re-renders the PIN form and starts afresh."""
463 api = _FakeApi([_desc(PairMethod.DYNAMIC_PIN)])
464 provider = _FakeProvider(api, submit_outcomes=["session_lost", "success"])
465 session, _mass = _make_session(_ok_finish)
466 player = _make_player(api, provider)
467
468 task = asyncio.create_task(player.run_setup_flow(session))
469 await _wait_step(session, step_type=FlowStepType.FORM, step_id="enter_pin")
470 session.handle_submit({CONF_PAIRING_PIN: "000000"})
471
472 error_step = await _wait_step(
473 session, step_type=FlowStepType.FORM, step_id="enter_pin", with_errors=True
474 )
475 assert error_step.errors == {"base": "pairing_error_no_pin_session"}
476 session.handle_submit({CONF_PAIRING_PIN: "123456"})
477
478 await _wait_for(lambda: session.finished)
479 await task
480 assert provider.submitted_pins == ["000000", "123456"]
481 assert provider.start_calls == 2
482
483
484async def test_gesture_timeout_propagates(monkeypatch: pytest.MonkeyPatch) -> None:
485 """An expired gesture wait propagates (timed_out abort) and tears the session down."""
486 monkeypatch.setattr("music_assistant.providers.sendspin.player.PAIR_GESTURE_TIMEOUT", 0.05)
487 api = _FakeApi([_desc(PairMethod.DYNAMIC_PIN)])
488 provider = _FakeProvider(api, gesture=True)
489 session, _mass = _make_session(_ok_finish)
490 player = _make_player(api, provider)
491
492 with pytest.raises(StepExpiredError):
493 await player.run_setup_flow(session)
494 assert provider.cancel_calls == 1
495
496
497async def test_abort_mid_pairing_runs_cleanup() -> None:
498 """Cancelling the flow while a PIN session is in flight tears it down in the finally."""
499 api = _FakeApi([_desc(PairMethod.DYNAMIC_PIN)])
500 provider = _FakeProvider(api)
501 session, _mass = _make_session(_ok_finish)
502 player = _make_player(api, provider)
503
504 task = asyncio.create_task(player.run_setup_flow(session))
505 await _wait_step(session, step_type=FlowStepType.FORM, step_id="enter_pin")
506 assert provider.session is not None
507
508 task.cancel()
509 with pytest.raises(asyncio.CancelledError):
510 await task
511
512 assert provider.cancel_calls == 1
513 assert not session.finished
514
515
516async def test_token_pairing_success() -> None:
517 """A token-only device drives the token form and pairs on submit."""
518 collected: dict[str, Any] = {}
519
520 async def finish(_s: SetupSession, values: dict[str, Any]) -> dict[str, str]:
521 collected["values"] = values
522 return {"player_id": "client-1"}
523
524 api = _FakeApi([_desc(PairMethod.PAIRING_PSK)])
525 provider = _FakeProvider(api)
526 session, mass = _make_session(finish)
527 player = _make_player(api, provider)
528
529 task = asyncio.create_task(player.run_setup_flow(session))
530 await _wait_step(session, step_type=FlowStepType.FORM, step_id="enter_token")
531 assert not any(s.step_id == "select_method" for s in _published_steps(mass))
532 session.handle_submit({CONF_PAIRING_TOKEN: "tok-123"})
533
534 await _wait_for(lambda: session.finished)
535 await task
536 assert provider.tokens == ["tok-123"]
537 assert collected["values"] == {}
538
539
540async def test_token_invalid_re_renders_then_succeeds() -> None:
541 """An invalid token re-renders the token form with a base error, then pairs on retry."""
542 api = _FakeApi([_desc(PairMethod.PAIRING_PSK)])
543 provider = _FakeProvider(api, token_errors=[SecurityActionError("pairing_error_token_invalid")])
544 session, _mass = _make_session(_ok_finish)
545 player = _make_player(api, provider)
546
547 task = asyncio.create_task(player.run_setup_flow(session))
548 await _wait_step(session, step_type=FlowStepType.FORM, step_id="enter_token")
549 session.handle_submit({CONF_PAIRING_TOKEN: "bad"})
550
551 error_step = await _wait_step(
552 session, step_type=FlowStepType.FORM, step_id="enter_token", with_errors=True
553 )
554 assert error_step.errors == {"base": "pairing_error_token_invalid"}
555 session.handle_submit({CONF_PAIRING_TOKEN: "good"})
556
557 await _wait_for(lambda: session.finished)
558 await task
559 assert provider.tokens == ["bad", "good"]
560
561
562async def test_no_pair_methods_aborts() -> None:
563 """A device offering nothing usable aborts with the no_pair_methods reason."""
564 api = _FakeApi([_desc(PairMethod.DYNAMIC_PIN, locked_out=True)])
565 provider = _FakeProvider(api)
566 session, _mass = _make_session(_ok_finish)
567 player = _make_player(api, provider)
568
569 with pytest.raises(AbortFlow) as excinfo:
570 await player.run_setup_flow(session)
571 assert excinfo.value.reason == "no_pair_methods"
572 assert provider.start_calls == 0
573
574
575async def test_unencrypted_connection_aborts() -> None:
576 """An unencrypted (legacy) connection has nothing to pair and aborts."""
577 api = _FakeApi([_desc(PairMethod.DYNAMIC_PIN)])
578 api.connection_security = None
579 provider = _FakeProvider(api)
580 session, _mass = _make_session(_ok_finish)
581 player = _make_player(api, provider)
582
583 with pytest.raises(AbortFlow) as excinfo:
584 await player.run_setup_flow(session)
585 assert excinfo.value.reason == "nothing_to_configure"
586
587
588def test_pairing_method_options_derivation() -> None:
589 """Static PIN needs both PIN methods usable; token and unpaired are independent."""
590 api = _FakeApi([_desc(PairMethod.DYNAMIC_PIN), _desc(PairMethod.STATIC_PIN)])
591 provider = _FakeProvider(api)
592 player = _make_player(api, provider)
593 assert player._pairing_method_options(
594 cast("SendspinProvider", provider), offer_unpaired=True
595 ) == [PAIR_METHOD_PIN, "static_pin"]
596
597 api_single = _FakeApi(
598 [_desc(PairMethod.STATIC_PIN), _desc(PairMethod.PAIRING_PSK)], unpaired_access=True
599 )
600 provider_single = _FakeProvider(api_single)
601 player_single = _make_player(api_single, provider_single)
602 assert player_single._pairing_method_options(
603 cast("SendspinProvider", provider_single), offer_unpaired=True
604 ) == [PAIR_METHOD_PIN, PAIR_METHOD_TOKEN, PAIR_METHOD_UNPAIRED]
605 assert player_single._pairing_method_options(
606 cast("SendspinProvider", provider_single), offer_unpaired=False
607 ) == [PAIR_METHOD_PIN, PAIR_METHOD_TOKEN]
608