use std::cell::RefCell;
use std::rc::Rc;
use slotmap::SlotMap;
use teksilo_core::ObserverHandle;
use teksilo_core::color_prop::ColorProp;
use teksilo_core::signal::Signal;
use crate::chart_change::{ChartChange, SeriesId};
use crate::series_pattern::SeriesPattern;
#[derive(Debug, Clone)]
pub struct ChartDatum<T> {
pub category: T,
pub value: f32,
pub color: Option<ColorProp>,
}
impl<T> ChartDatum<T> {
pub fn new(category: T, value: f32) -> Self {
Self {
category,
value,
color: None,
}
}
pub fn with_color(mut self, color: impl Into<ColorProp>) -> Self {
self.color = Some(color.into());
self
}
}
pub struct ChartSeries<T> {
pub name: String,
pub color: Option<ColorProp>,
pub pattern: Option<SeriesPattern>,
pub visible: bool,
pub points: Vec<ChartDatum<T>>,
}
impl<T> std::fmt::Debug for ChartSeries<T>
where
T: std::fmt::Debug,
{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ChartSeries")
.field("name", &self.name)
.field("pattern", &self.pattern)
.field("visible", &self.visible)
.field("len", &self.points.len())
.finish()
}
}
impl<T> Clone for ChartSeries<T>
where
T: Clone,
{
fn clone(&self) -> Self {
Self {
name: self.name.clone(),
color: self.color.clone(),
pattern: self.pattern,
visible: self.visible,
points: self.points.clone(),
}
}
}
impl<T> ChartSeries<T> {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
color: None,
pattern: None,
visible: true,
points: Vec::new(),
}
}
pub fn color(mut self, color: impl Into<ColorProp>) -> Self {
self.color = Some(color.into());
self
}
pub fn pattern(mut self, pattern: SeriesPattern) -> Self {
self.pattern = Some(pattern);
self
}
pub fn visibility(mut self, visible: bool) -> Self {
self.visible = visible;
self
}
pub fn push(&mut self, category: T, value: f32) {
self.points.push(ChartDatum::new(category, value));
}
pub fn data(mut self, points: Vec<ChartDatum<T>>) -> Self {
self.points = points;
self
}
}
pub struct SeriesView<'a, T> {
pub id: SeriesId,
pub name: &'a str,
pub color: Option<&'a ColorProp>,
pub pattern: Option<SeriesPattern>,
pub visible: bool,
pub points: &'a [ChartDatum<T>],
}
struct SeriesEntry<T> {
name: String,
color: Option<ColorProp>,
pattern: Option<SeriesPattern>,
visible: bool,
points: Vec<ChartDatum<T>>,
}
struct ObserverEntry {
id: u64,
callback: Rc<dyn Fn(&ChartChange)>,
}
fn color_prop_eq(a: &ColorProp, b: &ColorProp) -> bool {
match (a, b) {
(ColorProp::Static(x), ColorProp::Static(y)) => x == y,
(ColorProp::Bound(x), ColorProp::Bound(y)) => Signal::same(x, y),
(ColorProp::TextRole(x), ColorProp::TextRole(y)) => x == y,
(ColorProp::SurfaceRole(x), ColorProp::SurfaceRole(y)) => x == y,
(ColorProp::BorderRole(x), ColorProp::BorderRole(y)) => x == y,
(ColorProp::DynamicTextRole(x), ColorProp::DynamicTextRole(y)) => Signal::same(x, y),
(ColorProp::DynamicSurfaceRole(x), ColorProp::DynamicSurfaceRole(y)) => Signal::same(x, y),
(ColorProp::DynamicBorderRole(x), ColorProp::DynamicBorderRole(y)) => Signal::same(x, y),
_ => false,
}
}
struct ChartModelInner<T> {
arena: SlotMap<slotmap::DefaultKey, SeriesEntry<T>>,
order: Vec<SeriesId>,
observers: Vec<ObserverEntry>,
next_observer_id: u64,
structure_version: Signal<u64>,
style_version: Signal<u64>,
#[cfg(debug_assertions)]
debug_adapter: Option<Rc<dyn crate::debug_registry::ModelDebug>>,
}
pub struct ChartModel<T: 'static> {
inner: Rc<RefCell<ChartModelInner<T>>>,
}
impl<T: 'static> ChartModel<T> {
pub fn new() -> Self {
Self {
inner: Rc::new(RefCell::new(ChartModelInner {
arena: SlotMap::new(),
order: Vec::new(),
observers: Vec::new(),
next_observer_id: 1,
structure_version: Signal::new(0),
style_version: Signal::new(0),
#[cfg(debug_assertions)]
debug_adapter: None,
})),
}
}
pub fn from_series_vec(series: Vec<ChartSeries<T>>) -> Self {
let model = Self::new();
{
let mut guard = model.inner.borrow_mut();
for s in series {
let key = guard.arena.insert(SeriesEntry {
name: s.name,
color: s.color,
pattern: s.pattern,
visible: s.visible,
points: s.points,
});
let id = SeriesId::from_key(key);
guard.order.push(id);
}
}
model
}
pub fn from_points(points: Vec<ChartDatum<T>>) -> Self {
Self::from_series_vec(vec![ChartSeries::new(String::new()).data(points)])
}
pub fn only_series(&self) -> Option<SeriesId> {
let guard = self.inner.borrow();
if guard.order.len() == 1 {
Some(guard.order[0])
} else {
None
}
}
pub fn add_series(&self, name: impl Into<String>) -> SeriesId {
let (id, index) = {
let mut guard = self.inner.borrow_mut();
let key = guard.arena.insert(SeriesEntry {
name: name.into(),
color: None,
pattern: None,
visible: true,
points: Vec::new(),
});
let id = SeriesId::from_key(key);
let index = guard.order.len();
guard.order.push(id);
(id, index)
};
self.notify(ChartChange::SeriesInserted { index, series: id });
self.bump_structure();
id
}
pub fn insert_series(&self, index: usize, name: impl Into<String>) -> SeriesId {
let id = {
let mut guard = self.inner.borrow_mut();
let key = guard.arena.insert(SeriesEntry {
name: name.into(),
color: None,
pattern: None,
visible: true,
points: Vec::new(),
});
let id = SeriesId::from_key(key);
guard.order.insert(index, id);
id
};
self.notify(ChartChange::SeriesInserted { index, series: id });
self.bump_structure();
id
}
pub fn remove_series(&self, series: SeriesId) {
{
let mut guard = self.inner.borrow_mut();
guard.arena.remove(series.key()).expect("unknown SeriesId");
guard.order.retain(|&id| id != series);
}
self.notify(ChartChange::SeriesRemoved { series });
self.bump_structure();
}
pub fn rename_series(&self, series: SeriesId, name: impl Into<String>) {
let name = name.into();
let changed = {
let mut guard = self.inner.borrow_mut();
let entry = &mut guard.arena[series.key()];
if entry.name == name {
false
} else {
entry.name = name;
true
}
};
if !changed {
return;
}
self.notify(ChartChange::SeriesRenamed { series });
self.bump_structure();
}
pub fn set_series_color(&self, series: SeriesId, color: impl Into<ColorProp>) {
let color = color.into();
let changed = {
let mut guard = self.inner.borrow_mut();
let entry = &mut guard.arena[series.key()];
if entry
.color
.as_ref()
.is_some_and(|c| color_prop_eq(c, &color))
{
false
} else {
entry.color = Some(color);
true
}
};
if !changed {
return;
}
self.notify(ChartChange::SeriesColorChanged { series });
self.bump_style();
}
pub fn clear_series_color(&self, series: SeriesId) {
let changed = {
let mut guard = self.inner.borrow_mut();
let entry = &mut guard.arena[series.key()];
if entry.color.is_none() {
false
} else {
entry.color = None;
true
}
};
if !changed {
return;
}
self.notify(ChartChange::SeriesColorChanged { series });
self.bump_style();
}
pub fn set_series_pattern(&self, series: SeriesId, pattern: SeriesPattern) {
let changed = {
let mut guard = self.inner.borrow_mut();
let entry = &mut guard.arena[series.key()];
if entry.pattern == Some(pattern) {
false
} else {
entry.pattern = Some(pattern);
true
}
};
if !changed {
return;
}
self.notify(ChartChange::SeriesPatternChanged { series });
self.bump_style();
}
pub fn clear_series_pattern(&self, series: SeriesId) {
let changed = {
let mut guard = self.inner.borrow_mut();
let entry = &mut guard.arena[series.key()];
if entry.pattern.is_none() {
false
} else {
entry.pattern = None;
true
}
};
if !changed {
return;
}
self.notify(ChartChange::SeriesPatternChanged { series });
self.bump_style();
}
pub fn set_series_visible(&self, series: SeriesId, visible: bool) {
let changed = {
let mut guard = self.inner.borrow_mut();
let entry = &mut guard.arena[series.key()];
if entry.visible == visible {
false
} else {
entry.visible = visible;
true
}
};
if !changed {
return;
}
self.notify(ChartChange::SeriesVisibilityChanged { series });
self.bump_structure();
}
pub fn move_series(&self, series: SeriesId, to: usize) {
let from = {
let guard = self.inner.borrow();
guard
.order
.iter()
.position(|&id| id == series)
.expect("unknown SeriesId")
};
if from == to {
return;
}
{
let mut guard = self.inner.borrow_mut();
let id = guard.order.remove(from);
guard.order.insert(to, id);
}
self.notify(ChartChange::SeriesMoved { series, from, to });
self.bump_structure();
}
pub fn clear(&self) {
{
let mut guard = self.inner.borrow_mut();
guard.arena.clear();
guard.order.clear();
}
self.notify(ChartChange::Reset);
self.bump_structure();
}
pub fn push_point(&self, series: SeriesId, category: T, value: f32) {
let index = {
let mut guard = self.inner.borrow_mut();
let entry = &mut guard.arena[series.key()];
let index = entry.points.len();
entry.points.push(ChartDatum::new(category, value));
index
};
self.notify(ChartChange::PointsInserted {
series,
range: index..index + 1,
});
self.bump_structure();
}
pub fn insert_point(&self, series: SeriesId, index: usize, category: T, value: f32) {
{
let mut guard = self.inner.borrow_mut();
guard.arena[series.key()]
.points
.insert(index, ChartDatum::new(category, value));
}
self.notify(ChartChange::PointsInserted {
series,
range: index..index + 1,
});
self.bump_structure();
}
pub fn remove_point(&self, series: SeriesId, index: usize) -> ChartDatum<T> {
let datum = {
let mut guard = self.inner.borrow_mut();
guard.arena[series.key()].points.remove(index)
};
self.notify(ChartChange::PointsRemoved {
series,
range: index..index + 1,
});
self.bump_structure();
datum
}
pub fn update_point(&self, series: SeriesId, index: usize, category: T, value: f32) {
{
let mut guard = self.inner.borrow_mut();
guard.arena[series.key()].points[index] = ChartDatum::new(category, value);
}
self.notify(ChartChange::PointUpdated { series, index });
self.bump_structure();
}
pub fn replace_series_data(&self, series: SeriesId, points: Vec<ChartDatum<T>>) {
{
let mut guard = self.inner.borrow_mut();
guard.arena[series.key()].points = points;
}
self.notify(ChartChange::SeriesDataReplaced { series });
self.bump_structure();
}
pub fn series_count(&self) -> usize {
self.inner.borrow().order.len()
}
pub fn series_ids(&self) -> Vec<SeriesId> {
self.inner.borrow().order.clone()
}
pub fn series_id_at(&self, index: usize) -> Option<SeriesId> {
self.inner.borrow().order.get(index).copied()
}
pub fn series_index_of(&self, series: SeriesId) -> Option<usize> {
self.inner
.borrow()
.order
.iter()
.position(|&id| id == series)
}
pub fn point_count(&self, series: SeriesId) -> usize {
self.inner
.borrow()
.arena
.get(series.key())
.map(|e| e.points.len())
.unwrap_or(0)
}
pub fn with_series<R>(
&self,
series: SeriesId,
f: impl FnOnce(&str, Option<&ColorProp>, bool) -> R,
) -> Option<R> {
let guard = self.inner.borrow();
guard
.arena
.get(series.key())
.map(|e| f(&e.name, e.color.as_ref(), e.visible))
}
pub fn with_point<R>(
&self,
series: SeriesId,
index: usize,
f: impl FnOnce(&ChartDatum<T>) -> R,
) -> Option<R> {
let guard = self.inner.borrow();
guard
.arena
.get(series.key())
.and_then(|e| e.points.get(index))
.map(f)
}
pub fn with_series_view<R>(
&self,
series: SeriesId,
f: impl FnOnce(SeriesView<'_, T>) -> R,
) -> Option<R> {
let guard = self.inner.borrow();
guard.arena.get(series.key()).map(|e| {
f(SeriesView {
id: series,
name: &e.name,
color: e.color.as_ref(),
pattern: e.pattern,
visible: e.visible,
points: &e.points,
})
})
}
pub fn with_all_series<R>(&self, f: impl FnOnce(&[SeriesView<'_, T>]) -> R) -> R {
let guard = self.inner.borrow();
let views: Vec<SeriesView<'_, T>> = guard
.order
.iter()
.filter_map(|&id| {
guard.arena.get(id.key()).map(|e| SeriesView {
id,
name: &e.name,
color: e.color.as_ref(),
pattern: e.pattern,
visible: e.visible,
points: &e.points,
})
})
.collect();
f(&views)
}
pub fn structure_version(&self) -> Signal<u64> {
self.inner.borrow().structure_version.clone()
}
pub fn style_version(&self) -> Signal<u64> {
self.inner.borrow().style_version.clone()
}
pub fn observe_changes(&self, f: impl Fn(&ChartChange) + 'static) -> ObserverHandle {
let mut guard = self.inner.borrow_mut();
let id = guard.next_observer_id;
guard.next_observer_id += 1;
guard.observers.push(ObserverEntry {
id,
callback: Rc::new(f),
});
let inner = self.inner.clone();
ObserverHandle::new(
self.inner.clone(),
id,
Rc::new(move |observer_id| {
inner.borrow_mut().observers.retain(|e| e.id != observer_id);
}),
)
}
fn notify(&self, change: ChartChange) {
let callbacks: Vec<Rc<dyn Fn(&ChartChange)>> = self
.inner
.borrow()
.observers
.iter()
.map(|e| e.callback.clone())
.collect();
for cb in &callbacks {
cb(&change);
}
}
fn bump_structure(&self) {
let sig = self.inner.borrow().structure_version.clone();
sig.set(sig.get().wrapping_add(1));
}
fn bump_style(&self) {
let sig = self.inner.borrow().style_version.clone();
sig.set(sig.get().wrapping_add(1));
}
}
impl<T: std::fmt::Debug + 'static> ChartModel<T> {
pub fn debug_named(self, _name: impl Into<String>) -> Self {
#[cfg(debug_assertions)]
{
let weak = Rc::downgrade(&self.inner);
let adapter: Rc<dyn crate::debug_registry::ModelDebug> =
Rc::new(ChartModelDebug::<T> { weak });
let name = _name.into();
crate::debug_registry::register(name, Rc::downgrade(&adapter));
self.inner.borrow_mut().debug_adapter = Some(adapter);
}
self
}
}
#[cfg(debug_assertions)]
struct ChartModelDebug<T> {
weak: std::rc::Weak<RefCell<ChartModelInner<T>>>,
}
#[cfg(debug_assertions)]
impl<T: std::fmt::Debug + 'static> crate::debug_registry::ModelDebug for ChartModelDebug<T> {
fn kind(&self) -> &'static str {
"ChartModel"
}
fn len(&self) -> usize {
self.weak
.upgrade()
.map(|inner| inner.borrow().arena.values().map(|e| e.points.len()).sum())
.unwrap_or(0)
}
fn debug_dump(&self, out: &mut dyn std::fmt::Write) {
let Some(inner) = self.weak.upgrade() else {
return;
};
let guard = inner.borrow();
for (i, &id) in guard.order.iter().enumerate() {
if let Some(e) = guard.arena.get(id.key()) {
let _ = writeln!(
out,
"[{}] {:?} ({} pts, visible={})",
i,
e.name,
e.points.len(),
e.visible
);
}
}
}
}
impl<T: 'static> Default for ChartModel<T> {
fn default() -> Self {
Self::new()
}
}
impl<T: 'static> Clone for ChartModel<T> {
fn clone(&self) -> Self {
Self {
inner: self.inner.clone(),
}
}
}
impl<T: std::fmt::Debug + 'static> std::fmt::Debug for ChartModel<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let guard = self.inner.borrow();
f.debug_struct("ChartModel")
.field("series_count", &guard.order.len())
.finish()
}
}
#[cfg(test)]
impl<T: 'static> ChartModel<T> {
pub(crate) fn observer_count(&self) -> usize {
self.inner.borrow().observers.len()
}
}
#[cfg(test)]
mod tests {
use std::cell::Cell;
use super::*;
#[test]
fn datum_with_color_sets_a_per_point_override() {
use teksilo_core::color_prop::ColorProp;
use teksilo_tokens::SurfaceRole;
let plain = ChartDatum::new("Q1".to_string(), 5.0);
assert!(plain.color.is_none(), "a plain datum has no color override");
let colored = ChartDatum::new("Q1".to_string(), 5.0).with_color(SurfaceRole::StatusError);
assert!(matches!(
colored.color,
Some(ColorProp::SurfaceRole(SurfaceRole::StatusError))
));
let _ = ChartDatum::new("Q2".to_string(), 1.0);
}
fn sample() -> (ChartModel<String>, SeriesId, SeriesId) {
let model = ChartModel::from_series_vec(vec![
ChartSeries::new("Revenue").data(vec![
ChartDatum::new("Q1".to_string(), 10.0),
ChartDatum::new("Q2".to_string(), 20.0),
]),
ChartSeries::new("Costs").data(vec![ChartDatum::new("Q1".to_string(), 5.0)]),
]);
let a = model.series_id_at(0).unwrap();
let b = model.series_id_at(1).unwrap();
(model, a, b)
}
#[test]
fn from_series_vec_builds_correctly() {
let (model, a, b) = sample();
assert_eq!(model.series_count(), 2);
assert_eq!(model.point_count(a), 2);
assert_eq!(model.point_count(b), 1);
assert_eq!(
model.with_series(a, |name, _, visible| (name.to_string(), visible)),
Some(("Revenue".to_string(), true))
);
}
#[test]
fn new_is_empty() {
let model: ChartModel<String> = ChartModel::new();
assert_eq!(model.series_count(), 0);
assert_eq!(model.only_series(), None);
}
#[test]
fn only_series_some_iff_exactly_one() {
let model: ChartModel<String> = ChartModel::new();
assert_eq!(model.only_series(), None);
let a = model.add_series("A");
assert_eq!(model.only_series(), Some(a));
model.add_series("B");
assert_eq!(model.only_series(), None);
}
#[test]
fn from_points_builds_single_anonymous_series() {
let model = ChartModel::from_points(vec![
ChartDatum::new("a".to_string(), 1.0),
ChartDatum::new("b".to_string(), 2.0),
]);
assert_eq!(model.series_count(), 1);
let s = model.only_series().unwrap();
assert_eq!(model.point_count(s), 2);
assert_eq!(model.with_series(s, |_, _, visible| visible), Some(true));
}
fn track_changes(
model: &ChartModel<String>,
) -> (Rc<RefCell<Vec<ChartChange>>>, ObserverHandle) {
let log: Rc<RefCell<Vec<ChartChange>>> = Rc::new(RefCell::new(Vec::new()));
let l = log.clone();
let handle = model.observe_changes(move |c| l.borrow_mut().push(c.clone()));
(log, handle)
}
#[test]
fn add_series_emits_inserted_and_bumps_structure() {
let model: ChartModel<String> = ChartModel::new();
let structure = model.structure_version();
let style = model.style_version();
let (log, _handle) = track_changes(&model);
let s = model.add_series("A");
assert_eq!(log.borrow().len(), 1);
assert_eq!(
log.borrow()[0],
ChartChange::SeriesInserted {
index: 0,
series: s
}
);
assert_eq!(structure.get(), 1);
assert_eq!(style.get(), 0);
}
#[test]
fn observer_sees_pre_bump_structure_version_during_notify() {
let model: ChartModel<String> = ChartModel::new();
let structure = model.structure_version();
let seen_during_callback: Rc<Cell<Option<u64>>> = Rc::new(Cell::new(None));
let seen = seen_during_callback.clone();
let sig = structure.clone();
let _handle = model.observe_changes(move |_| seen.set(Some(sig.get())));
let before = structure.get();
model.add_series("A");
let after = structure.get();
assert_eq!(after, before + 1, "the mutation did bump the signal");
assert_eq!(
seen_during_callback.get(),
Some(before),
"notify runs before the version bump, so a ChartChange observer \
reading structure_version() synchronously sees the pre-bump value"
);
}
#[test]
fn observer_sees_pre_bump_style_version_during_notify() {
let (model, a, _b) = sample();
let style = model.style_version();
let seen_during_callback: Rc<Cell<Option<u64>>> = Rc::new(Cell::new(None));
let seen = seen_during_callback.clone();
let sig = style.clone();
let _handle = model.observe_changes(move |_| seen.set(Some(sig.get())));
let before = style.get();
model.set_series_color(a, test_color());
let after = style.get();
assert_eq!(after, before + 1);
assert_eq!(seen_during_callback.get(), Some(before));
}
#[test]
fn insert_series_at_index() {
let (model, a, b) = sample();
let c = model.insert_series(1, "Middle");
assert_eq!(model.series_ids(), vec![a, c, b]);
}
#[test]
fn remove_series_emits_removed_and_bumps_structure() {
let (model, a, _b) = sample();
let structure_before = model.structure_version().get();
let (log, _handle) = track_changes(&model);
model.remove_series(a);
assert_eq!(log.borrow().len(), 1);
assert_eq!(log.borrow()[0], ChartChange::SeriesRemoved { series: a });
assert_eq!(model.series_count(), 1);
assert!(model.structure_version().get() > structure_before);
}
#[test]
fn rename_series_emits_renamed_and_bumps_structure() {
let (model, a, _b) = sample();
let style_before = model.style_version().get();
let (log, _handle) = track_changes(&model);
model.rename_series(a, "New Name");
assert_eq!(log.borrow().len(), 1);
assert_eq!(log.borrow()[0], ChartChange::SeriesRenamed { series: a });
assert_eq!(
model.with_series(a, |name, _, _| name.to_string()),
Some("New Name".to_string())
);
assert_eq!(
model.style_version().get(),
style_before,
"renaming is not a style change"
);
}
#[test]
fn rename_series_noop_does_not_notify() {
let (model, a, _b) = sample();
let structure_before = model.structure_version().get();
let (log, _handle) = track_changes(&model);
model.rename_series(a, "Revenue"); assert_eq!(log.borrow().len(), 0);
assert_eq!(model.structure_version().get(), structure_before);
}
#[test]
fn set_series_color_bumps_style_not_structure() {
let (model, a, _b) = sample();
let structure_before = model.structure_version().get();
let style_before = model.style_version().get();
let (log, _handle) = track_changes(&model);
model.set_series_color(a, test_color());
assert_eq!(log.borrow().len(), 1);
assert_eq!(
log.borrow()[0],
ChartChange::SeriesColorChanged { series: a }
);
assert_eq!(
model.structure_version().get(),
structure_before,
"color change must not bump structure_version"
);
assert!(model.style_version().get() > style_before);
assert!(model.with_series(a, |_, color, _| color.is_some()).unwrap());
}
#[test]
fn set_series_color_noop_does_not_notify() {
let (model, a, _b) = sample();
model.set_series_color(a, test_color());
let structure_before = model.structure_version().get();
let style_before = model.style_version().get();
let (log, _handle) = track_changes(&model);
model.set_series_color(a, test_color()); assert_eq!(log.borrow().len(), 0);
assert_eq!(model.structure_version().get(), structure_before);
assert_eq!(model.style_version().get(), style_before);
}
#[test]
fn clear_series_color_bumps_style_and_clears() {
let (model, a, _b) = sample();
model.set_series_color(a, test_color());
let style_before = model.style_version().get();
let (log, _handle) = track_changes(&model);
model.clear_series_color(a);
assert_eq!(log.borrow().len(), 1);
assert_eq!(
log.borrow()[0],
ChartChange::SeriesColorChanged { series: a }
);
assert!(model.style_version().get() > style_before);
assert!(!model.with_series(a, |_, color, _| color.is_some()).unwrap());
}
#[test]
fn clear_series_color_noop_does_not_notify_when_already_none() {
let (model, a, _b) = sample();
let structure_before = model.structure_version().get();
let style_before = model.style_version().get();
let (log, _handle) = track_changes(&model);
model.clear_series_color(a);
assert_eq!(log.borrow().len(), 0);
assert_eq!(model.structure_version().get(), structure_before);
assert_eq!(model.style_version().get(), style_before);
}
#[test]
fn set_series_visible_bumps_structure() {
let (model, a, _b) = sample();
let structure_before = model.structure_version().get();
let (log, _handle) = track_changes(&model);
model.set_series_visible(a, false);
assert_eq!(log.borrow().len(), 1);
assert_eq!(
log.borrow()[0],
ChartChange::SeriesVisibilityChanged { series: a }
);
assert!(model.structure_version().get() > structure_before);
assert_eq!(model.with_series(a, |_, _, visible| visible), Some(false));
}
#[test]
fn set_series_visible_noop_does_not_notify() {
let (model, a, _b) = sample();
let structure_before = model.structure_version().get();
let (log, _handle) = track_changes(&model);
model.set_series_visible(a, true); assert_eq!(log.borrow().len(), 0);
assert_eq!(model.structure_version().get(), structure_before);
}
#[test]
fn move_series_emits_moved_and_bumps_structure() {
let (model, a, b) = sample();
let structure_before = model.structure_version().get();
let (log, _handle) = track_changes(&model);
model.move_series(a, 1);
assert_eq!(model.series_ids(), vec![b, a]);
assert_eq!(log.borrow().len(), 1);
assert_eq!(
log.borrow()[0],
ChartChange::SeriesMoved {
series: a,
from: 0,
to: 1
}
);
assert!(model.structure_version().get() > structure_before);
}
#[test]
fn move_series_noop_does_not_notify() {
let (model, a, _b) = sample();
let structure_before = model.structure_version().get();
let (log, _handle) = track_changes(&model);
model.move_series(a, 0); assert_eq!(log.borrow().len(), 0);
assert_eq!(model.structure_version().get(), structure_before);
}
#[test]
fn push_point_emits_inserted_and_bumps_structure() {
let (model, a, _b) = sample();
let structure_before = model.structure_version().get();
let (log, _handle) = track_changes(&model);
model.push_point(a, "Q3".to_string(), 30.0);
assert_eq!(model.point_count(a), 3);
assert_eq!(log.borrow().len(), 1);
assert_eq!(
log.borrow()[0],
ChartChange::PointsInserted {
series: a,
range: 2..3
}
);
assert!(model.structure_version().get() > structure_before);
}
#[test]
fn insert_point_at_index() {
let (model, a, _b) = sample();
model.insert_point(a, 1, "Q1.5".to_string(), 15.0);
assert_eq!(model.point_count(a), 3);
assert_eq!(
model.with_point(a, 1, |d| d.category.clone()),
Some("Q1.5".to_string())
);
}
#[test]
fn remove_point_emits_removed_and_returns_datum() {
let (model, a, _b) = sample();
let (log, _handle) = track_changes(&model);
let removed = model.remove_point(a, 0);
assert_eq!(removed.category, "Q1");
assert_eq!(model.point_count(a), 1);
assert_eq!(log.borrow().len(), 1);
assert_eq!(
log.borrow()[0],
ChartChange::PointsRemoved {
series: a,
range: 0..1
}
);
}
#[test]
fn update_point_bumps_structure_not_style() {
let (model, a, _b) = sample();
let structure_before = model.structure_version().get();
let style_before = model.style_version().get();
let (log, _handle) = track_changes(&model);
model.update_point(a, 0, "Q1-revised".to_string(), 99.0);
assert_eq!(log.borrow().len(), 1);
assert_eq!(
log.borrow()[0],
ChartChange::PointUpdated {
series: a,
index: 0
}
);
assert!(model.structure_version().get() > structure_before);
assert_eq!(model.style_version().get(), style_before);
assert_eq!(model.with_point(a, 0, |d| d.value), Some(99.0));
}
#[test]
fn replace_series_data_emits_replaced() {
let (model, a, _b) = sample();
let (log, _handle) = track_changes(&model);
model.replace_series_data(a, vec![ChartDatum::new("X".to_string(), 1.0)]);
assert_eq!(model.point_count(a), 1);
assert_eq!(log.borrow().len(), 1);
assert_eq!(
log.borrow()[0],
ChartChange::SeriesDataReplaced { series: a }
);
}
#[test]
fn clear_emits_reset() {
let (model, _a, _b) = sample();
let (log, _handle) = track_changes(&model);
model.clear();
assert_eq!(model.series_count(), 0);
assert_eq!(log.borrow().len(), 1);
assert_eq!(log.borrow()[0], ChartChange::Reset);
}
#[test]
fn observer_removed_on_handle_drop() {
let model: ChartModel<String> = ChartModel::new();
let count = Rc::new(Cell::new(0));
let c = count.clone();
let handle = model.observe_changes(move |_| c.set(c.get() + 1));
model.add_series("A");
assert_eq!(count.get(), 1);
drop(handle);
model.add_series("B");
assert_eq!(count.get(), 1);
}
#[test]
fn multiple_observers() {
let model: ChartModel<String> = ChartModel::new();
let count = Rc::new(Cell::new(0));
let c1 = count.clone();
let c2 = count.clone();
let _h1 = model.observe_changes(move |_| c1.set(c1.get() + 1));
let _h2 = model.observe_changes(move |_| c2.set(c2.get() + 1));
model.add_series("A");
assert_eq!(count.get(), 2);
}
#[test]
fn clone_shares_data_and_observers() {
let (model, _a, _b) = sample();
let clone = model.clone();
let count = Rc::new(Cell::new(0));
let c = count.clone();
let _handle = model.observe_changes(move |_| c.set(c.get() + 1));
clone.add_series("New");
assert_eq!(model.series_count(), 3);
assert_eq!(count.get(), 1);
}
#[test]
fn with_all_series_returns_views_in_order() {
let (model, a, b) = sample();
let ids: Vec<SeriesId> =
model.with_all_series(|views| views.iter().map(|v| v.id).collect());
assert_eq!(ids, vec![a, b]);
let names: Vec<String> =
model.with_all_series(|views| views.iter().map(|v| v.name.to_string()).collect());
assert_eq!(names, vec!["Revenue".to_string(), "Costs".to_string()]);
}
#[test]
fn with_point_out_of_bounds_returns_none() {
let (model, a, _b) = sample();
assert_eq!(model.with_point(a, 99, |d| d.value), None);
}
fn stale_id(model: &ChartModel<String>) -> SeriesId {
let ghost = model.add_series("Ghost");
model.remove_series(ghost);
ghost
}
#[test]
fn with_series_unknown_id_returns_none() {
let (model, _a, _b) = sample();
let ghost = stale_id(&model);
assert_eq!(model.with_series(ghost, |_, _, _| ()), None);
assert_eq!(model.with_point(ghost, 0, |d| d.value), None);
}
#[test]
#[should_panic(expected = "unknown SeriesId")]
fn remove_series_unknown_id_panics() {
let (model, _a, _b) = sample();
let ghost = stale_id(&model);
model.remove_series(ghost);
}
#[test]
#[should_panic]
fn rename_series_unknown_id_panics() {
let (model, _a, _b) = sample();
let ghost = stale_id(&model);
model.rename_series(ghost, "X");
}
#[test]
#[should_panic]
fn push_point_unknown_series_panics() {
let (model, _a, _b) = sample();
let ghost = stale_id(&model);
model.push_point(ghost, "X".to_string(), 1.0);
}
#[test]
#[should_panic(expected = "unknown SeriesId")]
fn move_series_unknown_id_panics() {
let (model, _a, _b) = sample();
let ghost = stale_id(&model);
model.move_series(ghost, 0);
}
fn test_color() -> teksilo_tokens::Color {
teksilo_tokens::Color::from_hex("#FF0000")
}
}