Preserve source identity ownership across prepared model commands
This commit is contained in:
+101
-13
@@ -1,6 +1,6 @@
|
||||
use crate::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Clone, Error, Serialize, Deserialize)]
|
||||
@@ -93,6 +93,65 @@ pub struct Session {
|
||||
pub struct PreparedCommand {
|
||||
pub project: Project,
|
||||
pub selected: Option<Id>,
|
||||
/// Complete correspondence from surviving original identities to candidate
|
||||
/// identities, including unchanged objects. Newly created objects have no
|
||||
/// origin. Replacement establishes correspondence by common original UUIDs.
|
||||
pub identity_map: BTreeMap<Id, Id>,
|
||||
}
|
||||
|
||||
/// Track actual identity operations, not aliases or structural similarity.
|
||||
/// The reverse map composes renames without scanning every original object.
|
||||
struct IdentityProvenance {
|
||||
original: BTreeSet<Id>,
|
||||
current_to_original: BTreeMap<Id, Id>,
|
||||
}
|
||||
|
||||
impl IdentityProvenance {
|
||||
fn new(project: &Project) -> Self {
|
||||
let original: BTreeSet<_> = project.identities().into_iter().map(|(id, _)| id).collect();
|
||||
let current_to_original = original.iter().map(|id| (*id, *id)).collect();
|
||||
Self {
|
||||
original,
|
||||
current_to_original,
|
||||
}
|
||||
}
|
||||
|
||||
fn replace(&mut self, project: &Project) {
|
||||
let present: BTreeSet<_> = project.identities().into_iter().map(|(id, _)| id).collect();
|
||||
self.current_to_original = self
|
||||
.original
|
||||
.intersection(&present)
|
||||
.map(|id| (*id, *id))
|
||||
.collect();
|
||||
}
|
||||
|
||||
fn retain_present(&mut self, project: &Project) {
|
||||
if !self.current_to_original.is_empty() {
|
||||
let present: BTreeSet<_> = project.identities().into_iter().map(|(id, _)| id).collect();
|
||||
self.current_to_original
|
||||
.retain(|id, _| present.contains(id));
|
||||
}
|
||||
}
|
||||
|
||||
fn into_map(self) -> BTreeMap<Id, Id> {
|
||||
self.current_to_original
|
||||
.into_iter()
|
||||
.map(|(current, original)| (original, current))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn reidentify_tracked(
|
||||
project: &mut Project,
|
||||
old: Id,
|
||||
new: Id,
|
||||
provenance: &mut IdentityProvenance,
|
||||
) -> Result<(), ModelError> {
|
||||
reidentify(project, old, new)?;
|
||||
if let Some(original) = provenance.current_to_original.remove(&old) {
|
||||
provenance.current_to_original.insert(new, original);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn prepare_command(
|
||||
@@ -101,12 +160,21 @@ pub fn prepare_command(
|
||||
) -> Result<PreparedCommand, ModelError> {
|
||||
let mut candidate = project.clone();
|
||||
let mut pending_deletions = BTreeSet::new();
|
||||
let selected = apply_command(&mut candidate, command, 0, &mut pending_deletions)?;
|
||||
let mut provenance = IdentityProvenance::new(project);
|
||||
let selected = apply_command(
|
||||
&mut candidate,
|
||||
command,
|
||||
0,
|
||||
&mut pending_deletions,
|
||||
&mut provenance,
|
||||
)?;
|
||||
crate::references::finish_deletions(&mut candidate, &pending_deletions);
|
||||
provenance.retain_present(&candidate);
|
||||
validate_candidate(&candidate)?;
|
||||
Ok(PreparedCommand {
|
||||
project: candidate,
|
||||
selected,
|
||||
identity_map: provenance.into_map(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -171,6 +239,7 @@ impl Session {
|
||||
let PreparedCommand {
|
||||
project: candidate,
|
||||
selected,
|
||||
..
|
||||
} = prepare_command(&self.project, &command)?;
|
||||
if candidate != self.project {
|
||||
self.past
|
||||
@@ -197,8 +266,15 @@ pub fn validate_candidate(p: &Project) -> Result<(), ModelError> {
|
||||
pub fn execute_batch(project: &Project, commands: &[Command]) -> Result<Project, ModelError> {
|
||||
let mut candidate = project.clone();
|
||||
let mut pending_deletions = BTreeSet::new();
|
||||
let mut provenance = IdentityProvenance::new(project);
|
||||
for command in commands {
|
||||
apply_command(&mut candidate, command, 0, &mut pending_deletions)?;
|
||||
apply_command(
|
||||
&mut candidate,
|
||||
command,
|
||||
0,
|
||||
&mut pending_deletions,
|
||||
&mut provenance,
|
||||
)?;
|
||||
}
|
||||
crate::references::finish_deletions(&mut candidate, &pending_deletions);
|
||||
validate_candidate(&candidate)?;
|
||||
@@ -266,6 +342,7 @@ fn apply_command(
|
||||
c: &Command,
|
||||
depth: usize,
|
||||
pending_deletions: &mut BTreeSet<Id>,
|
||||
provenance: &mut IdentityProvenance,
|
||||
) -> Result<Option<Id>, ModelError> {
|
||||
if depth > 64 {
|
||||
return Err(ModelError(
|
||||
@@ -276,12 +353,13 @@ fn apply_command(
|
||||
Command::Replace(value) => {
|
||||
*p = value.clone();
|
||||
pending_deletions.clear();
|
||||
provenance.replace(p);
|
||||
None
|
||||
}
|
||||
Command::Batch(commands) => {
|
||||
let mut selected = None;
|
||||
for command in commands {
|
||||
let id = apply_command(p, command, depth + 1, pending_deletions)?;
|
||||
let id = apply_command(p, command, depth + 1, pending_deletions, provenance)?;
|
||||
if id.is_some() {
|
||||
selected = id;
|
||||
}
|
||||
@@ -342,7 +420,7 @@ fn apply_command(
|
||||
let mut e = value.clone();
|
||||
e.id = *old;
|
||||
p.elements.insert(*old, e);
|
||||
reidentify(p, *old, value.id)?;
|
||||
reidentify_tracked(p, *old, value.id, provenance)?;
|
||||
if ownership_change {
|
||||
attach(p, value.id);
|
||||
}
|
||||
@@ -360,7 +438,7 @@ fn apply_command(
|
||||
let mut v = value.clone();
|
||||
v.id = *old;
|
||||
p.relations.insert(*old, v);
|
||||
reidentify(p, *old, value.id)?;
|
||||
reidentify_tracked(p, *old, value.id, provenance)?;
|
||||
if pending_deletions.remove(old) {
|
||||
pending_deletions.insert(value.id);
|
||||
}
|
||||
@@ -378,7 +456,9 @@ fn apply_command(
|
||||
let mut v = value.clone();
|
||||
v.id = *old;
|
||||
p.diagrams.insert(*old, v);
|
||||
reidentify(p, *old, value.id)?;
|
||||
// Replacing a diagram can remove embedded placement identities.
|
||||
provenance.retain_present(p);
|
||||
reidentify_tracked(p, *old, value.id, provenance)?;
|
||||
Some(value.id)
|
||||
}
|
||||
Command::CreateArtifact(value) => {
|
||||
@@ -393,7 +473,7 @@ fn apply_command(
|
||||
let mut v = value.clone();
|
||||
v.id = *old;
|
||||
p.artifact_links.insert(*old, v);
|
||||
reidentify(p, *old, value.id)?;
|
||||
reidentify_tracked(p, *old, value.id, provenance)?;
|
||||
Some(value.id)
|
||||
}
|
||||
Command::CreateTrace(value) => {
|
||||
@@ -408,7 +488,7 @@ fn apply_command(
|
||||
let mut v = value.clone();
|
||||
v.id = *old;
|
||||
p.traces.insert(*old, v);
|
||||
reidentify(p, *old, value.id)?;
|
||||
reidentify_tracked(p, *old, value.id, provenance)?;
|
||||
Some(value.id)
|
||||
}
|
||||
Command::CreateNode { diagram, node } => {
|
||||
@@ -432,7 +512,7 @@ fn apply_command(
|
||||
let mut n = node.clone();
|
||||
n.id = *original;
|
||||
d.nodes.insert(*original, n);
|
||||
reidentify(p, *original, node.id)?;
|
||||
reidentify_tracked(p, *original, node.id, provenance)?;
|
||||
Some(node.id)
|
||||
}
|
||||
Command::RelocateNode { node, diagram } => {
|
||||
@@ -467,7 +547,7 @@ fn apply_command(
|
||||
name,
|
||||
description,
|
||||
} => {
|
||||
reidentify(p, p.id, *id)?;
|
||||
reidentify_tracked(p, p.id, *id, provenance)?;
|
||||
p.alias = alias.clone();
|
||||
p.name = name.clone();
|
||||
p.description = description.clone();
|
||||
@@ -483,7 +563,7 @@ fn apply_command(
|
||||
Some(*element)
|
||||
}
|
||||
Command::Reidentify(old, new) => {
|
||||
reidentify(p, *old, *new)?;
|
||||
reidentify_tracked(p, *old, *new, provenance)?;
|
||||
if pending_deletions.remove(old) {
|
||||
pending_deletions.insert(*new);
|
||||
}
|
||||
@@ -518,7 +598,13 @@ fn apply_command(
|
||||
items: vec![],
|
||||
},
|
||||
)?;
|
||||
return apply_command(p, &Command::Batch(commands), depth + 1, pending_deletions);
|
||||
return apply_command(
|
||||
p,
|
||||
&Command::Batch(commands),
|
||||
depth + 1,
|
||||
pending_deletions,
|
||||
provenance,
|
||||
);
|
||||
}
|
||||
Some(p.relate(*kind, name, *source, *target))
|
||||
}
|
||||
@@ -530,6 +616,7 @@ fn apply_command(
|
||||
return Err(missing());
|
||||
}
|
||||
crate::references::delete_cascade_deferred(p, *id, pending_deletions);
|
||||
provenance.retain_present(p);
|
||||
None
|
||||
}
|
||||
Command::AddStep(owner, exchange) => {
|
||||
@@ -569,6 +656,7 @@ fn apply_command(
|
||||
.and_then(|s| s.get(*index))
|
||||
.ok_or_else(|| ModelError("Step index is out of range.".into()))?;
|
||||
crate::references::delete_cascade_deferred(p, id, pending_deletions);
|
||||
provenance.retain_present(p);
|
||||
Some(*owner)
|
||||
}
|
||||
Command::MoveStep(owner, from, to) => {
|
||||
|
||||
@@ -261,6 +261,16 @@ pub fn inspect_includes(
|
||||
|
||||
type Edits = BTreeMap<LogicalPath, Vec<(Span, String)>>;
|
||||
|
||||
/// Diagnose the exact editable bytes, without inserting omitted UUIDs. Locations
|
||||
/// refer to the supplied bundle, including when successful compilation would
|
||||
/// otherwise stabilize identities before returning its index and diagnostics.
|
||||
pub fn lint_bundle(bundle: &SourceBundle, limits: ProjectLimits) -> Vec<SourceDiagnostic> {
|
||||
match compile_once(bundle, limits) {
|
||||
Ok((compiled, _)) => compiled.diagnostics,
|
||||
Err(diagnostics) => diagnostics,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compile all reachable files as one source3 model and stabilize omitted UUIDs.
|
||||
/// Missing/unreachable files and every admission or engineering error leave the
|
||||
/// input untouched. V1/V2 single-file migration remains in `language::parse`.
|
||||
@@ -727,6 +737,28 @@ pub fn update_bundle(
|
||||
current: &CompiledSourceProject,
|
||||
project: &Project,
|
||||
limits: ProjectLimits,
|
||||
) -> Result<CompiledSourceProject, Vec<SourceDiagnostic>> {
|
||||
let identities = current
|
||||
.decoded
|
||||
.project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.filter(|(id, _)| project.contains(*id))
|
||||
.map(|(id, _)| (id, id))
|
||||
.collect();
|
||||
update_bundle_with_identity_map(current, project, &identities, limits)
|
||||
}
|
||||
|
||||
/// Preserve declaration and reference-comment ownership through identity edits.
|
||||
/// `identities` maps every surviving original identity to its candidate identity;
|
||||
/// new records have no entry. Use the map from `prepare_command`, including for
|
||||
/// nested batches and identity swaps. An arbitrary model replacement without
|
||||
/// command provenance can use `update_bundle`'s ordinary same-UUID matching.
|
||||
pub fn update_bundle_with_identity_map(
|
||||
current: &CompiledSourceProject,
|
||||
project: &Project,
|
||||
identities: &BTreeMap<Id, Id>,
|
||||
limits: ProjectLimits,
|
||||
) -> Result<CompiledSourceProject, Vec<SourceDiagnostic>> {
|
||||
// Public document/index fields are convenient for callers, but cannot become
|
||||
// an unchecked editing authority if a caller has changed either of them.
|
||||
@@ -760,26 +792,68 @@ pub fn update_bundle(
|
||||
)]
|
||||
})?;
|
||||
let old = ¤t.decoded.project;
|
||||
// A supplied map is editing intent, never permission to create duplicate
|
||||
// records, refer to absent objects, or move syntax between record classes.
|
||||
let category = |model: &Project, id: Id| {
|
||||
if model.id == id {
|
||||
0
|
||||
} else if model.elements.contains_key(&id) {
|
||||
1
|
||||
} else if model.relations.contains_key(&id) {
|
||||
2
|
||||
} else if model.diagrams.contains_key(&id) {
|
||||
3
|
||||
} else if model.artifact_links.contains_key(&id) {
|
||||
4
|
||||
} else if model.traces.contains_key(&id) {
|
||||
5
|
||||
} else {
|
||||
6 // Node placements; existence is checked separately.
|
||||
}
|
||||
};
|
||||
if identities.values().collect::<BTreeSet<_>>().len() != identities.len()
|
||||
|| identities.iter().any(|(before, after)| {
|
||||
!old.contains(*before)
|
||||
|| !project.contains(*after)
|
||||
|| category(old, *before) != category(project, *after)
|
||||
})
|
||||
{
|
||||
return Err(vec![problem(
|
||||
¤t.bundle,
|
||||
¤t.bundle.entry,
|
||||
Span::default(),
|
||||
"invalid_identity_map",
|
||||
"Source identity correspondence must map existing records one-to-one within their record class.",
|
||||
)]);
|
||||
}
|
||||
let origins: BTreeMap<_, _> = identities.iter().map(|(old, new)| (*new, *old)).collect();
|
||||
let before: BTreeMap<_, _> = language::format_declarations(old).into_iter().collect();
|
||||
let after: BTreeMap<_, _> = language::format_declarations(project).into_iter().collect();
|
||||
let old_aliases: BTreeMap<_, _> = old
|
||||
.identities()
|
||||
.into_iter()
|
||||
.map(|(id, alias)| (alias.to_owned(), id))
|
||||
.map(|(id, alias)| (alias.to_owned(), format!("original:{id}")))
|
||||
.collect();
|
||||
let new_aliases: BTreeMap<_, _> = project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.map(|(id, alias)| (alias.to_owned(), id))
|
||||
.map(|(id, alias)| {
|
||||
let identity = origins.get(&id).map_or_else(
|
||||
|| format!("new:{id}"),
|
||||
|origin| format!("original:{origin}"),
|
||||
);
|
||||
(alias.to_owned(), identity)
|
||||
})
|
||||
.collect();
|
||||
let mut bundle = current.bundle.clone();
|
||||
let mut edits: Edits = BTreeMap::new();
|
||||
for (id, old_text) in &before {
|
||||
let location = ¤t.index.declarations[id];
|
||||
if after.get(id) != Some(old_text) {
|
||||
let new_text = identities.get(id).and_then(|id| after.get(id));
|
||||
if new_text != Some(old_text) {
|
||||
let source = &bundle.files[&location.path][location.span.start..location.span.end];
|
||||
let mut local = Vec::new();
|
||||
if let Some(new_text) = after.get(id) {
|
||||
if let Some(new_text) = new_text {
|
||||
let parse = |text| {
|
||||
language::edit_declaration(text).map_err(|error| {
|
||||
vec![problem(
|
||||
@@ -906,8 +980,7 @@ pub fn update_bundle(
|
||||
.index
|
||||
.declarations
|
||||
.iter()
|
||||
.filter(|(id, _)| project.contains(**id))
|
||||
.map(|(id, location)| (*id, location.path.clone()))
|
||||
.filter_map(|(id, location)| identities.get(id).map(|new| (*new, location.path.clone())))
|
||||
.collect();
|
||||
let mut chosen_defaults = BTreeMap::new();
|
||||
let mut used_paths: BTreeSet<_> = bundle
|
||||
@@ -919,7 +992,7 @@ pub fn update_bundle(
|
||||
let aliases: BTreeMap<_, _> = project.identities().into_iter().collect();
|
||||
let mut new_ids: Vec<_> = after
|
||||
.keys()
|
||||
.filter(|id| !before.contains_key(*id))
|
||||
.filter(|id| !origins.contains_key(*id))
|
||||
.copied()
|
||||
.collect();
|
||||
new_ids.sort_by_key(|id| declaration_order(project, *id, &aliases));
|
||||
@@ -1089,8 +1162,8 @@ struct SyntaxUpdate<'a> {
|
||||
source: &'a str,
|
||||
before: &'a str,
|
||||
after: &'a str,
|
||||
old_aliases: &'a BTreeMap<String, Id>,
|
||||
new_aliases: &'a BTreeMap<String, Id>,
|
||||
old_aliases: &'a BTreeMap<String, String>,
|
||||
new_aliases: &'a BTreeMap<String, String>,
|
||||
}
|
||||
impl SyntaxUpdate<'_> {
|
||||
fn header(
|
||||
@@ -1420,7 +1493,7 @@ impl SyntaxUpdate<'_> {
|
||||
fn item_key(
|
||||
value: &language::EditValue,
|
||||
source: &str,
|
||||
aliases: &BTreeMap<String, Id>,
|
||||
aliases: &BTreeMap<String, String>,
|
||||
) -> Option<String> {
|
||||
use language::EditKind;
|
||||
match &value.kind {
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
use oa_core::*;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
fn pair() -> (Project, Id, Id) {
|
||||
let mut project = Project::empty();
|
||||
let first = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "First", None)
|
||||
.unwrap();
|
||||
let second = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "Second", None)
|
||||
.unwrap();
|
||||
(project, first, second)
|
||||
}
|
||||
|
||||
fn identities(project: &Project) -> BTreeSet<Id> {
|
||||
project.identities().into_iter().map(|(id, _)| id).collect()
|
||||
}
|
||||
|
||||
fn valid_map(original: &Project, prepared: &PreparedCommand) {
|
||||
let origins = identities(original);
|
||||
let present = identities(&prepared.project);
|
||||
let targets: BTreeSet<_> = prepared.identity_map.values().copied().collect();
|
||||
assert_eq!(
|
||||
targets.len(),
|
||||
prepared.identity_map.len(),
|
||||
"correspondence is injective"
|
||||
);
|
||||
assert!(targets.is_subset(&present));
|
||||
assert!(prepared.identity_map.keys().all(|id| origins.contains(id)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_chains_and_nested_swaps_compose_original_identity() {
|
||||
let (project, first, second) = pair();
|
||||
let temporary = Id::new_v4();
|
||||
let final_id = Id::new_v4();
|
||||
let chained = prepare_command(
|
||||
&project,
|
||||
&Command::Batch(vec![
|
||||
Command::Reidentify(first, temporary),
|
||||
Command::Batch(vec![Command::Reidentify(temporary, final_id)]),
|
||||
]),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
chained.identity_map,
|
||||
BTreeMap::from([
|
||||
(project.id, project.id),
|
||||
(first, final_id),
|
||||
(second, second)
|
||||
])
|
||||
);
|
||||
assert_eq!(chained.selected, Some(final_id));
|
||||
valid_map(&project, &chained);
|
||||
|
||||
let swapped = prepare_command(
|
||||
&project,
|
||||
&Command::Batch(vec![
|
||||
Command::Reidentify(first, temporary),
|
||||
Command::Batch(vec![
|
||||
Command::Reidentify(second, first),
|
||||
Command::Reidentify(temporary, second),
|
||||
]),
|
||||
]),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
swapped.identity_map,
|
||||
BTreeMap::from([(project.id, project.id), (first, second), (second, first)])
|
||||
);
|
||||
assert_eq!(swapped.project.elements[&second].alias, "first");
|
||||
assert_eq!(swapped.project.elements[&first].alias, "second");
|
||||
valid_map(&project, &swapped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_edit_commands_record_their_implicit_identity_change() {
|
||||
let project = Project::demo();
|
||||
let new = Id::new_v4();
|
||||
let mut element = project.elements.values().next().unwrap().clone();
|
||||
let element_id = element.id;
|
||||
element.id = new;
|
||||
element.alias = "edited_identity_and_alias".into();
|
||||
let mut relation = project.relations.values().next().unwrap().clone();
|
||||
let relation_id = relation.id;
|
||||
relation.id = new;
|
||||
let mut diagram = project.diagrams.values().next().unwrap().clone();
|
||||
let diagram_id = diagram.id;
|
||||
let mut node = diagram.nodes.values().next().unwrap().clone();
|
||||
let node_id = node.id;
|
||||
node.id = new;
|
||||
diagram.id = new;
|
||||
let mut artifact = project.artifact_links.values().next().unwrap().clone();
|
||||
let artifact_id = artifact.id;
|
||||
artifact.id = new;
|
||||
let mut trace = project.traces.values().next().unwrap().clone();
|
||||
let trace_id = trace.id;
|
||||
trace.id = new;
|
||||
let cases = [
|
||||
(element_id, Command::EditElement(element_id, element)),
|
||||
(relation_id, Command::EditRelation(relation_id, relation)),
|
||||
(diagram_id, Command::EditDiagram(diagram_id, diagram)),
|
||||
(
|
||||
node_id,
|
||||
Command::EditNode {
|
||||
diagram: diagram_id,
|
||||
original: node_id,
|
||||
node,
|
||||
},
|
||||
),
|
||||
(artifact_id, Command::EditArtifact(artifact_id, artifact)),
|
||||
(trace_id, Command::EditTrace(trace_id, trace)),
|
||||
(
|
||||
project.id,
|
||||
Command::EditProject {
|
||||
id: new,
|
||||
alias: project.alias.clone(),
|
||||
name: project.name.clone(),
|
||||
description: project.description.clone(),
|
||||
},
|
||||
),
|
||||
];
|
||||
for (old, command) in cases {
|
||||
let prepared = prepare_command(&project, &command).unwrap();
|
||||
let expected: BTreeMap<_, _> = identities(&project)
|
||||
.into_iter()
|
||||
.map(|id| (id, if id == old { new } else { id }))
|
||||
.collect();
|
||||
assert_eq!(prepared.identity_map, expected, "{command:?}");
|
||||
valid_map(&project, &prepared);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_and_cascade_discard_origins_before_uuid_reuse() {
|
||||
let (mut project, first, second) = pair();
|
||||
let child = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "Nested work", Some(first))
|
||||
.unwrap();
|
||||
let mut fresh = project.elements[&child].clone();
|
||||
fresh.parent = None;
|
||||
fresh.alias = "replacement_work".into();
|
||||
let prepared = prepare_command(
|
||||
&project,
|
||||
&Command::Batch(vec![Command::Delete(first), Command::CreateElement(fresh)]),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(prepared.project.contains(child));
|
||||
assert_eq!(
|
||||
prepared.identity_map,
|
||||
BTreeMap::from([(project.id, project.id), (second, second)])
|
||||
);
|
||||
valid_map(&project, &prepared);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_diagram_replacement_cannot_revive_deleted_node_origin() {
|
||||
let mut project = Project::empty();
|
||||
let element = project.add(Kind::Activity, "Work", 0., 0.);
|
||||
let mut diagram = project.diagrams.values().next().unwrap().clone();
|
||||
let node = diagram.nodes.values().next().unwrap().clone();
|
||||
diagram.nodes.clear();
|
||||
let prepared = prepare_command(
|
||||
&project,
|
||||
&Command::Batch(vec![
|
||||
Command::EditDiagram(diagram.id, diagram.clone()),
|
||||
Command::CreateNode {
|
||||
diagram: diagram.id,
|
||||
node: node.clone(),
|
||||
},
|
||||
]),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(prepared.project, project);
|
||||
assert_eq!(
|
||||
prepared.identity_map,
|
||||
BTreeMap::from([
|
||||
(project.id, project.id),
|
||||
(element, element),
|
||||
(diagram.id, diagram.id)
|
||||
])
|
||||
);
|
||||
assert!(!prepared.identity_map.contains_key(&node.id));
|
||||
valid_map(&project, &prepared);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_creation_has_no_origin_even_when_subsequently_reidentified() {
|
||||
let (project, _, _) = pair();
|
||||
let mut fresh = project
|
||||
.make_element(Kind::Activity, Layer::Oa, "New work", None)
|
||||
.unwrap();
|
||||
let created = fresh.id;
|
||||
let renamed = Id::new_v4();
|
||||
fresh.alias = "fresh_identity".into();
|
||||
let prepared = prepare_command(
|
||||
&project,
|
||||
&Command::Batch(vec![
|
||||
Command::CreateElement(fresh),
|
||||
Command::Reidentify(created, renamed),
|
||||
]),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(prepared.selected, Some(renamed));
|
||||
assert!(prepared.project.contains(renamed));
|
||||
assert_eq!(
|
||||
prepared.identity_map,
|
||||
identities(&project)
|
||||
.into_iter()
|
||||
.map(|id| (id, id))
|
||||
.collect()
|
||||
);
|
||||
valid_map(&project, &prepared);
|
||||
let generated = prepare_command(
|
||||
&project,
|
||||
&Command::AddElement {
|
||||
kind: Kind::Activity,
|
||||
layer: Layer::Oa,
|
||||
name: "Generated once".into(),
|
||||
parent: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let selected = generated.selected.unwrap();
|
||||
assert!(generated.project.contains(selected));
|
||||
assert!(!generated.identity_map.values().any(|id| *id == selected));
|
||||
assert_eq!(generated.project.elements.len(), project.elements.len() + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacement_resets_correspondence_against_the_original_input() {
|
||||
let (project, first, second) = pair();
|
||||
let temporary = Id::new_v4();
|
||||
let mut replacement = project.clone();
|
||||
replacement.elements.remove(&second);
|
||||
let new = replacement
|
||||
.add_element(Kind::Activity, Layer::Oa, "Replacement addition", None)
|
||||
.unwrap();
|
||||
let final_id = Id::new_v4();
|
||||
let prepared = prepare_command(
|
||||
&project,
|
||||
&Command::Batch(vec![
|
||||
Command::Reidentify(first, temporary),
|
||||
Command::Delete(second),
|
||||
Command::Replace(replacement),
|
||||
Command::Reidentify(first, final_id),
|
||||
]),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
prepared.identity_map,
|
||||
BTreeMap::from([(project.id, project.id), (first, final_id)])
|
||||
);
|
||||
assert!(!prepared.identity_map.values().any(|id| *id == new));
|
||||
valid_map(&project, &prepared);
|
||||
let restored = prepare_command(
|
||||
&project,
|
||||
&Command::Batch(vec![
|
||||
Command::Delete(first),
|
||||
Command::Replace(project.clone()),
|
||||
]),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
restored.identity_map,
|
||||
identities(&project)
|
||||
.into_iter()
|
||||
.map(|id| (id, id))
|
||||
.collect()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_preparation_leaves_input_unchanged_after_intermediate_identity_work() {
|
||||
let (project, first, second) = pair();
|
||||
let before = project.clone();
|
||||
let temporary = Id::new_v4();
|
||||
let failed = prepare_command(
|
||||
&project,
|
||||
&Command::Batch(vec![
|
||||
Command::Reidentify(first, temporary),
|
||||
Command::Reidentify(second, temporary),
|
||||
]),
|
||||
);
|
||||
assert!(failed.is_err());
|
||||
assert_eq!(project, before);
|
||||
let failed_validation = prepare_command(
|
||||
&project,
|
||||
&Command::Batch(vec![
|
||||
Command::Reidentify(first, temporary),
|
||||
Command::SetAlias(temporary, "second".into()),
|
||||
]),
|
||||
);
|
||||
assert!(failed_validation.is_err());
|
||||
assert_eq!(project, before);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
use oa_core::{source_project::*, *};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn source() -> CompiledSourceProject {
|
||||
compile_bundle(
|
||||
&SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("project.arcadia".into(), "arcadia 3; project p \"Identity review\" { include \"team/first.arcadia\"; include \"team/second.arcadia\"; include \"contracts.arcadia\"; }".into()),
|
||||
("team/first.arcadia".into(), "// Rationale for first\nsa_exchange_item first \"First\" { description \"First rationale\"; }\n".into()),
|
||||
("team/second.arcadia".into(), "// Rationale for second\nsa_exchange_item second \"Second\" {}\n".into()),
|
||||
("contracts.arcadia".into(), "sa_interface contract \"Contract\" { items [\n @first // first item rationale\n ,\n @second // second item rationale\n ]; }\n".into()),
|
||||
]),
|
||||
},
|
||||
ProjectLimits::default(),
|
||||
).unwrap()
|
||||
}
|
||||
|
||||
fn find(project: &Project, alias: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, a)| *a == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
fn unchanged(project: &Project) -> BTreeMap<Id, Id> {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.map(|(id, _)| (id, id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_and_alias_edit_keep_authored_file_and_reference_rationale() {
|
||||
let current = source();
|
||||
let mut candidate = current.decoded.project.clone();
|
||||
let first = find(&candidate, "first");
|
||||
let changed = Id::new_v4();
|
||||
let mut identities = unchanged(&candidate);
|
||||
identities.insert(first, changed);
|
||||
reidentify(&mut candidate, first, changed).unwrap();
|
||||
candidate.elements.get_mut(&changed).unwrap().alias = "revised_first".into();
|
||||
let result = update_bundle_with_identity_map(
|
||||
¤t,
|
||||
&candidate,
|
||||
&identities,
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.decoded.project, candidate);
|
||||
assert_eq!(
|
||||
result.index.declarations[&changed].path,
|
||||
"team/first.arcadia"
|
||||
);
|
||||
assert_eq!(result.bundle.files.len(), current.bundle.files.len());
|
||||
assert_eq!(
|
||||
result.bundle.files["team/second.arcadia"],
|
||||
current.bundle.files["team/second.arcadia"]
|
||||
);
|
||||
assert_eq!(
|
||||
result.bundle.files["team/first.arcadia"],
|
||||
current.bundle.files["team/first.arcadia"]
|
||||
.replace(&first.to_string(), &changed.to_string())
|
||||
.replace("sa_exchange_item first ", "sa_exchange_item revised_first ")
|
||||
);
|
||||
assert!(
|
||||
result.bundle.files["contracts.arcadia"].contains("@revised_first // first item rationale")
|
||||
);
|
||||
assert_eq!(
|
||||
update_bundle(&result, &candidate, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.bundle,
|
||||
result.bundle
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_swap_and_reorder_keep_both_declarations_and_item_comments_with_origin() {
|
||||
let current = source();
|
||||
let mut candidate = current.decoded.project.clone();
|
||||
let first = find(&candidate, "first");
|
||||
let second = find(&candidate, "second");
|
||||
let temporary = Id::new_v4();
|
||||
let mut identities = unchanged(&candidate);
|
||||
identities.insert(first, second);
|
||||
identities.insert(second, first);
|
||||
reidentify(&mut candidate, first, temporary).unwrap();
|
||||
reidentify(&mut candidate, second, first).unwrap();
|
||||
reidentify(&mut candidate, temporary, second).unwrap();
|
||||
let contract = find(&candidate, "contract");
|
||||
let Some(SharedBody::Interface { items }) =
|
||||
candidate.elements.get_mut(&contract).unwrap().shared_mut()
|
||||
else {
|
||||
panic!("interface")
|
||||
};
|
||||
items.reverse();
|
||||
let result = update_bundle_with_identity_map(
|
||||
¤t,
|
||||
&candidate,
|
||||
&identities,
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.decoded.project, candidate);
|
||||
assert_eq!(
|
||||
result.index.declarations[&second].path,
|
||||
"team/first.arcadia"
|
||||
);
|
||||
assert_eq!(
|
||||
result.index.declarations[&first].path,
|
||||
"team/second.arcadia"
|
||||
);
|
||||
let text = &result.bundle.files["contracts.arcadia"];
|
||||
assert!(text.contains("@first // first item rationale"), "{text}");
|
||||
assert!(text.contains("@second, // second item rationale"), "{text}");
|
||||
assert!(
|
||||
text.find("second item rationale").unwrap() < text.find("first item rationale").unwrap(),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provenance_cannot_merge_records_or_cross_record_classes() {
|
||||
let current = source();
|
||||
let candidate = ¤t.decoded.project;
|
||||
let first = find(candidate, "first");
|
||||
let second = find(candidate, "second");
|
||||
for destination in [second, candidate.id, Id::new_v4()] {
|
||||
let mut identities = unchanged(candidate);
|
||||
identities.insert(first, destination);
|
||||
let errors = update_bundle_with_identity_map(
|
||||
¤t,
|
||||
candidate,
|
||||
&identities,
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(errors[0].diagnostic.code, "invalid_identity_map");
|
||||
}
|
||||
}
|
||||
@@ -191,6 +191,40 @@ fn cross_file_typed_ports_global_forward_references_and_stable_ids() {
|
||||
assert_eq!(warning.diagnostic.line, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advisory_lint_locations_refer_to_raw_drafts_before_identity_stabilization() {
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"P\" { include \"work.arcadia\"; }",
|
||||
&[(
|
||||
"work.arcadia",
|
||||
"sa_function first \"First\" {}\nsa_function second \"Second\" {}\n",
|
||||
)],
|
||||
);
|
||||
let before = input.clone();
|
||||
let raw = &input.files["work.arcadia"];
|
||||
let warnings = lint_bundle(&input, ProjectLimits::default());
|
||||
let second = warnings
|
||||
.iter()
|
||||
.find(|d| {
|
||||
d.path == "work.arcadia"
|
||||
&& d.diagnostic.code == "unallocated_leaf"
|
||||
&& d.diagnostic.line == 2
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
second.diagnostic.span.start,
|
||||
raw.find("sa_function second").unwrap()
|
||||
);
|
||||
assert!(raw[second.diagnostic.span.start..second.diagnostic.span.end].contains("second"));
|
||||
assert_eq!(input, before);
|
||||
let compiled = compile(&input);
|
||||
assert_ne!(compiled.bundle, input);
|
||||
assert!(compiled.diagnostics.iter().any(|d| d.path == "work.arcadia"
|
||||
&& d.diagnostic.code == "unallocated_leaf"
|
||||
&& d.diagnostic.line == 2
|
||||
&& d.diagnostic.span.start > second.diagnostic.span.start));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_errors_keep_exact_fragment_unicode_positions() {
|
||||
let input = bundle(
|
||||
|
||||
Reference in New Issue
Block a user