Skip to main content

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