Coverage Report

Created: 2026-09-05 23:00

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
20
    pub fn apply_status_loaded(&mut self, status: Option<SelfUpdateStatus>) {
56
20
        self.checking = false;
57
20
        match status {
58
16
            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
16
                if !self.beta_pending {
64
14
                    self.beta_optin = Some(s.beta_optin_effective);
65
14
                
}2
66
16
                self.unsupported = false;
67
16
                self.status = Some(s);
68
            }
69
4
            None => self.unsupported = true,
70
        }
71
20
        self.clear_run_on_status_refresh();
72
20
    }
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
2
    pub fn apply_status_error(&mut self, msg: &str) {
78
2
        self.checking = false;
79
2
        self.action_error = Some(format!("Couldn't fetch update status: {msg}"));
80
2
    }
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
2
    pub fn apply_setting_error(&mut self, msg: &str) {
86
2
        self.action_error = Some(format!("Couldn't update setting: {msg}"));
87
2
    }
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
40
    pub fn clear_run_on_status_refresh(&mut self) {
103
40
        if 
matches!38
(self.run.as_ref().map(|r| r.phase), Some(RunPhase::Failed)) {
104
2
            self.run = None;
105
2
            self.run_abort = None;
106
38
        }
107
40
    }
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
22
    pub fn dismiss_run(&mut self) {
114
22
        if self
115
22
            .run
116
22
            .as_ref()
117
22
            .is_some_and(|r| matches!(r.phase, RunPhase::Done | RunPhase::Failed))
118
6
        {
119
6
            self.run = None;
120
6
            self.run_abort = None;
121
16
        }
122
22
    }
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
32
    pub fn can_start_update(&self) -> bool {
130
32
        self.run.is_none()
131
12
            && self
132
12
                .status
133
12
                .as_ref()
134
12
                .is_some_and(|s| 
s.installer_asset10
.
is_some10
() &&
s.latest_version8
.
is_some8
())
135
32
    }
136
137
    /// Begin a new run at `FetchingInstaller`. Callers must check
138
    /// [`Self::can_start_update`] first — this unconditionally (over)writes
139
    /// `run`.
140
4
    pub fn begin_run(&mut self) {
141
4
        self.run = Some(UpdateRun {
142
4
            phase: RunPhase::FetchingInstaller,
143
4
            bytes_done: 0,
144
4
            bytes_total: 0,
145
4
            error: None,
146
4
            error_code: None,
147
4
            completed_components: Vec::new(),
148
4
        });
149
4
    }
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
8
    pub fn can_check_now(&self) -> bool {
157
8
        !self.checking && 
self.run6
.
is_none6
() &&
!self.unsupported4
158
8
    }
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
14
    pub fn wants_first_check(&self) -> bool {
181
14
        self.status.as_ref().is_some_and(|s| 
s.checked_at10
.
is_none10
())
182
8
            && !self.unsupported
183
8
            && !self.checking
184
4
            && self.run.is_none()
185
14
    }
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
12
    pub fn beta_optin_shown(&self) -> bool {
194
12
        self.beta_optin
195
12
            .unwrap_or_else(|| 
self.status2
.
as_ref2
().
is_some_and2
(|s| s.beta_optin_effective))
196
12
    }
197
198
    /// Whether the daemon's last-known status reports an installable update.
199
    #[must_use]
