// 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. // // 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. #include "../src/core/ui/drag_out.h" #include #include #include 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) // --- Gesture boundary --------------------------------------------------------- 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 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 } // 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. 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); } // 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. 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 } // --- 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. // 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 } // 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); } // 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); } // 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); } // 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); } // --- 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 CF_HDROP 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 on the OS // clipboard. 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. 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. // 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); } int main() { testInsidePanelStaysInternal(); testLeavingClientAreaIsOsDrag(); testBoundaryHalfOpen(); testNoDragOrNoSamplesIsNone(); testReentryReturnsInternal(); testOffsetPanelRect(); testSingleCaptureOverReaperUiIsInstrumentDrop(); testSingleCaptureInsidePanelStaysInternal(); testSingleCaptureOffReaperIsOsDrag(); testMultiCaptureOverReaperUiIsOsDrag(); testS17FieldsIgnoredWhenNotDragging(); testSinglePath(); testMultiPreservesOrder(); testDedupeSamePath(); testSkipMissing(); testSkipUnresolved(); testEmptySelection(); testAllSkippedYieldsEmpty(); testMixedTallies(); testEmptyPathsHandsOffNothingAndKeepsInternalDrag(); testResolvablePayloadHandsOff(); testAllSkippedSelectionKeepsInternalDrag(); if (g_fail == 0) std::printf("All tests passed.\n"); return g_fail ? 1 : 0; }