240 lines
9.5 KiB
C++
240 lines
9.5 KiB
C++
|
|
/* 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;
|
||
|
|
}
|
||
|
|
}
|