200
12
    pub fn update_offered(&self) -> bool {
201
12
        self.status.as_ref().is_some_and(|s| s.update_available)
202
12
    }
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
8
    pub fn update_section_visible(&self) -> bool {
211
8
        self.run.is_some() || 
self4
.
update_offered4
()
212
8
    }
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
4
    pub fn header_badge_visible(&self) -> bool {
219
4
        self.run.is_none() && 
self2
.
update_offered2
()
220
4
    }
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
40
    pub fn apply_run_event(&mut self, ev: UpdateRunEvent) -> RunOutcome {
230
40
        let Some(
run38
) = self.run.as_mut() else {
231
2
            return RunOutcome::Continue;
232
        };
233
24
        match ev {
234
            UpdateRunEvent::FetchProgress {
235
2
                bytes_done,
236
2
                bytes_total,
237
2
            } => {
238
2
                run.phase = RunPhase::FetchingInstaller;
239
2
                run.bytes_done = bytes_done;
240
2
                run.bytes_total = bytes_total;
241
2
            }
242
16
            UpdateRunEvent::Installer(InstallerEvent::Phase { phase, message }) => {
243
16
                log::debug!("installer phase: {phase} — {message}");
244
16
                run.phase = match phase.as_str() {
245
16
                    "resolve" => 
RunPhase::Resolve2
,
246
14
                    "download" => 
RunPhase::Download2
,
247
12
                    "verify" => 
RunPhase::Verify2
,
248
10
                    "stage" => 
RunPhase::Stage2
,
249
8
                    "escalate" => 
RunPhase::WaitingAuth2
,
250
6
                    "install" => 
RunPhase::Install2
,
251
4
                    "post_install" => 
RunPhase::PostInstall2
,
252
2
                    _ => run.phase, // tolerate future/unknown phases
253
                };
254
            }
255
            UpdateRunEvent::Installer(InstallerEvent::Progress {
256
2
                phase,
257
2
                bytes_done,
258
2
                bytes_total,
259
            }) => {
260
2
                log::trace!("installer progress ({phase}): {bytes_done}/{bytes_total}");
261
2
                run.bytes_done = bytes_done;
262
2
                run.bytes_total = bytes_total;
263
            }
264
            UpdateRunEvent::Installer(InstallerEvent::Complete {
265
2
                installed_version,
266
2
                components,
267
            }) => {
268
2
                log::info!("installer completed: {installed_version} ({components:?})");
269
2
                run.phase = RunPhase::Done;
270
2
                run.completed_components = components;
271
            }
272
4
            UpdateRunEvent::Installer(InstallerEvent::Error { code, message }) => {
273
4
                log::warn!("installer reported error {code}: {message}");
274
4
                run.phase = RunPhase::Failed;
275
4
                run.error = Some(message);
276
4
                run.error_code = Some(code);
277
            }
278
4
            UpdateRunEvent::Failed(message) => {
279
4
                run.phase = RunPhase::Failed;
280
4
                run.error = Some(message);
281
4
            }
282
            UpdateRunEvent::Finished {
283
8
                exit_ok,
284
8
                stderr_tail,
285
            } => {
286
8
                if run.phase == RunPhase::Done {
287
                    // Daemon was restarted by the installer; caller refreshes
288
                    // status.
289
2
                    return RunOutcome::RefetchStatus;
290
6
                }
291
6
                if run.phase != RunPhase::Failed {
292
4
                    run.phase = RunPhase::Failed;
293
4
                    run.error = Some(if exit_ok {
294
2
                        "installer ended unexpectedly".to_string()
295
                    } else {
296
2
                        format!("installer failed: {stderr_tail}")
297
                    });
298
2
                }
299
            }
300
        }
301
36
        RunOutcome::Continue
302
40
    }
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
20
    pub fn was_authorization_declined(&self) -> bool {
348
20
        self.error_code.as_deref() == Some("escalation_denied")
349
20
    }
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
20
    pub fn cancellable(&self) -> bool {
355
10
        matches!(
356
20
            self.phase,
357
            RunPhase::FetchingInstaller
358
                | RunPhase::Resolve
359
                | RunPhase::Download
360
                | RunPhase::Verify
361
                | RunPhase::Stage
362
        )
363
20
    }
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
144
    fn run(phase: RunPhase) -> UpdateRun {
400
144
        UpdateRun {
401
144
            phase,
402
144
            bytes_done: 0,
403
144
            bytes_total: 0,
404
144
            error: None,
405
144
            error_code: None,
406
144
            completed_components: Vec::new(),
407
144
        }
408
144
    }
