gled-model 2.34.0

The data model of gled: projects, scenes, effects, animations, curves and assets
Documentation
//! Saved assets must load into the model and save again without losing or
//! renaming anything. The fixtures are real assets from the public asset
//! repository; `local_asset_repository_round_trips` checks a whole local one.

use std::{
    fs,
    path::{Path, PathBuf},
    str::FromStr,
};

use gled_model::{
    asset::{
        ASSET_VERSION, Asset, AssetTrait, animation::Animation, curve::Curve,
        midi_controller::MidiController, output_device::OutputDevice, palette::Palette,
        project::Project, scene::Scene,
    },
    asset_id::AssetId,
    patching::definition::{ColorOrder, FixtureDefinition},
};
use serde_json::Value;
use uuid::Uuid;

/// Fields of older gled versions that the current model no longer has. The
/// first ones were already dropped on save before the model became its own
/// crate; the SVG and its lamp groups gave way to the patch.
const LEGACY_FIELDS: &[&str] = &[
    "external_index",
    "palette_rgb_overwrite",
    "artnet_grid",
    "artnet_grid_width",
    "artnet_grid_height",
    "artnet_scan_index",
    "svg",
    "groups",
    "groups_overwrite",
    "group_index",
];

/// `original` is contained in `saved`: every field of the file is still
/// there with the same value. Saving may add fields that have defaults.
fn assert_contained(original: &Value, saved: &Value, path: &str) {
    match (original, saved) {
        (Value::Object(original), Value::Object(saved)) => {
            for (key, value) in original {
                let Some(saved_value) = saved.get(key) else {
                    assert!(
                        LEGACY_FIELDS.contains(&key.as_str()),
                        "{path}.{key} was dropped when saving"
                    );
                    continue;
                };
                assert_contained(value, saved_value, &format!("{path}.{key}"));
            }
        }
        (Value::Array(original), Value::Array(saved)) => {
            assert_eq!(original.len(), saved.len(), "{path} changed its length");
            for (index, (value, saved_value)) in original.iter().zip(saved).enumerate() {
                assert_contained(value, saved_value, &format!("{path}[{index}]"));
            }
        }
        (Value::Number(original), Value::Number(saved)) => {
            let (Some(original), Some(saved)) = (original.as_f64(), saved.as_f64()) else {
                panic!("{path} is not a number any more");
            };
            // f32 fields read from f64 text.
            assert!(
                (original - saved).abs() <= 1e-6 * original.abs().max(1.0),
                "{path} changed from {original} to {saved}"
            );
        }
        _ => assert_eq!(original, saved, "{path} changed"),
    }
}

/// Loads every asset of a kind from `dir`, saves it and loads it again.
/// Returns how many there were.
fn round_trip<T: AssetTrait>(dir: &Path) -> usize {
    let Ok(entries) = fs::read_dir(dir.join(T::DIR_NAME)) else {
        return 0;
    };
    let mut count = 0;
    for entry in entries {
        let path = entry.expect("directory entry").path();
        if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
            continue;
        }
        let stem = path
            .file_stem()
            .and_then(|stem| stem.to_str())
            .expect("file name");
        let id = AssetId::<T>::from_uuid(Uuid::from_str(stem).expect("file named by uuid"));
        let text = fs::read_to_string(&path).expect("readable asset");

        let asset = Asset::<T>::read(text.as_bytes(), id)
            .unwrap_or_else(|err| panic!("{} does not load: {err}", path.display()));
        let saved = (*asset).clone().into_json().expect("serializes");
        let again = Asset::<T>::read(saved.as_bytes(), id).expect("saved asset loads");
        assert_eq!(asset, again, "{} changed in a save", path.display());

        let mut original: Value = serde_json::from_str(&text).expect("json");
        let saved: Value = serde_json::from_str(&saved).expect("json");
        // saving stamps the current version
        assert_eq!(
            saved["version"],
            ASSET_VERSION,
            "{} saved without the version",
            path.display()
        );
        if let Value::Object(fields) = &mut original {
            fields.remove("version");
        }
        assert_contained(&original, &saved, &path.display().to_string());
        count += 1;
    }
    count
}

fn round_trip_all(dir: &Path) -> usize {
    round_trip::<Animation>(dir)
        + round_trip::<Curve>(dir)
        + round_trip::<FixtureDefinition>(dir)
        + round_trip::<MidiController>(dir)
        + round_trip::<OutputDevice>(dir)
        + round_trip::<Palette>(dir)
        + round_trip::<Project>(dir)
        + round_trip::<Scene>(dir)
}

#[test]
fn fixtures_round_trip() {
    let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
    assert_eq!(round_trip_all(&dir), 10);
}

/// The NanaTube as gled converts it from its old
/// SVG: six GRB tubes, grouped into halves and segments.
#[test]
fn a_converted_fixture_definition_keeps_its_lamp() {
    let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("tests/fixtures/fixture_definitions/eb1e1aca-d9d4-42ce-ab78-0a21a50619d4.json");
    let text = fs::read_to_string(&path).expect("readable asset");
    let id =
        AssetId::from_uuid(Uuid::from_str("eb1e1aca-d9d4-42ce-ab78-0a21a50619d4").expect("uuid"));
    let asset = Asset::<FixtureDefinition>::read(text.as_bytes(), id).expect("loads");
    let definition = &asset.data;

    assert_eq!(asset.path, ["Photonenkollektiv", "NanaTube"]);
    assert_eq!(definition.pixels.len(), 86);
    assert_eq!(definition.channel_count(), 258);
    assert_eq!(
        definition.mode_names(),
        ["individual", "full", "half", "segment"]
    );
    assert_eq!(definition.modes[3].group_count(), 6);
    assert_eq!(definition.pixels[0].color_order(), Some(ColorOrder::Grb));
    assert!(
        definition
            .modes
            .iter()
            .all(|mode| mode.is_partition_of(definition.pixels.len()))
    );
}

/// Checks every asset of a local asset repository, by default the desktop
/// app's (`~/.local/share/gled2` on Linux), or `GLED_ASSETS_DIR`.
#[test]
#[ignore = "reads the local asset repository"]
fn local_asset_repository_round_trips() {
    let dir = std::env::var_os("GLED_ASSETS_DIR")
        .map(PathBuf::from)
        .or_else(|| {
            std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share/gled2"))
        })
        .expect("GLED_ASSETS_DIR or HOME");
    let count = round_trip_all(&dir);
    println!("{count} assets round-trip in {}", dir.display());
    assert!(count > 0, "no assets in {}", dir.display());
}