feat: professionalize control plane and standalone delivery

This commit is contained in:
theshy
2026-04-07 10:46:30 +08:00
parent d0cf1fd0df
commit 862db502b0
100 changed files with 8313 additions and 1483 deletions

View File

@ -0,0 +1,149 @@
from __future__ import annotations
import tempfile
import unittest
from http import HTTPStatus
from pathlib import Path
from types import SimpleNamespace
from biliup_next.app.control_plane_get_dispatcher import ControlPlaneGetDispatcher
from biliup_next.core.models import ActionRecord, Task, TaskContext
class FakeRepo:
def __init__(self, task: Task, context: TaskContext | None = None, actions: list[ActionRecord] | None = None) -> None:
self.task = task
self.context = context
self.actions = actions or []
def query_tasks(self, **kwargs): # type: ignore[no-untyped-def]
return [self.task], 1
def get_task(self, task_id: str) -> Task | None:
return self.task if task_id == self.task.id else None
def get_task_context(self, task_id: str) -> TaskContext | None:
return self.context if self.context and self.context.task_id == task_id else None
def list_task_contexts_for_task_ids(self, task_ids: list[str]) -> dict[str, TaskContext]:
if self.context and self.context.task_id in task_ids:
return {self.context.task_id: self.context}
return {}
def list_steps_for_task_ids(self, task_ids: list[str]) -> dict[str, list[object]]:
return {self.task.id: []} if self.task.id in task_ids else {}
def list_task_contexts_by_session_key(self, session_key: str) -> list[TaskContext]:
if self.context and self.context.session_key == session_key:
return [self.context]
return []
def list_steps(self, task_id: str) -> list[object]:
return []
def list_artifacts(self, task_id: str) -> list[object]:
return []
def list_action_records(
self,
task_id: str | None = None,
limit: int = 200,
action_name: str | None = None,
status: str | None = None,
) -> list[ActionRecord]:
items = list(self.actions)
if task_id is not None:
items = [item for item in items if item.task_id == task_id]
if action_name is not None:
items = [item for item in items if item.action_name == action_name]
if status is not None:
items = [item for item in items if item.status == status]
return items[:limit]
class FakeSettingsService:
def __init__(self, root) -> None: # type: ignore[no-untyped-def]
self.root = root
def load_redacted(self):
return SimpleNamespace(settings={"runtime": {"control_token": "secret"}})
def load(self):
return SimpleNamespace(schema={"title": "SettingsSchema"})
class ControlPlaneGetDispatcherTests(unittest.TestCase):
def _dispatcher(self, tmpdir: str, repo: FakeRepo) -> ControlPlaneGetDispatcher:
state = {
"root": Path(tmpdir),
"repo": repo,
"settings": {
"paths": {"session_dir": str(Path(tmpdir) / "session")},
"comment": {"post_split_comment": True, "post_full_video_timeline_comment": True},
"cleanup": {},
"publish": {},
},
"registry": SimpleNamespace(list_manifests=lambda: [{"name": "publish.biliup_cli"}]),
"manifests": [{"name": "publish.biliup_cli"}],
}
return ControlPlaneGetDispatcher(
state,
attention_state_fn=lambda payload: "running" if payload.get("status") == "running" else "stable",
delivery_state_label_fn=lambda payload: "pending_comment" if payload.get("delivery_state", {}).get("split_comment") == "pending" else "stable",
build_scheduler_preview_fn=lambda state, include_stage_scan=False, limit=200: {"items": [{"limit": limit}]},
settings_service_factory=FakeSettingsService,
)
def test_handle_settings_schema_returns_schema(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
task = Task("task-1", "local_file", "/tmp/source.mp4", "task-title", "published", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00")
dispatcher = self._dispatcher(tmpdir, FakeRepo(task))
body, status = dispatcher.handle_settings_schema()
self.assertEqual(status, HTTPStatus.OK)
self.assertEqual(body["title"], "SettingsSchema")
def test_handle_history_filters_records(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
task = Task("task-1", "local_file", "/tmp/source.mp4", "task-title", "published", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00")
actions = [
ActionRecord(None, "task-1", "comment", "ok", "comment ok", "{}", "2026-01-01T00:01:00+00:00"),
ActionRecord(None, "task-1", "publish", "error", "publish failed", "{}", "2026-01-01T00:02:00+00:00"),
]
dispatcher = self._dispatcher(tmpdir, FakeRepo(task, actions=actions))
body, status = dispatcher.handle_history(limit=100, task_id="task-1", action_name="comment", status="ok")
self.assertEqual(status, HTTPStatus.OK)
self.assertEqual(len(body["items"]), 1)
self.assertEqual(body["items"][0]["action_name"], "comment")
def test_handle_session_returns_not_found_when_missing(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
task = Task("task-1", "local_file", "/tmp/source.mp4", "task-title", "published", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00")
dispatcher = self._dispatcher(tmpdir, FakeRepo(task))
body, status = dispatcher.handle_session("missing-session")
self.assertEqual(status, HTTPStatus.NOT_FOUND)
self.assertEqual(body["error"], "session not found")
def test_handle_tasks_filters_attention(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
task = Task("task-1", "local_file", "/tmp/source.mp4", "task-title", "running", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00")
dispatcher = self._dispatcher(tmpdir, FakeRepo(task))
body, status = dispatcher.handle_tasks(
limit=10,
offset=0,
status=None,
search=None,
sort="updated_desc",
attention="running",
delivery=None,
)
self.assertEqual(status, HTTPStatus.OK)
self.assertEqual(body["total"], 1)
self.assertEqual(body["items"][0]["id"], "task-1")