Files
ARCADIA-Studio/experiments/source-authoring/tests/experiment.rs
T

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)
);
}
}