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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
462 lines
15 KiB
Python
462 lines
15 KiB
Python
"""素材片段使用记录追踪 + 受控复用机制测试(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_time:100 次避不开时调用复用回调返回历史区间(不再清空历史)
|
||
"""
|
||
|
||
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:
|
||
"""模拟 AssetModel:id + classification_result(JSON 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:
|
||
"""模拟 db:db.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
|