"""积分消耗规则单元测试 (#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"