Implement and archive Phase 5 integration acceptance

This commit is contained in:
2026-09-07 10:38:58 +02:00
parent cc82830889
commit 86c3ebeb6d
18 changed files with 1224 additions and 39 deletions

View File

@@ -19,3 +19,5 @@ anyhow.workspace = true
[dev-dependencies]
tb-ide = { path = "../tb-ide" }
crossterm.workspace = true
ratatui.workspace = true

View File

@@ -0,0 +1,804 @@
//! Phase-5 acceptance drives App through native input; CLI comparisons use run_chain itself.
use super::*;
use crossterm::event::{
Event, KeyCode as K, KeyEvent, KeyModifiers as M, MouseButton, MouseEvent, MouseEventKind,
};
use ratatui::{backend::TestBackend, Terminal};
use std::{
fs,
sync::atomic::{AtomicUsize, Ordering},
};
use tb_ide::{
app::{App, DialogKind, Execution, Hit, Mode},
commands::Command,
export::{ExportStatus, ProjectStamp},
};
use tb_runtime::{snapshot, value::Value};
struct Temp(PathBuf);
impl Temp {
fn new() -> Self {
static N: AtomicUsize = AtomicUsize::new(0);
let p = std::env::temp_dir().join(format!(
"tb-accept-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&p).unwrap();
Self(p.canonicalize().unwrap())
}
}
impl Drop for Temp {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn key(a: &mut App, k: K, m: M) {
a.handle(Event::Key(KeyEvent::new(k, m)));
}
fn plain(a: &mut App, k: K) {
key(a, k, M::NONE);
}
fn text(a: &mut App, s: &str) {
for c in s.chars() {
plain(a, K::Char(c));
}
}
fn field(a: &mut App, n: usize, s: &str) {
for _ in 0..30 {
if a.dialog.as_ref().unwrap().focus == n {
break;
}
plain(a, K::Tab);
}
assert_eq!(a.dialog.as_ref().unwrap().focus, n);
key(a, K::Char('a'), M::CONTROL);
plain(a, K::Delete);
text(a, s);
}
fn choice(a: &mut App, n: usize, s: &str) {
for _ in 0..30 {
if a.dialog.as_ref().unwrap().focus == n {
break;
}
plain(a, K::Tab);
}
for _ in 0..200 {
if a.dialog.as_ref().unwrap().fields[n]
.string()
.eq_ignore_ascii_case(s)
{
return;
}
plain(a, K::Right);
}
panic!("choice missing {s}: {:?}", a.dialog);
}
fn submit(a: &mut App) {
plain(a, K::Enter);
assert!(a.dialog.is_none(), "{:?} / {}", a.dialog, a.message);
}
fn menu(a: &mut App, c: Command) {
let menus = a.menus();
let (m, i) = menus
.iter()
.enumerate()
.find_map(|(m, menu)| {
menu.items
.iter()
.position(|i| i.command == Some(c))
.map(|i| (m, i))
})
.unwrap_or_else(|| panic!("menu missing {c:?}"));
key(a, K::Char(menus[m].mnemonic), M::ALT);
for _ in 0..i {
plain(a, K::Down);
}
plain(a, K::Enter);
assert_eq!(a.last_command, Some(c), "{}", a.message);
}
fn draw(a: &mut App) -> String {
let mut t = Terminal::new(TestBackend::new(a.size.0, a.size.1)).unwrap();
t.draw(|f| a.render(f)).unwrap();
t.backend()
.buffer()
.content
.chunks(a.size.0 as usize)
.map(|r| r.iter().map(|c| c.symbol()).collect::<String>())
.collect::<Vec<_>>()
.join("\n")
}
fn click_hit(a: &mut App, predicate: impl Fn(&Hit) -> bool) {
draw(a);
let r = a
.hits
.iter()
.rev()
.find(|(_, h)| predicate(h))
.unwrap_or_else(|| panic!("hit missing"))
.0;
a.handle(Event::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: r.x,
row: r.y,
modifiers: M::NONE,
}));
}
fn replace_code(a: &mut App, s: &str) {
key(a, K::Char('a'), M::CONTROL);
a.handle(Event::Paste(s.into()));
}
fn code(a: &App) -> String {
a.project
.document(a.active_document().unwrap())
.unwrap()
.code()
.into()
}
fn cursor_line(a: &mut App, line: usize) {
key(a, K::Home, M::CONTROL);
for _ in 1..line {
plain(a, K::Down);
}
plain(a, K::Home);
}
fn save_as(a: &mut App, path: &Path) {
menu(a, Command::SaveAs);
field(a, 0, &path.display().to_string());
submit(a);
}
fn property(a: &mut App, name: &str, value: &str) {
if !a.properties {
plain(a, K::F(10));
}
click_hit(a, |h| matches!(h, Hit::DesignAction("property")));
choice(a, 0, name);
submit(a);
plain(a, K::F(2));
key(a, K::Char('a'), M::CONTROL);
text(a, value);
plain(a, K::Enter);
assert!(a.dialog.is_none(), "{:?}", a.dialog);
assert!(!a.value_focus);
}
fn tick(a: &mut App, now: u64) {
for _ in 0..40 {
a.tick_execution(now);
if !matches!(a.execution, Execution::Running) {
break;
}
}
}
fn open_code(a: &mut App, name: &str) {
menu(a, Command::Code);
if a.dialog.is_none() {
menu(a, Command::Code);
}
choice(a, 0, name);
submit(a);
assert_eq!(a.mode, Mode::Environment);
}
fn create(a: &mut App, t: &Temp) -> PathBuf {
assert_eq!(a.project.members().len(), 1);
assert!(code(a).is_empty());
assert!(draw(a).contains("Untitled"));
menu(a, Command::NewProject);
if a.dialog.is_some() {
choice(a, 0, "Verwerfen");
submit(a);
}
assert!(code(a).is_empty());
menu(a, Command::SyntaxChecking);
let include=format!("total=1\nCALL Bump(total)\nForm1.Hide\nCLS\nLINE INPUT input$\nPRINT total; input$; COMMAND$\nOPEN \"{}\" FOR OUTPUT AS #1\nPRINT #1, total\nCLOSE #1\nIF COMMAND$=\"error\" THEN ERROR 5\nEND\n",t.0.join("result.txt").display());
replace_code(a, &include);
menu(a, Command::SaveText);
field(a, 0, &t.0.join("work.bi").display().to_string());
submit(a);
replace_code(a, "' Main module\n");
save_as(a, &t.0.join("main.bas"));
menu(a, Command::NewModule);
field(a, 0, "Helper");
submit(a);
replace_code(a, "SUB Bump(n AS INTEGER)\nn=n+1\nEND SUB\n");
save_as(a, &t.0.join("helper.bas"));
menu(a, Command::NewForm);
assert_eq!(a.mode, Mode::Designer);
property(a, "CAPTION", "Abnahme");
let designer_focus = a.active;
plain(a, K::F(1));
assert_eq!(a.help.page().path, "docs/forms-referenz.md");
plain(a, K::Tab);
plain(a, K::Enter);
key(a, K::F(1), M::ALT);
plain(a, K::Esc);
assert_eq!(a.active, designer_focus);
plain(a, K::F(12));
plain(a, K::Enter);
choice(a, 0, "Load");
submit(a);
let handler = code(a);
assert!(handler.to_uppercase().contains("FORM_LOAD"), "{handler}");
cursor_line(a, 1);
key(a, K::End, M::CONTROL); // Replace body through the editor, retaining the generated event signature.
replace_code(
a,
&format!(
"DECLARE SUB Bump(n AS INTEGER)\nDIM SHARED total AS INTEGER\n{}",
handler.replace("END SUB", "SHARED total\n'$INCLUDE: 'work.bi'\nEND SUB")
),
);
key(a, K::F(12), M::SHIFT);
assert_eq!(a.mode, Mode::Designer);
// Toolbox Enter is the documented keyboard equivalent of double-click placement.
menu(a, Command::Toolbox);
for _ in 0..2 {
plain(a, K::Down);
}
plain(a, K::Enter);
assert!(a.designer.selections.values().any(|s| !s.is_empty()));
property(a, "INDEX", "0");
property(a, "CAPTION", "Start");
menu(a, Command::Toolbox);
plain(a, K::Down);
plain(a, K::Down);
plain(a, K::Enter);
property(a, "INDEX", "1");
property(a, "CTLNAME", "CommandButton1");
menu(a, Command::MenuDesign);
plain(a, K::Insert);
plain(a, K::Enter);
field(a, 1, "&Datei");
submit(a);
// Save all members and the chosen startup through actual dialog fields.
open_code(a, "main.bas");
menu(a, Command::Startup);
choice(a, 0, "Form1.frm");
submit(a);
let path = t.0.join("project.mak");
menu(a, Command::SaveProject);
field(a, 0, &path.display().to_string());
submit(a);
assert_eq!(a.project.members().len(), 3);
assert!(!a.project.is_dirty());
let form = fs::read_to_string(t.0.join("Form1.frm")).unwrap();
let parsed = tb_ui::frm::read_text("Form1.frm", &form).unwrap();
let array: Vec<_> = parsed
.root
.children
.iter()
.filter(|n| n.class == tb_frontend::forms::ObjectClass::CommandButton)
.map(tb_ide::designer::node_key)
.collect();
assert_eq!(
array,
vec![
("COMMANDBUTTON1".into(), Some(0)),
("COMMANDBUTTON1".into(), Some(1))
]
);
assert!(form.to_uppercase().contains("BEGIN MENU"));
assert!(form.to_uppercase().contains("FORM_LOAD"));
path
}
fn exports(a: &mut App, t: &Temp) {
let stamp = ProjectStamp::capture(&a.project);
for c in [Command::MakeExe, Command::MakeLibrary] {
menu(a, c);
assert!(draw(a).contains("Erzeugen: Phase 6"));
choice(a, 1, "linux");
choice(a, 2, "x86_64");
field(a, 3, "");
plain(a, K::Enter);
assert!(a.dialog.as_ref().unwrap().error.contains("Ausgabepfad"));
field(a, 3, &t.0.join("bad.tbc").display().to_string());
plain(a, K::Enter);
assert!(a.dialog.as_ref().unwrap().error.contains("TBC"));
let existing = t.0.join("existing");
fs::write(&existing, "original").unwrap();
field(a, 3, &existing.display().to_string());
plain(a, K::Enter);
assert!(a
.dialog
.as_ref()
.unwrap()
.error
.contains("Überschreibentscheidung"));
plain(a, K::Tab);
plain(a, K::Char(' '));
plain(a, K::Enter);
assert_eq!(fs::read_to_string(existing).unwrap(), "original");
let out = t.0.join("native-result");
field(a, 3, &out.display().to_string());
plain(a, K::Enter);
let request = a.dialog.as_ref().unwrap().request.clone().unwrap();
assert_eq!(request.project, stamp);
for status in [
ExportStatus::Running,
ExportStatus::Failed("Kontrollierter Fehler".into()),
ExportStatus::Cancelled,
ExportStatus::Success,
] {
a.export_result(&request, status.clone()).unwrap();
assert!(draw(a).contains(&status.text()));
assert!(!out.exists());
}
plain(a, K::Esc);
assert_eq!(ProjectStamp::capture(&a.project), stamp);
}
}
#[test]
fn create_save_debug_help_exports_and_reopen() {
let t = Temp::new();
let mut a = App::new(&t.0, t.0.join("options"), (80, 25)).unwrap();
let path = create(&mut a, &t);
let saved: Vec<_> = [
"main.bas",
"helper.bas",
"Form1.frm",
"work.bi",
"project.mak",
]
.map(|p| fs::read(t.0.join(p)).unwrap())
.into();
exports(&mut a, &t);
open_code(&mut a, "Form1.frm");
let line = code(&a)
.lines()
.position(|l| l.contains("$INCLUDE"))
.unwrap()
+ 1;
cursor_line(&mut a, line);
menu(&mut a, Command::IncludedFile);
assert!(a
.project
.document(a.active_document().unwrap())
.unwrap()
.source_path()
.ends_with("work.bi"));
cursor_line(&mut a, 2);
plain(&mut a, K::F(9));
let code_focus = a.active;
cursor_line(&mut a, 6);
plain(&mut a, K::F(1));
assert!(a.help.page().title.contains("Index") || a.help.page().path.starts_with("docs/"));
plain(&mut a, K::Tab);
plain(&mut a, K::Enter);
key(&mut a, K::F(1), M::ALT);
plain(&mut a, K::Esc);
assert_eq!(a.active, code_focus);
menu(&mut a, Command::CommandLine);
field(&mut a, 0, "!");
submit(&mut a);
key(&mut a, K::F(5), M::SHIFT);
tick(&mut a, 0);
assert_eq!(a.execution, Execution::Paused, "{}", a.message);
assert!(a
.session
.vm
.as_ref()
.unwrap()
.current_file()
.ends_with("work.bi"));
assert_eq!(a.session.vm.as_ref().unwrap().current_line(), 2);
plain(&mut a, K::F(10));
tick(&mut a, 0);
assert_eq!(a.execution, Execution::Paused);
assert_eq!(a.session.vm.as_ref().unwrap().current_line(), 3);
menu(&mut a, Command::AddWatch);
field(&mut a, 0, "total");
submit(&mut a);
assert!(matches!(a.debugger.watches[0].value, Ok(Value::Int(2))));
menu(&mut a, Command::Immediate);
text(&mut a, "total=9");
plain(&mut a, K::Enter);
tick(&mut a, 0);
assert_eq!(a.execution, Execution::Paused);
plain(&mut a, K::F(5));
tick(&mut a, 0);
assert_eq!(a.execution, Execution::Waiting, "{}", a.message);
menu(&mut a, Command::Output);
a.handle(Event::Paste("abc\n".into()));
tick(&mut a, 100);
assert_eq!(a.execution, Execution::Ended, "{}", a.message);
assert_eq!(snapshot::text(a.session.screen()), "? abc\n 9 abc!\n");
assert!(fs::read_to_string(t.0.join("result.txt"))
.unwrap()
.contains('9'));
for (p, bytes) in [
"main.bas",
"helper.bas",
"Form1.frm",
"work.bi",
"project.mak",
]
.iter()
.zip(saved.iter())
{
assert_eq!(&fs::read(t.0.join(p)).unwrap(), bytes);
}
menu(&mut a, Command::NewProject);
if a.dialog.is_some() {
choice(&mut a, 0, "Verwerfen");
submit(&mut a);
}
menu(&mut a, Command::OpenProject);
field(&mut a, 0, &path.display().to_string());
plain(&mut a, K::Enter);
choice(&mut a, 0, "Verwerfen");
submit(&mut a);
assert_eq!(a.project.members().len(), 3);
assert!(a
.project
.document(a.project.startup().unwrap())
.unwrap()
.source_path()
.ends_with("Form1.frm"));
assert!(!a.project.is_dirty());
for id in a.project.members() {
let doc = a.project.document(id).unwrap();
match doc.content() {
tb_vm::project_io::Content::Form(form) => assert_eq!(
form.as_ref(),
&tb_ui::frm::read_text(
&doc.source_path().display().to_string(),
&fs::read_to_string(doc.source_path()).unwrap()
)
.unwrap()
),
_ => assert_eq!(doc.code(), fs::read_to_string(doc.source_path()).unwrap()),
}
}
menu(&mut a, Command::CommandLine);
field(&mut a, 0, "!");
submit(&mut a);
menu(&mut a, Command::Start);
tick(&mut a, 0);
assert_eq!(a.execution, Execution::Waiting);
menu(&mut a, Command::Output);
a.handle(Event::Paste("abc\n".into()));
tick(&mut a, 100);
assert_eq!(a.execution, Execution::Ended);
assert_eq!(snapshot::text(a.session.screen()), "? abc\n 2 abc!\n");
}
struct ScriptHost {
now: u64,
sent: bool,
events: std::collections::VecDeque<Ereignis>,
}
impl Host for ScriptHost {
fn present(&mut self, _: &tb_runtime::screen::TextScreen) {}
fn jetzt_ms(&mut self) -> u64 {
self.now
}
fn next_event(&mut self, _: bool) -> Option<Ereignis> {
self.events.pop_front()
}
fn warten(&mut self, _: Option<u64>) -> Option<Ereignis> {
assert!(!self.sent, "Unexpected extra wait");
self.sent = true;
self.now = 100;
self.events
.extend("abc\r".chars().map(|c| Ereignis::Taste(c.to_string(), 0)));
self.events.pop_front()
}
}
#[test]
fn saved_project_ide_source_cli_and_tbc_have_identical_sessions() {
let t = Temp::new();
let mut a = App::new(&t.0, t.0.join("options"), (80, 25)).unwrap();
let path = create(&mut a, &t);
assert_eq!(
super::cmd_build(&[path.display().to_string()]),
ExitCode::SUCCESS
);
for command in ["!", "error"] {
menu(&mut a, Command::CommandLine);
field(&mut a, 0, command);
submit(&mut a);
fs::write(t.0.join("result.txt"), "initial").unwrap();
menu(&mut a, Command::Start);
let ide = a.session.vm.as_ref().unwrap();
assert_eq!(ide.rt.command, command);
assert_eq!((ide.rt.screen.cols(), ide.rt.screen.rows()), (80, 25));
assert!(ide.forms.has_visible_forms());
assert!(!ide.rt.dateien.ist_offen(1));
assert!(a.session.host.events.is_empty());
tick(&mut a, 0);
assert_eq!(a.execution, Execution::Waiting, "{}", a.message);
// Same input and time boundary as ScriptHost::warten, through native App input.
a.handle(Event::Paste("abc\n".into()));
tick(&mut a, 100);
assert_eq!(
a.execution,
if command == "!" {
Execution::Ended
} else {
Execution::Error
},
"{}",
a.message
);
if command == "error" {
assert!(a.message.contains('5'));
}
let output = snapshot::text(a.session.screen());
let file = fs::read(t.0.join("result.txt")).unwrap();
assert_eq!(output, format!("? abc\n 2 abc{command}\n"));
assert!(String::from_utf8_lossy(&file).contains('2'));
for target in [path.clone(), path.with_extension("tbc")] {
fs::write(t.0.join("result.txt"), "initial").unwrap();
let input = super::compile(Some(&target.display().to_string()))
.unwrap()
.1;
let initial = new_execution(input, command, Some((80, 25)), None).unwrap();
assert!(initial.forms.has_visible_forms());
assert_eq!(initial.rt.command, command);
assert!(!initial.rt.dateien.ist_offen(1));
let mut host = ScriptHost {
now: 0,
sent: false,
events: Default::default(),
};
let (event, vm) = super::run_chain(
&[target.display().to_string(), command.into()],
&mut host,
Some((80, 25)),
)
.unwrap();
if command == "!" {
assert_eq!(event, RunEvent::Ended);
} else {
assert!(
matches!(event, RunEvent::Error { code: 5, .. }),
"{event:?}"
);
}
assert_eq!(snapshot::text(&vm.rt.screen), output);
assert_eq!(fs::read(t.0.join("result.txt")).unwrap(), file);
assert!(!vm.rt.dateien.ist_offen(1));
assert_eq!(host.now, 100);
}
menu(&mut a, Command::Restart);
assert!(a.session.host.events.is_empty());
assert!(!a.session.vm.as_ref().unwrap().rt.dateien.ist_offen(1));
tick(&mut a, 0);
assert_eq!(a.execution, Execution::Waiting);
a.handle(Event::Paste("abc\n".into()));
tick(&mut a, 100);
assert_eq!(snapshot::text(a.session.screen()), output);
}
}
#[test]
fn every_menu_command_has_native_keyboard_and_mouse_routing() {
let t = Temp::new();
for designer in [false, true] {
let menus = tb_ide::commands::menus(designer);
for (m, group) in menus.iter().enumerate() {
for (i, item) in group.items.iter().enumerate() {
let Some(command) = item.command else {
continue;
};
for mouse in [false, true] {
let mut a = App::new(&t.0, t.0.join("options"), (120, 45)).unwrap();
if designer {
menu(&mut a, Command::NewForm);
}
if mouse {
if designer {
plain(&mut a, K::F(11));
plain(&mut a, K::Esc);
}
click_hit(&mut a, |h| matches!(h,Hit::Menu(n) if *n==m));
click_hit(&mut a, |h| matches!(h,Hit::MenuItem(n) if *n==i));
assert_eq!(a.last_command, Some(command), "{command:?}");
} else {
menu(&mut a, command);
}
assert!(a.basic_events.is_empty(), "{command:?}");
assert!(!a.message.contains("folgt in Phase-5"));
}
}
}
}
}
#[test]
fn function_keys_conflict_contexts_and_errors_keep_a_single_input_owner() {
let t = Temp::new();
let mut a = App::new(&t.0, t.0.join("options"), (100, 30)).unwrap();
menu(&mut a, Command::SyntaxChecking);
replace_code(&mut a, "PRINT (\n");
menu(&mut a, Command::Start);
assert_eq!(a.execution, Execution::Error);
assert!(a.session.vm.is_none());
let invalid = code(&a);
assert!(invalid.contains("PRINT ("));
replace_code(&mut a, "LINE INPUT s$\nPRINT s$\nEND\n");
plain(&mut a, K::F(5));
tick(&mut a, 0);
assert_eq!(a.execution, Execution::Waiting);
plain(&mut a, K::F(4));
assert!(a.session.fullscreen);
plain(&mut a, K::F(4));
assert!(!a.session.fullscreen);
plain(&mut a, K::F(11));
assert!(a.menu.is_some());
plain(&mut a, K::Esc);
plain(&mut a, K::F(2));
assert!(matches!(
a.dialog.as_ref().unwrap().kind,
DialogKind::ChooseCode
));
plain(&mut a, K::Esc);
plain(&mut a, K::F(12));
assert!(a.basic_events.is_empty());
if a.dialog.is_some() {
plain(&mut a, K::Esc);
}
key(&mut a, K::Pause, M::CONTROL);
assert_eq!(a.execution, Execution::Paused);
assert!(a.basic_events.is_empty());
a.handle(Event::Resize(40, 10));
assert!(draw(&mut a).contains("80×25"));
a.handle(Event::Resize(100, 30));
assert!(draw(&mut a).contains("Untitled"));
plain(&mut a, K::F(5));
menu(&mut a, Command::Output);
text(&mut a, "one");
plain(&mut a, K::Enter);
tick(&mut a, 100);
assert_eq!(snapshot::text(a.session.screen()), "? one\none\n");
open_code(&mut a, "Untitled.bas");
replace_code(&mut a, "RUN \"missing\"\n");
menu(&mut a, Command::Start);
tick(&mut a, 0);
assert_eq!(a.execution, Execution::Error);
assert!(a.message.contains("nicht gefunden"));
open_code(&mut a, "Untitled.bas");
assert!(code(&a).contains("missing"));
replace_code(&mut a, "PRINT SHELL(\"exit 7\")\nEND\n");
menu(&mut a, Command::Start);
tick(&mut a, 0);
assert_eq!(
a.session.host.shell_request.take().as_deref(),
Some("exit 7")
);
a.session.host.shell_result = Some(Ok(7));
tick(&mut a, 0);
assert_eq!(snapshot::text(a.session.screen()), " 7 \n");
menu(&mut a, Command::NewForm);
plain(&mut a, K::F(2));
assert!(a.value_focus);
plain(&mut a, K::Esc);
plain(&mut a, K::F(10));
assert!(!a.properties && a.menu.is_some());
plain(&mut a, K::F(10));
assert!(a.properties && a.menu.is_none());
plain(&mut a, K::F(11));
assert!(a.menu.is_some());
plain(&mut a, K::Esc);
plain(&mut a, K::F(12));
assert!(matches!(
a.dialog.as_ref().unwrap().kind,
DialogKind::DesignObject(_)
));
plain(&mut a, K::Esc);
assert!(a.basic_events.is_empty());
}
#[test]
fn partial_save_and_external_edit_recover_without_losing_documents() {
let t = Temp::new();
let mut a = App::new(&t.0, t.0.join("options"), (100, 30)).unwrap();
replace_code(&mut a, "PRINT 1\n");
menu(&mut a, Command::NewModule);
field(&mut a, 0, "Second");
submit(&mut a);
replace_code(&mut a, "PRINT 2\n");
let ids = a.project.members();
menu(&mut a, Command::SaveProject);
field(&mut a, 0, &t.0.join("p.mak").display().to_string());
field(&mut a, 2, &t.0.join("first.bas").display().to_string());
field(
&mut a,
4,
&t.0.join("absent/second.bas").display().to_string(),
);
plain(&mut a, K::Enter);
assert!(!a.dialog.as_ref().unwrap().error.is_empty());
assert!(!a.project.document(ids[0]).unwrap().is_dirty());
assert!(a.project.document(ids[1]).unwrap().is_dirty());
assert!(a
.project
.document(ids[1])
.unwrap()
.code()
.contains("PRINT 2"));
assert!(!t.0.join("p.mak").exists());
field(&mut a, 4, &t.0.join("second.bas").display().to_string());
submit(&mut a);
open_code(&mut a, "first.bas");
replace_code(&mut a, "PRINT 3\n");
fs::write(t.0.join("first.bas"), "' external\n").unwrap();
menu(&mut a, Command::SaveFile);
plain(&mut a, K::Enter);
assert!(a
.dialog
.as_ref()
.unwrap()
.error
.contains("Überschreibentscheidung"));
assert_eq!(
fs::read_to_string(t.0.join("first.bas")).unwrap(),
"' external\n"
);
assert!(code(&a).contains("PRINT 3"));
plain(&mut a, K::Tab);
plain(&mut a, K::Char(' '));
submit(&mut a);
assert!(!a.project.is_dirty());
menu(&mut a, Command::Start);
tick(&mut a, 0);
assert_eq!(a.execution, Execution::Ended);
assert_eq!(snapshot::text(a.session.screen()), " 3 \n 2 \n");
}
#[test]
fn reference_matrix_is_complete_and_points_to_runnable_tests() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let changes = root.join("openspec/changes");
let name = "phase-5-08-integration-und-phasenabnahme";
let active = changes.join(name);
let change = if active.exists() {
active
} else {
fs::read_dir(changes.join("archive"))
.unwrap()
.map(|p| p.unwrap().path())
.find(|p| p.file_name().unwrap().to_string_lossy().ends_with(name))
.unwrap()
};
let overview = fs::read_to_string(
changes
.join("archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md"),
)
.unwrap();
let overview = overview
.split("## Vollständige Zuordnung der Referenzbedienung")
.nth(1)
.unwrap()
.split("## Explizite")
.next()
.unwrap();
let matrix = fs::read_to_string(change.join("befehlsmatrix.md")).unwrap();
let rows = |s: &str| {
s.lines()
.filter(|l| l.starts_with('|'))
.skip(2)
.map(|l| {
l.split('|')
.skip(1)
.map(|c| c.trim().to_owned())
.collect::<Vec<_>>()
})
.collect::<Vec<_>>()
};
let expected = rows(overview);
let actual = rows(&matrix);
assert_eq!(expected.len(), 45);
assert_eq!(actual.len(), expected.len());
for (expected, actual) in expected.iter().zip(actual) {
assert_eq!(&actual[..3], &expected[..3]);
let tests: Vec<_> = actual[3].split('`').skip(1).step_by(2).collect();
assert!(!tests.is_empty());
for test in tests {
let (file, function) = test.split_once("::").unwrap();
let source = fs::read_to_string(root.join(file)).unwrap();
assert!(
source.contains(&format!("#[test]\nfn {function}(")),
"{test}"
);
}
}
}

