diff --git a/packages/tilia-core/src/tilia_core/timemap/__init__.py b/packages/tilia-core/src/tilia_core/timemap/__init__.py index f38bf1ab0..e2c0ea2b8 100644 --- a/packages/tilia-core/src/tilia_core/timemap/__init__.py +++ b/packages/tilia-core/src/tilia_core/timemap/__init__.py @@ -2,17 +2,146 @@ from __future__ import annotations +import logging +from collections.abc import Mapping +from dataclasses import dataclass + from tilia_core import tla -from tilia_core.timemap.table import MeasureRow +from tilia_core.timemap.table import MeasureRow, build_rows, media_warning from tilia_core.timemap.timemap import Positions, TimeMap, build_time_map -__all__ = ["MeasureRow", "Positions", "TimeMap", "time_map_for"] +__all__ = [ + "MeasureRow", + "Positions", + "TimeMap", + "TimeMapFound", + "find_time_map", + "measure_rows", + "time_map_for", + "time_map_of", +] + +logger = logging.getLogger(__name__) + +TIME_MAP_ROLE = "time_map" + + +@dataclass(frozen=True) +class TimeMapFound: + """A document's time map as `find_time_map` chose it, or why there is none.""" + + time_map: TimeMap | None + timeline_id: str | None # the chosen beat timeline, even when it gives no time map + guessed: bool # no timeline had the role time_map: the first beat timeline was taken + reason: str | None # why the chosen timeline gives no time map + warnings: tuple[str, ...] # several claim the role; a last beat past the media + + +def find_time_map( + document: tla.Document, roles: Mapping[str, str | list[str] | None] | None = None +) -> TimeMapFound: + """The document's time map: its beat timeline whose role is time_map, else + its first beat timeline by ordinal, guessed. + + `roles` gives each timeline's role by its id, as the caller resolved them + (the index: from the file and the corpus's manifest), and a timeline it + leaves out has none; without it, the roles are the file's. A role that is a + list claims time_map when it holds it. When several beat timelines have the + role, the first by ordinal wins, with a warning that names them. When the + chosen timeline gives no time map, no other stands in: the file has no + bars, and `reason` says why. A document without a beat timeline has no + time map, and nothing is wrong with it: no reason, no warning. + + Every beat timeline whose last beat is at or after the end of the media + gets a warning too, with its id, the time map's or not. The warnings are + logged here, once for each call, and not when a table is built. + """ + beat_timelines = sorted( + (t for t in document.timelines.values() if t.kind == "beat"), + key=_by_ordinal, + ) + if not beat_timelines: + return TimeMapFound( + time_map=None, timeline_id=None, guessed=False, reason=None, warnings=() + ) + claiming = [t for t in beat_timelines if _claims_the_role(t, roles)] + warnings = [_several_claim(claiming)] if len(claiming) > 1 else [] + timeline = (claiming or beat_timelines)[0] + guessed = not claiming + time_map, reason = build_time_map(document, timeline, guessed=guessed) + for beats in beat_timelines: + warning = media_warning(document, beats) + if warning is not None: + warnings.append(warning) + for warning in warnings: + logger.warning("%s.", warning) + return TimeMapFound( + time_map=time_map, + timeline_id=timeline.id, + guessed=guessed, + reason=reason, + warnings=tuple(warnings), + ) + + +def _several_claim(claiming: list[tla.Timeline]) -> str: + ids = [timeline.id for timeline in claiming] + return ( + f"timelines {', '.join(ids[:-1])} and {ids[-1]} claim the role time_map; " + f"{ids[0]}, the first by ordinal, is used" + ) + + +def _by_ordinal(timeline: tla.Timeline) -> tuple[int, float]: + # An ordinal that isn't a whole number, which the reader doesn't refuse + # yet, comes after the others, in the document's order. + ordinal: object = timeline.ordinal + if isinstance(ordinal, int) and not isinstance(ordinal, bool): + return 0, ordinal + if isinstance(ordinal, float) and ordinal.is_integer(): + return 0, ordinal + return 1, 0 + + +def _claims_the_role( + timeline: tla.Timeline, roles: Mapping[str, str | list[str] | None] | None +) -> bool: + role = timeline.metadata.get("role") if roles is None else roles.get(timeline.id) + if isinstance(role, (list, tuple)): + return TIME_MAP_ROLE in role + return role == TIME_MAP_ROLE + + +def time_map_of( + document: tla.Document, roles: Mapping[str, str | list[str] | None] | None = None +) -> TimeMap | None: + """The document's time map, chosen as `find_time_map` chooses it; None when it has none.""" + return find_time_map(document, roles).time_map def time_map_for(document: tla.Document, timeline_id: str) -> TimeMap | None: """The time map of the beat timeline `timeline_id`; None when it has none.""" - timeline = document.timelines.get(timeline_id) - if timeline is None or timeline.kind != "beat": + timeline = _beat_timeline(document, timeline_id) + if timeline is None: return None time_map, _ = build_time_map(document, timeline) return time_map + + +def measure_rows(document: tla.Document, timeline_id: str) -> list[MeasureRow]: + """The measure table of the beat timeline `timeline_id`, in its order. + + Also for a folded table, which has no time map. No rows while the table + has a problem (`table.table_problem`: a beat without a usable time, a row + whose beats don't start at its downbeat, or two beats at the same time or + out of time order), and none for a timeline that isn't a beat timeline. + """ + timeline = _beat_timeline(document, timeline_id) + if timeline is None: + return [] + return build_rows(document, timeline) + + +def _beat_timeline(document: tla.Document, timeline_id: str) -> tla.Timeline | None: + timeline = document.timelines.get(timeline_id) + return timeline if timeline is not None and timeline.kind == "beat" else None diff --git a/packages/tilia-core/src/tilia_core/timemap/table.py b/packages/tilia-core/src/tilia_core/timemap/table.py index 56ad100a2..8e5c91c83 100644 --- a/packages/tilia-core/src/tilia_core/timemap/table.py +++ b/packages/tilia-core/src/tilia_core/timemap/table.py @@ -3,19 +3,21 @@ from __future__ import annotations -import logging +import itertools import math from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass, field from fractions import Fraction -from typing import Any, NoReturn +from typing import Any, NoReturn, TypeGuard from tilia_core import tla from tilia_core.timemap._rows import RowIn, rows_of from tilia_core.timemap.meters import meters_of from tilia_core.timemap.units import MeasureMeter, format_units -logger = logging.getLogger(__name__) +# Float noise: two beats closer than this are at the same time, and a time this +# much beyond the tolerance is still on a beat. +EPS = 1e-6 class FrozenMetadata(dict[str, "str | tuple[str, ...]"]): @@ -107,19 +109,82 @@ def __post_init__(self) -> None: def build_rows(document: tla.Document, timeline: tla.Timeline) -> list[MeasureRow]: """The timeline's measure table, in its order; also for a folded table. - None while a beat has no usable time (`missing_time` says which), or while a - row's beats don't start at its downbeat (`broken_row`): rows built without - them would be wrong. + No rows while the table has a problem (`table_problem` says which): rows + built then would be wrong, with measures that end before they start. """ - if missing_time(timeline) is not None or broken_row(timeline) is not None: + if table_problem(timeline) is not None: return [] return build_rows_from(document, timeline, rows_of(timeline)) +def table_problem(timeline: tla.Timeline) -> str | None: + """Why the timeline's table can't give rows, as a reason; else None. + + A beat without a usable time (`missing_time`), a row whose beats don't + start at its downbeat (`broken_row`), or two beats at the same time or out + of time order (`disorder`). None doesn't promise rows: a timeline without + a table, or with an empty one, has none, and no problem either. + """ + return _checked(timeline)[0] + + +def _checked(timeline: tla.Timeline) -> tuple[str | None, list[float]]: + # The table's problem, and the times of its beats in its order: none when it + # has a problem, so the times given are always usable. + reason = missing_time(timeline) or broken_row(timeline) + if reason is not None: + return reason, [] + times = _times(timeline) + reason = disorder(times) + return reason, [] if reason is not None else times + + +def _times(timeline: tla.Timeline) -> list[float]: + # The times of the table's beats, in its order, once missing_time has + # found each of them usable. + rows = timeline.measures.rows if timeline.measures is not None else [] + return [timeline.components[b].attrs["time"] for row in rows for b in row.beats] + + +def disorder(times: Sequence[float]) -> str | None: + """The first two beats at the same time, or the first out of time order, as + a reason; else None.""" + for a, b in itertools.pairwise(times): + if abs(b - a) < EPS: + return f"two beats at the same time ({a})" + if b < a: + return f"beats out of time order (at {b})" + return None + + +def media_warning(document: tla.Document, timeline: tla.Timeline) -> str | None: + """The warning, with the timeline's id, that its last beat is at or after + the end of the media; None when it isn't, or when the table has no rows. + + With more than one beat, the last measure ends one gap after the last + beat, and the warning says so; with one, it ends on the beat. + """ + reason, times = _checked(timeline) + if reason is not None or not times: + return None + last, gap = _last_gap(times) + media_length = _media_length(document) + if media_length is None or not _ends_by(media_length, last): + return None + warning = ( + f"Timeline {timeline.id}: the last beat ({last}) is at or after " + f"the end of the media ({media_length})" + ) + if gap > 0: + warning += "; the last measure ends one gap after it" + return warning + + def missing_time(timeline: tla.Timeline) -> str | None: """The first beat without a usable time, as a reason naming its measure; else None. - A time is usable when it is a finite number: a missing, NaN or text time isn't. + A time is usable when it is a finite number: a missing, NaN or text time + isn't, nor a whole number too large for a float. """ if timeline.measures is None: return None @@ -147,12 +212,18 @@ def broken_row(timeline: tla.Timeline) -> str | None: def _has_time(component: tla.Component | None) -> bool: - time = component.attrs.get("time") if component is not None else None - return ( - isinstance(time, (int, float)) - and not isinstance(time, bool) - and math.isfinite(time) - ) + return component is not None and _is_number(component.attrs.get("time")) + + +def _is_number(value: object) -> TypeGuard[int | float]: + """A finite number, as JSON stores one: not a bool, NaN, an infinity, or a + whole number too large for a float, which JSON reads as an int.""" + if isinstance(value, bool) or not isinstance(value, (int, float)): + return False + try: + return math.isfinite(value) + except OverflowError: + return False def build_rows_from( @@ -168,7 +239,7 @@ def build_rows_from( return [] folded = is_folded(rows) ends = [row.beat_times[0] for row in rows[1:]] - ends.append(_closing_barline(document, timeline.id, rows)) + ends.append(_closing_barline(document, rows)) passes: dict[int, int] = {} movement = 1 out: list[MeasureRow] = [] @@ -229,28 +300,43 @@ def is_folded(rows: Sequence[RowIn]) -> bool: return any(row.next is not None for row in rows) -def _closing_barline( - document: tla.Document, timeline_id: str, rows: Sequence[RowIn] -) -> float: +def _closing_barline(document: tla.Document, rows: Sequence[RowIn]) -> float: # The last beat lasts as long as the gap before it, or until the end of the # media if that comes sooner. No beats are added to the last measure. - *before, last = [t for row in rows[-2:] for t in row.beat_times][-2:] - end = last + (last - before[0] if before else 0.0) - media_length = document.media_length - if media_length is None or document.time_unit != "seconds": - return end # the media's length is in seconds - if media_length <= last: - logger.warning( - "Timeline %s: the last beat (%s) is at or after the end of the media (%s); " - "the last measure ends one gap after it.", - timeline_id, - last, - media_length, - ) - return end + last, gap = _last_gap([t for row in rows[-2:] for t in row.beat_times]) + end = last + gap + media_length = _media_length(document) + if media_length is None or _ends_by(media_length, last): + return end # past the media, the gap alone; find_time_map warns return min(end, media_length) +def _last_gap(times: Sequence[float]) -> tuple[float, float]: + # The last beat, and the gap before it, which the last measure lasts past + # it: none for a single beat. The closing barline and its warning share it. + return times[-1], (times[-1] - times[-2] if len(times) > 1 else 0.0) + + +def _media_length(document: tla.Document) -> float | None: + """The media's length, when it caps the closing barline; else None. + + It is in seconds, so it doesn't cap a quarters file. The format allows a + number above zero, or none; the reader doesn't check, so anything else + (text, a bool, NaN, infinity, a whole number too large for a float, zero + or less) counts as none. + """ + length: object = document.media_length + if document.time_unit != "seconds" or not _is_number(length): + return None + return length if length > 0 else None + + +def _ends_by(media_length: float, last_beat: float) -> bool: + # Whether the media ends at or before the last beat: one test for the + # closing barline and its warning, so they can't disagree. + return media_length <= last_beat + + def find_rows( rows: Sequence[MeasureRow], number: int | None = None, diff --git a/packages/tilia-core/src/tilia_core/timemap/timemap.py b/packages/tilia-core/src/tilia_core/timemap/timemap.py index ca2b0e079..5e486d5c1 100644 --- a/packages/tilia-core/src/tilia_core/timemap/timemap.py +++ b/packages/tilia-core/src/tilia_core/timemap/timemap.py @@ -16,15 +16,14 @@ from tilia_core import tla from tilia_core.timemap._rows import rows_of from tilia_core.timemap.table import ( + EPS, MeasureRow, - broken_row, build_rows_from, is_folded, - missing_time, + table_problem, ) TOLERANCE = 0.1 # in the file's unit: a time this close to a beat is on it -EPS = 1e-6 # added to the tolerance, so float noise never decides whether a time is on a beat FOLDED = ( "the table lists the measures played next (`next`), " @@ -185,18 +184,13 @@ def build_time_map( table = timeline.measures if table is None or (not table.rows and timeline.components): return None, "no measure table" - reason = missing_time(timeline) or broken_row(timeline) + reason = table_problem(timeline) if reason is not None: return None, reason rows_in = rows_of(timeline) times = [t for row in rows_in for t in row.beat_times] if len(times) < 2: return None, "fewer than two beats" - for a, b in itertools.pairwise(times): - if abs(b - a) < EPS: - return None, f"two beats at the same time ({a})" - if b < a: - return None, f"beats out of time order (at {b})" if is_folded(rows_in): return None, FOLDED rows = build_rows_from(document, timeline, rows_in) diff --git a/packages/tilia-core/tests/timemap/test_timemap_choose.py b/packages/tilia-core/tests/timemap/test_timemap_choose.py new file mode 100644 index 000000000..f9c426dba --- /dev/null +++ b/packages/tilia-core/tests/timemap/test_timemap_choose.py @@ -0,0 +1,524 @@ +import dataclasses +import logging + +import pytest + +import tilia_core.timemap as timemap +from docs import beat_timeline, document, pickup_piece +from tilia_core import tla +from tilia_core.timemap import ( + TimeMapFound, + find_time_map, + measure_rows, + time_map_for, + time_map_of, +) +from tilia_core.timemap.timemap import FOLDED + + +def _beats( + ordinal: int, role: str | None = None, *, last: int = 3, id: str | None = None +) -> tla.Timeline: + """2/4, a beat every second from 0 to `last`.""" + return beat_timeline( + [ + {"time": float(t), "measure": {} if t % 2 == 0 else None} + for t in range(last + 1) + ], + id=id, + ordinal=ordinal, + role=role, + ) + + +def _other(kind: str, ordinal: int, **kwargs) -> tla.Timeline: + return tla.Timeline(id=tla.new_id(), kind=kind, ordinal=ordinal, **kwargs) + + +def _folded(ordinal: int = 1, role: str | None = None) -> tla.Timeline: + timeline = _beats(ordinal, role) + timeline.measures.rows[1].next = [timeline.measures.rows[0].id] + return timeline + + +def test_the_timeline_with_the_role_is_the_time_map(): + # Spec, US4 scenario 1. + first, second = _beats(1), _beats(2, role="time_map") + + found = find_time_map(document(first, second)) + + assert found.timeline_id == second.id + assert found.time_map is not None + assert found.time_map.timeline_id == second.id + assert not found.guessed and not found.time_map.guessed + assert found.reason is None and found.warnings == () + + +def test_without_the_role_the_first_beat_timeline_by_ordinal_is_guessed(): + # Spec, US4 scenario 2: by ordinal, not by the order of the file; other + # kinds don't count. + markers = _other("marker", 1) + later, earlier = _beats(5), _beats(3) + + found = find_time_map(document(markers, later, earlier)) + + assert found.timeline_id == earlier.id + assert found.time_map.timeline_id == earlier.id + assert found.guessed and found.time_map.guessed + assert found.reason is None and found.warnings == () + + +@pytest.mark.parametrize( + "claimants, warning", + [ + ( + 2, + "timelines B and C claim the role time_map; " + "B, the first by ordinal, is used", + ), + ( + 3, + "timelines A, B and C claim the role time_map; " + "A, the first by ordinal, is used", + ), + ], +) +def test_when_several_claim_the_role_the_first_by_ordinal_wins( + claimants, warning, caplog +): + # Spec, US4 scenario 3: one warning, however many claim it, naming them in + # ordinal order; returned and logged (TimeLineAnnotator/project-management#13 + # (the measure table)). + unclaimed = _beats(1) + claiming = [_beats(10 - j, role="time_map", id="CBA"[j]) for j in range(claimants)] + + with caplog.at_level(logging.WARNING): + found = find_time_map(document(unclaimed, *claiming)) + + assert found.timeline_id == claiming[-1].id + assert found.time_map.timeline_id == claiming[-1].id + assert not found.guessed + assert found.warnings == (warning,) + (record,) = caplog.records + assert record.name == "tilia_core.timemap" + assert record.getMessage() == f"{found.warnings[0]}." + + +def test_the_callers_roles_count_not_only_the_files(): + # Spec, US4 scenario 4: the roles WP12 resolved from the file and the manifest. + in_file, in_manifest = _beats(1, role="time_map"), _beats(2) + doc = document(in_file, in_manifest) + roles = {in_file.id: None, in_manifest.id: "time_map"} + + found = find_time_map(doc, roles) + + assert found.timeline_id == in_manifest.id and not found.guessed + assert found.warnings == () + assert time_map_of(doc, roles).timeline_id == in_manifest.id + assert time_map_of(doc).timeline_id == in_file.id + + +@pytest.mark.parametrize("roles", [{}, "none for each"]) +def test_the_callers_roles_replace_the_files(roles): + first, second = _beats(1), _beats(2, role="time_map") + if roles == "none for each": + roles = {first.id: None, second.id: None} + + found = find_time_map(document(first, second), roles) + + assert found.timeline_id == first.id and found.guessed + + +def test_only_a_beat_timeline_can_have_the_role(): + markers = _other("marker", 1, metadata={"role": "time_map"}) + beats = _beats(2) + + for roles in (None, {markers.id: "time_map"}): + found = find_time_map(document(markers, beats), roles) + + assert found.timeline_id == beats.id and found.guessed + + +@pytest.mark.parametrize( + "role, claims", + [(["time_map"], True), (["analysis", "time_map"], True), (["analysis"], False)], +) +def test_a_role_list_that_holds_time_map_claims_it(role, claims): + # Metadata values may be lists (TimeLineAnnotator/project-management#13 + # (the measure table)). + first, listed = _beats(1), _beats(2) + listed.metadata["role"] = role + + found = find_time_map(document(first, listed)) + + assert found.timeline_id == (listed.id if claims else first.id) + assert found.guessed is not claims + + +@pytest.mark.parametrize("ordinal", [None, "1", 1.5, float("nan"), True, [1]]) +def test_an_ordinal_that_isnt_a_whole_number_sorts_after_the_others(ordinal): + # Nothing raises while the reader lets one through + # (TimeLineAnnotator/project-management#7 (format in the core)). + odd, other = _beats(1), _beats(3) + odd.ordinal = ordinal + + found = find_time_map(document(odd, other)) + + assert found.timeline_id == other.id and found.guessed + + +def test_a_whole_ordinal_written_as_a_float_sorts_by_its_value(): + two, three = _beats(1), _beats(3) + two.ordinal = 2.0 + + assert find_time_map(document(three, two)).timeline_id == two.id + + +def test_a_timeline_that_names_a_beat_timeline_gets_its_table_and_time_map(): + # Spec, US4 scenario 5: a score timeline names its table (WP3's FR-133). + default, named = _beats(1, role="time_map"), _beats(2, last=7) + score = _other("score", 3, attrs={"measure_table": named.id}) + doc = document(default, named, score) + + time_map = time_map_for(doc, score.attrs["measure_table"]) + rows = measure_rows(doc, score.attrs["measure_table"]) + + assert time_map.timeline_id == named.id and not time_map.guessed + assert [row.number for row in rows] == [1, 2, 3, 4] + assert rows == time_map.measure_rows() + assert find_time_map(doc).timeline_id == default.id + + +def _one_beat(**kwargs) -> tla.Timeline: + return beat_timeline([{"time": 0.0, "measure": {}}], **kwargs) + + +def _same_time(**kwargs) -> tla.Timeline: + return beat_timeline( + [{"time": 0.0, "measure": {}}, {"time": 0.0}, {"time": 1.0}], **kwargs + ) + + +def _out_of_order(**kwargs) -> tla.Timeline: + return beat_timeline( + [{"time": 0.0, "measure": {}}, {"time": 2.0, "measure": {}}, {"time": 1.0}], + **kwargs, + ) + + +@pytest.mark.parametrize( + "make, reason", + [ + (_one_beat, "fewer than two beats"), + (_same_time, "two beats at the same time (0.0)"), + (_folded, FOLDED), + ], +) +@pytest.mark.parametrize("role", ["time_map", None]) +def test_no_other_timeline_stands_in_for_one_without_a_time_map(make, reason, role): + # Spec, US4 scenario 6, and FR-003: the file has no bars, and says why. + chosen, other = make(ordinal=1, role=role), _beats(2) + doc = document(chosen, other) + + found = find_time_map(doc) + + assert found.time_map is None + assert found.timeline_id == chosen.id + assert found.guessed is (role is None) + assert found.reason == reason + assert found.warnings == () + assert time_map_of(doc) is None + + +def test_a_document_without_beat_timelines_has_no_time_map(caplog): + for doc in (document(), document(_other("marker", 1), _other("hierarchy", 2))): + found = find_time_map(doc) + + assert found == TimeMapFound( + time_map=None, timeline_id=None, guessed=False, reason=None, warnings=() + ) + assert time_map_of(doc) is None + assert not caplog.records + + +def test_found_is_frozen_and_hashes(): + found = find_time_map(document(*(_beats(j, role="time_map") for j in (1, 2)))) + + with pytest.raises(dataclasses.FrozenInstanceError): + found.guessed = False # type: ignore[misc] + assert isinstance(found.warnings, tuple) + hash(found) + + +@pytest.mark.parametrize("media_length", [19.5, 20.0]) +def test_the_last_beat_at_or_after_the_end_of_the_media_is_returned_and_logged( + media_length, caplog +): + # FR-082: returned, and logged once with the timeline's id. The index calls + # find_time_map once when it reads a file. + timeline = pickup_piece() # the last beat at 20 s + doc = document(timeline, media_length=media_length) + + with caplog.at_level(logging.WARNING): + found = find_time_map(doc) + + assert found.time_map.end == 21.0 + (warning,) = found.warnings + assert warning == ( + f"Timeline {timeline.id}: the last beat (20.0) is at or after the end of " + f"the media ({media_length}); the last measure ends one gap after it" + ) + (record,) = caplog.records + assert record.name == "tilia_core.timemap" + assert record.levelno == logging.WARNING + assert timeline.id in record.getMessage() + assert warning in record.getMessage() + + +def test_the_warnings_come_in_the_order_they_were_found(): + # The claim first, then each timeline past the media, by ordinal. + later = _beats(2, role="time_map", id="late") + earlier = _beats(1, role="time_map", id="early") + + found = find_time_map(document(later, earlier, media_length=2.0)) + + assert len(found.warnings) == 3 + assert found.warnings[0] == ( + "timelines early and late claim the role time_map; " + "early, the first by ordinal, is used" + ) + assert found.warnings[1].startswith(f"Timeline {earlier.id}: the last beat (3.0)") + assert found.warnings[2].startswith(f"Timeline {later.id}: the last beat (3.0)") + + +def test_every_beat_timeline_past_the_media_is_warned_about(caplog): + # The tables a score or an import names too, once for each call of + # find_time_map (TimeLineAnnotator/project-management#13 (the measure + # table)). A table without rows has no closing barline. + default, named = _beats(1, role="time_map"), _beats(2, last=7) + timeless = _beats(3, last=7) + timeless.components[timeless.measures.rows[0].beats[1]].attrs["time"] = None + doc = document(default, named, timeless, media_length=5.0) + + with caplog.at_level(logging.WARNING): + found = find_time_map(doc) + + assert found.timeline_id == default.id + (warning,) = found.warnings + assert warning.startswith(f"Timeline {named.id}: the last beat (7.0)") + (record,) = caplog.records + assert record.getMessage() == f"{warning}." + + +@pytest.mark.parametrize( + "media_length, time_unit", + [(None, "seconds"), (20.4, "seconds"), (30.0, "seconds"), (19.5, "quarters")], +) +def test_no_media_warning_when_the_media_lasts_past_the_last_beat( + media_length, time_unit, caplog +): + # A quarters file's times aren't in the media's seconds. + doc = document(pickup_piece(), media_length=media_length, time_unit=time_unit) + + with caplog.at_level(logging.WARNING): + found = find_time_map(doc) + + assert found.time_map is not None and found.warnings == () + assert not caplog.records + + +@pytest.mark.parametrize( + "media_length", + [ + "20", + True, + float("nan"), + float("inf"), + pytest.param(10**400, id="10**400"), + 0, + 0.0, + -5.0, + [19.5], + ], +) +def test_a_media_length_the_format_doesnt_allow_is_no_length(media_length, caplog): + # The schema allows a number above zero, or null; the reader doesn't check. + # No cap on the closing barline, no warning, and nothing raises. 10**400 + # is a whole number too large for a float. + timeline = pickup_piece() + doc = document(timeline, media_length=media_length) + + with caplog.at_level(logging.WARNING): + found = find_time_map(doc) + rows = measure_rows(doc, timeline.id) + + assert found.time_map.end == 21.0 and rows[-1].end == 21.0 + assert found.warnings == () + assert not caplog.records + + +def test_a_media_length_too_large_for_a_float_read_from_a_file_is_no_length(caplog): + # JSON reads a whole number of any size as an int, and the reader keeps + # it, while it refuses 1e999, which JSON reads as a float. + data = tla.canonical_bytes(document(pickup_piece(), media_length=20.4)) + assert data.count(b'"length": 20.4') == 1 + doc = tla.loads(data.replace(b'"length": 20.4', b'"length": 1' + b"0" * 400)) + assert doc.media_length == 10**400 + + with caplog.at_level(logging.WARNING): + found = find_time_map(doc) + + assert found.warnings == () + assert not caplog.records + + +def test_a_whole_number_media_length_caps_and_warns(): + # JSON reads "length": 7 as an int, which the format allows. One measure of + # four beats, every 2 s: its closing barline is at 8. + timeline = beat_timeline( + [{"time": t, "measure": {} if t == 0.0 else None} for t in (0.0, 2.0, 4.0, 6.0)] + ) + + capped = find_time_map(document(timeline, media_length=7)) + past = find_time_map(document(timeline, media_length=6)) + + assert capped.time_map.end == 7 and capped.warnings == () + assert past.time_map.end == 8.0 + assert past.warnings == ( + f"Timeline {timeline.id}: the last beat (6.0) is at or after the end of the " + "media (6); the last measure ends one gap after it", + ) + + +def test_one_beat_past_the_media_is_warned_about_without_a_gap(): + # Its only measure ends on the beat: there is no gap after it. + one = beat_timeline([{"time": 5.0, "measure": {}}]) + + found = find_time_map(document(one, media_length=4.0)) + + assert found.reason == "fewer than two beats" + assert found.warnings == ( + f"Timeline {one.id}: the last beat (5.0) is at or after the end of the " + "media (4.0)", + ) + + +@pytest.mark.parametrize("make", [_same_time, _out_of_order]) +def test_a_table_out_of_time_order_gets_no_media_warning(make, caplog): + # It has no rows, so no closing barline. + timeline = make() + doc = document(timeline, media_length=0.5) + + with caplog.at_level(logging.WARNING): + found = find_time_map(doc) + + assert found.time_map is None and found.warnings == () + assert not caplog.records + + +def test_a_folded_table_past_the_media_is_warned_about(caplog): + # It has no time map, but its rows have a closing barline (FR-013). + folded = _folded() + doc = document(folded, media_length=2.0) + + with caplog.at_level(logging.WARNING): + found = find_time_map(doc) + + assert found.time_map is None and found.reason == FOLDED + (warning,) = found.warnings + assert warning.startswith(f"Timeline {folded.id}: the last beat (3.0)") + assert len(caplog.records) == 1 + + +def test_tables_and_named_time_maps_log_nothing_about_the_media(caplog): + # FR-082: only find_time_map logs it, so a table built many times doesn't. + timeline = pickup_piece() + doc = document(timeline, media_length=19.5) + + with caplog.at_level(logging.WARNING): + for _ in range(3): + assert time_map_for(doc, timeline.id) is not None + assert measure_rows(doc, timeline.id) + + assert not caplog.records + + +def test_measure_rows_of_a_folded_table(): + # FR-013: no time map, but the rows are computed, in the timeline's order. + timeline = _folded() + doc = document(timeline) + + rows = measure_rows(doc, timeline.id) + + assert time_map_for(doc, timeline.id) is None + assert [(row.number, row.start, row.end, row.folded) for row in rows] == [ + (1, 0.0, 2.0, True), + (2, 2.0, 4.0, True), + ] + + +def test_measure_rows_give_none_while_a_beat_has_no_usable_time(): + timeline = _beats(1) + beat = timeline.components[timeline.measures.rows[1].beats[1]] + beat.attrs["time"] = float("nan") + + assert measure_rows(document(timeline), timeline.id) == [] + + +def test_a_beat_time_too_large_for_a_float_is_no_time(caplog): + # TimeLineAnnotator/project-management#319 (a beat time too large for a + # float): JSON reads a whole number of any size as an int, which a float + # can't hold. In a timeline that isn't the time map, it doesn't stop the + # document's: that timeline alone has no rows and no time map. + chosen, other = _beats(1, role="time_map"), _beats(2) + last = other.measures.rows[-1].beats[-1] + other.components[last].attrs["time"] = 10**400 + doc = document(chosen, other, media_length=2.0) + + with caplog.at_level(logging.WARNING): + found = find_time_map(doc) + + assert found.timeline_id == chosen.id and found.time_map is not None + (warning,) = found.warnings + assert warning.startswith(f"Timeline {chosen.id}: the last beat (3.0)") + assert len(caplog.records) == 1 + assert measure_rows(doc, other.id) == [] + assert time_map_for(doc, other.id) is None + assert find_time_map(document(other)).reason == "a beat without a time (measure 2)" + + +@pytest.mark.parametrize("make", [_same_time, _out_of_order]) +def test_measure_rows_give_none_while_beats_are_out_of_order_or_share_a_time(make): + # No measure that ends before it starts + # (TimeLineAnnotator/project-management#13 (the measure table)). + timeline = make() + + assert measure_rows(document(timeline), timeline.id) == [] + + +def test_measure_rows_of_a_timeline_that_is_not_a_beat_timeline(): + # Even one that carries a table, as only a beat timeline should. + beats, markers = _beats(1), _other("marker", 2) + with_table = dataclasses.replace(_beats(3), kind="marker") + doc = document(beats, markers, with_table) + + assert measure_rows(doc, markers.id) == [] + assert measure_rows(doc, with_table.id) == [] + assert time_map_for(doc, with_table.id) is None + assert measure_rows(doc, tla.new_id()) == [] + assert len(measure_rows(doc, beats.id)) == 2 + + +def test_the_package_exports_the_contracts_names_that_exist(): + assert sorted(timemap.__all__) == [ + "MeasureRow", + "Positions", + "TimeMap", + "TimeMapFound", + "find_time_map", + "measure_rows", + "time_map_for", + "time_map_of", + ] + for name in timemap.__all__: + assert getattr(timemap, name) is not None diff --git a/packages/tilia-core/tests/timemap/test_timemap_positions.py b/packages/tilia-core/tests/timemap/test_timemap_positions.py index 071e2380e..116f91206 100644 --- a/packages/tilia-core/tests/timemap/test_timemap_positions.py +++ b/packages/tilia-core/tests/timemap/test_timemap_positions.py @@ -4,6 +4,7 @@ import pytest +import tilia_core.timemap.timemap as timemap_module from docs import beat_timeline, document, pickup_piece from tilia_core import tla from tilia_core.timemap import TimeMap, time_map_for @@ -514,15 +515,17 @@ def test_timemap_guessed_is_kept(): assert time_map.guessed and reason is None -def test_timemap_folded_table_is_refused_before_its_rows_are_built(caplog): - # The media ends before the last beat, which building the rows would log. +def test_timemap_folded_table_is_refused_before_its_rows_are_built(monkeypatch): folded = _timeline({0: {"number": 1}, 2: {}}, last=3) folded.measures.rows[1].next = [folded.measures.rows[0].id] - time_map, reason = build_time_map(document(folded, media_length=2.5), folded) + def build_rows_from(*args): + raise AssertionError("the rows of a folded table were built") + + monkeypatch.setattr(timemap_module, "build_rows_from", build_rows_from) + time_map, reason = build_time_map(document(folded), folded) assert time_map is None and reason.startswith("the table lists the measures") - assert not caplog.records def test_timemap_derives_its_arrays_from_its_rows_and_beats(): diff --git a/packages/tilia-core/tests/timemap/test_timemap_prototype.py b/packages/tilia-core/tests/timemap/test_timemap_prototype.py index 91ad3d0b2..26d516d96 100644 --- a/packages/tilia-core/tests/timemap/test_timemap_prototype.py +++ b/packages/tilia-core/tests/timemap/test_timemap_prototype.py @@ -35,24 +35,18 @@ from docs import beat_timeline, document from tilia_core import tla -from tilia_core.timemap import TimeMap, time_map_for +from tilia_core.timemap import TimeMap, find_time_map, time_map_for from tilia_core.timemap import timemap as timemap_module # The prototype's TimeMap.position, length, beat_seconds and bar_seconds are the -# core's position, length and unit_seconds, which aren't written yet. Both marks -# are strict: once the names exist, these tests fail until the mark is removed. +# core's position, length and unit_seconds, which aren't written yet. The mark is +# strict: once the names exist, these tests fail until it is removed. needs_distances = pytest.mark.xfail( raises=AttributeError, strict=True, reason="TimeMap.position, length and unit_seconds come with " "TimeLineAnnotator/project-management#215", ) -# The prototype's pick_time_map is the core's find_time_map, not written yet. -needs_find_time_map = pytest.mark.xfail( - raises=ImportError, - strict=True, - reason="find_time_map comes with TimeLineAnnotator/project-management#216", -) T44 = [1.0 + 0.5 * i for i in range(12)] # 4/4, bars 1-3: downbeats 1.0, 3.0, 5.0 @@ -430,10 +424,8 @@ def test_no_time_map(): assert time_map_for(doc, tla.new_id()) is None -@needs_find_time_map def test_choosing_the_time_map(): - from tilia_core.timemap import find_time_map - + """The prototype's pick_time_map, which is the core's find_time_map.""" # the first beat timeline by ordinal hierarchy = tla.Timeline(id=tla.new_id(), kind="hierarchy", ordinal=1) late = beat_timeline([], ordinal=5) diff --git a/packages/tilia-core/tests/timemap/test_timemap_table.py b/packages/tilia-core/tests/timemap/test_timemap_table.py index c5019ed11..c0d454490 100644 --- a/packages/tilia-core/tests/timemap/test_timemap_table.py +++ b/packages/tilia-core/tests/timemap/test_timemap_table.py @@ -234,13 +234,13 @@ def test_the_end_of_the_media_caps_the_closing_barline(media_length, end, caplog @pytest.mark.parametrize("media_length", [19.5, 20.0]) def test_media_ending_at_or_before_the_last_beat_leaves_the_gap(media_length, caplog): + # find_time_map warns about it, once per file; a table built logs nothing (FR-082). timeline = pickup_piece() with caplog.at_level(logging.WARNING): rows = build_rows(document(timeline, media_length=media_length), timeline) assert rows[-1].end == 21.0 - assert timeline.id in caplog.text - assert "media" in caplog.text + assert not caplog.records def test_the_end_of_the_media_doesnt_cap_a_quarters_file(caplog):