@@ -34,6 +34,8 @@ func TestLoadTemplates(t *testing.T) {
3434 "section-pulseaudio" ,
3535 "section-systemd" ,
3636 "section-bluetooth" ,
37+ "section-upgrade" ,
38+ "upgrade-ring" ,
3739 "mpris-player" ,
3840 "pulseaudio-sink" ,
3941 "systemd-unit" ,
@@ -109,6 +111,21 @@ func TestSectionTemplates(t *testing.T) {
109111 template : "section-bluetooth" ,
110112 data : & BluetoothView {Powered : true , PairingActive : true , PairingUntilMs : 1_700_000_000_000 },
111113 },
114+ {
115+ name : "Upgrade badge up to date" ,
116+ template : "section-upgrade" ,
117+ data : & UpgradeStatus {Current : "1.0" , Latest : "1.0" , UpgradeAvailable : false },
118+ },
119+ {
120+ name : "Upgrade badge update available" ,
121+ template : "section-upgrade" ,
122+ data : & UpgradeStatus {Current : "1.0" , Latest : "1.1" , UpgradeAvailable : true },
123+ },
124+ {
125+ name : "Upgrade badge unknown (no detection)" ,
126+ template : "section-upgrade" ,
127+ data : (* UpgradeStatus )(nil ),
128+ },
112129 }
113130
114131 for _ , tt := range tests {
@@ -131,6 +148,103 @@ func TestSectionTemplates(t *testing.T) {
131148 }
132149}
133150
151+ // TestUpgradeBadgeTemplate asserts the badge label per state and that the
152+ // last-check time is surfaced in the tooltip; every state is a re-check button.
153+ func TestUpgradeBadgeTemplate (t * testing.T ) {
154+ tmpl := LoadTemplates ()
155+ checked := time .Date (2026 , 6 , 15 , 20 , 46 , 34 , 0 , time .UTC )
156+
157+ // Badge is icon-only; state is asserted via the tooltip and a distinguishing
158+ // SVG path fragment of the expected icon.
159+ tests := []struct {
160+ name string
161+ status * UpgradeStatus
162+ wantInTitle string
163+ wantIconSVG string // path fragment unique to the expected icon
164+ wantPost string // hx-post target for the click
165+ wantConfirm bool // destructive action must be confirmed
166+ }{
167+ {
168+ name : "up to date surfaces checked-at in tooltip, check icon, re-checks" ,
169+ status : & UpgradeStatus {Current : "1.0" , Latest : "1.0" , UpgradeAvailable : false , CheckedAt : checked },
170+ wantInTitle : "Up to date · checked " + (& UpgradeStatus {CheckedAt : checked }).CheckedAtLabel (),
171+ wantIconSVG : "M20 6 9 17l-5-5" , // icon-check
172+ wantPost : "/upgrade/check" ,
173+ },
174+ {
175+ name : "update available surfaces latest version, arrow-up icon, installs with confirm" ,
176+ status : & UpgradeStatus {Current : "1.0" , Latest : "1.1" , UpgradeAvailable : true , CheckedAt : checked },
177+ wantInTitle : "Upgrade available: 1.1" ,
178+ wantIconSVG : "M12 19V5" , // icon-arrow-up
179+ wantPost : "/upgrade/start" ,
180+ wantConfirm : true ,
181+ },
182+ {
183+ name : "unknown shows check prompt, refresh icon, re-checks" ,
184+ status : nil ,
185+ wantInTitle : "Check for upgrades" ,
186+ wantIconSVG : "M21 3v5h-5" , // icon-rotate-cw
187+ wantPost : "/upgrade/check" ,
188+ },
189+ }
190+
191+ for _ , tt := range tests {
192+ t .Run (tt .name , func (t * testing.T ) {
193+ var buf bytes.Buffer
194+ if err := tmpl .ExecuteTemplate (& buf , "section-upgrade" , tt .status ); err != nil {
195+ t .Fatalf ("execute section-upgrade: %v" , err )
196+ }
197+ out := buf .String ()
198+ if ! strings .Contains (out , tt .wantInTitle ) {
199+ t .Errorf ("expected tooltip %q in output, got: %s" , tt .wantInTitle , out )
200+ }
201+ if ! strings .Contains (out , tt .wantIconSVG ) {
202+ t .Errorf ("expected icon path %q in output, got: %s" , tt .wantIconSVG , out )
203+ }
204+ if ! strings .Contains (out , `hx-post="` + tt .wantPost + `"` ) {
205+ t .Errorf ("expected hx-post %q in output, got: %s" , tt .wantPost , out )
206+ }
207+ if gotConfirm := strings .Contains (out , "hx-confirm=" ); gotConfirm != tt .wantConfirm {
208+ t .Errorf ("hx-confirm present = %v, want %v; output: %s" , gotConfirm , tt .wantConfirm , out )
209+ }
210+ })
211+ }
212+ }
213+
214+ func TestUpgradeBadgeRunning (t * testing.T ) {
215+ tmpl := LoadTemplates ()
216+ pct := 42
217+
218+ render := func (status * UpgradeStatus ) string {
219+ t .Helper ()
220+ var buf bytes.Buffer
221+ if err := tmpl .ExecuteTemplate (& buf , "section-upgrade" , status ); err != nil {
222+ t .Fatalf ("execute section-upgrade: %v" , err )
223+ }
224+ return buf .String ()
225+ }
226+
227+ // Running badge is the ring's sse-swap target, no click action.
228+ out := render (& UpgradeStatus {Latest : "1.1" , Run : & UpgradeRun {State : "running" , Percent : & pct }})
229+ if ! strings .Contains (out , `sse-swap="upgrade-progress"` ) {
230+ t .Errorf ("running badge should be the ring sse-swap target, got: %s" , out )
231+ }
232+ if strings .Contains (out , "hx-post=" ) {
233+ t .Errorf ("running badge should not be clickable, got: %s" , out )
234+ }
235+
236+ // Ring fill: stroke-dashoffset = 100 - percent.
237+ if ! strings .Contains (out , `stroke-dashoffset="58"` ) {
238+ t .Errorf ("ring should reflect 42%% (offset 58), got: %s" , out )
239+ }
240+
241+ // No percent yet → spinner, no ring.
242+ noPct := render (& UpgradeStatus {Latest : "1.1" , Run : & UpgradeRun {State : "running" }})
243+ if ! strings .Contains (noPct , "spinner" ) || strings .Contains (noPct , "upgrade-ring-fill" ) {
244+ t .Errorf ("running without percent should be a spinner, got: %s" , noPct )
245+ }
246+ }
247+
134248// TestComponentTemplates verifies all component templates can be executed without panic
135249func TestComponentTemplates (t * testing.T ) {
136250 tmpl := LoadTemplates ()
0 commit comments