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 { 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 { 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::>() .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::>() .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::() }; 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) ); } }