320 lines
12 KiB
Go
320 lines
12 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package wayland
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/input"
|
|
"github.com/godbus/dbus/v5"
|
|
)
|
|
|
|
func test_owner(t *testing.T, layout input.KeyboardLayout, modifiers ...string) *keyboardOwner {
|
|
t.Helper()
|
|
keyboard := New(modifiers, layout, nil)
|
|
policy := keyboardPolicy{enabled: true}
|
|
symbols, _, bindings := make_grabs(keyboard.configuration, policy)
|
|
return &keyboardOwner{keyboard: keyboard, configuration: keyboard.configuration, policy: policy,
|
|
bindings: bindings, router: input.NewCommandRouter(symbols, bindings), held: make(map[uint16]uint32), captured: make(map[uint16]bool), started: time.Now()}
|
|
}
|
|
|
|
func send_key(t *testing.T, owner *keyboardOwner, symbol uint32, code uint16, state uint32, released bool) {
|
|
t.Helper()
|
|
if err := owner.handle_key(keyEvent{symbol: symbol, code: code, state: state, released: released}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func next_command(t *testing.T, keyboard *Keyboard, command input.Command) input.CommandEvent {
|
|
t.Helper()
|
|
select {
|
|
case event := <-keyboard.commands:
|
|
if event.Command != command {
|
|
t.Fatalf("command = %q, want %q", event.Command, command)
|
|
}
|
|
return event
|
|
default:
|
|
t.Fatalf("missing command %q", command)
|
|
}
|
|
return input.CommandEvent{}
|
|
}
|
|
|
|
func TestReaderChordsDispatchOnceAndReleaseBothOrders(t *testing.T) {
|
|
for _, modifier := range []struct {
|
|
name string
|
|
symbol uint32
|
|
}{{"Insert", 0xff63}, {"KP_Insert", 0xff9e}, {"KP_Insert", 0xffb0}, {"Caps_Lock", 0xffe5}} {
|
|
for _, modifierFirst := range []bool{false, true} {
|
|
owner := test_owner(t, input.DesktopKeyboardLayout, modifier.name)
|
|
send_key(t, owner, modifier.symbol, 118, 0, false)
|
|
send_key(t, owner, 's', 39, 0, false)
|
|
send_key(t, owner, 's', 39, 0, false)
|
|
event := next_command(t, owner.keyboard, input.CycleSpeechMode)
|
|
if !event.Chord.CthulhuModifier || event.Timestamp == 0 {
|
|
t.Fatal("missing chord identity")
|
|
}
|
|
if modifierFirst {
|
|
send_key(t, owner, modifier.symbol, 118, 0, true)
|
|
}
|
|
send_key(t, owner, 's', 39, 0, true)
|
|
if !modifierFirst {
|
|
send_key(t, owner, modifier.symbol, 118, 0, true)
|
|
}
|
|
if owner.keyboard.active.Load() || len(owner.held) != 0 || len(owner.keyboard.commands) != 0 {
|
|
t.Fatal("repeat or held state survived release")
|
|
}
|
|
send_key(t, owner, 's', 39, 0, false)
|
|
if len(owner.keyboard.commands) != 0 {
|
|
t.Fatal("plain typing became a command")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTypingIntentModifiersRepeatsAndHighKeycodes(t *testing.T) {
|
|
owner := test_owner(t, input.DesktopKeyboardLayout, "Insert")
|
|
send_key(t, owner, 0xffe3, 37, 0, false)
|
|
send_key(t, owner, 0xff53, 114, 4, false)
|
|
if owner.keyboard.intent.RecentIntent() != input.WordIntent {
|
|
t.Fatal("Control+Right lost editing intent")
|
|
}
|
|
send_key(t, owner, 0xff53, 114, 4, false)
|
|
for index := 0; index < 3; index++ {
|
|
event := <-owner.keyboard.events
|
|
if event.Repeat != (index == 2) {
|
|
t.Fatalf("repeat for event %d = %v", index, event.Repeat)
|
|
}
|
|
}
|
|
if len(owner.keyboard.commands) != 0 {
|
|
t.Fatal("typing was routed as a command")
|
|
}
|
|
send_key(t, owner, 'x', 293, 0, false) // Must never alias physical Control at 37.
|
|
if len(owner.keyboard.events) != 0 || owner.keyboard.overflow.Load() != 1 {
|
|
t.Fatal("wide keycode was truncated")
|
|
}
|
|
}
|
|
|
|
func TestBrowseCaseLocksAndFocusedButton(t *testing.T) {
|
|
configuration := keyboardConfiguration{layout: input.DesktopKeyboardLayout, modifiers: []string{"Insert"}}
|
|
for _, mask := range []uint32{0, 2, 16, 18} {
|
|
policy := keyboardPolicy{enabled: true, browse: true}
|
|
symbols, grabs, bindings := make_grabs(configuration, policy)
|
|
owner := test_owner(t, configuration.layout, "Insert")
|
|
owner.bindings, owner.policy, owner.router = bindings, policy, input.NewCommandRouter(symbols, bindings)
|
|
send_key(t, owner, 'H', 43, mask|1, false)
|
|
next_command(t, owner.keyboard, input.DocumentPreviousHeading)
|
|
send_key(t, owner, 'h', 43, mask, true)
|
|
if len(owner.held) != 0 {
|
|
t.Fatal("changed release symbol left a key held")
|
|
}
|
|
found := false
|
|
for _, grab := range grabs {
|
|
if grab == (keyGrab{'H', mask | 1}) {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("shifted heading grab missing")
|
|
}
|
|
}
|
|
_, grabs, bindings := make_grabs(configuration, keyboardPolicy{enabled: true, browse: true, button: true})
|
|
if bindings[input.CommandChord{Symbol: 0xff0d}] != "" {
|
|
t.Fatal("native Return still activates document")
|
|
}
|
|
for _, grab := range grabs {
|
|
if grab.Symbol == 0xff0d {
|
|
t.Fatal("native button Return remains grabbed")
|
|
}
|
|
}
|
|
modifiers, grabs, _ := make_grabs(configuration, keyboardPolicy{secure: true, enabled: true, browse: true})
|
|
if len(modifiers) != 0 || len(grabs) != 0 {
|
|
t.Fatal("secure input retains grabs")
|
|
}
|
|
}
|
|
|
|
// Compositors emit the shifted keysym for Shift+digit (US layout: Shift+3 is
|
|
// numbersign), while bindings name the unshifted digit plus the Shift bit.
|
|
func TestShiftPairsFromKeymapLayouts(t *testing.T) {
|
|
us := build_shift_pairs("us")
|
|
for shifted, base := range map[uint32]uint32{'!': '1', '@': '2', '#': '3', '$': '4', '%': '5', '^': '6', ':': ';', '?': '/'} {
|
|
if us.unshifted[shifted] != base {
|
|
t.Fatalf("us shifted %q unshifts to %q, want %q", shifted, us.unshifted[shifted], base)
|
|
}
|
|
}
|
|
if partners := us.shifted['3']; len(partners) != 1 || partners[0] != '#' {
|
|
t.Fatalf("us partners for 3 = %v, want [#]", partners)
|
|
}
|
|
// The same physical chord differs by layout: GB Shift+3 is sterling.
|
|
gb := build_shift_pairs("gb")
|
|
if gb.unshifted[0xa3] != '3' {
|
|
t.Fatalf("gb sterling unshifts to %q, want 3", gb.unshifted[0xa3])
|
|
}
|
|
if empty := build_shift_pairs("no-such-layout"); len(empty.unshifted) != 0 || len(empty.shifted) != 0 {
|
|
t.Fatal("unknown layout produced shift pairs")
|
|
}
|
|
}
|
|
|
|
func TestBrowseShiftedDigitHeadingLevels(t *testing.T) {
|
|
if loadShiftPairs().unshifted['#'] != '3' {
|
|
t.Skip("default XKB keymap is not US-like; shifted symbols here assume US pairs")
|
|
}
|
|
configuration := keyboardConfiguration{layout: input.DesktopKeyboardLayout, modifiers: []string{"Insert"}}
|
|
previous := []input.Command{
|
|
input.DocumentPreviousHeadingAtLevel1, input.DocumentPreviousHeadingAtLevel2,
|
|
input.DocumentPreviousHeadingAtLevel3, input.DocumentPreviousHeadingAtLevel4,
|
|
input.DocumentPreviousHeadingAtLevel5, input.DocumentPreviousHeadingAtLevel6,
|
|
}
|
|
shifted := []uint32{'!', '@', '#', '$', '%', '^'}
|
|
for _, mask := range []uint32{0, 2, 16, 18} {
|
|
policy := keyboardPolicy{enabled: true, browse: true}
|
|
symbols, grabs, bindings := make_grabs(configuration, policy)
|
|
owner := test_owner(t, configuration.layout, "Insert")
|
|
owner.bindings, owner.policy, owner.router = bindings, policy, input.NewCommandRouter(symbols, bindings)
|
|
for level := 0; level < 6; level++ {
|
|
code := uint16(10 + level)
|
|
found := false
|
|
for _, grab := range grabs {
|
|
if grab == (keyGrab{shifted[level], mask | 1}) {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("shifted digit grab missing for level %d mask %d", level+1, mask)
|
|
}
|
|
send_key(t, owner, shifted[level], code, mask|1, false)
|
|
next_command(t, owner.keyboard, previous[level])
|
|
// Releasing Shift first changes the reported symbol to the digit.
|
|
send_key(t, owner, uint32('1'+level), code, mask, true)
|
|
if len(owner.held) != 0 {
|
|
t.Fatal("changed release symbol left a digit key held")
|
|
}
|
|
send_key(t, owner, uint32('1'+level), code, mask, false)
|
|
next_command(t, owner.keyboard, input.Command("document-next-heading-at-level-"+string(rune('1'+level))))
|
|
send_key(t, owner, uint32('1'+level), code, mask, true)
|
|
}
|
|
}
|
|
// Alt+Shift+digit heading lists share the translated symbol.
|
|
policy := keyboardPolicy{enabled: true, browse: true}
|
|
symbols, _, bindings := make_grabs(configuration, policy)
|
|
listOwner := test_owner(t, configuration.layout, "Insert")
|
|
listOwner.bindings, listOwner.policy, listOwner.router = bindings, policy, input.NewCommandRouter(symbols, bindings)
|
|
send_key(t, listOwner, '#', 12, 1|8, false)
|
|
next_command(t, listOwner.keyboard, input.DocumentListHeadingsAtLevel3)
|
|
send_key(t, listOwner, '3', 12, 8, true)
|
|
// Outside browse mode the shifted symbol must reach monitoring untouched.
|
|
owner := test_owner(t, input.DesktopKeyboardLayout, "Insert")
|
|
send_key(t, owner, '#', 12, 1, false)
|
|
event := <-owner.keyboard.events
|
|
if event.Name != "numbersign" || len(owner.keyboard.commands) != 0 {
|
|
t.Fatalf("plain typing changed to %q", event.Name)
|
|
}
|
|
}
|
|
|
|
func TestAmbiguousShiftPartnersAreNotGrabbed(t *testing.T) {
|
|
// US Shift+apostrophe and GB Shift+2 both produce a double quote.
|
|
// Without the active group, the monitor cannot route it reliably.
|
|
pairs := build_shift_pairs("us,gb")
|
|
if _, ok := pairs.unshifted['"']; ok {
|
|
t.Fatal("ambiguous double quote has a reverse mapping")
|
|
}
|
|
original := loadShiftPairs
|
|
loadShiftPairs = func() shiftPairs { return pairs }
|
|
t.Cleanup(func() { loadShiftPairs = original })
|
|
_, grabs, _ := make_grabs(keyboardConfiguration{layout: input.DesktopKeyboardLayout, modifiers: []string{"Insert"}},
|
|
keyboardPolicy{enabled: true, browse: true})
|
|
for _, grab := range grabs {
|
|
if grab.Symbol == '"' && grab.Modifiers&1 != 0 {
|
|
t.Fatalf("ambiguous shifted symbol grabbed: %+v", grab)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLearnModeAndSecureReset(t *testing.T) {
|
|
owner := test_owner(t, input.LaptopKeyboardLayout, "Insert")
|
|
send_key(t, owner, 0xff63, 118, 0, false)
|
|
send_key(t, owner, 'h', 43, 0, false)
|
|
if !next_command(t, owner.keyboard, input.ToggleLearnMode).LearnModeStarted {
|
|
t.Fatal("learn mode did not begin")
|
|
}
|
|
send_key(t, owner, 'h', 43, 0, true)
|
|
send_key(t, owner, 0xff63, 118, 0, true)
|
|
owner.policy.learn = true // The protocol owner installs its full grab after release.
|
|
send_key(t, owner, 'q', 24, 0, false)
|
|
next_command(t, owner.keyboard, input.LearnKey)
|
|
send_key(t, owner, 'q', 24, 0, true)
|
|
if loadShiftPairs().unshifted['#'] == '3' {
|
|
send_key(t, owner, '#', 12, 1, false)
|
|
learned := next_command(t, owner.keyboard, input.LearnKey)
|
|
if learned.KeyName != "3" || learned.Chord.Symbol != '3' || learned.Chord.Modifiers != 1 {
|
|
t.Fatalf("learned shifted digit = %q %+v, want Shift+3", learned.KeyName, learned.Chord)
|
|
}
|
|
send_key(t, owner, '3', 12, 0, true)
|
|
}
|
|
send_key(t, owner, 0xff1b, 9, 0, false)
|
|
if next_command(t, owner.keyboard, input.ToggleLearnMode).LearnModeStarted {
|
|
t.Fatal("Escape restarted learn mode")
|
|
}
|
|
if owner.learning {
|
|
t.Fatal("Escape left learn mode active")
|
|
}
|
|
owner.keyboard.SetSecureInputActive(true)
|
|
send_key(t, owner, 'p', 33, 0, false)
|
|
if len(owner.keyboard.events) != 0 || len(owner.keyboard.commands) != 0 {
|
|
t.Fatal("secure input leaked output")
|
|
}
|
|
}
|
|
|
|
func TestSignalQueueBoundedOrderedAndSecureGeneration(t *testing.T) {
|
|
keyboard := New([]string{"Insert"}, input.DesktopKeyboardLayout, nil)
|
|
queue := &signalQueue{keyboard: keyboard, signals: make(chan queuedSignal, 2), overflow: make(chan struct{})}
|
|
for index := 0; index < 3; index++ {
|
|
queue.DeliverSignal("", "", &dbus.Signal{Path: managerPath, Name: monitorInterface + ".KeyEvent", Body: []any{uint32(index)}})
|
|
}
|
|
select {
|
|
case <-queue.overflow:
|
|
default:
|
|
t.Fatal("overflow did not fail closed")
|
|
}
|
|
for index := 0; index < 2; index++ {
|
|
if (<-queue.signals).signal.Body[0] != uint32(index) {
|
|
t.Fatal("signal order changed")
|
|
}
|
|
}
|
|
keyboard.SetSecureInputActive(true)
|
|
queue.DeliverSignal("", "", &dbus.Signal{Path: managerPath, Name: monitorInterface + ".KeyEvent"})
|
|
if len(queue.signals) != 0 {
|
|
t.Fatal("secure key queued")
|
|
}
|
|
keyboard.SetSecureInputActive(false)
|
|
queue.DeliverSignal("", "", &dbus.Signal{Path: managerPath, Name: monitorInterface + ".KeyEvent"})
|
|
if (<-queue.signals).generation != 2 {
|
|
t.Fatal("secure generation not advanced")
|
|
}
|
|
}
|
|
|
|
func TestKeyboardConfigurationValidation(t *testing.T) {
|
|
keyboard := New([]string{"Insert"}, input.DesktopKeyboardLayout, nil)
|
|
if err := keyboard.SetKeyboardConfiguration(context.Background(), input.LaptopKeyboardLayout, []string{"Caps_Lock"}, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := keyboard.SetKeyboardConfiguration(context.Background(), input.DesktopKeyboardLayout, []string{"Super_L"}, nil); err == nil {
|
|
t.Fatal("unsupported modifier accepted")
|
|
}
|
|
if keyboard.configuration.layout != input.LaptopKeyboardLayout {
|
|
t.Fatal("failed update changed configuration")
|
|
}
|
|
}
|
|
|
|
func TestUnknownModifierBitsDoNotDispatchTruncatedChord(t *testing.T) {
|
|
owner := test_owner(t, input.DesktopKeyboardLayout, "Insert")
|
|
send_key(t, owner, 0xff63, 118, 0, false)
|
|
send_key(t, owner, 'q', 24, 1<<20, false)
|
|
if len(owner.keyboard.commands) != 0 {
|
|
t.Fatal("wide modifier mask became Cthulhu+Q")
|
|
}
|
|
}
|