use shared_kernel::{normalize_optional_string, normalize_required_string, normalize_string_list}; use crate::commands::{default_tags_for_theme, validate_publish_requirements}; use crate::{ SQUARE_HOLE_DEFAULT_DURATION_LIMIT_MS, SQUARE_HOLE_MAX_DIFFICULTY, SQUARE_HOLE_MAX_HOLE_OPTION_COUNT, SQUARE_HOLE_MIN_DIFFICULTY, SQUARE_HOLE_MIN_HOLE_OPTION_COUNT, SQUARE_HOLE_MIN_SHAPE_OPTION_COUNT, SquareHoleCreatorConfig, SquareHoleDropConfirmation, SquareHoleDropFeedback, SquareHoleDropInput, SquareHoleDropRejectReason, SquareHoleError, SquareHoleHoleOption, SquareHoleHoleSnapshot, SquareHolePublicationStatus, SquareHoleResultDraft, SquareHoleRunSnapshot, SquareHoleRunStatus, SquareHoleShapeOption, SquareHoleShapeSnapshot, SquareHoleWorkProfile, }; pub fn compile_result_draft( profile_id: String, config: &SquareHoleCreatorConfig, ) -> SquareHoleResultDraft { let game_name = format!("{}方洞挑战", config.theme_text); let hole_options = normalize_hole_options(config.hole_options.clone(), &config.theme_text); let shape_options = normalize_shape_options( config.shape_options.clone(), &config.theme_text, hole_options.as_slice(), ); let background_prompt = normalize_required_string(&config.background_prompt) .unwrap_or_else(|| default_background_prompt(&config.theme_text)); let summary = format!( "{}主题,{} 个形状,难度 {},真实规则:{}。", config.theme_text, config.shape_count, config.difficulty, config.twist_rule ); let mut draft = SquareHoleResultDraft { profile_id, game_name, theme_text: config.theme_text.clone(), twist_rule: config.twist_rule.clone(), summary, tags: default_tags_for_theme(&config.theme_text), cover_image_src: config.cover_image_src.clone(), background_prompt, background_image_src: config.background_image_src.clone(), shape_options, hole_options, shape_count: config.shape_count, difficulty: config.difficulty, publish_ready: false, blockers: Vec::new(), }; draft.blockers = validate_publish_requirements(&draft); draft.publish_ready = draft.blockers.is_empty(); draft } pub fn create_work_profile( work_id: String, profile_id: String, owner_user_id: String, source_session_id: Option, draft: &SquareHoleResultDraft, updated_at_micros: i64, ) -> Result { let work_id = normalize_required_string(work_id).ok_or(SquareHoleError::MissingText)?; let profile_id = normalize_required_string(profile_id).ok_or(SquareHoleError::MissingProfileId)?; let owner_user_id = normalize_required_string(owner_user_id).ok_or(SquareHoleError::MissingOwnerUserId)?; Ok(SquareHoleWorkProfile { work_id, profile_id, owner_user_id, source_session_id: normalize_optional_string(source_session_id), game_name: draft.game_name.clone(), theme_text: draft.theme_text.clone(), twist_rule: draft.twist_rule.clone(), summary: draft.summary.clone(), tags: normalize_string_list(draft.tags.clone()), cover_image_src: draft.cover_image_src.clone(), background_prompt: draft.background_prompt.clone(), background_image_src: draft.background_image_src.clone(), hole_options: { normalize_hole_options(draft.hole_options.clone(), &draft.theme_text) }, shape_options: normalize_shape_options( draft.shape_options.clone(), &draft.theme_text, normalize_hole_options(draft.hole_options.clone(), &draft.theme_text).as_slice(), ), shape_count: draft.shape_count, difficulty: draft.difficulty, publication_status: SquareHolePublicationStatus::Draft, play_count: 0, updated_at_micros, published_at_micros: None, }) } pub fn publish_work_profile( profile: &SquareHoleWorkProfile, published_at_micros: i64, ) -> Result { if profile.shape_count == 0 { return Err(SquareHoleError::InvalidShapeCount); } if !(SQUARE_HOLE_MIN_DIFFICULTY..=SQUARE_HOLE_MAX_DIFFICULTY).contains(&profile.difficulty) { return Err(SquareHoleError::InvalidDifficulty); } let mut next = profile.clone(); next.publication_status = SquareHolePublicationStatus::Published; next.updated_at_micros = published_at_micros; next.published_at_micros = Some(published_at_micros); Ok(next) } pub fn start_run_at( run_id: String, owner_user_id: String, profile_id: String, config: &SquareHoleCreatorConfig, started_at_ms: u64, ) -> Result { let run_id = normalize_required_string(run_id).ok_or(SquareHoleError::MissingRunId)?; let owner_user_id = normalize_required_string(owner_user_id).ok_or(SquareHoleError::MissingOwnerUserId)?; let profile_id = normalize_required_string(profile_id).ok_or(SquareHoleError::MissingProfileId)?; let hole_options = normalize_hole_options(config.hole_options.clone(), &config.theme_text); let shape_options = normalize_shape_options( config.shape_options.clone(), &config.theme_text, hole_options.as_slice(), ); let current_shape = build_shape_at(0, config.shape_count, shape_options.as_slice(), &run_id); Ok(SquareHoleRunSnapshot { run_id, profile_id, owner_user_id, status: SquareHoleRunStatus::Running, snapshot_version: 1, started_at_ms, duration_limit_ms: SQUARE_HOLE_DEFAULT_DURATION_LIMIT_MS, remaining_ms: SQUARE_HOLE_DEFAULT_DURATION_LIMIT_MS, total_shape_count: config.shape_count, completed_shape_count: 0, combo: 0, best_combo: 0, score: 0, rule_label: config.twist_rule.clone(), background_image_src: config.background_image_src.clone(), current_shape: Some(current_shape), shape_options, holes: build_holes(hole_options.as_slice()), last_feedback: None, }) } pub fn confirm_drop_at( run: &SquareHoleRunSnapshot, input: &SquareHoleDropInput, ) -> Result { let hole_id = normalize_required_string(&input.hole_id).ok_or(SquareHoleError::MissingHoleId)?; let mut next = resolve_run_timer_at(run, input.dropped_at_ms); if next.status != SquareHoleRunStatus::Running { return Ok(rejected(next, SquareHoleDropRejectReason::RunNotActive)); } if input.client_snapshot_version != next.snapshot_version { return Ok(rejected( next, SquareHoleDropRejectReason::SnapshotVersionMismatch, )); } let Some(hole) = next.holes.iter().find(|item| item.hole_id == hole_id) else { return Ok(rejected(next, SquareHoleDropRejectReason::HoleNotFound)); }; let Some(shape) = next.current_shape.clone() else { return Ok(rejected(next, SquareHoleDropRejectReason::Incompatible)); }; if !is_shape_accepted_by_hole(&shape, hole) { next.combo = 0; next.snapshot_version = next.snapshot_version.saturating_add(1); return Ok(rejected(next, SquareHoleDropRejectReason::Incompatible)); } let message = format!("{}进入{}", shape.label, hole.label); let feedback = SquareHoleDropFeedback { accepted: true, reject_reason: None, message, }; next.completed_shape_count = next.completed_shape_count.saturating_add(1); next.combo = next.combo.saturating_add(1); next.best_combo = next.best_combo.max(next.combo); next.score = next.score.saturating_add(100 + next.combo * 10); next.current_shape = if next.completed_shape_count >= next.total_shape_count { next.status = SquareHoleRunStatus::Won; None } else { Some(build_shape_from_previous_options( next.completed_shape_count, next.total_shape_count, next.shape_options.as_slice(), next.run_id.as_str(), )) }; next.snapshot_version = next.snapshot_version.saturating_add(1); next.last_feedback = Some(feedback.clone()); Ok(SquareHoleDropConfirmation { feedback, run: next, }) } pub fn resolve_run_timer_at(run: &SquareHoleRunSnapshot, now_ms: u64) -> SquareHoleRunSnapshot { let mut next = run.clone(); if next.status != SquareHoleRunStatus::Running { return next; } let elapsed_ms = now_ms.saturating_sub(next.started_at_ms); next.remaining_ms = next.duration_limit_ms.saturating_sub(elapsed_ms); if next.remaining_ms == 0 { let feedback = SquareHoleDropFeedback { accepted: false, reject_reason: Some(SquareHoleDropRejectReason::TimeUp), message: "时间到".to_string(), }; next.status = SquareHoleRunStatus::Failed; next.combo = 0; next.current_shape = None; next.last_feedback = Some(feedback); next.snapshot_version = next.snapshot_version.saturating_add(1); } next } pub fn stop_run_at(run: &SquareHoleRunSnapshot) -> SquareHoleRunSnapshot { let mut next = run.clone(); if next.status == SquareHoleRunStatus::Running { next.status = SquareHoleRunStatus::Stopped; next.combo = 0; next.snapshot_version = next.snapshot_version.saturating_add(1); next.last_feedback = Some(SquareHoleDropFeedback { accepted: false, reject_reason: Some(SquareHoleDropRejectReason::RunNotActive), message: "已退出本局".to_string(), }); } next } pub fn build_shape_at( index: u32, total: u32, options: &[SquareHoleShapeOption], run_seed: &str, ) -> SquareHoleShapeSnapshot { if let Some(option) = pick_shape_option(index, options, run_seed) { let shape_kind = option.shape_kind; let label = option.label; return SquareHoleShapeSnapshot { shape_id: format!("square-hole-shape-{index:03}"), color: fallback_shape_color(&shape_kind).to_string(), shape_kind, label, target_hole_id: option.target_hole_id, image_src: option.image_src, }; } let kind = if index + 1 == total { "square" } else if index % 4 == 0 { "circle" } else if index % 4 == 1 { "triangle" } else if index % 4 == 2 { "diamond" } else { "star" }; SquareHoleShapeSnapshot { shape_id: format!("square-hole-shape-{index:03}"), shape_kind: kind.to_string(), label: match kind { "square" => "方块", "circle" => "圆块", "triangle" => "三角块", "diamond" => "菱形块", _ => "星形块", } .to_string(), target_hole_id: fallback_target_hole_id(index).to_string(), color: match kind { "square" => "#facc15", "circle" => "#22c55e", "triangle" => "#38bdf8", "diamond" => "#fb7185", _ => "#c084fc", } .to_string(), image_src: None, } } pub fn default_holes() -> Vec { default_hole_options("玩具") .into_iter() .enumerate() .map(|(index, option)| { let positions = [(0.5, 0.28), (0.24, 0.54), (0.76, 0.54)]; let (x, y) = positions[index.min(positions.len() - 1)]; SquareHoleHoleSnapshot { hole_id: option.hole_id, hole_kind: option.hole_kind, label: option.label, x, y, image_src: option.image_src, } }) .collect() } pub fn default_shape_options(theme_text: &str, hole_ids: &[String]) -> Vec { let theme = normalize_required_string(theme_text).unwrap_or_else(|| "玩具".to_string()); let default_hole_ids = if hole_ids.is_empty() { default_hole_options(theme_text) .into_iter() .map(|option| option.hole_id) .collect::>() } else { hole_ids.to_vec() }; [ ("square", "方块"), ("circle", "圆块"), ("triangle", "三角块"), ("diamond", "菱形块"), ("star", "星形块"), ("arch", "拱形块"), ] .into_iter() .enumerate() .map(|(index, (kind, label))| SquareHoleShapeOption { option_id: format!("{kind}-option"), shape_kind: kind.to_string(), label: label.to_string(), target_hole_id: default_hole_ids[index % default_hole_ids.len()].clone(), image_prompt: format!("{theme}主题的{label}贴纸图,透明背景,明亮可爱,游戏资产"), image_src: None, }) .collect() } pub fn default_hole_options(theme_text: &str) -> Vec { let theme = normalize_required_string(theme_text).unwrap_or_else(|| "玩具".to_string()); vec![ SquareHoleHoleOption { hole_id: "hole-1".to_string(), hole_kind: "hole-1".to_string(), label: "洞口 1".to_string(), image_prompt: format!("{theme}主题的第一个洞口贴纸图,透明背景,明亮可爱,游戏资产"), image_src: None, }, SquareHoleHoleOption { hole_id: "hole-2".to_string(), hole_kind: "hole-2".to_string(), label: "洞口 2".to_string(), image_prompt: format!("{theme}主题的第二个洞口贴纸图,透明背景,明亮可爱,游戏资产"), image_src: None, }, SquareHoleHoleOption { hole_id: "hole-3".to_string(), hole_kind: "hole-3".to_string(), label: "洞口 3".to_string(), image_prompt: format!("{theme}主题的第三个洞口贴纸图,透明背景,明亮可爱,游戏资产"), image_src: None, }, ] } pub fn normalize_shape_options( options: Vec, theme_text: &str, hole_options: &[SquareHoleHoleOption], ) -> Vec { let hole_ids = if hole_options.is_empty() { default_hole_options(theme_text) .into_iter() .map(|option| option.hole_id) .collect::>() } else { hole_options .iter() .map(|option| option.hole_id.clone()) .collect::>() }; let mut normalized = Vec::new(); for (index, option) in options.into_iter().enumerate() { let shape_kind = normalize_required_string(&option.shape_kind) .unwrap_or_else(|| fallback_shape_kind(index)); let label = normalize_required_string(&option.label) .unwrap_or_else(|| fallback_shape_label(&shape_kind).to_string()); let option_id = normalize_required_string(&option.option_id) .unwrap_or_else(|| format!("{shape_kind}-option-{index}")); let target_hole_id = normalize_required_string(&option.target_hole_id) .filter(|value| hole_ids.iter().any(|hole_id| hole_id == value)) .unwrap_or_else(|| hole_ids[index % hole_ids.len()].clone()); let image_prompt = normalize_required_string(&option.image_prompt).unwrap_or_else(|| { format!( "{}主题的{}贴纸图,透明背景,明亮可爱,游戏资产", normalize_required_string(theme_text).unwrap_or_else(|| "玩具".to_string()), label ) }); normalized.push(SquareHoleShapeOption { option_id, shape_kind, label, target_hole_id, image_prompt, image_src: option.image_src.and_then(normalize_required_string), }); } let defaults = default_shape_options(theme_text, hole_ids.as_slice()); let mut default_index = 0; while normalized.len() < SQUARE_HOLE_MIN_SHAPE_OPTION_COUNT { let mut fallback = defaults[default_index % defaults.len()].clone(); if normalized .iter() .any(|option| option.option_id == fallback.option_id) { fallback.option_id = format!("{}-{}", fallback.option_id, normalized.len()); } normalized.push(fallback); default_index += 1; } normalized } pub fn normalize_hole_options( options: Vec, theme_text: &str, ) -> Vec { let mut normalized = Vec::new(); for (index, option) in options .into_iter() .take(SQUARE_HOLE_MAX_HOLE_OPTION_COUNT) .enumerate() { let hole_kind = normalize_required_string(&option.hole_kind) .unwrap_or_else(|| format!("hole-{}", index + 1)); let label = normalize_required_string(&option.label) .unwrap_or_else(|| fallback_hole_label(index).to_string()); let hole_id = normalize_required_string(&option.hole_id) .unwrap_or_else(|| format!("hole-{}", index + 1)); normalized.push(SquareHoleHoleOption { hole_id, hole_kind, label, image_prompt: normalize_required_string(&option.image_prompt).unwrap_or_else(|| { format!( "{}主题的{}贴纸图,透明背景,明亮可爱,游戏资产", normalize_required_string(theme_text).unwrap_or_else(|| "玩具".to_string()), fallback_hole_label(index) ) }), image_src: option.image_src.and_then(normalize_required_string), }); } for fallback in default_hole_options(theme_text) { if normalized.len() >= SQUARE_HOLE_MIN_HOLE_OPTION_COUNT { break; } if !normalized .iter() .any(|option| option.hole_id == fallback.hole_id) { normalized.push(fallback); } } normalized } pub fn default_background_prompt(theme_text: &str) -> String { format!( "{}主题的竖屏游戏背景,舞台中央有洞口面板,明亮夸张,适合移动端小游戏", normalize_required_string(theme_text).unwrap_or_else(|| "玩具".to_string()) ) } fn build_holes(options: &[SquareHoleHoleOption]) -> Vec { let normalized = normalize_hole_options(options.to_vec(), "玩具"); let positions = [ (0.5, 0.28), (0.24, 0.54), (0.76, 0.54), (0.24, 0.78), (0.5, 0.78), (0.76, 0.78), ]; normalized .into_iter() .enumerate() .map(|(index, option)| { let (x, y) = positions[index.min(positions.len() - 1)]; SquareHoleHoleSnapshot { hole_id: option.hole_id, hole_kind: option.hole_kind, label: option.label, x, y, image_src: option.image_src, } }) .collect() } fn build_shape_from_previous_options( index: u32, total: u32, options: &[SquareHoleShapeOption], run_seed: &str, ) -> SquareHoleShapeSnapshot { build_shape_at(index, total, options, run_seed) } fn pick_shape_option( index: u32, options: &[SquareHoleShapeOption], run_seed: &str, ) -> Option { if options.is_empty() { return None; } let base_seed = run_seed.as_bytes().iter().fold(index, |current, byte| { current.wrapping_mul(31).wrapping_add(u32::from(*byte)) }); let seed = options .iter() .enumerate() .map(|(option_index, option)| { let mut hash = base_seed.wrapping_add(option_index as u32).wrapping_mul(97); for byte in option.option_id.as_bytes() { hash = hash.wrapping_mul(33).wrapping_add(u32::from(*byte)); } hash }) .fold(0u32, u32::wrapping_add); options.get((seed as usize) % options.len()).cloned() } fn fallback_shape_kind(index: usize) -> String { match index % 6 { 0 => "square", 1 => "circle", 2 => "triangle", 3 => "diamond", 4 => "star", _ => "arch", } .to_string() } fn fallback_shape_label(kind: &str) -> &'static str { match kind { "square" => "方块", "circle" => "圆块", "triangle" => "三角块", "diamond" => "菱形块", "star" => "星形块", "arch" => "拱形块", _ => "形状块", } } fn fallback_hole_label(index: usize) -> String { format!("洞口 {}", index + 1) } fn fallback_shape_color(kind: &str) -> &'static str { match kind { "square" => "#facc15", "circle" => "#22c55e", "triangle" => "#38bdf8", "diamond" => "#fb7185", "star" => "#c084fc", "arch" => "#f97316", _ => "#f8fafc", } } fn is_shape_accepted_by_hole( shape: &SquareHoleShapeSnapshot, hole: &SquareHoleHoleSnapshot, ) -> bool { shape.target_hole_id == hole.hole_id } fn fallback_target_hole_id(index: u32) -> &'static str { match index % 3 { 0 => "hole-1", 1 => "hole-2", _ => "hole-3", } } fn rejected( mut run: SquareHoleRunSnapshot, reject_reason: SquareHoleDropRejectReason, ) -> SquareHoleDropConfirmation { let message = match reject_reason { SquareHoleDropRejectReason::RunNotActive => "当前局已结束", SquareHoleDropRejectReason::SnapshotVersionMismatch => "操作慢了一步", SquareHoleDropRejectReason::HoleNotFound => "洞口不存在", SquareHoleDropRejectReason::Incompatible => "这个洞不对", SquareHoleDropRejectReason::TimeUp => "时间到", } .to_string(); let feedback = SquareHoleDropFeedback { accepted: false, reject_reason: Some(reject_reason), message, }; run.last_feedback = Some(feedback.clone()); SquareHoleDropConfirmation { feedback, run } } #[cfg(test)] mod tests { use super::*; use crate::commands::build_creator_config; fn test_config(shape_count: u32) -> SquareHoleCreatorConfig { build_creator_config("玩具", "方洞万能", shape_count, 4).expect("config should be valid") } fn test_config_with_custom_targets(shape_count: u32) -> SquareHoleCreatorConfig { SquareHoleCreatorConfig { hole_options: vec![ SquareHoleHoleOption { hole_id: "hole-alpha".to_string(), hole_kind: "hole-alpha".to_string(), label: "洞口 Alpha".to_string(), image_prompt: "玩具主题的 Alpha 洞口贴纸图".to_string(), image_src: None, }, SquareHoleHoleOption { hole_id: "hole-beta".to_string(), hole_kind: "hole-beta".to_string(), label: "洞口 Beta".to_string(), image_prompt: "玩具主题的 Beta 洞口贴纸图".to_string(), image_src: None, }, SquareHoleHoleOption { hole_id: "hole-gamma".to_string(), hole_kind: "hole-gamma".to_string(), label: "洞口 Gamma".to_string(), image_prompt: "玩具主题的 Gamma 洞口贴纸图".to_string(), image_src: None, }, ], shape_options: vec![SquareHoleShapeOption { option_id: "shape-alpha".to_string(), shape_kind: "square".to_string(), label: "Alpha 形状".to_string(), target_hole_id: "hole-alpha".to_string(), image_prompt: "玩具主题的 Alpha 形状贴纸图".to_string(), image_src: None, }], ..test_config(shape_count) } } #[test] fn draft_is_publishable_with_required_fields() { let draft = compile_result_draft("profile-1".to_string(), &test_config(8)); assert!(draft.publish_ready); assert!(draft.blockers.is_empty()); assert!(draft.tags.contains(&"方洞挑战".to_string())); } #[test] fn run_starts_with_current_shape_and_default_holes() { let run = start_run_at( "run-1".to_string(), "user-1".to_string(), "profile-1".to_string(), &test_config(8), 1_000, ) .expect("run should start"); assert_eq!(run.status, SquareHoleRunStatus::Running); assert!(run.current_shape.is_some()); assert_eq!(run.holes.len(), 3); } #[test] fn target_hole_accepts_current_shape() { let run = start_run_at( "run-1".to_string(), "user-1".to_string(), "profile-1".to_string(), &test_config(8), 1_000, ) .expect("run should start"); let result = confirm_drop_at( &run, &SquareHoleDropInput { run_id: run.run_id.clone(), owner_user_id: run.owner_user_id.clone(), hole_id: run.current_shape.as_ref().unwrap().target_hole_id.clone(), client_snapshot_version: run.snapshot_version, client_event_id: "event-1".to_string(), dropped_at_ms: 1_100, }, ) .expect("drop should resolve"); assert!(result.feedback.accepted); assert_eq!(result.run.completed_shape_count, 1); assert_eq!(result.run.combo, 1); } #[test] fn accepted_drop_uses_base_combo_score() { let run = start_run_at( "run-1".to_string(), "user-1".to_string(), "profile-1".to_string(), &test_config_with_custom_targets(8), 1_000, ) .expect("run should start"); let result = confirm_drop_at( &run, &SquareHoleDropInput { run_id: run.run_id.clone(), owner_user_id: run.owner_user_id.clone(), hole_id: run.current_shape.as_ref().unwrap().target_hole_id.clone(), client_snapshot_version: run.snapshot_version, client_event_id: "event-1".to_string(), dropped_at_ms: 1_100, }, ) .expect("drop should resolve"); assert!(result.feedback.accepted); assert_eq!(result.run.score, 110); } #[test] fn wrong_target_hole_rejects_and_resets_combo() { let mut run = start_run_at( "run-1".to_string(), "user-1".to_string(), "profile-1".to_string(), &test_config_with_custom_targets(8), 1_000, ) .expect("run should start"); run.combo = 2; let target_hole_id = run.current_shape.as_ref().unwrap().target_hole_id.clone(); let wrong_hole_id = run .holes .iter() .find(|hole| hole.hole_id != target_hole_id) .expect("test run should have a non-target hole") .hole_id .clone(); let result = confirm_drop_at( &run, &SquareHoleDropInput { run_id: run.run_id.clone(), owner_user_id: run.owner_user_id.clone(), hole_id: wrong_hole_id, client_snapshot_version: run.snapshot_version, client_event_id: "event-1".to_string(), dropped_at_ms: 1_100, }, ) .expect("drop should resolve"); assert!(!result.feedback.accepted); assert_eq!( result.feedback.reject_reason, Some(SquareHoleDropRejectReason::Incompatible) ); assert_eq!(result.run.combo, 0); } #[test] fn last_shape_win_finishes_run() { let mut run = start_run_at( "run-1".to_string(), "user-1".to_string(), "profile-1".to_string(), &test_config(6), 1_000, ) .expect("run should start"); run.completed_shape_count = 5; run.current_shape = Some(build_shape_at(5, 6, &[], run.run_id.as_str())); let result = confirm_drop_at( &run, &SquareHoleDropInput { run_id: run.run_id.clone(), owner_user_id: run.owner_user_id.clone(), hole_id: run.current_shape.as_ref().unwrap().target_hole_id.clone(), client_snapshot_version: run.snapshot_version, client_event_id: "event-1".to_string(), dropped_at_ms: 1_100, }, ) .expect("drop should resolve"); assert_eq!(result.run.status, SquareHoleRunStatus::Won); assert!(result.run.current_shape.is_none()); } #[test] fn timer_expiration_fails_running_run() { let run = start_run_at( "run-1".to_string(), "user-1".to_string(), "profile-1".to_string(), &test_config(8), 1_000, ) .expect("run should start"); let expired = resolve_run_timer_at(&run, 1_000 + SQUARE_HOLE_DEFAULT_DURATION_LIMIT_MS); assert_eq!(expired.status, SquareHoleRunStatus::Failed); assert_eq!( expired .last_feedback .and_then(|feedback| feedback.reject_reason), Some(SquareHoleDropRejectReason::TimeUp) ); } }