OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / Probe / Program.cs

278 lines · csharp ref:
using System.Text;

using IFSEngine.Model;
using IFSEngine.Serialization;

using IFSRenderer.Shared;

using Newtonsoft.Json.Linq;

// Round-trip tests for the .ifsjson node-position format, run against the real
// Windows-written template files that ship with the app.

string root = FindRepoRoot();
string templates = Path.Combine(root, "WpfDisplay/Library/Templates");

var includeNames = Directory.GetFiles(Path.Combine(root, "WpfDisplay/Library/Includes"))
    .Select(Path.GetFileName).ToList();
var transforms = Directory
    .GetFiles(Path.Combine(root, "IFSTransforms/Transforms"), "*.ifstf", SearchOption.AllDirectories)
    .Select(f => TransformPlugin.FromFile(f).Result)
    .Where(t => t.IncludeUses.All(u => includeNames.Contains(u))).ToList();
var effects = Directory.GetFiles(Path.Combine(root, "WpfDisplay/Library/PostEffects"), "*.ifsfx")
    .Select(f => EffectPlugin.FromFile(f).Result).ToList();

int failed = 0;
string tmp = Path.Combine(Path.GetTempPath(), "ifs-nodes-tests");
Directory.CreateDirectory(tmp);

// 1. Documents WHY this shim exists: the engine's own serializer omits Nodes.
Check("engine serializer omits Nodes (the bug this fixes)", () =>
{
    string src = File.ReadAllText(Path.Combine(templates, "nebula_sample.ifsjson"), Encoding.UTF8);
    IFS ifs = IfsSerializer.DeserializeJsonString(src, transforms, effects, true);
    string resaved = IfsSerializer.SerializeJsonString(ifs);
    return !resaved.Contains("\"Nodes\"") ? null : "engine unexpectedly kept Nodes";
});

foreach (string file in Directory.GetFiles(templates, "*.ifsjson").OrderBy(f => f))
{
    string name = Path.GetFileName(file);
    string original = File.ReadAllText(file, Encoding.UTF8);

    // 2. Positions are read off a real Windows-written file.
    Check($"{name}: reads Nodes", () =>
    {
        IFS ifs = IfsNodesSerializer.DeserializeJsonString(original, transforms, effects, true);
        var positions = ifs.Iterators.Select(i => i.GetPosition()).ToList();
        if (positions.Count == 0)
            return "no iterators";
        if (positions.Any(p => p is null))
            return $"{positions.Count(p => p is null)}/{positions.Count} iterators lost their position";
        return null;
    });

    // 3. Saving keeps them, with the same values in the same order.
    Check($"{name}: save preserves Nodes exactly", () =>
    {
        IFS ifs = IfsNodesSerializer.DeserializeJsonString(original, transforms, effects, true);
        var before = ifs.Iterators.Select(i => i.GetPosition()).ToList();

        string saved = IfsNodesSerializer.SerializeJsonString(ifs);
        if (!saved.Contains("\"Nodes\""))
            return "Nodes key missing after save";

        IFS reloaded = IfsNodesSerializer.DeserializeJsonString(saved, transforms, effects, true);
        var after = reloaded.Iterators.Select(i => i.GetPosition()).ToList();

        if (before.Count != after.Count)
            return $"iterator count changed {before.Count} -> {after.Count}";
        for (int i = 0; i < before.Count; i++)
        {
            if (before[i] is null || after[i] is null)
                return $"position {i} became null";
            if (Math.Abs(before[i].Value.X - after[i].Value.X) > 1e-9 ||
                Math.Abs(before[i].Value.Y - after[i].Value.Y) > 1e-9)
                return $"position {i} moved: {before[i]} -> {after[i]}";
        }
        return null;
    });

    // 4. The values match the raw JSON the Windows app wrote, in file order.
    //    This is the real guard on the IReadOnlySet enumeration-order risk.
    Check($"{name}: order matches the source file", () =>
    {
        if (JObject.Parse(original)["Nodes"] is not JArray raw)
            return "source has no Nodes array";

        IFS ifs = IfsNodesSerializer.DeserializeJsonString(original, transforms, effects, true);
        var loaded = ifs.Iterators.Select(i => i.GetPosition()).ToList();

        int n = Math.Min(raw.Count, loaded.Count);
        for (int i = 0; i < n; i++)
        {
            if (raw[i] is not JObject p || loaded[i] is null)
                continue;
            double x = (double)p["X"], y = (double)p["Y"];
            if (Math.Abs(x - loaded[i].Value.X) > 1e-9 || Math.Abs(y - loaded[i].Value.Y) > 1e-9)
                return $"index {i}: file has ({x},{y}) but iterator {i} got {loaded[i]}";
        }
        return null;
    });

    // 5. Full file round-trip through disk.
    Check($"{name}: file round-trip on disk", () =>
    {
        IFS ifs = IfsNodesSerializer.LoadJsonFile(file, transforms, effects, true);
        string outPath = Path.Combine(tmp, name);
        IfsNodesSerializer.SaveJsonFile(ifs, outPath);

        string written = File.ReadAllText(outPath, Encoding.UTF8);
        string a = JObject.Parse(original)["Nodes"].ToString(Newtonsoft.Json.Formatting.None);
        string b = JObject.Parse(written)["Nodes"].ToString(Newtonsoft.Json.Formatting.None);
        if (a != b)
            return $"Nodes differ\n        was: {Trim(a)}\n        now: {Trim(b)}";

        // BOM preserved, as the WPF reader expects
        byte[] head = File.ReadAllBytes(outPath).Take(3).ToArray();
        if (head.Length < 3 || head[0] != 0xEF || head[1] != 0xBB || head[2] != 0xBF)
            return "output is missing the UTF-8 BOM";
        return null;
    });
}

