Merge pull request #2459 from forslund/test/common-play-skills

Common play skill tests
pull/2470/head
Åke 2020-02-07 08:33:54 +01:00 committed by GitHub
commit 74c6a483fd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 384 additions and 75 deletions

View File

@ -12,21 +12,19 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
import time
from os.path import abspath
from mycroft.messagebus.message import Message
def ensure_uri(s):
"""
Interprete paths as file:// uri's
"""Interprete paths as file:// uri's.
Args:
s: string to be checked
Arguments:
s: string to be checked
Returns:
if s is uri, s is returned otherwise file:// is prepended
Returns:
if s is uri, s is returned otherwise file:// is prepended
"""
if isinstance(s, str):
if '://' not in s:
@ -43,33 +41,25 @@ def ensure_uri(s):
class AudioService:
"""
AudioService class for interacting with the audio subsystem
"""AudioService class for interacting with the audio subsystem
Arguments:
bus: Mycroft messagebus connection
Arguments:
bus: Mycroft messagebus connection
"""
def __init__(self, bus):
self.bus = bus
self.bus.on('mycroft.audio.service.track_info_reply',
self._track_info)
self.info = None
def _track_info(self, message=None):
"""
Handler for catching returning track info
"""
self.info = message.data
def queue(self, tracks=None):
""" Queue up a track to playing playlist.
"""Queue up a track to playing playlist.
Args:
tracks: track uri or list of track uri's
Arguments:
tracks: track uri or list of track uri's
Each track can be added as a tuple with (uri, mime)
to give a hint of the mime type to the system
"""
tracks = tracks or []
if isinstance(tracks, str):
if isinstance(tracks, (str, tuple)):
tracks = [tracks]
elif not isinstance(tracks, list):
raise ValueError
@ -78,15 +68,15 @@ class AudioService:
data={'tracks': tracks}))
def play(self, tracks=None, utterance=None, repeat=None):
""" Start playback.
"""Start playback.
Args:
tracks: track uri or list of track uri's
Each track can be added as a tuple with (uri, mime)
to give a hint of the mime type to the system
utterance: forward utterance for further processing by the
audio service.
repeat: if the playback should be looped
Arguments:
tracks: track uri or list of track uri's
Each track can be added as a tuple with (uri, mime)
to give a hint of the mime type to the system
utterance: forward utterance for further processing by the
audio service.
repeat: if the playback should be looped
"""
repeat = repeat or False
tracks = tracks or []
@ -102,31 +92,30 @@ class AudioService:
'repeat': repeat}))
def stop(self):
""" Stop the track. """
"""Stop the track."""
self.bus.emit(Message('mycroft.audio.service.stop'))
def next(self):
""" Change to next track. """
"""Change to next track."""
self.bus.emit(Message('mycroft.audio.service.next'))
def prev(self):
""" Change to previous track. """
"""Change to previous track."""
self.bus.emit(Message('mycroft.audio.service.prev'))
def pause(self):
""" Pause playback. """
"""Pause playback."""
self.bus.emit(Message('mycroft.audio.service.pause'))
def resume(self):
""" Resume paused playback. """
"""Resume paused playback."""
self.bus.emit(Message('mycroft.audio.service.resume'))
def seek(self, seconds=1):
"""
seek X seconds
"""Seek X seconds.
Args:
seconds (int): number of seconds to seek, if negative rewind
Arguments:
seconds (int): number of seconds to seek, if negative rewind
"""
if seconds < 0:
self.seek_backward(abs(seconds))
@ -134,42 +123,37 @@ class AudioService:
self.seek_forward(seconds)
def seek_forward(self, seconds=1):
"""
skip ahead X seconds
"""Skip ahead X seconds.
Args:
seconds (int): number of seconds to skip
Arguments:
seconds (int): number of seconds to skip
"""
self.bus.emit(Message('mycroft.audio.service.seek_forward',
{"seconds": seconds}))
def seek_backward(self, seconds=1):
"""
rewind X seconds
"""Rewind X seconds
Args:
seconds (int): number of seconds to rewind
Arguments:
seconds (int): number of seconds to rewind
"""
self.bus.emit(Message('mycroft.audio.service.seek_backward',
{"seconds": seconds}))
def track_info(self):
""" Request information of current playing track.
"""Request information of current playing track.
Returns:
Dict with track info.
Returns:
Dict with track info.
"""
self.info = None
self.bus.emit(Message('mycroft.audio.service.track_info'))
wait = 5.0
while self.info is None and wait >= 0:
time.sleep(0.1)
wait -= 0.1
return self.info or {}
info = self.bus.wait_for_response(
Message('mycroft.audio.service.track_info'),
reply_type='mycroft.audio.service.track_info_reply',
timeout=5)
return info.data if info else {}
def available_backends(self):
""" Return available audio backends.
"""Return available audio backends.
Returns:
dict with backend names as keys
@ -180,4 +164,5 @@ class AudioService:
@property
def is_playing(self):
"""True if the audioservice is playing, else False."""
return self.track_info() != {}

