feat(prune): R3 guarded deletion — confirm-to-delete + panel button
Extend BANK_PRUNE_FOLDER from report-only to dry-run→confirm-with-manifest→ delete exactly the pure-core orphan set (fresh recompute, stale entries skipped). Windows routes to Recycle Bin (SHFileOperation+FOF_ALLOWUNDO), else unlink. New pure prune_button module + footer button dispatching the action id. No ext-state write, no undo point (deletion is not REAPER-undoable).
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
// Standalone tests for reasampler::prune_button — no REAPER, no test framework.
|
||||
// Same fast loop as the sibling pure tests (mode_switch / tab_strip): assert the
|
||||
// footer prune-button placement math and hit-testing directly.
|
||||
//
|
||||
// Covers (R3 brief §button pure module): right-anchored layout in a wide footer;
|
||||
// vertical inset; SUPPRESSION (empty rect) when the footer is too narrow to clear the
|
||||
// tail-label inset or is degenerate; hit-test in/out/edge (half-open bounds); a
|
||||
// suppressed/empty button claims no point; draw and hit-test agree over the whole rect.
|
||||
|
||||
#include "../src/prune_button.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
using namespace reasampler;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
// --- Layout: wide footer, right-anchored -------------------------------------
|
||||
|
||||
// Footer 400 wide at origin (0, 100), height 26. Default spec: buttonWidth 72,
|
||||
// rightInset 84, verticalInset 4, minLeftInset 120. Right edge = 0+400-84 = 316,
|
||||
// left = 316-72 = 244 (>= 0+120, so placed). Top = 100+4 = 104, height = 26-8 = 18.
|
||||
static void testWideFooterRightAnchored() {
|
||||
FooterRect f{0, 100, 400, 26};
|
||||
const ButtonRect b = computePruneButton(f, PruneButtonSpec{});
|
||||
CHECK(!b.empty());
|
||||
CHECK((b == ButtonRect{244, 104, 72, 18}));
|
||||
// Right edge sits at the rightInset from the footer's right.
|
||||
CHECK(b.x + b.width == f.x + f.width - 84);
|
||||
// Left edge clears the reserved tail-label inset.
|
||||
CHECK(b.x >= f.x + 120);
|
||||
}
|
||||
|
||||
// Origin offset is honoured (button anchors to THIS footer's right, not 0).
|
||||
static void testOffsetFooterAnchors() {
|
||||
FooterRect f{10, 200, 400, 26};
|
||||
const ButtonRect b = computePruneButton(f, PruneButtonSpec{});
|
||||
CHECK(!b.empty());
|
||||
CHECK(b.x + b.width == f.x + f.width - 84); // = 10+400-84 = 326
|
||||
CHECK(b.x == 254);
|
||||
}
|
||||
|
||||
// --- Suppression: too narrow / degenerate ------------------------------------
|
||||
|
||||
// A footer just wide enough that the button's left edge would fall past the
|
||||
// minLeftInset is suppressed (empty). left = x + width - rightInset - buttonWidth.
|
||||
// Need left < x + minLeftInset -> width < rightInset + buttonWidth + minLeftInset
|
||||
// = 84 + 72 + 120 = 276. Width 275 suppresses; 276 places (boundary).
|
||||
static void testNarrowFooterSuppressed() {
|
||||
CHECK(computePruneButton(FooterRect{0, 0, 275, 26}, PruneButtonSpec{}).empty());
|
||||
CHECK(!computePruneButton(FooterRect{0, 0, 276, 26}, PruneButtonSpec{}).empty());
|
||||
}
|
||||
|
||||
static void testDegenerateFooterSuppressed() {
|
||||
CHECK(computePruneButton(FooterRect{0, 0, 0, 26}, PruneButtonSpec{}).empty()); // no width
|
||||
CHECK(computePruneButton(FooterRect{0, 0, 400, 0}, PruneButtonSpec{}).empty()); // no height
|
||||
PruneButtonSpec zero{}; zero.buttonWidth = 0;
|
||||
CHECK(computePruneButton(FooterRect{0, 0, 400, 26}, zero).empty()); // zero button
|
||||
}
|
||||
|
||||
// A very thin footer (height <= 2*verticalInset) still places a button but clamps its
|
||||
// height to the footer's own, rather than yielding a negative height.
|
||||
static void testThinFooterClampsHeight() {
|
||||
FooterRect f{0, 0, 400, 6}; // 6 <= 2*4, so height would be negative -> clamp
|
||||
const ButtonRect b = computePruneButton(f, PruneButtonSpec{});
|
||||
CHECK(!b.empty());
|
||||
CHECK(b.y == f.y);
|
||||
CHECK(b.height == f.height);
|
||||
}
|
||||
|
||||
// --- Hit-test ----------------------------------------------------------------
|
||||
|
||||
static void testHitTestInside() {
|
||||
FooterRect f{0, 100, 400, 26};
|
||||
const ButtonRect b = computePruneButton(f, PruneButtonSpec{}); // {244,104,72,18}
|
||||
CHECK(hitTestPruneButton(b.x, b.y, b)); // top-left corner (inclusive)
|
||||
CHECK(hitTestPruneButton(b.x + b.width - 1, b.y + b.height - 1, b)); // bottom-right inclusive
|
||||
CHECK(hitTestPruneButton(b.x + b.width / 2, b.y + b.height / 2, b)); // centre
|
||||
}
|
||||
|
||||
// Half-open bounds: the far edges (x+width, y+height) are EXCLUDED, matching the draw.
|
||||
static void testHitTestEdgesExcluded() {
|
||||
FooterRect f{0, 100, 400, 26};
|
||||
const ButtonRect b = computePruneButton(f, PruneButtonSpec{});
|
||||
CHECK(!hitTestPruneButton(b.x - 1, b.y, b)); // just left
|
||||
CHECK(!hitTestPruneButton(b.x + b.width, b.y, b)); // right edge excluded
|
||||
CHECK(!hitTestPruneButton(b.x, b.y - 1, b)); // just above
|
||||
CHECK(!hitTestPruneButton(b.x, b.y + b.height, b)); // bottom edge excluded
|
||||
}
|
||||
|
||||
// A suppressed (empty) button never claims a point — a click in the footer where the
|
||||
// button would have been falls through to the tail cycle, never a phantom prune.
|
||||
static void testEmptyButtonClaimsNothing() {
|
||||
ButtonRect empty{};
|
||||
CHECK(!hitTestPruneButton(0, 0, empty));
|
||||
CHECK(!hitTestPruneButton(5, 5, empty));
|
||||
// A "no button" from a narrow footer also claims nothing at any point.
|
||||
const ButtonRect suppressed = computePruneButton(FooterRect{0, 0, 200, 26}, PruneButtonSpec{});
|
||||
CHECK(suppressed.empty());
|
||||
CHECK(!hitTestPruneButton(150, 13, suppressed));
|
||||
}
|
||||
|
||||
// Draw/hit-test agreement: every point inside the computed rect hit-tests true, and the
|
||||
// four immediate outside neighbours hit-test false (the load-bearing consistency).
|
||||
static void testHitTestMatchesLayout() {
|
||||
FooterRect f{3, 7, 377, 22}; // awkward origin/size
|
||||
const ButtonRect b = computePruneButton(f, PruneButtonSpec{});
|
||||
CHECK(!b.empty());
|
||||
for (int py = b.y; py < b.y + b.height; ++py)
|
||||
for (int px = b.x; px < b.x + b.width; ++px)
|
||||
CHECK(hitTestPruneButton(px, py, b));
|
||||
CHECK(!hitTestPruneButton(b.x - 1, b.y, b));
|
||||
CHECK(!hitTestPruneButton(b.x + b.width, b.y, b));
|
||||
}
|
||||
|
||||
int main() {
|
||||
testWideFooterRightAnchored();
|
||||
testOffsetFooterAnchors();
|
||||
testNarrowFooterSuppressed();
|
||||
testDegenerateFooterSuppressed();
|
||||
testThinFooterClampsHeight();
|
||||
testHitTestInside();
|
||||
testHitTestEdgesExcluded();
|
||||
testEmptyButtonClaimsNothing();
|
||||
testHitTestMatchesLayout();
|
||||
|
||||
if (g_fail == 0) std::printf("prune_button: all tests passed\n");
|
||||
else std::printf("prune_button: %d CHECK(s) FAILED\n", g_fail);
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -324,6 +324,65 @@ static void testReportEmptyOrphanSet() {
|
||||
CHECK(!r.truncated);
|
||||
}
|
||||
|
||||
// --- pruneDeletePlan (R3 staleness guard) ------------------------------------
|
||||
//
|
||||
// plan = confirmed ∩ freshOrphans, in confirmed order. Guards BOTH directions so
|
||||
// "what was shown is what is deleted" holds after a recompute at delete time.
|
||||
|
||||
// No change between confirm and delete: the plan is the confirmed set exactly, in order.
|
||||
static void testDeletePlanStableEqualsConfirmed() {
|
||||
const std::vector<std::string> confirmed{"b/a.wav", "b/b.wav", "b/c.wav"};
|
||||
const std::vector<std::string> fresh{"b/a.wav", "b/b.wav", "b/c.wav"};
|
||||
const std::vector<std::string> plan = pruneDeletePlan(confirmed, fresh);
|
||||
CHECK((plan == confirmed)); // exact set AND order
|
||||
}
|
||||
|
||||
// A confirmed file that VANISHED (gone from fresh present -> not a fresh orphan) is a
|
||||
// skip: it drops out of the plan. Would FAIL if the plan ignored freshOrphans.
|
||||
static void testDeletePlanSkipsVanishedFile() {
|
||||
const std::vector<std::string> confirmed{"b/a.wav", "b/gone.wav", "b/c.wav"};
|
||||
const std::vector<std::string> fresh{"b/a.wav", "b/c.wav"}; // gone.wav disappeared
|
||||
const std::vector<std::string> plan = pruneDeletePlan(confirmed, fresh);
|
||||
CHECK((plan == std::vector<std::string>{"b/a.wav", "b/c.wav"}));
|
||||
CHECK(!contains(plan, "b/gone.wav"));
|
||||
}
|
||||
|
||||
// A confirmed file that became REFERENCED between confirm and delete drops OUT of the
|
||||
// fresh orphan set (pruneOrphans excludes it), so the plan skips it — never deletes a
|
||||
// now-referenced file. Modelled here as its absence from `fresh`. Load-bearing safety.
|
||||
static void testDeletePlanSkipsNowReferencedFile() {
|
||||
const std::vector<std::string> confirmed{"b/x.wav", "b/y.wav"};
|
||||
const std::vector<std::string> fresh{"b/x.wav"}; // y.wav now referenced -> not a fresh orphan
|
||||
const std::vector<std::string> plan = pruneDeletePlan(confirmed, fresh);
|
||||
CHECK((plan == std::vector<std::string>{"b/x.wav"}));
|
||||
}
|
||||
|
||||
// A NEWLY-APPEARED orphan (in fresh, NOT in confirmed) is NEVER swept: it was not shown,
|
||||
// so it must not be deleted without its own confirm. Would FAIL if plan = fresh.
|
||||
static void testDeletePlanNeverSweepsUnconfirmed() {
|
||||
const std::vector<std::string> confirmed{"b/a.wav"};
|
||||
const std::vector<std::string> fresh{"b/a.wav", "b/new_orphan.wav"};
|
||||
const std::vector<std::string> plan = pruneDeletePlan(confirmed, fresh);
|
||||
CHECK((plan == std::vector<std::string>{"b/a.wav"}));
|
||||
CHECK(!contains(plan, "b/new_orphan.wav"));
|
||||
}
|
||||
|
||||
// Empty inputs: an empty confirmed (nothing shown) -> empty plan regardless of fresh; an
|
||||
// empty fresh (everything went stale) -> empty plan (all skipped).
|
||||
static void testDeletePlanEmptyInputs() {
|
||||
CHECK(pruneDeletePlan({}, {"b/a.wav"}).empty());
|
||||
CHECK(pruneDeletePlan({"b/a.wav"}, {}).empty());
|
||||
CHECK(pruneDeletePlan({}, {}).empty());
|
||||
}
|
||||
|
||||
// Duplicate spellings in confirmed are de-duplicated in the plan (mirror of pruneOrphans).
|
||||
static void testDeletePlanDeduplicatesConfirmed() {
|
||||
const std::vector<std::string> confirmed{"b/a.wav", "b/a.wav", "b/b.wav"};
|
||||
const std::vector<std::string> fresh{"b/a.wav", "b/b.wav"};
|
||||
const std::vector<std::string> plan = pruneDeletePlan(confirmed, fresh);
|
||||
CHECK((plan == std::vector<std::string>{"b/a.wav", "b/b.wav"}));
|
||||
}
|
||||
|
||||
int main() {
|
||||
testNullTestAllReferenced();
|
||||
testFormulaMixedPopulations();
|
||||
@@ -346,6 +405,12 @@ int main() {
|
||||
testReportTruncatesListButKeepsExactCountAndSize();
|
||||
testReportUncappedWhenCapZero();
|
||||
testReportEmptyOrphanSet();
|
||||
testDeletePlanStableEqualsConfirmed();
|
||||
testDeletePlanSkipsVanishedFile();
|
||||
testDeletePlanSkipsNowReferencedFile();
|
||||
testDeletePlanNeverSweepsUnconfirmed();
|
||||
testDeletePlanEmptyInputs();
|
||||
testDeletePlanDeduplicatesConfirmed();
|
||||
|
||||
if (g_fail == 0) std::printf("prune_reconcile_tests: ALL PASS\n");
|
||||
else std::printf("prune_reconcile_tests: %d FAILURE(S)\n", g_fail);
|
||||
|
||||
Reference in New Issue
Block a user