resolve keyboard shortcuts through one action table instead of a switch

This commit is contained in:
2026-08-07 18:47:29 +02:00
parent f61f0bf761
commit 1f754de431
14 changed files with 953 additions and 88 deletions

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));
}