Files
ECC/skills/taste-application/tests/test_resolve.py
T
928c1dea72 feat(tasteforge): package reusable workflows and preserve native edits (#3033)
* feat: bundle standalone taste distillation and application workflows

* docs: fix imported taste skill markdown lint

* docs: align Turkish agent catalog with taste skills

* refactor: make ECC the canonical reusable video engine

* fix: preserve video duration when applying image overlays

* fix: preserve background colors in image compositing

* fix: report best-effort duration targets and shortfalls

* feat: ship verified Fusion presets with compatibility provenance

* feat(tasteforge): preserve native edits in application bundles

* feat(tasteforge): compile local preservation without hosted input

* fix: update js-yaml to patched 4.3.2

* test: report bounded Stop wrapper failure diagnostics

* fix(tasteforge): fail closed on unsafe output names, missing overlays and cadence

- cli: default report and spec paths are derived from pack name and profile
  genre; require the manifest's name pattern before using either as a
  filename part so a traversal string cannot write outside cwd/out.
- apply_local: a pack without cadence.json, or with no measured shots and
  no explicit mean_shot, raises instead of silently planning 1.0s shots and
  reporting a measured cadence.
- legacy apply: a missing overlay aborts before any paid upload; forge()
  would have rejected it after every take was generated.
- requirements-live: pin fal-client>=0.13.0, the first release whose
  subscribe() accepts client_timeout.

Addresses the five P1 findings from the independent review of #3033.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015fxHRsydPqEcYngGbqkgt1

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 15:31:36 +01:00

321 lines
10 KiB
Python

# ruff: noqa: N802 -- fake methods preserve Resolve API names
"""Resolve adapter regression tests; no connection to Resolve is made."""
import json
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from tasteforge.resolve import allocate_placements, apply_placements, probe_asset
class Item:
def __init__(self, request):
self.request = request
self.start = request["recordFrame"]
self.frames = request["endFrame"] + 1
self.props = {"Opacity": 100, "CompositeMode": 0}
def GetStart(self):
return self.start
def GetEnd(self):
return None if self.start is None else self.start + self.frames
def GetDuration(self):
return self.frames
def GetClipEnabled(self):
return True
def GetMediaPoolItem(self):
return self.request["mediaPoolItem"]
def GetProperty(self, key=None):
return self.props.copy() if key is None else self.props[key]
def SetProperty(self, key, value):
self.props[key] = value
return True
class Media:
def __init__(self, path):
self.path = path
def GetClipProperty(self, key):
return self.path
class Timeline:
def __init__(self):
self.tracks = {1: []}
def GetName(self):
return "target"
def GetSetting(self, key):
return "30"
def GetTrackCount(self, kind):
return len(self.tracks) if kind == "video" else 0
def GetItemListInTrack(self, kind, track):
return self.tracks[track]
def AddTrack(self, kind):
self.tracks[len(self.tracks) + 1] = []
return True
class Pool:
def __init__(self, timeline, fault=None, host_mode="inclusive"):
self.timeline, self.fault, self.calls = timeline, fault, []
self.host_mode = host_mode
def ImportMedia(self, paths):
return [Media(paths[0])]
def AppendToTimeline(self, requests):
request = requests[0]
self.calls.append(request)
item = Item(request)
if self.host_mode == "exclusive":
item.frames -= 1
self.timeline.tracks[request["trackIndex"]].append(item)
if self.fault == "null":
item.start = None
if self.fault == "shift":
item.start += 1
if self.fault == "trim":
item.frames -= 1
if self.fault == "later" and len(self.calls) == 2:
self.timeline.tracks[2][0].frames -= 1
if self.fault == "disabled":
item.GetClipEnabled = lambda: False
if self.fault == "property":
item.SetProperty = lambda key, value: True
if self.fault == "path":
item.request["mediaPoolItem"].path = "/wrong.mov"
if self.fault == "track":
self.timeline.tracks[request["trackIndex"]].remove(item)
if self.fault == "base":
self.timeline.tracks[1].append(Item(request))
return [item]
class ResolveTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.path = Path(self.tmp.name) / "asset.mov"
self.path.write_bytes(b"fixture")
self.events = [
dict(
id="a",
asset=str(self.path),
record_frame=0,
frames=10,
opacity=88,
composite=22,
),
dict(
id="b",
asset=str(self.path),
record_frame=5,
frames=10,
opacity=100,
composite=0,
),
dict(
id="c",
asset=str(self.path),
record_frame=10,
frames=5,
opacity=50,
composite=22,
),
]
self.probe = lambda path: dict(fps=30, frames=20, has_alpha=True)
def plan(self, events=None, **kwargs):
return allocate_placements(
self.events if events is None else events,
fps=30,
base_track_count=1,
probe=self.probe,
**kwargs,
)
def apply(self, fault=None, source_end_mode="inclusive", host_mode="inclusive"):
tl = Timeline()
pool = Pool(tl, fault, host_mode)
result = apply_placements(
tl,
pool,
self.events,
source_timeline="source",
source_end_mode=source_end_mode,
fps=30,
base_track_count=1,
probe=self.probe,
)
return result, pool
def test_overlap_coloring_and_inclusive_source_end(self):
plan = self.plan()
self.assertEqual([p["track"] for p in plan], [2, 3, 2])
receipt, pool = self.apply()
self.assertEqual(pool.calls[0]["endFrame"], 9)
self.assertEqual(receipt["placements"][0]["actual"]["end"], 10)
self.assertTrue(receipt["preservation"]["base_tracks_match"])
self.assertNotIn("track", self.events[0])
def test_readback_failure_never_returns_receipt(self):
for fault in (
"null",
"shift",
"trim",
"later",
"base",
"disabled",
"property",
"path",
"track",
):
with self.subTest(fault=fault), self.assertRaises(RuntimeError):
self.apply(fault)
def test_occupied_overlay_tracks_rejected_before_append(self):
tl = Timeline()
tl.tracks[2] = [object()]
pool = Pool(tl)
with self.assertRaises(ValueError):
apply_placements(
tl,
pool,
self.events,
source_timeline="source",
source_end_mode="inclusive",
fps=30,
base_track_count=1,
probe=self.probe,
)
self.assertEqual(pool.calls, [])
def test_invalid_contract(self):
for key, value in [
("frames", 1.5),
("frames", True),
("frames", 0),
("record_frame", -1),
("opacity", float("nan")),
("opacity", 101),
("composite", None),
("asset", self.tmp.name),
]:
with self.subTest(key=key, value=value), self.assertRaises(ValueError):
self.plan([{**self.events[0], key: value}])
with self.assertRaises(ValueError):
self.plan([self.events[0], self.events[0]])
def test_metadata_gates(self):
for metadata in [
dict(fps=24, frames=20, has_alpha=True),
dict(fps=30, frames=2, has_alpha=True),
dict(fps=30, frames=20, has_alpha=False),
]:
with self.subTest(metadata=metadata), self.assertRaises(ValueError):
allocate_placements(
[{**self.events[0], "requires_alpha": True}],
fps=30,
base_track_count=1,
probe=lambda p: metadata,
)
def test_timeline_fps_mismatch_before_mutation(self):
tl = Timeline()
tl.GetSetting = lambda key: "24"
pool = Pool(tl)
with self.assertRaises(ValueError):
apply_placements(
tl,
pool,
self.events,
source_timeline="source",
source_end_mode="inclusive",
fps=30,
base_track_count=1,
probe=self.probe,
)
self.assertEqual(pool.calls, [])
def test_exclusive_host_and_receipt(self):
receipt, pool = self.apply(source_end_mode="exclusive", host_mode="exclusive")
self.assertEqual(pool.calls[0]["endFrame"], 10)
self.assertEqual(receipt["source_end_mode"], "exclusive")
self.assertEqual(receipt["placements"][0]["actual"]["duration"], 10)
def test_mode_mismatch_fails_without_retry(self):
for mode, host in [("inclusive", "exclusive"), ("exclusive", "inclusive")]:
tl = Timeline()
pool = Pool(tl, host_mode=host)
with self.assertRaises(RuntimeError):
apply_placements(
tl,
pool,
self.events,
source_timeline="source",
source_end_mode=mode,
fps=30,
base_track_count=1,
probe=self.probe,
)
self.assertEqual(len(pool.calls), 1)
def test_mode_must_be_explicit_and_valid(self):
with self.assertRaises(ValueError):
self.apply(source_end_mode="auto")
with self.assertRaises(TypeError):
apply_placements(
Timeline(),
None,
self.events,
source_timeline="source",
fps=30,
base_track_count=1,
probe=self.probe,
)
class ProbeTests(unittest.TestCase):
def test_ffprobe_alpha_and_frame_count(self):
stream = dict(
avg_frame_rate="30000/1001",
r_frame_rate="30000/1001",
nb_read_frames="42",
pix_fmt="yuva444p10le",
)
with patch(
"tasteforge.resolve.subprocess.run",
return_value=SimpleNamespace(stdout=json.dumps(dict(streams=[stream]))),
) as run:
result = probe_asset(Path("/asset.mov"))
self.assertEqual(result, dict(fps="30000/1001", frames=42, has_alpha=True))
self.assertIn("-count_frames", run.call_args.args[0])
def test_probe_rejects_no_video_and_ambiguous_rate(self):
for streams in [[], [dict(avg_frame_rate="24", r_frame_rate="30")]]:
with (
patch(
"tasteforge.resolve.subprocess.run",
return_value=SimpleNamespace(
stdout=json.dumps(dict(streams=streams))
),
),
self.assertRaises(ValueError),
):
probe_asset(Path("/asset.mov"))