Files
TerminalBasic/crates/tb-ide/tests/app.rs
Chili Palmer 5a1a284505 Phase 6-07a: Fensterlokale Control-Menüs und Windows-Testkorrekturen
Control-Menüs öffnen für alle Fensterarten am [≡]-Symbol des aktiven
Fensters: eigener Auswahlzustand statt Kopplung an die Menüleiste,
bevorzugt unter der Titelzeile, bei Platzmangel nach oben aufgeklappt,
am rechten Rand nur so weit nach links wie nötig. Regressionstest über
TestBackend für Code-, Projekt-, Output-, Immediate-, Debug- und
Hilfefenster, maximiert, minimiert, Rand und Maus.

Nebenbefunde der Windows-Testausführung: Temporärdatei vor sync_all
schreibend öffnen (Zugriff verweigert), relative Projektverweise immer
mit / schreiben, zeilenendenneutrale Vergleiche in Referenzmatrix-,
Kompatibilitäts- und MAK-Beispieltests, DriveListBox-Zeichenbild
plattformneutral. Korpus-Sollausgaben per .gitattributes auf LF.

Specs ide-oberflaeche und ide-projekte synchronisiert, Change archiviert.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 11:02:40 +02:00

1362 lines
46 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.is_some());
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),
_ => panic!("Keine absolute RGB-Farbe: {color:?}"),
}
}
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;
for (fg, bg) in tb_ide::options::Options::default().colors {
assert!(theme_contrast(dos(fg), dos(bg)) >= 4.5);
}
assert!(theme_contrast(dos(15), dos(7)) < 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(fg), dos(n)) >= 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;
assert_eq!(dos(1), Color::Rgb(0, 0, 170));
assert_eq!(dos(5), Color::Rgb(170, 0, 170));
assert_eq!(dos(6), Color::Rgb(170, 85, 0));
for n in 0..16 {
assert_eq!(dos(n), tb_ui::screen::basic_color(n));
let mut cell = Cell::default();
cell.set_symbol("X").set_bg(dos(n)).set_fg(dos(15));
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'));
assert!(sequence.contains("38;2;255;255;255"));
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");
}
}
#[test]
fn theme_windows_erase_desktop_and_underlying_windows_on_every_redraw() {
use ratatui::{
layout::Rect,
style::{Color, Modifier},
};
let t = Temp::new();
let mut app = t.app();
app.options.syntax_checking = false;
// Ein sichtbares Zeichen macht das Durchscheinen sicher erkennbar.
app.options.desktop = '@';
type_text(&mut app, "PRINT 42");
let mut term = Terminal::new(TestBackend::new(app.size.0, app.size.1)).unwrap();
for selected in [false, true, false] {
if selected {
key(&mut app, K::Char('a'), M::CONTROL);
} else {
plain(&mut app, K::Right);
}
term.draw(|f| app.render(f)).unwrap();
let b = term.backend().buffer();
let rect = app.rect(app.windows.iter().find(|w| w.id == app.active).unwrap());
let inner = Rect::new(rect.x + 1, rect.y + 1, rect.width - 2, rect.height - 2);
for y in inner.y..inner.bottom() {
for x in inner.x..inner.right() {
let c = &b[(x, y)];
assert_ne!(c.symbol(), "@", "Desktop in Fenster bei {x}/{y}");
assert_eq!(c.bg, Color::Rgb(0, 0, 170));
if y > inner.y {
assert_eq!(c.symbol(), " ", "Restinhalt bei {x}/{y}");
assert!(!c.modifier.contains(Modifier::REVERSED));
}
}
}
assert_eq!(b[(inner.x, inner.y)].symbol(), "P");
assert_eq!(
b[(inner.x, inner.y)].modifier.contains(Modifier::REVERSED),
selected
);
}
// Beim Aktivieren eines überlappenden Fensters müssen auch fremde Texte weg.
app.execute(Command::NewModule);
plain(&mut app, K::Enter);
assert!(app.dialog.is_none());
term.draw(|f| app.render(f)).unwrap();
let b = term.backend().buffer();
let rect = app.rect(app.windows.iter().find(|w| w.id == app.active).unwrap());
for y in rect.y + 1..rect.bottom() - 1 {
for x in rect.x + 1..rect.right() - 1 {
assert_eq!(
b[(x, y)].symbol(),
" ",
"Überdeckter Fensterinhalt bei {x}/{y}"
);
}
}
}
/// Sucht die Zelle, an der `text` in der Ausgabe beginnt.
fn find_text(b: &ratatui::buffer::Buffer, text: &str) -> Option<(u16, u16)> {
let chars: Vec<String> = text.chars().map(|c| c.to_string()).collect();
for y in 0..b.area.height {
for x in 0..b.area.width.saturating_sub(chars.len() as u16) {
if chars
.iter()
.enumerate()
.all(|(i, c)| b[(x + i as u16, y)].symbol() == c)
{
return Some((x, y));
}
}
}
None
}
/// Öffnet das Control-Menü per Alt+Minus und prüft die Anheftregel: linke Spalte gleich
/// Symbolspalte (oder so weit links, dass es passt und das Symbol überdeckt), Rahmen grenzt
/// unten oder oben an die Titelzeile, vollständig in der Arbeitsfläche, kein Menüleistentitel
/// hervorgehoben. Liefert das Rechteck des Aufklappbereichs.
fn assert_control_menu_attached(app: &mut App, context: &str) -> ratatui::layout::Rect {
let r = app.rect(app.active_window().unwrap());
let area = app.area();
key(app, K::Char('-'), M::ALT);
assert!(app.control_menu.is_some(), "{context}: Control-Menü offen");
assert_eq!(
app.menu, None,
"{context}: Menüleiste darf nicht ausgewählt sein"
);
let (_, b) = draw(app);
assert_eq!(
(b[(1, 0)].fg, b[(1, 0)].bg),
(tb_ide::render::dos(0), tb_ide::render::dos(7)),
"{context}: „File“ darf nicht hervorgehoben sein"
);
let (rx, ry) = find_text(&b, "Restore").unwrap_or_else(|| panic!("{context}: Restore"));
let (_, cy) = find_text(&b, "Close").unwrap_or_else(|| panic!("{context}: Close"));
let (x, top, bottom) = (rx - 1, ry - 1, cy + 1);
let mut right = x + 1;
while right < b.area.width && b[(right, top)].symbol() != "" {
right += 1;
}
let popup = ratatui::layout::Rect::new(x, top, right + 1 - x, bottom + 1 - top);
assert_eq!(b[(x, top)].symbol(), "", "{context}: linke obere Ecke");
assert_eq!(b[(x, bottom)].symbol(), "", "{context}: linke untere Ecke");
assert!(
popup.y >= area.y && popup.bottom() <= area.bottom() && popup.right() <= area.right(),
"{context}: Popup {popup:?} außerhalb der Arbeitsfläche {area:?}"
);
assert!(
popup.x <= r.x && r.x < popup.right(),
"{context}: Popup {popup:?} überdeckt die Symbolspalte von {r:?} nicht"
);
if r.x + popup.width <= area.right() {
assert_eq!(popup.x, r.x, "{context}: linke Spalte gleich Symbolspalte");
} else {
assert_eq!(
popup.right(),
area.right(),
"{context}: nur so weit links wie nötig"
);
}
if popup.height <= area.bottom() - (r.y + 1) {
assert_eq!(popup.y, r.y + 1, "{context}: direkt unter der Titelzeile");
} else {
assert_eq!(
popup.bottom(),
r.y,
"{context}: nach oben aufgeklappt bis an die Titelzeile"
);
}
popup
}
#[test]
fn control_menus_attach_to_the_symbol_of_every_window() {
let t = Temp::new();
let mut app = t.app();
let area = app.area();
let code = app.active;
let p = assert_control_menu_attached(&mut app, "Code");
let r = app.rect(app.active_window().unwrap());
assert_eq!((p.x, p.y), (r.x, r.y + 1));
plain(&mut app, K::Esc);
assert!(app.control_menu.is_none());
assert_eq!(app.active, code);
plain(&mut app, K::F(6));
assert_eq!(app.active_window().unwrap().kind, WindowKind::Project);
assert_control_menu_attached(&mut app, "Project");
plain(&mut app, K::Esc);
menu(&mut app, Command::Output);
assert_eq!(app.active_window().unwrap().kind, WindowKind::Output);
assert_control_menu_attached(&mut app, "Output");
plain(&mut app, K::Esc);
menu(&mut app, Command::Immediate);
assert_eq!(app.active_window().unwrap().kind, WindowKind::Immediate);
assert_control_menu_attached(&mut app, "Immediate");
plain(&mut app, K::Esc);
menu(&mut app, Command::Debug);
assert_eq!(app.active_window().unwrap().kind, WindowKind::Debug);
assert_control_menu_attached(&mut app, "Debug");
plain(&mut app, K::Esc);
menu(&mut app, Command::HelpWindow);
assert_eq!(app.active_window().unwrap().kind, WindowKind::Help);
assert_control_menu_attached(&mut app, "Help");
plain(&mut app, K::Esc);
assert_eq!(app.active_window().unwrap().kind, WindowKind::Help);
menu(&mut app, Command::Immediate);
key(&mut app, K::F(10), M::CONTROL);
let p = assert_control_menu_attached(&mut app, "Maximized");
assert_eq!((p.x, p.y), (area.x, area.y + 1));
plain(&mut app, K::Esc);
key(&mut app, K::F(9), M::CONTROL);
assert_eq!(app.active_window().unwrap().state, WindowState::Minimized);
let r = app.rect(app.active_window().unwrap());
let p = assert_control_menu_attached(&mut app, "Minimized");
assert_eq!(
(p.x, p.bottom()),
(r.x, r.y),
"minimiertes Fenster klappt nach oben"
);
plain(&mut app, K::Esc);
key(&mut app, K::F(5), M::CONTROL);
key(&mut app, K::F(8), M::CONTROL);
for _ in 0..20 {
key(&mut app, K::Left, M::CONTROL);
}
plain(&mut app, K::Enter);
key(&mut app, K::F(7), M::CONTROL);
for _ in 0..30 {
key(&mut app, K::Right, M::CONTROL);
}
plain(&mut app, K::Enter);
let r = app.rect(app.active_window().unwrap());
assert_eq!(r.right(), area.right());
let p = assert_control_menu_attached(&mut app, "Rechter Rand");
assert!(p.x < r.x && p.right() == area.right());
plain(&mut app, K::Esc);
// Normales Fenster am unteren Rand: zu wenig Platz unter der Titelzeile, klappt nach oben.
key(&mut app, K::F(8), M::CONTROL);
for _ in 0..20 {
key(&mut app, K::Up, M::CONTROL);
}
plain(&mut app, K::Enter);
key(&mut app, K::F(7), M::CONTROL);
for _ in 0..30 {
key(&mut app, K::Down, M::CONTROL);
}
plain(&mut app, K::Enter);
let r = app.rect(app.active_window().unwrap());
assert_eq!(r.bottom(), area.bottom());
let p = assert_control_menu_attached(&mut app, "Unterer Rand");
assert_eq!(p.bottom(), r.y, "klappt nach oben");
plain(&mut app, K::Esc);
// Maus: Klick auf [≡] öffnet identisch, Klick auf einen Eintrag führt ihn aus.
key(&mut app, K::F(5), M::CONTROL);
draw(&mut app);
let r = app.rect(app.active_window().unwrap());
click(&mut app, r.x, r.y);
assert!(app.control_menu.is_some());
assert_eq!(app.menu, None);
let (_, b) = draw(&mut app);
let (mx, my) = find_text(&b, "Minimize").unwrap();
click(&mut app, mx, my);
assert!(app.control_menu.is_none());
assert_eq!(app.active_window().unwrap().state, WindowState::Minimized);
}