openusd 0.8.0

Rust native USD library
Documentation
//! Text format compliance tests.
//!
//! Each test parses a `.usda` file from the vendor compliance suite and compares
//! the resulting spec data against the corresponding JSON baseline.

use std::collections::BTreeMap;
use std::path::Path;

use openusd::sdf;
use openusd::usda;

const ASSETS: &str = concat!(
    env!("CARGO_WORKSPACE_DIR"),
    "vendor/core-spec-supplemental-release_dec2025/file_formats/tests/assets/text"
);

/// Parse a `.usda` file and compare the spec data against a JSON baseline.
///
/// Builds a `{ path: { field: value, ... }, ... }` map from the parsed data
/// and asserts it matches the expected baseline.
fn assert_text_format(name: &str) {
    let assets = Path::new(ASSETS);
    let usda = assets.join("usda").join(format!("{name}.usda"));
    let baseline = assets.join("baseline").join(format!("{name}.json"));

    let reader = usda::read_file(&usda).unwrap_or_else(|e| panic!("failed to parse {}: {e:#}", usda.display()));

    // Build actual output as a sorted map of path → fields.
    let mut actual: BTreeMap<String, BTreeMap<&str, &sdf::Value>> = BTreeMap::new();
    for (path, spec) in reader.iter() {
        let fields: BTreeMap<&str, &sdf::Value> = spec.fields.iter().map(|(k, v)| (k.as_str(), v)).collect();
        actual.insert(path.to_string(), fields);
    }

    let mut actual_json = serde_json::to_value(&actual).expect("failed to serialize actual data");
    let mut expected_json: serde_json::Value =
        serde_json::from_str(&std::fs::read_to_string(&baseline).expect("failed to read baseline"))
            .expect("failed to parse baseline JSON");
    normalize_json(&mut actual_json);
    normalize_json(&mut expected_json);
    drop_relationship_variability(&mut actual_json);
    drop_relationship_variability(&mut expected_json);
    check_unregistered_fields(&mut actual_json, &mut expected_json, name);
    expand_baseline_escapes(&mut expected_json);

    let diffs = diff_json::compare_values(&actual_json, &expected_json);
    assert!(
        diffs.is_empty(),
        "mismatch for {name}:\n{}",
        diff_json::DiffFormatter::new().format(&diffs)
    );
}

/// Normalize JSON numbers to avoid false mismatches between our output and the
/// Python-generated baselines. Two sources of noise:
///
/// 1. Integer vs float: Python's JSON encoder preserves the Python type, so
///    a USDA matrix `((0.9, 0, 0, 0), ...)` produces baseline JSON with mixed
///    `0` (int) and `0.9` (float). Our Rust parser stores all components with
///    their declared type (`f64` for matrices), always emitting `0.0`. We
///    normalize whole-number floats to integers on both sides.
///
/// 2. f32 precision: Typed values like `color3f` are stored as `f32` in Rust
///    but as Python `float` (f64) in the reference. When serde serializes `f32`
///    to JSON it round-trips through `f64`, exposing representation noise
///    (e.g. `0.4f32` becomes `0.4000000059604645`). We re-round such values
///    through their shortest `f32` representation to recover the original text.
fn normalize_json(v: &mut serde_json::Value) {
    match v {
        serde_json::Value::Number(n) if n.is_f64() => {
            let f = n.as_f64().unwrap();
            if f.fract() == 0.0 && f.is_finite() && f.abs() < i64::MAX as f64 {
                // Whole-number float → integer.
                *v = serde_json::Value::from(f as i64);
            } else {
                // f32 precision noise → if round-tripping through f32 gives a
                // shorter representation, use it (e.g. 0.4000000059604645 → 0.4).
                let short = format!("{}", f as f32);
                let long = format!("{f}");
                if short.len() < long.len()
                    && let Ok(clean) = short.parse::<f64>()
                {
                    *v = serde_json::json!(clean);
                }
            }
        }
        serde_json::Value::Array(a) => a.iter_mut().for_each(normalize_json),
        serde_json::Value::Object(m) => {
            // The Python baseline encoder omits customData on references/payloads.
            // Strip it from objects that look like references (have "asset" or "layerOffset").
            if m.contains_key("layerOffset") || m.contains_key("asset") {
                m.remove("customData");
            }
            m.values_mut().for_each(normalize_json);
        }
        _ => {}
    }
}

/// The two vendor compliance suites disagree on string escapes. Real C++ expands
/// every escape in a quoted string through `TfEscapeStringReplaceChar`
/// (`Sdf_EvalQuotedString`) — including inside a triple-quoted one, and
/// including an escape it has no special meaning for, where the backslash is
/// dropped and the character kept. This Python `file_formats` baseline leaves
/// the backslash in. We follow C++, since reading a layer USD itself wrote is
/// what the escape handling is for, and expand the baseline the same way.
///
/// Only the escaped quotes the suite actually exercises are expanded; the whole
/// baseline set contains one such string.
fn expand_baseline_escapes(v: &mut serde_json::Value) {
    match v {
        serde_json::Value::String(text) => {
            *text = text.replace("\\'", "'").replace("\\\"", "\"");
        }
        serde_json::Value::Object(fields) => fields.values_mut().for_each(expand_baseline_escapes),
        serde_json::Value::Array(items) => items.iter_mut().for_each(expand_baseline_escapes),
        _ => {}
    }
}

