Skip to main content

rust_elm/
scope.rs

1use crate::cmd::Cmd;
2use crate::effect::{run_registered_env_task, run_registered_task, Effect, EffectId};
3use crate::optics::{Casepath, CasePath, StateLens};
4use rust_identified_vec::{Identifiable, IdentifiedVec};
5use crate::reducer::Reducer;
6use std::marker::PhantomData;
7
8/// Focuses nested state/action and runs a child reducer (UDF `Scope`).
9#[derive(Debug)]
10pub struct ScopeReducer<R, PS: 'static, PA: 'static, CS: 'static, CA: 'static, AK: 'static, SK>
11where
12    AK: Clone,
13    SK: StateLens<PS, CS>,
14{
15    pub state_kp: SK,
16    pub action_kp: AK,
17    pub cancel_id: EffectId,
18    pub child: R,
19    _marker: PhantomData<(PA, CA, PS, CS)>,
20}
21
22impl<R, PS: 'static, PA: 'static, CS: 'static, CA: 'static, AK: 'static, SK>
23    ScopeReducer<R, PS, PA, CS, CA, AK, SK>
24where
25    AK: Clone,
26    SK: StateLens<PS, CS>,
27{
28    pub fn new(state_kp: SK, action_kp: AK, cancel_id: EffectId, child: R) -> Self {
29        Self {
30            state_kp,
31            action_kp,
32            cancel_id,
33            child,
34            _marker: PhantomData,
35        }
36    }
37}
38
39impl<R, PS: 'static, PA: 'static, CS: 'static, CA: 'static, AK: 'static, SK> Reducer
40    for ScopeReducer<R, PS, PA, CS, CA, AK, SK>
41where
42    R: Reducer<State = CS, Action = CA>,
43    PA: Send + 'static,
44    CA: Clone + Send + 'static,
45    AK: Casepath<PA, CA> + Clone + Send + Sync + 'static,
46    SK: StateLens<PS, CS>,
47{
48    type State = PS;
49    type Action = PA;
50
51    fn reduce(&self, state: &mut PS, action: PA) -> Cmd<PA> {
52        let Some(child_action) = self.action_kp.extract(&action) else {
53            return Cmd::none();
54        };
55        let Some(child) = self.state_kp.focus_mut(state) else {
56            return Cmd::none();
57        };
58        let cmd = self.child.reduce(child, child_action);
59        lift_cmd(cmd, self.action_kp.clone(), self.cancel_id)
60    }
61}
62
63/// `ifLet` — run child reducer when optional state is `Some`; cancel on dismiss.
64#[derive(Debug)]
65pub struct IfLetReducer<R, PS: 'static, PA: 'static, CS: 'static, CA: 'static, AK: 'static, SK>
66where
67    AK: Clone,
68    SK: StateLens<PS, CS>,
69{
70    pub scope: ScopeReducer<R, PS, PA, CS, CA, AK, SK>,
71    pub dismiss: fn(PA) -> bool,
72    pub clear: fn(&mut PS),
73    _marker: PhantomData<(PA, CA, PS, CS)>,
74}
75
76impl<R, PS: 'static, PA: 'static, CS: 'static, CA: 'static, AK: 'static, SK>
77    IfLetReducer<R, PS, PA, CS, CA, AK, SK>
78where
79    AK: Clone,
80    SK: StateLens<PS, CS>,
81{
82    pub fn new(
83        state_kp: SK,
84        action_kp: AK,
85        dismiss: fn(PA) -> bool,
86        clear: fn(&mut PS),
87        cancel_id: EffectId,
88        child: R,
89    ) -> Self {
90        Self {
91            scope: ScopeReducer::new(state_kp, action_kp, cancel_id, child),
92            dismiss,
93            clear,
94            _marker: PhantomData,
95        }
96    }
97}
98
99impl<R, PS: 'static, PA: 'static, CS: 'static, CA: 'static, AK: 'static, SK> Reducer
100    for IfLetReducer<R, PS, PA, CS, CA, AK, SK>
101where
102    R: Reducer<State = CS, Action = CA>,
103    PA: Clone + Send + 'static,
104    CA: Clone + Send + 'static,
105    AK: Casepath<PA, CA> + Clone + Send + Sync + 'static,
106    SK: StateLens<PS, CS>,
107{
108    type State = PS;
109    type Action = PA;
110
111    fn reduce(&self, state: &mut PS, action: PA) -> Cmd<PA> {
112        if (self.dismiss)(action.clone()) {
113            let had_child = self.scope.state_kp.focus_mut(state).is_some();
114            (self.clear)(state);
115            if had_child {
116                return Cmd::single(Effect::cancel(self.scope.cancel_id));
117            }
118            return Cmd::none();
119        }
120        self.scope.reduce(state, action)
121    }
122}
123
124/// `ifCaseLet` — `ifLet` for enum-variant child state (same mechanics, enum-focused accessors).
125pub type IfCaseLetReducer<R, PS, PA, CS, CA, SK> =
126    IfLetReducer<R, PS, PA, CS, CA, CasePath<'static, PA, CA>, SK>;
127
128/// `optional` — scope that no-ops when child state is absent (no dismiss/cancel).
129pub type OptionalReducer<R, PS, PA, CS, CA, SK> =
130    ScopeReducer<R, PS, PA, CS, CA, CasePath<'static, PA, CA>, SK>;
131
132/// Run a child reducer for each element in an [`IdentifiedVec`].
133#[derive(Clone, Debug)]
134pub struct ForEachReducer<R, PS, PA, CS, CA, Id> {
135    pub get_vec: fn(&mut PS) -> &mut IdentifiedVec<Id, CS>,
136    pub embed: fn(Id, CA) -> PA,
137    pub extract: fn(PA) -> Option<(Id, CA)>,
138    pub extract_remove: fn(PA) -> Option<Id>,
139    pub cancel_id: fn(Id) -> EffectId,
140    pub child: R,
141}
142
143impl<R, PS, PA, CS, CA, Id> ForEachReducer<R, PS, PA, CS, CA, Id> {
144    pub fn new(
145        get_vec: fn(&mut PS) -> &mut IdentifiedVec<Id, CS>,
146        embed: fn(Id, CA) -> PA,
147        extract: fn(PA) -> Option<(Id, CA)>,
148        extract_remove: fn(PA) -> Option<Id>,
149        cancel_id: fn(Id) -> EffectId,
150        child: R,
151    ) -> Self {
152        Self {
153            get_vec,
154            embed,
155            extract,
156            extract_remove,
157            cancel_id,
158            child,
159        }
160    }
161}
162
163impl<R, PS, PA, CS, CA, Id> Reducer for ForEachReducer<R, PS, PA, CS, CA, Id>
164where
165    R: Reducer<State = CS, Action = CA>,
166    CS: Identifiable<Id = Id>,
167    Id: Copy + Eq + std::hash::Hash + Send + Sync + 'static,
168    PA: Clone + Send + 'static,
169    CA: Send + 'static,
170{
171    type State = PS;
172    type Action = PA;
173
174    fn reduce(&self, state: &mut PS, action: PA) -> Cmd<PA> {
175        if let Some(id) = (self.extract_remove)(action.clone()) {
176            let vec = (self.get_vec)(state);
177            if vec.remove(id).is_some() {
178                return Cmd::single(Effect::cancel((self.cancel_id)(id)));
179            }
180            return Cmd::none();
181        }
182
183        let Some((id, child_action)) = (self.extract)(action) else {
184            return Cmd::none();
185        };
186        let cancel_id = (self.cancel_id)(id);
187        let Some(child) = (self.get_vec)(state).get_mut(id) else {
188            return Cmd::none();
189        };
190        let cmd = self.child.reduce(child, child_action);
191        lift_cmd_with_id(cmd, self.embed, id, cancel_id)
192    }
193}
194
195/// Lift child command into parent action space with a per-id embed fn.
196pub fn lift_cmd_with_id<PA, CA, Id>(
197    cmd: Cmd<CA>,
198    embed: fn(Id, CA) -> PA,
199    id: Id,
200    cancel_id: EffectId,
201) -> Cmd<PA>
202where
203    CA: Send + 'static,
204    PA: Send + 'static,
205    Id: Copy + Send + Sync + 'static,
206{
207    match cmd {
208        Cmd::None => Cmd::None,
209        Cmd::Single(effect) => Cmd::Single(tag_cancel_id(
210            map_effect_with_id(effect, embed, id),
211            cancel_id,
212        )),
213        Cmd::Batch(cmds) => Cmd::Batch(
214            cmds.into_iter()
215                .map(|c| lift_cmd_with_id(c, embed, id, cancel_id))
216                .collect(),
217        ),
218    }
219}
220
221fn map_effect_with_id<PA, CA, Id>(
222    effect: Effect<CA>,
223    embed: fn(Id, CA) -> PA,
224    id: Id,
225) -> Effect<PA>
226where
227    CA: Send + 'static,
228    PA: Send + 'static,
229    Id: Copy + Send + Sync + 'static,
230{
231    match effect {
232        Effect::None => Effect::None,
233        Effect::Task { run, .. } => Effect::from_fn(move || {
234            let fut = run();
235            Box::pin(async move { fut.await.map(|a| embed(id, a)) })
236        }),
237        Effect::RegisteredTask { id: task_id } => Effect::from_fn(move || {
238            let fut = run_registered_task::<CA>(task_id);
239            Box::pin(async move { fut.await.map(|a| embed(id, a)) })
240        }),
241        Effect::EnvTask { run, .. } => Effect::from_env_fn(move |env| {
242            let fut = run(env);
243            Box::pin(async move { fut.await.map(|a| embed(id, a)) })
244        }),
245        Effect::RegisteredEnvTask { id: task_id } => Effect::from_env_fn(move |env| {
246            let fut = run_registered_env_task::<CA>(&env, task_id);
247            Box::pin(async move { fut.await.map(|a| embed(id, a)) })
248        }),
249        Effect::Batch(items) => Effect::Batch(
250            items
251                .into_iter()
252                .map(|e| map_effect_with_id(e, embed, id))
253                .collect(),
254        ),
255        Effect::Cancellable {
256            id: cid,
257            cancel_in_flight,
258            inner,
259        } => Effect::Cancellable {
260            id: cid,
261            cancel_in_flight,
262            inner: Box::new(map_effect_with_id(*inner, embed, id)),
263        },
264        Effect::Debounce {
265            id: did,
266            duration,
267            inner,
268        } => Effect::Debounce {
269            id: did,
270            duration,
271            inner: Box::new(map_effect_with_id(*inner, embed, id)),
272        },
273        Effect::Throttle {
274            id: tid,
275            duration,
276            latest,
277            inner,
278        } => Effect::Throttle {
279            id: tid,
280            duration,
281            latest,
282            inner: Box::new(map_effect_with_id(*inner, embed, id)),
283        },
284        Effect::RegisteredRun { id: run_id } => Effect::RegisteredRun { id: run_id },
285        Effect::Provide { env, inner } => Effect::Provide {
286            env,
287            inner: Box::new(map_effect_with_id(*inner, embed, id)),
288        },
289        Effect::Retry { attempts, inner } => Effect::Retry {
290            attempts,
291            inner: Box::new(map_effect_with_id(*inner, embed, id)),
292        },
293        Effect::Timeout { duration, inner } => Effect::Timeout {
294            duration,
295            inner: Box::new(map_effect_with_id(*inner, embed, id)),
296        },
297        Effect::Sequence(items) => Effect::Sequence(
298            items
299                .into_iter()
300                .map(|e| map_effect_with_id(e, embed, id))
301                .collect(),
302        ),
303        Effect::Race(items) => Effect::Race(
304            items
305                .into_iter()
306                .map(|e| map_effect_with_id(e, embed, id))
307                .collect(),
308        ),
309        Effect::Catch { inner, recover: _ } => map_effect_with_id(*inner, embed, id),
310        Effect::Cancel { id: cancel_id } => Effect::Cancel { id: cancel_id },
311    }
312}
313
314/// Lift child command into parent action space and tag effects with a cancel id.
315pub fn lift_cmd<PA, CA, CP>(cmd: Cmd<CA>, casepath: CP, cancel_id: EffectId) -> Cmd<PA>
316where
317    CA: Send + 'static,
318    PA: Send + 'static,
319    CP: Casepath<PA, CA> + Clone + Send + Sync + 'static,
320{
321    match cmd {
322        Cmd::None => Cmd::None,
323        Cmd::Single(effect) => Cmd::Single(tag_cancel_id(
324            map_effect_with_casepath(effect, casepath),
325            cancel_id,
326        )),
327        Cmd::Batch(cmds) => Cmd::Batch(
328            cmds.into_iter()
329                .map(|c| lift_cmd(c, casepath.clone(), cancel_id))
330                .collect(),
331        ),
332    }
333}
334
335fn map_effect_with_casepath<PA, CA, CP>(effect: Effect<CA>, casepath: CP) -> Effect<PA>
336where
337    CA: Send + 'static,
338    PA: Send + 'static,
339    CP: Casepath<PA, CA> + Clone + Send + Sync + 'static,
340{
341    match effect {
342        Effect::None => Effect::None,
343        Effect::Task { run, .. } => {
344            let casepath = casepath.clone();
345            Effect::from_fn(move || {
346                let casepath = casepath.clone();
347                let fut = run();
348                Box::pin(async move { fut.await.map(|a| casepath.wrap(a)) })
349            })
350        }
351        Effect::RegisteredTask { id: task_id } => {
352            let casepath = casepath.clone();
353            Effect::from_fn(move || {
354                let casepath = casepath.clone();
355                let fut = run_registered_task::<CA>(task_id);
356                Box::pin(async move { fut.await.map(|a| casepath.wrap(a)) })
357            })
358        }
359        Effect::EnvTask { run, .. } => {
360            let casepath = casepath.clone();
361            Effect::from_env_fn(move |env| {
362                let casepath = casepath.clone();
363                let fut = run(env);
364                Box::pin(async move { fut.await.map(|a| casepath.wrap(a)) })
365            })
366        }
367        Effect::RegisteredEnvTask { id: task_id } => {
368            let casepath = casepath.clone();
369            Effect::from_env_fn(move |env| {
370                let casepath = casepath.clone();
371                let fut = run_registered_env_task::<CA>(&env, task_id);
372                Box::pin(async move { fut.await.map(|a| casepath.wrap(a)) })
373            })
374        }
375        Effect::Batch(items) => Effect::Batch(
376            items
377                .into_iter()
378                .map(|e| map_effect_with_casepath(e, casepath.clone()))
379                .collect(),
380        ),
381        Effect::Cancellable {
382            id: cid,
383            cancel_in_flight,
384            inner,
385        } => Effect::Cancellable {
386            id: cid,
387            cancel_in_flight,
388            inner: Box::new(map_effect_with_casepath(*inner, casepath.clone())),
389        },
390        Effect::Debounce {
391            id: did,
392            duration,
393            inner,
394        } => Effect::Debounce {
395            id: did,
396            duration,
397            inner: Box::new(map_effect_with_casepath(*inner, casepath.clone())),
398        },
399        Effect::Throttle {
400            id: tid,
401            duration,
402            latest,
403            inner,
404        } => Effect::Throttle {
405            id: tid,
406            duration,
407            latest,
408            inner: Box::new(map_effect_with_casepath(*inner, casepath.clone())),
409        },
410        Effect::RegisteredRun { id: run_id } => Effect::RegisteredRun { id: run_id },
411        Effect::Provide { env, inner } => Effect::Provide {
412            env,
413            inner: Box::new(map_effect_with_casepath(*inner, casepath.clone())),
414        },
415        Effect::Retry { attempts, inner } => Effect::Retry {
416            attempts,
417            inner: Box::new(map_effect_with_casepath(*inner, casepath.clone())),
418        },
419        Effect::Timeout { duration, inner } => Effect::Timeout {
420            duration,
421            inner: Box::new(map_effect_with_casepath(*inner, casepath.clone())),
422        },
423        Effect::Sequence(items) => Effect::Sequence(
424            items
425                .into_iter()
426                .map(|e| map_effect_with_casepath(e, casepath.clone()))
427                .collect(),
428        ),
429        Effect::Race(items) => Effect::Race(
430            items
431                .into_iter()
432                .map(|e| map_effect_with_casepath(e, casepath.clone()))
433                .collect(),
434        ),
435        Effect::Catch { inner, recover: _ } => map_effect_with_casepath(*inner, casepath),
436        Effect::Cancel { id: cancel_id } => Effect::Cancel { id: cancel_id },
437    }
438}
439
440fn tag_cancel_id<M>(effect: Effect<M>, cancel_id: EffectId) -> Effect<M> {
441    match effect {
442        Effect::None => Effect::None,
443        Effect::Task { run, .. } => Effect::Task { id: cancel_id, run },
444        Effect::RegisteredTask { .. } | Effect::EnvTask { .. } | Effect::RegisteredEnvTask { .. }
445        | Effect::RegisteredRun { .. } => Effect::Cancellable {
446            id: cancel_id,
447            cancel_in_flight: true,
448            inner: Box::new(tag_cancel_id(effect, cancel_id)),
449        },
450        Effect::Batch(items) => {
451            Effect::Batch(items.into_iter().map(|e| tag_cancel_id(e, cancel_id)).collect())
452        }
453        Effect::Cancellable {
454            id,
455            cancel_in_flight,
456            inner,
457        } => Effect::Cancellable {
458            id,
459            cancel_in_flight,
460            inner: Box::new(tag_cancel_id(*inner, cancel_id)),
461        },
462        Effect::Debounce {
463            id,
464            duration,
465            inner,
466        } => Effect::Debounce {
467            id,
468            duration,
469            inner: Box::new(tag_cancel_id(*inner, cancel_id)),
470        },
471        Effect::Throttle {
472            id,
473            duration,
474            latest,
475            inner,
476        } => Effect::Throttle {
477            id,
478            duration,
479            latest,
480            inner: Box::new(tag_cancel_id(*inner, cancel_id)),
481        },
482        Effect::Provide { env, inner } => Effect::Provide {
483            env,
484            inner: Box::new(tag_cancel_id(*inner, cancel_id)),
485        },
486        Effect::Retry { attempts, inner } => Effect::Retry {
487            attempts,
488            inner: Box::new(tag_cancel_id(*inner, cancel_id)),
489        },
490        Effect::Timeout { duration, inner } => Effect::Timeout {
491            duration,
492            inner: Box::new(tag_cancel_id(*inner, cancel_id)),
493        },
494        Effect::Sequence(items) => Effect::Sequence(
495            items
496                .into_iter()
497                .map(|e| tag_cancel_id(e, cancel_id))
498                .collect(),
499        ),
500        Effect::Race(items) => {
501            Effect::Race(items.into_iter().map(|e| tag_cancel_id(e, cancel_id)).collect())
502        }
503        Effect::Catch { inner, recover } => Effect::Catch {
504            inner: Box::new(tag_cancel_id(*inner, cancel_id)),
505            recover,
506        },
507        Effect::Cancel { .. } => effect,
508    }
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514    use crate::effect::Effect;
515    use crate::optics::CasePath;
516    use key_paths_derive::{Cp, Kp};
517    use rust_identified_vec::IdentifiedVec;
518    use crate::reducer::Reduce;
519
520    #[derive(Default, Debug, PartialEq, Eq, Kp, Cp)]
521    struct Child {
522        n: i32,
523    }
524
525    #[derive(Default, Debug, PartialEq, Eq, Kp)]
526    struct Parent {
527        child: Option<Child>,
528    }
529
530    #[derive(Clone, Debug, PartialEq, Eq, Kp, Cp)]
531    enum ParentAction {
532        Child(ChildAction),
533        Dismiss,
534        Other,
535    }
536
537    #[derive(Clone, Debug, PartialEq, Eq, Kp)]
538    enum ChildAction {
539        Inc(String),
540    }
541
542    fn child_reducer(s: &mut Child, a: ChildAction) -> Cmd<ChildAction> {
543        match a {
544            ChildAction::Inc(_) => s.n += 1,
545        }
546        Cmd::none()
547    }
548
549    // fn child_kp() -> rust_key_paths::KpType<'static, Parent, Child> {
550    //     fn get(p: &Parent) -> Option<&Child> {
551    //         p.child.as_ref()
552    //     }
553    //     fn get_mut(p: &mut Parent) -> Option<&mut Child> {
554    //         p.child.as_mut()
555    //     }
556    //     rust_key_paths::Kp::new(get, get_mut)
557    // }
558
559    fn child_action_kp() -> CasePath<'static, ParentAction, ChildAction> {
560        ParentAction::child_cp()
561    }
562
563    fn dismiss(a: ParentAction) -> bool {
564        matches!(a, ParentAction::Dismiss)
565    }
566
567    fn clear(p: &mut Parent) {
568        p.child = None;
569    }
570
571    #[test]
572    fn scope_accepts_derived_state_kp() {
573        let n_kp = Child::n_cp();
574        assert_eq!(n_kp.get_ref(&Child { n: 42 }), Some(&42));
575
576        let scope = ScopeReducer::new(
577            Parent::child(),
578            ParentAction::child_cp(),
579            1,
580            Reduce::new(child_reducer),
581        );
582        let mut parent = Parent {
583            child: Some(Child { n: 0 }),
584        };
585        scope.reduce(&mut parent, ParentAction::Child(ChildAction::Inc("Akash Soni".to_string())));
586        assert_eq!(parent.child.as_ref().unwrap().n, 1);
587    }
588
589    #[test]
590    fn scope_isolates_child_state() {
591        let scope = ScopeReducer::new(
592           Parent::child(),
593            ParentAction::child_cp(),
594            1,
595            Reduce::new(child_reducer),
596        );
597        let mut parent = Parent {
598            child: Some(Child { n: 0 }),
599        };
600        scope.reduce(&mut parent, ParentAction::Child(ChildAction::Inc("Akash Soni".to_string())));
601        assert_eq!(parent.child.as_ref().unwrap().n, 1);
602        scope.reduce(&mut parent, ParentAction::Other);
603        assert_eq!(parent.child.as_ref().unwrap().n, 1);
604    }
605
606    #[test]
607    fn if_let_dismiss_cancels_and_clears() {
608        let if_let = IfLetReducer::new(
609            Parent::child(),
610            ParentAction::child_cp(),
611            dismiss,
612            clear,
613            42,
614            Reduce::new(child_reducer),
615        );
616        let mut parent = Parent {
617            child: Some(Child { n: 0 }),
618        };
619        let cmd = if_let.reduce(&mut parent, ParentAction::Dismiss);
620        assert!(parent.child.is_none());
621        assert_eq!(cmd.effect_count(), 1);
622        assert!(matches!(
623            cmd.into_effects().first(),
624            Some(Effect::Cancel { id: 42 })
625        ));
626    }
627
628    #[derive(Debug, Clone, PartialEq, Eq)]
629    struct Row {
630        id: u64,
631        n: i32,
632    }
633
634    impl Identifiable for Row {
635        type Id = u64;
636        fn id(&self) -> u64 {
637            self.id
638        }
639    }
640
641    #[derive(Default, Debug, PartialEq, Eq)]
642    struct ListParent {
643        rows: IdentifiedVec<u64, Row>,
644    }
645
646    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
647    enum ListAction {
648        Row(u64, RowAction),
649        Remove(u64),
650    }
651
652    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
653    enum RowAction {
654        Bump,
655    }
656
657    fn row_reducer(r: &mut Row, a: RowAction) -> Cmd<RowAction> {
658        if matches!(a, RowAction::Bump) {
659            r.n += 1;
660        }
661        Cmd::none()
662    }
663
664    fn get_vec(p: &mut ListParent) -> &mut IdentifiedVec<u64, Row> {
665        &mut p.rows
666    }
667
668    fn embed_row(id: u64, a: RowAction) -> ListAction {
669        ListAction::Row(id, a)
670    }
671
672    fn extract_row(a: ListAction) -> Option<(u64, RowAction)> {
673        match a {
674            ListAction::Row(id, action) => Some((id, action)),
675            _ => None,
676        }
677    }
678
679    fn extract_remove(a: ListAction) -> Option<u64> {
680        match a {
681            ListAction::Remove(id) => Some(id),
682            _ => None,
683        }
684    }
685
686    fn cancel_for_id(id: u64) -> EffectId {
687        1000 + id
688    }
689
690    #[test]
691    fn for_each_scopes_by_id() {
692        let for_each = ForEachReducer::new(
693            get_vec,
694            embed_row,
695            extract_row,
696            extract_remove,
697            cancel_for_id,
698            Reduce::new(row_reducer),
699        );
700        let mut parent = ListParent::default();
701        parent.rows.insert(Row { id: 1, n: 0 });
702        parent.rows.insert(Row { id: 2, n: 0 });
703        for_each.reduce(&mut parent, ListAction::Row(1, RowAction::Bump));
704        assert_eq!(parent.rows.get(1).unwrap().n, 1);
705        assert_eq!(parent.rows.get(2).unwrap().n, 0);
706    }
707
708    #[test]
709    fn for_each_remove_emits_cancel() {
710        let for_each = ForEachReducer::new(
711            get_vec,
712            embed_row,
713            extract_row,
714            extract_remove,
715            cancel_for_id,
716            Reduce::new(row_reducer),
717        );
718        let mut parent = ListParent::default();
719        parent.rows.insert(Row { id: 1, n: 0 });
720        let cmd = for_each.reduce(&mut parent, ListAction::Remove(1));
721        assert!(parent.rows.get(1).is_none());
722        assert!(matches!(
723            cmd.into_effects().first(),
724            Some(Effect::Cancel { id: 1001 })
725        ));
726    }
727}