The export button rendered, resampled and encoded a 24 MP frame on the UI
thread and the window was dead for all of it. That was written down as a known
compromise, on the grounds that a batch is what makes the wait intolerable
rather than merely noticeable. This is the batch, so the compromise comes due.
The grid's selection now exports (FR-EXP-7). A worker thread takes a clone of
the `GpuContext` — an `Arc` pair over a device and a queue — and opens each
photograph for itself: fetch, sidecar, decode, demosaic, render at full size,
resample, sharpen, encode, write. Nothing of that touches the interface, which
keeps drawing throughout, and the progress goes where every other background
job's does: one row in the activity register, with a count and a bar.
Why the worker does not borrow the session it could have had. A
`DevelopSession` owns the `AdjustPass` the canvas renders from, so handing it
to a worker would stop the develop view drawing for the length of the batch —
the same freeze, moved. Opening a session per image instead costs a
`Demosaicer` and an `AdjustPass` each time round, and the pipeline cache is
per-pass so the composed shader is recompiled per image rather than once for
the run. Against a full-resolution decode, render and encode that is a few
percent, and it keeps this file out of the pipeline `develop` owns. A reusable
export pass is the obvious next economy if a profile ever says so.
The open image is the exception, and it is why the develop button is not simply
a one-image batch. Its edit lives in the interface's session and may not have
reached a sidecar yet, so a worker that re-opened the file would export the
saved version rather than the one on screen. That frame is therefore rendered
by the caller and handed over as `Source::Rendered`; everything after the
render — the Lanczos reduction, the encode, the write, which is the larger half
of the wait and all of its variance — still leaves the UI thread. So the
develop export is no longer synchronous, but it is not fully off-thread either,
and the doc comment says so rather than claiming otherwise.
Cancellation (NFR-ARCH-3) is an `AtomicBool` read between stages, and the
export button becomes the cancel button while a run is live — a batch that
could only be stopped by not touching the selection would be a trap. Waits on
another worker use `recv_timeout` rather than `recv`, so a cancelled batch
sitting on a forty-megabyte download gives up within 100 ms instead of when the
transfer finishes. The honest bound is worse than that: a frame already in
render has no interior stopping point, so the worst case is one image. Closing
that needs the render itself to become interruptible, which is NFR-ARCH-2's
scheduler and not a finer poll here.
Failures are per image and typed (NFR-ARCH-4). One unreadable body, one folder
that cannot be written, one server that went away — each is a message on the
channel, a line in the log, and a count in the summary, and the batch carries
on. A run with any failure keeps its row until it is cleared, because that is
the row somebody came to the list to find; a cancelled run does not, because
they asked for it.
Two collisions that look alike and are not. `CollisionPolicy` is the user's
answer to "a file of this name was already there", and Overwrite is a fine
answer to that. It is not an answer to "the frame I exported four seconds ago
was also called this" — two folders in a library each holding an IMG_0001 is
ordinary — so a name the run has already issued is always stepped past whatever
the policy says about the folder. Both halves are held by tests.
Supporting changes, each smaller than it sounds. `open_session` comes out of
`load_bytes` so the worker shares the JPEG-versus-RAW routing rather than
carrying a copy that would drift; the half that builds a `slint::Image` stays
behind, where it belongs. `LibraryController::selected_image_paths` answers
from the catalog rather than from the loaded window, because selection is by id
and survives a scrub — a selection made before scrolling routinely names
photographs no row holds. `cache_context_for` takes an id for the same reason,
so a batch reads the originals cache instead of re-downloading three hundred
files. `format_date` is shared so `{date}` and the timeline agree about what
day a photograph was taken.
Left undone, deliberately: the batch is sequential, where FR-EXP-7 asks for all
available cores. Four full-resolution frames in flight is tens of megabytes
each and a straightforward way to exhaust a tablet, and the GPU is shared with
the interface in any case. Also undone: exporting with a chosen preset rather
than the current export settings — that is FR-EXP-5's machinery, which does not
exist yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
624 lines
21 KiB
Rust
624 lines
21 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())
|
|
}
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
}
|