feat: render minimal flight OSD from supervisor snapshots (#3)
This commit is contained in:
@@ -4,7 +4,8 @@
|
||||
// when told to. Each target drives two independent visual layers: a
|
||||
// fullscreen fallback/menu canvas below video, visible while decoded pilot
|
||||
// video is absent, and a transparent, input-transparent OSD overlay above
|
||||
// video, reserved as the future flight OSD rendering surface. When video
|
||||
// video that renders deliberately minimal flight telemetry from
|
||||
// supervisor-published OSD snapshots. When video
|
||||
// output is presented the video window takes rendering priority and the
|
||||
// fallback disappears, returning when video ends. Presence is observed from
|
||||
// the video output itself on the display server, never from WFB or launcher
|
||||
|
||||
@@ -254,7 +254,7 @@ func TestRunLayersDrawsEveryLayerOnItsOwnCanvas(t *testing.T) {
|
||||
{
|
||||
Name: "osd-overlay",
|
||||
Opener: fakeOpener{open: func(int) (Session, error) { return osdSession, nil }},
|
||||
Template: NewOSDTemplate(NewVideoPresence()),
|
||||
Template: NewOSDTemplate(NewVideoPresence(), stubSource{}),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -296,7 +296,7 @@ func TestRunLayersKeepsOtherLayerAliveWhenOneTargetFails(t *testing.T) {
|
||||
{
|
||||
Name: "alive",
|
||||
Opener: fakeOpener{open: func(int) (Session, error) { return alive, nil }},
|
||||
Template: NewOSDTemplate(NewVideoPresence()),
|
||||
Template: NewOSDTemplate(NewVideoPresence(), stubSource{}),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+51
-13
@@ -1,23 +1,61 @@
|
||||
package display
|
||||
|
||||
// OSDTemplate is the flight OSD layout of a display target: a transparent,
|
||||
// input-transparent surface above pilot video, reserved as the future OSD
|
||||
// rendering surface. It presents nothing yet and never draws the fallback:
|
||||
// the OSD is a telemetry surface, not a video substitute, and WFB or video
|
||||
// decode never move into the UI process to feed it. Flight telemetry
|
||||
// rendering arrives in later Ground Station work; the shared presence keeps
|
||||
// the OSD on the same video lifecycle as the fallback canvas, so the session
|
||||
// redraws it on every presence transition.
|
||||
type OSDTemplate struct {
|
||||
video *VideoPresence
|
||||
import (
|
||||
"git.piksar.eu/piotr/groundstation/internal/osd"
|
||||
)
|
||||
|
||||
// SnapshotSource serves the latest supervisor-published OSD snapshot while
|
||||
// it is fresh. The file-backed implementation lives in the osd package; the
|
||||
// template only consumes the contract.
|
||||
type SnapshotSource interface {
|
||||
Latest() (osd.Snapshot, bool)
|
||||
}
|
||||
|
||||
// NewOSDTemplate builds the OSD surface for a target.
|
||||
func NewOSDTemplate(video *VideoPresence) OSDTemplate {
|
||||
return OSDTemplate{video: video}
|
||||
// OSDTemplate is the flight OSD layout of a display target: a transparent,
|
||||
// input-transparent surface above pilot video. While decoded video is
|
||||
// absent the fallback canvas owns the screen and the OSD presents nothing;
|
||||
// while video is present the OSD draws deliberately minimal telemetry from
|
||||
// the latest published snapshot — arm state and requested CH6 WFB power.
|
||||
// The rendering gate is the video presence the display observes on the
|
||||
// display server, never the snapshot's Video field: the supervisor's field
|
||||
// is its launcher knowledge, while the display trusts only the presented
|
||||
// video output itself. The OSD never draws the fallback: it is a telemetry
|
||||
// surface, not a video substitute, and WFB or video decode never move into
|
||||
// the UI process to feed it. The OSD refreshes on its own slower cadence
|
||||
// than the video and the video window keeps rendering priority.
|
||||
type OSDTemplate struct {
|
||||
video *VideoPresence
|
||||
source SnapshotSource
|
||||
}
|
||||
|
||||
// NewOSDTemplate builds the OSD surface for a target from the shared video
|
||||
// presence and the supervisor-published snapshot source.
|
||||
func NewOSDTemplate(video *VideoPresence, source SnapshotSource) OSDTemplate {
|
||||
return OSDTemplate{video: video, source: source}
|
||||
}
|
||||
|
||||
func (t OSDTemplate) Draw(canvas Canvas) {
|
||||
canvas.Clear()
|
||||
if t.video.Active() {
|
||||
if snapshot, ok := t.source.Latest(); ok {
|
||||
drawOSD(canvas, snapshot)
|
||||
}
|
||||
}
|
||||
canvas.Flush()
|
||||
}
|
||||
|
||||
// drawOSD renders the deliberately minimal flight telemetry: two short
|
||||
// lines in the top-left corner. The full final RC state stays in the
|
||||
// snapshot; the pilot-facing overlay stays terse.
|
||||
func drawOSD(canvas Canvas, snapshot osd.Snapshot) {
|
||||
arm := "OFF"
|
||||
if snapshot.Armed {
|
||||
arm = "ARMED"
|
||||
}
|
||||
power := "OFF"
|
||||
if snapshot.Power {
|
||||
power = "ON"
|
||||
}
|
||||
canvas.Text(32, 32, "ARM "+arm)
|
||||
canvas.Text(32, 64, "PWR "+power)
|
||||
}
|
||||
|
||||
@@ -1,43 +1,125 @@
|
||||
package display
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.piksar.eu/piotr/groundstation/internal/osd"
|
||||
)
|
||||
|
||||
// stubSource serves one fixed snapshot, or none.
|
||||
type stubSource struct {
|
||||
snapshot osd.Snapshot
|
||||
ok bool
|
||||
}
|
||||
|
||||
func (s stubSource) Latest() (osd.Snapshot, bool) { return s.snapshot, s.ok }
|
||||
|
||||
func armedSource(sequence uint16) stubSource {
|
||||
return stubSource{snapshot: osd.Snapshot{Sequence: sequence, Armed: true, Power: true}, ok: true}
|
||||
}
|
||||
|
||||
func TestOSDTemplateStaysTransparentWhileVideoAbsent(t *testing.T) {
|
||||
canvas := &fakeCanvas{width: 800, height: 600}
|
||||
|
||||
NewOSDTemplate(NewVideoPresence()).Draw(canvas)
|
||||
NewOSDTemplate(NewVideoPresence(), armedSource(1)).Draw(canvas)
|
||||
|
||||
clears, flushes := canvas.drawCounts()
|
||||
if clears != 1 || flushes != 1 {
|
||||
t.Fatalf("clears=%d flushes=%d want 1 and 1", clears, flushes)
|
||||
}
|
||||
if lines := canvas.lines(); len(lines) != 0 {
|
||||
t.Fatalf("OSD lines=%q want none: the OSD is a telemetry surface, not a fallback", lines)
|
||||
t.Fatalf("OSD lines=%q want none: without video the fallback canvas owns the screen", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOSDTemplateStaysTransparentWhileVideoPresent(t *testing.T) {
|
||||
func TestOSDTemplateDrawsMinimalTelemetryWhileVideoPresent(t *testing.T) {
|
||||
canvas := &fakeCanvas{width: 800, height: 600}
|
||||
presence := NewVideoPresence()
|
||||
presence.SetActive(true)
|
||||
|
||||
NewOSDTemplate(presence).Draw(canvas)
|
||||
NewOSDTemplate(presence, armedSource(7)).Draw(canvas)
|
||||
|
||||
clears, flushes := canvas.drawCounts()
|
||||
if clears != 1 || flushes != 1 {
|
||||
t.Fatalf("clears=%d flushes=%d want 1 and 1", clears, flushes)
|
||||
}
|
||||
lines := canvas.lines()
|
||||
want := []string{"ARM ARMED", "PWR ON"}
|
||||
if len(lines) != len(want) {
|
||||
t.Fatalf("lines=%q want %q", lines, want)
|
||||
}
|
||||
for i := range want {
|
||||
if lines[i] != want[i] {
|
||||
t.Fatalf("line %d=%q want %q", i, lines[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOSDTemplateDrawsDisarmedAndPoweredOffState(t *testing.T) {
|
||||
canvas := &fakeCanvas{width: 800, height: 600}
|
||||
presence := NewVideoPresence()
|
||||
presence.SetActive(true)
|
||||
|
||||
NewOSDTemplate(presence, stubSource{ok: true}).Draw(canvas)
|
||||
|
||||
lines := canvas.lines()
|
||||
want := []string{"ARM OFF", "PWR OFF"}
|
||||
if len(lines) != len(want) {
|
||||
t.Fatalf("lines=%q want %q", lines, want)
|
||||
}
|
||||
for i := range want {
|
||||
if lines[i] != want[i] {
|
||||
t.Fatalf("line %d=%q want %q", i, lines[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOSDTemplateStaysTransparentWithoutASnapshotWhileVideoPresent(t *testing.T) {
|
||||
canvas := &fakeCanvas{width: 800, height: 600}
|
||||
presence := NewVideoPresence()
|
||||
presence.SetActive(true)
|
||||
|
||||
NewOSDTemplate(presence, stubSource{}).Draw(canvas)
|
||||
|
||||
if lines := canvas.lines(); len(lines) != 0 {
|
||||
t.Fatalf("OSD lines=%q want none while video is present", lines)
|
||||
t.Fatalf("OSD lines=%q want none without a published snapshot", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOSDTemplateGatesOnObservedVideoPresenceNotSnapshotVideo(t *testing.T) {
|
||||
// The rendering gate is the video lifecycle the display observes on the
|
||||
// display server, never the snapshot's Video field: the supervisor's
|
||||
// field is its launcher knowledge, while the display trusts only the
|
||||
// presented video output itself.
|
||||
canvas := &fakeCanvas{width: 800, height: 600}
|
||||
presence := NewVideoPresence()
|
||||
|
||||
claimingVideo := stubSource{snapshot: osd.Snapshot{Armed: true, Video: true}, ok: true}
|
||||
NewOSDTemplate(presence, claimingVideo).Draw(canvas)
|
||||
if lines := canvas.lines(); len(lines) != 0 {
|
||||
t.Fatalf("lines=%q want none: snapshot presence must never replace the observed lifecycle", lines)
|
||||
}
|
||||
|
||||
denyingVideo := stubSource{snapshot: osd.Snapshot{Armed: true}, ok: true}
|
||||
presence.SetActive(true)
|
||||
NewOSDTemplate(presence, denyingVideo).Draw(canvas)
|
||||
if lines := canvas.lines(); len(lines) == 0 {
|
||||
t.Fatal("the observed video lifecycle must gate rendering even when the snapshot denies video")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOSDTemplateNeverDrawsTheFallbackText(t *testing.T) {
|
||||
canvas := &fakeCanvas{width: 800, height: 600}
|
||||
presence := NewVideoPresence()
|
||||
template := NewOSDTemplate(presence)
|
||||
template := NewOSDTemplate(presence, armedSource(3))
|
||||
|
||||
for _, active := range []bool{false, true, false} {
|
||||
presence.SetActive(active)
|
||||
template.Draw(canvas)
|
||||
if lines := canvas.lines(); len(lines) != 0 {
|
||||
t.Fatalf("lines=%q after presence=%v want none", lines, active)
|
||||
for _, line := range canvas.lines() {
|
||||
if line == wantFallbackText {
|
||||
t.Fatalf("the OSD is a telemetry surface, not a fallback: drew %q", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +133,30 @@ var ewmhNonVideoTypes = map[string]bool{
|
||||
"POPUP_MENU": true,
|
||||
}
|
||||
|
||||
// osdShapePadding widens each punched output region by this many pixels on
|
||||
// every side, so glyph antialiasing artifacts at fixed-font edges never
|
||||
// clip visibly.
|
||||
const osdShapePadding = 2
|
||||
|
||||
// osdFallbackFontMetrics are the font metrics used when the font query
|
||||
// fails: close to the common 6x13 fixed cell so a punched region still
|
||||
// covers the glyphs. The OSD only ever draws terse ASCII telemetry.
|
||||
func osdFallbackFontMetrics() (ascent, descent, cell int16) {
|
||||
return 11, 3, 6
|
||||
}
|
||||
|
||||
// osdTextRect is the output region covering one drawn text: its glyph cell
|
||||
// around the baseline, widened by the shape padding.
|
||||
func osdTextRect(x, y, units int, cell, ascent, descent int16) xproto.Rectangle {
|
||||
pad := int16(osdShapePadding)
|
||||
return xproto.Rectangle{
|
||||
X: int16(x) - pad,
|
||||
Y: int16(y) - ascent - pad,
|
||||
Width: uint16(units*int(cell) + 2*int(pad)),
|
||||
Height: uint16(int(ascent+descent) + 2*int(pad)),
|
||||
}
|
||||
}
|
||||
|
||||
// X11Opener connects to an X server (selected by DISPLAY in the environment)
|
||||
// and maps one fullscreen kiosk window for the layer role it is built with:
|
||||
// the fallback/menu canvas below video (RoleBelow) or the transparent,
|
||||
@@ -143,6 +167,12 @@ type X11Opener struct {
|
||||
// PollInterval is how often X events are drained while waiting, so that
|
||||
// cancellation stays responsive. Zero selects 100 ms.
|
||||
PollInterval time.Duration
|
||||
// RefreshInterval is how often the layer redraws without an X event,
|
||||
// so template data that changes independently of the display server —
|
||||
// the OSD overlay's published snapshots — appears at the OSD's own
|
||||
// slower cadence. Zero keeps the purely event-driven redraws of the
|
||||
// fallback canvas.
|
||||
RefreshInterval time.Duration
|
||||
// Fullscreen applies the fullscreen window state. It defaults to
|
||||
// shelling out to wmctrl; tests may replace it. A failure is logged and
|
||||
// the window degrades to a plain window.
|
||||
@@ -326,6 +356,18 @@ func (o X11Opener) Open(ctx context.Context) (Session, error) {
|
||||
xproto.GcForeground|xproto.GcBackground|xproto.GcFont,
|
||||
[]uint32{screen.WhitePixel, screen.BlackPixel, uint32(font)})
|
||||
|
||||
// The OSD overlay punches its output shape per drawn region, so it
|
||||
// needs the real glyph metrics; the canvas never punches shapes.
|
||||
ascent, descent, cell := osdFallbackFontMetrics()
|
||||
if o.Role == RoleAbove {
|
||||
if info, err := xproto.QueryFont(conn, xproto.Fontable(font)).Reply(); err == nil {
|
||||
ascent, descent = int16(info.FontAscent), int16(info.FontDescent)
|
||||
if width := int16(info.MaxBounds.CharacterWidth); width > 0 {
|
||||
cell = width
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &x11Session{
|
||||
conn: conn,
|
||||
window: window,
|
||||
@@ -335,12 +377,16 @@ func (o X11Opener) Open(ctx context.Context) (Session, error) {
|
||||
black: screen.BlackPixel,
|
||||
white: screen.WhitePixel,
|
||||
poll: o.pollInterval(),
|
||||
refresh: o.RefreshInterval,
|
||||
video: presence,
|
||||
videoWindows: videoWindowSet(videoWindows),
|
||||
typeAtom: typeAtom,
|
||||
markerAtom: markerAtom,
|
||||
role: o.Role,
|
||||
unicodeFont: unicodeFont,
|
||||
fontAscent: ascent,
|
||||
fontDescent: descent,
|
||||
fontCell: cell,
|
||||
pollEvent: conn.PollForEvent,
|
||||
probe: connLivenessProbe(conn),
|
||||
}, nil
|
||||
@@ -596,12 +642,21 @@ type x11Session struct {
|
||||
black uint32
|
||||
white uint32
|
||||
poll time.Duration
|
||||
refresh time.Duration
|
||||
video *VideoPresence
|
||||
videoWindows map[xproto.Window]struct{}
|
||||
typeAtom xproto.Atom
|
||||
markerAtom xproto.Atom
|
||||
role LayerRole
|
||||
unicodeFont bool
|
||||
// fontAscent, fontDescent and fontCell describe the drawn font so the
|
||||
// OSD overlay can punch its output shape around each text's glyph cell.
|
||||
fontAscent int16
|
||||
fontDescent int16
|
||||
fontCell int16
|
||||
// drawn accumulates the output regions of texts drawn since the last
|
||||
// clear; the OSD overlay's flush punches its bounding shape over it.
|
||||
drawn []xproto.Rectangle
|
||||
// pollEvent drains one pending X event; conn.PollForEvent in
|
||||
// production. Tests script it.
|
||||
pollEvent func() (xgb.Event, xgb.Error)
|
||||
@@ -615,18 +670,27 @@ func (s *x11Session) Canvas() Canvas { return s }
|
||||
func (s *x11Session) Size() (int, int) { return s.width, s.height }
|
||||
|
||||
func (s *x11Session) Clear() {
|
||||
// A fresh draw pass starts with no output regions: the OSD overlay's
|
||||
// next flush re-punches its shape over exactly what this pass draws.
|
||||
s.resetOSDRegions()
|
||||
// ClearArea tiles the window background (black, set at creation); it
|
||||
// involves no graphics context.
|
||||
xproto.ClearArea(s.conn, false, s.window, 0, 0,
|
||||
uint16(s.width), uint16(s.height))
|
||||
}
|
||||
|
||||
// resetOSDRegions forgets the output regions of the previous draw pass.
|
||||
func (s *x11Session) resetOSDRegions() {
|
||||
s.drawn = s.drawn[:0]
|
||||
}
|
||||
|
||||
func (s *x11Session) Text(x, y int, message string) {
|
||||
xproto.ChangeGC(s.conn, s.gc, xproto.GcForeground, []uint32{s.white})
|
||||
if s.unicodeFont {
|
||||
chars := utf16Char2b(message, maxImageText16Units)
|
||||
xproto.ImageText16(s.conn, byte(len(chars)), xproto.Drawable(s.window), s.gc,
|
||||
int16(x), int16(y), chars)
|
||||
s.recordOSDText(x, y, len(chars))
|
||||
return
|
||||
}
|
||||
message = foldToASCII(message)
|
||||
@@ -635,15 +699,52 @@ func (s *x11Session) Text(x, y int, message string) {
|
||||
}
|
||||
xproto.ImageText8(s.conn, byte(len(message)), xproto.Drawable(s.window), s.gc,
|
||||
int16(x), int16(y), message)
|
||||
s.recordOSDText(x, y, len(message))
|
||||
}
|
||||
|
||||
// recordOSDText remembers one drawn text's output region so the overlay's
|
||||
// flush can punch its bounding shape over exactly what was drawn. Only the
|
||||
// OSD overlay punches shapes; the fallback canvas always stays plain.
|
||||
func (s *x11Session) recordOSDText(x, y, units int) {
|
||||
if s.role != RoleAbove || units <= 0 {
|
||||
return
|
||||
}
|
||||
s.drawn = append(s.drawn,
|
||||
osdTextRect(x, y, units, s.fontCell, s.fontAscent, s.fontDescent))
|
||||
}
|
||||
|
||||
func (s *x11Session) Flush() {
|
||||
// The OSD overlay presents pixels only where this draw pass put text:
|
||||
// its bounding shape is re-punched over the recorded regions while its
|
||||
// input shape stays empty, so it never covers video elsewhere and
|
||||
// never intercepts input. A failure here is a broken connection; the
|
||||
// liveness probe below ends the session and the layer reconnects.
|
||||
if s.role == RoleAbove {
|
||||
if err := applyOSDShapes(s.conn, s.window, s.drawn); err != nil {
|
||||
log.Printf("osd overlay shapes: %v", err)
|
||||
}
|
||||
}
|
||||
s.conn.Sync()
|
||||
}
|
||||
|
||||
// applyOSDShapes sets the overlay's bounding shape to the drawn regions
|
||||
// (possibly none) and keeps its input shape empty.
|
||||
func applyOSDShapes(conn *xgb.Conn, window xproto.Window, rects []xproto.Rectangle) error {
|
||||
if err := shape.RectanglesChecked(conn, shape.SoSet, shape.SkBounding, 0,
|
||||
window, 0, 0, rects).Check(); err != nil {
|
||||
return fmt.Errorf("bounding shape: %w", err)
|
||||
}
|
||||
if err := shape.RectanglesChecked(conn, shape.SoSet, shape.SkInput, 0,
|
||||
window, 0, 0, nil).Check(); err != nil {
|
||||
return fmt.Errorf("empty input shape: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *x11Session) WaitEvent(ctx context.Context) (bool, error) {
|
||||
ticker := time.NewTicker(s.poll)
|
||||
defer ticker.Stop()
|
||||
lastRefresh := time.Now()
|
||||
for {
|
||||
for {
|
||||
event, err := s.pollEvent()
|
||||
@@ -660,6 +761,14 @@ func (s *x11Session) WaitEvent(ctx context.Context) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
// The OSD overlay redraws on its own slower cadence than video so
|
||||
// newly published snapshots appear without waiting for X events.
|
||||
// A zero interval disables it, keeping the fallback canvas purely
|
||||
// event-driven.
|
||||
if s.refresh > 0 && time.Since(lastRefresh) >= s.refresh {
|
||||
lastRefresh = time.Now()
|
||||
return true, nil
|
||||
}
|
||||
// Round trip keeps the connection liveness visible even without
|
||||
// incoming events; the reply-bearing cookie is resolved with Reply
|
||||
// (see connLivenessProbe).
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/BurntSushi/xgb"
|
||||
"github.com/BurntSushi/xgb/shape"
|
||||
"github.com/BurntSushi/xgb/xproto"
|
||||
)
|
||||
@@ -297,3 +300,93 @@ func TestTransparentShapesEmptyInputAndOutput(t *testing.T) {
|
||||
t.Fatalf("shape kinds %v must empty both the input and the bounding region", transparentShapeKinds())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOSDTextRectCoversTheGlyphCellAroundTheBaseline(t *testing.T) {
|
||||
rect := osdTextRect(32, 32, 9, 6, 11, 3)
|
||||
if rect.X != 30 || rect.Y != 19 {
|
||||
t.Fatalf("origin=(%d,%d) want (30,19): the region must start left of and above the baseline", rect.X, rect.Y)
|
||||
}
|
||||
if rect.Width != 9*6+4 || rect.Height != 11+3+4 {
|
||||
t.Fatalf("size=%dx%d want %dx%d with padding", rect.Width, rect.Height, 9*6+4, 11+3+4)
|
||||
}
|
||||
if osdTextRect(0, 0, 1, 6, 11, 3).Width == 0 {
|
||||
t.Fatal("a one-unit text must still produce a visible region")
|
||||
}
|
||||
}
|
||||
|
||||
func TestX11SessionRecordsOSDTextRegionsOnlyAboveVideo(t *testing.T) {
|
||||
above := &x11Session{role: RoleAbove, fontCell: 6, fontAscent: 11, fontDescent: 3}
|
||||
above.recordOSDText(32, 32, 9)
|
||||
above.recordOSDText(32, 64, 5)
|
||||
if len(above.drawn) != 2 {
|
||||
t.Fatalf("drawn=%v want one region per OSD text", above.drawn)
|
||||
}
|
||||
if above.drawn[0] != osdTextRect(32, 32, 9, 6, 11, 3) {
|
||||
t.Fatalf("first region=%v want the glyph cell of the first text", above.drawn[0])
|
||||
}
|
||||
|
||||
above.resetOSDRegions()
|
||||
if len(above.drawn) != 0 {
|
||||
t.Fatalf("drawn=%v want the recorded regions reset on clear", above.drawn)
|
||||
}
|
||||
|
||||
below := &x11Session{role: RoleBelow, fontCell: 6, fontAscent: 11, fontDescent: 3}
|
||||
below.recordOSDText(32, 32, 9)
|
||||
if len(below.drawn) != 0 {
|
||||
t.Fatalf("drawn=%v want none: the fallback canvas never punches output shapes", below.drawn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestX11SessionWaitEventRefreshesOnItsInterval(t *testing.T) {
|
||||
// The OSD layer redraws on its own slower cadence: refresh must return
|
||||
// redraw even with no X events pending, so template data — the latest
|
||||
// published snapshot — appears without video or exposure churn.
|
||||
session := &x11Session{
|
||||
poll: time.Millisecond,
|
||||
refresh: 2 * time.Millisecond,
|
||||
pollEvent: func() (xgb.Event, xgb.Error) { return nil, nil },
|
||||
probe: func() error { return nil },
|
||||
}
|
||||
|
||||
done := make(chan eventResult, 1)
|
||||
go func() {
|
||||
redraw, err := session.WaitEvent(context.Background())
|
||||
done <- eventResult{redraw: redraw, err: err}
|
||||
}()
|
||||
select {
|
||||
case result := <-done:
|
||||
if result.err != nil {
|
||||
t.Fatalf("WaitEvent=%v", result.err)
|
||||
}
|
||||
if !result.redraw {
|
||||
t.Fatal("the refresh interval must request a redraw")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("WaitEvent never returned the refresh redraw")
|
||||
}
|
||||
}
|
||||
|
||||
func TestX11SessionWaitEventWithoutRefreshKeepsWaiting(t *testing.T) {
|
||||
probes := 0
|
||||
session := &x11Session{
|
||||
poll: time.Millisecond,
|
||||
pollEvent: func() (xgb.Event, xgb.Error) { return nil, nil },
|
||||
probe: func() error {
|
||||
probes++
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||
defer cancel()
|
||||
redraw, err := session.WaitEvent(ctx)
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("WaitEvent=%v want clean cancellation without a refresh interval", err)
|
||||
}
|
||||
if redraw {
|
||||
t.Fatal("no refresh interval must mean no refresh redraws")
|
||||
}
|
||||
if probes == 0 {
|
||||
t.Fatal("liveness probes must continue while idle")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user