Move initial story test to use tox and pytest

This commit is contained in:
Jonathan Harker 2024-09-20 17:04:05 +12:00
parent 32e381e262
commit 1d8ec111ff
5 changed files with 256 additions and 70 deletions

0
setclass/__init__.py Normal file
View file

170
setclass/setclass.py Normal file
View file

@ -0,0 +1,170 @@
#!/usr/bin/env python3
class cache_property:
"""
Property that only computes its value once when first accessed, and caches the result.
"""
def __init__(self, function):
self.function = function
self.name = function.__name__
def __get__(self, obj, type=None) -> object:
obj.__dict__[self.name] = self.function(obj)
return obj.__dict__[self.name]
class SetClass(list):
"""
Musical set class, containing zero or more pitch classes.
"""
def __init__(self, *args: int, tonality: int = 12):
"""
Instantiate a Class Set with a series of integers. Each pitch class is "normalised" by
modulo the tonality; the set is sorted in ascending order; and values "rotated" until the
lowest value is 0. For a number of divisions of the octave other than the default (Western
harmony) value of 12, supply an integer value using the 'tonality' keyword. Example:
sc = SetClass(0, 7, 9)
sc = SetClass(0, 2, 3, tonality=7)
"""
self._tonality = tonality
# deduplicate arg values, modulo each by tonality
pitches = set()
for i in set({*args}):
try:
pitches.add(int(i) % tonality)
except ValueError:
raise ValueError("Requires integer arguments (use 10 and 11 for 'T' and 'E')")
# if necessary "rotate" so that the lowest non-zero interval is zero
if pitches:
n = min(pitches)
pitches = [i - n for i in pitches]
# in ascending order
for i in sorted(pitches):
super().append(i)
def __repr__(self) -> str:
"""Return the Python instance string representation."""
return f"SetClass{self.pitch_classes}".replace(' ', '')
def __hash__(self) -> int:
return sum([hash(i) for i in self.pitch_classes])
@property
def pitch_classes(self):
return list(self)
@cache_property
def tonality(self) -> int:
"""
Returns the number of (equal) divisions of the octave. The default value is 12, which
represents traditional Western chromatic harmony, the octave divided into twelve semitones.
"""
return self._tonality
@cache_property
def cardinality(self) -> int:
"""Returns the cardinality of the set class, i.e. the number of pitch classes."""
return len(self.pitch_classes)
@cache_property
def brightness(self) -> int:
"""Returns the brightness of the set class, defined as the sum of the pitch class values."""
return sum(self.pitch_classes)
@cache_property
def adjacency_intervals(self):
"""Adjacency intervals between the pitch classes, used for Leonard notation subscripts."""
if not self.pitch_classes:
return list()
intervals = list()
prev = None
for i in self.pitch_classes:
if prev is not None:
intervals.append(i - prev)
prev = i
intervals.append(self.tonality - prev)
return intervals
@cache_property
def versions(self):
"""
Returns all possible zero-normalised versions (clock rotations) of this set class,
sorted by brightness.
"""
# The empty set class has one version, itself
if not self.pitch_classes:
return [self]
versions = set()
for i in self.pitch_classes:
translate = self.tonality - i
versions.add(SetClass(*[j + translate for j in self.pitch_classes], tonality=self.tonality))
versions = list(versions)
versions.sort(key=lambda x: x.brightness)
return versions
@cache_property
def darkest_form(self):
"""
Returns the version with the smallest brightness value.
TODO: How to break a tie? Or return all matches in a tuple? Sorted by what?
"""
return self.versions[0] if self.versions else self
@cache_property
def brightest_form(self):
"""
Returns the version with the largest brightness value.
TODO: How to break a tie? Or return all matches in a tuple? Sorted by what?
"""
return self.versions[-1] if self.versions else self
@cache_property
def complement(self):
"""
Returns the set class containing all pitch classes absent in this one (rotated so the
smallest is 0).
"""
return SetClass(*[i for i in range(self.tonality) if i not in self.pitch_classes], tonality=self.tonality)
@cache_property
def dozenal_notation(self) -> str:
"""
If tonality is no greater than 12, return a string representation using Dozenal Society
characters '' for 10 and '' for 11 (the Pitman forms from Unicode 8.0 release, 2015).
"""
return f"{self}" if self.tonality > 12 else f"{self}".replace('10', '').replace('11', '')
@cache_property
def duodecimal_notation(self) -> str:
"""
If tonality is no greater than 12, replace 10 and 11 with 'T' and 'E'.
"""
return f"{self}" if self.tonality > 12 else f"{self}".replace('10', 'T').replace('11', 'E')
@cache_property
def leonard_notation(self) -> str:
"""
Returns a string representation of this set class using subscripts to denote the adjacency
intervals between the pitch classes instead of commas, and the overall brightness (sum of
pitch class values) denoted as a superscript. For example, Forte 5-20, {0,1,5,6,8} is
notated [01568]²
"""
# Return numbers as subscript and superscript strings:
def subscript(n: int) -> str:
return "".join(["₀₁₂₃₄₅₆₇₈₉"[int(i)] for i in str(n)])
def superscript(n: int) -> str:
return "".join(["⁰¹²³⁴⁵⁶⁷⁸⁹"[int(i)] for i in str(n)])
pitches = ""
for i in range(self.cardinality):
pitch = self.pitch_classes[i]
sub = subscript(self.adjacency_intervals[i])
pitches += f"{pitch}{sub}"
sup = superscript(self.brightness)
return f"[{pitches}]{sup}"

