Add drag-and-drop regression test and build targets

This commit is contained in:
Armin 2026-10-04 23:58:44 +02:00
commit f28e4d37c5
7 changed files with 329 additions and 39 deletions

1
.gitignore vendored
View file

@ -1 +1,2 @@
build/
build-tests/

View file

@ -90,6 +90,42 @@ target_link_libraries(Monoslicer
juce::juce_recommended_lto_flags
)
# Drag and drop regression test. Off by default: a console app needs its own copy
# of the JUCE modules, so building it by default would roughly double build time.
# Enable with -DMONOSLICER_BUILD_TESTS=ON, or just use `make test`.
option(MONOSLICER_BUILD_TESTS "Build the test executables" OFF)
if(MONOSLICER_BUILD_TESTS)
juce_add_console_app(MonoslicerDnDTest
PRODUCT_NAME "MonoslicerDnDTest"
)
target_sources(MonoslicerDnDTest PRIVATE
Tests/DragAndDropTest.cpp
Source/PluginProcessor.cpp
Source/PluginEditor.cpp
Source/SliceManager.cpp
Source/WaveformDisplay.cpp
Source/PianoRollDisplay.cpp
)
target_compile_definitions(MonoslicerDnDTest PUBLIC
JUCE_WEB_BROWSER=0
JUCE_USE_CURL=0
JUCE_VST3_CAN_REPLACE_VST2=0
JucePlugin_Name="Monoslicer"
GIT_VERSION="${GIT_VERSION}"
# handleDragDrop() defers filesDropped() via MessageManager::callAsync, so the
# test needs to be able to pump the message loop to observe it.
JUCE_MODAL_LOOPS_PERMITTED=1
)
target_link_libraries(MonoslicerDnDTest
PRIVATE
juce::juce_audio_utils
juce::juce_audio_processors
juce::juce_audio_formats
juce::juce_dsp
)
endif()
# Suppress JUCE / VST3 SDK warnings globally for all targets.
if (CMAKE_CXX_COMPILER_ID MATCHES "Clang")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fcolor-diagnostics")

View file

