Coverage for test/unittests/test_skill_installer.py: 98%

173 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2025-06-17 13:44 +0000

1from unittest.mock import Mock 

2 

3import pytest 

4 

5from ovos_bus_client import Message 

6from ovos_core.skill_installer import SkillsStore 

7 

8 

9class MessageBusMock: 

10 """Replaces actual message bus calls in unit tests. 

11 

12 The message bus should not be running during unit tests so mock it 

13 out in a way that makes it easy to test code that calls it. 

14 """ 

15 

16 def __init__(self): 

17 self.message_types = [] 

18 self.message_data = [] 

19 self.event_handlers = [] 

20 

21 def emit(self, message): 

22 self.message_types.append(message.msg_type) 

23 self.message_data.append(message.data) 

24 

25 def on(self, event, _): 

26 self.event_handlers.append(event) 

27 

28 def remove(self, event, _): 

29 self.event_handlers.remove(event) 

30 

31 def once(self, event, _): 

32 self.event_handlers.append(event) 

33 

34 def wait_for_response(self, message): 

35 self.emit(message) 

36 

37 

38@pytest.fixture(scope="function", autouse=True) 

39def skills_store(request): 

40 config = getattr(request, 'param', {}) 

41 return SkillsStore(bus=MessageBusMock(), config=config) 

42 

43 

44def test_shutdown(skills_store): 

45 assert skills_store.shutdown() is None 

46 

47 

48def test_play_error_sound(skills_store): 

49 skills_store.play_error_sound() 

50 assert skills_store.bus.message_data[-1] == { 

51 "uri": "snd/error.mp3" 

52 } 

53 assert skills_store.bus.message_types[-1] == "mycroft.audio.play_sound" 

54 

55 

56@pytest.mark.parametrize("skills_store", [{"sounds": {"pip_error": "snd/custom_error.mp3"}}], indirect=True) 

57def test_play_error_sound_custom(skills_store): 

58 skills_store.play_error_sound() 

59 assert skills_store.bus.message_data[-1] == { 

60 "uri": "snd/custom_error.mp3" 

61 } 

62 assert skills_store.bus.message_types[-1] == "mycroft.audio.play_sound" 

63 

64 

65def test_play_success_sound(skills_store): 

66 skills_store.play_success_sound() 

67 assert skills_store.bus.message_data[-1] == { 

68 "uri": "snd/acknowledge.mp3" 

69 } 

70 assert skills_store.bus.message_types[-1] == "mycroft.audio.play_sound" 

71 

72 

73@pytest.mark.parametrize("skills_store", [{"sounds": {"pip_success": "snd/custom_success.mp3"}}], indirect=True) 

74def test_play_success_sound_custom(skills_store): 

75 skills_store.play_success_sound() 

76 assert skills_store.bus.message_data[-1] == { 

77 "uri": "snd/custom_success.mp3" 

78 } 

79 assert skills_store.bus.message_types[-1] == "mycroft.audio.play_sound" 

80 

81 

82def test_pip_install_no_packages(skills_store): 

83 # TODO: This method should be refactored in 0.1.0 for easier unit testing 

84 skills_store.play_error_sound = Mock() 

85 res = skills_store.pip_install([]) 

86 assert res is False 

87 skills_store.play_error_sound.assert_called_once() 

88 

89 

90def test_pip_install_no_constraints(skills_store): 

91 skills_store.play_error_sound = Mock() 

92 res = skills_store.pip_install(["foo", "bar"], constraints="not/real") 

93 assert res is False 

94 skills_store.play_error_sound.assert_called_once() 

95 

96 

97def test_pip_install_happy_path(): 

98 # TODO: This method should be refactored in 0.1.0 for easier unit testing 

99 assert True 

100 

101 

102def test_pip_uninstall_no_packages(skills_store): 

103 # TODO: This method should be refactored in 0.1.0 for easier unit testing 

104 skills_store.play_error_sound = Mock() 

105 res = skills_store.pip_uninstall([]) 

106 assert res is False 

107 skills_store.play_error_sound.assert_called_once() 

108 

109 

110def test_pip_uninstall_no_constraints(skills_store): 

111 skills_store.play_error_sound = Mock() 

