From 66cc36017c04e51c8ac901e5e6ec5e0ac51b5d6d Mon Sep 17 00:00:00 2001 From: tannevaled Date: Tue, 8 Sep 2026 20:08:07 +0200 Subject: [PATCH] Screens answers a list that cannot be empty and has one primary Screens returned []Screen, so two rules lived as conventions rather than as the type, and one of them cost somebody their desk. NEVER EMPTY. Nothing stopped an empty slice arriving, and the darwin back-end sent one: liveDisplays answered (nil, nil) when the window server counted zero, which is a failed read wearing the clothes of a fact. go-xrkit/desk looks its display up by name, found none, and quit -- twice on 2026-09-08, on a headset somebody was wearing: desk: "VITURE Beast" is not attached any more; there is -- stopping Nothing after "there is": the message names every attached display, and it named NONE. Three of the four back-ends already refused to say it; the fourth was the one that shipped. EXACTLY ONE PRIMARY, FIRST. primaryFirst walked the slice unsetting duplicate flags -- "only one may claim it" -- and every caller that wanted the main display then scanned for the flag again. It is a property of the list, not something to go looking for. type ScreenList struct{ all []Screen } func (l ScreenList) Primary() Screen func (l ScreenList) All() []Screen func (l ScreenList) Len() int func (l ScreenList) ByName(string) (Screen, bool) newScreenList is the only way a populated one comes into being, and it is where both rules live. ErrNoScreens is deliberately NOT ErrScreensUnsupported: that one means this build cannot enumerate at all, which no retry will change; this one means the enumeration ran and came back empty, which a second later may not. ByName is here because every caller wrote it: looking a display up by name is what survives the desktop being rearranged, and three separate loops were doing it. Two hand-written guards go away with it -- VisibleScreenSize's "len(screens) == 0" on both Linux and Windows, and its screens[0], which is Primary() said properly. The platform messages that name a REASON are kept and wrapped around ErrNoScreens, so a caller can test one sentinel without losing "the Wayland compositor advertises no output". The back-end tests are about the geometry a protocol reports rather than about the list, so they take a one-word adapter (allOf) and are otherwise unchanged. Proved by sabotage: with the empty check disabled the new test names both cases. Co-Authored-By: Claude Opus 5 --- liveplacement_darwin_test.go | 4 +- screen_darwin.go | 6 +- screen_linux.go | 8 +-- screen_live_windows_test.go | 2 +- screen_other.go | 4 +- screen_test.go | 10 +-- screen_wayland.go | 22 +++---- screen_wayland_test.go | 16 ++--- screen_windows.go | 12 ++-- screen_x11.go | 14 ++--- screen_x11_test.go | 6 +- screenlist.go | 100 ++++++++++++++++++++++++++++++ screenlist_test.go | 116 +++++++++++++++++++++++++++++++++++ 13 files changed, 268 insertions(+), 52 deletions(-) create mode 100644 screenlist.go create mode 100644 screenlist_test.go diff --git a/liveplacement_darwin_test.go b/liveplacement_darwin_test.go index 403d6ae..2e1ed4e 100644 --- a/liveplacement_darwin_test.go +++ b/liveplacement_darwin_test.go @@ -74,14 +74,14 @@ func callOnMain(f func()) { func mainScreens(t *testing.T) []Screen { t.Helper() var ( - ss []Screen + ss ScreenList err error ) callOnMain(func() { ss, err = Screens() }) if err != nil { t.Fatalf("Screens() = %v", err) } - return ss + return ss.All() } // mainOpen is Open() on the reserved thread. diff --git a/screen_darwin.go b/screen_darwin.go index 127a1d5..0334bde 100644 --- a/screen_darwin.go +++ b/screen_darwin.go @@ -43,10 +43,10 @@ func VisibleScreenSize() (w, h int, ok bool) { // application, and enumerated from a goroutine that is not on the main thread, // can therefore come back nameless. Everything placement depends on is exact // regardless. -func Screens() ([]Screen, error) { +func Screens() (ScreenList, error) { infos, err := cocoa.Screens() if err != nil { - return nil, err + return ScreenList{}, err } out := make([]Screen, len(infos)) for i, s := range infos { @@ -64,7 +64,7 @@ func Screens() ([]Screen, error) { Primary: s.Primary, } } - return out, nil + return newScreenList(out) } // toCocoa is the reverse projection, used by Open to hand a chosen screen back diff --git a/screen_linux.go b/screen_linux.go index 837f3ee..331440f 100644 --- a/screen_linux.go +++ b/screen_linux.go @@ -20,13 +20,13 @@ import ( // See [Screen] for what the fields mean; the two back-ends fill them from very // different protocols and are documented where they do it (screen_wayland.go // and screen_x11.go). -func Screens() ([]Screen, error) { +func Screens() (ScreenList, error) { if name := os.Getenv("WAYLAND_DISPLAY"); name != "" { return waylandScreens(name) } disp := os.Getenv("DISPLAY") if disp == "" { - return nil, fmt.Errorf("window: cannot enumerate screens: neither WAYLAND_DISPLAY nor DISPLAY is set") + return ScreenList{}, fmt.Errorf("window: cannot enumerate screens: neither WAYLAND_DISPLAY nor DISPLAY is set") } return x11Screens(disp) } @@ -40,10 +40,10 @@ func Screens() ([]Screen, error) { // every attached panel, not only the primary one. func VisibleScreenSize() (w, h int, ok bool) { screens, err := Screens() - if err != nil || len(screens) == 0 { + if err != nil { return 0, 0, false } - s := screens[0] + s := screens.Primary() if s.VisibleWidth <= 0 || s.VisibleHeight <= 0 { return 0, 0, false } diff --git a/screen_live_windows_test.go b/screen_live_windows_test.go index b813c69..92b59e7 100644 --- a/screen_live_windows_test.go +++ b/screen_live_windows_test.go @@ -30,7 +30,7 @@ func TestLiveScreens(t *testing.T) { if os.Getenv("WINDOW_LIVE_SCREENS") != "1" { t.Skip("set WINDOW_LIVE_SCREENS=1 to enumerate this machine's displays") } - screens, err := Screens() + screens, err := allOf(Screens()) if err != nil { t.Fatalf("Screens: %v", err) } diff --git a/screen_other.go b/screen_other.go index ff0452c..46a9037 100644 --- a/screen_other.go +++ b/screen_other.go @@ -23,6 +23,6 @@ func VisibleScreenSize() (w, h int, ok bool) { // or wl_output and Windows through EnumDisplayMonitors; a browser has the // Screen Detail API, so this remains a gap to be filled per back-end and not a // limit of the API. -func Screens() ([]Screen, error) { - return nil, ErrScreensUnsupported +func Screens() (ScreenList, error) { + return ScreenList{}, ErrScreensUnsupported } diff --git a/screen_test.go b/screen_test.go index 11c6900..abbce95 100644 --- a/screen_test.go +++ b/screen_test.go @@ -57,8 +57,8 @@ func TestScreenIsZero(t *testing.T) { func TestScreens(t *testing.T) { screens, err := Screens() if err != nil { - if len(screens) != 0 { - t.Fatalf("Screens() failed (%v) but returned %d screens, want none", err, len(screens)) + if screens.Len() != 0 { + t.Fatalf("Screens() failed (%v) but returned %d screens, want none", err, screens.Len()) } if !errors.Is(err, ErrScreensUnsupported) { t.Logf("Screens() unavailable for a platform reason: %v", err) @@ -66,7 +66,7 @@ func TestScreens(t *testing.T) { return } primaries := 0 - for i, s := range screens { + for i, s := range screens.All() { if s.IsZero() { t.Errorf("screen %d is the zero value", i) } @@ -80,7 +80,7 @@ func TestScreens(t *testing.T) { primaries++ } } - if len(screens) > 0 && primaries != 1 { - t.Errorf("got %d primary screens among %d, want exactly 1", primaries, len(screens)) + if screens.Len() > 0 && primaries != 1 { + t.Errorf("got %d primary screens among %d, want exactly 1", primaries, screens.Len()) } } diff --git a/screen_wayland.go b/screen_wayland.go index ac91deb..3b5495b 100644 --- a/screen_wayland.go +++ b/screen_wayland.go @@ -32,19 +32,19 @@ import ( // It opens its OWN connection and closes it again: enumerating displays is // something an application does before it has a window, and borrowing a // window's connection would make the answer depend on having one. -func waylandScreens(name string) ([]Screen, error) { +func waylandScreens(name string) (ScreenList, error) { path, err := waylandSocketPath(name) if err != nil { - return nil, err + return ScreenList{}, err } nc, err := net.Dial("unix", path) if err != nil { - return nil, fmt.Errorf("window: cannot connect to Wayland compositor: %w", err) + return ScreenList{}, fmt.Errorf("window: cannot connect to Wayland compositor: %w", err) } uc, ok := nc.(*net.UnixConn) if !ok { // net.Dial("unix", ...) always yields *net.UnixConn _ = nc.Close() - return nil, fmt.Errorf("window: Wayland dial returned %T, want *net.UnixConn", nc) + return ScreenList{}, fmt.Errorf("window: Wayland dial returned %T, want *net.UnixConn", nc) } return screensOnWayland(wayland.New(uc)) } @@ -52,30 +52,30 @@ func waylandScreens(name string) ([]Screen, error) { // screensOnWayland is waylandScreens with the connection already open, which // is what makes the whole exchange testable against a scripted compositor. // It closes the connection: it is the only owner of it. -func screensOnWayland(conn *wayland.Conn) ([]Screen, error) { +func screensOnWayland(conn *wayland.Conn) (ScreenList, error) { defer func() { _ = conn.Close() }() reg, err := conn.Display().GetRegistry() if err != nil { - return nil, err + return ScreenList{}, err } // One round trip for the globals the compositor advertises, a second for // the property burst each bound output then sends. Both are needed: an // output read before its done has no mode and no name. if err := conn.Roundtrip(); err != nil { - return nil, err + return ScreenList{}, err } outs, err := reg.Outputs() if err != nil { - return nil, err + return ScreenList{}, err } if err := conn.Roundtrip(); err != nil { - return nil, err + return ScreenList{}, err } if len(outs) == 0 { - return nil, fmt.Errorf("window: the Wayland compositor advertises no output") + return ScreenList{}, fmt.Errorf("window: the Wayland compositor advertises no output: %w", ErrNoScreens) } - return primaryFirst(waylandScreensOf(outs)), nil + return newScreenList(waylandScreensOf(outs)) } // waylandScreensOf is the projection onto [Screen], separated from the dialing diff --git a/screen_wayland_test.go b/screen_wayland_test.go index fc8c725..6c253a9 100644 --- a/screen_wayland_test.go +++ b/screen_wayland_test.go @@ -87,7 +87,7 @@ func fakeOutputCompositor(sc *srvConn, outs []outSpec) { // dialFakeOutputs runs the scripted compositor over a socket pair and returns // what screensOnWayland made of it. -func dialFakeOutputs(t *testing.T, outs []outSpec) ([]Screen, error) { +func dialFakeOutputs(t *testing.T, outs []outSpec) (ScreenList, error) { t.Helper() cli, srv := socketPairWin(t) t.Cleanup(func() { _ = srv.Close() }) @@ -96,7 +96,7 @@ func dialFakeOutputs(t *testing.T, outs []outSpec) ([]Screen, error) { } func TestWaylandScreensReadTheOutputBurst(t *testing.T) { - screens, err := dialFakeOutputs(t, []outSpec{ + screens, err := allOf(dialFakeOutputs(t, []outSpec{ // A 2x laptop panel: 2560x1440 device pixels are 1280x720 points. {Make: "Sharp", Model: "LQ133M1", Connector: "eDP-1", Descr: "the built-in panel", PhysWMM: 294, PhysHMM: 165, @@ -106,7 +106,7 @@ func TestWaylandScreensReadTheOutputBurst(t *testing.T) { {X: 1280, Make: "DELL", Model: "U2720Q", Connector: "DP-2", PhysWMM: 597, PhysHMM: 336, ModeW: 1920, ModeH: 1080, Refresh: 59951, Scale: 1}, - }) + })) if err != nil { t.Fatalf("screensOnWayland: %v", err) } @@ -130,9 +130,9 @@ func TestWaylandScreensSwapTheAxesOfARotatedPanel(t *testing.T) { // transform 1 is a quarter turn: a 1080x1920 panel in portrait is a // 1920x1080 mode with its axes swapped, and reporting it unswapped would // overlap whatever sits beside it with nothing saying so. - screens, err := dialFakeOutputs(t, []outSpec{ + screens, err := allOf(dialFakeOutputs(t, []outSpec{ {Model: "Portrait", Transform: 1, ModeW: 1920, ModeH: 1080, Scale: 1}, - }) + })) if err != nil { t.Fatalf("screensOnWayland: %v", err) } @@ -162,7 +162,7 @@ func TestWaylandScreenNamePrefersTheModel(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { tc.out.ModeW, tc.out.ModeH, tc.out.Scale = 800, 600, 1 - screens, err := dialFakeOutputs(t, []outSpec{tc.out}) + screens, err := allOf(dialFakeOutputs(t, []outSpec{tc.out})) if err != nil { t.Fatalf("screensOnWayland: %v", err) } @@ -184,9 +184,9 @@ func TestWaylandScreensWithNoOutputAtAll(t *testing.T) { func TestWaylandScreensIgnoreAnUnfinishedBurst(t *testing.T) { // Properties published without a closing done describe nothing yet: acting // on half a burst would place the output where the compositor never said. - screens, err := dialFakeOutputs(t, []outSpec{ + screens, err := allOf(dialFakeOutputs(t, []outSpec{ {X: 500, Model: "Half", ModeW: 1920, ModeH: 1080, Scale: 2, SkipDone: true}, - }) + })) if err != nil { t.Fatalf("screensOnWayland: %v", err) } diff --git a/screen_windows.go b/screen_windows.go index 1d646d6..e55951d 100644 --- a/screen_windows.go +++ b/screen_windows.go @@ -54,7 +54,7 @@ import ( // See [Screen] for what the fields mean, and winScreensOf for the one place // Windows genuinely differs from the other back-ends: it has no single logical // coordinate space, so on a mixed-DPI desktop these rectangles do not tile. -func Screens() ([]Screen, error) { +func Screens() (ScreenList, error) { // Per-Monitor-V2 first, and its result is deliberately ignored: it fails // when awareness has ALREADY been set, by an earlier call or by the // application manifest, which is not a problem — the process is aware, it @@ -66,7 +66,7 @@ func Screens() ([]Screen, error) { handles = append(handles, m) return true }); err != nil { - return nil, fmt.Errorf("window: cannot enumerate displays: %w", err) + return ScreenList{}, fmt.Errorf("window: cannot enumerate displays: %w", err) } // Describing the monitors happens OUTSIDE the enumeration callback. The @@ -104,9 +104,9 @@ func Screens() ([]Screen, error) { }) } if len(mons) == 0 { - return nil, fmt.Errorf("window: the desktop reports no display") + return ScreenList{}, fmt.Errorf("window: the desktop reports no display: %w", ErrNoScreens) } - return winScreensOf(mons), nil + return newScreenList(winScreensOf(mons)) } // VisibleScreenSize returns the usable area of the primary display in LOGICAL @@ -116,10 +116,10 @@ func Screens() ([]Screen, error) { // See [Screens], which supersedes it for anything multi-display. func VisibleScreenSize() (w, h int, ok bool) { screens, err := Screens() - if err != nil || len(screens) == 0 { + if err != nil { return 0, 0, false } - s := screens[0] + s := screens.Primary() if s.VisibleWidth <= 0 || s.VisibleHeight <= 0 { return 0, 0, false } diff --git a/screen_x11.go b/screen_x11.go index 4c25159..8c6bf09 100644 --- a/screen_x11.go +++ b/screen_x11.go @@ -35,14 +35,14 @@ import ( // has several, but they are separate coordinate spaces that no window can move // between, so listing them together would describe a desktop that does not // exist. -func x11Screens(disp string) ([]Screen, error) { +func x11Screens(disp string) (ScreenList, error) { d, err := parseDisplay(disp) if err != nil { - return nil, err + return ScreenList{}, err } conn, err := dialAuthenticated(disp) if err != nil { - return nil, err + return ScreenList{}, err } defer func() { _ = conn.Close() }() return screensOn(conn, d.screen) @@ -50,15 +50,15 @@ func x11Screens(disp string) ([]Screen, error) { // screensOn is Screens with the connection already open, which is what makes // the whole projection testable against a scripted server. -func screensOn(conn *x11.Conn, screen int) ([]Screen, error) { +func screensOn(conn *x11.Conn, screen int) (ScreenList, error) { sc := conn.Setup().ScreenOf(screen) if sc == nil { - return nil, fmt.Errorf("window: DISPLAY names screen %d, and this server has %d", + return ScreenList{}, fmt.Errorf("window: DISPLAY names screen %d, and this server has %d", screen, len(conn.Setup().Screens)) } mons, err := conn.Monitors(screen) if err != nil { - return nil, err + return ScreenList{}, err } // One scale for the whole desktop, because that is all X11 has: Xft.dpi is // a resource on the root window, not a property of a panel. A mixed-DPI X11 @@ -100,7 +100,7 @@ func screensOn(conn *x11.Conn, screen int) ([]Screen, error) { s.VisibleHeight = points(vh, scale) out = append(out, s) } - return primaryFirst(out), nil + return newScreenList(out) } // points converts device pixels to logical points. The X11 back-end scales by diff --git a/screen_x11_test.go b/screen_x11_test.go index c80e878..4b00c5c 100644 --- a/screen_x11_test.go +++ b/screen_x11_test.go @@ -209,7 +209,7 @@ func TestScreensOnProjectsPixelsOntoPoints(t *testing.T) { conn := dialScripted(t, randrScreenScript(mons, "Xft.dpi:\t192\n", []uint32{0, 27, 3840, 1053})) - screens, err := screensOn(conn, 0) + screens, err := allOf(screensOn(conn, 0)) if err != nil { t.Fatalf("screensOn: %v", err) } @@ -240,7 +240,7 @@ func TestScreensOnWithNoWindowManagerAndNoScale(t *testing.T) { mons := []monSpec{{NameAtom: 0x40, Name: "screen", Width: 1920, Height: 1080}} conn := dialScripted(t, randrScreenScript(mons, "", nil)) - screens, err := screensOn(conn, 0) + screens, err := allOf(screensOn(conn, 0)) if err != nil { t.Fatalf("screensOn: %v", err) } @@ -256,7 +256,7 @@ func TestScreensOnSurvivesAServerThatAnswersNothing(t *testing.T) { // itself is still a display, and a caller that asked for a list must not // get an empty one. conn := dialScripted(t, nil) - screens, err := screensOn(conn, 0) + screens, err := allOf(screensOn(conn, 0)) if err != nil { t.Fatalf("screensOn: %v", err) } diff --git a/screenlist.go b/screenlist.go new file mode 100644 index 0000000..3e80894 --- /dev/null +++ b/screenlist.go @@ -0,0 +1,100 @@ +// Copyright (c) the go-widgets/window authors. All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package window + +import "fmt" + +// ScreenList is the display list of a live system: NEVER EMPTY, primary first, +// and with exactly one primary — by construction rather than by convention. +// +// ⛔⛔ BOTH HALVES WERE CONVENTIONS, AND ONE OF THEM COST SOMEBODY THEIR DESK. +// Screens used to answer []Screen, so nothing stopped an empty one arriving, +// and the darwin back-end sent one: liveDisplays returned (nil, nil) when the +// window server counted zero, which is a failed read wearing the clothes of a +// fact. go-xrkit/desk looks its display up by name, found none, and quit -- +// twice on 2026-09-08, on a headset somebody was wearing: +// +// desk: "VITURE Beast" is not attached any more; there is -- stopping +// +// Nothing after "there is": the message names every attached display and named +// NONE. Three of the four back-ends already refused to say it (Windows and +// Wayland returned an error, and primaryBounds called it ErrDisplayList); the +// fourth was the one that shipped. +// +// ⭐ AND THE PRIMARY WAS A REPAIR RATHER THAN A GUARANTEE. primaryFirst walked +// the slice unsetting duplicate flags -- "only one may claim it" -- and every +// caller that wanted the main display then scanned for the flag again. It is a +// field of the list, not a property to go looking for. +// +// The zero ScreenList is empty and reports itself so; there is no way to build +// a populated one but [newScreenList], which is where both rules live. +type ScreenList struct { + // all is primary-first, exactly one Primary, and either empty (the zero + // value) or complete. It is never partially built. + all []Screen +} + +// newScreenList is the only way a populated ScreenList comes into being. +// +// It REFUSES an empty slice, because a live display server always has a screen: +// somebody is looking at something. A back-end that has nothing to report has +// failed to read, and must say so as an error rather than as a list. +func newScreenList(screens []Screen) (ScreenList, error) { + if len(screens) == 0 { + return ScreenList{}, fmt.Errorf( + "window: the display server reported no screen at all, which a live "+ + "one does not have -- this is a read that failed, not a machine "+ + "with nothing attached: %w", ErrNoScreens) + } + return ScreenList{all: primaryFirst(screens)}, nil +} + +// ErrNoScreens is what a back-end that could not read the display list reports. +// +// It is deliberately NOT [ErrScreensUnsupported]: that one means this platform +// has no way to enumerate at all, which is a fact about the build. This one +// means the enumeration ran and came back with nothing, which is a fact about +// the moment and may be different a second later. +var ErrNoScreens = fmt.Errorf("window: no screens in the display list") + +// Len is how many screens there are. It is at least 1 for any list a back-end +// returned, and 0 only for the zero value. +func (l ScreenList) Len() int { return len(l.all) } + +// Primary is the display that owns the desktop's origin. +// +// ⭐ A FIELD RATHER THAN A SEARCH. Every caller that wanted it used to loop +// looking for Screen.Primary, which is a scan for something the list already +// knew — and a scan that has to decide what to do when it finds none. +// +// The zero value has no screens and returns the zero Screen, which +// [Screen.IsZero] reports. +func (l ScreenList) Primary() Screen { + if len(l.all) == 0 { + return Screen{} + } + return l.all[0] +} + +// All is every screen, primary first. +// +// The slice is the list's own: reading it is free, and writing to it would +// break the guarantees this type exists for. Copy it if you mean to sort it. +func (l ScreenList) All() []Screen { return l.all } + +// ByName is the screen called name, and whether there is one. +// +// ⭐ IT IS HERE BECAUSE EVERY CALLER WROTE IT. Looking a display up by name is +// what survives the desktop being rearranged — creating a virtual display moves +// the others, so a rectangle captured a moment ago names nothing — and three +// separate loops were doing it. +func (l ScreenList) ByName(name string) (Screen, bool) { + for _, s := range l.all { + if s.Name == name { + return s, true + } + } + return Screen{}, false +} diff --git a/screenlist_test.go b/screenlist_test.go new file mode 100644 index 0000000..7c800ad --- /dev/null +++ b/screenlist_test.go @@ -0,0 +1,116 @@ +// Copyright (c) the go-widgets/window authors. All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package window + +import ( + "errors" + "testing" +) + +// allOf adapts a ScreenList back to a slice, for the back-end tests written +// before the type existed. They are about the geometry a protocol reports, not +// about the list, so they read better unchanged. +func allOf(l ScreenList, err error) ([]Screen, error) { return l.All(), err } + +// TestAnEmptyDisplayListIsRefused. +// +// ⛔⛔ THIS IS THE DEFECT THE TYPE EXISTS FOR. Screens used to answer []Screen, +// so an empty one could arrive, and the darwin back-end sent one: liveDisplays +// returned (nil, nil) when the window server counted zero. go-xrkit/desk looks +// its display up by name, found none, and quit -- twice on 2026-09-08, on a +// headset somebody was wearing. +func TestAnEmptyDisplayListIsRefused(t *testing.T) { + for _, in := range [][]Screen{nil, {}} { + l, err := newScreenList(in) + if !errors.Is(err, ErrNoScreens) { + t.Errorf("newScreenList(%v) = %v, want ErrNoScreens", in, err) + } + if l.Len() != 0 { + t.Errorf("a refused list came back with %d screens", l.Len()) + } + } + // ⛔ AND ErrNoScreens IS NOT ErrScreensUnsupported. One says this build + // cannot enumerate at all, which no retry will change; the other says the + // enumeration ran and came back empty, which a second later may not. + if errors.Is(ErrNoScreens, ErrScreensUnsupported) || + errors.Is(ErrScreensUnsupported, ErrNoScreens) { + t.Error("the two sentinels answer to each other; they mean different things") + } +} + +// ⭐ EXACTLY ONE PRIMARY, FIRST, WHATEVER THE BACK-END HANDED OVER. This used +// to be primaryFirst applied by discipline, and every caller that wanted the +// main display then scanned for the flag again. +func TestTheListHasOnePrimaryAndItLeads(t *testing.T) { + for _, c := range []struct { + name string + in []Screen + want string // the name that must end up primary and first + }{ + {"the flagged one moves to the front", + []Screen{{Name: "a"}, {Name: "b", Primary: true}, {Name: "c"}}, "b"}, + {"two claims, the first wins", + []Screen{{Name: "a", Primary: true}, {Name: "b", Primary: true}}, "a"}, + {"nobody claims it, the first is it", + []Screen{{Name: "a"}, {Name: "b"}}, "a"}, + } { + t.Run(c.name, func(t *testing.T) { + l, err := newScreenList(c.in) + if err != nil { + t.Fatalf("newScreenList: %v", err) + } + if got := l.Primary().Name; got != c.want { + t.Errorf("Primary() = %q, want %q", got, c.want) + } + all := l.All() + if all[0].Name != c.want || !all[0].Primary { + t.Errorf("All()[0] = %+v, want %q flagged primary", all[0], c.want) + } + n := 0 + for _, s := range all { + if s.Primary { + n++ + } + } + if n != 1 { + t.Errorf("%d screens claim to be primary, want exactly 1", n) + } + if l.Len() != len(c.in) { + t.Errorf("Len() = %d, want %d: no screen may be lost", l.Len(), len(c.in)) + } + }) + } +} + +func TestByNameFindsAndRefuses(t *testing.T) { + l, err := newScreenList([]Screen{{Name: "Color LCD"}, {Name: "VITURE Beast"}}) + if err != nil { + t.Fatalf("newScreenList: %v", err) + } + if s, ok := l.ByName("VITURE Beast"); !ok || s.Name != "VITURE Beast" { + t.Errorf("ByName(%q) = %+v, %v", "VITURE Beast", s, ok) + } + if s, ok := l.ByName("nothing plugged in here"); ok { + t.Errorf("ByName found %+v for a name that is not there", s) + } +} + +// The zero value says it is empty rather than pretending, and hands back a +// Screen that reports itself zero. +func TestTheZeroListIsEmptyAndSaysSo(t *testing.T) { + var l ScreenList + if l.Len() != 0 { + t.Errorf("the zero ScreenList has %d screens", l.Len()) + } + if !l.Primary().IsZero() { + t.Errorf("the zero ScreenList has a primary: %+v", l.Primary()) + } + if l.All() != nil { + t.Errorf("the zero ScreenList's All() is %v, want nil", l.All()) + } + if _, ok := l.ByName("anything"); ok { + t.Error("the zero ScreenList found a screen by name") + } +}