eeadf6de76
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
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 / Build Staging API Image (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 / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 58s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m13s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m41s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m59s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 5m18s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 6m15s
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
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
- points_rules: VIRAL_VIDEO_MODEL_CONFIG统一配置5模型(seedance-2.5/2.0/2.0-fast/2.0-mini/wan-3.0) - 所有Seedance模型supports_audio=True(更正Fast支持音频) - 双计费模式: token(Seedance按元/百万tokens)+per_second(Wan 3.0按元/秒) - seedance-2.0支持4k(2160短边); fast/mini限480p/720p - list_viral_video_models()按dashscope_available过滤wan-3.0 - _match_model_prefix按key长度倒序匹配避免fast/mini被前缀误匹配 - ai_client: _resolve_video_provider_and_id()返回(provider, model_id, cfg)三元组 - provider=dashscope时路由到新dashscope_client; doubao走原有火山方舟 - dashscope_client(新): DashScope异步API(X-DashScope-Async:enable) - POST创建→GET轮询SUCCEEDED/FAILED→httpx.stream下载 - get_dashscope_client()单例;无API key返回None(隐藏wan-3.0) - worker: gen_audio按模型cfg.supports_audio动态取值;日志打印provider - routes: GET /api/v1/viral-video/models返回前端模型选择器数据 - 单测: 6个模型×分辨率积分断言、DashScope happy/error路径、/models端点、provider分发
644 lines
26 KiB
Python
644 lines
26 KiB
Python
"""积分消耗规则单元测试 (#1895) — v1.6.2: 仅保留 voice_clone 相关"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
|
||
import pytest
|
||
|
||
from packages.domain.points_rules import (
|
||
FREE_USER_MULTIPLIER,
|
||
MEMBER_DISCOUNT,
|
||
MEMBERSHIP_PRICES,
|
||
POINTS_PACKAGES,
|
||
POINTS_SCENES,
|
||
calculate_points_cost,
|
||
)
|
||
|
||
|
||
class TestPointsScenesConfig:
|
||
"""场景配置完整性"""
|
||
|
||
def test_registered_scenes_include_voice_clone_and_viral_video(self):
|
||
"""场景配置:包含声音克隆(训练/合成)+ 爆款视频(动态定价)。"""
|
||
assert {"voice_clone_train", "voice_clone_synth", "viral_video"}.issubset(set(POINTS_SCENES.keys()))
|
||
|
||
def test_viral_video_scene_is_dynamic_with_zero_base(self):
|
||
"""viral_video 必须注册但 base_points=0 且 dynamic=True,不使用 @points_gate。"""
|
||
vv = POINTS_SCENES["viral_video"]
|
||
assert vv["base_points"] == 0
|
||
assert vv["dynamic"] is True
|
||
assert vv["unit"] == "次"
|
||
assert vv["name"] == "爆款视频"
|
||
|
||
def test_voice_clone_scenes_defined(self):
|
||
# 保留声音克隆两个场景
|
||
assert "voice_clone_train" in POINTS_SCENES
|
||
assert "voice_clone_synth" in POINTS_SCENES
|
||
|
||
def test_required_keys_present(self):
|
||
for key, scene in POINTS_SCENES.items():
|
||
assert "base_points" in scene, f"{key} missing base_points"
|
||
assert "unit" in scene, f"{key} missing unit"
|
||
assert "name" in scene, f"{key} missing name"
|
||
|
||
def test_voice_clone_train_is_free(self):
|
||
assert POINTS_SCENES["voice_clone_train"]["base_points"] == 0
|
||
|
||
def test_voice_clone_synth_is_per_minute(self):
|
||
assert POINTS_SCENES["voice_clone_synth"]["base_points"] == 1
|
||
assert POINTS_SCENES["voice_clone_synth"]["unit"] == "分钟"
|
||
|
||
def test_calculate_points_cost_returns_zero_for_dynamic_viral_video(self):
|
||
"""calculate_points_cost 对动态场景 viral_video 必须返回 0(由业务侧手动计算)。"""
|
||
assert calculate_points_cost("viral_video", is_member=False) == 0.0
|
||
assert calculate_points_cost("viral_video", is_member=True, member_type="monthly") == 0.0
|
||
|
||
|
||
class TestPointsPackages:
|
||
def test_three_packages(self):
|
||
assert len(POINTS_PACKAGES) == 3
|
||
assert POINTS_PACKAGES["starter_pack"]["points"] == 100
|
||
assert POINTS_PACKAGES["basic_pack"]["points"] == 500
|
||
assert POINTS_PACKAGES["pro_pack"]["points"] == 2000
|
||
|
||
|
||
class TestMembershipPrices:
|
||
def test_three_plans(self):
|
||
assert len(MEMBERSHIP_PRICES) == 3
|
||
assert MEMBERSHIP_PRICES["monthly"]["price_cents"] == 1990
|
||
assert MEMBERSHIP_PRICES["quarterly"]["duration_days"] == 90
|
||
assert MEMBERSHIP_PRICES["yearly"]["price_cents"] == 15900
|
||
|
||
|
||
class TestCalculatePointsCost:
|
||
"""核心计费逻辑"""
|
||
|
||
# ── 声音克隆合成(按时长计费) ──
|
||
|
||
def test_voice_clone_synth_base(self):
|
||
cost = calculate_points_cost("voice_clone_synth", is_member=False, duration_minutes=3)
|
||
assert cost == math.ceil(3 * FREE_USER_MULTIPLIER)
|
||
|
||
def test_voice_clone_synth_rounds_up(self):
|
||
cost = calculate_points_cost("voice_clone_synth", is_member=False, duration_minutes=2.3)
|
||
assert cost == math.ceil(3 * FREE_USER_MULTIPLIER)
|
||
|
||
def test_voice_clone_synth_minimum_1_minute(self):
|
||
cost = calculate_points_cost("voice_clone_synth", is_member=False, duration_minutes=0.1)
|
||
assert cost == math.ceil(1 * FREE_USER_MULTIPLIER)
|
||
|
||
# ── 免费场景 ──
|
||
|
||
def test_voice_clone_train_free(self):
|
||
cost = calculate_points_cost("voice_clone_train", is_member=False)
|
||
assert cost == 0
|
||
|
||
def test_voice_clone_train_free_for_member(self):
|
||
cost = calculate_points_cost("voice_clone_train", is_member=True)
|
||
assert cost == 0
|
||
|
||
# ── 会员折扣 ──
|
||
|
||
def test_monthly_member_discount(self):
|
||
cost = calculate_points_cost("voice_clone_synth", is_member=True, duration_minutes=1, member_type="monthly")
|
||
assert cost == max(1, math.floor(1 * MEMBER_DISCOUNT["monthly"]))
|
||
|
||
def test_yearly_member_deep_discount(self):
|
||
cost = calculate_points_cost(
|
||
"voice_clone_synth",
|
||
is_member=True,
|
||
duration_minutes=2,
|
||
member_type="yearly",
|
||
)
|
||
assert cost == max(1, math.floor(2 * MEMBER_DISCOUNT["yearly"]))
|
||
|
||
def test_member_without_type_no_discount(self):
|
||
cost = calculate_points_cost("voice_clone_synth", is_member=True, duration_minutes=1)
|
||
assert cost == 1
|
||
|
||
# ── 已下线/未知场景(向后兼容:返回 0) ──
|
||
|
||
@pytest.mark.parametrize(
|
||
"scene",
|
||
[
|
||
"ai_voice",
|
||
"ai_video",
|
||
"ai_digital_human",
|
||
"ai_rewrite",
|
||
"ai_cover",
|
||
"ai_title",
|
||
"douyin_extract",
|
||
"nonexistent",
|
||
],
|
||
)
|
||
def test_retired_scenes_return_zero(self, scene):
|
||
assert calculate_points_cost(scene, is_member=False) == 0
|
||
assert calculate_points_cost(scene, is_member=True, duration_minutes=10) == 0
|
||
|
||
|
||
# ============ 爆款视频动态定价 (#2151) ============
|
||
|
||
|
||
class TestResolveVideoDimensions:
|
||
"""resolve_video_dimensions(): 分辨率别名、比例、默认兜底。"""
|
||
|
||
def test_1080p_16_9(self):
|
||
"""1080p + 16:9 → w=1920, h=1080。"""
|
||
from packages.domain.points_rules import resolve_video_dimensions
|
||
|
||
w, h = resolve_video_dimensions("1080p", "16:9")
|
||
assert (w, h) == (1920, 1080)
|
||
|
||
def test_480p_16_9(self):
|
||
"""480p + 16:9 → 854×480(ceil(480*16/9)=854,偶对齐)。"""
|
||
from packages.domain.points_rules import resolve_video_dimensions
|
||
|
||
w, h = resolve_video_dimensions("480p", "16:9")
|
||
assert (w, h) == (854, 480)
|
||
|
||
def test_720p_1_1(self):
|
||
"""1:1 正方形 → w == h。"""
|
||
from packages.domain.points_rules import resolve_video_dimensions
|
||
|
||
w, h = resolve_video_dimensions("720p", "1:1")
|
||
assert (w, h) == (720, 720)
|
||
|
||
def test_1080p_1_1(self):
|
||
from packages.domain.points_rules import resolve_video_dimensions
|
||
|
||
w, h = resolve_video_dimensions("1080p", "1:1")
|
||
assert (w, h) == (1080, 1080)
|
||
|
||
def test_resolution_aliases(self):
|
||
"""中文/英文别名应正确映射到对应高度。"""
|
||
from packages.domain.points_rules import resolve_video_dimensions
|
||
|
||
cases = [
|
||
("普清", 480),
|
||
("sd", 480),
|
||
("low", 480),
|
||
("default", 480),
|
||
("高清", 720),
|
||
("medium", 720),
|
||
("hd", 720),
|
||
("超清", 1080),
|
||
("fhd", 1080),
|
||
("ultra", 1080),
|
||
("全能", 1080),
|
||
("high", 1080),
|
||
]
|
||
for alias, expected_h in cases:
|
||
_, h = resolve_video_dimensions(alias, "1:1")
|
||
assert h == expected_h, f"{alias} -> h={h}, expected {expected_h}"
|
||
|
||
def test_unknown_resolution_falls_back_to_720p(self):
|
||
"""未知分辨率字符串兜底到 720p。"""
|
||
from packages.domain.points_rules import resolve_video_dimensions
|
||
|
||
w, h = resolve_video_dimensions("garbage-xxx", "1:1")
|
||
assert h == 720
|
||
assert w == 720
|
||
|
||
def test_4k_16_9(self):
|
||
"""#2159 4k 横屏:短边=height=2160,width=3840。"""
|
||
from packages.domain.points_rules import resolve_video_dimensions
|
||
|
||
w, h = resolve_video_dimensions("4k", "16:9")
|
||
assert (w, h) == (3840, 2160)
|
||
|
||
def test_2160p_alias(self):
|
||
"""2160p 别名→4k。"""
|
||
from packages.domain.points_rules import resolve_video_dimensions
|
||
|
||
w, h = resolve_video_dimensions("2160p", "9:16")
|
||
assert (w, h) == (2160, 3840)
|
||
|
||
def test_empty_resolution_defaults_to_720p_9_16(self):
|
||
"""空 resolution + 空 ratio → 默认 720p + 9:16 竖屏 (720×1280)。"""
|
||
from packages.domain.points_rules import resolve_video_dimensions
|
||
|
||
w, h = resolve_video_dimensions("", "")
|
||
assert (w, h) == (720, 1280)
|
||
|
||
def test_none_resolution_default_ratio(self):
|
||
"""None resolution + None ratio → 720p + 9:16 竖屏默认。"""
|
||
from packages.domain.points_rules import resolve_video_dimensions
|
||
|
||
w, h = resolve_video_dimensions(None, None)
|
||
assert (w, h) == (720, 1280)
|
||
|
||
def test_720p_9_16_portrait(self):
|
||
"""720p + 9:16 竖屏 → 短边是 width=720,height=1280(v10实测)。"""
|
||
from packages.domain.points_rules import resolve_video_dimensions
|
||
|
||
w, h = resolve_video_dimensions("720p", "9:16")
|
||
assert (w, h) == (720, 1280)
|
||
|
||
def test_1080p_9_16_portrait(self):
|
||
"""1080p + 9:16 竖屏 → 1080×1920。"""
|
||
from packages.domain.points_rules import resolve_video_dimensions
|
||
|
||
w, h = resolve_video_dimensions("1080p", "9:16")
|
||
assert (w, h) == (1080, 1920)
|
||
|
||
def test_480p_9_16_portrait(self):
|
||
"""480p + 9:16 竖屏 → 480×854。"""
|
||
from packages.domain.points_rules import resolve_video_dimensions
|
||
|
||
w, h = resolve_video_dimensions("480p", "9:16")
|
||
assert (w, h) == (480, 854)
|
||
|
||
def test_all_dimensions_even(self):
|
||
"""所有返回尺寸都应是偶数(视频编码要求)。"""
|
||
from packages.domain.points_rules import resolve_video_dimensions
|
||
|
||
for res in ("480p", "720p", "1080p", "普清", "高清", "超清"):
|
||
for ratio in ("16:9", "9:16", "1:1"):
|
||
w, h = resolve_video_dimensions(res, ratio)
|
||
assert w % 2 == 0 and h % 2 == 0, f"{res}/{ratio} -> ({w},{h}) not even"
|
||
|
||
def test_whitespace_resolution_case_insensitive(self):
|
||
"""前后空格 + 大写应被规范化处理。"""
|
||
from packages.domain.points_rules import resolve_video_dimensions
|
||
|
||
w, h = resolve_video_dimensions(" 1080P ", " 16:9 ")
|
||
assert (w, h) == (1920, 1080)
|
||
|
||
|
||
class TestMatchModelPrefix:
|
||
"""_match_model_prefix() 前缀匹配 + 兜底。"""
|
||
|
||
def test_seedance_2_5_exact(self):
|
||
from packages.domain.points_rules import _match_model_prefix
|
||
|
||
assert _match_model_prefix("seedance-2.5") == "seedance-2.5"
|
||
|
||
def test_seedance_2_5_with_variant(self):
|
||
"""带后缀版本号(如 seedance-2.5-pro)仍匹配 seedance-2.5。"""
|
||
from packages.domain.points_rules import _match_model_prefix
|
||
|
||
assert _match_model_prefix("seedance-2.5-pro") == "seedance-2.5"
|
||
|
||
def test_seedance_2_0_exact(self):
|
||
from packages.domain.points_rules import _match_model_prefix
|
||
|
||
assert _match_model_prefix("seedance-2.0") == "seedance-2.0"
|
||
|
||
def test_seedance_2_0_with_variant(self):
|
||
from packages.domain.points_rules import _match_model_prefix
|
||
|
||
assert _match_model_prefix("seedance-2.0-lite") == "seedance-2.0"
|
||
|
||
def test_unknown_model_falls_back_to_2_5(self):
|
||
"""未知模型前缀兜底 seedance-2.5。"""
|
||
from packages.domain.points_rules import _match_model_prefix
|
||
|
||
assert _match_model_prefix("kling-v1") == "seedance-2.5"
|
||
assert _match_model_prefix("") == "seedance-2.5"
|
||
assert _match_model_prefix(None) == "seedance-2.5"
|
||
|
||
def test_case_insensitive(self):
|
||
from packages.domain.points_rules import _match_model_prefix
|
||
|
||
assert _match_model_prefix("SEEDANCE-2.0") == "seedance-2.0"
|
||
|
||
|
||
class TestInferResolutionKey:
|
||
"""_infer_resolution_key(w, h): 按短边 1000+/650-999/<650 三档。"""
|
||
|
||
def test_short_side_ge_1000_is_1080p(self):
|
||
from packages.domain.points_rules import _infer_resolution_key
|
||
|
||
assert _infer_resolution_key(1920, 1080) == "1080p" # 横屏
|
||
assert _infer_resolution_key(1080, 1920) == "1080p" # 竖屏
|
||
assert _infer_resolution_key(1080, 1080) == "1080p" # 方屏
|
||
|
||
def test_short_side_650_to_999_is_720p(self):
|
||
from packages.domain.points_rules import _infer_resolution_key
|
||
|
||
assert _infer_resolution_key(1280, 720) == "720p"
|
||
assert _infer_resolution_key(720, 1280) == "720p"
|
||
assert _infer_resolution_key(720, 720) == "720p"
|
||
|
||
def test_short_side_lt_650_is_480p(self):
|
||
from packages.domain.points_rules import _infer_resolution_key
|
||
|
||
assert _infer_resolution_key(854, 480) == "480p"
|
||
assert _infer_resolution_key(480, 854) == "480p"
|
||
assert _infer_resolution_key(480, 480) == "480p"
|
||
# 极小值兜底
|
||
assert _infer_resolution_key(1, 1) == "480p"
|
||
|
||
def test_portrait_1280_height_is_720p_short_side(self):
|
||
"""竖屏 720×1280 短边=720,应识别为 720p 而非 1080p(老bug回归)。"""
|
||
from packages.domain.points_rules import _infer_resolution_key
|
||
|
||
assert _infer_resolution_key(720, 1280) == "720p"
|
||
|
||
|
||
class TestCalculateViralVideoCredits:
|
||
"""calculate_viral_video_credits():爆款视频动态定价核心函数。"""
|
||
|
||
def test_default_args_returns_float(self):
|
||
"""默认参数返回 float。"""
|
||
from packages.domain.points_rules import calculate_viral_video_credits
|
||
|
||
credits = calculate_viral_video_credits(15, 1280, 720)
|
||
assert isinstance(credits, float)
|
||
|
||
def test_return_is_rounded_to_two_decimals(self):
|
||
"""round(..., 2) 后值本身就是两位小数(再 round 不变化)。"""
|
||
from packages.domain.points_rules import calculate_viral_video_credits
|
||
|
||
for dur, w, h in [(15, 1280, 720), (5, 854, 480), (30, 1920, 1080), (10, 720, 720)]:
|
||
credits = calculate_viral_video_credits(dur, w, h)
|
||
assert round(credits, 2) == credits
|
||
|
||
def test_has_video_input_uses_lower_price(self):
|
||
"""has_video_input=True 时使用参考视频价格(有视频输入便宜)。"""
|
||
from packages.domain.points_rules import calculate_viral_video_credits
|
||
|
||
no_input = calculate_viral_video_credits(15, 1280, 720, model="seedance-2.5", has_video_input=False)
|
||
with_input = calculate_viral_video_credits(15, 1280, 720, model="seedance-2.5", has_video_input=True)
|
||
assert with_input < no_input
|
||
|
||
def test_unknown_model_falls_back_to_seedance_2_5(self):
|
||
"""未知 model 前缀兜底到 seedance-2.5 价格,与默认等价。"""
|
||
from packages.domain.points_rules import calculate_viral_video_credits
|
||
|
||
unknown = calculate_viral_video_credits(15, 1280, 720, model="unknown-model")
|
||
default = calculate_viral_video_credits(15, 1280, 720, model="seedance-2.5")
|
||
assert unknown == default
|
||
|
||
def test_actual_tokens_overrides_calculation(self):
|
||
"""传入 actual_tokens>0 时用它替代公式计算的 tokens。"""
|
||
from packages.domain.points_rules import (
|
||
VIRAL_VIDEO_FIXED_COST,
|
||
VIRAL_VIDEO_MODEL_PRICES,
|
||
VIRAL_VIDEO_PROFIT_MULTIPLIER,
|
||
calculate_viral_video_credits,
|
||
)
|
||
|
||
price = VIRAL_VIDEO_MODEL_PRICES[("seedance-2.5", "720p", False)]
|
||
actual_tokens = 2_000_000
|
||
expected = round(
|
||
(actual_tokens / 1_000_000 * price + VIRAL_VIDEO_FIXED_COST) * VIRAL_VIDEO_PROFIT_MULTIPLIER, 2
|
||
)
|
||
credits = calculate_viral_video_credits(15, 1280, 720, actual_tokens=actual_tokens)
|
||
assert credits == expected
|
||
|
||
def test_zero_duration_width_height_defensive_max1(self):
|
||
"""duration/width/height 为 0/None 时 max(1,...) 防御,结果>0。"""
|
||
from packages.domain.points_rules import calculate_viral_video_credits
|
||
|
||
c_zero = calculate_viral_video_credits(0, 0, 0)
|
||
assert c_zero > 0
|
||
c_none = calculate_viral_video_credits(None, None, None)
|
||
assert c_none > 0
|
||
c_one = calculate_viral_video_credits(1, 1, 1)
|
||
assert c_none == c_one
|
||
|
||
def test_non_default_fps_affects_tokens(self):
|
||
"""fps 非默认值(30) 应比默认(24) 积分高。"""
|
||
from packages.domain.points_rules import calculate_viral_video_credits
|
||
|
||
c24 = calculate_viral_video_credits(15, 1280, 720, fps=24)
|
||
c30 = calculate_viral_video_credits(15, 1280, 720, fps=30)
|
||
assert c30 > c24
|
||
|
||
def test_seedance_2_0_priced_lower_than_2_5_at_1080p(self):
|
||
"""seedance-2.0 在 1080p 无视频输入时定价低于 seedance-2.5。"""
|
||
from packages.domain.points_rules import calculate_viral_video_credits
|
||
|
||
c20 = calculate_viral_video_credits(15, 1920, 1080, model="seedance-2.0", has_video_input=False)
|
||
c25 = calculate_viral_video_credits(15, 1920, 1080, model="seedance-2.5", has_video_input=False)
|
||
assert c20 < c25
|
||
|
||
def test_formula_includes_fixed_cost_and_multiplier(self):
|
||
"""手算公式结果应与函数返回一致(固定成本 + 利润系数)。"""
|
||
from packages.domain.points_rules import (
|
||
VIRAL_VIDEO_FIXED_COST,
|
||
VIRAL_VIDEO_FPS,
|
||
VIRAL_VIDEO_MODEL_PRICES,
|
||
VIRAL_VIDEO_PROFIT_MULTIPLIER,
|
||
calculate_viral_video_credits,
|
||
)
|
||
|
||
dur, w, h = 10, 1280, 720
|
||
price = VIRAL_VIDEO_MODEL_PRICES[("seedance-2.5", "720p", False)]
|
||
tokens = dur * w * h * VIRAL_VIDEO_FPS / 1024.0
|
||
expected = round((tokens / 1_000_000 * price + VIRAL_VIDEO_FIXED_COST) * VIRAL_VIDEO_PROFIT_MULTIPLIER, 2)
|
||
assert calculate_viral_video_credits(dur, w, h) == expected
|
||
|
||
def test_seedance_2_0_with_video_input_falls_back_to_seedance_2_5_price(self):
|
||
"""seedance-2.0 + has_video_input=True 组合不在价格表,走 line 111 fallback 到 seedance-2.5 的 720p False 价格。"""
|
||
from packages.domain.points_rules import (
|
||
VIRAL_VIDEO_FIXED_COST,
|
||
VIRAL_VIDEO_FPS,
|
||
VIRAL_VIDEO_MODEL_PRICES,
|
||
VIRAL_VIDEO_PROFIT_MULTIPLIER,
|
||
calculate_viral_video_credits,
|
||
)
|
||
|
||
dur, w, h = 10, 1280, 720
|
||
# 兜底价格 = seedance-2.5/720p/False = 70.0
|
||
price = VIRAL_VIDEO_MODEL_PRICES[("seedance-2.5", "720p", False)]
|
||
assert price == 70.0
|
||
tokens = dur * w * h * VIRAL_VIDEO_FPS / 1024.0
|
||
expected = round((tokens / 1_000_000 * price + VIRAL_VIDEO_FIXED_COST) * VIRAL_VIDEO_PROFIT_MULTIPLIER, 2)
|
||
credits = calculate_viral_video_credits(dur, w, h, model="seedance-2.0", has_video_input=True)
|
||
assert credits == expected
|
||
|
||
def test_fps_zero_or_none_falls_back_to_default(self):
|
||
"""fps=0/None 时 int(fps or 24) 兜底到默认 24,结果与 fps=24 一致。"""
|
||
from packages.domain.points_rules import calculate_viral_video_credits
|
||
|
||
c_default = calculate_viral_video_credits(10, 1280, 720, fps=24)
|
||
c_zero = calculate_viral_video_credits(10, 1280, 720, fps=0)
|
||
c_none = calculate_viral_video_credits(10, 1280, 720, fps=None)
|
||
assert c_zero == c_default
|
||
assert c_none == c_default
|
||
|
||
# ──────── P0 计费回归:短边规则 + 价格精确断言 ────────
|
||
|
||
def test_15s_720p_portrait_is_29_68(self):
|
||
"""P0 回归:15s/720p/9:16 竖屏 (720×1280) 必须 =29.68 积分。"""
|
||
from packages.domain.points_rules import (
|
||
calculate_viral_video_credits,
|
||
resolve_video_dimensions,
|
||
)
|
||
|
||
w, h = resolve_video_dimensions("720p", "9:16")
|
||
assert (w, h) == (720, 1280)
|
||
assert calculate_viral_video_credits(15, w, h) == 29.68
|
||
|
||
def test_30s_1080p_portrait_is_146_14(self):
|
||
"""P0 回归:30s/1080p/9:16 竖屏 (1080×1920) =146.14 积分。"""
|
||
from packages.domain.points_rules import (
|
||
calculate_viral_video_credits,
|
||
resolve_video_dimensions,
|
||
)
|
||
|
||
w, h = resolve_video_dimensions("1080p", "9:16")
|
||
assert (w, h) == (1080, 1920)
|
||
assert calculate_viral_video_credits(30, w, h) == 146.14
|
||
|
||
def test_portrait_landscape_same_pixels_same_price(self):
|
||
"""相同像素数(横竖屏旋转)积分一致。"""
|
||
from packages.domain.points_rules import calculate_viral_video_credits
|
||
|
||
assert calculate_viral_video_credits(15, 1280, 720) == calculate_viral_video_credits(15, 720, 1280)
|
||
|
||
|
||
class TestViralVideoCreditsWithBreakdown:
|
||
"""calculate_viral_video_credits_with_breakdown:返回 (credits, breakdown_dict)。"""
|
||
|
||
def test_returns_credits_matching_plain_version(self):
|
||
"""新函数返回的 credits 必须与 calculate_viral_video_credits 完全一致,且 breakdown 字段齐全。"""
|
||
from packages.domain.points_rules import (
|
||
calculate_viral_video_credits,
|
||
calculate_viral_video_credits_with_breakdown,
|
||
)
|
||
|
||
for dur, w, h, model, hvi in [
|
||
(15, 1280, 720, "seedance-2.5", False),
|
||
(10, 720, 1280, "seedance-2.0", False),
|
||
(30, 1920, 1080, "seedance-2.5", False),
|
||
(5, 480, 480, "", False),
|
||
]:
|
||
c1 = calculate_viral_video_credits(dur, w, h, model=model, has_video_input=hvi)
|
||
c2, bd = calculate_viral_video_credits_with_breakdown(dur, w, h, model=model, has_video_input=hvi)
|
||
assert c1 == c2
|
||
assert isinstance(bd, dict)
|
||
for key in (
|
||
"tokens",
|
||
"video_cost",
|
||
"fixed_cost",
|
||
"profit_multiplier",
|
||
"model_price",
|
||
"width",
|
||
"height",
|
||
"fps",
|
||
):
|
||
assert key in bd, f"breakdown missing key: {key}"
|
||
assert bd["fixed_cost"] == 0.15
|
||
assert bd["profit_multiplier"] == 1.3
|
||
assert bd["width"] == w
|
||
assert bd["height"] == h
|
||
assert bd["fps"] == 24
|
||
assert bd["tokens"] > 0
|
||
assert bd["model_price"] > 0
|
||
expected = round((bd["video_cost"] + bd["fixed_cost"]) * bd["profit_multiplier"], 2)
|
||
assert expected == c2
|
||
|
||
def test_actual_tokens_overrides_computed(self):
|
||
"""actual_tokens 传入时应覆盖按公式计算的 tokens。"""
|
||
from packages.domain.points_rules import calculate_viral_video_credits_with_breakdown
|
||
|
||
c, bd = calculate_viral_video_credits_with_breakdown(
|
||
15,
|
||
1280,
|
||
720,
|
||
actual_tokens=1_000_000,
|
||
)
|
||
assert bd["tokens"] == 1_000_000.0
|
||
# video_cost = 1M/1M * 70 = 70; total = (70+0.15)*1.3 = 91.195 → 91.20
|
||
assert c == 91.20
|
||
|
||
|
||
# ============ #2159 多模型定价单测 ============
|
||
|
||
|
||
class TestMultiModelCredits:
|
||
"""#2159 多模型积分估算正确性(含 token/second 两种计费模式)。"""
|
||
|
||
def test_seedance_2_5_15s_720p_9x16(self):
|
||
from packages.domain.points_rules import calculate_viral_video_credits
|
||
|
||
# 15s/720p/9:16 → 720×1280
|
||
# tokens = 15*720*1280*24/1024 = 324000
|
||
# video_cost = 324000/1M*70 = 22.68
|
||
# total = (22.68+0.15)*1.3 = 29.679 ≈ 29.68
|
||
c = calculate_viral_video_credits(15, 720, 1280, model="seedance-2.5")
|
||
assert c == 29.68, f"got {c}"
|
||
|
||
def test_seedance_2_0_30s_1080p_9x16(self):
|
||
# 30s/1080p/9:16 → 1080×1920
|
||
# tokens = 30*1080*1920*24/1024 = 1,458,000
|
||
# video_cost = 1.458M/1M*51 = 74.358
|
||
# total = (74.358+0.15)*1.3 = 96.86
|
||
from packages.domain.points_rules import calculate_viral_video_credits
|
||
|
||
c = calculate_viral_video_credits(30, 1080, 1920, model="seedance-2.0")
|
||
assert c == 96.86, f"got {c}"
|
||
|
||
def test_seedance_2_0_fast_15s_720p_9x16(self):
|
||
# 15s/720p/9:16 tokens=324000, price=28
|
||
# video_cost = 0.324*28 = 9.072
|
||
# total = (9.072+0.15)*1.3 = 11.99
|
||
from packages.domain.points_rules import calculate_viral_video_credits
|
||
|
||
c = calculate_viral_video_credits(15, 720, 1280, model="seedance-2.0-fast")
|
||
assert c == 11.99, f"got {c}"
|
||
|
||
def test_seedance_2_0_mini_15s_720p_9x16(self):
|
||
# price=9.2, tokens=324000
|
||
# video_cost = 0.324*9.2 = 2.9808
|
||
# total = (2.9808+0.15)*1.3 = 4.07
|
||
from packages.domain.points_rules import calculate_viral_video_credits
|
||
|
||
c = calculate_viral_video_credits(15, 720, 1280, model="seedance-2.0-mini")
|
||
assert c == 4.07, f"got {c}"
|
||
|
||
def test_wan_3_0_per_second_billing(self):
|
||
# per_second: 10s/720p price=0.6元/秒
|
||
# video_cost = 10*0.6 = 6.0
|
||
# total = (6.0+0.15)*1.3 = 7.995 ≈ 8.00
|
||
from packages.domain.points_rules import calculate_viral_video_credits
|
||
|
||
c = calculate_viral_video_credits(10, 720, 1280, model="wan-3.0")
|
||
assert c == 8.0, f"got {c}"
|
||
|
||
def test_seedance_2_0_4k_16x9(self):
|
||
# 5s/4k/16:9 → 3840×2160, price=80
|
||
# tokens = 5*3840*2160*24/1024 = 972000
|
||
# video_cost = 0.972*80 = 77.76
|
||
# total = (77.76+0.15)*1.3 = 101.28
|
||
from packages.domain.points_rules import calculate_viral_video_credits
|
||
|
||
c = calculate_viral_video_credits(5, 3840, 2160, model="seedance-2.0")
|
||
assert c == 101.28, f"got {c}"
|
||
|
||
def test_model_config_has_all_6_models(self):
|
||
from packages.domain.points_rules import VIRAL_VIDEO_MODEL_CONFIG
|
||
|
||
expected = {"seedance-2.5", "seedance-2.0", "seedance-2.0-fast", "seedance-2.0-mini", "wan-3.0"}
|
||
assert expected.issubset(set(VIRAL_VIDEO_MODEL_CONFIG.keys()))
|
||
|
||
def test_list_models_hides_wan_when_dashscope_unavailable(self):
|
||
from packages.domain.points_rules import list_viral_video_models
|
||
|
||
all_models = list_viral_video_models(include_placeholder=False, dashscope_available=False)
|
||
keys = {m["key"] for m in all_models}
|
||
assert "wan-3.0" not in keys
|
||
assert "seedance-2.5" in keys
|
||
# is_default
|
||
defaults = [m for m in all_models if m["is_default"]]
|
||
assert len(defaults) == 1
|
||
assert defaults[0]["key"] == "seedance-2.5"
|
||
|
||
def test_list_models_includes_wan_when_dashscope_available(self):
|
||
from packages.domain.points_rules import list_viral_video_models
|
||
|
||
models = list_viral_video_models(include_placeholder=False, dashscope_available=True)
|
||
keys = {m["key"] for m in models}
|
||
assert "wan-3.0" in keys
|
||
|
||
def test_infer_4k(self):
|
||
from packages.domain.points_rules import _infer_resolution_key
|
||
|
||
assert _infer_resolution_key(3840, 2160) == "4k"
|
||
assert _infer_resolution_key(2160, 3840) == "4k"
|
||
assert _infer_resolution_key(1920, 1080) == "1080p"
|