Compose ordinary source defaults with the reviewed Text workspace
This commit is contained in:
@@ -3,6 +3,8 @@
|
|||||||
mod semantic_source;
|
mod semantic_source;
|
||||||
#[path = "migration_v2/source.rs"]
|
#[path = "migration_v2/source.rs"]
|
||||||
mod v2_source;
|
mod v2_source;
|
||||||
|
pub(crate) use crate::source_defaults::Mode as DeclarationFormat;
|
||||||
|
use crate::source_defaults::{self, Context as DefaultContext};
|
||||||
use crate::*;
|
use crate::*;
|
||||||
use serde_json::{Map, Value};
|
use serde_json::{Map, Value};
|
||||||
use std::{collections::BTreeMap, fmt};
|
use std::{collections::BTreeMap, fmt};
|
||||||
@@ -645,25 +647,34 @@ fn offset_for(source: &str, line: usize, column: usize) -> usize {
|
|||||||
source.len()
|
source.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Canonical output uses persistent aliases and explicit identities. Typed
|
/// Canonical output uses persistent aliases and explicit identities, omitting
|
||||||
/// nested records are ordinary language fields; JSON documents are not embedded.
|
/// only values supplied by source defaults. Typed nested records are ordinary
|
||||||
|
/// language fields; JSON documents are not embedded.
|
||||||
pub fn format(project: &Project) -> String {
|
pub fn format(project: &Project) -> String {
|
||||||
let mut out = std::format!(
|
let mut out = std::format!(
|
||||||
"arcadia {SCHEMA_VERSION};\nproject {} {} id \"{}\" {{\n description {};\n\n",
|
"arcadia {SCHEMA_VERSION};\nproject {} {} id \"{}\" {{\n",
|
||||||
project.alias,
|
project.alias,
|
||||||
quote(&project.name),
|
quote(&project.name),
|
||||||
project.id,
|
project.id,
|
||||||
quote(&project.description)
|
|
||||||
);
|
);
|
||||||
for (_, declaration) in format_declarations(project) {
|
if !project.description.is_empty() {
|
||||||
|
out.push_str(&std::format!(
|
||||||
|
" description {};\n",
|
||||||
|
quote(&project.description)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
out.push('\n');
|
||||||
|
for (_, declaration) in format_declarations(project, DeclarationFormat::Compact) {
|
||||||
out.push_str(&declaration);
|
out.push_str(&declaration);
|
||||||
}
|
}
|
||||||
out.push_str("}\n");
|
out.push_str("}\n");
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The same typed declaration writer is used by flat and modular source output.
|
/// Shared typed writer for canonical output and lossless editing. Complete edit
|
||||||
pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
|
/// comparisons retain defaults; emitted declarations and value fragments apply
|
||||||
|
/// only the omission rules valid in their selected source context.
|
||||||
|
pub(crate) fn format_declarations(project: &Project, mode: DeclarationFormat) -> Vec<(Id, String)> {
|
||||||
let aliases = project
|
let aliases = project
|
||||||
.identities()
|
.identities()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -675,6 +686,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
|
|||||||
let mut fields = payload(&serde_json::to_value(&e.body).unwrap());
|
let mut fields = payload(&serde_json::to_value(&e.body).unwrap());
|
||||||
fields.insert("description".into(), Value::String(e.description.clone()));
|
fields.insert("description".into(), Value::String(e.description.clone()));
|
||||||
fields.insert("parent".into(), serde_json::to_value(e.parent).unwrap());
|
fields.insert("parent".into(), serde_json::to_value(e.parent).unwrap());
|
||||||
|
let fields = source_defaults::fields(DefaultContext::Element(e.kind()), &fields, mode);
|
||||||
write_declaration(
|
write_declaration(
|
||||||
&mut out,
|
&mut out,
|
||||||
&element_word(e.kind(), e.layer()),
|
&element_word(e.kind(), e.layer()),
|
||||||
@@ -690,6 +702,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
|
|||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
let mut fields = payload(&serde_json::to_value(&r.body).unwrap());
|
let mut fields = payload(&serde_json::to_value(&r.body).unwrap());
|
||||||
fields.insert("description".into(), Value::String(r.description.clone()));
|
fields.insert("description".into(), Value::String(r.description.clone()));
|
||||||
|
let fields = source_defaults::fields(DefaultContext::Relation(r.kind()), &fields, mode);
|
||||||
write_declaration(
|
write_declaration(
|
||||||
&mut out,
|
&mut out,
|
||||||
&relation_word(r.kind()),
|
&relation_word(r.kind()),
|
||||||
@@ -705,6 +718,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
|
|||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
let mut fields = record_fields(d);
|
let mut fields = record_fields(d);
|
||||||
fields.remove("nodes");
|
fields.remove("nodes");
|
||||||
|
let fields = source_defaults::fields(DefaultContext::Diagram, &fields, mode);
|
||||||
write_declaration(
|
write_declaration(
|
||||||
&mut out,
|
&mut out,
|
||||||
"diagram",
|
"diagram",
|
||||||
@@ -719,6 +733,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
|
|||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
let mut fields = record_fields(n);
|
let mut fields = record_fields(n);
|
||||||
fields.insert("diagram".into(), Value::String(d.id.to_string()));
|
fields.insert("diagram".into(), Value::String(d.id.to_string()));
|
||||||
|
let fields = source_defaults::fields(DefaultContext::Node, &fields, mode);
|
||||||
write_declaration(&mut out, "node", &n.alias, None, n.id, &fields, &aliases);
|
write_declaration(&mut out, "node", &n.alias, None, n.id, &fields, &aliases);
|
||||||
declarations.push((n.id, out));
|
declarations.push((n.id, out));
|
||||||
}
|
}
|
||||||
@@ -731,7 +746,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
|
|||||||
&a.alias,
|
&a.alias,
|
||||||
Some(&a.name),
|
Some(&a.name),
|
||||||
a.id,
|
a.id,
|
||||||
&record_fields(a),
|
&source_defaults::fields(DefaultContext::Artifact, &record_fields(a), mode),
|
||||||
&aliases,
|
&aliases,
|
||||||
);
|
);
|
||||||
declarations.push((a.id, out));
|
declarations.push((a.id, out));
|
||||||
@@ -744,7 +759,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
|
|||||||
&t.alias,
|
&t.alias,
|
||||||
Some(&t.name),
|
Some(&t.name),
|
||||||
t.id,
|
t.id,
|
||||||
&record_fields(t),
|
&source_defaults::fields(DefaultContext::Trace, &record_fields(t), mode),
|
||||||
&aliases,
|
&aliases,
|
||||||
);
|
);
|
||||||
declarations.push((t.id, out));
|
declarations.push((t.id, out));
|
||||||
@@ -1742,7 +1757,9 @@ pub(crate) fn resolve_declarations(
|
|||||||
*viewpoint = ScenarioViewpoint::MixedLegacy;
|
*viewpoint = ScenarioViewpoint::MixedLegacy;
|
||||||
}
|
}
|
||||||
let mut body = serde_json::to_value(initial).unwrap();
|
let mut body = serde_json::to_value(initial).unwrap();
|
||||||
let defaults = if legacy {
|
let defaults = if project.schema_version >= 7 {
|
||||||
|
source_defaults::parser_fields(DefaultContext::Element(kind))
|
||||||
|
} else if legacy {
|
||||||
crate::persistence::v3::legacy_defaults(kind)
|
crate::persistence::v3::legacy_defaults(kind)
|
||||||
.and_then(|value| value.as_object().cloned())
|
.and_then(|value| value.as_object().cloned())
|
||||||
.unwrap_or_else(|| payload(&body))
|
.unwrap_or_else(|| payload(&body))
|
||||||
@@ -1799,7 +1816,12 @@ pub(crate) fn resolve_declarations(
|
|||||||
let mut body =
|
let mut body =
|
||||||
serde_json::to_value(RelationBody::new(kind, Id::nil(), Id::nil()))
|
serde_json::to_value(RelationBody::new(kind, Id::nil(), Id::nil()))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let merged = merge_fields(payload(&body), fields).map_err(&conversion_error)?;
|
let defaults = if project.schema_version >= 7 {
|
||||||
|
source_defaults::parser_fields(DefaultContext::Relation(kind))
|
||||||
|
} else {
|
||||||
|
payload(&body)
|
||||||
|
};
|
||||||
|
let merged = merge_fields(defaults, fields).map_err(&conversion_error)?;
|
||||||
replace_payload(&mut body, merged);
|
replace_payload(&mut body, merged);
|
||||||
let body = serde_json::from_value(body)
|
let body = serde_json::from_value(body)
|
||||||
.map_err(|e| conversion_error(e.to_string()))?;
|
.map_err(|e| conversion_error(e.to_string()))?;
|
||||||
@@ -1824,7 +1846,10 @@ pub(crate) fn resolve_declarations(
|
|||||||
}
|
}
|
||||||
let defaults =
|
let defaults =
|
||||||
serde_json::json!({"description":"","layer":"Oa","projection":"Overview","routes":[],"view_options":crate::view_options::ViewOptions::default()});
|
serde_json::json!({"description":"","layer":"Oa","projection":"Overview","routes":[],"view_options":crate::view_options::ViewOptions::default()});
|
||||||
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
|
let defaults = if project.schema_version >= 7 {
|
||||||
|
source_defaults::parser_fields(DefaultContext::Diagram)
|
||||||
|
} else { defaults.as_object().unwrap().clone() };
|
||||||
|
let mut record = merge_fields(defaults, fields)
|
||||||
.map_err(&conversion_error)?;
|
.map_err(&conversion_error)?;
|
||||||
header(&mut record, d, true);
|
header(&mut record, d, true);
|
||||||
record.insert("nodes".into(), serde_json::json!({}));
|
record.insert("nodes".into(), serde_json::json!({}));
|
||||||
@@ -1839,7 +1864,10 @@ pub(crate) fn resolve_declarations(
|
|||||||
return Err(conversion_error("Missing required field 'element'.".into()));
|
return Err(conversion_error("Missing required field 'element'.".into()));
|
||||||
}
|
}
|
||||||
let defaults = serde_json::json!({"element":null,"position":{"x":0.0,"y":0.0},"size":{"width":224.0,"height":94.0},"collapsed":false,"port_anchors":[]});
|
let defaults = serde_json::json!({"element":null,"position":{"x":0.0,"y":0.0},"size":{"width":224.0,"height":94.0},"collapsed":false,"port_anchors":[]});
|
||||||
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
|
let defaults = if project.schema_version >= 7 {
|
||||||
|
source_defaults::parser_fields(DefaultContext::Node)
|
||||||
|
} else { defaults.as_object().unwrap().clone() };
|
||||||
|
let mut record = merge_fields(defaults, fields)
|
||||||
.map_err(&conversion_error)?;
|
.map_err(&conversion_error)?;
|
||||||
header(&mut record, d, false);
|
header(&mut record, d, false);
|
||||||
let node: NodePlacement = serde_json::from_value(Value::Object(record))
|
let node: NodePlacement = serde_json::from_value(Value::Object(record))
|
||||||
@@ -1851,7 +1879,10 @@ pub(crate) fn resolve_declarations(
|
|||||||
return Err(conversion_error("Missing required field 'owner'.".into()));
|
return Err(conversion_error("Missing required field 'owner'.".into()));
|
||||||
}
|
}
|
||||||
let defaults = serde_json::json!({"description":"","owner":null,"relation":"References","uri":"","revision":null,"fragment":null});
|
let defaults = serde_json::json!({"description":"","owner":null,"relation":"References","uri":"","revision":null,"fragment":null});
|
||||||
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
|
let defaults = if project.schema_version >= 7 {
|
||||||
|
source_defaults::parser_fields(DefaultContext::Artifact)
|
||||||
|
} else { defaults.as_object().unwrap().clone() };
|
||||||
|
let mut record = merge_fields(defaults, fields)
|
||||||
.map_err(&conversion_error)?;
|
.map_err(&conversion_error)?;
|
||||||
header(&mut record, d, true);
|
header(&mut record, d, true);
|
||||||
let artifact = serde_json::from_value(Value::Object(record))
|
let artifact = serde_json::from_value(Value::Object(record))
|
||||||
@@ -1867,7 +1898,10 @@ pub(crate) fn resolve_declarations(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let defaults = serde_json::json!({"description":"","oa":null,"sa":null,"kind":"Refinement","baseline":null});
|
let defaults = serde_json::json!({"description":"","oa":null,"sa":null,"kind":"Refinement","baseline":null});
|
||||||
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
|
let defaults = if project.schema_version >= 7 {
|
||||||
|
source_defaults::parser_fields(DefaultContext::Trace)
|
||||||
|
} else { defaults.as_object().unwrap().clone() };
|
||||||
|
let mut record = merge_fields(defaults, fields)
|
||||||
.map_err(&conversion_error)?;
|
.map_err(&conversion_error)?;
|
||||||
header(&mut record, d, true);
|
header(&mut record, d, true);
|
||||||
let trace = if project.schema_version < 5 {
|
let trace = if project.schema_version < 5 {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pub mod references;
|
|||||||
pub mod routing;
|
pub mod routing;
|
||||||
pub mod semantic;
|
pub mod semantic;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
|
mod source_defaults;
|
||||||
pub mod source_files;
|
pub mod source_files;
|
||||||
pub mod source_project;
|
pub mod source_project;
|
||||||
pub mod topology_context;
|
pub mod topology_context;
|
||||||
|
|||||||
@@ -0,0 +1,382 @@
|
|||||||
|
//! Ordinary source defaults shared by current parsing and declaration rendering.
|
||||||
|
//!
|
||||||
|
//! These are source omission defaults, not graphical creation defaults. The
|
||||||
|
//! policy operates on complete typed declaration maps. Required named fields
|
||||||
|
//! and selected constructor payloads remain distinct from omission defaults.
|
||||||
|
//! Historical versions use their existing frozen parser paths. Semantic baseline
|
||||||
|
//! history is never traversed or normalized here.
|
||||||
|
use crate::{Kind, RelationKind};
|
||||||
|
use serde_json::{Map, Value, json};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum Context {
|
||||||
|
Element(Kind),
|
||||||
|
Relation(RelationKind),
|
||||||
|
Diagram,
|
||||||
|
Node,
|
||||||
|
Artifact,
|
||||||
|
Trace,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum Mode {
|
||||||
|
CompleteForEdit,
|
||||||
|
Compact,
|
||||||
|
/// Internal lossless-edit fragments: retain every top-level value while
|
||||||
|
/// compacting its selected constructor's optional nested fields.
|
||||||
|
CompactValues,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Top-level field inventory for the current ordinary reader. Callers must
|
||||||
|
/// check required fields before merging; null entries only admit their names.
|
||||||
|
/// This never supplies defaults inside a selected constructor.
|
||||||
|
pub(crate) fn parser_fields(context: Context) -> Map<String, Value> {
|
||||||
|
let mut fields = declaration_defaults(context);
|
||||||
|
let required: &[&str] = match context {
|
||||||
|
Context::Element(kind) => {
|
||||||
|
fields.remove("description");
|
||||||
|
fields.remove("parent");
|
||||||
|
if kind == Kind::Port {
|
||||||
|
&["contract"]
|
||||||
|
} else {
|
||||||
|
&[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Context::Relation(kind) => {
|
||||||
|
fields.remove("description");
|
||||||
|
match kind {
|
||||||
|
RelationKind::ContextualLink => &["owner", "source", "target", "purpose"],
|
||||||
|
RelationKind::FunctionalExchange => {
|
||||||
|
&["source", "target", "source_port", "target_port"]
|
||||||
|
}
|
||||||
|
_ => &["source", "target"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Context::Diagram => &[],
|
||||||
|
Context::Node => &["element"],
|
||||||
|
Context::Artifact => &["owner"],
|
||||||
|
Context::Trace => &["oa", "sa"],
|
||||||
|
};
|
||||||
|
for name in required {
|
||||||
|
fields.insert((*name).into(), Value::Null);
|
||||||
|
}
|
||||||
|
fields
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn fields(
|
||||||
|
context: Context,
|
||||||
|
input: &Map<String, Value>,
|
||||||
|
mode: Mode,
|
||||||
|
) -> Map<String, Value> {
|
||||||
|
let mut result = input.clone();
|
||||||
|
restore_serialization_skips(context, &mut result);
|
||||||
|
if mode != Mode::CompleteForEdit {
|
||||||
|
compact_nested(context, &mut result);
|
||||||
|
}
|
||||||
|
if mode == Mode::Compact {
|
||||||
|
// Compact a composite and its declared default by the same nested rules
|
||||||
|
// before whole-field comparison. This also makes compact input stable:
|
||||||
|
// e.g. String{minimum_length:0} still denotes the default definition.
|
||||||
|
let mut defaults = declaration_defaults(context);
|
||||||
|
compact_nested(context, &mut defaults);
|
||||||
|
omit_defaults(&mut result, &defaults);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore_serialization_skips(context: Context, fields: &mut Map<String, Value>) {
|
||||||
|
match context {
|
||||||
|
Context::Element(Kind::Occurrence) => {
|
||||||
|
if let Some(exchange) = constructor_payload(fields, "body", "Exchange") {
|
||||||
|
exchange
|
||||||
|
.entry("operation".to_owned())
|
||||||
|
.or_insert(Value::Null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Context::Node => {
|
||||||
|
fields.entry("port_anchors".to_owned()).or_insert(json!([]));
|
||||||
|
}
|
||||||
|
Context::Trace => {
|
||||||
|
if let Some(baseline) = fields.get_mut("baseline").and_then(Value::as_object_mut) {
|
||||||
|
baseline.entry("semantic".to_owned()).or_insert(Value::Null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn declaration_defaults(context: Context) -> Map<String, Value> {
|
||||||
|
let mut defaults = match context {
|
||||||
|
Context::Element(kind) => element_defaults(kind),
|
||||||
|
Context::Relation(kind) => relation_defaults(kind),
|
||||||
|
Context::Diagram => object(json!({
|
||||||
|
"description": "", "layer": "Oa", "projection": "Overview",
|
||||||
|
"routes": [], "view_options": {
|
||||||
|
"hidden_edges": [], "relation_kinds": "All",
|
||||||
|
"work_categories": "All", "participant_categories": "All",
|
||||||
|
"coverage": "All", "work_contexts": "All", "exchange_labels": "Name"
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
Context::Node => object(json!({
|
||||||
|
"position": {"x": 0.0, "y": 0.0},
|
||||||
|
"size": {"width": 224.0, "height": 94.0},
|
||||||
|
"collapsed": false, "port_anchors": []
|
||||||
|
})),
|
||||||
|
Context::Artifact => object(json!({
|
||||||
|
"description": "", "relation": "References", "uri": "",
|
||||||
|
"revision": null, "fragment": null
|
||||||
|
})),
|
||||||
|
Context::Trace => object(json!({
|
||||||
|
"description": "", "kind": "Refinement", "baseline": null
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
match context {
|
||||||
|
Context::Element(_) => {
|
||||||
|
defaults.insert("description".into(), json!(""));
|
||||||
|
defaults.insert("parent".into(), Value::Null);
|
||||||
|
}
|
||||||
|
Context::Relation(_) => {
|
||||||
|
defaults.insert("description".into(), json!(""));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
fn element_defaults(kind: Kind) -> Map<String, Value> {
|
||||||
|
if let Some(state_kind) = kind.state_kind() {
|
||||||
|
// The declaration selects this discriminator; no GUI factory is used.
|
||||||
|
return object(json!({
|
||||||
|
"kind": state_kind, "entry": "", "exit": "", "do_activity": ""
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
match kind {
|
||||||
|
Kind::Process | Kind::FunctionalChain => object(json!({"steps": []})),
|
||||||
|
Kind::ExchangeItem => object(json!({
|
||||||
|
"kind": "Information", "mechanism": "Unspecified", "supertype": null
|
||||||
|
})),
|
||||||
|
Kind::ExchangeCategory => object(json!({"family": "Work", "exchanges": []})),
|
||||||
|
Kind::DataType => object(json!({
|
||||||
|
"definition": {"String": {"minimum_length": 0, "maximum_length": null}},
|
||||||
|
"default": null, "unit": ""
|
||||||
|
})),
|
||||||
|
Kind::DataField => object(json!({
|
||||||
|
"type_ref": "String", "minimum": 1, "maximum": 1, "unit": "",
|
||||||
|
"direction": "Unspecified", "storage": "Value", "default": null
|
||||||
|
})),
|
||||||
|
Kind::Interface => object(json!({"supertype": null})),
|
||||||
|
// Port.contract is required, even when its explicit value is Legacy.
|
||||||
|
Kind::Port => object(json!({"legacy": null})),
|
||||||
|
Kind::Scenario => object(json!({
|
||||||
|
"viewpoint": "MixedLegacy", "context": null,
|
||||||
|
"precondition": "", "postcondition": "", "steps": []
|
||||||
|
})),
|
||||||
|
Kind::Lifeline => object(json!({"represented": null})),
|
||||||
|
Kind::Occurrence => object(json!({
|
||||||
|
"body": {"Exchange": {
|
||||||
|
"exchange": null, "sender": null, "receiver": null,
|
||||||
|
"message_kind": "Asynchronous", "reply_to": null,
|
||||||
|
"values": [], "operation": null
|
||||||
|
}},
|
||||||
|
"precondition": "", "postcondition": ""
|
||||||
|
})),
|
||||||
|
Kind::Event => object(json!({"body": {"Signal": {"items": []}}})),
|
||||||
|
Kind::Constraint => object(json!({"expression": "", "constrained": []})),
|
||||||
|
// Unit-body declarations have no additional omission defaults. A future
|
||||||
|
// Kind reaches this branch without gaining speculative payload rules.
|
||||||
|
_ => Map::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relation_defaults(kind: RelationKind) -> Map<String, Value> {
|
||||||
|
match kind {
|
||||||
|
RelationKind::InterfaceItemAllocation => object(json!({
|
||||||
|
"order": 0, "transmission": "Unspecified", "acquisition": "Unspecified"
|
||||||
|
})),
|
||||||
|
RelationKind::DataAssociation => object(json!({
|
||||||
|
"source_role": "", "target_role": "",
|
||||||
|
"source_minimum": 0, "target_minimum": 0,
|
||||||
|
"source_maximum": null, "target_maximum": null,
|
||||||
|
"source_navigable": false, "target_navigable": false
|
||||||
|
})),
|
||||||
|
RelationKind::Interaction | RelationKind::FunctionalExchange => {
|
||||||
|
object(json!({"items": []}))
|
||||||
|
}
|
||||||
|
RelationKind::BoundaryExchange => object(json!({"items": [], "oriented": true})),
|
||||||
|
RelationKind::CommunicationMean => object(json!({"items": [], "oriented": false})),
|
||||||
|
RelationKind::CapabilityExtend => object(json!({"condition": ""})),
|
||||||
|
RelationKind::SequenceLink => object(json!({"guard": ""})),
|
||||||
|
RelationKind::StateTransition => object(json!({
|
||||||
|
"transition_kind": "External", "trigger": "", "guard": "", "effect": "",
|
||||||
|
"triggers": [], "effects": []
|
||||||
|
})),
|
||||||
|
RelationKind::StateAction => object(json!({"phase": "Do"})),
|
||||||
|
// source/target, FunctionalExchange ports and ContextualLink's owner,
|
||||||
|
// endpoints and purpose are required, despite factory placeholder values.
|
||||||
|
_ => Map::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compact_nested(context: Context, fields: &mut Map<String, Value>) {
|
||||||
|
match context {
|
||||||
|
Context::Element(Kind::DataType) => compact_data_definition(fields),
|
||||||
|
Context::Element(Kind::Port) => {
|
||||||
|
if let Some(legacy) = fields.get_mut("legacy").and_then(Value::as_object_mut) {
|
||||||
|
omit_nulls(legacy, &["interface"]);
|
||||||
|
}
|
||||||
|
// Every selected PortContract payload field remains required.
|
||||||
|
}
|
||||||
|
Context::Element(Kind::Occurrence) => compact_occurrence(fields),
|
||||||
|
Context::Diagram => {
|
||||||
|
if let Some(options) = fields
|
||||||
|
.get_mut("view_options")
|
||||||
|
.and_then(Value::as_object_mut)
|
||||||
|
{
|
||||||
|
omit_defaults(
|
||||||
|
options,
|
||||||
|
&object(json!({
|
||||||
|
"hidden_edges": [], "relation_kinds": "All",
|
||||||
|
"work_categories": "All", "participant_categories": "All",
|
||||||
|
"coverage": "All", "work_contexts": "All", "exchange_labels": "Name"
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(routes) = fields.get_mut("routes").and_then(Value::as_array_mut) {
|
||||||
|
for route in routes {
|
||||||
|
if let Some(route) = route.as_object_mut() {
|
||||||
|
omit_defaults(route, &object(json!({"waypoints": [], "label": null})));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Route entries, EdgeLabel coordinates, projection machine IDs, and
|
||||||
|
// Only([])/Any([]) selectors remain even when their values are empty.
|
||||||
|
}
|
||||||
|
Context::Trace => {
|
||||||
|
if let Some(baseline) = fields.get_mut("baseline").and_then(Value::as_object_mut) {
|
||||||
|
omit_nulls(baseline, &["semantic"]);
|
||||||
|
}
|
||||||
|
// No recursion through snapshots or semantic historical facets.
|
||||||
|
}
|
||||||
|
// DataValue/TypeRef payloads, EventBody fields, Node geometry/anchors,
|
||||||
|
// contextual endpoints/purposes, and other required records stay intact.
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compact_data_definition(fields: &mut Map<String, Value>) {
|
||||||
|
for tag in ["Integer", "Real"] {
|
||||||
|
if let Some(payload) = constructor_payload(fields, "definition", tag) {
|
||||||
|
omit_nulls(payload, &["minimum", "maximum"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(payload) = constructor_payload(fields, "definition", "String") {
|
||||||
|
omit_nulls(payload, &["maximum_length"]);
|
||||||
|
// minimum_length is required inside a selected String constructor.
|
||||||
|
}
|
||||||
|
if let Some(payload) = constructor_payload(fields, "definition", "Record") {
|
||||||
|
omit_nulls(payload, &["supertype"]);
|
||||||
|
}
|
||||||
|
// Boolean has no payload; Enumeration.literals remains required even [].
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compact_occurrence(fields: &mut Map<String, Value>) {
|
||||||
|
if let Some(exchange) = constructor_payload(fields, "body", "Exchange") {
|
||||||
|
omit_defaults(
|
||||||
|
exchange,
|
||||||
|
&object(json!({
|
||||||
|
"exchange": null, "sender": null, "receiver": null,
|
||||||
|
"message_kind": "Asynchronous", "reply_to": null,
|
||||||
|
"values": [], "operation": null
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
if let Some(operation) = exchange.get_mut("operation").and_then(Value::as_object_mut) {
|
||||||
|
omit_nulls(operation, &["allocation"]);
|
||||||
|
// Keep the containing operation field even if its record becomes {}:
|
||||||
|
// explicit unresolved operation intent is not an absent operation.
|
||||||
|
}
|
||||||
|
if let Some(values) = exchange.get_mut("values").and_then(Value::as_array_mut) {
|
||||||
|
for value in values {
|
||||||
|
if let Some(message_value) = value.as_object_mut() {
|
||||||
|
omit_defaults(message_value, &object(json!({"path": []})));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// MessageKind::Timer.expression, MessageValue.field/expression remain.
|
||||||
|
}
|
||||||
|
for (tag, optional) in [
|
||||||
|
("Action", &["action", "lifeline"][..]),
|
||||||
|
("State", &["state", "lifeline"][..]),
|
||||||
|
("SequenceReference", &["sequence"][..]),
|
||||||
|
("ScenarioUse", &["scenario"][..]),
|
||||||
|
] {
|
||||||
|
if let Some(payload) = constructor_payload(fields, "body", tag) {
|
||||||
|
omit_nulls(payload, optional);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ScenarioUse.bindings, Control.kind, Fragment.operator/branches and
|
||||||
|
// Branch.guard/occurrences are required, including empty strings/lists.
|
||||||
|
}
|
||||||
|
|
||||||
|
fn constructor_payload<'a>(
|
||||||
|
fields: &'a mut Map<String, Value>,
|
||||||
|
key: &str,
|
||||||
|
tag: &str,
|
||||||
|
) -> Option<&'a mut Map<String, Value>> {
|
||||||
|
let wrapper = fields.get_mut(key)?.as_object_mut()?;
|
||||||
|
if wrapper.len() != 1 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
wrapper.get_mut(tag)?.as_object_mut()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn omit_nulls(fields: &mut Map<String, Value>, names: &[&str]) {
|
||||||
|
for name in names {
|
||||||
|
if fields.get(*name).is_some_and(Value::is_null) {
|
||||||
|
fields.remove(*name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn omit_defaults(fields: &mut Map<String, Value>, defaults: &Map<String, Value>) {
|
||||||
|
for (name, default) in defaults {
|
||||||
|
if fields.get(name).is_some_and(|value| same(value, default)) {
|
||||||
|
fields.remove(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comparison recurses only to compare a known field with its exact declared
|
||||||
|
// default. It never traverses arbitrary data looking for values to remove.
|
||||||
|
fn same(value: &Value, default: &Value) -> bool {
|
||||||
|
match (value, default) {
|
||||||
|
(Value::Object(a), Value::Object(b)) => {
|
||||||
|
a.len() == b.len()
|
||||||
|
&& a.iter()
|
||||||
|
.all(|(key, value)| b.get(key).is_some_and(|d| same(value, d)))
|
||||||
|
}
|
||||||
|
(Value::Array(a), Value::Array(b)) => {
|
||||||
|
a.len() == b.len() && a.iter().zip(b).all(|(value, d)| same(value, d))
|
||||||
|
}
|
||||||
|
(Value::Number(a), Value::Number(b)) => {
|
||||||
|
// serde_json numeric equality alone can equate -0.0 with +0.0.
|
||||||
|
// Extracted f32 coordinates are represented exactly as f64 here.
|
||||||
|
a == b
|
||||||
|
&& match (a.as_f64(), b.as_f64()) {
|
||||||
|
(Some(x), Some(y)) if x == 0.0 && y == 0.0 => x.to_bits() == y.to_bits(),
|
||||||
|
_ => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => value == default,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn object(value: Value) -> Map<String, Value> {
|
||||||
|
match value {
|
||||||
|
Value::Object(fields) => fields,
|
||||||
|
_ => unreachable!("literal source default is an object"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "source_defaults_tests.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,718 @@
|
|||||||
|
//! Independent literal oracles for the production source-default policy.
|
||||||
|
use super::{self as policy, Context, Mode};
|
||||||
|
use crate::{Kind, RelationKind};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn complete_comparison_keeps_populated_serializer_skip_values() {
|
||||||
|
for (context, input) in [
|
||||||
|
(
|
||||||
|
Context::Element(Kind::Occurrence),
|
||||||
|
json!({"body":{"Exchange":{"operation":{"allocation":"allocation-id"},"values":[]}}}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Context::Node,
|
||||||
|
json!({"port_anchors":[{"port":"port-id","side":"Left","offset":0.0}]}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Context::Trace,
|
||||||
|
json!({"baseline":{"semantic":{"historical":"payload"}}}),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
assert_eq!(rendered(context, &input, Mode::CompleteForEdit), input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compact_value_fragments_keep_selected_constructor_requirements() {
|
||||||
|
for (context, input, expected) in [
|
||||||
|
(
|
||||||
|
Context::Element(Kind::DataType),
|
||||||
|
json!({"definition":{"String":{"minimum_length":0,"maximum_length":null}},"default":null,"unit":""}),
|
||||||
|
json!({"definition":{"String":{"minimum_length":0}},"default":null,"unit":""}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Context::Element(Kind::Event),
|
||||||
|
json!({"body":{"Signal":{"items":[]}}}),
|
||||||
|
json!({"body":{"Signal":{"items":[]}}}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Context::Element(Kind::Occurrence),
|
||||||
|
json!({"body":{"Exchange":{"operation":{"allocation":null},"values":[]}},"precondition":""}),
|
||||||
|
json!({"body":{"Exchange":{"operation":{}}},"precondition":""}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Context::Element(Kind::Occurrence),
|
||||||
|
json!({"body":{"ScenarioUse":{"scenario":null,"bindings":[]}}}),
|
||||||
|
json!({"body":{"ScenarioUse":{"bindings":[]}}}),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
assert_eq!(rendered(context, &input, Mode::CompactValues), expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_scenario_default_is_independent_of_graphical_creation() {
|
||||||
|
use crate::{ElementBody, Layer, ScenarioViewpoint, SharedBody, language};
|
||||||
|
let project = language::parse(
|
||||||
|
"arcadia 7; project p \"P\" { oa_scenario o \"O\" {} sa_scenario s \"S\" {} }",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
for element in project.elements.values() {
|
||||||
|
assert!(matches!(
|
||||||
|
element.body.shared(),
|
||||||
|
Some(SharedBody::Scenario {
|
||||||
|
viewpoint: ScenarioViewpoint::MixedLegacy,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
assert!(!matches!(
|
||||||
|
ElementBody::new(Kind::Scenario, element.layer())
|
||||||
|
.unwrap()
|
||||||
|
.shared(),
|
||||||
|
Some(SharedBody::Scenario {
|
||||||
|
viewpoint: ScenarioViewpoint::MixedLegacy,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let source = language::format(&project);
|
||||||
|
assert!(!source.contains("description"));
|
||||||
|
assert!(!source.contains("parent"));
|
||||||
|
assert!(!source.contains("viewpoint"));
|
||||||
|
assert_eq!(language::parse(&source).unwrap(), project);
|
||||||
|
|
||||||
|
let mut typed = project;
|
||||||
|
for element in typed.elements.values_mut() {
|
||||||
|
let viewpoint = if element.layer() == Layer::Oa {
|
||||||
|
ScenarioViewpoint::OperationalActivity
|
||||||
|
} else {
|
||||||
|
ScenarioViewpoint::Functional
|
||||||
|
};
|
||||||
|
*element.body.shared_mut().unwrap() = SharedBody::Scenario {
|
||||||
|
viewpoint,
|
||||||
|
context: None,
|
||||||
|
precondition: String::new(),
|
||||||
|
postcondition: String::new(),
|
||||||
|
steps: vec![],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let source = language::format(&typed);
|
||||||
|
assert!(source.contains("viewpoint OperationalActivity;"));
|
||||||
|
assert!(source.contains("viewpoint Functional;"));
|
||||||
|
assert_eq!(language::parse(&source).unwrap(), typed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shared_parser_defaults_preserve_required_fields_and_kind_checks() {
|
||||||
|
for (declaration, message) in [
|
||||||
|
(
|
||||||
|
"oa_port p0 \"Port\" {}",
|
||||||
|
"Missing required field 'contract'",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"allocation r \"R\" { source @owner; }",
|
||||||
|
"Missing required field 'target'",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"functional_exchange r \"R\" { source @owner; target @work; }",
|
||||||
|
"Missing required field 'source_port'",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"contextual_link r \"R\" {}",
|
||||||
|
"Missing required field 'owner'",
|
||||||
|
),
|
||||||
|
("node n { diagram @d; }", "Missing required field 'element'"),
|
||||||
|
("artifact a \"A\" {}", "Missing required field 'owner'"),
|
||||||
|
("trace t \"T\" {}", "Missing required field 'oa'"),
|
||||||
|
(
|
||||||
|
"oa_state s \"S\" { kind Initial; }",
|
||||||
|
"Declaration kind disagrees",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"oa_activity a \"A\" { unknown true; }",
|
||||||
|
"Unknown field 'unknown'",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"oa_data_type t \"T\" { definition { String {}; }; }",
|
||||||
|
"minimum_length",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"oa_occurrence o \"O\" { body { ScenarioUse {}; }; }",
|
||||||
|
"bindings",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let source = format!(
|
||||||
|
"arcadia 7; project p \"P\" {{ oa_entity owner \"Owner\" {{}} oa_activity work \"Work\" {{}} diagram d \"D\" {{}} {declaration} }}"
|
||||||
|
);
|
||||||
|
let errors = crate::language::parse(&source).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
errors.iter().any(|error| error.message.contains(message)),
|
||||||
|
"{declaration}: {errors:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rendered(context: Context, input: &Value, mode: Mode) -> Value {
|
||||||
|
Value::Object(policy::fields(context, input.as_object().unwrap(), mode))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compact(context: Context, input: Value, expected: Value) {
|
||||||
|
let original = input.clone();
|
||||||
|
let result = rendered(context, &input, Mode::Compact);
|
||||||
|
assert_eq!(result, expected, "context {context:?}");
|
||||||
|
assert_eq!(input, original, "the input remains immutable");
|
||||||
|
assert_eq!(
|
||||||
|
rendered(context, &result, Mode::Compact),
|
||||||
|
result,
|
||||||
|
"compact policy is idempotent"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn complete_comparison_restores_only_the_three_known_skips() {
|
||||||
|
let exchange = json!({"body":{"Exchange":{"exchange":null,"sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[]}}});
|
||||||
|
let mut complete = exchange.clone();
|
||||||
|
complete["body"]["Exchange"]["operation"] = Value::Null;
|
||||||
|
assert_eq!(
|
||||||
|
rendered(
|
||||||
|
Context::Element(Kind::Occurrence),
|
||||||
|
&exchange,
|
||||||
|
Mode::CompleteForEdit
|
||||||
|
),
|
||||||
|
complete
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rendered(
|
||||||
|
Context::Node,
|
||||||
|
&json!({"element":"e","diagram":"d"}),
|
||||||
|
Mode::CompleteForEdit
|
||||||
|
),
|
||||||
|
json!({"element":"e","diagram":"d","port_anchors":[]})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rendered(
|
||||||
|
Context::Trace,
|
||||||
|
&json!({"baseline":{"oa":{"name":"","description":""},"sa":{"name":"","description":""}}}),
|
||||||
|
Mode::CompleteForEdit
|
||||||
|
),
|
||||||
|
json!({"baseline":{"oa":{"name":"","description":""},"sa":{"name":"","description":""},"semantic":null}})
|
||||||
|
);
|
||||||
|
for input in [
|
||||||
|
json!({"body":{"Action":{"action":null,"lifeline":null}}}),
|
||||||
|
json!({"body":{"State":{"state":null,"lifeline":null}}}),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
rendered(
|
||||||
|
Context::Element(Kind::Occurrence),
|
||||||
|
&input,
|
||||||
|
Mode::CompleteForEdit
|
||||||
|
),
|
||||||
|
input
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for input in [
|
||||||
|
json!({"baseline":null}),
|
||||||
|
json!({"baseline":{"semantic":{"historical":[null,[],false,0,""]},"oa":{"name":"n","description":""},"sa":{"name":"s","description":""}}}),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
rendered(Context::Trace, &input, Mode::CompleteForEdit),
|
||||||
|
input
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_element_omits_only_empty_common_defaults() {
|
||||||
|
for kind in Kind::ALL {
|
||||||
|
// Future/unknown payloads are preservation sentinels, not admitted source.
|
||||||
|
compact(
|
||||||
|
Context::Element(kind),
|
||||||
|
json!({"description":"","parent":null,"future":{"empty":[],"absent":null,"zero":0,"flag":false}}),
|
||||||
|
json!({"future":{"empty":[],"absent":null,"zero":0,"flag":false}}),
|
||||||
|
);
|
||||||
|
compact(
|
||||||
|
Context::Element(kind),
|
||||||
|
json!({"description":" \n","parent":"owner"}),
|
||||||
|
json!({"description":" \n","parent":"owner"}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ordinary_element_defaults_are_context_specific() {
|
||||||
|
for kind in [Kind::Process, Kind::FunctionalChain] {
|
||||||
|
compact(Context::Element(kind), json!({"steps":[]}), json!({}));
|
||||||
|
compact(
|
||||||
|
Context::Element(kind),
|
||||||
|
json!({"steps":["second","first"]}),
|
||||||
|
json!({"steps":["second","first"]}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
compact(
|
||||||
|
Context::Element(Kind::ExchangeItem),
|
||||||
|
json!({"kind":"Information","mechanism":"Unspecified","supertype":null}),
|
||||||
|
json!({}),
|
||||||
|
);
|
||||||
|
compact(
|
||||||
|
Context::Element(Kind::ExchangeItem),
|
||||||
|
json!({"kind":"Energy","mechanism":"Flow","supertype":"base"}),
|
||||||
|
json!({"kind":"Energy","mechanism":"Flow","supertype":"base"}),
|
||||||
|
);
|
||||||
|
compact(
|
||||||
|
Context::Element(Kind::ExchangeCategory),
|
||||||
|
json!({"family":"Work","exchanges":[]}),
|
||||||
|
json!({}),
|
||||||
|
);
|
||||||
|
compact(
|
||||||
|
Context::Element(Kind::ExchangeCategory),
|
||||||
|
json!({"family":"Participant","exchanges":["x"]}),
|
||||||
|
json!({"family":"Participant","exchanges":["x"]}),
|
||||||
|
);
|
||||||
|
compact(
|
||||||
|
Context::Element(Kind::Interface),
|
||||||
|
json!({"supertype":null}),
|
||||||
|
json!({}),
|
||||||
|
);
|
||||||
|
compact(
|
||||||
|
Context::Element(Kind::Lifeline),
|
||||||
|
json!({"represented":null}),
|
||||||
|
json!({}),
|
||||||
|
);
|
||||||
|
compact(
|
||||||
|
Context::Element(Kind::Constraint),
|
||||||
|
json!({"expression":"","constrained":[]}),
|
||||||
|
json!({}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scenario_source_default_never_uses_the_graphical_viewpoint_default() {
|
||||||
|
compact(
|
||||||
|
Context::Element(Kind::Scenario),
|
||||||
|
json!({"viewpoint":"MixedLegacy","context":null,"precondition":"","postcondition":"","steps":[]}),
|
||||||
|
json!({}),
|
||||||
|
);
|
||||||
|
for viewpoint in [
|
||||||
|
"OperationalActivity",
|
||||||
|
"Functional",
|
||||||
|
"ParticipantInteraction",
|
||||||
|
"BoundaryInterface",
|
||||||
|
] {
|
||||||
|
compact(
|
||||||
|
Context::Element(Kind::Scenario),
|
||||||
|
json!({"viewpoint":viewpoint,"context":"scope","precondition":"ready","postcondition":"","steps":["two","one"]}),
|
||||||
|
json!({"viewpoint":viewpoint,"context":"scope","precondition":"ready","steps":["two","one"]}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn each_state_header_supplies_its_own_kind() {
|
||||||
|
for (kind, name) in [
|
||||||
|
(Kind::State, "State"),
|
||||||
|
(Kind::Mode, "Mode"),
|
||||||
|
(Kind::Initial, "Initial"),
|
||||||
|
(Kind::Final, "Final"),
|
||||||
|
(Kind::Choice, "Choice"),
|
||||||
|
(Kind::Junction, "Junction"),
|
||||||
|
(Kind::Fork, "Fork"),
|
||||||
|
(Kind::Join, "Join"),
|
||||||
|
(Kind::ShallowHistory, "ShallowHistory"),
|
||||||
|
(Kind::DeepHistory, "DeepHistory"),
|
||||||
|
(Kind::EntryPoint, "EntryPoint"),
|
||||||
|
(Kind::ExitPoint, "ExitPoint"),
|
||||||
|
(Kind::Terminate, "Terminate"),
|
||||||
|
] {
|
||||||
|
compact(
|
||||||
|
Context::Element(kind),
|
||||||
|
json!({"kind":name,"entry":"","exit":"","do_activity":""}),
|
||||||
|
json!({}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Invalid mismatches must not be silently normalized by the writer policy.
|
||||||
|
compact(
|
||||||
|
Context::Element(Kind::Mode),
|
||||||
|
json!({"kind":"State","entry":"work"}),
|
||||||
|
json!({"kind":"State","entry":"work"}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn data_field_unbounded_and_present_empty_values_are_not_defaults() {
|
||||||
|
compact(
|
||||||
|
Context::Element(Kind::DataField),
|
||||||
|
json!({"type_ref":"String","minimum":1,"maximum":1,"unit":"","direction":"Unspecified","storage":"Value","default":null}),
|
||||||
|
json!({}),
|
||||||
|
);
|
||||||
|
compact(
|
||||||
|
Context::Element(Kind::DataField),
|
||||||
|
json!({"type_ref":{"Named":"record"},"minimum":0,"maximum":null,"unit":"m","direction":"Out","storage":"Reference","default":[]}),
|
||||||
|
json!({"type_ref":{"Named":"record"},"minimum":0,"maximum":null,"unit":"m","direction":"Out","storage":"Reference","default":[]}),
|
||||||
|
);
|
||||||
|
for value in [
|
||||||
|
json!({"Boolean":false}),
|
||||||
|
json!({"Integer":0}),
|
||||||
|
json!({"Real":0.0}),
|
||||||
|
json!({"String":""}),
|
||||||
|
] {
|
||||||
|
compact(
|
||||||
|
Context::Element(Kind::DataField),
|
||||||
|
json!({"default":[value.clone()]}),
|
||||||
|
json!({"default":[value]}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn data_definition_whole_default_and_selected_required_fields_differ() {
|
||||||
|
compact(
|
||||||
|
Context::Element(Kind::DataType),
|
||||||
|
json!({"definition":{"String":{"minimum_length":0,"maximum_length":null}},"default":null,"unit":""}),
|
||||||
|
json!({}),
|
||||||
|
);
|
||||||
|
for (input, expected) in [
|
||||||
|
(
|
||||||
|
json!({"Integer":{"minimum":null,"maximum":null}}),
|
||||||
|
json!({"Integer":{}}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
json!({"Real":{"minimum":null,"maximum":2.5}}),
|
||||||
|
json!({"Real":{"maximum":2.5}}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
json!({"String":{"minimum_length":4,"maximum_length":null}}),
|
||||||
|
json!({"String":{"minimum_length":4}}),
|
||||||
|
),
|
||||||
|
(json!({"Record":{"supertype":null}}), json!({"Record":{}})),
|
||||||
|
(
|
||||||
|
json!({"Enumeration":{"literals":[]}}),
|
||||||
|
json!({"Enumeration":{"literals":[]}}),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
compact(
|
||||||
|
Context::Element(Kind::DataType),
|
||||||
|
json!({"definition":input}),
|
||||||
|
json!({"definition":expected}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
compact(
|
||||||
|
Context::Element(Kind::DataType),
|
||||||
|
json!({"definition":"Boolean","default":{"Boolean":false}}),
|
||||||
|
json!({"definition":"Boolean","default":{"Boolean":false}}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn port_contract_and_its_required_payload_stay_explicit() {
|
||||||
|
compact(
|
||||||
|
Context::Element(Kind::Port),
|
||||||
|
json!({"contract":"Legacy","legacy":{"direction":"Out","interface":null}}),
|
||||||
|
json!({"contract":"Legacy","legacy":{"direction":"Out"}}),
|
||||||
|
);
|
||||||
|
for contract in [
|
||||||
|
json!({"FunctionInput":{"incoming_items":[]}}),
|
||||||
|
json!({"FunctionOutput":{"outgoing_items":[]}}),
|
||||||
|
json!({"Component":{"kind":"Standard","orientation":"Unset"}}),
|
||||||
|
] {
|
||||||
|
compact(
|
||||||
|
Context::Element(Kind::Port),
|
||||||
|
json!({"contract":contract.clone(),"legacy":null}),
|
||||||
|
json!({"contract":contract}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn occurrence_whole_default_is_distinct_from_selected_constructor_payload() {
|
||||||
|
compact(
|
||||||
|
Context::Element(Kind::Occurrence),
|
||||||
|
json!({"body":{"Exchange":{"exchange":null,"sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[]}},"precondition":"","postcondition":""}),
|
||||||
|
json!({}),
|
||||||
|
);
|
||||||
|
for (input, expected) in [
|
||||||
|
(
|
||||||
|
json!({"Action":{"action":null,"lifeline":null}}),
|
||||||
|
json!({"Action":{}}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
json!({"State":{"state":null,"lifeline":null}}),
|
||||||
|
json!({"State":{}}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
json!({"SequenceReference":{"sequence":null}}),
|
||||||
|
json!({"SequenceReference":{}}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
json!({"ScenarioUse":{"scenario":null,"bindings":[]}}),
|
||||||
|
json!({"ScenarioUse":{"bindings":[]}}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
json!({"Branch":{"guard":"","occurrences":[]}}),
|
||||||
|
json!({"Branch":{"guard":"","occurrences":[]}}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
json!({"Fragment":{"operator":"Parallel","branches":[]}}),
|
||||||
|
json!({"Fragment":{"operator":"Parallel","branches":[]}}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
json!({"Control":{"kind":"Choice"}}),
|
||||||
|
json!({"Control":{"kind":"Choice"}}),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
compact(
|
||||||
|
Context::Element(Kind::Occurrence),
|
||||||
|
json!({"body":input}),
|
||||||
|
json!({"body":expected}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unresolved_operation_and_required_message_value_are_preserved() {
|
||||||
|
compact(
|
||||||
|
Context::Element(Kind::Occurrence),
|
||||||
|
json!({"body":{"Exchange":{"exchange":null,"sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[{"field":"f","path":[],"expression":""}],"operation":{"allocation":null}}}}),
|
||||||
|
json!({"body":{"Exchange":{"values":[{"field":"f","expression":""}],"operation":{}}}}),
|
||||||
|
);
|
||||||
|
compact(
|
||||||
|
Context::Element(Kind::Occurrence),
|
||||||
|
json!({"body":{"Exchange":{"exchange":"flow","sender":"a","receiver":"b","message_kind":{"Timer":{"expression":""}},"reply_to":"request","values":[],"operation":null}}}),
|
||||||
|
json!({"body":{"Exchange":{"exchange":"flow","sender":"a","receiver":"b","message_kind":{"Timer":{"expression":""}},"reply_to":"request"}}}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn event_body_whole_default_does_not_prune_selected_required_fields() {
|
||||||
|
compact(
|
||||||
|
Context::Element(Kind::Event),
|
||||||
|
json!({"body":{"Signal":{"items":[]}}}),
|
||||||
|
json!({}),
|
||||||
|
);
|
||||||
|
for body in [
|
||||||
|
json!({"Signal":{"items":["i"]}}),
|
||||||
|
json!({"Time":{"kind":"After","expression":""}}),
|
||||||
|
json!({"Change":{"expression":""}}),
|
||||||
|
] {
|
||||||
|
compact(
|
||||||
|
Context::Element(Kind::Event),
|
||||||
|
json!({"body":body.clone()}),
|
||||||
|
json!({"body":body}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relation_required_endpoints_and_contextual_payloads_remain() {
|
||||||
|
for kind in RelationKind::ALL {
|
||||||
|
compact(
|
||||||
|
Context::Relation(kind),
|
||||||
|
json!({"description":"","source":"s","target":"t","future":[]}),
|
||||||
|
json!({"source":"s","target":"t","future":[]}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
compact(
|
||||||
|
Context::Relation(RelationKind::FunctionalExchange),
|
||||||
|
json!({"source":"s","target":"t","source_port":"sp","target_port":"tp","items":[]}),
|
||||||
|
json!({"source":"s","target":"t","source_port":"sp","target_port":"tp"}),
|
||||||
|
);
|
||||||
|
let contextual = json!({"owner":"o","source":{"references":[],"occurrence":"s"},"target":{"references":[],"occurrence":"t"},"purpose":{"Precedence":{"guard":""}}});
|
||||||
|
compact(
|
||||||
|
Context::Relation(RelationKind::ContextualLink),
|
||||||
|
contextual.clone(),
|
||||||
|
contextual,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relation_defaults_preserve_contextual_orientation_and_multiplicity() {
|
||||||
|
for (kind, orientation) in [
|
||||||
|
(RelationKind::BoundaryExchange, true),
|
||||||
|
(RelationKind::CommunicationMean, false),
|
||||||
|
] {
|
||||||
|
compact(
|
||||||
|
Context::Relation(kind),
|
||||||
|
json!({"oriented":orientation,"items":[]}),
|
||||||
|
json!({}),
|
||||||
|
);
|
||||||
|
compact(
|
||||||
|
Context::Relation(kind),
|
||||||
|
json!({"oriented":!orientation}),
|
||||||
|
json!({"oriented":!orientation}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
compact(
|
||||||
|
Context::Relation(RelationKind::DataAssociation),
|
||||||
|
json!({"source_role":"","target_role":"","source_minimum":0,"target_minimum":0,"source_maximum":null,"target_maximum":null,"source_navigable":false,"target_navigable":false}),
|
||||||
|
json!({}),
|
||||||
|
);
|
||||||
|
compact(
|
||||||
|
Context::Relation(RelationKind::DataAssociation),
|
||||||
|
json!({"source_maximum":0,"target_maximum":1,"source_navigable":true}),
|
||||||
|
json!({"source_maximum":0,"target_maximum":1,"source_navigable":true}),
|
||||||
|
);
|
||||||
|
compact(
|
||||||
|
Context::Relation(RelationKind::InterfaceItemAllocation),
|
||||||
|
json!({"order":0,"transmission":"Unspecified","acquisition":"Unspecified"}),
|
||||||
|
json!({}),
|
||||||
|
);
|
||||||
|
compact(
|
||||||
|
Context::Relation(RelationKind::CapabilityExtend),
|
||||||
|
json!({"condition":""}),
|
||||||
|
json!({}),
|
||||||
|
);
|
||||||
|
compact(
|
||||||
|
Context::Relation(RelationKind::SequenceLink),
|
||||||
|
json!({"guard":""}),
|
||||||
|
json!({}),
|
||||||
|
);
|
||||||
|
compact(
|
||||||
|
Context::Relation(RelationKind::StateAction),
|
||||||
|
json!({"phase":"Do"}),
|
||||||
|
json!({}),
|
||||||
|
);
|
||||||
|
compact(
|
||||||
|
Context::Relation(RelationKind::StateTransition),
|
||||||
|
json!({"transition_kind":"External","trigger":"","guard":"","effect":"","triggers":[],"effects":[]}),
|
||||||
|
json!({}),
|
||||||
|
);
|
||||||
|
compact(
|
||||||
|
Context::Relation(RelationKind::StateTransition),
|
||||||
|
json!({"transition_kind":"Internal","triggers":["t"],"effects":["e"]}),
|
||||||
|
json!({"transition_kind":"Internal","triggers":["t"],"effects":["e"]}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diagram_options_remove_only_unrestricted_defaults() {
|
||||||
|
let defaults = json!({"hidden_edges":[],"relation_kinds":"All","work_categories":"All","participant_categories":"All","coverage":"All","work_contexts":"All","exchange_labels":"Name"});
|
||||||
|
compact(
|
||||||
|
Context::Diagram,
|
||||||
|
json!({"description":"","layer":"Oa","projection":"Overview","routes":[],"view_options":defaults}),
|
||||||
|
json!({}),
|
||||||
|
);
|
||||||
|
compact(
|
||||||
|
Context::Diagram,
|
||||||
|
json!({"layer":"Sa","projection":{"StateMachine":{"machine":"m"}},"view_options":{"hidden_edges":[],"relation_kinds":{"Only":[]},"work_categories":{"Any":[]},"participant_categories":"All","coverage":"All","work_contexts":{"Any":[]},"exchange_labels":"Name"}}),
|
||||||
|
json!({"layer":"Sa","projection":{"StateMachine":{"machine":"m"}},"view_options":{"relation_kinds":{"Only":[]},"work_categories":{"Any":[]},"work_contexts":{"Any":[]}}}),
|
||||||
|
);
|
||||||
|
compact(
|
||||||
|
Context::Diagram,
|
||||||
|
json!({"routes":[{"edge":"e","waypoints":[],"label":null}]}),
|
||||||
|
json!({"routes":[{"edge":"e"}]}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn node_whole_defaults_preserve_negative_zero_and_required_coordinates() {
|
||||||
|
compact(
|
||||||
|
Context::Node,
|
||||||
|
json!({"diagram":"d","element":"e","position":{"x":0.0,"y":0.0},"size":{"width":224.0,"height":94.0},"collapsed":false}),
|
||||||
|
json!({"diagram":"d","element":"e"}),
|
||||||
|
);
|
||||||
|
for position in [
|
||||||
|
json!({"x":-0.0,"y":0.0}),
|
||||||
|
json!({"x":0.0,"y":-0.0}),
|
||||||
|
json!({"x":1.0,"y":0.0}),
|
||||||
|
] {
|
||||||
|
let result = rendered(
|
||||||
|
Context::Node,
|
||||||
|
&json!({"position":position.clone()}),
|
||||||
|
Mode::Compact,
|
||||||
|
);
|
||||||
|
assert_eq!(result, json!({"position":position.clone()}));
|
||||||
|
for coordinate in ["x", "y"] {
|
||||||
|
assert_eq!(
|
||||||
|
result["position"][coordinate].as_f64().unwrap().to_bits(),
|
||||||
|
position[coordinate].as_f64().unwrap().to_bits(),
|
||||||
|
"coordinate sign is part of fidelity"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compact(
|
||||||
|
Context::Node,
|
||||||
|
json!({"size":{"width":300.0,"height":94.0},"port_anchors":[{"port":"p","side":"Left","offset":0.0}]}),
|
||||||
|
json!({"size":{"width":300.0,"height":94.0},"port_anchors":[{"port":"p","side":"Left","offset":0.0}]}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn artifact_present_empty_strings_and_trace_history_stay_distinct() {
|
||||||
|
compact(
|
||||||
|
Context::Artifact,
|
||||||
|
json!({"owner":"o","description":"","relation":"References","uri":"","revision":null,"fragment":null}),
|
||||||
|
json!({"owner":"o"}),
|
||||||
|
);
|
||||||
|
compact(
|
||||||
|
Context::Artifact,
|
||||||
|
json!({"owner":"o","relation":"Implements","revision":"","fragment":""}),
|
||||||
|
json!({"owner":"o","relation":"Implements","revision":"","fragment":""}),
|
||||||
|
);
|
||||||
|
compact(
|
||||||
|
Context::Trace,
|
||||||
|
json!({"oa":"o","sa":"s","description":"","kind":"Refinement","baseline":null}),
|
||||||
|
json!({"oa":"o","sa":"s"}),
|
||||||
|
);
|
||||||
|
compact(
|
||||||
|
Context::Trace,
|
||||||
|
json!({"baseline":{"oa":{"name":"","description":""},"sa":{"name":"s","description":""}}}),
|
||||||
|
json!({"baseline":{"oa":{"name":"","description":""},"sa":{"name":"s","description":""}}}),
|
||||||
|
);
|
||||||
|
let baseline = json!({"oa":{"name":"o","description":""},"sa":{"name":"s","description":""},"semantic":{"facets":[{"Value":null},{"Value":[]},{"Value":false},{"Value":0},{"Value":""}]}});
|
||||||
|
compact(
|
||||||
|
Context::Trace,
|
||||||
|
json!({"kind":"Manual","baseline":baseline.clone()}),
|
||||||
|
json!({"kind":"Manual","baseline":baseline}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nested_future_fields_prevent_whole_default_removal() {
|
||||||
|
// These sentinels exercise preservation, not current parser admission of
|
||||||
|
// unknown fields. Only known slots may lose their redundant defaults.
|
||||||
|
compact(
|
||||||
|
Context::Element(Kind::DataType),
|
||||||
|
json!({"definition":{"String":{"minimum_length":0,"maximum_length":null,"future":[]}}}),
|
||||||
|
json!({"definition":{"String":{"minimum_length":0,"future":[]}}}),
|
||||||
|
);
|
||||||
|
compact(
|
||||||
|
Context::Element(Kind::Occurrence),
|
||||||
|
json!({"body":{"Exchange":{"exchange":null,"sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[],"operation":null,"future":false}}}),
|
||||||
|
json!({"body":{"Exchange":{"future":false}}}),
|
||||||
|
);
|
||||||
|
compact(
|
||||||
|
Context::Diagram,
|
||||||
|
json!({"view_options":{"hidden_edges":[],"relation_kinds":"All","work_categories":"All","participant_categories":"All","coverage":"All","work_contexts":"All","exchange_labels":"Name","future":null}}),
|
||||||
|
json!({"view_options":{"future":null}}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_and_multikey_constructor_wrappers_are_not_reinterpreted() {
|
||||||
|
for definition in [
|
||||||
|
json!({"Future":{"minimum":null,"maximum":null}}),
|
||||||
|
json!({"String":{"minimum_length":0,"maximum_length":null},"Future":{}}),
|
||||||
|
] {
|
||||||
|
compact(
|
||||||
|
Context::Element(Kind::DataType),
|
||||||
|
json!({"definition":definition.clone()}),
|
||||||
|
json!({"definition":definition}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for body in [
|
||||||
|
json!({"Future":{"operation":null,"values":[]}}),
|
||||||
|
json!({"Exchange":{"exchange":null,"values":[]},"Future":{}}),
|
||||||
|
] {
|
||||||
|
let fields = json!({"body":body});
|
||||||
|
assert_eq!(
|
||||||
|
rendered(
|
||||||
|
Context::Element(Kind::Occurrence),
|
||||||
|
&fields,
|
||||||
|
Mode::CompleteForEdit
|
||||||
|
),
|
||||||
|
fields,
|
||||||
|
"do not invent a selected Exchange constructor in an unknown wrapper",
|
||||||
|
);
|
||||||
|
compact(Context::Element(Kind::Occurrence), fields.clone(), fields);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -726,7 +726,8 @@ fn apply_edits(bundle: &mut SourceBundle, edits: Edits) -> Result<(), Vec<Source
|
|||||||
pub fn format_bundle(project: &Project) -> SourceBundle {
|
pub fn format_bundle(project: &Project) -> SourceBundle {
|
||||||
let ownership = default_ownership(project);
|
let ownership = default_ownership(project);
|
||||||
let aliases: BTreeMap<_, _> = project.identities().into_iter().collect();
|
let aliases: BTreeMap<_, _> = project.identities().into_iter().collect();
|
||||||
let mut declarations = language::format_declarations(project);
|
let mut declarations =
|
||||||
|
language::format_declarations(project, language::DeclarationFormat::Compact);
|
||||||
declarations.sort_by_key(|(id, _)| declaration_order(project, *id, &aliases));
|
declarations.sort_by_key(|(id, _)| declaration_order(project, *id, &aliases));
|
||||||
let mut files: BTreeMap<LogicalPath, String> = BTreeMap::new();
|
let mut files: BTreeMap<LogicalPath, String> = BTreeMap::new();
|
||||||
for (id, declaration) in declarations {
|
for (id, declaration) in declarations {
|
||||||
@@ -735,11 +736,13 @@ pub fn format_bundle(project: &Project) -> SourceBundle {
|
|||||||
.or_default()
|
.or_default()
|
||||||
.push_str(&unindent(&declaration));
|
.push_str(&unindent(&declaration));
|
||||||
}
|
}
|
||||||
let mut entry = format!(
|
let mut entry = format!("arcadia {SCHEMA_VERSION};\n{}{{\n", project_header(project));
|
||||||
"arcadia {SCHEMA_VERSION};\n{}{{\n description {};\n",
|
if !project.description.is_empty() {
|
||||||
project_header(project),
|
entry.push_str(&format!(
|
||||||
quote(&project.description)
|
" description {};\n",
|
||||||
);
|
quote(&project.description)
|
||||||
|
));
|
||||||
|
}
|
||||||
for path in files.keys() {
|
for path in files.keys() {
|
||||||
entry.push_str(&format!(" include {};\n", quote(path)));
|
entry.push_str(&format!(" include {};\n", quote(path)));
|
||||||
}
|
}
|
||||||
@@ -850,8 +853,25 @@ pub fn update_bundle_with_identity_map(
|
|||||||
)]);
|
)]);
|
||||||
}
|
}
|
||||||
let origins: BTreeMap<_, _> = identities.iter().map(|(old, new)| (*new, *old)).collect();
|
let origins: BTreeMap<_, _> = identities.iter().map(|(old, new)| (*new, *old)).collect();
|
||||||
let before: BTreeMap<_, _> = language::format_declarations(old).into_iter().collect();
|
// Comparison must include defaults even when persistence serialization or
|
||||||
let after: BTreeMap<_, _> = language::format_declarations(project).into_iter().collect();
|
// compact output omits them. Otherwise an unrelated payload edit erases
|
||||||
|
// unchanged explicit fields from the authored document.
|
||||||
|
let before: BTreeMap<_, _> =
|
||||||
|
language::format_declarations(old, language::DeclarationFormat::CompleteForEdit)
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
let after: BTreeMap<_, _> =
|
||||||
|
language::format_declarations(project, language::DeclarationFormat::CompleteForEdit)
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
let compact: BTreeMap<_, _> =
|
||||||
|
language::format_declarations(project, language::DeclarationFormat::Compact)
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
let compact_values: BTreeMap<_, _> =
|
||||||
|
language::format_declarations(project, language::DeclarationFormat::CompactValues)
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
let old_aliases: BTreeMap<_, _> = old
|
let old_aliases: BTreeMap<_, _> = old
|
||||||
.identities()
|
.identities()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -907,6 +927,27 @@ pub fn update_bundle_with_identity_map(
|
|||||||
};
|
};
|
||||||
let mut previous = parse(old_text)?;
|
let mut previous = parse(old_text)?;
|
||||||
let mut next = parse(new_text)?;
|
let mut next = parse(new_text)?;
|
||||||
|
let next_id = identities[&id];
|
||||||
|
let value_text = &compact_values[&next_id];
|
||||||
|
let insertion = InsertionSyntax::new(
|
||||||
|
&next.fields,
|
||||||
|
&parse(value_text)?.fields,
|
||||||
|
value_text,
|
||||||
|
&parse(&compact[&next_id])?.fields,
|
||||||
|
);
|
||||||
|
let old_kind = previous.header["kind"];
|
||||||
|
let new_kind = next.header["kind"];
|
||||||
|
if old_text[old_kind.start..old_kind.end] != new_text[new_kind.start..new_kind.end]
|
||||||
|
&& let language::EditKind::Record(actual_fields) = &actual.fields.kind
|
||||||
|
&& let language::EditKind::Record(previous_fields) = &mut previous.fields.kind
|
||||||
|
{
|
||||||
|
// An implicit value is supplied by its declaration context.
|
||||||
|
// After a kind change, an unchanged value may need explicit
|
||||||
|
// syntax (e.g. BoundaryExchange true -> CommunicationMean true).
|
||||||
|
// Reconsider only absent fields; authored explicit values still
|
||||||
|
// compare against their complete old values and keep trivia.
|
||||||
|
previous_fields.retain(|key, _| actual_fields.contains_key(key));
|
||||||
|
}
|
||||||
if actual.lexical_owner.is_some()
|
if actual.lexical_owner.is_some()
|
||||||
&& let language::EditKind::Record(fields) = &actual.fields.kind
|
&& let language::EditKind::Record(fields) = &actual.fields.kind
|
||||||
&& !fields.contains_key("parent")
|
&& !fields.contains_key("parent")
|
||||||
@@ -923,6 +964,7 @@ pub fn update_bundle_with_identity_map(
|
|||||||
source,
|
source,
|
||||||
before: old_text,
|
before: old_text,
|
||||||
after: new_text,
|
after: new_text,
|
||||||
|
insertion: Some(&insertion),
|
||||||
old_aliases: &old_aliases,
|
old_aliases: &old_aliases,
|
||||||
new_aliases: &new_aliases,
|
new_aliases: &new_aliases,
|
||||||
legacy_value_paths: false,
|
legacy_value_paths: false,
|
||||||
@@ -953,6 +995,7 @@ pub fn update_bundle_with_identity_map(
|
|||||||
source,
|
source,
|
||||||
before: &before,
|
before: &before,
|
||||||
after: &after,
|
after: &after,
|
||||||
|
insertion: None,
|
||||||
old_aliases: &old_aliases,
|
old_aliases: &old_aliases,
|
||||||
new_aliases: &new_aliases,
|
new_aliases: &new_aliases,
|
||||||
legacy_value_paths: false,
|
legacy_value_paths: false,
|
||||||
@@ -1011,7 +1054,7 @@ pub fn update_bundle_with_identity_map(
|
|||||||
project,
|
project,
|
||||||
identities,
|
identities,
|
||||||
&original_order,
|
&original_order,
|
||||||
&after,
|
&compact,
|
||||||
limits,
|
limits,
|
||||||
)?;
|
)?;
|
||||||
let result = compile_bundle(&bundle, limits)?;
|
let result = compile_bundle(&bundle, limits)?;
|
||||||
@@ -1119,10 +1162,73 @@ fn inserted_value(text: &str, indent: &str, source: &str) -> String {
|
|||||||
output
|
output
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Value fragments retain required payload fields even when the enclosing
|
||||||
|
// declaration field is itself omittable. For example, a selected String type
|
||||||
|
// needs minimum_length; an Event Signal needs items. The complete tree remains
|
||||||
|
// the comparison authority; this parallel tree is only an insertion template.
|
||||||
|
struct InsertionSyntax<'a> {
|
||||||
|
source: &'a str,
|
||||||
|
values: BTreeMap<usize, Span>,
|
||||||
|
omitted_fields: BTreeSet<usize>,
|
||||||
|
}
|
||||||
|
impl<'a> InsertionSyntax<'a> {
|
||||||
|
fn new(
|
||||||
|
complete: &language::EditValue,
|
||||||
|
compact_values: &language::EditValue,
|
||||||
|
source: &'a str,
|
||||||
|
compact: &language::EditValue,
|
||||||
|
) -> Self {
|
||||||
|
let mut result = Self {
|
||||||
|
source,
|
||||||
|
values: BTreeMap::new(),
|
||||||
|
omitted_fields: BTreeSet::new(),
|
||||||
|
};
|
||||||
|
result.collect(complete, compact_values);
|
||||||
|
if let (language::EditKind::Record(all), language::EditKind::Record(kept)) =
|
||||||
|
(&complete.kind, &compact.kind)
|
||||||
|
{
|
||||||
|
for (key, field) in all {
|
||||||
|
if !kept.contains_key(key) {
|
||||||
|
result.omitted_fields.insert(field.value.span.start);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
fn collect(&mut self, complete: &language::EditValue, compact: &language::EditValue) {
|
||||||
|
// Borrow the existing compact text once. Copying each containing record
|
||||||
|
// here would duplicate a large nested payload at every depth.
|
||||||
|
self.values.insert(complete.span.start, compact.span);
|
||||||
|
match (&complete.kind, &compact.kind) {
|
||||||
|
(language::EditKind::Record(all), language::EditKind::Record(kept)) => {
|
||||||
|
for (key, field) in all {
|
||||||
|
if let Some(next) = kept.get(key) {
|
||||||
|
self.collect(&field.value, &next.value);
|
||||||
|
} else {
|
||||||
|
self.omitted_fields.insert(field.value.span.start);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(language::EditKind::List(all), language::EditKind::List(kept)) => {
|
||||||
|
assert_eq!(
|
||||||
|
all.len(),
|
||||||
|
kept.len(),
|
||||||
|
"compaction preserves array membership"
|
||||||
|
);
|
||||||
|
for (field, next) in all.iter().zip(kept) {
|
||||||
|
self.collect(field, next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct SyntaxUpdate<'a> {
|
struct SyntaxUpdate<'a> {
|
||||||
source: &'a str,
|
source: &'a str,
|
||||||
before: &'a str,
|
before: &'a str,
|
||||||
after: &'a str,
|
after: &'a str,
|
||||||
|
insertion: Option<&'a InsertionSyntax<'a>>,
|
||||||
old_aliases: &'a BTreeMap<String, String>,
|
old_aliases: &'a BTreeMap<String, String>,
|
||||||
new_aliases: &'a BTreeMap<String, String>,
|
new_aliases: &'a BTreeMap<String, String>,
|
||||||
// Source3 migration only adds path qualification; the existing field identity
|
// Source3 migration only adds path qualification; the existing field identity
|
||||||
@@ -1131,6 +1237,16 @@ struct SyntaxUpdate<'a> {
|
|||||||
identity_origins: Option<&'a BTreeMap<Id, Id>>,
|
identity_origins: Option<&'a BTreeMap<Id, Id>>,
|
||||||
}
|
}
|
||||||
impl SyntaxUpdate<'_> {
|
impl SyntaxUpdate<'_> {
|
||||||
|
fn inserted(&self, value: &language::EditValue) -> &str {
|
||||||
|
self.insertion
|
||||||
|
.and_then(|syntax| {
|
||||||
|
syntax
|
||||||
|
.values
|
||||||
|
.get(&value.span.start)
|
||||||
|
.map(|span| &syntax.source[span.start..span.end])
|
||||||
|
})
|
||||||
|
.unwrap_or(&self.after[value.span.start..value.span.end])
|
||||||
|
}
|
||||||
fn header(
|
fn header(
|
||||||
&self,
|
&self,
|
||||||
actual: &BTreeMap<&str, Span>,
|
actual: &BTreeMap<&str, Span>,
|
||||||
@@ -1243,7 +1359,7 @@ impl SyntaxUpdate<'_> {
|
|||||||
_ => {
|
_ => {
|
||||||
erase_syntax(self.source, actual.span, edits);
|
erase_syntax(self.source, actual.span, edits);
|
||||||
let text = inserted_value(
|
let text = inserted_value(
|
||||||
&self.after[after.span.start..after.span.end],
|
self.inserted(after),
|
||||||
indent_at(self.source, actual.span.start),
|
indent_at(self.source, actual.span.start),
|
||||||
self.source,
|
self.source,
|
||||||
);
|
);
|
||||||
@@ -1308,6 +1424,9 @@ impl SyntaxUpdate<'_> {
|
|||||||
.iter()
|
.iter()
|
||||||
.filter(|(key, field)| {
|
.filter(|(key, field)| {
|
||||||
!actual.contains_key(*key)
|
!actual.contains_key(*key)
|
||||||
|
&& self.insertion.is_none_or(|syntax| {
|
||||||
|
!syntax.omitted_fields.contains(&field.value.span.start)
|
||||||
|
})
|
||||||
&& before
|
&& before
|
||||||
.and_then(|fields| fields.get(*key))
|
.and_then(|fields| fields.get(*key))
|
||||||
.is_none_or(|old| !self.same(&old.value, &field.value))
|
.is_none_or(|old| !self.same(&old.value, &field.value))
|
||||||
@@ -1326,11 +1445,7 @@ impl SyntaxUpdate<'_> {
|
|||||||
.unwrap_or_else(|| format!("{} ", indent_at(self.source, span.start)));
|
.unwrap_or_else(|| format!("{} ", indent_at(self.source, span.start)));
|
||||||
let mut text = String::new();
|
let mut text = String::new();
|
||||||
for (key, field) in added {
|
for (key, field) in added {
|
||||||
let value = inserted_value(
|
let value = inserted_value(self.inserted(&field.value), &indent, self.source);
|
||||||
&self.after[field.value.span.start..field.value.span.end],
|
|
||||||
&indent,
|
|
||||||
self.source,
|
|
||||||
);
|
|
||||||
if multiline {
|
if multiline {
|
||||||
text.push_str(&indent);
|
text.push_str(&indent);
|
||||||
text.push_str(&format!("{key} {value};{}", newline(self.source)));
|
text.push_str(&format!("{key} {value};{}", newline(self.source)));
|
||||||
@@ -1450,11 +1565,7 @@ impl SyntaxUpdate<'_> {
|
|||||||
} else {
|
} else {
|
||||||
text.push(' ');
|
text.push(' ');
|
||||||
}
|
}
|
||||||
text.push_str(&inserted_value(
|
text.push_str(&inserted_value(self.inserted(next), &indent, self.source));
|
||||||
&self.after[next.span.start..next.span.end],
|
|
||||||
&indent,
|
|
||||||
self.source,
|
|
||||||
));
|
|
||||||
suffix = "";
|
suffix = "";
|
||||||
}
|
}
|
||||||
if i + 1 < after.len() {
|
if i + 1 < after.len() {
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ pub(super) fn upgrade(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.expect("an explicit source migration report");
|
.expect("an explicit source migration report");
|
||||||
let project = ¤t.decoded.project;
|
let project = ¤t.decoded.project;
|
||||||
let canonical: BTreeMap<_, _> = language::format_declarations(project).into_iter().collect();
|
let canonical: BTreeMap<_, _> =
|
||||||
|
language::format_declarations(project, language::DeclarationFormat::CompleteForEdit)
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
let changed: BTreeSet<_> = report
|
let changed: BTreeSet<_> = report
|
||||||
.semantic_defaults
|
.semantic_defaults
|
||||||
.iter()
|
.iter()
|
||||||
@@ -59,6 +62,7 @@ pub(super) fn upgrade(
|
|||||||
source,
|
source,
|
||||||
before: source,
|
before: source,
|
||||||
after,
|
after,
|
||||||
|
insertion: None,
|
||||||
old_aliases: &aliases,
|
old_aliases: &aliases,
|
||||||
new_aliases: &aliases,
|
new_aliases: &aliases,
|
||||||
legacy_value_paths: true,
|
legacy_value_paths: true,
|
||||||
|
|||||||
@@ -0,0 +1,344 @@
|
|||||||
|
//! Existing source-7 nested omission compatibility, independently of formatting.
|
||||||
|
//!
|
||||||
|
//! Selected constructors use their own field contracts. These handwritten
|
||||||
|
//! sources and literal models distinguish nullable optional slots from required
|
||||||
|
//! fields; they do not prescribe compact canonical output or broaden admission.
|
||||||
|
|
||||||
|
use oa_core::{source_project::*, *};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
fn id(number: u8) -> Id {
|
||||||
|
Id::parse_str(&format!("00000000-0000-4000-8000-{number:012}")).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bundle(declarations: &str) -> SourceBundle {
|
||||||
|
let source = format!(
|
||||||
|
"arcadia 7;\nproject p \"Admission\" id \"00000000-0000-4000-8000-000000000001\" {{\n{declarations}\n}}\n"
|
||||||
|
);
|
||||||
|
SourceBundle {
|
||||||
|
entry: "project.arcadia".into(),
|
||||||
|
files: BTreeMap::from([("project.arcadia".into(), source)]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn element(number: u8, alias: &str, name: &str, parent: Option<Id>, body: ElementBody) -> Element {
|
||||||
|
Element {
|
||||||
|
id: id(number),
|
||||||
|
alias: alias.into(),
|
||||||
|
name: name.into(),
|
||||||
|
description: String::new(),
|
||||||
|
parent,
|
||||||
|
body,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_admitted(input: &SourceBundle, elements: Vec<Element>) -> Project {
|
||||||
|
let expected = Project {
|
||||||
|
schema_version: 7,
|
||||||
|
id: id(1),
|
||||||
|
alias: "p".into(),
|
||||||
|
name: "Admission".into(),
|
||||||
|
description: String::new(),
|
||||||
|
elements: elements.into_iter().map(|e| (e.id, e)).collect(),
|
||||||
|
relations: BTreeMap::new(),
|
||||||
|
diagrams: BTreeMap::new(),
|
||||||
|
artifact_links: BTreeMap::new(),
|
||||||
|
traces: BTreeMap::new(),
|
||||||
|
};
|
||||||
|
let compiled = compile_bundle(input, ProjectLimits::default()).unwrap();
|
||||||
|
assert_eq!(compiled.decoded.project, expected, "complete literal model");
|
||||||
|
assert_eq!(
|
||||||
|
compiled.bundle, *input,
|
||||||
|
"fixed IDs require no source stabilization"
|
||||||
|
);
|
||||||
|
compiled.decoded.project
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prefix(layer: Layer) -> &'static str {
|
||||||
|
match layer {
|
||||||
|
Layer::Oa => "oa",
|
||||||
|
Layer::Sa => "sa",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn data_bundle(layer: Layer, definition: &str) -> SourceBundle {
|
||||||
|
bundle(&format!(
|
||||||
|
" {}_data_type subject \"Subject\" id \"00000000-0000-4000-8000-000000000002\" {{ definition {{ {definition}; }}; }}",
|
||||||
|
prefix(layer)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn admit_data(layer: Layer, source: &str, definition: DataTypeDefinition) -> Project {
|
||||||
|
assert_admitted(
|
||||||
|
&data_bundle(layer, source),
|
||||||
|
vec![element(
|
||||||
|
2,
|
||||||
|
"subject",
|
||||||
|
"Subject",
|
||||||
|
None,
|
||||||
|
ElementBody::shared_in(
|
||||||
|
layer,
|
||||||
|
SharedBody::DataType {
|
||||||
|
definition,
|
||||||
|
default: None,
|
||||||
|
unit: String::new(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_missing(input: &SourceBundle, field: &str) {
|
||||||
|
let errors = compile_bundle(input, ProjectLimits::default()).unwrap_err();
|
||||||
|
assert_eq!(
|
||||||
|
errors.len(),
|
||||||
|
1,
|
||||||
|
"expect the targeted admission error: {errors:?}"
|
||||||
|
);
|
||||||
|
let diagnostic = &errors[0].diagnostic;
|
||||||
|
assert_eq!(diagnostic.code, "invalid_source", "{errors:?}");
|
||||||
|
assert!(
|
||||||
|
diagnostic.message.to_lowercase().contains("missing") && diagnostic.message.contains(field),
|
||||||
|
"must reject the missing constructor field {field}, not an unrelated model rule: {errors:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn integer_optional_bounds_can_be_omitted_individually_and_together() {
|
||||||
|
for layer in [Layer::Oa, Layer::Sa] {
|
||||||
|
for (omitted, explicit, minimum, maximum) in [
|
||||||
|
(
|
||||||
|
"Integer {}",
|
||||||
|
"Integer { minimum none; maximum none; }",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Integer { maximum 9; }",
|
||||||
|
"Integer { minimum none; maximum 9; }",
|
||||||
|
None,
|
||||||
|
Some(9),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Integer { minimum -3; }",
|
||||||
|
"Integer { minimum -3; maximum none; }",
|
||||||
|
Some(-3),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let expected = DataTypeDefinition::Integer { minimum, maximum };
|
||||||
|
assert_eq!(
|
||||||
|
admit_data(layer, omitted, expected.clone()),
|
||||||
|
admit_data(layer, explicit, expected),
|
||||||
|
"omitted and explicit optional Integer bounds agree"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn real_optional_bounds_can_be_omitted_individually_and_together() {
|
||||||
|
for layer in [Layer::Oa, Layer::Sa] {
|
||||||
|
for (omitted, explicit, minimum, maximum) in [
|
||||||
|
(
|
||||||
|
"Real {}",
|
||||||
|
"Real { minimum none; maximum none; }",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Real { maximum 2.5; }",
|
||||||
|
"Real { minimum none; maximum 2.5; }",
|
||||||
|
None,
|
||||||
|
Some(2.5),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Real { minimum -1.25; }",
|
||||||
|
"Real { minimum -1.25; maximum none; }",
|
||||||
|
Some(-1.25),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let expected = DataTypeDefinition::Real { minimum, maximum };
|
||||||
|
assert_eq!(
|
||||||
|
admit_data(layer, omitted, expected.clone()),
|
||||||
|
admit_data(layer, explicit, expected),
|
||||||
|
"omitted and explicit optional Real bounds agree"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn string_optional_maximum_can_be_omitted_without_resetting_required_minimum() {
|
||||||
|
for layer in [Layer::Oa, Layer::Sa] {
|
||||||
|
let expected = DataTypeDefinition::String {
|
||||||
|
minimum_length: 4,
|
||||||
|
maximum_length: None,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
admit_data(layer, "String { minimum_length 4; }", expected.clone()),
|
||||||
|
admit_data(
|
||||||
|
layer,
|
||||||
|
"String { minimum_length 4; maximum_length none; }",
|
||||||
|
expected,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicitly_selected_string_requires_minimum_even_when_whole_definition_has_a_default() {
|
||||||
|
for layer in [Layer::Oa, Layer::Sa] {
|
||||||
|
assert_missing(&data_bundle(layer, "String {}"), "minimum_length");
|
||||||
|
assert_missing(
|
||||||
|
&data_bundle(layer, "String { maximum_length none; }"),
|
||||||
|
"minimum_length",
|
||||||
|
);
|
||||||
|
admit_data(
|
||||||
|
layer,
|
||||||
|
"String { minimum_length 0; }",
|
||||||
|
DataTypeDefinition::String {
|
||||||
|
minimum_length: 0,
|
||||||
|
maximum_length: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn absent_numeric_maximum_is_distinct_from_an_explicit_zero_bound() {
|
||||||
|
for layer in [Layer::Oa, Layer::Sa] {
|
||||||
|
let unbounded = admit_data(
|
||||||
|
layer,
|
||||||
|
"Integer { minimum -3; }",
|
||||||
|
DataTypeDefinition::Integer {
|
||||||
|
minimum: Some(-3),
|
||||||
|
maximum: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let bounded = admit_data(
|
||||||
|
layer,
|
||||||
|
"Integer { minimum -3; maximum 0; }",
|
||||||
|
DataTypeDefinition::Integer {
|
||||||
|
minimum: Some(-3),
|
||||||
|
maximum: Some(0),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_ne!(unbounded, bounded, "zero is a bound, not omitted syntax");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn record_has_no_required_payload_field_and_omitted_supertype_means_none() {
|
||||||
|
for layer in [Layer::Oa, Layer::Sa] {
|
||||||
|
let expected = DataTypeDefinition::Record { supertype: None };
|
||||||
|
assert_eq!(
|
||||||
|
admit_data(layer, "Record {}", expected.clone()),
|
||||||
|
admit_data(layer, "Record { supertype none; }", expected),
|
||||||
|
);
|
||||||
|
let errors = compile_bundle(
|
||||||
|
&data_bundle(layer, "Record { fields []; }"),
|
||||||
|
ProjectLimits::default(),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(errors.len(), 1, "{errors:?}");
|
||||||
|
assert_eq!(errors[0].diagnostic.code, "invalid_source", "{errors:?}");
|
||||||
|
assert!(
|
||||||
|
errors[0]
|
||||||
|
.diagnostic
|
||||||
|
.message
|
||||||
|
.contains("unknown field `fields`"),
|
||||||
|
"Record remains a closed constructor: {errors:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enumeration_still_requires_its_list_even_when_empty_list_is_admitted() {
|
||||||
|
for layer in [Layer::Oa, Layer::Sa] {
|
||||||
|
assert_missing(&data_bundle(layer, "Enumeration {}"), "literals");
|
||||||
|
admit_data(
|
||||||
|
layer,
|
||||||
|
"Enumeration { literals []; }",
|
||||||
|
DataTypeDefinition::Enumeration { literals: vec![] },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn port_bundle(layer: Layer, fields: &str) -> SourceBundle {
|
||||||
|
let owner_kind = match layer {
|
||||||
|
Layer::Oa => "oa_entity",
|
||||||
|
Layer::Sa => "sa_system",
|
||||||
|
};
|
||||||
|
bundle(&format!(
|
||||||
|
" {owner_kind} owner \"Owner\" id \"00000000-0000-4000-8000-000000000002\" {{}}\n {}_port subject \"Subject\" id \"00000000-0000-4000-8000-000000000003\" {{ parent @owner; {fields} }}",
|
||||||
|
prefix(layer)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn admit_port(layer: Layer, fields: &str) -> Project {
|
||||||
|
let owner_body = match layer {
|
||||||
|
Layer::Oa => ElementBody::Oa(OaBody::Entity),
|
||||||
|
Layer::Sa => ElementBody::Sa(SaBody::System),
|
||||||
|
};
|
||||||
|
assert_admitted(
|
||||||
|
&port_bundle(layer, fields),
|
||||||
|
vec![
|
||||||
|
element(2, "owner", "Owner", None, owner_body),
|
||||||
|
element(
|
||||||
|
3,
|
||||||
|
"subject",
|
||||||
|
"Subject",
|
||||||
|
Some(id(2)),
|
||||||
|
ElementBody::shared_in(
|
||||||
|
layer,
|
||||||
|
SharedBody::Port {
|
||||||
|
contract: PortContract::Legacy,
|
||||||
|
legacy: Some(LegacyPortContract {
|
||||||
|
direction: PortDirection::Out,
|
||||||
|
interface: None,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_port_interface_can_be_omitted_but_preserves_direction() {
|
||||||
|
for layer in [Layer::Oa, Layer::Sa] {
|
||||||
|
assert_eq!(
|
||||||
|
admit_port(layer, "contract Legacy; legacy { direction Out; };"),
|
||||||
|
admit_port(
|
||||||
|
layer,
|
||||||
|
"contract Legacy; legacy { direction Out; interface none; };"
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_port_direction_remains_required_inside_selected_record() {
|
||||||
|
for layer in [Layer::Oa, Layer::Sa] {
|
||||||
|
assert_missing(
|
||||||
|
&port_bundle(layer, "contract Legacy; legacy {};"),
|
||||||
|
"direction",
|
||||||
|
);
|
||||||
|
assert_missing(
|
||||||
|
&port_bundle(layer, "contract Legacy; legacy { interface none; };"),
|
||||||
|
"direction",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn port_declaration_still_requires_explicit_contract() {
|
||||||
|
for layer in [Layer::Oa, Layer::Sa] {
|
||||||
|
assert_missing(
|
||||||
|
&port_bundle(layer, "legacy { direction Out; };"),
|
||||||
|
"contract",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
//! Public flat/modular formatter fidelity over frozen input and bounded mutations.
|
||||||
|
//! Legacy fixture migration uses the public parser. The original adapter tests
|
||||||
|
//! and exact inputs remain archived at source commit 2a64344; this suite exercises
|
||||||
|
//! production formatters, not that frozen experiment or lossless model edits.
|
||||||
|
|
||||||
|
use oa_core::{language, source_project::*, *};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
const COASTAL: &str = include_str!("../../../examples/coastal-rescue.arcadia");
|
||||||
|
const KEYED: &str =
|
||||||
|
include_str!("../../../harness/fixtures/release1-native/semantic-keyed.arcadia");
|
||||||
|
const STATES: &str =
|
||||||
|
include_str!("../../../harness/fixtures/release1-native/state-actions.arcadia");
|
||||||
|
const DENSE: &str =
|
||||||
|
include_str!("../../../harness/fixtures/release1-native/dense-40/dense-40.arcadia");
|
||||||
|
const OPERATIONS: &str = include_str!(
|
||||||
|
"../../../harness/fixtures/interface-operation-probe/output/boundary-only.arcadia"
|
||||||
|
);
|
||||||
|
|
||||||
|
fn read(source: &str) -> Project {
|
||||||
|
language::parse(source).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn identity(project: &Project, alias: &str) -> Id {
|
||||||
|
project
|
||||||
|
.identities()
|
||||||
|
.into_iter()
|
||||||
|
.find(|(_, name)| *name == alias)
|
||||||
|
.unwrap()
|
||||||
|
.0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_common_omissions(project: &Project, compiled: &CompiledSourceProject) {
|
||||||
|
if project.description.is_empty() {
|
||||||
|
assert!(compiled.index.project_description.is_none());
|
||||||
|
}
|
||||||
|
let descriptions = project
|
||||||
|
.elements
|
||||||
|
.values()
|
||||||
|
.map(|e| (e.id, &e.description))
|
||||||
|
.chain(project.relations.values().map(|r| (r.id, &r.description)))
|
||||||
|
.chain(project.diagrams.values().map(|d| (d.id, &d.description)))
|
||||||
|
.chain(
|
||||||
|
project
|
||||||
|
.artifact_links
|
||||||
|
.values()
|
||||||
|
.map(|a| (a.id, &a.description)),
|
||||||
|
)
|
||||||
|
.chain(project.traces.values().map(|t| (t.id, &t.description)));
|
||||||
|
for (id, description) in descriptions {
|
||||||
|
if description.is_empty() {
|
||||||
|
assert!(
|
||||||
|
!compiled
|
||||||
|
.index
|
||||||
|
.fields
|
||||||
|
.contains_key(&(id, "description".into()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for element in project.elements.values().filter(|e| e.parent.is_none()) {
|
||||||
|
assert!(
|
||||||
|
!compiled
|
||||||
|
.index
|
||||||
|
.fields
|
||||||
|
.contains_key(&(element.id, "parent".into()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_fidelity(label: &str, project: &Project) -> (Project, String) {
|
||||||
|
let flat = language::format(project);
|
||||||
|
let compact_model = read(&flat);
|
||||||
|
assert_eq!(
|
||||||
|
compact_model, *project,
|
||||||
|
"{label}: every persistent field survives"
|
||||||
|
);
|
||||||
|
|
||||||
|
let input = SourceBundle {
|
||||||
|
entry: "project.arcadia".into(),
|
||||||
|
files: BTreeMap::from([("project.arcadia".into(), flat.clone())]),
|
||||||
|
};
|
||||||
|
let compiled = compile_bundle(&input, ProjectLimits::default()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
compiled.decoded.project, *project,
|
||||||
|
"{label}: source-project compile"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
compiled.bundle, input,
|
||||||
|
"{label}: compact identities/source are stable"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_common_omissions(project, &compiled);
|
||||||
|
assert_eq!(
|
||||||
|
language::format(&compact_model),
|
||||||
|
flat,
|
||||||
|
"{label}: flat idempotence"
|
||||||
|
);
|
||||||
|
|
||||||
|
let modular = format_bundle(project);
|
||||||
|
assert!(
|
||||||
|
modular.files.len() > 1,
|
||||||
|
"{label}: exercise generated includes"
|
||||||
|
);
|
||||||
|
let modular_compiled = compile_bundle(&modular, ProjectLimits::default()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
modular_compiled.decoded.project, *project,
|
||||||
|
"{label}: complete modular model"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
modular_compiled.bundle, modular,
|
||||||
|
"{label}: modular source/IDs are stable"
|
||||||
|
);
|
||||||
|
assert_common_omissions(project, &modular_compiled);
|
||||||
|
assert_eq!(
|
||||||
|
format_bundle(&compact_model),
|
||||||
|
modular,
|
||||||
|
"{label}: flat-to-modular determinism"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
format_bundle(&modular_compiled.decoded.project),
|
||||||
|
modular,
|
||||||
|
"{label}: modular idempotence"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pinned measured compact bytes from the published fidelity-1 stderr.log at
|
||||||
|
// 2a64344, not a comparison against today's (now compact) canonical writer.
|
||||||
|
// A small header-layout allowance avoids enforcing incidental blank lines.
|
||||||
|
let measured_compact_bytes = match label {
|
||||||
|
"coastal-source3" => 28_465,
|
||||||
|
"keyed-source5-history" => 12_066,
|
||||||
|
"state-actions-source5" => 6_673,
|
||||||
|
"dense40-source6" => 19_342,
|
||||||
|
"boundary-operations-source6" => 3_660,
|
||||||
|
"state-negative-zero" => 6_672,
|
||||||
|
"keyed-present-empty-default" => 12_108,
|
||||||
|
"coastal-present-empty-artifact-strings" => 28_483,
|
||||||
|
"boundary-selected-and-unresolved-operations" => 3_806,
|
||||||
|
_ => panic!("missing independent compact measurement for {label}"),
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
flat.len() <= measured_compact_bytes + 16,
|
||||||
|
"{label}: {} bytes exceeds compact evidence budget {}",
|
||||||
|
flat.len(),
|
||||||
|
measured_compact_bytes + 16
|
||||||
|
);
|
||||||
|
(compact_model, flat)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migrated_coastal_oa_sa_views_ports_artifacts_and_traces_roundtrip() {
|
||||||
|
assert!(COASTAL.starts_with("arcadia 3;"));
|
||||||
|
let project = read(COASTAL);
|
||||||
|
assert!(project.elements.values().any(|e| e.layer() == Layer::Oa));
|
||||||
|
assert!(project.elements.values().any(|e| e.layer() == Layer::Sa));
|
||||||
|
assert!(
|
||||||
|
project
|
||||||
|
.elements
|
||||||
|
.values()
|
||||||
|
.any(|e| matches!(e.shared(), Some(SharedBody::Port { .. })))
|
||||||
|
);
|
||||||
|
assert!(project.elements.values().any(
|
||||||
|
|e| matches!(e.shared(), Some(SharedBody::Scenario { steps, .. }) if !steps.is_empty())
|
||||||
|
));
|
||||||
|
assert!(project.diagrams.values().any(|d| d.layer == Layer::Oa
|
||||||
|
&& d.projection == ProjectionKind::FunctionalDataflow
|
||||||
|
&& !d.nodes.is_empty()));
|
||||||
|
assert!(project.diagrams.values().any(|d| d.layer == Layer::Sa
|
||||||
|
&& d.projection == ProjectionKind::Allocation
|
||||||
|
&& !d.nodes.is_empty()));
|
||||||
|
assert!(!project.artifact_links.is_empty());
|
||||||
|
assert!(project.traces.values().any(|t| t.baseline.is_some()));
|
||||||
|
assert_fidelity("coastal-source3", &project);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keyed_scenarios_records_and_historical_binding_annotation_facets_roundtrip() {
|
||||||
|
let project = read(KEYED);
|
||||||
|
for layer in [Layer::Oa, Layer::Sa] {
|
||||||
|
assert!(project.elements.values().any(|e| e.layer() == layer
|
||||||
|
&& matches!(
|
||||||
|
e.shared(),
|
||||||
|
Some(SharedBody::DataType {
|
||||||
|
definition: DataTypeDefinition::Record { .. },
|
||||||
|
..
|
||||||
|
})
|
||||||
|
)));
|
||||||
|
assert!(project.elements.values().any(|e| e.layer() == layer && matches!(e.shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::ScenarioUse { bindings, .. }, .. }) if !bindings.is_empty())));
|
||||||
|
}
|
||||||
|
assert!(project.elements.values().any(|e| matches!(e.shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { values, .. }, .. }) if values.iter().any(|v| !v.path.is_empty() && !v.expression.is_empty()))));
|
||||||
|
let facets: Vec<_> = project
|
||||||
|
.traces
|
||||||
|
.values()
|
||||||
|
.filter_map(|t| t.baseline.as_ref()?.semantic.as_ref())
|
||||||
|
.flat_map(|baseline| &baseline.facets)
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
facets
|
||||||
|
.iter()
|
||||||
|
.any(|facet| matches!(facet, semantic::FacetReview::Binding { .. }))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
facets
|
||||||
|
.iter()
|
||||||
|
.any(|facet| matches!(facet, semantic::FacetReview::Annotation { .. }))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
KEYED.contains("uuid \""),
|
||||||
|
"fixture contains historical identity atoms"
|
||||||
|
);
|
||||||
|
let (_, source) = assert_fidelity("keyed-source5-history", &project);
|
||||||
|
assert!(
|
||||||
|
source.contains("uuid \""),
|
||||||
|
"historical atoms remain historical"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn state_machine_projection_typed_actions_and_bounded_integer_roundtrip() {
|
||||||
|
let project = read(STATES);
|
||||||
|
let machine = identity(&project, "operating");
|
||||||
|
let view = &project.diagrams[&identity(&project, "state_view")];
|
||||||
|
assert_eq!(view.projection, ProjectionKind::StateMachine { machine });
|
||||||
|
assert!(!view.nodes.is_empty());
|
||||||
|
for phase in [
|
||||||
|
StateActionKind::Entry,
|
||||||
|
StateActionKind::Do,
|
||||||
|
StateActionKind::Exit,
|
||||||
|
] {
|
||||||
|
assert!(project.relations.values().any(|r| matches!(&r.body, RelationBody::StateAction { phase: actual, .. } if *actual == phase)));
|
||||||
|
}
|
||||||
|
assert!(project.relations.values().any(|r| matches!(&r.body, RelationBody::StateTransition { transition_kind: TransitionKind::Internal, triggers, effects, .. } if !triggers.is_empty() && !effects.is_empty())));
|
||||||
|
assert!(
|
||||||
|
matches!(project.elements[&identity(&project, "retry_budget")].shared(), Some(SharedBody::DataType { definition: DataTypeDefinition::Integer { minimum: Some(0), maximum: Some(4) }, unit, .. }) if unit == "attempts")
|
||||||
|
);
|
||||||
|
assert_fidelity("state-actions-source5", &project);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forty_exchange_diagram_with_categories_and_processes_roundtrip() {
|
||||||
|
let project = read(DENSE);
|
||||||
|
assert_eq!(
|
||||||
|
project
|
||||||
|
.relations
|
||||||
|
.values()
|
||||||
|
.filter(|r| r.kind() == RelationKind::Interaction)
|
||||||
|
.count(),
|
||||||
|
40
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
project
|
||||||
|
.elements
|
||||||
|
.values()
|
||||||
|
.filter(|e| e.kind() == Kind::ExchangeCategory)
|
||||||
|
.count(),
|
||||||
|
5
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
project
|
||||||
|
.elements
|
||||||
|
.values()
|
||||||
|
.filter(|e| e.kind() == Kind::Process)
|
||||||
|
.count(),
|
||||||
|
3
|
||||||
|
);
|
||||||
|
let view = &project.diagrams[&identity(&project, "forty_exchange_view")];
|
||||||
|
assert_eq!(view.projection, ProjectionKind::FunctionalDataflow);
|
||||||
|
assert_eq!(view.nodes.len(), 2);
|
||||||
|
assert!(view.routes.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
view.view_options.relation_kinds,
|
||||||
|
view_options::RelationKinds::All
|
||||||
|
);
|
||||||
|
assert_fidelity("dense40-source6", &project);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn component_ports_interface_operations_and_boundary_calls_roundtrip() {
|
||||||
|
let project = read(OPERATIONS);
|
||||||
|
assert_eq!(
|
||||||
|
project
|
||||||
|
.relations
|
||||||
|
.values()
|
||||||
|
.filter(|r| r.kind() == RelationKind::InterfaceItemAllocation)
|
||||||
|
.count(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
project
|
||||||
|
.elements
|
||||||
|
.values()
|
||||||
|
.filter(|e| matches!(
|
||||||
|
e.shared(),
|
||||||
|
Some(SharedBody::Port {
|
||||||
|
contract: PortContract::Component {
|
||||||
|
kind: ComponentPortKind::Standard,
|
||||||
|
..
|
||||||
|
},
|
||||||
|
..
|
||||||
|
})
|
||||||
|
))
|
||||||
|
.count(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
assert!(project.elements.values().any(|e| matches!(
|
||||||
|
e.shared(),
|
||||||
|
Some(SharedBody::ExchangeItem {
|
||||||
|
mechanism: ExchangeMechanism::Operation,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
)));
|
||||||
|
assert!(project.elements.values().any(|e| matches!(e.shared(), Some(SharedBody::Scenario { viewpoint: ScenarioViewpoint::BoundaryInterface, steps, .. }) if steps.len() == 2)));
|
||||||
|
assert_eq!(
|
||||||
|
project
|
||||||
|
.elements
|
||||||
|
.values()
|
||||||
|
.filter(|e| matches!(
|
||||||
|
e.shared(),
|
||||||
|
Some(SharedBody::Occurrence {
|
||||||
|
body: OccurrenceBody::Exchange {
|
||||||
|
message_kind: MessageKind::Call,
|
||||||
|
operation: None,
|
||||||
|
..
|
||||||
|
},
|
||||||
|
..
|
||||||
|
})
|
||||||
|
))
|
||||||
|
.count(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
assert_fidelity("boundary-operations-source6", &project);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn negative_zero_node_coordinate_survives_canonical_compact_and_bundle_compile() {
|
||||||
|
let mut project = read(STATES);
|
||||||
|
let diagram = identity(&project, "state_view");
|
||||||
|
let node = identity(&project, "view_machine");
|
||||||
|
project
|
||||||
|
.diagrams
|
||||||
|
.get_mut(&diagram)
|
||||||
|
.unwrap()
|
||||||
|
.nodes
|
||||||
|
.get_mut(&node)
|
||||||
|
.unwrap()
|
||||||
|
.position = Position { x: -0.0, y: 0.0 };
|
||||||
|
let (decoded, flat) = assert_fidelity("state-negative-zero", &project);
|
||||||
|
let bundle = SourceBundle {
|
||||||
|
entry: "project.arcadia".into(),
|
||||||
|
files: BTreeMap::from([("project.arcadia".into(), flat)]),
|
||||||
|
};
|
||||||
|
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||||
|
let modular = compile_bundle(&format_bundle(&project), ProjectLimits::default()).unwrap();
|
||||||
|
// Project/Position PartialEq alone equates the signs of zero. Check bits in
|
||||||
|
// each independently decoded representation, including source-project compile.
|
||||||
|
for actual in [
|
||||||
|
&decoded,
|
||||||
|
&compiled.decoded.project,
|
||||||
|
&modular.decoded.project,
|
||||||
|
] {
|
||||||
|
let position = &actual.diagrams[&diagram].nodes[&node].position;
|
||||||
|
assert_eq!(position.x.to_bits(), (-0.0_f32).to_bits());
|
||||||
|
assert_eq!(position.y.to_bits(), 0.0_f32.to_bits());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn present_empty_field_default_and_unbounded_maximum_remain_distinct_from_omission() {
|
||||||
|
let mut project = read(KEYED);
|
||||||
|
let field_id = identity(&project, "oa_navigation_latitude");
|
||||||
|
let Some(SharedBody::DataField {
|
||||||
|
minimum,
|
||||||
|
maximum,
|
||||||
|
default,
|
||||||
|
storage,
|
||||||
|
..
|
||||||
|
}) = project.elements.get_mut(&field_id).unwrap().shared_mut()
|
||||||
|
else {
|
||||||
|
panic!("fixture must contain the claimed DataField");
|
||||||
|
};
|
||||||
|
assert_eq!(*minimum, 0);
|
||||||
|
assert_eq!(*maximum, Some(1));
|
||||||
|
assert_eq!(*default, None);
|
||||||
|
assert_eq!(*storage, FieldStorage::Value);
|
||||||
|
*maximum = None;
|
||||||
|
*default = Some(vec![]);
|
||||||
|
let (decoded, _) = assert_fidelity("keyed-present-empty-default", &project);
|
||||||
|
assert!(
|
||||||
|
matches!(decoded.elements[&field_id].shared(), Some(SharedBody::DataField { maximum: None, default: Some(values), .. }) if values.is_empty())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn present_empty_artifact_revision_and_fragment_survive() {
|
||||||
|
let mut project = read(COASTAL);
|
||||||
|
let artifact_id = identity(&project, "console_enclosure_cad");
|
||||||
|
let artifact = project.artifact_links.get_mut(&artifact_id).unwrap();
|
||||||
|
artifact.revision = Some(String::new());
|
||||||
|
artifact.fragment = Some(String::new());
|
||||||
|
let (decoded, _) = assert_fidelity("coastal-present-empty-artifact-strings", &project);
|
||||||
|
assert_eq!(
|
||||||
|
decoded.artifact_links[&artifact_id].revision.as_deref(),
|
||||||
|
Some("")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decoded.artifact_links[&artifact_id].fragment.as_deref(),
|
||||||
|
Some("")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selected_and_unresolved_operation_intent_survive_as_distinct_present_records() {
|
||||||
|
let mut project = read(OPERATIONS);
|
||||||
|
let allocation = identity(&project, "operation_probe_30");
|
||||||
|
assert_eq!(
|
||||||
|
project.relations[&allocation].kind(),
|
||||||
|
RelationKind::InterfaceItemAllocation
|
||||||
|
);
|
||||||
|
let selected = identity(&project, "operation_probe_43");
|
||||||
|
let unresolved = identity(&project, "operation_probe_44");
|
||||||
|
for (subject, value) in [(selected, Some(allocation)), (unresolved, None)] {
|
||||||
|
let Some(SharedBody::Occurrence {
|
||||||
|
body: OccurrenceBody::Exchange { operation, .. },
|
||||||
|
..
|
||||||
|
}) = project.elements.get_mut(&subject).unwrap().shared_mut()
|
||||||
|
else {
|
||||||
|
panic!("fixture must contain a Call Exchange");
|
||||||
|
};
|
||||||
|
assert_eq!(*operation, None);
|
||||||
|
*operation = Some(OperationUse { allocation: value });
|
||||||
|
}
|
||||||
|
let (decoded, _) = assert_fidelity("boundary-selected-and-unresolved-operations", &project);
|
||||||
|
assert!(
|
||||||
|
matches!(decoded.elements[&selected].shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { operation: Some(OperationUse { allocation: Some(actual) }), .. }, .. }) if *actual == allocation)
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
decoded.elements[&unresolved].shared(),
|
||||||
|
Some(SharedBody::Occurrence {
|
||||||
|
body: OccurrenceBody::Exchange {
|
||||||
|
operation: Some(OperationUse { allocation: None }),
|
||||||
|
..
|
||||||
|
},
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
//! Focused public-output contracts. Field locations come from the real compiler;
|
||||||
|
//! token comparisons ignore layout while retaining typed tags and required syntax.
|
||||||
|
use oa_core::{language, source_project::*, *};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
fn project(declarations: &str) -> Project {
|
||||||
|
language::parse(&format!(
|
||||||
|
"arcadia 7; project p \"P\" id \"00000000-0000-4000-8000-000000000001\" {{ {declarations} }}"
|
||||||
|
)).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exports(project: &Project) -> Vec<CompiledSourceProject> {
|
||||||
|
let flat = language::format(project);
|
||||||
|
assert_eq!(language::parse(&flat).unwrap(), *project);
|
||||||
|
let flat_bundle = SourceBundle {
|
||||||
|
entry: "project.arcadia".into(),
|
||||||
|
files: BTreeMap::from([("project.arcadia".into(), flat)]),
|
||||||
|
};
|
||||||
|
[flat_bundle, format_bundle(project)]
|
||||||
|
.into_iter()
|
||||||
|
.map(|bundle| {
|
||||||
|
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||||
|
assert_eq!(compiled.decoded.project, *project);
|
||||||
|
assert_eq!(
|
||||||
|
compiled.bundle, bundle,
|
||||||
|
"all identities are explicit and stable"
|
||||||
|
);
|
||||||
|
compiled
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn identity(project: &Project, alias: &str) -> Id {
|
||||||
|
project
|
||||||
|
.identities()
|
||||||
|
.into_iter()
|
||||||
|
.find(|(_, name)| *name == alias)
|
||||||
|
.unwrap()
|
||||||
|
.0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field(compiled: &CompiledSourceProject, alias: &str, name: &str) -> Option<String> {
|
||||||
|
let location = compiled
|
||||||
|
.index
|
||||||
|
.fields
|
||||||
|
.get(&(identity(&compiled.decoded.project, alias), name.into()))?;
|
||||||
|
let text = &compiled.bundle.files[&location.path][location.span.start..location.span.end];
|
||||||
|
Some(
|
||||||
|
language::tokenize(text)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|token| {
|
||||||
|
!matches!(
|
||||||
|
token.class,
|
||||||
|
language::TokenClass::Whitespace | language::TokenClass::Comment
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.map(|token| &text[token.span.start..token.span.end])
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" "),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_project_has_no_synthetic_description_or_module() {
|
||||||
|
let empty = project("");
|
||||||
|
for compiled in exports(&empty) {
|
||||||
|
assert!(compiled.index.project_description.is_none());
|
||||||
|
assert!(compiled.index.fields.is_empty());
|
||||||
|
assert!(compiled.index.includes.is_empty());
|
||||||
|
assert_eq!(compiled.bundle.files.len(), 1);
|
||||||
|
assert_eq!(compiled.index.declarations.len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_empty_descriptions_disappear_whitespace_and_text_survive_exactly() {
|
||||||
|
let mut model =
|
||||||
|
project("oa_activity work \"Work\" id \"00000000-0000-4000-8000-000000000002\" {}");
|
||||||
|
let work = identity(&model, "work");
|
||||||
|
for description in ["", " \t\n", "A quoted \"requirement\" — 雨"] {
|
||||||
|
model.description = description.into();
|
||||||
|
model.elements.get_mut(&work).unwrap().description = description.into();
|
||||||
|
for compiled in exports(&model) {
|
||||||
|
assert_eq!(
|
||||||
|
compiled.index.project_description.is_some(),
|
||||||
|
!description.is_empty()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
field(&compiled, "work", "description").is_some(),
|
||||||
|
!description.is_empty()
|
||||||
|
);
|
||||||
|
assert!(field(&compiled, "work", "parent").is_none());
|
||||||
|
// exports compares the complete model, including exact whitespace
|
||||||
|
// and escapes in both project and element descriptions.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sa_view_keeps_its_layer_and_node_references_but_omits_presentation_defaults() {
|
||||||
|
let model = project(
|
||||||
|
r#"
|
||||||
|
sa_function work "Work" id "00000000-0000-4000-8000-000000000002" {}
|
||||||
|
diagram view "View" id "00000000-0000-4000-8000-000000000003" { layer Sa; }
|
||||||
|
node placement id "00000000-0000-4000-8000-000000000004" {
|
||||||
|
diagram @view; element @work;
|
||||||
|
position { x 0.0; y 0.0; }; size { width 224.0; height 94.0; };
|
||||||
|
collapsed false; port_anchors [];
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
for compiled in exports(&model) {
|
||||||
|
assert_eq!(field(&compiled, "view", "layer").as_deref(), Some("Sa"));
|
||||||
|
for name in ["description", "projection", "routes", "view_options"] {
|
||||||
|
assert!(field(&compiled, "view", name).is_none(), "view.{name}");
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
field(&compiled, "placement", "diagram").as_deref(),
|
||||||
|
Some("@ view")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
field(&compiled, "placement", "element").as_deref(),
|
||||||
|
Some("@ work")
|
||||||
|
);
|
||||||
|
for name in ["position", "size", "collapsed", "port_anchors"] {
|
||||||
|
assert!(
|
||||||
|
field(&compiled, "placement", name).is_none(),
|
||||||
|
"placement.{name}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nested_type_defaults_disappear_without_losing_required_or_present_empty_values() {
|
||||||
|
let model = project(
|
||||||
|
r#"
|
||||||
|
sa_data_type implicit "Implicit" id "00000000-0000-4000-8000-000000000002" {}
|
||||||
|
sa_data_type integer "Integer" id "00000000-0000-4000-8000-000000000003" {
|
||||||
|
definition { Integer { minimum none; maximum none; }; };
|
||||||
|
}
|
||||||
|
sa_data_type text "Text" id "00000000-0000-4000-8000-000000000004" {
|
||||||
|
definition { String { minimum_length 4; maximum_length none; }; };
|
||||||
|
}
|
||||||
|
sa_data_type record "Record" id "00000000-0000-4000-8000-000000000005" {
|
||||||
|
definition { Record { supertype none; }; };
|
||||||
|
}
|
||||||
|
sa_data_type flag "Flag" id "00000000-0000-4000-8000-000000000006" {
|
||||||
|
definition Boolean; default { Boolean false; };
|
||||||
|
}
|
||||||
|
sa_data_field optional "Optional" id "00000000-0000-4000-8000-000000000007" {
|
||||||
|
parent @record; minimum 0; maximum none; default [];
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
for compiled in exports(&model) {
|
||||||
|
assert!(field(&compiled, "implicit", "definition").is_none());
|
||||||
|
assert_eq!(
|
||||||
|
field(&compiled, "integer", "definition").as_deref(),
|
||||||
|
Some("{ Integer { } ; }")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
field(&compiled, "text", "definition").as_deref(),
|
||||||
|
Some("{ String { minimum_length 4 ; } ; }")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
field(&compiled, "record", "definition").as_deref(),
|
||||||
|
Some("{ Record { } ; }")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
field(&compiled, "flag", "definition").as_deref(),
|
||||||
|
Some("Boolean")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
field(&compiled, "flag", "default").as_deref(),
|
||||||
|
Some("{ Boolean false ; }")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
field(&compiled, "optional", "minimum").as_deref(),
|
||||||
|
Some("0")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
field(&compiled, "optional", "maximum").as_deref(),
|
||||||
|
Some("none")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
field(&compiled, "optional", "default").as_deref(),
|
||||||
|
Some("[ ]")
|
||||||
|
);
|
||||||
|
assert!(field(&compiled, "optional", "type_ref").is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn required_port_contract_and_empty_item_list_survive_while_optional_interface_disappears() {
|
||||||
|
let model = project(
|
||||||
|
r#"
|
||||||
|
sa_function work "Work" id "00000000-0000-4000-8000-000000000002" {}
|
||||||
|
sa_port sink "Sink" id "00000000-0000-4000-8000-000000000003" {
|
||||||
|
parent @work; contract { FunctionInput { incoming_items []; }; };
|
||||||
|
}
|
||||||
|
sa_system host "Host" id "00000000-0000-4000-8000-000000000004" {}
|
||||||
|
sa_port preserved "Preserved" id "00000000-0000-4000-8000-000000000005" {
|
||||||
|
parent @host; contract Legacy; legacy { direction Out; interface none; };
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
for compiled in exports(&model) {
|
||||||
|
assert_eq!(
|
||||||
|
field(&compiled, "sink", "contract").as_deref(),
|
||||||
|
Some("{ FunctionInput { incoming_items [ ] ; } ; }")
|
||||||
|
);
|
||||||
|
assert!(field(&compiled, "sink", "legacy").is_none());
|
||||||
|
assert_eq!(
|
||||||
|
field(&compiled, "preserved", "contract").as_deref(),
|
||||||
|
Some("Legacy")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
field(&compiled, "preserved", "legacy").as_deref(),
|
||||||
|
Some("{ direction Out ; }")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_scenario_default_and_selected_occurrence_constructor_remain_distinct() {
|
||||||
|
let model = project(
|
||||||
|
r#"
|
||||||
|
sa_scenario mixed "Mixed" id "00000000-0000-4000-8000-000000000002" { viewpoint MixedLegacy; }
|
||||||
|
sa_scenario strict "Strict" id "00000000-0000-4000-8000-000000000003" { viewpoint Functional; }
|
||||||
|
oa_process flow "Flow" id "00000000-0000-4000-8000-000000000004" { steps [@exchange, @action]; }
|
||||||
|
oa_occurrence exchange "Exchange" id "00000000-0000-4000-8000-000000000005" {
|
||||||
|
parent @flow; body { Exchange {}; };
|
||||||
|
}
|
||||||
|
oa_occurrence action "Action" id "00000000-0000-4000-8000-000000000006" {
|
||||||
|
parent @flow; body { Action { action none; lifeline none; }; };
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
for compiled in exports(&model) {
|
||||||
|
assert!(field(&compiled, "mixed", "viewpoint").is_none());
|
||||||
|
assert_eq!(
|
||||||
|
field(&compiled, "strict", "viewpoint").as_deref(),
|
||||||
|
Some("Functional")
|
||||||
|
);
|
||||||
|
assert!(field(&compiled, "exchange", "body").is_none());
|
||||||
|
assert_eq!(
|
||||||
|
field(&compiled, "action", "body").as_deref(),
|
||||||
|
Some("{ Action { } ; }")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
field(&compiled, "action", "parent").as_deref(),
|
||||||
|
Some("@ flow")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,612 @@
|
|||||||
|
//! Exact-source regressions for fields skipped by persistence serialization.
|
||||||
|
//!
|
||||||
|
//! The archived Windows baseline confirmed three exact-source failures after
|
||||||
|
//! full model equality passed. Complete comparison now preserves these fields;
|
||||||
|
//! the former ignored witnesses are normal regression tests.
|
||||||
|
//! No-op and same-record header controls remain normal tests: they must preserve
|
||||||
|
//! the same fixtures without entering the changed-payload record-erasure branch.
|
||||||
|
|
||||||
|
use oa_core::{source_project::*, *};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
const EXCHANGE_SOURCE: &str = r#"arcadia 7;
|
||||||
|
project p "Preservation" id "00000000-0000-4000-8000-000000000001" {
|
||||||
|
oa_activity first "First" id "00000000-0000-4000-8000-000000000002" {}
|
||||||
|
oa_activity second "Second" id "00000000-0000-4000-8000-000000000003" {}
|
||||||
|
interaction flow "Flow" id "00000000-0000-4000-8000-000000000004" {
|
||||||
|
source @first; target @second;
|
||||||
|
}
|
||||||
|
oa_process owner "Owner" id "00000000-0000-4000-8000-000000000005" {
|
||||||
|
steps [@subject];
|
||||||
|
}
|
||||||
|
oa_occurrence subject "Subject" id "00000000-0000-4000-8000-000000000006" {
|
||||||
|
parent @owner;
|
||||||
|
body { Exchange {
|
||||||
|
exchange none;
|
||||||
|
sender none; receiver none;
|
||||||
|
message_kind Asynchronous; reply_to none; values [];
|
||||||
|
operation none ; // Keep this authored default and its spelling.
|
||||||
|
}; };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
const NODE_SOURCE: &str = r#"arcadia 7;
|
||||||
|
project p "Preservation" id "00000000-0000-4000-8000-000000000001" {
|
||||||
|
oa_activity work "Work" id "00000000-0000-4000-8000-000000000002" {}
|
||||||
|
diagram view "View" id "00000000-0000-4000-8000-000000000003" {}
|
||||||
|
node placement id "00000000-0000-4000-8000-000000000004" {
|
||||||
|
diagram @view; element @work;
|
||||||
|
position { x 12.5; y -4.0; };
|
||||||
|
port_anchors [] ; // Keep explicit automatic placement.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
const TRACE_SOURCE: &str = r#"arcadia 7;
|
||||||
|
project p "Preservation" id "00000000-0000-4000-8000-000000000001" {
|
||||||
|
oa_activity work "Work" id "00000000-0000-4000-8000-000000000002" {}
|
||||||
|
sa_function function "Function" id "00000000-0000-4000-8000-000000000003" {}
|
||||||
|
trace mapping "Mapping" id "00000000-0000-4000-8000-000000000004" {
|
||||||
|
oa @work; sa @function; kind Manual;
|
||||||
|
baseline {
|
||||||
|
oa { name "Work"; description "Old OA snapshot"; };
|
||||||
|
sa { name "Function"; description "Old SA snapshot"; };
|
||||||
|
semantic none ; // Keep the explicit prose-only baseline.
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
const CONSTRUCTOR_SOURCE: &str = r#"arcadia 7;
|
||||||
|
project p "Preservation" id "00000000-0000-4000-8000-000000000001" {
|
||||||
|
oa_scenario owner "Owner" id "00000000-0000-4000-8000-000000000002" {
|
||||||
|
steps [@subject];
|
||||||
|
}
|
||||||
|
oa_occurrence subject "Subject" id "00000000-0000-4000-8000-000000000003" {
|
||||||
|
parent @owner;
|
||||||
|
body { Action {
|
||||||
|
action none;
|
||||||
|
lifeline none ; // Keep this shared field's distinctive spelling.
|
||||||
|
}; };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
const RELATION_CONTEXT_SOURCE: &str = r#"arcadia 7;
|
||||||
|
project p "Preservation" id "00000000-0000-4000-8000-000000000001" {
|
||||||
|
oa_entity first "First" id "00000000-0000-4000-8000-000000000002" {}
|
||||||
|
oa_entity second "Second" id "00000000-0000-4000-8000-000000000003" {}
|
||||||
|
// These port declarations are untouched by the relation context change.
|
||||||
|
oa_port output "Output" id "00000000-0000-4000-8000-000000000005" {
|
||||||
|
parent @first; contract Legacy;
|
||||||
|
legacy { direction Out; interface none; };
|
||||||
|
}
|
||||||
|
oa_port input "Input" id "00000000-0000-4000-8000-000000000006" {
|
||||||
|
parent @second; contract Legacy;
|
||||||
|
legacy { direction In; interface none; };
|
||||||
|
}
|
||||||
|
boundary_exchange link "Link" id "00000000-0000-4000-8000-000000000004" {
|
||||||
|
source @output; target @input;
|
||||||
|
oriented true ; // Retain this rationale when changing the default value.
|
||||||
|
items [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
fn id(number: u8) -> Id {
|
||||||
|
Id::parse_str(&format!("00000000-0000-4000-8000-{number:012}")).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_bundle(source: &str) -> SourceBundle {
|
||||||
|
SourceBundle {
|
||||||
|
entry: "project.arcadia".into(),
|
||||||
|
files: BTreeMap::from([("project.arcadia".into(), source.into())]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn project() -> Project {
|
||||||
|
Project {
|
||||||
|
schema_version: 7,
|
||||||
|
id: id(1),
|
||||||
|
alias: "p".into(),
|
||||||
|
name: "Preservation".into(),
|
||||||
|
description: String::new(),
|
||||||
|
elements: BTreeMap::new(),
|
||||||
|
relations: BTreeMap::new(),
|
||||||
|
diagrams: BTreeMap::new(),
|
||||||
|
artifact_links: BTreeMap::new(),
|
||||||
|
traces: BTreeMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn element(number: u8, alias: &str, name: &str, parent: Option<Id>, body: ElementBody) -> Element {
|
||||||
|
Element {
|
||||||
|
id: id(number),
|
||||||
|
alias: alias.into(),
|
||||||
|
name: name.into(),
|
||||||
|
description: String::new(),
|
||||||
|
parent,
|
||||||
|
body,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handwritten typed expectations are independent of canonical serialization and
|
||||||
|
// source hydration. No model factories or generated/random identities are used.
|
||||||
|
fn exchange_project() -> Project {
|
||||||
|
let mut expected = project();
|
||||||
|
expected.elements = [
|
||||||
|
element(2, "first", "First", None, ElementBody::Oa(OaBody::Activity)),
|
||||||
|
element(
|
||||||
|
3,
|
||||||
|
"second",
|
||||||
|
"Second",
|
||||||
|
None,
|
||||||
|
ElementBody::Oa(OaBody::Activity),
|
||||||
|
),
|
||||||
|
element(
|
||||||
|
5,
|
||||||
|
"owner",
|
||||||
|
"Owner",
|
||||||
|
None,
|
||||||
|
ElementBody::Oa(OaBody::Process { steps: vec![id(6)] }),
|
||||||
|
),
|
||||||
|
element(
|
||||||
|
6,
|
||||||
|
"subject",
|
||||||
|
"Subject",
|
||||||
|
Some(id(5)),
|
||||||
|
ElementBody::Oa(OaBody::Shared(SharedBody::Occurrence {
|
||||||
|
body: OccurrenceBody::Exchange {
|
||||||
|
exchange: None,
|
||||||
|
sender: None,
|
||||||
|
receiver: None,
|
||||||
|
message_kind: MessageKind::Asynchronous,
|
||||||
|
reply_to: None,
|
||||||
|
values: vec![],
|
||||||
|
operation: None,
|
||||||
|
},
|
||||||
|
precondition: String::new(),
|
||||||
|
postcondition: String::new(),
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(|element| (element.id, element))
|
||||||
|
.collect();
|
||||||
|
expected.relations.insert(
|
||||||
|
id(4),
|
||||||
|
Relation {
|
||||||
|
id: id(4),
|
||||||
|
alias: "flow".into(),
|
||||||
|
name: "Flow".into(),
|
||||||
|
description: String::new(),
|
||||||
|
body: RelationBody::Interaction {
|
||||||
|
source: id(2),
|
||||||
|
target: id(3),
|
||||||
|
items: vec![],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expected
|
||||||
|
}
|
||||||
|
|
||||||
|
fn node_project() -> Project {
|
||||||
|
let mut expected = project();
|
||||||
|
expected.elements.insert(
|
||||||
|
id(2),
|
||||||
|
element(2, "work", "Work", None, ElementBody::Oa(OaBody::Activity)),
|
||||||
|
);
|
||||||
|
expected.diagrams.insert(
|
||||||
|
id(3),
|
||||||
|
Diagram {
|
||||||
|
id: id(3),
|
||||||
|
alias: "view".into(),
|
||||||
|
name: "View".into(),
|
||||||
|
description: String::new(),
|
||||||
|
layer: Layer::Oa,
|
||||||
|
projection: ProjectionKind::Overview,
|
||||||
|
view_options: view_options::ViewOptions {
|
||||||
|
hidden_edges: vec![],
|
||||||
|
relation_kinds: view_options::RelationKinds::All,
|
||||||
|
work_categories: view_options::CategorySelection::All,
|
||||||
|
participant_categories: view_options::CategorySelection::All,
|
||||||
|
coverage: view_options::Coverage::All,
|
||||||
|
work_contexts: view_options::ContextSelection::All,
|
||||||
|
exchange_labels: view_options::ExchangeLabels::Name,
|
||||||
|
},
|
||||||
|
nodes: BTreeMap::from([(
|
||||||
|
id(4),
|
||||||
|
NodePlacement {
|
||||||
|
id: id(4),
|
||||||
|
alias: "placement".into(),
|
||||||
|
element: id(2),
|
||||||
|
position: Position { x: 12.5, y: -4.0 },
|
||||||
|
size: Size {
|
||||||
|
width: 224.0,
|
||||||
|
height: 94.0,
|
||||||
|
},
|
||||||
|
collapsed: false,
|
||||||
|
port_anchors: vec![],
|
||||||
|
},
|
||||||
|
)]),
|
||||||
|
routes: vec![],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expected
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trace_project() -> Project {
|
||||||
|
let mut expected = project();
|
||||||
|
expected.elements = [
|
||||||
|
element(2, "work", "Work", None, ElementBody::Oa(OaBody::Activity)),
|
||||||
|
element(
|
||||||
|
3,
|
||||||
|
"function",
|
||||||
|
"Function",
|
||||||
|
None,
|
||||||
|
ElementBody::Sa(SaBody::Function),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(|element| (element.id, element))
|
||||||
|
.collect();
|
||||||
|
expected.traces.insert(
|
||||||
|
id(4),
|
||||||
|
Trace {
|
||||||
|
id: id(4),
|
||||||
|
alias: "mapping".into(),
|
||||||
|
name: "Mapping".into(),
|
||||||
|
description: String::new(),
|
||||||
|
oa: id(2),
|
||||||
|
sa: id(3),
|
||||||
|
kind: TraceKind::Manual,
|
||||||
|
baseline: Some(TraceBaseline {
|
||||||
|
oa: TracedProperties {
|
||||||
|
name: "Work".into(),
|
||||||
|
description: "Old OA snapshot".into(),
|
||||||
|
},
|
||||||
|
sa: TracedProperties {
|
||||||
|
name: "Function".into(),
|
||||||
|
description: "Old SA snapshot".into(),
|
||||||
|
},
|
||||||
|
semantic: None,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expected
|
||||||
|
}
|
||||||
|
|
||||||
|
fn checked_fixture(source: &str, expected: &Project) -> CompiledSourceProject {
|
||||||
|
let input = source_bundle(source);
|
||||||
|
let compiled = compile_bundle(&input, ProjectLimits::default()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
compiled.decoded.project, *expected,
|
||||||
|
"fixture model admission"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
compiled.bundle, input,
|
||||||
|
"fixed IDs must avoid stabilization edits"
|
||||||
|
);
|
||||||
|
compiled
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replacement(source: &str, before: &str, after: &str) -> String {
|
||||||
|
assert_eq!(
|
||||||
|
source.matches(before).count(),
|
||||||
|
1,
|
||||||
|
"unique authored edit token"
|
||||||
|
);
|
||||||
|
source.replacen(before, after, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_preserved_update(
|
||||||
|
original: &CompiledSourceProject,
|
||||||
|
expected: &Project,
|
||||||
|
expected_source: &str,
|
||||||
|
) {
|
||||||
|
// Independently admit the exact expected spelling before testing the updater.
|
||||||
|
let expected_bundle = source_bundle(expected_source);
|
||||||
|
let authored = compile_bundle(&expected_bundle, ProjectLimits::default()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
authored.decoded.project, *expected,
|
||||||
|
"expected source/model agree"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
authored.bundle, expected_bundle,
|
||||||
|
"expected source is already stable"
|
||||||
|
);
|
||||||
|
|
||||||
|
let updated = update_bundle(original, expected, ProjectLimits::default()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
updated.decoded.project, *expected,
|
||||||
|
"entire intended model survives"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
updated.bundle, expected_bundle,
|
||||||
|
"only the requested token may change; preserve explicit defaults and all trivia"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context changes may introduce/remove redundant default syntax. Admit each
|
||||||
|
// narrowly enumerated spelling against the same literal target model, and then
|
||||||
|
// compare complete source bundles. No normalization may mask edits to unrelated
|
||||||
|
// fields, declarations, whitespace, or comments.
|
||||||
|
fn assert_context_update(
|
||||||
|
original: &CompiledSourceProject,
|
||||||
|
expected: &Project,
|
||||||
|
allowed_sources: &[String],
|
||||||
|
) {
|
||||||
|
let allowed: Vec<_> = allowed_sources
|
||||||
|
.iter()
|
||||||
|
.map(|source| {
|
||||||
|
let bundle = source_bundle(source);
|
||||||
|
let authored = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
authored.decoded.project, *expected,
|
||||||
|
"allowed source/model agree"
|
||||||
|
);
|
||||||
|
assert_eq!(authored.bundle, bundle, "allowed source is already stable");
|
||||||
|
bundle
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let updated = update_bundle(original, expected, ProjectLimits::default()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
updated.decoded.project, *expected,
|
||||||
|
"entire context-change model survives"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
allowed.contains(&updated.bundle),
|
||||||
|
"preserve unchanged source exactly; only listed changed/default syntax alternatives are allowed\nactual: {:?}\nallowed: {:?}",
|
||||||
|
updated.bundle,
|
||||||
|
allowed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_exchange_operation_none_survives_noop() {
|
||||||
|
let expected = exchange_project();
|
||||||
|
let original = checked_fixture(EXCHANGE_SOURCE, &expected);
|
||||||
|
assert_preserved_update(&original, &expected, EXCHANGE_SOURCE);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_exchange_operation_none_survives_header_rename() {
|
||||||
|
let mut expected = exchange_project();
|
||||||
|
let original = checked_fixture(EXCHANGE_SOURCE, &expected);
|
||||||
|
expected.elements.get_mut(&id(6)).unwrap().name = "Renamed subject".into();
|
||||||
|
let source = replacement(EXCHANGE_SOURCE, "\"Subject\"", "\"Renamed subject\"");
|
||||||
|
assert_preserved_update(&original, &expected, &source);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_exchange_operation_none_survives_exchange_binding_edit() {
|
||||||
|
let mut expected = exchange_project();
|
||||||
|
let original = checked_fixture(EXCHANGE_SOURCE, &expected);
|
||||||
|
let Some(SharedBody::Occurrence {
|
||||||
|
body: OccurrenceBody::Exchange { exchange, .. },
|
||||||
|
..
|
||||||
|
}) = expected.elements.get_mut(&id(6)).unwrap().body.shared_mut()
|
||||||
|
else {
|
||||||
|
panic!("literal fixture must be an Exchange occurrence");
|
||||||
|
};
|
||||||
|
*exchange = Some(id(4));
|
||||||
|
let source = replacement(EXCHANGE_SOURCE, "exchange none;", "exchange @flow;");
|
||||||
|
assert_preserved_update(&original, &expected, &source);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_node_empty_port_anchors_survive_noop() {
|
||||||
|
let expected = node_project();
|
||||||
|
let original = checked_fixture(NODE_SOURCE, &expected);
|
||||||
|
assert_preserved_update(&original, &expected, NODE_SOURCE);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_node_empty_port_anchors_survive_header_alias_rename() {
|
||||||
|
let mut expected = node_project();
|
||||||
|
let original = checked_fixture(NODE_SOURCE, &expected);
|
||||||
|
// Nodes have no display-name header. Rename the same node's header alias.
|
||||||
|
expected
|
||||||
|
.diagrams
|
||||||
|
.get_mut(&id(3))
|
||||||
|
.unwrap()
|
||||||
|
.nodes
|
||||||
|
.get_mut(&id(4))
|
||||||
|
.unwrap()
|
||||||
|
.alias = "renamed_placement".into();
|
||||||
|
let source = replacement(
|
||||||
|
NODE_SOURCE,
|
||||||
|
"node placement id",
|
||||||
|
"node renamed_placement id",
|
||||||
|
);
|
||||||
|
assert_preserved_update(&original, &expected, &source);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_node_empty_port_anchors_survive_position_edit() {
|
||||||
|
let mut expected = node_project();
|
||||||
|
let original = checked_fixture(NODE_SOURCE, &expected);
|
||||||
|
expected
|
||||||
|
.diagrams
|
||||||
|
.get_mut(&id(3))
|
||||||
|
.unwrap()
|
||||||
|
.nodes
|
||||||
|
.get_mut(&id(4))
|
||||||
|
.unwrap()
|
||||||
|
.position
|
||||||
|
.x = 25.0;
|
||||||
|
let source = replacement(NODE_SOURCE, "x 12.5;", "x 25.0;");
|
||||||
|
assert_preserved_update(&original, &expected, &source);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_trace_semantic_none_survives_noop() {
|
||||||
|
let expected = trace_project();
|
||||||
|
let original = checked_fixture(TRACE_SOURCE, &expected);
|
||||||
|
assert_preserved_update(&original, &expected, TRACE_SOURCE);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_trace_semantic_none_survives_header_rename() {
|
||||||
|
let mut expected = trace_project();
|
||||||
|
let original = checked_fixture(TRACE_SOURCE, &expected);
|
||||||
|
expected.traces.get_mut(&id(4)).unwrap().name = "Renamed mapping".into();
|
||||||
|
let source = replacement(TRACE_SOURCE, "\"Mapping\"", "\"Renamed mapping\"");
|
||||||
|
assert_preserved_update(&original, &expected, &source);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_trace_semantic_none_survives_snapshot_prose_edit() {
|
||||||
|
let mut expected = trace_project();
|
||||||
|
let original = checked_fixture(TRACE_SOURCE, &expected);
|
||||||
|
expected
|
||||||
|
.traces
|
||||||
|
.get_mut(&id(4))
|
||||||
|
.unwrap()
|
||||||
|
.baseline
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.oa
|
||||||
|
.description = "Revised OA snapshot".into();
|
||||||
|
let source = replacement(
|
||||||
|
TRACE_SOURCE,
|
||||||
|
"\"Old OA snapshot\"",
|
||||||
|
"\"Revised OA snapshot\"",
|
||||||
|
);
|
||||||
|
assert_preserved_update(&original, &expected, &source);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn constructor_change_removes_inapplicable_default_and_keeps_shared_default() {
|
||||||
|
let mut expected = project();
|
||||||
|
expected.elements = [
|
||||||
|
element(
|
||||||
|
2,
|
||||||
|
"owner",
|
||||||
|
"Owner",
|
||||||
|
None,
|
||||||
|
ElementBody::Oa(OaBody::Shared(SharedBody::Scenario {
|
||||||
|
viewpoint: ScenarioViewpoint::MixedLegacy,
|
||||||
|
context: None,
|
||||||
|
precondition: String::new(),
|
||||||
|
postcondition: String::new(),
|
||||||
|
steps: vec![id(3)],
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
element(
|
||||||
|
3,
|
||||||
|
"subject",
|
||||||
|
"Subject",
|
||||||
|
Some(id(2)),
|
||||||
|
ElementBody::Oa(OaBody::Shared(SharedBody::Occurrence {
|
||||||
|
body: OccurrenceBody::Action {
|
||||||
|
action: None,
|
||||||
|
lifeline: None,
|
||||||
|
},
|
||||||
|
precondition: String::new(),
|
||||||
|
postcondition: String::new(),
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(|element| (element.id, element))
|
||||||
|
.collect();
|
||||||
|
let original = checked_fixture(CONSTRUCTOR_SOURCE, &expected);
|
||||||
|
expected.elements.get_mut(&id(3)).unwrap().body =
|
||||||
|
ElementBody::Oa(OaBody::Shared(SharedBody::Occurrence {
|
||||||
|
body: OccurrenceBody::State {
|
||||||
|
state: None,
|
||||||
|
lifeline: None,
|
||||||
|
},
|
||||||
|
precondition: String::new(),
|
||||||
|
postcondition: String::new(),
|
||||||
|
}));
|
||||||
|
// Remove only the inapplicable field's syntax: its separating space and all
|
||||||
|
// surrounding trivia stay. The shared lifeline spelling/comment is exact.
|
||||||
|
let source = replacement(CONSTRUCTOR_SOURCE, "body { Action {", "body { State {");
|
||||||
|
let compact = replacement(&source, "action none;", " ");
|
||||||
|
let expanded = replacement(
|
||||||
|
&compact,
|
||||||
|
" }; };",
|
||||||
|
" state none;\n }; };",
|
||||||
|
);
|
||||||
|
// Omission of newly applicable state and explicit state none both mean the
|
||||||
|
// literal State target; neither may retain the old action field.
|
||||||
|
assert_context_update(&original, &expected, &[compact, expanded]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relation_kind_change_updates_explicit_value_when_context_default_changes() {
|
||||||
|
let mut expected = project();
|
||||||
|
expected.elements = [
|
||||||
|
element(2, "first", "First", None, ElementBody::Oa(OaBody::Entity)),
|
||||||
|
element(3, "second", "Second", None, ElementBody::Oa(OaBody::Entity)),
|
||||||
|
element(
|
||||||
|
5,
|
||||||
|
"output",
|
||||||
|
"Output",
|
||||||
|
Some(id(2)),
|
||||||
|
ElementBody::Oa(OaBody::Shared(SharedBody::Port {
|
||||||
|
contract: PortContract::Legacy,
|
||||||
|
legacy: Some(LegacyPortContract {
|
||||||
|
direction: PortDirection::Out,
|
||||||
|
interface: None,
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
element(
|
||||||
|
6,
|
||||||
|
"input",
|
||||||
|
"Input",
|
||||||
|
Some(id(3)),
|
||||||
|
ElementBody::Oa(OaBody::Shared(SharedBody::Port {
|
||||||
|
contract: PortContract::Legacy,
|
||||||
|
legacy: Some(LegacyPortContract {
|
||||||
|
direction: PortDirection::In,
|
||||||
|
interface: None,
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(|element| (element.id, element))
|
||||||
|
.collect();
|
||||||
|
expected.relations.insert(
|
||||||
|
id(4),
|
||||||
|
Relation {
|
||||||
|
id: id(4),
|
||||||
|
alias: "link".into(),
|
||||||
|
name: "Link".into(),
|
||||||
|
description: String::new(),
|
||||||
|
body: RelationBody::BoundaryExchange {
|
||||||
|
source: id(5),
|
||||||
|
target: id(6),
|
||||||
|
oriented: true,
|
||||||
|
items: vec![],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let original = checked_fixture(RELATION_CONTEXT_SOURCE, &expected);
|
||||||
|
// BoundaryExchange connects participant ports; CommunicationMean connects
|
||||||
|
// their OA entity owners. Endpoint changes are explicit parts of this target,
|
||||||
|
// not inferred or normalized away. Port records remain exactly unchanged.
|
||||||
|
expected.relations.get_mut(&id(4)).unwrap().body = RelationBody::CommunicationMean {
|
||||||
|
source: id(2),
|
||||||
|
target: id(3),
|
||||||
|
oriented: false,
|
||||||
|
items: vec![],
|
||||||
|
};
|
||||||
|
let source = replacement(
|
||||||
|
RELATION_CONTEXT_SOURCE,
|
||||||
|
"boundary_exchange link",
|
||||||
|
"communication_mean link",
|
||||||
|
);
|
||||||
|
let source = replacement(&source, "source @output;", "source @first;");
|
||||||
|
let source = replacement(&source, "target @input;", "target @second;");
|
||||||
|
let explicit = replacement(&source, "oriented true ;", "oriented false ;");
|
||||||
|
// Three spaces between name/value plus one before the semicolon survive
|
||||||
|
// syntax removal. The following rationale comment and all other bytes stay.
|
||||||
|
let omitted = replacement(&source, "oriented true ;", " ");
|
||||||
|
assert_context_update(&original, &expected, &[explicit, omitted]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
//! Public update witnesses for compact insertion and complete comparison.
|
||||||
|
use oa_core::{language, source_project::*, *};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
fn compile(body: &str) -> CompiledSourceProject {
|
||||||
|
compile_bundle(
|
||||||
|
&SourceBundle {
|
||||||
|
entry: "project.arcadia".into(),
|
||||||
|
files: BTreeMap::from([
|
||||||
|
(
|
||||||
|
"project.arcadia".into(),
|
||||||
|
"arcadia 7; project p \"P\" { include \"model.arcadia\"; }".into(),
|
||||||
|
),
|
||||||
|
("model.arcadia".into(), body.into()),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
ProjectLimits::default(),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
fn id(project: &Project, alias: &str) -> Id {
|
||||||
|
project
|
||||||
|
.identities()
|
||||||
|
.into_iter()
|
||||||
|
.find(|(_, a)| *a == alias)
|
||||||
|
.unwrap()
|
||||||
|
.0
|
||||||
|
}
|
||||||
|
fn update(before: &CompiledSourceProject, project: &Project) -> CompiledSourceProject {
|
||||||
|
let result = update_bundle(before, project, ProjectLimits::default()).unwrap();
|
||||||
|
assert_eq!(result.decoded.project, *project);
|
||||||
|
assert_eq!(
|
||||||
|
result.bundle.files["project.arcadia"],
|
||||||
|
before.bundle.files["project.arcadia"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
compile_bundle(&result.bundle, ProjectLimits::default())
|
||||||
|
.unwrap()
|
||||||
|
.bundle,
|
||||||
|
result.bundle
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
update_bundle(&result, project, ProjectLimits::default())
|
||||||
|
.unwrap()
|
||||||
|
.bundle,
|
||||||
|
result.bundle
|
||||||
|
);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
fn words(text: &str) -> Vec<&str> {
|
||||||
|
language::tokenize(text)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|t| {
|
||||||
|
!matches!(
|
||||||
|
t.class,
|
||||||
|
language::TokenClass::Whitespace | language::TokenClass::Comment
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.map(|t| &text[t.span.start..t.span.end])
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn omitted_exchange_body_changes_to_one_compact_action_constructor() {
|
||||||
|
let before = compile(
|
||||||
|
"oa_scenario s \"S\" { steps [@o]; }\noa_occurrence o \"O\" { parent @s; // keep this\n}\n",
|
||||||
|
);
|
||||||
|
let mut project = before.decoded.project.clone();
|
||||||
|
let occurrence = id(&project, "o");
|
||||||
|
let Some(SharedBody::Occurrence { body, .. }) =
|
||||||
|
project.elements.get_mut(&occurrence).unwrap().shared_mut()
|
||||||
|
else {
|
||||||
|
panic!("occurrence")
|
||||||
|
};
|
||||||
|
assert!(matches!(body, OccurrenceBody::Exchange { .. }));
|
||||||
|
*body = OccurrenceBody::Action {
|
||||||
|
action: None,
|
||||||
|
lifeline: None,
|
||||||
|
};
|
||||||
|
let result = update(&before, &project);
|
||||||
|
let text = &result.bundle.files["model.arcadia"];
|
||||||
|
let tokens = words(text);
|
||||||
|
assert_eq!(tokens.iter().filter(|&&t| t == "body").count(), 1);
|
||||||
|
assert!(tokens.contains(&"Action"));
|
||||||
|
for absent in ["Exchange", "action", "lifeline", "operation", "description"] {
|
||||||
|
assert!(!tokens.contains(&absent), "{absent}: {text}");
|
||||||
|
}
|
||||||
|
assert!(text.contains("parent @s; // keep this"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_signal_constructor_still_inserts_its_required_empty_items() {
|
||||||
|
let before =
|
||||||
|
compile("oa_event e \"E\" { body { Time { expression \"1 s\"; kind After; }; }; }\n");
|
||||||
|
let mut project = before.decoded.project.clone();
|
||||||
|
let event = id(&project, "e");
|
||||||
|
let Some(SharedBody::Event { body }) = project.elements.get_mut(&event).unwrap().shared_mut()
|
||||||
|
else {
|
||||||
|
panic!("event")
|
||||||
|
};
|
||||||
|
*body = EventBody::Signal { items: vec![] };
|
||||||
|
let result = update(&before, &project);
|
||||||
|
let tokens = words(&result.bundle.files["model.arcadia"]);
|
||||||
|
assert!(tokens.contains(&"Signal"));
|
||||||
|
assert!(tokens.windows(4).any(|w| w == ["items", "[", "]", ";"]));
|
||||||
|
assert!(!tokens.contains(&"expression"));
|
||||||
|
assert!(!tokens.contains(&"kind"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scalar_to_default_string_record_retains_required_minimum_only() {
|
||||||
|
let before = compile("oa_data_type t \"T\" { definition Boolean; // typed rationale\n}\n");
|
||||||
|
let mut project = before.decoded.project.clone();
|
||||||
|
let datatype = id(&project, "t");
|
||||||
|
let Some(SharedBody::DataType { definition, .. }) =
|
||||||
|
project.elements.get_mut(&datatype).unwrap().shared_mut()
|
||||||
|
else {
|
||||||
|
panic!("data type")
|
||||||
|
};
|
||||||
|
*definition = DataTypeDefinition::String {
|
||||||
|
minimum_length: 0,
|
||||||
|
maximum_length: None,
|
||||||
|
};
|
||||||
|
let result = update(&before, &project);
|
||||||
|
let text = &result.bundle.files["model.arcadia"];
|
||||||
|
let tokens = words(text);
|
||||||
|
assert!(tokens.contains(&"String"));
|
||||||
|
assert!(tokens.windows(3).any(|w| w == ["minimum_length", "0", ";"]));
|
||||||
|
assert!(!tokens.contains(&"maximum_length"));
|
||||||
|
assert!(text.contains("// typed rationale"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inserted_route_list_item_omits_only_optional_fields() {
|
||||||
|
let before = compile(
|
||||||
|
"oa_activity a \"A\" {}\noa_activity b \"B\" {}\ninteraction flow \"Flow\" { source @a; target @b; }\ndiagram d \"D\" { routes []; // routing rationale\n}\n",
|
||||||
|
);
|
||||||
|
let mut project = before.decoded.project.clone();
|
||||||
|
let diagram = id(&project, "d");
|
||||||
|
let edge = id(&project, "flow");
|
||||||
|
// Deserialize only the explicit witness value, independently of omission policy.
|
||||||
|
project.diagrams.get_mut(&diagram).unwrap().routes =
|
||||||
|
serde_json::from_value(serde_json::json!([{"edge":edge,"waypoints":[],"label":null}]))
|
||||||
|
.unwrap();
|
||||||
|
let result = update(&before, &project);
|
||||||
|
let text = &result.bundle.files["model.arcadia"];
|
||||||
|
let tokens = words(text);
|
||||||
|
assert!(tokens.windows(4).any(|w| w == ["edge", "@", "flow", ";"]));
|
||||||
|
assert!(!tokens.contains(&"waypoints"));
|
||||||
|
assert!(!tokens.contains(&"label"));
|
||||||
|
assert!(text.contains("// routing rationale"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_sign_change_inserts_position_without_default_siblings() {
|
||||||
|
let before = compile(
|
||||||
|
"oa_activity a \"A\" {}\ndiagram d \"D\" {}\nnode n { diagram @d; element @a; // placement rationale\n}\n",
|
||||||
|
);
|
||||||
|
let mut project = before.decoded.project.clone();
|
||||||
|
let diagram = id(&project, "d");
|
||||||
|
let node = id(&project, "n");
|
||||||
|
assert_eq!(
|
||||||
|
project.diagrams[&diagram].nodes[&node].position.x.to_bits(),
|
||||||
|
0.0_f32.to_bits()
|
||||||
|
);
|
||||||
|
project
|
||||||
|
.diagrams
|
||||||
|
.get_mut(&diagram)
|
||||||
|
.unwrap()
|
||||||
|
.nodes
|
||||||
|
.get_mut(&node)
|
||||||
|
.unwrap()
|
||||||
|
.position
|
||||||
|
.x = -0.0;
|
||||||
|
let result = update(&before, &project);
|
||||||
|
assert_eq!(
|
||||||
|
result.decoded.project.diagrams[&diagram].nodes[&node]
|
||||||
|
.position
|
||||||
|
.x
|
||||||
|
.to_bits(),
|
||||||
|
(-0.0_f32).to_bits()
|
||||||
|
);
|
||||||
|
let text = &result.bundle.files["model.arcadia"];
|
||||||
|
let tokens = words(text);
|
||||||
|
assert!(tokens.windows(3).any(|w| w == ["x", "-0.0", ";"]));
|
||||||
|
for absent in ["size", "collapsed", "port_anchors"] {
|
||||||
|
assert!(!tokens.contains(&absent));
|
||||||
|
}
|
||||||
|
assert!(text.contains("// placement rationale"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kind_change_materializes_unchanged_value_when_its_old_omission_default_changes() {
|
||||||
|
let before = compile(
|
||||||
|
"oa_entity a \"A\" {}\noa_entity b \"B\" {}\noa_port out \"Out\" { parent @a; contract Legacy; legacy { direction Out; }; }\noa_port input \"In\" { parent @b; contract Legacy; legacy { direction In; }; }\nboundary_exchange flow \"Flow\" { source @out; target @input; // orientation inherited\n}\n",
|
||||||
|
);
|
||||||
|
let mut project = before.decoded.project.clone();
|
||||||
|
let flow = id(&project, "flow");
|
||||||
|
assert!(matches!(
|
||||||
|
project.relations[&flow].body,
|
||||||
|
RelationBody::BoundaryExchange { oriented: true, .. }
|
||||||
|
));
|
||||||
|
project.relations.get_mut(&flow).unwrap().body = RelationBody::CommunicationMean {
|
||||||
|
source: id(&before.decoded.project, "a"),
|
||||||
|
target: id(&before.decoded.project, "b"),
|
||||||
|
oriented: true,
|
||||||
|
items: vec![],
|
||||||
|
};
|
||||||
|
let result = update(&before, &project);
|
||||||
|
let text = &result.bundle.files["model.arcadia"];
|
||||||
|
assert!(
|
||||||
|
words(text)
|
||||||
|
.windows(3)
|
||||||
|
.any(|w| w == ["oriented", "true", ";"])
|
||||||
|
);
|
||||||
|
assert!(text.contains("// orientation inherited"));
|
||||||
|
let before_ports = &before.bundle.files["model.arcadia"];
|
||||||
|
assert_eq!(
|
||||||
|
text.split("communication_mean").next().unwrap(),
|
||||||
|
before_ports.split("boundary_exchange").next().unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,16 @@ use oa_core::{language, persistence, validation, *};
|
|||||||
fn read(source: &str) -> Project {
|
fn read(source: &str) -> Project {
|
||||||
language::parse(source).unwrap_or_else(|e| panic!("{e:?}\n{source}"))
|
language::parse(source).unwrap_or_else(|e| panic!("{e:?}\n{source}"))
|
||||||
}
|
}
|
||||||
|
fn replace_once(source: &str, before: &str, after: &str) -> String {
|
||||||
|
assert_eq!(
|
||||||
|
source.matches(before).count(),
|
||||||
|
1,
|
||||||
|
"unique mutation anchor {before:?}"
|
||||||
|
);
|
||||||
|
let changed = source.replacen(before, after, 1);
|
||||||
|
assert_ne!(changed, source, "test mutation must change the source");
|
||||||
|
changed
|
||||||
|
}
|
||||||
fn rejected(source: &str, fragment: &str) {
|
fn rejected(source: &str, fragment: &str) {
|
||||||
let errors = language::parse(source).expect_err(source);
|
let errors = language::parse(source).expect_err(source);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -753,8 +763,8 @@ fn typed_events_vertices_and_transition_kinds_round_trip_in_both_layers() {
|
|||||||
source.push('}');
|
source.push('}');
|
||||||
let model = read(&source);
|
let model = read(&source);
|
||||||
let canonical = language::format(&model);
|
let canonical = language::format(&model);
|
||||||
|
assert!(!canonical.contains("transition_kind External;"));
|
||||||
for fragment in [
|
for fragment in [
|
||||||
"transition_kind External;",
|
|
||||||
"transition_kind Local;",
|
"transition_kind Local;",
|
||||||
"transition_kind Internal;",
|
"transition_kind Internal;",
|
||||||
"triggers [@deadline, @assess];",
|
"triggers [@deadline, @assess];",
|
||||||
@@ -776,6 +786,7 @@ fn typed_events_vertices_and_transition_kinds_round_trip_in_both_layers() {
|
|||||||
.find(|r| r.alias == "recover")
|
.find(|r| r.alias == "recover")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let RelationBody::StateTransition {
|
let RelationBody::StateTransition {
|
||||||
|
transition_kind,
|
||||||
trigger,
|
trigger,
|
||||||
guard,
|
guard,
|
||||||
effect,
|
effect,
|
||||||
@@ -784,32 +795,42 @@ fn typed_events_vertices_and_transition_kinds_round_trip_in_both_layers() {
|
|||||||
else {
|
else {
|
||||||
panic!("expected transition")
|
panic!("expected transition")
|
||||||
};
|
};
|
||||||
|
assert_eq!(*transition_kind, TransitionKind::External);
|
||||||
assert_eq!(trigger, "Operator confirms\nНе запускать код");
|
assert_eq!(trigger, "Operator confirms\nНе запускать код");
|
||||||
assert_eq!(guard, "available && status == \"Ready\"");
|
assert_eq!(guard, "available && status == \"Ready\"");
|
||||||
assert_eq!(effect, "Record the assessment");
|
assert_eq!(effect, "Record the assessment");
|
||||||
|
// External is omitted in compact output. Insert an explicit invalid
|
||||||
|
// discriminator into Recover using its unique, nondefault trigger list.
|
||||||
|
let invalid_kind = replace_once(
|
||||||
|
&canonical,
|
||||||
|
"triggers [@deadline, @assess];",
|
||||||
|
"transition_kind Unsupported;\n triggers [@deadline, @assess];",
|
||||||
|
);
|
||||||
|
rejected(&invalid_kind, "Unsupported");
|
||||||
for invalid in [
|
for invalid in [
|
||||||
canonical.replace("transition_kind External;", "transition_kind Unsupported;"),
|
replace_once(
|
||||||
canonical.replace(
|
&canonical,
|
||||||
"triggers [@deadline, @assess];",
|
"triggers [@deadline, @assess];",
|
||||||
"triggers [\"deadline\", @assess];",
|
"triggers [\"deadline\", @assess];",
|
||||||
),
|
),
|
||||||
canonical.replace(
|
replace_once(
|
||||||
|
&canonical,
|
||||||
"triggers [@deadline, @assess];",
|
"triggers [@deadline, @assess];",
|
||||||
"triggers [@deadline, @deadline];",
|
"triggers [@deadline, @deadline];",
|
||||||
),
|
),
|
||||||
canonical.replace("effects [@assess];", "effects [@payload];"),
|
replace_once(&canonical, "effects [@assess];", "effects [@payload];"),
|
||||||
canonical.replace("kind After;", "kind Relative;"),
|
replace_once(&canonical, "kind After;", "kind Relative;"),
|
||||||
canonical.replace("items [@payload];", "items [@assess];"),
|
replace_once(&canonical, "items [@payload];", "items [@assess];"),
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
language::parse(&invalid).is_err(),
|
language::parse(&invalid).is_err(),
|
||||||
"invalid typed state/event source accepted"
|
"invalid typed state/event source accepted"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let legacy = canonical
|
// Canonical source already omits External. Removing the supplied lists
|
||||||
.replace("transition_kind External;", "")
|
// independently exercises their empty omission defaults and retained prose.
|
||||||
.replace("triggers [@deadline, @assess];", "")
|
let legacy = replace_once(&canonical, "triggers [@deadline, @assess];", "");
|
||||||
.replace("effects [@assess];", "");
|
let legacy = replace_once(&legacy, "effects [@assess];", "");
|
||||||
let legacy = read(&legacy);
|
let legacy = read(&legacy);
|
||||||
let RelationBody::StateTransition {
|
let RelationBody::StateTransition {
|
||||||
transition_kind,
|
transition_kind,
|
||||||
@@ -829,7 +850,7 @@ fn typed_events_vertices_and_transition_kinds_round_trip_in_both_layers() {
|
|||||||
(retained_trigger, retained_guard, retained_effect),
|
(retained_trigger, retained_guard, retained_effect),
|
||||||
(trigger, guard, effect)
|
(trigger, guard, effect)
|
||||||
);
|
);
|
||||||
let draft = canonical.replace("expression \"30 s\";", "expression \"\";");
|
let draft = replace_once(&canonical, "expression \"30 s\";", "expression \"\";");
|
||||||
assert!(language::parse(&draft).is_ok());
|
assert!(language::parse(&draft).is_ok());
|
||||||
let deadline = model
|
let deadline = model
|
||||||
.elements
|
.elements
|
||||||
@@ -1038,7 +1059,7 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
|
|||||||
"fixture must cover actual old scenario content"
|
"fixture must cover actual old scenario content"
|
||||||
);
|
);
|
||||||
let canonical = language::format(&from_source);
|
let canonical = language::format(&from_source);
|
||||||
assert!(canonical.contains("viewpoint MixedLegacy;"));
|
assert!(!canonical.contains("viewpoint MixedLegacy;"));
|
||||||
for relation in from_source.relations.values() {
|
for relation in from_source.relations.values() {
|
||||||
if let RelationBody::StateTransition {
|
if let RelationBody::StateTransition {
|
||||||
transition_kind,
|
transition_kind,
|
||||||
|
|||||||
@@ -767,7 +767,15 @@ fn resetting_last_source_anchors_preserves_comments_and_can_add_a_new_manual_rec
|
|||||||
assert_eq!(cleared.decoded.project, prepared.project);
|
assert_eq!(cleared.decoded.project, prepared.project);
|
||||||
let text = &cleared.bundle.files["project.arcadia"];
|
let text = &cleared.bundle.files["project.arcadia"];
|
||||||
assert!(text.contains("// first rationale") && text.contains("// second rationale"));
|
assert!(text.contains("// first rationale") && text.contains("// second rationale"));
|
||||||
assert!(!text.contains("port_anchors"));
|
// Lossless editing may retain the authored empty field and its annotations.
|
||||||
|
// Canonical export, rather than a model update, normalizes default syntax.
|
||||||
|
assert!(!language::format(&cleared.decoded.project).contains("port_anchors"));
|
||||||
|
assert_eq!(
|
||||||
|
update_bundle(&cleared, &cleared.decoded.project, ProjectLimits::default())
|
||||||
|
.unwrap()
|
||||||
|
.bundle,
|
||||||
|
cleared.bundle
|
||||||
|
);
|
||||||
let prepared = prepare_command(
|
let prepared = prepare_command(
|
||||||
&cleared.decoded.project,
|
&cleared.decoded.project,
|
||||||
&Command::SetPortAnchor {
|
&Command::SetPortAnchor {
|
||||||
|
|||||||
@@ -75,6 +75,164 @@ fn assert_pair(current: &StoredProject) {
|
|||||||
assert_eq!(compiled.bundle, current.source_project);
|
assert_eq!(compiled.bundle, current.source_project);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn explicit_empty_port_anchors_survive_http_move_and_restart_but_export_compactly() {
|
||||||
|
fn replace_once(source: &mut String, before: &str, after: &str) {
|
||||||
|
assert_eq!(
|
||||||
|
source.matches(before).count(),
|
||||||
|
1,
|
||||||
|
"ambiguous or absent anchor: {before}"
|
||||||
|
);
|
||||||
|
let replacement = source.replacen(before, after, 1);
|
||||||
|
assert_ne!(*source, replacement, "fixture mutation must change source");
|
||||||
|
*source = replacement;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assert_exports(server: &TestServer, current: &StoredProject, node: Id) -> String {
|
||||||
|
let response = server
|
||||||
|
.client
|
||||||
|
.get(server.url(&format!("/projects/{}/source-project", current.resource_id)))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
assert_eq!(response.headers()["etag"], current.etag());
|
||||||
|
assert_eq!(
|
||||||
|
response.json::<SourceBundle>().await.unwrap(),
|
||||||
|
current.source_project
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = server
|
||||||
|
.client
|
||||||
|
.get(server.url(&format!("/projects/{}/source", current.resource_id)))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let canonical = response.text().await.unwrap();
|
||||||
|
assert_eq!(oa_core::language::parse(&canonical).unwrap(), current.model);
|
||||||
|
assert_eq!(canonical, oa_core::language::format(¤t.model));
|
||||||
|
let exported = compile_bundle(
|
||||||
|
&SourceBundle {
|
||||||
|
entry: "project.arcadia".into(),
|
||||||
|
files: BTreeMap::from([("project.arcadia".into(), canonical.clone())]),
|
||||||
|
},
|
||||||
|
ProjectLimits::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(exported.decoded.project, current.model);
|
||||||
|
assert!(exported.index.declarations.contains_key(&node));
|
||||||
|
assert!(
|
||||||
|
!exported
|
||||||
|
.index
|
||||||
|
.fields
|
||||||
|
.contains_key(&(node, "port_anchors".into()))
|
||||||
|
);
|
||||||
|
canonical
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut authored = source_bundle();
|
||||||
|
replace_once(
|
||||||
|
authored.files.get_mut("views.arcadia").unwrap(),
|
||||||
|
"diagram @architecture; element @vessel;",
|
||||||
|
"diagram @architecture; element @vessel;\n position { x 12.5; y -4.0; };\n port_anchors [] ; // Keep explicit automatic placement.\n",
|
||||||
|
);
|
||||||
|
let compiled = compile_bundle(&authored, ProjectLimits::default()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
compiled.bundle, authored,
|
||||||
|
"the HTTP fixture must already be stable"
|
||||||
|
);
|
||||||
|
let diagram = id(&compiled.decoded.project, "architecture");
|
||||||
|
let node = id(&compiled.decoded.project, "actor_view");
|
||||||
|
assert!(
|
||||||
|
compiled
|
||||||
|
.index
|
||||||
|
.fields
|
||||||
|
.contains_key(&(node, "port_anchors".into()))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
compiled.decoded.project.diagrams[&diagram].nodes[&node].position,
|
||||||
|
oa_core::Position { x: 12.5, y: -4.0 }
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
compiled.decoded.project.diagrams[&diagram].nodes[&node]
|
||||||
|
.port_anchors
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Independent full-model oracle: only this placement coordinate changes.
|
||||||
|
let mut expected_model = compiled.decoded.project.clone();
|
||||||
|
expected_model
|
||||||
|
.diagrams
|
||||||
|
.get_mut(&diagram)
|
||||||
|
.unwrap()
|
||||||
|
.nodes
|
||||||
|
.get_mut(&node)
|
||||||
|
.unwrap()
|
||||||
|
.position
|
||||||
|
.x = 25.0;
|
||||||
|
let mut expected_source = authored.clone();
|
||||||
|
replace_once(
|
||||||
|
expected_source.files.get_mut("views.arcadia").unwrap(),
|
||||||
|
"x 12.5;",
|
||||||
|
"x 25.0;",
|
||||||
|
);
|
||||||
|
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let server = TestServer::start(directory.path(), None).await;
|
||||||
|
let response = server
|
||||||
|
.client
|
||||||
|
.post(server.url("/projects/import-source-project"))
|
||||||
|
.json(&authored)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let status = response.status();
|
||||||
|
let value: Value = response.json().await.unwrap();
|
||||||
|
assert_eq!(status, StatusCode::CREATED, "{value}");
|
||||||
|
let original = server
|
||||||
|
.get(value["resource_id"].as_str().unwrap().parse().unwrap())
|
||||||
|
.await;
|
||||||
|
assert_eq!(original.model, compiled.decoded.project);
|
||||||
|
assert_eq!(original.source_project, authored);
|
||||||
|
assert_pair(&original);
|
||||||
|
assert_exports(&server, &original, node).await;
|
||||||
|
|
||||||
|
let response = transact(
|
||||||
|
&server,
|
||||||
|
&original,
|
||||||
|
json!([{"MoveNode": {"diagram": diagram, "node": node,
|
||||||
|
"position": {"x": 25.0, "y": -4.0}, "with_descendants": false}}]),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let status = response.status();
|
||||||
|
let value: Value = response.json().await.unwrap();
|
||||||
|
assert_eq!(status, StatusCode::OK, "{value}");
|
||||||
|
assert_eq!(value["changed"], true);
|
||||||
|
let current = server.get(original.resource_id).await;
|
||||||
|
assert_eq!(current.revision, original.revision + 1);
|
||||||
|
assert_eq!(current.model, expected_model);
|
||||||
|
// Exact bundle equality covers the explicit empty field, its rationale,
|
||||||
|
// sibling placement, and all untouched files and source trivia.
|
||||||
|
assert_eq!(current.source_project, expected_source);
|
||||||
|
assert_pair(¤t);
|
||||||
|
let canonical = assert_exports(&server, ¤t, node).await;
|
||||||
|
|
||||||
|
server.stop().await;
|
||||||
|
let server = TestServer::start(directory.path(), None).await;
|
||||||
|
let reopened = server.get(current.resource_id).await;
|
||||||
|
assert_eq!(reopened.model, expected_model);
|
||||||
|
assert_eq!(reopened.source_project, expected_source);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&reopened).unwrap(),
|
||||||
|
serde_json::to_value(¤t).unwrap()
|
||||||
|
);
|
||||||
|
assert_pair(&reopened);
|
||||||
|
assert_eq!(assert_exports(&server, &reopened, node).await, canonical);
|
||||||
|
server.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn port_anchor_http_commands_and_source_edits_preserve_per_view_state_after_restart() {
|
async fn port_anchor_http_commands_and_source_edits_preserve_per_view_state_after_restart() {
|
||||||
let directory = tempfile::tempdir().unwrap();
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user