460 lines
18 KiB
Rust
460 lines
18 KiB
Rust
use super::*;
|
|
|
|
fn fixture() -> Document {
|
|
let bundle = SourceBundle {
|
|
entry: "project.arcadia".into(),
|
|
files: BTreeMap::from([
|
|
("project.arcadia".into(), r#"arcadia 7;
|
|
project mission "Испытание" id "10000000-0000-4000-8000-000000000001" {
|
|
include "work.arcadia"; include "views.arcadia";
|
|
}
|
|
"#.replace('\n', "\r\n")),
|
|
("work.arcadia".into(), r#"// @assess and assess are explanatory prose.
|
|
oa_activity assess "Оценка\u0020ущерба" id "10000000-0000-4000-8000-000000000002" { description "Keep @assess and assess"; }
|
|
oa_activity assist "Оценка\u0020ущерба" id "10000000-0000-4000-8000-000000000003" { parent @ // owner evidence
|
|
assess; }
|
|
sa_function service "Service" id "10000000-0000-4000-8000-000000000004" {}
|
|
interaction transfer "Transfer" id "10000000-0000-4000-8000-000000000005" { source @assess; target @assist; }
|
|
trace review "Reviewed correspondence" id "10000000-0000-4000-8000-000000000006" { oa @assess; sa @service; kind Refinement; baseline { oa { name "Old assess"; description "@assess"; }; sa { name "Old service"; description "Kept"; }; }; }
|
|
artifact evidence "Evidence" id "10000000-0000-4000-8000-000000000007" { owner @review; uri "docs/assess.md"; }
|
|
"#.replace('\n', "\r\n")),
|
|
("views.arcadia".into(), r#"diagram overview "Overview" id "10000000-0000-4000-8000-000000000008" { layer Oa; routes [{ edge @ // routed identity
|
|
transfer; waypoints [{ x 4.20e2; y -16; }]; }]; }
|
|
node placement id "10000000-0000-4000-8000-000000000009" { diagram @overview; element @assess; position { x -0.0; y 12.375; }; size { width 260; height 94; }; }
|
|
"#.replace('\n', "\r\n")),
|
|
]),
|
|
};
|
|
let document = Document::from_bundle(bundle.clone(), ProjectLimits::default()).unwrap();
|
|
assert_eq!(
|
|
document.bundle(),
|
|
&bundle,
|
|
"fixture must need no migration or ID insertion"
|
|
);
|
|
document
|
|
}
|
|
|
|
fn id(number: u8) -> Id {
|
|
Id::parse_str(&format!("10000000-0000-4000-8000-{number:012}")).unwrap()
|
|
}
|
|
|
|
fn prepare(document: &Document, target: Id, replacement: &str) -> PreparedAliasRename {
|
|
document
|
|
.alias_rename_request(target, replacement.into())
|
|
.unwrap()
|
|
.prepare()
|
|
.unwrap()
|
|
}
|
|
|
|
// Capture every live Document state relevant to refusal/no-op. Arc identity also
|
|
// catches replacing an equal snapshot or saved marker behind the user's back.
|
|
struct Unchanged {
|
|
current: Arc<CompiledSourceProject>,
|
|
saved: Option<Arc<SourceBundle>>,
|
|
past: Vec<(Arc<CompiledSourceProject>, usize)>,
|
|
future: Vec<(Arc<CompiledSourceProject>, usize)>,
|
|
revision: u64,
|
|
current_bytes: usize,
|
|
recovered: bool,
|
|
}
|
|
|
|
impl Unchanged {
|
|
fn capture(document: &Document) -> Self {
|
|
Self {
|
|
current: document.snapshot(),
|
|
saved: document.saved.clone(),
|
|
past: document
|
|
.past
|
|
.iter()
|
|
.map(|h| (h.snapshot.clone(), h.bytes))
|
|
.collect(),
|
|
future: document
|
|
.future
|
|
.iter()
|
|
.map(|h| (h.snapshot.clone(), h.bytes))
|
|
.collect(),
|
|
revision: document.revision,
|
|
current_bytes: document.current_bytes,
|
|
recovered: document.recovered_unsaved,
|
|
}
|
|
}
|
|
|
|
fn assert(&self, document: &Document) {
|
|
assert!(Arc::ptr_eq(&self.current, &document.current));
|
|
match (&self.saved, &document.saved) {
|
|
(Some(before), Some(after)) => assert!(Arc::ptr_eq(before, after)),
|
|
(None, None) => {}
|
|
_ => panic!("saved baseline changed"),
|
|
}
|
|
for (before, after) in [
|
|
(&self.past, &document.past),
|
|
(&self.future, &document.future),
|
|
] {
|
|
assert_eq!(before.len(), after.len());
|
|
for ((snapshot, bytes), entry) in before.iter().zip(after) {
|
|
assert!(Arc::ptr_eq(snapshot, &entry.snapshot));
|
|
assert_eq!(*bytes, entry.bytes);
|
|
}
|
|
}
|
|
assert_eq!(self.revision, document.revision);
|
|
assert_eq!(self.current_bytes, document.current_bytes);
|
|
assert_eq!(self.recovered, document.recovered_unsaved);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn each_identity_family_changes_only_authored_alias_roles_and_one_history_entry() {
|
|
// Literal role-specific replacements are independent of the updater/tokenizer.
|
|
// They deliberately exclude prose, historical review text and URI spelling.
|
|
type AliasCase<'a> = (u8, &'a str, &'a str, &'a [(&'a str, &'a str)]);
|
|
let cases: &[AliasCase<'_>] = &[
|
|
(
|
|
1,
|
|
"mission",
|
|
"renamed",
|
|
&[("project mission ", "project renamed ")],
|
|
),
|
|
(
|
|
2,
|
|
"assess",
|
|
"renamed",
|
|
&[
|
|
("oa_activity assess ", "oa_activity renamed "),
|
|
("\r\n assess;", "\r\n renamed;"),
|
|
("@assess;", "@renamed;"),
|
|
],
|
|
),
|
|
(
|
|
5,
|
|
"transfer",
|
|
"renamed",
|
|
&[
|
|
("interaction transfer ", "interaction renamed "),
|
|
("\r\n transfer;", "\r\n renamed;"),
|
|
],
|
|
),
|
|
(
|
|
6,
|
|
"review",
|
|
"renamed",
|
|
&[
|
|
("trace review ", "trace renamed "),
|
|
("owner @review;", "owner @renamed;"),
|
|
],
|
|
),
|
|
(
|
|
7,
|
|
"evidence",
|
|
"renamed",
|
|
&[("artifact evidence ", "artifact renamed ")],
|
|
),
|
|
(
|
|
8,
|
|
"overview",
|
|
"renamed",
|
|
&[
|
|
("diagram overview ", "diagram renamed "),
|
|
("diagram @overview;", "diagram @renamed;"),
|
|
],
|
|
),
|
|
(
|
|
9,
|
|
"placement",
|
|
"renamed",
|
|
&[("node placement ", "node renamed ")],
|
|
),
|
|
];
|
|
for &(number, old, new, replacements) in cases {
|
|
let mut document = fixture();
|
|
let original = document.snapshot();
|
|
let mut expected_model = document.project().clone();
|
|
// Explicit per-family oracle, separate from production expectation helper.
|
|
match number {
|
|
1 => expected_model.alias = new.into(),
|
|
2 => expected_model.elements.get_mut(&id(2)).unwrap().alias = new.into(),
|
|
5 => expected_model.relations.get_mut(&id(5)).unwrap().alias = new.into(),
|
|
6 => expected_model.traces.get_mut(&id(6)).unwrap().alias = new.into(),
|
|
7 => expected_model.artifact_links.get_mut(&id(7)).unwrap().alias = new.into(),
|
|
8 => expected_model.diagrams.get_mut(&id(8)).unwrap().alias = new.into(),
|
|
9 => {
|
|
expected_model
|
|
.diagrams
|
|
.get_mut(&id(8))
|
|
.unwrap()
|
|
.nodes
|
|
.get_mut(&id(9))
|
|
.unwrap()
|
|
.alias = new.into()
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
let mut expected_bundle = document.bundle().clone();
|
|
for source in expected_bundle.files.values_mut() {
|
|
for &(before, after) in replacements {
|
|
*source = source.replace(before, after);
|
|
}
|
|
}
|
|
let state = Unchanged::capture(&document);
|
|
let prepared = prepare(&document, id(number), new);
|
|
state.assert(&document);
|
|
assert!(prepared.changed());
|
|
assert_eq!(prepared.old_alias(), old);
|
|
assert_eq!(prepared.new_alias(), new);
|
|
assert_eq!(prepared.target(), id(number));
|
|
assert_eq!(prepared.revision(), 0);
|
|
assert_eq!(prepared.before().bundle, original.bundle);
|
|
assert_eq!(prepared.after().bundle, expected_bundle);
|
|
assert_eq!(prepared.after().decoded.project, expected_model);
|
|
assert!(document.commit_alias_rename(&prepared).unwrap());
|
|
assert!(
|
|
Arc::ptr_eq(&document.current, &prepared.0.after),
|
|
"adopt the exact prepared Arc"
|
|
);
|
|
assert_eq!(document.past.len(), 1);
|
|
assert!(document.dirty());
|
|
let after = Unchanged::capture(&document);
|
|
assert!(document.commit_alias_rename(&prepared.clone()).is_err());
|
|
after.assert(&document);
|
|
assert!(document.undo());
|
|
assert!(Arc::ptr_eq(&document.current, &original));
|
|
assert!(!document.dirty());
|
|
assert!(!document.can_undo());
|
|
assert!(document.redo());
|
|
assert_eq!(document.bundle(), &expected_bundle);
|
|
assert_eq!(document.project(), &expected_model);
|
|
document.mark_saved(document.snapshot());
|
|
let reopened =
|
|
Document::from_bundle(document.bundle().clone(), ProjectLimits::default()).unwrap();
|
|
assert_eq!(reopened.project(), &expected_model);
|
|
assert_eq!(reopened.bundle(), &expected_bundle);
|
|
assert!(!reopened.dirty());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn highlighted_keyword_alias_is_valid_without_normalizing_input() {
|
|
assert!(matches!(
|
|
language::tokenize("oa_activity")[0].class,
|
|
language::TokenClass::Keyword
|
|
));
|
|
let mut document = fixture();
|
|
let prepared = prepare(&document, id(2), "oa_activity");
|
|
document.commit_alias_rename(&prepared).unwrap();
|
|
assert_eq!(document.project().elements[&id(2)].alias, "oa_activity");
|
|
assert_eq!(document.project().elements[&id(2)].name, "Оценка ущерба");
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_and_identical_aliases_preserve_live_state_and_redo() {
|
|
let mut document = fixture();
|
|
let first = prepare(&document, id(2), "changed");
|
|
document.commit_alias_rename(&first).unwrap();
|
|
assert!(document.undo());
|
|
document.invalidate_saved_baseline();
|
|
document.mark_recovered();
|
|
let state = Unchanged::capture(&document);
|
|
for replacement in ["", "has space", "project", "assist", " assess", "assess "] {
|
|
assert!(
|
|
document
|
|
.alias_rename_request(id(2), replacement.into())
|
|
.unwrap()
|
|
.prepare()
|
|
.is_err()
|
|
);
|
|
state.assert(&document);
|
|
}
|
|
assert!(
|
|
document
|
|
.alias_rename_request(Id::nil(), "new".into())
|
|
.is_err()
|
|
);
|
|
let no_op = prepare(&document, id(2), "assess");
|
|
assert!(!no_op.changed());
|
|
assert!(!document.commit_alias_rename(&no_op).unwrap());
|
|
state.assert(&document);
|
|
assert!(document.redo());
|
|
assert_eq!(document.project().elements[&id(2)].alias, "changed");
|
|
}
|
|
|
|
#[test]
|
|
fn equal_bytes_undo_replay_cross_document_and_exhausted_revision_refuse() {
|
|
let mut document = fixture();
|
|
let old = prepare(&document, id(2), "renamed");
|
|
let mut second = fixture();
|
|
let second_state = Unchanged::capture(&second);
|
|
assert!(second.commit_alias_rename(&old).is_err());
|
|
second_state.assert(&second);
|
|
// Even an identical shared accepted Arc cannot confer this Document's owner.
|
|
second.current = document.current.clone();
|
|
let second_state = Unchanged::capture(&second);
|
|
assert!(second.commit_alias_rename(&old).is_err());
|
|
second_state.assert(&second);
|
|
document
|
|
.execute(Command::Rename(id(2), "Later name".into()))
|
|
.unwrap();
|
|
let edited = Unchanged::capture(&document);
|
|
assert!(document.commit_alias_rename(&old).is_err());
|
|
edited.assert(&document);
|
|
assert!(document.undo());
|
|
assert!(Arc::ptr_eq(&document.current, &old.0.request.before));
|
|
let undone = Unchanged::capture(&document);
|
|
assert!(document.commit_alias_rename(&old).is_err());
|
|
undone.assert(&document);
|
|
document.revision = u64::MAX - 1;
|
|
let last = prepare(&document, id(2), "renamed");
|
|
document
|
|
.execute(Command::Rename(id(2), "Exhaust revision".into()))
|
|
.unwrap();
|
|
document.undo();
|
|
assert!(Arc::ptr_eq(&document.current, &last.0.request.before));
|
|
let exhausted = Unchanged::capture(&document);
|
|
assert!(document.commit_alias_rename(&last).is_err());
|
|
assert!(
|
|
document
|
|
.alias_rename_request(id(2), "renamed".into())
|
|
.is_err()
|
|
);
|
|
assert!(
|
|
document
|
|
.alias_rename_request(id(2), "assess".into())
|
|
.is_err()
|
|
);
|
|
exhausted.assert(&document);
|
|
}
|
|
|
|
#[test]
|
|
fn preview_retains_latest_saved_baseline_and_recovery_state() {
|
|
for baseline in ["saved_before", "unknown", "raw_repair", "recovered"] {
|
|
let mut document = fixture();
|
|
let prepared = prepare(&document, id(2), "renamed");
|
|
let original = document.snapshot();
|
|
match baseline {
|
|
"saved_before" => document.mark_saved(original.clone()),
|
|
"unknown" => document.invalidate_saved_baseline(),
|
|
"raw_repair" => {
|
|
let mut raw = document.bundle().clone();
|
|
raw.files
|
|
.insert("project.arcadia".into(), "invalid repair source".into());
|
|
document.bind_repair_baseline(raw);
|
|
}
|
|
"recovered" => document.mark_recovered(),
|
|
_ => unreachable!(),
|
|
}
|
|
let saved = document.saved.clone();
|
|
let recovered = document.recovered_unsaved;
|
|
assert!(document.commit_alias_rename(&prepared).unwrap());
|
|
assert_eq!(document.saved, saved);
|
|
assert_eq!(document.recovered_unsaved, recovered);
|
|
assert!(document.dirty());
|
|
assert!(document.undo());
|
|
assert_eq!(document.saved, saved);
|
|
assert_eq!(document.recovered_unsaved, recovered);
|
|
assert_eq!(document.dirty(), baseline != "saved_before");
|
|
}
|
|
// A completed save after Preview remains authoritative, including when it
|
|
// clears recovery; a worker must not restore its earlier state on Confirm.
|
|
let mut document = fixture();
|
|
document.mark_recovered();
|
|
document.invalidate_saved_baseline();
|
|
let prepared = prepare(&document, id(2), "renamed");
|
|
document.mark_saved(document.snapshot());
|
|
document.commit_alias_rename(&prepared).unwrap();
|
|
assert!(!document.recovered_unsaved);
|
|
document.undo();
|
|
assert!(!document.dirty());
|
|
// Undo from saved C to B, then a new B->C rename can correctly finish clean.
|
|
document.redo();
|
|
document.mark_saved(document.snapshot());
|
|
document.undo();
|
|
let return_to_saved = prepare(&document, id(2), "renamed");
|
|
document.commit_alias_rename(&return_to_saved).unwrap();
|
|
assert!(!document.dirty());
|
|
assert!(!document.can_redo());
|
|
}
|
|
|
|
#[test]
|
|
fn mutable_public_preview_copies_cannot_change_prepared_authority() {
|
|
let mut document = fixture();
|
|
let prepared = prepare(&document, id(2), "renamed");
|
|
let exact = prepared.after().clone();
|
|
let mut counterfeit = prepared.after().clone();
|
|
counterfeit.decoded.project.name = "Injected unrelated edit".into();
|
|
counterfeit.index.declarations.clear();
|
|
let unchanged = Unchanged::capture(&document);
|
|
assert!(document.apply_compiled(counterfeit).is_err());
|
|
unchanged.assert(&document);
|
|
let mut unrelated = exact.decoded.project.clone();
|
|
unrelated.name = "A self-consistent unrelated candidate".into();
|
|
let forged_dto = source_project::compile_bundle(
|
|
&source_project::format_bundle(&unrelated),
|
|
ProjectLimits::default(),
|
|
)
|
|
.unwrap();
|
|
assert_ne!(forged_dto.decoded.project, prepared.after().decoded.project);
|
|
// No API converts either DTO to PreparedAliasRename. Mutating owned copies
|
|
// leaves the opaque capability and its exact adopted model/source intact.
|
|
document.commit_alias_rename(&prepared).unwrap();
|
|
assert_eq!(document.project(), &exact.decoded.project);
|
|
assert_eq!(document.bundle(), &exact.bundle);
|
|
}
|
|
|
|
#[test]
|
|
fn exact_history_capacity_admits_immediate_undo_and_rejects_larger_candidate() {
|
|
let mut document = fixture();
|
|
let mut bundle = document.bundle().clone();
|
|
let padding = HISTORY_BYTES / 2 - document.current_bytes;
|
|
bundle
|
|
.files
|
|
.get_mut("views.arcadia")
|
|
.unwrap()
|
|
.push_str(&format!("//{}\r\n", "x".repeat(padding - 4)));
|
|
document.apply_bundle(bundle).unwrap();
|
|
assert_eq!(document.current_bytes, HISTORY_BYTES / 2);
|
|
// A same-length alias retains the exact estimate; older history is evictable.
|
|
let boundary = prepare(&document, id(2), "rating");
|
|
assert_eq!(
|
|
boundary.0.before_bytes + boundary.0.after_bytes,
|
|
HISTORY_BYTES
|
|
);
|
|
let unchanged = Unchanged::capture(&document);
|
|
assert!(
|
|
document
|
|
.alias_rename_request(id(2), "longer_alias".into())
|
|
.unwrap()
|
|
.prepare()
|
|
.is_err()
|
|
);
|
|
unchanged.assert(&document);
|
|
document.commit_alias_rename(&boundary).unwrap();
|
|
assert_eq!(document.past.len(), 1);
|
|
assert!(document.undo());
|
|
assert!(Arc::ptr_eq(&document.current, &boundary.0.request.before));
|
|
assert!(!document.can_undo());
|
|
assert!(document.redo());
|
|
assert!(Arc::ptr_eq(&document.current, &boundary.0.after));
|
|
assert!(check_history_capacity(usize::MAX, 1).is_err());
|
|
assert!(check_history_capacity(HISTORY_BYTES, 1).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn mint_and_commit_refuse_exhausted_or_inconsistent_live_history_estimate() {
|
|
let mut document = fixture();
|
|
let prepared = prepare(&document, id(2), "renamed");
|
|
// Inject cache corruption to falsify the commit-side check independently of
|
|
// the preparation admission. Normal edits also change revision and Arc.
|
|
document.current_bytes = HISTORY_BYTES;
|
|
let unchanged = Unchanged::capture(&document);
|
|
assert!(
|
|
document
|
|
.alias_rename_request(id(2), "renamed".into())
|
|
.is_err()
|
|
);
|
|
assert!(document.commit_alias_rename(&prepared).is_err());
|
|
// No-op intent needs no Undo reservation even at the admission boundary.
|
|
assert!(
|
|
document
|
|
.alias_rename_request(id(2), "assess".into())
|
|
.is_ok()
|
|
);
|
|
unchanged.assert(&document);
|
|
}
|