// Headless tests of the simulation core: UDescentSimSubsystem (step order, dt clamp, clock, FastForward, // late steps, pause), the intent latch and its debug override, and the boot registry. The tests create a game // world by hand and call Step / FastForward themselves: no map, no renderer, no tick. #include "Tests/DescentTestCommon.h" #include "Core/DescentBoot.h" #include "Core/DescentFlyPawn.h" #include "Core/DescentGameClasses.h" #include "Core/DescentGameMode.h" #include "Core/DescentIntent.h" #include "Core/DescentSimSubsystem.h" #include #include "Algo/Reverse.h" #include "GameFramework/Character.h" #include "GameFramework/PlayerController.h" #include "Async/TaskGraphInterfaces.h" #if WITH_DEV_AUTOMATION_TESTS namespace { /** Registers a callback that appends "" to a shared log, returning its handle. */ FDescentHandle LogStep(UDescentSimSubsystem& Sim, EDescentSimStep Step, TSharedRef> Log, const FString& Tag, FName Owner = NAME_None, int32 SubOrder = 0) { FDescentSimRegisterOptions O; O.Owner = Owner; O.SubOrder = SubOrder; return Sim.RegisterStep(Step, [Log, Tag](const FDescentSimFrame&) { Log->Add(Tag); }, O); } FString Joined(const TArray& Log) { return FString::Join(Log, TEXT(",")); } } IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDescentSimStepOrderTest, "Descent.Sim.StepOrder", DESCENT_TEST_FLAGS) bool FDescentSimStepOrderTest::RunTest(const FString& Parameters) { // The enum has every entry of the section 1.4 tables, in table order. TestEqual(TEXT("twelve sim steps"), static_cast(EDescentSimStep::Count), 12); TestEqual(TEXT("seven late steps"), static_cast(EDescentLateStep::Count), 7); TestEqual(TEXT("step 1 is the intent latch"), static_cast(EDescentSimStep::Intent), 0); TestTrue(TEXT("carrier before tenders"), EDescentSimStep::Carrier < EDescentSimStep::Tenders); TestTrue(TEXT("tool before vitals"), EDescentSimStep::TenderTool < EDescentSimStep::Vitals); TestTrue(TEXT("terrain before ore"), EDescentSimStep::Terrain < EDescentSimStep::Ore); TestTrue(TEXT("horror last"), static_cast(EDescentSimStep::Horror) == static_cast(EDescentSimStep::Count) - 1); TestEqual(TEXT("step names"), FString(DescentSimStepName(EDescentSimStep::Terrain)), FString(TEXT("Terrain"))); TestEqual(TEXT("late step names"), FString(DescentLateStepName(EDescentLateStep::HudModel)), FString(TEXT("HudModel"))); // Register in the reverse of execution order, then in a shuffled order in a second world: the sequence must be the same. const EDescentSimStep All[] = { EDescentSimStep::Intent, EDescentSimStep::Carrier, EDescentSimStep::Carousel, EDescentSimStep::Tenders, EDescentSimStep::TenderTool, EDescentSimStep::Vitals, EDescentSimStep::Terrain, EDescentSimStep::Ore, EDescentSimStep::Cargo, EDescentSimStep::Fauna, EDescentSimStep::Sonar, EDescentSimStep::Horror }; auto RunWith = [&](const TArray& RegistrationOrder) -> FString { FDescentTestWorld TW; UDescentSimSubsystem* Sim = TW.Sub(); if (!Sim) { return TEXT("no sim subsystem"); } TSharedRef> Log = MakeShared>(); for (int32 I : RegistrationOrder) { LogStep(*Sim, All[I], Log, DescentSimStepName(All[I])); } Sim->Step(1.0 / 30.0); return Joined(*Log); }; TArray Reverse, Shuffled = { 7, 2, 11, 0, 5, 9, 3, 10, 1, 8, 4, 6 }; for (int32 I = 11; I >= 0; --I) { Reverse.Add(I); } const FString Expected = TEXT("Intent,Carrier,Carousel,Tenders,TenderTool,Vitals,Terrain,Ore,Cargo,Fauna,Sonar,Horror"); TestEqual(TEXT("registered in reverse: runs in enum order"), RunWith(Reverse), Expected); TestEqual(TEXT("registered shuffled: runs in enum order"), RunWith(Shuffled), Expected); // Unregistered entries are skipped; only the registered ones run. { FDescentTestWorld TW; UDescentSimSubsystem* Sim = TW.Sub(); if (!TestNotNull(TEXT("sim subsystem exists in a game world"), Sim)) { return false; } TSharedRef> Log = MakeShared>(); LogStep(*Sim, EDescentSimStep::Horror, Log, TEXT("horror")); LogStep(*Sim, EDescentSimStep::Terrain, Log, TEXT("terrain")); Sim->Step(0.02); TestEqual(TEXT("only the registered entries run"), Joined(*Log), FString(TEXT("terrain,horror"))); TestEqual(TEXT("NumRegistered counts one terrain callback"), Sim->NumRegistered(EDescentSimStep::Terrain), 1); TestEqual(TEXT("NumRegistered counts none for Ore"), Sim->NumRegistered(EDescentSimStep::Ore), 0); } // Inside one entry: by SubOrder, then by owner name, never by registration order. { auto Inside = [&](bool bBackwards) -> FString { FDescentTestWorld TW; UDescentSimSubsystem* Sim = TW.Sub(); TSharedRef> Log = MakeShared>(); struct FReg { const TCHAR* Tag; const TCHAR* Owner; int32 Sub; }; TArray Regs = { { TEXT("b0"), TEXT("Bravo"), 0 }, { TEXT("a0"), TEXT("Alpha"), 0 }, { TEXT("z-5"), TEXT("Zulu"), -5 }, { TEXT("c9"), TEXT("Charlie"), 9 } }; if (bBackwards) { Algo::Reverse(Regs); } for (const FReg& R : Regs) { LogStep(*Sim, EDescentSimStep::Cargo, Log, R.Tag, R.Owner, R.Sub); } Sim->Step(0.01); return Joined(*Log); }; const FString Forward = Inside(false); TestEqual(TEXT("within an entry: SubOrder, then owner"), Forward, FString(TEXT("z-5,a0,b0,c9"))); TestEqual(TEXT("within an entry: registration order does not matter"), Inside(true), Forward); } // Late steps have their own order. { FDescentTestWorld TW; UDescentSimSubsystem* Sim = TW.Sub(); TSharedRef> Log = MakeShared>(); Sim->RegisterLateStep(EDescentLateStep::Dashboards, [Log](const FDescentSimFrame&) { Log->Add(TEXT("dash")); }); Sim->RegisterLateStep(EDescentLateStep::Environment, [Log](const FDescentSimFrame&) { Log->Add(TEXT("env")); }); Sim->RegisterLateStep(EDescentLateStep::HudModel, [Log](const FDescentSimFrame&) { Log->Add(TEXT("hud")); }); Sim->LateStep(0.016); TestEqual(TEXT("late steps run in table order"), Joined(*Log), FString(TEXT("env,hud,dash"))); } return true; } IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDescentSimClockTest, "Descent.Sim.ClockAndClamp", DESCENT_TEST_FLAGS) bool FDescentSimClockTest::RunTest(const FString& Parameters) { FDescentTestWorld TW; UDescentSimSubsystem* Sim = TW.Sub(); if (!TestNotNull(TEXT("sim subsystem"), Sim)) { return false; } TestEqual(TEXT("clock starts at 0"), Sim->SimTime(), 0.0); TestEqual(TEXT("no steps yet"), Sim->StepCount(), static_cast(0)); // Section 1.4: Step(min(DeltaSeconds, 0.05)). double SeenDt = -1.0, SeenTime = -1.0; int64 SeenIndex = -1; Sim->RegisterStep(EDescentSimStep::Carrier, [&](const FDescentSimFrame& F) { SeenDt = F.Dt; SeenTime = F.SimTime; SeenIndex = F.StepIndex; }); Sim->Step(0.02); TestEqual(TEXT("dt passes through below the clamp"), SeenDt, 0.02, 1e-12); TestEqual(TEXT("callbacks see the clock after this step"), SeenTime, 0.02, 1e-12); TestEqual(TEXT("first step has index 1"), SeenIndex, static_cast(1)); Sim->Step(1.0); TestEqual(TEXT("a 1 s frame is clamped to 0.05"), SeenDt, 0.05, 1e-12); TestEqual(TEXT("clock advanced by the clamped dt"), Sim->SimTime(), 0.07, 1e-12); Sim->Step(-3.0); TestEqual(TEXT("a negative dt becomes 0"), SeenDt, 0.0, 1e-12); Sim->Step(std::numeric_limits::quiet_NaN()); TestEqual(TEXT("a NaN dt becomes 0"), SeenDt, 0.0, 1e-12); TestEqual(TEXT("clock unchanged by the bad dts"), Sim->SimTime(), 0.07, 1e-12); TestEqual(TEXT("but they still count as steps"), Sim->StepCount(), static_cast(4)); TestEqual(TEXT("LastDt"), Sim->LastDt(), 0.0, 1e-12); // A callback that calls Step again is refused instead of recursing. int32 Calls = 0; Sim->RegisterStep(EDescentSimStep::Horror, [&](const FDescentSimFrame&) { ++Calls; Sim->Step(0.01); }); AddExpectedError(TEXT("Step called from inside a step"), EAutomationExpectedErrorFlags::Contains, 1); const double Before = Sim->SimTime(); Sim->Step(0.01); TestEqual(TEXT("the re-entrant call did not run"), Calls, 1); TestEqual(TEXT("and advanced the clock only once"), Sim->SimTime(), Before + 0.01, 1e-12); return true; } IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDescentSimFastForwardTest, "Descent.Sim.FastForward", DESCENT_TEST_FLAGS) bool FDescentSimFastForwardTest::RunTest(const FString& Parameters) { // Same arguments, same number of steps, same clock: in two independent worlds. auto Run = [](double Seconds, double Dt, int64& OutSteps, double& OutClock, int64& OutCalls, bool& bAllFast) { FDescentTestWorld TW; UDescentSimSubsystem* Sim = TW.Sub(); OutCalls = 0; bAllFast = true; Sim->RegisterStep(EDescentSimStep::Terrain, [&](const FDescentSimFrame& F) { ++OutCalls; bAllFast &= F.bFastForward; }); const int64 N = Sim->FastForward(Seconds, Dt); OutSteps = Sim->StepCount(); OutClock = Sim->SimTime(); return N; }; int64 StepsA = 0, StepsB = 0, CallsA = 0, CallsB = 0; double ClockA = 0.0, ClockB = 0.0; bool bFastA = false, bFastB = false; const int64 RanA = Run(10.0, 1.0 / 30.0, StepsA, ClockA, CallsA, bFastA); const int64 RanB = Run(10.0, 1.0 / 30.0, StepsB, ClockB, CallsB, bFastB); TestEqual(TEXT("10 s at 1/30 is 300 steps"), RanA, static_cast(300)); TestEqual(TEXT("both runs ran the same number of steps"), RanA, RanB); TestEqual(TEXT("both runs have the same step count"), StepsA, StepsB); TestEqual(TEXT("both runs end on exactly the same clock"), ClockA, ClockB, 0.0); TestEqual(TEXT("the clock is 10 s"), ClockA, 10.0, 1e-9); TestEqual(TEXT("a registered callback ran once per step"), CallsA, static_cast(300)); TestTrue(TEXT("callbacks are told they are fast-forwarding"), bFastA && bFastB); // A span that is not a multiple of dt ends with one shorter step, so the clock is exact. int64 Steps = 0, Calls = 0; double Clock = 0.0; bool bFast = false; const int64 Ran = Run(1.0, 0.03, Steps, Clock, Calls, bFast); TestEqual(TEXT("1 s at 0.03: 33 full steps and a remainder"), Ran, static_cast(34)); TestEqual(TEXT("the clock is exactly 1 s"), Clock, 1.0, 1e-9); // dt is clamped like Step's: a 1 s "step" is 0.05 s long, 20 of them make a second. const int64 Clamped = Run(1.0, 1.0, Steps, Clock, Calls, bFast); TestEqual(TEXT("dt 1.0 is clamped to 0.05"), Clamped, static_cast(20)); TestEqual(TEXT("clamped run clock"), Clock, 1.0, 1e-9); // Nothing to do, and a refusal. { FDescentTestWorld TW; UDescentSimSubsystem* Sim = TW.Sub(); TestEqual(TEXT("0 seconds runs nothing"), Sim->FastForward(0.0), static_cast(0)); TestEqual(TEXT("negative seconds run nothing"), Sim->FastForward(-5.0), static_cast(0)); TestEqual(TEXT("dt 0 runs nothing"), Sim->FastForward(5.0, 0.0), static_cast(0)); AddExpectedError(TEXT("would run more than"), EAutomationExpectedErrorFlags::Contains, 1); TestEqual(TEXT("an absurd span is refused"), Sim->FastForward(1.0e9), static_cast(0)); TestEqual(TEXT("and leaves the clock alone"), Sim->SimTime(), 0.0); TestFalse(TEXT("not fast-forwarding any more"), Sim->IsFastForwarding()); } // The default dt is the 30 FPS of the scenario runner. { FDescentTestWorld TW; UDescentSimSubsystem* Sim = TW.Sub(); TestEqual(TEXT("default dt: 2 s is 60 steps"), Sim->FastForward(2.0), static_cast(60)); } return true; } IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDescentSimRegistrationTest, "Descent.Sim.Registration", DESCENT_TEST_FLAGS) bool FDescentSimRegistrationTest::RunTest(const FString& Parameters) { FDescentTestWorld TW; UDescentSimSubsystem* Sim = TW.Sub(); if (!TestNotNull(TEXT("sim subsystem"), Sim)) { return false; } // Unregister: the callback stops, a second Unregister says no, an empty function is refused. int32 A = 0, B = 0; const FDescentHandle HA = Sim->RegisterStep(EDescentSimStep::Fauna, [&](const FDescentSimFrame&) { ++A; }); const FDescentHandle HB = Sim->RegisterStep(EDescentSimStep::Fauna, [&](const FDescentSimFrame&) { ++B; }); TestTrue(TEXT("valid handles"), HA.IsValid() && HB.IsValid() && HA != HB); Sim->Step(0.01); TestTrue(TEXT("both ran"), A == 1 && B == 1); TestTrue(TEXT("Unregister succeeds"), Sim->Unregister(HA)); TestFalse(TEXT("a second Unregister fails"), Sim->Unregister(HA)); TestFalse(TEXT("an invalid handle fails"), Sim->Unregister(FDescentHandle())); Sim->Step(0.01); TestTrue(TEXT("only B ran the second time"), A == 1 && B == 2); AddExpectedError(TEXT("has an empty function"), EAutomationExpectedErrorFlags::Contains, 1); TestFalse(TEXT("an empty function is refused"), Sim->RegisterStep(EDescentSimStep::Fauna, FDescentSimFn()).IsValid()); // A callback may unregister another (and itself) and register new ones while the step runs. int32 Victim = 0, Late = 0, SelfRemoving = 0; FDescentHandle HVictim, HSelf; FDescentSimRegisterOptions First; First.Owner = TEXT("aaa"); Sim->RegisterStep(EDescentSimStep::Sonar, [&](const FDescentSimFrame&) { Sim->Unregister(HVictim); Sim->RegisterStep(EDescentSimStep::Sonar, [&](const FDescentSimFrame&) { ++Late; }); }, First); FDescentSimRegisterOptions Mid; Mid.Owner = TEXT("bbb"); HVictim = Sim->RegisterStep(EDescentSimStep::Sonar, [&](const FDescentSimFrame&) { ++Victim; }, Mid); FDescentSimRegisterOptions Last; Last.Owner = TEXT("ccc"); HSelf = Sim->RegisterStep(EDescentSimStep::Sonar, [&](const FDescentSimFrame&) { ++SelfRemoving; Sim->Unregister(HSelf); }, Last); Sim->Step(0.01); TestEqual(TEXT("the victim was unregistered before its turn and did not run"), Victim, 0); TestEqual(TEXT("a callback registered during the step waits for the next one"), Late, 0); TestEqual(TEXT("the self-removing callback ran once"), SelfRemoving, 1); Sim->Step(0.01); TestEqual(TEXT("the newly registered callback runs from the next step"), Late, 1); TestEqual(TEXT("the self-removing callback is gone"), SelfRemoving, 1); // Rate limit: a 10 Hz callback in 30 Hz steps runs every third step with the accumulated dt. { FDescentTestWorld TW2; UDescentSimSubsystem* S2 = TW2.Sub(); int32 Calls = 0; double DtSum = 0.0; FDescentSimRegisterOptions O; O.Owner = TEXT("slow"); O.RateHz = 10.0; S2->RegisterStep(EDescentSimStep::Sonar, [&](const FDescentSimFrame& F) { ++Calls; DtSum += F.Dt; }, O); S2->FastForward(3.0); TestEqual(TEXT("10 Hz callback in 3 s runs 30 times"), Calls, 30); TestEqual(TEXT("and was handed all the time"), DtSum, 3.0, 1e-9); } return true; } IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDescentSimLateAndPauseTest, "Descent.Sim.LateStepAndPause", DESCENT_TEST_FLAGS) bool FDescentSimLateAndPauseTest::RunTest(const FString& Parameters) { FDescentTestWorld TW; UDescentSimSubsystem* Sim = TW.Sub(); if (!TestNotNull(TEXT("sim subsystem"), Sim)) { return false; } // LateStep: clamped, counted, does not move the sim clock. double SeenDt = 0.0; Sim->RegisterLateStep(EDescentLateStep::Audio, [&](const FDescentSimFrame& F) { SeenDt = F.Dt; }); Sim->LateStep(0.016); TestEqual(TEXT("late dt passes through"), SeenDt, 0.016, 1e-12); Sim->LateStep(5.0); TestEqual(TEXT("late dt is clamped"), SeenDt, Descent::Tune::Sim::MaxLateDt, 1e-12); TestEqual(TEXT("two late steps"), Sim->LateStepCount(), static_cast(2)); TestEqual(TEXT("late steps do not advance the sim clock"), Sim->SimTime(), 0.0); // A late callback at 10 Hz fed 60 Hz frames: every sixth frame. int32 Slow = 0; FDescentSimRegisterOptions O; O.Owner = TEXT("hud"); O.RateHz = 10.0; Sim->RegisterLateStep(EDescentLateStep::HudModel, [&](const FDescentSimFrame&) { ++Slow; }, O); for (int32 I = 0; I < 60; ++I) { Sim->LateStep(1.0 / 60.0); } TestEqual(TEXT("a 10 Hz late step over one second of 60 Hz frames runs 10 times"), Slow, 10); // Pause stops the tick function, not Step itself (so FastForward and tests can always drive it). // Drive the tick function's ExecuteTick directly, as the engine would. FDescentSimTickFunction& Tick = static_cast(Sim->GetSimTickFunction()); TestFalse(TEXT("sim starts unpaused"), Sim->IsPaused()); Tick.ExecuteTick(0.02f, LEVELTICK_All, ENamedThreads::GameThread, FGraphEventRef()); TestEqual(TEXT("an unpaused tick steps the sim"), Sim->SimTime(), 0.02, 1e-6); Sim->SetPaused(true); Tick.ExecuteTick(0.02f, LEVELTICK_All, ENamedThreads::GameThread, FGraphEventRef()); TestEqual(TEXT("a paused tick does not"), Sim->SimTime(), 0.02, 1e-6); Sim->Step(0.01); TestEqual(TEXT("but Step called directly always runs"), Sim->SimTime(), 0.03, 1e-6); Sim->SetPaused(false); Tick.ExecuteTick(0.02f, LEVELTICK_ViewportsOnly, ENamedThreads::GameThread, FGraphEventRef()); TestEqual(TEXT("the editor viewport tick is not gameplay"), Sim->SimTime(), 0.03, 1e-6); return true; } IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDescentSimIntentTest, "Descent.Sim.Intent", DESCENT_TEST_FLAGS) bool FDescentSimIntentTest::RunTest(const FString& Parameters) { FDescentTestWorld TW; UDescentSimSubsystem* Sim = TW.Sub(); if (!TestNotNull(TEXT("sim subsystem"), Sim)) { return false; } // What systems read inside a step is the latched value. FDescentIntent Seen; int32 Steps = 0; Sim->RegisterStep(EDescentSimStep::Carrier, [&](const FDescentSimFrame&) { Seen = Sim->GetIntent(); ++Steps; }); TestTrue(TEXT("the intent starts neutral"), Sim->GetIntent().IsNeutral()); // Live intent: written by the controller, latched at the start of the next step. FDescentIntent Live; Live.Move = FVector(0.5, -1.0, 0.0); Live.bPrimary = true; Sim->SetLiveIntent(Live); TestTrue(TEXT("writing the live intent does not change the latched one before a step"), Sim->GetIntent().IsNeutral()); Sim->Step(0.02); TestTrue(TEXT("the step latched the live move"), Seen.Move.Equals(FVector(0.5, -1.0, 0.0))); TestTrue(TEXT("and the primary button"), Seen.bPrimary); // The latch clamps: a controller bug cannot make a vehicle move at 5 times its speed. FDescentIntent Wild; Wild.Move = FVector(5.0, -9.0, 0.0); Wild.Move.Z = std::numeric_limits::quiet_NaN(); Sim->SetLiveIntent(Wild); Sim->Step(0.02); TestTrue(TEXT("move is clamped to -1..1 and NaN becomes 0"), Seen.Move.Equals(FVector(1.0, -1.0, 0.0))); // Debug override: wins for its sim duration, then the live intent is back. Sim->SetLiveIntent(Live); FDescentIntent Debug; Debug.Move = FVector(1.0, 0.0, 0.0); Debug.Look = FVector2D(30.0, -10.0); Debug.bSprint = true; Sim->SetDebugIntent(Debug, 0.5); TestTrue(TEXT("override reported active"), Sim->IsIntentOverridden()); Steps = 0; int32 OverrideSteps = 0; for (int32 I = 0; I < 40; ++I) { Sim->Step(0.025); if (Seen.bSprint && Seen.Move.X == 1.0 && Seen.Look.X == 30.0 && !Seen.bPrimary) { ++OverrideSteps; } } TestEqual(TEXT("0.5 s of sim at 0.025 s per step is 20 override steps"), OverrideSteps, 20); TestFalse(TEXT("then the override is gone"), Sim->IsIntentOverridden()); TestTrue(TEXT("and the live intent is latched again"), Seen.Move.Equals(FVector(0.5, -1.0, 0.0)) && Seen.bPrimary && !Seen.bSprint); // FastForward consumes an override in sim time, with no frames played. Sim->SetDebugIntent(Debug, 2.0); Sim->FastForward(1.0); TestTrue(TEXT("one of two seconds fast-forwarded: still held"), Sim->IsIntentOverridden() && FMath::IsNearlyEqual(Sim->DebugIntentRemaining(), 1.0, 1e-6)); Sim->FastForward(1.5); TestFalse(TEXT("fast-forwarded past its end: expired"), Sim->IsIntentOverridden()); // Clear, and a replacement. Sim->SetDebugIntent(Debug, 10.0); Sim->ClearDebugIntent(); TestFalse(TEXT("cleared"), Sim->IsIntentOverridden()); FDescentIntent Other; Other.bSecondary = true; Sim->SetDebugIntent(Debug, 10.0); Sim->SetDebugIntent(Other, 1.0); Sim->Step(0.01); TestTrue(TEXT("a new override replaces the old one"), Seen.bSecondary && !Seen.bSprint); // The struct itself. FDescentIntent N; TestTrue(TEXT("default is neutral"), N.IsNeutral()); N.bJump = true; TestFalse(TEXT("a pressed key is not neutral"), N.IsNeutral()); TestTrue(TEXT("equality"), N == N && N != FDescentIntent()); return true; } // --------------------------------------------------------------------------------------------------------- // Boot registry // --------------------------------------------------------------------------------------------------------- IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDescentBootOrderTest, "Descent.Boot.Order", DESCENT_TEST_FLAGS) bool FDescentBootOrderTest::RunTest(const FString& Parameters) { // A private registry, so no real boot step runs against a fake world. auto Run = [](const TArray>& Registrations, FDescentBootReport* OutReport = nullptr) { FDescentBootRegistry Reg; TSharedRef> Log = MakeShared>(); for (const TPair& R : Registrations) { const FString Name = R.Key; Reg.Register(*Name, R.Value, [Log, Name](FDescentBootContext&) { Log->Add(Name); }); } FDescentBootContext Ctx; const FDescentBootReport Report = Reg.Run(Ctx); if (OutReport) { *OutReport = Report; } return Joined(*Log); }; using FPair = TPair; const FString Expected = TEXT("world_def,env,carrier,tenders,stream,character,save"); const FString Forward = Run({ FPair(TEXT("world_def"), Descent::Boot::Order::BuildWorldDef), FPair(TEXT("env"), Descent::Boot::Order::SpawnEnvironment), FPair(TEXT("carrier"), Descent::Boot::Order::SpawnCarrier), FPair(TEXT("tenders"), Descent::Boot::Order::SpawnTenders), FPair(TEXT("stream"), Descent::Boot::Order::StreamInitial), FPair(TEXT("character"), Descent::Boot::Order::PlaceCharacter), FPair(TEXT("save"), Descent::Boot::Order::LoadSave) }); const FString Backward = Run({ FPair(TEXT("save"), Descent::Boot::Order::LoadSave), FPair(TEXT("character"), Descent::Boot::Order::PlaceCharacter), FPair(TEXT("stream"), Descent::Boot::Order::StreamInitial), FPair(TEXT("tenders"), Descent::Boot::Order::SpawnTenders), FPair(TEXT("carrier"), Descent::Boot::Order::SpawnCarrier), FPair(TEXT("env"), Descent::Boot::Order::SpawnEnvironment), FPair(TEXT("world_def"), Descent::Boot::Order::BuildWorldDef) }); TestEqual(TEXT("registered in order: runs by order number"), Forward, Expected); TestEqual(TEXT("registered backwards: same sequence"), Backward, Expected); // Equal order numbers are ordered by name, not by registration. TestEqual(TEXT("ties break by name"), Run({ FPair(TEXT("beta"), 10), FPair(TEXT("alpha"), 10), FPair(TEXT("gamma"), 5) }), FString(TEXT("gamma,alpha,beta"))); // A failing step is reported, named, and does not stop the others. { FDescentBootRegistry Reg; TSharedRef> Log = MakeShared>(); Reg.Register(TEXT("first"), 1, [Log](FDescentBootContext&) { Log->Add(TEXT("first")); }); Reg.Register(TEXT("broken"), 2, [Log](FDescentBootContext& C) { Log->Add(TEXT("broken")); C.Fail(TEXT("no carrier layout")); }); Reg.Register(TEXT("last"), 3, [Log](FDescentBootContext& C) { Log->Add(TEXT("last")); if (C.HasFailed()) { Log->Add(TEXT("leaked failure")); } }); FDescentBootContext Ctx; AddExpectedError(TEXT("DESCENT_BOOT_FAIL step=broken"), EAutomationExpectedErrorFlags::Contains, 1); const FDescentBootReport Report = Reg.Run(Ctx); TestEqual(TEXT("all three ran"), Joined(*Log), FString(TEXT("first,broken,last"))); TestFalse(TEXT("the report says the boot failed"), Report.bOk); TestEqual(TEXT("three entries"), Report.Ran.Num(), 3); if (Report.Ran.Num() == 3) { TestTrue(TEXT("first ok, second failed, third ok"), Report.Ran[0].bOk && !Report.Ran[1].bOk && Report.Ran[2].bOk); TestEqual(TEXT("the failure carries its reason"), Report.Ran[1].Error, FString(TEXT("no carrier layout"))); } } // The same name registered twice replaces. { FDescentBootRegistry Reg; int32 Old = 0, New = 0; Reg.Register(TEXT("dup"), 1, [&](FDescentBootContext&) { ++Old; }); AddExpectedError(TEXT("registered twice"), EAutomationExpectedErrorFlags::Contains, 1); Reg.Register(TEXT("dup"), 1, [&](FDescentBootContext&) { ++New; }); FDescentBootContext Ctx; Reg.Run(Ctx); TestTrue(TEXT("the later registration won"), Old == 0 && New == 1); TestTrue(TEXT("Unregister"), Reg.Unregister(TEXT("dup")) && !Reg.Unregister(TEXT("dup"))); } // The named constants fix the order of section 1.5 now, whoever registers. using namespace Descent::Boot::Order; TestTrue(TEXT("section 1.5 order: world def, environment, terrain actor, carrier"), BuildWorldDef < SpawnEnvironment && SpawnEnvironment < SpawnTerrainActor && SpawnTerrainActor < SpawnCarrier); TestTrue(TEXT("tenders, fixtures, placeables come after the carrier"), SpawnCarrier < SpawnTenders && SpawnTenders < SpawnFixtures && SpawnFixtures < SpawnStartPlaceables); TestTrue(TEXT("the blocking terrain stream comes after everything it streams around"), SpawnStartPlaceables < StreamInitial); TestTrue(TEXT("the character is placed after the stream, then the first mode, then the save"), StreamInitial < PlaceCharacter && PlaceCharacter < EnterFirstMode && EnterFirstMode < LoadSave); TestTrue(TEXT("UI is created before the player is placed"), StreamInitial < CreateUi && CreateUi < PlaceCharacter); TestTrue(TEXT("sentinels bracket the sequence"), BeforeAll < BuildWorldDef && LoadSave < AfterAll); // The real registry is sorted the same way (including the core's own self-check step). const TArray Real = FDescentBootRegistry::Get().GetOrdered(); bool bSorted = true; for (int32 I = 1; I < Real.Num(); ++I) { bSorted &= Real[I - 1].Order < Real[I].Order || (Real[I - 1].Order == Real[I].Order && Real[I - 1].Name < Real[I].Name); } TestTrue(TEXT("the real registry lists its steps in order"), bSorted); bool bHasSelfCheck = false; for (const FDescentBootRegistry::FStep& S : Real) { bHasSelfCheck |= S.Name == TEXT("CoreSeams"); } TestTrue(TEXT("the core registered its own CoreSeams boot step with the macro"), bHasSelfCheck); return true; } IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDescentGameClassesTest, "Descent.Boot.GameClasses", DESCENT_TEST_FLAGS) bool FDescentGameClassesTest::RunTest(const FString& Parameters) { // A private registry: the highest priority wins, ties go to the owner name that sorts first, never to registration order. FDescentGameClassRegistry Reg; TestNull(TEXT("nothing registered: null"), Reg.Resolve(EDescentGameClassSlot::Pawn)); Reg.Register(EDescentGameClassSlot::Pawn, 0, TEXT("Fly"), []() -> UClass* { return ADescentFlyPawn::StaticClass(); }); TestEqual(TEXT("the only registration wins"), Reg.Resolve(EDescentGameClassSlot::Pawn), ADescentFlyPawn::StaticClass()); Reg.Register(EDescentGameClassSlot::Pawn, 100, TEXT("Walker"), []() -> UClass* { return ACharacter::StaticClass(); }); TestEqual(TEXT("a higher priority replaces it"), Reg.Resolve(EDescentGameClassSlot::Pawn), ACharacter::StaticClass()); TestEqual(TEXT("and names its owner"), Reg.ResolveOwner(EDescentGameClassSlot::Pawn), FName(TEXT("Walker"))); TestNull(TEXT("other slots are unaffected"), Reg.Resolve(EDescentGameClassSlot::PlayerController)); Reg.Register(EDescentGameClassSlot::Pawn, 100, TEXT("Aardvark"), []() -> UClass* { return ADescentFlyPawn::StaticClass(); }); TestEqual(TEXT("equal priority: the owner name that sorts first"), Reg.ResolveOwner(EDescentGameClassSlot::Pawn), FName(TEXT("Aardvark"))); Reg.Unregister(EDescentGameClassSlot::Pawn, TEXT("Aardvark")); Reg.Unregister(EDescentGameClassSlot::Pawn, TEXT("Walker")); TestEqual(TEXT("unregistering falls back"), Reg.Resolve(EDescentGameClassSlot::Pawn), ADescentFlyPawn::StaticClass()); // The real registry and the game mode: the fly pawn registered itself, and the game mode's defaults picked it up. TestEqual(TEXT("the real registry resolves the placeholder pawn"), FDescentGameClassRegistry::Get().Resolve(EDescentGameClassSlot::Pawn) != nullptr, true); const ADescentGameMode* Defaults = GetDefault(); TestNotNull(TEXT("game mode defaults"), Defaults); if (Defaults) { TestTrue(TEXT("the game mode's pawn class is the registered one"), Defaults->DefaultPawnClass == FDescentGameClassRegistry::Get().Resolve(EDescentGameClassSlot::Pawn)); TestTrue(TEXT("and a controller class is set"), Defaults->PlayerControllerClass != nullptr); } return true; } #endif // WITH_DEV_AUTOMATION_TESTS