409
410
36
    fn status(update_available: bool) -> SelfUpdateStatus {
411
36
        SelfUpdateStatus {
412
36
            current_version: "0.2.2-beta.2".to_string(),
413
36
            latest_version: Some("v0.2.3".to_string()),
414
36
            update_available,
415
36
            checked_at: None,
416
36
            last_check_error: None,
417
36
            beta_optin_effective: false,
418
36
            installer_asset: Some(InstallerAsset {
419
36
                name: "super-stt-installer".to_string(),
420
36
                url: "https://example.invalid/installer".to_string(),
421
36
                size: 1024,
422
36
                sha256: "0".repeat(64),
423
36
            }),
424
36
        }
425
36
    }
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
2
    fn an_installer_failure_keeps_its_code_beside_its_message() {
433
2
        let mut update = UpdateState::default();
434
2
        update.begin_run();
435
2
        let outcome = update.apply_run_event(UpdateRunEvent::Installer(InstallerEvent::Error {
436
2
            code: "escalation_denied".to_string(),
437
2
            message: "authorization was denied".to_string(),
438
2
        }));
439
2
        assert_eq!(outcome, RunOutcome::Continue);
440
441
2
        let run = update.run.as_ref().expect("run");
442
2
        assert_eq!(run.phase, RunPhase::Failed);
443
2
        assert_eq!(run.error.as_deref(), Some("authorization was denied"));
444
2
        assert_eq!(run.error_code.as_deref(), Some("escalation_denied"));
445
2
    }
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
2
    fn an_app_side_failure_carries_no_installer_code() {
451
2
        let mut update = UpdateState::default();
452
2
        update.begin_run();
453
2
        let _ = update.apply_run_event(UpdateRunEvent::Failed("spawn failed".to_string()));
454
455
2
        let run = update.run.as_ref().expect("run");
456
2
        assert_eq!(run.phase, RunPhase::Failed);
457
2
        assert!(run.error_code.is_none());
458
2
        assert!(!run.was_authorization_declined());
459
2
    }
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
2
    fn only_escalation_denied_counts_as_a_declined_prompt() {
467
2
        let declined = UpdateRun {
468
2
            error_code: Some("escalation_denied".to_string()),
469
2
            ..run(RunPhase::Failed)
470
2
        };
471
2
        assert!(declined.was_authorization_declined());
472
473
14
        for code in [
474
2
            "escalation_unavailable",
475
2
            "install_failed",
476
2
            "post_install_failed",
477
2
            "checksum_mismatch",
478
2
            "download_failed",
479
2
            "no_release_found",
480
2
            "unsupported_arch",
481
2
        ] {
482
14
            let other = UpdateRun {
483
14
                error_code: Some(code.to_string()),
484
14
                ..run(RunPhase::Failed)
485
14
            };
486
14
            assert!(
487
14
                !other.was_authorization_declined(),
488
                "{code} is a real failure, not a declined prompt"
489
            );
490
        }
491
492
2
        assert!(!run(RunPhase::Failed).was_authorization_declined());
493
2
    }
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
2
    fn a_never_checked_daemon_is_asked_to_check() {
503
2
        let mut update = UpdateState::default();
504
2
        update.apply_status_loaded(Some(SelfUpdateStatus {
505
2
            checked_at: None,
506
2
            ..status(false)
507
2
        }));
508
2
        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
2
        update.checking = true;
513
2
        assert!(!update.wants_first_check());
514
2
    }
515
516
    #[test]
517
2
    fn a_daemon_that_has_already_checked_is_left_alone() {
518
2
        let mut update = UpdateState::default();
519
2
        update.apply_status_loaded(Some(SelfUpdateStatus {
520
2
            checked_at: Some("2026-08-25T00:00:00Z".to_string()),
521
2
            ..status(false)
522
2
        }));
523
2
        assert!(!update.wants_first_check());
524
2
    }
525
526
    #[test]
527
2
    fn no_first_check_before_a_status_or_against_an_unsupported_daemon() {
528
2
        assert!(
529
2
            !UpdateState::default().wants_first_check(),
530
            "nothing to conclude before the first status lands"
531
        );
532
533
2
        let mut update = UpdateState::default();
534
2
        update.apply_status_loaded(None);
535
2
        assert!(update.unsupported);
536
2
        assert!(!update.wants_first_check());
537
2
    }
538
539
    #[test]
540
2
    fn no_first_check_while_busy() {
541
2
        let never = SelfUpdateStatus {
542
2
            checked_at: None,
543
2
            ..status(false)
544
2
        };
545
546
2
        let checking = UpdateState {
547
2
            status: Some(never.clone()),
548
2
            checking: true,
549
2
            ..Default::default()
550
2
        };
551
2
        assert!(!checking.wants_first_check());
552
553
2
        let running = UpdateState {
554
2
            status: Some(never),
555
2
            run: Some(run(RunPhase::Download)),
556
2
            ..Default::default()
557
2
        };
558
2
        assert!(!running.wants_first_check());
559
2
    }