View File

@ -52,7 +52,8 @@ class CommonPlaySkill(MycroftSkill, ABC):
# with a translatable name in their initialize() method.
def bind(self, bus):
""" Overrides the normal bind method.
"""Overrides the normal bind method.
Adds handlers for play:query and play:start messages allowing
interaction with the playback control skill.
@ -66,6 +67,7 @@ class CommonPlaySkill(MycroftSkill, ABC):
self.add_event('play:start', self.__handle_play_start)
def __handle_play_query(self, message):
"""Query skill if it can start playback from given phrase."""
search_phrase = message.data["phrase"]
# First, notify the requestor that we are attempting to handle
@ -94,11 +96,19 @@ class CommonPlaySkill(MycroftSkill, ABC):
"searching": False}))
def __calc_confidence(self, match, phrase, level):
# "play pandora"
# "play pandora is my girlfriend"
# "play tom waits on pandora"
"""Translate confidence level and match to a 0-1 value.
# Assume the more of the words that get consumed, the better the match
"play pandora"
"play pandora is my girlfriend"
"play tom waits on pandora"
Assume the more of the words that get consumed, the better the match
Arguments:
match (str): Matching string
phrase (str): original input phrase
level (CPSMatchLevel): match level
"""
consumed_pct = len(match.split()) / len(phrase.split())
if consumed_pct > 1.0:
consumed_pct = 1.0 / consumed_pct # deal with over/under-matching
@ -123,6 +133,7 @@ class CommonPlaySkill(MycroftSkill, ABC):
return 0.0 # should never happen
def __handle_play_start(self, message):
"""Bus handler for starting playback using the skill."""
if message.data["skill_id"] != self.skill_id:
# Not for this skill!
return
@ -142,8 +153,7 @@ class CommonPlaySkill(MycroftSkill, ABC):
self.CPS_start(phrase, data)
def CPS_play(self, *args, **kwargs):
"""
Begin playback of a media file or stream
"""Begin playback of a media file or stream
Normally this method will be invoked with somthing like:
self.CPS_play(url)
@ -160,6 +170,7 @@ class CommonPlaySkill(MycroftSkill, ABC):
self.audioservice.play(*args, **kwargs)
def stop(self):
"""Stop anything playing on the audioservice."""
if self.audioservice.is_playing:
self.audioservice.stop()
return True
@ -172,11 +183,9 @@ class CommonPlaySkill(MycroftSkill, ABC):
# act as a CommonPlay Skill
@abstractmethod
def CPS_match_query_phrase(self, phrase):
"""
Analyze phrase to see if it is a play-able phrase with this
skill.
"""Analyze phrase to see if it is a play-able phrase with this skill.
Args:
Arguments:
phrase (str): User phrase uttered after "Play", e.g. "some music"
Returns:
@ -204,10 +213,9 @@ class CommonPlaySkill(MycroftSkill, ABC):
@abstractmethod
def CPS_start(self, phrase, data):
"""
Begin playing whatever is specified in 'phrase'
"""Begin playing whatever is specified in 'phrase'
Args:
Arguments:
phrase (str): User phrase uttered after "Play", e.g. "some music"
data (dict): Callback data specified in match_query_phrase()
"""

View File

@ -0,0 +1,176 @@
from unittest import TestCase, mock
from mycroft.messagebus import Message
from mycroft.skills.audioservice import AudioService
class TestAudioServiceControls(TestCase):
def assertLastMessageTypeEqual(self, bus, msg_type):
message = bus.emit.call_args_list[-1][0][0]
self.assertEqual(message.msg_type, msg_type)
def setUp(self):
self.bus = mock.Mock(name='bus')
self.audioservice = AudioService(self.bus)
def test_pause(self):
self.audioservice.pause()
self.assertLastMessageTypeEqual(self.bus,
'mycroft.audio.service.pause')
def test_resume(self):
self.audioservice.resume()
self.assertLastMessageTypeEqual(self.bus,
'mycroft.audio.service.resume')
def test_next(self):
self.audioservice.next()
self.assertLastMessageTypeEqual(self.bus, 'mycroft.audio.service.next')
def test_prev(self):
self.audioservice.prev()
self.assertLastMessageTypeEqual(self.bus, 'mycroft.audio.service.prev')
def test_stop(self):
self.audioservice.stop()
self.assertLastMessageTypeEqual(self.bus, 'mycroft.audio.service.stop')
def test_seek(self):
self.audioservice.seek()
message = self.bus.emit.call_args_list[-1][0][0]
self.assertEqual(message.msg_type,
'mycroft.audio.service.seek_forward')
self.assertEqual(message.data['seconds'], 1)
self.audioservice.seek(5)
message = self.bus.emit.call_args_list[-1][0][0]
self.assertEqual(message.msg_type,
'mycroft.audio.service.seek_forward')
self.assertEqual(message.data['seconds'], 5)
self.audioservice.seek(-5)
message = self.bus.emit.call_args_list[-1][0][0]
self.assertEqual(message.msg_type,
'mycroft.audio.service.seek_backward')
self.assertEqual(message.data['seconds'], 5)
class TestAudioServicePlay(TestCase):
def setUp(self):
self.bus = mock.Mock(name='bus')
self.audioservice = AudioService(self.bus)
def test_proper_uri(self):
self.audioservice.play('file:///hello_nasty.mp3')
message = self.bus.emit.call_args_list[-1][0][0]
self.assertEqual(message.msg_type, 'mycroft.audio.service.play')
self.assertEqual(message.data['tracks'], ['file:///hello_nasty.mp3'])
self.assertEqual(message.data['repeat'], False)
def test_path(self):
self.audioservice.play('/hello_nasty.mp3')
message = self.bus.emit.call_args_list[-1][0][0]
self.assertEqual(message.msg_type, 'mycroft.audio.service.play')
self.assertEqual(message.data['tracks'], ['file:///hello_nasty.mp3'])
self.assertEqual(message.data['repeat'], False)
def test_tuple(self):
"""Test path together with mimetype."""
self.audioservice.play(('/hello_nasty.mp3', 'audio/mp3'))
message = self.bus.emit.call_args_list[-1][0][0]
self.assertEqual(message.msg_type, 'mycroft.audio.service.play')
self.assertEqual(message.data['tracks'],
[('file:///hello_nasty.mp3', 'audio/mp3')])
self.assertEqual(message.data['repeat'], False)
def test_invalid(self):
"""Test play request with invalid type."""
with self.assertRaises(ValueError):
self.audioservice.play(12)
def test_extra_arguments(self):
"""Test sending along utterance and setting repeat."""
self.audioservice.play('/hello_nasty.mp3', 'on vlc', True)
message = self.bus.emit.call_args_list[-1][0][0]
self.assertEqual(message.msg_type, 'mycroft.audio.service.play')
self.assertEqual(message.data['tracks'], ['file:///hello_nasty.mp3'])
self.assertEqual(message.data['repeat'], True)
self.assertEqual(message.data['utterance'], 'on vlc')
class TestAudioServiceQueue(TestCase):
def setUp(self):
self.bus = mock.Mock(name='bus')
self.audioservice = AudioService(self.bus)
def test_uri(self):
self.audioservice.queue('file:///hello_nasty.mp3')
message = self.bus.emit.call_args_list[-1][0][0]
self.assertEqual(message.msg_type, 'mycroft.audio.service.queue')
self.assertEqual(message.data['tracks'], ['file:///hello_nasty.mp3'])
def test_path(self):
self.audioservice.queue('/hello_nasty.mp3')
message = self.bus.emit.call_args_list[-1][0][0]
self.assertEqual(message.msg_type, 'mycroft.audio.service.queue')
self.assertEqual(message.data['tracks'], ['file:///hello_nasty.mp3'])
def test_tuple(self):
self.audioservice.queue(('/hello_nasty.mp3', 'audio/mp3'))
message = self.bus.emit.call_args_list[-1][0][0]
self.assertEqual(message.msg_type, 'mycroft.audio.service.queue')
self.assertEqual(message.data['tracks'],
[('file:///hello_nasty.mp3', 'audio/mp3')])
def test_invalid(self):
with self.assertRaises(ValueError):
self.audioservice.queue(12)
class TestAudioServiceMisc(TestCase):
def test_lifecycle(self):
bus = mock.Mock(name='bus')
audioservice = AudioService(bus)
self.assertEqual(audioservice.bus, bus)
def test_available_backends(self):
bus = mock.Mock(name='bus')
audioservice = AudioService(bus)
available_backends = {
'simple': {
'suported_uris': ['http', 'file'],
'default': True,
'remote': False
}
}
bus.wait_for_response.return_value = Message('test_msg',
available_backends)
response = audioservice.available_backends()
self.assertEqual(available_backends, response)
# Check no response behaviour
bus.wait_for_response.return_value = None
response = audioservice.available_backends()
self.assertEqual({}, response)
def test_track_info(self):
"""Test is_playing property."""
bus = mock.Mock(name='bus')
audioservice = AudioService(bus)
info = {'album': 'Hello Nasty',
'artist': 'Beastie Boys',
'name': 'Intergalactic'
}
bus.wait_for_response.return_value = Message('test_msg', info)
self.assertEqual(audioservice.track_info(), info)
bus.wait_for_response.return_value = None
self.assertEqual(audioservice.track_info(), {})
def test_is_playing(self):
"""Test is_playing property."""
bus = mock.Mock(name='bus')
audioservice = AudioService(bus)
audioservice.track_info = mock.Mock()
audioservice.track_info.return_value = {'track': 'one cool song'}
self.assertTrue(audioservice.is_playing)
audioservice.track_info.return_value = {}
self.assertFalse(audioservice.is_playing)

View File

@ -0,0 +1,140 @@
from unittest import TestCase, mock
from mycroft.messagebus import Message
from mycroft.skills.common_play_skill import CommonPlaySkill, CPSMatchLevel
from mycroft.skills.audioservice import AudioService
class AnyCallable:
"""Class matching any callable.
Useful for assert_called_with arguments.
"""
def __eq__(self, other):
return callable(other)
class TestCommonPlay(TestCase):
def setUp(self):
self.skill = CPSTest()
self.bus = mock.Mock(name='bus')
self.skill.bind(self.bus)
self.audioservice = mock.Mock(name='audioservice')
self.skill.audioservice = self.audioservice
def test_lifecycle(self):
skill = CPSTest()
bus = mock.Mock(name='bus')
skill.bind(bus)
self.assertTrue(isinstance(skill.audioservice, AudioService))
bus.on.assert_any_call('play:query', AnyCallable())
bus.on.assert_any_call('play:start', AnyCallable())
skill.shutdown()
def test_handle_start_playback(self):
"""Test common play start method."""
self.skill.audioservice.is_playing = True
start_playback = self.bus.on.call_args_list[-1][0][1]
phrase = 'Don\'t open until doomsday'
start_playback(Message('play:start', data={'phrase': phrase,
'skill_id': 'asdf'}))
self.skill.CPS_start.assert_not_called()
self.bus.emit.reset_mock()
start_playback(Message('play:start',
data={'phrase': phrase,
'skill_id': self.skill.skill_id}))
self.audioservice.stop.assert_called_once_with()
self.skill.CPS_start.assert_called_once_with(phrase, None)
def test_cps_play(self):
"""Test audioservice play helper."""
self.skill.play_service_string = 'play on godzilla'
self.skill.CPS_play(['looking_for_freedom.mp3'],
utterance='play on mothra')
self.audioservice.play.assert_called_once_with(
['looking_for_freedom.mp3'], utterance='play on mothra')
self.audioservice.play.reset_mock()
# Assert that the utterance is injected
self.skill.CPS_play(['looking_for_freedom.mp3'])
self.audioservice.play.assert_called_once_with(
['looking_for_freedom.mp3'], utterance='play on godzilla')
def test_stop(self):
"""Test default reaction to stop command."""
self.audioservice.is_playing = False
self.assertFalse(self.skill.stop())
self.audioservice.is_playing = True
self.assertTrue(self.skill.stop())
class TestCPSQuery(TestCase):
def setUp(self):
self.skill = CPSTest()
self.bus = mock.Mock(name='bus')
self.skill.bind(self.bus)
self.audioservice = mock.Mock(name='audioservice')
self.skill.audioservice = self.audioservice
self.query_phrase = self.bus.on.call_args_list[-2][0][1]
def test_handle_play_query_no_match(self):
"""Test common play match when no match is found."""
# Check Not matching queries
self.skill.CPS_match_query_phrase.return_value = None
self.query_phrase(Message('play:query',
data={'phrase': 'Monster mash'}))
# Check that the skill replied that it was searching
extension = self.bus.emit.call_args_list[-2][0][0]
self.assertEqual(extension.data['phrase'], 'Monster mash')
self.assertEqual(extension.data['skill_id'], self.skill.skill_id)
self.assertEqual(extension.data['searching'], True)
# Assert that the skill reported that it couldn't find the phrase
response = self.bus.emit.call_args_list[-1][0][0]
self.assertEqual(response.data['phrase'], 'Monster mash')
self.assertEqual(response.data['skill_id'], self.skill.skill_id)
self.assertEqual(response.data['searching'], False)
def test_play_query_match(self):
"""Test common play match when a match is found."""
phrase = 'Don\'t open until doomsday'
self.skill.CPS_match_query_phrase.return_value = (phrase,
CPSMatchLevel.TITLE)
self.query_phrase(Message('play:query',
data={'phrase': phrase}))
# Assert that the skill reported the correct confidence
response = self.bus.emit.call_args_list[-1][0][0]
self.assertEqual(response.data['phrase'], phrase)
self.assertEqual(response.data['skill_id'], self.skill.skill_id)
self.assertAlmostEqual(response.data['conf'], 0.85)
# Partial phrase match
self.skill.CPS_match_query_phrase.return_value = ('until doomsday',
CPSMatchLevel.TITLE)
self. query_phrase(Message('play:query',
data={'phrase': phrase}))
# Assert that the skill reported the correct confidence
response = self.bus.emit.call_args_list[-1][0][0]
self.assertEqual(response.data['phrase'], phrase)
self.assertEqual(response.data['skill_id'], self.skill.skill_id)
self.assertAlmostEqual(response.data['conf'], 0.825)
class CPSTest(CommonPlaySkill):
"""Simple skill for testing the CommonPlaySkill"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.CPS_match_query_phrase = mock.Mock(name='match_phrase')
self.CPS_start = mock.Mock(name='start_playback')
self.skill_id = 'CPSTest'
def CPS_match_query_phrase(self, phrase):
pass
def CPS_start(self, data):
pass