// Headless automation tests (no renderer, no world). Run with Tools/run_tests.ps1. // All test names start with "Descent." so `Automation RunTests Descent` selects exactly these. #include "Tests/DescentTestCommon.h" #include "Debug/DescentImageStats.h" #include "Debug/DescentScenario.h" #include "Debug/DescentStateRegistry.h" #include "DescentUnits.h" #include "HAL/FileManager.h" #include "Misc/FileHelper.h" #include "Misc/Paths.h" #include "Serialization/JsonReader.h" #include "Serialization/JsonSerializer.h" #if WITH_DEV_AUTOMATION_TESTS // --------------------------------------------------------------------------------------------------------- // Harness // --------------------------------------------------------------------------------------------------------- IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDescentUnitsTest, "Descent.Harness.Units", DESCENT_TEST_FLAGS) bool FDescentUnitsTest::RunTest(const FString& Parameters) { TestEqual(TEXT("1 m is 100 uu"), Descent::MetresToUU(1.0), 100.0); TestEqual(TEXT("699 cm is 6.99 m"), Descent::UUToMetres(699.0), 6.99); TestTrue(TEXT("vector round trip"), Descent::UUToMetres(Descent::MetresToUU(FVector(1.5, -2.0, 3.25))).Equals(FVector(1.5, -2.0, 3.25), 1e-9)); return true; } IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDescentFileStemTest, "Descent.Harness.FileStemValidation", DESCENT_TEST_FLAGS) bool FDescentFileStemTest::RunTest(const FString& Parameters) { TestTrue(TEXT("plain stem"), FDescentStateRegistry::IsValidFileStem(TEXT("kazah_showcase_front34"))); TestTrue(TEXT("dots and dashes"), FDescentStateRegistry::IsValidFileStem(TEXT("run-2.final"))); TestFalse(TEXT("empty"), FDescentStateRegistry::IsValidFileStem(TEXT(""))); TestFalse(TEXT("parent dir"), FDescentStateRegistry::IsValidFileStem(TEXT("../evil"))); TestFalse(TEXT("forward slash"), FDescentStateRegistry::IsValidFileStem(TEXT("a/b"))); TestFalse(TEXT("back slash"), FDescentStateRegistry::IsValidFileStem(TEXT("a\\b"))); TestFalse(TEXT("drive letter"), FDescentStateRegistry::IsValidFileStem(TEXT("C:foo"))); TestFalse(TEXT("dotdot inside"), FDescentStateRegistry::IsValidFileStem(TEXT("a..b"))); return true; } IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDescentStateRegistryTest, "Descent.Harness.StateRegistry", DESCENT_TEST_FLAGS) bool FDescentStateRegistryTest::RunTest(const FString& Parameters) { FDescentStateRegistry& Reg = FDescentStateRegistry::Get(); { // A system adds its own section with one object; it disappears when the object does. FDescentStateProviderRegistrar Registrar(TEXT("zz_test_section"), [](UWorld*, FJsonObject& Out) { Out.SetNumberField(TEXT("answer"), 42); Out.SetField(TEXT("pos_m"), Descent::Json::Vec3(Descent::UUToMetres(FVector(150.0, -250.0, 50.0)))); }); TestTrue(TEXT("section listed"), Reg.GetSectionNames().Contains(FName(TEXT("zz_test_section")))); TestTrue(TEXT("built-in 'meta' listed"), Reg.GetSectionNames().Contains(FName(TEXT("meta")))); const TSharedRef Snap = Reg.BuildSnapshot(nullptr, TEXT("unit_test")); TestEqual(TEXT("schema"), static_cast(Snap->GetNumberField(TEXT("schema"))), 1); TestEqual(TEXT("name"), Snap->GetStringField(TEXT("name")), FString(TEXT("unit_test"))); const TSharedPtr* Section = nullptr; if (TestTrue(TEXT("custom section present"), Snap->TryGetObjectField(TEXT("zz_test_section"), Section))) { TestEqual(TEXT("custom value"), static_cast((*Section)->GetNumberField(TEXT("answer"))), 42); const TArray>& P = (*Section)->GetArrayField(TEXT("pos_m")); if (TestEqual(TEXT("vec3 has 3 numbers"), P.Num(), 3)) { TestEqual(TEXT("x is metres"), P[0]->AsNumber(), 1.5); TestEqual(TEXT("y is metres"), P[1]->AsNumber(), -2.5); TestEqual(TEXT("z is metres"), P[2]->AsNumber(), 0.5); } } } TestFalse(TEXT("section gone after the registrar"), Reg.GetSectionNames().Contains(FName(TEXT("zz_test_section")))); return true; } IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDescentStateFileTest, "Descent.Harness.StateFileRoundTrip", DESCENT_TEST_FLAGS) bool FDescentStateFileTest::RunTest(const FString& Parameters) { const TSharedRef Snap = FDescentStateRegistry::Get().BuildSnapshot(nullptr, TEXT("roundtrip")); const FString Path = FDescentStateRegistry::GetStateDir() / TEXT("_automation_roundtrip.json"); FString Error; if (!TestTrue(TEXT("write succeeds"), FDescentStateRegistry::WriteJsonFile(Snap, Path, Error))) { AddError(Error); return false; } FString Text; TestTrue(TEXT("read back"), FFileHelper::LoadFileToString(Text, *Path)); TSharedPtr Parsed; TestTrue(TEXT("valid JSON"), FJsonSerializer::Deserialize(TJsonReaderFactory<>::Create(Text), Parsed) && Parsed.IsValid()); if (Parsed.IsValid()) { TestEqual(TEXT("name survives"), Parsed->GetStringField(TEXT("name")), FString(TEXT("roundtrip"))); TestTrue(TEXT("meta section survives"), Parsed->HasTypedField(TEXT("meta"))); } IFileManager::Get().Delete(*Path, false, true); return true; } IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDescentScenarioApiTest, "Descent.Harness.ScenarioBuilder", DESCENT_TEST_FLAGS) bool FDescentScenarioApiTest::RunTest(const FString& Parameters) { FDescentScenarioBuilder B; B.TimeLimit(30.f).WaitFrames(3).Exec(TEXT("stat fps")).LookAt(FVector(5, 0, 0), FVector::ZeroVector).Shot(TEXT("a")).DumpState(TEXT("b")).Quit(); TestEqual(TEXT("six steps were recorded"), B.Steps.Num(), 6); TestEqual(TEXT("time limit"), B.TimeLimitSeconds, 30.f); TestEqual(TEXT("last step is Quit"), B.Steps.Last().Label, FString(TEXT("Quit"))); const TArray Names = FDescentScenarioRegistry::Get().GetNames(); TestTrue(TEXT("'smoke' is registered"), Names.Contains(TEXT("smoke"))); TestTrue(TEXT("'kazah_showcase' is registered"), Names.Contains(TEXT("kazah_showcase"))); // Registered scenarios must build without a world (they only record steps). for (const FString& Name : Names) { const FDescentScenarioRegistry::FEntry* E = FDescentScenarioRegistry::Get().Find(Name); FDescentScenarioBuilder Built; E->Build(Built); TestTrue(*FString::Printf(TEXT("scenario %s has steps"), *Name), Built.Steps.Num() > 0); TestTrue(*FString::Printf(TEXT("scenario %s ends with Quit"), *Name), Built.Steps.Num() > 0 && Built.Steps.Last().Label == TEXT("Quit")); } return true; } IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDescentScenarioStepsTest, "Descent.Harness.ScenarioNewSteps", DESCENT_TEST_FLAGS) bool FDescentScenarioStepsTest::RunTest(const FString& Parameters) { // The step API of DESIGN section 9.2 records one step per call, with a label that names it. FDescentScenarioBuilder B; B.WaitSim(2.0).FastForward(30.0).Expect(TEXT("e"), [](FDescentScenarioContext&) { return true; }) .ExpectNear(TEXT("n"), 1.0, 1.0, 0.1).ExpectNear(TEXT("m"), [](FDescentScenarioContext&) { return 2.0; }, 2.0, 0.1); TestEqual(TEXT("five steps"), B.Steps.Num(), 5); TestTrue(TEXT("WaitSim label"), B.Steps[0].Label.StartsWith(TEXT("WaitSim"))); TestTrue(TEXT("FastForward label"), B.Steps[1].Label.StartsWith(TEXT("FastForward"))); TestTrue(TEXT("Expect label"), B.Steps[2].Label.Contains(TEXT("Expect(e)"))); TestTrue(TEXT("ExpectNear label"), B.Steps[3].Label.Contains(TEXT("ExpectNear(n)"))); TestTrue(TEXT("core_sim is registered"), FDescentScenarioRegistry::Get().GetNames().Contains(TEXT("core_sim"))); TestTrue(TEXT("kazah_closeups is registered"), FDescentScenarioRegistry::Get().GetNames().Contains(TEXT("kazah_closeups"))); return true; } IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDescentStatePartsTest, "Descent.Harness.StateRegistryParts", DESCENT_TEST_FLAGS) bool FDescentStatePartsTest::RunTest(const FString& Parameters) { // Several files own parts of ONE section (the design's `ui` is {hud, widgets, window, cargo_display}). FDescentStateRegistry& Reg = FDescentStateRegistry::Get(); { FDescentStatePartRegistrar PartA(TEXT("zz_parts"), TEXT("alpha"), [](UWorld*, FJsonObject& Out) { Out.SetNumberField(TEXT("a"), 1); }); FDescentStatePartRegistrar PartB(TEXT("zz_parts"), TEXT("beta"), [](UWorld*, FJsonObject& Out) { Out.SetStringField(TEXT("b"), TEXT("two")); }); TestTrue(TEXT("a section that has only parts is listed"), Reg.GetSectionNames().Contains(FName(TEXT("zz_parts")))); const TSharedRef Snap = Reg.BuildSnapshot(nullptr, TEXT("parts")); const TSharedPtr* Section = nullptr; if (TestTrue(TEXT("section present"), Snap->TryGetObjectField(TEXT("zz_parts"), Section))) { const TSharedPtr* A = nullptr; const TSharedPtr* B = nullptr; TestTrue(TEXT("part alpha present"), (*Section)->TryGetObjectField(TEXT("alpha"), A)); TestTrue(TEXT("part beta present"), (*Section)->TryGetObjectField(TEXT("beta"), B)); if (A && B) { TestEqual(TEXT("alpha value"), static_cast((*A)->GetNumberField(TEXT("a"))), 1); TestEqual(TEXT("beta value"), (*B)->GetStringField(TEXT("b")), FString(TEXT("two"))); } } } TestFalse(TEXT("section gone with its parts"), Reg.GetSectionNames().Contains(FName(TEXT("zz_parts")))); // The sections the core registers are all there. for (const TCHAR* Name : { TEXT("sim"), TEXT("boot"), TEXT("ui"), TEXT("env_query"), TEXT("stream") }) { TestTrue(*FString::Printf(TEXT("core section %s is registered"), Name), Reg.GetSectionNames().Contains(FName(Name))); } const TSharedRef Snap = Reg.BuildSnapshot(nullptr, TEXT("core")); const TSharedPtr* Ui = nullptr; if (TestTrue(TEXT("ui section present"), Snap->TryGetObjectField(TEXT("ui"), Ui))) { TestTrue(TEXT("ui has its hud part"), (*Ui)->HasTypedField(TEXT("hud"))); } return true; } IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDescentLumaStatsTest, "Descent.Harness.LumaStats", DESCENT_TEST_FLAGS) bool FDescentLumaStatsTest::RunTest(const FString& Parameters) { const int32 W = 100, H = 50; TArray Pixels; Pixels.Init(FColor(0, 0, 0, 255), W * H); TConstArrayView64 View(Pixels.GetData(), Pixels.Num()); FDescentLumaStats Black = Descent::Image::ComputeStats(View, W, H); TestEqual(TEXT("black mean"), Black.MeanLuma, 0.0, 1e-9); TestEqual(TEXT("black non_black"), Black.NonBlack, 0.0, 1e-9); // Left half white, right half black. for (int32 Y = 0; Y < H; ++Y) { for (int32 X = 0; X < W / 2; ++X) { Pixels[Y * W + X] = FColor(255, 255, 255, 255); } } FDescentLumaStats Half = Descent::Image::ComputeStats(View, W, H); TestEqual(TEXT("half white: mean luma 0.5"), Half.MeanLuma, 0.5, 1e-6); TestEqual(TEXT("half white: non_black 0.5"), Half.NonBlack, 0.5, 1e-6); TestEqual(TEXT("half white: mean r"), Half.MeanR, 0.5, 1e-6); // Centre 40 percent spans x 30..70: 20 of 40 columns are white. TestEqual(TEXT("half white: centre luma 0.5"), Half.CentreLuma, 0.5, 1e-6); TestEqual(TEXT("half white: p99 is white"), Half.P99Luma, 1.0, 1e-3); TestEqual(TEXT("rect over the white half"), Descent::Image::MeanLumaRect(View, W, H, 0.0, 0.0, 0.5, 1.0), 1.0, 1e-6); TestEqual(TEXT("rect over the black half"), Descent::Image::MeanLumaRect(View, W, H, 0.5, 0.0, 1.0, 1.0), 0.0, 1e-6); TestEqual(TEXT("whole-frame rect"), Descent::Image::MeanLumaRect(View, W, H, 0.0, 0.0, 1.0, 1.0), 0.5, 1e-6); // A ring around (25, 25) of radius 5..10 lies entirely inside the white half. TestEqual(TEXT("ring inside the white half"), Descent::Image::MeanLumaRing(View, W, H, 25.0, 25.0, 5.0, 10.0), 1.0, 1e-6); TestEqual(TEXT("ring inside the black half"), Descent::Image::MeanLumaRing(View, W, H, 75.0, 25.0, 5.0, 10.0), 0.0, 1e-6); // Rec.709 weights: pure green is 0.7152. TArray Green; Green.Init(FColor(0, 255, 0, 255), 4); TestEqual(TEXT("green luma"), Descent::Image::ComputeStats(TConstArrayView64(Green.GetData(), 4), 2, 2).MeanLuma, 0.7152, 1e-6); // A mismatched size gives zeros instead of reading out of bounds. TestEqual(TEXT("mismatched size"), Descent::Image::ComputeStats(View, W + 1, H).MeanLuma, 0.0, 1e-9); return true; } #endif // WITH_DEV_AUTOMATION_TESTS