Skip to main content

vizia_core/resource/
mod.rs

1//! Resource management for fonts, themes, images, and translations.
2
3mod file_resource_loader;
4mod image_id;
5mod url_resource_loader;
6
7pub use file_resource_loader::FileResourceLoader;
8pub use image_id::ImageId;
9#[cfg(feature = "url-loader")]
10pub use url_resource_loader::UrlResourceLoader;
11use vizia_id::IdManager;
12use vizia_reactive::{Signal, SignalGet, SignalUpdate};
13
14use crate::context::ResourceContext;
15use crate::entity::Entity;
16use crate::prelude::IntoCssStr;
17// use crate::view::Canvas;
18use chrono::{DateTime, Utc};
19use fluent_bundle::types::{FluentNumber, FluentNumberOptions};
20use fluent_bundle::{FluentArgs, FluentBundle, FluentResource, FluentValue};
21use hashbrown::{HashMap, HashSet};
22use std::fmt;
23use unic_langid::LanguageIdentifier;
24
25/// Error type for translation operations.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum TranslationError {
28    /// FTL file syntax is invalid.
29    InvalidFtl(String),
30    /// Failed to add resource to translation bundle.
31    BundleError(String),
32}
33
34impl fmt::Display for TranslationError {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        match self {
37            TranslationError::InvalidFtl(msg) => write!(f, "Invalid FTL syntax: {}", msg),
38            TranslationError::BundleError(msg) => {
39                write!(f, "Failed to add to translation bundle: {}", msg)
40            }
41        }
42    }
43}
44
45impl std::error::Error for TranslationError {}
46
47fn fluent_number<'a>(positional: &[FluentValue<'a>], named: &FluentArgs) -> FluentValue<'a> {
48    let Some(first) = positional.first() else {
49        return FluentValue::Error;
50    };
51
52    let mut number = match first {
53        FluentValue::Number(num) => num.clone(),
54        FluentValue::String(value) => value
55            .parse::<FluentNumber>()
56            .unwrap_or_else(|_| FluentNumber::new(0.0, FluentNumberOptions::default())),
57        _ => return FluentValue::Error,
58    };
59
60    number.options.merge(named);
61    FluentValue::Number(number)
62}
63
64fn style_str(args: &FluentArgs, key: &str) -> Option<String> {
65    match args.get(key) {
66        Some(FluentValue::String(value)) => Some(value.to_string()),
67        _ => None,
68    }
69}
70
71fn datetime_format_pattern(args: &FluentArgs) -> String {
72    let weekday = match style_str(args, "weekday").as_deref() {
73        Some("long") => Some("%A"),
74        Some("short") => Some("%a"),
75        _ => None,
76    };
77
78    let month = match style_str(args, "month").as_deref() {
79        Some("long") => Some("%B"),
80        Some("short") => Some("%b"),
81        Some("2-digit") => Some("%m"),
82        Some("numeric") => Some("%-m"),
83        _ => None,
84    };
85
86    let day = match style_str(args, "day").as_deref() {
87        Some("2-digit") => Some("%d"),
88        Some("numeric") => Some("%-d"),
89        _ => None,
90    };
91
92    let year = match style_str(args, "year").as_deref() {
93        Some("2-digit") => Some("%y"),
94        Some("numeric") => Some("%Y"),
95        _ => None,
96    };
97
98    let hour = match style_str(args, "hour").as_deref() {
99        Some("2-digit") => Some("%H"),
100        Some("numeric") => Some("%-H"),
101        _ => None,
102    };
103
104    let minute = match style_str(args, "minute").as_deref() {
105        Some("2-digit") => Some("%M"),
106        Some("numeric") => Some("%-M"),
107        _ => None,
108    };
109
110    let mut date_parts = Vec::new();
111    if let Some(part) = weekday {
112        date_parts.push(part);
113    }
114    if let Some(part) = month {
115        date_parts.push(part);
116    }
117    if let Some(part) = day {
118        date_parts.push(part);
119    }
120    if let Some(part) = year {
121        date_parts.push(part);
122    }
123
124    let mut pattern = date_parts.join(" ");
125    if hour.is_some() || minute.is_some() {
126        if !pattern.is_empty() {
127            pattern.push(' ');
128        }
129        let mut time_parts = Vec::new();
130        if let Some(part) = hour {
131            time_parts.push(part);
132        }
133        if let Some(part) = minute {
134            time_parts.push(part);
135        }
136        pattern.push_str(&time_parts.join(":"));
137    }
138
139    if pattern.is_empty() { "%Y-%m-%d %H:%M:%S".to_string() } else { pattern }
140}
141
142fn fluent_datetime<'a>(positional: &[FluentValue<'a>], named: &FluentArgs) -> FluentValue<'a> {
143    let Some(first) = positional.first() else {
144        return FluentValue::Error;
145    };
146
147    let millis = match first {
148        FluentValue::Number(num) => num.value as i64,
149        FluentValue::String(value) => value.parse::<i64>().unwrap_or_default(),
150        _ => return FluentValue::Error,
151    };
152
153    let Some(dt) = DateTime::<Utc>::from_timestamp_millis(millis) else {
154        return FluentValue::Error;
155    };
156
157    let pattern = datetime_format_pattern(named);
158    FluentValue::String(dt.format(&pattern).to_string().into())
159}
160
161fn make_bundle(lang: LanguageIdentifier) -> FluentBundle<FluentResource> {
162    let mut bundle = FluentBundle::new(vec![lang]);
163
164    bundle.add_function("NUMBER", fluent_number).expect("Failed to register NUMBER function");
165    bundle.add_function("DATETIME", fluent_datetime).expect("Failed to register DATETIME function");
166
167    bundle
168}
169
170/// Structured diagnostics emitted by localization while resolving messages.
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub(crate) enum LocalizationIssue {
173    /// A message key was not found in any fallback bundle.
174    MissingMessage { key: String, requested_locale: String },
175    /// A message attribute was not found in any fallback bundle.
176    MissingAttribute { key: String, attribute: String, requested_locale: String },
177    /// Fluent formatting reported errors while resolving a message.
178    FormatError { key: String, locale: String, details: String },
179}
180
181impl fmt::Display for LocalizationIssue {
182    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
183        match self {
184            LocalizationIssue::MissingMessage { key, requested_locale } => {
185                write!(f, "Missing localized message '{}' for locale '{}'.", key, requested_locale)
186            }
187            LocalizationIssue::MissingAttribute { key, attribute, requested_locale } => write!(
188                f,
189                "Missing localized attribute '{}.{}' for locale '{}'.",
190                key, attribute, requested_locale
191            ),
192            LocalizationIssue::FormatError { key, locale, details } => {
193                write!(f, "Formatting error for key '{}' in locale '{}': {}", key, locale, details)
194            }
195        }
196    }
197}
198
199/// Request to load an image resource.
200#[derive(Debug, Clone)]
201pub struct ImageRequest {
202    /// Name used to reference the loaded image in styles and image views.
203    pub name: String,
204    /// Path or URL to the image resource.
205    pub path: String,
206    /// How long the image should be retained in memory.
207    pub policy: ImageRetentionPolicy,
208}
209
210/// Request to load a font resource.
211#[derive(Debug, Clone)]
212pub struct FontRequest {
213    /// Path or URL to the font resource.
214    pub path: String,
215}
216
217/// Request to load a translation resource.
218#[derive(Debug, Clone)]
219pub struct TranslationRequest {
220    /// Language identifier this translation file belongs to.
221    pub lang: LanguageIdentifier,
222    /// Path or URL to the translation resource.
223    pub path: String,
224}
225
226/// Loading status of a resource.
227#[derive(Debug, Clone, Copy, PartialEq, Eq)]
228pub enum LoadingStatus {
229    /// Resource has not been loaded yet.
230    NotLoaded,
231    /// Resource is currently loading.
232    Loading,
233    /// Resource has been successfully loaded.
234    Loaded,
235    /// Resource failed to load.
236    Error,
237}
238
239/// Preferred execution strategy for handling a single resource request.
240#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
241pub enum ResourceLoadExecution {
242    /// Let loaders choose their default strategy.
243    #[default]
244    Auto,
245    /// Prefer asynchronous loading.
246    Async,
247    /// Prefer synchronous loading.
248    Sync,
249}
250
251/// Per-request options that influence how a resource is loaded.
252#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
253pub struct ResourceLoadOptions {
254    /// Preferred execution strategy for this request.
255    pub execution: ResourceLoadExecution,
256}
257
258impl ResourceLoadOptions {
259    /// Construct options with automatic execution strategy selection.
260    pub const fn auto() -> Self {
261        Self { execution: ResourceLoadExecution::Auto }
262    }
263
264    /// Construct options that prefer asynchronous loading.
265    pub const fn asynchronous() -> Self {
266        Self { execution: ResourceLoadExecution::Async }
267    }
268
269    /// Construct options that prefer synchronous loading.
270    pub const fn synchronous() -> Self {
271        Self { execution: ResourceLoadExecution::Sync }
272    }
273}
274
275/// A resource request that loaders can handle.
276#[non_exhaustive]
277#[derive(Debug, Clone)]
278pub enum ResourceRequest {
279    /// Request to load an image.
280    Image(ImageRequest),
281    /// Request to load a font.
282    Font(FontRequest),
283    /// Request to load a translation file.
284    Translation(TranslationRequest),
285}
286
287impl ResourceRequest {
288    /// Returns the canonical resource path associated with this request.
289    pub fn path(&self) -> &str {
290        match self {
291            ResourceRequest::Image(req) => &req.path,
292            ResourceRequest::Font(req) => &req.path,
293            ResourceRequest::Translation(req) => &req.path,
294        }
295    }
296}
297
298/// A resource request queued for dispatch, paired with loader policy.
299#[derive(Debug, Clone)]
300pub struct QueuedResourceRequest {
301    /// Request payload describing what to load.
302    pub request: ResourceRequest,
303    /// Loader policy describing how to perform the load.
304    pub options: ResourceLoadOptions,
305}
306
307/// Trait for loading resources asynchronously or from various sources.
308///
309/// Loaders are invoked in chain-of-responsibility order when a resource is requested.
310/// Return `true` to indicate the request was handled (no further loaders are tried),
311/// or `false` to continue to the next loader.
312pub trait ResourceLoader: Send + Sync + 'static {
313    /// Attempt to load a resource.
314    ///
315    /// `request` describes what to load, while `options` describes how to load it.
316    /// Return `true` if this loader handled the request, `false` to try the next loader.
317    fn load(
318        &self,
319        request: ResourceRequest,
320        options: ResourceLoadOptions,
321        cx: &mut ResourceContext,
322    ) -> bool;
323
324    /// Query the loading status of a resource path.
325    ///
326    /// Default implementation returns `NotLoaded` — override to track async loading progress.
327    fn status(&self, _path: &str) -> LoadingStatus {
328        LoadingStatus::NotLoaded
329    }
330}
331
332pub(crate) enum ImageOrSvg {
333    Svg(skia_safe::svg::Dom),
334    Image(skia_safe::Image),
335}
336
337pub(crate) struct StoredImage {
338    pub image: ImageOrSvg,
339    pub retention_policy: ImageRetentionPolicy,
340    pub used: bool,
341    pub dirty: bool,
342    pub observers: HashSet<Entity>,
343}
344
345/// An image should be stored in the resource manager.
346#[derive(Debug, Copy, Clone, PartialEq)]
347pub enum ImageRetentionPolicy {
348    ///  The image should live for the entire duration of the application.
349    Forever,
350    /// The image should be dropped when not used for one frame.
351    DropWhenUnusedForOneFrame,
352    /// The image should be dropped when no views are using the image.
353    DropWhenNoObservers,
354}
355
356#[doc(hidden)]
357#[derive(Default)]
358pub struct ResourceManager {
359    pub styles: Vec<Box<dyn IntoCssStr>>,
360
361    pub(crate) image_id_manager: IdManager<ImageId>,
362    pub(crate) images: HashMap<ImageId, StoredImage>,
363    pub(crate) image_ids: HashMap<String, ImageId>,
364    pub(crate) image_sources: HashMap<String, String>,
365
366    pub translations: HashMap<LanguageIdentifier, FluentBundle<FluentResource>>,
367
368    pub language: LanguageIdentifier,
369
370    pub(crate) resource_loaders: Vec<Box<dyn ResourceLoader>>,
371    /// Resource requests waiting to be dispatched through the configured loader chain.
372    pub(crate) pending_requests: Vec<QueuedResourceRequest>,
373    /// Reactive status tracking per resource path.
374    /// Signals allow automatic updates to Memos when status changes.
375    pub(crate) loading_status: HashMap<String, Signal<LoadingStatus>>,
376}
377
378impl ResourceManager {
379    pub fn new() -> Self {
380        // Get the system locale
381        let locale = sys_locale::get_locale().and_then(|l| l.parse().ok()).unwrap_or_default();
382
383        let mut image_id_manager = IdManager::new();
384
385        // Create root id for broken image
386        image_id_manager.create();
387
388        let images = HashMap::new();
389
390        #[cfg(feature = "url-loader")]
391        // Keep file loading ahead of URL loading so local/file:// paths are resolved first.
392        let resource_loaders: Vec<Box<dyn ResourceLoader>> =
393            vec![Box::new(FileResourceLoader), Box::new(UrlResourceLoader::default())];
394
395        #[cfg(not(feature = "url-loader"))]
396        let resource_loaders: Vec<Box<dyn ResourceLoader>> = vec![Box::new(FileResourceLoader)];
397
398        ResourceManager {
399            image_id_manager,
400            images,
401            image_ids: HashMap::new(),
402            image_sources: HashMap::new(),
403            styles: Vec::new(),
404
405            translations: HashMap::from([(
406                LanguageIdentifier::default(),
407                make_bundle(LanguageIdentifier::default()),
408            )]),
409
410            language: locale,
411            resource_loaders,
412            pending_requests: Vec::new(),
413            loading_status: HashMap::new(),
414        }
415    }
416
417    /// Registers a stable image key to source path/URL mapping.
418    pub(crate) fn register_image_source(&mut self, name: String, path: String) {
419        self.image_sources.insert(name, path);
420    }
421
422    /// Resolves an image key to its source path/URL when registered.
423    pub(crate) fn resolve_image_source<'a>(&'a self, name_or_path: &'a str) -> &'a str {
424        self.image_sources.get(name_or_path).map(String::as_str).unwrap_or(name_or_path)
425    }
426
427    pub(crate) fn report_localization_issue(&self, issue: LocalizationIssue) {
428        // Localization issues are non-fatal and intended for diagnostics.
429        log::warn!("{}", issue);
430    }
431
432    pub fn renegotiate_language(&mut self) {
433        let available = self
434            .translations
435            .keys()
436            .filter(|&x| x != &LanguageIdentifier::default())
437            .collect::<Vec<_>>();
438        let locale = sys_locale::get_locale()
439            .and_then(|l| l.parse().ok())
440            .unwrap_or_else(|| available.first().copied().cloned().unwrap_or_default());
441        let default = LanguageIdentifier::default();
442        let default_ref = &default; // ???
443        let langs = fluent_langneg::negotiate::negotiate_languages(
444            &[locale],
445            &available,
446            Some(&default_ref),
447            fluent_langneg::NegotiationStrategy::Filtering,
448        );
449        self.language = (**langs.first().unwrap()).clone();
450    }
451
452    fn negotiate_translation_locale(&self, locale: &LanguageIdentifier) -> LanguageIdentifier {
453        if self.translations.contains_key(locale) {
454            return locale.clone();
455        }
456
457        let available = self
458            .translations
459            .keys()
460            .filter(|&lang| lang != &LanguageIdentifier::default())
461            .collect::<Vec<_>>();
462
463        if available.is_empty() {
464            return LanguageIdentifier::default();
465        }
466
467        // Pick a fallback from the registered translations: prefer `self.language` if it
468        // is one of them, otherwise the first registered translation. `available` is
469        // non-empty here (checked above), so `available.first()` is always `Some`.
470        let first_available = *available.first().expect("non-empty checked above");
471        let fallback =
472            if available.contains(&&self.language) { &self.language } else { first_available };
473        let langs = fluent_langneg::negotiate::negotiate_languages(
474            &[locale],
475            &available,
476            Some(&fallback),
477            fluent_langneg::NegotiationStrategy::Filtering,
478        );
479
480        langs.first().map(|lang| (**lang).clone()).unwrap_or_else(|| fallback.clone())
481    }
482
483    pub fn translation_locales(&self, locale: &LanguageIdentifier) -> Vec<LanguageIdentifier> {
484        let mut locales = Vec::new();
485
486        if self.translations.contains_key(locale) {
487            locales.push(locale.clone());
488        }
489
490        let negotiated = self.negotiate_translation_locale(locale);
491        if !locales.contains(&negotiated) {
492            locales.push(negotiated);
493        }
494
495        let default = LanguageIdentifier::default();
496        if !locales.contains(&default) {
497            locales.push(default);
498        }
499
500        locales
501    }
502
503    pub fn add_translation(
504        &mut self,
505        lang: LanguageIdentifier,
506        ftl: String,
507    ) -> Result<(), TranslationError> {
508        match fluent_bundle::FluentResource::try_new(ftl) {
509            Ok(res) => {
510                let bundle =
511                    self.translations.entry(lang.clone()).or_insert_with(|| make_bundle(lang));
512                bundle.add_resource(res).map_err(|errors| {
513                    let msg = format!("{:?}", errors);
514                    TranslationError::BundleError(msg)
515                })?;
516                self.renegotiate_language();
517                Ok(())
518            }
519            Err((_, parse_errors)) => {
520                let msg =
521                    parse_errors.iter().map(|e| format!("{:?}", e)).collect::<Vec<_>>().join("; ");
522                Err(TranslationError::InvalidFtl(msg))
523            }
524        }
525    }
526
527    pub fn current_translation(
528        &self,
529        locale: &LanguageIdentifier,
530    ) -> &FluentBundle<FluentResource> {
531        let locale = self.translation_locales(locale).into_iter().next().unwrap();
532        self.translations.get(&locale).unwrap()
533    }
534
535    pub fn mark_images_unused(&mut self) {
536        for (_, img) in self.images.iter_mut() {
537            img.used = false;
538        }
539    }
540
541    pub fn evict_unused_images(&mut self) {
542        let rem = self
543            .images
544            .iter()
545            .filter_map(|(id, img)| match img.retention_policy {
546                ImageRetentionPolicy::DropWhenUnusedForOneFrame => (!img.used).then_some(*id),
547
548                ImageRetentionPolicy::DropWhenNoObservers => {
549                    img.observers.is_empty().then_some(*id)
550                }
551
552                ImageRetentionPolicy::Forever => None,
553            })
554            .collect::<Vec<_>>();
555
556        for id in rem {
557            self.images.remove(&id);
558
559            // Collect all name→id mappings that point to this id.
560            let removed_names = self
561                .image_ids
562                .iter()
563                .filter_map(|(name, img_id)| (*img_id == id).then_some(name.clone()))
564                .collect::<Vec<_>>();
565
566            // Remove each name from image_ids and image_sources, recording the resolved
567            // source paths (image_sources maps name→path; for direct-path references the
568            // name IS the path, so fall back to the name itself).
569            let mut source_paths = Vec::new();
570            for name in removed_names {
571                self.image_ids.remove(&name);
572                let source_path = self.image_sources.remove(&name).unwrap_or(name);
573                source_paths.push(source_path);
574            }
575
576            // Clear loading_status for each source path, but only when no remaining
577            // image_sources entry still references it (another name may alias the same path).
578            for source_path in source_paths {
579                if !self.image_sources.values().any(|p| p == &source_path) {
580                    self.loading_status.remove(&source_path);
581                }
582            }
583
584            self.image_id_manager.destroy(id);
585        }
586    }
587
588    /// Query the loading status of a resource path.
589    pub fn resource_status(&self, path: &str) -> LoadingStatus {
590        // First check cached status signal
591        if let Some(signal) = self.loading_status.get(path) {
592            return signal.get();
593        }
594
595        // If not in cache, ask each loader in the chain
596        for loader in &self.resource_loaders {
597            let status = loader.status(path);
598            if status != LoadingStatus::NotLoaded {
599                return status;
600            }
601        }
602
603        LoadingStatus::NotLoaded
604    }
605
606    /// Enqueue a resource request to be handled by the resource system.
607    pub(crate) fn queue_resource_request(
608        &mut self,
609        request: ResourceRequest,
610        options: ResourceLoadOptions,
611    ) {
612        self.pending_requests.push(QueuedResourceRequest { request, options });
613    }
614
615    /// Drain pending resource requests for this frame.
616    pub(crate) fn take_pending_resource_requests(&mut self) -> Vec<QueuedResourceRequest> {
617        std::mem::take(&mut self.pending_requests)
618    }
619
620    /// Update the loading status of a resource path.
621    pub(crate) fn set_resource_status(&mut self, path: impl Into<String>, status: LoadingStatus) {
622        let path = path.into();
623        // Get or create the signal for this path
624        let signal = self.loading_status.entry(path).or_insert_with(|| Signal::new(status));
625        // Update the signal - this will notify any observers (Memos, Bindings)
626        signal.set_if_changed(status);
627    }
628}
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633    use crate::entity::Entity;
634    use hashbrown::HashSet;
635
636    fn test_image() -> skia_safe::Image {
637        skia_safe::Image::from_encoded(unsafe {
638            skia_safe::Data::new_bytes(include_bytes!("../../resources/images/broken_image.png"))
639        })
640        .unwrap()
641    }
642
643    fn stored_image(
644        policy: ImageRetentionPolicy,
645        used: bool,
646        observers: HashSet<Entity>,
647    ) -> StoredImage {
648        StoredImage {
649            image: ImageOrSvg::Image(test_image()),
650            retention_policy: policy,
651            used,
652            dirty: false,
653            observers,
654        }
655    }
656
657    #[test]
658    fn add_translation_returns_error_for_invalid_ftl() {
659        let mut manager = ResourceManager::new();
660
661        // Invalid FTL: unclosed placeable
662        let res = manager.add_translation("en-US".parse().unwrap(), "hello = { $name".to_string());
663
664        assert!(matches!(res, Err(TranslationError::InvalidFtl(_))));
665    }
666
667    #[test]
668    fn translation_locales_prefers_exact_then_default() {
669        let mut manager = ResourceManager::new();
670
671        manager.add_translation("fr".parse().unwrap(), "hello = Bonjour".to_string()).unwrap();
672
673        let locales = manager.translation_locales(&"fr".parse().unwrap());
674
675        assert_eq!(locales.first(), Some(&"fr".parse().unwrap()));
676        assert!(locales.contains(&LanguageIdentifier::default()));
677    }
678
679    #[test]
680    fn translation_locales_falls_back_to_default_when_no_locale_matches() {
681        let manager = ResourceManager::new();
682
683        let locales = manager.translation_locales(&"zz-ZZ".parse().unwrap());
684
685        assert_eq!(locales, vec![LanguageIdentifier::default()]);
686    }
687
688    #[test]
689    fn current_translation_falls_back_to_registered_bundle_when_requested_locale_missing() {
690        let mut manager = ResourceManager::new();
691
692        manager.add_translation("en-US".parse().unwrap(), "hello = Hello".to_string()).unwrap();
693
694        let bundle = manager.current_translation(&"zz-ZZ".parse().unwrap());
695
696        assert!(bundle.get_message("hello").is_some());
697    }
698
699    #[test]
700    fn current_translation_returns_registered_bundle_for_exact_match() {
701        let mut manager = ResourceManager::new();
702
703        manager.add_translation("fr".parse().unwrap(), "hello = Bonjour".to_string()).unwrap();
704
705        let bundle = manager.current_translation(&"fr".parse().unwrap());
706        let message = bundle.get_message("hello");
707
708        assert!(message.is_some());
709    }
710
711    #[test]
712    fn current_translation_returns_empty_default_when_no_translations_registered() {
713        let manager = ResourceManager::new();
714
715        // No `add_translation` call. The only entry in `translations` is the seeded empty
716        // default. A miss must not panic — it falls back to that default bundle.
717        let bundle = manager.current_translation(&"zz-ZZ".parse().unwrap());
718
719        assert!(bundle.get_message("hello").is_none());
720    }
721
722    #[test]
723    fn report_localization_issue_does_not_panic() {
724        let manager = ResourceManager::new();
725        manager.report_localization_issue(LocalizationIssue::MissingMessage {
726            key: "missing-key".to_string(),
727            requested_locale: "en-US".to_string(),
728        });
729    }
730
731    #[test]
732    fn evict_unused_images_keeps_used_one_frame_images() {
733        let mut manager = ResourceManager::new();
734
735        let used_id = manager.image_id_manager.create();
736        manager.images.insert(
737            used_id,
738            stored_image(ImageRetentionPolicy::DropWhenUnusedForOneFrame, true, HashSet::new()),
739        );
740
741        let unused_id = manager.image_id_manager.create();
742        manager.images.insert(
743            unused_id,
744            stored_image(ImageRetentionPolicy::DropWhenUnusedForOneFrame, false, HashSet::new()),
745        );
746
747        manager.evict_unused_images();
748
749        assert!(manager.images.contains_key(&used_id));
750        assert!(!manager.images.contains_key(&unused_id));
751    }
752
753    #[test]
754    fn evict_unused_images_clears_status_for_evicted_paths() {
755        let mut manager = ResourceManager::new();
756
757        let id = manager.image_id_manager.create();
758        let name = "my-image".to_string();
759        let path = "test://evicted-image".to_string();
760
761        manager.images.insert(
762            id,
763            stored_image(ImageRetentionPolicy::DropWhenNoObservers, false, HashSet::new()),
764        );
765        // image_ids is keyed by name; image_sources maps name → source path.
766        manager.image_ids.insert(name.clone(), id);
767        manager.image_sources.insert(name.clone(), path.clone());
768        manager.set_resource_status(path.clone(), LoadingStatus::Loaded);
769
770        manager.evict_unused_images();
771
772        assert!(!manager.image_ids.contains_key(&name));
773        assert!(!manager.image_sources.contains_key(&name));
774        assert_eq!(manager.resource_status(&path), LoadingStatus::NotLoaded);
775    }
776}