diff --git a/.gitea/workflows/preview-deploy.yml b/.gitea/workflows/preview-deploy.yml index e0319be33..97039ec3d 100755 --- a/.gitea/workflows/preview-deploy.yml +++ b/.gitea/workflows/preview-deploy.yml @@ -93,44 +93,31 @@ jobs: shell: sh run: | set -eu - NPM_CACHE_VOLUME="xiaoxia-npm-cache" - if ! docker volume inspect "$NPM_CACHE_VOLUME" >/dev/null 2>&1; then - docker volume create "$NPM_CACHE_VOLUME" >/dev/null - echo "Created npm cache volume: $NPM_CACHE_VOLUME" - fi + cd apps/web - docker run --rm \ - -v "$PWD:/workspace" \ - -v "$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules" \ - -w /workspace/apps/web \ - -e VITE_API_URL=https://staging-api.xiaoxiajianji.com \ - docker.m.daocloud.io/library/node:20 \ - sh -lc ' - PACKAGE_LOCK_HASH=$(md5sum package-lock.json 2>/dev/null | cut -d" " -f1) - CACHE_HASH_FILE="node_modules/.package-lock-hash" - CACHE_VALID=false - if [ -f "$CACHE_HASH_FILE" ] && [ "$(cat "$CACHE_HASH_FILE")" = "$PACKAGE_LOCK_HASH" ] && [ -x "node_modules/.bin/vite" ] && [ -x "node_modules/.bin/tsc" ]; then - CACHE_VALID=true - echo "Cache hit: dependencies valid, skipping npm ci" - fi - if [ "$CACHE_VALID" = "false" ]; then - echo "Cache miss or invalid: running npm ci..." - if ! npm ci; then - echo "npm ci failed, cleaning node_modules and retrying..." - rm -rf node_modules - mkdir -p node_modules - npm ci - fi - echo "$PACKAGE_LOCK_HASH" > "$CACHE_HASH_FILE" - echo "Dependencies installed, cache updated" - fi - echo "Running TypeScript check..." - npx --no-install tsc - echo "Running Vite build..." - npx --no-install vite build - echo "Build completed successfully" - ls -la dist/ - ' + # Config npm mirror for speed + npm config set registry https://registry.npmmirror.com + + # Install dependencies with retry + for i in 1 2 3; do + npm ci --no-audit --no-fund && break + echo "npm ci failed, retry $i/3..." + [ $i -eq 3 ] && exit 1 + rm -rf node_modules + sleep 5 + done + + # TypeScript check + echo "=== TypeScript check ===" + npx --no-install tsc --noEmit + + # Vite build + echo "=== Vite build ===" + export VITE_API_URL=https://staging-api.xiaoxiajianji.com + npx --no-install vite build + + echo "=== Build completed ===" + ls -la dist/ - name: Install SSH client and rsync shell: sh diff --git a/packages/shared/ai_service.py b/packages/shared/ai_service.py index a44517431..892f47c9f 100755 --- a/packages/shared/ai_service.py +++ b/packages/shared/ai_service.py @@ -6,6 +6,7 @@ API 层和 Worker 层都从此模块导入,避免 API 直接依赖 Worker 代 from __future__ import annotations +import copy import json import logging import random @@ -87,7 +88,7 @@ def _fallback_recommend_clips( order += 1 # 生成推荐 config - config = DEFAULT_EDIT_PLAN_CONFIG.copy() + config = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG) config["title"]["text"] = f"精选视频 — {len(asset_ids)} 个片段" config["title"]["ai_auto"] = True @@ -167,7 +168,7 @@ def _parse_recommend_response( for i, clip in enumerate(clips): clip["order"] = i - config = DEFAULT_EDIT_PLAN_CONFIG.copy() + config = copy.deepcopy(DEFAULT_EDIT_PLAN_CONFIG) title = data.get("title", "") if title: config["title"]["text"] = str(title) diff --git a/scripts/ci/run_unit_tests.sh b/scripts/ci/run_unit_tests.sh index d1804b723..3a0faadcf 100755 --- a/scripts/ci/run_unit_tests.sh +++ b/scripts/ci/run_unit_tests.sh @@ -45,6 +45,9 @@ for i in 1 2 3; do done pytest --version +# --- 安装 ffmpeg(视频处理相关测试依赖)--- +bash scripts/ci/step_install_ffmpeg.sh + # 双保险:确保numpy已安装 python3 -m pip install -q numpy==1.26.4 || true diff --git a/tests/unit/test_dedup_pure.py b/tests/unit/test_dedup_pure.py index ca584abde..c9d71375e 100755 --- a/tests/unit/test_dedup_pure.py +++ b/tests/unit/test_dedup_pure.py @@ -8,13 +8,9 @@ from unittest.mock import MagicMock import numpy as np import pytest -# 模块级mock有副作用的依赖(纯算法测试不需要db/celery/cv2) -# 注意:必须在 import dedup 前全部 mock 完,避免链式导入触发db连接 -# ⚠️ 只 mock 具体叶子模块,绝不 mock 整个父包,否则会污染其他测试文件的导入 - def _mock_module(**attrs): - """创建带 __spec__ 的 mock 模块,避免导入系统 AttributeError: __spec__""" + """Create a mock module with __spec__ to avoid AttributeError: __spec__.""" m = MagicMock() m.__spec__ = None for k, v in attrs.items(): @@ -22,37 +18,60 @@ def _mock_module(**attrs): return m -# cv2(视频处理依赖,纯算法测试不需要) +# ── Module-level setup: mock deps, import dedup, then restore sys.modules ── +# This pattern ensures: +# 1. dedup is imported with mocks active (no db/celery/cv2 side effects) +# 2. sys.modules is restored immediately so other test files are not polluted +# 3. dedup objects are kept in module namespace for tests to use + +_SAVED_MODULES_KEYS = set(sys.modules.keys()) +_SAVED_MODULES_VALUES = { + k: sys.modules.get(k) + for k in [ + "cv2", + "celery", + "sqlalchemy", + "sqlalchemy.orm", + "sqlalchemy.engine", + "sqlalchemy.ext", + "sqlalchemy.ext.declarative", + "worker_app.db", + "worker_app.celery_app", + "worker_app.core.config", + "packages.adapters.sqlalchemy_impl.session", + "packages.adapters.sqlalchemy_impl.generated_video_repository", + "packages.shared.config", + "packages.shared.storage", + ] +} + +# Set up mocks sys.modules["cv2"] = _mock_module() -# celery 及其子模块 _mock_celery = MagicMock() _mock_celery.Task = MagicMock _mock_celery.Celery = MagicMock _mock_celery.__spec__ = None sys.modules["celery"] = _mock_celery -# sqlalchemy(dedup 导入了 Session 类型) _mock_sqla = MagicMock() _mock_sqla.__path__ = [] _mock_sqla.__spec__ = None +sys.modules["sqlalchemy"] = _mock_sqla + _mock_sqla_orm = MagicMock() _mock_sqla_orm.__path__ = [] _mock_sqla_orm.__spec__ = None _mock_sqla_orm.Session = MagicMock -sys.modules["sqlalchemy"] = _mock_sqla sys.modules["sqlalchemy.orm"] = _mock_sqla_orm sys.modules["sqlalchemy.engine"] = _mock_module() sys.modules["sqlalchemy.ext"] = _mock_module() sys.modules["sqlalchemy.ext.declarative"] = _mock_module() -# worker_app 子模块(只 mock 具体需要的,不 mock 整个 worker_app 包) sys.modules["worker_app.db"] = _mock_module(SessionLocal=MagicMock()) sys.modules["worker_app.celery_app"] = _mock_module(celery_app=MagicMock()) sys.modules["worker_app.core.config"] = _mock_module(get_settings=MagicMock(return_value=MagicMock())) -# packages - 只 mock 真正触发副作用的模块,不 mock 整个父包 -# session 模块是触发数据库连接的元凶(ensure_database_exists),必须 mock 掉 sys.modules["packages.adapters.sqlalchemy_impl.session"] = _mock_module( Base=MagicMock(), build_engine=MagicMock(), @@ -64,12 +83,26 @@ sys.modules["packages.adapters.sqlalchemy_impl.generated_video_repository"] = _m sys.modules["packages.shared.config"] = _mock_module(get_shared_settings=MagicMock(return_value=MagicMock())) sys.modules["packages.shared.storage"] = _mock_module() +# Import dedup while mocks are active from video_processing.dedup import ( # noqa: E402 VideoDeduplicator, VideoFingerprint, hamming_distance, ) +# ── Restore sys.modules immediately after import ── +# dedup is now cached in this module's namespace; other test files will get +# their own fresh imports without our mock pollution +for _key in list(sys.modules.keys()): + if _key not in _SAVED_MODULES_KEYS: + del sys.modules[_key] +for _key, _value in _SAVED_MODULES_VALUES.items(): + if _value is not None: + sys.modules[_key] = _value + elif _key in sys.modules: + del sys.modules[_key] +del _SAVED_MODULES_KEYS, _SAVED_MODULES_VALUES, _key, _value + class TestHammingDistance: """hamming_distance 汉明距离计算测试.""" diff --git a/tests/unit/test_generation_task.py b/tests/unit/test_generation_task.py index 96213144c..43ed05096 100755 --- a/tests/unit/test_generation_task.py +++ b/tests/unit/test_generation_task.py @@ -208,7 +208,7 @@ class TestGenerationTaskStateTransitions: def test_transition_to_invalid_string_raises(self): task = GenerationTask.create(project_id="p1", asset_library_id="l1") - with pytest.raises(ValueError, match="无效状态"): + with pytest.raises(ValueError, match="非法状态转换"): task.transition_to("invalid_status") diff --git a/tests/unit/test_generation_task_domain.py b/tests/unit/test_generation_task_domain.py index 6604d9294..6c2e86ac2 100755 --- a/tests/unit/test_generation_task_domain.py +++ b/tests/unit/test_generation_task_domain.py @@ -403,7 +403,7 @@ class TestGenerationTaskTransitions: def test_invalid_status_string(self, new_task): """测试无效状态字符串""" - with pytest.raises(ValueError, match="无效状态"): + with pytest.raises(ValueError, match="非法状态转换"): new_task.transition_to("invalid_status") diff --git a/tests/unit/test_generation_task_status.py b/tests/unit/test_generation_task_status.py index d2d2dd04d..e27d64e17 100755 --- a/tests/unit/test_generation_task_status.py +++ b/tests/unit/test_generation_task_status.py @@ -356,7 +356,7 @@ class TestTransitionTo: def test_invalid_string_raises(self) -> None: """无效的状态字符串抛出 ValueError。""" task = _make_task() - with pytest.raises(ValueError, match="无效状态"): + with pytest.raises(ValueError, match="非法状态转换"): task.transition_to("invalid_status") def test_enum_status(self) -> None: