Some checks failed
CI / Rust Check (push) Has been cancelled
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
style: 统一代码格式和注释风格 docs: 更新多个功能文档的完整度和状态 feat(runtime): 添加路径验证工具支持 fix(pipeline): 改进条件判断和变量解析逻辑 test(types): 为ID类型添加全面测试用例 chore: 更新依赖项和Cargo.lock文件 perf(mcp): 优化MCP协议传输和错误处理
824 lines
24 KiB
Rust
824 lines
24 KiB
Rust
//! HTML Exporter - Interactive web-based classroom export
|
|
//!
|
|
//! Generates a self-contained HTML file with:
|
|
//! - Responsive layout
|
|
//! - Scene navigation
|
|
//! - Speaker notes toggle
|
|
//! - Table of contents
|
|
//! - Embedded CSS/JS
|
|
|
|
use crate::generation::{Classroom, GeneratedScene, SceneContent, SceneType, SceneAction};
|
|
use super::{ExportOptions, ExportResult, Exporter, sanitize_filename};
|
|
use zclaw_types::Result;
|
|
|
|
/// HTML exporter
|
|
pub struct HtmlExporter {
|
|
/// Template name (reserved for future template support)
|
|
#[allow(dead_code)] // TODO: Implement template-based HTML export
|
|
template: String,
|
|
}
|
|
|
|
impl HtmlExporter {
|
|
/// Create new HTML exporter
|
|
pub fn new() -> Self {
|
|
Self {
|
|
template: "default".to_string(),
|
|
}
|
|
}
|
|
|
|
/// Create with specific template
|
|
#[allow(dead_code)] // Reserved for future template support
|
|
pub fn with_template(template: &str) -> Self {
|
|
Self {
|
|
template: template.to_string(),
|
|
}
|
|
}
|
|
|
|
/// Generate HTML content
|
|
fn generate_html(&self, classroom: &Classroom, options: &ExportOptions) -> Result<String> {
|
|
let mut html = String::new();
|
|
|
|
// HTML header
|
|
html.push_str(&self.generate_header(classroom, options));
|
|
|
|
// Body content
|
|
html.push_str("<body>\n");
|
|
html.push_str(&self.generate_body_start(classroom, options));
|
|
|
|
// Title slide
|
|
if options.title_slide {
|
|
html.push_str(&self.generate_title_slide(classroom));
|
|
}
|
|
|
|
// Table of contents
|
|
if options.table_of_contents {
|
|
html.push_str(&self.generate_toc(classroom));
|
|
}
|
|
|
|
// Scenes
|
|
html.push_str("<main class=\"scenes\">\n");
|
|
for scene in &classroom.scenes {
|
|
html.push_str(&self.generate_scene(scene, options));
|
|
}
|
|
html.push_str("</main>\n");
|
|
|
|
// Footer
|
|
html.push_str(&self.generate_footer(classroom));
|
|
|
|
html.push_str(&self.generate_body_end());
|
|
html.push_str("</body>\n</html>");
|
|
|
|
Ok(html)
|
|
}
|
|
|
|
/// Generate HTML header with embedded CSS
|
|
fn generate_header(&self, classroom: &Classroom, options: &ExportOptions) -> String {
|
|
let custom_css = options.custom_css.as_deref().unwrap_or("");
|
|
|
|
format!(
|
|
r#"<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>{title}</title>
|
|
<style>
|
|
{default_css}
|
|
{custom_css}
|
|
</style>
|
|
</head>
|
|
"#,
|
|
title = html_escape(&classroom.title),
|
|
default_css = get_default_css(),
|
|
custom_css = custom_css,
|
|
)
|
|
}
|
|
|
|
/// Generate body start with navigation
|
|
fn generate_body_start(&self, classroom: &Classroom, _options: &ExportOptions) -> String {
|
|
format!(
|
|
r#"
|
|
<nav class="top-nav">
|
|
<div class="nav-brand">{title}</div>
|
|
<div class="nav-controls">
|
|
<button id="toggle-notes" class="btn">Notes</button>
|
|
<button id="toggle-toc" class="btn">Contents</button>
|
|
<button id="prev-scene" class="btn">← Prev</button>
|
|
<span id="scene-counter">1 / {total}</span>
|
|
<button id="next-scene" class="btn">Next →</button>
|
|
</div>
|
|
</nav>
|
|
"#,
|
|
title = html_escape(&classroom.title),
|
|
total = classroom.scenes.len(),
|
|
)
|
|
}
|
|
|
|
/// Generate title slide
|
|
fn generate_title_slide(&self, classroom: &Classroom) -> String {
|
|
format!(
|
|
r#"
|
|
<section class="scene title-slide" id="scene-0">
|
|
<div class="scene-content">
|
|
<h1>{title}</h1>
|
|
<p class="description">{description}</p>
|
|
<div class="meta">
|
|
<span class="topic">{topic}</span>
|
|
<span class="level">{level}</span>
|
|
<span class="duration">{duration}</span>
|
|
</div>
|
|
<div class="objectives">
|
|
<h3>Learning Objectives</h3>
|
|
<ul>
|
|
{objectives}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
"#,
|
|
title = html_escape(&classroom.title),
|
|
description = html_escape(&classroom.description),
|
|
topic = html_escape(&classroom.topic),
|
|
level = format_level(&classroom.level),
|
|
duration = format_duration(classroom.total_duration),
|
|
objectives = classroom.objectives.iter()
|
|
.map(|o| format!(" <li>{}</li>", html_escape(o)))
|
|
.collect::<Vec<_>>()
|
|
.join("\n"),
|
|
)
|
|
}
|
|
|
|
/// Generate table of contents
|
|
fn generate_toc(&self, classroom: &Classroom) -> String {
|
|
let items: String = classroom.scenes.iter()
|
|
.enumerate()
|
|
.map(|(i, scene)| {
|
|
format!(
|
|
" <li><a href=\"#scene-{}\">{}</a></li>",
|
|
i + 1,
|
|
html_escape(&scene.content.title)
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
|
|
format!(
|
|
r#"
|
|
<aside class="toc" id="toc-panel">
|
|
<h2>Contents</h2>
|
|
<ol>
|
|
{}
|
|
</ol>
|
|
</aside>
|
|
"#,
|
|
items
|
|
)
|
|
}
|
|
|
|
/// Generate a single scene
|
|
fn generate_scene(&self, scene: &GeneratedScene, options: &ExportOptions) -> String {
|
|
let notes_html = if options.include_notes {
|
|
scene.content.notes.as_ref()
|
|
.map(|n| format!(
|
|
r#" <aside class="speaker-notes">{}</aside>"#,
|
|
html_escape(n)
|
|
))
|
|
.unwrap_or_default()
|
|
} else {
|
|
String::new()
|
|
};
|
|
|
|
let actions_html = self.generate_actions(&scene.content.actions);
|
|
|
|
format!(
|
|
r#"
|
|
<section class="scene scene-{type}" id="scene-{order}" data-duration="{duration}">
|
|
<div class="scene-header">
|
|
<h2>{title}</h2>
|
|
<span class="scene-type">{type}</span>
|
|
</div>
|
|
<div class="scene-body">
|
|
{content}
|
|
{actions}
|
|
</div>
|
|
{notes}
|
|
</section>
|
|
"#,
|
|
type = format_scene_type(&scene.content.scene_type),
|
|
order = scene.order + 1,
|
|
duration = scene.content.duration_seconds,
|
|
title = html_escape(&scene.content.title),
|
|
content = self.format_scene_content(&scene.content),
|
|
actions = actions_html,
|
|
notes = notes_html,
|
|
)
|
|
}
|
|
|
|
/// Format scene content based on type
|
|
fn format_scene_content(&self, content: &SceneContent) -> String {
|
|
match content.scene_type {
|
|
SceneType::Slide => {
|
|
if let Some(desc) = content.content.get("description").and_then(|v| v.as_str()) {
|
|
format!("<p class=\"slide-description\">{}</p>", html_escape(desc))
|
|
} else {
|
|
String::new()
|
|
}
|
|
}
|
|
SceneType::Quiz => {
|
|
let questions = content.content.get("questions")
|
|
.and_then(|v| v.as_array())
|
|
.map(|arr| {
|
|
arr.iter()
|
|
.filter_map(|q| {
|
|
let text = q.get("text").and_then(|t| t.as_str()).unwrap_or("");
|
|
Some(format!("<li>{}</li>", html_escape(text)))
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
format!(
|
|
r#"<div class="quiz-questions"><ol>{}</ol></div>"#,
|
|
questions
|
|
)
|
|
}
|
|
SceneType::Discussion => {
|
|
if let Some(topic) = content.content.get("discussion_topic").and_then(|v| v.as_str()) {
|
|
format!("<p class=\"discussion-topic\">Discussion: {}</p>", html_escape(topic))
|
|
} else {
|
|
String::new()
|
|
}
|
|
}
|
|
_ => {
|
|
if let Some(desc) = content.content.get("description").and_then(|v| v.as_str()) {
|
|
format!("<p>{}</p>", html_escape(desc))
|
|
} else {
|
|
String::new()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Generate actions section
|
|
fn generate_actions(&self, actions: &[SceneAction]) -> String {
|
|
if actions.is_empty() {
|
|
return String::new();
|
|
}
|
|
|
|
let actions_html: String = actions.iter()
|
|
.filter_map(|action| match action {
|
|
SceneAction::Speech { text, agent_role } => Some(format!(
|
|
r#" <div class="action speech" data-role="{}">
|
|
<span class="role">{}</span>
|
|
<p>{}</p>
|
|
</div>"#,
|
|
html_escape(agent_role),
|
|
html_escape(agent_role),
|
|
html_escape(text)
|
|
)),
|
|
SceneAction::WhiteboardDrawText { text, .. } => Some(format!(
|
|
r#" <div class="action whiteboard-text">
|
|
<span class="label">Whiteboard:</span>
|
|
<code>{}</code>
|
|
</div>"#,
|
|
html_escape(text)
|
|
)),
|
|
SceneAction::WhiteboardDrawShape { shape, .. } => Some(format!(
|
|
r#" <div class="action whiteboard-shape">
|
|
<span class="label">Draw:</span>
|
|
<span>{}</span>
|
|
</div>"#,
|
|
html_escape(shape)
|
|
)),
|
|
SceneAction::QuizShow { quiz_id } => Some(format!(
|
|
r#" <div class="action quiz-show" data-quiz-id="{}">
|
|
<span class="label">Quiz:</span>
|
|
<span>{}</span>
|
|
</div>"#,
|
|
html_escape(quiz_id),
|
|
html_escape(quiz_id)
|
|
)),
|
|
SceneAction::Discussion { topic, duration_seconds } => Some(format!(
|
|
r#" <div class="action discussion">
|
|
<span class="label">Discussion:</span>
|
|
<span>{}</span>
|
|
<span class="duration">({}s)</span>
|
|
</div>"#,
|
|
html_escape(topic),
|
|
duration_seconds.unwrap_or(300)
|
|
)),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
|
|
if actions_html.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!(
|
|
r#"<div class="actions">
|
|
{}
|
|
</div>"#,
|
|
actions_html
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Generate footer
|
|
fn generate_footer(&self, classroom: &Classroom) -> String {
|
|
format!(
|
|
r#"
|
|
<footer class="classroom-footer">
|
|
<p>Generated by ZCLAW</p>
|
|
<p>Topic: {topic} | Duration: {duration} | Style: {style}</p>
|
|
</footer>
|
|
"#,
|
|
topic = html_escape(&classroom.topic),
|
|
duration = format_duration(classroom.total_duration),
|
|
style = format_style(&classroom.style),
|
|
)
|
|
}
|
|
|
|
/// Generate body end with JavaScript
|
|
fn generate_body_end(&self) -> String {
|
|
format!(
|
|
r#"
|
|
<script>
|
|
{js}
|
|
</script>
|
|
"#,
|
|
js = get_default_js()
|
|
)
|
|
}
|
|
}
|
|
|
|
impl Default for HtmlExporter {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl Exporter for HtmlExporter {
|
|
fn export(&self, classroom: &Classroom, options: &ExportOptions) -> Result<ExportResult> {
|
|
let html = self.generate_html(classroom, options)?;
|
|
let filename = format!("{}.html", sanitize_filename(&classroom.title));
|
|
|
|
Ok(ExportResult {
|
|
content: html.into_bytes(),
|
|
mime_type: "text/html".to_string(),
|
|
filename,
|
|
extension: "html".to_string(),
|
|
})
|
|
}
|
|
|
|
fn format(&self) -> super::ExportFormat {
|
|
super::ExportFormat::Html
|
|
}
|
|
|
|
fn extension(&self) -> &str {
|
|
"html"
|
|
}
|
|
|
|
fn mime_type(&self) -> &str {
|
|
"text/html"
|
|
}
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
/// Escape HTML special characters
|
|
fn html_escape(s: &str) -> String {
|
|
s.replace('&', "&")
|
|
.replace('<', "<")
|
|
.replace('>', ">")
|
|
.replace('"', """)
|
|
.replace('\'', "'")
|
|
}
|
|
|
|
/// Format duration in minutes
|
|
fn format_duration(seconds: u32) -> String {
|
|
let minutes = seconds / 60;
|
|
let secs = seconds % 60;
|
|
if secs > 0 {
|
|
format!("{}m {}s", minutes, secs)
|
|
} else {
|
|
format!("{}m", minutes)
|
|
}
|
|
}
|
|
|
|
/// Format difficulty level
|
|
fn format_level(level: &crate::generation::DifficultyLevel) -> String {
|
|
match level {
|
|
crate::generation::DifficultyLevel::Beginner => "Beginner",
|
|
crate::generation::DifficultyLevel::Intermediate => "Intermediate",
|
|
crate::generation::DifficultyLevel::Advanced => "Advanced",
|
|
crate::generation::DifficultyLevel::Expert => "Expert",
|
|
}.to_string()
|
|
}
|
|
|
|
/// Format teaching style
|
|
fn format_style(style: &crate::generation::TeachingStyle) -> String {
|
|
match style {
|
|
crate::generation::TeachingStyle::Lecture => "Lecture",
|
|
crate::generation::TeachingStyle::Discussion => "Discussion",
|
|
crate::generation::TeachingStyle::Pbl => "Project-Based",
|
|
crate::generation::TeachingStyle::Flipped => "Flipped Classroom",
|
|
crate::generation::TeachingStyle::Socratic => "Socratic",
|
|
}.to_string()
|
|
}
|
|
|
|
/// Format scene type
|
|
fn format_scene_type(scene_type: &SceneType) -> String {
|
|
match scene_type {
|
|
SceneType::Slide => "slide",
|
|
SceneType::Quiz => "quiz",
|
|
SceneType::Interactive => "interactive",
|
|
SceneType::Pbl => "pbl",
|
|
SceneType::Discussion => "discussion",
|
|
SceneType::Media => "media",
|
|
SceneType::Text => "text",
|
|
}.to_string()
|
|
}
|
|
|
|
/// Get default CSS styles
|
|
fn get_default_css() -> &'static str {
|
|
r#"
|
|
:root {
|
|
--primary: #3b82f6;
|
|
--secondary: #64748b;
|
|
--background: #f8fafc;
|
|
--surface: #ffffff;
|
|
--text: #1e293b;
|
|
--border: #e2e8f0;
|
|
--accent: #10b981;
|
|
}
|
|
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: var(--background);
|
|
color: var(--text);
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.top-nav {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
height: 60px;
|
|
background: var(--surface);
|
|
border-bottom: 1px solid var(--border);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0 24px;
|
|
z-index: 100;
|
|
}
|
|
|
|
.nav-brand {
|
|
font-weight: 600;
|
|
font-size: 18px;
|
|
}
|
|
|
|
.nav-controls {
|
|
display: flex;
|
|
gap: 12px;
|
|
align-items: center;
|
|
}
|
|
|
|
.btn {
|
|
padding: 8px 16px;
|
|
border: 1px solid var(--border);
|
|
background: var(--surface);
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.btn:hover {
|
|
background: var(--background);
|
|
}
|
|
|
|
.scenes {
|
|
margin-top: 80px;
|
|
padding: 24px;
|
|
max-width: 900px;
|
|
margin-left: auto;
|
|
margin-right: auto;
|
|
}
|
|
|
|
.scene {
|
|
background: var(--surface);
|
|
border-radius: 12px;
|
|
padding: 32px;
|
|
margin-bottom: 24px;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
}
|
|
|
|
.title-slide {
|
|
text-align: center;
|
|
padding: 64px 32px;
|
|
}
|
|
|
|
.title-slide h1 {
|
|
font-size: 36px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.scene-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 24px;
|
|
padding-bottom: 16px;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
.scene-header h2 {
|
|
font-size: 24px;
|
|
}
|
|
|
|
.scene-type {
|
|
padding: 4px 12px;
|
|
background: var(--primary);
|
|
color: white;
|
|
border-radius: 4px;
|
|
font-size: 12px;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.scene-body {
|
|
font-size: 16px;
|
|
}
|
|
|
|
.actions {
|
|
margin-top: 24px;
|
|
padding: 16px;
|
|
background: var(--background);
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.action {
|
|
padding: 12px;
|
|
margin-bottom: 8px;
|
|
background: var(--surface);
|
|
border-radius: 6px;
|
|
}
|
|
|
|
.action .role {
|
|
font-weight: 600;
|
|
color: var(--primary);
|
|
text-transform: capitalize;
|
|
}
|
|
|
|
.speaker-notes {
|
|
margin-top: 24px;
|
|
padding: 16px;
|
|
background: #fef3c7;
|
|
border-left: 4px solid #f59e0b;
|
|
border-radius: 4px;
|
|
display: none;
|
|
}
|
|
|
|
.speaker-notes.visible {
|
|
display: block;
|
|
}
|
|
|
|
.toc {
|
|
position: fixed;
|
|
top: 60px;
|
|
right: -300px;
|
|
width: 280px;
|
|
height: calc(100vh - 60px);
|
|
background: var(--surface);
|
|
border-left: 1px solid var(--border);
|
|
padding: 24px;
|
|
overflow-y: auto;
|
|
transition: right 0.3s ease;
|
|
z-index: 99;
|
|
}
|
|
|
|
.toc.visible {
|
|
right: 0;
|
|
}
|
|
|
|
.toc ol {
|
|
list-style: decimal;
|
|
padding-left: 20px;
|
|
}
|
|
|
|
.toc li {
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.toc a {
|
|
color: var(--text);
|
|
text-decoration: none;
|
|
}
|
|
|
|
.toc a:hover {
|
|
color: var(--primary);
|
|
}
|
|
|
|
.classroom-footer {
|
|
text-align: center;
|
|
padding: 32px;
|
|
color: var(--secondary);
|
|
font-size: 14px;
|
|
}
|
|
|
|
.meta {
|
|
display: flex;
|
|
gap: 16px;
|
|
justify-content: center;
|
|
margin: 16px 0;
|
|
}
|
|
|
|
.meta span {
|
|
padding: 4px 12px;
|
|
background: var(--background);
|
|
border-radius: 4px;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.objectives {
|
|
text-align: left;
|
|
max-width: 500px;
|
|
margin: 24px auto;
|
|
}
|
|
|
|
.objectives ul {
|
|
list-style: disc;
|
|
padding-left: 24px;
|
|
}
|
|
|
|
.objectives li {
|
|
margin-bottom: 8px;
|
|
}
|
|
"#
|
|
}
|
|
|
|
/// Get default JavaScript
|
|
fn get_default_js() -> &'static str {
|
|
r#"
|
|
let currentScene = 0;
|
|
const scenes = document.querySelectorAll('.scene');
|
|
const totalScenes = scenes.length;
|
|
|
|
function showScene(index) {
|
|
scenes.forEach((s, i) => {
|
|
s.style.display = i === index ? 'block' : 'none';
|
|
});
|
|
document.getElementById('scene-counter').textContent = `${index + 1} / ${totalScenes}`;
|
|
}
|
|
|
|
document.getElementById('prev-scene').addEventListener('click', () => {
|
|
if (currentScene > 0) {
|
|
currentScene--;
|
|
showScene(currentScene);
|
|
}
|
|
});
|
|
|
|
document.getElementById('next-scene').addEventListener('click', () => {
|
|
if (currentScene < totalScenes - 1) {
|
|
currentScene++;
|
|
showScene(currentScene);
|
|
}
|
|
});
|
|
|
|
document.getElementById('toggle-notes').addEventListener('click', () => {
|
|
document.querySelectorAll('.speaker-notes').forEach(n => {
|
|
n.classList.toggle('visible');
|
|
});
|
|
});
|
|
|
|
document.getElementById('toggle-toc').addEventListener('click', () => {
|
|
document.getElementById('toc-panel').classList.toggle('visible');
|
|
});
|
|
|
|
// Initialize
|
|
showScene(0);
|
|
|
|
// Keyboard navigation
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'ArrowRight' || e.key === ' ') {
|
|
if (currentScene < totalScenes - 1) {
|
|
currentScene++;
|
|
showScene(currentScene);
|
|
}
|
|
} else if (e.key === 'ArrowLeft') {
|
|
if (currentScene > 0) {
|
|
currentScene--;
|
|
showScene(currentScene);
|
|
}
|
|
}
|
|
});
|
|
"#
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::generation::{ClassroomMetadata, TeachingStyle, DifficultyLevel};
|
|
|
|
fn create_test_classroom() -> Classroom {
|
|
Classroom {
|
|
id: "test-1".to_string(),
|
|
title: "Test Classroom".to_string(),
|
|
description: "A test classroom".to_string(),
|
|
topic: "Testing".to_string(),
|
|
style: TeachingStyle::Lecture,
|
|
level: DifficultyLevel::Beginner,
|
|
total_duration: 1800,
|
|
objectives: vec!["Learn A".to_string(), "Learn B".to_string()],
|
|
scenes: vec![
|
|
GeneratedScene {
|
|
id: "scene-1".to_string(),
|
|
outline_id: "outline-1".to_string(),
|
|
content: SceneContent {
|
|
title: "Introduction".to_string(),
|
|
scene_type: SceneType::Slide,
|
|
content: serde_json::json!({"description": "Intro slide"}),
|
|
actions: vec![SceneAction::Speech {
|
|
text: "Welcome!".to_string(),
|
|
agent_role: "teacher".to_string(),
|
|
}],
|
|
duration_seconds: 600,
|
|
notes: Some("Speaker notes here".to_string()),
|
|
},
|
|
order: 0,
|
|
},
|
|
],
|
|
metadata: ClassroomMetadata::default(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_html_export() {
|
|
let exporter = HtmlExporter::new();
|
|
let classroom = create_test_classroom();
|
|
let options = ExportOptions::default();
|
|
|
|
let result = exporter.export(&classroom, &options).unwrap();
|
|
|
|
assert_eq!(result.extension, "html");
|
|
assert_eq!(result.mime_type, "text/html");
|
|
assert!(result.filename.ends_with(".html"));
|
|
|
|
let html = String::from_utf8(result.content).unwrap();
|
|
assert!(html.contains("<!DOCTYPE html>"));
|
|
assert!(html.contains("Test Classroom"));
|
|
assert!(html.contains("Introduction"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_html_escape() {
|
|
assert_eq!(html_escape("Hello <World>"), "Hello <World>");
|
|
assert_eq!(html_escape("A & B"), "A & B");
|
|
assert_eq!(html_escape("Say \"Hi\""), "Say "Hi"");
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_duration() {
|
|
assert_eq!(format_duration(1800), "30m");
|
|
assert_eq!(format_duration(3665), "61m 5s");
|
|
assert_eq!(format_duration(60), "1m");
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_level() {
|
|
assert_eq!(format_level(&DifficultyLevel::Beginner), "Beginner");
|
|
assert_eq!(format_level(&DifficultyLevel::Expert), "Expert");
|
|
}
|
|
|
|
#[test]
|
|
fn test_include_notes() {
|
|
let exporter = HtmlExporter::new();
|
|
let classroom = create_test_classroom();
|
|
|
|
let options_with_notes = ExportOptions {
|
|
include_notes: true,
|
|
..Default::default()
|
|
};
|
|
|
|
let result = exporter.export(&classroom, &options_with_notes).unwrap();
|
|
let html = String::from_utf8(result.content).unwrap();
|
|
assert!(html.contains("Speaker notes here"));
|
|
|
|
let options_no_notes = ExportOptions {
|
|
include_notes: false,
|
|
..Default::default()
|
|
};
|
|
|
|
let result = exporter.export(&classroom, &options_no_notes).unwrap();
|
|
let html = String::from_utf8(result.content).unwrap();
|
|
assert!(!html.contains("Speaker notes here"));
|
|
}
|
|
}
|