Skip to main content

fission_core/
registry.rs

1use crate::{
2    action::video::{
3        VideoPause, VideoPlay, VideoSeek, VideoSetMuted, VideoSetRate, VideoSetVolume, VideoStop,
4    },
5    async_runtime::{
6        JobRef, JobRequestPayload, JobSpec, ServiceBindings, ServiceSlot, ServiceSpec,
7        ServiceStartPayload,
8    },
9    context::{Effects, ReducerContext},
10    effect::{ActionInput, Effect, EffectEnvelope},
11    ui::{VideoAudioOptions, Widget},
12    Action, ActionEnvelope, ActionId, BoxedReducer, GlobalState,
13};
14use anyhow::{anyhow, Result};
15use fission_diagnostics::prelude as diag;
16use fission_ir::WidgetId;
17use serde::Serialize;
18use std::any::TypeId;
19use std::collections::{BTreeMap, BTreeSet, HashMap};
20use std::error::Error;
21use std::fmt;
22
23#[derive(Debug)]
24pub(crate) struct ActionDeserializationError;
25
26impl ActionDeserializationError {
27    pub(crate) fn new(_source: serde_json::Error) -> Self {
28        Self
29    }
30}
31
32impl fmt::Display for ActionDeserializationError {
33    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
34        formatter.write_str("failed to deserialize action payload")
35    }
36}
37
38impl Error for ActionDeserializationError {}
39
40pub(crate) fn emit_action_dispatch_failure(
41    action_id: ActionId,
42    target: WidgetId,
43    error: &anyhow::Error,
44) {
45    let failure_kind = if error.downcast_ref::<ActionDeserializationError>().is_some() {
46        "action_deserialization"
47    } else {
48        "reducer_dispatch"
49    };
50    #[cfg(not(target_arch = "wasm32"))]
51    eprintln!(
52        "{}",
53        action_dispatch_failure_message(action_id, target, failure_kind)
54    );
55    diag::emit(
56        diag::DiagCategory::Input,
57        diag::DiagLevel::Error,
58        diag::DiagEventKind::ActionDispatchFailed {
59            action_id: action_id.as_u128(),
60            target: target.as_u128(),
61            failure_kind: failure_kind.into(),
62        },
63    );
64}
65
66#[cfg(any(not(target_arch = "wasm32"), test))]
67fn action_dispatch_failure_message(
68    action_id: ActionId,
69    target: WidgetId,
70    failure_kind: &'static str,
71) -> String {
72    format!(
73        "Fission action dispatch failed: action_id={} target={} failure_kind={failure_kind}",
74        action_id.as_u128(),
75        target.as_u128()
76    )
77}
78
79/// The canonical 3-argument handler signature for modern reducers.
80///
81/// ```rust,ignore
82/// fn handle_increment(state: &mut Counter, _: Increment, _ctx: &mut ReducerContext<Counter>) {
83///     state.count += 1;
84/// }
85/// ```
86pub type Handler<S, A> = for<'a, 'b, 'c> fn(&mut S, A, &mut ReducerContext<'a, 'b, 'c, S>);
87
88/// Trait that allows both 2-argument (legacy) and 3-argument (modern) handler
89/// functions to be used with [`ActionRegistry::register`] and
90/// [`BuildCtxHandle::bind`](crate::build::BuildCtxHandle::bind).
91pub trait IntoHandler<S: GlobalState, A> {
92    /// Invoke the handler with the given state, action, and context.
93    fn call<'a, 'b, 'c>(&self, state: &mut S, action: A, ctx: &mut ReducerContext<'a, 'b, 'c, S>);
94}
95
96// Impl for Legacy (2-arg)
97impl<S: GlobalState, A> IntoHandler<S, A> for fn(&mut S, A) {
98    fn call<'a, 'b, 'c>(&self, state: &mut S, action: A, _ctx: &mut ReducerContext<'a, 'b, 'c, S>) {
99        (self)(state, action);
100    }
101}
102
103// Impl for Modern (3-arg)
104impl<S: GlobalState, A> IntoHandler<S, A>
105    for for<'a, 'b, 'c> fn(&mut S, A, &mut ReducerContext<'a, 'b, 'c, S>)
106{
107    fn call<'a, 'b, 'c>(&self, state: &mut S, action: A, ctx: &mut ReducerContext<'a, 'b, 'c, S>) {
108        (self)(state, action, ctx);
109    }
110}
111
112// Internal typed reducer storage
113type TypedReducer<S> = Box<
114    dyn for<'a, 'b, 'c> Fn(
115            &mut S,
116            &ActionEnvelope,
117            WidgetId,
118            &mut Effects<'a, S>,
119            &'b ActionInput,
120        ) -> Result<()>
121        + Send
122        + Sync,
123>;
124
125fn into_boxed_runtime_reducer<S: GlobalState>(
126    state_type_id: TypeId,
127    typed_reducer: TypedReducer<S>,
128) -> BoxedReducer {
129    Box::new(
130        move |app_states: &mut HashMap<TypeId, Box<dyn GlobalState>>,
131              action: &ActionEnvelope,
132              target: WidgetId,
133              out_effects: &mut Vec<EffectEnvelope>,
134              input: &ActionInput,
135              callback_registry|
136              -> Result<()> {
137            if let Some(state_box) = app_states.get_mut(&state_type_id) {
138                let concrete_state = state_box
139                    .downcast_mut::<S>()
140                    .ok_or_else(|| anyhow!("Failed to downcast GlobalState to concrete type"))?;
141
142                let mut effects_builder = Effects::new_runtime(0, callback_registry.clone());
143
144                typed_reducer(concrete_state, action, target, &mut effects_builder, input)?;
145
146                out_effects.extend(effects_builder.out);
147
148                Ok(())
149            } else {
150                anyhow::bail!("Target GlobalState for reducer not found in runtime.");
151            }
152        },
153    )
154}
155
156/// A per-frame collection of action handlers registered during widget building.
157///
158/// `ActionRegistry` is populated by [`BuildCtxHandle::bind`](crate::build::BuildCtxHandle::bind)
159/// calls. After the widget
160/// tree is built, the registry is absorbed into the [`Runtime`](crate::Runtime)
161/// via [`Runtime::absorb_registry`](crate::Runtime::absorb_registry).
162pub struct ActionRegistry<S: GlobalState> {
163    handlers: BTreeMap<ActionId, Vec<TypedReducer<S>>>,
164    bound_handlers: BTreeMap<ActionId, TypedReducer<S>>,
165    runtime_handlers: BTreeMap<ActionId, BoxedReducer>,
166}
167
168impl<S: GlobalState> Default for ActionRegistry<S> {
169    fn default() -> Self {
170        Self {
171            handlers: BTreeMap::new(),
172            bound_handlers: BTreeMap::new(),
173            runtime_handlers: BTreeMap::new(),
174        }
175    }
176}
177
178impl<S: GlobalState> ActionRegistry<S> {
179    pub fn new() -> Self {
180        Self::default()
181    }
182
183    /// Registers an explicit handler for an action type.
184    ///
185    /// Every registered handler runs when the action is dispatched. Declarative
186    /// widget bindings use a separate exactly-once path; call `register` only
187    /// when multicast handling is intentional.
188    pub fn register<A: Action, H: IntoHandler<S, A> + Send + Sync + 'static>(
189        &mut self,
190        handler: H,
191    ) {
192        self.register_with_id(A::static_id(), handler);
193    }
194
195    pub(crate) fn register_with_id<A: Action, H: IntoHandler<S, A> + Send + Sync + 'static>(
196        &mut self,
197        action_id: ActionId,
198        handler: H,
199    ) {
200        self.handlers
201            .entry(action_id)
202            .or_default()
203            .push(Self::typed_reducer(handler));
204    }
205
206    pub(crate) fn register_bound<A: Action, H: IntoHandler<S, A> + Send + Sync + 'static>(
207        &mut self,
208        handler: H,
209    ) {
210        self.bound_handlers
211            .entry(A::static_id())
212            .or_insert_with(|| Self::typed_reducer(handler));
213    }
214
215    fn typed_reducer<A: Action, H: IntoHandler<S, A> + Send + Sync + 'static>(
216        handler: H,
217    ) -> TypedReducer<S> {
218        Box::new(
219            move |state: &mut S,
220                  envelope: &ActionEnvelope,
221                  _target,
222                  effects,
223                  input|
224                  -> Result<()> {
225                let action: A = serde_json::from_slice(&envelope.payload)
226                    .map_err(ActionDeserializationError::new)?;
227
228                let mut ctx = ReducerContext { effects, input };
229
230                handler.call(state, action, &mut ctx);
231                Ok(())
232            },
233        )
234    }
235
236    pub fn action_ids(&self) -> Vec<ActionId> {
237        self.handlers
238            .keys()
239            .chain(self.bound_handlers.keys())
240            .chain(self.runtime_handlers.keys())
241            .copied()
242            .collect::<BTreeSet<_>>()
243            .into_iter()
244            .collect()
245    }
246
247    pub(crate) fn register_runtime_reducer(&mut self, action_id: ActionId, reducer: BoxedReducer) {
248        self.runtime_handlers.entry(action_id).or_insert(reducer);
249    }
250
251    pub fn dispatch_with_input(
252        &mut self,
253        state: &mut S,
254        action: &ActionEnvelope,
255        target: WidgetId,
256        input: &ActionInput,
257    ) -> Result<Vec<EffectEnvelope>> {
258        let mut effects_builder = Effects::new_headless(0);
259        let target: WidgetId = target.into();
260        let dispatch_result = (|| {
261            if let Some(reducers) = self.handlers.get_mut(&action.id) {
262                reducers.iter_mut().try_for_each(|reducer| {
263                    reducer(state, action, target, &mut effects_builder, input)
264                })?;
265            }
266            if let Some(reducer) = self.bound_handlers.get_mut(&action.id) {
267                reducer(state, action, target, &mut effects_builder, input)?;
268            }
269            Ok(())
270        })();
271        if let Err(error) = dispatch_result {
272            emit_action_dispatch_failure(action.id, target, &error);
273            return Err(error);
274        }
275        Ok(effects_builder.out)
276    }
277
278    pub fn dispatch(
279        &mut self,
280        state: &mut S,
281        action: &ActionEnvelope,
282        target: WidgetId,
283    ) -> Result<Vec<EffectEnvelope>> {
284        self.dispatch_with_input(state, action, target, &ActionInput::None)
285    }
286
287    pub(crate) fn into_runtime_reducers(self) -> HashMap<ActionId, Vec<BoxedReducer>> {
288        let mut runtime_reducers: HashMap<ActionId, Vec<BoxedReducer>> = HashMap::new();
289        let state_type_id = TypeId::of::<S>();
290
291        for (action_id, reducer) in self.runtime_handlers {
292            runtime_reducers.entry(action_id).or_default().push(reducer);
293        }
294
295        for (action_id, typed_reducers) in self.handlers {
296            for typed_reducer in typed_reducers {
297                runtime_reducers
298                    .entry(action_id)
299                    .or_default()
300                    .push(into_boxed_runtime_reducer(state_type_id, typed_reducer));
301            }
302        }
303
304        for (action_id, typed_reducer) in self.bound_handlers {
305            runtime_reducers
306                .entry(action_id)
307                .or_default()
308                .push(into_boxed_runtime_reducer(state_type_id, typed_reducer));
309        }
310        runtime_reducers
311    }
312}
313
314/// Registration data for a [`Video`](crate::ui::Video) widget collected during
315/// widget building.
316#[derive(Clone, Debug)]
317pub struct VideoRegistration {
318    /// The stable widget identity of the video node.
319    pub node_id: WidgetId,
320    /// URL or asset path to the video file.
321    pub source: String,
322    /// Whether to start playing automatically.
323    pub autoplay: bool,
324    /// Whether to loop playback.
325    pub loop_playback: bool,
326    /// Audio-session behavior requested by this video.
327    pub audio: VideoAudioOptions,
328}
329
330/// Registration data for a platform web view collected during widget building.
331#[derive(Clone, Debug)]
332pub struct WebRegistration {
333    /// The stable widget identity of the web view node.
334    pub node_id: WidgetId,
335    /// The URL to load.
336    pub url: String,
337    /// Optional custom user-agent string.
338    pub user_agent: Option<String>,
339}
340
341/// Z-order layer for portal entries.
342///
343/// Portals are sorted by layer (then by registration order within a layer).
344/// Higher layers paint on top of lower layers.
345#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
346pub enum PortalLayer {
347    /// Default overlay layer.
348    Default = 0,
349    /// Modal dialog layer.
350    Modal = 100,
351    /// Flyout / dropdown layer.
352    Flyout = 200,
353    /// Toast notification layer (topmost).
354    Toast = 300,
355}
356
357/// An entry in the portal overlay stack.
358///
359/// Created by [`crate::internal::BuildCtx::register_portal`] and friends.
360#[derive(Clone, Debug)]
361pub struct PortalEntry {
362    /// Which overlay layer this portal belongs to.
363    pub layer: PortalLayer,
364    /// Insertion order (for stable ordering within a layer).
365    pub seq: u64,
366    /// Optional stable identity.
367    pub id: Option<WidgetId>,
368    /// The portal's widget tree.
369    pub node: Widget,
370}
371
372/// The mutable context available during `impl From<Component> for Widget`.
373///
374#[derive(Clone, Copy)]
375pub struct VideoControlCtx {
376    pub(crate) target: WidgetId,
377}
378
379impl VideoControlCtx {
380    pub fn play(&self) -> ActionEnvelope {
381        let action = VideoPlay {
382            target: self.target,
383        };
384        ActionEnvelope {
385            id: VideoPlay::static_id(),
386            payload: action.encode(),
387        }
388    }
389
390    pub fn pause(&self) -> ActionEnvelope {
391        let action = VideoPause {
392            target: self.target,
393        };
394        ActionEnvelope {
395            id: VideoPause::static_id(),
396            payload: action.encode(),
397        }
398    }
399
400    pub fn stop(&self) -> ActionEnvelope {
401        let action = VideoStop {
402            target: self.target,
403        };
404        ActionEnvelope {
405            id: VideoStop::static_id(),
406            payload: action.encode(),
407        }
408    }
409
410    pub fn seek_to(&self, position_ms: u64) -> ActionEnvelope {
411        let action = VideoSeek {
412            target: self.target,
413            position_ms,
414        };
415        ActionEnvelope {
416            id: VideoSeek::static_id(),
417            payload: action.encode(),
418        }
419    }
420
421    pub fn set_rate(&self, rate: f32) -> ActionEnvelope {
422        let action = VideoSetRate {
423            target: self.target,
424            rate,
425        };
426        ActionEnvelope {
427            id: VideoSetRate::static_id(),
428            payload: action.encode(),
429        }
430    }
431
432    pub fn set_volume(&self, volume: f32) -> ActionEnvelope {
433        let action = VideoSetVolume {
434            target: self.target,
435            volume,
436        };
437        ActionEnvelope {
438            id: VideoSetVolume::static_id(),
439            payload: action.encode(),
440        }
441    }
442
443    pub fn set_muted(&self, muted: bool) -> ActionEnvelope {
444        let action = VideoSetMuted {
445            target: self.target,
446            muted,
447        };
448        ActionEnvelope {
449            id: VideoSetMuted::static_id(),
450            payload: action.encode(),
451        }
452    }
453}
454
455#[derive(Clone, Debug, PartialEq, Eq, Hash)]
456pub struct ResourceKey(String);
457
458impl ResourceKey {
459    pub fn new(name: impl Into<String>) -> Self {
460        Self(name.into())
461    }
462
463    pub fn widget(name: impl AsRef<str>, id: WidgetId) -> Self {
464        Self(format!("widget:{}:{}", id.as_u128(), name.as_ref()))
465    }
466
467    pub fn as_str(&self) -> &str {
468        &self.0
469    }
470}
471
472#[derive(Clone, Copy, Debug, PartialEq, Eq)]
473pub enum ResourcePolicy {
474    PreserveOnChange,
475    RestartOnChange,
476}
477
478impl Default for ResourcePolicy {
479    fn default() -> Self {
480        Self::RestartOnChange
481    }
482}
483
484#[derive(Clone, Debug, PartialEq)]
485pub struct RuntimeResourceDeclaration {
486    pub key: String,
487    pub deps: Option<Vec<u8>>,
488    pub policy: ResourcePolicy,
489    pub kind: RuntimeResourceKind,
490}
491
492#[derive(Clone, Debug, PartialEq)]
493pub enum RuntimeResourceKind {
494    Job(JobResource),
495    Service(ServiceResource),
496    Timer(TimerResource),
497}
498
499#[derive(Clone, Debug, PartialEq)]
500pub struct JobResource {
501    pub key: ResourceKey,
502    pub effect: EffectEnvelope,
503    pub deps: Option<Vec<u8>>,
504    pub policy: ResourcePolicy,
505}
506
507impl JobResource {
508    pub fn new<J: JobSpec>(key: ResourceKey, job: JobRef<J>, request: J::Request) -> Self {
509        let payload =
510            serde_json::to_vec(&request).expect("job resource request serialization must succeed");
511        Self {
512            key,
513            effect: EffectEnvelope {
514                req_id: 0,
515                effect: Effect::Job(JobRequestPayload {
516                    job_name: job.name.to_string(),
517                    payload,
518                }),
519                on_ok: None,
520                on_err: None,
521                service_bindings: None,
522                resource: None,
523            },
524            deps: None,
525            policy: ResourcePolicy::RestartOnChange,
526        }
527    }
528
529    pub fn deps<T: Serialize>(mut self, deps: T) -> Self {
530        self.deps =
531            Some(serde_json::to_vec(&deps).expect("resource deps serialization must succeed"));
532        self
533    }
534
535    pub fn preserve_on_change(mut self) -> Self {
536        self.policy = ResourcePolicy::PreserveOnChange;
537        self
538    }
539
540    pub fn restart_on_change(mut self) -> Self {
541        self.policy = ResourcePolicy::RestartOnChange;
542        self
543    }
544
545    pub fn on_ok(mut self, action: ActionEnvelope) -> Self {
546        self.effect.on_ok = Some(action);
547        self
548    }
549
550    pub fn on_err(mut self, action: ActionEnvelope) -> Self {
551        self.effect.on_err = Some(action);
552        self
553    }
554}
555
556#[derive(Clone, Debug, PartialEq)]
557pub struct ServiceResource {
558    pub key: ResourceKey,
559    pub effect: EffectEnvelope,
560    pub deps: Option<Vec<u8>>,
561    pub policy: ResourcePolicy,
562}
563
564impl ServiceResource {
565    pub fn new<Svc: ServiceSpec>(
566        key: ResourceKey,
567        slot: ServiceSlot<Svc>,
568        config: Svc::Config,
569    ) -> Self {
570        let config = serde_json::to_vec(&config)
571            .expect("service resource config serialization must succeed");
572        Self {
573            key,
574            effect: EffectEnvelope {
575                req_id: 0,
576                effect: Effect::StartService(ServiceStartPayload {
577                    service_name: slot.ty.name.to_string(),
578                    slot_key: slot.slot_key().to_string(),
579                    config,
580                }),
581                on_ok: None,
582                on_err: None,
583                service_bindings: Some(ServiceBindings::default()),
584                resource: None,
585            },
586            deps: None,
587            policy: ResourcePolicy::RestartOnChange,
588        }
589    }
590
591    pub fn deps<T: Serialize>(mut self, deps: T) -> Self {
592        self.deps =
593            Some(serde_json::to_vec(&deps).expect("resource deps serialization must succeed"));
594        self
595    }
596
597    pub fn preserve_on_change(mut self) -> Self {
598        self.policy = ResourcePolicy::PreserveOnChange;
599        self
600    }
601
602    pub fn restart_on_change(mut self) -> Self {
603        self.policy = ResourcePolicy::RestartOnChange;
604        self
605    }
606
607    pub fn on_started(mut self, action: ActionEnvelope) -> Self {
608        if let Some(bindings) = self.effect.service_bindings.as_mut() {
609            bindings.on_started = Some(action);
610        }
611        self
612    }
613
614    pub fn on_start_failed(mut self, action: ActionEnvelope) -> Self {
615        if let Some(bindings) = self.effect.service_bindings.as_mut() {
616            bindings.on_start_failed = Some(action);
617        }
618        self
619    }
620
621    pub fn on_event(mut self, action: ActionEnvelope) -> Self {
622        if let Some(bindings) = self.effect.service_bindings.as_mut() {
623            bindings.on_event = Some(action);
624        }
625        self
626    }
627
628    pub fn on_stopped(mut self, action: ActionEnvelope) -> Self {
629        if let Some(bindings) = self.effect.service_bindings.as_mut() {
630            bindings.on_stopped = Some(action);
631        }
632        self
633    }
634
635    pub fn on_command_ok(mut self, action: ActionEnvelope) -> Self {
636        if let Some(bindings) = self.effect.service_bindings.as_mut() {
637            bindings.on_command_ok = Some(action);
638        }
639        self
640    }
641
642    pub fn on_command_err(mut self, action: ActionEnvelope) -> Self {
643        if let Some(bindings) = self.effect.service_bindings.as_mut() {
644            bindings.on_command_err = Some(action);
645        }
646        self
647    }
648}
649
650#[derive(Clone, Debug, PartialEq, Eq)]
651pub struct TimerResource {
652    pub key: ResourceKey,
653    pub interval_ms: u64,
654    pub payload: Vec<u8>,
655    pub on_tick: Option<ActionEnvelope>,
656    pub deps: Option<Vec<u8>>,
657    pub immediate: bool,
658    pub policy: ResourcePolicy,
659}
660
661impl TimerResource {
662    pub fn new<T: Serialize>(key: ResourceKey, interval: std::time::Duration, payload: T) -> Self {
663        Self {
664            key,
665            interval_ms: interval.as_millis() as u64,
666            payload: serde_json::to_vec(&payload)
667                .expect("timer resource payload serialization must succeed"),
668            on_tick: None,
669            deps: None,
670            immediate: false,
671            policy: ResourcePolicy::RestartOnChange,
672        }
673    }
674
675    pub fn deps<T: Serialize>(mut self, deps: T) -> Self {
676        self.deps =
677            Some(serde_json::to_vec(&deps).expect("resource deps serialization must succeed"));
678        self
679    }
680
681    pub fn preserve_on_change(mut self) -> Self {
682        self.policy = ResourcePolicy::PreserveOnChange;
683        self
684    }
685
686    pub fn restart_on_change(mut self) -> Self {
687        self.policy = ResourcePolicy::RestartOnChange;
688        self
689    }
690
691    pub fn immediate(mut self) -> Self {
692        self.immediate = true;
693        self
694    }
695
696    pub fn on_tick(mut self, action: ActionEnvelope) -> Self {
697        self.on_tick = Some(action);
698        self
699    }
700}
701
702#[derive(Default)]
703pub struct ResourceRegistry {
704    declarations: Vec<RuntimeResourceDeclaration>,
705    seen_keys: HashMap<String, usize>,
706}
707
708impl ResourceRegistry {
709    pub fn new() -> Self {
710        Self::default()
711    }
712
713    pub fn job(&mut self, resource: JobResource) {
714        self.push(RuntimeResourceDeclaration {
715            key: resource.key.as_str().to_string(),
716            deps: resource.deps.clone(),
717            policy: resource.policy,
718            kind: RuntimeResourceKind::Job(resource),
719        });
720    }
721
722    pub fn service(&mut self, resource: ServiceResource) {
723        self.push(RuntimeResourceDeclaration {
724            key: resource.key.as_str().to_string(),
725            deps: resource.deps.clone(),
726            policy: resource.policy,
727            kind: RuntimeResourceKind::Service(resource),
728        });
729    }
730
731    pub fn timer(&mut self, resource: TimerResource) {
732        self.push(RuntimeResourceDeclaration {
733            key: resource.key.as_str().to_string(),
734            deps: resource.deps.clone(),
735            policy: resource.policy,
736            kind: RuntimeResourceKind::Timer(resource),
737        });
738    }
739
740    pub fn take(&mut self) -> Vec<RuntimeResourceDeclaration> {
741        self.seen_keys.clear();
742        std::mem::take(&mut self.declarations)
743    }
744
745    fn push(&mut self, declaration: RuntimeResourceDeclaration) {
746        if let Some(index) = self.seen_keys.get(&declaration.key) {
747            panic!(
748                "duplicate runtime resource declaration for key '{}' at index {}",
749                declaration.key, index
750            );
751        }
752        let index = self.declarations.len();
753        self.seen_keys.insert(declaration.key.clone(), index);
754        self.declarations.push(declaration);
755    }
756}
757
758#[cfg(test)]
759mod dispatch_failure_tests {
760    use super::action_dispatch_failure_message;
761    use crate::{ActionId, WidgetId};
762
763    #[test]
764    fn fallback_message_contains_only_sanitized_dispatch_metadata() {
765        let message = action_dispatch_failure_message(
766            ActionId::from_u128(7),
767            WidgetId::from_u128(11),
768            "action_deserialization",
769        );
770
771        assert_eq!(
772            message,
773            "Fission action dispatch failed: action_id=7 target=11 failure_kind=action_deserialization"
774        );
775    }
776}