Skip to content

Complete TestAdapter provider call recording (docs/plans/phase-2-completion.md Phase 1) #14

@JohnLudlow

Description

@JohnLudlow

Overview

TestAdapter currently records only adapter lifecycle calls (InitializeAsync, ShutdownAsync). All provider properties — RenderProvider, InputProvider, AssetProvider, AudioPlayer — delegate directly to the inner HeadlessAdapter without recording.

This issue adds four internal recording decorator classes that intercept every provider method call, append a RecordedCall entry to TestAdapter's shared list, then delegate to the inner provider. It also adds a public Inner property so test code can access provider-specific typed collections (for example HeadlessRenderProvider.RecordedSprites).

Plan issue

Plan status

Not started

Definition of terms

Term Meaning
Recording decorator A class that wraps a provider interface, forwards all calls to an inner implementation, and appends a RecordedCall entry to a shared list for each call.
RecordedCall A record DTO with ProviderName, MethodName, and IReadOnlyList<object?> Arguments already defined in the codebase.

Architectural considerations and constraints

  • Shared list: All four decorators write to the same List<RecordedCall> owned by TestAdapter, allowing cross-provider sequence assertions.
  • Internal visibility: Decorator classes are internal sealed — implementation details not exposed as public API.
  • Additive, not replacement: HeadlessAudioPlayer and HeadlessRenderProvider already have their own typed recording collections. The TestAdapter layer provides a unified call log alongside those, not instead of them.
  • IUserInterfaceProvider is empty: No decorator is required until the interface is finalised; UserInterfaceProvider continues to delegate directly.
  • Inner property: Exposes the inner HeadlessAdapter so test code can access provider-specific typed recording collections where needed.
  • All existing TestAdapter tests must continue to pass without modification.

Implementation guide

Plan requirements

  • (Not started) TestAdapter records render provider calls

    • GIVEN a TestAdapter
    • WHEN RenderProvider.SubmitSprite(dto) is called
    • THEN RecordedCalls contains a RecordedCall("Render", "SubmitSprite", [dto]) entry
  • (Not started) TestAdapter records audio player calls

    • GIVEN a TestAdapter
    • WHEN AudioPlayer.StartPlayback("sfx", false) is called
    • THEN RecordedCalls contains a RecordedCall("Audio", "StartPlayback", ["sfx", false]) entry
  • (Not started) TestAdapter records asset provider calls

    • GIVEN a TestAdapter
    • WHEN AssetProvider.GetAsset("id") is called
    • THEN RecordedCalls contains a RecordedCall("Asset", "GetAsset", ["id"]) entry
  • (Not started) TestAdapter records input provider calls

    • GIVEN a TestAdapter
    • WHEN InputProvider.IsKeyDown("Space") is called
    • THEN RecordedCalls contains a RecordedCall("Input", "IsKeyDown", ["Space"]) entry
  • (Not started) Lifecycle calls remain in order alongside provider calls

    • GIVEN a TestAdapter
    • WHEN InitializeAsync, then RenderProvider.SubmitSprite, then ShutdownAsync are called in sequence
    • THEN RecordedCalls contains all three entries in that order
  • (Not started) Inner adapter is accessible for provider-level inspection

    • GIVEN a TestAdapter
    • WHEN adapter.Inner.RenderProvider is cast to HeadlessRenderProvider
    • THEN RecordedSprites reflects sprite submissions made via the recording decorator

Phase 1 — Create four decorator classes

Not started

Objective

Create RecordingRenderProvider, RecordingInputProvider, RecordingAssetProvider, and RecordingAudioPlayer in src/GameEngineAdapter.Headless/.

Each class: accepts an inner provider and a List<RecordedCall> via primary constructor; for each interface method, appends to the list then delegates to the inner.

Technical details

ProviderName values per decorator:

Decorator ProviderName
RecordingRenderProvider "Render"
RecordingInputProvider "Input"
RecordingAssetProvider "Asset"
RecordingAudioPlayer "Audio"
Adapter lifecycle (existing) "Adapter"

Phase requirements

  • (Not started) All four decorator classes compile
    • GIVEN the new source files
    • WHEN dotnet build is run
    • THEN the solution builds without errors

Examples

// src/GameEngineAdapter.Headless/RecordingRenderProvider.cs

