671 lines
23 KiB
Rust
671 lines
23 KiB
Rust
//! 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,
|
|
/// TRACES: FR-EXP-7
|
|
/// Rendering and writing finished files.
|
|
Export,
|
|
}
|
|
|
|
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
|
|
// A batch export fetches every original it does not already have
|
|
// cached, which on a selection of three hundred RAW files is the
|
|
// largest transfer this application ever starts. It moves nothing
|
|
// at all when the whole selection is already on disk, but the coarse
|
|
// answer has to be the cautious one: the question this flag answers
|
|
// is asked by somebody on a metered connection.
|
|
| Kind::Export => 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())
|
|
}
|
|
}
|
|
|
|
/// TRACES: FR-NC-6a
|
|
/// A download someone is watching, as the line under its bar and how full the
|
|
/// bar is: `received` of `total` bytes, and a fraction in 0..1 — or below
|
|
/// zero when there is no total, which the bar draws as indeterminate rather
|
|
/// than as a position it would have to invent.
|
|
pub fn describe_download(received: u64, total: Option<u64>) -> (String, f32) {
|
|
match total.filter(|&t| t > 0) {
|
|
// Nothing yet: the size alone says what the wait is for, where
|
|
// "0 kB of 38.0 MB" would read as a transfer that has stalled.
|
|
Some(t) if received == 0 => (format!("Downloading {}", describe_bytes(t)), 0.0),
|
|
// A file that grew since the scan measured it can overrun the
|
|
// catalog's length; the bar stops full rather than past its end.
|
|
Some(t) => (
|
|
format!(
|
|
"Downloading — {} of {}",
|
|
describe_bytes(received),
|
|
describe_bytes(t.max(received))
|
|
),
|
|
(received as f64 / t as f64).min(1.0) as f32,
|
|
),
|
|
None if received > 0 => (format!("Downloading — {}", describe_bytes(received)), -1.0),
|
|
None => ("Downloading…".to_string(), -1.0),
|
|
}
|
|
}
|
|
|
|
/// 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_download_reads_as_what_it_knows() {
|
|
let mb = 1024 * 1024;
|
|
assert_eq!(
|
|
describe_download(0, None),
|
|
("Downloading…".to_string(), -1.0)
|
|
);
|
|
assert_eq!(
|
|
describe_download(0, Some(38 * mb)),
|
|
("Downloading 38.0 MB".to_string(), 0.0)
|
|
);
|
|
let (text, fraction) = describe_download(19 * mb, Some(38 * mb));
|
|
assert_eq!(text, "Downloading — 19.0 MB of 38.0 MB");
|
|
assert_eq!(fraction, 0.5);
|
|
let (text, fraction) = describe_download(12 * mb, None);
|
|
assert_eq!(text, "Downloading — 12.0 MB");
|
|
assert!(fraction < 0.0, "no total, no position");
|
|
let (text, fraction) = describe_download(40 * mb, Some(38 * mb));
|
|
assert_eq!(text, "Downloading — 40.0 MB of 40.0 MB");
|
|
assert_eq!(fraction, 1.0, "an overrun stops full");
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
}
|