Control-Menüs öffnen für alle Fensterarten am [≡]-Symbol des aktiven Fensters: eigener Auswahlzustand statt Kopplung an die Menüleiste, bevorzugt unter der Titelzeile, bei Platzmangel nach oben aufgeklappt, am rechten Rand nur so weit nach links wie nötig. Regressionstest über TestBackend für Code-, Projekt-, Output-, Immediate-, Debug- und Hilfefenster, maximiert, minimiert, Rand und Maus. Nebenbefunde der Windows-Testausführung: Temporärdatei vor sync_all schreibend öffnen (Zugriff verweigert), relative Projektverweise immer mit / schreiben, zeilenendenneutrale Vergleiche in Referenzmatrix-, Kompatibilitäts- und MAK-Beispieltests, DriveListBox-Zeichenbild plattformneutral. Korpus-Sollausgaben per .gitattributes auf LF. Specs ide-oberflaeche und ide-projekte synchronisiert, Change archiviert. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
299 lines
10 KiB
Rust
299 lines
10 KiB
Rust
use std::{
|
|
fs,
|
|
path::{Path, PathBuf},
|
|
sync::atomic::{AtomicUsize, Ordering},
|
|
};
|
|
use tb_vm::project_io::{identity, relative_path, Content, Manifest, SourceLoader};
|
|
struct Temp(PathBuf);
|
|
impl Temp {
|
|
fn new() -> Self {
|
|
static N: AtomicUsize = AtomicUsize::new(0);
|
|
let p = std::env::temp_dir().join(format!(
|
|
"tb-project-io-{}-{}",
|
|
std::process::id(),
|
|
N.fetch_add(1, Ordering::Relaxed)
|
|
));
|
|
fs::create_dir_all(&p).unwrap();
|
|
Self(p.canonicalize().unwrap())
|
|
}
|
|
fn write(&self, name: &str, text: &str) -> PathBuf {
|
|
let p = self.0.join(name);
|
|
fs::create_dir_all(p.parent().unwrap()).unwrap();
|
|
fs::write(&p, text).unwrap();
|
|
p
|
|
}
|
|
}
|
|
impl Drop for Temp {
|
|
fn drop(&mut self) {
|
|
let _ = fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
#[test]
|
|
fn overlays_keep_original_files_and_distinct_module_contexts() {
|
|
let t = Temp::new();
|
|
let p = t.write("app.mak", "a.bas\nb.bas\n");
|
|
t.write("a.bas", "'$INCLUDE: 'common.bi'\nPRINT n\nEND\n");
|
|
t.write("b.bas", "'$INCLUDE: 'common.bi'\nSUB Test\nEND SUB\n");
|
|
let include = t.write("common.bi", "CONST n=1\n");
|
|
let mut loader = SourceLoader::default();
|
|
loader
|
|
.insert(&include, Content::Text("CONST n=7\n".into()))
|
|
.unwrap();
|
|
let input = loader.load(&p).unwrap();
|
|
let mut origins = Vec::new();
|
|
for (i, u) in input.units.iter().enumerate() {
|
|
let (_, errors) = u.parse(i as u16, &mut origins);
|
|
assert!(errors.is_empty());
|
|
assert!(u
|
|
.segments
|
|
.iter()
|
|
.any(|s| s.text == "CONST n=7\n" && s.first_line == 1));
|
|
}
|
|
let positions: Vec<_> = origins
|
|
.iter()
|
|
.filter(|o| Path::new(&o.path).file_name().unwrap() == "common.bi")
|
|
.collect();
|
|
assert_eq!(positions.len(), 2);
|
|
assert_ne!(positions[0].module, positions[1].module);
|
|
assert_eq!(fs::read_to_string(include).unwrap(), "CONST n=1\n");
|
|
}
|
|
#[test]
|
|
fn relative_include_precedes_search_paths_and_virtual_files_are_resolved() {
|
|
let t = Temp::new();
|
|
let main = t.write("src/main.bas", "'$INCLUDE: 'Mixed Name.bi'\n");
|
|
t.write("library/mixed name.bi", "PRINT 8\n");
|
|
let mut loader = SourceLoader::default();
|
|
loader.include_paths.push(t.0.join("library"));
|
|
assert!(loader.load(&main).unwrap().units[0]
|
|
.segments
|
|
.iter()
|
|
.any(|s| s.text == "PRINT 8\n"));
|
|
loader
|
|
.insert(
|
|
&t.0.join("src/MIXED NAME.BI"),
|
|
Content::Text("PRINT 9\n".into()),
|
|
)
|
|
.unwrap();
|
|
assert!(loader.load(&main).unwrap().units[0]
|
|
.segments
|
|
.iter()
|
|
.any(|s| s.text == "PRINT 9\n"));
|
|
let virtual_main = t.0.join("virtual.bas");
|
|
loader
|
|
.insert(&virtual_main, Content::Text("END\n".into()))
|
|
.unwrap();
|
|
assert_eq!(loader.load(&virtual_main).unwrap().units[0].name, "VIRTUAL");
|
|
assert!(!virtual_main.exists());
|
|
}
|
|
#[test]
|
|
fn cycle_missing_include_and_literal_directives_are_distinguished() {
|
|
let t = Temp::new();
|
|
let main = t.write("main.bas", "'$INCLUDE: 'outer.bi'\n");
|
|
t.write("outer.bi", "'$INCLUDE: 'inner.bi'\n");
|
|
t.write("inner.bi", "'$INCLUDE: 'outer.bi'\n");
|
|
let loader = SourceLoader::default();
|
|
let error = loader.load(&main).unwrap_err();
|
|
assert!(
|
|
error.contains("zyklisches") && error.contains("outer.bi") && error.contains("inner.bi")
|
|
);
|
|
t.write("inner.bi", "'$INCLUDE: 'missing.bi'\n");
|
|
let error = loader.load(&main).unwrap_err();
|
|
assert!(error.contains("inner.bi:1") && error.contains("missing.bi"));
|
|
t.write("main.bas","PRINT \"$INCLUDE: 'missing.bi'\"\n' Dieser Kommentar erwähnt $INCLUDE: 'missing.bi'\nDATA $INCLUDE: 'missing.bi'\nEND\n");
|
|
let input = loader.load(&main).unwrap();
|
|
assert!(input.units[0]
|
|
.segments
|
|
.iter()
|
|
.any(|s| s.text.starts_with("PRINT")));
|
|
t.write("main.bas", "PRINT 1: '$INCLUDE: 'good.bi'\nEND\n");
|
|
t.write("good.bi", "PRINT 2\n");
|
|
let input = loader.load(&main).unwrap();
|
|
let code = input.units[0]
|
|
.segments
|
|
.iter()
|
|
.map(|s| s.text.as_str())
|
|
.collect::<String>();
|
|
assert!(code.contains("PRINT 1:") && code.contains("PRINT 2"));
|
|
t.write("main.bas", "'$INCLUDE: 'unterminated\n");
|
|
assert!(loader.load(&main).unwrap_err().contains("leeres $INCLUDE"));
|
|
}
|
|
#[test]
|
|
fn startup_metadata_roundtrip_and_invalid_contracts() {
|
|
let t = Temp::new();
|
|
t.write("a.bas", "END\n");
|
|
t.write("b.bas", "");
|
|
let p = t.write(
|
|
"p.mak",
|
|
"' keep this\nA.BAS\n\n' $STARTUP: \"b.bas\"\nb.bas\n",
|
|
);
|
|
let loader = SourceLoader::default();
|
|
let input = loader.load(&p).unwrap();
|
|
let output = input.manifest.text(&t.0.join("nested/new.mak")).unwrap();
|
|
assert!(output.contains("../a.bas") || output.contains("../A.BAS"));
|
|
assert!(output.contains("' keep this"));
|
|
let roundtrip = Manifest::parse(&t.0.join("nested/new.mak"), &output, &loader).unwrap();
|
|
assert_eq!(roundtrip.members().count(), 2);
|
|
assert_eq!(
|
|
identity(roundtrip.startup.as_ref().unwrap()).unwrap(),
|
|
identity(&t.0.join("b.bas")).unwrap()
|
|
);
|
|
for text in [
|
|
"' $STARTUP: \"a.bas\"\n' $STARTUP: \"a.bas\"\na.bas\n",
|
|
"' $STARTUP: a.bas\na.bas\n",
|
|
"' $STARTUP: \"b.bas\"\na.bas\n",
|
|
"' $STARTUP: \"\"\na.bas\n",
|
|
"' $STARTUP: \"missing.bas\"\na.bas\n",
|
|
] {
|
|
assert!(Manifest::parse(&p, text, &loader).is_err(), "{text}");
|
|
}
|
|
assert!(Manifest::parse(&p, "a.bas\n", &loader)
|
|
.unwrap()
|
|
.startup
|
|
.is_none());
|
|
assert_eq!(
|
|
relative_path(&t.0.join("nested"), &t.0.join("a.bas")).unwrap(),
|
|
"../a.bas"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn documented_mak_example_is_accepted_by_the_shared_loader() {
|
|
let t = Temp::new();
|
|
t.write("main.bas", "END\n");
|
|
t.write("lib.bas", "");
|
|
t.write("form.frm", "VERSION 1.00\nBegin Form F\nEnd\n");
|
|
// Die Dokumentation liegt je Checkout mit CRLF vor; der Beispielblock wird LF-neutral gesucht.
|
|
let docs = include_str!("../../../docs/dateiformate.md").replace("\r\n", "\n");
|
|
let example = docs
|
|
.split("```mak\n")
|
|
.nth(1)
|
|
.unwrap()
|
|
.split("```")
|
|
.next()
|
|
.unwrap();
|
|
let path = t.write("example.mak", example);
|
|
let result = SourceLoader::default().load(&path).unwrap();
|
|
assert_eq!(result.units.len(), 3);
|
|
assert_eq!(result.forms.len(), 1);
|
|
assert_eq!(
|
|
result.manifest.startup.unwrap().file_name().unwrap(),
|
|
"main.bas"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn case_insensitive_parent_lookup_works_from_relative_base_without_changing_cwd() {
|
|
let t = Temp::new();
|
|
let sibling = t.write("Sibling.bas", "END\n");
|
|
fs::create_dir(t.0.join("child")).unwrap();
|
|
let base = PathBuf::from(
|
|
relative_path(&std::env::current_dir().unwrap(), &t.0.join("child")).unwrap(),
|
|
);
|
|
let found = tb_vm::project_io::relative_case_insensitive(&base, "../SIBLING.BAS").unwrap();
|
|
assert_eq!(identity(&found).unwrap(), identity(&sibling).unwrap());
|
|
}
|
|
#[test]
|
|
fn tbl_members_move_save_as_order_startup_and_same_size_cache_replacement() {
|
|
use tb_frontend::{forms::FormCatalog, source::SourceUnit};
|
|
use tb_vm::{
|
|
interp::{RunEvent, Vm},
|
|
project::ProjectCompiler,
|
|
};
|
|
let t = Temp::new();
|
|
let library = |n, value| {
|
|
ProjectCompiler::default()
|
|
.compile_library(
|
|
&[SourceUnit::new(
|
|
"LIB",
|
|
"removed.bas",
|
|
&format!("CONST N={n}\nSUB Work\nPRINT {value}\nEND SUB\nPRINT \"LIB\""),
|
|
)],
|
|
&FormCatalog::default(),
|
|
&[],
|
|
&[],
|
|
)
|
|
.unwrap()
|
|
.to_tbl()
|
|
.unwrap()
|
|
};
|
|
let first = library(1, 1);
|
|
let second = library(2, 2);
|
|
assert_eq!(first.len(), second.len());
|
|
t.write("old/main.bas", "PRINT N\nCALL Work\n");
|
|
t.write("old/last.bas", "PRINT \"LAST\"\n");
|
|
let lib = t.0.join("old/Mixed.tbl");
|
|
fs::write(&lib, &first).unwrap();
|
|
let mak = t.write(
|
|
"old/app.mak",
|
|
"mixed.TBL\nlast.bas\nmain.bas\n' $STARTUP: \"main.bas\"\n",
|
|
);
|
|
let loader = SourceLoader::default();
|
|
let input = loader.load(&mak).unwrap();
|
|
assert_eq!(input.units.len(), 2);
|
|
assert_eq!(input.libraries.len(), 1);
|
|
let run = |code| {
|
|
let mut vm = Vm::new(code);
|
|
assert_eq!(
|
|
vm.run(&mut tb_runtime::host::CaptureHost::default()),
|
|
RunEvent::Ended
|
|
);
|
|
tb_runtime::snapshot::text(&vm.rt.screen)
|
|
};
|
|
let mut c = ProjectCompiler::default();
|
|
assert_eq!(
|
|
run(input.compile(&mut c, "APP").unwrap()),
|
|
" 1 \n 1 \nLIB\nLAST\n"
|
|
);
|
|
fs::write(&lib, &second).unwrap();
|
|
assert_eq!(
|
|
run(input.compile(&mut c, "APP").unwrap()),
|
|
" 2 \n 2 \nLIB\nLAST\n"
|
|
);
|
|
let body_only = library(2, 3);
|
|
assert_eq!(second.len(), body_only.len());
|
|
fs::write(&lib, &body_only).unwrap();
|
|
assert_eq!(
|
|
run(input.compile(&mut c, "APP").unwrap()),
|
|
" 2 \n 3 \nLIB\nLAST\n"
|
|
);
|
|
assert_eq!(
|
|
c.stats.compiled, 0,
|
|
"Unveränderte Deklarationen dürfen Quellprodukte wiederverwenden"
|
|
);
|
|
fs::create_dir_all(t.0.join("save")).unwrap();
|
|
let saved = t.0.join("save/app.mak");
|
|
fs::write(&saved, input.manifest.text(&saved).unwrap()).unwrap();
|
|
assert_eq!(
|
|
identity(&loader.load(&saved).unwrap().libraries[0]).unwrap(),
|
|
identity(&input.libraries[0]).unwrap()
|
|
);
|
|
fs::rename(t.0.join("old"), t.0.join("moved")).unwrap();
|
|
assert_eq!(
|
|
run(loader
|
|
.load(&t.0.join("moved/app.mak"))
|
|
.unwrap()
|
|
.compile(&mut c, "APP")
|
|
.unwrap()),
|
|
" 2 \n 3 \nLIB\nLAST\n"
|
|
);
|
|
let moved = t.0.join("moved/app.mak");
|
|
for text in [
|
|
"Mixed.tbl\n' $STARTUP: \"Mixed.tbl\"",
|
|
"Mixed.tbl\nmixed.TBL\n",
|
|
"absent.tbl\n",
|
|
] {
|
|
assert!(Manifest::parse(&moved, text, &loader).is_err(), "{text}");
|
|
}
|
|
assert!(
|
|
tb_vm::project_io::read_document(&t.0.join("moved/Mixed.tbl"))
|
|
.unwrap_err()
|
|
.contains("kein Textdokument")
|
|
);
|
|
fs::remove_file(t.0.join("moved/Mixed.tbl")).unwrap();
|
|
let error = loader.load(&moved).unwrap_err();
|
|
assert!(
|
|
error.contains("app.mak") && error.contains("mixed.TBL"),
|
|
"{error}"
|
|
);
|
|
}
|