Seregon/StratoSDK

StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.

Rust/27.3 KB/No license
crates/strato-ui-core/src/image_cache_tests.rs
StratoSDK / crates / strato-ui-core / src / image_cache_tests.rs
1use std::{borrow::Cow, rc::Rc};
2 
3use rust_embed::RustEmbed;
4 
5use crate::{
6 r#async::executor::{Background, Foreground},
7 AssetProvider,
8};
9 
10use super::*;
11 
12#[derive(Clone, Copy, RustEmbed)]
13#[folder = "test_data"]
14pub struct Assets;
15 
16// Implement the AssetProvider trait here (required by App::new).
17impl AssetProvider for Assets {
18 fn get(&self, path: &str) -> Result<Cow<'_, [u8]>> {
19 match path {
20 "animated.webp" => Ok(Cow::Borrowed(include_bytes!("../test_data/animated.webp"))),
21 "numbers-1000ms.gif" => Ok(Cow::Borrowed(include_bytes!(
22 "../../strato-ui-renderer/examples/assets/numbers-1000ms.gif"
23 ))),
24 _ => <Assets as RustEmbed>::get(path)
25 .map(|f| f.data)
26 .ok_or_else(|| anyhow!("no asset exists at path {}", path)),
27 }
28 }
29}
30 
31fn new_asset_cache() -> AssetCache {
32 AssetCache::new(
33 Box::new(Assets),
34 Foreground::test().into(),
35 Background::default().into(),
36 )
37}
38 
39fn load_bundled_image(
40 image_cache: &ImageCache,
41 asset_cache: &AssetCache,
42 path: &'static str,
43 bounds: Vector2I,
44 fit_type: FitType,
45 animated_image_behavior: AnimatedImageBehavior,
46) -> Rc<Image> {
47 let image = image_cache.image(
48 AssetSource::Bundled { path },
49 bounds,
50 fit_type,
51 animated_image_behavior,
52 CacheOption::BySize,
53 None,
54 asset_cache,
55 );
56 let AssetState::Loaded { data: image } = image else {
57 panic!("Bundled asset should be available immediately!");
58 };
59 image
60}
61 
62#[test]
63fn test_passes_through_asset_cache_original() {
64 let asset_cache = new_asset_cache();
65 let image_cache = ImageCache::new();
66 
67 let source = AssetSource::Bundled { path: "local.png" };
68 let image_asset: AssetState<ImageType> = asset_cache.load_asset(source.clone());
69 let AssetState::Loaded { data: image } = image_asset else {
70 panic!("Bundled asset should be available immediately!");
71 };
72 let ImageType::StaticBitmap { image } = image.as_ref() else {
73 panic!("Expected static image but got dynamic image!");
74 };
75 let image_asset_weak = Arc::downgrade(image);
76 
77 let bounds = Vector2I::new(1024, 1024);
78 let image = image_cache.image(
79 source,
80 bounds,
81 FitType::Cover,
82 AnimatedImageBehavior::FullAnimation,
83 CacheOption::Original,
84 None,
85 &asset_cache,
86 );
87 let AssetState::Loaded { data: image } = image else {
88 panic!("Bundled asset should be available immediately!");
89 };
90 let Image::Static(image) = image.as_ref() else {
91 panic!("Expected static image but got dynamic image!");
92 };
93 
94 // Assert that the image returned from the image cache and the asset stored
95 // in the asset cache point to the same underlying data (i.e.: there were
96 // no copies made).
97 assert!(image_asset_weak.ptr_eq(&Arc::downgrade(image)));
98}
99 
100#[test]
101fn test_passes_through_asset_cache_original_when_target_size_matches_source_size() {
102 let asset_cache = new_asset_cache();
103 let image_cache = ImageCache::new();
104 
105 let source = AssetSource::Bundled { path: "local.png" };
106 let image_asset: AssetState<ImageType> = asset_cache.load_asset(source.clone());
107 let AssetState::Loaded { data: image } = image_asset else {
108 panic!("Bundled asset should be available immediately!");
109 };
110 let ImageType::StaticBitmap { image } = image.as_ref() else {
111 panic!("Expected static image but got dynamic image!");
112 };
113 let image_asset_weak = Arc::downgrade(image);
114 
115 // Load the image with `CacheOption::BySize` but use the source asset's
116 // size as the bounds.
117 let bounds = image.size();
118 let image = image_cache.image(
119 source,
120 bounds,
121 FitType::Cover,
122 AnimatedImageBehavior::FullAnimation,
123 CacheOption::BySize,
124 None,
125 &asset_cache,
126 );
127 let AssetState::Loaded { data: image } = image else {
128 panic!("Bundled asset should be available immediately!");
129 };
130 let Image::Static(image) = image.as_ref() else {
131 panic!("Expected static image but got dynamic image!");
132 };
133 
134 // Assert that the image returned from the image cache and the asset stored
135 // in the asset cache point to the same underlying data (i.e.: there were
136 // no copies made).
137 assert!(image_asset_weak.ptr_eq(&Arc::downgrade(image)));
138}
139 
140#[test]
141fn test_respects_max_dimensions_for_cacheoption_original() {
142 let asset_cache = new_asset_cache();
143 let image_cache = ImageCache::new();
144 
145 // We pass a very small value for bounds, which should get ignored due to
146 // use of `CacheOption::Original`.
147 let bounds = Vector2I::new(10, 10);
148 
149 let image = image_cache.image(
150 AssetSource::Bundled { path: "local.png" },
151 bounds,
152 FitType::Cover,
153 AnimatedImageBehavior::FullAnimation,
154 CacheOption::Original,
155 None,
156 &asset_cache,
157 );
158 let AssetState::Loaded { data: image } = image else {
159 panic!("Bundled asset should be available immediately!");
160 };
161 
162 let Image::Static(image) = image.as_ref() else {
163 panic!("Expected static image but got dynamic image!");
164 };
165 // Assert that the image, without resizing or a max dimension, matches our expectations.
166 assert_eq!(image.img.dimensions(), (1024, 1024));
167 
168 let image = image_cache.image(
169 AssetSource::Bundled { path: "local.png" },
170 bounds,
171 FitType::Cover,
172 AnimatedImageBehavior::FullAnimation,
173 CacheOption::Original,
174 Some(512),
175 &asset_cache,
176 );
177 let AssetState::Loaded { data: image } = image else {
178 panic!("Bundled asset should be available immediately!");
179 };
180 
181 let Image::Static(image) = image.as_ref() else {
182 panic!("Expected static image but got dynamic image!");
183 };
184 // Assert that, when we specify a max dimension of 512, the image is resized accordingly.
185 assert_eq!(image.img.dimensions(), (512, 512));
186}
187 
188#[test]
189fn test_first_frame_preview_returns_static_for_animated_gif() {
190 let asset_cache = new_asset_cache();
191 let image_cache = ImageCache::new();
192 
193 let image = load_bundled_image(
194 &image_cache,
195 &asset_cache,
196 "numbers-1000ms.gif",
197 Vector2I::new(16, 16),
198 FitType::Contain,
199 AnimatedImageBehavior::FirstFramePreview,
200 );
201 
202 let Image::Static(image) = image.as_ref() else {
203 panic!("Expected static image but got animated image!");
204 };
205 assert_eq!(image.img.dimensions(), (16, 16));
206}
207 
208#[test]
209fn test_first_frame_preview_keeps_full_animation_in_asset_cache() {
210 let asset_cache = new_asset_cache();
211 let image_cache = ImageCache::new();
212 
213 for path in ["numbers-1000ms.gif", "animated.webp"] {
214 let image = load_bundled_image(
215 &image_cache,
216 &asset_cache,
217 path,
218 Vector2I::new(16, 16),
219 FitType::Contain,
220 AnimatedImageBehavior::FirstFramePreview,
221 );
222 
223 assert!(matches!(image.as_ref(), Image::Static(_)));
224 
225 let asset: AssetState<ImageType> = asset_cache.load_asset(AssetSource::Bundled { path });
226 let AssetState::Loaded { data } = asset else {
227 panic!("Animated asset should be available immediately!");
228 };
229 assert!(matches!(data.as_ref(), ImageType::AnimatedBitmap { .. }));
230 }
231}
232 
233#[test]
234fn test_first_frame_preview_returns_static_for_animated_webp() {
235 let asset_cache = new_asset_cache();
236 let image_cache = ImageCache::new();
237 
238 let image = load_bundled_image(
239 &image_cache,
240 &asset_cache,
241 "animated.webp",
242 Vector2I::new(16, 16),
243 FitType::Contain,
244 AnimatedImageBehavior::FirstFramePreview,
245 );
246 
247 let Image::Static(image) = image.as_ref() else {
248 panic!("Expected static image but got animated image!");
249 };
250 assert_eq!(image.img.dimensions(), (16, 16));
251}
252 
253#[test]
254fn test_full_animation_still_returns_animated_for_gif_and_webp() {
255 let asset_cache = new_asset_cache();
256 let image_cache = ImageCache::new();
257 
258 for path in ["numbers-1000ms.gif", "animated.webp"] {
259 let image = load_bundled_image(
260 &image_cache,
261 &asset_cache,
262 path,
263 Vector2I::new(16, 16),
264 FitType::Contain,
265 AnimatedImageBehavior::FullAnimation,
266 );
267 
268 let Image::Animated(image) = image.as_ref() else {
269 panic!("Expected animated image but got static image!");
270 };
271 assert!(image.frames.len() > 1);
272 }
273}
274 
275#[test]
276fn test_first_frame_preview_does_not_regress_static_formats() {
277 let asset_cache = new_asset_cache();
278 let image_cache = ImageCache::new();
279 
280 let image = load_bundled_image(
281 &image_cache,
282 &asset_cache,
283 "local.png",
284 Vector2I::new(16, 16),
285 FitType::Contain,
286 AnimatedImageBehavior::FirstFramePreview,
287 );
288 
289 let Image::Static(image) = image.as_ref() else {
290 panic!("Expected static image but got animated image!");
291 };
292 assert_eq!(image.img.dimensions(), (16, 16));
293}
294 
295#[test]
296fn test_preview_and_full_animation_requests_do_not_collide_in_rendered_image_cache() {
297 let asset_cache = new_asset_cache();
298 let image_cache = ImageCache::new();
299 let bounds = Vector2I::new(16, 16);
300 
301 let preview = load_bundled_image(
302 &image_cache,
303 &asset_cache,
304 "numbers-1000ms.gif",
305 bounds,
306 FitType::Contain,
307 AnimatedImageBehavior::FirstFramePreview,
308 );
309 let full = load_bundled_image(
310 &image_cache,
311 &asset_cache,
312 "numbers-1000ms.gif",
313 bounds,
314 FitType::Contain,
315 AnimatedImageBehavior::FullAnimation,
316 );
317 let preview_again = load_bundled_image(
318 &image_cache,
319 &asset_cache,
320 "numbers-1000ms.gif",
321 bounds,
322 FitType::Contain,
323 AnimatedImageBehavior::FirstFramePreview,
324 );
325 let full_again = load_bundled_image(
326 &image_cache,
327 &asset_cache,
328 "numbers-1000ms.gif",
329 bounds,
330 FitType::Contain,
331 AnimatedImageBehavior::FullAnimation,
332 );
333 
334 assert!(matches!(preview.as_ref(), Image::Static(_)));
335 assert!(matches!(full.as_ref(), Image::Animated(_)));
336 assert!(Rc::ptr_eq(&preview, &preview_again));
337 assert!(Rc::ptr_eq(&full, &full_again));
338 assert!(!Rc::ptr_eq(&preview, &full));
339}
340 
341#[test]
342fn test_svg_text_rasterizes_with_loaded_system_fonts() {
343 let image_type = ImageType::try_from_bytes(
344 br##"<svg width="160" height="40" viewBox="0 0 160 40" xmlns="http://www.w3.org/2000/svg">
345 <text x="10" y="24" font-size="20" fill="#000000">Warp</text>
346</svg>
347"##,
348 )
349 .expect("SVG should parse");
350 let ImageType::Svg { svg } = &image_type else {
351 panic!("Expected SVG image type");
352 };
353 let font_family = svg
354 .fontdb()
355 .faces()
356 .flat_map(|face| face.families.iter().map(|(family, _)| family.as_str()))
357 .find(|family| {
358 matches!(
359 *family,
360 "Arial"
361 | "Helvetica"
362 | "Inter"
363 | "DejaVu Sans"
364 | "Liberation Sans"
365 | "Noto Sans"
366 | "Cantarell"
367 | "Segoe UI"
368 )
369 })
370 .or_else(|| {
371 svg.fontdb()
372 .faces()
373 .find_map(|face| face.families.first().map(|(family, _)| family.as_str()))
374 })
375 .expect("System fonts should be loaded");
376 
377 let svg = format!(
378 "<svg width=\"160\" height=\"40\" viewBox=\"0 0 160 40\" xmlns=\"http://www.w3.org/2000/svg\">\
379 <text x=\"10\" y=\"24\" font-family=\"{font_family}\" font-size=\"20\" fill=\"#000000\">Warp</text>\
380</svg>"
381 );
382 
383 let image_type =
384 ImageType::try_from_bytes(svg.as_bytes()).expect("SVG with installed font should parse");
385 let image = image_type
386 .to_image(
387 Vector2I::new(160, 40),
388 FitType::Contain,
389 true,
390 AnimatedImageBehavior::FullAnimation,
391 )
392 .expect("SVG should rasterize");
393 let Image::Static(image) = image else {
394 panic!("Expected static image");
395 };
396 
397 assert!(image
398 .rgba_bytes()
399 .chunks_exact(4)
400 .any(|pixel| pixel[3] != 0));
401}
402 
403#[test]
404fn test_evict_image_drops_arc_for_resized_bysize() {
405 let asset_cache = new_asset_cache();
406 let image_cache = ImageCache::new();
407 let source = AssetSource::Bundled { path: "local.png" };
408 
409 // Request the image at a smaller size than its 1024x1024 source, which forces a resize
410 // and allocates a fresh Arc<StaticImage> not shared with AssetCache.
411 let bounds = Vector2I::new(64, 64);
412 let weak = {
413 let image = image_cache.image(
414 source.clone(),
415 bounds,
416 FitType::Cover,
417 AnimatedImageBehavior::FullAnimation,
418 CacheOption::BySize,
419 None,
420 &asset_cache,
421 );
422 let AssetState::Loaded { data: image } = image else {
423 panic!("Bundled asset should be available immediately!");
424 };
425 let Image::Static(arc) = image.as_ref() else {
426 panic!("Expected static image!");
427 };
428 Arc::downgrade(arc)
429 // The local Rc<Image> clone is dropped here; only ImageCache holds the entry now.
430 };
431 
432 assert_eq!(
433 weak.strong_count(),
434 1,
435 "ImageCache should be the sole strong holder after the caller drops its Rc clone"
436 );
437 
438 // Evicting from ImageCache should make the Arc releasable by TextureCache.
439 image_cache.evict_image(&source);
440 assert_eq!(
441 weak.strong_count(),
442 0,
443 "After evict_image, the resized Arc should have no strong holders (cascade invariant)"
444 );
445}
446 
447#[test]
448fn test_evict_size_drops_arc_only_for_targeted_entry() {
449 let asset_cache = new_asset_cache();
450 let image_cache = ImageCache::new();
451 let source = AssetSource::Bundled { path: "local.png" };
452 
453 // Cache the same asset at two distinct sizes.
454 let small_bounds = Vector2I::new(32, 32);
455 let large_bounds = Vector2I::new(256, 256);
456 
457 let weak_small = {
458 let image = image_cache.image(
459 source.clone(),
460 small_bounds,
461 FitType::Cover,
462 AnimatedImageBehavior::FullAnimation,
463 CacheOption::BySize,
464 None,
465 &asset_cache,
466 );
467 let AssetState::Loaded { data: image } = image else {
468 panic!("Bundled asset should be available immediately!");
469 };
470 let Image::Static(arc) = image.as_ref() else {
471 panic!("Expected static image!");
472 };
473 Arc::downgrade(arc)
474 };
475 
476 let weak_large = {
477 let image = image_cache.image(
478 source.clone(),
479 large_bounds,
480 FitType::Cover,
481 AnimatedImageBehavior::FullAnimation,
482 CacheOption::BySize,
483 None,
484 &asset_cache,
485 );
486 let AssetState::Loaded { data: image } = image else {
487 panic!("Bundled asset should be available immediately!");
488 };
489 let Image::Static(arc) = image.as_ref() else {
490 panic!("Expected static image!");
491 };
492 Arc::downgrade(arc)
493 };
494 
495 assert_eq!(weak_small.strong_count(), 1);
496 assert_eq!(weak_large.strong_count(), 1);
497 
498 // Evict only the small size entry.
499 image_cache.evict_size(&source, small_bounds, AnimatedImageBehavior::FullAnimation);
500 
501 assert_eq!(
502 weak_small.strong_count(),
503 0,
504 "Small size Arc should have no strong holders after evict_size"
505 );
506 assert_eq!(
507 weak_large.strong_count(),
508 1,
509 "Large size Arc should remain alive; only the small size was evicted"
510 );
511}
512 
513#[test]
514fn test_svg_image_size_returns_intrinsic_dimensions() {
515 let image_type = ImageType::try_from_bytes(
516 br##"<svg width="160" height="40" viewBox="0 0 160 40" xmlns="http://www.w3.org/2000/svg"></svg>"##,
517 )
518 .expect("SVG should parse");
519 
520 assert_eq!(image_type.image_size(), Some(Vector2I::new(160, 40)));
521}
522 
523#[test]
524fn test_respects_max_dimensions_for_cacheoption_bysize() {
525 let asset_cache = new_asset_cache();
526 let image_cache = ImageCache::new();
527 
528 let bounds = Vector2I::new(768, 768);
529 
530 let image = image_cache.image(
531 AssetSource::Bundled { path: "local.png" },
532 bounds,
533 FitType::Cover,
534 AnimatedImageBehavior::FullAnimation,
535 CacheOption::BySize,
536 None,
537 &asset_cache,
538 );
539 let AssetState::Loaded { data: image } = image else {
540 panic!("Bundled asset should be available immediately!");
541 };
542 
543 let Image::Static(image) = image.as_ref() else {
544 panic!("Expected static image but got dynamic image!");
545 };
546 // Assert that the image gets resized to match the provided bounds.
547 assert_eq!(image.img.dimensions(), (768, 768));
548 
549 let image = image_cache.image(
550 AssetSource::Bundled { path: "local.png" },
551 bounds,
552 FitType::Cover,
553 AnimatedImageBehavior::FullAnimation,
554 CacheOption::BySize,
555 Some(512),
556 &asset_cache,
557 );
558 let AssetState::Loaded { data: image } = image else {
559 panic!("Bundled asset should be available immediately!");
560 };
561 
562 let Image::Static(image) = image.as_ref() else {
563 panic!("Expected static image but got dynamic image!");
564 };
565 // Assert that, when we specify a max dimension of 512, the image is resized accordingly.
566 assert_eq!(image.img.dimensions(), (512, 512));
567}
568