2021-04-10 20:30:41 +00:00
|
|
|
from typing import Optional
|
2021-04-05 23:32:58 +00:00
|
|
|
from queue import Empty
|
|
|
|
import unittest
|
|
|
|
import multiprocessing
|
|
|
|
import time
|
2021-04-06 21:39:08 +00:00
|
|
|
import os
|
|
|
|
import json
|
2021-04-05 23:32:58 +00:00
|
|
|
|
|
|
|
from player import Player
|
|
|
|
from helpers.logging_manager import LoggingManager
|
2021-04-12 21:59:51 +00:00
|
|
|
from helpers.state_manager import StateManager
|
2021-04-10 20:30:41 +00:00
|
|
|
from helpers.os_environment import isMacOS
|
2021-04-08 19:53:51 +00:00
|
|
|
|
2021-04-05 23:32:58 +00:00
|
|
|
# How long to wait (by default) in secs for the player to respond.
|
|
|
|
TIMEOUT_MSG_MAX_S = 10
|
|
|
|
TIMEOUT_QUIT_S = 10
|
2021-04-06 21:39:08 +00:00
|
|
|
|
|
|
|
test_dir = dir_path = os.path.dirname(os.path.realpath(__file__)) + "/"
|
|
|
|
resource_dir = test_dir + "resources/"
|
2021-04-06 23:07:15 +00:00
|
|
|
|
2021-04-08 21:05:25 +00:00
|
|
|
|
2021-04-06 23:07:15 +00:00
|
|
|
# All because constant dicts are still mutable in python :/
|
|
|
|
def getPlanItem(length: int, weight: int):
|
2021-04-08 19:53:51 +00:00
|
|
|
if length not in [1, 2, 5]:
|
2021-04-06 23:07:15 +00:00
|
|
|
raise ValueError("Invalid length dummy planitem.")
|
|
|
|
# TODO: This assumes we're handling one channel where timeslotitemid is unique
|
|
|
|
item = {
|
|
|
|
"timeslotitemid": weight,
|
|
|
|
"managedid": str(length),
|
2021-04-08 19:53:51 +00:00
|
|
|
"filename": resource_dir + str(length) + "sec.mp3",
|
2021-04-06 23:07:15 +00:00
|
|
|
"weight": weight,
|
2021-04-08 19:53:51 +00:00
|
|
|
"title": str(length) + "sec",
|
|
|
|
"length": "00:00:0{}".format(length),
|
2021-04-06 23:07:15 +00:00
|
|
|
}
|
|
|
|
return item
|
|
|
|
|
2021-04-08 19:53:51 +00:00
|
|
|
|
2021-04-06 23:07:15 +00:00
|
|
|
def getPlanItemJSON(length: int, weight: int):
|
2021-04-10 20:30:41 +00:00
|
|
|
return str(json.dumps(getPlanItem(**locals())))
|
|
|
|
|
|
|
|
|
|
|
|
# All because constant dicts are still mutable in python :/
|
|
|
|
def getMarker(name: str, time: float, position: str, section: Optional[str] = None):
|
|
|
|
# Time is not validated here, to allow tests to check server response.
|
|
|
|
marker = {
|
|
|
|
"name": name, # User friendly name, eg. "Hit the vocals"
|
|
|
|
"time": time, # Position (secs) through item
|
|
|
|
"section": section, # for linking in loops, if none, assume intro, cue, outro based on "position"
|
|
|
|
"position": position, # start, mid, end
|
|
|
|
}
|
|
|
|
return marker
|
|
|
|
|
|
|
|
|
|
|
|
def getMarkerJSON(name: str, time: float, position: str, section: Optional[str] = None):
|
2021-04-12 21:59:51 +00:00
|
|
|
return json.dumps(getMarker(**locals()))
|
2021-04-06 23:07:15 +00:00
|
|
|
|
2021-04-08 19:53:51 +00:00
|
|
|
|
2021-04-05 23:32:58 +00:00
|
|
|
class TestPlayer(unittest.TestCase):
|
|
|
|
|
|
|
|
player: multiprocessing.Process
|
|
|
|
player_from_q: multiprocessing.Queue
|
|
|
|
player_to_q: multiprocessing.Queue
|
|
|
|
logger: LoggingManager
|
2021-04-12 21:59:51 +00:00
|
|
|
server_state: StateManager
|
2021-04-05 23:32:58 +00:00
|
|
|
|
|
|
|
# initialization logic for the test suite declared in the test module
|
|
|
|
# code that is executed before all tests in one test run
|
|
|
|
@classmethod
|
|
|
|
def setUpClass(cls):
|
|
|
|
cls.logger = LoggingManager("Test_Player")
|
2021-09-11 15:49:08 +00:00
|
|
|
cls.server_state = StateManager(
|
|
|
|
"BAPSicleServer", cls.logger, default_state={"tracklist_mode": "off"}
|
|
|
|
) # Mostly dummy here.
|
2021-04-05 23:32:58 +00:00
|
|
|
|
|
|
|
# clean up logic for the test suite declared in the test module
|
|
|
|
# code that is executed after all tests in one test run
|
|
|
|
@classmethod
|
|
|
|
def tearDownClass(cls):
|
|
|
|
pass
|
|
|
|
|
|
|
|
# initialization logic
|
|
|
|
# code that is executed before each test
|
|
|
|
def setUp(self):
|
|
|
|
self.player_from_q = multiprocessing.Queue()
|
|
|
|
self.player_to_q = multiprocessing.Queue()
|
2021-04-08 19:53:51 +00:00
|
|
|
self.player = multiprocessing.Process(
|
2021-09-11 15:49:08 +00:00
|
|
|
target=Player,
|
|
|
|
args=(-1, self.player_to_q, self.player_from_q, self.server_state),
|
2021-04-08 19:53:51 +00:00
|
|
|
)
|
2021-04-05 23:32:58 +00:00
|
|
|
self.player.start()
|
2021-04-08 19:53:51 +00:00
|
|
|
self._send_msg_wait_OKAY("CLEAR") # Empty any previous track items.
|
2021-04-06 23:07:15 +00:00
|
|
|
self._send_msg_wait_OKAY("STOP")
|
|
|
|
self._send_msg_wait_OKAY("UNLOAD")
|
|
|
|
self._send_msg_wait_OKAY("PLAYONLOAD:False")
|
|
|
|
self._send_msg_wait_OKAY("REPEAT:none")
|
|
|
|
self._send_msg_wait_OKAY("AUTOADVANCE:True")
|
2021-04-05 23:32:58 +00:00
|
|
|
|
|
|
|
# clean up logic
|
|
|
|
# code that is executed after each test
|
|
|
|
def tearDown(self):
|
|
|
|
# Try to kill it, waits the timeout.
|
|
|
|
if self._send_msg_and_wait("QUIT"):
|
|
|
|
self.player.join(timeout=TIMEOUT_QUIT_S)
|
|
|
|
self.logger.log.info("Player quit successfully.")
|
|
|
|
else:
|
|
|
|
self.logger.log.error("No response on teardown, terminating player.")
|
|
|
|
# It's brain dead :/
|
|
|
|
self.player.terminate()
|
|
|
|
|
|
|
|
def _send_msg(self, msg: str):
|
|
|
|
self.player_to_q.put("TEST:{}".format(msg))
|
|
|
|
|
2021-04-08 19:53:51 +00:00
|
|
|
def _wait_for_msg(
|
|
|
|
self, msg: str, sources_filter=["TEST"], timeout: int = TIMEOUT_MSG_MAX_S
|
|
|
|
):
|
2021-04-05 23:32:58 +00:00
|
|
|
elapsed = 0
|
|
|
|
got_anything = False
|
|
|
|
while elapsed < timeout:
|
|
|
|
try:
|
|
|
|
response: str = self.player_from_q.get_nowait()
|
|
|
|
if response:
|
2021-04-08 19:53:51 +00:00
|
|
|
self.logger.log.info(
|
|
|
|
"Received response: {}\nWas looking for {}:{}".format(
|
|
|
|
response, sources_filter, msg
|
|
|
|
)
|
|
|
|
)
|
2021-04-05 23:32:58 +00:00
|
|
|
got_anything = True
|
2021-04-08 19:53:51 +00:00
|
|
|
source = response[: response.index(":")]
|
2021-04-05 23:32:58 +00:00
|
|
|
if source in sources_filter:
|
2021-04-08 19:53:51 +00:00
|
|
|
return response[
|
2021-09-11 16:48:57 +00:00
|
|
|
len(source + ":" + msg) + 1:
|
2021-04-08 19:53:51 +00:00
|
|
|
] # +1 to remove trailing : on source.
|
2021-04-08 21:48:38 +00:00
|
|
|
except Empty:
|
2021-04-05 23:32:58 +00:00
|
|
|
pass
|
|
|
|
finally:
|
2021-04-07 19:16:01 +00:00
|
|
|
time.sleep(0.01)
|
|
|
|
elapsed += 0.01
|
2021-04-05 23:32:58 +00:00
|
|
|
return False if got_anything else None
|
|
|
|
|
2021-04-08 19:53:51 +00:00
|
|
|
def _send_msg_and_wait(
|
|
|
|
self, msg: str, sources_filter=["TEST"], timeout: int = TIMEOUT_MSG_MAX_S
|
|
|
|
):
|
2021-04-05 23:32:58 +00:00
|
|
|
self._send_msg(msg)
|
|
|
|
return self._wait_for_msg(msg, sources_filter, timeout)
|
|
|
|
|
2021-04-08 19:53:51 +00:00
|
|
|
def _send_msg_wait_OKAY(
|
|
|
|
self, msg: str, sources_filter=["TEST"], timeout: int = TIMEOUT_MSG_MAX_S
|
2021-04-12 21:59:51 +00:00
|
|
|
) -> Optional[str]:
|
2021-04-06 21:39:08 +00:00
|
|
|
response = self._send_msg_and_wait(msg, sources_filter, timeout)
|
|
|
|
|
|
|
|
self.assertTrue(response)
|
|
|
|
|
|
|
|
self.assertTrue(isinstance(response, str))
|
|
|
|
|
|
|
|
response = response.split(":", 1)
|
|
|
|
|
|
|
|
self.assertEqual(response[0], "OKAY")
|
|
|
|
|
|
|
|
if len(response) > 1:
|
|
|
|
return response[1]
|
|
|
|
return None
|
|
|
|
|
2021-04-05 23:32:58 +00:00
|
|
|
def test_player_running(self):
|
2021-04-06 21:39:08 +00:00
|
|
|
response = self._send_msg_wait_OKAY("STATUS")
|
|
|
|
|
|
|
|
self.assertTrue(response)
|
|
|
|
|
|
|
|
json_obj = json.loads(response)
|
|
|
|
|
|
|
|
self.assertTrue(json_obj["initialised"])
|
|
|
|
|
|
|
|
def test_player_play(self):
|
2021-04-06 23:07:15 +00:00
|
|
|
|
2021-04-08 19:53:51 +00:00
|
|
|
response = self._send_msg_wait_OKAY("ADD:" + getPlanItemJSON(2, 0))
|
2021-04-06 23:07:15 +00:00
|
|
|
|
2021-04-06 21:39:08 +00:00
|
|
|
# Should return nothing, just OKAY.
|
|
|
|
self.assertFalse(response)
|
|
|
|
|
|
|
|
# Check we can load the file
|
|
|
|
response = self._send_msg_wait_OKAY("LOAD:0")
|
|
|
|
self.assertFalse(response)
|
|
|
|
|
|
|
|
# Check we can play the file
|
|
|
|
response = self._send_msg_wait_OKAY("PLAY")
|
|
|
|
self.assertFalse(response)
|
|
|
|
|
|
|
|
time.sleep(1)
|
|
|
|
|
|
|
|
response = self._send_msg_wait_OKAY("STATUS")
|
|
|
|
|
|
|
|
self.assertTrue(response)
|
|
|
|
|
|
|
|
json_obj = json.loads(response)
|
|
|
|
|
|
|
|
self.assertTrue(json_obj["playing"])
|
|
|
|
|
|
|
|
# Check the file stops playing.
|
|
|
|
# TODO: Make sure replay / play on load not enabled.
|
|
|
|
time.sleep(2)
|
|
|
|
|
|
|
|
response = self._send_msg_wait_OKAY("STATUS")
|
2021-04-05 23:32:58 +00:00
|
|
|
|
|
|
|
self.assertTrue(response)
|
|
|
|
|
2021-04-06 21:39:08 +00:00
|
|
|
json_obj = json.loads(response)
|
|
|
|
|
|
|
|
self.assertFalse(json_obj["playing"])
|
|
|
|
|
2021-04-06 23:09:23 +00:00
|
|
|
# This test checks if the player progresses to the next item and plays on load.
|
|
|
|
def test_play_on_load(self):
|
2021-04-08 19:53:51 +00:00
|
|
|
self._send_msg_wait_OKAY("ADD:" + getPlanItemJSON(5, 0))
|
2021-04-06 23:09:23 +00:00
|
|
|
|
2021-04-08 19:53:51 +00:00
|
|
|
self._send_msg_wait_OKAY("ADD:" + getPlanItemJSON(5, 1))
|
2021-04-06 23:09:23 +00:00
|
|
|
|
|
|
|
self._send_msg_wait_OKAY("PLAYONLOAD:True")
|
|
|
|
|
|
|
|
self._send_msg_wait_OKAY("LOAD:0")
|
|
|
|
|
|
|
|
# We should be playing the first item.
|
|
|
|
response = self._send_msg_wait_OKAY("STATUS")
|
|
|
|
self.assertTrue(response)
|
|
|
|
json_obj = json.loads(response)
|
|
|
|
self.assertTrue(json_obj["playing"])
|
|
|
|
self.assertEqual(json_obj["loaded_item"]["weight"], 0)
|
|
|
|
|
2021-04-08 19:28:35 +00:00
|
|
|
time.sleep(5)
|
2021-04-06 23:09:23 +00:00
|
|
|
|
|
|
|
# Now we should be onto playing the second item.
|
|
|
|
response = self._send_msg_wait_OKAY("STATUS")
|
|
|
|
self.assertTrue(response)
|
|
|
|
json_obj = json.loads(response)
|
|
|
|
self.assertTrue(json_obj["playing"])
|
|
|
|
self.assertEqual(json_obj["loaded_item"]["weight"], 1)
|
|
|
|
|
2021-04-07 19:16:01 +00:00
|
|
|
# Okay, now stop. Test if play on load causes havok with auto advance.
|
|
|
|
self._send_msg_wait_OKAY("STOP")
|
|
|
|
self._send_msg_wait_OKAY("AUTOADVANCE:False")
|
|
|
|
self._send_msg_wait_OKAY("LOAD:0")
|
|
|
|
|
2021-04-08 19:28:35 +00:00
|
|
|
time.sleep(6)
|
2021-04-07 19:16:01 +00:00
|
|
|
|
|
|
|
# Now, we've not auto-advanced, but we've not loaded a new item.
|
|
|
|
# Therefore, we shouldn't have played a second time. Leave repeat-one for that.
|
|
|
|
response = self._send_msg_wait_OKAY("STATUS")
|
|
|
|
self.assertTrue(response)
|
|
|
|
json_obj = json.loads(response)
|
|
|
|
self.assertFalse(json_obj["playing"])
|
|
|
|
self.assertEqual(json_obj["loaded_item"]["weight"], 0)
|
|
|
|
|
2021-04-06 23:26:57 +00:00
|
|
|
# This test checks that the player repeats the first item without moving onto the second.
|
2021-04-06 23:16:53 +00:00
|
|
|
def test_repeat_one(self):
|
2021-04-08 19:53:51 +00:00
|
|
|
self._send_msg_wait_OKAY("ADD:" + getPlanItemJSON(5, 0))
|
2021-04-06 23:16:53 +00:00
|
|
|
# Add a second item to make sure we don't load this one when repeat one.
|
2021-04-08 19:53:51 +00:00
|
|
|
self._send_msg_wait_OKAY("ADD:" + getPlanItemJSON(5, 1))
|
2021-04-06 23:16:53 +00:00
|
|
|
|
|
|
|
# TODO Test without play on load? What's the behaviour here?
|
|
|
|
self._send_msg_wait_OKAY("PLAYONLOAD:True")
|
|
|
|
self._send_msg_wait_OKAY("REPEAT:one")
|
|
|
|
|
|
|
|
self._send_msg_wait_OKAY("LOAD:0")
|
|
|
|
|
2021-04-08 19:28:35 +00:00
|
|
|
time.sleep(0.5)
|
|
|
|
|
2021-04-06 23:16:53 +00:00
|
|
|
# Try 3 repeats to make sure.
|
|
|
|
for repeat in range(3):
|
|
|
|
# We should be playing the first item.
|
|
|
|
response = self._send_msg_wait_OKAY("STATUS")
|
|
|
|
self.assertTrue(response)
|
|
|
|
json_obj = json.loads(response)
|
|
|
|
self.assertTrue(json_obj["playing"])
|
|
|
|
# Check we're not playing the second item.
|
|
|
|
self.assertEqual(json_obj["loaded_item"]["weight"], 0)
|
|
|
|
|
2021-04-08 19:28:35 +00:00
|
|
|
time.sleep(5)
|
2021-04-06 23:09:23 +00:00
|
|
|
|
2021-04-06 23:26:57 +00:00
|
|
|
# This test checks that the player repeats all plan items before playing the first again.
|
|
|
|
def test_repeat_all(self):
|
|
|
|
# Add two items to repeat all between
|
2021-04-08 19:53:51 +00:00
|
|
|
self._send_msg_wait_OKAY("ADD:" + getPlanItemJSON(5, 0))
|
|
|
|
self._send_msg_wait_OKAY("ADD:" + getPlanItemJSON(5, 1))
|
2021-04-06 23:26:57 +00:00
|
|
|
|
|
|
|
# TODO Test without play on load? What's the behaviour here?
|
|
|
|
self._send_msg_wait_OKAY("PLAYONLOAD:True")
|
|
|
|
self._send_msg_wait_OKAY("REPEAT:all")
|
|
|
|
|
|
|
|
self._send_msg_wait_OKAY("LOAD:0")
|
|
|
|
|
2021-04-10 19:24:53 +00:00
|
|
|
time.sleep(1)
|
2021-04-06 23:26:57 +00:00
|
|
|
# Try 3 repeats to make sure.
|
|
|
|
for repeat in range(3):
|
|
|
|
# We should be playing the first item.
|
|
|
|
response = self._send_msg_wait_OKAY("STATUS")
|
|
|
|
self.assertTrue(response)
|
|
|
|
json_obj = json.loads(response)
|
|
|
|
self.assertTrue(json_obj["playing"])
|
|
|
|
self.assertEqual(json_obj["loaded_item"]["weight"], 0)
|
|
|
|
|
2021-04-08 19:28:35 +00:00
|
|
|
time.sleep(5)
|
2021-04-06 23:26:57 +00:00
|
|
|
|
|
|
|
# We should be playing the second item.
|
|
|
|
response = self._send_msg_wait_OKAY("STATUS")
|
|
|
|
self.assertTrue(response)
|
|
|
|
json_obj = json.loads(response)
|
|
|
|
self.assertTrue(json_obj["playing"])
|
|
|
|
self.assertEqual(json_obj["loaded_item"]["weight"], 1)
|
|
|
|
|
2021-04-08 19:28:35 +00:00
|
|
|
time.sleep(5)
|
2021-04-06 23:26:57 +00:00
|
|
|
|
2021-04-10 20:30:41 +00:00
|
|
|
# TODO: Test validation of trying to break this.
|
|
|
|
# TODO: Test cue behaviour.
|
|
|
|
def test_markers(self):
|
|
|
|
self._send_msg_wait_OKAY("ADD:" + getPlanItemJSON(5, 0))
|
|
|
|
self._send_msg_wait_OKAY("ADD:" + getPlanItemJSON(5, 1))
|
|
|
|
self._send_msg_wait_OKAY("ADD:" + getPlanItemJSON(5, 2))
|
|
|
|
|
|
|
|
self._send_msg_wait_OKAY("LOAD:2") # To test currently loaded marker sets.
|
|
|
|
|
|
|
|
markers = [
|
2021-09-11 16:48:57 +00:00
|
|
|
# Markers are stored as float, to compare against later,
|
|
|
|
# these must all be floats, despite int being supported.
|
2021-04-12 21:59:51 +00:00
|
|
|
getMarkerJSON("Intro Name", 2.0, "start", None),
|
2021-04-10 20:30:41 +00:00
|
|
|
getMarkerJSON("Cue Name", 3.14, "mid", None),
|
2021-04-12 21:59:51 +00:00
|
|
|
getMarkerJSON("Outro Name", 4.0, "end", None),
|
|
|
|
getMarkerJSON("Start Loop", 2.0, "start", "The Best Loop 1"),
|
|
|
|
getMarkerJSON("Mid Loop", 3.0, "mid", "The Best Loop 1"),
|
2021-04-10 20:30:41 +00:00
|
|
|
getMarkerJSON("End Loop", 3.5, "end", "The Best Loop 1"),
|
|
|
|
]
|
|
|
|
# Command, Weight?/itemid? (-1 is loaded), marker json (Intro at 2 seconds.)
|
2021-04-12 21:59:51 +00:00
|
|
|
self._send_msg_wait_OKAY("SETMARKER:0:" + markers[0])
|
|
|
|
self._send_msg_wait_OKAY("SETMARKER:0:" + markers[1])
|
|
|
|
self._send_msg_wait_OKAY("SETMARKER:1:" + markers[2])
|
|
|
|
self._send_msg_wait_OKAY("SETMARKER:-1:" + markers[3])
|
|
|
|
self._send_msg_wait_OKAY("SETMARKER:-1:" + markers[4])
|
|
|
|
self._send_msg_wait_OKAY("SETMARKER:-1:" + markers[5])
|
2021-04-10 20:30:41 +00:00
|
|
|
|
|
|
|
# Test we didn't completely break the player
|
|
|
|
response = self._send_msg_wait_OKAY("STATUS")
|
|
|
|
self.assertTrue(response)
|
|
|
|
json_obj = json.loads(response)
|
2021-04-12 21:59:51 +00:00
|
|
|
self.logger.log.warning(json_obj)
|
2021-04-10 20:30:41 +00:00
|
|
|
|
2021-04-12 21:59:51 +00:00
|
|
|
# time.sleep(1000000)
|
2021-04-10 20:30:41 +00:00
|
|
|
# Now test that all the markers we setup are present.
|
2021-04-12 21:59:51 +00:00
|
|
|
item = json_obj["show_plan"][0]
|
|
|
|
self.assertEqual(item["weight"], 0)
|
2021-09-11 15:49:08 +00:00
|
|
|
self.assertEqual(
|
|
|
|
item["intro"], 2.0
|
|
|
|
) # Backwards compat with basic Webstudio intro/cue/outro
|
2021-04-12 21:59:51 +00:00
|
|
|
self.assertEqual(item["cue"], 3.14)
|
2021-09-11 15:49:08 +00:00
|
|
|
self.assertEqual(
|
|
|
|
[json.dumps(item) for item in item["markers"]], markers[0:2]
|
|
|
|
) # Check the full marker configs match
|
2021-04-10 20:30:41 +00:00
|
|
|
|
|
|
|
item = json_obj["show_plan"][1]
|
2021-04-12 21:59:51 +00:00
|
|
|
self.assertEqual(item["weight"], 1)
|
|
|
|
self.assertEqual(item["outro"], 4.0)
|
|
|
|
self.assertEqual([json.dumps(item) for item in item["markers"]], [markers[2]])
|
2021-04-10 20:30:41 +00:00
|
|
|
|
|
|
|
# In this case, we want to make sure both the current and loaded items are updated
|
|
|
|
for item in [json_obj["show_plan"][2], json_obj["loaded_item"]]:
|
2021-04-12 21:59:51 +00:00
|
|
|
self.assertEqual(item["weight"], 2)
|
2021-09-11 16:48:57 +00:00
|
|
|
# This is a loop marker. It should not appear as a standard intro, outro or cue.
|
|
|
|
# Default of 0.0 should apply to all.
|
2021-04-12 21:59:51 +00:00
|
|
|
self.assertEqual(item["intro"], 0.0)
|
|
|
|
self.assertEqual(item["outro"], 0.0)
|
|
|
|
self.assertEqual(item["cue"], 0.0)
|
2021-09-11 15:49:08 +00:00
|
|
|
self.assertEqual(
|
|
|
|
[json.dumps(item) for item in item["markers"]], markers[3:]
|
|
|
|
)
|
2021-04-10 20:30:41 +00:00
|
|
|
|
2021-04-10 21:56:53 +00:00
|
|
|
# TODO: Now test editing/deleting them
|
|
|
|
|
2021-04-06 23:26:57 +00:00
|
|
|
|
2021-04-05 23:32:58 +00:00
|
|
|
# runs the unit tests in the module
|
2021-04-08 19:53:51 +00:00
|
|
|
if __name__ == "__main__":
|
2021-04-08 21:56:57 +00:00
|
|
|
# Fixes fork error.
|
|
|
|
if isMacOS():
|
|
|
|
multiprocessing.set_start_method("spawn", True)
|
2021-04-08 21:05:25 +00:00
|
|
|
unittest.main()
|