ffb40038d3
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 8s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 8s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m1s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m45s
AI Code Review / AI Code Review (pull_request) Successful in 6m32s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 7m28s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 8m3s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 8m21s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 9m39s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m32s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 25m33s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 13m46s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 14m59s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 43h19m12s
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 43h43m59s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 43h44m13s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 43h43m26s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 43h43m30s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 43h43m36s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 43h43m36s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 43h43m36s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 43h44m21s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 43h18m44s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 43h43m2s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 43h18m44s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 43h18m43s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 43h18m44s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 43h43m2s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 43h43m52s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 43h43m2s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 43h43m30s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 43h43m52s
300 lines
14 KiB
Python
300 lines
14 KiB
Python
"""generation_common 公共服务辅助函数单元测试。
|
||
|
||
覆盖 query_voice_durations / writeback_edit_plan_config / collect_plan_segments /
|
||
resolve_latest_plan_by_template 四个下沉函数的主路径、边界与容错路径。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from unittest.mock import MagicMock
|
||
|
||
import pytest
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# query_voice_durations
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
|
||
class TestQueryVoiceDurations:
|
||
def _make_db_with_rows(self, rows):
|
||
"""构造 MagicMock db,query().filter().all() 返回 rows。"""
|
||
db = MagicMock()
|
||
db.query.return_value.filter.return_value.all.return_value = list(rows)
|
||
return db
|
||
|
||
def test_empty_input_returns_empty_list(self):
|
||
from app.services.generation_common import query_voice_durations
|
||
|
||
db = MagicMock()
|
||
assert query_voice_durations(db, []) == []
|
||
assert query_voice_durations(db, None) == []
|
||
db.query.assert_not_called()
|
||
|
||
def test_all_empty_or_falsy_ids_returns_zero_list(self):
|
||
from app.services.generation_common import query_voice_durations
|
||
|
||
db = MagicMock()
|
||
assert query_voice_durations(db, ["", None, ""]) == [0.0, 0.0, 0.0]
|
||
|
||
def test_normal_lookup_returns_durations_in_input_order(self):
|
||
from app.services.generation_common import query_voice_durations
|
||
|
||
db = self._make_db_with_rows([("v1", 3.5), ("v2", 7.2)])
|
||
result = query_voice_durations(db, ["v1", "v2", "v-missing"])
|
||
assert result == [3.5, 7.2, 0.0]
|
||
|
||
def test_duplicate_ids_returns_consistent_durations_preserves_order(self):
|
||
"""#1855:同配音 id 多次出现应返回相同时长,保持输入顺序/长度。"""
|
||
from app.services.generation_common import query_voice_durations
|
||
|
||
db = self._make_db_with_rows([("v1", 4.0)])
|
||
result = query_voice_durations(db, ["v1", "v1", "v1"])
|
||
assert result == [4.0, 4.0, 4.0]
|
||
|
||
def test_non_numeric_duration_coerced_to_zero(self):
|
||
from app.services.generation_common import query_voice_durations
|
||
|
||
db = self._make_db_with_rows([("v1", None), ("v2", "not-a-number"), ("v3", 2.0)])
|
||
result = query_voice_durations(db, ["v1", "v2", "v3"])
|
||
assert result == [0.0, 0.0, 2.0]
|
||
|
||
def test_db_exception_returns_zeros_and_logs(self, caplog):
|
||
from app.services.generation_common import query_voice_durations
|
||
|
||
db = MagicMock()
|
||
db.query.side_effect = RuntimeError("DB boom")
|
||
with caplog.at_level("WARNING"):
|
||
result = query_voice_durations(db, ["v1", "v2"])
|
||
assert result == [0.0, 0.0]
|
||
assert any("配音时长查询失败" in rec.message for rec in caplog.records)
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# writeback_edit_plan_config
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
|
||
def _make_plan_model(config=None):
|
||
plan = MagicMock()
|
||
plan.config = config if config is not None else {}
|
||
return plan
|
||
|
||
|
||
class TestWritebackEditPlanConfig:
|
||
def test_empty_plan_id_returns_immediately(self):
|
||
from app.services.generation_common import writeback_edit_plan_config
|
||
|
||
db = MagicMock()
|
||
writeback_edit_plan_config("", "task1", None, db)
|
||
db.query.assert_not_called()
|
||
|
||
def test_plan_not_found_logs_and_returns(self, caplog):
|
||
from app.services.generation_common import writeback_edit_plan_config
|
||
|
||
db = MagicMock()
|
||
db.query.return_value.filter.return_value.first.return_value = None
|
||
with caplog.at_level("WARNING"):
|
||
writeback_edit_plan_config("p999", "task1", None, db)
|
||
db.commit.assert_not_called()
|
||
assert any("plan不存在" in rec.message for rec in caplog.records)
|
||
|
||
def test_writes_task_id_preserves_existing_config(self):
|
||
from app.services.generation_common import writeback_edit_plan_config
|
||
|
||
plan = _make_plan_model({"other": "keep-me"})
|
||
db = MagicMock()
|
||
db.query.return_value.filter.return_value.first.return_value = plan
|
||
writeback_edit_plan_config("p1", "task-xyz", None, db)
|
||
assert plan.config["generation_task_id"] == "task-xyz"
|
||
assert plan.config["other"] == "keep-me"
|
||
assert "title" not in plan.config or not plan.config.get("title")
|
||
db.commit.assert_called_once()
|
||
|
||
def test_merges_title_without_title_change(self):
|
||
"""#1901: 写 'title' 字段,未变标题保留 cover。"""
|
||
from app.services.generation_common import writeback_edit_plan_config
|
||
|
||
plan = _make_plan_model({"title": {"text": "old"}, "cover": "x"})
|
||
db = MagicMock()
|
||
db.query.return_value.filter.return_value.first.return_value = plan
|
||
writeback_edit_plan_config("p1", "t1", {"text": "old"}, db)
|
||
assert plan.config["title"] == {"text": "old"}
|
||
# 旧 key 不应残留
|
||
assert "title_config" not in plan.config
|
||
# 标题未变 → cover 保留
|
||
assert plan.config.get("cover") == "x"
|
||
|
||
def test_merges_title_fallback_to_old_title_config_key(self):
|
||
"""#1901: 老数据存在 title_config(无 title)时,也能正确识别旧标题文字。"""
|
||
from app.services.generation_common import writeback_edit_plan_config
|
||
|
||
plan = _make_plan_model({"title_config": {"text": "old"}, "cover": "x"})
|
||
db = MagicMock()
|
||
db.query.return_value.filter.return_value.first.return_value = plan
|
||
writeback_edit_plan_config("p1", "t1", {"text": "old"}, db)
|
||
# 写入新 key "title",旧 key 被清除
|
||
assert plan.config["title"] == {"text": "old"}
|
||
assert "title_config" not in plan.config
|
||
assert plan.config.get("cover") == "x"
|
||
|
||
def test_title_change_clears_cover(self):
|
||
"""#1901: 标题变化时清 cover,新配置写到 'title'。"""
|
||
from app.services.generation_common import writeback_edit_plan_config
|
||
|
||
plan = _make_plan_model({"title": {"text": "old"}, "cover": "x"})
|
||
db = MagicMock()
|
||
db.query.return_value.filter.return_value.first.return_value = plan
|
||
writeback_edit_plan_config("p1", "t1", {"text": "new-title"}, db)
|
||
assert "cover" not in plan.config
|
||
assert plan.config["title"] == {"text": "new-title"}
|
||
assert "title_config" not in plan.config
|
||
|
||
def test_title_config_normalizes_legacy_keys(self):
|
||
"""#1901: 写入时归一化 font_size/font_preset/font_color → size/font/color,与 worker 对齐。"""
|
||
from app.services.generation_common import writeback_edit_plan_config
|
||
|
||
plan = _make_plan_model({})
|
||
db = MagicMock()
|
||
db.query.return_value.filter.return_value.first.return_value = plan
|
||
writeback_edit_plan_config(
|
||
"p1",
|
||
"t1",
|
||
{"text": "hi", "font_size": 32, "font_preset": "楷体", "font_color": "#ff0000", "bold": True},
|
||
db,
|
||
)
|
||
title = plan.config["title"]
|
||
assert title["text"] == "hi"
|
||
assert title["size"] == 32
|
||
assert title["font"] == "楷体"
|
||
assert title["color"] == "#ff0000"
|
||
# 原始 key 保留(方便调用方排查,但归一化后的 key 必须存在)
|
||
assert title["font_size"] == 32
|
||
|
||
def test_config_not_dict_treated_as_empty(self):
|
||
from app.services.generation_common import writeback_edit_plan_config
|
||
|
||
plan = _make_plan_model(config=None)
|
||
db = MagicMock()
|
||
db.query.return_value.filter.return_value.first.return_value = plan
|
||
writeback_edit_plan_config("p1", "t1", {"text": "hi"}, db)
|
||
assert plan.config["generation_task_id"] == "t1"
|
||
assert plan.config["title"] == {"text": "hi"}
|
||
assert "title_config" not in plan.config
|
||
|
||
def test_exception_triggers_rollback_and_logs(self, caplog):
|
||
from app.services.generation_common import writeback_edit_plan_config
|
||
|
||
db = MagicMock()
|
||
db.query.return_value.filter.return_value.first.side_effect = RuntimeError("fail")
|
||
with caplog.at_level("WARNING"):
|
||
writeback_edit_plan_config("p1", "t1", None, db)
|
||
db.rollback.assert_called_once()
|
||
assert any("回写plan.config异常" in rec.message for rec in caplog.records)
|
||
|
||
def test_exception_with_rollback_also_failing_is_safe(self, caplog):
|
||
"""外层异常后,db.rollback() 自己也抛异常时也不应中断(pass 兜底)。"""
|
||
from app.services.generation_common import writeback_edit_plan_config
|
||
|
||
db = MagicMock()
|
||
db.query.return_value.filter.return_value.first.side_effect = RuntimeError("fail")
|
||
db.rollback.side_effect = RuntimeError("rollback boom")
|
||
with caplog.at_level("WARNING"):
|
||
# 不应抛出异常
|
||
writeback_edit_plan_config("p1", "t1", None, db)
|
||
assert any("回写plan.config异常" in rec.message for rec in caplog.records)
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# collect_plan_segments
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
|
||
def _make_clip(asset_id, start, duration):
|
||
c = MagicMock()
|
||
c.asset_id = asset_id
|
||
c.start_time = start
|
||
c.duration = duration
|
||
return c
|
||
|
||
|
||
class TestCollectPlanSegments:
|
||
def test_empty_plan_returns_empty(self):
|
||
from app.services.generation_common import collect_plan_segments
|
||
|
||
repo = MagicMock()
|
||
repo.list_by_plan.return_value = []
|
||
assert collect_plan_segments("p1", repo) == {}
|
||
|
||
def test_single_page_collects_segments(self):
|
||
from app.services.generation_common import collect_plan_segments
|
||
|
||
repo = MagicMock()
|
||
repo.list_by_plan.side_effect = [
|
||
[_make_clip("a1", 0.0, 5.0), _make_clip("a1", 10.0, 3.0), _make_clip("a2", 2.0, 4.0)],
|
||
[],
|
||
]
|
||
segs = collect_plan_segments("p1", repo, page_size=500)
|
||
assert segs["a1"] == [(0.0, 5.0), (10.0, 13.0)]
|
||
assert segs["a2"] == [(2.0, 6.0)]
|
||
|
||
def test_pagination_walks_all_batches(self):
|
||
from app.services.generation_common import collect_plan_segments
|
||
|
||
repo = MagicMock()
|
||
page1 = [_make_clip("a1", 0.0, 1.0)] * 2
|
||
page2 = [_make_clip("a2", 0.0, 2.0)] * 2
|
||
page3 = [_make_clip("a3", 0.0, 1.0)] # short final batch → stop
|
||
repo.list_by_plan.side_effect = [page1, page2, page3]
|
||
segs = collect_plan_segments("p1", repo, page_size=2)
|
||
assert set(segs.keys()) == {"a1", "a2", "a3"}
|
||
assert repo.list_by_plan.call_count == 3
|
||
|
||
def test_skips_zero_or_negative_duration_clips(self):
|
||
from app.services.generation_common import collect_plan_segments
|
||
|
||
repo = MagicMock()
|
||
repo.list_by_plan.side_effect = [
|
||
[_make_clip(None, 0.0, 5.0), _make_clip("a1", 0.0, 0.0), _make_clip("a1", 1.0, -1.0)],
|
||
[],
|
||
]
|
||
assert collect_plan_segments("p1", repo) == {}
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
# resolve_latest_plan_by_template
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
|
||
class TestResolveLatestPlanByTemplate:
|
||
@pytest.mark.parametrize("tid", ["", None, " "])
|
||
def test_empty_template_returns_none(self, tid):
|
||
from app.services.generation_common import resolve_latest_plan_by_template
|
||
|
||
db = MagicMock()
|
||
assert resolve_latest_plan_by_template(db, template_id=tid, user_id="u1") is None
|
||
db.query.assert_not_called()
|
||
|
||
def test_returns_latest_plan_id(self):
|
||
from app.services.generation_common import resolve_latest_plan_by_template
|
||
|
||
db = MagicMock()
|
||
latest = MagicMock(id="plan-xyz")
|
||
db.query.return_value.filter.return_value.order_by.return_value.first.return_value = latest
|
||
assert resolve_latest_plan_by_template(db, template_id=" tpl1 ", user_id="u1") == "plan-xyz"
|
||
|
||
def test_no_plan_returns_none(self):
|
||
from app.services.generation_common import resolve_latest_plan_by_template
|
||
|
||
db = MagicMock()
|
||
db.query.return_value.filter.return_value.order_by.return_value.first.return_value = None
|
||
assert resolve_latest_plan_by_template(db, template_id="tpl", user_id="u") is None
|
||
|
||
def test_db_exception_returns_none_and_logs(self, caplog):
|
||
from app.services.generation_common import resolve_latest_plan_by_template
|
||
|
||
db = MagicMock()
|
||
db.query.side_effect = RuntimeError("boom")
|
||
with caplog.at_level("WARNING"):
|
||
assert resolve_latest_plan_by_template(db, template_id="tpl", user_id="u") is None
|
||
assert any("查找最新plan失败" in rec.message for rec in caplog.records)
|