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