Skip to main content

mnemo/tui/
ui.rs

1//! Rendu Ratatui de la TUI, style « ops dashboard ».
2//!
3//! Disposition verticale :
4//! - **barre de commande** (haut) : badges identité (mnemo + version + projet +
5//!   branche + total), barre de recherche, puces de filtres actifs ;
6//! - **synthèse / KPI** : total, visibles, succès, échecs, taux d'échec,
7//!   projets, shell dominant (masquée sur terminal court) ;
8//! - **corps** : liste des commandes (gauche) et panneau de détails sectionné
9//!   (droite, masqué sur terminal étroit) ;
10//! - **pied** : raccourcis essentiels et message de statut.
11//!
12//! Toutes les couleurs passent par [`crate::tui::theme`] ; le formatage des
13//! chaînes par [`crate::tui::format`]. Le rendu est responsive et ne panique
14//! jamais, y compris en dimensions réduites.
15
16use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
17use ratatui::style::{Color, Modifier, Style};
18use ratatui::text::{Line, Span};
19use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
20use ratatui::Frame;
21
22use crate::tui::app::{Overview, TuiApp, TuiFilters, TuiMode};
23use crate::tui::{format, help, theme};
24
25/// Largeur/hauteur minimales en dessous desquelles on affiche un avertissement.
26const MIN_WIDTH: u16 = 60;
27const MIN_HEIGHT: u16 = 12;
28/// En dessous de cette hauteur, la barre de synthèse est masquée.
29const OVERVIEW_MIN_HEIGHT: u16 = 18;
30/// En dessous de cette largeur, le panneau de détails est masqué.
31const DETAILS_MIN_WIDTH: u16 = 84;
32
33/// Version compilée de mnemo, affichée comme badge d'identité.
34const VERSION: &str = env!("CARGO_PKG_VERSION");
35
36/// Point d'entrée du rendu d'une frame.
37pub fn render(f: &mut Frame, app: &mut TuiApp) {
38    let area = f.area();
39    if area.width < MIN_WIDTH || area.height < MIN_HEIGHT {
40        render_too_small(f, area);
41        return;
42    }
43
44    let show_overview = area.height >= OVERVIEW_MIN_HEIGHT;
45    let overview_h = if show_overview { 3 } else { 0 };
46
47    let chunks = Layout::default()
48        .direction(Direction::Vertical)
49        .constraints([
50            Constraint::Length(5), // barre de commande (badges + recherche + filtres)
51            Constraint::Length(overview_h),
52            Constraint::Min(1),    // corps
53            Constraint::Length(2), // pied
54        ])
55        .split(area);
56
57    render_command_bar(f, chunks[0], app);
58    if show_overview {
59        render_overview(f, chunks[1], &app.overview());
60    }
61
62    let show_details = area.width >= DETAILS_MIN_WIDTH;
63    let body = if show_details {
64        Layout::default()
65            .direction(Direction::Horizontal)
66            .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
67            .split(chunks[2])
68    } else {
69        Layout::default()
70            .direction(Direction::Horizontal)
71            .constraints([Constraint::Percentage(100)])
72            .split(chunks[2])
73    };
74
75    // La hauteur visible de la liste sert de taille de page.
76    app.page_size = body[0].height.saturating_sub(2).max(1) as usize;
77
78    render_list(f, body[0], app);
79    if show_details {
80        render_details(f, body[1], app);
81    }
82    render_footer(f, chunks[3], app);
83
84    match app.mode {
85        TuiMode::Help => render_help_popup(f, area),
86        TuiMode::Filters => render_filters_popup(f, area, app),
87        TuiMode::ConfirmDelete => render_confirm_popup(f, area, app),
88        _ => {}
89    }
90}
91
92fn render_too_small(f: &mut Frame, area: Rect) {
93    let msg = Paragraph::new(format!(
94        "Terminal trop petit ({}x{}).\nAgrandissez la fenêtre (min {MIN_WIDTH}x{MIN_HEIGHT}).",
95        area.width, area.height
96    ))
97    .alignment(Alignment::Center)
98    .style(Style::default().fg(theme::WARNING));
99    f.render_widget(msg, area);
100}
101
102// -- Barre de commande -----------------------------------------------------
103
104/// Badge « clé valeur » avec une couleur d'accent sur la valeur.
105fn badge(label: &str, value: &str, color: Color) -> Vec<Span<'static>> {
106    vec![
107        Span::styled(format!("{label} "), theme::label()),
108        Span::styled(value.to_string(), theme::badge(color)),
109    ]
110}
111
112fn render_command_bar(f: &mut Frame, area: Rect, app: &TuiApp) {
113    let inner = block_inner(f, area, " mnemo ");
114    let rows = Layout::default()
115        .direction(Direction::Vertical)
116        .constraints([
117            Constraint::Length(1), // badges
118            Constraint::Length(1), // recherche
119            Constraint::Length(1), // filtres
120        ])
121        .split(inner);
122
123    f.render_widget(Paragraph::new(badges_line(app)), rows[0]);
124    f.render_widget(Paragraph::new(search_line(app)), rows[1]);
125    f.render_widget(Paragraph::new(filters_line(&app.filters)), rows[2]);
126}
127
128/// Ligne d'identité : mnemo + version + projet + branche + total.
129fn badges_line(app: &TuiApp) -> Line<'static> {
130    let mut spans = vec![
131        Span::styled("mnemo ", theme::title()),
132        Span::styled(format!("v{VERSION}"), theme::badge(theme::INFO)),
133    ];
134    let sep = || Span::styled("  ·  ", theme::label());
135
136    if let Some(project) = &app.current_project {
137        spans.push(sep());
138        spans.extend(badge("projet", project, theme::INFO));
139    }
140    if let Some(branch) = &app.current_branch {
141        spans.push(sep());
142        spans.extend(badge("branche", branch, theme::ACCENT));
143    }
144    spans.push(sep());
145    spans.extend(badge(
146        "total",
147        &app.records.len().to_string(),
148        theme::WARNING,
149    ));
150    Line::from(spans)
151}
152
153/// Ligne de recherche avec curseur en mode Search.
154fn search_line(app: &TuiApp) -> Line<'static> {
155    let (text, style) = if app.query.is_empty() {
156        (
157            "(tapez pour filtrer)".to_string(),
158            Style::default().fg(theme::MUTED),
159        )
160    } else {
161        (app.query.clone(), theme::strong())
162    };
163    Line::from(vec![
164        Span::styled("Recherche ", theme::label()),
165        Span::styled(text, style),
166        Span::styled(
167            if app.mode == TuiMode::Search {
168                "▏"
169            } else {
170                ""
171            },
172            Style::default().fg(theme::WARNING),
173        ),
174    ])
175}
176
177/// Ligne de puces décrivant les filtres actifs.
178fn filters_line(filters: &TuiFilters) -> Line<'static> {
179    let mut spans = vec![Span::styled("Filtres ", theme::label())];
180    if filters.is_empty() {
181        spans.push(Span::styled("[aucun filtre]", theme::label()));
182        return Line::from(spans);
183    }
184    let mut chip = |label: &str, value: String, color: Color| {
185        spans.push(Span::styled(format!("[{label}: "), theme::label()));
186        spans.push(Span::styled(value, theme::badge(color)));
187        spans.push(Span::styled("] ", theme::label()));
188    };
189    if let Some(p) = &filters.project {
190        chip("projet", p.clone(), theme::INFO);
191    }
192    if let Some(b) = &filters.branch {
193        chip("branche", b.clone(), theme::ACCENT);
194    }
195    if let Some(c) = &filters.cwd {
196        chip("dossier", format::truncate_middle(c, 28), theme::INFO);
197    }
198    if filters.status != crate::tui::app::StatusFilter::All {
199        let color = match filters.status {
200            crate::tui::app::StatusFilter::Success => theme::SUCCESS,
201            crate::tui::app::StatusFilter::Failure => theme::DANGER,
202            crate::tui::app::StatusFilter::All => theme::MUTED,
203        };
204        chip("statut", filters.status.label().to_string(), color);
205    }
206    Line::from(spans)
207}
208
209// -- Synthèse / KPI --------------------------------------------------------
210
211fn render_overview(f: &mut Frame, area: Rect, ov: &Overview) {
212    let inner = block_inner(f, area, " Synthèse ");
213
214    let rate = ov.failure_rate();
215    let rate_color = if rate >= 25.0 {
216        theme::DANGER
217    } else if rate >= 10.0 {
218        theme::WARNING
219    } else {
220        theme::SUCCESS
221    };
222
223    let mut spans = vec![
224        Span::styled("Total ", theme::label()),
225        Span::styled(ov.total.to_string(), theme::strong()),
226        Span::styled("   Visibles ", theme::label()),
227        Span::styled(ov.visible.to_string(), theme::strong()),
228        Span::styled("   Succès ", theme::label()),
229        Span::styled(ov.success.to_string(), theme::badge(theme::SUCCESS)),
230        Span::styled("   Échecs ", theme::label()),
231        Span::styled(ov.failed.to_string(), theme::badge(theme::DANGER)),
232        Span::styled("   Taux d'échec ", theme::label()),
233        Span::styled(format!("{rate:.1}%"), theme::badge(rate_color)),
234        Span::styled("   Projets ", theme::label()),
235        Span::styled(ov.projects.to_string(), theme::badge(theme::INFO)),
236    ];
237    if let Some(shell) = &ov.top_shell {
238        spans.push(Span::styled("   Shell ", theme::label()));
239        spans.push(Span::styled(shell.clone(), theme::badge(theme::ACCENT)));
240    }
241    f.render_widget(Paragraph::new(Line::from(spans)), inner);
242}
243
244// -- Liste -----------------------------------------------------------------
245
246fn render_list(f: &mut Frame, area: Rect, app: &TuiApp) {
247    let title = format!(" Commandes ({}) ", app.filtered.len());
248    let block = Block::default()
249        .borders(Borders::ALL)
250        .border_style(theme::border())
251        .title(Span::styled(title, theme::title()));
252
253    if app.filtered.is_empty() {
254        let hint = if app.records.is_empty() {
255            "Aucune commande en base.\nImportez votre historique : mnemo import."
256        } else {
257            "Aucun résultat.\nAjustez la recherche ou videz les filtres (Ctrl+L)."
258        };
259        let p = Paragraph::new(hint)
260            .block(block)
261            .alignment(Alignment::Center)
262            .style(theme::label());
263        f.render_widget(p, area);
264        return;
265    }
266
267    // Largeur disponible pour la commande, après colonnes heure/statut/contexte.
268    let inner_width = area.width.saturating_sub(2) as usize;
269    let ctx_width = 14usize;
270    let fixed = 2 /* symbole > */ + 5 /* heure */ + 2 + 2 /* statut */ + ctx_width + 1;
271    let cmd_width = inner_width.saturating_sub(fixed).max(8);
272
273    let items: Vec<ListItem> = app
274        .filtered
275        .iter()
276        .map(|&idx| {
277            let r = &app.records[idx];
278            let time = format::short_time(&r.created_at);
279            let status = Span::styled(
280                format!("{} ", format::status_symbol(r.exit_code)),
281                Style::default().fg(theme::status_color(r.exit_code)),
282            );
283            let ctx = format!(
284                "{:<width$}",
285                format::truncate_end(&format::context_label(r), ctx_width),
286                width = ctx_width
287            );
288            ListItem::new(Line::from(vec![
289                Span::styled(format!("{time}  "), Style::default().fg(theme::WARNING)),
290                status,
291                Span::styled(ctx, Style::default().fg(theme::INFO)),
292                Span::raw(" "),
293                Span::styled(format::truncate_end(&r.command, cmd_width), theme::value()),
294            ]))
295        })
296        .collect();
297
298    let list = List::new(items)
299        .block(block)
300        .highlight_style(theme::selected())
301        .highlight_symbol("> ");
302
303    let mut state = ListState::default();
304    state.select(Some(app.selected));
305    f.render_stateful_widget(list, area, &mut state);
306}
307
308// -- Détails ---------------------------------------------------------------
309
310fn render_details(f: &mut Frame, area: Rect, app: &TuiApp) {
311    let block = Block::default()
312        .borders(Borders::ALL)
313        .border_style(theme::border())
314        .title(Span::styled(" Détails ", theme::title()));
315    let Some(r) = app.selected_record() else {
316        let p = Paragraph::new("Sélectionnez une commande pour voir ses détails.")
317            .block(block)
318            .alignment(Alignment::Center)
319            .style(theme::label());
320        f.render_widget(p, area);
321        return;
322    };
323
324    let mut lines: Vec<Line> = Vec::new();
325    let opt = |v: &Option<String>| v.clone().unwrap_or_else(|| "-".to_string());
326
327    // Section COMMAND : commande + badge de statut.
328    lines.push(section("COMMAND"));
329    lines.push(Line::from(Span::styled(r.command.clone(), theme::strong())));
330    lines.push(Line::from(vec![
331        Span::styled("statut     ", theme::label()),
332        Span::styled(
333            format::status_text(r.exit_code),
334            theme::badge(theme::status_color(r.exit_code)),
335        ),
336    ]));
337    lines.push(Line::from(""));
338
339    // Section CONTEXT.
340    lines.push(section("CONTEXT"));
341    lines.push(field("cwd", opt(&r.cwd)));
342    lines.push(field("hostname", opt(&r.hostname)));
343    lines.push(field("shell", opt(&r.shell)));
344    lines.push(Line::from(""));
345
346    // Section EXECUTION.
347    lines.push(section("EXECUTION"));
348    lines.push(field(
349        "exit_code",
350        r.exit_code
351            .map(|c| c.to_string())
352            .unwrap_or("-".to_string()),
353    ));
354    lines.push(field("created_at", r.created_at.clone()));
355    lines.push(Line::from(""));
356
357    // Section GIT.
358    lines.push(section("GIT"));
359    lines.push(field("root", opt(&r.git_root)));
360    lines.push(field("branch", opt(&r.git_branch)));
361    lines.push(field("remote", opt(&r.git_remote)));
362    lines.push(Line::from(""));
363
364    // Section METADATA.
365    lines.push(section("METADATA"));
366    lines.push(field("id", r.id.to_string()));
367    lines.push(field("session", opt(&r.session_id)));
368
369    let p = Paragraph::new(lines)
370        .block(block)
371        .wrap(Wrap { trim: false });
372    f.render_widget(p, area);
373}
374
375/// Titre de section dans le panneau de détails.
376fn section(title: &str) -> Line<'static> {
377    Line::from(Span::styled(
378        title.to_string(),
379        Style::default()
380            .fg(theme::ACCENT)
381            .add_modifier(Modifier::BOLD),
382    ))
383}
384
385/// Champ « libellé valeur » avec libellé atténué.
386fn field(label: &str, value: String) -> Line<'static> {
387    Line::from(vec![
388        Span::styled(format!("{label:<11}"), theme::label()),
389        Span::styled(value, theme::value()),
390    ])
391}
392
393// -- Pied ------------------------------------------------------------------
394
395fn render_footer(f: &mut Frame, area: Rect, app: &TuiApp) {
396    let chunks = Layout::default()
397        .direction(Direction::Vertical)
398        .constraints([Constraint::Length(1), Constraint::Length(1)])
399        .split(area);
400
401    f.render_widget(Paragraph::new(footer_hints(app.mode)), chunks[0]);
402
403    if let Some(msg) = &app.status_message {
404        f.render_widget(
405            Paragraph::new(msg.clone()).style(Style::default().fg(theme::WARNING)),
406            chunks[1],
407        );
408    }
409}
410
411/// Construit la ligne de raccourcis du pied selon le mode.
412fn footer_hints(mode: TuiMode) -> Line<'static> {
413    let pairs: &[(&str, &str)] = match mode {
414        TuiMode::Details => &[
415            ("Enter", "sélection"),
416            ("/", "recherche"),
417            ("j/k", "naviguer"),
418            ("F", "filtres"),
419            ("y", "copier"),
420            ("e", "export"),
421            ("x", "suppr"),
422            ("?", "aide"),
423            ("Esc", "quitter"),
424        ],
425        _ => &[
426            ("Enter", "sélection"),
427            ("Tab", "détails"),
428            ("Ctrl+P/B/D", "filtrer"),
429            ("Ctrl+L", "clear"),
430            ("F1", "aide"),
431            ("Esc", "quitter"),
432        ],
433    };
434    let mut spans: Vec<Span<'static>> = Vec::new();
435    for (i, (key, desc)) in pairs.iter().enumerate() {
436        if i > 0 {
437            spans.push(Span::styled("  ", theme::label()));
438        }
439        spans.push(Span::styled(format!("[{key}]"), theme::badge(theme::INFO)));
440        spans.push(Span::styled(format!(" {desc}"), theme::label()));
441    }
442    Line::from(spans)
443}
444
445// -- Overlays --------------------------------------------------------------
446
447fn render_help_popup(f: &mut Frame, area: Rect) {
448    let popup = centered_rect(70, 80, area);
449    f.render_widget(Clear, popup);
450
451    let lines: Vec<Line> = help::shortcuts()
452        .into_iter()
453        .map(|(key, desc)| {
454            if desc.is_empty() {
455                Line::from(Span::styled(
456                    key,
457                    Style::default()
458                        .fg(theme::ACCENT)
459                        .add_modifier(Modifier::BOLD),
460                ))
461            } else {
462                Line::from(vec![
463                    Span::styled(format!("  {key:<20}"), theme::badge(theme::INFO)),
464                    Span::styled(desc, theme::value()),
465                ])
466            }
467        })
468        .collect();
469
470    let p = Paragraph::new(lines)
471        .block(
472            Block::default()
473                .borders(Borders::ALL)
474                .border_style(theme::border())
475                .title(Span::styled(
476                    " Aide - raccourcis (Esc pour fermer) ",
477                    theme::title(),
478                )),
479        )
480        .wrap(Wrap { trim: false });
481    f.render_widget(p, popup);
482}
483
484fn render_filters_popup(f: &mut Frame, area: Rect, app: &TuiApp) {
485    let popup = centered_rect(60, 50, area);
486    f.render_widget(Clear, popup);
487
488    let lines = vec![
489        filters_line(&app.filters),
490        Line::from(""),
491        Line::from(Span::styled("Depuis la sélection :", theme::label())),
492        field_help("p", "filtrer par projet"),
493        field_help("b", "filtrer par branche"),
494        field_help("w", "filtrer par dossier (cwd)"),
495        field_help("s", "statut : tous / succès / échecs"),
496        field_help("c", "effacer tous les filtres"),
497        Line::from(""),
498        Line::from(Span::styled("Esc / f : fermer ce panneau", theme::label())),
499    ];
500    let p = Paragraph::new(lines).block(
501        Block::default()
502            .borders(Borders::ALL)
503            .border_style(theme::border())
504            .title(Span::styled(" Filtres interactifs ", theme::title())),
505    );
506    f.render_widget(p, popup);
507}
508
509/// Ligne d'aide « touche  description » pour le panneau de filtres.
510fn field_help(key: &str, desc: &str) -> Line<'static> {
511    Line::from(vec![
512        Span::styled(format!("  {key:<3}"), theme::badge(theme::INFO)),
513        Span::styled(desc.to_string(), theme::value()),
514    ])
515}
516
517fn render_confirm_popup(f: &mut Frame, area: Rect, app: &TuiApp) {
518    let popup = centered_rect(60, 30, area);
519    f.render_widget(Clear, popup);
520
521    let id = app
522        .selected_id()
523        .map(|i| i.to_string())
524        .unwrap_or_else(|| "?".to_string());
525    let cmd = app
526        .selected_record()
527        .map(|r| r.command.clone())
528        .unwrap_or_default();
529
530    let lines = vec![
531        Line::from(Span::styled(
532            format!("Supprimer la commande #{id} ?"),
533            Style::default()
534                .fg(theme::DANGER)
535                .add_modifier(Modifier::BOLD),
536        )),
537        Line::from(""),
538        Line::from(Span::styled(cmd, theme::strong())),
539        Line::from(""),
540        Line::from(Span::styled(
541            "Une sauvegarde est créée avant suppression.",
542            theme::label(),
543        )),
544        Line::from(vec![
545            Span::styled("[y]", theme::badge(theme::SUCCESS)),
546            Span::styled(" confirmer   ", theme::label()),
547            Span::styled("[n / Esc]", theme::badge(theme::WARNING)),
548            Span::styled(" annuler", theme::label()),
549        ]),
550    ];
551    let p = Paragraph::new(lines)
552        .block(
553            Block::default()
554                .borders(Borders::ALL)
555                .border_style(Style::default().fg(theme::DANGER))
556                .title(Span::styled(
557                    " Confirmation ",
558                    Style::default()
559                        .fg(theme::DANGER)
560                        .add_modifier(Modifier::BOLD),
561                )),
562        )
563        .wrap(Wrap { trim: false });
564    f.render_widget(p, popup);
565}
566
567// -- Utilitaires de layout -------------------------------------------------
568
569/// Rend un bloc bordé titré et renvoie son aire intérieure.
570fn block_inner(f: &mut Frame, area: Rect, title: &str) -> Rect {
571    let block = Block::default()
572        .borders(Borders::ALL)
573        .border_style(theme::border())
574        .title(Span::styled(title.to_string(), theme::title()));
575    let inner = block.inner(area);
576    f.render_widget(block, area);
577    inner
578}
579
580/// Calcule un rectangle centré occupant `percent_x` × `percent_y` de `area`.
581fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
582    let vertical = Layout::default()
583        .direction(Direction::Vertical)
584        .constraints([
585            Constraint::Percentage((100 - percent_y) / 2),
586            Constraint::Percentage(percent_y),
587            Constraint::Percentage((100 - percent_y) / 2),
588        ])
589        .split(area);
590    Layout::default()
591        .direction(Direction::Horizontal)
592        .constraints([
593            Constraint::Percentage((100 - percent_x) / 2),
594            Constraint::Percentage(percent_x),
595            Constraint::Percentage((100 - percent_x) / 2),
596        ])
597        .split(vertical[1])[1]
598}
599
600#[cfg(test)]
601mod tests {
602    use super::*;
603    use crate::db::CommandRecord;
604    use crate::tui::app::{StatusFilter, TuiFilters};
605    use ratatui::backend::TestBackend;
606    use ratatui::Terminal;
607
608    fn rec(id: i64, command: &str, exit: Option<i64>) -> CommandRecord {
609        CommandRecord {
610            id,
611            command: command.to_string(),
612            cwd: Some("/home/killian/mnemo".to_string()),
613            shell: Some("bash".to_string()),
614            hostname: Some("host".to_string()),
615            exit_code: exit,
616            created_at: "2026-06-14 17:29:11".to_string(),
617            git_root: Some("/home/killian/mnemo".to_string()),
618            git_branch: Some("main".to_string()),
619            git_remote: None,
620            session_id: Some("abcd".to_string()),
621        }
622    }
623
624    fn sample() -> Vec<CommandRecord> {
625        vec![
626            rec(1, "cargo build", Some(0)),
627            rec(2, "cargo test", Some(1)),
628            rec(3, &"git commit -m ".repeat(40), Some(0)),
629        ]
630    }
631
632    fn app() -> TuiApp {
633        let mut a = TuiApp::new(sample(), TuiFilters::default(), String::new());
634        a.set_current_context(Some("mnemo".to_string()), Some("main".to_string()));
635        a
636    }
637
638    /// Rend l'app dans un backend de test de dimensions données : ne doit pas
639    /// paniquer et doit produire une frame.
640    fn render_at(width: u16, height: u16, app: &mut TuiApp) {
641        let backend = TestBackend::new(width, height);
642        let mut terminal = Terminal::new(backend).unwrap();
643        terminal.draw(|f| render(f, app)).unwrap();
644    }
645
646    /// Rend l'app et renvoie le texte concaténé du buffer (pour vérifier la
647    /// présence de libellés).
648    fn render_text(width: u16, height: u16, app: &mut TuiApp) -> String {
649        let backend = TestBackend::new(width, height);
650        let mut terminal = Terminal::new(backend).unwrap();
651        terminal.draw(|f| render(f, app)).unwrap();
652        terminal
653            .backend()
654            .buffer()
655            .content
656            .iter()
657            .map(|c| c.symbol())
658            .collect()
659    }
660
661    #[test]
662    fn rendu_dimensions_standard_sans_panique() {
663        render_at(120, 40, &mut app());
664    }
665
666    #[test]
667    fn rendu_synthese_affiche_les_libelles_kpi() {
668        let text = render_text(120, 40, &mut app());
669        assert!(text.contains("Synthèse"));
670        assert!(text.contains("Total"));
671        assert!(text.contains("Visibles"));
672        assert!(text.contains("Succès"));
673        assert!(text.contains("Échecs"));
674        assert!(text.contains("Taux d'échec"));
675    }
676
677    #[test]
678    fn rendu_terminal_etroit_masque_details() {
679        // Largeur < DETAILS_MIN_WIDTH : pas de panneau de détails, pas de panique.
680        render_at(70, 30, &mut app());
681    }
682
683    #[test]
684    fn rendu_terminal_court_masque_synthese() {
685        // Hauteur < OVERVIEW_MIN_HEIGHT : pas de barre de synthèse.
686        render_at(120, 14, &mut app());
687    }
688
689    #[test]
690    fn rendu_dimensions_minimales_sans_panique() {
691        render_at(MIN_WIDTH, MIN_HEIGHT, &mut app());
692    }
693
694    #[test]
695    fn rendu_trop_petit_affiche_avertissement() {
696        render_at(40, 8, &mut app());
697    }
698
699    #[test]
700    fn rendu_liste_vide_sans_panique() {
701        let mut a = TuiApp::new(Vec::new(), TuiFilters::default(), String::new());
702        render_at(120, 40, &mut a);
703    }
704
705    #[test]
706    fn rendu_commande_tres_longue_sans_panique() {
707        let mut a = app();
708        a.push_query_char('g');
709        a.push_query_char('i');
710        a.push_query_char('t');
711        render_at(100, 30, &mut a);
712    }
713
714    #[test]
715    fn rendu_tous_les_overlays_sans_panique() {
716        for mode in [TuiMode::Help, TuiMode::Filters, TuiMode::ConfirmDelete] {
717            let mut a = app();
718            a.mode = mode;
719            render_at(120, 40, &mut a);
720        }
721    }
722
723    #[test]
724    fn ligne_filtres_aucun() {
725        let line = filters_line(&TuiFilters::default());
726        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
727        assert!(text.contains("[aucun filtre]"));
728    }
729
730    #[test]
731    fn ligne_filtres_affiche_les_puces_actives() {
732        let filters = TuiFilters {
733            project: Some("mnemo".to_string()),
734            branch: Some("main".to_string()),
735            cwd: None,
736            status: StatusFilter::Failure,
737        };
738        let line = filters_line(&filters);
739        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
740        assert!(text.contains("projet"));
741        assert!(text.contains("mnemo"));
742        assert!(text.contains("branche"));
743        assert!(text.contains("main"));
744        assert!(text.contains("statut"));
745        assert!(text.contains("échecs"));
746    }
747
748    #[test]
749    fn badges_contiennent_version_et_contexte() {
750        let line = badges_line(&app());
751        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
752        assert!(text.contains("mnemo"));
753        assert!(text.contains(&format!("v{VERSION}")));
754        assert!(text.contains("projet"));
755        assert!(text.contains("branche"));
756        assert!(text.contains("total"));
757    }
758
759    #[test]
760    fn pied_details_contient_les_actions_cles() {
761        let line = footer_hints(TuiMode::Details);
762        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
763        assert!(text.contains("Enter"));
764        assert!(text.contains("recherche"));
765        assert!(text.contains("export"));
766        assert!(text.contains("quitter"));
767    }
768}