// 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") } }