Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions packages/tilia-core/src/tilia_core/timemap/meters.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
class _BeatUnit:
"""A beat unit as desktop#621's component holds it, with its beat's id as its id.

Its units are parsed once for each call of `meters_of`.
Its units are parsed when it is read, not again for each measure it governs.
"""

id: str
Expand Down Expand Up @@ -73,8 +73,8 @@ def meters_of(rows: Sequence[RowIn]) -> list[MeasureMeter]:
meters.append(
MeasureMeter(
denominator=denominator,
units=units,
beat_values=fit_units(units, beat_count),
units=tuple(units),
beat_values=tuple(fit_units(units, beat_count)),
beat_unit_id=governing.id if governing else None,
starts_here=bool(in_measure) or index == 0,
is_ambiguous=is_fit_ambiguous(units, beat_count),
Expand Down
37 changes: 25 additions & 12 deletions packages/tilia-core/src/tilia_core/timemap/units.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from __future__ import annotations

from collections.abc import Sequence
from dataclasses import dataclass
from fractions import Fraction

Expand All @@ -18,6 +19,10 @@
# Far above any denominator used in notation. Enforced wherever a beat unit
# is set, so the dialog and inspector, which can't show more, never meet one.
MAX_DENOMINATOR = 128
# Far above any unit written in notation, and far below the 4300 digits
# Python 3.10.7 and later refuse to read as an int (earlier versions have no
# limit), so a unit reads the same on every Python.
MAX_UNIT_LENGTH = 32

_UNIT_CHARACTERS = set("0123456789/")

Expand Down Expand Up @@ -50,12 +55,15 @@ def parse_units(text: str) -> UnitsParseResult:
item = item.strip()
if not item:
return UnitsParseResult([], "Missing unit around '+'.")
too_long = len(item) > MAX_UNIT_LENGTH
shown = item[:MAX_UNIT_LENGTH] + "…" if too_long else item
not_a_fraction = UnitsParseResult(
[], f"'{item}' is not a whole number or fraction such as 1/2."
[], f"'{shown}' is not a whole number or fraction such as 1/2."
)
# Fraction() also accepts decimals, signs and exponents; units are
# written the way they are in notation.
if not set(item) <= _UNIT_CHARACTERS:
# written the way they are in notation. A longer unit is refused
# before Fraction() sees it, which may or may not read it.
if too_long or not set(item) <= _UNIT_CHARACTERS:
return not_a_fraction
try:
unit = Fraction(item)
Expand All @@ -68,7 +76,7 @@ def parse_units(text: str) -> UnitsParseResult:
return UnitsParseResult(units)


def format_units(units: list[Fraction]) -> str:
def format_units(units: Sequence[Fraction]) -> str:
"""Writes units in canonical form, e.g. [2, 3] as "2+3"."""
return "+".join(str(unit) for unit in units)

Expand All @@ -95,15 +103,19 @@ def is_fit_ambiguous(units: list[Fraction], beat_count: int) -> bool:
@dataclass(frozen=True)
class MeasureMeter:
"""
The meter a measure ends up with. `beat_unit_id` is the beat unit that
governs the measure, or None where the default applies. `is_assumed` is
True when its values weren't set by the user (see BeatUnit.assumed).
The meter a measure ends up with. `beat_unit_id` names the beat unit
that governs the measure, or is None where the default applies. In the
app it is that BeatUnit component's own id. In tilia_core's copy of this
module it is the id of the beat that carries the governing unit, since
tilia_core reads a .tla beat unit as part of its beat, without an id of
its own. `is_assumed` is True when its values weren't set by the user
(see BeatUnit.assumed).
"""

denominator: int
units: list[Fraction]
beat_values: list[Fraction]
beat_unit_id: int | None
units: tuple[Fraction, ...]
beat_values: tuple[Fraction, ...]
beat_unit_id: str | None
starts_here: bool
is_ambiguous: bool
is_conflicting: bool
Expand All @@ -117,5 +129,6 @@ def numerator(self) -> Fraction:
def time_signature(self) -> tuple[Fraction, int]:
return self.numerator, self.denominator

def quarter_length(self, beat_value: Fraction) -> Fraction:
return beat_value * 4 / self.denominator
def quarter_length(self, beat_value: Fraction | int) -> Fraction:
# An int divided by the denominator would give a float.
return Fraction(beat_value) * 4 / self.denominator
33 changes: 25 additions & 8 deletions packages/tilia-core/tests/timemap/test_timemap_meters.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,22 +91,22 @@ def test_ambiguous_fit(self):
timeline = tapped(5, (2, 3), {0: unit(4, "2+3")})

result = meters(timeline)
assert [m.beat_values for m in result] == [[2, 3], [2, 3, 2]]
assert [m.beat_values for m in result] == [(2, 3), (2, 3, 2)]
assert [m.is_ambiguous for m in result] == [False, True]

def test_short_measures_take_the_start_of_the_pattern(self):
timeline = tapped(6, (2,), {0: unit(4, "1+2+3")})

result = meters(timeline)
assert [m.beat_values for m in result] == [[1, 2]] * 3
assert [m.beat_values for m in result] == [(1, 2)] * 3
assert all(m.is_ambiguous for m in result)

def test_pickup_also_takes_the_start_of_the_pattern(self):
timeline = tapped(3, (1, 2), {0: unit(4, "3+2")})

result = meters(timeline)
assert result[0].beat_values == [3]
assert result[1].beat_values == [3, 2]
assert result[0].beat_values == (3,)
assert result[1].beat_values == (3, 2)

def test_two_beat_units_in_a_measure_first_wins(self):
timeline = tapped(8, units={4: unit(8, "1"), 6: unit(2, "1")})
Expand All @@ -116,6 +116,22 @@ def test_two_beat_units_in_a_measure_first_wins(self):
assert meter.beat_unit_id == beat_ids(timeline)[4]
assert meter.denominator == 8

def test_meters_can_be_hashed(self):
first, second, third = meters(tapped(6, (2,)))

# The last two are both 2/4 under the first measure's beat unit.
assert hash(second) == hash(third)
assert {first, second, third} == {first, second}

@pytest.mark.parametrize("beat_value", [1, F(1)])
def test_quarter_length_is_exact(self, beat_value):
(meter,) = meters(tapped(3, (3,), {0: unit(6, "1")}))

length = meter.quarter_length(beat_value)

assert isinstance(length, F)
assert length == F(2, 3)


class TestBeforeTheFirstUnit:
# TiLiA's loader keeps a unit in the first measure: a copy of the first
Expand All @@ -127,7 +143,7 @@ def test_the_measures_take_the_first_units_values_assumed(self, first):

result = meters(timeline)
assert [m.time_signature for m in result] == [(6, 8)] * 4
assert [m.units for m in result] == [[3]] * 4
assert [m.units for m in result] == [(3,)] * 4
assert [m.is_assumed for m in result] == [True, True, True, False]
assert [m.starts_here for m in result] == [True, False, False, True]
assert [m.beat_unit_id for m in result] == [
Expand All @@ -142,7 +158,7 @@ def test_a_pattern_is_read_from_its_start_in_those_measures(self):
timeline = tapped(5, (1, 2, 2), {0: None, 3: unit(8, "2+3")})

result = meters(timeline)
assert [m.beat_values for m in result] == [[2], [2, 3], [2, 3]]
assert [m.beat_values for m in result] == [(2,), (2, 3), (2, 3)]
assert [m.is_ambiguous for m in result] == [True, False, False]

@pytest.mark.parametrize("first", [None, unit(8, "x")])
Expand All @@ -151,7 +167,7 @@ def test_with_no_readable_unit_a_measure_of_n_taps_is_n_over_4(self, first):

result = meters(timeline)
assert [m.time_signature for m in result] == [(4, 4), (3, 4)]
assert [m.units for m in result] == [[1], [1]]
assert [m.units for m in result] == [(1,), (1,)]
assert [m.beat_unit_id for m in result] == [None, None]
assert [m.starts_here for m in result] == [True, False]
assert all(m.is_assumed for m in result)
Expand Down Expand Up @@ -192,6 +208,7 @@ class TestUnreadableUnits:
unit(8, 3),
unit(0, "1"),
unit(129, "1"),
unit(8, "1" * 33),
],
)
def test_is_ignored_and_the_unit_before_carries_on(self, bad, caplog):
Expand Down Expand Up @@ -251,7 +268,7 @@ def test_scenario(
(meter,) = meters(tapped(taps, (taps,), {0: beat_unit}))

assert meter.time_signature == time_signature
assert meter.beat_values == beat_values
assert meter.beat_values == tuple(beat_values)
assert [meter.quarter_length(value) for value in meter.beat_values] == quarters
assert meter.is_assumed == assumed
assert meter.is_ambiguous == ambiguous
Expand Down
1 change: 1 addition & 0 deletions packages/tilia-core/tests/timemap/test_timemap_rows.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ def test_a_beat_unit_that_cannot_be_read_is_ignored(caplog):
{"denominator": 0, "units": "1"},
{"denominator": 129, "units": "1"},
{"denominator": "8", "units": "1"},
{"denominator": 8, "units": "1" * 33},
],
)
def test_a_beat_unit_desktop_621_refuses_is_left_out(beat_unit, caplog):
Expand Down
37 changes: 37 additions & 0 deletions packages/tilia-core/tests/timemap/test_timemap_units.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import sys
from fractions import Fraction as F

