475 lines
16 KiB
Rust
475 lines
16 KiB
Rust
use oa_core::{
|
|||
|
|
Project,
|
||
|
|
language::{self, TokenClass},
|
||
|
|
source_project::{self, ProjectLimits, SourceBundle},
|
||
|
|
};
|
||
|
|
use source_authoring_experiment::compile;
|
||
|
|
use std::collections::BTreeMap;
|
||
|
|
|
||
|
|
const CASES: [(&str, &str, &str); 3] = [
|
||
|
|
(
|
||
|
|
"OA",
|
||
|
|
include_str!("../fixtures/oa-a.arcadia"),
|
||
|
|
include_str!("../fixtures/oa-b.arcadia"),
|
||
|
|
),
|
||
|
|
(
|
||
|
|
"SA",
|
||
|
|
include_str!("../fixtures/sa-a.arcadia"),
|
||
|
|
include_str!("../fixtures/sa-b.arcadia"),
|
||
|
|
),
|
||
|
|
(
|
||
|
|
"Scenario",
|
||
|
|
include_str!("../fixtures/scenario-a.arcadia"),
|
||
|
|
include_str!("../fixtures/scenario-b.arcadia"),
|
||
|
|
),
|
||
|
|
];
|
||
|
|
fn bundle(source: &str) -> SourceBundle {
|
||
|
|
SourceBundle {
|
||
|
|
entry: "project.arcadia".into(),
|
||
|
|
files: BTreeMap::from([("project.arcadia".into(), source.into())]),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
fn source3(source: &str) -> Project {
|
||
|
|
source_project::compile_bundle(&bundle(source), ProjectLimits::default())
|
||
|
|
.unwrap()
|
||
|
|
.decoded
|
||
|
|
.project
|
||
|
|
}
|
||
|
|
fn b(source: &str) -> Project {
|
||
|
|
compile(&bundle(source)).unwrap()
|
||
|
|
}
|
||
|
|
fn ids(project: &Project) -> BTreeMap<String, oa_core::Id> {
|
||
|
|
project
|
||
|
|
.identities()
|
||
|
|
.into_iter()
|
||
|
|
.map(|(id, alias)| (alias.to_owned(), id))
|
||
|
|
.collect()
|
||
|
|
}
|
||
|
|
fn field_count(source: &str, field: &str) -> usize {
|
||
|
|
language::tokenize(source)
|
||
|
|
.iter()
|
||
|
|
.filter(|token| &source[token.span.start..token.span.end] == field)
|
||
|
|
.count()
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn three_independently_authored_pairs_have_identical_complete_models() {
|
||
|
|
for (name, a, candidate) in CASES {
|
||
|
|
let a = source3(a);
|
||
|
|
let candidate = b(candidate);
|
||
|
|
assert_eq!(a, candidate, "{name}");
|
||
|
|
assert_eq!(
|
||
|
|
language::parse(&language::format(&candidate)).unwrap(),
|
||
|
|
a,
|
||
|
|
"canonical source parity: {name}"
|
||
|
|
);
|
||
|
|
assert_eq!(
|
||
|
|
ids(&a).len(),
|
||
|
|
a.elements.len() + a.relations.len() + 1,
|
||
|
|
"no accidental generated identities"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn rename_changes_one_name_without_replacing_ids_or_other_properties() {
|
||
|
|
for (name, a, candidate) in CASES {
|
||
|
|
let old = source3(a);
|
||
|
|
let (before, after) = if name == "SA" {
|
||
|
|
("Transmit report", "Transmit distress report")
|
||
|
|
} else {
|
||
|
|
("Report distress", "Report emergency")
|
||
|
|
};
|
||
|
|
let mut expected = old.clone();
|
||
|
|
let element = expected
|
||
|
|
.elements
|
||
|
|
.values_mut()
|
||
|
|
.find(|element| element.name == before)
|
||
|
|
.unwrap();
|
||
|
|
element.name = after.into();
|
||
|
|
let a_changed = a.replace(&format!("\"{before}\""), &format!("\"{after}\""));
|
||
|
|
let b_changed = candidate.replace(&format!("\"{before}\""), &format!("\"{after}\""));
|
||
|
|
assert_eq!(source3(&a_changed), expected, "{name}");
|
||
|
|
assert_eq!(b(&b_changed), expected, "{name}");
|
||
|
|
assert_eq!(ids(&old), ids(&expected));
|
||
|
|
for (original, changed) in [(a, a_changed.as_str()), (candidate, b_changed.as_str())] {
|
||
|
|
assert_eq!(
|
||
|
|
original
|
||
|
|
.lines()
|
||
|
|
.zip(changed.lines())
|
||
|
|
.filter(|(a, b)| a != b)
|
||
|
|
.count(),
|
||
|
|
1
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn moved_document(source: &str, path: &str) -> SourceBundle {
|
||
|
|
let start = source.find('{').unwrap() + 1;
|
||
|
|
let end = source.rfind('}').unwrap();
|
||
|
|
SourceBundle {
|
||
|
|
entry: "project.arcadia".into(),
|
||
|
|
files: BTreeMap::from([
|
||
|
|
(
|
||
|
|
"project.arcadia".into(),
|
||
|
|
format!("{}\n include \"{path}\";\n}}\n", &source[..start]),
|
||
|
|
),
|
||
|
|
(path.into(), source[start..end].into()),
|
||
|
|
]),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn document_relocation_and_forward_references_do_not_change_identity_or_semantics() {
|
||
|
|
for (name, a, candidate) in CASES {
|
||
|
|
let expected = source3(a);
|
||
|
|
for path in ["model.arcadia", "domain/subsystem/model.arcadia"] {
|
||
|
|
assert_eq!(
|
||
|
|
compile(&moved_document(candidate, path)).unwrap(),
|
||
|
|
expected,
|
||
|
|
"{name} B move"
|
||
|
|
);
|
||
|
|
assert_eq!(
|
||
|
|
source_project::compile_bundle(&moved_document(a, path), ProjectLimits::default())
|
||
|
|
.unwrap()
|
||
|
|
.decoded
|
||
|
|
.project,
|
||
|
|
expected,
|
||
|
|
"{name} A move"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Fixture-only block movement, not part of the parser or production editing.
|
||
|
|
fn declaration_range(source: &str, prefix: &str) -> std::ops::Range<usize> {
|
||
|
|
let start = source.find(prefix).unwrap();
|
||
|
|
let open = start + source[start..].find('{').unwrap();
|
||
|
|
let mut depth = 0;
|
||
|
|
for (offset, ch) in source[open..].char_indices() {
|
||
|
|
if ch == '{' {
|
||
|
|
depth += 1;
|
||
|
|
}
|
||
|
|
if ch == '}' {
|
||
|
|
depth -= 1;
|
||
|
|
if depth == 0 {
|
||
|
|
return start..open + offset + 1;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
panic!("Unclosed fixture declaration")
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn occurrence_reordering_changes_only_order_and_retains_call_reply_identity() {
|
||
|
|
let (_, a, candidate) = CASES[2];
|
||
|
|
let old = source3(a);
|
||
|
|
let a = a.replace(
|
||
|
|
"steps [@initial_report, @accepted, @update_report]",
|
||
|
|
"steps [@update_report, @initial_report, @accepted]",
|
||
|
|
);
|
||
|
|
let moved = declaration_range(candidate, "oa_occurrence update_report");
|
||
|
|
let initial = candidate.find("oa_occurrence initial_report").unwrap();
|
||
|
|
let mut reordered = candidate.to_owned();
|
||
|
|
let block = reordered[moved.clone()].to_owned();
|
||
|
|
reordered.replace_range(moved, "");
|
||
|
|
reordered.insert_str(initial, &format!("{block}\n "));
|
||
|
|
let mut expected = old.clone();
|
||
|
|
expected
|
||
|
|
.elements
|
||
|
|
.values_mut()
|
||
|
|
.find(|element| element.alias == "reports")
|
||
|
|
.unwrap()
|
||
|
|
.steps_mut()
|
||
|
|
.unwrap()
|
||
|
|
.rotate_right(1);
|
||
|
|
assert_eq!(source3(&a), expected);
|
||
|
|
assert_eq!(b(&reordered), expected);
|
||
|
|
assert_eq!(ids(&old), ids(&expected));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn adding_a_repeat_needs_two_authored_sites_in_a_and_one_in_b() {
|
||
|
|
let (_, a, candidate) = CASES[2];
|
||
|
|
let new_body = "body { Exchange { exchange @report_message; sender @reporter_line; receiver @assessor_line; message_kind Asynchronous; reply_to none; values []; }; };";
|
||
|
|
let new_header = "oa_occurrence further_report \"Further report\" id \"00000000-0000-4000-8000-000000000316\"";
|
||
|
|
let mut a_added = a.replace(
|
||
|
|
"steps [@initial_report, @accepted, @update_report]",
|
||
|
|
"steps [@initial_report, @accepted, @update_report, @further_report]",
|
||
|
|
);
|
||
|
|
a_added.insert_str(
|
||
|
|
a_added.rfind('}').unwrap(),
|
||
|
|
&format!(" {new_header} {{ parent @reports; {new_body} }}\n"),
|
||
|
|
);
|
||
|
|
let mut b_added = candidate.to_owned();
|
||
|
|
let end_steps = b_added.rfind(" };").unwrap();
|
||
|
|
b_added.insert_str(
|
||
|
|
end_steps,
|
||
|
|
&format!(" {new_header} {{ {new_body} }}\n"),
|
||
|
|
);
|
||
|
|
let actual = b(&b_added);
|
||
|
|
assert_eq!(actual, source3(&a_added));
|
||
|
|
let old = source3(a);
|
||
|
|
for (alias, id) in ids(&old) {
|
||
|
|
assert_eq!(ids(&actual)[&alias], id);
|
||
|
|
}
|
||
|
|
assert_eq!(actual.elements.len(), old.elements.len() + 1);
|
||
|
|
assert_eq!(actual.relations, old.relations);
|
||
|
|
assert_eq!(
|
||
|
|
actual
|
||
|
|
.elements
|
||
|
|
.values()
|
||
|
|
.find(|element| element.alias == "reports")
|
||
|
|
.unwrap()
|
||
|
|
.steps()
|
||
|
|
.unwrap()
|
||
|
|
.len(),
|
||
|
|
4
|
||
|
|
);
|
||
|
|
// Current core semantics accept an incomplete A edit, with an explicit warning.
|
||
|
|
// The B steps block prevents this particular two-site omission by construction.
|
||
|
|
let a_missing_membership = a_added.replace(", @further_report]", "]");
|
||
|
|
let incomplete =
|
||
|
|
source_project::compile_bundle(&bundle(&a_missing_membership), ProjectLimits::default())
|
||
|
|
.unwrap();
|
||
|
|
assert!(
|
||
|
|
incomplete
|
||
|
|
.diagnostics
|
||
|
|
.iter()
|
||
|
|
.any(|issue| issue.diagnostic.code == "unordered_occurrence")
|
||
|
|
);
|
||
|
|
assert_ne!(incomplete.decoded.project, actual);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn explicit_field_escape_preserves_diagram_geometry_and_exact_strings() {
|
||
|
|
let (_, a, candidate) = CASES[1];
|
||
|
|
let explicit = r#"
|
||
|
|
diagram reporting_view "Reporting view" id "00000000-0000-4000-8000-000000000220" { layer Sa; projection FunctionalDataflow; description "Review \"quotation\" and @literal, {braces}, λ."; }
|
||
|
|
node send_placement id "00000000-0000-4000-8000-000000000221" { diagram @reporting_view; element @send_report; position { x -0.0; y 32.5; }; size { width 200.0; height 100.0; }; collapsed false; }
|
||
|
|
"#;
|
||
|
|
let add = |source: &str| {
|
||
|
|
let mut result = source.to_owned();
|
||
|
|
result.insert_str(result.rfind('}').unwrap(), explicit);
|
||
|
|
result
|
||
|
|
};
|
||
|
|
let actual = b(&add(candidate));
|
||
|
|
assert_eq!(actual, source3(&add(a)));
|
||
|
|
let node = actual
|
||
|
|
.diagrams
|
||
|
|
.values()
|
||
|
|
.next()
|
||
|
|
.unwrap()
|
||
|
|
.nodes
|
||
|
|
.values()
|
||
|
|
.next()
|
||
|
|
.unwrap();
|
||
|
|
assert_eq!(node.position.x.to_bits(), (-0.0_f32).to_bits());
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn nesting_never_infers_allocations_or_crosses_semantic_ownership_rules() {
|
||
|
|
let (_, _, source) = CASES[1];
|
||
|
|
let missing_work = source
|
||
|
|
.lines()
|
||
|
|
.filter(|line| !line.trim_start().starts_with("allocation "))
|
||
|
|
.collect::<Vec<_>>()
|
||
|
|
.join("\n");
|
||
|
|
assert!(
|
||
|
|
compile(&bundle(&missing_work))
|
||
|
|
.unwrap_err()
|
||
|
|
.contains("actual work allocation"),
|
||
|
|
"The shorthand must not invent allocations to repair dependent port allocations"
|
||
|
|
);
|
||
|
|
let no_allocations = source
|
||
|
|
.lines()
|
||
|
|
.filter(|line| {
|
||
|
|
!["allocation ", "port_allocation ", "exchange_allocation "]
|
||
|
|
.iter()
|
||
|
|
.any(|prefix| line.trim_start().starts_with(prefix))
|
||
|
|
})
|
||
|
|
.collect::<Vec<_>>()
|
||
|
|
.join("\n");
|
||
|
|
let actual = b(&no_allocations);
|
||
|
|
let mut expected = b(source);
|
||
|
|
expected.relations.retain(|_, relation| {
|
||
|
|
!matches!(
|
||
|
|
relation.kind(),
|
||
|
|
oa_core::RelationKind::Allocation
|
||
|
|
| oa_core::RelationKind::PortAllocation
|
||
|
|
| oa_core::RelationKind::ExchangeAllocation
|
||
|
|
)
|
||
|
|
});
|
||
|
|
assert_eq!(actual, expected);
|
||
|
|
// A nested function under the system is not silently converted into an allocation.
|
||
|
|
let invalid = source.replace(
|
||
|
|
"sa_function manage \"Manage reporting\"",
|
||
|
|
"sa_system manage \"Manage reporting\"",
|
||
|
|
);
|
||
|
|
assert!(compile(&bundle(&invalid)).is_err());
|
||
|
|
let wrong_owner = source.replace(
|
||
|
|
"sa_function send_report \"Transmit report\"",
|
||
|
|
"sa_actor send_report \"Transmit report\"",
|
||
|
|
);
|
||
|
|
assert!(
|
||
|
|
compile(&bundle(&wrong_owner))
|
||
|
|
.unwrap_err()
|
||
|
|
.contains("SA function owner")
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn alias_rename_keeps_all_identity_references_and_cross_file_ports_resolve() {
|
||
|
|
let (_, a, candidate) = CASES[1];
|
||
|
|
let mut expected = source3(a);
|
||
|
|
expected
|
||
|
|
.elements
|
||
|
|
.values_mut()
|
||
|
|
.find(|element| element.alias == "send_report")
|
||
|
|
.unwrap()
|
||
|
|
.alias = "transmit_report".into();
|
||
|
|
assert_eq!(
|
||
|
|
b(&candidate.replace("send_report", "transmit_report")),
|
||
|
|
expected
|
||
|
|
);
|
||
|
|
assert_eq!(
|
||
|
|
source3(&a.replace("send_report", "transmit_report")),
|
||
|
|
expected
|
||
|
|
);
|
||
|
|
let exchange = candidate
|
||
|
|
.lines()
|
||
|
|
.find(|line| line.trim_start().starts_with("functional_exchange "))
|
||
|
|
.unwrap();
|
||
|
|
let owner_source =
|
||
|
|
candidate.replace(exchange, " include \"connections/reporting.arcadia\";");
|
||
|
|
let mut split = bundle(&owner_source);
|
||
|
|
split
|
||
|
|
.files
|
||
|
|
.insert("connections/reporting.arcadia".into(), exchange.into());
|
||
|
|
assert_eq!(compile(&split).unwrap(), source3(a));
|
||
|
|
let other_path = "contracts/dataflow.arcadia";
|
||
|
|
let exchange_source = split.files.remove("connections/reporting.arcadia").unwrap();
|
||
|
|
split.files.insert(other_path.into(), exchange_source);
|
||
|
|
*split.files.get_mut("project.arcadia").unwrap() =
|
||
|
|
owner_source.replace("connections/reporting.arcadia", other_path);
|
||
|
|
assert_eq!(compile(&split).unwrap(), source3(a));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn conflicts_and_bad_semantic_shortcuts_fail_explicitly() {
|
||
|
|
let (_, _, oa) = CASES[0];
|
||
|
|
let parent = oa.replace("000000000104\" {}", "000000000104\" { parent @vessel; }");
|
||
|
|
assert!(
|
||
|
|
compile(&bundle(&parent))
|
||
|
|
.unwrap_err()
|
||
|
|
.contains("Contradictory parent")
|
||
|
|
);
|
||
|
|
let (_, _, sa) = CASES[1];
|
||
|
|
let endpoints = sa.replace(
|
||
|
|
"from @report_out to @report_in items [@report_data];",
|
||
|
|
"from @report_out to @report_in items [@report_data]; { source @assess; }",
|
||
|
|
);
|
||
|
|
assert!(
|
||
|
|
compile(&bundle(&endpoints))
|
||
|
|
.unwrap_err()
|
||
|
|
.contains("explicit endpoint")
|
||
|
|
);
|
||
|
|
let reversed = sa.replace(
|
||
|
|
"from @report_out to @report_in",
|
||
|
|
"from @report_in to @report_out",
|
||
|
|
);
|
||
|
|
assert!(
|
||
|
|
compile(&bundle(&reversed)).is_err(),
|
||
|
|
"Core must reject wrong function port directions"
|
||
|
|
);
|
||
|
|
let missing = sa.replace("from @report_out", "from @missing");
|
||
|
|
assert!(
|
||
|
|
compile(&bundle(&missing))
|
||
|
|
.unwrap_err()
|
||
|
|
.contains("no explicit function owner")
|
||
|
|
);
|
||
|
|
let duplicate = sa.replace("000000000209", "000000000208");
|
||
|
|
assert!(
|
||
|
|
compile(&bundle(&duplicate))
|
||
|
|
.unwrap_err()
|
||
|
|
.contains("Duplicate identity")
|
||
|
|
);
|
||
|
|
let (_, _, scenario) = CASES[2];
|
||
|
|
let conflict = scenario.replace("steps {", "steps []; steps {");
|
||
|
|
assert!(
|
||
|
|
compile(&bundle(&conflict))
|
||
|
|
.unwrap_err()
|
||
|
|
.contains("Duplicate field steps")
|
||
|
|
);
|
||
|
|
let incomplete = scenario.replace("oa_occurrence accepted", "oa_activity accepted");
|
||
|
|
assert!(
|
||
|
|
compile(&bundle(&incomplete))
|
||
|
|
.unwrap_err()
|
||
|
|
.contains("occurrence declarations only")
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn objective_fixture_metrics_are_reported_without_claiming_human_timings() {
|
||
|
|
println!(
|
||
|
|
"Task | A/B lines | A/B significant tokens | A/B explicit refs | A/B parent fields | A/B steps references | A/B owner-to-member line span"
|
||
|
|
);
|
||
|
|
for (name, a, candidate) in CASES {
|
||
|
|
let count = |source| {
|
||
|
|
language::tokenize(source)
|
||
|
|
.iter()
|
||
|
|
.filter(|token| {
|
||
|
|
!matches!(token.class, TokenClass::Comment | TokenClass::Whitespace)
|
||
|
|
})
|
||
|
|
.count()
|
||
|
|
};
|
||
|
|
let steps = |source: &str| {
|
||
|
|
source
|
||
|
|
.lines()
|
||
|
|
.filter_map(|line| line.find("steps [").map(|index| &line[index..]))
|
||
|
|
.map(|tail| tail.split(']').next().unwrap().matches('@').count())
|
||
|
|
.sum::<usize>()
|
||
|
|
};
|
||
|
|
let (parent, child) = match name {
|
||
|
|
"OA" => ("oa_activity respond", "oa_activity assess"),
|
||
|
|
"SA" => (
|
||
|
|
"sa_function send_report",
|
||
|
|
if candidate.contains("output report_out") {
|
||
|
|
"report_out \"Report output\""
|
||
|
|
} else {
|
||
|
|
unreachable!()
|
||
|
|
},
|
||
|
|
),
|
||
|
|
_ => ("oa_scenario reports", "oa_occurrence update_report"),
|
||
|
|
};
|
||
|
|
let span = |source: &str| {
|
||
|
|
source
|
||
|
|
.lines()
|
||
|
|
.position(|line| line.contains(child))
|
||
|
|
.unwrap()
|
||
|
|
.abs_diff(
|
||
|
|
source
|
||
|
|
.lines()
|
||
|
|
.position(|line| line.contains(parent))
|
||
|
|
.unwrap(),
|
||
|
|
)
|
||
|
|
};
|
||
|
|
println!(
|
||
|
|
"{name} | {}/{} | {}/{} | {}/{} | {}/{} | {}/{} | {}/{}",
|
||
|
|
a.lines().count(),
|
||
|
|
candidate.lines().count(),
|
||
|
|
count(a),
|
||
|
|
count(candidate),
|
||
|
|
field_count(a, "@"),
|
||
|
|
field_count(candidate, "@"),
|
||
|
|
field_count(a, "parent"),
|
||
|
|
field_count(candidate, "parent"),
|
||
|
|
steps(a),
|
||
|
|
steps(candidate),
|
||
|
|
span(a),
|
||
|
|
span(candidate)
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|