diff --git a/.gitattributes b/.gitattributes index afc46b13..60b6b35b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -102,3 +102,4 @@ harness/experiments/default-policy-20260913/** -text whitespace=cr-at-eol harness/experiments/default-policy-20260913/**/*.log -whitespace harness/experiments/default-fidelity-20260913/** -text whitespace=cr-at-eol harness/experiments/default-fidelity-20260913/**/*.log -whitespace +harness/experiments/default-production-20260913/** -text whitespace=cr-at-eol 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 7197328c..5d416518 100644 --- a/crates/oa-core/src/lib.rs +++ b/crates/oa-core/src/lib.rs @@ -10,6 +10,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/tests/default_omission_policy.rs b/crates/oa-core/src/source_defaults_tests.rs similarity index 79% rename from crates/oa-core/tests/default_omission_policy.rs rename to crates/oa-core/src/source_defaults_tests.rs index df5751a3..049495e1 100644 --- a/crates/oa-core/tests/default_omission_policy.rs +++ b/crates/oa-core/src/source_defaults_tests.rs @@ -1,12 +1,158 @@ -//! Independent field-map oracles for an isolated source-7 policy experiment. -//! This does not wire the policy into the production formatter or source editor. -#[path = "../../../harness/prototypes/source-defaults-20260913.rs"] -mod policy; - -use oa_core::{Kind, RelationKind}; -use policy::{Context, Mode}; +//! 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)) } diff --git a/crates/oa-core/src/source_project.rs b/crates/oa-core/src/source_project.rs index 4d3e377f..55c82dc7 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,77 @@ 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 { + values: BTreeMap, + omitted_fields: BTreeSet, +} +impl InsertionSyntax { + fn new( + complete: &language::EditValue, + compact_values: &language::EditValue, + source: &str, + compact: &language::EditValue, + ) -> Self { + let mut result = Self { + values: BTreeMap::new(), + omitted_fields: BTreeSet::new(), + }; + result.collect(complete, compact_values, source); + 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, + source: &str, + ) { + self.values.insert( + complete.span.start, + source[compact.span.start..compact.span.end].to_owned(), + ); + 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, source); + } 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, source); + } + } + _ => {} + } + } +} + struct SyntaxUpdate<'a> { source: &'a str, before: &'a str, after: &'a str, + insertion: Option<&'a InsertionSyntax>, old_aliases: &'a BTreeMap, new_aliases: &'a BTreeMap, // Source3 migration only adds path qualification; the existing field identity @@ -1131,6 +1241,12 @@ 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(String::as_str) + .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_fidelity.rs b/crates/oa-core/tests/default_omission_fidelity.rs index e43eb2be..65b99bdb 100644 --- a/crates/oa-core/tests/default_omission_fidelity.rs +++ b/crates/oa-core/tests/default_omission_fidelity.rs @@ -1,8 +1,7 @@ -//! Full-model fidelity of the isolated field-deletion adapter over frozen input. -//! This does not install a formatter, exercise lossless model edits, or claim an -//! exhaustive constructor corpus. Legacy fixture migration uses the public parser. -#[path = "../../../harness/prototypes/source-defaults-render-probe-20260913.rs"] -mod render_probe; +//! 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; @@ -31,14 +30,46 @@ fn identity(project: &Project, alias: &str) -> Id { .0 } -fn assert_fidelity(label: &str, project: &Project) -> (Project, render_probe::Probe) { - let probe = render_probe::compact(project); - let canonical_model = read(&probe.canonical); - assert_eq!( - canonical_model, *project, - "{label}: original canonical baseline" - ); - let compact_model = read(&probe.compact); +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" @@ -46,7 +77,7 @@ fn assert_fidelity(label: &str, project: &Project) -> (Project, render_probe::Pr let input = SourceBundle { entry: "project.arcadia".into(), - files: BTreeMap::from([("project.arcadia".into(), probe.compact.clone())]), + files: BTreeMap::from([("project.arcadia".into(), flat.clone())]), }; let compiled = compile_bundle(&input, ProjectLimits::default()).unwrap(); assert_eq!( @@ -58,35 +89,61 @@ fn assert_fidelity(label: &str, project: &Project) -> (Project, render_probe::Pr "{label}: compact identities/source are stable" ); - let second = render_probe::compact(&compact_model); + assert_common_omissions(project, &compiled); assert_eq!( - second.canonical, probe.canonical, - "{label}: deterministic baseline" - ); - assert_eq!( - second.compact, probe.compact, - "{label}: compact/reparse is idempotent" - ); - assert_eq!( - second.removed_fields, probe.removed_fields, - "{label}: deterministic omission count" + language::format(&compact_model), + flat, + "{label}: flat idempotence" ); + + let modular = format_bundle(project); assert!( - probe.removed_fields >= 10, - "{label}: exercise nontrivial omission, got {}", - probe.removed_fields + 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!( - probe.compact.len() < probe.canonical.len(), - "{label}: source is smaller" + flat.len() <= measured_compact_bytes + 16, + "{label}: {} bytes exceeds compact evidence budget {}", + flat.len(), + measured_compact_bytes + 16 ); - eprintln!( - "fidelity {label}: {} -> {} bytes; {} fields removed", - probe.canonical.len(), - probe.compact.len(), - probe.removed_fields - ); - (compact_model, probe) + (compact_model, flat) } #[test] @@ -150,9 +207,9 @@ fn keyed_scenarios_records_and_historical_binding_annotation_facets_roundtrip() KEYED.contains("uuid \""), "fixture contains historical identity atoms" ); - let (_, probe) = assert_fidelity("keyed-source5-history", &project); + let (_, source) = assert_fidelity("keyed-source5-history", &project); assert!( - probe.compact.contains("uuid \""), + source.contains("uuid \""), "historical atoms remain historical" ); } @@ -286,16 +343,20 @@ fn negative_zero_node_coordinate_survives_canonical_compact_and_bundle_compile() .get_mut(&node) .unwrap() .position = Position { x: -0.0, y: 0.0 }; - let (decoded, probe) = assert_fidelity("state-negative-zero", &project); - let canonical = read(&probe.canonical); + let (decoded, flat) = assert_fidelity("state-negative-zero", &project); let bundle = SourceBundle { entry: "project.arcadia".into(), - files: BTreeMap::from([("project.arcadia".into(), probe.compact)]), + 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 [&canonical, &decoded, &compiled.decoded.project] { + 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()); 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 index 8805bc70..bd148c79 100644 --- a/crates/oa-core/tests/default_omission_preservation.rs +++ b/crates/oa-core/tests/default_omission_preservation.rs @@ -1,9 +1,8 @@ //! Exact-source regressions for fields skipped by persistence serialization. //! -//! The ignored tests assert the desired behavior, not the observed defect. -//! Windows baseline-1 confirmed all three failures at exact-source comparison -//! after full model equality passed. Run with `--ignored` to recheck them, then -//! remove the ignores when the preservation fix lands. +//! 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. @@ -381,7 +380,6 @@ fn explicit_exchange_operation_none_survives_header_rename() { } #[test] -#[ignore = "Windows baseline-1 confirmed: Exchange binding edit erases explicit operation none despite correct full model; run --ignored until preservation is fixed"] fn explicit_exchange_operation_none_survives_exchange_binding_edit() { let mut expected = exchange_project(); let original = checked_fixture(EXCHANGE_SOURCE, &expected); @@ -426,7 +424,6 @@ fn explicit_node_empty_port_anchors_survive_header_alias_rename() { } #[test] -#[ignore = "Windows baseline-1 confirmed: Node position edit erases explicit port_anchors [] despite correct full model; run --ignored until preservation is fixed"] fn explicit_node_empty_port_anchors_survive_position_edit() { let mut expected = node_project(); let original = checked_fixture(NODE_SOURCE, &expected); @@ -460,7 +457,6 @@ fn explicit_trace_semantic_none_survives_header_rename() { } #[test] -#[ignore = "Windows baseline-1 confirmed: snapshot prose edit erases explicit semantic none despite correct full model; run --ignored until preservation is fixed"] fn explicit_trace_semantic_none_survives_snapshot_prose_edit() { let mut expected = trace_project(); let original = checked_fixture(TRACE_SOURCE, &expected); 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/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/docs/language.html b/docs/language.html index 3b7d79c0..21223ea7 100644 --- a/docs/language.html +++ b/docs/language.html @@ -2,7 +2,7 @@ - + Arcadia Studio OA / SA Language and Editing Contract