Files
xiaoxia-saas/tests/unit/test_points_rules.py
T
xiaoxia 90cc0026b1
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 19s
CI/CD Pipeline / Build Staging API Image (push) Successful in 30s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 29s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 53s
CI/CD Pipeline / Integration Tests (push) Successful in 3m29s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m51s
CI/CD Pipeline / Validate - Style (push) Successful in 4m41s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m22s
CI/CD Pipeline / Validate - Security (push) Successful in 7m23s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 4m14s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m14s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m26s
CI/CD Pipeline / Unit Tests (push) Successful in 12m4s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m28s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Failing after 29h47m59s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 29h56m14s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 30h0m18s
CI/CD Pipeline / PR Build Web Image (push) Failing after 29h59m43s
CI/CD Pipeline / PR Build API Image (push) Failing after 29h59m43s
CI/CD Pipeline / Deploy Production (push) Failing after 29h47m10s
CI/CD Pipeline / Build Production Web Image (push) Failing after 29h47m23s
CI/CD Pipeline / Build Production API Image (push) Failing after 29h47m24s
CI/CD Pipeline / CI Gate (push) Failing after 29h47m22s
CI/CD Pipeline / Canary Release to Production (push) Failing after 29h47m9s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 29h59m44s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 29h55m34s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 29h55m44s
CI/CD Pipeline / Frontend Lint (push) Failing after 29h59m41s
feat: 会员+积分系统后端 (#1895) (#1919)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-15 09:23:26 +08:00

141 lines
5.0 KiB
Python

"""积分消耗规则单元测试 (#1895)"""
from __future__ import annotations
import math
import pytest
from packages.domain.points_rules import (
DAILY_FREE_CLIP_LIMIT,
FREE_USER_MULTIPLIER,
MEMBER_DISCOUNT,
MEMBERSHIP_PRICES,
POINTS_PACKAGES,
POINTS_SCENES,
calculate_points_cost,
)
class TestPointsScenesConfig:
"""场景配置完整性"""
def test_all_nine_scenes_defined(self):
assert len(POINTS_SCENES) == 9
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_ai_video_has_extra_per_30s(self):
assert POINTS_SCENES["ai_video"]["extra_per_30s"] == 1
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 TestDailyFreeLimit:
def test_limit_is_2(self):
assert DAILY_FREE_CLIP_LIMIT == 2
class TestCalculatePointsCost:
"""核心计费逻辑"""
# ── 按次计费 ──
def test_per_time_base_cost(self):
# ai_rewrite: 1积分/次,免费用户 ceil(1 * 1.15) = 2
cost = calculate_points_cost("ai_rewrite", is_member=False, quantity=1)
assert cost == math.ceil(1 * FREE_USER_MULTIPLIER)
def test_per_time_multiple(self):
# ai_cover: 1积分/张,3张 → base=3, free: ceil(3*1.15)=4
cost = calculate_points_cost("ai_cover", is_member=False, quantity=3)
assert cost == math.ceil(3 * FREE_USER_MULTIPLIER)
# ── 按时长计费 ──
def test_per_minute_base(self):
# ai_voice: 1积分/分钟,3分钟 → base=3, free: ceil(3*1.15)=4
cost = calculate_points_cost("ai_voice", is_member=False, duration_minutes=3)
assert cost == math.ceil(3 * FREE_USER_MULTIPLIER)
def test_per_minute_rounds_up(self):
# 2.3分钟 → ceil(2.3)=3分钟 → base=3
cost = calculate_points_cost("ai_voice", is_member=False, duration_minutes=2.3)
assert cost == math.ceil(3 * FREE_USER_MULTIPLIER)
def test_digital_human_expensive(self):
# ai_digital_human: 15积分/分钟,1分钟 → base=15, free: ceil(15*1.15)=18
cost = calculate_points_cost("ai_digital_human", is_member=False, duration_minutes=1)
assert cost == 18
# ── 免费场景 ──
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_ai_video_short_no_extra(self):
# 20s (0.33min) ≤ 30s,不额外加积分,base=3, free: ceil(3*1.15)=4
cost = calculate_points_cost("ai_video", is_member=False, quantity=1, duration_minutes=0.33)
assert cost == math.ceil(3 * FREE_USER_MULTIPLIER)
def test_ai_video_long_extra_charge(self):
# 80s → base=3 + extra ceil((80-30)/30)=2 → total_base=5, free: ceil(5*1.15)=6
cost = calculate_points_cost("ai_video", is_member=False, quantity=1, duration_minutes=80 / 60)
assert cost == math.ceil(5 * FREE_USER_MULTIPLIER)
# ── 会员折扣 ──
def test_monthly_member_discount(self):
# ai_voice 1分钟 base=1, 月卡0.9 → floor(1*0.9)=1 → max(1,1)=1
cost = calculate_points_cost("ai_voice", is_member=True, duration_minutes=1, member_type="monthly")
assert cost == max(1, math.floor(1 * 0.9))
def test_yearly_member_deep_discount(self):
# ai_digital_human 2分钟 base=30, 年卡0.8 → floor(30*0.8)=24
cost = calculate_points_cost(
"ai_digital_human",
is_member=True,
duration_minutes=2,
member_type="yearly",
)
assert cost == max(1, math.floor(30 * 0.8))
def test_member_without_type_no_discount(self):
# is_member=True 但没传 member_type → 不按会员折扣
cost = calculate_points_cost("ai_voice", is_member=True, duration_minutes=1)
assert cost == 1 # base=1, no discount applied
# ── 异常 ──
def test_unknown_scene_raises(self):
with pytest.raises(ValueError, match="Unknown points scene"):
calculate_points_cost("nonexistent_scene", is_member=False)