Establish OA/SA prototype baseline and first-release work

This commit is contained in:
2026-09-05 17:04:16 +03:00
commit 93ba83d321
109 changed files with 56512 additions and 0 deletions
+900
View File
@@ -0,0 +1,900 @@
arcadia 2;
project mission "Coastal rescue — operational need to system behavior" id "eceea6cd-108b-45a9-9505-8b7fa462af96" {
description "A worked OA/SA example. Compare allocation and functional decomposition views; inspect traces, repeated scenario messages, interface contracts, degraded modes, and external engineering artifacts.";
sa_lifeline vessel_service_lifeline "Vessel service lifeline" id "03b71dc5-435f-4c7f-923a-cb6e672212ba" {
description "";
parent @repeated_distress_reports;
represented @vessel_communication_service;
}
sa_initial start_reporting "Start reporting" id "0deb4c4c-d7cc-4dd9-bcc5-f357387cf978" {
description "";
do_activity "";
entry "";
exit "";
kind Initial;
parent @reporting_quality_region;
}
sa_state_machine reporting_operation_modes "Reporting operation modes" id "1a1b215a-c102-4948-9c88-dabfadc8e08e" {
description "";
parent @rescue_coordination_system;
}
sa_exchange_item backup_electrical_energy "Backup electrical energy" id "1b81d963-06e7-42d3-af31-bf5236829477" {
description "";
kind Energy;
parent none;
}
sa_lifeline coordination_system_lifeline "Coordination system lifeline" id "1f7da722-c91e-4d0e-a344-c1a2511dd96b" {
description "";
parent @repeated_distress_reports;
represented @rescue_coordination_system;
}
sa_mission support_timely_rescue_decisions "Support timely rescue decisions" id "218e9ac0-1fdb-4ce8-bbcb-4cc5c36b3746" {
description "";
parent none;
}
sa_occurrence updated_distress_report "Updated distress report" id "28b8e56d-7ddf-4950-8c30-7385c044cc72" {
body {
Exchange {
exchange @validated_distress_report;
receiver @coordination_system_lifeline;
sender @vessel_service_lifeline;
};
};
description "";
parent @repeated_distress_reports;
postcondition "The incident position is refreshed without creating a second incident.";
precondition "The vessel has more recent position information.";
}
oa_activity assess_incident "Assess incident" id "3388cc0d-0abc-4479-a948-6740266ec236" {
description "";
parent @respond_to_distress;
}
sa_constraint response_time_budget "Response time budget" id "3584d050-0b4e-4620-bbea-8c3c87d2a19f" {
constrained [@classify_incident_and_alert_operator, @validated_distress_report];
description "";
expression "incident_alert_latency <= 120 s";
parent none;
}
sa_system rescue_coordination_system "Rescue coordination system" id "47e28c63-803b-4ea6-af92-e778f62ecc3e" {
description "";
parent none;
}
sa_functional_chain digital_distress_handling "Digital distress handling" id "49743116-7777-4df8-9601-74fd4cba22c0" {
description "";
parent none;
steps [@report_handled];
}
oa_process distress_to_response "Distress to response" id "51c8ed9f-93ea-45fe-8829-e4ec88f09ce5" {
description "";
parent none;
steps [@distress_received];
}
oa_occurrence distress_received "Distress received" id "5e0c0d0e-5eb5-467c-bc35-dfe29d534703" {
body {
Exchange {
exchange @distress_signal;
receiver none;
sender none;
};
};
description "";
parent @distress_to_response;
postcondition "";
precondition "";
}
sa_exchange_item digital_distress_report "Digital distress report" id "5e16affb-7fa4-4a3d-8bfe-6340586ab83f" {
description "";
kind Information;
parent none;
}
oa_capability protect_life_at_sea "Protect life at sea" id "6ca5efd6-2e92-4508-a02d-4013793743cb" {
description "";
parent none;
}
sa_port report_function_output "Report function output" id "6ecf32ed-4009-48ba-b374-3bf156f6c355" {
description "";
direction Out;
interface @distress_reporting_interface;
parent @transmit_distress_report;
}
oa_entity rescue_coordination "Rescue coordination" id "721b68ab-5136-408e-890d-925215d5661e" {
description "";
parent none;
}
sa_capability coordinate_rescue_response "Coordinate rescue response" id "7781bd9f-e788-4049-9af4-9a3eab40d377" {
description "";
parent none;
}
oa_actor vessel_in_distress "Vessel in distress" id "7e8ea298-1c68-4c9e-994d-6008d47f62ce" {
description "";
parent none;
}
oa_exchange_item distress_report "Distress report" id "835c2e84-f4e9-4592-b5df-d205803d97b7" {
description "";
kind Information;
parent none;
}
sa_port system_reporting_input "System reporting input" id "8477d19d-4a62-43bb-a7e3-845b9b3d1002" {
description "";
direction In;
interface @distress_reporting_interface;
parent @rescue_coordination_system;
}
sa_occurrence initial_distress_report "Initial distress report" id "84a0db92-ac74-493a-af29-74ec7715d7fa" {
body {
Exchange {
exchange @validated_distress_report;
receiver @coordination_system_lifeline;
sender @vessel_service_lifeline;
};
};
description "";
parent @repeated_distress_reports;
postcondition "";
precondition "";
}
sa_port vessel_reporting_output "Vessel reporting output" id "a0e18d73-a9ce-4e9e-8ecf-0a54b1412932" {
description "";
direction Out;
interface @distress_reporting_interface;
parent @vessel_communication_service;
}
sa_function transmit_distress_report "Transmit distress report" id "b0a649d6-0558-48f5-8437-161be8329159" {
description "";
parent @manage_distress_response;
}
sa_function classify_incident_and_alert_operator "Classify incident and alert operator" id "b1cc95c7-1d2f-42d0-9d93-587954bd563b" {
description "";
parent @manage_distress_response;
}
sa_occurrence report_handled "Report handled" id "bcdb57f1-5fa8-46ce-9498-b467cea5d98f" {
body {
Exchange {
exchange @validated_distress_report;
receiver none;
sender none;
};
};
description "";
parent @digital_distress_handling;
postcondition "";
precondition "";
}
sa_function manage_distress_response "Manage distress response" id "c64f93a7-bf47-42b5-b432-7589b9144f2c" {
description "";
parent none;
}
sa_actor vessel_communication_service "Vessel communication service" id "cfb45cf5-bebe-4632-9bde-0ee023c42583" {
description "";
parent none;
}
sa_mode normal_reporting "Normal reporting" id "d00b5d17-8660-4955-a70d-988a6e6b6c4e" {
description "";
do_activity "";
entry "";
exit "";
kind Mode;
parent @reporting_quality_region;
}
sa_data_field reported_latitude "Reported latitude" id "d0f0b612-f0b9-451e-b0d2-5e74eedb2f36" {
description "";
maximum 1;
minimum 1;
parent @digital_distress_report;
type_ref Real;
unit "degrees north";
}
oa_activity report_distress "Report distress" id "d50e92c4-840e-4822-bcf7-033feb2eb879" {
description "";
parent @respond_to_distress;
}
sa_mode degraded_reporting "Degraded reporting" id "d7fad7e9-d799-4fa6-b49f-1304d3285b5e" {
description "";
do_activity "";
entry "";
exit "";
kind Mode;
parent @reporting_quality_region;
}
sa_exchange_item rescue_supply_package "Rescue supply package" id "eb8cf40e-8194-403f-9896-f968790a9504" {
description "";
kind Material;
parent none;
}
sa_region reporting_quality_region "Reporting quality region" id "edc1ad70-8fc9-42a1-a668-05c4738bb225" {
description "";
parent @reporting_operation_modes;
}
sa_interface distress_reporting_interface "Distress reporting interface" id "f008c7a3-9bda-4b7e-a4c5-8ee736183cb4" {
description "";
items [@digital_distress_report];
parent none;
}
oa_role incident_coordinator "Incident coordinator" id "f59b18f3-aa83-4050-9bc8-06ea0efaaa4b" {
description "";
parent none;
}
sa_scenario repeated_distress_reports "Repeated distress reports" id "f7514a9c-933c-4133-8b77-37c0dccd935f" {
context @coordinate_rescue_response;
description "";
parent none;
postcondition "The operator has a classified incident with the latest vessel report.";
precondition "A vessel has detected an emergency and the reporting service is available.";
steps [@initial_distress_report, @updated_distress_report];
}
oa_activity respond_to_distress "Respond to distress" id "fa23a2a8-6f13-49e3-be47-f757f26d5c9f" {
description "";
parent none;
}
sa_port assessment_function_input "Assessment function input" id "fa6051e1-0e60-4a1e-bfa1-f7111e06b74f" {
description "";
direction In;
interface @distress_reporting_interface;
parent @classify_incident_and_alert_operator;
}
state_transition signal_restored "Signal restored" id "00e36d6e-78e8-476a-bbd6-a6e3bcd5a018" {
description "";
effect "Clear the degraded-reporting alert.";
guard "position_is_current";
source @degraded_reporting;
target @normal_reporting;
trigger "validated_report_received";
}
state_action review_on_degraded_entry "Review on degraded entry" id "044cdc5f-a34a-4e51-9774-c1774a33dc91" {
description "";
phase Entry;
source @degraded_reporting;
target @classify_incident_and_alert_operator;
}
allocation assesses_incident "Assesses incident" id "17fef4ab-acc7-4f21-b70d-5d64f6ad1261" {
description "";
source @rescue_coordination;
target @assess_incident;
}
availability assessment_in_degraded_mode "Assessment in degraded mode" id "2c092728-eafb-47e3-bcc7-8b795e36a25e" {
description "";
source @classify_incident_and_alert_operator;
target @degraded_reporting;
}
state_transition signal_degraded "Signal degraded" id "331c78a2-b6b3-452f-a238-84a6a3df3547" {
description "";
effect "Flag the incident and request an operator review.";
guard "seconds_since_last_report > 30";
source @normal_reporting;
target @degraded_reporting;
trigger "report_timeout";
}
capability_involvement system_capability_support "System capability support" id "39ff3786-d94c-40ad-90fa-c009ab6f53f9" {
description "";
source @classify_incident_and_alert_operator;
target @coordinate_rescue_response;
}
chain_involvement operational_realization "Operational realization" id "41d934ba-76f7-4425-b433-2d4d293ab0df" {
description "";
source @distress_to_response;
target @protect_life_at_sea;
}
role_realization fulfils_coordinator_role "Fulfils coordinator role" id "4cf85b5a-eb4c-40f1-8bfc-06b30b24a9ef" {
description "";
source @rescue_coordination;
target @incident_coordinator;
}
state_transition initialize_reporting "Initialize reporting" id "5d34a01d-0dad-4a68-9b3b-998dce0d6e9b" {
description "";
effect "";
guard "";
source @start_reporting;
target @normal_reporting;
trigger "";
}
capability_involvement operational_contribution "Operational contribution" id "64ab242b-6043-4452-9b28-7205fec309df" {
description "";
source @assess_incident;
target @protect_life_at_sea;
}
interaction distress_signal "Distress signal" id "689a8e09-b7ca-4d97-b551-a21e3b438bfe" {
description "";
items [@distress_report];
source @report_distress;
target @assess_incident;
}
allocation vessel_transmission "Vessel transmission" id "6a82e148-d38e-4103-8481-046b4c474952" {
description "";
source @vessel_communication_service;
target @transmit_distress_report;
}
interface_requirement reporting_service_required "Reporting service required" id "7c32cbe5-0821-4793-a37f-9f017fc8a3f4" {
description "";
source @rescue_coordination_system;
target @distress_reporting_interface;
}
chain_involvement functional_chain_support "Functional chain support" id "92468388-63c2-4f2c-a1fd-2c3f27b76398" {
description "";
source @digital_distress_handling;
target @coordinate_rescue_response;
}
allocation system_assessment "System assessment" id "93e3a60d-33c0-4110-83d9-63f2902d5eeb" {
description "";
source @rescue_coordination_system;
target @classify_incident_and_alert_operator;
}
role_assignment coordinates_assessment "Coordinates assessment" id "a06f90b9-361b-4da2-86a9-0447df519e0c" {
description "";
source @incident_coordinator;
target @assess_incident;
}
functional_exchange validated_distress_report "Validated distress report" id "ae3ceb52-113f-40b3-8a90-fd6e6b8c775d" {
description "";
items [@digital_distress_report];
source @transmit_distress_report;
source_port @report_function_output;
target @classify_incident_and_alert_operator;
target_port @assessment_function_input;
}
availability assessment_in_normal_mode "Assessment in normal mode" id "bce2587e-f47c-4d85-ac6d-a098170bbaa5" {
description "";
source @classify_incident_and_alert_operator;
target @normal_reporting;
}
exchange_allocation report_carried_by_connection "Report carried by connection" id "d6a2c8d2-2a4f-4d90-a490-c2c661db4423" {
description "";
source @distress_reporting_connection;
target @validated_distress_report;
}
mission_capability mission_capability "Mission capability" id "e88a9747-0d1c-4813-8273-e6710f622e9b" {
description "";
source @support_timely_rescue_decisions;
target @coordinate_rescue_response;
}
boundary_exchange distress_reporting_connection "Distress reporting connection" id "eb7ae249-165c-4717-bebc-da476eebb05e" {
description "";
items [@digital_distress_report];
source @vessel_reporting_output;
target @system_reporting_input;
}
interface_provision reporting_service_provided "Reporting service provided" id "ec3f9e3f-5134-49af-9969-29f818dfcb46" {
description "";
source @vessel_communication_service;
target @distress_reporting_interface;
}
allocation reports_distress "Reports distress" id "f844c555-b407-402c-81c9-4928245249ca" {
description "";
source @vessel_in_distress;
target @report_distress;
}
diagram sa_system_boundary_and_functions "SA system boundary and functions" id "064d0c1c-5db0-4516-ab6f-5bbf18d02e6a" {
description "A focused projection of the shared engineering model.";
layer Sa;
projection Allocation;
}
node view_vessel_communication_service id "00edf6bc-b857-439a-8747-f1f66c1675e2" {
collapsed false;
diagram @sa_system_boundary_and_functions;
element @vessel_communication_service;
position {
x 60.0;
y 60.0;
};
size {
height 166.0;
width 272.0;
};
}
node view_transmit_distress_report id "035dab75-ef9e-4ca3-a107-87262e3c680b" {
collapsed false;
diagram @sa_system_boundary_and_functions;
element @transmit_distress_report;
position {
x 84.0;
y 108.0;
};
size {
height 94.0;
width 224.0;
};
}
node view_support_timely_rescue_decisions id "16b5055a-c98b-4276-b564-85b7fe1f020f" {
collapsed false;
diagram @sa_system_boundary_and_functions;
element @support_timely_rescue_decisions;
position {
x 392.0;
y 60.0;
};
size {
height 94.0;
width 224.0;
};
}
node view_digital_distress_handling id "4aa2fb09-b367-402c-a438-d4f918f04e08" {
collapsed false;
diagram @sa_system_boundary_and_functions;
element @digital_distress_handling;
position {
x 676.0;
y 60.0;
};
size {
height 94.0;
width 224.0;
};
}
node view_rescue_coordination_system id "5a6d6a1c-9915-4e33-8299-b512c50b18f5" {
collapsed false;
diagram @sa_system_boundary_and_functions;
element @rescue_coordination_system;
position {
x 960.0;
y 60.0;
};
size {
height 166.0;
width 272.0;
};
}
node view_manage_distress_response id "621e0e5e-d1be-4693-b95a-7c48d36e07b4" {
collapsed false;
diagram @sa_system_boundary_and_functions;
element @manage_distress_response;
position {
x 60.0;
y 286.0;
};
size {
height 94.0;
width 224.0;
};
}
node view_coordinate_rescue_response id "9f41eaff-3456-494b-b136-9f6f01afd90f" {
collapsed false;
diagram @sa_system_boundary_and_functions;
element @coordinate_rescue_response;
position {
x 344.0;
y 286.0;
};
size {
height 94.0;
width 224.0;
};
}
node view_classify_incident_and_alert_operator id "e75a6c26-dc64-4fa3-bc8f-a1a38e1f9b68" {
collapsed false;
diagram @sa_system_boundary_and_functions;
element @classify_incident_and_alert_operator;
position {
x 984.0;
y 108.0;
};
size {
height 94.0;
width 224.0;
};
}
diagram sa_functional_decomposition "SA functional decomposition" id "a67786d6-5b08-4b06-a0c8-584a04f179ef" {
description "A focused projection of the shared engineering model.";
layer Sa;
projection Decomposition;
}
node view_classify_incident_and_alert_operator_5ae489f2 id "5ae489f2-4be1-4842-b464-37900983e333" {
collapsed false;
diagram @sa_functional_decomposition;
element @classify_incident_and_alert_operator;
position {
x 84.0;
y 108.0;
};
size {
height 94.0;
width 224.0;
};
}
node view_manage_distress_response_80ab61e0 id "80ab61e0-d1f2-4008-9858-0759bdaa02e3" {
collapsed false;
diagram @sa_functional_decomposition;
element @manage_distress_response;
position {
x 60.0;
y 60.0;
};
size {
height 276.0;
width 272.0;
};
}
node view_transmit_distress_report_e02431fd id "e02431fd-c27e-4b48-9c94-37b8e3d55375" {
collapsed false;
diagram @sa_functional_decomposition;
element @transmit_distress_report;
position {
x 84.0;
y 218.0;
};
size {
height 94.0;
width 224.0;
};
}
diagram oa_participants_and_activities "OA participants and activities" id "d7f59b88-4a9b-4906-b859-1c6d044bbb52" {
description "A focused projection of the shared engineering model.";
layer Oa;
projection Allocation;
}
node view_rescue_coordination id "2f5fed31-e2dc-419d-9ebc-a5c9e9682277" {
collapsed false;
diagram @oa_participants_and_activities;
element @rescue_coordination;
position {
x 60.0;
y 60.0;
};
size {
height 166.0;
width 272.0;
};
}
node view_protect_life_at_sea id "91fe2d63-0083-4df2-9c29-563766e1b706" {
collapsed false;
diagram @oa_participants_and_activities;
element @protect_life_at_sea;
position {
x 392.0;
y 60.0;
};
size {
height 94.0;
width 224.0;
};
}
node view_distress_to_response id "a045fd40-84ea-48f3-a6d4-95641492d960" {
collapsed false;
diagram @oa_participants_and_activities;
element @distress_to_response;
position {
x 676.0;
y 60.0;
};
size {
height 94.0;
width 224.0;
};
}
node view_report_distress id "a2cabc9f-c12c-4eff-8acf-0e9b86829cbe" {
collapsed false;
diagram @oa_participants_and_activities;
element @report_distress;
position {
x 984.0;
y 108.0;
};
size {
height 94.0;
width 224.0;
};
}
node view_assess_incident id "acaa5a3d-51c4-45da-b5aa-4825bccd747c" {
collapsed false;
diagram @oa_participants_and_activities;
element @assess_incident;
position {
x 84.0;
y 108.0;
};
size {
height 94.0;
width 224.0;
};
}
node view_vessel_in_distress id "af3a61b3-db02-400f-bd18-b4422e2db7a7" {
collapsed false;
diagram @oa_participants_and_activities;
element @vessel_in_distress;
position {
x 960.0;
y 60.0;
};
size {
height 166.0;
width 272.0;
};
}
node view_respond_to_distress id "e4c687d7-4a44-4dee-a2d8-bdbe6a5cf649" {
collapsed false;
diagram @oa_participants_and_activities;
element @respond_to_distress;
position {
x 60.0;
y 286.0;
};
size {
height 94.0;
width 224.0;
};
}
node view_incident_coordinator id "fcbda72a-5478-4d77-8330-16f89cdefc37" {
collapsed false;
diagram @oa_participants_and_activities;
element @incident_coordinator;
position {
x 344.0;
y 286.0;
};
size {
height 94.0;
width 224.0;
};
}
artifact console_enclosure_cad "Console enclosure CAD" id "091b5a40-1631-49ef-8b53-1471c016a13f" {
description "Illustrative external engineering reference; opening a model does not fetch it.";
fragment none;
owner @rescue_coordination_system;
relation References;
revision "B.3";
uri "cad://rescue-console/enclosure.step";
}
artifact response_implementation "Response implementation" id "0b09f95e-0545-44c3-b687-84489ce1422b" {
description "Illustrative external engineering reference; opening a model does not fetch it.";
fragment none;
owner @classify_incident_and_alert_operator;
relation Implements;
revision "design-v2";
uri "git+https://example.org/rescue/control?path=src/response.rs";
}
trace trace_respond_to_distress "Respond to distress ↔ Manage distress response" id "1d0a908f-9e22-42b7-9aac-26bbe3f5df78" {
baseline {
oa {
description "";
name "Respond to distress";
};
sa {
description "";
name "Manage distress response";
};
};
description "Reviewed OA/SA correspondence in the coastal rescue example.";
kind Refinement;
oa @respond_to_distress;
sa @manage_distress_response;
}
trace trace_rescue_coordination "Rescue coordination ↔ Rescue coordination system" id "49f2b55a-6dcc-44ba-a68f-a305942db7c9" {
baseline {
oa {
description "";
name "Rescue coordination";
};
sa {
description "";
name "Rescue coordination system";
};
};
description "Reviewed OA/SA correspondence in the coastal rescue example.";
kind Participant;
oa @rescue_coordination;
sa @rescue_coordination_system;
}
trace trace_distress_to_response "Distress to response ↔ Digital distress handling" id "4ac98979-6c84-4a66-b043-373b70bb5203" {
baseline {
oa {
description "";
name "Distress to response";
};
sa {
description "";
name "Digital distress handling";
};
};
description "Reviewed OA/SA correspondence in the coastal rescue example.";
kind Sequence;
oa @distress_to_response;
sa @digital_distress_handling;
}
trace trace_protect_life_at_sea "Protect life at sea ↔ Coordinate rescue response" id "4b6acf23-dab3-462d-b45f-066aea916932" {
baseline {
oa {
description "";
name "Protect life at sea";
};
sa {
description "";
name "Coordinate rescue response";
};
};
description "Reviewed OA/SA correspondence in the coastal rescue example.";
kind Capability;
oa @protect_life_at_sea;
sa @coordinate_rescue_response;
}
trace trace_distress_signal "Distress signal ↔ Validated distress report" id "587cca51-69e8-47e7-9674-32ff49926a22" {
baseline {
oa {
description "";
name "Distress signal";
};
sa {
description "";
name "Validated distress report";
};
};
description "Reviewed OA/SA correspondence in the coastal rescue example.";
kind Exchange;
oa @distress_signal;
sa @validated_distress_report;
}
trace trace_distress_report "Distress report ↔ Digital distress report" id "7790684b-220f-443d-8754-52c29414367f" {
baseline {
oa {
description "";
name "Distress report";
};
sa {
description "";
name "Digital distress report";
};
};
description "Reviewed OA/SA correspondence in the coastal rescue example.";
kind Item;
oa @distress_report;
sa @digital_distress_report;
}
trace trace_vessel_in_distress "Vessel in distress ↔ Vessel communication service" id "9a8101e4-ebce-4836-a0e3-63440c2e1e87" {
baseline {
oa {
description "";
name "Vessel in distress";
};
sa {
description "";
name "Vessel communication service";
};
};
description "Reviewed OA/SA correspondence in the coastal rescue example.";
kind Participant;
oa @vessel_in_distress;
sa @vessel_communication_service;
}
trace trace_assess_incident "Assess incident ↔ Classify incident and alert operator" id "b8fee65b-21da-4a27-9b42-575d417bd942" {
baseline {
oa {
description "";
name "Assess incident";
};
sa {
description "";
name "Classify incident and alert operator";
};
};
description "Reviewed OA/SA correspondence in the coastal rescue example.";
kind Refinement;
oa @assess_incident;
sa @classify_incident_and_alert_operator;
}
trace trace_distress_received "Distress received ↔ Report handled" id "be4365d6-2a43-433c-935f-c039b266242f" {
baseline {
oa {
description "";
name "Distress received";
};
sa {
description "";
name "Report handled";
};
};
description "Reviewed OA/SA correspondence in the coastal rescue example.";
kind Scenario;
oa @distress_received;
sa @report_handled;
}
trace trace_report_distress "Report distress ↔ Transmit distress report" id "d3f224a5-0b2d-4eba-93c9-232b9e84e742" {
baseline {
oa {
description "";
name "Report distress";
};
sa {
description "";
name "Transmit distress report";
};
};
description "Reviewed OA/SA correspondence in the coastal rescue example.";
kind Refinement;
oa @report_distress;
sa @transmit_distress_report;
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,37 @@
use oa_core::{
validation::{Severity, validate},
*,
};
#[test]
fn sequence_gaps_name_occurrences_exchanges_and_endpoints() {
let mut p = Project::demo();
let first = p
.relations
.values()
.find(|r| r.kind() == RelationKind::Interaction)
.unwrap()
.clone();
let process = p
.elements
.values()
.find(|e| e.kind() == Kind::Process)
.unwrap()
.id;
let other = p.add(Kind::Activity, "Unrelated work", 0., 0.);
let reply = p.relate(
RelationKind::Interaction,
"Unrelated message",
other,
first.source(),
);
let p = execute_batch(&p, &[Command::AddStep(process, reply)]).unwrap();
let issue = validate(&p)
.into_iter()
.find(|i| i.code == "sequence_gap")
.unwrap();
assert_eq!(issue.severity, Severity::Warning);
assert_eq!(issue.element, Some(process));
assert!(issue.message.contains("steps 1 and 2"));
assert!(issue.message.contains("Distress signal"));
assert!(issue.message.contains("Unrelated message"));
}
@@ -0,0 +1,514 @@
use oa_core::{
projection, references,
transitions::*,
validation::{self, Severity},
*,
};
fn kinds(layer: Layer) -> (Kind, Kind, Kind) {
match layer {
Layer::Oa => (Kind::Actor, Kind::OperationalMission, Kind::Capability),
Layer::Sa => (Kind::SystemActor, Kind::Mission, Kind::SystemCapability),
}
}
fn add(p: &mut Project, kind: Kind, layer: Layer, name: &str) -> Id {
p.add_element(kind, layer, name, None).unwrap()
}
fn transition(p: &Project, request: &TransitionRequest) -> (Project, TransitionPlan) {
let plan = plan_transition(p, request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
(apply_transition(p, &plan).unwrap(), plan)
}
#[test]
fn capability_and_mission_links_enforce_direction_family_and_layer() {
for layer in [Layer::Oa, Layer::Sa] {
let mut p = Project::empty();
let (participant_kind, mission_kind, capability_kind) = kinds(layer);
let participant = add(&mut p, participant_kind, layer, "Mission participant");
let mission = add(&mut p, mission_kind, layer, "Protect lives");
let capability = add(&mut p, capability_kind, layer, "Rescue");
let other = add(&mut p, capability_kind, layer, "Locate distress");
let opposite = if layer == Layer::Oa {
Layer::Sa
} else {
Layer::Oa
};
let cross_layer = add(&mut p, kinds(opposite).2, opposite, "Remote capability");
for (kind, source, target) in [
(RelationKind::MissionInvolvement, participant, mission),
(RelationKind::MissionCapability, mission, capability),
(RelationKind::CapabilityInclude, capability, other),
(RelationKind::CapabilityExtend, capability, other),
(RelationKind::CapabilityGeneralization, capability, other),
] {
assert!(validation::can_connect(&p, kind, source, target));
let created =
execute_batch(&p, &[Command::Connect(kind, "Link".into(), source, target)])
.unwrap();
let relation = created.relations.values().next().unwrap();
assert_eq!(relation.body.endpoints(), (source, target));
for invalid_target in [source, cross_layer] {
assert!(!validation::can_connect(&p, kind, source, invalid_target));
assert!(
execute_batch(
&p,
&[Command::Connect(
kind,
"Invalid".into(),
source,
invalid_target
)]
)
.is_err()
);
}
}
for (kind, source, target) in [
(RelationKind::MissionInvolvement, mission, participant),
(RelationKind::MissionInvolvement, capability, mission),
(RelationKind::MissionCapability, capability, mission),
(RelationKind::CapabilityInclude, mission, capability),
(RelationKind::CapabilityExtend, participant, capability),
(RelationKind::CapabilityGeneralization, mission, mission),
] {
assert!(!validation::can_connect(&p, kind, source, target));
}
}
}
#[test]
fn inclusion_and_specialization_reject_transitive_cycles_atomically() {
for layer in [Layer::Oa, Layer::Sa] {
for (kind, code) in [
(RelationKind::CapabilityInclude, "capability_include_cycle"),
(
RelationKind::CapabilityGeneralization,
"capability_generalization_cycle",
),
] {
let mut p = Project::empty();
let ids: Vec<_> = ["A", "B", "C"]
.into_iter()
.map(|name| add(&mut p, kinds(layer).2, layer, name))
.collect();
p.relate(kind, "A to B", ids[0], ids[1]);
p.relate(kind, "B to C", ids[1], ids[2]);
let mut session = Session::new(p.clone());
session
.execute(Command::Rename(ids[0], "Undoable draft".into()))
.unwrap();
session.undo();
let revision = session.revision();
assert!(!validation::can_connect(&p, kind, ids[2], ids[0]));
let error = session
.execute_many(&[
Command::Rename(ids[0], "Must roll back".into()),
Command::Connect(kind, "Would close cycle".into(), ids[2], ids[0]),
])
.unwrap_err();
assert!(error.0.contains(code), "{error}");
assert_eq!(session.project(), &p);
assert_eq!(session.revision(), revision);
assert!(session.can_redo());
// Even an imported or edited complete graph is checked, not just the connector UI.
let closing = p.relate(kind, "Cycle", ids[2], ids[0]);
assert!(
validation::validate(&p)
.iter()
.any(|issue| issue.code == code && issue.element == Some(closing))
);
}
}
}
#[test]
fn conditional_extension_remains_editable_and_condition_is_validated() {
let mut p = Project::empty();
let base = add(&mut p, Kind::Capability, Layer::Oa, "Rescue");
let extending = add(&mut p, Kind::Capability, Layer::Oa, "Night rescue");
let mut session = Session::new(p);
let link = session
.execute(Command::Connect(
RelationKind::CapabilityExtend,
"Night assistance".into(),
extending,
base,
))
.unwrap()
.unwrap();
assert!(
validation::validate(session.project())
.iter()
.any(|issue| issue.element == Some(link)
&& issue.code == "missing_extension_condition"
&& issue.severity == Severity::Warning)
);
let mut edited = session.project().relations[&link].clone();
if let RelationBody::CapabilityExtend { condition, .. } = &mut edited.body {
*condition = "daylight == false && visibility < 500".into();
}
session
.execute(Command::EditRelation(link, edited.clone()))
.unwrap();
assert!(
!validation::validate(session.project())
.iter()
.any(|issue| issue.code == "missing_extension_condition")
);
let accepted = session.project().clone();
if let RelationBody::CapabilityExtend { condition, .. } = &mut edited.body {
*condition = "invalid\u{0000}condition".into();
}
assert!(
session
.execute(Command::EditRelation(link, edited))
.is_err()
);
assert_eq!(session.project(), &accepted);
session.undo();
assert!(
validation::validate(session.project())
.iter()
.any(|issue| issue.code == "missing_extension_condition")
);
session.redo();
assert_eq!(session.project(), &accepted);
}
#[test]
fn operational_missions_have_typed_bidirectional_counterparts_and_legacy_choices() {
for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] {
let mut p = Project::empty();
let source = add(
&mut p,
kinds(direction.source_layer()).1,
direction.source_layer(),
"Protect lives",
);
let request = TransitionRequest::new(direction, vec![source]);
let (candidate, plan) = transition(&p, &request);
let target = plan.changes[0].target;
assert_eq!(
candidate.elements[&target].kind(),
kinds(direction.target_layer()).1
);
let trace = candidate.traces.values().next().unwrap();
assert_eq!(trace.kind, TraceKind::Mission);
assert_eq!(
candidate.elements[&trace.oa].kind(),
Kind::OperationalMission
);
assert_eq!(candidate.elements[&trace.sa].kind(), Kind::Mission);
assert!(validation::trace_permits(&candidate, trace));
assert!(
plan_transition(&candidate, &request)
.unwrap()
.commands
.is_empty()
);
let mut session = Session::new(p.clone());
session.execute_many(&plan.commands).unwrap();
session.undo();
assert_eq!(session.project(), &p);
session.redo();
assert_eq!(session.project(), &candidate);
}
assert_eq!(
counterpart_kinds(Kind::Mission, TransitionDirection::SaToOa),
vec![Kind::OperationalMission, Kind::Capability]
);
assert_eq!(
counterpart_kinds(Kind::Capability, TransitionDirection::OaToSa),
vec![Kind::SystemCapability, Kind::Mission]
);
// A previously intentional capability↔mission relation remains a manual trace and is reused.
let mut p = Project::empty();
let capability = add(&mut p, Kind::Capability, Layer::Oa, "User-level goal");
let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![capability]);
request.mappings.push(TransitionMapping {
source: capability,
target: None,
target_kind: Some(Kind::Mission),
});
let (candidate, plan) = transition(&p, &request);
let mission = plan.changes[0].target;
assert_eq!(
candidate.traces.values().next().unwrap().kind,
TraceKind::Manual
);
let reverse = plan_transition(
&candidate,
&TransitionRequest::new(TransitionDirection::SaToOa, vec![mission]),
)
.unwrap();
assert!(reverse.conflicts.is_empty());
assert_eq!(reverse.changes[0].target, capability);
assert!(reverse.commands.is_empty());
}
fn mission_map(layer: Layer) -> (Project, Vec<Id>) {
let mut p = Project::empty();
let (actor_kind, mission_kind, capability_kind) = kinds(layer);
let actor = add(&mut p, actor_kind, layer, "Rescue coordinator");
let mission = add(&mut p, mission_kind, layer, "Protect lives");
let rescue = add(&mut p, capability_kind, layer, "Rescue");
let locate = add(&mut p, capability_kind, layer, "Locate distress");
let night = add(&mut p, capability_kind, layer, "Night rescue");
let mut relations = vec![
p.relate(
RelationKind::MissionInvolvement,
"Participates",
actor,
mission,
),
p.relate(
RelationKind::MissionCapability,
"Delivered by",
mission,
rescue,
),
p.relate(
RelationKind::CapabilityInclude,
"Always locate",
rescue,
locate,
),
p.relate(
RelationKind::CapabilityGeneralization,
"Specializes rescue",
night,
rescue,
),
];
let extension = p.relate(
RelationKind::CapabilityExtend,
"Night assistance",
night,
rescue,
);
if let RelationBody::CapabilityExtend { condition, .. } =
&mut p.relations.get_mut(&extension).unwrap().body
{
*condition = "light_level < 10".into();
}
relations.push(extension);
(p, relations)
}
#[test]
fn mission_map_transition_preserves_all_relationship_endpoints_and_conditions_both_ways() {
for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] {
let (p, relations) = mission_map(direction.source_layer());
let mut request = TransitionRequest::new(direction, relations.clone());
request.scope = DependencyScope::Required;
let actor = p
.elements
.values()
.find(|e| e.kind().is_performer())
.unwrap()
.id;
request.mappings.push(TransitionMapping {
source: actor,
target: None,
target_kind: Some(kinds(direction.target_layer()).0),
});
let (candidate, plan) = transition(&p, &request);
let mapped = |source| {
plan.changes
.iter()
.find(|change| change.source == source)
.unwrap()
.target
};
for id in &relations {
let original = &p.relations[id];
let counterpart = &candidate.relations[&mapped(*id)];
assert_eq!(counterpart.kind(), original.kind());
assert_eq!(
counterpart.body.endpoints(),
(mapped(original.source()), mapped(original.target()))
);
if let RelationBody::CapabilityExtend { condition, .. } = &counterpart.body {
assert_eq!(condition, "light_level < 10");
}
assert_eq!(
references::relation_references(counterpart),
vec![counterpart.source(), counterpart.target()]
);
}
assert!(
candidate.elements.values().all(|e| e.parent.is_none()),
"capability relations do not create containment"
);
assert!(
candidate
.traces
.values()
.any(|t| t.kind == TraceKind::Mission)
);
let mut reverse = TransitionRequest::new(
if direction == TransitionDirection::OaToSa {
TransitionDirection::SaToOa
} else {
TransitionDirection::OaToSa
},
relations.iter().map(|id| mapped(*id)).collect(),
);
reverse.scope = DependencyScope::Required;
let repeated = plan_transition(&candidate, &reverse).unwrap();
assert!(repeated.conflicts.is_empty(), "{:?}", repeated.conflicts);
assert!(repeated.commands.is_empty());
}
}
#[test]
fn mission_reidentification_and_deletion_preserve_reference_integrity_and_undo() {
let (mut p, relations) = mission_map(Layer::Oa);
let mission = p
.elements
.values()
.find(|e| e.kind() == Kind::OperationalMission)
.unwrap()
.id;
let scenario = add(&mut p, Kind::Scenario, Layer::Oa, "Mission demonstration");
if let Some(SharedBody::Scenario { context, .. }) =
p.elements.get_mut(&scenario).unwrap().shared_mut()
{
*context = Some(mission);
}
let diagram = p.ensure_diagram(Layer::Oa);
let node = p.place(diagram, mission, Position { x: 10., y: 20. });
let evidence = Id::new_v4();
p.artifact_links.insert(
evidence,
ArtifactLink {
id: evidence,
alias: "mission_evidence".into(),
name: "Mission evidence".into(),
description: String::new(),
owner: mission,
relation: ArtifactRelation::Verifies,
uri: "git:reviews/mission.md".into(),
revision: Some("review-1".into()),
fragment: None,
},
);
let (p, plan) = transition(
&p,
&TransitionRequest::new(TransitionDirection::OaToSa, vec![mission]),
);
let sa = plan.changes[0].target;
let mut session = Session::new(p.clone());
let renamed_id = Id::new_v4();
session
.execute(Command::Reidentify(mission, renamed_id))
.unwrap();
let updated = session.project();
assert_eq!(updated.relations[&relations[0]].target(), renamed_id);
assert_eq!(updated.relations[&relations[1]].source(), renamed_id);
assert!(references::element_references(&updated.elements[&scenario]).contains(&renamed_id));
assert_eq!(updated.diagrams[&diagram].nodes[&node].element, renamed_id);
assert_eq!(updated.artifact_links[&evidence].owner, renamed_id);
assert_eq!(updated.traces.values().next().unwrap().oa, renamed_id);
let reidentified = updated.clone();
session.execute(Command::Delete(renamed_id)).unwrap();
let deleted = session.project();
assert!(
deleted.elements.contains_key(&sa),
"deletion never cascades to the opposite-layer counterpart"
);
assert!(deleted.elements.contains_key(&scenario));
assert!(references::element_references(&deleted.elements[&scenario]).is_empty());
assert!(!deleted.relations.contains_key(&relations[0]));
assert!(!deleted.relations.contains_key(&relations[1]));
assert!(deleted.relations.contains_key(&relations[2]));
assert!(deleted.traces.is_empty());
assert!(!deleted.artifact_links.contains_key(&evidence));
assert!(!deleted.diagrams[&diagram].nodes.contains_key(&node));
session.undo();
assert_eq!(session.project(), &reidentified);
session.undo();
assert_eq!(session.project(), &p);
// The same identity visitor covers incoming/outgoing include, extend,
// generalization, and mission-capability endpoints.
let capability = p.relations[&relations[1]].target();
let updated_capability = Id::new_v4();
session
.execute(Command::Reidentify(capability, updated_capability))
.unwrap();
for original in p.relations.values() {
let (source, target) = original.body.endpoints();
let map = |id| {
if id == capability {
updated_capability
} else {
id
}
};
assert_eq!(
session.project().relations[&original.id].body.endpoints(),
(map(source), map(target))
);
}
let updated = session.project().clone();
session
.execute(Command::Delete(updated_capability))
.unwrap();
assert!(session.project().relations.values().all(|relation| {
!references::relation_references(relation).contains(&updated_capability)
}));
assert!(session.project().elements.contains_key(&mission));
assert!(session.project().relations.contains_key(&relations[0]));
session.undo();
assert_eq!(session.project(), &updated);
}
#[test]
fn capability_semantics_remain_edges_independent_of_visual_containment() {
let mut p = Project::empty();
let parent = add(&mut p, Kind::Capability, Layer::Oa, "Capability group");
let child = p
.add_element(
Kind::Capability,
Layer::Oa,
"Specific capability",
Some(parent),
)
.unwrap();
let include = p.relate(
RelationKind::CapabilityInclude,
"Includes group behavior",
child,
parent,
);
let generalization = p.relate(
RelationKind::CapabilityGeneralization,
"Separate specialization",
parent,
child,
);
validate_candidate(&p).unwrap();
let diagram = p.ensure_diagram(Layer::Oa);
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Decomposition;
let parent_node = p.place(diagram, parent, Position { x: 0., y: 0. });
let child_node = p.place(diagram, child, Position { x: 25., y: 75. });
let view = projection::project_view(&p, diagram);
assert_eq!(
view.nodes
.iter()
.find(|n| n.id == child_node)
.unwrap()
.visual_parent,
Some(parent_node)
);
for (id, source, target) in [
(include, child_node, parent_node),
(generalization, parent_node, child_node),
] {
let edge = view.edges.iter().find(|edge| edge.id == id).unwrap();
assert_eq!((edge.source, edge.target), (source, target));
}
}
+177
View File
@@ -0,0 +1,177 @@
use oa_core::{projection::*, *};
#[test]
fn architecture_is_nested_exchange_context_and_ports_attach_without_placements() {
let p = Project::demo();
let d = p
.diagrams
.values()
.find(|d| d.layer == Layer::Sa && d.projection == ProjectionKind::Allocation)
.unwrap();
let view = project_view(&p, d.id);
assert!(!view.nodes.is_empty());
assert!(view.nodes.iter().all(|n| {
let kind = p.elements[&n.element].kind();
kind.is_performer() || kind.is_work()
}));
for n in view
.nodes
.iter()
.filter(|n| p.elements[&n.element].kind().is_work())
{
let parent = view
.nodes
.iter()
.find(|parent| Some(parent.id) == n.visual_parent)
.unwrap();
assert!(p.elements[&parent.element].kind().is_performer());
assert!(n.position.x > parent.position.x && n.position.y > parent.position.y);
}
assert_eq!(view.ports.len(), 4);
assert!(
d.nodes
.values()
.all(|n| p.elements[&n.element].kind() != Kind::Port)
);
assert!(
view.edges
.iter()
.any(|e| p.relations[&e.id].kind() == RelationKind::BoundaryExchange)
);
for edge in &view.edges {
assert!(matches!(
p.relations[&edge.id].kind(),
RelationKind::FunctionalExchange | RelationKind::BoundaryExchange
));
for (id, owner) in [
(edge.source_port, edge.source),
(edge.target_port, edge.target),
] {
let port = view.ports.iter().find(|p| Some(p.element) == id).unwrap();
assert_eq!(port.owner, owner);
let node = view.nodes.iter().find(|n| n.id == owner).unwrap();
assert!(
port.position.x == node.position.x
|| port.position.x == node.position.x + node.size.width
);
}
}
}
#[test]
fn port_identity_collapse_and_group_movement_do_not_invent_semantic_edges() {
let mut p = Project::demo();
let diagram = p
.diagrams
.values()
.find(|d| d.layer == Layer::Sa && d.projection == ProjectionKind::Allocation)
.unwrap()
.id;
let view = project_view(&p, diagram);
let system = view
.nodes
.iter()
.find(|n| p.elements[&n.element].kind() == Kind::System)
.unwrap();
let node = system.id;
let before = p.clone();
let moved = execute_batch(
&p,
&[Command::MoveNode {
diagram,
node,
position: Position {
x: p.diagrams[&diagram].nodes[&node].position.x + 140.,
y: p.diagrams[&diagram].nodes[&node].position.y + 80.,
},
with_descendants: true,
}],
)
.unwrap();
let after = project_view(&moved, diagram);
for port in view.ports.iter().filter(|port| {
port.owner == node
|| view
.nodes
.iter()
.any(|n| n.id == port.owner && n.visual_parent == Some(node))
}) {
let shifted = after
.ports
.iter()
.find(|p| p.element == port.element)
.unwrap();
assert_eq!(shifted.position.x, port.position.x + 140.);
assert_eq!(shifted.position.y, port.position.y + 80.);
}
assert_eq!(moved.elements, before.elements);
assert_eq!(moved.relations, before.relations);
p.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&node)
.unwrap()
.collapsed = true;
let collapsed = project_view(&p, diagram);
assert!(collapsed.ports.iter().all(|port| {
p.elements[&port.element].parent
!= Some(
view.nodes
.iter()
.find(|n| n.visual_parent == Some(node))
.unwrap()
.element,
)
}));
let exchange = collapsed
.edges
.iter()
.find(|e| p.relations[&e.id].kind() == RelationKind::FunctionalExchange)
.unwrap();
assert_eq!(exchange.target, node);
assert_eq!(
exchange.target_port, None,
"A collapsed link must not invent a system port"
);
assert_eq!(p.relations, before.relations);
}
#[test]
fn view_filtering_keeps_placements_and_source_and_separates_capability_content() {
let mut p = Project::demo();
let diagram = p
.diagrams
.values()
.find(|d| d.layer == Layer::Oa && d.projection == ProjectionKind::Allocation)
.unwrap()
.id;
let cap = p
.elements
.values()
.find(|e| e.kind() == Kind::Capability)
.unwrap()
.id;
p.place(diagram, cap, Position { x: 600., y: 0. });
let original = p.clone();
assert!(
!project_view(&p, diagram)
.nodes
.iter()
.any(|n| n.element == cap)
);
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Capabilities;
let view = project_view(&p, diagram);
assert!(view.nodes.iter().any(|n| n.element == cap));
assert!(
!view
.nodes
.iter()
.any(|n| p.elements[&n.element].kind().is_work())
);
assert_eq!(
p.diagrams[&diagram].nodes,
original.diagrams[&diagram].nodes
);
assert_eq!(language::parse(&language::format(&p)).unwrap(), p);
}
@@ -0,0 +1,139 @@
use oa_core::{
language::{self, ParseLimits},
persistence,
validation::Severity,
*,
};
fn limits(graph: usize, records: usize, steps: usize) -> ParseLimits {
ParseLimits {
max_source_bytes: 8 * 1024 * 1024,
max_graph_objects: graph,
max_records: records,
max_step_references: steps,
}
}
#[test]
fn lint_reports_located_engineering_warnings_without_rejecting_valid_drafts() {
let source = "arcadia 2; project example \"航海\" {\n sa_function assess \"Assess\" {}\n}";
assert!(language::parse(source).is_ok());
let diagnostics = language::lint(source);
let missing = diagnostics
.iter()
.find(|d| d.code == "missing_system")
.unwrap();
assert_eq!(missing.severity, Severity::Warning);
let work = diagnostics
.iter()
.find(|d| d.code == "unallocated_leaf")
.unwrap();
assert_eq!(work.severity, Severity::Warning);
assert_eq!((work.line, work.column), (2, 5));
assert_eq!(
&source[work.span.start..work.span.start + 11],
"sa_function"
);
assert!(work.object_id.is_some());
assert_eq!(work.field.as_deref(), Some("allocation"));
let invalid = "arcadia 2; project example \"航海\" { oa_activity a \"A\" { parent @a; } }";
let rejected = language::parse(invalid).unwrap_err();
assert!(rejected.iter().all(|d| d.severity == Severity::Error));
let parent = rejected
.iter()
.find(|d| d.code == "invalid_parent")
.unwrap();
assert_eq!(parent.span.start, invalid.find("@a").unwrap());
assert_eq!(
parent.column,
invalid[..parent.span.start].chars().count() + 1
);
}
#[test]
fn declaration_limits_precede_reference_resolution_and_structural_validation() {
let mut source = String::from("arcadia 2; project example \"Oversized\" {\n");
for i in 0..12_000 {
source.push_str(&format!("oa_activity a{i} \"A\" {{ parent @missing; }}\n"));
}
source.push('}');
let rejected = language::parse_limited(&source, limits(5_000, 20_000, 50_000)).unwrap_err();
assert_eq!(rejected[0].code, "model_limit");
assert_eq!(rejected[0].line, 5_002);
assert!(rejected[0].span.start < source.len() / 2);
assert!(matches!(
persistence::decode_limited(source.as_bytes(), limits(5_000, 20_000, 50_000)),
Err(persistence::FileError::Limit(_))
));
let rejected = language::parse_limited(
&source,
ParseLimits {
max_source_bytes: 20,
..ParseLimits::UNLIMITED
},
)
.unwrap_err();
assert_eq!(rejected[0].code, "source_limit");
}
#[test]
fn json_record_and_step_limits_precede_model_errors_and_preserve_the_candidate() {
let mut project = Project::empty();
// Invalid parents would fail validation; admission must reject the count first.
for i in 1..=6u128 {
let id = Id::from_u128(i);
project.elements.insert(
id,
Element {
id,
alias: format!("a{i}"),
name: "A".into(),
description: String::new(),
parent: Some(Id::nil()),
body: ElementBody::Oa(OaBody::Activity),
},
);
}
let bytes = serde_json::to_vec(&project).unwrap();
assert!(matches!(
persistence::decode_limited(&bytes, limits(5, 100, 100)),
Err(persistence::FileError::Limit(_))
));
let demo = Project::demo();
let count = demo.identities().len();
assert!(limits(1_000, count, 50_000).check_model(&demo).is_ok());
assert!(limits(1_000, count - 1, 50_000).check_model(&demo).is_err());
assert!(
language::parse_limited(&language::format(&demo), limits(1_000, count - 1, 50_000))
.is_err()
);
let source = "arcadia 2; project example \"Steps\" { oa_scenario story \"Story\" { steps [@story, @story]; } }";
assert_eq!(
language::parse_limited(source, limits(100, 100, 1)).unwrap_err()[0].code,
"model_limit"
);
}
#[test]
fn legacy_budgets_include_generated_occurrences_and_placements_before_migration() {
let source = r#"arcadia 1; project "Legacy" { activity a "A" {} activity b "B" {} interaction report "Report" from a to b; process story "Story" { steps [report, report]; } }"#;
// Four original graph records become six after occurrence creation.
assert_eq!(
language::parse_limited(source, limits(5, 100, 100)).unwrap_err()[0].code,
"model_limit"
);
let accepted = language::parse_limited(source, limits(6, 11, 2)).unwrap();
assert_eq!(accepted.identities().len(), 11);
assert!(language::parse_limited(source, limits(6, 10, 2)).is_err());
let old = include_bytes!("../../../examples/legacy-v1-coastal-rescue.oa.json");
assert!(matches!(
persistence::decode_limited(old, limits(1, 2, 1)),
Err(persistence::FileError::Limit(_))
));
let original = persistence::decode(old).unwrap();
let native = serde_json::to_vec(&original).unwrap();
assert_eq!(
persistence::decode_limited(&native, limits(5000, 20000, 50000)).unwrap(),
original
);
}
File diff suppressed because it is too large Load Diff
+756
View File
@@ -0,0 +1,756 @@
use oa_core::{
references,
transitions::*,
validation::{self, Severity},
*,
};
fn add(p: &mut Project, kind: Kind, layer: Layer, parent: Option<Id>) -> Id {
p.add_element(kind, layer, &kind.to_string(), parent)
.unwrap()
}
fn vertex(p: &mut Project, region: Id, kind: Kind) -> Id {
add(p, kind, p.elements[&region].layer(), Some(region))
}
fn region(p: &mut Project, owner: Id) -> Id {
add(p, Kind::Region, p.elements[&owner].layer(), Some(owner))
}
fn edge(p: &mut Project, source: Id, target: Id) -> Id {
p.relate(RelationKind::StateTransition, "Transition", source, target)
}
fn set_kind(p: &mut Project, id: Id, value: TransitionKind) {
if let RelationBody::StateTransition {
transition_kind, ..
} = &mut p.relations.get_mut(&id).unwrap().body
{
*transition_kind = value;
}
}
fn set_guard(p: &mut Project, id: Id, value: &str) {
if let RelationBody::StateTransition { guard, .. } = &mut p.relations.get_mut(&id).unwrap().body
{
*guard = value.into();
}
}
fn event(p: &mut Project, layer: Layer, body: EventBody) -> Id {
let id = add(p, Kind::Event, layer, None);
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Event { body };
id
}
fn diagnostics(p: &Project, code: &str) -> Vec<validation::Issue> {
validation::validate(p)
.into_iter()
.filter(|i| i.code == code)
.collect()
}
fn valid(p: &Project) {
let errors: Vec<_> = validation::validate(p)
.into_iter()
.filter(|i| i.severity == Severity::Error)
.collect();
assert!(errors.is_empty(), "{errors:?}");
}
fn rejects(p: &Project, code: &str) {
assert!(
diagnostics(p, code)
.iter()
.any(|i| i.severity == Severity::Error),
"Expected {code}: {:?}",
validation::validate(p)
);
}
fn fixture(layer: Layer) -> (Project, Id, Id, Id, Id) {
let mut p = Project::empty();
let owner = add(
&mut p,
if layer == Layer::Oa {
Kind::Entity
} else {
Kind::System
},
layer,
None,
);
let machine = add(&mut p, Kind::StateMachine, layer, Some(owner));
let main = region(&mut p, machine);
let initial = vertex(&mut p, main, Kind::Initial);
let ready = vertex(&mut p, main, Kind::State);
edge(&mut p, initial, ready);
(p, owner, machine, main, ready)
}
#[test]
fn every_vertex_is_an_editable_draft_but_only_stable_states_own_behavior_or_regions() {
for layer in [Layer::Oa, Layer::Sa] {
for kind in Kind::ALL.into_iter().filter(|k| k.is_state()) {
let (mut p, _, _, main, _) = fixture(layer);
if kind == Kind::Initial {
continue;
}
let id = vertex(&mut p, main, kind);
valid(&p);
assert!(!validation::can_parent(&p, kind, layer, None));
assert!(validation::can_parent(&p, kind, layer, Some(main)));
assert_eq!(
validation::can_parent(&p, Kind::Region, layer, Some(id)),
kind.is_stable_state()
);
let work = add(
&mut p,
if layer == Layer::Oa {
Kind::Activity
} else {
Kind::Function
},
layer,
None,
);
assert_eq!(
RelationKind::StateAction.permits(kind, p.elements[&work].kind()),
kind.is_stable_state()
);
assert_eq!(
RelationKind::Availability.permits(p.elements[&work].kind(), kind),
kind.is_stable_state()
);
if !kind.is_stable_state() {
if let Some(SharedBody::State { entry, .. }) =
p.elements.get_mut(&id).unwrap().shared_mut()
{
*entry = "Retained legacy annotation".into();
}
assert_eq!(diagnostics(&p, "pseudostate_behavior").len(), 1);
valid(&p);
}
}
}
}
#[test]
fn typed_events_trigger_alternatives_and_effects_validate_without_owner_restrictions() {
let (mut p, _, _, main, ready) = fixture(Layer::Sa);
let end = vertex(&mut p, main, Kind::Final);
let item = add(&mut p, Kind::ExchangeItem, Layer::Sa, None);
let signal = event(&mut p, Layer::Sa, EventBody::Signal { items: vec![item] });
let time = event(
&mut p,
Layer::Sa,
EventBody::Time {
kind: TimeEventKind::After,
expression: "30 s".into(),
},
);
let change = event(
&mut p,
Layer::Sa,
EventBody::Change {
expression: "pressure > limit".into(),
},
);
let work = add(&mut p, Kind::Function, Layer::Sa, None);
let external = add(&mut p, Kind::SystemActor, Layer::Sa, None);
p.relate(
RelationKind::Allocation,
"External function",
external,
work,
);
let transition = edge(&mut p, ready, end);
if let RelationBody::StateTransition {
triggers,
effects,
trigger,
effect,
..
} = &mut p.relations.get_mut(&transition).unwrap().body
{
*triggers = vec![signal, time, change, work];
*effects = vec![work];
*trigger = "Any of these events".into();
*effect = "Record the reason".into();
}
valid(&p);
assert!(diagnostics(&p, "unresolved_trigger").is_empty());
let baseline = p.clone();
for replacement in [vec![signal, signal], vec![item], vec![Id::new_v4()]] {
let mut invalid = baseline.clone();
if let RelationBody::StateTransition { triggers, .. } =
&mut invalid.relations.get_mut(&transition).unwrap().body
{
*triggers = replacement;
}
assert!(Session::try_new(invalid).is_err());
}
if let RelationBody::StateTransition { effects, .. } =
&mut p.relations.get_mut(&transition).unwrap().body
{
*effects = vec![signal];
}
rejects(&p, "invalid_reference_kind");
p = baseline.clone();
if let Some(SharedBody::Event { body }) = p.elements.get_mut(&signal).unwrap().shared_mut() {
*body = EventBody::Signal { items: vec![work] };
}
rejects(&p, "invalid_reference_kind");
let mut p = baseline;
let foreign = event(&mut p, Layer::Oa, EventBody::Signal { items: vec![] });
if let RelationBody::StateTransition { triggers, .. } =
&mut p.relations.get_mut(&transition).unwrap().body
{
*triggers = vec![foreign];
}
rejects(&p, "invalid_reference_kind");
for body in [
EventBody::Time {
kind: TimeEventKind::At,
expression: String::new(),
},
EventBody::Change {
expression: " ".into(),
},
] {
let mut p = Project::empty();
event(&mut p, Layer::Oa, body);
valid(&p);
assert_eq!(diagnostics(&p, "missing_event_expression").len(), 1);
}
}
#[test]
fn event_reference_identity_cleanup_and_undo_preserve_triggering_intent() {
let (mut p, _, _, main, ready) = fixture(Layer::Oa);
let end = vertex(&mut p, main, Kind::Final);
let item = add(&mut p, Kind::ExchangeItem, Layer::Oa, None);
let signal = event(&mut p, Layer::Oa, EventBody::Signal { items: vec![item] });
let work = add(&mut p, Kind::Activity, Layer::Oa, None);
let transition = edge(&mut p, ready, end);
if let RelationBody::StateTransition {
triggers,
effects,
guard,
effect,
..
} = &mut p.relations.get_mut(&transition).unwrap().body
{
*triggers = vec![signal];
*effects = vec![work];
*guard = "accepted".into();
*effect = "Effect annotation survives".into();
}
valid(&p);
for id in [item, signal, work, transition, ready, main] {
let mut session = Session::new(p.clone());
let replacement = Id::new_v4();
session
.execute(Command::Reidentify(id, replacement))
.unwrap();
assert!(
session
.project()
.elements
.values()
.all(|e| !references::element_references(e).contains(&id))
);
assert!(
session
.project()
.relations
.values()
.all(|r| !references::relation_references(r).contains(&id))
);
let renamed = session.project().clone();
session.execute(Command::Delete(replacement)).unwrap();
valid(session.project());
if id == signal {
let RelationBody::StateTransition {
trigger,
triggers,
effects,
guard,
effect,
..
} = &session.project().relations[&transition].body
else {
panic!()
};
assert!(triggers.is_empty());
assert!(trigger.contains(&p.elements[&signal].name));
assert!(trigger.contains("replacement trigger"));
assert_eq!(effects, &[work]);
assert_eq!(guard, "accepted");
assert_eq!(effect, "Effect annotation survives");
assert_eq!(
diagnostics(session.project(), "unresolved_trigger").len(),
1
);
}
if id == transition {
assert!(session.project().elements.contains_key(&signal));
}
session.undo();
assert_eq!(session.project(), &renamed);
session.undo();
assert_eq!(session.project(), &p);
session.redo();
assert_eq!(session.project(), &renamed);
}
let mut session = Session::new(p);
let mut changed = session.project().relations[&transition].clone();
if let RelationBody::StateTransition { trigger, .. } = &mut changed.body {
*trigger = "Exact authored legacy trigger".into();
}
session
.execute(Command::EditRelation(transition, changed))
.unwrap();
session.execute(Command::Delete(signal)).unwrap();
assert!(
matches!(&session.project().relations[&transition].body, RelationBody::StateTransition { trigger, .. } if trigger == "Exact authored legacy trigger")
);
}
#[test]
fn transition_kinds_distinguish_internal_external_self_and_local_composite_routing() {
let (mut p, _, _, main, composite) = fixture(Layer::Sa);
let nested = region(&mut p, composite);
let child = vertex(&mut p, nested, Kind::Mode);
let other = vertex(&mut p, main, Kind::State);
let local = edge(&mut p, composite, child);
set_kind(&mut p, local, TransitionKind::Local);
let internal = edge(&mut p, composite, composite);
set_kind(&mut p, internal, TransitionKind::Internal);
edge(&mut p, child, child);
edge(&mut p, child, other);
valid(&p);
for (source, target, kind) in [
(child, other, TransitionKind::Internal),
(composite, composite, TransitionKind::Local),
(composite, other, TransitionKind::Local),
(child, other, TransitionKind::Local),
] {
let mut invalid = p.clone();
let id = edge(&mut invalid, source, target);
set_kind(&mut invalid, id, kind);
rejects(&invalid, "transition_kind");
}
let machine = add(&mut p, Kind::StateMachine, Layer::Sa, None);
let foreign = region(&mut p, machine);
let foreign = vertex(&mut p, foreign, Kind::State);
assert!(!validation::can_connect(
&p,
RelationKind::StateTransition,
child,
foreign
));
edge(&mut p, child, foreign);
rejects(&p, "transition_machine");
}
#[test]
fn initial_final_and_terminate_reject_forbidden_routes_and_cardinality_atomically() {
let (p, _, _, main, ready) = fixture(Layer::Oa);
let initial = p
.children(main)
.find(|e| e.kind() == Kind::Initial)
.unwrap()
.id;
assert!(!validation::can_connect(
&p,
RelationKind::StateTransition,
initial,
ready
));
let mut invalid = p.clone();
edge(&mut invalid, initial, ready);
rejects(&invalid, "initial_transition_count");
for kind in [Kind::Final, Kind::Terminate] {
let mut p = p.clone();
let terminal = vertex(&mut p, main, kind);
edge(&mut p, ready, terminal);
valid(&p);
assert!(!validation::can_connect(
&p,
RelationKind::StateTransition,
terminal,
ready
));
edge(&mut p, terminal, ready);
rejects(&p, "pseudostate_direction");
}
for kind in Kind::ALL
.into_iter()
.filter(|k| k.is_state() && !k.is_stable_state())
{
let mut p = p.clone();
let v = if kind == Kind::Initial {
initial
} else {
vertex(&mut p, main, kind)
};
let transition = edge(&mut p, v, v);
rejects(&p, "pseudostate_self");
let mut before = p.clone();
before.relations.remove(&transition);
let mut session = Session::new(before.clone());
assert!(session.execute(Command::Replace(p)).is_err());
assert_eq!(session.project(), &before);
}
for (prose, guard) in [("timer", ""), ("", "condition")] {
let mut p = p.clone();
let id = p
.relations
.values()
.find(|r| r.source() == initial)
.unwrap()
.id;
if let RelationBody::StateTransition {
trigger,
guard: actual,
..
} = &mut p.relations.get_mut(&id).unwrap().body
{
*trigger = prose.into();
*actual = guard.into();
}
rejects(&p, "initial_transition");
}
}
#[test]
fn choices_and_junctions_report_unfinished_guards_but_reject_conflicting_defaults_or_triggers() {
let (mut p, _, _, main, ready) = fixture(Layer::Sa);
let choice = vertex(&mut p, main, Kind::Choice);
let junction = vertex(&mut p, main, Kind::Junction);
let fallback = vertex(&mut p, main, Kind::State);
edge(&mut p, ready, choice);
let yes = edge(&mut p, choice, junction);
set_guard(&mut p, yes, "pressure <= limit");
let no = edge(&mut p, choice, fallback);
set_guard(&mut p, no, "else");
edge(&mut p, fallback, junction);
edge(&mut p, junction, ready);
valid(&p);
assert!(diagnostics(&p, "missing_choice_guard").is_empty());
let mut invalid = p.clone();
set_guard(&mut invalid, yes, "else");
rejects(&invalid, "choice_default");
let mut draft = p.clone();
set_guard(&mut draft, yes, "");
set_guard(&mut draft, no, "unknown");
valid(&draft);
assert_eq!(diagnostics(&draft, "missing_choice_guard").len(), 1);
assert_eq!(diagnostics(&draft, "missing_choice_default").len(), 1);
assert!(!validation::can_connect(
&p,
RelationKind::StateTransition,
fallback,
choice
));
let mut invalid = p.clone();
edge(&mut invalid, fallback, choice);
rejects(&invalid, "pseudostate_cardinality");
for source in [choice, junction] {
let mut invalid = p.clone();
let id = invalid
.relations
.values()
.find(|r| r.source() == source)
.unwrap()
.id;
if let RelationBody::StateTransition { trigger, .. } =
&mut invalid.relations.get_mut(&id).unwrap().body
{
*trigger = "after 30s".into();
}
rejects(&invalid, "pseudostate_trigger");
}
edge(&mut p, junction, fallback);
valid(&p);
assert_eq!(diagnostics(&p, "junction_guard").len(), 1);
}
#[test]
fn histories_remain_per_region_with_shallow_direct_and_deep_nested_fallbacks() {
let (mut p, _, _, main, ready) = fixture(Layer::Sa);
let nested = region(&mut p, ready);
let nested_state = vertex(&mut p, nested, Kind::State);
let shallow = vertex(&mut p, main, Kind::ShallowHistory);
let deep = vertex(&mut p, main, Kind::DeepHistory);
assert_eq!(diagnostics(&p, "incomplete_state_vertex").len(), 2);
let fallback = edge(&mut p, shallow, ready);
edge(&mut p, deep, nested_state);
valid(&p);
let mut invalid = p.clone();
invalid
.relations
.get_mut(&fallback)
.unwrap()
.body
.set_endpoints(shallow, nested_state);
rejects(&invalid, "history_scope");
let mut invalid = p.clone();
set_guard(&mut invalid, fallback, "resume");
rejects(&invalid, "pseudostate_segment");
let mut invalid = p.clone();
edge(&mut invalid, deep, ready);
rejects(&invalid, "pseudostate_cardinality");
for (kind, code) in [
(Kind::ShallowHistory, "multiple_shallow_history"),
(Kind::DeepHistory, "multiple_deep_history"),
] {
let mut invalid = p.clone();
vertex(&mut invalid, main, kind);
rejects(&invalid, code);
let mut valid_other = p.clone();
vertex(&mut valid_other, nested, kind);
valid(&valid_other);
}
let outside = add(&mut p, Kind::StateMachine, Layer::Sa, None);
let outside_region = region(&mut p, outside);
let foreign = vertex(&mut p, outside_region, Kind::State);
edge(&mut p, deep, foreign);
rejects(&p, "history_scope");
}
#[test]
fn fork_join_detect_concurrency_at_the_common_composite_not_deep_immediate_regions() {
let (mut p, _, machine, main, ready) = fixture(Layer::Sa);
let fork = vertex(&mut p, main, Kind::Fork);
let join = vertex(&mut p, main, Kind::Join);
let left = vertex(&mut p, main, Kind::State);
let left_region = region(&mut p, left);
let left_leaf = vertex(&mut p, left_region, Kind::State);
let right_region = region(&mut p, machine);
let right = vertex(&mut p, right_region, Kind::State);
let right_region = region(&mut p, right);
let right_leaf = vertex(&mut p, right_region, Kind::State);
edge(&mut p, ready, fork);
let arm = edge(&mut p, fork, left_leaf);
edge(&mut p, fork, right_leaf);
edge(&mut p, left_leaf, join);
edge(&mut p, right_leaf, join);
edge(&mut p, join, ready);
valid(&p);
let sibling_region = region(&mut p, left);
let sibling_leaf = vertex(&mut p, sibling_region, Kind::State);
assert!(!validation::can_connect(
&p,
RelationKind::StateTransition,
fork,
sibling_leaf
));
let mut invalid = p.clone();
edge(&mut invalid, fork, sibling_leaf);
rejects(&invalid, "orthogonal_regions");
let mut invalid = p.clone();
set_guard(&mut invalid, arm, "ready");
rejects(&invalid, "pseudostate_segment");
let mut invalid = p.clone();
edge(&mut invalid, fork, join);
rejects(&invalid, "orthogonal_endpoint");
let mut invalid = p.clone();
edge(&mut invalid, ready, fork);
rejects(&invalid, "pseudostate_cardinality");
let mut invalid = p.clone();
edge(&mut invalid, join, left);
rejects(&invalid, "pseudostate_cardinality");
let mut invalid = p.clone();
let incoming = invalid
.relations
.values()
.find(|r| r.target() == join)
.unwrap()
.id;
if let RelationBody::StateTransition { trigger, .. } =
&mut invalid.relations.get_mut(&incoming).unwrap().body
{
*trigger = "synchronize".into();
}
rejects(&invalid, "pseudostate_segment");
}
#[test]
fn boundary_points_enforce_inside_outside_local_routing_and_one_route_per_region() {
let (mut p, _, machine, main, composite) = fixture(Layer::Sa);
let outside = vertex(&mut p, main, Kind::State);
let nested = region(&mut p, composite);
let child = vertex(&mut p, nested, Kind::State);
let entry = vertex(&mut p, nested, Kind::EntryPoint);
let exit = vertex(&mut p, nested, Kind::ExitPoint);
edge(&mut p, outside, entry);
assert!(validation::can_connect(
&p,
RelationKind::StateTransition,
entry,
child
));
let route = edge(&mut p, entry, child);
assert!(matches!(
p.relations[&route].body,
RelationBody::StateTransition {
transition_kind: TransitionKind::Local,
..
}
));
edge(&mut p, child, exit);
edge(&mut p, exit, outside);
valid(&p);
for (source, target) in [
(child, entry),
(entry, outside),
(outside, exit),
(exit, child),
] {
assert!(!validation::can_connect(
&p,
RelationKind::StateTransition,
source,
target
));
let mut invalid = p.clone();
edge(&mut invalid, source, target);
rejects(&invalid, "boundary_transition");
}
let other = vertex(&mut p, nested, Kind::Mode);
assert!(!validation::can_connect(
&p,
RelationKind::StateTransition,
entry,
other
));
let mut invalid = p.clone();
edge(&mut invalid, entry, other);
rejects(&invalid, "entry_point_cardinality");
let mut invalid = p.clone();
set_kind(&mut invalid, route, TransitionKind::External);
rejects(&invalid, "transition_kind");
let concurrent = region(&mut p, composite);
let concurrent_state = vertex(&mut p, concurrent, Kind::State);
assert_eq!(diagnostics(&p, "entry_region_default").len(), 1);
edge(&mut p, entry, concurrent_state);
valid(&p);
assert!(diagnostics(&p, "entry_region_default").is_empty());
let top_region = region(&mut p, machine);
let top_exit = vertex(&mut p, top_region, Kind::ExitPoint);
edge(&mut p, outside, top_exit);
valid(&p);
assert!(
!diagnostics(&p, "incomplete_state_vertex")
.iter()
.any(|i| i.element == Some(top_exit))
);
}
#[test]
fn reachability_follows_nested_default_entry_and_reports_disconnected_vertices() {
let (mut p, _, _, main, composite) = fixture(Layer::Oa);
let nested = region(&mut p, composite);
let initial = vertex(&mut p, nested, Kind::Initial);
let child = vertex(&mut p, nested, Kind::State);
edge(&mut p, initial, child);
let isolated = vertex(&mut p, main, Kind::Final);
let unreachable = diagnostics(&p, "unreachable_state");
assert_eq!(unreachable.len(), 1);
assert_eq!(unreachable[0].element, Some(isolated));
edge(&mut p, child, isolated);
valid(&p);
assert!(diagnostics(&p, "unreachable_state").is_empty());
}
#[test]
fn machine_subtrees_and_selected_edges_transition_events_work_and_payloads_in_both_directions() {
for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] {
let layer = direction.source_layer();
let (mut p, owner, machine, main, ready) = fixture(layer);
let end = vertex(&mut p, main, Kind::Terminate);
let vertices: Vec<_> = Kind::ALL
.into_iter()
.filter(|kind| {
kind.is_state() && !matches!(kind, Kind::Initial | Kind::State | Kind::Terminate)
})
.map(|kind| vertex(&mut p, main, kind))
.collect();
let item = add(&mut p, Kind::ExchangeItem, layer, None);
let signal = event(&mut p, layer, EventBody::Signal { items: vec![item] });
let work = add(
&mut p,
if layer == Layer::Oa {
Kind::Activity
} else {
Kind::Function
},
layer,
None,
);
p.relate(RelationKind::Allocation, "Performs", owner, work);
let transition = edge(&mut p, ready, end);
if let RelationBody::StateTransition {
triggers,
effects,
guard,
..
} = &mut p.relations.get_mut(&transition).unwrap().body
{
*triggers = vec![signal, work];
*effects = vec![work];
*guard = "accepted".into();
}
let action = p.relate(RelationKind::StateAction, "On entry", ready, work);
let availability = p.relate(RelationKind::Availability, "Available", work, ready);
valid(&p);
for (source, scope) in [
(machine, DependencyScope::Subtree),
(transition, DependencyScope::Required),
] {
let mut request = TransitionRequest::new(direction, vec![source]);
request.scope = scope;
request.mappings.push(TransitionMapping {
source: owner,
target: None,
target_kind: Some(if layer == Layer::Oa {
Kind::System
} else {
Kind::Entity
}),
});
let plan = plan_transition(&p, &request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
let candidate = execute_batch(&p, &plan.commands).unwrap();
valid(&candidate);
let mapped = |id| plan.changes.iter().find(|c| c.source == id).unwrap().target;
assert!(
matches!(&candidate.relations[&mapped(transition)].body, RelationBody::StateTransition { triggers, effects, guard, .. } if *triggers == vec![mapped(signal), mapped(work)] && *effects == vec![mapped(work)] && guard == "accepted")
);
assert!(
matches!(&candidate.elements[&mapped(signal)].shared(), Some(SharedBody::Event { body: EventBody::Signal { items } }) if *items == vec![mapped(item)])
);
if scope == DependencyScope::Subtree {
assert!(candidate.relations.contains_key(&mapped(action)));
for id in &vertices {
assert_eq!(
candidate.elements[&mapped(*id)].kind(),
p.elements[id].kind()
);
assert!(
candidate
.traces
.values()
.any(|t| t.kind == TraceKind::State && (t.oa == *id || t.sa == *id))
);
}
}
assert!(!plan.changes.iter().any(|c| c.source == availability));
assert!(
candidate
.traces
.values()
.any(|t| t.kind == TraceKind::State && (t.oa == signal || t.sa == signal))
);
let mut session = Session::new(p.clone());
session.execute_many(&plan.commands).unwrap();
session.undo();
assert_eq!(session.project(), &p);
session.redo();
assert_eq!(session.project(), &candidate);
}
}
}
+519
View File
@@ -0,0 +1,519 @@
use oa_core::{
references,
transitions::*,
validation::{self, Severity},
*,
};
fn kinds(layer: Layer) -> (Kind, Kind) {
match layer {
Layer::Oa => (Kind::Process, Kind::Activity),
Layer::Sa => (Kind::FunctionalChain, Kind::Function),
}
}
fn occurrence(p: &Project, owner: Id, name: &str, body: OccurrenceBody) -> Element {
let mut element = p
.make_element(
Kind::Occurrence,
p.elements[&owner].layer(),
name,
Some(owner),
)
.unwrap();
*element.shared_mut().unwrap() = SharedBody::Occurrence {
body,
precondition: String::new(),
postcondition: String::new(),
};
element
}
fn add_occurrence(p: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id {
let element = occurrence(p, owner, name, body);
let id = element.id;
p.elements.insert(id, element);
p.elements
.get_mut(&owner)
.unwrap()
.steps_mut()
.unwrap()
.push(id);
id
}
fn action(p: &mut Project, owner: Id, name: &str) -> Id {
let layer = p.elements[&owner].layer();
let work = p.add_element(kinds(layer).1, layer, name, None).unwrap();
let id = add_occurrence(
p,
owner,
&format!("Perform {name}"),
OccurrenceBody::Action {
action: Some(work),
lifeline: None,
},
);
p.elements.get_mut(&id).unwrap().description =
format!("Contribution within {}", p.elements[&owner].name);
id
}
fn link(p: &mut Project, source: Id, target: Id, guard: &str) -> Id {
let id = p.relate(
RelationKind::SequenceLink,
"Next contribution",
source,
target,
);
if let RelationBody::SequenceLink { guard: value, .. } =
&mut p.relations.get_mut(&id).unwrap().body
{
*value = guard.into();
}
id
}
fn branch_fixture(layer: Layer) -> (Project, Id, Id) {
let mut p = Project::empty();
let main = p
.add_element(kinds(layer).0, layer, "Respond to distress", None)
.unwrap();
let reusable = p
.add_element(kinds(layer).0, layer, "Prepare resources", None)
.unwrap();
action(&mut p, reusable, "Mobilize responder");
let start = action(&mut p, main, "Assess distress");
let fork = add_occurrence(
&mut p,
main,
"Prepare in parallel",
OccurrenceBody::Control {
kind: ControlKind::Fork,
},
);
let locate = action(&mut p, main, "Locate vessel");
let reuse = add_occurrence(
&mut p,
main,
"Use preparations",
OccurrenceBody::SequenceReference {
sequence: Some(reusable),
},
);
let join = add_occurrence(
&mut p,
main,
"Both preparations ready",
OccurrenceBody::Control {
kind: ControlKind::Join,
},
);
let choice = add_occurrence(
&mut p,
main,
"Select response",
OccurrenceBody::Control {
kind: ControlKind::Choice,
},
);
let direct = action(&mut p, main, "Dispatch rescue");
let fallback = action(&mut p, main, "Request assistance");
let merge = add_occurrence(
&mut p,
main,
"Response selected",
OccurrenceBody::Control {
kind: ControlKind::Merge,
},
);
let end = action(&mut p, main, "Confirm response");
for (source, target, guard) in [
(start, fork, ""),
(fork, locate, ""),
(fork, reuse, ""),
(locate, join, ""),
(reuse, join, ""),
(join, choice, ""),
(choice, direct, "available_resources > 0"),
(choice, fallback, "else"),
(direct, merge, ""),
(fallback, merge, ""),
(merge, end, ""),
] {
link(&mut p, source, target, guard);
}
(p, main, reusable)
}
fn errors(p: &Project) -> Vec<validation::Issue> {
validation::validate(p)
.into_iter()
.filter(|i| i.severity == Severity::Error)
.collect()
}
fn coherent_path(p: &Project) {
// These contextual-path fixtures deliberately leave the global system boundary
// and performer allocation open.
let unexpected: Vec<_> = validation::validate(p)
.into_iter()
.filter(|issue| {
issue.severity != Severity::Warning
|| !matches!(issue.code.as_str(), "unallocated_leaf" | "missing_system")
})
.collect();
assert!(unexpected.is_empty(), "{unexpected:?}");
}
#[test]
fn branching_reusable_processes_transition_as_contextual_topology_in_both_directions() {
for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] {
let (p, main, reusable) = branch_fixture(direction.source_layer());
coherent_path(&p);
let mut request = TransitionRequest::new(direction, vec![main]);
request.scope = DependencyScope::Required;
let plan = plan_transition(&p, &request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
let mapped = |id| {
plan.changes
.iter()
.find(|change| change.source == id)
.unwrap()
.target
};
let candidate = apply_transition(&p, &plan).unwrap();
assert_eq!(
candidate.elements[&mapped(main)].kind(),
kinds(direction.target_layer()).0
);
assert_eq!(
candidate.elements[&mapped(reusable)].kind(),
kinds(direction.target_layer()).0
);
for element in p.elements.values() {
let counterpart = &candidate.elements[&mapped(element.id)];
assert_eq!(counterpart.description, element.description);
let expected: Vec<_> = references::element_references(element)
.into_iter()
.map(mapped)
.collect();
assert_eq!(references::element_references(counterpart), expected);
if element.kind() == Kind::Occurrence {
let trace = candidate
.traces
.values()
.find(|trace| trace.oa == element.id || trace.sa == element.id)
.unwrap();
assert_eq!(trace.kind, TraceKind::Sequence);
}
}
for relation in p.relations.values() {
let counterpart = &candidate.relations[&mapped(relation.id)];
let mut expected = relation.body.clone();
expected.set_endpoints(mapped(relation.source()), mapped(relation.target()));
assert_eq!(counterpart.body, expected);
}
assert!(
candidate
.relations
.values()
.all(|r| r.kind() == RelationKind::SequenceLink),
"contextual precedence never fabricates global exchanges"
);
let mut session = Session::new(p.clone());
session.execute_many(&plan.commands).unwrap();
assert_eq!(session.project(), &candidate);
session.undo();
assert_eq!(session.project(), &p);
session.redo();
assert_eq!(session.project(), &candidate);
let mut reverse = TransitionRequest::new(
if direction == TransitionDirection::OaToSa {
TransitionDirection::SaToOa
} else {
TransitionDirection::OaToSa
},
vec![mapped(main)],
);
reverse.scope = DependencyScope::Required;
let repeat = plan_transition(&candidate, &reverse).unwrap();
assert!(repeat.conflicts.is_empty(), "{:?}", repeat.conflicts);
assert!(repeat.commands.is_empty());
}
}
#[test]
fn contextual_links_match_work_exchange_endpoints_and_reject_foreign_owners() {
for layer in [Layer::Oa, Layer::Sa] {
let mut p = Project::empty();
let owner = p
.add_element(kinds(layer).0, layer, "Context", None)
.unwrap();
let a = action(&mut p, owner, "A");
let b = action(&mut p, owner, "B");
let wrong = action(&mut p, owner, "Wrong");
let work = |p: &Project, id| match p.elements[&id].shared().unwrap() {
SharedBody::Occurrence {
body:
OccurrenceBody::Action {
action: Some(work), ..
},
..
} => *work,
_ => unreachable!(),
};
let global = p.relate(
if layer == Layer::Oa {
RelationKind::Interaction
} else {
RelationKind::FunctionalExchange
},
"A sends to B",
work(&p, a),
work(&p, b),
);
let exchange = add_occurrence(
&mut p,
owner,
"Contextual message",
OccurrenceBody::Exchange {
exchange: Some(global),
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
},
);
let other = p
.add_element(kinds(layer).0, layer, "Other context", None)
.unwrap();
let foreign = action(&mut p, other, "Foreign");
for (source, target) in [(a, exchange), (exchange, b)] {
assert!(validation::can_connect(
&p,
RelationKind::SequenceLink,
source,
target
));
}
for (source, target) in [(wrong, exchange), (exchange, wrong), (a, foreign), (a, a)] {
assert!(!validation::can_connect(
&p,
RelationKind::SequenceLink,
source,
target
));
assert!(
execute_batch(
&p,
&[Command::Connect(
RelationKind::SequenceLink,
"Invalid".into(),
source,
target
)]
)
.is_err()
);
}
let original_exchange = p.relations[&global].clone();
let accepted = execute_batch(
&p,
&[
Command::Connect(RelationKind::SequenceLink, "Before".into(), a, exchange),
Command::Connect(RelationKind::SequenceLink, "After".into(), exchange, b),
],
)
.unwrap();
assert_eq!(accepted.relations[&global], original_exchange);
let scenario = p
.add_element(Kind::Scenario, layer, "Timeline", None)
.unwrap();
let invalid = occurrence(
&p,
scenario,
"Wrong control context",
OccurrenceBody::Control {
kind: ControlKind::Fork,
},
);
assert!(
execute_batch(&p, &[Command::CreateElement(invalid)])
.unwrap_err()
.0
.contains("sequence_contribution_owner")
);
}
}
#[test]
fn reuse_rejects_recursive_or_cross_layer_targets_and_deleted_targets_become_drafts() {
let mut p = Project::empty();
let a = p.add_element(Kind::Process, Layer::Oa, "A", None).unwrap();
let b = p.add_element(Kind::Process, Layer::Oa, "B", None).unwrap();
let c = p.add_element(Kind::Process, Layer::Oa, "C", None).unwrap();
let use_b = add_occurrence(
&mut p,
a,
"Reuse B",
OccurrenceBody::SequenceReference { sequence: Some(b) },
);
add_occurrence(
&mut p,
b,
"Reuse C",
OccurrenceBody::SequenceReference { sequence: Some(c) },
);
let foreign = p
.add_element(Kind::FunctionalChain, Layer::Sa, "SA sequence", None)
.unwrap();
let mut session = Session::new(p.clone());
for target in [a, c, foreign] {
let invalid = occurrence(
session.project(),
c,
"Invalid reuse",
OccurrenceBody::SequenceReference {
sequence: Some(target),
},
);
assert!(session.execute(Command::CreateElement(invalid)).is_err());
assert_eq!(session.project(), &p);
}
let new_b = Id::new_v4();
session.execute(Command::Reidentify(b, new_b)).unwrap();
assert!(references::element_references(&session.project().elements[&use_b]).contains(&new_b));
let reidentified = session.project().clone();
session.execute(Command::Delete(new_b)).unwrap();
assert!(session.project().elements.contains_key(&use_b));
assert!(matches!(
session.project().elements[&use_b].shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::SequenceReference { sequence: None },
..
})
));
assert!(session.project().elements.contains_key(&c));
assert!(errors(session.project()).is_empty());
assert!(
validation::validate(session.project())
.iter()
.any(|issue| issue.element == Some(use_b) && issue.code == "incomplete_reference")
);
session.undo();
assert_eq!(session.project(), &reidentified);
session.undo();
assert_eq!(session.project(), &p);
}
#[test]
fn loops_are_allowed_while_missing_guards_and_disconnected_paths_are_diagnosed() {
let mut p = Project::empty();
let owner = p
.add_element(Kind::Process, Layer::Oa, "Retry until ready", None)
.unwrap();
let start = action(&mut p, owner, "Start");
let gate = add_occurrence(
&mut p,
owner,
"Ready?",
OccurrenceBody::Control {
kind: ControlKind::Loop,
},
);
let repeat = action(&mut p, owner, "Retry");
let end = action(&mut p, owner, "Finish");
link(&mut p, start, gate, "");
link(&mut p, gate, repeat, "!ready");
link(&mut p, repeat, gate, "");
let exit = link(&mut p, gate, end, "ready");
coherent_path(&p);
if let RelationBody::SequenceLink { guard, .. } = &mut p.relations.get_mut(&exit).unwrap().body
{
guard.clear();
}
assert!(errors(&p).is_empty());
assert!(
validation::validate(&p)
.iter()
.any(|i| i.element == Some(exit) && i.code == "missing_sequence_guard")
);
let island = action(&mut p, owner, "Disconnected work");
assert!(
validation::validate(&p)
.iter()
.any(|i| i.element == Some(island) && i.code == "isolated_contribution")
);
p.relations.remove(&exit);
assert!(
validation::validate(&p)
.iter()
.any(|i| i.element == Some(gate) && i.code == "control_cardinality")
);
assert!(
validation::validate(&p)
.iter()
.any(|i| i.element == Some(gate) && i.code == "nonterminating_contribution")
);
}
#[test]
fn topology_identity_edits_and_deletions_preserve_remaining_paths_and_undo() {
let (p, main, _) = branch_fixture(Layer::Sa);
for id in p.elements.keys().chain(p.relations.keys()).copied() {
let mut session = Session::new(p.clone());
let replacement = Id::new_v4();
session
.execute(Command::Reidentify(id, replacement))
.unwrap();
let reidentified = session.project().clone();
assert!(
!reidentified
.elements
.values()
.any(|e| references::element_references(e).contains(&id))
);
assert!(
!reidentified
.relations
.values()
.any(|r| references::relation_references(r).contains(&id))
);
session.execute(Command::Delete(replacement)).unwrap();
assert!(errors(session.project()).is_empty());
session.undo();
assert_eq!(session.project(), &reidentified);
session.undo();
assert_eq!(session.project(), &p);
}
// List order is presentation order: changing it leaves explicit links untouched.
let mut session = Session::new(p.clone());
session.execute(Command::MoveStep(main, 0, 4)).unwrap();
assert_eq!(session.project().relations, p.relations);
coherent_path(session.project());
}
#[test]
fn legacy_linear_exchange_only_sequences_keep_their_existing_meaning() {
let mut p = Project::empty();
let a = p.add_element(Kind::Activity, Layer::Oa, "A", None).unwrap();
let b = p.add_element(Kind::Activity, Layer::Oa, "B", None).unwrap();
let c = p.add_element(Kind::Activity, Layer::Oa, "C", None).unwrap();
let owner = p
.add_element(Kind::Process, Layer::Oa, "Legacy order", None)
.unwrap();
let ab = p.relate(RelationKind::Interaction, "AB", a, b);
let ca = p.relate(RelationKind::Interaction, "CA", c, a);
let mut session = Session::new(p);
session.execute(Command::AddStep(owner, ab)).unwrap();
session.execute(Command::AddStep(owner, ca)).unwrap();
let issues = validation::validate(session.project());
assert!(issues.iter().any(|i| i.code == "sequence_gap"));
assert!(
!issues
.iter()
.any(|i| i.code == "isolated_contribution" || i.code == "sequence_starts")
);
assert!(
session
.project()
.relations
.values()
.all(|r| r.kind() != RelationKind::SequenceLink)
);
}
+575
View File
@@ -0,0 +1,575 @@
use oa_core::{
persistence::{self, FileError},
transitions::*,
*,
};
#[test]
fn unowned_state_machine_is_valid_incomplete_work_in_both_layers() {
for layer in [Layer::Oa, Layer::Sa] {
let p = Project::empty();
assert!(validation::can_parent(&p, Kind::StateMachine, layer, None));
let machine = p
.make_element(Kind::StateMachine, layer, "Draft machine", None)
.unwrap();
let id = machine.id;
let created = execute_batch(&p, &[Command::CreateElement(machine)]).unwrap();
let issues = validation::validate(&created);
assert!(
issues
.iter()
.all(|i| i.severity != validation::Severity::Error)
);
assert!(issues.iter().any(|i| i.element == Some(id)
&& i.code == "machine_owner"
&& i.severity == validation::Severity::Warning));
assert!(!validation::can_parent(&created, Kind::Region, layer, None));
}
}
fn placement_fixture() -> (Project, Id, Id, Id) {
let mut p = Project::empty();
let element = p
.add_element(Kind::Activity, Layer::Oa, "Placed work", None)
.unwrap();
let source = p.ensure_diagram(Layer::Oa);
p.diagrams.get_mut(&source).unwrap().projection = ProjectionKind::Decomposition;
let target = p.ensure_diagram(Layer::Oa);
let node = p.place(source, element, Position { x: 123., y: -42. });
let placement = p
.diagrams
.get_mut(&source)
.unwrap()
.nodes
.get_mut(&node)
.unwrap();
placement.collapsed = true;
placement.size = Size {
width: 350.,
height: 135.,
};
let artifact = Id::new_v4();
p.artifact_links.insert(
artifact,
ArtifactLink {
id: artifact,
alias: "placement_evidence".into(),
name: "Placement evidence".into(),
description: "Retained with the same placement".into(),
owner: node,
relation: ArtifactRelation::References,
uri: "git:designs/layout#view".into(),
revision: Some("layout-v2".into()),
fragment: None,
},
);
(p, source, target, node)
}
#[test]
fn relocating_a_placement_preserves_identity_artifacts_and_history() {
let (p, source, target, node) = placement_fixture();
let original = p.diagrams[&source].nodes[&node].clone();
let command = Command::RelocateNode {
node,
diagram: target,
};
let json = serde_json::to_string(&command).unwrap();
assert_eq!(serde_json::from_str::<Command>(&json).unwrap(), command);
let mut session = Session::new(p.clone());
assert_eq!(session.execute(command.clone()).unwrap(), Some(node));
assert!(
!session.project().diagrams[&source]
.nodes
.contains_key(&node)
);
assert_eq!(session.project().diagrams[&target].nodes[&node], original);
assert_eq!(session.project().artifact_links, p.artifact_links);
assert_eq!(session.project().elements, p.elements);
let revision = session.revision();
let relocated = session.project().clone();
session.execute(command).unwrap();
assert_eq!(session.revision(), revision);
session.undo();
assert_eq!(session.project(), &p);
session.redo();
assert_eq!(session.project(), &relocated);
}
#[test]
fn invalid_placement_relocations_preserve_model_artifacts_and_redo() {
let (mut p, source, target, node) = placement_fixture();
let element = p.diagrams[&source].nodes[&node].element;
p.place(target, element, Position { x: 0., y: 0. });
let incompatible = p.ensure_diagram(Layer::Sa);
let mut session = Session::new(p.clone());
session
.execute(Command::Rename(element, "Pending redo".into()))
.unwrap();
session.undo();
let revision = session.revision();
for diagram in [target, incompatible, Id::new_v4()] {
assert!(
session
.execute(Command::RelocateNode { node, diagram })
.is_err()
);
assert_eq!(session.project(), &p);
assert_eq!(session.revision(), revision);
assert!(session.can_redo());
}
assert!(
execute_batch(
&p,
&[Command::RelocateNode {
node: Id::new_v4(),
diagram: source
}]
)
.is_err()
);
let mut traceability = p.diagrams[&incompatible].clone();
traceability.projection = ProjectionKind::Traceability;
let accepted = execute_batch(
&p,
&[
Command::EditDiagram(incompatible, traceability),
Command::RelocateNode {
node,
diagram: incompatible,
},
],
)
.unwrap();
assert_eq!(
accepted.diagrams[&incompatible].nodes[&node],
p.diagrams[&source].nodes[&node]
);
assert_eq!(accepted.artifact_links, p.artifact_links);
}
fn reviewed_divergence(direction: TransitionDirection) -> (Project, Id, Id) {
let mut p = Project::empty();
let oa = p
.add_element(Kind::Activity, Layer::Oa, "Operational assessment", None)
.unwrap();
let sa = p
.add_element(Kind::Function, Layer::Sa, "System classification", None)
.unwrap();
p.elements.get_mut(&oa).unwrap().description = "Operational intent".into();
p.elements.get_mut(&sa).unwrap().description = "Technical responsibility".into();
let (source, target) = match direction {
TransitionDirection::OaToSa => (oa, sa),
TransitionDirection::SaToOa => (sa, oa),
};
let mut request = TransitionRequest::new(direction, vec![source]);
request.mappings.push(TransitionMapping {
source,
target: Some(target),
target_kind: None,
});
for field in [TransitionField::Name, TransitionField::Description] {
request.resolutions.push(FieldResolution {
source,
field,
decision: FieldDecision::KeepTarget,
});
}
let plan = plan_transition(&p, &request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
let reviewed = apply_transition(&p, &plan).unwrap();
assert_eq!(reviewed.properties(source), p.properties(source));
assert_eq!(reviewed.properties(target), p.properties(target));
(reviewed, source, target)
}
fn reverse(direction: TransitionDirection) -> TransitionDirection {
match direction {
TransitionDirection::OaToSa => TransitionDirection::SaToOa,
TransitionDirection::SaToOa => TransitionDirection::OaToSa,
}
}
#[test]
fn keeping_divergent_values_is_idempotent_in_both_directions() {
for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] {
let (p, source, target) = reviewed_divergence(direction);
for (direction, source) in [(direction, source), (reverse(direction), target)] {
let plan =
plan_transition(&p, &TransitionRequest::new(direction, vec![source])).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
assert!(plan.commands.is_empty(), "{:?}", plan.commands);
assert!(
plan.changes
.iter()
.all(|c| c.action == TransitionAction::Reuse && c.fields.is_empty())
);
assert_eq!(apply_transition(&p, &plan).unwrap(), p);
}
}
}
#[test]
fn later_source_changes_propagate_without_overwriting_other_reviewed_fields() {
for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] {
let (p, source, target) = reviewed_divergence(direction);
let target_description = p.elements[&target].description.clone();
let edited =
execute_batch(&p, &[Command::Rename(source, "New source decision".into())]).unwrap();
let request = TransitionRequest::new(direction, vec![source]);
let plan = plan_transition(&edited, &request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
assert_eq!(plan.changes[0].fields.len(), 1);
assert_eq!(plan.changes[0].fields[0].field, TransitionField::Name);
let applied = apply_transition(&edited, &plan).unwrap();
assert_eq!(applied.elements[&target].name, "New source decision");
assert_eq!(applied.elements[&target].description, target_description);
let repeat = plan_transition(&applied, &request).unwrap();
assert!(repeat.conflicts.is_empty());
assert!(repeat.commands.is_empty());
}
}
#[test]
fn destination_only_changes_remain_available_for_reverse_propagation() {
for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] {
let (p, source, target) = reviewed_divergence(direction);
let edited = execute_batch(
&p,
&[Command::Rename(target, "Destination local change".into())],
)
.unwrap();
let forward =
plan_transition(&edited, &TransitionRequest::new(direction, vec![source])).unwrap();
assert!(forward.conflicts.is_empty(), "{:?}", forward.conflicts);
assert!(
forward.commands.is_empty(),
"A skipped field must retain its previous baseline"
);
assert_eq!(apply_transition(&edited, &forward).unwrap(), edited);
let back = plan_transition(
&edited,
&TransitionRequest::new(reverse(direction), vec![target]),
)
.unwrap();
assert!(back.conflicts.is_empty(), "{:?}", back.conflicts);
let applied = apply_transition(&edited, &back).unwrap();
assert_eq!(applied.elements[&source].name, "Destination local change");
}
}
#[test]
fn divergent_changes_on_both_sides_still_require_review() {
for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] {
let (p, source, target) = reviewed_divergence(direction);
let changed = execute_batch(
&p,
&[
Command::Rename(source, "Changed source".into()),
Command::Rename(target, "Changed target".into()),
],
)
.unwrap();
let request = TransitionRequest::new(direction, vec![source]);
let plan = plan_transition(&changed, &request).unwrap();
assert!(
plan.conflicts
.iter()
.any(|c| c.field == Some(TransitionField::Name))
);
assert!(apply_transition(&changed, &plan).is_err());
assert_eq!(changed.elements[&target].name, "Changed target");
}
}
#[test]
fn an_explicit_merged_destination_stays_reviewed_on_repeated_preview() {
for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] {
let (p, source, target) = reviewed_divergence(direction);
let mut request = TransitionRequest::new(direction, vec![source]);
request.resolutions.push(FieldResolution {
source,
field: TransitionField::Name,
decision: FieldDecision::Value("Reviewed merged name".into()),
});
let plan = plan_transition(&p, &request).unwrap();
assert!(plan.conflicts.is_empty());
let applied = apply_transition(&p, &plan).unwrap();
assert_eq!(applied.elements[&target].name, "Reviewed merged name");
let repeated =
plan_transition(&applied, &TransitionRequest::new(direction, vec![source])).unwrap();
assert!(repeated.conflicts.is_empty());
assert!(repeated.commands.is_empty());
}
}
#[test]
fn same_parent_reparent_preserves_sequence_order_revision_and_redo() {
let mut p = Project::empty();
let a = p.add_element(Kind::Activity, Layer::Oa, "A", None).unwrap();
let b = p.add_element(Kind::Activity, Layer::Oa, "B", None).unwrap();
let exchange = p.relate(RelationKind::Interaction, "Message", a, b);
let sequence = p
.add_element(Kind::Process, Layer::Oa, "Procedure", None)
.unwrap();
let mut session = Session::new(p);
for _ in 0..3 {
session
.execute(Command::AddStep(sequence, exchange))
.unwrap();
}
let first = session.project().elements[&sequence].steps().unwrap()[0];
session
.execute(Command::Rename(sequence, "Temporary name".into()))
.unwrap();
session.undo();
let before = session.project().clone();
let revision = session.revision();
session
.execute(Command::Reparent {
element: first,
parent: Some(sequence),
})
.unwrap();
assert_eq!(session.project(), &before);
assert_eq!(session.revision(), revision);
assert!(session.can_redo());
}
fn fragment_session(layer: Layer) -> (Session, Id) {
let mut p = Project::empty();
let scenario = p
.add_element(Kind::Scenario, layer, "Scenario", None)
.unwrap();
let mut fragment = p
.make_element(Kind::Occurrence, layer, "Alternative", Some(scenario))
.unwrap();
*fragment.shared_mut().unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Fragment {
operator: FragmentOperator::Alternative,
branches: vec![],
},
precondition: String::new(),
postcondition: String::new(),
};
let id = fragment.id;
let mut session = Session::new(p);
session.execute(Command::CreateElement(fragment)).unwrap();
(session, id)
}
fn branches(p: &Project, fragment: Id) -> &Vec<Id> {
match p.elements[&fragment].shared().unwrap() {
SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
} => branches,
_ => panic!("Expected fragment"),
}
}
#[test]
fn same_parent_reparent_preserves_fragment_branch_order_and_history() {
for layer in [Layer::Oa, Layer::Sa] {
let (mut session, fragment) = fragment_session(layer);
for name in ["First branch", "Second branch", "Third branch"] {
session
.execute(Command::AddElement {
kind: Kind::Occurrence,
layer,
name: name.into(),
parent: Some(fragment),
})
.unwrap();
}
let first = branches(session.project(), fragment)[0];
session
.execute(Command::Rename(fragment, "Temporary name".into()))
.unwrap();
session.undo();
let before = session.project().clone();
let revision = session.revision();
session
.execute(Command::Reparent {
element: first,
parent: Some(fragment),
})
.unwrap();
assert_eq!(session.project(), &before);
assert_eq!(session.revision(), revision);
assert!(session.can_redo());
}
}
#[test]
fn factory_and_add_commands_share_fragment_branch_defaults() {
for layer in [Layer::Oa, Layer::Sa] {
let (mut session, fragment) = fragment_session(layer);
let child = session
.project()
.make_element(Kind::Occurrence, layer, "Factory branch", Some(fragment))
.unwrap();
let factory_id = child.id;
assert!(matches!(
child.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Branch { .. },
..
})
));
session.execute(Command::CreateElement(child)).unwrap();
let added_id = session
.execute(Command::AddElement {
kind: Kind::Occurrence,
layer,
name: "Command branch".into(),
parent: Some(fragment),
})
.unwrap()
.unwrap();
assert_eq!(
session.project().elements[&factory_id].body,
session.project().elements[&added_id].body
);
assert_eq!(
branches(session.project(), fragment),
&vec![factory_id, added_id]
);
let step = session
.project()
.make_element(Kind::Occurrence, layer, "Branch message", Some(factory_id))
.unwrap();
assert!(matches!(
step.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { .. },
..
})
));
session.execute(Command::CreateElement(step)).unwrap();
}
}
#[test]
fn checked_save_returns_exact_bytes_and_accepts_unchanged_external_content() {
let dir = tempfile::tempdir().unwrap();
for name in ["project.arcadia", "project.oa.json"] {
let path = dir.path().join(name);
let mut p = Project::empty();
let previous = persistence::save_checked(&path, &p, None).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), previous);
p.description = "A checked update with Unicode 海岸 and a newline\n".into();
let written = persistence::save_checked(&path, &p, Some(&previous)).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), written);
assert_eq!(persistence::decode(&written).unwrap(), p);
let same = persistence::save_checked(&path, &p, Some(&written)).unwrap();
assert_eq!(same, written);
assert_eq!(std::fs::read(&path).unwrap(), written);
}
}
#[test]
fn checked_save_rejects_external_changes_and_preserves_their_exact_bytes() {
let dir = tempfile::tempdir().unwrap();
for name in ["project.arcadia", "project.oa.json"] {
let path = dir.path().join(name);
let mut p = Project::empty();
let previous = persistence::save_checked(&path, &p, None).unwrap();
let external = b"External editor content, including an unfinished draft\r\n";
std::fs::write(&path, external).unwrap();
p.name = "Local unsaved changes".into();
assert!(matches!(
persistence::save_checked(&path, &p, Some(&previous)),
Err(FileError::Conflict)
));
assert_eq!(std::fs::read(&path).unwrap(), external);
}
}
#[test]
fn checked_save_rejects_external_deletion_without_recreating_the_file() {
let dir = tempfile::tempdir().unwrap();
for name in ["project.arcadia", "project.oa.json"] {
let path = dir.path().join(name);
let p = Project::empty();
let previous = persistence::save_checked(&path, &p, None).unwrap();
std::fs::remove_file(&path).unwrap();
assert!(matches!(
persistence::save_checked(&path, &p, Some(&previous)),
Err(FileError::Conflict)
));
assert!(!path.exists());
}
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
}
#[test]
fn capability_mission_transition_remains_an_explicit_choice_in_both_directions() {
assert_eq!(
counterpart_kinds(Kind::Capability, TransitionDirection::OaToSa),
vec![Kind::SystemCapability, Kind::Mission]
);
assert_eq!(
counterpart_kinds(Kind::Mission, TransitionDirection::SaToOa),
vec![Kind::OperationalMission, Kind::Capability]
);
for direction in [TransitionDirection::OaToSa, TransitionDirection::SaToOa] {
let mut p = Project::empty();
let (kind, layer) = match direction {
TransitionDirection::OaToSa => (Kind::Capability, Layer::Oa),
TransitionDirection::SaToOa => (Kind::Mission, Layer::Sa),
};
let source = p.add_element(kind, layer, "Protect life", None).unwrap();
let mut request = TransitionRequest::new(direction, vec![source]);
if direction == TransitionDirection::OaToSa {
let default_plan = plan_transition(&p, &request).unwrap();
let default = apply_transition(&p, &default_plan).unwrap();
assert_eq!(
default.elements[&default_plan.changes[0].target].kind(),
Kind::SystemCapability
);
request.mappings.push(TransitionMapping {
source,
target: None,
target_kind: Some(Kind::Mission),
});
} else {
let default_plan = plan_transition(&p, &request).unwrap();
let default = apply_transition(&p, &default_plan).unwrap();
assert_eq!(
default.elements[&default_plan.changes[0].target].kind(),
Kind::OperationalMission
);
request.mappings.push(TransitionMapping {
source,
target: None,
target_kind: Some(Kind::Capability),
});
}
let plan = plan_transition(&p, &request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
let target = plan.changes[0].target;
let applied = apply_transition(&p, &plan).unwrap();
assert_eq!(
applied.elements[&target].kind(),
if direction == TransitionDirection::OaToSa {
Kind::Mission
} else {
Kind::Capability
}
);
assert_eq!(applied.traces.len(), 1);
assert_eq!(
applied.traces.values().next().unwrap().kind,
TraceKind::Manual
);
for (direction, source) in [(direction, source), (reverse(direction), target)] {
let repeat =
plan_transition(&applied, &TransitionRequest::new(direction, vec![source]))
.unwrap();
assert!(repeat.conflicts.is_empty(), "{:?}", repeat.conflicts);
assert!(repeat.commands.is_empty());
assert_eq!(apply_transition(&applied, &repeat).unwrap(), applied);
}
}
}
+859
View File
@@ -0,0 +1,859 @@
use oa_core::{
projection,
transitions::*,
validation::{self, Severity},
*,
};
fn accepted(p: &Project) {
let errors: Vec<_> = validation::validate(p)
.into_iter()
.filter(|i| i.severity == Severity::Error)
.collect();
assert!(errors.is_empty(), "{errors:?}");
}
fn rejected(p: &Project, code: &str) {
assert!(
validation::validate(p)
.iter()
.any(|i| i.severity == Severity::Error && i.code == code),
"Expected {code}: {:?}",
validation::validate(p)
);
}
fn add(p: &mut Project, kind: Kind, layer: Layer, name: &str, parent: Option<Id>) -> Id {
p.add_element(kind, layer, name, parent).unwrap()
}
fn work_pair() -> (Project, Id, Id) {
let mut p = Project::empty();
let oa = add(&mut p, Kind::Activity, Layer::Oa, "Assess", None);
let sa = add(&mut p, Kind::Function, Layer::Sa, "Classify", None);
(p, oa, sa)
}
fn make_trace(p: &mut Project, oa: Id, sa: Id) -> Id {
let id = Id::new_v4();
let t = Trace {
id,
alias: p.fresh_alias("work_trace", "trace", id),
name: "Refinement".into(),
description: "Rationale".into(),
oa,
sa,
kind: TraceKind::Refinement,
baseline: Some(TraceBaseline {
oa: p.properties(oa).unwrap(),
sa: p.properties(sa).unwrap(),
}),
};
p.traces.insert(id, t);
id
}
fn artifact(p: &mut Project, owner: Id) -> Id {
let id = Id::new_v4();
p.artifact_links.insert(
id,
ArtifactLink {
id,
alias: p.fresh_alias("requirement", "artifact", id),
name: "REQ-42".into(),
description: "Engineering requirement".into(),
owner,
relation: ArtifactRelation::Satisfies,
uri: "requirements+custom://project/REQ-42?view=full".into(),
revision: Some("baseline-2026/September".into()),
fragment: Some("verification".into()),
},
);
id
}
#[test]
fn demo_is_valid_and_preserves_uuid_and_alias_through_json() {
let p = Project::demo();
accepted(&p);
let bytes = serde_json::to_vec(&p).unwrap();
assert_eq!(serde_json::from_slice::<Project>(&bytes).unwrap(), p);
assert!(
p.elements
.values()
.find(|e| e.kind() == Kind::Process)
.unwrap()
.steps()
.unwrap()
.iter()
.all(|id| p.elements[id].kind() == Kind::Occurrence)
);
}
#[test]
fn failed_batch_preserves_model_history_revision_and_redo() {
let (mut p, oa, _) = work_pair();
let actor = add(&mut p, Kind::Actor, Layer::Oa, "Reporter", None);
let mut s = Session::new(p);
s.execute(Command::Rename(oa, "Updated".into())).unwrap();
s.undo();
let before = s.project().clone();
let revision = s.revision();
let result = s.execute_many(&[
Command::Rename(oa, "Would be lost".into()),
Command::Connect(
RelationKind::FunctionalExchange,
"Invalid".into(),
actor,
oa,
),
]);
assert!(result.is_err());
assert_eq!(s.project(), &before);
assert_eq!(s.revision(), revision);
assert!(s.can_redo());
}
#[test]
fn batch_is_one_undo_and_noop_does_not_change_revision() {
let (p, oa, _) = work_pair();
let mut s = Session::new(p.clone());
s.execute_many(&[
Command::Rename(oa, "Renamed".into()),
Command::Describe(oa, "Detailed".into()),
])
.unwrap();
assert_eq!(s.revision(), 1);
s.undo();
assert_eq!(s.project(), &p);
assert!(!s.dirty());
let revision = s.revision();
s.execute_many(&[]).unwrap();
assert_eq!(s.revision(), revision);
assert!(s.can_redo());
}
#[test]
fn identity_aliases_and_map_keys_share_global_namespace() {
let (mut p, oa, _) = work_pair();
let d = p.ensure_diagram(Layer::Oa);
let node = p.place(d, oa, Position { x: 0., y: 0. });
p.diagrams
.get_mut(&d)
.unwrap()
.nodes
.get_mut(&node)
.unwrap()
.alias = p.elements[&oa].alias.clone();
rejected(&p, "duplicate_alias");
p.diagrams
.get_mut(&d)
.unwrap()
.nodes
.get_mut(&node)
.unwrap()
.alias = "placed_assess".into();
p.diagrams
.get_mut(&d)
.unwrap()
.nodes
.get_mut(&node)
.unwrap()
.id = oa;
rejected(&p, "duplicate_identity");
rejected(&p, "map_identity");
}
#[test]
fn rename_and_collision_insertion_preserve_existing_aliases() {
let (mut p, oa, _) = work_pair();
let alias = p.elements[&oa].alias.clone();
p = execute_batch(
&p,
&[Command::Rename(oa, "Completely new display name".into())],
)
.unwrap();
assert_eq!(p.elements[&oa].alias, alias);
let first = add(&mut p, Kind::Activity, Layer::Oa, "Same name", None);
let first_alias = p.elements[&first].alias.clone();
let second = add(&mut p, Kind::Activity, Layer::Oa, "Same name", None);
assert_eq!(p.elements[&first].alias, first_alias);
assert_ne!(p.elements[&second].alias, first_alias);
accepted(&p);
}
#[test]
fn nested_work_is_independent_of_leaf_allocation() {
let mut p = Project::empty();
let actor = add(&mut p, Kind::Actor, Layer::Oa, "Actor", None);
let parent = add(&mut p, Kind::Activity, Layer::Oa, "Parent", None);
let child = add(&mut p, Kind::Activity, Layer::Oa, "Child", Some(parent));
p.relate(RelationKind::Allocation, "Performs", actor, child);
accepted(&p);
assert_eq!(validation::allocation_summary(&p, parent), Some(actor));
let before = p.clone();
assert!(
execute_batch(
&p,
&[Command::Connect(
RelationKind::Allocation,
"Invalid parent allocation".into(),
actor,
parent
)]
)
.is_err()
);
assert_eq!(p, before);
let other = add(
&mut p,
Kind::Activity,
Layer::Oa,
"Unallocated child",
Some(parent),
);
assert!(p.elements.contains_key(&other));
assert_eq!(validation::allocation_summary(&p, parent), None);
}
#[test]
fn parent_cycles_cross_layer_ownership_and_multiple_performers_reject() {
let (mut p, oa, sa) = work_pair();
p.elements.get_mut(&oa).unwrap().parent = Some(sa);
rejected(&p, "invalid_parent");
p.elements.get_mut(&oa).unwrap().parent = None;
let child = add(&mut p, Kind::Activity, Layer::Oa, "Child", Some(oa));
p.elements.get_mut(&oa).unwrap().parent = Some(child);
rejected(&p, "containment_cycle");
p.elements.get_mut(&oa).unwrap().parent = None;
let a = add(&mut p, Kind::Actor, Layer::Oa, "A", None);
let b = add(&mut p, Kind::Actor, Layer::Oa, "B", None);
p.relate(RelationKind::Allocation, "A performs", a, child);
p.relate(RelationKind::Allocation, "B performs", b, child);
rejected(&p, "multiple_performers");
}
#[test]
fn parent_and_endpoint_queries_match_creation_rules() {
let mut p = Project::empty();
let system = add(&mut p, Kind::System, Layer::Sa, "System", None);
let function = add(&mut p, Kind::Function, Layer::Sa, "Function", None);
assert!(validation::can_parent(
&p,
Kind::Port,
Layer::Sa,
Some(system)
));
assert!(!validation::can_parent(&p, Kind::Port, Layer::Sa, None));
assert!(!validation::can_parent(
&p,
Kind::Port,
Layer::Oa,
Some(system)
));
assert!(validation::can_connect(
&p,
RelationKind::Allocation,
system,
function
));
p.relate(RelationKind::Allocation, "Performs", system, function);
assert!(!validation::can_connect(
&p,
RelationKind::Allocation,
system,
function
));
}
#[test]
fn create_occurrence_attaches_once_and_repeats_exchange_by_distinct_ids() {
let p = Project::demo();
let process = p
.elements
.values()
.find(|e| e.kind() == Kind::Process)
.unwrap()
.id;
let exchange = p
.relations
.values()
.find(|r| r.kind() == RelationKind::Interaction)
.unwrap()
.id;
let p = execute_batch(
&p,
&[
Command::AddStep(process, exchange),
Command::AddStep(process, exchange),
],
)
.unwrap();
let steps = p.elements[&process].steps().unwrap();
assert_eq!(steps.len(), 3);
assert_ne!(steps[0], steps[1]);
assert_ne!(steps[1], steps[2]);
accepted(&p);
let mut bad = p.clone();
bad.elements
.get_mut(&process)
.unwrap()
.steps_mut()
.unwrap()
.push(steps[0]);
rejected(&bad, "duplicate_reference");
}
#[test]
fn scenario_lifelines_and_occurrences_require_same_context() {
let mut p = Project::empty();
let actor = add(&mut p, Kind::Actor, Layer::Oa, "Actor", None);
let scenario = add(&mut p, Kind::Scenario, Layer::Oa, "Scenario", None);
let other = add(&mut p, Kind::Scenario, Layer::Oa, "Other scenario", None);
let line = add(&mut p, Kind::Lifeline, Layer::Oa, "Actor line", Some(other));
*p.elements.get_mut(&line).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
represented: Some(actor),
};
let action = add(&mut p, Kind::Activity, Layer::Oa, "Action", None);
let mut occurrence = p
.make_element(
Kind::Occurrence,
Layer::Oa,
"Action occurrence",
Some(scenario),
)
.unwrap();
*occurrence.shared_mut().unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Action {
action: Some(action),
lifeline: Some(line),
},
precondition: "Before".into(),
postcondition: "After".into(),
};
p.elements.insert(occurrence.id, occurrence);
rejected(&p, "lifeline_context");
}
#[test]
fn items_ports_interfaces_and_exchange_allocation_are_typed() {
let mut p = Project::empty();
let system = add(&mut p, Kind::System, Layer::Sa, "System", None);
let actor = add(&mut p, Kind::SystemActor, Layer::Sa, "Actor", None);
let f1 = add(&mut p, Kind::Function, Layer::Sa, "Send", None);
let f2 = add(&mut p, Kind::Function, Layer::Sa, "Receive", None);
let item = add(&mut p, Kind::ExchangeItem, Layer::Sa, "Message", None);
let interface = add(&mut p, Kind::Interface, Layer::Sa, "Link interface", None);
*p.elements
.get_mut(&interface)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Interface { items: vec![item] };
let port = |p: &mut Project, name: &str, parent, direction| {
let id = add(p, Kind::Port, Layer::Sa, name, Some(parent));
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Port {
direction,
interface: Some(interface),
};
id
};
let out = port(&mut p, "System output", system, PortDirection::Out);
let input = port(&mut p, "Actor input", actor, PortDirection::In);
let fp1 = port(&mut p, "Function output", f1, PortDirection::Out);
let fp2 = port(&mut p, "Function input", f2, PortDirection::In);
let boundary = p.relate(RelationKind::BoundaryExchange, "Boundary link", out, input);
if let RelationBody::BoundaryExchange { items, .. } =
&mut p.relations.get_mut(&boundary).unwrap().body
{
items.push(item);
}
let exchange = p.relate(RelationKind::FunctionalExchange, "Message exchange", f1, f2);
p.relations.get_mut(&exchange).unwrap().body = RelationBody::FunctionalExchange {
source: f1,
target: f2,
source_port: Some(fp1),
target_port: Some(fp2),
items: vec![item],
};
p.relate(
RelationKind::ExchangeAllocation,
"Carried on",
boundary,
exchange,
);
p.relate(
RelationKind::InterfaceProvision,
"Provides",
system,
interface,
);
p.relate(
RelationKind::InterfaceRequirement,
"Requires",
actor,
interface,
);
accepted(&p);
if let RelationBody::FunctionalExchange { source_port, .. } =
&mut p.relations.get_mut(&exchange).unwrap().body
{
*source_port = Some(input);
}
rejected(&p, "functional_port");
}
#[test]
fn state_regions_initial_final_and_typed_actions_validate() {
let mut p = Project::empty();
let sys = add(&mut p, Kind::System, Layer::Sa, "System", None);
let machine = add(
&mut p,
Kind::StateMachine,
Layer::Sa,
"Lifecycle",
Some(sys),
);
let region = add(
&mut p,
Kind::Region,
Layer::Sa,
"Main region",
Some(machine),
);
let initial = add(
&mut p,
Kind::Initial,
Layer::Sa,
"Initial point",
Some(region),
);
let state = add(&mut p, Kind::State, Layer::Sa, "Ready", Some(region));
let final_state = add(&mut p, Kind::Final, Layer::Sa, "Finished", Some(region));
let work = add(&mut p, Kind::Function, Layer::Sa, "Initialize", None);
p.relate(RelationKind::StateTransition, "Begin", initial, state);
p.relate(RelationKind::StateTransition, "Finish", state, final_state);
p.relate(RelationKind::StateAction, "Entry action", state, work);
p.relate(RelationKind::Availability, "Available", work, state);
accepted(&p);
p.relate(
RelationKind::StateTransition,
"Invalid restart",
final_state,
state,
);
rejected(&p, "pseudostate_direction");
}
#[test]
fn artifacts_preserve_arbitrary_uri_and_revision_without_fetch() {
let (mut p, oa, _) = work_pair();
let id = artifact(&mut p, oa);
accepted(&p);
let encoded = serde_json::to_vec(&p).unwrap();
let decoded: Project = serde_json::from_slice(&encoded).unwrap();
assert_eq!(decoded.artifact_links[&id], p.artifact_links[&id]);
p.artifact_links.get_mut(&id).unwrap().uri = "file:///C:/Engineering/REQ%2042.md".into();
accepted(&p);
p.artifact_links.get_mut(&id).unwrap().uri = "broken%XZ".into();
rejected(&p, "invalid_uri");
}
#[test]
fn reidentify_updates_containment_sequences_traces_artifacts_and_views_atomically() {
let (mut p, oa, sa) = work_pair();
let child = add(&mut p, Kind::Activity, Layer::Oa, "Child", Some(oa));
let trace = make_trace(&mut p, oa, sa);
let link = artifact(&mut p, oa);
let diagram = p.ensure_diagram(Layer::Oa);
let node = p.place(diagram, oa, Position { x: 1.25, y: -0. });
let new = Id::new_v4();
let mut s = Session::new(p.clone());
s.execute(Command::Reidentify(oa, new)).unwrap();
assert_eq!(s.project().elements[&child].parent, Some(new));
assert_eq!(s.project().traces[&trace].oa, new);
assert_eq!(s.project().artifact_links[&link].owner, new);
assert_eq!(s.project().diagrams[&diagram].nodes[&node].element, new);
assert_eq!(s.project().elements[&new].alias, p.elements[&oa].alias);
s.undo();
assert_eq!(s.project(), &p);
}
#[test]
fn deleting_source_cascades_owned_records_but_keeps_other_layer_counterpart() {
let (mut p, oa, sa) = work_pair();
let child = add(&mut p, Kind::Activity, Layer::Oa, "Child", Some(oa));
let trace = make_trace(&mut p, oa, sa);
let link = artifact(&mut p, child);
let mut s = Session::new(p.clone());
s.execute(Command::Delete(oa)).unwrap();
assert!(!s.project().elements.contains_key(&child));
assert!(!s.project().traces.contains_key(&trace));
assert!(!s.project().artifact_links.contains_key(&link));
assert!(s.project().elements.contains_key(&sa));
s.undo();
assert_eq!(s.project(), &p);
}
#[test]
fn relation_deletion_prunes_owned_occurrences_and_undo_restores_order() {
let p = Project::demo();
let exchange = p
.relations
.values()
.find(|r| r.kind() == RelationKind::Interaction)
.unwrap()
.id;
let process = p
.elements
.values()
.find(|e| e.kind() == Kind::Process)
.unwrap()
.id;
let mut s = Session::new(p.clone());
s.execute(Command::Delete(exchange)).unwrap();
assert!(s.project().elements[&process].steps().unwrap().is_empty());
s.undo();
assert_eq!(s.project(), &p);
}
#[test]
fn nested_projection_encloses_children_and_moves_only_selected_diagram() {
let mut p = Project::empty();
let root = add(&mut p, Kind::Entity, Layer::Oa, "Organization", None);
let child = add(&mut p, Kind::Entity, Layer::Oa, "Team", Some(root));
let d = p.ensure_diagram(Layer::Oa);
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Decomposition;
let parent_node = p.place(d, root, Position { x: 100., y: 100. });
let child_node = p.place(d, child, Position { x: 500., y: 400. });
let view = projection::project_view(&p, d);
let parent = view.nodes.iter().find(|n| n.id == parent_node).unwrap();
let nested = view.nodes.iter().find(|n| n.id == child_node).unwrap();
assert_eq!(nested.visual_parent, Some(parent_node));
assert!(parent.position.x + parent.size.width >= nested.position.x + nested.size.width + 24.);
let moved = execute_batch(
&p,
&[Command::MoveNode {
diagram: d,
node: parent_node,
position: Position { x: 125., y: 150. },
with_descendants: true,
}],
)
.unwrap();
assert_eq!(
moved.diagrams[&d].nodes[&child_node].position,
Position { x: 525., y: 450. }
);
assert_eq!(moved.elements, p.elements);
let arranged = execute_batch(&p, &[Command::Layout(d)]).unwrap();
let pn = &arranged.diagrams[&d].nodes[&parent_node];
let cn = &arranged.diagrams[&d].nodes[&child_node];
assert!(cn.position.x > pn.position.x);
assert!(cn.position.y > pn.position.y);
assert!(cn.position.y + cn.size.height < pn.position.y + pn.size.height);
}
#[test]
fn invalid_geometry_and_new_payload_fields_are_rejected() {
let (mut p, oa, _) = work_pair();
let d = p.ensure_diagram(Layer::Oa);
let node = p.place(d, oa, Position { x: f32::NAN, y: 0. });
rejected(&p, "invalid_bounds");
p.diagrams
.get_mut(&d)
.unwrap()
.nodes
.get_mut(&node)
.unwrap()
.position
.x = 0.;
p.diagrams
.get_mut(&d)
.unwrap()
.nodes
.get_mut(&node)
.unwrap()
.size
.width = -1.;
rejected(&p, "invalid_bounds");
assert!(
serde_json::from_str::<SharedBody>(
r#"{"Port":{"direction":"In","interface":null,"typo":true}}"#
)
.is_err()
);
assert!(serde_json::from_str::<Command>(r#"{"Reparent":{"element":"00000000-0000-0000-0000-000000000001","parent":null,"typo":true}}"#).is_err());
}
#[test]
fn bidirectional_transition_creates_reuses_and_updates_one_history_transaction() {
let mut p = Project::empty();
let oa = add(&mut p, Kind::Activity, Layer::Oa, "Assess", None);
p.elements.get_mut(&oa).unwrap().description = "Operational intent".into();
let request = TransitionRequest::new(TransitionDirection::OaToSa, vec![oa]);
let plan = plan_transition(&p, &request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
let target = plan.changes[0].target;
let migrated = apply_transition(&p, &plan).unwrap();
assert_eq!(migrated.elements[&target].kind(), Kind::Function);
assert_eq!(migrated.traces.len(), 1);
let again = plan_transition(&migrated, &request).unwrap();
assert!(again.conflicts.is_empty());
assert!(again.commands.is_empty());
let edited = execute_batch(
&migrated,
&[Command::Describe(target, "Updated system intent".into())],
)
.unwrap();
let reverse = plan_transition(
&edited,
&TransitionRequest::new(TransitionDirection::SaToOa, vec![target]),
)
.unwrap();
assert!(reverse.conflicts.is_empty(), "{:?}", reverse.conflicts);
let restored = apply_transition(&edited, &reverse).unwrap();
assert_eq!(restored.elements[&oa].description, "Updated system intent");
assert_eq!(restored.traces.len(), 1);
let mut s = Session::new(p.clone());
s.execute(Command::Replace(migrated)).unwrap();
s.undo();
assert_eq!(s.project(), &p);
}
#[test]
fn both_side_transition_conflicts_require_explicit_field_resolution() {
let (mut p, oa, sa) = work_pair();
p.elements.get_mut(&sa).unwrap().name = p.elements[&oa].name.clone();
let trace = make_trace(&mut p, oa, sa);
p.elements.get_mut(&oa).unwrap().name = "New OA name".into();
p.elements.get_mut(&sa).unwrap().name = "New SA name".into();
assert_eq!(trace_drift(&p, &p.traces[&trace]), Drift::BothChanged);
let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![oa]);
let plan = plan_transition(&p, &request).unwrap();
assert!(
plan.conflicts
.iter()
.any(|c| c.field == Some(TransitionField::Name))
);
assert!(apply_transition(&p, &plan).is_err());
request.resolutions.push(FieldResolution {
source: oa,
field: TransitionField::Name,
decision: FieldDecision::KeepTarget,
});
let accepted_plan = plan_transition(&p, &request).unwrap();
assert!(
accepted_plan.conflicts.is_empty(),
"{:?}",
accepted_plan.conflicts
);
let updated = apply_transition(&p, &accepted_plan).unwrap();
assert_eq!(updated.elements[&sa].name, "New SA name");
assert_eq!(
trace_drift(&updated, &updated.traces[&trace]),
Drift::Unchanged
);
}
#[test]
fn participant_transition_requires_boundary_decision_and_stale_plans_reject() {
let mut p = Project::empty();
let oa = add(&mut p, Kind::Entity, Layer::Oa, "Operations", None);
let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![oa]);
assert!(!plan_transition(&p, &request).unwrap().conflicts.is_empty());
request.mappings.push(TransitionMapping {
source: oa,
target: None,
target_kind: Some(Kind::System),
});
let plan = plan_transition(&p, &request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
p.name = "Changed project".into();
assert!(apply_transition(&p, &plan).unwrap_err().0.contains("stale"));
}
#[test]
fn explicit_dependencies_map_chain_order_and_repeated_occurrences() {
let mut p = Project::demo();
p.traces.clear();
let process = p
.elements
.values()
.find(|e| e.kind() == Kind::Process)
.unwrap()
.id;
let exchange = p
.relations
.values()
.find(|r| r.kind() == RelationKind::Interaction)
.unwrap()
.id;
let p = execute_batch(&p, &[Command::AddStep(process, exchange)]).unwrap();
let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![process]);
assert!(!plan_transition(&p, &request).unwrap().conflicts.is_empty());
request.scope = DependencyScope::Required;
let plan = plan_transition(&p, &request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
let result = apply_transition(&p, &plan).unwrap();
let chain = plan
.changes
.iter()
.find(|c| c.source == process)
.unwrap()
.target;
let steps = result.elements[&chain].steps().unwrap();
assert_eq!(steps.len(), 2);
assert_ne!(steps[0], steps[1]);
let relation = |id: Id| match result.elements[&id].shared() {
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
exchange: Some(r), ..
},
..
}) => *r,
_ => panic!("exchange occurrence"),
};
assert_eq!(relation(steps[0]), relation(steps[1]));
}
#[test]
fn many_traces_need_explicit_counterpart_and_layout_does_not_cause_drift() {
let (mut p, oa, sa) = work_pair();
let first = make_trace(&mut p, oa, sa);
let second = add(&mut p, Kind::Function, Layer::Sa, "Another function", None);
make_trace(&mut p, oa, second);
let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![oa]);
assert!(!plan_transition(&p, &request).unwrap().conflicts.is_empty());
request.mappings.push(TransitionMapping {
source: oa,
target: Some(sa),
target_kind: None,
});
let plan = plan_transition(&p, &request).unwrap();
assert!(plan.conflicts.is_empty());
let d = p.ensure_diagram(Layer::Oa);
p.place(d, oa, Position { x: 400., y: 500. });
assert_eq!(trace_drift(&p, &p.traces[&first]), Drift::Unchanged);
}
#[test]
fn reidentifying_data_types_and_function_ports_updates_every_typed_reference() {
let mut p = Project::empty();
let item = add(&mut p, Kind::ExchangeItem, Layer::Sa, "Item", None);
let dtype = add(&mut p, Kind::DataType, Layer::Sa, "Count type", None);
let field = add(&mut p, Kind::DataField, Layer::Sa, "Count", Some(item));
*p.elements.get_mut(&field).unwrap().shared_mut().unwrap() = SharedBody::DataField {
type_ref: TypeRef::Named(dtype),
minimum: 0,
maximum: None,
unit: "items".into(),
};
let function = add(&mut p, Kind::Function, Layer::Sa, "Function", None);
let other = add(&mut p, Kind::Function, Layer::Sa, "Other", None);
let port = add(&mut p, Kind::Port, Layer::Sa, "Output", Some(function));
let rel = p.relate(RelationKind::FunctionalExchange, "Data", function, other);
if let RelationBody::FunctionalExchange {
source_port, items, ..
} = &mut p.relations.get_mut(&rel).unwrap().body
{
*source_port = Some(port);
items.push(item);
}
let dtype_new = Id::new_v4();
let port_new = Id::new_v4();
let result = execute_batch(
&p,
&[
Command::Reidentify(dtype, dtype_new),
Command::Reidentify(port, port_new),
],
)
.unwrap();
assert!(
matches!(result.elements[&field].shared(),Some(SharedBody::DataField{type_ref:TypeRef::Named(id),..})if *id==dtype_new)
);
assert!(
matches!(&result.relations[&rel].body,RelationBody::FunctionalExchange{source_port:Some(id),..}if *id==port_new)
);
}
#[test]
fn every_demo_identity_can_be_remapped_and_deleted_without_dangling_references() {
let p = Project::demo();
for (id, _) in p.identities() {
let replacement = Id::new_v4();
let remapped = execute_batch(&p, &[Command::Reidentify(id, replacement)])
.unwrap_or_else(|err| panic!("Reidentify {id}: {err}"));
let restored = execute_batch(&remapped, &[Command::Reidentify(replacement, id)]).unwrap();
assert_eq!(
restored, p,
"Reidentification must preserve all values and order"
);
if id != p.id {
let deleted = execute_batch(&p, &[Command::Delete(id)])
.unwrap_or_else(|err| panic!("Delete {id}: {err}"));
accepted(&deleted);
}
}
}
#[test]
fn shared_roles_remain_valid_without_an_invented_unique_performer() {
let mut p = Project::empty();
let a = add(&mut p, Kind::Entity, Layer::Oa, "Team A", None);
let b = add(&mut p, Kind::Entity, Layer::Oa, "Team B", None);
let role = add(&mut p, Kind::Role, Layer::Oa, "Responder", None);
let work = add(&mut p, Kind::Activity, Layer::Oa, "Respond", None);
p.relate(RelationKind::RoleRealization, "A realizes", a, role);
p.relate(
RelationKind::RoleAssignment,
"Role responsibility",
role,
work,
);
assert_eq!(validation::effective_performer(&p, work), Some(a));
assert!(
!validation::validate(&p)
.iter()
.any(|i| i.code == "unallocated_leaf")
);
p.relate(RelationKind::RoleRealization, "B realizes", b, role);
accepted(&p);
assert_eq!(validation::effective_performer(&p, work), None);
assert!(
validation::validate(&p)
.iter()
.any(|i| i.code == "ambiguous_role_performer" && i.severity == Severity::Warning)
);
}
#[test]
fn automatic_transition_does_not_accept_arbitrary_target_kinds() {
let (p, oa, sa) = work_pair();
let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![oa]);
request.mappings.push(TransitionMapping {
source: oa,
target: None,
target_kind: Some(Kind::System),
});
assert!(!plan_transition(&p, &request).unwrap().conflicts.is_empty());
let mut p = p;
let actor = add(&mut p, Kind::SystemActor, Layer::Sa, "Actor", None);
request.mappings[0].target = Some(actor);
request.mappings[0].target_kind = None;
assert!(!plan_transition(&p, &request).unwrap().conflicts.is_empty());
request.mappings[0].target = Some(sa);
request.resolutions.push(FieldResolution {
source: oa,
field: TransitionField::Name,
decision: FieldDecision::TakeSource,
});
assert!(plan_transition(&p, &request).unwrap().conflicts.is_empty());
}
#[test]
fn demo_has_both_layers_complete_contracts_and_no_checks() {
let p = Project::demo();
assert_eq!(p.elements.len(), 41);
for layer in [Layer::Oa, Layer::Sa] {
for projection in [
ProjectionKind::Allocation,
ProjectionKind::FunctionalDataflow,
ProjectionKind::Capabilities,
] {
assert!(
p.diagrams
.values()
.any(|d| d.layer == layer && d.projection == projection)
);
}
}
assert_eq!(p.traces.len(), 10);
assert_eq!(p.artifact_links.len(), 2);
assert!(
validation::validate(&p).is_empty(),
"{:?}",
validation::validate(&p)
);
assert!(p.elements.values().any(|e| e.kind() == Kind::System));
assert!(p.elements.values().any(|e| matches!(
e.shared(),
Some(SharedBody::ExchangeItem {
kind: ExchangeItemKind::Material
})
)));
assert!(p.elements.values().any(|e| matches!(
e.shared(),
Some(SharedBody::ExchangeItem {
kind: ExchangeItemKind::Energy
})
)));
}