View file

View file

@ -0,0 +1,250 @@
from setclass.setclass import SetClass
# ----------------------------------------------------------------------------
# Functional tests
def test_zero_normalised_intervals():
a = SetClass(0, 1, 3)
assert a == SetClass(1, 2, 4)
assert a == SetClass(4, 5, 7)
def test_modulo_intervals():
a = SetClass(0, 1, 3)
assert a == SetClass(12, 13, 15) # modulo down
assert a == SetClass(-11, -9, -12) # modulo up
def test_negative_intervals():
a = SetClass(0, 9, 10)
assert a == SetClass(-3, -2, 0)
def test_immutable_intervals():
a = SetClass(0, 1, 3)
assert a == SetClass(0, 3, 1)
assert a == SetClass(3, 0, 1)
assert a == SetClass(1, 3, 0)
assert a == SetClass(1, 0, 3)
assert a == SetClass(3, 1, 0)
def test_repeat_intervals():
a = SetClass(0, 1, 3)
assert a == SetClass(0, 1, 3, 3, 1)
assert a == SetClass(0, 12, 1, -11, 15)
assert a == SetClass(12, 1, -11, 15, -9)
# ----------------------------------------------------------------------------
# Example Forte 5-20 set class
f520 = SetClass(0, 1, 5, 6, 8)
def test_tonality():
assert f520.tonality == 12
def test_cardinality():
assert f520.cardinality == 5
def test_brightness():
assert f520.brightness == 20
def test_pitch_classes():
assert len(f520) == 5
assert len(f520.pitch_classes) == 5
def test_adjacency_intervals():
assert len(f520.adjacency_intervals) == 5
def test_versions():
assert len(f520.versions) == 5
def test_brightest_form():
b = f520.brightest_form
assert b in f520.versions
assert b == SetClass(0, 4, 5, 9, 10)
assert b.leonard_notation == '[0₄4₁5₄9₁10₂]²⁸'
def test_darkest_form():
d = f520.darkest_form
assert d in f520.versions
assert d == SetClass(0, 1, 3, 7, 8)
assert d.leonard_notation == '[0₁1₂3₄7₁8₄]¹⁹'
def test_leonard_notation():
assert f520.leonard_notation == '[0₁1₄5₁6₂8₄]²⁰'
# ----------------------------------------------------------------------------
# Complement of Forte 5-20 set class: Forte 7-20
def test_complement():
assert f520.complement == SetClass(0, 1, 2, 5, 7, 8, 9)
def test_complement_tonality():
assert f520.complement.tonality == 12
def test_complement_cardinality():
assert f520.complement.cardinality == 7
def test_complement_brightness():
assert f520.complement.brightness == 32
def test_complement_pitch_classes():
assert len(f520.complement) == 7
assert len(f520.complement.pitch_classes) == 7
def test_complement_adjacency_intervals():
assert len(f520.complement.adjacency_intervals) == 7
def test_complement_versions():
assert len(f520.complement.versions) == 7
def test_complement_brightest_form():
b = f520.complement.brightest_form
assert b in f520.complement.versions
assert b == SetClass(0, 3, 5, 6, 7, 10, 11)
assert b.leonard_notation == '[0₃3₂5₁6₁7₃10₁11₁]⁴²'
def test_complement_darkest_form():
d = f520.complement.darkest_form
assert d in f520.complement.versions
assert d == SetClass(0, 1, 2, 5, 6, 7, 10)
assert d.leonard_notation == '[0₁1₁2₃5₁6₁7₃10₂]³¹'
def test_complement_leonard_notation():
assert f520.complement.leonard_notation == '[0₁1₁2₃5₂7₁8₁9₃]³²'
# ----------------------------------------------------------------------------
# Forte 0-1, the empty set class
empty = SetClass()
def test_empty_tonality():
assert empty.tonality == 12
def test_empty_cardinality():
assert empty.cardinality == 0
def test_empty_brightness():
assert empty.brightness == 0
def test_empty_pitch_classes():
assert len(empty) == 0
assert len(empty.pitch_classes) == 0
def test_empty_ajdacency_intervals():
assert len(empty.adjacency_intervals) == 0
def test_empty_versions():
assert len(empty.versions) == 1
assert empty.versions[0] == empty
def test_empty_brightest_form():
assert empty.brightest_form == empty
def test_empty_darkest_form():
assert empty.darkest_form == empty
def test_empty_bright_dark_forms_equal():
assert empty.brightest_form == empty.darkest_form
def test_empty_duodecimal_notation():
assert empty.duodecimal_notation == 'SetClass[]'
def test_empty_dozenal_notation():
assert empty.dozenal_notation == 'SetClass[]'
def test_empty_leonard_notation():
assert empty.leonard_notation == '[]⁰'
# ----------------------------------------------------------------------------
# Forte 12-1, the complement of empty (full set class)
f121 = empty.complement
def test_empty_complement():
assert f121 == SetClass(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11)
def test_empty_complement_tonality():
assert f121.tonality == 12
def test_empty_complement_cardinality():
assert f121.cardinality == 12
def test_empty_complement_brightness():
assert f121.brightness == 66
def test_empty_complement_pitch_classes():
assert len(f121) == 12
assert len(f121.pitch_classes) == 12
def test_empty_complement_adjacency_intervals():
assert len(f121.adjacency_intervals) == 12
def test_empty_complement_versions():
assert len(f121.versions) == 1
assert f121.versions[0] == f121
def test_empty_complement_brightest_form():
assert f121.brightest_form == f121
def test_empty_complement_darkest_form():
assert f121.darkest_form == f121
def test_empty_complement_bright_dark_forms_equal():
assert f121.brightest_form == f121.darkest_form
def test_empty_complement_duodecimal_notation():
assert f121.duodecimal_notation == 'SetClass[0,1,2,3,4,5,6,7,8,9,T,E]'
def test_empty_complement_dozenal_notation():
assert f121.dozenal_notation == 'SetClass[0,1,2,3,4,5,6,7,8,9,↊,↋]'
def test_empty_complement_leonard_notation():
assert f121.leonard_notation == '[0₁1₁2₁3₁4₁5₁6₁7₁8₁9₁10₁11₁]⁶⁶'