test: verify compact default policy against real model round trips
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
//! Test-only fidelity bridge. Reuse the production canonical renderer and remove
|
||||
//! only policy-selected field spans; never regenerate a value or reference.
|
||||
//! This is not a source editor, production formatter, or general syntax parser.
|
||||
#[path = "source-defaults-20260913.rs"]
|
||||
mod policy;
|
||||
|
||||
use oa_core::{Id, Project, language};
|
||||
use policy::{Context, Mode};
|
||||
use serde_json::{Map, Value};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
pub struct Probe {
|
||||
pub canonical: String,
|
||||
pub compact: String,
|
||||
pub removed_fields: usize,
|
||||
}
|
||||
|
||||
pub fn compact(project: &Project) -> Probe {
|
||||
assert_eq!(project.schema_version, 7, "this probe is source7 only");
|
||||
let canonical = language::format(project);
|
||||
assert_eq!(
|
||||
language::parse(&canonical).unwrap(),
|
||||
*project,
|
||||
"original renderer must admit the entire model first"
|
||||
);
|
||||
let records = extracted_fields(project);
|
||||
let outline = language::outline(
|
||||
&canonical,
|
||||
language::OutlineLimits {
|
||||
max_source_bytes: usize::MAX,
|
||||
max_tokens: usize::MAX,
|
||||
max_declarations: usize::MAX,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!outline.has_lexical_errors && !outline.has_unmatched_braces);
|
||||
let mut edits = Vec::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
for entry in outline.declarations {
|
||||
let declaration = entry.declaration;
|
||||
let id = declaration.id.expect("canonical explicit identity");
|
||||
let start = declaration.span.start;
|
||||
let end = declaration.span.end;
|
||||
let mut reader = Reader::new(&canonical[start..end]);
|
||||
reader.seek_body();
|
||||
if id == project.id {
|
||||
// Project headers are outside the field policy. Its canonical first
|
||||
// field is description; leave all following declarations to outline.
|
||||
let description = reader.field();
|
||||
assert_eq!(description.name, "description");
|
||||
if project.description.is_empty() {
|
||||
edits.push(line_span(
|
||||
&canonical,
|
||||
start + description.start,
|
||||
start + description.end,
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
assert!(seen.insert(id), "duplicate canonical identity");
|
||||
let (context, input) = records
|
||||
.get(&id)
|
||||
.expect("every canonical record maps to a typed model record");
|
||||
let complete = policy::fields(*context, input, Mode::CompleteForEdit);
|
||||
let reduced = policy::fields(*context, &complete, Mode::Compact);
|
||||
assert_subset(&Value::Object(complete), &Value::Object(reduced.clone()));
|
||||
let fields = reader.fields();
|
||||
reader.expect("}");
|
||||
assert_eq!(
|
||||
reader.cursor,
|
||||
reader.tokens.len(),
|
||||
"consume exactly one canonical declaration"
|
||||
);
|
||||
prune_record(&canonical, start, &fields, &reduced, &mut edits);
|
||||
}
|
||||
assert_eq!(
|
||||
seen,
|
||||
records.keys().copied().collect(),
|
||||
"every model record must be exercised"
|
||||
);
|
||||
edits.sort_unstable();
|
||||
for pair in edits.windows(2) {
|
||||
assert!(pair[0].1 <= pair[1].0, "field deletions must not overlap");
|
||||
}
|
||||
let removed_fields = edits.len();
|
||||
let mut result = canonical.clone();
|
||||
for (start, end) in edits.into_iter().rev() {
|
||||
result.replace_range(start..end, "");
|
||||
}
|
||||
Probe {
|
||||
canonical,
|
||||
compact: result,
|
||||
removed_fields,
|
||||
}
|
||||
}
|
||||
|
||||
fn extracted_fields(project: &Project) -> BTreeMap<Id, (Context, Map<String, Value>)> {
|
||||
let mut records = BTreeMap::new();
|
||||
for element in project.elements.values() {
|
||||
let mut fields = payload(serde_json::to_value(&element.body).unwrap());
|
||||
fields.insert(
|
||||
"description".into(),
|
||||
Value::String(element.description.clone()),
|
||||
);
|
||||
fields.insert(
|
||||
"parent".into(),
|
||||
serde_json::to_value(element.parent).unwrap(),
|
||||
);
|
||||
records.insert(element.id, (Context::Element(element.kind()), fields));
|
||||
}
|
||||
for relation in project.relations.values() {
|
||||
let mut fields = payload(serde_json::to_value(&relation.body).unwrap());
|
||||
fields.insert(
|
||||
"description".into(),
|
||||
Value::String(relation.description.clone()),
|
||||
);
|
||||
records.insert(relation.id, (Context::Relation(relation.kind()), fields));
|
||||
}
|
||||
for diagram in project.diagrams.values() {
|
||||
let mut fields = record_fields(diagram);
|
||||
fields.remove("nodes");
|
||||
records.insert(diagram.id, (Context::Diagram, fields));
|
||||
for node in diagram.nodes.values() {
|
||||
let mut fields = record_fields(node);
|
||||
fields.insert("diagram".into(), Value::String(diagram.id.to_string()));
|
||||
records.insert(node.id, (Context::Node, fields));
|
||||
}
|
||||
}
|
||||
for artifact in project.artifact_links.values() {
|
||||
records.insert(artifact.id, (Context::Artifact, record_fields(artifact)));
|
||||
}
|
||||
for trace in project.traces.values() {
|
||||
records.insert(trace.id, (Context::Trace, record_fields(trace)));
|
||||
}
|
||||
records
|
||||
}
|
||||
|
||||
fn record_fields(value: &impl serde::Serialize) -> Map<String, Value> {
|
||||
let mut fields = serde_json::to_value(value)
|
||||
.unwrap()
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone();
|
||||
for key in ["id", "alias", "name"] {
|
||||
fields.remove(key);
|
||||
}
|
||||
fields
|
||||
}
|
||||
|
||||
fn payload(value: Value) -> Map<String, Value> {
|
||||
match value {
|
||||
Value::Object(fields)
|
||||
if fields.len() == 1
|
||||
&& fields
|
||||
.keys()
|
||||
.next()
|
||||
.unwrap()
|
||||
.starts_with(char::is_uppercase) =>
|
||||
{
|
||||
payload(fields.into_values().next().unwrap())
|
||||
}
|
||||
Value::Object(fields) => fields,
|
||||
Value::String(_) => Map::new(),
|
||||
_ => panic!("typed declaration payload"),
|
||||
}
|
||||
}
|
||||
|
||||
// The policy may only delete fields. It must never silently replace a value,
|
||||
// change a number's sign, shorten an ordered array, or add an invented field.
|
||||
fn assert_subset(before: &Value, after: &Value) {
|
||||
match (before, after) {
|
||||
(Value::Object(before), Value::Object(after)) => {
|
||||
for (key, value) in after {
|
||||
assert_subset(before.get(key).expect("no added field"), value);
|
||||
}
|
||||
}
|
||||
(Value::Array(before), Value::Array(after)) => {
|
||||
assert_eq!(before.len(), after.len());
|
||||
for (a, b) in before.iter().zip(after) {
|
||||
assert_subset(a, b);
|
||||
}
|
||||
}
|
||||
(Value::Number(a), Value::Number(b)) => {
|
||||
assert_eq!(a, b);
|
||||
if a.as_f64() == Some(0.0) {
|
||||
assert_eq!(a.as_f64().unwrap().to_bits(), b.as_f64().unwrap().to_bits());
|
||||
}
|
||||
}
|
||||
_ => assert_eq!(before, after),
|
||||
}
|
||||
}
|
||||
|
||||
enum SyntaxValue {
|
||||
Record(Vec<Field>),
|
||||
Array(Vec<SyntaxValue>),
|
||||
Scalar,
|
||||
}
|
||||
struct Field {
|
||||
name: String,
|
||||
start: usize,
|
||||
end: usize,
|
||||
value: SyntaxValue,
|
||||
}
|
||||
struct Reader<'a> {
|
||||
source: &'a str,
|
||||
tokens: Vec<language::TokenSpan>,
|
||||
cursor: usize,
|
||||
}
|
||||
|
||||
impl<'a> Reader<'a> {
|
||||
fn new(source: &'a str) -> Self {
|
||||
let tokens = language::tokenize(source)
|
||||
.into_iter()
|
||||
.filter(|token| {
|
||||
!matches!(
|
||||
token.class,
|
||||
language::TokenClass::Whitespace | language::TokenClass::Comment
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
source,
|
||||
tokens,
|
||||
cursor: 0,
|
||||
}
|
||||
}
|
||||
fn text(&self) -> &str {
|
||||
let span = self.tokens[self.cursor].span;
|
||||
&self.source[span.start..span.end]
|
||||
}
|
||||
fn expect(&mut self, text: &str) {
|
||||
assert_eq!(self.text(), text);
|
||||
self.cursor += 1;
|
||||
}
|
||||
fn seek_body(&mut self) {
|
||||
while self.text() != "{" {
|
||||
self.cursor += 1;
|
||||
}
|
||||
self.expect("{");
|
||||
}
|
||||
fn fields(&mut self) -> Vec<Field> {
|
||||
let mut fields = Vec::new();
|
||||
while self.text() != "}" {
|
||||
fields.push(self.field());
|
||||
}
|
||||
fields
|
||||
}
|
||||
fn field(&mut self) -> Field {
|
||||
let start = self.tokens[self.cursor].span.start;
|
||||
let name = self.text().to_owned();
|
||||
self.cursor += 1;
|
||||
let value = self.value();
|
||||
let end = self.tokens[self.cursor].span.end;
|
||||
self.expect(";");
|
||||
Field {
|
||||
name,
|
||||
start,
|
||||
end,
|
||||
value,
|
||||
}
|
||||
}
|
||||
fn value(&mut self) -> SyntaxValue {
|
||||
match self.text() {
|
||||
"{" => {
|
||||
self.expect("{");
|
||||
let fields = self.fields();
|
||||
self.expect("}");
|
||||
SyntaxValue::Record(fields)
|
||||
}
|
||||
"[" => {
|
||||
self.expect("[");
|
||||
let mut values = Vec::new();
|
||||
while self.text() != "]" {
|
||||
values.push(self.value());
|
||||
if self.text() != "]" {
|
||||
self.expect(",");
|
||||
}
|
||||
}
|
||||
self.expect("]");
|
||||
SyntaxValue::Array(values)
|
||||
}
|
||||
"@" | "uuid" => {
|
||||
self.cursor += 2;
|
||||
SyntaxValue::Scalar
|
||||
}
|
||||
_ => {
|
||||
self.cursor += 1;
|
||||
SyntaxValue::Scalar
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn line_span(source: &str, start: usize, end: usize) -> (usize, usize) {
|
||||
let line = source[..start].rfind('\n').map_or(0, |offset| offset + 1);
|
||||
assert!(
|
||||
source[line..start].chars().all(|c| c == ' '),
|
||||
"canonical field starts on its own indented line"
|
||||
);
|
||||
assert_eq!(source.as_bytes().get(end), Some(&b'\n'));
|
||||
(line, end + 1)
|
||||
}
|
||||
|
||||
fn prune_record(
|
||||
source: &str,
|
||||
base: usize,
|
||||
fields: &[Field],
|
||||
kept: &Map<String, Value>,
|
||||
edits: &mut Vec<(usize, usize)>,
|
||||
) {
|
||||
for key in kept.keys() {
|
||||
assert!(
|
||||
fields.iter().any(|field| &field.name == key),
|
||||
"retained field missing from rendered source: {key}"
|
||||
);
|
||||
}
|
||||
for field in fields {
|
||||
if let Some(value) = kept.get(&field.name) {
|
||||
prune_value(source, base, &field.value, value, edits);
|
||||
} else {
|
||||
edits.push(line_span(source, base + field.start, base + field.end));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prune_value(
|
||||
source: &str,
|
||||
base: usize,
|
||||
syntax: &SyntaxValue,
|
||||
kept: &Value,
|
||||
edits: &mut Vec<(usize, usize)>,
|
||||
) {
|
||||
match (syntax, kept) {
|
||||
(SyntaxValue::Record(fields), Value::Object(values)) => {
|
||||
prune_record(source, base, fields, values, edits)
|
||||
}
|
||||
(SyntaxValue::Array(fields), Value::Array(values)) => {
|
||||
assert_eq!(fields.len(), values.len());
|
||||
for (field, value) in fields.iter().zip(values) {
|
||||
prune_value(source, base, field, value, edits);
|
||||
}
|
||||
}
|
||||
(
|
||||
SyntaxValue::Scalar,
|
||||
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_),
|
||||
) => {}
|
||||
_ => panic!("canonical syntax must match the typed value shape"),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user