Skip to main content

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