Merge Ψ-W1-T4: resolve drop targets per move, not once
# Conflicts: # src/shell/actions/CLAUDE.md
This commit is contained in:
+270
-111
@@ -1,16 +1,18 @@
|
||||
// Standalone tests for reasampler::drag_out — no REAPER, no test framework. Same fast loop
|
||||
// as the sibling pure tests (mode_switch et al.): assert the gesture-
|
||||
// boundary decision and the path-list assembly directly.
|
||||
// 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 (M11 drag-out brief §test cases):
|
||||
// * Gesture boundary: inside-panel drag stays Internal; leaving the client area with
|
||||
// armed samples -> OsDrag; no armed samples (or not dragging) -> None; half-open edge
|
||||
// behavior; re-entry back inside returns to Internal (position-only decision).
|
||||
// * Path-list assembly: single, multi, dedupe (cross-bank copy case), skip-missing,
|
||||
// skip-unresolved, empty selection, order preservation, mixed tallies.
|
||||
// Covers:
|
||||
// * The full class matrix: every ReaperSurface x {single, multi} x {track, no track}, inside
|
||||
// and outside the client rect, plus the half-open edge and a non-zero panel origin.
|
||||
// * 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>
|
||||
@@ -22,115 +24,261 @@ static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
// --- Gesture boundary ---------------------------------------------------------
|
||||
// --- Fixtures -----------------------------------------------------------------
|
||||
|
||||
static const PanelClientRect kPanel{0, 0, 400, 300};
|
||||
|
||||
// A drag with samples, pointer well inside the client rect -> the existing internal drag
|
||||
// (invariant #4: inside-panel drag stays internal, unchanged).
|
||||
static void testInsidePanelStaysInternal() {
|
||||
DragState s{/*dragging=*/true, /*hasArmedSamples=*/true};
|
||||
CHECK(decideGesture(200, 150, kPanel, s) == DragGesture::Internal);
|
||||
CHECK(decideGesture(0, 0, kPanel, s) == DragGesture::Internal); // top-left corner
|
||||
CHECK(decideGesture(399, 299, kPanel, s) == DragGesture::Internal); // last inside px
|
||||
// A live single-card drag over `surface`, 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;
|
||||
}
|
||||
|
||||
// A drag with samples whose pointer has left the client rect (any edge) -> OS drag.
|
||||
static void testLeavingClientAreaIsOsDrag() {
|
||||
DragState s{/*dragging=*/true, /*hasArmedSamples=*/true};
|
||||
CHECK(decideGesture(-1, 150, kPanel, s) == DragGesture::OsDrag); // left of panel
|
||||
CHECK(decideGesture(400, 150, kPanel, s) == DragGesture::OsDrag); // right edge (x+w)
|
||||
CHECK(decideGesture(200, -5, kPanel, s) == DragGesture::OsDrag); // above
|
||||
CHECK(decideGesture(200, 300, kPanel, s) == DragGesture::OsDrag); // below (y+h)
|
||||
CHECK(decideGesture(1000, 1000, kPanel, s) == DragGesture::OsDrag);// far outside
|
||||
// Every surface the law enumerates, so the matrix tests iterate rather than list.
|
||||
static const ReaperSurface kAllSurfaces[] = {
|
||||
ReaperSurface::OffReaper, ReaperSurface::TrackPanel, ReaperSurface::FxSurface,
|
||||
ReaperSurface::FxEmbed, ReaperSurface::Arrange, ReaperSurface::Other,
|
||||
};
|
||||
|
||||
// Pins kAllSurfaces against ReaperSurface::Count so a 7th 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The half-open boundary: x+width and y+height are OUTSIDE (OsDrag), the pixel just inside
|
||||
// is Internal — matches the panel's other hit-tests so the edge is claimed consistently.
|
||||
// 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() {
|
||||
DragState s{true, true};
|
||||
CHECK(decideGesture(399, 150, kPanel, s) == DragGesture::Internal);
|
||||
CHECK(decideGesture(400, 150, kPanel, s) == DragGesture::OsDrag);
|
||||
CHECK(decideGesture(200, 299, kPanel, s) == DragGesture::Internal);
|
||||
CHECK(decideGesture(200, 300, kPanel, s) == DragGesture::OsDrag);
|
||||
const DropContext off = ctx(ReaperSurface::OffReaper);
|
||||
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);
|
||||
}
|
||||
|
||||
// No armed samples -> None regardless of position (an empty-payload drag never goes to the
|
||||
// OS). Not dragging -> None even with samples (the shell asks only mid-drag, but the guard
|
||||
// is explicit).
|
||||
static void testNoDragOrNoSamplesIsNone() {
|
||||
CHECK(decideGesture(1000, 1000, kPanel, DragState{true, false}) == DragGesture::None);
|
||||
CHECK(decideGesture(200, 150, kPanel, DragState{true, false}) == DragGesture::None);
|
||||
CHECK(decideGesture(1000, 1000, kPanel, DragState{false, true}) == DragGesture::None);
|
||||
CHECK(decideGesture(200, 150, kPanel, DragState{false, false}) == DragGesture::None);
|
||||
}
|
||||
|
||||
// Re-entry: the decision is position-only, so a pointer that left (OsDrag) and came back
|
||||
// inside reads Internal again. (The shell, having handed off to the modal OS loop, simply
|
||||
// stops asking — but the pure function must not be stateful.)
|
||||
static void testReentryReturnsInternal() {
|
||||
DragState s{true, true};
|
||||
CHECK(decideGesture(500, 150, kPanel, s) == DragGesture::OsDrag); // left
|
||||
CHECK(decideGesture(200, 150, kPanel, s) == DragGesture::Internal); // re-entered
|
||||
}
|
||||
|
||||
// A non-zero panel origin (the client rect need not sit at 0,0) — the boundary tracks the
|
||||
// rect, not the absolute axes.
|
||||
// A non-zero panel origin — the boundary tracks the rect, not the absolute axes.
|
||||
static void testOffsetPanelRect() {
|
||||
PanelClientRect p{50, 20, 100, 80}; // spans x[50,150) y[20,100)
|
||||
DragState s{true, true};
|
||||
CHECK(decideGesture(100, 60, p, s) == DragGesture::Internal);
|
||||
CHECK(decideGesture(49, 60, p, s) == DragGesture::OsDrag); // just left of origin
|
||||
CHECK(decideGesture(150, 60, p, s) == DragGesture::OsDrag); // x+width
|
||||
CHECK(decideGesture(100, 19, p, s) == DragGesture::OsDrag); // just above origin
|
||||
const PanelClientRect p{50, 20, 100, 80}; // spans x[50,150) y[20,100)
|
||||
const DropContext off = ctx(ReaperSurface::OffReaper);
|
||||
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);
|
||||
}
|
||||
|
||||
// --- S17 InstrumentDrop gesture (single-capture over REAPER UI) ---------------
|
||||
//
|
||||
// M11 REGRESSION GUARD (load-bearing): every M11 case above uses DragState{true, true},
|
||||
// which leaves singleCapture=overReaperUi=false — so an M11-era payload outside the client
|
||||
// rect still decides OsDrag exactly as before. The tests above ARE the M11 non-regression
|
||||
// proof; these add the new middle case.
|
||||
// --- Matrix: outside the client, single card -----------------------------------
|
||||
|
||||
// A SINGLE-capture drag that has left the panel but is still over REAPER's own UI is an
|
||||
// instrument drop (heading for a track's FX button), NOT an OS drag.
|
||||
static void testSingleCaptureOverReaperUiIsInstrumentDrop() {
|
||||
DragState s{/*dragging=*/true, /*hasArmedSamples=*/true,
|
||||
/*singleCapture=*/true, /*overReaperUi=*/true};
|
||||
CHECK(decideGesture(500, 150, kPanel, s) == DragGesture::InstrumentDrop); // right of panel
|
||||
CHECK(decideGesture(-5, 150, kPanel, s) == DragGesture::InstrumentDrop); // left of panel
|
||||
CHECK(decideGesture(200, 400, kPanel, s) == DragGesture::InstrumentDrop); // below
|
||||
// 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);
|
||||
}
|
||||
|
||||
// InstrumentDrop is an OUTSIDE-only refinement: the same single-capture state INSIDE the
|
||||
// client rect is still the unchanged Internal bank-to-bank drag (invariant #4).
|
||||
static void testSingleCaptureInsidePanelStaysInternal() {
|
||||
DragState s{true, true, /*singleCapture=*/true, /*overReaperUi=*/true};
|
||||
CHECK(decideGesture(200, 150, kPanel, s) == DragGesture::Internal);
|
||||
// The FX chain / floating-FX windows keep their existing outcome.
|
||||
static void testSingleOverFxSurfaceIsInstrumentDrop() {
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::FxSurface)) ==
|
||||
DropClass::InstrumentDrop);
|
||||
}
|
||||
|
||||
// A single-capture drag that has left REAPER ENTIRELY (overReaperUi=false) falls through to
|
||||
// OsDrag — the M11 OS drag-out to Explorer/another DAW, unchanged. This is the boundary
|
||||
// refinement's other half: leaving the client rect no longer immediately means OS-bound.
|
||||
static void testSingleCaptureOffReaperIsOsDrag() {
|
||||
DragState s{true, true, /*singleCapture=*/true, /*overReaperUi=*/false};
|
||||
CHECK(decideGesture(500, 150, kPanel, s) == DragGesture::OsDrag);
|
||||
// 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);
|
||||
}
|
||||
|
||||
// A MULTI-capture drag over REAPER's UI is REJECTED for InstrumentDrop (the S17 open-question
|
||||
// lean): it is NOT a single instrument placement, so it falls through to OsDrag even while
|
||||
// over REAPER's UI — the multi-file drag-out is the natural gesture for a multi payload.
|
||||
static void testMultiCaptureOverReaperUiIsOsDrag() {
|
||||
DragState s{true, true, /*singleCapture=*/false, /*overReaperUi=*/true};
|
||||
CHECK(decideGesture(500, 150, kPanel, s) == DragGesture::OsDrag);
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Not-dragging / no-armed-samples still short-circuits to None regardless of the S17 fields.
|
||||
static void testS17FieldsIgnoredWhenNotDragging() {
|
||||
CHECK(decideGesture(500, 150, kPanel,
|
||||
DragState{false, true, true, true}) == DragGesture::None);
|
||||
CHECK(decideGesture(500, 150, kPanel,
|
||||
DragState{true, false, true, true}) == DragGesture::None);
|
||||
// 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 testSingleOffReaperIsOsHandoff() {
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::OffReaper)) ==
|
||||
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 testMultiOffReaperIsOsHandoff() {
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel, ctx(ReaperSurface::OffReaper, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A track under the pointer never turns OffReaper into a REAPER-internal outcome: OffReaper is
|
||||
// the shell's "the info string was empty and there was no track" verdict, and the law trusts it.
|
||||
static void testOffReaperIgnoresTrackFlag() {
|
||||
CHECK(decideDropClass(kOutX, kOutY, kPanel,
|
||||
ctx(ReaperSurface::OffReaper, true, false)) == DropClass::OsHandoff);
|
||||
}
|
||||
|
||||
// --- Not-a-drag ----------------------------------------------------------------
|
||||
|
||||
// No armed samples, or not dragging -> None regardless of position or surface.
|
||||
static void testNoDragOrNoSamplesIsNone() {
|
||||
for (ReaperSurface s : kAllSurfaces) {
|
||||
DropContext c = 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);
|
||||
}
|
||||
|
||||
// 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.
|
||||
for (ReaperSurface s : kAllSurfaces) {
|
||||
(void)decideDropClass(kOutX - 10, kOutY, kPanel, ctx(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}) {
|
||||
const DropClass c = decideDropClass(kOutX, kOutY, kPanel, ctx(s, single, haveTrack));
|
||||
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 -------------------------------------------------------
|
||||
@@ -158,7 +306,7 @@ static void testMultiPreservesOrder() {
|
||||
}
|
||||
|
||||
// Two index entries resolving to the SAME file (the cross-bank copy case — one file, two
|
||||
// entries) yield ONE CF_HDROP path; the extra is counted, first occurrence wins.
|
||||
// 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);
|
||||
@@ -167,8 +315,7 @@ static void testDedupeSamePath() {
|
||||
CHECK(l.skippedDuplicate == 1);
|
||||
}
|
||||
|
||||
// A stale index entry (file gone from disk) is skipped — never a dangling path on the OS
|
||||
// clipboard.
|
||||
// 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);
|
||||
@@ -227,9 +374,8 @@ static void testMixedTallies() {
|
||||
// (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. The user has to press and start an entirely new drag; retrying
|
||||
// the SAME stale selection resolves to the same empty payload and fails identically. These pin
|
||||
// the fix: the two side effects (teardown, hand-off) are one decision.
|
||||
// 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() {
|
||||
@@ -254,18 +400,31 @@ static void testAllSkippedSelectionKeepsInternalDrag() {
|
||||
}
|
||||
|
||||
int main() {
|
||||
testInsidePanelStaysInternal();
|
||||
testLeavingClientAreaIsOsDrag();
|
||||
testInsideClientIsAlwaysInternal();
|
||||
testBoundaryHalfOpen();
|
||||
testNoDragOrNoSamplesIsNone();
|
||||
testReentryReturnsInternal();
|
||||
testOffsetPanelRect();
|
||||
|
||||
testSingleCaptureOverReaperUiIsInstrumentDrop();
|
||||
testSingleCaptureInsidePanelStaysInternal();
|
||||
testSingleCaptureOffReaperIsOsDrag();
|
||||
testMultiCaptureOverReaperUiIsOsDrag();
|
||||
testS17FieldsIgnoredWhenNotDragging();
|
||||
testSingleOverTrackPanelIsInstrumentDrop();
|
||||
testSingleOverFxSurfaceIsInstrumentDrop();
|
||||
testSingleOverFxEmbedRefuses();
|
||||
testSingleOverArrangeIsArrangeInsert();
|
||||
testSingleOverOtherReaperUiRefuses();
|
||||
testSingleOffReaperIsOsHandoff();
|
||||
|
||||
testMultiOverInstrumentSurfacesRefuses();
|
||||
testMultiOverArrangeIsArrangeInsert();
|
||||
testMultiOffReaperIsOsHandoff();
|
||||
testMultiOverOtherReaperUiRefuses();
|
||||
|
||||
testNullTrackWithSurfaceRefuses();
|
||||
testOffReaperIgnoresTrackFlag();
|
||||
testNoDragOrNoSamplesIsNone();
|
||||
|
||||
testClassTransitionsAreReversible();
|
||||
testUnresolvableEvaluationDoesNotAffectTheNext();
|
||||
testDragSpeedCannotChangeTheOutcome();
|
||||
testNoLiveOutsideCombinationResolvesToNone();
|
||||
testEveryClassHasACue();
|
||||
|
||||
testSinglePath();
|
||||
testMultiPreservesOrder();
|
||||
|
||||
@@ -177,68 +177,98 @@ static void testBadClassIdRejected() {
|
||||
CHECK(!buildVstPresetBytes(std::string(32, 'A'), state).empty());
|
||||
}
|
||||
|
||||
// --- FX-hotspot classification (S-VIEW-BUG-1 / S-GA-DropFX) -------------------
|
||||
// --- Surface classification ---------------------------------------------------
|
||||
//
|
||||
// THE RULE (prefix-based — see instrument_drop.h): "fx_*" names the FX-chain / floating-FX
|
||||
// windows; "tcp.fx*" / "mcp.fx*" name the TCP/MCP FX button and its sibling FX sub-elements.
|
||||
// The SDK warns GetThingFromPoint "may append additional information", so exact-token
|
||||
// matching (the previous, DAW-falsified predicate) is wrong; the prefix family is the
|
||||
// documented-adjacent surface. Bare "tcp"/"mcp" and non-FX sub-elements are NOT hotspots.
|
||||
// THE RULE (prefix-based — see instrument_drop.h): the SDK warns GetThingFromPoint "may append
|
||||
// additional information", so exact-token matching (a previous, DAW-falsified predicate) is
|
||||
// wrong. Ordering is load-bearing: the embed strip is matched BEFORE the track panel, and the
|
||||
// track panel now claims the WHOLE "tcp*"/"mcp*" family rather than just its FX sub-elements.
|
||||
|
||||
// The TCP/MCP FX-button family arms an instrument drop — including sibling FX sub-elements
|
||||
// and tokens with appended information.
|
||||
static void testTcpMcpFxFamilyIsHotspot() {
|
||||
CHECK(infoNamesFxHotspot("tcp.fx")); // TCP FX button (WALTER element name)
|
||||
CHECK(infoNamesFxHotspot("mcp.fx")); // MCP FX button
|
||||
CHECK(infoNamesFxHotspot("tcp.fxbyp")); // FX bypass — sibling FX element
|
||||
CHECK(infoNamesFxHotspot("tcp.fxparm")); // FX param knob area — sibling FX element
|
||||
CHECK(infoNamesFxHotspot("mcp.fxlist")); // MCP FX insert list
|
||||
CHECK(infoNamesFxHotspot("tcp.fx.1")); // appended info (SDK: "may append...")
|
||||
CHECK(infoNamesFxHotspot("tcp.fx extra")); // appended info, arbitrary form
|
||||
using ui::ReaperSurface;
|
||||
|
||||
static ReaperSurface onTrack(const std::string& info) {
|
||||
return classifyReaperSurface(info, /*haveTrack=*/true);
|
||||
}
|
||||
|
||||
// The FX chain / floating-FX windows (the surfaces the ORIGINAL predicate matched) still
|
||||
// classify as hotspots — the fix does not regress the "fx_" surface.
|
||||
static void testFxWindowStillHotspot() {
|
||||
CHECK(infoNamesFxHotspot("fx_chain")); // FX chain window
|
||||
CHECK(infoNamesFxHotspot("fx_0")); // first FX, floating
|
||||
CHECK(infoNamesFxHotspot("fx_12")); // arbitrary floating-FX index
|
||||
// The FX chain / floating-FX windows.
|
||||
static void testFxWindowIsFxSurface() {
|
||||
CHECK(onTrack("fx_chain") == ReaperSurface::FxSurface);
|
||||
CHECK(onTrack("fx_0") == ReaperSurface::FxSurface);
|
||||
CHECK(onTrack("fx_12") == ReaperSurface::FxSurface);
|
||||
}
|
||||
|
||||
// Non-FX surfaces are NOT hotspots — a drop here is not an instrument drop (it would fall
|
||||
// through to the OS drag / no-op). This includes the bare TCP/MCP tokens and all non-FX
|
||||
// "tcp.*"/"mcp.*" sub-elements (e.g. mute button, volume fader, track name, meter).
|
||||
static void testNonFxSurfacesAreNotHotspot() {
|
||||
CHECK(!infoNamesFxHotspot("tcp")); // bare track control panel — NOT an FX hotspot
|
||||
CHECK(!infoNamesFxHotspot("mcp")); // bare mixer control panel — NOT an FX hotspot
|
||||
CHECK(!infoNamesFxHotspot("tcp.mute")); // mute button — track panel, not FX
|
||||
CHECK(!infoNamesFxHotspot("tcp.vol")); // volume fader — track panel, not FX
|
||||
CHECK(!infoNamesFxHotspot("tcp.f")); // truncated non-FX token — prefix must be whole
|
||||
CHECK(!infoNamesFxHotspot("arrange"));
|
||||
CHECK(!infoNamesFxHotspot("spacer_0"));
|
||||
CHECK(!infoNamesFxHotspot("")); // pointer over nothing REAPER classifies
|
||||
CHECK(!infoNamesFxHotspot("trans")); // transport
|
||||
CHECK(!infoNamesFxHotspot("envcp")); // envelope control panel — a track thing, not FX
|
||||
// The FX-button family within the TCP/MCP — the surface the glyph-only rule used to be limited
|
||||
// to, still an instrument surface (as TrackPanel, which resolves identically).
|
||||
static void testTcpMcpFxFamilyIsTrackPanel() {
|
||||
CHECK(onTrack("tcp.fx") == ReaperSurface::TrackPanel);
|
||||
CHECK(onTrack("mcp.fx") == ReaperSurface::TrackPanel);
|
||||
CHECK(onTrack("tcp.fxbyp") == ReaperSurface::TrackPanel);
|
||||
CHECK(onTrack("tcp.fxparm") == ReaperSurface::TrackPanel);
|
||||
CHECK(onTrack("mcp.fxlist") == ReaperSurface::TrackPanel);
|
||||
CHECK(onTrack("tcp.fx.1") == ReaperSurface::TrackPanel); // appended info
|
||||
CHECK(onTrack("tcp.fx extra") == ReaperSurface::TrackPanel); // appended info, arbitrary form
|
||||
}
|
||||
|
||||
// The embed-strip sub-element is NOT a hotspot. "tcp.fxembed" / "mcp.fxembed" is the surface
|
||||
// where a ReaSampler 9000 instance draws inline in the TCP/MCP via IReaperUIEmbedInterface.
|
||||
// Dropping a card there must NOT add a SECOND instance on top of the existing embed — the
|
||||
// drop should be ignored (no instrument drop), even though the token starts with "tcp.fx".
|
||||
// This documents the explicit exclusion in infoNamesFxHotspot and would catch a regression if
|
||||
// the exclude guard were accidentally removed.
|
||||
static void testEmbedStripIsNotHotspot() {
|
||||
CHECK(!infoNamesFxHotspot("tcp.fxembed")); // TCP embed strip — existing instance's surface
|
||||
CHECK(!infoNamesFxHotspot("mcp.fxembed")); // MCP embed strip — existing instance's surface
|
||||
// With hypothetically appended info (SDK "may append") — still excluded.
|
||||
CHECK(!infoNamesFxHotspot("tcp.fxembed.1"));
|
||||
CHECK(!infoNamesFxHotspot("mcp.fxembed extra"));
|
||||
// THE ROOT-CAUSE FIX: the bare panel token and every non-FX sub-element are now the instrument
|
||||
// hotspot too. A TCP too narrow to draw the FX button reports "tcp", which under the old
|
||||
// glyph-only rule produced a cue-less no-op.
|
||||
static void testWholeTrackPanelIsTheHotspot() {
|
||||
CHECK(onTrack("tcp") == ReaperSurface::TrackPanel); // bare track control panel
|
||||
CHECK(onTrack("mcp") == ReaperSurface::TrackPanel); // bare mixer control panel
|
||||
CHECK(onTrack("tcp.mute") == ReaperSurface::TrackPanel); // mute button
|
||||
CHECK(onTrack("tcp.vol") == ReaperSurface::TrackPanel); // volume fader
|
||||
CHECK(onTrack("tcp.meter") == ReaperSurface::TrackPanel); // meter
|
||||
CHECK(onTrack("tcp.f") == ReaperSurface::TrackPanel); // truncated token — still the panel
|
||||
}
|
||||
|
||||
// The embed strip is where a ReaSampler 9000 instance already draws inline via
|
||||
// IReaperUIEmbedInterface. It must NOT resolve to a hotspot — a drop there would stack a second
|
||||
// instance on the first. Matched before the "tcp"/"mcp" rule, so widening the panel hotspot
|
||||
// cannot swallow it; do not reorder these two checks in the classifier.
|
||||
static void testEmbedStripIsItsOwnSurface() {
|
||||
CHECK(onTrack("tcp.fxembed") == ReaperSurface::FxEmbed);
|
||||
CHECK(onTrack("mcp.fxembed") == ReaperSurface::FxEmbed);
|
||||
CHECK(onTrack("tcp.fxembed.1") == ReaperSurface::FxEmbed);
|
||||
CHECK(onTrack("mcp.fxembed extra") == ReaperSurface::FxEmbed);
|
||||
}
|
||||
|
||||
// The arrange, including a token with appended information.
|
||||
static void testArrangeIsArrange() {
|
||||
CHECK(onTrack("arrange") == ReaperSurface::Arrange);
|
||||
CHECK(onTrack("arrange extra") == ReaperSurface::Arrange);
|
||||
}
|
||||
|
||||
// Anything else REAPER names is Other — a defined refusal, never a guessed outcome. Includes
|
||||
// tokens REAPER may add in future versions.
|
||||
static void testUnnamedReaperSurfacesAreOther() {
|
||||
CHECK(onTrack("spacer_0") == ReaperSurface::Other);
|
||||
CHECK(onTrack("trans") == ReaperSurface::Other);
|
||||
CHECK(onTrack("envcp") == ReaperSurface::Other);
|
||||
CHECK(onTrack("ruler") == ReaperSurface::Other);
|
||||
CHECK(onTrack("something_reaper_adds_in_2030") == ReaperSurface::Other);
|
||||
}
|
||||
|
||||
// The empty info string splits on whether a track came back with it. No track means the pointer
|
||||
// has left REAPER (the OS hand-off's trigger); a track with no info means we are over REAPER on
|
||||
// a surface we cannot name, which must refuse rather than be treated as off-REAPER.
|
||||
static void testEmptyInfoSplitsOnTrackPresence() {
|
||||
CHECK(classifyReaperSurface("", /*haveTrack=*/false) == ReaperSurface::OffReaper);
|
||||
CHECK(classifyReaperSurface("", /*haveTrack=*/true) == ReaperSurface::Other);
|
||||
}
|
||||
|
||||
// The SDK's documented null-track-with-valid-info case: the surface is read from the string
|
||||
// alone, so the classifier reports it faithfully and the gesture law decides what a missing
|
||||
// track means for that surface.
|
||||
static void testNullTrackStillClassifiesTheSurface() {
|
||||
CHECK(classifyReaperSurface("arrange", false) == ReaperSurface::Arrange);
|
||||
CHECK(classifyReaperSurface("tcp", false) == ReaperSurface::TrackPanel);
|
||||
CHECK(classifyReaperSurface("fx_chain", false) == ReaperSurface::FxSurface);
|
||||
}
|
||||
|
||||
// Do not reintroduce a per-surface "capture carries" loop test: buildInstrumentDropPreset takes
|
||||
// only sampleId (proven by testPresetRoundTripsThroughInstrumentReader), and per-surface hotspot
|
||||
// coverage already exists (testTcpMcpFxFamilyIsHotspot / testFxWindowStillHotspot) — a loop with
|
||||
// an identical body per surface string can't distinguish them.
|
||||
// only sampleId (proven by testPresetRoundTripsThroughInstrumentReader), and per-surface
|
||||
// coverage already exists above — a loop with an identical body per surface string can't
|
||||
// distinguish them.
|
||||
|
||||
// --- All-or-nothing rollback --------------------------------------------------
|
||||
|
||||
@@ -288,10 +318,14 @@ int main() {
|
||||
testDeterministic();
|
||||
testBadClassIdRejected();
|
||||
|
||||
testTcpMcpFxFamilyIsHotspot();
|
||||
testFxWindowStillHotspot();
|
||||
testNonFxSurfacesAreNotHotspot();
|
||||
testEmbedStripIsNotHotspot();
|
||||
testFxWindowIsFxSurface();
|
||||
testTcpMcpFxFamilyIsTrackPanel();
|
||||
testWholeTrackPanelIsTheHotspot();
|
||||
testEmbedStripIsItsOwnSurface();
|
||||
testArrangeIsArrange();
|
||||
testUnnamedReaperSurfacesAreOther();
|
||||
testEmptyInfoSplitsOnTrackPresence();
|
||||
testNullTrackStillClassifiesTheSurface();
|
||||
|
||||
testAddFailureLeavesNothingToRollBack();
|
||||
testPresetFailureRollsBackTheCreatedIndex();
|
||||
|
||||
Reference in New Issue
Block a user