Say what the application is doing, in one bar and one list
Every background job reported into a window property of its own — library-thumbs-done, library-pin-total, library-syncing — which only the grid ever read. A pin download that outlived the view it was started from drew nothing at all once the user opened an image, and there was no answer anywhere to "what is this busy with", because the answer was spread across eight properties nothing collected. They report to one register now (ui/dr-ui/src/activity.rs). It publishes an aggregate, which draws a three-pixel bar across the top of the shell in every view, and a row per job, which the settings page lists: scans, thumbnail batches, pin and open downloads, sidecar uploads, the sync and the trash. Failures stay on the list until they are cleared; routine successes do not, or a scroll would bury them. The handle removes a still-running job when it drops, so a worker that dies mid-transfer takes its row with it rather than leaving the bar sweeping for the rest of the session. Also carries in-flight work from a parallel session — the drawn icon set and the dr-pipeline ops split. dr-pipeline's build script does not compile at this commit; ui/dr-ui does, with clippy clean and its tests passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,613 @@
|
||||
//! TRACES: FR-CAT-1 | FR-NC-6 | FR-NC-6c
|
||||
//! One register of the work running behind the interface.
|
||||
//!
|
||||
//! Every background operation in this crate has the same shape: a worker
|
||||
//! thread with an `mpsc` channel, drained by a `slint::Timer` on the UI thread.
|
||||
//! Each of them used to report into a window property of its own —
|
||||
//! `library-thumbs-done`, `library-pin-total`, `library-syncing` — and that had
|
||||
//! two consequences worth removing.
|
||||
//!
|
||||
//! - **Only the grid could see them.** A pin download outlives the view it was
|
||||
//! started from, so opening an image made an hour of transfers invisible: the
|
||||
//! properties are read by `LibraryGrid` and nothing else.
|
||||
//! - **There was no answer to "what is this doing".** The answer was spread
|
||||
//! across eight properties that no code path ever collected, which is why the
|
||||
//! settings page could not show a list of transfers: there was nothing to
|
||||
//! list.
|
||||
//!
|
||||
//! So the jobs report here instead, and this publishes twice: an aggregate that
|
||||
//! drives the bar across the top of the shell, and a row per job for the
|
||||
//! settings page.
|
||||
//!
|
||||
//! # Everything here is single-threaded on purpose
|
||||
//!
|
||||
//! No `Arc`, no lock. The workers already hand their progress to the UI thread
|
||||
//! through a channel, and the drain that reads it is the only thing that talks
|
||||
//! to this register — so the shared-state problem was solved before this file
|
||||
//! existed, and re-solving it with a mutex would only add a lock that is never
|
||||
//! contended.
|
||||
//!
|
||||
//! # A job that stops reporting cannot hang the bar
|
||||
//!
|
||||
//! [`Activity`] is a handle whose `Drop` removes a still-running job. The drain
|
||||
//! closures own their handle, so a worker that dies mid-transfer, a timer
|
||||
//! replaced by a newer batch, or a window that closes all take their rows with
|
||||
//! them. Without that, one lost `Finished` message would leave the bar sweeping
|
||||
//! for the rest of the session — and a progress indicator that lies about
|
||||
//! whether anything is happening is worse than none.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::{Rc, Weak};
|
||||
|
||||
use slint::ComponentHandle;
|
||||
|
||||
use crate::{ActivityRow, AppWindow};
|
||||
|
||||
/// How many stopped jobs to keep.
|
||||
///
|
||||
/// A short history rather than none: "did the sync work?" is asked *after* the
|
||||
/// sync, and a list that empties the instant a job ends can only ever answer
|
||||
/// questions about the present. Short, because it is a status list and not a
|
||||
/// log — the ones worth keeping past this are failures, and those are held
|
||||
/// until the user clears them.
|
||||
const KEEP_STOPPED: usize = 8;
|
||||
|
||||
/// How often the register publishes into the window.
|
||||
///
|
||||
/// Publishing on every change would rebuild the row model once per drained
|
||||
/// message, and a thumbnail batch drains a hundred in a tick. This is fast
|
||||
/// enough that a bar looks live and slow enough that a burst of completions
|
||||
/// costs one update rather than a hundred.
|
||||
const PUBLISH_INTERVAL: std::time::Duration = std::time::Duration::from_millis(150);
|
||||
|
||||
/// What kind of work a job is.
|
||||
///
|
||||
/// Coarser than the set of functions that start jobs: the user's question is
|
||||
/// "is something downloading", not which of three call sites issued the fetch.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
/// Walking the remote library (FR-CAT-1).
|
||||
Scan,
|
||||
/// Reading capture times across the whole library.
|
||||
Index,
|
||||
/// Previews for the cells on screen.
|
||||
Thumbnails,
|
||||
/// Original files coming down — a pin, or an image being opened.
|
||||
Download,
|
||||
/// Sidecars and derived data going up.
|
||||
Upload,
|
||||
/// The two-way exchange of catalog and shards.
|
||||
Sync,
|
||||
/// Moving files to the trash, restoring them, or emptying it.
|
||||
Trash,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
/// Whether this job moves bytes over the network.
|
||||
///
|
||||
/// FR-NC-6c: the user's question on a slow or metered connection is about
|
||||
/// *transfers* specifically, so the row says which jobs are ones.
|
||||
pub fn is_transfer(self) -> bool {
|
||||
match self {
|
||||
Kind::Scan
|
||||
| Kind::Thumbnails
|
||||
| Kind::Download
|
||||
| Kind::Upload
|
||||
| Kind::Sync
|
||||
| Kind::Trash => true,
|
||||
// Reads headers over the network today, but it is bounded by the
|
||||
// catalog rather than by anything the user asked to move, and it
|
||||
// stores nothing. Calling it a transfer would put an hours-long
|
||||
// background sweep in the same sentence as a download they are
|
||||
// waiting on.
|
||||
Kind::Index => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum State {
|
||||
Running,
|
||||
Done,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// One job's row in the register.
|
||||
#[derive(Debug, Clone)]
|
||||
struct Record {
|
||||
id: u64,
|
||||
kind: Kind,
|
||||
title: String,
|
||||
detail: String,
|
||||
done: usize,
|
||||
/// Zero means "no denominator" — a directory walk cannot know its extent,
|
||||
/// and a percentage invented for it is worse than admitting as much.
|
||||
total: usize,
|
||||
state: State,
|
||||
}
|
||||
|
||||
impl Record {
|
||||
fn fraction(&self) -> f32 {
|
||||
if self.total == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
(self.done as f32 / self.total as f32).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
fn row(&self) -> ActivityRow {
|
||||
ActivityRow {
|
||||
title: self.title.clone().into(),
|
||||
detail: self.detail.clone().into(),
|
||||
fraction: self.fraction(),
|
||||
determinate: self.total > 0,
|
||||
running: self.state == State::Running,
|
||||
failed: self.state == State::Failed,
|
||||
transfer: self.kind.is_transfer(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The aggregate the shell's bar is drawn from.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Summary {
|
||||
pub running: usize,
|
||||
/// Stopped jobs still listed — recent successes and unread failures.
|
||||
pub kept: usize,
|
||||
/// `None` where nothing running knows its own extent.
|
||||
pub fraction: Option<f32>,
|
||||
}
|
||||
|
||||
/// Every background job the application is running.
|
||||
pub struct ActivityLog {
|
||||
records: RefCell<Vec<Record>>,
|
||||
next_id: Cell<u64>,
|
||||
/// Where to publish. Absent in tests, which is what makes the whole
|
||||
/// register testable without a window.
|
||||
window: RefCell<Option<slint::Weak<AppWindow>>>,
|
||||
pump: RefCell<Option<slint::Timer>>,
|
||||
dirty: Cell<bool>,
|
||||
}
|
||||
|
||||
impl Default for ActivityLog {
|
||||
/// An empty register attached to nothing.
|
||||
///
|
||||
/// Publishing is a no-op until [`ActivityLog::attach`] gives it a window,
|
||||
/// which is what lets a controller hold one in a test with no interface
|
||||
/// running — and what makes this file's own tests possible.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
records: RefCell::new(Vec::new()),
|
||||
next_id: Cell::new(1),
|
||||
window: RefCell::new(None),
|
||||
pump: RefCell::new(None),
|
||||
dirty: Cell::new(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ActivityLog {
|
||||
pub fn new() -> Rc<Self> {
|
||||
Rc::new(Self::default())
|
||||
}
|
||||
|
||||
/// Start publishing into `window`.
|
||||
///
|
||||
/// The timer holds a *weak* reference back: an `Rc` here would be a cycle
|
||||
/// through the closure, and the register would outlive the window it is
|
||||
/// describing.
|
||||
pub fn attach(self: &Rc<Self>, window: &AppWindow) {
|
||||
*self.window.borrow_mut() = Some(window.as_weak());
|
||||
|
||||
let weak = Rc::downgrade(self);
|
||||
let timer = slint::Timer::default();
|
||||
timer.start(slint::TimerMode::Repeated, PUBLISH_INTERVAL, move || {
|
||||
let Some(log) = Weak::upgrade(&weak) else {
|
||||
return;
|
||||
};
|
||||
if log.dirty.replace(false) {
|
||||
log.publish();
|
||||
}
|
||||
});
|
||||
*self.pump.borrow_mut() = Some(timer);
|
||||
|
||||
// Once immediately, so an empty register starts out saying so rather
|
||||
// than leaving whatever the properties defaulted to.
|
||||
self.publish();
|
||||
}
|
||||
|
||||
/// Register a job and hand back the handle that reports on it.
|
||||
pub fn begin(self: &Rc<Self>, kind: Kind, title: impl Into<String>) -> Activity {
|
||||
let id = self.next_id.get();
|
||||
self.next_id.set(id + 1);
|
||||
|
||||
self.records.borrow_mut().push(Record {
|
||||
id,
|
||||
kind,
|
||||
title: title.into(),
|
||||
detail: String::new(),
|
||||
done: 0,
|
||||
total: 0,
|
||||
state: State::Running,
|
||||
});
|
||||
self.touch();
|
||||
|
||||
Activity {
|
||||
log: self.clone(),
|
||||
id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop every stopped job, keeping what is still running.
|
||||
pub fn clear_finished(&self) {
|
||||
self.records
|
||||
.borrow_mut()
|
||||
.retain(|r| r.state == State::Running);
|
||||
self.touch();
|
||||
}
|
||||
|
||||
pub fn summary(&self) -> Summary {
|
||||
let records = self.records.borrow();
|
||||
let running: Vec<&Record> = records
|
||||
.iter()
|
||||
.filter(|r| r.state == State::Running)
|
||||
.collect();
|
||||
|
||||
// Summed across jobs rather than averaged: two downloads of wildly
|
||||
// different size are one wait as far as the person watching is
|
||||
// concerned, and averaging their percentages would have the bar jump
|
||||
// backwards whenever a small job joined a large one.
|
||||
let (done, total) = running
|
||||
.iter()
|
||||
.filter(|r| r.total > 0)
|
||||
.fold((0usize, 0usize), |(d, t), r| (d + r.done, t + r.total));
|
||||
|
||||
Summary {
|
||||
running: running.len(),
|
||||
kept: records.len() - running.len(),
|
||||
fraction: (total > 0).then(|| (done as f32 / total as f32).clamp(0.0, 1.0)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The rows the settings page lists, running jobs first.
|
||||
///
|
||||
/// Stable within each group — insertion order — so a list being watched
|
||||
/// does not reshuffle itself under the reader every time a count changes.
|
||||
fn rows(&self) -> Vec<ActivityRow> {
|
||||
let records = self.records.borrow();
|
||||
records
|
||||
.iter()
|
||||
.filter(|r| r.state == State::Running)
|
||||
.chain(records.iter().filter(|r| r.state != State::Running))
|
||||
.map(Record::row)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn publish(&self) {
|
||||
let Some(window) = self.window.borrow().as_ref().and_then(slint::Weak::upgrade) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Built before anything is written, so no property setter can run with
|
||||
// `records` borrowed.
|
||||
let rows = self.rows();
|
||||
let summary = self.summary();
|
||||
|
||||
window.set_activity_rows(slint::ModelRc::new(slint::VecModel::from(rows)));
|
||||
window.set_activity_busy(summary.running > 0);
|
||||
window.set_activity_running(summary.running as i32);
|
||||
window.set_activity_kept(summary.kept as i32);
|
||||
window.set_activity_determinate(summary.fraction.is_some());
|
||||
window.set_activity_fraction(summary.fraction.unwrap_or(0.0));
|
||||
}
|
||||
|
||||
fn touch(&self) {
|
||||
self.dirty.set(true);
|
||||
}
|
||||
|
||||
/// Edit a *running* job.
|
||||
///
|
||||
/// A stopped one is left alone: a drain loop reads whatever the channel
|
||||
/// still holds after the message that ended the job, and a queued progress
|
||||
/// update arriving second would otherwise overwrite the failure the user
|
||||
/// needs to read with a count that no longer means anything.
|
||||
fn with(&self, id: u64, f: impl FnOnce(&mut Record)) {
|
||||
if let Some(record) = self
|
||||
.records
|
||||
.borrow_mut()
|
||||
.iter_mut()
|
||||
.find(|r| r.id == id && r.state == State::Running)
|
||||
{
|
||||
f(record);
|
||||
}
|
||||
self.touch();
|
||||
}
|
||||
|
||||
/// Stop a job, keeping it in the list.
|
||||
///
|
||||
/// First outcome wins, for the reason given on [`ActivityLog::with`]: a
|
||||
/// worker that reports a failure and then closes its channel has failed,
|
||||
/// and the closing must not relabel it as finished.
|
||||
fn stop(&self, id: u64, state: State, detail: String) {
|
||||
{
|
||||
let mut records = self.records.borrow_mut();
|
||||
let Some(record) = records
|
||||
.iter_mut()
|
||||
.find(|r| r.id == id && r.state == State::Running)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
record.state = state;
|
||||
record.detail = detail;
|
||||
// A finished job reads as complete whatever it counted: a scan that
|
||||
// ends having found forty of an unknown number is done, not 40%.
|
||||
if state == State::Done && record.total > 0 {
|
||||
record.done = record.total;
|
||||
}
|
||||
trim(&mut records);
|
||||
}
|
||||
self.touch();
|
||||
}
|
||||
|
||||
/// Take a running job out of the register entirely.
|
||||
///
|
||||
/// Returns whether there was one to remove, which is how [`Activity::drop`]
|
||||
/// tells "the drain forgot about this" from "it stopped properly".
|
||||
fn remove(&self, id: u64) -> bool {
|
||||
let removed = {
|
||||
let mut records = self.records.borrow_mut();
|
||||
let before = records.len();
|
||||
records.retain(|r| !(r.id == id && r.state == State::Running));
|
||||
records.len() != before
|
||||
};
|
||||
self.touch();
|
||||
removed
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the oldest stopped jobs past [`KEEP_STOPPED`].
|
||||
///
|
||||
/// Failures are exempt: a transfer that failed while the user was elsewhere is
|
||||
/// the single most useful thing this list holds, and letting eight successful
|
||||
/// thumbnail batches push it out would lose exactly the row worth keeping.
|
||||
fn trim(records: &mut Vec<Record>) {
|
||||
let mut excess = records
|
||||
.iter()
|
||||
.filter(|r| r.state == State::Done)
|
||||
.count()
|
||||
.saturating_sub(KEEP_STOPPED);
|
||||
|
||||
records.retain(|r| {
|
||||
if excess > 0 && r.state == State::Done {
|
||||
excess -= 1;
|
||||
return false;
|
||||
}
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
/// Bytes as a figure to put beside a transfer.
|
||||
///
|
||||
/// Three scales rather than one: a sidecar is a few kilobytes and a RAW file is
|
||||
/// tens of megabytes, and a single unit makes one of them read as "0.0" or as
|
||||
/// six digits. One decimal at most — this is a status line, not a measurement.
|
||||
pub fn describe_bytes(bytes: u64) -> String {
|
||||
const KB: f64 = 1024.0;
|
||||
const MB: f64 = 1024.0 * KB;
|
||||
const GB: f64 = 1024.0 * MB;
|
||||
|
||||
let b = bytes as f64;
|
||||
if b >= GB {
|
||||
format!("{:.1} GB", b / GB)
|
||||
} else if b >= MB {
|
||||
format!("{:.1} MB", b / MB)
|
||||
} else {
|
||||
format!("{:.0} kB", (b / KB).ceil())
|
||||
}
|
||||
}
|
||||
|
||||
/// A running job, held by whatever is reporting on it.
|
||||
///
|
||||
/// Every method is idempotent and every one is a no-op once the job has
|
||||
/// stopped, because the drains that call them are loops over a channel that may
|
||||
/// deliver a late message after the one that ended the job.
|
||||
pub struct Activity {
|
||||
log: Rc<ActivityLog>,
|
||||
id: u64,
|
||||
}
|
||||
|
||||
impl Activity {
|
||||
/// What the job is currently doing, in the job's own words.
|
||||
pub fn detail(&self, detail: impl Into<String>) {
|
||||
let detail = detail.into();
|
||||
self.log.with(self.id, |r| r.detail = detail);
|
||||
}
|
||||
|
||||
/// How much work there is. Zero leaves the job indeterminate.
|
||||
pub fn total(&self, total: usize) {
|
||||
self.log.with(self.id, |r| r.total = total);
|
||||
}
|
||||
|
||||
/// More work turned up mid-flight — a batch that grew once the plan came
|
||||
/// back. Added rather than replaced, so the count already served stays
|
||||
/// meaningful.
|
||||
pub fn add_total(&self, extra: usize) {
|
||||
self.log.with(self.id, |r| r.total += extra);
|
||||
}
|
||||
|
||||
/// One unit of work finished.
|
||||
pub fn advance(&self) {
|
||||
self.log.with(self.id, |r| r.done += 1);
|
||||
}
|
||||
|
||||
/// Set both counts at once, for workers that report a running total.
|
||||
pub fn progress(&self, done: usize, total: usize) {
|
||||
self.log.with(self.id, |r| {
|
||||
r.done = done;
|
||||
r.total = total;
|
||||
});
|
||||
}
|
||||
|
||||
/// The job finished. `note` is what the list shows afterwards.
|
||||
pub fn finish(&self, note: impl Into<String>) {
|
||||
self.log.stop(self.id, State::Done, note.into());
|
||||
}
|
||||
|
||||
/// The job finished and is not worth remembering.
|
||||
///
|
||||
/// For routine work the user never asked for by name: scrolling the grid
|
||||
/// starts a thumbnail batch every second or so, and keeping those would
|
||||
/// push a failed transfer out of the list within moments of it happening.
|
||||
/// A failure is still kept — only [`Activity::finish`]'s history is
|
||||
/// skipped.
|
||||
pub fn finish_quietly(&self) {
|
||||
self.log.remove(self.id);
|
||||
}
|
||||
|
||||
/// The job stopped without doing what it set out to do.
|
||||
///
|
||||
/// Kept in the list until the user clears it: this is the row they came to
|
||||
/// the settings page to find.
|
||||
pub fn fail(&self, message: impl Into<String>) {
|
||||
self.log.stop(self.id, State::Failed, message.into());
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Activity {
|
||||
/// A job whose handle goes away while it is still running is removed, not
|
||||
/// marked failed. Nobody is coming back to report on it, and a row frozen
|
||||
/// at "downloading, 12 of 900" would sit in the list claiming to be live
|
||||
/// for the rest of the session.
|
||||
fn drop(&mut self) {
|
||||
if self.log.remove(self.id) {
|
||||
log::debug!("a background job ended without saying so");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_running_job_makes_the_bar_busy() {
|
||||
let log = ActivityLog::new();
|
||||
assert_eq!(log.summary().running, 0);
|
||||
|
||||
let scan = log.begin(Kind::Scan, "Scanning");
|
||||
assert_eq!(log.summary().running, 1);
|
||||
// A scan has no denominator, so the bar must not claim a position.
|
||||
assert_eq!(log.summary().fraction, None);
|
||||
|
||||
scan.finish("up to date");
|
||||
assert_eq!(log.summary().running, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_is_summed_across_jobs() {
|
||||
let log = ActivityLog::new();
|
||||
let a = log.begin(Kind::Thumbnails, "Thumbnails");
|
||||
let b = log.begin(Kind::Download, "Downloading");
|
||||
a.progress(1, 10);
|
||||
b.progress(4, 10);
|
||||
|
||||
// 5 of 20, not the average of 10% and 40%.
|
||||
assert_eq!(log.summary().fraction, Some(0.25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_job_without_a_denominator_does_not_dilute_one_that_has_it() {
|
||||
let log = ActivityLog::new();
|
||||
let scan = log.begin(Kind::Scan, "Scanning");
|
||||
let fetch = log.begin(Kind::Download, "Downloading");
|
||||
fetch.progress(3, 4);
|
||||
scan.detail("120 folders");
|
||||
|
||||
assert_eq!(log.summary().fraction, Some(0.75));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dropped_handle_takes_its_row_with_it() {
|
||||
let log = ActivityLog::new();
|
||||
{
|
||||
let _thumbs = log.begin(Kind::Thumbnails, "Thumbnails");
|
||||
assert_eq!(log.summary().running, 1);
|
||||
}
|
||||
// The worker died, or a newer batch replaced the timer holding this.
|
||||
// Either way the bar must not sweep for ever.
|
||||
assert_eq!(log.summary().running, 0);
|
||||
assert_eq!(log.summary().kept, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_finished_job_is_kept_and_reads_as_complete() {
|
||||
let log = ActivityLog::new();
|
||||
let sweep = log.begin(Kind::Index, "Indexing");
|
||||
sweep.progress(40, 100);
|
||||
// Ended early with everything it was going to do done.
|
||||
sweep.finish("4000 dated");
|
||||
drop(sweep);
|
||||
|
||||
let summary = log.summary();
|
||||
assert_eq!(summary.running, 0);
|
||||
assert_eq!(summary.kept, 1, "a finished job stays in the list");
|
||||
|
||||
let rows = log.rows();
|
||||
assert_eq!(rows[0].fraction, 1.0);
|
||||
assert!(!rows[0].running);
|
||||
assert!(!rows[0].failed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failure_survives_a_run_of_successes() {
|
||||
let log = ActivityLog::new();
|
||||
log.begin(Kind::Download, "Downloading")
|
||||
.fail("host is down");
|
||||
for i in 0..KEEP_STOPPED * 2 {
|
||||
log.begin(Kind::Thumbnails, format!("Batch {i}"))
|
||||
.finish("done");
|
||||
}
|
||||
|
||||
let rows = log.rows();
|
||||
assert!(
|
||||
rows.iter().any(|r| r.failed),
|
||||
"the one row worth keeping was pushed out by routine successes"
|
||||
);
|
||||
assert!(rows.len() <= KEEP_STOPPED + 1, "history is bounded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_leaves_running_jobs_alone() {
|
||||
let log = ActivityLog::new();
|
||||
let scan = log.begin(Kind::Scan, "Scanning");
|
||||
log.begin(Kind::Sync, "Syncing").fail("timed out");
|
||||
|
||||
log.clear_finished();
|
||||
assert_eq!(log.summary().kept, 0);
|
||||
assert_eq!(log.summary().running, 1);
|
||||
drop(scan);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_jobs_are_listed_first() {
|
||||
let log = ActivityLog::new();
|
||||
log.begin(Kind::Sync, "Syncing").finish("nothing to do");
|
||||
let scan = log.begin(Kind::Scan, "Scanning");
|
||||
|
||||
let rows = log.rows();
|
||||
assert_eq!(rows[0].title, "Scanning");
|
||||
assert!(rows[0].running);
|
||||
drop(scan);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_late_message_cannot_restart_a_stopped_job() {
|
||||
let log = ActivityLog::new();
|
||||
let fetch = log.begin(Kind::Download, "Downloading");
|
||||
fetch.fail("connection reset");
|
||||
// The drain reads one more queued message before it notices.
|
||||
fetch.advance();
|
||||
|
||||
assert_eq!(log.summary().running, 0);
|
||||
assert!(log.rows()[0].failed);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user