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();
}