Skip to main content

teksilo_data/
keyed_tree_checked_model.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `KeyedTreeCheckedModel<K>` — per-node checkbox state for a tree **keyed by a
5//! stable domain id**, with optional descendant→ancestor tristate aggregation.
6//!
7//! The keyed counterpart of [`TreeCheckedModel`](crate::TreeCheckedModel) — the
8//! checkbox twin of [`KeyedSelectionModel`](crate::KeyedSelectionModel). Where
9//! `TreeCheckedModel` is bound to a `TreeModel<T>` and keyed by `NodeId`, this
10//! model is keyed by *your* domain key `K` (an entity id, a tagged enum) and
11//! takes the tree *shape* as two injected closures (`children` + `parent`), so
12//! it composes over a [`TreeDataSlice`](crate::TreeDataSlice) or any
13//! [`TreeDataSource`] — the "select scenes to export"
14//! tristate over an external outline, without mirroring into a `TreeModel`.
15//!
16//! Because identity is the domain key (stable across a full re-source), a
17//! node's check state survives the tree reloading — a checked scene stays
18//! checked after the backend refreshes. Use [`prune_missing`](KeyedTreeCheckedModel::prune_missing)
19//! after a reload to drop the state of nodes that no longer exist.
20//!
21//! Semantics, cascade behaviour, the `Signal<CheckState>` / `Signal<bool>`
22//! bridge, and the re-entry guard are identical to `TreeCheckedModel` — see its
23//! [module docs](crate::tree_checked_model) for the detail. This model is a
24//! share-by-clone handle (`Rc<RefCell<…>>` internally).
25//!
26//! ## Example
27//!
28//! ```
29//! use teksilo_data::{KeyedTreeCheckedModel, CheckState, TreeDataSlice, TreeRow};
30//!
31//! // An outline: Binder(1) → { Chapter(2) → Scene(3), Scene(4) }
32//! let slice: TreeDataSlice<u64, &str> = TreeDataSlice::from_rows(vec![
33//!     TreeRow::new(1, "Binder", 0),
34//!     TreeRow::new(2, "Chapter", 1),
35//!     TreeRow::new(3, "Scene A", 2),
36//!     TreeRow::new(4, "Scene B", 1),
37//! ]);
38//!
39//! let checked = KeyedTreeCheckedModel::from_source(slice.clone());
40//! let _ = (checked.signal_for(1), checked.signal_for(2), checked.signal_for(3), checked.signal_for(4));
41//!
42//! checked.check(3);                                         // one scene under the chapter
43//! assert_eq!(checked.check_state(&2), CheckState::Checked); // chapter has only Scene A → Checked
44//! assert_eq!(checked.check_state(&1), CheckState::Indeterminate); // Binder: 2 of {chapter, Scene B}
45//! ```
46
47use std::cell::{Cell, RefCell};
48use std::collections::{HashMap, HashSet};
49use std::rc::Rc;
50
51use teksilo_core::signal::{ObserverHandle, Signal};
52
53use crate::check_state::CheckState;
54use crate::dnd_types::ItemKey;
55use crate::tree_checked_model::AggregateMode;
56use crate::tree_data_source::TreeDataSource;
57
58/// A tree-shape query: `key -> children keys` / `key -> parent key`.
59type ChildrenFn<K> = Rc<dyn Fn(&K) -> Vec<K>>;
60type ParentFn<K> = Rc<dyn Fn(&K) -> Option<K>>;
61
62struct Inner<K: ItemKey> {
63    state: HashMap<K, Signal<CheckState>>,
64    observers: HashMap<K, ObserverHandle>,
65    bool_signals: HashMap<K, Signal<bool>>,
66    bridge_guards: HashMap<K, Rc<Cell<bool>>>,
67    bridge_observers: HashMap<K, (ObserverHandle, ObserverHandle)>,
68    /// Keys whose signal is *currently* being written by [`write_state`] as
69    /// part of an in-progress cascade — scoped per-key, not a single global
70    /// flag, so an unrelated key's write (e.g. triggered by an app observer
71    /// reacting mid-cascade) still runs its own cascade + ancestor recompute
72    /// instead of silently no-opping. See [`crate::tree_checked_model`] for
73    /// the full rationale (identical design, keyed by `K` here).
74    suppressed: HashSet<K>,
75}
76
77/// Per-node checkbox state for a domain-keyed tree, with optional
78/// descendant→ancestor tristate aggregation. See the [module docs](self).
79pub struct KeyedTreeCheckedModel<K: ItemKey> {
80    children: ChildrenFn<K>,
81    parent: ParentFn<K>,
82    inner: Rc<RefCell<Inner<K>>>,
83    mode: Rc<Cell<AggregateMode>>,
84}
85
86impl<K: ItemKey> KeyedTreeCheckedModel<K> {
87    /// Create a model over a tree whose shape is given by two closures:
88    /// `children(key) -> Vec<K>` and `parent(key) -> Option<K>`. Uses the
89    /// default [`AggregateMode::DescendantsDriveAncestors`].
90    pub fn new(
91        children: impl Fn(&K) -> Vec<K> + 'static,
92        parent: impl Fn(&K) -> Option<K> + 'static,
93    ) -> Self {
94        Self {
95            children: Rc::new(children),
96            parent: Rc::new(parent),
97            inner: Rc::new(RefCell::new(Inner {
98                state: HashMap::new(),
99                observers: HashMap::new(),
100                bool_signals: HashMap::new(),
101                bridge_guards: HashMap::new(),
102                bridge_observers: HashMap::new(),
103                suppressed: HashSet::new(),
104            })),
105            mode: Rc::new(Cell::new(AggregateMode::default())),
106        }
107    }
108
109    /// Create a model whose tree shape is read from a cloneable
110    /// [`TreeDataSource`] (e.g. a [`TreeDataSlice`](crate::TreeDataSlice)). The
111    /// source is cloned into the shape closures, so the model reflects the live
112    /// tree — call [`prune_missing`](Self::prune_missing) after the source
113    /// reloads to drop state for removed nodes.
114    pub fn from_source<S>(source: S) -> Self
115    where
116        S: TreeDataSource<Key = K> + Clone + 'static,
117    {
118        let for_children = source.clone();
119        let for_parent = source;
120        Self::new(
121            move |k| for_children.child_keys(k),
122            move |k| for_parent.parent(k),
123        )
124    }
125
126    /// Set the [`AggregateMode`] at construction.
127    pub fn with_mode(self, mode: AggregateMode) -> Self {
128        self.mode.set(mode);
129        self
130    }
131
132    /// The current [`AggregateMode`].
133    pub fn aggregate_mode(&self) -> AggregateMode {
134        self.mode.get()
135    }
136
137    /// Change the cascade behaviour; takes effect on the next write.
138    pub fn set_aggregate_mode(&self, mode: AggregateMode) {
139        self.mode.set(mode);
140    }
141
142    /// Writable `Signal<CheckState>` for `key` (cached). External writes trigger
143    /// the configured aggregation pass. The cascade observer is wired
144    /// **idempotently** — including for a signal first materialised by a cascade
145    /// (`write_state`) before its own `signal_for` was ever called — so binding a
146    /// lazily-realised (e.g. virtualized) row still cascades on write.
147    pub fn signal_for(&self, key: K) -> Signal<CheckState> {
148        // Get or create the signal (a cascade may have created it observer-less).
149        let sig = self
150            .inner
151            .borrow_mut()
152            .state
153            .entry(key.clone())
154            .or_insert_with(|| Signal::new(CheckState::Unchecked))
155            .clone();
156        // Wire the cascade observer once, if this key doesn't have one yet.
157        if !self.inner.borrow().observers.contains_key(&key) {
158            let handle = self.make_cascade_observer(&sig, key.clone());
159            self.inner.borrow_mut().observers.insert(key, handle);
160        }
161        sig
162    }
163
164    /// Build the cascade observer for `node`'s signal: on any write, cascade
165    /// Checked/Unchecked to descendants and recompute ancestors, guarded against
166    /// re-entry.
167    fn make_cascade_observer(&self, sig: &Signal<CheckState>, node: K) -> ObserverHandle {
168        let inner_w = Rc::downgrade(&self.inner);
169        let mode_w = Rc::downgrade(&self.mode);
170        let children = self.children.clone();
171        let parent = self.parent.clone();
172        sig.observe(move |new_state| {
173            let Some(inner_rc) = inner_w.upgrade() else {
174                return;
175            };
176            let Some(mode_rc) = mode_w.upgrade() else {
177                return;
178            };
179            // Re-entry guard: a no-op only if THIS key's write is itself a
180            // cascade-internal echo (see `Inner::suppressed`). An unrelated
181            // key reached via `write_state`'s notification (e.g. an app
182            // observer that checks a different key) is not suppressed and
183            // runs its own cascade below.
184            if inner_rc.borrow().suppressed.contains(&node) {
185                return;
186            }
187            if mode_rc.get() != AggregateMode::DescendantsDriveAncestors {
188                return;
189            }
190            // Cascade Checked / Unchecked down; Indeterminate is parent-only.
191            if *new_state != CheckState::Indeterminate {
192                cascade_descendants(&children, &inner_rc, &node, *new_state);
193            }
194            // Recompute ancestors.
195            let mut cur = parent(&node);
196            while let Some(p) = cur {
197                recompute_from_children(&children, &inner_rc, &p);
198                cur = parent(&p);
199            }
200        })
201    }
202
203    /// Two-state `Signal<bool>` projection of [`signal_for`](Self::signal_for)
204    /// (cached, writable). `Checked → true`; anything else → `false`. See
205    /// [`crate::TreeCheckedModel::bool_signal_for`].
206    pub fn bool_signal_for(&self, key: K) -> Signal<bool> {
207        if let Some(b) = self.inner.borrow().bool_signals.get(&key) {
208            return b.clone();
209        }
210        let tristate = self.signal_for(key.clone());
211        let bool_sig = Signal::new(tristate.get() == CheckState::Checked);
212        let guard = Rc::new(Cell::new(false));
213
214        // tristate → bool
215        let bool_for_tri = bool_sig.clone();
216        let guard_for_tri = guard.clone();
217        let tri_to_bool = tristate.observe(move |state| {
218            if guard_for_tri.get() {
219                return;
220            }
221            let want = matches!(state, CheckState::Checked);
222            if bool_for_tri.get() != want {
223                guard_for_tri.set(true);
224                bool_for_tri.set(want);
225                guard_for_tri.set(false);
226            }
227        });
228
229        // bool → tristate (the tristate cascade observer takes it from there)
230        let tri_for_bool = tristate.clone();
231        let guard_for_bool = guard.clone();
232        let bool_to_tri = bool_sig.observe(move |checked| {
233            if guard_for_bool.get() {
234                return;
235            }
236            let want = if *checked {
237                CheckState::Checked
238            } else {
239                CheckState::Unchecked
240            };
241            if tri_for_bool.get() != want {
242                guard_for_bool.set(true);
243                tri_for_bool.set(want);
244                guard_for_bool.set(false);
245            }
246        });
247
248        let mut inner = self.inner.borrow_mut();
249        inner.bool_signals.insert(key.clone(), bool_sig.clone());
250        inner.bridge_guards.insert(key.clone(), guard);
251        inner
252            .bridge_observers
253            .insert(key, (tri_to_bool, bool_to_tri));
254        bool_sig
255    }
256
257    /// The current [`CheckState`] for `key` (`Unchecked` if never touched).
258    pub fn check_state(&self, key: &K) -> CheckState {
259        self.inner
260            .borrow()
261            .state
262            .get(key)
263            .map(|s| s.get())
264            .unwrap_or(CheckState::Unchecked)
265    }
266
267    /// Set `key` to [`CheckState::Checked`] (triggers cascade + ancestor recompute).
268    pub fn check(&self, key: K) {
269        self.signal_for(key).set(CheckState::Checked);
270    }
271
272    /// Set `key` to [`CheckState::Unchecked`] (triggers cascade + ancestor recompute).
273    pub fn uncheck(&self, key: K) {
274        self.signal_for(key).set(CheckState::Unchecked);
275    }
276
277    /// Toggle `key`: a leaf under `DescendantsDriveAncestors` cycles two-state;
278    /// a branch or `AggregateMode::None` cycles the full tristate sequence.
279    pub fn toggle(&self, key: K) {
280        let current = self.check_state(&key);
281        let next = match (self.mode.get(), self.is_leaf(&key), current) {
282            (AggregateMode::DescendantsDriveAncestors, true, CheckState::Unchecked) => {
283                CheckState::Checked
284            }
285            (AggregateMode::DescendantsDriveAncestors, true, _) => CheckState::Unchecked,
286            (_, _, _) => current.next_tristate(),
287        };
288        self.signal_for(key).set(next);
289    }
290
291    /// All keys whose current state is exactly [`CheckState::Checked`]. May
292    /// include stale keys after a tree mutation — call [`prune_missing`](Self::prune_missing)
293    /// or filter against the current tree yourself.
294    pub fn checked_keys(&self) -> Vec<K> {
295        self.inner
296            .borrow()
297            .state
298            .iter()
299            .filter(|(_, sig)| sig.get() == CheckState::Checked)
300            .map(|(k, _)| k.clone())
301            .collect()
302    }
303
304    /// Reset all known nodes to [`CheckState::Unchecked`].
305    ///
306    /// Writes every tracked key directly via the internal `write_state`
307    /// helper (per-key cascade-suppressed) instead of `signal_for(..).set(..)`'s normal
308    /// path, which would, for every currently-checked key, cascade the
309    /// write down its whole descendant subtree and recompute every
310    /// ancestor up to the root — redundant here, since every tracked key
311    /// ends up `Unchecked` and "all children unchecked" is already the
312    /// correct parent aggregate. See [`TreeCheckedModel::clear`](crate::TreeCheckedModel::clear)
313    /// for the non-keyed twin of this same optimization.
314    pub fn clear(&self) {
315        let keys: Vec<K> = self.inner.borrow().state.keys().cloned().collect();
316        for k in keys {
317            write_state(&self.inner, &k, CheckState::Unchecked);
318        }
319    }
320
321    /// Drop cached check state (and its signals/observers) for every key for
322    /// which `exists(&key)` returns `false`, then [`reaggregate`](Self::reaggregate)
323    /// surviving parents against the current tree. Call after a reload so a
324    /// deleted node's state doesn't linger in `checked_keys()` **and** the
325    /// ancestors it used to affect show the correct tristate. Mirrors
326    /// [`crate::KeyedSelectionModel::prune_missing`].
327    pub fn prune_missing(&self, exists: impl Fn(&K) -> bool) {
328        // Snapshot keys first, then run the caller's `exists` with no borrow held
329        // (it may query the model / source without a double-borrow panic).
330        let all: Vec<K> = self.inner.borrow().state.keys().cloned().collect();
331        let stale: Vec<K> = all.into_iter().filter(|k| !exists(k)).collect();
332        if !stale.is_empty() {
333            let mut inner = self.inner.borrow_mut();
334            for k in &stale {
335                inner.state.remove(k);
336                inner.observers.remove(k);
337                inner.bool_signals.remove(k);
338                inner.bridge_guards.remove(k);
339                inner.bridge_observers.remove(k);
340            }
341        }
342        // Always reaggregate, even when `stale` came back empty: the removed
343        // node(s) may never have been explicitly checked/toggled (and so never
344        // had a `state` entry at all — an untouched leaf is the common case),
345        // which makes `stale` misleadingly empty even though the tree genuinely
346        // lost a subtree. Skipping `reaggregate()` in that case would leave a
347        // SURVIVING ancestor's cached aggregate stale forever (it was computed
348        // against the OLD child set, which included the now-gone node).
349        // `reaggregate()` is a no-op when nothing actually changed, so this
350        // costs nothing in the common case where `exists` really did accept
351        // every tracked key.
352        self.reaggregate();
353    }
354
355    /// Recompute every surviving parent's aggregate from the **current** tree
356    /// shape + leaf states, deepest first. Call after the backing tree's
357    /// structure changed (a reload that added/removed/moved nodes) so parent
358    /// tristates reflect the new children; [`prune_missing`](Self::prune_missing)
359    /// does this for you. A no-op under [`AggregateMode::None`].
360    pub fn reaggregate(&self) {
361        if self.mode.get() != AggregateMode::DescendantsDriveAncestors {
362            return;
363        }
364        let tracked: Vec<K> = self.inner.borrow().state.keys().cloned().collect();
365        // Expand the recompute set to every ancestor reachable by walking up
366        // from a tracked key, even one that was never itself explicitly
367        // checked/toggled (and so has no `state` entry of its own). Without
368        // this, an untouched node sitting between a tracked descendant and a
369        // tracked ancestor would read back as the `Unchecked` default —
370        // instead of being recomputed from ITS OWN (possibly freshly
371        // reshaped) children — whenever a full re-source makes it into a
372        // meaningful branch it never was before (see the module's "reload
373        // with a different shape" scenario).
374        let mut keys: HashSet<K> = tracked.iter().cloned().collect();
375        for k in &tracked {
376            let mut cur = (self.parent)(k);
377            let mut guard = 0usize;
378            while let Some(p) = cur {
379                if !keys.insert(p.clone()) {
380                    // Already queued — whoever queued it also walked (or will
381                    // walk) the rest of its ancestor chain.
382                    break;
383                }
384                guard += 1;
385                if guard > 1_000_000 {
386                    break; // bound against a malformed (cyclic) parent closure
387                }
388                cur = (self.parent)(&p);
389            }
390        }
391        let mut keys: Vec<K> = keys.into_iter().collect();
392        // Deepest first, so a parent recomputes after its children are finalised.
393        keys.sort_by_key(|k| std::cmp::Reverse(self.depth_of(k)));
394        // No outer guard needed: `recompute_from_children` -> `write_state`
395        // already scopes suppression to each individual key it writes.
396        for k in keys {
397            recompute_from_children(&self.children, &self.inner, &k);
398        }
399    }
400
401    /// Depth of `key` in the current tree (root = 0), via the parent closure.
402    fn depth_of(&self, key: &K) -> usize {
403        let mut depth = 0usize;
404        let mut cur = (self.parent)(key);
405        // Bound the walk against a malformed (cyclic) parent closure.
406        while let Some(p) = cur {
407            depth += 1;
408            if depth > 1_000_000 {
409                break;
410            }
411            cur = (self.parent)(&p);
412        }
413        depth
414    }
415
416    fn is_leaf(&self, key: &K) -> bool {
417        (self.children)(key).is_empty()
418    }
419}
420
421/// RAII guard: marks a single key as cascade-suppressed on creation,
422/// unmarks it on drop — so a panic in a shape closure mid-write can't leave
423/// that key permanently unable to cascade. Scoped to one `K` (see
424/// `Inner::suppressed`), not the whole model.
425struct SuppressGuard<K: ItemKey> {
426    inner: Rc<RefCell<Inner<K>>>,
427    key: K,
428}
429
430impl<K: ItemKey> SuppressGuard<K> {
431    fn new(inner: &Rc<RefCell<Inner<K>>>, key: K) -> Self {
432        inner.borrow_mut().suppressed.insert(key.clone());
433        Self {
434            inner: inner.clone(),
435            key,
436        }
437    }
438}
439
440impl<K: ItemKey> Drop for SuppressGuard<K> {
441    fn drop(&mut self) {
442        // A borrow may still be held during a panic unwind; best-effort clear.
443        if let Ok(mut inner) = self.inner.try_borrow_mut() {
444            inner.suppressed.remove(&self.key);
445        }
446    }
447}
448
449// Free functions — the cascade observer holds only closures + a `Weak<Inner>`.
450
451fn cascade_descendants<K: ItemKey>(
452    children: &ChildrenFn<K>,
453    inner: &Rc<RefCell<Inner<K>>>,
454    root: &K,
455    target: CheckState,
456) {
457    for child in children(root) {
458        write_state(inner, &child, target);
459        cascade_descendants(children, inner, &child, target);
460    }
461}
462
463fn recompute_from_children<K: ItemKey>(
464    children: &ChildrenFn<K>,
465    inner: &Rc<RefCell<Inner<K>>>,
466    node: &K,
467) {
468    let kids = children(node);
469    if kids.is_empty() {
470        return;
471    }
472    let mut all_checked = true;
473    let mut all_unchecked = true;
474    for child in &kids {
475        match read_state(inner, child) {
476            CheckState::Checked => all_unchecked = false,
477            CheckState::Unchecked => all_checked = false,
478            CheckState::Indeterminate => {
479                all_checked = false;
480                all_unchecked = false;
481            }
482        }
483    }
484    let new_state = if all_checked {
485        CheckState::Checked
486    } else if all_unchecked {
487        CheckState::Unchecked
488    } else {
489        CheckState::Indeterminate
490    };
491    write_state(inner, node, new_state);
492}
493
494fn read_state<K: ItemKey>(inner: &Rc<RefCell<Inner<K>>>, node: &K) -> CheckState {
495    inner
496        .borrow()
497        .state
498        .get(node)
499        .map(|s| s.get())
500        .unwrap_or(CheckState::Unchecked)
501}
502
503fn write_state<K: ItemKey>(inner: &Rc<RefCell<Inner<K>>>, node: &K, state: CheckState) {
504    let sig = {
505        let mut map = inner.borrow_mut();
506        map.state
507            .entry(node.clone())
508            .or_insert_with(|| Signal::new(CheckState::Unchecked))
509            .clone()
510    };
511    if sig.get() != state {
512        // Suppress only `node`'s own cascade observer for the duration of
513        // this write — it's about to see the value it's already applying.
514        let _guard = SuppressGuard::new(inner, node.clone());
515        sig.set(state);
516    }
517}
518
519impl<K: ItemKey> Clone for KeyedTreeCheckedModel<K> {
520    fn clone(&self) -> Self {
521        Self {
522            children: self.children.clone(),
523            parent: self.parent.clone(),
524            inner: self.inner.clone(),
525            mode: self.mode.clone(),
526        }
527    }
528}
529
530impl<K: ItemKey> std::fmt::Debug for KeyedTreeCheckedModel<K> {
531    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
532        f.debug_struct("KeyedTreeCheckedModel")
533            .field("mode", &self.mode.get())
534            .field("tracked_nodes", &self.inner.borrow().state.len())
535            .finish()
536    }
537}
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542    use crate::{TreeDataSlice, TreeRow};
543
544    // Binder(1) → { Chapter(2) → { Scene(3), Scene(5) }, Scene(4) }
545    fn slice() -> TreeDataSlice<u64, &'static str> {
546        TreeDataSlice::from_rows(vec![
547            TreeRow::new(1, "Binder", 0),
548            TreeRow::new(2, "Chapter", 1),
549            TreeRow::new(3, "Scene A", 2),
550            TreeRow::new(5, "Scene C", 2),
551            TreeRow::new(4, "Scene B", 1),
552        ])
553    }
554
555    fn model() -> KeyedTreeCheckedModel<u64> {
556        let m = KeyedTreeCheckedModel::from_source(slice());
557        // Pre-register the observer chain before mutating.
558        let _ = (
559            m.signal_for(1),
560            m.signal_for(2),
561            m.signal_for(3),
562            m.signal_for(4),
563            m.signal_for(5),
564        );
565        m
566    }
567
568    #[test]
569    fn descendants_drive_ancestors() {
570        let m = model();
571        m.check(3);
572        assert_eq!(m.check_state(&3), CheckState::Checked);
573        // Chapter(2) has children {3, 5}; only 3 checked → Indeterminate.
574        assert_eq!(m.check_state(&2), CheckState::Indeterminate);
575        // Binder(1) has children {2, 4}; 2 indeterminate → Indeterminate.
576        assert_eq!(m.check_state(&1), CheckState::Indeterminate);
577
578        m.check(5);
579        // Chapter now fully checked.
580        assert_eq!(m.check_state(&2), CheckState::Checked);
581        assert_eq!(m.check_state(&1), CheckState::Indeterminate); // Scene B still unchecked
582        m.check(4);
583        assert_eq!(m.check_state(&1), CheckState::Checked);
584    }
585
586    #[test]
587    fn parent_cascades_to_descendants() {
588        let m = model();
589        m.check(2); // Chapter → both scenes
590        assert_eq!(m.check_state(&3), CheckState::Checked);
591        assert_eq!(m.check_state(&5), CheckState::Checked);
592        m.uncheck(2);
593        assert_eq!(m.check_state(&3), CheckState::Unchecked);
594        assert_eq!(m.check_state(&5), CheckState::Unchecked);
595    }
596
597    #[test]
598    fn check_root_cascades_whole_tree() {
599        let m = model();
600        m.check(1);
601        for k in [2u64, 3, 4, 5] {
602            assert_eq!(m.check_state(&k), CheckState::Checked);
603        }
604    }
605
606    #[test]
607    fn aggregate_mode_none_is_independent() {
608        let m = KeyedTreeCheckedModel::from_source(slice()).with_mode(AggregateMode::None);
609        let _ = (m.signal_for(1), m.signal_for(3));
610        m.check(3);
611        assert_eq!(m.check_state(&3), CheckState::Checked);
612        assert_eq!(m.check_state(&2), CheckState::Unchecked);
613        assert_eq!(m.check_state(&1), CheckState::Unchecked);
614    }
615
616    #[test]
617    fn external_signal_write_cascades() {
618        let m = model();
619        m.signal_for(2).set(CheckState::Checked); // as if a Checkbox wrote it
620        assert_eq!(m.check_state(&3), CheckState::Checked);
621        assert_eq!(m.check_state(&5), CheckState::Checked);
622    }
623
624    #[test]
625    fn bool_signal_bridge() {
626        let m = model();
627        let b = m.bool_signal_for(3);
628        assert!(!b.get());
629        b.set(true);
630        assert_eq!(m.check_state(&3), CheckState::Checked);
631        m.uncheck(3);
632        assert!(!b.get());
633    }
634
635    #[test]
636    fn bool_signal_indeterminate_reads_false() {
637        let m = model();
638        let binder_bool = m.bool_signal_for(1);
639        m.check(3); // Binder → Indeterminate
640        assert_eq!(m.check_state(&1), CheckState::Indeterminate);
641        assert!(!binder_bool.get());
642    }
643
644    #[test]
645    fn checked_keys_excludes_indeterminate() {
646        let m = model();
647        m.check(3);
648        let keys = m.checked_keys();
649        assert!(keys.contains(&3));
650        assert!(!keys.contains(&2)); // Indeterminate
651        assert!(!keys.contains(&1));
652    }
653
654    #[test]
655    fn signal_is_stable_across_calls() {
656        let m = model();
657        let s1 = m.signal_for(3);
658        let s2 = m.signal_for(3);
659        m.check(3);
660        assert_eq!(s1.get(), CheckState::Checked);
661        assert_eq!(s2.get(), CheckState::Checked);
662    }
663
664    #[test]
665    fn prune_missing_drops_stale_state() {
666        let m = model();
667        m.check(3);
668        assert!(m.checked_keys().contains(&3));
669        // Simulate a reload where scene 3 was deleted: only {1,2,4,5} survive.
670        m.prune_missing(|k| *k != 3);
671        assert!(!m.checked_keys().contains(&3));
672        assert_eq!(m.check_state(&3), CheckState::Unchecked); // forgotten
673    }
674
675    #[test]
676    fn clear_resets_all() {
677        let m = model();
678        m.check(1);
679        m.clear();
680        assert_eq!(m.checked_keys(), Vec::<u64>::new());
681    }
682
683    #[test]
684    fn clear_resets_every_key_and_still_notifies() {
685        // Covers the fast path (`clear` writes every tracked key directly
686        // instead of cascading each one): check(2) cascades Checked down to
687        // its children {3, 5} and leaves Binder(1) Indeterminate (Scene
688        // B(4) still unchecked); check(4) then aggregates Binder(1) up to
689        // fully Checked. Observers + a bool-signal bridge on every key must
690        // still all see Unchecked after clear() even though it no longer
691        // walks the tree.
692        let m = model();
693        m.check(2);
694        m.check(4);
695        assert_eq!(m.check_state(&1), CheckState::Checked);
696        assert_eq!(m.check_state(&2), CheckState::Checked);
697        assert_eq!(m.check_state(&3), CheckState::Checked);
698        assert_eq!(m.check_state(&5), CheckState::Checked);
699        let bool_3 = m.bool_signal_for(3);
700        assert!(bool_3.get());
701
702        let notified: Rc<RefCell<HashSet<u64>>> = Rc::new(RefCell::new(HashSet::new()));
703        let mut handles = Vec::new();
704        for key in [1u64, 2, 3, 4, 5] {
705            let log = notified.clone();
706            handles.push(m.signal_for(key).observe(move |state| {
707                if *state == CheckState::Unchecked {
708                    log.borrow_mut().insert(key);
709                }
710            }));
711        }
712
713        m.clear();
714
715        assert_eq!(m.checked_keys(), Vec::<u64>::new());
716        for key in [1u64, 2, 3, 4, 5] {
717            assert_eq!(m.check_state(&key), CheckState::Unchecked);
718        }
719        assert!(!bool_3.get());
720        assert_eq!(
721            notified.borrow().len(),
722            5,
723            "every tracked key must still notify its own observers on clear: {:?}",
724            notified.borrow()
725        );
726        drop(handles);
727    }
728
729    #[test]
730    fn lazy_signal_still_cascades() {
731        // Regression: a node's signal first materialised by a cascade (write_state)
732        // must still cascade when its own signal_for is called later (virtualized
733        // row realizing after a parent was checked).
734        let m = KeyedTreeCheckedModel::from_source(slice());
735        let _ = m.signal_for(1); // only the root is realized
736        m.check(1); // cascades Checked to 2,3,4,5 via observer-less signals
737        assert_eq!(m.check_state(&3), CheckState::Checked);
738
739        let scene3 = m.signal_for(3); // scene 3's row finally realizes + binds
740        scene3.set(CheckState::Unchecked); // user unchecks it
741        // Chapter(2) must recompute (5 still Checked, 3 now Unchecked → mixed).
742        assert_eq!(m.check_state(&2), CheckState::Indeterminate);
743        assert_eq!(m.check_state(&1), CheckState::Indeterminate);
744    }
745
746    #[test]
747    fn prune_missing_reaggregates_ancestors() {
748        // Regression: after a reload that removes a checked node, surviving
749        // ancestors must show the recomputed tristate, not the stale one.
750        let s = slice(); // Binder(1)→Chapter(2)→{A(3),C(5)}, B(4) under Binder
751        let m = KeyedTreeCheckedModel::from_source(s.clone());
752        let _ = (
753            m.signal_for(1),
754            m.signal_for(2),
755            m.signal_for(3),
756            m.signal_for(5),
757        );
758        m.check(3);
759        assert_eq!(m.check_state(&2), CheckState::Indeterminate);
760        assert_eq!(m.check_state(&1), CheckState::Indeterminate);
761
762        // Reload with Scene A(3) removed → Chapter(2) now only has C(5), unchecked.
763        s.set_rows(vec![
764            TreeRow::new(1, "Binder", 0),
765            TreeRow::new(2, "Chapter", 1),
766            TreeRow::new(5, "Scene C", 2),
767            TreeRow::new(4, "Scene B", 1),
768        ]);
769        assert_eq!(s.child_keys_of(&2), vec![5]);
770
771        m.prune_missing(|k| s.contains_key(k));
772        assert!(!m.checked_keys().contains(&3));
773        assert_eq!(m.check_state(&2), CheckState::Unchecked);
774        assert_eq!(m.check_state(&1), CheckState::Unchecked);
775    }
776
777    #[test]
778    fn reaggregate_after_added_child() {
779        let s = slice();
780        let m = KeyedTreeCheckedModel::from_source(s.clone());
781        let _ = (m.signal_for(2), m.signal_for(3), m.signal_for(5));
782        m.check(2); // Chapter fully checked (A + C)
783        assert_eq!(m.check_state(&2), CheckState::Checked);
784
785        // Reload: add a new unchecked scene D(6) under Chapter(2).
786        s.set_rows(vec![
787            TreeRow::new(1, "Binder", 0),
788            TreeRow::new(2, "Chapter", 1),
789            TreeRow::new(3, "Scene A", 2),
790            TreeRow::new(5, "Scene C", 2),
791            TreeRow::new(6, "Scene D", 2),
792            TreeRow::new(4, "Scene B", 1),
793        ]);
794        m.reaggregate();
795        // Chapter gained an unchecked child → Indeterminate.
796        assert_eq!(m.check_state(&2), CheckState::Indeterminate);
797    }
798
799    #[test]
800    fn new_with_explicit_closures() {
801        // No TreeDataSource — pure closures. A -> [B, C].
802        let parents: HashMap<&str, &str> = [("B", "A"), ("C", "A")].into_iter().collect();
803        let m = KeyedTreeCheckedModel::<&str>::new(
804            |k| match *k {
805                "A" => vec!["B", "C"],
806                _ => vec![],
807            },
808            move |k| parents.get(k).copied(),
809        );
810        let _ = (m.signal_for("A"), m.signal_for("B"), m.signal_for("C"));
811        m.check("B");
812        assert_eq!(m.check_state(&"A"), CheckState::Indeterminate);
813        m.check("C");
814        assert_eq!(m.check_state(&"A"), CheckState::Checked);
815    }
816
817    #[test]
818    fn reentrant_write_to_unrelated_key_still_cascades() {
819        // Regression: cascade suppression must be scoped to the keys an
820        // in-progress cascade actually touches, not the whole model. An app
821        // observer reacting to "a" becoming Checked by checking the
822        // *unrelated* key "c" (under a different root) must still get its
823        // own full cascade — "c"'s ancestor "root2" has to recompute, even
824        // though "root1"'s cascade is still on the stack.
825        let parents: HashMap<&str, &str> = [("a", "root1"), ("b", "root1"), ("c", "root2")]
826            .into_iter()
827            .collect();
828        let m = KeyedTreeCheckedModel::<&str>::new(
829            |k| match *k {
830                "root1" => vec!["a", "b"],
831                "root2" => vec!["c"],
832                _ => vec![],
833            },
834            move |k| parents.get(k).copied(),
835        );
836        let _ = (
837            m.signal_for("root1"),
838            m.signal_for("a"),
839            m.signal_for("b"),
840            m.signal_for("root2"),
841            m.signal_for("c"),
842        );
843
844        let m_for_observer = m.clone();
845        let _obs = m.signal_for("a").observe(move |state| {
846            if *state == CheckState::Checked {
847                m_for_observer.check("c");
848            }
849        });
850
851        m.check("root1"); // cascades Checked to a (and b), reentrantly checking c
852
853        assert_eq!(m.check_state(&"a"), CheckState::Checked);
854        assert_eq!(m.check_state(&"c"), CheckState::Checked);
855        // root2's only child (c) is Checked, so root2 must have recomputed —
856        // not stayed at its stale Unchecked default.
857        assert_eq!(m.check_state(&"root2"), CheckState::Checked);
858    }
859}