Files
xiaoxia-saas/tests/unit/test_points_rules.py
T
xiaoxia 6161ecdc35
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 0s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
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 / 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 / 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 56s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m20s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m44s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m4s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m53s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 5m4s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 5m26s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 5m37s
AI Code Review / AI Code Review (pull_request) Successful in 6m53s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 12m15s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 9m55s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 30s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 5m46s
fix(points) P0: 分辨率映射改为短边规则,修复竖屏积分少算68%的计费bug
原逻辑把 p 值当 height,9:16 竖屏时返回 (405,720),实际 Seedance 720p 9:16 输出是 (720,1280)。
正确规则:p 值=短边像素数。
- 横屏16:9:短边是height,width=ceil(short*16/9)偶对齐(720p→1280×720、1080p→1920×1080、480p→854×480)
- 竖屏9:16:短边是width,height=ceil(short*16/9)偶对齐(720p→720×1280、1080p→1080×1920、480p→480×854)
- 方屏1:1:w=h=short
- _RESOLUTION_HEIGHT 改名 _RESOLUTION_SHORT_SIDE 语义更准确
- _infer_resolution_key 改为接收(w,h)按 min(w,h) 短边推断(settle 用实际输出尺寸查价目表)
- w/h max(1,...) 防御前置,避免 UnboundLocalError

修复前后价格对比:
- 15s/720p/9:16:9.52 → 29.68(+211%,原少收68%)
- 30s/1080p/9:16:46.34 → 146.14
- 15s/720p/16:9 与 15s/720p/9:16 同价(像素数相同,合理)
- 15s/720p/1:1:16.78

新增单测:
- 9组(resolution,ratio)→(w,h)精确断言(含480p ceil+偶对齐)
- 所有组合偶数校验
- _infer_resolution_key 按短边推断(含竖屏720×1280→720p回归测试,防止重蹈覆辙)
- 3组灵应指定的精确积分数值断言(29.68/146.14/横竖同价)
全量单测 16407 passed。
2026-10-03 01:17:04 +08:00

534 lines
21 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.
"""积分消耗规则单元测试 (#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("2160p", "1:1")
assert h == 720
assert w == 720
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