Files
TerminalBasic/crates/tb-ide/tests/documents.rs

539 lines
20 KiB
Rust

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::<Vec<_>>() == ["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::<Vec<_>>(),
reopened.sources().unwrap().units[0]
.segments
.iter()
.map(|s| &s.text)
.collect::<Vec<_>>()
);
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<u8> = 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::<Vec<_>>(),
disk.units[0]
.segments
.iter()
.map(|s| (&s.text, s.first_line))
.collect::<Vec<_>>()
);
}
#[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");
}
#[test]
fn libraries_are_binary_members_with_relative_save_as_and_no_source_views() {
let t = Temp::new();
let lib = t.write("lib.bas", "SUB Answer\nPRINT 42\nEND SUB\n");
let mut compiler = tb_vm::project::ProjectCompiler::default();
let bytes = SourceLoader::default()
.load(&lib)
.unwrap()
.compile_library(&mut compiler)
.unwrap()
.to_tbl()
.unwrap();
let tbl = t.0.join("lib.tbl");
fs::write(&tbl, &bytes).unwrap();
fs::remove_file(lib).unwrap();
let main = t.write("main.bas", "CALL Answer\nEND\n");
let mak = t.write("p.mak", "lib.tbl\nmain.bas\n'$STARTUP: \"main.bas\"\n");
let mut p = Project::open(&mak, vec![]).unwrap();
let id = p.members()[0];
assert!(p.document(id).unwrap().is_library());
assert!(p.open_view(id).is_err());
assert!(p.replace_text(id, 0..0, "bad").is_err());
assert!(p.save_file(id, None).is_err());
assert!(p.set_startup(Some(id)).is_err());
assert_eq!(p.source_members().len(), 1);
p.sources()
.unwrap()
.compile(&mut compiler, "consumer")
.unwrap();
fs::create_dir(t.0.join("sub")).unwrap();
p.save_project(&t.plan("sub/p.mak")).unwrap();
let text = fs::read_to_string(t.0.join("sub/p.mak")).unwrap();
assert!(text.contains("../lib.tbl"), "{text}");
assert_eq!(fs::read(&tbl).unwrap(), bytes);
let mut reopened = Project::open(&t.0.join("sub/p.mak"), vec![]).unwrap();
let id = reopened.members()[0];
reopened.remove_file(id, None).unwrap();
assert!(tbl.exists());
reopened.add_file(&tbl).unwrap();
assert_eq!(reopened.manifest().members().last().unwrap(), &tbl);
assert!(reopened.find_document(&main).is_some());
reopened
.sources()
.unwrap()
.compile(&mut compiler, "consumer")
.unwrap();
}