Test public mixed reads through external model and diagnostic oracles

This commit is contained in:
2026-09-24 21:21:05 -07:00
parent 042e25efc2
commit ddf823a3d3
+812
View File
@@ -0,0 +1,812 @@
//! External consumer: the linked oa-core library is not built with cfg(test).
//! Expected models use authored public values, never a parser or formatter oracle.
use oa_core::analysis as a;
use oa_core::analysis_numeric::Exact;
use oa_core::language::{self, ParseLimits, Span};
use oa_core::source_project::{ProjectLimits, SourceBundle, SourceDiagnostic};
pub use oa_core::*;
use std::collections::BTreeMap;
// This existing helper constructs independent public model values only. It does
// not import private parser/index/writer code or enable cfg(test) in oa-core.
#[path = "../src/language/analysis_source/model_oracle.rs"]
mod independent_model;
fn id(n: u128) -> Id {
Id::from_u128(n)
}
fn empty_project() -> Project {
let mut p = Project::empty();
p.id = id(1);
p.alias = "p".into();
p.name = "Project".into();
p
}
fn document(body: &str) -> String {
format!(
"arcadia 8;\nproject p \"Project\" id \"{}\" {{\n{body}\n}}\n",
id(1)
)
}
fn single(body: &str) -> SourceBundle {
SourceBundle {
entry: "main.arcadia".into(),
files: BTreeMap::from([("main.arcadia".into(), document(body))]),
}
}
fn succeeds(
bundle: &SourceBundle,
limits: ProjectLimits,
expected: &Project,
warnings: &[SourceDiagnostic],
) {
let original = bundle.clone();
let checked = language::check_mixed_bundle(bundle, limits)
.unwrap_or_else(|errors| panic!("expected complete read: {errors:#?}"));
assert_eq!(checked.project(), expected);
assert_eq!(checked.warnings(), warnings);
assert_eq!(bundle, &original, "read success preserves every input byte");
drop(checked);
assert_eq!(bundle, &original);
}
fn fails(bundle: &SourceBundle, limits: ProjectLimits) -> Vec<SourceDiagnostic> {
let original = bundle.clone();
let errors = match language::check_mixed_bundle(bundle, limits) {
Ok(_) => panic!("invalid input returned a successful read witness"),
Err(errors) => errors,
};
assert!(!errors.is_empty());
assert_eq!(bundle, &original, "read refusal preserves every input byte");
errors
}
fn authored_span(source: &str, literal: &str) -> Span {
assert_eq!(
source.matches(literal).count(),
1,
"ambiguous authored span"
);
let start = source.find(literal).unwrap();
Span {
start,
end: start + literal.len(),
}
}
fn error_at(
bundle: &SourceBundle,
errors: &[SourceDiagnostic],
path: &str,
span: Span,
code: &str,
message: &str,
) {
assert_eq!(errors.len(), 1, "{errors:#?}");
let error = &errors[0];
assert_eq!(error.path, path);
assert_eq!(error.diagnostic.severity, validation::Severity::Error);
assert_eq!(error.diagnostic.code, code);
assert_eq!(error.diagnostic.message, message);
assert_eq!(error.diagnostic.span, span);
let source = bundle.files.get(path).map_or("", String::as_str);
let prefix = &source[..span.start];
assert_eq!(
error.diagnostic.line,
prefix.bytes().filter(|b| *b == b'\n').count() + 1
);
assert_eq!(
error.diagnostic.column,
prefix.rsplit('\n').next().unwrap().chars().count() + 1
);
}
fn rich(layer: Layer, crlf: bool) -> (SourceBundle, Project, Vec<SourceDiagnostic>) {
let (ordinary, typed, work) = match layer {
Layer::Oa => (
include_str!("../src/language/analysis_source/lower/fixtures/ordinary-oa.arcadia"),
include_str!("../src/language/analysis_source/lower/fixtures/typed-oa.arcadia"),
"oa_activity",
),
Layer::Sa => (
include_str!("../src/language/analysis_source/lower/fixtures/ordinary-sa.arcadia"),
include_str!("../src/language/analysis_source/lower/fixtures/typed-sa.arcadia"),
"sa_function",
),
};
let mut expected = independent_model::project(layer);
let mut main = ordinary
.replace("\r\n", "\n")
.replacen("arcadia 7;", "arcadia 8;", 1);
assert!(main.starts_with("arcadia 8;"));
// Forward typed uses refer back into nested ordinary Work in this file.
main = main.replacen("{\n", "{\n include \"typed.arcadia\";\n", 1);
if layer == Layer::Sa {
let system = Element {
id: id(999),
alias: "system".into(),
name: "System".into(),
description: String::new(),
parent: None,
body: ElementBody::Sa(SaBody::System),
};
let at = main.rfind('}').unwrap();
main.insert_str(
at,
&format!(
" sa_system system \"System\" id \"{}\" {{}}\n",
system.id
),
);
assert!(expected.elements.insert(system.id, system).is_none());
}
let mut typed = typed.replace("\r\n", "\n");
// Textual argument order is independent of the model's parameter-ID map.
assert!(typed.contains("@combine(a: @receive_time, b: @dispatch_time)"));
typed = typed.replace(
"@combine(a: @receive_time, b: @dispatch_time)",
"@combine(b: @dispatch_time, a: @receive_time)",
);
if crlf {
main = format!("// 港🌊\r\n{}", main.replace('\n', "\r\n"));
typed = typed.replace('\n', "\r\n");
}
let bundle = SourceBundle {
entry: "main.arcadia".into(),
files: BTreeMap::from([
("main.arcadia".into(), main),
("typed.arcadia".into(), typed),
]),
};
// Two nested leaves, so four warnings, unlike the earlier one-Work fixture.
// Existing validation orders both allocation warnings before coverage warnings.
let mut warnings = Vec::new();
for allocation in [true, false] {
for (alias, display) in [("receive", "Receive"), ("dispatch", "Dispatch")] {
let element = expected
.elements
.values()
.find(|e| e.alias == alias)
.unwrap();
let literal = format!("{work} {alias} \"{display}\" id \"{}\" {{}}", element.id);
let source = &bundle.files["main.arcadia"];
let span = authored_span(source, &literal);
let prefix = &source[..span.start];
warnings.push(SourceDiagnostic {
path: "main.arcadia".into(), related: Vec::new(),
diagnostic: language::Diagnostic {
severity: validation::Severity::Warning,
code: if allocation { "unallocated_leaf" } else { "work_without_behavior_context" }.into(),
object_id: Some(element.id),
field: allocation.then(|| "allocation".into()),
message: if allocation { format!("Allocate leaf '{display}' to a performer.") } else {
"Include this leaf activity/function in a process/chain or scenario through a work action or exchange occurrence.".into()
},
span, line: prefix.bytes().filter(|b| *b == b'\n').count() + 1,
column: prefix.rsplit('\n').next().unwrap().chars().count() + 1,
},
});
}
}
(bundle, expected, warnings)
}
#[test]
fn public_mixed_read_complete_oa_sa_models_and_all_located_warnings() {
for layer in [Layer::Oa, Layer::Sa] {
for crlf in [false, true] {
let (bundle, expected, warnings) = rich(layer, crlf);
assert_eq!(expected.definitions.len(), 4);
assert_eq!(warnings.len(), 4);
succeeds(&bundle, ProjectLimits::default(), &expected, &warnings);
}
}
}
fn calc(alias: &str, number: u128, body: &str) -> String {
format!(
"calculation {alias} \"Calculation\" id \"{}\" {{ input x id \"{}\" : Number; result Number; body {body}; }}",
id(number),
id(1)
)
}
fn calculation_model(alias: &str, number: u128, body: a::Expr) -> a::Definition {
a::Definition {
id: id(number),
alias: alias.into(),
name: "Calculation".into(),
description: String::new(),
body: a::DefinitionBody::Calculation(a::CalculationDefinition {
parameters: vec![a::Parameter {
id: a::ParameterId(id(1)),
name: "x".into(),
value_type: a::AnalysisType::Scalar(a::ScalarType::Number),
}],
result: a::AnalysisType::Scalar(a::ScalarType::Number),
body: a::Binding::Expression(body),
}),
}
}
#[test]
fn public_mixed_read_scoped_uuid_reuse_and_cross_file_call_are_not_global_collisions() {
let mut bundle = single("include \"f.arcadia\"; include \"g.arcadia\";");
bundle
.files
.insert("f.arcadia".into(), calc("f", 2, "@g(x: $x)"));
bundle.files.insert("g.arcadia".into(), calc("g", 3, "$x"));
let mut expected = empty_project();
expected.definitions.insert(
id(2),
calculation_model(
"f",
2,
a::Expr::Call {
definition: a::DefinitionRef::Local(id(3)),
arguments: BTreeMap::from([(
a::ParameterId(id(1)),
a::Expr::Parameter(a::ParameterId(id(1))),
)]),
},
),
);
expected.definitions.insert(
id(3),
calculation_model("g", 3, a::Expr::Parameter(a::ParameterId(id(1)))),
);
succeeds(&bundle, ProjectLimits::default(), &expected, &[]);
}
#[test]
fn public_mixed_read_explicit_identity_classes_versions_and_long_forms() {
let empty = single("");
succeeds(&empty, ProjectLimits::default(), &empty_project(), &[]);
let ordinary = format!("oa_entity e \"Entity\" id \"{}\" {{}}", id(2));
let typed = calc("f", 3, "$x");
let valid = single(&format!("{ordinary}\n{typed}"));
assert!(language::check_mixed_bundle(&valid, ProjectLimits::default()).is_ok());
// Each deletion starts from the same valid complete input.
for (literal, expected_start) in [
(format!("id \"{}\"", id(1)), None),
(format!("id \"{}\"", id(2)), Some("oa_entity")),
(format!("id \"{}\"", id(3)), Some("calculation")),
] {
let mut missing = valid.clone();
let text = missing.files.get_mut("main.arcadia").unwrap();
*text = text.replacen(&literal, "", 1);
let start = match expected_start {
None => text.find('{').unwrap(),
Some("oa_entity") => {
text.find("oa_entity").unwrap()
+ text[text.find("oa_entity").unwrap()..].find('{').unwrap()
}
Some(word) => text.find(word).unwrap(),
};
let errors = fails(&missing, ProjectLimits::default());
error_at(
&missing,
&errors,
"main.arcadia",
Span {
start,
end: start + 1,
},
"invalid_source",
"Mixed source8 requires explicit UUIDs.",
);
}
let mut parameter = valid.clone();
let text = parameter.files.get_mut("main.arcadia").unwrap();
*text = text.replace(&format!("input x id \"{}\"", id(1)), "input x");
let start = text.find("calculation").unwrap();
error_at(
&parameter,
&fails(&parameter, ProjectLimits::default()),
"main.arcadia",
Span {
start,
end: start + 1,
},
"invalid_source",
"Mixed source8 requires explicit UUIDs.",
);
for version in ["3", "7", "9"] {
let mut bad = empty.clone();
*bad.files.get_mut("main.arcadia").unwrap() =
document("").replacen("arcadia 8", &format!("arcadia {version}"), 1);
error_at(
&bad,
&fails(&bad, ProjectLimits::default()),
"main.arcadia",
Span { start: 8, end: 9 },
"invalid_source",
"Mixed source requires arcadia 8.",
);
}
for word in [
"oa_engineering_property",
"sa_engineering_property",
"oa_analysis_check",
"sa_analysis_check",
"oa_analysis_case",
"sa_analysis_case",
] {
let bad = single(&format!("{word} v \"Value\" id \"{}\" {{}}", id(2)));
let errors = fails(&bad, ProjectLimits::default());
let start = bad.files["main.arcadia"].find(word).unwrap();
assert_eq!(errors[0].path, "main.arcadia");
assert_eq!(errors[0].diagnostic.code, "invalid_source");
assert_eq!(
errors[0].diagnostic.span,
Span {
start,
end: start + 1
}
);
assert!(
errors[0].diagnostic.message.contains("short"),
"{errors:#?}"
);
}
}
fn chain() -> SourceBundle {
let mut bundle = single("include \"part.arcadia\";");
bundle.files.insert(
"part.arcadia".into(),
"include \"last.arcadia\";\r\n".into(),
);
bundle.files.insert("last.arcadia".into(), "//é\r\n".into());
bundle
}
#[test]
fn public_mixed_read_exact_input_file_edge_and_depth_boundaries() {
let empty = single("");
assert_eq!(empty.files["main.arcadia"].len(), 78);
for cap in [78, 77] {
let limits = ProjectLimits {
model: ParseLimits {
max_source_bytes: cap,
..ParseLimits::UNLIMITED
},
..ProjectLimits::default()
};
if cap == 78 {
succeeds(&empty, limits, &empty_project(), &[]);
} else {
error_at(
&empty,
&fails(&empty, limits),
"main.arcadia",
Span { start: 0, end: 1 },
"invalid_source",
"Source8 index/output byte limit exceeded.",
);
}
}
let bundle = chain();
assert_eq!(bundle.files.values().map(String::len).sum::<usize>(), 132);
let fits = ProjectLimits {
max_files: 3,
max_include_edges: 2,
max_include_depth: 2,
model: ParseLimits {
max_source_bytes: 132,
..ParseLimits::UNLIMITED
},
};
succeeds(&bundle, fits, &empty_project(), &[]);
for (limits, path, span, code, message) in [
(
ProjectLimits {
max_files: 2,
..fits
},
"main.arcadia",
Span { start: 0, end: 0 },
"project_limit",
"Source project exceeds 2 files.",
),
(
ProjectLimits {
max_include_edges: 1,
..fits
},
"part.arcadia",
Span { start: 0, end: 1 },
"project_limit",
"Include graph exceeds 1 edges.",
),
(
ProjectLimits {
max_include_depth: 1,
..fits
},
"part.arcadia",
Span { start: 0, end: 23 },
"project_limit",
"Include depth exceeds 1.",
),
(
ProjectLimits {
model: ParseLimits {
max_source_bytes: 131,
..fits.model
},
..fits
},
"main.arcadia",
Span { start: 0, end: 1 },
"invalid_source",
"Source8 index/output byte limit exceeded.",
),
] {
error_at(&bundle, &fails(&bundle, limits), path, span, code, message);
}
}
#[test]
fn public_mixed_read_paths_missing_cycles_and_detached_files_are_errors() {
let good = chain();
succeeds(&good, ProjectLimits::default(), &empty_project(), &[]);
let mut missing = good.clone();
missing.files.remove("last.arcadia");
error_at(
&missing,
&fails(&missing, ProjectLimits::default()),
"part.arcadia",
Span { start: 0, end: 23 },
"missing_include",
"Included file 'last.arcadia' is missing.",
);
let mut detached = good.clone();
detached
.files
.insert("unused.arcadia".into(), "// valid but detached\n".into());
error_at(
&detached,
&fails(&detached, ProjectLimits::default()),
"unused.arcadia",
Span { start: 0, end: 0 },
"unreachable_file",
"Supplied source file is not reachable from the entry file.",
);
let mut cycle = good.clone();
cycle
.files
.insert("last.arcadia".into(), "include \"part.arcadia\";".into());
error_at(
&cycle,
&fails(&cycle, ProjectLimits::default()),
"last.arcadia",
Span { start: 0, end: 23 },
"include_cycle",
"Include cycle: part.arcadia -> last.arcadia -> part.arcadia.",
);
let mut invalid = good.clone();
invalid
.files
.insert("../escape.arcadia".into(), String::new());
error_at(
&invalid,
&fails(&invalid, ProjectLimits::default()),
"main.arcadia",
Span { start: 0, end: 0 },
"invalid_path",
"Non-canonical source path '../escape.arcadia'.",
);
let mut collision = good;
collision
.files
.insert("PART.arcadia".into(), "// detached uppercase\n".into());
let errors = fails(&collision, ProjectLimits::default());
error_at(
&collision,
&errors,
"main.arcadia",
Span { start: 0, end: 0 },
"duplicate_path",
"Case-colliding source paths 'PART.arcadia' and 'part.arcadia'.",
);
}
#[test]
fn public_mixed_read_final_nested_model_limit_is_distinct_from_syntax_entries() {
let bundle = single(&calc("f", 2, "$x"));
let mut expected = empty_project();
expected.definitions.insert(
id(2),
calculation_model("f", 2, a::Expr::Parameter(a::ParameterId(id(1)))),
);
// Two declarations fit both policies. Parameter and expression make four
// final records; this does not pretend to set the internal entry quota.
let limits = ProjectLimits {
model: ParseLimits {
max_records: 4,
max_graph_objects: 1,
..ParseLimits::UNLIMITED
},
..ProjectLimits::default()
};
succeeds(&bundle, limits, &expected, &[]);
let errors = fails(
&bundle,
ProjectLimits {
model: ParseLimits {
max_records: 3,
..limits.model
},
..limits
},
);
error_at(
&bundle,
&errors,
"main.arcadia",
Span { start: 0, end: 1 },
"model_limit",
&format!(
"Model exceeds admission limits (1 graph objects, 3 total records, {} ordered references).",
usize::MAX
),
);
let errors = fails(
&bundle,
ProjectLimits {
model: ParseLimits {
max_graph_objects: 0,
..limits.model
},
..limits
},
);
assert_eq!(errors[0].diagnostic.code, "model_limit");
let start = bundle.files["main.arcadia"].find("calculation").unwrap();
assert_eq!(
errors[0].diagnostic.span,
Span {
start,
end: start + 1
}
);
}
#[test]
fn public_mixed_read_typed_reference_caller_allowance_has_a_real_positive_control() {
let bundle = single(&calc("f", 2, "$x"));
let mut expected = empty_project();
expected.definitions.insert(
id(2),
calculation_model("f", 2, a::Expr::Parameter(a::ParameterId(id(1)))),
);
let fits = ProjectLimits {
model: ParseLimits {
max_step_references: 1,
..ParseLimits::UNLIMITED
},
..ProjectLimits::default()
};
succeeds(&bundle, fits, &expected, &[]);
let start = bundle.files["main.arcadia"].find("$x").unwrap() + 1;
error_at(
&bundle,
&fails(
&bundle,
ProjectLimits {
model: ParseLimits {
max_step_references: 0,
..fits.model
},
..fits
},
),
"main.arcadia",
Span {
start,
end: start + 1,
},
"invalid_source",
"Source8 references limit exceeded.",
);
}
#[test]
fn public_mixed_read_malformed_duplicate_and_wrong_domain_references() {
let calculation = calc("f", 2, "1");
let valid = single(&calculation);
let mut expected = empty_project();
expected.definitions.insert(
id(2),
calculation_model("f", 2, a::Expr::Number(Exact::from_integer(1))),
);
succeeds(&valid, ProjectLimits::default(), &expected, &[]);
let mut malformed = valid.clone();
*malformed.files.get_mut("main.arcadia").unwrap() = document(&calc("f", 2, ""));
let start = malformed.files["main.arcadia"].find("body ;").unwrap() + 5;
error_at(
&malformed,
&fails(&malformed, ProjectLimits::default()),
"main.arcadia",
Span {
start,
end: start + 1,
},
"invalid_source",
"Expected an analysis expression.",
);
let mut unresolved = single("include \"typed.arcadia\";");
unresolved
.files
.insert("typed.arcadia".into(), calc("f", 2, "@missing(x: 1)"));
let start = unresolved.files["typed.arcadia"].find("@missing").unwrap() + 1;
error_at(
&unresolved,
&fails(&unresolved, ProjectLimits::default()),
"typed.arcadia",
Span {
start,
end: start + 7,
},
"invalid_source",
"Unknown global reference '@missing'.",
);
let ordinary = format!("oa_entity q \"Entity\" id \"{}\" {{}}", id(3));
let wrong = single(&format!("{ordinary}\n{}", calc("f", 2, "@q(x: 1)")));
let start = wrong.files["main.arcadia"].find("@q").unwrap() + 1;
error_at(
&wrong,
&fails(&wrong, ProjectLimits::default()),
"main.arcadia",
Span {
start,
end: start + 1,
},
"invalid_source",
"Wrong reference domain for '@q': Element(Entity, Oa).",
);
let duplicate = single(&format!("{calculation}\n{}", calc("f", 3, "1")));
let errors = fails(&duplicate, ProjectLimits::default());
let second = calc("f", 3, "1");
let span = authored_span(&duplicate.files["main.arcadia"], &second);
error_at(
&duplicate,
&errors,
"main.arcadia",
span,
"invalid_source",
"Duplicate global alias.",
);
assert_eq!(errors[0].related.len(), 1);
assert_eq!(errors[0].related[0].path, "main.arcadia");
assert_eq!(
errors[0].related[0].span,
authored_span(&duplicate.files["main.arcadia"], &calculation)
);
let second = calc("g", 2, "1");
let duplicate_id = single(&format!("{calculation}\n{second}"));
error_at(
&duplicate_id,
&fails(&duplicate_id, ProjectLimits::default()),
"main.arcadia",
authored_span(&duplicate_id.files["main.arcadia"], &second),
"invalid_source",
"Duplicate global identity.",
);
let duplicate_parameter = calc("f", 2, "$x").replace(
"result Number;",
&format!("input x id \"{}\" : Number; result Number;", id(4)),
);
let bad = single(&duplicate_parameter);
let at = bad.files["main.arcadia"].rfind("input x").unwrap() + 6;
error_at(
&bad,
&fails(&bad, ProjectLimits::default()),
"main.arcadia",
Span {
start: at,
end: at + 1,
},
"invalid_source",
"Duplicate parameter name.",
);
let second_parameter = format!("input y id \"{}\" : Number;", id(1));
let duplicate_parameter_id = calc("f", 2, "$x").replace(
"result Number;",
&format!("{second_parameter} result Number;"),
);
let bad = single(&duplicate_parameter_id);
let at = authored_span(&bad.files["main.arcadia"], &second_parameter).start + 11;
error_at(
&bad,
&fails(&bad, ProjectLimits::default()),
"main.arcadia",
Span {
start: at,
end: at + 1,
},
"invalid_source",
"Duplicate parameter identity.",
);
}
#[test]
fn public_mixed_read_unicode_columns_are_not_utf8_byte_columns() {
let source = "arcadia 8;\r\nproject p \"港🌊\" id \"00000000-0000-0000-0000-000000000001\" { oa_activity w \"Work\" id \"00000000-0000-0000-0000-000000000002\" {} }\r\n";
let bundle = SourceBundle {
entry: "main.arcadia".into(),
files: BTreeMap::from([("main.arcadia".into(), source.into())]),
};
let mut expected = empty_project();
expected.name = "港🌊".into();
expected.elements.insert(
id(2),
Element {
id: id(2),
alias: "w".into(),
name: "Work".into(),
description: String::new(),
parent: None,
body: ElementBody::Oa(OaBody::Activity),
},
);
let warnings = [
("unallocated_leaf", Some("allocation"), "Allocate leaf 'Work' to a performer."),
("work_without_behavior_context", None, "Include this leaf activity/function in a process/chain or scenario through a work action or exchange occurrence."),
].map(|(code, field, message)| SourceDiagnostic {
path: "main.arcadia".into(), related: Vec::new(),
diagnostic: language::Diagnostic { severity: validation::Severity::Warning, code: code.into(), object_id: Some(id(2)), field: field.map(str::to_owned), message: message.into(), span: Span { start: 76, end: 141 }, line: 2, column: 60 },
});
succeeds(&bundle, ProjectLimits::default(), &expected, &warnings);
}
#[test]
fn public_mixed_read_ordinary_parity_uses_an_independent_complete_model() {
let b = single(&format!(
"oa_entity team \"Team\" id \"{}\" {{ oa_actor sailor \"Sailor\" id \"{}\" {{ description \"海\"; }} }}",
id(2),
id(3)
));
let mut expected = empty_project();
for (n, alias, name, description, parent, body) in [
(2, "team", "Team", "", None, ElementBody::Oa(OaBody::Entity)),
(
3,
"sailor",
"Sailor",
"海",
Some(id(2)),
ElementBody::Oa(OaBody::Actor),
),
] {
expected.elements.insert(
id(n),
Element {
id: id(n),
alias: alias.into(),
name: name.into(),
description: description.into(),
parent,
body,
},
);
}
succeeds(&b, ProjectLimits::default(), &expected, &[]);
let ordinary = language::parse_with_report(&b.files["main.arcadia"]).unwrap();
assert!(ordinary.migration.is_none());
assert_eq!(ordinary.project, expected);
let compiled = source_project::compile_bundle(&b, ProjectLimits::default()).unwrap();
assert_eq!(compiled.decoded.project, expected);
assert_eq!(compiled.bundle, b);
}