View File

@@ -336,3 +336,6 @@ impl Host for PipeHost {
self.start.elapsed().as_millis() as u64
}
}
#[cfg(test)]
mod ide_acceptance;

View File

@@ -720,14 +720,14 @@ impl App {
for f in &input.forms {
catalog.append(&f.catalog());
}
ensure!(
!catalog
.objects
.iter()
.any(|o| o.name.eq_ignore_ascii_case(new)),
"Name {new} bereits vorhanden"
);
let form_name = self.design_form()?.root.name.clone();
ensure!(
catalog.objects.iter().filter(|o| o.name.eq_ignore_ascii_case(new)).all(|o| {
node_key(&selected).1.is_some() && o.array && o.class == selected.class
&& o.parent_form.as_deref().is_some_and(|name| name.eq_ignore_ascii_case(&form_name))
}),
"Name {new} bereits vorhanden; nur indizierte Controls derselben Klasse und Form können ein Array bilden"
);
let target = if selected.class == Class::Form {
catalog.find(&old)
} else {
@@ -887,27 +887,52 @@ impl App {
.join("; ")
)
})?;
let mut candidate_catalog = forms::FormCatalog::default();
for form in &candidate.forms {
candidate_catalog.append(&form.catalog());
}
let bindings = |references: &[(tb_frontend::SourcePos, String, u16)],
files: &[tb_frontend::source::SourceFile],
objects: &forms::FormCatalog,
rename_old: bool| {
let mut result = Vec::new();
for (p, n, o) in references {
let mut n = n.clone();
let object = &objects.objects[*o as usize];
let mut name = object.name.clone();
let mut owner = object.parent_form.clone();
if rename_old && *o == target {
name = new.to_uppercase();
if n == old {
n = new.to_uppercase();
} else if let Some(r) = handlers.get(&n) {
n = r.to_uppercase();
}
}
result.push((files[p.source as usize].path.clone(), n, *o));
if rename_old && selected.class == Class::Form && owner.as_deref() == Some(&old) {
owner = Some(new.to_uppercase());
}
// Merging arrays changes catalog indices. Compare semantic object identities.
result.push((
files[p.source as usize].path.clone(),
n,
name,
owner,
object.class.name(),
object.array,
));
}
result.sort();
result
};
ensure!(
bindings(&bound.objects, &source_files, true)
== bindings(&candidate_bound.objects, &candidate_compiled.sources, false),
bindings(&bound.objects, &source_files, &catalog, true)
== bindings(
&candidate_bound.objects,
&candidate_compiled.sources,
&candidate_catalog,
false
),
"Unklare oder verdeckte Objektverweise; nichts geändert"
);
self.project