560
561
10
    fn beta_status(beta_optin_effective: bool) -> SelfUpdateStatus {
562
10
        SelfUpdateStatus {
563
10
            beta_optin_effective,
564
10
            ..status(false)
565
10
        }
566
10
    }
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
2
    fn beta_toggle_falls_back_to_the_daemon_until_it_is_touched() {
574
2
        assert!(!UpdateState::default().beta_optin_shown());
575
576
2
        let mut update = UpdateState::default();
577
2
        update.apply_status_loaded(Some(beta_status(true)));
578
2
        assert!(update.beta_optin_shown());
579
2
    }
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
2
    fn beta_toggle_shows_the_pressed_value_before_a_status_arrives() {
585
2
        let mut update = UpdateState::default();
586
2
        update.apply_status_loaded(Some(beta_status(false)));
587
588
        // What the handler does on press.
589
2
        update.beta_optin = Some(true);
590
2
        update.beta_pending = true;
591
592
2
        assert!(update.beta_optin_shown());
593
2
        assert!(
594
2
            !update
595
2
                .status
596
2
                .as_ref()
597
2
                .expect("status stored")
598
2
                .beta_optin_effective,
599
            "the daemon has not been told yet — only the local value moved"
600
        );
601
2
    }
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
2
    fn a_stale_status_does_not_move_a_pending_beta_toggle() {
607
2
        let mut update = UpdateState {
608
2
            beta_optin: Some(true),
609
2
            beta_pending: true,
610
2
            ..Default::default()
611
2
        };
612
2
        update.apply_status_loaded(Some(beta_status(false)));
613
2
        assert!(update.beta_optin_shown(), "pending write wins");
614
615
        // Once the write settles, the daemon has the last word again.
616
2
        update.beta_pending = false;
617
2
        update.apply_status_loaded(Some(beta_status(false)));
618
2
        assert!(!update.beta_optin_shown());
619
2
    }
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
2
    fn an_unpending_status_resyncs_the_beta_toggle() {
625
2
        let mut update = UpdateState {
626
2
            beta_optin: Some(false),
627
2
            ..Default::default()
628
2
        };
629
2
        update.apply_status_loaded(Some(beta_status(true)));
630
2
        assert!(update.beta_optin_shown());
631
2
    }
632
633
    // ---- apply_status_loaded --------------------------------------------
634
635
    #[test]
636
2
    fn apply_status_loaded_none_marks_unsupported() {
637
2
        let mut update = UpdateState::default();
638
2
        update.apply_status_loaded(None);
639
2
        assert!(update.unsupported);
640
2
        assert!(update.status.is_none());
641
2
        assert!(!update.checking, "checking must end regardless of outcome");
642
2
    }
643
644
    #[test]
645
2
    fn apply_status_loaded_some_clears_unsupported_and_stores_status() {
646
2
        let mut update = UpdateState {
647
2
            unsupported: true,
648
2
            checking: true,
649
2
            ..Default::default()
650
2
        };
651
2
        update.apply_status_loaded(Some(status(true)));
652
2
        assert!(!update.unsupported);
653
2
        assert_eq!(
654
2
            update.status.as_ref().map(|s| s.update_available),
655
            Some(true)
656
        );
657
2
        assert!(!update.checking);
658
2
    }
659
660
    #[test]
661
2
    fn apply_status_error_sets_banner_and_ends_checking() {
662
2
        let mut update = UpdateState {
663
2
            checking: true,
664
2
            ..Default::default()
665
2
        };
666
2
        update.apply_status_error("boom");
667
2
        assert!(!update.checking);
668
2
        assert_eq!(
669
2
            update.action_error.as_deref(),
670
            Some("Couldn't fetch update status: boom")
671
        );
672
2
    }
673
674
    #[test]
