wsi_streamer/tile/
service.rs

1//! Tile Service for orchestrating tile generation.
2//!
3//! The TileService is the main entry point for tile requests. It orchestrates:
4//! - Request validation
5//! - Cache lookups
6//! - Slide access via registry
7//! - JPEG decoding and re-encoding
8//! - Result caching
9//!
10//! # Architecture
11//!
12//! ```text
13//! ┌─────────────────────────────────────────────────────────────────┐
14//! │                         TileService                              │
15//! │  ┌─────────────────────────────────────────────────────────┐    │
16//! │  │                    get_tile()                           │    │
17//! │  │  1. Validate params   4. Read tile from slide           │    │
18//! │  │  2. Check cache       5. Encode at quality              │    │
19//! │  │  3. Get slide         6. Cache & return                 │    │
20//! │  └─────────────────────────────────────────────────────────┘    │
21//! │           │                    │                    │            │
22//! │           ▼                    ▼                    ▼            │
23//! │    ┌───────────┐      ┌──────────────┐    ┌──────────────────┐  │
24//! │    │ TileCache │      │ SlideRegistry│    │ JpegTileEncoder  │  │
25//! │    └───────────┘      └──────────────┘    └──────────────────┘  │
26//! └─────────────────────────────────────────────────────────────────┘
27//! ```
28
29use std::sync::Arc;
30
31use bytes::Bytes;
32use image::codecs::jpeg::JpegEncoder;
33use image::{DynamicImage, ImageReader, RgbImage};
34use std::io::Cursor;
35
36use crate::error::TileError;
37use crate::slide::{SlideRegistry, SlideSource};
38
39use super::cache::{TileCache, TileCacheKey};
40use super::encoder::{is_valid_quality, JpegTileEncoder, DEFAULT_JPEG_QUALITY};
41
42// =============================================================================
43// Tile Request
44// =============================================================================
45
46/// A request for a tile.
47///
48/// This struct contains all parameters needed to identify and render a tile.
49#[derive(Debug, Clone)]
50pub struct TileRequest {
51    /// Slide identifier (e.g., S3 path)
52    pub slide_id: String,
53
54    /// Pyramid level (0 = highest resolution)
55    pub level: usize,
56
57    /// Tile X coordinate (0-indexed from left)
58    pub tile_x: u32,
59
60    /// Tile Y coordinate (0-indexed from top)
61    pub tile_y: u32,
62
63    /// JPEG quality (1-100, defaults to 80)
64    pub quality: u8,
65}
66
67impl TileRequest {
68    /// Create a new tile request with default quality.
69    pub fn new(slide_id: impl Into<String>, level: usize, tile_x: u32, tile_y: u32) -> Self {
70        Self {
71            slide_id: slide_id.into(),
72            level,
73            tile_x,
74            tile_y,
75            quality: DEFAULT_JPEG_QUALITY,
76        }
77    }
78
79    /// Create a new tile request with specified quality.
80    pub fn with_quality(
81        slide_id: impl Into<String>,
82        level: usize,
83        tile_x: u32,
84        tile_y: u32,
85        quality: u8,
86    ) -> Self {
87        Self {
88            slide_id: slide_id.into(),
89            level,
90            tile_x,
91            tile_y,
92            quality,
93        }
94    }
95}
96
97// =============================================================================
98// Tile Response
99// =============================================================================
100
101/// Response from the tile service.
102#[derive(Debug, Clone)]
103pub struct TileResponse {
104    /// The encoded JPEG tile data
105    pub data: Bytes,
106
107    /// Whether this tile was served from cache
108    pub cache_hit: bool,
109
110    /// The JPEG quality used for encoding
111    pub quality: u8,
112}
113
114// =============================================================================
115// Tile Service
116// =============================================================================
117
118/// Service for generating and caching tiles.
119///
120/// The TileService orchestrates the full tile pipeline:
121/// 1. Validates request parameters
122/// 2. Checks the tile cache for existing results
123/// 3. Fetches the slide from the registry
124/// 4. Reads raw tile data from the slide
125/// 5. Decodes and re-encodes at the requested quality
126/// 6. Caches and returns the result
127///
128/// # Type Parameters
129///
130/// * `S` - The slide source type (e.g., S3-based source)
131///
132/// # Example
133///
134/// ```ignore
135/// use wsi_streamer::tile::{TileService, TileRequest};
136/// use wsi_streamer::slide::SlideRegistry;
137///
138/// // Create registry and service
139/// let registry = SlideRegistry::new(source);
140/// let service = TileService::new(registry);
141///
142/// // Request a tile
143/// let request = TileRequest::new("slides/sample.svs", 0, 1, 2);
144/// let response = service.get_tile(request).await?;
145///
146/// println!("Tile size: {} bytes, cache hit: {}", response.data.len(), response.cache_hit);
147/// ```
148pub struct TileService<S: SlideSource> {
149    /// The slide registry for accessing slides
150    registry: Arc<SlideRegistry<S>>,
151
152    /// Cache for encoded tiles
153    cache: TileCache,
154
155    /// JPEG encoder
156    encoder: JpegTileEncoder,
157}
158
159impl<S: SlideSource> TileService<S> {
160    /// Create a new tile service with default cache settings.
161    ///
162    /// Uses default tile cache capacity (100MB).
163    pub fn new(registry: SlideRegistry<S>) -> Self {
164        Self {
165            registry: Arc::new(registry),
166            cache: TileCache::new(),
167            encoder: JpegTileEncoder::new(),
168        }
169    }
170
171    /// Create a new tile service with a shared registry.
172    ///
173    /// This allows multiple services or components to share the same registry.
174    pub fn with_shared_registry(registry: Arc<SlideRegistry<S>>) -> Self {
175        Self {
176            registry,
177            cache: TileCache::new(),
178            encoder: JpegTileEncoder::new(),
179        }
180    }
181
182    /// Create a new tile service with custom cache capacity.
183    ///
184    /// # Arguments
185    ///
186    /// * `registry` - The slide registry
187    /// * `cache_capacity` - Maximum tile cache size in bytes
188    pub fn with_cache_capacity(registry: SlideRegistry<S>, cache_capacity: usize) -> Self {
189        Self {
190            registry: Arc::new(registry),
191            cache: TileCache::with_capacity(cache_capacity),
192            encoder: JpegTileEncoder::new(),
193        }
194    }
195
196    /// Get a tile, using cache when available.
197    ///
198    /// This is the main entry point for tile requests. It:
199    /// 1. Validates the request parameters
200    /// 2. Checks the cache for an existing tile
201    /// 3. If not cached, fetches from the slide and encodes
202    /// 4. Caches and returns the result
203    ///
204    /// # Errors
205    ///
206    /// Returns an error if:
207    /// - The slide cannot be found or opened
208    /// - The level is out of range
209    /// - The tile coordinates are out of bounds
210    /// - The tile data cannot be decoded or encoded
211    pub async fn get_tile(&self, request: TileRequest) -> Result<TileResponse, TileError> {
212        // Validate quality
213        if !is_valid_quality(request.quality) {
214            return Err(TileError::InvalidQuality {
215                quality: request.quality,
216            });
217        }
218        let quality = request.quality;
219
220        // Create cache key
221        let cache_key = TileCacheKey::new(
222            request.slide_id.as_str(),
223            request.level as u32,
224            request.tile_x,
225            request.tile_y,
226            quality,
227        );
228
229        // Check cache first
230        if let Some(cached_data) = self.cache.get(&cache_key).await {
231            return Ok(TileResponse {
232                data: cached_data,
233                cache_hit: true,
234                quality,
235            });
236        }
237
238        // Cache miss - need to generate tile
239        let tile_data = self.generate_tile(&request, quality).await?;
240
241        // Cache the result
242        self.cache.put(cache_key, tile_data.clone()).await;
243
244        Ok(TileResponse {
245            data: tile_data,
246            cache_hit: false,
247            quality,
248        })
249    }
250
251    /// Generate a tile without caching.
252    ///
253    /// This is useful for one-off requests or when you want to bypass the cache.
254    pub async fn generate_tile(
255        &self,
256        request: &TileRequest,
257        quality: u8,
258    ) -> Result<Bytes, TileError> {
259        // Get the slide from registry
260        let slide = self
261            .registry
262            .get_slide(&request.slide_id)
263            .await
264            .map_err(|e| match e {
265                crate::error::FormatError::Io(io_err) => {
266                    if matches!(io_err, crate::error::IoError::NotFound(_)) {
267                        TileError::SlideNotFound {
268                            slide_id: request.slide_id.clone(),
269                        }
270                    } else {
271                        TileError::Io(io_err)
272                    }
273                }
274                crate::error::FormatError::Tiff(tiff_err) => TileError::Slide(tiff_err),
275                crate::error::FormatError::UnsupportedFormat { reason } => {
276                    TileError::Slide(crate::error::TiffError::InvalidTagValue {
277                        tag: "Format",
278                        message: reason,
279                    })
280                }
281            })?;
282
283        // Validate level
284        let level_count = slide.level_count();
285        if request.level >= level_count {
286            return Err(TileError::InvalidLevel {
287                level: request.level,
288                max_levels: level_count,
289            });
290        }
291
292        // Validate tile coordinates
293        let (max_x, max_y) = slide
294            .tile_count(request.level)
295            .ok_or(TileError::InvalidLevel {
296                level: request.level,
297                max_levels: level_count,
298            })?;
299
300        if request.tile_x >= max_x || request.tile_y >= max_y {
301            return Err(TileError::TileOutOfBounds {
302                level: request.level,
303                x: request.tile_x,
304                y: request.tile_y,
305                max_x,
306                max_y,
307            });
308        }
309
310        // Read the raw tile data from the slide
311        let raw_tile = slide
312            .read_tile(request.level, request.tile_x, request.tile_y)
313            .await?;
314
315        // Decode and re-encode at the requested quality
316        let encoded_tile = self.encoder.encode(&raw_tile, quality)?;
317
318        Ok(encoded_tile)
319    }
320
321    /// Get tile cache statistics.
322    ///
323    /// Returns `(current_size, capacity, entry_count)`.
324    pub async fn cache_stats(&self) -> (usize, usize, usize) {
325        let size = self.cache.size().await;
326        let capacity = self.cache.capacity();
327        let count = self.cache.len().await;
328        (size, capacity, count)
329    }
330
331    /// Clear the tile cache.
332    pub async fn clear_cache(&self) {
333        self.cache.clear().await;
334    }
335
336    /// Invalidate cached tiles for a specific slide.
337    ///
338    /// This removes all cached tiles for the given slide from the tile cache.
339    /// Note: This is O(n) where n is the number of cached tiles.
340    pub async fn invalidate_slide(&self, _slide_id: &str) {
341        // TODO: Implement efficient per-slide invalidation
342        // For now, this would require iterating the cache which isn't supported
343        // by the LRU cache. A production implementation might use a different
344        // data structure or maintain a secondary index.
345    }
346
347    /// Get a reference to the underlying registry.
348    pub fn registry(&self) -> &Arc<SlideRegistry<S>> {
349        &self.registry
350    }
351
352    /// Generate a thumbnail for a slide.
353    ///
354    /// This finds the lowest resolution level that fits within the requested
355    /// max dimension and returns a tile or composited image.
356    ///
357    /// # Arguments
358    ///
359    /// * `slide_id` - The slide identifier
360    /// * `max_dimension` - Maximum width or height for the thumbnail
361    /// * `quality` - JPEG quality (1-100)
362    ///
363    /// # Returns
364    ///
365    /// A JPEG-encoded thumbnail image.
366    pub async fn generate_thumbnail(
367        &self,
368        slide_id: &str,
369        max_dimension: u32,
370        quality: u8,
371    ) -> Result<TileResponse, TileError> {
372        // Validate quality
373        if !is_valid_quality(quality) {
374            return Err(TileError::InvalidQuality { quality });
375        }
376
377        // Get the slide from registry
378        let slide = self
379            .registry
380            .get_slide(slide_id)
381            .await
382            .map_err(|e| match e {
383                crate::error::FormatError::Io(io_err) => {
384                    if matches!(io_err, crate::error::IoError::NotFound(_)) {
385                        TileError::SlideNotFound {
386                            slide_id: slide_id.to_string(),
387                        }
388                    } else {
389                        TileError::Io(io_err)
390                    }
391                }
392                crate::error::FormatError::Tiff(tiff_err) => TileError::Slide(tiff_err),
393                crate::error::FormatError::UnsupportedFormat { reason } => {
394                    TileError::Slide(crate::error::TiffError::InvalidTagValue {
395                        tag: "Format",
396                        message: reason,
397                    })
398                }
399            })?;
400
401        let (full_width, full_height) = slide.dimensions().ok_or(TileError::InvalidLevel {
402            level: 0,
403            max_levels: 0,
404        })?;
405
406        // Calculate target downsample
407        let max_dim = full_width.max(full_height);
408        let downsample = max_dim as f64 / max_dimension as f64;
409
410        // Find best level for this downsample (or use lowest resolution level)
411        let level = slide
412            .best_level_for_downsample(downsample)
413            .unwrap_or(slide.level_count().saturating_sub(1));
414
415        let info = slide.level_info(level).ok_or(TileError::InvalidLevel {
416            level,
417            max_levels: slide.level_count(),
418        })?;
419
420        // If single tile covers the entire level, just return that tile
421        // (resize if needed to fit max_dimension)
422        if info.tiles_x == 1 && info.tiles_y == 1 {
423            let request = TileRequest::with_quality(slide_id, level, 0, 0, quality);
424            let tile_response = self.get_tile(request).await?;
425
426            // Resize if the tile is larger than max_dimension
427            if info.width > max_dimension || info.height > max_dimension {
428                let resized = self.resize_image(&tile_response.data, max_dimension, quality)?;
429                return Ok(TileResponse {
430                    data: resized,
431                    cache_hit: false,
432                    quality,
433                });
434            }
435
436            return Ok(tile_response);
437        }
438
439        // For multiple tiles, composite them into a single image
440        let composite = self
441            .composite_level_tiles(slide_id, level, &info, quality)
442            .await?;
443
444        // Resize the composite to fit within max_dimension
445        let resized = self.resize_image(&composite, max_dimension, quality)?;
446
447        Ok(TileResponse {
448            data: resized,
449            cache_hit: false,
450            quality,
451        })
452    }
453
454    /// Composite all tiles from a level into a single image.
455    async fn composite_level_tiles(
456        &self,
457        slide_id: &str,
458        level: usize,
459        info: &crate::slide::LevelInfo,
460        quality: u8,
461    ) -> Result<Bytes, TileError> {
462        // Create a canvas for the full level
463        let mut canvas = RgbImage::new(info.width, info.height);
464
465        // Read and place each tile
466        for tile_y in 0..info.tiles_y {
467            for tile_x in 0..info.tiles_x {
468                let request = TileRequest::with_quality(slide_id, level, tile_x, tile_y, quality);
469                let tile_response = self.get_tile(request).await?;
470
471                // Decode the tile
472                let cursor = Cursor::new(&tile_response.data[..]);
473                let reader = ImageReader::with_format(cursor, image::ImageFormat::Jpeg);
474                let tile_img = reader.decode().map_err(|e| TileError::DecodeError {
475                    message: format!("Failed to decode tile ({}, {}): {}", tile_x, tile_y, e),
476                })?;
477
478                // Calculate position on canvas
479                let x_pos = tile_x * info.tile_width;
480                let y_pos = tile_y * info.tile_height;
481
482                // Convert tile to RGB and copy to canvas
483                let tile_rgb = tile_img.to_rgb8();
484
485                // Copy pixels to canvas (handling edge tiles that may be smaller)
486                for (ty, row) in tile_rgb.rows().enumerate() {
487                    for (tx, pixel) in row.enumerate() {
488                        let canvas_x = x_pos + tx as u32;
489                        let canvas_y = y_pos + ty as u32;
490                        if canvas_x < info.width && canvas_y < info.height {
491                            canvas.put_pixel(canvas_x, canvas_y, *pixel);
492                        }
493                    }
494                }
495            }
496        }
497
498        // Encode the composite as JPEG
499        let mut output = Vec::new();
500        let mut encoder = JpegEncoder::new_with_quality(&mut output, quality);
501        encoder
502            .encode_image(&DynamicImage::ImageRgb8(canvas))
503            .map_err(|e| TileError::EncodeError {
504                message: format!("Failed to encode composite: {}", e),
505            })?;
506
507        Ok(Bytes::from(output))
508    }
509
510    /// Resize an image to fit within max_dimension while preserving aspect ratio.
511    fn resize_image(
512        &self,
513        jpeg_data: &[u8],
514        max_dimension: u32,
515        quality: u8,
516    ) -> Result<Bytes, TileError> {
517        // Decode the source image
518        let cursor = Cursor::new(jpeg_data);
519        let reader = ImageReader::with_format(cursor, image::ImageFormat::Jpeg);
520        let img = reader.decode().map_err(|e| TileError::DecodeError {
521            message: format!("Failed to decode image for resize: {}", e),
522        })?;
523
524        let (width, height) = (img.width(), img.height());
525
526        // Calculate new dimensions maintaining aspect ratio
527        let scale = max_dimension as f64 / width.max(height) as f64;
528        let new_width = (width as f64 * scale).round() as u32;
529        let new_height = (height as f64 * scale).round() as u32;
530
531        // Resize using high-quality Lanczos3 filter
532        let resized =
533            img.resize_exact(new_width, new_height, image::imageops::FilterType::Lanczos3);
534
535        // Encode as JPEG
536        let mut output = Vec::new();
537        let mut encoder = JpegEncoder::new_with_quality(&mut output, quality);
538        encoder
539            .encode_image(&resized)
540            .map_err(|e| TileError::EncodeError {
541                message: format!("Failed to encode resized image: {}", e),
542            })?;
543
544        Ok(Bytes::from(output))
545    }
546}
547
548// =============================================================================
549// Tests
550// =============================================================================
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555    use crate::error::IoError;
556    use crate::io::RangeReader;
557    use crate::slide::SlideSource;
558    use async_trait::async_trait;
559    use image::codecs::jpeg::JpegEncoder;
560    use image::{GrayImage, Luma};
561
562    /// Create a test JPEG image
563    fn create_test_jpeg() -> Vec<u8> {
564        let img = GrayImage::from_fn(256, 256, |x, y| {
565            let val = ((x + y) % 256) as u8;
566            Luma([val])
567        });
568
569        let mut buf = Vec::new();
570        let mut encoder = JpegEncoder::new_with_quality(&mut buf, 90);
571        encoder.encode_image(&img).unwrap();
572        buf
573    }
574
575    /// Create a minimal valid TIFF file with actual JPEG tile data
576    fn create_tiff_with_jpeg_tile() -> Vec<u8> {
577        let jpeg_data = create_test_jpeg();
578        let jpeg_len = jpeg_data.len() as u32;
579
580        // We need enough space for the TIFF structure + JPEG data
581        let tile_data_offset = 1000u32;
582        let total_size = tile_data_offset as usize + jpeg_data.len() + 100;
583        let mut data = vec![0u8; total_size];
584
585        // Little-endian TIFF header
586        data[0] = 0x49; // 'I'
587        data[1] = 0x49; // 'I'
588        data[2] = 0x2A; // Version 42
589        data[3] = 0x00;
590        data[4] = 0x08; // First IFD at offset 8
591        data[5] = 0x00;
592        data[6] = 0x00;
593        data[7] = 0x00;
594
595        // IFD at offset 8
596        // Entry count = 8
597        data[8] = 0x08;
598        data[9] = 0x00;
599
600        let mut offset = 10;
601
602        // Helper to write IFD entry
603        let write_entry =
604            |data: &mut [u8], offset: &mut usize, tag: u16, typ: u16, count: u32, value: u32| {
605                data[*offset..*offset + 2].copy_from_slice(&tag.to_le_bytes());
606                data[*offset + 2..*offset + 4].copy_from_slice(&typ.to_le_bytes());
607                data[*offset + 4..*offset + 8].copy_from_slice(&count.to_le_bytes());
608                data[*offset + 8..*offset + 12].copy_from_slice(&value.to_le_bytes());
609                *offset += 12;
610            };
611
612        // ImageWidth (2048)
613        write_entry(&mut data, &mut offset, 256, 4, 1, 2048);
614
615        // ImageLength (1536)
616        write_entry(&mut data, &mut offset, 257, 4, 1, 1536);
617
618        // Compression (7 = JPEG)
619        write_entry(&mut data, &mut offset, 259, 3, 1, 7);
620
621        // TileWidth (256)
622        write_entry(&mut data, &mut offset, 322, 3, 1, 256);
623
624        // TileLength (256)
625        write_entry(&mut data, &mut offset, 323, 3, 1, 256);
626
627        // TileOffsets - 8x6=48 tiles, all pointing to same JPEG data for simplicity
628        // Store offsets at position 200
629        write_entry(&mut data, &mut offset, 324, 4, 48, 200);
630
631        // TileByteCounts - all tiles have same size
632        write_entry(&mut data, &mut offset, 325, 4, 48, 600);
633
634        // BitsPerSample
635        write_entry(&mut data, &mut offset, 258, 3, 1, 8);
636
637        // Next IFD offset (0 = no more IFDs)
638        data[offset..offset + 4].copy_from_slice(&0u32.to_le_bytes());
639
640        // Write tile offsets array at offset 200 (all point to same tile data)
641        for i in 0..48u32 {
642            let arr_offset = 200 + (i as usize) * 4;
643            data[arr_offset..arr_offset + 4].copy_from_slice(&tile_data_offset.to_le_bytes());
644        }
645
646        // Write tile byte counts array at offset 600
647        for i in 0..48u32 {
648            let arr_offset = 600 + (i as usize) * 4;
649            data[arr_offset..arr_offset + 4].copy_from_slice(&jpeg_len.to_le_bytes());
650        }
651
652        // Write the actual JPEG tile data
653        data[tile_data_offset as usize..tile_data_offset as usize + jpeg_data.len()]
654            .copy_from_slice(&jpeg_data);
655
656        data
657    }
658
659    /// Mock range reader
660    struct MockReader {
661        data: Bytes,
662        identifier: String,
663    }
664
665    #[async_trait]
666    impl RangeReader for MockReader {
667        async fn read_exact_at(&self, offset: u64, len: usize) -> Result<Bytes, IoError> {
668            let start = offset as usize;
669            let end = start + len;
670            if end > self.data.len() {
671                return Err(IoError::RangeOutOfBounds {
672                    offset,
673                    requested: len as u64,
674                    size: self.data.len() as u64,
675                });
676            }
677            Ok(self.data.slice(start..end))
678        }
679
680        fn size(&self) -> u64 {
681            self.data.len() as u64
682        }
683
684        fn identifier(&self) -> &str {
685            &self.identifier
686        }
687    }
688
689    /// Mock slide source
690    struct MockSlideSource {
691        data: Bytes,
692    }
693
694    impl MockSlideSource {
695        fn new(data: Vec<u8>) -> Self {
696            Self {
697                data: Bytes::from(data),
698            }
699        }
700    }
701
702    #[async_trait]
703    impl SlideSource for MockSlideSource {
704        type Reader = MockReader;
705
706        async fn create_reader(&self, slide_id: &str) -> Result<Self::Reader, IoError> {
707            if slide_id.contains("notfound") {
708                return Err(IoError::NotFound(slide_id.to_string()));
709            }
710            Ok(MockReader {
711                data: self.data.clone(),
712                identifier: format!("mock://{}", slide_id),
713            })
714        }
715    }
716
717    #[tokio::test]
718    async fn test_tile_request_creation() {
719        let request = TileRequest::new("test.svs", 0, 1, 2);
720        assert_eq!(request.slide_id, "test.svs");
721        assert_eq!(request.level, 0);
722        assert_eq!(request.tile_x, 1);
723        assert_eq!(request.tile_y, 2);
724        assert_eq!(request.quality, DEFAULT_JPEG_QUALITY);
725
726        let request_q = TileRequest::with_quality("test.svs", 1, 3, 4, 95);
727        assert_eq!(request_q.quality, 95);
728    }
729
730    #[tokio::test]
731    async fn test_get_tile_success() {
732        let tiff_data = create_tiff_with_jpeg_tile();
733        let source = MockSlideSource::new(tiff_data);
734        let registry = SlideRegistry::new(source);
735        let service = TileService::new(registry);
736
737        let request = TileRequest::new("test.tif", 0, 0, 0);
738        let response = service.get_tile(request).await;
739
740        assert!(response.is_ok());
741        let response = response.unwrap();
742
743        // Should be a cache miss on first request
744        assert!(!response.cache_hit);
745        assert_eq!(response.quality, DEFAULT_JPEG_QUALITY);
746
747        // Verify it's valid JPEG
748        assert!(response.data.len() > 2);
749        assert_eq!(response.data[0], 0xFF);
750        assert_eq!(response.data[1], 0xD8);
751    }
752
753    #[tokio::test]
754    async fn test_get_tile_cache_hit() {
755        let tiff_data = create_tiff_with_jpeg_tile();
756        let source = MockSlideSource::new(tiff_data);
757        let registry = SlideRegistry::new(source);
758        let service = TileService::new(registry);
759
760        let request = TileRequest::new("test.tif", 0, 0, 0);
761
762        // First request - cache miss
763        let response1 = service.get_tile(request.clone()).await.unwrap();
764        assert!(!response1.cache_hit);
765
766        // Second request - cache hit
767        let response2 = service.get_tile(request).await.unwrap();
768        assert!(response2.cache_hit);
769        assert_eq!(response1.data, response2.data);
770    }
771
772    #[tokio::test]
773    async fn test_different_quality_different_cache() {
774        let tiff_data = create_tiff_with_jpeg_tile();
775        let source = MockSlideSource::new(tiff_data);
776        let registry = SlideRegistry::new(source);
777        let service = TileService::new(registry);
778
779        let request_q80 = TileRequest::with_quality("test.tif", 0, 0, 0, 80);
780        let request_q95 = TileRequest::with_quality("test.tif", 0, 0, 0, 95);
781
782        // Request at quality 80
783        let response1 = service.get_tile(request_q80.clone()).await.unwrap();
784        assert!(!response1.cache_hit);
785
786        // Request at quality 95 - should be cache miss (different quality)
787        let response2 = service.get_tile(request_q95).await.unwrap();
788        assert!(!response2.cache_hit);
789
790        // Request at quality 80 again - should be cache hit
791        let response3 = service.get_tile(request_q80).await.unwrap();
792        assert!(response3.cache_hit);
793    }
794
795    #[tokio::test]
796    async fn test_invalid_level() {
797        let tiff_data = create_tiff_with_jpeg_tile();
798        let source = MockSlideSource::new(tiff_data);
799        let registry = SlideRegistry::new(source);
800        let service = TileService::new(registry);
801
802        // Request level 5 when only level 0 exists
803        let request = TileRequest::new("test.tif", 5, 0, 0);
804        let result = service.get_tile(request).await;
805
806        assert!(result.is_err());
807        match result.unwrap_err() {
808            TileError::InvalidLevel { level, max_levels } => {
809                assert_eq!(level, 5);
810                assert_eq!(max_levels, 1);
811            }
812            e => panic!("Expected InvalidLevel error, got {:?}", e),
813        }
814    }
815
816    #[tokio::test]
817    async fn test_tile_out_of_bounds() {
818        let tiff_data = create_tiff_with_jpeg_tile();
819        let source = MockSlideSource::new(tiff_data);
820        let registry = SlideRegistry::new(source);
821        let service = TileService::new(registry);
822
823        // Request tile (100, 100) when max is (8, 6)
824        let request = TileRequest::new("test.tif", 0, 100, 100);
825        let result = service.get_tile(request).await;
826
827        assert!(result.is_err());
828        match result.unwrap_err() {
829            TileError::TileOutOfBounds {
830                level,
831                x,
832                y,
833                max_x,
834                max_y,
835            } => {
836                assert_eq!(level, 0);
837                assert_eq!(x, 100);
838                assert_eq!(y, 100);
839                assert_eq!(max_x, 8);
840                assert_eq!(max_y, 6);
841            }
842            e => panic!("Expected TileOutOfBounds error, got {:?}", e),
843        }
844    }
845
846    #[tokio::test]
847    async fn test_slide_not_found() {
848        let tiff_data = create_tiff_with_jpeg_tile();
849        let source = MockSlideSource::new(tiff_data);
850        let registry = SlideRegistry::new(source);
851        let service = TileService::new(registry);
852
853        let request = TileRequest::new("notfound.tif", 0, 0, 0);
854        let result = service.get_tile(request).await;
855
856        assert!(result.is_err());
857        match result.unwrap_err() {
858            TileError::SlideNotFound { slide_id } => {
859                assert_eq!(slide_id, "notfound.tif");
860            }
861            e => panic!("Expected SlideNotFound error, got {:?}", e),
862        }
863    }
864
865    #[tokio::test]
866    async fn test_cache_stats() {
867        let tiff_data = create_tiff_with_jpeg_tile();
868        let source = MockSlideSource::new(tiff_data);
869        let registry = SlideRegistry::new(source);
870        let service = TileService::with_cache_capacity(registry, 10 * 1024 * 1024); // 10MB
871
872        let (size, capacity, count) = service.cache_stats().await;
873        assert_eq!(size, 0);
874        assert_eq!(capacity, 10 * 1024 * 1024);
875        assert_eq!(count, 0);
876
877        // Add a tile
878        let request = TileRequest::new("test.tif", 0, 0, 0);
879        service.get_tile(request).await.unwrap();
880
881        let (size, _, count) = service.cache_stats().await;
882        assert!(size > 0);
883        assert_eq!(count, 1);
884    }
885
886    #[tokio::test]
887    async fn test_clear_cache() {
888        let tiff_data = create_tiff_with_jpeg_tile();
889        let source = MockSlideSource::new(tiff_data);
890        let registry = SlideRegistry::new(source);
891        let service = TileService::new(registry);
892
893        // Add some tiles
894        service
895            .get_tile(TileRequest::new("test.tif", 0, 0, 0))
896            .await
897            .unwrap();
898        service
899            .get_tile(TileRequest::new("test.tif", 0, 1, 0))
900            .await
901            .unwrap();
902
903        let (_, _, count) = service.cache_stats().await;
904        assert_eq!(count, 2);
905
906        // Clear cache
907        service.clear_cache().await;
908
909        let (size, _, count) = service.cache_stats().await;
910        assert_eq!(size, 0);
911        assert_eq!(count, 0);
912    }
913
914    #[tokio::test]
915    async fn test_quality_validation() {
916        let tiff_data = create_tiff_with_jpeg_tile();
917        let source = MockSlideSource::new(tiff_data);
918        let registry = SlideRegistry::new(source);
919        let service = TileService::new(registry);
920
921        // Quality 0 should be rejected
922        let request = TileRequest::with_quality("test.tif", 0, 0, 0, 0);
923        let result = service.get_tile(request).await;
924        assert!(matches!(
925            result,
926            Err(TileError::InvalidQuality { quality: 0 })
927        ));
928
929        // Quality 255 should be rejected
930        let request = TileRequest::with_quality("test.tif", 0, 1, 0, 255);
931        let result = service.get_tile(request).await;
932        assert!(matches!(
933            result,
934            Err(TileError::InvalidQuality { quality: 255 })
935        ));
936    }
937
938    // =========================================================================
939    // Thumbnail Tests
940    // =========================================================================
941
942    #[tokio::test]
943    async fn test_generate_thumbnail_returns_valid_jpeg() {
944        let tiff_data = create_tiff_with_jpeg_tile();
945        let source = MockSlideSource::new(tiff_data);
946        let registry = SlideRegistry::new(source);
947        let service = TileService::new(registry);
948
949        // Request a 512px thumbnail
950        let result = service.generate_thumbnail("test.tif", 512, 80).await;
951
952        assert!(result.is_ok());
953        let response = result.unwrap();
954
955        // Verify it's valid JPEG
956        assert!(response.data.len() > 2);
957        assert_eq!(response.data[0], 0xFF); // SOI marker
958        assert_eq!(response.data[1], 0xD8);
959        assert_eq!(response.data[response.data.len() - 2], 0xFF); // EOI marker
960        assert_eq!(response.data[response.data.len() - 1], 0xD9);
961    }
962
963    #[tokio::test]
964    async fn test_generate_thumbnail_composites_multiple_tiles() {
965        let tiff_data = create_tiff_with_jpeg_tile();
966        let source = MockSlideSource::new(tiff_data);
967        let registry = SlideRegistry::new(source);
968        let service = TileService::new(registry);
969
970        // The test TIFF is 2048x1536 with 256x256 tiles (8x6 = 48 tiles)
971        // Request a 512px thumbnail - this should composite tiles
972        let thumbnail_result = service.generate_thumbnail("test.tif", 512, 80).await;
973        assert!(thumbnail_result.is_ok());
974        let thumbnail = thumbnail_result.unwrap();
975
976        // Get a single tile for comparison
977        let tile_result = service
978            .get_tile(TileRequest::with_quality("test.tif", 0, 0, 0, 80))
979            .await;
980        assert!(tile_result.is_ok());
981        let single_tile = tile_result.unwrap();
982
983        // The thumbnail should be a properly composited image
984        // Verify it's a valid JPEG that we can decode
985        let cursor = std::io::Cursor::new(&thumbnail.data[..]);
986        let reader = image::ImageReader::with_format(cursor, image::ImageFormat::Jpeg);
987        let img = reader.decode();
988        assert!(img.is_ok(), "Thumbnail should be a valid decodable image");
989
990        let decoded = img.unwrap();
991        // The thumbnail should be resized to fit within 512px
992        assert!(
993            decoded.width() <= 512 && decoded.height() <= 512,
994            "Thumbnail dimensions should fit within max_dimension"
995        );
996
997        // Also verify the single tile is smaller than what a full composite would be
998        // (this confirms we're not just returning a single tile)
999        let tile_cursor = std::io::Cursor::new(&single_tile.data[..]);
1000        let tile_reader = image::ImageReader::with_format(tile_cursor, image::ImageFormat::Jpeg);
1001        let tile_img = tile_reader.decode().unwrap();
1002
1003        // A single tile is 256x256, which is smaller than our 512px max
1004        assert_eq!(tile_img.width(), 256);
1005        assert_eq!(tile_img.height(), 256);
1006    }
1007
1008    #[tokio::test]
1009    async fn test_generate_thumbnail_respects_max_dimension() {
1010        let tiff_data = create_tiff_with_jpeg_tile();
1011        let source = MockSlideSource::new(tiff_data);
1012        let registry = SlideRegistry::new(source);
1013        let service = TileService::new(registry);
1014
1015        // Test different max dimensions
1016        for max_dim in [128, 256, 512, 1024] {
1017            let result = service.generate_thumbnail("test.tif", max_dim, 80).await;
1018            assert!(result.is_ok());
1019
1020            let response = result.unwrap();
1021            let cursor = std::io::Cursor::new(&response.data[..]);
1022            let reader = image::ImageReader::with_format(cursor, image::ImageFormat::Jpeg);
1023            let img = reader.decode().unwrap();
1024
1025            // Both dimensions should be <= max_dim
1026            assert!(
1027                img.width() <= max_dim,
1028                "Width {} should be <= max_dim {}",
1029                img.width(),
1030                max_dim
1031            );
1032            assert!(
1033                img.height() <= max_dim,
1034                "Height {} should be <= max_dim {}",
1035                img.height(),
1036                max_dim
1037            );
1038
1039            // At least one dimension should be close to max_dim
1040            // (within 1 pixel due to rounding)
1041            let max_actual = img.width().max(img.height());
1042            assert!(
1043                max_actual >= max_dim - 1,
1044                "Max dimension {} should be close to {}",
1045                max_actual,
1046                max_dim
1047            );
1048        }
1049    }
1050
1051    #[tokio::test]
1052    async fn test_generate_thumbnail_preserves_aspect_ratio() {
1053        let tiff_data = create_tiff_with_jpeg_tile();
1054        let source = MockSlideSource::new(tiff_data);
1055        let registry = SlideRegistry::new(source);
1056        let service = TileService::new(registry);
1057
1058        // The test TIFF is 2048x1536, which has aspect ratio 4:3
1059        let result = service.generate_thumbnail("test.tif", 400, 80).await;
1060        assert!(result.is_ok());
1061
1062        let response = result.unwrap();
1063        let cursor = std::io::Cursor::new(&response.data[..]);
1064        let reader = image::ImageReader::with_format(cursor, image::ImageFormat::Jpeg);
1065        let img = reader.decode().unwrap();
1066
1067        // The thumbnail should preserve the 4:3 aspect ratio (approximately)
1068        let aspect_ratio = img.width() as f64 / img.height() as f64;
1069        let expected_ratio = 2048.0 / 1536.0; // ~1.333
1070
1071        assert!(
1072            (aspect_ratio - expected_ratio).abs() < 0.1,
1073            "Aspect ratio {} should be close to expected {}",
1074            aspect_ratio,
1075            expected_ratio
1076        );
1077    }
1078
1079    #[tokio::test]
1080    async fn test_generate_thumbnail_invalid_quality() {
1081        let tiff_data = create_tiff_with_jpeg_tile();
1082        let source = MockSlideSource::new(tiff_data);
1083        let registry = SlideRegistry::new(source);
1084        let service = TileService::new(registry);
1085
1086        // Quality 0 should be rejected
1087        let result = service.generate_thumbnail("test.tif", 256, 0).await;
1088        assert!(matches!(
1089            result,
1090            Err(TileError::InvalidQuality { quality: 0 })
1091        ));
1092
1093        // Quality 255 should be rejected
1094        let result = service.generate_thumbnail("test.tif", 256, 255).await;
1095        assert!(matches!(
1096            result,
1097            Err(TileError::InvalidQuality { quality: 255 })
1098        ));
1099    }
1100
1101    #[tokio::test]
1102    async fn test_generate_thumbnail_slide_not_found() {
1103        let tiff_data = create_tiff_with_jpeg_tile();
1104        let source = MockSlideSource::new(tiff_data);
1105        let registry = SlideRegistry::new(source);
1106        let service = TileService::new(registry);
1107
1108        let result = service.generate_thumbnail("notfound.tif", 256, 80).await;
1109        assert!(result.is_err());
1110        match result.unwrap_err() {
1111            TileError::SlideNotFound { slide_id } => {
1112                assert_eq!(slide_id, "notfound.tif");
1113            }
1114            e => panic!("Expected SlideNotFound error, got {:?}", e),
1115        }
1116    }
1117}