You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
settings/tests/ui/display_test.cpp

375 lines
14 KiB
C++

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
// SPDX-License-Identifier: BSD-3-Clause
#include <gtest/gtest.h>
#include <chrono>
#include <filesystem>
#include <functional>
#include <future>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <tuple>
#include <vector>
#include "base/dbus.h"
#include "base/event_loop.h"
#include "base/timer.h"
#include "cutefish/display/display_types.h"
#include "ui/application.h"
#include "ui/keys.h"
#include "ui/label.h"
#include "ui/test_input.h"
#include "ui/window.h"
#include "widgets/listrow.h"
#include "widgets/rowcombobox.h"
#include "window/page.h"
#include "window/settingswindow.h"
using namespace cutefish;
using namespace cutefish::base;
using namespace cutefish::display;
using namespace cutefish::ui;
using namespace settings;
using namespace std::chrono_literals;
namespace {
bool waitFor(const std::function<bool()>& condition, std::chrono::milliseconds timeout = 5s)
{
EventLoop& loop = Application::instance()->loop();
if (condition())
return true;
Timer poll(&loop);
poll.onTimeout = [&] {
if (condition())
loop.quit();
};
Timer limit(&loop);
limit.setSingleShot(true);
limit.onTimeout = [&loop] { loop.quit(); };
poll.start(5ms);
limit.start(timeout);
loop.run();
return condition();
}
View* findView(View* view, const std::string& name)
{
if (!view)
return nullptr;
if (view->name() == name)
return view;
for (const auto& child : view->children()) {
if (View* found = findView(child.get(), name))
return found;
}
return nullptr;
}
bool isShown(View* view)
{
if (!view)
return false;
for (; view; view = view->parent()) {
if (!view->isVisible())
return false;
}
return true;
}
std::string texts(View* view)
{
std::string result;
if (auto* label = dynamic_cast<Label*>(view); label && isShown(label))
result += label->text() + "|";
for (const auto& child : view->children())
result += texts(child.get());
return result;
}
// A DRM card node that exists, for the brightness row's connector name.
std::string cardDevice()
{
std::error_code error;
for (const auto& entry : std::filesystem::directory_iterator("/dev/dri", error)) {
if (entry.path().filename().string().starts_with("card"))
return entry.path().string();
}
return {};
}
using Request = std::tuple<std::string, bool, RequestedMode, uint32_t, uint32_t>;
// The compositor's display service, the night light and brightness of
// cutefish-services, on a thread of their own: the page reads brightness
// with blocking calls.
class FakeServices
{
public:
FakeServices()
{
std::promise<void> ready;
m_thread = std::thread([this, &ready] { run(ready); });
ready.get_future().get();
}
~FakeServices()
{
m_handle.post([this] { m_loop->quit(); });
m_thread.join();
}
// False when another connection owns a name: the page would talk to that
// service instead.
bool owned() const { return m_owned; }
std::vector<std::vector<Request>> requests() const { return locked(m_requests); }
std::vector<bool> previews() const { return locked(m_previews); }
std::vector<std::string> confirmed() const { return locked(m_confirmed); }
int32_t schedule() const { return locked(m_schedule); }
std::vector<std::string> brightnessDisplays() const { return locked(m_brightnessDisplays); }
private:
template<typename T>
T locked(const T& value) const
{
std::lock_guard lock(m_mutex);
return value;
}
void run(std::promise<void>& ready)
{
EventLoop loop;
m_loop = &loop;
m_handle = loop.handle();
std::unique_ptr<dbus::Connection> bus = dbus::Connection::open(dbus::Connection::Bus::Session, &loop);
std::unique_ptr<dbus::Connection> services = dbus::Connection::open(dbus::Connection::Bus::Session, &loop);
PhysicalDisplay panel;
panel.sessionId = "eDP-1";
panel.connectorName = "eDP-1";
panel.devicePath = cardDevice();
panel.connectionType = 1;
panel.capabilities = ColorTransformCapability;
panel.recommendedScale120 = 180;
for (const auto& [width, height] : {std::pair{2160, 1440}, std::pair{1920, 1280}, std::pair{1080, 720}}) {
Mode mode;
mode.width = width;
mode.height = height;
mode.refreshMilliHz = 60000;
mode.preferred = width == 2160;
mode.current = width == 2160;
panel.modes.push_back(mode);
}
std::vector<PhysicalDisplay> physical = {panel};
LogicalDisplay logical;
logical.id = "eDP-1";
logical.destinations = {"eDP-1"};
logical.width = 1440;
logical.height = 960;
logical.scale120 = 180;
logical.primary = true;
std::vector<LogicalDisplay> logicals = {logical};
uint64_t revision = 1;
std::string previewToken;
dbus::Object display(bus.get(), "/org/cutefish/DisplayConfig1", "org.cutefish.DisplayConfig1");
display.addMethod("GetState", [&] {
return std::make_tuple(std::string("test"), std::string("seat0"), uint64_t(1), revision, physical,
logicals, std::vector<Placement>(), previewToken,
uint64_t(previewToken.empty() ? 0 : 1));
});
const auto configure = [&](bool preview) {
return [&, preview](const std::string&, uint64_t, uint64_t, const std::vector<Request>& displays,
const std::string&, const std::vector<Placement>&, const MirrorGroups&) {
std::string token;
{
std::lock_guard lock(m_mutex);
m_requests.push_back(displays);
m_previews.push_back(preview);
token = "token-" + std::to_string(m_requests.size());
}
if (preview)
previewToken = token;
++revision;
for (const auto& [id, enabled, mode, scale120, rotation] : displays) {
for (Mode& candidate : physical.front().modes)
candidate.current = candidate.width == mode.width && candidate.height == mode.height;
logicals.front().scale120 = scale120;
logicals.front().rotation = rotation;
}
display.emitSignal("OperationFinished", token, true, false, uint32_t(0), std::string(),
std::vector<std::string>(), uint64_t(1), revision);
display.emitSignal("StateChanged", uint64_t(1), revision);
return token;
};
};
display.addMethod("SetConfiguration", configure(false));
display.addMethod("ApplyConfiguration", configure(true));
display.addMethod("ConfirmConfiguration", [&](const std::string& token) {
{
std::lock_guard lock(m_mutex);
m_confirmed.push_back(token);
}
previewToken.clear();
display.emitSignal("StateChanged", uint64_t(1), revision);
});
display.addMethod("RevertConfiguration", [&](const std::string&) {
previewToken.clear();
display.emitSignal("StateChanged", uint64_t(1), revision);
});
display.addSignal("StateChanged", "tt");
display.addSignal("OperationFinished", "sbbusastt");
display.addSignal("PreviewChanged", "sttt");
display.publish();
m_owned = bus->requestName("org.cutefish.DisplayConfig");
bool enabled = false;
dbus::Object nightLight(services.get(), "/org/cutefish/Services/NightLight", "org.cutefish.Services.NightLight");
nightLight.addProperty("enabled", [&] { return enabled; });
nightLight.addProperty("temperature", [] { return 0.5; });
nightLight.addProperty("schedule", [this] { return locked(m_schedule); });
nightLight.addProperty("customStart", [] { return int32_t(18 * 60); });
nightLight.addProperty("customEnd", [] { return int32_t(6 * 60); });
nightLight.addProperty("available", [] { return true; });
nightLight.addProperty("sunset", [] { return int32_t(18 * 60 + 30); });
nightLight.addProperty("sunrise", [] { return int32_t(6 * 60 + 15); });
nightLight.addMethod("setEnabled", [&](bool value) {
enabled = value;
nightLight.emitSignal("enabledChanged", value);
});
nightLight.addMethod("setSchedule", [&](int32_t value) {
{
std::lock_guard lock(m_mutex);
m_schedule = value;
}
nightLight.emitSignal("scheduleChanged", value);
});
for (const char* name : {"enabledChanged", "availableChanged"})
nightLight.addSignal(name, "b");
for (const char* name :
{"scheduleChanged", "customStartChanged", "customEndChanged", "sunsetChanged", "sunriseChanged"})
nightLight.addSignal(name, "i");
nightLight.addSignal("temperatureChanged", "d");
nightLight.publish();
if (!panel.devicePath.empty())
m_brightnessDisplays = {std::filesystem::path(panel.devicePath).filename().string() + "-eDP-1"};
dbus::Object brightness(services.get(), "/org/cutefish/Services/Brightness", "org.cutefish.Services.Brightness");
brightness.addMethod("displays", [this] { return locked(m_brightnessDisplays); });
brightness.addMethod("displayBrightness", [](const std::string&) { return int32_t(70); });
brightness.addMethod("setDisplayBrightness", [](const std::string&, int32_t) {});
brightness.addSignal("displaysChanged", "");
brightness.addSignal("displayBrightnessChanged", "si");
brightness.publish();
m_owned = services->requestName("org.cutefish.Services") && m_owned;
ready.set_value();
loop.run();
}
std::thread m_thread;
EventLoop* m_loop = nullptr;
LoopHandle m_handle;
bool m_owned = false;
mutable std::mutex m_mutex;
std::vector<std::vector<Request>> m_requests;
std::vector<bool> m_previews;
std::vector<std::string> m_confirmed;
int32_t m_schedule = 0;
std::vector<std::string> m_brightnessDisplays;
};
struct DisplayPageTest : testing::Test
{
void SetUp() override
{
services = std::make_unique<FakeServices>();
ASSERT_TRUE(services->owned());
settingsWindow = std::make_unique<SettingsWindow>();
settingsWindow->window().setScale(1.5);
settingsWindow->window().resize({900, 820});
settingsWindow->reveal();
settingsWindow->switchToPage("display");
settingsWindow->window().grab();
ASSERT_TRUE(waitFor([this] { return isShown(view("display:name")); }));
}
void TearDown() override
{
settingsWindow.reset();
services.reset();
}
View* view(const std::string& name) { return findView(settingsWindow->currentPage(), name); }
RowComboBox* combo(const std::string& name) { return dynamic_cast<RowComboBox*>(view(name)); }
std::unique_ptr<FakeServices> services;
std::unique_ptr<SettingsWindow> settingsWindow;
};
} // namespace
TEST_F(DisplayPageTest, NamesTheOnlyScreen)
{
EXPECT_EQ(texts(view("display:name")), "Built-in Display|");
EXPECT_FALSE(isShown(view("display:tabs")));
EXPECT_FALSE(isShown(view("display:arrangement")));
EXPECT_FALSE(isShown(view("display:error")));
EXPECT_EQ(combo("display:resolution")->currentText(), "2160 × 1440 (Recommended)");
EXPECT_EQ(combo("display:resolution")->count(), 3);
EXPECT_EQ(combo("display:refresh")->currentText(), "60.00 Hz");
EXPECT_EQ(combo("display:scale")->currentText(), "150% (Recommended)");
EXPECT_EQ(combo("display:rotation")->currentText(), "Normal");
EXPECT_EQ(isShown(view("display:brightness")), !services->brightnessDisplays().empty());
}
TEST_F(DisplayPageTest, ScaleAppliesAtOnce)
{
RowComboBox* scale = combo("display:scale");
ASSERT_TRUE(scale && scale->onActivated);
// 125%.
scale->onActivated(1);
ASSERT_TRUE(waitFor([this] { return services->requests().size() == 1; }));
EXPECT_FALSE(services->previews().front());
EXPECT_EQ(std::get<3>(services->requests().front().front()), 150u);
ASSERT_TRUE(waitFor([this] { return combo("display:scale")->currentText() == "125%"; }));
}
TEST_F(DisplayPageTest, ResolutionWaitsForConfirmation)
{
const size_t windows = Application::instance()->windows().size();
RowComboBox* resolution = combo("display:resolution");
ASSERT_TRUE(resolution && resolution->onActivated);
resolution->onActivated(1);
ASSERT_TRUE(waitFor([this] { return services->requests().size() == 1; }));
EXPECT_TRUE(services->previews().front());
EXPECT_EQ(std::get<2>(services->requests().front().front()).width, 1920);
// The confirmation opens over the window; Return keeps the mode.
ASSERT_TRUE(waitFor([windows] { return Application::instance()->windows().size() == windows + 1; }));
Window* dialog = Application::instance()->windows().back();
TestInput::setKeyboardFocus(*dialog, true);
TestInput::typeKey(*dialog, key::Return);
ASSERT_TRUE(waitFor([this] { return services->confirmed().size() == 1; }));
EXPECT_EQ(services->confirmed().front(), "token-1");
ASSERT_TRUE(waitFor([windows] { return Application::instance()->windows().size() == windows; }));
EXPECT_EQ(combo("display:resolution")->currentText(), "1920 × 1280");
}
TEST_F(DisplayPageTest, NightLightScheduleRows)
{
ASSERT_TRUE(waitFor([this] { return isShown(view("display:nightlight")); }));
EXPECT_FALSE(isShown(view("display:sunset")));
EXPECT_FALSE(isShown(view("display:from")));
RowComboBox* schedule = combo("display:schedule");
ASSERT_TRUE(schedule && schedule->onActivated);
schedule->onActivated(1);
ASSERT_TRUE(waitFor([this] { return services->schedule() == 1 && isShown(view("display:sunset")); }));
// Sunset 18:30 in the session's short time format.
EXPECT_NE(texts(view("display:sunset")).find("30"), std::string::npos);
schedule->onActivated(2);
ASSERT_TRUE(waitFor([this] { return isShown(view("display:from")) && !isShown(view("display:sunset")); }));
}