// 6. A brand-new iterator with no position must not break saving.
Check("iterator without a position serialises as null", () =>
{
    IFS ifs = IfsNodesSerializer.LoadJsonFile(
        Path.Combine(templates, "nebula_sample.ifsjson"), transforms, effects, true);
    ifs.AddIterator(new Iterator(transforms[0]), true);
    string saved = IfsNodesSerializer.SerializeJsonString(ifs);
    if (JObject.Parse(saved)["Nodes"] is not JArray nodes)
        return "no Nodes array";
    if (nodes.Count != ifs.Iterators.Count)
        return $"Nodes has {nodes.Count} entries for {ifs.Iterators.Count} iterators";
    return null;
});

// 7. The shipped palette pack parses, and every palette is usable.
Check("SampleGradientPack.gradient parses into usable palettes", () =>
{
    string pack = Path.Combine(root, "WpfDisplay/Library/Palettes/SampleGradientPack.gradient");
    if (!File.Exists(pack))
        return "palette pack missing";

    var palettes = FlamePalette.FromFileAsync(pack).Result;
    if (palettes.Count == 0)
        return "no palettes parsed";

    foreach (FlamePalette pal in palettes)
    {
        if (pal.Colors.Count < 2)
            return $"'{pal.Name}' has {pal.Colors.Count} colours";
        // the UI samples the gradient through GetColorLerp across 0..1
        for (int i = 0; i < 16; i++)
        {
            var c = pal.GetColorLerp(i / 15f);
            if (float.IsNaN(c.X) || float.IsNaN(c.Y) || float.IsNaN(c.Z))
                return $"'{pal.Name}' produced NaN at t={i / 15f}";
        }
    }
    Console.WriteLine($"        ({palettes.Count} palettes, {palettes[0].Colors.Count} colours each)");
    return null;
});

// 8. Undo/redo restores parameters AND node positions.
Check("undo restores state including node positions", () =>
{
    IFS ifs = IfsNodesSerializer.LoadJsonFile(
        Path.Combine(templates, "nebula_sample.ifsjson"), transforms, effects, true);
    var tracker = new IFSHistoryTracker();

    double originalBrightness = ifs.Brightness;
    var originalFirst = ifs.Iterators.First().GetPosition();

    tracker.TakeSnapshot(ifs);
    ifs.Brightness = originalBrightness + 5;
    ifs.Iterators.First().SetPosition(new NodePosition(-999, -999));

    IFS restored = tracker.Undo(ifs, transforms, effects);
    if (Math.Abs(restored.Brightness - originalBrightness) > 1e-9)
        return $"brightness not restored: {restored.Brightness} vs {originalBrightness}";

    var restoredFirst = restored.Iterators.First().GetPosition();
    if (restoredFirst is null || originalFirst is null)
        return "positions missing after undo";
    if (Math.Abs(restoredFirst.Value.X - originalFirst.Value.X) > 1e-9)
        return $"node position not restored: {restoredFirst} vs {originalFirst}";
    return null;
});