112 res = skills_store.pip_uninstall(["foo", "bar"], constraints="not/real") 

113 assert res is False 

114 skills_store.play_error_sound.assert_called_once() 

115 

116 

117def test_pip_uninstall_happy_path(): 

118 # TODO: This method should be refactored in 0.1.0 for easier unit testing 

119 assert True 

120 

121 

122def test_validate_skill(skills_store): 

123 assert skills_store.validate_skill("https://github.com/openvoiceos/skill-foo") is True 

124 assert skills_store.validate_skill("https://gitlab.com/foo/skill-bar") is False 

125 assert skills_store.validate_skill("literally-anything-else") is False 

126 

127 

128@pytest.mark.parametrize('skills_store', [{"allow_pip": False}], indirect=True) 

129def test_handle_install_skill_not_allowed(skills_store): 

130 skills_store.play_error_sound = Mock() 

131 skills_store.validate_skill = Mock() 

132 skills_store.handle_install_skill(Message(msg_type="test", data={})) 

133 skills_store.play_error_sound.assert_called_once() 

134 assert skills_store.bus.message_types[-1] == "ovos.skills.install.failed" 

135 assert skills_store.bus.message_data[-1] == {"error": "pip disabled in mycroft.conf"} 

136 skills_store.validate_skill.assert_not_called() 

137 

138 

139@pytest.mark.parametrize('skills_store', [{"allow_pip": True}], indirect=True) 

140def test_handle_install_skill_not_from_github(skills_store): 

141 skills_store.play_error_sound = Mock() 

142 skills_store.handle_install_skill(Message(msg_type="test", data={"url": "beautifulsoup4"})) 

143 skills_store.play_error_sound.assert_called_once() 

144 assert skills_store.bus.message_types[-1] == "ovos.skills.install.failed" 

145 assert skills_store.bus.message_data[-1] == {"error": "skill url validation failed"} 

146 

147 

148@pytest.mark.parametrize('skills_store', [{"allow_pip": True}], indirect=True) 

149def test_handle_install_skill_from_github(skills_store): 

150 skills_store.play_error_sound = Mock() 

151 skills_store.pip_install = Mock(return_value=True) 

152 skills_store.handle_install_skill( 

153 Message(msg_type="test", data={"url": "https://github.com/OpenVoiceOS/skill-foo"})) 

154 skills_store.play_error_sound.assert_not_called() 

155 skills_store.pip_install.assert_called_once_with(["git+https://github.com/OpenVoiceOS/skill-foo"]) 

156 assert skills_store.bus.message_types[-1] == "ovos.skills.install.complete" 

157 assert skills_store.bus.message_data[-1] == {} 

158 

159 

160@pytest.mark.parametrize('skills_store', [{"allow_pip": True}], indirect=True) 

161def test_handle_install_skill_from_github_failure(skills_store): 

162 skills_store.play_error_sound = Mock() 

163 skills_store.pip_install = Mock(return_value=False) 

164 skills_store.handle_install_skill( 

165 Message(msg_type="test", data={"url": "https://github.com/OpenVoiceOS/skill-foo"})) 

166 skills_store.play_error_sound.assert_not_called() 

167 skills_store.pip_install.assert_called_once_with(["git+https://github.com/OpenVoiceOS/skill-foo"]) 

168 assert skills_store.bus.message_types[-1] == "ovos.skills.install.failed" 

169 

170 

171@pytest.mark.parametrize('skills_store', [{"allow_pip": False}], indirect=True) 

172def test_handle_uninstall_skill_not_allowed(skills_store): 

173 skills_store.play_error_sound = Mock() 

174 skills_store.handle_uninstall_skill(Message(msg_type="test", data={})) 

175 skills_store.play_error_sound.assert_called_once() 

176 assert skills_store.bus.message_types[-1] == "ovos.skills.uninstall.failed" 

177 assert skills_store.bus.message_data[-1] == {"error": "pip disabled in mycroft.conf"} 

178 

179 

180@pytest.mark.parametrize('skills_store', [{"allow_pip": True}], indirect=True) 

181def test_handle_uninstall_skill(skills_store): 

182 skills_store.play_error_sound = Mock() 

