resolve keyboard shortcuts through one action table instead of a switch

The controls panel needs to say what each key does right now, and a panel that
keeps its own list of that is a list that goes stale. So the list moves into
lib/core/ControlAction.h: which actions exist, what each is bound to, and when
each does something. InputMapper stops deciding that and switches on the
resolved action instead, so the panel and the key handling cannot disagree
about what Q means -- there is only one place that says.

The table declares; it never performs. It holds no simulation access, fires no
events, and names nothing: display strings live in the ui target, which formats
the bindings this hands it, so a badge is rendered from the real binding rather
than typed beside it. What an action *does* stays exactly where it was.

ControlContext is the snapshot the rules read, which is what keeps this
testable without a world. Two of its facts come from BlueprintLibrary, which is
built after the world view and so arrives by setter; one comes from the new
hovered-transfer flag on BuildModeController, resolved once on mouse-move
through the same classifier the click and the ghost colour already use.

Behaviour is unchanged, deliberately. Ctrl still separates the chords and every
other modifier is still ignored, so Shift+A pans as before; matching modifiers
exactly would have silently swallowed those presses. Build hotkeys and F3/F4
stay outside the table -- the first are advertised on the build buttons and
already derive their badges from the handler's own table, the second are
development controls the panel must never offer.

The tests are the point of putting this in lib: every row's bindings must
resolve back to that row's action in that same context, which fails the moment
a shown row and its handler part ways.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
This commit is contained in:
2026-08-07 17:20:44 +02:00
parent 02f2314588
commit 77bbd58d02
14 changed files with 953 additions and 88 deletions

View File

