diff --git a/.github/workflows/go-unit-tests.yml b/.github/workflows/go-unit-tests.yml index d5ea49c47..170757f72 100644 --- a/.github/workflows/go-unit-tests.yml +++ b/.github/workflows/go-unit-tests.yml @@ -33,6 +33,7 @@ on: - 'ryoku/apps/ryostore/backend/**' - 'ryoku/hub/backend/**' - 'ryoku/rashin/backend/**' + - 'ryoku/wm/**' - '.github/workflows/go-unit-tests.yml' workflow_dispatch: @@ -169,3 +170,23 @@ jobs: go build ./... go vet ./... go test ./... + + wm: + name: window-manager providers (hyprland, niri) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: ryoku/wm/go.mod + + - name: Build, vet, and test + shell: bash + working-directory: ryoku/wm + run: | + go build ./... + go vet ./... + go test ./... diff --git a/ryoku/wm/hyprland/act.go b/ryoku/wm/hyprland/act.go index 296096b57..7c46e11ae 100644 --- a/ryoku/wm/hyprland/act.go +++ b/ryoku/wm/hyprland/act.go @@ -144,7 +144,16 @@ func runAct(args []string) error { if state != "on" && state != "off" { return fmt.Errorf("act %s: state must be on or off, got %q", action, state) } - expr := `hl.dsp.dpms({ state = ` + luaStr(state) + // Hyprland's Lua dispatcher reads the toggle field named `action`, with + // values enable, disable or toggle. A `state` key is silently ignored, so + // an explicit on request could leave the display off. Map on/off here. + toggle := "toggle" + if state == "on" { + toggle = "enable" + } else { + toggle = "disable" + } + expr := `hl.dsp.dpms({ action = ` + luaStr(toggle) if len(rest) > 1 && rest[1] != "" { expr += `, monitor = ` + luaStr(rest[1]) } diff --git a/ryoku/wm/hyprland/act_test.go b/ryoku/wm/hyprland/act_test.go index a241ee7e1..f72ce587f 100644 --- a/ryoku/wm/hyprland/act_test.go +++ b/ryoku/wm/hyprland/act_test.go @@ -43,8 +43,10 @@ func TestActEmitsLuaDialect(t *testing.T) { []string{"dispatch", `hl.dsp.workspace.toggle_special("sharebar")`}}, {"session exit", []string{"session.exit"}, []string{"dispatch", `hl.dsp.exit()`}}, - {"output power", []string{"output.power", "off", "eDP-2"}, - []string{"dispatch", `hl.dsp.dpms({ state = "off", monitor = "eDP-2" })`}}, + {"output power off", []string{"output.power", "off", "eDP-2"}, + []string{"dispatch", `hl.dsp.dpms({ action = "disable", monitor = "eDP-2" })`}}, + {"output power on", []string{"output.power", "on"}, + []string{"dispatch", `hl.dsp.dpms({ action = "enable" })`}}, {"output enable on", []string{"output.enable", "DP-1", "on"}, []string{"eval", `hl.monitor({ output = "DP-1", mode = "preferred", position = "auto", scale = 1 })`}}, {"output enable off", []string{"output.enable", "DP-1", "off"}, @@ -95,6 +97,49 @@ func TestActEmitsLuaDialect(t *testing.T) { } } +// An explicit on or off must be idempotent: repeating the same request must not +// toggle the display, because the resume path can re-issue an enable while the +// lock surface is still appearing and flash the screen. The dispatcher field is +// `action`, so the request names its intent instead of relying on a flip. +func TestActOutputPowerIsIdempotent(t *testing.T) { + cases := []struct { + name string + args []string + want string + }{ + {"on", []string{"output.power", "on"}, `hl.dsp.dpms({ action = "enable" })`}, + {"off", []string{"output.power", "off"}, `hl.dsp.dpms({ action = "disable" })`}, + {"on with monitor", []string{"output.power", "on", "eDP-2"}, + `hl.dsp.dpms({ action = "enable", monitor = "eDP-2" })`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var got []string + restore := stubCtl(t, func(args ...string) ([]byte, error) { + got = args + return nil, nil + }) + defer restore() + if err := runAct(tc.args); err != nil { + t.Fatalf("runAct(%v): %v", tc.args, err) + } + want := []string{"dispatch", tc.want} + if strings.Join(got, "\x00") != strings.Join(want, "\x00") { + t.Errorf("argv mismatch\n got: %q\nwant: %q", got, want) + } + // Repeating the same request must emit the same literal, never a + // toggle, so a second enable cannot re-disable the display. + got = nil + if err := runAct(tc.args); err != nil { + t.Fatalf("repeat runAct(%v): %v", tc.args, err) + } + if strings.Join(got, "\x00") != strings.Join(want, "\x00") { + t.Errorf("repeat argv mismatch\n got: %q\nwant: %q", got, want) + } + }) + } +} + // A missing argument must name the value, so a bad keybind is diagnosable, and // must never reach the compositor with an empty selector (which Hyprland applies // to the focused window).