Files
xiaoxia-saas/tests/unit/test_asset_segment_tracker.py
T
CI Bot b2a334f99b
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2m52s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m33s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m42s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 3m52s
AI Code Review / AI Code Review (pull_request) Failing after 4m16s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m24s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m33s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m34s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
fix(segments): AI Review 阻塞项修复 — 记录写入行级锁 + 克隆失败不降级共用
- record_used_segments 读取 AssetModel 加 with_for_update() 行级锁,
  序列化并发事务对 classification_result 的读-改-写,杜绝区间记录丢失更新
  (SQLite 下 with_for_update 为 no-op)
- 批量生成:变体 plan 克隆前置到任务创建之前,全部成功才建任务(无脏数据);
  克隆含 1 次重试抗 DB 抖动,仍失败则 500 中断,不再静默退回共用源 plan
- 预览生成:克隆失败标记任务 failed 并返回 500,不再退回原 plan
  (两条修复共同保证批量/多预览视频内容不重复的业务约束)
- 测试:FakeSession 支持 with_for_update;预览相关测试 mock EditPlanService
  或显式置空 source_edit_plan_id;zip 加 strict(B905)
2026-08-30 02:33:19 +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 == 3
assert REUSE_RATIO_LIMIT == 0.15
assert SEGMENT_EDGE_GAP == 0.3
# ── 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 达到上限(3)的区间不再参与复用;全部达上限返回 None。"""
models = {
"a1": FakeModel(
"a1",
{
"used_time_ranges": [
{"start": 0.0, "end": 10.0, "use_count": 3, "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_fourth_use_rejected(patched_model):
"""同区间复用第 4 次被拒绝:use_count=2 的可复用,use_count=3 的不可复用。"""
models = {
"a1": FakeModel(
"a1",
{
"used_time_ranges": [
{"start": 0.0, "end": 10.0, "use_count": 2, "last_used_at": "2026-03-01T00:00:00"},
{"start": 10.0, "end": 20.0, "use_count": 3, "last_used_at": "2026-01-01T00:00:00"},
]
},
)
}
db = _db(models)
result = find_reusable_range(db, "a1", 5.0, 30.0)
# 只能选 use_count=2 的区间(start=0),不能选 use_count=3 的(虽然它更老)
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