Files
ARCADIA-Studio/crates/oa-desktop/src/document.rs
T

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(&current);
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(&current);
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));
}
}