Files
ProjectEleri/Plugins/DirectiveUtilities/Source/DirectiveUtilitiesTests/Private/Tests/DirectiveUtilAsyncTaskTest.cpp
2026-07-15 19:17:47 +02:00

412 lines
16 KiB
C++

// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Tasks/DirectiveUtilTask_Delay.h"
#include "Tasks/DirectiveUtilTask_AsyncLoadAsset.h"
#include "Tests/DirectiveUtilTestObject.h"
#include "Engine/World.h"
#include "Engine/Engine.h"
#include "Engine/StaticMesh.h"
#include "Engine/StaticMeshActor.h"
#include "TimerManager.h"
#include "UObject/SoftObjectPath.h"
#include "UObject/SoftObjectPtr.h"
#include "Misc/AutomationTest.h"
#if WITH_EDITOR
namespace DirectiveUtilAsyncTaskTestHelpers
{
/**
* Spawns a transient world, starts a cancellable delay in it, and returns a rooted listener bound
* to the delay's Completed delegate. The world is stored on the listener so the latent command can
* tick its timer manager across frames and tear it down once the scenario settles.
*
* A timer set on a never-ticked FTimerManager is queued as pending and only promoted to active on
* the first tick; it fires on a later tick. Because a manager can be ticked at most once per frame,
* driving the timer to completion requires advancing real frames, hence the latent command below.
*/
UDirectiveUtilDelegateListener* StartDelayScenario(float Duration, bool bCancel)
{
UWorld* World = UWorld::CreateWorld(EWorldType::Editor, false);
if (!World)
{
return nullptr;
}
FWorldContext& WorldContext = GEngine->CreateNewWorldContext(EWorldType::Editor);
WorldContext.SetCurrentWorld(World);
UDirectiveUtilDelegateListener* Listener = NewObject<UDirectiveUtilDelegateListener>();
Listener->AddToRoot();
Listener->ScenarioWorld = World;
UDirectiveUtilTask_Delay* Task = UDirectiveUtilTask_Delay::CancellableDelay(World, Duration);
Listener->Keepalive = Task;
Task->Completed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnCompleted);
Task->Activate();
if (bCancel)
{
Task->EndTask();
}
return Listener;
}
}
/**
* Latent command that ticks a delay scenario's world each frame until the delay completes or the
* frame budget runs out, then asserts against the expected outcome and tears the world down.
*/
DEFINE_LATENT_AUTOMATION_COMMAND_FOUR_PARAMETER(FDirectiveUtilTickDelayScenario, FAutomationTestBase*, Test, UDirectiveUtilDelegateListener*, Listener, int32, FramesRemaining, bool, bExpectComplete);
bool FDirectiveUtilTickDelayScenario::Update()
{
if (!Listener)
{
return true;
}
UWorld* World = Listener->ScenarioWorld.Get();
if (World)
{
World->GetTimerManager().Tick(0.1f);
}
const bool bBudgetExhausted = (--FramesRemaining <= 0);
if (Listener->bCompleted || bBudgetExhausted)
{
if (bExpectComplete)
{
Test->TestTrue(TEXT("Cancellable delay broadcasts Completed once its timer elapses"), Listener->bCompleted);
Test->TestEqual(TEXT("Cancellable delay broadcasts Completed exactly once"), Listener->CompletedCount, 1);
}
else
{
Test->TestFalse(TEXT("EndTask cancels the delay so Completed never fires"), Listener->bCompleted);
}
if (World)
{
GEngine->DestroyWorldContext(World);
World->DestroyWorld(false);
}
Listener->ScenarioWorld = nullptr;
Listener->Keepalive = nullptr;
Listener->RemoveFromRoot();
return true;
}
return false;
}
/**
* DirectiveUtilTask_Delay: verifies the timer-backed completion fires, that EndTask cancels it before it fires,
* and that activating with a null world context is guarded rather than crashing.
*/
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilDelayTaskTest, "DirectiveUtilities.AsyncTaskDelayTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilDelayTaskTest::RunTest(const FString& Parameters)
{
// The null-world activation intentionally logs a warning.
AddExpectedMessagePlain(TEXT("Cancellable Delay failed to activate. World is null."), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1);
// Completion path: the delay fires once its timer elapses (driven across frames by the latent command).
if (UDirectiveUtilDelegateListener* Completed = DirectiveUtilAsyncTaskTestHelpers::StartDelayScenario(0.05f, /*bCancel=*/false))
{
ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilTickDelayScenario(this, Completed, 600, /*bExpectComplete=*/true));
}
else
{
AddError(TEXT("Failed to create a transient world for the delay completion scenario."));
}
// Cancellation path: EndTask clears the timer so Completed never fires across a short window.
if (UDirectiveUtilDelegateListener* Cancelled = DirectiveUtilAsyncTaskTestHelpers::StartDelayScenario(0.05f, /*bCancel=*/true))
{
ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilTickDelayScenario(this, Cancelled, 10, /*bExpectComplete=*/false));
}
else
{
AddError(TEXT("Failed to create a transient world for the delay cancellation scenario."));
}
// Null world guard: activating with a null context object logs a warning and does not crash.
UDirectiveUtilTask_Delay* NullWorldTask = UDirectiveUtilTask_Delay::CancellableDelay(nullptr, 0.05f);
if (TestNotNull("CancellableDelay returns a task even with a null context", NullWorldTask))
{
NullWorldTask->AddToRoot();
NullWorldTask->Activate();
NullWorldTask->RemoveFromRoot();
}
return true;
}
/** Latent command that waits for an async-load listener to settle (completed or failed) or times out. */
DEFINE_LATENT_AUTOMATION_COMMAND_THREE_PARAMETER(FDirectiveUtilWaitForAsyncLoad, FAutomationTestBase*, Test, UDirectiveUtilDelegateListener*, Listener, int32, FramesRemaining);
bool FDirectiveUtilWaitForAsyncLoad::Update()
{
if (!Listener)
{
return true;
}
if (Listener->bFailed)
{
Test->AddError(TEXT("Async Load Asset reported failure while loading a valid engine asset."));
Listener->RemoveFromRoot();
Listener->Keepalive = nullptr;
return true;
}
if (Listener->bCompleted)
{
Test->TestNotNull(TEXT("Async Load Asset resolved the requested asset"), Listener->LastObject.Get());
Listener->RemoveFromRoot();
Listener->Keepalive = nullptr;
return true;
}
if (--FramesRemaining <= 0)
{
Test->AddError(TEXT("Async Load Asset did not complete within the frame budget."));
Listener->RemoveFromRoot();
Listener->Keepalive = nullptr;
return true;
}
return false;
}
/**
* DirectiveUtilTask_AsyncLoadAsset: verifies the null soft-reference path broadcasts Failed synchronously, and
* that loading a real engine asset eventually broadcasts Completed with the resolved object.
*/
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilAsyncLoadAssetTest, "DirectiveUtilities.AsyncTaskLoadAssetTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilAsyncLoadAssetTest::RunTest(const FString& Parameters)
{
// The null soft-reference path intentionally logs a warning.
AddExpectedMessagePlain(TEXT("Async Load Asset failed to activate. The soft object reference is null."), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1);
// Failure path: a null soft reference broadcasts Failed synchronously on activation.
{
UDirectiveUtilDelegateListener* Listener = NewObject<UDirectiveUtilDelegateListener>();
Listener->AddToRoot();
UDirectiveUtilTask_AsyncLoadAsset* Task = UDirectiveUtilTask_AsyncLoadAsset::AsyncLoadAsset(nullptr, TSoftObjectPtr<UObject>());
Listener->Keepalive = Task;
Task->Completed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnObjectCompleted);
Task->Failed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnObjectFailed);
Task->Activate();
TestTrue("Null soft reference broadcasts Failed", Listener->bFailed);
TestFalse("Null soft reference does not broadcast Completed", Listener->bCompleted);
Listener->RemoveFromRoot();
Listener->Keepalive = nullptr;
}
// Success path: loading a real engine asset broadcasts Completed with the resolved object.
{
UDirectiveUtilDelegateListener* Listener = NewObject<UDirectiveUtilDelegateListener>();
Listener->AddToRoot();
const TSoftObjectPtr<UObject> SoftCube(FSoftObjectPath(TEXT("/Engine/BasicShapes/Cube.Cube")));
UDirectiveUtilTask_AsyncLoadAsset* Task = UDirectiveUtilTask_AsyncLoadAsset::AsyncLoadAsset(nullptr, SoftCube);
Listener->Keepalive = Task;
Task->Completed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnObjectCompleted);
Task->Failed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnObjectFailed);
Task->Activate();
// The streamable completion delegate fires on a later engine tick; poll until it settles.
ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilWaitForAsyncLoad(this, Listener, 600));
}
return true;
}
/** Latent command that waits for a batch async-load listener to complete, then asserts the slot contract. */
DEFINE_LATENT_AUTOMATION_COMMAND_THREE_PARAMETER(FDirectiveUtilWaitForBatchAsyncLoad, FAutomationTestBase*, Test, UDirectiveUtilDelegateListener*, Listener, int32, FramesRemaining);
bool FDirectiveUtilWaitForBatchAsyncLoad::Update()
{
if (!Listener)
{
return true;
}
if (Listener->bCompleted)
{
Test->TestEqual(TEXT("Async Load Assets broadcasts Completed exactly once"), Listener->CompletedCount, 1);
Test->TestEqual(TEXT("Async Load Assets preserves the input slot count"), Listener->LastObjects.Num(), 3);
if (Listener->LastObjects.Num() == 3)
{
Test->TestNotNull(TEXT("Async Load Assets resolves the first asset"), Listener->LastObjects[0].Get());
Test->TestNotNull(TEXT("Async Load Assets resolves the second asset"), Listener->LastObjects[1].Get());
Test->TestNull(TEXT("Async Load Assets keeps a null slot for an unset reference"), Listener->LastObjects[2].Get());
}
Listener->RemoveFromRoot();
Listener->Keepalive = nullptr;
return true;
}
if (--FramesRemaining <= 0)
{
Test->AddError(TEXT("Async Load Assets did not complete within the frame budget."));
Listener->RemoveFromRoot();
Listener->Keepalive = nullptr;
return true;
}
return false;
}
/** Latent command that waits a fixed frame window and then asserts a cancelled batch load never completed. */
DEFINE_LATENT_AUTOMATION_COMMAND_THREE_PARAMETER(FDirectiveUtilVerifyCancelledBatchLoad, FAutomationTestBase*, Test, UDirectiveUtilDelegateListener*, Listener, int32, FramesRemaining);
bool FDirectiveUtilVerifyCancelledBatchLoad::Update()
{
if (!Listener)
{
return true;
}
if (--FramesRemaining > 0)
{
return false;
}
Test->TestFalse(TEXT("Cancel prevents the batch Completed broadcast"), Listener->bCompleted);
Listener->RemoveFromRoot();
Listener->Keepalive = nullptr;
return true;
}
/**
* DirectiveUtilTask_AsyncLoadAssets: verifies a batch resolves in input order with null slots for unset references,
* that an empty input completes immediately with an empty array, and that Cancel suppresses Completed.
*/
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilAsyncLoadAssetsTest, "DirectiveUtilities.AsyncTaskLoadAssetsTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilAsyncLoadAssetsTest::RunTest(const FString& Parameters)
{
// Empty input: Completed broadcasts synchronously on activation with an empty array.
{
UDirectiveUtilDelegateListener* Listener = NewObject<UDirectiveUtilDelegateListener>();
Listener->AddToRoot();
UDirectiveUtilTask_AsyncLoadAssets* Task = UDirectiveUtilTask_AsyncLoadAssets::AsyncLoadAssets(nullptr, TArray<TSoftObjectPtr<UObject>>());
Listener->Keepalive = Task;
Task->Completed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnObjectsCompleted);
Task->Activate();
TestTrue("Empty batch broadcasts Completed immediately", Listener->bCompleted);
TestEqual("Empty batch broadcasts Completed exactly once", Listener->CompletedCount, 1);
TestEqual("Empty batch reports an empty array", Listener->LastObjects.Num(), 0);
Listener->Keepalive = nullptr;
Listener->RemoveFromRoot();
}
// Batch path: two valid engine meshes plus an unset reference resolve in input order.
{
UDirectiveUtilDelegateListener* Listener = NewObject<UDirectiveUtilDelegateListener>();
Listener->AddToRoot();
TArray<TSoftObjectPtr<UObject>> Assets;
Assets.Add(TSoftObjectPtr<UObject>(FSoftObjectPath(TEXT("/Engine/BasicShapes/Cube.Cube"))));
Assets.Add(TSoftObjectPtr<UObject>(FSoftObjectPath(TEXT("/Engine/BasicShapes/Sphere.Sphere"))));
Assets.Add(TSoftObjectPtr<UObject>());
UDirectiveUtilTask_AsyncLoadAssets* Task = UDirectiveUtilTask_AsyncLoadAssets::AsyncLoadAssets(nullptr, Assets);
Listener->Keepalive = Task;
Task->Completed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnObjectsCompleted);
Task->Activate();
// The streamable completion delegate fires on a later engine tick; poll until it settles.
ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilWaitForBatchAsyncLoad(this, Listener, 600));
}
// Cancel path: cancelling before completion suppresses the Completed broadcast. Uses a mesh no
// other test loads so the request is genuinely in flight when Cancel arrives; if the asset is
// already in memory the batch completes synchronously and there is nothing left to cancel.
{
UDirectiveUtilDelegateListener* Listener = NewObject<UDirectiveUtilDelegateListener>();
Listener->AddToRoot();
TArray<TSoftObjectPtr<UObject>> Assets;
Assets.Add(TSoftObjectPtr<UObject>(FSoftObjectPath(TEXT("/Engine/EngineMeshes/SM_MatPreviewMesh_01.SM_MatPreviewMesh_01"))));
UDirectiveUtilTask_AsyncLoadAssets* Task = UDirectiveUtilTask_AsyncLoadAssets::AsyncLoadAssets(nullptr, Assets);
Listener->Keepalive = Task;
Task->Completed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnObjectsCompleted);
Task->Activate();
if (Listener->bCompleted)
{
AddInfo(TEXT("Batch load completed synchronously (asset already in memory); skipping the cancel scenario."));
Listener->Keepalive = nullptr;
Listener->RemoveFromRoot();
}
else
{
Task->Cancel();
ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilVerifyCancelledBatchLoad(this, Listener, 10));
}
}
return true;
}
/**
* DirectiveUtilTask_AsyncLoadClass: verifies the null soft-class path broadcasts Failed synchronously, and that
* loading a real class broadcasts Completed with the resolved class.
*/
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilAsyncLoadClassTest, "DirectiveUtilities.AsyncTaskLoadClassTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilAsyncLoadClassTest::RunTest(const FString& Parameters)
{
// The null soft-class path intentionally logs a warning.
AddExpectedMessagePlain(TEXT("Async Load Class failed to activate. The soft class reference is null."), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1);
// Failure path: a null soft class reference broadcasts Failed synchronously on activation.
{
UDirectiveUtilDelegateListener* Listener = NewObject<UDirectiveUtilDelegateListener>();
Listener->AddToRoot();
UDirectiveUtilTask_AsyncLoadClass* Task = UDirectiveUtilTask_AsyncLoadClass::AsyncLoadClass(nullptr, TSoftClassPtr<UObject>());
Listener->Keepalive = Task;
Task->Completed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnClassCompleted);
Task->Failed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnClassFailed);
Task->Activate();
TestTrue("Null soft class broadcasts Failed", Listener->bFailed);
TestFalse("Null soft class does not broadcast Completed", Listener->bCompleted);
Listener->RemoveFromRoot();
Listener->Keepalive = nullptr;
}
// Success path: loading a real class broadcasts Completed with the resolved class.
{
UDirectiveUtilDelegateListener* Listener = NewObject<UDirectiveUtilDelegateListener>();
Listener->AddToRoot();
const TSoftClassPtr<UObject> SoftClass(AStaticMeshActor::StaticClass());
UDirectiveUtilTask_AsyncLoadClass* Task = UDirectiveUtilTask_AsyncLoadClass::AsyncLoadClass(nullptr, SoftClass);
Listener->Keepalive = Task;
Task->Completed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnClassCompleted);
Task->Failed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnClassFailed);
Task->Activate();
// The streamable completion delegate fires on a later engine tick; poll until it settles.
ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilWaitForAsyncLoad(this, Listener, 600));
}
return true;
}
#endif // WITH_EDITOR