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}