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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user