- A tapped shift (on or off) survives the field re-reporting its context: it lasts until a character is typed, the hint changes or a field is focused - An input-method reset forgets the guessed text and pending dead key, so double-space can't delete a character the keyboard never saw - Fields that state typing preferences only get capitals if they ask - Trackpad moves in fields without surrounding text make the guess unknown; Backspace removes a whole grapheme cluster from it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
451 lines
16 KiB
C++
451 lines
16 KiB
C++
#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 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 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"
|