oximedia_cache/content_aware_cache.rs
1//! Media-content-aware caching.
2//!
3//! Standard eviction policies treat all entries equally. For multimedia
4//! workloads, different content types have very different access patterns
5//! and cost/benefit trade-offs:
6//!
7//! * Manifests are tiny but highly re-fetched → very high priority, short TTL.
8//! * Video segments are large and often sequential → medium priority, long TTL.
9//! * Thumbnails are small and rarely re-fetched but cheap → very long TTL.
10//!
11//! [`ContentAwareCache`] layers this domain knowledge on top of
12//! [`LruCache`] so that eviction candidates are
13//! scored by a combined recency × priority × size-efficiency metric rather
14//! than pure LRU order.
15
16use std::time::{Duration, Instant};
17
18use crate::lru_cache::LruCache;
19
20// ── MediaContentType ──────────────────────────────────────────────────────────
21
22/// The media type of a cached entry.
23///
24/// The variant determines the default priority, TTL, and eviction score.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum MediaContentType {
27 /// A video segment (e.g. MPEG-DASH chunk or HLS `.ts` file).
28 VideoSegment {
29 /// Encoded bitrate in bits per second.
30 bitrate: u32,
31 /// Codec name (e.g. `"av1"`, `"vp9"`).
32 codec: String,
33 },
34 /// An audio-only segment.
35 AudioSegment {
36 /// Encoded bitrate in bits per second.
37 bitrate: u32,
38 },
39 /// A still image (e.g. JPEG or PNG frame).
40 Image {
41 /// Width in pixels.
42 width: u32,
43 /// Height in pixels.
44 height: u32,
45 },
46 /// A streaming manifest / playlist (e.g. HLS `.m3u8` or DASH `.mpd`).
47 Manifest,
48 /// A thumbnail preview image.
49 Thumbnail,
50 /// Lightweight metadata / sidecar (e.g. `.json` or `.xml` descriptor).
51 Metadata,
52}
53
54// ── ContentCachePriority ──────────────────────────────────────────────────────
55
56/// Numeric priority (higher = more important to keep in cache).
57///
58/// Derived from [`MediaContentType`] via [`ContentCachePriority::for_type`].
59#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
60pub struct ContentCachePriority(pub u8);
61
62impl ContentCachePriority {
63 /// Compute the priority for the given content type.
64 ///
65 /// | Content type | Priority |
66 /// |-------------------------------------|----------|
67 /// | Manifest | 10 |
68 /// | Thumbnail | 8 |
69 /// | VideoSegment (bitrate ≥ 4 Mbps) | 7 |
70 /// | VideoSegment (bitrate < 4 Mbps) | 6 |
71 /// | AudioSegment | 5 |
72 /// | Image | 4 |
73 /// | Metadata | 3 |
74 pub fn for_type(content_type: &MediaContentType) -> Self {
75 let p = match content_type {
76 MediaContentType::Manifest => 10,
77 MediaContentType::Thumbnail => 8,
78 MediaContentType::VideoSegment { bitrate, .. } => {
79 if *bitrate >= 4_000_000 {
80 7
81 } else {
82 6
83 }
84 }
85 MediaContentType::AudioSegment { .. } => 5,
86 MediaContentType::Image { .. } => 4,
87 MediaContentType::Metadata => 3,
88 };
89 Self(p)
90 }
91}
92
93// ── CacheEntry ────────────────────────────────────────────────────────────────
94
95/// A single entry held in [`ContentAwareCache`].
96#[derive(Debug, Clone)]
97pub struct CacheEntry {
98 /// The cache key.
99 pub key: String,
100 /// Raw payload bytes.
101 pub data: Vec<u8>,
102 /// Media content type (determines priority and TTL).
103 pub content_type: MediaContentType,
104 /// Wall-clock time at which this entry was first inserted.
105 pub inserted_at: Instant,
106 /// Wall-clock time of the most recent successful lookup.
107 pub last_accessed: Instant,
108 /// Total number of successful lookups since insertion.
109 pub access_count: u32,
110 /// `data.len()` cached to avoid recomputation.
111 pub size_bytes: usize,
112}
113
114impl CacheEntry {
115 /// Create a new entry.
116 fn new(key: String, data: Vec<u8>, content_type: MediaContentType) -> Self {
117 let size = data.len();
118 let now = Instant::now();
119 Self {
120 key,
121 data,
122 content_type,
123 inserted_at: now,
124 last_accessed: now,
125 access_count: 0,
126 size_bytes: size,
127 }
128 }
129
130 /// Compute the eviction score for this entry.
131 ///
132 /// A higher score means the entry is a *better* eviction candidate (i.e.
133 /// it should be evicted first).
134 ///
135 /// ```text
136 /// score = (1.0 - recency) × (1.0 / priority) × size_factor
137 /// ```
138 ///
139 /// Where:
140 /// * `recency = e^(-age_secs / 60.0)` — exponential decay over 1 minute.
141 /// * `priority = ContentCachePriority::for_type(content_type).0` cast to f32.
142 /// * `size_factor = size_bytes / 1_048_576.0 + 1.0` — larger entries score
143 /// higher (give bigger bang for the eviction buck).
144 pub fn score_for_eviction(&self) -> f32 {
145 let age_secs = self.last_accessed.elapsed().as_secs_f32();
146 let recency = (-age_secs / 60.0_f32).exp(); // 1.0 when just accessed, → 0 with age
147 let priority = ContentCachePriority::for_type(&self.content_type).0 as f32;
148 let size_factor = self.size_bytes as f32 / 1_048_576.0 + 1.0;
149 (1.0 - recency) * (1.0 / priority.max(0.001)) * size_factor
150 }
151}
152
153// ── TTL helpers ───────────────────────────────────────────────────────────────
154
155/// Return the recommended TTL for the given content type.
156///
157/// | Content type | TTL |
158/// |-----------------------|------------|
159/// | Manifest | 30 s |
160/// | VideoSegment | 300 s (5 min) |
161/// | AudioSegment | 300 s |
162/// | Image | 3 600 s (1 h) |
163/// | Thumbnail | 86 400 s (24 h) |
164/// | Metadata | 600 s (10 min) |
165pub fn ttl_for_type(content_type: &MediaContentType) -> Duration {
166 match content_type {
167 MediaContentType::Manifest => Duration::from_secs(30),
168 MediaContentType::VideoSegment { .. } => Duration::from_secs(300),
169 MediaContentType::AudioSegment { .. } => Duration::from_secs(300),
170 MediaContentType::Image { .. } => Duration::from_secs(3_600),
171 MediaContentType::Thumbnail => Duration::from_secs(86_400),
172 MediaContentType::Metadata => Duration::from_secs(600),
173 }
174}
175
176// ── ContentAwareCache ─────────────────────────────────────────────────────────
177
178/// A media-content-aware cache that scores eviction candidates by a
179/// recency × priority × size metric rather than pure LRU order.
180///
181/// Internally, it delegates storage to an [`LruCache<String, CacheEntry>`] for
182/// O(1) operations and maintains a live count of bytes.
183pub struct ContentAwareCache {
184 inner: LruCache<String, CacheEntry>,
185 /// Capacity in number of entries.
186 capacity: usize,
187 /// Current total size of all resident entries in bytes.
188 total_bytes: usize,
189 /// Optional byte-level capacity; entries whose cumulative size exceeds
190 /// this trigger an additional content-aware eviction pass.
191 max_bytes: Option<usize>,
192}
193
194impl ContentAwareCache {
195 /// Create a new `ContentAwareCache` with an entry-count `capacity`.
196 pub fn new(capacity: usize) -> Self {
197 Self {
198 inner: LruCache::new(capacity),
199 capacity,
200 total_bytes: 0,
201 max_bytes: None,
202 }
203 }
204
205 /// Set an optional byte-level capacity in addition to the entry count cap.
206 pub fn with_max_bytes(mut self, max_bytes: usize) -> Self {
207 self.max_bytes = Some(max_bytes);
208 self
209 }
210
211 // ── Insertion ─────────────────────────────────────────────────────────────
212
213 /// Insert a media entry.
214 ///
215 /// If the cache is at capacity, the entry with the highest eviction score
216 /// is removed first. If the new entry has a higher priority than the
217 /// current worst entry, it displaces it; otherwise the LRU entry is used
218 /// as the fallback.
219 pub fn insert_media(&mut self, key: String, data: Vec<u8>, content_type: MediaContentType) {
220 let size = data.len();
221
222 // If the key already exists, remove the old size first.
223 if let Some(old) = self.inner.remove(&key) {
224 self.total_bytes = self.total_bytes.saturating_sub(old.size_bytes);
225 }
226
227 // Enforce byte-level capacity if configured.
228 if let Some(max_bytes) = self.max_bytes {
229 while self.total_bytes + size > max_bytes && !self.inner.is_empty() {
230 self.evict_worst();
231 }
232 }
233
234 // Enforce entry-count capacity.
235 if self.inner.len() >= self.capacity {
236 self.evict_worst();
237 }
238
239 let entry = CacheEntry::new(key.clone(), data, content_type);
240 self.total_bytes += size;
241 self.inner.insert(key, entry, size);
242 }
243
244 // ── Retrieval ─────────────────────────────────────────────────────────────
245
246 /// Look up `key` and return an immutable reference to its [`CacheEntry`]
247 /// if present (updating last-accessed time).
248 pub fn get(&mut self, key: &str) -> Option<&CacheEntry> {
249 // We need to update last_accessed but LruCache::get only returns &V.
250 // We do a two-step: get (to update LRU order) then update the entry.
251 let key_owned = key.to_string();
252 if self.inner.contains(&key_owned) {
253 // Touch the entry to update `last_accessed` and `access_count`.
254 // We use `peek` to get a reference without a second LRU move, then
255 // perform a targeted update via `insert` (which handles duplicates).
256 let updated_entry = {
257 let entry = self.inner.peek(&key_owned)?;
258 let mut e = entry.clone();
259 e.last_accessed = Instant::now();
260 e.access_count = e.access_count.saturating_add(1);
261 e
262 };
263 let size = updated_entry.size_bytes;
264 // Re-insert to move to MRU head and persist the updated timestamps.
265 // Adjust total_bytes: remove old, add back same size.
266 self.total_bytes = self.total_bytes.saturating_sub(size);
267 self.inner.insert(key_owned.clone(), updated_entry, size);
268 self.total_bytes += size;
269 self.inner.peek(&key_owned)
270 } else {
271 None
272 }
273 }
274
275 /// Peek at `key` without updating access metadata or LRU order.
276 pub fn peek(&self, key: &str) -> Option<&CacheEntry> {
277 self.inner.peek(&key.to_string())
278 }
279
280 // ── Removal ───────────────────────────────────────────────────────────────
281
282 /// Explicitly remove an entry by `key`.
283 ///
284 /// Returns `true` if the entry was present.
285 pub fn remove(&mut self, key: &str) -> bool {
286 if let Some(entry) = self.inner.remove(&key.to_string()) {
287 self.total_bytes = self.total_bytes.saturating_sub(entry.size_bytes);
288 true
289 } else {
290 false
291 }
292 }
293
294 // ── TTL expiry ────────────────────────────────────────────────────────────
295
296 /// Scan all entries and remove those whose TTL has elapsed.
297 ///
298 /// Returns the number of entries evicted.
299 ///
300 /// This is an O(n) operation; callers should invoke it periodically rather
301 /// than on every access.
302 pub fn evict_expired(&mut self) -> usize {
303 // Collect expired keys — we cannot mutate inner while iterating.
304 // We use a drain-based approach: repeatedly pop the LRU until we find a
305 // non-expired entry or exhaust the cache. This is O(n) worst case but
306 // is acceptable for a maintenance sweep.
307 //
308 // A more efficient approach would require direct iteration over the
309 // backing HashMap; that's not exposed by LruCache, so we collect keys
310 // via `peek` patterns. Instead we collect entries into a scratch vec.
311 let mut expired_keys: Vec<String> = Vec::new();
312 let mut remaining: Vec<(String, CacheEntry)> = Vec::new();
313
314 // Drain by repeated LRU eviction, noting which entries are expired.
315 while let Some((k, entry)) = self.inner.evict_lru() {
316 let ttl = ttl_for_type(&entry.content_type);
317 if entry.inserted_at.elapsed() > ttl {
318 expired_keys.push(k);
319 } else {
320 remaining.push((k, entry));
321 }
322 }
323
324 // Re-insert non-expired entries.
325 for (k, entry) in remaining {
326 let size = entry.size_bytes;
327 self.inner.insert(k, entry, size);
328 }
329
330 // Recompute total_bytes.
331 self.total_bytes = 0;
332 // We cannot iterate LruCache directly; use a fresh total.
333 // Instead, drain and reinsert again is wasteful; track via expired_keys count.
334 // Correct approach: adjust total_bytes by subtracting expired sizes.
335 // Since we already re-inserted all non-expired entries, recalculate.
336 // Use the stats for a best-effort figure.
337 self.total_bytes = self.inner.stats().total_size_bytes;
338
339 expired_keys.len()
340 }
341
342 // ── Statistics ────────────────────────────────────────────────────────────
343
344 /// Return the number of entries currently in the cache.
345 pub fn len(&self) -> usize {
346 self.inner.len()
347 }
348
349 /// Return `true` when the cache has no entries.
350 pub fn is_empty(&self) -> bool {
351 self.inner.is_empty()
352 }
353
354 /// Return the total number of bytes currently stored.
355 pub fn total_bytes(&self) -> usize {
356 self.total_bytes
357 }
358
359 /// Return the entry-count capacity.
360 pub fn capacity(&self) -> usize {
361 self.capacity
362 }
363
364 // ── Internal eviction ─────────────────────────────────────────────────────
365
366 /// Evict the entry with the highest eviction score (content-aware).
367 ///
368 /// Because [`LruCache`] does not expose iteration, we must drain and
369 /// re-insert to find the worst entry. This is O(n) per eviction;
370 /// acceptable for moderate cache sizes and infrequent evictions.
371 ///
372 /// If finding the worst entry is too expensive (e.g. very large cache),
373 /// callers can fall back to plain LRU via `inner.evict_lru()` directly.
374 fn evict_worst(&mut self) {
375 if self.inner.is_empty() {
376 return;
377 }
378
379 // Drain everything, find the worst, re-insert the rest.
380 let mut entries: Vec<(String, CacheEntry)> = Vec::with_capacity(self.inner.len());
381 while let Some((k, entry)) = self.inner.evict_lru() {
382 entries.push((k, entry));
383 }
384
385 // Find the index of the highest eviction score.
386 let worst_idx = entries
387 .iter()
388 .enumerate()
389 .max_by(|(_, (_, a)), (_, (_, b))| {
390 a.score_for_eviction()
391 .partial_cmp(&b.score_for_eviction())
392 .unwrap_or(std::cmp::Ordering::Equal)
393 })
394 .map(|(i, _)| i)
395 .unwrap_or(0);
396
397 // Remove the worst entry.
398 let (_, evicted) = entries.remove(worst_idx);
399 self.total_bytes = self.total_bytes.saturating_sub(evicted.size_bytes);
400
401 // Re-insert the remaining entries.
402 for (k, entry) in entries {
403 let size = entry.size_bytes;
404 self.inner.insert(k, entry, size);
405 }
406 }
407}
408
409// ── Tests ─────────────────────────────────────────────────────────────────────
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414
415 // ── ContentCachePriority ──────────────────────────────────────────────────
416
417 #[test]
418 fn test_priority_manifest_is_highest() {
419 let p = ContentCachePriority::for_type(&MediaContentType::Manifest);
420 assert_eq!(p.0, 10);
421 }
422
423 #[test]
424 fn test_priority_thumbnail() {
425 let p = ContentCachePriority::for_type(&MediaContentType::Thumbnail);
426 assert_eq!(p.0, 8);
427 }
428
429 #[test]
430 fn test_priority_high_bitrate_video() {
431 let p = ContentCachePriority::for_type(&MediaContentType::VideoSegment {
432 bitrate: 5_000_000,
433 codec: "av1".into(),
434 });
435 assert_eq!(p.0, 7);
436 }
437
438 #[test]
439 fn test_priority_low_bitrate_video() {
440 let p = ContentCachePriority::for_type(&MediaContentType::VideoSegment {
441 bitrate: 1_000_000,
442 codec: "vp9".into(),
443 });
444 assert_eq!(p.0, 6);
445 }
446
447 #[test]
448 fn test_priority_audio() {
449 let p =
450 ContentCachePriority::for_type(&MediaContentType::AudioSegment { bitrate: 128_000 });
451 assert_eq!(p.0, 5);
452 }
453
454 #[test]
455 fn test_priority_image() {
456 let p = ContentCachePriority::for_type(&MediaContentType::Image {
457 width: 1920,
458 height: 1080,
459 });
460 assert_eq!(p.0, 4);
461 }
462
463 #[test]
464 fn test_priority_metadata() {
465 let p = ContentCachePriority::for_type(&MediaContentType::Metadata);
466 assert_eq!(p.0, 3);
467 }
468
469 // ── TTL ───────────────────────────────────────────────────────────────────
470
471 #[test]
472 fn test_ttl_manifest() {
473 assert_eq!(
474 ttl_for_type(&MediaContentType::Manifest),
475 Duration::from_secs(30)
476 );
477 }
478
479 #[test]
480 fn test_ttl_video_segment() {
481 let ct = MediaContentType::VideoSegment {
482 bitrate: 2_000_000,
483 codec: "av1".into(),
484 };
485 assert_eq!(ttl_for_type(&ct), Duration::from_secs(300));
486 }
487
488 #[test]
489 fn test_ttl_thumbnail() {
490 assert_eq!(
491 ttl_for_type(&MediaContentType::Thumbnail),
492 Duration::from_secs(86_400)
493 );
494 }
495
496 #[test]
497 fn test_ttl_image() {
498 let ct = MediaContentType::Image {
499 width: 100,
500 height: 100,
501 };
502 assert_eq!(ttl_for_type(&ct), Duration::from_secs(3_600));
503 }
504
505 // ── ContentAwareCache basic operations ────────────────────────────────────
506
507 #[test]
508 fn test_insert_and_get() {
509 let mut cache = ContentAwareCache::new(16);
510 cache.insert_media(
511 "seg1".into(),
512 vec![0u8; 1024],
513 MediaContentType::VideoSegment {
514 bitrate: 2_000_000,
515 codec: "av1".into(),
516 },
517 );
518 let entry = cache.get("seg1");
519 assert!(entry.is_some());
520 assert_eq!(entry.map(|e| e.size_bytes), Some(1024));
521 }
522
523 #[test]
524 fn test_get_absent_returns_none() {
525 let mut cache = ContentAwareCache::new(8);
526 assert!(cache.get("missing").is_none());
527 }
528
529 #[test]
530 fn test_len_and_is_empty() {
531 let mut cache = ContentAwareCache::new(8);
532 assert!(cache.is_empty());
533 cache.insert_media("m".into(), vec![1, 2], MediaContentType::Manifest);
534 assert_eq!(cache.len(), 1);
535 assert!(!cache.is_empty());
536 }
537
538 #[test]
539 fn test_remove() {
540 let mut cache = ContentAwareCache::new(8);
541 cache.insert_media("key".into(), vec![0u8; 512], MediaContentType::Metadata);
542 assert!(cache.remove("key"));
543 assert!(cache.get("key").is_none());
544 }
545
546 #[test]
547 fn test_remove_absent() {
548 let mut cache = ContentAwareCache::new(8);
549 assert!(!cache.remove("ghost"));
550 }
551
552 #[test]
553 fn test_total_bytes_tracking() {
554 let mut cache = ContentAwareCache::new(16);
555 cache.insert_media("a".into(), vec![0u8; 100], MediaContentType::Manifest);
556 cache.insert_media("b".into(), vec![0u8; 200], MediaContentType::Metadata);
557 assert_eq!(cache.total_bytes(), 300);
558 cache.remove("a");
559 assert_eq!(cache.total_bytes(), 200);
560 }
561
562 #[test]
563 fn test_capacity_reported() {
564 let cache = ContentAwareCache::new(32);
565 assert_eq!(cache.capacity(), 32);
566 }
567
568 // ── Eviction scoring ─────────────────────────────────────────────────────
569
570 #[test]
571 fn test_score_for_eviction_just_inserted_is_low() {
572 let entry = CacheEntry::new("k".into(), vec![0u8; 100], MediaContentType::Manifest);
573 // Just inserted → recency ≈ 1.0 → (1 - 1) * … ≈ 0.
574 let score = entry.score_for_eviction();
575 assert!(
576 score < 0.1,
577 "fresh entry should have low eviction score, got {score}"
578 );
579 }
580
581 #[test]
582 fn test_score_low_priority_higher_than_high_priority() {
583 // An aged metadata entry should score higher than an aged manifest entry
584 // because metadata has lower priority (easier to re-fetch).
585 let manifest_entry =
586 CacheEntry::new("m".into(), vec![0u8; 100], MediaContentType::Manifest);
587 let meta_entry = CacheEntry::new("d".into(), vec![0u8; 100], MediaContentType::Metadata);
588 // Force age by checking the formula with same recency.
589 // priority_manifest = 10, priority_metadata = 3 → 1/3 > 1/10.
590 let p_manifest = ContentCachePriority::for_type(&MediaContentType::Manifest).0 as f32;
591 let p_meta = ContentCachePriority::for_type(&MediaContentType::Metadata).0 as f32;
592 assert!(
593 1.0 / p_meta > 1.0 / p_manifest,
594 "metadata entry should evict before manifest"
595 );
596 drop(manifest_entry);
597 drop(meta_entry);
598 }
599
600 // ── Content-aware eviction when at capacity ───────────────────────────────
601
602 #[test]
603 fn test_eviction_prefers_low_priority_entries() {
604 // Capacity of 2: insert a Manifest + a Metadata.
605 // Then insert a third entry. The Metadata (priority=3) should be evicted
606 // over Manifest (priority=10), all else being equal.
607 let mut cache = ContentAwareCache::new(2);
608 // Insert manifest and metadata with tiny data to avoid timing noise.
609 cache.insert_media("manifest".into(), vec![0u8; 1], MediaContentType::Manifest);
610 cache.insert_media("meta".into(), vec![0u8; 1], MediaContentType::Metadata);
611 // Force the manifest to be "recently used" relative to metadata.
612 let _ = cache.get("manifest");
613 // Insert third entry to trigger eviction.
614 cache.insert_media(
615 "new".into(),
616 vec![0u8; 1],
617 MediaContentType::VideoSegment {
618 bitrate: 2_000_000,
619 codec: "av1".into(),
620 },
621 );
622 assert_eq!(cache.len(), 2);
623 // Manifest should still be present (higher priority).
624 assert!(
625 cache.peek("manifest").is_some(),
626 "manifest should survive eviction"
627 );
628 }
629
630 // ── access_count and last_accessed updates ────────────────────────────────
631
632 #[test]
633 fn test_access_count_increments_on_get() {
634 let mut cache = ContentAwareCache::new(8);
635 cache.insert_media("k".into(), vec![1, 2, 3], MediaContentType::Thumbnail);
636 cache.get("k");
637 cache.get("k");
638 let count = cache.peek("k").map(|e| e.access_count).unwrap_or(0);
639 assert_eq!(count, 2, "access_count should be 2 after two gets");
640 }
641
642 // ── Byte-level capacity ───────────────────────────────────────────────────
643
644 #[test]
645 fn test_max_bytes_triggers_eviction() {
646 let mut cache = ContentAwareCache::new(100).with_max_bytes(500);
647 // Insert 5 × 100-byte entries = 500 bytes (at limit).
648 for i in 0..5u32 {
649 cache.insert_media(
650 format!("seg_{i}"),
651 vec![0u8; 100],
652 MediaContentType::AudioSegment { bitrate: 128_000 },
653 );
654 }
655 assert!(cache.total_bytes() <= 500);
656 // Insert one more → must evict to stay within budget.
657 cache.insert_media("extra".into(), vec![0u8; 100], MediaContentType::Metadata);
658 assert!(
659 cache.total_bytes() <= 500,
660 "total bytes exceeded budget: {}",
661 cache.total_bytes()
662 );
663 }
664
665 // ── Peek does not update metadata ─────────────────────────────────────────
666
667 #[test]
668 fn test_peek_does_not_change_access_count() {
669 let mut cache = ContentAwareCache::new(8);
670 cache.insert_media("p".into(), vec![99], MediaContentType::Manifest);
671 let before = cache.peek("p").map(|e| e.access_count).unwrap_or(99);
672 let _ = cache.peek("p");
673 let after = cache.peek("p").map(|e| e.access_count).unwrap_or(99);
674 assert_eq!(before, after, "peek must not change access_count");
675 }
676
677 // ── Upsert behaviour ─────────────────────────────────────────────────────
678
679 #[test]
680 fn test_insert_same_key_updates_value() {
681 let mut cache = ContentAwareCache::new(8);
682 cache.insert_media("k".into(), vec![1, 2, 3], MediaContentType::Manifest);
683 cache.insert_media("k".into(), vec![10, 20], MediaContentType::Manifest);
684 assert_eq!(cache.len(), 1, "duplicate key should not increase len");
685 assert_eq!(
686 cache.total_bytes(),
687 2,
688 "total_bytes should reflect updated size"
689 );
690 }
691
692 // ── evict_expired ─────────────────────────────────────────────────────────
693
694 #[test]
695 fn test_evict_expired_no_entries() {
696 let mut cache = ContentAwareCache::new(8);
697 assert_eq!(cache.evict_expired(), 0);
698 }
699
700 #[test]
701 fn test_evict_expired_fresh_entries_survive() {
702 let mut cache = ContentAwareCache::new(8);
703 cache.insert_media("fresh".into(), vec![0u8; 10], MediaContentType::Manifest);
704 // Manifest TTL is 30 s; the entry was just inserted → should not expire.
705 let evicted = cache.evict_expired();
706 assert_eq!(evicted, 0);
707 assert!(cache.peek("fresh").is_some());
708 }
709}