Skip to content

Commit 8a06b4d

Browse files
matthaneclaude
andcommitted
Stop only the test video after the onboarding playback window
External review of the 2.0.0~beta1 package: play_test_video stopped playback blindly five seconds after play() — wall time, not a playback handle (verbatim legacy behavior). If the test video failed to open, or the user started something else inside the window, the blind stop killed whatever was playing instead. Guard the stop on the playing item still being the bundled test video: separator/case-normalized path comparison, with the isPlaying/ getPlayingFile race against a natural stop reading as "not our video" rather than unwinding the script. The rest of the flow (completed toast, settings reopen) is unchanged. New unit suite pins the guard decisions and the two flow shapes (happy path still stops the test video; playback started inside the window is spared). Co-authored-by: Claude <noreply@anthropic.com>
1 parent c53f32c commit 8a06b4d

2 files changed

Lines changed: 155 additions & 1 deletion

File tree

resources/lib/aom/onboarding.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
macros — a Phase 7 work item); error toasts pass Gui a custom title/icon.
2020
"""
2121

22+
import os
2223
import sys
2324

2425
import xbmc
@@ -84,13 +85,40 @@ def play_test_video(self):
8485
duration_ms=10000)
8586

8687
xbmc.sleep(5000)
87-
xbmc.Player().stop()
88+
self._stop_if_still_test_video()
8889

8990
self._gui.notification(
9091
self._gui.localized(STRING_TEST_VIDEO_COMPLETED),
9192
duration_ms=10000)
9293
xbmc.executebuiltin(f'Addon.OpenSettings({ADDON_ID})')
9394

95+
def _stop_if_still_test_video(self):
96+
"""Stop playback only while the test video is still the playing item.
97+
98+
The stop fires 5s after play() on wall time, not on a playback
99+
handle: by then the test video may have failed to open, or the user
100+
may have started something else, and a blind ``Player().stop()``
101+
would kill that instead. Paths compare separator/case-normalized
102+
(Kodi reports either slash direction on Windows), and the
103+
isPlaying/getPlayingFile pair can race a natural stop, so a raise
104+
reads as "not our video".
105+
"""
106+
player = xbmc.Player()
107+
try:
108+
playing = player.getPlayingFile() if player.isPlaying() else None
109+
except Exception:
110+
playing = None
111+
if playing is None:
112+
self._log("AOM_Onboarding: test video no longer playing; "
113+
"nothing to stop", xbmc.LOGDEBUG)
114+
return
115+
if (os.path.normcase(os.path.normpath(playing))
116+
!= os.path.normcase(os.path.normpath(self._test_video_path))):
117+
self._log(f"AOM_Onboarding: another item is playing ({playing}); "
118+
f"leaving it alone", xbmc.LOGDEBUG)
119+
return
120+
player.stop()
121+
94122
def bypass_test_video(self):
95123
"""Clear new_install without playing the test video.
96124

