OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / AvaloniaShell.Tests / Fakes / FakeRenderQueue.cs

174 lines · csharp ref:
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;

using IFSEngine.Model;

using IFSRenderer.AvaloniaShell;
using IFSRenderer.AvaloniaShell.Rendering;

namespace IFSRenderer.AvaloniaShell.Tests.Fakes;

/// <summary>
/// An <see cref="IRenderQueue"/> with no GPU: it parses the real plugin library from disk and
/// records engine calls, but draws nothing.
/// </summary>
/// <remarks>
/// <para>
/// <b>Actions are DEFERRED, exactly as the real service defers them.</b> This is the whole
/// point of the fake and it must not be "simplified" into running them inline. The bug this
/// harness exists to pin — node and post-effect edits appearing one action late — is an
/// ORDERING bug: the UI read the fractal before the queued mutation had run. A fake that
/// executed <see cref="Post"/> synchronously would make the broken code pass, because the
/// mutation would already have happened by the time the UI looked.
/// </para>
/// <para>
/// So a test drives the queue the way the render thread does: perform the gesture, then
/// <see cref="Drain"/>, then pump the dispatcher for anything the action posted back.
/// </para>
/// </remarks>
public sealed class FakeRenderQueue : IRenderQueue
{
    private readonly Queue<Action<IRenderer>> _pending = new();

    /// <summary>The engine stand-in handed to every queued action.</summary>
    public FakeRenderer Renderer { get; } = new();

    /// <summary>Actions queued but not yet run — non-zero means the UI is ahead of the engine.</summary>
    public int PendingCount => _pending.Count;

    /// <summary>Total actions ever queued, for asserting a gesture did any engine work at all.</summary>
    public int PostCount { get; private set; }

    public IReadOnlyList<TransformPlugin> Transforms { get; private set; } = [];
    public IReadOnlyList<EffectPlugin> Effects { get; private set; } = [];
    public IFS Current { get; private set; }

    public object FrameLock { get; } = new();
    public byte[] FrameBuffer { get; } = [];
    public int FrameWidth => 0;
    public int FrameHeight => 0;

    public event Action FrameReady;
    public event Action<Exception> Failed;

    public string GpuName => "(fake)";
    public Exception LastError { get; private set; }
    public ulong TotalIterations => 0;
    public int IterationLevel => 0;
    public double Fps => 0;
    public double IterationSpeed => 0;
    public int InvocationIters => 0;
    public int WorkgroupCount => Renderer.WorkgroupCount;

    /// <summary>Mirrors RenderService: resuming clears the recorded failure.</summary>
    public bool Paused
    {
        get => _paused;
        set
        {
            if (!value)
                LastError = null;
            _paused = value;
        }
    }

    private bool _paused;

    /// <summary>Loads the real plugin files. No GL context is created.</summary>
    public async Task StartAsync(string libraryDir, string transformsDir)
    {
        string includesDir = Path.Combine(libraryDir, "Includes");
        string postFxDir = Path.Combine(libraryDir, "PostEffects");

        var includeFiles = Directory.Exists(includesDir) ? Directory.GetFiles(includesDir) : [];
        var includeNames = includeFiles.Select(Path.GetFileName).ToList();

        var allTransforms = (await Task.WhenAll(Directory
            .GetFiles(transformsDir, "*.ifstf", SearchOption.AllDirectories)
            .Select(TransformPlugin.FromFile))).ToList();
        Transforms = allTransforms.Where(t => t.IncludeUses.All(u => includeNames.Contains(u))).ToList();

        var allEffects = Directory.Exists(postFxDir)
            ? (await Task.WhenAll(Directory.GetFiles(postFxDir, "*.ifsfx", SearchOption.AllDirectories)
                .Select(EffectPlugin.FromFile))).ToList()
            : [];
        Effects = allEffects.Where(t => t.IncludeUses.All(u => includeNames.Contains(u))).ToList();
    }

    public Task ReloadPluginsAsync(string libraryDir, string transformsDir) =>
        StartAsync(libraryDir, transformsDir);

    public void LoadFractal(IFS fractal, int viewportWidth, int viewportHeight)
    {
        Current = fractal;
        Post(r => r.LoadParams(fractal));
    }

    public void Post(Action<IRenderer> action)
    {
        PostCount++;
        _pending.Enqueue(action);
    }

    public Task PostAsync(Action<IRenderer> action)
    {
        Post(action);
        return Task.CompletedTask;
    }

    /// <summary>Runs every queued action, as the render thread's loop would.</summary>
    /// <returns>How many actions ran.</returns>
    public int Drain()
    {
        int ran = 0;
        // Actions may queue further actions; keep going until the queue is empty.
        while (_pending.Count > 0)
        {
            Action<IRenderer> action = _pending.Dequeue();
            try
            {
                action(Renderer);
            }
            catch (Exception ex)
            {
                LastError = ex;
                Paused = true;
                Failed?.Invoke(ex);
            }
            ran++;
        }
        return ran;
    }

    /// <summary>Discards queued actions without running them.</summary>
    public void Clear() => _pending.Clear();

    public void SetViewport(int width, int height, bool fillViewport = true) { }
    public Task SetWorkgroupCountAsync(int count) => PostAsync(r => r.SetWorkgroupCount(count));

    public Task ExportAsync(string path, int width, int height, int targetLevel,
        Action<int> onLevel = null, ExportMetadata metadata = null) => Task.CompletedTask;

    public Task ExportFramesAsync(string directory, int width, int height, int targetLevel,
        Action<int, int> onProgress, Func<bool> isCancelled, bool rawExr = false,
        Action<byte[], int, int> frameSink = null) => Task.CompletedTask;

    public Task<byte[]> RenderThumbnailAsync(IFS candidate, int width, int height, int level) =>
        Task.FromResult(Array.Empty<byte>());

    public void Dispose() { }

    /// <summary>Raises <see cref="Failed"/> as a dispatch failure would, for the stall test.</summary>
    public void SimulateRenderFailure(Exception ex)
    {
        LastError = ex;
        Paused = true;
        Failed?.Invoke(ex);
    }

    private void Unused() => FrameReady?.Invoke();
}