Files
xiaoxia-saas/tests/unit/test_asset_segment_tracker.py
T
xiaoxia a0c14db33c
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m44s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m15s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 36s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m41s
CI/CD Pipeline / Validate - Style (push) Successful in 5m55s
CI/CD Pipeline / Integration Tests (push) Successful in 6m6s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m51s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 9m18s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m45s
CI/CD Pipeline / Validate - Security (push) Successful in 10m34s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m36s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 5m13s
CI/CD Pipeline / Unit Tests (push) Failing after 14m3s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
feat(asset-dedup): 素材去重机制6项优化 — 调参+警告+贪心分配 (#1611)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-01 17:40:34 +08:00

462 lines
15 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.
"""素材片段使用记录追踪 + 受控复用机制测试(asset_segment_tracker.
覆盖:
- get_used_segments 聚合 metadata 中持久化的区间
- record_used_segments 追加新记录(use_count=1,保留原有 metadata 字段)
- record_used_segments 复用同一区间时累加 use_count / 刷新 last_used_at
- remove_used_segment 匹配删除(tolerance + plan_id,旧数据按时间匹配)
- reset_used_segments 清空(其他字段不动)
- find_reusable_range:选最久未用且 use_count<3 的区间;全部达上限返回 None
- make_reuse_callback:返回复用区间、累加 reused_tracker、DB 异常返回 None
- _calc_random_start_time100 次避不开时调用复用回调返回历史区间(不再清空历史)
"""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
import pytest
from app.services import asset_segment_tracker as ast
from app.services.asset_segment_tracker import (
MAX_RANGE_USE_COUNT,
REUSE_RATIO_LIMIT,
SEGMENT_EDGE_GAP,
find_reusable_range,
get_used_segments,
make_reuse_callback,
record_used_segments,
remove_used_segment,
reset_used_segments,
)
from packages.domain.plan_generator_utils import _calc_random_start_time
class FakeModel:
"""模拟 AssetModelid + classification_resultJSON Text+ updated_at。"""
def __init__(self, asset_id: str, meta: dict | None = None):
self.id = asset_id
self.classification_result = json.dumps(meta, ensure_ascii=False) if meta else None
self.updated_at = None
def meta(self) -> dict:
return json.loads(self.classification_result) if self.classification_result else {}
class _InExpr:
def __init__(self, ids, models):
self._ids = ids
self._models = models
def all(self):
return [self._models[i] for i in self._ids if i in self._models]
class _EqExpr:
def __init__(self, target_id, models):
self._target_id = target_id
self._models = models
def with_for_update(self):
# 模拟 SQLAlchemy Query.with_for_update() 链式返回自身
return self
def first(self):
return self._models.get(self._target_id)
class FakeSession:
"""模拟 dbdb.query(Model).filter(Model.id.in_(ids)).all() / .filter(Model.id == id).first()。"""
class _Col:
def __init__(self, models):
self._models = models
def in_(self, ids):
return _InExpr(list(ids), self._models)
def __eq__(self, other):
return _EqExpr(other, self._models)
def __init__(self, models: dict[str, FakeModel]):
self._models = models
self.commits = 0
def query(self, _model):
col = self._Col(self._models)
class _Q:
def filter(self_inner, expr):
return expr
q = _Q()
_model.id = col
return q
def commit(self):
self.commits += 1
@pytest.fixture
def patched_model(monkeypatch):
monkeypatch.setattr(ast, "AssetModel", FakeModel)
def _db(models):
return FakeSession(models)
def _ranges(db, aid="a1"):
model = db._models[aid]
return json.loads(model.classification_result)["used_time_ranges"]
# ── 配置常量 ──────────────────────────────────────────────────────────────────
def test_config_constants():
assert MAX_RANGE_USE_COUNT == 2
assert REUSE_RATIO_LIMIT == 0.10
assert SEGMENT_EDGE_GAP == 1.5
# ── get_used_segments ─────────────────────────────────────────────────────────
def test_get_used_segments_aggregates_ranges(patched_model):
models = {
"a1": FakeModel(
"a1",
{
"used_time_ranges": [
{"start": 1.0, "end": 5.0, "plan_id": "p1", "use_count": 2},
{"start": 9.0, "end": 12.0, "plan_id": "p2"},
]
},
),
"a2": FakeModel("a2", {"other": 1}),
"a3": FakeModel("a3"),
}
db = _db(models)
assert get_used_segments(db, ["a1", "a2", "a3", "missing"]) == {"a1": [(1.0, 5.0), (9.0, 12.0)]}
def test_get_used_segments_empty(patched_model):
assert get_used_segments(_db({}), []) == {}
# ── record_used_segments ──────────────────────────────────────────────────────
def test_record_appends_new_range_with_use_count_one(patched_model):
models = {"a1": FakeModel("a1", {"generation_use_count": 48, "review_status": "pending_review"})}
db = _db(models)
record_used_segments(db, "a1", 12.5, 20.3, "plan-x")
meta = json.loads(models["a1"].classification_result)
assert meta["generation_use_count"] == 48
assert meta["review_status"] == "pending_review"
ranges = meta["used_time_ranges"]
assert len(ranges) == 1
assert ranges[0]["start"] == 12.5 and ranges[0]["end"] == 20.3
assert ranges[0]["plan_id"] == "plan-x"
assert ranges[0]["use_count"] == 1
assert "created_at" in ranges[0] and "last_used_at" in ranges[0]
assert db.commits == 0 # 不自行 commit
def test_record_reuse_same_range_increments_use_count(patched_model):
"""新片段与历史区间高度重叠(复用)→ 累加 use_count,不新增记录。"""
models = {
"a1": FakeModel(
"a1",
{
"used_time_ranges": [
{
"start": 10.0,
"end": 20.0,
"plan_id": "p1",
"use_count": 1,
"created_at": "2026-01-01T00:00:00+00:00",
"last_used_at": "2026-01-01T00:00:00+00:00",
},
]
},
)
}
db = _db(models)
# 同一起点复用(find_reusable_range 返回的就是历史区间起点)
record_used_segments(db, "a1", 10.0, 20.0, "p2")
ranges = _ranges(db)
assert len(ranges) == 1
assert ranges[0]["use_count"] == 2
assert ranges[0]["last_used_at"] != "2026-01-01T00:00:00+00:00"
def test_record_distinct_range_appends(patched_model):
models = {
"a1": FakeModel(
"a1",
{
"used_time_ranges": [
{"start": 10.0, "end": 20.0, "plan_id": "p1", "use_count": 1},
]
},
)
}
db = _db(models)
record_used_segments(db, "a1", 25.0, 35.0, "p2")
ranges = _ranges(db)
assert len(ranges) == 2
assert ranges[1]["use_count"] == 1
def test_record_missing_asset_no_raise(patched_model):
db = _db({})
record_used_segments(db, "ghost", 1.0, 2.0, "p") # 不抛异常
# ── remove_used_segment ───────────────────────────────────────────────────────
def test_remove_matching_range(patched_model):
models = {
"a1": FakeModel(
"a1",
{
"used_time_ranges": [
{"start": 1.0, "end": 5.0, "plan_id": "p1"},
{"start": 9.0, "end": 12.0, "plan_id": "p2"},
]
},
)
}
db = _db(models)
assert remove_used_segment(db, "a1", 1.0, 5.0, plan_id="p1") is True
assert len(_ranges(db)) == 1
assert _ranges(db)[0]["start"] == 9.0
def test_remove_plan_mismatch_keeps_range(patched_model):
models = {"a1": FakeModel("a1", {"used_time_ranges": [{"start": 1.0, "end": 5.0, "plan_id": "p1"}]})}
db = _db(models)
assert remove_used_segment(db, "a1", 1.0, 5.0, plan_id="other") is False
assert len(_ranges(db)) == 1
def test_remove_legacy_range_without_plan_id(patched_model):
"""旧数据记录缺 plan_id → 按时间匹配可删除。"""
models = {
"a1": FakeModel(
"a1",
{
"used_time_ranges": [
{"start": 2.0, "end": 12.0, "created_at": "2026-01-01T00:00:00"},
]
},
)
}
db = _db(models)
assert remove_used_segment(db, "a1", 2.0, 12.0, plan_id="plan-new") is True
assert _ranges(db) == []
# ── reset_used_segments(仅运维/测试)─────────────────────────────────────────
def test_reset_clears_ranges_keeps_other_fields(patched_model):
models = {
"a1": FakeModel(
"a1",
{
"generation_use_count": 3,
"used_time_ranges": [
{"start": 1.0, "end": 5.0},
],
},
)
}
db = _db(models)
reset_used_segments(db, "a1")
meta = json.loads(models["a1"].classification_result)
assert meta["used_time_ranges"] == []
assert meta["generation_use_count"] == 3
# ── find_reusable_range:受控复用选择 ─────────────────────────────────────────
def test_find_reusable_prefers_oldest_unused(patched_model):
"""选 last_used_at 最老、use_count 未达上限的区间;能容纳 clip_duration。"""
models = {
"a1": FakeModel(
"a1",
{
"used_time_ranges": [
{"start": 0.0, "end": 8.0, "use_count": 1, "last_used_at": "2026-08-01T00:00:00+00:00"},
{
"start": 10.0,
"end": 20.0,
"use_count": 1,
"last_used_at": "2026-01-01T00:00:00+00:00",
}, # 最久未用
]
},
)
}
db = _db(models)
result = find_reusable_range(db, "a1", clip_duration=5.0, asset_total=30.0)
assert result is not None
start, end = result
assert start == 10.0 and end == 15.0
def test_find_reusable_excludes_max_use_count(patched_model):
"""use_count 达到上限(2)的区间不再参与复用;全部达上限返回 None。"""
models = {
"a1": FakeModel(
"a1",
{
"used_time_ranges": [
{"start": 0.0, "end": 10.0, "use_count": 2, "last_used_at": "2026-01-01T00:00:00"},
]
},
)
}
db = _db(models)
assert find_reusable_range(db, "a1", 5.0, 30.0) is None
def test_find_reusable_third_use_rejected(patched_model):
"""同区间复用第 3 次被拒绝:use_count=1 的可复用,use_count=2 的不可复用。"""
models = {
"a1": FakeModel(
"a1",
{
"used_time_ranges": [
{"start": 0.0, "end": 10.0, "use_count": 1, "last_used_at": "2026-03-01T00:00:00"},
{"start": 10.0, "end": 20.0, "use_count": 2, "last_used_at": "2026-01-01T00:00:00"},
]
},
)
}
db = _db(models)
result = find_reusable_range(db, "a1", 5.0, 30.0)
# 只能选 use_count=1 的区间(start=0),不能选 use_count=2 的(虽然它更老)
assert result is not None and result[0] == 0.0
def test_find_reusable_clamps_to_asset_bounds(patched_model):
"""历史区间起点 + clip_duration 会越素材末尾时,起点钳制到 max_start。"""
models = {
"a1": FakeModel(
"a1",
{
"used_time_ranges": [
{"start": 25.0, "end": 30.0, "use_count": 1, "last_used_at": "2026-01-01T00:00:00"},
]
},
)
}
db = _db(models)
result = find_reusable_range(db, "a1", clip_duration=10.0, asset_total=30.0)
assert result is not None
start, end = result
assert end <= 30.0 + 1e-6 and start >= 0.0
def test_find_reusable_no_ranges_returns_none(patched_model):
models = {"a1": FakeModel("a1", {"other": 1})}
db = _db(models)
assert find_reusable_range(db, "a1", 5.0, 30.0) is None
# ── make_reuse_callback ───────────────────────────────────────────────────────
def test_reuse_callback_returns_range_and_tracks_duration(patched_model):
models = {
"a1": FakeModel(
"a1",
{
"used_time_ranges": [
{"start": 10.0, "end": 20.0, "use_count": 1, "last_used_at": "2026-01-01T00:00:00"},
]
},
)
}
db = _db(models)
reused: dict[str, float] = {}
cb = make_reuse_callback(db, {"a1": 30.0}, reused)
result = cb("a1", 8.0)
assert result is not None and result[0] == 10.0
assert reused["a1"] == 8.0 # 复用时长累加
def test_reuse_callback_db_error_returns_none(patched_model):
class BoomSession:
def query(self, _m):
raise RuntimeError("db down")
reused: dict[str, float] = {}
cb = make_reuse_callback(BoomSession(), {"a1": 30.0}, reused)
assert cb("a1", 8.0) is None # 异常被吞,返回 None
assert reused == {}
# ── _calc_random_start_time 与受控回调集成 ────────────────────────────────────
def test_calc_random_start_uses_reuse_callback_when_exhausted(monkeypatch):
"""素材区间被占满、100 次随机找不到空位时,调用复用回调返回历史区间。"""
import packages.domain.plan_generator_utils as pgu
monkeypatch.setattr(pgu.random, "uniform", lambda a, b: 0.5) # 固定候选点必撞区间
durations = {"a1": 30.0}
used = {"a1": [(0.0, 30.0)]} # 全占满
calls = []
def reuse_cb(asset_id, clip_duration):
calls.append((asset_id, clip_duration))
return (10.0, 18.0)
result = _calc_random_start_time("a1", 8.0, durations, used, on_exhausted=reuse_cb)
assert calls == [("a1", 8.0)]
assert result == 10.0
def test_calc_random_start_reuse_callback_none_returns_none(monkeypatch):
"""复用回调返回 None(区间全部达上限/复用占比超闸门)→ calc 返回 None。
新机制下不做末尾/0.0 重叠降级(那会把片段放回已用过的画面),
由调用方轮询下一个素材或报 400;历史记录不被清空。
"""
import packages.domain.plan_generator_utils as pgu
monkeypatch.setattr(pgu.random, "uniform", lambda a, b: 0.5)
durations = {"a1": 30.0}
used = {"a1": [(0.0, 30.0)]}
used_before = list(used["a1"])
result = _calc_random_start_time("a1", 8.0, durations, used, on_exhausted=lambda aid, d: None)
assert result is None
assert used["a1"] == used_before # 历史记录未被清空
def test_calc_random_start_no_callback_backward_compatible(monkeypatch):
"""不传 on_exhausted 时行为与旧版兼容(100 次失败走降级)。"""
import packages.domain.plan_generator_utils as pgu
monkeypatch.setattr(pgu.random, "uniform", lambda a, b: 0.5)
result = _calc_random_start_time("a1", 8.0, {"a1": 30.0}, {"a1": [(0.0, 30.0)]})
assert result is not None