tests/unit/test_onboarding.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"""Unit tests for aom.onboarding's test-video stop guard.
2+
3+
The 5s stop in ``play_test_video`` fires on wall time, not on a playback
4+
handle, so it must verify the bundled test video is still the playing item
5+
before stopping (a blind ``Player().stop()`` could kill playback the user
6+
started inside the window — the legacy behavior).
7+
8+
Kodi is faked via Kodistubs; ``xbmc.Player`` is monkeypatched with a
9+
scriptable fake so the guard's decision is pinned without real playback.
10+
"""
11+
12+
import pytest
13+
14+
import resources.lib.aom.onboarding as onboarding
15+
16+
17+
class FakePlayer:
18+
"""Scriptable xbmc.Player: what is playing (or a raise), and the stops."""
19+
20+
def __init__(self):
21+
self.playing_file = None # None = nothing playing
22+
self.raises = False # getPlayingFile raises (stop race)
23+
self.played = []
24+
self.stopped = 0
25+
26+
def play(self, path):
27+
self.played.append(path)
28+
self.playing_file = path
29+
30+
def isPlaying(self):
31+
return self.raises or self.playing_file is not None
32+
33+
def getPlayingFile(self):
34+
if self.raises:
35+
raise RuntimeError('XBMCAddon: no file playing')
36+
return self.playing_file
37+
38+
def stop(self):
39+
self.stopped += 1
40+
self.playing_file = None
41+
42+
43+
TEST_PATH = 'addons/script.audiooffsetmanager/resources/media/test-video.mp4'
44+
45+
46+
@pytest.fixture
47+
def rig(monkeypatch):
48+
"""(onboarding instance, fake player): one shared player instance so the
49+
guard sees the same state play() produced."""
50+
player = FakePlayer()
51+
monkeypatch.setattr(onboarding.xbmc, 'Player', lambda: player)
52+
onb = onboarding._Onboarding()
53+
onb._test_video_path = TEST_PATH
54+
return onb, player
55+
56+
57+
class TestStopGuard:
58+
59+
def test_stops_while_test_video_still_playing(self, rig):
60+
onb, player = rig
61+
player.playing_file = TEST_PATH
62+
onb._stop_if_still_test_video()
63+
assert player.stopped == 1
64+
65+
def test_path_comparison_is_separator_normalized(self, rig):
66+
# Kodi may report the path with redundant or mixed separators; the
67+
# comparison is normpath/normcase-based, not string equality.
68+
onb, player = rig
69+
player.playing_file = TEST_PATH.replace('/resources/',
70+
'//resources/./')
71+
onb._stop_if_still_test_video()
72+
assert player.stopped == 1
73+
74+
def test_leaves_other_playback_alone(self, rig):
75+
# The user started something else inside the 5s window: never stop it.
76+
onb, player = rig
77+
player.playing_file = 'videodb://movies/titles/42'
78+
onb._stop_if_still_test_video()
79+
assert player.stopped == 0
80+
81+
def test_no_stop_when_nothing_is_playing(self, rig):
82+
# The test video failed to open (or already ended): nothing to stop.
83+
onb, player = rig
84+
player.playing_file = None
85+
onb._stop_if_still_test_video()
86+
assert player.stopped == 0
87+
88+
def test_raise_reads_as_not_ours(self, rig):
89+
# isPlaying/getPlayingFile can race a natural stop; a raise must be
90+
# treated as "not our video", never propagate out of the script.
91+
onb, player = rig
92+
player.raises = True
93+
onb._stop_if_still_test_video()
94+
assert player.stopped == 0
95+
96+
97+
class TestPlayFlowWiring:
98+
99+
def test_happy_path_plays_then_stops_the_test_video(self, rig, monkeypatch):
100+
# The full flow still stops the test video when it is (still) the
101+
# playing item — the guard replaced the blind stop, not the stop.
102+
onb, player = rig
103+
monkeypatch.setattr(onboarding.xbmcvfs, 'exists', lambda path: True)
104+
monkeypatch.setattr(onboarding.xbmc, 'sleep', lambda ms: None)
105+
monkeypatch.setattr(onboarding.xbmc, 'executebuiltin', lambda cmd: None)
106+
107+
onb.play_test_video()
108+
109+
assert player.played == [onb._test_video_path]
110+
assert player.stopped == 1
111+
112+
def test_flow_spares_playback_started_inside_the_window(self, rig,
113+
monkeypatch):
114+
# Regression pin for the review finding: the user starts a different
115+
# item during the 5s wait; the flow must not stop it.
116+
onb, player = rig
117+
monkeypatch.setattr(onboarding.xbmcvfs, 'exists', lambda path: True)
118+
monkeypatch.setattr(
119+
onboarding.xbmc, 'sleep',
120+
lambda ms: player.play('videodb://movies/titles/42'))
121+
monkeypatch.setattr(onboarding.xbmc, 'executebuiltin', lambda cmd: None)
122+
123+
onb.play_test_video()
124+
125+
assert player.stopped == 0
126+
assert player.playing_file == 'videodb://movies/titles/42'

0 commit comments

Comments
 (0)