675
2
    fn apply_setting_error_sets_its_own_banner_text() {
676
2
        let mut update = UpdateState::default();
677
2
        update.apply_setting_error("boom");
678
2
        assert_eq!(
679
2
            update.action_error.as_deref(),
680
            Some("Couldn't update setting: boom")
681
        );
682
2
    }
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
2
    fn clear_run_on_status_refresh_keeps_a_done_run() {
692
2
        let mut update = UpdateState {
693
2
            run: Some(UpdateRun {
694
2
                completed_components: vec!["daemon".to_string(), "app".to_string()],
695
2
                ..run(RunPhase::Done)
696
2
            }),
697
2
            ..Default::default()
698
2
        };
699
700
2
        update.clear_run_on_status_refresh();
701
702
2
        let run = update.run.expect("Done run must survive a status refresh");
703
2
        assert_eq!(run.phase, RunPhase::Done);
704
4
        
assert!2
(
run.completed_components.iter()2
.
any2
(|c| c == "app"));
705
2
    }
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
2
    fn clear_run_on_status_refresh_clears_a_failed_run() {
712
2
        let mut update = UpdateState {
713
2
            run: Some(run(RunPhase::Failed)),
714
2
            ..Default::default()
715
2
        };
716
717
2
        update.clear_run_on_status_refresh();
718
719
2
        assert!(update.run.is_none());
720
2
    }
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
2
    fn clear_run_on_status_refresh_never_clears_an_in_flight_run() {
728
16
        for phase in IN_FLIGHT_PHASES {
729
16
            let mut update = UpdateState {
730
16
                run: Some(run(phase)),
731
16
                ..Default::default()
732
16
            };
733
16
            update.clear_run_on_status_refresh();
734
16
            assert_eq!(update.run.map(|r| r.phase), Some(phase), "phase {phase:?}");
735
        }
736
2
    }
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
2
    fn dismiss_run_clears_a_failed_run_so_a_future_start_update_is_not_blocked() {
745
2
        let mut update = UpdateState {
746
2
            status: Some(status(true)),
747
2
            run: Some(run(RunPhase::Failed)),
748
2
            ..Default::default()
749
2
        };
750
751
2
        update.dismiss_run();
752
753
2
        assert!(
754
2
            update.run.is_none(),
755
            "can_start_update's `run.is_none()` guard must see None after Dismiss"
756
        );
757
2
        assert!(update.can_start_update());
758
2
    }
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
2
    fn dismiss_run_clears_a_done_run() {
764
2
        let mut update = UpdateState {
765
2
            run: Some(run(RunPhase::Done)),
766
2
            ..Default::default()
767
2
        };
768
769
2
        update.dismiss_run();
770
771
2
        assert!(update.run.is_none());
772
2
    }
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
2
    fn dismiss_run_never_clears_an_in_flight_run() {
779
16
        for phase in IN_FLIGHT_PHASES {
780
16
            let mut update = UpdateState {
781
16
                run: Some(run(phase)),
782
16
                ..Default::default()
783
16
            };
784
16
            update.dismiss_run();
785
16
            assert_eq!(update.run.map(|r| r.phase), Some(phase), "phase {phase:?}");
786
        }
787
2
    }
788
789
    // ---- apply_run_event: installer phase mapping -------------------------
790
791
    #[test]
792
2
    fn apply_run_event_maps_every_installer_phase() {
793
2
        let cases = [
794
2
            ("resolve", RunPhase::Resolve),
795
2
            ("download", RunPhase::Download),
796
2
            ("verify", RunPhase::Verify),
797
2
            ("stage", RunPhase::Stage),
798
2
            ("escalate", RunPhase::WaitingAuth),
799
2
            ("install", RunPhase::Install),
800
2
            ("post_install", RunPhase::PostInstall),
801
2
        ];
802
14
        for (wire, expected) in 
cases2
{
803
14
            let mut update = UpdateState {
804
14
                run: Some(run(RunPhase::FetchingInstaller)),
805
14
                ..Default::default()
806
14
            };
807
14
            let outcome =
808
14
                update.apply_run_event(UpdateRunEvent::Installer(InstallerEvent::Phase {
809
14
                    phase: wire.to_string(),
810
14
                    message: "x".to_string(),
811
14
                }));
812
14
            assert_eq!(outcome, RunOutcome::Continue);
813
14
            assert_eq!(
814
14
                update.run.map(|r| r.phase),
815
14
                Some(expected),
816
                "wire phase {wire:?}"
817
            );
818
        }
819
2
    }
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
2
    fn apply_run_event_leaves_phase_unchanged_on_unknown_phase_string() {
826
2
        let mut update = UpdateState {
827
2
            run: Some(run(RunPhase::Stage)),
828
2
            ..Default::default()
829
2
        };
830
2
        let outcome = update.apply_run_event(UpdateRunEvent::Installer(InstallerEvent::Phase {
831
2
            phase: "defragment".to_string(),
832
2
            message: "x".to_string(),
833
2
        }));
834
2
        assert_eq!(outcome, RunOutcome::Continue);
835
2
        assert_eq!(update.run.map(|r| r.phase), Some(RunPhase::Stage));
836
2
    }
