diff --git a/packages/tilia-core/pyproject.toml b/packages/tilia-core/pyproject.toml index c0ba73bd5..f6969cb7c 100644 --- a/packages/tilia-core/pyproject.toml +++ b/packages/tilia-core/pyproject.toml @@ -8,7 +8,7 @@ version = "0.0.2" description = "TiLiA's file format and corpus tools, without a user interface" readme = "README.md" requires-python = ">=3.10" -dependencies = ["pypdf>=6.0.0"] +dependencies = ["pypdf>=6.0.0", "webcolors>=24.8.0"] license = "GPL-3.0-or-later" license-files = ["LICENSE"] authors = [ @@ -17,6 +17,10 @@ authors = [ {name = "Anne Foo"} ] +[project.optional-dependencies] +# Checking harmonies and keys, and later importing them, reads music21. +harmony = ["music21>=9.1"] + [project.scripts] tilia = "tilia_core.commands:main" diff --git a/packages/tilia-core/src/tilia_core/importers/_commit.py b/packages/tilia-core/src/tilia_core/importers/_commit.py index 09e76b1cf..aeeed7e8f 100644 --- a/packages/tilia-core/src/tilia_core/importers/_commit.py +++ b/packages/tilia-core/src/tilia_core/importers/_commit.py @@ -8,13 +8,10 @@ from __future__ import annotations import random -import string from collections.abc import Collection, Iterable from typing import Any -from tilia_core import tla - -_ROW_ID_CHARACTERS = string.ascii_letters + string.digits +from tilia_core import rules, tla def commit( @@ -42,7 +39,7 @@ def commit( def new_row_id(taken: Collection[str]) -> str: """A range row's id: six letters and digits, none of `taken`, as TiLiA makes them.""" while True: - row_id = "".join(random.choices(_ROW_ID_CHARACTERS, k=6)) + row_id = "".join(random.choices(rules.ROW_ID_CHARACTERS, k=rules.ROW_ID_LENGTH)) if row_id not in taken: return row_id diff --git a/packages/tilia-core/src/tilia_core/rules.py b/packages/tilia-core/src/tilia_core/rules.py new file mode 100644 index 000000000..96e9fc753 --- /dev/null +++ b/packages/tilia-core/src/tilia_core/rules.py @@ -0,0 +1,741 @@ +"""The one set of rules for a valid component. + +Everything that creates or changes a component goes through these rules and +keeps none of its own: the importers, the `tilia` command, bulk edits, and the +reader and the writer of `.tla` files. TiLiA checks a component in several +places (when it creates one, when it sets a value, in its editing operations), +which don't always agree; the rules are the strictest of them and of the +format's schema. + +There is one function per kind the importers create. Each takes the media +length, the timeline's other components (not the one checked) and the +attributes of the component to check, and gives the first rule they break: +`(False, reason)`, or `(True, "")` when they break none. The reasons are +TiLiA's, word for word, where TiLiA has one. A value that isn't one always +reads ` is not a valid .`, whatever the kind. + +A harmony's quality and inversion and a mode's type are checked against the +installed music21, as TiLiA checks them, so checking a harmony or a mode needs +the `harmony` extra. Without it, the other rules are checked, and when none is +broken, `CantCheck` is raised, before the color is checked: a bad color mustn't +hide that a rule couldn't be checked. music21 is imported only when a harmony or +a mode is checked. +""" + +from __future__ import annotations + +import importlib.util +import math +import string +from collections.abc import Callable, Collection, Iterable, Mapping, Sequence +from types import ModuleType +from typing import TYPE_CHECKING, Any + +import webcolors + +if TYPE_CHECKING: + from tilia_core import tla + +INSTALL_HARMONY = 'pip install "tilia-core[harmony]"' + +# TiLiA's values (tilia/timelines/harmony/constants.py). Qualities and mode types +# are music21's. +HARMONY_ACCIDENTALS = (2, 1, 0, -1, -2) +HARMONY_DISPLAY_MODES = ("letter", "roman", "custom") +FONT_TYPES = ("analytic", "normal") +_STEPS = range(7) +_LEVELS = (1, 2) +_NOT_MODE_TYPES = ("ionian", "aeolian") +# Qualities music21 has no chord type for, each a chord type it has plus one added +# tone, spelled as in a chord symbol, from the root: TiLiA's, as `dev` has them +# since 43a61224 and 28539120. TiLiA takes them from here once it checks with +# the rules. +ADDED_TONE_QUALITIES: dict[str, tuple[str, str]] = { + "dominant-seventh-flat-ninth": ("dominant-seventh", "b9"), + "dominant-seventh-sharp-ninth": ("dominant-seventh", "#9"), + "dominant-seventh-sharp-eleventh": ("dominant-seventh", "#11"), + "dominant-seventh-flat-thirteenth": ("dominant-seventh", "b13"), + "dominant-seventh-added-thirteenth": ("dominant-seventh", "13"), + "dominant-ninth-sharp-eleventh": ("dominant-ninth", "#11"), + "major-seventh-sharp-eleventh": ("major-seventh", "#11"), + "major-seventh-added-sixth": ("major-seventh", "6"), + "major-sixth-added-ninth": ("major-sixth", "9"), + "minor-sixth-added-ninth": ("minor-sixth", "9"), + "major-added-ninth": ("major", "9"), + "minor-added-ninth": ("minor", "9"), + "minor-seventh-added-eleventh": ("minor-seventh", "11"), +} +# Qualities TiLiA keeps in root position, whatever their intervals. +_NO_INVERSION_QUALITIES = frozenset( + {"Italian", "French", "German", "Neapolitan", "Tristan", "power"} +) + +# Two hierarchy boundaries count as the same when they are within 1e-9 s of each +# other, or within a billionth of the larger, whichever is more: `math.isclose` +# with this `abs_tol` and its default `rel_tol` of 1e-9, as TiLiA's +# `get_boundary_conflicts` compares them since `dev`'s 2ceec157. So below 1 s they +# meet within 1e-9 s, and at an hour within 3.6e-6 s. Files TiLiA saved before then +# hold boundaries a few ulps apart where they should meet. +_TIME_ABS_TOL = 1e-9 + +# The names TiLiA's reasons give the point kinds (their `frontend_name`). +_POINT_NAMES = { + "marker": "marker", + "harmony": "harmony", + "mode": "mode", + "beat": "beat", + "pdf_marker": "PDF page marker", +} + +_TEXTS = ("label", "comments", "custom_text") +_SEGMENT_TIMES = ("start", "end", "pre_start", "post_end") +_UNSET_TIMES = ("pre_start", "post_end") # None: they follow the start and the end + +# A range row's id, as TiLiA's `generate_row_id` makes it. +ROW_ID_LENGTH = 6 +ROW_ID_CHARACTERS = string.ascii_letters + string.digits + +# The colors Qt reads: #RGB, #RRGGBB, #AARRGGBB, #RRRGGGBBB and #RRRRGGGGBBBB, +# and the names in `QColor.colorNames()`, which are CSS 3's (SVG 1.0's) and +# "transparent". CSS 3's come from webcolors, so the core keeps no copy of them. +_HEX_LENGTHS = (3, 6, 8, 9, 12) +_HEX_DIGITS = frozenset(string.hexdigits) +_COLOR_NAMES = frozenset(webcolors.names("css3")) | {"transparent"} + + +class Outcome(tuple[bool, str]): + """What checking the rules gives: `(True, "")`, or `(False, reason)`. + + It is true when no rule is broken, so `if rules.marker(...):` reads as "the + marker keeps to the rules"; it also unpacks as `ok, reason`. `others` are + the ids of the other components a broken rule is about: those at the same + time or place, the hierarchies it crosses, the ranges it should join. + """ + + # Read through `others`, which can't be set: every outcome that keeps to the + # rules is the one `OK`. A tuple's subclass can't give it a slot. + _others: tuple[str, ...] + + def __new__(cls, ok: bool, reason: str = "", others: Iterable[str] = ()) -> Outcome: + outcome = super().__new__(cls, (bool(ok), reason)) + outcome._others = tuple(others) + return outcome + + def __bool__(self) -> bool: + return self[0] + + def __getnewargs__(self) -> tuple[bool, str, tuple[str, ...]]: # type: ignore[override] + # What copy and pickle give __new__: a tuple's would be the pair alone. + return self[0], self[1], self._others + + @property + def others(self) -> tuple[str, ...]: + return self._others + + @property + def ok(self) -> bool: + return self[0] + + @property + def reason(self) -> str: + return self[1] + + +OK = Outcome(True) + + +class CantCheck(Exception): + """The rules that need music21 can't be checked: the `harmony` extra is missing, + or music21 won't import (the error is the exception's cause). + + This isn't a broken rule: the component may well keep to them. + """ + + command = INSTALL_HARMONY + + def __init__(self, kind: str, error: Exception | None = None) -> None: + if error is None: + message = f"checking a {kind} needs: {INSTALL_HARMONY}" + else: + message = ( + f"checking a {kind} needs music21, which can't be imported" + f" ({type(error).__name__}: {error}): {INSTALL_HARMONY}" + ) + super().__init__(message) + self.kind = kind + self.error = error + + def __reduce__(self) -> tuple[Any, ...]: + # The error isn't carried, since it may not copy or pickle: its text is in + # the message, which is. + return type(self), (self.kind,), {"args": self.args, "error": None} + + +# The private functions give the first rule they find broken, or None. `_kept` +# checks them in order, each only once the ones before it are kept. Then it raises +# `CantCheck` when the rules that need music21 couldn't be checked, and only then +# checks the color: an importer keeps a row whose color isn't one, with the +# default color, so a bad color mustn't hide another broken rule, nor that a rule +# couldn't be checked. + +Check = Callable[[], "Outcome | None"] + + +def _kept( + attrs: Mapping[str, Any], + *checks: Check, + cant_check: CantCheck | None = None, +) -> Outcome: + """The first rule `checks` find broken, else `cant_check` raised, when the + rules that need music21 couldn't be checked, else the color's outcome.""" + for check in checks: + broken = check() + if broken is not None and not broken: + return broken + if cant_check is not None: + raise cant_check from cant_check.error + broken = _color_of(attrs) + return OK if broken is None else broken + + +def marker( + media_length: float, others: Iterable[tla.Component], attrs: Mapping[str, Any] +) -> Outcome: + """A marker.""" + return _kept(attrs, lambda: _point("marker", media_length, others, attrs)) + + +def beat( + media_length: float, others: Iterable[tla.Component], attrs: Mapping[str, Any] +) -> Outcome: + """A beat. Its measure mark and beat unit follow the measure table's rules.""" + return _kept(attrs, lambda: _point("beat", media_length, others, attrs)) + + +def pdf_marker( + media_length: float, + others: Iterable[tla.Component], + attrs: Mapping[str, Any], + *, + page_count: int, +) -> Outcome: + """A PDF marker, in a PDF of `page_count` pages: 0 for a PDF that can't be read.""" + return _kept( + attrs, + lambda: _point("pdf_marker", media_length, others, attrs), + lambda: _page(attrs.get("page_number"), page_count), + ) + + +def harmony( + media_length: float, others: Iterable[tla.Component], attrs: Mapping[str, Any] +) -> Outcome: + """A harmony. Without music21, raises `CantCheck` when no rule but the color is + broken: the color is checked after it.""" + music21, error = _music21() + return _kept( + attrs, + lambda: _point("harmony", media_length, others, attrs), + lambda: _one_of(attrs, "step", _STEPS), + lambda: _one_of(attrs, "applied_to", _STEPS), + lambda: _one_of(attrs, "accidental", HARMONY_ACCIDENTALS), + lambda: _quality_and_inversion(music21, attrs) if music21 else None, + lambda: _one_of(attrs, "level", _LEVELS), + lambda: _one_of(attrs, "display_mode", HARMONY_DISPLAY_MODES), + lambda: _one_of(attrs, "custom_text_font_type", FONT_TYPES), + cant_check=None if music21 else CantCheck("harmony", error), + ) + + +def mode( + media_length: float, others: Iterable[tla.Component], attrs: Mapping[str, Any] +) -> Outcome: + """A key. Without music21, raises `CantCheck` when no rule but the color is + broken: the color is checked after it.""" + music21, error = _music21() + return _kept( + attrs, + lambda: _point("mode", media_length, others, attrs), + lambda: _one_of(attrs, "step", _STEPS), + lambda: _one_of(attrs, "accidental", HARMONY_ACCIDENTALS), + lambda: _mode_type(music21, attrs) if music21 else None, + lambda: _one_of(attrs, "level", _LEVELS), + cant_check=None if music21 else CantCheck("mode", error), + ) + + +def hierarchy( + media_length: float, others: Iterable[tla.Component], attrs: Mapping[str, Any] +) -> Outcome: + """A hierarchy. Hierarchies nest: one that overlaps another lies inside it, + at a lower level.""" + start, end, level = attrs.get("start"), attrs.get("end"), attrs.get("level") + hierarchies = [other for other in others if other.kind == "hierarchy"] + return _kept( + attrs, + lambda: _every_kind(attrs, _SEGMENT_TIMES), + lambda: _hierarchy_times(start, end, media_length), + lambda: _hierarchy_level(level), + lambda: _pre_start(attrs.get("pre_start"), start), + lambda: _post_end(attrs.get("post_end"), end, media_length), + lambda: _same_place(start, end, level, hierarchies), + lambda: _crossed(start, end, level, hierarchies), + ) + + +def range_( + media_length: float, + others: Iterable[tla.Component], + attrs: Mapping[str, Any], + *, + rows: Sequence[Mapping[str, Any]], +) -> Outcome: + """A range, in a timeline whose attribute `rows` is `rows`. + + Ranges in a row may overlap. + """ + start, end = attrs.get("start"), attrs.get("end") + row_ids = [row.get("id") for row in rows] + return _kept( + attrs, + lambda: _every_kind(attrs, _SEGMENT_TIMES), + lambda: _range_place(attrs.get("row_id"), row_ids, start, end, media_length), + lambda: _pre_start(attrs.get("pre_start"), start), + lambda: _post_end(attrs.get("post_end"), end, media_length), + lambda: _joined(attrs, others, rows), + ) + + +def range_rows(rows: Sequence[Mapping[str, Any]]) -> Outcome: + """A range timeline's rows: each with its own id of six letters and digits, + a name, and a color or none.""" + ids: set[str] = set() + for row in rows: + row_id = row.get("id") + if not _is_row_id(row_id): + return _broken(not_valid("row_id", row_id)) + if row_id in ids: + return _broken(f"There is already a row with the id {row_id}.") + ids.add(row_id) + name = row.get("name") + if not isinstance(name, str): + return _broken(not_valid("row_name", name)) + for row in rows: + broken = _color_of(row) + if broken is not None: + return broken + return OK + + +def color(value: Any) -> Outcome: + """A color: the default (None or ""), or one Qt reads, checked without Qt. + + An importer keeps a row whose color isn't one, with the kind's default color + instead, and a note. + """ + if value is None or value == "": + return OK + if isinstance(value, str) and _qt_reads(value): + return OK + return _broken(not_valid("color", value)) + + +def split_added_tone(quality: str) -> tuple[str, str]: + """A quality's base quality and added tone, or the quality and "".""" + return ADDED_TONE_QUALITIES.get(quality, (quality, "")) + + +def not_valid(attribute: str, value: Any) -> str: + """The reason for a value of `attribute` that isn't one, for every kind. + + The rules give it for a value they refuse as not being one, such as a time of + nan, and the importers for a value they can't read, so the two read alike. + """ + return f"{_shown(value)} is not a valid {attribute.replace('_', ' ')}." + + +def _point( + kind: str, + media_length: float, + others: Iterable[tla.Component], + attrs: Mapping[str, Any], +) -> Outcome | None: + """The rules for every kind, the time's bounds, and no other at the same time.""" + broken = _every_kind(attrs, ("time",)) + if broken is not None: + return broken + time = attrs["time"] + if time < 0: + return _broken(f"Time can't be negative. Got '{_media_time(time)}'") + if time > media_length: + return _broken( + f"Time '{_media_time(time)}' is bigger than media time" + f" '{_media_time(media_length)}'" + ) + same = [ + other.id + for other in others + if other.kind == kind and other.attrs.get("time") == time + ] + if same: + return Outcome( + False, + f"There is already a {_POINT_NAMES[kind]} component at the selected position.", + same, + ) + return None + + +def _every_kind(attrs: Mapping[str, Any], times: Iterable[str]) -> Outcome | None: + """Times are finite numbers and texts are text. The color is checked last.""" + for name in times: + value = attrs.get(name) + if value is None and name in _UNSET_TIMES: + continue + if not _is_number(value): + return _broken(not_valid(name, value)) + for name in _TEXTS: + if name in attrs and not isinstance(attrs[name], str): + return _broken(not_valid(name, attrs[name])) + return None + + +def _color_of(attrs: Mapping[str, Any]) -> Outcome | None: + if "color" in attrs: + outcome = color(attrs["color"]) + if not outcome: + return outcome + return None + + +def _page(page_number: Any, page_count: int) -> Outcome | None: + if not _is_integer(page_number): + return _broken(not_valid("page_number", page_number)) + if not 1 <= page_number <= page_count: + return _broken("The PDF has no page with that number.") + return None + + +def _one_of( + attrs: Mapping[str, Any], name: str, allowed: Collection[int | str] +) -> Outcome | None: + """`name`, when the component has it, is one of `allowed`, as it is: 1.0 isn't 1.""" + if name not in attrs: + return None + value = attrs[name] + if (_is_integer(value) or isinstance(value, str)) and value in allowed: + return None + return _broken(not_valid(name, value)) + + +def _music21() -> tuple[ModuleType | None, Exception | None]: + """music21, with what the rules read of it; or None, without the `harmony` + extra, and with the error when music21 is installed but won't import, + whatever the error is.""" + try: + if importlib.util.find_spec("music21") is None: + return None, None + import music21.harmony + import music21.key + except Exception as error: + return None, error + return music21, None + + +def _quality_and_inversion( + music21: ModuleType, attrs: Mapping[str, Any] +) -> Outcome | None: + """The quality is one of music21's `CHORD_TYPES` or of TiLiA's added-tone + qualities, and the inversion one it has.""" + quality = attrs.get("quality", "major") + if not ( + isinstance(quality, str) + and split_added_tone(quality)[0] in music21.harmony.CHORD_TYPES + ): + return _broken(not_valid("quality", quality)) + inversion = attrs.get("inversion", 0) + if not (_is_integer(inversion) and 0 <= inversion <= _inversions(music21, quality)): + return _broken(not_valid("inversion", inversion)) + return None + + +def _inversions(music21: ModuleType, quality: str) -> int: + """A quality's inversions, as TiLiA's `get_inversion_amount` counts them: an + added tone isn't one the chord can be inverted to.""" + quality, _ = split_added_tone(quality) + if quality in _NO_INVERSION_QUALITIES: + return 0 + intervals = str(music21.harmony.CHORD_TYPES[quality][0]) + return len(intervals.split(",")) - 1 + + +def _mode_type(music21: ModuleType, attrs: Mapping[str, Any]) -> Outcome | None: + """The type is one of music21's `modeSharpsAlter`, but ionian and aeolian.""" + types = [ + name for name in music21.key.modeSharpsAlter if name not in _NOT_MODE_TYPES + ] + return _one_of(attrs, "type", types) + + +def _hierarchy_times(start: float, end: float, media_length: float) -> Outcome | None: + # The values are compared as they are given, as `_point` compares them, so a + # media length too large for a float is longer than any time. TiLiA shows them + # as floats, and so do the reasons. + if start > media_length: + return _broken( + f"Start time '{float(start)}' is bigger than media time" + f" '{float(media_length)}'" + ) + if end > media_length: + return _broken( + f"End time '{float(end)}' is bigger than media time" + f" '{float(media_length)}'" + ) + if start < 0: + return _broken(f"Start time can't be negative. Got '{float(start)}'") + if end <= start: + return _broken( + f"End time '{float(end)}' should be bigger than start time" + f" '{float(start)}'" + ) + return None + + +def _hierarchy_level(level: Any) -> Outcome | None: + if not _is_integer(level): + return _broken(not_valid("level", level)) + if level < 1: + return _broken(f"Level can't be below 1. Got '{_shown(level)}'") + return None + + +def _pre_start(pre_start: float | None, start: float) -> Outcome | None: + """`pre_start`, when set, is from 0 to the start.""" + if pre_start is None: + return None + if pre_start < 0: + return _broken("pre_start is outside media bounds.") + if pre_start > start: + return _broken("pre_start can't be after start time.") + return None + + +def _post_end( + post_end: float | None, end: float, media_length: float +) -> Outcome | None: + """`post_end`, when set, is from the end to the media length.""" + if post_end is None: + return None + if post_end > media_length: + return _broken("post_end is outside media bounds.") + if post_end < end: + return _broken("post_end can't be before end time.") + return None + + +def _same_place( + start: float, end: float, level: int, hierarchies: Iterable[tla.Component] +) -> Outcome | None: + same = [ + other.id + for other in hierarchies + if (other.attrs.get("start"), other.attrs.get("end"), other.attrs.get("level")) + == (start, end, level) + ] + if same: + return Outcome( + False, + "There is already a hierarchy component at the selected position.", + same, + ) + return None + + +def _crossed( + start: float, end: float, level: int, hierarchies: Iterable[tla.Component] +) -> Outcome | None: + crossed = [ + other.id for other in hierarchies if _cross(start, end, level, other.attrs) + ] + if crossed: + return Outcome( + False, "Component would overlap with existing component.", crossed + ) + return None + + +def _cross(start: float, end: float, level: int, other: Mapping[str, Any]) -> bool: + """Whether two hierarchies overlap, and neither lies inside the other, lower. + + This is when TiLiA's `get_boundary_conflicts` finds a conflict between them, as + `dev` has it since 2ceec157: two boundaries `_times_close` finds close count as + the same. + """ + other_start, other_end = other.get("start"), other.get("end") + other_level = other.get("level") + if not ( + _is_comparable(other_start) + and _is_comparable(other_end) + and _is_comparable(other_level) + ): + return False + if not (_lt(start, other_end) and _lt(other_start, end)): + return False + inside = _le(other_start, start) and _le(end, other_end) and level < other_level + around = _le(start, other_start) and _le(other_end, end) and other_level < level + return not (inside or around) + + +def _times_close(a: float, b: float) -> bool: + """Whether two times count as the same, as TiLiA's `_times_close` says: within + `_TIME_ABS_TOL`, or within a billionth of the larger, whichever is more.""" + try: + return math.isclose(a, b, abs_tol=_TIME_ABS_TOL) + except OverflowError: # an integer too large for a float is close only to itself + return a == b + + +def _le(a: float, b: float) -> bool: + return a < b or _times_close(a, b) + + +def _lt(a: float, b: float) -> bool: + return a < b and not _times_close(a, b) + + +def _range_place( + row_id: Any, row_ids: Sequence[Any], start: float, end: float, media_length: float +) -> Outcome | None: + if not row_id or row_id not in row_ids: + return _broken("Invalid row ID.") + if start >= end: + return _broken("Start time must be before end time.") + if start < 0 or end > media_length: + return _broken("Range is outside media bounds.") + return None + + +def _joined( + attrs: Mapping[str, Any], + others: Iterable[tla.Component], + rows: Sequence[Mapping[str, Any]], +) -> Outcome | None: + """`joined_right`, when set, is a next range in the row, starting at this one's end. + + The next ranges are those of the row that start first after this one starts. + The reasons are the range importer's (`_apply_pending_joins`). + """ + joined = attrs.get("joined_right") + if joined is None: + return None + row_id, start, end = attrs["row_id"], attrs["start"], attrs["end"] + row = next((row.get("name") for row in rows if row.get("id") == row_id), row_id) + later = [ + other + for other in others + if other.kind == "range" + and other.attrs.get("row_id") == row_id + and _is_number(other.attrs.get("start")) + and other.attrs["start"] > start + ] + if not later: + return _broken( + f"joined_with_next=true on last range of row {row!r};" + " no next neighbor to join with" + ) + next_start = min(other.attrs["start"] for other in later) + following = [other.id for other in later if other.attrs["start"] == next_start] + if next_start != end: + return Outcome( + False, + f"joined_with_next=true but next range on row {row!r} starts at" + f" {float(next_start)} (must equal {float(end)} for ranges to join)", + following, + ) + if joined not in following: + return Outcome( + False, + "joined_with_next=true but the range it joins isn't the next range" + f" on row {row!r}", + following, + ) + return None + + +def _qt_reads(text: str) -> bool: + """Whether Qt reads `text` as a color, as `QColor(text).isValid()` says.""" + if text.startswith("#"): + digits = text[1:] + return len(digits) in _HEX_LENGTHS and all(c in _HEX_DIGITS for c in digits) + # Qt reads a name of up to 255 UTF-16 units, in any case, leaving out spaces + # and tabs. A NUL, or a character beyond Latin-1, ends it. + if len(text.encode("utf-16-le")) // 2 > 255: + return False + name = [] + for character in text: + if character in " \t": + continue + if character == "\0" or ord(character) > 0xFF: + break + name.append(character.lower()) + return "".join(name) in _COLOR_NAMES + + +def _media_time(seconds: float) -> str: + """A time as TiLiA's `format_media_time` shows it ("01:05.0", "1:02:05.0"), + with a minus sign before a negative one ("-00:01.0"). It is rounded to tenths + before its minutes are counted, so 59.96 is "01:00.0", not "00:60.0".""" + if seconds < 0: + return "-" + _media_time(-seconds) + seconds = float(f"{seconds:.1f}") + seconds_and_fraction = f"{seconds % 60:.1f}".zfill(4) + minutes = int(seconds // 60) + hours = f"{minutes // 60}:" if minutes >= 60 else "" + return f"{hours}{str(minutes % 60).zfill(2)}:{seconds_and_fraction}" + + +def _is_number(value: Any) -> bool: + """A finite number, as JSON stores one: not a bool, nan, an infinity, or an + integer too large for a float.""" + if isinstance(value, bool) or not isinstance(value, (int, float)): + return False + try: + return math.isfinite(value) + except OverflowError: + return False + + +def _is_comparable(value: Any) -> bool: + """A number another can be compared with: an integer of any size, or a + finite float. A component that breaks a rule with one is still compared.""" + return _is_integer(value) or _is_number(value) + + +def _is_integer(value: Any) -> bool: + return isinstance(value, int) and not isinstance(value, bool) + + +def _is_row_id(value: Any) -> bool: + return ( + isinstance(value, str) + and len(value) == ROW_ID_LENGTH + and all(c in ROW_ID_CHARACTERS for c in value) + ) + + +def _shown(value: Any) -> str: + """`value` as a reason shows it. An integer Python won't turn into text, one + of more than 4,300 digits unless set otherwise, is named instead, and so is a + value holding one, by its type: "a list that can't be shown".""" + try: + return f"{value}" + except ValueError: + if isinstance(value, int): + return "an integer too long to show" + name = type(value).__name__ + article = "an" if name[:1].lower() in "aeiou" else "a" + return f"{article} {name} that can't be shown" + + +def _broken(reason: str) -> Outcome: + return Outcome(False, reason) diff --git a/packages/tilia-core/tests/test_rules.py b/packages/tilia-core/tests/test_rules.py new file mode 100644 index 000000000..fca4edd1f --- /dev/null +++ b/packages/tilia-core/tests/test_rules.py @@ -0,0 +1,1348 @@ +import collections +import copy +import importlib.util +import itertools +import math +import pickle +import random +import sys +from importlib.metadata import metadata + +import pytest +import webcolors + +from tilia_core import rules, tla + +OK = (True, "") +MEDIA = 60.0 +PAGES = 3 +ROW = "Ab12Cd" +ROWS = [{"id": ROW, "name": "Strings"}, {"id": "Ef34Gh", "name": "Winds"}] +INSTALL = 'pip install "tilia-core[harmony]"' + +POINT_KINDS = ["marker", "harmony", "mode", "pdf_marker", "beat"] +POINT_NAMES = { + "marker": "marker", + "harmony": "harmony", + "mode": "mode", + "pdf_marker": "PDF page marker", + "beat": "beat", +} +# Valid attributes for each kind, which a test changes one at a time. +VALID = { + "marker": {"time": 1.0}, + "harmony": {"time": 1.0}, + "mode": {"time": 1.0}, + "pdf_marker": {"time": 1.0, "page_number": 1}, + "beat": {"time": 1.0}, + "hierarchy": {"start": 1.0, "end": 2.0, "level": 1}, + "range": {"start": 1.0, "end": 2.0, "row_id": ROW}, +} + + +def check(kind, others=(), media_length=MEDIA, **attrs): + """Checks a component of `kind` with VALID's attributes, changed by `attrs`.""" + if kind in ("harmony", "mode"): + pytest.importorskip("music21", reason="needs the harmony extra") + attrs = {**VALID[kind], **attrs} + if kind == "pdf_marker": + return rules.pdf_marker(media_length, others, attrs, page_count=PAGES) + if kind == "range": + return rules.range_(media_length, others, attrs, rows=ROWS) + return getattr(rules, kind)(media_length, others, attrs) + + +def component(kind, **attrs): + return tla.Component(id=tla.new_id(), kind=kind, attrs=attrs) + + +def hierarchy(start, end, level, **attrs): + return component("hierarchy", start=start, end=end, level=level, **attrs) + + +def range_in(row_id, start, end, **attrs): + return component("range", start=start, end=end, row_id=row_id, **attrs) + + +@pytest.fixture +def music21(): + return pytest.importorskip("music21", reason="needs the harmony extra") + + +@pytest.fixture +def default_int_digits(): + # Python's default limit on the digits of an integer it turns into text. + if not hasattr(sys, "set_int_max_str_digits"): + pytest.skip("this Python shows integers of any length") + limit = sys.get_int_max_str_digits() + sys.set_int_max_str_digits(4300) + yield + sys.set_int_max_str_digits(limit) + + +@pytest.fixture +def without_music21(monkeypatch): + find_spec = importlib.util.find_spec + + def find_spec_without_music21(name, *args, **kwargs): + if name.partition(".")[0] == "music21": + return None + return find_spec(name, *args, **kwargs) + + monkeypatch.setattr(importlib.util, "find_spec", find_spec_without_music21) + + +@pytest.fixture +def broken_music21(monkeypatch): + # Installed, so found, but importing it fails, as with a numpy it can't use. + find_spec = importlib.util.find_spec + + def find_spec_with_music21(name, *args, **kwargs): + if name.partition(".")[0] == "music21": + return object() + return find_spec(name, *args, **kwargs) + + monkeypatch.setattr(importlib.util, "find_spec", find_spec_with_music21) + for name in ("music21", "music21.harmony", "music21.key"): + monkeypatch.setitem(sys.modules, name, None) + + +@pytest.fixture +def music21_raising(tmp_path, monkeypatch): + # A music21 that is installed but raises something other than ImportError + # when imported, as with a numpy it can't use. + package = tmp_path / "music21" + package.mkdir() + (package / "__init__.py").write_text( + 'raise AttributeError("np.float_ was removed in the NumPy 2.0 release.")\n' + ) + for name in [name for name in sys.modules if name.partition(".")[0] == "music21"]: + monkeypatch.delitem(sys.modules, name) + monkeypatch.syspath_prepend(str(tmp_path)) + importlib.invalidate_caches() + + +# TiLiA's creation rules, which its importers apply: `validate_creation` of its +# point-like and segment-like components, and the range timeline's +# `_validate_component_creation`. They say whether TiLiA creates a component, +# for the tests that show a rule the core adds. They take only what TiLiA's +# creation rules look at: not a colour, a text or a harmony's values, which only +# `set_data` checks, so TiLiA's importers store any of them. + + +def tilia_creates_point(time, existing_times=(), media_length=MEDIA, **_): + if time > media_length: + return False + elif time < 0: + return False + return time not in existing_times + + +def tilia_creates_hierarchy(start, end, level, existing=(), media_length=MEDIA, **_): + if start > media_length: + return False + elif end > media_length: + return False + elif end <= start: + return False + return (start, end, level) not in existing + + +def tilia_creates_range( + start, end, row_id, pre_start=None, post_end=None, media_length=MEDIA, **_ +): + if not row_id or row_id not in [row["id"] for row in ROWS]: + return False + if start >= end: + return False + if start < 0 or end > media_length: + return False + if pre_start is not None and pre_start < 0: + return False + if post_end is not None and post_end > media_length: + return False + return True + + +# `dev`'s comparisons of hierarchy boundaries since 2ceec157: two times count as +# the same when they are within 1e-9 of each other, or within a billionth of the +# larger, whichever is more (`math.isclose`'s default `rel_tol` is 1e-9). + + +def _times_close(a, b): + return math.isclose(a, b, abs_tol=1e-9) + + +def _le(a, b): + return a < b or _times_close(a, b) + + +def _lt(a, b): + return a < b and not _times_close(a, b) + + +def tilia_boundary_conflicts(hierarchies): + """`HierarchyTLComponentManager.get_boundary_conflicts`, on (start, end, level)s.""" + + def starts_or_ends_strictly_inside(outer, inner): + return (_lt(outer[0], inner[0]) and _lt(inner[0], outer[1])) or ( + _lt(outer[0], inner[1]) and _lt(inner[1], outer[1]) + ) + + conflicts = [] + for hrc1, hrc2 in itertools.combinations(hierarchies, 2): + start1, end1, level1 = hrc1 + start2, end2, level2 = hrc2 + if starts_or_ends_strictly_inside(hrc1, hrc2): + if level2 >= level1: + conflicts.append((hrc1, hrc2)) + elif not _lt(start2, start1) and _lt(end1, end2): + conflicts.append((hrc1, hrc2)) + if starts_or_ends_strictly_inside(hrc2, hrc1): + if level1 >= level2: + conflicts.append((hrc2, hrc1)) + elif not _lt(start1, start2) and _lt(end2, end1): + conflicts.append((hrc2, hrc1)) + if ( + _times_close(start1, start2) + and _times_close(end1, end2) + and level1 == level2 + ): + conflicts.append((hrc1, hrc2)) + return conflicts + + +class TestOutcome: + def test_a_kept_rule_is_true_and_no_reason(self): + assert rules.OK == (True, "") + assert rules.OK.others == () + + def test_an_outcome_is_true_only_when_no_rule_is_broken(self): + assert rules.OK + assert not rules.Outcome(False, "Invalid row ID.") + + def test_its_truth_is_a_bool_whatever_ok_was_given_as(self): + assert bool(rules.Outcome(1)) is True + assert bool(rules.Outcome(0, "Invalid row ID.")) is False + + def test_ok_is_a_bool_however_its_read(self): + assert rules.Outcome(1).ok is True + ok, _ = rules.Outcome(0, "Invalid row ID.") + assert ok is False + + def test_if_reads_as_keeping_to_the_rules(self): + assert rules.marker(60.0, [], {"time": 1.0}) + assert not rules.marker(60.0, [], {"time": -1.0}) + assert not rules.hierarchy(60.0, [], {"start": 0.0, "end": 1.0, "level": 0}) + + def test_a_broken_rule_unpacks_as_its_reason(self): + ok, reason = rules.Outcome(False, "Invalid row ID.") + + assert (ok, reason) == (False, "Invalid row ID.") + + @pytest.mark.parametrize( + "duplicate", [copy.copy, copy.deepcopy, lambda o: pickle.loads(pickle.dumps(o))] + ) + def test_an_outcome_copies_and_pickles_whole(self, duplicate): + outcome = rules.Outcome(False, "Invalid row ID.", ["a"]) + + copied = duplicate(outcome) + + assert copied == (False, "Invalid row ID.") + assert copied.others == ("a",) + assert not copied + assert duplicate(rules.OK) + + def test_cant_check_pickles_whole(self): + error = rules.CantCheck("harmony") + + copied = pickle.loads(pickle.dumps(error)) + + assert str(copied) == str(error) + assert copied.kind == "harmony" + + def test_cant_check_copies_and_pickles_whatever_its_error(self): + class Picky(Exception): + def __init__(self, a, b): + super().__init__(f"{a} {b}") + + error = rules.CantCheck("harmony", Picky(1, 2)) + + for duplicate in ( + copy.copy, + copy.deepcopy, + lambda e: pickle.loads(pickle.dumps(e)), + ): + copied = duplicate(error) + assert str(copied) == str(error) + assert copied.kind == "harmony" + + def test_a_broken_rule_names_the_other_components(self): + outcome = rules.Outcome(False, "Component would overlap", ["a", "b"]) + + assert outcome == (False, "Component would overlap") + assert outcome.others == ("a", "b") + assert (outcome.ok, outcome.reason) == (False, "Component would overlap") + + @pytest.mark.parametrize( + "outcome", + [rules.Outcome(True), rules.Outcome(False, "Invalid row ID.", ["a"])], + ) + def test_its_others_cant_be_changed(self, outcome): + # Every outcome that keeps to the rules is the one `OK`, so changing its + # others would change them all. The test changes new outcomes, so that it + # leaves `OK` as it is if it fails. + others = outcome.others + + with pytest.raises(AttributeError): + outcome.others = ("zz",) + + assert outcome.others == others + + +class TestEveryKind: + @pytest.mark.parametrize( + "kind, attribute, reason", + [ + ("marker", "time", "nan is not a valid time."), + ("harmony", "time", "nan is not a valid time."), + ("mode", "time", "nan is not a valid time."), + ("pdf_marker", "time", "nan is not a valid time."), + ("beat", "time", "nan is not a valid time."), + ("hierarchy", "start", "nan is not a valid start."), + ("hierarchy", "end", "nan is not a valid end."), + ("hierarchy", "pre_start", "nan is not a valid pre start."), + ("hierarchy", "post_end", "nan is not a valid post end."), + ("range", "start", "nan is not a valid start."), + ("range", "end", "nan is not a valid end."), + ("range", "pre_start", "nan is not a valid pre start."), + ("range", "post_end", "nan is not a valid post end."), + ], + ) + def test_a_time_of_nan(self, kind, attribute, reason): + assert check(kind, **{attribute: float("nan")}) == (False, reason) + + def test_tilia_creates_components_at_nan(self): + nan = float("nan") + + assert tilia_creates_point(nan) + assert tilia_creates_hierarchy(nan, 2.0, 1) + assert tilia_creates_hierarchy(1.0, nan, 1) + assert tilia_creates_range(nan, 2.0, ROW) + assert tilia_creates_range(1.0, 2.0, ROW, pre_start=nan) + + @pytest.mark.parametrize("value", [float("inf"), float("-inf"), "1.0", None, True]) + def test_a_time_that_isnt_a_finite_number(self, value): + assert check("marker", time=value) == ( + False, + f"{value} is not a valid time.", + ) + + def test_an_integer_too_large_for_a_float(self): + huge = 10**400 + + assert check("marker", time=huge) == (False, f"{huge} is not a valid time.") + assert check("hierarchy", end=huge) == (False, f"{huge} is not a valid end.") + + @pytest.mark.parametrize("kind", [*POINT_KINDS, "hierarchy", "range"]) + def test_a_media_length_too_large_for_a_float(self, kind): + assert check(kind, media_length=10**400) == OK + + def test_an_integer_too_long_to_show(self, default_int_digits): + huge = 10**5000 + + assert check("marker", time=huge) == ( + False, + "an integer too long to show is not a valid time.", + ) + assert check("hierarchy", level=-huge) == ( + False, + "Level can't be below 1. Got 'an integer too long to show'", + ) + + @pytest.mark.parametrize( + "value, shown", + [ + ([10**5000], "a list"), + ((10**5000,), "a tuple"), + ({"a": 10**5000}, "a dict"), + (collections.OrderedDict(a=10**5000), "an OrderedDict"), + ], + ) + def test_another_value_that_cant_be_shown(self, default_int_digits, value, shown): + assert check("marker", label=value) == ( + False, + f"{shown} that can't be shown is not a valid label.", + ) + + def test_a_missing_time(self): + assert rules.marker(MEDIA, [], {}) == (False, "None is not a valid time.") + + @pytest.mark.parametrize("time", [0, 60]) + def test_times_given_as_integers(self, time): + assert check("marker", time=time) == OK + + @pytest.mark.parametrize( + "kind, attribute, reason", + [ + ("marker", "label", "3 is not a valid label."), + ("marker", "comments", "3 is not a valid comments."), + ("hierarchy", "label", "3 is not a valid label."), + ("range", "comments", "3 is not a valid comments."), + ("harmony", "custom_text", "3 is not a valid custom text."), + ("mode", "comments", "3 is not a valid comments."), + ], + ) + def test_a_text_that_isnt_text(self, kind, attribute, reason): + assert check(kind, **{attribute: 3}) == (False, reason) + + @pytest.mark.parametrize("attribute", ["label", "comments"]) + def test_texts(self, attribute): + assert check("marker", **{attribute: ""}) == OK + assert check("marker", **{attribute: "Exposição 🎻"}) == OK + + def test_a_text_not_in_nfc_is_kept(self): + # Text is stored in NFC by whatever stores it: the reader, the + # writer and the CSV reader. + assert check("marker", label="Exposição") == OK + + +class TestColor: + def test_the_names_are_css_3s_and_transparent(self): + # The names Qt reads: CSS 3's, from webcolors, and Qt's "transparent". + for name in [*webcolors.names("css3"), "transparent"]: + assert rules.color(name), name + assert not rules.color("rebeccapurple") # CSS 4's, which Qt doesn't read + + @pytest.mark.parametrize( + "value", + [ + None, + "", + "#f0a", + "#FF00aa", + "#80FF00aa", + "#fff000aaa", + "#ffff0000aaaa", + "steelblue", + "SteelBlue", + "steel blue", + "\tred ", + "transparent", + ], + ) + def test_the_default_or_a_color_qt_reads(self, value): + assert rules.color(value) == OK + + @pytest.mark.parametrize( + "value", + [ + "notacolor", + "#ff", + "#ffff", + "#fffffffffffffff", + "#ggg", + "#123", + " #fff", + "rgb(1, 2, 3)", + "red\n", + 0, + True, + ], + ) + def test_a_color_that_isnt_one(self, value): + assert rules.color(value) == (False, f"{value} is not a valid color.") + + @pytest.mark.parametrize("kind", ["marker", "hierarchy", "range"]) + def test_each_kind_with_a_color_checks_it(self, kind): + assert check(kind, color="#f0a") == OK + assert check(kind, color="notacolor") == ( + False, + "notacolor is not a valid color.", + ) + + @pytest.mark.parametrize( + "kind, attrs, reason", + [ + ( + "marker", + {"time": 61.0}, + "Time '01:01.0' is bigger than media time '01:00.0'", + ), + ("hierarchy", {"level": 0}, "Level can't be below 1. Got '0'"), + ("range", {"row_id": "Zz99Zz"}, "Invalid row ID."), + ("marker", {"label": 3}, "3 is not a valid label."), + ], + ) + def test_a_bad_color_doesnt_hide_another_broken_rule(self, kind, attrs, reason): + # An importer keeps a row whose color isn't one, so the color is checked last. + assert check(kind, color="notacolor", **attrs) == (False, reason) + + def test_a_bad_row_color_doesnt_hide_another_broken_row(self): + rows = [ + {"id": ROW, "name": "Strings", "color": "notacolor"}, + {"id": ROW, "name": "Winds"}, + ] + + assert rules.range_rows(rows) == ( + False, + f"There is already a row with the id {ROW}.", + ) + + def test_colors_are_those_qt_reads(self): + qt = pytest.importorskip("PySide6.QtGui", reason="compares with Qt") + names = qt.QColor.colorNames() + samples = [ + *names, + *(name.upper() for name in names), + *(" ".join(name) for name in names), + "#fff", + "#FfF", + "#ffff", + "#fffff", + "#ff00ff00", + "#fffffffff", + "#ffffffffffff", + "#fffffffffffffff", + "#", + "#+12345", + "#12 345", + "#\tfff", + "#fff\t", + " #fff", + "fff", + "red\t", + "\tr\te\td\t", + "red\n", + "red ", + "red\x00", + "re\x00d", + "redΩ", + "redΩblah", + "Khaki", + "whİte", + "ſteelblue", + "RED", + "red\U0001f3bb", + "notacolor", + "a" * 300, + " " * 300 + "red", + " " * 250 + "red", + ] + + disagree = [ + sample + for sample in samples + if rules.color(sample).ok is not qt.QColor(sample).isValid() + ] + + assert disagree == [] + + +class TestPointKinds: + @pytest.mark.parametrize("kind", POINT_KINDS) + @pytest.mark.parametrize("time", [0.0, 30.0, MEDIA]) + def test_a_time_from_0_to_the_media_length(self, kind, time): + assert check(kind, time=time) == OK + + @pytest.mark.parametrize("kind", POINT_KINDS) + def test_a_negative_time(self, kind): + assert check(kind, time=-1.0) == ( + False, + "Time can't be negative. Got '-00:01.0'", + ) + + @pytest.mark.parametrize( + "time, shown", + [(-0.5, "-00:00.5"), (-61.5, "-01:01.5"), (-3725.0, "-1:02:05.0")], + ) + def test_a_negative_time_is_shown_with_a_minus_sign(self, time, shown): + assert check("marker", time=time, media_length=7200.0) == ( + False, + f"Time can't be negative. Got '{shown}'", + ) + + @pytest.mark.parametrize("kind", POINT_KINDS) + def test_a_time_beyond_the_media_length(self, kind): + assert check(kind, time=60.5) == ( + False, + "Time '01:00.5' is bigger than media time '01:00.0'", + ) + + def test_times_of_an_hour_or_more_show_hours(self): + assert check("marker", time=3725.0, media_length=3600.0) == ( + False, + "Time '1:02:05.0' is bigger than media time '1:00:00.0'", + ) + + def test_media_length_given_as_an_integer(self): + assert check("marker", time=61.0, media_length=60) == ( + False, + "Time '01:01.0' is bigger than media time '01:00.0'", + ) + + @pytest.mark.parametrize( + "media_length, shown", + [(59.96, "01:00.0"), (3599.96, "1:00:00.0"), (59.94, "00:59.9")], + ) + def test_a_time_is_rounded_before_its_minutes_are_counted( + self, media_length, shown + ): + assert check("marker", time=7200.0, media_length=media_length) == ( + False, + f"Time '2:00:00.0' is bigger than media time '{shown}'", + ) + + @pytest.mark.parametrize("kind", POINT_KINDS) + @pytest.mark.parametrize("time", [0.0, 1.0, MEDIA]) + def test_a_time_another_component_of_the_kind_has(self, kind, time): + same = component(kind, **{**VALID[kind], "time": time}) + others = [component(kind, **{**VALID[kind], "time": 30.5}), same] + + outcome = check(kind, others, time=time) + + assert outcome == ( + False, + f"There is already a {POINT_NAMES[kind]} component at the selected position.", + ) + assert outcome.others == (same.id,) + + @pytest.mark.parametrize("kind", POINT_KINDS) + def test_another_kind_at_the_same_time(self, kind): + other_kind = "mode" if kind == "harmony" else "harmony" + others = [component(other_kind, time=1.0)] + + assert check(kind, others, time=1.0) == OK + + def test_bounds_are_checked_before_other_components(self): + others = [component("marker", time=61.0)] + + assert check("marker", others, time=61.0) == ( + False, + "Time '01:01.0' is bigger than media time '01:00.0'", + ) + + +class TestPdfMarker: + @pytest.mark.parametrize("page_number", [1, 2, PAGES]) + def test_a_page_the_pdf_has(self, page_number): + assert check("pdf_marker", page_number=page_number) == OK + + @pytest.mark.parametrize("page_number", [0, PAGES + 1, -1]) + def test_a_page_the_pdf_doesnt_have(self, page_number): + assert check("pdf_marker", page_number=page_number) == ( + False, + "The PDF has no page with that number.", + ) + + def test_a_pdf_that_cant_be_read_has_no_pages(self): + attrs = {"time": 1.0, "page_number": 1} + + assert rules.pdf_marker(MEDIA, [], attrs, page_count=0) == ( + False, + "The PDF has no page with that number.", + ) + + @pytest.mark.parametrize("page_number", [1.5, 1.0, "1", True, None]) + def test_a_page_number_that_isnt_an_integer(self, page_number): + assert check("pdf_marker", page_number=page_number) == ( + False, + f"{page_number} is not a valid page number.", + ) + + +class TestHarmony: + @pytest.mark.parametrize( + "attribute, values", + [ + ("step", [0, 6]), + ("applied_to", [0, 6]), + ("accidental", [-2, -1, 0, 1, 2]), + ("inversion", [0, 2]), + ("level", [1, 2]), + ("display_mode", ["letter", "roman", "custom"]), + ("custom_text_font_type", ["analytic", "normal"]), + ], + ) + def test_values_tilia_allows(self, attribute, values): + for value in values: + assert check("harmony", **{attribute: value}) == OK + + @pytest.mark.parametrize( + "attribute, value, reason", + [ + ("step", -1, "-1 is not a valid step."), + ("step", 7, "7 is not a valid step."), + ("step", 1.0, "1.0 is not a valid step."), + ("step", True, "True is not a valid step."), + ("applied_to", 7, "7 is not a valid applied to."), + ("accidental", 3, "3 is not a valid accidental."), + ("accidental", -3, "-3 is not a valid accidental."), + ("quality", "maj", "maj is not a valid quality."), + ("quality", None, "None is not a valid quality."), + ("inversion", -1, "-1 is not a valid inversion."), + ("inversion", 3, "3 is not a valid inversion."), + ("level", 0, "0 is not a valid level."), + ("level", 3, "3 is not a valid level."), + ("display_mode", "roman2", "roman2 is not a valid display mode."), + ( + "custom_text_font_type", + "bold", + "bold is not a valid custom text font type.", + ), + ], + ) + def test_values_tilia_refuses_when_it_sets_them(self, attribute, value, reason): + assert check("harmony", **{attribute: value}) == (False, reason) + + def test_every_quality_music21_has(self, music21): + for quality in music21.harmony.CHORD_TYPES: + assert check("harmony", quality=quality) == OK + + def test_qualities_are_read_from_the_installed_music21(self, music21, monkeypatch): + monkeypatch.setitem( + music21.harmony.CHORD_TYPES, "made-up", ["1,2,3,4", ["made-up"]] + ) + + assert check("harmony", quality="made-up", inversion=3) == OK + + @pytest.mark.parametrize( + "quality, inversions", + [("major", 2), ("dominant-seventh", 3), ("power", 0), ("Italian", 0)], + ) + def test_inversions_as_tilia_counts_them(self, quality, inversions): + assert check("harmony", quality=quality, inversion=inversions) == OK + assert check("harmony", quality=quality, inversion=inversions + 1) == ( + False, + f"{inversions + 1} is not a valid inversion.", + ) + + @pytest.mark.parametrize( + "quality, base, tone, inversions", + [ + ("dominant-seventh-flat-ninth", "dominant-seventh", "b9", 3), + ("dominant-seventh-sharp-ninth", "dominant-seventh", "#9", 3), + ("dominant-seventh-sharp-eleventh", "dominant-seventh", "#11", 3), + ("dominant-seventh-flat-thirteenth", "dominant-seventh", "b13", 3), + ("dominant-seventh-added-thirteenth", "dominant-seventh", "13", 3), + ("dominant-ninth-sharp-eleventh", "dominant-ninth", "#11", 4), + ("major-seventh-sharp-eleventh", "major-seventh", "#11", 3), + ("major-seventh-added-sixth", "major-seventh", "6", 3), + ("major-sixth-added-ninth", "major-sixth", "9", 3), + ("minor-sixth-added-ninth", "minor-sixth", "9", 3), + ("major-added-ninth", "major", "9", 2), + ("minor-added-ninth", "minor", "9", 2), + ("minor-seventh-added-eleventh", "minor-seventh", "11", 3), + ], + ) + def test_the_added_tone_qualities_tilia_has(self, quality, base, tone, inversions): + # An added tone isn't one the chord can be inverted to: the base quality's + # inversions count. + assert rules.split_added_tone(quality) == (base, tone) + assert check("harmony", quality=quality, inversion=inversions) == OK + assert check("harmony", quality=quality, inversion=inversions + 1) == ( + False, + f"{inversions + 1} is not a valid inversion.", + ) + + def test_a_quality_without_an_added_tone_is_its_own_base(self): + assert rules.split_added_tone("major") == ("major", "") + + def test_the_inversion_of_the_default_quality(self): + assert check("harmony", inversion=2) == OK + + def test_the_rules_in_order(self): + assert check("harmony", step=7, level=3) == (False, "7 is not a valid step.") + + +class TestMode: + @pytest.mark.parametrize( + "attribute, values", + [ + ("step", [0, 6]), + ("accidental", [-2, 2]), + ( + "type", + [ + "major", + "minor", + "dorian", + "phrygian", + "lydian", + "mixolydian", + "locrian", + ], + ), + ("level", [1, 2]), + ], + ) + def test_values_tilia_allows(self, attribute, values): + for value in values: + assert check("mode", **{attribute: value}) == OK + + @pytest.mark.parametrize( + "attribute, value, reason", + [ + ("step", 7, "7 is not a valid step."), + ("accidental", 3, "3 is not a valid accidental."), + ("type", "ionian", "ionian is not a valid type."), + ("type", "aeolian", "aeolian is not a valid type."), + ("type", "dorianx", "dorianx is not a valid type."), + ("level", 3, "3 is not a valid level."), + ], + ) + def test_values_tilia_refuses_when_it_sets_them(self, attribute, value, reason): + assert check("mode", **{attribute: value}) == (False, reason) + + def test_types_are_read_from_the_installed_music21(self, music21, monkeypatch): + monkeypatch.setitem(music21.key.modeSharpsAlter, "made-up", 0) + + assert check("mode", type="made-up") == OK + + +class TestWithoutMusic21: + @pytest.mark.parametrize("kind", ["harmony", "mode"]) + def test_checking_fails_naming_the_install_command(self, kind, without_music21): + with pytest.raises(rules.CantCheck) as raised: + getattr(rules, kind)(MEDIA, [], {"time": 1.0}) + + assert INSTALL in str(raised.value) + assert raised.value.command == INSTALL + + def test_the_command_installs_an_extra_the_core_has(self): + assert "harmony" in metadata("tilia-core").get_all("Provides-Extra", []) + + @pytest.mark.parametrize("kind", ["harmony", "mode"]) + def test_a_music21_that_wont_import(self, kind, broken_music21): + with pytest.raises(rules.CantCheck) as raised: + getattr(rules, kind)(MEDIA, [], {"time": 1.0}) + + assert isinstance(raised.value.__cause__, ImportError) + assert str(raised.value) == ( + f"checking a {kind} needs music21, which can't be imported" + " (ModuleNotFoundError: import of music21.harmony halted; None in" + f" sys.modules): {INSTALL}" + ) + + @pytest.mark.parametrize("kind", ["harmony", "mode"]) + def test_a_bad_color_doesnt_hide_that_it_cant_be_checked( + self, kind, without_music21 + ): + # An importer keeps a component whose only problem is its color. + with pytest.raises(rules.CantCheck): + getattr(rules, kind)(MEDIA, [], {"time": 1.0, "color": "notacolor"}) + + @pytest.mark.parametrize("kind", ["harmony", "mode"]) + def test_a_music21_that_raises_something_else(self, kind, music21_raising): + with pytest.raises(rules.CantCheck) as raised: + getattr(rules, kind)(MEDIA, [], {"time": 1.0}) + + assert isinstance(raised.value.__cause__, AttributeError) + assert str(raised.value) == ( + f"checking a {kind} needs music21, which can't be imported" + " (AttributeError: np.float_ was removed in the NumPy 2.0 release.):" + f" {INSTALL}" + ) + + @pytest.mark.parametrize("kind", ["harmony", "mode"]) + def test_the_other_rules_are_checked(self, kind, without_music21): + attrs = {"time": 1.0, "level": 3, "quality": "maj", "type": "dorianx"} + + assert getattr(rules, kind)(MEDIA, [], attrs) == ( + False, + "3 is not a valid level.", + ) + + @pytest.mark.parametrize("kind", ["marker", "pdf_marker", "beat", "hierarchy"]) + def test_other_kinds_dont_need_it(self, kind, without_music21): + assert check(kind) == OK + + +class TestHierarchy: + @pytest.mark.parametrize("start, end", [(0.0, 1.0), (1.0, MEDIA), (0.0, MEDIA)]) + def test_within_the_media_length(self, start, end): + assert check("hierarchy", start=start, end=end) == OK + + def test_a_start_beyond_the_media_length(self): + assert check("hierarchy", start=61.0, end=62.0) == ( + False, + "Start time '61.0' is bigger than media time '60.0'", + ) + + def test_an_end_beyond_the_media_length(self): + assert check("hierarchy", start=1.0, end=61.0) == ( + False, + "End time '61.0' is bigger than media time '60.0'", + ) + + @pytest.mark.parametrize("start", [-1.0, -0.5]) + def test_a_negative_start(self, start): + assert check("hierarchy", start=start) == ( + False, + f"Start time can't be negative. Got '{start}'", + ) + assert tilia_creates_hierarchy(start, 2.0, 1) + + @pytest.mark.parametrize("end", [2.0, 1.0]) + def test_an_end_not_after_its_start(self, end): + assert check("hierarchy", start=2.0, end=end) == ( + False, + f"End time '{end}' should be bigger than start time '2.0'", + ) + + @pytest.mark.parametrize( + "start, end, reason", + [ + (61, 62, "Start time '61.0' is bigger than media time '60.0'"), + (1, 61, "End time '61.0' is bigger than media time '60.0'"), + (-1, 2, "Start time can't be negative. Got '-1.0'"), + (2, 1, "End time '1.0' should be bigger than start time '2.0'"), + ], + ) + def test_numbers_given_as_integers_read_as_tilias_floats(self, start, end, reason): + assert check("hierarchy", start=start, end=end, media_length=60) == ( + False, + reason, + ) + + @pytest.mark.parametrize("level", [0, -1]) + def test_a_level_below_1(self, level): + assert check("hierarchy", level=level) == ( + False, + f"Level can't be below 1. Got '{level}'", + ) + assert tilia_creates_hierarchy(1.0, 2.0, level) + + @pytest.mark.parametrize("level", [1.5, 1.0, "1", True, None]) + def test_a_level_that_isnt_an_integer(self, level): + assert check("hierarchy", level=level) == ( + False, + f"{level} is not a valid level.", + ) + + @pytest.mark.parametrize("pre_start", [None, 0.0, 0.5, 1.0]) + def test_a_pre_start_from_0_to_the_start(self, pre_start): + assert check("hierarchy", pre_start=pre_start) == OK + + def test_a_pre_start_below_0(self): + assert check("hierarchy", pre_start=-0.5) == ( + False, + "pre_start is outside media bounds.", + ) + assert tilia_creates_hierarchy(1.0, 2.0, 1, pre_start=-0.5) + + def test_a_pre_start_after_the_start(self): + assert check("hierarchy", pre_start=1.5) == ( + False, + "pre_start can't be after start time.", + ) + assert tilia_creates_hierarchy(1.0, 2.0, 1, pre_start=1.5) + + @pytest.mark.parametrize("post_end", [None, 2.0, 30.0, MEDIA]) + def test_a_post_end_from_the_end_to_the_media_length(self, post_end): + assert check("hierarchy", post_end=post_end) == OK + + def test_a_post_end_before_the_end(self): + assert check("hierarchy", post_end=1.5) == ( + False, + "post_end can't be before end time.", + ) + assert tilia_creates_hierarchy(1.0, 2.0, 1, post_end=1.5) + + def test_a_post_end_beyond_the_media_length(self): + assert check("hierarchy", post_end=60.5) == ( + False, + "post_end is outside media bounds.", + ) + assert tilia_creates_hierarchy(1.0, 2.0, 1, post_end=60.5) + + def test_the_position_of_another_hierarchy(self): + same = hierarchy(1.0, 2.0, 1) + others = [hierarchy(0.0, 1.0, 1), same] + + outcome = check("hierarchy", others, start=1.0, end=2.0, level=1) + + assert outcome == ( + False, + "There is already a hierarchy component at the selected position.", + ) + assert outcome.others == (same.id,) + + def test_the_same_times_at_another_level(self): + others = [hierarchy(1.0, 2.0, 1)] + + assert check("hierarchy", others, start=1.0, end=2.0, level=2) == OK + + def test_times_are_checked_before_other_components(self): + others = [hierarchy(2.0, 1.0, 1)] + + assert check("hierarchy", others, start=2.0, end=1.0) == ( + False, + "End time '1.0' should be bigger than start time '2.0'", + ) + + @pytest.mark.parametrize( + "existing, candidate", + [ + ((0, 10, 1), (5, 15, 1)), # they cross, at one level + ((5, 15, 1), (0, 10, 1)), + ((0, 10, 2), (5, 15, 1)), # they cross, at two levels + ((0, 10, 1), (5, 15, 2)), + ((0, 10, 1), (2, 5, 2)), # inside a lower one + ((0, 10, 1), (0, 5, 2)), + ((2, 5, 1), (0, 10, 1)), # around one at its level + ((2, 5, 2), (0, 10, 1)), # around a higher one + ((0, 10, 1), (2, 5, 1)), # inside one at its level + ], + ) + def test_hierarchies_that_dont_nest(self, existing, candidate): + other = hierarchy(*existing) + start, end, level = candidate + + outcome = check("hierarchy", [other], start=start, end=end, level=level) + + assert outcome == (False, "Component would overlap with existing component.") + assert outcome.others == (other.id,) + assert tilia_creates_hierarchy(*candidate, existing=[existing]) + + @pytest.mark.parametrize( + "existing, candidate", + [ + ((0, 10, 2), (2, 5, 1)), # inside a higher one + ((0, 10, 2), (0, 10, 1)), # the same times, at another level + ((2, 5, 1), (0, 10, 2)), # around a lower one + ((0, 10, 1), (10, 20, 1)), # side by side + ((10, 20, 1), (0, 10, 2)), + ], + ) + def test_hierarchies_that_nest(self, existing, candidate): + start, end, level = candidate + others = [hierarchy(*existing)] + + assert check("hierarchy", others, start=start, end=end, level=level) == OK + + def test_every_hierarchy_it_crosses(self): + crossed = [hierarchy(0, 10, 1), hierarchy(10, 20, 1)] + others = [*crossed, hierarchy(0, 30, 3)] + + outcome = check("hierarchy", others, start=5, end=15, level=2) + + assert outcome.others == tuple(h.id for h in crossed) + + def test_crossing_one_whose_start_is_a_huge_integer(self): + other = hierarchy(-(10**400), 10, 1) + + outcome = check("hierarchy", [other], start=5, end=15, level=1) + + assert outcome == (False, "Component would overlap with existing component.") + + def test_crossing_one_whose_level_is_a_huge_integer(self): + other = hierarchy(0, 10, 10**400) + + outcome = check("hierarchy", [other], start=5, end=15, level=1) + + assert outcome == (False, "Component would overlap with existing component.") + + def test_crossing_one_whose_level_is_written_as_a_float(self): + other = hierarchy(0, 10, 1.0) + + outcome = check("hierarchy", [other], start=5, end=15, level=1) + + assert outcome == (False, "Component would overlap with existing component.") + + def test_nesting_is_tilias_boundary_conflicts(self): + # Every pair of hierarchies with times from 0 to 4 and levels from 1 to 8. + spans = [ + (start, end, level) + for start, end in itertools.combinations(range(5), 2) + for level in range(1, 9) + ] + for existing, candidate in itertools.product(spans, repeat=2): + start, end, level = candidate + others = [hierarchy(*existing)] + + outcome = check("hierarchy", others, start=start, end=end, level=level) + + conflicts = tilia_boundary_conflicts([existing, candidate]) + assert outcome.ok is not bool(conflicts), (existing, candidate) + + @pytest.mark.parametrize( + "existing, candidate", + [ + # As in files TiLiA saved before it rescaled subtrees in one step. + ((0.0, 1.0, 1), (1.0 - 1e-13, 2.0, 1)), + # Below 1 s, boundaries within 1e-9 meet. + ((1e-13, 1.0, 2), (0.0, 1.0, 1)), + ((0.0, 0.5, 1), (0.5 - 9e-10, 1.0, 1)), + # Above it, boundaries within a billionth of the larger. + ((0.0, 3600.0, 1), (3600.0 - 2e-6, 3601.0, 1)), + ], + ) + def test_close_boundaries_meet(self, existing, candidate): + start, end, level = candidate + others = [hierarchy(*existing)] + + outcome = check( + "hierarchy", others, media_length=7200.0, start=start, end=end, level=level + ) + + assert outcome == OK + + @pytest.mark.parametrize( + "existing, candidate", + [ + ((0.0, 0.5, 1), (0.5 - 2e-9, 1.0, 1)), + ((0.0, 3600.0, 1), (3600.0 - 5e-6, 3601.0, 1)), + ], + ) + def test_boundaries_further_apart_cross(self, existing, candidate): + start, end, level = candidate + others = [hierarchy(*existing)] + + outcome = check( + "hierarchy", others, media_length=7200.0, start=start, end=end, level=level + ) + + assert outcome == (False, "Component would overlap with existing component.") + + def test_nesting_with_float_noise_is_tilias_boundary_conflicts(self): + # Every pair of spans with boundaries from 0 to 4 s at levels 1 to 3, with + # each boundary moved by up to 2e-9, and none before 0. Below 1 s, 1e-9 is + # more than a billionth of a time, and above it less, so the sweep compares + # both parts of the tolerance. + noise = random.Random(2026) + moves = (0, 1e-13, -1e-13, 5e-10, -5e-10, 2e-9, -2e-9) + spans = [ + (start, end, level) + for start, end in itertools.combinations((0.0, 0.25, 0.5, 1.0, 2.0, 4.0), 2) + for level in (1, 2, 3) + ] + for existing, candidate in itertools.product(spans, repeat=2): + for _ in range(4): + moved = [ + ( + max(0.0, start + noise.choice(moves)), + end + noise.choice(moves), + level, + ) + for start, end, level in (existing, candidate) + ] + start, end, level = moved[1] + + outcome = check( + "hierarchy", + [hierarchy(*moved[0])], + start=start, + end=end, + level=level, + ) + + conflicts = tilia_boundary_conflicts(moved) + assert outcome.ok is not bool(conflicts), moved + + def test_other_kinds_arent_compared(self): + others = [component("marker", time=1.0), range_in(ROW, 0.0, 5.0)] + + assert check("hierarchy", others) == OK + + +class TestRange: + def test_a_range_in_a_row(self): + assert check("range") == OK + + @pytest.mark.parametrize( + "start, end, pre_start, post_end", + [ + (0.0, MEDIA, None, None), + (1.0, 2.0, 0.0, MEDIA), + (1.0, 2.0, 1.0, 2.0), + (0.0, MEDIA, 0.0, MEDIA), + ], + ) + def test_the_edges(self, start, end, pre_start, post_end): + attrs = {"start": start, "end": end, "pre_start": pre_start} + + assert check("range", **attrs, post_end=post_end) == OK + + @pytest.mark.parametrize("row_id", ["", None, "Zz99Zz"]) + def test_a_row_the_timeline_doesnt_have(self, row_id): + assert check("range", row_id=row_id) == (False, "Invalid row ID.") + + @pytest.mark.parametrize("end", [1.0, 0.5]) + def test_an_end_not_after_its_start(self, end): + assert check("range", start=1.0, end=end) == ( + False, + "Start time must be before end time.", + ) + + @pytest.mark.parametrize("start, end", [(-0.5, 2.0), (1.0, 60.5)]) + def test_outside_the_media(self, start, end): + assert check("range", start=start, end=end) == ( + False, + "Range is outside media bounds.", + ) + + def test_a_pre_start_below_0(self): + assert check("range", pre_start=-0.5) == ( + False, + "pre_start is outside media bounds.", + ) + + def test_a_pre_start_after_the_start(self): + assert check("range", pre_start=1.5) == ( + False, + "pre_start can't be after start time.", + ) + assert tilia_creates_range(1.0, 2.0, ROW, pre_start=1.5) + + def test_a_post_end_beyond_the_media_length(self): + assert check("range", post_end=60.5) == ( + False, + "post_end is outside media bounds.", + ) + + def test_a_post_end_before_the_end(self): + assert check("range", post_end=1.5) == ( + False, + "post_end can't be before end time.", + ) + assert tilia_creates_range(1.0, 2.0, ROW, post_end=1.5) + + def test_the_row_is_checked_first(self): + assert check("range", start=2.0, end=1.0, row_id="Zz99Zz") == ( + False, + "Invalid row ID.", + ) + + def test_ranges_in_a_row_may_overlap(self): + others = [range_in(ROW, 0.0, 5.0), range_in(ROW, 1.0, 2.0)] + + assert check("range", others, start=1.0, end=2.0) == OK + + +class TestJoinedRight: + def test_joined_to_the_next_range_in_its_row(self): + following = range_in(ROW, 2.0, 3.0) + others = [range_in(ROW, 0.0, 1.0), range_in(ROW, 4.0, 5.0), following] + + assert check("range", others, joined_right=following.id) == OK + + def test_the_next_ranges_share_their_start(self): + following = [range_in(ROW, 2.0, 3.0), range_in(ROW, 2.0, 4.0)] + + for joined in following: + assert check("range", following, joined_right=joined.id) == OK + + def test_a_range_with_the_same_start_doesnt_come_next(self): + following = range_in(ROW, 2.0, 3.0) + others = [range_in(ROW, 1.0, 1.5), following] + + assert check("range", others, joined_right=following.id) == OK + + def test_the_last_range_of_its_row(self): + others = [range_in(ROW, 0.0, 1.0), range_in("Ef34Gh", 2.0, 3.0)] + + assert check("range", others, joined_right=others[1].id) == ( + False, + "joined_with_next=true on last range of row 'Strings';" + " no next neighbor to join with", + ) + + def test_the_next_range_doesnt_start_at_its_end(self): + following = range_in(ROW, 2.5, 3.0) + others = [following, range_in(ROW, 4.0, 5.0)] + + outcome = check("range", others, joined_right=following.id) + + assert outcome == ( + False, + "joined_with_next=true but next range on row 'Strings' starts at 2.5" + " (must equal 2.0 for ranges to join)", + ) + assert outcome.others == (following.id,) + + def test_joined_to_a_range_that_isnt_the_next(self): + between = range_in(ROW, 1.5, 1.8) + later = range_in(ROW, 2.0, 3.0) + + outcome = check("range", [between, later], end=1.5, joined_right=later.id) + + assert outcome == ( + False, + "joined_with_next=true but the range it joins isn't the next range" + " on row 'Strings'", + ) + assert outcome.others == (between.id,) + + def test_joined_to_a_range_in_another_row(self): + following = range_in(ROW, 2.0, 3.0) + elsewhere = range_in("Ef34Gh", 2.0, 3.0) + + outcome = check("range", [following, elsewhere], joined_right=elsewhere.id) + + assert outcome == ( + False, + "joined_with_next=true but the range it joins isn't the next range" + " on row 'Strings'", + ) + + +class TestRows: + def test_rows(self): + assert rules.range_rows(ROWS) == OK + assert rules.range_rows([{"id": "000000", "name": "", "color": "#f0a"}]) == OK + + @pytest.mark.parametrize( + "row_id", ["Ab12C", "Ab12Cd7", "Ab-2Cd", "Ab12Cé", 123456, None] + ) + def test_an_id_that_isnt_six_letters_and_digits(self, row_id): + assert rules.range_rows([{"id": row_id, "name": "Strings"}]) == ( + False, + f"{row_id} is not a valid row id.", + ) + + def test_an_id_two_rows_have(self): + rows = [{"id": ROW, "name": "Strings"}, {"id": ROW, "name": "Winds"}] + + assert rules.range_rows(rows) == ( + False, + f"There is already a row with the id {ROW}.", + ) + + @pytest.mark.parametrize("name", [3, None]) + def test_a_name_that_isnt_text(self, name): + assert rules.range_rows([{"id": ROW, "name": name}]) == ( + False, + f"{name} is not a valid row name.", + ) + + @pytest.mark.parametrize("color", [None, "", "steelblue"]) + def test_a_color(self, color): + assert rules.range_rows([{"id": ROW, "name": "Strings", "color": color}]) == OK + + def test_a_color_that_isnt_one(self): + assert rules.range_rows( + [{"id": ROW, "name": "Strings", "color": "notacolor"}] + ) == ( + False, + "notacolor is not a valid color.", + ) + + +class TestNotValid: + @pytest.mark.parametrize( + "attribute, value, text", + [ + ("time", "abc", "abc is not a valid time."), + ("post_end", "abc", "abc is not a valid post end."), + ("label", 3, "3 is not a valid label."), + ("page_number", 1.5, "1.5 is not a valid page number."), + ( + "custom_text_font_type", + "bold", + "bold is not a valid custom text font type.", + ), + ], + ) + def test_one_text_for_a_value_that_isnt_one(self, attribute, value, text): + assert rules.not_valid(attribute, value) == text