use std::io::{IsTerminal, Write};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::sync::{Arc, Mutex, PoisonError, TryLockError};
#[cfg(not(target_arch = "wasm32"))]
use std::time::Instant;
#[cfg(target_arch = "wasm32")]
use web_time::Instant;
pub const NO_PROGRESS_ENV: &str = "FOCR_NO_PROGRESS";
const BAR_CELLS: usize = 24;
static ACTIVE_LINE_WIDTH: AtomicUsize = AtomicUsize::new(0);
static ACTIVE_GENERATION: AtomicUsize = AtomicUsize::new(0);
static NEXT_GENERATION: AtomicUsize = AtomicUsize::new(1);
static PROGRESS_SUPPRESSED: AtomicBool = AtomicBool::new(false);
static OUTPUT_LOCK: Mutex<()> = Mutex::new(());
struct Inner {
label: &'static str,
total: usize,
done: usize,
current: String,
started: Instant,
eta_secs: Option<f64>,
}
struct RenderFrame {
label: &'static str,
total: usize,
done: usize,
current: String,
elapsed_secs: f64,
eta_secs: Option<f64>,
}
impl From<&Inner> for RenderFrame {
fn from(inner: &Inner) -> Self {
Self {
label: inner.label,
total: inner.total,
done: inner.done,
current: inner.current.clone(),
elapsed_secs: inner.started.elapsed().as_secs_f64(),
eta_secs: inner.eta_secs,
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct PageEta {
pages: u64,
elapsed_ms: u64,
}
impl PageEta {
pub fn add_window(&mut self, pages: usize, elapsed_ms: u64) {
self.pages += pages as u64;
self.elapsed_ms = self.elapsed_ms.saturating_add(elapsed_ms);
}
#[must_use]
pub fn remaining_ms(&self, pages_left: usize) -> Option<u64> {
(self.pages > 0).then(|| {
let ms = u128::from(self.elapsed_ms) * pages_left as u128 / u128::from(self.pages);
u64::try_from(ms).unwrap_or(u64::MAX)
})
}
}
#[derive(Default)]
struct WindowTally {
eta: PageEta,
width: usize,
tokens: usize,
elapsed_ms: u64,
}
impl WindowTally {
fn add(&mut self, window: &crate::native_engine::window::WindowStats) {
self.eta.add_window(window.pages.len(), window.elapsed_ms);
self.width = self.width.max(window.pages.len());
self.tokens += window.generated_tokens;
self.elapsed_ms = self.elapsed_ms.saturating_add(window.elapsed_ms);
}
fn status(&self, index: usize, done: usize, total: usize) -> String {
let window = index + 1;
let windows = total.div_ceil(self.width.max(1)).max(window);
let rate = self.tokens as f64 * 1000.0 / self.elapsed_ms.max(1) as f64;
format!("page {done}/{total}, window {window}/{windows}, {rate:.1} tok/s")
}
}
struct Shared {
state: Mutex<Inner>,
finished: AtomicBool,
suspended: AtomicBool,
generation: usize,
}
enum RenderCommand {
Draw,
Finish(Option<Sender<()>>),
}
pub struct Progress {
inner: Option<Arc<Shared>>,
renderer: Option<Sender<RenderCommand>>,
}
impl Progress {
pub fn new(label: &'static str, total: usize, human_mode: bool) -> Self {
if !human_mode
|| total == 0
|| PROGRESS_SUPPRESSED.load(Ordering::Acquire)
|| !stderr_supports_progress()
{
return Self {
inner: None,
renderer: None,
};
}
let inner = Arc::new(Shared {
state: Mutex::new(Inner {
label,
total,
done: 0,
current: String::new(),
started: Instant::now(),
eta_secs: None,
}),
finished: AtomicBool::new(false),
suspended: AtomicBool::new(false),
generation: NEXT_GENERATION.fetch_add(1, Ordering::Relaxed),
});
let (renderer, commands) = channel();
let renderer_state = Arc::clone(&inner);
if std::thread::Builder::new()
.name("focr-progress".into())
.spawn(move || render_commands(renderer_state, commands))
.is_err()
{
return Self {
inner: None,
renderer: None,
};
}
Self {
inner: Some(inner),
renderer: Some(renderer),
}
}
pub fn is_enabled(&self) -> bool {
self.inner.is_some() && self.renderer.is_some()
}
pub fn start_item(&self, current: impl Into<String>) {
let Some(inner) = &self.inner else { return };
if inner.finished.load(Ordering::Acquire) {
return;
}
inner.suspended.store(false, Ordering::Release);
let mut g = lock(&inner.state);
if inner.finished.load(Ordering::Acquire) {
return;
}
g.current = current.into();
drop(g);
self.send(RenderCommand::Draw);
}
pub fn reading_item(&self, current: impl Into<String>) {
let Some(inner) = &self.inner else { return };
if lock(&inner.state).eta_secs.is_none() {
self.start_item(current);
}
}
pub fn complete_item(&self) {
let Some(inner) = &self.inner else { return };
if inner.finished.load(Ordering::Acquire) {
return;
}
let mut g = lock(&inner.state);
if inner.finished.load(Ordering::Acquire) {
return;
}
g.done = (g.done + 1).min(g.total);
}
pub fn set_total(&self, total: usize) {
let Some(inner) = &self.inner else { return };
if inner.finished.load(Ordering::Acquire) {
return;
}
let mut g = lock(&inner.state);
g.total = total;
g.done = g.done.min(total);
}
pub fn suspend(&self) {
let Some(inner) = &self.inner else { return };
if inner.finished.load(Ordering::Acquire) {
return;
}
inner.suspended.store(true, Ordering::Release);
clear_bar_line(inner.generation);
}
pub fn note(&self, msg: &str) {
let Some(inner) = &self.inner else {
stderr_message(format_args!("{msg}"));
return;
};
if inner.finished.load(Ordering::Acquire) || PROGRESS_SUPPRESSED.load(Ordering::Acquire) {
return;
}
inner.suspended.store(true, Ordering::Release);
{
let _output = output_lock();
clear_generation_locked(inner.generation);
let mut err = std::io::stderr().lock();
let _ = writeln!(err, "{msg}");
let _ = err.flush();
}
inner.suspended.store(false, Ordering::Release);
self.send(RenderCommand::Draw);
}
pub fn finish(&self) {
let Some(inner) = &self.inner else { return };
if inner.finished.swap(true, Ordering::AcqRel) {
return;
}
inner.suspended.store(true, Ordering::Release);
let (ack_tx, ack_rx) = channel();
self.send(RenderCommand::Finish(Some(ack_tx)));
if ack_rx.recv().is_err() {
clear_bar_line(inner.generation);
}
}
pub fn retire(&self) {
let Some(inner) = &self.inner else { return };
if inner.finished.swap(true, Ordering::AcqRel) {
return;
}
inner.suspended.store(true, Ordering::Release);
self.send(RenderCommand::Finish(None));
try_clear_bar_line(inner.generation);
}
pub fn document_sink(&self) -> crate::DocumentSink {
let Some(inner) = &self.inner else {
return Box::new(|_: &crate::DocumentEvent<'_>| {});
};
let Some(renderer) = self.renderer.as_ref().cloned() else {
return Box::new(|_: &crate::DocumentEvent<'_>| {});
};
let inner = Arc::clone(inner);
let mut tally = WindowTally::default();
Box::new(move |event: &crate::DocumentEvent<'_>| {
if inner.finished.load(Ordering::Acquire) || PROGRESS_SUPPRESSED.load(Ordering::Acquire)
{
return;
}
let mut g = lock(&inner.state);
if inner.finished.load(Ordering::Acquire) {
return;
}
match event {
crate::DocumentEvent::Page(streamed) => {
let page = streamed.index;
g.done = page.min(g.total);
g.current = format!("page {page}/{}", g.total);
}
crate::DocumentEvent::Window(window) => {
tally.add(window);
g.current = tally.status(window.index, g.done, g.total);
let left = g.total - g.done;
g.eta_secs = tally.eta.remaining_ms(left).map(|ms| ms as f64 / 1000.0);
}
}
drop(g);
let _ = renderer.send(RenderCommand::Draw);
})
}
fn send(&self, command: RenderCommand) {
if let Some(renderer) = &self.renderer {
let _ = renderer.send(command);
}
}
}
impl Drop for Progress {
fn drop(&mut self) {
self.retire();
}
}
fn lock(inner: &Mutex<Inner>) -> std::sync::MutexGuard<'_, Inner> {
inner.lock().unwrap_or_else(PoisonError::into_inner)
}
fn output_lock() -> std::sync::MutexGuard<'static, ()> {
OUTPUT_LOCK.lock().unwrap_or_else(PoisonError::into_inner)
}
fn try_output_lock() -> Option<std::sync::MutexGuard<'static, ()>> {
match OUTPUT_LOCK.try_lock() {
Ok(output) => Some(output),
Err(TryLockError::Poisoned(e)) => Some(e.into_inner()),
Err(TryLockError::WouldBlock) => None,
}
}
fn render_commands(inner: Arc<Shared>, commands: Receiver<RenderCommand>) {
while let Ok(command) = commands.recv() {
match command {
RenderCommand::Draw => draw_latest(&inner),
RenderCommand::Finish(ack) => {
clear_bar_line(inner.generation);
if let Some(ack) = ack {
let _ = ack.send(());
}
return;
}
}
}
clear_bar_line(inner.generation);
}
fn draw_latest(inner: &Shared) {
let frame = {
let g = lock(&inner.state);
RenderFrame::from(&*g)
};
let _ = draw_frame(inner, &frame);
}
fn draw_frame(inner: &Shared, frame: &RenderFrame) -> usize {
let _output = output_lock();
if inner.finished.load(Ordering::Acquire)
|| inner.suspended.load(Ordering::Acquire)
|| PROGRESS_SUPPRESSED.load(Ordering::Acquire)
{
clear_generation_locked(inner.generation);
return 0;
}
let line = render_line(
frame.label,
frame.done,
frame.total,
&frame.current,
frame.elapsed_secs,
frame.eta_secs,
);
let width = line.chars().count();
let previous_width = ACTIVE_LINE_WIDTH.load(Ordering::Acquire);
let pad = previous_width.saturating_sub(width);
ACTIVE_GENERATION.store(inner.generation, Ordering::Release);
ACTIVE_LINE_WIDTH.store(width.max(previous_width), Ordering::Release);
let mut err = std::io::stderr().lock();
let _ = write!(err, "\r{line}{:pad$}", "");
let _ = err.flush();
drop(err);
if inner.finished.load(Ordering::Acquire)
|| inner.suspended.load(Ordering::Acquire)
|| PROGRESS_SUPPRESSED.load(Ordering::Acquire)
{
clear_generation_locked(inner.generation);
0
} else {
width
}
}
fn clear_generation_locked(generation: usize) {
if ACTIVE_GENERATION.load(Ordering::Acquire) != generation {
return;
}
let width = ACTIVE_LINE_WIDTH.swap(0, Ordering::AcqRel);
ACTIVE_GENERATION.store(0, Ordering::Release);
if width == 0 {
return;
}
let mut err = std::io::stderr().lock();
let _ = write!(err, "\r{:width$}\r", "");
let _ = err.flush();
ACTIVE_LINE_WIDTH.store(0, Ordering::Release);
}
fn render_line(
label: &str,
done: usize,
total: usize,
current: &str,
elapsed_secs: f64,
eta_secs: Option<f64>,
) -> String {
let total = total.max(1);
let done = done.min(total);
let filled = done * BAR_CELLS / total;
let pct = done * 100 / total;
let mut line = format!(
"[focr] {label} [{}{}] {pct:>3}%",
"#".repeat(filled),
"-".repeat(BAR_CELLS - filled)
);
if !current.is_empty() {
line.push(' ');
line.extend(current.chars().map(|ch| {
if ch.is_ascii() && !ch.is_ascii_control() {
ch
} else {
'?'
}
}));
}
line.push_str(&format!(", {} elapsed", fmt_duration(elapsed_secs)));
if done > 0 && done < total {
match eta_secs {
Some(eta) => line.push_str(&format!(", estimate {} left", fmt_duration(eta))),
None => {
let eta = elapsed_secs / done as f64 * (total - done) as f64;
line.push_str(&format!(", ~{} left", fmt_duration(eta)));
}
}
}
line
}
pub fn clear_active_line() {
let _output = output_lock();
clear_active_line_locked();
}
pub fn stderr_message(args: std::fmt::Arguments<'_>) {
let _output = output_lock();
clear_active_line_locked();
let mut err = std::io::stderr().lock();
let _ = writeln!(err, "{args}");
let _ = err.flush();
}
pub fn try_stderr_message(args: std::fmt::Arguments<'_>) -> bool {
let Some(_output) = try_output_lock() else {
return false;
};
clear_active_line_locked();
let mut err = std::io::stderr().lock();
let _ = writeln!(err, "{args}");
let _ = err.flush();
true
}
fn clear_bar_line(generation: usize) {
let _output = output_lock();
clear_generation_locked(generation);
}
pub fn suppress_for_interrupt() {
PROGRESS_SUPPRESSED.store(true, Ordering::Release);
try_clear_active_line();
}
fn try_clear_bar_line(generation: usize) {
if let Some(_output) = try_output_lock() {
clear_generation_locked(generation);
}
}
fn try_clear_active_line() {
if let Some(_output) = try_output_lock() {
clear_active_line_locked();
}
}
fn clear_active_line_locked() {
let width = ACTIVE_LINE_WIDTH.swap(0, Ordering::AcqRel);
ACTIVE_GENERATION.store(0, Ordering::Release);
if width == 0 {
return;
}
let mut err = std::io::stderr().lock();
let _ = write!(err, "\r{:width$}\r", "");
let _ = err.flush();
}
fn fmt_duration(secs: f64) -> String {
let s = secs.max(0.0).round() as u64;
if s < 60 {
format!("{s}s")
} else if s < 3600 {
format!("{}m{:02}s", s / 60, s % 60)
} else {
format!("{}h{:02}m", s / 3600, (s % 3600) / 60)
}
}
fn stderr_supports_progress() -> bool {
if crate::timing_enabled() {
return false;
}
progress_allowed(
std::io::stderr().is_terminal(),
std::env::var("TERM").ok().as_deref(),
std::env::var(NO_PROGRESS_ENV).ok().as_deref(),
)
}
pub fn progress_allowed(
stderr_is_tty: bool,
term: Option<&str>,
no_progress: Option<&str>,
) -> bool {
if !stderr_is_tty {
return false;
}
if term == Some("dumb") {
return false;
}
match no_progress {
None => true,
Some(v) => {
let v = v.trim();
v.is_empty() || v == "0"
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::mpsc;
use std::time::Duration;
static TEST_GLOBAL_LOCK: Mutex<()> = Mutex::new(());
fn streamed(index: usize) -> crate::DocumentEvent<'static> {
crate::DocumentEvent::Page(crate::StreamedPage {
index,
body: "body",
page: None,
})
}
fn window_stats(
index: usize,
pages: std::ops::Range<usize>,
generated_tokens: usize,
elapsed_ms: u64,
) -> crate::native_engine::window::WindowStats {
crate::native_engine::window::WindowStats {
index,
pages,
lookahead: true,
reused: false,
fallback_reason: None,
straddle: false,
prefill_tokens: 0,
copied_rows: 0,
forced_tokens: 0,
generated_tokens,
finish: crate::native_engine::FinishReason::Stop,
kv_bytes_peak: 0,
rss_bytes_peak: None,
retire_fallbacks: 0,
elapsed_ms,
}
}
fn test_bar(total: usize) -> (Progress, Arc<Shared>, std::thread::JoinHandle<()>) {
let shared = Arc::new(Shared {
state: Mutex::new(Inner {
label: "ocr",
total,
done: 0,
current: String::new(),
started: Instant::now(),
eta_secs: None,
}),
finished: AtomicBool::new(false),
suspended: AtomicBool::new(false),
generation: NEXT_GENERATION.fetch_add(1, Ordering::Relaxed),
});
let (renderer, commands) = channel();
let renderer_state = Arc::clone(&shared);
let renderer_thread = std::thread::spawn(move || render_commands(renderer_state, commands));
(
Progress {
inner: Some(Arc::clone(&shared)),
renderer: Some(renderer),
},
shared,
renderer_thread,
)
}
#[test]
fn not_a_tty_never_draws() {
assert!(!progress_allowed(false, None, None));
assert!(!progress_allowed(false, Some("xterm-256color"), None));
assert!(!progress_allowed(false, Some("xterm"), Some("0")));
}
#[test]
fn tty_gate_respects_term_and_kill_switch() {
assert!(progress_allowed(true, None, None));
assert!(progress_allowed(true, Some("xterm-256color"), None));
assert!(!progress_allowed(true, Some("dumb"), None));
assert!(!progress_allowed(true, Some("xterm"), Some("1")));
assert!(!progress_allowed(true, Some("xterm"), Some("true")));
assert!(progress_allowed(true, Some("xterm"), Some("0")));
assert!(progress_allowed(true, Some("xterm"), Some("")));
}
#[test]
fn robot_and_json_modes_construct_disabled() {
let bar = Progress::new("ocr", 10, false);
assert!(!bar.is_enabled());
bar.start_item("page 1/10");
bar.complete_item();
bar.suspend();
bar.retire();
let mut sink = bar.document_sink();
sink(&streamed(3));
}
#[test]
fn zero_total_constructs_disabled() {
assert!(!Progress::new("ocr", 0, true).is_enabled());
}
#[test]
fn set_total_rescales_the_bar_and_its_document_sink() {
let _global = TEST_GLOBAL_LOCK
.lock()
.unwrap_or_else(PoisonError::into_inner);
let (bar, shared, renderer) = test_bar(5);
bar.complete_item();
bar.complete_item();
bar.complete_item();
bar.set_total(3);
let (done, total) = {
let g = lock(&shared.state);
(g.done, g.total)
};
assert_eq!((done, total), (3, 3));
bar.set_total(2);
assert_eq!(lock(&shared.state).done, 2, "done never exceeds total");
let mut sink = bar.document_sink();
sink(&streamed(1));
assert_eq!(lock(&shared.state).current, "page 1/2");
bar.finish();
bar.set_total(9);
assert_eq!(lock(&shared.state).total, 2, "a finished bar is frozen");
drop(bar);
renderer.join().expect("renderer exits");
}
#[test]
fn render_line_shape() {
let line = render_line("ocr", 4, 12, "page 5/12", 42.0, None);
assert!(line.starts_with("[focr] ocr ["));
assert!(line.contains(" 33%"), "{line}");
assert!(line.contains("page 5/12"), "{line}");
assert!(line.contains("42s elapsed"), "{line}");
assert!(line.contains("~1m24s left"), "{line}");
assert!(!line.contains('\r') && !line.contains('\n'));
assert!(line.is_ascii());
}
#[test]
fn render_line_sanitizes_terminal_control_and_wide_characters() {
let line = render_line(
"batch",
0,
1,
"image 1/1: bad\r\n\u{1b}[31m-wide-\u{754c}.png",
0.0,
None,
);
assert!(line.is_ascii(), "{line:?}");
assert!(!line.contains('\r') && !line.contains('\n'));
assert!(!line.contains('\u{1b}'));
assert!(line.contains("bad???[31m-wide-?.png"), "{line:?}");
}
#[test]
fn finish_does_not_wait_for_document_sink_state_lock() {
let _serial = TEST_GLOBAL_LOCK.lock().unwrap();
let (bar, shared, renderer) = test_bar(2);
let guard = lock(&shared.state);
let (tx, rx) = mpsc::channel();
let worker = std::thread::spawn(move || {
bar.finish();
let _ = tx.send(());
});
assert!(
rx.recv_timeout(Duration::from_secs(1)).is_ok(),
"finish blocked behind a busy page-sink mutex"
);
drop(guard);
worker.join().expect("finish worker");
renderer.join().expect("progress renderer");
assert!(shared.finished.load(Ordering::Acquire));
}
#[test]
fn renderer_cannot_publish_after_retirement() {
let _serial = TEST_GLOBAL_LOCK.lock().unwrap();
ACTIVE_LINE_WIDTH.store(0, Ordering::Release);
ACTIVE_GENERATION.store(0, Ordering::Release);
let (bar, shared, progress_renderer) = test_bar(2);
let frame = {
let mut g = lock(&shared.state);
g.current = "page 1/2".into();
RenderFrame::from(&*g)
};
let output_guard = output_lock();
let (started_tx, started_rx) = mpsc::channel();
let (done_tx, done_rx) = mpsc::channel();
let renderer_state = Arc::clone(&shared);
let renderer = std::thread::spawn(move || {
let _ = started_tx.send(());
let rendered_width = draw_frame(&renderer_state, &frame);
let _ = done_tx.send(rendered_width);
});
started_rx.recv().expect("renderer starts");
bar.retire();
assert!(
done_rx.try_recv().is_err(),
"renderer must be at output gate"
);
drop(output_guard);
assert_eq!(
done_rx
.recv_timeout(Duration::from_secs(1))
.expect("renderer retires"),
0
);
renderer.join().expect("renderer worker");
progress_renderer.join().expect("progress renderer");
assert_eq!(ACTIVE_LINE_WIDTH.load(Ordering::Acquire), 0);
}
#[test]
fn stale_renderer_cannot_clear_a_newer_generation() {
let _serial = TEST_GLOBAL_LOCK.lock().unwrap();
let old_generation = NEXT_GENERATION.fetch_add(1, Ordering::Relaxed);
let newer_generation = NEXT_GENERATION.fetch_add(1, Ordering::Relaxed);
ACTIVE_GENERATION.store(newer_generation, Ordering::Release);
ACTIVE_LINE_WIDTH.store(37, Ordering::Release);
let shared = Arc::new(Shared {
state: Mutex::new(Inner {
label: "old",
total: 1,
done: 0,
current: String::new(),
started: Instant::now(),
eta_secs: None,
}),
finished: AtomicBool::new(true),
suspended: AtomicBool::new(true),
generation: old_generation,
});
let (commands, receiver) = channel();
let renderer = std::thread::spawn(move || render_commands(shared, receiver));
let (ack_tx, ack_rx) = channel();
commands
.send(RenderCommand::Finish(Some(ack_tx)))
.expect("finish stale renderer");
ack_rx
.recv_timeout(Duration::from_secs(1))
.expect("stale renderer acknowledges retirement");
renderer.join().expect("stale renderer");
assert_eq!(ACTIVE_GENERATION.load(Ordering::Acquire), newer_generation);
assert_eq!(ACTIVE_LINE_WIDTH.load(Ordering::Acquire), 37);
ACTIVE_GENERATION.store(0, Ordering::Release);
ACTIVE_LINE_WIDTH.store(0, Ordering::Release);
}
#[test]
fn teardown_stderr_never_waits_for_terminal_output() {
let _serial = TEST_GLOBAL_LOCK.lock().unwrap();
let output_guard = output_lock();
let (tx, rx) = mpsc::channel();
let worker = std::thread::spawn(move || {
let wrote = try_stderr_message(format_args!("teardown"));
let _ = tx.send(wrote);
});
assert!(
!rx.recv_timeout(Duration::from_secs(1))
.expect("teardown stderr returns"),
"teardown stderr unexpectedly acquired the busy output lock"
);
worker.join().expect("teardown stderr worker");
drop(output_guard);
}
#[test]
fn document_sink_never_waits_for_terminal_output() {
let _serial = TEST_GLOBAL_LOCK.lock().unwrap();
let (bar, _shared, renderer) = test_bar(2);
let mut sink = bar.document_sink();
let output_guard = output_lock();
let (tx, rx) = mpsc::channel();
let callback = std::thread::spawn(move || {
sink(&streamed(1));
let _ = tx.send(());
});
assert!(
rx.recv_timeout(Duration::from_secs(1)).is_ok(),
"model-runtime callback waited for terminal I/O"
);
callback.join().expect("page callback");
drop(output_guard);
bar.finish();
renderer.join().expect("progress renderer");
}
#[test]
fn render_line_eta_bounds() {
assert!(!render_line("ocr", 0, 5, "page 1/5", 3.0, None).contains("left"));
let done = render_line("ocr", 5, 5, "", 30.0, None);
assert!(!done.contains("left"));
assert!(done.contains("100%"));
}
#[test]
fn a_windowed_estimate_is_labelled_and_replaces_the_linear_one() {
let line = render_line("ocr", 10, 20, "page 10/20", 900.0, Some(840.0));
assert!(line.ends_with(", estimate 14m00s left"), "{line:?}");
assert!(!line.contains('~'), "{line:?}");
}
#[test]
fn page_eta_is_the_running_mean_of_a_page() {
let mut eta = PageEta::default();
assert_eq!(eta.remaining_ms(20), None, "no rate before a window ends");
eta.add_window(10, 1_000);
assert_eq!(eta.remaining_ms(10), Some(1_000));
eta.add_window(10, 3_000);
assert_eq!(eta.remaining_ms(10), Some(2_000), "the mean of both");
assert_eq!(eta.remaining_ms(0), Some(0));
}
#[test]
fn page_eta_falls_as_a_steady_run_goes() {
let (pages, window) = (35, 10);
let mut eta = PageEta::default();
let mut previous = u64::MAX;
for done in (window..=pages).step_by(window) {
eta.add_window(window, 850_000);
let left = eta.remaining_ms(pages - done).expect("a window ended");
assert!(left < previous, "{left} after {previous}");
previous = left;
}
}
#[test]
fn a_window_reports_its_place_rate_and_estimate() {
let _serial = TEST_GLOBAL_LOCK.lock().unwrap();
let (bar, shared, renderer) = test_bar(25);
let mut sink = bar.document_sink();
sink(&streamed(10));
sink(&crate::DocumentEvent::Window(&window_stats(
0,
0..10,
5_000,
500_000,
)));
{
let g = lock(&shared.state);
assert_eq!(g.current, "page 10/25, window 1/3, 10.0 tok/s");
assert_eq!(g.eta_secs, Some(750.0));
}
bar.reading_item("read page 12/25");
assert_eq!(
lock(&shared.state).current,
"page 10/25, window 1/3, 10.0 tok/s",
"the line keeps reporting the windows"
);
sink(&streamed(25));
sink(&crate::DocumentEvent::Window(&window_stats(
2,
20..25,
1_000,
100_000,
)));
assert_eq!(
lock(&shared.state).current,
"page 25/25, window 3/3, 10.0 tok/s",
"a short last window leaves the count alone"
);
bar.finish();
drop(bar);
renderer.join().expect("renderer exits");
}
#[test]
fn reading_names_a_page_until_a_window_has_ended() {
let _serial = TEST_GLOBAL_LOCK.lock().unwrap();
let (bar, shared, renderer) = test_bar(25);
bar.reading_item("read page 3/25");
assert_eq!(lock(&shared.state).current, "read page 3/25");
bar.finish();
drop(bar);
renderer.join().expect("renderer exits");
}
#[test]
fn duration_formatting() {
assert_eq!(fmt_duration(3.2), "3s");
assert_eq!(fmt_duration(62.4), "1m02s");
assert_eq!(fmt_duration(3725.0), "1h02m");
assert_eq!(fmt_duration(-1.0), "0s");
}
}