paces 0.0.20__tar.gz → 0.0.22__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. {paces-0.0.20 → paces-0.0.22}/PKG-INFO +1 -1
  2. {paces-0.0.20 → paces-0.0.22}/paces/measure.py +15 -4
  3. {paces-0.0.20 → paces-0.0.22}/paces/model.py +5 -1
  4. {paces-0.0.20 → paces-0.0.22}/paces/segmenters.py +114 -29
  5. {paces-0.0.20 → paces-0.0.22}/pyproject.toml +1 -1
  6. {paces-0.0.20 → paces-0.0.22}/tests/test_ask.py +2 -1
  7. {paces-0.0.20 → paces-0.0.22}/tests/test_segment.py +138 -0
  8. {paces-0.0.20 → paces-0.0.22}/.gitignore +0 -0
  9. {paces-0.0.20 → paces-0.0.22}/LICENSE +0 -0
  10. {paces-0.0.20 → paces-0.0.22}/README.md +0 -0
  11. {paces-0.0.20 → paces-0.0.22}/docs/README.md +0 -0
  12. {paces-0.0.20 → paces-0.0.22}/docs/alignment/README.md +0 -0
  13. {paces-0.0.20 → paces-0.0.22}/docs/poc-reference/README.md +0 -0
  14. {paces-0.0.20 → paces-0.0.22}/docs/poc-reference/artifacts/clips.json +0 -0
  15. {paces-0.0.20 → paces-0.0.22}/paces/__init__.py +0 -0
  16. {paces-0.0.20 → paces-0.0.22}/paces/__main__.py +0 -0
  17. {paces-0.0.20 → paces-0.0.22}/paces/ask.py +0 -0
  18. {paces-0.0.20 → paces-0.0.22}/paces/bodies/__init__.py +0 -0
  19. {paces-0.0.20 → paces-0.0.22}/paces/bodies/_base.py +0 -0
  20. {paces-0.0.20 → paces-0.0.22}/paces/bodies/derivation.py +0 -0
  21. {paces-0.0.20 → paces-0.0.22}/paces/bodies/document.py +0 -0
  22. {paces-0.0.20 → paces-0.0.22}/paces/bodies/signal.py +0 -0
  23. {paces-0.0.20 → paces-0.0.22}/paces/bodies/step.py +0 -0
  24. {paces-0.0.20 → paces-0.0.22}/paces/corrections.py +0 -0
  25. {paces-0.0.20 → paces-0.0.22}/paces/cues.py +0 -0
  26. {paces-0.0.20 → paces-0.0.22}/paces/data/ask/ask.css +0 -0
  27. {paces-0.0.20 → paces-0.0.22}/paces/data/ask/ask.html +0 -0
  28. {paces-0.0.20 → paces-0.0.22}/paces/data/ask/ask.js +0 -0
  29. {paces-0.0.20 → paces-0.0.22}/paces/derivation.py +0 -0
  30. {paces-0.0.20 → paces-0.0.22}/paces/edits.py +0 -0
  31. {paces-0.0.20 → paces-0.0.22}/paces/evidence.py +0 -0
  32. {paces-0.0.20 → paces-0.0.22}/paces/excerpts.py +0 -0
  33. {paces-0.0.20 → paces-0.0.22}/paces/pose.py +0 -0
  34. {paces-0.0.20 → paces-0.0.22}/paces/projection.py +0 -0
  35. {paces-0.0.20 → paces-0.0.22}/paces/render.py +0 -0
  36. {paces-0.0.20 → paces-0.0.22}/paces/segmentation.py +0 -0
  37. {paces-0.0.20 → paces-0.0.22}/paces/timed_text.py +0 -0
  38. {paces-0.0.20 → paces-0.0.22}/paces/tools.py +0 -0
  39. {paces-0.0.20 → paces-0.0.22}/tests/audio_synth.py +0 -0
  40. {paces-0.0.20 → paces-0.0.22}/tests/data/body_schemas.json +0 -0
  41. {paces-0.0.20 → paces-0.0.22}/tests/data/routine.json +0 -0
  42. {paces-0.0.20 → paces-0.0.22}/tests/poc_fixture.py +0 -0
  43. {paces-0.0.20 → paces-0.0.22}/tests/test_chapters.py +0 -0
  44. {paces-0.0.20 → paces-0.0.22}/tests/test_cli_surface.py +0 -0
  45. {paces-0.0.20 → paces-0.0.22}/tests/test_corrections.py +0 -0
  46. {paces-0.0.20 → paces-0.0.22}/tests/test_cues.py +0 -0
  47. {paces-0.0.20 → paces-0.0.22}/tests/test_derivation.py +0 -0
  48. {paces-0.0.20 → paces-0.0.22}/tests/test_edit_protection_residuals.py +0 -0
  49. {paces-0.0.20 → paces-0.0.22}/tests/test_edits.py +0 -0
  50. {paces-0.0.20 → paces-0.0.22}/tests/test_evidence.py +0 -0
  51. {paces-0.0.20 → paces-0.0.22}/tests/test_evidence_schemas.py +0 -0
  52. {paces-0.0.20 → paces-0.0.22}/tests/test_excerpts.py +0 -0
  53. {paces-0.0.20 → paces-0.0.22}/tests/test_measure.py +0 -0
  54. {paces-0.0.20 → paces-0.0.22}/tests/test_pose.py +0 -0
  55. {paces-0.0.20 → paces-0.0.22}/tests/test_render_media.py +0 -0
  56. {paces-0.0.20 → paces-0.0.22}/tests/test_roundtrip_poc.py +0 -0
  57. {paces-0.0.20 → paces-0.0.22}/tests/test_smoke.py +0 -0
  58. {paces-0.0.20 → paces-0.0.22}/tests/test_vertical_slice.py +0 -0
  59. {paces-0.0.20 → paces-0.0.22}/tests/video_synth.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: paces