837
838
    #[test]
839
2
    fn apply_run_event_complete_records_completed_components() {
840
2
        let mut update = UpdateState {
841
2
            run: Some(run(RunPhase::Install)),
842
2
            ..Default::default()
843
2
        };
844
2
        let _ = update.apply_run_event(UpdateRunEvent::Installer(InstallerEvent::Complete {
845
2
            installed_version: "v0.2.3".to_string(),
846
2
            components: vec!["daemon".to_string(), "app".to_string()],
847
2
        }));
848
2
        let run = update.run.expect("run must still exist");
849
2
        assert_eq!(run.phase, RunPhase::Done);
850
2
        assert_eq!(run.completed_components, vec!["daemon", "app"]);
851
2
    }
852
853
    #[test]
854
2
    fn apply_run_event_installer_error_lands_in_failed_with_message() {
855
2
        let mut update = UpdateState {
856
2
            run: Some(run(RunPhase::Verify)),
857
2
            ..Default::default()
858
2
        };
859
2
        let _ = update.apply_run_event(UpdateRunEvent::Installer(InstallerEvent::Error {
860
2
            code: "checksum_mismatch".to_string(),
861
2
            message: "boom".to_string(),
862
2
        }));
863
2
        let run = update.run.expect("run must still exist");
864
2
        assert_eq!(run.phase, RunPhase::Failed);
865
2
        assert_eq!(run.error.as_deref(), Some("boom"));
866
2
    }
867
868
    #[test]
869
2
    fn apply_run_event_app_side_failed_lands_in_failed_with_message() {
870
2
        let mut update = UpdateState {
871
2
            run: Some(run(RunPhase::Download)),
872
2
            ..Default::default()
873
2
        };
874
2
        let _ = update.apply_run_event(UpdateRunEvent::Failed(
875
2
            "spawn installer-bin: boom".to_string(),
876
2
        ));
877
2
        let run = update.run.expect("run must still exist");
878
2
        assert_eq!(run.phase, RunPhase::Failed);
879
2
        assert_eq!(run.error.as_deref(), Some("spawn installer-bin: boom"));
880
2
    }
881
882
    #[test]
883
2
    fn apply_run_event_finished_after_non_terminal_becomes_failed_with_stderr_tail() {
884
2
        let mut update = UpdateState {
885
2
            run: Some(run(RunPhase::Install)),
886
2
            ..Default::default()
887
2
        };
888
2
        let outcome = update.apply_run_event(UpdateRunEvent::Finished {
889
2
            exit_ok: false,
890
2
            stderr_tail: "line1\nline2".to_string(),
891
2
        });
892
2
        assert_eq!(outcome, RunOutcome::Continue);
893
2
        let run = update.run.expect("run must still exist");
894
2
        assert_eq!(run.phase, RunPhase::Failed);
895
2
        assert_eq!(run.error.as_deref(), Some("installer failed: line1\nline2"));
896
2
    }
897
898
    #[test]
899
2
    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
2
        let mut update = UpdateState {
903
2
            run: Some(run(RunPhase::PostInstall)),
904
2
            ..Default::default()
905
2
        };
906
2
        let _ = update.apply_run_event(UpdateRunEvent::Finished {
907
2
            exit_ok: true,
908
2
            stderr_tail: String::new(),
909
2
        });
910
2
        let run = update.run.expect("run must still exist");
911
2
        assert_eq!(run.phase, RunPhase::Failed);
912
2
        assert_eq!(run.error.as_deref(), Some("installer ended unexpectedly"));
913
2
    }
914
915
    #[test]