import pytest

from tilia_core.timemap.units import (
MAX_DENOMINATOR,
MAX_UNIT_LENGTH,
fit_units,
format_units,
is_fit_ambiguous,
Expand Down Expand Up @@ -39,6 +41,41 @@ def test_invalid(self, text):
assert result.error
assert result.units == []

def test_longest_unit_is_read(self):
assert parse_units("1" * MAX_UNIT_LENGTH).is_valid

def test_longer_unit_is_refused_and_cut_short_in_the_message(self):
result = parse_units("1" * (MAX_UNIT_LENGTH + 1))

assert not result.is_valid
assert result.units == []
assert result.error == (
f"'{'1' * MAX_UNIT_LENGTH}…' is not a whole number or fraction "
"such as 1/2."
)

@pytest.mark.skipif(
not hasattr(sys, "set_int_max_str_digits"),
reason="this Python has no limit on the digits int() reads",
)
@pytest.mark.parametrize("digit_limit", [0, 4300])
def test_very_long_unit_is_refused_whatever_the_digit_limit(self, digit_limit):
# Python 3.10.7 and later refuse to read an integer of more than 4300
# digits by default; earlier versions read any length, as 0 does.
before = sys.get_int_max_str_digits()
sys.set_int_max_str_digits(digit_limit)
try:
result = parse_units("1" * 5000)
finally:
sys.set_int_max_str_digits(before)

assert not result.is_valid
assert result.units == []
assert result.error == (
f"'{'1' * MAX_UNIT_LENGTH}…' is not a whole number or fraction "
"such as 1/2."
)

def test_format(self):
assert format_units([F(2), F(3)]) == "2+3"
assert format_units([F(1, 3)]) == "1/3"
Expand Down
Loading