Skip to main content

rust_elm/compose/
scope.rs

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