1use crate::types::*;
7use pulldown_cmark::{html, Parser};
8
9#[derive(Debug, thiserror::Error)]
11pub enum RenderError {
12 #[error("Template error: {0}")]
13 TemplateError(String),
14
15 #[error("Missing required field: {0}")]
16 MissingField(String),
17}
18
19#[derive(Debug, Clone)]
21pub struct RenderConfig {
22 pub reveal_hints: bool,
24
25 pub reveal_solution: bool,
27
28 pub enable_playground: bool,
30
31 pub playground_url: String,
33
34 pub enable_progress: bool,
36
37 pub enabled: bool,
39
40 pub manage_assets: bool,
42}
43
44impl Default for RenderConfig {
45 fn default() -> Self {
46 Self {
47 reveal_hints: false,
48 reveal_solution: false,
49 enable_playground: true,
50 playground_url: "https://play.rust-lang.org".to_string(),
51 enable_progress: true,
52 enabled: true,
53 manage_assets: false,
54 }
55 }
56}
57
58pub fn render_exercise(parsed: &ParsedExercise) -> Result<String, RenderError> {
60 render_exercise_with_config(parsed, &RenderConfig::default())
61}
62
63pub fn render_exercise_with_config(
65 parsed: &ParsedExercise,
66 config: &RenderConfig,
67) -> Result<String, RenderError> {
68 match parsed {
69 ParsedExercise::Code(exercise) => render_code_exercise(exercise, config),
70 ParsedExercise::UseCase(exercise) => render_usecase_exercise(exercise, config),
71 }
72}
73
74fn render_code_exercise(
77 exercise: &Exercise,
78 config: &RenderConfig,
79) -> Result<String, RenderError> {
80 let mut html = String::new();
81
82 html.push_str(&format!(
83 r#"<article class="exercise" data-exercise-id="{}" data-difficulty="{}">"#,
84 escape_html(&exercise.metadata.id),
85 exercise.metadata.difficulty
86 ));
87 html.push('\n');
88
89 html.push_str(&render_code_header(exercise));
90 html.push_str(&render_code_navigation(exercise));
91
92 if !exercise.description.is_empty() {
93 html.push_str(&render_description(&exercise.description, &exercise.metadata.id));
94 }
95
96 if let Some(objectives) = &exercise.objectives {
97 html.push_str(&render_objectives(objectives, &exercise.metadata.id));
98 }
99
100 if let Some(discussion) = &exercise.discussion {
101 html.push_str(&render_discussion(discussion));
102 }
103
104 if let Some(starter) = &exercise.starter {
105 html.push_str(&render_starter(starter, &exercise.metadata.id));
106 }
107
108 if !exercise.hints.is_empty() {
109 html.push_str(&render_hints(&exercise.hints, config.reveal_hints, &exercise.metadata.id));
110 }
111
112 if let Some(solution) = &exercise.solution {
113 html.push_str(&render_solution(solution, config.reveal_solution, &exercise.metadata.id));
114 }
115
116 if let Some(tests) = &exercise.tests {
117 html.push_str(&render_tests(tests, &exercise.metadata.id, config));
118 }
119
120 if let Some(reflection) = &exercise.reflection {
121 html.push_str(&render_reflection(reflection, &exercise.metadata.id));
122 }
123
124 if config.enable_progress {
125 html.push_str(&render_footer(&exercise.metadata.id));
126 }
127
128 html.push_str("</article>\n");
129 Ok(html)
130}
131
132fn render_usecase_exercise(
135 exercise: &UseCaseExercise,
136 config: &RenderConfig,
137) -> Result<String, RenderError> {
138 let mut html = String::new();
139 let id = &exercise.metadata.id;
140
141 html.push_str(&format!(
142 r#"<article class="usecase-exercise" data-exercise-id="{}" data-domain="{}" data-difficulty="{}">"#,
143 escape_html(id),
144 exercise.metadata.domain,
145 exercise.metadata.difficulty
146 ));
147 html.push('\n');
148
149 html.push_str(&render_usecase_header(exercise));
150
151 if !exercise.description.is_empty() {
152 html.push_str(&render_description(&exercise.description, id));
153 }
154
155 if let Some(objectives) = &exercise.objectives {
156 html.push_str(&render_objectives(objectives, id));
157 }
158
159 html.push_str(&render_scenario(&exercise.scenario, id));
161
162 html.push_str(&render_prompt(&exercise.prompt, id));
164
165 if !exercise.hints.is_empty() {
167 html.push_str(&render_hints(&exercise.hints, config.reveal_hints, id));
168 }
169
170 html.push_str(&render_response_area(&exercise.evaluation, id));
172
173 html.push_str(&render_evaluation_placeholder(id));
175
176 if let Some(context) = &exercise.context {
178 html.push_str(&render_context(context, id));
179 }
180
181 if config.enable_progress {
182 html.push_str(&render_footer(id));
183 }
184
185 html.push_str("</article>\n");
186 Ok(html)
187}
188
189fn render_description(description: &str, exercise_id: &str) -> String {
192 let mut html = String::new();
193 html.push_str(&format!(
194 r#"<section class="exercise-description" id="{}-description">"#,
195 exercise_id
196 ));
197 html.push('\n');
198 let parser = Parser::new(description);
199 let mut description_html = String::new();
200 html::push_html(&mut description_html, parser);
201 html.push_str(&description_html);
202 html.push_str("</section>\n");
203 html
204}
205
206fn render_objectives(objectives: &Objectives, exercise_id: &str) -> String {
207 let mut html = String::new();
208 html.push_str(&format!(
209 r#"<section class="exercise-objectives" id="{}-objectives">"#,
210 exercise_id
211 ));
212 html.push('\n');
213 html.push_str(" <h3>๐ฏ Learning Objectives</h3>\n");
214 html.push_str(r#" <div class="objectives-grid">"#);
215 html.push('\n');
216
217 if !objectives.thinking.is_empty() {
218 html.push_str(r#" <div class="objectives-thinking">"#);
219 html.push('\n');
220 html.push_str(" <h4>Thinking</h4>\n");
221 html.push_str(" <ul>\n");
222 for (i, obj) in objectives.thinking.iter().enumerate() {
223 let id = format!("{}-thinking-{}", exercise_id, i);
224 html.push_str(&format!(
225 r#" <li><input type="checkbox" id="{}" class="objective-checkbox"><label for="{}">{}</label></li>"#,
226 id, id, escape_html(obj)
227 ));
228 html.push('\n');
229 }
230 html.push_str(" </ul>\n");
231 html.push_str(" </div>\n");
232 }
233
234 if !objectives.doing.is_empty() {
235 html.push_str(r#" <div class="objectives-doing">"#);
236 html.push('\n');
237 html.push_str(" <h4>Doing</h4>\n");
238 html.push_str(" <ul>\n");
239 for (i, obj) in objectives.doing.iter().enumerate() {
240 let id = format!("{}-doing-{}", exercise_id, i);
241 html.push_str(&format!(
242 r#" <li><input type="checkbox" id="{}" class="objective-checkbox"><label for="{}">{}</label></li>"#,
243 id, id, escape_html(obj)
244 ));
245 html.push('\n');
246 }
247 html.push_str(" </ul>\n");
248 html.push_str(" </div>\n");
249 }
250 html.push_str(" </div>\n");
251 html.push_str("</section>\n");
252 html
253}
254
255fn render_hints(hints: &[Hint], reveal: bool, exercise_id: &str) -> String {
256 let mut html = String::new();
257 html.push_str(&format!(
258 r#"<section class="exercise-hints" id="{}-hints">"#,
259 exercise_id
260 ));
261 html.push('\n');
262 html.push_str(" <h3>๐ก Hints</h3>\n");
263 for hint in hints {
264 let open_attr = if reveal { " open" } else { "" };
265 let title = hint
266 .title
267 .as_ref()
268 .map(|t| format!("Hint {}: {}", hint.level, t))
269 .unwrap_or_else(|| format!("Hint {}", hint.level));
270
271 html.push_str(&format!(
272 r#" <details class="hint" data-level="{}"{}>
273 <summary>{}</summary>
274 <div class="hint-content">
275"#,
276 hint.level,
277 open_attr,
278 escape_html(&title)
279 ));
280 let parser = Parser::new(&hint.content);
281 let mut hint_html = String::new();
282 html::push_html(&mut hint_html, parser);
283 html.push_str(&hint_html);
284 html.push_str(" </div>\n");
285 html.push_str(" </details>\n");
286 }
287 html.push_str("</section>\n");
288 html
289}
290
291fn render_footer(exercise_id: &str) -> String {
292 let mut html = String::new();
293 html.push_str(r#"<footer class="exercise-footer">"#);
294 html.push('\n');
295 html.push_str(&format!(
296 r#" <button class="btn btn-complete" data-exercise-id="{}">โ Mark Complete</button>"#,
297 exercise_id
298 ));
299 html.push('\n');
300 html.push_str("</footer>\n");
301 html
302}
303
304fn render_code_header(exercise: &Exercise) -> String {
307 let mut html = String::new();
308 html.push_str(r#"<header class="exercise-header">"#);
309 html.push('\n');
310 if let Some(title) = &exercise.title {
311 html.push_str(&format!(r#" <h2 class="exercise-title">{}</h2>"#, escape_html(title)));
312 html.push('\n');
313 }
314 html.push_str(&format!(r#" <code class=\"exercise-id\">{}</code>"#, escape_html(&exercise.metadata.id)));
315 html.push('\n');
316 html.push_str(r#" <div class="exercise-meta">"#);
317 html.push('\n');
318
319 let difficulty_class = match exercise.metadata.difficulty {
320 Difficulty::Beginner => "beginner",
321 Difficulty::Intermediate => "intermediate",
322 Difficulty::Advanced => "advanced",
323 };
324 let difficulty_icon = match exercise.metadata.difficulty {
325 Difficulty::Beginner => "โญ",
326 Difficulty::Intermediate => "โญโญ",
327 Difficulty::Advanced => "โญโญโญ",
328 };
329 html.push_str(&format!(
330 r#" <span class="badge difficulty {}">{} {}</span>"#,
331 difficulty_class, difficulty_icon, exercise.metadata.difficulty
332 ));
333 html.push('\n');
334
335 if let Some(minutes) = exercise.metadata.time_minutes {
336 let time_str = if minutes >= 60 {
337 format!("{}h {}m", minutes / 60, minutes % 60)
338 } else {
339 format!("{} min", minutes)
340 };
341 html.push_str(&format!(r#" <span class="badge time">โฑ๏ธ {}</span>"#, time_str));
342 html.push('\n');
343 }
344
345 if !exercise.metadata.prerequisites.is_empty() {
346 let prereqs: Vec<String> = exercise
347 .metadata
348 .prerequisites
349 .iter()
350 .map(|p| format!("<a href=\"#{}\">{}</a>", escape_html(p), escape_html(p)))
351 .collect();
352 html.push_str(&format!(r#" <span class="badge prerequisites">๐ Requires: {}</span>"#, prereqs.join(", ")));
353 html.push('\n');
354 }
355 html.push_str(" </div>\n");
356 html.push_str("</header>\n");
357 html
358}
359
360fn render_code_navigation(exercise: &Exercise) -> String {
361 let mut html = String::new();
362 let id = &exercise.metadata.id;
363 html.push_str(r#"<nav class="exercise-nav" aria-label="Exercise sections"><ul>"#);
364 if !exercise.description.is_empty() {
365 html.push_str(&format!(r##"<li><a href="#{}-description" data-section="description">๐ Overview</a></li>"##, id));
366 }
367 if exercise.objectives.is_some() {
368 html.push_str(&format!(r##"<li><a href="#{}-objectives" data-section="objectives">๐ฏ Objectives</a></li>"##, id));
369 }
370 if exercise.starter.is_some() {
371 html.push_str(&format!(r##"<li><a href="#{}-starter" data-section="starter">๐ป Code</a></li>"##, id));
372 }
373 if !exercise.hints.is_empty() {
374 html.push_str(&format!(r##"<li><a href="#{}-hints" data-section="hints">๐ก Hints</a></li>"##, id));
375 }
376 if exercise.solution.is_some() {
377 html.push_str(&format!(r##"<li><a href="#{}-solution" data-section="solution">โ
Solution</a></li>"##, id));
378 }
379 if exercise.tests.is_some() {
380 html.push_str(&format!(r##"<li><a href="#{}-tests" data-section="tests">๐งช Tests</a></li>"##, id));
381 }
382 if exercise.reflection.is_some() {
383 html.push_str(&format!(r##"<li><a href="#{}-reflection" data-section="reflection">๐ค Reflect</a></li>"##, id));
384 }
385 html.push_str("</ul></nav>\n");
386 html
387}
388
389fn render_discussion(discussion: &[String]) -> String {
390 let mut html = String::new();
391 html.push_str(r#"<section class="exercise-discussion">"#);
392 html.push('\n');
393 html.push_str(" <h3>๐ฌ Discussion</h3>\n");
394 html.push_str(" <ul>\n");
395 for item in discussion {
396 html.push_str(&format!(" <li>{}</li>\n", escape_html(item)));
397 }
398 html.push_str(" </ul>\n");
399 html.push_str("</section>\n");
400 html
401}
402
403fn render_starter(starter: &StarterCode, exercise_id: &str) -> String {
404 let mut html = String::new();
405 html.push_str(&format!(r#"<section class="exercise-starter" id="{}-starter">"#, exercise_id));
406 html.push('\n');
407 html.push_str(r#" <div class="code-header">"#);
408 html.push('\n');
409 if let Some(filename) = &starter.filename {
410 html.push_str(&format!(r#" <span class="filename">{}</span>"#, escape_html(filename)));
411 html.push('\n');
412 }
413 html.push_str(r#" <div class="code-actions">"#);
414 html.push('\n');
415 html.push_str(&format!(r#" <button class="btn btn-copy" data-target="code-{}" title="Copy code">๐ Copy</button>"#, exercise_id));
416 html.push('\n');
417 html.push_str(&format!(r#" <button class="btn btn-reset" data-target="code-{}" title="Reset to original">โบ Reset</button>"#, exercise_id));
418 html.push('\n');
419 html.push_str(" </div>\n");
420 html.push_str(" </div>\n");
421 html.push_str(&format!(
422 r#" <textarea class="code-editor" id="code-{}" data-language="{}" data-original="{}" spellcheck="false"></textarea>"#,
423 exercise_id,
424 escape_html(&starter.language),
425 escape_html_attr(&starter.code)
426 ));
427 html.push('\n');
428 html.push_str("</section>\n");
429 html
430}
431
432fn render_solution(solution: &Solution, reveal: bool, exercise_id: &str) -> String {
433 let mut html = String::new();
434 html.push_str(&format!(r#"<section class="exercise-solution" id="{}-solution">"#, exercise_id));
435 html.push('\n');
436 let should_open = match solution.reveal {
437 SolutionReveal::Always => true,
438 SolutionReveal::Never => false,
439 SolutionReveal::OnDemand => reveal,
440 };
441 let open_attr = if should_open { " open" } else { "" };
442 html.push_str(&format!(r#" <details class="solution"{}>"#, open_attr));
443 html.push('\n');
444 html.push_str(r#" <summary><span class="solution-warning">โ ๏ธ Try the exercise first!</span><span class="solution-toggle">Show Solution</span></summary>"#);
445 html.push('\n');
446 html.push_str(r#" <div class="solution-content">"#);
447 html.push('\n');
448 html.push_str(&format!(r#" <pre><code class="language-{}">{}</code></pre>"#, escape_html(&solution.language), escape_html(&solution.code)));
449 html.push('\n');
450 if let Some(explanation) = &solution.explanation {
451 html.push_str(r#" <div class="solution-explanation"><h4>Explanation</h4>"#);
452 let parser = Parser::new(explanation);
453 let mut explanation_html = String::new();
454 html::push_html(&mut explanation_html, parser);
455 html.push_str(&explanation_html);
456 html.push_str("</div>\n");
457 }
458 html.push_str(" </div>\n </details>\n</section>\n");
459 html
460}
461
462fn render_tests(tests: &TestBlock, exercise_id: &str, config: &RenderConfig) -> String {
463 let mut html = String::new();
464 html.push_str(&format!(r#"<section class="exercise-tests" id="{}-tests" data-mode="{}">"#, exercise_id, tests.mode));
465 html.push('\n');
466 html.push_str(" <h3>๐งช Tests</h3>\n");
467 html.push_str(r#" <div class="test-actions">"#);
468 html.push('\n');
469 if tests.mode == TestMode::Playground && config.enable_playground {
470 html.push_str(&format!(
471 r#" <button class="btn btn-run-tests" data-exercise-id="{}" data-playground-url="{}">โถ Run Tests</button>"#,
472 exercise_id, escape_html(&config.playground_url)
473 ));
474 html.push('\n');
475 } else {
476 html.push_str(r#" <div class="local-test-info"><p>Run these tests locally with:</p><pre><code>cargo test</code></pre></div>"#);
477 html.push('\n');
478 }
479 html.push_str(" </div>\n");
480 html.push_str(&format!(r#" <div class="test-results" id="results-{}" hidden></div>"#, exercise_id));
481 html.push('\n');
482 html.push_str(r#" <details class="tests-code"><summary>View Test Code</summary>"#);
483 html.push('\n');
484 html.push_str(&format!(r#" <pre><code class="language-{}">{}</code></pre>"#, escape_html(&tests.language), escape_html(&tests.code)));
485 html.push('\n');
486 html.push_str(" </details>\n</section>\n");
487 html
488}
489
490fn render_reflection(reflection: &[String], exercise_id: &str) -> String {
491 let mut html = String::new();
492 html.push_str(&format!(r#"<section class="exercise-reflection" id="{}-reflection">"#, exercise_id));
493 html.push('\n');
494 html.push_str(" <h3>๐ค Reflection</h3>\n");
495 html.push_str(" <ul>\n");
496 for item in reflection {
497 html.push_str(&format!(" <li>{}</li>\n", escape_html(item)));
498 }
499 html.push_str(" </ul>\n</section>\n");
500 html
501}
502
503fn render_usecase_header(exercise: &UseCaseExercise) -> String {
506 let mut html = String::new();
507 html.push_str(r#"<header class="exercise-header">"#);
508 html.push('\n');
509
510 if let Some(title) = &exercise.title {
511 html.push_str(&format!(r#" <h2 class="exercise-title">{}</h2>"#, escape_html(title)));
512 html.push('\n');
513 }
514
515 html.push_str(r#" <div class="exercise-meta">"#);
516 html.push('\n');
517
518 let domain_class = exercise.metadata.domain.to_string();
520 html.push_str(&format!(
521 r#" <span class="badge domain {}">{}</span>"#,
522 domain_class, exercise.metadata.domain
523 ));
524 html.push('\n');
525
526 let difficulty_class = match exercise.metadata.difficulty {
528 Difficulty::Beginner => "beginner",
529 Difficulty::Intermediate => "intermediate",
530 Difficulty::Advanced => "advanced",
531 };
532 html.push_str(&format!(
533 r#" <span class="badge difficulty {}">{}</span>"#,
534 difficulty_class, exercise.metadata.difficulty
535 ));
536 html.push('\n');
537
538 if let Some(minutes) = exercise.metadata.time_minutes {
539 html.push_str(&format!(r#" <span class="badge time">{} min</span>"#, minutes));
540 html.push('\n');
541 }
542 html.push_str(" </div>\n</header>\n");
543 html
544}
545
546fn render_scenario(scenario: &Scenario, _exercise_id: &str) -> String {
547 let mut html = String::new();
548 html.push_str(r#"<section class="exercise-scenario">"#);
549 html.push('\n');
550 html.push_str(" <h3>Scenario</h3>\n");
551
552 if let Some(org) = &scenario.organization {
554 html.push_str(r#" <div class="scenario-header">"#);
555 html.push_str(&format!(r#" <span class="organization">{}</span>"#, escape_html(org)));
556 html.push_str(" </div>\n");
557 }
558
559 html.push_str(r#" <div class="scenario-content">"#);
561 let parser = Parser::new(&scenario.content);
562 let mut content_html = String::new();
563 html::push_html(&mut content_html, parser);
564 html.push_str(&content_html);
565 html.push_str(" </div>\n");
566
567 if !scenario.constraints.is_empty() {
569 html.push_str(r#" <div class="scenario-constraints">"#);
570 html.push('\n');
571 html.push_str(" <h4>Constraints</h4>\n");
572 html.push_str(" <ul>\n");
573 for c in &scenario.constraints {
574 html.push_str(&format!(" <li>{}</li>\n", escape_html(c)));
575 }
576 html.push_str(" </ul>\n");
577 html.push_str(" </div>\n");
578 }
579
580 html.push_str("</section>\n");
581 html
582}
583
584fn render_prompt(prompt: &UseCasePrompt, _exercise_id: &str) -> String {
585 let mut html = String::new();
586 html.push_str(r#"<section class="exercise-prompt">"#);
587 html.push('\n');
588 html.push_str(" <h3>Your Task</h3>\n");
589
590 html.push_str(r#" <div class="prompt-content">"#);
591 let parser = Parser::new(&prompt.prompt);
592 let mut prompt_html = String::new();
593 html::push_html(&mut prompt_html, parser);
594 html.push_str(&prompt_html);
595 html.push_str(" </div>\n");
596
597 if !prompt.aspects.is_empty() {
598 html.push_str(r#" <div class="prompt-aspects">"#);
599 html.push('\n');
600 html.push_str(" <h4>Address these aspects:</h4>\n");
601 html.push_str(" <ul>\n");
602 for aspect in &prompt.aspects {
603 html.push_str(&format!(" <li>{}</li>\n", escape_html(aspect)));
604 }
605 html.push_str(" </ul>\n");
606 html.push_str(" </div>\n");
607 }
608
609 html.push_str("</section>\n");
610 html
611}
612
613fn render_response_area(eval: &EvaluationCriteria, exercise_id: &str) -> String {
614 let mut html = String::new();
615 html.push_str(r#"<section class="exercise-response">"#);
616 html.push('\n');
617 html.push_str(" <h3>Your Response</h3>\n");
618
619 let min_words = eval.min_words.unwrap_or(0);
621 let max_words = eval.max_words.unwrap_or(10000);
622
623 let range_display = if eval.max_words.is_some() {
624 format!("{}-{} words", min_words, max_words)
625 } else if eval.min_words.is_some() {
626 format!("{}+ words", min_words)
627 } else {
628 "No word count limit".to_string()
629 };
630
631 html.push_str(r#" <div class="response-requirements">"#);
632 html.push_str(&format!(r#" <span class="word-count-req">{}</span>"#, range_display));
633 html.push_str(" </div>\n");
634
635 html.push_str(&format!(
637 r#" <textarea class="response-editor" id="response-{}" placeholder="Enter your analysis here..." data-min-words="{}" data-max-words="{}"></textarea>"#,
638 exercise_id, min_words, max_words
639 ));
640 html.push('\n');
641
642 html.push_str(r#" <div class="response-meta">"#);
643 html.push_str(r#" <span class="current-word-count">0 words</span>"#);
644 html.push_str(" </div>\n");
645
646 html.push_str(r#" <div class="response-actions">"#);
647 html.push_str(&format!(
648 r#" <button class="btn-submit" data-exercise-id="{}">Submit for Evaluation</button>"#,
649 exercise_id
650 ));
651 html.push_str(" </div>\n");
652
653 html.push_str("</section>\n");
654 html
655}
656
657fn render_evaluation_placeholder(exercise_id: &str) -> String {
658 format!(
659 r#"<section class="exercise-evaluation" id="evaluation-{}" hidden><h3>Evaluation Results</h3><div class="overall-score"></div><div class="criterion-scores"></div><div class="feedback"></div></section>"#,
660 exercise_id
661 )
662}
663
664fn render_context(context: &str, exercise_id: &str) -> String {
665 let mut html = String::new();
666 html.push_str(&format!(r#"<section class="exercise-context" id="context-{}" hidden>"#, exercise_id));
667 html.push('\n');
668 html.push_str(" <h3>Key Learning Points</h3>\n");
669 html.push_str(r#" <div class="context-content">"#);
670 let parser = Parser::new(context);
671 let mut context_html = String::new();
672 html::push_html(&mut context_html, parser);
673 html.push_str(&context_html);
674 html.push_str(" </div>\n");
675 html.push_str("</section>\n");
676 html
677}
678
679fn escape_html(s: &str) -> String {
682 s.replace('&', "&")
683 .replace('<', "<")
684 .replace('>', ">")
685 .replace('"', """)
686 .replace('\'', "'")
687}
688
689fn escape_html_attr(s: &str) -> String {
690 s.replace('&', "&")
691 .replace('<', "<")
692 .replace('>', ">")
693 .replace('"', """)
694 .replace('\'', "'")
695 .replace('\n', " ")
696 .replace('\r', " ")
697}
698
699#[cfg(test)]
700mod tests {
701 use super::*;
702
703 #[test]
704 fn test_render_simple_code_exercise() {
705 let exercise = Exercise {
706 metadata: ExerciseMetadata {
707 id: "test-exercise".to_string(),
708 difficulty: Difficulty::Beginner,
709 time_minutes: Some(15),
710 prerequisites: vec![],
711 },
712 title: Some("Test Exercise".to_string()),
713 description: "A simple test exercise.".to_string(),
714 ..Default::default()
715 };
716
717 let parsed = ParsedExercise::Code(exercise);
718 let html = render_exercise(&parsed).unwrap();
719
720 assert!(html.contains(r#"data-exercise-id="test-exercise""#));
721 assert!(html.contains("Test Exercise"));
722 assert!(html.contains("beginner"));
723 }
724
725 #[test]
726 fn test_render_usecase_exercise() {
727 let exercise = UseCaseExercise {
728 metadata: UseCaseMetadata {
729 id: "uc-001".to_string(),
730 difficulty: Difficulty::Intermediate,
731 domain: UseCaseDomain::Healthcare,
732 ..Default::default()
733 },
734 title: Some("HIPAA Analysis".to_string()),
735 scenario: Scenario {
736 organization: Some("Hospital".to_string()),
737 content: "Scenario text".to_string(),
738 ..Default::default()
739 },
740 prompt: UseCasePrompt {
741 prompt: "Analyze it".to_string(),
742 ..Default::default()
743 },
744 ..Default::default()
745 };
746
747 let parsed = ParsedExercise::UseCase(exercise);
748 let html = render_exercise(&parsed).unwrap();
749
750 assert!(html.contains("usecase-exercise"));
751 assert!(html.contains("data-domain=\"healthcare\""));
752 assert!(html.contains("HIPAA Analysis"));
753 assert!(html.contains("Hospital"));
754 }
755}