diff --git a/tests/unit/test_ai_client_video.py b/tests/unit/test_ai_client_video.py index 1cf01e336..1ba1d8b2a 100644 --- a/tests/unit/test_ai_client_video.py +++ b/tests/unit/test_ai_client_video.py @@ -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) diff --git a/tests/unit/test_viral_video_p0.py b/tests/unit/test_viral_video_p0.py index 2ab4de7c7..e4c8a66d4 100644 --- a/tests/unit/test_viral_video_p0.py +++ b/tests/unit/test_viral_video_p0.py @@ -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