Files
xiaoxia-saas/tests/unit/test_asset_availability.py
xiaoxia 4aeb1d5b66
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 2m41s
CI/CD Pipeline / Integration Tests (push) Successful in 3m46s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m32s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m35s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 4m27s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 6m31s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 9m14s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 4m10s
CI/CD Pipeline / Unit Tests (push) Successful in 17m43s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m28s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m4s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 6m2s
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
fix(render): 修复生成视频时长短于模板要求时长 (#1614)
fix(render): 修复生成视频时长短于模板要求时长 (#1614)
2026-09-01 20:00:12 +08:00

457 lines
18 KiB
Python
Executable File
Raw Permalink 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.
"""Task H 单测:素材余量四字段(used_duration/available_duration/used_ratio/usable)。
覆盖:
1. compute_asset_availability 纯函数各分支(无区间/未满/可复用/全达上限/非视频/无时长/区间合并/扩边判定);
2. _asset_availability_fields 路由辅助(视频有值、非视频 None+usable=True、异常零影响);
3. _to_asset_response 四字段注入;
4. smart_match_assets 结果层过滤 usable=false。
"""
import json
import os
import sys
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from app.api.routes.assets import ( # noqa: E402
_asset_availability_fields,
_to_asset_response,
smart_match_assets,
)
from app.schemas.asset import SmartMatchRequest # noqa: E402
from app.services.asset_segment_tracker import ( # noqa: E402
MAX_RANGE_USE_COUNT,
SEGMENT_EDGE_GAP,
compute_asset_availability,
)
VIDEO_DURATION = 60.0
def _make_asset(duration=VIDEO_DURATION, ranges=None, file_type="video", classification_result=None):
"""构造测试用 Asset-like 对象(领域实体形态:metadata 为 dict)。
ranges: list of dictsused_time_ranges 条目),同时写入 metadata dict
Asset 实体形态,repository 返回)与 classification_result JSON 字符串
ORM AssetModel 形态);_read_meta 两种形态都必须能读到。
"""
meta_dict = {"used_time_ranges": ranges} if ranges is not None else {}
if classification_result is None and ranges is not None:
classification_result = json.dumps(meta_dict)
return SimpleNamespace(
id="asset-test",
project_id="proj-1",
library_id="lib-1",
name="测试素材",
storage_key="key/test-asset.mp4",
thumbnail_url=None,
mime_type="video/mp4" if file_type == "video" else "audio/mpeg",
file_size=1000,
duration=duration,
width=1080,
height=1920,
fps=30,
codec="h264",
status=SimpleNamespace(value="ready"),
classification_status=SimpleNamespace(value="completed"),
quality_score=90.0,
created_at=__import__("datetime").datetime(2026, 8, 1, 12, 0, 0),
uploaded_by_user_id="user-1",
tag_ids=[],
file_type=file_type,
# ORM 形态
classification_result=classification_result,
# 领域实体形态(真实路由 repository 返回的 Asset
metadata=meta_dict,
)
def _make_orm_style_asset(duration=VIDEO_DURATION, ranges=None):
"""ORM AssetModel 形态:只有 classification_result JSON 字符串,无 metadata 属性。"""
a = _make_asset(duration=duration, ranges=ranges)
del a.metadata
return a
def _range(start, end, use_count=1):
return {
"start": start,
"end": end,
"plan_id": "plan-1",
"created_at": "2026-08-29T10:00:00",
"use_count": use_count,
"last_used_at": "2026-08-29T10:00:00",
}
# ── compute_asset_availability 纯函数 ─────────────────────────────────────────
class TestComputeAssetAvailability:
def test_no_ranges_fully_usable(self):
"""无历史区间:used=0, ratio=0, usable=True。"""
info = compute_asset_availability(_make_asset(ranges=[]))
assert info is not None
assert info["used_duration"] == 0.0
assert info["available_duration"] == VIDEO_DURATION
assert info["used_ratio"] == 0.0
assert info["usable"] is True
def test_none_model_returns_none(self):
assert compute_asset_availability(None) is None
def test_non_video_returns_none(self):
"""非视频(音频)返回 None,路由层按可用处理。"""
info = compute_asset_availability(_make_asset(file_type="audio"))
assert info is None
def test_zero_duration_returns_none(self):
info = compute_asset_availability(_make_asset(duration=0.0))
assert info is None
def test_partial_usage_usable(self):
"""使用 10s,剩余 50s 空闲(≥3s),usable=True。"""
info = compute_asset_availability(_make_asset(ranges=[_range(5.0, 15.0)]))
assert info["used_duration"] == pytest.approx(10.0, abs=0.01)
assert info["available_duration"] == pytest.approx(50.0, abs=0.01)
assert info["used_ratio"] == pytest.approx(10.0 / 60.0, abs=0.001)
assert info["usable"] is True
def test_overlapping_ranges_merged(self):
"""重叠区间合并后计算 used_duration,不重复计时。"""
info = compute_asset_availability(_make_asset(ranges=[_range(0.0, 10.0), _range(5.0, 20.0)]))
# 合并后 [0,20] → 20s
assert info["used_duration"] == pytest.approx(20.0, abs=0.01)
assert info["used_ratio"] == pytest.approx(20.0 / 60.0, abs=0.001)
def test_full_coverage_but_reusable(self):
"""区间铺满全片(无空闲段),但 use_count 未达上限 → usable=True(受控复用)。"""
info = compute_asset_availability(
_make_asset(
duration=10.0,
ranges=[_range(0.0, 10.0, use_count=1)],
)
)
assert info["used_duration"] == pytest.approx(10.0, abs=0.01)
assert info["available_duration"] == 0.0
assert info["usable"] is True
def test_exhausted_not_usable(self):
"""无空闲段 且 所有区间 use_count 达上限 → usable=False。"""
info = compute_asset_availability(
_make_asset(
duration=10.0,
ranges=[_range(0.0, 10.0, use_count=MAX_RANGE_USE_COUNT)],
)
)
assert info["usable"] is False
assert info["available_duration"] == 0.0
assert info["used_ratio"] == pytest.approx(1.0, abs=0.001)
def test_exhausted_multiple_ranges_all_capped(self):
"""多个区间铺满、全部达上限 → usable=False;任一未满即 usable=True。"""
info_capped = compute_asset_availability(
_make_asset(
duration=20.0,
ranges=[
_range(0.0, 10.0, use_count=MAX_RANGE_USE_COUNT),
_range(10.0, 20.0, use_count=MAX_RANGE_USE_COUNT),
],
)
)
assert info_capped["usable"] is False
info_partial = compute_asset_availability(
_make_asset(
duration=20.0,
ranges=[
_range(0.0, 10.0, use_count=MAX_RANGE_USE_COUNT),
_range(10.0, 20.0, use_count=MAX_RANGE_USE_COUNT - 1),
],
)
)
assert info_partial["usable"] is True
def test_edge_gap_consumed_not_usable(self):
"""区间未物理铺满,但扩边(+0.3s)后空闲段 <3s → 视为无空闲段;
区间 use_count 均达上限 → usable=False。"""
# 10s 素材:[0, 4.0] 与 [4.6, 10],物理空闲 [4.0,4.6] 仅 0.6s
# 扩边后左区间延至 4.3、右区间起于 4.3,空闲被吃掉
info = compute_asset_availability(
_make_asset(
duration=10.0,
ranges=[
_range(0.0, 4.0, use_count=MAX_RANGE_USE_COUNT),
_range(4.6, 10.0, use_count=MAX_RANGE_USE_COUNT),
],
)
)
assert info["usable"] is False
def test_large_gap_remains_usable(self):
"""区间之间留有 ≥3s 空闲段(扩边后仍 ≥3s)→ usable=True。"""
# [0,2] 扩边到 [0,3.5][9,10] 扩边到 [7.5,10];空闲 [3.5,7.5]=4.0s >=3
# 使用 [0,2] 与 [9,10],扩边后空闲 [3.5,7.5]=4.0s ≥3 → usable
info = compute_asset_availability(
_make_asset(
duration=10.0,
ranges=[
_range(0.0, 2.0, use_count=MAX_RANGE_USE_COUNT),
_range(9.0, 10.0, use_count=MAX_RANGE_USE_COUNT),
],
)
)
assert info["usable"] is True
def test_invalid_ranges_skipped(self):
"""脏数据(缺 start/end、end<=start、use_count 非法)不崩溃,合法区间照常计算。"""
info = compute_asset_availability(
_make_asset(
duration=30.0,
ranges=[
{"start": "bad"},
{"start": 5.0, "end": 3.0},
"junk",
_range(0.0, 10.0, use_count="not-a-number"),
],
)
)
assert info is not None
assert info["used_duration"] == pytest.approx(10.0, abs=0.01)
# use_count 非法按 1 处理 → 未达上限,且空闲段充足
assert info["usable"] is True
def test_broken_classification_json_treated_as_unused(self):
"""classification_result 是非法 JSON 时按无历史区间处理。"""
info = compute_asset_availability(_make_asset(classification_result="not-json{{{"))
assert info is not None
assert info["used_duration"] == 0.0
assert info["usable"] is True
def test_segment_edge_gap_constant(self):
"""边缘间隙常量为 1.5s(与 MediaKit 冲突检测同口径)。"""
assert SEGMENT_EDGE_GAP == 1.5
def test_domain_entity_metadata_dict_form(self):
"""领域实体形态(metadata 为 dict,无 classification_result)也能读到区间。
真实路由 repository 返回 Asset 实体,区间记录在 metadata dict 里
repository 与 ORM classification_result JSON 互转)。
"""
a = _make_asset(duration=30.0, ranges=[_range(0.0, 12.0)])
del a.classification_result # 实体没有该列
info = compute_asset_availability(a)
assert info is not None
assert info["used_duration"] == pytest.approx(12.0, abs=0.01)
assert info["usable"] is True
def test_orm_model_classification_result_form(self):
"""ORM AssetModel 形态(只有 classification_result JSON 字符串)正常。"""
a = _make_orm_style_asset(duration=30.0, ranges=[_range(0.0, 12.0)])
assert not hasattr(a, "metadata")
info = compute_asset_availability(a)
assert info is not None
assert info["used_duration"] == pytest.approx(12.0, abs=0.01)
# ── 路由层辅助:_asset_availability_fields / _to_asset_response ──────────────
class TestAssetAvailabilityFields:
def test_video_asset_returns_values(self):
fields = _asset_availability_fields(_make_asset(ranges=[_range(0.0, 10.0)]))
assert fields["usable"] is True
assert fields["used_duration"] == pytest.approx(10.0, abs=0.01)
assert fields["available_duration"] == pytest.approx(50.0, abs=0.01)
assert fields["used_ratio"] is not None
def test_non_video_returns_none_fields_usable_true(self):
fields = _asset_availability_fields(_make_asset(file_type="audio"))
assert fields["used_duration"] is None
assert fields["available_duration"] is None
assert fields["used_ratio"] is None
assert fields["usable"] is True
def test_exception_falls_back_to_zero_impact(self, monkeypatch):
"""compute 抛异常时路由层兜底:None 字段 + usable=True,不影响响应。"""
import app.api.routes.assets as assets_module
def _boom(_model):
raise RuntimeError("unexpected")
monkeypatch.setattr(assets_module, "compute_asset_availability", _boom)
fields = _asset_availability_fields(_make_asset())
assert fields["used_duration"] is None
assert fields["usable"] is True
class TestToAssetResponseInjectsFields:
def _storage_stub(self):
svc = MagicMock()
svc.get_download_url.return_value = "https://example.com/signed"
return svc
def test_video_response_carries_availability_fields(self):
asset = _make_asset(ranges=[_range(0.0, 12.0)])
resp = _to_asset_response(asset, storage_service=self._storage_stub())
assert resp.usable is True
assert resp.used_duration == pytest.approx(12.0, abs=0.01)
assert resp.available_duration == pytest.approx(48.0, abs=0.01)
assert resp.used_ratio == pytest.approx(0.2, abs=0.01)
def test_exhausted_asset_response_usable_false(self):
asset = _make_asset(
duration=10.0,
ranges=[_range(0.0, 10.0, use_count=MAX_RANGE_USE_COUNT)],
)
resp = _to_asset_response(asset, storage_service=self._storage_stub())
assert resp.usable is False
assert resp.used_ratio == pytest.approx(1.0, abs=0.001)
def test_non_video_response_fields_none_usable_true(self):
asset = _make_asset(file_type="audio")
resp = _to_asset_response(asset, storage_service=self._storage_stub())
assert resp.used_duration is None
assert resp.available_duration is None
assert resp.used_ratio is None
assert resp.usable is True
# ── smart_match_assets 结果层过滤 ────────────────────────────────────────────
def _exhausted_asset(asset_id):
"""构造一个 usable=false 的视频素材:10s 铺满、区间 use_count 均达上限。"""
a = _make_asset(
duration=10.0,
ranges=[_range(0.0, 10.0, use_count=MAX_RANGE_USE_COUNT)],
)
a.id = asset_id
a.name = f"exhausted-{asset_id}"
return a
def _fresh_asset(asset_id, duration=60.0):
a = _make_asset(duration=duration, ranges=[])
a.id = asset_id
a.name = f"fresh-{asset_id}"
return a
class TestSmartMatchFiltersExhausted:
def _call(self, assets):
lib_repo = MagicMock()
lib_repo.get.return_value = SimpleNamespace(project_id="proj-1")
asset_repo = MagicMock()
asset_repo.find_by_library_and_file_type.return_value = assets
project_repo = MagicMock()
project = MagicMock()
project.can_access.return_value = True
project_repo.find_by_id.return_value = project
user = SimpleNamespace(id="user-1")
auth_user = SimpleNamespace(user=user)
# storage_service 在 _to_asset_response 内 get_storage_service()patch 掉
import app.api.routes.assets as assets_module
svc = MagicMock()
svc.get_download_url.return_value = "https://example.com/signed"
original_get_storage = assets_module.get_storage_service
assets_module.get_storage_service = lambda: svc
try:
resp = smart_match_assets(
SmartMatchRequest(library_id="lib-1", kind="video"),
authenticated_user=auth_user,
asset_repository=asset_repo,
asset_library_repository=lib_repo,
project_repository=project_repo,
)
finally:
assets_module.get_storage_service = original_get_storage
return resp
def test_exhausted_assets_excluded(self):
"""smart-match 结果中 usable=false 的素材被剔除,新鲜素材保留。"""
assets = [
_exhausted_asset("a-exhausted-1"),
_exhausted_asset("a-exhausted-2"),
_fresh_asset("a-fresh-1"),
]
resp = self._call(assets)
returned_ids = {item.id for item in resp.items}
assert "a-fresh-1" in returned_ids
assert "a-exhausted-1" not in returned_ids
assert "a-exhausted-2" not in returned_ids
# total_candidates 是过滤前的候选总数
assert resp.total_candidates == 3
# 返回的素材全部 usable=True
assert all(item.usable for item in resp.items)
def test_all_exhausted_falls_back_to_all(self):
"""全部素材已用尽时回退保留全部(不返回空——空结果会让前端回退全选,
反而绕过评分排序;耗尽素材仍可走复用区间)。"""
assets = [_exhausted_asset("a-ex-1"), _exhausted_asset("a-ex-2")]
resp = self._call(assets)
returned_ids = {item.id for item in resp.items}
assert returned_ids == {"a-ex-1", "a-ex-2"}
assert resp.total_candidates == 2
def test_fresh_assets_all_returned(self):
assets = [_fresh_asset("a-1"), _fresh_asset("a-2")]
resp = self._call(assets)
assert len(resp.items) == 2
assert all(item.usable for item in resp.items)
class TestSmartMatchFlatStructure:
"""P0 回归:smart-match 响应必须扁平——item 顶层直接可读素材字段,
前端 items.map(a => a.id) 不能再拿到 undefined(此前 item.asset 嵌套包装
导致 GET /assets/undefined 404 + from-assets 422,自动模式全链路断裂)。"""
def test_item_id_at_top_level(self):
"""item.id 直接在顶层可读,不存在 item.asset 包装层。"""
assets = [_fresh_asset("a-flat-1"), _fresh_asset("a-flat-2")]
resp = TestSmartMatchFiltersExhausted()._call(assets)
ids = [item.id for item in resp.items]
assert ids == ["a-flat-1", "a-flat-2"]
# 嵌套 asset 字段已移除
assert all(not hasattr(item, "asset") for item in resp.items)
def test_item_is_asset_response_superset(self):
"""条目携带 AssetResponse 全部关键字段 + usable/余量,前端可直接渲染卡片。"""
assets = [_fresh_asset("a-fields-1")]
resp = TestSmartMatchFiltersExhausted()._call(assets)
item = resp.items[0]
assert item.id == "a-fields-1"
assert item.name == "fresh-a-fields-1"
assert item.storage_key == "key/test-asset.mp4"
assert item.mime_type == "video/mp4"
assert item.duration == 60.0
assert item.status == "ready"
assert item.thumbnail_url is None or item.thumbnail_url.startswith("http")
# 余量/可用性字段顶层可读(isAssetUsable 依赖)
assert item.usable is True
assert item.used_duration == 0.0
assert item.available_duration == 60.0
assert item.used_ratio == 0.0
# 评分字段保留
assert 0.0 <= item.score <= 100.0
assert isinstance(item.breakdown, dict)
def test_score_and_breakdown_preserved(self):
"""扁平化后评分字段不丢失。"""
assets = [_fresh_asset("a-score-1")]
resp = TestSmartMatchFiltersExhausted()._call(assets)
item = resp.items[0]
assert isinstance(item.score, float)
assert item.score > 0
assert isinstance(item.breakdown, dict) and item.breakdown