// Headless tests of the UI track (DESIGN_UE.md 8.5, 9): HUD layout, style, widgets, window framework, dashboards. // Nothing here renders; the pictures are the business of the ui_* scenarios. #include "Tests/DescentTestCommon.h" #include "Core/DescentSimSubsystem.h" #include "Core/Tuning/DescentTune_UI.h" #include "Debug/DescentStateRegistry.h" #include "UI/DescentDash.h" #include "UI/DescentHudLayout.h" #include "UI/DescentUiDemo.h" #include "UI/DescentUiSubsystem.h" #include "UI/DescentWindows.h" #include "UI/NeuIcons.h" #include "UI/NeuStyle.h" #include "UI/NeuWidgets.h" #include "HAL/IConsoleManager.h" #include "InputCoreTypes.h" #if WITH_DEV_AUTOMATION_TESTS namespace UiTune = Descent::Tune::UI; namespace { /** Layout size in UI units for a viewport size in pixels. */ FVector2f UiTestLayout(float W, float H) { const float S = UiTune::UiScaleForViewport(W, H); return FVector2f(W / S, H / S); } FPointerEvent UiTestMouse(const FVector2f& At, FKey Button = EKeys::LeftMouseButton) { return FPointerEvent(0, At, At, TSet{ Button }, Button, 0.f, FModifierKeysState()); } } // --------------------------------------------------------------------------------------------------------- // HUD layout: section 8.5, "radio, prompt, vitals, hotbar, vehicle panel, objective and depth panel do not intersect" // --------------------------------------------------------------------------------------------------------- IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDescentUiHudLayoutTest, "Descent.UI.HudLayout", DESCENT_TEST_FLAGS) bool FDescentUiHudLayoutTest::RunTest(const FString& Parameters) { const FIntPoint Sizes[] = { FIntPoint(1280, 720), FIntPoint(1000, 560), FIntPoint(800, 450) }; using EMode = Descent::Ui::EHudDemoMode; const EMode Modes[] = { EMode::Foot, EMode::Swim, EMode::Sub, EMode::HelmChase, EMode::HelmBridge }; const TCHAR* ModeNames[] = { TEXT("Foot"), TEXT("Swim"), TEXT("Sub"), TEXT("HelmChase"), TEXT("HelmBridge") }; const TCHAR* Seven[] = { TEXT("radio"), TEXT("prompt"), TEXT("vitals"), TEXT("hotbar"), TEXT("vehicle"), TEXT("objective"), TEXT("depth") }; for (const FIntPoint& Px : Sizes) { const FVector2f Size = UiTestLayout(static_cast(Px.X), static_cast(Px.Y)); for (int32 M = 0; M < UE_ARRAY_COUNT(Modes); ++M) { for (int32 Variant = 0; Variant < 4; ++Variant) { FDescentHudModel Hud; Descent::Ui::FillDemoHud(Hud, Modes[M]); Hud.Vitals.bShowO2 = true; // the widest vitals panel Hud.Vehicle.bVisible = true; // and the vehicle panel next to the hotbar: the tightest case Hud.bShowHotbar = true; Hud.bShowProximity = true; Hud.bObjectiveFolded = (Variant & 1) != 0; if (Variant & 2) { Hud.Radio.Empty(); // radio absent: nothing else may move into its place } const FDescentHudLayout L = FDescentHudLayout::Compute(Size, Hud); const FString Case = FString::Printf(TEXT("%dx%d %s variant %d"), Px.X, Px.Y, ModeNames[M], Variant); FName A, B; TestFalse(FString::Printf(TEXT("%s: no two regions intersect (%s vs %s)"), *Case, *A.ToString(), *B.ToString()), L.FindOverlap(A, B)); for (const FDescentHudRect* R : L.VisibleRects()) { TestTrue(FString::Printf(TEXT("%s: %s lies inside the viewport"), *Case, *R->Name.ToString()), R->InsideSize(Size)); } if (Variant == 0) { for (const TCHAR* Name : Seven) { bool bFound = false; for (const FDescentHudRect* R : L.All()) { if (R->Name == Name) { bFound = R->bVisible || FString(Name) == TEXT("radio") || FString(Name) == TEXT("objective"); } } TestTrue(FString::Printf(TEXT("%s: region %s is part of the layout"), *Case, Name), bFound); } } } } } // The narrow-screen rule: below 900 wide the gauges are 56, the radio sits under the depth panel and a folded objective is hidden. FDescentHudModel Hud; Descent::Ui::FillDemoHud(Hud, EMode::Foot); Hud.bObjectiveFolded = true; const FDescentHudLayout Narrow = FDescentHudLayout::Compute(FVector2f(800.f, 450.f), Hud); TestTrue(TEXT("800 wide is narrow"), Narrow.bNarrow); TestEqual(TEXT("narrow gauges are 56"), Narrow.Gauge, 56.f); TestTrue(TEXT("the radio sits under the depth panel"), Narrow.Radio.Y >= Narrow.Depth.Bottom()); TestFalse(TEXT("a folded objective is hidden on a narrow screen"), Narrow.Objective.bVisible); const FDescentHudLayout Wide = FDescentHudLayout::Compute(FVector2f(1280.f, 720.f), Hud); TestFalse(TEXT("1280 wide is not narrow"), Wide.bNarrow); TestEqual(TEXT("wide gauges are 72"), Wide.Gauge, 72.f); TestTrue(TEXT("on a wide screen the radio is above the hotbar"), Wide.Radio.Bottom() <= Wide.Hotbar.Y); TestTrue(TEXT("the objective is at most 30 percent wide (190 minimum)"), Wide.Objective.W <= 1280.f * 0.30f + 0.01f); TestTrue(TEXT("the prompt sits 30 below the centre"), FMath::IsNearlyEqual(Wide.Prompt.Y, 360.f + 30.f, 0.01f)); Hud.Mode = TEXT("Title"); TestEqual(TEXT("nothing shows on the title card"), FDescentHudLayout::Compute(FVector2f(1280.f, 720.f), Hud).VisibleRects().Num(), 0); return true; } // --------------------------------------------------------------------------------------------------------- // Style: tokens and the generated shadow images // --------------------------------------------------------------------------------------------------------- IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDescentUiStyleTest, "Descent.UI.Style", DESCENT_TEST_FLAGS) bool FDescentUiStyleTest::RunTest(const FString& Parameters) { // Tokens are the v2 CSS values, round-tripped through linear colour. const FColor Bg = Neu::Bg().ToFColor(true); TestEqual(TEXT("bg is #1a1f26"), (Bg.R << 16) | (Bg.G << 8) | Bg.B, 0x1a1f26); const FColor Cy = Neu::Cyan().ToFColor(true); TestEqual(TEXT("cyan is #62d9e6"), (Cy.R << 16) | (Cy.G << 8) | Cy.B, 0x62d9e6); const FColor Lo = Neu::Lo().ToFColor(true); TestEqual(TEXT("lo is #0e1216"), (Lo.R << 16) | (Lo.G << 8) | Lo.B, 0x0e1216); TestEqual(TEXT("corner radius 16"), UiTune::CornerRadiusPx, 16.f); // Outer shadow of a 120 x 80 body with radius 16, soft: opaque inside, fading to nothing at the edge of the image. const FNeuShadowImage Outer = Neu::MakeShadowImage(ENeuShadowKind::Outer, ENeuShadowScale::Soft, 120.f, 80.f, 16.f); const int32 Reach = Neu::ShadowReach(ENeuShadowScale::Soft); TestEqual(TEXT("outer image is the body plus the reach on every side"), Outer.Width, 120 + 2 * Reach); TestEqual(TEXT("outer image height"), Outer.Height, 80 + 2 * Reach); const int32 Cy0 = Outer.Height / 2; TestTrue(TEXT("outer shadow is opaque under the body"), Outer.At(Outer.Width / 2, Cy0).A > 245); TestTrue(TEXT("outer shadow is about half at the body edge"), FMath::Abs(static_cast(Outer.At(Reach, Cy0).A) - 128) < 40); TestTrue(TEXT("outer shadow is gone at the image border"), Outer.At(0, Cy0).A < 4); bool bMonotonic = true; for (int32 X = 1; X < Reach + 4; ++X) { bMonotonic &= Outer.At(X, Cy0).A >= Outer.At(X - 1, Cy0).A; } TestTrue(TEXT("alpha rises smoothly from the border towards the body"), bMonotonic); TestTrue(TEXT("rounded corner: the body's corner pixel is lighter than its edge middle"), Outer.At(Reach + 1, Reach + 1).A < Outer.At(Reach + 1, Cy0).A); // Inner shadows: the dark one hugs the top-left rim, the light one the bottom-right rim; nothing in the middle or outside the shape. const FNeuShadowImage Dark = Neu::MakeShadowImage(ENeuShadowKind::InnerDark, ENeuShadowScale::Soft, 160.f, 120.f, 16.f); const FNeuShadowImage Light = Neu::MakeShadowImage(ENeuShadowKind::InnerLight, ENeuShadowScale::Soft, 160.f, 120.f, 16.f); TestEqual(TEXT("inner image is the body size"), Dark.Width, 160); TestTrue(TEXT("dark inner shadow is strong just inside the top edge"), Dark.At(80, 2).A > 150); TestTrue(TEXT("dark inner shadow is weak just inside the bottom edge"), Dark.At(80, 117).A < 40); TestTrue(TEXT("light inner shadow is strong just inside the bottom edge"), Light.At(80, 117).A > 130); TestTrue(TEXT("light inner shadow is weak just inside the top edge"), Light.At(80, 2).A < 40); TestTrue(TEXT("no inner shadow in the middle"), Dark.At(80, 60).A < 6 && Light.At(80, 60).A < 6); TestTrue(TEXT("nothing outside the rounded corner"), Dark.At(0, 0).A < 20); // 9-slice sizes: a body smaller than the canonical size gets its own texture. TestTrue(TEXT("a 52 px slot is too small for the soft inner 9-slice"), 52.f < Neu::NineSliceMinBody(ENeuShadowKind::InnerDark, ENeuShadowScale::Soft, 12.f)); TestTrue(TEXT("a 300 x 200 panel is big enough for the soft outer 9-slice"), 200.f >= Neu::NineSliceMinBody(ENeuShadowKind::Outer, ENeuShadowScale::Soft, 16.f)); // Item icons: the table of section 6.1. TestEqual(TEXT("ferrite is an ore"), static_cast(Neu::IconStyleFor(TEXT("ferrite")).Shape), static_cast(ENeuIconShape::Ore)); TestEqual(TEXT("ingot is refined"), static_cast(Neu::IconStyleFor(TEXT("ingot")).Shape), static_cast(ENeuIconShape::Refined)); TestEqual(TEXT("tank2 is a tank"), static_cast(Neu::IconStyleFor(TEXT("tank2")).Shape), static_cast(ENeuIconShape::Tank)); TestEqual(TEXT("flashlight is a tool"), static_cast(Neu::IconStyleFor(TEXT("flashlight")).Shape), static_cast(ENeuIconShape::Tool)); TestEqual(TEXT("kit_locker is a kit with the glyph Lo"), Neu::IconStyleFor(TEXT("kit_locker")).Glyph, FString(TEXT("Lo"))); TestEqual(TEXT("ferrite symbol"), Neu::OreSymbol(TEXT("ferrite")), FString(TEXT("Fe"))); TestEqual(TEXT("cobalt colour is #3fe6ff"), Neu::OreColor(TEXT("cobalt")).ToFColor(true).R, static_cast(0x3f)); return true; } // --------------------------------------------------------------------------------------------------------- // Widgets: click, disabled, slider mapping // --------------------------------------------------------------------------------------------------------- IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDescentUiWidgetsTest, "Descent.UI.Widgets", DESCENT_TEST_FLAGS) bool FDescentUiWidgetsTest::RunTest(const FString& Parameters) { int32 Clicks = 0; TSharedRef Button = SNew(SNeuButton).Text(TEXT("GO")).OnClick([&Clicks]() { ++Clicks; }); const FGeometry Geo = FGeometry::MakeRoot(FVector2f(120.f, 38.f), FSlateLayoutTransform()); const FVector2f Inside(60.f, 19.f); const FVector2f Outside(300.f, 19.f); Button->OnMouseButtonDown(Geo, UiTestMouse(Inside)); TestTrue(TEXT("pressed after the mouse goes down"), Button->IsPressed()); Button->OnMouseButtonUp(Geo, UiTestMouse(Inside)); TestFalse(TEXT("released"), Button->IsPressed()); TestEqual(TEXT("one click fired"), Clicks, 1); Button->OnMouseButtonDown(Geo, UiTestMouse(Inside)); Button->OnMouseButtonUp(Geo, UiTestMouse(Outside)); TestEqual(TEXT("releasing outside the button does not click"), Clicks, 1); Button->SetEnabled(false); Button->OnMouseButtonDown(Geo, UiTestMouse(Inside)); Button->OnMouseButtonUp(Geo, UiTestMouse(Inside)); TestEqual(TEXT("a disabled button never clicks"), Clicks, 1); Button->SetEnabled(true); Button->Click(); TestEqual(TEXT("Click() acts like a click"), Clicks, 2); // Slider: the thumb centre never leaves the track, ends map to 0 and 1. TestEqual(TEXT("left end is 0"), SNeuSlider::ValueAtX(0.f, 300.f, 26.f), 0.f); TestEqual(TEXT("right end is 1"), SNeuSlider::ValueAtX(300.f, 300.f, 26.f), 1.f); TestEqual(TEXT("the middle is a half"), SNeuSlider::ValueAtX(150.f, 300.f, 26.f), 0.5f, 0.001f); float Got = -1.f; TSharedRef Slider = SNew(SNeuSlider).Value(0.f).Width(300.f).OnValue([&Got](float V) { Got = V; }); const FGeometry SGeo = FGeometry::MakeRoot(FVector2f(300.f, 34.f), FSlateLayoutTransform()); Slider->OnMouseButtonDown(SGeo, UiTestMouse(FVector2f(150.f, 17.f))); TestEqual(TEXT("clicking the middle of the slider reports a half"), Got, 0.5f, 0.001f); Slider->OnMouseMove(SGeo, UiTestMouse(FVector2f(300.f, 17.f))); TestEqual(TEXT("dragging to the end reports 1"), Got, 1.f, 0.001f); Slider->OnMouseButtonUp(SGeo, UiTestMouse(FVector2f(300.f, 17.f))); Got = -1.f; Slider->SetEnabled(false); Slider->OnMouseButtonDown(SGeo, UiTestMouse(FVector2f(150.f, 17.f))); TestEqual(TEXT("a disabled slider ignores the mouse"), Got, -1.f); // A cell click, and a greyed cell that must not select. int32 CellClicks = 0; TSharedRef Cell = SNew(SNeuCell).ItemId(TEXT("ferrite")).OnClick([&CellClicks]() { ++CellClicks; }); const FGeometry CGeo = FGeometry::MakeRoot(FVector2f(74.f, 74.f), FSlateLayoutTransform()); Cell->OnMouseButtonUp(CGeo, UiTestMouse(FVector2f(30.f, 30.f))); TSharedRef Grey = SNew(SNeuCell).ItemId(TEXT("weapon")).Greyed(true).OnClick([&CellClicks]() { ++CellClicks; }); Grey->OnMouseButtonUp(CGeo, UiTestMouse(FVector2f(30.f, 30.f))); TestEqual(TEXT("the normal cell selects, the greyed weapon cell does not"), CellClicks, 1); return true; } // --------------------------------------------------------------------------------------------------------- // Window framework: names, fit, open / close / tab, pause, Bloom binding, state parts // --------------------------------------------------------------------------------------------------------- IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDescentUiWindowsTest, "Descent.UI.Windows", DESCENT_TEST_FLAGS) bool FDescentUiWindowsTest::RunTest(const FString& Parameters) { // Names the console accepts. EDescentWindow W; FName Tab; TestTrue(TEXT("pda"), Descent::Ui::ParseWindowName(TEXT("pda"), W, Tab) && W == EDescentWindow::Pda && Tab.IsNone()); TestTrue(TEXT("map opens the PDA on its map tab"), Descent::Ui::ParseWindowName(TEXT("MAP"), W, Tab) && W == EDescentWindow::Pda && Tab == TEXT("map")); for (const TCHAR* Name : { TEXT("fabricator"), TEXT("refinery"), TEXT("packer"), TEXT("storage"), TEXT("terminal"), TEXT("vat"), TEXT("build"), TEXT("pause"), TEXT("title"), TEXT("end"), TEXT("logs"), TEXT("status") }) { TestTrue(FString::Printf(TEXT("'%s' is a window"), Name), Descent::Ui::ParseWindowName(Name, W, Tab)); } TestFalse(TEXT("nonsense is not a window"), Descent::Ui::ParseWindowName(TEXT("toaster"), W, Tab)); // Every window fits the viewport at the three sizes, scaled down only as far as needed. const EDescentWindow All[] = { EDescentWindow::Pda, EDescentWindow::Fabricator, EDescentWindow::Refinery, EDescentWindow::Packer, EDescentWindow::Storage, EDescentWindow::Terminal, EDescentWindow::Vat, EDescentWindow::Build, EDescentWindow::Pause, EDescentWindow::Title, EDescentWindow::EndCard }; for (const FIntPoint& Px : { FIntPoint(1280, 720), FIntPoint(1000, 560), FIntPoint(800, 450) }) { const FVector2f Layout = UiTestLayout(static_cast(Px.X), static_cast(Px.Y)); for (const EDescentWindow Win : All) { const Descent::Ui::FWindowFit Fit = Descent::Ui::FitWindow(Descent::Ui::WindowDesignSize(Win), Layout); const FString Case = FString::Printf(TEXT("%s at %dx%d"), *Descent::Ui::WindowName(Win).ToString(), Px.X, Px.Y); TestTrue(*(Case + TEXT(" fits")), Fit.bFits && Fit.Size.X <= Layout.X && Fit.Size.Y <= Layout.Y); TestTrue(*(Case + TEXT(" is not scaled below 0.6 (legibility)")), Fit.Scale >= 0.6f); TestTrue(*(Case + TEXT(" is never enlarged")), Fit.Scale <= 1.f); } } TestEqual(TEXT("the PDA is shown at full size on 1280x720"), Descent::Ui::FitWindow(Descent::Ui::WindowDesignSize(EDescentWindow::Pda), FVector2f(1280.f, 720.f)).Scale, 1.f); // Keys that close a window (the host forwards them to Close). TestTrue(TEXT("Esc closes the pause window (resume)"), Descent::Ui::KeyClosesWindow(EDescentWindow::Pause, EKeys::Escape)); TestTrue(TEXT("Tab closes the PDA"), Descent::Ui::KeyClosesWindow(EDescentWindow::Pda, EKeys::Tab)); TestTrue(TEXT("M closes the PDA"), Descent::Ui::KeyClosesWindow(EDescentWindow::Pda, EKeys::M)); TestFalse(TEXT("E does not close the PDA"), Descent::Ui::KeyClosesWindow(EDescentWindow::Pda, EKeys::E)); TestTrue(TEXT("E closes the fabricator"), Descent::Ui::KeyClosesWindow(EDescentWindow::Fabricator, EKeys::E)); TestFalse(TEXT("E does not close the pause window"), Descent::Ui::KeyClosesWindow(EDescentWindow::Pause, EKeys::E)); TestFalse(TEXT("nothing closes the title card from the keyboard"), Descent::Ui::KeyClosesWindow(EDescentWindow::Title, EKeys::Escape)); TestFalse(TEXT("W closes nothing"), Descent::Ui::KeyClosesWindow(EDescentWindow::Terminal, EKeys::W)); // The subsystem in a throw-away world. FDescentTestWorld TW; UDescentUiSubsystem* Ui = TW.Sub(); UDescentSimSubsystem* Sim = TW.Sub(); if (!TestNotNull(TEXT("the UI subsystem exists in a game world"), Ui) || !TestNotNull(TEXT("and the sim"), Sim)) { return false; } TestFalse(TEXT("no window at the start"), Ui->IsWindowOpen()); TestTrue(TEXT("open the PDA"), Ui->Open(EDescentWindow::Pda)); TestTrue(TEXT("a window is open"), Ui->IsWindowOpen()); TestEqual(TEXT("the PDA opens on its first tab"), Ui->GetTab(), FName(TEXT("inventory"))); TestEqual(TEXT("the PDA has four tabs"), Ui->GetTabs().Num(), 4); TestTrue(TEXT("switch to the map"), Ui->SetTab(TEXT("map"))); TestEqual(TEXT("tab is map"), Ui->GetTab(), FName(TEXT("map"))); TestFalse(TEXT("an unknown tab is refused"), Ui->SetTab(TEXT("toaster"))); TestEqual(TEXT("and the tab is unchanged"), Ui->GetTab(), FName(TEXT("map"))); TestFalse(TEXT("the PDA does not pause the sim"), Sim->IsPaused()); Ui->Close(); TestFalse(TEXT("closed"), Ui->IsWindowOpen()); TestTrue(TEXT("open by console name"), Ui->OpenByName(TEXT("logs")) && Ui->GetWindow() == EDescentWindow::Pda && Ui->GetTab() == TEXT("logs")); Ui->Close(); TestFalse(TEXT("a wrong console name is refused"), Ui->OpenByName(TEXT("toaster"))); // Pause: opening pauses the sim, closing resumes it; a pause someone else made is not undone. Ui->Open(EDescentWindow::Pause); TestTrue(TEXT("pause window pauses the sim"), Sim->IsPaused() && Ui->IsPaused()); Ui->Close(); TestFalse(TEXT("closing resumes the sim"), Sim->IsPaused()); Sim->SetPaused(true); Ui->Open(EDescentWindow::Pause); Ui->Close(); TestTrue(TEXT("a sim paused by someone else stays paused"), Sim->IsPaused()); Sim->SetPaused(false); // Demo data fills the models; the fabricator's tabs are its categories. Ui->SetDemo(true); TestTrue(TEXT("demo is on"), Ui->IsDemo()); TestEqual(TEXT("24 inventory cells"), Ui->Models().Pda.Slots.Num(), 24); TestEqual(TEXT("six recipe categories"), Ui->Models().Fabricator.Categories.Num(), 6); Ui->Open(EDescentWindow::Fabricator); TestEqual(TEXT("the fabricator has one tab per category"), Ui->GetTabs().Num(), 6); TestEqual(TEXT("the first is SUBMERSIBLE"), Ui->GetTabLabel(Ui->GetTab()), FString(TEXT("SUBMERSIBLE"))); Ui->Close(); TestTrue(TEXT("the weapon recipe is locked"), Ui->Models().Fabricator.Categories.Last().Rows[0].bLocked); TestFalse(TEXT("so it cannot be afforded"), Ui->Models().Fabricator.Categories.Last().Rows[0].CanAfford()); // The state parts. Ui->Open(EDescentWindow::Terminal); const TSharedRef Snap = FDescentStateRegistry::Get().BuildSnapshot(TW.Get(), TEXT("ui_test")); const TSharedPtr* UiObj = nullptr; if (TestTrue(TEXT("ui section"), Snap->TryGetObjectField(TEXT("ui"), UiObj))) { const TSharedPtr* Win = nullptr; const TSharedPtr* Widgets = nullptr; const TSharedPtr* Cargo = nullptr; if (TestTrue(TEXT("ui.window"), (*UiObj)->TryGetObjectField(TEXT("window"), Win))) { TestTrue(TEXT("window is open"), (*Win)->GetBoolField(TEXT("open"))); TestEqual(TEXT("window name"), (*Win)->GetStringField(TEXT("name")), FString(TEXT("terminal"))); TestTrue(TEXT("window fits the viewport"), (*Win)->GetBoolField(TEXT("fits_viewport"))); } if (TestTrue(TEXT("ui.widgets"), (*UiObj)->TryGetObjectField(TEXT("widgets"), Widgets))) { TArray Names; for (const TSharedPtr& V : (*Widgets)->GetArrayField(TEXT("list"))) { Names.Add(V->AsObject()->GetStringField(TEXT("name"))); } for (const TCHAR* Name : { TEXT("depth"), TEXT("objective"), TEXT("vitals"), TEXT("hotbar"), TEXT("radio"), TEXT("prompt"), TEXT("vehicle"), TEXT("window") }) { TestTrue(FString::Printf(TEXT("ui.widgets lists %s"), Name), Names.Contains(Name)); } } if (TestTrue(TEXT("ui.cargo_display"), (*UiObj)->TryGetObjectField(TEXT("cargo_display"), Cargo))) { TestEqual(TEXT("cargo cap"), static_cast((*Cargo)->GetNumberField(TEXT("cap"))), 8); TestEqual(TEXT("cargo used"), static_cast((*Cargo)->GetNumberField(TEXT("used"))), 5); TestEqual(TEXT("one cell entry per hold cell"), (*Cargo)->GetArrayField(TEXT("cells")).Num(), 8); } } Ui->Close(); Ui->SetDemo(false); TestEqual(TEXT("demo off clears the models"), Ui->Models().Pda.Slots.Num(), 0); // The Bloom slider is bound to a console variable that may or may not exist. Ui->SetBloomVariableName(TEXT("descent.UI.NoSuchVariable")); TestFalse(TEXT("a missing variable means the slider is disabled"), Ui->IsBloomAvailable()); static TAutoConsoleVariable CVarTestBloom(TEXT("descent.UI.TestBloom"), 0.25f, TEXT("Test variable for the Bloom slider binding."), ECVF_Default); Ui->SetBloomVariableName(TEXT("descent.UI.TestBloom")); TestTrue(TEXT("an existing variable enables it"), Ui->IsBloomAvailable()); TestEqual(TEXT("it reads the value"), Ui->GetBloomValue(), 0.25f, 0.0001f); Ui->SetBloomValue(0.6f); TestEqual(TEXT("it writes the value"), CVarTestBloom.GetValueOnGameThread(), 0.6f, 0.0001f); Ui->SetBloomValue(5.f); TestTrue(TEXT("it clamps to the slider's span"), CVarTestBloom.GetValueOnGameThread() <= Ui->GetBloomMax() + 0.0001f); return true; } // --------------------------------------------------------------------------------------------------------- // Dashboards (V6, U11) // --------------------------------------------------------------------------------------------------------- IMPLEMENT_SIMPLE_AUTOMATION_TEST(FDescentUiDashTest, "Descent.UI.Dash", DESCENT_TEST_FLAGS) bool FDescentUiDashTest::RunTest(const FString& Parameters) { FTenderDashModel M; M.DepthM = 2061.0; M.RatingM = 2300.0; M.HullPct = 82.f; M.PowerPct = 64.f; M.TempC = 5.14; M.SpeedMps = 1.84f; M.HoldUsed = 5.f; M.HoldCap = 8.f; M.BioFu = 1.2f; M.BioCapFu = 4.f; SDescentDash::FReadout R = SDescentDash::MakeReadout(M); TestEqual(TEXT("depth text"), R.DepthText, FString(TEXT("2,061 m"))); TestEqual(TEXT("rating text"), R.RatingText, FString(TEXT("RATING 2,300 m"))); TestFalse(TEXT("not crushing"), R.bCrush); TestEqual(TEXT("line 1"), R.Line1, FString(TEXT("TEMP 5.1 °C SPD 1.8 m/s"))); TestEqual(TEXT("line 2"), R.Line2, FString(TEXT("HOLD 5/8 · BIO 1.2/4.0"))); TestEqual(TEXT("title"), R.TitleText, FString(TEXT("BT-01 TENDER · DRILL"))); TestEqual(TEXT("depth bar over 1,740..3,400"), R.DepthBar, (2061.f - 1740.f) / (3400.f - 1740.f), 0.0005f); TestEqual(TEXT("rating tick over the same span"), R.RatingTick, (2300.f - 1740.f) / (3400.f - 1740.f), 0.0005f); TestFalse(TEXT("hull 82 is cyan"), R.bHullRed); TestFalse(TEXT("power 64 is amber"), R.bPowerRed); M.DepthM = 2410.0; M.HullPct = 29.f; M.PowerPct = 19.f; M.HoldUsed = 8.f; R = SDescentDash::MakeReadout(M); TestTrue(TEXT("below the rating line, over it: crush"), R.bCrush); TestEqual(TEXT("crush text"), R.RatingText, FString(TEXT("!! CRUSH DEPTH 2,300 m"))); TestTrue(TEXT("hull under 30 is red"), R.bHullRed); TestTrue(TEXT("power under 20 is red"), R.bPowerRed); TestTrue(TEXT("a full hold turns line 2 red"), R.bHoldFull); M.HullPct = 30.f; M.PowerPct = 20.f; R = SDescentDash::MakeReadout(M); TestFalse(TEXT("hull exactly 30 is not red"), R.bHullRed); TestFalse(TEXT("power exactly 20 is not red"), R.bPowerRed); // The cargo display: 4 columns, cells fill bottom-up in arrival order, all inside the tank. const FVector2f Tank(340.f, 230.f); const SCargoDisplay::FCellLayout L = SCargoDisplay::LayoutCells(8, Tank); TestEqual(TEXT("8 cells make 2 rows"), L.Rows, 2); const FVector2f C0 = SCargoDisplay::CellPosition(L, 0, Tank); const FVector2f C3 = SCargoDisplay::CellPosition(L, 3, Tank); const FVector2f C4 = SCargoDisplay::CellPosition(L, 4, Tank); TestTrue(TEXT("cell 3 is to the right of cell 0 in the same row"), C3.X > C0.X && FMath::IsNearlyEqual(C3.Y, C0.Y)); TestTrue(TEXT("cell 4 starts the next row, above cell 0"), FMath::IsNearlyEqual(C4.X, C0.X) && C4.Y < C0.Y); TestTrue(TEXT("the first cell is in the bottom row"), C0.Y + L.Cell <= Tank.Y && C0.Y > Tank.Y * 0.4f); for (int32 I = 0; I < 8; ++I) { const FVector2f P = SCargoDisplay::CellPosition(L, I, Tank); TestTrue(*FString::Printf(TEXT("cell %d lies inside the tank"), I), P.X >= 0.f && P.Y >= 0.f && P.X + L.Cell <= Tank.X && P.Y + L.Cell <= Tank.Y); } const SCargoDisplay::FCellLayout Big = SCargoDisplay::LayoutCells(24, FVector2f(340.f, 330.f)); TestEqual(TEXT("24 cells make 6 rows"), Big.Rows, 6); TestTrue(TEXT("and the cells shrink to fit"), Big.Cell < L.Cell); FCargoDisplayModel C; C.Order = { TEXT("ferrite"), TEXT("cobalt"), TEXT("ferrite") }; TestEqual(TEXT("used"), C.Used(), 3); TestEqual(TEXT("ferrite count"), C.CountOf(TEXT("ferrite")), 2); TestFalse(TEXT("not full"), C.IsFull()); C.Order.SetNum(8); TestTrue(TEXT("full at the cap"), C.IsFull()); C.Order.Pop(); TestFalse(TEXT("unloading takes from the end: no longer full"), C.IsFull()); return true; } #endif // WITH_DEV_AUTOMATION_TESTS