diff --git a/crates/oa-core/src/language.rs b/crates/oa-core/src/language.rs index 24e3f891..f04e9552 100644 --- a/crates/oa-core/src/language.rs +++ b/crates/oa-core/src/language.rs @@ -3,6 +3,8 @@ mod semantic_source; #[path = "migration_v2/source.rs"] mod v2_source; +pub(crate) use crate::source_defaults::Mode as DeclarationFormat; +use crate::source_defaults::{self, Context as DefaultContext}; use crate::*; use serde_json::{Map, Value}; use std::{collections::BTreeMap, fmt}; @@ -645,25 +647,34 @@ fn offset_for(source: &str, line: usize, column: usize) -> usize { source.len() } -/// Canonical output uses persistent aliases and explicit identities. Typed -/// nested records are ordinary language fields; JSON documents are not embedded. +/// Canonical output uses persistent aliases and explicit identities, omitting +/// only values supplied by source defaults. Typed nested records are ordinary +/// language fields; JSON documents are not embedded. pub fn format(project: &Project) -> String { let mut out = std::format!( - "arcadia {SCHEMA_VERSION};\nproject {} {} id \"{}\" {{\n description {};\n\n", + "arcadia {SCHEMA_VERSION};\nproject {} {} id \"{}\" {{\n", project.alias, quote(&project.name), 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("}\n"); out } -/// The same typed declaration writer is used by flat and modular source output. -pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> { +/// Shared typed writer for canonical output and lossless editing. Complete edit +/// 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 .identities() .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()); fields.insert("description".into(), Value::String(e.description.clone())); fields.insert("parent".into(), serde_json::to_value(e.parent).unwrap()); + let fields = source_defaults::fields(DefaultContext::Element(e.kind()), &fields, mode); write_declaration( &mut out, &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 fields = payload(&serde_json::to_value(&r.body).unwrap()); fields.insert("description".into(), Value::String(r.description.clone())); + let fields = source_defaults::fields(DefaultContext::Relation(r.kind()), &fields, mode); write_declaration( &mut out, &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 fields = record_fields(d); fields.remove("nodes"); + let fields = source_defaults::fields(DefaultContext::Diagram, &fields, mode); write_declaration( &mut out, "diagram", @@ -719,6 +733,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> { let mut out = String::new(); let mut fields = record_fields(n); 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); declarations.push((n.id, out)); } @@ -731,7 +746,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> { &a.alias, Some(&a.name), a.id, - &record_fields(a), + &source_defaults::fields(DefaultContext::Artifact, &record_fields(a), mode), &aliases, ); declarations.push((a.id, out)); @@ -744,7 +759,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> { &t.alias, Some(&t.name), t.id, - &record_fields(t), + &source_defaults::fields(DefaultContext::Trace, &record_fields(t), mode), &aliases, ); declarations.push((t.id, out)); @@ -1742,7 +1757,9 @@ pub(crate) fn resolve_declarations( *viewpoint = ScenarioViewpoint::MixedLegacy; } 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) .and_then(|value| value.as_object().cloned()) .unwrap_or_else(|| payload(&body)) @@ -1799,7 +1816,12 @@ pub(crate) fn resolve_declarations( let mut body = serde_json::to_value(RelationBody::new(kind, Id::nil(), Id::nil())) .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); let body = serde_json::from_value(body) .map_err(|e| conversion_error(e.to_string()))?; @@ -1824,7 +1846,10 @@ pub(crate) fn resolve_declarations( } let defaults = 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)?; header(&mut record, d, true); 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())); } 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)?; header(&mut record, d, false); 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())); } 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)?; header(&mut record, d, true); 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 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)?; header(&mut record, d, true); let trace = if project.schema_version < 5 { diff --git a/crates/oa-core/src/lib.rs b/crates/oa-core/src/lib.rs index 7934fd65..43615cc9 100644 --- a/crates/oa-core/src/lib.rs +++ b/crates/oa-core/src/lib.rs @@ -11,6 +11,7 @@ pub mod references; pub mod routing; pub mod semantic; pub mod session; +mod source_defaults; pub mod source_files; pub mod source_project; pub mod topology_context; diff --git a/crates/oa-core/src/source_defaults.rs b/crates/oa-core/src/source_defaults.rs new file mode 100644 index 00000000..f9a4cf95 --- /dev/null +++ b/crates/oa-core/src/source_defaults.rs @@ -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 { + 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, + mode: Mode, +) -> Map { + 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) { + 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 { + 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 { + 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 { + 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) { + 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) { + 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) { + 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, + key: &str, + tag: &str, +) -> Option<&'a mut Map> { + 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, 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, defaults: &Map) { + 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 { + match value { + Value::Object(fields) => fields, + _ => unreachable!("literal source default is an object"), + } +} + +#[cfg(test)] +#[path = "source_defaults_tests.rs"] +mod tests; diff --git a/crates/oa-core/src/source_defaults_tests.rs b/crates/oa-core/src/source_defaults_tests.rs new file mode 100644 index 00000000..049495e1 --- /dev/null +++ b/crates/oa-core/src/source_defaults_tests.rs @@ -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); + } +} diff --git a/crates/oa-core/src/source_project.rs b/crates/oa-core/src/source_project.rs index 4d3e377f..22575d07 100644 --- a/crates/oa-core/src/source_project.rs +++ b/crates/oa-core/src/source_project.rs @@ -726,7 +726,8 @@ fn apply_edits(bundle: &mut SourceBundle, edits: Edits) -> Result<(), Vec SourceBundle { let ownership = default_ownership(project); 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)); let mut files: BTreeMap = BTreeMap::new(); for (id, declaration) in declarations { @@ -735,11 +736,13 @@ pub fn format_bundle(project: &Project) -> SourceBundle { .or_default() .push_str(&unindent(&declaration)); } - let mut entry = format!( - "arcadia {SCHEMA_VERSION};\n{}{{\n description {};\n", - project_header(project), - quote(&project.description) - ); + let mut entry = format!("arcadia {SCHEMA_VERSION};\n{}{{\n", project_header(project)); + if !project.description.is_empty() { + entry.push_str(&format!( + " description {};\n", + quote(&project.description) + )); + } for path in files.keys() { 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 before: BTreeMap<_, _> = language::format_declarations(old).into_iter().collect(); - let after: BTreeMap<_, _> = language::format_declarations(project).into_iter().collect(); + // Comparison must include defaults even when persistence serialization or + // 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 .identities() .into_iter() @@ -907,6 +927,27 @@ pub fn update_bundle_with_identity_map( }; let mut previous = parse(old_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() && let language::EditKind::Record(fields) = &actual.fields.kind && !fields.contains_key("parent") @@ -923,6 +964,7 @@ pub fn update_bundle_with_identity_map( source, before: old_text, after: new_text, + insertion: Some(&insertion), old_aliases: &old_aliases, new_aliases: &new_aliases, legacy_value_paths: false, @@ -953,6 +995,7 @@ pub fn update_bundle_with_identity_map( source, before: &before, after: &after, + insertion: None, old_aliases: &old_aliases, new_aliases: &new_aliases, legacy_value_paths: false, @@ -1011,7 +1054,7 @@ pub fn update_bundle_with_identity_map( project, identities, &original_order, - &after, + &compact, limits, )?; let result = compile_bundle(&bundle, limits)?; @@ -1119,10 +1162,73 @@ fn inserted_value(text: &str, indent: &str, source: &str) -> String { 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, + omitted_fields: BTreeSet, +} +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> { source: &'a str, before: &'a str, after: &'a str, + insertion: Option<&'a InsertionSyntax<'a>>, old_aliases: &'a BTreeMap, new_aliases: &'a BTreeMap, // Source3 migration only adds path qualification; the existing field identity @@ -1131,6 +1237,16 @@ struct SyntaxUpdate<'a> { identity_origins: Option<&'a BTreeMap>, } 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( &self, actual: &BTreeMap<&str, Span>, @@ -1243,7 +1359,7 @@ impl SyntaxUpdate<'_> { _ => { erase_syntax(self.source, actual.span, edits); let text = inserted_value( - &self.after[after.span.start..after.span.end], + self.inserted(after), indent_at(self.source, actual.span.start), self.source, ); @@ -1308,6 +1424,9 @@ impl SyntaxUpdate<'_> { .iter() .filter(|(key, field)| { !actual.contains_key(*key) + && self.insertion.is_none_or(|syntax| { + !syntax.omitted_fields.contains(&field.value.span.start) + }) && before .and_then(|fields| fields.get(*key)) .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))); let mut text = String::new(); for (key, field) in added { - let value = inserted_value( - &self.after[field.value.span.start..field.value.span.end], - &indent, - self.source, - ); + let value = inserted_value(self.inserted(&field.value), &indent, self.source); if multiline { text.push_str(&indent); text.push_str(&format!("{key} {value};{}", newline(self.source))); @@ -1450,11 +1565,7 @@ impl SyntaxUpdate<'_> { } else { text.push(' '); } - text.push_str(&inserted_value( - &self.after[next.span.start..next.span.end], - &indent, - self.source, - )); + text.push_str(&inserted_value(self.inserted(next), &indent, self.source)); suffix = ""; } if i + 1 < after.len() { diff --git a/crates/oa-core/src/source_project/source_migration.rs b/crates/oa-core/src/source_project/source_migration.rs index 05ce86d1..54d50df9 100644 --- a/crates/oa-core/src/source_project/source_migration.rs +++ b/crates/oa-core/src/source_project/source_migration.rs @@ -11,7 +11,10 @@ pub(super) fn upgrade( .as_ref() .expect("an explicit source migration report"); 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 .semantic_defaults .iter() @@ -59,6 +62,7 @@ pub(super) fn upgrade( source, before: source, after, + insertion: None, old_aliases: &aliases, new_aliases: &aliases, legacy_value_paths: true, diff --git a/crates/oa-core/tests/default_omission_admission.rs b/crates/oa-core/tests/default_omission_admission.rs new file mode 100644 index 00000000..d7a69251 --- /dev/null +++ b/crates/oa-core/tests/default_omission_admission.rs @@ -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, 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) -> 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", + ); + } +} diff --git a/crates/oa-core/tests/default_omission_fidelity.rs b/crates/oa-core/tests/default_omission_fidelity.rs new file mode 100644 index 00000000..65b99bdb --- /dev/null +++ b/crates/oa-core/tests/default_omission_fidelity.rs @@ -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 }), + .. + }, + .. + }) + )); +} diff --git a/crates/oa-core/tests/default_omission_output.rs b/crates/oa-core/tests/default_omission_output.rs new file mode 100644 index 00000000..ce5e464b --- /dev/null +++ b/crates/oa-core/tests/default_omission_output.rs @@ -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 { + 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 { + 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::>() + .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") + ); + } +} diff --git a/crates/oa-core/tests/default_omission_preservation.rs b/crates/oa-core/tests/default_omission_preservation.rs new file mode 100644 index 00000000..bd148c79 --- /dev/null +++ b/crates/oa-core/tests/default_omission_preservation.rs @@ -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, 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]); +} diff --git a/crates/oa-core/tests/default_omission_updates.rs b/crates/oa-core/tests/default_omission_updates.rs new file mode 100644 index 00000000..c718d21e --- /dev/null +++ b/crates/oa-core/tests/default_omission_updates.rs @@ -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() + ); +} diff --git a/crates/oa-core/tests/language.rs b/crates/oa-core/tests/language.rs index b598ed8a..d18394f8 100644 --- a/crates/oa-core/tests/language.rs +++ b/crates/oa-core/tests/language.rs @@ -3,6 +3,16 @@ use oa_core::{language, persistence, validation, *}; fn read(source: &str) -> Project { 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) { let errors = language::parse(source).expect_err(source); assert!( @@ -753,8 +763,8 @@ fn typed_events_vertices_and_transition_kinds_round_trip_in_both_layers() { source.push('}'); let model = read(&source); let canonical = language::format(&model); + assert!(!canonical.contains("transition_kind External;")); for fragment in [ - "transition_kind External;", "transition_kind Local;", "transition_kind Internal;", "triggers [@deadline, @assess];", @@ -776,6 +786,7 @@ fn typed_events_vertices_and_transition_kinds_round_trip_in_both_layers() { .find(|r| r.alias == "recover") .unwrap(); let RelationBody::StateTransition { + transition_kind, trigger, guard, effect, @@ -784,32 +795,42 @@ fn typed_events_vertices_and_transition_kinds_round_trip_in_both_layers() { else { panic!("expected transition") }; + assert_eq!(*transition_kind, TransitionKind::External); assert_eq!(trigger, "Operator confirms\nНе запускать код"); assert_eq!(guard, "available && status == \"Ready\""); 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 [ - canonical.replace("transition_kind External;", "transition_kind Unsupported;"), - canonical.replace( + replace_once( + &canonical, "triggers [@deadline, @assess];", "triggers [\"deadline\", @assess];", ), - canonical.replace( + replace_once( + &canonical, "triggers [@deadline, @assess];", "triggers [@deadline, @deadline];", ), - canonical.replace("effects [@assess];", "effects [@payload];"), - canonical.replace("kind After;", "kind Relative;"), - canonical.replace("items [@payload];", "items [@assess];"), + replace_once(&canonical, "effects [@assess];", "effects [@payload];"), + replace_once(&canonical, "kind After;", "kind Relative;"), + replace_once(&canonical, "items [@payload];", "items [@assess];"), ] { assert!( language::parse(&invalid).is_err(), "invalid typed state/event source accepted" ); } - let legacy = canonical - .replace("transition_kind External;", "") - .replace("triggers [@deadline, @assess];", "") - .replace("effects [@assess];", ""); + // Canonical source already omits External. Removing the supplied lists + // independently exercises their empty omission defaults and retained prose. + let legacy = replace_once(&canonical, "triggers [@deadline, @assess];", ""); + let legacy = replace_once(&legacy, "effects [@assess];", ""); let legacy = read(&legacy); let RelationBody::StateTransition { transition_kind, @@ -829,7 +850,7 @@ fn typed_events_vertices_and_transition_kinds_round_trip_in_both_layers() { (retained_trigger, retained_guard, retained_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()); let deadline = model .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" ); let canonical = language::format(&from_source); - assert!(canonical.contains("viewpoint MixedLegacy;")); + assert!(!canonical.contains("viewpoint MixedLegacy;")); for relation in from_source.relations.values() { if let RelationBody::StateTransition { transition_kind, diff --git a/crates/oa-core/tests/port_anchors.rs b/crates/oa-core/tests/port_anchors.rs index bb92f098..b0c70657 100644 --- a/crates/oa-core/tests/port_anchors.rs +++ b/crates/oa-core/tests/port_anchors.rs @@ -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); let text = &cleared.bundle.files["project.arcadia"]; 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( &cleared.decoded.project, &Command::SetPortAnchor { diff --git a/crates/oa-server/tests/http/port_anchors.rs b/crates/oa-server/tests/http/port_anchors.rs index 33c42456..9d9b017a 100644 --- a/crates/oa-server/tests/http/port_anchors.rs +++ b/crates/oa-server/tests/http/port_anchors.rs @@ -75,6 +75,164 @@ fn assert_pair(current: &StoredProject) { 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::().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] async fn port_anchor_http_commands_and_source_edits_preserve_per_view_state_after_restart() { let directory = tempfile::tempdir().unwrap();