diff --git a/packages/tilia-core/src/tilia_core/build.py b/packages/tilia-core/src/tilia_core/build.py index 6e685c353..45b8fc270 100644 --- a/packages/tilia-core/src/tilia_core/build.py +++ b/packages/tilia-core/src/tilia_core/build.py @@ -1 +1,342 @@ """Building a document from a script: a new file, its fields, its media and its timelines.""" + +from __future__ import annotations + +import logging +import math +import numbers +import os +import re +import struct +from pathlib import Path +from typing import BinaryIO + +from tilia_core import tla +from tilia_core.labels import nfc +from tilia_core.timemap import pattern +from tilia_core.tla.layout import TIMELINE_SHAPES + +logger = logging.getLogger(__name__) + +# TiLiA's texts (tilia/errors.py and tilia/ui/cli/), word for word. +_CANT_SET_MEDIA_LENGTH = ( + "Cannot set media length with 'metadata set'." + " Use 'metadata set-media-length' instead." +) +_NOT_A_MEDIA_LENGTH = ( + "Cannot set media metadata to {}. Media length must be a positive number." +) +_MEDIA_LOADED = "Can't change media length when a media file is loaded." +_MEDIA_NOT_FOUND = "No media available at '{}'. Try using another path." +_NO_DURATION = ( + "No media loaded and no duration set. Load a media file with 'load-media'" + " or set a duration with 'metadata set-media-length'." +) +_NOT_APPLICABLE = "'{}' is not valid for timeline kind '{}'." +_INVALID_BEAT_PATTERN = "Invalid beat pattern '{}': {}" +# The spec's, where TiLiA has none (FR-052, FR-056), and the one text for a +# value that isn't one (TimeLineAnnotator/project-management#298 (texts for the +# rules)), which rules.not_valid will give. +_HAS_COMPONENTS = ( + "The media length can't change once timelines have components:" + " rescale the file in TiLiA." +) +_NO_LENGTH = ( + "{}: give its length with --duration-if-error," + " or set it first with metadata set-media-length" +) +_NOT_VALID = "{} is not a valid {}." + +# A millisecond, the precision of TiLiA's player: a file TiLiA saved holds its +# media's length in whole milliseconds, and a WAV's header gives it exactly +# (TimeLineAnnotator/project-management#327 (a media length within a +# millisecond)). +_SAME_LENGTH = 0.001 + +# tilia.constants.YOUTUBE_URL_REGEX. A stand-in until WP3 gives its copy +# (TimeLineAnnotator/project-management#176 (media paths)), which replaces it +# (TimeLineAnnotator/project-management#317 (WP3's YouTube pattern in build)). +_YOUTUBE_URL = re.compile( + r"^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube(-nocookie)?\.com|youtu.be))" + r"(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$" +) + +# The kinds a script adds; the others come with WP28. +_KINDS = ("beat", "hierarchy", "marker", "harmony") +# Without a height, TiLiA's out-of-the-box defaults, fixed in the core: its +# settings' default heights, and for a beat timeline 35 plus the band its time +# signatures take (34), which a new one shows. A harmony timeline has no height +# of its own: its levels have one. +_DEFAULT_HEIGHTS = {"marker": 30, "hierarchy": 120, "beat": 35 + 34} +# TiLiA's set_data refuses a lower height +# (TimeLineAnnotator/project-management#316 (which heights timelines add accepts)). +_MIN_HEIGHT = 10 + + +def new_script_document() -> tla.Document: + """A new document with TiLiA's two required fields, `title` "Untitled" and + `notes` "", and no others (FR-054).""" + document = tla.new_document() + document.metadata = {"title": "Untitled", "notes": ""} + return document + + +def set_field(document: tla.Document, name: str, value: str | list[str]) -> None: + """Set the file field `name`, or add it after the others (FR-050). + + Raises ValueError for `media length`, which isn't a field: it is set with + `set_media_length`. + """ + if name.lower() == "media length": + raise ValueError(_CANT_SET_MEDIA_LENGTH) + if isinstance(value, str): + text: str | list[str] = nfc(value) + elif isinstance(value, list) and all(isinstance(item, str) for item in value): + text = [nfc(item) for item in value] + else: + raise TypeError(f"a field's value is text or a list of texts, not {value!r}") + document.metadata[nfc(name)] = text + + +def set_media_length(document: tla.Document, seconds: float) -> None: + """Set the media length, in seconds (FR-051, FR-056). + + Raises ValueError for a length that isn't a positive number; while media is + attached, whose length comes with it; and for a change to a known length + once timelines have components, which nothing moves or crops. A length + less than a millisecond from the known one isn't a change then, and the + known one stays. + """ + length = _media_length(seconds) + if document.media_path: + raise ValueError(_MEDIA_LOADED) + document.media_length = _check_change(document, length) + + +def load_media( + document: tla.Document, + path: str | os.PathLike[str], + *, + duration_if_error: float | None = None, +) -> float: + """Record the media, a local file or a YouTube link, and set its length + (FR-052, FR-056). Returns the length set. + + A local path must be a file, and is stored absolute: the writer stores it + relative to the saved file. The length is a WAV's, read from its header; + else `duration_if_error`; else, while no media is attached, the known + length. Without one, or for a change `set_media_length` refuses, raises + ValueError, and the document is left as it was. Where `set_media_length` + keeps the known length, so does this. + """ + text = os.fspath(path) + length: float | None = None + if _YOUTUBE_URL.match(text): + stored = name = text + else: + local = Path(text.replace("\\", "/")) + # Not Path.exists: a folder isn't media, and os.path.isfile gives False, + # rather than raising, for a path the system can't look at. + if not os.path.isfile(local): + raise ValueError(_MEDIA_NOT_FOUND.format(local)) + stored = str(local.resolve()).replace("\\", "/") + name = local.name + if local.suffix.lower() == ".wav": + length = wav_length(local) + if length is None: + if duration_if_error is not None: + length = _media_length(duration_if_error) + elif not document.media_path: + length = _known_length(document) + if length is None: + raise ValueError(_NO_LENGTH.format(name)) + length = _check_change(document, length) + document.media_path = stored + document.media_length = length + return length + + +def wav_length(path: str | os.PathLike[str]) -> float | None: + """A WAV file's length in seconds, from its header; None for a file that + isn't a WAV, or whose header can't be read (research R8). + + The header is read with `struct`, since Python's `wave` module refuses + floating-point WAVs, and WAVE_FORMAT_EXTENSIBLE before Python 3.12. + """ + try: + with open(path, "rb") as file: + return _wav_length(file, os.fstat(file.fileno()).st_size) + except OSError as error: + logger.info("Can't read %s: %s", path, error) + return None + + +def _wav_length(file: BinaryIO, size: int) -> float | None: + """The `data` chunk's size over the `fmt ` chunk's byte rate (or block + align times sample rate). The `fmt ` chunk's first 14 bytes, the oldest + form of it, hold both. A data size past the end of the file, as a WAV + written as a stream gives it, counts the bytes there.""" + head = file.read(12) + if len(head) < 12 or head[:4] != b"RIFF" or head[8:] != b"WAVE": + return None + byte_rate: int | None = None + data_size: int | None = None + position = 12 + while byte_rate is None or data_size is None: + file.seek(position) + header = file.read(8) + if len(header) < 8: + return None + name, (chunk_size,) = header[:4], struct.unpack(" str: + """Add an empty timeline after the others, and return its id (FR-055, FR-060). + + `kind` is "beat", "hierarchy", "marker" or "harmony". Without a height, the + timeline gets TiLiA's default; a harmony timeline takes none. A beat + timeline shows its time signatures, and keeps `beat_pattern` as given + ("4" without one). + + Raises ValueError while no media length is known, and for a height or a + beat pattern the kind doesn't take or TiLiA wouldn't keep; TypeError for a + beat pattern that isn't text. + """ + if _known_length(document) is None: + raise ValueError(_NO_DURATION) + if kind not in _KINDS: + kinds = ", ".join(_KINDS) + raise ValueError(f"can't add a timeline of kind {kind!r}: give one of {kinds}") + if beat_pattern is not None and kind != "beat": + raise ValueError(_NOT_APPLICABLE.format("--beat-pattern", kind)) + if height is not None and kind == "harmony": + raise ValueError(_NOT_APPLICABLE.format("--height", kind)) + if height is not None and not _is_height(height): + raise ValueError(_NOT_VALID.format(height, "height")) + if beat_pattern is not None: + if not isinstance(beat_pattern, str): + raise TypeError( + f"a beat pattern is text, such as '10[4] 3', not {beat_pattern!r}" + ) + parsed = pattern.parse(beat_pattern) + if not parsed.is_complete: + raise ValueError(_INVALID_BEAT_PATTERN.format(beat_pattern, parsed.error)) + + # The format's defaults, as the reader fills them in. A beat timeline's + # show_time_signatures is true there, as desktop#621 makes new ones. + defaults = TIMELINE_SHAPES[kind].defaults + attrs = {key: value for key, value in defaults.items() if key != "name"} + if kind != "harmony": + attrs["height"] = _DEFAULT_HEIGHTS[kind] if height is None else int(height) + if beat_pattern is not None: + attrs["beat_pattern"] = beat_pattern + ordinals = (timeline.ordinal for timeline in document.timelines.values()) + timeline_id = tla.new_id() + document.timelines[timeline_id] = tla.Timeline( + id=timeline_id, + kind=kind, + ordinal=max(ordinals, default=0) + 1, + name=nfc(name), + attrs=attrs, + ) + return timeline_id + + +def find_timeline( + document: tla.Document, *, name: str | None = None, ordinal: int | None = None +) -> str: + """The id of the timeline with this ordinal, or else of the first, by + ordinal, with this name, as TiLiA's command line finds an import's target. + + Raises ValueError when there is none. + """ + if ordinal is not None: + for timeline_id, timeline in document.timelines.items(): + if timeline.ordinal == ordinal: + return timeline_id + raise ValueError(f"No timeline found with {ordinal=}") + if name is None: + raise TypeError("find_timeline needs a name or an ordinal") + wanted = nfc(name) + by_ordinal = sorted(document.timelines.items(), key=lambda item: item[1].ordinal) + for timeline_id, timeline in by_ordinal: + if timeline.name == wanted: + return timeline_id + raise ValueError(f"No timeline found with name={name}") + + +def _media_length(value: object) -> float: + """`value` as a media length: a number of seconds above 0. A file can't hold + NaN or infinity.""" + if isinstance(value, numbers.Real) and not isinstance(value, bool): + try: + length = float(value) + except OverflowError: + length = math.inf + if math.isfinite(length) and length > 0: + return length + raise ValueError(_NOT_A_MEDIA_LENGTH.format(value)) + + +def _known_length(document: tla.Document) -> float | None: + """The document's media length, as it is, when `_media_length` accepts it; + else None. The reader keeps a file's length as the file has it, and one + the format doesn't allow (text, a bool, zero or less, a whole number too + large for a float) counts as no length, as WP9's time map reads it + (TimeLineAnnotator/project-management#216 (choose a document's time map + by role)).""" + length = document.media_length + try: + _media_length(length) + except ValueError: + return None + return length + + +def _check_change(document: tla.Document, length: float) -> float: + """The length to set: a known length can't change once timelines have + components (FR-056). Then a length less than a millisecond from it is the + same one, and the known length stays, as the components were checked + against it. The difference is rounded to the nanosecond first, since + 3.126 - 3.125 computes as a little under 0.001.""" + known = _known_length(document) + if known is None or not _has_components(document): + return length + if round(abs(length - known), 9) < _SAME_LENGTH: + return known + raise ValueError(_HAS_COMPONENTS) + + +def _has_components(document: tla.Document) -> bool: + for timeline in document.timelines.values(): + if timeline.components: + return True + # A kind the core doesn't know keeps its components in `raw`. + if timeline.raw is not None and timeline.raw.get("components"): + return True + return False + + +def _is_height(value: object) -> bool: + # True and False are below it too. + return isinstance(value, numbers.Integral) and value >= _MIN_HEIGHT diff --git a/packages/tilia-core/tests/importers/import_docs.py b/packages/tilia-core/tests/importers/import_docs.py index 42b6b3d0b..3231de331 100644 --- a/packages/tilia-core/tests/importers/import_docs.py +++ b/packages/tilia-core/tests/importers/import_docs.py @@ -11,10 +11,6 @@ from tilia_core import tla -# A stand-in until tla.FORMAT_VERSION exists (desktop#700); then use that -# instead (TimeLineAnnotator/project-management#275). -FORMAT_VERSION = "1.0.0-draft.1" - def document( *, @@ -24,7 +20,7 @@ def document( ) -> tla.Document: return tla.Document( document_id=tla.new_id(), - format_version=FORMAT_VERSION, + format_version=tla.FORMAT_VERSION, time_unit=time_unit, media_length=media_length, timelines={timeline.id: timeline for timeline in timelines}, diff --git a/packages/tilia-core/tests/importers/test_build.py b/packages/tilia-core/tests/importers/test_build.py new file mode 100644 index 000000000..821070073 --- /dev/null +++ b/packages/tilia-core/tests/importers/test_build.py @@ -0,0 +1,1028 @@ +"""`tilia_core.build`: a script's new document, its fields, its media and its +timelines (spec FR-050–FR-052, FR-054–FR-056, FR-060; User Story 6).""" + +import copy +import json +import math +import struct +import wave +from pathlib import Path + +import pytest +from import_docs import component, document, timeline + +from tilia_core import build, tla + +# TiLiA's texts (tilia/errors.py and its command line), word for word. +CANT_SET_MEDIA_LENGTH = ( + "Cannot set media length with 'metadata set'." + " Use 'metadata set-media-length' instead." +) +NOT_A_MEDIA_LENGTH = ( + "Cannot set media metadata to {}. Media length must be a positive number." +) +MEDIA_LOADED = "Can't change media length when a media file is loaded." +MEDIA_NOT_FOUND = "No media available at '{}'. Try using another path." +NO_DURATION = ( + "No media loaded and no duration set. Load a media file with 'load-media'" + " or set a duration with 'metadata set-media-length'." +) +NOT_APPLICABLE = "'{}' is not valid for timeline kind '{}'." +# The spec's texts, where TiLiA has none. +HAS_COMPONENTS = ( + "The media length can't change once timelines have components:" + " rescale the file in TiLiA." +) +NO_LENGTH = ( + "{}: give its length with --duration-if-error," + " or set it first with metadata set-media-length" +) + +YOUTUBE = "https://www.youtube.com/watch?v=dQw4w9WgXcQ" + + +def refused(call, *args, **kwargs) -> str: + with pytest.raises(ValueError) as error: + call(*args, **kwargs) + return str(error.value) + + +def stored(path: Path) -> str: + """A local media path as `load_media` stores it: absolute, with "/".""" + return str(path.resolve()).replace("\\", "/") + + +# WAVs, written in the test + + +def pcm16_wav(path: Path, *, rate: int, channels: int, frames: int) -> Path: + with wave.open(str(path), "wb") as file: + file.setnchannels(channels) + file.setsampwidth(2) + file.setframerate(rate) + file.writeframes(b"\0\0" * channels * frames) + return path + + +def chunk(name: bytes, data: bytes, *, size: int | None = None) -> bytes: + """A RIFF chunk, with its pad byte when its size is odd.""" + header = name + struct.pack(" bytes: + body = b"WAVE" + b"".join(chunks) + return b"RIFF" + struct.pack(" bytes: + align = channels * bits // 8 + head = struct.pack(" Path: + """IEEE float (format 3), with the `fact` chunk such files carry.""" + path.write_bytes( + riff( + fmt(3, channels=channels, rate=rate, bits=32, extra=struct.pack(" Path: + """WAVE_FORMAT_EXTENSIBLE, 24-bit, with an odd-sized chunk before the data.""" + extension = struct.pack(" Path: + """137,800 frames at 44.1 kHz: 3.124716553287982 s from its header, and + 3.125 s to TiLiA's player, which counts whole milliseconds.""" + return pcm16_wav(path, rate=44100, channels=1, frames=137800) + + +def test_a_wav_within_a_millisecond_is_loaded_with_the_known_length(tmp_path): + # A file TiLiA saved, with components, given its WAV again + # (TimeLineAnnotator/project-management#327). + doc = with_a_marker(3.125) + wav = tilias_wav(tmp_path / "rec.wav") + assert build.wav_length(wav) == 3.124716553287982 + + assert build.load_media(doc, wav) == 3.125 + assert doc.media_path == stored(wav) + assert doc.media_length == 3.125 + + +# 3.126 s and 3.124 s at 8 kHz. +@pytest.mark.parametrize("frames", [25008, 24992]) +def test_a_wav_a_millisecond_from_the_known_length_is_refused(tmp_path, frames): + doc = with_a_marker(3.125) + before = copy.deepcopy(doc) + wav = pcm16_wav(tmp_path / "b.wav", rate=8000, channels=1, frames=frames) + + assert refused(build.load_media, doc, wav) == HAS_COMPONENTS + assert doc == before + + +def test_without_components_a_wav_within_a_millisecond_keeps_its_length(tmp_path): + doc = document(media_length=3.125, timelines=[timeline("marker")]) + wav = tilias_wav(tmp_path / "rec.wav") + + assert build.load_media(doc, wav) == 3.124716553287982 + assert doc.media_length == 3.124716553287982 + + +# Adding timelines (FR-055, FR-060) + + +def script_document(media_length=60.0): + doc = build.new_script_document() + build.set_media_length(doc, media_length) + return doc + + +@pytest.mark.parametrize( + "kind, height", [("marker", 30), ("hierarchy", 120), ("beat", 69)] +) +def test_a_timeline_without_a_height_gets_tilias_default(kind, height): + # TiLiA's out-of-the-box defaults, fixed in the core + # (TimeLineAnnotator/project-management#254 (a timeline's default height)); + # beat is 35 and 34 for the time signatures' band. + doc = script_document() + + timeline_id = build.add_timeline(doc, kind, "Name") + + assert doc.timelines[timeline_id].attrs["height"] == height + + +@pytest.mark.parametrize("kind", ["marker", "hierarchy", "beat"]) +def test_a_timeline_gets_the_height_given(kind): + # A beat timeline's band isn't added to a height given, as in TiLiA. + doc = script_document() + + timeline_id = build.add_timeline(doc, kind, height=50) + + assert doc.timelines[timeline_id].attrs["height"] == 50 + + +def test_a_harmony_timeline_has_no_height(): + doc = script_document() + + timeline_id = build.add_timeline(doc, "harmony", "Chords") + + assert "height" not in doc.timelines[timeline_id].attrs + + +def test_a_timeline_is_added_empty_with_its_kind_and_name(): + doc = script_document() + + timeline_id = build.add_timeline(doc, "hierarchy", "Phrases") + + added = doc.timelines[timeline_id] + assert added.id == timeline_id + assert added.kind == "hierarchy" + assert added.name == "Phrases" + assert added.components == {} + assert added.metadata == {} + + +def test_a_timelines_name_is_stored_in_nfc(): + doc = script_document() + + timeline_id = build.add_timeline(doc, "marker", "Cadéncias") + + assert doc.timelines[timeline_id].name == "Cadéncias" + + +def test_timelines_are_added_after_the_others(): + doc = document( + media_length=60.0, + timelines=[timeline("marker", ordinal=1), timeline("marker", ordinal=5)], + ) + + first = build.add_timeline(doc, "marker") + second = build.add_timeline(doc, "beat") + + assert doc.timelines[first].ordinal == 6 + assert doc.timelines[second].ordinal == 7 + assert first != second + + +def test_the_first_timeline_has_ordinal_1(): + doc = script_document() + + assert doc.timelines[build.add_timeline(doc, "marker")].ordinal == 1 + + +def test_a_beat_timeline_shows_time_signatures_with_pattern_4(): + doc = script_document() + + beat = doc.timelines[build.add_timeline(doc, "beat", "Measures")] + + assert beat.attrs["show_time_signatures"] is True + assert beat.attrs["beat_pattern"] == "4" + + +def test_a_beat_pattern_is_stored_as_given(): + doc = script_document() + + timeline_id = build.add_timeline(doc, "beat", beat_pattern="10[4] 3") + + assert doc.timelines[timeline_id].attrs["beat_pattern"] == "10[4] 3" + + +def test_a_built_document_reads_back_as_it_was(): + # The file always holds a height (WP3's layout), and reading the file + # gives what was built. + doc = script_document(312.5) + build.set_field(doc, "composer", "W. A. Mozart") + for kind, name in [ + ("beat", "Measures"), + ("harmony", "Chords"), + ("hierarchy", "Phrases"), + ("marker", "Cadences"), + ]: + build.add_timeline(doc, kind, name) + + data = tla.canonical_bytes(doc) + back = tla.loads(data) + + assert back.metadata == doc.metadata + assert back.media_length == 312.5 + for timeline_id, built in doc.timelines.items(): + read = back.timelines[timeline_id] + assert (read.kind, read.name, read.ordinal) == ( + built.kind, + built.name, + built.ordinal, + ) + assert read.attrs == built.attrs + heights = { + written["name"]: written.get("height") + for written in json.loads(data)["timelines"].values() + } + assert heights == {"Measures": 69, "Chords": None, "Phrases": 120, "Cadences": 30} + + +def test_no_timeline_until_the_media_length_is_known(): + # FR-055: checked first, as TiLiA does. + doc = build.new_script_document() + before = copy.deepcopy(doc) + + assert refused(build.add_timeline, doc, "marker") == NO_DURATION + assert refused(build.add_timeline, doc, "harmony", height=40) == NO_DURATION + assert doc == before + + +@pytest.mark.parametrize("length", NOT_ALLOWED) +def test_no_timeline_while_the_length_is_one_the_format_doesnt_allow(length): + # FR-055. TiLiA refuses a timeline while the duration is falsy, so at 0 too. + doc = read_with_length(build.new_script_document(), length) + before = copy.deepcopy(doc) + + assert refused(build.add_timeline, doc, "marker") == NO_DURATION + assert doc == before + + +def test_a_harmony_timeline_takes_no_height(): + doc = script_document() + before = copy.deepcopy(doc) + + message = refused(build.add_timeline, doc, "harmony", height=40) + + assert message == NOT_APPLICABLE.format("--height", "harmony") + assert doc == before + + +@pytest.mark.parametrize("kind", ["marker", "hierarchy", "harmony"]) +def test_only_a_beat_timeline_takes_a_beat_pattern(kind): + doc = script_document() + before = copy.deepcopy(doc) + + message = refused(build.add_timeline, doc, kind, beat_pattern="4") + + assert message == NOT_APPLICABLE.format("--beat-pattern", kind) + assert doc == before + + +@pytest.mark.parametrize( + "pattern, reason", + [ + ("10[", "Unclosed '['."), + ("0", "Bar lengths must be at least 1."), + ("4 x", "Unexpected 'x'."), + ("", "Enter at least one bar length."), + ], +) +def test_a_beat_pattern_that_doesnt_parse_is_refused(pattern, reason): + doc = script_document() + before = copy.deepcopy(doc) + + message = refused(build.add_timeline, doc, "beat", beat_pattern=pattern) + + assert message == f"Invalid beat pattern '{pattern}': {reason}" + assert doc == before + + +@pytest.mark.parametrize("pattern", [[4], 4, b"4"]) +def test_a_beat_pattern_is_text(pattern): + doc = script_document() + before = copy.deepcopy(doc) + + with pytest.raises(TypeError): + build.add_timeline(doc, "beat", beat_pattern=pattern) + assert doc == before + + +@pytest.mark.parametrize("height", [0, -5, 9, 10.0, True, "40"]) +def test_a_height_is_an_integer_of_at_least_10(height): + # TiLiA's set_data refuses these (validate_bounded_integer, lower=10), and + # the strictest of TiLiA's checks applies + # (TimeLineAnnotator/project-management#316 (which heights timelines add accepts)). + doc = script_document() + before = copy.deepcopy(doc) + + message = refused(build.add_timeline, doc, "marker", height=height) + + assert message == f"{height} is not a valid height." + assert doc == before + + +def test_a_height_of_10_is_kept(): + doc = script_document() + + timeline_id = build.add_timeline(doc, "marker", height=10) + + assert doc.timelines[timeline_id].attrs["height"] == 10 + + +@pytest.mark.parametrize("kind", ["range", "pdf", "score", "slider", "Marker"]) +def test_other_kinds_are_refused(kind): + doc = script_document() + before = copy.deepcopy(doc) + + message = refused(build.add_timeline, doc, kind) + + assert repr(kind) in message + assert doc == before + + +# Finding timelines + + +def test_a_timeline_is_found_by_name(): + chords = timeline("harmony", name="Chords", ordinal=2) + doc = document(timelines=[timeline("beat", name="Measures"), chords]) + + assert build.find_timeline(doc, name="Chords") == chords.id + + +def test_of_two_timelines_with_one_name_the_first_by_ordinal_is_found(): + second = timeline("marker", name="Same", ordinal=2) + first = timeline("marker", name="Same", ordinal=1) + doc = document(timelines=[second, first]) + + assert build.find_timeline(doc, name="Same") == first.id + + +def test_a_name_is_compared_in_nfc(): + found = timeline("marker", name="Cadéncias") + doc = document(timelines=[found]) + + assert build.find_timeline(doc, name="Cadéncias") == found.id + + +def test_a_timeline_is_found_by_ordinal(): + third = timeline("marker", ordinal=3) + doc = document(timelines=[timeline("marker", ordinal=1), third]) + + assert build.find_timeline(doc, ordinal=3) == third.id + + +def test_an_ordinal_is_used_before_a_name(): + # As TiLiA's command line finds an import's target. + by_name = timeline("marker", name="A", ordinal=1) + by_ordinal = timeline("marker", name="B", ordinal=2) + doc = document(timelines=[by_name, by_ordinal]) + + assert build.find_timeline(doc, name="A", ordinal=2) == by_ordinal.id + + +def test_no_timeline_found(): + # TiLiA's texts (tilia/ui/cli/timelines/utils.py), which don't quote the name. + doc = document(timelines=[timeline("marker", name="A", ordinal=1)]) + + assert refused(build.find_timeline, doc, name="Chords") == ( + "No timeline found with name=Chords" + ) + assert refused(build.find_timeline, doc, ordinal=2) == ( + "No timeline found with ordinal=2" + ) + + +def test_find_timeline_needs_a_name_or_an_ordinal(): + with pytest.raises(TypeError): + build.find_timeline(document())