From 07099e3ef09b0dbe9266b2fe7263f73ce6362d29 Mon Sep 17 00:00:00 2001 From: iven Date: Thu, 2 Apr 2026 01:13:15 +0800 Subject: [PATCH] =?UTF-8?q?test(hands):=20expand=20Slideshow=20tests=20(4?= =?UTF-8?q?=E2=86=9234)=20and=20fix=20Clip=20invalid=20action=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slideshow: add navigation edge cases, autoplay/pause/resume, spotlight/ laser/highlight defaults, content block deserialization, Hand trait dispatch, and add_slide helper tests. Clip: fix test_execute_invalid_action to expect Err (execute returns HandError for unknown variants). Co-Authored-By: Claude Opus 4.6 --- crates/zclaw-hands/src/hands/clip.rs | 387 ++++++++++++++++++++++ crates/zclaw-hands/src/hands/slideshow.rs | 369 +++++++++++++++++++++ 2 files changed, 756 insertions(+) diff --git a/crates/zclaw-hands/src/hands/clip.rs b/crates/zclaw-hands/src/hands/clip.rs index 9231235..9a29671 100644 --- a/crates/zclaw-hands/src/hands/clip.rs +++ b/crates/zclaw-hands/src/hands/clip.rs @@ -640,3 +640,390 @@ impl Hand for ClipHand { } } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + // === Config & Defaults === + + #[test] + fn test_hand_config() { + let hand = ClipHand::new(); + assert_eq!(hand.config().id, "clip"); + assert_eq!(hand.config().name, "视频剪辑"); + assert!(!hand.config().needs_approval); + assert!(hand.config().enabled); + assert!(hand.config().tags.contains(&"video".to_string())); + assert!(hand.config().input_schema.is_some()); + } + + #[test] + fn test_default_impl() { + let hand = ClipHand::default(); + assert_eq!(hand.config().id, "clip"); + } + + #[test] + fn test_needs_approval() { + let hand = ClipHand::new(); + assert!(!hand.needs_approval()); + } + + #[test] + fn test_check_dependencies() { + let hand = ClipHand::new(); + let deps = hand.check_dependencies().unwrap(); + // May or may not find ffmpeg depending on test environment + // Just verify it doesn't panic + let _ = deps; + } + + // === VideoFormat === + + #[test] + fn test_video_format_default() { + assert!(matches!(VideoFormat::default(), VideoFormat::Mp4)); + } + + #[test] + fn test_video_format_deserialize() { + let fmt: VideoFormat = serde_json::from_value(json!("mp4")).unwrap(); + assert!(matches!(fmt, VideoFormat::Mp4)); + + let fmt: VideoFormat = serde_json::from_value(json!("webm")).unwrap(); + assert!(matches!(fmt, VideoFormat::Webm)); + + let fmt: VideoFormat = serde_json::from_value(json!("gif")).unwrap(); + assert!(matches!(fmt, VideoFormat::Gif)); + } + + #[test] + fn test_video_format_serialize() { + assert_eq!(serde_json::to_value(&VideoFormat::Mp4).unwrap(), "mp4"); + assert_eq!(serde_json::to_value(&VideoFormat::Webm).unwrap(), "webm"); + } + + // === Resolution === + + #[test] + fn test_resolution_default() { + assert!(matches!(Resolution::default(), Resolution::Original)); + } + + #[test] + fn test_resolution_presets() { + let r: Resolution = serde_json::from_value(json!("p720")).unwrap(); + assert!(matches!(r, Resolution::P720)); + + let r: Resolution = serde_json::from_value(json!("p1080")).unwrap(); + assert!(matches!(r, Resolution::P1080)); + + let r: Resolution = serde_json::from_value(json!("p4k")).unwrap(); + assert!(matches!(r, Resolution::P4k)); + } + + #[test] + fn test_resolution_custom() { + let r: Resolution = serde_json::from_value(json!({"custom": {"width": 800, "height": 600}})).unwrap(); + match r { + Resolution::Custom { width, height } => { + assert_eq!(width, 800); + assert_eq!(height, 600); + } + _ => panic!("Expected Custom"), + } + } + + #[test] + fn test_resolution_serialize() { + assert_eq!(serde_json::to_value(&Resolution::P720).unwrap(), "p720"); + assert_eq!(serde_json::to_value(&Resolution::Original).unwrap(), "original"); + } + + // === TrimConfig === + + #[test] + fn test_trim_config_deserialize() { + let config: TrimConfig = serde_json::from_value(json!({ + "inputPath": "/input.mp4", + "outputPath": "/output.mp4", + "startTime": 5.0, + "duration": 10.0 + })).unwrap(); + assert_eq!(config.input_path, "/input.mp4"); + assert_eq!(config.output_path, "/output.mp4"); + assert_eq!(config.start_time, Some(5.0)); + assert_eq!(config.duration, Some(10.0)); + assert!(config.end_time.is_none()); + } + + #[test] + fn test_trim_config_minimal() { + let config: TrimConfig = serde_json::from_value(json!({ + "inputPath": "/in.mp4", + "outputPath": "/out.mp4" + })).unwrap(); + assert!(config.start_time.is_none()); + assert!(config.end_time.is_none()); + assert!(config.duration.is_none()); + } + + // === ConvertConfig === + + #[test] + fn test_convert_config_deserialize() { + let config: ConvertConfig = serde_json::from_value(json!({ + "inputPath": "/input.avi", + "outputPath": "/output.mp4", + "format": "mp4", + "resolution": "p1080", + "videoBitrate": "4M", + "audioBitrate": "192k" + })).unwrap(); + assert_eq!(config.input_path, "/input.avi"); + assert!(matches!(config.format, VideoFormat::Mp4)); + assert!(matches!(config.resolution, Resolution::P1080)); + assert_eq!(config.video_bitrate, Some("4M".to_string())); + assert_eq!(config.audio_bitrate, Some("192k".to_string())); + } + + #[test] + fn test_convert_config_defaults() { + let config: ConvertConfig = serde_json::from_value(json!({ + "inputPath": "/in.mp4", + "outputPath": "/out.mp4" + })).unwrap(); + assert!(matches!(config.format, VideoFormat::Mp4)); + assert!(matches!(config.resolution, Resolution::Original)); + assert!(config.video_bitrate.is_none()); + assert!(config.audio_bitrate.is_none()); + } + + // === ThumbnailConfig === + + #[test] + fn test_thumbnail_config_deserialize() { + let config: ThumbnailConfig = serde_json::from_value(json!({ + "inputPath": "/video.mp4", + "outputPath": "/thumb.jpg", + "time": 5.0, + "width": 320, + "height": 240 + })).unwrap(); + assert_eq!(config.input_path, "/video.mp4"); + assert_eq!(config.time, 5.0); + assert_eq!(config.width, Some(320)); + assert_eq!(config.height, Some(240)); + } + + #[test] + fn test_thumbnail_config_defaults() { + let config: ThumbnailConfig = serde_json::from_value(json!({ + "inputPath": "/v.mp4", + "outputPath": "/t.jpg" + })).unwrap(); + assert_eq!(config.time, 0.0); + assert!(config.width.is_none()); + assert!(config.height.is_none()); + } + + // === ConcatConfig === + + #[test] + fn test_concat_config_deserialize() { + let config: ConcatConfig = serde_json::from_value(json!({ + "inputPaths": ["/a.mp4", "/b.mp4"], + "outputPath": "/merged.mp4" + })).unwrap(); + assert_eq!(config.input_paths.len(), 2); + assert_eq!(config.output_path, "/merged.mp4"); + } + + // === VideoInfo === + + #[test] + fn test_video_info_deserialize() { + let info: VideoInfo = serde_json::from_value(json!({ + "path": "/test.mp4", + "durationSecs": 120.5, + "width": 1920, + "height": 1080, + "fps": 30.0, + "format": "mp4", + "videoCodec": "h264", + "audioCodec": "aac", + "bitrateKbps": 5000, + "fileSizeBytes": 75_000_000 + })).unwrap(); + assert_eq!(info.path, "/test.mp4"); + assert_eq!(info.duration_secs, 120.5); + assert_eq!(info.width, 1920); + assert_eq!(info.fps, 30.0); + assert_eq!(info.video_codec, "h264"); + assert_eq!(info.audio_codec, Some("aac".to_string())); + assert_eq!(info.bitrate_kbps, Some(5000)); + assert_eq!(info.file_size_bytes, 75_000_000); + } + + // === ClipAction Deserialization === + + #[test] + fn test_action_trim() { + let action: ClipAction = serde_json::from_value(json!({ + "action": "trim", + "config": { + "inputPath": "/in.mp4", + "outputPath": "/out.mp4", + "startTime": 1.0, + "endTime": 5.0 + } + })).unwrap(); + match action { + ClipAction::Trim { config } => { + assert_eq!(config.input_path, "/in.mp4"); + assert_eq!(config.start_time, Some(1.0)); + } + _ => panic!("Expected Trim"), + } + } + + #[test] + fn test_action_convert() { + let action: ClipAction = serde_json::from_value(json!({ + "action": "convert", + "config": { + "inputPath": "/in.avi", + "outputPath": "/out.mp4" + } + })).unwrap(); + assert!(matches!(action, ClipAction::Convert { .. })); + } + + #[test] + fn test_action_resize() { + let action: ClipAction = serde_json::from_value(json!({ + "action": "resize", + "input_path": "/in.mp4", + "output_path": "/out.mp4", + "resolution": "p720" + })).unwrap(); + match action { + ClipAction::Resize { input_path, resolution, .. } => { + assert_eq!(input_path, "/in.mp4"); + assert!(matches!(resolution, Resolution::P720)); + } + _ => panic!("Expected Resize"), + } + } + + #[test] + fn test_action_thumbnail() { + let action: ClipAction = serde_json::from_value(json!({ + "action": "thumbnail", + "config": { + "inputPath": "/in.mp4", + "outputPath": "/thumb.jpg" + } + })).unwrap(); + assert!(matches!(action, ClipAction::Thumbnail { .. })); + } + + #[test] + fn test_action_concat() { + let action: ClipAction = serde_json::from_value(json!({ + "action": "concat", + "config": { + "inputPaths": ["/a.mp4", "/b.mp4"], + "outputPath": "/out.mp4" + } + })).unwrap(); + assert!(matches!(action, ClipAction::Concat { .. })); + } + + #[test] + fn test_action_info() { + let action: ClipAction = serde_json::from_value(json!({ + "action": "info", + "path": "/video.mp4" + })).unwrap(); + match action { + ClipAction::Info { path } => assert_eq!(path, "/video.mp4"), + _ => panic!("Expected Info"), + } + } + + #[test] + fn test_action_check_ffmpeg() { + let action: ClipAction = serde_json::from_value(json!({"action": "check_ffmpeg"})).unwrap(); + assert!(matches!(action, ClipAction::CheckFfmpeg)); + } + + #[test] + fn test_action_invalid() { + let result = serde_json::from_value::(json!({"action": "nonexistent"})); + assert!(result.is_err()); + } + + // === Hand execute dispatch === + + #[tokio::test] + async fn test_execute_check_ffmpeg() { + let hand = ClipHand::new(); + let ctx = HandContext::default(); + let result = hand.execute(&ctx, json!({"action": "check_ffmpeg"})).await.unwrap(); + // Just verify it doesn't crash and returns a valid result + assert!(result.output.is_object()); + // "available" field should exist + assert!(result.output["available"].is_boolean()); + } + + #[tokio::test] + async fn test_execute_invalid_action() { + let hand = ClipHand::new(); + let ctx = HandContext::default(); + let result = hand.execute(&ctx, json!({"action": "bogus"})).await; + assert!(result.is_err()); + } + + // === Status === + + #[test] + fn test_status() { + let hand = ClipHand::new(); + let status = hand.status(); + // Either Idle (ffmpeg found) or Failed (not found) — just verify it doesn't panic + assert!(matches!(status, crate::HandStatus::Idle | crate::HandStatus::Failed)); + } + + // === Roundtrip === + + #[test] + fn test_trim_action_roundtrip() { + let json = json!({ + "action": "trim", + "config": { + "inputPath": "/in.mp4", + "outputPath": "/out.mp4", + "startTime": 2.0, + "duration": 5.0 + } + }); + let action: ClipAction = serde_json::from_value(json).unwrap(); + let serialized = serde_json::to_value(&action).unwrap(); + assert_eq!(serialized["action"], "trim"); + assert_eq!(serialized["config"]["inputPath"], "/in.mp4"); + assert_eq!(serialized["config"]["startTime"], 2.0); + assert_eq!(serialized["config"]["duration"], 5.0); + } + + #[test] + fn test_info_action_roundtrip() { + let json = json!({"action": "info", "path": "/video.mp4"}); + let action: ClipAction = serde_json::from_value(json).unwrap(); + let serialized = serde_json::to_value(&action).unwrap(); + assert_eq!(serialized["action"], "info"); + assert_eq!(serialized["path"], "/video.mp4"); + } +} diff --git a/crates/zclaw-hands/src/hands/slideshow.rs b/crates/zclaw-hands/src/hands/slideshow.rs index 325cbc1..cc10125 100644 --- a/crates/zclaw-hands/src/hands/slideshow.rs +++ b/crates/zclaw-hands/src/hands/slideshow.rs @@ -346,13 +346,50 @@ impl Hand for SlideshowHand { #[cfg(test)] mod tests { use super::*; + use serde_json::json; + + // === Config & Defaults === #[tokio::test] async fn test_slideshow_creation() { let hand = SlideshowHand::new(); assert_eq!(hand.config().id, "slideshow"); + assert_eq!(hand.config().name, "幻灯片"); + assert!(!hand.config().needs_approval); + assert!(hand.config().enabled); + assert!(hand.config().tags.contains(&"presentation".to_string())); } + #[test] + fn test_default_impl() { + let hand = SlideshowHand::default(); + assert_eq!(hand.config().id, "slideshow"); + } + + #[test] + fn test_needs_approval() { + let hand = SlideshowHand::new(); + assert!(!hand.needs_approval()); + } + + #[test] + fn test_status() { + let hand = SlideshowHand::new(); + assert_eq!(hand.status(), HandStatus::Idle); + } + + #[test] + fn test_default_state() { + let state = SlideshowState::default(); + assert_eq!(state.current_slide, 0); + assert_eq!(state.total_slides, 0); + assert!(!state.is_playing); + assert_eq!(state.auto_play_interval_ms, 5000); + assert!(state.slides.is_empty()); + } + + // === Navigation === + #[tokio::test] async fn test_navigation() { let hand = SlideshowHand::with_slides_async(vec![ @@ -374,6 +411,53 @@ mod tests { assert_eq!(hand.get_state().await.current_slide, 1); } + #[tokio::test] + async fn test_next_slide_at_end() { + let hand = SlideshowHand::with_slides_async(vec![ + SlideContent { title: "Only Slide".to_string(), subtitle: None, content: vec![], notes: None, background: None }, + ]).await; + + // At slide 0, should not advance past last slide + hand.execute_action(SlideshowAction::NextSlide).await.unwrap(); + assert_eq!(hand.get_state().await.current_slide, 0); + } + + #[tokio::test] + async fn test_prev_slide_at_beginning() { + let hand = SlideshowHand::with_slides_async(vec![ + SlideContent { title: "Slide 1".to_string(), subtitle: None, content: vec![], notes: None, background: None }, + SlideContent { title: "Slide 2".to_string(), subtitle: None, content: vec![], notes: None, background: None }, + ]).await; + + // At slide 0, should not go below 0 + hand.execute_action(SlideshowAction::PrevSlide).await.unwrap(); + assert_eq!(hand.get_state().await.current_slide, 0); + } + + #[tokio::test] + async fn test_goto_slide_out_of_range() { + let hand = SlideshowHand::with_slides_async(vec![ + SlideContent { title: "Slide 1".to_string(), subtitle: None, content: vec![], notes: None, background: None }, + ]).await; + + let result = hand.execute_action(SlideshowAction::GotoSlide { slide_number: 5 }).await.unwrap(); + assert!(!result.success); + } + + #[tokio::test] + async fn test_goto_slide_returns_content() { + let hand = SlideshowHand::with_slides_async(vec![ + SlideContent { title: "First".to_string(), subtitle: None, content: vec![], notes: None, background: None }, + SlideContent { title: "Second".to_string(), subtitle: None, content: vec![], notes: None, background: None }, + ]).await; + + let result = hand.execute_action(SlideshowAction::GotoSlide { slide_number: 1 }).await.unwrap(); + assert!(result.success); + assert_eq!(result.output["slide_content"]["title"], "Second"); + } + + // === Spotlight & Laser & Highlight === + #[tokio::test] async fn test_spotlight() { let hand = SlideshowHand::new(); @@ -384,6 +468,20 @@ mod tests { let result = hand.execute_action(action).await.unwrap(); assert!(result.success); + assert_eq!(result.output["element_id"], "title"); + assert_eq!(result.output["duration_ms"], 2000); + } + + #[tokio::test] + async fn test_spotlight_default_duration() { + let hand = SlideshowHand::new(); + let action = SlideshowAction::Spotlight { + element_id: "elem".to_string(), + duration_ms: default_spotlight_duration(), + }; + + let result = hand.execute_action(action).await.unwrap(); + assert_eq!(result.output["duration_ms"], 2000); } #[tokio::test] @@ -397,8 +495,96 @@ mod tests { let result = hand.execute_action(action).await.unwrap(); assert!(result.success); + assert_eq!(result.output["x"], 100.0); + assert_eq!(result.output["y"], 200.0); } + #[tokio::test] + async fn test_highlight_default_color() { + let hand = SlideshowHand::new(); + let action = SlideshowAction::Highlight { + x: 10.0, y: 20.0, width: 100.0, height: 50.0, + color: None, duration_ms: 2000, + }; + + let result = hand.execute_action(action).await.unwrap(); + assert!(result.success); + assert_eq!(result.output["color"], "#ffcc00"); + } + + #[tokio::test] + async fn test_highlight_custom_color() { + let hand = SlideshowHand::new(); + let action = SlideshowAction::Highlight { + x: 0.0, y: 0.0, width: 50.0, height: 50.0, + color: Some("#ff0000".to_string()), duration_ms: 1000, + }; + + let result = hand.execute_action(action).await.unwrap(); + assert_eq!(result.output["color"], "#ff0000"); + } + + // === AutoPlay / Pause / Resume === + + #[tokio::test] + async fn test_autoplay_pause_resume() { + let hand = SlideshowHand::new(); + + // AutoPlay + let result = hand.execute_action(SlideshowAction::AutoPlay { interval_ms: 3000 }).await.unwrap(); + assert!(result.success); + assert!(hand.get_state().await.is_playing); + assert_eq!(hand.get_state().await.auto_play_interval_ms, 3000); + + // Pause + hand.execute_action(SlideshowAction::Pause).await.unwrap(); + assert!(!hand.get_state().await.is_playing); + + // Resume + hand.execute_action(SlideshowAction::Resume).await.unwrap(); + assert!(hand.get_state().await.is_playing); + + // Stop + hand.execute_action(SlideshowAction::StopAutoPlay).await.unwrap(); + assert!(!hand.get_state().await.is_playing); + } + + #[tokio::test] + async fn test_autoplay_default_interval() { + let hand = SlideshowHand::new(); + hand.execute_action(SlideshowAction::AutoPlay { interval_ms: default_interval() }).await.unwrap(); + assert_eq!(hand.get_state().await.auto_play_interval_ms, 5000); + } + + // === PlayAnimation === + + #[tokio::test] + async fn test_play_animation() { + let hand = SlideshowHand::new(); + let result = hand.execute_action(SlideshowAction::PlayAnimation { + animation_id: "fade_in".to_string(), + }).await.unwrap(); + + assert!(result.success); + assert_eq!(result.output["animation_id"], "fade_in"); + } + + // === GetState === + + #[tokio::test] + async fn test_get_state() { + let hand = SlideshowHand::with_slides_async(vec![ + SlideContent { title: "A".to_string(), subtitle: None, content: vec![], notes: None, background: None }, + ]).await; + + let result = hand.execute_action(SlideshowAction::GetState).await.unwrap(); + assert!(result.success); + assert_eq!(result.output["total_slides"], 1); + assert_eq!(result.output["current_slide"], 0); + } + + // === SetContent === + #[tokio::test] async fn test_set_content() { let hand = SlideshowHand::new(); @@ -421,5 +607,188 @@ mod tests { assert!(result.success); assert_eq!(hand.get_state().await.total_slides, 1); + assert_eq!(hand.get_state().await.slides[0].title, "Test Slide"); + } + + #[tokio::test] + async fn test_set_content_append() { + let hand = SlideshowHand::with_slides_async(vec![ + SlideContent { title: "First".to_string(), subtitle: None, content: vec![], notes: None, background: None }, + ]).await; + + let content = SlideContent { + title: "Appended".to_string(), subtitle: None, content: vec![], notes: None, background: None, + }; + + let result = hand.execute_action(SlideshowAction::SetContent { + slide_number: 1, + content, + }).await.unwrap(); + + assert!(result.success); + assert_eq!(result.output["status"], "slide_added"); + assert_eq!(hand.get_state().await.total_slides, 2); + } + + #[tokio::test] + async fn test_set_content_invalid_index() { + let hand = SlideshowHand::new(); + + let content = SlideContent { + title: "Gap".to_string(), subtitle: None, content: vec![], notes: None, background: None, + }; + + let result = hand.execute_action(SlideshowAction::SetContent { + slide_number: 5, + content, + }).await.unwrap(); + + assert!(!result.success); + } + + // === Action Deserialization === + + #[test] + fn test_deserialize_next_slide() { + let action: SlideshowAction = serde_json::from_value(json!({"action": "next_slide"})).unwrap(); + assert!(matches!(action, SlideshowAction::NextSlide)); + } + + #[test] + fn test_deserialize_goto_slide() { + let action: SlideshowAction = serde_json::from_value(json!({"action": "goto_slide", "slide_number": 3})).unwrap(); + match action { + SlideshowAction::GotoSlide { slide_number } => assert_eq!(slide_number, 3), + _ => panic!("Expected GotoSlide"), + } + } + + #[test] + fn test_deserialize_laser() { + let action: SlideshowAction = serde_json::from_value(json!({ + "action": "laser", "x": 50.0, "y": 75.0 + })).unwrap(); + match action { + SlideshowAction::Laser { x, y, .. } => { + assert_eq!(x, 50.0); + assert_eq!(y, 75.0); + } + _ => panic!("Expected Laser"), + } + } + + #[test] + fn test_deserialize_autoplay() { + let action: SlideshowAction = serde_json::from_value(json!({"action": "auto_play"})).unwrap(); + match action { + SlideshowAction::AutoPlay { interval_ms } => assert_eq!(interval_ms, 5000), + _ => panic!("Expected AutoPlay"), + } + } + + #[test] + fn test_deserialize_invalid_action() { + let result = serde_json::from_value::(json!({"action": "nonexistent"})); + assert!(result.is_err()); + } + + // === ContentBlock Deserialization === + + #[test] + fn test_content_block_text() { + let block: ContentBlock = serde_json::from_value(json!({ + "type": "text", "text": "Hello" + })).unwrap(); + match block { + ContentBlock::Text { text, style } => { + assert_eq!(text, "Hello"); + assert!(style.is_none()); + } + _ => panic!("Expected Text"), + } + } + + #[test] + fn test_content_block_list() { + let block: ContentBlock = serde_json::from_value(json!({ + "type": "list", "items": ["A", "B"], "ordered": true + })).unwrap(); + match block { + ContentBlock::List { items, ordered } => { + assert_eq!(items, vec!["A", "B"]); + assert!(ordered); + } + _ => panic!("Expected List"), + } + } + + #[test] + fn test_content_block_code() { + let block: ContentBlock = serde_json::from_value(json!({ + "type": "code", "code": "fn main() {}", "language": "rust" + })).unwrap(); + match block { + ContentBlock::Code { code, language } => { + assert_eq!(code, "fn main() {}"); + assert_eq!(language, Some("rust".to_string())); + } + _ => panic!("Expected Code"), + } + } + + #[test] + fn test_content_block_table() { + let block: ContentBlock = serde_json::from_value(json!({ + "type": "table", + "headers": ["Name", "Age"], + "rows": [["Alice", "30"]] + })).unwrap(); + match block { + ContentBlock::Table { headers, rows } => { + assert_eq!(headers, vec!["Name", "Age"]); + assert_eq!(rows, vec![vec!["Alice", "30"]]); + } + _ => panic!("Expected Table"), + } + } + + // === Hand trait via execute === + + #[tokio::test] + async fn test_hand_execute_dispatch() { + let hand = SlideshowHand::with_slides_async(vec![ + SlideContent { title: "S1".to_string(), subtitle: None, content: vec![], notes: None, background: None }, + SlideContent { title: "S2".to_string(), subtitle: None, content: vec![], notes: None, background: None }, + ]).await; + + let ctx = HandContext::default(); + let result = hand.execute(&ctx, json!({"action": "next_slide"})).await.unwrap(); + assert!(result.success); + assert_eq!(result.output["current_slide"], 1); + } + + #[tokio::test] + async fn test_hand_execute_invalid_action() { + let hand = SlideshowHand::new(); + let ctx = HandContext::default(); + let result = hand.execute(&ctx, json!({"action": "invalid"})).await.unwrap(); + assert!(!result.success); + } + + // === add_slide helper === + + #[tokio::test] + async fn test_add_slide() { + let hand = SlideshowHand::new(); + hand.add_slide(SlideContent { + title: "Dynamic".to_string(), subtitle: None, content: vec![], notes: None, background: None, + }).await; + hand.add_slide(SlideContent { + title: "Dynamic 2".to_string(), subtitle: None, content: vec![], notes: None, background: None, + }).await; + + let state = hand.get_state().await; + assert_eq!(state.total_slides, 2); + assert_eq!(state.slides.len(), 2); } }