Skip to main content

dioxus_hoverfx_core/
integration.rs

1use std::{
2    borrow::Cow,
3    collections::BTreeMap,
4    sync::{Arc, Mutex},
5    thread,
6};
7
8use serde::{Deserialize, Serialize};
9
10use crate::{
11    HOVERFX_PACKAGE_NAME, HOVERFX_PACKAGE_VERSION, HoverFxConfig, HoverFxDiagnosticVerbosity,
12    HoverFxFallbackStrategy, HoverFxManifestFragment, HoverFxOutputReport, HoverFxPreset,
13    HoverFxRoutePolicy, HoverFxSerializationFormat, explain_hoverfx, hoverfx_cache_key,
14    hoverfx_manifest_fragment,
15};
16
17pub const DEFAULT_HOVERFX_CONFIG_ID: &str = "__DXH_CONFIG__";
18pub const DEFAULT_HOVERFX_PREPAINT_STYLE_ID: &str = "__DXH_PREPAINT__";
19pub const DEFAULT_HOVERFX_RUNTIME_ASSET_ID: &str = "hoverfx.runtime";
20pub const DEFAULT_HOVERFX_WORKER_ASSET_ID: &str = "hoverfx.worker";
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "kebab-case")]
24pub enum HoverFxDiagnosticContext {
25    Build,
26    Ssr,
27    Runtime,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "camelCase")]
32pub struct HoverFxDiagnosticVerbosityByContext {
33    pub build: HoverFxDiagnosticVerbosity,
34    pub ssr: HoverFxDiagnosticVerbosity,
35    pub runtime: HoverFxDiagnosticVerbosity,
36}
37
38impl Default for HoverFxDiagnosticVerbosityByContext {
39    fn default() -> Self {
40        Self {
41            build: HoverFxDiagnosticVerbosity::Detailed,
42            ssr: HoverFxDiagnosticVerbosity::Summary,
43            runtime: HoverFxDiagnosticVerbosity::Off,
44        }
45    }
46}
47
48impl HoverFxDiagnosticVerbosityByContext {
49    pub fn for_context(&self, context: HoverFxDiagnosticContext) -> HoverFxDiagnosticVerbosity {
50        match context {
51            HoverFxDiagnosticContext::Build => self.build,
52            HoverFxDiagnosticContext::Ssr => self.ssr,
53            HoverFxDiagnosticContext::Runtime => self.runtime,
54        }
55    }
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "camelCase")]
60pub struct HoverFxRuntimeIds {
61    pub config_id: String,
62    pub prepaint_style_id: String,
63    pub runtime_asset_id: String,
64    pub worker_asset_id: String,
65}
66
67impl Default for HoverFxRuntimeIds {
68    fn default() -> Self {
69        Self {
70            config_id: DEFAULT_HOVERFX_CONFIG_ID.to_string(),
71            prepaint_style_id: DEFAULT_HOVERFX_PREPAINT_STYLE_ID.to_string(),
72            runtime_asset_id: DEFAULT_HOVERFX_RUNTIME_ASSET_ID.to_string(),
73            worker_asset_id: DEFAULT_HOVERFX_WORKER_ASSET_ID.to_string(),
74        }
75    }
76}
77
78impl HoverFxRuntimeIds {
79    pub fn with_config_id(mut self, id: impl Into<String>) -> Self {
80        self.config_id = id.into();
81        self
82    }
83
84    pub fn with_prepaint_style_id(mut self, id: impl Into<String>) -> Self {
85        self.prepaint_style_id = id.into();
86        self
87    }
88
89    pub fn with_runtime_asset_id(mut self, id: impl Into<String>) -> Self {
90        self.runtime_asset_id = id.into();
91        self
92    }
93
94    pub fn with_worker_asset_id(mut self, id: impl Into<String>) -> Self {
95        self.worker_asset_id = id.into();
96        self
97    }
98
99    pub fn duplicate_ids(&self) -> Vec<String> {
100        let mut seen = BTreeMap::<&str, usize>::new();
101        for id in [
102            self.config_id.as_str(),
103            self.prepaint_style_id.as_str(),
104            self.runtime_asset_id.as_str(),
105            self.worker_asset_id.as_str(),
106        ] {
107            *seen.entry(id).or_default() += 1;
108        }
109        seen.into_iter()
110            .filter_map(|(id, count)| (count > 1).then(|| id.to_string()))
111            .collect()
112    }
113
114    pub fn deduped(mut self) -> Self {
115        let mut counts = BTreeMap::<String, usize>::new();
116        for id in [
117            &mut self.config_id,
118            &mut self.prepaint_style_id,
119            &mut self.runtime_asset_id,
120            &mut self.worker_asset_id,
121        ] {
122            let count = counts.entry(id.clone()).or_default();
123            if *count > 0 {
124                id.push('-');
125                id.push_str(&(*count + 1).to_string());
126            }
127            *count += 1;
128        }
129        self
130    }
131}
132
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(rename_all = "camelCase")]
135pub struct HoverFxAssetPaths {
136    pub runtime_base_path: String,
137    pub worker_base_path: String,
138    pub runtime_asset_name: String,
139    pub worker_asset_name: String,
140}
141
142impl Default for HoverFxAssetPaths {
143    fn default() -> Self {
144        Self {
145            runtime_base_path: "/assets".to_string(),
146            worker_base_path: "/assets".to_string(),
147            runtime_asset_name: "dioxus-hoverfx.js".to_string(),
148            worker_asset_name: "dioxus-hoverfx-worker.js".to_string(),
149        }
150    }
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(rename_all = "kebab-case")]
155pub enum HoverFxAssetBudgetCategory {
156    RuntimeScript,
157    WorkerScript,
158    PrepaintStyle,
159    ConfigJson,
160    EffectCssVars,
161}
162
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
164#[serde(rename_all = "camelCase")]
165pub struct HoverFxAssetBudgetEntry {
166    pub category: HoverFxAssetBudgetCategory,
167    pub bytes: usize,
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171#[serde(rename_all = "camelCase")]
172pub struct HoverFxAssetBudgetBridge {
173    pub package: String,
174    pub entries: Vec<HoverFxAssetBudgetEntry>,
175}
176
177impl HoverFxAssetBudgetBridge {
178    pub fn total_bytes(&self) -> usize {
179        self.entries.iter().map(|entry| entry.bytes).sum()
180    }
181}
182
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
184#[serde(rename_all = "camelCase")]
185pub struct HoverFxBatchOptions {
186    pub format: HoverFxSerializationFormat,
187    pub deterministic_parallel: bool,
188    pub sort_by_cache_key: bool,
189}
190
191impl Default for HoverFxBatchOptions {
192    fn default() -> Self {
193        Self {
194            format: HoverFxSerializationFormat::StableJson,
195            deterministic_parallel: false,
196            sort_by_cache_key: true,
197        }
198    }
199}
200
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202#[serde(rename_all = "camelCase")]
203pub struct HoverFxBatchPayload {
204    pub route: Option<String>,
205    pub cache_key: String,
206    pub json: String,
207    pub bytes: usize,
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211#[serde(rename_all = "camelCase")]
212pub struct HoverFxBatchSerializationReport {
213    pub package: String,
214    pub deterministic_parallel: bool,
215    pub payloads: Vec<HoverFxBatchPayload>,
216    pub total_bytes: usize,
217}
218
219#[derive(Debug)]
220pub struct HoverFxBorrowedView<'a> {
221    pub config: &'a HoverFxConfig,
222    pub policy: Cow<'a, HoverFxRoutePolicy>,
223}
224
225impl<'a> HoverFxBorrowedView<'a> {
226    pub fn new(config: &'a HoverFxConfig, policy: &'a HoverFxRoutePolicy) -> Self {
227        Self {
228            config,
229            policy: Cow::Borrowed(policy),
230        }
231    }
232
233    pub fn with_owned_policy(config: &'a HoverFxConfig, policy: HoverFxRoutePolicy) -> Self {
234        Self {
235            config,
236            policy: Cow::Owned(policy),
237        }
238    }
239
240    pub fn manifest_fragment(&self) -> HoverFxManifestFragment {
241        hoverfx_manifest_fragment(self.config, self.policy.as_ref())
242    }
243}
244
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246#[serde(rename_all = "camelCase")]
247pub struct HoverFxCssFragment {
248    pub id: String,
249    pub css: String,
250    pub bytes: usize,
251}
252
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
254#[serde(rename_all = "camelCase")]
255pub struct HoverFxMotionPolicy {
256    pub reduced_motion: String,
257    pub view_transition: String,
258    pub render_lane: String,
259}
260
261impl Default for HoverFxMotionPolicy {
262    fn default() -> Self {
263        Self {
264            reduced_motion: "respect".to_string(),
265            view_transition: "optional".to_string(),
266            render_lane: "background".to_string(),
267        }
268    }
269}
270
271pub trait HoverFxMotionPolicyHook {
272    fn apply(&self, policy: HoverFxMotionPolicy) -> HoverFxMotionPolicy;
273}
274
275#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
276#[serde(rename_all = "camelCase")]
277pub struct HoverFxRouteMetadata {
278    pub route: String,
279    #[serde(default, skip_serializing_if = "Vec::is_empty")]
280    pub cache_keys: Vec<String>,
281    #[serde(default, skip_serializing_if = "Vec::is_empty")]
282    pub packages: Vec<String>,
283    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
284    pub labels: BTreeMap<String, String>,
285}
286
287#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
288#[serde(rename_all = "camelCase")]
289pub struct HoverFxOptimizerArtifact {
290    pub id: String,
291    pub content_hash: String,
292    pub bytes: usize,
293    pub minified: bool,
294}
295
296pub trait HoverFxOptimizerReuseHook {
297    fn reuse_artifact(&self, artifact: &HoverFxOptimizerArtifact) -> Option<String>;
298}
299
300pub trait HoverFxCacheBackend {
301    fn get(&self, key: &str) -> Option<String>;
302    fn put(&self, key: String, value: String);
303}
304
305#[derive(Clone, Default)]
306pub struct HoverFxMemoryCache {
307    values: Arc<Mutex<BTreeMap<String, String>>>,
308}
309
310impl HoverFxCacheBackend for HoverFxMemoryCache {
311    fn get(&self, key: &str) -> Option<String> {
312        self.values.lock().ok()?.get(key).cloned()
313    }
314
315    fn put(&self, key: String, value: String) {
316        if let Ok(mut values) = self.values.lock() {
317            values.insert(key, value);
318        }
319    }
320}
321
322#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
323#[serde(rename_all = "camelCase")]
324pub struct HoverFxOffloadPlan {
325    pub package: String,
326    pub lane: String,
327    pub serializable: bool,
328    pub fallback: HoverFxFallbackStrategy,
329    pub payload_cache_key: String,
330    pub tasks: Vec<String>,
331}
332
333#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
334#[serde(rename_all = "camelCase")]
335pub struct HoverFxCompactDictionary {
336    pub version: u8,
337    pub terms: BTreeMap<String, u16>,
338}
339
340#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
341#[serde(rename_all = "camelCase")]
342pub struct HoverFxTraceEvent {
343    pub name: String,
344    pub cache_key: String,
345    pub route: Option<String>,
346    pub message: String,
347}
348
349#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
350#[serde(rename_all = "camelCase")]
351pub struct HoverFxStrataMigrationPlan {
352    pub package: String,
353    pub route: Option<String>,
354    pub steps: Vec<String>,
355}
356
357/// Serialize route-scoped HoverFX configs with stable cache keys.
358///
359/// ```rust
360/// use dioxus_hoverfx_core::prelude::*;
361///
362/// let config = hoverfx().with_default_effect(HoverFxPreset::Spotlight.as_attr());
363/// let policy = hoverfx_route_policy().route("/gallery");
364/// let batch = hoverfx_serialize_batch([(&config, policy)], HoverFxBatchOptions::default())?;
365///
366/// assert_eq!(batch.payloads.len(), 1);
367/// assert!(batch.total_bytes > 0);
368/// # Ok::<(), serde_json::Error>(())
369/// ```
370pub fn hoverfx_serialize_batch<'a>(
371    entries: impl IntoIterator<Item = (&'a HoverFxConfig, HoverFxRoutePolicy)>,
372    options: HoverFxBatchOptions,
373) -> serde_json::Result<HoverFxBatchSerializationReport> {
374    let entries = entries.into_iter().collect::<Vec<_>>();
375    let mut payloads = if options.deterministic_parallel && entries.len() > 1 {
376        thread::scope(|scope| {
377            let mut handles = Vec::new();
378            for (config, policy) in entries {
379                handles.push(scope.spawn(move || serialize_one(config, &policy, options.format)));
380            }
381            handles
382                .into_iter()
383                .map(|handle| handle.join().expect("HoverFX batch worker panicked"))
384                .collect::<serde_json::Result<Vec<_>>>()
385        })?
386    } else {
387        entries
388            .into_iter()
389            .map(|(config, policy)| serialize_one(config, &policy, options.format))
390            .collect::<serde_json::Result<Vec<_>>>()?
391    };
392    if options.sort_by_cache_key {
393        payloads.sort_by(|a, b| a.cache_key.cmp(&b.cache_key));
394    }
395    let total_bytes = payloads.iter().map(|payload| payload.bytes).sum();
396    Ok(HoverFxBatchSerializationReport {
397        package: HOVERFX_PACKAGE_NAME.to_string(),
398        deterministic_parallel: options.deterministic_parallel,
399        payloads,
400        total_bytes,
401    })
402}
403
404pub fn hoverfx_asset_budget_bridge(
405    config: &HoverFxConfig,
406    policy: &HoverFxRoutePolicy,
407) -> HoverFxAssetBudgetBridge {
408    let output = config.output_report(policy);
409    HoverFxAssetBudgetBridge {
410        package: HOVERFX_PACKAGE_NAME.to_string(),
411        entries: vec![
412            HoverFxAssetBudgetEntry {
413                category: HoverFxAssetBudgetCategory::ConfigJson,
414                bytes: output.config_bytes,
415            },
416            HoverFxAssetBudgetEntry {
417                category: HoverFxAssetBudgetCategory::RuntimeScript,
418                bytes: output.runtime_bytes,
419            },
420            HoverFxAssetBudgetEntry {
421                category: HoverFxAssetBudgetCategory::EffectCssVars,
422                bytes: output.style_bytes,
423            },
424        ],
425    }
426}
427
428pub fn hoverfx_precomputed_css_fragments() -> Vec<HoverFxCssFragment> {
429    HoverFxPreset::ALL
430        .into_iter()
431        .map(|preset| {
432            let css = format!(
433                "[data-dxh-effect=\"{}\"]{{--dxh-preset:\"{}\";}}",
434                preset.as_attr(),
435                preset.as_attr()
436            );
437            HoverFxCssFragment {
438                id: preset.as_attr().to_string(),
439                bytes: css.len(),
440                css,
441            }
442        })
443        .collect()
444}
445
446pub fn hoverfx_apply_motion_hook<H: HoverFxMotionPolicyHook>(
447    policy: HoverFxMotionPolicy,
448    hook: &H,
449) -> HoverFxMotionPolicy {
450    hook.apply(policy)
451}
452
453pub fn hoverfx_coalesce_route_metadata(
454    route: impl Into<String>,
455    fragments: impl IntoIterator<Item = HoverFxManifestFragment>,
456) -> HoverFxRouteMetadata {
457    let mut metadata = HoverFxRouteMetadata {
458        route: route.into(),
459        ..HoverFxRouteMetadata::default()
460    };
461    for fragment in fragments {
462        metadata.cache_keys.push(fragment.cache_key);
463        if !metadata.packages.contains(&fragment.package) {
464            metadata.packages.push(fragment.package);
465        }
466        metadata.labels.extend(fragment.labels);
467    }
468    metadata.cache_keys.sort();
469    metadata.packages.sort();
470    metadata
471}
472
473pub fn hoverfx_optimizer_artifacts(report: &HoverFxOutputReport) -> Vec<HoverFxOptimizerArtifact> {
474    [
475        ("hoverfx.config", report.config_bytes, true),
476        ("hoverfx.runtime", report.runtime_bytes, true),
477        ("hoverfx.style", report.style_bytes, true),
478    ]
479    .into_iter()
480    .map(|(id, bytes, minified)| HoverFxOptimizerArtifact {
481        id: id.to_string(),
482        content_hash: integration_hash_hex([id, &report.cache_key, &bytes.to_string()]),
483        bytes,
484        minified,
485    })
486    .collect()
487}
488
489pub fn hoverfx_cache_put_report<B: HoverFxCacheBackend>(
490    cache: &B,
491    report: &HoverFxOutputReport,
492) -> String {
493    let key = format!(
494        "{}:{}:{}",
495        HOVERFX_PACKAGE_NAME, HOVERFX_PACKAGE_VERSION, report.cache_key
496    );
497    cache.put(
498        key.clone(),
499        serde_json::to_string(report).unwrap_or_default(),
500    );
501    key
502}
503
504pub fn hoverfx_workertown_offload_plan(
505    config: &HoverFxConfig,
506    policy: &HoverFxRoutePolicy,
507) -> HoverFxOffloadPlan {
508    HoverFxOffloadPlan {
509        package: HOVERFX_PACKAGE_NAME.to_string(),
510        lane: "hoverfx".to_string(),
511        serializable: true,
512        fallback: policy.fallback,
513        payload_cache_key: hoverfx_cache_key(config, policy.route.as_deref(), Some("workertown")),
514        tasks: vec![
515            "pointer-sampling".to_string(),
516            "dirty-rect-render".to_string(),
517            "tooltip-textfx-trigger".to_string(),
518        ],
519    }
520}
521
522pub fn hoverfx_compact_dictionary() -> HoverFxCompactDictionary {
523    let mut terms = BTreeMap::new();
524    for (index, term) in [
525        "config",
526        "runtime",
527        "worker",
528        "style",
529        "route",
530        "profile",
531        "fallback",
532        "workertown",
533    ]
534    .into_iter()
535    .enumerate()
536    {
537        terms.insert(term.to_string(), index as u16);
538    }
539    HoverFxCompactDictionary { version: 1, terms }
540}
541
542pub fn hoverfx_trace_report(
543    config: &HoverFxConfig,
544    policy: &HoverFxRoutePolicy,
545    mut emit: impl FnMut(HoverFxTraceEvent),
546) {
547    let explain = explain_hoverfx(config, policy);
548    emit(HoverFxTraceEvent {
549        name: "hoverfx.policy".to_string(),
550        cache_key: explain.cache_key.clone(),
551        route: policy.route.clone(),
552        message: explain.runtime_decision,
553    });
554    emit(HoverFxTraceEvent {
555        name: "hoverfx.output".to_string(),
556        cache_key: explain.cache_key,
557        route: policy.route.clone(),
558        message: format!("{} config bytes", explain.output.config_bytes),
559    });
560}
561
562pub fn hoverfx_strata_migration_plan(
563    config: &HoverFxConfig,
564    policy: &HoverFxRoutePolicy,
565) -> HoverFxStrataMigrationPlan {
566    HoverFxStrataMigrationPlan {
567        package: HOVERFX_PACKAGE_NAME.to_string(),
568        route: policy.route.clone(),
569        steps: vec![
570            "emit HoverFxManifestFragment from standalone config".to_string(),
571            format!(
572                "register cache key {}",
573                config.cache_key(policy.route.as_deref())
574            ),
575            "attach runtime/style assets through Strata route metadata".to_string(),
576            "preserve fallback and diagnostic context policy".to_string(),
577        ],
578    }
579}
580
581pub fn hoverfx_conformance_fixture() -> serde_json::Value {
582    let config = HoverFxConfig::default();
583    let policy = HoverFxRoutePolicy::default().route("/hoverfx");
584    serde_json::json!({
585        "manifest": hoverfx_manifest_fragment(&config, &policy),
586        "dictionary": hoverfx_compact_dictionary(),
587        "cssFragments": hoverfx_precomputed_css_fragments(),
588    })
589}
590
591fn serialize_one(
592    config: &HoverFxConfig,
593    policy: &HoverFxRoutePolicy,
594    format: HoverFxSerializationFormat,
595) -> serde_json::Result<HoverFxBatchPayload> {
596    let json = config.to_preferred_json(format)?;
597    Ok(HoverFxBatchPayload {
598        route: policy.route.clone(),
599        cache_key: hoverfx_cache_key(config, policy.route.as_deref(), Some(format.as_attr())),
600        bytes: json.len(),
601        json,
602    })
603}
604
605fn integration_hash_hex<'a>(parts: impl IntoIterator<Item = &'a str>) -> String {
606    let mut hash = 0xcbf29ce484222325u64;
607    for part in parts {
608        for byte in part.as_bytes() {
609            hash ^= u64::from(*byte);
610            hash = hash.wrapping_mul(0x100000001b3);
611        }
612        hash ^= 0xff;
613        hash = hash.wrapping_mul(0x100000001b3);
614    }
615    format!("{hash:016x}")
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621    use crate::{HoverFxDefinition, HoverFxPresetProfile, hoverfx_output_budget};
622
623    #[test]
624    fn batch_serialization_parallel_is_deterministic() {
625        let config = HoverFxConfig::default()
626            .with_effect(HoverFxDefinition::from_preset(HoverFxPreset::Tooltip));
627        let a = HoverFxRoutePolicy::default().route("/a");
628        let b = HoverFxRoutePolicy::default().route("/b");
629        let serial = hoverfx_serialize_batch(
630            [(&config, a.clone()), (&config, b.clone())],
631            HoverFxBatchOptions::default(),
632        )
633        .unwrap();
634        let parallel = hoverfx_serialize_batch(
635            [(&config, a), (&config, b)],
636            HoverFxBatchOptions {
637                deterministic_parallel: true,
638                ..HoverFxBatchOptions::default()
639            },
640        )
641        .unwrap();
642
643        assert_eq!(serial.payloads, parallel.payloads);
644        assert!(parallel.deterministic_parallel);
645    }
646
647    #[test]
648    fn asset_budget_duplicate_ids_cache_and_offload_are_reported() {
649        let config = HoverFxConfig::default().with_profile(HoverFxPresetProfile::Aggressive);
650        let policy = HoverFxRoutePolicy::default()
651            .route("/hoverfx")
652            .budget(hoverfx_output_budget().runtime_bytes(8));
653        let report = config.output_report(&policy);
654        let bridge = hoverfx_asset_budget_bridge(&config, &policy);
655        let ids = HoverFxRuntimeIds::default().with_config_id(DEFAULT_HOVERFX_PREPAINT_STYLE_ID);
656        let cache = HoverFxMemoryCache::default();
657        let key = hoverfx_cache_put_report(&cache, &report);
658        let offload = hoverfx_workertown_offload_plan(&config, &policy);
659
660        assert!(bridge.total_bytes() >= report.config_bytes);
661        assert_eq!(
662            ids.duplicate_ids(),
663            vec![DEFAULT_HOVERFX_PREPAINT_STYLE_ID.to_string()]
664        );
665        assert!(ids.deduped().duplicate_ids().is_empty());
666        assert!(cache.get(&key).is_some());
667        assert!(offload.serializable);
668        assert!(offload.tasks.iter().any(|task| task == "dirty-rect-render"));
669    }
670
671    #[test]
672    fn css_motion_metadata_optimizer_trace_and_migration_are_concrete() {
673        struct ForceDisable;
674
675        impl HoverFxMotionPolicyHook for ForceDisable {
676            fn apply(&self, mut policy: HoverFxMotionPolicy) -> HoverFxMotionPolicy {
677                policy.reduced_motion = "disable".to_string();
678                policy
679            }
680        }
681
682        let config = HoverFxConfig::default();
683        let policy = HoverFxRoutePolicy::default().route("/hoverfx");
684        let manifest = config.manifest_fragment(&policy);
685        let metadata = hoverfx_coalesce_route_metadata("/hoverfx", [manifest]);
686        let css = hoverfx_precomputed_css_fragments();
687        let output = config.output_report(&policy);
688        let artifacts = hoverfx_optimizer_artifacts(&output);
689        let motion = hoverfx_apply_motion_hook(HoverFxMotionPolicy::default(), &ForceDisable);
690        let dictionary = hoverfx_compact_dictionary();
691        let migration = hoverfx_strata_migration_plan(&config, &policy);
692        let mut traces = Vec::new();
693        hoverfx_trace_report(&config, &policy, |event| traces.push(event));
694        let fixture = hoverfx_conformance_fixture();
695
696        assert_eq!(metadata.route, "/hoverfx");
697        assert!(css.iter().any(|fragment| fragment.id == "tooltip"));
698        assert!(
699            artifacts
700                .iter()
701                .any(|artifact| artifact.id == "hoverfx.runtime")
702        );
703        assert_eq!(motion.reduced_motion, "disable");
704        assert_eq!(dictionary.terms["runtime"], 1);
705        assert!(migration.steps.iter().any(|step| step.contains("Strata")));
706        assert_eq!(traces.len(), 2);
707        assert!(fixture.get("manifest").is_some());
708    }
709}