From cefc580a2f38f14c0245c9d6a5acbaa67feaf8d4 Mon Sep 17 00:00:00 2001 From: Ben Connors Date: Thu, 26 Sep 2019 19:08:01 -0400 Subject: Various fixes; start implementing tests --- blc2/functions/audio.py | 23 +++++++------ blc2/functions/function.py | 5 ++- blc2/functions/scene.py | 26 +++++++++++---- blc2/workspace.py | 6 ++++ examples/workspace.xml | 4 +++ tests/conftest.py | 18 ++++++++++ tests/silence.m4a | Bin 0 -> 2130 bytes tests/test_functions_audio.py | 36 ++++++++++++++++++++ tests/test_functions_scene.py | 76 ++++++++++++++++++++++++++++++++++++++++++ tests/test_topology.py | 55 ++++++++++++++++++++++++++++++ 10 files changed, 232 insertions(+), 17 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/silence.m4a create mode 100644 tests/test_functions_audio.py create mode 100644 tests/test_functions_scene.py create mode 100644 tests/test_topology.py diff --git a/blc2/functions/audio.py b/blc2/functions/audio.py index 0d59856..42cd3f6 100644 --- a/blc2/functions/audio.py +++ b/blc2/functions/audio.py @@ -66,6 +66,7 @@ class Audio(Function): raise ValueError("Fades must be nonnegative") if v != self._fade_out: + self._duration = max(0, self._duration+self._fade_out-v) self._fade_out = v self.w.function_changed(self) @@ -75,7 +76,7 @@ class Audio(Function): @property def audio_scope(self): - return self._audio_scope.union() + return self._audio_scope @property def actual_duration(self): @@ -85,16 +86,16 @@ class Audio(Function): def duration(self): return self._duration - def _set_duration(self, value): + def _set_duration(self, value, update=True): """Set the duration. This is called by Workspace when the duration for the file has been rechecked; do not call this elsewhere, duration is intended to be immutable from outside the library. """ - if value != self._duration: - self._duration = max(value-self._fade_out, 0) - self._actual_duration = value + self._duration = max(value-self._fade_out, 0) + self._actual_duration = value + if update: self.w.function_changed(self) def get_data(self): @@ -104,8 +105,10 @@ class Audio(Function): return None def render(self, t, data = None): + if t > self._actual_duration: + return (), (), None return ((), - ((self._audio_id, self._filename, 0, self.fade_in, max(0, self.duration-self.fade_out), self.fade_out),), + ((self._audio_id, self._filename, 0, self.fade_in, self._duration, self.fade_out),), None) @property @@ -117,11 +120,11 @@ class Audio(Function): def filename(self, value: str): """Set the current audio filename, updating the duration.""" if value is not None: - self.duration = self.w.get_audio_length(value) - self.audio_scope = frozenset(((value,),)) + self._set_duration(self.w.get_audio_length(value), update=False) + self._audio_scope = frozenset((value,)) else: - self.duration = 0 - self.audio_scope = frozenset() + self._set_duration(0, update=False) + self._audio_scope = frozenset() self._filename = value self._audio_id = hash((self._audio_id, self._filename)) diff --git a/blc2/functions/function.py b/blc2/functions/function.py index d210936..79d3b2b 100644 --- a/blc2/functions/function.py +++ b/blc2/functions/function.py @@ -21,7 +21,10 @@ class Function(XMLSerializable, metaclass=ABCMeta): Any change to the scope, audio scope, or actual duration of the function must be reported using ``Workspace.function_changed`` with the new values. This should also be - extended to any change in e.g. values for lighting and audio primitives. + extended to any change in e.g. values for lighting and audio primitives. + + It is an error to change Function.type or Function.fade_out_mode in user code. These + values are public for informational purposes. """ type: FUNCTION fade_out_mode: EXTERNAL diff --git a/blc2/functions/scene.py b/blc2/functions/scene.py index ddda1c5..c786287 100644 --- a/blc2/functions/scene.py +++ b/blc2/functions/scene.py @@ -26,18 +26,33 @@ class Scene(Function): same fashion. Alternatively, values can be updated in bulk using ``Scene.update`` or set in bulk with ``Scene.set``. """ - audio_scope = () - scope = frozenset() - values = frozenset() type = SCENE - def __init__(self, w, id_ = None, name = None, values: Mapping[Fixture.Channel, int] = None): + def __init__(self, w, id_ = None, name = None, values: Mapping[Fixture.Channel, int] = ()): super().__init__(w=w, id_=id_, name=name) self._values = {} self._render = () + self._scope = () self._update(values, changed=False) + @property + def scope(self): + return self._scope + + @property + def values(self): + """Return the scene's values.""" + return self._values.copy() + + @values.setter + def values(self, v): + self.set(v) + + @property + def audio_scope(self): + return frozenset() + @property def fade_in(self): return 0 @@ -56,8 +71,7 @@ class Scene(Function): def _update_render(self, changed=True): self._render = tuple(self._values.items()) - self.scope = frozenset(self._values.keys()) - self.values = self._render + self._scope = frozenset(self._values.keys()) if changed: self.w.function_changed(self) diff --git a/blc2/workspace.py b/blc2/workspace.py index 51b35af..55757d3 100644 --- a/blc2/workspace.py +++ b/blc2/workspace.py @@ -89,6 +89,7 @@ class Workspace(XMLSerializable): """ if f.id in self.fixtures: raise ValueError("A fixture with that ID already exists") + self._last_fixture_id = max(self._last_fixture_id, f.id) self.fixtures[f.id] = f def register_function(self, f: Function): @@ -98,6 +99,7 @@ class Workspace(XMLSerializable): """ if f.id in self.functions: raise ValueError("A function with that ID already exists") + self._last_function_id = max(self._last_function_id, f.id) self.functions[f.id] = f @property @@ -183,6 +185,8 @@ class Workspace(XMLSerializable): :param f: the changed function """ + if f.id not in self._change_callbacks: + return for callback in self._change_callbacks[f.id]: callback(f) @@ -191,6 +195,8 @@ class Workspace(XMLSerializable): This also handles removing the function from the Workspace. """ + if f.id not in self._change_callbacks: + return for callback in self._delete_callbacks[f.id]: callback(f) diff --git a/examples/workspace.xml b/examples/workspace.xml index 8be8bbe..a2be9e0 100644 --- a/examples/workspace.xml +++ b/examples/workspace.xml @@ -34,5 +34,9 @@ test.wav + + + + diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..71bd3ac --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,18 @@ +import datetime as dt + +import pytest + +from blc2.workspace import Workspace +from blc2.topology import Fixture + +@pytest.fixture +def ws(): + """Return a workspace.""" + return Workspace("", "" ,"", dt.datetime.now()) + +@pytest.fixture +def aws(): + """Return a more advanced workspace with 4 channels on 1 fixture.""" + w = Workspace("", "", "", dt.datetime.now()) + Fixture(w, id_=0, channel_count=4) + return w diff --git a/tests/silence.m4a b/tests/silence.m4a new file mode 100644 index 0000000..0775df6 Binary files /dev/null and b/tests/silence.m4a differ diff --git a/tests/test_functions_audio.py b/tests/test_functions_audio.py new file mode 100644 index 0000000..de6aa2a --- /dev/null +++ b/tests/test_functions_audio.py @@ -0,0 +1,36 @@ +import os + +from blc2.functions.audio import Audio + +def test_audio(aws): + a = Audio(aws) + + a.fade_out = 1000 + a.fade_in = 1000 + a.filename = "nonexistant" + + assert a.audio_scope == {"nonexistant"} + assert not a.scope + assert a.duration == 0 + assert a.actual_duration == 0 + + a.filename = "tests/silence.m4a" + assert a.audio_scope == {"tests/silence.m4a"} + assert a.duration == 2024 + assert a.actual_duration == 3024 + + a.fade_out = 500 + assert a.fade_out == 500 + assert a.duration == 2524 + assert a.actual_duration == 3024 + + lc, ac, _ = a.render(0) + assert not lc + assert len(ac) == 1 + assert ac[0][1:] == ("tests/silence.m4a", 0, 1000, 2524, 500) + + _, ac2, _ = a.render(3000) + assert ac2 == ac + + _, ac, _ = a.render(5000) + assert not ac diff --git a/tests/test_functions_scene.py b/tests/test_functions_scene.py new file mode 100644 index 0000000..7a5b78b --- /dev/null +++ b/tests/test_functions_scene.py @@ -0,0 +1,76 @@ +import pytest + +from blc2.functions.scene import Scene +from blc2.constants import INFTY + +def test_scene(aws): + """Test creation and modification of scenes.""" + c1, c2, c3, c4 = aws.fixtures[0].channels + + s1 = Scene(aws) + assert s1.scope == frozenset() + assert s1.scope == frozenset() + + values = {c1: 0, c2: 1, c3: 2, c4: 4} + s1.values = values + assert s1.values == values + assert s1.scope == frozenset(values.keys()) + + s1.update({c1: 10}) + assert s1.values[c1] == 10 + assert s1.scope == frozenset(values.keys()) + for c, v in values.items(): + if c == c1: + continue + assert v == s1.values[c] + + s1.set(values) + assert s1.values == values + assert s1.scope == frozenset(values.keys()) + + del s1[c1] + assert s1.scope == {c2, c3, c4} + assert c1 not in s1.values + assert s1[c1] is None + + s1[c1] = 10 + assert s1.scope == frozenset(values.keys()) + assert s1[c1] == 10 + + assert s1.duration == INFTY + assert s1.actual_duration == INFTY + assert s1.fade_in == 0 + assert s1.fade_out == 0 + assert not s1.audio_scope + + with pytest.raises(Exception): + s1.duration = 0 + + with pytest.raises(Exception): + s1.actual_duration = 0 + + with pytest.raises(Exception): + s1.fade_in = 0 + + with pytest.raises(Exception): + s1.fade_out = 0 + + with pytest.raises(Exception): + s1.scope = frozenset() + + with pytest.raises(Exception): + s1.audio_scope = frozenset() + + s1.set(values) + lc, ac, _ = s1.render(0) + assert ac == () + assert dict(lc) == values + + lc, ac, _ = s1.render(1234567890) + assert ac == () + assert dict(lc) == values + + s1[c1] = 10 + lc, ac, _ = s1.render(1) + assert dict(lc)[c1] == 10 + assert False not in (v == values[c] for c, v in lc if c != c1) diff --git a/tests/test_topology.py b/tests/test_topology.py new file mode 100644 index 0000000..0579d63 --- /dev/null +++ b/tests/test_topology.py @@ -0,0 +1,55 @@ +"""Module for testing topology behaviour.""" + +import pytest + +from blc2.topology import Fixture +from blc2.workspace import Workspace + +def test_fixture_create(ws): + """Test basic fixture creation.""" + f = Fixture(ws, id_=0, name="Test 1") + assert f.id in ws.fixtures + assert f.id == 0 + assert f.name == "Test 1" + assert not f.channels + + with pytest.raises(ValueError): + Fixture(ws, id_=0, name="Test 2") + + assert ws.fixtures[0] == f + assert len(ws.fixtures) == 1 + + f2 = Fixture(ws, name="Test 3") + assert f2.id is not None + assert ws.fixtures[f2.id] == f2 + assert len(ws.fixtures) == 2 + + f3 = Fixture(ws, channel_count = 3) + assert len(f3.channels) == 3 + +def test_fixture_channels(ws): + """Test fixture channel manipulations.""" + f = Fixture(ws, channel_count=3) + for i in range(3): + f.channels[i].address = (0, i) + channels = tuple(f.channels) + + with pytest.raises(ValueError): + f.channel_count = -1 + assert len(f.channels) == 3 + + ## Verify that the channels haven't been changed + for a, b in zip(f.channels, channels): + assert id(a) == id(b) + + f.channel_count = 2 + assert len(f.channels) == 2 + for a, b in zip(f.channels, channels[:2]): + assert id(a) == id(b) + + f.channel_count = 3 + assert len(f.channels) == 3 + for a, b in zip(f.channels, channels[:2]): + assert id(a) == id(b) + + assert id(f.channels[2]) != id(channels[2]) -- cgit v1.2.3