Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/go-unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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 ./...
11 changes: 10 additions & 1 deletion ryoku/wm/hyprland/act.go
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}
Expand Down
49 changes: 47 additions & 2 deletions ryoku/wm/hyprland/act_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down Expand Up @@ -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).
Expand Down
Loading