// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package xorg import ( "context" "os" "os/exec" "strings" "testing" "time" "git.stormux.org/storm/cthulhu/internal/input" ) func TestNativeLockStatesAndShiftCapsLock(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_X11") != "1" { t.Skip("requires isolated X11 display") } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() listener := NewCommands([]string{"Caps_Lock"}, input.LaptopKeyboardLayout) if err := listener.Start(ctx); err != nil { t.Fatal(err) } defer listener.Stop(context.Background()) key := func(args ...string) { t.Helper() if out, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil { t.Fatalf("keys: %v %s", err, out) } } state := func(name string, locked bool) { t.Helper() select { case event := <-listener.LockStates(): if event.Name != name || event.Locked != locked || event.Timestamp == 0 { t.Fatalf("lock state = %+v, want %s %v with timestamp", event, name, locked) } case err := <-listener.Failures(): t.Fatal(err) case <-ctx.Done(): t.Fatalf("missing lock state %s %v", name, locked) } } command := func(want input.Command) { t.Helper() select { case event := <-listener.Commands(): if event.Command != want || event.Timestamp == 0 { t.Fatalf("command = %+v, want %s with timestamp", event, want) } case err := <-listener.Failures(): t.Fatal(err) case <-ctx.Done(): t.Fatalf("missing command %s", want) } } lockMask := func() string { t.Helper() out, err := exec.CommandContext(ctx, "xset", "q").Output() if err != nil { t.Fatal(err) } fields := strings.Fields(string(out)) for index, field := range fields { if field == "LED" && index+2 < len(fields) { return fields[index+2] } } t.Fatal("missing lock state") return "" } initial := lockMask() // Caps Lock alone and Shift+CapsLock+V must not toggle capitalization. key("key", "Caps_Lock") key("keydown", "Shift_L", "keydown", "Caps_Lock", "key", "v", "keyup", "Shift_L", "keyup", "Caps_Lock") command(input.PresentVersion) if lockMask() != initial { t.Fatal("version chord changed capitalization") } for _, shift := range []string{"Shift_L", "Shift_R"} { key("keydown", shift, "key", "Caps_Lock", "keyup", shift) state("Caps Lock", true) if lockMask() == initial { t.Fatal("Shift+CapsLock did not enable capitalization") } // The reader modifier and commands still work while capitalization is on. key("keydown", "Caps_Lock", "key", "t", "keyup", "Caps_Lock") command(input.PresentTime) key("keydown", "Caps_Lock", "keydown", shift, "key", "v", "keyup", "Caps_Lock", "keyup", shift) command(input.PresentVersion) if lockMask() == initial { t.Fatal("reader chord cleared capitalization") } // Reverse modifier order and release Shift first. key("keydown", "Caps_Lock", "keydown", shift, "keyup", shift, "keyup", "Caps_Lock") state("Caps Lock", false) if lockMask() != initial { t.Fatal("Shift+CapsLock did not disable capitalization") } } key("key", "Num_Lock") state("Num Lock", true) key("key", "Num_Lock") state("Num Lock", false) // A default keymap may leave Scroll Lock without a lock action. Exercise // the XKB indicator itself to verify its actual state notifications. for _, enabled := range []bool{true, false} { option := "led" if !enabled { option = "-led" } if out, err := exec.CommandContext(ctx, "xset", option, "named", "Scroll Lock").CombinedOutput(); err != nil { t.Fatalf("Scroll Lock indicator: %v %s", err, out) } state("Scroll Lock", enabled) } if err := listener.SetKeyboardConfiguration(ctx, input.LaptopKeyboardLayout, nil, nil); err != nil { t.Fatal(err) } key("key", "Caps_Lock") state("Caps Lock", true) key("key", "Caps_Lock") state("Caps Lock", false) }