Files
TerminalBasic/crates/tb-ide/tests/app.rs

1152 lines
38 KiB
Rust

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::<String>())
.collect::<Vec<_>>()
.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::<Vec<_>>(),
["File", "Edit", "View", "Search", "Run", "Debug", "Options", "Window", "Help"]
);
assert_eq!(
commands::menus(true)
.iter()
.map(|m| m.title)
.collect::<Vec<_>>(),
["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::<Vec<_>>(),
[
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("<F6=Window>"));
}
#[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");
}
}