// 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}, 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 #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) // --- Fixtures ----------------------------------------------------------------- static const PanelClientRect kPanel{0, 0, 400, 300}; // 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; } // 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, }; // 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, 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 = 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); } // 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 = 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); } // --- 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 keep their existing outcome. 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 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 ------------------------------------------------------- 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); } int main() { testInsideClientIsAlwaysInternal(); testBoundaryHalfOpen(); testOffsetPanelRect(); testSingleOverTrackPanelIsInstrumentDrop(); testSingleOverFxSurfaceIsInstrumentDrop(); testSingleOverFxEmbedRefuses(); testSingleOverArrangeIsArrangeInsert(); testSingleOverOtherReaperUiRefuses(); testSingleOffReaperIsOsHandoff(); testMultiOverInstrumentSurfacesRefuses(); testMultiOverArrangeIsArrangeInsert(); testMultiOffReaperIsOsHandoff(); testMultiOverOtherReaperUiRefuses(); testNullTrackWithSurfaceRefuses(); testOffReaperIgnoresTrackFlag(); testNoDragOrNoSamplesIsNone(); testClassTransitionsAreReversible(); testUnresolvableEvaluationDoesNotAffectTheNext(); testDragSpeedCannotChangeTheOutcome(); testNoLiveOutsideCombinationResolvesToNone(); testEveryClassHasACue(); 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; }