Files

450 lines
16 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crossterm::event::{Event, KeyCode as K, KeyEvent, KeyModifiers as M};
use std::{
fs,
path::PathBuf,
sync::atomic::{AtomicUsize, Ordering},
};
use tb_ide::{
app::{App, DialogKind, WindowKind},
commands::Command,
documents::{Destination, Project},
editor::{column, offset},
};
struct Temp(PathBuf);
impl Temp {
fn new() -> Self {
static N: AtomicUsize = AtomicUsize::new(0);
let p = std::env::temp_dir().join(format!(
"tb-editor-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&p).unwrap();
Self(p.canonicalize().unwrap())
}
fn app(&self) -> App {
let mut a = App::new(&self.0, self.0.join("options.ini"), (100, 30)).unwrap();
a.options.syntax_checking = false;
a
}
}
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 plain(a: &mut App, k: K) {
key(a, k, M::NONE);
}
fn view(a: &App) -> tb_ide::documents::ViewId {
let WindowKind::Code(v) = a.active_window().unwrap().kind else {
panic!()
};
v
}
fn code(a: &App) -> &str {
a.project
.document(a.active_document().unwrap())
.unwrap()
.code()
}
fn field(a: &mut App, n: usize, text: &str) {
while a.dialog.as_ref().unwrap().focus != n {
plain(a, K::Tab);
}
key(a, K::Char('a'), M::CONTROL);
plain(a, K::Delete);
for c in text.chars() {
plain(a, K::Char(c));
}
}
fn toggle(a: &mut App, n: usize) {
while a.dialog.as_ref().unwrap().focus != n {
plain(a, K::Tab);
}
plain(a, K::Char(' '));
}
#[test]
fn cua_unicode_overwrite_wordstar_and_shared_undo() {
let t = Temp::new();
let mut a = t.app();
a.handle(Event::Paste("α\t\nzweite Zeile\n".into()));
assert_eq!(code(&a), "α\t\nzweite Zeile\n");
key(&mut a, K::Home, M::CONTROL);
key(&mut a, K::Char('k'), M::CONTROL);
plain(&mut a, K::Char('0'));
plain(&mut a, K::Right);
plain(&mut a, K::Right);
assert_eq!(a.project.view(view(&a)).unwrap().cursor, 3);
plain(&mut a, K::Down);
assert_eq!(
a.project.view(view(&a)).unwrap().cursor,
"α\t\n".len() + 8
);
key(&mut a, K::Char('q'), M::CONTROL);
plain(&mut a, K::Char('0'));
assert_eq!(a.project.view(view(&a)).unwrap().cursor, 0);
let original = code(&a).to_owned();
key(&mut a, K::Char('q'), M::CONTROL);
plain(&mut a, K::Esc);
plain(&mut a, K::F(3));
assert_eq!(code(&a), original);
key(&mut a, K::Char('a'), M::CONTROL);
key(&mut a, K::Char('x'), M::CONTROL);
assert_eq!(code(&a), "");
key(&mut a, K::Char('z'), M::CONTROL);
assert_eq!(code(&a), original);
assert_eq!(
a.project.view(view(&a)).unwrap().selection(),
Some(0..original.len())
);
key(&mut a, K::Insert, M::CONTROL);
a.execute(Command::NewWindow);
key(&mut a, K::End, M::CONTROL);
key(&mut a, K::Insert, M::SHIFT);
assert_eq!(code(&a), original.repeat(2));
key(&mut a, K::Char('z'), M::CONTROL);
assert_eq!(code(&a), original);
key(&mut a, K::Home, M::CONTROL);
plain(&mut a, K::Insert);
plain(&mut a, K::Char('é'));
assert!(code(&a).starts_with("é\t"));
key(&mut a, K::Char('y'), M::CONTROL);
assert_eq!(code(&a), "zweite Zeile\n");
key(&mut a, K::Char('z'), M::CONTROL);
assert!(code(&a).starts_with("é\t"));
assert_eq!(column("α\t", 8), 10);
assert_eq!(offset("α\t", 8, 8), 3);
key(&mut a, K::Home, M::CONTROL);
key(&mut a, K::Right, M::SHIFT);
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 30)).unwrap();
terminal.draw(|f| a.render(f)).unwrap();
assert!(terminal
.backend()
.buffer()
.content
.iter()
.any(|c| c.modifier.contains(ratatui::style::Modifier::REVERSED)));
}
#[test]
fn search_scope_direction_wrap_and_shortening_replace_all() {
let t = Temp::new();
let mut a = t.app();
a.handle(Event::Paste("one One stone one\n".into()));
key(&mut a, K::Home, M::CONTROL);
a.execute(Command::Replace);
field(&mut a, 0, "one");
field(&mut a, 1, "");
toggle(&mut a, 4);
while a.dialog.as_ref().unwrap().focus != 6 {
plain(&mut a, K::Tab);
}
plain(&mut a, K::Right);
plain(&mut a, K::Enter);
assert_eq!(code(&a), " stone \n");
key(&mut a, K::Char('z'), M::CONTROL);
assert_eq!(code(&a), "one One stone one\n");
a.execute(Command::Find);
field(&mut a, 0, "one");
toggle(&mut a, 3);
plain(&mut a, K::Enter);
assert_eq!(a.project.view(view(&a)).unwrap().selection(), Some(0..3));
plain(&mut a, K::F(3));
assert_eq!(a.project.view(view(&a)).unwrap().selection(), Some(14..17));
plain(&mut a, K::F(3));
assert!(a.message.contains("Rücksprung"));
plain(&mut a, K::F(3));
assert_eq!(a.project.view(view(&a)).unwrap().selection(), Some(0..3));
key(&mut a, K::Char('\\'), M::CONTROL);
assert_eq!(a.project.view(view(&a)).unwrap().selection(), Some(14..17));
a.execute(Command::Find);
toggle(&mut a, 2);
plain(&mut a, K::Enter);
assert_eq!(a.project.view(view(&a)).unwrap().selection(), Some(0..3));
a.execute(Command::Replace);
field(&mut a, 1, "x");
toggle(&mut a, 5);
while a.dialog.as_ref().unwrap().focus != 6 {
plain(&mut a, K::Tab);
}
plain(&mut a, K::Right);
plain(&mut a, K::Enter);
assert_eq!(code(&a), "x One stone one\n");
key(&mut a, K::Char('z'), M::CONTROL);
assert_eq!(code(&a), "one One stone one\n");
assert_eq!(a.project.view(view(&a)).unwrap().selection(), Some(0..3));
}
#[test]
fn line_leave_incomplete_invalid_disabled_and_current_revisions() {
let t = Temp::new();
let mut a = t.app();
a.options.syntax_checking = true;
for c in "print \"MiX\"".chars() {
plain(&mut a, K::Char(c));
}
plain(&mut a, K::Enter);
assert_eq!(code(&a), "PRINT \"MiX\"\n");
assert!(a.current_compilation().is_some());
a.handle(Event::Paste("SUB Fresh\n".into()));
assert!(a.dialog.is_none());
assert!(a.message.contains("unvollständig"), "{}", a.message);
assert!(a.current_compilation().is_none());
a.handle(Event::Paste("END SUB\n".into()));
assert!(a.current_compilation().is_some(), "{}", a.message);
a.handle(Event::Paste("PRINT )\n".into()));
assert!(matches!(
a.dialog.as_ref().map(|d| &d.kind),
Some(DialogKind::Diagnostics)
));
assert!(a.current_compilation().is_none());
plain(&mut a, K::Esc);
a.execute(Command::Undo);
assert!(a.compile_current().is_ok());
a.options.syntax_checking = false;
a.handle(Event::Paste("print )\n".into()));
assert!(a.dialog.is_none());
assert!(code(&a).ends_with("print )\n"));
assert!(a.current_compilation().is_none());
assert!(a.compile_current().is_err());
}
#[test]
fn procedures_and_shared_save_hook_preserve_drafts_and_frm_code() {
let t = Temp::new();
let mut a = t.app();
a.handle(Event::Paste("DEFINT A-Z\n".into()));
a.execute(Command::NewSub);
field(&mut a, 0, "Fresh");
plain(&mut a, K::Enter);
assert!(code(&a).contains("SUB Fresh"));
assert!(a.compile_current().is_ok());
key(&mut a, K::Home, M::CONTROL);
key(&mut a, K::F(2), M::SHIFT);
plain(&mut a, K::Right);
plain(&mut a, K::Enter);
assert!(a.project.view(view(&a)).unwrap().cursor > 0);
key(&mut a, K::F(2), M::CONTROL);
assert_eq!(a.project.view(view(&a)).unwrap().cursor, 0);
let id = a.active_document().unwrap();
let v = view(&a);
let cursor = code(&a).find("SUB Fresh").unwrap() + 4;
a.project.view_mut(v).unwrap().cursor = cursor;
a.project.view_mut(v).unwrap().anchor = Some(cursor + 5);
a.project
.save_file(id, Some(&Destination::new(t.0.join("main.bas"))))
.unwrap();
let saved = code(&a).to_owned();
assert!(saved.contains("DECLARE SUB FRESH"));
assert_eq!(
a.project.view(v).unwrap().cursor,
saved.find("SUB Fresh").unwrap() + 4
);
assert_eq!(
a.project.view(v).unwrap().selection().map(|r| &saved[r]),
Some("Fresh")
);
let revision = a.project.document(id).unwrap().revision();
a.project.save_file(id, None).unwrap();
assert_eq!(a.project.document(id).unwrap().revision(), revision);
assert_eq!(code(&a), saved);
let end = code(&a).len();
a.project.replace_text(id, end..end, "SUB Draft\n").unwrap();
let draft = code(&a).to_owned();
a.project.save_file(id, None).unwrap();
assert_eq!(fs::read_to_string(t.0.join("main.bas")).unwrap(), draft);
assert!(a.project.save_notices[0].contains("übersprungen"));
// Separater gültiger Projektstand für die Forms-Pflege.
a.project.replace_text(id, 0..draft.len(), &saved).unwrap();
let form = a.project.new_form("MainForm").unwrap();
a.project
.replace_text(form, 0..0, "SUB Button_Click\nEND SUB\n")
.unwrap();
a.project
.save_file(form, Some(&Destination::new(t.0.join("MainForm.frm"))))
.unwrap();
let text = fs::read_to_string(t.0.join("MainForm.frm")).unwrap();
assert!(text.contains("DECLARE SUB BUTTON_CLICK"), "{text}");
let parsed = tb_ui::frm::read_text("MainForm.frm", &text).unwrap();
assert_eq!(parsed.code, a.project.document(form).unwrap().code());
}
#[test]
fn includes_and_diagnostic_selection_use_original_physical_files() {
let t = Temp::new();
fs::write(t.0.join("one.bi"), "x$=1\n").unwrap();
fs::write(t.0.join("two.bi"), "y%=\"bad\"\n").unwrap();
fs::write(
t.0.join("main.bas"),
"'$INCLUDE: 'one.bi'\n'$INCLUDE: 'two.bi'\n",
)
.unwrap();
let mut a = t.app();
a.load_initial_project(t.0.join("main.bas")).unwrap();
assert!(a.compile_current().is_err());
let errors = a.editor.diagnostics.clone();
assert_eq!(errors.len(), 2);
for (i, d) in errors.iter().enumerate() {
assert_eq!(d.pos.line, 1);
a.goto_diagnostic(d).unwrap();
assert_eq!(
a.project
.document(a.active_document().unwrap())
.unwrap()
.source_path(),
t.0.join(if i == 0 { "one.bi" } else { "two.bi" })
);
}
a.load_initial_project(t.0.join("main.bas")).unwrap();
a.execute(Command::IncludedLines);
let original = code(&a).to_owned();
a.handle(Event::Paste("corruption".into()));
assert_eq!(code(&a), original);
assert!(a.editor.expansions.contains_key(&view(&a)));
a.execute(Command::IncludedLines);
a.execute(Command::IncludedFile);
assert!(a
.project
.document(a.active_document().unwrap())
.unwrap()
.source_path()
.ends_with("one.bi"));
let id = a.active_document().unwrap();
let len = code(&a).len();
a.project.replace_text(id, 0..len, "x$=\"ok\"\n").unwrap();
fs::write(t.0.join("two.bi"), "y%=2\n").unwrap(); // Open overlay remains authoritative.
let other = a.project.open_document(&t.0.join("two.bi")).unwrap();
let len = a.project.document(other).unwrap().code().len();
a.project.replace_text(other, 0..len, "y%=2\n").unwrap();
assert!(a.compile_current().is_ok());
let mut p = Project::open(&t.0.join("main.bas"), vec![]).unwrap();
let id = p.open_document(&t.0.join("one.bi")).unwrap();
assert_eq!(p.document(id).unwrap().code(), "x$=1\n");
}
#[test]
fn crlf_navigation_word_boundaries_readonly_and_normalization_keep_views() {
let t = Temp::new();
let mut a = t.app();
a.handle(Event::Paste("word next\r\n\tX\r\n".into()));
key(&mut a, K::Home, M::CONTROL);
key(&mut a, K::Right, M::CONTROL);
assert_eq!(a.project.view(view(&a)).unwrap().cursor, 5);
plain(&mut a, K::End);
assert_eq!(a.project.view(view(&a)).unwrap().cursor, 9);
plain(&mut a, K::Right);
assert_eq!(a.project.view(view(&a)).unwrap().cursor, 11);
plain(&mut a, K::Left);
assert_eq!(a.project.view(view(&a)).unwrap().cursor, 9);
plain(&mut a, K::Delete);
assert_eq!(code(&a), "word next界\tX\r\n");
a.execute(Command::Undo);
let id = a.active_document().unwrap();
let v = view(&a);
let second = a.project.open_view(id).unwrap();
a.project.view_mut(second).unwrap().cursor = 5;
a.project
.replace_text(id, 0..code(&a).len(), "print 1\n")
.unwrap();
a.project.view_mut(second).unwrap().cursor = 8;
a.project.view_mut(v).unwrap().cursor = 8;
a.options.syntax_checking = true;
plain(&mut a, K::Down);
assert_eq!(code(&a), "PRINT 1\n");
assert_eq!(a.project.view(second).unwrap().cursor, 6);
a.execute(Command::Undo);
assert_eq!(code(&a), "print 1\n");
assert_eq!(a.project.view(second).unwrap().cursor, 8);
a.options.syntax_checking = false;
a.execute(Command::IncludedLines);
let before = code(&a).to_owned();
for command in [
Command::Cut,
Command::Paste,
Command::Clear,
Command::Undo,
Command::NewSub,
Command::LoadText,
Command::Replace,
] {
a.execute(command);
assert!(a.dialog.is_none());
assert_eq!(code(&a), before);
assert!(a.message.contains("schreibgeschützt"));
}
let scroll = a.project.view(view(&a)).unwrap().scroll_line;
plain(&mut a, K::PageDown);
assert!(a.project.view(view(&a)).unwrap().scroll_line > scroll);
}
#[test]
fn external_include_revision_and_project_context_declarations() {
let t = Temp::new();
fs::write(
t.0.join("defs.bi"),
"DEFINT A-Z\nTYPE Pair\nX AS INTEGER\nEND TYPE\nCONST N=1\n",
)
.unwrap();
fs::write(
t.0.join("main.bas"),
"'$INCLUDE: 'defs.bi'\nSUB Work(p AS Pair, n)\np.x=n+N\nEND SUB\n",
)
.unwrap();
let mut a = t.app();
a.load_initial_project(t.0.join("main.bas")).unwrap();
assert!(a.compile_current().is_ok());
assert!(a.current_compilation().is_some());
fs::write(
t.0.join("defs.bi"),
"DEFINT A-Z\nTYPE Pair\nX AS INTEGER\nEND TYPE\nCONST N=2\n",
)
.unwrap();
// Geöffnete Include-Dokumente sind maßgeblich; externe Änderungen überschreiben keinen Puffer.
assert!(a.current_compilation().is_some());
let include = a.project.find_document(&t.0.join("defs.bi")).unwrap();
let len = a.project.document(include).unwrap().code().len();
a.project
.replace_text(
include,
0..len,
&fs::read_to_string(t.0.join("defs.bi")).unwrap(),
)
.unwrap();
assert!(a.current_compilation().is_none());
assert!(a.current_diagnostics().is_none());
assert!(a.compile_current().is_ok());
let id = a.active_document().unwrap();
a.project.save_file(id, None).unwrap();
assert!(
code(&a).contains("DECLARE SUB WORK (P AS PAIR, N%)"),
"{} {:?}",
code(&a),
a.project.save_notices
);
assert!(a.project.save_notices.is_empty());
assert!(a.compile_current().is_ok());
a.execute(Command::NewFunction);
field(&mut a, 0, "Fresh");
plain(&mut a, K::Enter);
assert!(code(&a).contains("FUNCTION Fresh%"), "{}", code(&a));
assert!(a.compile_current().is_ok());
let other = a.project.new_module("Other").unwrap();
a.project
.replace_text(other, 0..0, "SUB Taken\nEND SUB\n")
.unwrap();
a.execute(Command::NewSub);
field(&mut a, 0, "Taken");
plain(&mut a, K::Enter);
assert!(a.dialog.as_ref().unwrap().error.contains("bereits"));
plain(&mut a, K::Esc);
// Ein erst nach dem Öffnen eingefügtes Include wird bis zum Öffnen direkt vom Loader gelesen.
fs::write(t.0.join("late.bi"), "CONST LATE=1\n").unwrap();
a.project
.replace_text(id, 0..0, "'$INCLUDE: 'late.bi'\n")
.unwrap();
assert!(a.compile_current().is_ok());
fs::remove_file(t.0.join("late.bi")).unwrap();
assert!(a.current_compilation().is_none());
assert!(a.compile_current().is_err());
assert!(a.current_diagnostics().is_none());
}