3
- Version: 0.0.20
3
+ Version: 0.0.22
4
4
  Summary: Turn instructional media into structured, interactive learning material
5
5
  Project-URL: Homepage, https://github.com/thorwhalen/paces
6
6
  Project-URL: Repository, https://github.com/thorwhalen/paces
@@ -45,6 +45,7 @@ from paces.model import MetricGrid, decimal_str, seconds_per_unit
45
45
  from paces.segmenters import (
46
46
  Capability,
47
47
  Segmentation,
48
+ check_step_durations,
48
49
  place_on_grid,
49
50
  register,
50
51
  )
@@ -231,7 +232,7 @@ def measure_grid(
231
232
  flags += (f"tempo-unmeasured: {tempo_unmeasured_note}{suffix}",)
232
233
 
233
234
  # Explicit beats inferred — but a disagreement is a finding, not a secret.
234
- final_tempo = known_tempo or measured_tempo
235
+ final_tempo = known_tempo if known_tempo is not None else measured_tempo
235
236
  if (
236
237
  known_tempo is not None
237
238
  and measured_tempo is not None
@@ -243,7 +244,7 @@ def measure_grid(
243
244
  f"measures {measured_tempo} — using yours; drop tempoBpm from "
244
245
  "grid= to use the measured value",
245
246
  )
246
- final_origin = known_origin or measured_origin
247
+ final_origin = known_origin if known_origin is not None else measured_origin
247
248
  if known_origin is None and measured_origin is not None:
248
249
  flags += (
249
250
  f"origin-estimated: first beat of the music region "
@@ -267,11 +268,19 @@ def measure_grid(
267
268
  confidence = UNMEASURED_CONFIDENCE
268
269
  else:
269
270
  confidence = (
270
- KNOWN_ORIGIN_CONFIDENCE if known_origin else DFLT_MEASURED_CONFIDENCE
271
+ KNOWN_ORIGIN_CONFIDENCE
272
+ if known_origin is not None
273
+ else DFLT_MEASURED_CONFIDENCE
271
274
  )
272
275
 
273
276
  spu = seconds_per_unit(grid)
274
- if total_units and total_units > 0 and spu and final_origin and region:
277
+ if (
278
+ total_units
279
+ and total_units > 0
280
+ and spu
281
+ and final_origin is not None
282
+ and region is not None
283
+ ):
275
284
  routine_s = total_units * spu
276
285
  region_s = region.end - float(Fraction(final_origin))
277
286
  evidence.update(routine_s=round(routine_s, 2), region_s=round(region_s, 2))
@@ -300,6 +309,8 @@ def _segment_grid_measured(inputs: Mapping[str, Any]) -> Segmentation:
300
309
  partial: MetricGrid | None = inputs["grid"]
301
310
  unit = partial.unit if partial is not None else DFLT_UNIT
302
311
  subdivisions = partial.subdivisions if partial is not None else DFLT_SUBDIVISIONS
312
+ # bad durations fail here, before the costly measurement, not after it
313
+ check_step_durations(inputs["steps"], who="grid-measured")
303
314
  total_units = sum(row["duration"] for row in inputs["steps"])
304
315
  measurement = measure_grid(
305
316
  inputs["media"],
@@ -345,7 +345,11 @@ def resolve(doc: StepDocument) -> dict:
345
345
  """
346
346
  grid = doc.metric
347
347
  spu = seconds_per_unit(grid) if grid is not None else None
348
- origin = float(Fraction(grid.origin)) if grid and grid.origin else None
348
+ origin = (
349
+ float(Fraction(grid.origin))
350
+ if grid is not None and grid.origin is not None
351
+ else None
352
+ )
349
353
 
350
354
  def _wall(offset_units: float) -> float | None:
351
355
  if spu is None or origin is None:
@@ -39,7 +39,7 @@ from __future__ import annotations
39
39
  import importlib
40
40
  import math
41
41
  import time
42
- from collections.abc import Mapping, Sequence
42
+ from collections.abc import Iterable, Mapping, Sequence
43
43
  from dataclasses import dataclass, field
44
44
  from pathlib import Path
45
45
  from typing import Any, Callable
@@ -131,18 +131,31 @@ def _slugify(text: str) -> str:
131
131
  return slug or "step"
132
132
 
133
133
 
134
- def unique_slug_ids(names: Sequence[str], *, fallback: str = "step") -> list[str]:
134
+ def unique_slug_ids(
135
+ names: Sequence[str], *, fallback: str = "step", taken: Iterable[str] = ()
136
+ ) -> list[str]:
135
137
  """One unique, git-readable id per name; unnamed ones get ``{fallback}-{i}``.
136
138
 
139
+ An id already in ``taken`` (or produced earlier in this call) gets a
140
+ ``-2``, ``-3``… suffix — the first one free. ``taken`` is only read.
141
+
137
142
  >>> unique_slug_ids(["Turn", "", "Turn"], fallback="cue")
138
143
  ['turn', 'cue-2', 'turn-2']
144
+ >>> unique_slug_ids(["Turn", "Turn", "Turn 2"])
145
+ ['turn', 'turn-2', 'turn-2-2']
146
+ >>> unique_slug_ids(["Turn"], taken={"turn"})
147
+ ['turn-2']
139
148
  """
140
- seen: dict[str, int] = {}
149
+ used = set(taken)
141
150
  ids = []
142
151
  for index, name in enumerate(names):
143
- slug = _slugify(name) if name else f"{fallback}-{index + 1}"
144
- seen[slug] = seen.get(slug, 0) + 1
145
- ids.append(slug if seen[slug] == 1 else f"{slug}-{seen[slug]}")
152
+ base = _slugify(name) if name else f"{fallback}-{index + 1}"
153
+ candidate, n = base, 1
154
+ while candidate in used:
155
+ n += 1
156
+ candidate = f"{base}-{n}"
157
+ used.add(candidate)
158
+ ids.append(candidate)
146
159
  return ids
147
160
 
148
161
 
@@ -150,29 +163,104 @@ def _normalize_steps(steps: Sequence | None) -> list[dict]:
150
163
  """Accept names, (name, duration) pairs, or mappings; emit uniform dicts.
151
164
 
152
165
  Uniform shape: ``{id, name, duration (float units or None), spans, children}``.
166
+ Ids are unique across the whole tree, as a document requires. An id the
167
+ caller gave is kept as given, and two equal ones are a ``ValueError``
168
+ naming both paths. A generated one is the name's slug, suffixed ``-2``,
169
+ ``-3``… when taken; generation runs breadth-first (every top-level row,
170
+ then each level of children), so editing children never renames a parent.
171
+ A generated id still follows position among same-named steps — give
172
+ repeated steps that must keep their identity across edits explicit ids.
173
+
174
+ >>> [r["id"] for r in _normalize_steps(["Turn", "Turn", {"name": "x", "id": "turn-2"}])]
175
+ ['turn', 'turn-3', 'turn-2']
153
176
  """
177
+ rows = _step_rows(steps, where="steps")
178
+
179
+ def _paths(rows: list[dict], where: str):
180
+ for i, row in enumerate(rows):
181
+ yield f"{where}[{i}]", row
182
+ yield from _paths(row["children"], f"{where}[{i}].children")
183
+
184
+ explicit: dict[str, str] = {}
185
+ for path, row in _paths(rows, "steps"):
186
+ if row["id"] is None:
187
+ continue
188
+ if row["id"] in explicit:
189
+ raise ValueError(
190
+ f"duplicate step id {row['id']!r} at {explicit[row['id']]} and {path}"
191
+ )
192
+ explicit[row["id"]] = path
193
+
194
+ taken = set(explicit)
195
+ level = rows
196
+ while level:
197
+ pending = [row for row in level if row["id"] is None]
198
+ ids = unique_slug_ids(
199
+ [row.get("name") or "step" for row in pending], taken=taken
200
+ )
201
+ for row, step_id in zip(pending, ids):
202
+ row["id"] = step_id
203
+ taken.update(ids)
204
+ level = [child for row in level for child in row["children"]]
205
+ return rows
206
+
207
+
208
+ def _walk_rows(rows: list[dict]):
209
+ """Every row of a normalised step tree, parents before children."""
210
+ for row in rows:
211
+ yield row
212
+ yield from _walk_rows(row["children"])
213
+
214
+
215
+ def _undated_ids(rows: list[dict]) -> list[str]:
216
+ """Ids of the (nested) normalised rows that have no duration."""
217
+ return [row["id"] for row in _walk_rows(rows) if row["duration"] is None]
218
+
219
+
220
+ def check_step_durations(rows: list[dict], *, who: str = "place_on_grid") -> None:
221
+ """Raise ``ValueError`` unless every (nested) row has a positive, finite
222
+ duration — the precondition for placing a step list on a grid. ``rows``
223
+ are normalised step rows (what :func:`segment` hands a segmenter in
224
+ ``inputs["steps"]``), not the raw forms :func:`segment` accepts."""
225
+ undated = _undated_ids(rows)
226
+ if undated:
227
+ raise ValueError(f"{who} needs a duration for every step: {undated}")
228
+ bad = [
229
+ row["id"]
230
+ for row in _walk_rows(rows)
231
+ if not (math.isfinite(row["duration"]) and row["duration"] > 0)
232
+ ]
233
+ if bad:
234
+ raise ValueError(f"{who} needs positive, finite durations: {bad}")
235
+
236
+
237
+ def _step_rows(steps: Sequence | None, *, where: str) -> list[dict]:
154
238
  if steps is None:
155
239
  return []
156
240
  rows: list[dict] = []
157
241
  for i, item in enumerate(steps):
242
+ is_sequence = isinstance(item, Sequence) and not isinstance(item, bytes)
158
243
  if isinstance(item, str):
159
244
  row = {"name": item}
160
245
  elif isinstance(item, Mapping):
161
246
  row = dict(item)
162
- elif isinstance(item, Sequence):
247
+ elif is_sequence and len(item) == 2:
163
248
  name, duration = item
164
249
  row = {"name": name, "duration": duration}
165
250
  else:
251
+ got = f"a {len(item)}-item sequence" if is_sequence else type(item).__name__
166
252
  raise TypeError(
167
- f"steps[{i}]: expected a name, a (name, duration) pair, or a "
168
- f"mapping — got {type(item).__name__}"
253
+ f"{where}[{i}]: expected a name, a (name, duration) pair, or a "
254
+ f"mapping — got {got}"
169
255
  )
170
- row.setdefault("id", _slugify(row.get("name", "")) or f"step-{i + 1}")
256
+ row["id"] = row.get("id")
171
257
  row["duration"] = (
172
258
  None if row.get("duration") is None else float(row["duration"])
173
259
  )
174
260
  row["spans"] = [tuple(map(float, s)) for s in row.get("spans", ())]
175
- row["children"] = _normalize_steps(row.get("children") or row.get("steps"))
261
+ row["children"] = _step_rows(
262
+ row.get("children") or row.get("steps"), where=f"{where}[{i}].children"
263
+ )
176
264
  rows.append(row)
177
265
  return rows
178
266
 
@@ -296,8 +384,8 @@ def _facts(
296
384
  pass
297
385
  if steps:
298
386
  facts.add("steps")
299
- if all(row["duration"] is not None for row in steps):
300
- facts.add("steps.durations")
387
+ if not _undated_ids(steps):
388
+ facts.add("steps.durations") # children too: placement needs them all
301
389
  if any(row["spans"] for row in steps):
302
390
  facts.add("steps.spans")
303
391
  if boundaries:
@@ -371,6 +459,11 @@ def segment(
371
459
  chapters = _normalize_chapters(metadata)
372
460
  subtitles, subtitles_problem = _subtitle_cues(metadata)
373
461
  problem_flags = (subtitles_problem,) if subtitles_problem else ()
462
+ undated = _undated_ids(step_rows)
463
+ if undated and len(undated) < sum(1 for _ in _walk_rows(step_rows)):
464
+ # durations were given but not for all: say which, so a refused or
465
+ # unplaced result never just asks for what the caller supplied
466
+ problem_flags += (f"steps-undated: {undated}",)
374
467
  transcript_cues = normalize_timed_text(transcript)
375
468
  # fail fast: a malformed log raises before any segmenter runs
376
469
  correction_log = (
@@ -412,9 +505,15 @@ def segment(
412
505
  raise ValueError(f"unknown segmenter {segmenter!r}; registered: {known}")
413
506
  missing_facts = capability.needs - facts
414
507
  if missing_facts:
508
+ undated_note = (
509
+ f"; steps without a duration: {undated}"
510
+ if "steps.durations" in missing_facts and undated
511
+ else ""
512
+ )
415
513
  raise ValueError(
416
514
  f"segmenter {capability.name!r} needs inputs you did not "
417
- f"provide: {sorted(missing_facts)} (facts present: {sorted(facts)})"
515
+ f"provide: {sorted(missing_facts)} (facts present: "
516
+ f"{sorted(facts)}){undated_note}"
418
517
  )
419
518
  else:
420
519
  candidates = [
@@ -516,11 +615,6 @@ def place_on_grid(steps: Sequence, grid: MetricGrid | Mapping) -> Segmentation:
516
615
  ([((1.0, 5.0),), ((5.0, 13.0),)], (1.0, 5.0, 13.0))
517
616
  """
518
617
 
519
- def _all_rows(rows: list[dict]):
520
- for row in rows:
521
- yield row
522
- yield from _all_rows(row["children"])
523
-
524
618
  grid = _normalize_grid(grid)
525
619
  rows = _normalize_steps(steps)
526
620
  if grid is None:
@@ -534,16 +628,7 @@ def place_on_grid(steps: Sequence, grid: MetricGrid | Mapping) -> Segmentation:
534
628
  raise ValueError(f"place_on_grid needs a grid with {' and '.join(missing)}")
535
629
  if not rows:
536
630
  raise ValueError("place_on_grid needs at least one step")
537
- undated = [row["id"] for row in _all_rows(rows) if row["duration"] is None]
538
- if undated:
539
- raise ValueError(f"place_on_grid needs a duration for every step: {undated}")
540
- bad = [
541
- row["id"]
542
- for row in _all_rows(rows)
543
- if not (math.isfinite(row["duration"]) and row["duration"] > 0)
544
- ]
545
- if bad:
546
- raise ValueError(f"place_on_grid needs positive, finite durations: {bad}")
631
+ check_step_durations(rows)
547
632
  spu = seconds_per_unit(grid)
548
633
  origin = float(grid.origin)
549
634
 
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
6
6
 
7
7
  [project]
8
8
  name = "paces"
9
- version = "0.0.20"
9
+ version = "0.0.22"
10
10
  description = "Turn instructional media into structured, interactive learning material"
11
11
  readme = "README.md"
12
12
  license = "MIT"
@@ -456,7 +456,8 @@ def test_js_repeated_names_are_separate_rows():
456
456
  """verse/chorus/verse: each occurrence is marked on its own, and replays."""
457
457
  names = ["verse", "chorus", "verse"]
458
458
  proposal = propose(segment(boundaries=CUTS, steps=names))
459
- assert [s["id"] for s in proposal["steps"]] == names # ids repeat
459
+ # ids are unique now (#47), but rows stay the key: names still repeat
460
+ assert [s["id"] for s in proposal["steps"]] == ["verse", "chorus", "verse-2"]
460
461
  answers = _answers_after(proposal, [{"type": "absent", "index": 2, "on": True}])
461
462
  assert [(c["verb"], c["anchorT"]) for c in answers["corrections"]] == [
462
463
  ("absent", "25")
@@ -199,3 +199,141 @@ def test_place_on_grid_names_what_is_missing(steps, grid, match):
199
199
 
200
200
  with pytest.raises(ValueError, match=match):
201
201
  place_on_grid(steps, grid)
202
+
203
+
204
+ @pytest.mark.parametrize("origin, start", [("0", 0.0), ("0.0", 0.0), ("51.2", 51.2)])
205
+ def test_resolve_computes_wall_clock_from_any_origin_including_zero(origin, start):
206
+ """An origin of 0 is a real origin, not a missing one (#46)."""
207
+ from paces.model import resolve
208
+
209
+ doc = to_document(
210
+ segment(steps=[("a", 1)], grid={**GRID, "origin": origin}),
211
+ doc_id="d",
212
+ title="t",
213
+ )
214
+ assert resolve(doc)["steps"][0]["start_s"] == pytest.approx(start)
215
+
216
+
217
+ def test_resolve_without_an_origin_has_no_wall_clock():
218
+ from paces.model import resolve
219
+
220
+ doc = to_document(segment(steps=[("a", 1)], grid=GRID), doc_id="d", title="t")
221
+ doc = doc.model_copy(
222
+ update={"metric": doc.metric.model_copy(update={"origin": None})}
223
+ )
224
+ assert resolve(doc)["steps"][0]["start_s"] is None
225
+
226
+
227
+ def test_resolve_treats_a_numeric_zero_origin_as_an_origin():
228
+ """A grid updated in place skips wire validation; 0 must still be 0 (#46)."""
229
+ from paces.model import resolve
230
+
231
+ doc = to_document(segment(steps=[("a", 1)], grid=GRID), doc_id="d", title="t")
232
+ doc = doc.model_copy(update={"metric": doc.metric.model_copy(update={"origin": 0})})
233
+ assert resolve(doc)["steps"][0]["start_s"] == 0.0
234
+
235
+
236
+ def test_duplicate_step_names_get_unique_ids_tree_wide():
237
+ """Edits, locks and the evidence layer address steps by id (#47)."""
238
+ steps = [
239
+ ("Turn", 2),
240
+ ("Turn", 2),
241
+ {"name": "Kick", "duration": 4, "children": [("Turn", 2), ("Turn", 2)]},
242
+ ]
243
+ seg = segment(steps=steps, grid=GRID)
244
+ ids = [seg.steps[0].id, seg.steps[1].id, *(c.id for c in seg.steps[2].children)]
245
+ assert ids == ["turn", "turn-2", "turn-3", "turn-4"]
246
+ doc = to_document(seg, doc_id="d", title="t")
247
+ from paces.model import validate_document
248
+
249
+ assert not [i for i in validate_document(doc) if "duplicate step id" in i]
250
+
251
+
252
+ def test_an_explicit_step_id_is_kept_and_never_reused():
253
+ seg = segment(
254
+ steps=[("Turn", 1), {"name": "x", "id": "turn", "duration": 1}], grid=GRID
255
+ )
256
+ assert [s.id for s in seg.steps] == ["turn-2", "turn"]
257
+
258
+
259
+ def test_a_step_that_is_neither_name_pair_nor_mapping_is_named_in_the_error():
260
+ with pytest.raises(TypeError, match=r"steps\[1\].*3-item sequence"):
261
+ segment(steps=[("a", 1), ("b", 1, "extra")], grid=GRID)
262
+ with pytest.raises(TypeError, match=r"steps\[0\]\.children\[0\]"):
263
+ segment(steps=[{"name": "p", "duration": 1, "children": [3]}], grid=GRID)
264
+
265
+
266
+ def test_bytes_is_not_a_name_duration_pair():
267
+ with pytest.raises(TypeError, match=r"steps\[0\].*got bytes"):
268
+ segment(steps=[b"ab"], grid=GRID)
269
+
270
+
271
+ def test_duplicate_explicit_ids_name_both_paths():
272
+ steps = [
273
+ {"name": "a", "id": "x", "duration": 1},
274
+ ("b", 1),
275
+ {"name": "c", "duration": 2, "children": [("d", 1), {"name": "e", "id": "x"}]},
276
+ ]
277
+ with pytest.raises(
278
+ ValueError, match=r"'x' at steps\[0\] and steps\[2\]\.children\[1\]"
279
+ ):
280
+ segment(steps=steps, grid=GRID)
281
+
282
+
283
+ def test_generated_ids_are_breadth_first_so_children_never_rename_parents():
284
+ before = segment(steps=[{"name": "Turn", "duration": 2}, ("Turn", 2)], grid=GRID)
285
+ after = segment(
286
+ steps=[{"name": "Turn", "duration": 2, "children": [("Turn", 2)]}, ("Turn", 2)],
287
+ grid=GRID,
288
+ )
289
+ assert [s.id for s in before.steps] == [s.id for s in after.steps]
290
+ assert [s.id for s in after.steps] == ["turn", "turn-2"]
291
+ assert after.steps[0].children[0].id == "turn-3"
292
+
293
+
294
+ def test_partly_undated_steps_are_named_in_a_flag():
295
+ """The refused state's 'try:' must not read as if no durations were given."""
296
+ steps = [("a", 2), {"name": "p", "duration": 2, "children": ["u"]}]
297
+ seg = segment(steps=steps, grid=GRID)
298
+ assert "steps-undated: ['u']" in seg.flags
299
+ assert not [f for f in segment(steps=["a"], grid=GRID).flags if "undated" in f]
300
+
301
+
302
+ def _forbid_measuring(monkeypatch):
303
+ import paces.measure
304
+
305
+ def _measure_grid(*args, **kwargs):
306
+ raise AssertionError("measure_grid must not be called")
307
+
308
+ monkeypatch.setattr(paces.measure, "measure_grid", _measure_grid)
309
+
310
+
311
+ def test_an_undated_child_keeps_placement_from_being_selected(tmp_path, monkeypatch):
312
+ """'steps.durations' covers children, so no placing segmenter is chosen —
313
+ and grid-measured is never asked to measure for a list it cannot place."""
314
+ steps = [{"name": "p", "duration": 2, "children": ["undated"]}]
315
+ seg = segment(steps=steps, grid=GRID)
316
+ assert seg.method != "grid-placed"
317
+ media = tmp_path / "song.wav"
318
+ media.write_bytes(b"")
319
+ _forbid_measuring(monkeypatch)
320
+ with pytest.raises(
321
+ ValueError,
322
+ match=r"needs inputs you did not provide: \['steps\.durations'\]"
323
+ r".*steps without a duration: \['undated'\]",
324
+ ):
325
+ segment(str(media), steps=steps, segmenter="grid-measured")
326
+
327
+
328
+ @pytest.mark.parametrize("bad", [0, -1, float("nan")])
329
+ def test_grid_measured_rejects_bad_durations_before_measuring(
330
+ bad, tmp_path, monkeypatch
331
+ ):
332
+ pytest.importorskip("mixing")
333
+ pytest.importorskip("librosa")
334
+ media = tmp_path / "song.wav"
335
+ media.write_bytes(b"")
336
+ _forbid_measuring(monkeypatch)
337
+ steps = [("a", 1), {"name": "p", "duration": 2, "children": [("c", bad)]}]
338
+ with pytest.raises(ValueError, match=r"positive, finite durations: \['c'\]"):
339
+ segment(str(media), steps=steps, segmenter="grid-measured")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes