diff --git a/.github/workflows/skill_tests.yml b/.github/workflows/skill_tests.yml index 4e035d3..7cc9602 100644 --- a/.github/workflows/skill_tests.yml +++ b/.github/workflows/skill_tests.yml @@ -8,8 +8,11 @@ on: jobs: py_build_tests: uses: neongeckocom/.github/.github/workflows/python_build_tests.yml@master - # skill_unit_tests: # One day - # uses: neongeckocom/.github/.github/workflows/skill_tests.yml@master + skill_unit_tests: # One day + uses: neongeckocom/.github/.github/workflows/skill_tests.yml@master + with: + neon_versions: "[3.7, 3.8, 3.9, '3.10', '3.11']" + ovos_versions: "[3.7, 3.8, 3.9, '3.10', '3.11']" # skill_intent_tests: # uses: neongeckocom/.github/.github/workflows/skill_test_intents.yml@master skill_resource_tests: diff --git a/.gitignore b/.gitignore index 1683bdf..619ab7b 100644 --- a/.gitignore +++ b/.gitignore @@ -159,3 +159,5 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. .idea/ .vscode/* + +test/skill_fs/* \ No newline at end of file diff --git a/__init__.py b/__init__.py index 59fd779..2fea823 100644 --- a/__init__.py +++ b/__init__.py @@ -1,17 +1,21 @@ # pylint: disable=unused-import,missing-docstring,invalid-name import random from os import listdir -from os.path import dirname +from os.path import dirname, join from ovos_workshop.intents import IntentBuilder from ovos_workshop.decorators import skill_api_method, intent_handler from ovos_workshop.skills import OVOSSkill +from ovos_bus_client.apis.ocp import OCPInterface -from .stardate import StarDate -from .constants import SPICY_SOUNDS +from skill_easter_eggs.stardate import StarDate +from skill_easter_eggs.constants import SPICY_SOUNDS class EasterEggsSkill(OVOSSkill): + def initialize(self): + self.ocp = OCPInterface(bus=self.bus) # pylint: disable=attribute-defined-outside-init + @property def grandma_mode(self): return self.settings.get("grandma_mode_enabled", True) @@ -79,24 +83,31 @@ def handle_number_of_languages_intent(self, _): @intent_handler(IntentBuilder("portal_intent").require("portal_keyword").build()) def handle_portal_intent(self, _): - path, files = self.get_reference_files("/sounds/portal", "mp3") + path, files = self.get_reference_files("sounds/portal", "mp3") if len(files): mp3 = path + "/" + random.choice(files) - self.play_audio(mp3) + self._play_in_ocp(mp3, title="Portal Easter Egg") else: self.speak_dialog("bad_file") - def get_reference_files(self, path_ending, extension): - path = dirname(__file__) + path_ending + def get_reference_files(self, path_ending: str, extension: str): + """Get a list of files in a directory + + If grandma mode is enabled, filter out spicy sounds + path_ending: str, path to directory, should not start with / + extension: str, file extension to filter by + """ + path_ending = path_ending.strip("/") + path = join(dirname(__file__), path_ending) if self.grandma_mode: - files = [sound for sound in listdir(path) if f".{extension}" in sound and sound not in SPICY_SOUNDS] + files = [sound for sound in listdir(path) if f".{extension}" in sound and f"{path_ending}/{sound}" not in SPICY_SOUNDS] else: files = [sound for sound in listdir(path) if f".{extension}" in sound] return path, files @intent_handler(IntentBuilder("hal_intent").require("hal_keyword").build()) def handle_hal_intent(self, _): - path, files = self.get_reference_files("/sounds/hal", "mp3") + path, files = self.get_reference_files("sounds/hal", "mp3") if len(files): mp3 = path + "/" + random.choice(files) self.play_audio(mp3) @@ -106,7 +117,7 @@ def handle_hal_intent(self, _): @intent_handler(IntentBuilder("duke_nukem_intent").require("duke_nukem_keyword").build()) def handle_dukenukem_intent(self, _): if not self.grandma_mode: - path, files = self.get_reference_files("/sounds/dukenukem", "wav") + path, files = self.get_reference_files("sounds/dukenukem", "wav") if len(files): wav = path + "/" + random.choice(files) self.play_audio(wav) @@ -117,7 +128,7 @@ def handle_dukenukem_intent(self, _): @intent_handler(IntentBuilder("arnold_intent").require("arnold_keyword").build()) def handle_arnold_intent(self, _): - path, files = self.get_reference_files("/sounds/arnold", "wav") + path, files = self.get_reference_files("sounds/arnold", "wav") if len(files): wav = path + "/" + random.choice(files) self.play_audio(wav) @@ -126,7 +137,7 @@ def handle_arnold_intent(self, _): @intent_handler(IntentBuilder("bender_intent").require("bender_keyword").build()) def handle_bender_intent(self, _): - path, files = self.get_reference_files("/sounds/bender", "mp3") + path, files = self.get_reference_files("sounds/bender", "mp3") if len(files): mp3 = path + "/" + random.choice(files) self.play_audio(mp3) @@ -135,13 +146,28 @@ def handle_bender_intent(self, _): @intent_handler(IntentBuilder("glados_intent").require("glados_keyword").build()) def handle_glados_intent(self, _): - path, files = self.get_reference_files("/sounds/glados", "mp3") + path, files = self.get_reference_files("sounds/glados", "mp3") if len(files): mp3 = path + "/" + random.choice(files) - self.play_audio(mp3) + self._play_in_ocp(mp3, title="GlaDOS says...") else: self.speak_dialog("bad_file") @skill_api_method def get_display_date(self): return StarDate().getStardate() + + def _play_in_ocp(self, media, title="Easter Egg!"): + data = { + "match_confidence": 100, + "media_type": 1, # MediaType.AUDIO + "length": 0, + "uri": media, + "playback": 2, # PlaybackType.AUDIO + "image": "", + "bg_image": "", + "skill_icon": "", + "title": title, + "skill_id": self.skill_id, + } + self.ocp.play(tracks=[data]) diff --git a/requirements/requirements-dev.txt b/requirements/requirements-dev.txt new file mode 100644 index 0000000..0cc9c76 --- /dev/null +++ b/requirements/requirements-dev.txt @@ -0,0 +1,4 @@ +neon-minerva>=0.1.0 +ovos_plugin_manager +pytest +pytest-cov diff --git a/requirements.txt b/requirements/requirements.txt similarity index 70% rename from requirements.txt rename to requirements/requirements.txt index c442f05..d58ec26 100644 --- a/requirements.txt +++ b/requirements/requirements.txt @@ -1,3 +1,4 @@ python-dateutil ovos-utils~=0.0, >=0.0.38 ovos_workshop~=0.0, >=0.0.15 +ovos_bus_client~=0.0, >=0.0.6 diff --git a/setup.py b/setup.py index 55f5479..77f1581 100644 --- a/setup.py +++ b/setup.py @@ -92,7 +92,7 @@ def find_resource_files(): version=get_version(), url=f"https://github.com/OpenVoiceOS/{SKILL_NAME}", license="BSD-3-Clause", - install_requires=get_requirements("requirements.txt"), + install_requires=get_requirements("requirements/requirements.txt"), author="Jarbas", author_email="jarbas@openvoiceos.com", long_description=long_description, @@ -102,4 +102,5 @@ def find_resource_files(): package_data={SKILL_PKG: find_resource_files()}, include_package_data=True, entry_points={"ovos.plugin.skill": PLUGIN_ENTRY_POINT}, + extras_require={"test": get_requirements("requirements/requirements-dev.txt")} ) diff --git a/skill.json b/skill.json index 779b423..32e0c33 100644 --- a/skill.json +++ b/skill.json @@ -23,6 +23,7 @@ "requirements": { "python": [ "ovos-utils~=0.0, >=0.0.38", + "ovos_bus_client~=0.0, >=0.0.6", "ovos_workshop~=0.0, >=0.0.15", "python-dateutil" ], diff --git a/test/test_skill.py b/test/test_skill.py new file mode 100644 index 0000000..cede61c --- /dev/null +++ b/test/test_skill.py @@ -0,0 +1,202 @@ +# Pytest boilerplate +from genericpath import isdir +from json import dumps +from os.path import dirname, join +from os import environ, getenv, makedirs +from unittest.mock import Mock, patch +import shutil + +from ovos_plugin_manager.skills import find_skill_plugins +from ovos_utils.messagebus import FakeBus +import pytest + +from skill_easter_eggs import EasterEggsSkill +from skill_easter_eggs.constants import SPICY_SOUNDS + + +@pytest.fixture(scope="session") +def test_skill(test_skill_id="skill-easter-eggs.openvoiceos", bus=FakeBus()): + # Get test skill + bus.emitter = bus.ee + bus.run_forever() + skill_entrypoint = getenv("TEST_SKILL_ENTRYPOINT") + if not skill_entrypoint: + skill_entrypoints = list(find_skill_plugins().keys()) + assert test_skill_id in skill_entrypoints + skill_entrypoint = test_skill_id + + skill = EasterEggsSkill(skill_id=test_skill_id, bus=bus) + skill.speak = Mock() + skill.speak_dialog = Mock() + skill.play_audio = Mock() + yield skill + shutil.rmtree(join(dirname(__file__), "skill_fs"), ignore_errors=False) + + +@pytest.fixture +def reset_skill_mocks(test_skill): + # Reset mocks before each test + test_skill.speak.reset_mock() + test_skill.speak_dialog.reset_mock() + test_skill.play_audio.reset_mock() + yield + + +class TestEasterEggSkill: + test_fs = join(dirname(__file__), "skill_fs") + data_dir = join(test_fs, "data") + conf_dir = join(test_fs, "config") + environ["XDG_DATA_HOME"] = data_dir + environ["XDG_CONFIG_HOME"] = conf_dir + if not isdir(test_fs): + makedirs(data_dir) + makedirs(conf_dir) + + with open(join(conf_dir, "mycroft.conf"), "w", encoding="utf-8") as f: + f.write(dumps({"Audio": {"backends": {"ocp": {"active": True}}}})) + + def test_grandma_mode_set_by_default(self, test_skill): + assert test_skill.grandma_mode is True + + def test_ocp_api_available(self, test_skill): + assert test_skill.ocp is not None + + def test_ocp_api_unavailable_when_ocp_is_disabled(self, test_skill): + # TODO: Fully implement + assert True + + def test_handle_grandma_mode(self, test_skill): + test_skill.handle_grandma_mode(None) + test_skill.speak.assert_called_once_with("Ok, we'll tone it down a bit.") + assert test_skill.settings["grandma_mode_enabled"] is True + + def test_handle_adult_mode(self, test_skill): + # TODO: Fully implement + assert True + + def test_handle_stardate_intent(self, test_skill): + test_skill.handle_stardate_intent(None) + test_skill.speak_dialog.assert_called_once_with( + "stardate", {"stardate": test_skill._create_spoken_stardate()} + ) + + def test_create_spoken_stardate(self, test_skill): + # TODO: Fully implement + assert True + + def test_handle_pod_intent(self, test_skill): + # TODO: Fully implement + assert True + + def test_handle_robotic_laws_intent(self, test_skill): + # TODO: Fully implement + assert True + + def test_handle_rock_paper_scissors_lizard_spock_intent(self, test_skill): + # TODO: Fully implement + assert True + + def test_handle_number_of_languages_intent(self, test_skill): + # TODO: Fully implement + assert True + + def test_handle_portal_intent(self, test_skill): + with patch("skill_easter_eggs.EasterEggsSkill._play_in_ocp") as mock_ocp_play: + test_skill.handle_portal_intent(None) + mock_ocp_play.assert_called_once() + + def test_get_reference_files_grandma_mode(self, test_skill): + spicy_arnold_sounds = [ + gubernator.replace("sounds/arnold/", "") + for gubernator in SPICY_SOUNDS + if gubernator.startswith("sounds/arnold") + ] + with patch("skill_easter_eggs.EasterEggsSkill.grandma_mode", True): + _, arnold_safe = test_skill.get_reference_files( + "/sounds/arnold", extension="wav" + ) + for spicy_arnold in spicy_arnold_sounds: + assert spicy_arnold not in arnold_safe + with patch("skill_easter_eggs.EasterEggsSkill.grandma_mode", False): + _, arnold_spicy = test_skill.get_reference_files( + "sounds/arnold", extension="wav" + ) + for spicy_arnold in [ + gubernator.replace("sounds/arnold/", "") + for gubernator in SPICY_SOUNDS + if gubernator.startswith("sounds/arnold") + ]: + assert spicy_arnold in arnold_spicy + + def test_handle_hal_intent(self, test_skill): + # TODO: Fully implement + assert True + + def test_handle_dukenukem_intent(self, test_skill): + # TODO: Fully implement + assert True + + def test_handle_handle_arnold_intent(self, test_skill): + # TODO: Fully implement + assert True + + def test_handle_bender_intent(self, test_skill): + # TODO: Fully implement + assert True + + def test_handle_glados_intent(self, test_skill): + # TODO: Fully implement + assert True + + def test_get_display_date(self, test_skill): + # TODO: Fully implement + assert True + + def test_play_in_ocp(self, test_skill): + with open(join(self.conf_dir, "mycroft.conf"), "w", encoding="utf-8") as f: + f.write(dumps({"Audio": {"backends": {"ocp": {"active": True}}}})) + media_path = "~/sounds/test.mp3" + + with patch("ovos_bus_client.apis.ocp.OCPInterface.play") as mock_ocp_play: + test_skill._play_in_ocp(media_path) + mock_ocp_play.assert_called_once_with( + tracks=[ + { + "match_confidence": 100, + "media_type": 1, + "length": 0, + "uri": media_path, + "playback": 2, + "image": "", + "bg_image": "", + "skill_icon": "", + "title": "Easter Egg!", + "skill_id": test_skill.skill_id, + } + ] + ) + + def test_play_in_ocp_custom_title(self, test_skill): + media_path = "~/sounds/test.mp3" + test_skill.ocp = Mock() + test_skill._play_in_ocp(media=media_path, title="GladOS says...") + test_skill.ocp.play.assert_called_once_with( + tracks=[ + { + "match_confidence": 100, + "media_type": 1, + "length": 0, + "uri": media_path, + "playback": 2, + "image": "", + "bg_image": "", + "skill_icon": "", + "title": "GladOS says...", + "skill_id": test_skill.skill_id, + } + ] + ) + + +if __name__ == "__main__": + pytest.main() diff --git a/test/test_stardate.py b/test/test_stardate.py new file mode 100644 index 0000000..1f0b0e8 --- /dev/null +++ b/test/test_stardate.py @@ -0,0 +1,3 @@ +# TODO: Implement tests for stardate.py +def test_stardate(): + assert True