Video pipeline fixes: visibility strip, rich briefs, spotlight timing + fps (#369)

* feat(video): pipeline visibility — strip, state-aware queue, render-error capture

Task 1 of the 2026-07-09 video-pipeline review. New CEO-gated GET
/video/pipeline lists every in-flight video item (authoring statuses,
rendering attempt n/max, terminal failures with the error — now stamped
onto the video_draft marker instead of dying as a log line).
source_task_id exposed on both video schemas. Panel: pipeline strip on
the Social page, state-aware queue empty copy, title/script on queue
rows, missing cuts disabled instead of a blank player, notifications
deep-link related_task_id. MAX_VIDEO_RENDER_ATTEMPTS moved to the
markers policy layer (single source of truth).

* feat(video): rich authoring briefs — changelog section, brand voice, kit pointer

Task 2 of the 2026-07-09 video-pipeline review. The release brief is
now a structured block (full CHANGELOG section capped at 4000 chars +
highlights) instead of one LLM-compressed sentence; brand_voice and a
motion/kit design-bar pointer are appended centrally in open_video_task
so release, spotlight, and on-demand paths all inherit them.
suggested_input_props seeded on the video_draft marker; third
acceptance criterion pins the design bar; propose_video docstring
points at the kit.

* fix(video): spotlight video drafts on CEO approval, renderer honors data-fps

Task 4 of the 2026-07-09 video-pipeline review. The companion-video
hook moves from propose_feature_spotlight (HoM authoring time) to
XPostService approve's posted-success branch for x_feature drafts,
mirroring the release-publish seam — a rejected spotlight no longer
burns a ux-dev cycle; wants_video/video_script ride the x_feature_ref
marker. Best-effort: a video-engine failure never breaks the post.
render.js reads data-fps from the composition HTML (clamped 24-60,
fallback 30) instead of hardcoding 30; parseFps covered by node --test.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-09 08:31:08 +02:00
committed by GitHub
co-authored by Renn F
parent 12b91d17a2
commit 18998c4a42
28 changed files with 1677 additions and 190 deletions
@@ -9,6 +9,7 @@ from uuid import uuid4
import pytest
from roboco.config import settings as cfg
from roboco.foundation.policy.content import markers
from roboco.services.gateway.content_actions import ContentActions, ContentActionsDeps
@@ -181,13 +182,20 @@ async def test_propose_feature_spotlight_materializes_new_draft_task(
# --------------------------------------------------------------------------- #
# wants_video companion — additive, default-False, best-effort
# wants_video companion — additive, default-False. Task 4 (2026-07-09 pipeline
# fixes) moved the actual video-authoring open OFF authoring time and onto
# XPostService.approve (see test_x_post_service.py), so this verb only stamps
# the request onto the draft's x_feature_ref marker and never touches the
# video engine itself, regardless of wants_video or the video flags.
# --------------------------------------------------------------------------- #
def _mock_spotlight_materialization(monkeypatch: pytest.MonkeyPatch) -> Any:
def _mock_spotlight_materialization(
monkeypatch: pytest.MonkeyPatch,
) -> tuple[Any, Any]:
"""Wire an open exploration + a materializing XEngine, mirroring the happy
path above, so wants_video tests only need to stub the video engine."""
path above, so wants_video tests only need to inspect the returned draft's
marker (or stub the video engine to prove it's never called)."""
agent_id = uuid4()
exploration = _FakeTask(assigned_to=agent_id)
task_svc = MagicMock()
@@ -199,57 +207,51 @@ def _mock_spotlight_materialization(monkeypatch: pytest.MonkeyPatch) -> Any:
x_engine.is_feature_seen = AsyncMock(return_value=False)
x_engine.materialize_feature_spotlight = AsyncMock(return_value=materialized)
monkeypatch.setattr("roboco.services.x_engine.get_x_engine", lambda _s: x_engine)
return agent_id
return agent_id, materialized
def _mock_video_engine(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
def _actions_with_flushable_session(role: str) -> ContentActions:
"""``_actions`` with ``task.session.flush`` made awaitable — needed once
``wants_video`` triggers the marker-write flush in
``propose_feature_spotlight`` (mirrors the same pattern in
test_content_actions_roadmap.py)."""
actions = _actions(role)
actions.task.session.flush = AsyncMock()
return actions
@pytest.mark.asyncio
async def test_propose_feature_spotlight_never_opens_video_task(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Even with both video flags on and wants_video=True, this verb never
opens a video-authoring task — that now happens at CEO-approve time."""
monkeypatch.setattr(cfg, "video_engine_enabled", True)
monkeypatch.setattr(cfg, "video_on_spotlight", True)
agent_id, _materialized = _mock_spotlight_materialization(monkeypatch)
video_engine = MagicMock()
video_engine.open_video_task = AsyncMock(return_value=MagicMock())
monkeypatch.setattr(
"roboco.services.video_engine.get_video_engine", lambda _s: video_engine
)
return video_engine
def _enable_video(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(cfg, "video_engine_enabled", True)
monkeypatch.setattr(cfg, "video_on_spotlight", True)
@pytest.mark.asyncio
async def test_propose_feature_spotlight_wants_video_opens_video_task(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_enable_video(monkeypatch)
agent_id = _mock_spotlight_materialization(monkeypatch)
video_engine = _mock_video_engine(monkeypatch)
env = await _actions("head_marketing").propose_feature_spotlight(
agent_id=agent_id, **_valid_kwargs(), wants_video=True
)
env = await _actions_with_flushable_session(
"head_marketing"
).propose_feature_spotlight(agent_id=agent_id, **_valid_kwargs(), wants_video=True)
assert env.error is None
video_engine.open_video_task.assert_awaited_once()
kwargs = video_engine.open_video_task.call_args.kwargs
assert kwargs["occasion"] == "spotlight org-memory"
assert kwargs["platforms"] == ["x", "tiktok"]
expected_brief = (
"Organizational Memory Loop: Did you know RoboCo agents learn from "
"every completed task?"
)
assert kwargs["brief"] == expected_brief
assert kwargs["script"] == expected_brief # falls back — no video_script given
video_engine.open_video_task.assert_not_called()
@pytest.mark.asyncio
async def test_propose_feature_spotlight_wants_video_uses_explicit_script(
async def test_propose_feature_spotlight_wants_video_stamps_marker(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_enable_video(monkeypatch)
agent_id = _mock_spotlight_materialization(monkeypatch)
video_engine = _mock_video_engine(monkeypatch)
agent_id, materialized = _mock_spotlight_materialization(monkeypatch)
env = await _actions("head_marketing").propose_feature_spotlight(
env = await _actions_with_flushable_session(
"head_marketing"
).propose_feature_spotlight(
agent_id=agent_id,
**_valid_kwargs(),
wants_video=True,
@@ -257,63 +259,44 @@ async def test_propose_feature_spotlight_wants_video_uses_explicit_script(
)
assert env.error is None
kwargs = video_engine.open_video_task.call_args.kwargs
assert kwargs["script"] == "Custom voiceover script"
assert kwargs["brief"] != "Custom voiceover script" # brief is always title:body
ref = markers.get_x_feature_ref(materialized)
assert ref is not None
assert ref["slug"] == "org-memory"
assert ref["title"] == "Organizational Memory Loop"
assert ref["wants_video"] is True
assert ref["video_script"] == "Custom voiceover script"
@pytest.mark.asyncio
async def test_propose_feature_spotlight_default_wants_video_false_skips_video(
async def test_propose_feature_spotlight_wants_video_without_script_stores_empty(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Default False -> byte-for-byte unchanged spotlight behavior: the video
engine is never even looked up."""
_enable_video(monkeypatch)
agent_id = _mock_spotlight_materialization(monkeypatch)
video_engine = _mock_video_engine(monkeypatch)
"""No explicit script -> stored as "" (the fallback-to-brief logic lives
in XPostService._open_spotlight_video at approve time, not here)."""
agent_id, materialized = _mock_spotlight_materialization(monkeypatch)
env = await _actions("head_marketing").propose_feature_spotlight(
agent_id=agent_id, **_valid_kwargs()
)
env = await _actions_with_flushable_session(
"head_marketing"
).propose_feature_spotlight(agent_id=agent_id, **_valid_kwargs(), wants_video=True)
assert env.error is None
assert env.status == "feature_spotlight_proposed"
video_engine.open_video_task.assert_not_called()
ref = markers.get_x_feature_ref(materialized)
assert ref is not None
assert ref["video_script"] == ""
@pytest.mark.asyncio
async def test_propose_feature_spotlight_wants_video_but_flags_off_skips_video(
async def test_propose_feature_spotlight_default_wants_video_false_leaves_marker(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "video_engine_enabled", False)
monkeypatch.setattr(cfg, "video_on_spotlight", False)
agent_id = _mock_spotlight_materialization(monkeypatch)
video_engine = _mock_video_engine(monkeypatch)
"""Default False -> byte-for-byte unchanged: this verb never re-touches
the x_feature_ref marker at all."""
agent_id, materialized = _mock_spotlight_materialization(monkeypatch)
env = await _actions("head_marketing").propose_feature_spotlight(
agent_id=agent_id, **_valid_kwargs(), wants_video=True
)
assert env.error is None
video_engine.open_video_task.assert_not_called()
@pytest.mark.asyncio
async def test_propose_feature_spotlight_video_failure_does_not_break_spotlight(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Best-effort: a video-engine blow-up must not surface as an error on the
spotlight verb — the spotlight draft already materialized."""
_enable_video(monkeypatch)
agent_id = _mock_spotlight_materialization(monkeypatch)
monkeypatch.setattr(
"roboco.services.video_engine.get_video_engine",
MagicMock(side_effect=RuntimeError("video-engine boom")),
)
env = await _actions("head_marketing").propose_feature_spotlight(
agent_id=agent_id, **_valid_kwargs(), wants_video=True
)
env = await _actions_with_flushable_session(
"head_marketing"
).propose_feature_spotlight(agent_id=agent_id, **_valid_kwargs())
assert env.error is None
assert env.status == "feature_spotlight_proposed"
assert markers.get_x_feature_ref(materialized) is None
+53
View File
@@ -287,6 +287,59 @@ async def test_list_awaiting_main_pm_all_returns_root_tasks() -> None:
assert out == roots
# ---------------------------------------------------------------------------
# list_video_pipeline_tasks — pipeline-strip basis (task 1, 2026-07-09)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_list_video_pipeline_tasks_keeps_non_terminal_and_unrendered() -> None:
"""The query itself only excludes CANCELLED at the SQL level; the
COMPLETED-but-rendered drop happens in Python off the marker, so this
exercises that filter directly against a mixed mocked result set."""
in_progress = _build_task(status=TaskStatus.IN_PROGRESS, orchestration_markers=None)
awaiting_ceo = _build_task(
status=TaskStatus.AWAITING_CEO_APPROVAL, orchestration_markers=None
)
rendered = _build_task(
status=TaskStatus.COMPLETED,
orchestration_markers={"video_draft": {"render_status": "rendered"}},
)
pending_render = _build_task(
status=TaskStatus.COMPLETED,
orchestration_markers={"video_draft": {"render_attempts": 2}},
)
failed_render = _build_task(
status=TaskStatus.COMPLETED,
orchestration_markers={
"video_draft": {"render_status": "failed", "render_error": "boom"}
},
)
scalars = MagicMock()
scalars.all.return_value = [
in_progress,
awaiting_ceo,
rendered,
pending_render,
failed_render,
]
result = MagicMock()
result.scalars.return_value = scalars
svc = _service_with(result)
out = await svc.list_video_pipeline_tasks()
assert out == [in_progress, awaiting_ceo, pending_render, failed_render]
@pytest.mark.asyncio
async def test_list_video_pipeline_tasks_empty_when_nothing_in_flight() -> None:
scalars = MagicMock()
scalars.all.return_value = []
result = MagicMock()
result.scalars.return_value = scalars
svc = _service_with(result)
assert await svc.list_video_pipeline_tasks() == []
# ---------------------------------------------------------------------------
# all_subtasks_terminal
# ---------------------------------------------------------------------------
+166 -2
View File
@@ -19,6 +19,7 @@ from roboco.foundation.policy.content import markers
from roboco.models.base import AgentRole, AgentStatus, Complexity, Team
from roboco.models.base import TaskStatus as TS
from roboco.services import video_engine as video_engine_module
from roboco.services.company_goals import get_company_goals_service
from roboco.services.task import VIDEO_POST_SOURCE, VIDEO_SOURCE, get_task_service
from sqlalchemy import delete, select
@@ -32,6 +33,7 @@ UX_DEV_2_UUID = _foundation.AGENTS["ux-dev-2"].uuid
SLUG = "roboco"
ONE = 1
TWO = 2
THREE = 3
async def _seed(session: AsyncSession) -> None:
@@ -134,6 +136,10 @@ async def test_open_video_task_creates_assigned_authoring_task(
# would auto-block for subtasks it never owns and deadlock.
assert task.estimated_complexity == Complexity.LOW
assert task.acceptance_criteria # non-empty
# Third AC line: composition follows the design bar / demo-kit register.
assert len(task.acceptance_criteria) == THREE
assert "motion/README.md" in task.acceptance_criteria[2]
assert "panel-demo" in task.acceptance_criteria[2]
project = await db_session.get(ProjectTable, task.project_id)
assert project is not None
assert project.slug == SLUG
@@ -142,7 +148,14 @@ async def test_open_video_task_creates_assigned_authoring_task(
assert draft["occasion"] == "release v1.0.0"
assert draft["script"] == "Here's what shipped"
assert draft["platforms"] == ["x", "tiktok"]
assert draft["brief"] == "Announce the release"
# brief is enriched: raw content + motion design-bar pointer appended.
assert draft["brief"].startswith("Announce the release")
assert "motion/README.md" in draft["brief"]
assert "motion/kit/README.md" in draft["brief"]
assert "compositions/panel-demo/" in draft["brief"]
assert "Brand voice" not in draft["brief"] # unset -> omitted
assert draft["suggested_input_props"] == {} # none supplied
assert task.description == draft["brief"]
@pytest.mark.asyncio
@@ -433,7 +446,16 @@ async def test_draft_release_video_opens_authoring_task(
assert draft["occasion"] == "release 1.0.0"
assert draft["platforms"] == ["x", "tiktok"]
assert draft["script"] == "RoboCo v1.0.0 just shipped a huge release."
assert draft["brief"] == draft["script"]
# brief is now the structured changelog block, not the LLM one-liner —
# the whole CHANGELOG section (not one bullet) plus a highlights list.
assert draft["brief"] != draft["script"]
assert _CHANGELOG.strip() in draft["brief"]
assert "a huge new release" in draft["brief"]
assert "motion/README.md" in draft["brief"]
assert draft["suggested_input_props"] == {
"version": "1.0.0",
"highlights": ["a huge new release"],
}
@pytest.mark.asyncio
@@ -451,6 +473,10 @@ async def test_draft_release_video_falls_back_to_template_on_local_model_failure
assert draft is not None
assert "1.0.0" in draft["script"]
assert "a huge new release" in draft["script"]
# The structured brief is built independent of the local model, so a
# model outage still produces the full changelog-derived brief.
assert _CHANGELOG.strip() in draft["brief"]
assert draft["suggested_input_props"]["highlights"] == ["a huge new release"]
@pytest.mark.asyncio
@@ -466,6 +492,144 @@ async def test_draft_release_video_falls_back_to_template_on_empty_local_reply(
draft = markers.get_video_draft(task)
assert draft is not None
assert draft["script"] == "RoboCo v2.0.0 just shipped: no bullets."
assert "- no bullets" in draft["brief"]
assert draft["suggested_input_props"] == {
"version": "2.0.0",
"highlights": ["no bullets"],
}
@pytest.mark.asyncio
async def test_draft_release_video_brief_contains_brand_voice_when_set(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch, video_on_release=True)
await get_company_goals_service(db_session).upsert(
{"brand_voice": "Dry wit, never an exclamation point."}
)
_mock_local_model(monkeypatch, "shipped!")
engine = video_engine_module.VideoEngine(db_session)
task = await engine.draft_release_video(version="1.0.0", changelog=_CHANGELOG)
assert task is not None
draft = markers.get_video_draft(task)
assert draft is not None
assert "Dry wit, never an exclamation point." in draft["brief"]
# --------------------------------------------------------------------------- #
# brief enrichment shared by every occasion — brand voice + motion pointer
# (spec Task 2: "Feed the video brief")
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_open_video_task_brief_carries_motion_pointer_no_brand_voice_by_default(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Simulates the on-demand (`POST /video/request`) caller, which passes
its brief through verbatim enrichment must land centrally here since
that route can't append it itself."""
await _seed(db_session)
_enable(monkeypatch)
engine = video_engine_module.VideoEngine(db_session)
task = await engine.open_video_task(
occasion="on-demand demo",
script="script",
platforms=["x"],
brief="CEO's on-demand brief, verbatim.",
)
assert task is not None
draft = markers.get_video_draft(task)
assert draft is not None
assert draft["brief"].startswith("CEO's on-demand brief, verbatim.")
assert "motion/README.md" in draft["brief"]
assert "motion/kit/README.md" in draft["brief"]
assert "compositions/panel-demo/" in draft["brief"]
assert "Brand voice" not in draft["brief"] # unset -> omitted
assert task.description == draft["brief"]
@pytest.mark.asyncio
async def test_open_video_task_brief_includes_brand_voice_when_set(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch)
await get_company_goals_service(db_session).upsert(
{"brand_voice": "Dry wit, never an exclamation point."}
)
engine = video_engine_module.VideoEngine(db_session)
task = await engine.open_video_task(
occasion="on-demand demo 2",
script="script",
platforms=["x"],
brief="CEO's on-demand brief.",
)
assert task is not None
draft = markers.get_video_draft(task)
assert draft is not None
assert "Dry wit, never an exclamation point." in draft["brief"]
@pytest.mark.asyncio
async def test_open_video_task_does_not_compress_spotlight_brief(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Mirrors content_actions._open_spotlight_video's `f"{title}: {body}"`
shape the spotlight body is already rich; enrichment only appends,
never truncates or rewrites the original content."""
await _seed(db_session)
_enable(monkeypatch)
engine = video_engine_module.VideoEngine(db_session)
feature_brief = (
"Organizational Memory Loop: Did you know RoboCo agents learn from "
"every completed task? A local model distills one high-signal lesson "
"per task, retrieved back into future briefings."
)
task = await engine.open_video_task(
occasion="spotlight org-memory",
script=feature_brief,
platforms=["x", "tiktok"],
brief=feature_brief,
)
assert task is not None
draft = markers.get_video_draft(task)
assert draft is not None
assert draft["brief"].startswith(feature_brief) # not truncated/compressed
assert "motion/README.md" in draft["brief"]
@pytest.mark.asyncio
async def test_open_video_task_no_suggested_input_props_defaults_empty(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch)
engine = video_engine_module.VideoEngine(db_session)
task = await engine.open_video_task(
occasion="spotlight x", script="s", platforms=["x"], brief="b"
)
assert task is not None
draft = markers.get_video_draft(task)
assert draft is not None
assert draft["suggested_input_props"] == {}
@pytest.mark.asyncio
async def test_open_video_task_acceptance_criteria_has_design_bar_line(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch)
engine = video_engine_module.VideoEngine(db_session)
task = await engine.open_video_task(
occasion="release v9.9.9", script="s", platforms=["x"], brief="b"
)
assert task is not None
assert len(task.acceptance_criteria) == THREE
assert "motion/README.md" in task.acceptance_criteria[2]
assert "panel-demo" in task.acceptance_criteria[2]
@pytest.mark.asyncio
+256 -2
View File
@@ -12,6 +12,7 @@ from unittest.mock import AsyncMock, patch
from uuid import uuid4
import pytest
from roboco.config import settings as cfg
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
from roboco.foundation import identity as _foundation
from roboco.foundation.policy.content import markers
@@ -126,6 +127,40 @@ async def _seed_draft(
return task
_FEATURE_SLUG = "org-memory"
_FEATURE_TITLE = "Organizational Memory Loop"
async def _seed_feature_draft(
session: AsyncSession,
*,
wants_video: bool = True,
video_script: str = "",
body: str = "Draft body",
) -> TaskTable:
"""An X_FEATURE_SOURCE draft carrying the x_feature_ref marker
``propose_feature_spotlight`` stamps (Task 4, 2026-07-09 pipeline fixes):
slug/title always, plus wants_video/video_script when a companion video
was requested at authoring time."""
task = await _seed_draft(session, source=X_FEATURE_SOURCE, body=body)
markers.set_x_feature_ref(
task,
{
"slug": _FEATURE_SLUG,
"title": _FEATURE_TITLE,
"wants_video": wants_video,
"video_script": video_script,
},
)
await session.flush()
return task
def _enable_video(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(cfg, "video_engine_enabled", True)
monkeypatch.setattr(cfg, "video_on_spotlight", True)
def _svc(session: AsyncSession) -> XPostService:
return get_x_post_service(session)
@@ -324,8 +359,9 @@ async def test_list_open_posts_excludes_terminal(db_session: AsyncSession) -> No
async def test_approve_posts_feature_spotlight_draft(
db_session: AsyncSession,
) -> None:
"""The feature-spotlight source needs zero service changes: it rides the
same generic approve path as x_post/x_reply."""
"""The feature-spotlight source rides the same generic post path as
x_post/x_reply; it only branches for the best-effort video hook below
(a no-op here since this draft carries no x_feature_ref marker)."""
task = await _seed_draft(db_session, source=X_FEATURE_SOURCE)
client = _StubClient()
with (
@@ -463,3 +499,221 @@ async def test_approve_does_not_flush_edited_body_before_lock(
assert result.status == "already_posted"
await db_session.refresh(task)
assert markers.get_x_draft_body(task) == original_body
# --------------------------------------------------------------------------- #
# Spotlight video hook (Task 4, 2026-07-09 pipeline fixes): moved from
# authoring time (propose_feature_spotlight) to this posted-success branch so
# a ux-dev never burns a cycle on a spotlight the CEO then rejects.
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_approve_feature_spotlight_with_video_opens_video_task(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
_enable_video(monkeypatch)
task = await _seed_feature_draft(db_session, video_script="Custom voiceover script")
client = _StubClient()
video_engine = AsyncMock()
video_engine.open_video_task = AsyncMock(return_value=None)
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
patch(
"roboco.services.video_engine.get_video_engine",
return_value=video_engine,
),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "posted"
video_engine.open_video_task.assert_awaited_once()
kwargs = video_engine.open_video_task.call_args.kwargs
assert kwargs["occasion"] == "spotlight org-memory"
assert kwargs["platforms"] == ["x", "tiktok"]
assert kwargs["script"] == "Custom voiceover script"
assert kwargs["brief"] == "Organizational Memory Loop: Draft body"
@pytest.mark.asyncio
async def test_approve_feature_spotlight_video_falls_back_to_brief_script(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""No explicit video_script -> script falls back to the brief, mirroring
the fallback the authoring-time hook used to do."""
_enable_video(monkeypatch)
task = await _seed_feature_draft(db_session)
client = _StubClient()
video_engine = AsyncMock()
video_engine.open_video_task = AsyncMock(return_value=None)
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
patch(
"roboco.services.video_engine.get_video_engine",
return_value=video_engine,
),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "posted"
kwargs = video_engine.open_video_task.call_args.kwargs
expected_brief = "Organizational Memory Loop: Draft body"
assert kwargs["script"] == expected_brief
assert kwargs["brief"] == expected_brief
@pytest.mark.asyncio
async def test_approve_feature_spotlight_reapprove_does_not_reopen_video(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Idempotent re-approve: the second call short-circuits on the already-
COMPLETED check before ever reaching _post/_open_spotlight_video again."""
_enable_video(monkeypatch)
task = await _seed_feature_draft(db_session)
client = _StubClient()
video_engine = AsyncMock()
video_engine.open_video_task = AsyncMock(return_value=None)
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
patch(
"roboco.services.video_engine.get_video_engine",
return_value=video_engine,
),
):
svc = _svc(db_session)
first = await svc.approve(_id(task))
second = await svc.approve(_id(task))
assert first is not None
assert first.status == "posted"
assert second is not None
assert second.status == "already_posted"
video_engine.open_video_task.assert_awaited_once()
@pytest.mark.asyncio
async def test_approve_plain_x_post_never_opens_video(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A plain x_post draft carries no x_feature_ref, so the source check
alone keeps the video hook from ever firing for it."""
_enable_video(monkeypatch)
task = await _seed_draft(db_session, source=X_POST_SOURCE)
client = _StubClient()
video_engine = AsyncMock()
video_engine.open_video_task = AsyncMock(return_value=None)
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
patch(
"roboco.services.video_engine.get_video_engine",
return_value=video_engine,
),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "posted"
video_engine.open_video_task.assert_not_called()
@pytest.mark.asyncio
async def test_reject_feature_spotlight_with_wants_video_opens_none(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Rejecting a spotlight draft never posts, so the video hook (which only
fires from the posted-success branch of _post) never runs either."""
_enable_video(monkeypatch)
task = await _seed_feature_draft(db_session)
video_engine = AsyncMock()
video_engine.open_video_task = AsyncMock(return_value=None)
with patch(
"roboco.services.video_engine.get_video_engine",
return_value=video_engine,
):
updated = await _svc(db_session).reject(_id(task), "not on-brand")
assert updated is not None
assert updated.status == TS.CANCELLED
video_engine.open_video_task.assert_not_called()
@pytest.mark.asyncio
async def test_approve_feature_spotlight_video_flags_off_skips(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(cfg, "video_engine_enabled", False)
monkeypatch.setattr(cfg, "video_on_spotlight", False)
task = await _seed_feature_draft(db_session)
client = _StubClient()
video_engine = AsyncMock()
video_engine.open_video_task = AsyncMock(return_value=None)
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
patch(
"roboco.services.video_engine.get_video_engine",
return_value=video_engine,
),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "posted"
video_engine.open_video_task.assert_not_called()
@pytest.mark.asyncio
async def test_approve_feature_spotlight_without_wants_video_skips(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Flags on but the draft's author didn't request a video (wants_video
absent/False on the marker) -> no video task, distinct from the
flags-off case above."""
_enable_video(monkeypatch)
task = await _seed_feature_draft(db_session, wants_video=False)
client = _StubClient()
video_engine = AsyncMock()
video_engine.open_video_task = AsyncMock(return_value=None)
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
patch(
"roboco.services.video_engine.get_video_engine",
return_value=video_engine,
),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "posted"
video_engine.open_video_task.assert_not_called()
@pytest.mark.asyncio
async def test_approve_feature_spotlight_video_failure_does_not_break_post(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Best-effort: a video-engine blow-up must not affect the already-
succeeded post."""
_enable_video(monkeypatch)
task = await _seed_feature_draft(db_session)
client = _StubClient()
with (
patch("roboco.services.x_post_service.build_x_client", return_value=client),
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
patch(
"roboco.services.video_engine.get_video_engine",
side_effect=RuntimeError("video-engine boom"),
),
):
result = await _svc(db_session).approve(_id(task))
assert result is not None
assert result.status == "posted"
await db_session.refresh(task)
assert task.status == TS.COMPLETED