1use 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
25const MIN_WIDTH: u16 = 60;
27const MIN_HEIGHT: u16 = 12;
28const OVERVIEW_MIN_HEIGHT: u16 = 18;
30const DETAILS_MIN_WIDTH: u16 = 84;
32
33const VERSION: &str = env!("CARGO_PKG_VERSION");
35
36pub 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), Constraint::Length(overview_h),
52 Constraint::Min(1), Constraint::Length(2), ])
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 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
102fn 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), Constraint::Length(1), Constraint::Length(1), ])
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
128fn 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
153fn 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
177fn 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
209fn 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
244fn 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 let inner_width = area.width.saturating_sub(2) as usize;
269 let ctx_width = 14usize;
270 let fixed = 2 + 5 + 2 + 2 + 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
308fn 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 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 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 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 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 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
375fn 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
385fn 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
393fn 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
411fn 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
445fn 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
509fn 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
567fn 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
580fn 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 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 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 render_at(70, 30, &mut app());
681 }
682
683 #[test]
684 fn rendu_terminal_court_masque_synthese() {
685 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}