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