use crossterm::event::{ Event, KeyCode as K, KeyEvent, KeyModifiers as M, MouseButton, MouseEvent, MouseEventKind, }; use ratatui::{backend::TestBackend, layout::Rect, Terminal}; use std::{ fs, path::PathBuf, sync::atomic::{AtomicUsize, Ordering}, }; use tb_ide::{ app::{App, Mode, WindowKind}, commands::Command, help::{layout, normalize, token_at, Catalog, Help, Page}, }; struct Temp(PathBuf); impl Temp { fn new() -> Self { static N: AtomicUsize = AtomicUsize::new(0); let p = std::env::temp_dir().join(format!( "tb-help-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed) )); fs::create_dir_all(&p).unwrap(); Self(p.canonicalize().unwrap()) } fn app(&self) -> App { App::new(&self.0, self.0.join("options"), (100, 30)).unwrap() } } impl Drop for Temp { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } fn key(a: &mut App, k: K, m: M) { a.handle(Event::Key(KeyEvent::new(k, m))); } fn draw(a: &mut App) -> String { let mut t = Terminal::new(TestBackend::new(a.size.0, a.size.1)).unwrap(); t.draw(|f| a.render(f)).unwrap(); t.backend() .buffer() .content .chunks(a.size.0 as usize) .map(|r| r.iter().map(|c| c.symbol()).collect::()) .collect::>() .join("\n") } fn menu(a: &mut App, c: Command) { let (m, i) = a .menus() .iter() .enumerate() .find_map(|(m, menu)| { menu.items .iter() .position(|i| i.command == Some(c)) .map(|i| (m, i)) }) .unwrap(); a.menu = Some((m, i)); key(a, K::Enter, M::NONE); assert_eq!(a.last_command, Some(c)); } #[test] fn catalog_links_and_missing_targets_are_checked() { let c = Catalog::embedded(); c.validate().unwrap(); for p in &c.pages { for h in &p.headings { let (_, b) = c.resolve("", &format!("{}#{}", p.path, h.anchor)).unwrap(); assert_eq!(b, h.block); } } let duplicates = Catalog::new(&[("a.md", "# Titel\n\n## Titel\n\n## Titel-1\n\n## Titel\n")]); for h in &duplicates.pages[0].headings { assert_eq!( duplicates .resolve("", &format!("a.md#{}", h.anchor)) .unwrap() .1, h.block ); } let bad = Catalog::new(&[("docs/a.md", "# A\n\n[broken](missing.md)")]); assert!(bad.validate().unwrap_err().to_string().contains("fehlt")); let bad = Catalog::new(&[("docs/a.md", "# A\n\n[broken](#missing)")]); assert!(bad.validate().is_err()); for target in [ "../../secret", "%2e%2e/%2e%2e/secret", "/etc/passwd", "file:///etc/passwd", "../missing.md", "hilfe.md#missing", "hilfe.md#%GG", "C:%5csecret", ] { assert!(c.resolve("docs/hilfe.md", target).is_err(), "{target}"); } assert!(c.resolve("docs/hilfe.md", "../PLAN.md").is_ok()); let t = Temp::new(); let mut a = t.app(); a.execute(Command::UsingHelp); let before = a.help.position.clone(); assert!(a .help .open("https://example.org/private") .unwrap_err() .to_string() .contains("Extern")); assert_eq!(a.help.position, before); } #[test] fn markdown_blocks_escapes_links_and_unicode_layout() { let p=Page::parse("sample","# Überschrift\n\nAbsatz mit **fett**, *kursiv*, \\*Escape\\* und [Link](#überschrift).\n\n1. Eins\n2. Zwei\n\n```basic\nPRINT \"界ä\"\n012345678901234567890123456789\n```\n\n| Spalte | Breit |\n|---|---|\n| Wert | 界界界界 |\n"); let text = p .blocks .iter() .map(|b| b.text()) .collect::>() .join("\n"); assert!(text.contains("*Escape*")); assert!(text.contains("◄Link►")); assert!(text.contains("1. Eins")); assert!(text.contains("2. Zwei")); assert!(p.blocks.iter().any(|b| b.pre && b.text().contains("PRINT"))); assert!(p.blocks.iter().any(|b| b.pre && b.text().contains("界界"))); assert_eq!(p.headings[0].anchor, "überschrift"); for width in [1, 7, 20, 80] { let rows = layout(&p, width); for (i, b) in p.blocks.iter().enumerate() { let reconstructed: String = rows .iter() .filter(|r| r.block == i) .flat_map(|r| b.glyphs[r.start..r.end].iter().map(|g| g.ch)) .collect(); assert_eq!(reconstructed, b.text()); } } } #[test] fn offline_help_child() { if std::env::var_os("TB_HELP_OFFLINE_CHILD").is_none() { return; } let base = std::env::current_dir().unwrap(); assert!(!base.join("docs").exists()); let mut a = App::new(&base, base.join("options"), (100, 30)).unwrap(); for command in [ Command::HelpIndex, Command::HelpContents, Command::Keyboard, Command::UsingHelp, Command::Tutorial, ] { a.execute(command); assert_eq!(a.active_window().unwrap().kind, WindowKind::Help); assert!(!draw(&mut a).is_empty()); } for path in [ "docs/sprachreferenz.md", "docs/bibliothek.md", "docs/forms-referenz.md", "docs/ide-bedienung.md", "docs/tutorial.md", ] { let (p, b) = Catalog::embedded().resolve("", path).unwrap(); a.help.visit(p, b, None); assert!(a.help.page().blocks.len() > 10); assert!(draw(&mut a).contains(&a.help.page().title)); } } #[test] fn help_works_from_a_directory_without_checkout() { let t = Temp::new(); let status = std::process::Command::new(std::env::current_exe().unwrap()) .current_dir(&t.0) .env("TB_HELP_OFFLINE_CHILD", "1") .args(["--exact", "offline_help_child", "--nocapture"]) .status() .unwrap(); assert!(status.success()); } #[test] fn context_functions_properties_commands_and_unknown_queries() { assert_eq!(normalize("lEfT$"), "LEFT"); assert_eq!(token_at("PRINT LEFT$(x$, 2)", 8), "LEFT$"); let t = Temp::new(); let mut a = t.app(); let id = a.active_document().unwrap(); a.project .replace_text(id, 0..0, "PRINT lEfT$(\"abc\", 2)") .unwrap(); let view = match a.active_window().unwrap().kind { WindowKind::Code(v) => v, _ => panic!(), }; a.project.view_mut(view).unwrap().cursor = 9; key(&mut a, K::F(1), M::NONE); assert_eq!(a.help.page().path, "docs/bibliothek.md"); assert!(a.help.page().blocks[a.help.position.block] .text() .contains("Strings")); key(&mut a, K::Esc, M::NONE); a.execute(Command::NewForm); assert_eq!(a.mode, Mode::Designer); let specs = tb_frontend::forms::properties(tb_frontend::forms::ObjectClass::Form); a.designer.property = specs.iter().position(|p| p.name == "CAPTION").unwrap(); a.properties = true; let previous = a.active; key(&mut a, K::F(2), M::NONE); key(&mut a, K::F(1), M::NONE); assert_eq!(a.help.page().path, "docs/forms-referenz.md"); assert!(a.help.page().blocks[a.help.position.block] .text() .contains("Caption")); assert!(draw(&mut a).contains("Caption")); key(&mut a, K::Esc, M::NONE); assert_eq!(a.active, previous); assert!(a.properties && a.value_focus); a.value_focus = false; a.properties = false; let (m, i) = a .menus() .iter() .enumerate() .find_map(|(m, menu)| { menu.items .iter() .position(|i| i.command == Some(Command::SaveProject)) .map(|i| (m, i)) }) .unwrap(); a.menu = Some((m, i)); key(&mut a, K::F(1), M::NONE); assert!(a.help.page().blocks[a.help.position.block] .text() .contains("Speichern")); a.help_context("unbekanntxyz", None); assert!(draw(&mut a).contains("unbekanntxyz")); assert!(draw(&mut a).contains("Kein passendes Thema")); a.help_context("PRINT", None); assert!(a.help.position.query.is_some()); assert!(a.help.page().links.len() > 2); } #[test] fn links_history_resize_scroll_and_focus_use_real_events() { let t = Temp::new(); let mut a = t.app(); let previous = a.active; menu(&mut a, Command::HelpContents); let help_id = a.active; key(&mut a, K::Tab, M::NONE); let first = a.help.position.link; key(&mut a, K::Tab, M::NONE); assert_ne!(a.help.position.link, first); key(&mut a, K::BackTab, M::SHIFT); assert_eq!(a.help.position.link, first); key(&mut a, K::Down, M::NONE); let before = a.help.position.clone(); key(&mut a, K::Enter, M::NONE); assert_ne!(a.help.position.page, before.page); key(&mut a, K::F(1), M::ALT); assert_eq!(a.help.position, before); let w = a.windows.iter_mut().find(|w| w.id == help_id).unwrap(); w.normal = Rect::new(2, 2, 28, 18); let small = draw(&mut a); assert!(small.contains("Contents")); let pos = a.help.position.clone(); a.handle(Event::Resize(120, 40)); a.windows .iter_mut() .find(|w| w.id == help_id) .unwrap() .normal .width = 85; draw(&mut a); assert_eq!(a.help.position, pos); let (p, b) = Catalog::embedded().resolve("", "docs/tutorial.md").unwrap(); a.help.visit(p, b, None); key(&mut a, K::PageDown, M::NONE); assert!(a.help.position.block > 0); key(&mut a, K::Home, M::NONE); assert_eq!(a.help.position.block, 0); key(&mut a, K::Esc, M::NONE); assert_eq!(a.active, previous); assert!(!a.windows.iter().any(|w| w.kind == WindowKind::Help)); } #[test] fn twenty_back_steps_end_of_contents_and_all_entries() { let mut h = Help::default(); let mut states = vec![]; for i in 0..25 { states.push(h.position.clone()); h.visit(i % 12, i, None); } assert_eq!(h.history.len(), 20); for expected in states.into_iter().rev().take(20) { h.back(); assert_eq!(h.position, expected); } let p = h.position.clone(); h.back(); assert_eq!(h.position, p); assert!(h.notice.contains("20")); let (last, _) = Catalog::embedded().resolve("", "PLAN.md").unwrap(); h.visit(last, 0, None); let p = h.position.clone(); h.next(); assert_eq!(h.position, p); assert!(h.notice.contains("Ende")); let t = Temp::new(); let mut a = t.app(); for (c, path) in [ (Command::HelpIndex, "index"), (Command::HelpContents, "contents"), (Command::Keyboard, "docs/tastatur.md"), (Command::UsingHelp, "docs/hilfe.md"), (Command::Tutorial, "docs/tutorial.md"), ] { menu(&mut a, c); assert_eq!(a.help.page().path, path); assert!(draw(&mut a).contains(&a.help.page().title)); } key(&mut a, K::F(1), M::SHIFT); assert_eq!(a.help.page().path, "docs/hilfe.md"); key(&mut a, K::F(1), M::CONTROL); assert_eq!(a.help.page().path, "docs/tutorial.md"); menu(&mut a, Command::About); let title = &a.dialog.as_ref().unwrap().title; assert!(title.contains(env!("CARGO_PKG_VERSION"))); assert!(title.contains(env!("CARGO_PKG_AUTHORS"))); assert!(title.contains("Copyright")); } #[test] fn right_click_configuration_and_external_link_follow() { let t = Temp::new(); let mut a = t.app(); let event = Event::Mouse(MouseEvent { kind: MouseEventKind::Down(MouseButton::Right), column: 2, row: 3, modifiers: M::NONE, }); a.options.right_help = false; a.handle(event.clone()); assert_ne!(a.active_window().unwrap().kind, WindowKind::Help); a.options.right_help = true; a.handle(event); assert_eq!(a.active_window().unwrap().kind, WindowKind::Help); let (p, b) = Catalog::embedded() .resolve("", "docs/dateiformate.md") .unwrap(); a.help.visit(p, b, None); let link = a .help .page() .links .iter() .position(|l| l.target.starts_with("https:")) .unwrap(); for _ in 0..=link { key(&mut a, K::Tab, M::NONE); } assert!(draw(&mut a).contains("extern")); let before = a.help.position.clone(); key(&mut a, K::Enter, M::NONE); assert_eq!(a.help.position, before); assert!(a.help.notice.contains("nicht automatisch")); assert!(!a.session.file_shell); assert!(a.session.host.shell_request.is_none()); } #[test] fn every_command_and_forms_alias_resolves_and_invalid_alias_fails() { use tb_frontend::forms::ObjectClass; let c = Catalog::embedded(); for designer in [false, true] { for menu in tb_ide::commands::menus(designer) { for command in menu.items.iter().filter_map(|i| i.command) { c.resolve("", tb_ide::help::command_target(command, designer)) .unwrap(); } } } let t = Temp::new(); let mut a = t.app(); for class in ObjectClass::ALL { for property in tb_frontend::forms::properties(class) { a.help_context(property.name, Some(class.name())); assert!( a.help.position.query.is_none(), "{}.{}", class.name(), property.name ); assert_eq!(a.help.page().path, "docs/forms-referenz.md"); let page = a.help.page(); let heading = page .headings .iter() .rev() .find(|h| h.block <= a.help.position.block) .unwrap(); assert!(heading.title.to_uppercase().contains(class.name())); } } // The same resolver validates command aliases; a stale anchor is a hard failure. assert!(c .resolve( "", "docs/ide-bedienung.md#absichtlich-fehlender-befehlsanker" ) .is_err()); } #[test] fn code_and_tables_remain_reachable_after_resize_and_horizontal_scroll() { let t = Temp::new(); let mut a = t.app(); a.execute(Command::HelpContents); a.windows .iter_mut() .find(|w| w.id == a.active) .unwrap() .normal = Rect::new(2, 2, 22, 18); let (p, _) = Catalog::embedded().resolve("", "docs/tutorial.md").unwrap(); let code = Catalog::embedded().pages[p] .blocks .iter() .position(|b| b.pre && b.text().contains("Hallo aus")) .unwrap(); a.help.visit(p, code, None); let before = a.help.position.clone(); let small = draw(&mut a); assert!(small.contains("PRINT")); for _ in 0..3 { key(&mut a, K::Right, M::NONE); } assert!(draw(&mut a).contains("Formular")); let after_scroll = a.help.position.clone(); a.handle(Event::Resize(110, 35)); a.windows .iter_mut() .find(|w| w.id == a.active) .unwrap() .normal .width = 70; draw(&mut a); assert_eq!(a.help.position, after_scroll); for _ in 0..3 { key(&mut a, K::Left, M::NONE); } assert_eq!(a.help.position, before); assert!(draw(&mut a).contains("Hallo aus dem Formular")); let (p, _) = Catalog::embedded() .resolve("", "docs/ide-bedienung.md") .unwrap(); let row = Catalog::embedded().pages[p] .blocks .iter() .position(|b| b.pre && b.text().contains("Index, Contents")) .unwrap(); a.help.visit(p, row, None); a.windows .iter_mut() .find(|w| w.id == a.active) .unwrap() .normal .width = 22; let initial = draw(&mut a); assert!(!initial.contains("Using Help")); let mut found = false; for _ in 0..20 { key(&mut a, K::Right, M::NONE); found |= draw(&mut a).contains("Using Help"); } assert!(found); // Real Unicode paragraphs wrap and remain at the identical logical anchor. a.execute(Command::UsingHelp); key(&mut a, K::PageDown, M::NONE); let pos = a.help.position.clone(); let narrow = draw(&mut a); assert!(!narrow.contains('\u{fffd}')); a.windows .iter_mut() .find(|w| w.id == a.active) .unwrap() .normal .width = 90; draw(&mut a); assert_eq!(a.help.position, pos); } #[test] fn help_preserves_fullscreen_and_does_not_modify_designer_or_source() { let t = Temp::new(); let mut a = t.app(); a.execute(Command::Output); a.session.fullscreen = true; let original = a.active; key(&mut a, K::F(1), M::SHIFT); assert!(!a.session.fullscreen); assert!(draw(&mut a).contains("Hilfe benutzen")); key(&mut a, K::Esc, M::NONE); assert_eq!(a.active, original); assert!(a.session.fullscreen); a.session.fullscreen = false; a.execute(Command::NewForm); let doc = a.designer.document.unwrap(); let before = a.project.document(doc).unwrap().revision(); key(&mut a, K::F(1), M::SHIFT); for k in [K::Down, K::Right, K::Tab, K::PageDown, K::Delete] { key(&mut a, k, M::NONE); } assert_eq!(a.project.document(doc).unwrap().revision(), before); for ch in "LEFT$".chars() { key(&mut a, K::Char(ch), M::NONE); } key(&mut a, K::Enter, M::NONE); assert!(a.help.position.query.as_deref() == Some("LEFT$")); key(&mut a, K::Tab, M::NONE); key(&mut a, K::Enter, M::NONE); assert!(a.help.position.query.is_none()); key(&mut a, K::F(1), M::ALT); assert_eq!(a.help.position.query.as_deref(), Some("LEFT$")); assert_eq!(a.project.document(doc).unwrap().revision(), before); }