Update directive utilities plugin

This commit is contained in:
2026-08-15 21:06:43 +02:00
parent 2fe7f83a38
commit a06fed1cba
103 changed files with 18211 additions and 1071 deletions

View File

@@ -0,0 +1,320 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilArrayFunctionLibrary.h"
#include "Tests/DirectiveUtilTestObject.h"
#include "EdGraph/EdGraph.h"
#include "EdGraph/EdGraphPin.h"
#include "EdGraphSchema_K2.h"
#include "Engine/Blueprint.h"
#include "K2Node_CallArrayFunction.h"
#include "K2Node_Event.h"
#include "K2Node_VariableGet.h"
#include "K2Node_VariableSet.h"
#include "Kismet2/BlueprintEditorUtils.h"
#include "Kismet2/KismetEditorUtilities.h"
#include "Misc/AutomationTest.h"
#include "UObject/Package.h"
namespace DirectiveUtilArrayBlueprintVmTest
{
template <typename NodeType>
NodeType* AddNode(UEdGraph& Graph)
{
NodeType* Node = NewObject<NodeType>(&Graph);
Graph.AddNode(Node);
Node->CreateNewGuid();
Node->PostPlacedNewNode();
return Node;
}
UK2Node_VariableGet* AddVariableGet(UEdGraph& Graph, const FName PropertyName)
{
UK2Node_VariableGet* Node = AddNode<UK2Node_VariableGet>(Graph);
Node->VariableReference.SetSelfMember(PropertyName);
Node->AllocateDefaultPins();
return Node;
}
UK2Node_VariableSet* AddVariableSet(UEdGraph& Graph, const FName PropertyName)
{
UK2Node_VariableSet* Node = AddNode<UK2Node_VariableSet>(Graph);
Node->VariableReference.SetSelfMember(PropertyName);
Node->AllocateDefaultPins();
return Node;
}
UK2Node_CallArrayFunction* AddArrayCall(UEdGraph& Graph, const FName FunctionName)
{
UK2Node_CallArrayFunction* Node = AddNode<UK2Node_CallArrayFunction>(Graph);
Node->FunctionReference.SetExternalMember(FunctionName, UDirectiveUtilArrayFunctionLibrary::StaticClass());
Node->AllocateDefaultPins();
return Node;
}
bool Connect(UEdGraph& Graph, UEdGraphNode& FromNode, const FName FromPinName,
UEdGraphNode& ToNode, const FName ToPinName)
{
UEdGraphPin* FromPin = FromNode.FindPin(FromPinName);
UEdGraphPin* ToPin = ToNode.FindPin(ToPinName);
const UEdGraphSchema_K2* Schema = CastChecked<UEdGraphSchema_K2>(Graph.GetSchema());
return FromPin && ToPin && Schema->TryCreateConnection(FromPin, ToPin);
}
bool ConnectVariable(UEdGraph& Graph, const FName PropertyName,
UEdGraphNode& ToNode, const FName ToPinName)
{
UK2Node_VariableGet* VariableGet = AddVariableGet(Graph, PropertyName);
return Connect(Graph, *VariableGet, PropertyName, ToNode, ToPinName);
}
UBlueprint* BuildScenarioBlueprint(FAutomationTestBase& Test, const FName TargetProperty,
const FName SourceProperty, const FName ItemProperty)
{
const FName BlueprintName(*FString::Printf(
TEXT("BP_ArrayThunkVm_%s_%s"),
*TargetProperty.ToString(),
*FGuid::NewGuid().ToString(EGuidFormats::Digits)));
UPackage* Package = CreatePackage(
*FString::Printf(TEXT("/Temp/DirectiveUtilitiesTests/%s"), *BlueprintName.ToString()));
Package->SetFlags(RF_Transient);
UBlueprint* Blueprint = FKismetEditorUtilities::CreateBlueprint(
UDirectiveUtilTestObject::StaticClass(),
Package,
BlueprintName,
BPTYPE_Normal,
TEXT("DirectiveUtilities.ArrayBlueprintVmTests"));
if (!Test.TestNotNull(TEXT("The array VM test Blueprint should be created"), Blueprint))
{
return nullptr;
}
UEdGraph* Graph = FBlueprintEditorUtils::FindEventGraph(Blueprint);
if (!Test.TestNotNull(TEXT("The array VM test Blueprint should have an event graph"), Graph))
{
return nullptr;
}
UK2Node_Event* Event = AddNode<UK2Node_Event>(*Graph);
Event->EventReference.SetExternalMember(
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilTestObject, RunArrayThunkScenario),
UDirectiveUtilTestObject::StaticClass());
Event->bOverrideFunction = true;
Event->AllocateDefaultPins();
UK2Node_CallArrayFunction* Append = AddArrayCall(
*Graph,
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_AppendOptimized));
UK2Node_CallArrayFunction* Insert = AddArrayCall(
*Graph,
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_InsertOptimized));
UK2Node_VariableSet* SetInsertResult = AddVariableSet(*Graph, TEXT("TestInsertResult"));
UK2Node_CallArrayFunction* RemoveIndices = AddArrayCall(
*Graph,
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_RemoveAtIndices));
UK2Node_VariableSet* SetRemovedCount = AddVariableSet(*Graph, TEXT("TestRemovedCount"));
UK2Node_CallArrayFunction* RemoveAll = AddArrayCall(
*Graph,
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_RemoveAllOccurrences));
UK2Node_VariableSet* SetRemoveAllResult = AddVariableSet(*Graph, TEXT("TestRemoveAllResult"));
bool bConnected = true;
bConnected &= Connect(*Graph, *Event, UEdGraphSchema_K2::PN_Then, *Append, UEdGraphSchema_K2::PN_Execute);
bConnected &= Connect(*Graph, *Append, UEdGraphSchema_K2::PN_Then, *Insert, UEdGraphSchema_K2::PN_Execute);
bConnected &= Connect(*Graph, *Insert, UEdGraphSchema_K2::PN_Then, *SetInsertResult, UEdGraphSchema_K2::PN_Execute);
bConnected &= Connect(*Graph, *Insert, UEdGraphSchema_K2::PN_ReturnValue, *SetInsertResult, TEXT("TestInsertResult"));
bConnected &= Connect(*Graph, *SetInsertResult, UEdGraphSchema_K2::PN_Then, *RemoveIndices, UEdGraphSchema_K2::PN_Execute);
bConnected &= Connect(*Graph, *RemoveIndices, UEdGraphSchema_K2::PN_Then, *SetRemovedCount, UEdGraphSchema_K2::PN_Execute);
bConnected &= Connect(*Graph, *RemoveIndices, UEdGraphSchema_K2::PN_ReturnValue, *SetRemovedCount, TEXT("TestRemovedCount"));
bConnected &= Connect(*Graph, *SetRemovedCount, UEdGraphSchema_K2::PN_Then, *RemoveAll, UEdGraphSchema_K2::PN_Execute);
bConnected &= Connect(*Graph, *RemoveAll, UEdGraphSchema_K2::PN_Then, *SetRemoveAllResult, UEdGraphSchema_K2::PN_Execute);
bConnected &= Connect(*Graph, *RemoveAll, UEdGraphSchema_K2::PN_ReturnValue, *SetRemoveAllResult, TEXT("TestRemoveAllResult"));
for (UK2Node_CallArrayFunction* Call : {Append, Insert, RemoveIndices, RemoveAll})
{
bConnected &= ConnectVariable(*Graph, TargetProperty, *Call, TEXT("TargetArray"));
}
bConnected &= ConnectVariable(*Graph, SourceProperty, *Append, TEXT("SourceArray"));
bConnected &= ConnectVariable(*Graph, SourceProperty, *Insert, TEXT("SourceArray"));
bConnected &= ConnectVariable(*Graph, TEXT("TestIndices"), *RemoveIndices, TEXT("Indices"));
bConnected &= ConnectVariable(*Graph, ItemProperty, *RemoveAll, TEXT("Item"));
Insert->FindPinChecked(TEXT("Index"))->DefaultValue = TEXT("1");
if (!Test.TestTrue(TEXT("The array VM test graph should connect every pin"), bConnected))
{
return nullptr;
}
FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(Blueprint);
FKismetEditorUtilities::CompileBlueprint(Blueprint, EBlueprintCompileOptions::SkipGarbageCollection);
if (!Test.TestTrue(
TEXT("The array VM test Blueprint should compile"),
Blueprint->Status == BS_UpToDate || Blueprint->Status == BS_UpToDateWithWarnings))
{
return nullptr;
}
return Blueprint;
}
UDirectiveUtilTestObject* CreateScenarioInstance(FAutomationTestBase& Test, UBlueprint& Blueprint)
{
UDirectiveUtilTestObject* Instance = NewObject<UDirectiveUtilTestObject>(
GetTransientPackage(), Blueprint.GeneratedClass);
if (!Test.TestNotNull(TEXT("The compiled array VM test instance should be created"), Instance))
{
return nullptr;
}
Instance->TestIndices = {0, 4};
return Instance;
}
bool RunScenario(
FAutomationTestBase& Test,
UDirectiveUtilTestObject& Instance,
const bool bExpectedInsertResult = true,
const int32 ExpectedRemovedCount = 2,
const bool bExpectedRemoveAllResult = true)
{
UFunction* Function = Instance.FindFunction(
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilTestObject, RunArrayThunkScenario));
if (!Test.TestNotNull(TEXT("The compiled array VM event should exist"), Function))
{
return false;
}
Instance.ProcessEvent(Function, nullptr);
Test.TestEqual(
TEXT("Insert Array Optimized should marshal its Blueprint result"),
Instance.TestInsertResult,
bExpectedInsertResult);
Test.TestEqual(
TEXT("Remove At Indices should marshal its Blueprint result"),
Instance.TestRemovedCount,
ExpectedRemovedCount);
Test.TestEqual(
TEXT("Remove All Occurrences should marshal its Blueprint result"),
Instance.TestRemoveAllResult,
bExpectedRemoveAllResult);
return true;
}
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilArrayBlueprintVmTest,
"DirectiveUtilities.ArrayBlueprintVmTests",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilArrayBlueprintVmTest::RunTest(const FString& Parameters)
{
using namespace DirectiveUtilArrayBlueprintVmTest;
if (UBlueprint* Blueprint = BuildScenarioBlueprint(
*this, TEXT("TestBoolArray"), TEXT("TestBoolSourceArray"), TEXT("TestBoolItem")))
{
if (UDirectiveUtilTestObject* Instance = CreateScenarioInstance(*this, *Blueprint))
{
Instance->TestBoolArray = {true, false, true};
Instance->TestBoolSourceArray = {false, true};
Instance->TestBoolItem = false;
if (RunScenario(*this, *Instance))
{
TestEqual(TEXT("Boolean wildcard values should survive Blueprint VM execution"),
Instance->TestBoolArray, TArray<bool>({true, true}));
}
}
}
if (UBlueprint* Blueprint = BuildScenarioBlueprint(
*this, TEXT("TestBoolArray"), TEXT("TestBoolArray"), TEXT("TestBoolItem")))
{
if (UDirectiveUtilTestObject* Instance = CreateScenarioInstance(*this, *Blueprint))
{
Instance->TestBoolArray = {true, false};
Instance->TestBoolItem = true;
Instance->TestIndices = {-1, 99};
if (RunScenario(*this, *Instance, true, 0, true))
{
TestEqual(
TEXT("Self-aliasing wildcard arrays should survive Blueprint VM execution"),
Instance->TestBoolArray,
TArray<bool>({false, false, false, false}));
}
}
}
if (UBlueprint* Blueprint = BuildScenarioBlueprint(
*this, TEXT("TestBoolArray"), TEXT("TestBoolSourceArray"), TEXT("TestBoolItem")))
{
if (UDirectiveUtilTestObject* Instance = CreateScenarioInstance(*this, *Blueprint))
{
Instance->TestBoolArray = {true};
Instance->TestBoolSourceArray.Reset();
Instance->TestBoolItem = false;
Instance->TestIndices = {-1, 99};
if (RunScenario(*this, *Instance, false, 0, false))
{
TestEqual(
TEXT("No-op wildcard calls should preserve the target through Blueprint VM execution"),
Instance->TestBoolArray,
TArray<bool>({true}));
}
}
}
if (UBlueprint* Blueprint = BuildScenarioBlueprint(
*this, TEXT("TestStringArray"), TEXT("TestStringSourceArray"), TEXT("TestStringItem")))
{
if (UDirectiveUtilTestObject* Instance = CreateScenarioInstance(*this, *Blueprint))
{
Instance->TestStringArray = {TEXT("A"), TEXT("B"), TEXT("A")};
Instance->TestStringSourceArray = {TEXT("C"), TEXT("A")};
Instance->TestStringItem = TEXT("A");
if (RunScenario(*this, *Instance))
{
TestEqual(TEXT("String wildcard values should survive Blueprint VM execution"),
Instance->TestStringArray, TArray<FString>({TEXT("C"), TEXT("B"), TEXT("C")}));
}
}
}
if (UBlueprint* Blueprint = BuildScenarioBlueprint(
*this, TEXT("TestCollisionArray"), TEXT("TestCollisionSourceArray"), TEXT("TestCollisionItem")))
{
if (UDirectiveUtilTestObject* Instance = CreateScenarioInstance(*this, *Blueprint))
{
Instance->TestCollisionArray = {{1}, {2}, {1}};
Instance->TestCollisionSourceArray = {{3}, {1}};
Instance->TestCollisionItem = {1};
if (RunScenario(*this, *Instance))
{
TestEqual(TEXT("Struct wildcard values should survive Blueprint VM execution"),
Instance->TestCollisionArray, TArray<FDirectiveUtilCollisionValue>({{3}, {2}, {3}}));
}
}
}
if (UBlueprint* Blueprint = BuildScenarioBlueprint(
*this, TEXT("TestObjectArray"), TEXT("TestObjectSourceArray"), TEXT("TestObjectItem")))
{
if (UDirectiveUtilTestObject* Instance = CreateScenarioInstance(*this, *Blueprint))
{
UObject* A = NewObject<UDirectiveUtilTestObject>(Instance);
UObject* B = NewObject<UDirectiveUtilTestObject>(Instance);
UObject* C = NewObject<UDirectiveUtilTestObject>(Instance);
Instance->TestObjectArray = {A, B, A};
Instance->TestObjectSourceArray = {C, A};
Instance->TestObjectItem = A;
if (RunScenario(*this, *Instance))
{
TestEqual(TEXT("Object wildcard arrays should retain three values"), Instance->TestObjectArray.Num(), 3);
if (Instance->TestObjectArray.Num() == 3)
{
TestEqual(TEXT("Object wildcard arrays should retain the first source object"), Instance->TestObjectArray[0].Get(), C);
TestEqual(TEXT("Object wildcard arrays should retain the target object"), Instance->TestObjectArray[1].Get(), B);
TestEqual(TEXT("Object wildcard arrays should retain the second source object"), Instance->TestObjectArray[2].Get(), C);
}
}
}
}
return true;
}

View File

@@ -0,0 +1,207 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Misc/AutomationTest.h"
#include "EdGraph/EdGraph.h"
#include "EdGraph/EdGraphNode.h"
#include "EdGraph/EdGraphPin.h"
#include "EdGraphSchema_K2.h"
#include "Engine/Blueprint.h"
#include "K2Node_CallArrayFunction.h"
#include "Kismet2/BlueprintEditorUtils.h"
#include "Kismet2/CompilerResultsLog.h"
#include "Libraries/DirectiveUtilArrayFunctionLibrary.h"
#include "Tests/DirectiveUtilTestObject.h"
namespace DirectiveUtilArrayNodeTest
{
struct FDependentPin
{
FName Name;
EPinContainerType ContainerType;
};
struct FArrayNodeCase
{
FName FunctionName;
TArray<FDependentPin> DependentPins;
};
FEdGraphPinType MakeArrayType(
const FName Category,
const FName SubCategory = NAME_None,
UObject* SubCategoryObject = nullptr)
{
FEdGraphPinType PinType;
PinType.PinCategory = Category;
PinType.PinSubCategory = SubCategory;
PinType.PinSubCategoryObject = SubCategoryObject;
PinType.ContainerType = EPinContainerType::Array;
return PinType;
}
UEdGraph* MakeGraph()
{
UBlueprint* Blueprint = NewObject<UBlueprint>();
Blueprint->GeneratedClass = UObject::StaticClass();
Blueprint->SkeletonGeneratedClass = UObject::StaticClass();
UEdGraph* Graph = NewObject<UEdGraph>(Blueprint);
Graph->Schema = UEdGraphSchema_K2::StaticClass();
Blueprint->UbergraphPages.Add(Graph);
return Graph;
}
UEdGraphPin* AddArrayOutput(UEdGraph& Graph, const FName Name, const FEdGraphPinType& PinType)
{
UEdGraphNode* SourceNode = NewObject<UEdGraphNode>(&Graph);
Graph.AddNode(SourceNode);
return SourceNode->CreatePin(EGPD_Output, PinType, Name);
}
UK2Node_CallArrayFunction* AddFunctionNode(UEdGraph& Graph, const FName FunctionName)
{
UK2Node_CallArrayFunction* Node = NewObject<UK2Node_CallArrayFunction>(&Graph);
Graph.AddNode(Node);
const UFunction* Function = UDirectiveUtilArrayFunctionLibrary::StaticClass()->FindFunctionByName(FunctionName);
Node->FunctionReference.SetExternalMember(FunctionName, UDirectiveUtilArrayFunctionLibrary::StaticClass());
Node->bDefaultsToPureFunc = Function && Function->HasAnyFunctionFlags(FUNC_BlueprintPure);
Node->AllocateDefaultPins();
return Node;
}
void Connect(UEdGraphPin& OutputPin, UK2Node& Node, UEdGraphPin& InputPin)
{
OutputPin.MakeLinkTo(&InputPin);
Node.PinConnectionListChanged(&InputPin);
}
void Disconnect(UEdGraphPin& OutputPin, UK2Node& Node, UEdGraphPin& InputPin)
{
OutputPin.BreakLinkTo(&InputPin);
Node.PinConnectionListChanged(&InputPin);
}
bool HasElementType(
const UEdGraphPin& Pin,
const FEdGraphPinType& ArrayType,
const EPinContainerType ContainerType)
{
return Pin.PinType.ContainerType == ContainerType
&& Pin.PinType.PinCategory == ArrayType.PinCategory
&& Pin.PinType.PinSubCategory == ArrayType.PinSubCategory
&& Pin.PinType.PinSubCategoryObject == ArrayType.PinSubCategoryObject;
}
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilArrayNodeWildcardTest,
"DirectiveUtilities.ArrayNodeWildcardTests",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilArrayNodeWildcardTest::RunTest(const FString& Parameters)
{
using namespace DirectiveUtilArrayNodeTest;
const TArray<FArrayNodeCase> NodeCases = {
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_NextIndex), {}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_PreviousIndex), {}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_RemoveDuplicates), {}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_AppendOptimized), {{TEXT("SourceArray"), EPinContainerType::Array}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_InsertOptimized), {{TEXT("SourceArray"), EPinContainerType::Array}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_GetValidFirstItemCopy), {{TEXT("OutItem"), EPinContainerType::None}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_GetValidLastItemCopy), {{TEXT("OutItem"), EPinContainerType::None}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_GetValidItemFromIndexCopy), {{TEXT("OutItem"), EPinContainerType::None}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_GetRandomItem), {{TEXT("OutItem"), EPinContainerType::None}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_LastValue), {{TEXT("OutItem"), EPinContainerType::None}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_Pop), {{TEXT("OutItem"), EPinContainerType::None}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_PopFirst), {{TEXT("OutItem"), EPinContainerType::None}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_RemoveAtSwap), {}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_RemoveAtIndices), {}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_RemoveAllOccurrences), {{TEXT("Item"), EPinContainerType::None}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_Slice), {{TEXT("OutArray"), EPinContainerType::Array}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_Rotate), {}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_GetDistinct), {{TEXT("OutArray"), EPinContainerType::Array}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_CountOccurrences), {{TEXT("ItemToCount"), EPinContainerType::None}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_GetMostCommon), {{TEXT("OutItem"), EPinContainerType::None}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_Sample), {{TEXT("OutArray"), EPinContainerType::Array}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_SampleFromStream), {{TEXT("OutArray"), EPinContainerType::Array}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_SampleWeighted), {{TEXT("OutArray"), EPinContainerType::Array}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_SampleWeightedFromStream), {{TEXT("OutArray"), EPinContainerType::Array}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_GetPage), {{TEXT("OutArray"), EPinContainerType::Array}}}
};
const TArray<FEdGraphPinType> ArrayTypes = {
MakeArrayType(UEdGraphSchema_K2::PC_Boolean),
MakeArrayType(UEdGraphSchema_K2::PC_String),
MakeArrayType(UEdGraphSchema_K2::PC_Object, NAME_None, UDirectiveUtilTestObject::StaticClass()),
MakeArrayType(UEdGraphSchema_K2::PC_Struct, NAME_None, FDirectiveUtilCollisionValue::StaticStruct())
};
for (const FArrayNodeCase& NodeCase : NodeCases)
{
UEdGraph* Graph = MakeGraph();
UK2Node_CallArrayFunction* Node = AddFunctionNode(*Graph, NodeCase.FunctionName);
UEdGraphPin* TargetArray = Node->FindPin(TEXT("TargetArray"));
TestNotNull(*FString::Printf(TEXT("%s should have a TargetArray pin"), *NodeCase.FunctionName.ToString()), TargetArray);
if (!TargetArray)
{
continue;
}
TestTrue(
*FString::Printf(TEXT("%s should begin as a wildcard array"), *NodeCase.FunctionName.ToString()),
TargetArray->PinType.IsArray() && TargetArray->PinType.PinCategory == UEdGraphSchema_K2::PC_Wildcard);
if (NodeCase.FunctionName == GET_FUNCTION_NAME_CHECKED(UDirectiveUtilArrayFunctionLibrary, Array_RemoveAtIndices))
{
const UEdGraphPin* IndicesPin = Node->FindPin(TEXT("Indices"));
TestNotNull(TEXT("RemoveAtIndices should have an Indices pin"), IndicesPin);
if (IndicesPin)
{
TestTrue(
TEXT("RemoveAtIndices should keep Indices as an integer array"),
IndicesPin->PinType.IsArray()
&& IndicesPin->PinType.PinCategory == UEdGraphSchema_K2::PC_Int);
}
}
FCompilerResultsLog ModuleValidationLog;
FBlueprintEditorUtils::ValidateEditorOnlyNodes(Node, ModuleValidationLog);
TestEqual(
*FString::Printf(TEXT("%s should be valid in a runtime Blueprint"), *NodeCase.FunctionName.ToString()),
ModuleValidationLog.NumWarnings,
0);
for (int32 TypeIndex = 0; TypeIndex < ArrayTypes.Num(); ++TypeIndex)
{
const FEdGraphPinType& ArrayType = ArrayTypes[TypeIndex];
UEdGraphPin* ArrayOutput = AddArrayOutput(
*Graph,
*FString::Printf(TEXT("ArrayOutput%d"), TypeIndex),
ArrayType);
Connect(*ArrayOutput, *Node, *TargetArray);
TestTrue(
*FString::Printf(TEXT("%s should resolve TargetArray type %d"), *NodeCase.FunctionName.ToString(), TypeIndex),
HasElementType(*TargetArray, ArrayType, EPinContainerType::Array));
for (const FDependentPin& ExpectedPin : NodeCase.DependentPins)
{
const UEdGraphPin* DependentPin = Node->FindPin(ExpectedPin.Name);
TestNotNull(
*FString::Printf(TEXT("%s should have a %s pin"), *NodeCase.FunctionName.ToString(), *ExpectedPin.Name.ToString()),
DependentPin);
if (DependentPin)
{
TestTrue(
*FString::Printf(TEXT("%s should resolve %s type %d"), *NodeCase.FunctionName.ToString(), *ExpectedPin.Name.ToString(), TypeIndex),
HasElementType(*DependentPin, ArrayType, ExpectedPin.ContainerType));
}
}
Disconnect(*ArrayOutput, *Node, *TargetArray);
TestTrue(
*FString::Printf(TEXT("%s should reset after type %d"), *NodeCase.FunctionName.ToString(), TypeIndex),
TargetArray->PinType.IsArray() && TargetArray->PinType.PinCategory == UEdGraphSchema_K2::PC_Wildcard);
}
}
return true;
}

View File

@@ -0,0 +1,801 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilArrayFunctionLibrary.h"
#include "Tests/DirectiveUtilTestObject.h"
#include "Algo/Reverse.h"
#include "Misc/AutomationTest.h"
namespace
{
FArrayProperty* GetIntegerArrayProperty()
{
return FindFProperty<FArrayProperty>(
UDirectiveUtilTestObject::StaticClass(),
GET_MEMBER_NAME_CHECKED(UDirectiveUtilTestObject, TestArray));
}
TArray<int32> MakeSequentialValues(const int32 Count)
{
TArray<int32> Values;
Values.SetNumUninitialized(Count);
for (int32 Index = 0; Index < Count; ++Index)
{
Values[Index] = Index;
}
return Values;
}
TArray<int32> MakeRepeatingValues(const int32 Count, const int32 DistinctCount)
{
TArray<int32> Values;
Values.Reserve(Count);
for (int32 Index = 0; Index < Count; ++Index)
{
Values.Add(Index % DistinctCount);
}
return Values;
}
TArray<int32> MakeDistinctReference(const TArray<int32>& Values)
{
TArray<int32> Result;
for (const int32 Value : Values)
{
Result.AddUnique(Value);
}
return Result;
}
bool FindMostCommonReference(const TArray<int32>& Values, int32& OutValue, int32& OutCount)
{
OutValue = 0;
OutCount = 0;
TMap<int32, int32> Counts;
for (const int32 Value : Values)
{
++Counts.FindOrAdd(Value);
}
for (const TPair<int32, int32>& Pair : Counts)
{
OutCount = FMath::Max(OutCount, Pair.Value);
}
for (const int32 Value : Values)
{
if (Counts.FindRef(Value) == OutCount)
{
OutValue = Value;
break;
}
}
return !Values.IsEmpty();
}
int32 CountReference(const TArray<int32>& Values, const int32 QueryValue)
{
int32 Count = 0;
for (const int32 Value : Values)
{
Count += Value == QueryValue ? 1 : 0;
}
return Count;
}
TArray<int32> RemoveAllReference(const TArray<int32>& Values, const int32 QueryValue)
{
TArray<int32> Result;
Result.Reserve(Values.Num());
for (const int32 Value : Values)
{
if (Value != QueryValue)
{
Result.Add(Value);
}
}
return Result;
}
TArray<int32> SliceReference(const TArray<int32>& Values, const int32 StartIndex, const int32 Count)
{
TArray<int32> Result;
Result.Reserve(Count);
for (int32 Index = 0; Index < Count; ++Index)
{
Result.Add(Values[StartIndex + Index]);
}
return Result;
}
TArray<int32> MakeSampleReference(const int32 SourceCount, const int32 RequestedCount, const int32 Seed)
{
TArray<int32> AvailableIndices = MakeSequentialValues(SourceCount);
TArray<int32> Result;
const int32 SampleCount = FMath::Clamp(RequestedCount, 0, SourceCount);
Result.Reserve(SampleCount);
FRandomStream RandomStream(Seed);
for (int32 SampleIndex = 0; SampleIndex < SampleCount; ++SampleIndex)
{
const int32 SelectedIndex = RandomStream.RandRange(0, AvailableIndices.Num() - 1);
Result.Add(AvailableIndices[SelectedIndex]);
AvailableIndices.RemoveAtSwap(SelectedIndex, 1, EAllowShrinking::No);
}
return Result;
}
template <typename ValueType>
TArray<ValueType> MakeRotationReference(const TArray<ValueType>& Values, const int32 Shift)
{
if (Values.IsEmpty())
{
return Values;
}
int32 NormalizedShift = Shift % Values.Num();
if (NormalizedShift < 0)
{
NormalizedShift += Values.Num();
}
TArray<ValueType> Result;
Result.Reserve(Values.Num());
for (int32 Index = 0; Index < Values.Num(); ++Index)
{
Result.Add(Values[(Index - NormalizedShift + Values.Num()) % Values.Num()]);
}
return Result;
}
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilArrayCardinalityTest,
"DirectiveUtilities.ArrayScenarios.Cardinality",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilArrayCardinalityTest::RunTest(const FString& Parameters)
{
FArrayProperty* ArrayProperty = GetIntegerArrayProperty();
if (!TestNotNull("Integer array property should be available", ArrayProperty))
{
return false;
}
UDirectiveUtilTestObject* TestObject = NewObject<UDirectiveUtilTestObject>();
struct FScenario
{
int32 ItemCount;
int32 DistinctCount;
};
const TArray<FScenario> Scenarios = {
{0, 1},
{1, 1},
{2, 1},
{3, 2},
{16, 16},
{31, 7},
{64, 4},
{257, 257},
{1024, 1},
{4096, 257},
{16384, 1024}
};
for (const FScenario& Scenario : Scenarios)
{
const TArray<int32> Source = MakeRepeatingValues(Scenario.ItemCount, Scenario.DistinctCount);
const TArray<int32> ExpectedDistinct = MakeDistinctReference(Source);
const FString Label = FString::Printf(
TEXT("items=%d distinct=%d"),
Scenario.ItemCount,
FMath::Min(Scenario.ItemCount, Scenario.DistinctCount));
TestObject->TestArray = Source;
UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveDuplicates(&TestObject->TestArray, ArrayProperty);
TestEqual(Label + TEXT(" RemoveDuplicates"), TestObject->TestArray, ExpectedDistinct);
TArray<int32> DistinctResult;
UDirectiveUtilArrayFunctionLibrary::GenericArray_GetDistinct(
&Source,
ArrayProperty,
&DistinctResult,
ArrayProperty);
TestEqual(Label + TEXT(" GetDistinct"), DistinctResult, ExpectedDistinct);
int32 MostCommonValue = INDEX_NONE;
int32 MostCommonCount = INDEX_NONE;
const bool bFoundMostCommon = UDirectiveUtilArrayFunctionLibrary::GenericArray_GetMostCommon(
&Source,
ArrayProperty,
&MostCommonValue,
&MostCommonCount);
if (Source.IsEmpty())
{
TestFalse(Label + TEXT(" GetMostCommon should fail"), bFoundMostCommon);
TestEqual(Label + TEXT(" GetMostCommon count"), MostCommonCount, 0);
}
else
{
TestTrue(Label + TEXT(" GetMostCommon should succeed"), bFoundMostCommon);
TestEqual(Label + TEXT(" GetMostCommon value"), MostCommonValue, 0);
TestEqual(
Label + TEXT(" GetMostCommon count"),
MostCommonCount,
FMath::DivideAndRoundUp(Scenario.ItemCount, Scenario.DistinctCount));
}
}
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilArraySamplingScenarioTest,
"DirectiveUtilities.ArrayScenarios.Sampling",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilArraySamplingScenarioTest::RunTest(const FString& Parameters)
{
FArrayProperty* ArrayProperty = GetIntegerArrayProperty();
if (!TestNotNull("Integer array property should be available", ArrayProperty))
{
return false;
}
constexpr int32 Seed = 7351;
for (const int32 SourceCount : {0, 1, 2, 3, 4, 17, 100, 1000})
{
const TArray<int32> Source = MakeSequentialValues(SourceCount);
const TArray<int32> RequestedCounts = {-1, 0, 1, SourceCount / 4, SourceCount, SourceCount + 5};
for (const int32 RequestedCount : RequestedCounts)
{
FRandomStream FirstStream(Seed);
FRandomStream SecondStream(Seed);
TArray<int32> FirstSample;
TArray<int32> SecondSample;
UDirectiveUtilArrayFunctionLibrary::GenericArray_Sample(
&Source,
ArrayProperty,
RequestedCount,
false,
&FirstStream,
&FirstSample,
ArrayProperty);
UDirectiveUtilArrayFunctionLibrary::GenericArray_Sample(
&Source,
ArrayProperty,
RequestedCount,
false,
&SecondStream,
&SecondSample,
ArrayProperty);
const FString Label = FString::Printf(TEXT("source=%d requested=%d"), SourceCount, RequestedCount);
TestEqual(Label + TEXT(" count"), FirstSample.Num(), FMath::Clamp(RequestedCount, 0, SourceCount));
TestEqual(Label + TEXT(" deterministic"), FirstSample, SecondSample);
TSet<int32> UniqueValues;
for (const int32 Value : FirstSample)
{
TestTrue(Label + TEXT(" source membership"), Source.Contains(Value));
UniqueValues.Add(Value);
}
TestEqual(Label + TEXT(" uniqueness"), UniqueValues.Num(), FirstSample.Num());
}
}
const TArray<int32> BoundarySource = MakeSequentialValues(100);
for (const int32 RequestedCount : {24, 25, 26, 75, 100})
{
FRandomStream RandomStream(Seed);
TArray<int32> Sample;
UDirectiveUtilArrayFunctionLibrary::GenericArray_Sample(
&BoundarySource,
ArrayProperty,
RequestedCount,
false,
&RandomStream,
&Sample,
ArrayProperty);
TestEqual(
FString::Printf(TEXT("threshold requested=%d"), RequestedCount),
Sample,
MakeSampleReference(BoundarySource.Num(), RequestedCount, Seed));
}
const TArray<int32> ReplacementSource = MakeSequentialValues(7);
for (const int32 RequestedCount : {0, 1, 7, 14, 100})
{
FRandomStream FirstStream(Seed);
FRandomStream SecondStream(Seed);
TArray<int32> FirstSample;
TArray<int32> SecondSample;
UDirectiveUtilArrayFunctionLibrary::GenericArray_Sample(
&ReplacementSource,
ArrayProperty,
RequestedCount,
true,
&FirstStream,
&FirstSample,
ArrayProperty);
UDirectiveUtilArrayFunctionLibrary::GenericArray_Sample(
&ReplacementSource,
ArrayProperty,
RequestedCount,
true,
&SecondStream,
&SecondSample,
ArrayProperty);
const FString Label = FString::Printf(TEXT("replacement requested=%d"), RequestedCount);
TestEqual(Label + TEXT(" count"), FirstSample.Num(), RequestedCount);
TestEqual(Label + TEXT(" deterministic"), FirstSample, SecondSample);
for (const int32 Value : FirstSample)
{
TestTrue(Label + TEXT(" source membership"), ReplacementSource.Contains(Value));
}
}
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilArrayWeightedSamplingScenarioTest,
"DirectiveUtilities.ArrayScenarios.WeightedSampling",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilArrayWeightedSamplingScenarioTest::RunTest(const FString& Parameters)
{
FArrayProperty* ArrayProperty = GetIntegerArrayProperty();
if (!TestNotNull("Integer array property should be available", ArrayProperty))
{
return false;
}
constexpr int32 Seed = 4815;
for (const int32 SourceCount : {0, 1, 2, 3, 17, 100, 1000, 4096})
{
const TArray<int32> Source = MakeSequentialValues(SourceCount);
TArray<float> Weights;
Weights.Reserve(SourceCount);
TSet<int32> SelectableValues;
for (int32 Index = 0; Index < SourceCount; ++Index)
{
const float Weight = Index % 3 == 0 ? 0.0f : static_cast<float>((Index % 7) + 1);
Weights.Add(Weight);
if (Weight > 0.0f)
{
SelectableValues.Add(Index);
}
}
const TArray<int32> RequestedCounts = {0, 1, SourceCount / 4, SourceCount, SourceCount + 5};
for (const int32 RequestedCount : RequestedCounts)
{
for (const bool bWithReplacement : {false, true})
{
FRandomStream FirstStream(Seed);
FRandomStream SecondStream(Seed);
TArray<int32> FirstSample;
TArray<int32> SecondSample;
const bool bFirstSucceeded = UDirectiveUtilArrayFunctionLibrary::GenericArray_SampleWeighted(
&Source,
ArrayProperty,
Weights,
RequestedCount,
bWithReplacement,
&FirstStream,
&FirstSample,
ArrayProperty);
const bool bSecondSucceeded = UDirectiveUtilArrayFunctionLibrary::GenericArray_SampleWeighted(
&Source,
ArrayProperty,
Weights,
RequestedCount,
bWithReplacement,
&SecondStream,
&SecondSample,
ArrayProperty);
const FString Label = FString::Printf(
TEXT("source=%d requested=%d replacement=%d"),
SourceCount,
RequestedCount,
bWithReplacement);
const bool bExpectedSuccess = RequestedCount == 0 || !SelectableValues.IsEmpty();
TestEqual(Label + TEXT(" first validity"), bFirstSucceeded, bExpectedSuccess);
TestEqual(Label + TEXT(" second validity"), bSecondSucceeded, bExpectedSuccess);
TestEqual(Label + TEXT(" deterministic"), FirstSample, SecondSample);
const int32 ExpectedCount = !bExpectedSuccess
? 0
: bWithReplacement
? RequestedCount
: FMath::Min(RequestedCount, SelectableValues.Num());
TestEqual(Label + TEXT(" count"), FirstSample.Num(), ExpectedCount);
TSet<int32> UniqueValues;
for (const int32 Value : FirstSample)
{
TestTrue(Label + TEXT(" selectable membership"), SelectableValues.Contains(Value));
UniqueValues.Add(Value);
}
if (!bWithReplacement)
{
TestEqual(Label + TEXT(" unique source indices"), UniqueValues.Num(), FirstSample.Num());
}
}
}
}
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilArrayRangeScenarioTest,
"DirectiveUtilities.ArrayScenarios.RangesAndPages",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilArrayRangeScenarioTest::RunTest(const FString& Parameters)
{
FArrayProperty* ArrayProperty = GetIntegerArrayProperty();
if (!TestNotNull("Integer array property should be available", ArrayProperty))
{
return false;
}
struct FPageScenario
{
int32 ItemCount;
int32 PageIndex;
int32 PageSize;
bool bExpectedValid;
int32 ExpectedPageCount;
};
const TArray<FPageScenario> PageScenarios = {
{0, 0, 1, false, 0},
{1, 0, 1, true, 1},
{2, 1, 1, true, 2},
{5, 0, 2, true, 3},
{5, 1, 2, true, 3},
{5, 2, 2, true, 3},
{5, 3, 2, false, 3},
{10, 3, 3, true, 4},
{10, 0, 10, true, 1},
{10, 0, 11, true, 1},
{10, -1, 3, false, 0},
{10, 0, 0, false, 0},
{10, 0, -1, false, 0},
{10, MAX_int32, 3, false, 4}
};
for (const FPageScenario& Scenario : PageScenarios)
{
const TArray<int32> Source = MakeSequentialValues(Scenario.ItemCount);
TArray<int32> Page;
int32 PageCount = INDEX_NONE;
const bool bValid = UDirectiveUtilArrayFunctionLibrary::GenericArray_GetPage(
&Source,
ArrayProperty,
Scenario.PageIndex,
Scenario.PageSize,
&Page,
ArrayProperty,
&PageCount);
const FString Label = FString::Printf(
TEXT("items=%d page=%d size=%d"),
Scenario.ItemCount,
Scenario.PageIndex,
Scenario.PageSize);
TestEqual(Label + TEXT(" validity"), bValid, Scenario.bExpectedValid);
TestEqual(Label + TEXT(" page count"), PageCount, Scenario.ExpectedPageCount);
TArray<int32> ExpectedPage;
if (Scenario.bExpectedValid)
{
const int32 StartIndex = Scenario.PageIndex * Scenario.PageSize;
for (int32 Index = StartIndex; Index < FMath::Min(StartIndex + Scenario.PageSize, Source.Num()); ++Index)
{
ExpectedPage.Add(Source[Index]);
}
}
TestEqual(Label + TEXT(" values"), Page, ExpectedPage);
}
const TArray<int32> SliceSource = MakeSequentialValues(10);
struct FSliceScenario
{
int32 StartIndex;
int32 Count;
};
const TArray<FSliceScenario> SliceScenarios = {
{-5, 3},
{0, -1},
{0, 0},
{0, 10},
{0, 20},
{5, 3},
{9, 5},
{10, 1},
{11, 1},
{MAX_int32, MAX_int32}
};
for (const FSliceScenario& Scenario : SliceScenarios)
{
const int32 StartIndex = FMath::Clamp(Scenario.StartIndex, 0, SliceSource.Num());
const int32 CopyCount = Scenario.Count > 0
? FMath::Min(Scenario.Count, SliceSource.Num() - StartIndex)
: 0;
TArray<int32> ExpectedSlice;
for (int32 Offset = 0; Offset < CopyCount; ++Offset)
{
ExpectedSlice.Add(SliceSource[StartIndex + Offset]);
}
TArray<int32> Slice;
UDirectiveUtilArrayFunctionLibrary::GenericArray_Slice(
&SliceSource,
ArrayProperty,
Scenario.StartIndex,
Scenario.Count,
&Slice,
ArrayProperty);
const FString Label = FString::Printf(TEXT("start=%d count=%d"), Scenario.StartIndex, Scenario.Count);
TestEqual(Label + TEXT(" separate output"), Slice, ExpectedSlice);
TArray<int32> AliasedSlice = SliceSource;
UDirectiveUtilArrayFunctionLibrary::GenericArray_Slice(
&AliasedSlice,
ArrayProperty,
Scenario.StartIndex,
Scenario.Count,
&AliasedSlice,
ArrayProperty);
TestEqual(Label + TEXT(" aliased output"), AliasedSlice, ExpectedSlice);
}
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilArrayRotationScenarioTest,
"DirectiveUtilities.ArrayScenarios.Rotation",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilArrayRotationScenarioTest::RunTest(const FString& Parameters)
{
FArrayProperty* IntegerArrayProperty = GetIntegerArrayProperty();
FArrayProperty* StringArrayProperty = FindFProperty<FArrayProperty>(
UDirectiveUtilTestObject::StaticClass(),
GET_MEMBER_NAME_CHECKED(UDirectiveUtilTestObject, TestStringArray));
if (!TestNotNull("Integer array property should be available", IntegerArrayProperty)
|| !TestNotNull("String array property should be available", StringArrayProperty))
{
return false;
}
for (const int32 ItemCount : {0, 1, 2, 3, 4, 5, 16, 257, 10000})
{
const TArray<int32> Source = MakeSequentialValues(ItemCount);
const TArray<int32> Shifts = {
0,
1,
-1,
ItemCount,
ItemCount + 1,
-ItemCount - 1,
MAX_int32,
MIN_int32
};
for (const int32 Shift : Shifts)
{
TArray<int32> Rotated = Source;
UDirectiveUtilArrayFunctionLibrary::GenericArray_Rotate(&Rotated, IntegerArrayProperty, Shift);
TestEqual(
FString::Printf(TEXT("POD items=%d shift=%d"), ItemCount, Shift),
Rotated,
MakeRotationReference(Source, Shift));
}
}
for (const int32 ItemCount : {0, 1, 2, 5, 32})
{
TArray<FString> Source;
Source.Reserve(ItemCount);
for (int32 Index = 0; Index < ItemCount; ++Index)
{
Source.Add(FString::Printf(TEXT("Value%d"), Index));
}
for (const int32 Shift : {0, 1, -1, 7, -11, MAX_int32, MIN_int32})
{
TArray<FString> Rotated = Source;
UDirectiveUtilArrayFunctionLibrary::GenericArray_Rotate(&Rotated, StringArrayProperty, Shift);
TestEqual(
FString::Printf(TEXT("managed items=%d shift=%d"), ItemCount, Shift),
Rotated,
MakeRotationReference(Source, Shift));
}
}
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilArrayOrderingScenarioTest,
"DirectiveUtilities.ArrayScenarios.NaturalOrdering",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilArrayOrderingScenarioTest::RunTest(const FString& Parameters)
{
for (const int32 ItemCount : {0, 1, 2, 10, 100, 1000})
{
TArray<FString> Strings;
TArray<FString> ExpectedStrings;
TArray<FName> Names;
TArray<FName> ExpectedNames;
Strings.Reserve(ItemCount);
ExpectedStrings.Reserve(ItemCount);
Names.Reserve(ItemCount);
ExpectedNames.Reserve(ItemCount);
for (int32 Index = 0; Index < ItemCount; ++Index)
{
ExpectedStrings.Add(FString::Printf(TEXT("Item%d"), Index));
ExpectedNames.Add(FName(*FString::Printf(TEXT("Actor%d"), Index)));
}
for (int32 Index = ItemCount - 1; Index >= 0; --Index)
{
Strings.Add(ExpectedStrings[Index]);
Names.Add(ExpectedNames[Index]);
}
UDirectiveUtilArrayFunctionLibrary::NaturalSortStringArray(Strings);
UDirectiveUtilArrayFunctionLibrary::NaturalSortNameArray(Names);
const FString Label = FString::Printf(TEXT("items=%d"), ItemCount);
TestEqual(Label + TEXT(" string ascending"), Strings, ExpectedStrings);
TestEqual(Label + TEXT(" name ascending"), Names, ExpectedNames);
UDirectiveUtilArrayFunctionLibrary::NaturalSortStringArray(Strings, true);
UDirectiveUtilArrayFunctionLibrary::NaturalSortNameArray(Names, true);
Algo::Reverse(ExpectedStrings);
Algo::Reverse(ExpectedNames);
TestEqual(Label + TEXT(" string descending"), Strings, ExpectedStrings);
TestEqual(Label + TEXT(" name descending"), Names, ExpectedNames);
}
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilArrayDeterministicFuzzTest,
"DirectiveUtilities.ArrayScenarios.DeterministicFuzz",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilArrayDeterministicFuzzTest::RunTest(const FString& Parameters)
{
FArrayProperty* ArrayProperty = GetIntegerArrayProperty();
if (!TestNotNull("Integer array property should be available", ArrayProperty))
{
return false;
}
UDirectiveUtilTestObject* TestObject = NewObject<UDirectiveUtilTestObject>();
const TArray<int32> ItemCounts = {0, 1, 2, 3, 7, 16, 31, 32, 127, 128, 1024, 4096};
for (const int32 Seed : {17, 271, 4099, 65537, 104729})
{
for (int32 CountIndex = 0; CountIndex < ItemCounts.Num(); ++CountIndex)
{
const int32 ItemCount = ItemCounts[CountIndex];
FRandomStream Stream(Seed + ItemCount * 31);
TArray<int32> Source;
Source.Reserve(ItemCount);
for (int32 Index = 0; Index < ItemCount; ++Index)
{
Source.Add(Stream.RandRange(-64, 64));
}
const FString Label = FString::Printf(TEXT("seed=%d items=%d"), Seed, ItemCount);
const TArray<int32> ExpectedDistinct = MakeDistinctReference(Source);
TestObject->TestArray = Source;
UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveDuplicates(&TestObject->TestArray, ArrayProperty);
TestEqual(Label + TEXT(" RemoveDuplicates"), TestObject->TestArray, ExpectedDistinct);
TArray<int32> DistinctResult;
UDirectiveUtilArrayFunctionLibrary::GenericArray_GetDistinct(
&Source,
ArrayProperty,
&DistinctResult,
ArrayProperty);
TestEqual(Label + TEXT(" GetDistinct"), DistinctResult, ExpectedDistinct);
int32 ExpectedMostCommon = 0;
int32 ExpectedMostCommonCount = 0;
const bool bExpectedMostCommon = FindMostCommonReference(
Source,
ExpectedMostCommon,
ExpectedMostCommonCount);
int32 MostCommon = 0;
int32 MostCommonCount = 0;
const bool bFoundMostCommon = UDirectiveUtilArrayFunctionLibrary::GenericArray_GetMostCommon(
&Source,
ArrayProperty,
&MostCommon,
&MostCommonCount);
TestEqual(Label + TEXT(" GetMostCommon result"), bFoundMostCommon, bExpectedMostCommon);
TestEqual(Label + TEXT(" GetMostCommon value"), MostCommon, ExpectedMostCommon);
TestEqual(Label + TEXT(" GetMostCommon count"), MostCommonCount, ExpectedMostCommonCount);
const int32 QueryValue = Stream.RandRange(-70, 70);
TestEqual(
Label + TEXT(" CountOccurrences"),
UDirectiveUtilArrayFunctionLibrary::GenericArray_CountOccurrences(&Source, ArrayProperty, &QueryValue),
CountReference(Source, QueryValue));
const TArray<int32> ExpectedRemoved = RemoveAllReference(Source, QueryValue);
TestObject->TestArray = Source;
const bool bRemoved = UDirectiveUtilArrayFunctionLibrary::GenericArray_RemoveAllOccurrences(
&TestObject->TestArray,
ArrayProperty,
&QueryValue);
TestEqual(Label + TEXT(" RemoveAllOccurrences result"), bRemoved, ExpectedRemoved.Num() != Source.Num());
TestEqual(Label + TEXT(" RemoveAllOccurrences values"), TestObject->TestArray, ExpectedRemoved);
TArray<int32> ExpectedAppended = Source;
ExpectedAppended.Append(Source);
TestObject->TestArray = Source;
UDirectiveUtilArrayFunctionLibrary::GenericArray_AppendOptimized(
&TestObject->TestArray,
ArrayProperty,
&TestObject->TestArray,
ArrayProperty);
TestEqual(Label + TEXT(" AppendOptimized self append"), TestObject->TestArray, ExpectedAppended);
for (const int32 Shift : {MIN_int32, -ItemCount - 1, -1, 0, 1, ItemCount + 1, MAX_int32})
{
TestObject->TestArray = Source;
UDirectiveUtilArrayFunctionLibrary::GenericArray_Rotate(&TestObject->TestArray, ArrayProperty, Shift);
TestEqual(
Label + FString::Printf(TEXT(" Rotate shift=%d"), Shift),
TestObject->TestArray,
MakeRotationReference(Source, Shift));
}
const int32 StartIndex = Stream.RandRange(-ItemCount - 2, ItemCount + 2);
const int32 SliceCount = Stream.RandRange(-2, ItemCount + 2);
const int32 ExpectedStart = FMath::Clamp(StartIndex, 0, ItemCount);
const int32 ExpectedSliceCount = FMath::Max(0, FMath::Min(SliceCount, ItemCount - ExpectedStart));
const TArray<int32> ExpectedSlice = SliceReference(Source, ExpectedStart, ExpectedSliceCount);
TArray<int32> SliceResult;
UDirectiveUtilArrayFunctionLibrary::GenericArray_Slice(
&Source,
ArrayProperty,
StartIndex,
SliceCount,
&SliceResult,
ArrayProperty);
TestEqual(Label + TEXT(" Slice"), SliceResult, ExpectedSlice);
const int32 PageSize = CountIndex % 4 == 0 ? 0 : 1 << (CountIndex % 7);
const int32 ExpectedPageCount = PageSize > 0 ? FMath::DivideAndRoundUp(ItemCount, PageSize) : 0;
for (const int32 PageIndex : {-1, 0, FMath::Max(0, ExpectedPageCount - 1), ExpectedPageCount})
{
TArray<int32> PageResult;
int32 PageCount = -1;
const bool bPageValid = UDirectiveUtilArrayFunctionLibrary::GenericArray_GetPage(
&Source,
ArrayProperty,
PageIndex,
PageSize,
&PageResult,
ArrayProperty,
&PageCount);
const bool bExpectedValid = PageSize > 0 && PageIndex >= 0 && PageIndex < ExpectedPageCount;
const TArray<int32> ExpectedPage = bExpectedValid
? SliceReference(Source, PageIndex * PageSize, FMath::Min(PageSize, ItemCount - PageIndex * PageSize))
: TArray<int32>();
const FString PageLabel = Label + FString::Printf(TEXT(" Page index=%d size=%d"), PageIndex, PageSize);
TestEqual(PageLabel + TEXT(" validity"), bPageValid, bExpectedValid);
TestEqual(
PageLabel + TEXT(" count"),
PageCount,
PageSize > 0 && PageIndex >= 0 ? ExpectedPageCount : 0);
TestEqual(PageLabel + TEXT(" values"), PageResult, ExpectedPage);
}
}
}
return true;
}

View File

@@ -12,13 +12,11 @@
#include "Components/StaticMeshComponent.h"
#include "GameFramework/DefaultPawn.h"
#include "GameFramework/PlayerController.h"
#include "TimerManager.h"
#include "Misc/AutomationTest.h"
#if WITH_EDITOR
namespace DirectiveUtilAsyncTraceTestHelpers
{
/** Creates a transient game world with a physics scene, initialized for play so traces resolve. */
UWorld* CreateTraceWorld()
{
UWorld* World = UWorld::CreateWorld(EWorldType::Game, false);
@@ -33,7 +31,6 @@ namespace DirectiveUtilAsyncTraceTestHelpers
return World;
}
/** Spawns a blocking cube actor at the origin so traces have something to hit. */
AStaticMeshActor* SpawnBlockingCube(UWorld* World, UStaticMesh* CubeMesh)
{
AStaticMeshActor* Cube = World->SpawnActor<AStaticMeshActor>(FVector::ZeroVector, FRotator::ZeroRotator);
@@ -42,15 +39,36 @@ namespace DirectiveUtilAsyncTraceTestHelpers
Component->SetStaticMesh(CubeMesh);
Component->SetCollisionProfileName(TEXT("BlockAll"));
Component->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
Component->UpdateCollisionProfile();
return Cube;
}
}
/**
* Latent command that ticks a trace world each frame until every listener has reported completion
* (or the frame budget runs out), asserts the expected outcome, and tears the world down.
*/
bool UDirectiveUtilTestMoveToLocationTask::HasRegisteredTimers() const
{
const UWorld* World = TimerWorld.Get();
return World && (World->GetTimerManager().TimerExists(TimerHandle) || World->GetTimerManager().TimerExists(StuckTimerHandle));
}
void UDirectiveUtilTestMoveToLocationTask::RegisterTimersForTest(UWorld* World)
{
TimerWorld = World;
World->GetTimerManager().SetTimer(TimerHandle, FTimerDelegate::CreateLambda([] {}), 60.0f, true);
World->GetTimerManager().SetTimer(StuckTimerHandle, FTimerDelegate::CreateLambda([] {}), 60.0f, true);
}
bool UDirectiveUtilTestMoveToActorTask::HasRegisteredTimers() const
{
const UWorld* World = TimerWorld.Get();
return World && (World->GetTimerManager().TimerExists(TimerHandle) || World->GetTimerManager().TimerExists(StuckTimerHandle));
}
void UDirectiveUtilTestMoveToActorTask::RegisterTimersForTest(UWorld* World)
{
TimerWorld = World;
World->GetTimerManager().SetTimer(TimerHandle, FTimerDelegate::CreateLambda([] {}), 60.0f, true);
World->GetTimerManager().SetTimer(StuckTimerHandle, FTimerDelegate::CreateLambda([] {}), 60.0f, true);
}
class FDirectiveUtilTickTraceWorld : public IAutomationLatentCommand
{
public:
@@ -111,18 +129,12 @@ private:
int32 FramesRemaining;
};
/**
* DirectiveUtilTask_AsyncTrace: verifies the null-world guard broadcasts an empty result, and that each trace
* shape (line, sphere, box, capsule) resolves against a blocking body and reports a hit.
*/
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilAsyncTraceTest, "DirectiveUtilities.AsyncTaskTraceTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilAsyncTraceTest, "DirectiveUtilities.AsyncTaskTraceTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilAsyncTraceTest::RunTest(const FString& Parameters)
{
// The null-world activation intentionally logs a warning.
AddExpectedMessagePlain(TEXT("Async Trace failed to activate. World is null."), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1);
// Null world guard: activating with a null context broadcasts an empty result and does not crash.
{
UDirectiveUtilDelegateListener* Listener = NewObject<UDirectiveUtilDelegateListener>();
Listener->AddToRoot();
@@ -142,8 +154,8 @@ bool FDirectiveUtilAsyncTraceTest::RunTest(const FString& Parameters)
UStaticMesh* CubeMesh = LoadObject<UStaticMesh>(nullptr, TEXT("/Engine/BasicShapes/Cube.Cube"));
if (!CubeMesh)
{
AddInfo(TEXT("Engine cube mesh unavailable; skipping async trace hit scenarios."));
return true;
AddError(TEXT("Engine cube mesh unavailable for async trace hit scenarios."));
return false;
}
UWorld* World = DirectiveUtilAsyncTraceTestHelpers::CreateTraceWorld();
@@ -154,10 +166,9 @@ bool FDirectiveUtilAsyncTraceTest::RunTest(const FString& Parameters)
}
DirectiveUtilAsyncTraceTestHelpers::SpawnBlockingCube(World, CubeMesh);
// Trace straight down through the cube at the origin so every shape intersects it.
const FVector Start(0.0f, 0.0f, 500.0f);
const FVector End(0.0f, 0.0f, -500.0f);
const ETraceTypeQuery Channel = ETraceTypeQuery::TraceTypeQuery1; // Visibility, which BlockAll blocks.
const ETraceTypeQuery VisibilityChannel = ETraceTypeQuery::TraceTypeQuery1;
auto MakeListener = [](UDirectiveUtilTask_AsyncTrace* Task) -> UDirectiveUtilDelegateListener*
{
@@ -170,82 +181,23 @@ bool FDirectiveUtilAsyncTraceTest::RunTest(const FString& Parameters)
};
TArray<UDirectiveUtilDelegateListener*> Listeners;
Listeners.Add(MakeListener(UDirectiveUtilTask_AsyncTrace::AsyncLineTraceByChannel(World, Start, End, Channel, false)));
Listeners.Add(MakeListener(UDirectiveUtilTask_AsyncTrace::AsyncSphereTraceByChannel(World, Start, End, 25.0f, Channel, false)));
Listeners.Add(MakeListener(UDirectiveUtilTask_AsyncTrace::AsyncBoxTraceByChannel(World, Start, End, FVector(25.0f), FRotator::ZeroRotator, Channel, false)));
Listeners.Add(MakeListener(UDirectiveUtilTask_AsyncTrace::AsyncCapsuleTraceByChannel(World, Start, End, 25.0f, 50.0f, Channel, false)));
Listeners.Add(MakeListener(UDirectiveUtilTask_AsyncTrace::AsyncLineTraceByChannel(World, Start, End, VisibilityChannel, false)));
Listeners.Add(MakeListener(UDirectiveUtilTask_AsyncTrace::AsyncSphereTraceByChannel(World, Start, End, 25.0f, VisibilityChannel, false)));
Listeners.Add(MakeListener(UDirectiveUtilTask_AsyncTrace::AsyncBoxTraceByChannel(World, Start, End, FVector(25.0f), FRotator::ZeroRotator, VisibilityChannel, false)));
Listeners.Add(MakeListener(UDirectiveUtilTask_AsyncTrace::AsyncCapsuleTraceByChannel(World, Start, End, 25.0f, 50.0f, VisibilityChannel, false)));
ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilTickTraceWorld(this, World, Listeners, 120));
return true;
}
/**
* Latent command that ticks a move-to-location world each frame until the listener reports
* completion (or the frame budget runs out), asserts a single failed completion, and tears the
* world down.
*/
class FDirectiveUtilTickMoveToLocationWorld : public IAutomationLatentCommand
{
public:
FDirectiveUtilTickMoveToLocationWorld(FAutomationTestBase* InTest, UWorld* InWorld, UDirectiveUtilDelegateListener* InListener, int32 InFrames)
: Test(InTest)
, World(InWorld)
, Listener(InListener)
, FramesRemaining(InFrames)
{
}
virtual bool Update() override
{
if (UWorld* TickWorld = World.Get())
{
TickWorld->Tick(LEVELTICK_All, 0.05f);
}
if (Listener && !Listener->bCompleted && --FramesRemaining > 0)
{
return false;
}
if (Listener)
{
Test->TestTrue(TEXT("Move without navigation broadcasts Completed"), Listener->bCompleted);
Test->TestFalse(TEXT("Move without navigation reports failure"), Listener->bLastSuccess);
Test->TestEqual(TEXT("Move without navigation completes exactly once"), Listener->CompletedCount, 1);
Listener->Keepalive = nullptr;
Listener->RemoveFromRoot();
}
if (UWorld* TearDownWorld = World.Get())
{
GEngine->DestroyWorldContext(TearDownWorld);
TearDownWorld->DestroyWorld(false);
}
return true;
}
private:
FAutomationTestBase* Test;
TWeakObjectPtr<UWorld> World;
UDirectiveUtilDelegateListener* Listener;
int32 FramesRemaining;
};
/**
* DirectiveUtilTask_MoveToLocation: verifies the guard paths (null controller, controller without a pawn) and
* EndTask all broadcast Completed(false) without crashing, that a second EndTask does not broadcast
* again, and that a move with no navigation data terminates with failure. The successful navigation
* path requires a built navigation mesh and is exercised in a project-level test rather than here.
*/
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilMoveToLocationTest, "DirectiveUtilities.AsyncTaskMoveToLocationTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilMoveToLocationTest, "DirectiveUtilities.AsyncTaskMoveToLocationTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilMoveToLocationTest::RunTest(const FString& Parameters)
{
// The guard paths intentionally log a warning.
AddExpectedMessagePlain(TEXT("Controller, pawn, or world is unavailable while moving to location. Aborting."), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1);
AddExpectedMessagePlain(TEXT("Controller or pawn has been destroyed while moving to location. Aborting."), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1);
// Null controller guard: activating broadcasts Completed(false).
{
UDirectiveUtilDelegateListener* Listener = NewObject<UDirectiveUtilDelegateListener>();
Listener->AddToRoot();
@@ -262,16 +214,15 @@ bool FDirectiveUtilMoveToLocationTest::RunTest(const FString& Parameters)
Listener->RemoveFromRoot();
}
UWorld* World = UWorld::CreateWorld(EWorldType::Editor, false);
UWorld* World = UWorld::CreateWorld(EWorldType::Game, false);
if (!World)
{
AddError(TEXT("Failed to create a transient world for the move-to-location test."));
return false;
}
FWorldContext& WorldContext = GEngine->CreateNewWorldContext(EWorldType::Editor);
FWorldContext& WorldContext = GEngine->CreateNewWorldContext(EWorldType::Game);
WorldContext.SetCurrentWorld(World);
// Controller-without-pawn guard: activating broadcasts Completed(false).
{
APlayerController* Controller = World->SpawnActor<APlayerController>();
@@ -290,7 +241,6 @@ bool FDirectiveUtilMoveToLocationTest::RunTest(const FString& Parameters)
Listener->RemoveFromRoot();
}
// EndTask broadcasts Completed(false) and clears timers without crashing.
{
APlayerController* Controller = World->SpawnActor<APlayerController>();
@@ -309,7 +259,6 @@ bool FDirectiveUtilMoveToLocationTest::RunTest(const FString& Parameters)
Listener->RemoveFromRoot();
}
// Double completion guard: a second EndTask does not broadcast Completed again.
{
APlayerController* Controller = World->SpawnActor<APlayerController>();
@@ -328,31 +277,44 @@ bool FDirectiveUtilMoveToLocationTest::RunTest(const FString& Parameters)
Listener->RemoveFromRoot();
}
{
APlayerController* Controller = World->SpawnActor<APlayerController>();
ADefaultPawn* Pawn = World->SpawnActor<ADefaultPawn>();
if (Controller && Pawn)
{
Controller->SetPawn(Pawn);
UDirectiveUtilTestMoveToLocationTask* Task = NewObject<UDirectiveUtilTestMoveToLocationTask>();
Task->Configure(Controller, FVector(1000.0f, 0.0f, 0.0f), true);
Task->RegisterTimersForTest(World);
TestTrue("Move to location registers both lifecycle timers", Task->HasRegisteredTimers());
Task->ClearController();
Task->Complete();
TestFalse("Move to location clears timers without a controller", Task->HasRegisteredTimers());
Task->Configure(Controller, FVector(1000.0f, 0.0f, 0.0f), true);
Task->Activate();
TestFalse("A completed move to location should not restart", Task->HasRegisteredTimers());
}
}
GEngine->DestroyWorldContext(World);
World->DestroyWorld(false);
// No-navigation failure: without a navmesh the idle path-following check terminates the task
// with failure instead of polling forever.
{
// SimpleMoveToLocation may warn when the world has no navigation system.
AddExpectedMessagePlain(TEXT("SimpleMoveToActor called for NavSys:"), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1);
AddExpectedMessagePlain(TEXT("SimpleMove failed for"), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1);
UWorld* MoveWorld = DirectiveUtilAsyncTraceTestHelpers::CreateTraceWorld();
if (!MoveWorld)
{
AddInfo(TEXT("Failed to create a transient game world; skipping the no-navigation move scenario."));
return true;
AddError(TEXT("Failed to create a transient game world for the no-navigation move scenario."));
return false;
}
APlayerController* Controller = MoveWorld->SpawnActor<APlayerController>();
ADefaultPawn* Pawn = MoveWorld->SpawnActor<ADefaultPawn>(FVector::ZeroVector, FRotator::ZeroRotator);
if (!Controller || !Pawn)
{
AddInfo(TEXT("Failed to spawn a controller or pawn; skipping the no-navigation move scenario."));
AddError(TEXT("Failed to spawn a controller or pawn for the no-navigation move scenario."));
GEngine->DestroyWorldContext(MoveWorld);
MoveWorld->DestroyWorld(false);
return true;
return false;
}
Controller->SetPawn(Pawn);
@@ -364,26 +326,26 @@ bool FDirectiveUtilMoveToLocationTest::RunTest(const FString& Parameters)
Task->Completed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnBoolCompleted);
Task->Activate();
ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilTickMoveToLocationWorld(this, MoveWorld, Listener, 120));
TestTrue("A location move without navigation broadcasts Completed", Listener->bCompleted);
TestFalse("A location move without navigation reports failure", Listener->bLastSuccess);
TestEqual("A location move without navigation completes exactly once", Listener->CompletedCount, 1);
Listener->Keepalive = nullptr;
Listener->RemoveFromRoot();
GEngine->DestroyWorldContext(MoveWorld);
MoveWorld->DestroyWorld(false);
}
return true;
}
/**
* DirectiveUtilTask_MoveToActor: verifies the guard paths (null controller, null goal) broadcast Completed(false)
* without crashing, that a second EndTask does not broadcast again, and that a move with no
* navigation data terminates with failure. The successful navigation path requires a built
* navigation mesh and is exercised in a project-level test rather than here.
*/
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilMoveToActorTest, "DirectiveUtilities.AsyncTaskMoveToActorTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilMoveToActorTest, "DirectiveUtilities.AsyncTaskMoveToActorTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilMoveToActorTest::RunTest(const FString& Parameters)
{
// The guard paths intentionally log a warning.
AddExpectedMessagePlain(TEXT("Controller, pawn, goal, or world is unavailable while moving to actor. Aborting."), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1);
AddExpectedMessagePlain(TEXT("Controller, pawn, or world is unavailable while moving to actor. Aborting."), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1);
AddExpectedMessagePlain(TEXT("Controller, pawn, or goal has been destroyed while moving to actor. Aborting."), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1);
// Null controller guard: activating broadcasts Completed(false).
{
UDirectiveUtilDelegateListener* Listener = NewObject<UDirectiveUtilDelegateListener>();
Listener->AddToRoot();
@@ -400,16 +362,15 @@ bool FDirectiveUtilMoveToActorTest::RunTest(const FString& Parameters)
Listener->RemoveFromRoot();
}
UWorld* World = UWorld::CreateWorld(EWorldType::Editor, false);
UWorld* World = UWorld::CreateWorld(EWorldType::Game, false);
if (!World)
{
AddError(TEXT("Failed to create a transient world for the move-to-actor test."));
return false;
}
FWorldContext& WorldContext = GEngine->CreateNewWorldContext(EWorldType::Editor);
FWorldContext& WorldContext = GEngine->CreateNewWorldContext(EWorldType::Game);
WorldContext.SetCurrentWorld(World);
// Null goal guard: a controller with a pawn but no goal broadcasts Completed(false).
{
APlayerController* Controller = World->SpawnActor<APlayerController>();
ADefaultPawn* Pawn = World->SpawnActor<ADefaultPawn>(FVector::ZeroVector, FRotator::ZeroRotator);
@@ -433,11 +394,10 @@ bool FDirectiveUtilMoveToActorTest::RunTest(const FString& Parameters)
}
else
{
AddInfo(TEXT("Failed to spawn a controller or pawn; skipping the null-goal scenario."));
AddError(TEXT("Failed to spawn a controller or pawn for the null-goal scenario."));
}
}
// Double completion guard: a second EndTask does not broadcast Completed again.
{
APlayerController* Controller = World->SpawnActor<APlayerController>();
@@ -456,21 +416,35 @@ bool FDirectiveUtilMoveToActorTest::RunTest(const FString& Parameters)
Listener->RemoveFromRoot();
}
{
APlayerController* Controller = World->SpawnActor<APlayerController>();
ADefaultPawn* Pawn = World->SpawnActor<ADefaultPawn>();
AStaticMeshActor* Goal = World->SpawnActor<AStaticMeshActor>();
if (Controller && Pawn && Goal)
{
Controller->SetPawn(Pawn);
UDirectiveUtilTestMoveToActorTask* Task = NewObject<UDirectiveUtilTestMoveToActorTask>();
Task->Configure(Controller, Goal, true);
Task->RegisterTimersForTest(World);
TestTrue("Move to actor registers both lifecycle timers", Task->HasRegisteredTimers());
Task->ClearController();
Task->Complete();
TestFalse("Move to actor clears timers without a controller", Task->HasRegisteredTimers());
Task->Configure(Controller, Goal, true);
Task->Activate();
TestFalse("A completed move to actor should not restart", Task->HasRegisteredTimers());
}
}
GEngine->DestroyWorldContext(World);
World->DestroyWorld(false);
// No-navigation failure: without a navmesh the idle path-following check terminates the task
// with failure instead of polling forever.
{
// SimpleMoveToActor may warn when the world has no navigation system.
AddExpectedMessagePlain(TEXT("SimpleMoveToActor called for NavSys:"), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1);
AddExpectedMessagePlain(TEXT("SimpleMove failed for"), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1);
UWorld* MoveWorld = DirectiveUtilAsyncTraceTestHelpers::CreateTraceWorld();
if (!MoveWorld)
{
AddInfo(TEXT("Failed to create a transient game world; skipping the no-navigation move scenario."));
return true;
AddError(TEXT("Failed to create a transient game world for the no-navigation move scenario."));
return false;
}
APlayerController* Controller = MoveWorld->SpawnActor<APlayerController>();
@@ -478,10 +452,10 @@ bool FDirectiveUtilMoveToActorTest::RunTest(const FString& Parameters)
AStaticMeshActor* GoalActor = MoveWorld->SpawnActor<AStaticMeshActor>(FVector(10000.0f, 0.0f, 0.0f), FRotator::ZeroRotator);
if (!Controller || !Pawn || !GoalActor)
{
AddInfo(TEXT("Failed to spawn a controller, pawn, or goal; skipping the no-navigation move scenario."));
AddError(TEXT("Failed to spawn a controller, pawn, or goal for the no-navigation move scenario."));
GEngine->DestroyWorldContext(MoveWorld);
MoveWorld->DestroyWorld(false);
return true;
return false;
}
Controller->SetPawn(Pawn);
@@ -493,10 +467,14 @@ bool FDirectiveUtilMoveToActorTest::RunTest(const FString& Parameters)
Task->Completed.AddDynamic(Listener, &UDirectiveUtilDelegateListener::OnBoolCompleted);
Task->Activate();
ADD_LATENT_AUTOMATION_COMMAND(FDirectiveUtilTickMoveToLocationWorld(this, MoveWorld, Listener, 120));
TestTrue("An actor move without navigation broadcasts Completed", Listener->bCompleted);
TestFalse("An actor move without navigation reports failure", Listener->bLastSuccess);
TestEqual("An actor move without navigation completes exactly once", Listener->CompletedCount, 1);
Listener->Keepalive = nullptr;
Listener->RemoveFromRoot();
GEngine->DestroyWorldContext(MoveWorld);
MoveWorld->DestroyWorld(false);
}
return true;
}
#endif // WITH_EDITOR

View File

@@ -1,6 +1,9 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Misc/AutomationTest.h"
#include "UObject/Class.h"
#include "UObject/Package.h"
#include "UObject/UnrealType.h"
#include "UObject/UObjectIterator.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilBlueprintCategoryTest, "DirectiveUtilities.BlueprintCategoryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
@@ -8,8 +11,9 @@ IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilBlueprintCategoryTest, "Directive
bool FDirectiveUtilBlueprintCategoryTest::RunTest(const FString& Parameters)
{
const FString ExpectedCategoryRoot = TEXT("Directive Utilities");
const FString RuntimeScriptPackage = TEXT("/Script/DirectiveUtilitiesRuntime");
const TSet<FString> PluginScriptPackages = {
TEXT("/Script/DirectiveUtilitiesRuntime"),
RuntimeScriptPackage,
TEXT("/Script/DirectiveUtilitiesEditor")
};
@@ -32,6 +36,15 @@ bool FDirectiveUtilBlueprintCategoryTest::RunTest(const FString& Parameters)
}
++TestedFunctionCount;
if (Class->GetOutermost()->GetName() == RuntimeScriptPackage)
{
TestFalse(
FString::Printf(TEXT("%s.%s is available at runtime"), *Class->GetName(), *Function->GetName()),
Class->GetOutermost()->HasAnyPackageFlags(PKG_EditorOnly | PKG_UncookedOnly | PKG_Developer)
|| Function->HasAnyFunctionFlags(FUNC_EditorOnly)
);
}
const FString Category = Function->GetMetaData(TEXT("Category"));
FString CategoryRoot = Category;
int32 CategoryDelimiterIndex = INDEX_NONE;

View File

@@ -0,0 +1,64 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#if WITH_EDITOR
#include "AssetRegistry/DirectiveUtilDependencyCycleFinder.h"
#include "Misc/AutomationTest.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilDependencyCycleFinderTest,
"DirectiveUtilities.EditorDependencyCycleFinderTests",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilDependencyCycleFinderTest::RunTest(const FString& Parameters)
{
TMap<FName, TArray<FName>> Graph;
Graph.Add(TEXT("/Game/A"), {TEXT("/Game/B")});
Graph.Add(TEXT("/Game/B"), {TEXT("/Game/A")});
Graph.Add(TEXT("/Game/C"), {TEXT("/Game/C")});
Graph.Add(TEXT("/Game/D"), {TEXT("/Game/E")});
Graph.Add(TEXT("/Game/E"));
const TArray<FDirectiveUtilAssetDependencyCycle> Cycles =
DirectiveUtilitiesEditor::FDependencyCycleFinder(Graph).Find();
TestEqual(TEXT("The graph contains two cycles"), Cycles.Num(), 2);
if (Cycles.Num() == 2)
{
TestEqual(TEXT("The first cycle contains two packages"), Cycles[0].Packages.Num(), 2);
TestTrue(TEXT("The first cycle contains A"), Cycles[0].Packages.Contains(TEXT("/Game/A")));
TestTrue(TEXT("The first cycle contains B"), Cycles[0].Packages.Contains(TEXT("/Game/B")));
TestEqual(TEXT("The self-cycle contains one package"), Cycles[1].Packages.Num(), 1);
TestTrue(TEXT("The self-cycle contains C"), Cycles[1].Packages.Contains(TEXT("/Game/C")));
}
constexpr int32 NodeCount = 20000;
constexpr int32 CycleStart = NodeCount / 2;
TArray<FName> Nodes;
Nodes.Reserve(NodeCount);
for (int32 Index = 0; Index < NodeCount; ++Index)
{
Nodes.Add(*FString::Printf(TEXT("/Game/Deep/%05d"), Index));
}
TMap<FName, TArray<FName>> DeepGraph;
DeepGraph.Reserve(NodeCount);
for (int32 Index = 0; Index < NodeCount - 1; ++Index)
{
DeepGraph.Add(Nodes[Index], {Nodes[Index + 1]});
}
DeepGraph.Add(Nodes.Last(), {Nodes[CycleStart]});
const TArray<FDirectiveUtilAssetDependencyCycle> DeepCycles =
DirectiveUtilitiesEditor::FDependencyCycleFinder(DeepGraph).Find();
TestEqual(TEXT("The deep graph contains one cycle"), DeepCycles.Num(), 1);
if (DeepCycles.Num() == 1)
{
TestEqual(TEXT("The deep cycle contains every connected member"), DeepCycles[0].Packages.Num(), NodeCount - CycleStart);
TestTrue(TEXT("The deep cycle starts at the expected package"), DeepCycles[0].Packages.Contains(Nodes[CycleStart]));
TestTrue(TEXT("The deep cycle ends at the expected package"), DeepCycles[0].Packages.Contains(Nodes.Last()));
}
return true;
}
#endif

View File

@@ -0,0 +1,167 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#if WITH_EDITOR
#include "Subsystems/DirectiveUtilEditorActorSubsystem.h"
#include "Components/BoxComponent.h"
#include "Engine/Engine.h"
#include "Engine/World.h"
#include "Misc/AutomationTest.h"
#include <limits>
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilEditorActorLayoutTest,
"DirectiveUtilities.EditorActorLayoutTests",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilEditorActorLayoutTest::RunTest(const FString& Parameters)
{
UWorld* World = UWorld::CreateWorld(EWorldType::Editor, false);
if (!World)
{
AddError(TEXT("Failed to create an editor world"));
return false;
}
FWorldContext& WorldContext = GEngine->CreateNewWorldContext(EWorldType::Editor);
WorldContext.SetCurrentWorld(World);
auto SpawnBox = [World](const FVector Location, const FVector Extent, const ECollisionChannel ObjectType) {
AActor* Actor = World->SpawnActor<AActor>();
Actor->ClearFlags(RF_Transient);
UBoxComponent* Box = NewObject<UBoxComponent>(Actor);
Actor->SetRootComponent(Box);
Box->SetBoxExtent(Extent);
Box->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
Box->SetCollisionObjectType(ObjectType);
Box->SetCollisionResponseToAllChannels(ECR_Block);
Box->RegisterComponent();
Actor->SetActorLocation(Location);
return Actor;
};
AActor* First = SpawnBox(FVector(0.0f, 0.0f, 100.0f), FVector(10.0f), ECC_WorldDynamic);
AActor* Middle = SpawnBox(FVector(30.0f, 20.0f, 100.0f), FVector(10.0f), ECC_WorldDynamic);
AActor* Last = SpawnBox(FVector(100.0f, 40.0f, 100.0f), FVector(10.0f), ECC_WorldDynamic);
FDirectiveUtilActorOperationResult AlignResult = UDirectiveUtilEditorActorSubsystem::AlignActors(
{First, Middle, Last},
EDirectiveUtilActorLayoutAxis::Y,
EDirectiveUtilActorAlignment::Minimum);
TestEqual(TEXT("Alignment changes two actors"), AlignResult.ChangedActors.Num(), 2);
TestTrue(TEXT("Alignment preserves X"), FMath::IsNearlyEqual(Middle->GetActorLocation().X, 30.0f));
TestTrue(TEXT("Alignment matches minimum bounds"), FMath::IsNearlyEqual(Middle->GetActorLocation().Y, 0.0f));
FDirectiveUtilActorOperationResult DistributeResult = UDirectiveUtilEditorActorSubsystem::DistributeActors(
{First, Middle, Last},
EDirectiveUtilActorLayoutAxis::X,
EDirectiveUtilActorDistribution::Centers);
TestEqual(TEXT("Distribution changes the middle actor"), DistributeResult.ChangedActors.Num(), 1);
TestTrue(TEXT("Center distribution uses equal spacing"), FMath::IsNearlyEqual(Middle->GetActorLocation().X, 50.0f));
TestTrue(TEXT("Distribution preserves Y"), FMath::IsNearlyEqual(Middle->GetActorLocation().Y, 0.0f));
AActor* Floor = SpawnBox(FVector::ZeroVector, FVector(200.0f, 200.0f, 10.0f), ECC_WorldStatic);
World->UpdateWorldComponents(true, false);
const FVector BeforeInvalidSnap = Middle->GetActorLocation();
const FDirectiveUtilActorOperationResult InvalidDistanceResult = UDirectiveUtilEditorActorSubsystem::SnapActorsToSurface(
{Middle},
FVector::DownVector,
std::numeric_limits<float>::quiet_NaN(),
ECC_Visibility,
EDirectiveUtilSurfacePlacement::Pivot,
false);
TestTrue(TEXT("Surface snapping skips a non-finite distance"), InvalidDistanceResult.SkippedActors.Contains(Middle));
TestTrue(TEXT("A non-finite distance preserves the actor transform"), Middle->GetActorLocation().Equals(BeforeInvalidSnap));
const FDirectiveUtilActorOperationResult InvalidDirectionResult = UDirectiveUtilEditorActorSubsystem::SnapActorsToSurface(
{Middle},
FVector(std::numeric_limits<float>::infinity(), 0.0f, -1.0f),
500.0f,
ECC_Visibility,
EDirectiveUtilSurfacePlacement::Pivot,
false);
TestTrue(TEXT("Surface snapping skips a non-finite direction"), InvalidDirectionResult.SkippedActors.Contains(Middle));
const FDirectiveUtilActorOperationResult InvalidChannelResult = UDirectiveUtilEditorActorSubsystem::SnapActorsToSurface(
{Middle},
FVector::DownVector,
500.0f,
static_cast<ECollisionChannel>(ECC_MAX),
EDirectiveUtilSurfacePlacement::Pivot,
false);
TestTrue(TEXT("Surface snapping skips an invalid collision channel"), InvalidChannelResult.SkippedActors.Contains(Middle));
FDirectiveUtilActorOperationResult SnapResult = UDirectiveUtilEditorActorSubsystem::SnapActorsToSurface(
{Middle},
FVector::DownVector,
500.0f,
ECC_Visibility,
EDirectiveUtilSurfacePlacement::Pivot,
false);
TestEqual(TEXT("Surface snapping changes the actor"), SnapResult.ChangedActors.Num(), 1);
TestTrue(TEXT("Pivot snapping reaches the floor surface"), FMath::IsNearlyEqual(Middle->GetActorLocation().Z, 10.0f, 0.1f));
AActor* BoundsActor = SpawnBox(FVector(150.0f, 150.0f, 100.0f), FVector(10.0f), ECC_WorldDynamic);
FDirectiveUtilActorOperationResult BoundsSnapResult = UDirectiveUtilEditorActorSubsystem::SnapActorsToSurface(
{BoundsActor},
FVector::DownVector,
500.0f,
ECC_Visibility,
EDirectiveUtilSurfacePlacement::Bounds,
false);
TestEqual(TEXT("Bounds snapping changes the actor"), BoundsSnapResult.ChangedActors.Num(), 1);
TestTrue(TEXT("Bounds snapping places the lower bound on the surface"), FMath::IsNearlyEqual(BoundsActor->GetActorLocation().Z, 20.0f, 0.1f));
AActor* NormalActor = SpawnBox(FVector(-150.0f, -150.0f, 100.0f), FVector(10.0f), ECC_WorldDynamic);
NormalActor->SetActorRotation(FRotator(45.0f, 0.0f, 0.0f));
FDirectiveUtilActorOperationResult NormalSnapResult = UDirectiveUtilEditorActorSubsystem::SnapActorsToSurface(
{NormalActor},
FVector::DownVector,
500.0f,
ECC_Visibility,
EDirectiveUtilSurfacePlacement::Pivot,
true);
TestEqual(TEXT("Normal-aligned snapping changes the actor"), NormalSnapResult.ChangedActors.Num(), 1);
TestTrue(TEXT("Normal-aligned snapping points the actor up from the surface"), NormalActor->GetActorUpVector().Equals(FVector::UpVector, 0.01f));
AActor* MissActor = SpawnBox(FVector(500.0f, 500.0f, 100.0f), FVector(10.0f), ECC_WorldDynamic);
FDirectiveUtilActorOperationResult MissResult = UDirectiveUtilEditorActorSubsystem::SnapActorsToSurface(
{MissActor},
FVector::DownVector,
500.0f,
ECC_Visibility,
EDirectiveUtilSurfacePlacement::Pivot,
false);
TestTrue(TEXT("Surface snapping reports collision misses as skipped"), MissResult.SkippedActors.Contains(MissActor));
Floor->Destroy();
AActor* Ceiling = SpawnBox(FVector(0.0f, 0.0f, 50.0f), FVector(20.0f, 20.0f, 10.0f), ECC_WorldStatic);
World->UpdateWorldComponents(true, false);
AActor* ActorWithoutRoot = World->SpawnActor<AActor>();
ActorWithoutRoot->ClearFlags(RF_Transient);
FDirectiveUtilActorOperationResult FailedMoveResult = UDirectiveUtilEditorActorSubsystem::SnapActorsToSurface(
{ActorWithoutRoot},
FVector::UpVector,
500.0f,
ECC_Visibility,
EDirectiveUtilSurfacePlacement::Pivot,
false);
TestTrue(TEXT("Surface snapping reports a failed transform as skipped"), FailedMoveResult.SkippedActors.Contains(ActorWithoutRoot));
TestFalse(TEXT("A failed transform is not reported as changed"), FailedMoveResult.ChangedActors.Contains(ActorWithoutRoot));
AActor* TransientActor = SpawnBox(FVector::ZeroVector, FVector(10.0f), ECC_WorldDynamic);
TransientActor->SetFlags(RF_Transient);
FDirectiveUtilActorOperationResult InvalidResult = UDirectiveUtilEditorActorSubsystem::AlignActors(
{First, TransientActor, nullptr},
EDirectiveUtilActorLayoutAxis::X,
EDirectiveUtilActorAlignment::Center);
TestTrue(TEXT("Transient actors are skipped"), InvalidResult.SkippedActors.Contains(TransientActor));
Ceiling->Destroy();
GEngine->DestroyWorldContext(World);
World->DestroyWorld(false);
return true;
}
#endif

View File

@@ -1,3 +1,5 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#if WITH_EDITOR
#include "Subsystems/DirectiveUtilEditorActorSubsystem.h"
@@ -5,17 +7,23 @@
#include "Misc/AutomationTest.h"
#include "Engine/StaticMesh.h"
#include "Engine/StaticMeshActor.h"
#include "Engine/Texture2D.h"
#include "Components/StaticMeshComponent.h"
#include "Components/BoxComponent.h"
#include "Components/CapsuleComponent.h"
#include "Materials/Material.h"
#include "Materials/MaterialExpressionTextureSample.h"
#include "Materials/MaterialInterface.h"
#include "Engine/World.h"
#include "Engine/Engine.h"
#include "UObject/Package.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilEditorActorSubsystemTest, "DirectiveUtilities.EditorActorSubsystemTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilEditorActorSubsystemTest::RunTest(const FString& Parameters)
{
UDirectiveUtilEditorActorSubsystem::FocusActorsInViewport({nullptr});
// IsActorWithinBoxBounds should not crash and should return false with null Actor
TestFalse("IsActorWithinBoxBounds should return false with null Actor",
UDirectiveUtilEditorActorSubsystem::IsActorWithinBoxBounds(nullptr, nullptr));
@@ -60,8 +68,8 @@ bool FDirectiveUtilEditorActorSubsystemFilterTest::RunTest(const FString& Parame
UStaticMesh* Sphere = LoadObject<UStaticMesh>(nullptr, TEXT("/Engine/BasicShapes/Sphere.Sphere"));
if (!Cube || !Sphere)
{
AddInfo(TEXT("Engine basic shapes unavailable; skipping include/exclude behaviour test."));
return true;
AddError(TEXT("Engine basic shapes unavailable for the include/exclude behaviour test."));
return false;
}
UWorld* World = UWorld::CreateWorld(EWorldType::Editor, false);
@@ -126,8 +134,8 @@ bool FDirectiveUtilEditorActorSubsystemFilterCoverageTest::RunTest(const FString
UMaterialInterface* MatB = LoadObject<UMaterialInterface>(nullptr, TEXT("/Engine/EngineMaterials/WorldGridMaterial.WorldGridMaterial"));
if (!Cube || !Sphere || !MatA || !MatB)
{
AddInfo(TEXT("Engine basic shapes/materials unavailable; skipping filter coverage test."));
return true;
AddError(TEXT("Engine basic shapes/materials unavailable for the filter coverage test."));
return false;
}
UWorld* World = UWorld::CreateWorld(EWorldType::Editor, false);
@@ -257,6 +265,31 @@ bool FDirectiveUtilEditorActorSubsystemFilterCoverageTest::RunTest(const FString
TestTrue("ByBounds around A's size => A", RunFilter([&](const TArray<AActor*>& S, TArray<AActor*>& O){ UDirectiveUtilEditorActorSubsystem::FilterActorsByBounds(S, O, Size - FVector(1.0f), Size + FVector(1.0f), Include); }).Contains(ActorA));
}
// Static-mesh bounds use the source mesh dimensions, independently of actor transform.
{
const FVector MeshSize = Cube->GetBounds().BoxExtent * 2.0f;
const TArray<AActor*> Match = RunFilter([&](const TArray<AActor*>& S, TArray<AActor*>& O)
{
UDirectiveUtilEditorActorSubsystem::FilterActorsByStaticMeshBounds(
S, O, MeshSize - FVector(1.0f), MeshSize + FVector(1.0f), Include);
});
TestTrue("ByStaticMeshBounds includes the cube actor", Match.Contains(ActorA));
TestFalse("ByStaticMeshBounds excludes the actor without a mesh", Match.Contains(ActorC));
}
// Override lightmap resolution is a distinct search lane from the mesh default.
{
CompA->bOverrideLightMapRes = true;
CompA->OverriddenLightMapRes = 128;
const TArray<AActor*> Match = RunFilter([](const TArray<AActor*>& S, TArray<AActor*>& O)
{
UDirectiveUtilEditorActorSubsystem::FilterActorsByLightmapResolution(
S, O, 128, 128, OverrideOnly, Include);
});
TestTrue("ByLightmapResolution finds the exact override", Match.Contains(ActorA));
TestFalse("ByLightmapResolution rejects a different override", Match.Contains(ActorB));
}
// World location: A at origin, B far away.
{
const TArray<AActor*> Near = RunFilter([](const TArray<AActor*>& S, TArray<AActor*>& O){ UDirectiveUtilEditorActorSubsystem::FilterActorsByWorldLocation(S, O, FVector::ZeroVector, 50.0f, Include); });
@@ -269,6 +302,67 @@ bool FDirectiveUtilEditorActorSubsystemFilterCoverageTest::RunTest(const FString
TestEqual("ByTextureName(absent) Exclude => all", RunFilter([](const TArray<AActor*>& S, TArray<AActor*>& O){ UDirectiveUtilEditorActorSubsystem::FilterActorsByTextureName(S, O, TEXT("__udcore_absent_texture__"), BaseAndOverride, Exclude); }).Num(), 3);
}
// Texture-reference filtering must follow the same include/exclude contract.
{
UTexture2D* AbsentTexture = NewObject<UTexture2D>(GetTransientPackage());
const TArray<AActor*> Inc = RunFilter([&](const TArray<AActor*>& S, TArray<AActor*>& O)
{
UDirectiveUtilEditorActorSubsystem::FilterActorsByTexture(S, O, AbsentTexture, BaseAndOverride, Include);
});
const TArray<AActor*> Exc = RunFilter([&](const TArray<AActor*>& S, TArray<AActor*>& O)
{
UDirectiveUtilEditorActorSubsystem::FilterActorsByTexture(S, O, AbsentTexture, BaseAndOverride, Exclude);
});
TestEqual("ByTexture(absent) Include returns none", Inc.Num(), 0);
TestEqual("ByTexture(absent) Exclude returns every valid source actor", Exc.Num(), Src.Num());
}
// Missing-resource convenience filters are semantic aliases, not merely spawn-tested nodes.
{
UStaticMesh* MissingMaterialMesh = DuplicateObject<UStaticMesh>(Cube, GetTransientPackage());
MissingMaterialMesh->SetMaterial(0, nullptr);
UStaticMeshComponent* MissingMaterialComp = SpawnMesh(MissingMaterialMesh, EComponentMobility::Static);
UStaticMeshComponent* MissingMeshComp = SpawnMesh(nullptr, EComponentMobility::Static);
const TArray<AActor*> MissingSource = { MissingMaterialComp->GetOwner(), MissingMeshComp->GetOwner(), ActorA };
TArray<AActor*> MissingMaterials;
UDirectiveUtilEditorActorSubsystem::FilterActorsByMissingMaterials(
MissingSource, MissingMaterials, BaseOnly, Include);
TestTrue("MissingMaterials finds the null source material", MissingMaterials.Contains(MissingMaterialComp->GetOwner()));
TestFalse("MissingMaterials rejects the valid cube material", MissingMaterials.Contains(ActorA));
TArray<AActor*> MissingMeshes;
UDirectiveUtilEditorActorSubsystem::FilterActorsByMissingStaticMeshes(MissingSource, MissingMeshes, Include);
TestTrue("MissingStaticMeshes finds a mesh component with no mesh", MissingMeshes.Contains(MissingMeshComp->GetOwner()));
TestFalse("MissingStaticMeshes rejects a valid mesh", MissingMeshes.Contains(ActorA));
}
{
UMaterial* MissingTextureMaterial = NewObject<UMaterial>(GetTransientPackage());
UMaterialExpressionTextureSample* MissingTextureExpression = NewObject<UMaterialExpressionTextureSample>(MissingTextureMaterial);
MissingTextureMaterial->GetExpressionCollection().AddExpression(MissingTextureExpression);
CompA->SetMaterial(0, MissingTextureMaterial);
const TArray<AActor*> MissingTextures = RunFilter([](const TArray<AActor*>& S, TArray<AActor*>& O)
{
UDirectiveUtilEditorActorSubsystem::FilterActorsByMissingTextures(S, O, OverrideOnly, Include);
});
TestTrue("Missing texture filter should include an actor with an unset texture expression", MissingTextures.Contains(ActorA));
TestFalse("Missing texture filter should exclude actors without unset texture expressions", MissingTextures.Contains(ActorB));
CompA->SetMaterial(0, MatA);
}
{
AActor* DestroyedActor = World->SpawnActor<AActor>();
World->DestroyActor(DestroyedActor);
TestFalse("Destroyed actor should be invalid", IsValid(DestroyedActor));
TArray<AActor*> InvalidActors = {DestroyedActor};
TArray<AActor*> FilteredInvalidActors;
UDirectiveUtilEditorActorSubsystem::FilterActorsByTag(
InvalidActors,
FilteredInvalidActors,
TEXT("Absent"),
Exclude);
TestEqual("Actor filters should skip invalid actors", FilteredInvalidActors.Num(), 0);
}
GEngine->DestroyWorldContext(World);
World->DestroyWorld(false);
@@ -327,10 +421,12 @@ IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilEditorActorSubsystemQueryAlignmen
bool FDirectiveUtilEditorActorSubsystemQueryAlignmentTest::RunTest(const FString& Parameters)
{
UStaticMesh* Cube = LoadObject<UStaticMesh>(nullptr, TEXT("/Engine/BasicShapes/Cube.Cube"));
if (!Cube)
UMaterialInterface* BasicMaterial = LoadObject<UMaterialInterface>(
nullptr, TEXT("/Engine/BasicShapes/BasicShapeMaterial.BasicShapeMaterial"));
if (!Cube || !BasicMaterial)
{
AddInfo(TEXT("Engine basic shapes unavailable; skipping query alignment test."));
return true;
AddError(TEXT("Engine basic shapes or materials unavailable for the query alignment test."));
return false;
}
// The GetActorsBy* queries read from the editor world rather than a passed array.
@@ -345,8 +441,8 @@ bool FDirectiveUtilEditorActorSubsystemQueryAlignmentTest::RunTest(const FString
}
if (!EditorWorld)
{
AddInfo(TEXT("No editor world available; skipping query alignment test."));
return true;
AddError(TEXT("No editor world available for the query alignment test."));
return false;
}
AActor* MeshActor = EditorWorld->SpawnActor<AActor>();
@@ -358,6 +454,7 @@ bool FDirectiveUtilEditorActorSubsystemQueryAlignmentTest::RunTest(const FString
MeshComponent->SetMobility(EComponentMobility::Movable);
MeshComponent->SetupAttachment(Root);
MeshComponent->SetStaticMesh(Cube);
MeshComponent->SetMaterial(0, BasicMaterial);
MeshComponent->RegisterComponent();
MeshActor->AddInstanceComponent(MeshComponent);
MeshActor->SetActorLocation(FVector::ZeroVector);
@@ -365,6 +462,10 @@ bool FDirectiveUtilEditorActorSubsystemQueryAlignmentTest::RunTest(const FString
// The query methods keep no instance state, so a transient instance is enough headless.
UDirectiveUtilEditorActorSubsystem* Subsystem = NewObject<UDirectiveUtilEditorActorSubsystem>();
TArray<AActor*> ByClass;
Subsystem->GetActorsByClass(ByClass, AActor::StaticClass(), World, Include);
TestTrue("Class: AActor query finds the fixture actor", ByClass.Contains(MeshActor));
// Bounding box: an enclosing box finds the actor, a disjoint one does not.
TArray<AActor*> InBox;
Subsystem->GetActorsByBoundingBox(InBox, FVector(-100000.0f), FVector(100000.0f), World, Include);
@@ -378,6 +479,81 @@ bool FDirectiveUtilEditorActorSubsystemQueryAlignmentTest::RunTest(const FString
Subsystem->GetActorsByStaticMeshName(ByName, TEXT("cub"), World, Include);
TestTrue("StaticMeshName: lowercase substring finds the actor", ByName.Contains(MeshActor));
TArray<AActor*> ByMaterial;
Subsystem->GetActorsByMaterial(ByMaterial, BasicMaterial, OverrideOnly, World, Include);
TestTrue("Material: generic actor with a static mesh component is included", ByMaterial.Contains(MeshActor));
TArray<AActor*> ByMaterialSoftReference;
Subsystem->GetActorsByMaterialSoftReference(
ByMaterialSoftReference, BasicMaterial, OverrideOnly, World, Include);
TestTrue("Material soft reference: generic actor with a static mesh component is included",
ByMaterialSoftReference.Contains(MeshActor));
TArray<AActor*> ByMaterialName;
Subsystem->GetActorsByMaterialName(
ByMaterialName, BasicMaterial->GetName(), OverrideOnly, World, Include);
TestTrue("Material name: generic actor with a static mesh component is included",
ByMaterialName.Contains(MeshActor));
TArray<AActor*> WithoutMaterial;
Subsystem->GetActorsByMaterial(WithoutMaterial, BasicMaterial, OverrideOnly, World, Exclude);
TestFalse("Material exclude: matching generic actor is excluded", WithoutMaterial.Contains(MeshActor));
const int32 VertexCount = Cube->GetNumVertices(0);
TArray<AActor*> ByVertexCount;
Subsystem->GetActorsByVertexCount(ByVertexCount, VertexCount, VertexCount, World, Include);
TestTrue("VertexCount: exact range finds the fixture actor", ByVertexCount.Contains(MeshActor));
const int32 TriangleCount = Cube->GetNumTriangles(0);
TArray<AActor*> ByTriangleCount;
Subsystem->GetActorsByTriCount(ByTriangleCount, TriangleCount, TriangleCount, World, Include);
TestTrue("TriangleCount: exact range finds the fixture actor", ByTriangleCount.Contains(MeshActor));
const float MeshSize = Cube->GetBoundingBox().GetSize().Size();
TArray<AActor*> ByMeshSize;
Subsystem->GetActorsByMeshSize(ByMeshSize, MeshSize - 1.0f, MeshSize + 1.0f, World, Include);
TestTrue("MeshSize: enclosing range finds the fixture actor", ByMeshSize.Contains(MeshActor));
TArray<AActor*> ByWorldLocation;
Subsystem->GetActorsByWorldLocation(ByWorldLocation, FVector::ZeroVector, 100.0f, World, Include);
TestTrue("WorldLocation: nearby query finds the fixture actor", ByWorldLocation.Contains(MeshActor));
const int32 LODCount = Cube->GetNumLODs();
TArray<AActor*> ByLODCount;
Subsystem->GetActorsByLODCount(ByLODCount, LODCount, LODCount, World, Include);
TestTrue("LODCount: exact range finds the fixture actor", ByLODCount.Contains(MeshActor));
TArray<AActor*> NaniteOff;
TArray<AActor*> NaniteOn;
Subsystem->GetActorsByNaniteEnabled(NaniteOff, false, World, Include);
Subsystem->GetActorsByNaniteEnabled(NaniteOn, true, World, Include);
TestTrue("Nanite: fixture belongs to exactly one state", NaniteOff.Contains(MeshActor) != NaniteOn.Contains(MeshActor));
const int32 SourceLightmapResolution = Cube->GetLightMapResolution();
TArray<AActor*> ByLightmapResolution;
Subsystem->GetActorsByLightmapResolution(
ByLightmapResolution, SourceLightmapResolution, SourceLightmapResolution, World, Include);
TestTrue("LightmapResolution: exact source resolution finds the fixture actor", ByLightmapResolution.Contains(MeshActor));
TArray<AActor*> ByMobility;
Subsystem->GetActorsByMobility(ByMobility, EComponentMobility::Movable, World, Include);
TestTrue("Mobility query finds the movable fixture actor", ByMobility.Contains(MeshActor));
TArray<AActor*> ByStaticMesh;
Subsystem->GetActorsByStaticMesh(ByStaticMesh, Cube, World, Include);
TestTrue("StaticMesh reference query finds the fixture actor", ByStaticMesh.Contains(MeshActor));
TArray<AActor*> ByStaticMeshSoft;
Subsystem->GetActorsByStaticMeshSoftReference(ByStaticMeshSoft, Cube, World, Include);
TestTrue("StaticMesh soft-reference query finds the fixture actor", ByStaticMeshSoft.Contains(MeshActor));
UTexture2D* AbsentTexture = NewObject<UTexture2D>(GetTransientPackage());
TArray<AActor*> ByTexture;
Subsystem->GetActorsByTexture(ByTexture, AbsentTexture, World, Include);
TestFalse("Texture reference query rejects an absent texture", ByTexture.Contains(MeshActor));
TArray<AActor*> ByTextureSoft;
Subsystem->GetActorsByTextureSoftReference(ByTextureSoft, AbsentTexture, World, Include);
TestFalse("Texture soft-reference query rejects an absent texture", ByTextureSoft.Contains(MeshActor));
TArray<AActor*> ByTextureName;
Subsystem->GetActorsByTextureName(ByTextureName, TEXT("__udcore_absent_texture__"), World, Include);
TestFalse("Texture-name query rejects an absent texture", ByTextureName.Contains(MeshActor));
TArray<AActor*> InvalidActors = { MeshActor };
Subsystem->GetInvalidActors(InvalidActors);
TestEqual("GetInvalidActors resets its deprecated output", InvalidActors.Num(), 0);
// Mobility: the root component's mobility is what counts.
const TArray<AActor*> Source = { MeshActor };
TArray<AActor*> MovableActors;
@@ -392,6 +568,47 @@ bool FDirectiveUtilEditorActorSubsystemQueryAlignmentTest::RunTest(const FString
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilPushOverrideMaterialsTest, "DirectiveUtilities.PushOverrideMaterialsTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilPushOverrideMaterialsTest::RunTest(const FString& Parameters)
{
UMaterialInterface* BaseMaterial = LoadObject<UMaterialInterface>(
nullptr, TEXT("/Engine/BasicShapes/BasicShapeMaterial.BasicShapeMaterial"));
UMaterialInterface* OverrideMaterial = LoadObject<UMaterialInterface>(
nullptr, TEXT("/Engine/EngineMaterials/WorldGridMaterial.WorldGridMaterial"));
UStaticMesh* Cube = LoadObject<UStaticMesh>(nullptr, TEXT("/Engine/BasicShapes/Cube.Cube"));
if (!BaseMaterial || !OverrideMaterial || !Cube)
{
AddError(TEXT("Engine materials unavailable for the override material test."));
return false;
}
UStaticMesh* StaticMesh = DuplicateObject<UStaticMesh>(Cube, GetTransientPackage());
StaticMesh->GetStaticMaterials()[0].MaterialInterface = BaseMaterial;
UStaticMeshComponent* StaticMeshComponent = NewObject<UStaticMeshComponent>(GetTransientPackage());
StaticMeshComponent->SetStaticMesh(StaticMesh);
StaticMeshComponent->SetMaterial(0, OverrideMaterial);
UDirectiveUtilEditorActorSubsystem::PushOverrideMaterialsToSource(StaticMeshComponent);
TestEqual("Override material is copied to its source slot", StaticMesh->GetMaterial(0), OverrideMaterial);
UStaticMesh* MeshWithoutOverride = DuplicateObject<UStaticMesh>(Cube, GetTransientPackage());
MeshWithoutOverride->GetStaticMaterials()[0].MaterialInterface = BaseMaterial;
UStaticMeshComponent* ComponentWithoutOverride = NewObject<UStaticMeshComponent>(GetTransientPackage());
ComponentWithoutOverride->SetStaticMesh(MeshWithoutOverride);
UDirectiveUtilEditorActorSubsystem::PushOverrideMaterialsToSource(ComponentWithoutOverride);
TestEqual("A source slot is unchanged when the component has no override",
MeshWithoutOverride->GetMaterial(0), BaseMaterial);
AddExpectedError(TEXT("Static Mesh Component is invalid."), EAutomationExpectedErrorFlags::Exact, 1);
UDirectiveUtilEditorActorSubsystem::PushOverrideMaterialsToSource(nullptr);
UStaticMeshComponent* ComponentWithoutMesh = NewObject<UStaticMeshComponent>(GetTransientPackage());
AddExpectedError(TEXT("Static Mesh Component has no valid static mesh."), EAutomationExpectedErrorFlags::Exact, 1);
UDirectiveUtilEditorActorSubsystem::PushOverrideMaterialsToSource(ComponentWithoutMesh);
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilEditorActorSubsystemBoundsTest, "DirectiveUtilities.EditorActorSubsystemBoundsTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilEditorActorSubsystemBoundsTest::RunTest(const FString& Parameters)

View File

@@ -0,0 +1,65 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#if WITH_EDITOR
#include "AssetRegistry/IAssetRegistry.h"
#include "Libraries/DirectiveUtilEditorAssetAuditLibrary.h"
#include "Misc/AutomationTest.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilEditorAssetAuditLibraryTest,
"DirectiveUtilities.EditorAssetAuditLibraryTests",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilEditorAssetAuditLibraryTest::RunTest(const FString& Parameters)
{
FDirectiveUtilAssetAuditOptions Options;
Options.PackagePaths = {TEXT("/Engine/BasicShapes")};
const FDirectiveUtilAssetAuditReport Report = UDirectiveUtilEditorAssetAuditLibrary::BuildAssetAuditReport(Options);
TestTrue(TEXT("The engine shape scan returns assets"), !Report.Assets.IsEmpty());
if (const IAssetRegistry* AssetRegistry = IAssetRegistry::Get())
{
TestTrue(TEXT("An asset audit starts the initial registry scan"), AssetRegistry->IsSearchAllAssets());
}
for (const FDirectiveUtilAssetAuditEntry& Entry : Report.Assets)
{
TestTrue(TEXT("Report assets have valid data"), Entry.Asset.IsValid());
TestTrue(TEXT("Report assets have a package"), !Entry.PackageName.IsNone());
TestTrue(TEXT("Report assets have a class"), !Entry.AssetClass.IsEmpty());
TestTrue(TEXT("Report counts are non-negative"), Entry.DependencyCount >= 0 && Entry.ReferencerCount >= 0);
}
const FString Csv = UDirectiveUtilEditorAssetAuditLibrary::AssetAuditReportToCsv(Report);
TestTrue(TEXT("CSV includes its header"), Csv.StartsWith(TEXT("Asset,Package,Path,Class")));
TestTrue(TEXT("CSV includes scanned assets"), Csv.Contains(TEXT("/Engine/BasicShapes")));
Options.ExcludedPackagePaths = {TEXT("/Engine/BasicShapes")};
TestTrue(
TEXT("Excluded paths return no candidates"),
UDirectiveUtilEditorAssetAuditLibrary::FindUnreferencedAssetCandidates(Options).IsEmpty());
TestTrue(
TEXT("Excluded paths return no missing references"),
UDirectiveUtilEditorAssetAuditLibrary::FindMissingAssetReferences(Options).IsEmpty());
TestTrue(
TEXT("Excluded paths return no cycles"),
UDirectiveUtilEditorAssetAuditLibrary::FindAssetDependencyCycles(Options).IsEmpty());
FDirectiveUtilAssetAuditOptions EngineOptions;
EngineOptions.PackagePaths = {TEXT("/Engine")};
const FDirectiveUtilAssetAuditReport EngineReport = UDirectiveUtilEditorAssetAuditLibrary::BuildAssetAuditReport(EngineOptions);
TestTrue(TEXT("A full engine audit returns more than the basic shapes scan"), EngineReport.Assets.Num() > Report.Assets.Num());
for (const FDirectiveUtilAssetDependencyCycle& Cycle : EngineReport.DependencyCycles)
{
TestTrue(TEXT("Dependency cycles contain at least one package"), !Cycle.Packages.IsEmpty());
for (int32 Index = 1; Index < Cycle.Packages.Num(); ++Index)
{
TestTrue(TEXT("Dependency cycle packages are sorted"), Cycle.Packages[Index - 1].LexicalLess(Cycle.Packages[Index]));
}
}
return true;
}
#endif

View File

@@ -1,8 +1,18 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#if WITH_EDITOR
#include "AssetRegistry/AssetData.h"
#include "AssetRegistry/AssetRegistryModule.h"
#include "Editor.h"
#include "Libraries/DirectiveUtilEditorAssetLibrary.h"
#include "Engine/World.h"
#include "Misc/AutomationTest.h"
#include "Misc/Guid.h"
#include "ObjectTools.h"
#include "Subsystems/EditorAssetSubsystem.h"
#include "Tests/DirectiveUtilTestObject.h"
#include "UObject/ObjectRedirector.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilEditorAssetLibraryTest, "DirectiveUtilities.EditorAssetLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
@@ -45,6 +55,78 @@ bool FDirectiveUtilEditorAssetLibraryTest::RunTest(const FString& Parameters)
TestEqual("FindDuplicateAssets should return each asset path once", UniquePaths.Num(), Pair.Value.DuplicateAssetPaths.Num());
}
int32 RedirectorsProcessed = INDEX_NONE;
TestEqual("FixUpRedirectorsInPaths succeeds when no redirectors match",
UDirectiveUtilEditorAssetLibrary::FixUpRedirectorsInPaths(
{TEXT("/Game/DirectiveUtilitiesTests/NoRedirectors")}, RedirectorsProcessed),
EDirectiveUtilSuccessStatus::Success);
TestEqual("No redirectors are reported for an empty path", RedirectorsProcessed, 0);
return true;
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilFixUpRedirectorsTest, "DirectiveUtilities.FixUpRedirectorsTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilFixUpRedirectorsTest::RunTest(const FString& Parameters)
{
UEditorAssetSubsystem* EditorAssetSubsystem = GEditor
? GEditor->GetEditorSubsystem<UEditorAssetSubsystem>()
: nullptr;
if (!EditorAssetSubsystem)
{
AddError(TEXT("Editor asset subsystem unavailable for the redirector test."));
return false;
}
const FString RootPath = FString::Printf(
TEXT("/Game/DirectiveUtilitiesTests/Redirectors_%s"),
*FGuid::NewGuid().ToString(EGuidFormats::Digits));
const FString OriginalName = TEXT("OriginalAsset");
const FString OriginalPackageName = RootPath / OriginalName;
UPackage* Package = CreatePackage(*OriginalPackageName);
UDirectiveUtilTestObject* Asset = NewObject<UDirectiveUtilTestObject>(
Package, *OriginalName, RF_Public | RF_Standalone);
FAssetRegistryModule::AssetCreated(Asset);
const FString RenamedAssetName = TEXT("RenamedAsset");
ObjectTools::FPackageGroupName PackageGroupName;
PackageGroupName.PackageName = RootPath / RenamedAssetName;
PackageGroupName.ObjectName = RenamedAssetName;
TSet<UPackage*> RefusedPackages;
FText RenameError;
if (!ObjectTools::RenameSingleObject(
Asset, PackageGroupName, RefusedPackages, RenameError, nullptr, true))
{
EditorAssetSubsystem->DeleteDirectory(RootPath);
AddError(FString::Printf(TEXT("Failed to rename the redirector test asset: %s"), *RenameError.ToString()));
return false;
}
const FString OriginalObjectPath = OriginalPackageName + TEXT(".") + OriginalName;
UObjectRedirector* Redirector = FindObject<UObjectRedirector>(nullptr, *OriginalObjectPath);
if (!Redirector)
{
EditorAssetSubsystem->DeleteDirectory(RootPath);
AddError(TEXT("Renaming the test asset did not create a redirector."));
return false;
}
TestNotNull("Renaming an asset creates a redirector", Redirector);
AddExpectedMessagePlain(
TEXT("Redirector fix-up requires an interactive editor session."),
ELogVerbosity::Warning,
EAutomationExpectedMessageFlags::Contains,
1);
int32 RedirectorsProcessed = INDEX_NONE;
const EDirectiveUtilSuccessStatus Status = UDirectiveUtilEditorAssetLibrary::FixUpRedirectorsInPaths(
{RootPath}, RedirectorsProcessed);
TestEqual("Redirector fix-up fails closed in unattended runs", Status, EDirectiveUtilSuccessStatus::Failure);
TestEqual("No redirector is reported as processed after a guarded run", RedirectorsProcessed, 0);
TestTrue("Guarded fix-up leaves the redirector intact", EditorAssetSubsystem->DoesAssetExist(OriginalObjectPath));
TestTrue("Renamed asset remains available", EditorAssetSubsystem->DoesAssetExist(
RootPath / RenamedAssetName + TEXT(".") + RenamedAssetName));
TestTrue("Redirector test assets are removed", EditorAssetSubsystem->DeleteDirectory(RootPath));
return true;
}

View File

@@ -0,0 +1,191 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#if WITH_EDITOR
#include "AssetRegistry/AssetRegistryModule.h"
#include "Components/StaticMeshComponent.h"
#include "Libraries/DirectiveUtilEditorBlueprintLibrary.h"
#include "Misc/AutomationTest.h"
#include "Tests/DirectiveUtilTestObject.h"
#include "EdGraphSchema_K2.h"
#include "Engine/Blueprint.h"
#include "Engine/SCS_Node.h"
#include "Engine/SimpleConstructionScript.h"
#include "GameFramework/Actor.h"
#include "Kismet2/BlueprintEditorUtils.h"
#include "Kismet2/KismetEditorUtilities.h"
#include "Misc/Guid.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilEditorBlueprintLibraryTest,
"DirectiveUtilities.EditorBlueprintLibraryTests",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilEditorBlueprintLibraryTest::RunTest(const FString& Parameters)
{
auto ContainsBlueprint = [](const TArray<FAssetData>& Assets, const UBlueprint* Expected) {
return Assets.ContainsByPredicate([Expected](const FAssetData& Asset) {
return Asset.GetAsset() == Expected;
});
};
TestEqual(
TEXT("A null Blueprint has unknown status"),
UDirectiveUtilEditorBlueprintLibrary::GetBlueprintCompileStatus(nullptr),
EDirectiveUtilBlueprintCompileStatus::Unknown);
UBlueprint* Blueprint = NewObject<UBlueprint>();
const TArray<TPair<EBlueprintStatus, EDirectiveUtilBlueprintCompileStatus>> Statuses = {
{BS_Unknown, EDirectiveUtilBlueprintCompileStatus::Unknown},
{BS_Dirty, EDirectiveUtilBlueprintCompileStatus::Dirty},
{BS_Error, EDirectiveUtilBlueprintCompileStatus::Error},
{BS_UpToDate, EDirectiveUtilBlueprintCompileStatus::UpToDate},
{BS_BeingCreated, EDirectiveUtilBlueprintCompileStatus::BeingCreated},
{BS_UpToDateWithWarnings, EDirectiveUtilBlueprintCompileStatus::UpToDateWithWarnings},
};
for (const TPair<EBlueprintStatus, EDirectiveUtilBlueprintCompileStatus>& Status : Statuses)
{
Blueprint->Status = Status.Key;
TestEqual(
TEXT("Blueprint status maps to the public enum"),
UDirectiveUtilEditorBlueprintLibrary::GetBlueprintCompileStatus(Blueprint),
Status.Value);
}
FDirectiveUtilBlueprintSearchOptions Options;
Options.PackagePaths = {TEXT("/Engine/BasicShapes")};
TestTrue(
TEXT("A folder without Blueprints has no compile-status matches"),
UDirectiveUtilEditorBlueprintLibrary::FindBlueprintsByCompileStatus(
EDirectiveUtilBlueprintCompileStatus::UpToDate,
Options).IsEmpty());
TestTrue(
TEXT("A null parent class returns no matches"),
UDirectiveUtilEditorBlueprintLibrary::FindBlueprintsByParentClass(nullptr, Options).IsEmpty());
TestTrue(
TEXT("A folder without Blueprints has no parent-class matches"),
UDirectiveUtilEditorBlueprintLibrary::FindBlueprintsByParentClass(AActor::StaticClass(), Options).IsEmpty());
TestTrue(
TEXT("A non-interface class returns no interface matches"),
UDirectiveUtilEditorBlueprintLibrary::FindBlueprintsImplementingInterface(UObject::StaticClass(), Options).IsEmpty());
TestTrue(
TEXT("A folder without Blueprints has no interface matches"),
UDirectiveUtilEditorBlueprintLibrary::FindBlueprintsImplementingInterface(UInterface::StaticClass(), Options).IsEmpty());
TestTrue(
TEXT("A non-component class returns no component matches"),
UDirectiveUtilEditorBlueprintLibrary::FindBlueprintsContainingComponentClass(UObject::StaticClass(), Options).IsEmpty());
TestTrue(
TEXT("A folder without Blueprints has no component matches"),
UDirectiveUtilEditorBlueprintLibrary::FindBlueprintsContainingComponentClass(UActorComponent::StaticClass(), Options).IsEmpty());
TestTrue(
TEXT("A Blueprint without generated properties has no unused variables"),
UDirectiveUtilEditorBlueprintLibrary::GetUnusedBlueprintVariables(Blueprint).IsEmpty());
const FName FixtureName(*FString::Printf(
TEXT("BP_InspectionFixture_%s"),
*FGuid::NewGuid().ToString(EGuidFormats::Digits)));
const FString FixturePackageName = FString::Printf(
TEXT("/Game/DirectiveUtilitiesTests/%s"),
*FixtureName.ToString());
UPackage* TestPackage = CreatePackage(*FixturePackageName);
UBlueprint* InspectionBlueprint = FKismetEditorUtilities::CreateBlueprint(
AActor::StaticClass(),
TestPackage,
FixtureName,
BPTYPE_Normal,
TEXT("DirectiveUtilities.EditorBlueprintLibraryTests"));
if (!TestNotNull(TEXT("The inspection fixture Blueprint is created"), InspectionBlueprint))
{
return false;
}
TestTrue(
TEXT("The inspection fixture implements its test interface"),
FBlueprintEditorUtils::ImplementNewInterface(
InspectionBlueprint,
UDirectiveUtilTestInterface::StaticClass()->GetClassPathName()));
USCS_Node* ComponentNode = InspectionBlueprint->SimpleConstructionScript->CreateNode(
UStaticMeshComponent::StaticClass(),
TEXT("InspectionComponent"));
InspectionBlueprint->SimpleConstructionScript->AddNode(ComponentNode);
FEdGraphPinType VariableType;
VariableType.PinCategory = UEdGraphSchema_K2::PC_Int;
TestTrue(
TEXT("The inspection fixture adds an unused variable"),
FBlueprintEditorUtils::AddMemberVariable(InspectionBlueprint, TEXT("UnusedValue"), VariableType));
FBlueprintEditorUtils::SetBlueprintVariableMetaData(
InspectionBlueprint,
TEXT("UnusedValue"),
nullptr,
FBlueprintMetadata::MD_Private,
TEXT("true"));
FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(InspectionBlueprint);
FKismetEditorUtilities::CompileBlueprint(InspectionBlueprint);
FAssetRegistryModule::AssetCreated(InspectionBlueprint);
FDirectiveUtilBlueprintSearchOptions FixtureOptions;
FixtureOptions.PackagePaths = {TEXT("/Game/DirectiveUtilitiesTests")};
TestTrue(
TEXT("Compile-status search finds the fixture"),
ContainsBlueprint(
UDirectiveUtilEditorBlueprintLibrary::FindBlueprintsByCompileStatus(
EDirectiveUtilBlueprintCompileStatus::UpToDate,
FixtureOptions),
InspectionBlueprint));
TestTrue(
TEXT("Direct-parent search finds the fixture"),
ContainsBlueprint(
UDirectiveUtilEditorBlueprintLibrary::FindBlueprintsByParentClass(
AActor::StaticClass(),
FixtureOptions,
false),
InspectionBlueprint));
TestTrue(
TEXT("Descendant-parent search finds the fixture"),
ContainsBlueprint(
UDirectiveUtilEditorBlueprintLibrary::FindBlueprintsByParentClass(
AActor::StaticClass(),
FixtureOptions,
true),
InspectionBlueprint));
TestTrue(
TEXT("Interface search finds the fixture"),
ContainsBlueprint(
UDirectiveUtilEditorBlueprintLibrary::FindBlueprintsImplementingInterface(
UDirectiveUtilTestInterface::StaticClass(),
FixtureOptions),
InspectionBlueprint));
TestTrue(
TEXT("Exact component search finds the fixture"),
ContainsBlueprint(
UDirectiveUtilEditorBlueprintLibrary::FindBlueprintsContainingComponentClass(
UStaticMeshComponent::StaticClass(),
FixtureOptions,
false),
InspectionBlueprint));
TestTrue(
TEXT("Derived component search finds the fixture"),
ContainsBlueprint(
UDirectiveUtilEditorBlueprintLibrary::FindBlueprintsContainingComponentClass(
UActorComponent::StaticClass(),
FixtureOptions,
true),
InspectionBlueprint));
TestTrue(
TEXT("Unused-variable search finds the fixture variable"),
UDirectiveUtilEditorBlueprintLibrary::GetUnusedBlueprintVariables(InspectionBlueprint).Contains(TEXT("UnusedValue")));
FixtureOptions.ExcludedPackagePaths = {TEXT("/Game/DirectiveUtilitiesTests")};
TestTrue(
TEXT("Excluded paths remove the fixture from search"),
UDirectiveUtilEditorBlueprintLibrary::FindBlueprintsByParentClass(
AActor::StaticClass(),
FixtureOptions).IsEmpty());
FAssetRegistryModule::AssetDeleted(InspectionBlueprint);
TestPackage->SetDirtyFlag(false);
return true;
}
#endif

View File

@@ -0,0 +1,98 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilEditorTaskLibrary.h"
#include "Misc/AutomationTest.h"
#include "Tasks/DirectiveUtilEditorSlowTask.h"
#include <limits>
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilEditorTaskLibraryTest,
"DirectiveUtilities.EditorTaskLibraryTests",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilEditorTaskLibraryTest::RunTest(const FString& Parameters)
{
TestNull("StartEditorSlowTask should reject zero work", UDirectiveUtilEditorTaskLibrary::StartEditorSlowTask(0.0f, FText::GetEmpty()));
TestNull(
"StartEditorSlowTask should reject NaN work",
UDirectiveUtilEditorTaskLibrary::StartEditorSlowTask(
std::numeric_limits<float>::quiet_NaN(),
FText::GetEmpty()));
TestNull(
"StartEditorSlowTask should reject infinite work",
UDirectiveUtilEditorTaskLibrary::StartEditorSlowTask(
std::numeric_limits<float>::infinity(),
FText::GetEmpty()));
UDirectiveUtilEditorSlowTask* PublicTask = UDirectiveUtilEditorTaskLibrary::StartEditorSlowTask(
1.0f,
FText::FromString(TEXT("Public task")));
TestNotNull("StartEditorSlowTask should create a task for positive work", PublicTask);
if (PublicTask)
{
TestNull(
"StartEditorSlowTask should reject a nested task",
UDirectiveUtilEditorTaskLibrary::StartEditorSlowTask(
1.0f,
FText::FromString(TEXT("Nested task"))));
PublicTask->Finish();
}
UDirectiveUtilEditorSlowTask* Task = NewObject<UDirectiveUtilEditorSlowTask>();
Task->Initialize(2.0f, FText::FromString(TEXT("Test task")), false, false);
TestTrue("Slow task should be active after initialization", Task->IsActive());
TestTrue("Advance should accept non-negative work", Task->Advance(1.0f, FText::FromString(TEXT("Step"))));
TestFalse("Advance should reject negative work", Task->Advance(-1.0f, FText::GetEmpty()));
TestFalse("Advance should reject NaN work", Task->Advance(std::numeric_limits<float>::quiet_NaN(), FText::GetEmpty()));
TestFalse("Advance should reject infinite work", Task->Advance(std::numeric_limits<float>::infinity(), FText::GetEmpty()));
TestFalse("A non-cancellable task should not report cancellation", Task->IsCancelRequested());
Task->Finish();
TestFalse("Slow task should be inactive after Finish", Task->IsActive());
TestFalse("Advance should fail after Finish", Task->Advance(1.0f, FText::GetEmpty()));
Task->Initialize(std::numeric_limits<float>::quiet_NaN(), FText::GetEmpty(), false, false);
TestFalse("Initialize should leave a task inactive for NaN work", Task->IsActive());
Task->Initialize(std::numeric_limits<float>::infinity(), FText::GetEmpty(), false, false);
TestFalse("Initialize should leave a task inactive for infinite work", Task->IsActive());
UDirectiveUtilEditorSlowTask* FirstTask = NewObject<UDirectiveUtilEditorSlowTask>();
UDirectiveUtilEditorSlowTask* SecondTask = NewObject<UDirectiveUtilEditorSlowTask>();
FirstTask->Initialize(1.0f, FText::FromString(TEXT("First task")), false, false);
SecondTask->Initialize(1.0f, FText::FromString(TEXT("Second task")), false, false);
TestTrue("The first slow task should remain active", FirstTask->IsActive());
TestFalse("A concurrent slow task should remain inactive", SecondTask->IsActive());
FirstTask->Finish();
SecondTask->Initialize(1.0f, FText::FromString(TEXT("Second task")), false, false);
TestTrue("A slow task should start after the active task finishes", SecondTask->IsActive());
SecondTask->Finish();
TWeakObjectPtr<UDirectiveUtilEditorSlowTask> AbandonedTask = NewObject<UDirectiveUtilEditorSlowTask>();
AbandonedTask->Initialize(1.0f, FText::FromString(TEXT("Abandoned task")), false, false);
TestTrue("An unfinished slow task should be active before collection", AbandonedTask->IsActive());
CollectGarbage(RF_NoFlags);
TestTrue("An unfinished slow task should stay alive until Finish", AbandonedTask.IsValid());
if (AbandonedTask.IsValid())
{
AbandonedTask->Finish();
}
CollectGarbage(RF_NoFlags);
TestFalse("A finished slow task should be collectable", AbandonedTask.IsValid());
UDirectiveUtilEditorSlowTask* TaskAfterCollection = NewObject<UDirectiveUtilEditorSlowTask>();
TaskAfterCollection->Initialize(1.0f, FText::FromString(TEXT("Task after collection")), false, false);
TestTrue("A slow task should start after the previous task is collected", TaskAfterCollection->IsActive());
TaskAfterCollection->Finish();
TestTrue(
"ShowEditorNotification should create a notification while Slate is active",
UDirectiveUtilEditorTaskLibrary::ShowEditorNotification(
FText::FromString(TEXT("Directive Utilities test")),
EDirectiveUtilEditorNotificationState::Success,
0.01f));
TestTrue(
"ShowEditorNotification should normalize a non-finite duration",
UDirectiveUtilEditorTaskLibrary::ShowEditorNotification(
FText::FromString(TEXT("Directive Utilities duration test")),
EDirectiveUtilEditorNotificationState::Neutral,
std::numeric_limits<float>::quiet_NaN()));
return true;
}

View File

@@ -1,41 +1,244 @@
#include "Libraries/DirectiveUtilFunctionLibrary.h"
#include "Tests/AutomationCommon.h"
#include "Misc/AutomationTest.h"
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilFunctionLibraryTest, "DirectiveUtilities.FunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
#include "Libraries/DirectiveUtilFunctionLibrary.h"
#include "Async/Async.h"
#include "Engine/World.h"
#include "HAL/PlatformProcess.h"
#include "Misc/AutomationTest.h"
#include "Misc/App.h"
#include "UObject/Package.h"
namespace DirectiveUtilFunctionLibraryTest
{
EDirectiveUtilBuildConfiguration GetExpectedBuildConfiguration(const EBuildConfiguration BuildConfiguration)
{
switch (BuildConfiguration)
{
case EBuildConfiguration::Unknown:
return EDirectiveUtilBuildConfiguration::Unknown;
case EBuildConfiguration::Debug:
return EDirectiveUtilBuildConfiguration::Debug;
case EBuildConfiguration::DebugGame:
return EDirectiveUtilBuildConfiguration::DebugGame;
case EBuildConfiguration::Development:
return EDirectiveUtilBuildConfiguration::Development;
case EBuildConfiguration::Shipping:
return EDirectiveUtilBuildConfiguration::Shipping;
case EBuildConfiguration::Test:
return EDirectiveUtilBuildConfiguration::Test;
}
return EDirectiveUtilBuildConfiguration::Unknown;
}
EDirectiveUtilBuildTargetType GetExpectedBuildTargetType(const EBuildTargetType BuildTargetType)
{
switch (BuildTargetType)
{
case EBuildTargetType::Unknown:
return EDirectiveUtilBuildTargetType::Unknown;
case EBuildTargetType::Game:
return EDirectiveUtilBuildTargetType::Game;
case EBuildTargetType::Server:
return EDirectiveUtilBuildTargetType::Server;
case EBuildTargetType::Client:
return EDirectiveUtilBuildTargetType::Client;
case EBuildTargetType::Editor:
return EDirectiveUtilBuildTargetType::Editor;
case EBuildTargetType::Program:
return EDirectiveUtilBuildTargetType::Program;
}
return EDirectiveUtilBuildTargetType::Unknown;
}
enum class EClipboardStage : uint8
{
CopyText,
CheckText,
CheckString,
CheckClear,
Complete
};
class FClipboardRoundTripCommand : public IAutomationLatentCommand
{
public:
FClipboardRoundTripCommand(FAutomationTestBase* InTest, FString InOriginalClipboard)
: Test(InTest)
, OriginalClipboard(MoveTemp(InOriginalClipboard))
{
}
virtual bool Update() override
{
switch (Stage)
{
case EClipboardStage::CopyText:
UDirectiveUtilFunctionLibrary::CopyTextToClipboard(FText::FromString(TextPayload));
BeginRetryWindow(EClipboardStage::CheckText);
return false;
case EClipboardStage::CheckText:
if (!HasExpectedValue(UDirectiveUtilFunctionLibrary::GetTextFromClipboard().ToString(), TextPayload, TEXT("GetTextFromClipboard should return the copied text")))
{
return false;
}
UDirectiveUtilFunctionLibrary::CopyStringToClipboard(StringPayload);
BeginRetryWindow(EClipboardStage::CheckString);
return false;
case EClipboardStage::CheckString:
if (!HasExpectedValue(UDirectiveUtilFunctionLibrary::GetStringFromClipboard(), StringPayload, TEXT("GetStringFromClipboard should return the copied string")))
{
return false;
}
UDirectiveUtilFunctionLibrary::ClearClipboard();
BeginRetryWindow(EClipboardStage::CheckClear);
return false;
case EClipboardStage::CheckClear:
if (!HasExpectedValue(UDirectiveUtilFunctionLibrary::GetStringFromClipboard(), FString(), TEXT("GetStringFromClipboard should return an empty string after clearing the clipboard")))
{
return false;
}
UDirectiveUtilFunctionLibrary::CopyStringToClipboard(OriginalClipboard);
Stage = EClipboardStage::Complete;
return true;
case EClipboardStage::Complete:
return true;
}
return true;
}
private:
void BeginRetryWindow(EClipboardStage NextStage)
{
Stage = NextStage;
FramesRemaining = 120;
}
bool HasExpectedValue(const FString& Actual, const FString& Expected, const TCHAR* FailureMessage)
{
if (Actual == Expected)
{
return true;
}
if (--FramesRemaining > 0)
{
return false;
}
Test->TestEqual(FailureMessage, Actual, Expected);
return true;
}
FAutomationTestBase* Test;
FString OriginalClipboard;
EClipboardStage Stage = EClipboardStage::CopyText;
int32 FramesRemaining = 0;
const FString TextPayload = TEXT("Directive Utilities Text Clipboard");
const FString StringPayload = TEXT("Directive Utilities String Clipboard");
};
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilFunctionLibraryTest, "DirectiveUtilities.FunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilFunctionLibraryTest::RunTest(const FString& Parameters)
{
// Preserve the user/CI clipboard so this test doesn't destroy it, and restore it before returning.
const FString OriginalClipboard = UDirectiveUtilFunctionLibrary::GetStringFromClipboard();
const FText TestText = FText::FromString(TEXT("Hello, Clipboard!"));
UDirectiveUtilFunctionLibrary::CopyTextToClipboard(TestText);
const FText ClipboardText = UDirectiveUtilFunctionLibrary::GetTextFromClipboard();
TestEqual("GetTextFromClipboard should return the copied text", ClipboardText.ToString(), TestText.ToString());
ADD_LATENT_AUTOMATION_COMMAND(FEngineWaitLatentCommand(1.0f));
const FString TestString = TEXT("Hello, Clipboard!");
UDirectiveUtilFunctionLibrary::CopyStringToClipboard(TestString);
const FString ClipboardString = UDirectiveUtilFunctionLibrary::GetStringFromClipboard();
TestEqual("GetStringFromClipboard should return the copied string", ClipboardString, TestString);
ADD_LATENT_AUTOMATION_COMMAND(FEngineWaitLatentCommand(1.0f));
UDirectiveUtilFunctionLibrary::ClearClipboard();
const FString ClearedClipboardString = UDirectiveUtilFunctionLibrary::GetStringFromClipboard();
TestEqual("GetStringFromClipboard should return an empty string after clearing the clipboard", ClearedClipboardString, TEXT(""));
ADD_LATENT_AUTOMATION_COMMAND(DirectiveUtilFunctionLibraryTest::FClipboardRoundTripCommand(this, OriginalClipboard));
const FString ProjectVersion = UDirectiveUtilFunctionLibrary::GetProjectVersion();
TestNotEqual("GetProjectVersion should return a non-empty string", ProjectVersion, FString(""));
TestTrue("IsRunningInEditor should return true in editor context",
UDirectiveUtilFunctionLibrary::IsRunningInEditor());
#if WITH_EDITOR
constexpr bool bExpectedEditorContext = true;
#else
constexpr bool bExpectedEditorContext = false;
#endif
TestEqual(
"IsRunningInEditor should match the target context",
UDirectiveUtilFunctionLibrary::IsRunningInEditor(),
bExpectedEditorContext);
// Pure pass-through to the engine's GetDerivedClasses (which appends to the output array).
struct FWorldTypeCase
{
EWorldType::Type WorldType;
EDirectiveUtilWorldType Expected;
};
const FWorldTypeCase WorldTypeCases[] = {
{ EWorldType::None, EDirectiveUtilWorldType::None },
{ EWorldType::Game, EDirectiveUtilWorldType::Game },
{ EWorldType::Editor, EDirectiveUtilWorldType::Editor },
{ EWorldType::PIE, EDirectiveUtilWorldType::PlayInEditor },
{ EWorldType::EditorPreview, EDirectiveUtilWorldType::EditorPreview },
{ EWorldType::GamePreview, EDirectiveUtilWorldType::GamePreview },
{ EWorldType::GameRPC, EDirectiveUtilWorldType::GameRPC },
{ EWorldType::Inactive, EDirectiveUtilWorldType::Inactive }
};
UWorld* TestWorld = NewObject<UWorld>();
TestNotNull("A transient world should be created for world type tests", TestWorld);
if (TestWorld)
{
for (const FWorldTypeCase& TestCase : WorldTypeCases)
{
TestWorld->WorldType = TestCase.WorldType;
TestEqual(
FString::Printf(TEXT("GetWorldType should map %s"), LexToString(TestCase.WorldType)),
UDirectiveUtilFunctionLibrary::GetWorldType(TestWorld),
TestCase.Expected);
}
TestWorld->WorldType = static_cast<EWorldType::Type>(MAX_uint8);
TestEqual(
"GetWorldType should reject unsupported world types",
UDirectiveUtilFunctionLibrary::GetWorldType(TestWorld),
EDirectiveUtilWorldType::Unknown);
}
TestEqual(
"GetWorldType should return Unknown for a null context",
UDirectiveUtilFunctionLibrary::GetWorldType(nullptr),
EDirectiveUtilWorldType::Unknown);
TestEqual(
"GetWorldType should return Unknown when the context has no world",
UDirectiveUtilFunctionLibrary::GetWorldType(GetTransientPackage()),
EDirectiveUtilWorldType::Unknown);
TestEqual(
"GetBuildConfigurationType should match the application build configuration",
UDirectiveUtilFunctionLibrary::GetBuildConfigurationType(),
DirectiveUtilFunctionLibraryTest::GetExpectedBuildConfiguration(FApp::GetBuildConfiguration()));
TestEqual(
"GetBuildTargetType should match the application build target",
UDirectiveUtilFunctionLibrary::GetBuildTargetType(),
DirectiveUtilFunctionLibraryTest::GetExpectedBuildTargetType(FApp::GetBuildTargetType()));
TestNotNull("The world type enum should be reflected", StaticEnum<EDirectiveUtilWorldType>());
TestNotNull("The build configuration enum should be reflected", StaticEnum<EDirectiveUtilBuildConfiguration>());
TestNotNull("The build target type enum should be reflected", StaticEnum<EDirectiveUtilBuildTargetType>());
const UFunction* GetWorldTypeFunction = UDirectiveUtilFunctionLibrary::StaticClass()->FindFunctionByName(
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilFunctionLibrary, GetWorldType));
TestNotNull("GetWorldType should be reflected", GetWorldTypeFunction);
if (GetWorldTypeFunction)
{
TestTrue("GetWorldType should be Blueprint pure", GetWorldTypeFunction->HasAnyFunctionFlags(FUNC_BlueprintPure));
TestFalse("GetWorldType should be available at runtime", GetWorldTypeFunction->HasAnyFunctionFlags(FUNC_EditorOnly));
#if WITH_EDITOR
TestEqual(
"GetWorldType should use its input as the Blueprint world context",
GetWorldTypeFunction->GetMetaData(TEXT("WorldContext")),
FString(TEXT("WorldContextObject")));
#endif
}
// Happy path (recursive): the running module guarantees a stable hierarchy to query.
TArray<UClass*> RecursiveDerived;
UDirectiveUtilFunctionLibrary::GetChildClasses(UBlueprintFunctionLibrary::StaticClass(), true, RecursiveDerived);
TestTrue("GetChildClasses should return a non-empty list for a base class with subclasses",
@@ -45,19 +248,121 @@ bool FDirectiveUtilFunctionLibraryTest::RunTest(const FString& Parameters)
TestFalse("GetChildClasses should not include the base class itself",
RecursiveDerived.Contains(UBlueprintFunctionLibrary::StaticClass()));
// Non-recursive results must not exceed the recursive results for the same base.
double ElapsedMilliseconds = 123.0;
TestFalse(
"StartStopwatch should reject NAME_None",
UDirectiveUtilFunctionLibrary::StartStopwatch(NAME_None));
TestFalse(
"StopStopwatch should reject NAME_None",
UDirectiveUtilFunctionLibrary::StopStopwatch(NAME_None, ElapsedMilliseconds));
TestEqual("StopStopwatch should reset elapsed time for NAME_None", ElapsedMilliseconds, 0.0);
const FName MissingStopwatchKey(TEXT("DirectiveUtilMissingStopwatch"));
ElapsedMilliseconds = 123.0;
TestFalse(
"StopStopwatch should reject a missing key",
UDirectiveUtilFunctionLibrary::StopStopwatch(MissingStopwatchKey, ElapsedMilliseconds));
TestEqual("StopStopwatch should reset elapsed time for a missing key", ElapsedMilliseconds, 0.0);
const FName BasicStopwatchKey(TEXT("DirectiveUtilBasicStopwatch"));
TestTrue(
"StartStopwatch should start an unused key",
UDirectiveUtilFunctionLibrary::StartStopwatch(BasicStopwatchKey));
TestFalse(
"StartStopwatch should preserve an active key by default",
UDirectiveUtilFunctionLibrary::StartStopwatch(BasicStopwatchKey));
TestTrue(
"StopStopwatch should stop an active key",
UDirectiveUtilFunctionLibrary::StopStopwatch(BasicStopwatchKey, ElapsedMilliseconds));
TestTrue("StopStopwatch should return a non-negative duration", ElapsedMilliseconds >= 0.0);
TestFalse(
"StopStopwatch should consume the active key",
UDirectiveUtilFunctionLibrary::StopStopwatch(BasicStopwatchKey, ElapsedMilliseconds));
const FName RestartStopwatchKey(TEXT("DirectiveUtilRestartStopwatch"));
TestTrue(
"StartStopwatch should start the restart test key",
UDirectiveUtilFunctionLibrary::StartStopwatch(RestartStopwatchKey));
TestTrue(
"StartStopwatch should replace an active key when requested",
UDirectiveUtilFunctionLibrary::StartStopwatch(RestartStopwatchKey, true));
TestTrue(
"StopStopwatch should stop a restarted key",
UDirectiveUtilFunctionLibrary::StopStopwatch(RestartStopwatchKey, ElapsedMilliseconds));
const FName FirstOverlapKey(TEXT("DirectiveUtilFirstOverlapStopwatch"));
const FName SecondOverlapKey(TEXT("DirectiveUtilSecondOverlapStopwatch"));
TestTrue(
"StartStopwatch should start the first overlapping key",
UDirectiveUtilFunctionLibrary::StartStopwatch(FirstOverlapKey));
TestTrue(
"StartStopwatch should start the second overlapping key",
UDirectiveUtilFunctionLibrary::StartStopwatch(SecondOverlapKey));
TestTrue(
"StopStopwatch should stop the first overlapping key out of order",
UDirectiveUtilFunctionLibrary::StopStopwatch(FirstOverlapKey, ElapsedMilliseconds));
TestTrue(
"StopStopwatch should stop the second overlapping key",
UDirectiveUtilFunctionLibrary::StopStopwatch(SecondOverlapKey, ElapsedMilliseconds));
const FName TimedStopwatchKey(TEXT("DirectiveUtilTimedStopwatch"));
TestTrue(
"StartStopwatch should start the elapsed-time test key",
UDirectiveUtilFunctionLibrary::StartStopwatch(TimedStopwatchKey));
FPlatformProcess::SleepNoStats(0.01f);
TestTrue(
"StopStopwatch should stop the elapsed-time test key",
UDirectiveUtilFunctionLibrary::StopStopwatch(TimedStopwatchKey, ElapsedMilliseconds));
TestTrue("StopStopwatch should measure elapsed real time in milliseconds", ElapsedMilliseconds >= 5.0);
const FName CrossThreadStopwatchKey(TEXT("DirectiveUtilCrossThreadStopwatch"));
TestTrue(
"StartStopwatch should start a key that will stop on another thread",
UDirectiveUtilFunctionLibrary::StartStopwatch(CrossThreadStopwatchKey));
double CrossThreadElapsedMilliseconds = 0.0;
TFuture<bool> CrossThreadStop = Async(EAsyncExecution::ThreadPool, [&CrossThreadElapsedMilliseconds, CrossThreadStopwatchKey]()
{
return UDirectiveUtilFunctionLibrary::StopStopwatch(
CrossThreadStopwatchKey,
CrossThreadElapsedMilliseconds);
});
TestTrue("StopStopwatch should find a key started on another thread", CrossThreadStop.Get());
TestTrue("Cross-thread stopwatch duration should be non-negative", CrossThreadElapsedMilliseconds >= 0.0);
const UFunction* StartStopwatchFunction = UDirectiveUtilFunctionLibrary::StaticClass()->FindFunctionByName(
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilFunctionLibrary, StartStopwatch));
const UFunction* StopStopwatchFunction = UDirectiveUtilFunctionLibrary::StaticClass()->FindFunctionByName(
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilFunctionLibrary, StopStopwatch));
TestNotNull("StartStopwatch should be reflected", StartStopwatchFunction);
TestNotNull("StopStopwatch should be reflected", StopStopwatchFunction);
for (const UFunction* StopwatchFunction : { StartStopwatchFunction, StopStopwatchFunction })
{
if (!StopwatchFunction)
{
continue;
}
TestTrue("Stopwatch functions should be Blueprint callable", StopwatchFunction->HasAnyFunctionFlags(FUNC_BlueprintCallable));
TestFalse("Stopwatch functions should not be Blueprint pure", StopwatchFunction->HasAnyFunctionFlags(FUNC_BlueprintPure));
TestFalse("Stopwatch functions should be available at runtime", StopwatchFunction->HasAnyFunctionFlags(FUNC_EditorOnly));
#if WITH_EDITOR
TestEqual(
"Stopwatch functions should use the profiling category",
StopwatchFunction->GetMetaData(TEXT("Category")),
FString(TEXT("Directive Utilities|Utility|Profiling")));
#endif
}
TArray<UClass*> NonRecursiveDerived;
UDirectiveUtilFunctionLibrary::GetChildClasses(UBlueprintFunctionLibrary::StaticClass(), false, NonRecursiveDerived);
TestTrue("GetChildClasses non-recursive count should not exceed recursive count",
NonRecursiveDerived.Num() <= RecursiveDerived.Num());
// Leaf class with no subclasses: an empty input array should remain empty.
TArray<UClass*> LeafDerived;
UDirectiveUtilFunctionLibrary::GetChildClasses(UDirectiveUtilFunctionLibrary::StaticClass(), true, LeafDerived);
TestEqual("GetChildClasses should return an empty list for a class with no subclasses",
LeafDerived.Num(), 0);
// Null base class: should not crash and should add nothing.
TArray<UClass*> NullBaseDerived;
UDirectiveUtilFunctionLibrary::GetChildClasses(nullptr, true, NullBaseDerived);
TestEqual("GetChildClasses should return an empty list for a null base class",
@@ -87,11 +392,9 @@ bool FDirectiveUtilFunctionLibraryTest::RunTest(const FString& Parameters)
TestFalse("GetCommandLineOption should return false for an empty key",
UDirectiveUtilFunctionLibrary::GetCommandLineOption(CommandLine, TEXT(""), Value));
// Smoke test through the process-command-line path.
TestFalse("HasCommandLineSwitch should not find a switch that was never passed",
UDirectiveUtilFunctionLibrary::HasCommandLineSwitch(TEXT("DirectiveUtilitiesDefinitelyNotPassed")));
}
UDirectiveUtilFunctionLibrary::CopyStringToClipboard(OriginalClipboard);
return true;
}

View File

@@ -1,10 +1,12 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilGameplayTagFunctionLibrary.h"
#include "GameplayTagsManager.h"
#include "Misc/AutomationTest.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilGameplayTagFunctionLibraryTest, "DirectiveUtilities.GameplayTagFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilGameplayTagFunctionLibraryTest, "DirectiveUtilities.GameplayTagFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilGameplayTagFunctionLibraryTest::RunTest(const FString& Parameters)
{
@@ -109,5 +111,23 @@ bool FDirectiveUtilGameplayTagFunctionLibraryTest::RunTest(const FString& Parame
TestEqual("FindRegisteredTags with no match should be empty", UDirectiveUtilGameplayTagFunctionLibrary::FindRegisteredTags(TEXT("DirectiveUtilitiesNoSuchTagXYZ")).Num(), 0);
TestEqual("FindRegisteredTags with an empty substring should be empty", UDirectiveUtilGameplayTagFunctionLibrary::FindRegisteredTags(TEXT("")).Num(), 0);
#if WITH_EDITOR
const TArray<FName> RegistryQueryFunctions = {
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilGameplayTagFunctionLibrary, GetTagParents),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilGameplayTagFunctionLibrary, GetTagChildren),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilGameplayTagFunctionLibrary, GetTagDirectChildren),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilGameplayTagFunctionLibrary, IsLeafTag)
};
for (const FName FunctionName : RegistryQueryFunctions)
{
const UFunction* Function = UDirectiveUtilGameplayTagFunctionLibrary::StaticClass()->FindFunctionByName(FunctionName);
TestNotNull(*FString::Printf(TEXT("%s should be reflected"), *FunctionName.ToString()), Function);
if (Function)
{
TestFalse(*FString::Printf(TEXT("%s should not advertise worker-thread safety"), *FunctionName.ToString()), Function->HasMetaData(TEXT("BlueprintThreadSafe")));
}
}
#endif
return true;
}

View File

@@ -4,6 +4,7 @@
#include "Types/DirectiveUtilInputTypes.h"
#include "Types/DirectiveUtilTypes.h"
#include "InputMappingContext.h"
#include "EnhancedPlayerInput.h"
#include "EnhancedInputSubsystems.h"
#include "Engine/GameInstance.h"
#include "Engine/LocalPlayer.h"
@@ -12,34 +13,24 @@
#include "GameFramework/PlayerController.h"
#include "Misc/AutomationTest.h"
#if WITH_EDITOR
/**
* Exercises the Enhanced Input library's active paths against a real EnhancedInput subsystem,
* which requires a live local player. A standalone game instance + local player is built for this;
* if that cannot be created in the headless harness the test skips its assertions rather than failing.
*/
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilInputActivePathTest, "DirectiveUtilities.InputActivePathTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilInputActivePathTest, "DirectiveUtilities.InputActivePathTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilInputActivePathTest::RunTest(const FString& Parameters)
{
// These warnings only fire on the graceful-skip paths; allow (but never require) them.
AddExpectedMessagePlain(TEXT("LocalPlayer not found. Cannot set input mapping contexts."), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1);
AddExpectedMessagePlain(TEXT("EnhancedInput subsystem not found."), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1);
UGameInstance* GameInstance = NewObject<UGameInstance>(GEngine);
if (!GameInstance)
{
AddInfo(TEXT("Could not create a game instance; skipping Enhanced Input active-path assertions."));
return true;
AddError(TEXT("Could not create a game instance for Enhanced Input active-path assertions."));
return false;
}
GameInstance->AddToRoot();
GameInstance->InitializeStandalone();
UWorld* World = GameInstance->GetWorld();
// CreateLocalPlayer ensures when there is no game viewport client (headless), so build the local
// player directly via AddLocalPlayer, which initializes the local-player subsystems without one.
UClass* LocalPlayerClass = GEngine->LocalPlayerClass ? GEngine->LocalPlayerClass.Get() : ULocalPlayer::StaticClass();
ULocalPlayer* LocalPlayer = NewObject<ULocalPlayer>(GEngine, LocalPlayerClass);
if (World && LocalPlayer)
@@ -49,33 +40,40 @@ bool FDirectiveUtilInputActivePathTest::RunTest(const FString& Parameters)
if (!World || !LocalPlayer)
{
AddInfo(TEXT("Local player unavailable in the headless harness; skipping Enhanced Input active-path assertions."));
AddError(TEXT("Local player unavailable in the Enhanced Input test harness."));
GameInstance->Shutdown();
GameInstance->RemoveFromRoot();
return true;
return false;
}
APlayerController* PlayerController = World->SpawnActor<APlayerController>();
if (!PlayerController)
{
AddInfo(TEXT("Could not spawn a player controller; skipping Enhanced Input active-path assertions."));
AddError(TEXT("Could not spawn a player controller for Enhanced Input active-path assertions."));
GameInstance->Shutdown();
GameInstance->RemoveFromRoot();
return true;
return false;
}
PlayerController->SetPlayer(LocalPlayer);
PlayerController->InitInputSystem();
PlayerController->PlayerInput = NewObject<UEnhancedPlayerInput>(PlayerController);
UEnhancedInputLocalPlayerSubsystem* Subsystem = UDirectiveUtilInputFunctionLibrary::GetEnhancedInputSubsystem(PlayerController);
if (!Subsystem)
{
AddInfo(TEXT("Enhanced Input subsystem unavailable for the synthetic local player; skipping active-path assertions."));
AddError(TEXT("Enhanced Input subsystem unavailable for the synthetic local player."));
GameInstance->Shutdown();
GameInstance->RemoveFromRoot();
return true;
return false;
}
// We have a live subsystem: exercise the active add/active/swap/remove/clear paths for real.
TestNotNull("GetEnhancedInputSubsystem returns the subsystem for a live local player", Subsystem);
auto ApplyPendingMappings = [Subsystem]
{
FModifyContextOptions Options;
Options.bForceImmediately = true;
Subsystem->RequestRebuildControlMappings(Options);
};
UInputMappingContext* ContextA = NewObject<UInputMappingContext>(GetTransientPackage());
UInputMappingContext* ContextB = NewObject<UInputMappingContext>(GetTransientPackage());
@@ -84,7 +82,6 @@ bool FDirectiveUtilInputActivePathTest::RunTest(const FString& Parameters)
const TSoftObjectPtr<UInputMappingContext> SoftA(ContextA);
const TSoftObjectPtr<UInputMappingContext> SoftB(ContextB);
// Add context A.
FDirectiveUtilEnhancedInputContextData DataA;
DataA.InputContext = SoftA;
DataA.Priority = 0;
@@ -93,38 +90,47 @@ bool FDirectiveUtilInputActivePathTest::RunTest(const FString& Parameters)
TestEqual("AddInputMappingContexts succeeds for a live controller",
UDirectiveUtilInputFunctionLibrary::AddInputMappingContexts(PlayerController, ToAdd, false),
EDirectiveUtilSuccessStatus::Success);
ApplyPendingMappings();
TestTrue("Added context is reported active",
UDirectiveUtilInputFunctionLibrary::IsInputMappingContextActive(PlayerController, SoftA));
// Swap A -> B.
TestEqual("SwapInputMappingContexts succeeds",
UDirectiveUtilInputFunctionLibrary::SwapInputMappingContexts(PlayerController, SoftA, SoftB, 0, false),
EDirectiveUtilSuccessStatus::Success);
ApplyPendingMappings();
TestFalse("Swapped-out context is inactive",
UDirectiveUtilInputFunctionLibrary::IsInputMappingContextActive(PlayerController, SoftA));
TestTrue("Swapped-in context is active",
UDirectiveUtilInputFunctionLibrary::IsInputMappingContextActive(PlayerController, SoftB));
// Remove B.
TArray<TSoftObjectPtr<UInputMappingContext>> ToRemove;
ToRemove.Add(SoftB);
TestEqual("RemoveInputMappingContexts succeeds",
UDirectiveUtilInputFunctionLibrary::RemoveInputMappingContexts(PlayerController, ToRemove),
EDirectiveUtilSuccessStatus::Success);
ApplyPendingMappings();
TestFalse("Removed context is inactive",
UDirectiveUtilInputFunctionLibrary::IsInputMappingContextActive(PlayerController, SoftB));
TestEqual("Swap adds the new context when the previous context is inactive",
UDirectiveUtilInputFunctionLibrary::SwapInputMappingContexts(PlayerController, SoftA, SoftB, 7, true),
EDirectiveUtilSuccessStatus::Success);
ApplyPendingMappings();
TestTrue("Fallback swap context is active",
UDirectiveUtilInputFunctionLibrary::IsInputMappingContextActive(PlayerController, SoftB));
UDirectiveUtilInputFunctionLibrary::RemoveInputMappingContexts(PlayerController, {SoftB});
ApplyPendingMappings();
// Clear all (after re-adding A).
UDirectiveUtilInputFunctionLibrary::AddInputMappingContexts(PlayerController, ToAdd, false);
TestEqual("ClearAllInputMappingContexts succeeds",
UDirectiveUtilInputFunctionLibrary::ClearAllInputMappingContexts(PlayerController),
EDirectiveUtilSuccessStatus::Success);
ApplyPendingMappings();
TestFalse("Context is inactive after clear-all",
UDirectiveUtilInputFunctionLibrary::IsInputMappingContextActive(PlayerController, SoftA));
// A clearing add whose every context fails to load must leave the existing mappings untouched.
AddExpectedMessagePlain(TEXT("Input Mapping Contexts failed to load and were not added!"), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1);
AddExpectedMessagePlain(TEXT("input mapping contexts could not be loaded"), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1);
UDirectiveUtilInputFunctionLibrary::AddInputMappingContexts(PlayerController, ToAdd, false);
ApplyPendingMappings();
FDirectiveUtilEnhancedInputContextData UnresolvableData;
UnresolvableData.Priority = 0;
TArray<FDirectiveUtilEnhancedInputContextData> UnresolvableToAdd;
@@ -142,5 +148,3 @@ bool FDirectiveUtilInputActivePathTest::RunTest(const FString& Parameters)
return true;
}
#endif // WITH_EDITOR

View File

@@ -1,16 +1,17 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilInputFunctionLibrary.h"
#include "Types/DirectiveUtilTypes.h"
#include "InputMappingContext.h"
#include "Misc/AutomationTest.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilInputFunctionLibraryTest, "DirectiveUtilities.InputFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilInputFunctionLibraryTest, "DirectiveUtilities.InputFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilInputFunctionLibraryTest::RunTest(const FString& Parameters)
{
// The null-controller and invalid-context paths intentionally log warnings. Register them as
// expected (plain match, negative count = consume if present, never required) so the run is clean.
AddExpectedMessagePlain(TEXT("PlayerController is null. Cannot set input mapping contexts."), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1);
AddExpectedMessagePlain(TEXT("Both the previous and new input mapping contexts must be valid."), ELogVerbosity::Warning, EAutomationExpectedMessageFlags::Contains, -1);
// AddInputMappingContexts should return Failure with null controller
TArray<FDirectiveUtilEnhancedInputContextData> Contexts;
@@ -39,8 +40,6 @@ bool FDirectiveUtilInputFunctionLibraryTest::RunTest(const FString& Parameters)
UDirectiveUtilInputFunctionLibrary::RemoveInputMappingContexts(nullptr, EmptyRemoveContexts),
EDirectiveUtilSuccessStatus::Failure);
// SwapInputMappingContexts loads both contexts before using the controller, so invalid
// (unset) soft pointers must return Failure regardless of the (null) controller.
const TSoftObjectPtr<UInputMappingContext> NullContext;
TestEqual("SwapInputMappingContexts should fail when both contexts are invalid",
UDirectiveUtilInputFunctionLibrary::SwapInputMappingContexts(nullptr, NullContext, NullContext, 0, false),

View File

@@ -1,8 +1,10 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilMapFunctionLibrary.h"
#include "Tests/DirectiveUtilTestObject.h"
#include "Misc/AutomationTest.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilMapFunctionLibraryTest, "DirectiveUtilities.MapFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilMapFunctionLibraryTest, "DirectiveUtilities.MapFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilMapFunctionLibraryTest::RunTest(const FString& Parameters)
{
@@ -144,5 +146,11 @@ bool FDirectiveUtilMapFunctionLibraryTest::RunTest(const FString& Parameters)
TestEqual("Append with null or mismatched properties should preserve the value", *UntouchedValue, 10);
}
TestObject->TestMap = {{1, 10}, {2, 20}};
UDirectiveUtilMapFunctionLibrary::GenericMap_Append(&TestObject->TestMap, MapProperty, &TestObject->TestMap, MapProperty, true);
TestEqual("Append with the same map should preserve its size", TestObject->TestMap.Num(), 2);
TestEqual("Append with the same map should preserve the first value", TestObject->TestMap.FindRef(1), 10);
TestEqual("Append with the same map should preserve the second value", TestObject->TestMap.FindRef(2), 20);
return true;
}

View File

@@ -0,0 +1,342 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Misc/AutomationTest.h"
#include "EdGraph/EdGraph.h"
#include "EdGraph/EdGraphNode.h"
#include "EdGraph/EdGraphPin.h"
#include "EdGraphSchema_K2.h"
#include "Engine/Blueprint.h"
#include "K2Node_CallFunction.h"
#include "K2Node_TemporaryVariable.h"
#include "Kismet2/BlueprintEditorUtils.h"
#include "Kismet2/CompilerResultsLog.h"
#include "Libraries/DirectiveUtilMapFunctionLibrary.h"
#include "Nodes/DirectiveUtilMapNodeMigration.h"
#include "Nodes/K2Node_DirectiveUtilMapAppend.h"
#include "Tests/DirectiveUtilTestObject.h"
namespace DirectiveUtilMapNodeTest
{
struct FDependentPin
{
FName Name;
FName Category;
EPinContainerType ContainerType;
};
struct FMapNodeCase
{
FName FunctionName;
TArray<FDependentPin> DependentPins;
};
FEdGraphPinType MakeMapType(
const FName KeyCategory,
const FName ValueCategory,
UObject* KeySubCategoryObject = nullptr,
UObject* ValueSubCategoryObject = nullptr)
{
FEdGraphPinType PinType;
PinType.PinCategory = KeyCategory;
PinType.PinSubCategoryObject = KeySubCategoryObject;
PinType.PinValueType.TerminalCategory = ValueCategory;
PinType.PinValueType.TerminalSubCategoryObject = ValueSubCategoryObject;
PinType.ContainerType = EPinContainerType::Map;
return PinType;
}
UEdGraph* MakeGraph()
{
UBlueprint* Blueprint = NewObject<UBlueprint>();
Blueprint->GeneratedClass = UObject::StaticClass();
Blueprint->SkeletonGeneratedClass = UObject::StaticClass();
UEdGraph* Graph = NewObject<UEdGraph>(Blueprint);
Graph->Schema = UEdGraphSchema_K2::StaticClass();
Blueprint->UbergraphPages.Add(Graph);
return Graph;
}
UEdGraphPin* AddMapOutput(UEdGraph& Graph, const FName Name, const FEdGraphPinType& PinType)
{
UK2Node_TemporaryVariable* SourceNode = NewObject<UK2Node_TemporaryVariable>(&Graph);
SourceNode->VariableType = PinType;
Graph.AddNode(SourceNode);
SourceNode->AllocateDefaultPins();
UEdGraphPin* OutputPin = SourceNode->GetVariablePin();
OutputPin->PinName = Name;
return OutputPin;
}
UK2Node_CallFunction* AddFunctionNode(UEdGraph& Graph, const FName FunctionName)
{
UK2Node_CallFunction* Node = NewObject<UK2Node_CallFunction>(&Graph);
Graph.AddNode(Node);
const UFunction* Function = UDirectiveUtilMapFunctionLibrary::StaticClass()->FindFunctionByName(FunctionName);
Node->FunctionReference.SetExternalMember(FunctionName, UDirectiveUtilMapFunctionLibrary::StaticClass());
Node->bDefaultsToPureFunc = Function && Function->HasAnyFunctionFlags(FUNC_BlueprintPure);
Node->AllocateDefaultPins();
return Node;
}
void Connect(UEdGraphPin& OutputPin, UK2Node& Node, UEdGraphPin& InputPin)
{
OutputPin.MakeLinkTo(&InputPin);
Node.PinConnectionListChanged(&InputPin);
}
void Disconnect(UEdGraphPin& OutputPin, UK2Node& Node, UEdGraphPin& InputPin)
{
OutputPin.BreakLinkTo(&InputPin);
Node.PinConnectionListChanged(&InputPin);
}
bool HasMapType(const UEdGraphPin& Pin, const FName KeyCategory, const FName ValueCategory)
{
return Pin.PinType.IsMap()
&& Pin.PinType.PinCategory == KeyCategory
&& Pin.PinType.PinValueType.TerminalCategory == ValueCategory;
}
bool HasMapType(const UEdGraphPin& Pin, const FEdGraphPinType& ExpectedType)
{
return Pin.PinType.IsMap()
&& Pin.PinType.PinCategory == ExpectedType.PinCategory
&& Pin.PinType.PinSubCategory == ExpectedType.PinSubCategory
&& Pin.PinType.PinSubCategoryObject == ExpectedType.PinSubCategoryObject
&& Pin.PinType.PinValueType == ExpectedType.PinValueType;
}
bool HasElementType(
const UEdGraphPin& Pin,
const FName Category,
UObject* SubCategoryObject,
const EPinContainerType ContainerType)
{
return Pin.PinType.PinCategory == Category
&& Pin.PinType.PinSubCategoryObject == SubCategoryObject
&& Pin.PinType.ContainerType == ContainerType;
}
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilMapNodeWildcardTest,
"DirectiveUtilities.MapNodeWildcardTests",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilMapNodeWildcardTest::RunTest(const FString& Parameters)
{
using namespace DirectiveUtilMapNodeTest;
const TArray<FMapNodeCase> NodeCases = {
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMapFunctionLibrary, Map_FindOrAdd), {{TEXT("Key"), UEdGraphSchema_K2::PC_String, EPinContainerType::None}, {TEXT("Value"), UEdGraphSchema_K2::PC_Int, EPinContainerType::None}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMapFunctionLibrary, Map_ClearValues), {}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMapFunctionLibrary, Map_GetKeysByValue), {{TEXT("Value"), UEdGraphSchema_K2::PC_Int, EPinContainerType::None}, {TEXT("Keys"), UEdGraphSchema_K2::PC_String, EPinContainerType::Array}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMapFunctionLibrary, Map_HasValue), {{TEXT("Value"), UEdGraphSchema_K2::PC_Int, EPinContainerType::None}}},
{GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMapFunctionLibrary, Map_RemoveKeys), {{TEXT("Keys"), UEdGraphSchema_K2::PC_String, EPinContainerType::Array}}},
};
const FEdGraphPinType StringIntMap = MakeMapType(UEdGraphSchema_K2::PC_String, UEdGraphSchema_K2::PC_Int);
for (const FMapNodeCase& NodeCase : NodeCases)
{
UEdGraph* Graph = MakeGraph();
UK2Node_CallFunction* Node = AddFunctionNode(*Graph, NodeCase.FunctionName);
UEdGraphPin* TargetMap = Node->FindPin(TEXT("TargetMap"));
TestNotNull(*FString::Printf(TEXT("%s should have a TargetMap pin"), *NodeCase.FunctionName.ToString()), TargetMap);
if (!TargetMap)
{
continue;
}
TestTrue(*FString::Printf(TEXT("%s should begin as a wildcard map"), *NodeCase.FunctionName.ToString()), HasMapType(*TargetMap, UEdGraphSchema_K2::PC_Wildcard, UEdGraphSchema_K2::PC_Wildcard));
UEdGraphPin* MapOutput = AddMapOutput(*Graph, TEXT("MapOutput"), StringIntMap);
Connect(*MapOutput, *Node, *TargetMap);
TestTrue(*FString::Printf(TEXT("%s should resolve its map type"), *NodeCase.FunctionName.ToString()), HasMapType(*TargetMap, UEdGraphSchema_K2::PC_String, UEdGraphSchema_K2::PC_Int));
for (const FDependentPin& ExpectedPin : NodeCase.DependentPins)
{
const UEdGraphPin* DependentPin = Node->FindPin(ExpectedPin.Name);
TestNotNull(*FString::Printf(TEXT("%s should have a %s pin"), *NodeCase.FunctionName.ToString(), *ExpectedPin.Name.ToString()), DependentPin);
if (DependentPin)
{
TestEqual(*FString::Printf(TEXT("%s should resolve %s"), *NodeCase.FunctionName.ToString(), *ExpectedPin.Name.ToString()), DependentPin->PinType.PinCategory, ExpectedPin.Category);
TestEqual(*FString::Printf(TEXT("%s should preserve the %s container"), *NodeCase.FunctionName.ToString(), *ExpectedPin.Name.ToString()), DependentPin->PinType.ContainerType, ExpectedPin.ContainerType);
}
}
Disconnect(*MapOutput, *Node, *TargetMap);
TestTrue(*FString::Printf(TEXT("%s should reset after disconnect"), *NodeCase.FunctionName.ToString()), HasMapType(*TargetMap, UEdGraphSchema_K2::PC_Wildcard, UEdGraphSchema_K2::PC_Wildcard));
for (const FDependentPin& ExpectedPin : NodeCase.DependentPins)
{
const UEdGraphPin* DependentPin = Node->FindPin(ExpectedPin.Name);
if (DependentPin)
{
TestEqual(*FString::Printf(TEXT("%s should reset %s after disconnect"), *NodeCase.FunctionName.ToString(), *ExpectedPin.Name.ToString()), DependentPin->PinType.PinCategory, UEdGraphSchema_K2::PC_Wildcard);
}
}
}
const FEdGraphPinType ObjectStructMap = MakeMapType(
UEdGraphSchema_K2::PC_Object,
UEdGraphSchema_K2::PC_Struct,
UDirectiveUtilTestObject::StaticClass(),
FDirectiveUtilCollisionValue::StaticStruct());
for (const FMapNodeCase& NodeCase : NodeCases)
{
UEdGraph* Graph = MakeGraph();
UK2Node_CallFunction* Node = AddFunctionNode(*Graph, NodeCase.FunctionName);
UEdGraphPin* TargetMap = Node->FindPin(TEXT("TargetMap"));
if (!TestNotNull(*FString::Printf(TEXT("%s should have a typed TargetMap pin"), *NodeCase.FunctionName.ToString()), TargetMap))
{
continue;
}
UEdGraphPin* MapOutput = AddMapOutput(*Graph, TEXT("ObjectStructMapOutput"), ObjectStructMap);
Connect(*MapOutput, *Node, *TargetMap);
TestTrue(
*FString::Printf(TEXT("%s should preserve map subtype objects"), *NodeCase.FunctionName.ToString()),
HasMapType(*TargetMap, ObjectStructMap));
for (const FDependentPin& ExpectedPin : NodeCase.DependentPins)
{
const UEdGraphPin* DependentPin = Node->FindPin(ExpectedPin.Name);
if (!DependentPin)
{
continue;
}
const bool bUsesKeyType = ExpectedPin.Name == TEXT("Key") || ExpectedPin.Name == TEXT("Keys");
TestTrue(
*FString::Printf(TEXT("%s should preserve %s subtype"), *NodeCase.FunctionName.ToString(), *ExpectedPin.Name.ToString()),
HasElementType(
*DependentPin,
bUsesKeyType ? UEdGraphSchema_K2::PC_Object : UEdGraphSchema_K2::PC_Struct,
bUsesKeyType
? static_cast<UObject*>(UDirectiveUtilTestObject::StaticClass())
: static_cast<UObject*>(FDirectiveUtilCollisionValue::StaticStruct()),
ExpectedPin.ContainerType));
}
Disconnect(*MapOutput, *Node, *TargetMap);
TestTrue(
*FString::Printf(TEXT("%s should reset after a typed disconnect"), *NodeCase.FunctionName.ToString()),
HasMapType(*TargetMap, UEdGraphSchema_K2::PC_Wildcard, UEdGraphSchema_K2::PC_Wildcard));
}
UEdGraph* AppendGraph = MakeGraph();
UK2Node_DirectiveUtilMapAppend* AppendNode = NewObject<UK2Node_DirectiveUtilMapAppend>(AppendGraph);
AppendGraph->AddNode(AppendNode);
AppendNode->AllocateDefaultPins();
TestTrue(
"Append node should be defined in an uncooked-only module",
AppendNode->GetClass()->GetOutermost()->HasAnyPackageFlags(PKG_UncookedOnly));
FCompilerResultsLog ModuleValidationLog;
FBlueprintEditorUtils::ValidateEditorOnlyNodes(AppendNode, ModuleValidationLog);
TestEqual("Append should be valid in a runtime Blueprint", ModuleValidationLog.NumWarnings, 0);
TestEqual(
"Append should call the runtime append function",
AppendNode->GetTargetFunction(),
UDirectiveUtilMapFunctionLibrary::StaticClass()->FindFunctionByName(GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMapFunctionLibrary, Map_Append)));
UEdGraphPin* TargetMap = AppendNode->FindPin(TEXT("TargetMap"));
UEdGraphPin* SourceMap = AppendNode->FindPin(TEXT("SourceMap"));
TestNotNull("Append should have a TargetMap pin", TargetMap);
TestNotNull("Append should have a SourceMap pin", SourceMap);
if (!TargetMap || !SourceMap)
{
return false;
}
TestTrue("Append TargetMap should begin as a wildcard map", HasMapType(*TargetMap, UEdGraphSchema_K2::PC_Wildcard, UEdGraphSchema_K2::PC_Wildcard));
TestTrue("Append SourceMap should begin as a wildcard map", HasMapType(*SourceMap, UEdGraphSchema_K2::PC_Wildcard, UEdGraphSchema_K2::PC_Wildcard));
UEdGraphPin* SourceOutput = AddMapOutput(*AppendGraph, TEXT("SourceOutput"), StringIntMap);
Connect(*SourceOutput, *AppendNode, *SourceMap);
TestTrue("Append TargetMap should resolve from SourceMap", HasMapType(*TargetMap, UEdGraphSchema_K2::PC_String, UEdGraphSchema_K2::PC_Int));
TestTrue("Append SourceMap should resolve from SourceMap", HasMapType(*SourceMap, UEdGraphSchema_K2::PC_String, UEdGraphSchema_K2::PC_Int));
FString DisallowReason;
const FEdGraphPinType NameIntMap = MakeMapType(UEdGraphSchema_K2::PC_Name, UEdGraphSchema_K2::PC_Int);
UEdGraphPin* MismatchedKeyOutput = AddMapOutput(*AppendGraph, TEXT("MismatchedKeyOutput"), NameIntMap);
TestTrue("Append should reject a different key type", AppendNode->IsConnectionDisallowed(TargetMap, MismatchedKeyOutput, DisallowReason));
const FEdGraphPinType StringStringMap = MakeMapType(UEdGraphSchema_K2::PC_String, UEdGraphSchema_K2::PC_String);
UEdGraphPin* MismatchedValueOutput = AddMapOutput(*AppendGraph, TEXT("MismatchedValueOutput"), StringStringMap);
TestTrue("Append should reject a different value type", AppendNode->IsConnectionDisallowed(TargetMap, MismatchedValueOutput, DisallowReason));
TestFalse("Append should accept matching map types", AppendNode->IsConnectionDisallowed(TargetMap, SourceOutput, DisallowReason));
Disconnect(*SourceOutput, *AppendNode, *SourceMap);
TestTrue("Append TargetMap should reset after disconnect", HasMapType(*TargetMap, UEdGraphSchema_K2::PC_Wildcard, UEdGraphSchema_K2::PC_Wildcard));
TestTrue("Append SourceMap should reset after disconnect", HasMapType(*SourceMap, UEdGraphSchema_K2::PC_Wildcard, UEdGraphSchema_K2::PC_Wildcard));
UEdGraphPin* TypedSourceOutput = AddMapOutput(*AppendGraph, TEXT("TypedSourceOutput"), ObjectStructMap);
Connect(*TypedSourceOutput, *AppendNode, *SourceMap);
TestTrue("Append TargetMap should preserve subtype objects", HasMapType(*TargetMap, ObjectStructMap));
TestTrue("Append SourceMap should preserve subtype objects", HasMapType(*SourceMap, ObjectStructMap));
Disconnect(*TypedSourceOutput, *AppendNode, *SourceMap);
UEdGraph* MigrationGraph = MakeGraph();
UBlueprint* MigrationBlueprint = CastChecked<UBlueprint>(MigrationGraph->GetOuter());
UK2Node_CallFunction* LegacyAppendNode = AddFunctionNode(
*MigrationGraph,
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMapFunctionLibrary, Map_Append));
LegacyAppendNode->NodePosX = 320;
LegacyAppendNode->NodePosY = 180;
UEdGraphPin* LegacyTargetMap = LegacyAppendNode->FindPin(TEXT("TargetMap"));
UEdGraphPin* LegacyOverwrite = LegacyAppendNode->FindPin(TEXT("bOverwriteExisting"));
TestNotNull("Legacy Append should have a TargetMap pin", LegacyTargetMap);
TestNotNull("Legacy Append should have an overwrite pin", LegacyOverwrite);
if (!LegacyTargetMap || !LegacyOverwrite)
{
return false;
}
LegacyOverwrite->DefaultValue = TEXT("false");
UEdGraphPin* LegacyMapOutput = AddMapOutput(*MigrationGraph, TEXT("LegacyMapOutput"), StringIntMap);
Connect(*LegacyMapOutput, *LegacyAppendNode, *LegacyTargetMap);
TestTrue("Legacy Append should be upgraded", DirectiveUtilMapNodeMigration::UpgradeLegacyAppendNodes(*MigrationBlueprint));
TArray<UK2Node_DirectiveUtilMapAppend*> MigratedAppendNodes;
MigrationGraph->GetNodesOfClass(MigratedAppendNodes);
TestEqual("Migration should create one Append node", MigratedAppendNodes.Num(), 1);
if (MigratedAppendNodes.Num() != 1)
{
return false;
}
UK2Node_DirectiveUtilMapAppend* MigratedAppendNode = MigratedAppendNodes[0];
TestEqual("Migration should preserve the X position", MigratedAppendNode->NodePosX, 320);
TestEqual("Migration should preserve the Y position", MigratedAppendNode->NodePosY, 180);
TestEqual("Migration should preserve the overwrite default", MigratedAppendNode->FindPinChecked(TEXT("bOverwriteExisting"))->DefaultValue, FString(TEXT("false")));
TestTrue("Migrated TargetMap should preserve its connection", HasMapType(*MigratedAppendNode->FindPinChecked(TEXT("TargetMap")), UEdGraphSchema_K2::PC_String, UEdGraphSchema_K2::PC_Int));
TestTrue("Migrated SourceMap should resolve from TargetMap", HasMapType(*MigratedAppendNode->FindPinChecked(TEXT("SourceMap")), UEdGraphSchema_K2::PC_String, UEdGraphSchema_K2::PC_Int));
UEdGraph* FailedMigrationGraph = MakeGraph();
UBlueprint* FailedMigrationBlueprint = CastChecked<UBlueprint>(FailedMigrationGraph->GetOuter());
UK2Node_CallFunction* IncompatibleLegacyNode = AddFunctionNode(
*FailedMigrationGraph,
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMapFunctionLibrary, Map_Append));
UEdGraphPin* IncompatibleTargetMap = IncompatibleLegacyNode->FindPinChecked(TEXT("TargetMap"));
UEdGraphPin* LegacyOnlyPin = IncompatibleLegacyNode->CreatePin(
EGPD_Input,
UEdGraphSchema_K2::PC_Int,
TEXT("LegacyOnly"));
LegacyOnlyPin->ParentPin = IncompatibleTargetMap;
IncompatibleTargetMap->SubPins.Add(LegacyOnlyPin);
UEdGraphPin* IncompatibleMapOutput = AddMapOutput(*FailedMigrationGraph, TEXT("IncompatibleMapOutput"), StringIntMap);
Connect(*IncompatibleMapOutput, *IncompatibleLegacyNode, *IncompatibleTargetMap);
AddExpectedMessagePlain(
TEXT("BackwardCompatibilityNodeConversion Error 'cannot find pin LegacyOnly"),
ELogVerbosity::Warning,
EAutomationExpectedMessageFlags::Contains,
1);
TestFalse(
"An incompatible legacy Append node should not be replaced",
DirectiveUtilMapNodeMigration::UpgradeLegacyAppendNodes(*FailedMigrationBlueprint));
TestTrue(
"A failed migration should preserve the legacy connection",
IncompatibleMapOutput->LinkedTo.Contains(IncompatibleTargetMap));
TestTrue(
"A failed migration should preserve the legacy node",
FailedMigrationGraph->Nodes.Contains(IncompatibleLegacyNode));
return true;
}

View File

@@ -0,0 +1,252 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilMapFunctionLibrary.h"
#include "Tests/DirectiveUtilTestObject.h"
#include "Misc/AutomationTest.h"
namespace
{
template <typename PropertyType>
PropertyType* GetTestProperty(const FName PropertyName)
{
return FindFProperty<PropertyType>(UDirectiveUtilTestObject::StaticClass(), PropertyName);
}
TSet<int32> MakeIntegerSet(const TArray<int32>& Values)
{
TSet<int32> Result;
for (const int32 Value : Values)
{
Result.Add(Value);
}
return Result;
}
TSet<FString> MakeStringSet(const TArray<FString>& Values)
{
TSet<FString> Result;
for (const FString& Value : Values)
{
Result.Add(Value);
}
return Result;
}
TSet<FName> MakeNameSet(const TArray<FName>& Values)
{
TSet<FName> Result;
for (const FName Value : Values)
{
Result.Add(Value);
}
return Result;
}
template <typename ValueType>
bool HaveSameValues(const TSet<ValueType>& Left, const TSet<ValueType>& Right)
{
if (Left.Num() != Right.Num())
{
return false;
}
for (const ValueType& Value : Left)
{
if (!Right.Contains(Value))
{
return false;
}
}
return true;
}
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilMapScenarioTest,
"DirectiveUtilities.MapScenarios.CardinalityAndTypes",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilMapScenarioTest::RunTest(const FString& Parameters)
{
FMapProperty* IntegerMapProperty = GetTestProperty<FMapProperty>(GET_MEMBER_NAME_CHECKED(UDirectiveUtilTestObject, TestMap));
FArrayProperty* IntegerArrayProperty = GetTestProperty<FArrayProperty>(GET_MEMBER_NAME_CHECKED(UDirectiveUtilTestObject, TestArray));
FMapProperty* StringMapProperty = GetTestProperty<FMapProperty>(GET_MEMBER_NAME_CHECKED(UDirectiveUtilTestObject, TestStringMap));
FMapProperty* StringMapProperty2 = GetTestProperty<FMapProperty>(GET_MEMBER_NAME_CHECKED(UDirectiveUtilTestObject, TestStringMap2));
FArrayProperty* StringArrayProperty = GetTestProperty<FArrayProperty>(GET_MEMBER_NAME_CHECKED(UDirectiveUtilTestObject, TestStringArray));
FMapProperty* StructMapProperty = GetTestProperty<FMapProperty>(GET_MEMBER_NAME_CHECKED(UDirectiveUtilTestObject, TestStructValueMap));
FArrayProperty* NameArrayProperty = GetTestProperty<FArrayProperty>(GET_MEMBER_NAME_CHECKED(UDirectiveUtilTestObject, TestNameArray));
if (!TestNotNull("Integer map property should be available", IntegerMapProperty)
|| !TestNotNull("Integer array property should be available", IntegerArrayProperty)
|| !TestNotNull("String map property should be available", StringMapProperty)
|| !TestNotNull("Second string map property should be available", StringMapProperty2)
|| !TestNotNull("String array property should be available", StringArrayProperty)
|| !TestNotNull("Struct map property should be available", StructMapProperty)
|| !TestNotNull("Name array property should be available", NameArrayProperty))
{
return false;
}
UDirectiveUtilTestObject* TestObject = NewObject<UDirectiveUtilTestObject>();
for (const int32 ItemCount : {0, 1, 2, 16, 257, 4096})
{
TestObject->TestMap.Reset();
for (int32 Key = 0; Key < ItemCount; ++Key)
{
TestObject->TestMap.Add(Key, Key % 7);
}
const FString Label = FString::Printf(TEXT("integer items=%d"), ItemCount);
const int32 QueryValue = 3;
TArray<int32> ExpectedKeys;
for (int32 Key = 0; Key < ItemCount; ++Key)
{
if (Key % 7 == QueryValue)
{
ExpectedKeys.Add(Key);
}
}
TestObject->TestArray.Reset();
UDirectiveUtilMapFunctionLibrary::GenericMap_GetKeysByValue(
&TestObject->TestMap,
IntegerMapProperty,
&QueryValue,
&TestObject->TestArray,
IntegerArrayProperty);
TestEqual(Label + TEXT(" GetKeysByValue count"), TestObject->TestArray.Num(), ExpectedKeys.Num());
TestTrue(Label + TEXT(" GetKeysByValue values"), HaveSameValues(MakeIntegerSet(TestObject->TestArray), MakeIntegerSet(ExpectedKeys)));
TestEqual(
Label + TEXT(" HasValue"),
UDirectiveUtilMapFunctionLibrary::GenericMap_HasValue(&TestObject->TestMap, IntegerMapProperty, &QueryValue),
!ExpectedKeys.IsEmpty());
const int32 ExistingKey = ItemCount > 0 ? ItemCount / 2 : 0;
int32 FoundValue = -1;
UDirectiveUtilMapFunctionLibrary::GenericMap_FindOrAdd(
&TestObject->TestMap,
IntegerMapProperty,
&ExistingKey,
&FoundValue);
TestEqual(Label + TEXT(" FindOrAdd value"), FoundValue, ItemCount > 0 ? ExistingKey % 7 : 0);
UDirectiveUtilMapFunctionLibrary::GenericMap_ClearValues(&TestObject->TestMap, IntegerMapProperty);
TestEqual(Label + TEXT(" ClearValues count"), TestObject->TestMap.Num(), FMath::Max(ItemCount, 1));
for (const TPair<int32, int32>& Pair : TestObject->TestMap)
{
TestEqual(Label + TEXT(" ClearValues value"), Pair.Value, 0);
}
TestObject->TestArray.Reset();
for (int32 Key = 0; Key < ItemCount; Key += 3)
{
TestObject->TestArray.Add(Key);
}
const int32 ExpectedRemoved = TestObject->TestArray.Num();
const int32 Removed = UDirectiveUtilMapFunctionLibrary::GenericMap_RemoveKeys(
&TestObject->TestMap,
IntegerMapProperty,
&TestObject->TestArray,
IntegerArrayProperty);
TestEqual(Label + TEXT(" RemoveKeys count"), Removed, ExpectedRemoved);
TestEqual(Label + TEXT(" RemoveKeys remaining"), TestObject->TestMap.Num(), FMath::Max(ItemCount, 1) - ExpectedRemoved);
}
for (const int32 ItemCount : {0, 1, 16, 257})
{
TestObject->TestStringMap.Reset();
TestObject->TestStringMap2.Reset();
for (int32 Index = 0; Index < ItemCount; ++Index)
{
const FString Key = FString::Printf(TEXT("Key%d"), Index);
TestObject->TestStringMap.Add(Key, FString::Printf(TEXT("Original%d"), Index));
TestObject->TestStringMap2.Add(Key, FString::Printf(TEXT("Replacement%d"), Index));
}
TestObject->TestStringMap2.Add(TEXT("Added"), TEXT("AddedValue"));
const FString Label = FString::Printf(TEXT("string items=%d"), ItemCount);
UDirectiveUtilMapFunctionLibrary::GenericMap_Append(
&TestObject->TestStringMap,
StringMapProperty,
&TestObject->TestStringMap2,
StringMapProperty2,
false);
TestEqual(Label + TEXT(" Append count"), TestObject->TestStringMap.Num(), ItemCount + 1);
TestEqual(Label + TEXT(" Append new value"), TestObject->TestStringMap.FindRef(TEXT("Added")), FString(TEXT("AddedValue")));
if (ItemCount > 0)
{
TestEqual(Label + TEXT(" Append preserve"), TestObject->TestStringMap.FindRef(TEXT("Key0")), FString(TEXT("Original0")));
}
UDirectiveUtilMapFunctionLibrary::GenericMap_Append(
&TestObject->TestStringMap,
StringMapProperty,
&TestObject->TestStringMap2,
StringMapProperty2,
true);
if (ItemCount > 0)
{
TestEqual(Label + TEXT(" Append overwrite"), TestObject->TestStringMap.FindRef(TEXT("Key0")), FString(TEXT("Replacement0")));
}
const int32 CountBeforeSelfAppend = TestObject->TestStringMap.Num();
UDirectiveUtilMapFunctionLibrary::GenericMap_Append(
&TestObject->TestStringMap,
StringMapProperty,
&TestObject->TestStringMap,
StringMapProperty,
true);
TestEqual(Label + TEXT(" self append"), TestObject->TestStringMap.Num(), CountBeforeSelfAppend);
const FString QueryValue = ItemCount > 0 ? TEXT("Replacement0") : TEXT("missing");
TestObject->TestStringArray.Reset();
UDirectiveUtilMapFunctionLibrary::GenericMap_GetKeysByValue(
&TestObject->TestStringMap,
StringMapProperty,
&QueryValue,
&TestObject->TestStringArray,
StringArrayProperty);
const TArray<FString> ExpectedKeys = ItemCount > 0 ? TArray<FString>({TEXT("Key0")}) : TArray<FString>();
TestTrue(Label + TEXT(" managed GetKeysByValue"), HaveSameValues(MakeStringSet(TestObject->TestStringArray), MakeStringSet(ExpectedKeys)));
UDirectiveUtilMapFunctionLibrary::GenericMap_ClearValues(&TestObject->TestStringMap, StringMapProperty);
for (const TPair<FString, FString>& Pair : TestObject->TestStringMap)
{
TestTrue(Label + TEXT(" managed ClearValues"), Pair.Value.IsEmpty());
}
}
FDirectiveUtilCollisionValue FirstStructValue;
FirstStructValue.Value = 1;
FDirectiveUtilCollisionValue SecondStructValue;
SecondStructValue.Value = 2;
TestObject->TestStructValueMap = {
{TEXT("Alpha"), FirstStructValue},
{TEXT("Beta"), SecondStructValue},
{TEXT("Gamma"), FirstStructValue}
};
const FDirectiveUtilCollisionValue StructQuery = FirstStructValue;
TestObject->TestNameArray.Reset();
UDirectiveUtilMapFunctionLibrary::GenericMap_GetKeysByValue(
&TestObject->TestStructValueMap,
StructMapProperty,
&StructQuery,
&TestObject->TestNameArray,
NameArrayProperty);
TestTrue(
"Struct GetKeysByValue",
HaveSameValues(
MakeNameSet(TestObject->TestNameArray),
MakeNameSet(TArray<FName>({TEXT("Alpha"), TEXT("Gamma")}))));
TestTrue(
"Struct HasValue",
UDirectiveUtilMapFunctionLibrary::GenericMap_HasValue(
&TestObject->TestStructValueMap,
StructMapProperty,
&StructQuery));
UDirectiveUtilMapFunctionLibrary::GenericMap_ClearValues(&TestObject->TestStructValueMap, StructMapProperty);
for (const TPair<FName, FDirectiveUtilCollisionValue>& Pair : TestObject->TestStructValueMap)
{
TestEqual("Struct ClearValues", Pair.Value.Value, 0);
}
return true;
}

View File

@@ -0,0 +1,540 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilMathFunctionLibrary.h"
#include "Async/ParallelFor.h"
#include "Misc/AutomationTest.h"
#include <limits>
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilMathExtendedFunctionLibraryTest,
"DirectiveUtilities.Math.ExtendedFunctionLibrary",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilMathExtendedFunctionLibraryTest::RunTest(const FString& Parameters)
{
float FloatResult = 0.0f;
float Strength = 0.0f;
TestTrue("Angle array average accepts values across the degree seam",
UDirectiveUtilMathFunctionLibrary::GetAngleArrayAverage({350.0f, 10.0f}, FloatResult, Strength));
TestTrue("Angle array average crosses the degree seam",
FMath::IsNearlyZero(FloatResult, 1.e-4f));
TestTrue("Angle array average reports concentration",
FMath::IsNearlyEqual(Strength, FMath::Cos(FMath::DegreesToRadians(10.0f)), 1.e-4f));
TestFalse("Angle array average rejects an undefined antipodal mean",
UDirectiveUtilMathFunctionLibrary::GetAngleArrayAverage({0.0f, 180.0f}, FloatResult, Strength));
TestFalse("Angle array average rejects non-finite values",
UDirectiveUtilMathFunctionLibrary::GetAngleArrayAverage(
{0.0f, std::numeric_limits<float>::quiet_NaN()}, FloatResult, Strength));
TestTrue("Weighted float average accepts aligned arrays",
UDirectiveUtilMathFunctionLibrary::GetWeightedFloatArrayAverage(
{10.0f, 20.0f}, {1.0f, 3.0f}, FloatResult));
TestTrue("Weighted float average applies weights",
FMath::IsNearlyEqual(FloatResult, 17.5f, 1.e-4f));
TestTrue("Weighted float average ignores negative weights",
UDirectiveUtilMathFunctionLibrary::GetWeightedFloatArrayAverage(
{10.0f, 20.0f}, {-1.0f, 2.0f}, FloatResult)
&& FMath::IsNearlyEqual(FloatResult, 20.0f, 1.e-4f));
TestFalse("Weighted float average rejects mismatched arrays",
UDirectiveUtilMathFunctionLibrary::GetWeightedFloatArrayAverage(
{10.0f}, {1.0f, 2.0f}, FloatResult));
FVector VectorResult = FVector::ZeroVector;
TestTrue("Weighted vector average accepts aligned arrays",
UDirectiveUtilMathFunctionLibrary::GetWeightedVectorArrayAverage(
{FVector::ForwardVector, FVector::RightVector}, {1.0f, 1.0f}, VectorResult));
TestTrue("Weighted vector average applies weights",
VectorResult.Equals(FVector(0.5, 0.5, 0.0), 1.e-6));
TestTrue("Weighted vector average preserves large finite values",
UDirectiveUtilMathFunctionLibrary::GetWeightedVectorArrayAverage(
{FVector(1.e300, 0.0, 0.0)}, {1.e20f}, VectorResult)
&& FMath::IsNearlyEqual(VectorResult.X / 1.e300, 1.0, 1.e-12));
TestFalse("Weighted vector average rejects non-finite values",
UDirectiveUtilMathFunctionLibrary::GetWeightedVectorArrayAverage(
{FVector::ForwardVector, FVector(std::numeric_limits<double>::infinity(), 0.0, 0.0)},
{1.0f, 1.0f}, VectorResult));
TArray<float> FloatArrayResult;
TestTrue("Float array normalization accepts finite values",
UDirectiveUtilMathFunctionLibrary::NormalizeFloatArrayToRange(
{2.0f, 4.0f, 6.0f}, -1.0f, 1.0f, FloatArrayResult));
TestTrue("Float array normalization maps the full range",
FloatArrayResult.Num() == 3
&& FMath::IsNearlyEqual(FloatArrayResult[0], -1.0f)
&& FMath::IsNearlyZero(FloatArrayResult[1])
&& FMath::IsNearlyEqual(FloatArrayResult[2], 1.0f));
TestTrue("Float array normalization accepts reversed output bounds",
UDirectiveUtilMathFunctionLibrary::NormalizeFloatArrayToRange(
{2.0f, 4.0f, 6.0f}, 1.0f, -1.0f, FloatArrayResult)
&& FMath::IsNearlyEqual(FloatArrayResult[0], 1.0f)
&& FMath::IsNearlyEqual(FloatArrayResult[2], -1.0f));
TestTrue("Float array normalization maps a constant array to the output minimum",
UDirectiveUtilMathFunctionLibrary::NormalizeFloatArrayToRange(
{4.0f, 4.0f}, 5.0f, 10.0f, FloatArrayResult)
&& FloatArrayResult == TArray<float>({5.0f, 5.0f}));
TestFalse("Float array normalization rejects an empty array",
UDirectiveUtilMathFunctionLibrary::NormalizeFloatArrayToRange(
{}, 0.0f, 1.0f, FloatArrayResult));
TArray<float> InPlaceValues = {2.0f, 4.0f, 6.0f};
TestTrue("Float array normalization supports the same input and output array",
UDirectiveUtilMathFunctionLibrary::NormalizeFloatArrayToRange(
InPlaceValues, -1.0f, 1.0f, InPlaceValues)
&& InPlaceValues == TArray<float>({-1.0f, 0.0f, 1.0f}));
TestTrue("Weight normalization accepts positive weights",
UDirectiveUtilMathFunctionLibrary::NormalizeWeights({1.0f, 3.0f}, FloatArrayResult));
TestTrue("Weight normalization sums to one",
FloatArrayResult.Num() == 2
&& FMath::IsNearlyEqual(FloatArrayResult[0], 0.25f)
&& FMath::IsNearlyEqual(FloatArrayResult[1], 0.75f));
TestTrue("Weight normalization clears negative weights",
UDirectiveUtilMathFunctionLibrary::NormalizeWeights({-2.0f, 2.0f}, FloatArrayResult)
&& FMath::IsNearlyZero(FloatArrayResult[0])
&& FMath::IsNearlyEqual(FloatArrayResult[1], 1.0f));
TestFalse("Weight normalization rejects all-zero weights",
UDirectiveUtilMathFunctionLibrary::NormalizeWeights({0.0f, -1.0f}, FloatArrayResult));
TArray<float> InPlaceWeights = {1.0f, 3.0f};
TestTrue("Weight normalization supports the same input and output array",
UDirectiveUtilMathFunctionLibrary::NormalizeWeights(InPlaceWeights, InPlaceWeights)
&& InPlaceWeights == TArray<float>({0.25f, 0.75f}));
TestTrue("Float array percentile accepts finite values",
UDirectiveUtilMathFunctionLibrary::GetFloatArrayPercentile(
{4.0f, 1.0f, 3.0f, 2.0f}, 25.0f, FloatResult));
TestTrue("Float array percentile interpolates adjacent values",
FMath::IsNearlyEqual(FloatResult, 1.75f, 1.e-4f));
TestTrue("Float array percentile clamps above one hundred",
UDirectiveUtilMathFunctionLibrary::GetFloatArrayPercentile(
{1.0f, 4.0f}, 125.0f, FloatResult)
&& FMath::IsNearlyEqual(FloatResult, 4.0f));
TestTrue("Float array percentile uses the Type 7 sample position",
UDirectiveUtilMathFunctionLibrary::GetFloatArrayPercentile(
{10.0f, 1.0f, 8.0f, 2.0f, 7.0f, 3.0f, 6.0f, 4.0f, 9.0f, 5.0f}, 40.0f, FloatResult)
&& FMath::IsNearlyEqual(FloatResult, 4.6f, 1.e-4f));
TestFalse("Float array percentile rejects non-finite values",
UDirectiveUtilMathFunctionLibrary::GetFloatArrayPercentile(
{1.0f, std::numeric_limits<float>::infinity()}, 50.0f, FloatResult));
TestTrue("Root mean square accepts finite values",
UDirectiveUtilMathFunctionLibrary::GetFloatArrayRootMeanSquare({3.0f, 4.0f}, FloatResult));
TestTrue("Root mean square uses the arithmetic mean of squares",
FMath::IsNearlyEqual(FloatResult, FMath::Sqrt(12.5f), 1.e-4f));
TestFalse("Root mean square rejects an empty array",
UDirectiveUtilMathFunctionLibrary::GetFloatArrayRootMeanSquare({}, FloatResult));
TestTrue("Smooth Step clamps below its range",
FMath::IsNearlyZero(UDirectiveUtilMathFunctionLibrary::SmoothStep(-1.0f, 0.0f, 1.0f)));
TestTrue("Smooth Step reaches its midpoint",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::SmoothStep(0.5f, 0.0f, 1.0f), 0.5f));
TestTrue("Smooth Step accepts reversed bounds",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::SmoothStep(0.25f, 1.0f, 0.0f), 0.15625f));
TestTrue("Smooth Step treats equal bounds as a step",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::SmoothStep(2.0f, 2.0f, 2.0f), 1.0f));
TestTrue("Smoother Step reaches its midpoint",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::SmootherStep(0.5f, 0.0f, 1.0f), 0.5f));
TestTrue("Smoother Step has quintic shaping",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::SmootherStep(0.25f, 0.0f, 1.0f), 0.103515625f));
TestTrue("Range Falloff applies linear attenuation",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::RangeFalloff(5.0f, 0.0f, 10.0f), 0.5f));
TestTrue("Range Falloff applies its exponent",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::RangeFalloff(5.0f, 0.0f, 10.0f, 2.0f), 0.25f));
TestTrue("Range Falloff remains one inside the inner radius",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::RangeFalloff(2.0f, 3.0f, 10.0f), 1.0f));
TestTrue("Range Falloff reaches zero at the outer radius",
FMath::IsNearlyZero(UDirectiveUtilMathFunctionLibrary::RangeFalloff(10.0f, 3.0f, 10.0f)));
double Distance = 0.0;
TestTrue("Direction and distance accepts distinct points",
UDirectiveUtilMathFunctionLibrary::GetDirectionAndDistance(
FVector::ZeroVector, FVector(3.0, 4.0, 0.0), VectorResult, Distance));
TestTrue("Direction and distance returns a unit direction",
VectorResult.Equals(FVector(0.6, 0.8, 0.0), 1.e-6));
TestTrue("Direction and distance returns the length",
FMath::IsNearlyEqual(Distance, 5.0));
TestFalse("Direction and distance rejects equal points",
UDirectiveUtilMathFunctionLibrary::GetDirectionAndDistance(
FVector::ZeroVector, FVector::ZeroVector, VectorResult, Distance));
TestTrue("Direction and distance handles large finite coordinates",
UDirectiveUtilMathFunctionLibrary::GetDirectionAndDistance(
FVector::ZeroVector, FVector(1.e200, 0.0, 0.0), VectorResult, Distance)
&& VectorResult.Equals(FVector::ForwardVector, 1.e-12)
&& FMath::IsNearlyEqual(Distance / 1.e200, 1.0, 1.e-12));
TestTrue("Signed angle handles large finite vectors",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::SignedAngleBetweenVectors(
FVector(1.e200, 0.0, 0.0), FVector(0.0, 1.e200, 0.0), FVector::UpVector), 90.0f, 1.e-4f));
TestTrue("Direction Within Cone handles large finite vectors",
UDirectiveUtilMathFunctionLibrary::IsDirectionWithinCone(
FVector(1.e200, 0.0, 0.0), FVector(1.e200, 0.0, 0.0), 0.0f));
TestTrue("Direction Within Cone includes an identical non-axis direction at zero width",
UDirectiveUtilMathFunctionLibrary::IsDirectionWithinCone(
FVector(3.e200, 2.e200, 1.e200), FVector(3.e200, 2.e200, 1.e200), 0.0f));
TestFalse("Direction Within Cone excludes a measurable angle from a zero-width cone",
UDirectiveUtilMathFunctionLibrary::IsDirectionWithinCone(
FVector(FMath::Cos(FMath::DegreesToRadians(0.005)), FMath::Sin(FMath::DegreesToRadians(0.005)), 0.0),
FVector::ForwardVector, 0.0f));
const FVector2D RotatedPoint = UDirectiveUtilMathFunctionLibrary::RotatePointAroundPivot2D(
FVector2D(2.0, 1.0), FVector2D(1.0, 1.0), 90.0f);
TestTrue("Rotate Point Around Pivot 2D preserves the pivot offset",
RotatedPoint.Equals(FVector2D(1.0, 2.0), 1.e-6));
TestTrue("Rotate Point Around Pivot 2D rejects non-finite input",
UDirectiveUtilMathFunctionLibrary::RotatePointAroundPivot2D(
FVector2D(std::numeric_limits<double>::infinity(), 0.0), FVector2D::ZeroVector, 90.0f).IsZero());
TestTrue("Signed Distance To Plane is positive in front of the plane",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::SignedDistanceToPlane(
FVector(0.0, 0.0, 5.0), FVector(0.0, 0.0, 2.0), FVector::UpVector), 3.0));
TestTrue("Signed Distance To Plane follows the normal direction",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::SignedDistanceToPlane(
FVector(0.0, 0.0, 5.0), FVector(0.0, 0.0, 2.0), -FVector::UpVector), -3.0));
TestTrue("Signed Distance To Plane rejects a zero normal",
FMath::IsNearlyZero(UDirectiveUtilMathFunctionLibrary::SignedDistanceToPlane(
FVector::UpVector, FVector::ZeroVector, FVector::ZeroVector)));
TestTrue("Signed Distance To Plane handles a large finite normal",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::SignedDistanceToPlane(
FVector::ForwardVector, FVector::ZeroVector, FVector(1.e200, 0.0, 0.0)), 1.0, 1.e-12));
TestTrue("Point Within Cone includes a point inside the cone",
UDirectiveUtilMathFunctionLibrary::IsPointWithinCone(
FVector(10.0, 0.0, 0.0), FVector::ZeroVector, FVector::ForwardVector, 10.0f, 20.0));
TestFalse("Point Within Cone excludes a point outside the cone",
UDirectiveUtilMathFunctionLibrary::IsPointWithinCone(
FVector(0.0, 10.0, 0.0), FVector::ZeroVector, FVector::ForwardVector, 10.0f, 20.0));
TestFalse("Point Within Cone applies the maximum distance",
UDirectiveUtilMathFunctionLibrary::IsPointWithinCone(
FVector(10.0, 0.0, 0.0), FVector::ZeroVector, FVector::ForwardVector, 10.0f, 5.0));
TestTrue("Point Within Cone treats zero maximum distance as unlimited",
UDirectiveUtilMathFunctionLibrary::IsPointWithinCone(
FVector(10.0, 0.0, 0.0), FVector::ZeroVector, FVector::ForwardVector, 10.0f));
TestFalse("Point Within Cone applies maximum distance to large finite coordinates",
UDirectiveUtilMathFunctionLibrary::IsPointWithinCone(
FVector(1.5e200, 0.0, 0.0), FVector::ZeroVector, FVector::ForwardVector, 180.0f, 1.e200));
bool bCircleSamplesValid = true;
bool bAnnulusSamplesValid = true;
bool bSphereSamplesValid = true;
for (int32 Index = 0; Index < 100; ++Index)
{
const FVector2D CirclePoint = UDirectiveUtilMathFunctionLibrary::RandomPointInCircle(5.0f);
const FVector2D AnnulusPoint = UDirectiveUtilMathFunctionLibrary::RandomPointInAnnulus(2.0f, 5.0f);
const FVector SpherePoint = UDirectiveUtilMathFunctionLibrary::RandomPointInSphere(5.0f);
bCircleSamplesValid &= CirclePoint.Size() <= 5.0 + 1.e-6;
bAnnulusSamplesValid &= AnnulusPoint.Size() >= 2.0 - 1.e-6 && AnnulusPoint.Size() <= 5.0 + 1.e-6;
bSphereSamplesValid &= SpherePoint.Size() <= 5.0 + 1.e-6;
}
TestTrue("Random Point In Circle stays within its radius", bCircleSamplesValid);
TestTrue("Random Point In Annulus stays between its radii", bAnnulusSamplesValid);
TestTrue("Random Point In Sphere stays within its radius", bSphereSamplesValid);
FRandomStream FirstStream(12345);
FRandomStream SecondStream(12345);
TestTrue("Random Point In Circle stream variant is deterministic",
UDirectiveUtilMathFunctionLibrary::RandomPointInCircleFromStream(FirstStream, 5.0f).Equals(
UDirectiveUtilMathFunctionLibrary::RandomPointInCircleFromStream(SecondStream, 5.0f), 1.e-9));
TestTrue("Random Point In Annulus stream variant is deterministic",
UDirectiveUtilMathFunctionLibrary::RandomPointInAnnulusFromStream(FirstStream, 2.0f, 5.0f).Equals(
UDirectiveUtilMathFunctionLibrary::RandomPointInAnnulusFromStream(SecondStream, 2.0f, 5.0f), 1.e-9));
TestTrue("Random Point In Sphere stream variant is deterministic",
UDirectiveUtilMathFunctionLibrary::RandomPointInSphereFromStream(FirstStream, 5.0f).Equals(
UDirectiveUtilMathFunctionLibrary::RandomPointInSphereFromStream(SecondStream, 5.0f), 1.e-9));
#if WITH_EDITOR
const TArray<FName> StreamRandomFunctions = {
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GetRandomIndexFromWeightsFromStream),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, RandomPointInCircleFromStream),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, RandomPointInAnnulusFromStream),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, RandomPointInSphereFromStream)
};
for (const FName FunctionName : StreamRandomFunctions)
{
const UFunction* Function = UDirectiveUtilMathFunctionLibrary::StaticClass()->FindFunctionByName(FunctionName);
TestTrue(*FString::Printf(TEXT("%s should be exposed to Blueprint"), *FunctionName.ToString()), Function != nullptr);
if (Function)
{
TestFalse(
*FString::Printf(TEXT("%s should not advertise inert Blueprint thread safety"), *FunctionName.ToString()),
Function->HasMetaData(TEXT("BlueprintThreadSafe")));
}
}
#endif
TArray<FVector> ParallelRandomResults;
ParallelRandomResults.SetNumUninitialized(64);
ParallelFor(ParallelRandomResults.Num(), [&ParallelRandomResults](const int32 TaskIndex)
{
FRandomStream Stream(99173);
const FVector2D Circle = UDirectiveUtilMathFunctionLibrary::RandomPointInCircleFromStream(Stream, 5.0f);
const FVector2D Annulus = UDirectiveUtilMathFunctionLibrary::RandomPointInAnnulusFromStream(Stream, 2.0f, 5.0f);
ParallelRandomResults[TaskIndex] = UDirectiveUtilMathFunctionLibrary::RandomPointInSphereFromStream(Stream, 5.0f)
+ FVector(Circle.X, Circle.Y, Annulus.X + Annulus.Y);
});
bool bParallelRandomResultsMatch = true;
for (int32 Index = 1; Index < ParallelRandomResults.Num(); ++Index)
{
bParallelRandomResultsMatch &= ParallelRandomResults[Index].Equals(ParallelRandomResults[0], 1.e-12);
}
TestTrue("Seeded random nodes should remain deterministic across worker tasks", bParallelRandomResultsMatch);
FRandomStream ExpectedCircleStream(24680);
const double ExpectedCircleAngle = static_cast<double>(ExpectedCircleStream.FRand()) * UE_TWO_PI;
const double ExpectedCircleRadius = FMath::Sqrt(static_cast<double>(ExpectedCircleStream.FRand())) * 5.0;
const FVector2D ExpectedCirclePoint(
FMath::Cos(ExpectedCircleAngle) * ExpectedCircleRadius,
FMath::Sin(ExpectedCircleAngle) * ExpectedCircleRadius);
FRandomStream ActualCircleStream(24680);
const FVector2D ActualCirclePoint =
UDirectiveUtilMathFunctionLibrary::RandomPointInCircleFromStream(ActualCircleStream, 5.0f);
TestTrue("Random Point In Circle consumes angle before radius",
ActualCirclePoint.Equals(ExpectedCirclePoint, 1.e-6));
TestEqual("Random Point In Circle consumes two stream samples",
ActualCircleStream.GetCurrentSeed(), ExpectedCircleStream.GetCurrentSeed());
FRandomStream ExpectedAnnulusStream(13579);
const double ExpectedAnnulusAngle = static_cast<double>(ExpectedAnnulusStream.FRand()) * UE_TWO_PI;
const double ExpectedAnnulusRadius = FMath::Sqrt(FMath::Lerp(
4.0, 25.0, static_cast<double>(ExpectedAnnulusStream.FRand())));
const FVector2D ExpectedAnnulusPoint(
FMath::Cos(ExpectedAnnulusAngle) * ExpectedAnnulusRadius,
FMath::Sin(ExpectedAnnulusAngle) * ExpectedAnnulusRadius);
FRandomStream ActualAnnulusStream(13579);
const FVector2D ActualAnnulusPoint =
UDirectiveUtilMathFunctionLibrary::RandomPointInAnnulusFromStream(ActualAnnulusStream, 2.0f, 5.0f);
TestTrue("Random Point In Annulus consumes angle before radius",
ActualAnnulusPoint.Equals(ExpectedAnnulusPoint, 1.e-6));
TestEqual("Random Point In Annulus consumes two stream samples",
ActualAnnulusStream.GetCurrentSeed(), ExpectedAnnulusStream.GetCurrentSeed());
FRandomStream ExpectedSphereStream(97531);
FVector ExpectedSpherePoint;
double ExpectedSphereSizeSquared;
do
{
const double X = static_cast<double>(ExpectedSphereStream.FRand()) * 2.0 - 1.0;
const double Y = static_cast<double>(ExpectedSphereStream.FRand()) * 2.0 - 1.0;
const double Z = static_cast<double>(ExpectedSphereStream.FRand()) * 2.0 - 1.0;
ExpectedSpherePoint = FVector(X, Y, Z);
ExpectedSphereSizeSquared = ExpectedSpherePoint.SizeSquared();
}
while (ExpectedSphereSizeSquared > 1.0);
ExpectedSpherePoint *= 5.0;
FRandomStream ActualSphereStream(97531);
TestTrue("Random Point In Sphere consumes coordinates in XYZ order",
UDirectiveUtilMathFunctionLibrary::RandomPointInSphereFromStream(ActualSphereStream, 5.0f).Equals(
ExpectedSpherePoint, 1.e-12)
&& ActualSphereStream.GetCurrentSeed() == ExpectedSphereStream.GetCurrentSeed());
FRandomStream UnchangedStream(86420);
const int32 UnchangedSeed = UnchangedStream.GetCurrentSeed();
UDirectiveUtilMathFunctionLibrary::RandomPointInCircleFromStream(UnchangedStream, 0.0f);
UDirectiveUtilMathFunctionLibrary::RandomPointInAnnulusFromStream(UnchangedStream, 0.0f, 0.0f);
UDirectiveUtilMathFunctionLibrary::RandomPointInSphereFromStream(
UnchangedStream, std::numeric_limits<float>::infinity());
TestEqual("Invalid and zero radii do not advance random streams",
UnchangedStream.GetCurrentSeed(), UnchangedSeed);
FRandomStream DistributionStream(112358);
double CircleDistributionMean = 0.0;
double AnnulusDistributionMean = 0.0;
double SphereDistributionMean = 0.0;
constexpr int32 DistributionSampleCount = 10000;
for (int32 Index = 0; Index < DistributionSampleCount; ++Index)
{
const FVector2D CirclePoint = UDirectiveUtilMathFunctionLibrary::RandomPointInCircleFromStream(
DistributionStream, 5.0f);
const FVector2D AnnulusPoint = UDirectiveUtilMathFunctionLibrary::RandomPointInAnnulusFromStream(
DistributionStream, 2.0f, 5.0f);
const FVector SpherePoint = UDirectiveUtilMathFunctionLibrary::RandomPointInSphereFromStream(
DistributionStream, 5.0f);
CircleDistributionMean += CirclePoint.SizeSquared() / 25.0;
AnnulusDistributionMean += (AnnulusPoint.SizeSquared() - 4.0) / 21.0;
SphereDistributionMean += FMath::Pow(SpherePoint.Size() / 5.0, 3.0);
}
CircleDistributionMean /= DistributionSampleCount;
AnnulusDistributionMean /= DistributionSampleCount;
SphereDistributionMean /= DistributionSampleCount;
TestTrue("Random Point In Circle is uniform by area",
FMath::IsNearlyEqual(CircleDistributionMean, 0.5, 0.02));
TestTrue("Random Point In Annulus is uniform by area",
FMath::IsNearlyEqual(AnnulusDistributionMean, 0.5, 0.02));
TestTrue("Random Point In Sphere is uniform by volume",
FMath::IsNearlyEqual(SphereDistributionMean, 0.5, 0.02));
TestTrue("Random point functions reject non-finite radii",
UDirectiveUtilMathFunctionLibrary::RandomPointInCircle(
std::numeric_limits<float>::quiet_NaN()).IsZero()
&& UDirectiveUtilMathFunctionLibrary::RandomPointInAnnulus(
0.0f, std::numeric_limits<float>::infinity()).IsZero()
&& UDirectiveUtilMathFunctionLibrary::RandomPointInSphere(
std::numeric_limits<float>::infinity()).IsZero());
TestTrue("Angle array average accepts equivalent angles across multiple turns",
UDirectiveUtilMathFunctionLibrary::GetAngleArrayAverage({730.0f, -710.0f}, FloatResult, Strength)
&& FMath::IsNearlyEqual(FloatResult, 10.0f, 1.e-4f)
&& FMath::IsNearlyEqual(Strength, 1.0f, 1.e-4f));
FloatResult = 123.0f;
Strength = 123.0f;
TestFalse("Angle array average rejects an empty array",
UDirectiveUtilMathFunctionLibrary::GetAngleArrayAverage({}, FloatResult, Strength));
TestTrue("A rejected angle average resets both outputs",
FMath::IsNearlyZero(FloatResult) && FMath::IsNearlyZero(Strength));
FloatResult = 123.0f;
TestTrue("Weighted float average ignores unusable weights",
UDirectiveUtilMathFunctionLibrary::GetWeightedFloatArrayAverage(
{10.0f, 20.0f, 30.0f, 40.0f},
{std::numeric_limits<float>::quiet_NaN(), -1.0f, std::numeric_limits<float>::infinity(), 2.0f},
FloatResult)
&& FMath::IsNearlyEqual(FloatResult, 40.0f));
FloatResult = 123.0f;
TestFalse("Weighted float average rejects arrays without a usable weight",
UDirectiveUtilMathFunctionLibrary::GetWeightedFloatArrayAverage(
{10.0f, 20.0f}, {-1.0f, std::numeric_limits<float>::quiet_NaN()}, FloatResult));
TestTrue("A rejected weighted float average resets its output", FMath::IsNearlyZero(FloatResult));
VectorResult = FVector(123.0);
TestTrue("Weighted vector average ignores unusable weights",
UDirectiveUtilMathFunctionLibrary::GetWeightedVectorArrayAverage(
{FVector(10.0, 20.0, 30.0), FVector(-4.0, 5.0, -6.0)},
{std::numeric_limits<float>::infinity(), 3.0f}, VectorResult)
&& VectorResult.Equals(FVector(-4.0, 5.0, -6.0), 1.e-9));
VectorResult = FVector(123.0);
TestFalse("Weighted vector average rejects arrays without a usable weight",
UDirectiveUtilMathFunctionLibrary::GetWeightedVectorArrayAverage(
{FVector::ForwardVector}, {-1.0f}, VectorResult));
TestTrue("A rejected weighted vector average resets its output", VectorResult.IsZero());
FloatArrayResult = {123.0f};
TestFalse("Float array normalization rejects a non-finite source value",
UDirectiveUtilMathFunctionLibrary::NormalizeFloatArrayToRange(
{1.0f, std::numeric_limits<float>::quiet_NaN()}, 0.0f, 1.0f, FloatArrayResult));
TestTrue("Rejected float array normalization clears its output", FloatArrayResult.IsEmpty());
TestFalse("Float array normalization rejects a non-finite output bound",
UDirectiveUtilMathFunctionLibrary::NormalizeFloatArrayToRange(
{1.0f, 2.0f}, 0.0f, std::numeric_limits<float>::infinity(), FloatArrayResult));
TestTrue("Weight normalization ignores non-finite and negative weights",
UDirectiveUtilMathFunctionLibrary::NormalizeWeights(
{std::numeric_limits<float>::quiet_NaN(), std::numeric_limits<float>::infinity(), -2.0f, 4.0f},
FloatArrayResult)
&& FloatArrayResult == TArray<float>({0.0f, 0.0f, 0.0f, 1.0f}));
FloatArrayResult = {123.0f};
TestFalse("Weight normalization rejects an empty array",
UDirectiveUtilMathFunctionLibrary::NormalizeWeights({}, FloatArrayResult));
TestTrue("Rejected weight normalization clears its output", FloatArrayResult.IsEmpty());
TestTrue("Percentiles clamp below zero",
UDirectiveUtilMathFunctionLibrary::GetFloatArrayPercentile({7.0f, 3.0f, 11.0f}, -50.0f, FloatResult)
&& FMath::IsNearlyEqual(FloatResult, 3.0f));
TestTrue("Percentiles return the maximum at one hundred",
UDirectiveUtilMathFunctionLibrary::GetFloatArrayPercentile({7.0f, 3.0f, 11.0f}, 100.0f, FloatResult)
&& FMath::IsNearlyEqual(FloatResult, 11.0f));
TestTrue("A single-value percentile is stable at every finite percentile",
UDirectiveUtilMathFunctionLibrary::GetFloatArrayPercentile({-7.5f}, 37.25f, FloatResult)
&& FMath::IsNearlyEqual(FloatResult, -7.5f));
FloatResult = 123.0f;
TestFalse("Percentiles reject a non-finite percentile",
UDirectiveUtilMathFunctionLibrary::GetFloatArrayPercentile(
{1.0f, 2.0f}, std::numeric_limits<float>::quiet_NaN(), FloatResult));
TestTrue("A rejected percentile resets its output", FMath::IsNearlyZero(FloatResult));
const float LargeFiniteValue = std::numeric_limits<float>::max() * 0.25f;
TestTrue("Root mean square remains finite near the float limit",
UDirectiveUtilMathFunctionLibrary::GetFloatArrayRootMeanSquare(
{LargeFiniteValue, -LargeFiniteValue}, FloatResult)
&& FMath::IsFinite(FloatResult)
&& FMath::IsNearlyEqual(FloatResult / LargeFiniteValue, 1.0f, 1.e-5f));
FloatResult = 123.0f;
TestFalse("Root mean square rejects non-finite values",
UDirectiveUtilMathFunctionLibrary::GetFloatArrayRootMeanSquare(
{1.0f, std::numeric_limits<float>::infinity()}, FloatResult));
TestTrue("A rejected root mean square resets its output", FMath::IsNearlyZero(FloatResult));
TestTrue("Signed angle is invariant under positive vector scaling",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::SignedAngleBetweenVectors(
FVector(20.0, 0.0, 5.0), FVector(0.0, 30.0, -7.0), FVector(0.0, 0.0, 9.0)), 90.0f, 1.e-4f));
TestTrue("A half-turn signed angle has the expected magnitude",
FMath::IsNearlyEqual(FMath::Abs(UDirectiveUtilMathFunctionLibrary::SignedAngleBetweenVectors(
FVector::ForwardVector, -FVector::ForwardVector, FVector::UpVector)), 180.0f, 1.e-4f));
TestEqual("Signed angle rejects non-finite vectors",
UDirectiveUtilMathFunctionLibrary::SignedAngleBetweenVectors(
FVector(std::numeric_limits<double>::infinity(), 0.0, 0.0), FVector::RightVector, FVector::UpVector),
0.0f);
TestTrue("Delta angle ignores complete turns",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::DeltaAngle(-1080.0f + 15.0f, 1440.0f - 25.0f), -40.0f));
TestTrue("Angle interpolation permits negative extrapolation",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::LerpAngle(10.0f, 350.0f, -1.0f), 30.0f));
TestTrue("Angle interpolation ignores complete turns in the delta",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::LerpAngle(
-1080.0f + 15.0f, 1440.0f - 25.0f, 0.5f), -1085.0f));
TestTrue("Ping Pong repeats across multiple positive periods",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::PingPong(123.0f, -2.0f, 3.0f), 3.0f));
TestTrue("Ping Pong repeats across multiple negative periods",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::PingPong(-122.0f, -2.0f, 3.0f), -2.0f));
TestTrue("Smooth Step clamps above its range",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::SmoothStep(100.0f, -2.0f, 3.0f), 1.0f));
TestTrue("Smoother Step accepts reversed bounds",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::SmootherStep(0.25f, 1.0f, 0.0f), 0.103515625f));
TestTrue("Step functions reject non-finite values",
FMath::IsNearlyZero(UDirectiveUtilMathFunctionLibrary::SmoothStep(
std::numeric_limits<float>::quiet_NaN(), 0.0f, 1.0f))
&& FMath::IsNearlyZero(UDirectiveUtilMathFunctionLibrary::SmootherStep(
0.5f, 0.0f, std::numeric_limits<float>::infinity())));
TestTrue("Range Falloff accepts reversed radii",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::RangeFalloff(5.0f, 10.0f, 0.0f), 0.5f));
TestTrue("Range Falloff clamps negative distances to zero",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::RangeFalloff(-5.0f, 2.0f, 10.0f), 1.0f));
TestTrue("Range Falloff treats non-positive exponents as a hard inner range",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::RangeFalloff(9.0f, 2.0f, 10.0f, -3.0f), 1.0f));
TestTrue("Range Falloff is full strength at a collapsed shared radius",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::RangeFalloff(5.0f, 5.0f, 5.0f), 1.0f));
TestTrue("Range Falloff is full strength at the origin when both radii are zero",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::RangeFalloff(0.0f, 0.0f, 0.0f), 1.0f));
TestTrue("Range Falloff is zero outside a collapsed shared radius",
FMath::IsNearlyZero(UDirectiveUtilMathFunctionLibrary::RangeFalloff(6.0f, 5.0f, 5.0f)));
TestTrue("A full-width cone includes the opposite direction",
UDirectiveUtilMathFunctionLibrary::IsDirectionWithinCone(
-FVector::ForwardVector, FVector::ForwardVector, 180.0f));
TestFalse("A zero-width cone excludes the opposite direction",
UDirectiveUtilMathFunctionLibrary::IsDirectionWithinCone(
-FVector::ForwardVector, FVector::ForwardVector, 0.0f));
TestTrue("A point on both cone boundaries is included",
UDirectiveUtilMathFunctionLibrary::IsPointWithinCone(
FVector(5.0, 5.0, 0.0), FVector::ZeroVector, FVector::ForwardVector, 45.0f,
FMath::Sqrt(50.0)));
TestTrue("Negative cone distance is treated as unlimited",
UDirectiveUtilMathFunctionLibrary::IsPointWithinCone(
FVector(100.0, 0.0, 0.0), FVector::ZeroVector, FVector::ForwardVector, 0.0f, -1.0));
TestTrue("Rotating by a complete turn preserves a translated point",
UDirectiveUtilMathFunctionLibrary::RotatePointAroundPivot2D(
FVector2D(1000003.0, -1999995.0), FVector2D(1000000.0, -2000000.0), 1080.0f)
.Equals(FVector2D(1000003.0, -1999995.0), 1.e-8));
TestTrue("Signed plane distance is translation invariant",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::SignedDistanceToPlane(
FVector(1000000.0, -2000000.0, 3000007.0), FVector(1000000.0, -2000000.0, 3000000.0),
FVector(0.0, 0.0, 123.0)), 7.0, 1.e-9));
FRandomStream PositiveRadiusStream(424242);
FRandomStream NegativeRadiusStream(424242);
TestTrue("Random circle stream treats negative radius as magnitude",
UDirectiveUtilMathFunctionLibrary::RandomPointInCircleFromStream(PositiveRadiusStream, 5.0f).Equals(
UDirectiveUtilMathFunctionLibrary::RandomPointInCircleFromStream(NegativeRadiusStream, -5.0f), 1.e-12));
FRandomStream OrderedAnnulusStream(31337);
FRandomStream ReversedAnnulusStream(31337);
TestTrue("Random annulus stream accepts negative reversed radii",
UDirectiveUtilMathFunctionLibrary::RandomPointInAnnulusFromStream(OrderedAnnulusStream, 2.0f, 5.0f).Equals(
UDirectiveUtilMathFunctionLibrary::RandomPointInAnnulusFromStream(ReversedAnnulusStream, -5.0f, -2.0f),
1.e-12));
FRandomStream PositiveSphereStream(8675309);
FRandomStream NegativeSphereStream(8675309);
TestTrue("Random sphere stream treats negative radius as magnitude",
UDirectiveUtilMathFunctionLibrary::RandomPointInSphereFromStream(PositiveSphereStream, 5.0f).Equals(
UDirectiveUtilMathFunctionLibrary::RandomPointInSphereFromStream(NegativeSphereStream, -5.0f), 1.e-12));
return !HasAnyErrors();
}

View File

@@ -1,7 +1,11 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilMathFunctionLibrary.h"
#include "Misc/AutomationTest.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilMathFunctionLibraryTest, "DirectiveUtilities.MathFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
#include <limits>
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilMathFunctionLibraryTest, "DirectiveUtilities.MathFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilMathFunctionLibraryTest::RunTest(const FString& Parameters)
{
@@ -11,8 +15,84 @@ bool FDirectiveUtilMathFunctionLibraryTest::RunTest(const FString& Parameters)
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::AngleBetweenVectors(FVector::ForwardVector, FVector::RightVector), 90.0f, 0.01f));
TestTrue("AngleBetweenVectors should return ~180 for opposite vectors",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::AngleBetweenVectors(FVector::ForwardVector, -FVector::ForwardVector), 180.0f, 0.01f));
TestTrue("AngleBetweenVectors should handle zero vector gracefully",
FMath::IsFinite(UDirectiveUtilMathFunctionLibrary::AngleBetweenVectors(FVector::ZeroVector, FVector::ForwardVector)));
TestEqual("AngleBetweenVectors should return 0 for a zero vector",
UDirectiveUtilMathFunctionLibrary::AngleBetweenVectors(FVector::ZeroVector, FVector::ForwardVector), 0.0f);
TestEqual("AngleBetweenVectors should return 0 for two zero vectors",
UDirectiveUtilMathFunctionLibrary::AngleBetweenVectors(FVector::ZeroVector, FVector::ZeroVector), 0.0f);
TestTrue("SignedAngleBetweenVectors returns a positive counterclockwise angle around the axis",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::SignedAngleBetweenVectors(
FVector::ForwardVector, FVector::RightVector, FVector::UpVector), 90.0f, 1.e-4f));
TestTrue("SignedAngleBetweenVectors returns a negative clockwise angle around the axis",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::SignedAngleBetweenVectors(
FVector::RightVector, FVector::ForwardVector, FVector::UpVector), -90.0f, 1.e-4f));
TestTrue("SignedAngleBetweenVectors reverses sign with the axis",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::SignedAngleBetweenVectors(
FVector::ForwardVector, FVector::RightVector, -FVector::UpVector), -90.0f, 1.e-4f));
TestTrue("SignedAngleBetweenVectors projects directions onto the axis plane",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::SignedAngleBetweenVectors(
FVector(1.0, 0.0, 4.0), FVector(0.0, 1.0, -3.0), FVector::UpVector), 90.0f, 1.e-4f));
TestEqual("SignedAngleBetweenVectors returns zero for a zero direction",
UDirectiveUtilMathFunctionLibrary::SignedAngleBetweenVectors(FVector::ZeroVector, FVector::RightVector, FVector::UpVector), 0.0f);
TestEqual("SignedAngleBetweenVectors returns zero for a direction parallel to the axis",
UDirectiveUtilMathFunctionLibrary::SignedAngleBetweenVectors(FVector::UpVector, FVector::RightVector, FVector::UpVector), 0.0f);
TestEqual("SignedAngleBetweenVectors returns zero for a zero axis",
UDirectiveUtilMathFunctionLibrary::SignedAngleBetweenVectors(FVector::ForwardVector, FVector::RightVector, FVector::ZeroVector), 0.0f);
TestTrue("DeltaAngle crosses the positive angle seam by the shortest path",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::DeltaAngle(350.0f, 10.0f), 20.0f, 1.e-4f));
TestTrue("DeltaAngle crosses the negative angle seam by the shortest path",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::DeltaAngle(10.0f, 350.0f), -20.0f, 1.e-4f));
TestEqual("DeltaAngle returns zero for equivalent wrapped angles",
UDirectiveUtilMathFunctionLibrary::DeltaAngle(-180.0f, 180.0f), 0.0f);
TestTrue("DeltaAngle canonicalizes an exactly opposite pair to +180",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::DeltaAngle(0.0f, 180.0f), 180.0f, 1.e-4f)
&& FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::DeltaAngle(0.0f, -180.0f), 180.0f, 1.e-4f)
&& FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::DeltaAngle(0.0f, 540.0f), 180.0f, 1.e-4f)
&& FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::DeltaAngle(0.0f, -540.0f), 180.0f, 1.e-4f));
TestEqual("DeltaAngle returns zero for non-finite input",
UDirectiveUtilMathFunctionLibrary::DeltaAngle(std::numeric_limits<float>::infinity(), 0.0f), 0.0f);
TestTrue("LerpAngle crosses the angle seam by the shortest path",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::LerpAngle(350.0f, 10.0f, 0.5f), 360.0f, 1.e-4f));
TestTrue("LerpAngle returns A at Alpha 0",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::LerpAngle(350.0f, 10.0f, 0.0f), 350.0f, 1.e-4f));
TestTrue("LerpAngle reaches A plus the shortest delta at Alpha 1",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::LerpAngle(350.0f, 10.0f, 1.0f), 370.0f, 1.e-4f));
TestTrue("LerpAngle permits extrapolation without wrapping the result",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::LerpAngle(0.0f, 90.0f, 2.0f), 180.0f, 1.e-4f)
&& FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::LerpAngle(0.0f, 90.0f, 3.0f), 270.0f, 1.e-4f));
TestEqual("LerpAngle returns zero for non-finite input",
UDirectiveUtilMathFunctionLibrary::LerpAngle(0.0f, 90.0f, std::numeric_limits<float>::quiet_NaN()), 0.0f);
TestTrue("PingPong reaches the middle of an ascending range",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::PingPong(0.5f, 0.0f, 1.0f), 0.5f, 1.e-4f));
TestTrue("PingPong reverses after the upper bound",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::PingPong(1.5f, 0.0f, 1.0f), 0.5f, 1.e-4f));
TestTrue("PingPong supports negative values",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::PingPong(-0.25f, 0.0f, 1.0f), 0.25f, 1.e-4f));
TestTrue("PingPong accepts reversed bounds",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::PingPong(12.5f, 20.0f, 10.0f), 12.5f, 1.e-4f));
TestEqual("PingPong returns the shared bound for a zero-sized range",
UDirectiveUtilMathFunctionLibrary::PingPong(100.0f, 7.0f, 7.0f), 7.0f);
TestEqual("PingPong returns zero for non-finite input",
UDirectiveUtilMathFunctionLibrary::PingPong(std::numeric_limits<float>::infinity(), 0.0f, 1.0f), 0.0f);
TestTrue("IsDirectionWithinCone includes a direction inside the cone",
UDirectiveUtilMathFunctionLibrary::IsDirectionWithinCone(FVector(1.0, 1.0, 0.0), FVector::ForwardVector, 46.0f));
TestTrue("IsDirectionWithinCone includes a direction on the cone boundary",
UDirectiveUtilMathFunctionLibrary::IsDirectionWithinCone(FVector(1.0, 1.0, 0.0), FVector::ForwardVector, 45.0f));
TestFalse("IsDirectionWithinCone excludes a direction outside the cone",
UDirectiveUtilMathFunctionLibrary::IsDirectionWithinCone(FVector::RightVector, FVector::ForwardVector, 45.0f));
TestTrue("IsDirectionWithinCone clamps angles above 180 degrees",
UDirectiveUtilMathFunctionLibrary::IsDirectionWithinCone(-FVector::ForwardVector, FVector::ForwardVector, 270.0f));
TestFalse("IsDirectionWithinCone clamps negative angles to zero",
UDirectiveUtilMathFunctionLibrary::IsDirectionWithinCone(FVector(1.0, 0.1, 0.0), FVector::ForwardVector, -20.0f));
TestFalse("IsDirectionWithinCone rejects a zero direction",
UDirectiveUtilMathFunctionLibrary::IsDirectionWithinCone(FVector::ZeroVector, FVector::ForwardVector, 45.0f));
TestFalse("IsDirectionWithinCone rejects a non-finite angle",
UDirectiveUtilMathFunctionLibrary::IsDirectionWithinCone(
FVector::ForwardVector, FVector::ForwardVector, std::numeric_limits<float>::quiet_NaN()));
{
const FVector2D Sample2D(12.34f, 56.78f);
@@ -75,15 +155,16 @@ bool FDirectiveUtilMathFunctionLibraryTest::RunTest(const FString& Parameters)
const TArray<EDirectiveUtilEaseType> AllEaseTypes = {
EDirectiveUtilEaseType::BackIn, EDirectiveUtilEaseType::BackOut, EDirectiveUtilEaseType::BackInOut,
EDirectiveUtilEaseType::ElasticIn, EDirectiveUtilEaseType::ElasticOut, EDirectiveUtilEaseType::ElasticInOut,
EDirectiveUtilEaseType::BounceIn, EDirectiveUtilEaseType::BounceOut, EDirectiveUtilEaseType::BounceInOut
EDirectiveUtilEaseType::BounceIn, EDirectiveUtilEaseType::BounceOut, EDirectiveUtilEaseType::BounceInOut,
EDirectiveUtilEaseType::Linear
};
for (const EDirectiveUtilEaseType EaseType : AllEaseTypes)
{
const FString TypeName = FString::FromInt(static_cast<int32>(EaseType));
TestTrue(FString::Printf(TEXT("EaseAlpha(0) should be ~0 for type %s"), *TypeName),
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::EaseAlpha(0.0f, EaseType), 0.0f, 1.e-3f));
TestTrue(FString::Printf(TEXT("EaseAlpha(1) should be ~1 for type %s"), *TypeName),
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::EaseAlpha(1.0f, EaseType), 1.0f, 1.e-3f));
TestEqual(FString::Printf(TEXT("EaseAlpha(0) should be exactly 0 for type %s"), *TypeName),
UDirectiveUtilMathFunctionLibrary::EaseAlpha(0.0f, EaseType), 0.0f);
TestEqual(FString::Printf(TEXT("EaseAlpha(1) should be exactly 1 for type %s"), *TypeName),
UDirectiveUtilMathFunctionLibrary::EaseAlpha(1.0f, EaseType), 1.0f);
for (const float Sample : {0.0f, 0.25f, 0.5f, 0.75f, 1.0f})
{
TestTrue(FString::Printf(TEXT("EaseAlpha(%.2f) should be finite for type %s"), Sample, *TypeName),
@@ -94,6 +175,22 @@ bool FDirectiveUtilMathFunctionLibraryTest::RunTest(const FString& Parameters)
TestEqual("EaseAlpha should clamp alpha below 0",
UDirectiveUtilMathFunctionLibrary::EaseAlpha(-1.0f, EDirectiveUtilEaseType::BounceOut),
UDirectiveUtilMathFunctionLibrary::EaseAlpha(0.0f, EDirectiveUtilEaseType::BounceOut));
TestEqual("Linear ease should pass the clamped alpha through",
UDirectiveUtilMathFunctionLibrary::EaseAlpha(0.3f, EDirectiveUtilEaseType::Linear), 0.3f);
const FTransform EaseStart(FRotator::ZeroRotator, FVector::ZeroVector, FVector::OneVector);
const FTransform EaseTarget(FRotator(0.0, 90.0, 0.0), FVector(10.0, 0.0, 0.0), FVector(3.0));
const FTransform EasedMidpoint = UDirectiveUtilMathFunctionLibrary::EaseTransform(
EaseStart, EaseTarget, 0.5f, EDirectiveUtilEaseType::Linear);
TestTrue("EaseTransform should blend location, rotation, and scale",
EasedMidpoint.GetLocation().Equals(FVector(5.0, 0.0, 0.0), 1.e-4)
&& EasedMidpoint.GetRotation().Equals(FRotator(0.0, 45.0, 0.0).Quaternion(), 1.e-4)
&& EasedMidpoint.GetScale3D().Equals(FVector(2.0), 1.e-4));
TestTrue("EaseTransform should return its endpoints at alpha 0 and 1",
UDirectiveUtilMathFunctionLibrary::EaseTransform(
EaseStart, EaseTarget, 0.0f, EDirectiveUtilEaseType::BounceOut).Equals(EaseStart, 1.e-4)
&& UDirectiveUtilMathFunctionLibrary::EaseTransform(
EaseStart, EaseTarget, 1.0f, EDirectiveUtilEaseType::BounceOut).Equals(EaseTarget, 1.e-4));
TestEqual("EaseAlpha should clamp alpha above 1",
UDirectiveUtilMathFunctionLibrary::EaseAlpha(2.0f, EDirectiveUtilEaseType::BounceOut),
UDirectiveUtilMathFunctionLibrary::EaseAlpha(1.0f, EDirectiveUtilEaseType::BounceOut));
@@ -147,6 +244,10 @@ bool FDirectiveUtilMathFunctionLibraryTest::RunTest(const FString& Parameters)
UDirectiveUtilMathFunctionLibrary::GetRandomIndexFromWeights(TArray<float>()), static_cast<int32>(INDEX_NONE));
TestEqual("Weighted random with all-zero weights returns INDEX_NONE",
UDirectiveUtilMathFunctionLibrary::GetRandomIndexFromWeights({0.0f, 0.0f, 0.0f}), static_cast<int32>(INDEX_NONE));
TestEqual("Weighted random ignores non-finite weights",
UDirectiveUtilMathFunctionLibrary::GetRandomIndexFromWeights({std::numeric_limits<float>::quiet_NaN(), 1.0f, std::numeric_limits<float>::infinity()}), 1);
TestTrue("Weighted random handles large finite totals",
UDirectiveUtilMathFunctionLibrary::GetRandomIndexFromWeights({MAX_flt, MAX_flt}) != INDEX_NONE);
for (int32 Iteration = 0; Iteration < 25; ++Iteration)
{
TestEqual("Weighted random {0,1,0} always selects index 1",
@@ -172,6 +273,10 @@ bool FDirectiveUtilMathFunctionLibraryTest::RunTest(const FString& Parameters)
UDirectiveUtilMathFunctionLibrary::FormatDuration(45.0f).ToString(), FString(TEXT("45s")));
TestEqual("FormatDuration(-90) is \"-1m 30s\"",
UDirectiveUtilMathFunctionLibrary::FormatDuration(-90.0f).ToString(), FString(TEXT("-1m 30s")));
TestEqual("FormatDuration(-45, false) is \"0m\"",
UDirectiveUtilMathFunctionLibrary::FormatDuration(-45.0f, false).ToString(), FString(TEXT("0m")));
TestFalse("FormatDuration handles the largest finite float without wrapping negative",
UDirectiveUtilMathFunctionLibrary::FormatDuration(MAX_flt).ToString().StartsWith(TEXT("-")));
TestEqual("FormatRelativeTime 5 minutes back reads \"5 minutes ago\"",
UDirectiveUtilMathFunctionLibrary::FormatRelativeTime(FDateTime::Now() - FTimespan::FromMinutes(5)).ToString(), FString(TEXT("5 minutes ago")));
@@ -231,6 +336,50 @@ bool FDirectiveUtilMathFunctionLibraryTest::RunTest(const FString& Parameters)
UDirectiveUtilMathFunctionLibrary::GetFloatArrayStandardDeviation(EmptyFloats), 0.0f);
}
TestTrue("GetIntArrayMedian should average the full int32 range without overflow",
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::GetIntArrayMedian({MIN_int32, MAX_int32}), -0.5f, 1.e-4f));
TestEqual("GetIntArrayMedian should handle repeated values",
UDirectiveUtilMathFunctionLibrary::GetIntArrayMedian({7, 7, 7, 7, 7}), 7.0f);
TestEqual("GetFloatArrayMedian should handle repeated values",
UDirectiveUtilMathFunctionLibrary::GetFloatArrayMedian({7.5f, 7.5f, 7.5f, 7.5f}), 7.5f);
TestTrue("GetFloatArrayMedian should return NaN when any input is NaN",
FMath::IsNaN(UDirectiveUtilMathFunctionLibrary::GetFloatArrayMedian({1.0f, std::numeric_limits<float>::quiet_NaN(), 3.0f})));
FRandomStream MedianStream(481516);
for (int32 Iteration = 0; Iteration < 200; ++Iteration)
{
const int32 Count = MedianStream.RandRange(1, 257);
TArray<int32> IntValues;
TArray<float> FloatValues;
IntValues.Reserve(Count);
FloatValues.Reserve(Count);
for (int32 Index = 0; Index < Count; ++Index)
{
const int32 Value = MedianStream.RandRange(-1000, 1000);
IntValues.Add(Value);
FloatValues.Add(static_cast<float>(Value) * 0.25f);
}
TArray<int32> SortedInts = IntValues;
SortedInts.Sort();
const int32 Middle = SortedInts.Num() / 2;
const float ExpectedIntMedian = SortedInts.Num() % 2 == 0
? static_cast<float>((static_cast<double>(SortedInts[Middle - 1]) + static_cast<double>(SortedInts[Middle])) * 0.5)
: static_cast<float>(SortedInts[Middle]);
TestTrue(
FString::Printf(TEXT("Integer median fuzz case %d"), Iteration),
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::GetIntArrayMedian(IntValues), ExpectedIntMedian, 1.e-4f));
TArray<float> SortedFloats = FloatValues;
SortedFloats.Sort();
const float ExpectedFloatMedian = SortedFloats.Num() % 2 == 0
? static_cast<float>((static_cast<double>(SortedFloats[Middle - 1]) + static_cast<double>(SortedFloats[Middle])) * 0.5)
: SortedFloats[Middle];
TestTrue(
FString::Printf(TEXT("Float median fuzz case %d"), Iteration),
FMath::IsNearlyEqual(UDirectiveUtilMathFunctionLibrary::GetFloatArrayMedian(FloatValues), ExpectedFloatMedian, 1.e-4f));
}
FRandomStream StreamA(12345);
FRandomStream StreamB(12345);
const int32 StreamIndexA = UDirectiveUtilMathFunctionLibrary::GetRandomIndexFromWeightsFromStream(StreamA, {1.0f, 1.0f, 1.0f, 1.0f});

View File

@@ -0,0 +1,83 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilMathFunctionLibrary.h"
#include "Misc/AutomationTest.h"
namespace
{
template <typename ValueType>
double GetSortedMedian(TArray<ValueType> Values)
{
Values.Sort();
const int32 Middle = Values.Num() / 2;
if (Values.Num() % 2 != 0)
{
return static_cast<double>(Values[Middle]);
}
return (static_cast<double>(Values[Middle - 1]) + static_cast<double>(Values[Middle])) * 0.5;
}
TArray<int32> MakeMedianValues(const int32 Count, const int32 Pattern)
{
TArray<int32> Values;
Values.Reserve(Count);
for (int32 Index = 0; Index < Count; ++Index)
{
switch (Pattern)
{
case 0:
Values.Add(Index - Count / 2);
break;
case 1:
Values.Add(Count - Index);
break;
case 2:
Values.Add(Index % 7);
break;
default:
Values.Add(Index % 2 == 0 ? MIN_int32 : MAX_int32);
break;
}
}
return Values;
}
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilMathCardinalityTest,
"DirectiveUtilities.MathScenarios.MedianCardinality",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilMathCardinalityTest::RunTest(const FString& Parameters)
{
for (const int32 ItemCount : {1, 2, 3, 4, 5, 16, 17, 256, 257, 4096, 4097})
{
for (int32 Pattern = 0; Pattern < 4; ++Pattern)
{
const TArray<int32> IntegerValues = MakeMedianValues(ItemCount, Pattern);
TArray<float> FloatValues;
FloatValues.Reserve(ItemCount);
for (const int32 Value : IntegerValues)
{
FloatValues.Add(static_cast<float>(Value) * 0.25f);
}
const FString Label = FString::Printf(TEXT("items=%d pattern=%d"), ItemCount, Pattern);
TestTrue(
Label + TEXT(" integer median"),
FMath::IsNearlyEqual(
UDirectiveUtilMathFunctionLibrary::GetIntArrayMedian(IntegerValues),
static_cast<float>(GetSortedMedian(IntegerValues)),
1.e-4f));
TestTrue(
Label + TEXT(" float median"),
FMath::IsNearlyEqual(
UDirectiveUtilMathFunctionLibrary::GetFloatArrayMedian(FloatValues),
static_cast<float>(GetSortedMedian(FloatValues)),
1.e-4f));
}
}
return true;
}

View File

@@ -0,0 +1,837 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilMathFunctionLibrary.h"
#include "Components/SplineComponent.h"
#include "Misc/AutomationTest.h"
#include "UObject/Class.h"
#include <limits>
namespace
{
bool PointsEqual(const TArray<FVector>& A, const TArray<FVector>& B, const double Tolerance = 1.e-9)
{
if (A.Num() != B.Num())
{
return false;
}
for (int32 Index = 0; Index < A.Num(); ++Index)
{
if (!A[Index].Equals(B[Index], Tolerance))
{
return false;
}
}
return true;
}
bool PointsEqualAfterTranslation(const TArray<FVector>& BasePoints, const TArray<FVector>& TranslatedPoints,
const FVector& Translation, const double Tolerance = 1.e-6)
{
if (BasePoints.Num() != TranslatedPoints.Num())
{
return false;
}
for (int32 Index = 0; Index < BasePoints.Num(); ++Index)
{
if (!(TranslatedPoints[Index] - Translation).Equals(BasePoints[Index], Tolerance))
{
return false;
}
}
return true;
}
bool PointsMatchLocation(const TArray<FVector>& Points, const FVector& Location, const double Tolerance = 1.e-9)
{
for (const FVector& Point : Points)
{
if (!Point.Equals(Location, Tolerance))
{
return false;
}
}
return true;
}
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilPointGenerationTest,
"DirectiveUtilities.Math.PointGeneration",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilPointGenerationTest::RunTest(const FString& Parameters)
{
const FName CallableGeneratorNames[] = {
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GenerateGridPoints2D),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GenerateGridPoints3D),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GenerateGridTransforms2D),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GenerateGridTransforms3D),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GenerateRectangularHexGrid),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GenerateRectangularHexGridTransforms),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GetRectangularHexGridCoordinates),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GenerateHexagonalHexGrid),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GenerateHexagonalHexGridTransforms),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GetHexesInRange),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GetHexRing),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GetHexLine),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GeneratePointsAlongDirection),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GeneratePointsBetweenLocations),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GeneratePointsOnCircle),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GenerateTransformsOnCircle),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GeneratePointsOnArc),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GenerateTransformsOnArc),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GeneratePointsOnDisc),
GET_FUNCTION_NAME_CHECKED(UDirectiveUtilMathFunctionLibrary, GeneratePointsOnSphere)
};
for (const FName FunctionName : CallableGeneratorNames)
{
const UFunction* Function = UDirectiveUtilMathFunctionLibrary::StaticClass()->FindFunctionByName(FunctionName);
TestNotNull(*FString::Printf(TEXT("%s should be exposed to Blueprint"), *FunctionName.ToString()), Function);
if (Function)
{
TestTrue(*FString::Printf(TEXT("%s should be callable"), *FunctionName.ToString()),
Function->HasAnyFunctionFlags(FUNC_BlueprintCallable));
TestFalse(*FString::Printf(TEXT("%s should not execute as a pure node"), *FunctionName.ToString()),
Function->HasAnyFunctionFlags(FUNC_BlueprintPure));
#if WITH_EDITOR
TestFalse(*FString::Printf(TEXT("%s should not advertise inert Blueprint thread safety"), *FunctionName.ToString()),
Function->HasMetaData(TEXT("BlueprintThreadSafe")));
#endif
}
}
const TArray<FVector> Grid2D = UDirectiveUtilMathFunctionLibrary::GenerateGridPoints2D(
FVector::ZeroVector, FRotator::ZeroRotator, FIntPoint(3, 2), FVector2D(10.0, 20.0), true);
const TArray<FVector> ExpectedGrid2D = {
FVector(-10.0, -10.0, 0.0), FVector(0.0, -10.0, 0.0), FVector(10.0, -10.0, 0.0),
FVector(-10.0, 10.0, 0.0), FVector(0.0, 10.0, 0.0), FVector(10.0, 10.0, 0.0)
};
TestTrue(TEXT("2D grid is centered and ordered by X then Y"), PointsEqual(Grid2D, ExpectedGrid2D));
const TArray<FVector> RotatedGrid2D = UDirectiveUtilMathFunctionLibrary::GenerateGridPoints2D(
FVector(5.0, 7.0, 9.0), FRotator(0.0, 90.0, 0.0), FIntPoint(2, 1), FVector2D(3.0, 0.0), false);
TestTrue(TEXT("2D grid rotation places its local X axis in world space"),
RotatedGrid2D.Num() == 2
&& RotatedGrid2D[0].Equals(FVector(5.0, 7.0, 9.0), 1.e-9)
&& RotatedGrid2D[1].Equals(FVector(5.0, 10.0, 9.0), 1.e-9));
const TArray<FVector> Grid3D = UDirectiveUtilMathFunctionLibrary::GenerateGridPoints3D(
FVector::ZeroVector, FRotator::ZeroRotator, FIntVector(2, 2, 2), FVector(2.0, 4.0, 6.0), true);
TestTrue(TEXT("3D grid is centered and ordered by X then Y then Z"),
Grid3D.Num() == 8
&& Grid3D[0].Equals(FVector(-1.0, -2.0, -3.0), 1.e-9)
&& Grid3D[1].Equals(FVector(1.0, -2.0, -3.0), 1.e-9)
&& Grid3D[2].Equals(FVector(-1.0, 2.0, -3.0), 1.e-9)
&& Grid3D.Last().Equals(FVector(1.0, 2.0, 3.0), 1.e-9));
TestTrue(TEXT("Grid generation rejects non-positive dimensions"),
UDirectiveUtilMathFunctionLibrary::GenerateGridPoints2D(
FVector::ZeroVector, FRotator::ZeroRotator, FIntPoint(2, 0), FVector2D(1.0), true).IsEmpty());
TestTrue(TEXT("Grid generation rejects point-count overflow"),
UDirectiveUtilMathFunctionLibrary::GenerateGridPoints3D(
FVector::ZeroVector, FRotator::ZeroRotator, FIntVector(MAX_int32, 2, 2), FVector::OneVector, true).IsEmpty());
const FRotator GridInstanceRotation(10.0, 20.0, 30.0);
const FQuat GridInstanceQuaternion = GridInstanceRotation.Quaternion();
const FVector GridInstanceScale(0.25, 0.5, 0.75);
const TArray<FTransform> GridTransforms2D = UDirectiveUtilMathFunctionLibrary::GenerateGridTransforms2D(
FVector::ZeroVector, FRotator::ZeroRotator, FIntPoint(3, 2), FVector2D(10.0, 20.0), true,
GridInstanceRotation, GridInstanceScale);
bool bGridTransforms2DValid = GridTransforms2D.Num() == Grid2D.Num();
for (int32 Index = 0; Index < GridTransforms2D.Num(); ++Index)
{
bGridTransforms2DValid &= GridTransforms2D[Index].GetLocation().Equals(Grid2D[Index], 1.e-9);
bGridTransforms2DValid &= GridTransforms2D[Index].GetRotation().Equals(GridInstanceQuaternion, 1.e-12);
bGridTransforms2DValid &= GridTransforms2D[Index].GetScale3D() == GridInstanceScale;
}
TestTrue(TEXT("2D grid transforms match point locations and broadcast rotation and scale"),
bGridTransforms2DValid);
const TArray<FTransform> GridTransforms3D = UDirectiveUtilMathFunctionLibrary::GenerateGridTransforms3D(
FVector::ZeroVector, FRotator::ZeroRotator, FIntVector(2, 2, 2), FVector(2.0, 4.0, 6.0), true,
GridInstanceRotation, GridInstanceScale);
bool bGridTransforms3DValid = GridTransforms3D.Num() == Grid3D.Num();
for (int32 Index = 0; Index < GridTransforms3D.Num(); ++Index)
{
bGridTransforms3DValid &= GridTransforms3D[Index].GetLocation().Equals(Grid3D[Index], 1.e-9);
bGridTransforms3DValid &= GridTransforms3D[Index].GetRotation().Equals(GridInstanceQuaternion, 1.e-12);
bGridTransforms3DValid &= GridTransforms3D[Index].GetScale3D() == GridInstanceScale;
}
TestTrue(TEXT("3D grid transforms match point locations and broadcast rotation and scale"),
bGridTransforms3DValid);
TestTrue(TEXT("Grid transform generation rejects invalid dimensions and non-finite scale"),
UDirectiveUtilMathFunctionLibrary::GenerateGridTransforms2D(
FVector::ZeroVector, FRotator::ZeroRotator, FIntPoint(2, 0), FVector2D(1.0), true).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GenerateGridTransforms3D(
FVector::ZeroVector, FRotator::ZeroRotator, FIntVector(2, 2, 2), FVector::OneVector, true,
FRotator::ZeroRotator, FVector(std::numeric_limits<double>::infinity())).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GenerateGridTransforms3D(
FVector::ZeroVector, FRotator::ZeroRotator, FIntVector(MAX_int32, 2, 2), FVector::OneVector, true)
.IsEmpty());
const double HexRadius = 10.0;
const FVector PointyQ = UDirectiveUtilMathFunctionLibrary::HexCoordinateToLocation(
FIntPoint(1, 0), FVector::ZeroVector, FRotator::ZeroRotator, HexRadius,
EDirectiveUtilHexOrientation::PointyTop);
const FVector PointyR = UDirectiveUtilMathFunctionLibrary::HexCoordinateToLocation(
FIntPoint(0, 1), FVector::ZeroVector, FRotator::ZeroRotator, HexRadius,
EDirectiveUtilHexOrientation::PointyTop);
TestTrue(TEXT("Pointy-top axial coordinates use the expected basis"),
PointyQ.Equals(FVector(UE_DOUBLE_SQRT_3 * HexRadius, 0.0, 0.0), 1.e-9)
&& PointyR.Equals(FVector(UE_DOUBLE_SQRT_3 * 0.5 * HexRadius, 15.0, 0.0), 1.e-9));
const FVector FlatQ = UDirectiveUtilMathFunctionLibrary::HexCoordinateToLocation(
FIntPoint(1, 0), FVector::ZeroVector, FRotator::ZeroRotator, HexRadius,
EDirectiveUtilHexOrientation::FlatTop);
const FVector FlatR = UDirectiveUtilMathFunctionLibrary::HexCoordinateToLocation(
FIntPoint(0, 1), FVector::ZeroVector, FRotator::ZeroRotator, HexRadius,
EDirectiveUtilHexOrientation::FlatTop);
TestTrue(TEXT("Flat-top axial coordinates use the expected basis"),
FlatQ.Equals(FVector(15.0, UE_DOUBLE_SQRT_3 * 0.5 * HexRadius, 0.0), 1.e-9)
&& FlatR.Equals(FVector(0.0, UE_DOUBLE_SQRT_3 * HexRadius, 0.0), 1.e-9));
const FVector HexOrigin(11.0, 13.0, 17.0);
const FRotator HexRotation(23.0, 37.0, 11.0);
const FVector HexPlaneNormal = HexRotation.Quaternion().GetAxisZ();
for (const EDirectiveUtilHexOrientation HexOrientation : {
EDirectiveUtilHexOrientation::PointyTop, EDirectiveUtilHexOrientation::FlatTop })
{
const FIntPoint Coordinate(-7, 4);
const FVector Location = UDirectiveUtilMathFunctionLibrary::HexCoordinateToLocation(
Coordinate, HexOrigin, HexRotation, 25.0, HexOrientation, 3.0);
TestEqual(TEXT("Hex coordinate conversion round trips through a rotated layout"),
UDirectiveUtilMathFunctionLibrary::LocationToHexCoordinate(
Location, HexOrigin, HexRotation, 25.0, HexOrientation, 3.0), Coordinate);
TestEqual(TEXT("Location conversion projects onto the hex plane"),
UDirectiveUtilMathFunctionLibrary::LocationToHexCoordinate(
Location + HexPlaneNormal * 500.0, HexOrigin, HexRotation, 25.0, HexOrientation, 3.0), Coordinate);
}
const FVector GappedHex = UDirectiveUtilMathFunctionLibrary::HexCoordinateToLocation(
FIntPoint(1, 0), FVector::ZeroVector, FRotator::ZeroRotator, HexRadius,
EDirectiveUtilHexOrientation::PointyTop, 2.0);
TestTrue(TEXT("Hex gap adds to the adjacent edge distance"),
FMath::IsNearlyEqual(GappedHex.Size(), UE_DOUBLE_SQRT_3 * HexRadius + 2.0, 1.e-9));
const TArray<FIntPoint> HexNeighbors = UDirectiveUtilMathFunctionLibrary::GetHexNeighbors(FIntPoint(3, -2));
const TArray<FIntPoint> ExpectedHexNeighbors = {
FIntPoint(4, -2), FIntPoint(4, -3), FIntPoint(3, -3),
FIntPoint(2, -2), FIntPoint(2, -1), FIntPoint(3, -1)
};
TestTrue(TEXT("Hex neighbors use stable axial direction order"), HexNeighbors == ExpectedHexNeighbors);
TestEqual(TEXT("Hex distance counts axial grid steps"),
UDirectiveUtilMathFunctionLibrary::GetHexDistance(FIntPoint(0, 0), FIntPoint(3, -5)), 5LL);
TestEqual(TEXT("Hex distance uses 64-bit intermediates"),
UDirectiveUtilMathFunctionLibrary::GetHexDistance(
FIntPoint(MAX_int32, MAX_int32), FIntPoint(MIN_int32, MIN_int32)), 8589934590LL);
const TArray<FVector> RectangularHexGrid = UDirectiveUtilMathFunctionLibrary::GenerateRectangularHexGrid(
FVector::ZeroVector, FRotator::ZeroRotator, FIntPoint(3, 2), HexRadius,
EDirectiveUtilHexOrientation::PointyTop, 0.0, true);
TestTrue(TEXT("Rectangular hex grid is centered and ordered by row then column"),
RectangularHexGrid.Num() == 6
&& RectangularHexGrid[0].Equals(-RectangularHexGrid.Last(), 1.e-9)
&& RectangularHexGrid[1].X < RectangularHexGrid[2].X
&& RectangularHexGrid[2].Y < RectangularHexGrid[3].Y);
const TArray<FVector> UncenteredHexGrid = UDirectiveUtilMathFunctionLibrary::GenerateRectangularHexGrid(
HexOrigin, FRotator::ZeroRotator, FIntPoint(2, 2), HexRadius,
EDirectiveUtilHexOrientation::FlatTop, 0.0, false);
TestTrue(TEXT("Uncentered rectangular hex grid starts at its origin"),
UncenteredHexGrid.Num() == 4 && UncenteredHexGrid[0] == HexOrigin);
const TArray<FVector> HexagonalGrid = UDirectiveUtilMathFunctionLibrary::GenerateHexagonalHexGrid(
FVector::ZeroVector, FRotator::ZeroRotator, 2, HexRadius,
EDirectiveUtilHexOrientation::PointyTop);
TSet<FIntPoint> HexagonalCoordinates;
bool bHexagonalGridValid = HexagonalGrid.Num() == 19;
for (const FVector& Point : HexagonalGrid)
{
const FIntPoint Coordinate = UDirectiveUtilMathFunctionLibrary::LocationToHexCoordinate(
Point, FVector::ZeroVector, FRotator::ZeroRotator, HexRadius,
EDirectiveUtilHexOrientation::PointyTop);
bHexagonalGridValid &= UDirectiveUtilMathFunctionLibrary::GetHexDistance(FIntPoint::ZeroValue, Coordinate) <= 2;
HexagonalCoordinates.Add(Coordinate);
}
TestTrue(TEXT("Hexagonal grid contains every coordinate through its requested radius"),
bHexagonalGridValid && HexagonalCoordinates.Num() == 19);
TestEqual(TEXT("A zero-radius hexagonal grid contains its center"),
UDirectiveUtilMathFunctionLibrary::GenerateHexagonalHexGrid(
HexOrigin, FRotator::ZeroRotator, 0, HexRadius).Num(), 1);
TestTrue(TEXT("Hex generators reject invalid layouts and counts"),
UDirectiveUtilMathFunctionLibrary::GenerateRectangularHexGrid(
FVector::ZeroVector, FRotator::ZeroRotator, FIntPoint(0, 2), HexRadius).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GenerateRectangularHexGrid(
FVector::ZeroVector, FRotator::ZeroRotator, FIntPoint(2, 2), 0.0).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GenerateHexagonalHexGrid(
FVector::ZeroVector, FRotator::ZeroRotator, -1, HexRadius).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GenerateHexagonalHexGrid(
FVector::ZeroVector, FRotator::ZeroRotator, 30000, HexRadius).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::HexCoordinateToLocation(
FIntPoint(1, 0), FVector::ZeroVector, FRotator::ZeroRotator, HexRadius,
EDirectiveUtilHexOrientation::PointyTop, -UE_DOUBLE_SQRT_3 * HexRadius).IsZero()
&& UDirectiveUtilMathFunctionLibrary::GetHexNeighbors(FIntPoint(MAX_int32, 0)).IsEmpty());
const TArray<FIntPoint> HexesInRange = UDirectiveUtilMathFunctionLibrary::GetHexesInRange(FIntPoint(2, -1), 1);
const TArray<FIntPoint> ExpectedHexesInRange = {
FIntPoint(2, -2), FIntPoint(3, -2), FIntPoint(1, -1), FIntPoint(2, -1),
FIntPoint(3, -1), FIntPoint(1, 0), FIntPoint(2, 0)
};
TestTrue(TEXT("Hexes in range cover the center and its neighbors ordered by R then Q"),
HexesInRange == ExpectedHexesInRange);
const TArray<FIntPoint> HexagonalGridCoordinates =
UDirectiveUtilMathFunctionLibrary::GetHexesInRange(FIntPoint::ZeroValue, 2);
bool bHexagonalOrderValid = HexagonalGridCoordinates.Num() == HexagonalGrid.Num();
for (int32 Index = 0; bHexagonalOrderValid && Index < HexagonalGrid.Num(); ++Index)
{
bHexagonalOrderValid &= HexagonalGrid[Index].Equals(
UDirectiveUtilMathFunctionLibrary::HexCoordinateToLocation(
HexagonalGridCoordinates[Index], FVector::ZeroVector, FRotator::ZeroRotator, HexRadius,
EDirectiveUtilHexOrientation::PointyTop), 1.e-9);
}
TestTrue(TEXT("Hexes in range around zero pair with hexagonal grid cells by index"), bHexagonalOrderValid);
const TArray<FIntPoint> RectangularCoordinates =
UDirectiveUtilMathFunctionLibrary::GetRectangularHexGridCoordinates(
FIntPoint(2, 2), EDirectiveUtilHexOrientation::FlatTop);
bool bRectangularOrderValid = RectangularCoordinates.Num() == UncenteredHexGrid.Num();
for (int32 Index = 0; bRectangularOrderValid && Index < UncenteredHexGrid.Num(); ++Index)
{
bRectangularOrderValid &= UncenteredHexGrid[Index].Equals(
UDirectiveUtilMathFunctionLibrary::HexCoordinateToLocation(
RectangularCoordinates[Index], HexOrigin, FRotator::ZeroRotator, HexRadius,
EDirectiveUtilHexOrientation::FlatTop), 1.e-9);
}
TestTrue(TEXT("Rectangular hex grid coordinates pair with grid cells by index"), bRectangularOrderValid);
const TArray<FIntPoint> HexRing = UDirectiveUtilMathFunctionLibrary::GetHexRing(FIntPoint(1, 1), 2);
bool bRingValid = HexRing.Num() == 12;
for (int32 Index = 0; bRingValid && Index < HexRing.Num(); ++Index)
{
bRingValid &= UDirectiveUtilMathFunctionLibrary::GetHexDistance(FIntPoint(1, 1), HexRing[Index]) == 2;
bRingValid &= UDirectiveUtilMathFunctionLibrary::GetHexDistance(
HexRing[Index], HexRing[(Index + 1) % HexRing.Num()]) == 1;
}
TestTrue(TEXT("A hex ring traces adjacent cells at the requested radius"), bRingValid);
const TArray<FIntPoint> ZeroHexRing = UDirectiveUtilMathFunctionLibrary::GetHexRing(FIntPoint(4, 5), 0);
TestTrue(TEXT("A zero-radius hex ring returns the center"),
ZeroHexRing.Num() == 1 && ZeroHexRing[0] == FIntPoint(4, 5));
const TArray<FIntPoint> HexLine = UDirectiveUtilMathFunctionLibrary::GetHexLine(
FIntPoint(0, 0), FIntPoint(3, -3));
const TArray<FIntPoint> ExpectedHexLine = {
FIntPoint(0, 0), FIntPoint(1, -1), FIntPoint(2, -2), FIntPoint(3, -3)
};
TestTrue(TEXT("A hex line follows a straight axial direction"), HexLine == ExpectedHexLine);
const TArray<FIntPoint> DiagonalHexLine = UDirectiveUtilMathFunctionLibrary::GetHexLine(
FIntPoint(-1, 2), FIntPoint(1, 3));
bool bDiagonalLineValid = DiagonalHexLine.Num() == 4
&& DiagonalHexLine[0] == FIntPoint(-1, 2) && DiagonalHexLine.Last() == FIntPoint(1, 3);
for (int32 Index = 0; bDiagonalLineValid && Index < DiagonalHexLine.Num() - 1; ++Index)
{
bDiagonalLineValid &= UDirectiveUtilMathFunctionLibrary::GetHexDistance(
DiagonalHexLine[Index], DiagonalHexLine[Index + 1]) == 1;
}
TestTrue(TEXT("A hex line steps through adjacent cells between its endpoints"), bDiagonalLineValid);
const TArray<FIntPoint> SingleHexLine = UDirectiveUtilMathFunctionLibrary::GetHexLine(
FIntPoint(7, -2), FIntPoint(7, -2));
TestTrue(TEXT("A zero-length hex line returns its cell"),
SingleHexLine.Num() == 1 && SingleHexLine[0] == FIntPoint(7, -2));
const TArray<FVector> HexCorners = UDirectiveUtilMathFunctionLibrary::GetHexCellCorners(
FIntPoint::ZeroValue, FVector::ZeroVector, FRotator::ZeroRotator, HexRadius,
EDirectiveUtilHexOrientation::PointyTop);
bool bCornersValid = HexCorners.Num() == 6
&& HexCorners[0].Equals(FVector(UE_DOUBLE_SQRT_3 * 0.5 * HexRadius, 0.5 * HexRadius, 0.0), 1.e-9);
for (int32 Index = 0; bCornersValid && Index < 6; ++Index)
{
bCornersValid &= FMath::IsNearlyEqual(HexCorners[Index].Size(), HexRadius, 1.e-9);
bCornersValid &= FMath::IsNearlyEqual(
FVector::Distance(HexCorners[Index], HexCorners[(Index + 1) % 6]), HexRadius, 1.e-9);
}
TestTrue(TEXT("Pointy-top cell corners lie at the cell radius with matching side length"), bCornersValid);
const TArray<FVector> FlatHexCorners = UDirectiveUtilMathFunctionLibrary::GetHexCellCorners(
FIntPoint::ZeroValue, FVector::ZeroVector, FRotator::ZeroRotator, HexRadius,
EDirectiveUtilHexOrientation::FlatTop);
TestTrue(TEXT("Flat-top cell corners start on the local X axis"),
FlatHexCorners.Num() == 6 && FlatHexCorners[0].Equals(FVector(HexRadius, 0.0, 0.0), 1.e-9));
const TArray<FVector> GappedHexCorners = UDirectiveUtilMathFunctionLibrary::GetHexCellCorners(
FIntPoint(1, 0), FVector::ZeroVector, FRotator::ZeroRotator, HexRadius,
EDirectiveUtilHexOrientation::PointyTop, 2.0);
TestTrue(TEXT("Hex gap moves the cell center but not the corner distance"),
GappedHexCorners.Num() == 6
&& FMath::IsNearlyEqual(FVector::Distance(GappedHexCorners[0], GappedHex), HexRadius, 1.e-9));
TestTrue(TEXT("Hex queries reject invalid input"),
UDirectiveUtilMathFunctionLibrary::GetHexesInRange(FIntPoint::ZeroValue, -1).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GetHexesInRange(FIntPoint(MAX_int32, 0), 1).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GetHexRing(FIntPoint::ZeroValue, -1).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GetHexRing(FIntPoint(MAX_int32, 0), 1).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GetHexLine(
FIntPoint(MIN_int32, MIN_int32), FIntPoint(MAX_int32, MAX_int32)).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GetHexCellCorners(FIntPoint(1, 1), FVector::ZeroVector,
FRotator::ZeroRotator, 0.0).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GetRectangularHexGridCoordinates(FIntPoint(0, 3)).IsEmpty());
const int32 MaximumGeneratedElementCount = UDirectiveUtilMathFunctionLibrary::MaximumGeneratedElementCount;
TestEqual(
TEXT("The maximum supported rectangular grid size remains available"),
UDirectiveUtilMathFunctionLibrary::GetRectangularHexGridCoordinates(FIntPoint(1000, 1000)).Num(),
MaximumGeneratedElementCount);
TestTrue(TEXT("Generated collections reject the first unsupported count"),
UDirectiveUtilMathFunctionLibrary::GenerateGridPoints2D(
FVector::ZeroVector, FRotator::ZeroRotator,
FIntPoint(MaximumGeneratedElementCount + 1, 1), FVector2D(1.0, 1.0)).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GetHexesInRange(FIntPoint::ZeroValue, 577).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GetHexRing(
FIntPoint::ZeroValue, MaximumGeneratedElementCount / 6 + 1).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GetHexLine(
FIntPoint::ZeroValue, FIntPoint(MaximumGeneratedElementCount, 0)).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongDirection(
FVector::ZeroVector, FVector::ForwardVector,
MaximumGeneratedElementCount + 1, 1.0).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GeneratePointsOnSphere(
FVector::ZeroVector, FRotator::ZeroRotator, 1.0,
MaximumGeneratedElementCount + 1).IsEmpty());
const TArray<FVector> NoiseBase = {
FVector::ZeroVector, FVector(37.0, 11.0, 5.0), FVector(250.0, -90.0, 40.0)
};
const TArray<FVector> NoisePoints = UDirectiveUtilMathFunctionLibrary::OffsetLocationsByNoise(
NoiseBase, 100.0, 25.0);
bool bNoiseValid = NoisePoints.Num() == 3;
bool bAnyNoiseOffset = false;
for (int32 Index = 0; bNoiseValid && Index < NoisePoints.Num(); ++Index)
{
const FVector NoiseDelta = NoisePoints[Index] - NoiseBase[Index];
bNoiseValid &= FMath::IsNearlyZero(NoiseDelta.X) && FMath::IsNearlyZero(NoiseDelta.Y)
&& FMath::Abs(NoiseDelta.Z) <= 25.0 + 1.e-6;
bAnyNoiseOffset |= !FMath::IsNearlyZero(NoiseDelta.Z);
}
TestTrue(TEXT("Noise offsets displace along the requested direction within the amplitude"),
bNoiseValid && bAnyNoiseOffset);
TestTrue(TEXT("Noise offsets are deterministic"),
UDirectiveUtilMathFunctionLibrary::OffsetLocationsByNoise(NoiseBase, 100.0, 25.0) == NoisePoints);
TestTrue(TEXT("A zero noise amplitude leaves locations unchanged"),
UDirectiveUtilMathFunctionLibrary::OffsetLocationsByNoise(NoiseBase, 100.0, 0.0) == NoiseBase);
TestTrue(TEXT("Noise offsets reject invalid input"),
UDirectiveUtilMathFunctionLibrary::OffsetLocationsByNoise(NoiseBase, 0.0, 25.0).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::OffsetLocationsByNoise(
NoiseBase, 100.0, 25.0, FVector::ZeroVector).IsEmpty());
const TArray<FVector> DirectionPoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongDirection(
FVector::ZeroVector, FVector(10.0, 0.0, 0.0), 4, 2.0, true);
const TArray<FVector> ExpectedDirectionPoints = {
FVector(-3.0, 0.0, 0.0), FVector(-1.0, 0.0, 0.0),
FVector(1.0, 0.0, 0.0), FVector(3.0, 0.0, 0.0)
};
TestTrue(TEXT("Direction points normalize once and center around the origin"),
PointsEqual(DirectionPoints, ExpectedDirectionPoints));
const TArray<FVector> ReversedDirectionPoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongDirection(
FVector::ZeroVector, FVector::ForwardVector, 3, -2.0, false);
TestTrue(TEXT("Direction points preserve signed spacing"),
ReversedDirectionPoints.Num() == 3
&& ReversedDirectionPoints[0].Equals(FVector::ZeroVector)
&& ReversedDirectionPoints[2].Equals(FVector(-4.0, 0.0, 0.0)));
TestTrue(TEXT("Direction points reject a zero direction"),
UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongDirection(
FVector::ZeroVector, FVector::ZeroVector, 3, 1.0, false).IsEmpty());
const TArray<FVector> SegmentPoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsBetweenLocations(
FVector::ZeroVector, FVector(10.0, 0.0, 0.0), 3, true);
TestTrue(TEXT("Segment points include exact endpoints"),
SegmentPoints.Num() == 3
&& SegmentPoints[0] == FVector::ZeroVector
&& SegmentPoints[1].Equals(FVector(5.0, 0.0, 0.0))
&& SegmentPoints[2] == FVector(10.0, 0.0, 0.0));
const TArray<FVector> InteriorSegmentPoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsBetweenLocations(
FVector::ZeroVector, FVector(9.0, 0.0, 0.0), 2, false);
TestTrue(TEXT("Segment points can exclude both endpoints"),
InteriorSegmentPoints.Num() == 2
&& InteriorSegmentPoints[0].Equals(FVector(3.0, 0.0, 0.0))
&& InteriorSegmentPoints[1].Equals(FVector(6.0, 0.0, 0.0)));
const TArray<FVector> SingleSegmentPoint = UDirectiveUtilMathFunctionLibrary::GeneratePointsBetweenLocations(
FVector(2.0, 4.0, 6.0), FVector(6.0, 8.0, 10.0), 1, true);
TestTrue(TEXT("A single segment point is the midpoint"),
SingleSegmentPoint.Num() == 1 && SingleSegmentPoint[0].Equals(FVector(4.0, 6.0, 8.0)));
USplineComponent* Spline = NewObject<USplineComponent>();
Spline->SetSplinePoints({ FVector::ZeroVector, FVector(100.0, 0.0, 0.0) },
ESplineCoordinateSpace::Local, false);
Spline->SetSplinePointType(0, ESplinePointType::Linear, false);
Spline->SetSplinePointType(1, ESplinePointType::Linear, true);
TestTrue(TEXT("Spline generators reject unsupported sample counts"),
UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSplineByCount(
Spline, MaximumGeneratedElementCount + 1).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GenerateTransformsAlongSplineByCount(
Spline, MaximumGeneratedElementCount + 1).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSpline(
Spline, 0.00005).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GenerateTransformsAlongSpline(
Spline, 0.00005).IsEmpty());
const TArray<FVector> SplinePoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSpline(
Spline, 30.0, true, EDirectiveUtilSplineSpacingMode::Fixed);
TestTrue(TEXT("Spline points use fixed spacing and append the exact open endpoint"),
SplinePoints.Num() == 5
&& SplinePoints[0].Equals(FVector::ZeroVector)
&& SplinePoints[1].Equals(FVector(30.0, 0.0, 0.0), 1.e-4)
&& SplinePoints[3].Equals(FVector(90.0, 0.0, 0.0), 1.e-4)
&& SplinePoints[4].Equals(FVector(100.0, 0.0, 0.0), 1.e-4));
TestEqual(TEXT("Spline endpoint can be excluded"),
UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSpline(
Spline, 30.0, false, EDirectiveUtilSplineSpacingMode::Fixed).Num(), 4);
const TArray<FVector> EvenSplinePoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSpline(
Spline, 30.0, true, EDirectiveUtilSplineSpacingMode::Even);
TestTrue(TEXT("Even spline spacing divides the range without a short final interval"),
EvenSplinePoints.Num() == 5
&& EvenSplinePoints[1].Equals(FVector(25.0, 0.0, 0.0), 1.e-4)
&& EvenSplinePoints[3].Equals(FVector(75.0, 0.0, 0.0), 1.e-4)
&& EvenSplinePoints[4].Equals(FVector(100.0, 0.0, 0.0), 1.e-4));
const TArray<FVector> RangedSplinePoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSpline(
Spline, 25.0, true, EDirectiveUtilSplineSpacingMode::Fixed, ESplineCoordinateSpace::World, 20.0, 80.0);
TestTrue(TEXT("Spline sampling honors a start and end distance"),
RangedSplinePoints.Num() == 4
&& RangedSplinePoints[0].Equals(FVector(20.0, 0.0, 0.0), 1.e-4)
&& RangedSplinePoints[1].Equals(FVector(45.0, 0.0, 0.0), 1.e-4)
&& RangedSplinePoints[3].Equals(FVector(80.0, 0.0, 0.0), 1.e-4));
TestEqual(TEXT("Spline sampling drops a regular sample that lands on the endpoint"),
UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSpline(
Spline, 25.0, true, EDirectiveUtilSplineSpacingMode::Fixed, ESplineCoordinateSpace::World,
0.0, 50.0 + 1.e-10).Num(), 3);
const TArray<FVector> CountedSplinePoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSplineByCount(
Spline, 5, true);
TestTrue(TEXT("Spline sampling by count includes both exact endpoints"),
CountedSplinePoints.Num() == 5
&& CountedSplinePoints[0].Equals(FVector::ZeroVector, 1.e-4)
&& CountedSplinePoints[2].Equals(FVector(50.0, 0.0, 0.0), 1.e-4)
&& CountedSplinePoints[4].Equals(FVector(100.0, 0.0, 0.0), 1.e-4));
const TArray<FVector> InteriorSplinePoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSplineByCount(
Spline, 4, false);
TestTrue(TEXT("Spline sampling by count can exclude both endpoints"),
InteriorSplinePoints.Num() == 4
&& InteriorSplinePoints[0].Equals(FVector(20.0, 0.0, 0.0), 1.e-4)
&& InteriorSplinePoints[3].Equals(FVector(80.0, 0.0, 0.0), 1.e-4));
const TArray<FVector> SingleSplinePoint = UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSplineByCount(
Spline, 1, true);
TestTrue(TEXT("A single spline point by count is the range midpoint"),
SingleSplinePoint.Num() == 1 && SingleSplinePoint[0].Equals(FVector(50.0, 0.0, 0.0), 1.e-4));
Spline->SetClosedLoop(true, true);
const TArray<FVector> ClosedSplinePoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSpline(
Spline, 30.0, true, EDirectiveUtilSplineSpacingMode::Fixed);
TestTrue(TEXT("Closed spline sampling does not repeat its first point"),
ClosedSplinePoints.Num() > 1 && !ClosedSplinePoints[0].Equals(ClosedSplinePoints.Last(), 1.e-4));
const TArray<FVector> ClosedCountedPoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSplineByCount(
Spline, 4, true);
TestTrue(TEXT("Closed spline sampling by count spreads points around the loop"),
ClosedCountedPoints.Num() == 4
&& !ClosedCountedPoints[0].Equals(ClosedCountedPoints.Last(), 1.e-4));
USplineComponent* SinglePointSpline = NewObject<USplineComponent>();
SinglePointSpline->SetSplinePoints({ FVector(3.0, 4.0, 5.0) }, ESplineCoordinateSpace::Local, true);
const TArray<FVector> ZeroLengthSplinePoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSpline(
SinglePointSpline, 10.0);
TestTrue(TEXT("A zero-length spline returns its only point"),
ZeroLengthSplinePoints.Num() == 1 && ZeroLengthSplinePoints[0].Equals(FVector(3.0, 4.0, 5.0)));
TestTrue(TEXT("Spline sampling rejects invalid input"),
UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSpline(nullptr, 10.0).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSpline(Spline, 0.0).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSpline(
Spline, 10.0, true, static_cast<EDirectiveUtilSplineSpacingMode>(255)).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSpline(
Spline, 10.0, true, EDirectiveUtilSplineSpacingMode::Fixed,
static_cast<ESplineCoordinateSpace::Type>(255)).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSpline(
Spline, 10.0, true, EDirectiveUtilSplineSpacingMode::Fixed,
ESplineCoordinateSpace::World, 80.0, 20.0).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSplineByCount(Spline, 0).IsEmpty());
const TArray<FVector> CirclePoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnCircle(
FVector::ZeroVector, FRotator::ZeroRotator, 10.0, 4, 0.0);
const TArray<FVector> ExpectedCirclePoints = {
FVector(10.0, 0.0, 0.0), FVector(0.0, 10.0, 0.0),
FVector(-10.0, 0.0, 0.0), FVector(0.0, -10.0, 0.0)
};
TestTrue(TEXT("Circle points are evenly spaced without repeating the first point"),
PointsEqual(CirclePoints, ExpectedCirclePoints, 1.e-8));
const FRotator PlaneRotation(17.0, 31.0, 43.0);
const FVector PlaneNormal = PlaneRotation.Quaternion().GetAxisZ();
const FVector PlaneCenter(11.0, 13.0, 17.0);
const TArray<FVector> RotatedCircle = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnCircle(
PlaneCenter, PlaneRotation, -7.0, 64, 15.0);
bool bCirclePlaneValid = RotatedCircle.Num() == 64;
for (const FVector& Point : RotatedCircle)
{
const FVector Offset = Point - PlaneCenter;
bCirclePlaneValid &= FMath::IsNearlyEqual(Offset.Size(), 7.0, 1.e-8);
bCirclePlaneValid &= FMath::IsNearlyZero(FVector::DotProduct(Offset, PlaneNormal), 1.e-8);
}
TestTrue(TEXT("Circle points honor rotation and negative radius"), bCirclePlaneValid);
const TArray<FVector> ArcPoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnArc(
FVector::ZeroVector, FRotator::ZeroRotator, 10.0, 3, 0.0, 90.0, true);
TestTrue(TEXT("Arc points include the requested endpoint"),
ArcPoints.Num() == 3
&& ArcPoints[0].Equals(FVector(10.0, 0.0, 0.0), 1.e-8)
&& ArcPoints[1].Equals(FVector(UE_DOUBLE_INV_SQRT_2 * 10.0, UE_DOUBLE_INV_SQRT_2 * 10.0, 0.0), 1.e-8)
&& ArcPoints[2].Equals(FVector(0.0, 10.0, 0.0), 1.e-8));
const TArray<FVector> OpenArcPoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnArc(
FVector::ZeroVector, FRotator::ZeroRotator, 10.0, 3, 0.0, 90.0, false);
TestTrue(TEXT("Open arc points exclude the requested endpoint"),
OpenArcPoints.Num() == 3
&& OpenArcPoints.Last().Equals(
FVector(FMath::Cos(UE_DOUBLE_PI / 3.0) * 10.0, FMath::Sin(UE_DOUBLE_PI / 3.0) * 10.0, 0.0), 1.e-8));
const TArray<FVector> DiscPoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnDisc(
PlaneCenter, PlaneRotation, 25.0, 1024, 27.0);
const TArray<FVector> RepeatedDiscPoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnDisc(
PlaneCenter, PlaneRotation, 25.0, 1024, 27.0);
bool bDiscValid = DiscPoints.Num() == 1024;
for (const FVector& Point : DiscPoints)
{
const FVector Offset = Point - PlaneCenter;
bDiscValid &= Offset.Size() < 25.0;
bDiscValid &= FMath::IsNearlyZero(FVector::DotProduct(Offset, PlaneNormal), 1.e-8);
}
TestTrue(TEXT("Disc points are deterministic and remain inside the rotated disc"),
bDiscValid && PointsEqual(DiscPoints, RepeatedDiscPoints));
const TArray<FVector> SingleDiscPoint = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnDisc(
PlaneCenter, PlaneRotation, 25.0, 1, 27.0);
TestTrue(TEXT("A single disc point is its center"),
SingleDiscPoint.Num() == 1 && SingleDiscPoint[0] == PlaneCenter);
const TArray<FVector> SpherePoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnSphere(
FVector::ZeroVector, PlaneRotation, 25.0, 1024, 27.0);
const TArray<FVector> RepeatedSpherePoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnSphere(
FVector::ZeroVector, PlaneRotation, 25.0, 1024, 27.0);
FVector SphereMean = FVector::ZeroVector;
bool bSphereValid = SpherePoints.Num() == 1024;
for (const FVector& Point : SpherePoints)
{
bSphereValid &= FMath::IsNearlyEqual(Point.Size(), 25.0, 1.e-8);
SphereMean += Point;
}
if (!SpherePoints.IsEmpty())
{
SphereMean /= SpherePoints.Num();
}
TestTrue(TEXT("Sphere points are deterministic and remain on the surface"),
bSphereValid && SphereMean.Size() < 0.01 && PointsEqual(SpherePoints, RepeatedSpherePoints));
const FVector SphereCenter(-1200.0, 3400.0, -5600.0);
const FRotator SphereRotation(-37.0, 123.0, 71.0);
const FQuat SphereRotationQuaternion = SphereRotation.Quaternion();
const TArray<FVector> LocalSpherePoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnSphere(
FVector::ZeroVector, FRotator::ZeroRotator, 17.0, 257, 23.5);
const TArray<FVector> RotatedSpherePoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnSphere(
SphereCenter, SphereRotation, 17.0, 257, 23.5);
bool bSphereRotationValid = LocalSpherePoints.Num() == RotatedSpherePoints.Num();
for (int32 Index = 0; Index < LocalSpherePoints.Num() && bSphereRotationValid; ++Index)
{
const FVector ExpectedPoint = SphereCenter
+ SphereRotationQuaternion.RotateVector(LocalSpherePoints[Index]);
bSphereRotationValid &= RotatedSpherePoints[Index].Equals(ExpectedPoint, 1.e-8);
}
TestTrue(TEXT("Sphere rotation transforms every local distribution point"), bSphereRotationValid);
constexpr double SphereAngleOffset = 1153.25;
const TArray<FVector> UnoffsetSpherePoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnSphere(
FVector::ZeroVector, FRotator::ZeroRotator, 17.0, 257, 0.0);
const TArray<FVector> OffsetSpherePoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnSphere(
FVector::ZeroVector, FRotator::ZeroRotator, 17.0, 257, SphereAngleOffset);
const FQuat SphereOffsetRotation(FVector::UpVector,
FMath::DegreesToRadians(FMath::Fmod(SphereAngleOffset, 360.0)));
bool bSphereAngleOffsetValid = UnoffsetSpherePoints.Num() == OffsetSpherePoints.Num();
for (int32 Index = 0; Index < UnoffsetSpherePoints.Num() && bSphereAngleOffsetValid; ++Index)
{
bSphereAngleOffsetValid &= OffsetSpherePoints[Index].Equals(
SphereOffsetRotation.RotateVector(UnoffsetSpherePoints[Index]), 1.e-8);
}
TestTrue(TEXT("Sphere angle offset rotates the distribution around local Z"),
bSphereAngleOffsetValid);
const TArray<FVector> SingleSpherePoint = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnSphere(
PlaneCenter, PlaneRotation, 25.0, 1, 27.0);
TestTrue(TEXT("A single sphere point follows the rotated local Z axis"),
SingleSpherePoint.Num() == 1
&& SingleSpherePoint[0].Equals(PlaneCenter + PlaneNormal * 25.0, 1.e-8));
const FVector LargeTranslation(1000000.0, -2000000.0, 3000000.0);
const FRotator AuditRotation(-37.0, 123.0, 71.0);
TestTrue(TEXT("A translated center offsets every generated arc point exactly once"),
PointsEqualAfterTranslation(
UDirectiveUtilMathFunctionLibrary::GeneratePointsOnArc(
FVector::ZeroVector, AuditRotation, -13.5, 11, 1080.25, -450.5, true),
UDirectiveUtilMathFunctionLibrary::GeneratePointsOnArc(
LargeTranslation, AuditRotation, -13.5, 11, 1080.25, -450.5, true),
LargeTranslation));
TestTrue(TEXT("Translated origins preserve every spatial generator's local offsets"),
PointsEqualAfterTranslation(
UDirectiveUtilMathFunctionLibrary::GenerateGridPoints2D(
FVector::ZeroVector, AuditRotation, FIntPoint(4, 3), FVector2D(-7.0, 11.0), true),
UDirectiveUtilMathFunctionLibrary::GenerateGridPoints2D(
LargeTranslation, AuditRotation, FIntPoint(4, 3), FVector2D(-7.0, 11.0), true),
LargeTranslation)
&& PointsEqualAfterTranslation(
UDirectiveUtilMathFunctionLibrary::GenerateGridPoints3D(
FVector::ZeroVector, AuditRotation, FIntVector(3, 2, 2), FVector(5.0, -7.0, 0.0), false),
UDirectiveUtilMathFunctionLibrary::GenerateGridPoints3D(
LargeTranslation, AuditRotation, FIntVector(3, 2, 2), FVector(5.0, -7.0, 0.0), false),
LargeTranslation)
&& PointsEqualAfterTranslation(
UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongDirection(
FVector::ZeroVector, FVector(-2.0, 3.0, -5.0), 7, -3.25, true),
UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongDirection(
LargeTranslation, FVector(-2.0, 3.0, -5.0), 7, -3.25, true),
LargeTranslation)
&& PointsEqualAfterTranslation(
UDirectiveUtilMathFunctionLibrary::GeneratePointsBetweenLocations(
FVector(-11.0, 5.0, 8.0), FVector(17.0, -9.0, 3.0), 8, false),
UDirectiveUtilMathFunctionLibrary::GeneratePointsBetweenLocations(
LargeTranslation + FVector(-11.0, 5.0, 8.0),
LargeTranslation + FVector(17.0, -9.0, 3.0), 8, false),
LargeTranslation)
&& PointsEqualAfterTranslation(
UDirectiveUtilMathFunctionLibrary::GeneratePointsOnCircle(
FVector::ZeroVector, AuditRotation, -13.5, 17, -725.25),
UDirectiveUtilMathFunctionLibrary::GeneratePointsOnCircle(
LargeTranslation, AuditRotation, -13.5, 17, -725.25),
LargeTranslation)
&& PointsEqualAfterTranslation(
UDirectiveUtilMathFunctionLibrary::GeneratePointsOnDisc(
FVector::ZeroVector, AuditRotation, -13.5, 257, 1080.25),
UDirectiveUtilMathFunctionLibrary::GeneratePointsOnDisc(
LargeTranslation, AuditRotation, -13.5, 257, 1080.25),
LargeTranslation)
&& PointsEqualAfterTranslation(
UDirectiveUtilMathFunctionLibrary::GeneratePointsOnSphere(
FVector::ZeroVector, AuditRotation, -13.5, 257, -1080.25),
UDirectiveUtilMathFunctionLibrary::GeneratePointsOnSphere(
LargeTranslation, AuditRotation, -13.5, 257, -1080.25),
LargeTranslation));
for (const EDirectiveUtilHexOrientation Orientation : {
EDirectiveUtilHexOrientation::PointyTop, EDirectiveUtilHexOrientation::FlatTop })
{
TestTrue(TEXT("Translated origins preserve rectangular and hexagonal grid offsets"),
PointsEqualAfterTranslation(
UDirectiveUtilMathFunctionLibrary::GenerateRectangularHexGrid(
FVector::ZeroVector, AuditRotation, FIntPoint(4, 3), 9.5, Orientation, -1.25, true),
UDirectiveUtilMathFunctionLibrary::GenerateRectangularHexGrid(
LargeTranslation, AuditRotation, FIntPoint(4, 3), 9.5, Orientation, -1.25, true),
LargeTranslation)
&& PointsEqualAfterTranslation(
UDirectiveUtilMathFunctionLibrary::GenerateHexagonalHexGrid(
FVector::ZeroVector, AuditRotation, 4, 9.5, Orientation, -1.25),
UDirectiveUtilMathFunctionLibrary::GenerateHexagonalHexGrid(
LargeTranslation, AuditRotation, 4, 9.5, Orientation, -1.25),
LargeTranslation));
for (const FIntPoint Coordinate : {
FIntPoint::ZeroValue, FIntPoint(-17, 29), FIntPoint(1234, -987), FIntPoint(-4096, -2048) })
{
const FVector Location = UDirectiveUtilMathFunctionLibrary::HexCoordinateToLocation(
Coordinate, LargeTranslation, AuditRotation, 9.5, Orientation, -1.25);
TestEqual(TEXT("Odd signed hex coordinates round trip through translated rotated layouts"),
UDirectiveUtilMathFunctionLibrary::LocationToHexCoordinate(
Location, LargeTranslation, AuditRotation, 9.5, Orientation, -1.25), Coordinate);
}
}
const TArray<FVector> ZeroRadiusCircle = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnCircle(
LargeTranslation, AuditRotation, 0.0, 9, 123.0);
const TArray<FVector> ZeroRadiusArc = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnArc(
LargeTranslation, AuditRotation, 0.0, 9, -30.0, -720.0, false);
const TArray<FVector> ZeroRadiusDisc = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnDisc(
LargeTranslation, AuditRotation, 0.0, 9, 123.0);
const TArray<FVector> ZeroRadiusSphere = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnSphere(
LargeTranslation, AuditRotation, 0.0, 9, 123.0);
TestTrue(TEXT("Zero-radius generators preserve their requested count at the center"),
ZeroRadiusCircle.Num() == 9 && PointsMatchLocation(ZeroRadiusCircle, LargeTranslation)
&& ZeroRadiusArc.Num() == 9 && PointsMatchLocation(ZeroRadiusArc, LargeTranslation)
&& ZeroRadiusDisc.Num() == 9 && PointsMatchLocation(ZeroRadiusDisc, LargeTranslation)
&& ZeroRadiusSphere.Num() == 9 && PointsMatchLocation(ZeroRadiusSphere, LargeTranslation));
TestTrue(TEXT("Degenerate linear generators preserve their requested count and location"),
PointsMatchLocation(UDirectiveUtilMathFunctionLibrary::GenerateGridPoints3D(
LargeTranslation, AuditRotation, FIntVector(2, 3, 4), FVector::ZeroVector, true), LargeTranslation)
&& PointsMatchLocation(UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongDirection(
LargeTranslation, FVector(2.0, -3.0, 4.0), 7, 0.0, true), LargeTranslation)
&& PointsMatchLocation(UDirectiveUtilMathFunctionLibrary::GeneratePointsBetweenLocations(
LargeTranslation, LargeTranslation, 7, false), LargeTranslation));
USplineComponent* TransformedSpline = NewObject<USplineComponent>();
TransformedSpline->SetWorldLocation(LargeTranslation);
TransformedSpline->SetWorldRotation(AuditRotation);
TransformedSpline->SetWorldScale3D(FVector(2.0, 3.0, 0.5));
TransformedSpline->SetSplinePoints({ FVector::ZeroVector, FVector(100.0, 0.0, 0.0) },
ESplineCoordinateSpace::Local, false);
TransformedSpline->SetSplinePointType(0, ESplinePointType::Linear, false);
TransformedSpline->SetSplinePointType(1, ESplinePointType::Linear, true);
const TArray<FVector> TransformedSplinePoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSpline(
TransformedSpline, 60.0);
TestEqual(TEXT("Scaled spline sampling produces the expected point count"), TransformedSplinePoints.Num(), 5);
if (TransformedSplinePoints.Num() == 5)
{
TestTrue(TEXT("Spline sampling returns its world-space start"),
TransformedSplinePoints[0].Equals(LargeTranslation, 1.e-4));
TestTrue(TEXT("Spline sampling preserves regular world-space intervals"),
FMath::IsNearlyEqual(FVector::Distance(TransformedSplinePoints[0], TransformedSplinePoints[1]), 60.0, 1.e-4)
&& FMath::IsNearlyEqual(FVector::Distance(TransformedSplinePoints[1], TransformedSplinePoints[2]), 60.0, 1.e-4)
&& FMath::IsNearlyEqual(FVector::Distance(TransformedSplinePoints[2], TransformedSplinePoints[3]), 60.0, 1.e-4));
TestTrue(TEXT("Spline sampling appends its exact world-space endpoint"),
TransformedSplinePoints.Last().Equals(TransformedSpline->GetLocationAtDistanceAlongSpline(
TransformedSpline->GetSplineLength(), ESplineCoordinateSpace::World), 1.e-4));
}
const TArray<FVector> LocalSplinePoints = UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSpline(
TransformedSpline, 60.0, true, EDirectiveUtilSplineSpacingMode::Fixed, ESplineCoordinateSpace::Local);
TestTrue(TEXT("Spline sampling can return local-space points"),
LocalSplinePoints.Num() == TransformedSplinePoints.Num()
&& LocalSplinePoints[0].Equals(FVector::ZeroVector, 1.e-4)
&& LocalSplinePoints.Last().Equals(FVector(100.0, 0.0, 0.0), 1.e-4));
const double Infinity = std::numeric_limits<double>::infinity();
const double NaN = std::numeric_limits<double>::quiet_NaN();
const FRotator InvalidRotation(Infinity, 0.0, 0.0);
TestTrue(TEXT("Point generators reject non-finite values"),
UDirectiveUtilMathFunctionLibrary::GenerateGridPoints2D(
FVector::ZeroVector, FRotator::ZeroRotator, FIntPoint(2, 2), FVector2D(Infinity, 1.0), true).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongDirection(
FVector::ZeroVector, FVector::ForwardVector, 2, Infinity, false).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GeneratePointsOnCircle(
FVector::ZeroVector, InvalidRotation, 1.0, 4, 0.0).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GeneratePointsOnArc(
FVector::ZeroVector, FRotator::ZeroRotator, 1.0, 4, 0.0, Infinity, true).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GeneratePointsOnDisc(
FVector(Infinity, 0.0, 0.0), FRotator::ZeroRotator, 1.0, 4, 0.0).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GeneratePointsOnSphere(
FVector::ZeroVector, FRotator::ZeroRotator, Infinity, 4, 0.0).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GenerateRectangularHexGrid(
FVector::ZeroVector, FRotator::ZeroRotator, FIntPoint(2, 2), Infinity).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSpline(Spline, Infinity).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSpline(
Spline, 10.0, true, EDirectiveUtilSplineSpacingMode::Fixed,
ESplineCoordinateSpace::World, Infinity, -1.0).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSplineByCount(
Spline, 2, true, ESplineCoordinateSpace::World, 0.0, NaN).IsEmpty());
TestTrue(TEXT("Every spatial generator rejects a non-finite origin or center"),
UDirectiveUtilMathFunctionLibrary::GenerateGridPoints3D(
FVector(NaN, 0.0, 0.0), FRotator::ZeroRotator, FIntVector(1), FVector::OneVector, true).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GeneratePointsBetweenLocations(
FVector::ZeroVector, FVector(Infinity, 0.0, 0.0), 2, true).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GeneratePointsOnArc(
FVector(NaN, 0.0, 0.0), FRotator::ZeroRotator, 1.0, 2, 0.0, 90.0, true).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GenerateHexagonalHexGrid(
FVector(Infinity, 0.0, 0.0), FRotator::ZeroRotator, 1, 1.0).IsEmpty());
TestTrue(TEXT("Spline sampling rejects negative spacing"),
UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongSpline(Spline, -1.0).IsEmpty());
TestTrue(TEXT("Point generators return empty arrays for non-positive counts"),
UDirectiveUtilMathFunctionLibrary::GeneratePointsAlongDirection(
FVector::ZeroVector, FVector::ForwardVector, 0, 1.0, false).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GeneratePointsBetweenLocations(
FVector::ZeroVector, FVector::OneVector, -1, true).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GeneratePointsOnCircle(
FVector::ZeroVector, FRotator::ZeroRotator, 1.0, 0, 0.0).IsEmpty());
return !HasAnyErrors();
}

View File

@@ -1,7 +1,9 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilRegexFunctionLibrary.h"
#include "Misc/AutomationTest.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilRegexFunctionLibraryTest, "DirectiveUtilities.RegexFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilRegexFunctionLibraryTest, "DirectiveUtilities.RegexFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilRegexFunctionLibraryTest::RunTest(const FString& Parameters)
{

View File

@@ -0,0 +1,104 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilArrayFunctionLibrary.h"
#include "Libraries/DirectiveUtilFunctionLibrary.h"
#include "Libraries/DirectiveUtilGameplayTagFunctionLibrary.h"
#include "Libraries/DirectiveUtilInputFunctionLibrary.h"
#include "Libraries/DirectiveUtilMapFunctionLibrary.h"
#include "Libraries/DirectiveUtilMathFunctionLibrary.h"
#include "Libraries/DirectiveUtilRegexFunctionLibrary.h"
#include "Libraries/DirectiveUtilSaveGameFunctionLibrary.h"
#include "Libraries/DirectiveUtilStringFunctionLibrary.h"
#include "Libraries/DirectiveUtilTextFunctionLibrary.h"
#include "Misc/AutomationTest.h"
#include "Modules/ModuleManager.h"
#include "Tasks/DirectiveUtilTask_AsyncLoadAsset.h"
#include "Tasks/DirectiveUtilTask_AsyncTrace.h"
#include "Tasks/DirectiveUtilTask_Delay.h"
#include "Tasks/DirectiveUtilTask_Flow.h"
#include "Tasks/DirectiveUtilTask_MoveToLocation.h"
#include "UObject/Class.h"
#include "UObject/Package.h"
#include "UObject/UnrealType.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilRuntimeSurfaceTest,
"DirectiveUtilities.Runtime.Surface",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilRuntimeSurfaceTest::RunTest(const FString& Parameters)
{
struct FClassExpectation
{
UClass* Class;
};
const FClassExpectation Expectations[] = {
{ UDirectiveUtilArrayFunctionLibrary::StaticClass() },
{ UDirectiveUtilFunctionLibrary::StaticClass() },
{ UDirectiveUtilGameplayTagFunctionLibrary::StaticClass() },
{ UDirectiveUtilInputFunctionLibrary::StaticClass() },
{ UDirectiveUtilMapFunctionLibrary::StaticClass() },
{ UDirectiveUtilMathFunctionLibrary::StaticClass() },
{ UDirectiveUtilRegexFunctionLibrary::StaticClass() },
{ UDirectiveUtilSaveGameFunctionLibrary::StaticClass() },
{ UDirectiveUtilStringFunctionLibrary::StaticClass() },
{ UDirectiveUtilTextFunctionLibrary::StaticClass() },
{ UDirectiveUtilTask_AsyncLoadAsset::StaticClass() },
{ UDirectiveUtilTask_AsyncLoadClass::StaticClass() },
{ UDirectiveUtilTask_AsyncLoadAssets::StaticClass() },
{ UDirectiveUtilTask_AsyncTrace::StaticClass() },
{ UDirectiveUtilTask_Delay::StaticClass() },
{ UDirectiveUtilTask_UpdateForDuration::StaticClass() },
{ UDirectiveUtilTask_RepeatWithInterval::StaticClass() },
{ UDirectiveUtilTask_MoveToLocation::StaticClass() },
{ UDirectiveUtilTask_MoveToActor::StaticClass() }
};
TestTrue(TEXT("The runtime module is loaded"), FModuleManager::Get().IsModuleLoaded(TEXT("DirectiveUtilitiesRuntime")));
#if !WITH_EDITOR
TestFalse(TEXT("The editor module is absent from a game target"), FModuleManager::Get().IsModuleLoaded(TEXT("DirectiveUtilitiesEditor")));
TestFalse(TEXT("The uncooked node module is absent from a game target"), FModuleManager::Get().IsModuleLoaded(TEXT("DirectiveUtilitiesBlueprintNodes")));
#endif
for (const FClassExpectation& Expectation : Expectations)
{
TestNotNull(TEXT("Runtime class is reflected"), Expectation.Class);
if (!Expectation.Class)
{
continue;
}
TestEqual(
FString::Printf(TEXT("%s belongs to the runtime script package"), *Expectation.Class->GetName()),
Expectation.Class->GetOutermost()->GetName(),
FString(TEXT("/Script/DirectiveUtilitiesRuntime")));
TestFalse(
FString::Printf(TEXT("%s is not in an editor-only package"), *Expectation.Class->GetName()),
Expectation.Class->GetOutermost()->HasAnyPackageFlags(PKG_EditorOnly | PKG_UncookedOnly | PKG_Developer));
for (TFieldIterator<UFunction> FunctionIterator(Expectation.Class, EFieldIteratorFlags::ExcludeSuper); FunctionIterator; ++FunctionIterator)
{
const UFunction* Function = *FunctionIterator;
if (!Function->HasAnyFunctionFlags(FUNC_BlueprintCallable | FUNC_BlueprintPure))
{
continue;
}
TestFalse(
FString::Printf(TEXT("%s.%s is available outside the editor"), *Expectation.Class->GetName(), *Function->GetName()),
Function->HasAnyFunctionFlags(FUNC_EditorOnly));
#if WITH_EDITOR
const FString Category = Function->GetMetaData(TEXT("Category"));
TestTrue(
FString::Printf(TEXT("%s.%s has a Directive Utilities category"), *Expectation.Class->GetName(), *Function->GetName()),
Category == TEXT("Directive Utilities") || Category.StartsWith(TEXT("Directive Utilities|")));
#endif
}
}
return !HasAnyErrors();
}

View File

@@ -1,10 +1,12 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilSaveGameFunctionLibrary.h"
#include "Tests/DirectiveUtilTestObject.h"
#include "Kismet/GameplayStatics.h"
#include "GameFramework/SaveGame.h"
#include "Misc/AutomationTest.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilSaveGameFunctionLibraryTest, "DirectiveUtilities.SaveGameFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilSaveGameFunctionLibraryTest, "DirectiveUtilities.SaveGameFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilSaveGameFunctionLibraryTest::RunTest(const FString& Parameters)
{
@@ -84,6 +86,11 @@ bool FDirectiveUtilSaveGameFunctionLibraryTest::RunTest(const FString& Parameter
UDirectiveUtilSaveGameFunctionLibrary::DoesSaveSlotExist(SlotB));
TestNotNull("A renamed slot should still deserialize",
UGameplayStatics::LoadGameFromSlot(SlotB, 0));
const FString SlotBCaseVariant = SlotB.ToLower();
TestTrue("RenameSaveSlot should accept a case-only spelling change",
UDirectiveUtilSaveGameFunctionLibrary::RenameSaveSlot(SlotB, SlotBCaseVariant));
TestNotNull("A case-only renamed slot should still deserialize",
UGameplayStatics::LoadGameFromSlot(SlotBCaseVariant, 0));
USaveGame* OtherSave = NewObject<UDirectiveUtilTestSaveGame>();
TestTrue("SaveGameToSlot should write the collision test slot",
@@ -99,12 +106,18 @@ bool FDirectiveUtilSaveGameFunctionLibraryTest::RunTest(const FString& Parameter
UDirectiveUtilSaveGameFunctionLibrary::DoesSaveSlotExist(TEXT("../escape")));
TestFalse("DeleteSaveSlot should reject an invalid slot name",
UDirectiveUtilSaveGameFunctionLibrary::DeleteSaveSlot(TEXT("../escape")));
TestFalse("DoesSaveSlotExist should reject a nested slot path",
UDirectiveUtilSaveGameFunctionLibrary::DoesSaveSlotExist(TEXT("Profiles/Slot1")));
TestFalse("RenameSaveSlot should reject an invalid source slot name",
UDirectiveUtilSaveGameFunctionLibrary::RenameSaveSlot(TEXT("../escape"), SlotC));
TestFalse("RenameSaveSlot should reject an invalid destination slot name",
UDirectiveUtilSaveGameFunctionLibrary::RenameSaveSlot(SlotC, TEXT("../escape")));
TestTrue("A rejected rename should leave the source slot intact",
UDirectiveUtilSaveGameFunctionLibrary::DoesSaveSlotExist(SlotC));
TestFalse("DoesSaveSlotExist should reject the reserved device name CON",
UDirectiveUtilSaveGameFunctionLibrary::DoesSaveSlotExist(TEXT("CON")));
TestFalse("DoesSaveSlotExist should reject CON with an extension",
UDirectiveUtilSaveGameFunctionLibrary::DoesSaveSlotExist(TEXT("CON.sav")));
TestTrue("DeleteSaveSlot should delete an existing slot",
UDirectiveUtilSaveGameFunctionLibrary::DeleteSaveSlot(SlotB));
@@ -115,6 +128,7 @@ bool FDirectiveUtilSaveGameFunctionLibraryTest::RunTest(const FString& Parameter
// Clean up
UGameplayStatics::DeleteGameInSlot(SlotA, 0);
UGameplayStatics::DeleteGameInSlot(SlotB, 0);
UGameplayStatics::DeleteGameInSlot(SlotB.ToLower(), 0);
UGameplayStatics::DeleteGameInSlot(SlotC, 0);
}

View File

@@ -1,7 +1,37 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilStringFunctionLibrary.h"
#include "Misc/AutomationTest.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilStringFunctionLibraryTest, "DirectiveUtilities.StringFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
namespace
{
int32 ReferenceLevenshteinDistance(const FString& Left, const FString& Right)
{
TArray<int32> PreviousRow;
TArray<int32> CurrentRow;
PreviousRow.SetNumUninitialized(Right.Len() + 1);
CurrentRow.SetNumUninitialized(Right.Len() + 1);
for (int32 ColumnIndex = 0; ColumnIndex <= Right.Len(); ++ColumnIndex)
{
PreviousRow[ColumnIndex] = ColumnIndex;
}
for (int32 RowIndex = 1; RowIndex <= Left.Len(); ++RowIndex)
{
CurrentRow[0] = RowIndex;
for (int32 ColumnIndex = 1; ColumnIndex <= Right.Len(); ++ColumnIndex)
{
CurrentRow[ColumnIndex] = FMath::Min3(
PreviousRow[ColumnIndex] + 1,
CurrentRow[ColumnIndex - 1] + 1,
PreviousRow[ColumnIndex - 1] + (Left[RowIndex - 1] == Right[ColumnIndex - 1] ? 0 : 1));
}
Swap(PreviousRow, CurrentRow);
}
return PreviousRow.Last();
}
}
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilStringFunctionLibraryTest, "DirectiveUtilities.StringFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilStringFunctionLibraryTest::RunTest(const FString& Parameters)
{
@@ -232,11 +262,26 @@ bool FDirectiveUtilStringFunctionLibraryTest::RunTest(const FString& Parameters)
TestFalse("IsValidFileName should reject a backslash", UDirectiveUtilStringFunctionLibrary::IsValidFileName(TEXT("a\\b")));
TestFalse("IsValidFileName should reject an empty string", UDirectiveUtilStringFunctionLibrary::IsValidFileName(TEXT("")));
TestFalse("IsValidFileName should reject a reserved character", UDirectiveUtilStringFunctionLibrary::IsValidFileName(TEXT("a:b")));
TestFalse("IsValidFileName should reject the current-directory segment", UDirectiveUtilStringFunctionLibrary::IsValidFileName(TEXT(".")));
TestFalse("IsValidFileName should reject the parent-directory segment", UDirectiveUtilStringFunctionLibrary::IsValidFileName(TEXT("..")));
TestFalse("IsValidFileName should reject the reserved device name CON", UDirectiveUtilStringFunctionLibrary::IsValidFileName(TEXT("CON")));
TestFalse("IsValidFileName should reject CON with an extension", UDirectiveUtilStringFunctionLibrary::IsValidFileName(TEXT("CON.sav")));
TestFalse("IsValidFileName should reject NUL case-insensitively", UDirectiveUtilStringFunctionLibrary::IsValidFileName(TEXT("nul")));
TestFalse("IsValidFileName should reject COM1", UDirectiveUtilStringFunctionLibrary::IsValidFileName(TEXT("COM1")));
TestFalse("IsValidFileName should reject LPT9", UDirectiveUtilStringFunctionLibrary::IsValidFileName(TEXT("LPT9")));
TestFalse("IsValidFileName should reject a trailing dot", UDirectiveUtilStringFunctionLibrary::IsValidFileName(TEXT("trailing.")));
TestFalse("IsValidFileName should reject a run of dots", UDirectiveUtilStringFunctionLibrary::IsValidFileName(TEXT("...")));
TestTrue("IsValidFileName should accept console, which is not a reserved device name",
UDirectiveUtilStringFunctionLibrary::IsValidFileName(TEXT("console")));
{
const FString Sanitized = UDirectiveUtilStringFunctionLibrary::SanitizeFileName(TEXT("../a/b?.sav"));
TestTrue("SanitizeFileName should produce a name that IsValidFileName accepts",
UDirectiveUtilStringFunctionLibrary::IsValidFileName(Sanitized));
const FString SanitizedReserved = UDirectiveUtilStringFunctionLibrary::SanitizeFileName(TEXT("CON"));
TestTrue("SanitizeFileName should rewrite a reserved device name into a valid file name",
UDirectiveUtilStringFunctionLibrary::IsValidFileName(SanitizedReserved)
&& SanitizedReserved == TEXT("_CON"));
const FString Replaced = UDirectiveUtilStringFunctionLibrary::SanitizeFileName(TEXT("a/b"), TEXT("_"));
TestTrue("SanitizeFileName should substitute the replacement character for stripped characters",
Replaced.Contains(TEXT("_")));
@@ -271,6 +316,43 @@ bool FDirectiveUtilStringFunctionLibraryTest::RunTest(const FString& Parameters)
UDirectiveUtilStringFunctionLibrary::FindBestStringMatch(TEXT(""), {TEXT("abc")}, Similarity), 0);
TestTrue("FindBestStringMatch should report similarity 0 for an empty input against a non-empty candidate",
FMath::IsNearlyEqual(Similarity, 0.0f, 1.e-4f));
TestEqual("FindBestStringMatch should keep the first equally close candidate",
UDirectiveUtilStringFunctionLibrary::FindBestStringMatch(TEXT("cat"), {TEXT("bat"), TEXT("hat")}, Similarity), 0);
}
TestEqual("Levenshtein should trim equal prefixes and suffixes without changing the result",
UDirectiveUtilStringFunctionLibrary::GetLevenshteinDistance(TEXT("shared-prefix-A-shared-suffix"), TEXT("shared-prefix-B-shared-suffix")), 1);
TestEqual("Levenshtein should support Unicode code units",
UDirectiveUtilStringFunctionLibrary::GetLevenshteinDistance(TEXT("café-one"), TEXT("café-two")), 3);
FRandomStream LevenshteinStream(90210);
for (int32 Iteration = 0; Iteration < 200; ++Iteration)
{
FString Left;
FString Right;
const int32 LeftLength = LevenshteinStream.RandRange(0, 64);
const int32 RightLength = LevenshteinStream.RandRange(0, 64);
Left.Reserve(LeftLength);
Right.Reserve(RightLength);
for (int32 Index = 0; Index < LeftLength; ++Index)
{
Left.AppendChar(static_cast<TCHAR>(TEXT('a') + LevenshteinStream.RandRange(0, 5)));
}
for (int32 Index = 0; Index < RightLength; ++Index)
{
Right.AppendChar(static_cast<TCHAR>(TEXT('a') + LevenshteinStream.RandRange(0, 5)));
}
const int32 ExpectedDistance = ReferenceLevenshteinDistance(Left, Right);
TestEqual(
FString::Printf(TEXT("Levenshtein fuzz case %d"), Iteration),
UDirectiveUtilStringFunctionLibrary::GetLevenshteinDistance(Left, Right),
ExpectedDistance);
TestEqual(
FString::Printf(TEXT("Levenshtein symmetry case %d"), Iteration),
UDirectiveUtilStringFunctionLibrary::GetLevenshteinDistance(Right, Left),
ExpectedDistance);
}
return true;

View File

@@ -0,0 +1,82 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilStringFunctionLibrary.h"
#include "Misc/AutomationTest.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilStringCardinalityTest,
"DirectiveUtilities.StringScenarios.Cardinality",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilStringCardinalityTest::RunTest(const FString& Parameters)
{
for (const int32 CharacterCount : {0, 1, 2, 31, 32, 255, 256, 1024})
{
FString Source;
Source.Reserve(CharacterCount);
for (int32 Index = 0; Index < CharacterCount; ++Index)
{
Source.AppendChar(TEXT('a'));
}
const FString Label = FString::Printf(TEXT("characters=%d"), CharacterCount);
TestEqual(
Label + TEXT(" identical"),
UDirectiveUtilStringFunctionLibrary::GetLevenshteinDistance(Source, Source),
0);
TestEqual(
Label + TEXT(" empty comparison"),
UDirectiveUtilStringFunctionLibrary::GetLevenshteinDistance(Source, FString()),
CharacterCount);
if (!Source.IsEmpty())
{
for (const int32 ChangedIndex : {0, CharacterCount / 2, CharacterCount - 1})
{
FString Changed = Source;
Changed[ChangedIndex] = TEXT('b');
TestEqual(
FString::Printf(TEXT("characters=%d changed=%d"), CharacterCount, ChangedIndex),
UDirectiveUtilStringFunctionLibrary::GetLevenshteinDistance(Source, Changed),
1);
}
}
}
for (const int32 CandidateCount : {0, 1, 2, 31, 32, 256, 1000})
{
TArray<FString> Candidates;
Candidates.Reserve(CandidateCount);
for (int32 Index = 0; Index < CandidateCount; ++Index)
{
Candidates.Add(FString::Printf(TEXT("Candidate%04d"), Index));
}
float Similarity = -1.0f;
if (Candidates.IsEmpty())
{
TestEqual(
"empty candidate set index",
UDirectiveUtilStringFunctionLibrary::FindBestStringMatch(TEXT("Candidate0000"), Candidates, Similarity),
INDEX_NONE);
TestEqual("empty candidate set similarity", Similarity, 0.0f);
continue;
}
const TArray<int32> ExpectedIndices = {0, CandidateCount / 2, CandidateCount - 1};
for (const int32 ExpectedIndex : ExpectedIndices)
{
Similarity = -1.0f;
const int32 MatchIndex = UDirectiveUtilStringFunctionLibrary::FindBestStringMatch(
Candidates[ExpectedIndex],
Candidates,
Similarity);
const FString Label = FString::Printf(TEXT("candidates=%d expected=%d"), CandidateCount, ExpectedIndex);
TestEqual(Label + TEXT(" index"), MatchIndex, ExpectedIndex);
TestEqual(Label + TEXT(" similarity"), Similarity, 1.0f);
}
}
return true;
}

View File

@@ -1,7 +1,9 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilTextFunctionLibrary.h"
#include "Misc/AutomationTest.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilTextFunctionLibraryTest, "DirectiveUtilities.TextFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter)
IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDirectiveUtilTextFunctionLibraryTest, "DirectiveUtilities.TextFunctionLibraryTests", EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilTextFunctionLibraryTest::RunTest(const FString& Parameters)
{

View File

@@ -0,0 +1,485 @@
// Copyright (c) 2026 Unreal Directive. Licensed under the MIT License.
#include "Libraries/DirectiveUtilMathFunctionLibrary.h"
#include "Components/SplineComponent.h"
#include "Math/RotationMatrix.h"
#include "Misc/AutomationTest.h"
#include <limits>
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FDirectiveUtilTransformArrayTest,
"DirectiveUtilities.Math.TransformArrays",
EAutomationTestFlags::EditorContext | EAutomationTestFlags::ClientContext | EAutomationTestFlags::EngineFilter)
bool FDirectiveUtilTransformArrayTest::RunTest(const FString& Parameters)
{
const TArray<FVector> Locations = {
FVector(1.0, 2.0, 3.0),
FVector(4.0, 5.0, 6.0),
FVector(7.0, 8.0, 9.0)
};
const FRotator SharedRotator(10.0, 20.0, 30.0);
const FQuat SharedRotation = SharedRotator.Quaternion();
const FVector SharedScale(2.0, 3.0, 4.0);
const TArray<FTransform> SharedTransforms = UDirectiveUtilMathFunctionLibrary::LocationsToTransforms(
Locations, SharedRotator, SharedScale);
bool bSharedTransformsValid = SharedTransforms.Num() == Locations.Num();
for (int32 Index = 0; Index < SharedTransforms.Num(); ++Index)
{
bSharedTransformsValid &= SharedTransforms[Index].GetLocation() == Locations[Index];
bSharedTransformsValid &= SharedTransforms[Index].GetRotation().Equals(SharedRotation, 1.e-12);
bSharedTransformsValid &= SharedTransforms[Index].GetScale3D() == SharedScale;
}
TestTrue(TEXT("Locations to transforms preserves order and broadcasts rotation and scale"),
bSharedTransformsValid);
TestTrue(TEXT("Locations to transforms accepts an empty location array"),
UDirectiveUtilMathFunctionLibrary::LocationsToTransforms(
{}, SharedRotator, SharedScale).IsEmpty());
TArray<FTransform> Transforms;
TestTrue(TEXT("Empty attribute arrays use identity rotation and scale"),
UDirectiveUtilMathFunctionLibrary::MakeTransformsFromArrays(Locations, {}, {}, Transforms));
bool bIdentityAttributesValid = Transforms.Num() == Locations.Num();
for (int32 Index = 0; Index < Transforms.Num(); ++Index)
{
bIdentityAttributesValid &= Transforms[Index].GetLocation() == Locations[Index];
bIdentityAttributesValid &= Transforms[Index].GetRotation().Equals(FQuat::Identity, 1.e-12);
bIdentityAttributesValid &= Transforms[Index].GetScale3D() == FVector::OneVector;
}
TestTrue(TEXT("Identity attributes preserve every location"), bIdentityAttributesValid);
TestTrue(TEXT("Single attribute values broadcast across the location array"),
UDirectiveUtilMathFunctionLibrary::MakeTransformsFromArrays(
Locations, { SharedRotator }, { SharedScale }, Transforms));
bool bBroadcastAttributesValid = Transforms.Num() == Locations.Num();
for (const FTransform& Transform : Transforms)
{
bBroadcastAttributesValid &= Transform.GetRotation().Equals(SharedRotation, 1.e-12);
bBroadcastAttributesValid &= Transform.GetScale3D() == SharedScale;
}
TestTrue(TEXT("Broadcast attributes are applied to every transform"), bBroadcastAttributesValid);
const TArray<FRotator> Rotations = {
FRotator::ZeroRotator,
FRotator(0.0, 90.0, 0.0),
FRotator(45.0, 0.0, 0.0)
};
const TArray<FVector> Scales = {
FVector::OneVector,
FVector(2.0),
FVector(-1.0, 1.0, 0.5)
};
TestTrue(TEXT("Full attribute arrays map element by element"),
UDirectiveUtilMathFunctionLibrary::MakeTransformsFromArrays(
Locations, Rotations, Scales, Transforms));
bool bPerTransformAttributesValid = Transforms.Num() == Locations.Num();
for (int32 Index = 0; Index < Transforms.Num(); ++Index)
{
bPerTransformAttributesValid &= Transforms[Index].GetLocation() == Locations[Index];
bPerTransformAttributesValid &= Transforms[Index].GetRotation().Equals(
Rotations[Index].Quaternion(), 1.e-12);
bPerTransformAttributesValid &= Transforms[Index].GetScale3D() == Scales[Index];
}
TestTrue(TEXT("Per-transform attributes preserve index alignment"), bPerTransformAttributesValid);
Transforms = { FTransform::Identity };
TestFalse(TEXT("Mismatched rotation counts are rejected"),
UDirectiveUtilMathFunctionLibrary::MakeTransformsFromArrays(
Locations, { FRotator::ZeroRotator, FRotator::ZeroRotator }, {}, Transforms));
TestTrue(TEXT("A rejected attribute count clears the output"), Transforms.IsEmpty());
TestFalse(TEXT("Mismatched scale counts are rejected"),
UDirectiveUtilMathFunctionLibrary::MakeTransformsFromArrays(
Locations, {}, { FVector::OneVector, FVector::OneVector }, Transforms));
TestTrue(TEXT("A rejected scale count clears the output"), Transforms.IsEmpty());
const double Infinity = std::numeric_limits<double>::infinity();
const FRotator InvalidRotation(Infinity, 0.0, 0.0);
TestTrue(TEXT("Locations to transforms rejects non-finite values"),
UDirectiveUtilMathFunctionLibrary::LocationsToTransforms(
{ FVector(Infinity, 0.0, 0.0) }, FRotator::ZeroRotator, FVector::OneVector).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::LocationsToTransforms(
Locations, InvalidRotation, FVector::OneVector).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::LocationsToTransforms(
Locations, FRotator::ZeroRotator, FVector(Infinity)).IsEmpty());
TestFalse(TEXT("Transform arrays reject a non-finite location"),
UDirectiveUtilMathFunctionLibrary::MakeTransformsFromArrays(
{ FVector::ZeroVector, FVector(Infinity) }, {}, {}, Transforms));
TestTrue(TEXT("A non-finite location clears partial output"), Transforms.IsEmpty());
TestFalse(TEXT("Transform arrays reject a non-finite rotation"),
UDirectiveUtilMathFunctionLibrary::MakeTransformsFromArrays(
Locations, { InvalidRotation }, {}, Transforms));
TestTrue(TEXT("A non-finite rotation leaves no output"), Transforms.IsEmpty());
TestFalse(TEXT("Transform arrays reject a non-finite scale"),
UDirectiveUtilMathFunctionLibrary::MakeTransformsFromArrays(
Locations, {}, { FVector(Infinity) }, Transforms));
TestTrue(TEXT("A non-finite scale leaves no output"), Transforms.IsEmpty());
TestTrue(TEXT("Empty locations produce a valid empty transform array"),
UDirectiveUtilMathFunctionLibrary::MakeTransformsFromArrays(
{}, { SharedRotator }, { SharedScale }, Transforms));
TestTrue(TEXT("An empty transform result contains no values"), Transforms.IsEmpty());
const FVector LargeLocation(1000000.0, -2000000.0, 3000000.0);
const FRotator WrappedRotation(-1080.0, 1440.0, 720.0);
const FVector SignedScale(-2.0, 0.0, 4.0);
const TArray<FTransform> OddTransforms = UDirectiveUtilMathFunctionLibrary::LocationsToTransforms(
{LargeLocation, -LargeLocation}, WrappedRotation, SignedScale);
TestTrue(TEXT("Locations to transforms preserves large locations and signed scales"),
OddTransforms.Num() == 2
&& OddTransforms[0].GetLocation() == LargeLocation
&& OddTransforms[1].GetLocation() == -LargeLocation
&& OddTransforms[0].GetScale3D() == SignedScale
&& OddTransforms[1].GetScale3D() == SignedScale
&& OddTransforms[0].GetRotation().Equals(WrappedRotation.Quaternion(), 1.e-12));
const FVector SingleLocation(-7.0, 11.0, -13.0);
const FRotator SingleRotation(17.0, -29.0, 43.0);
const FVector SingleScale(0.0, -1.0, 2.0);
TestTrue(TEXT("Single-element attribute arrays map without special-case drift"),
UDirectiveUtilMathFunctionLibrary::MakeTransformsFromArrays(
{SingleLocation}, {SingleRotation}, {SingleScale}, Transforms)
&& Transforms.Num() == 1
&& Transforms[0].GetLocation() == SingleLocation
&& Transforms[0].GetRotation().Equals(SingleRotation.Quaternion(), 1.e-12)
&& Transforms[0].GetScale3D() == SingleScale);
Transforms = {FTransform::Identity};
TestFalse(TEXT("Empty locations still reject an impossible rotation count"),
UDirectiveUtilMathFunctionLibrary::MakeTransformsFromArrays(
{}, {FRotator::ZeroRotator, SharedRotator}, {}, Transforms));
TestTrue(TEXT("Rejected empty-location attributes clear the output"), Transforms.IsEmpty());
Transforms = {FTransform::Identity};
TestFalse(TEXT("Empty locations still reject an impossible scale count"),
UDirectiveUtilMathFunctionLibrary::MakeTransformsFromArrays(
{}, {}, {FVector::OneVector, SharedScale}, Transforms));
TestTrue(TEXT("Rejected empty-location scales clear the output"), Transforms.IsEmpty());
const FVector FacingTarget(100.0, -200.0, 300.0);
const TArray<FVector> FacingLocations = {
FacingTarget + FVector(10.0, 0.0, 0.0),
FacingTarget + FVector(0.0, -20.0, 0.0),
FacingTarget
};
const TArray<FTransform> FacingTransforms =
UDirectiveUtilMathFunctionLibrary::LocationsToFacingTransforms(
FacingLocations, FacingTarget, FVector::UpVector, FRotator::ZeroRotator, SharedScale, false);
TestTrue(TEXT("Facing transforms preserve locations and face their target"),
FacingTransforms.Num() == 3
&& FacingTransforms[0].GetLocation() == FacingLocations[0]
&& FacingTransforms[0].GetRotation().GetAxisX().Equals(FVector::BackwardVector, 1.e-8)
&& FacingTransforms[1].GetRotation().GetAxisX().Equals(FVector::RightVector, 1.e-8)
&& FacingTransforms[2].GetRotation().Equals(FQuat::Identity, 1.e-12)
&& FacingTransforms[0].GetScale3D() == SharedScale);
const TArray<FTransform> AwayTransforms =
UDirectiveUtilMathFunctionLibrary::LocationsToFacingTransforms(
FacingLocations, FacingTarget, FVector::UpVector, FRotator::ZeroRotator, FVector::OneVector, true);
TestTrue(TEXT("Facing transforms can point away from their target"),
AwayTransforms.Num() == 3
&& AwayTransforms[0].GetRotation().GetAxisX().Equals(FVector::ForwardVector, 1.e-8)
&& AwayTransforms[1].GetRotation().GetAxisX().Equals(FVector::LeftVector, 1.e-8));
const FRotator FacingOffset(13.0, 17.0, 19.0);
const FQuat ExpectedFacingOffset = FRotationMatrix::MakeFromXZ(
FVector::BackwardVector, FVector::UpVector).ToQuat() * FacingOffset.Quaternion();
const TArray<FTransform> OffsetFacingTransforms =
UDirectiveUtilMathFunctionLibrary::LocationsToFacingTransforms(
{FacingLocations[0]}, FacingTarget, FVector::UpVector, FacingOffset);
TestTrue(TEXT("Facing transforms apply their rotation offset in local space"),
OffsetFacingTransforms.Num() == 1
&& OffsetFacingTransforms[0].GetRotation().Equals(ExpectedFacingOffset, 1.e-12));
TestTrue(TEXT("Facing transforms reject invalid shared inputs"),
UDirectiveUtilMathFunctionLibrary::LocationsToFacingTransforms(
FacingLocations, FacingTarget, FVector::ZeroVector).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::LocationsToFacingTransforms(
FacingLocations, FVector(Infinity), FVector::UpVector).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::LocationsToFacingTransforms(
FacingLocations, FacingTarget, FVector::UpVector, InvalidRotation).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::LocationsToFacingTransforms(
FacingLocations, FacingTarget, FVector::UpVector, FRotator::ZeroRotator,
FVector(Infinity)).IsEmpty());
const FVector RadialCenter(1000.0, -2000.0, 3000.0);
const FRotator RadialPlane(17.0, 31.0, 43.0);
const FQuat RadialPlaneQuaternion = RadialPlane.Quaternion();
const FVector RadialNormal = RadialPlaneQuaternion.GetAxisZ();
const TArray<FVector> CircleLocations = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnCircle(
RadialCenter, RadialPlane, 25.0, 12, 11.0);
const TArray<FTransform> InwardCircleTransforms =
UDirectiveUtilMathFunctionLibrary::GenerateTransformsOnCircle(
RadialCenter, RadialPlane, 25.0, 12, 11.0,
EDirectiveUtilRadialOrientation::FaceCenter, FRotator::ZeroRotator, SharedScale);
bool bInwardCircleValid = InwardCircleTransforms.Num() == CircleLocations.Num();
for (int32 Index = 0; Index < InwardCircleTransforms.Num(); ++Index)
{
const FVector Inward = (RadialCenter - CircleLocations[Index]).GetSafeNormal();
bInwardCircleValid &= InwardCircleTransforms[Index].GetLocation().Equals(CircleLocations[Index], 1.e-8);
bInwardCircleValid &= InwardCircleTransforms[Index].GetRotation().GetAxisX().Equals(Inward, 1.e-8);
bInwardCircleValid &= InwardCircleTransforms[Index].GetScale3D() == SharedScale;
}
TestTrue(TEXT("Circle transforms match point locations and face their center"), bInwardCircleValid);
const TArray<FTransform> OutwardCircleTransforms =
UDirectiveUtilMathFunctionLibrary::GenerateTransformsOnCircle(
RadialCenter, RadialPlane, 25.0, 12, 11.0,
EDirectiveUtilRadialOrientation::FaceAwayFromCenter);
const TArray<FTransform> ForwardCircleTransforms =
UDirectiveUtilMathFunctionLibrary::GenerateTransformsOnCircle(
RadialCenter, RadialPlane, 25.0, 12, 11.0,
EDirectiveUtilRadialOrientation::FollowPath);
const TArray<FTransform> ReverseCircleTransforms =
UDirectiveUtilMathFunctionLibrary::GenerateTransformsOnCircle(
RadialCenter, RadialPlane, 25.0, 12, 11.0,
EDirectiveUtilRadialOrientation::FaceAgainstPath);
bool bCircleOrientationsValid = OutwardCircleTransforms.Num() == CircleLocations.Num()
&& ForwardCircleTransforms.Num() == CircleLocations.Num()
&& ReverseCircleTransforms.Num() == CircleLocations.Num();
for (int32 Index = 0; Index < CircleLocations.Num() && bCircleOrientationsValid; ++Index)
{
const FVector Radial = (CircleLocations[Index] - RadialCenter).GetSafeNormal();
const FVector Tangent = FVector::CrossProduct(RadialNormal, Radial).GetSafeNormal();
bCircleOrientationsValid &= OutwardCircleTransforms[Index].GetRotation().GetAxisX().Equals(Radial, 1.e-8);
bCircleOrientationsValid &= ForwardCircleTransforms[Index].GetRotation().GetAxisX().Equals(Tangent, 1.e-8);
bCircleOrientationsValid &= ReverseCircleTransforms[Index].GetRotation().GetAxisX().Equals(-Tangent, 1.e-8);
}
TestTrue(TEXT("Circle transforms support outward and both path orientations"), bCircleOrientationsValid);
const TArray<FTransform> FixedCircleTransforms =
UDirectiveUtilMathFunctionLibrary::GenerateTransformsOnCircle(
RadialCenter, RadialPlane, 25.0, 4, 0.0,
EDirectiveUtilRadialOrientation::Fixed, FacingOffset);
FQuat ExpectedFixedRotation = RadialPlaneQuaternion * FacingOffset.Quaternion();
ExpectedFixedRotation.Normalize();
TestTrue(TEXT("Fixed circle transforms preserve the plane rotation and local offset"),
FixedCircleTransforms.Num() == 4
&& FixedCircleTransforms[0].GetRotation().Equals(ExpectedFixedRotation, 1.e-12)
&& FixedCircleTransforms[3].GetRotation().Equals(ExpectedFixedRotation, 1.e-12));
const TArray<FTransform> NegativeArcTransforms =
UDirectiveUtilMathFunctionLibrary::GenerateTransformsOnArc(
RadialCenter, RadialPlane, 25.0, 3, 0.0, -90.0, true,
EDirectiveUtilRadialOrientation::FollowPath);
TestTrue(TEXT("A negative arc reverses follow-path orientation"),
NegativeArcTransforms.Num() == 3
&& NegativeArcTransforms[0].GetRotation().GetAxisX().Equals(
-RadialPlaneQuaternion.GetAxisY(), 1.e-8));
const TArray<FVector> NegativeArcLocations = UDirectiveUtilMathFunctionLibrary::GeneratePointsOnArc(
RadialCenter, RadialPlane, 25.0, 3, 0.0, -90.0, true);
TestTrue(TEXT("Arc transforms match translated rotated point generation"),
NegativeArcTransforms.Num() == NegativeArcLocations.Num()
&& NegativeArcTransforms[0].GetLocation().Equals(NegativeArcLocations[0], 1.e-8)
&& NegativeArcTransforms[1].GetLocation().Equals(NegativeArcLocations[1], 1.e-8)
&& NegativeArcTransforms[2].GetLocation().Equals(NegativeArcLocations[2], 1.e-8));
const TArray<FTransform> ZeroRadiusArcTransforms =
UDirectiveUtilMathFunctionLibrary::GenerateTransformsOnArc(
RadialCenter, RadialPlane, 0.0, 3, 0.0, 90.0, true,
EDirectiveUtilRadialOrientation::FaceCenter);
TestTrue(TEXT("Zero-radius arc transforms retain a deterministic radial orientation"),
ZeroRadiusArcTransforms.Num() == 3
&& ZeroRadiusArcTransforms[0].GetLocation() == RadialCenter
&& ZeroRadiusArcTransforms[0].GetRotation().GetAxisX().Equals(
-RadialPlaneQuaternion.GetAxisX(), 1.e-8));
TestTrue(TEXT("Radial transform generators reject invalid input"),
UDirectiveUtilMathFunctionLibrary::GenerateTransformsOnCircle(
RadialCenter, RadialPlane, Infinity, 3).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GenerateTransformsOnArc(
RadialCenter, RadialPlane, 1.0, 3, 0.0, Infinity).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GenerateTransformsOnCircle(
RadialCenter, RadialPlane, 1.0, 3, 0.0,
static_cast<EDirectiveUtilRadialOrientation>(255)).IsEmpty());
USplineComponent* Spline = NewObject<USplineComponent>();
Spline->SetSplinePoints({FVector::ZeroVector, FVector(100.0, 0.0, 0.0)},
ESplineCoordinateSpace::Local, false);
Spline->SetSplinePointType(0, ESplinePointType::Linear, false);
Spline->SetSplinePointType(1, ESplinePointType::Linear, false);
Spline->SetScaleAtSplinePoint(0, FVector(1.0, 2.0, 3.0), false);
Spline->SetScaleAtSplinePoint(1, FVector(3.0, 4.0, 5.0), true);
const FVector SplineScaleMultiplier(2.0, 0.5, -1.0);
const TArray<FTransform> SplineTransforms =
UDirectiveUtilMathFunctionLibrary::GenerateTransformsAlongSpline(
Spline, 30.0, true, EDirectiveUtilSplineSpacingMode::Fixed,
ESplineCoordinateSpace::World, true, FacingOffset, SplineScaleMultiplier);
const double SplineDistances[] = {0.0, 30.0, 60.0, 90.0, 100.0};
bool bSplineTransformsValid = SplineTransforms.Num() == 5;
for (int32 Index = 0; Index < SplineTransforms.Num(); ++Index)
{
FTransform Expected = Spline->GetTransformAtDistanceAlongSpline(
static_cast<float>(SplineDistances[Index]), ESplineCoordinateSpace::World, true);
FQuat ExpectedRotation = Expected.GetRotation() * FacingOffset.Quaternion();
ExpectedRotation.Normalize();
bSplineTransformsValid &= SplineTransforms[Index].GetLocation().Equals(Expected.GetLocation(), 1.e-8);
bSplineTransformsValid &= SplineTransforms[Index].GetRotation().Equals(ExpectedRotation, 1.e-8);
bSplineTransformsValid &= SplineTransforms[Index].GetScale3D().Equals(
Expected.GetScale3D() * SplineScaleMultiplier, 1.e-8);
}
TestTrue(TEXT("Spline transforms preserve sampling, spline rotation, and spline scale"),
bSplineTransformsValid);
const TArray<FTransform> UnscaledSplineTransforms =
UDirectiveUtilMathFunctionLibrary::GenerateTransformsAlongSpline(
Spline, 1000.0, true, EDirectiveUtilSplineSpacingMode::Fixed,
ESplineCoordinateSpace::World, false, FRotator::ZeroRotator, SharedScale);
TestTrue(TEXT("Spline scale can be replaced by a shared multiplier"),
UnscaledSplineTransforms.Num() == 2
&& UnscaledSplineTransforms[0].GetScale3D() == SharedScale
&& UnscaledSplineTransforms[1].GetScale3D() == SharedScale);
const TArray<FTransform> CountedSplineTransforms =
UDirectiveUtilMathFunctionLibrary::GenerateTransformsAlongSplineByCount(Spline, 3, true);
TestTrue(TEXT("Spline transforms by count include both exact endpoints"),
CountedSplineTransforms.Num() == 3
&& CountedSplineTransforms[0].GetLocation().Equals(FVector::ZeroVector, 1.e-4)
&& CountedSplineTransforms[1].GetLocation().Equals(FVector(50.0, 0.0, 0.0), 1.e-4)
&& CountedSplineTransforms[2].GetLocation().Equals(FVector(100.0, 0.0, 0.0), 1.e-4));
USplineComponent* SinglePointSpline = NewObject<USplineComponent>();
SinglePointSpline->SetSplinePoints({FVector(3.0, 4.0, 5.0)}, ESplineCoordinateSpace::Local, true);
const TArray<FTransform> SinglePointSplineTransforms =
UDirectiveUtilMathFunctionLibrary::GenerateTransformsAlongSpline(
SinglePointSpline, 10.0);
TestTrue(TEXT("A zero-length spline returns one transform"),
SinglePointSplineTransforms.Num() == 1
&& SinglePointSplineTransforms[0].GetLocation().Equals(FVector(3.0, 4.0, 5.0), 1.e-8));
TestTrue(TEXT("Spline transform generation rejects invalid input"),
UDirectiveUtilMathFunctionLibrary::GenerateTransformsAlongSpline(
nullptr, 10.0).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GenerateTransformsAlongSpline(
Spline, 0.0).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GenerateTransformsAlongSpline(
Spline, 10.0, true, EDirectiveUtilSplineSpacingMode::Fixed,
ESplineCoordinateSpace::World, true, InvalidRotation).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GenerateTransformsAlongSpline(
Spline, 10.0, true, EDirectiveUtilSplineSpacingMode::Fixed,
ESplineCoordinateSpace::World, true, FRotator::ZeroRotator, FVector(Infinity)).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GenerateTransformsAlongSplineByCount(
Spline, 0).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GenerateTransformsAlongSplineByCount(
Spline, 3, true, ESplineCoordinateSpace::World, true, InvalidRotation).IsEmpty());
const TArray<FTransform> HexGridTransforms =
UDirectiveUtilMathFunctionLibrary::GenerateRectangularHexGridTransforms(
RadialCenter, FRotator::ZeroRotator, FIntPoint(2, 2), 25.0,
EDirectiveUtilHexOrientation::PointyTop, 0.0, false, FacingOffset, SharedScale);
const TArray<FVector> HexGridPoints = UDirectiveUtilMathFunctionLibrary::GenerateRectangularHexGrid(
RadialCenter, FRotator::ZeroRotator, FIntPoint(2, 2), 25.0,
EDirectiveUtilHexOrientation::PointyTop, 0.0, false);
bool bHexTransformsValid = HexGridTransforms.Num() == 4 && HexGridPoints.Num() == 4;
for (int32 Index = 0; bHexTransformsValid && Index < HexGridTransforms.Num(); ++Index)
{
bHexTransformsValid &= HexGridTransforms[Index].GetLocation().Equals(HexGridPoints[Index], 1.e-9)
&& HexGridTransforms[Index].GetRotation().Equals(FacingOffset.Quaternion(), 1.e-8)
&& HexGridTransforms[Index].GetScale3D() == SharedScale;
}
TestTrue(TEXT("Rectangular hex grid transforms share instance rotation and scale over grid cells"),
bHexTransformsValid);
TestEqual(TEXT("Hexagonal hex grid transforms cover the requested rings"),
UDirectiveUtilMathFunctionLibrary::GenerateHexagonalHexGridTransforms(
RadialCenter, FRotator::ZeroRotator, 1, 25.0).Num(), 7);
TestTrue(TEXT("Hex grid transform generators reject invalid input"),
UDirectiveUtilMathFunctionLibrary::GenerateRectangularHexGridTransforms(
RadialCenter, FRotator::ZeroRotator, FIntPoint(2, 2), 25.0,
EDirectiveUtilHexOrientation::PointyTop, 0.0, true, InvalidRotation).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::GenerateHexagonalHexGridTransforms(
RadialCenter, FRotator::ZeroRotator, 1, 0.0).IsEmpty());
const TArray<FVector> NoiseBaseLocations = {
FVector::ZeroVector, FVector(37.0, 11.0, 5.0), FVector(250.0, -90.0, 40.0)
};
const TArray<FTransform> NoiseBaseTransforms = UDirectiveUtilMathFunctionLibrary::LocationsToTransforms(
NoiseBaseLocations, FacingOffset, SharedScale);
const TArray<FTransform> NoisedTransforms = UDirectiveUtilMathFunctionLibrary::OffsetTransformsByNoise(
NoiseBaseTransforms, 100.0, 25.0);
const TArray<FVector> NoisedLocations = UDirectiveUtilMathFunctionLibrary::OffsetLocationsByNoise(
NoiseBaseLocations, 100.0, 25.0);
bool bNoiseTransformsValid = NoisedTransforms.Num() == 3 && NoisedLocations.Num() == 3;
for (int32 Index = 0; bNoiseTransformsValid && Index < NoisedTransforms.Num(); ++Index)
{
bNoiseTransformsValid &= NoisedTransforms[Index].GetLocation().Equals(NoisedLocations[Index], 1.e-9)
&& NoisedTransforms[Index].GetRotation().Equals(NoiseBaseTransforms[Index].GetRotation(), 1.e-9)
&& NoisedTransforms[Index].GetScale3D() == NoiseBaseTransforms[Index].GetScale3D();
}
TestTrue(TEXT("Transform noise offsets match location noise offsets and preserve rotation and scale"),
bNoiseTransformsValid);
TestTrue(TEXT("Transform noise offsets reject invalid input"),
UDirectiveUtilMathFunctionLibrary::OffsetTransformsByNoise(NoiseBaseTransforms, -1.0, 25.0).IsEmpty());
const TArray<FVector> EaseFromLocations = {
FVector::ZeroVector, FVector(10.0, 0.0, 0.0), FVector(20.0, 0.0, 0.0)
};
const TArray<FVector> EaseToLocations = {
FVector(0.0, 10.0, 0.0), FVector(10.0, 10.0, 0.0), FVector(20.0, 10.0, 0.0)
};
const TArray<FVector> EasedLocations = UDirectiveUtilMathFunctionLibrary::EaseLocationArrays(
EaseFromLocations, EaseToLocations, 0.5f, EDirectiveUtilEaseType::Linear, {});
TestTrue(TEXT("Eased location arrays blend element-wise"),
EasedLocations.Num() == 3
&& EasedLocations[0].Equals(FVector(0.0, 5.0, 0.0), 1.e-4)
&& EasedLocations[2].Equals(FVector(20.0, 5.0, 0.0), 1.e-4));
const TArray<FVector> StaggeredLocations = UDirectiveUtilMathFunctionLibrary::EaseLocationArrays(
EaseFromLocations, EaseToLocations, 0.0f, EDirectiveUtilEaseType::Linear, { 0.0f, 0.5f, 1.0f });
TestTrue(TEXT("Per-element alphas stagger the blend"),
StaggeredLocations.Num() == 3
&& StaggeredLocations[0].Equals(EaseFromLocations[0], 1.e-4)
&& StaggeredLocations[1].Equals(FVector(10.0, 5.0, 0.0), 1.e-4)
&& StaggeredLocations[2].Equals(EaseToLocations[2], 1.e-4));
TestTrue(TEXT("Eased arrays reject mismatched lengths"),
UDirectiveUtilMathFunctionLibrary::EaseLocationArrays(
EaseFromLocations, { FVector::ZeroVector }, 0.5f, EDirectiveUtilEaseType::Linear, {}).IsEmpty()
&& UDirectiveUtilMathFunctionLibrary::EaseLocationArrays(
EaseFromLocations, EaseToLocations, 0.5f, EDirectiveUtilEaseType::Linear, { 0.5f, 0.5f }).IsEmpty());
const TArray<FTransform> EaseFromTransforms = UDirectiveUtilMathFunctionLibrary::LocationsToTransforms(
EaseFromLocations, FRotator::ZeroRotator, FVector::OneVector);
const TArray<FTransform> EaseToTransforms = UDirectiveUtilMathFunctionLibrary::LocationsToTransforms(
EaseToLocations, FRotator(0.0, 90.0, 0.0), FVector(3.0));
const TArray<FTransform> EasedTransforms = UDirectiveUtilMathFunctionLibrary::EaseTransformArrays(
EaseFromTransforms, EaseToTransforms, 0.5f, EDirectiveUtilEaseType::Linear, {});
TestTrue(TEXT("Eased transform arrays blend location, rotation, and scale element-wise"),
EasedTransforms.Num() == 3
&& EasedTransforms[1].GetLocation().Equals(FVector(10.0, 5.0, 0.0), 1.e-4)
&& EasedTransforms[1].GetRotation().Equals(FRotator(0.0, 45.0, 0.0).Quaternion(), 1.e-4)
&& EasedTransforms[1].GetScale3D().Equals(FVector(2.0), 1.e-4));
const TArray<FVector> SamplePath = {
FVector::ZeroVector, FVector(10.0, 0.0, 0.0), FVector(10.0, 10.0, 0.0)
};
TestTrue(TEXT("Location array sampling is distance-weighted"),
UDirectiveUtilMathFunctionLibrary::SampleLocationArray(SamplePath, 0.75f).Equals(
FVector(10.0, 5.0, 0.0), 1.e-4)
&& UDirectiveUtilMathFunctionLibrary::SampleLocationArray(SamplePath, 0.0f).Equals(
SamplePath[0], 1.e-4)
&& UDirectiveUtilMathFunctionLibrary::SampleLocationArray(SamplePath, 1.0f).Equals(
SamplePath.Last(), 1.e-4)
&& UDirectiveUtilMathFunctionLibrary::SampleLocationArray(SamplePath, 1.5f).Equals(
SamplePath.Last(), 1.e-4));
TestTrue(TEXT("Closed-loop sampling wraps alpha back to the start"),
UDirectiveUtilMathFunctionLibrary::SampleLocationArray(SamplePath, 1.0f, true).Equals(
SamplePath[0], 1.e-4));
TestTrue(TEXT("Degenerate location array sampling returns the only point or zero"),
UDirectiveUtilMathFunctionLibrary::SampleLocationArray({ FVector(3.0, 4.0, 5.0) }, 0.7f).Equals(
FVector(3.0, 4.0, 5.0), 1.e-4)
&& UDirectiveUtilMathFunctionLibrary::SampleLocationArray({}, 0.5f).IsZero());
const TArray<FTransform> SampleTransformPath = {
FTransform(FRotator::ZeroRotator, FVector::ZeroVector, FVector::OneVector),
FTransform(FRotator(0.0, 90.0, 0.0), FVector(10.0, 0.0, 0.0), FVector(3.0))
};
const FTransform SampledTransform = UDirectiveUtilMathFunctionLibrary::SampleTransformArray(
SampleTransformPath, 0.5f);
TestTrue(TEXT("Transform array sampling blends location, rotation, and scale"),
SampledTransform.GetLocation().Equals(FVector(5.0, 0.0, 0.0), 1.e-4)
&& SampledTransform.GetRotation().Equals(FRotator(0.0, 45.0, 0.0).Quaternion(), 1.e-4)
&& SampledTransform.GetScale3D().Equals(FVector(2.0), 1.e-4));
TestTrue(TEXT("Empty transform array sampling returns the identity"),
UDirectiveUtilMathFunctionLibrary::SampleTransformArray({}, 0.5f).Equals(FTransform::Identity));
return !HasAnyErrors();
}