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()); }