/// The two vendor compliance suites disagree on relationship variability. Real
/// C++ stamps `uniform` on every relationship its text parser reads when the
/// `varying` keyword is absent, and its text writer spells only the `varying`
/// case, so the field has to be present for a relationship to round-trip. This
/// Python `file_formats` baseline records no `variability` on a relationship at
/// all. We follow C++ and drop the field here, where a relationship spec is the
/// one carrying no `typeName`.
fn drop_relationship_variability(v: &mut serde_json::Value) {
    let Some(specs) = v.as_object_mut() else { return };
    for spec in specs.values_mut() {
        let Some(fields) = spec.as_object_mut() else { continue };
        if !fields.contains_key("typeName") && fields.get("variability") == Some(&serde_json::json!("uniform")) {
            fields.remove("variability");
        }
    }
}

/// Metadata fields the two suites represent differently, as
/// `(file, spec path, field, the JSON we store)`.
///
/// Nothing declares these fields, so C++ keeps the text each was authored with
/// and writes it back unchanged, which is how a layer carries metadata some
/// other tool understands (`SdfUnregisteredValue`,
/// `Sdf_TextParserHelpers`). This Python baseline has no such concept and
/// parses the value like any other, so a quoted string loses its quotes, a
/// `None` becomes nothing at all, and a list op holds parsed numbers. We
/// follow C++, and the fourth column is what we store instead, which
/// [`check_unregistered_fields`] asserts before setting the field aside.
const UNREGISTERED_FIELDS: &[(&str, &str, &str, &str)] = &[
    ("layermetadata", "/", "baz", r#""None""#),
    ("relations", "/foo/otherFoo.everything:in:one", "foo", r#""\"bar\"""#),
    (
        "relations",
        "/foo/otherFoo.everything:in:one",
        "this_metadata_value",
        r#"{"prepend": ["5.67"]}"#,
    ),
];

/// Check each divergent field against what this implementation stores, then
/// take it out of both sides so the rest of the spec still compares.
///
/// Asserting here is what keeps the cell covered: dropping the field alone
/// would let a later change stop recording the literal, or record a different
/// one, without any test noticing.
fn check_unregistered_fields(actual: &mut serde_json::Value, expected: &mut serde_json::Value, name: &str) {
    for (_, path, field, ours) in UNREGISTERED_FIELDS.iter().filter(|(file, ..)| *file == name) {
        let ours: serde_json::Value = serde_json::from_str(ours).expect("valid JSON in the table");
        let found = actual
            .get(path)
            .and_then(|fields| fields.get(field))
            .unwrap_or_else(|| panic!("{name}: {path} has no {field}"));
        assert_eq!(found, &ours, "{name}: {path}.{field}");

        for side in [&mut *actual, expected] {
            if let Some(fields) = side.get_mut(path).and_then(serde_json::Value::as_object_mut) {
                fields.remove(*field);
            }
        }
    }
}

mod text_parser {
    use super::*;

    #[test]
    fn test_empty() {
        assert_text_format("empty");
    }

    #[test]
    fn test_simple() {
        assert_text_format("simple");
    }

    // The corpus authors bare scalars (`3 : 5.67`) and `None` as `vector3f`
    // time samples. §16.2.16.6 of the core spec requires a resolvable type's value
    // to have the declared shape, so the parser rejects the file; this records
    // the deliberate disagreement with the Python baseline. The well-formed
    // constructs the file also exercises are covered by the parser's unit
    // tests and `fixtures/typed_timesamples.usda`.
    #[test]
    fn attributes_malformed() {
        let usda = Path::new(ASSETS).join("usda").join("attributes.usda");
        let error = usda::read_file(&usda).expect_err("bare scalars against vector3f samples are rejected");
        let message = format!("{error:#}");
        assert!(
            message.contains("vector3f"),
            "the error names the offending declaration: {message}"
        );
    }

    #[test]
    fn test_relations() {
        assert_text_format("relations");
    }

    #[test]
    fn test_primmetadata() {
        assert_text_format("primmetadata");
    }

    #[test]
    fn test_layermetadata() {
        assert_text_format("layermetadata");
    }

    #[test]
    fn test_dictionaries() {
        assert_text_format("dictionaries");
    }

    #[test]
    fn test_splines() {
        assert_text_format("splines");
    }

    // The two vendor compliance suites disagree on how a variant-child prim
    // renders in a path. The pcp.txt composition goldens are produced by real
    // C++ OpenUSD and use no slash (`/A{vset=sel}B`); this Python `file_formats`
    // baseline (spec_path.py renders every prim as `/{name}`) uses a slash
    // (`/A{vset=sel}/B`). Our paths follow the canonical C++ form so the 285
    // composition goldens pass; matching this baseline's slash form would break
    // them. Left ignored rather than tricked into the non-canonical form.
    #[test]
    #[ignore = "Python file_formats baseline uses non-canonical variant-child slash; we follow C++/pcp.txt"]
    fn test_variants() {
        assert_text_format("variants");
    }

    #[test]
    fn test_geometryattributes() {
        assert_text_format("geometryattributes");
    }
}