xtc/tests/integration/test_juce_playback.cpp

240 lines
9.5 KiB
C++
Raw Normal View History

/* Native JUCE GUI playback regression. Connects only to a private software probe;
* neither user preferences nor physical audio/MIDI routes are changed. */
#include "../../src/gui/JUCE/main_window.hpp"
#include "../../src/gui/JUCE/sequencer_track_widget.hpp"
#include "../../src/gui/JUCE/sequencer_widget.hpp"
#include "../../src/gui/JUCE/ui.hpp"
#include <atomic>
#include <cerrno>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <jack/jack.h>
#include <stdexcept>
#include <string>
#include <unistd.h>
#define REQUIRE(c) \
do \
{ \
if (!(c)) \
throw std::runtime_error(#c); \
} while (0)
namespace
{
struct Probe
{
jack_client_t *client = nullptr;
jack_port_t *ports[2]{};
std::atomic<bool> heard[2]{};
std::atomic<bool> invalid{false};
~Probe()
{
if (client)
jack_client_close(client);
}
static int process(jack_nframes_t frames, void *data)
{
auto &probe = *static_cast<Probe *>(data);
for (int channel = 0; channel < 2; ++channel)
{
auto *samples =
static_cast<const float *>(jack_port_get_buffer(probe.ports[channel], frames));
for (jack_nframes_t i = 0; i < frames; ++i)
{
if (!std::isfinite(samples[i]))
probe.invalid.store(true, std::memory_order_relaxed);
if (std::fabs(samples[i]) > .001f)
probe.heard[channel].store(true, std::memory_order_relaxed);
}
}
return 0;
}
};
struct TemporaryDirectory
{
std::string path;
TemporaryDirectory()
{
char name[] = "/tmp/xtc-playback-XXXXXX";
REQUIRE(mkdtemp(name));
path = name;
}
~TemporaryDirectory() { std::filesystem::remove_all(path); }
};
UiWidget *find(UiGroup &group, const char *text, bool exact = true)
{
for (int i = 0; i < group.children(); ++i)
{
auto *child = group.child(i);
if (child->label() && (exact ? !std::strcmp(child->label(), text)
: std::strstr(child->label(), text) != nullptr))
return child;
if (auto *nested = dynamic_cast<UiGroup *>(child))
if (auto *result = find(*nested, text, exact))
return result;
}
return nullptr;
}
SequencerWidget *timeline(UiGroup &group)
{
for (int i = 0; i < group.children(); ++i)
{
if (auto *s = dynamic_cast<SequencerWidget *>(group.child(i)))
return s;
if (auto *nested = dynamic_cast<UiGroup *>(group.child(i)))
if (auto *s = timeline(*nested))
return s;
}
return nullptr;
}
void pump(int milliseconds)
{
auto end = std::chrono::steady_clock::now() + std::chrono::milliseconds(milliseconds);
while (std::chrono::steady_clock::now() < end)
Ui::wait(.01);
}
void mouse_click(juce::Component *target, juce::Point<float> point)
{
REQUIRE(target);
auto now = juce::Time::getCurrentTime();
juce::MouseEvent down(juce::Desktop::getInstance().getMainMouseSource(), point,
juce::ModifierKeys(juce::ModifierKeys::leftButtonModifier), 1, 0, 0, 0, 0,
target, target, now, point, now, 1, false);
target->mouseDown(down);
juce::MouseEvent up(juce::Desktop::getInstance().getMainMouseSource(), point,
juce::ModifierKeys(), 1, 0, 0, 0, 0, target, target, now, point, now, 1,
false);
target->mouseUp(up);
}
void click(MainWindow &, UiWidget *button)
{
REQUIRE(button && button->active());
// Deliver JUCE MouseEvents to the native TextButton child.
juce::Component *target = nullptr;
for (int i = 0; i < button->getNumChildComponents(); ++i)
if (dynamic_cast<juce::Button *>(button->getChildComponent(i)))
target = button->getChildComponent(i);
REQUIRE(target);
mouse_click(target, target->getLocalBounds().toFloat().getCentre());
}
} // namespace
int main()
{
UiInitialiser initialise;
try
{
TemporaryDirectory directory;
xtc_settings settings{};
xtc_settings_defaults(&settings);
std::strcpy(settings.midi_input_port, "xtc-missing-production-keyboard:out");
std::strcpy(settings.midi_output_port, "xtc-missing-production-synth:in");
std::snprintf(settings.client_name, sizeof(settings.client_name), "xtc-smoke-%ld",
static_cast<long>(getpid()));
settings.main_x = 0;
settings.main_y = 0;
settings.width = 1280;
settings.height = 800;
settings.transport_detached = settings.sequencer_detached = settings.graph_detached =
settings.mixer_detached = 0;
settings.loop_enabled = 0;
settings.stop_after_tail = 0;
std::string config = directory.path + "/preferences.conf";
REQUIRE(xtc_settings_save(&settings, config.c_str()) == XTC_OK);
Probe probe;
jack_status_t status{};
probe.client = jack_client_open("xtc-playback-probe", JackNoStartServer, &status);
REQUIRE(probe.client);
probe.ports[0] =
jack_port_register(probe.client, "left", JACK_DEFAULT_AUDIO_TYPE, JackPortIsInput, 0);
probe.ports[1] =
jack_port_register(probe.client, "right", JACK_DEFAULT_AUDIO_TYPE, JackPortIsInput, 0);
REQUIRE(probe.ports[0] && probe.ports[1]);
REQUIRE(jack_set_process_callback(probe.client, Probe::process, &probe) == 0 &&
jack_activate(probe.client) == 0);
MainWindow window(false, config.c_str());
window.show();
pump(100);
REQUIRE(window.open_project("examples/projects/playback-test.xtc") == XTC_OK);
auto *s = timeline(window);
REQUIRE(s);
click(window, find(window, "Play"));
pump(300);
if (!find(window, "running", false))
{
if (auto *error = find(window, "Start failed", false))
std::fprintf(stderr, "Observed: %s\n", error->label());
}
REQUIRE(find(window, "running", false));
REQUIRE(find(window, "MIDI device unavailable", false));
for (int channel = 0; channel < 2; ++channel)
{
std::string source =
std::string(settings.client_name) + (channel ? ":output_right" : ":output");
int code = -1;
for (int attempt = 0; attempt < 50; ++attempt)
{
code = jack_connect(probe.client, source.c_str(),
jack_port_name(probe.ports[channel]));
if (code == 0 || code == EEXIST)
break;
pump(10);
}
REQUIRE(code == 0 || code == EEXIST);
}
auto start = s->position();
pump(700);
REQUIRE(s->position() > start);
REQUIRE(probe.heard[0].load() && probe.heard[1].load() && !probe.invalid.load());
click(window, find(window, "Stop"));
pump(300);
auto paused = s->position();
pump(200);
REQUIRE(s->position() == paused);
// Space resumes after a sequencer child has taken focus.
s->take_focus();
REQUIRE(static_cast<juce::Component *>(s)->keyPressed(juce::KeyPress(' ')));
pump(300);
REQUIRE(s->position() > paused);
// A child at local (0,0) must paint consistently when composed into
// the full workstation; this catches retained-coordinate origin errors.
xtc_track_info track{};
REQUIRE(xtc_project_get_track(s->project(), 0, &track) == XTC_OK);
auto *header = s->track_widget(track.id)->header();
auto *menu = window.child(0);
REQUIRE(header->isShowing() && menu->isShowing());
mouse_click(header, {14, 12});
pump(60);
REQUIRE(s->track_expanded(track.id));
auto menu_image = menu->createComponentSnapshot(menu->getLocalBounds());
auto header_image = header->createComponentSnapshot(header->getLocalBounds());
auto image = window.createComponentSnapshot(window.getLocalBounds());
REQUIRE(image.getPixelAt(2, 2) == menu_image.getPixelAt(2, 2));
REQUIRE(image.getPixelAt(header->x() + 5, header->y() + 5) ==
header_image.getPixelAt(5, 5));
if (const char *capture = std::getenv("XTC_JUCE_SCREENSHOT"))
{
juce::FileOutputStream stream{juce::File(capture)};
REQUIRE(stream.openedOk());
juce::PNGImageFormat png;
REQUIRE(png.writeImageToStream(image, stream));
}
window.do_callback();
REQUIRE(!window.shown());
xtc_settings saved{};
REQUIRE(xtc_settings_load(&saved, config.c_str()) == XTC_OK);
REQUIRE(!std::strcmp(saved.midi_input_port, settings.midi_input_port) &&
!std::strcmp(saved.midi_output_port, settings.midi_output_port));
std::puts("Live Play/Stop/Space and stereo playback passed with unavailable saved MIDI "
"devices; preferences retained.");
return 0;
}
catch (const std::exception &error)
{
std::fprintf(stderr, "Playback regression failed: %s\n", error.what());
return 1;
}
}