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/sound_test.cpp

331 lines
12 KiB
C++

// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
// SPDX-License-Identifier: BSD-3-Clause
#include <gtest/gtest.h>
#include <chrono>
#include <cmath>
#include <csignal>
#include <cstdlib>
#include <fcntl.h>
#include <filesystem>
#include <functional>
#include <map>
#include <memory>
#include <optional>
#include <spawn.h>
#include <string>
#include <sys/wait.h>
#include <unistd.h>
#include <vector>
#include "base/event_loop.h"
#include "base/paths.h"
#include "base/timer.h"
#include "cutefish/audio/backend.h"
#include "sound_teststream.h"
#include "ui/application.h"
#include "ui/icon.h"
#include "ui/label.h"
#include "ui/segmented_control.h"
#include "ui/window.h"
#include "widgets/listrow.h"
#include "widgets/sliderrow.h"
#include "window/page.h"
#include "window/settingswindow.h"
extern char** environ;
using namespace cutefish;
using namespace cutefish::audio;
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)
{
base::EventLoop& loop = Application::instance()->loop();
if (condition())
return true;
base::Timer poll(&loop);
poll.onTimeout = [&] {
if (condition())
loop.quit();
};
base::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;
}
// Whether a shown label under the view reads the text.
bool showsText(View* view, const std::string& text)
{
if (auto* label = dynamic_cast<Label*>(view); label && isShown(label) && label->text() == text)
return true;
for (const auto& child : view->children()) {
if (showsText(child.get(), text))
return true;
}
return false;
}
// A program run beside the test, its output into a log file.
class Process
{
public:
~Process() { stop(); }
bool start(const std::string& program, const std::vector<std::string>& arguments, const std::string& log)
{
posix_spawn_file_actions_t actions;
posix_spawn_file_actions_init(&actions);
posix_spawn_file_actions_addopen(&actions, STDOUT_FILENO, log.c_str(), O_WRONLY | O_CREAT | O_APPEND, 0644);
posix_spawn_file_actions_adddup2(&actions, STDOUT_FILENO, STDERR_FILENO);
std::vector<char*> argv{const_cast<char*>(program.c_str())};
for (const std::string& argument : arguments)
argv.push_back(const_cast<char*>(argument.c_str()));
argv.push_back(nullptr);
const int result = posix_spawn(&m_pid, program.c_str(), &actions, nullptr, argv.data(), environ);
posix_spawn_file_actions_destroy(&actions);
if (result != 0)
m_pid = 0;
return m_pid != 0;
}
void stop()
{
if (!m_pid)
return;
::kill(m_pid, SIGTERM);
for (int i = 0; i < 300; ++i) {
if (::waitpid(m_pid, nullptr, WNOHANG) == m_pid) {
m_pid = 0;
return;
}
::usleep(10000);
}
::kill(m_pid, SIGKILL);
::waitpid(m_pid, nullptr, 0);
m_pid = 0;
}
private:
pid_t m_pid = 0;
};
// The variables the private PipeWire is found by; put back afterwards.
const char* const kVariables[] = {"XDG_RUNTIME_DIR", "PIPEWIRE_RUNTIME_DIR", "PIPEWIRE_REMOTE", "XDG_CONFIG_HOME",
"XDG_STATE_HOME", "XDG_CACHE_HOME"};
} // namespace
// The Sound page against a private PipeWire and WirePlumber with two sinks and a source.
class SoundPageTest : public testing::Test
{
protected:
static void SetUpTestSuite()
{
for (const char* name : kVariables) {
if (const char* value = std::getenv(name))
saved[name] = value;
}
char directory[] = "/tmp/cutefish-sound-test-XXXXXX";
runtime = ::mkdtemp(directory);
::setenv("XDG_RUNTIME_DIR", runtime.c_str(), 1);
::setenv("PIPEWIRE_RUNTIME_DIR", runtime.c_str(), 1);
::setenv("PIPEWIRE_REMOTE", "cutefish-audio-test", 1);
::setenv("XDG_CONFIG_HOME", (runtime + "/config").c_str(), 1);
::setenv("XDG_STATE_HOME", (runtime + "/state").c_str(), 1);
::setenv("XDG_CACHE_HOME", (runtime + "/cache").c_str(), 1);
backend = Backend::shared();
startServers();
ASSERT_TRUE(waitFor([] { return backend->isConnected(); }, 10s));
activateDevices();
}
static void TearDownTestSuite()
{
drivers.clear();
backend.reset();
stopServers();
for (const char* name : kVariables) {
const auto value = saved.find(name);
if (value == saved.end())
::unsetenv(name);
else
::setenv(name, value->second.c_str(), 1);
}
std::error_code error;
std::filesystem::remove_all(runtime, error);
}
static void startServers()
{
const std::string log = runtime + "/servers.log";
ASSERT_TRUE(server.start(base::paths::findExecutable("pipewire"),
{"-c", SETTINGS_SOURCE_DIR "/../tests/ui/sound_pipewire.conf"}, log));
ASSERT_TRUE(waitFor([] { return std::filesystem::exists(runtime + "/cutefish-audio-test"); }));
ASSERT_TRUE(policy.start(base::paths::findExecutable("wireplumber"), {"--profile=policy"}, log));
}
static void stopServers()
{
drivers.clear();
policy.stop();
server.stop();
}
static void activateDevices()
{
for (const char* name : {"test-output-a", "test-output-b", "test-input"}) {
ASSERT_TRUE(waitFor([name] { return find(name) != nullptr; }, 10s));
const auto node = find(name);
auto stream = std::make_unique<TestStream>(backend->core(), std::string("cutefish.internal.test-") + name,
node->property(PW_KEY_OBJECT_SERIAL),
node->kind() == AudioObject::Source);
ASSERT_TRUE(stream->connected);
TestStream* added = stream.get();
ASSERT_TRUE(waitFor([added] { return added->streaming; }));
drivers.push_back(std::move(stream));
}
ASSERT_TRUE(waitFor([] { return backend->defaultNode(AudioObject::Sink) != nullptr; }, 10s));
// WirePlumber leaves the virtual test source alone as the default.
find("test-input")->setDefault(true);
ASSERT_TRUE(waitFor([] { return backend->defaultNode(AudioObject::Source) == find("test-input"); }));
}
static std::shared_ptr<AudioObject> find(const std::string& name)
{
for (const auto& object : backend->objects()) {
if (object->name() == name)
return object;
}
return nullptr;
}
void openPage()
{
settingsWindow = std::make_unique<SettingsWindow>();
settingsWindow->window().setScale(1.5);
settingsWindow->window().resize({900, 820});
settingsWindow->reveal();
settingsWindow->switchToPage("sound");
settingsWindow->window().grab();
}
View* view(const std::string& name) { return findView(settingsWindow->currentPage(), name); }
View* child(const std::string& prefix, const std::shared_ptr<AudioObject>& device)
{
View* found = view(prefix + std::to_string(device->index()));
return isShown(found) ? found : nullptr;
}
void TearDown() override { settingsWindow.reset(); }
static inline std::string runtime;
static inline std::map<std::string, std::string> saved;
static inline std::shared_ptr<Backend> backend;
static inline Process server;
static inline Process policy;
static inline std::vector<std::unique_ptr<TestStream>> drivers;
std::unique_ptr<SettingsWindow> settingsWindow;
};
TEST_F(SoundPageTest, MultipleDevices)
{
auto playback = std::make_unique<TestStream>(backend->core(), "settings-playback",
find("test-output-a")->property(PW_KEY_OBJECT_SERIAL));
ASSERT_TRUE(waitFor([&] { return playback->streaming; }));
ASSERT_TRUE(waitFor([] { return find("settings-playback") != nullptr; }));
openPage();
const auto first = find("test-output-a");
const auto second = find("test-output-b");
const auto source = find("test-input");
ASSERT_TRUE(waitFor([&] { return child("device-", first) && child("device-", second); }));
EXPECT_FALSE(child("device-", source));
settingsWindow->window().grab();
EXPECT_GT(child("device-", first)->geometry().width, 600);
auto* secondRow = dynamic_cast<ListRow*>(child("device-", second));
ASSERT_TRUE(secondRow && secondRow->onClicked);
secondRow->onClicked();
ASSERT_TRUE(waitFor([&] { return backend->defaultNode(AudioObject::Sink) == second; }));
ASSERT_TRUE(waitFor([&] { return child("volume-", second) != nullptr; }));
EXPECT_FALSE(child("volume-", first));
const double originalVolume = first->volume();
auto* slider = dynamic_cast<SliderRow*>(child("volume-", second));
ASSERT_TRUE(slider && slider->onMoved);
slider->onMoved(0.57);
ASSERT_TRUE(waitFor([&] { return std::abs(second->volume() - 0.57) < 0.0001; }));
EXPECT_LT(std::abs(first->volume() - originalVolume), 0.0001);
EXPECT_FALSE(view("mute"));
// With no mute switch, muting shows as zero.
second->setMuted(true);
ASSERT_TRUE(waitFor([&] { return slider->value() == 0.0; }));
slider->onMoved(0.4);
ASSERT_TRUE(waitFor([&] { return !second->muted() && std::abs(second->volume() - 0.4) < 0.0001; }));
slider->onMoved(0.0);
ASSERT_TRUE(waitFor([&] { return second->muted(); }));
EXPECT_FALSE(first->muted());
EXPECT_LT(std::abs(first->volume() - originalVolume), 0.0001);
auto* tabs = dynamic_cast<SegmentedControl*>(view("tabs"));
ASSERT_TRUE(tabs && tabs->onCurrentChanged);
tabs->setCurrentIndex(1);
tabs->onCurrentChanged(1);
ASSERT_TRUE(waitFor([&] { return child("device-", source) != nullptr; }));
EXPECT_FALSE(child("device-", first));
ASSERT_TRUE(waitFor([&] { return child("volume-", source) != nullptr; }));
const auto stream = find("settings-playback");
ASSERT_TRUE(stream);
EXPECT_FALSE(view("stream-" + std::to_string(stream->index())));
EXPECT_FALSE(showsText(settingsWindow->currentPage(), "Applications"));
EXPECT_FALSE(showsText(settingsWindow->currentPage(), "Test sound"));
// The daemon going and coming back takes the rows with it.
tabs->setCurrentIndex(0);
tabs->onCurrentChanged(0);
const std::string firstRow = "device-" + std::to_string(first->index());
playback.reset();
ASSERT_TRUE(waitFor([] { return find("settings-playback") == nullptr; }));
stopServers();
ASSERT_TRUE(waitFor([] { return !backend->isConnected(); }));
ASSERT_TRUE(waitFor([&] { return !isShown(view(firstRow)); }));
EXPECT_TRUE(showsText(settingsWindow->currentPage(), "Connecting to the audio service…"));
startServers();
ASSERT_TRUE(waitFor([] { return find("test-output-a") && find("test-output-b"); }, 10s));
activateDevices();
ASSERT_TRUE(waitFor([&] { return child("device-", find("test-output-a")) != nullptr; }, 10s));
}