@@ -16,6 +16,7 @@ add_files(
WorldCameraTest.cpp
SelectionControllerTest.cpp
BuildModeControllerTest.cpp
ControlActionTest.cpp
BuildingTest.cpp
BuildingConfigTest.cpp
ShipTest.cpp

View File

@@ -0,0 +1,328 @@
#include "catch.hpp"
#include <algorithm>
#include <vector>
#include "BuildModeController.h"
#include "BuildingType.h"
#include "ControlAction.h"
// REQ-UI-CONTROLS-ACCURACY. The panel and the input handling read one table, and these
// tests are what makes that pay: the round-trip case below fails the moment a row is
// shown whose binding resolves elsewhere, which is the drift the whole design exists to
// prevent. Everything here works on action ids -- the display strings live in the ui
// target and are not what can silently go wrong.
namespace
{
ControlContext generalContext()
{
return ControlContext();
}
ControlContext selectionContext(bool placeable = true)
{
ControlContext context;
context.selection = ControlSelection::Buildings;
context.selectionCount = 3;
context.placeableBuildingSelected = placeable;
return context;
}
ControlContext builderContext(BuildingType type)
{
ControlContext context;
context.mode = BuildMode::Builder;
context.builderType = type;
return context;
}
ControlContext blueprintContext(bool transfer = false)
{
ControlContext context;
context.mode = BuildMode::Blueprint;
context.hoveredGhostIsTransfer = transfer;
return context;
}
ControlContext deconstructContext()
{
ControlContext context;
context.mode = BuildMode::Deconstruct;
return context;
}
// A spread wide enough that every availability rule and every binding condition is
// exercised by the whole-table properties below.
std::vector<ControlContext> allContexts()
{
ControlContext beltDragging = builderContext(BuildingType::Belt);
beltDragging.draggingBelt = true;
ControlContext generalWithBlueprint = generalContext();
generalWithBlueprint.temporaryBlueprintExists = true;
ControlContext fieldSelection = selectionContext(false);
fieldSelection.selection = ControlSelection::FieldObjects;
return {generalContext(),
generalWithBlueprint,
selectionContext(),
fieldSelection,
builderContext(BuildingType::Belt),
builderContext(BuildingType::Assembler),
beltDragging,
blueprintContext(false),
blueprintContext(true),
deconstructContext()};
}
std::vector<ControlAction> shownActions(const ControlContext& context)
{
std::vector<ControlAction> actions = getContextActions(context);
const std::vector<ControlAction> always = getAlwaysAvailableActions(context);
actions.insert(actions.end(), always.begin(), always.end());
return actions;
}
bool contains(const std::vector<ControlAction>& actions, ControlAction action)
{
return std::find(actions.begin(), actions.end(), action) != actions.end();
}
} // namespace
TEST_CASE("ControlAction: every shown row is available", "[controls]")
{
for (const ControlContext& context : allContexts())
{
for (ControlAction action : shownActions(context))
{
REQUIRE(isControlActionAvailable(action, context));
}
}
}
TEST_CASE("ControlAction: no row is shown twice", "[controls]")
{
for (const ControlContext& context : allContexts())
{
std::vector<ControlAction> actions = shownActions(context);
std::vector<ControlAction> unique = actions;
std::sort(unique.begin(), unique.end());
unique.erase(std::unique(unique.begin(), unique.end()), unique.end());
REQUIRE(unique.size() == actions.size());
}
}
// The core anti-drift property: a row's badges are rendered from its bindings, so every
// binding a row advertises must actually trigger that row's action in that same context.
TEST_CASE("ControlAction: every advertised binding resolves back to its own action",
"[controls]")
{
for (const ControlContext& context : allContexts())
{
for (ControlAction action : shownActions(context))
{
const std::vector<ControlBinding> bindings =
getControlActionBindings(action, context);
REQUIRE_FALSE(bindings.empty());
for (const ControlBinding& binding : bindings)
{
if (binding.isMouse)
{
REQUIRE(resolveMouseAction(binding.mouse, context) == action);
}
else
{
REQUIRE(resolveKeyAction(binding.key, binding.modifiers, context)
== action);
}
}
}
}
}
// The other direction: an input that resolves to something must resolve to an action
// that is actually available there, so no input can trigger a no-op. This is weaker
// than "must be a shown row" on purpose -- a context may omit an available binding
// (Ctrl+click in the General context), which REQ-UI-CONTROLS-ACCURACY permits; what it
// may never do is act on an unavailable one.
TEST_CASE("ControlAction: every resolvable input is available", "[controls]")
{
const std::vector<MouseBinding> mouseBindings = {
MouseBinding::LeftClick, MouseBinding::LeftDrag, MouseBinding::CtrlLeftClick,
MouseBinding::CtrlLeftDrag, MouseBinding::RightClick};
for (const ControlContext& context : allContexts())
{
for (MouseBinding binding : mouseBindings)
{
const ControlAction action = resolveMouseAction(binding, context);
if (action != ControlAction::None)
{
REQUIRE(isControlActionAvailable(action, context));
}
}
const std::vector<int> keys = {Qt::Key_A, Qt::Key_D, Qt::Key_W, Qt::Key_S,
Qt::Key_Space, Qt::Key_C, Qt::Key_V, Qt::Key_R,
Qt::Key_Q, Qt::Key_Escape};
for (int key : keys)
{
for (Qt::KeyboardModifiers modifiers :
{Qt::KeyboardModifiers(Qt::NoModifier),
Qt::KeyboardModifiers(Qt::ShiftModifier),
Qt::KeyboardModifiers(Qt::ControlModifier)})
{
const ControlAction action = resolveKeyAction(key, modifiers, context);
if (action != ControlAction::None)
{
REQUIRE(isControlActionAvailable(action, context));
}
}
}
}
}
TEST_CASE("ControlAction: contexts are named by mode and selection", "[controls]")
{
REQUIRE(getControlContextKind(generalContext()) == ControlContextKind::General);
REQUIRE(getControlContextKind(selectionContext()) == ControlContextKind::Selection);
REQUIRE(getControlContextKind(builderContext(BuildingType::Belt))
== ControlContextKind::Build);
REQUIRE(getControlContextKind(blueprintContext()) == ControlContextKind::Blueprint);
REQUIRE(getControlContextKind(deconstructContext())
== ControlContextKind::Deconstruct);
}
TEST_CASE("ControlAction: Q enters deconstruct mode, or leaves the active one",
"[controls]")
{
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, generalContext())
== ControlAction::EnterDeconstruct);
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, selectionContext())
== ControlAction::EnterDeconstruct);
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, builderContext(BuildingType::Belt))
== ControlAction::ExitMode);
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, blueprintContext())
== ControlAction::ExitMode);
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, deconstructContext())
== ControlAction::ExitMode);
}
TEST_CASE("ControlAction: Ctrl distinguishes the chords, other modifiers do not",
"[controls]")
{
const ControlContext context = selectionContext();
REQUIRE(resolveKeyAction(Qt::Key_C, Qt::NoModifier, context)
== ControlAction::CopyTemporary);
REQUIRE(resolveKeyAction(Qt::Key_C, Qt::ControlModifier, context)
== ControlAction::CreateBlueprint);
REQUIRE(resolveKeyAction(Qt::Key_V, Qt::ControlModifier, context)
== ControlAction::OpenBlueprints);
// Shift+A must still pan, as it always has -- matching modifiers exactly instead of
// testing Ctrl alone would silently swallow the press.
REQUIRE(resolveKeyAction(Qt::Key_A, Qt::ShiftModifier, context) == ControlAction::Move);
REQUIRE(resolveKeyAction(Qt::Key_R, Qt::ShiftModifier, blueprintContext())
== ControlAction::Rotate);
}
TEST_CASE("ControlAction: a transfer target turns the click into Apply settings",
"[controls]")
{
const ControlContext plain = blueprintContext(false);
const ControlContext transfer = blueprintContext(true);
REQUIRE(resolveMouseAction(MouseBinding::LeftClick, plain) == ControlAction::Place);
REQUIRE(resolveMouseAction(MouseBinding::LeftClick, transfer)
== ControlAction::ApplySettings);
REQUIRE(contains(getContextActions(plain), ControlAction::Place));
REQUIRE_FALSE(contains(getContextActions(plain), ControlAction::ApplySettings));
REQUIRE(contains(getContextActions(transfer), ControlAction::ApplySettings));
REQUIRE_FALSE(contains(getContextActions(transfer), ControlAction::Place));
}
TEST_CASE("ControlAction: a belt drag takes the right mouse button from ExitMode",
"[controls]")
{
ControlContext dragging = builderContext(BuildingType::Belt);
dragging.draggingBelt = true;
const ControlContext idle = builderContext(BuildingType::Belt);
REQUIRE(resolveMouseAction(MouseBinding::RightClick, idle) == ControlAction::ExitMode);
REQUIRE(resolveMouseAction(MouseBinding::RightClick, dragging)
== ControlAction::CancelBeltLine);
// Q still leaves the mode outright while the drag runs, so ExitMode stays shown --
// with its right-click badge dropped, since the button now means something else.
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, dragging) == ControlAction::ExitMode);
const std::vector<ControlBinding> idleBindings =
getControlActionBindings(ControlAction::ExitMode, idle);
const std::vector<ControlBinding> dragBindings =
getControlActionBindings(ControlAction::ExitMode, dragging);
REQUIRE(idleBindings.size() == 2);
REQUIRE(dragBindings.size() == 1);
REQUIRE_FALSE(dragBindings.front().isMouse);
}
TEST_CASE("ControlAction: dragging a line is offered for belts only", "[controls]")
{
REQUIRE(resolveMouseAction(MouseBinding::LeftDrag, builderContext(BuildingType::Belt))
== ControlAction::PlaceBeltLine);
REQUIRE(resolveMouseAction(MouseBinding::LeftDrag,
builderContext(BuildingType::Assembler))
== ControlAction::None);
REQUIRE(resolveMouseAction(MouseBinding::LeftDrag, blueprintContext())
== ControlAction::None);
}
TEST_CASE("ControlAction: blueprint keys are offered only where they do something",
"[controls]")
{
// V does nothing until C has captured something (REQ-UI-BLUEPRINT-TEMP).
ControlContext withBlueprint = generalContext();
withBlueprint.temporaryBlueprintExists = true;
REQUIRE_FALSE(contains(shownActions(generalContext()), ControlAction::PasteTemporary));
REQUIRE(contains(shownActions(withBlueprint), ControlAction::PasteTemporary));
REQUIRE(resolveKeyAction(Qt::Key_V, Qt::NoModifier, generalContext())
== ControlAction::None);
// C and Ctrl+C need a selection holding something placeable (REQ-UI-HOTKEYS).
ControlContext fieldSelection = selectionContext(false);
fieldSelection.selection = ControlSelection::FieldObjects;
REQUIRE(contains(getContextActions(selectionContext()), ControlAction::CopyTemporary));
REQUIRE_FALSE(contains(getContextActions(fieldSelection), ControlAction::CopyTemporary));
REQUIRE(resolveKeyAction(Qt::Key_C, Qt::NoModifier, fieldSelection)
== ControlAction::None);
// Ctrl+V opens the dialog whatever is going on (REQ-UI-HOTKEYS).
for (const ControlContext& context : allContexts())
{
REQUIRE(resolveKeyAction(Qt::Key_V, Qt::ControlModifier, context)
== ControlAction::OpenBlueprints);
}
}
TEST_CASE("ControlAction: selection rows appear only once something is selected",
"[controls]")
{
const std::vector<ControlAction> general = getContextActions(generalContext());
const std::vector<ControlAction> selection = getContextActions(selectionContext());
REQUIRE(contains(general, ControlAction::Select));
REQUIRE(contains(general, ControlAction::EnterDeconstruct));
REQUIRE_FALSE(contains(general, ControlAction::AddToSelection));
REQUIRE_FALSE(contains(general, ControlAction::CreateBlueprint));
REQUIRE(contains(selection, ControlAction::AddToSelection));
REQUIRE(contains(selection, ControlAction::AddAreaToSelection));
REQUIRE(contains(selection, ControlAction::CreateBlueprint));
REQUIRE(contains(selection, ControlAction::EnterDeconstruct));
}