#include "catch.hpp" #include #include #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 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 shownActions(const ControlContext& context) { std::vector actions = getContextActions(context); const std::vector always = getAlwaysAvailableActions(context); actions.insert(actions.end(), always.begin(), always.end()); return actions; } bool contains(const std::vector& 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 actions = shownActions(context); std::vector 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 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 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 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 idleBindings = getControlActionBindings(ControlAction::ExitMode, idle); const std::vector 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 general = getContextActions(generalContext()); const std::vector 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)); }