Files
xiaoxia-saas/tests/unit/test_generation_common.py
T
CI Bot 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
style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
2026-09-14 11:38:53 +00:00

300 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)