// 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(); 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(); Listener->AddToRoot(); UDirectiveUtilTask_AsyncLoadAsset* Task = UDirectiveUtilTask_AsyncLoadAsset::AsyncLoadAsset(nullptr, TSoftObjectPtr()); 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(); Listener->AddToRoot(); const TSoftObjectPtr 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(); Listener->AddToRoot(); UDirectiveUtilTask_AsyncLoadAssets* Task = UDirectiveUtilTask_AsyncLoadAssets::AsyncLoadAssets(nullptr, TArray>()); 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(); Listener->AddToRoot(); TArray> Assets; Assets.Add(TSoftObjectPtr(FSoftObjectPath(TEXT("/Engine/BasicShapes/Cube.Cube")))); Assets.Add(TSoftObjectPtr(FSoftObjectPath(TEXT("/Engine/BasicShapes/Sphere.Sphere")))); Assets.Add(TSoftObjectPtr()); 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(); Listener->AddToRoot(); TArray> Assets; Assets.Add(TSoftObjectPtr(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(); Listener->AddToRoot(); UDirectiveUtilTask_AsyncLoadClass* Task = UDirectiveUtilTask_AsyncLoadClass::AsyncLoadClass(nullptr, TSoftClassPtr()); 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(); Listener->AddToRoot(); const TSoftClassPtr 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