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 | | } |