feat(actions): item-level Design/Arrange move + untag actions; W3-A polish

Add three MIDI-bindable item actions mirroring the track tag family, routed
through the shared hookcommand: retag selected items' membership then re-drive
mint/apply so each lands on its mode's managed lane (manual-lane items exempt),
all in one undo block. Extract shared item_read seam, simplify applyMintPlan's
lane-count pass, and guard reconcile against unregistered-mode lanes.
This commit is contained in:
2026-07-25 13:30:07 -04:00
parent 22012861c5
commit 261efe5452
9 changed files with 372 additions and 54 deletions
+105
View File
@@ -1074,6 +1074,109 @@ static void testAutoTagDecision() {
}
}
// -- D2 W3-B item-level mode-move decision -----------------------------------
//
// planItemRetag: the pure decision behind the three item actions. A non-empty target
// tags each eligible selected item into it; an EMPTY target untags (→ Arrange default).
// Manual-lane items are EXEMPT (no op) and empty-GUID items are skipped.
// Find the single op for `guid`, or nullptr.
static const ItemRetagOp* retagOpFor(const std::vector<ItemRetagOp>& ops,
const std::string& guid) {
for (const auto& o : ops)
if (o.guid == guid) return &o;
return nullptr;
}
static void testPlanItemRetag() {
// Move -> Design: a managed-lane / normal item is tagged into Design (untag=false).
{
std::vector<RetagItem> sel{
RetagItem{"{A}", /*onManualLane=*/false},
RetagItem{"{B}", false},
};
auto ops = planItemRetag(sel, kDesignModeId);
CHECK(ops.size() == 2);
const ItemRetagOp* a = retagOpFor(ops, "{A}");
CHECK(a != nullptr);
CHECK(a && !a->untag); // a tag, not an untag
CHECK(a && a->modeId == kDesignModeId); // into Design specifically
const ItemRetagOp* b = retagOpFor(ops, "{B}");
CHECK(b && !b->untag && b->modeId == kDesignModeId);
}
// Empty target ⇒ UNTAG each item (Move -> Arrange / Untag items collapse to this).
// untag must be true and modeId empty — NOT a tag into "arrange".
{
std::vector<RetagItem> sel{ RetagItem{"{A}", false} };
auto ops = planItemRetag(sel, std::string{});
CHECK(ops.size() == 1);
const ItemRetagOp* a = retagOpFor(ops, "{A}");
CHECK(a != nullptr);
CHECK(a && a->untag); // an untag
CHECK(a && a->modeId.empty()); // no target mode carried on an untag
}
// Manual-lane exemption: a manual-lane item yields NO op — not for Move nor for Untag.
{
std::vector<RetagItem> sel{
RetagItem{"{NORMAL}", false},
RetagItem{"{MANUAL}", true}, // on a hand-managed lane ⇒ EXEMPT
};
auto design = planItemRetag(sel, kDesignModeId);
CHECK(design.size() == 1);
CHECK(retagOpFor(design, "{NORMAL}") != nullptr);
CHECK(retagOpFor(design, "{MANUAL}") == nullptr); // exempt — never retagged
auto untag = planItemRetag(sel, std::string{});
CHECK(untag.size() == 1);
CHECK(retagOpFor(untag, "{NORMAL}") != nullptr);
CHECK(retagOpFor(untag, "{MANUAL}") == nullptr); // exempt — never untagged
}
// Empty-GUID items are skipped defensively; empty selection ⇒ no ops.
{
std::vector<RetagItem> sel{ RetagItem{"", false}, RetagItem{"{A}", false} };
auto ops = planItemRetag(sel, kDesignModeId);
CHECK(ops.size() == 1);
CHECK(retagOpFor(ops, "{A}") != nullptr);
CHECK(planItemRetag({}, kDesignModeId).empty());
CHECK(planItemRetag({}, std::string{}).empty());
}
}
// -- D2 W3-B reconcile unregistered-mode guard (pure decision) ---------------
//
// reconcileManagedLanes (shell) recovers a lane's managed ownership from its durable
// name, but must NOT record ownership for a mode the registry no longer knows — a lane
// keyed to an unregistered mode can never become the active mode's lane and would stay
// silenced+hidden forever, orphaning its items. The guard's pure decision is exactly
// modeIdFromLaneName(name) ∈ modes(): this locks that composition so the shell's guard
// (which calls model.modes().contains(*mode)) cannot silently drift.
static void testReconcileUnregisteredModeGuardDecision() {
ViewModeModel vm; // seeds Arrange + Design only
// A managed lane naming a REGISTERED mode: mode decodes and IS contained ⇒ record.
{
const std::string name = laneNameForMode(kDesignModeId);
auto mode = modeIdFromLaneName(name);
CHECK(mode.has_value());
CHECK(vm.modes().contains(*mode)); // guard passes ⇒ shell records ownership
}
// A managed lane naming an UNREGISTERED mode: mode decodes but is NOT contained ⇒
// the guard rejects it and the shell leaves the lane off the index (manual-by-default).
{
const std::string name = laneNameForMode("removed_mode");
auto mode = modeIdFromLaneName(name);
CHECK(mode.has_value());
CHECK(*mode == "removed_mode");
CHECK(!vm.modes().contains(*mode)); // guard fails ⇒ shell must skip
}
}
// -- D2.7 Lane minting decision (Wave 3) -------------------------------------
//
// planLaneMinting: a track with content of only ONE mode is NOT split (D1 unchanged);
@@ -1519,6 +1622,8 @@ int main() {
testLaneOwnershipLastWriterWins();
testManagedOnlyPlannerAndQuery();
testAutoTagDecision();
testPlanItemRetag();
testReconcileUnregisteredModeGuardDecision();
testLaneMintingSingleModeNoSplit();
testLaneMintingMultiModeMintsAndAssignsAll();
testLaneMintingManualLaneExempt();