use std::{ fs, path::PathBuf, sync::atomic::{AtomicUsize, Ordering}, }; use tb_ide::documents::{Decision, Destination, Project, SavePlan, StartupRemoval}; use tb_vm::project_io::{Content, SourceLoader}; struct Temp(PathBuf); impl Temp { fn new() -> Self { static N: AtomicUsize = AtomicUsize::new(0); let p = std::env::temp_dir().join(format!( "tb-documents-{}-{}", 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 } fn plan(&self, name: &str) -> SavePlan { SavePlan { project: Some(Destination::new(self.0.join(name))), ..SavePlan::default() } } } impl Drop for Temp { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } #[test] fn project_create_save_reopen_preserves_forms_members_startup_and_cli_sources() { let t = Temp::new(); let mut p = Project::new(&t.0).unwrap(); let a = p.new_module("Main").unwrap(); let b = p.new_module("Lib").unwrap(); let form = p.new_form("Form1").unwrap(); p.replace_text(a, 0..0, "CALL Answer\nEND\n").unwrap(); p.replace_text(b, 0..0, "SUB Answer\nPRINT 7\nEND SUB\n") .unwrap(); p.edit_form(form,|f| {let parsed=tb_ui::frm::read_text("f.frm","VERSION 1.00\nBegin Form Form1\n Begin CommandButton Button1\n Index = 0\n End\n Begin CommandButton Button1\n Index = 2\n End\nEnd\n\nSUB Button1_Click(Index AS INTEGER)\nEND SUB\n")?;*f=parsed;Ok(())}).unwrap(); p.set_startup(Some(a)).unwrap(); let mut plan = t.plan("p.mak"); for (id, name) in [(a, "Main.bas"), (b, "Lib.bas"), (form, "Form1.frm")] { plan.files.insert(id, Destination::new(t.0.join(name))); } p.save_project(&plan).unwrap(); assert!(!p.is_dirty()); let restored = Project::open(&t.0.join("p.mak"), vec![]).unwrap(); assert_eq!(restored.members().len(), 3); assert_eq!( restored .document(restored.startup().unwrap()) .unwrap() .path() .unwrap() .file_name() .unwrap(), "Main.bas" ); let input = restored.sources().unwrap(); assert_eq!(input.forms[0].root.children.len(), 2); assert!(input.forms[0].code.contains("Button1_Click")); assert!(fs::read_to_string(t.0.join("Form1.frm")) .unwrap() .lines() .any(|line| line.split_whitespace().collect::>() == ["Index", "=", "0"])); let disk = SourceLoader::default().load(&t.0.join("p.mak")).unwrap(); assert_eq!(disk.forms, input.forms); let mut catalog = tb_frontend::forms::FormCatalog::default(); for f in &disk.forms { catalog.append(&f.catalog()); } tb_vm::compile_project("P", &disk.units, &catalog, &disk.forms).unwrap(); } #[cfg(unix)] #[test] fn unrepresentable_save_paths_fail_before_writing_or_changing_documents() { use std::{ffi::OsString, os::unix::ffi::OsStringExt}; let t = Temp::new(); let mut p = Project::new(&t.0).unwrap(); let id = p.new_module("Main").unwrap(); p.replace_text(id, 0..0, "PRINT 1\n").unwrap(); for name in [ OsString::from_vec(b"bad\xff.bas".to_vec()), "bad\n.bas".into(), ] { let target = t.0.join(name); assert!(p.save_file(id, Some(&Destination::new(&target))).is_err()); assert!(!target.exists()); assert!(p.document(id).unwrap().path().is_none()); assert!(p.document(id).unwrap().is_dirty()); assert_eq!(p.document(id).unwrap().code(), "PRINT 1\n"); } let manifest = tb_vm::project_io::Manifest { lines: vec![tb_vm::project_io::ProjectLine::File( t.0.join(OsString::from_vec(b"bad\xff.bas".to_vec())), )], startup: None, }; assert!(manifest.text(&t.0.join("p.mak")).is_err()); assert_eq!(fs::read_dir(&t.0).unwrap().count(), 0); } #[test] fn shared_views_text_import_and_undo_are_document_transactions() { let t = Temp::new(); let mut p = Project::new(&t.0).unwrap(); let id = p.new_module("Main").unwrap(); p.replace_text(id, 0..0, "äbcdef\n").unwrap(); let a = p.open_view(id).unwrap(); let b = p.open_view(id).unwrap(); p.view_mut(a).unwrap().cursor = 2; p.view_mut(b).unwrap().cursor = 7; p.view_mut(b).unwrap().scroll_line = 9; let import = t.write("text.txt", "HELLO"); p.load_text(id, 2, &import).unwrap(); assert_eq!(p.view_code(a).unwrap(), "äHELLObcdef\n"); assert_eq!(p.view_code(a).unwrap(), p.view_code(b).unwrap()); assert_eq!(p.view(b).unwrap().cursor, 12); assert_eq!(p.view(a).unwrap().cursor, 2); assert!(p.undo(id).unwrap()); assert_eq!(p.document(id).unwrap().code(), "äbcdef\n"); assert_eq!(p.view(b).unwrap().cursor, 7); assert_eq!(p.view(b).unwrap().scroll_line, 9); p.replace_text(id, 2..7, "").unwrap(); assert_eq!(p.view(b).unwrap().cursor, 2); p.undo(id).unwrap(); p.close_view(a).unwrap(); assert!(p.document(id).is_ok()); assert_eq!(p.view_code(b).unwrap(), "äbcdef\n"); let rev = p.document(id).unwrap().revision(); assert!(p.replace_text(id, 1..2, "bad").is_err()); assert_eq!(p.document(id).unwrap().revision(), rev); p.save_text(id, Some(0..2), &Destination::new(t.0.join("selection.txt"))) .unwrap(); assert_eq!(fs::read_to_string(t.0.join("selection.txt")).unwrap(), "ä"); } #[test] fn edited_include_wins_in_both_modules_without_writing_the_expansion() { let t = Temp::new(); let path = t.write("p.mak", "a.bas\nb.bas\n"); t.write("a.bas", "'$INCLUDE: 'shared.bi'\nEND\n"); t.write("b.bas", "'$INCLUDE: 'shared.bi'\n"); let file = t.write("shared.bi", "CONST N=1\n"); let mut p = Project::open(&path, vec![]).unwrap(); let id = p.open_document(&file).unwrap(); assert_eq!(p.open_document(&file).unwrap(), id); p.replace_text(id, 8..9, "9").unwrap(); let input = p.sources().unwrap(); for u in input.units { assert!(u.segments.iter().any(|s| s.text == "CONST N=9\n")); } assert_eq!(fs::read_to_string(&file).unwrap(), "CONST N=1\n"); p.save_file(id, None).unwrap(); assert_eq!(fs::read_to_string(&file).unwrap(), "CONST N=9\n"); assert_eq!( fs::read_to_string(t.0.join("a.bas")).unwrap(), "'$INCLUDE: 'shared.bi'\nEND\n" ); } #[test] fn failed_open_add_and_cancel_do_not_replace_existing_documents() { let t = Temp::new(); let mut p = Project::new(&t.0).unwrap(); let id = p.new_module("Current").unwrap(); p.replace_text(id, 0..0, "PRINT 1\n").unwrap(); let cyclic = t.write("cyclic.bas", "'$INCLUDE: 'cyclic.bas'\n"); let missing = t.write("bad.mak", "missing.bas\n"); for path in [cyclic.clone(), missing] { assert!(p.open_project(&path, Decision::Discard).is_err()); assert_eq!(p.document(id).unwrap().code(), "PRINT 1\n"); } assert!(p.add_file(&cyclic).is_err()); assert_eq!(p.members(), vec![id]); assert!(!p.new_project(Decision::Cancel).unwrap()); assert!(!p.prepare_close(Decision::Cancel).unwrap()); assert!(p.document(id).unwrap().is_dirty()); let valid = t.write("valid.bas", "END\n"); assert!(!p.open_project(&valid, Decision::Cancel).unwrap()); assert!(p.open_project(&valid, Decision::Discard).unwrap()); assert!(p.document(id).is_err()); } #[test] fn startup_removal_requires_explicit_replacement_and_never_deletes_a_file() { let t = Temp::new(); let path = t.write("p.mak", "' header\na.bas\nb.bas\n"); let a_path = t.write("a.bas", "END\n"); t.write("b.bas", ""); let mut p = Project::open(&path, vec![]).unwrap(); let ids = p.members(); p.set_startup(Some(ids[0])).unwrap(); assert!(p.remove_file(ids[0], None).is_err()); assert!(!p.remove_file(ids[0], Some(StartupRemoval::Cancel)).unwrap()); assert_eq!(p.members(), ids); assert!(p .remove_file(ids[0], Some(StartupRemoval::Replace(ids[0]))) .is_err()); assert_eq!(p.startup(), Some(ids[0])); p.remove_file(ids[0], Some(StartupRemoval::Replace(ids[1]))) .unwrap(); assert_eq!(p.startup(), Some(ids[1])); assert!(a_path.exists()); assert!(p.document(ids[0]).is_ok()); p.save_project(&SavePlan::default()).unwrap(); let saved = fs::read_to_string(path).unwrap(); assert!(saved.contains("' header")); assert!(!saved.lines().any(|l| l == "a.bas")); p.remove_file(ids[1], Some(StartupRemoval::Default)) .unwrap(); assert_eq!(p.startup(), None); } #[test] fn external_changes_and_save_as_collisions_need_explicit_decisions() { let t = Temp::new(); let file = t.write("main.bas", "old\n"); let mut p = Project::open(&file, vec![]).unwrap(); let id = p.members()[0]; p.replace_text(id, 0..3, "new").unwrap(); fs::write(&file, "external\n").unwrap(); assert!(p .save_file(id, None) .unwrap_err() .to_string() .contains("Überschreibentscheidung")); assert_eq!(fs::read_to_string(&file).unwrap(), "external\n"); assert!(p.document(id).unwrap().is_dirty()); p.save_file( id, Some(&Destination { path: file.clone(), overwrite: true, }), ) .unwrap(); assert_eq!(fs::read_to_string(&file).unwrap(), "new\n"); let other = t.write("other.bas", "keep\n"); assert!(p.save_file(id, Some(&Destination::new(&other))).is_err()); assert_eq!(p.document(id).unwrap().path(), Some(file.as_path())); p.save_file( id, Some(&Destination { path: other.clone(), overwrite: true, }), ) .unwrap(); assert_eq!(fs::read_to_string(&other).unwrap(), "new\n"); assert_eq!(p.document(id).unwrap().path(), Some(other.as_path())); assert!(p .save_text( id, None, &Destination { path: other, overwrite: true } ) .is_err()); } #[test] fn partial_save_preserves_unsaved_data_and_does_not_finish_close() { let t = Temp::new(); let path = t.write("p.mak", "a.bas\nb.bas\n"); let a = t.write("a.bas", "old A\n"); let b = t.write("b.bas", "old B\n"); let mut p = Project::open(&path, vec![]).unwrap(); let ids = p.members(); for id in &ids { p.replace_text(*id, 0..3, "new").unwrap(); } let mut perms = fs::metadata(&b).unwrap().permissions(); perms.set_readonly(true); fs::set_permissions(&b, perms).unwrap(); let result = p.prepare_close(Decision::Save(&SavePlan::default())); assert!(result.is_err()); assert!(!p.document(ids[0]).unwrap().is_dirty()); assert!(p.document(ids[1]).unwrap().is_dirty()); assert!(p.is_dirty()); assert_eq!(p.path(), Some(path.as_path())); assert_eq!(fs::read_to_string(&a).unwrap(), "new A\n"); assert_eq!(fs::read_to_string(&b).unwrap(), "old B\n"); assert_eq!(fs::read_to_string(&path).unwrap(), "a.bas\nb.bas\n"); // Die ursprünglichen Schreibrechte aus einer normalen Datei wiederherstellen. fs::set_permissions(&b, fs::metadata(&a).unwrap().permissions()).unwrap(); assert!(p .prepare_close(Decision::Save(&SavePlan::default())) .unwrap()); assert!(!p.is_dirty()); assert!(fs::read_dir(&t.0).unwrap().all(|e| !e .unwrap() .file_name() .to_string_lossy() .starts_with(".tb-save-"))); } #[test] fn project_save_as_rebases_members_and_keeps_include_targets() { let t = Temp::new(); let path = t.write("old/p.mak", "main.bas\n' retained\n"); t.write("old/main.bas", "'$INCLUDE: 'inner.bi'\nEND\n"); t.write("old/inner.bi", "PRINT 3\n"); fs::create_dir(t.0.join("new")).unwrap(); let mut p = Project::open(&path, vec![]).unwrap(); let before = p.sources().unwrap(); let target = t.0.join("new/q.mak"); p.save_project(&t.plan("new/q.mak")).unwrap(); assert_eq!(p.path(), Some(target.as_path())); let reopened = Project::open(&target, vec![]).unwrap(); assert_eq!( before.units[0] .segments .iter() .map(|s| &s.text) .collect::>(), reopened.sources().unwrap().units[0] .segments .iter() .map(|s| &s.text) .collect::>() ); assert!(fs::read_to_string(&target) .unwrap() .contains("../old/main.bas")); let bad = t.plan("missing/q.mak"); assert!(p.save_project(&bad).is_err()); assert_eq!(p.path(), Some(target.as_path())); } #[test] fn binary_import_only_saves_to_explicit_text_target_and_preserves_original_forever() { let t = Temp::new(); let bytes: Vec = include_str!("../../tb-ui/tests/data/new.frm.hex") .split_whitespace() .map(|s| u8::from_str_radix(s, 16).unwrap()) .collect(); let binary = t.0.join("source.frm"); fs::write(&binary, &bytes).unwrap(); let mut p = Project::open(&binary, vec![]).unwrap(); let id = p.members()[0]; p.replace_text(id, 0..0, "SUB Form_Load\nEND SUB\n") .unwrap(); assert!(p .save_file(id, None) .unwrap_err() .to_string() .contains("binäre FRM")); let text = t.0.join("converted.frm"); p.save_file(id, Some(&Destination::new(&text))).unwrap(); assert!(fs::read_to_string(&text).unwrap().starts_with("VERSION")); assert_eq!(fs::read(&binary).unwrap(), bytes); assert!(p .save_file( id, Some(&Destination { path: binary.clone(), overwrite: true }) ) .is_err()); let original = p.document(id).unwrap().content().clone(); p.edit_form(id, |f| { f.root.name = "Edited".into(); f.code.push_str("' changed\n"); Ok(()) }) .unwrap(); assert!(p.undo(id).unwrap()); assert_eq!(p.document(id).unwrap().content(), &original); let mut plan = t.plan("converted.mak"); p.save_project(&plan).unwrap(); plan.project = None; let restored = Project::open(&t.0.join("converted.mak"), vec![]).unwrap(); assert!(matches!( restored.document(restored.members()[0]).unwrap().content(), Content::Form(_) )); } #[test] fn save_plan_prevents_same_destination_and_project_overwrite() { let t = Temp::new(); let mut p = Project::new(&t.0).unwrap(); let a = p.new_module("A").unwrap(); let b = p.new_module("B").unwrap(); let mut plan = t.plan("p.mak"); let target = t.0.join("same.bas"); plan.files.insert(a, Destination::new(&target)); plan.files.insert(b, Destination::new(&target)); assert!(p.save_project(&plan).is_err()); assert!(!target.exists()); assert!(p.document(a).unwrap().is_dirty()); assert!(p .save_file(a, Some(&Destination::new(t.0.join("bad.frm")))) .is_err()); assert!(p.new_module("../bad").is_err()); } #[test] fn module_save_as_rebases_includes_and_failed_save_does_not_change_identity_or_text() { let t = Temp::new(); let path = t.write("old/p.mak", "main.bas\n"); let original = "'$INCLUDE: 'common.bi' ' keep comment\nEND\n"; t.write("old/main.bas", original); t.write("old/common.bi", "PRINT 42\n"); fs::create_dir(t.0.join("new")).unwrap(); let mut p = Project::open(&path, vec![]).unwrap(); let id = p.members()[0]; let old = p.document(id).unwrap().source_path().to_path_buf(); assert!(p .save_file(id, Some(&Destination::new(t.0.join("missing/main.bas")))) .is_err()); assert_eq!(p.document(id).unwrap().source_path(), old); assert_eq!(p.document(id).unwrap().code(), original); let view = p.open_view(id).unwrap(); p.save_file(id, Some(&Destination::new(t.0.join("new/main.bas")))) .unwrap(); assert!(p .document(id) .unwrap() .code() .contains("../old/common.bi' ' keep comment")); assert_eq!(p.view(view).unwrap().document(), id); p.save_project(&SavePlan::default()).unwrap(); let restored = Project::open(&path, vec![]).unwrap(); assert!(restored.sources().unwrap().units[0] .segments .iter() .any(|s| s.text == "PRINT 42\n")); assert_eq!(fs::read_to_string(old).unwrap(), original); } #[test] fn aliases_share_documents_and_form_code_offsets_match_saved_text() { let t = Temp::new(); let file = t.write("Mixed.bas", "PRINT 1\n"); let mut p = Project::new(&t.0).unwrap(); let a = p.add_file(&file).unwrap(); assert_eq!(p.open_document(&t.0.join("MIXED.BAS")).unwrap(), a); #[cfg(unix)] { std::os::unix::fs::symlink(&file, t.0.join("alias.bas")).unwrap(); assert_eq!(p.open_document(&t.0.join("alias.bas")).unwrap(), a); } let id = p.new_form("F").unwrap(); p.replace_text(id, 0..0, "SUB Form_Load\nERROR 6\nEND SUB\n") .unwrap(); let target = t.0.join("F.frm"); p.save_file(id, Some(&Destination::new(&target))).unwrap(); // Der gemeinsame Speicherhook ergänzt DECLAREs; Speicherstand und Disk müssen dieselben physischen Zeilen liefern. let before = p.sources().unwrap(); let disk = SourceLoader::default().load(&target).unwrap(); assert_eq!( before.units[1] .segments .iter() .map(|s| (&s.text, s.first_line)) .collect::>(), disk.units[0] .segments .iter() .map(|s| (&s.text, s.first_line)) .collect::>() ); } #[test] fn cursor_after_shortening_uses_original_position_and_open_after_save_uses_fresh_disk() { let t = Temp::new(); let path = t.write("p.mak", "main.bas\n"); t.write("main.bas", "abcdefgh\n"); let mut p = Project::open(&path, vec![]).unwrap(); let id = p.members()[0]; let v = p.open_view(id).unwrap(); p.view_mut(v).unwrap().cursor = 9; p.replace_text(id, 1..2, "").unwrap(); assert_eq!(p.view(v).unwrap().cursor, 8); p.undo(id).unwrap(); assert_eq!(p.view(v).unwrap().cursor, 9); p.replace_text(id, 0..8, "PRINT 7").unwrap(); assert!(p .open_project(&path, Decision::Save(&SavePlan::default())) .unwrap()); assert_eq!(p.document(p.members()[0]).unwrap().code(), "PRINT 7\n"); }