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, Execution, Field, WindowKind}, commands::Command, }; use tb_runtime::{snapshot, value::Value}; struct Temp(PathBuf); impl Temp { fn new() -> Self { static N: AtomicUsize = AtomicUsize::new(0); let p = std::env::temp_dir().join(format!( "tb-execution-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed) )); fs::create_dir_all(&p).unwrap(); Self(p.canonicalize().unwrap()) } fn app(&self, source: &str) -> App { let path = self.0.join("main.bas"); fs::write(&path, source).unwrap(); let mut app = App::new(&self.0, self.0.join("options"), (100, 30)).unwrap(); app.load_initial_project(path).unwrap(); app.options.syntax_checking = false; app } } 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 tick(a: &mut App) { for _ in 0..20 { a.tick_execution(0); } } fn output(a: &App) -> String { snapshot::text(a.session.screen()) } fn edit_window(a: &mut App) { let id = a .windows .iter() .find(|w| matches!(w.kind, WindowKind::Code(_))) .unwrap() .id; a.execute(Command::FocusWindow(id)); } #[test] fn f5_input_pause_copy_and_single_delivery_keep_the_same_vm() { let t = Temp::new(); let mut a = t.app("LINE INPUT \"Name: \"; s$\nPRINT s$\nEND\n"); key(&mut a, K::F(5), M::NONE); tick(&mut a); assert_eq!(a.execution, Execution::Waiting); key(&mut a, K::Char('a'), M::NONE); tick(&mut a); edit_window(&mut a); key(&mut a, K::Char('a'), M::CONTROL); key(&mut a, K::Char('c'), M::CONTROL); assert!(a.editor.clipboard.starts_with("LINE INPUT")); assert_eq!(a.execution, Execution::Waiting); key(&mut a, K::Pause, M::CONTROL); assert_eq!(a.execution, Execution::Paused); assert!(a.message.contains("main.bas")); key(&mut a, K::F(5), M::NONE); assert!(a.dialog.is_none()); a.execute(Command::Output); key(&mut a, K::Char('b'), M::NONE); key(&mut a, K::Enter, M::NONE); tick(&mut a); assert_eq!(a.execution, Execution::Ended, "{}", a.message); assert_eq!(output(&a), "Name: ? ab\nab\n"); assert!(!a.quit); assert!(a.session.vm.is_none()); key(&mut a, K::F(5), M::SHIFT); tick(&mut a); key(&mut a, K::Char('c'), M::CONTROL); tick(&mut a); assert_eq!(a.execution, Execution::Paused); assert_eq!(output(&a), "Name: ? \n"); } #[test] fn changed_paused_source_requires_explicit_old_revision_or_restart() { let t = Temp::new(); let mut a = t.app("STOP\nPRINT \"old\"\nEND\n"); a.execute(Command::Start); tick(&mut a); assert_eq!(a.execution, Execution::Paused); let id = a.project.members()[0]; a.project .replace_text( id, 0..a.project.document(id).unwrap().code().len(), "STOP\nPRINT \"new\"\nEND\n", ) .unwrap(); a.execute(Command::Continue); assert!(matches!( a.dialog.as_ref().unwrap().kind, DialogKind::ResumeRevision )); a.dialog.as_mut().unwrap().fields[0] = Field::choice("Fortsetzen", vec!["restart".into(), "old".into()], 1); key(&mut a, K::Enter, M::NONE); assert!(a.session.old_revision); assert!(a .title(a.active_window().unwrap()) .contains("ALTES KOMPILAT")); tick(&mut a); assert_eq!(output(&a), "old\n"); a.execute(Command::Restart); tick(&mut a); a.project .replace_text( id, 0..a.project.document(id).unwrap().code().len(), "' comment\nPRINT \"latest\"\nEND\n", ) .unwrap(); a.execute(Command::Continue); key(&mut a, K::Enter, M::NONE); tick(&mut a); assert_eq!(output(&a), "latest\n"); assert!(!a.session.old_revision); a.project .replace_text( id, 0..a.project.document(id).unwrap().code().len(), "PRINT (\n", ) .unwrap(); a.execute(Command::Start); assert_eq!(a.execution, Execution::Error); assert!(a.session.vm.is_none()); } #[test] fn output_viewports_resize_and_end_preserve_buffer_and_basic_colors() { let t = Temp::new(); let mut a = t.app("COLOR 4,1\nPRINT \"visible\"\nSTOP\nEND\n"); a.execute(Command::Start); tick(&mut a); let original = output(&a); let size = (a.session.screen().cols(), a.session.screen().rows()); let active = a.active; let w = a.windows.iter_mut().find(|w| w.id == active).unwrap(); w.normal = ratatui::layout::Rect::new(3, 3, 30, 8); let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 30)).unwrap(); terminal.draw(|f| a.render(f)).unwrap(); assert_eq!(terminal.backend().buffer()[(4, 4)].symbol(), "v"); assert_eq!( terminal.backend().buffer()[(4, 4)].fg, tb_ui::screen::basic_color(4) ); key(&mut a, K::F(4), M::NONE); terminal.draw(|f| a.render(f)).unwrap(); assert_eq!(terminal.backend().buffer()[(0, 0)].symbol(), "v"); assert_eq!((a.session.screen().cols(), a.session.screen().rows()), size); a.handle(Event::Resize(120, 40)); assert_eq!( (a.session.screen().cols(), a.session.screen().rows()), (120, 40) ); key(&mut a, K::F(4), M::NONE); a.execute(Command::Continue); tick(&mut a); assert_eq!(a.execution, Execution::Ended); assert_eq!(output(&a), original); } #[test] fn restart_command_run_targets_and_missing_target_preserve_documents() { let t = Temp::new(); let mut a = t.app("PRINT COMMAND$\nn%=n%+1\nSTOP\nEND\n"); a.execute(Command::CommandLine); a.dialog.as_mut().unwrap().fields[0] = Field::text("args", "one two"); key(&mut a, K::Enter, M::NONE); for _ in 0..3 { a.execute(Command::Restart); tick(&mut a); assert_eq!(output(&a), "one two\n"); assert!(matches!( a.session.vm.as_ref().unwrap().inspect("n"), Some(Value::Int(1)) )); } let id = a.project.members()[0]; let path = a.project.document(id).unwrap().source_path().to_path_buf(); fs::write(t.0.join("other.bas"), "PRINT \"foreign\"\nEND\n").unwrap(); a.project .replace_text( id, 0..a.project.document(id).unwrap().code().len(), "RUN \"OTHER\"\n", ) .unwrap(); a.execute(Command::Start); tick(&mut a); assert_eq!(output(&a), "foreign\n"); assert_eq!(a.project.document(id).unwrap().source_path(), path); assert_eq!(a.project.document(id).unwrap().code(), "RUN \"OTHER\"\n"); a.project .replace_text( id, 0..a.project.document(id).unwrap().code().len(), "RUN \"missing\"\n", ) .unwrap(); a.execute(Command::Start); tick(&mut a); assert_eq!(a.execution, Execution::Error); assert!(a.message.contains("nicht gefunden")); assert_eq!(a.project.document(id).unwrap().code(), "RUN \"missing\"\n"); assert!(!a.quit); a.project .replace_text( id, 0..a.project.document(id).unwrap().code().len(), "10 PRINT \"skip\"\n20 STOP\n30 RUN 40\n40 PRINT \"target\"\n50 END\n", ) .unwrap(); a.execute(Command::Start); tick(&mut a); a.execute(Command::Continue); tick(&mut a); assert_eq!(output(&a), "target\n"); } #[test] fn print_uses_utf8_protected_output_and_does_not_mutate_source() { let t = Temp::new(); let mut a = t.app("PRINT \"Grüße\"\n"); let id = a.project.members()[0]; let WindowKind::Code(v) = a.active_window().unwrap().kind else { panic!() }; a.project.view_mut(v).unwrap().anchor = Some(0); a.project.view_mut(v).unwrap().cursor = 5; a.execute(Command::Print); assert!(a.dialog.as_ref().unwrap().fields[0] .string() .ends_with("LPT1.TXT")); key(&mut a, K::Enter, M::NONE); assert_eq!(fs::read_to_string(t.0.join("LPT1.TXT")).unwrap(), "PRINT"); a.execute(Command::Print); key(&mut a, K::Enter, M::NONE); assert!(!a.dialog.as_ref().unwrap().error.is_empty()); a.dialog.as_mut().unwrap().fields[1] = Field::toggle("overwrite", true); a.dialog.as_mut().unwrap().fields[2] = Field::toggle("selection", false); key(&mut a, K::Enter, M::NONE); assert_eq!( fs::read_to_string(t.0.join("LPT1.TXT")).unwrap(), "PRINT \"Grüße\"\n" ); a.execute(Command::Print); a.dialog.as_mut().unwrap().fields[0] = Field::text("path", t.0.join("main.bas").display().to_string()); a.dialog.as_mut().unwrap().fields[1] = Field::toggle("overwrite", true); key(&mut a, K::Enter, M::NONE); assert!(!a.dialog.as_ref().unwrap().error.is_empty()); assert_eq!(a.project.document(id).unwrap().code(), "PRINT \"Grüße\"\n"); assert_eq!( fs::read_to_string(t.0.join("main.bas")).unwrap(), "PRINT \"Grüße\"\n" ); key(&mut a, K::Esc, M::NONE); fs::write(t.0.join("blocked"), "existing destination parent").unwrap(); a.execute(Command::Print); a.dialog.as_mut().unwrap().fields[0] = Field::text("path", t.0.join("blocked/print.txt").display().to_string()); key(&mut a, K::Enter, M::NONE); assert!(!a.dialog.as_ref().unwrap().error.is_empty()); assert_eq!( fs::read_to_string(t.0.join("blocked")).unwrap(), "existing destination parent" ); assert_eq!(a.project.document(id).unwrap().code(), "PRINT \"Grüße\"\n"); } #[test] fn shell_request_is_resumed_once_and_error_is_basic_error() { let t = Temp::new(); let mut a = t.app("PRINT SHELL(\"exit 7\")\nEND\n"); a.execute(Command::Start); tick(&mut a); assert_eq!( a.session.host.shell_request.take().as_deref(), Some("exit 7") ); a.session.host.shell_result = Some(Ok(7)); tick(&mut a); assert_eq!(output(&a), " 7 \n"); assert!(a.session.host.shell_request.is_none()); a.execute(Command::Start); tick(&mut a); a.session.host.shell_request = None; a.session.host.shell_result = Some(Err(tb_runtime::errors::RuntimeError(53))); tick(&mut a); assert_eq!(a.execution, Execution::Error); assert!(a.message.contains("53")); } #[test] fn startup_selection_matches_shared_cli_pipeline_and_legacy_mak() { use tb_vm::project_io::{new_execution, SourceLoader}; let t = Temp::new(); fs::write(t.0.join("a.bas"), "PRINT \"A\"\n").unwrap(); fs::write(t.0.join("b.bas"), "PRINT \"B\"\n").unwrap(); for (file, name) in [("one.frm", "One"), ("two.frm", "Two")] { fs::write( t.0.join(file), format!("VERSION 1.00\nBEGIN Form {name}\nEND\n"), ) .unwrap(); } for startup in [None, Some("b.bas"), Some("two.frm")] { fs::write( t.0.join("p.mak"), format!( "a.bas\nb.bas\none.frm\ntwo.frm\n{}", startup .map(|s| format!("' $STARTUP: \"{s}\"\n")) .unwrap_or_default() ), ) .unwrap(); let mut a = App::new(&t.0, t.0.join("options"), (80, 25)).unwrap(); a.load_initial_project(t.0.join("p.mak")).unwrap(); a.session.command = "args".into(); a.execute(Command::Start); let sources = SourceLoader::default().load(&t.0.join("p.mak")).unwrap(); let module = sources.compile(&mut Default::default(), "CLI").unwrap(); let module = tb_vm::bytecode::CompiledModule::from_tbc(&module.to_tbc()).unwrap(); let mut cli = new_execution(module, "args", Some((80, 25)), None).unwrap(); let ide = a.session.vm.as_ref().unwrap(); assert_eq!(ide.rt.command, cli.rt.command); assert_eq!(ide.forms.active_form(), cli.forms.active_form()); assert_eq!(ide.forms.has_visible_forms(), startup != Some("b.bas")); assert_eq!( sources.units[0].name.to_ascii_uppercase(), if startup == Some("two.frm") { "TWO" } else if startup == Some("b.bas") { "B" } else { "A" } ); if startup == Some("b.bas") { tick(&mut a); assert_eq!( cli.run(&mut tb_runtime::host::CaptureHost::default()), tb_vm::interp::RunEvent::Ended ); assert_eq!(output(&a), "B\nA\n"); assert_eq!(output(&a), snapshot::text(&cli.rt.screen)); } } } #[test] fn ide_and_cli_run_wrapper_match_input_time_and_forms_script() { let t = Temp::new(); let form="VERSION 1.00\nBEGIN Form Form1\n BEGIN Timer Timer1\n END\nEND\nTimer1.Interval=100\nTimer1.Enabled=-1\nSLEEP 10\nForm1.Hide\nCLS\nLINE INPUT s$\nPRINT s$; COMMAND$\nEND\nSUB Timer1_Timer\nTimer1.Enabled=0\nEND SUB\n"; fs::write(t.0.join("form.frm"), form).unwrap(); let mut a = App::new(&t.0, t.0.join("options"), (80, 25)).unwrap(); a.load_initial_project(t.0.join("form.frm")).unwrap(); a.session.command = "!".into(); a.execute(Command::Start); a.tick_execution(0); assert_eq!(a.execution, Execution::Waiting); a.tick_execution(100); a.handle(Event::Paste("hello\n".into())); for _ in 0..20 { a.tick_execution(100); } assert_eq!(a.execution, Execution::Ended, "{}", a.message); // CLI wrapper gets the same scripted input at the same host deadline. struct ScriptHost { now: u64, events: std::collections::VecDeque, } impl tb_runtime::host::Host for ScriptHost { fn present(&mut self, _: &tb_runtime::screen::TextScreen) {} fn next_event(&mut self, _: bool) -> Option { self.events.pop_front() } fn jetzt_ms(&mut self) -> u64 { self.now } fn warten(&mut self, deadline: Option) -> Option { if let Some(d) = deadline { self.now = d; None } else { self.events.extend( "hello\r" .chars() .map(|c| tb_runtime::host::Ereignis::Taste(c.to_string(), 0)), ); self.events.pop_front() } } } let module = tb_vm::project_io::load_program(&t.0.join("form.frm")).unwrap(); let mut cli = tb_vm::project_io::new_execution(module, "!", Some((80, 25)), None).unwrap(); assert_eq!( cli.run(&mut ScriptHost { now: 0, events: Default::default() }), tb_vm::interp::RunEvent::Ended ); assert_eq!(output(&a), snapshot::text(&cli.rt.screen)); assert_eq!(output(&a), "? hello\nhello!\n"); } #[test] fn restart_drops_files_data_traps_and_queued_actions() { let t = Temp::new(); let mut a=t.app(&format!("DATA 7\nSTOP\nREAD n%\nOPEN \"{}\" FOR OUTPUT AS #1\nON TIMER(1) GOSUB H\nTIMER ON\nSTOP\nEND\nH:\nRETURN\n",t.0.join("opened.txt").display())); a.execute(Command::Shell); assert!(a.session.file_shell); a.execute(Command::Start); tick(&mut a); assert!(!a.session.file_shell); assert!(a.session.host.shell_request.is_none()); for _ in 0..3 { let vm = a.session.vm.as_ref().unwrap(); assert!(!vm.rt.dateien.ist_offen(1)); assert_eq!(vm.rt.traps.naechste_frist(), None); assert!(matches!(vm.inspect("n"), Some(Value::Int(0)))); a.execute(Command::Continue); tick(&mut a); let vm = a.session.vm.as_ref().unwrap(); assert!(vm.rt.dateien.ist_offen(1)); assert!(vm.rt.traps.naechste_frist().is_some()); assert!(matches!(vm.inspect("n"), Some(Value::Int(7)))); a.session .host .events .push_back(tb_runtime::host::Ereignis::Taste("stale".into(), 0)); a.execute(Command::Restart); tick(&mut a); assert!(a.session.host.events.is_empty()); } } #[test] fn named_run_of_current_target_uses_edited_sources() { let t = Temp::new(); let mut a = t.app("PRINT \"disk\"\nEND\n"); let id = a.project.members()[0]; a.project .replace_text( id, 0..a.project.document(id).unwrap().code().len(), "RUN \"MAIN.BAS\"\n", ) .unwrap(); a.execute(Command::Start); a.tick_execution(0); assert!(a.session.project_target); a.project .replace_text( id, 0..a.project.document(id).unwrap().code().len(), "PRINT \"edited\"\nEND\n", ) .unwrap(); tick(&mut a); assert_eq!(output(&a), "edited\n"); } #[test] fn explicit_end_and_system_end_visible_forms_but_natural_end_keeps_them_live() { let t = Temp::new(); for ending in ["END", "SYSTEM", ""] { let path = t.0.join("form.frm"); fs::write( &path, format!("VERSION 1.00\nBEGIN Form Form1\nEND\n{ending}\n"), ) .unwrap(); let mut a = App::new(&t.0, t.0.join("options"), (80, 25)).unwrap(); a.load_initial_project(path).unwrap(); a.execute(Command::Start); assert!(a.session.vm.as_ref().unwrap().forms.has_visible_forms()); tick(&mut a); if ending.is_empty() { assert_eq!(a.execution, Execution::Waiting); key(&mut a, K::F(4), M::NONE); assert!(a.session.fullscreen); a.handle(Event::Resize(40, 10)); key(&mut a, K::Pause, M::CONTROL); assert_eq!(a.execution, Execution::Paused); a.handle(Event::Resize(80, 25)); key(&mut a, K::F(5), M::NONE); tick(&mut a); assert_eq!(a.execution, Execution::Waiting); assert!(a.session.vm.as_ref().unwrap().forms.has_visible_forms()); } else { assert_eq!(a.execution, Execution::Ended); assert!(a.session.vm.is_none()); } assert!(!a.quit); } } #[test] fn comment_only_source_change_also_requires_revision_choice() { let t = Temp::new(); let mut a = t.app("STOP ' old\nPRINT 1\nEND\n"); a.execute(Command::Start); tick(&mut a); let compiled = a.current_compilation().unwrap().to_tbc(); let id = a.project.members()[0]; a.project .replace_text( id, 0..a.project.document(id).unwrap().code().len(), "STOP ' new\nPRINT 1\nEND\n", ) .unwrap(); assert_eq!(a.compile_current().unwrap().to_tbc(), compiled); a.execute(Command::Continue); assert!(matches!( a.dialog.as_ref().unwrap().kind, DialogKind::ResumeRevision )); } #[test] fn platform_probe_records_each_basic_key_once_and_keeps_ide_keys_out() { let t = Temp::new(); let log = t.0.join("platform-events.log"); let source = include_str!("../../../tests/platform/input.frm") .replace("platform-events.log", &log.to_string_lossy()); let path = t.0.join("input.frm"); fs::write(&path, source).unwrap(); let mut a = t.app(""); a.load_initial_project(path).unwrap(); a.execute(Command::Start); tick(&mut a); key(&mut a, K::Char('a'), M::NONE); tick(&mut a); a.handle(Event::Key(KeyEvent::new_with_kind( K::Char('a'), M::NONE, crossterm::event::KeyEventKind::Release, ))); tick(&mut a); let events = fs::read_to_string(&log).unwrap(); assert_eq!( events.replace('\r', ""), "Down: 97 : 0 \nPress: 97 : 0 \nUp: 97 : 0 \n" ); edit_window(&mut a); key(&mut a, K::F(1), M::NONE); tick(&mut a); assert_eq!(fs::read_to_string(&log).unwrap(), events); assert!(matches!(a.active_window().unwrap().kind, WindowKind::Help)); }