Files
lime/test/x11-status.lisp
2026-10-02 16:35:17 -05:00

236 lines
15 KiB
Common Lisp

(in-package #:lime.x11.test)
(in-suite lime-x11-suite)
(test status-formatters
(is (= 60 (lime.x11::%next-minute-delay 120)))
(is (= 1 (lime.x11::%next-minute-delay 179)))
(is (< (abs (- 0.25d0 (lime.x11::%next-minute-delay 179.75d0))) 0.00001))
(let ((data (lime.x11::%clock-snapshot (encode-universal-time 59 7 15 30 9 2026))))
(is (= 15 (getf data :hour)))
(is (= 2026 (getf data :year)))
(is (string= " Wed Sep 30, 03:07" (lime.x11:format-clock-status data)))
(is (string= " Wed Sep 30, 15:07" (lime.x11:format-clock-status data :twenty-four-hour-p t))))
(is (string= " Wed Sep 30, 12:00"
(lime.x11:format-clock-status (lime.x11::%clock-snapshot (encode-universal-time 0 0 0 30 9 2026)))))
(is (= 25 (lime.x11::%volume-percent "Volume: front-left: 16384 / 25% / -36.12 dB, front-right: 16384 / 25%")))
(is (= 150 (lime.x11::%volume-percent "mono: 98304 / 150% / 1.23 dB")))
(signals error (lime.x11::%volume-percent "no sink"))
(is (lime.x11::%volume-event-p "Event 'change' on sink #42"))
(is (lime.x11::%volume-event-p "Event 'change' on server #0"))
(is (lime.x11::%volume-event-p "Event 'remove' on card #1"))
(is (not (lime.x11::%volume-event-p "Event 'new' on client #9")))
(is (not (lime.x11::%volume-event-p "Event 'change' on sink-input #9")))
(is (equal '(:state :playing :title "スパイダー" :source :mpd)
(lime.x11::%music-snapshot "play" "スパイダー" :mpd)))
(is (string= " スパイダー" (lime.x11:format-music-status (lime.x11::%music-snapshot "play" "スパイダー" :mpd))))
(let ((data (lime.x11::%mpris-line-snapshot (format nil "Paused~Chello" #\Tab) "spotify")))
(is (eq :paused (getf data :state)))
(is (equal "spotify" (getf data :source)))
(is (string= " hello" (lime.x11:format-music-status data))))
(is (null (lime.x11::%mpris-line-snapshot "" "spotify")))
(let ((data (lime.x11::%music-snapshot "stop" "old track" :mpd)))
(is (equal "old track" (getf data :title)))
(is (eq :stopped (getf data :state)))
(is (null (lime.x11:format-music-status data))))
(is (string= " Untitled track" (lime.x11:format-music-status (lime.x11::%music-snapshot "play" "" :mpd))))
(is (string= " 25%" (lime.x11:format-volume-status '(:volume 25 :muted-p nil))))
(is (string= " Muted" (lime.x11:format-volume-status '(:volume 25 :muted-p t))))
(is (string= "\"a\\\"b\\\\c\"" (lime.x11::%mpd-quote "a\"b\\c")))
(signals error (lime.x11::%mpd-quote (format nil "foo~%bar"))))
(test status-icons-and-gaps-are-presentation-options
(let ((font (lime.x11::%open-text-font
(lime.x11:make-ui-font-style
:family "Dina, Fira Code Nerd Font, Noto Color Emoji" :size 12 :weight :bold))))
(unwind-protect
(dolist (pair '(("" "Song") ("" "スパイダー") ("" "25%")
("" "Muted") ("" "Wed Sep 30, 03:07")))
(destructuring-bind (icon label) pair
(let ((text (lime.x11::%status-icon-label icon label " ")))
(is (string= text (concatenate 'string icon " " label)))
(is (> (lime.x11::%text-width font text)
(lime.x11::%text-width font (concatenate 'string icon " " label)))))))
(lime.x11::%free-text-font font)))
(is (string= "PLAY · My track"
(lime.x11:format-music-status '(:state :playing :title "My track")
:playing-icon "PLAY" :icon-gap " · ")))
(is (string= "Quiet" (lime.x11:format-volume-status '(:muted-p t :volume 30)
:muted-icon nil :muted-label "Quiet"))))
(test status-icon-colors-preserve-text-and-provider-behavior
(let* ((click (lambda (&rest ignored) (declare (ignore ignored))))
(provider (make-volume-status-provider
:foreground "#dbc9a6" :maximum-width 100 :click-action click
:formatter (lambda (data)
(format-volume-status data :icon-foreground "#76929f"))))
(segment (lime.x11::%format-status-segment provider '(:volume 25))))
(is (string= " 25%" (head-bar-segment-text segment)))
(is (string= "#dbc9a6" (head-bar-segment-foreground segment)))
(is (string= "#76929f" (head-bar-segment-prefix-foreground segment)))
(is (= 1 (head-bar-segment-prefix-length segment)))
(is (= 100 (head-bar-segment-maximum-width segment)))
(is (eq click (head-bar-segment-click-action segment))))
(dolist (segment (list (format-music-status '(:state :paused :title "スパイダー")
:paused-icon "PAUSE" :icon-foreground "#76929f")
(format-volume-status '(:muted-p t) :icon-foreground "#76929f")
(format-clock-status (lime.x11::%clock-snapshot (get-universal-time))
:icon-foreground "#76929f")))
(is (typep segment 'head-bar-segment))
(is (string= "#76929f" (head-bar-segment-prefix-foreground segment)))
(is (null (head-bar-segment-foreground segment))))
(is (string= "25%" (format-volume-status '(:volume 25) :icon nil :icon-foreground "#76929f")))
(is (null (format-music-status '(:state :stopped) :icon-foreground "#76929f")))
(signals invalid-transition (format-volume-status '(:volume 25) :icon-foreground "blue"))
(dolist (bad '(-1 99 1/2 nil))
(signals invalid-transition
(lime.x11::%validate-head-bar-segment (make-head-bar-segment :text "test" :prefix-length bad)))))
(test status-provider-validation
(let ((provider (lime.x11:make-clock-status-provider)))
(is (equal (list provider) (lime.x11::%validate-status-providers (list provider))))
(is (lime.x11::%validate-status-providers (list provider (lambda (backend head) (declare (ignore backend head))))))
(signals lime.core:invalid-transition
(lime.x11::%validate-status-providers (list provider (lime.x11:make-clock-status-provider)))))
(signals lime.core:invalid-transition
(lime.x11::%validate-status-providers (list (lime.x11:make-status-provider :id :broken))))
(signals error (lime.x11:make-music-status-provider :source :unknown))
(signals error (lime.x11:make-music-status-provider :maximum-width 0))
(signals invalid-transition
(lime.x11::%validate-status-providers (list (lime.x11:make-clock-status-provider :formatter nil))))
(signals invalid-transition
(lime.x11::%validate-status-providers (list (lime.x11:make-volume-status-provider :foreground "red"))))
(is (typep (lime.x11:make-music-status-provider :source "brave" :sources '("brave")) 'lime.x11:status-provider)))
(test status-data-is-snapshotted-and-formatted-only-by-the-consumer
(let* ((calls 0)
(runtime (lime.x11::make-status-runtime :closed-p t))
(input (list :state :playing :title (copy-seq "Original")))
(provider (lime.x11:make-music-status-provider
:formatter (lambda (data)
(incf calls)
;; Even a mutating formatter cannot damage the cache.
(setf (char (getf data :title) 0) #\X)
(getf data :title))))
(worker (lime.x11::make-status-worker :runtime runtime :provider provider)))
(let ((returned (lime.x11:status-publish worker input)))
(setf (char (getf returned :title) 0) #\Y))
(setf (getf input :state) :paused (char (getf input :title) 0) #\Z)
(is (zerop calls))
(is (equal '(:state :playing :title "Original") (lime.x11::%status-worker-snapshot worker)))
(let ((segment (lime.x11::%format-status-segment provider (lime.x11::%status-worker-snapshot worker))))
(is (= 1 calls))
(is (string= "Xriginal" (head-bar-segment-text segment)))
(is (equal '(:state :playing :title "Original") (lime.x11::%status-worker-snapshot worker))))
;; NIL means unavailable, not a formatting callback with incomplete data.
(is (null (lime.x11::%format-status-segment provider nil)))
(is (= 1 calls))))
(test status-formatters-can-return-colored-segments-or-hide-data
(let* ((click (lambda (backend head) (declare (ignore backend head))))
(provider (lime.x11:make-volume-status-provider :foreground "#aabbcc" :maximum-width 90 :click-action click))
(value '(:volume 25 :muted-p nil :sink "fixture")))
(let ((segment (lime.x11::%format-status-segment provider value)))
(is (eq :volume (head-bar-segment-id segment)))
(is (equal "#aabbcc" (lime.x11:head-bar-segment-foreground segment)))
(is (= 90 (head-bar-segment-maximum-width segment)))
(is (eq click (head-bar-segment-click-action segment))))
(setf (lime.x11:status-provider-formatter provider)
(lambda (data) (make-head-bar-segment :text (format nil "Volume ~D" (getf data :volume))
:foreground "#112233" :maximum-width 40)))
(let ((segment (lime.x11::%format-status-segment provider value)))
(is (string= "Volume 25" (head-bar-segment-text segment)))
(is (equal "#112233" (lime.x11:head-bar-segment-foreground segment)))
(is (= 40 (head-bar-segment-maximum-width segment)))
(is (eq click (head-bar-segment-click-action segment))))
(setf (lime.x11:status-provider-formatter provider) (constantly nil))
(is (null (lime.x11::%format-status-segment provider value)))
(setf (lime.x11:status-provider-formatter provider)
(lambda (data) (declare (ignore data)) (make-head-bar-segment :text "bad" :foreground "not a color")))
(signals invalid-transition (lime.x11::%format-status-segment provider value))))
(test custom-status-formatting-can-show-stopped-music-and-differing-date-styles
(let* ((music (lime.x11:make-music-status-provider
:formatter (lambda (data) (format nil "~(~A~): ~A" (getf data :state) (getf data :title)))))
(clock (lime.x11:make-clock-status-provider
:formatter (lambda (data) (format nil "~D-~2,'0D-~2,'0D" (getf data :year)
(getf data :month) (getf data :day))))))
(is (string= "stopped: Track"
(head-bar-segment-text (lime.x11::%format-status-segment music
(lime.x11::%music-snapshot "stop" "Track" :mpd)))))
(is (string= "2026-09-30"
(head-bar-segment-text (lime.x11::%format-status-segment clock
(lime.x11::%clock-snapshot (encode-universal-time 0 0 0 30 9 2026))))))))
(test status-publication-and-cancellation
(let* ((runtime (lime.x11::make-status-runtime :closed-p t))
(worker (lime.x11::make-status-worker :runtime runtime)))
(lime.x11:status-publish worker "first")
(is (string= "first" (lime.x11::status-worker-value worker)))
(lime.x11:status-publish worker nil)
(is (null (lime.x11::status-worker-value worker)))
(lime.x11::%stop-status-worker worker)
(lime.x11:status-publish worker "late result")
(is (null (lime.x11::status-worker-value worker)))
(is (not (lime.x11:status-wait worker 60)))
(let ((cancelled nil) (ran nil))
(lime.x11::%call-with-status-resource worker (lambda () (setf cancelled t))
(lambda () (setf ran t)))
(is (eq t cancelled))
(is (not ran)))))
(test volume-subscription-coalesces-bursts-without-polling
(let* ((worker (lime.x11::make-status-worker :runtime (lime.x11::make-status-runtime :closed-p t)))
(snapshots 0)
(events (format nil "Event 'new' on client #1~%Event 'change' on sink #2~%Event 'change' on sink #2~%Event 'change' on server #0~%")))
(signals error ; finite fixture EOF is a normal source disconnect
(lime.x11::%run-volume-status
worker
:subscribe (lambda (ignored argv action)
(declare (ignore ignored))
(is (equal '("pactl" "subscribe") argv))
(with-input-from-string (stream events) (funcall action stream nil)))
:snapshot (lambda (ignored) (declare (ignore ignored)) (format nil "snapshot ~D" (incf snapshots)))))
(is (= 2 snapshots)) ; initial + one burst, not 3 events or an interval
(is (string= "snapshot 2" (lime.x11::status-worker-value worker)))))
(test widget-width-cap-keeps-following-widgets-and-graphemes
(let ((font (lime.x11::%open-text-font (lime.x11:make-ui-font-style))))
(unwind-protect
(let* ((music (lime.x11:make-head-bar-segment :id :music :text "👩‍💻👩‍💻👩‍💻 a long track"
:maximum-width 65))
(clock (lime.x11:make-head-bar-segment :id :clock :text "12:34"))
(runs (nth-value 3 (lime.x11::%fit-bar-status font (list music clock) 500)))
(first (first runs)))
(is (eq music (getf first :segment)))
(is (<= (getf first :width) 65))
(is (find (getf first :prefix)
(lime.x11::text-metrics-boundaries
(lime.x11::%text-metrics font (lime.x11:head-bar-segment-text music)))))
(is (eq clock (getf (car (last runs)) :segment))))
(lime.x11::%free-text-font font))))
(test music-source-switching-and-command-routing
(let* ((provider (lime.x11:make-music-status-provider :mpd-host "localhost" :mpd-port 6601))
(runtime (lime.x11::make-status-runtime :closed-p t))
(backend (lime.x11::%make-x11-backend :status-runtime runtime :head-bar-status-providers (list provider)))
(old-worker nil))
;; Exercise the real lifecycle with a test source, never actual playback.
(setf (lime.x11::status-provider-run provider)
(lambda (worker) (lime.x11:status-wait worker 60))
old-worker (lime.x11::%start-status-worker runtime provider)
(lime.x11::status-runtime-workers runtime) (list old-worker))
(unwind-protect
(progn
(is (equal '("mpc" "--host" "localhost" "--port" "6601" "prev")
(lime.x11::%music-command-argv provider :previous)))
(is (equal "spotify" (lime.x11:backend-cycle-music-source backend)))
(is (not (bt:thread-alive-p (lime.x11::status-worker-thread old-worker))))
(is (= 1 (length (lime.x11::status-runtime-workers runtime))))
(is (equal '("playerctl" "--player" "spotify" "play-pause")
(lime.x11::%music-command-argv provider :toggle)))
(is (equal '("playerctl" "--player" "spotify" "previous")
(lime.x11::%music-command-argv provider :previous)))
(is (eq :mpd (lime.x11:backend-cycle-music-source backend)))
(signals error (lime.x11::%music-command-argv provider :invalid)))
(mapc #'lime.x11::%stop-status-worker (lime.x11::status-runtime-workers runtime)))))