Files
roboco/tests/unit/services/test_video_engine.py
T

485 lines
18 KiB
Python
Raw Normal View History

"""VideoEngine coverage: authoring-task origination + held-draft materialization.
Mirrors the X-engine tests: flag-gated, dedup, rolling open-cap, and a
deterministic ux-dev balance. The authoring task (source=video) is a normal
ASSIGNED delivery task — never held; the post draft (source=video_post) is
Secretary-owned and held for the CEO. Asserted against a real Postgres DB.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock
import pytest
from roboco.config import settings as cfg
from roboco.db.tables import AgentTable, ProjectTable
from roboco.foundation import identity as _foundation
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.task import VIDEO_POST_SOURCE, VIDEO_SOURCE, get_task_service
from sqlalchemy import delete, select
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
SECRETARY_UUID = _foundation.AGENTS["secretary-1"].uuid
UX_DEV_1_UUID = _foundation.AGENTS["ux-dev-1"].uuid
UX_DEV_2_UUID = _foundation.AGENTS["ux-dev-2"].uuid
SLUG = "roboco"
ONE = 1
TWO = 2
async def _seed(session: AsyncSession) -> None:
for uuid, slug, role, team in (
(SYSTEM_UUID, "system", AgentRole.SYSTEM, None),
(SECRETARY_UUID, "secretary-1", AgentRole.SECRETARY, None),
(UX_DEV_1_UUID, "ux-dev-1", AgentRole.DEVELOPER, Team.UX_UI),
(UX_DEV_2_UUID, "ux-dev-2", AgentRole.DEVELOPER, Team.UX_UI),
):
if await session.get(AgentTable, uuid) is None:
session.add(
AgentTable(
id=uuid,
name=slug,
slug=slug,
role=role,
team=team,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
)
await session.flush()
existing = await session.execute(
select(ProjectTable).where(ProjectTable.slug == SLUG)
)
if existing.scalar_one_or_none() is None:
session.add(
ProjectTable(
name="RoboCo",
slug=SLUG,
git_url="https://github.com/x/roboco.git",
default_branch="master",
protected_branches=["master"],
assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID,
is_active=True,
video_engine_enabled=True,
)
)
await session.flush()
def _enable(monkeypatch: pytest.MonkeyPatch, **overrides: object) -> None:
monkeypatch.setattr(cfg, "video_engine_enabled", True)
monkeypatch.setattr(cfg, "self_heal_project_slug", SLUG)
monkeypatch.setattr(cfg, "video_max_open_posts", 5)
for key, value in overrides.items():
monkeypatch.setattr(cfg, key, value)
def _mock_local_model(monkeypatch: pytest.MonkeyPatch, reply: str | None) -> AsyncMock:
mock = AsyncMock(return_value=reply)
monkeypatch.setattr(video_engine_module, "_chat", mock)
return mock
# --------------------------------------------------------------------------- #
# open_video_task
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_disabled_opens_no_video_task(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
monkeypatch.setattr(cfg, "video_engine_enabled", False)
engine = video_engine_module.VideoEngine(db_session)
task = await engine.open_video_task(
occasion="release v1.0.0", script="script", platforms=["x"], brief="brief"
)
assert task is None
assert await get_task_service(db_session).list_open_video_posts() == []
@pytest.mark.asyncio
async def test_open_video_task_creates_assigned_authoring_task(
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 v1.0.0",
script="Here's what shipped",
platforms=["x", "tiktok"],
brief="Announce the release",
)
assert task is not None
assert task.team == Team.UX_UI
assert task.assigned_to == UX_DEV_1_UUID # deterministic first pick
assert task.source == VIDEO_SOURCE
assert task.status == TS.PENDING
assert task.confirmed_by_human is True # normal delivery task, not CEO-held
# LOW so it clears _check_dev_needs_subtasks; a medium/high root dev task
# would auto-block for subtasks it never owns and deadlock.
assert task.estimated_complexity == Complexity.LOW
assert task.acceptance_criteria # non-empty
project = await db_session.get(ProjectTable, task.project_id)
assert project is not None
assert project.slug == SLUG
draft = markers.get_video_draft(task)
assert draft is not None
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"
@pytest.mark.asyncio
async def test_open_video_task_balances_across_ux_devs(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch)
engine = video_engine_module.VideoEngine(db_session)
first = await engine.open_video_task(
occasion="release v1.0.0", script="s", platforms=["x"], brief="b"
)
second = await engine.open_video_task(
occasion="release v2.0.0", script="s", platforms=["x"], brief="b"
)
assert first is not None
assert second is not None
assert first.assigned_to == UX_DEV_1_UUID
assert second.assigned_to == UX_DEV_2_UUID # balanced onto the less-loaded dev
@pytest.mark.asyncio
async def test_open_video_task_dedupes_same_occasion(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch)
engine = video_engine_module.VideoEngine(db_session)
await engine.open_video_task(
occasion="release v1.0.0", script="s", platforms=["x"], brief="b"
)
second = await engine.open_video_task(
occasion="release v1.0.0", script="s2", platforms=["x"], brief="b2"
)
assert second is None
open_tasks = await get_task_service(db_session).list_open_video_posts()
assert len(open_tasks) == ONE
@pytest.mark.asyncio
async def test_open_video_task_respects_open_cap(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch, video_max_open_posts=1)
engine = video_engine_module.VideoEngine(db_session)
await engine.open_video_task(
occasion="release v1.0.0", script="s", platforms=["x"], brief="b"
)
second = await engine.open_video_task(
occasion="release v2.0.0", script="s", platforms=["x"], brief="b"
)
assert second is None
open_tasks = await get_task_service(db_session).list_open_video_posts()
assert len(open_tasks) == ONE
@pytest.mark.asyncio
async def test_open_video_task_unresolvable_project_opens_nothing(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch)
monkeypatch.setattr(cfg, "self_heal_project_slug", "no-such-project")
engine = video_engine_module.VideoEngine(db_session)
task = await engine.open_video_task(
occasion="release v1.0.0", script="s", platforms=["x"], brief="b"
)
assert task is None
assert await get_task_service(db_session).list_open_video_posts() == []
@pytest.mark.asyncio
async def test_open_video_task_no_op_when_project_not_opted_in(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
# Flip the per-project opt-in back off — the global flag stays on.
project = (
await db_session.execute(select(ProjectTable).where(ProjectTable.slug == SLUG))
).scalar_one()
project.video_engine_enabled = False
await db_session.flush()
_enable(monkeypatch)
engine = video_engine_module.VideoEngine(db_session)
task = await engine.open_video_task(
occasion="release v1.0.0", script="s", platforms=["x"], brief="b"
)
assert task is None
assert await get_task_service(db_session).list_open_video_posts() == []
@pytest.mark.asyncio
async def test_open_video_task_insert_error_returns_none_session_usable(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""F042 guard: a real DBAPI error on the authoring insert (assignee FK
absent) rolls back ONLY the savepoint and returns None; the shared session
stays usable, so a caller's later commit is not poisoned."""
if await db_session.get(AgentTable, SYSTEM_UUID) is None:
db_session.add(
AgentTable(
id=SYSTEM_UUID,
name="system",
slug="system",
role=AgentRole.SYSTEM,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
)
# ux-devs deliberately absent -> the assigned_to FK violates at flush.
await db_session.execute(
delete(AgentTable).where(AgentTable.id.in_([UX_DEV_1_UUID, UX_DEV_2_UUID]))
)
has_project = (
await db_session.execute(select(ProjectTable).where(ProjectTable.slug == SLUG))
).scalar_one_or_none()
if has_project is None:
db_session.add(
ProjectTable(
name="RoboCo",
slug=SLUG,
git_url="https://github.com/x/roboco.git",
default_branch="master",
protected_branches=["master"],
assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID,
is_active=True,
video_engine_enabled=True,
)
)
await db_session.flush()
_enable(monkeypatch)
engine = video_engine_module.VideoEngine(db_session)
task = await engine.open_video_task(
occasion="fk-fail", script="s", platforms=["x"], brief="b"
)
assert task is None
# Not poisoned: a follow-up statement runs cleanly (this raised
# PendingRollbackError before the savepoint fix).
check = await db_session.execute(
select(ProjectTable).where(ProjectTable.slug == SLUG)
)
assert check.scalar_one_or_none() is not None
# --------------------------------------------------------------------------- #
# _originate_video_post
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_originate_video_post_holds_draft_for_secretary(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch)
engine = video_engine_module.VideoEngine(db_session)
source_task = await engine.open_video_task(
occasion="release v1.0.0",
script="Here's what shipped",
platforms=["x", "tiktok"],
brief="Announce the release",
)
assert source_task is not None
draft_task = await engine._originate_video_post(
source_task=source_task,
mp4_paths={
"vertical": "/render/out/a-vertical.mp4",
"square": "/render/out/a-square.mp4",
},
captions={
"x": "We just shipped v1.0.0!",
"tiktok": "New release, check it out",
},
platforms=["x", "tiktok"],
)
assert draft_task.team == Team.MAIN_PM
assert draft_task.assigned_to == SECRETARY_UUID
assert draft_task.source == VIDEO_POST_SOURCE
assert draft_task.status == TS.PENDING
assert draft_task.confirmed_by_human is False # HELD; never dispatched
draft = markers.get_video_draft(draft_task)
assert draft is not None
assert draft["occasion"] == "release v1.0.0" # carried forward from the source
assert draft["script"] == "Here's what shipped"
assert draft["mp4_paths"] == {
"vertical": "/render/out/a-vertical.mp4",
"square": "/render/out/a-square.mp4",
}
assert draft["x_caption"] == "We just shipped v1.0.0!"
assert draft["tiktok_caption"] == "New release, check it out"
assert draft["platforms"] == ["x", "tiktok"]
assert draft["render_status"] == "rendered"
assert draft["source_task_id"] == str(source_task.id) # traceability back-ref
@pytest.mark.asyncio
async def test_originate_video_post_not_counted_by_dedupe_against_new_occasion(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A materialized post draft still occupies the shared open-cap/dedupe
pool (list_open_video_posts spans both sources) — a fresh occasion is
unaffected, but the cap still counts it."""
await _seed(db_session)
_enable(monkeypatch, video_max_open_posts=2)
engine = video_engine_module.VideoEngine(db_session)
source_task = await engine.open_video_task(
occasion="release v1.0.0", script="s", platforms=["x"], brief="b"
)
assert source_task is not None
source_task.status = TS.COMPLETED
await db_session.flush()
await engine._originate_video_post(
source_task=source_task,
mp4_paths={"vertical": "a.mp4", "square": "b.mp4"},
captions={"x": "caption"},
platforms=["x"],
)
# One open (the held draft; the source authoring task is now COMPLETED and
# therefore excluded) plus room for exactly one more before the cap bites.
open_tasks = await get_task_service(db_session).list_open_video_posts()
assert len(open_tasks) == ONE
second = await engine.open_video_task(
occasion="release v2.0.0", script="s", platforms=["x"], brief="b"
)
assert second is not None
open_tasks = await get_task_service(db_session).list_open_video_posts()
assert len(open_tasks) == TWO
# --------------------------------------------------------------------------- #
# draft_release_video
# --------------------------------------------------------------------------- #
_CHANGELOG = "## [1.0.0]\n\n### Added\n- a huge new release\n"
@pytest.mark.asyncio
async def test_draft_release_video_disabled_opens_nothing(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch, video_on_release=True)
monkeypatch.setattr(cfg, "video_engine_enabled", False)
engine = video_engine_module.VideoEngine(db_session)
task = await engine.draft_release_video(version="1.0.0", changelog=_CHANGELOG)
assert task is None
assert await get_task_service(db_session).list_open_video_posts() == []
@pytest.mark.asyncio
async def test_draft_release_video_sub_switch_off_opens_nothing(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch, video_on_release=False)
engine = video_engine_module.VideoEngine(db_session)
task = await engine.draft_release_video(version="1.0.0", changelog=_CHANGELOG)
assert task is None
assert await get_task_service(db_session).list_open_video_posts() == []
@pytest.mark.asyncio
async def test_draft_release_video_opens_authoring_task(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch, video_on_release=True)
_mock_local_model(monkeypatch, "RoboCo v1.0.0 just shipped a huge release.")
engine = video_engine_module.VideoEngine(db_session)
task = await engine.draft_release_video(version="1.0.0", changelog=_CHANGELOG)
assert task is not None
assert task.source == VIDEO_SOURCE
assert task.team == Team.UX_UI
assert task.confirmed_by_human is True
draft = markers.get_video_draft(task)
assert draft is not None
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"]
@pytest.mark.asyncio
async def test_draft_release_video_falls_back_to_template_on_local_model_failure(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch, video_on_release=True)
mock = AsyncMock(side_effect=RuntimeError("ollama down"))
monkeypatch.setattr(video_engine_module, "_chat", mock)
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 "1.0.0" in draft["script"]
assert "a huge new release" in draft["script"]
@pytest.mark.asyncio
async def test_draft_release_video_falls_back_to_template_on_empty_local_reply(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch, video_on_release=True)
_mock_local_model(monkeypatch, None) # non-success / empty local-model reply
engine = video_engine_module.VideoEngine(db_session)
task = await engine.draft_release_video(version="2.0.0", changelog="- no bullets")
assert task is not None
draft = markers.get_video_draft(task)
assert draft is not None
assert draft["script"] == "RoboCo v2.0.0 just shipped: no bullets."
@pytest.mark.asyncio
async def test_draft_release_video_dedupes_same_version(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch, video_on_release=True)
_mock_local_model(monkeypatch, "shipped!")
engine = video_engine_module.VideoEngine(db_session)
first = await engine.draft_release_video(version="1.0.0", changelog=_CHANGELOG)
second = await engine.draft_release_video(version="1.0.0", changelog=_CHANGELOG)
assert first is not None
assert second is None
open_tasks = await get_task_service(db_session).list_open_video_posts()
assert len(open_tasks) == ONE