Skip to main content

lark_webhook_notify/
workflow.rs

1use chrono::Local;
2use std::collections::HashMap;
3
4use crate::blocks::{TextAlign, TextSize};
5use crate::builder::CardBuilder;
6use crate::templates::{ColorTheme, GenericCardTemplate, LanguageCode, get_translation};
7
8/// Progress counters for a single task set, used by [`task_set_progress`].
9pub struct TaskSetProgress {
10    /// Number of tasks completed so far.
11    pub complete: u32,
12    /// Total number of tasks in the set.
13    pub total: u32,
14}
15
16pub fn network_submission_start(
17    network_set_name: &str,
18    network_type: &str,
19    group: Option<&str>,
20    prefix: Option<&str>,
21    metadata: Option<&serde_json::Value>,
22    language: LanguageCode,
23) -> GenericCardTemplate {
24    let t = |k: &str| get_translation(k, language);
25    let metadata_text = format!(
26        "**{}:** {}\n**{}:** {}",
27        t("network_set_name"),
28        network_set_name,
29        t("network_type"),
30        network_type,
31    );
32    let status_text = t("running");
33    let mut builder = CardBuilder::new()
34        .header(
35            &t("network_submission_started"),
36            Some(&status_text),
37            Some(ColorTheme::Wathet),
38            None,
39        )
40        .markdown(&metadata_text, TextAlign::Left, TextSize::Normal);
41
42    if group.is_some() || prefix.is_some() {
43        builder = builder
44            .columns()
45            .column(&t("group"), Some(group.unwrap_or("*unknown*")), "auto", 1)
46            .column(
47                &t("storage_prefix"),
48                Some(prefix.unwrap_or("*N/A*")),
49                "weighted",
50                1,
51            )
52            .end_columns();
53    }
54    if let Some(meta) = metadata {
55        let s = serde_json::to_string_pretty(meta).unwrap_or_default();
56        builder = builder.collapsible(
57            &t("metadata_overview"),
58            &format!("```json\n{s}\n```"),
59            false,
60        );
61    }
62    builder.build()
63}
64
65pub fn network_submission_complete(
66    network_set_name: &str,
67    submitted_count: Option<u32>,
68    group: Option<&str>,
69    prefix: Option<&str>,
70    duration: Option<&str>,
71    metadata: Option<&serde_json::Value>,
72    language: LanguageCode,
73) -> GenericCardTemplate {
74    let t = |k: &str| get_translation(k, language);
75    let mut lines = vec![format!(
76        "**{}:** {}",
77        t("network_set_name"),
78        network_set_name
79    )];
80    if let Some(c) = submitted_count {
81        lines.push(format!("**{}:** {c}", t("submitted_count")));
82    }
83    if let Some(d) = duration {
84        lines.push(format!("**{}:** {d}", t("duration")));
85    }
86
87    let status_text = t("success");
88    let mut builder = CardBuilder::new()
89        .header(
90            &t("network_submission_complete"),
91            Some(&status_text),
92            Some(ColorTheme::Green),
93            None,
94        )
95        .markdown(&lines.join("\n"), TextAlign::Left, TextSize::Normal);
96
97    if group.is_some() || prefix.is_some() {
98        builder = builder
99            .columns()
100            .column(&t("group"), Some(group.unwrap_or("*unknown*")), "auto", 1)
101            .column(
102                &t("storage_prefix"),
103                Some(prefix.unwrap_or("*N/A*")),
104                "weighted",
105                1,
106            )
107            .end_columns();
108    }
109    if let Some(meta) = metadata {
110        let s = serde_json::to_string_pretty(meta).unwrap_or_default();
111        builder = builder.collapsible(
112            &t("metadata_overview"),
113            &format!("```json\n{s}\n```"),
114            false,
115        );
116    }
117    builder.build()
118}
119
120pub fn network_submission_failure(
121    network_set_name: &str,
122    error_message: &str,
123    submitted_count: Option<u32>,
124    group: Option<&str>,
125    language: LanguageCode,
126) -> GenericCardTemplate {
127    let t = |k: &str| get_translation(k, language);
128    let mut lines = vec![
129        format!("**{}:** {}", t("network_set_name"), network_set_name),
130        format!("**{}:** {}", t("group"), group.unwrap_or("*unknown*")),
131    ];
132    if let Some(c) = submitted_count {
133        lines.push(format!("**{}:** {c}", t("submitted_count")));
134    }
135
136    let status_text = t("failed");
137    CardBuilder::new()
138        .header(
139            &t("network_submission_failed"),
140            Some(&status_text),
141            Some(ColorTheme::Red),
142            None,
143        )
144        .markdown(&lines.join("\n"), TextAlign::Left, TextSize::Normal)
145        .collapsible(&t("error_details"), error_message, false)
146        .build()
147}
148
149pub fn config_upload_complete(
150    config_name: &str,
151    file_count: u32,
152    labels: Option<&[&str]>,
153    desc: Option<&str>,
154    language: LanguageCode,
155) -> GenericCardTemplate {
156    let t = |k: &str| get_translation(k, language);
157    let mut lines = vec![
158        format!("**{}:** {}", t("config_name"), config_name),
159        format!("**{}:** {}", t("files_uploaded"), file_count),
160    ];
161    if let Some(d) = desc {
162        lines.push(format!("**{}:** {d}", t("description")));
163    }
164
165    let status_text = t("success");
166    let mut builder = CardBuilder::new()
167        .header(
168            &t("config_uploaded"),
169            Some(&status_text),
170            Some(ColorTheme::Green),
171            None,
172        )
173        .markdown(&lines.join("\n"), TextAlign::Left, TextSize::Normal);
174
175    if let Some(lbls) = labels
176        && !lbls.is_empty()
177    {
178        builder = builder.collapsible(&t("uploaded_files"), &lbls.join(","), false);
179    }
180    builder.build()
181}
182
183pub fn job_submission_start(
184    job_title: &str,
185    desc: Option<&str>,
186    group: Option<&str>,
187    prefix: Option<&str>,
188    msg: Option<&str>,
189    metadata: Option<&serde_json::Value>,
190    language: LanguageCode,
191) -> GenericCardTemplate {
192    let t = |k: &str| get_translation(k, language);
193    let start_time = Local::now().format("%Y-%m-%d %H:%M").to_string();
194    let running_status = format!(
195        "<font color='wathet-400'> :StatusInFlight: {}</font>",
196        t("running")
197    );
198    let mut lines = vec![
199        format!("**{}:** {}", t("task_name"), job_title),
200        format!("**{}:** {}", t("start_time"), start_time),
201    ];
202    if let Some(d) = desc {
203        lines.push(format!("**{}:** {d}", t("task_description")));
204    }
205    lines.push(format!("**{}:** {}", t("execution_status"), running_status));
206
207    let status_text = t("running");
208    let mut builder = CardBuilder::new()
209        .header(
210            &t("task_submission_started"),
211            Some(&status_text),
212            Some(ColorTheme::Wathet),
213            None,
214        )
215        .markdown(&lines.join("\n"), TextAlign::Left, TextSize::Normal);
216
217    if group.is_some() || prefix.is_some() {
218        builder = builder
219            .columns()
220            .column(
221                &t("result_storage"),
222                Some(group.unwrap_or("*unknown*")),
223                "auto",
224                1,
225            )
226            .column(
227                &t("storage_prefix"),
228                Some(prefix.unwrap_or("*N/A*")),
229                "weighted",
230                1,
231            )
232            .end_columns();
233    }
234    if let Some(m) = msg {
235        builder = builder.collapsible(&t("running_overview"), m, false);
236    }
237    if let Some(meta) = metadata {
238        let s = serde_json::to_string_pretty(meta).unwrap_or_default();
239        builder = builder.collapsible(
240            &t("metadata_overview"),
241            &format!("```json\n{s}\n```"),
242            false,
243        );
244    }
245    builder.build()
246}
247
248#[allow(clippy::too_many_arguments)]
249pub fn job_submission_complete(
250    job_title: &str,
251    submitted_count: u32,
252    desc: Option<&str>,
253    group: Option<&str>,
254    prefix: Option<&str>,
255    duration: Option<&str>,
256    msg: Option<&str>,
257    metadata: Option<&serde_json::Value>,
258    language: LanguageCode,
259) -> GenericCardTemplate {
260    let t = |k: &str| get_translation(k, language);
261    let completion_time = Local::now().format("%Y-%m-%d %H:%M").to_string();
262    let status_text = format!(
263        "<font color='wathet-400'> :StatusInFlight: {}, {}</font>",
264        t("task_submission_complete"),
265        t("running")
266    );
267    let mut lines = vec![
268        format!("**{}:** {}", t("task_name"), job_title),
269        format!("**{}:** {}", t("completion_time"), completion_time),
270        format!("**{}:** {}", t("submitted_count"), submitted_count),
271    ];
272    if let Some(d) = desc {
273        lines.push(format!("**{}:** {d}", t("task_description")));
274    }
275    if let Some(d) = duration {
276        lines.push(format!("**{}:** {d}", t("execution_duration")));
277    }
278    lines.push(format!("**{}:** {}", t("execution_status"), status_text));
279
280    let header_status_text = t("submitted");
281    let mut builder = CardBuilder::new()
282        .header(
283            &t("task_submission_complete"),
284            Some(&header_status_text),
285            Some(ColorTheme::Wathet),
286            None,
287        )
288        .markdown(&lines.join("\n"), TextAlign::Left, TextSize::Normal);
289
290    if group.is_some() || prefix.is_some() {
291        builder = builder
292            .columns()
293            .column(
294                &t("result_storage"),
295                Some(group.unwrap_or("*unknown*")),
296                "auto",
297                1,
298            )
299            .column(
300                &t("storage_prefix"),
301                Some(prefix.unwrap_or("*N/A*")),
302                "weighted",
303                1,
304            )
305            .end_columns();
306    }
307    if let Some(m) = msg {
308        builder = builder.collapsible(&t("running_overview"), m, false);
309    }
310    if let Some(meta) = metadata {
311        let s = serde_json::to_string_pretty(meta).unwrap_or_default();
312        builder = builder.collapsible(
313            &t("metadata_overview"),
314            &format!("```json\n{s}\n```"),
315            false,
316        );
317    }
318    builder.build()
319}
320
321pub fn job_submission_failure(
322    job_title: &str,
323    error_message: &str,
324    submitted_count: Option<u32>,
325    group: Option<&str>,
326    language: LanguageCode,
327) -> GenericCardTemplate {
328    let t = |k: &str| get_translation(k, language);
329    let mut lines = vec![
330        format!("**{}:** {}", t("task_name"), job_title),
331        format!("**{}:** {}", t("group"), group.unwrap_or("*unknown*")),
332    ];
333    if let Some(c) = submitted_count {
334        lines.push(format!("**{}:** {c}", t("submitted_before_failure")));
335    }
336
337    let status_text = t("failed");
338    CardBuilder::new()
339        .header(
340            &t("task_submission_failed"),
341            Some(&status_text),
342            Some(ColorTheme::Red),
343            None,
344        )
345        .markdown(&lines.join("\n"), TextAlign::Left, TextSize::Normal)
346        .collapsible(&t("error_details"), error_message, false)
347        .build()
348}
349
350#[allow(clippy::too_many_arguments)]
351pub fn job_complete(
352    job_title: &str,
353    success: bool,
354    status: i32,
355    group: Option<&str>,
356    prefix: Option<&str>,
357    desc: Option<&str>,
358    msg: Option<&str>,
359    duration: Option<&str>,
360    title: Option<&str>,
361    language: LanguageCode,
362) -> GenericCardTemplate {
363    let t = |k: &str| get_translation(k, language);
364    let completion_time = Local::now().format("%Y-%m-%d %H:%M").to_string();
365
366    let (task_status, status_text, color, default_title) = if success {
367        (
368            format!("<font color='green'> :CheckMark: {}</font>", t("completed")),
369            t("success"),
370            ColorTheme::Green,
371            t("task_completion_notification"),
372        )
373    } else {
374        (
375            format!(
376                "<font color='red'> :CrossMark: {}: {status}</font>",
377                t("failed")
378            ),
379            t("failure"),
380            ColorTheme::Red,
381            t("task_failure_notification"),
382        )
383    };
384
385    let card_title = title.unwrap_or(&default_title);
386    let mut lines = vec![
387        format!("**{}:** {}", t("task_name"), job_title),
388        format!("**{}:** {}", t("completion_time"), completion_time),
389    ];
390    if let Some(d) = desc {
391        lines.push(format!("**{}:** {d}", t("task_description")));
392    }
393    if let Some(d) = duration {
394        lines.push(format!("**{}:** {d}", t("execution_duration")));
395    }
396    lines.push(format!("**{}:** {}", t("execution_status"), task_status));
397
398    let mut builder = CardBuilder::new()
399        .header(card_title, Some(&status_text), Some(color), None)
400        .markdown(&lines.join("\n"), TextAlign::Left, TextSize::Normal);
401
402    if group.is_some() || prefix.is_some() {
403        builder = builder
404            .columns()
405            .column(&t("group"), Some(group.unwrap_or("*unknown*")), "auto", 1)
406            .column(
407                &t("storage_prefix"),
408                Some(prefix.unwrap_or("*N/A*")),
409                "weighted",
410                1,
411            )
412            .end_columns();
413    }
414    if let Some(m) = msg {
415        builder = builder.collapsible(&t("result_overview"), m, false);
416    }
417    builder.build()
418}
419
420/// NOTE: The header is always blue. `overall_status` is used only as the header
421/// tag label (not for color detection), allowing callers to pass a descriptive
422/// string like "3/5 complete" for display purposes.
423pub fn task_set_progress(
424    progress: &HashMap<&str, TaskSetProgress>,
425    overall_status: &str,
426    language: LanguageCode,
427) -> GenericCardTemplate {
428    let t = |k: &str| get_translation(k, language);
429    let mut table_lines = vec![
430        format!(
431            "| {} | Progress | {} | Total |",
432            t("task_set_name"),
433            t("completed")
434        ),
435        "|:---|:---|---:|---:|".to_owned(),
436    ];
437    for (name, p) in progress {
438        let pct = if p.total > 0 {
439            p.complete as f64 / p.total as f64 * 100.0
440        } else {
441            0.0
442        };
443        table_lines.push(format!(
444            "| {name} | {pct:.1}% | {} | {} |",
445            p.complete, p.total
446        ));
447    }
448
449    CardBuilder::new()
450        .header(
451            &t("task_set_progress"),
452            Some(overall_status),
453            Some(ColorTheme::Blue),
454            None,
455        )
456        .collapsible(&t("task_sets"), &table_lines.join("\n"), true)
457        .build()
458}
459
460pub fn result_collection_start(
461    task_set_names: &[&str],
462    job_title: Option<&str>,
463    group: Option<&str>,
464    msg: Option<&str>,
465    language: LanguageCode,
466) -> GenericCardTemplate {
467    let t = |k: &str| get_translation(k, language);
468    let resolved_title = job_title.map(|s| s.to_owned()).unwrap_or_else(|| {
469        if task_set_names.len() == 1 {
470            task_set_names[0].to_owned()
471        } else {
472            format!("Collection of {} Task Sets", task_set_names.len())
473        }
474    });
475    let mut lines = vec![
476        format!("**{}:** {}", t("task_name"), resolved_title),
477        format!("**{}:** {}", t("task_set_count"), task_set_names.len()),
478    ];
479    if let Some(g) = group {
480        lines.push(format!("**{}:** {g}", t("group")));
481    }
482
483    let status_text = t("running");
484    let mut builder = CardBuilder::new()
485        .header(
486            &t("result_collection_started"),
487            Some(&status_text),
488            Some(ColorTheme::Wathet),
489            None,
490        )
491        .markdown(&lines.join("\n"), TextAlign::Left, TextSize::Normal);
492
493    if let Some(m) = msg {
494        builder = builder.collapsible(&t("running_overview"), m, false);
495    }
496    builder.build()
497}
498
499pub fn result_collection_complete(
500    task_set_names: &[&str],
501    job_title: Option<&str>,
502    group: Option<&str>,
503    prefix: Option<&str>,
504    msg: Option<&str>,
505    language: LanguageCode,
506) -> GenericCardTemplate {
507    let t = |k: &str| get_translation(k, language);
508    let resolved_title = job_title.map(|s| s.to_owned()).unwrap_or_else(|| {
509        if task_set_names.len() == 1 {
510            task_set_names[0].to_owned()
511        } else {
512            format!("Collection of {} Task Sets", task_set_names.len())
513        }
514    });
515    let mut lines = vec![
516        format!("**{}:** {}", t("task_name"), resolved_title),
517        format!("**{}:** {}", t("task_set_count"), task_set_names.len()),
518    ];
519    if let Some(g) = group {
520        lines.push(format!("**{}:** {g}", t("group")));
521    }
522
523    let status_text = t("success");
524    let mut builder = CardBuilder::new()
525        .header(
526            &t("result_collection_complete"),
527            Some(&status_text),
528            Some(ColorTheme::Green),
529            None,
530        )
531        .markdown(&lines.join("\n"), TextAlign::Left, TextSize::Normal);
532
533    if group.is_some() || prefix.is_some() {
534        builder = builder
535            .columns()
536            .column(&t("group"), Some(group.unwrap_or("*unknown*")), "auto", 1)
537            .column(
538                &t("storage_prefix"),
539                Some(prefix.unwrap_or("*N/A*")),
540                "weighted",
541                1,
542            )
543            .end_columns();
544    }
545    if let Some(m) = msg {
546        builder = builder.collapsible(&t("state_overview"), m, false);
547    }
548    builder.build()
549}
550
551pub fn comparison_complete(
552    comparison_name: &str,
553    task_set_count: u32,
554    result_rows: u32,
555    result_columns: u32,
556    comparison_table: Option<&str>,
557    language: LanguageCode,
558) -> GenericCardTemplate {
559    let t = |k: &str| get_translation(k, language);
560    let metadata_text = format!(
561        "**{}:** {}\n**{}:** {}",
562        t("comparison_name"),
563        comparison_name,
564        t("task_sets_compared"),
565        task_set_count,
566    );
567
568    let status_text = t("success");
569    let mut builder = CardBuilder::new()
570        .header(
571            &t("comparison_complete"),
572            Some(&status_text),
573            Some(ColorTheme::Orange),
574            None,
575        )
576        .markdown(&metadata_text, TextAlign::Left, TextSize::Normal)
577        .columns()
578        .column(
579            &t("result_rows"),
580            Some(&result_rows.to_string()),
581            "weighted",
582            1,
583        )
584        .column(
585            &t("result_columns"),
586            Some(&result_columns.to_string()),
587            "weighted",
588            1,
589        )
590        .end_columns();
591
592    if let Some(tbl) = comparison_table {
593        builder = builder.collapsible(&t("comparison_results"), tbl, false);
594    }
595    builder.build()
596}
597
598pub fn create_custom_template(language: LanguageCode) -> CardBuilder {
599    CardBuilder::new().language(language)
600}
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605    use crate::templates::{LanguageCode, LarkTemplate};
606    use std::collections::HashMap;
607
608    fn zh() -> LanguageCode {
609        LanguageCode::Zh
610    }
611
612    #[test]
613    fn test_network_submission_start() {
614        let t = network_submission_start("netset1", "TCP", None, None, None, zh());
615        let card = t.generate();
616        assert_eq!(card["schema"], "2.0");
617        assert_eq!(card["header"]["template"], "wathet");
618        let el0 = card["body"]["elements"][0]["content"].as_str().unwrap();
619        assert!(el0.contains("netset1"));
620        assert!(el0.contains("TCP"));
621    }
622
623    #[test]
624    fn test_network_submission_complete_green() {
625        let t = network_submission_complete("ns", Some(10), None, None, None, None, zh());
626        let card = t.generate();
627        assert_eq!(card["schema"], "2.0");
628        assert_eq!(card["header"]["template"], "green");
629        let el0 = card["body"]["elements"][0]["content"].as_str().unwrap();
630        assert!(el0.contains("ns"));
631    }
632
633    #[test]
634    fn test_network_submission_failure_red() {
635        let t = network_submission_failure("ns", "timeout", None, None, zh());
636        let card = t.generate();
637        assert_eq!(card["schema"], "2.0");
638        assert_eq!(card["header"]["template"], "red");
639        let elements = card["body"]["elements"].as_array().unwrap();
640        assert!(elements.len() >= 2); // markdown + collapsible
641    }
642
643    #[test]
644    fn test_config_upload_complete() {
645        let t = config_upload_complete("cfg1", 5, None, None, zh());
646        let card = t.generate();
647        assert_eq!(card["schema"], "2.0");
648        assert_eq!(card["header"]["template"], "green");
649        let el0 = card["body"]["elements"][0]["content"].as_str().unwrap();
650        assert!(el0.contains("cfg1"));
651    }
652
653    #[test]
654    fn test_job_submission_start() {
655        let t = job_submission_start("job1", None, None, None, None, None, zh());
656        let card = t.generate();
657        assert_eq!(card["schema"], "2.0");
658        assert_eq!(card["header"]["template"], "wathet");
659        let el0 = card["body"]["elements"][0]["content"].as_str().unwrap();
660        assert!(el0.contains("job1"));
661    }
662
663    #[test]
664    fn test_job_submission_complete() {
665        let t = job_submission_complete("job1", 42, None, None, None, None, None, None, zh());
666        let card = t.generate();
667        assert_eq!(card["schema"], "2.0");
668        assert_eq!(card["header"]["template"], "wathet");
669        let el0 = card["body"]["elements"][0]["content"].as_str().unwrap();
670        assert!(el0.contains("job1"));
671        assert!(el0.contains("42"));
672    }
673
674    #[test]
675    fn test_job_submission_failure_red() {
676        let t = job_submission_failure("job1", "oom", None, None, zh());
677        let card = t.generate();
678        assert_eq!(card["schema"], "2.0");
679        assert_eq!(card["header"]["template"], "red");
680        let elements = card["body"]["elements"].as_array().unwrap();
681        assert!(elements.len() >= 2);
682    }
683
684    #[test]
685    fn test_job_complete_success() {
686        let t = job_complete("job1", true, 0, None, None, None, None, None, None, zh());
687        let card = t.generate();
688        assert_eq!(card["schema"], "2.0");
689        assert_eq!(card["header"]["template"], "green");
690        let el0 = card["body"]["elements"][0]["content"].as_str().unwrap();
691        assert!(el0.contains("job1"));
692    }
693
694    #[test]
695    fn test_job_complete_failure() {
696        let t = job_complete("job1", false, 1, None, None, None, None, None, None, zh());
697        let card = t.generate();
698        assert_eq!(card["schema"], "2.0");
699        assert_eq!(card["header"]["template"], "red");
700        let el0 = card["body"]["elements"][0]["content"].as_str().unwrap();
701        assert!(el0.contains("1")); // status code in body
702    }
703
704    #[test]
705    fn test_task_set_progress() {
706        let mut progress = HashMap::new();
707        progress.insert(
708            "set_a",
709            TaskSetProgress {
710                complete: 5,
711                total: 10,
712            },
713        );
714        let t = task_set_progress(&progress, "running", zh());
715        let card = t.generate();
716        assert_eq!(card["schema"], "2.0");
717        assert_eq!(card["header"]["template"], "blue");
718        let elements = card["body"]["elements"].as_array().unwrap();
719        assert!(!elements.is_empty());
720    }
721
722    #[test]
723    fn test_result_collection_start() {
724        let t = result_collection_start(&["set_a", "set_b"], None, None, None, zh());
725        let card = t.generate();
726        assert_eq!(card["schema"], "2.0");
727        assert_eq!(card["header"]["template"], "wathet");
728        let el0 = card["body"]["elements"][0]["content"].as_str().unwrap();
729        assert!(el0.contains("2")); // task_set_count
730    }
731
732    #[test]
733    fn test_result_collection_complete() {
734        let t = result_collection_complete(&["set_a"], Some("my_job"), None, None, None, zh());
735        let card = t.generate();
736        assert_eq!(card["schema"], "2.0");
737        assert_eq!(card["header"]["template"], "green");
738        let el0 = card["body"]["elements"][0]["content"].as_str().unwrap();
739        assert!(el0.contains("my_job"));
740    }
741
742    #[test]
743    fn test_comparison_complete_orange() {
744        let t = comparison_complete("cmp1", 3, 100, 5, None, zh());
745        let card = t.generate();
746        assert_eq!(card["schema"], "2.0");
747        assert_eq!(card["header"]["template"], "orange");
748        let el0 = card["body"]["elements"][0]["content"].as_str().unwrap();
749        assert!(el0.contains("cmp1"));
750    }
751
752    #[test]
753    fn test_task_set_progress_fields() {
754        let p = TaskSetProgress {
755            complete: 3,
756            total: 7,
757        };
758        assert_eq!(p.complete, 3);
759        assert_eq!(p.total, 7);
760    }
761
762    #[test]
763    fn test_create_custom_template() {
764        let t = create_custom_template(zh())
765            .header("Custom", Some("info"), None, None)
766            .markdown("content", TextAlign::Left, TextSize::Normal)
767            .build();
768        let card = t.generate();
769        assert_eq!(card["header"]["title"]["content"], "Custom");
770    }
771}