1661 lines
60 KiB
Rust
1661 lines
60 KiB
Rust
mod application;
|
|
mod commands;
|
|
mod domain;
|
|
mod errors;
|
|
mod events;
|
|
|
|
pub use application::*;
|
|
pub use commands::*;
|
|
pub use domain::*;
|
|
pub use errors::*;
|
|
|
|
use shared_kernel::format_rfc3339 as format_shared_rfc3339;
|
|
use time::OffsetDateTime;
|
|
|
|
pub const PROFILE_MEMBERSHIP_DEFAULT_PERIOD_DAYS: u32 = 30;
|
|
|
|
pub fn format_utc_micros(micros: i64) -> String {
|
|
let timestamp = OffsetDateTime::from_unix_timestamp_nanos(i128::from(micros) * 1_000)
|
|
.unwrap_or(OffsetDateTime::UNIX_EPOCH);
|
|
format_shared_rfc3339(timestamp).unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
|
|
}
|
|
|
|
pub fn runtime_profile_recharge_point_products() -> Vec<RuntimeProfileRechargeProductSnapshot> {
|
|
vec![
|
|
build_points_recharge_product("points_60", "60泥点", 600, 60, 0, "", "60泥点"),
|
|
build_points_recharge_product(
|
|
"points_180",
|
|
"180泥点",
|
|
1800,
|
|
180,
|
|
90,
|
|
"首充加赠",
|
|
"首充加赠90泥点",
|
|
),
|
|
build_points_recharge_product(
|
|
"points_300",
|
|
"300泥点",
|
|
3000,
|
|
300,
|
|
150,
|
|
"首充加赠",
|
|
"首充加赠150泥点",
|
|
),
|
|
build_points_recharge_product(
|
|
"points_680",
|
|
"680泥点",
|
|
6800,
|
|
680,
|
|
340,
|
|
"首充加赠",
|
|
"首充加赠340泥点",
|
|
),
|
|
]
|
|
}
|
|
|
|
/// 中文注释:保留旧展示 helper 的兼容入口;首充资格已改为按商品档位在配置表侧计算。
|
|
pub fn resolve_runtime_profile_recharge_point_products(
|
|
_has_points_recharged: bool,
|
|
) -> Vec<RuntimeProfileRechargeProductSnapshot> {
|
|
runtime_profile_recharge_point_products()
|
|
}
|
|
|
|
pub fn runtime_profile_recharge_membership_products() -> Vec<RuntimeProfileRechargeProductSnapshot>
|
|
{
|
|
vec![
|
|
build_membership_recharge_product(
|
|
"member_starter",
|
|
"Starter",
|
|
1990,
|
|
30,
|
|
RuntimeProfileMembershipTier::Starter,
|
|
"每月200泥点 用于游戏创作",
|
|
),
|
|
build_membership_recharge_product(
|
|
"member_basic",
|
|
"Basic",
|
|
6990,
|
|
30,
|
|
RuntimeProfileMembershipTier::Basic,
|
|
"每月800泥点 用于游戏创作",
|
|
),
|
|
build_membership_recharge_product(
|
|
"member_pro",
|
|
"Pro",
|
|
19990,
|
|
30,
|
|
RuntimeProfileMembershipTier::Pro,
|
|
"每月2500泥点 用于游戏创作",
|
|
),
|
|
build_membership_recharge_product(
|
|
"member_ultimate",
|
|
"Ultimate",
|
|
41990,
|
|
30,
|
|
RuntimeProfileMembershipTier::Ultimate,
|
|
"每月6000泥点 用于游戏创作",
|
|
),
|
|
]
|
|
}
|
|
|
|
pub fn runtime_profile_membership_tier_rank(tier: RuntimeProfileMembershipTier) -> Option<u8> {
|
|
match tier {
|
|
RuntimeProfileMembershipTier::Normal => None,
|
|
RuntimeProfileMembershipTier::Month | RuntimeProfileMembershipTier::Starter => Some(1),
|
|
RuntimeProfileMembershipTier::Season | RuntimeProfileMembershipTier::Basic => Some(2),
|
|
RuntimeProfileMembershipTier::Year | RuntimeProfileMembershipTier::Pro => Some(3),
|
|
RuntimeProfileMembershipTier::Ultimate => Some(4),
|
|
}
|
|
}
|
|
|
|
pub fn runtime_profile_membership_period_points(tier: RuntimeProfileMembershipTier) -> u64 {
|
|
match tier {
|
|
RuntimeProfileMembershipTier::Starter => 200,
|
|
RuntimeProfileMembershipTier::Basic => 800,
|
|
RuntimeProfileMembershipTier::Pro => 2500,
|
|
RuntimeProfileMembershipTier::Ultimate => 6000,
|
|
_ => 0,
|
|
}
|
|
}
|
|
|
|
pub fn runtime_profile_membership_queue_limit(tier: RuntimeProfileMembershipTier) -> u32 {
|
|
match tier {
|
|
RuntimeProfileMembershipTier::Starter | RuntimeProfileMembershipTier::Basic => 2,
|
|
RuntimeProfileMembershipTier::Pro => 5,
|
|
RuntimeProfileMembershipTier::Ultimate => 10,
|
|
_ => 0,
|
|
}
|
|
}
|
|
|
|
pub fn runtime_profile_membership_discount_bps(tier: RuntimeProfileMembershipTier) -> u32 {
|
|
match tier {
|
|
RuntimeProfileMembershipTier::Basic => 8500,
|
|
RuntimeProfileMembershipTier::Pro => 8000,
|
|
RuntimeProfileMembershipTier::Ultimate => 7000,
|
|
_ => 0,
|
|
}
|
|
}
|
|
|
|
pub fn runtime_profile_membership_benefits() -> Vec<RuntimeProfileMembershipBenefitSnapshot> {
|
|
vec![
|
|
RuntimeProfileMembershipBenefitSnapshot {
|
|
benefit_name: "特权名称".to_string(),
|
|
normal_value: "普通".to_string(),
|
|
month_value: "月卡".to_string(),
|
|
season_value: "季卡".to_string(),
|
|
year_value: "年卡".to_string(),
|
|
starter_value: "Starter".to_string(),
|
|
basic_value: "Basic".to_string(),
|
|
pro_value: "Pro".to_string(),
|
|
ultimate_value: "Ultimate".to_string(),
|
|
},
|
|
RuntimeProfileMembershipBenefitSnapshot {
|
|
benefit_name: "每月泥点".to_string(),
|
|
normal_value: "0".to_string(),
|
|
month_value: "0".to_string(),
|
|
season_value: "0".to_string(),
|
|
year_value: "0".to_string(),
|
|
starter_value: "200".to_string(),
|
|
basic_value: "800".to_string(),
|
|
pro_value: "2500".to_string(),
|
|
ultimate_value: "6000".to_string(),
|
|
},
|
|
RuntimeProfileMembershipBenefitSnapshot {
|
|
benefit_name: "同时排队".to_string(),
|
|
normal_value: "1".to_string(),
|
|
month_value: "2".to_string(),
|
|
season_value: "2".to_string(),
|
|
year_value: "2".to_string(),
|
|
starter_value: "2".to_string(),
|
|
basic_value: "2".to_string(),
|
|
pro_value: "5".to_string(),
|
|
ultimate_value: "10".to_string(),
|
|
},
|
|
RuntimeProfileMembershipBenefitSnapshot {
|
|
benefit_name: "商业所有权".to_string(),
|
|
normal_value: "0%".to_string(),
|
|
month_value: "有".to_string(),
|
|
season_value: "有".to_string(),
|
|
year_value: "有".to_string(),
|
|
starter_value: "有".to_string(),
|
|
basic_value: "有".to_string(),
|
|
pro_value: "有".to_string(),
|
|
ultimate_value: "有".to_string(),
|
|
},
|
|
]
|
|
}
|
|
|
|
pub fn runtime_profile_recharge_product_by_id(
|
|
product_id: &str,
|
|
) -> Option<RuntimeProfileRechargeProductSnapshot> {
|
|
runtime_profile_recharge_point_products()
|
|
.into_iter()
|
|
.chain(runtime_profile_recharge_membership_products())
|
|
.find(|product| product.product_id == product_id)
|
|
}
|
|
|
|
pub fn visible_runtime_profile_user_tags(tags: &[String]) -> Vec<String> {
|
|
tags.iter()
|
|
.filter(|tag| tag.as_str() == "北科")
|
|
.cloned()
|
|
.collect()
|
|
}
|
|
|
|
fn build_points_recharge_product(
|
|
product_id: &str,
|
|
title: &str,
|
|
price_cents: u64,
|
|
points_amount: u64,
|
|
bonus_points: u64,
|
|
badge_label: &str,
|
|
description: &str,
|
|
) -> RuntimeProfileRechargeProductSnapshot {
|
|
RuntimeProfileRechargeProductSnapshot {
|
|
product_id: product_id.to_string(),
|
|
title: title.to_string(),
|
|
price_cents,
|
|
kind: RuntimeProfileRechargeProductKind::Points,
|
|
points_amount,
|
|
bonus_points,
|
|
duration_days: 0,
|
|
badge_label: badge_label.to_string(),
|
|
description: description.to_string(),
|
|
tier: RuntimeProfileMembershipTier::Normal,
|
|
membership_period_points: 0,
|
|
membership_period_days: 0,
|
|
membership_queue_limit: 0,
|
|
membership_discount_bps: 0,
|
|
}
|
|
}
|
|
|
|
fn build_membership_recharge_product(
|
|
product_id: &str,
|
|
title: &str,
|
|
price_cents: u64,
|
|
duration_days: u32,
|
|
tier: RuntimeProfileMembershipTier,
|
|
description: &str,
|
|
) -> RuntimeProfileRechargeProductSnapshot {
|
|
RuntimeProfileRechargeProductSnapshot {
|
|
product_id: product_id.to_string(),
|
|
title: title.to_string(),
|
|
price_cents,
|
|
kind: RuntimeProfileRechargeProductKind::Membership,
|
|
points_amount: 0,
|
|
bonus_points: 0,
|
|
duration_days,
|
|
badge_label: String::new(),
|
|
description: description.to_string(),
|
|
tier,
|
|
membership_period_points: runtime_profile_membership_period_points(tier),
|
|
membership_period_days: PROFILE_MEMBERSHIP_DEFAULT_PERIOD_DAYS,
|
|
membership_queue_limit: runtime_profile_membership_queue_limit(tier),
|
|
membership_discount_bps: runtime_profile_membership_discount_bps(tier),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn defaults_match_shared_contract_baseline() {
|
|
let settings = RuntimeSettings::defaults();
|
|
|
|
assert!((settings.music_volume - DEFAULT_MUSIC_VOLUME).abs() < f32::EPSILON);
|
|
assert_eq!(settings.platform_theme, RuntimePlatformTheme::Light);
|
|
}
|
|
|
|
#[cfg(any())]
|
|
#[test]
|
|
fn default_creation_entry_types_include_baby_object_match() {
|
|
let configs = default_creation_entry_type_snapshots(1);
|
|
let baby_object_match = configs
|
|
.iter()
|
|
.find(|item| item.id == "baby-object-match")
|
|
.expect("baby-object-match creation entry should be seeded");
|
|
|
|
assert_eq!(baby_object_match.title, "宝贝识物");
|
|
assert_eq!(baby_object_match.subtitle, "亲子识物分类");
|
|
assert!(baby_object_match.visible);
|
|
assert!(baby_object_match.open);
|
|
assert_eq!(baby_object_match.badge, "可创建");
|
|
assert_eq!(baby_object_match.sort_order, 90);
|
|
assert_eq!(baby_object_match.category_id, "character");
|
|
assert_eq!(baby_object_match.category_label, "角色创作");
|
|
assert_eq!(baby_object_match.category_sort_order, 40);
|
|
assert_eq!(
|
|
baby_object_match.image_src,
|
|
"/child-motion-demo/picture-book-grass-stage.png"
|
|
);
|
|
}
|
|
|
|
#[cfg(any())]
|
|
#[test]
|
|
fn default_creation_entry_types_open_rpg_entry() {
|
|
let configs = default_creation_entry_type_snapshots(1);
|
|
let rpg = configs
|
|
.iter()
|
|
.find(|item| item.id == "rpg")
|
|
.expect("rpg creation entry should be seeded");
|
|
|
|
assert_eq!(rpg.title, "文字冒险");
|
|
assert_eq!(rpg.subtitle, "经典 RPG 体验");
|
|
assert!(rpg.visible);
|
|
assert!(rpg.open);
|
|
assert_eq!(rpg.badge, "可创建");
|
|
assert_eq!(rpg.sort_order, 10);
|
|
assert_eq!(rpg.category_id, "recommended");
|
|
assert_eq!(rpg.category_label, "热门推荐");
|
|
assert_eq!(rpg.category_sort_order, 20);
|
|
assert_eq!(rpg.image_src, "/creation-type-references/rpg.webp");
|
|
}
|
|
|
|
#[cfg(any())]
|
|
#[test]
|
|
fn default_creation_entry_types_do_not_seed_recent_as_template_category() {
|
|
let configs = default_creation_entry_type_snapshots(1);
|
|
|
|
assert!(configs.iter().all(|item| item.category_id != "recent"));
|
|
assert!(configs.iter().all(|item| item.category_label != "最近创作"));
|
|
}
|
|
|
|
#[cfg(any())]
|
|
#[test]
|
|
fn creation_entry_event_banners_json_normalizes_multiple_banners() {
|
|
let normalized = normalize_creation_entry_event_banners_json(
|
|
r#"[
|
|
{
|
|
"title": " 周末拼图赛 ",
|
|
"description": " 拼一个新主题 ",
|
|
"coverImageSrc": "/creation-type-references/puzzle.webp",
|
|
"prizePoolMudPoints": 1200,
|
|
"startsAtText": "2026-06-01",
|
|
"endsAtText": "2026-06-30",
|
|
"renderMode": "structured",
|
|
"htmlCode": "<div>ignored</div>"
|
|
},
|
|
{
|
|
"title": "HTML 横幅",
|
|
"description": "沙箱片段",
|
|
"coverImageSrc": "/creation-type-references/puzzle.webp",
|
|
"prizePoolMudPoints": 900,
|
|
"startsAtText": "2026-07-01",
|
|
"endsAtText": "2026-07-31",
|
|
"renderMode": "html",
|
|
"htmlCode": " <section>安全片段</section> "
|
|
}
|
|
]"#,
|
|
)
|
|
.expect("valid banner json should normalize");
|
|
let banners = decode_creation_entry_event_banner_snapshots(&normalized)
|
|
.expect("normalized banner json should decode");
|
|
|
|
assert_eq!(banners.len(), 2);
|
|
assert_eq!(banners[0].title, "周末拼图赛");
|
|
assert_eq!(banners[0].description, "拼一个新主题");
|
|
assert_eq!(banners[0].render_mode, "structured");
|
|
assert!(banners[0].html_code.is_none());
|
|
assert_eq!(banners[1].render_mode, "html");
|
|
assert_eq!(
|
|
banners[1].html_code.as_deref(),
|
|
Some("<section>安全片段</section>")
|
|
);
|
|
}
|
|
|
|
#[cfg(any())]
|
|
#[test]
|
|
fn creation_entry_event_banners_json_empty_input_returns_defaults() {
|
|
let normalized = normalize_creation_entry_event_banners_json(" ")
|
|
.expect("blank banner json should use defaults");
|
|
let banners = decode_creation_entry_event_banner_snapshots(&normalized)
|
|
.expect("default banner json should decode");
|
|
|
|
assert_eq!(banners, default_creation_entry_event_banner_snapshots());
|
|
}
|
|
|
|
#[cfg(any())]
|
|
#[test]
|
|
fn creation_entry_event_banners_none_returns_default_announcements() {
|
|
let legacy_banner = CreationEntryEventBannerSnapshot {
|
|
title: "旧结构化横幅".to_string(),
|
|
description: "旧库单条字段".to_string(),
|
|
cover_image_src:
|
|
"/branding/taonier-logo-spiral-reference-concepts/taonier-spiral-bouncy-clay.png"
|
|
.to_string(),
|
|
prize_pool_mud_points: 58_000,
|
|
starts_at_text: "2024.10.20 10:00".to_string(),
|
|
ends_at_text: "2024.11.20 23:59".to_string(),
|
|
render_mode: "structured".to_string(),
|
|
html_code: None,
|
|
};
|
|
|
|
let banners = resolve_creation_entry_event_banner_responses(None, &legacy_banner);
|
|
|
|
assert_eq!(banners.len(), 1);
|
|
assert_eq!(banners[0].render_mode, "html");
|
|
assert_eq!(banners[0].title, "创作公告");
|
|
assert!(
|
|
banners[0]
|
|
.html_code
|
|
.as_deref()
|
|
.unwrap_or("")
|
|
.contains("创作公告")
|
|
);
|
|
assert!(
|
|
banners[0]
|
|
.html_code
|
|
.as_deref()
|
|
.unwrap_or("")
|
|
.contains("/creation-type-references/puzzle.webp")
|
|
);
|
|
assert_ne!(banners[0].cover_image_src, legacy_banner.cover_image_src);
|
|
}
|
|
|
|
#[cfg(any())]
|
|
#[test]
|
|
fn creation_entry_event_banners_json_accepts_announcement_html_code() {
|
|
let normalized = normalize_creation_entry_event_banners_json(
|
|
r#"[
|
|
"<section>纯 HTML 公告</section>",
|
|
{"title": "后台公告", "htmlCode": "<article>自定义公告</article>"}
|
|
]"#,
|
|
)
|
|
.expect("announcement html json should normalize");
|
|
let banners = decode_creation_entry_event_banner_snapshots(&normalized)
|
|
.expect("normalized announcement json should decode");
|
|
|
|
assert_eq!(banners.len(), 2);
|
|
assert_eq!(banners[0].title, "公告 1");
|
|
assert_eq!(banners[0].render_mode, "html");
|
|
assert_eq!(
|
|
banners[0].html_code.as_deref(),
|
|
Some("<section>纯 HTML 公告</section>")
|
|
);
|
|
assert_eq!(banners[1].title, "后台公告");
|
|
assert_eq!(
|
|
banners[1].html_code.as_deref(),
|
|
Some("<article>自定义公告</article>")
|
|
);
|
|
}
|
|
|
|
#[cfg(any())]
|
|
#[test]
|
|
fn creation_entry_event_banners_json_rejects_script_like_html() {
|
|
let script_error = normalize_creation_entry_event_banners_json(
|
|
r#"[
|
|
{
|
|
"title": "脚本横幅",
|
|
"description": "不允许脚本",
|
|
"coverImageSrc": "/creation-type-references/puzzle.webp",
|
|
"prizePoolMudPoints": 100,
|
|
"startsAtText": "2026-06-01",
|
|
"endsAtText": "2026-06-30",
|
|
"renderMode": "html",
|
|
"htmlCode": "<script>alert(1)</script>"
|
|
}
|
|
]"#,
|
|
)
|
|
.expect_err("script tag should be rejected");
|
|
let javascript_url_error = normalize_creation_entry_event_banners_json(
|
|
r#"[
|
|
{
|
|
"title": "链接横幅",
|
|
"description": "不允许 javascript URL",
|
|
"coverImageSrc": "/creation-type-references/puzzle.webp",
|
|
"prizePoolMudPoints": 100,
|
|
"startsAtText": "2026-06-01",
|
|
"endsAtText": "2026-06-30",
|
|
"renderMode": "html",
|
|
"htmlCode": "<a href=\"javascript:alert(1)\">bad</a>"
|
|
}
|
|
]"#,
|
|
)
|
|
.expect_err("javascript url should be rejected");
|
|
|
|
assert!(script_error.contains("脚本代码"));
|
|
assert!(javascript_url_error.contains("脚本代码"));
|
|
}
|
|
|
|
#[cfg(any())]
|
|
#[test]
|
|
fn default_creation_entry_types_include_bark_battle() {
|
|
let configs = default_creation_entry_type_snapshots(1);
|
|
let bark_battle = configs
|
|
.iter()
|
|
.find(|item| item.id == "bark-battle")
|
|
.expect("bark-battle creation entry should be seeded");
|
|
|
|
assert_eq!(bark_battle.title, "汪汪声浪");
|
|
assert!(bark_battle.visible);
|
|
assert!(bark_battle.open);
|
|
assert_eq!(bark_battle.badge, "可创建");
|
|
assert_eq!(bark_battle.sort_order, 85);
|
|
assert_eq!(
|
|
bark_battle.image_src,
|
|
"/creation-type-references/bark-battle.webp"
|
|
);
|
|
}
|
|
|
|
#[cfg(any())]
|
|
#[test]
|
|
fn default_creation_entry_types_include_wooden_fish() {
|
|
let configs = default_creation_entry_type_snapshots(1);
|
|
let wooden_fish = configs
|
|
.iter()
|
|
.find(|item| item.id == "wooden-fish")
|
|
.expect("wooden-fish creation entry should be seeded");
|
|
|
|
assert_eq!(wooden_fish.title, "敲木鱼");
|
|
assert!(wooden_fish.visible);
|
|
assert!(wooden_fish.open);
|
|
assert_eq!(wooden_fish.badge, "可创建");
|
|
assert_eq!(wooden_fish.sort_order, 47);
|
|
assert_eq!(wooden_fish.image_src, "/wooden-fish/default-hit-object.png");
|
|
}
|
|
|
|
#[cfg(any())]
|
|
#[test]
|
|
fn default_creation_entry_types_include_puzzle_clear() {
|
|
let configs = default_creation_entry_type_snapshots(1);
|
|
let puzzle_clear = configs
|
|
.iter()
|
|
.find(|item| item.id == "puzzle-clear")
|
|
.expect("puzzle-clear creation entry should be seeded");
|
|
|
|
assert_eq!(puzzle_clear.title, "拼消消");
|
|
assert!(puzzle_clear.visible);
|
|
assert!(puzzle_clear.open);
|
|
assert_eq!(puzzle_clear.badge, "可创建");
|
|
assert_eq!(puzzle_clear.sort_order, 46);
|
|
assert_eq!(puzzle_clear.category_id, "recommended");
|
|
}
|
|
|
|
#[cfg(any())]
|
|
#[test]
|
|
fn creation_entry_response_uses_unified_creation_contract_title() {
|
|
let response = build_creation_entry_config_response(CreationEntryConfigSnapshot {
|
|
config_id: CREATION_ENTRY_CONFIG_GLOBAL_ID.to_string(),
|
|
start_card: CreationEntryStartCardSnapshot {
|
|
title: DEFAULT_CREATION_ENTRY_START_TITLE.to_string(),
|
|
description: DEFAULT_CREATION_ENTRY_START_DESCRIPTION.to_string(),
|
|
idle_badge: DEFAULT_CREATION_ENTRY_START_IDLE_BADGE.to_string(),
|
|
busy_badge: DEFAULT_CREATION_ENTRY_START_BUSY_BADGE.to_string(),
|
|
},
|
|
type_modal: CreationEntryTypeModalSnapshot {
|
|
title: DEFAULT_CREATION_ENTRY_MODAL_TITLE.to_string(),
|
|
description: DEFAULT_CREATION_ENTRY_MODAL_DESCRIPTION.to_string(),
|
|
},
|
|
event_banner: default_creation_entry_event_banner_snapshots()
|
|
.into_iter()
|
|
.next()
|
|
.expect("default banner"),
|
|
event_banners_json: Some(default_creation_entry_event_banners_json()),
|
|
creation_types: vec![CreationEntryTypeSnapshot {
|
|
id: "puzzle".to_string(),
|
|
title: "定制拼图".to_string(),
|
|
subtitle: "拼图关卡创作".to_string(),
|
|
badge: "可创建".to_string(),
|
|
image_src: "/creation-type-references/puzzle.webp".to_string(),
|
|
visible: true,
|
|
open: true,
|
|
sort_order: 30,
|
|
category_id: "recommended".to_string(),
|
|
category_label: "热门推荐".to_string(),
|
|
category_sort_order: 20,
|
|
updated_at_micros: 1,
|
|
unified_creation_spec_json: Some(
|
|
r#"{"playId":"puzzle","title":"想做个什么玩法?","workspaceStage":"puzzle-agent-workspace","generationStage":"puzzle-generating","resultStage":"puzzle-result","fields":[{"id":"pictureDescription","kind":"text","label":"画面描述","required":true}]}"#
|
|
.to_string(),
|
|
),
|
|
}],
|
|
updated_at_micros: 1,
|
|
public_work_interactions_json: Some(default_public_work_interaction_config_json()),
|
|
});
|
|
let puzzle = response
|
|
.creation_types
|
|
.iter()
|
|
.find(|item| item.id == "puzzle")
|
|
.expect("puzzle entry");
|
|
|
|
assert_eq!(
|
|
puzzle
|
|
.unified_creation_spec
|
|
.as_ref()
|
|
.map(|spec| spec.title.as_str()),
|
|
Some("想做个什么玩法?")
|
|
);
|
|
}
|
|
|
|
#[cfg(any())]
|
|
#[test]
|
|
fn public_work_interaction_config_defaults_and_overrides() {
|
|
let defaults = resolve_public_work_interaction_config_responses(None);
|
|
let puzzle = defaults
|
|
.iter()
|
|
.find(|item| item.source_type == "puzzle")
|
|
.expect("puzzle interaction should exist");
|
|
assert!(puzzle.like_enabled);
|
|
assert!(puzzle.remix_enabled);
|
|
|
|
let normalized = normalize_public_work_interaction_config_json(
|
|
r#"[{
|
|
"sourceType": "puzzle",
|
|
"likeEnabled": false,
|
|
"remixEnabled": true,
|
|
"likeDisabledMessage": "拼图点赞维护中。",
|
|
"remixDisabledMessage": ""
|
|
}]"#,
|
|
)
|
|
.expect("interaction config should normalize");
|
|
let resolved = resolve_public_work_interaction_config_responses(Some(&normalized));
|
|
let puzzle = resolved
|
|
.iter()
|
|
.find(|item| item.source_type == "puzzle")
|
|
.expect("puzzle interaction should exist");
|
|
|
|
assert!(!puzzle.like_enabled);
|
|
assert_eq!(puzzle.like_disabled_message, "拼图点赞维护中。");
|
|
assert_eq!(puzzle.remix_disabled_message, "拼图作品改造暂不可用。");
|
|
}
|
|
|
|
#[test]
|
|
fn normalized_clamps_music_volume_into_valid_range() {
|
|
let low = RuntimeSettings::normalized(-1.0, RuntimePlatformTheme::Light);
|
|
let high = RuntimeSettings::normalized(3.5, RuntimePlatformTheme::Dark);
|
|
|
|
assert_eq!(low.music_volume, 0.0);
|
|
assert_eq!(high.music_volume, 1.0);
|
|
assert_eq!(high.platform_theme, RuntimePlatformTheme::Dark);
|
|
}
|
|
|
|
#[test]
|
|
fn theme_from_client_string_falls_back_to_light() {
|
|
assert_eq!(
|
|
RuntimePlatformTheme::from_client_str("dark"),
|
|
RuntimePlatformTheme::Dark
|
|
);
|
|
assert_eq!(
|
|
RuntimePlatformTheme::from_client_str("LIGHT"),
|
|
RuntimePlatformTheme::Light
|
|
);
|
|
assert_eq!(
|
|
RuntimePlatformTheme::from_client_str("mythic"),
|
|
RuntimePlatformTheme::Light
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_upsert_input_rejects_blank_user_id() {
|
|
let error = build_runtime_setting_upsert_input(
|
|
" ".to_string(),
|
|
DEFAULT_MUSIC_VOLUME,
|
|
RuntimePlatformTheme::Light,
|
|
1,
|
|
)
|
|
.expect_err("blank user id should fail");
|
|
|
|
assert_eq!(error, RuntimeSettingsFieldError::MissingUserId);
|
|
}
|
|
|
|
#[cfg(any())]
|
|
#[test]
|
|
fn browse_history_theme_from_client_string_falls_back_to_mythic() {
|
|
assert_eq!(
|
|
RuntimeBrowseHistoryThemeMode::from_client_str("martial"),
|
|
RuntimeBrowseHistoryThemeMode::Martial
|
|
);
|
|
assert_eq!(
|
|
RuntimeBrowseHistoryThemeMode::from_client_str("RIFT"),
|
|
RuntimeBrowseHistoryThemeMode::Rift
|
|
);
|
|
assert_eq!(
|
|
RuntimeBrowseHistoryThemeMode::from_client_str("unknown"),
|
|
RuntimeBrowseHistoryThemeMode::Mythic
|
|
);
|
|
}
|
|
|
|
#[cfg(any())]
|
|
#[test]
|
|
fn build_browse_history_sync_input_normalizes_optionals_and_visited_at() {
|
|
let input = build_runtime_browse_history_sync_input(
|
|
" user-1 ".to_string(),
|
|
vec![RuntimeBrowseHistoryWriteInput {
|
|
owner_user_id: " owner-a ".to_string(),
|
|
profile_id: " profile-a ".to_string(),
|
|
world_name: " 世界A ".to_string(),
|
|
subtitle: Some(" ".to_string()),
|
|
summary_text: Some(" 简介 ".to_string()),
|
|
cover_image_src: Some(" /cover.png ".to_string()),
|
|
theme_mode: Some(" arcane ".to_string()),
|
|
author_display_name: Some(" ".to_string()),
|
|
visited_at: None,
|
|
}],
|
|
1_713_680_000_000_000,
|
|
)
|
|
.expect("sync input should build");
|
|
|
|
assert_eq!(input.user_id, "user-1");
|
|
assert_eq!(input.entries.len(), 1);
|
|
assert_eq!(input.entries[0].owner_user_id, "owner-a");
|
|
assert_eq!(input.entries[0].profile_id, "profile-a");
|
|
assert_eq!(input.entries[0].world_name, "世界A");
|
|
assert_eq!(input.entries[0].subtitle, None);
|
|
assert_eq!(input.entries[0].summary_text, Some("简介".to_string()));
|
|
assert_eq!(
|
|
input.entries[0].cover_image_src,
|
|
Some("/cover.png".to_string())
|
|
);
|
|
assert_eq!(input.entries[0].theme_mode, Some("arcane".to_string()));
|
|
assert_eq!(input.entries[0].author_display_name, None);
|
|
assert_eq!(
|
|
input.entries[0].visited_at,
|
|
Some("2024-04-21T06:13:20Z".to_string())
|
|
);
|
|
}
|
|
|
|
#[cfg(any())]
|
|
#[test]
|
|
fn prepare_browse_history_entries_sorts_desc_and_dedups_by_owner_profile() {
|
|
let entries = prepare_runtime_browse_history_entries(RuntimeBrowseHistorySyncInput {
|
|
user_id: "user-1".to_string(),
|
|
entries: vec![
|
|
RuntimeBrowseHistoryWriteInput {
|
|
owner_user_id: "owner-a".to_string(),
|
|
profile_id: "profile-a".to_string(),
|
|
world_name: "世界旧".to_string(),
|
|
subtitle: None,
|
|
summary_text: None,
|
|
cover_image_src: None,
|
|
theme_mode: Some("martial".to_string()),
|
|
author_display_name: None,
|
|
visited_at: Some("2026-04-20T10:00:00Z".to_string()),
|
|
},
|
|
RuntimeBrowseHistoryWriteInput {
|
|
owner_user_id: "owner-b".to_string(),
|
|
profile_id: "profile-b".to_string(),
|
|
world_name: "世界B".to_string(),
|
|
subtitle: None,
|
|
summary_text: None,
|
|
cover_image_src: None,
|
|
theme_mode: Some("rift".to_string()),
|
|
author_display_name: Some("作者B".to_string()),
|
|
visited_at: Some("2026-04-21T10:00:00Z".to_string()),
|
|
},
|
|
RuntimeBrowseHistoryWriteInput {
|
|
owner_user_id: "owner-a".to_string(),
|
|
profile_id: "profile-a".to_string(),
|
|
world_name: "世界新".to_string(),
|
|
subtitle: None,
|
|
summary_text: None,
|
|
cover_image_src: None,
|
|
theme_mode: Some("unknown".to_string()),
|
|
author_display_name: Some("".to_string()),
|
|
visited_at: Some("2026-04-21T11:00:00Z".to_string()),
|
|
},
|
|
],
|
|
updated_at_micros: 1_776_000_000_000_000,
|
|
})
|
|
.expect("entries should prepare");
|
|
|
|
assert_eq!(entries.len(), 2);
|
|
assert_eq!(entries[0].world_name, "世界新");
|
|
assert_eq!(entries[0].theme_mode, RuntimeBrowseHistoryThemeMode::Mythic);
|
|
assert_eq!(
|
|
entries[0].author_display_name,
|
|
DEFAULT_BROWSE_HISTORY_AUTHOR_DISPLAY_NAME
|
|
);
|
|
assert_eq!(entries[1].world_name, "世界B");
|
|
assert!(entries[0].visited_at_micros > entries[1].visited_at_micros);
|
|
}
|
|
|
|
#[cfg(any())]
|
|
#[test]
|
|
fn build_browse_history_sync_input_silently_filters_invalid_entries() {
|
|
let input = build_runtime_browse_history_sync_input(
|
|
"user-1".to_string(),
|
|
vec![
|
|
RuntimeBrowseHistoryWriteInput {
|
|
owner_user_id: " ".to_string(),
|
|
profile_id: "profile-a".to_string(),
|
|
world_name: "世界A".to_string(),
|
|
subtitle: None,
|
|
summary_text: None,
|
|
cover_image_src: None,
|
|
theme_mode: None,
|
|
author_display_name: None,
|
|
visited_at: None,
|
|
},
|
|
RuntimeBrowseHistoryWriteInput {
|
|
owner_user_id: "owner-b".to_string(),
|
|
profile_id: "profile-b".to_string(),
|
|
world_name: " 世界B ".to_string(),
|
|
subtitle: None,
|
|
summary_text: None,
|
|
cover_image_src: None,
|
|
theme_mode: None,
|
|
author_display_name: None,
|
|
visited_at: None,
|
|
},
|
|
RuntimeBrowseHistoryWriteInput {
|
|
owner_user_id: "owner-c".to_string(),
|
|
profile_id: "".to_string(),
|
|
world_name: "世界C".to_string(),
|
|
subtitle: None,
|
|
summary_text: None,
|
|
cover_image_src: None,
|
|
theme_mode: None,
|
|
author_display_name: None,
|
|
visited_at: None,
|
|
},
|
|
],
|
|
1_776_000_000_000_000,
|
|
)
|
|
.expect("sync input should build");
|
|
|
|
assert_eq!(input.entries.len(), 1);
|
|
assert_eq!(input.entries[0].owner_user_id, "owner-b");
|
|
assert_eq!(input.entries[0].profile_id, "profile-b");
|
|
assert_eq!(input.entries[0].world_name, "世界B");
|
|
}
|
|
|
|
#[test]
|
|
fn build_profile_inputs_reject_blank_user_id() {
|
|
assert_eq!(
|
|
build_runtime_profile_dashboard_get_input(" ".to_string())
|
|
.expect_err("dashboard input should fail"),
|
|
RuntimeProfileFieldError::MissingUserId
|
|
);
|
|
assert_eq!(
|
|
build_runtime_profile_wallet_ledger_list_input(" ".to_string())
|
|
.expect_err("wallet ledger input should fail"),
|
|
RuntimeProfileFieldError::MissingUserId
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn profile_dashboard_record_formats_optional_timestamp() {
|
|
let record = build_runtime_profile_dashboard_record(RuntimeProfileDashboardSnapshot {
|
|
user_id: "user-1".to_string(),
|
|
wallet_balance: 8,
|
|
total_play_time_ms: 12,
|
|
played_world_count: 2,
|
|
updated_at_micros: Some(1_713_680_000_000_000),
|
|
daily_free_points: RuntimeProfileDailyFreePointsSnapshot {
|
|
day_key: 19_834,
|
|
granted_points: PROFILE_DEFAULT_DAILY_FREE_POINTS_PER_DAY,
|
|
remaining_points: 12,
|
|
resets_at_micros: 1_713_715_200_000_000,
|
|
updated_at_micros: 1_713_680_000_000_000,
|
|
reset_points: PROFILE_DEFAULT_DAILY_FREE_POINTS_PER_DAY,
|
|
},
|
|
});
|
|
|
|
assert_eq!(record.updated_at, Some("2024-04-21T06:13:20Z".to_string()));
|
|
assert_eq!(record.daily_free_points.reset_points, 20);
|
|
}
|
|
|
|
#[test]
|
|
fn profile_wallet_config_requires_positive_daily_free_points() {
|
|
assert_eq!(
|
|
build_runtime_profile_wallet_config_admin_upsert_input(
|
|
"admin-1".to_string(),
|
|
100,
|
|
1_713_680_000_000_000,
|
|
0,
|
|
)
|
|
.expect_err("zero daily free points should fail"),
|
|
RuntimeProfileFieldError::InvalidDailyFreePointsPerDay,
|
|
);
|
|
assert_eq!(
|
|
build_runtime_profile_wallet_config_admin_upsert_input(
|
|
"admin-1".to_string(),
|
|
100,
|
|
1_713_680_000_000_000,
|
|
35,
|
|
)
|
|
.expect("positive daily free points should pass")
|
|
.daily_free_points_per_day,
|
|
35,
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn profile_wallet_ledger_source_type_formats_to_snapshot_sync() {
|
|
assert_eq!(
|
|
RuntimeProfileWalletLedgerSourceType::SnapshotSync.as_str(),
|
|
"snapshot_sync"
|
|
);
|
|
assert_eq!(
|
|
RuntimeProfileWalletLedgerSourceType::NewUserRegistrationReward.as_str(),
|
|
"new_user_registration_reward"
|
|
);
|
|
assert_eq!(
|
|
RuntimeProfileWalletLedgerSourceType::PointsRecharge.as_str(),
|
|
"points_recharge"
|
|
);
|
|
assert_eq!(
|
|
RuntimeProfileWalletLedgerSourceType::MembershipPeriodGrant.as_str(),
|
|
"membership_period_grant"
|
|
);
|
|
assert_eq!(
|
|
RuntimeProfileWalletLedgerSourceType::MembershipPeriodReset.as_str(),
|
|
"membership_period_reset"
|
|
);
|
|
assert_eq!(
|
|
RuntimeProfileWalletLedgerSourceType::AssetOperationConsume.as_str(),
|
|
"asset_operation_consume"
|
|
);
|
|
assert_eq!(
|
|
RuntimeProfileWalletLedgerSourceType::AssetOperationRefund.as_str(),
|
|
"asset_operation_refund"
|
|
);
|
|
assert_eq!(
|
|
RuntimeProfileWalletLedgerSourceType::DailyTaskReward.as_str(),
|
|
"daily_task_reward"
|
|
);
|
|
assert_eq!(
|
|
RuntimeProfileWalletLedgerSourceType::DailyFreeGrant.as_str(),
|
|
"daily_free_grant"
|
|
);
|
|
assert_eq!(
|
|
RuntimeProfileWalletLedgerSourceType::DailyFreeReset.as_str(),
|
|
"daily_free_reset"
|
|
);
|
|
assert_eq!(
|
|
RuntimeProfileWalletLedgerSourceType::RechargeRefundRecovery.as_str(),
|
|
"recharge_refund_recovery"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn new_user_registration_wallet_reward_starts_with_one_hundred_points() {
|
|
assert_eq!(PROFILE_NEW_USER_INITIAL_WALLET_POINTS, 100);
|
|
assert_eq!(
|
|
calculate_runtime_profile_wallet_balance(
|
|
0,
|
|
PROFILE_NEW_USER_INITIAL_WALLET_POINTS as i64,
|
|
)
|
|
.expect("new user registration reward should fit wallet balance"),
|
|
100
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_profile_beijing_day_key_uses_business_day_boundary() {
|
|
// 中文注释:2024-05-06 00:00:00 Asia/Shanghai 前后 1 微秒。
|
|
let before_beijing_midnight = 1_714_924_799_999_999;
|
|
let after_beijing_midnight = 1_714_924_800_000_000;
|
|
|
|
assert_eq!(
|
|
runtime_profile_beijing_day_key(before_beijing_midnight),
|
|
runtime_profile_beijing_day_key(after_beijing_midnight) - 1
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn analytics_date_dimension_handles_iso_week_across_year() {
|
|
let date_key = parse_analytics_calendar_date_key("2024-12-31").unwrap();
|
|
let dimension = build_analytics_date_dimension_from_date_key(date_key);
|
|
|
|
assert_eq!(dimension.calendar_date, "2024-12-31");
|
|
assert_eq!(dimension.weekday, 2);
|
|
assert_eq!(dimension.iso_week_key, 202501);
|
|
assert_eq!(
|
|
dimension.week_start_date_key,
|
|
parse_analytics_calendar_date_key("2024-12-30").unwrap()
|
|
);
|
|
assert_eq!(
|
|
dimension.week_end_date_key,
|
|
parse_analytics_calendar_date_key("2025-01-05").unwrap()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn analytics_date_dimension_handles_leap_day() {
|
|
let date_key = parse_analytics_calendar_date_key("2024-02-29").unwrap();
|
|
let dimension = build_analytics_date_dimension_from_date_key(date_key);
|
|
|
|
assert_eq!(dimension.calendar_date, "2024-02-29");
|
|
assert_eq!(dimension.weekday, 4);
|
|
assert_eq!(dimension.month_key, 202402);
|
|
assert_eq!(dimension.month_end_date_key, date_key);
|
|
assert_eq!(dimension.quarter_key, 20241);
|
|
}
|
|
|
|
#[test]
|
|
fn analytics_date_dimension_handles_quarter_boundary() {
|
|
let date_key = parse_analytics_calendar_date_key("2024-04-01").unwrap();
|
|
let dimension = build_analytics_date_dimension_from_date_key(date_key);
|
|
|
|
assert_eq!(dimension.quarter_key, 20242);
|
|
assert_eq!(dimension.quarter_start_date_key, date_key);
|
|
assert_eq!(
|
|
dimension.quarter_end_date_key,
|
|
parse_analytics_calendar_date_key("2024-06-30").unwrap()
|
|
);
|
|
assert_eq!(
|
|
dimension.year_start_date_key,
|
|
parse_analytics_calendar_date_key("2024-01-01").unwrap()
|
|
);
|
|
assert_eq!(
|
|
dimension.year_end_date_key,
|
|
parse_analytics_calendar_date_key("2024-12-31").unwrap()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_profile_task_status_matches_progress_and_claim() {
|
|
assert_eq!(
|
|
resolve_runtime_profile_task_status(false, 1, 1, false),
|
|
RuntimeProfileTaskStatus::Disabled
|
|
);
|
|
assert_eq!(
|
|
resolve_runtime_profile_task_status(true, 0, 1, false),
|
|
RuntimeProfileTaskStatus::Incomplete
|
|
);
|
|
assert_eq!(
|
|
resolve_runtime_profile_task_status(true, 1, 1, false),
|
|
RuntimeProfileTaskStatus::Claimable
|
|
);
|
|
assert_eq!(
|
|
resolve_runtime_profile_task_status(true, 1, 1, true),
|
|
RuntimeProfileTaskStatus::Claimed
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_task_config_input_rejects_invalid_reward_and_threshold() {
|
|
assert_eq!(
|
|
build_runtime_profile_task_config_admin_upsert_input(
|
|
"admin".to_string(),
|
|
PROFILE_TASK_ID_DAILY_LOGIN.to_string(),
|
|
"每日登录".to_string(),
|
|
"".to_string(),
|
|
PROFILE_TASK_EVENT_KEY_DAILY_LOGIN.to_string(),
|
|
RuntimeProfileTaskCycle::Daily,
|
|
RuntimeTrackingScopeKind::User,
|
|
0,
|
|
10,
|
|
true,
|
|
10,
|
|
1,
|
|
)
|
|
.expect_err("zero threshold should fail"),
|
|
RuntimeProfileFieldError::InvalidTaskThreshold
|
|
);
|
|
assert_eq!(
|
|
build_runtime_profile_task_config_admin_upsert_input(
|
|
"admin".to_string(),
|
|
PROFILE_TASK_ID_DAILY_LOGIN.to_string(),
|
|
"每日登录".to_string(),
|
|
"".to_string(),
|
|
PROFILE_TASK_EVENT_KEY_DAILY_LOGIN.to_string(),
|
|
RuntimeProfileTaskCycle::Daily,
|
|
RuntimeTrackingScopeKind::User,
|
|
1,
|
|
0,
|
|
true,
|
|
10,
|
|
1,
|
|
)
|
|
.expect_err("zero reward should fail"),
|
|
RuntimeProfileFieldError::InvalidTaskReward
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_task_config_input_accepts_only_user_scope() {
|
|
let input = build_runtime_profile_task_config_admin_upsert_input(
|
|
"admin".to_string(),
|
|
PROFILE_TASK_ID_DAILY_LOGIN.to_string(),
|
|
"每日登录".to_string(),
|
|
"".to_string(),
|
|
PROFILE_TASK_EVENT_KEY_DAILY_LOGIN.to_string(),
|
|
RuntimeProfileTaskCycle::Daily,
|
|
RuntimeTrackingScopeKind::User,
|
|
1,
|
|
10,
|
|
true,
|
|
10,
|
|
1,
|
|
)
|
|
.expect("user scope should be accepted");
|
|
assert_eq!(input.scope_kind, RuntimeTrackingScopeKind::User);
|
|
|
|
for scope_kind in [
|
|
RuntimeTrackingScopeKind::Site,
|
|
RuntimeTrackingScopeKind::Module,
|
|
RuntimeTrackingScopeKind::Work,
|
|
] {
|
|
assert_eq!(
|
|
build_runtime_profile_task_config_admin_upsert_input(
|
|
"admin".to_string(),
|
|
PROFILE_TASK_ID_DAILY_LOGIN.to_string(),
|
|
"每日登录".to_string(),
|
|
"".to_string(),
|
|
PROFILE_TASK_EVENT_KEY_DAILY_LOGIN.to_string(),
|
|
RuntimeProfileTaskCycle::Daily,
|
|
scope_kind,
|
|
1,
|
|
10,
|
|
true,
|
|
10,
|
|
1,
|
|
)
|
|
.expect_err("non-user scope should fail"),
|
|
RuntimeProfileFieldError::UnsupportedProfileTaskScopeKind
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn recharge_product_catalog_matches_reference_prices() {
|
|
let point_products = runtime_profile_recharge_point_products();
|
|
let membership_products = runtime_profile_recharge_membership_products();
|
|
|
|
assert_eq!(point_products.len(), 4);
|
|
assert_eq!(point_products[0].product_id, "points_60");
|
|
assert_eq!(point_products[0].title, "60泥点");
|
|
assert_eq!(point_products[0].price_cents, 600);
|
|
assert_eq!(point_products[0].bonus_points, 0);
|
|
assert_eq!(point_products[0].badge_label, "");
|
|
assert_eq!(point_products[0].description, "60泥点");
|
|
assert_eq!(point_products[1].product_id, "points_180");
|
|
assert_eq!(point_products[1].bonus_points, 90);
|
|
assert_eq!(point_products[2].bonus_points, 150);
|
|
assert_eq!(point_products[3].product_id, "points_680");
|
|
assert_eq!(point_products[3].price_cents, 6800);
|
|
assert_eq!(point_products[3].bonus_points, 340);
|
|
assert_eq!(point_products[3].description, "首充加赠340泥点");
|
|
assert_eq!(membership_products.len(), 4);
|
|
assert_eq!(membership_products[0].product_id, "member_starter");
|
|
assert_eq!(membership_products[0].title, "Starter");
|
|
assert_eq!(membership_products[0].price_cents, 1990);
|
|
assert_eq!(membership_products[0].membership_period_points, 200);
|
|
assert_eq!(
|
|
membership_products[0].membership_period_days,
|
|
PROFILE_MEMBERSHIP_DEFAULT_PERIOD_DAYS
|
|
);
|
|
assert_eq!(membership_products[3].product_id, "member_ultimate");
|
|
assert_eq!(membership_products[3].membership_period_points, 6000);
|
|
assert_eq!(membership_products[3].membership_queue_limit, 10);
|
|
|
|
let benefits = runtime_profile_membership_benefits();
|
|
assert!(
|
|
benefits
|
|
.iter()
|
|
.any(|benefit| benefit.benefit_name == "每月泥点")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recharge_point_products_do_not_hide_all_first_bonus_by_account_flag() {
|
|
let first_recharge_products = resolve_runtime_profile_recharge_point_products(false);
|
|
assert_eq!(first_recharge_products[0].bonus_points, 0);
|
|
assert_eq!(first_recharge_products[0].badge_label, "");
|
|
assert_eq!(first_recharge_products[0].description, "60泥点");
|
|
assert_eq!(first_recharge_products[1].bonus_points, 90);
|
|
assert_eq!(first_recharge_products[1].badge_label, "首充加赠");
|
|
assert_eq!(first_recharge_products[1].description, "首充加赠90泥点");
|
|
|
|
let repeated_recharge_products = resolve_runtime_profile_recharge_point_products(true);
|
|
assert_eq!(repeated_recharge_products[0].bonus_points, 0);
|
|
assert_eq!(repeated_recharge_products[1].bonus_points, 90);
|
|
assert_eq!(repeated_recharge_products[3].bonus_points, 340);
|
|
assert_eq!(repeated_recharge_products[3].badge_label, "首充加赠");
|
|
assert_eq!(repeated_recharge_products[3].description, "首充加赠340泥点");
|
|
}
|
|
|
|
#[test]
|
|
fn build_recharge_order_input_accepts_configured_product_id_later() {
|
|
let input = build_runtime_profile_recharge_order_create_input(
|
|
"user-1".to_string(),
|
|
"custom-points-600".to_string(),
|
|
"mock".to_string(),
|
|
1,
|
|
)
|
|
.expect("product existence is validated against database config later");
|
|
|
|
assert_eq!(input.product_id, "custom-points-600");
|
|
assert_eq!(input.payment_channel, "mock");
|
|
}
|
|
|
|
#[test]
|
|
fn build_recharge_order_input_rejects_missing_payment_channel() {
|
|
let error = build_runtime_profile_recharge_order_create_input(
|
|
"user-1".to_string(),
|
|
"points_60".to_string(),
|
|
" ".to_string(),
|
|
1,
|
|
)
|
|
.expect_err("missing payment channel should fail");
|
|
|
|
assert_eq!(error, RuntimeProfileFieldError::MissingPaymentChannel);
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_profile_identity_helpers_keep_existing_key_shape() {
|
|
assert_eq!(
|
|
build_runtime_profile_recharge_wallet_ledger_id("user-1", 200, "points_60"),
|
|
"user-1:200:points_60"
|
|
);
|
|
let order_id = build_runtime_profile_recharge_order_id("user-1", 200, "points_60");
|
|
assert!(order_id.starts_with("rcg"));
|
|
assert!(order_id.len() <= 32);
|
|
assert!(
|
|
order_id
|
|
.chars()
|
|
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit())
|
|
);
|
|
assert_eq!(
|
|
build_runtime_profile_redeem_code_usage_id("GIFT", "user-1", 300, 2),
|
|
"redeem:GIFT:user-1:300:2"
|
|
);
|
|
assert_eq!(
|
|
build_runtime_profile_redeem_code_ledger_id("redeem:GIFT:user-1:300:2"),
|
|
"redeem:GIFT:user-1:300:2:ledger"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_profile_membership_purchase_extends_from_active_expiry() {
|
|
let update =
|
|
resolve_runtime_profile_membership_purchase_update(Some(10), Some(200), 100, 30);
|
|
|
|
assert_eq!(update.started_at_micros, 10);
|
|
assert_eq!(
|
|
update.expires_at_micros,
|
|
200 + 30 * PROFILE_RUNTIME_DAY_MICROS
|
|
);
|
|
|
|
let expired_update =
|
|
resolve_runtime_profile_membership_purchase_update(Some(10), Some(80), 100, 1);
|
|
assert_eq!(expired_update.started_at_micros, 10);
|
|
assert_eq!(
|
|
expired_update.expires_at_micros,
|
|
100 + PROFILE_RUNTIME_DAY_MICROS
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recharge_refund_target_points_uses_cumulative_floor_and_finishes_exactly() {
|
|
assert_eq!(
|
|
calculate_runtime_profile_recharge_refund_target_points(270, 1, 1_800),
|
|
Ok(0)
|
|
);
|
|
assert_eq!(
|
|
calculate_runtime_profile_recharge_refund_target_points(270, 600, 1_800),
|
|
Ok(90)
|
|
);
|
|
assert_eq!(
|
|
calculate_runtime_profile_recharge_refund_target_points(270, 1_799, 1_800),
|
|
Ok(269)
|
|
);
|
|
assert_eq!(
|
|
calculate_runtime_profile_recharge_refund_target_points(270, 1_800, 1_800),
|
|
Ok(270)
|
|
);
|
|
assert!(
|
|
calculate_runtime_profile_recharge_refund_target_points(270, 1_801, 1_800).is_err()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recharge_refund_settlement_plan_keeps_partial_paid_and_full_exact() {
|
|
let partial =
|
|
build_runtime_profile_recharge_refund_settlement_plan(0, 0, 0, 600, 1_800, 270)
|
|
.unwrap();
|
|
assert!(!partial.order_fully_refunded);
|
|
assert_eq!(partial.cumulative_success_refund_cents, 600);
|
|
assert_eq!(partial.incremental_target_recovery_points, 90);
|
|
|
|
let full = build_runtime_profile_recharge_refund_settlement_plan(
|
|
partial.successful_refund_count,
|
|
partial.cumulative_success_refund_cents,
|
|
partial.target_recovery_points,
|
|
1_200,
|
|
1_800,
|
|
270,
|
|
)
|
|
.unwrap();
|
|
assert!(full.order_fully_refunded);
|
|
assert_eq!(full.target_recovery_points, 270);
|
|
assert_eq!(full.incremental_target_recovery_points, 180);
|
|
assert!(
|
|
build_runtime_profile_recharge_refund_settlement_plan(
|
|
full.successful_refund_count,
|
|
full.cumulative_success_refund_cents,
|
|
full.target_recovery_points,
|
|
1,
|
|
1_800,
|
|
270,
|
|
)
|
|
.is_err()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recharge_refund_recovery_only_uses_permanent_points() {
|
|
assert_eq!(
|
|
resolve_runtime_profile_recharge_refund_recovery(60, 150, 20, 80),
|
|
(50, 10)
|
|
);
|
|
assert_eq!(
|
|
resolve_runtime_profile_recharge_refund_recovery(60, 100, 20, 80),
|
|
(0, 60)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recharge_refund_recovery_protects_unrelated_holds_and_repaid_debt_uses_permanent_points() {
|
|
assert_eq!(
|
|
resolve_runtime_profile_recharge_refund_recovery_with_holds(60, 150, 20, 80, 30),
|
|
(20, 40)
|
|
);
|
|
assert_eq!(
|
|
resolve_runtime_profile_recharge_refund_recovery_with_holds(60, 170, 40, 80, 30),
|
|
(20, 40),
|
|
"新增每日免费泥点不能偿还退款欠账"
|
|
);
|
|
assert_eq!(
|
|
resolve_runtime_profile_recharge_refund_recovery_with_holds(60, 190, 20, 80, 30),
|
|
(60, 0),
|
|
"新增永久泥点应可偿清退款欠账"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recharge_refund_hold_capacity_rejects_insufficient_permanent_points() {
|
|
assert_eq!(
|
|
validate_runtime_profile_recharge_refund_hold_capacity(90, 270, 0),
|
|
Ok(270)
|
|
);
|
|
assert_eq!(
|
|
validate_runtime_profile_recharge_refund_hold_capacity(180, 270, 90),
|
|
Ok(180)
|
|
);
|
|
assert!(validate_runtime_profile_recharge_refund_hold_capacity(181, 270, 90).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn recharge_refund_partial_hold_reserves_one_point_for_cumulative_rounding_races() {
|
|
assert_eq!(
|
|
resolve_runtime_profile_recharge_refund_hold_points(30, 300, 600),
|
|
31
|
|
);
|
|
assert_eq!(
|
|
resolve_runtime_profile_recharge_refund_hold_points(60, 600, 600),
|
|
60,
|
|
"全额退款的累计目标是精确值,不需要舍入缓冲"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wallet_debit_restrictions_block_manual_freeze_and_refund_debt() {
|
|
let consume = RuntimeProfileWalletLedgerSourceType::AssetOperationConsume;
|
|
assert!(
|
|
validate_runtime_profile_wallet_debit_restrictions(-1, consume, true, false).is_err()
|
|
);
|
|
assert!(
|
|
validate_runtime_profile_wallet_debit_restrictions(-1, consume, false, true).is_err()
|
|
);
|
|
assert!(
|
|
validate_runtime_profile_wallet_debit_restrictions(
|
|
-1,
|
|
RuntimeProfileWalletLedgerSourceType::RechargeRefundRecovery,
|
|
true,
|
|
true,
|
|
)
|
|
.is_ok(),
|
|
"退款追回应绕过消费冻结"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wallet_debit_availability_protects_active_refund_holds() {
|
|
assert_eq!(
|
|
validate_runtime_profile_wallet_debit_availability(100, 40, 60),
|
|
Ok(0)
|
|
);
|
|
assert!(validate_runtime_profile_wallet_debit_availability(100, 40, 61).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn recharge_refund_status_transition_never_regresses_success() {
|
|
assert_eq!(
|
|
resolve_runtime_profile_recharge_refund_status_transition(
|
|
RuntimeProfileRechargeRefundStatus::Processing,
|
|
RuntimeProfileRechargeRefundStatus::Abnormal,
|
|
),
|
|
RuntimeProfileRechargeRefundStatusTransition::Advance
|
|
);
|
|
assert_eq!(
|
|
resolve_runtime_profile_recharge_refund_status_transition(
|
|
RuntimeProfileRechargeRefundStatus::Abnormal,
|
|
RuntimeProfileRechargeRefundStatus::Success,
|
|
),
|
|
RuntimeProfileRechargeRefundStatusTransition::Advance
|
|
);
|
|
assert_eq!(
|
|
resolve_runtime_profile_recharge_refund_status_transition(
|
|
RuntimeProfileRechargeRefundStatus::Success,
|
|
RuntimeProfileRechargeRefundStatus::Closed,
|
|
),
|
|
RuntimeProfileRechargeRefundStatusTransition::Conflict
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recharge_refund_hold_follows_terminal_provider_status_only() {
|
|
use RuntimeProfileRechargeRefundHoldStatus::{Active, Released, Settled};
|
|
use RuntimeProfileRechargeRefundStatus::{Abnormal, Closed, Processing, Success};
|
|
|
|
assert_eq!(
|
|
resolve_runtime_profile_recharge_refund_hold_status(Active, Processing),
|
|
Active
|
|
);
|
|
assert_eq!(
|
|
resolve_runtime_profile_recharge_refund_hold_status(Active, Abnormal),
|
|
Active
|
|
);
|
|
assert_eq!(
|
|
resolve_runtime_profile_recharge_refund_hold_status(Active, Closed),
|
|
Released
|
|
);
|
|
assert_eq!(
|
|
resolve_runtime_profile_recharge_refund_hold_status(Active, Success),
|
|
Settled
|
|
);
|
|
assert_eq!(
|
|
resolve_runtime_profile_recharge_refund_hold_status(Settled, Closed),
|
|
Settled
|
|
);
|
|
assert_eq!(
|
|
resolve_runtime_profile_recharge_refund_hold_status(Released, Success),
|
|
Settled
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recharge_refund_observation_validates_money_and_success_time() {
|
|
let valid = build_runtime_profile_recharge_refund_observation_input(
|
|
"observation-1".to_string(),
|
|
RuntimeProfileRechargeRefundObservationSource::Callback,
|
|
Some("notification-ref".to_string()),
|
|
"payload-ref".to_string(),
|
|
"refund-1".to_string(),
|
|
"wx-refund-1".to_string(),
|
|
"order-1".to_string(),
|
|
"transaction-1".to_string(),
|
|
RuntimeProfileRechargeRefundStatus::Success,
|
|
600,
|
|
600,
|
|
600,
|
|
600,
|
|
Some(100),
|
|
200,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(valid.refund_cents, 600);
|
|
|
|
assert!(
|
|
build_runtime_profile_recharge_refund_observation_input(
|
|
"observation-1".to_string(),
|
|
RuntimeProfileRechargeRefundObservationSource::Callback,
|
|
None,
|
|
"payload-ref".to_string(),
|
|
"refund-1".to_string(),
|
|
"wx-refund-1".to_string(),
|
|
"order-1".to_string(),
|
|
"transaction-1".to_string(),
|
|
RuntimeProfileRechargeRefundStatus::Success,
|
|
600,
|
|
601,
|
|
600,
|
|
600,
|
|
None,
|
|
200,
|
|
)
|
|
.is_err()
|
|
);
|
|
assert!(
|
|
build_runtime_profile_recharge_refund_observation_input(
|
|
"observation-2".to_string(),
|
|
RuntimeProfileRechargeRefundObservationSource::Query,
|
|
None,
|
|
"payload-ref-2".to_string(),
|
|
"refund-2".to_string(),
|
|
"wx-refund-2".to_string(),
|
|
"order-2".to_string(),
|
|
"transaction-2".to_string(),
|
|
RuntimeProfileRechargeRefundStatus::Processing,
|
|
600,
|
|
300,
|
|
601,
|
|
300,
|
|
None,
|
|
200,
|
|
)
|
|
.is_err()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recharge_refund_bill_checkpoint_requires_a_real_calendar_date() {
|
|
assert!(
|
|
build_runtime_profile_recharge_refund_bill_checkpoint_advance_input(
|
|
"wechat-v3-refund-bill".to_string(),
|
|
"2026-02-29".to_string(),
|
|
"sha1-ref".to_string(),
|
|
1,
|
|
100,
|
|
)
|
|
.is_err()
|
|
);
|
|
assert!(
|
|
build_runtime_profile_recharge_refund_bill_checkpoint_advance_input(
|
|
"wechat-v3-refund-bill".to_string(),
|
|
"2026-07-13".to_string(),
|
|
"sha1-ref".to_string(),
|
|
1,
|
|
100,
|
|
)
|
|
.is_ok()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_profile_wallet_balance_calculation_guards_edges() {
|
|
assert_eq!(
|
|
convert_runtime_profile_wallet_unsigned_delta(8).expect("small amount should convert"),
|
|
8
|
|
);
|
|
assert_eq!(
|
|
convert_runtime_profile_wallet_unsigned_delta(i64::MAX as u64 + 1)
|
|
.expect_err("oversized amount should fail"),
|
|
RuntimeProfileFieldError::WalletAmountOverflow
|
|
);
|
|
assert_eq!(
|
|
calculate_runtime_profile_wallet_balance(10, 5).expect("positive delta should add"),
|
|
15
|
|
);
|
|
assert_eq!(
|
|
calculate_runtime_profile_wallet_balance(10, -4)
|
|
.expect("negative delta should subtract"),
|
|
6
|
|
);
|
|
assert_eq!(
|
|
calculate_runtime_profile_wallet_balance(3, -4).expect_err("overspend should fail"),
|
|
RuntimeProfileFieldError::InsufficientWalletBalance
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_profile_redeem_code_usage_validation_matches_modes() {
|
|
let base = RuntimeProfileRedeemCodeSnapshot {
|
|
code: "GIFT".to_string(),
|
|
mode: RuntimeProfileRedeemCodeMode::Public,
|
|
reward_points: 30,
|
|
max_uses: 2,
|
|
global_used_count: 0,
|
|
enabled: true,
|
|
allowed_user_ids: Vec::new(),
|
|
created_by: "admin".to_string(),
|
|
created_at_micros: 1,
|
|
updated_at_micros: 1,
|
|
starts_at_micros: None,
|
|
expires_at_micros: None,
|
|
};
|
|
|
|
validate_runtime_profile_redeem_code_usage(&base, "user-1", 1, 1)
|
|
.expect("public code under per-user limit should pass");
|
|
assert_eq!(
|
|
validate_runtime_profile_redeem_code_usage(&base, "user-1", 2, 1)
|
|
.expect_err("public code over per-user limit should fail"),
|
|
RuntimeProfileFieldError::RedeemCodeUsesExhausted
|
|
);
|
|
|
|
let unique = RuntimeProfileRedeemCodeSnapshot {
|
|
mode: RuntimeProfileRedeemCodeMode::Unique,
|
|
global_used_count: 9,
|
|
..base.clone()
|
|
};
|
|
validate_runtime_profile_redeem_code_usage(&unique, "user-1", 0, 1)
|
|
.expect("unique code should allow a user that has not redeemed it");
|
|
assert_eq!(
|
|
validate_runtime_profile_redeem_code_usage(&unique, "user-1", 1, 1)
|
|
.expect_err("unique code should reject the same user twice"),
|
|
RuntimeProfileFieldError::RedeemCodeUsesExhausted
|
|
);
|
|
|
|
let private = RuntimeProfileRedeemCodeSnapshot {
|
|
mode: RuntimeProfileRedeemCodeMode::Private,
|
|
allowed_user_ids: vec!["user-2".to_string()],
|
|
global_used_count: 9,
|
|
..base.clone()
|
|
};
|
|
assert_eq!(
|
|
validate_runtime_profile_redeem_code_usage(&private, "user-1", 0, 1)
|
|
.expect_err("private code should check allow list"),
|
|
RuntimeProfileFieldError::RedeemCodeNotAllowedForUser
|
|
);
|
|
validate_runtime_profile_redeem_code_usage(&private, "user-2", 0, 1)
|
|
.expect("private code should allow an allowed user that has not redeemed it");
|
|
assert_eq!(
|
|
validate_runtime_profile_redeem_code_usage(&private, "user-2", 1, 1)
|
|
.expect_err("private code should reject the same allowed user twice"),
|
|
RuntimeProfileFieldError::RedeemCodeUsesExhausted
|
|
);
|
|
|
|
let disabled = RuntimeProfileRedeemCodeSnapshot {
|
|
enabled: false,
|
|
..base
|
|
};
|
|
assert_eq!(
|
|
validate_runtime_profile_redeem_code_usage(&disabled, "user-1", 0, 1)
|
|
.expect_err("disabled code should fail"),
|
|
RuntimeProfileFieldError::RedeemCodeDisabled
|
|
);
|
|
}
|
|
|
|
#[cfg(any())]
|
|
#[test]
|
|
fn runtime_save_checkpoint_update_rejects_session_mismatch() {
|
|
let existing = RuntimeSnapshotRecord {
|
|
user_id: "user-1".to_string(),
|
|
version: SAVE_SNAPSHOT_VERSION,
|
|
saved_at: "2026-04-29T00:00:00Z".to_string(),
|
|
saved_at_micros: 1,
|
|
bottom_tab: "story".to_string(),
|
|
game_state: serde_json::json!({
|
|
"runtimeSessionId": "session-old",
|
|
"runtimeStats": {
|
|
"playTimeMs": 10,
|
|
"lastPlayTickAt": "2026-04-29T00:00:00Z"
|
|
}
|
|
}),
|
|
current_story: None,
|
|
game_state_json: "{}".to_string(),
|
|
current_story_json: None,
|
|
created_at_micros: 1,
|
|
updated_at_micros: 1,
|
|
};
|
|
let input = RuntimeSaveCheckpointInput {
|
|
session_id: "session-new".to_string(),
|
|
bottom_tab: "story".to_string(),
|
|
saved_at_micros: 2,
|
|
updated_at_micros: 3,
|
|
};
|
|
|
|
assert_eq!(
|
|
build_runtime_save_checkpoint_update(input, existing)
|
|
.expect_err("mismatched session should fail"),
|
|
RuntimeProfileFieldError::RuntimeSessionMismatch {
|
|
expected_session_id: "session-old".to_string(),
|
|
actual_session_id: "session-new".to_string(),
|
|
}
|
|
);
|
|
}
|
|
}
|