test(viral-video): 补_ resolve_video_model_id/_replace_henjin_everywhere/_mark_failed fallback单测(diff覆盖率≥60%)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 0s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m21s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m21s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m25s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 4m20s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m35s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 5m4s
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled

#2157 P0/P1: CI Unit Tests失败根因——diff覆盖率42%<60%门槛
补单测覆盖:
- ai_client._resolve_video_model_id: 9个case (None/空/doubao-/ep-/别名/大小写/dot normalize/未识别警告)
- viral_video._replace_henjin_everywhere: str/list/dict递归+nested+非str透传
- viral_video._mark_failed_and_notify: 原session正常/异常fallback新session/已终态job
本地: 16423 passed, diff覆盖率89%
This commit is contained in:
xiaoxia
2026-10-03 16:33:27 +08:00
parent ac27a3d862
commit dede83c8fb
2 changed files with 194 additions and 0 deletions
+67
View File
@@ -483,3 +483,70 @@ class TestVideoGenerationCancelled:
doubao_video_poll_interval=0, doubao_video_timeout=10, doubao_video_model="seedance"
)
assert client.video_generation("p", output_dir=str(tmp_path)) is None
# ============ #2157 _resolve_video_model_id 模型ID映射单测 ============
class TestResolveVideoModelId:
"""覆盖 _resolve_video_model_id 各分支(#2157 P0 修复)。"""
def _import_target(self):
from packages.shared.ai_client import _resolve_video_model_id
return _resolve_video_model_id
def test_none_uses_default(self):
fn = self._import_target()
with patch("packages.shared.ai_client.get_shared_settings") as ms:
ms.return_value = MagicMock(doubao_video_model="doubao-seedance-2-5-260628")
assert fn(None) == "doubao-seedance-2-5-260628"
def test_empty_uses_default(self):
fn = self._import_target()
with patch("packages.shared.ai_client.get_shared_settings") as ms:
ms.return_value = MagicMock(doubao_video_model="doubao-seedance-2-5-260628")
assert fn(" ") == "doubao-seedance-2-5-260628"
def test_doubao_prefix_passthrough(self):
fn = self._import_target()
assert fn("doubao-seedance-2-5-260628") == "doubao-seedance-2-5-260628"
def test_ep_prefix_passthrough(self):
fn = self._import_target()
assert fn("ep-20260721114705-b568m") == "ep-20260721114705-b568m"
def test_seedance_2_5_alias(self):
fn = self._import_target()
assert fn("seedance-2.5") == "doubao-seedance-2-5-260628"
def test_seedance_2_0_alias(self):
fn = self._import_target()
assert fn("seedance-2.0") == "doubao-seedance-2-0-250628"
def test_seedance_2_5_uppercase(self):
fn = self._import_target()
assert fn("Seedance-2.5") == "doubao-seedance-2-5-260628"
def test_seedance_dot_normalize(self):
fn = self._import_target()
# 传 "seedance-2.5" 带 dot 走 MAP.get 已命中;
# 构造带 seedance 但 key 变体的兜底场景
with patch(
"packages.shared.ai_client.VIRAL_VIDEO_MODEL_ID_MAP",
{
"seedance-2-5": "doubao-seedance-2-5-260628",
},
):
assert fn("seedance-2.5") == "doubao-seedance-2-5-260628"
def test_unknown_seedance_falls_back_to_default_and_warns(self, caplog):
fn = self._import_target()
import logging
with patch("packages.shared.ai_client.get_shared_settings") as ms:
ms.return_value = MagicMock(doubao_video_model="doubao-seedance-2-5-260628")
with caplog.at_level(logging.WARNING, logger="shared.ai_client"):
# 任何不认识的别名
assert fn("seedance-9.9") == "doubao-seedance-2-5-260628"
assert any("未识别" in r.message for r in caplog.records if "ai_client" in r.name)
+127
View File
@@ -313,3 +313,130 @@ class TestResumeReadsImageAnalysis:
# resume 本身应该调用 _run_render_pipeline
resume_src = inspect.getsource(vv.resume_viral_video_pipeline)
assert "_run_render_pipeline" in resume_src
# ============ #2157 _replace_henjin_everywhere 递归替换单测 ============
class TestReplaceHenjinEverywhere:
"""覆盖 #2157 P1:递归替换 copy_result 中所有层级的'很近'→'最近'。"""
def _import(self):
from apps.worker.worker_app.tasks.viral_video import _replace_henjin_everywhere
return _replace_henjin_everywhere
def test_plain_string_no_henjin(self):
fn = self._import()
assert fn("最近好物推荐") == "最近好物推荐"
assert fn("") == ""
assert fn(None) is None
assert fn(123) == 123
def test_string_with_henjin(self):
fn = self._import()
assert fn("很近是不是总觉得颈肩发僵") == "最近是不是总觉得颈肩发僵"
# 多次出现
assert fn("很近很近都很近") == "最近最近都最近"
def test_list_recursive(self):
fn = self._import()
out = fn(["很近a", "b", ["很近c", "d"]])
assert out == ["最近a", "b", ["最近c", "d"]]
def test_dict_recursive_nested(self):
fn = self._import()
obj = {
"overview": {"theme": "很近颈肩", "title": "x"},
"scene_and_lighting": "很近才好用",
"voiceover_script": "很近是不是",
"final_copy": "很近好物",
"shots": [
{"scene_and_dialogue": "很近第一镜", "action_details": "很近动作", "audio_bgm": "很近音乐"},
{"nested": {"deep": "很近深层"}},
],
"int_field": 42,
}
import json
out = fn(obj)
assert "很近" not in json.dumps(out, ensure_ascii=False)
assert out["overview"]["theme"] == "最近颈肩"
assert out["shots"][0]["scene_and_dialogue"] == "最近第一镜"
assert out["shots"][1]["nested"]["deep"] == "最近深层"
assert out["int_field"] == 42
class TestMarkFailedAndNotifySessionFallback:
"""#2157 P1:_mark_failed_and_notify 在原session失效时fallback到新SessionLocal。"""
def test_fallback_to_new_session_when_original_save_raises(self, tmp_path):
from unittest.mock import MagicMock, patch
from apps.worker.worker_app.tasks import viral_video as vv
job = MagicMock()
job.is_terminal = False
job.mark_failed = MagicMock()
# 原 session 保存抛异常
orig_session = MagicMock()
orig_repo = MagicMock()
def _raise(*a, **kw):
raise RuntimeError("session in rollback")
# 第一次调用_save_job抛异常,触发fallback
with patch.object(vv, "_save_job", side_effect=_raise):
fake_ssn = MagicMock()
fake_repo = MagicMock()
fake_job_in_db = MagicMock()
fake_job_in_db.is_terminal = False
fake_repo.get.return_value = fake_job_in_db
with patch.object(vv, "SessionLocal", return_value=fake_ssn):
with patch.object(vv, "SQLAlchemyViralVideoJobRepository", return_value=fake_repo):
with patch.object(vv, "_emit_progress") as mock_emit:
vv._mark_failed_and_notify("job-1", orig_session, orig_repo, job, "boom", stage="render")
# 原session上mark_failed被调用过
job.mark_failed.assert_called()
# fallback路径:新session上repo.get(job-1)被调用,且新job被mark_failed并commit
fake_repo.get.assert_called_with("job-1")
fake_job_in_db.mark_failed.assert_called_with("boom")
fake_repo.update.assert_called_with(fake_job_in_db)
fake_ssn.commit.assert_called()
fake_ssn.close.assert_called()
mock_emit.assert_called_once()
def test_original_session_happy_path_no_fallback(self):
from unittest.mock import MagicMock, patch
from apps.worker.worker_app.tasks import viral_video as vv
job = MagicMock()
job.is_terminal = False
session = MagicMock()
repo = MagicMock()
with patch.object(vv, "_save_job") as mock_save:
with patch.object(vv, "_emit_progress") as mock_emit:
# 不mock SessionLocal,如果fallback被错误触发会抛AttributeError
vv._mark_failed_and_notify("job-2", session, repo, job, "err", stage="copy")
job.mark_failed.assert_called_with("err")
mock_save.assert_called()
mock_emit.assert_called_once()
def test_terminal_job_not_marked(self):
from unittest.mock import MagicMock, patch
from apps.worker.worker_app.tasks import viral_video as vv
job = MagicMock()
job.is_terminal = True # 已终态
session = MagicMock()
repo = MagicMock()
with patch.object(vv, "_save_job") as mock_save:
with patch.object(vv, "_emit_progress"):
fake_ssn = MagicMock()
with patch.object(vv, "SessionLocal", return_value=fake_ssn):
with patch.object(vv, "SQLAlchemyViralVideoJobRepository") as mock_repo_cls:
vv._mark_failed_and_notify("job-3", session, repo, job, "x")
# 终态job不调用mark_failed
job.mark_failed.assert_not_called()
# 且因 job 已终态,_save_job 也不应被调用(marked=False 才fallback;但此处 job 非 None 且 is_terminal=True,marked 保持 False 进入 fallback)
# fallback路径会重新打开session,get到的job也是终态,不会update