916
2
    fn apply_run_event_finished_after_done_keeps_done_and_requests_refetch() {
917
2
        let mut update = UpdateState {
918
2
            run: Some(run(RunPhase::Done)),
919
2
            ..Default::default()
920
2
        };
921
2
        let outcome = update.apply_run_event(UpdateRunEvent::Finished {
922
2
            exit_ok: true,
923
2
            stderr_tail: String::new(),
924
2
        });
925
2
        assert_eq!(outcome, RunOutcome::RefetchStatus);
926
2
        assert_eq!(update.run.map(|r| r.phase), Some(RunPhase::Done));
927
2
    }
928
929
    #[test]
930
2
    fn apply_run_event_finished_after_failed_stays_failed_unchanged() {
931
2
        let mut update = UpdateState {
932
2
            run: Some(UpdateRun {
933
2
                error: Some("original error".to_string()),
934
2
                ..run(RunPhase::Failed)
935
2
            }),
936
2
            ..Default::default()
937
2
        };
938
2
        let outcome = update.apply_run_event(UpdateRunEvent::Finished {
939
2
            exit_ok: false,
940
2
            stderr_tail: "ignored".to_string(),
941
2
        });
942
2
        assert_eq!(outcome, RunOutcome::Continue);
943
2
        let run = update.run.expect("run must still exist");
944
2
        assert_eq!(run.phase, RunPhase::Failed);
945
2
        assert_eq!(
946
2
            run.error.as_deref(),
947
            Some("original error"),
948
            "must not overwrite the original failure with the exit reason"
949
        );
950
2
    }
951
952
    #[test]
953
2
    fn apply_run_event_is_a_no_op_without_an_active_run() {
954
2
        let mut update = UpdateState::default();
955
2
        let outcome = update.apply_run_event(UpdateRunEvent::Failed("x".to_string()));
956
2
        assert_eq!(outcome, RunOutcome::Continue);
957
2
        assert!(update.run.is_none());
958
2
    }
959
960
    // ---- byte-progress fields ----------------------------------------------
961
962
    #[test]
963
2
    fn fetch_progress_updates_byte_fields_and_phase() {
964
2
        let mut update = UpdateState {
965
2
            run: Some(run(RunPhase::Resolve)),
966
2
            ..Default::default()
967
2
        };
968
2
        let _ = update.apply_run_event(UpdateRunEvent::FetchProgress {
969
2
            bytes_done: 512,
970
2
            bytes_total: 2048,
971
2
        });
972
2
        let run = update.run.expect("run must still exist");
973
2
        assert_eq!(run.phase, RunPhase::FetchingInstaller);
974
2
        assert_eq!(run.bytes_done, 512);
975
2
        assert_eq!(run.bytes_total, 2048);
976
2
    }
977
978
    #[test]
979
2
    fn installer_progress_updates_byte_fields_without_changing_phase() {
980
2
        let mut update = UpdateState {
981
2
            run: Some(run(RunPhase::Download)),
982
2
            ..Default::default()
983
2
        };
984
2
        let _ = update.apply_run_event(UpdateRunEvent::Installer(InstallerEvent::Progress {
985
2
            phase: "download".to_string(),
986
2
            bytes_done: 100,
987
2
            bytes_total: 400,
988
2
        }));
989
2
        let run = update.run.expect("run must still exist");
990
2
        assert_eq!(run.phase, RunPhase::Download);
991
2
        assert_eq!(run.bytes_done, 100);
992
2
        assert_eq!(run.bytes_total, 400);
993
2
    }
994
995
    // ---- predicates: truth tables ------------------------------------------
996
997
    #[test]
