Skip to main content

teksilo_data/
tree_checked_model.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `TreeCheckedModel` — per-node checkbox state for a tree, with optional
5//! descendant→ancestor tristate aggregation.
6//!
7//! Companion to [`crate::CheckedModel`] for trees. Defaults to the standard
8//! "Outlook folder selection" semantic: a parent's state is `Checked` if all
9//! descendants are checked, `Unchecked` if none, `Indeterminate` otherwise;
10//! toggling a parent cascades `Checked`/`Unchecked` down to all descendants.
11//! Set the mode to [`AggregateMode::None`] to give every node independent
12//! state instead. The model is a share-by-clone handle (`Rc<RefCell<…>>`
13//! internally) — cloning produces a second view onto the same checkbox state.
14//!
15//! External writes (e.g. a `Checkbox` widget bound to
16//! `signal_for(node)` setting it directly) trigger the same
17//! cascade-and-recompute pass as the model's own
18//! `check`/`uncheck`/`toggle` methods, via per-node observers. A
19//! re-entry guard prevents the cascade pass from re-firing
20//! observers it triggers itself.
21//!
22//! ## Example
23//!
24//! ```rust
25//! # use teksilo_data::{TreeModel, TreeCheckedModel, CheckState};
26//! let tree = TreeModel::new();
27//! let root = tree.insert_root(0, "root");
28//! let child_a = tree.insert_child(root, 0, "a");
29//! let child_b = tree.insert_child(root, 1, "b");
30//!
31//! let model = TreeCheckedModel::new(tree);
32//! // Pre-register signal chains before mutating.
33//! let _ = (model.signal_for(root), model.signal_for(child_a), model.signal_for(child_b));
34//!
35//! model.check(child_a);
36//! assert_eq!(model.check_state(root), CheckState::Indeterminate);
37//! model.check(child_b);
38//! assert_eq!(model.check_state(root), CheckState::Checked);
39//! ```
40//!
41//! ## Limitation: tree-mutation desync
42//!
43//! `signal_for(node)` and `bool_signal_for(node)` cache signals keyed
44//! by `NodeId`. The cache is never invalidated. If the underlying
45//! `TreeModel<T>` mutates (`remove`, `move_node`, etc.) the cached
46//! entry for a removed `NodeId` lingers indefinitely:
47//!
48//! - `checked_nodes()` may include a stale `NodeId` whose underlying
49//!   tree node no longer exists. Callers that consume this list
50//!   should validate each id against the current tree state before
51//!   acting on it.
52//! - `bool_signal_for` / `signal_for` for a removed node still return
53//!   their cached signal handle. Setting it has no observable effect
54//!   on the tree (the cascade walks `tree.children(node)` which is
55//!   empty for a freed node).
56//!
57//! This is an acceptable trade-off because `NodeId`s are not reused
58//! by `TreeModel` (slotmap keys are versioned), so a stale id can
59//! never alias a fresh node. If a future use case needs strict
60//! invalidation on removal, subscribe to `TreeModel`'s change events
61//! and clear the relevant entries. Tracked as out-of-scope for V1.
62
63use std::cell::{Cell, RefCell};
64use std::collections::{HashMap, HashSet};
65use std::rc::Rc;
66
67use teksilo_core::signal::{ObserverHandle, Signal};
68
69use crate::check_state::CheckState;
70use crate::tree_change::NodeId;
71use crate::tree_model::TreeModel;
72
73/// How a parent's [`CheckState`] relates to its descendants.
74#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
75pub enum AggregateMode {
76    /// Each node owns its state independently; parent states do not reflect
77    /// their descendants and cascades do not occur.
78    None,
79    /// All-checked → `Checked`; all-unchecked → `Unchecked`; mixed →
80    /// `Indeterminate`. Toggling a parent cascades `Checked`/`Unchecked` to
81    /// all descendants and recomputes every ancestor. This is the default and
82    /// corresponds to the "Outlook folder selection" tristate pattern.
83    #[default]
84    DescendantsDriveAncestors,
85}
86
87struct Inner {
88    state: HashMap<NodeId, Signal<CheckState>>,
89    /// Keep cascade-observer handles alive for the model's lifetime.
90    observers: HashMap<NodeId, ObserverHandle>,
91    /// Cached `Signal<bool>` views for nodes whose callers asked for
92    /// the two-state projection (`bool_signal_for`).
93    bool_signals: HashMap<NodeId, Signal<bool>>,
94    /// Per-node bidirectional bridge guards (tristate ↔ bool). Each
95    /// is a small `Cell<bool>` flipped while one side propagates to
96    /// the other so the back-channel observer no-ops.
97    bridge_guards: HashMap<NodeId, Rc<Cell<bool>>>,
98    /// Bridge observer handles (tristate→bool and bool→tristate),
99    /// kept alive for the model's lifetime.
100    bridge_observers: HashMap<NodeId, (ObserverHandle, ObserverHandle)>,
101    /// Nodes whose signal is *currently* being written by [`write_state`] as
102    /// part of an in-progress cascade — scoped per-node, not a single global
103    /// flag, so an unrelated node's write (e.g. triggered by an app observer
104    /// reacting mid-cascade) still runs its own cascade + ancestor recompute
105    /// instead of silently no-opping. A node's own cascade observer, seeing
106    /// its own id here, knows the write is a cascade-internal echo of a walk
107    /// already in progress and skips re-cascading it.
108    suppressed: HashSet<NodeId>,
109}
110
111/// Per-node checkbox state for a [`TreeModel<T>`](crate::TreeModel), with optional
112/// descendant→ancestor tristate aggregation.
113///
114/// See the [module documentation](self) for the full semantics and limitations.
115/// Clone to share the same checkbox state between multiple call sites.
116pub struct TreeCheckedModel<T: 'static> {
117    tree: TreeModel<T>,
118    inner: Rc<RefCell<Inner>>,
119    mode: Rc<Cell<AggregateMode>>,
120}
121
122impl<T: 'static> TreeCheckedModel<T> {
123    /// Create a new model wrapping `tree` with the default
124    /// [`AggregateMode::DescendantsDriveAncestors`] cascade behaviour.
125    pub fn new(tree: TreeModel<T>) -> Self {
126        Self {
127            tree,
128            inner: Rc::new(RefCell::new(Inner {
129                state: HashMap::new(),
130                observers: HashMap::new(),
131                bool_signals: HashMap::new(),
132                bridge_guards: HashMap::new(),
133                bridge_observers: HashMap::new(),
134                suppressed: HashSet::new(),
135            })),
136            mode: Rc::new(Cell::new(AggregateMode::default())),
137        }
138    }
139
140    /// Create a new model wrapping `tree` with an explicit [`AggregateMode`].
141    pub fn with_mode(tree: TreeModel<T>, mode: AggregateMode) -> Self {
142        let m = Self::new(tree);
143        m.mode.set(mode);
144        m
145    }
146
147    /// Returns the current [`AggregateMode`] controlling cascade behaviour.
148    pub fn aggregate_mode(&self) -> AggregateMode {
149        self.mode.get()
150    }
151
152    /// Change the cascade behaviour; takes effect on the next write to any node's signal.
153    pub fn set_aggregate_mode(&self, mode: AggregateMode) {
154        self.mode.set(mode);
155    }
156
157    /// Writable `Signal<CheckState>` for `node`. Cached: repeat calls
158    /// return the same root. External writes (e.g. from a `Checkbox`)
159    /// trigger the configured aggregation pass. The cascade observer is
160    /// wired **idempotently** — including for a signal first materialised by
161    /// a cascade (`write_state`) before its own `signal_for` was ever called
162    /// (a lazily/virtualized-realised row) — so a later external write to it
163    /// still cascades.
164    pub fn signal_for(&self, node: NodeId) -> Signal<CheckState> {
165        // Get or create the signal (a cascade may have created it observer-less).
166        let sig = self
167            .inner
168            .borrow_mut()
169            .state
170            .entry(node)
171            .or_insert_with(|| Signal::new(CheckState::Unchecked))
172            .clone();
173        // Wire the cascade observer once, if this node doesn't have one yet.
174        if !self.inner.borrow().observers.contains_key(&node) {
175            let handle = self.make_cascade_observer(&sig, node);
176            self.inner.borrow_mut().observers.insert(node, handle);
177        }
178        sig
179    }
180
181    /// Build the cascade observer for `node`'s signal: on any write, cascade
182    /// Checked/Unchecked to descendants and recompute ancestors, guarded
183    /// against re-entry. It's a no-op while the model is performing its own
184    /// cascade pass (suppress = true).
185    fn make_cascade_observer(&self, sig: &Signal<CheckState>, node: NodeId) -> ObserverHandle {
186        let inner_w = Rc::downgrade(&self.inner);
187        let mode_w = Rc::downgrade(&self.mode);
188        let tree = self.tree.clone();
189        sig.observe(move |new_state| {
190            let inner_rc = match inner_w.upgrade() {
191                Some(rc) => rc,
192                None => return,
193            };
194            let mode_rc = match mode_w.upgrade() {
195                Some(rc) => rc,
196                None => return,
197            };
198            // Re-entry guard: a no-op only if THIS node's write is itself a
199            // cascade-internal echo (see `Inner::suppressed`). An unrelated
200            // node reached via `write_state`'s notification (e.g. an app
201            // observer that checks a different node) is not suppressed and
202            // runs its own cascade below.
203            if inner_rc.borrow().suppressed.contains(&node) {
204                return;
205            }
206            if mode_rc.get() != AggregateMode::DescendantsDriveAncestors {
207                return;
208            }
209            // Cascade Checked / Unchecked to all descendants;
210            // Indeterminate is a parent-only state and doesn't propagate.
211            if *new_state != CheckState::Indeterminate {
212                cascade_descendants(&tree, &inner_rc, node, *new_state);
213            }
214            // Recompute ancestors.
215            let mut cur = tree.parent(node);
216            while let Some(p) = cur {
217                recompute_from_children(&tree, &inner_rc, p);
218                cur = tree.parent(p);
219            }
220        })
221    }
222
223    /// Two-state projection of `signal_for` for callers that want
224    /// to bind a leaf's check state to a `Signal<bool>`-shaped widget
225    /// (e.g. a non-tristate `Checkbox`). The returned signal is
226    /// **writable**: setting it to `true` calls `check(node)` (which
227    /// runs the configured cascade), `false` calls `uncheck(node)`.
228    /// Writes from the model side propagate back into the bool signal
229    /// (`Checked → true`, anything else → `false`). Cached: repeat
230    /// calls return the same handle.
231    ///
232    /// For leaves under `AggregateMode::DescendantsDriveAncestors`
233    /// this is the right pairing — a leaf's state is two-state by
234    /// nature, and the model's ancestor recompute still runs. For
235    /// branches you typically want the tristate `signal_for` so
236    /// `Indeterminate` is visible.
237    pub fn bool_signal_for(&self, node: NodeId) -> Signal<bool> {
238        if let Some(b) = self.inner.borrow().bool_signals.get(&node) {
239            return b.clone();
240        }
241        // Make sure the tristate signal exists (with its cascade
242        // observer attached) before we wire the bridge.
243        let tristate = self.signal_for(node);
244        let bool_sig = Signal::new(tristate.get() == CheckState::Checked);
245        let guard = Rc::new(Cell::new(false));
246
247        // tristate → bool
248        let bool_for_tri = bool_sig.clone();
249        let guard_for_tri = guard.clone();
250        let tri_to_bool = tristate.observe(move |state| {
251            if guard_for_tri.get() {
252                return;
253            }
254            let want = matches!(state, CheckState::Checked);
255            if bool_for_tri.get() != want {
256                guard_for_tri.set(true);
257                bool_for_tri.set(want);
258                guard_for_tri.set(false);
259            }
260        });
261
262        // bool → tristate (the existing tristate cascade observer
263        // takes it from there, including ancestor recompute).
264        let tri_for_bool = tristate.clone();
265        let guard_for_bool = guard.clone();
266        let bool_to_tri = bool_sig.observe(move |checked| {
267            if guard_for_bool.get() {
268                return;
269            }
270            let want = if *checked {
271                CheckState::Checked
272            } else {
273                CheckState::Unchecked
274            };
275            if tri_for_bool.get() != want {
276                guard_for_bool.set(true);
277                tri_for_bool.set(want);
278                guard_for_bool.set(false);
279            }
280        });
281
282        let mut inner = self.inner.borrow_mut();
283        inner.bool_signals.insert(node, bool_sig.clone());
284        inner.bridge_guards.insert(node, guard);
285        inner
286            .bridge_observers
287            .insert(node, (tri_to_bool, bool_to_tri));
288        bool_sig
289    }
290
291    /// Returns the current [`CheckState`] for `node` (defaults to `Unchecked`
292    /// if the node's signal has never been written or read).
293    pub fn check_state(&self, node: NodeId) -> CheckState {
294        self.inner
295            .borrow()
296            .state
297            .get(&node)
298            .map(|s| s.get())
299            .unwrap_or(CheckState::Unchecked)
300    }
301
302    /// Set `node` to [`CheckState::Checked`], triggering the configured cascade and
303    /// ancestor recompute; notifies observers of every affected node's signal.
304    pub fn check(&self, node: NodeId) {
305        // Setting via signal_for runs through the observer, which
306        // performs the cascade. No need to duplicate logic here.
307        self.signal_for(node).set(CheckState::Checked);
308    }
309
310    /// Set `node` to [`CheckState::Unchecked`], triggering the configured cascade and
311    /// ancestor recompute; notifies observers of every affected node's signal.
312    pub fn uncheck(&self, node: NodeId) {
313        self.signal_for(node).set(CheckState::Unchecked);
314    }
315
316    /// Toggle `node`'s check state: under `DescendantsDriveAncestors` a leaf
317    /// cycles two-state (`Unchecked` ↔ `Checked`); a branch or `AggregateMode::None`
318    /// cycles the full tristate sequence via [`CheckState::next_tristate`].
319    pub fn toggle(&self, node: NodeId) {
320        let current = self.check_state(node);
321        let next = match (self.mode.get(), self.is_leaf(node), current) {
322            (AggregateMode::DescendantsDriveAncestors, true, CheckState::Unchecked) => {
323                CheckState::Checked
324            }
325            (AggregateMode::DescendantsDriveAncestors, true, _) => CheckState::Unchecked,
326            (_, _, _) => current.next_tristate(),
327        };
328        self.signal_for(node).set(next);
329    }
330
331    /// Returns all `NodeId`s whose current state is exactly [`CheckState::Checked`].
332    ///
333    /// Note: may include stale ids if the underlying tree has been mutated since
334    /// the signals were first registered — see the module-level limitation note.
335    pub fn checked_nodes(&self) -> Vec<NodeId> {
336        self.inner
337            .borrow()
338            .state
339            .iter()
340            .filter_map(|(id, sig)| (sig.get() == CheckState::Checked).then_some(*id))
341            .collect()
342    }
343
344    /// Reset all known nodes to [`CheckState::Unchecked`] and notify observers.
345    ///
346    /// Writes every tracked node directly via the internal `write_state`
347    /// helper (per-node cascade-suppressed, like the recompute pass) instead of going
348    /// through `check`/`uncheck`'s normal `signal_for(..).set(..)` path —
349    /// the latter would, for every currently-checked node, cascade the
350    /// write down its entire descendant subtree and recompute every
351    /// ancestor up to the root, all *before* the outer loop even reaches
352    /// those same nodes. Since every tracked node ends up `Unchecked` here,
353    /// there is nothing left to aggregate: "all children unchecked" is
354    /// already the correct parent state, so skipping the cascade and
355    /// ancestor recompute entirely still leaves every node's state
356    /// consistent — one direct write per tracked node instead of a
357    /// cascade+recompute pass per *checked* one.
358    pub fn clear(&self) {
359        // Snapshot keys to avoid borrow-during-iteration.
360        let keys: Vec<NodeId> = self.inner.borrow().state.keys().copied().collect();
361        for k in keys {
362            write_state(&self.inner, k, CheckState::Unchecked);
363        }
364    }
365
366    fn is_leaf(&self, node: NodeId) -> bool {
367        self.tree.children(node).is_empty()
368    }
369}
370
371/// RAII guard: marks a single node as cascade-suppressed on creation,
372/// unmarks it on drop — so a panic mid-write can't leave that node
373/// permanently unable to cascade. Scoped to one [`NodeId`] (see
374/// `Inner::suppressed`), not the whole model.
375struct SuppressGuard {
376    inner: Rc<RefCell<Inner>>,
377    node: NodeId,
378}
379
380impl SuppressGuard {
381    fn new(inner: &Rc<RefCell<Inner>>, node: NodeId) -> Self {
382        inner.borrow_mut().suppressed.insert(node);
383        Self {
384            inner: inner.clone(),
385            node,
386        }
387    }
388}
389
390impl Drop for SuppressGuard {
391    fn drop(&mut self) {
392        // A borrow may still be held during a panic unwind; best-effort clear.
393        if let Ok(mut inner) = self.inner.try_borrow_mut() {
394            inner.suppressed.remove(&self.node);
395        }
396    }
397}
398
399// Free functions so the observer closure can call them without
400// holding `&self` (the model isn't `Clone` cheaply, and the closure
401// only has a `Weak<Inner>`).
402
403fn cascade_descendants<T: 'static>(
404    tree: &TreeModel<T>,
405    inner: &Rc<RefCell<Inner>>,
406    root: NodeId,
407    target: CheckState,
408) {
409    for child in tree.children(root) {
410        write_state(inner, child, target);
411        cascade_descendants(tree, inner, child, target);
412    }
413}
414
415fn recompute_from_children<T: 'static>(
416    tree: &TreeModel<T>,
417    inner: &Rc<RefCell<Inner>>,
418    node: NodeId,
419) {
420    let kids = tree.children(node);
421    if kids.is_empty() {
422        return;
423    }
424    let mut all_checked = true;
425    let mut all_unchecked = true;
426    for child in &kids {
427        let st = read_state(inner, *child);
428        match st {
429            CheckState::Checked => all_unchecked = false,
430            CheckState::Unchecked => all_checked = false,
431            CheckState::Indeterminate => {
432                all_checked = false;
433                all_unchecked = false;
434            }
435        }
436    }
437    let new_state = if all_checked {
438        CheckState::Checked
439    } else if all_unchecked {
440        CheckState::Unchecked
441    } else {
442        CheckState::Indeterminate
443    };
444    write_state(inner, node, new_state);
445}
446
447fn read_state(inner: &Rc<RefCell<Inner>>, node: NodeId) -> CheckState {
448    inner
449        .borrow()
450        .state
451        .get(&node)
452        .map(|s| s.get())
453        .unwrap_or(CheckState::Unchecked)
454}
455
456fn write_state(inner: &Rc<RefCell<Inner>>, node: NodeId, state: CheckState) {
457    let sig = {
458        let mut map = inner.borrow_mut();
459        map.state
460            .entry(node)
461            .or_insert_with(|| Signal::new(CheckState::Unchecked))
462            .clone()
463    };
464    if sig.get() != state {
465        // Suppress only `node`'s own cascade observer for the duration of
466        // this write — it's about to see the value it's already applying.
467        let _guard = SuppressGuard::new(inner, node);
468        sig.set(state);
469    }
470}
471
472impl<T: 'static> Clone for TreeCheckedModel<T> {
473    fn clone(&self) -> Self {
474        Self {
475            tree: self.tree.clone(),
476            inner: self.inner.clone(),
477            mode: self.mode.clone(),
478        }
479    }
480}
481
482impl<T: 'static> std::fmt::Debug for TreeCheckedModel<T> {
483    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
484        f.debug_struct("TreeCheckedModel")
485            .field("mode", &self.mode.get())
486            .field("tracked_nodes", &self.inner.borrow().state.len())
487            .finish()
488    }
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494
495    fn sample_tree() -> (
496        TreeModel<&'static str>,
497        NodeId,
498        NodeId,
499        NodeId,
500        NodeId,
501        NodeId,
502    ) {
503        // root1 (parent)
504        //   ├─ a (leaf)
505        //   └─ b (leaf)
506        // root2 (parent)
507        //   └─ c (leaf)
508        let t = TreeModel::new();
509        let root1 = t.insert_root(0, "root1");
510        let a = t.insert_child(root1, 0, "a");
511        let b = t.insert_child(root1, 1, "b");
512        let root2 = t.insert_root(1, "root2");
513        let c = t.insert_child(root2, 0, "c");
514        (t, root1, a, b, root2, c)
515    }
516
517    #[test]
518    fn descendants_drive_ancestors_default() {
519        let (t, root1, a, b, _root2, _c) = sample_tree();
520        let m = TreeCheckedModel::new(t);
521        // Pre-register every node's signal so the observer chain is
522        // wired before we mutate.
523        let _ = (m.signal_for(root1), m.signal_for(a), m.signal_for(b));
524
525        m.check(a);
526        assert_eq!(m.check_state(a), CheckState::Checked);
527        assert_eq!(m.check_state(root1), CheckState::Indeterminate);
528
529        m.check(b);
530        assert_eq!(m.check_state(root1), CheckState::Checked);
531    }
532
533    #[test]
534    fn set_parent_cascades_to_descendants() {
535        let (t, root1, a, b, _r2, _c) = sample_tree();
536        let m = TreeCheckedModel::new(t);
537        let _ = (m.signal_for(root1), m.signal_for(a), m.signal_for(b));
538
539        m.check(root1);
540        assert_eq!(m.check_state(a), CheckState::Checked);
541        assert_eq!(m.check_state(b), CheckState::Checked);
542        assert_eq!(m.check_state(root1), CheckState::Checked);
543
544        m.uncheck(root1);
545        assert_eq!(m.check_state(a), CheckState::Unchecked);
546        assert_eq!(m.check_state(b), CheckState::Unchecked);
547    }
548
549    #[test]
550    fn external_signal_write_triggers_cascade() {
551        // Simulate a Checkbox widget writing directly to the per-node
552        // signal — the observer should still cascade.
553        let (t, root1, a, b, _r2, _c) = sample_tree();
554        let m = TreeCheckedModel::new(t);
555        let parent_sig = m.signal_for(root1);
556        let _ = (m.signal_for(a), m.signal_for(b));
557
558        parent_sig.set(CheckState::Checked);
559        assert_eq!(m.check_state(a), CheckState::Checked);
560        assert_eq!(m.check_state(b), CheckState::Checked);
561    }
562
563    #[test]
564    fn lazy_signal_still_cascades() {
565        // Regression: a signal first materialised by a cascade (write_state)
566        // must still cascade when its own signal_for is called later (a
567        // virtualized row realizing after its parent was checked).
568        let (t, root1, a, _b, _r2, _c) = sample_tree();
569        let m = TreeCheckedModel::new(t);
570        let _ = m.signal_for(root1); // only the parent is realized
571        m.check(root1); // cascades Checked to a, b via observer-less signals
572        assert_eq!(m.check_state(a), CheckState::Checked);
573
574        let a_sig = m.signal_for(a); // leaf a's row finally realizes + binds
575        a_sig.set(CheckState::Unchecked); // user unchecks it
576        // root1 must recompute (b still Checked, a now Unchecked → mixed).
577        assert_eq!(m.check_state(root1), CheckState::Indeterminate);
578    }
579
580    #[test]
581    fn aggregate_mode_none_disables_propagation() {
582        let (t, root1, a, _b, _r2, _c) = sample_tree();
583        let m = TreeCheckedModel::with_mode(t, AggregateMode::None);
584        let _ = (m.signal_for(root1), m.signal_for(a));
585
586        m.check(a);
587        assert_eq!(m.check_state(a), CheckState::Checked);
588        assert_eq!(m.check_state(root1), CheckState::Unchecked);
589    }
590
591    #[test]
592    fn signal_for_is_stable_across_calls() {
593        let (t, _root1, a, _b, _r2, _c) = sample_tree();
594        let m = TreeCheckedModel::new(t);
595
596        let s1 = m.signal_for(a);
597        let s2 = m.signal_for(a);
598        m.check(a);
599        assert_eq!(s1.get(), CheckState::Checked);
600        assert_eq!(s2.get(), CheckState::Checked);
601    }
602
603    #[test]
604    fn checked_nodes_excludes_indeterminate() {
605        let (t, root1, a, _b, _r2, _c) = sample_tree();
606        let m = TreeCheckedModel::new(t);
607        let _ = (m.signal_for(root1), m.signal_for(a));
608        m.check(a);
609        let nodes = m.checked_nodes();
610        assert!(nodes.contains(&a));
611        assert!(!nodes.contains(&root1));
612    }
613
614    #[test]
615    fn toggle_leaf_two_state_in_aggregate_mode() {
616        let (t, _root1, a, _b, _r2, _c) = sample_tree();
617        let m = TreeCheckedModel::new(t);
618
619        m.toggle(a);
620        assert_eq!(m.check_state(a), CheckState::Checked);
621        m.toggle(a);
622        assert_eq!(m.check_state(a), CheckState::Unchecked);
623    }
624
625    #[test]
626    fn bool_signal_writes_propagate_to_tristate() {
627        let (t, _root1, a, _b, _r2, _c) = sample_tree();
628        let m = TreeCheckedModel::new(t);
629        let bool_sig = m.bool_signal_for(a);
630        assert!(!bool_sig.get());
631
632        bool_sig.set(true);
633        assert_eq!(m.check_state(a), CheckState::Checked);
634
635        bool_sig.set(false);
636        assert_eq!(m.check_state(a), CheckState::Unchecked);
637    }
638
639    #[test]
640    fn bool_signal_reflects_tristate_writes() {
641        let (t, _root1, a, _b, _r2, _c) = sample_tree();
642        let m = TreeCheckedModel::new(t);
643        let bool_sig = m.bool_signal_for(a);
644
645        m.check(a);
646        assert!(bool_sig.get());
647        m.uncheck(a);
648        assert!(!bool_sig.get());
649    }
650
651    #[test]
652    fn bool_signal_indeterminate_reads_as_false() {
653        let (t, root1, a, b, _r2, _c) = sample_tree();
654        let m = TreeCheckedModel::new(t);
655        let parent_bool = m.bool_signal_for(root1);
656        let _ = (m.signal_for(a), m.signal_for(b));
657
658        m.check(a); // → root1 becomes Indeterminate
659        assert_eq!(m.check_state(root1), CheckState::Indeterminate);
660        assert!(!parent_bool.get(), "Indeterminate must not read as true");
661    }
662
663    #[test]
664    fn bool_signal_writes_through_leaves_recompute_ancestors() {
665        let (t, root1, a, b, _r2, _c) = sample_tree();
666        let m = TreeCheckedModel::new(t);
667        let a_bool = m.bool_signal_for(a);
668        let b_bool = m.bool_signal_for(b);
669
670        a_bool.set(true);
671        assert_eq!(m.check_state(root1), CheckState::Indeterminate);
672        b_bool.set(true);
673        assert_eq!(m.check_state(root1), CheckState::Checked);
674    }
675
676    #[test]
677    fn bool_signal_for_is_stable_across_calls() {
678        let (t, _root1, a, _b, _r2, _c) = sample_tree();
679        let m = TreeCheckedModel::new(t);
680        let s1 = m.bool_signal_for(a);
681        let s2 = m.bool_signal_for(a);
682        s1.set(true);
683        assert!(s2.get());
684    }
685
686    #[test]
687    fn clear_resets_all() {
688        let (t, root1, _a, _b, _r2, _c) = sample_tree();
689        let m = TreeCheckedModel::new(t);
690        let _ = (m.signal_for(root1),);
691        m.check(root1);
692        m.clear();
693        assert_eq!(m.checked_nodes(), Vec::<NodeId>::new());
694    }
695
696    #[test]
697    fn clear_resets_every_node_and_still_notifies() {
698        // Covers the fast path (`clear` writes every tracked node directly
699        // instead of cascading each one): mix a directly-checked ancestor
700        // (root1, cascades to a/b), a directly-checked leaf (c, whose
701        // ancestor root2 must have recomputed to Checked), and observers +
702        // a bool-signal bridge on several nodes to prove every one still
703        // gets notified exactly once even though clear() no longer walks
704        // the tree.
705        let (t, root1, a, b, root2, c) = sample_tree();
706        let m = TreeCheckedModel::new(t);
707        let _ = (m.signal_for(root1), m.signal_for(a), m.signal_for(b));
708        let bool_a = m.bool_signal_for(a);
709
710        m.check(root1); // cascades Checked to a, b
711        m.check(c); // root2 recomputes to Checked
712        assert_eq!(m.check_state(root1), CheckState::Checked);
713        assert_eq!(m.check_state(root2), CheckState::Checked);
714        assert!(bool_a.get());
715
716        let notified: Rc<RefCell<HashSet<NodeId>>> = Rc::new(RefCell::new(HashSet::new()));
717        let mut handles = Vec::new();
718        for node in [root1, a, b, root2, c] {
719            let log = notified.clone();
720            handles.push(m.signal_for(node).observe(move |state| {
721                if *state == CheckState::Unchecked {
722                    log.borrow_mut().insert(node);
723                }
724            }));
725        }
726
727        m.clear();
728
729        assert_eq!(m.checked_nodes(), Vec::<NodeId>::new());
730        for node in [root1, a, b, root2, c] {
731            assert_eq!(m.check_state(node), CheckState::Unchecked);
732        }
733        assert!(!bool_a.get());
734        assert_eq!(
735            notified.borrow().len(),
736            5,
737            "every tracked node must still notify its own observers on clear: {:?}",
738            notified.borrow()
739        );
740        drop(handles);
741    }
742
743    #[test]
744    fn reentrant_write_to_unrelated_node_still_cascades() {
745        // Regression: cascade suppression must be scoped to the nodes an
746        // in-progress cascade actually touches, not the whole model. An app
747        // observer reacting to `a` becoming Checked by checking the
748        // *unrelated* node `c` (under a different root) must still get its
749        // own full cascade — `c`'s ancestor `root2` has to recompute, even
750        // though `root1`'s cascade is still on the stack.
751        let (t, root1, a, b, root2, c) = sample_tree();
752        let m = TreeCheckedModel::new(t);
753        let _ = (
754            m.signal_for(root1),
755            m.signal_for(a),
756            m.signal_for(b),
757            m.signal_for(root2),
758            m.signal_for(c),
759        );
760
761        let m_for_observer = m.clone();
762        let _obs = m.signal_for(a).observe(move |state| {
763            if *state == CheckState::Checked {
764                m_for_observer.check(c);
765            }
766        });
767
768        m.check(root1); // cascades Checked to a (and b), reentrantly checking c
769
770        assert_eq!(m.check_state(a), CheckState::Checked);
771        assert_eq!(m.check_state(c), CheckState::Checked);
772        // root2's only child (c) is Checked, so root2 must have recomputed —
773        // not stayed at its stale Unchecked default.
774        assert_eq!(m.check_state(root2), CheckState::Checked);
775    }
776}