853 lines
32 KiB
Rust
853 lines
32 KiB
Rust
//! Accepted graphical and source state, committed and restored together.
|
|
//! Editor drafts and filesystem receipts belong to their respective adapters.
|
|
use oa_core::{
|
|
Command, Id, ModelError, Project, language, prepare_command,
|
|
source_project::{self, CompiledSourceProject, ProjectLimits, SourceBundle, SourceDiagnostic},
|
|
validation::Severity,
|
|
};
|
|
use std::{fmt, sync::Arc};
|
|
|
|
const HISTORY_LIMIT: usize = 100;
|
|
const HISTORY_BYTES: usize = 32 * 1024 * 1024;
|
|
|
|
#[path = "document_alias_rename.rs"]
|
|
mod alias_rename;
|
|
pub use alias_rename::PreparedAliasRename;
|
|
|
|
struct HistoryEntry {
|
|
snapshot: Arc<CompiledSourceProject>,
|
|
bytes: usize,
|
|
}
|
|
|
|
struct RelocatedHistory {
|
|
current: Arc<CompiledSourceProject>,
|
|
past: Vec<HistoryEntry>,
|
|
future: Vec<HistoryEntry>,
|
|
}
|
|
|
|
fn estimated_bytes(compiled: &CompiledSourceProject) -> usize {
|
|
compiled
|
|
.bundle
|
|
.files
|
|
.values()
|
|
.fold(0usize, |sum, text| sum.saturating_add(text.len()))
|
|
.saturating_add(
|
|
serde_json::to_vec(&compiled.decoded.project).map_or(usize::MAX, |bytes| bytes.len()),
|
|
)
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum DocumentError {
|
|
Model(ModelError),
|
|
Source(Vec<SourceDiagnostic>),
|
|
}
|
|
|
|
impl DocumentError {
|
|
pub fn diagnostics(&self) -> &[SourceDiagnostic] {
|
|
match self {
|
|
Self::Model(_) => &[],
|
|
Self::Source(diagnostics) => diagnostics,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for DocumentError {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::Model(error) => error.fmt(formatter),
|
|
Self::Source(diagnostics) => {
|
|
if let Some(first) = diagnostics.first() {
|
|
write!(
|
|
formatter,
|
|
"{}:{}:{}: {}",
|
|
first.path,
|
|
first.diagnostic.line,
|
|
first.diagnostic.column,
|
|
first.diagnostic.message
|
|
)
|
|
} else {
|
|
formatter.write_str("Source validation failed.")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for DocumentError {
|
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
|
match self {
|
|
Self::Model(error) => Some(error),
|
|
Self::Source(_) => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<ModelError> for DocumentError {
|
|
fn from(error: ModelError) -> Self {
|
|
Self::Model(error)
|
|
}
|
|
}
|
|
|
|
impl From<Vec<SourceDiagnostic>> for DocumentError {
|
|
fn from(diagnostics: Vec<SourceDiagnostic>) -> Self {
|
|
Self::Source(diagnostics)
|
|
}
|
|
}
|
|
|
|
pub struct Document {
|
|
// Distinguishes equal accepted snapshots belonging to different documents.
|
|
alias_owner: Arc<()>,
|
|
current: Arc<CompiledSourceProject>,
|
|
past: Vec<HistoryEntry>,
|
|
future: Vec<HistoryEntry>,
|
|
current_bytes: usize,
|
|
saved: Option<Arc<SourceBundle>>,
|
|
revision: u64,
|
|
view_index: std::cell::OnceCell<oa_core::view_options::ViewIndex>,
|
|
limits: ProjectLimits,
|
|
recovered_unsaved: bool,
|
|
}
|
|
|
|
impl Document {
|
|
/// A valid generated workspace starts clean, like a new semantic Session.
|
|
pub fn from_project(project: Project, limits: ProjectLimits) -> Result<Self, DocumentError> {
|
|
oa_core::validate_candidate(&project)?;
|
|
let document = Self::from_bundle(source_project::format_bundle(&project), limits)?;
|
|
if document.project() != &project {
|
|
return Err(DocumentError::Model(ModelError(
|
|
"Generated source does not reproduce the supplied project.".into(),
|
|
)));
|
|
}
|
|
Ok(document)
|
|
}
|
|
|
|
/// Retain the exact input as the saved baseline. If compilation inserts
|
|
/// missing IDs, those accepted source changes still need saving.
|
|
pub fn from_bundle(bundle: SourceBundle, limits: ProjectLimits) -> Result<Self, DocumentError> {
|
|
let current = Arc::new(source_project::compile_bundle(&bundle, limits)?);
|
|
let current_bytes = estimated_bytes(¤t);
|
|
Ok(Self {
|
|
alias_owner: Arc::new(()),
|
|
current,
|
|
current_bytes,
|
|
past: Vec::new(),
|
|
future: Vec::new(),
|
|
saved: Some(Arc::new(bundle)),
|
|
revision: 0,
|
|
view_index: Default::default(),
|
|
limits,
|
|
recovered_unsaved: false,
|
|
})
|
|
}
|
|
|
|
pub fn project(&self) -> &Project {
|
|
&self.current.decoded.project
|
|
}
|
|
|
|
/// Semantic reachability is shared by every diagram at this accepted revision.
|
|
pub fn view_index(&self) -> &oa_core::view_options::ViewIndex {
|
|
self.view_index
|
|
.get_or_init(|| oa_core::view_options::ViewIndex::new(self.project()))
|
|
}
|
|
|
|
pub fn bundle(&self) -> &SourceBundle {
|
|
&self.current.bundle
|
|
}
|
|
|
|
pub fn compiled(&self) -> &CompiledSourceProject {
|
|
&self.current
|
|
}
|
|
|
|
pub fn revision(&self) -> u64 {
|
|
self.revision
|
|
}
|
|
|
|
pub fn dirty(&self) -> bool {
|
|
self.recovered_unsaved || self.saved.as_deref() != Some(self.bundle())
|
|
}
|
|
|
|
/// Recovered work is a detached copy until explicitly saved by its user.
|
|
pub fn mark_recovered(&mut self) {
|
|
self.recovered_unsaved = true;
|
|
}
|
|
|
|
pub fn can_undo(&self) -> bool {
|
|
!self.past.is_empty()
|
|
}
|
|
|
|
pub fn can_redo(&self) -> bool {
|
|
!self.future.is_empty()
|
|
}
|
|
|
|
/// Capture this before asynchronous saving. Completion must return the same
|
|
/// snapshot, rather than marking whatever is current at completion as saved.
|
|
pub fn snapshot(&self) -> Arc<CompiledSourceProject> {
|
|
self.current.clone()
|
|
}
|
|
|
|
/// Records the bytes actually saved, even when later edits remain current.
|
|
/// The I/O adapter owns document identity and save completion ordering.
|
|
pub fn mark_saved(&mut self, snapshot: Arc<CompiledSourceProject>) {
|
|
self.saved = Some(Arc::new(snapshot.bundle.clone()));
|
|
self.recovered_unsaved = false;
|
|
}
|
|
|
|
/// Bind an installed or recovered repair destination to source read there.
|
|
/// These bytes may be invalid or differ from the retained accepted model,
|
|
/// but must replace the previous disk baseline. Accepted history is retained;
|
|
/// applying or undoing to old saved content does not save this destination.
|
|
pub(crate) fn bind_repair_baseline(&mut self, source: SourceBundle) {
|
|
self.saved = Some(Arc::new(source));
|
|
}
|
|
|
|
/// Recovery changed the current destination, but its source could not be
|
|
/// reloaded. No accepted or historical snapshot is known to be saved there.
|
|
pub(crate) fn invalidate_saved_baseline(&mut self) {
|
|
self.saved = None;
|
|
}
|
|
|
|
/// Check a source Save as before writing any files. A retained included file
|
|
/// must never be displaced by the new entry, including on a later Undo.
|
|
pub fn check_save_entry(&self, entry: &str) -> Result<(), DocumentError> {
|
|
if entry == self.bundle().entry {
|
|
return Ok(());
|
|
}
|
|
self.relocated_history(entry).map(|_| ())
|
|
}
|
|
|
|
/// Adopt a successfully saved source entry without recording an engineering
|
|
/// edit or clearing Redo. The caller still marks the exact receipt as saved.
|
|
pub fn adopt_saved_entry(
|
|
&mut self,
|
|
saved: &CompiledSourceProject,
|
|
revision: u64,
|
|
) -> Result<(), DocumentError> {
|
|
if revision != self.revision {
|
|
return Err(
|
|
ModelError("Newer edits retain the current source location.".into()).into(),
|
|
);
|
|
}
|
|
let RelocatedHistory {
|
|
current,
|
|
past,
|
|
future,
|
|
} = self.relocated_history(&saved.bundle.entry)?;
|
|
if current.bundle != saved.bundle
|
|
|| current.decoded.project != saved.decoded.project
|
|
|| current.index != saved.index
|
|
{
|
|
return Err(ModelError(
|
|
"The save receipt changes more than the source entry location.".into(),
|
|
)
|
|
.into());
|
|
}
|
|
if current.bundle != self.current.bundle {
|
|
self.current_bytes = estimated_bytes(¤t);
|
|
self.current = current;
|
|
self.past = past;
|
|
self.future = future;
|
|
// Source-position readers must reject their old revision even though
|
|
// changing the save location does not create an Undo transaction.
|
|
self.revision = self.revision.saturating_add(1);
|
|
self.view_index.take();
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn relocated_history(&self, entry: &str) -> Result<RelocatedHistory, DocumentError> {
|
|
if entry.contains('/') || source_project::resolve_include("", entry).as_deref() != Ok(entry)
|
|
{
|
|
return Err(ModelError("Choose a portable .arcadia entry file name.".into()).into());
|
|
}
|
|
let snapshots = std::iter::once(self.current.as_ref())
|
|
.chain(self.past.iter().map(|h| h.snapshot.as_ref()))
|
|
.chain(self.future.iter().map(|h| h.snapshot.as_ref()));
|
|
let mut uniform_entry = true;
|
|
for snapshot in snapshots {
|
|
uniform_entry &= snapshot.bundle.entry == self.bundle().entry;
|
|
if snapshot.bundle.files.keys().any(|path| {
|
|
path != &snapshot.bundle.entry && path.to_lowercase() == entry.to_lowercase()
|
|
}) {
|
|
return Err(ModelError(format!(
|
|
"The entry name '{entry}' is used by an included file in this document or its Undo/Redo history. Choose another entry name."
|
|
)).into());
|
|
}
|
|
}
|
|
let relocate = |snapshot: &Arc<CompiledSourceProject>| -> Result<Arc<CompiledSourceProject>, DocumentError> {
|
|
if snapshot.bundle.entry == entry {
|
|
return Ok(snapshot.clone());
|
|
}
|
|
let mut bundle = snapshot.bundle.clone();
|
|
let text = bundle
|
|
.files
|
|
.remove(&bundle.entry)
|
|
.ok_or_else(|| ModelError("The accepted source entry is missing.".into()))?;
|
|
bundle.entry = entry.to_owned();
|
|
bundle.files.insert(entry.to_owned(), text);
|
|
let compiled = source_project::compile_bundle(&bundle, self.limits)?;
|
|
if compiled.bundle != bundle || compiled.decoded.project != snapshot.decoded.project {
|
|
return Err(ModelError(
|
|
"Changing the entry location would change the model or authored source.".into(),
|
|
)
|
|
.into());
|
|
}
|
|
Ok(Arc::new(compiled))
|
|
};
|
|
let current = relocate(&self.current)?;
|
|
let history = |entries: &[HistoryEntry]| {
|
|
entries
|
|
.iter()
|
|
.map(|old| {
|
|
// Different entry names in history are authored source changes.
|
|
// Keep those snapshots exact instead of collapsing real edits.
|
|
let snapshot = if uniform_entry {
|
|
relocate(&old.snapshot)?
|
|
} else {
|
|
old.snapshot.clone()
|
|
};
|
|
Ok(HistoryEntry {
|
|
bytes: estimated_bytes(&snapshot),
|
|
snapshot,
|
|
})
|
|
})
|
|
.collect::<Result<Vec<_>, DocumentError>>()
|
|
};
|
|
Ok(RelocatedHistory {
|
|
current,
|
|
past: history(&self.past)?,
|
|
future: history(&self.future)?,
|
|
})
|
|
}
|
|
|
|
pub fn execute(&mut self, command: Command) -> Result<Option<Id>, DocumentError> {
|
|
let candidate = prepare_command(self.project(), &command)?;
|
|
// Equal model values can still carry different record provenance, such
|
|
// as deleting and recreating an identical record with a reused UUID.
|
|
// Only the resulting model/source pair determines whether this is a no-op.
|
|
let compiled = source_project::update_bundle_with_identity_map(
|
|
&self.current,
|
|
&candidate.project,
|
|
&candidate.identity_map,
|
|
self.limits,
|
|
)?;
|
|
self.commit(compiled);
|
|
Ok(candidate.selected)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub fn apply_bundle(&mut self, bundle: SourceBundle) -> Result<bool, DocumentError> {
|
|
let compiled = source_project::compile_bundle(&bundle, self.limits)?;
|
|
Ok(self.commit(compiled))
|
|
}
|
|
|
|
/// Public compiled fields are convenient data, not a validation capability.
|
|
/// Rebuild diagnostics/index and reject a stale or altered model/index pair.
|
|
pub fn apply_compiled(
|
|
&mut self,
|
|
compiled: CompiledSourceProject,
|
|
) -> Result<bool, DocumentError> {
|
|
let checked = source_project::compile_bundle(&compiled.bundle, self.limits)?;
|
|
if checked.bundle != compiled.bundle
|
|
|| checked.decoded.project != compiled.decoded.project
|
|
|| checked.index != compiled.index
|
|
{
|
|
return Err(DocumentError::Source(vec![SourceDiagnostic {
|
|
path: compiled.bundle.entry,
|
|
diagnostic: language::Diagnostic {
|
|
severity: Severity::Error,
|
|
code: "stale_source_index".into(),
|
|
object_id: None,
|
|
field: None,
|
|
line: 1,
|
|
column: 1,
|
|
message: "Compile the current documents before applying this source snapshot."
|
|
.into(),
|
|
span: language::Span::default(),
|
|
},
|
|
related: Vec::new(),
|
|
}]));
|
|
}
|
|
Ok(self.commit(checked))
|
|
}
|
|
|
|
pub fn undo(&mut self) -> bool {
|
|
let Some(previous) = self.past.pop() else {
|
|
return false;
|
|
};
|
|
self.future.push(HistoryEntry {
|
|
snapshot: std::mem::replace(&mut self.current, previous.snapshot),
|
|
bytes: std::mem::replace(&mut self.current_bytes, previous.bytes),
|
|
});
|
|
self.revision = self.revision.saturating_add(1);
|
|
self.view_index.take();
|
|
true
|
|
}
|
|
|
|
pub fn redo(&mut self) -> bool {
|
|
let Some(next) = self.future.pop() else {
|
|
return false;
|
|
};
|
|
self.past.push(HistoryEntry {
|
|
snapshot: std::mem::replace(&mut self.current, next.snapshot),
|
|
bytes: std::mem::replace(&mut self.current_bytes, next.bytes),
|
|
});
|
|
self.revision = self.revision.saturating_add(1);
|
|
self.view_index.take();
|
|
true
|
|
}
|
|
|
|
fn commit(&mut self, compiled: CompiledSourceProject) -> bool {
|
|
if compiled.bundle == self.current.bundle {
|
|
return false;
|
|
}
|
|
let bytes = estimated_bytes(&compiled);
|
|
self.commit_snapshot(Arc::new(compiled), bytes)
|
|
}
|
|
|
|
// Only validated internal paths supply a changed snapshot and its exact
|
|
// estimate. The ordinary commit wrapper checks bundle inequality; the alias
|
|
// path checks its private changed flag and exact accepted-snapshot authority.
|
|
// Alias preparation shares this algorithm while retaining the live save and
|
|
// history state, and does not clone/recompile the candidate on adoption.
|
|
fn commit_snapshot(&mut self, compiled: Arc<CompiledSourceProject>, bytes: usize) -> bool {
|
|
self.past.push(HistoryEntry {
|
|
snapshot: std::mem::replace(&mut self.current, compiled),
|
|
bytes: std::mem::replace(&mut self.current_bytes, bytes),
|
|
});
|
|
self.future.clear();
|
|
let mut retained = self.past.iter().fold(self.current_bytes, |sum, entry| {
|
|
sum.saturating_add(entry.bytes)
|
|
});
|
|
while !self.past.is_empty() && (self.past.len() > HISTORY_LIMIT || retained > HISTORY_BYTES)
|
|
{
|
|
retained = retained.saturating_sub(self.past.remove(0).bytes);
|
|
}
|
|
self.revision = self.revision.saturating_add(1);
|
|
self.view_index.take();
|
|
true
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "document_save_as_tests.rs"]
|
|
mod save_as_tests;
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use oa_core::{Kind, Layer};
|
|
|
|
fn document() -> Document {
|
|
Document::from_project(Project::empty(), ProjectLimits::default()).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn cached_view_index_tracks_accepted_creation_undo_and_redo() {
|
|
use oa_core::{Relation, RelationBody, RelationKind, view_options::ViewOptions};
|
|
let mut p = Project::empty();
|
|
let source = p
|
|
.add_element(Kind::Entity, Layer::Oa, "Team", None)
|
|
.unwrap();
|
|
let target = p
|
|
.add_element(Kind::Actor, Layer::Oa, "Dispatcher", None)
|
|
.unwrap();
|
|
let mut document = Document::from_project(p, ProjectLimits::default()).unwrap();
|
|
let id = Id::new_v4();
|
|
let options = ViewOptions::default();
|
|
let visible = |d: &Document| d.view_index().prepare(Layer::Oa, &options).includes(id);
|
|
assert!(!visible(&document));
|
|
document
|
|
.execute(Command::CreateRelation(Relation {
|
|
id,
|
|
alias: "radio".into(),
|
|
name: "Radio".into(),
|
|
description: String::new(),
|
|
body: RelationBody::new(RelationKind::CommunicationMean, source, target),
|
|
}))
|
|
.unwrap();
|
|
assert!(visible(&document));
|
|
assert!(document.undo());
|
|
assert!(!visible(&document));
|
|
assert!(document.redo());
|
|
assert!(visible(&document));
|
|
}
|
|
|
|
fn comment(bundle: &SourceBundle, text: &str) -> SourceBundle {
|
|
let mut changed = bundle.clone();
|
|
changed
|
|
.files
|
|
.get_mut(&changed.entry)
|
|
.unwrap()
|
|
.push_str(text);
|
|
changed
|
|
}
|
|
|
|
#[test]
|
|
fn creation_selects_the_single_uuid_persisted_in_source() {
|
|
let mut document = document();
|
|
let id = document
|
|
.execute(Command::AddElement {
|
|
kind: Kind::Activity,
|
|
layer: Layer::Oa,
|
|
name: "Assess incident".into(),
|
|
parent: None,
|
|
})
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(document.project().elements.len(), 1);
|
|
assert_eq!(document.project().elements[&id].name, "Assess incident");
|
|
let location = &document.compiled().index.declarations[&id];
|
|
let declaration =
|
|
&document.bundle().files[&location.path][location.span.start..location.span.end];
|
|
assert!(declaration.contains(&id.to_string()));
|
|
let reopened =
|
|
source_project::compile_bundle(document.bundle(), ProjectLimits::default()).unwrap();
|
|
assert_eq!(reopened.decoded.project, *document.project());
|
|
assert_eq!(document.revision(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn rejected_compile_command_and_writeback_preserve_state_and_redo() {
|
|
let bundle = source_project::format_bundle(&Project::empty());
|
|
let bytes = bundle.files.values().map(String::len).sum::<usize>();
|
|
let mut limits = ProjectLimits::default();
|
|
limits.model.max_source_bytes = bytes + 100;
|
|
let mut document = Document::from_bundle(bundle, limits).unwrap();
|
|
document
|
|
.apply_bundle(comment(document.bundle(), "// preserved future\n"))
|
|
.unwrap();
|
|
assert!(document.undo());
|
|
let before = document.snapshot();
|
|
let revision = document.revision();
|
|
let history = (document.past.len(), document.future.len());
|
|
|
|
let malformed = comment(document.bundle(), "?");
|
|
assert!(document.apply_bundle(malformed).is_err());
|
|
assert!(document.execute(Command::Delete(Id::new_v4())).is_err());
|
|
let error = document
|
|
.execute(Command::Rename(document.project().id, "x".repeat(200)))
|
|
.unwrap_err();
|
|
assert!(!error.diagnostics().is_empty());
|
|
assert!(error.to_string().contains(&document.bundle().entry));
|
|
assert!(Arc::ptr_eq(&before, &document.snapshot()));
|
|
assert_eq!(document.revision(), revision);
|
|
assert_eq!((document.past.len(), document.future.len()), history);
|
|
assert!(!document.dirty());
|
|
assert!(document.redo());
|
|
}
|
|
|
|
#[test]
|
|
fn comment_and_semantic_history_restore_exact_bytes_and_branch_together() {
|
|
let mut document = document();
|
|
let initial = document.snapshot();
|
|
let annotated = comment(
|
|
document.bundle(),
|
|
"\r\n// Project rationale: preserve me.\r\n",
|
|
);
|
|
assert!(document.apply_bundle(annotated.clone()).unwrap());
|
|
assert_eq!(document.project(), &initial.decoded.project);
|
|
assert_eq!(document.revision(), 1);
|
|
document
|
|
.execute(Command::Rename(
|
|
document.project().id,
|
|
"Rescue workspace".into(),
|
|
))
|
|
.unwrap();
|
|
let renamed = document.snapshot();
|
|
assert!(document.undo());
|
|
assert_eq!(document.bundle(), &annotated);
|
|
assert!(document.undo());
|
|
assert_eq!(document.bundle(), &initial.bundle);
|
|
assert!(!document.dirty());
|
|
assert!(document.redo());
|
|
assert_eq!(document.bundle(), &annotated);
|
|
assert!(document.redo());
|
|
assert_eq!(document.bundle(), &renamed.bundle);
|
|
assert!(document.undo());
|
|
let branch = comment(document.bundle(), "// A different source-only decision.\n");
|
|
assert!(document.apply_bundle(branch.clone()).unwrap());
|
|
assert!(!document.can_redo());
|
|
assert_eq!(document.project(), &initial.decoded.project);
|
|
assert!(document.undo());
|
|
assert_eq!(document.bundle(), &annotated);
|
|
assert!(document.redo());
|
|
assert_eq!(document.bundle(), &branch);
|
|
}
|
|
|
|
#[test]
|
|
fn nested_cross_file_reparent_and_source_edit_share_exact_history() {
|
|
let bundle = SourceBundle {
|
|
entry: "project.arcadia".into(),
|
|
files: [
|
|
(
|
|
"project.arcadia".into(),
|
|
"arcadia 3; project rescue \"Rescue\" { include \"oa/source.arcadia\"; include \"oa/target.arcadia\"; }\n".into(),
|
|
),
|
|
(
|
|
"oa/source.arcadia".into(),
|
|
"// Source module\r\noa_activity respond \"Respond\" {\r\n // Assessment rationale follows this activity.\r\n oa_activity assess \"Assess\" {}\r\n}\r\n".into(),
|
|
),
|
|
(
|
|
"oa/target.arcadia".into(),
|
|
"// Destination module\noa_activity coordinate \"Coordinate\" {}\n".into(),
|
|
),
|
|
].into(),
|
|
};
|
|
let mut document = Document::from_bundle(bundle, ProjectLimits::default()).unwrap();
|
|
document.mark_saved(document.snapshot());
|
|
let original = document.snapshot();
|
|
let id = |alias: &str| {
|
|
original
|
|
.decoded
|
|
.project
|
|
.elements
|
|
.values()
|
|
.find(|element| element.alias == alias)
|
|
.unwrap()
|
|
.id
|
|
};
|
|
let child = id("assess");
|
|
let destination = id("coordinate");
|
|
document
|
|
.execute(Command::Reparent {
|
|
element: child,
|
|
parent: Some(destination),
|
|
})
|
|
.unwrap();
|
|
let moved = document.snapshot();
|
|
assert_eq!(
|
|
document.project().elements[&child].parent,
|
|
Some(destination)
|
|
);
|
|
assert_eq!(
|
|
document.compiled().index.declarations[&child].path,
|
|
"oa/target.arcadia"
|
|
);
|
|
assert!(
|
|
document.bundle().files["oa/target.arcadia"]
|
|
.contains("// Assessment rationale follows this activity.")
|
|
);
|
|
assert!(
|
|
!document.bundle().files["oa/source.arcadia"]
|
|
.contains("// Assessment rationale follows this activity.")
|
|
);
|
|
let edited = comment(document.bundle(), "// Reviewed containment decision.\n");
|
|
assert!(document.apply_bundle(edited.clone()).unwrap());
|
|
assert_eq!(document.project(), &moved.decoded.project);
|
|
assert!(document.undo());
|
|
assert_eq!(document.bundle(), &moved.bundle);
|
|
assert!(document.undo());
|
|
assert_eq!(document.bundle(), &original.bundle);
|
|
assert_eq!(document.project(), &original.decoded.project);
|
|
assert!(!document.dirty());
|
|
assert!(document.redo());
|
|
assert_eq!(document.bundle(), &moved.bundle);
|
|
assert!(document.redo());
|
|
assert_eq!(document.bundle(), &edited);
|
|
let accepted = document.snapshot();
|
|
let invalid = comment(document.bundle(), "invalid_declaration;");
|
|
assert!(document.apply_bundle(invalid).is_err());
|
|
assert!(Arc::ptr_eq(&accepted, &document.snapshot()));
|
|
let reopened =
|
|
Document::from_bundle(document.bundle().clone(), ProjectLimits::default()).unwrap();
|
|
assert_eq!(reopened.project(), document.project());
|
|
assert_eq!(reopened.bundle(), document.bundle());
|
|
assert!(!reopened.dirty());
|
|
}
|
|
|
|
#[test]
|
|
fn recreated_equal_record_does_not_inherit_deleted_source_ownership() {
|
|
let bundle = SourceBundle {
|
|
entry: "project.arcadia".into(),
|
|
files: [
|
|
(
|
|
"project.arcadia".into(),
|
|
"arcadia 3; project rescue \"Rescue\" { include \"authored.arcadia\"; }\n"
|
|
.into(),
|
|
),
|
|
(
|
|
"authored.arcadia".into(),
|
|
"// Rationale for the original record.\noa_activity assess \"Assess\" {}\n"
|
|
.into(),
|
|
),
|
|
]
|
|
.into(),
|
|
};
|
|
let mut document = Document::from_bundle(bundle, ProjectLimits::default()).unwrap();
|
|
document.mark_saved(document.snapshot());
|
|
let before = document.snapshot();
|
|
let element = document.project().elements.values().next().unwrap().clone();
|
|
let id = element.id;
|
|
document
|
|
.execute(Command::Batch(vec![
|
|
Command::Delete(id),
|
|
Command::CreateElement(element),
|
|
]))
|
|
.unwrap();
|
|
assert_eq!(document.project(), &before.decoded.project);
|
|
assert_ne!(
|
|
document.compiled().index.declarations[&id].path,
|
|
"authored.arcadia"
|
|
);
|
|
assert!(
|
|
document.bundle().files["authored.arcadia"]
|
|
.contains("// Rationale for the original record.")
|
|
);
|
|
assert!(!document.bundle().files["authored.arcadia"].contains("oa_activity"));
|
|
assert!(document.dirty());
|
|
assert_eq!(document.revision(), 1);
|
|
let recreated = document.snapshot();
|
|
assert!(document.undo());
|
|
assert_eq!(document.bundle(), &before.bundle);
|
|
assert!(!document.dirty());
|
|
assert!(document.redo());
|
|
assert_eq!(document.bundle(), &recreated.bundle);
|
|
}
|
|
|
|
#[test]
|
|
fn asynchronous_save_marks_its_snapshot_without_cleaning_later_edits() {
|
|
let mut document = document();
|
|
document
|
|
.apply_bundle(comment(document.bundle(), "// Saved rationale.\n"))
|
|
.unwrap();
|
|
let saving = document.snapshot();
|
|
document
|
|
.apply_bundle(comment(document.bundle(), "// Later unsaved rationale.\n"))
|
|
.unwrap();
|
|
document.mark_saved(saving);
|
|
assert!(document.dirty());
|
|
assert!(document.undo());
|
|
assert!(!document.dirty());
|
|
assert!(document.redo());
|
|
assert!(document.dirty());
|
|
}
|
|
|
|
#[test]
|
|
fn no_op_command_or_source_apply_preserves_history_revision_and_redo() {
|
|
let mut document = document();
|
|
assert!(!document.undo());
|
|
assert!(!document.redo());
|
|
document
|
|
.apply_bundle(comment(document.bundle(), "// Future edit.\n"))
|
|
.unwrap();
|
|
assert!(document.undo());
|
|
let before = document.snapshot();
|
|
let revision = document.revision();
|
|
document
|
|
.execute(Command::Rename(
|
|
document.project().id,
|
|
document.project().name.clone(),
|
|
))
|
|
.unwrap();
|
|
assert!(!document.apply_bundle(document.bundle().clone()).unwrap());
|
|
assert!(
|
|
!document
|
|
.apply_compiled(document.compiled().clone())
|
|
.unwrap()
|
|
);
|
|
assert!(Arc::ptr_eq(&before, &document.snapshot()));
|
|
assert_eq!(document.revision(), revision);
|
|
assert!(document.can_redo());
|
|
assert!(!document.can_undo());
|
|
}
|
|
|
|
#[test]
|
|
fn altered_compiled_model_or_index_is_rejected_without_mutation() {
|
|
let mut document = document();
|
|
let before = document.snapshot();
|
|
let mut forged = document.compiled().clone();
|
|
forged.decoded.project.name = "Not in the source".into();
|
|
assert!(document.apply_compiled(forged).is_err());
|
|
let mut forged = document.compiled().clone();
|
|
forged.index.declarations.clear();
|
|
let error = document.apply_compiled(forged).unwrap_err();
|
|
assert_eq!(error.diagnostics()[0].diagnostic.code, "stale_source_index");
|
|
assert!(Arc::ptr_eq(&before, &document.snapshot()));
|
|
assert_eq!(document.revision(), 0);
|
|
assert!(!document.can_undo());
|
|
}
|
|
|
|
#[test]
|
|
fn omitted_id_stabilization_remains_dirty_until_exact_source_is_saved() {
|
|
let bundle = SourceBundle {
|
|
entry: "project.arcadia".into(),
|
|
files: [(
|
|
"project.arcadia".into(),
|
|
"arcadia 3; project rescue \"Rescue\" {}\n".into(),
|
|
)]
|
|
.into(),
|
|
};
|
|
let mut document = Document::from_bundle(bundle.clone(), ProjectLimits::default()).unwrap();
|
|
assert!(document.dirty());
|
|
assert_ne!(document.bundle(), &bundle);
|
|
let id = document.project().id;
|
|
assert!(!document.apply_bundle(document.bundle().clone()).unwrap());
|
|
assert_eq!(document.project().id, id);
|
|
document.mark_saved(document.snapshot());
|
|
assert!(!document.dirty());
|
|
}
|
|
|
|
#[test]
|
|
fn accepted_history_keeps_the_latest_hundred_complete_snapshots() {
|
|
let mut document = document();
|
|
for change in 0..105 {
|
|
document
|
|
.apply_bundle(comment(
|
|
document.bundle(),
|
|
&format!("// Change {change}.\n"),
|
|
))
|
|
.unwrap();
|
|
}
|
|
assert_eq!(document.past.len(), HISTORY_LIMIT);
|
|
let mut undone = 0;
|
|
while document.undo() {
|
|
undone += 1;
|
|
}
|
|
assert_eq!(undone, HISTORY_LIMIT);
|
|
assert_eq!(
|
|
document.bundle().files[&document.bundle().entry]
|
|
.matches("// Change")
|
|
.count(),
|
|
5
|
|
);
|
|
while document.redo() {}
|
|
assert_eq!(document.past.len(), HISTORY_LIMIT);
|
|
assert_eq!(document.revision(), 305);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_project_is_rejected_before_format_can_normalize_it() {
|
|
let mut project = Project::empty();
|
|
project.schema_version = 17;
|
|
assert!(Document::from_project(project, ProjectLimits::default()).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn byte_budget_trims_oldest_history_without_discarding_a_large_current() {
|
|
let mut document = document();
|
|
let rationale = format!("// {}\n", "x".repeat(11 * 1024 * 1024));
|
|
document
|
|
.apply_bundle(comment(document.bundle(), &rationale))
|
|
.unwrap();
|
|
document
|
|
.apply_bundle(comment(document.bundle(), "// Second version\n"))
|
|
.unwrap();
|
|
document
|
|
.apply_bundle(comment(document.bundle(), "// Third version\n"))
|
|
.unwrap();
|
|
assert_eq!(document.past.len(), 1);
|
|
let before = document.snapshot();
|
|
assert!(document.undo());
|
|
assert!(!document.can_undo());
|
|
assert!(document.redo());
|
|
assert!(Arc::ptr_eq(&before, &document.snapshot()));
|
|
let large = format!("// {}\n", "y".repeat(22 * 1024 * 1024));
|
|
document
|
|
.apply_bundle(comment(document.bundle(), &large))
|
|
.unwrap();
|
|
assert!(document.current_bytes > HISTORY_BYTES);
|
|
assert!(!document.can_undo());
|
|
assert!(document.bundle().files[&document.bundle().entry].ends_with(&large));
|
|
}
|
|
}
|