Files
roboco/tests/unit/gateway/test_content_actions_video.py
T
797847e379 [1dae04a7] Video: release 0.25.0 (revision) (#536)
* fix(release): CI wait polls the prod rung; escape the header tooltip apostrophe

get_latest_ci_conclusion defaults to the ladder's head rung, so
wait_for_ci searched slave for a release commit that lives on master
and timed out after 40 minutes with the run already green. The wait
now passes the prod branch explicitly. Also fixes the
react/no-unescaped-entities error that turned master's Panel CI red.

* fix(panel,video): dead dialog triggers behind tooltips; dotted composition ids render

HelpTip nested inside a Dialog/AlertDialog trigger puts the trigger's
click handler on the Tooltip root, which renders no DOM — the agents
Spawn item and the KB Reindex-All / Delete-index confirms were dead.
Tooltips now wrap the triggers. The video renderer accepts interior
single dots in composition ids (release-0.25.0) with '..' still
unrepresentable, and propose_video refuses an unrenderable id at
authoring time.

* fix(dispatch): restart-safe PM review turns

A leaf task in awaiting_pm_review had no periodic pickup: the closure
dispatcher bailed on childless tasks and skipped PR-bearing review
tasks as already-promoted, assuming the submit-time PM session was
still alive — an assumption every restart breaks. Proven live on the
docs-sync leaf after the 0.25.0 redeploy, which also dependency-blocked
its sibling dev task. Childless awaiting_pm_review tasks now flow to
the PM's review turn, and the merge turn respawns its PM when none is
active.

* [1dae04a7] Revise release-0.25.0 composition to 40s scene-based pacing with four feature cards

* [1dae04a7] docs(motion): update release-0.25.0 README section for 40s four-card revision

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
Co-authored-by: UX/UI Developer 1 <ux-dev-1@roboco.tech>
Co-authored-by: UX/UI Documenter <ux-doc@roboco.tech>
2026-07-16 17:15:12 +02:00

300 lines
10 KiB
Python

"""roboco.services.gateway.content_actions.propose_video — team-gated,
metadata-only video-authoring draft (no render)."""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.foundation.policy.content import markers
from roboco.services.gateway.content_actions import ContentActions, ContentActionsDeps
class _FakeTask:
"""Minimal stand-in for the ORM TaskTable row — carries just what
``propose_video`` touches."""
def __init__(
self,
*,
assigned_to: Any,
source: str = "video",
task_id: Any = None,
draft: dict[str, Any] | None = None,
) -> None:
self.id = task_id or uuid4()
self.assigned_to = assigned_to
self.source = source
self.orchestration_markers = {"video_draft": draft} if draft else None
def _actions(role: str, team: str | None) -> ContentActions:
task = MagicMock()
agent = MagicMock()
agent.role = role
agent.team = team
task.agent_for = AsyncMock(return_value=agent)
task.session = MagicMock()
task.session.flush = AsyncMock()
deps = ContentActionsDeps(
task=task,
git=MagicMock(),
a2a=MagicMock(),
journal=MagicMock(),
workspace=MagicMock(),
notifications=MagicMock(),
)
return ContentActions(deps)
def _valid_kwargs(**overrides: Any) -> dict[str, Any]:
kwargs: dict[str, Any] = {
"composition_id": "release-announcement-v1",
"x_caption": "We just shipped v1.0.0! Check out the new release.",
"tiktok_caption": "New release just dropped — here's what's inside.",
"platforms": ["x", "tiktok"],
}
kwargs.update(overrides)
return kwargs
def _mock_active_task(
monkeypatch: pytest.MonkeyPatch, task: _FakeTask | None
) -> MagicMock:
"""Stub the caller's currently-active task — the resolver propose_video uses
(``get_active_task_for_agent``), NOT an oldest-first scan over every open
video task."""
task_svc = MagicMock()
task_svc.get_active_task_for_agent = AsyncMock(return_value=task)
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
return task_svc
# --------------------------------------------------------------------------- #
# team gate
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_propose_video_forbidden_for_backend_dev() -> None:
env = await _actions("developer", "backend").propose_video(
agent_id=uuid4(), **_valid_kwargs()
)
assert env.error == "not_authorized"
@pytest.mark.asyncio
async def test_propose_video_forbidden_for_frontend_dev() -> None:
env = await _actions("developer", "frontend").propose_video(
agent_id=uuid4(), **_valid_kwargs()
)
assert env.error == "not_authorized"
@pytest.mark.asyncio
async def test_propose_video_forbidden_with_no_team() -> None:
env = await _actions("developer", None).propose_video(
agent_id=uuid4(), **_valid_kwargs()
)
assert env.error == "not_authorized"
# --------------------------------------------------------------------------- #
# field validation
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_propose_video_rejects_empty_composition_id() -> None:
env = await _actions("developer", "ux_ui").propose_video(
agent_id=uuid4(), **_valid_kwargs(composition_id="")
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_video_rejects_over_280_x_caption() -> None:
env = await _actions("developer", "ux_ui").propose_video(
agent_id=uuid4(), **_valid_kwargs(x_caption="z" * 281)
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_video_rejects_over_2200_tiktok_caption() -> None:
env = await _actions("developer", "ux_ui").propose_video(
agent_id=uuid4(), **_valid_kwargs(tiktok_caption="z" * 2201)
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_video_rejects_empty_platforms() -> None:
env = await _actions("developer", "ux_ui").propose_video(
agent_id=uuid4(), **_valid_kwargs(platforms=[])
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_video_rejects_unknown_platform() -> None:
env = await _actions("developer", "ux_ui").propose_video(
agent_id=uuid4(), **_valid_kwargs(platforms=["instagram"])
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_video_rejects_partially_unknown_platform() -> None:
env = await _actions("developer", "ux_ui").propose_video(
agent_id=uuid4(), **_valid_kwargs(platforms=["x", "instagram"])
)
assert env.error == "invalid_state"
# --------------------------------------------------------------------------- #
# authoring-task resolution — the caller's ACTIVE task, not an oldest-first scan
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_propose_video_no_active_task_is_invalid_state(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_mock_active_task(monkeypatch, None)
env = await _actions("developer", "ux_ui").propose_video(
agent_id=uuid4(), **_valid_kwargs()
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_video_rejects_when_active_task_not_video(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The dev's currently-active task is an ordinary code task, not a video
authoring task — refuse rather than write video metadata onto unrelated
work."""
agent_id = uuid4()
other = _FakeTask(assigned_to=agent_id, source="chore")
_mock_active_task(monkeypatch, other)
env = await _actions("developer", "ux_ui").propose_video(
agent_id=agent_id, **_valid_kwargs()
)
assert env.error == "invalid_state"
assert markers.get_video_draft(other) is None
@pytest.mark.asyncio
async def test_propose_video_rejects_when_active_task_is_held_post(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A held video_post draft is never a dev's authoring task (Secretary-owned,
source video_post) — refused by the source check."""
agent_id = uuid4()
held = _FakeTask(assigned_to=agent_id, source="video_post")
_mock_active_task(monkeypatch, held)
env = await _actions("developer", "ux_ui").propose_video(
agent_id=agent_id, **_valid_kwargs()
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_video_targets_active_task_not_an_older_one(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Regression: a UX/UI dev routinely holds more than one open video task
(finish A -> submit to QA -> claim B). propose_video must write to the task
the dev is ACTIVELY working on (via get_active_task_for_agent), never
silently onto an older open one — which would clobber an already-submitted
draft and report a false success."""
agent_id = uuid4()
older = _FakeTask(
assigned_to=agent_id,
draft={"occasion": "occ-a", "composition_id": "already-submitted"},
)
active = _FakeTask(assigned_to=agent_id, draft={"occasion": "occ-b"})
task_svc = _mock_active_task(monkeypatch, active)
env = await _actions("developer", "ux_ui").propose_video(
agent_id=agent_id, **_valid_kwargs(composition_id="occ-b-composition")
)
assert env.error is None
task_svc.get_active_task_for_agent.assert_awaited_once_with(agent_id)
assert env.task_id == str(active.id)
active_draft = markers.get_video_draft(active)
assert active_draft is not None
assert active_draft["composition_id"] == "occ-b-composition"
older_draft = markers.get_video_draft(older)
assert older_draft is not None
assert older_draft["composition_id"] == "already-submitted" # untouched
# --------------------------------------------------------------------------- #
# happy path — marker merge
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_propose_video_merges_onto_active_task_draft(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent_id = uuid4()
authoring = _FakeTask(
assigned_to=agent_id,
draft={"occasion": "release v1.0.0", "script": "script", "brief": "brief"},
)
_mock_active_task(monkeypatch, authoring)
env = await _actions("developer", "ux_ui").propose_video(
agent_id=agent_id, **_valid_kwargs(input_props={"title": "Launch"})
)
assert env.error is None
assert env.status == "video_proposed"
assert env.task_id == str(authoring.id)
draft = markers.get_video_draft(authoring)
assert draft is not None
# existing fields survive the merge
assert draft["occasion"] == "release v1.0.0"
assert draft["script"] == "script"
assert draft["brief"] == "brief"
# new fields land
assert draft["composition_id"] == "release-announcement-v1"
assert draft["input_props"] == {"title": "Launch"}
assert draft["x_caption"] == _valid_kwargs()["x_caption"]
assert draft["tiktok_caption"] == _valid_kwargs()["tiktok_caption"]
assert draft["platforms"] == ["x", "tiktok"]
@pytest.mark.asyncio
async def test_propose_video_defaults_input_props_to_empty_dict(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent_id = uuid4()
authoring = _FakeTask(assigned_to=agent_id)
_mock_active_task(monkeypatch, authoring)
env = await _actions("developer", "ux_ui").propose_video(
agent_id=agent_id, **_valid_kwargs()
)
assert env.error is None
draft = markers.get_video_draft(authoring)
assert draft is not None
assert draft["input_props"] == {}
@pytest.mark.asyncio
async def test_propose_video_rejects_unrenderable_composition_id() -> None:
"""An id the renderer's charset rule would 400 is refused at authoring
time, not at render time days later."""
env = await _actions("developer", "ux_ui").propose_video(
agent_id=uuid4(), **_valid_kwargs(composition_id="release 0.25.0!")
)
assert env.error == "invalid_state"
assert "not renderable" in (env.message or "")