View File

@@ -924,3 +924,63 @@ fn events_with_the_same_name_are_local_to_their_form() {
.code()
.contains("SUB Form_LOAD"));
}
#[test]
fn indexed_controls_merge_atomically_with_bindings_and_undo() {
let t = Temp::new();
let mut a = t.app();
let root = root(&mut a);
let first = a
.design_place(C::CommandButton, 0, root, Rect::new(1, 1, 8, 2))
.unwrap();
property(&mut a, "INDEX", "0");
let target = node(&mut a, first).name;
let second = a
.design_place(C::CommandButton, 0, root, Rect::new(1, 4, 8, 2))
.unwrap();
let old = node(&mut a, second).name;
// Scalar and duplicate-index merges must leave every document untouched.
let before = a.design_form().unwrap().clone();
assert!(a.design_rename(&target).is_err());
assert_eq!(*a.design_form().unwrap(), before);
property(&mut a, "INDEX", "0");
let before = a.design_form().unwrap().clone();
assert!(a.design_rename(&target).is_err());
assert_eq!(*a.design_form().unwrap(), before);
property(&mut a, "INDEX", "1");
let unrelated = a
.design_place(C::TextBox, 0, root, Rect::new(20, 1, 8, 2))
.unwrap();
let unrelated_name = node(&mut a, unrelated).name;
property(&mut a, "INDEX", "2");
let before = a.design_form().unwrap().clone();
assert!(a.design_rename(&target).is_err());
assert_eq!(*a.design_form().unwrap(), before);
a.design_select(second, false).unwrap();
let doc = a.design_document().unwrap();
let source = format!("SUB {old}_Click(Index AS INTEGER)\n{old}(1).Caption = {target}(0).Caption\n{unrelated_name}(2).Text = \"kept\"\nEND SUB\n");
a.project.replace_text(doc, 0..0, &source).unwrap();
let before = a.design_form().unwrap().clone();
a.design_rename(&target).unwrap();
assert_eq!(node(&mut a, second).name, target);
let code = &a.design_form().unwrap().code;
assert!(code
.to_uppercase()
.contains(&format!("SUB {}_CLICK", target.to_uppercase())));
assert!(code.contains(&format!("{target}(1).Caption")));
assert!(code.contains(&format!("{unrelated_name}(2).Text")));
a.execute(Command::Undo);
assert_eq!(*a.design_form().unwrap(), before);
// Two handlers for the merged event are ambiguous and cannot be committed.
a.project
.replace_text(
doc,
0..0,
&format!("SUB {target}_Click(Index AS INTEGER)\nEND SUB\n"),
)
.unwrap();
let before = a.design_form().unwrap().clone();
a.design_select(second, false).unwrap();
assert!(a.design_rename(&target).is_err());
assert_eq!(*a.design_form().unwrap(), before);
}