@ -2,7 +2,32 @@ JUCE_REPO := https://github.com/juce-framework/JUCE.git
JUCE_DIR := lib/JUCE
JUCE_TAG := 8.0.9
BUILD_DIR := build
CMAKE_ARGS := -DCMAKE_BUILD_TYPE=Release
TEST_BUILD_DIR := build-tests
BUILD_TYPE := Release
CMAKE_ARGS := -DCMAKE_BUILD_TYPE=$(BUILD_TYPE)
UNAME_S := $(shell uname -s)
# Platform-aware CPU count
ifeq ($(UNAME_S),Darwin)
JOBS := $(shell sysctl -n hw.ncpu 2>/dev/null || nproc 2>/dev/null || echo 4)
else
JOBS := $(shell nproc 2>/dev/null || getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4)
endif
ARTEFACTS := $(BUILD_DIR)/Monoslicer_artefacts/$(BUILD_TYPE)
VST3 := $(ARTEFACTS)/VST3/Monoslicer.vst3
# AU only exists on macOS; Standalone is a .app on macOS, a plain binary elsewhere
ifeq ($(UNAME_S),Darwin)
AU := $(ARTEFACTS)/AU/Monoslicer.component
AU_DIR := ~/Library/Audio/Plug-Ins/Components
VST3_DIR := ~/Library/Audio/Plug-Ins/VST3
FORMATS := AU and VST3
else
AU :=
VST3_DIR := ~/.vst3
FORMATS := VST3
endif
C_RESET := \033[0m
C_BOLD := \033[1m
@ -25,9 +50,9 @@ COLORIZE = sed -E "$$(printf ' \
s/(.*error:.*)$$/\033[1m\\1\033[0m/i \
')"
.PHONY: all clean fetch configure build install
.PHONY: all clean fetch configure build install test
all: install
all: build
$(JUCE_DIR)/CMakeLists.txt:
$(call STEP,Fetching JUCE $(JUCE_TAG))
@ -38,28 +63,42 @@ fetch: $(JUCE_DIR)/CMakeLists.txt
configure: fetch
$(call STEP,Configuring CMake)
$(call INFO,$(C_DIM)cmake -B build -S . -DCMAKE_BUILD_TYPE=Release$(C_RESET))
$(call INFO,$(C_DIM)cmake -B $(BUILD_DIR) -S . $(CMAKE_ARGS)$(C_RESET))
cmake -B $(BUILD_DIR) -S . $(CMAKE_ARGS) | $(COLORIZE)
build: configure
$(call STEP,Building Monoslicer)
$(call INFO,$(C_DIM)$(C_YELLOW)$(shell getconf _NPROCESSORS_ONLN)$(C_RESET)$(C_DIM) parallel jobs$(C_RESET))
cmake --build $(BUILD_DIR) -j$(shell getconf _NPROCESSORS_ONLN) | $(COLORIZE)
$(call INFO,$(C_DIM)$(C_YELLOW)$(JOBS)$(C_RESET)$(C_DIM) parallel jobs$(C_RESET))
cmake --build $(BUILD_DIR) -j$(JOBS) | $(COLORIZE)
install: build
$(call STEP,Installing plugins)
$(call INFO,$(C_DIM)AU → ~/Library/Audio/Plug-Ins/Components$(C_RESET))
mkdir -p ~/Library/Audio/Plug-Ins/Components
cp -R $(BUILD_DIR)/Monoslicer_artefacts/Release/AU/Monoslicer.component ~/Library/Audio/Plug-Ins/Components/
$(call INFO,$(C_DIM)VST3 → ~/Library/Audio/Plug-Ins/VST3$(C_RESET))
mkdir -p ~/Library/Audio/Plug-Ins/VST3
cp -R $(BUILD_DIR)/Monoslicer_artefacts/Release/VST3/Monoslicer.vst3 ~/Library/Audio/Plug-Ins/VST3/
ifeq ($(UNAME_S),Darwin)
$(call INFO,AU → $(AU_DIR))
mkdir -p $(AU_DIR)
cp -R $(AU) $(AU_DIR)/
$(call INFO,VST3 → $(VST3_DIR))
mkdir -p $(VST3_DIR)
cp -R $(VST3) $(VST3_DIR)/
$(call STEP,Rescanning Audio Units)
killall -9 AudioComponentRegistrar 2>/dev/null; true
@printf '\n $(C_GREEN)$(C_BOLD)✓ Done.$(C_RESET) $(C_GREEN)Monoslicer$(C_RESET)$(C_DIM) installed as AU and VST3$(C_RESET)\n\n'
else
$(call INFO,VST3 → $(VST3_DIR))
mkdir -p $(VST3_DIR)
cp -R $(VST3) $(VST3_DIR)/
endif
@printf '\n $(C_GREEN)$(C_BOLD)✓ Done.$(C_RESET) $(C_GREEN)Monoslicer$(C_RESET)$(C_DIM) installed as $(FORMATS)$(C_RESET)\n\n'
clean:
$(call STEP,Cleaning)
rm -rf $(BUILD_DIR)
$(call INFO,$(C_DIM)removed $(BUILD_DIR)/$(C_RESET))
rm -rf $(BUILD_DIR) $(TEST_BUILD_DIR)
$(call INFO,$(C_DIM)removed $(BUILD_DIR)/ and $(TEST_BUILD_DIR)/$(C_RESET))
@printf ' $(C_GREEN)$(C_BOLD)✓ Clean.$(C_RESET)\n'
# Tests live in their own build dir so `make` stays fast.
test:
$(call STEP,Building and running tests)
$(call INFO,$(C_DIM)separate build dir → $(TEST_BUILD_DIR)$(C_RESET))
cmake -B $(TEST_BUILD_DIR) -S . $(CMAKE_ARGS) -DMONOSLICER_BUILD_TESTS=ON > /dev/null
cmake --build $(TEST_BUILD_DIR) -j$(JOBS) --target MonoslicerDnDTest > /dev/null
$(TEST_BUILD_DIR)/MonoslicerDnDTest_artefacts/$(BUILD_TYPE)/MonoslicerDnDTest

View file

@ -28,10 +28,15 @@ Load an audio file, slice it into regions (automatically via onset detection or
Requires CMake 3.22+ and a C++17 compiler.
```sh
make # Fetch JUCE, configure, build, and install (AU + VST3)
make # Fetch JUCE, configure, build
make install # Build, then copy the plugins to your system directories
```
Running `make` with no arguments will fetch JUCE, build Monoslicer in Release mode, copy the AU and VST3 plugins to your system directories, and trigger an Audio Unit rescan.
`make` only builds — installing to system plugin folders is opt-in via `make install`.
On macOS `make install` copies the AU to `~/Library/Audio/Plug-Ins/Components` and the VST3
to `~/Library/Audio/Plug-Ins/VST3`, then triggers an Audio Unit rescan. On Linux it copies the
VST3 bundle to `~/.vst3`.
Individual targets are also available:
@ -39,13 +44,24 @@ Individual targets are also available:
make fetch # Clone JUCE 8.0.9
make configure # CMake configure
make build # Build (Release)
make install # Install AU + VST3 to system directories
make install # Build, then install (AU + VST3 on macOS, VST3 on Linux)
```
### Tests
```sh
make test
```
Currently one suite: `Tests/DragAndDropTest.cpp` verifies that dropping an audio file
onto the editor is discovered and loaded. It uses a real native peer, so it exercises
the same code path a host does. Tests build in a separate `build-tests/` directory, so
`make` is unaffected.
### Clean
```sh
make clean
make clean # removes build/ and build-tests/
```
## Plugin Formats

View file

@ -552,33 +552,18 @@ void MonoslicerEditor::comboBoxChanged(juce::ComboBox* comboBoxThatHasChanged)
}
}
// [EDITOR DIAG] temporary
static void ediag(const char* what, const juce::StringArray& files, int accept)
{
FILE* f = fopen("/tmp/monoslicer_drag.log", "a");
if (f == nullptr) return;
fprintf(f, "[editor] %s accept=%d files=%d", what, accept, (int)files.size());
for (const auto& x : files) fprintf(f, " \"%s\"", x.toUTF8().getAddress());
fprintf(f, "\n");
fclose(f);
}
bool MonoslicerEditor::isInterestedInFileDrag(const juce::StringArray& files)
{
for (const auto& f : files)
if (f.endsWithIgnoreCase(".wav") || f.endsWithIgnoreCase(".aiff") ||
f.endsWithIgnoreCase(".flac") || f.endsWithIgnoreCase(".ogg"))
{
ediag("isInterested", files, 1);
return true;
}
ediag("isInterested", files, 0);
return false;
}
void MonoslicerEditor::fileDragEnter(const juce::StringArray& files, int, int)
{
ediag("enter", files, isInterestedInFileDrag(files) ? 1 : 0);
if (isInterestedInFileDrag(files))
{
dropOverlay.setVisible(true);
@ -595,13 +580,11 @@ void MonoslicerEditor::fileDragMove(const juce::StringArray& files, int, int)
void MonoslicerEditor::fileDragExit(const juce::StringArray&)
{
ediag("exit", juce::StringArray(), 0);
dropOverlay.setVisible(false);
}
void MonoslicerEditor::filesDropped(const juce::StringArray& files, int, int)
{
ediag("drop", files, 1);
dropOverlay.setVisible(false);
for (const auto& f : files)
@ -611,7 +594,7 @@ void MonoslicerEditor::filesDropped(const juce::StringArray& files, int, int)
{
processorRef.loadAudioFile(file);
waveformDisplay.setSampleBuffer(&processorRef.getSampleBuffer(), processorRef.getSampleRateLoaded());
break;
}
}

View file

@ -101,7 +101,7 @@ private:
};
class MonoslicerEditor : public juce::AudioProcessorEditor,
private juce::FileDragAndDropTarget,
public juce::FileDragAndDropTarget,
private juce::Button::Listener,
private juce::ComboBox::Listener,
private juce::Timer

215
Tests/DragAndDropTest.cpp Normal file
View file

@ -0,0 +1,215 @@
// Regression test: dropping an audio file onto the plugin editor.
//
// Drag and drop discovery is driven entirely by RTTI. JUCE's
// ComponentPeer::DragHelpers::findDragAndDropTarget walks up the component
// chain and does dynamic_cast<FileDragAndDropTarget*> on each candidate, so the
// interface only has to be visible as a *public* base. Inheriting it privately
// makes the cast return null and the drop handlers are never called - silently,
// with no warning at build time.
//
// Build and run:
// cmake --build build --target MonoslicerDnDTest
// ./build/MonoslicerDnDTest_artefacts/Release/MonoslicerDnDTest
#include "../Source/PluginEditor.h"
#include "../Source/PluginProcessor.h"
#include <iostream>
namespace
{
int failures = 0;
void check (bool condition, const juce::String& what)
{
std::cout << (condition ? " \033[32mPASS\033[0m " : " \033[1;31mFAIL\033[0m ")
<< what << std::endl;
if (!condition)
++failures;
}
// Mirrors JUCE's DragHelpers::findDragAndDropTarget (juce_ComponentPeer.cpp).
// Returns the winning *component* rather than a FileDragAndDropTarget* so that
// this compiles - and fails at runtime rather than at build time - even when the
// interface is inherited privately. That is precisely the regression being
// guarded against: JUCE casts from Component*, which is always legal.
juce::Component* findDragTargetComponent (juce::Component* c,
const juce::StringArray& files)
{
for (; c != nullptr; c = c->getParentComponent())
{
if (auto* target = dynamic_cast<juce::FileDragAndDropTarget*> (c))
if (target->isInterestedInFileDrag (files))
return c;
}
return nullptr;
}
// Mirrors the host structure: JUCE's VST3 wrapper puts the plugin editor inside a
// ContentWrapperComponent and adds *that* to the desktop
// (juce_audio_plugin_client_VST3.cpp:1996). The peer therefore knows nothing about
// the editor directly - it can only reach it by walking the component chain.
struct HostWrapper final : juce::Component
{
explicit HostWrapper (juce::Component& child)
{
addAndMakeVisible (child);
setSize (child.getWidth(), child.getHeight());
}
void paint (juce::Graphics& g) override { g.fillAll (juce::Colours::black); }
};
void dumpChain (juce::Component* c)
{
std::cout << " chain:";
for (; c != nullptr; c = c->getParentComponent())
std::cout << " \"" << juce::String (typeid (*c).name()).upToLastOccurrenceOf ("::", true, false)
<< "\""
<< (c->isVisible() ? "" : "(hidden)")
<< (dynamic_cast<juce::FileDragAndDropTarget*> (c) != nullptr ? "[fdnd]" : "")
<< " " << c->getWidth() << "x" << c->getHeight();
std::cout << std::endl;
}
// The editor scales itself via setTransform() in resized(); at the base size
// that transform is the identity, which keeps pointer maths straightforward.
constexpr int baseWidth = 920;
constexpr int baseHeight = 606;
juce::File writeTestWav()
{
auto file = juce::File::getSpecialLocation (juce::File::tempDirectory)
.getChildFile ("monoslicer_dnd_test.wav");
constexpr double sampleRate = 44100.0;
constexpr int numSamples = 4096;
juce::AudioBuffer<float> buffer (1, numSamples);
for (int i = 0; i < numSamples; ++i)
buffer.setSample (0, i, std::sin (i / 20.0f));
juce::WavAudioFormat format;
std::unique_ptr<juce::FileOutputStream> out (file.createOutputStream());
if (out == nullptr)
{
std::cout << " \033[1;31mFAIL\033[0m could not create " << file.getFullPathName() << std::endl;
++failures;
return {};
}
{
// The writer has to be destroyed before the stream it writes to.
std::unique_ptr<juce::AudioFormatWriter> writer (
format.createWriterFor (out.get(), sampleRate, 1, 16, {}, 0));
if (writer == nullptr)
{
std::cout << " \033[1;31mFAIL\033[0m could not create a WAV writer" << std::endl;
++failures;
return {};
}
writer->writeFromAudioSampleBuffer (buffer, 0, numSamples);
}
out.release(); // commits and closes the file
if (file.getSize() == 0)
{
std::cout << " \033[1;31mFAIL\033[0m test WAV was written empty" << std::endl;
++failures;
return {};
}
return file;
}
}
int main()
{
juce::ScopedJuceInitialiser_GUI juceInitialiser;
std::cout << "\n\033[1mMonoslicer - file drag and drop\033[0m\n\n";
const auto wavFile = writeTestWav();
if (!wavFile.existsAsFile())
return 1;
const auto path = wavFile.getFullPathName();
const juce::Point<int> pointerPos (400, 300);
MonoslicerProcessor processor;
MonoslicerEditor editor (processor);
editor.setSize (baseWidth, baseHeight);
HostWrapper wrapper (editor);
wrapper.addToDesktop (0);
wrapper.setVisible (true);
auto* peer = wrapper.getPeer();
std::cout << "\n\033[1mRTTI visibility\033[0m\n";
check (dynamic_cast<juce::FileDragAndDropTarget*> (static_cast<juce::Component*> (&editor)) != nullptr,
"editor exposes FileDragAndDropTarget as a public base");
std::cout << "\n\033[1mAcceptance\033[0m\n";
check (editor.isInterestedInFileDrag ({ path }), "accepts a .wav file");
check (editor.isInterestedInFileDrag ({ "/tmp/notes.txt" }) == false, "rejects a .txt file");
std::cout << "\n\033[1mTarget discovery (as used by JUCE)\033[0m\n";
auto* underPointer = peer != nullptr ? peer->getComponent().getComponentAt (pointerPos)
: wrapper.getComponentAt (pointerPos);
if (underPointer == nullptr)
std::cout << " (no component under the pointer)" << std::endl;
dumpChain (underPointer);
check (findDragTargetComponent (underPointer, { path }) == static_cast<juce::Component*> (&editor),
"lookup from the component under the pointer finds the editor");
check (findDragTargetComponent (underPointer, { "/tmp/notes.txt" }) == nullptr,
"lookup for an unsupported file finds no target");
std::cout << "\n\033[1mEnd-to-end drop via a real native peer\033[0m\n";
if (peer != nullptr)
{
juce::ComponentPeer::DragInfo info;
info.files.add (path);
info.position = pointerPos;
check (peer->handleDragMove (info), "peer accepts the drag (handleDragMove)");
check (peer->handleDragDrop (info), "peer accepts the drop (handleDragDrop)");
// handleDragDrop() defers filesDropped() through MessageManager::callAsync
juce::MessageManager::getInstance()->runDispatchLoopUntil (250);
check (processor.getSampleBuffer().getNumSamples() == 4096,
"the dropped sample was decoded into the processor");
check (processor.getCurrentFile() == wavFile,
"the dropped file is recorded as the current file");
// Note: dropping does not auto-slice - that is a separate explicit action,
// and matches the behaviour of the "Load" button.
check (processor.getSampleRateLoaded() == 44100.0,
"the reader set the sample rate of the dropped file");
}
else
{
std::cout << " \033[33mSKIP\033[0m no native peer available (headless session)"
<< std::endl;
}
std::cout << "\n" << (failures == 0 ? "\033[32;1mAll checks passed.\033[0m"
: juce::String (failures) + " check(s) failed.")
<< "\n\n";
return failures == 0 ? 0 : 1;
}