use crossterm::event::{ Event, KeyCode as K, KeyEvent, KeyModifiers as M, MouseButton, MouseEvent, MouseEventKind, }; use ratatui::{backend::TestBackend, Terminal}; use std::{ fs, path::PathBuf, sync::atomic::{AtomicUsize, Ordering}, }; use tb_ide::{ app::{App, DialogKind, Execution, Hit, Mode, WindowKind, WindowState}, commands::{self, Command}, export::{ExportStatus, ProjectStamp}, options::Options, }; struct Temp(PathBuf); impl Temp { fn new() -> Self { static N: AtomicUsize = AtomicUsize::new(0); let path = std::env::temp_dir().join(format!( "tb-app-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed) )); fs::create_dir_all(&path).unwrap(); Self(path.canonicalize().unwrap()) } fn app(&self) -> App { App::new(&self.0, self.0.join("config/options.ini"), (100, 30)).unwrap() } } impl Drop for Temp { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } fn key(app: &mut App, k: K, m: M) { app.handle(Event::Key(KeyEvent::new(k, m))); } fn plain(app: &mut App, k: K) { key(app, k, M::NONE); } fn type_text(app: &mut App, text: &str) { for c in text.chars() { plain(app, K::Char(c)); } } fn field(app: &mut App, index: usize, text: &str) { while app.dialog.as_ref().unwrap().focus != index { plain(app, K::Tab); } key(app, K::Char('a'), M::CONTROL); plain(app, K::Delete); type_text(app, text); } fn menu(app: &mut App, command: Command) { let menus = app.menus(); let (m, i) = menus .iter() .enumerate() .find_map(|(m, menu)| { menu.items .iter() .position(|i| i.command == Some(command)) .map(|i| (m, i)) }) .unwrap(); key(app, K::Char(menus[m].mnemonic), M::ALT); for _ in 0..i { plain(app, K::Down); } plain(app, K::Enter); assert_eq!(app.last_command, Some(command)); } fn draw(app: &mut App) -> (String, ratatui::buffer::Buffer) { let mut term = Terminal::new(TestBackend::new(app.size.0, app.size.1)).unwrap(); term.draw(|f| app.render(f)).unwrap(); let b = term.backend().buffer().clone(); let text = b .content .chunks(app.size.0 as usize) .map(|row| row.iter().map(|c| c.symbol()).collect::()) .collect::>() .join("\n"); (text, b) } fn click(app: &mut App, x: u16, y: u16) { app.handle(Event::Mouse(MouseEvent { kind: MouseEventKind::Down(MouseButton::Left), column: x, row: y, modifiers: M::NONE, })); } #[test] fn start_snapshot_and_reference_menus_have_all_commands() { let t = Temp::new(); let mut app = t.app(); let (text, b) = draw(&mut app); assert!( text.contains("[1] Untitled") && text.contains("Project: Untitled") && text.contains("Copyright") && text.contains("00001:001") ); assert_eq!(b[(4, 1)].bg, tb_ide::render::dos(5)); assert_eq!(b[(4, 1)].fg, tb_ide::render::dos(15)); assert_eq!(b[(4, 2)].bg, tb_ide::render::dos(1)); assert_eq!(b[(1, 29)].bg, tb_ide::render::dos(3)); assert_eq!(b[(1, 0)].bg, tb_ide::render::dos(7)); plain(&mut app, K::Char('x')); assert_eq!( app.project .document(app.active_document().unwrap()) .unwrap() .code(), "x" ); assert_eq!( commands::menus(false) .iter() .map(|m| m.title) .collect::>(), ["File", "Edit", "View", "Search", "Run", "Debug", "Options", "Window", "Help"] ); assert_eq!( commands::menus(true) .iter() .map(|m| m.title) .collect::>(), ["File", "Edit", "View", "Tools", "Options", "Window", "Help"] ); let reference = include_str!("../../../docs/ide-referenz.md"); for designer in [false, true] { for m in commands::menus(designer) { let mut mnemonics = std::collections::BTreeSet::new(); for item in m.items.iter().filter(|i| i.command.is_some()) { let name = item.text(); assert!( reference.contains(name.trim_end_matches('…')) || item.command == Some(Command::Diagnostics) || matches!(item.command, Some(Command::Tool(_))), "Nicht in Referenz: {name}" ); assert!(item.mnemonic().is_some()); assert!( mnemonics.insert(item.mnemonic().unwrap()), "Doppeltes Mnemonic in {}: {}", m.title, item.label ); } } } assert_eq!( commands::menus(false)[0] .items .iter() .filter_map(|i| i.command) .collect::>(), [ Command::NewProject, Command::OpenProject, Command::SaveProject, Command::NewForm, Command::NewModule, Command::AddFile, Command::RemoveFile, Command::SaveFile, Command::SaveAs, Command::LoadText, Command::SaveText, Command::Print, Command::Shell, Command::Exit ] ); } #[test] fn modal_focus_and_dirty_project_cancel_preserve_document() { let t = Temp::new(); let mut app = t.app(); type_text(&mut app, "PRINT 7"); let id = app.active_document().unwrap(); let active = app.active; menu(&mut app, Command::NewModule); type_text(&mut app, "Other"); plain(&mut app, K::F(6)); key(&mut app, K::Char('c'), M::CONTROL); plain(&mut app, K::Esc); assert_eq!(app.active, active); assert_eq!(app.project.document(id).unwrap().code(), "PRINT 7"); assert!(app.basic_events.is_empty()); key(&mut app, K::F(4), M::ALT); assert!(matches!( app.dialog.as_ref().unwrap().kind, DialogKind::Dirty(_) )); plain(&mut app, K::Enter); assert!(!app.quit); menu(&mut app, Command::NewProject); plain(&mut app, K::Right); plain(&mut app, K::Enter); assert!(matches!( app.dialog.as_ref().unwrap().kind, DialogKind::Save { .. } )); plain(&mut app, K::Esc); assert!(app.project.document(id).unwrap().is_dirty()); assert!(!app.quit); menu(&mut app, Command::SaveProject); field(&mut app, 0, &t.0.join("project.mak").display().to_string()); plain(&mut app, K::Enter); assert!(app.dialog.is_none(), "{:?}", app.dialog); assert!(!app.project.is_dirty()); menu(&mut app, Command::Exit); assert!(app.quit); } #[test] fn dispatcher_keeps_function_keys_and_copy_out_of_basic_input() { let t = Temp::new(); let mut app = t.app(); key(&mut app, K::Char('c'), M::CONTROL); assert_eq!(app.last_command, Some(Command::Copy)); assert!(app.editor.clipboard.is_empty()); assert!(app.basic_events.is_empty()); plain(&mut app, K::F(11)); assert!(app.menu.is_some()); plain(&mut app, K::Esc); menu(&mut app, Command::NewForm); let form = app.active_document(); assert_eq!(app.mode, Mode::Designer); plain(&mut app, K::F(2)); assert!(app.value_focus); plain(&mut app, K::F(10)); assert!(!app.properties && app.menu.is_some()); plain(&mut app, K::F(10)); assert!(app.properties && app.menu.is_none()); plain(&mut app, K::F(12)); assert_eq!(app.last_command, Some(Command::Events)); assert_eq!(app.active_document(), form); assert!(app.basic_events.is_empty()); plain(&mut app, K::F(11)); plain(&mut app, K::Esc); menu(&mut app, Command::Code); plain(&mut app, K::Enter); assert_eq!(app.mode, Mode::Environment); app.execution = Execution::Paused; plain(&mut app, K::F(10)); assert_eq!(app.last_command, Some(Command::ProcedureStep)); assert_eq!(app.availability(Command::ProcedureStep), None); menu(&mut app, Command::Output); app.execution = Execution::Running; plain(&mut app, K::Char('a')); assert_eq!(app.basic_events.len(), 1); plain(&mut app, K::F(11)); assert_eq!(app.basic_events.len(), 1); } #[test] fn windows_restore_geometry_and_resize_without_document_loss() { let t = Temp::new(); let mut app = t.app(); app.options.syntax_checking = false; // Dieser Test prüft Fenster mit absichtlich ungültigem Entwurf. type_text(&mut app, "abc"); let doc = app.active_document().unwrap(); let first = app.active; menu(&mut app, Command::NewWindow); assert_eq!(app.active_document(), Some(doc)); assert_eq!(app.windows.len(), 3); key(&mut app, K::F(8), M::CONTROL); plain(&mut app, K::Left); plain(&mut app, K::Up); plain(&mut app, K::Enter); key(&mut app, K::F(7), M::CONTROL); plain(&mut app, K::Right); plain(&mut app, K::Down); plain(&mut app, K::Enter); let normal = app.active_window().unwrap().normal; key(&mut app, K::F(9), M::CONTROL); assert_eq!(app.active_window().unwrap().state, WindowState::Minimized); key(&mut app, K::F(10), M::CONTROL); assert_eq!(app.active_window().unwrap().state, WindowState::Maximized); key(&mut app, K::F(5), M::CONTROL); assert_eq!(app.active_window().unwrap().normal, normal); let active = app.active; app.handle(Event::Resize(40, 12)); let (small, _) = draw(&mut app); assert!(small.contains("mindestens")); assert!(!small.contains("Untitled")); plain(&mut app, K::Char('z')); app.handle(Event::Resize(80, 25)); draw(&mut app); assert_eq!(app.active, active); assert_eq!(app.project.document(doc).unwrap().code(), "abc"); for w in &app.windows { let r = app.rect(w); assert!(r.right() <= 80 && r.bottom() <= 24); } key(&mut app, K::Char('-'), M::ALT); assert!(app.control_menu); plain(&mut app, K::Esc); key(&mut app, K::F(4), M::CONTROL); assert_eq!(app.windows.len(), 2); assert!(app.project.document(doc).is_ok()); assert_eq!(app.active, first); plain(&mut app, K::F(6)); assert_ne!(app.active, first); key(&mut app, K::F(6), M::SHIFT); assert_eq!(app.active, first); menu(&mut app, Command::Arrange); assert_eq!(app.windows[0].normal.right(), app.windows[1].normal.x); } #[test] fn status_and_project_buttons_use_the_keyboard_command_path() { let t = Temp::new(); let mut app = t.app(); draw(&mut app); let before = app.active; click(&mut app, 17, 29); assert_eq!(app.last_command, Some(Command::NextWindow)); assert_ne!(app.active, before); let project = app .windows .iter() .find(|w| w.kind == WindowKind::Project) .unwrap() .clone(); let r = app.rect(&project); draw(&mut app); click(&mut app, r.x + 12, r.y + 2); assert_eq!(app.last_command, Some(Command::Code)); plain(&mut app, K::Esc); menu(&mut app, Command::NewForm); draw(&mut app); let (text, _) = draw(&mut app); assert!(text.contains("Property:")); assert!(!text.contains("")); } #[test] fn export_dialogs_validate_and_render_all_results_without_creating_files() { let t = Temp::new(); let mut app = t.app(); app.export_backend = false; let stamp = ProjectStamp::capture(&app.project); for command in [Command::MakeExe, Command::MakeLibrary] { menu(&mut app, command); let output = if command == Command::MakeLibrary { 1 } else { 3 }; let (system, architecture) = if command == Command::MakeExe { ( app.dialog.as_ref().unwrap().fields[1].string(), app.dialog.as_ref().unwrap().fields[2].string(), ) } else { (String::new(), String::new()) }; let (text, _) = draw(&mut app); assert!(text.contains("Erzeugen: Phase 6")); field(&mut app, output, ""); plain(&mut app, K::Enter); assert!(app.dialog.as_ref().unwrap().error.contains("Ausgabepfad")); field( &mut app, output, &t.0.join("notthere/out").display().to_string(), ); plain(&mut app, K::Enter); assert!(app .dialog .as_ref() .unwrap() .error .contains("Zielverzeichnis")); field(&mut app, output, &t.0.join("bad.tbc").display().to_string()); plain(&mut app, K::Enter); assert!(app.dialog.as_ref().unwrap().error.contains("TBC")); let existing = t.0.join("existing.tbl"); fs::write(&existing, b"keep").unwrap(); field(&mut app, output, &existing.display().to_string()); plain(&mut app, K::Enter); assert!(app .dialog .as_ref() .unwrap() .error .contains("Überschreibentscheidung")); plain(&mut app, K::Tab); plain(&mut app, K::Char(' ')); plain(&mut app, K::Enter); assert_eq!(fs::read(&existing).unwrap(), b"keep"); assert!(app.dialog.as_ref().unwrap().request.is_some()); let target = t.0.join("result.tbl"); field(&mut app, output, &target.display().to_string()); plain(&mut app, K::Enter); let request = app.dialog.as_ref().unwrap().request.clone().unwrap(); assert_eq!(request.system, system); assert_eq!(request.architecture, architecture); assert_eq!(request.project, stamp); for status in [ ExportStatus::Running, ExportStatus::Success, ExportStatus::Failed("Testfehler".into()), ExportStatus::Cancelled, ] { app.export_result(&request, status.clone()).unwrap(); let (text, _) = draw(&mut app); assert!(text.contains(&status.text()), "{text}"); assert!(!target.exists()); } plain(&mut app, K::Esc); assert_eq!(ProjectStamp::capture(&app.project), stamp); } } #[test] fn file_picker_save_conflict_and_project_replacement_use_real_events() { let t = Temp::new(); let mut app = t.app(); let member = t.0.join("existing.bas"); fs::write(&member, "PRINT 2\n").unwrap(); menu(&mut app, Command::AddFile); plain(&mut app, K::F(2)); let DialogKind::Browse { entries, .. } = &app.dialog.as_ref().unwrap().kind else { panic!("Dateiwahl fehlt") }; let index = entries.iter().position(|p| p == &member).unwrap(); for _ in 0..index { plain(&mut app, K::Right); } plain(&mut app, K::Enter); assert!(matches!( app.dialog.as_ref().unwrap().kind, DialogKind::AddFile )); plain(&mut app, K::Enter); let id = app.active_document().unwrap(); type_text(&mut app, "'edit\n"); fs::write(&member, "external\n").unwrap(); menu(&mut app, Command::SaveFile); plain(&mut app, K::Enter); assert!(app .dialog .as_ref() .unwrap() .error .contains("Überschreibentscheidung")); assert_eq!(fs::read_to_string(&member).unwrap(), "external\n"); plain(&mut app, K::Tab); plain(&mut app, K::Char(' ')); plain(&mut app, K::Enter); assert!(app.dialog.is_none()); assert!(!app.project.document(id).unwrap().is_dirty()); menu(&mut app, Command::LoadText); plain(&mut app, K::F(2)); plain(&mut app, K::Esc); assert!(matches!( app.dialog.as_ref().unwrap().kind, DialogKind::LoadText )); plain(&mut app, K::Esc); let path = t.0.join("sub/p.mak"); fs::create_dir(path.parent().unwrap()).unwrap(); fs::write(&path, "main.bas\n").unwrap(); fs::write(path.parent().unwrap().join("main.bas"), "END\n").unwrap(); menu(&mut app, Command::OpenProject); field(&mut app, 0, &path.display().to_string()); plain(&mut app, K::Enter); assert!(matches!( app.dialog.as_ref().unwrap().kind, DialogKind::Dirty(_) )); plain(&mut app, K::Right); plain(&mut app, K::Right); plain(&mut app, K::Enter); assert!(app.dialog.is_none()); assert_eq!(app.project.path(), Some(path.as_path())); menu(&mut app, Command::NewProject); assert!(app.dialog.is_none()); assert_eq!(app.project.members().len(), 1); assert_eq!(app.project.directory(), path.parent().unwrap()); } #[test] fn exit_with_partial_save_retains_dialog_and_unsaved_documents() { let t = Temp::new(); fs::write(t.0.join("a.bas"), "END\n").unwrap(); fs::write(t.0.join("b.bas"), "END\n").unwrap(); let mak = t.0.join("p.mak"); fs::write(&mak, "a.bas\nb.bas\n").unwrap(); let mut app = t.app(); app.load_initial_project(mak.clone()).unwrap(); let ids = app.project.members(); for id in &ids { app.project.replace_text(*id, 0..0, "' changed\n").unwrap(); } let b = t.0.join("b.bas"); let original = fs::metadata(&b).unwrap().permissions(); let mut perms = original.clone(); perms.set_readonly(true); fs::set_permissions(&b, perms).unwrap(); key(&mut app, K::F(4), M::ALT); plain(&mut app, K::Right); plain(&mut app, K::Enter); plain(&mut app, K::Enter); assert!(app .dialog .as_ref() .unwrap() .error .contains("schreibgeschützt")); assert!(!app.quit); assert!(!app.project.document(ids[0]).unwrap().is_dirty()); assert!(app.project.document(ids[1]).unwrap().is_dirty()); assert_eq!(fs::read_to_string(&mak).unwrap(), "a.bas\nb.bas\n"); fs::set_permissions(&b, original).unwrap(); plain(&mut app, K::Enter); assert!(app.quit); assert!(!app.project.is_dirty()); } #[test] fn mouse_focus_is_modal_and_program_abort_is_not_editor_copy() { let t = Temp::new(); let mut app = t.app(); let active = app.active; menu(&mut app, Command::NewModule); draw(&mut app); click(&mut app, 85, 2); assert_eq!(app.active, active); plain(&mut app, K::Esc); menu(&mut app, Command::Output); app.execution = Execution::Running; key(&mut app, K::Char('c'), M::CONTROL); assert_eq!(app.execution, Execution::Paused); assert!(app.basic_events.is_empty()); plain(&mut app, K::Char('x')); assert!( app.basic_events.is_empty(), "Tasten nach Break gehören nicht mehr BASIC" ); app.execution = Execution::Running; let r = app.rect(app.active_window().unwrap()); let mouse = MouseEvent { kind: MouseEventKind::Down(MouseButton::Right), column: r.x + 2, row: r.y + 2, modifiers: M::NONE, }; app.handle(Event::Mouse(mouse)); let mut local = mouse; local.column = 1; local.row = 1; assert_eq!(app.basic_events.last(), Some(&Event::Mouse(local))); app.execution = Execution::Idle; menu(&mut app, Command::RightMouse); plain(&mut app, K::Char(' ')); plain(&mut app, K::Enter); let before = app.last_command; app.handle(Event::Mouse(MouseEvent { kind: MouseEventKind::Down(MouseButton::Right), column: 2, row: 2, modifiers: M::NONE, })); assert_eq!(app.last_command, before); menu(&mut app, Command::RightMouse); plain(&mut app, K::Char(' ')); plain(&mut app, K::Enter); app.handle(Event::Mouse(MouseEvent { kind: MouseEventKind::Down(MouseButton::Right), column: 2, row: 2, modifiers: M::NONE, })); assert_eq!(app.last_command, Some(Command::Topic)); } #[test] fn designer_form_list_and_context_availability_match_the_active_window() { let t = Temp::new(); fs::write( t.0.join("Form1.frm"), "VERSION 1.00\nBegin Form Existing\nEnd\n", ) .unwrap(); let mut app = t.app(); menu(&mut app, Command::NewForm); assert_eq!( app.project .document(app.active_document().unwrap()) .unwrap() .source_path() .file_name() .unwrap(), "Form2.frm" ); let entries = app.window_commands(); assert_eq!(entries.len(), 1); assert!(entries[0].0.starts_with("• Form:")); plain(&mut app, K::F(11)); key(&mut app, K::Char('h'), M::ALT); assert_eq!(app.menus()[app.menu.unwrap().0].title, "Help"); plain(&mut app, K::Esc); menu(&mut app, Command::Code); plain(&mut app, K::Enter); menu(&mut app, Command::Project); assert!(app .availability(Command::LoadText) .unwrap() .contains("Codefenster")); } #[test] fn clicking_project_members_and_dialog_choices_transfers_focus_and_applies_values() { let t = Temp::new(); let mut app = t.app(); menu(&mut app, Command::NewForm); menu(&mut app, Command::Code); plain(&mut app, K::Enter); menu(&mut app, Command::Project); draw(&mut app); let r = app.rect(app.active_window().unwrap()); // Zweites Mitglied ist das Formular; der Listenklick bindet Enter an Project. click(&mut app, r.x + 3, r.y + 5); assert_eq!(app.active_window().unwrap().kind, WindowKind::Project); plain(&mut app, K::Enter); assert_eq!(app.mode, Mode::Designer); menu(&mut app, Command::RightMouse); draw(&mut app); let hit = app .hits .iter() .find(|(_, h)| matches!(h, tb_ide::app::Hit::DialogField(0))) .unwrap() .0; click(&mut app, hit.x, hit.y); plain(&mut app, K::Enter); assert!(!app.options.right_help); } #[test] fn options_apply_persist_across_bases_and_preserve_unsaved_values_on_error() { let t = Temp::new(); let mut app = t.app(); menu(&mut app, Command::Display); // Titelhintergrund: Palette 5 -> 6, Desktopzeichen und Tabweite. while app.dialog.as_ref().unwrap().focus != 7 { plain(&mut app, K::Tab); } plain(&mut app, K::Right); field(&mut app, 14, "."); field(&mut app, 15, "4"); plain(&mut app, K::Enter); assert!(app.dialog.is_none()); let (_, b) = draw(&mut app); assert_eq!(b[(4, 1)].bg, tb_ide::render::dos(6)); assert_eq!(app.options.tab_width, 4); let include = t.0.join("includes"); fs::create_dir(&include).unwrap(); fs::write(include.join("shared.bi"), "PRINT 42\n").unwrap(); menu(&mut app, Command::Paths); field(&mut app, 2, &include.display().to_string()); plain(&mut app, K::Enter); let id = app.active_document().unwrap(); app.project .replace_text(id, 0..0, "'$INCLUDE: 'shared.bi'\n") .unwrap(); assert!(app.project.sources().unwrap().units[0] .segments .iter() .any(|s| s.text == "PRINT 42\n")); menu(&mut app, Command::SyntaxChecking); assert!(!app.options.syntax_checking); menu(&mut app, Command::SaveOptions); plain(&mut app, K::Enter); assert!(app.dialog.is_none()); let elsewhere = t.0.join("elsewhere"); fs::create_dir(&elsewhere).unwrap(); let fresh = App::new(&elsewhere, app.config_path.clone(), (80, 25)).unwrap(); assert_eq!(fresh.options, app.options); let mut perms = fs::metadata(&app.config_path).unwrap().permissions(); let original = perms.clone(); perms.set_readonly(true); fs::set_permissions(&app.config_path, perms).unwrap(); menu(&mut app, Command::SyntaxChecking); menu(&mut app, Command::SaveOptions); plain(&mut app, K::Enter); assert!(app .dialog .as_ref() .unwrap() .error .contains("schreibgeschützt")); assert_ne!(app.options, app.saved_options); fs::set_permissions(&app.config_path, original).unwrap(); fs::write( &app.config_path, "version=1\ntab_width=0\ncolor.3=999,5\ndesktop=xx\nfuture=foo\n", ) .unwrap(); let (defaults, errors, _) = Options::load(&app.config_path); assert_eq!(defaults, Options::default()); assert_eq!(errors.len(), 4); } fn await_export(app: &mut App) -> ExportStatus { let start = std::time::Instant::now(); loop { app.tick_export(); let status = app.dialog.as_ref().unwrap().export_status.clone(); if status != ExportStatus::Running { return status; } assert!(start.elapsed().as_secs() < 10, "Export hängt"); std::thread::sleep(std::time::Duration::from_millis(2)); } } #[test] fn real_library_export_uses_dirty_include_and_mouse_without_runtime_template() { let t = Temp::new(); fs::write( t.0.join("lib.bas"), "SUB Answer\n'$INCLUDE: 'body.bi'\nEND SUB\n", ) .unwrap(); fs::write(t.0.join("body.bi"), "PRINT 1\n").unwrap(); let mut app = t.app(); app.load_initial_project(t.0.join("lib.bas")).unwrap(); let include = app.project.find_document(&t.0.join("body.bi")).unwrap(); app.project .replace_text(include, 0..8, "PRINT 42\n") .unwrap(); let stamp = ProjectStamp::capture(&app.project); menu(&mut app, Command::MakeLibrary); assert_eq!(app.dialog.as_ref().unwrap().fields.len(), 3); let out = t.0.join("out.tbl"); field(&mut app, 1, &out.display().to_string()); draw(&mut app); let r = app .hits .iter() .find(|(_, h)| matches!(h, Hit::DialogSubmit)) .unwrap() .0; app.handle(Event::Mouse(MouseEvent { kind: MouseEventKind::Down(MouseButton::Left), column: r.x, row: r.y, modifiers: M::NONE, })); assert_eq!(await_export(&mut app), ExportStatus::Success); assert_eq!(ProjectStamp::capture(&app.project), stamp); assert!(app.project.document(include).unwrap().is_dirty()); assert_eq!( fs::read_to_string(t.0.join("body.bi")).unwrap(), "PRINT 1\n" ); plain(&mut app, K::Esc); assert_eq!(app.last_export.as_ref().unwrap().1, ExportStatus::Success); fs::remove_file(t.0.join("lib.bas")).unwrap(); fs::remove_file(t.0.join("body.bi")).unwrap(); fs::write(t.0.join("consumer.bas"), "CALL Answer\nEND\n").unwrap(); fs::write(t.0.join("consumer.mak"), "consumer.bas\nout.tbl\n").unwrap(); app.load_initial_project(t.0.join("consumer.mak")).unwrap(); app.start_execution().unwrap(); for n in 0..100 { app.tick_execution(n); if app.execution == Execution::Ended { break; } } assert_eq!(tb_runtime::snapshot::text(app.session.screen()), " 42 \n"); } #[test] fn executable_target_validation_and_missing_template_are_honest() { use tb_ide::{ documents::Destination, export::{Artifact, ExportRequest}, }; let t = Temp::new(); let mut app = t.app(); for (system, arch) in [ ("windows", "aarch64"), ("macos", "x86_64"), ("unknown", "x86_64"), ] { assert!(ExportRequest::validate( &app.project, Artifact::Executable, system, arch, &Destination::new(t.0.join("out")) ) .is_err()); } app.runtime_dir = t.0.join("missing-runtimes"); menu(&mut app, Command::MakeExe); let out = t.0.join("out"); field(&mut app, 3, &out.display().to_string()); plain(&mut app, K::Enter); let status = await_export(&mut app); assert!( matches!(&status, ExportStatus::Failed(e) if e.contains("Runtime-Vorlage fehlt")), "{status:?}" ); assert!(!out.exists()); assert!(app.dialog.is_some()); } #[test] fn debugger_reports_absent_library_source_and_can_step_back_to_caller() { let t = Temp::new(); let path = t.0.join("lib.bas"); fs::write(&path, "SUB Answer\nPRINT 42\nEND SUB\n").unwrap(); let mut compiler = tb_vm::project::ProjectCompiler::default(); compiler.debug_symbols = true; let bytes = tb_vm::project_io::SourceLoader::default() .load(&path) .unwrap() .compile_library(&mut compiler) .unwrap() .to_tbl() .unwrap(); fs::write(t.0.join("lib.tbl"), bytes).unwrap(); fs::remove_file(&path).unwrap(); fs::write(t.0.join("main.bas"), "CALL Answer\nPRINT 9\nEND\n").unwrap(); fs::write(t.0.join("main.mak"), "main.bas\nlib.tbl\n").unwrap(); let mut app = t.app(); app.load_initial_project(t.0.join("main.mak")).unwrap(); let documents = app.project.documents().count(); let mut missing = false; for _ in 0..10 { app.execute(Command::Step); for n in 0..100 { app.tick_execution(n); if app.execution != Execution::Running { break; } } if app.message.contains("Bibliotheksquelle nicht verfügbar") { missing = true; break; } } assert!(missing, "{}", app.message); assert_eq!(app.project.documents().count(), documents); assert!(!path.exists()); app.execute(Command::ProcedureStep); for n in 0..100 { app.tick_execution(n); if app.execution != Execution::Running { break; } } app.execute(Command::Continue); for n in 0..100 { app.tick_execution(n); if app.execution == Execution::Ended { break; } } assert_eq!(app.execution, Execution::Ended, "{}", app.message); assert_eq!( tb_runtime::snapshot::text(app.session.screen()), " 42 \n 9 \n" ); } #[test] fn finalization_failure_is_reported_and_preserves_existing_output() { let t = Temp::new(); let mut app = t.app(); let target = tb_export::Target::MacosArm64; app.runtime_dir = t.0.join("runtimes"); let template = app .runtime_dir .join(target.triple()) .join(target.runtime_name()); fs::create_dir_all(template.parent().unwrap()).unwrap(); // Header für den negativen Finalisierungstest, kein ausführbarer Zielnachweis. let mut header = vec![0; 256]; header[..4].copy_from_slice(&[0xcf, 0xfa, 0xed, 0xfe]); header[4..8].copy_from_slice(&0x0100000cu32.to_le_bytes()); header[12..16].copy_from_slice(&2u32.to_le_bytes()); header.extend_from_slice(&tb_export::runtime_marker(target)); fs::write(&template, header).unwrap(); tb_export::prepare_template(&template, target).unwrap(); menu(&mut app, Command::MakeExe); for (n, value) in [(1, "macos"), (2, "aarch64")] { while app.dialog.as_ref().unwrap().focus != n { plain(&mut app, K::Tab); } while app.dialog.as_ref().unwrap().fields[n].string() != value { plain(&mut app, K::Right); } } let out = t.0.join("existing"); fs::write(&out, b"old").unwrap(); field(&mut app, 3, &out.display().to_string()); plain(&mut app, K::Tab); plain(&mut app, K::Char(' ')); plain(&mut app, K::Enter); let status = await_export(&mut app); assert!( matches!(&status,ExportStatus::Failed(e) if e.contains("Finalisierung")), "{status:?}" ); assert_eq!(fs::read(&out).unwrap(), b"old"); assert!(fs::read_dir(&t.0).unwrap().all(|p| !p .unwrap() .file_name() .to_string_lossy() .starts_with(".tb-export-"))); } // Prüft tatsächlich gerenderte Zellen, einschließlich geerbter Hintergründe. fn theme_rgb(color: ratatui::style::Color) -> (u8, u8, u8) { use ratatui::style::Color; match color { Color::Rgb(r, g, b) => (r, g, b), Color::Indexed(n @ 16..=231) => { let n = n - 16; let channel = |v: u8| if v == 0 { 0 } else { 55 + v * 40 }; (channel(n / 36), channel(n / 6 % 6), channel(n % 6)) } Color::Indexed(n @ 232..=255) => { let v = 8 + (n - 232) * 10; (v, v, v) } _ => { let n = (0..16) .find(|n| tb_ide::render::dos_color(*n, 16) == color) .unwrap_or_else(|| panic!("Unbestimmte IDE-Farbe: {color:?}")); theme_rgb(tb_ide::render::dos_color(n, u16::MAX)) } } } fn theme_contrast(fg: ratatui::style::Color, bg: ratatui::style::Color) -> f64 { let luminance = |color| { let (r, g, b) = theme_rgb(color); let linear = |v: u8| { let v = f64::from(v) / 255.0; if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } }; 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b) }; let (a, b) = (luminance(fg), luminance(bg)); (a.max(b) + 0.05) / (a.min(b) + 0.05) } fn assert_theme(app: &mut App, context: &str) { let (_, b) = draw(app); for (i, c) in b.content.iter().enumerate() { if !c.symbol().trim().is_empty() { let ratio = theme_contrast(c.fg, c.bg); assert!( ratio >= 4.5, "{context} ({},{}): {:?} {:?}/{:?}: {ratio:.2}:1", i % app.size.0 as usize, i / app.size.0 as usize, c.symbol(), c.fg, c.bg ); } } } #[test] fn theme_contrast_covers_rendered_states_and_rejects_bad_pairs() { use tb_ide::render::dos_color; for colors in [256, u16::MAX] { for (fg, bg) in tb_ide::options::Options::default().colors { assert!(theme_contrast(dos_color(fg, colors), dos_color(bg, colors)) >= 4.5); } assert!(theme_contrast(dos_color(15, colors), dos_color(7, colors)) < 4.5); for n in 0..16 { let fg = if matches!(n, 0 | 1 | 4 | 5 | 6 | 8 | 9) { 15 } else { 0 }; assert!( theme_contrast(dos_color(fg, colors), dos_color(n, colors)) >= 4.5, "Palettenbeschriftung {n}" ); } } let t = Temp::new(); let mut app = t.app(); app.options.syntax_checking = false; type_text(&mut app, "PRINT 42"); assert_theme(&mut app, "Editor"); key(&mut app, K::Char('a'), M::CONTROL); assert_theme(&mut app, "Textauswahl"); for m in 0..app.menus().len() { for i in 0..app.menu_entries(m).len() { app.menu = Some((m, i)); assert_theme(&mut app, "Menü einschließlich deaktivierter Einträge"); } } app.menu = None; for command in [ Command::Display, Command::Paths, Command::MakeExe, Command::MakeLibrary, Command::Find, Command::Replace, Command::About, ] { app.execute(command); assert_theme(&mut app, "Dialog"); if let Some(d) = &mut app.dialog { d.error = "Prüffehler: ungültiger Wert".into(); } assert_theme(&mut app, "Dialogfehler"); app.dialog = None; } for command in [ Command::Project, Command::Debug, Command::Calls, Command::HelpContents, Command::Keyboard, Command::UsingHelp, ] { app.execute(command); assert_theme(&mut app, "Projekt/Debugger/Hilfe"); plain(&mut app, K::Tab); assert_theme(&mut app, "Fokus/Link"); } let path = t.0.join("debug-colors.bas"); fs::write(&path, "x%=1\nx%=2\nPRINT x%\nEND\n").unwrap(); let mut debugger = t.app(); debugger.load_initial_project(path).unwrap(); plain(&mut debugger, K::Down); debugger.execute(Command::Breakpoint); assert_theme(&mut debugger, "Ungebundener Breakpoint"); debugger.execute(Command::Start); for _ in 0..30 { debugger.tick_execution(0); if debugger.execution == tb_ide::app::Execution::Paused { break; } } assert_eq!(debugger.execution, tb_ide::app::Execution::Paused); assert_theme(&mut debugger, "Gebundener Breakpoint und Ausführungszeile"); let (_, marks) = draw(&mut debugger); assert!(marks .content .iter() .any(|c| c.symbol() == "▶" && c.bg == tb_ide::render::dos(14))); // Ein absichtlich schlechtes Standardpaar muss auch die Zellprüfung brechen. let mut bad = t.app(); bad.options.colors[3] = (15, 7); assert!( std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| assert_theme( &mut bad, "Negativprobe" ))) .is_err() ); } #[test] fn theme_color_commands_use_explicit_values_and_never_modify_terminal_palette() { crossterm::style::force_color_output(true); use ratatui::{ backend::{Backend, CrosstermBackend}, buffer::Cell, style::Color, }; use tb_ide::render::dos_color; assert_eq!(dos_color(1, u16::MAX), Color::Rgb(0, 0, 170)); assert_eq!(dos_color(5, u16::MAX), Color::Rgb(170, 0, 170)); assert_eq!(dos_color(6, u16::MAX), Color::Rgb(170, 85, 0)); for colors in [16, 256, u16::MAX] { for n in 0..16 { let color = dos_color(n, colors); if let Color::Indexed(index) = color { assert!(index >= 16); } let mut cell = Cell::default(); cell.set_symbol("X") .set_bg(color) .set_fg(dos_color(15, colors)); let mut output = Vec::new(); CrosstermBackend::new(&mut output) .draw(std::iter::once((0, 0, &cell))) .unwrap(); let sequence = String::from_utf8(output).unwrap(); assert!(sequence.contains('X')); if colors >= 256 { assert!(sequence.contains("48;")); } if colors > 256 { assert!(sequence.contains("48;2;")); } assert!(!sequence.contains("\x1b]")); // Keine globale Palettenänderung. } } } #[test] fn theme_designer_chrome_and_handles_preserve_program_palette() { let t = Temp::new(); let mut app = t.app(); app.execute(Command::NewForm); let root = app.design_objects().unwrap()[0].0; app.design_select(root, false).unwrap(); let (_, b) = draw(&mut app); let handles: Vec<_> = b.content.iter().filter(|c| c.symbol() == "■").collect(); assert!(!handles.is_empty()); for c in handles { assert_eq!( (c.fg, c.bg), (tb_ide::render::dos(0), tb_ide::render::dos(14)) ); assert!(theme_contrast(c.fg, c.bg) >= 4.5); } let form = app.design_form().unwrap().clone(); let (_, screen) = tb_ide::designer::preview(&form, (100, 30)).unwrap(); let mut buf = ratatui::buffer::Buffer::empty(ratatui::layout::Rect::new(0, 0, 100, 30)); ratatui::widgets::Widget::render(tb_ui::screen::ScreenWidget(&screen), buf.area, &mut buf); for (i, c) in buf.content.iter().enumerate() { let original = screen.cell(i / 100 + 1, i % 100 + 1); assert_eq!(c.bg, tb_ui::screen::basic_color(original.bg)); } // Vorschau minimieren: ab hier gehören alle sichtbaren Zellen zur IDE. app.execute(Command::Minimize); for command in [Command::Toolbox, Command::Palette, Command::MenuDesign] { app.execute(command); assert_theme(&mut app, "Designer-Werkzeug"); } }