Files
sloposk/tests/tst_controller.cpp
T
ericek111andClaude Opus 5.5 ac0bfc2741 Keep blocks still when switching layers
- The main block reserves the width and rows of its biggest layer, so the
  numpad and function row no longer shift between letters and symbols
- Smaller layers stretch into the room: stretch keys widen, rows grow
  taller (rows above the layer, like the function row, keep their height)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:35:00 +02:00

411 lines
14 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "FakeEngine.h"
#include "input/KeyboardController.h"
#include "layout/LayoutModel.h"
#include "TestLayouts.h"
#include <QTest>
#include <xkbcommon/xkbcommon-keysyms.h>
using namespace Qt::StringLiterals;
namespace {
const TextContext Field{.field = true};
// A field with some text already: no auto-shift until a test asks for it.
const TextContext MidSentence{.field = true, .hasSurroundingText = true, .textBeforeCursor = u"so"_s};
KeyDef makeKey(KeyType type, const QString &label = {})
{
KeyDef k;
k.type = type;
k.label = label;
return k;
}
} // namespace
class TestController : public QObject
{
Q_OBJECT
private:
FakeEngine engine;
LayoutModel model;
std::unique_ptr<KeyboardController> controller;
qint64 now = 0;
void tap(const KeyDef &key, KeyOutput output = KeyOutput::Primary, const QString &alternate = {})
{
now += 1000; // far apart: never a double tap
controller->keyDown(key, now);
controller->keyUp(key, output, alternate, now + 50);
}
private Q_SLOTS:
void init()
{
engine = {};
model.setLayout(TestLayouts::build("us"));
model.setVisibleSections({MainSectionId});
controller = std::make_unique<KeyboardController>(engine, model);
controller->resetContext(MidSentence); // most tests type into a text field
}
void typesLowercase()
{
tap(makeKey(KeyType::Character, u"a"_s));
QCOMPARE(engine.output, u"a");
}
void shiftCapitalizesOneCharacter()
{
const KeyDef a = makeKey(KeyType::Character, u"a"_s);
tap(makeKey(KeyType::Shift));
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Once);
tap(a);
tap(a);
QCOMPARE(engine.output, u"Aa");
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Off);
}
void flickAndAlternateOutputs()
{
KeyDef e = makeKey(KeyType::Character, u"e"_s);
e.flick = u"3"_s;
tap(e, KeyOutput::Flick);
tap(e, KeyOutput::Alternate, u"é"_s);
QCOMPARE(engine.output, u"3é");
}
void backspaceActsOnPress()
{
controller->keyDown(makeKey(KeyType::Backspace), 0);
QCOMPARE(engine.keysyms, QList<quint32>{XKB_KEY_BackSpace});
}
void enterSendsReturn()
{
tap(makeKey(KeyType::Enter));
QCOMPARE(engine.keysyms, QList<quint32>{XKB_KEY_Return});
}
void layerSwitchChangesModelLayer()
{
KeyDef layer = makeKey(KeyType::LayerSwitch);
layer.target = u"numbers"_s;
controller->keyDown(layer, 0);
QCOMPARE(model.layerName(), u"numbers");
}
void backspaceStopsOnRelease()
{
const KeyDef backspace = makeKey(KeyType::Backspace);
controller->keyDown(backspace, 0);
controller->keyUp(backspace, KeyOutput::Primary, {}, 50);
QTest::qWait(700); // past the repeat delay
QCOMPARE(engine.keysyms.size(), 1);
}
void capitalizesAtSentenceStart()
{
controller->resetContext(Field);
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Once);
for (const char *c : {"h", "i", ".", " "})
tap(makeKey(KeyType::Character, QString::fromLatin1(c)));
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Once);
tap(makeKey(KeyType::Character, u"o"_s));
QCOMPARE(engine.output, u"Hi. O");
}
void noCapitalizationInPasswordFields()
{
TextContext context = Field;
context.purpose = TextContext::Purpose::Password;
controller->setContext(context);
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Off);
}
void fieldTextDrivesCapitalization()
{
TextContext context = Field;
context.hasSurroundingText = true;
context.textBeforeCursor = u"Done! "_s;
controller->setContext(context);
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Once);
context.textBeforeCursor = u"Done, "_s;
controller->setContext(context);
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Off);
}
void doubleSpaceTypesPeriod()
{
const KeyDef space = makeKey(KeyType::Space);
tap(makeKey(KeyType::Character, u"a"_s));
tap(space);
tap(space);
QCOMPARE(engine.output, u"a. ");
QCOMPARE(engine.mismatchedReplacements, 0);
// A Backspace keysym may reach the client after the commit and eat the new space.
QVERIFY(engine.keysyms.isEmpty());
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Once);
}
void doubleSpacePeriodCanBeTurnedOff()
{
controller->setOptions({.doubleSpacePeriod = false});
const KeyDef space = makeKey(KeyType::Space);
tap(makeKey(KeyType::Character, u"a"_s));
tap(space);
tap(space);
QCOMPARE(engine.output, u"a ");
}
void autoCapitalizationCanBeTurnedOff()
{
controller->resetContext(Field);
controller->setOptions({.autoCapitalize = false});
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Off);
tap(makeKey(KeyType::Character, u"a"_s));
QCOMPARE(engine.output, u"a");
}
void doubleTapShiftLocksAtSentenceStart()
{
controller->resetContext(Field); // a field was focused
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Once); // auto-shift
const KeyDef shift = makeKey(KeyType::Shift);
controller->keyDown(shift, 5000);
controller->keyUp(shift, KeyOutput::Primary, {}, 5050);
controller->keyDown(shift, 5200);
controller->keyUp(shift, KeyOutput::Primary, {}, 5250);
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Locked);
}
void slideTypesSymbolAndSwitchesBack()
{
KeyDef layer = makeKey(KeyType::LayerSwitch);
layer.target = u"numbers"_s;
controller->keyDown(layer, 0);
controller->slideReleased(makeKey(KeyType::Character, u"5"_s), 100);
QCOMPARE(engine.output, u"5");
QCOMPARE(model.layerName(), u"letters");
}
void trackpadMovesCursor()
{
controller->moveCursor({2, -1});
QCOMPARE(engine.keysyms, (QList<quint32>{XKB_KEY_Right, XKB_KEY_Right, XKB_KEY_Up}));
}
void globeRequestsNextLayout()
{
int requested = 0;
connect(controller.get(), &KeyboardController::nextLayoutRequested, this, [&] { ++requested; });
tap(makeKey(KeyType::Globe));
QCOMPARE(requested, 1);
}
void keysymKeyActsOnPressAndRepeats()
{
KeyDef left = makeKey(KeyType::Keysym, u"←"_s);
left.keysym = XKB_KEY_Left;
left.repeat = true;
controller->keyDown(left, 0);
QCOMPARE(engine.keysyms, QList<quint32>{XKB_KEY_Left});
QTRY_VERIFY_WITH_TIMEOUT(engine.keysyms.size() >= 3, 2000); // held: repeats
controller->keyUp(left, KeyOutput::Primary, {}, 0);
const auto sent = engine.keysyms.size();
QTest::qWait(200);
QCOMPARE(engine.keysyms.size(), sent);
QVERIFY(engine.output.isEmpty()); // no text
}
void sectionsAreComposedAroundTheMainKeys()
{
const qsizetype mainKeys = model.rowCount();
const qreal mainUnits = model.totalUnits();
model.setVisibleSections({MainSectionId, u"function"_s});
QCOMPARE(model.rowCount(), mainKeys + 13);
QCOMPARE(model.totalUnits(), mainUnits); // above, not beside
model.setVisibleSections({MainSectionId, u"navigation"_s});
QCOMPARE(model.totalUnits(), mainUnits + 3);
model.setVisibleSections({u"numpad"_s});
QCOMPARE(model.rowCount(), 19);
model.setVisibleSections({});
QCOMPARE(model.rowCount(), mainKeys); // never nothing
}
void switchingLayersKeepsTheOtherBlocksStill()
{
model.setLayout(TestLayouts::build("us", KeyboardMode::Full)); // letters have more rows than symbols
model.setVisibleSections({u"function"_s, MainSectionId, u"numpad"_s});
model.setSize({1000, 300});
const auto lastKeyRect = [this] { return model.keyRect(model.rowCount() - 1); }; // numpad "="
const auto firstKeyRect = [this] { return model.keyRect(0); }; // esc
const QRectF numpad = lastKeyRect();
const QRectF esc = firstKeyRect();
model.setLayer(u"numbers"_s);
QCOMPARE(lastKeyRect(), numpad);
QCOMPARE(firstKeyRect(), esc);
}
KeyDef keysymKey(quint32 keysym)
{
KeyDef key = makeKey(KeyType::Keysym);
key.keysym = keysym;
return key;
}
KeyDef modifierKey(const QString &name)
{
KeyDef key = makeKey(KeyType::Modifier);
key.modifier = name;
return key;
}
void ctrlTurnsKeysIntoShortcuts()
{
tap(modifierKey(u"ctrl"_s));
QCOMPARE(controller->modifierModes().value(u"ctrl"_s).toInt(), int(KeyboardController::ShiftMode::Once));
tap(makeKey(KeyType::Character, u"c"_s));
QCOMPARE(engine.keysyms, QList<quint32>{XKB_KEY_c});
QCOMPARE(engine.lastModifiers, Qt::ControlModifier);
QVERIFY(engine.output.isEmpty());
// One-shot: the next key types again.
tap(makeKey(KeyType::Character, u"c"_s));
QCOMPARE(engine.output, u"c");
}
void heldModifierAppliesToEveryKey()
{
const KeyDef alt = modifierKey(u"alt"_s);
controller->keyDown(alt, 0);
tap(keysymKey(XKB_KEY_Tab));
tap(keysymKey(XKB_KEY_Tab));
controller->keyUp(alt, KeyOutput::Primary, {}, 9000);
QCOMPARE(engine.keysyms.size(), 2);
QCOMPARE(engine.lastModifiers, Qt::AltModifier);
QCOMPARE(controller->modifierModes().value(u"alt"_s).toInt(), int(KeyboardController::ShiftMode::Off));
}
void tappedShiftAppliesToFunctionKeys()
{
tap(makeKey(KeyType::Character, u"a"_s)); // mid-sentence: no auto-shift
tap(makeKey(KeyType::Shift));
tap(keysymKey(XKB_KEY_F5));
QCOMPARE(engine.keysyms.last(), quint32(XKB_KEY_F5));
QCOMPARE(engine.lastModifiers, Qt::ShiftModifier);
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Off); // used up
}
void autoShiftAndCapsLockDontApplyToFunctionKeys()
{
controller->resetContext(Field); // sentence start: auto-shift on
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Once);
tap(keysymKey(XKB_KEY_Left));
QCOMPARE(engine.lastModifiers, Qt::NoModifier);
tap(makeKey(KeyType::CapsLock));
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Locked);
tap(keysymKey(XKB_KEY_Left));
QCOMPARE(engine.lastModifiers, Qt::NoModifier);
tap(makeKey(KeyType::Character, u"a"_s));
tap(makeKey(KeyType::Character, u"b"_s));
QCOMPARE(engine.output, u"AB");
tap(makeKey(KeyType::CapsLock));
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Off);
}
void autoShiftCapitalizesLettersOnly()
{
KeyDef lj = makeKey(KeyType::Character, u"ľ"_s); // sk number row: Shift+ľ = 2
lj.shifted = u"2"_s;
KeyDef one = makeKey(KeyType::Character, u"+"_s);
one.shifted = u"1"_s;
controller->resetContext(Field); // sentence start
tap(one);
QCOMPARE(engine.output, u"+"); // not the shifted level
controller->resetContext(Field);
tap(lj);
QCOMPARE(engine.output, u"+Ľ");
tap(makeKey(KeyType::Shift)); // the user's shift does pick it
tap(lj);
QCOMPARE(engine.output, u"+Ľ2");
}
void deadKeyComposesWithNextCharacter()
{
KeyDef acute = makeKey(KeyType::Character, u"´"_s);
acute.dead = XKB_KEY_dead_acute;
tap(makeKey(KeyType::Character, u"x"_s));
tap(acute);
QVERIFY(engine.output == u"x"); // nothing typed yet
tap(makeKey(KeyType::Character, u"e"_s));
QVERIFY(engine.output.endsWith(u"é"_s));
}
void holdKeysPressesWhileDown()
{
controller->setOptions({.holdKeys = true});
const KeyDef shift = makeKey(KeyType::Shift);
const KeyDef a = makeKey(KeyType::Character, u"a"_s);
controller->keyDown(shift, 0);
QCOMPARE(model.isUppercase(), true); // labels follow the held shift
controller->keyDown(a, 10);
controller->keyUp(a, KeyOutput::Primary, {}, 20);
controller->keyUp(shift, KeyOutput::Primary, {}, 30);
using E = FakeEngine::KeyEvent;
QCOMPARE(engine.keyEvents, (QList<E>{{XKB_KEY_Shift_L, true}, {XKB_KEY_a, true},
{XKB_KEY_a, false}, {XKB_KEY_Shift_L, false}}));
QVERIFY(engine.output.isEmpty());
QVERIFY(engine.keysyms.isEmpty());
QCOMPARE(model.isUppercase(), false);
}
void holdKeysLeavesNoKeyStuck()
{
controller->setOptions({.holdKeys = true});
const KeyDef ctrl = modifierKey(u"ctrl"_s);
controller->keyDown(ctrl, 0);
QCOMPARE(controller->modifierModes().value(u"ctrl"_s).toInt(), 1);
controller->setOptions({}); // turned off while held
QVERIFY(engine.held.isEmpty());
controller->keyUp(ctrl, KeyOutput::Primary, {}, 10); // back to sticky rules: harmless
QVERIFY(engine.held.isEmpty());
}
void holdKeysSendsDeadKeysAndCapsLockToTheSystem()
{
controller->setOptions({.holdKeys = true});
KeyDef acute = makeKey(KeyType::Character, u"´"_s);
acute.dead = XKB_KEY_dead_acute;
tap(acute);
tap(makeKey(KeyType::CapsLock));
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Locked);
QCOMPARE(engine.keyEvents.first().keysym, quint32(XKB_KEY_dead_acute));
QCOMPARE(engine.keyEvents[2].keysym, quint32(XKB_KEY_Caps_Lock));
}
void noTextRulesWithoutAField()
{
controller->resetContext({}); // keys go to a window, not a text field
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Off);
const KeyDef space = makeKey(KeyType::Space);
tap(makeKey(KeyType::Character, u"a"_s));
tap(space);
tap(space);
QCOMPARE(engine.output, u"a ");
}
void dismissEmitsSignal()
{
int dismissed = 0;
connect(controller.get(), &KeyboardController::dismissRequested, this, [&] { ++dismissed; });
tap(makeKey(KeyType::Dismiss));
QCOMPARE(dismissed, 1);
}
};
QTEST_GUILESS_MAIN(TestController)
#include "tst_controller.moc"