Files
sloposk/tests/tst_controller.cpp
T
2026-10-15 10:13:56 +02:00

461 lines
16 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, .hasSurroundingText = false, .textBeforeCursor = {}}; // reports no text
// 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 fieldHintsCanOptOutOfCapitalization()
{
TextContext context = Field;
context.hints = TextContext::AutoCompletion; // states preferences, no capitals among them
controller->setContext(context);
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Off);
context.hints |= TextContext::AutoCapitalization;
controller->setContext(context);
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Once);
}
void cursorMovesForgetTheGuessedText()
{
controller->resetContext(Field); // reports no text: we only know what we typed
for (const char *c : {"h", "i", "."})
tap(makeKey(KeyType::Character, QString::fromLatin1(c)));
controller->moveCursor({-2, 0}); // now somewhere inside "Hi."
tap(makeKey(KeyType::Space));
QCOMPARE(controller->shiftMode(), KeyboardController::ShiftMode::Off);
}
void contextReportKeepsTappedShift()
{
tap(makeKey(KeyType::Shift));
controller->setContext(MidSentence); // unchanged text, re-reported
tap(makeKey(KeyType::Character, u"a"_s));
QCOMPARE(engine.output, u"A");
}
void resetForgetsTextBefore()
{
TextContext context = MidSentence;
context.textBeforeCursor = u"word "_s;
controller->setContext(context);
controller->setContext(Field); // input-method reset: no surrounding text
tap(makeKey(KeyType::Space));
QCOMPARE(engine.output, u" ");
QCOMPARE(engine.mismatchedReplacements, 0);
}
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 ctrlWithDeadKeySendsTheDeadKeysym()
{
KeyDef acute = makeKey(KeyType::Character, u"´"_s);
acute.dead = XKB_KEY_dead_acute;
tap(modifierKey(u"ctrl"_s));
tap(acute);
QCOMPARE(engine.keysyms, QList<quint32>{XKB_KEY_dead_acute});
QCOMPARE(engine.lastModifiers, Qt::ControlModifier);
}
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"