// 9. Parameters survive a PNG export/import round-trip.
Check("PNG embeds and recovers parameters", () =>
{
    IFS ifs = IfsNodesSerializer.LoadJsonFile(
        Path.Combine(templates, "nebula_sample.ifsjson"), transforms, effects, true);

    const int w = 8, h = 4;
    byte[] bgra = new byte[w * h * 4];
    for (int i = 0; i < bgra.Length; i++)
        bgra[i] = (byte)(i % 256);

    string png = Path.Combine(tmp, "params-roundtrip.png");
    PngParams.SavePng(png, bgra, w, h, ifs);

    if (!PngParams.TryExtractParams(png, transforms, effects, out IFS recovered))
        return "no parameters found in the exported PNG";
    if (recovered.Iterators.Count != ifs.Iterators.Count)
        return $"iterator count {recovered.Iterators.Count} vs {ifs.Iterators.Count}";
    if (Math.Abs(recovered.Brightness - ifs.Brightness) > 1e-9)
        return "brightness differs after PNG round-trip";
    if (recovered.Iterators.First().GetPosition() is null)
        return "node positions lost in the PNG round-trip";
    return null;
});

// 10. Animation paths resolve for read (keying) and write (evaluation).
Check("animation paths round-trip through the engine", () =>
{
    IFS ifs = IfsNodesSerializer.LoadJsonFile(
        Path.Combine(templates, "nebula_sample.ifsjson"), transforms, effects, true);
    var iterator = ifs.Iterators.First();
    string realKey = iterator.RealParams.Keys.FirstOrDefault();

    var paths = new List<string> { "Brightness", "Camera.FieldOfView", "Camera.Position.X",
        $"Node[{iterator.Id}].BaseWeight", $"Node[{iterator.Id}].Opacity" };
    if (realKey is not null)
        paths.Add($"Node[{iterator.Id}].RealParams.[{realKey}]");

    foreach (string path in paths)
    {
        if (!ParamPath.TryGet(ifs, path, out double read))
            return $"could not read '{path}'";

        // Key it, move the playhead onto that key, and confirm the engine writes it back.
        double target = read + 1.25;
        ifs.Dopesheet.AddOrUpdateChannel(path, path, TimeOnly.FromTimeSpan(TimeSpan.FromSeconds(2)), target);
        ifs.EvaluateAt(TimeOnly.FromTimeSpan(TimeSpan.FromSeconds(2)));

        if (!ParamPath.TryGet(ifs, path, out double after))
            return $"could not re-read '{path}'";
        if (Math.Abs(after - target) > 1e-4)
            return $"'{path}' evaluated to {after}, expected {target}";
    }
    Console.WriteLine($"        ({paths.Count} paths keyed and evaluated)");
    return null;
});

Console.WriteLine();
Console.WriteLine(failed == 0 ? "ALL TESTS PASSED" : $"{failed} TEST(S) FAILED");
return failed == 0 ? 0 : 1;

void Check(string name, Func<string> test)
{
    string error;
    try { error = test(); }
    catch (Exception ex) { error = ex.GetType().Name + ": " + ex.Message; }

    if (error is null)
    {
        Console.WriteLine($"  ok    {name}");
    }
    else
    {
        Console.WriteLine($"  FAIL  {name}\n        {error}");
        failed++;
    }
}

static string Trim(string s) => s.Length <= 90 ? s : s[..90] + "…";

static string FindRepoRoot()
{
    var dir = new DirectoryInfo(AppContext.BaseDirectory);
    while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "IFSRenderer.slnx")))
        dir = dir.Parent;
    return dir?.FullName ?? Directory.GetCurrentDirectory();
}