Files
Genarrative/server-rs/crates/module-runtime/src/lib.rs

944 lines
33 KiB
Rust
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.
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 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,
60,
"首充双倍",
"首充送60泥点",
),
build_points_recharge_product(
"points_180",
"180泥点",
1800,
180,
180,
"首充双倍",
"首充送180泥点",
),
build_points_recharge_product(
"points_300",
"300泥点",
3000,
300,
300,
"首充双倍",
"首充送300泥点",
),
build_points_recharge_product(
"points_680",
"680泥点",
6800,
680,
680,
"首充双倍",
"首充送680泥点",
),
build_points_recharge_product(
"points_1280",
"1280泥点",
12800,
1280,
1280,
"首充双倍",
"首充送1280泥点",
),
build_points_recharge_product(
"points_3280",
"3280泥点",
32800,
3280,
3280,
"首充双倍",
"首充送3280泥点",
),
]
}
/// 中文注释:保留旧展示 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_month",
"月卡",
2800,
30,
RuntimeProfileMembershipTier::Month,
),
build_membership_recharge_product(
"member_season",
"季卡",
7800,
90,
RuntimeProfileMembershipTier::Season,
),
build_membership_recharge_product(
"member_year",
"年卡",
24800,
365,
RuntimeProfileMembershipTier::Year,
),
]
}
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(),
},
RuntimeProfileMembershipBenefitSnapshot {
benefit_name: "免费".to_string(),
normal_value: "免费".to_string(),
month_value: "¥28".to_string(),
season_value: "¥78".to_string(),
year_value: "¥248".to_string(),
},
RuntimeProfileMembershipBenefitSnapshot {
benefit_name: "免泥点回合数".to_string(),
normal_value: "30".to_string(),
month_value: "100".to_string(),
season_value: "100".to_string(),
year_value: "100".to_string(),
},
RuntimeProfileMembershipBenefitSnapshot {
benefit_name: "每日签到加成".to_string(),
normal_value: "0%".to_string(),
month_value: "0%".to_string(),
season_value: "+100%".to_string(),
year_value: "+210%".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,
}
}
fn build_membership_recharge_product(
product_id: &str,
title: &str,
price_cents: u64,
duration_days: u32,
tier: RuntimeProfileMembershipTier,
) -> 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: format!("{}天会员", duration_days),
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);
}
#[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.image_src,
"/child-motion-demo/picture-book-grass-stage.png"
);
}
#[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.image_src, "/creation-type-references/rpg.webp");
}
#[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"
);
}
#[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);
}
#[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
);
}
#[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())
);
}
#[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);
}
#[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
);
assert_eq!(
build_runtime_profile_play_stats_get_input(" ".to_string())
.expect_err("play stats 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),
});
assert_eq!(record.updated_at, Some("2024-04-21T06:13:20Z".to_string()));
}
#[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::AssetOperationConsume.as_str(),
"asset_operation_consume"
);
assert_eq!(
RuntimeProfileWalletLedgerSourceType::AssetOperationRefund.as_str(),
"asset_operation_refund"
);
assert_eq!(
RuntimeProfileWalletLedgerSourceType::DailyTaskReward.as_str(),
"daily_task_reward"
);
}
#[test]
fn new_user_registration_wallet_reward_starts_with_ten_points() {
assert_eq!(PROFILE_NEW_USER_INITIAL_WALLET_POINTS, 10);
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"),
10
);
}
#[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(), 6);
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, 60);
assert_eq!(point_products[0].description, "首充送60泥点");
assert_eq!(point_products[5].product_id, "points_3280");
assert_eq!(point_products[5].price_cents, 32800);
assert_eq!(point_products[5].bonus_points, 3280);
assert_eq!(point_products[5].description, "首充送3280泥点");
assert_eq!(membership_products.len(), 3);
assert_eq!(membership_products[0].title, "月卡");
assert_eq!(membership_products[0].price_cents, 2800);
assert_eq!(membership_products[2].duration_days, 365);
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, 60);
assert_eq!(first_recharge_products[0].badge_label, "首充双倍");
assert_eq!(first_recharge_products[0].description, "首充送60泥点");
let repeated_recharge_products = resolve_runtime_profile_recharge_point_products(true);
assert_eq!(repeated_recharge_products[0].bonus_points, 60);
assert_eq!(repeated_recharge_products[0].badge_label, "首充双倍");
assert_eq!(repeated_recharge_products[0].description, "首充送60泥点");
assert_eq!(repeated_recharge_products[5].bonus_points, 3280);
assert_eq!(repeated_recharge_products[5].badge_label, "首充双倍");
assert_eq!(repeated_recharge_products[5].description, "首充送3280泥点");
}
#[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_played_world_id(" user-1 ", " custom:world "),
"user-1:custom:world"
);
assert_eq!(
build_runtime_profile_snapshot_wallet_ledger_id(" user-1 ", 100, 30),
"user-1:100:30"
);
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 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,
};
validate_runtime_profile_redeem_code_usage(&base, "user-1", 1)
.expect("public code under per-user limit should pass");
assert_eq!(
validate_runtime_profile_redeem_code_usage(&base, "user-1", 2)
.expect_err("public code over per-user limit should fail"),
RuntimeProfileFieldError::RedeemCodeUsesExhausted
);
let private = RuntimeProfileRedeemCodeSnapshot {
mode: RuntimeProfileRedeemCodeMode::Private,
allowed_user_ids: vec!["user-2".to_string()],
..base.clone()
};
assert_eq!(
validate_runtime_profile_redeem_code_usage(&private, "user-1", 0)
.expect_err("private code should check allow list"),
RuntimeProfileFieldError::RedeemCodeNotAllowedForUser
);
let disabled = RuntimeProfileRedeemCodeSnapshot {
enabled: false,
..base
};
assert_eq!(
validate_runtime_profile_redeem_code_usage(&disabled, "user-1", 0)
.expect_err("disabled code should fail"),
RuntimeProfileFieldError::RedeemCodeDisabled
);
}
#[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(),
}
);
}
}