Skip to main content

studio_worker/ui/
app.rs

1//! The eframe `App`: a client of the daemon.  It renders the [`Replica`]
2//! the poller keeps fresh and sends the operator's actions through the
3//! [`ActionRunner`]; it never runs a job itself.
4
5use std::{
6    path::PathBuf,
7    sync::{atomic::Ordering, Arc},
8    time::Duration,
9};
10
11use eframe::egui;
12use parking_lot::Mutex;
13use tokio::runtime::Handle;
14
15use crate::{
16    daemon_api::EditableConfig,
17    daemon_link::{Action, LinkState, Replica},
18    runtime::HEARTBEAT_INTERVAL,
19};
20
21use super::{
22    actions::ActionRunner,
23    notifier::{decide, NotificationPrefs, Notifier, NotifyDecision},
24    tab::Tab,
25    tabs::{
26        about::{self as about_tab, AboutState, UpdateFeed},
27        config::{self as config_tab, ConfigDraft},
28        jobs::{self as jobs_tab, JobsContext, ThumbnailTextures},
29        logs::{self as logs_tab, LogFilter},
30        models::{self as models_tab, ModelAction, ModelRow},
31        status::{self as status_tab, StatusAction},
32    },
33    tray::{self, TrayVariant},
34};
35
36/// Tracing target for App-level lifecycle + tray events.  Stable so
37/// operators can filter with `RUST_LOG=studio_worker::ui::app=info`.
38const TRACE_TARGET: &str = "studio_worker::ui::app";
39
40/// Emit a structured breadcrumb when the tray health indicator flips
41/// between idle / busy / disconnected.  Pulled out of
42/// [`App::refresh_tray_variant`] so it is unit-testable without
43/// constructing a (non-`Send`) `App` + a real OS tray.
44fn log_tray_variant_change(from: TrayVariant, to: TrayVariant) {
45    tracing::info!(
46        target: TRACE_TARGET,
47        op = "tray_variant",
48        from = ?from,
49        to = ?to,
50        "tray status indicator changed"
51    );
52}
53
54/// The tray colour for the daemon's state: disconnected whenever the link
55/// is down, else derived from busy + heartbeat.
56pub fn tray_variant_for(link: &LinkState, replica: &Replica) -> TrayVariant {
57    if !link.is_connected() {
58        return TrayVariant::Disconnected;
59    }
60    let busy =
61        replica.busy.load(Ordering::SeqCst) || !replica.observers.active_jobs.lock().is_empty();
62    let hb = replica.observers.last_heartbeat.lock().clone();
63    tray::derive_variant(busy, hb.as_ref(), HEARTBEAT_INTERVAL)
64}
65
66/// Everything `App` needs to render and act on the world.
67pub struct AppDeps {
68    pub replica: Replica,
69    /// Minimise the window on its first frame (the config's
70    /// `start_minimised`, read before the daemon answers).
71    pub start_minimised: bool,
72    pub actions: ActionRunner,
73    pub config_path: PathBuf,
74    pub tokio: Handle,
75}
76
77/// A config save on its way to the daemon.
78type PendingSave = Arc<Mutex<Option<Result<EditableConfig, String>>>>;
79
80pub struct App {
81    deps: AppDeps,
82    tab: Tab,
83    config_draft: ConfigDraft,
84    pending_save: PendingSave,
85    log_filter: LogFilter,
86    about_state: AboutState,
87    textures: ThumbnailTextures,
88    /// `STUDIO_WORKER_UI_JOB`: a job to select once it shows up.
89    initial_job: Option<String>,
90    /// Identity (`job_id` + `finished_at`) of the newest recent-job we
91    /// have already raised a notification for.  Tracking identity
92    /// rather than ring length means a saturated, capped
93    /// `recent_jobs` ring (whose length pins at `RECENT_JOBS_CAP`)
94    /// can't make new arrivals invisible.
95    last_notified: Option<(String, chrono::DateTime<chrono::Utc>)>,
96    notifier: Box<dyn Notifier + Send + Sync>,
97    notification_prefs: NotificationPrefs,
98    tray_variant: TrayVariant,
99    quit_requested: Arc<std::sync::atomic::AtomicBool>,
100    /// Quit is under way: the daemon was told to stop, the window closes.
101    quitting: bool,
102    tray: Option<super::tray_host::TrayHandle>,
103    /// One-shot request to minimise the window on the first frame
104    /// (config `start_minimised`, default true).  Minimised to the
105    /// taskbar — not hidden — so the window stays reachable even when
106    /// no tray host is available.
107    start_minimised_pending: bool,
108}
109
110impl App {
111    pub fn new(deps: AppDeps) -> Self {
112        Self::with_notifier(deps, Self::default_notifier_box())
113    }
114
115    /// Used by tests to inject a `CapturingNotifier`.
116    pub fn with_notifier(deps: AppDeps, notifier: Box<dyn Notifier + Send + Sync>) -> Self {
117        let config_draft = ConfigDraft::from(&deps.replica.cfg.lock());
118        let start_minimised_pending = deps.start_minimised;
119        Self {
120            deps,
121            tab: Tab::initial(),
122            config_draft,
123            pending_save: Arc::default(),
124            log_filter: LogFilter::default(),
125            about_state: AboutState::default(),
126            textures: ThumbnailTextures::default(),
127            initial_job: std::env::var("STUDIO_WORKER_UI_JOB").ok(),
128            last_notified: None,
129            notifier,
130            notification_prefs: NotificationPrefs::default(),
131            tray_variant: TrayVariant::Disconnected,
132            quit_requested: Arc::new(std::sync::atomic::AtomicBool::new(false)),
133            quitting: false,
134            tray: None,
135            start_minimised_pending,
136        }
137    }
138
139    /// Whether the first frame will request a minimised window.
140    pub fn start_minimised_pending(&self) -> bool {
141        self.start_minimised_pending
142    }
143
144    pub fn attach_tray(&mut self, tray: super::tray_host::TrayHandle) {
145        self.tray = Some(tray);
146    }
147
148    pub fn quit_requested_handle(&self) -> Arc<std::sync::atomic::AtomicBool> {
149        self.quit_requested.clone()
150    }
151
152    pub fn notification_prefs(&self) -> NotificationPrefs {
153        self.notification_prefs
154    }
155
156    pub fn set_notification_prefs(&mut self, prefs: NotificationPrefs) {
157        self.notification_prefs = prefs;
158    }
159
160    pub fn tray_variant(&self) -> TrayVariant {
161        self.tray_variant
162    }
163
164    /// Exposed for `ui::run` which builds a notifier before App::new.
165    pub fn default_notifier_box() -> Box<dyn Notifier + Send + Sync> {
166        Box::new(super::notifier::DesktopNotifier)
167    }
168
169    /// Process any new entries in the recent-jobs ring and emit
170    /// notifications according to current prefs.  Idempotent.
171    pub fn drain_notifications(&mut self) {
172        // `recent_jobs` is newest-first, so walk from the front collecting
173        // every entry newer than the last one we notified on (identified
174        // by `job_id` + `finished_at`).
175        let new_entries: Vec<_> = {
176            let ring = self.deps.replica.observers.recent_jobs.lock();
177            let mut collected = Vec::new();
178            for entry in ring.iter() {
179                if self
180                    .last_notified
181                    .as_ref()
182                    .is_some_and(|(id, ts)| entry.job_id == *id && entry.finished_at == *ts)
183                {
184                    break;
185                }
186                collected.push(entry.clone());
187            }
188            collected
189        };
190        if let Some(newest) = new_entries.first() {
191            self.last_notified = Some((newest.job_id.clone(), newest.finished_at));
192        }
193        // Notify oldest-first so the OS order matches completion order.
194        for entry in new_entries.into_iter().rev() {
195            if let NotifyDecision::Show { title, body } = decide(self.notification_prefs, &entry) {
196                self.notifier.show(&title, &body);
197            }
198        }
199    }
200
201    /// Recompute the tray variant from live state.  Pushes the new
202    /// icon + tooltip to the OS tray when the variant changes.
203    pub fn refresh_tray_variant(&mut self) -> TrayVariant {
204        let link = self.deps.replica.link.lock().clone();
205        let v = tray_variant_for(&link, &self.deps.replica);
206        if v != self.tray_variant {
207            log_tray_variant_change(self.tray_variant, v);
208            if let Some(tray) = self.tray.as_mut() {
209                tray.set_variant(v);
210            }
211        }
212        self.tray_variant = v;
213        v
214    }
215
216    /// Shared by the real `ui` entry point and the headless tests.
217    pub fn render(&mut self, ui: &mut egui::Ui) {
218        let link = self.deps.replica.link.lock().clone();
219        egui::Panel::top("tab_bar").show_inside(ui, |ui| {
220            ui.add_space(4.0);
221            ui.horizontal(|ui| {
222                for tab in Tab::ALL {
223                    let selected = self.tab == tab;
224                    if ui.selectable_label(selected, tab.label()).clicked() {
225                        self.tab = tab;
226                    }
227                }
228            });
229            ui.add_space(2.0);
230            self.render_status_line(ui, &link);
231            ui.add_space(4.0);
232        });
233
234        egui::CentralPanel::default().show_inside(ui, |ui| {
235            egui::ScrollArea::vertical().show(ui, |ui| {
236                if !link.is_connected() && self.tab != Tab::About {
237                    render_unreachable(ui, &link, &self.deps.config_path);
238                    return;
239                }
240                match self.tab {
241                    Tab::Status => self.render_status(ui),
242                    Tab::Jobs => self.render_jobs(ui),
243                    Tab::Models => self.render_models(ui),
244                    Tab::Config => self.render_config(ui),
245                    Tab::Logs => self.render_logs(ui),
246                    Tab::About => self.render_about(ui),
247                }
248            });
249        });
250
251        // The poller updates the replica asynchronously; keep repainting so
252        // durations tick and new state shows without a user event.
253        ui.ctx().request_repaint_after(Duration::from_millis(500));
254    }
255
256    /// One line, always present (so nothing shifts): the link, and the
257    /// result of the last action.
258    fn render_status_line(&self, ui: &mut egui::Ui, link: &LinkState) {
259        ui.horizontal(|ui| {
260            let colour = match link {
261                LinkState::Connected { .. } => egui::Color32::LIGHT_GREEN,
262                LinkState::Connecting | LinkState::Starting { .. } => {
263                    egui::Color32::from_rgb(232, 168, 56)
264                }
265                LinkState::Unreachable { .. } => egui::Color32::LIGHT_RED,
266            };
267            let (dot, _) = ui.allocate_exact_size(egui::vec2(10.0, 10.0), egui::Sense::hover());
268            ui.painter().circle_filled(dot.center(), 4.0, colour);
269            ui.label(egui::RichText::new(link.summary()).small());
270            if let Some(feedback) = self.deps.actions.feedback.lock().clone() {
271                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
272                    let colour = if feedback.ok {
273                        egui::Color32::from_gray(190)
274                    } else {
275                        egui::Color32::LIGHT_RED
276                    };
277                    ui.label(
278                        egui::RichText::new(format!(
279                            "{} \u{00b7} {}",
280                            feedback.at.with_timezone(&chrono::Local).format("%H:%M:%S"),
281                            feedback.text
282                        ))
283                        .small()
284                        .color(colour),
285                    );
286                });
287            }
288        });
289    }
290
291    /// Shared housekeeping invoked before every frame's render.
292    fn pre_render(&mut self, ctx: &egui::Context) {
293        if self.start_minimised_pending {
294            self.start_minimised_pending = false;
295            tracing::info!(
296                target: TRACE_TARGET,
297                op = "start_minimised",
298                "minimising window on startup (config start_minimised)"
299            );
300            ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true));
301        }
302
303        self.drain_notifications();
304        self.refresh_tray_variant();
305        self.take_save_result();
306
307        // Hide-to-tray: intercept the OS close request and hide the window.
308        // The daemon keeps running either way; Quit comes from the tray.
309        if ctx.input(|i| i.viewport().close_requested()) && !self.quitting {
310            tracing::info!(
311                target: TRACE_TARGET,
312                op = "hide_to_tray",
313                "window close intercepted; hiding to tray (the daemon keeps running)"
314            );
315            ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
316            ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false));
317        }
318
319        // Tray Quit: stop the daemon, then close the UI.
320        if self.quit_requested.load(Ordering::SeqCst) && !self.quitting {
321            self.quitting = true;
322            tracing::info!(
323                target: TRACE_TARGET,
324                op = "quit",
325                "quit requested; stopping the daemon and closing the tray UI"
326            );
327            self.deps.actions.run_and_wait(Action::Shutdown);
328            ctx.send_viewport_cmd(egui::ViewportCommand::Close);
329        }
330    }
331
332    /// Expose the current tab for tests + future tray-state derivation.
333    pub fn current_tab(&self) -> Tab {
334        self.tab
335    }
336
337    pub fn set_tab(&mut self, tab: Tab) {
338        self.tab = tab;
339    }
340
341    pub fn deps(&self) -> &AppDeps {
342        &self.deps
343    }
344
345    fn render_jobs(&mut self, ui: &mut egui::Ui) {
346        let replica = &self.deps.replica;
347        let view = jobs_tab::JobsView::build(&replica.observers, chrono::Utc::now());
348        if let Some(spec) = &self.initial_job {
349            if let Some(id) = jobs_tab::resolve_initial_selection(spec, &view) {
350                *replica.selected_job.lock() = Some(id);
351                self.initial_job = None;
352            }
353        }
354        let selected = replica.selected_job.lock().clone();
355        let log = replica.selected_log.lock().clone();
356        let changed = jobs_tab::render(
357            ui,
358            &view,
359            JobsContext {
360                thumbnails: &replica.observers.thumbnails,
361                textures: &mut self.textures,
362                selected: selected.as_deref(),
363                log: log.as_ref(),
364            },
365        );
366        if let Some(selection) = changed {
367            *replica.selected_log.lock() = None;
368            *replica.selected_job.lock() = selection;
369        }
370    }
371
372    fn render_models(&mut self, ui: &mut egui::Ui) {
373        let rows: Vec<ModelRow> = self
374            .deps
375            .replica
376            .models
377            .lock()
378            .iter()
379            .map(ModelRow::from_entry)
380            .collect();
381        match models_tab::render(ui, &rows) {
382            Some(ModelAction::Load(id)) => self.deps.actions.run(Action::Load(id)),
383            Some(ModelAction::Unload(id)) => self.deps.actions.run(Action::Unload(id)),
384            None => {}
385        }
386    }
387
388    fn render_config(&mut self, ui: &mut egui::Ui) {
389        let live = self.deps.replica.cfg.lock().clone();
390        self.config_draft.follow(&live);
391        if let Some(edit) = config_tab::render(
392            ui,
393            &mut self.config_draft,
394            &self.deps.config_path,
395            &mut self.notification_prefs,
396        ) {
397            let slot = self.pending_save.clone();
398            let path = self.deps.config_path.clone();
399            let edit = EditableConfig::from_config(&edit);
400            std::thread::spawn(move || {
401                *slot.lock() = Some(crate::daemon_link::save_config(&path, &edit));
402            });
403        }
404    }
405
406    /// Apply the daemon's answer to a Save, once it arrived.
407    fn take_save_result(&mut self) {
408        let Some(result) = self.pending_save.lock().take() else {
409            return;
410        };
411        match result {
412            Ok(saved) => {
413                let mut cfg = self.deps.replica.cfg.lock();
414                saved.apply_to(&mut cfg);
415                self.config_draft.saved(&cfg);
416            }
417            Err(err) => self.config_draft.save_failed(err),
418        }
419    }
420
421    fn render_logs(&mut self, ui: &mut egui::Ui) {
422        logs_tab::render(
423            ui,
424            &self.deps.replica.observers.recent_logs,
425            &mut self.log_filter,
426        );
427    }
428
429    fn render_about(&mut self, ui: &mut egui::Ui) {
430        let daemon_version = self
431            .deps
432            .replica
433            .status
434            .lock()
435            .as_ref()
436            .map(|s| s.version.clone());
437        let view =
438            about_tab::AboutView::build(&self.about_state, &self.deps.config_path, daemon_version);
439        let feed = {
440            let cfg = self.deps.replica.cfg.lock();
441            UpdateFeed {
442                url: cfg.auto_update_feed.clone(),
443                prerelease: cfg.auto_update_prerelease,
444            }
445        };
446        about_tab::render(ui, &view, &self.about_state, &self.deps.tokio, &feed);
447    }
448
449    fn render_status(&mut self, ui: &mut egui::Ui) {
450        let replica = &self.deps.replica;
451        let view = {
452            let cfg = replica.cfg.lock();
453            let registration = replica.registration.lock().clone();
454            let hb = replica.observers.last_heartbeat.lock().clone();
455            let session_state = replica.observers.session_state.lock().clone();
456            let gpu = replica.observers.gpu_runtime.lock().clone();
457            let vram_total_gb = replica
458                .status
459                .lock()
460                .as_ref()
461                .map_or(0.0, |s| s.vram_total_gb);
462            status_tab::StatusView::build(
463                &cfg,
464                replica.registered(),
465                &registration,
466                replica.busy.load(Ordering::SeqCst),
467                replica.paused.load(Ordering::SeqCst),
468                hb.as_ref(),
469                vram_total_gb,
470                &session_state,
471                gpu.as_ref(),
472            )
473        };
474        match status_tab::render(ui, &view) {
475            Some(StatusAction::SetPaused(paused)) => {
476                self.deps.actions.run(Action::SetPaused(paused))
477            }
478            Some(StatusAction::ResetRegistration) => {
479                self.deps.actions.run(Action::ResetRegistration)
480            }
481            None => {}
482        }
483    }
484}
485
486/// What the window shows instead of the tabs while the daemon does not
487/// answer: never stale data.
488fn render_unreachable(ui: &mut egui::Ui, link: &LinkState, config_path: &std::path::Path) {
489    ui.heading("Worker daemon not reachable");
490    ui.add_space(6.0);
491    ui.horizontal(|ui| {
492        ui.spinner();
493        ui.label(link.summary());
494    });
495    ui.add_space(8.0);
496    let detail = match link {
497        LinkState::Starting { error } | LinkState::Unreachable { error, .. } => error.as_str(),
498        _ => "",
499    };
500    if !detail.is_empty() {
501        ui.label(
502            egui::RichText::new(detail)
503                .monospace()
504                .color(egui::Color32::from_gray(180)),
505        );
506        ui.add_space(8.0);
507    }
508    ui.label(format!(
509        "The tray UI shows what the daemon (`studio-worker run`) does.  It starts one when \
510         none is running and keeps retrying.  A daemon it starts writes its output to {}.",
511        crate::daemon_link::daemon_log_path(config_path).display()
512    ));
513}
514
515impl eframe::App for App {
516    fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
517        let ctx = ui.ctx().clone();
518        self.pre_render(&ctx);
519        self.render(ui);
520    }
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526    use crate::{config::Config, daemon_link::Replica};
527
528    fn tokio_handle() -> Handle {
529        static RT: std::sync::OnceLock<tokio::runtime::Runtime> = std::sync::OnceLock::new();
530        RT.get_or_init(|| {
531            tokio::runtime::Builder::new_multi_thread()
532                .enable_all()
533                .worker_threads(1)
534                .build()
535                .expect("tokio runtime")
536        })
537        .handle()
538        .clone()
539    }
540
541    fn mock_deps() -> AppDeps {
542        let replica = Replica::default();
543        let config_path = PathBuf::from("/tmp/studio-worker-test/config.toml");
544        AppDeps {
545            actions: ActionRunner::new(config_path.clone(), replica.clone()),
546            replica,
547            start_minimised: true,
548            config_path,
549            tokio: tokio_handle(),
550        }
551    }
552
553    fn connected(deps: &AppDeps) {
554        *deps.replica.link.lock() = LinkState::Connected {
555            url: "http://127.0.0.1:4787".into(),
556            version: crate::AGENT_VERSION.into(),
557        };
558    }
559
560    #[test]
561    fn start_minimised_pending_follows_the_config() {
562        let app = App::new(mock_deps());
563        assert!(app.start_minimised_pending());
564
565        let deps = AppDeps {
566            start_minimised: false,
567            ..mock_deps()
568        };
569        let app = App::new(deps);
570        assert!(!app.start_minimised_pending());
571    }
572
573    #[test]
574    fn log_tray_variant_change_emits_structured_transition() {
575        use crate::test_support::capture;
576        let logs = capture(|| {
577            super::log_tray_variant_change(TrayVariant::Disconnected, TrayVariant::Busy);
578        });
579        assert!(logs.contains("studio_worker::ui::app"), "{logs}");
580        assert!(logs.contains("op=\"tray_variant\""), "{logs}");
581        assert!(logs.contains("from=Disconnected"), "{logs}");
582        assert!(logs.contains("to=Busy"), "{logs}");
583    }
584
585    #[test]
586    fn the_tray_is_disconnected_whenever_the_link_is_down() {
587        let replica = Replica::default();
588        replica.busy.store(true, Ordering::SeqCst);
589        assert_eq!(
590            tray_variant_for(&LinkState::Connecting, &replica),
591            TrayVariant::Disconnected
592        );
593        let link = LinkState::Connected {
594            url: "u".into(),
595            version: "v".into(),
596        };
597        assert_eq!(tray_variant_for(&link, &replica), TrayVariant::Busy);
598    }
599
600    #[test]
601    fn a_running_lane_job_makes_the_tray_busy() {
602        let replica = Replica::default();
603        replica
604            .observers
605            .active_jobs
606            .lock()
607            .push(crate::runtime::CurrentJob {
608                job_id: "l".into(),
609                kind: crate::types::TaskKind::Llm,
610                model: "m".into(),
611                prompt: String::new(),
612                started_at: chrono::Utc::now(),
613                source: crate::runtime::JobSource::Lane,
614            });
615        let link = LinkState::Connected {
616            url: "u".into(),
617            version: "v".into(),
618        };
619        assert_eq!(tray_variant_for(&link, &replica), TrayVariant::Busy);
620    }
621
622    #[test]
623    fn new_defaults_to_status_tab() {
624        let app = App::new(mock_deps());
625        assert_eq!(app.current_tab(), Tab::Status);
626    }
627
628    #[test]
629    fn render_each_tab_connected_and_not_does_not_panic() {
630        for tab in Tab::ALL {
631            for link_up in [false, true] {
632                let deps = mock_deps();
633                if link_up {
634                    connected(&deps);
635                }
636                let mut app = App::new(deps);
637                app.set_tab(tab);
638                egui::__run_test_ui(|ui| app.render(ui));
639            }
640        }
641    }
642
643    #[test]
644    fn a_save_answer_becomes_the_draft_baseline() {
645        let deps = mock_deps();
646        let mut app = App::new(deps);
647        app.config_draft.current.vram_threshold_gb = 2.0;
648        app.config_draft.pending = true;
649        let mut saved = EditableConfig::from_config(&Config::default());
650        saved.vram_threshold_gb = 2.0;
651        *app.pending_save.lock() = Some(Ok(saved));
652        app.take_save_result();
653        assert!(!app.config_draft.dirty());
654        assert_eq!(app.deps.replica.cfg.lock().vram_threshold_gb, 2.0);
655
656        app.config_draft.current.vram_threshold_gb = 3.0;
657        *app.pending_save.lock() = Some(Err("invalid config".into()));
658        app.take_save_result();
659        assert_eq!(
660            app.config_draft.last_save_error.as_deref(),
661            Some("invalid config")
662        );
663    }
664
665    fn completed_recent_job(id: &str) -> crate::runtime::RecentJob {
666        let now = chrono::Utc::now();
667        crate::runtime::RecentJob {
668            job_id: id.into(),
669            kind: crate::types::TaskKind::Image,
670            model: "synthetic".into(),
671            prompt: "p".into(),
672            outcome: crate::runtime::JobOutcome::Completed,
673            started_at: now,
674            finished_at: now,
675            source: crate::runtime::JobSource::Studio,
676        }
677    }
678
679    /// Shared handle into a `CapturingNotifier`'s recorded
680    /// (title, body) pairs.
681    type Captured = Arc<Mutex<Vec<(String, String)>>>;
682
683    fn app_with_capturing_notifier(deps: AppDeps) -> (App, Captured) {
684        let captured: Captured = Arc::new(Mutex::new(Vec::new()));
685        let notifier = Box::new(crate::ui::notifier::CapturingNotifier {
686            captured: captured.clone(),
687        });
688        let mut app = App::with_notifier(deps, notifier);
689        app.set_notification_prefs(NotificationPrefs {
690            on_completion: true,
691            on_failure: true,
692        });
693        (app, captured)
694    }
695
696    #[test]
697    fn drain_notifications_fires_for_each_new_completed_job() {
698        let deps = mock_deps();
699        let observers = deps.replica.observers.clone();
700        let (mut app, captured) = app_with_capturing_notifier(deps);
701        crate::runtime::record_recent_job(&observers, completed_recent_job("a"));
702        crate::runtime::record_recent_job(&observers, completed_recent_job("b"));
703        app.drain_notifications();
704        assert_eq!(captured.lock().len(), 2);
705    }
706
707    #[test]
708    fn drain_notifications_is_idempotent_without_new_jobs() {
709        let deps = mock_deps();
710        let observers = deps.replica.observers.clone();
711        let (mut app, captured) = app_with_capturing_notifier(deps);
712        crate::runtime::record_recent_job(&observers, completed_recent_job("a"));
713        app.drain_notifications();
714        app.drain_notifications();
715        assert_eq!(captured.lock().len(), 1);
716    }
717
718    #[test]
719    fn drain_notifications_fires_after_recent_jobs_ring_saturates() {
720        let deps = mock_deps();
721        let observers = deps.replica.observers.clone();
722        let (mut app, captured) = app_with_capturing_notifier(deps);
723        for i in 0..(crate::runtime::RECENT_JOBS_CAP + 5) {
724            crate::runtime::record_recent_job(
725                &observers,
726                completed_recent_job(&format!("warm-{i}")),
727            );
728        }
729        app.drain_notifications();
730        captured.lock().clear();
731        crate::runtime::record_recent_job(&observers, completed_recent_job("after-saturation"));
732        app.drain_notifications();
733        let shown = captured.lock();
734        assert_eq!(shown.len(), 1);
735        assert!(shown[0].1.contains("image"), "{:?}", shown[0]);
736    }
737}