diff --git a/.gitignore b/.gitignore index 0e769db..7cde95b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ tags .swp .swo .swn +env diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..048d8d4 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,10 @@ +language: minimal +sudo: required +dist: xenial +services: + - docker +before_install: + - docker build -t playerctl-test . +script: + - docker run -it playerctl-test + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..97a3c15 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM ubuntu:19.04 + +WORKDIR /app + +RUN apt-get update && apt-get install -y \ + python3-pip \ + ninja-build \ + build-essential \ + libglib2.0-dev \ + libgirepository1.0-dev \ + gtk-doc-tools + +COPY requirements.txt . +RUN pip3 install -r requirements.txt + +ADD . /app + +RUN find -name __pycache__ | xargs rm -r || true +RUN meson --prefix=/usr build && ninja -C build && ninja -C build install +CMD ["dbus-run-session", "python3", "-m", "pytest"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2c7a5f8 --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +.PHONY: test +.DEFAULT_TARGET := test + +test: + dbus-run-session python3 -m pytest -sq + +docker-test: + docker build -t playerctl-test . + docker run -it playerctl-test + +format: + yapf -rip test diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..1ea6b80 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +timeout = 5 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2c343df --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +dbus-next==0.0.1 +meson +pytest +pytest-timeout +pytest-asyncio diff --git a/test/.gitignore b/test/.gitignore new file mode 100644 index 0000000..38ce427 --- /dev/null +++ b/test/.gitignore @@ -0,0 +1,123 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don’t work, or not +# install all needed dependencies. +#Pipfile.lock + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/mpris.py b/test/mpris.py new file mode 100644 index 0000000..82b745d --- /dev/null +++ b/test/mpris.py @@ -0,0 +1,174 @@ +from dbus_next.service import ServiceInterface, dbus_property, method, signal +from dbus_next import PropertyAccess, RequestNameReply +from dbus_next.aio import session_bus + +import asyncio + + +async def setup_buses(*names): + async def setup(name): + bus = await session_bus() + reply = await bus.request_name(f'org.mpris.MediaPlayer2.{name}') + assert reply == RequestNameReply.PRIMARY_OWNER + bus.export('/org/mpris/MediaPlayer2', MprisPlayer()) + return bus + + return await asyncio.gather(*(setup(name) for name in names)) + + +def get_interfaces(bus): + return bus._path_exports.get('/org/mpris/MediaPlayer2', []) + + +class MprisPlayer(ServiceInterface): + def __init__(self): + super().__init__('org.mpris.MediaPlayer2.Player') + self.reset() + + def reset(self): + # method calls + self.next_called = False + self.previous_called = False + self.pause_called = False + self.play_pause_called = False + self.stop_called = False + self.play_called = False + self.seek_called_with = None + self.set_position_called_with = None + self.open_uri_called_with = None + + # properties + self.playback_status = 'Stopped' + self.loop_status = 'None' + self.rate = 1.0 + self.shuffle = False + self.metadata = {} + self.volume = 1.0 + self.position = 0 + self.minimum_rate = 1.0 + self.maximum_rate = 1.0 + self.can_go_next = True + self.can_go_previous = True + self.can_play = True + self.can_pause = True + self.can_seek = True + self.can_control = True + + # signals + self.seeked_value = 0 + + @method() + def Next(self): + self.next_called = True + + @method() + def Previous(self): + self.previous_called = True + + @method() + def Pause(self): + self.pause_called = True + + @method() + def PlayPause(self): + self.play_pause_called = True + + @method() + def Stop(self): + self.stop_called = True + + @method() + def Play(self): + self.play_called = True + + @method() + def Seek(self, offset: 'x'): + self.seek_called_with = offset + + @method() + def SetPosition(self, track_id: 'o', position: 'x'): + self.set_position_called_with = (track_id, position) + + @method() + def OpenUri(self, uri: 's'): + self.open_uri_called_with = uri + + @signal() + def Seeked(self) -> 'x': + return self.seeked_value + + @dbus_property(access=PropertyAccess.READ) + def PlaybackStatus(self) -> 's': + return self.playback_status + + @dbus_property() + def LoopStatus(self) -> 's': + return self.loop_status + + @LoopStatus.setter + def LoopStatus(self, status: 's'): + self.loop_status = status + + @dbus_property() + def Rate(self) -> 'd': + return self.rate + + @Rate.setter + def Rate(self, rate: 'd'): + self.rate = rate + + @dbus_property() + def Shuffle(self) -> 'b': + return self.shuffle + + @Shuffle.setter + def Shuffle(self, shuffle: 'b'): + self.shuffle = shuffle + + @dbus_property(access=PropertyAccess.READ) + def Metadata(self) -> 'a{sv}': + return self.metadata + + @dbus_property() + def Volume(self) -> 'd': + return self.volume + + @Volume.setter + def Volume(self, volume: 'd'): + self.volume = volume + + @dbus_property(access=PropertyAccess.READ) + def Position(self) -> 'x': + return self.position + + @dbus_property(access=PropertyAccess.READ) + def MinimumRate(self) -> 'd': + return self.minimum_rate + + @dbus_property(access=PropertyAccess.READ) + def MaximumRate(self) -> 'd': + return self.maximum_rate + + @dbus_property(access=PropertyAccess.READ) + def CanGoNext(self) -> 'b': + return self.can_go_next + + @dbus_property(access=PropertyAccess.READ) + def CanGoPrevious(self) -> 'b': + return self.can_go_previous + + @dbus_property(access=PropertyAccess.READ) + def CanPlay(self) -> 'b': + return self.can_play + + @dbus_property(access=PropertyAccess.READ) + def CanPause(self) -> 'b': + return self.can_pause + + @dbus_property(access=PropertyAccess.READ) + def CanSeek(self) -> 'b': + return self.can_seek + + @dbus_property(access=PropertyAccess.READ) + def CanControl(self) -> 'b': + return self.can_control diff --git a/test/playerctl.py b/test/playerctl.py new file mode 100644 index 0000000..0db5699 --- /dev/null +++ b/test/playerctl.py @@ -0,0 +1,18 @@ +import asyncio + + +class CommandResult: + def __init__(self, stdout, stderr, ret): + self.stdout = stdout.decode().strip() + self.stderr = stderr.decode().strip() + self.ret = ret + + +async def playerctl(cmd): + proc = await asyncio.create_subprocess_shell( + f'playerctl {cmd}', + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE) + stdout, stderr = await proc.communicate() + await proc.wait() + return CommandResult(stdout, stderr, proc.returncode) diff --git a/test/test_basics.py b/test/test_basics.py new file mode 100644 index 0000000..3b8a0d4 --- /dev/null +++ b/test/test_basics.py @@ -0,0 +1,51 @@ +from .mpris import setup_buses +from .playerctl import playerctl + +import asyncio +import pytest + + +@pytest.mark.asyncio +async def test_basics(): + result = await playerctl('--help') + assert result.ret == 0, result.stderr + assert result.stdout + assert not result.stderr + + # with no players + result = await playerctl('--list-all') + assert result.ret == 0, result.stderr + assert not result.stdout + assert result.stderr + + result = await playerctl('--version') + assert result.ret == 0, result.stderr + assert result.stdout + assert not result.stderr + + commands = ('play', 'pause', 'play-pause', 'stop', 'next', 'previous', + 'position', 'position 5', 'volume', 'volume 0.5', 'status', + 'metadata', 'loop', 'loop None', 'shuffle', 'shuffle On', + 'open https://google.com') + + results = await asyncio.gather(*(playerctl(cmd) for cmd in commands)) + + for result in results: + assert result.ret == 1 + assert not result.stdout + assert result.stderr == 'No players found' + + +@pytest.mark.asyncio +async def test_list_names(): + [bus1, bus2, bus3] = await setup_buses('basics1', 'basics2', 'basics3') + + result = await playerctl('--list-all') + assert result.ret == 0, result.stderr + players = result.stdout.splitlines() + assert 'basics1' in players + assert 'basics2' in players + assert 'basics3' in players + + for bus in [bus1, bus2, bus3]: + bus.disconnect() diff --git a/test/test_commands.py b/test/test_commands.py new file mode 100644 index 0000000..eae731e --- /dev/null +++ b/test/test_commands.py @@ -0,0 +1,23 @@ +from .mpris import setup_buses, get_interfaces +from .playerctl import playerctl + +import asyncio +import pytest + + +@pytest.mark.asyncio +async def test_commands(): + [bus] = await setup_buses('commands') + [interface] = get_interfaces(bus) + + commands = ('play', 'pause', 'play-pause', 'stop', 'next', 'previous') + + def get_called(cmd): + return getattr(interface, f'{cmd.replace("-", "_")}_called') + + results = await asyncio.gather(*(playerctl(f'-p commands {cmd}') + for cmd in commands)) + + for i, result in enumerate(results): + cmd = commands[i] + assert get_called(cmd), f'{cmd} was not called: {result.stderr}'