diff --git a/internal/tools/list_devices_test.go b/internal/tools/list_devices_test.go new file mode 100644 index 00000000..e2e5c3a2 --- /dev/null +++ b/internal/tools/list_devices_test.go @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package tools + +import ( + "context" + "strings" + "testing" + + "github.com/xunholy/promptzero/internal/config" +) + +// TestListDevices_OutputStableAndOmitsEmptyType verifies the list_devices +// view is deterministic (sorted by name, not random map order) and omits +// the "type:" field for shorthand devices that set only a file — the +// bare-string form added in v0.749. +func TestListDevices_OutputStableAndOmitsEmptyType(t *testing.T) { + spec, ok := Get("list_devices") + if !ok { + t.Fatal("list_devices not registered") + } + cfg := &config.Config{Devices: map[string]config.Device{ + // Shorthand: only File set. + "garage": {File: "/ext/subghz/garage.sub"}, + // Full mapping with multiple commands. + "tv": {Type: "infrared", File: "/ext/infrared/tv.ir", Commands: map[string]string{ + "volume_up": "/ext/infrared/tv_volup.ir", + "power": "/ext/infrared/tv_power.ir", + }}, + }} + + out, err := spec.Handler(context.Background(), &Deps{Config: cfg}, nil) + if err != nil { + t.Fatalf("handler: %v", err) + } + + want := "- garage (file: /ext/subghz/garage.sub)\n" + + "- tv (type: infrared, file: /ext/infrared/tv.ir)\n" + + " command: power -> /ext/infrared/tv_power.ir\n" + + " command: volume_up -> /ext/infrared/tv_volup.ir\n" + if out != want { + t.Errorf("list_devices output mismatch:\n got:\n%s\nwant:\n%s", out, want) + } + + // Shorthand device must NOT print an empty "type:". + if strings.Contains(out, "type: ,") || strings.Contains(out, "(type: )") { + t.Errorf("shorthand device printed an empty type:\n%s", out) + } +} diff --git a/internal/tools/system.go b/internal/tools/system.go index fa1a5590..c1951ad9 100644 --- a/internal/tools/system.go +++ b/internal/tools/system.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "sort" "time" "github.com/xunholy/promptzero/internal/risk" @@ -103,11 +104,32 @@ func init() { if d.Config == nil || len(d.Config.Devices) == 0 { return "No devices configured. Add devices to config.yaml.", nil } + // Sort by name so the listing is stable across calls — map + // iteration order is random, which makes the output churn and + // is unpleasant to read or test. + names := make([]string, 0, len(d.Config.Devices)) + for name := range d.Config.Devices { + names = append(names, name) + } + sort.Strings(names) + var out string - for name, dev := range d.Config.Devices { - out += fmt.Sprintf("- %s (type: %s, file: %s)\n", name, dev.Type, dev.File) - for cmd, signal := range dev.Commands { - out += fmt.Sprintf(" command: %s -> %s\n", cmd, signal) + for _, name := range names { + dev := d.Config.Devices[name] + // Type is optional — the bare-string shorthand sets only + // File — so omit it rather than printing an empty "type: ". + if dev.Type != "" { + out += fmt.Sprintf("- %s (type: %s, file: %s)\n", name, dev.Type, dev.File) + } else { + out += fmt.Sprintf("- %s (file: %s)\n", name, dev.File) + } + cmds := make([]string, 0, len(dev.Commands)) + for cmd := range dev.Commands { + cmds = append(cmds, cmd) + } + sort.Strings(cmds) + for _, cmd := range cmds { + out += fmt.Sprintf(" command: %s -> %s\n", cmd, dev.Commands[cmd]) } } return out, nil