Coverage Report

Created: 2026-10-23 22:23

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
super-stt-app/src/state/update.rs
Line
Count
Source
1
// SPDX-License-Identifier: GPL-3.0-only
2
//! UI state for the self-update flow (the Updates page + header badge).
3
//!
4
//! Every state TRANSITION lives here as a pure method on [`UpdateState`] /
5
//! [`UpdateRun`] so it's testable without constructing an `AppModel` (which
6
//! can't be built outside the running app — private fields, no `Default`,
7
//! no test harness). `core/app/handlers/update.rs` calls these methods to
8
//! mutate state, then separately decides which `Task` (if any) to return —
9
//! it owns the side effects, this module owns the logic.
10
11
use crate::core::app::updater::{InstallerEvent, UpdateRunEvent};
12
use super_stt_shared::models::self_update::SelfUpdateStatus;
13
14
/// Self-update state: last known daemon status, a settings-toggle error
15
/// banner, and the in-flight apply run (if any).
16
// No Debug derive: the abort handle isn't Debug.
17
#[derive(Default)]
18
pub struct UpdateState {
19
    /// Last known daemon-reported status. None until first load.
20
    pub status: Option<SelfUpdateStatus>,
21
    /// True once `GET /v1/update` (or `POST /v1/update/check`) returned 404
22
    /// (daemon predates the feature).
23
    pub unsupported: bool,
24
    pub checking: bool,
25
    pub auto_check_enabled: Option<bool>,
26
    /// Beta opt-in as the toggle should currently render it. Owned locally
27
    /// rather than read straight off `status` so the switch can move the
28
    /// instant it is pressed: `status.beta_optin_effective` only changes when
29
    /// a whole new status arrives, and the re-check that produces one is a
30
    /// network round-trip. Re-synced from every fresh status, so the daemon
31
    /// still has the last word. `None` until the first status loads.
32
    pub beta_optin: Option<bool>,
33
    /// True while a beta-opt-in write and its follow-up re-check are in
34
    /// flight. Locks the toggle so a second press cannot race the first —
35
    /// the two would resolve in arrival order, not press order, and the
36
    /// switch would settle on whichever finished last.
37
    pub beta_pending: bool,
38
    /// In-flight or finished update run; None when idle.
39
    pub run: Option<UpdateRun>,
40
    /// Aborts the run's task stream. Only honored before the escalate
41
    /// phase — see [`UpdateRun::cancellable`].
42
    pub run_abort: Option<cosmic::iced::task::Handle>,
43
    /// Error from a settings toggle, shown in the page banner.
44
    pub action_error: Option<String>,
45
}
46
47
impl UpdateState {
48
    /// Applied on every fresh status (connect-time load, page refresh,
49
    /// manual/auto check via `CheckNow`, or the post-install
50
    /// `AvailableEventReceived` refetch): `None` means the daemon answered
51
    /// 404 (predates `/v1/update`), so it marks `unsupported` instead of
52
    /// storing a status; `Some` clears `unsupported` and stores it. Either
53
    /// way, `checking` ends and a stale `Failed` run is superseded (see
54
    /// [`Self::clear_run_on_status_refresh`]).
55
10
    pub fn apply_status_loaded(&mut self, status: Option<SelfUpdateStatus>) {
56
10
        self.checking = false;
57
10
        match status {
58
8
            Some(s) => {
59
                // The daemon has the last word on the beta channel — but not
60
                // while our own write is still settling, or a status fetched
61
                // before it would drag the switch back under the user's
62
                // finger and then flip it again a moment later.
63
8
                if !self.beta_pending {
64
7
                    self.beta_optin = Some(s.beta_optin_effective);
65
7
                
}1
66
8
                self.unsupported = false;
67
8
                self.status = Some(s);
68
            }
69
2
            None => self.unsupported = true,
70
        }
71
10
        self.clear_run_on_status_refresh();
72
10
    }
73
74
    /// A `GET`/`POST /v1/update*` call itself failed (network/daemon error,
75
    /// not a 404). Shown in the page banner; does not touch `status`,
76
    /// `unsupported`, or `run`.
77
1
    pub fn apply_status_error(&mut self, msg: &str) {
78
1
        self.checking = false;
79
1
        self.action_error = Some(format!("Couldn't fetch update status: {msg}"));
80
1
    }
81
82
    /// A settings-toggle write (`AutoCheckToggled`/`BetaOptinToggled`)
83
    /// failed. Distinct banner text from [`Self::apply_status_error`] so it
84
    /// names the right verb.
85
1
    pub fn apply_setting_error(&mut self, msg: &str) {
86
1
        self.action_error = Some(format!("Couldn't update setting: {msg}"));
87
1
    }
88
89
    /// A `Done`/`Failed` run has nothing further to preserve *once superseded
90
    /// by a fresh snapshot that no longer needs it as a stale error*, so a
91
    /// `Failed` run is cleared here rather than masking newer information
92
    /// (e.g. a newer version now available).
93
    ///
94
    /// A `Done` run's Restart affordance must survive this — the
95
    /// `Done → AvailableEventReceived → StatusLoaded` sequence fires
96
    /// immediately after a successful update (the daemon restarted onto the
97
    /// new version), so clearing `run` here would erase the CTA before the
98
    /// user ever sees it.
99
    ///
100
    /// An in-flight (non-terminal) run is never touched — only
101
    /// `CancelUpdate`/`DismissRun` end those.
102
20
    pub fn clear_run_on_status_refresh(&mut self) {
103
20
        if 
matches!19
(self.run.as_ref().map(|r| r.phase), Some(RunPhase::Failed)) {
104
1
            self.run = None;
105
1
            self.run_abort = None;
106
19
        }
107
20
    }
108
109
    /// Applied on `DismissRun`: only a terminal (`Done`/`Failed`) run can be
110
    /// dismissed this way — an in-flight run must go through `CancelUpdate`
111
    /// instead, so it still honors [`UpdateRun::cancellable`]'s
112
    /// escalate-phase safety cutoff.
113
11
    pub fn dismiss_run(&mut self) {
114
11
        if self
115
11
            .run
116
11
            .as_ref()
117
11
            .is_some_and(|r| matches!(r.phase, RunPhase::Done | RunPhase::Failed))
118
3
        {
119
3
            self.run = None;
120
3
            self.run_abort = None;
121
8
        }
122
11
    }
123
124
    /// Whether `StartUpdate` would actually begin a new run right now: no
125
    /// run already active (regardless of phase — a terminal one must be
126
    /// dismissed/superseded first), and the last-known status carries both
127
    /// an installable asset and a target tag to install.
128
    #[must_use]
129
16
    pub fn can_start_update(&self) -> bool {
130
16
        self.run.is_none()
131
6
            && self
132
6
                .status
133
6
                .as_ref()
134
6
                .is_some_and(|s| 
s.installer_asset5
.
is_some5
() &&
s.latest_version4
.
is_some4
())
135
16
    }
136
137
    /// Begin a new run at `FetchingInstaller`. Callers must check
138
    /// [`Self::can_start_update`] first — this unconditionally (over)writes
139
    /// `run`.
140
2
    pub fn begin_run(&mut self) {
141
2
        self.run = Some(UpdateRun {
142
2
            phase: RunPhase::FetchingInstaller,
143
2
            bytes_done: 0,
144
2
            bytes_total: 0,
145
2
            error: None,
146
2
            error_code: None,
147
2
            completed_components: Vec::new(),
148
2
        });
149
2
    }
150
151
    /// Whether the "Check now" button should be pressable: not already
152
    /// checking, no run in progress, and the daemon actually supports the
153
    /// feature (a `CheckNow` against an `unsupported` daemon would just
154
    /// 404 again).
155
    #[must_use]
156
4
    pub fn can_check_now(&self) -> bool {
157
4
        !self.checking && 
self.run3
.
is_none3
() &&
!self.unsupported2
158
4
    }
159
160
    /// Whether to ask the daemon for a check right now.
161
    ///
162
    /// A daemon defers its own first check for a minute after start so it
163
    /// does not compete with the model load, and until then it has no
164
    /// candidate to report: the Updates page reads empty and the header
165
    /// badge is absent, however long an update has actually been waiting.
166
    /// Restart the daemon and the app together — the development loop — and
167
    /// that is the state the app opens in. Asking closes the gap without
168
    /// changing when the daemon checks on its own.
169
    ///
170
    /// Self-limiting rather than latched: a *completed* check stamps
171
    /// `checked_at` whether it succeeded or failed, so this turns false as
172
    /// soon as one lands. A check that fails before completing reports
173
    /// through `StatusError`, which stores no status and so cannot re-enter
174
    /// this — it leaves the ask available to the next page open, which is
175
    /// the retry a user would expect anyway.
176
    ///
177
    /// Not asked while a run is in flight (the run owns the page) or while a
178
    /// check is already going.
179
    #[must_use]
180
7
    pub fn wants_first_check(&self) -> bool {
181
7
        self.status.as_ref().is_some_and(|s| 
s.checked_at5
.
is_none5
())
182
4
            && !self.unsupported
183
4
            && !self.checking
184
2
            && self.run.is_none()
185
7
    }
186
187
    /// Whether the beta-updates toggle should render as on.
188
    ///
189
    /// The locally-owned value when there is one — it is the fresher of the
190
    /// two the moment the switch is pressed — falling back to the daemon's
191
    /// last-known status before any toggle or status has landed.
192
    #[must_use]
193
6
    pub fn beta_optin_shown(&self) -> bool {
194
6
        self.beta_optin
195
6
            .unwrap_or_else(|| 
self.status1
.
as_ref1
().
is_some_and1
(|s| s.beta_optin_effective))
196
6
    }
197
198
    /// Whether the daemon's last-known status reports an installable update.
199
    #[must_use]
200
6
    pub fn update_offered(&self) -> bool {
201
6
        self.status.as_ref().is_some_and(|s| s.update_available)
202
6
    }
203
204
    /// Whether the Update section (CTA / in-flight progress / terminal
205
    /// panel) should render at all: whenever a run exists — independent of
206
    /// what the latest status says, so a `Done` run's Restart CTA survives
207
    /// the post-update refetch that reports `update_available: false` — or
208
    /// the daemon currently offers an update with no run yet started.
209
    #[must_use]
210
4
    pub fn update_section_visible(&self) -> bool {
211
4
        self.run.is_some() || 
self2
.
update_offered2
()
212
4
    }
213
214
    /// Whether the header-bar "Update available" badge should render: only
215
    /// while an update is offered and no apply run is in flight (once a run
216
    /// starts, the Updates page's phase readout is the source of truth).
217
    #[must_use]
218
2
    pub fn header_badge_visible(&self) -> bool {
219
2
        self.run.is_none() && 
self1
.
update_offered1
()
220
2
    }
221
222
    /// Fold one apply-flow event into the in-flight run. A no-op if there's
223
    /// no run (the stream outlived its cancellation, or a stray event
224
    /// arrived after `DismissRun`/`CancelUpdate`). Returns whether the
225
    /// caller should follow up with a status refetch — only for the
226
    /// post-`Done` `Finished` case, since the daemon restarted onto the new
227
    /// version and the cached status is now stale.
228
    #[must_use]
229
20
    pub fn apply_run_event(&mut self, ev: UpdateRunEvent) -> RunOutcome {
230
20
        let Some(
run19
) = self.run.as_mut() else {
231
1
            return RunOutcome::Continue;
232
        };
233
12
        match ev {
234
            UpdateRunEvent::FetchProgress {
235
1
                bytes_done,
236
1
                bytes_total,
237
1
            } => {
238
1
                run.phase = RunPhase::FetchingInstaller;
239
1
                run.bytes_done = bytes_done;
240
1
                run.bytes_total = bytes_total;
241
1
            }
242
8
            UpdateRunEvent::Installer(InstallerEvent::Phase { phase, message }) => {
243
8
                log::debug!("installer phase: {phase} — {message}");
244
8
                run.phase = match phase.as_str() {
245
8
                    "resolve" => 
RunPhase::Resolve1
,
246
7
                    "download" => 
RunPhase::Download1
,
247
6
                    "verify" => 
RunPhase::Verify1
,
248
5
                    "stage" => 
RunPhase::Stage1
,
249
4
                    "escalate" => 
RunPhase::WaitingAuth1
,
250
3
                    "install" => 
RunPhase::Install1
,
251
2
                    "post_install" => 
RunPhase::PostInstall1
,
252
1
                    _ => run.phase, // tolerate future/unknown phases
253
                };
254
            }
255
            UpdateRunEvent::Installer(InstallerEvent::Progress {
256
1
                phase,
257
1
                bytes_done,
258
1
                bytes_total,
259
            }) => {
260
1
                log::trace!("installer progress ({phase}): {bytes_done}/{bytes_total}");
261
1
                run.bytes_done = bytes_done;
262
1
                run.bytes_total = bytes_total;
263
            }
264
            UpdateRunEvent::Installer(InstallerEvent::Complete {
265
1
                installed_version,
266
1
                components,
267
            }) => {
268
1
                log::info!("installer completed: {installed_version} ({components:?})");
269
1
                run.phase = RunPhase::Done;
270
1
                run.completed_components = components;
271
            }
272
2
            UpdateRunEvent::Installer(InstallerEvent::Error { code, message }) => {
273
2
                log::warn!("installer reported error {code}: {message}");
274
2
                run.phase = RunPhase::Failed;
275
2
                run.error = Some(message);
276
2
                run.error_code = Some(code);
277
            }
278
2
            UpdateRunEvent::Failed(message) => {
279
2
                run.phase = RunPhase::Failed;
280
2
                run.error = Some(message);
281
2
            }
282
            UpdateRunEvent::Finished {
283
4
                exit_ok,
284
4
                stderr_tail,
285
            } => {
286
4
                if run.phase == RunPhase::Done {
287
                    // Daemon was restarted by the installer; caller refreshes
288
                    // status.
289
1
                    return RunOutcome::RefetchStatus;
290
3
                }
291
3
                if run.phase != RunPhase::Failed {
292
2
                    run.phase = RunPhase::Failed;
293
2
                    run.error = Some(if exit_ok {
294
1
                        "installer ended unexpectedly".to_string()
295
                    } else {
296
1
                        format!("installer failed: {stderr_tail}")
297
                    });
298
1
                }
299
            }
300
        }
301
18
        RunOutcome::Continue
302
20
    }
303
}
304
305
/// What the handler should do after [`UpdateState::apply_run_event`] folds
306
/// in one event.
307
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
308
pub enum RunOutcome {
309
    /// State was updated; no further app-side action needed.
310
    Continue,
311
    /// The run just reached its post-`Done` `Finished` — the daemon
312
    /// restarted onto the new version, so the caller should re-fetch status.
313
    RefetchStatus,
314
}
315
316
pub struct UpdateRun {
317
    pub phase: RunPhase,
318
    pub bytes_done: u64,
319
    pub bytes_total: u64,
320
    pub error: Option<String>,
321
    /// The installer's typed error code for a `Failed` run (its closed set
322
    /// lives in `super-stt-install`'s `InstallError::code`). `None` for a
323
    /// failure raised app-side, which has no installer code to report.
324
    ///
325
    /// Kept beside `error` because the message is for the user to read and
326
    /// the code is for the UI to branch on — see
327
    /// [`Self::was_authorization_declined`].
328
    pub error_code: Option<String>,
329
    pub completed_components: Vec<String>,
330
}
331
332
impl UpdateRun {
333
    /// Whether this failure is the user declining the authorization prompt
334
    /// rather than something going wrong.
335
    ///
336
    /// Worth telling apart: every other failure leaves the user with an
337
    /// update they still cannot apply, and a way to install it outside the
338
    /// app is a real answer. Declining the prompt is not a dead end — the
339
    /// answer is to press Update again and authorize — so handing over a
340
    /// shell command reads as a non-sequitur to someone who just pressed
341
    /// Cancel.
342
    ///
343
    /// Keyed on the installer's typed code, never on the message's wording:
344
    /// the codes are a closed contract (`InstallError::code`), the prose is
345
    /// not, and `pkexec` localizes its own diagnostics.
346
    #[must_use]
347
10
    pub fn was_authorization_declined(&self) -> bool {
348
10
        self.error_code.as_deref() == Some("escalation_denied")
349
10
    }
350
351
    /// Cancel is only offered while nothing system-owned has been touched
352
    /// (spec §1: safe to kill before the escalate phase).
353
    #[must_use]
354
10
    pub fn cancellable(&self) -> bool {
355
5
        matches!(
356
10
            self.phase,
357
            RunPhase::FetchingInstaller
358
                | RunPhase::Resolve
359
                | RunPhase::Download
360
                | RunPhase::Verify
361
                | RunPhase::Stage
362
        )
363
10
    }
364
}
365
366
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
367
pub enum RunPhase {
368
    FetchingInstaller,
369
    Resolve,
370
    Download,
371
    Verify,
372
    Stage,
373
    WaitingAuth,
374
    Install,
375
    PostInstall,
376
    Done,
377
    Failed,
378
}
379
380
#[cfg(test)]
381
mod tests {
382
    use super::{RunOutcome, RunPhase, UpdateRun, UpdateState};
383
    use crate::core::app::updater::{InstallerEvent, UpdateRunEvent};
384
    use super_stt_shared::models::self_update::{InstallerAsset, SelfUpdateStatus};
385
386
    /// All non-terminal (mid-flight) phases — used to sweep invariants that
387
    /// must hold across every phase before `Done`/`Failed`.
388
    const IN_FLIGHT_PHASES: [RunPhase; 8] = [
389
        RunPhase::FetchingInstaller,
390
        RunPhase::Resolve,
391
        RunPhase::Download,
392
        RunPhase::Verify,
393
        RunPhase::Stage,
394
        RunPhase::WaitingAuth,
395
        RunPhase::Install,
396
        RunPhase::PostInstall,
397
    ];
398
399
72
    fn run(phase: RunPhase) -> UpdateRun {
400
72
        UpdateRun {
401
72
            phase,
402
72
            bytes_done: 0,
403
72
            bytes_total: 0,
404
72
            error: None,
405
72
            error_code: None,
406
72
            completed_components: Vec::new(),
407
72
        }
408
72
    }
409
410
18
    fn status(update_available: bool) -> SelfUpdateStatus {
411
18
        SelfUpdateStatus {
412
18
            current_version: "0.2.2-beta.2".to_string(),
413
18
            latest_version: Some("v0.2.3".to_string()),
414
18
            update_available,
415
18
            checked_at: None,
416
18
            last_check_error: None,
417
18
            beta_optin_effective: false,
418
18
            installer_asset: Some(InstallerAsset {
419
18
                name: "super-stt-installer".to_string(),
420
18
                url: "https://example.invalid/installer".to_string(),
421
18
                size: 1024,
422
18
                sha256: "0".repeat(64),
423
18
            }),
424
18
        }
425
18
    }
426
427
    // ---- failure classification -------------------------------------------
428
429
    /// The installer's typed code has to survive into state, or the panel has
430
    /// nothing but prose to branch on — and `pkexec` localizes its prose.
431
    #[test]
432
1
    fn an_installer_failure_keeps_its_code_beside_its_message() {
433
1
        let mut update = UpdateState::default();
434
1
        update.begin_run();
435
1
        let outcome = update.apply_run_event(UpdateRunEvent::Installer(InstallerEvent::Error {
436
1
            code: "escalation_denied".to_string(),
437
1
            message: "authorization was denied".to_string(),
438
1
        }));
439
1
        assert_eq!(outcome, RunOutcome::Continue);
440
441
1
        let run = update.run.as_ref().expect("run");
442
1
        assert_eq!(run.phase, RunPhase::Failed);
443
1
        assert_eq!(run.error.as_deref(), Some("authorization was denied"));
444
1
        assert_eq!(run.error_code.as_deref(), Some("escalation_denied"));
445
1
    }
446
447
    /// An app-side failure never came from the installer, so it carries no
448
    /// code — and must not be mistaken for a declined prompt.
449
    #[test]
450
1
    fn an_app_side_failure_carries_no_installer_code() {
451
1
        let mut update = UpdateState::default();
452
1
        update.begin_run();
453
1
        let _ = update.apply_run_event(UpdateRunEvent::Failed("spawn failed".to_string()));
454
455
1
        let run = update.run.as_ref().expect("run");
456
1
        assert_eq!(run.phase, RunPhase::Failed);
457
1
        assert!(run.error_code.is_none());
458
1
        assert!(!run.was_authorization_declined());
459
1
    }
460
461
    /// Only the declined prompt is a declined prompt. `escalation_unavailable`
462
    /// in particular must NOT be: no polkit agent to authorize against is
463
    /// exactly the case where installing outside the app is the only way
464
    /// forward, so its panel has to keep offering that.
465
    #[test]
466
1
    fn only_escalation_denied_counts_as_a_declined_prompt() {
467
1
        let declined = UpdateRun {
468
1
            error_code: Some("escalation_denied".to_string()),
469
1
            ..run(RunPhase::Failed)
470
1
        };
471
1
        assert!(declined.was_authorization_declined());
472
473
7
        for code in [
474
1
            "escalation_unavailable",
475
1
            "install_failed",
476
1
            "post_install_failed",
477
1
            "checksum_mismatch",
478
1
            "download_failed",
479
1
            "no_release_found",
480
1
            "unsupported_arch",
481
1
        ] {
482
7
            let other = UpdateRun {
483
7
                error_code: Some(code.to_string()),
484
7
                ..run(RunPhase::Failed)
485
7
            };
486
7
            assert!(
487
7
                !other.was_authorization_declined(),
488
                "{code} is a real failure, not a declined prompt"
489
            );
490
        }
491
492
1
        assert!(!run(RunPhase::Failed).was_authorization_declined());
493
1
    }
494
495
    // ---- first-check kick ------------------------------------------------
496
497
    /// A daemon that has not checked yet reports no candidate at all, so the
498
    /// page and the badge read empty until its deferred first check runs.
499
    /// Asking once closes that window; the latch is what keeps a failing
500
    /// check from asking forever.
501
    #[test]
502
1
    fn a_never_checked_daemon_is_asked_to_check() {
503
1
        let mut update = UpdateState::default();
504
1
        update.apply_status_loaded(Some(SelfUpdateStatus {
505
1
            checked_at: None,
506
1
            ..status(false)
507
1
        }));
508
1
        assert!(update.wants_first_check());
509
510
        // The check that follows sets `checking`, which closes the ask for
511
        // the round trip; the status it returns closes it for good.
512
1
        update.checking = true;
513
1
        assert!(!update.wants_first_check());
514
1
    }
515
516
    #[test]
517
1
    fn a_daemon_that_has_already_checked_is_left_alone() {
518
1
        let mut update = UpdateState::default();
519
1
        update.apply_status_loaded(Some(SelfUpdateStatus {
520
1
            checked_at: Some("2026-08-25T00:00:00Z".to_string()),
521
1
            ..status(false)
522
1
        }));
523
1
        assert!(!update.wants_first_check());
524
1
    }
525
526
    #[test]
527
1
    fn no_first_check_before_a_status_or_against_an_unsupported_daemon() {
528
1
        assert!(
529
1
            !UpdateState::default().wants_first_check(),
530
            "nothing to conclude before the first status lands"
531
        );
532
533
1
        let mut update = UpdateState::default();
534
1
        update.apply_status_loaded(None);
535
1
        assert!(update.unsupported);
536
1
        assert!(!update.wants_first_check());
537
1
    }
538
539
    #[test]
540
1
    fn no_first_check_while_busy() {
541
1
        let never = SelfUpdateStatus {
542
1
            checked_at: None,
543
1
            ..status(false)
544
1
        };
545
546
1
        let checking = UpdateState {
547
1
            status: Some(never.clone()),
548
1
            checking: true,
549
1
            ..Default::default()
550
1
        };
551
1
        assert!(!checking.wants_first_check());
552
553
1
        let running = UpdateState {
554
1
            status: Some(never),
555
1
            run: Some(run(RunPhase::Download)),
556
1
            ..Default::default()
557
1
        };
558
1
        assert!(!running.wants_first_check());
559
1
    }
560
561
5
    fn beta_status(beta_optin_effective: bool) -> SelfUpdateStatus {
562
5
        SelfUpdateStatus {
563
5
            beta_optin_effective,
564
5
            ..status(false)
565
5
        }
566
5
    }
567
568
    // ---- beta opt-in toggle ---------------------------------------------
569
570
    /// Before anything is toggled the switch reports what the daemon says —
571
    /// including the pre-status default, where there is nothing to report.
572
    #[test]
573
1
    fn beta_toggle_falls_back_to_the_daemon_until_it_is_touched() {
574
1
        assert!(!UpdateState::default().beta_optin_shown());
575
576
1
        let mut update = UpdateState::default();
577
1
        update.apply_status_loaded(Some(beta_status(true)));
578
1
        assert!(update.beta_optin_shown());
579
1
    }
580
581
    /// The point of owning the value locally: pressing the switch moves it,
582
    /// without waiting for the network re-check that follows.
583
    #[test]
584
1
    fn beta_toggle_shows_the_pressed_value_before_a_status_arrives() {
585
1
        let mut update = UpdateState::default();
586
1
        update.apply_status_loaded(Some(beta_status(false)));
587
588
        // What the handler does on press.
589
1
        update.beta_optin = Some(true);
590
1
        update.beta_pending = true;
591
592
1
        assert!(update.beta_optin_shown());
593
1
        assert!(
594
1
            !update
595
1
                .status
596
1
                .as_ref()
597
1
                .expect("status stored")
598
1
                .beta_optin_effective,
599
            "the daemon has not been told yet — only the local value moved"
600
        );
601
1
    }
602
603
    /// A status fetched before our write landed must not drag the switch back
604
    /// under the user's finger, only for our own re-check to flip it again.
605
    #[test]
606
1
    fn a_stale_status_does_not_move_a_pending_beta_toggle() {
607
1
        let mut update = UpdateState {
608
1
            beta_optin: Some(true),
609
1
            beta_pending: true,
610
1
            ..Default::default()
611
1
        };
612
1
        update.apply_status_loaded(Some(beta_status(false)));
613
1
        assert!(update.beta_optin_shown(), "pending write wins");
614
615
        // Once the write settles, the daemon has the last word again.
616
1
        update.beta_pending = false;
617
1
        update.apply_status_loaded(Some(beta_status(false)));
618
1
        assert!(!update.beta_optin_shown());
619
1
    }
620
621
    /// Another client changing the channel is a real change, and the switch
622
    /// must follow it — the local value is a cache, not an override.
623
    #[test]
624
1
    fn an_unpending_status_resyncs_the_beta_toggle() {
625
1
        let mut update = UpdateState {
626
1
            beta_optin: Some(false),
627
1
            ..Default::default()
628
1
        };
629
1
        update.apply_status_loaded(Some(beta_status(true)));
630
1
        assert!(update.beta_optin_shown());
631
1
    }
632
633
    // ---- apply_status_loaded --------------------------------------------
634
635
    #[test]
636
1
    fn apply_status_loaded_none_marks_unsupported() {
637
1
        let mut update = UpdateState::default();
638
1
        update.apply_status_loaded(None);
639
1
        assert!(update.unsupported);
640
1
        assert!(update.status.is_none());
641
1
        assert!(!update.checking, "checking must end regardless of outcome");
642
1
    }
643
644
    #[test]
645
1
    fn apply_status_loaded_some_clears_unsupported_and_stores_status() {
646
1
        let mut update = UpdateState {
647
1
            unsupported: true,
648
1
            checking: true,
649
1
            ..Default::default()
650
1
        };
651
1
        update.apply_status_loaded(Some(status(true)));
652
1
        assert!(!update.unsupported);
653
1
        assert_eq!(
654
1
            update.status.as_ref().map(|s| s.update_available),
655
            Some(true)
656
        );
657
1
        assert!(!update.checking);
658
1
    }
659
660
    #[test]
661
1
    fn apply_status_error_sets_banner_and_ends_checking() {
662
1
        let mut update = UpdateState {
663
1
            checking: true,
664
1
            ..Default::default()
665
1
        };
666
1
        update.apply_status_error("boom");
667
1
        assert!(!update.checking);
668
1
        assert_eq!(
669
1
            update.action_error.as_deref(),
670
            Some("Couldn't fetch update status: boom")
671
        );
672
1
    }
673
674
    #[test]
675
1
    fn apply_setting_error_sets_its_own_banner_text() {
676
1
        let mut update = UpdateState::default();
677
1
        update.apply_setting_error("boom");
678
1
        assert_eq!(
679
1
            update.action_error.as_deref(),
680
            Some("Couldn't update setting: boom")
681
        );
682
1
    }
683
684
    // ---- clear_run_on_status_refresh: every RunPhase ----------------------
685
686
    /// (a) A completed app-component update's Restart affordance must
687
    /// survive the `Done → AvailableEventReceived → StatusLoaded` refetch
688
    /// that fires right after the daemon restarts onto the new version —
689
    /// even though that fresh status now reports `update_available: false`.
690
    #[test]
691
1
    fn clear_run_on_status_refresh_keeps_a_done_run() {
692
1
        let mut update = UpdateState {
693
1
            run: Some(UpdateRun {
694
1
                completed_components: vec!["daemon".to_string(), "app".to_string()],
695
1
                ..run(RunPhase::Done)
696
1
            }),
697
1
            ..Default::default()
698
1
        };
699
700
1
        update.clear_run_on_status_refresh();
701
702
1
        let run = update.run.expect("Done run must survive a status refresh");
703
1
        assert_eq!(run.phase, RunPhase::Done);
704
2
        
assert!1
(
run.completed_components.iter()1
.
any1
(|c| c == "app"));
705
1
    }
706
707
    /// A `Failed` run has no further affordance to preserve, so a fresh
708
    /// status refetch supersedes it rather than leaving stale error text
709
    /// masking newer information (e.g. a newer version now available).
710
    #[test]
711
1
    fn clear_run_on_status_refresh_clears_a_failed_run() {
712
1
        let mut update = UpdateState {
713
1
            run: Some(run(RunPhase::Failed)),
714
1
            ..Default::default()
715
1
        };
716
717
1
        update.clear_run_on_status_refresh();
718
719
1
        assert!(update.run.is_none());
720
1
    }
721
722
    /// An in-flight run must never be silently dropped by a status
723
    /// refetch — only `CancelUpdate` (which respects
724
    /// `UpdateRun::cancellable()`) or a terminal transition ends it. Swept
725
    /// across every mid-flight phase, not just one.
726
    #[test]
727
1
    fn clear_run_on_status_refresh_never_clears_an_in_flight_run() {
728
8
        for phase in IN_FLIGHT_PHASES {
729
8
            let mut update = UpdateState {
730
8
                run: Some(run(phase)),
731
8
                ..Default::default()
732
8
            };
733
8
            update.clear_run_on_status_refresh();
734
8
            assert_eq!(update.run.map(|r| r.phase), Some(phase), "phase {phase:?}");
735
        }
736
1
    }
737
738
    // ---- dismiss_run: every RunPhase --------------------------------------
739
740
    /// Dismissing a `Failed` run clears it — `can_start_update`'s guard is
741
    /// `run.is_none()`, so a cleared run means a subsequent `StartUpdate` is
742
    /// no longer blocked.
743
    #[test]
744
1
    fn dismiss_run_clears_a_failed_run_so_a_future_start_update_is_not_blocked() {
745
1
        let mut update = UpdateState {
746
1
            status: Some(status(true)),
747
1
            run: Some(run(RunPhase::Failed)),
748
1
            ..Default::default()
749
1
        };
750
751
1
        update.dismiss_run();
752
753
1
        assert!(
754
1
            update.run.is_none(),
755
            "can_start_update's `run.is_none()` guard must see None after Dismiss"
756
        );
757
1
        assert!(update.can_start_update());
758
1
    }
759
760
    /// Dismiss also clears a `Done` run (e.g. after the user chose not to
761
    /// restart, or a failed relaunch attempt) so it doesn't linger forever.
762
    #[test]
763
1
    fn dismiss_run_clears_a_done_run() {
764
1
        let mut update = UpdateState {
765
1
            run: Some(run(RunPhase::Done)),
766
1
            ..Default::default()
767
1
        };
768
769
1
        update.dismiss_run();
770
771
1
        assert!(update.run.is_none());
772
1
    }
773
774
    /// Dismiss must not be able to end an in-flight run in any mid-flight
775
    /// phase — that path belongs to `CancelUpdate`, which additionally
776
    /// enforces the escalate-phase safety cutoff (`UpdateRun::cancellable`).
777
    #[test]
778
1
    fn dismiss_run_never_clears_an_in_flight_run() {
779
8
        for phase in IN_FLIGHT_PHASES {
780
8
            let mut update = UpdateState {
781
8
                run: Some(run(phase)),
782
8
                ..Default::default()
783
8
            };
784
8
            update.dismiss_run();
785
8
            assert_eq!(update.run.map(|r| r.phase), Some(phase), "phase {phase:?}");
786
        }
787
1
    }
788
789
    // ---- apply_run_event: installer phase mapping -------------------------
790
791
    #[test]
792
1
    fn apply_run_event_maps_every_installer_phase() {
793
1
        let cases = [
794
1
            ("resolve", RunPhase::Resolve),
795
1
            ("download", RunPhase::Download),
796
1
            ("verify", RunPhase::Verify),
797
1
            ("stage", RunPhase::Stage),
798
1
            ("escalate", RunPhase::WaitingAuth),
799
1
            ("install", RunPhase::Install),
800
1
            ("post_install", RunPhase::PostInstall),
801
1
        ];
802
7
        for (wire, expected) in 
cases1
{
803
7
            let mut update = UpdateState {
804
7
                run: Some(run(RunPhase::FetchingInstaller)),
805
7
                ..Default::default()
806
7
            };
807
7
            let outcome =
808
7
                update.apply_run_event(UpdateRunEvent::Installer(InstallerEvent::Phase {
809
7
                    phase: wire.to_string(),
810
7
                    message: "x".to_string(),
811
7
                }));
812
7
            assert_eq!(outcome, RunOutcome::Continue);
813
7
            assert_eq!(
814
7
                update.run.map(|r| r.phase),
815
7
                Some(expected),
816
                "wire phase {wire:?}"
817
            );
818
        }
819
1
    }
820
821
    /// An unknown/future phase string must leave the current phase
822
    /// unchanged (forward-compat with a newer installer), never panic or
823
    /// reset to some default.
824
    #[test]
825
1
    fn apply_run_event_leaves_phase_unchanged_on_unknown_phase_string() {
826
1
        let mut update = UpdateState {
827
1
            run: Some(run(RunPhase::Stage)),
828
1
            ..Default::default()
829
1
        };
830
1
        let outcome = update.apply_run_event(UpdateRunEvent::Installer(InstallerEvent::Phase {
831
1
            phase: "defragment".to_string(),
832
1
            message: "x".to_string(),
833
1
        }));
834
1
        assert_eq!(outcome, RunOutcome::Continue);
835
1
        assert_eq!(update.run.map(|r| r.phase), Some(RunPhase::Stage));
836
1
    }
837
838
    #[test]
839
1
    fn apply_run_event_complete_records_completed_components() {
840
1
        let mut update = UpdateState {
841
1
            run: Some(run(RunPhase::Install)),
842
1
            ..Default::default()
843
1
        };
844
1
        let _ = update.apply_run_event(UpdateRunEvent::Installer(InstallerEvent::Complete {
845
1
            installed_version: "v0.2.3".to_string(),
846
1
            components: vec!["daemon".to_string(), "app".to_string()],
847
1
        }));
848
1
        let run = update.run.expect("run must still exist");
849
1
        assert_eq!(run.phase, RunPhase::Done);
850
1
        assert_eq!(run.completed_components, vec!["daemon", "app"]);
851
1
    }
852
853
    #[test]
854
1
    fn apply_run_event_installer_error_lands_in_failed_with_message() {
855
1
        let mut update = UpdateState {
856
1
            run: Some(run(RunPhase::Verify)),
857
1
            ..Default::default()
858
1
        };
859
1
        let _ = update.apply_run_event(UpdateRunEvent::Installer(InstallerEvent::Error {
860
1
            code: "checksum_mismatch".to_string(),
861
1
            message: "boom".to_string(),
862
1
        }));
863
1
        let run = update.run.expect("run must still exist");
864
1
        assert_eq!(run.phase, RunPhase::Failed);
865
1
        assert_eq!(run.error.as_deref(), Some("boom"));
866
1
    }
867
868
    #[test]
869
1
    fn apply_run_event_app_side_failed_lands_in_failed_with_message() {
870
1
        let mut update = UpdateState {
871
1
            run: Some(run(RunPhase::Download)),
872
1
            ..Default::default()
873
1
        };
874
1
        let _ = update.apply_run_event(UpdateRunEvent::Failed(
875
1
            "spawn installer-bin: boom".to_string(),
876
1
        ));
877
1
        let run = update.run.expect("run must still exist");
878
1
        assert_eq!(run.phase, RunPhase::Failed);
879
1
        assert_eq!(run.error.as_deref(), Some("spawn installer-bin: boom"));
880
1
    }
881
882
    #[test]
883
1
    fn apply_run_event_finished_after_non_terminal_becomes_failed_with_stderr_tail() {
884
1
        let mut update = UpdateState {
885
1
            run: Some(run(RunPhase::Install)),
886
1
            ..Default::default()
887
1
        };
888
1
        let outcome = update.apply_run_event(UpdateRunEvent::Finished {
889
1
            exit_ok: false,
890
1
            stderr_tail: "line1\nline2".to_string(),
891
1
        });
892
1
        assert_eq!(outcome, RunOutcome::Continue);
893
1
        let run = update.run.expect("run must still exist");
894
1
        assert_eq!(run.phase, RunPhase::Failed);
895
1
        assert_eq!(run.error.as_deref(), Some("installer failed: line1\nline2"));
896
1
    }
897
898
    #[test]
899
1
    fn apply_run_event_finished_exit_ok_after_non_terminal_is_still_failed() {
900
        // exit_ok but no terminal InstallerEvent ever arrived — the
901
        // installer exited without ever reporting Complete/Error.
902
1
        let mut update = UpdateState {
903
1
            run: Some(run(RunPhase::PostInstall)),
904
1
            ..Default::default()
905
1
        };
906
1
        let _ = update.apply_run_event(UpdateRunEvent::Finished {
907
1
            exit_ok: true,
908
1
            stderr_tail: String::new(),
909
1
        });
910
1
        let run = update.run.expect("run must still exist");
911
1
        assert_eq!(run.phase, RunPhase::Failed);
912
1
        assert_eq!(run.error.as_deref(), Some("installer ended unexpectedly"));
913
1
    }
914
915
    #[test]
916
1
    fn apply_run_event_finished_after_done_keeps_done_and_requests_refetch() {
917
1
        let mut update = UpdateState {
918
1
            run: Some(run(RunPhase::Done)),
919
1
            ..Default::default()
920
1
        };
921
1
        let outcome = update.apply_run_event(UpdateRunEvent::Finished {
922
1
            exit_ok: true,
923
1
            stderr_tail: String::new(),
924
1
        });
925
1
        assert_eq!(outcome, RunOutcome::RefetchStatus);
926
1
        assert_eq!(update.run.map(|r| r.phase), Some(RunPhase::Done));
927
1
    }
928
929
    #[test]
930
1
    fn apply_run_event_finished_after_failed_stays_failed_unchanged() {
931
1
        let mut update = UpdateState {
932
1
            run: Some(UpdateRun {
933
1
                error: Some("original error".to_string()),
934
1
                ..run(RunPhase::Failed)
935
1
            }),
936
1
            ..Default::default()
937
1
        };
938
1
        let outcome = update.apply_run_event(UpdateRunEvent::Finished {
939
1
            exit_ok: false,
940
1
            stderr_tail: "ignored".to_string(),
941
1
        });
942
1
        assert_eq!(outcome, RunOutcome::Continue);
943
1
        let run = update.run.expect("run must still exist");
944
1
        assert_eq!(run.phase, RunPhase::Failed);
945
1
        assert_eq!(
946
1
            run.error.as_deref(),
947
            Some("original error"),
948
            "must not overwrite the original failure with the exit reason"
949
        );
950
1
    }
951
952
    #[test]
953
1
    fn apply_run_event_is_a_no_op_without_an_active_run() {
954
1
        let mut update = UpdateState::default();
955
1
        let outcome = update.apply_run_event(UpdateRunEvent::Failed("x".to_string()));
956
1
        assert_eq!(outcome, RunOutcome::Continue);
957
1
        assert!(update.run.is_none());
958
1
    }
959
960
    // ---- byte-progress fields ----------------------------------------------
961
962
    #[test]
963
1
    fn fetch_progress_updates_byte_fields_and_phase() {
964
1
        let mut update = UpdateState {
965
1
            run: Some(run(RunPhase::Resolve)),
966
1
            ..Default::default()
967
1
        };
968
1
        let _ = update.apply_run_event(UpdateRunEvent::FetchProgress {
969
1
            bytes_done: 512,
970
1
            bytes_total: 2048,
971
1
        });
972
1
        let run = update.run.expect("run must still exist");
973
1
        assert_eq!(run.phase, RunPhase::FetchingInstaller);
974
1
        assert_eq!(run.bytes_done, 512);
975
1
        assert_eq!(run.bytes_total, 2048);
976
1
    }
977
978
    #[test]
979
1
    fn installer_progress_updates_byte_fields_without_changing_phase() {
980
1
        let mut update = UpdateState {
981
1
            run: Some(run(RunPhase::Download)),
982
1
            ..Default::default()
983
1
        };
984
1
        let _ = update.apply_run_event(UpdateRunEvent::Installer(InstallerEvent::Progress {
985
1
            phase: "download".to_string(),
986
1
            bytes_done: 100,
987
1
            bytes_total: 400,
988
1
        }));
989
1
        let run = update.run.expect("run must still exist");
990
1
        assert_eq!(run.phase, RunPhase::Download);
991
1
        assert_eq!(run.bytes_done, 100);
992
1
        assert_eq!(run.bytes_total, 400);
993
1
    }
994
995
    // ---- predicates: truth tables ------------------------------------------
996
997
    #[test]
998
1
    fn can_start_update_truth_table() {
999
        // No status yet: never startable.
1000
1
        assert!(!UpdateState::default().can_start_update());
1001
1002
        // Status with no installer asset (unsupported host arch): not startable.
1003
1
        let mut update = UpdateState {
1004
1
            status: Some(SelfUpdateStatus {
1005
1
                installer_asset: None,
1006
1
                ..status(true)
1007
1
            }),
1008
1
            ..Default::default()
1009
1
        };
1010
1
        assert!(!update.can_start_update());
1011
1012
        // Status with asset+tag, no run: startable.
1013
1
        update.status = Some(status(true));
1014
1
        assert!(update.can_start_update());
1015
1016
        // A run in ANY phase (including terminal) blocks a start.
1017
10
        for phase in IN_FLIGHT_PHASES
1018
1
            .into_iter()
1019
1
            .chain([RunPhase::Done, RunPhase::Failed])
1020
        {
1021
10
            update.run = Some(run(phase));
1022
10
            assert!(!update.can_start_update(), "phase {phase:?}");
1023
        }
1024
1025
        // Dismissing a terminal run un-blocks it again (Dismiss/auto-clear
1026
        // provide the reset).
1027
1
        update.run = Some(run(RunPhase::Failed));
1028
1
        update.dismiss_run();
1029
1
        assert!(update.can_start_update());
1030
1
    }
1031
1032
    /// An asset with no resolved tag can't be started — the view must gate
1033
    /// its idle CTA on `can_start_update()` rather than re-deriving a
1034
    /// weaker `installer_asset.is_some()` condition that misses this case.
1035
    #[test]
1036
1
    fn can_start_update_false_with_asset_but_no_tag() {
1037
1
        let update = UpdateState {
1038
1
            status: Some(SelfUpdateStatus {
1039
1
                latest_version: None,
1040
1
                ..status(true)
1041
1
            }),
1042
1
            ..Default::default()
1043
1
        };
1044
1
        assert!(!update.can_start_update());
1045
1
    }
1046
1047
    #[test]
1048
1
    fn can_check_now_truth_table() {
1049
1
        assert!(UpdateState::default().can_check_now());
1050
1051
1
        assert!(
1052
1
            !UpdateState {
1053
1
                checking: true,
1054
1
                ..Default::default()
1055
1
            }
1056
1
            .can_check_now()
1057
        );
1058
1059
1
        assert!(
1060
1
            !UpdateState {
1061
1
                run: Some(run(RunPhase::Install)),
1062
1
                ..Default::default()
1063
1
            }
1064
1
            .can_check_now()
1065
        );
1066
1067
        // F1: an unsupported daemon must disable "Check now" too, not just
1068
        // surface a repeat error on press.
1069
1
        assert!(
1070
1
            !UpdateState {
1071
1
                unsupported: true,
1072
1
                ..Default::default()
1073
1
            }
1074
1
            .can_check_now()
1075
        );
1076
1
    }
1077
1078
    #[test]
1079
1
    fn update_offered_truth_table() {
1080
1
        assert!(!UpdateState::default().update_offered());
1081
1
        assert!(
1082
1
            !UpdateState {
1083
1
                status: Some(status(false)),
1084
1
                ..Default::default()
1085
1
            }
1086
1
            .update_offered()
1087
        );
1088
1
        assert!(
1089
1
            UpdateState {
1090
1
                status: Some(status(true)),
1091
1
                ..Default::default()
1092
1
            }
1093
1
            .update_offered()
1094
        );
1095
1
    }
1096
1097
    /// The run UI must render whenever a run exists, independent of
1098
    /// `update_available` — the invariant that keeps the post-update
1099
    /// "Restart Super STT" CTA alive through the refetch that reports no
1100
    /// update.
1101
    #[test]
1102
1
    fn update_section_visible_truth_table() {
1103
1
        assert!(!UpdateState::default().update_section_visible());
1104
1105
1
        assert!(
1106
1
            UpdateState {
1107
1
                status: Some(status(true)),
1108
1
                ..Default::default()
1109
1
            }
1110
1
            .update_section_visible()
1111
        );
1112
1113
        // A run makes the section visible even when the freshest status
1114
        // says no update is available.
1115
1
        assert!(
1116
1
            UpdateState {
1117
1
                status: Some(status(false)),
1118
1
                run: Some(run(RunPhase::Done)),
1119
1
                ..Default::default()
1120
1
            }
1121
1
            .update_section_visible()
1122
        );
1123
1124
1
        assert!(
1125
1
            UpdateState {
1126
1
                status: None,
1127
1
                run: Some(run(RunPhase::Install)),
1128
1
                ..Default::default()
1129
1
            }
1130
1
            .update_section_visible()
1131
        );
1132
1
    }
1133
1134
    #[test]
1135
1
    fn header_badge_visible_hides_once_a_run_starts() {
1136
1
        let mut update = UpdateState {
1137
1
            status: Some(status(true)),
1138
1
            ..Default::default()
1139
1
        };
1140
1
        assert!(update.header_badge_visible());
1141
1
        update.run = Some(run(RunPhase::FetchingInstaller));
1142
1
        assert!(!update.header_badge_visible());
1143
1
    }
1144
1145
    #[test]
1146
1
    fn cancellable_truth_table() {
1147
5
        for phase in [
1148
1
            RunPhase::FetchingInstaller,
1149
1
            RunPhase::Resolve,
1150
1
            RunPhase::Download,
1151
1
            RunPhase::Verify,
1152
1
            RunPhase::Stage,
1153
1
        ] {
1154
5
            assert!(run(phase).cancellable(), "phase {phase:?}");
1155
        }
1156
5
        for phase in [
1157
1
            RunPhase::WaitingAuth,
1158
1
            RunPhase::Install,
1159
1
            RunPhase::PostInstall,
1160
1
            RunPhase::Done,
1161
1
            RunPhase::Failed,
1162
1
        ] {
1163
5
            assert!(!run(phase).cancellable(), "phase {phase:?}");
1164
        }
1165
1
    }
1166
}