531 lines
17 KiB
Rust
531 lines
17 KiB
Rust
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::<String>())
|
|
.collect::<Vec<_>>()
|
|
.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::<Vec<_>>()
|
|
.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);
|
|
}
|