Files
ARCADIA-Studio/crates/oa-core/tests/language.rs
T
Codex e2ecfea69b
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
Checkpoint source 7 workflows and in-progress diagram authoring redesign
Preserve current core, source, REST, desktop, recovery, documentation and validation work, including AGENTS.md and the development summary. This is a work-in-progress checkpoint: the new compact palette has outstanding compilation errors and native acceptance; earlier passing gates retain their documented scope.
2026-09-10 10:06:55 -07:00

1278 lines
55 KiB
Rust

use oa_core::{language, persistence, validation, *};
fn read(source: &str) -> Project {
language::parse(source).unwrap_or_else(|e| panic!("{e:?}\n{source}"))
}
fn rejected(source: &str, fragment: &str) {
let errors = language::parse(source).expect_err(source);
assert!(
errors.iter().any(|e| e.message.contains(fragment)),
"Expected {fragment:?}: {errors:?}"
);
for e in errors {
assert!(e.line > 0 && e.column > 0);
assert!(source.is_char_boundary(e.span.start));
assert!(source.is_char_boundary(e.span.end));
}
}
fn complete_model() -> Project {
let mut p = read(
r##"
arcadia 2;
project mission "Harbor safety — 港 🌊" {
description "Need and selected system response.\nVerifiable in both editors.";
oa_entity coordination "Coordination" {}
oa_entity team "Response team" { parent @coordination; }
oa_actor operator_one "Operator" { parent @team; }
oa_role response_role "Response role" { parent @team; }
oa_activity operational_work "Operational work" {}
oa_activity report "Report event" { parent @operational_work; }
oa_activity assess "Assess event" { parent @operational_work; }
oa_capability safe_harbor "Safe harbor" {}
oa_capability routine_safety "Routine safety" {}
oa_capability emergency_safety "Emergency safety" {}
oa_mission operational_goal "Protect harbor users" {}
mission_capability operational_mission_support "Required capability" { source @operational_goal; target @safe_harbor; }
mission_involvement operational_mission_participant "Mission participant" { source @coordination; target @operational_goal; }
capability_include required_safety "Includes safety" { source @safe_harbor; target @routine_safety; }
capability_extend emergency_extension "Emergency extension" { source @emergency_safety; target @safe_harbor; condition "sea_state > 4 && operator == \"Ready\""; }
capability_generalization specialized_safety "Specializes safety" { source @emergency_safety; target @routine_safety; }
oa_process response_path "Response path" { steps [@first_exchange, @repeat_exchange]; }
oa_process reviewed_path "Reviewed response" { steps [@review_start, @review_response]; }
oa_occurrence review_start "Assess before response" { parent @reviewed_path; body { Action { action @assess; lifeline none; }; }; }
oa_occurrence review_response "Reuse response path" { parent @reviewed_path; body { SequenceReference { sequence @response_path; }; }; }
sequence_link reviewed_follow "Continue response" { source @review_start; target @review_response; guard "assessment == \"Approved\""; }
allocation reporting "Performs" { source @operator_one; target @report; }
allocation assessment "Performs" { source @coordination; target @assess; }
role_realization responsible "Realizes role" { source @coordination; target @response_role; }
role_assignment assigned_work "Assigned work" { source @response_role; target @assess; }
interaction event_report "Event report" { source @report; target @assess; items [@operational_information]; }
capability_involvement support_work "Supports" { source @assess; target @safe_harbor; }
chain_involvement support_path "Illustrates" { source @response_path; target @safe_harbor; }
oa_occurrence first_exchange "First report" { parent @response_path; body { Exchange { exchange @event_report; sender none; receiver none; }; }; }
oa_occurrence repeat_exchange "Repeated report" { parent @response_path; body { Exchange { exchange @event_report; sender none; receiver none; }; }; }
oa_exchange_item operational_information "Operational report" { kind Information; }
oa_event incoming_report "Report arrived" { body { Signal { items [@operational_information]; }; }; }
oa_data_type operational_record "Operational record" { definition Record; }
oa_data_type weather_state "Weather classification" { definition { Enumeration { literals ["Clear", "Fog", "Storm"]; }; }; }
oa_data_field weather_field "Weather" { parent @operational_record; type_ref { Named @weather_state; }; minimum 0; maximum none; unit ""; }
oa_interface operational_contract "Operational communication" { items [@operational_information]; }
oa_port operational_port "Report channel" { parent @coordination; direction InOut; interface @operational_contract; }
oa_scenario operational_story "Operational story" { context @safe_harbor; steps []; }
oa_state_machine operational_modes "Operational modes" { parent @coordination; }
oa_region operational_region "Operational region" { parent @operational_modes; }
oa_initial operational_initial "Start" { parent @operational_region; }
oa_mode operational_normal "Normal" { parent @operational_region; entry "Begin watch"; exit "End watch"; do_activity "Coordinate"; }
state_transition operational_start "Start watch" { source @operational_initial; target @operational_normal; }
availability operational_available "Available" { source @assess; target @operational_normal; }
state_action operational_do "Perform assessment" { source @operational_normal; target @assess; phase Do; }
oa_constraint operational_limit "Visibility limit" { expression "visibility >= threshold"; constrained [@assess, @event_report]; }
sa_system safety_system "Harbor safety system" {}
sa_actor duty_officer "Duty officer" {}
sa_function system_service "Provide event support" {}
sa_function detect "Detect event" { parent @system_service; }
sa_function notify_officer "Notify operator" { parent @system_service; }
sa_capability supported_response "Supported response" {}
sa_mission mission_goal "Protect harbor operations" {}
mission_involvement system_mission_participant "System mission participant" { source @duty_officer; target @mission_goal; }
sa_functional_chain selected_chain "Detection to notification" { steps [@system_exchange_occurrence]; }
allocation system_detection "Performs" { source @safety_system; target @detect; }
allocation operator_notice "Performs" { source @duty_officer; target @notify_officer; }
sa_exchange_item system_information "Event information" { kind Event; }
sa_interface notification_interface "Notification contract" { items [@system_information]; }
sa_port detect_out "Detected event" { parent @detect; direction Out; }
sa_port notice_in "Event input" { parent @notify_officer; direction In; }
sa_port system_out "System output" { parent @safety_system; direction Out; interface @notification_interface; }
sa_port officer_in "Operator input" { parent @duty_officer; direction In; interface @notification_interface; }
functional_exchange detected_event "Detected event" { source @detect; target @notify_officer; source_port @detect_out; target_port @notice_in; items [@system_information]; }
boundary_exchange boundary_notice "Boundary notice" { source @system_out; target @officer_in; items [@system_information]; }
exchange_allocation carried_notice "Carried exchange" { source @boundary_notice; target @detected_event; }
interface_provision provides_notice "Provides" { source @safety_system; target @notification_interface; }
interface_requirement requires_notice "Requires" { source @duty_officer; target @notification_interface; }
mission_capability mission_support "Uses capability" { source @mission_goal; target @supported_response; }
capability_involvement system_support "Supports" { source @detect; target @supported_response; }
chain_involvement chain_support "Illustrates" { source @selected_chain; target @supported_response; }
sa_occurrence system_exchange_occurrence "Event occurrence" { parent @selected_chain; body { Exchange { exchange @detected_event; sender none; receiver none; }; }; }
sa_data_type distance "Distance" { definition Real; }
sa_data_type event_record "Event record" { definition Record; }
sa_data_field named_field "Measured range" { parent @event_record; type_ref { Named @distance; }; minimum 0; maximum none; unit "m"; }
sa_state_machine system_modes "System modes" { parent @safety_system; }
sa_region system_region "System region" { parent @system_modes; }
sa_initial initial_mode "Initial" { parent @system_region; }
sa_mode normal_mode "Normal" { parent @system_region; entry "Prepare"; exit "Release"; do_activity "Monitor"; }
sa_state degraded_state "Degraded" { parent @system_region; }
sa_final final_mode "Stopped" { parent @system_region; }
state_transition initialize "Initialize" { source @initial_mode; target @normal_mode; }
state_transition degrade "Degrade" { source @normal_mode; target @degraded_state; trigger "sensor failure"; guard "redundancy exhausted"; effect "Annunciate degraded service"; }
state_transition stop_system "Stop" { source @degraded_state; target @final_mode; trigger "shutdown"; }
availability detection_available "Available" { source @detect; target @normal_mode; }
state_action prepare_detection "Entry work" { source @normal_mode; target @detect; phase Entry; }
state_action release_detection "Exit work" { source @normal_mode; target @detect; phase Exit; }
sa_constraint latency_limit "Latency" { expression "notification latency <= 2 s"; constrained [@detected_event]; }
sa_scenario system_story "System story" { context @supported_response; precondition "Watch is active"; postcondition "Operator informed"; steps [@story_exchange, @story_action, @story_state, @alternative_flow]; }
sa_lifeline detector_line "Detector" { parent @system_story; represented @detect; }
sa_lifeline receiver_line "Receiver" { parent @system_story; represented @notify_officer; }
sa_lifeline system_line "System" { parent @system_story; represented @safety_system; }
sa_occurrence story_exchange "Send report" { parent @system_story; body { Exchange { exchange @detected_event; sender @detector_line; receiver @receiver_line; }; }; precondition "Event accepted"; postcondition "Report sent"; }
sa_occurrence story_action "Detect" { parent @system_story; body { Action { action @detect; lifeline @detector_line; }; }; }
sa_occurrence story_state "Normal mode" { parent @system_story; body { State { state @normal_mode; lifeline @system_line; }; }; }
sa_occurrence alternative_flow "Alternative" { parent @system_story; body { Fragment { operator Alternative; branches [@good_branch, @bad_branch]; }; }; }
sa_occurrence good_branch "Good branch" { parent @alternative_flow; body { Branch { guard "valid event"; occurrences [@branch_action]; }; }; }
sa_occurrence bad_branch "Bad branch" { parent @alternative_flow; body { Branch { guard "invalid event"; occurrences []; }; }; }
sa_occurrence branch_action "Acknowledge" { parent @good_branch; body { Action { action @notify_officer; lifeline @receiver_line; }; }; }
diagram operational_view "OA view" { layer Oa; projection Allocation; }
diagram system_view "SA view" { layer Sa; projection Decomposition; }
diagram trace_view "Trace view" { layer Oa; projection Traceability; }
node report_view { diagram @operational_view; element @report; position { x -0.0; y 12.375; }; size { width 260.0; height 94.0; }; collapsed true; }
node detect_view { diagram @system_view; element @detect; }
node trace_oa_view { diagram @trace_view; element @report; }
node trace_sa_view { diagram @trace_view; element @detect; }
trace work_trace "Operational realization" { oa @report; sa @detect; kind Refinement; baseline { oa { name "Report event"; description "Operational intent"; }; sa { name "Detect event"; description "Selected service"; }; }; }
trace participant_trace "Participant realization" { oa @coordination; sa @safety_system; kind Participant; }
trace goal_trace "Capability realization" { oa @safe_harbor; sa @supported_response; kind Capability; }
trace mission_trace "Mission realization" { oa @operational_goal; sa @mission_goal; kind Mission; }
trace exchange_trace "Exchange realization" { oa @event_report; sa @detected_event; kind Exchange; }
trace path_trace "Path realization" { oa @response_path; sa @selected_chain; kind Sequence; }
trace information_trace "Information realization" { oa @operational_information; sa @system_information; kind Item; }
trace scenario_trace "Scenario realization" { oa @operational_story; sa @system_story; kind Scenario; }
trace mode_trace "Mode realization" { oa @operational_normal; sa @normal_mode; kind State; }
trace manual_trace "Engineer rationale" { oa @assess; sa @mission_goal; kind Manual; }
artifact design_record "Design decision" { owner @work_trace; relation DerivedFrom; uri "docs/decision%20record.md"; revision "r42"; fragment "#operational-need"; }
}
"##,
);
let state_region = p
.elements
.values()
.find(|e| e.alias == "system_region")
.unwrap()
.id;
for kind in [
Kind::Choice,
Kind::Junction,
Kind::Fork,
Kind::Join,
Kind::ShallowHistory,
Kind::DeepHistory,
Kind::EntryPoint,
Kind::ExitPoint,
Kind::Terminate,
] {
p.add_element(kind, Layer::Sa, kind.label(), Some(state_region))
.unwrap();
}
let record = p
.elements
.values()
.find(|e| e.alias == "event_record")
.unwrap()
.id;
let associated = p
.add_element(Kind::DataType, Layer::Sa, "Associated record", None)
.unwrap();
if let Some(SharedBody::DataType { definition, .. }) =
p.elements.get_mut(&associated).unwrap().shared_mut()
{
*definition = DataTypeDefinition::Record { supertype: None };
}
p.relate(
RelationKind::DataAssociation,
"Related record",
record,
associated,
);
for (index, type_ref) in [
TypeRef::Boolean,
TypeRef::Integer,
TypeRef::Real,
TypeRef::String,
]
.into_iter()
.enumerate()
{
let id = p
.add_element(
Kind::DataField,
Layer::Sa,
&format!("Field {index}"),
Some(record),
)
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataField {
type_ref,
minimum: 1,
maximum: Some(5),
unit: "unit".into(),
direction: ParameterDirection::Unspecified,
storage: FieldStorage::Value,
default: None,
};
}
for definition in [
DataTypeDefinition::Boolean,
DataTypeDefinition::Integer {
minimum: None,
maximum: None,
},
DataTypeDefinition::String {
minimum_length: 0,
maximum_length: None,
},
] {
let id = p
.add_element(Kind::DataType, Layer::Sa, "Primitive type", None)
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition,
default: None,
unit: String::new(),
};
}
for kind in [
ExchangeItemKind::Material,
ExchangeItemKind::Energy,
ExchangeItemKind::Information,
] {
let id = p
.add_element(
Kind::ExchangeItem,
Layer::Sa,
"Physical exchange item",
None,
)
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
kind,
mechanism: ExchangeMechanism::Unspecified,
supertype: None,
};
}
let scenario = p
.elements
.values()
.find(|e| e.alias == "system_story")
.unwrap()
.id;
for operator in [
FragmentOperator::Parallel,
FragmentOperator::Loop,
FragmentOperator::Optional,
] {
let id = p
.add_element(
Kind::Occurrence,
Layer::Sa,
"Control fragment",
Some(scenario),
)
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Fragment {
operator,
branches: vec![],
},
precondition: String::new(),
postcondition: String::new(),
};
p.elements
.get_mut(&scenario)
.unwrap()
.steps_mut()
.unwrap()
.push(id);
}
let owner = p
.elements
.values()
.find(|e| e.alias == "safety_system")
.unwrap()
.id;
let function = p
.elements
.values()
.find(|e| e.alias == "detect")
.unwrap()
.id;
let component_port = Id::new_v4();
let function_port = Id::new_v4();
for (id, alias, parent, contract) in [
(
component_port,
"typed_component_port",
owner,
PortContract::Component {
kind: ComponentPortKind::Standard,
orientation: ComponentPortOrientation::Unset,
},
),
(
function_port,
"typed_function_output",
function,
PortContract::FunctionOutput {
outgoing_items: vec![],
},
),
] {
p.elements.insert(
id,
Element {
id,
alias: alias.into(),
name: alias.into(),
description: String::new(),
parent: Some(parent),
body: ElementBody::shared_in(
Layer::Sa,
SharedBody::Port {
contract,
legacy: None,
},
),
},
);
}
p.relate(
RelationKind::PortAllocation,
"Typed port allocation",
component_port,
function_port,
);
let participants: Vec<_> = p
.elements
.values()
.filter(|e| matches!(e.kind(), Kind::Entity | Kind::Actor))
.map(|e| e.id)
.collect();
let means = p.relate(
RelationKind::CommunicationMean,
"Operational communication",
participants[0],
participants[1],
);
let category = p
.add_element(
Kind::ExchangeCategory,
Layer::Oa,
"Operational exchange category",
None,
)
.unwrap();
*p.elements.get_mut(&category).unwrap().shared_mut().unwrap() = SharedBody::ExchangeCategory {
family: oa_core::view_options::ExchangeFamily::Participant,
exchanges: vec![means],
};
let caller = p
.add_element(Kind::Process, Layer::Oa, "Common work caller", None)
.unwrap();
let work = p
.elements
.values()
.find(|e| e.kind() == Kind::Activity)
.unwrap()
.id;
let mut occurrences = vec![];
for name in ["First contribution", "Second contribution"] {
let id = p
.add_element(Kind::Occurrence, Layer::Oa, name, Some(caller))
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Action {
action: Some(work),
lifeline: None,
},
precondition: String::new(),
postcondition: String::new(),
};
occurrences.push(id);
}
*p.elements.get_mut(&caller).unwrap().steps_mut().unwrap() = occurrences.clone();
let id = p.relate(
RelationKind::ContextualLink,
"Shared work incidence",
occurrences[0],
occurrences[1],
);
p.relations.get_mut(&id).unwrap().body = RelationBody::ContextualLink {
owner: caller,
source: OccurrenceEndpoint {
references: vec![],
occurrence: occurrences[0],
},
target: OccurrenceEndpoint {
references: vec![],
occurrence: occurrences[1],
},
purpose: ContextualLinkPurpose::CommonWork { work },
};
p
}
#[test]
fn all_typed_fields_round_trip_with_stable_aliases_and_occurrences() {
let p = complete_model();
let text = language::format(&p);
let parsed = read(&text);
assert_eq!(parsed, p);
assert_eq!(language::format(&parsed), text);
assert!(!text.contains("schema_version"));
assert!(text.contains("parent @operational_work;"));
assert!(text.contains("Named @distance;"));
assert!(text.contains("node report_view id \""));
assert!(text.contains("body {\n"));
for kind in Kind::ALL {
assert!(
p.elements.values().any(|element| element.kind() == kind),
"missing {kind}"
);
}
for kind in RelationKind::ALL {
assert!(
p.relations.values().any(|r| r.kind() == kind),
"missing {kind}"
);
}
let old_aliases = p
.identities()
.into_iter()
.map(|(id, a)| (id, a.to_owned()))
.collect::<Vec<_>>();
let mut renamed = p.clone();
for e in renamed.elements.values_mut() {
e.name.push_str(" renamed");
}
assert_eq!(
renamed
.identities()
.into_iter()
.map(|(id, a)| (id, a.to_owned()))
.collect::<Vec<_>>(),
old_aliases
);
assert_eq!(read(&language::format(&renamed)), renamed);
}
#[test]
fn branching_reused_processes_and_chains_have_lossless_readable_source() {
use std::fmt::Write;
for (prefix, work, sequence) in [
("oa", "activity", "process"),
("sa", "function", "functional_chain"),
] {
let mut source = format!(
"arcadia 2; project topology \"Contextual topology\" {{\n{prefix}_{work} prepare \"Prepare\" {{}}\n{prefix}_{work} assess \"Assess\" {{}}\n"
);
writeln!(
source,
"{prefix}_{sequence} reusable \"Reusable assessment\" {{ steps [@nested_work]; }}"
)
.unwrap();
writeln!(source, "{prefix}_occurrence nested_work \"Assessment contribution\" {{ parent @reusable; body {{ Action {{ action @assess; lifeline none; }}; }}; }}").unwrap();
writeln!(source, "{prefix}_{sequence} flow \"Reviewed process\" {{ steps [@entry, @forked, @left, @right, @joined, @choice, @merged, @looped, @reused, @finish]; }}").unwrap();
for (alias, action) in [
("entry", "prepare"),
("left", "prepare"),
("right", "assess"),
("finish", "assess"),
] {
writeln!(source, "{prefix}_occurrence {alias} \"{alias}\" {{ parent @flow; body {{ Action {{ action @{action}; lifeline none; }}; }}; }}").unwrap();
}
for (alias, kind) in [
("forked", "Fork"),
("joined", "Join"),
("choice", "Choice"),
("merged", "Merge"),
("looped", "Loop"),
] {
writeln!(source, "{prefix}_occurrence {alias} \"{kind}\" {{ parent @flow; body {{ Control {{ kind {kind}; }}; }}; }}").unwrap();
}
writeln!(source, "{prefix}_occurrence reused \"Reuse assessment\" {{ parent @flow; body {{ SequenceReference {{ sequence @reusable; }}; }}; }}").unwrap();
for (index, (from, to, guard)) in [
("entry", "forked", ""),
("forked", "left", ""),
("forked", "right", ""),
("left", "joined", ""),
("right", "joined", ""),
("joined", "choice", ""),
("choice", "looped", "sea_state > 4 && operator == \"Ready\""),
("choice", "merged", "else"),
("looped", "merged", "assessment accepted"),
("looped", "reused", "repeat assessment"),
("reused", "looped", ""),
("merged", "finish", ""),
]
.into_iter()
.enumerate()
{
writeln!(source, "sequence_link path_{index} \"Path {index}\" {{ source @{from}; target @{to}; guard {}; }}", serde_json::to_string(guard).unwrap()).unwrap();
}
source.push('}');
let model = read(&source);
let canonical = language::format(&model);
assert!(canonical.contains("sequence @reusable;"));
assert!(canonical.contains("kind Loop;"));
assert!(canonical.contains("operator == \\\"Ready\\\""));
assert_eq!(read(&canonical), model);
assert_eq!(
persistence::decode(&serde_json::to_vec(&model).unwrap()).unwrap(),
model
);
let mut broken = canonical.replace("sequence @reusable;", "sequence @flow;");
assert!(
language::parse(&broken)
.unwrap_err()
.iter()
.any(|d| d.code == "recursive_sequence_reference")
);
broken = canonical.replace("sequence @reusable;", "sequence \"reusable\";");
assert!(
language::parse(&broken).is_err(),
"identity references must use @alias"
);
}
}
#[test]
fn authoring_defaults_and_forward_references_remain_strict() {
let p = read(
r#"arcadia 2; project mission "New" { allocation assigned "Performs" { source @crew; target @work; } oa_activity work "Work" {} oa_entity crew "Crew" {} }"#,
);
assert_eq!(p.elements.len(), 2);
assert!(
p.elements
.values()
.all(|e| e.description.is_empty() && e.parent.is_none())
);
assert_eq!(p, read(&language::format(&p)));
let warnings = validation::validate(&read(
"arcadia 2; project mission \"Draft\" { sa_function unfinished \"Unallocated\" {} }",
));
assert!(
warnings
.iter()
.any(|i| i.severity == validation::Severity::Warning)
);
}
#[test]
fn typed_unknown_fields_enums_duplicates_and_reference_spelling_are_rejected() {
for (source, expected) in [
("arcadia 8; project mission \"x\" {}", "Unsupported schema"),
(
"arcadia 2; project mission \"x\" { oa_activity a \"A\" { nonsense 1; } }",
"Unknown field",
),
(
"arcadia 2; project mission \"x\" { oa_activity a \"A\" { description \"A\"; description \"B\"; } }",
"Duplicate field",
),
(
"arcadia 2; project mission \"x\" { oa_activity a \"A\" { description unquoted; } }",
"quoted string",
),
(
"arcadia 2; project mission \"x\" { oa_activity a \"A\" { description @mission; } }",
"does not accept an @alias",
),
(
"arcadia 2; project mission \"x\" { oa_activity a \"A\" { parent @missing; } }",
"Unknown reference",
),
(
"arcadia 2; project mission \"x\" { oa_activity a \"A\" { parent \"00000000-0000-0000-0000-000000000000\"; } }",
"requires an @alias",
),
(
"arcadia 2; project mission \"x\" { sa_exchange_item item_x \"Item\" { kind Unicorn; } }",
"unknown variant",
),
(
"arcadia 2; project mission \"x\" { sa_exchange_item item_x \"Item\" { kind \"Information\"; } }",
"unquoted enum",
),
(
"arcadia 2; project mission \"x\" { sa_data_type t \"Type\" { definition { Enumeration { literals [\"One\"]; surprise true; }; }; } }",
"unknown field",
),
(
"arcadia 2; project mission \"x\" { oa_actor shared \"A\" {} oa_activity shared \"B\" {} }",
"Duplicate alias",
),
(
"arcadia 2; project mission \"x\" { oa_actor a \"A\" {} allocation x \"X\" { source @a; } }",
"Missing required field",
),
] {
rejected(source, expected);
}
}
#[test]
fn deeply_nested_invalid_draft_returns_a_diagnostic_without_exhausting_the_stack() {
let source = format!(
"arcadia 2; project mission \"x\" {{ oa_activity a \"A\" {{ parent {}none{}; }} }}",
"[".repeat(5000),
"]".repeat(5000)
);
rejected(&source, "nest more than 64 levels");
let analysis = language::analyze(&source);
assert_eq!(analysis.declarations.len(), 2);
assert!(analysis.diagnostics[0].span.start < source.len());
}
#[test]
fn global_identity_and_alias_collisions_include_diagram_nodes_and_project() {
rejected(
r#"arcadia 2; project mission "x" id "00000000-0000-0000-0000-000000000001" { oa_activity a "A" id "00000000-0000-0000-0000-000000000001" {} }"#,
"Duplicate identity",
);
rejected(
r#"arcadia 2; project mission "x" { diagram view_one "View" {} oa_activity a "A" {} node a { diagram @view_one; element @a; } }"#,
"Duplicate alias",
);
rejected(
r#"arcadia 2; project mission "x" { oa_activity mission "A" {} }"#,
"Duplicate alias",
);
}
#[test]
fn typed_scenario_messages_values_and_reuse_round_trip_in_both_work_perspectives() {
for (prefix, work, exchange, viewpoint) in [
("oa", "activity", "interaction", "OperationalActivity"),
("sa", "function", "functional_exchange", "Functional"),
] {
let source = format!(
r#"arcadia 2; project messages "Typed exchanges and reuse" {{
{prefix}_{work} send_work "Send request" {{}}
{prefix}_{work} receive_work "Handle request" {{}}
{prefix}_exchange_item payload "Request payload" {{}}
{prefix}_data_field payload_value "Requested depth" {{ parent @payload; type_ref Real; unit "m"; }}
{exchange} request_exchange "Request" {{ source @send_work; target @receive_work; items [@payload]; }}
{prefix}_scenario reusable_story "Reusable request" {{ viewpoint {viewpoint}; steps [@nested_message]; }}
{prefix}_lifeline nested_sender "Sender" {{ parent @reusable_story; represented @send_work; }}
{prefix}_lifeline nested_receiver "Receiver" {{ parent @reusable_story; represented @receive_work; }}
{prefix}_occurrence nested_message "Asynchronous request" {{ parent @reusable_story; body {{ Exchange {{ exchange @request_exchange; sender @nested_sender; receiver @nested_receiver; message_kind Asynchronous; reply_to none; values []; }}; }}; }}
{prefix}_scenario story "Request lifecycle" {{ viewpoint {viewpoint}; steps [@created, @called, @replied, @waited, @reused_story, @deleted]; }}
{prefix}_lifeline sender_line "Requester" {{ parent @story; represented @send_work; }}
{prefix}_lifeline receiver_line "Responder" {{ parent @story; represented @receive_work; }}
{prefix}_occurrence created "Create responder" {{ parent @story; body {{ Exchange {{ exchange @request_exchange; sender @sender_line; receiver @receiver_line; message_kind Create; reply_to none; values []; }}; }}; }}
{prefix}_occurrence called "Call responder" {{ parent @story; body {{ Exchange {{ exchange @request_exchange; sender @sender_line; receiver @receiver_line; message_kind Call; reply_to none; values [{{ field @payload_value; expression "requested_depth + 2"; }}]; }}; }}; }}
{prefix}_occurrence replied "Return response" {{ parent @story; body {{ Exchange {{ exchange @request_exchange; sender @receiver_line; receiver @sender_line; message_kind Reply; reply_to @called; values []; }}; }}; }}
{prefix}_occurrence waited "Wait for next request" {{ parent @story; body {{ Exchange {{ exchange none; sender @sender_line; receiver none; message_kind {{ Timer {{ expression "after 5 s"; }}; }}; reply_to none; values []; }}; }}; }}
{prefix}_occurrence reused_story "Reuse request" {{ parent @story; body {{ ScenarioUse {{ scenario @reusable_story; bindings [{{ referenced @nested_sender; local @sender_line; }}, {{ referenced @nested_receiver; local @receiver_line; }}]; }}; }}; }}
{prefix}_occurrence deleted "Delete responder" {{ parent @story; body {{ Exchange {{ exchange @request_exchange; sender @sender_line; receiver @receiver_line; message_kind Delete; reply_to none; values []; }}; }}; }}
}}"#
);
let model = read(&source);
let canonical = language::format(&model);
for field in [
"reply_to @called;",
"field @payload_value;",
"scenario @reusable_story;",
"referenced @nested_sender;",
"local @sender_line;",
] {
assert!(
canonical.contains(field),
"missing readable reference {field}"
);
}
assert_eq!(read(&canonical), model);
assert_eq!(
persistence::decode(&serde_json::to_vec(&model).unwrap()).unwrap(),
model
);
assert!(
language::parse(&canonical.replace("reply_to @called;", "reply_to @created;")).is_err()
);
assert!(
language::parse(&canonical.replace("scenario @reusable_story;", "scenario @story;"))
.is_err()
);
let foreign_viewpoint = if prefix == "oa" {
"Functional"
} else {
"OperationalActivity"
};
assert!(
language::parse(&canonical.replace(
&format!("viewpoint {viewpoint};"),
&format!("viewpoint {foreign_viewpoint};")
))
.is_err()
);
}
}
#[test]
fn typed_events_vertices_and_transition_kinds_round_trip_in_both_layers() {
use std::fmt::Write;
for (prefix, participant, work) in [("oa", "entity", "activity"), ("sa", "system", "function")]
{
let mut source = format!(
r#"arcadia 2; project recovery "Recovery — безопасно" {{
{prefix}_{participant} owner "Owner" {{}}
{prefix}_{work} assess "Assess recovery" {{}}
{prefix}_exchange_item payload "Recovery report" {{}}
{prefix}_event report "Report received" {{ body {{ Signal {{ items [@payload]; }}; }}; }}
{prefix}_event deadline "Recovery deadline" {{ body {{ Time {{ kind After; expression "30 s"; }}; }}; }}
{prefix}_event scheduled "Planned handover" {{ body {{ Time {{ kind At; expression "2026-09-05T18:00:00Z"; }}; }}; }}
{prefix}_event changed "Pressure recovered" {{ body {{ Change {{ expression "pressure > 4 && status == \"Ready\""; }}; }}; }}
{prefix}_state_machine machine "Recovery machine" {{ parent @owner; }}
{prefix}_region region "Main region" {{ parent @machine; }}
{prefix}_initial initial "Initial" {{ parent @region; }}
{prefix}_state idle "Idle" {{ parent @region; }}
{prefix}_state active "Active" {{ parent @region; }}
{prefix}_region nested "Active region" {{ parent @active; }}
{prefix}_state recovering "Recovering" {{ parent @nested; }}
state_transition start "Start" {{ source @initial; target @idle; }}
state_transition recover "Recover" {{ source @idle; target @active; transition_kind External; triggers [@deadline, @assess]; effects [@assess]; trigger "Operator confirms\nНе запускать код"; guard "available && status == \"Ready\""; effect "Record the assessment"; }}
state_transition keep_active "Handle report" {{ source @active; target @active; transition_kind Internal; triggers [@report, @scheduled, @changed]; effects []; }}
state_transition enter_recovery "Enter recovery" {{ source @active; target @recovering; transition_kind Local; triggers []; effects []; }}
"#
);
for stem in [
"choice",
"junction",
"fork",
"join",
"shallow_history",
"deep_history",
"entry_point",
"exit_point",
"terminate",
] {
writeln!(
source,
"{prefix}_{stem} vertex_{stem} \"{stem}\" {{ parent @region; }}"
)
.unwrap();
}
source.push('}');
let model = read(&source);
let canonical = language::format(&model);
for fragment in [
"transition_kind External;",
"transition_kind Local;",
"transition_kind Internal;",
"triggers [@deadline, @assess];",
"effects [@assess];",
"items [@payload];",
"kind After;",
"kind At;",
] {
assert!(canonical.contains(fragment), "missing {fragment}");
}
assert_eq!(read(&canonical), model);
assert_eq!(
persistence::decode(&serde_json::to_vec(&model).unwrap()).unwrap(),
model
);
let transition = model
.relations
.values()
.find(|r| r.alias == "recover")
.unwrap();
let RelationBody::StateTransition {
trigger,
guard,
effect,
..
} = &transition.body
else {
panic!("expected transition")
};
assert_eq!(trigger, "Operator confirms\nНе запускать код");
assert_eq!(guard, "available && status == \"Ready\"");
assert_eq!(effect, "Record the assessment");
for invalid in [
canonical.replace("transition_kind External;", "transition_kind Unsupported;"),
canonical.replace(
"triggers [@deadline, @assess];",
"triggers [\"deadline\", @assess];",
),
canonical.replace(
"triggers [@deadline, @assess];",
"triggers [@deadline, @deadline];",
),
canonical.replace("effects [@assess];", "effects [@payload];"),
canonical.replace("kind After;", "kind Relative;"),
canonical.replace("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];", "");
let legacy = read(&legacy);
let RelationBody::StateTransition {
transition_kind,
triggers,
effects,
trigger: retained_trigger,
guard: retained_guard,
effect: retained_effect,
..
} = &legacy.relations[&transition.id].body
else {
panic!("expected transition")
};
assert_eq!(*transition_kind, TransitionKind::External);
assert!(triggers.is_empty() && effects.is_empty());
assert_eq!(
(retained_trigger, retained_guard, retained_effect),
(trigger, guard, effect)
);
let draft = canonical.replace("expression \"30 s\";", "expression \"\";");
assert!(language::parse(&draft).is_ok());
let deadline = model
.elements
.values()
.find(|e| e.alias == "deadline")
.unwrap()
.id;
let diagnostics = language::lint(&draft);
let warning = diagnostics
.iter()
.find(|d| d.code == "missing_event_expression")
.unwrap();
assert_eq!(warning.severity, validation::Severity::Warning);
assert_eq!(warning.object_id, Some(deadline));
assert!(warning.line > 0 && warning.column > 0);
}
}
#[test]
fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensions() {
// Golden V2 files predate explicit viewpoints and message semantics. Do not
// regenerate these fixtures from the current demo or canonical printer.
let old_source = include_str!("fixtures/v2-coastal-rescue.arcadia");
let old_json = include_bytes!("fixtures/v2-coastal-rescue.json");
assert!(!old_source.contains("viewpoint"));
assert!(!old_source.contains("message_kind"));
assert!(!old_source.contains("transition_kind"));
let from_source = read(old_source);
let from_json = persistence::decode(old_json).unwrap();
assert_eq!(from_source, from_json);
let original: serde_json::Value = serde_json::from_slice(old_json).unwrap();
let mut retained = serde_json::to_value(&from_json).unwrap();
assert_eq!(retained["schema_version"], oa_core::SCHEMA_VERSION);
retained["schema_version"] = 2.into();
let allocations: Vec<_> = from_json
.relations
.values()
.filter_map(|relation| {
if let RelationBody::InterfaceItemAllocation {
source,
target,
order,
..
} = relation.body
{
Some((source, target, order))
} else {
None
}
})
.collect();
for element in retained["elements"].as_object_mut().unwrap().values_mut() {
let element_id: Id = element["id"].as_str().unwrap().parse().unwrap();
for layer in ["Oa", "Sa"] {
let Some(shared) = element["body"]
.get_mut(layer)
.and_then(|body| body.get_mut("Shared"))
else {
continue;
};
if let Some(port) = shared.get_mut("Port") {
assert_eq!(port["contract"], "Legacy");
*port = port["legacy"].clone();
}
if let Some(item) = shared
.get_mut("ExchangeItem")
.and_then(serde_json::Value::as_object_mut)
{
if item["mechanism"] == "Event" {
item.insert("kind".into(), "Event".into());
}
item.remove("mechanism");
item.remove("supertype");
}
if let Some(kind) = shared
.get_mut("DataType")
.and_then(serde_json::Value::as_object_mut)
{
kind.remove("default");
kind.remove("unit");
let definition = &kind["definition"];
if let Some(name) = ["Integer", "Real", "String", "Record"]
.into_iter()
.find(|name| definition.get(name).is_some())
{
kind.insert("definition".into(), name.into());
}
}
if let Some(field) = shared
.get_mut("DataField")
.and_then(serde_json::Value::as_object_mut)
{
for key in ["direction", "storage", "default"] {
field.remove(key);
}
}
if let Some(interface) = shared.get_mut("Interface") {
let mut entries: Vec<_> = allocations
.iter()
.filter(|(source, _, _)| *source == element_id)
.collect();
entries.sort_by_key(|(_, _, order)| *order);
*interface = serde_json::json!({"items":entries.into_iter().map(|(_,target,_)|target).collect::<Vec<_>>()});
}
if let Some(scenario) = shared
.get_mut("Scenario")
.and_then(serde_json::Value::as_object_mut)
{
scenario.remove("viewpoint");
}
if let Some(exchange) = shared
.get_mut("Occurrence")
.and_then(|body| body.get_mut("body"))
.and_then(|body| body.get_mut("Exchange"))
.and_then(serde_json::Value::as_object_mut)
{
for added in ["message_kind", "reply_to", "values"] {
exchange.remove(added);
}
}
}
}
retained["relations"]
.as_object_mut()
.unwrap()
.retain(|_, relation| relation["body"].get("InterfaceItemAllocation").is_none());
for relation in retained["relations"].as_object_mut().unwrap().values_mut() {
if let Some(boundary) = relation["body"].get_mut("BoundaryExchange") {
assert_eq!(boundary["oriented"], true);
boundary.as_object_mut().unwrap().remove("oriented");
}
if let Some(transition) = relation["body"]
.get_mut("StateTransition")
.and_then(serde_json::Value::as_object_mut)
{
for added in ["transition_kind", "triggers", "effects"] {
transition.remove(added);
}
}
}
for diagram in retained["diagrams"].as_object_mut().unwrap().values_mut() {
assert_eq!(
diagram["view_options"],
serde_json::to_value(oa_core::view_options::ViewOptions::default()).unwrap()
);
diagram.as_object_mut().unwrap().remove("view_options");
assert_eq!(diagram["routes"], serde_json::json!([]));
diagram.as_object_mut().unwrap().remove("routes");
}
assert_eq!(
retained, original,
"every original V2 field survives; only explicit new compatibility defaults are added"
);
assert_eq!(from_json.id.to_string(), original["id"].as_str().unwrap());
assert_eq!(
from_json.elements.len(),
original["elements"].as_object().unwrap().len()
);
assert_eq!(
from_json.relations.len(),
original["relations"].as_object().unwrap().len() + allocations.len()
);
assert_eq!(
from_json.diagrams.len(),
original["diagrams"].as_object().unwrap().len()
);
assert_eq!(
serde_json::to_value(&from_json.traces).unwrap(),
original["traces"]
);
assert_eq!(
serde_json::to_value(&from_json.artifact_links).unwrap(),
original["artifact_links"]
);
let mut scenarios = 0;
for element in from_source.elements.values() {
if let Some(SharedBody::Scenario { viewpoint, .. }) = element.shared() {
scenarios += 1;
assert_eq!(*viewpoint, ScenarioViewpoint::MixedLegacy);
}
if let Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
message_kind,
reply_to,
values,
..
},
..
}) = element.shared()
{
assert_eq!(*message_kind, MessageKind::Asynchronous);
assert_eq!(*reply_to, None);
assert!(values.is_empty());
}
let before = &original["elements"][element.id.to_string()];
assert_eq!(element.alias, before["alias"].as_str().unwrap());
assert_eq!(element.name, before["name"].as_str().unwrap());
assert_eq!(
serde_json::to_value(element.parent).unwrap(),
before["parent"]
);
}
assert!(
scenarios > 0,
"fixture must cover actual old scenario content"
);
let canonical = language::format(&from_source);
assert!(canonical.contains("viewpoint MixedLegacy;"));
for relation in from_source.relations.values() {
if let RelationBody::StateTransition {
transition_kind,
triggers,
effects,
..
} = &relation.body
{
assert_eq!(*transition_kind, TransitionKind::External);
assert!(triggers.is_empty() && effects.is_empty());
}
}
assert_eq!(read(&canonical), from_source);
}
#[test]
fn v1_text_and_json_migrate_deterministically_without_losing_legacy_semantics() {
let text = include_str!("../../../examples/legacy-v1-coastal-rescue.arcadia");
let json = include_bytes!("../../../examples/legacy-v1-coastal-rescue.oa.json");
let from_text = read(text);
let from_json = persistence::decode(json).unwrap();
assert_eq!(from_text, from_json);
assert_eq!(read(text), from_text);
assert_eq!(persistence::decode(json).unwrap(), from_json);
assert_eq!(from_text.schema_version, oa_core::SCHEMA_VERSION);
assert!(!from_text.elements.values().any(|e| e.layer() == Layer::Sa));
let raw: serde_json::Value = serde_json::from_slice(json).unwrap();
for (id, record) in raw["elements"].as_object().unwrap() {
let id = Id::parse_str(id).unwrap();
let e = &from_text.elements[&id];
assert_eq!(e.name, record["name"].as_str().unwrap());
assert_eq!(e.description, record["description"].as_str().unwrap());
let n = from_text
.diagrams
.values()
.flat_map(|d| d.nodes.values())
.find(|n| n.element == id)
.unwrap();
assert_eq!(
serde_json::to_value(n.position).unwrap(),
record["position"]
);
if record["kind"] == "Process" {
let steps = e.steps().unwrap();
for (occurrence, exchange) in steps.iter().zip(record["steps"].as_array().unwrap()) {
assert!(
matches!(from_text.elements[occurrence].shared(),Some(SharedBody::Occurrence{body:OccurrenceBody::Exchange{exchange:Some(actual),..},..})if actual.to_string()==exchange.as_str().unwrap())
);
}
}
}
assert_eq!(read(&language::format(&from_text)), from_text);
}
#[test]
fn v1_repeated_exchange_occurrences_and_authored_aliases_survive() {
let source = r#"arcadia 1; project "Legacy" id "00000000-0000-0000-0000-000000000001" { activity first_custom "A" id "00000000-0000-0000-0000-000000000002" {} activity second_custom "B" id "00000000-0000-0000-0000-000000000003" {} interaction report_custom "Report" id "00000000-0000-0000-0000-000000000004" from first_custom to second_custom; process story_custom "Story" id "00000000-0000-0000-0000-000000000005" { position (-0, 12.5); steps [report_custom, report_custom]; } }"#;
let p = read(source);
assert_eq!(p, read(source));
let process = p
.elements
.values()
.find(|e| e.alias == "story_custom")
.unwrap();
let steps = process.steps().unwrap();
assert_ne!(steps[0], steps[1]);
assert_eq!(
p.elements[&steps[0]].shared(),
p.elements[&steps[1]].shared()
);
assert!(p.relations.values().any(|r| r.alias == "report_custom"));
let node = p
.diagrams
.values()
.flat_map(|d| d.nodes.values())
.find(|n| n.element == process.id)
.unwrap();
assert_eq!(node.position.x.to_bits(), (-0.0_f32).to_bits());
}
#[test]
fn malformed_json_never_ignores_duplicate_maps_or_nested_unknown_fields() {
let p = complete_model();
let json = serde_json::to_string(&p).unwrap();
let record = &p.elements.values().next().unwrap();
let encoded = serde_json::to_string(record).unwrap();
let duplicate = json.replacen(
&std::format!("\"{}\":{encoded}", record.id),
&std::format!("\"{}\":{encoded},\"{}\":{encoded}", record.id, record.id),
1,
);
assert!(persistence::decode(duplicate.as_bytes()).is_err());
let mut value = serde_json::to_value(&p).unwrap();
let item = p
.elements
.values()
.find(|e| e.kind() == Kind::ExchangeItem && e.layer() == Layer::Sa)
.unwrap();
value["elements"][item.id.to_string()]["body"]["Sa"]["Shared"]["ExchangeItem"]["surprise"] =
true.into();
assert!(persistence::decode(&serde_json::to_vec(&value).unwrap()).is_err());
}
#[test]
fn coordinate_bits_and_permitted_strings_survive_text_and_storage() {
let mut p = complete_model();
let values = [
0.,
-0.,
f32::from_bits(1),
-f32::from_bits(1),
-12.375,
999_999.94,
-1_000_000.,
];
for value in values {
let node = p
.diagrams
.values_mut()
.find_map(|d| d.nodes.values_mut().next())
.unwrap();
node.position = Position { x: value, y: value };
let id = node.id;
let parsed = read(&language::format(&p));
let node = parsed
.diagrams
.values()
.find_map(|d| d.nodes.get(&id))
.unwrap();
assert_eq!(node.position.x.to_bits(), value.to_bits());
}
p.description = "Unicode é e\u{301} 海 🌊\n\r\t\\\" <>&".into();
assert_eq!(read(&language::format(&p)), p);
let temp = tempfile::tempdir().unwrap();
for name in ["model.arcadia", "model.json"] {
let path = temp.path().join(name);
persistence::save(&path, &p).unwrap();
assert_eq!(persistence::load(&path).unwrap(), p);
}
let path = temp.path().join("model.json");
let before = std::fs::read(&path).unwrap();
p.description.push('\0');
assert!(persistence::save(&path, &p).is_err());
assert_eq!(std::fs::read(&path).unwrap(), before);
}
#[test]
fn tolerant_analysis_covers_unicode_and_returns_declarations_before_failure() {
use language::TokenClass;
for source in [
"",
"// 検査 🌊\narcadia 2;",
"oa_activity assess \"unfinished 🌊",
"parent @coordination; guard \"x\\q\";",
"}\u{000c}☃\t-2.5e-3 [none, true, false]",
] {
let mut end = 0;
for token in language::tokenize(source) {
assert_eq!(token.span.start, end);
assert!(token.span.end > token.span.start);
assert!(source.is_char_boundary(token.span.start));
assert!(source.is_char_boundary(token.span.end));
end = token.span.end;
}
assert_eq!(end, source.len());
}
assert_eq!(language::tokenize("// 🌊")[0].class, TokenClass::Comment);
let source = "arcadia 2;\nproject mission \"測試 🌊\" {\n oa_activity work \"Work\" id \"00000000-0000-0000-0000-000000000001\" {}\n oa_activity bad \"Bad\" { parent @unknown; }\n}";
let analysis = language::analyze(source);
assert!(
analysis
.declarations
.iter()
.any(|d| d.alias == "work" && d.id == Some(Id::from_u128(1)))
);
let error = &analysis.diagnostics[0];
let offset = source.find("@unknown").unwrap();
assert_eq!(error.span.start, offset);
assert_eq!(error.line, 4);
assert_eq!(
error.column,
source[..offset]
.rsplit('\n')
.next()
.unwrap()
.chars()
.count()
+ 1
);
}
#[test]
fn text_and_model_transactions_preserve_history_with_stable_aliases() {
let mut session = Session::new(complete_model());
let original = session.project().clone();
let edited = language::format(&original).replace("Harbor safety — 港 🌊", "Updated mission");
session.execute(Command::Replace(read(&edited))).unwrap();
assert!(session.dirty());
assert_eq!(session.project().name, "Updated mission");
session.undo();
assert_eq!(session.project(), &original);
assert!(!session.dirty());
session.redo();
assert_eq!(session.project().name, "Updated mission");
}
#[test]
fn complete_normative_examples_are_accepted_by_the_real_parser() {
let spec = include_str!("../../../docs/language.rst");
let mut examples = 0;
for section in spec.split(".. code-block:: text").skip(1) {
let mut lines = section.lines().skip_while(|line| line.trim().is_empty());
let mut code = String::new();
for line in lines.by_ref() {
if let Some(indented) = line.strip_prefix(" ") {
code.push_str(indented);
code.push('\n');
} else if line.is_empty() {
code.push('\n');
} else {
break;
}
}
if (1..=SCHEMA_VERSION).any(|version| {
code.trim_start()
.starts_with(&format!("arcadia {version};"))
}) {
read(&code);
examples += 1;
}
}
assert!(
examples > 0,
"The normative specification must include a complete authoring example."
);
}