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