test(hands): expand Slideshow tests (4→34) and fix Clip invalid action test

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 <noreply@anthropic.com>
This commit is contained in:
iven
2026-04-02 01:13:15 +08:00
parent dce9035584
commit 07099e3ef0
2 changed files with 756 additions and 0 deletions

View File

@@ -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::<ClipAction>(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");
}
}

View File

@@ -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::<SlideshowAction>(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);
}
}