paces 0.0.9__tar.gz → 0.0.10__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.
- {paces-0.0.9 → paces-0.0.10}/PKG-INFO +1 -1
- {paces-0.0.9 → paces-0.0.10}/paces/edits.py +104 -1
- {paces-0.0.9 → paces-0.0.10}/paces/model.py +86 -2
- {paces-0.0.9 → paces-0.0.10}/pyproject.toml +1 -1
- paces-0.0.10/tests/test_edit_protection_residuals.py +254 -0
- {paces-0.0.9 → paces-0.0.10}/.gitignore +0 -0
- {paces-0.0.9 → paces-0.0.10}/LICENSE +0 -0
- {paces-0.0.9 → paces-0.0.10}/README.md +0 -0
- {paces-0.0.9 → paces-0.0.10}/docs/README.md +0 -0
- {paces-0.0.9 → paces-0.0.10}/docs/alignment/README.md +0 -0
- {paces-0.0.9 → paces-0.0.10}/docs/poc-reference/README.md +0 -0
- {paces-0.0.9 → paces-0.0.10}/docs/poc-reference/artifacts/clips.json +0 -0
- {paces-0.0.9 → paces-0.0.10}/paces/__init__.py +0 -0
- {paces-0.0.9 → paces-0.0.10}/paces/__main__.py +0 -0
- {paces-0.0.9 → paces-0.0.10}/paces/derivation.py +0 -0
- {paces-0.0.9 → paces-0.0.10}/paces/excerpts.py +0 -0
- {paces-0.0.9 → paces-0.0.10}/paces/measure.py +0 -0
- {paces-0.0.9 → paces-0.0.10}/paces/pose.py +0 -0
- {paces-0.0.9 → paces-0.0.10}/paces/projection.py +0 -0
- {paces-0.0.9 → paces-0.0.10}/paces/render.py +0 -0
- {paces-0.0.9 → paces-0.0.10}/paces/segmenters.py +0 -0
- {paces-0.0.9 → paces-0.0.10}/paces/tools.py +0 -0
- {paces-0.0.9 → paces-0.0.10}/tests/audio_synth.py +0 -0
- {paces-0.0.9 → paces-0.0.10}/tests/data/routine.json +0 -0
- {paces-0.0.9 → paces-0.0.10}/tests/poc_fixture.py +0 -0
- {paces-0.0.9 → paces-0.0.10}/tests/test_chapters.py +0 -0
- {paces-0.0.9 → paces-0.0.10}/tests/test_cli_surface.py +0 -0
- {paces-0.0.9 → paces-0.0.10}/tests/test_derivation.py +0 -0
- {paces-0.0.9 → paces-0.0.10}/tests/test_edits.py +0 -0
- {paces-0.0.9 → paces-0.0.10}/tests/test_excerpts.py +0 -0
- {paces-0.0.9 → paces-0.0.10}/tests/test_measure.py +0 -0
- {paces-0.0.9 → paces-0.0.10}/tests/test_pose.py +0 -0
- {paces-0.0.9 → paces-0.0.10}/tests/test_render_media.py +0 -0
- {paces-0.0.9 → paces-0.0.10}/tests/test_roundtrip_poc.py +0 -0
- {paces-0.0.9 → paces-0.0.10}/tests/test_segment.py +0 -0
- {paces-0.0.9 → paces-0.0.10}/tests/test_smoke.py +0 -0
- {paces-0.0.9 → paces-0.0.10}/tests/test_vertical_slice.py +0 -0
- {paces-0.0.9 → paces-0.0.10}/tests/video_synth.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: paces
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.10
|
|
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
|
|
@@ -34,6 +34,16 @@ Path rules (each earned by an adversarial review, PR #11):
|
|
|
34
34
|
edit does not survive; only its lock record does.
|
|
35
35
|
- ``attrs`` bags merge committed-over-fresh per key: they are user/renderer
|
|
36
36
|
data that analysis does not produce, so regeneration never wins there.
|
|
37
|
+
- Renaming a list item's ``id`` onto an id already used by another item in
|
|
38
|
+
the same list is refused at edit time (previously accepted, and only
|
|
39
|
+
flagged later by :func:`~paces.model.validate_document`) — issue #12.
|
|
40
|
+
- A step rename (its own ``/id`` lock) is remembered by :func:`merge_regenerated`:
|
|
41
|
+
a fresh projection that still emits the pre-rename id (because analysis
|
|
42
|
+
does not know about the rename) is matched to the renamed committed step
|
|
43
|
+
instead of being treated as a new, unrelated step — otherwise the old id
|
|
44
|
+
resurfaces alongside the renamed one. This does not follow renames inside
|
|
45
|
+
a merge across multiple regenerations; only the most recent ``/id`` lock is
|
|
46
|
+
consulted (issue #12).
|
|
37
47
|
|
|
38
48
|
Not yet recorded anywhere: the fresh values a merge *rejects*
|
|
39
49
|
(``Origin.value_digest`` and the op-log arrive with the evidence layer,
|
|
@@ -129,6 +139,36 @@ def _resolve_parent(root: Any, segments: list[str], *, path: str):
|
|
|
129
139
|
raise ValueError(f"{path}: cannot set into {type(node).__name__}")
|
|
130
140
|
|
|
131
141
|
|
|
142
|
+
def _all_steps(steps: list) -> list:
|
|
143
|
+
"""Every step in the tree, depth-first — matching the scope of
|
|
144
|
+
:func:`~paces.model.validate_document`'s step-id uniqueness check, which
|
|
145
|
+
is document-wide, not per-sibling-list."""
|
|
146
|
+
out = []
|
|
147
|
+
for step in steps:
|
|
148
|
+
if isinstance(step, Mapping):
|
|
149
|
+
out.append(step)
|
|
150
|
+
out.extend(_all_steps(step.get("steps", [])))
|
|
151
|
+
return out
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _containing_list(dump: dict, segments: list[str]) -> list | None:
|
|
155
|
+
"""The list holding the item whose own field the leaf segment addresses
|
|
156
|
+
(e.g. for ``/steps/a/id``, the ``steps`` list a and its siblings live in),
|
|
157
|
+
or ``None`` when the leaf isn't a field on a list item."""
|
|
158
|
+
if len(segments) < 2:
|
|
159
|
+
return None
|
|
160
|
+
node: Any = dump
|
|
161
|
+
for i, segment in enumerate(segments[:-2]):
|
|
162
|
+
at = "/" + "/".join(segments[: i + 1])
|
|
163
|
+
if isinstance(node, list):
|
|
164
|
+
node = node[_index_of(node, segment, at=at)]
|
|
165
|
+
elif isinstance(node, Mapping):
|
|
166
|
+
node = node[_key_of(node, segment, at=at)]
|
|
167
|
+
else:
|
|
168
|
+
return None
|
|
169
|
+
return node if isinstance(node, list) else None
|
|
170
|
+
|
|
171
|
+
|
|
132
172
|
def _canonical_segments(root: Any, segments: list[str], *, path: str) -> list[str]:
|
|
133
173
|
"""The stablest spelling of a path: snake_case fields; list items by id
|
|
134
174
|
when one exists unambiguously in that list, by index otherwise."""
|
|
@@ -214,6 +254,33 @@ def apply_edits(
|
|
|
214
254
|
raw_path = edit.get("path", "")
|
|
215
255
|
segments = _canonical_segments(dump, _split_path(raw_path), path=raw_path)
|
|
216
256
|
container, key = _resolve_parent(dump, segments, path=raw_path)
|
|
257
|
+
if key == "id" and isinstance(container, Mapping):
|
|
258
|
+
new_id = edit["value"]
|
|
259
|
+
all_steps = _all_steps(dump.get("steps", []))
|
|
260
|
+
is_step = any(s is container for s in all_steps)
|
|
261
|
+
# A step's id is unique document-wide (validate_document enforces
|
|
262
|
+
# this across the whole tree, not per sibling list) — check the
|
|
263
|
+
# same scope here. Everything else (sources, cues, questions)
|
|
264
|
+
# only needs uniqueness within its own list.
|
|
265
|
+
candidates = (
|
|
266
|
+
all_steps if is_step else (_containing_list(dump, segments) or [])
|
|
267
|
+
)
|
|
268
|
+
collision = next(
|
|
269
|
+
(
|
|
270
|
+
candidate
|
|
271
|
+
for candidate in candidates
|
|
272
|
+
if isinstance(candidate, Mapping)
|
|
273
|
+
and candidate is not container
|
|
274
|
+
and candidate.get("id") == new_id
|
|
275
|
+
),
|
|
276
|
+
None,
|
|
277
|
+
)
|
|
278
|
+
if collision is not None:
|
|
279
|
+
scope = "the document" if is_step else "the same list"
|
|
280
|
+
raise ValueError(
|
|
281
|
+
f"edits[{i}]: cannot rename id to {new_id!r} — already used "
|
|
282
|
+
f"by another item in {scope}"
|
|
283
|
+
)
|
|
217
284
|
# The lock site must resolve BEFORE the mutation: an edit may change
|
|
218
285
|
# the very value a segment addresses (renaming a step's id).
|
|
219
286
|
site, relative = _lock_site(dump, segments)
|
|
@@ -352,14 +419,47 @@ def _merged_attrs(committed: Mapping, fresh: Mapping) -> dict:
|
|
|
352
419
|
return {**copy.deepcopy(dict(fresh)), **copy.deepcopy(dict(committed))}
|
|
353
420
|
|
|
354
421
|
|
|
422
|
+
def _renamed_from(committed: list[dict]) -> dict[str, dict]:
|
|
423
|
+
"""old id -> committed step, for steps whose own ``/id`` lock records a
|
|
424
|
+
rename (``apply_edits`` writes exactly this). Analysis that re-runs
|
|
425
|
+
without knowledge of the rename still emits the old id — without this,
|
|
426
|
+
that fresh step would be treated as unrelated and the old id would
|
|
427
|
+
resurface alongside the renamed one (issue #12)."""
|
|
428
|
+
out: dict[str, dict] = {}
|
|
429
|
+
for step in committed:
|
|
430
|
+
for lock in step.get("locks", []):
|
|
431
|
+
if lock.get("path") == "/id" and lock.get("was") is not None:
|
|
432
|
+
out[str(lock["was"])] = step
|
|
433
|
+
return out
|
|
434
|
+
|
|
435
|
+
|
|
355
436
|
def _merge_steps(committed: list[dict], fresh: list[dict]) -> list[dict]:
|
|
356
437
|
committed_by_id: dict[str, dict] = {}
|
|
357
438
|
for step in committed: # first occurrence wins, matching apply_edits
|
|
358
439
|
committed_by_id.setdefault(step["id"], step)
|
|
440
|
+
renamed_from = _renamed_from(committed)
|
|
359
441
|
fresh_ids = {step["id"] for step in fresh}
|
|
442
|
+
|
|
443
|
+
# Direct id matches are resolved FIRST and claim their committed step
|
|
444
|
+
# before any rename substitution runs: if fresh already carries the
|
|
445
|
+
# renamed id, that's the real match, and a stray fresh entry still using
|
|
446
|
+
# the old id (analysis emitting both, or simply unrelated) must not also
|
|
447
|
+
# claim the same committed step — that would merge one committed step
|
|
448
|
+
# into two output entries with the same id (issue #12, blocking review).
|
|
449
|
+
matched_committed_ids: set[str] = {
|
|
450
|
+
committed_by_id[fresh_step["id"]]["id"]
|
|
451
|
+
for fresh_step in fresh
|
|
452
|
+
if fresh_step["id"] in committed_by_id
|
|
453
|
+
}
|
|
454
|
+
|
|
360
455
|
merged: list[dict] = []
|
|
361
456
|
for fresh_step in fresh:
|
|
362
457
|
committed_step = committed_by_id.get(fresh_step["id"])
|
|
458
|
+
if committed_step is None:
|
|
459
|
+
candidate = renamed_from.get(fresh_step["id"])
|
|
460
|
+
if candidate is not None and candidate["id"] not in matched_committed_ids:
|
|
461
|
+
committed_step = candidate
|
|
462
|
+
matched_committed_ids.add(candidate["id"])
|
|
363
463
|
if committed_step is None:
|
|
364
464
|
merged.append(copy.deepcopy(fresh_step))
|
|
365
465
|
continue
|
|
@@ -378,7 +478,10 @@ def _merge_steps(committed: list[dict], fresh: list[dict]) -> list[dict]:
|
|
|
378
478
|
# Committed-only steps survive when they carry protection (locks anywhere
|
|
379
479
|
# in their subtree, or a user origin); analysis leftovers are superseded.
|
|
380
480
|
for position, committed_step in enumerate(committed):
|
|
381
|
-
if
|
|
481
|
+
if (
|
|
482
|
+
committed_step["id"] in fresh_ids
|
|
483
|
+
or committed_step["id"] in matched_committed_ids
|
|
484
|
+
):
|
|
382
485
|
continue
|
|
383
486
|
subtree_protected = _is_protected(committed_step) or any(
|
|
384
487
|
_is_protected(child) for child in _walk_dumps(committed_step)
|
|
@@ -24,6 +24,8 @@ Design decisions, each argued in ``docs/07-annotation-model.md``:
|
|
|
24
24
|
|
|
25
25
|
from __future__ import annotations
|
|
26
26
|
|
|
27
|
+
from collections.abc import Mapping
|
|
28
|
+
from datetime import datetime
|
|
27
29
|
from fractions import Fraction
|
|
28
30
|
from typing import Annotated, Any, Literal
|
|
29
31
|
|
|
@@ -169,6 +171,12 @@ class Cue(_Base):
|
|
|
169
171
|
# ── provenance & edit protection ────────────────────────────────────────────
|
|
170
172
|
|
|
171
173
|
|
|
174
|
+
#: ``Lock.at`` is always written by :func:`paces.edits._now_iso`; this is that
|
|
175
|
+
#: same format, enforced on the way in so a hand-authored ``at`` can't smuggle
|
|
176
|
+
#: an unparseable timestamp onto the wire.
|
|
177
|
+
_LOCK_AT_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
|
|
178
|
+
|
|
179
|
+
|
|
172
180
|
class Lock(_Base):
|
|
173
181
|
"""A human (or approved-AI) decision that regeneration MUST NOT overwrite."""
|
|
174
182
|
|
|
@@ -178,6 +186,25 @@ class Lock(_Base):
|
|
|
178
186
|
was: Any | None = None # the pre-edit value — makes the edit reversible
|
|
179
187
|
reason: str | None = None
|
|
180
188
|
|
|
189
|
+
@field_validator("by")
|
|
190
|
+
@classmethod
|
|
191
|
+
def _by_must_be_attributed(cls, value: str) -> str:
|
|
192
|
+
if not value.strip():
|
|
193
|
+
raise ValueError("Lock.by must not be empty — who made this edit?")
|
|
194
|
+
return value
|
|
195
|
+
|
|
196
|
+
@field_validator("at")
|
|
197
|
+
@classmethod
|
|
198
|
+
def _at_must_be_iso_utc(cls, value: str) -> str:
|
|
199
|
+
try:
|
|
200
|
+
datetime.strptime(value, _LOCK_AT_FORMAT)
|
|
201
|
+
except ValueError as e:
|
|
202
|
+
raise ValueError(
|
|
203
|
+
f"Lock.at must be ISO-8601 UTC, second resolution "
|
|
204
|
+
f"('2026-08-30T00:00:00Z'); got {value!r}"
|
|
205
|
+
) from e
|
|
206
|
+
return value
|
|
207
|
+
|
|
181
208
|
|
|
182
209
|
class Origin(_Base):
|
|
183
210
|
"""Back-reference into the evidence layer: why this node says what it says,
|
|
@@ -346,6 +373,61 @@ def resolve(doc: StepDocument) -> dict:
|
|
|
346
373
|
}
|
|
347
374
|
|
|
348
375
|
|
|
376
|
+
#: Field names whose schema type is deliberately ``float`` (``docs/07 §6.5``
|
|
377
|
+
#: accepts this as a pre-existing hole in the no-floats-on-the-wire rule) —
|
|
378
|
+
#: everywhere else, a Python ``float`` reaching the wire (typically through an
|
|
379
|
+
#: ``attrs`` bag or a ``Lock.was``, both typed ``Any``) is a leak. (A float
|
|
380
|
+
#: under an ``attrs`` key that happens to be literally named ``confidence``
|
|
381
|
+
#: slips through this same allowlist — a narrow, accepted gap: distinguishing
|
|
382
|
+
#: it would mean knowing ``attrs`` is user data everywhere it appears.)
|
|
383
|
+
_FLOAT_TYPED_KEYS = frozenset({"confidence"})
|
|
384
|
+
|
|
385
|
+
#: The keys a ``Lock`` always dumps as (``model_dump(mode="python")``, no
|
|
386
|
+
#: ``exclude_none``) — used to recognise a Lock structurally so ``was`` can be
|
|
387
|
+
#: checked against what ``path`` says it actually holds, not the literal key
|
|
388
|
+
#: name ``"was"`` (a ``Lock`` locking a ``confidence`` field legitimately
|
|
389
|
+
#: carries a float in ``was``).
|
|
390
|
+
_LOCK_DUMP_KEYS = frozenset({"path", "by", "at", "was", "reason"})
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _scan_floats(node: Any, path: str, *, key: str | None = None) -> list[str]:
|
|
394
|
+
"""Paths where a raw ``float`` reaches the wire outside a typed field."""
|
|
395
|
+
if isinstance(node, float):
|
|
396
|
+
if key in _FLOAT_TYPED_KEYS:
|
|
397
|
+
return []
|
|
398
|
+
return [
|
|
399
|
+
f"{path}: float value {node!r} on the no-floats wire (use a decimal string)"
|
|
400
|
+
]
|
|
401
|
+
if isinstance(node, Mapping):
|
|
402
|
+
if set(node) == _LOCK_DUMP_KEYS and isinstance(node.get("path"), str):
|
|
403
|
+
was_key = node["path"].rsplit("/", 1)[-1]
|
|
404
|
+
return [
|
|
405
|
+
issue
|
|
406
|
+
for k, v in node.items()
|
|
407
|
+
for issue in _scan_floats(
|
|
408
|
+
v, f"{path}/{k}", key=was_key if k == "was" else k
|
|
409
|
+
)
|
|
410
|
+
]
|
|
411
|
+
return [
|
|
412
|
+
issue
|
|
413
|
+
for k, v in node.items()
|
|
414
|
+
for issue in _scan_floats(v, f"{path}/{k}", key=k)
|
|
415
|
+
]
|
|
416
|
+
if isinstance(node, (list, tuple)):
|
|
417
|
+
return [
|
|
418
|
+
issue
|
|
419
|
+
for i, v in enumerate(node)
|
|
420
|
+
for issue in _scan_floats(
|
|
421
|
+
v,
|
|
422
|
+
f"{path}/{v['id']}"
|
|
423
|
+
if isinstance(v, Mapping) and "id" in v
|
|
424
|
+
else f"{path}/{i}",
|
|
425
|
+
key=key,
|
|
426
|
+
)
|
|
427
|
+
]
|
|
428
|
+
return []
|
|
429
|
+
|
|
430
|
+
|
|
349
431
|
def validate_document(doc: StepDocument) -> list[str]:
|
|
350
432
|
"""Semantic checks beyond the schema. Returns human-readable issues
|
|
351
433
|
(empty list = clean); never raises.
|
|
@@ -353,9 +435,11 @@ def validate_document(doc: StepDocument) -> list[str]:
|
|
|
353
435
|
Checks: children durations account for the parent's (``repeat`` included),
|
|
354
436
|
span sources exist, cue anchors point at real steps, step ids are unique
|
|
355
437
|
(id-addressed edits and the regeneration merge both key on them — a
|
|
356
|
-
duplicate makes those silently ambiguous)
|
|
438
|
+
duplicate makes those silently ambiguous), and no raw ``float`` has
|
|
439
|
+
leaked onto the no-floats wire through an ``attrs`` bag or a ``Lock.was``
|
|
440
|
+
(both typed ``Any``, so the schema alone cannot catch this).
|
|
357
441
|
"""
|
|
358
|
-
issues: list[str] =
|
|
442
|
+
issues: list[str] = _scan_floats(doc.model_dump(mode="python", by_alias=False), "")
|
|
359
443
|
source_ids = {s.id for s in doc.sources}
|
|
360
444
|
step_ids: set[str] = set()
|
|
361
445
|
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Residuals from PR #11's adversarial review, tracked as issue #12.
|
|
2
|
+
|
|
3
|
+
Each test below covers one item from the issue. Items declined (documented
|
|
4
|
+
instead of fixed, or explicitly deferred) are not tested here — see the issue
|
|
5
|
+
for the one-line reasons.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
from pydantic import ValidationError
|
|
12
|
+
|
|
13
|
+
from paces.edits import apply_edits, merge_regenerated
|
|
14
|
+
from paces.model import (
|
|
15
|
+
Lock,
|
|
16
|
+
Measure,
|
|
17
|
+
Source,
|
|
18
|
+
SourceSpan,
|
|
19
|
+
Step,
|
|
20
|
+
StepDocument,
|
|
21
|
+
validate_document,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _doc(**overrides) -> StepDocument:
|
|
26
|
+
fields = dict(
|
|
27
|
+
id="guide",
|
|
28
|
+
title="A guide",
|
|
29
|
+
steps=[Step(id="a", name="Step A", duration=Measure(value="2", unit="eight"))],
|
|
30
|
+
)
|
|
31
|
+
fields.update(overrides)
|
|
32
|
+
return StepDocument(**fields)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ── Lock.by / Lock.at validation ─────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_lock_by_rejects_empty_string():
|
|
39
|
+
with pytest.raises(ValidationError):
|
|
40
|
+
Lock(path="/name", by="", at="2026-08-30T00:00:00Z")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_lock_by_rejects_whitespace_only():
|
|
44
|
+
with pytest.raises(ValidationError):
|
|
45
|
+
Lock(path="/name", by=" ", at="2026-08-30T00:00:00Z")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_lock_at_rejects_junk_timestamp():
|
|
49
|
+
with pytest.raises(ValidationError):
|
|
50
|
+
Lock(path="/name", by="user:thor", at="not-a-timestamp")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_lock_at_rejects_non_utc_offset():
|
|
54
|
+
with pytest.raises(ValidationError):
|
|
55
|
+
Lock(path="/name", by="user:thor", at="2026-08-30T00:00:00+02:00")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_lock_at_accepts_the_format_edits_writes():
|
|
59
|
+
Lock(path="/name", by="user:thor", at="2026-08-30T00:00:00Z")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ── Lock.was float leak (validate_document) ──────────────────────────────────
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_validate_document_flags_float_leaked_into_lock_was():
|
|
66
|
+
doc = _doc(
|
|
67
|
+
steps=[
|
|
68
|
+
Step(
|
|
69
|
+
id="a",
|
|
70
|
+
name="Step A",
|
|
71
|
+
duration=Measure(value="2", unit="eight"),
|
|
72
|
+
locks=[
|
|
73
|
+
Lock(
|
|
74
|
+
path="/name", by="user:thor", at="2026-08-30T00:00:00Z", was=1.5
|
|
75
|
+
)
|
|
76
|
+
],
|
|
77
|
+
)
|
|
78
|
+
]
|
|
79
|
+
)
|
|
80
|
+
issues = validate_document(doc)
|
|
81
|
+
assert any("float" in issue for issue in issues)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_validate_document_flags_float_leaked_into_attrs():
|
|
85
|
+
doc = _doc(
|
|
86
|
+
steps=[
|
|
87
|
+
Step(
|
|
88
|
+
id="a",
|
|
89
|
+
name="Step A",
|
|
90
|
+
duration=Measure(value="2", unit="eight"),
|
|
91
|
+
attrs={"render.web": {"hue": 0.5}},
|
|
92
|
+
)
|
|
93
|
+
]
|
|
94
|
+
)
|
|
95
|
+
issues = validate_document(doc)
|
|
96
|
+
assert any("float" in issue for issue in issues)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_validate_document_does_not_flag_the_typed_confidence_float():
|
|
100
|
+
doc = _doc(
|
|
101
|
+
steps=[
|
|
102
|
+
Step(
|
|
103
|
+
id="a",
|
|
104
|
+
name="Step A",
|
|
105
|
+
duration=Measure(value="2", unit="eight"),
|
|
106
|
+
spans=[SourceSpan(source="vid", start="10", confidence=0.8)],
|
|
107
|
+
)
|
|
108
|
+
]
|
|
109
|
+
)
|
|
110
|
+
issues = validate_document(doc)
|
|
111
|
+
assert not any("float" in issue for issue in issues)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_validate_document_does_not_flag_a_lock_was_holding_the_old_confidence():
|
|
115
|
+
# apply_edits itself writes exactly this: a Lock.was carrying the
|
|
116
|
+
# pre-edit value of a schema-typed float field (SourceSpan.confidence).
|
|
117
|
+
doc = _doc(
|
|
118
|
+
steps=[
|
|
119
|
+
Step(
|
|
120
|
+
id="a",
|
|
121
|
+
name="Step A",
|
|
122
|
+
duration=Measure(value="2", unit="eight"),
|
|
123
|
+
spans=[SourceSpan(source="vid", start="10", confidence=0.8)],
|
|
124
|
+
)
|
|
125
|
+
]
|
|
126
|
+
)
|
|
127
|
+
edited = apply_edits(
|
|
128
|
+
doc,
|
|
129
|
+
[{"op": "set", "path": "/steps/a/spans/0/confidence", "value": 0.9}],
|
|
130
|
+
by="user:thor",
|
|
131
|
+
)
|
|
132
|
+
issues = validate_document(edited)
|
|
133
|
+
assert not any("float" in issue for issue in issues)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_validate_document_clean_document_has_no_float_issues():
|
|
137
|
+
assert validate_document(_doc()) == []
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ── rename onto an existing id is refused at edit time ───────────────────────
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _two_step_doc(**overrides) -> StepDocument:
|
|
144
|
+
fields = dict(
|
|
145
|
+
id="guide",
|
|
146
|
+
title="A guide",
|
|
147
|
+
sources=[Source(id="vid", kind="video", uri="https://example.com/v")],
|
|
148
|
+
steps=[
|
|
149
|
+
Step(
|
|
150
|
+
id="a",
|
|
151
|
+
name="Step A",
|
|
152
|
+
duration=Measure(value="2", unit="eight"),
|
|
153
|
+
spans=[SourceSpan(source="vid", start="10")],
|
|
154
|
+
),
|
|
155
|
+
Step(
|
|
156
|
+
id="b",
|
|
157
|
+
name="Step B",
|
|
158
|
+
duration=Measure(value="2", unit="eight"),
|
|
159
|
+
spans=[SourceSpan(source="vid", start="20")],
|
|
160
|
+
),
|
|
161
|
+
],
|
|
162
|
+
)
|
|
163
|
+
fields.update(overrides)
|
|
164
|
+
return StepDocument(**fields)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_apply_edits_refuses_rename_onto_an_existing_id():
|
|
168
|
+
with pytest.raises(ValueError, match="already used"):
|
|
169
|
+
apply_edits(
|
|
170
|
+
_two_step_doc(),
|
|
171
|
+
[{"op": "set", "path": "/steps/a/id", "value": "b"}],
|
|
172
|
+
by="user:thor",
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_apply_edits_allows_rename_onto_a_free_id():
|
|
177
|
+
doc = apply_edits(
|
|
178
|
+
_two_step_doc(),
|
|
179
|
+
[{"op": "set", "path": "/steps/a/id", "value": "a2"}],
|
|
180
|
+
by="user:thor",
|
|
181
|
+
)
|
|
182
|
+
assert [s.id for s in doc.steps] == ["a2", "b"]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_apply_edits_allows_renaming_id_to_itself():
|
|
186
|
+
doc = apply_edits(
|
|
187
|
+
_two_step_doc(),
|
|
188
|
+
[{"op": "set", "path": "/steps/a/id", "value": "a"}],
|
|
189
|
+
by="user:thor",
|
|
190
|
+
)
|
|
191
|
+
assert [s.id for s in doc.steps] == ["a", "b"]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_apply_edits_refuses_a_nested_rename_onto_a_top_level_id():
|
|
195
|
+
doc = _two_step_doc(
|
|
196
|
+
steps=[
|
|
197
|
+
Step(
|
|
198
|
+
id="a",
|
|
199
|
+
name="Step A",
|
|
200
|
+
duration=Measure(value="2", unit="eight"),
|
|
201
|
+
steps=[
|
|
202
|
+
Step(id="c", name="Sub", duration=Measure(value="2", unit="eight"))
|
|
203
|
+
],
|
|
204
|
+
),
|
|
205
|
+
Step(id="b", name="Step B", duration=Measure(value="2", unit="eight")),
|
|
206
|
+
]
|
|
207
|
+
)
|
|
208
|
+
# "b" isn't a sibling of "c" (it lives one level up) — the collision must
|
|
209
|
+
# still be caught document-wide, matching validate_document's own scope.
|
|
210
|
+
with pytest.raises(ValueError, match="already used"):
|
|
211
|
+
apply_edits(
|
|
212
|
+
doc,
|
|
213
|
+
[{"op": "set", "path": "/steps/a/steps/c/id", "value": "b"}],
|
|
214
|
+
by="user:thor",
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ── merge after an id rename does not resurrect the old id ──────────────────
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_merge_regenerated_does_not_resurrect_the_pre_rename_id():
|
|
222
|
+
committed = apply_edits(
|
|
223
|
+
_two_step_doc(),
|
|
224
|
+
[{"op": "set", "path": "/steps/a/id", "value": "a2"}],
|
|
225
|
+
by="user:thor",
|
|
226
|
+
)
|
|
227
|
+
# Analysis re-runs without knowledge of the rename: it still emits "a".
|
|
228
|
+
fresh = _two_step_doc()
|
|
229
|
+
merged = merge_regenerated(committed, fresh)
|
|
230
|
+
assert [s.id for s in merged.steps] == ["a2", "b"]
|
|
231
|
+
assert merged.steps[0].name == "Step A"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def test_merge_regenerated_does_not_double_match_when_fresh_has_both_ids():
|
|
235
|
+
committed = apply_edits(
|
|
236
|
+
_two_step_doc(),
|
|
237
|
+
[{"op": "set", "path": "/steps/a/id", "value": "a2"}],
|
|
238
|
+
by="user:thor",
|
|
239
|
+
)
|
|
240
|
+
# Fresh carries BOTH the old id and the already-renamed id (e.g. analysis
|
|
241
|
+
# caught up and emitted "a2", but a stray "a" is also present) — the
|
|
242
|
+
# renamed committed step must not be matched twice.
|
|
243
|
+
fresh = _two_step_doc(
|
|
244
|
+
steps=[
|
|
245
|
+
Step(id="a", name="Step A", duration=Measure(value="2", unit="eight")),
|
|
246
|
+
Step(id="a2", name="Step A", duration=Measure(value="2", unit="eight")),
|
|
247
|
+
Step(id="b", name="Step B", duration=Measure(value="2", unit="eight")),
|
|
248
|
+
]
|
|
249
|
+
)
|
|
250
|
+
merged = merge_regenerated(committed, fresh)
|
|
251
|
+
ids = [s.id for s in merged.steps]
|
|
252
|
+
assert ids == ["a", "a2", "b"]
|
|
253
|
+
assert len(ids) == len(set(ids))
|
|
254
|
+
assert validate_document(merged) == []
|
|
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
|