//! Quiz Hand - Assessment and evaluation capabilities //! //! Provides quiz functionality for teaching: //! - generate: Generate quiz questions from content //! - show: Display a quiz to users //! - submit: Submit an answer //! - grade: Grade submitted answers //! - analyze: Analyze quiz performance use async_trait::async_trait; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::sync::Arc; use tokio::sync::RwLock; use uuid::Uuid; use zclaw_types::Result; use zclaw_runtime::driver::{LlmDriver, CompletionRequest}; use crate::{Hand, HandConfig, HandContext, HandResult, HandStatus}; /// Trait for generating quiz questions using LLM or other AI #[async_trait] pub trait QuizGenerator: Send + Sync { /// Generate quiz questions based on topic and content async fn generate_questions( &self, topic: &str, content: Option<&str>, count: usize, difficulty: &DifficultyLevel, question_types: &[QuestionType], ) -> Result>; } /// Default placeholder generator (used when no LLM is configured) pub struct DefaultQuizGenerator; #[async_trait] impl QuizGenerator for DefaultQuizGenerator { async fn generate_questions( &self, topic: &str, _content: Option<&str>, count: usize, difficulty: &DifficultyLevel, _question_types: &[QuestionType], ) -> Result> { // Generate placeholder questions with randomized correct answers let options_pool: Vec> = vec![ vec!["光合作用".into(), "呼吸作用".into(), "蒸腾作用".into(), "运输作用".into()], vec!["牛顿".into(), "爱因斯坦".into(), "伽利略".into(), "开普勒".into()], vec!["太平洋".into(), "大西洋".into(), "印度洋".into(), "北冰洋".into()], vec!["DNA".into(), "RNA".into(), "蛋白质".into(), "碳水化合物".into()], vec!["引力".into(), "电磁力".into(), "强力".into(), "弱力".into()], ]; Ok((0..count) .map(|i| { let pool_idx = i % options_pool.len(); let mut opts = options_pool[pool_idx].clone(); // Shuffle options to randomize correct answer position let correct_idx = (i * 3 + 1) % opts.len(); opts.swap(0, correct_idx); let correct = opts[0].clone(); QuizQuestion { id: uuid_v4(), question_type: QuestionType::MultipleChoice, question: format!("关于{}的第{}题({}难度)", topic, i + 1, match difficulty { DifficultyLevel::Easy => "简单", DifficultyLevel::Medium => "中等", DifficultyLevel::Hard => "困难", DifficultyLevel::Adaptive => "自适应", }), options: Some(opts), correct_answer: Answer::Single(correct), explanation: Some(format!("第{}题的详细解释", i + 1)), hints: Some(vec![format!("提示:仔细阅读关于{}的内容", topic)]), points: 10.0, difficulty: difficulty.clone(), tags: vec![topic.to_string()], } }) .collect()) } } /// LLM-powered quiz generator that produces real questions via an LLM driver. pub struct LlmQuizGenerator { driver: Arc, model: String, } impl LlmQuizGenerator { pub fn new(driver: Arc, model: String) -> Self { Self { driver, model } } } #[async_trait] impl QuizGenerator for LlmQuizGenerator { async fn generate_questions( &self, topic: &str, content: Option<&str>, count: usize, difficulty: &DifficultyLevel, question_types: &[QuestionType], ) -> Result> { let difficulty_str = match difficulty { DifficultyLevel::Easy => "简单", DifficultyLevel::Medium => "中等", DifficultyLevel::Hard => "困难", DifficultyLevel::Adaptive => "中等", }; let type_str = if question_types.is_empty() { String::from("选择题(multiple_choice)") } else { question_types .iter() .map(|t| match t { QuestionType::MultipleChoice => "选择题", QuestionType::TrueFalse => "判断题", QuestionType::FillBlank => "填空题", QuestionType::ShortAnswer => "简答题", QuestionType::Essay => "论述题", _ => "选择题", }) .collect::>() .join(",") }; let content_section = match content { Some(c) if !c.is_empty() => format!("\n\n参考内容:\n{}", &c[..c.len().min(3000)]), _ => String::new(), }; let content_note = if content.is_some() && content.map_or(false, |c| !c.is_empty()) { "(基于提供的参考内容出题)" } else { "" }; let prompt = format!( r#"你是一个专业的出题专家。请根据以下要求生成测验题目: 主题: {} 难度: {} 题目类型: {} 数量: {}{} {} 请严格按照以下 JSON 格式输出,不要添加任何其他文字: ```json [ {{ "question": "题目内容", "options": ["选项A", "选项B", "选项C", "选项D"], "correct_answer": "正确答案(与options中某项完全一致)", "explanation": "答案解释", "hint": "提示信息" }} ] ``` 要求: 1. 题目要有实际内容,不要使用占位符 2. 正确答案必须随机分布(不要总在第一个选项) 3. 每道题的选项要有区分度,干扰项要合理 4. 解释要清晰准确 5. 直接输出 JSON,不要有 markdown 包裹"#, topic, difficulty_str, type_str, count, content_section, content_note, ); let request = CompletionRequest { model: self.model.clone(), system: Some("你是一个专业的出题专家,只输出纯JSON格式。".to_string()), messages: vec![zclaw_types::Message::user(&prompt)], tools: Vec::new(), max_tokens: Some(4096), temperature: Some(0.7), stop: Vec::new(), stream: false, thinking_enabled: false, reasoning_effort: None, plan_mode: false, }; let response = self.driver.complete(request).await.map_err(|e| { zclaw_types::ZclawError::Internal(format!("LLM quiz generation failed: {}", e)) })?; // Extract text from response let text: String = response .content .iter() .filter_map(|block| match block { zclaw_runtime::driver::ContentBlock::Text { text } => Some(text.clone()), _ => None, }) .collect::>() .join(""); // Parse JSON from response (handle markdown code fences) let json_str = extract_json(&text); let raw_questions: Vec = serde_json::from_str(json_str).map_err(|e| { zclaw_types::ZclawError::Internal(format!( "Failed to parse quiz JSON: {}. Raw: {}", e, &text[..text.len().min(200)] )) })?; let questions: Vec = raw_questions .into_iter() .take(count) .map(|q| { let options: Vec = q["options"] .as_array() .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()) .unwrap_or_default(); let correct = q["correct_answer"] .as_str() .unwrap_or("") .to_string(); QuizQuestion { id: uuid_v4(), question_type: QuestionType::MultipleChoice, question: q["question"].as_str().unwrap_or("未知题目").to_string(), options: if options.is_empty() { None } else { Some(options) }, correct_answer: Answer::Single(correct), explanation: q["explanation"].as_str().map(String::from), hints: q["hint"].as_str().map(|h| vec![h.to_string()]), points: 10.0, difficulty: difficulty.clone(), tags: vec![topic.to_string()], } }) .collect(); if questions.is_empty() { // Fallback to default if LLM returns nothing parseable return DefaultQuizGenerator .generate_questions(topic, content, count, difficulty, question_types) .await; } Ok(questions) } } /// Extract JSON from a string that may be wrapped in markdown code fences. fn extract_json(text: &str) -> &str { let trimmed = text.trim(); // Try to find ```json ... ``` block if let Some(start) = trimmed.find("```json") { let after_start = &trimmed[start + 7..]; if let Some(end) = after_start.find("```") { return after_start[..end].trim(); } } // Try to find ``` ... ``` block if let Some(start) = trimmed.find("```") { let after_start = &trimmed[start + 3..]; if let Some(end) = after_start.find("```") { return after_start[..end].trim(); } } // Try to find raw JSON array if let Some(start) = trimmed.find('[') { if let Some(end) = trimmed.rfind(']') { return &trimmed[start..=end]; } } trimmed } /// Quiz action types #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "action", rename_all = "snake_case")] pub enum QuizAction { /// Generate quiz from content Generate { topic: String, content: Option, question_count: Option, difficulty: Option, question_types: Option>, }, /// Show quiz question Show { quiz_id: String, question_index: Option, }, /// Submit answer Submit { quiz_id: String, question_id: String, answer: Answer, }, /// Grade quiz Grade { quiz_id: String, show_correct: Option, show_explanation: Option, }, /// Analyze results Analyze { quiz_id: String, }, /// Get hint Hint { quiz_id: String, question_id: String, hint_level: Option, }, /// Show explanation Explain { quiz_id: String, question_id: String, }, /// Get next question (adaptive) NextQuestion { quiz_id: String, current_score: Option, }, /// Create quiz from template CreateFromTemplate { template: QuizTemplate, }, /// Export quiz Export { quiz_id: String, format: ExportFormat, }, } /// Question types #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum QuestionType { #[default] MultipleChoice, TrueFalse, FillBlank, ShortAnswer, Matching, Ordering, Essay, } /// Difficulty levels #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum DifficultyLevel { Easy, #[default] Medium, Hard, Adaptive, } /// Answer types #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] pub enum Answer { Single(String), Multiple(Vec), Text(String), Ordering(Vec), Matching(Vec<(String, String)>), } /// Quiz template #[derive(Debug, Clone, Serialize, Deserialize)] pub struct QuizTemplate { pub title: String, pub description: String, pub time_limit_seconds: Option, pub passing_score: Option, pub allow_retry: bool, pub show_feedback: bool, pub shuffle_questions: bool, pub shuffle_options: bool, } /// Quiz question #[derive(Debug, Clone, Serialize, Deserialize)] pub struct QuizQuestion { pub id: String, pub question_type: QuestionType, pub question: String, pub options: Option>, pub correct_answer: Answer, pub explanation: Option, pub hints: Option>, pub points: f64, pub difficulty: DifficultyLevel, pub tags: Vec, } /// Quiz definition #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Quiz { pub id: String, pub title: String, pub description: String, pub questions: Vec, pub time_limit_seconds: Option, pub passing_score: f64, pub allow_retry: bool, pub show_feedback: bool, pub shuffle_questions: bool, pub shuffle_options: bool, pub created_at: i64, } /// Quiz attempt #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct QuizAttempt { pub quiz_id: String, pub answers: Vec, pub score: Option, pub started_at: i64, pub completed_at: Option, pub time_spent_seconds: Option, } /// Answer submission #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AnswerSubmission { pub question_id: String, pub answer: Answer, pub is_correct: Option, pub points_earned: Option, pub feedback: Option, } /// Export format #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] pub enum ExportFormat { #[default] Json, Qti, Gift, Markdown, } /// Quiz state #[derive(Debug, Clone, Default)] pub struct QuizState { pub quizzes: Vec, pub attempts: Vec, pub current_quiz_id: Option, pub current_question_index: usize, } /// Quiz Hand implementation pub struct QuizHand { config: HandConfig, state: Arc>, quiz_generator: Arc, } impl QuizHand { /// Create a new quiz hand with default generator pub fn new() -> Self { Self { config: HandConfig { id: "quiz".to_string(), name: "测验".to_string(), description: "生成和管理测验题目,评估答案,提供反馈".to_string(), needs_approval: false, dependencies: vec![], input_schema: Some(serde_json::json!({ "type": "object", "properties": { "action": { "type": "string" }, "quiz_id": { "type": "string" }, "topic": { "type": "string" }, } })), tags: vec!["assessment".to_string(), "education".to_string()], enabled: true, max_concurrent: 0, timeout_secs: 0, }, state: Arc::new(RwLock::new(QuizState::default())), quiz_generator: Arc::new(DefaultQuizGenerator), } } /// Create a quiz hand with custom generator (e.g., LLM-powered) pub fn with_generator(generator: Arc) -> Self { let mut hand = Self::new(); hand.quiz_generator = generator; hand } /// Set the quiz generator at runtime pub fn set_generator(&mut self, generator: Arc) { self.quiz_generator = generator; } /// Execute a quiz action pub async fn execute_action(&self, action: QuizAction) -> Result { match action { QuizAction::Generate { topic, content, question_count, difficulty, question_types } => { let count = question_count.unwrap_or(5); let diff = difficulty.unwrap_or_default(); let types = question_types.unwrap_or_else(|| vec![QuestionType::MultipleChoice]); // Use the configured generator (LLM or placeholder) let questions = self.quiz_generator .generate_questions(&topic, content.as_deref(), count, &diff, &types) .await?; let quiz = Quiz { id: uuid_v4(), title: format!("Quiz: {}", topic), description: format!("Test your knowledge of {}", topic), questions, time_limit_seconds: None, passing_score: 60.0, allow_retry: true, show_feedback: true, shuffle_questions: false, shuffle_options: true, created_at: current_timestamp(), }; let mut state = self.state.write().await; state.quizzes.push(quiz.clone()); state.current_quiz_id = Some(quiz.id.clone()); Ok(HandResult::success(serde_json::json!({ "status": "generated", "quiz": quiz, }))) } other => self.execute_other_action(other).await, } } /// Execute non-generate actions (requires lock) async fn execute_other_action(&self, action: QuizAction) -> Result { let mut state = self.state.write().await; match action { QuizAction::Show { quiz_id, question_index } => { let quiz = state.quizzes.iter() .find(|q| q.id == quiz_id); match quiz { Some(quiz) => { let idx = question_index.unwrap_or(state.current_question_index); if idx < quiz.questions.len() { let question = &quiz.questions[idx]; // Hide correct answer when showing let question_for_display = serde_json::json!({ "id": question.id, "type": question.question_type, "question": question.question, "options": question.options, "points": question.points, "difficulty": question.difficulty, }); Ok(HandResult::success(serde_json::json!({ "status": "showing", "question": question_for_display, "question_index": idx, "total_questions": quiz.questions.len(), }))) } else { Ok(HandResult::error("Question index out of range")) } } None => Ok(HandResult::error(format!("Quiz not found: {}", quiz_id))), } } QuizAction::Submit { quiz_id, question_id, answer } => { let submission = AnswerSubmission { question_id, answer, is_correct: None, points_earned: None, feedback: None, }; // Find or create attempt let attempt = state.attempts.iter_mut() .find(|a| a.quiz_id == quiz_id && a.completed_at.is_none()); match attempt { Some(attempt) => { attempt.answers.push(submission); } None => { let mut new_attempt = QuizAttempt { quiz_id, started_at: current_timestamp(), ..Default::default() }; new_attempt.answers.push(submission); state.attempts.push(new_attempt); } } Ok(HandResult::success(serde_json::json!({ "status": "submitted", }))) } QuizAction::Grade { quiz_id, show_correct, show_explanation } => { // First, find the quiz and clone necessary data let quiz_data = state.quizzes.iter() .find(|q| q.id == quiz_id) .map(|quiz| (quiz.clone(), quiz.passing_score)); let attempt = state.attempts.iter_mut() .find(|a| a.quiz_id == quiz_id && a.completed_at.is_none()); match (quiz_data, attempt) { (Some((quiz, passing_score)), Some(attempt)) => { let mut total_points = 0.0; let mut earned_points = 0.0; for submission in &mut attempt.answers { if let Some(question) = quiz.questions.iter() .find(|q| q.id == submission.question_id) { let is_correct = self.check_answer(&submission.answer, &question.correct_answer); submission.is_correct = Some(is_correct); submission.points_earned = Some(if is_correct { question.points } else { 0.0 }); total_points += question.points; earned_points += submission.points_earned.unwrap(); if show_explanation.unwrap_or(true) { submission.feedback = question.explanation.clone(); } } } let score = if total_points > 0.0 { (earned_points / total_points) * 100.0 } else { 0.0 }; attempt.score = Some(score); attempt.completed_at = Some(current_timestamp()); Ok(HandResult::success(serde_json::json!({ "status": "graded", "score": score, "total_points": total_points, "earned_points": earned_points, "passed": score >= passing_score, "answers": if show_correct.unwrap_or(true) { serde_json::to_value(&attempt.answers).unwrap_or(serde_json::Value::Null) } else { serde_json::Value::Null }, }))) } _ => Ok(HandResult::error("Quiz or attempt not found")), } } QuizAction::Analyze { quiz_id } => { let quiz = state.quizzes.iter().find(|q| q.id == quiz_id); let attempts: Vec<_> = state.attempts.iter() .filter(|a| a.quiz_id == quiz_id && a.completed_at.is_some()) .collect(); match quiz { Some(quiz) => { let scores: Vec = attempts.iter() .filter_map(|a| a.score) .collect(); let avg_score = if !scores.is_empty() { scores.iter().sum::() / scores.len() as f64 } else { 0.0 }; Ok(HandResult::success(serde_json::json!({ "status": "analyzed", "quiz_title": quiz.title, "total_attempts": attempts.len(), "average_score": avg_score, "pass_rate": scores.iter().filter(|&&s| s >= quiz.passing_score).count() as f64 / scores.len().max(1) as f64 * 100.0, }))) } None => Ok(HandResult::error(format!("Quiz not found: {}", quiz_id))), } } QuizAction::Hint { quiz_id, question_id, hint_level } => { let quiz = state.quizzes.iter().find(|q| q.id == quiz_id); match quiz { Some(quiz) => { let question = quiz.questions.iter() .find(|q| q.id == question_id); match question { Some(q) => { let level = hint_level.unwrap_or(1) as usize; let hint = q.hints.as_ref() .and_then(|h| h.get(level.saturating_sub(1))) .map(|s| s.as_str()) .unwrap_or("No hint available at this level"); Ok(HandResult::success(serde_json::json!({ "status": "hint", "hint": hint, "level": level, }))) } None => Ok(HandResult::error("Question not found")), } } None => Ok(HandResult::error(format!("Quiz not found: {}", quiz_id))), } } QuizAction::Explain { quiz_id, question_id } => { let quiz = state.quizzes.iter().find(|q| q.id == quiz_id); match quiz { Some(quiz) => { let question = quiz.questions.iter() .find(|q| q.id == question_id); match question { Some(q) => { Ok(HandResult::success(serde_json::json!({ "status": "explanation", "question": q.question, "correct_answer": q.correct_answer, "explanation": q.explanation, }))) } None => Ok(HandResult::error("Question not found")), } } None => Ok(HandResult::error(format!("Quiz not found: {}", quiz_id))), } } QuizAction::NextQuestion { quiz_id, current_score } => { // Adaptive quiz - select next question based on performance let quiz = state.quizzes.iter().find(|q| q.id == quiz_id); match quiz { Some(quiz) => { let _score = current_score.unwrap_or(0.0); let next_idx = state.current_question_index + 1; if next_idx < quiz.questions.len() { state.current_question_index = next_idx; Ok(HandResult::success(serde_json::json!({ "status": "next", "question_index": next_idx, }))) } else { Ok(HandResult::success(serde_json::json!({ "status": "complete", }))) } } None => Ok(HandResult::error(format!("Quiz not found: {}", quiz_id))), } } QuizAction::CreateFromTemplate { template } => { let quiz = Quiz { id: uuid_v4(), title: template.title, description: template.description, questions: Vec::new(), // Would be filled in time_limit_seconds: template.time_limit_seconds, passing_score: template.passing_score.unwrap_or(60.0), allow_retry: template.allow_retry, show_feedback: template.show_feedback, shuffle_questions: template.shuffle_questions, shuffle_options: template.shuffle_options, created_at: current_timestamp(), }; state.quizzes.push(quiz.clone()); Ok(HandResult::success(serde_json::json!({ "status": "created", "quiz_id": quiz.id, }))) } QuizAction::Export { quiz_id, format } => { let quiz = state.quizzes.iter().find(|q| q.id == quiz_id); match quiz { Some(quiz) => { let content = match format { ExportFormat::Json => serde_json::to_string_pretty(&quiz).unwrap_or_default(), ExportFormat::Markdown => self.export_markdown(quiz), _ => format!("{:?}", quiz), }; Ok(HandResult::success(serde_json::json!({ "status": "exported", "format": format, "content": content, }))) } None => Ok(HandResult::error(format!("Quiz not found: {}", quiz_id))), } } // Generate is handled in execute_action, this is just for exhaustiveness QuizAction::Generate { .. } => { Ok(HandResult::error("Generate action should be handled in execute_action")) } } } /// Check if answer is correct fn check_answer(&self, submitted: &Answer, correct: &Answer) -> bool { match (submitted, correct) { (Answer::Single(s), Answer::Single(c)) => s == c, (Answer::Multiple(s), Answer::Multiple(c)) => { let mut s_sorted = s.clone(); let mut c_sorted = c.clone(); s_sorted.sort(); c_sorted.sort(); s_sorted == c_sorted } (Answer::Text(s), Answer::Text(c)) => s.trim().to_lowercase() == c.trim().to_lowercase(), _ => false, } } /// Export quiz as markdown fn export_markdown(&self, quiz: &Quiz) -> String { let mut md = format!("# {}\n\n{}\n\n", quiz.title, quiz.description); for (i, q) in quiz.questions.iter().enumerate() { md.push_str(&format!("## Question {}\n\n{}\n\n", i + 1, q.question)); if let Some(options) = &q.options { for opt in options { md.push_str(&format!("- {}\n", opt)); } md.push_str("\n"); } if let Some(explanation) = &q.explanation { md.push_str(&format!("**Explanation:** {}\n\n", explanation)); } } md } /// Get current state pub async fn get_state(&self) -> QuizState { self.state.read().await.clone() } } impl Default for QuizHand { fn default() -> Self { Self::new() } } #[async_trait] impl Hand for QuizHand { fn config(&self) -> &HandConfig { &self.config } async fn execute(&self, _context: &HandContext, input: Value) -> Result { // P2-04: Reject oversized input before deserialization const MAX_INPUT_SIZE: usize = 50_000; // 50KB limit let input_str = serde_json::to_string(&input).unwrap_or_default(); if input_str.len() > MAX_INPUT_SIZE { return Ok(HandResult::error(format!( "Input too large ({} bytes, max {} bytes)", input_str.len(), MAX_INPUT_SIZE ))); } let action: QuizAction = match serde_json::from_value(input) { Ok(a) => a, Err(e) => { return Ok(HandResult::error(format!("Invalid quiz action: {}", e))); } }; self.execute_action(action).await } fn status(&self) -> HandStatus { HandStatus::Idle } } // Helper functions /// Generate a cryptographically secure UUID v4 fn uuid_v4() -> String { Uuid::new_v4().to_string() } fn current_timestamp() -> i64 { use std::time::{SystemTime, UNIX_EPOCH}; SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_millis() as i64 } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_quiz_creation() { let hand = QuizHand::new(); assert_eq!(hand.config().id, "quiz"); } #[tokio::test] async fn test_generate_quiz() { let hand = QuizHand::new(); let action = QuizAction::Generate { topic: "Rust Ownership".to_string(), content: None, question_count: Some(5), difficulty: Some(DifficultyLevel::Medium), question_types: None, }; let result = hand.execute_action(action).await.unwrap(); assert!(result.success); let state = hand.get_state().await; assert_eq!(state.quizzes.len(), 1); assert_eq!(state.quizzes[0].questions.len(), 5); } #[tokio::test] async fn test_show_question() { let hand = QuizHand::new(); // Generate first hand.execute_action(QuizAction::Generate { topic: "Test".to_string(), content: None, question_count: Some(3), difficulty: None, question_types: None, }).await.unwrap(); let quiz_id = hand.get_state().await.quizzes[0].id.clone(); let result = hand.execute_action(QuizAction::Show { quiz_id, question_index: Some(0), }).await.unwrap(); assert!(result.success); } #[tokio::test] async fn test_submit_and_grade() { let hand = QuizHand::new(); // Generate hand.execute_action(QuizAction::Generate { topic: "Test".to_string(), content: None, question_count: Some(2), difficulty: None, question_types: None, }).await.unwrap(); let state = hand.get_state().await; let quiz_id = state.quizzes[0].id.clone(); let q1_id = state.quizzes[0].questions[0].id.clone(); let q2_id = state.quizzes[0].questions[1].id.clone(); // Submit answers hand.execute_action(QuizAction::Submit { quiz_id: quiz_id.clone(), question_id: q1_id, answer: Answer::Single("Option A".to_string()), }).await.unwrap(); hand.execute_action(QuizAction::Submit { quiz_id: quiz_id.clone(), question_id: q2_id, answer: Answer::Single("Option A".to_string()), }).await.unwrap(); // Grade let result = hand.execute_action(QuizAction::Grade { quiz_id, show_correct: Some(true), show_explanation: Some(true), }).await.unwrap(); assert!(result.success); } #[tokio::test] async fn test_export_markdown() { let hand = QuizHand::new(); hand.execute_action(QuizAction::Generate { topic: "Test".to_string(), content: None, question_count: Some(2), difficulty: None, question_types: None, }).await.unwrap(); let quiz_id = hand.get_state().await.quizzes[0].id.clone(); let result = hand.execute_action(QuizAction::Export { quiz_id, format: ExportFormat::Markdown, }).await.unwrap(); assert!(result.success); } }