998
2
    fn can_start_update_truth_table() {
999
        // No status yet: never startable.
1000
2
        assert!(!UpdateState::default().can_start_update());
1001
1002
        // Status with no installer asset (unsupported host arch): not startable.
1003
2
        let mut update = UpdateState {
1004
2
            status: Some(SelfUpdateStatus {
1005
2
                installer_asset: None,
1006
2
                ..status(true)
1007
2
            }),
1008
2
            ..Default::default()
1009
2
        };
1010
2
        assert!(!update.can_start_update());
1011
1012
        // Status with asset+tag, no run: startable.
1013
2
        update.status = Some(status(true));
1014
2
        assert!(update.can_start_update());
1015
1016
        // A run in ANY phase (including terminal) blocks a start.
1017
20
        for phase in IN_FLIGHT_PHASES
1018
2
            .into_iter()
1019
2
            .chain([RunPhase::Done, RunPhase::Failed])
1020
        {
1021
20
            update.run = Some(run(phase));
1022
20
            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
2
        update.run = Some(run(RunPhase::Failed));
1028
2
        update.dismiss_run();
1029
2
        assert!(update.can_start_update());
1030
2
    }
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
2
    fn can_start_update_false_with_asset_but_no_tag() {
1037
2
        let update = UpdateState {
1038
2
            status: Some(SelfUpdateStatus {
1039
2
                latest_version: None,
1040
2
                ..status(true)
1041
2
            }),
1042
2
            ..Default::default()
1043
2
        };
1044
2
        assert!(!update.can_start_update());
1045
2
    }
1046
1047
    #[test]
1048
2
    fn can_check_now_truth_table() {
1049
2
        assert!(UpdateState::default().can_check_now());
1050
1051
2
        assert!(
1052
2
            !UpdateState {
1053
2
                checking: true,
1054
2
                ..Default::default()
1055
2
            }
1056
2
            .can_check_now()
1057
        );
1058
1059
2
        assert!(
1060
2
            !UpdateState {
1061
2
                run: Some(run(RunPhase::Install)),
1062
2
                ..Default::default()
1063
2
            }
1064
2
            .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
2
        assert!(
1070
2
            !UpdateState {
1071
2
                unsupported: true,
1072
2
                ..Default::default()
1073
2
            }
1074
2
            .can_check_now()
1075
        );
1076
2
    }
1077
1078
    #[test]
1079
2
    fn update_offered_truth_table() {
1080
2
        assert!(!UpdateState::default().update_offered());
1081
2
        assert!(
1082
2
            !UpdateState {
1083
2
                status: Some(status(false)),
1084
2
                ..Default::default()
1085
2
            }
1086
2
            .update_offered()
1087
        );
1088
2
        assert!(
1089
2
            UpdateState {
1090
2
                status: Some(status(true)),
1091
2
                ..Default::default()
1092
2
            }
1093
2
            .update_offered()
1094
        );
1095
2
    }
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
2
    fn update_section_visible_truth_table() {
1103
2
        assert!(!UpdateState::default().update_section_visible());
1104
1105
2
        assert!(
1106
2
            UpdateState {
1107
2
                status: Some(status(true)),
1108
2
                ..Default::default()
1109
2
            }
1110
2
            .update_section_visible()
1111
        );
1112
1113
        // A run makes the section visible even when the freshest status
1114
        // says no update is available.
1115
2
        assert!(
1116
2
            UpdateState {
1117
2
                status: Some(status(false)),
1118
2
                run: Some(run(RunPhase::Done)),
1119
2
                ..Default::default()
1120
2
            }
1121
2
            .update_section_visible()
1122
        );
1123
1124
2
        assert!(
1125
2
            UpdateState {
1126
2
                status: None,
1127
2
                run: Some(run(RunPhase::Install)),
1128
2
                ..Default::default()
1129
2
            }
1130
2
            .update_section_visible()
1131
        );
1132
2
    }
1133
1134
    #[test]
1135
2
    fn header_badge_visible_hides_once_a_run_starts() {
1136
2
        let mut update = UpdateState {
1137
2
            status: Some(status(true)),
1138
2
            ..Default::default()
1139
2
        };
1140
2
        assert!(update.header_badge_visible());
1141
2
        update.run = Some(run(RunPhase::FetchingInstaller));
1142
2
        assert!(!update.header_badge_visible());
1143
2
    }
1144
1145
    #[test]
1146
2
    fn cancellable_truth_table() {
1147
10
        for phase in [
1148
2
            RunPhase::FetchingInstaller,
1149
2
            RunPhase::Resolve,
1150
2
            RunPhase::Download,
1151
2
            RunPhase::Verify,
1152
2
            RunPhase::Stage,
1153
2
        ] {
1154
10
            assert!(run(phase).cancellable(), "phase {phase:?}");
1155
        }
1156
10
        for phase in [
1157
2
            RunPhase::WaitingAuth,
1158
2
            RunPhase::Install,
1159
2
            RunPhase::PostInstall,
1160
2
            RunPhase::Done,
1161
2
            RunPhase::Failed,
1162
2
        ] {
1163
10
            assert!(!run(phase).cancellable(), "phase {phase:?}");
1164
        }
1165
2
    }
1166
}