Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
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.
1278 lines
55 KiB
Rust
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."
|
|
);
|
|
}
|