183 skills_store.handle_uninstall_skill(Message(msg_type="test", data={})) 

184 skills_store.play_error_sound.assert_called_once() 

185 assert skills_store.bus.message_types[-1] == "ovos.skills.uninstall.failed" 

186 assert skills_store.bus.message_data[-1] == {"error": "not implemented"} 

187 

188 

189@pytest.mark.parametrize('skills_store', [{"allow_pip": False}], indirect=True) 

190def test_handle_install_python_not_allowed(skills_store): 

191 skills_store.play_error_sound = Mock() 

192 skills_store.pip_install = Mock() 

193 skills_store.handle_install_python(Message(msg_type="test", data={})) 

194 skills_store.play_error_sound.assert_called_once() 

195 assert skills_store.bus.message_types[-1] == "ovos.pip.install.failed" 

196 assert skills_store.bus.message_data[-1] == {"error": "pip disabled in mycroft.conf"} 

197 skills_store.pip_install.assert_not_called() 

198 

199 

200@pytest.mark.parametrize('skills_store', [{"allow_pip": True}], indirect=True) 

201def test_handle_install_python_no_packages(skills_store): 

202 skills_store.pip_install = Mock() 

203 skills_store.handle_install_python(Message(msg_type="test", data={})) 

204 assert skills_store.bus.message_types[-1] == "ovos.pip.install.failed" 

205 assert skills_store.bus.message_data[-1] == {"error": "no packages to install"} 

206 skills_store.pip_install.assert_not_called() 

207 

208 

209@pytest.mark.parametrize('skills_store', [{"allow_pip": True}], indirect=True) 

210def test_handle_install_python_success(skills_store): 

211 skills_store.play_error_sound = Mock() 

212 skills_store.pip_install = Mock() 

213 packages = ["requests", "fastapi"] 

214 skills_store.handle_install_python(Message(msg_type="test", data={"packages": packages})) 

215 skills_store.play_error_sound.assert_not_called() 

216 skills_store.pip_install.assert_called_once_with(packages) 

217 assert skills_store.bus.message_types[-1] == "ovos.pip.install.complete" 

218 assert skills_store.bus.message_data[-1] == {} 

219 

220 

221@pytest.mark.parametrize('skills_store', [{"allow_pip": False}], indirect=True) 

222def test_handle_uninstall_python_not_allowed(skills_store): 

223 skills_store.play_error_sound = Mock() 

224 skills_store.pip_uninstall = Mock() 

225 skills_store.handle_uninstall_python(Message(msg_type="test", data={})) 

226 skills_store.play_error_sound.assert_called_once() 

227 assert skills_store.bus.message_types[-1] == "ovos.pip.uninstall.failed" 

228 assert skills_store.bus.message_data[-1] == {"error": "pip disabled in mycroft.conf"} 

229 skills_store.pip_uninstall.assert_not_called() 

230 

231 

232@pytest.mark.parametrize('skills_store', [{"allow_pip": True}], indirect=True) 

233def test_handle_uninstall_python_no_packages(skills_store): 

234 skills_store.pip_uninstall = Mock() 

235 skills_store.handle_uninstall_python(Message(msg_type="test", data={})) 

236 assert skills_store.bus.message_types[-1] == "ovos.pip.uninstall.failed" 

237 assert skills_store.bus.message_data[-1] == {"error": "no packages to install"} 

238 skills_store.pip_uninstall.assert_not_called() 

239 

240 

241@pytest.mark.parametrize('skills_store', [{"allow_pip": True}], indirect=True) 

242def test_handle_uninstall_python_success(skills_store): 

243 skills_store.play_error_sound = Mock() 

244 skills_store.pip_uninstall = Mock() 

245 packages = ["requests", "fastapi"] 

246 skills_store.handle_uninstall_python(Message(msg_type="test", data={"packages": packages})) 

247 skills_store.play_error_sound.assert_not_called() 

248 skills_store.pip_uninstall.assert_called_once_with(packages) 

249 assert skills_store.bus.message_types[-1] == "ovos.pip.uninstall.complete" 

250 assert skills_store.bus.message_data[-1] == {} 

251 

252 

253if __name__ == "__main__": 

254 pytest.main()