Implement and archive Phase 5 integration acceptance
This commit is contained in:
@@ -19,3 +19,5 @@ anyhow.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tb-ide = { path = "../tb-ide" }
|
||||
crossterm.workspace = true
|
||||
ratatui.workspace = true
|
||||
|
||||
804
crates/tb-cli/src/ide_acceptance.rs
Normal file
804
crates/tb-cli/src/ide_acceptance.rs
Normal 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -336,3 +336,6 @@ impl Host for PipeHost {
|
||||
self.start.elapsed().as_millis() as u64
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ide_acceptance;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user