use std::collections::BTreeMap;
use std::fmt;
use std::ops::Deref;
use crate::compare::{floats_agree, is_local_digest_attribute, values_agree};
use crate::document::ArenaName;
use cadmpeg_core::dialect::DialectLayers;
use cadmpeg_core::text::NonBlankString;
#[cfg(feature = "schema")]
use schemars::JsonSchema;
use serde::ser::SerializeTuple;
use serde::{Serialize, Serializer};
use serde_json::Value;
use crate::CadIr;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct AttributeChange {
pub key: NonBlankString,
pub left: Option<String>,
pub right: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct SourceDiff {
#[cfg_attr(feature = "schema", schemars(with = "Option<(String, String)>"))]
pub format_change: Option<FormatChange>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(
feature = "schema",
schemars(with = "Option<(Option<DialectLayers>, Option<DialectLayers>)>")
)]
pub dialects_change: Option<DialectsChange>,
pub attributes: Vec<AttributeChange>,
pub local_digests: Vec<AttributeChange>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FormatChange {
before: Option<String>,
after: Option<String>,
}
impl FormatChange {
fn between(before: Option<&str>, after: Option<&str>) -> Option<Self> {
(before != after).then(|| Self {
before: before.map(str::to_owned),
after: after.map(str::to_owned),
})
}
#[must_use]
pub fn before(&self) -> Option<&str> {
self.before.as_deref()
}
#[must_use]
pub fn after(&self) -> Option<&str> {
self.after.as_deref()
}
}
impl Serialize for FormatChange {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut tuple = serializer.serialize_tuple(2)?;
tuple.serialize_element(self.before().unwrap_or(""))?;
tuple.serialize_element(self.after().unwrap_or(""))?;
tuple.end()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DialectsChange {
before: Option<DialectLayers>,
after: Option<DialectLayers>,
}
impl DialectsChange {
fn between(before: Option<&DialectLayers>, after: Option<&DialectLayers>) -> Option<Self> {
(before != after).then(|| Self {
before: before.cloned(),
after: after.cloned(),
})
}
#[must_use]
pub fn before(&self) -> Option<&DialectLayers> {
self.before.as_ref()
}
#[must_use]
pub fn after(&self) -> Option<&DialectLayers> {
self.after.as_ref()
}
}
impl Serialize for DialectsChange {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut tuple = serializer.serialize_tuple(2)?;
tuple.serialize_element(&self.before)?;
tuple.serialize_element(&self.after)?;
tuple.end()
}
}
impl SourceDiff {
#[must_use]
pub fn is_empty(&self) -> bool {
self.format_change.is_none() && self.dialects_change.is_none() && self.attributes.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(transparent)]
pub struct NonEmptyFields(Vec<String>);
impl NonEmptyFields {
#[must_use]
pub fn new(fields: Vec<String>) -> Option<Self> {
(!fields.is_empty()).then_some(Self(fields))
}
}
impl Deref for NonEmptyFields {
type Target = [String];
fn deref(&self) -> &[String] {
&self.0
}
}
impl<U> PartialEq<[U]> for NonEmptyFields
where
String: PartialEq<U>,
{
fn eq(&self, other: &[U]) -> bool {
self.0.as_slice() == other
}
}
impl<U, const N: usize> PartialEq<[U; N]> for NonEmptyFields
where
String: PartialEq<U>,
{
fn eq(&self, other: &[U; N]) -> bool {
self.0.as_slice() == other.as_slice()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct ModifiedEntity {
pub id: String,
pub fields: NonEmptyFields,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ArenaKind {
Model(ArenaName),
Native {
format: String,
name: String,
},
}
impl ArenaKind {
#[must_use]
pub fn native(format: impl Into<String>, name: impl Into<String>) -> Self {
Self::Native {
format: format.into(),
name: name.into(),
}
}
}
impl fmt::Display for ArenaKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Model(name) => f.write_str(name.as_str()),
Self::Native { format, name } => write!(f, "native.{format}.{name}"),
}
}
}
impl PartialEq<str> for ArenaKind {
fn eq(&self, other: &str) -> bool {
match self {
Self::Model(name) => name.as_str() == other,
Self::Native { format, name } => {
other
.strip_prefix("native.")
.and_then(|rest| rest.split_once('.'))
== Some((format.as_str(), name.as_str()))
}
}
}
}
impl PartialEq<&str> for ArenaKind {
fn eq(&self, other: &&str) -> bool {
self == *other
}
}
impl Serialize for ArenaKind {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(self)
}
}
#[cfg(feature = "schema")]
impl JsonSchema for ArenaKind {
fn schema_name() -> std::borrow::Cow<'static, str> {
"ArenaKind".into()
}
fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
String::json_schema(generator)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct ArenaDiff {
pub kind: ArenaKind,
pub added: Vec<String>,
pub removed: Vec<String>,
pub modified: Vec<ModifiedEntity>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct IrDiff {
pub tolerance_change: Option<(crate::units::Tolerances, crate::units::Tolerances)>,
pub source: SourceDiff,
pub per_arena: Vec<ArenaDiff>,
}
impl IrDiff {
pub fn is_empty(&self) -> bool {
self.tolerance_change.is_none()
&& self.source.is_empty()
&& self.per_arena.iter().all(|arena| {
arena.added.is_empty() && arena.removed.is_empty() && arena.modified.is_empty()
})
}
}
#[derive(Serialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[cfg_attr(feature = "schema", schemars(rename = "IrDiff"))]
struct IrDiffWriteWire<'a> {
unit_change: Option<(
crate::units::CanonicalUnitsWire,
crate::units::CanonicalUnitsWire,
)>,
tolerance_change: &'a Option<(crate::units::Tolerances, crate::units::Tolerances)>,
source: &'a SourceDiff,
per_arena: &'a [ArenaDiff],
}
impl Serialize for IrDiff {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
IrDiffWriteWire {
unit_change: None,
tolerance_change: &self.tolerance_change,
source: &self.source,
per_arena: &self.per_arena,
}
.serialize(serializer)
}
}
#[cfg(feature = "schema")]
impl JsonSchema for IrDiff {
fn schema_name() -> std::borrow::Cow<'static, str> {
"IrDiff".into()
}
fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
IrDiffWriteWire::json_schema(generator)
}
}
fn differing_fields<T: Serialize>(left: &T, right: &T) -> Vec<String> {
let (Ok(Value::Object(left)), Ok(Value::Object(right))) =
(serde_json::to_value(left), serde_json::to_value(right))
else {
return vec!["value".to_string()];
};
if left == right {
return vec!["value".to_string()];
}
left.keys()
.chain(right.keys())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.filter(|key| match (left.get(*key), right.get(*key)) {
(Some(before), Some(after)) => values_agree(before, after).is_err(),
(None, None) => false,
_ => true,
})
.cloned()
.collect()
}
fn arena<T, F>(kind: ArenaKind, left: &[T], right: &[T], identity: F) -> ArenaDiff
where
T: PartialEq + Serialize,
F: for<'a> Fn(&'a T) -> &'a str,
{
let left: BTreeMap<_, _> = left
.iter()
.map(|entity| (identity(entity), entity))
.collect();
let right: BTreeMap<_, _> = right
.iter()
.map(|entity| (identity(entity), entity))
.collect();
let removed = left
.keys()
.filter(|id| !right.contains_key(*id))
.map(|id| (*id).to_owned())
.collect();
let added = right
.keys()
.filter(|id| !left.contains_key(*id))
.map(|id| (*id).to_owned())
.collect();
let modified = left
.iter()
.filter_map(|(id, before)| {
let after = right.get(id)?;
if *before == *after {
return None;
}
let fields = NonEmptyFields::new(differing_fields(*before, *after))?;
Some(ModifiedEntity {
id: (*id).to_owned(),
fields,
})
})
.collect();
ArenaDiff {
kind,
added,
removed,
modified,
}
}
macro_rules! define_diff_arenas {
($( $field:ident: $element:ty, $class:ident, $doc:literal, [$($attribute:meta),*] $(, [$($schema_attr:meta),*])?; )*) => {
fn diff_arenas(left: &CadIr, right: &CadIr) -> Vec<ArenaDiff> {
vec![$(arena(
ArenaKind::Model(ArenaName::registered(
stringify!($field),
crate::document::arena_class!($class),
)),
&left.model.$field,
&right.model.$field,
crate::schema::EntitySchema::identity,
)),*]
}
};
}
crate::document::classified_arena_registry!(define_diff_arenas);
fn diff_native_namespaces(left: &CadIr, right: &CadIr) -> Vec<ArenaDiff> {
let namespaces = left
.native
.0
.keys()
.chain(right.native.0.keys())
.collect::<std::collections::BTreeSet<_>>();
namespaces
.into_iter()
.flat_map(|namespace| {
let left_ns = left.native.namespace(namespace);
let right_ns = right.native.namespace(namespace);
let arenas = left_ns
.into_iter()
.flat_map(|value| value.arenas().keys())
.chain(right_ns.into_iter().flat_map(|value| value.arenas().keys()))
.collect::<std::collections::BTreeSet<_>>();
arenas.into_iter().map(move |name| {
arena(
ArenaKind::native(namespace.as_str(), name.as_str()),
left_ns
.and_then(|value| value.arenas().get(name))
.map(Vec::as_slice)
.unwrap_or_default(),
right_ns
.and_then(|value| value.arenas().get(name))
.map(Vec::as_slice)
.unwrap_or_default(),
|record| record.id(),
)
})
})
.collect()
}
fn tolerances_agree(left: crate::units::Tolerances, right: crate::units::Tolerances) -> bool {
floats_agree(left.linear.get(), right.linear.get())
&& floats_agree(left.angular.get(), right.angular.get())
}
fn diff_source(left: &CadIr, right: &CadIr) -> SourceDiff {
let empty_attributes = BTreeMap::new();
let left_format = left
.source
.as_ref()
.map(crate::document::SourceMeta::format);
let right_format = right
.source
.as_ref()
.map(crate::document::SourceMeta::format);
let left_dialects = left.source.as_ref().and_then(|source| source.dialects());
let right_dialects = right.source.as_ref().and_then(|source| source.dialects());
let left_attributes = left
.source
.as_ref()
.map_or(&empty_attributes, |source| &source.attributes);
let right_attributes = right
.source
.as_ref()
.map_or(&empty_attributes, |source| &source.attributes);
let mut result = SourceDiff {
format_change: FormatChange::between(left_format, right_format),
dialects_change: DialectsChange::between(left_dialects, right_dialects),
..SourceDiff::default()
};
for change in attribute_changes(left_attributes, right_attributes) {
if is_local_digest_attribute(change.key.as_str()) {
result.local_digests.push(change);
} else {
result.attributes.push(change);
}
}
result
}
fn attribute_changes(
left: &BTreeMap<NonBlankString, String>,
right: &BTreeMap<NonBlankString, String>,
) -> Vec<AttributeChange> {
left.keys()
.chain(right.keys())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.filter_map(|key| {
let before = left.get(key);
let after = right.get(key);
(before != after).then(|| AttributeChange {
key: key.clone(),
left: before.cloned(),
right: after.cloned(),
})
})
.collect()
}
pub fn diff(left: &CadIr, right: &CadIr) -> IrDiff {
let tolerance_change = (!tolerances_agree(left.tolerances, right.tolerances))
.then_some((left.tolerances, right.tolerances));
let mut per_arena = diff_arenas(left, right);
per_arena.extend(diff_native_namespaces(left, right));
IrDiff {
tolerance_change,
source: diff_source(left, right),
per_arena,
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::diff;
use crate::compare;
use crate::examples::unit_cube;
#[test]
fn detects_changes_in_all_document_dimensions() {
let left = unit_cube().expect("valid unit cube fixture");
let mut right = left.clone();
let moved = right.model.points[0].position().get();
right.model.points[0].set_position(
crate::features::FinitePoint3::new(crate::math::Point3::new(
moved.x + 1.0,
moved.y,
moved.z,
))
.expect("a finite position is a point"),
);
right.model.loops.pop();
right.model.coedges.pop();
let result = diff(&left, &right);
assert!(!result.is_empty());
assert_eq!(
result
.per_arena
.iter()
.find(|a| a.kind == "points")
.expect("required invariant")
.modified[0]
.fields,
["position"]
);
assert_eq!(
result
.per_arena
.iter()
.find(|a| a.kind == "loops")
.expect("required invariant")
.removed
.len(),
1
);
assert_eq!(
result
.per_arena
.iter()
.find(|a| a.kind == "coedges")
.expect("required invariant")
.removed
.len(),
1
);
}
fn modified(result: &super::IrDiff, kind: &str) -> Vec<String> {
result
.per_arena
.iter()
.find(|arena| arena.kind == *kind)
.expect("every model arena appears in every diff")
.modified
.iter()
.map(|entity| entity.id.clone())
.collect()
}
fn scaled_point(ir: &crate::CadIr) -> usize {
ir.model
.points
.iter()
.position(|point| point.position().get().x.abs() >= 1.0)
.expect("the cube fixture places points away from the origin")
}
#[test]
fn a_last_place_coordinate_move_is_not_a_difference() {
let left = unit_cube().expect("valid unit cube fixture");
let mut right = left.clone();
let index = scaled_point(&left);
let before = right.model.points[index].position().get().x;
let after = f64::from_bits(before.to_bits() + 1);
assert_ne!(
before.to_bits(),
after.to_bits(),
"the coordinate must move, or this test proves nothing"
);
let moved = right.model.points[index].position().get();
right.model.points[index].set_position(
crate::features::FinitePoint3::new(crate::math::Point3::new(after, moved.y, moved.z))
.expect("a finite position is a point"),
);
assert_ne!(
serde_json::to_value(&left.model.points).expect("test fixture invariant"),
serde_json::to_value(&right.model.points).expect("test fixture invariant"),
"the serialized documents must differ, or exact equality would pass too"
);
let result = diff(&left, &right);
assert!(result.is_empty(), "{result:?}");
}
#[test]
fn a_last_place_tolerance_move_is_not_a_difference() {
let left = unit_cube().expect("valid unit cube fixture");
let mut right = left.clone();
right.tolerances.linear = crate::scalar::PositiveLength::new(f64::from_bits(
left.tolerances.linear.get().to_bits() + 1,
))
.expect("positive finite tolerance");
assert!(diff(&left, &right).is_empty());
right.tolerances.linear =
crate::scalar::PositiveLength::new(left.tolerances.linear.get() * 2.0)
.expect("positive finite tolerance");
assert!(diff(&left, &right).tolerance_change.is_some());
}
#[test]
fn a_genuine_coordinate_change_is_still_reported() {
let left = unit_cube().expect("valid unit cube fixture");
let mut right = left.clone();
let index = scaled_point(&left);
let point = right.model.points[index].position().get();
right.model.points[index].set_position(
crate::features::FinitePoint3::new(crate::math::Point3::new(
point.x.mul_add(1.0e-6, point.x),
point.y,
point.z,
))
.expect("a finite position is a point"),
);
let result = diff(&left, &right);
assert!(!result.is_empty());
assert_eq!(
modified(&result, "points"),
[left.model.points[index].id.as_str().to_owned()]
);
}
#[test]
fn an_integer_field_differing_by_one_is_always_reported() {
use crate::geometry::{nurbs::NurbsCurve, Curve, CurveGeometry, SolvedCurveGeometry};
use crate::ids::CurveId;
use crate::math::Point3;
let nurbs = |degree: u32| Curve {
parameter_range: None,
id: CurveId::mint("synthetic:tolerance:curve#nurbs").expect("valid identity"),
geometry: CurveGeometry::Solved(SolvedCurveGeometry::Nurbs(
NurbsCurve::from_lanes(
degree,
if degree == 1 {
vec![0.0, 0.0, 0.5, 1.0, 1.0]
} else {
vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0]
},
vec![
Point3::new(0.0, 0.0, 0.0),
Point3::new(1.0, 0.0, 0.0),
Point3::new(2.0, 0.0, 0.0),
],
None,
false,
)
.expect("test fixture invariant"),
)),
source_object: None,
};
let mut left = unit_cube().expect("valid unit cube fixture");
let mut right = left.clone();
left.model.curves.push(nurbs(1));
right.model.curves.push(nurbs(2));
let result = diff(&left, &right);
assert!(!result.is_empty());
assert_eq!(
modified(&result, "curves"),
["synthetic:tolerance:curve#nurbs"]
);
}
#[test]
fn a_difference_json_cannot_state_is_reported_modified() {
#[derive(PartialEq, serde::Serialize)]
struct Entity {
id: &'static str,
value: f64,
}
let left = [Entity {
id: "test:entity#0",
value: f64::NAN,
}];
let right = [Entity {
id: "test:entity#0",
value: f64::INFINITY,
}];
let result = super::arena(
super::ArenaKind::native("test", "entities"),
&left,
&right,
|entity| entity.id,
);
assert_eq!(
result
.modified
.iter()
.map(|entity| entity.id.as_str())
.collect::<Vec<_>>(),
["test:entity#0"]
);
assert_eq!(result.modified[0].fields, ["value"]);
}
fn with_source(attributes: &[(&str, &str)]) -> crate::CadIr {
let mut ir = unit_cube().expect("valid unit cube fixture");
ir.source = Some(crate::document::SourceMeta::classified(
cadmpeg_core::dialect::DialectLayers::of(
cadmpeg_core::dialect::DialectMatch::admitted(cadmpeg_core::dialect_id!(
"rhino:archive-80"
)),
),
cadmpeg_core::text::named_entries(
"the fixture document",
attributes
.iter()
.map(|(key, value)| ((*key).to_owned(), (*value).to_owned())),
)
.expect("the fixture states named attributes"),
));
ir
}
fn classify_source(ir: &mut crate::CadIr, dialect: cadmpeg_core::dialect::DialectMatch) {
let source = ir
.source
.take()
.expect("the test document has source metadata");
ir.source = Some(crate::document::SourceMeta::classified(
cadmpeg_core::dialect::DialectLayers::of(dialect),
source.attributes,
));
}
#[test]
fn a_source_attribute_difference_is_a_difference() {
let left = with_source(&[("program_version", "1.0"), ("object_count", "3")]);
let right = with_source(&[("program_version", "1.1"), ("object_count", "3")]);
let result = diff(&left, &right);
assert!(!result.is_empty());
assert_eq!(
result.source.attributes,
[super::AttributeChange {
key: cadmpeg_core::nonblank_literal!("program_version"),
left: Some("1.0".to_owned()),
right: Some("1.1".to_owned()),
}]
);
assert!(result.source.local_digests.is_empty());
}
#[test]
fn an_added_or_removed_source_attribute_is_a_difference() {
let left = with_source(&[("object_count", "3")]);
let right = with_source(&[]);
let forward = diff(&left, &right);
assert!(!forward.is_empty());
assert_eq!(forward.source.attributes[0].left.as_deref(), Some("3"));
assert_eq!(forward.source.attributes[0].right, None);
let backward = diff(&right, &left);
assert!(!backward.is_empty());
assert_eq!(backward.source.attributes[0].left, None);
assert_eq!(backward.source.attributes[0].right.as_deref(), Some("3"));
}
#[test]
fn a_machine_local_digest_difference_is_reported_but_is_not_a_difference() {
let left = with_source(&[("document_local_sha256", &"0".repeat(64))]);
let right = with_source(&[("document_local_sha256", &"1".repeat(64))]);
let result = diff(&left, &right);
assert_ne!(left.source, right.source);
assert!(result.is_empty(), "{result:?}");
assert!(result.source.attributes.is_empty());
assert_eq!(
result
.source
.local_digests
.iter()
.map(|change| change.key.as_str())
.collect::<Vec<_>>(),
["document_local_sha256"]
);
}
#[test]
fn the_carve_out_follows_the_suffix_convention() {
let key = format!("future_codec_thing{}", compare::LOCAL_DIGEST_SUFFIX);
let left = with_source(&[(&key, "a"), ("footer_fingerprint", "f")]);
let right = with_source(&[(&key, "b"), ("footer_fingerprint", "f")]);
let result = diff(&left, &right);
assert!(result.is_empty(), "{result:?}");
assert_eq!(result.source.local_digests[0].key.as_str(), key);
let right = with_source(&[(&key, "b"), ("footer_fingerprint", "g")]);
let result = diff(&left, &right);
assert!(!result.is_empty());
assert_eq!(result.source.attributes[0].key, "footer_fingerprint");
}
#[test]
fn absent_source_metadata_compares_without_panicking() {
let mut bare = unit_cube().expect("valid unit cube fixture");
bare.source = None;
let populated = with_source(&[("object_count", "3")]);
for (left, right) in [(&bare, &populated), (&populated, &bare)] {
let result = diff(left, right);
assert!(!result.is_empty());
let change = result
.source
.format_change
.as_ref()
.expect("test fixture invariant");
assert_ne!(change.before(), change.after());
assert_eq!(result.source.attributes.len(), 1);
}
let rendered =
serde_json::to_value(&diff(&bare, &populated).source).expect("test fixture invariant");
assert_eq!(rendered["format_change"], serde_json::json!(["", "rhino"]));
assert!(diff(&bare, &bare).is_empty());
}
#[test]
fn identical_documents_have_empty_diff() {
let ir = unit_cube().expect("valid unit cube fixture");
assert!(diff(&ir, &ir).is_empty());
}
#[test]
fn a_dialect_or_declared_divergence_is_a_difference() {
let mut left = with_source(&[]);
let mut right = left.clone();
classify_source(
&mut left,
cadmpeg_core::dialect::DialectMatch::admitted(cadmpeg_core::dialect_id!(
"rhino:archive-70"
)),
);
classify_source(
&mut right,
cadmpeg_core::dialect::DialectMatch::admitted(cadmpeg_core::dialect_id!(
"rhino:archive-80"
)),
);
let result = diff(&left, &right);
assert!(!result.is_empty());
let change = result
.source
.dialects_change
.as_ref()
.expect("test fixture invariant");
assert_eq!(
change.before(),
left.source
.as_ref()
.expect("test fixture invariant")
.dialects()
);
assert_eq!(
change.after(),
right
.source
.as_ref()
.expect("test fixture invariant")
.dialects()
);
let mut declared_left = with_source(&[]);
let mut declared_right = declared_left.clone();
classify_source(
&mut declared_left,
cadmpeg_core::dialect::DialectMatch::admitted(cadmpeg_core::dialect_id!(
"rhino:archive-70"
))
.with_declared(BTreeMap::from([(
cadmpeg_core::nonblank_literal!("archive_version"),
"70".into(),
)])),
);
classify_source(
&mut declared_right,
cadmpeg_core::dialect::DialectMatch::admitted(cadmpeg_core::dialect_id!(
"rhino:archive-70"
))
.with_declared(BTreeMap::from([(
cadmpeg_core::nonblank_literal!("archive_version"),
"80".into(),
)])),
);
let declared = diff(&declared_left, &declared_right);
assert!(!declared.is_empty());
let declared_change = declared
.source
.dialects_change
.as_ref()
.expect("test fixture invariant");
assert_eq!(
declared_change.before(),
declared_left
.source
.as_ref()
.expect("test fixture invariant")
.dialects()
);
assert_eq!(
declared_change.after(),
declared_right
.source
.as_ref()
.expect("test fixture invariant")
.dialects()
);
assert!(declared.source.attributes.is_empty());
}
#[test]
fn admission_and_instance_divergence_are_differences() {
use cadmpeg_core::dialect::{DialectLayers, DialectMatch};
let mut left = with_source(&[]);
let mut right = left.clone();
classify_source(
&mut left,
DialectMatch::admitted(cadmpeg_core::dialect_id!("rhino:archive-80")),
);
classify_source(
&mut right,
DialectMatch::refused(cadmpeg_core::dialect_id!("rhino:archive-80")),
);
assert!(!diff(&left, &right).is_empty());
classify_source(
&mut right,
DialectMatch::admitted(cadmpeg_core::dialect_id!("rhino:archive-80"))
.with_instance("embedded/model.3dm"),
);
assert!(!diff(&left, &right).is_empty());
let source = right.source.take().expect("test fixture invariant");
right.source = Some(crate::document::SourceMeta::classified(
DialectLayers::of(DialectMatch::admitted(cadmpeg_core::dialect_id!(
"rhino:archive-80",
)))
.with(
DialectMatch::residual(cadmpeg_core::dialect_id!("acis:text-acis"))
.with_instance("body.sat"),
)
.expect("the test dialect layers have distinct keys"),
source.attributes,
));
let result = diff(&left, &right);
assert!(!result.is_empty());
assert_eq!(
result
.source
.dialects_change
.as_ref()
.expect("test fixture invariant")
.after()
.expect("test fixture invariant")
.iter()
.count(),
2
);
}
#[test]
fn an_unpopulated_dialect_adds_no_key_to_the_serialized_diff() {
let ir = with_source(&[]);
let rendered =
serde_json::to_string(&diff(&ir, &ir).source).expect("test fixture invariant");
assert!(!rendered.contains("dialects_change"), "{rendered}");
}
}