Skip to main content

teksilo_data/
chart_window.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `ChartWindow<T>` — a "last N points per series" streaming projection over
5//! a [`crate::ChartModel`].
6//!
7//! Wraps a [`ChartModel<T>`](crate::ChartModel) and exposes the tail
8//! `window_size` points of every series — the live-scrolling-strip-chart
9//! pattern (a sensor feed, a log-rate graph, a stock ticker). Unlike
10//! [`crate::ChartAggregate`], `ChartWindow` copies **no point data**: it
11//! tracks, per series, the source index of the window's first visible point
12//! (`starts`) and delegates every read straight through to the source. That
13//! means a `ChartWindow<T>` needs no `T: Clone` bound at all.
14//!
15//! ## Reactivity
16//!
17//! The upstream [`ChartChange`] stream is translated, not collapsed to a
18//! blanket `Reset` (unlike [`crate::SortFilterListModel`], where an
19//! arbitrary sort-key move makes fine-grained translation unsafe — a
20//! fixed-size tail window has no such hazard): a tail append into a full
21//! window becomes a `PointsRemoved` + `PointsInserted` pair (the window
22//! slides), a tail append into a still-growing window becomes a plain
23//! `PointsInserted`, and symmetrically a **tail removal** (trimming the
24//! series' own end — e.g. discarding a bad trailing reading) becomes the
25//! mirror-image `PointsRemoved` + `PointsInserted` pair: points beyond the
26//! new total drop out of the window, and if the window slid backward to
27//! stay full, the newly-uncovered prefix is revealed as an insertion.
28//! Anything that isn't a clean tail append/removal (a mid-series insert or
29//! removal) falls back to a per-series rebuild reported as
30//! `SeriesDataReplaced`.
31//!
32//! ```ignore
33//! use teksilo_data::{ChartModel, ChartWindow};
34//! let model: ChartModel<i32> = ChartModel::new();
35//! let s = model.add_series("sensor");
36//! for i in 0..100 {
37//!     model.push_point(s, i, i as f32);
38//! }
39//! let window = ChartWindow::new(model.clone(), 10);
40//! assert_eq!(window.point_count(s), 10); // last 10 points only
41//! ```
42
43use std::cell::Cell;
44use std::cell::RefCell;
45use std::collections::HashMap;
46use std::rc::Rc;
47
48use teksilo_core::ObserverHandle;
49use teksilo_core::color_prop::ColorProp;
50
51use crate::chart_change::{ChartChange, SeriesId};
52use crate::chart_model::{ChartDatum, ChartModel};
53
54type SeriesIdsFn = Rc<dyn Fn() -> Vec<SeriesId>>;
55type PointCountFn = Rc<dyn Fn(SeriesId) -> usize>;
56type WithPointFn<T> = Rc<dyn Fn(SeriesId, usize, &dyn Fn(&ChartDatum<T>))>;
57type WithSeriesFn = Rc<dyn Fn(SeriesId, &dyn Fn(&str, Option<&ColorProp>, bool))>;
58type ObserveChartFn = Rc<dyn Fn(Box<dyn Fn(&ChartChange)>) -> ObserverHandle>;
59
60struct ObserverEntry {
61    id: u64,
62    callback: Rc<dyn Fn(&ChartChange)>,
63}
64
65struct ChartWindowInner<T: 'static> {
66    series_ids_fn: SeriesIdsFn,
67    point_count_fn: PointCountFn,
68    with_point_fn: WithPointFn<T>,
69    with_series_fn: WithSeriesFn,
70    window_size: usize,
71    /// Source index of window position 0, per series.
72    starts: HashMap<SeriesId, usize>,
73    /// First window-local index that may have changed, per series. See
74    /// [`ChartWindow::first_changed_index`].
75    divergence: HashMap<SeriesId, usize>,
76    observers: Vec<ObserverEntry>,
77    next_observer_id: u64,
78    _upstream_handle: Option<ObserverHandle>,
79}
80
81impl<T: 'static> ChartWindowInner<T> {
82    fn start_for(&self, series: SeriesId) -> usize {
83        let total = (self.point_count_fn)(series);
84        total.saturating_sub(self.window_size)
85    }
86
87    fn rebuild_series(&mut self, series: SeriesId) {
88        let start = self.start_for(series);
89        self.starts.insert(series, start);
90        self.divergence.insert(series, 0);
91    }
92
93    fn rebuild_all(&mut self) {
94        self.starts.clear();
95        self.divergence.clear();
96        for series in (self.series_ids_fn)() {
97            self.rebuild_series(series);
98        }
99    }
100
101    fn snapshot_callbacks(&self) -> Vec<Rc<dyn Fn(&ChartChange)>> {
102        self.observers.iter().map(|e| e.callback.clone()).collect()
103    }
104}
105
106/// A "last N points per series" streaming projection over a [`ChartModel<T>`].
107///
108/// See the module documentation for semantics.
109pub struct ChartWindow<T: 'static> {
110    inner: Rc<RefCell<ChartWindowInner<T>>>,
111}
112
113impl<T: 'static> ChartWindow<T> {
114    /// Wrap `source`, showing only the last `window_size` points of every
115    /// series.
116    pub fn new(source: ChartModel<T>, window_size: usize) -> Self {
117        let series_ids_fn: SeriesIdsFn = {
118            let m = source.clone();
119            Rc::new(move || m.series_ids())
120        };
121        let point_count_fn: PointCountFn = {
122            let m = source.clone();
123            Rc::new(move |series| m.point_count(series))
124        };
125        let with_point_fn: WithPointFn<T> = {
126            let m = source.clone();
127            Rc::new(move |series, idx, f| {
128                m.with_point(series, idx, |d| f(d));
129            })
130        };
131        let with_series_fn: WithSeriesFn = {
132            let m = source.clone();
133            Rc::new(move |series, f| {
134                m.with_series(series, |name, color, visible| f(name, color, visible));
135            })
136        };
137        let observe_fn: ObserveChartFn = {
138            let m = source;
139            Rc::new(move |callback| m.observe_changes(move |change| callback(change)))
140        };
141
142        let inner = Rc::new(RefCell::new(ChartWindowInner {
143            series_ids_fn,
144            point_count_fn,
145            with_point_fn,
146            with_series_fn,
147            window_size,
148            starts: HashMap::new(),
149            divergence: HashMap::new(),
150            observers: Vec::new(),
151            next_observer_id: 1,
152            _upstream_handle: None,
153        }));
154
155        inner.borrow_mut().rebuild_all();
156
157        let weak = Rc::downgrade(&inner);
158        let upstream_handle = (observe_fn)(Box::new(move |change| {
159            if let Some(strong) = weak.upgrade() {
160                translate_and_notify(&strong, change);
161            }
162        }));
163        inner.borrow_mut()._upstream_handle = Some(upstream_handle);
164
165        Self { inner }
166    }
167
168    /// The configured window size.
169    pub fn window_size(&self) -> usize {
170        self.inner.borrow().window_size
171    }
172
173    /// Change the window size, rebuilding every series and emitting
174    /// `ChartChange::Reset`.
175    pub fn set_window_size(&self, window_size: usize) {
176        let callbacks = {
177            let mut guard = self.inner.borrow_mut();
178            guard.window_size = window_size;
179            guard.rebuild_all();
180            guard.snapshot_callbacks()
181        };
182        for cb in &callbacks {
183            cb(&ChartChange::Reset);
184        }
185    }
186
187    /// Number of series (same set as the source).
188    pub fn series_count(&self) -> usize {
189        (self.inner.borrow().series_ids_fn)().len()
190    }
191
192    /// The series ids, in the source's display order.
193    pub fn series_ids(&self) -> Vec<SeriesId> {
194        (self.inner.borrow().series_ids_fn)()
195    }
196
197    /// Number of points currently visible in the window for `series`.
198    pub fn point_count(&self, series: SeriesId) -> usize {
199        let guard = self.inner.borrow();
200        let total = (guard.point_count_fn)(series);
201        let start = guard
202            .starts
203            .get(&series)
204            .copied()
205            .unwrap_or_else(|| total.saturating_sub(guard.window_size));
206        total.saturating_sub(start)
207    }
208
209    /// Access a series' metadata (delegates straight through to the
210    /// source). Returns `None` if `series` is unknown.
211    pub fn with_series<R>(
212        &self,
213        series: SeriesId,
214        f: impl FnOnce(&str, Option<&ColorProp>, bool) -> R,
215    ) -> Option<R> {
216        let with_series_fn = self.inner.borrow().with_series_fn.clone();
217        let f_cell: Cell<Option<_>> = Cell::new(Some(f));
218        let slot: Cell<Option<R>> = Cell::new(None);
219        (with_series_fn)(series, &|name, color, visible| {
220            if let Some(f) = f_cell.take() {
221                slot.set(Some(f(name, color, visible)));
222            }
223        });
224        slot.into_inner()
225    }
226
227    /// Access the point at window-local `index` within `series`. Returns
228    /// `None` if `series` is unknown or `index` is outside the window.
229    pub fn with_point<R>(
230        &self,
231        series: SeriesId,
232        index: usize,
233        f: impl FnOnce(&ChartDatum<T>) -> R,
234    ) -> Option<R> {
235        let (with_point_fn, src_idx) = {
236            let guard = self.inner.borrow();
237            let total = (guard.point_count_fn)(series);
238            let start = guard
239                .starts
240                .get(&series)
241                .copied()
242                .unwrap_or_else(|| total.saturating_sub(guard.window_size));
243            let visible = total.saturating_sub(start);
244            // Bounds-check before computing `start + index` — `index` is
245            // caller-supplied and may be far out of range (e.g. usize::MAX),
246            // which would overflow the addition once the window has slid
247            // (start > 0).
248            if index >= visible {
249                return None;
250            }
251            (guard.with_point_fn.clone(), start + index)
252        };
253        let f_cell: Cell<Option<_>> = Cell::new(Some(f));
254        let slot: Cell<Option<R>> = Cell::new(None);
255        (with_point_fn)(series, src_idx, &|d: &ChartDatum<T>| {
256            if let Some(f) = f_cell.take() {
257                slot.set(Some(f(d)));
258            }
259        });
260        slot.into_inner()
261    }
262
263    /// Register an observer for translated window changes. Returns an
264    /// `ObserverHandle` — dropping it removes the callback.
265    pub fn observe_changes(&self, f: impl Fn(&ChartChange) + 'static) -> ObserverHandle {
266        let mut guard = self.inner.borrow_mut();
267        let id = guard.next_observer_id;
268        guard.next_observer_id += 1;
269        guard.observers.push(ObserverEntry {
270            id,
271            callback: Rc::new(f),
272        });
273        let inner = self.inner.clone();
274        ObserverHandle::new(
275            self.inner.clone(),
276            id,
277            Rc::new(move |observer_id| {
278                inner.borrow_mut().observers.retain(|e| e.id != observer_id);
279            }),
280        )
281    }
282
283    /// First window-local index of `series` whose content may differ since
284    /// the latest translated change. Per-series (chart data is 2-level:
285    /// series, then points), unlike
286    /// [`SortFilterListModel::first_changed_index`](crate::SortFilterListModel::first_changed_index)'s
287    /// single flat value. `None` if `series` is unknown or unaffected yet.
288    pub fn first_changed_index(&self, series: SeriesId) -> Option<usize> {
289        self.inner.borrow().divergence.get(&series).copied()
290    }
291}
292
293/// Translate one upstream `ChartChange` into zero or more local changes,
294/// mutating `starts` / `divergence` as needed. See module docs.
295fn translate<T: 'static>(
296    inner: &mut ChartWindowInner<T>,
297    change: &ChartChange,
298) -> Vec<ChartChange> {
299    match change {
300        ChartChange::SeriesInserted { index, series } => {
301            let (index, series) = (*index, *series);
302            inner.starts.insert(series, 0);
303            inner.divergence.insert(series, 0);
304            vec![ChartChange::SeriesInserted { index, series }]
305        }
306        ChartChange::SeriesRemoved { series } => {
307            let series = *series;
308            inner.starts.remove(&series);
309            inner.divergence.remove(&series);
310            vec![ChartChange::SeriesRemoved { series }]
311        }
312        ChartChange::SeriesMoved { series, from, to } => vec![ChartChange::SeriesMoved {
313            series: *series,
314            from: *from,
315            to: *to,
316        }],
317        ChartChange::SeriesRenamed { series } => {
318            vec![ChartChange::SeriesRenamed { series: *series }]
319        }
320        ChartChange::SeriesColorChanged { series } => {
321            vec![ChartChange::SeriesColorChanged { series: *series }]
322        }
323        ChartChange::SeriesPatternChanged { series } => {
324            vec![ChartChange::SeriesPatternChanged { series: *series }]
325        }
326        ChartChange::SeriesVisibilityChanged { series } => {
327            vec![ChartChange::SeriesVisibilityChanged { series: *series }]
328        }
329        ChartChange::PointsInserted { series, range } => {
330            let series = *series;
331            let range = range.clone();
332            let window_size = inner.window_size;
333            let new_total = (inner.point_count_fn)(series);
334            let inserted = range.end - range.start;
335            let old_total = new_total - inserted;
336            let old_start = inner
337                .starts
338                .get(&series)
339                .copied()
340                .unwrap_or_else(|| old_total.saturating_sub(window_size));
341
342            if range.start != old_total {
343                // Not a tail append — a mid-series insert. Rebuild.
344                inner.rebuild_series(series);
345                return vec![ChartChange::SeriesDataReplaced { series }];
346            }
347
348            // Absolute recompute (mirrors `ChartAggregate::translate`'s
349            // `PointsInserted` branch): derive what the window shows from
350            // absolute source indices rather than assuming exactly one
351            // `shift`-sized block is evicted/appended. That assumption only
352            // holds once the window is already full — a multi-point tail
353            // append can take a not-yet-full window straight past full in
354            // one step, and treating that as a same-size slide emits an
355            // insertion range that's too short for the actual growth,
356            // landing past the end of a consumer's mirrored array.
357            let old_visible = old_total.saturating_sub(old_start);
358            let new_start = new_total.saturating_sub(window_size);
359            let new_visible = new_total.saturating_sub(new_start);
360            // Old-window entries below `new_start` are no longer in range;
361            // the rest (if any) survive as the new window's prefix.
362            let dropped = new_start.saturating_sub(old_start).min(old_visible);
363            let remaining = old_visible - dropped;
364
365            let mut out = Vec::new();
366            if dropped > 0 {
367                out.push(ChartChange::PointsRemoved {
368                    series,
369                    range: 0..dropped,
370                });
371            }
372            if new_visible > remaining {
373                out.push(ChartChange::PointsInserted {
374                    series,
375                    range: remaining..new_visible,
376                });
377            }
378            // A front-removal renumbers every surviving index, so the first
379            // index that may differ is 0; a pure append (no removal) leaves
380            // the old prefix untouched, so it's the position after it.
381            inner
382                .divergence
383                .insert(series, if dropped > 0 { 0 } else { remaining });
384            inner.starts.insert(series, new_start);
385            out
386        }
387        ChartChange::PointsRemoved { series, range } => {
388            let series = *series;
389            let range = range.clone();
390            let window_size = inner.window_size;
391            let new_total = (inner.point_count_fn)(series);
392            let removed = range.end - range.start;
393            let old_total = new_total + removed;
394            let old_start = inner
395                .starts
396                .get(&series)
397                .copied()
398                .unwrap_or_else(|| old_total.saturating_sub(window_size));
399
400            if range.end != old_total {
401                // Not a tail removal — a front/mid-series removal
402                // renumbers every point after it, including ones the
403                // window doesn't show. Rebuild.
404                inner.rebuild_series(series);
405                return vec![ChartChange::SeriesDataReplaced { series }];
406            }
407
408            // Tail-removal mirror of the `PointsInserted` tail-append
409            // branch above: absolute recompute from source indices rather
410            // than assuming a fixed-size slide. `new_start <= old_start`
411            // always holds here (removing points can only grow the window
412            // backward to keep it full, never shrink it forward) — the
413            // old window's front survives at a shifted local position, its
414            // tail (the removed points) is gone, and any newly-uncovered
415            // prefix below the old start is freshly revealed.
416            let old_visible = old_total.saturating_sub(old_start);
417            let new_start = new_total.saturating_sub(window_size);
418            let new_visible = new_total.saturating_sub(new_start);
419            // Old-window entries at or past `new_total` were removed; the
420            // rest (if any) survive as the front of the old window.
421            let survivors = new_total.saturating_sub(old_start).min(old_visible);
422            let removed_from_window = old_visible - survivors;
423            let revealed = old_start.saturating_sub(new_start);
424
425            let mut out = Vec::new();
426            if removed_from_window > 0 {
427                out.push(ChartChange::PointsRemoved {
428                    series,
429                    range: survivors..old_visible,
430                });
431            }
432            if revealed > 0 {
433                out.push(ChartChange::PointsInserted {
434                    series,
435                    range: 0..revealed,
436                });
437            }
438            // A revealed prefix renumbers every surviving index, so the
439            // first index that may differ is 0; otherwise the survivors
440            // (if any) are untouched and only the point past them changed.
441            inner.divergence.insert(
442                series,
443                if revealed > 0 {
444                    0
445                } else {
446                    survivors.min(new_visible)
447                },
448            );
449            inner.starts.insert(series, new_start);
450            out
451        }
452        ChartChange::PointUpdated { series, index } => {
453            let (series, index) = (*series, *index);
454            let start = inner.starts.get(&series).copied().unwrap_or(0);
455            if index >= start {
456                let local = index - start;
457                inner.divergence.insert(series, local);
458                vec![ChartChange::PointUpdated {
459                    series,
460                    index: local,
461                }]
462            } else {
463                vec![]
464            }
465        }
466        ChartChange::SeriesDataReplaced { series } => {
467            let series = *series;
468            inner.rebuild_series(series);
469            vec![ChartChange::SeriesDataReplaced { series }]
470        }
471        ChartChange::Reset => {
472            inner.rebuild_all();
473            vec![ChartChange::Reset]
474        }
475    }
476}
477
478fn translate_and_notify<T: 'static>(
479    inner: &Rc<RefCell<ChartWindowInner<T>>>,
480    change: &ChartChange,
481) {
482    let (changes, callbacks) = {
483        let mut guard = inner.borrow_mut();
484        let changes = translate(&mut guard, change);
485        (changes, guard.snapshot_callbacks())
486    };
487    for c in &changes {
488        for cb in &callbacks {
489            cb(c);
490        }
491    }
492}
493
494impl<T: 'static> Clone for ChartWindow<T> {
495    fn clone(&self) -> Self {
496        Self {
497            inner: self.inner.clone(),
498        }
499    }
500}
501
502impl<T: 'static> std::fmt::Debug for ChartWindow<T> {
503    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
504        let guard = self.inner.borrow();
505        f.debug_struct("ChartWindow")
506            .field("window_size", &guard.window_size)
507            .field("series_count", &(guard.series_ids_fn)().len())
508            .finish()
509    }
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515
516    fn one_series(n: usize) -> (ChartModel<i32>, SeriesId) {
517        let model: ChartModel<i32> = ChartModel::new();
518        let s = model.add_series("s");
519        for i in 0..n {
520            model.push_point(s, i as i32, i as f32);
521        }
522        (model, s)
523    }
524
525    fn track(window: &ChartWindow<i32>) -> (Rc<RefCell<Vec<ChartChange>>>, ObserverHandle) {
526        let log: Rc<RefCell<Vec<ChartChange>>> = Rc::new(RefCell::new(Vec::new()));
527        let l = log.clone();
528        let handle = window.observe_changes(move |c| l.borrow_mut().push(c.clone()));
529        (log, handle)
530    }
531
532    #[test]
533    fn initial_window_shows_only_the_tail() {
534        let (_model, s) = one_series(5);
535        let window = ChartWindow::new(_model, 3);
536        assert_eq!(window.point_count(s), 3);
537        let vals: Vec<f32> = (0..3)
538            .map(|i| window.with_point(s, i, |d| d.value).unwrap())
539            .collect();
540        assert_eq!(vals, vec![2.0, 3.0, 4.0]);
541    }
542
543    #[test]
544    fn with_point_out_of_range_returns_none_without_overflow() {
545        // Regression: `start + index` must be computed only after bounds-
546        // checking `index` — with a slid window (start > 0), an out-of-range
547        // `index` like `usize::MAX` would otherwise overflow the addition
548        // and panic (debug builds) instead of returning `None`.
549        let (model, s) = one_series(5);
550        let window = ChartWindow::new(model, 3); // window slid: start == 2
551        assert_eq!(window.with_point(s, usize::MAX, |d| d.value), None);
552    }
553
554    #[test]
555    fn full_window_shift_emits_removed_then_inserted() {
556        let (model, s) = one_series(3);
557        let window = ChartWindow::new(model.clone(), 3);
558        assert_eq!(window.point_count(s), 3);
559        let (log, _h) = track(&window);
560
561        model.push_point(s, 3, 3.0);
562
563        let entries = log.borrow();
564        assert_eq!(entries.len(), 2);
565        assert_eq!(
566            entries[0],
567            ChartChange::PointsRemoved {
568                series: s,
569                range: 0..1
570            }
571        );
572        assert_eq!(
573            entries[1],
574            ChartChange::PointsInserted {
575                series: s,
576                range: 2..3
577            }
578        );
579        drop(entries);
580
581        assert_eq!(window.point_count(s), 3);
582        let vals: Vec<f32> = (0..3)
583            .map(|i| window.with_point(s, i, |d| d.value).unwrap())
584            .collect();
585        assert_eq!(vals, vec![1.0, 2.0, 3.0]);
586    }
587
588    #[test]
589    fn partial_window_tail_append_emits_plain_inserted() {
590        let (model, s) = one_series(1);
591        let window = ChartWindow::new(model.clone(), 5);
592        let (log, _h) = track(&window);
593
594        model.push_point(s, 1, 1.0);
595
596        let entries = log.borrow();
597        assert_eq!(entries.len(), 1);
598        assert_eq!(
599            entries[0],
600            ChartChange::PointsInserted {
601                series: s,
602                range: 1..2
603            }
604        );
605        drop(entries);
606        assert_eq!(window.point_count(s), 2);
607    }
608
609    #[test]
610    fn mid_series_insert_falls_back_to_replace() {
611        let (model, s) = one_series(3);
612        let window = ChartWindow::new(model.clone(), 5);
613        let (log, _h) = track(&window);
614
615        model.insert_point(s, 1, 99, 99.0);
616
617        let entries = log.borrow();
618        assert_eq!(entries.len(), 1);
619        assert_eq!(entries[0], ChartChange::SeriesDataReplaced { series: s });
620        drop(entries);
621        assert_eq!(window.point_count(s), 4);
622    }
623
624    #[test]
625    fn front_removal_falls_back_to_replace() {
626        // Removing anything but the tail renumbers every later point,
627        // including ones the window doesn't show — must still rebuild.
628        let (model, s) = one_series(3);
629        let window = ChartWindow::new(model.clone(), 5);
630        let (log, _h) = track(&window);
631
632        model.remove_point(s, 0);
633
634        let entries = log.borrow();
635        assert_eq!(entries.len(), 1);
636        assert_eq!(entries[0], ChartChange::SeriesDataReplaced { series: s });
637        drop(entries);
638        assert_eq!(window.point_count(s), 2);
639    }
640
641    #[test]
642    fn partial_window_tail_removal_emits_plain_removed() {
643        // Window not full (5 points shown out of a 10-point capacity) —
644        // trimming the tail just shrinks it, nothing to reveal.
645        let (model, s) = one_series(5);
646        let window = ChartWindow::new(model.clone(), 10);
647        let (log, _h) = track(&window);
648
649        model.remove_point(s, 4); // the tail point
650
651        let entries = log.borrow();
652        assert_eq!(entries.len(), 1);
653        assert_eq!(
654            entries[0],
655            ChartChange::PointsRemoved {
656                series: s,
657                range: 4..5
658            }
659        );
660        drop(entries);
661        assert_eq!(window.point_count(s), 4);
662        let vals: Vec<f32> = (0..4)
663            .map(|i| window.with_point(s, i, |d| d.value).unwrap())
664            .collect();
665        assert_eq!(vals, vec![0.0, 1.0, 2.0, 3.0]);
666    }
667
668    #[test]
669    fn full_window_tail_removal_emits_removed_then_inserted() {
670        // Window full (shows the last 3 of 5) — trimming the tail slides it
671        // backward to reveal the point that just fell off the front.
672        let (model, s) = one_series(5); // window shows [2,3,4] -> [2.0,3.0,4.0]
673        let window = ChartWindow::new(model.clone(), 3);
674        assert_eq!(window.point_count(s), 3);
675        let (log, _h) = track(&window);
676
677        model.remove_point(s, 4); // remove the tail point (value 4.0)
678
679        let entries = log.borrow();
680        assert_eq!(entries.len(), 2);
681        assert_eq!(
682            entries[0],
683            ChartChange::PointsRemoved {
684                series: s,
685                range: 2..3
686            }
687        );
688        assert_eq!(
689            entries[1],
690            ChartChange::PointsInserted {
691                series: s,
692                range: 0..1
693            }
694        );
695        drop(entries);
696
697        assert_eq!(window.point_count(s), 3);
698        let vals: Vec<f32> = (0..3)
699            .map(|i| window.with_point(s, i, |d| d.value).unwrap())
700            .collect();
701        assert_eq!(vals, vec![1.0, 2.0, 3.0]);
702    }
703
704    #[test]
705    fn update_in_and_out_of_window() {
706        let (model, s) = one_series(5);
707        let window = ChartWindow::new(model.clone(), 2); // shows source indices 3,4
708        let (log, _h) = track(&window);
709
710        model.update_point(s, 4, 4, 40.0); // in window -> local index 1
711        model.update_point(s, 0, 0, 5.0); // out of window -> no emit
712
713        let entries = log.borrow();
714        assert_eq!(entries.len(), 1);
715        assert_eq!(
716            entries[0],
717            ChartChange::PointUpdated {
718                series: s,
719                index: 1
720            }
721        );
722        drop(entries);
723        assert_eq!(window.with_point(s, 1, |d| d.value), Some(40.0));
724    }
725
726    #[test]
727    fn reset_passes_through_and_rebuilds() {
728        let (model, s) = one_series(3);
729        let window = ChartWindow::new(model.clone(), 2);
730        let (log, _h) = track(&window);
731
732        model.clear();
733
734        assert_eq!(log.borrow().last(), Some(&ChartChange::Reset));
735        assert_eq!(window.point_count(s), 0);
736    }
737
738    #[test]
739    fn dropping_window_unregisters_upstream_observer() {
740        let model: ChartModel<i32> = ChartModel::new();
741        let before = model.observer_count();
742        let window = ChartWindow::new(model.clone(), 3);
743        assert_eq!(model.observer_count(), before + 1);
744        drop(window);
745        assert_eq!(model.observer_count(), before);
746    }
747
748    #[test]
749    fn set_window_size_rebuilds_and_emits_reset() {
750        let (model, s) = one_series(5);
751        let window = ChartWindow::new(model, 2);
752        assert_eq!(window.point_count(s), 2);
753        let (log, _h) = track(&window);
754
755        window.set_window_size(4);
756        assert_eq!(window.point_count(s), 4);
757        assert_eq!(log.borrow().last(), Some(&ChartChange::Reset));
758    }
759
760    #[test]
761    fn series_inserted_and_removed_pass_through() {
762        let model: ChartModel<i32> = ChartModel::new();
763        let window = ChartWindow::new(model.clone(), 3);
764        let (log, _h) = track(&window);
765
766        let s = model.add_series("new");
767        assert_eq!(
768            log.borrow().last(),
769            Some(&ChartChange::SeriesInserted {
770                index: 0,
771                series: s
772            })
773        );
774
775        model.remove_series(s);
776        assert_eq!(
777            log.borrow().last(),
778            Some(&ChartChange::SeriesRemoved { series: s })
779        );
780        assert_eq!(window.series_count(), 0);
781    }
782
783    #[test]
784    fn translate_multi_point_insert_not_full_to_overflow() {
785        // Regression: a bulk tail append (`range.len() > 1`) that takes a
786        // not-yet-full window straight past full must be handled by
787        // recomputing from absolute source indices, not by assuming exactly
788        // one fixed-size `shift` block is evicted/appended (that shortcut
789        // only holds once the window was already full). For this exact
790        // transition the old code emitted an insertion range too short for
791        // the actual growth, landing past the end of a consumer's mirrored
792        // array. `ChartModel` only ever emits length-1 `PointsInserted`
793        // ranges, so this builds a `ChartWindowInner` by hand (skipping
794        // `ChartWindow::new`'s auto-subscription, which would otherwise
795        // apply each push through the already-correct single-point path
796        // before there's a chance to test the batch one) and calls
797        // `translate` directly with a synthesized multi-point change.
798        let model: ChartModel<i32> = ChartModel::new();
799        let s = model.add_series("s");
800        model.push_point(s, 0, 0.0);
801        model.push_point(s, 1, 1.0); // model total: 2, window_size: 3 -> not full
802
803        let series_ids_fn: SeriesIdsFn = {
804            let m = model.clone();
805            Rc::new(move || m.series_ids())
806        };
807        let point_count_fn: PointCountFn = {
808            let m = model.clone();
809            Rc::new(move |series| m.point_count(series))
810        };
811        let with_point_fn: WithPointFn<i32> = {
812            let m = model.clone();
813            Rc::new(move |series, idx, f| {
814                m.with_point(series, idx, |d| f(d));
815            })
816        };
817        let with_series_fn: WithSeriesFn = {
818            let m = model.clone();
819            Rc::new(move |series, f| {
820                m.with_series(series, |name, color, visible| f(name, color, visible));
821            })
822        };
823
824        let mut inner = ChartWindowInner {
825            series_ids_fn,
826            point_count_fn,
827            with_point_fn,
828            with_series_fn,
829            window_size: 3,
830            starts: HashMap::new(),
831            divergence: HashMap::new(),
832            observers: Vec::new(),
833            next_observer_id: 1,
834            _upstream_handle: None,
835        };
836        inner.rebuild_all(); // caches start == 0 for the not-yet-full 2-point window
837
838        // Bulk-append 2 more points directly to the model — `inner` isn't
839        // subscribed, so it still thinks the total is 2 while the model now
840        // reports 4, exactly what a batch-insert API would report as one
841        // `PointsInserted { range: 2..4 }`.
842        model.push_point(s, 2, 2.0);
843        model.push_point(s, 3, 3.0);
844
845        let changes = translate(
846            &mut inner,
847            &ChartChange::PointsInserted {
848                series: s,
849                range: 2..4,
850            },
851        );
852
853        // Old window [0,1] (len 2) -> new window [1,2,3] (len 3): drop the
854        // one element before the new start, then insert the two new ones
855        // after the one that survives — never a range past the mirror's end.
856        assert_eq!(
857            changes,
858            vec![
859                ChartChange::PointsRemoved {
860                    series: s,
861                    range: 0..1
862                },
863                ChartChange::PointsInserted {
864                    series: s,
865                    range: 1..3
866                },
867            ]
868        );
869    }
870
871    #[test]
872    fn translate_multi_point_tail_removal_from_full_to_not_full() {
873        // Mirror of `translate_multi_point_insert_not_full_to_overflow` for
874        // removal: a bulk tail trim (`range.len() > 1`) that takes a full
875        // window back below capacity must be handled by the same absolute
876        // recompute, not a fixed-size `shift` assumption (which only holds
877        // for a single-point removal). `ChartModel::remove_point` only ever
878        // emits length-1 `PointsRemoved` ranges, so this builds a
879        // `ChartWindowInner` by hand and calls `translate` directly with a
880        // synthesized multi-point change, exactly like the insert-side
881        // regression test above.
882        let model: ChartModel<i32> = ChartModel::new();
883        let s = model.add_series("s");
884        for i in 0..5 {
885            model.push_point(s, i, i as f32);
886        }
887
888        let series_ids_fn: SeriesIdsFn = {
889            let m = model.clone();
890            Rc::new(move || m.series_ids())
891        };
892        let point_count_fn: PointCountFn = {
893            let m = model.clone();
894            Rc::new(move |series| m.point_count(series))
895        };
896        let with_point_fn: WithPointFn<i32> = {
897            let m = model.clone();
898            Rc::new(move |series, idx, f| {
899                m.with_point(series, idx, |d| f(d));
900            })
901        };
902        let with_series_fn: WithSeriesFn = {
903            let m = model.clone();
904            Rc::new(move |series, f| {
905                m.with_series(series, |name, color, visible| f(name, color, visible));
906            })
907        };
908
909        let mut inner = ChartWindowInner {
910            series_ids_fn,
911            point_count_fn,
912            with_point_fn,
913            with_series_fn,
914            window_size: 3,
915            starts: HashMap::new(),
916            divergence: HashMap::new(),
917            observers: Vec::new(),
918            next_observer_id: 1,
919            _upstream_handle: None,
920        };
921        inner.rebuild_all(); // caches start == 2 for the full 5-point window: [2,3,4]
922
923        // Bulk-remove the last 3 points directly from the model — `inner`
924        // isn't subscribed, so it still thinks the total is 5 while the
925        // model now reports 2, exactly what a batch-remove API would
926        // report as one `PointsRemoved { range: 2..5 }`.
927        model.remove_point(s, 4);
928        model.remove_point(s, 3);
929        model.remove_point(s, 2);
930
931        let changes = translate(
932            &mut inner,
933            &ChartChange::PointsRemoved {
934                series: s,
935                range: 2..5,
936            },
937        );
938
939        // Old window [2,3,4] (len 3) -> new window [0,1] (len 2): every old
940        // entry is gone (none survive below the new total of 2), and both
941        // remaining source points are freshly revealed at the front.
942        assert_eq!(
943            changes,
944            vec![
945                ChartChange::PointsRemoved {
946                    series: s,
947                    range: 0..3
948                },
949                ChartChange::PointsInserted {
950                    series: s,
951                    range: 0..2
952                },
953            ]
954        );
955    }
956}