internal sealed class RecordingRenderProvider(
    IRenderProvider inner,
    List<RecordedCall> calls) : IRenderProvider
{
    public FrameScope BeginFrame(in CameraDescriptor camera)
    {
        calls.Add(new RecordedCall("Render", "BeginFrame", [camera]));
        return inner.BeginFrame(in camera);
    }

    public void SubmitSprite(in SpriteDrawDto dto)
    {
        calls.Add(new RecordedCall("Render", "SubmitSprite", [dto]));
        inner.SubmitSprite(in dto);
    }

    public void SubmitText(in TextDrawDto dto)
    {
        calls.Add(new RecordedCall("Render", "SubmitText", [dto]));
        inner.SubmitText(in dto);
    }

    public void SubmitMesh(in MeshDrawDto dto)
    {
        calls.Add(new RecordedCall("Render", "SubmitMesh", [dto]));
        inner.SubmitMesh(in dto);
    }

    public void EndFrame()
    {
        calls.Add(new RecordedCall("Render", "EndFrame", []));
        inner.EndFrame();
    }

    public void Present()
    {
        calls.Add(new RecordedCall("Render", "Present", []));
        inner.Present();
    }
}
// src/GameEngineAdapter.Headless/RecordingAudioPlayer.cs

internal sealed class RecordingAudioPlayer(
    IAudioPlayer inner,
    List<RecordedCall> calls) : IAudioPlayer
{
    public void StartPlayback(string audioAssetId, bool loopPlayback = false)
    {
        calls.Add(new RecordedCall("Audio", "StartPlayback", [audioAssetId, loopPlayback]));
        inner.StartPlayback(audioAssetId, loopPlayback);
    }

    public void StopPlayback(string audioAssetId)
    {
        calls.Add(new RecordedCall("Audio", "StopPlayback", [audioAssetId]));
        inner.StopPlayback(audioAssetId);
    }

    public void SetVolume(string audioAssetId, float volume)
    {
        calls.Add(new RecordedCall("Audio", "SetVolume", [audioAssetId, volume]));
        inner.SetVolume(audioAssetId, volume);
    }
}

Phase 2 — Update TestAdapter to use decorators

Not started

Objective

Wire the four decorators into TestAdapter: add private fields, construct them in the constructor, override provider properties to return decorators, add the Inner property.

Technical details

Changes to TestAdapter.cs:

  • Add private readonly RecordingRenderProvider _renderProvider; (and three equivalent fields).
  • Add public HeadlessAdapter Inner => _inner;.
  • In the constructor, initialise each decorator: _renderProvider = new RecordingRenderProvider(_inner.RenderProvider, _recordedCalls);
  • Change each provider property to return the decorator: public IRenderProvider RenderProvider => _renderProvider;

Examples

// src/GameEngineAdapter.Headless/TestAdapter.cs (updated)

public sealed class TestAdapter : IEngineAdapter
{
    private readonly HeadlessAdapter _inner;
    private readonly List<RecordedCall> _recordedCalls = [];
    private readonly RecordingRenderProvider _renderProvider;
    private readonly RecordingInputProvider _inputProvider;
    private readonly RecordingAssetProvider _assetProvider;
    private readonly RecordingAudioPlayer _audioPlayer;

    public IReadOnlyList<RecordedCall> RecordedCalls => _recordedCalls;
    public HeadlessAdapter Inner => _inner;

    public EngineCapabilities Capabilities => _inner.Capabilities;
    public IRenderProvider RenderProvider => _renderProvider;
    public IInputProvider InputProvider => _inputProvider;
    public IUserInterfaceProvider UserInterfaceProvider => _inner.UserInterfaceProvider;
    public IAssetProvider AssetProvider => _assetProvider;
    public IAudioPlayer AudioPlayer => _audioPlayer;

    public TestAdapter(EngineConfig config)
    {
        _inner = new HeadlessAdapter(config);
        _renderProvider = new RecordingRenderProvider(_inner.RenderProvider, _recordedCalls);
        _inputProvider = new RecordingInputProvider(_inner.InputProvider, _recordedCalls);
        _assetProvider = new RecordingAssetProvider(_inner.AssetProvider, _recordedCalls);
        _audioPlayer = new RecordingAudioPlayer(_inner.AudioPlayer, _recordedCalls);
    }

    public Task InitializeAsync(EngineConfig config, CancellationToken ct = default)
    {
        _recordedCalls.Add(new RecordedCall("Adapter", "InitializeAsync", [config]));
        return _inner.InitializeAsync(config, ct);
    }

    public Task ShutdownAsync(CancellationToken ct = default)
    {
        _recordedCalls.Add(new RecordedCall("Adapter", "ShutdownAsync", []));
        return _inner.ShutdownAsync(ct);
    }

    public void Dispose() => _inner.Dispose();
}

Testing and compatibility

Unit tests required per decorator:

  • Correct ProviderName and MethodName in recorded entries.
  • Arguments captured accurately (for both in value-type parameters and reference parameters).
  • Delegation verified — inner method is called; return values forwarded for read methods.
  • All existing TestAdapter tests continue to pass without modification.

Performance: Decorators allocate one RecordedCall per call (argument array via collection expression [...]). This is acceptable in test contexts. No additional allocations beyond the RecordedCall itself.

See also

Metadata

Metadata

Assignees

No one assigned

    Labels

    area-libItems related to reusable librariesarea-technicalsystemsInternal systems to do with game developmentenhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions