use std::cell::RefCell;
use std::collections::HashSet;
use std::hash::Hash;
use std::ops::Range;
use std::rc::Rc;
use teksilo_core::ObserverHandle;
use crate::data_change::DataChange;
use crate::dnd_types::{
DragEligibility, DragSource, DropCommit, DropPosition, DropQuery, DropResponse,
};
use crate::list_data_source::ListDataSource;
struct ObserverEntry {
id: u64,
callback: Rc<dyn Fn(&DataChange)>,
}
struct ListModelInner<T> {
items: Vec<T>,
observers: Vec<ObserverEntry>,
next_observer_id: u64,
#[cfg(debug_assertions)]
debug_adapter: Option<Rc<dyn crate::debug_registry::ModelDebug>>,
}
pub struct ListModel<T: 'static> {
inner: Rc<RefCell<ListModelInner<T>>>,
}
impl<T: 'static> ListModel<T> {
pub fn new() -> Self {
Self {
inner: Rc::new(RefCell::new(ListModelInner {
items: Vec::new(),
observers: Vec::new(),
next_observer_id: 1,
#[cfg(debug_assertions)]
debug_adapter: None,
})),
}
}
pub fn from_vec(items: Vec<T>) -> Self {
Self {
inner: Rc::new(RefCell::new(ListModelInner {
items,
observers: Vec::new(),
next_observer_id: 1,
#[cfg(debug_assertions)]
debug_adapter: None,
})),
}
}
pub fn len(&self) -> usize {
self.inner.borrow().items.len()
}
pub fn is_empty(&self) -> bool {
self.inner.borrow().items.is_empty()
}
pub fn with_item<R>(&self, index: usize, f: impl FnOnce(&T) -> R) -> Option<R> {
let guard = self.inner.borrow();
guard.items.get(index).map(f)
}
pub fn push(&self, item: T) {
let index = {
let mut guard = self.inner.borrow_mut();
let index = guard.items.len();
guard.items.push(item);
index
};
self.notify(DataChange::ItemsInserted {
range: index..index + 1,
});
}
pub fn insert(&self, index: usize, item: T) {
{
let mut guard = self.inner.borrow_mut();
guard.items.insert(index, item);
}
self.notify(DataChange::ItemsInserted {
range: index..index + 1,
});
}
pub fn remove(&self, index: usize) -> T {
let item = {
let mut guard = self.inner.borrow_mut();
guard.items.remove(index)
};
self.notify(DataChange::ItemsRemoved {
range: index..index + 1,
});
item
}
pub fn set(&self, index: usize, item: T) {
{
let mut guard = self.inner.borrow_mut();
guard.items[index] = item;
}
self.notify(DataChange::ItemUpdated { index });
}
pub fn move_item(&self, from: usize, to: usize) {
if from == to {
return;
}
{
let mut guard = self.inner.borrow_mut();
let item = guard.items.remove(from);
guard.items.insert(to, item);
}
self.notify(DataChange::ItemsMoved { from, to, count: 1 });
}
pub fn move_items(&self, indices: &[usize], insert_gap: usize) -> bool {
let len = self.len();
let mut idx: Vec<usize> = indices.iter().copied().filter(|&i| i < len).collect();
idx.sort_unstable();
idx.dedup();
if idx.is_empty() {
return false;
}
let contiguous = idx.windows(2).all(|w| w[1] == w[0] + 1);
let from0 = idx[0];
let count = idx.len();
let at;
{
let mut guard = self.inner.borrow_mut();
let mut block: Vec<T> = idx.iter().rev().map(|&i| guard.items.remove(i)).collect();
block.reverse();
let removed_before = idx.iter().filter(|&&i| i < insert_gap).count();
at = insert_gap
.saturating_sub(removed_before)
.min(guard.items.len());
for (off, item) in block.into_iter().enumerate() {
guard.items.insert(at + off, item);
}
}
if contiguous && from0 != at {
self.notify(DataChange::ItemsMoved {
from: from0,
to: at,
count,
});
} else if contiguous {
} else {
self.notify(DataChange::Reset);
}
true
}
pub fn replace_all(&self, items: Vec<T>) {
{
let mut guard = self.inner.borrow_mut();
guard.items = items;
}
self.notify(DataChange::Reset);
}
pub fn clear(&self) {
{
let mut guard = self.inner.borrow_mut();
guard.items.clear();
}
self.notify(DataChange::Reset);
}
pub fn observe_changes(&self, f: impl Fn(&DataChange) + '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: DataChange) {
let callbacks: Vec<Rc<dyn Fn(&DataChange)>> = self
.inner
.borrow()
.observers
.iter()
.map(|e| e.callback.clone())
.collect();
for cb in &callbacks {
cb(&change);
}
}
}
impl<T: PartialEq + 'static> ListModel<T> {
pub fn reconcile_by_key<K: Eq + Hash>(&self, new_items: Vec<T>, key_fn: impl Fn(&T) -> K) {
let mut changes: Vec<DataChange> = Vec::new();
{
let mut guard = self.inner.borrow_mut();
reconcile_vec(&mut guard.items, new_items, &key_fn, &mut changes);
}
for change in changes {
self.notify(change);
}
}
}
fn reconcile_vec<T: PartialEq, K: Eq + Hash>(
items: &mut Vec<T>,
new_items: Vec<T>,
key_fn: &impl Fn(&T) -> K,
out: &mut Vec<DataChange>,
) {
let new_pairs: Vec<(K, T)> = new_items.into_iter().map(|it| (key_fn(&it), it)).collect();
let new_key_set: HashSet<&K> = new_pairs.iter().map(|(k, _)| k).collect();
let old_keys: Vec<K> = items.iter().map(key_fn).collect();
let remove_idxs: Vec<usize> = (0..items.len())
.filter(|&i| !new_key_set.contains(&old_keys[i]))
.collect();
for range in coalesce_ranges(&remove_idxs).into_iter().rev() {
items.drain(range.clone());
out.push(DataChange::ItemsRemoved { range });
}
let remaining_keys: HashSet<K> = items.iter().map(key_fn).collect();
let mut cursor = 0usize;
let mut pending_inserts: Vec<T> = Vec::new();
for (key, new_item) in new_pairs {
if remaining_keys.contains(&key) {
flush_inserts(items, &mut cursor, &mut pending_inserts, out);
let pos = items
.iter()
.skip(cursor)
.position(|it| key_fn(it) == key)
.expect(
"reconcile_by_key: key reported common but not found — \
key_fn must be stable and keys unique",
);
let actual = cursor + pos;
if actual != cursor {
let val = items.remove(actual);
items.insert(cursor, val);
out.push(DataChange::ItemsMoved {
from: actual,
to: cursor,
count: 1,
});
}
if items[cursor] != new_item {
items[cursor] = new_item;
out.push(DataChange::ItemUpdated { index: cursor });
}
cursor += 1;
} else {
pending_inserts.push(new_item);
}
}
flush_inserts(items, &mut cursor, &mut pending_inserts, out);
}
fn flush_inserts<T>(
items: &mut Vec<T>,
cursor: &mut usize,
pending: &mut Vec<T>,
out: &mut Vec<DataChange>,
) {
if pending.is_empty() {
return;
}
let start = *cursor;
let n = pending.len();
items.splice(start..start, pending.drain(..));
out.push(DataChange::ItemsInserted {
range: start..start + n,
});
*cursor += n;
}
fn coalesce_ranges(sorted_idxs: &[usize]) -> Vec<Range<usize>> {
let mut ranges = Vec::new();
let mut iter = sorted_idxs.iter().peekable();
while let Some(&start) = iter.next() {
let mut end = start + 1;
while iter.peek().is_some_and(|&&n| n == end) {
end += 1;
iter.next();
}
ranges.push(start..end);
}
ranges
}
impl<T: std::fmt::Debug + 'static> ListModel<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(ListModelDebug::<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 ListModelDebug<T> {
weak: std::rc::Weak<RefCell<ListModelInner<T>>>,
}
#[cfg(debug_assertions)]
impl<T: std::fmt::Debug + 'static> crate::debug_registry::ModelDebug for ListModelDebug<T> {
fn kind(&self) -> &'static str {
"ListModel"
}
fn len(&self) -> usize {
self.weak
.upgrade()
.map(|inner| inner.borrow().items.len())
.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, item) in guard.items.iter().enumerate() {
let _ = writeln!(out, "[{}] {:?}", i, item);
}
}
}
impl<T: 'static> Default for ListModel<T> {
fn default() -> Self {
Self::new()
}
}
impl<T: 'static> Clone for ListModel<T> {
fn clone(&self) -> Self {
Self {
inner: self.inner.clone(),
}
}
}
impl<T: std::fmt::Debug + 'static> std::fmt::Debug for ListModel<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ListModel")
.field("len", &self.inner.borrow().items.len())
.finish()
}
}
impl<T: 'static> ListDataSource for ListModel<T> {
type Item = T;
type Key = usize;
fn len(&self) -> usize {
ListModel::len(self)
}
fn with_item<R>(&self, index: usize, f: impl FnOnce(&T) -> R) -> Option<R> {
ListModel::with_item(self, index, f)
}
fn key_at(&self, index: usize) -> Option<usize> {
(index < ListModel::len(self)).then_some(index)
}
fn index_of(&self, key: &usize) -> Option<usize> {
(*key < ListModel::len(self)).then_some(*key)
}
fn observe_changes(&self, f: impl Fn(&DataChange) + 'static) -> ObserverHandle {
ListModel::observe_changes(self, f)
}
fn drag(&self, _key: &usize) -> DragEligibility {
DragEligibility::CanDrag
}
fn can_accept(&self, query: &DropQuery<'_, usize>) -> DropResponse {
match &query.source {
DragSource::SameView { .. } => match query.position {
DropPosition::Into => DropResponse::Reject,
DropPosition::Before | DropPosition::After => DropResponse::Accept,
},
DragSource::Foreign { .. } => DropResponse::Reject,
}
}
fn accept_drop(&self, commit: DropCommit<'_, usize>) -> bool {
let DragSource::SameView { key: from } = commit.source else {
return false;
};
let len = ListModel::len(self);
if from >= len {
return false;
}
let target = commit.target;
let shift = if from < target { 1 } else { 0 };
let to = match commit.position {
DropPosition::Before => target.saturating_sub(shift),
DropPosition::After => (target + 1).saturating_sub(shift),
DropPosition::Into => return false,
};
let to = to.min(len.saturating_sub(1));
self.move_item(from, to);
true
}
fn reorder_within(&self, sources: &[usize], target: &usize, position: DropPosition) -> bool {
if sources.len() <= 1 {
let Some(&from) = sources.first() else {
return false;
};
return self.accept_drop(DropCommit {
source: DragSource::SameView { key: from },
target: *target,
position,
});
}
let gap = match position {
DropPosition::Before => *target,
DropPosition::After => *target + 1,
DropPosition::Into => return false,
};
self.move_items(sources, gap)
}
fn on_drag_out(&self, key: &usize) {
if *key < ListModel::len(self) {
let _ = self.remove(*key);
}
}
}
#[cfg(test)]
mod tests {
use std::cell::Cell;
use super::*;
#[test]
fn new_is_empty() {
let model: ListModel<String> = ListModel::new();
assert!(model.is_empty());
assert_eq!(model.len(), 0);
}
#[test]
fn from_vec() {
let model = ListModel::from_vec(vec![10, 20, 30]);
assert_eq!(model.len(), 3);
assert_eq!(model.with_item(1, |v| *v), Some(20));
}
#[test]
fn push_emits_inserted() {
let model = ListModel::new();
let changes: Rc<RefCell<Vec<DataChange>>> = Rc::new(RefCell::new(Vec::new()));
let c = changes.clone();
let _handle = model.observe_changes(move |change| {
c.borrow_mut().push(change.clone());
});
model.push("a");
model.push("b");
let log = changes.borrow();
assert_eq!(log.len(), 2);
assert_eq!(log[0], DataChange::ItemsInserted { range: 0..1 });
assert_eq!(log[1], DataChange::ItemsInserted { range: 1..2 });
}
#[test]
fn insert_emits_inserted() {
let model = ListModel::from_vec(vec![1, 2, 3]);
let changes: Rc<RefCell<Vec<DataChange>>> = Rc::new(RefCell::new(Vec::new()));
let c = changes.clone();
let _handle = model.observe_changes(move |change| {
c.borrow_mut().push(change.clone());
});
model.insert(1, 99);
assert_eq!(model.len(), 4);
assert_eq!(model.with_item(1, |v| *v), Some(99));
let log = changes.borrow();
assert_eq!(log.len(), 1, "insert should emit exactly one change");
assert_eq!(log[0], DataChange::ItemsInserted { range: 1..2 });
}
#[test]
fn remove_emits_removed() {
let model = ListModel::from_vec(vec!["a", "b", "c"]);
let changes: Rc<RefCell<Vec<DataChange>>> = Rc::new(RefCell::new(Vec::new()));
let c = changes.clone();
let _handle = model.observe_changes(move |change| {
c.borrow_mut().push(change.clone());
});
let removed = model.remove(1);
assert_eq!(removed, "b");
assert_eq!(model.len(), 2);
let log = changes.borrow();
assert_eq!(log.len(), 1, "remove should emit exactly one change");
assert_eq!(log[0], DataChange::ItemsRemoved { range: 1..2 });
}
#[test]
fn set_emits_updated() {
let model = ListModel::from_vec(vec![10, 20, 30]);
let changes: Rc<RefCell<Vec<DataChange>>> = Rc::new(RefCell::new(Vec::new()));
let c = changes.clone();
let _handle = model.observe_changes(move |change| {
c.borrow_mut().push(change.clone());
});
model.set(2, 99);
assert_eq!(model.with_item(2, |v| *v), Some(99));
let log = changes.borrow();
assert_eq!(log.len(), 1, "set should emit exactly one change");
assert_eq!(log[0], DataChange::ItemUpdated { index: 2 });
}
#[test]
fn move_item_emits_moved() {
let model = ListModel::from_vec(vec!["a", "b", "c", "d"]);
let changes: Rc<RefCell<Vec<DataChange>>> = Rc::new(RefCell::new(Vec::new()));
let c = changes.clone();
let _handle = model.observe_changes(move |change| {
c.borrow_mut().push(change.clone());
});
model.move_item(0, 2);
assert_eq!(model.with_item(0, |v| *v), Some("b"));
assert_eq!(model.with_item(2, |v| *v), Some("a"));
let log = changes.borrow();
assert_eq!(log.len(), 1, "move_item should emit exactly one change");
assert_eq!(
log[0],
DataChange::ItemsMoved {
from: 0,
to: 2,
count: 1
}
);
}
#[test]
fn move_item_same_index_is_noop() {
let model = ListModel::from_vec(vec![1, 2, 3]);
let count = Rc::new(Cell::new(0));
let c = count.clone();
let _handle = model.observe_changes(move |_| {
c.set(c.get() + 1);
});
model.move_item(1, 1);
assert_eq!(count.get(), 0);
}
#[test]
fn replace_all_emits_reset() {
let model = ListModel::from_vec(vec![1, 2]);
let changes: Rc<RefCell<Vec<DataChange>>> = Rc::new(RefCell::new(Vec::new()));
let c = changes.clone();
let _handle = model.observe_changes(move |change| {
c.borrow_mut().push(change.clone());
});
model.replace_all(vec![10, 20, 30]);
assert_eq!(model.len(), 3);
let log = changes.borrow();
assert_eq!(log[0], DataChange::Reset);
}
#[test]
fn clear_emits_reset() {
let model = ListModel::from_vec(vec![1, 2, 3]);
let changes: Rc<RefCell<Vec<DataChange>>> = Rc::new(RefCell::new(Vec::new()));
let c = changes.clone();
let _handle = model.observe_changes(move |change| {
c.borrow_mut().push(change.clone());
});
model.clear();
assert!(model.is_empty());
let log = changes.borrow();
assert_eq!(log[0], DataChange::Reset);
}
#[test]
fn observer_removed_on_handle_drop() {
let model = ListModel::new();
let count = Rc::new(Cell::new(0));
let c = count.clone();
let handle = model.observe_changes(move |_| {
c.set(c.get() + 1);
});
model.push(1);
assert_eq!(count.get(), 1);
drop(handle);
model.push(2);
assert_eq!(count.get(), 1); }
#[test]
fn multiple_observers() {
let model = ListModel::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.push(42);
assert_eq!(count.get(), 2);
}
#[test]
fn clone_shares_data() {
let model = ListModel::from_vec(vec![1, 2, 3]);
let clone = model.clone();
model.push(4);
assert_eq!(clone.len(), 4);
assert_eq!(clone.with_item(3, |v| *v), Some(4));
}
#[test]
fn clone_shares_observers() {
let model = ListModel::from_vec(vec![1, 2]);
let count = Rc::new(Cell::new(0));
let c = count.clone();
let _handle = model.observe_changes(move |_| c.set(c.get() + 1));
let clone = model.clone();
clone.push(3); assert_eq!(count.get(), 1);
}
#[test]
fn with_item_out_of_bounds_returns_none() {
let model = ListModel::from_vec(vec![1, 2]);
assert_eq!(model.with_item(5, |v| *v), None);
}
#[test]
#[should_panic]
fn remove_out_of_bounds_panics() {
let model = ListModel::from_vec(vec![1]);
model.remove(5);
}
fn order<T: Clone>(model: &ListModel<T>) -> Vec<T> {
(0..model.len())
.map(|i| model.with_item(i, |v| v.clone()).unwrap())
.collect()
}
#[test]
fn list_source_accept_drop_after_reorders() {
let model = ListModel::from_vec(vec!["a", "b", "c", "d"]);
assert!(model.accept_drop(DropCommit {
source: DragSource::SameView { key: 0 },
target: 2,
position: DropPosition::After,
}));
assert_eq!(order(&model), vec!["b", "c", "a", "d"]);
}
#[test]
fn list_source_accept_drop_before_reorders() {
let model = ListModel::from_vec(vec!["a", "b", "c", "d"]);
assert!(model.accept_drop(DropCommit {
source: DragSource::SameView { key: 3 },
target: 1,
position: DropPosition::Before,
}));
assert_eq!(order(&model), vec!["a", "d", "b", "c"]);
}
#[test]
fn list_source_can_accept_rejects_into_accepts_sibling() {
let model = ListModel::from_vec(vec![1, 2, 3]);
assert_eq!(
model.can_accept(&DropQuery {
source: DragSource::SameView { key: 0 },
target: 1,
position: DropPosition::Into,
}),
DropResponse::Reject
);
assert_eq!(
model.can_accept(&DropQuery {
source: DragSource::SameView { key: 0 },
target: 1,
position: DropPosition::After,
}),
DropResponse::Accept
);
}
#[test]
fn list_source_key_is_positional_identity() {
let model = ListModel::from_vec(vec![10, 20, 30]);
assert_eq!(model.key_at(1), Some(1));
assert_eq!(model.index_of(&2), Some(2));
assert_eq!(model.key_at(5), None);
assert_eq!(model.index_of(&9), None);
}
fn snapshot<T: Clone>(model: &ListModel<T>) -> Vec<T> {
(0..model.len())
.filter_map(|i| model.with_item(i, |v| v.clone()))
.collect()
}
#[test]
fn move_items_block_move_contiguous_and_ordered() {
let model = ListModel::from_vec(vec!['A', 'B', 'C', 'D', 'E', 'F']);
model.move_items(&[0, 2, 4], 3);
assert_eq!(snapshot(&model), vec!['B', 'A', 'C', 'E', 'D', 'F']);
}
#[test]
fn move_items_to_end() {
let model = ListModel::from_vec(vec![1, 2, 3, 4]);
model.move_items(&[0, 1], 4);
assert_eq!(snapshot(&model), vec![3, 4, 1, 2]);
}
#[test]
fn move_items_ignores_out_of_range_and_dedups() {
let model = ListModel::from_vec(vec![1, 2, 3]);
model.move_items(&[1, 1, 9], 3);
assert_eq!(snapshot(&model), vec![1, 3, 2]);
}
#[test]
fn move_items_single_matches_move_item() {
let a = ListModel::from_vec(vec![1, 2, 3, 4, 5]);
let b = ListModel::from_vec(vec![1, 2, 3, 4, 5]);
a.move_items(&[1], 4);
b.move_item(1, 3);
assert_eq!(snapshot(&a), snapshot(&b));
}
#[test]
fn reorder_within_multi_row_lands_contiguously() {
let model = ListModel::from_vec(vec![0, 1, 2, 3, 4, 5]);
assert!(model.reorder_within(&[0, 1], &4, DropPosition::After));
assert_eq!(snapshot(&model), vec![2, 3, 4, 0, 1, 5]);
}
#[test]
fn reorder_within_single_row_uses_accept_drop() {
let model = ListModel::from_vec(vec![10, 20, 30]);
assert!(model.reorder_within(&[0], &2, DropPosition::After));
assert_eq!(snapshot(&model), vec![20, 30, 10]);
}
#[test]
fn reorder_within_into_is_rejected_for_flat_list() {
let model = ListModel::from_vec(vec![1, 2, 3]);
assert!(!model.reorder_within(&[0, 1], &2, DropPosition::Into));
assert_eq!(snapshot(&model), vec![1, 2, 3]);
}
#[test]
fn on_drag_out_removes_the_moved_row() {
let model = ListModel::from_vec(vec!['a', 'b', 'c']);
model.on_drag_out(&1);
assert_eq!(snapshot(&model), vec!['a', 'c']);
}
#[test]
fn move_items_contiguous_emits_moved_non_contiguous_resets() {
let model = ListModel::from_vec(vec![0, 1, 2, 3, 4]);
let log: Rc<RefCell<Vec<DataChange>>> = Rc::new(RefCell::new(Vec::new()));
let l = log.clone();
let _h = model.observe_changes(move |c| l.borrow_mut().push(c.clone()));
assert!(model.move_items(&[1, 2], 5));
assert!(matches!(
log.borrow().last(),
Some(DataChange::ItemsMoved { .. })
));
log.borrow_mut().clear();
assert!(model.move_items(&[0, 2], 0));
assert!(matches!(log.borrow().last(), Some(DataChange::Reset)));
}
#[test]
fn reorder_within_and_move_items_report_no_move_for_out_of_range() {
let model = ListModel::from_vec(vec![1, 2, 3]);
assert!(!model.reorder_within(&[99, 100], &0, DropPosition::Before));
assert!(!model.move_items(&[99, 100], 0));
assert_eq!(snapshot(&model), vec![1, 2, 3]);
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Row {
id: u64,
val: &'static str,
}
fn row(id: u64, val: &'static str) -> Row {
Row { id, val }
}
fn key(r: &Row) -> u64 {
r.id
}
fn ids(model: &ListModel<Row>) -> Vec<u64> {
(0..model.len())
.map(|i| model.with_item(i, |r| r.id).unwrap())
.collect()
}
fn vals(model: &ListModel<Row>) -> Vec<&'static str> {
(0..model.len())
.map(|i| model.with_item(i, |r| r.val).unwrap())
.collect()
}
fn record_changes(model: &ListModel<Row>) -> (Rc<RefCell<Vec<DataChange>>>, ObserverHandle) {
let log: Rc<RefCell<Vec<DataChange>>> = 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 reconcile_pure_insert_emits_one_coalesced_range() {
let model = ListModel::from_vec(vec![row(1, "a"), row(2, "b")]);
let (log, _h) = record_changes(&model);
model.reconcile_by_key(
vec![row(1, "a"), row(2, "b"), row(3, "c"), row(4, "d")],
key,
);
assert_eq!(ids(&model), vec![1, 2, 3, 4]);
assert_eq!(vals(&model), vec!["a", "b", "c", "d"]);
let entries = log.borrow();
assert_eq!(entries.len(), 1, "contiguous inserts coalesce: {entries:?}");
assert_eq!(entries[0], DataChange::ItemsInserted { range: 2..4 });
}
#[test]
fn reconcile_insert_at_front_and_middle() {
let model = ListModel::from_vec(vec![row(1, "a"), row(2, "b")]);
let (log, _h) = record_changes(&model);
model.reconcile_by_key(
vec![row(0, "z"), row(1, "a"), row(5, "m"), row(2, "b")],
key,
);
assert_eq!(ids(&model), vec![0, 1, 5, 2]);
let entries = log.borrow();
assert_eq!(entries.len(), 2, "{entries:?}");
assert_eq!(entries[0], DataChange::ItemsInserted { range: 0..1 });
assert_eq!(entries[1], DataChange::ItemsInserted { range: 2..3 });
}
#[test]
fn reconcile_pure_remove_emits_one_coalesced_range() {
let model = ListModel::from_vec(vec![row(1, "a"), row(2, "b"), row(3, "c"), row(4, "d")]);
let (log, _h) = record_changes(&model);
model.reconcile_by_key(vec![row(1, "a"), row(4, "d")], key);
assert_eq!(ids(&model), vec![1, 4]);
let entries = log.borrow();
assert_eq!(entries.len(), 1, "contiguous removes coalesce: {entries:?}");
assert_eq!(entries[0], DataChange::ItemsRemoved { range: 1..3 });
}
#[test]
fn reconcile_remove_scattered_emits_separate_ranges() {
let model = ListModel::from_vec(vec![row(1, "a"), row(2, "b"), row(3, "c"), row(4, "d")]);
let (log, _h) = record_changes(&model);
model.reconcile_by_key(vec![row(1, "a"), row(3, "c")], key);
assert_eq!(ids(&model), vec![1, 3]);
let entries = log.borrow();
assert_eq!(entries.len(), 2, "{entries:?}");
assert_eq!(entries[0], DataChange::ItemsRemoved { range: 3..4 });
assert_eq!(entries[1], DataChange::ItemsRemoved { range: 1..2 });
}
#[test]
fn reconcile_reorder_emits_only_moves() {
let model = ListModel::from_vec(vec![row(1, "a"), row(2, "b"), row(3, "c")]);
let (log, _h) = record_changes(&model);
model.reconcile_by_key(vec![row(3, "c"), row(2, "b"), row(1, "a")], key);
assert_eq!(ids(&model), vec![3, 2, 1]);
let entries = log.borrow();
assert!(
entries
.iter()
.all(|c| matches!(c, DataChange::ItemsMoved { .. })),
"a pure reorder must only emit moves: {entries:?}"
);
assert!(!entries.is_empty());
}
#[test]
fn reconcile_no_reorder_when_relative_order_already_matches() {
let model = ListModel::from_vec(vec![row(1, "a"), row(2, "b"), row(3, "c"), row(4, "d")]);
let (log, _h) = record_changes(&model);
model.reconcile_by_key(vec![row(1, "a"), row(3, "c"), row(4, "d")], key);
assert_eq!(ids(&model), vec![1, 3, 4]);
let entries = log.borrow();
assert!(
entries
.iter()
.all(|c| !matches!(c, DataChange::ItemsMoved { .. })),
"untouched relative order must not emit moves: {entries:?}"
);
}
#[test]
fn reconcile_in_place_update_emits_item_updated() {
let model = ListModel::from_vec(vec![row(1, "a"), row(2, "b"), row(3, "c")]);
let (log, _h) = record_changes(&model);
model.reconcile_by_key(vec![row(1, "a"), row(2, "CHANGED"), row(3, "c")], key);
assert_eq!(vals(&model), vec!["a", "CHANGED", "c"]);
let entries = log.borrow();
assert_eq!(entries.len(), 1, "{entries:?}");
assert_eq!(entries[0], DataChange::ItemUpdated { index: 1 });
}
#[test]
fn reconcile_combined_insert_remove_reorder_update() {
let model = ListModel::from_vec(vec![row(1, "a"), row(2, "b"), row(3, "c"), row(4, "d")]);
let (log, _h) = record_changes(&model);
model.reconcile_by_key(
vec![row(4, "d"), row(2, "B!"), row(5, "e"), row(1, "a")],
key,
);
assert_eq!(ids(&model), vec![4, 2, 5, 1]);
assert_eq!(vals(&model), vec!["d", "B!", "e", "a"]);
let entries = log.borrow();
assert!(!entries.is_empty());
assert!(
entries
.iter()
.any(|c| matches!(c, DataChange::ItemsMoved { .. })),
"{entries:?}"
);
assert!(
entries
.iter()
.any(|c| matches!(c, DataChange::ItemsInserted { .. })),
"{entries:?}"
);
assert!(
entries
.iter()
.any(|c| matches!(c, DataChange::ItemsRemoved { .. })),
"{entries:?}"
);
assert!(
entries
.iter()
.any(|c| matches!(c, DataChange::ItemUpdated { .. })),
"{entries:?}"
);
assert!(
!entries.iter().any(|c| matches!(c, DataChange::Reset)),
"{entries:?}"
);
}
#[test]
fn reconcile_empty_to_full() {
let model: ListModel<Row> = ListModel::new();
let (log, _h) = record_changes(&model);
model.reconcile_by_key(vec![row(1, "a"), row(2, "b"), row(3, "c")], key);
assert_eq!(ids(&model), vec![1, 2, 3]);
let entries = log.borrow();
assert_eq!(entries.len(), 1, "{entries:?}");
assert_eq!(entries[0], DataChange::ItemsInserted { range: 0..3 });
}
#[test]
fn reconcile_full_to_empty() {
let model = ListModel::from_vec(vec![row(1, "a"), row(2, "b"), row(3, "c")]);
let (log, _h) = record_changes(&model);
model.reconcile_by_key(vec![], key);
assert!(model.is_empty());
let entries = log.borrow();
assert_eq!(entries.len(), 1, "{entries:?}");
assert_eq!(entries[0], DataChange::ItemsRemoved { range: 0..3 });
}
#[test]
fn reconcile_identical_input_emits_nothing() {
let model = ListModel::from_vec(vec![row(1, "a"), row(2, "b"), row(3, "c")]);
let (log, _h) = record_changes(&model);
model.reconcile_by_key(vec![row(1, "a"), row(2, "b"), row(3, "c")], key);
assert_eq!(ids(&model), vec![1, 2, 3]);
assert!(
log.borrow().is_empty(),
"identical reconcile must not notify: {:?}",
log.borrow()
);
}
#[test]
fn reconcile_never_emits_reset() {
let scenarios: Vec<(Vec<Row>, Vec<Row>)> = vec![
(
vec![row(1, "a"), row(2, "b")],
vec![row(1, "a"), row(2, "b"), row(3, "c")],
),
(
vec![row(1, "a"), row(2, "b"), row(3, "c")],
vec![row(1, "a")],
),
(
vec![row(1, "a"), row(2, "b"), row(3, "c")],
vec![row(3, "c"), row(1, "a"), row(2, "b")],
),
(vec![], vec![row(1, "a"), row(2, "b")]),
(vec![row(1, "a"), row(2, "b")], vec![]),
(
vec![row(1, "a"), row(2, "b"), row(3, "c"), row(4, "d")],
vec![row(9, "x"), row(8, "y"), row(7, "z")],
),
];
for (before, after) in scenarios {
let model = ListModel::from_vec(before.clone());
let (log, _h) = record_changes(&model);
model.reconcile_by_key(after.clone(), key);
assert!(
!log.borrow().iter().any(|c| matches!(c, DataChange::Reset)),
"reconcile must never emit Reset — before {before:?}, after {after:?}, got {:?}",
log.borrow()
);
}
}
#[test]
#[should_panic(expected = "reconcile_by_key")]
fn reconcile_duplicate_key_in_new_items_panics() {
let model = ListModel::from_vec(vec![row(1, "a"), row(2, "b")]);
model.reconcile_by_key(vec![row(1, "a"), row(1, "a-dup"), row(2, "b")], key);
}
}