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::(); 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"); let docs = include_str!("../../../docs/dateiformate.md"); 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}" ); }