Files
reasampler/tests/test_drag_out.cpp
T
daniel fe1aec5925 Hand a card dragged onto a plugin's own editor to the OS as a file drop; withdraw the OLE drag when the pointer returns to REAPER
A code-ownership proof (window class procedure's module, via the pure window_origin) plus REAPER's FX-surface token routes a plugin editor to OLE; FX chrome keeps the instrument drop. The OLE loop re-asks the law each turn, so passing a plugin editor stays reversible.
2026-09-25 13:39:39 -04:00

671 lines
32 KiB
C++

// Standalone tests for reasampler::ui::drag_out — no REAPER, no test framework. Same fast loop
// as the sibling pure tests: assert the gesture law and the path-list assembly directly.
//
// Covers:
// * The full class matrix: every ReaperSurface x {single, multi} x {track, no track} x
// {over a host window, off it} x {plugin code under the pointer, not}, inside and outside the
// client rect, plus the half-open edge and a non-zero panel origin.
// * The OS drag loop's per-turn verdict (decideOsDragTurn), exhaustively.
// * Reversibility and speed-independence, stated as properties: a class transition sequence
// resolves the same forwards and backwards, and one unresolvable evaluation cannot change
// any later evaluation's outcome.
// * Exhaustiveness: no surface resolves to a do-nothing class, and every class has a cue.
// * Path-list assembly + OS hand-off ordering (unchanged from before this law).
#include "../src/core/ui/drag_out.h"
#include <cstddef>
#include <cstdio>
#include <string>
#include <vector>
using namespace reasampler;
using namespace reasampler::ui;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
// --- Fixtures -----------------------------------------------------------------
static const PanelClientRect kPanel{0, 0, 400, 300};
// A live single-card drag over `surface`, still inside REAPER, with a track resolved unless
// stated otherwise.
static DropContext ctx(ReaperSurface surface, bool single = true, bool haveTrack = true) {
DropContext c;
c.drag = DragState{/*dragging=*/true, /*hasArmedSamples=*/true};
c.singlePayload = single;
c.surface = surface;
c.haveTrack = haveTrack;
return c;
}
// The same drag with the shell's positive off-REAPER proof set. `surface` is whatever the SDK
// hit-test reported at that point — it still matters: the hand-off needs REAPER to have named
// nothing too, so the defaults here (Other, no track) are the only combination that hands off.
static DropContext offHost(ReaperSurface surface = ReaperSurface::Other, bool single = true,
bool haveTrack = false) {
DropContext c = ctx(surface, single, haveTrack);
c.pointerOffHost = true;
return c;
}
// The same drag with the shell's plugin-code proof set: the window under the pointer is a plugin's
// own editor. Defaults to where it matters — an FX surface REAPER attributed to a track.
static DropContext overPlugin(ReaperSurface surface = ReaperSurface::FxSurface, bool single = true,
bool haveTrack = true, bool offHostToo = false) {
DropContext c = ctx(surface, single, haveTrack);
c.pointerOverPluginEditor = true;
c.pointerOffHost = offHostToo;
return c;
}
// Every surface the law enumerates, so the matrix tests iterate rather than list.
static const ReaperSurface kAllSurfaces[] = {
ReaperSurface::TrackPanel, ReaperSurface::FxSurface, ReaperSurface::FxEmbed,
ReaperSurface::Arrange, ReaperSurface::Other,
};
// Pins kAllSurfaces against ReaperSurface::Count so a 6th surface added to the enum without a
// matching entry here fails the BUILD, not just a silently-incomplete matrix — the compiler
// alone does not enforce this (no -Wswitch/-Wall or /W4 anywhere in the build; see
// panel_drag.cpp's onLBtnUp for the same caveat on DropClass).
static_assert(sizeof(kAllSurfaces) / sizeof(kAllSurfaces[0]) ==
static_cast<std::size_t>(ReaperSurface::Count),
"kAllSurfaces must list exactly the surfaces below ReaperSurface::Count");
// A point comfortably outside the panel client rect.
static const int kOutX = 500, kOutY = 150;
// --- Matrix: inside the client -------------------------------------------------
// Inside the client the drag is the bank-to-bank drag, whatever REAPER reports underneath and
// whatever the payload size — the internal path must never be reinterpreted as an FX add or a
// timeline insert. This is the bank-to-bank regression floor.
static void testInsideClientIsAlwaysInternal() {
for (ReaperSurface s : kAllSurfaces) {
for (bool single : {true, false}) {
CHECK(decideDropClass(200, 150, kPanel, ctx(s, single)) == DropClass::Internal);
CHECK(decideDropClass(0, 0, kPanel, ctx(s, single)) == DropClass::Internal);
CHECK(decideDropClass(399, 299, kPanel, ctx(s, single)) == DropClass::Internal);
// Even a stale proof of either kind cannot reinterpret a point inside our own client
// rect: the inside-client answer is resolved ahead of both hand-off gates, so
// re-entering the panel always resumes the bank-to-bank gesture.
CHECK(decideDropClass(200, 150, kPanel, offHost(s, single)) == DropClass::Internal);
CHECK(decideDropClass(200, 150, kPanel, overPlugin(s, single)) == DropClass::Internal);
}
}
}
// The half-open boundary: x+width and y+height are OUTSIDE, the pixel just inside is Internal —
// matches the panel's other hit-tests so the edge is claimed consistently.
static void testBoundaryHalfOpen() {
const DropContext off = offHost();
CHECK(decideDropClass(399, 150, kPanel, off) == DropClass::Internal);
CHECK(decideDropClass(400, 150, kPanel, off) == DropClass::OsHandoff);
CHECK(decideDropClass(200, 299, kPanel, off) == DropClass::Internal);
CHECK(decideDropClass(200, 300, kPanel, off) == DropClass::OsHandoff);
}
// A non-zero panel origin — the boundary tracks the rect, not the absolute axes.
static void testOffsetPanelRect() {
const PanelClientRect p{50, 20, 100, 80}; // spans x[50,150) y[20,100)
const DropContext off = offHost();
CHECK(decideDropClass(100, 60, p, off) == DropClass::Internal);
CHECK(decideDropClass(49, 60, p, off) == DropClass::OsHandoff);
CHECK(decideDropClass(150, 60, p, off) == DropClass::OsHandoff);
CHECK(decideDropClass(100, 19, p, off) == DropClass::OsHandoff);
}
// --- Matrix: outside the client, single card -----------------------------------
// A single card over a track's panel loads the instrument — the WHOLE panel, which is the
// root-cause fix: a TCP too narrow to draw the FX button used to yield a cue-less no-op.
static void testSingleOverTrackPanelIsInstrumentDrop() {
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::TrackPanel)) ==
DropClass::InstrumentDrop);
CHECK(decideDropClass(-5, kOutY, kPanel, ctx(ReaperSurface::TrackPanel)) ==
DropClass::InstrumentDrop);
CHECK(decideDropClass(200, 400, kPanel, ctx(ReaperSurface::TrackPanel)) ==
DropClass::InstrumentDrop);
}
// The FX chain / floating-FX windows' own chrome — REAPER's preset bar, the chain list — keeps its
// existing outcome: no plugin-code proof, so the instrument drop.
static void testSingleOverFxSurfaceIsInstrumentDrop() {
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::FxSurface)) ==
DropClass::InstrumentDrop);
}
// The embed strip is where an instance ALREADY draws: dropping there must not stack a second,
// so it refuses rather than instantiating.
static void testSingleOverFxEmbedRefuses() {
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::FxEmbed)) ==
DropClass::Refuse);
}
// THE HEADLINE DEFECT: a single card over the arrange places a timeline item. It used to lock
// to InstrumentDrop on the first move outside the client and then release into nothing.
static void testSingleOverArrangeIsArrangeInsert() {
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::Arrange)) ==
DropClass::ArrangeInsert);
}
// Ruler / transport / docker chrome / any token REAPER adds later: a defined refusal.
static void testSingleOverOtherReaperUiRefuses() {
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::Other)) == DropClass::Refuse);
}
// Off REAPER entirely -> the OS drag-out, the one irreversible transition.
static void testSingleOffHostIsOsHandoff() {
CHECK(decideDropClass(kOutX, kOutY, kPanel, offHost()) == DropClass::OsHandoff);
}
// --- Matrix: outside the client, multi card ------------------------------------
// A multi payload names no single instrument, so both instrument surfaces refuse — a DEFINED
// outcome with a cue, where the old law silently took the OS path from these surfaces.
static void testMultiOverInstrumentSurfacesRefuses() {
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::TrackPanel, false)) ==
DropClass::Refuse);
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::FxSurface, false)) ==
DropClass::Refuse);
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::FxEmbed, false)) ==
DropClass::Refuse);
}
// Multi over the arrange still places — one item per capture; the shell lays them out.
static void testMultiOverArrangeIsArrangeInsert() {
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::Arrange, false)) ==
DropClass::ArrangeInsert);
}
// Multi off REAPER is the classic multi-file drag-out, unchanged.
static void testMultiOffHostIsOsHandoff() {
CHECK(decideDropClass(kOutX, kOutY, kPanel, offHost(ReaperSurface::Other, false)) ==
DropClass::OsHandoff);
}
static void testMultiOverOtherReaperUiRefuses() {
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::Other, false)) ==
DropClass::Refuse);
}
// --- The documented null-track / non-empty-info case ---------------------------
// GetThingFromPoint "may return NULL with valid info string to indicate non-track thing". Both
// outcomes that need a track therefore refuse instead of dereferencing nothing: over the arrange
// that is the empty region below the last track, and over a track panel it is a surface we
// cannot attribute.
static void testNullTrackWithSurfaceRefuses() {
for (ReaperSurface s : {ReaperSurface::TrackPanel, ReaperSurface::FxSurface,
ReaperSurface::Arrange}) {
for (bool single : {true, false}) {
CHECK(decideDropClass(kOutX, kOutY, kPanel,
ctx(s, single, /*haveTrack=*/false)) == DropClass::Refuse);
}
}
}
// --- The hand-off gate ---------------------------------------------------------
// THE REGRESSION FLOOR. No reading of REAPER's hit-test can reach the modal OLE loop while the
// pointer is over a host window and no plugin code owns it (ctx() sets neither proof) — asserted
// over EVERY surface the probe can report, both payload
// sizes and both track verdicts, not just the toolbar/transport cell that was reported. The
// defect was structural (an unnamed token read as "the user left REAPER"), so the guarantee has
// to be structural too.
static void testNoInReaperCombinationCanHandOff() {
for (ReaperSurface s : kAllSurfaces) {
for (bool single : {true, false}) {
for (bool haveTrack : {true, false}) {
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(s, single, haveTrack)) !=
DropClass::OsHandoff);
}
}
}
}
// The converse, stated as the gate's exact shape: with the ownership proof set, the hand-off
// happens EXACTLY on the cells where REAPER's hit-test also named nothing — no surface, no track.
// Both directions in one loop, so neither half can be weakened without a failure: a gate that
// dropped the token condition fails on the named cells, one that over-tightened fails on Other.
static void testOffHostHandsOffOnlyWhereReaperNamedNothing() {
for (ReaperSurface s : kAllSurfaces) {
for (bool single : {true, false}) {
for (bool haveTrack : {true, false}) {
const bool namedNothing = (s == ReaperSurface::Other) && !haveTrack;
const DropClass c =
decideDropClass(kOutX, kOutY, kPanel, offHost(s, single, haveTrack));
CHECK((c == DropClass::OsHandoff) == namedNothing);
}
}
}
}
// The shipped exception the token condition exists for: REAPER runs a bridged plugin's UI in
// reaper_host*.exe, so the process test reports off-host over a bridged FX window even though the
// pointer never left REAPER. The recognised token vetoes the PROCESS route, so on its own that
// proof changes nothing over a named surface — whether the bridged editor then hands off is the
// plugin-code route's call alone (testBridgedEditorHandsOffThroughThePluginRoute).
static void testOffHostOverANamedSurfaceKeepsTheReaperOutcome() {
CHECK(decideDropClass(kOutX, kOutY, kPanel, offHost(ReaperSurface::FxSurface, true, true)) ==
DropClass::InstrumentDrop);
CHECK(decideDropClass(kOutX, kOutY, kPanel, offHost(ReaperSurface::TrackPanel, true, true)) ==
DropClass::InstrumentDrop);
CHECK(decideDropClass(kOutX, kOutY, kPanel, offHost(ReaperSurface::Arrange, true, true)) ==
DropClass::ArrangeInsert);
CHECK(decideDropClass(kOutX, kOutY, kPanel, offHost(ReaperSurface::FxEmbed, true, true)) ==
DropClass::Refuse);
// And not just for those four spot values: over every named cell the ownership proof changes
// nothing at all, which is what "a veto can only move the answer toward staying in REAPER"
// means operationally.
for (ReaperSurface s : kAllSurfaces) {
if (s == ReaperSurface::Other) continue; // the one surface the gate can hand off from
for (bool single : {true, false}) {
for (bool haveTrack : {true, false}) {
CHECK(decideDropClass(kOutX, kOutY, kPanel, offHost(s, single, haveTrack)) ==
decideDropClass(kOutX, kOutY, kPanel, ctx(s, single, haveTrack)));
}
}
}
// Same equality on the remaining named cell: Other WITH a track is a surface REAPER did
// attribute, so it refuses off-host exactly as it does on-host.
for (bool single : {true, false}) {
CHECK(decideDropClass(kOutX, kOutY, kPanel,
offHost(ReaperSurface::Other, single, true)) == DropClass::Refuse);
}
}
// --- The plugin-editor route ---------------------------------------------------
// THE FEATURE: over a plugin's own editor inside an FX window, the files go to the plugin through
// the OS — whatever the payload size and whether or not REAPER attributed a track, since the
// plugin, not a track, is the receiver.
static void testPluginEditorOverFxSurfaceHandsOff() {
for (bool single : {true, false}) {
for (bool haveTrack : {true, false}) {
CHECK(decideDropClass(kOutX, kOutY, kPanel,
overPlugin(ReaperSurface::FxSurface, single, haveTrack)) ==
DropClass::OsHandoff);
}
}
}
// The same point WITHOUT the proof is the FX window's chrome: the instrument drop for a single
// card, the multi refusal otherwise. The proof is the whole difference, in both directions.
static void testSameFxSurfaceWithoutTheProofKeepsItsOutcome() {
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::FxSurface)) ==
DropClass::InstrumentDrop);
CHECK(decideDropClass(kOutX, kOutY, kPanel, overPlugin(ReaperSurface::FxSurface)) ==
DropClass::OsHandoff);
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::FxSurface, false)) ==
DropClass::Refuse);
CHECK(decideDropClass(kOutX, kOutY, kPanel, overPlugin(ReaperSurface::FxSurface, false)) ==
DropClass::OsHandoff);
}
// Plugin code under the pointer anywhere REAPER did not name an FX surface — the TCP, the MCP, the
// embed strip, the arrange, an extension window docked in REAPER (Other) — changes nothing: the
// token confines the route to FX windows. Asserted over every such cell, on-host and off.
static void testPluginProofOffAnFxSurfaceChangesNothing() {
for (ReaperSurface s : kAllSurfaces) {
if (s == ReaperSurface::FxSurface) continue;
for (bool single : {true, false}) {
for (bool haveTrack : {true, false}) {
for (bool off : {false, true}) {
const DropContext without =
off ? offHost(s, single, haveTrack) : ctx(s, single, haveTrack);
CHECK(decideDropClass(kOutX, kOutY, kPanel,
overPlugin(s, single, haveTrack, off)) ==
decideDropClass(kOutX, kOutY, kPanel, without));
}
}
}
}
}
// A bridged editor: the process proof AND the plugin proof are both set, REAPER names the FX
// window. The token vetoed the process route; the plugin route still hands off.
static void testBridgedEditorHandsOffThroughThePluginRoute() {
for (bool haveTrack : {true, false}) {
CHECK(decideDropClass(kOutX, kOutY, kPanel,
overPlugin(ReaperSurface::FxSurface, true, haveTrack,
/*offHostToo=*/true)) == DropClass::OsHandoff);
}
}
// --- Not-a-drag ----------------------------------------------------------------
// No armed samples, or not dragging -> None regardless of position or surface.
static void testNoDragOrNoSamplesIsNone() {
for (ReaperSurface s : kAllSurfaces) {
for (int proof = 0; proof < 3; ++proof) { // none, off-host, plugin editor
DropContext c = proof == 1 ? offHost(s) : proof == 2 ? overPlugin(s) : ctx(s);
c.drag = DragState{/*dragging=*/true, /*hasArmedSamples=*/false};
CHECK(decideDropClass(kOutX, kOutY, kPanel, c) == DropClass::None);
CHECK(decideDropClass(200, 150, kPanel, c) == DropClass::None);
c.drag = DragState{/*dragging=*/false, /*hasArmedSamples=*/true};
CHECK(decideDropClass(kOutX, kOutY, kPanel, c) == DropClass::None);
CHECK(decideDropClass(200, 150, kPanel, c) == DropClass::None);
}
}
}
// --- Reversibility, speed-independence, exhaustiveness -------------------------
// The transition sequence from the acceptance criteria: client -> arrange -> FX window ->
// arrange -> client. Every step resolves on its own terms, and the return leg reproduces the
// outgoing leg exactly — the instrument drop is still available after crossing the arrange, and
// re-entering the client resumes the internal drag.
static void testClassTransitionsAreReversible() {
const DropContext arrange = ctx(ReaperSurface::Arrange);
const DropContext fx = ctx(ReaperSurface::FxSurface);
const DropContext inside = ctx(ReaperSurface::Other); // surface is ignored inside
CHECK(decideDropClass(200, 150, kPanel, inside) == DropClass::Internal);
CHECK(decideDropClass(kOutX, kOutY, kPanel, arrange) == DropClass::ArrangeInsert);
CHECK(decideDropClass(kOutX, kOutY, kPanel, fx) == DropClass::InstrumentDrop);
CHECK(decideDropClass(kOutX, kOutY, kPanel, arrange) == DropClass::ArrangeInsert);
CHECK(decideDropClass(200, 150, kPanel, inside) == DropClass::Internal);
// The hand-off legs stay stateless too: evaluating an off-host or plugin-editor context leaves
// the next in-REAPER evaluation exactly where it was — which is what lets the OS loop withdraw
// on the law's own answer (decideOsDragTurn) rather than on anything remembered.
CHECK(decideDropClass(kOutX, kOutY, kPanel, offHost()) == DropClass::OsHandoff);
CHECK(decideDropClass(kOutX, kOutY, kPanel, arrange) == DropClass::ArrangeInsert);
CHECK(decideDropClass(200, 150, kPanel, inside) == DropClass::Internal);
// Through a plugin's editor and back out over its own window's chrome: the instrument drop is
// still there after the pass, and so is everything after it.
CHECK(decideDropClass(kOutX, kOutY, kPanel, overPlugin()) == DropClass::OsHandoff);
CHECK(decideDropClass(kOutX, kOutY, kPanel, fx) == DropClass::InstrumentDrop);
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::TrackPanel)) ==
DropClass::InstrumentDrop);
CHECK(decideDropClass(kOutX, kOutY, kPanel, arrange) == DropClass::ArrangeInsert);
CHECK(decideDropClass(kOutX, kOutY, kPanel, overPlugin()) == DropClass::OsHandoff);
CHECK(decideDropClass(200, 150, kPanel, inside) == DropClass::Internal);
}
// One unresolvable evaluation (a surface with no track, which refuses) followed by a resolvable
// one: the second resolves exactly as it would have on its own. This is the property the retired
// drag-lifetime "blocked" latch violated.
static void testUnresolvableEvaluationDoesNotAffectTheNext() {
const DropContext trackless = ctx(ReaperSurface::Arrange, true, /*haveTrack=*/false);
const DropContext resolvable = ctx(ReaperSurface::Arrange);
const DropClass standalone = decideDropClass(kOutX, kOutY, kPanel, resolvable);
CHECK(decideDropClass(kOutX, kOutY, kPanel, trackless) == DropClass::Refuse);
CHECK(decideDropClass(kOutX, kOutY, kPanel, resolvable) == standalone);
CHECK(standalone == DropClass::ArrangeInsert);
// And in the other order — the refusal is equally uninfluenced by what preceded it.
CHECK(decideDropClass(kOutX, kOutY, kPanel, trackless) == DropClass::Refuse);
}
// Drag speed only changes WHICH intermediate points get evaluated. Since the class is a function
// of the current point and context alone, a "fast flick" (one evaluation at the release point)
// and a "slow drag" (many evaluations ending at the same point) agree at that point — with the
// intermediate surfaces deliberately chosen to disagree with the destination.
static void testDragSpeedCannotChangeTheOutcome() {
const DropContext destination = ctx(ReaperSurface::TrackPanel);
const DropClass flick = decideDropClass(kOutX, kOutY, kPanel, destination);
// The slow path crosses everything else first, off-host and plugin-editor legs included.
for (ReaperSurface s : kAllSurfaces) {
(void)decideDropClass(kOutX - 10, kOutY, kPanel, ctx(s));
(void)decideDropClass(kOutX - 10, kOutY, kPanel, offHost(s));
(void)decideDropClass(kOutX - 10, kOutY, kPanel, overPlugin(s));
(void)decideDropClass(200, 150, kPanel, ctx(s)); // and back through the client
}
CHECK(decideDropClass(kOutX, kOutY, kPanel, destination) == flick);
CHECK(flick == DropClass::InstrumentDrop);
}
// EXHAUSTIVENESS (acceptance criterion 7, structural rather than spot-checked): for a live drag
// outside the client, no surface x payload x track combination resolves to None — i.e. there is
// no cell whose release does nothing without having said so. None is reachable only from a dead
// drag, which is asserted separately above.
static void testNoLiveOutsideCombinationResolvesToNone() {
for (ReaperSurface s : kAllSurfaces) {
for (bool single : {true, false}) {
for (bool haveTrack : {true, false}) {
for (bool off : {false, true}) {
for (bool plugin : {false, true}) {
DropContext c0 =
off ? offHost(s, single, haveTrack) : ctx(s, single, haveTrack);
c0.pointerOverPluginEditor = plugin;
const DropClass c = decideDropClass(kOutX, kOutY, kPanel, c0);
CHECK(c != DropClass::None);
CHECK(c != DropClass::Internal);
}
}
}
}
}
}
// Every class carries a cue, and only the two the shell deliberately does not draw map to a
// no-cursor cue — so "will this work" is answerable before release for every resolvable class.
static void testEveryClassHasACue() {
CHECK(cueForDropClass(DropClass::Internal) == DropCue::Internal);
CHECK(cueForDropClass(DropClass::InstrumentDrop) == DropCue::Instrument);
CHECK(cueForDropClass(DropClass::ArrangeInsert) == DropCue::ArrangeInsert);
CHECK(cueForDropClass(DropClass::Refuse) == DropCue::Refuse);
CHECK(cueForDropClass(DropClass::OsHandoff) == DropCue::OsOwned);
CHECK(cueForDropClass(DropClass::None) == DropCue::None);
}
// --- Path-list assembly -------------------------------------------------------
static ResolvedSample ok(const std::string& p) { return ResolvedSample{p, true}; }
static ResolvedSample missing(const std::string& p) { return ResolvedSample{p, false}; }
static ResolvedSample unresolved() { return ResolvedSample{"", false}; }
static void testSinglePath() {
PathList l = assemblePathList({ok("C:/proj/bank/a.wav")});
CHECK(l.paths.size() == 1);
CHECK(l.paths[0] == "C:/proj/bank/a.wav");
CHECK(l.skippedMissing == 0);
CHECK(l.skippedUnresolved == 0);
CHECK(l.skippedDuplicate == 0);
}
// Multiple distinct paths pass through in selection order (order preserved).
static void testMultiPreservesOrder() {
PathList l = assemblePathList({ok("b.wav"), ok("a.wav"), ok("c.wav")});
CHECK(l.paths.size() == 3);
CHECK(l.paths[0] == "b.wav");
CHECK(l.paths[1] == "a.wav");
CHECK(l.paths[2] == "c.wav");
}
// Two index entries resolving to the SAME file (the cross-bank copy case — one file, two
// entries) yield ONE path; the extra is counted, first occurrence wins.
static void testDedupeSamePath() {
PathList l = assemblePathList({ok("x.wav"), ok("y.wav"), ok("x.wav")});
CHECK(l.paths.size() == 2);
CHECK(l.paths[0] == "x.wav");
CHECK(l.paths[1] == "y.wav");
CHECK(l.skippedDuplicate == 1);
}
// A stale index entry (file gone from disk) is skipped — never a dangling path handed onward.
static void testSkipMissing() {
PathList l = assemblePathList({ok("a.wav"), missing("gone.wav"), ok("b.wav")});
CHECK(l.paths.size() == 2);
CHECK(l.paths[0] == "a.wav");
CHECK(l.paths[1] == "b.wav");
CHECK(l.skippedMissing == 1);
}
// An unresolvable entry (empty path — no project dir / empty relative) is skipped; note an
// empty path is skippedUnresolved, NOT skippedMissing, even though fileExists is false.
static void testSkipUnresolved() {
PathList l = assemblePathList({ok("a.wav"), unresolved(), ok("b.wav")});
CHECK(l.paths.size() == 2);
CHECK(l.skippedUnresolved == 1);
CHECK(l.skippedMissing == 0);
}
// Empty selection -> empty list, all tallies zero (the shell reads paths.empty() and does
// not start a drag).
static void testEmptySelection() {
PathList l = assemblePathList({});
CHECK(l.paths.empty());
CHECK(l.skippedMissing == 0);
CHECK(l.skippedUnresolved == 0);
CHECK(l.skippedDuplicate == 0);
}
// All-skipped selection -> empty list with the right tallies (nothing draggable).
static void testAllSkippedYieldsEmpty() {
PathList l = assemblePathList({missing("g1.wav"), unresolved(), missing("g2.wav")});
CHECK(l.paths.empty());
CHECK(l.skippedMissing == 2);
CHECK(l.skippedUnresolved == 1);
}
// Mixed: every skip class at once, plus a survivor, with independent tallies.
static void testMixedTallies() {
PathList l = assemblePathList({
ok("keep.wav"), // survives
missing("gone.wav"), // skippedMissing
unresolved(), // skippedUnresolved
ok("keep.wav"), // skippedDuplicate
ok("also.wav"), // survives
});
CHECK(l.paths.size() == 2);
CHECK(l.paths[0] == "keep.wav");
CHECK(l.paths[1] == "also.wav");
CHECK(l.skippedMissing == 1);
CHECK(l.skippedUnresolved == 1);
CHECK(l.skippedDuplicate == 1);
}
// --- OS hand-off ordering -----------------------------------------------------
//
// The empty-payload teardown regression: the shell used to wind its internal drag down
// (release capture, clear drag state) BEFORE checking whether the payload had resolved to any
// on-disk file. For an unresolvable payload, initiateDragOut is never reached — no OS drop
// happens at all — but the teardown ran anyway, so the drag just silently stopped mid-gesture
// with no highlight and no drop. These pin the fix: the two side effects (teardown, hand-off)
// are one decision.
// Nothing draggable -> hand off nothing AND keep the internal drag alive.
static void testEmptyPathsHandsOffNothingAndKeepsInternalDrag() {
const OsHandoff h = decideOsHandoff({});
CHECK(!h.handOffToOs);
}
// A resolvable payload -> hand off (release the internal drag, then start the OS drag).
static void testResolvablePayloadHandsOff() {
const OsHandoff one = decideOsHandoff({"C:/proj/bank/a.wav"});
CHECK(one.handOffToOs);
const OsHandoff many = decideOsHandoff({"a.wav", "b.wav", "c.wav"});
CHECK(many.handOffToOs);
}
// End to end through the assembler: a selection whose every entry is stale/unresolvable
// yields the keep-the-drag verdict, which is exactly the case the shell used to mishandle.
static void testAllSkippedSelectionKeepsInternalDrag() {
const PathList l = assemblePathList({missing("g1.wav"), unresolved()});
const OsHandoff h = decideOsHandoff(l.paths);
CHECK(!h.handOffToOs);
}
// --- The OS drag loop, once handed off ---------------------------------------------
// The whole truth table, one cell per line so a regression names its cell.
static void testOsDragTurnTruthTable() {
// Button held: stay with the OS while the law still hands off, withdraw the moment it doesn't.
CHECK(decideOsDragTurn(false, true, true) == OsDragTurn::Continue);
CHECK(decideOsDragTurn(false, true, false) == OsDragTurn::Withdraw);
// Released: the OS drops only where the law hands off; over a REAPER surface the release is
// given back to the panel, so REAPER's own outcome runs instead of a CF_HDROP import.
CHECK(decideOsDragTurn(false, false, true) == OsDragTurn::Drop);
CHECK(decideOsDragTurn(false, false, false) == OsDragTurn::WithdrawAtRelease);
// Escape ends the drag outright in every cell — never read as a release, never a withdrawal
// that would resume a drag the user just cancelled.
CHECK(decideOsDragTurn(true, true, true) == OsDragTurn::Cancel);
CHECK(decideOsDragTurn(true, true, false) == OsDragTurn::Cancel);
CHECK(decideOsDragTurn(true, false, true) == OsDragTurn::Cancel);
CHECK(decideOsDragTurn(true, false, false) == OsDragTurn::Cancel);
}
// End to end with the law: a hand-off started over a plugin's editor, then the pointer moves onto
// that FX window's chrome with the button held — withdrawn, and the resumed drag's release there is
// the instrument drop, not an OS drop. Moving back onto the editor keeps the OS drag.
static void testPassOverAPluginEditorIsWithdrawnOnReaperSurfaces() {
auto handsOff = [](const DropContext& c) {
return decideDropClass(kOutX, kOutY, kPanel, c) == DropClass::OsHandoff;
};
CHECK(decideOsDragTurn(false, true, handsOff(overPlugin())) == OsDragTurn::Continue);
CHECK(decideOsDragTurn(false, true, handsOff(ctx(ReaperSurface::FxSurface))) ==
OsDragTurn::Withdraw);
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::FxSurface)) ==
DropClass::InstrumentDrop);
CHECK(decideOsDragTurn(false, false, handsOff(ctx(ReaperSurface::Arrange))) ==
OsDragTurn::WithdrawAtRelease);
CHECK(decideOsDragTurn(false, false, handsOff(overPlugin())) == OsDragTurn::Drop);
// Leaving REAPER entirely is handed off by the other route and held the same way.
CHECK(decideOsDragTurn(false, true, handsOff(offHost())) == OsDragTurn::Continue);
}
int main() {
testInsideClientIsAlwaysInternal();
testBoundaryHalfOpen();
testOffsetPanelRect();
testSingleOverTrackPanelIsInstrumentDrop();
testSingleOverFxSurfaceIsInstrumentDrop();
testSingleOverFxEmbedRefuses();
testSingleOverArrangeIsArrangeInsert();
testSingleOverOtherReaperUiRefuses();
testSingleOffHostIsOsHandoff();
testMultiOverInstrumentSurfacesRefuses();
testMultiOverArrangeIsArrangeInsert();
testMultiOffHostIsOsHandoff();
testMultiOverOtherReaperUiRefuses();
testNullTrackWithSurfaceRefuses();
testNoInReaperCombinationCanHandOff();
testOffHostHandsOffOnlyWhereReaperNamedNothing();
testOffHostOverANamedSurfaceKeepsTheReaperOutcome();
testNoDragOrNoSamplesIsNone();
testPluginEditorOverFxSurfaceHandsOff();
testSameFxSurfaceWithoutTheProofKeepsItsOutcome();
testPluginProofOffAnFxSurfaceChangesNothing();
testBridgedEditorHandsOffThroughThePluginRoute();
testClassTransitionsAreReversible();
testUnresolvableEvaluationDoesNotAffectTheNext();
testDragSpeedCannotChangeTheOutcome();
testNoLiveOutsideCombinationResolvesToNone();
testEveryClassHasACue();
testSinglePath();
testMultiPreservesOrder();
testDedupeSamePath();
testSkipMissing();
testSkipUnresolved();
testEmptySelection();
testAllSkippedYieldsEmpty();
testMixedTallies();
testEmptyPathsHandsOffNothingAndKeepsInternalDrag();
testResolvablePayloadHandsOff();
testAllSkippedSelectionKeepsInternalDrag();
testOsDragTurnTruthTable();
testPassOverAPluginEditorIsWithdrawnOnReaperSurfaces();
if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0;
}