#![allow(clippy::too_many_lines)]
use crate::tile_cache::{TileCache, TileCacheStats};
use crate::tile_lifecycle::{TileLifecycleDiagnostics, TileLifecycleTracker};
use crate::tile_source::{TileData, TileSource, TileSourceDiagnostics};
use rustial_math::{
geo_to_tile, tile_bounds_world, FlatTileSelectionConfig, FlatTileView, GeoCoord, TileId,
WebMercator, WorldBounds,
};
use std::cmp::Ordering;
use std::collections::HashSet;
use std::time::SystemTime;
const DEFAULT_VISIBLE_TILE_BUDGET: usize = 512;
const MAX_ANCESTOR_DEPTH: u8 = 8;
#[derive(Debug, Clone, PartialEq)]
pub struct TileSelectionConfig {
pub visible_tile_budget: usize,
pub flat_view: FlatTileSelectionConfig,
pub source_min_zoom: u8,
pub source_max_zoom: u8,
pub raster_fade_duration: f32,
pub max_fading_ancestor_levels: u8,
pub max_child_depth: u8,
pub max_requests_per_frame: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ZoomPrefetchDirection {
In,
Out,
}
impl TileSelectionConfig {
#[inline]
pub fn effective_visible_tile_budget(&self, cache_capacity: usize) -> usize {
let policy_budget = self.visible_tile_budget.max(1);
let cache_budget = cache_capacity.saturating_sub(10).max(1);
policy_budget.min(cache_budget)
}
}
impl Default for TileSelectionConfig {
fn default() -> Self {
Self {
visible_tile_budget: DEFAULT_VISIBLE_TILE_BUDGET,
flat_view: FlatTileSelectionConfig::default(),
source_min_zoom: 0,
source_max_zoom: 22,
raster_fade_duration: 0.0,
max_fading_ancestor_levels: 3,
max_child_depth: 2,
max_requests_per_frame: usize::MAX,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TileTextureRegion {
pub u_min: f32,
pub v_min: f32,
pub u_max: f32,
pub v_max: f32,
}
impl TileTextureRegion {
pub const FULL: Self = Self {
u_min: 0.0,
v_min: 0.0,
u_max: 1.0,
v_max: 1.0,
};
pub fn from_tiles(target: &TileId, actual: &TileId) -> Self {
if target.zoom <= actual.zoom || *target == *actual {
return Self::FULL;
}
let dz = target.zoom - actual.zoom;
let scale = 1u32 << dz;
if target.x / scale != actual.x || target.y / scale != actual.y {
return Self::FULL;
}
let offset_x = target.x - actual.x * scale;
let offset_y = target.y - actual.y * scale;
let inv = 1.0 / scale as f32;
Self {
u_min: offset_x as f32 * inv,
v_min: offset_y as f32 * inv,
u_max: (offset_x + 1) as f32 * inv,
v_max: (offset_y + 1) as f32 * inv,
}
}
#[inline]
pub fn is_full(&self) -> bool {
*self == Self::FULL
}
pub fn from_child_tile(target: &TileId, child: &TileId) -> Option<Self> {
if child.zoom <= target.zoom {
return None;
}
let dz = child.zoom - target.zoom;
let scale = 1u32 << dz;
if child.x / scale != target.x || child.y / scale != target.y {
return None;
}
let offset_x = child.x - target.x * scale;
let offset_y = child.y - target.y * scale;
let inv = 1.0 / scale as f32;
Some(Self {
u_min: offset_x as f32 * inv,
v_min: offset_y as f32 * inv,
u_max: (offset_x + 1) as f32 * inv,
v_max: (offset_y + 1) as f32 * inv,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TilePixelRect {
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
}
impl TilePixelRect {
#[inline]
pub fn full(width: u32, height: u32) -> Self {
Self {
x: 0,
y: 0,
width,
height,
}
}
pub fn from_tiles(target: &TileId, actual: &TileId, width: u32, height: u32) -> Option<Self> {
if width == 0 || height == 0 {
return None;
}
if target.zoom <= actual.zoom || *target == *actual {
return Some(Self::full(width, height));
}
let dz = (target.zoom - actual.zoom) as u32;
let scale = 1u32.checked_shl(dz)?;
if target.x / scale != actual.x || target.y / scale != actual.y {
return None;
}
let crop_w = width / scale;
let crop_h = height / scale;
if crop_w == 0 || crop_h == 0 {
return None;
}
let offset_x = target.x.checked_sub(actual.x.checked_mul(scale)?)?;
let offset_y = target.y.checked_sub(actual.y.checked_mul(scale)?)?;
let x = offset_x.checked_mul(crop_w)?;
let y = offset_y.checked_mul(crop_h)?;
if x.checked_add(crop_w)? > width || y.checked_add(crop_h)? > height {
return None;
}
Some(Self {
x,
y,
width: crop_w,
height: crop_h,
})
}
}
#[derive(Debug, Clone, Copy)]
enum RequestUrgency {
Coverage,
FallbackRefine,
Refresh,
}
impl RequestUrgency {
#[inline]
fn rank(self) -> u8 {
match self {
Self::Coverage => 0,
Self::FallbackRefine => 1,
Self::Refresh => 2,
}
}
}
#[derive(Debug, Clone, Copy)]
struct RequestCandidate {
tile: TileId,
distance_sq: f64,
urgency: RequestUrgency,
}
impl RequestCandidate {
fn new(tile: TileId, camera_world: (f64, f64), urgency: RequestUrgency) -> Self {
let bounds = tile_bounds_world(&tile);
let center_x = (bounds.min.position.x + bounds.max.position.x) * 0.5;
let center_y = (bounds.min.position.y + bounds.max.position.y) * 0.5;
let dx = center_x - camera_world.0;
let dy = center_y - camera_world.1;
Self {
tile,
distance_sq: dx * dx + dy * dy,
urgency,
}
}
}
fn sort_request_candidates(candidates: &mut [RequestCandidate]) {
candidates.sort_by(|a, b| {
a.urgency
.rank()
.cmp(&b.urgency.rank())
.then_with(|| a.tile.zoom.cmp(&b.tile.zoom))
.then_with(|| {
a.distance_sq
.partial_cmp(&b.distance_sq)
.unwrap_or(Ordering::Equal)
})
.then_with(|| a.tile.y.cmp(&b.tile.y))
.then_with(|| a.tile.x.cmp(&b.tile.x))
});
}
fn desired_with_ancestor_retention<'a>(
desired: impl IntoIterator<Item = &'a TileId>,
) -> HashSet<TileId> {
let tiles: Vec<TileId> = desired.into_iter().copied().collect();
let mut retained = HashSet::with_capacity(tiles.len() * 2);
for tile in tiles {
retained.insert(tile);
let mut current = tile;
let mut depth = 0u8;
while depth < MAX_ANCESTOR_DEPTH {
if let Some(parent) = current.parent() {
retained.insert(parent);
current = parent;
depth += 1;
} else {
break;
}
}
}
retained
}
fn desired_with_temporal_retention(
current_desired: &[TileId],
previous_desired: &HashSet<TileId>,
) -> HashSet<TileId> {
desired_with_ancestor_retention(current_desired.iter().chain(previous_desired.iter()))
}
fn tile_contains(ancestor: TileId, tile: TileId) -> bool {
if tile.zoom < ancestor.zoom {
return false;
}
let dz = tile.zoom - ancestor.zoom;
if dz == 0 {
return tile == ancestor;
}
(tile.x >> dz) == ancestor.x && (tile.y >> dz) == ancestor.y
}
fn tile_at_zoom(tile: TileId, zoom: u8) -> TileId {
if zoom >= tile.zoom {
return tile;
}
let dz = tile.zoom - zoom;
TileId::new(zoom, tile.x >> dz, tile.y >> dz)
}
fn tiles_within_horizon(a: TileId, b: TileId, radius: u32) -> bool {
a.x.abs_diff(b.x) <= radius && a.y.abs_diff(b.y) <= radius
}
fn pending_tile_relevant_to_desired(tile: TileId, desired: &HashSet<TileId>) -> bool {
const DESCENDANT_RETENTION_DEPTH: u8 = 2;
const NEIGHBOR_RETENTION_RADIUS: u32 = 1;
desired.iter().copied().any(|desired_tile| {
if tile == desired_tile || tile_contains(tile, desired_tile) {
return true;
}
if tile.zoom > desired_tile.zoom
&& tile.zoom - desired_tile.zoom <= DESCENDANT_RETENTION_DEPTH
&& tile_contains(desired_tile, tile)
{
return true;
}
let common_zoom = tile.zoom.min(desired_tile.zoom);
let tile_common = tile_at_zoom(tile, common_zoom);
let desired_common = tile_at_zoom(desired_tile, common_zoom);
if !tiles_within_horizon(tile_common, desired_common, NEIGHBOR_RETENTION_RADIUS) {
return false;
}
tile.zoom <= desired_tile.zoom
|| tile.zoom - desired_tile.zoom <= DESCENDANT_RETENTION_DEPTH
})
}
fn tiles_along_route(route: &[GeoCoord], zoom: u8, camera_world: (f64, f64)) -> Vec<TileId> {
if route.len() < 2 {
return Vec::new();
}
let mut best_seg = 0usize;
let mut best_dist_sq = f64::MAX;
for (i, coord) in route.iter().enumerate() {
let w = WebMercator::project_clamped(coord);
let dx = w.position.x - camera_world.0;
let dy = w.position.y - camera_world.1;
let d2 = dx * dx + dy * dy;
if d2 < best_dist_sq {
best_dist_sq = d2;
best_seg = i;
}
}
let full_extent = 2.0 * WebMercator::max_extent();
let n_tiles = (1u64 << zoom) as f64;
let tile_width = full_extent / n_tiles;
let step = tile_width * 0.5;
let mut seen = HashSet::new();
let mut tiles = Vec::new();
let start = best_seg.min(route.len().saturating_sub(2));
for seg in start..route.len().saturating_sub(1) {
let a = WebMercator::project_clamped(&route[seg]);
let b = WebMercator::project_clamped(&route[seg + 1]);
let dx = b.position.x - a.position.x;
let dy = b.position.y - a.position.y;
let seg_len = (dx * dx + dy * dy).sqrt();
if seg_len < 1e-9 {
continue;
}
let steps = (seg_len / step).ceil() as usize;
for s in 0..=steps {
let t = if steps == 0 {
0.0
} else {
(s as f64) / (steps as f64)
};
let px = a.position.x + dx * t;
let py = a.position.y + dy * t;
let geo = WebMercator::unproject(&rustial_math::WorldCoord::new(px, py, 0.0));
let tile = geo_to_tile(&geo, zoom).tile_id();
if seen.insert(tile) {
tiles.push(tile);
}
}
}
tiles
}
fn overzoomed_display_targets(source_tile: &TileId, display_zoom: u8) -> Vec<TileId> {
if display_zoom <= source_tile.zoom {
return vec![*source_tile];
}
let dz = display_zoom - source_tile.zoom;
let scale = 1u32 << dz;
let base_x = source_tile.x * scale;
let base_y = source_tile.y * scale;
let mut targets = Vec::with_capacity((scale * scale) as usize);
for dy in 0..scale {
for dx in 0..scale {
targets.push(TileId::new(display_zoom, base_x + dx, base_y + dy));
}
}
targets
}
fn compute_fade_opacity(now: SystemTime, loaded_at: Option<SystemTime>, fade_duration: f32) -> f32 {
if fade_duration <= 0.0 {
return 1.0;
}
let Some(loaded) = loaded_at else {
return 1.0;
};
let elapsed = now.duration_since(loaded).unwrap_or_default().as_secs_f32();
(elapsed / fade_duration).clamp(0.0, 1.0)
}
fn emit_crossfade_parent(
visible: &mut VisibleTileSet,
child_target: TileId,
parent_opacity: f32,
max_levels: u8,
cache: &mut TileCache,
) {
let mut current = child_target;
let mut depth = 0u8;
while depth < max_levels {
if let Some(parent) = current.parent() {
let loaded = cache.get(&parent).and_then(|entry| entry.data()).cloned();
if let Some(data) = loaded {
cache.touch(&parent);
visible.tiles.push(VisibleTile {
target: child_target,
actual: parent,
data: Some(data),
fade_opacity: parent_opacity,
});
return;
}
current = parent;
depth += 1;
} else {
break;
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TileSelectionStats {
pub raw_candidate_tiles: usize,
pub visible_tiles: usize,
pub exact_visible_tiles: usize,
pub fallback_visible_tiles: usize,
pub missing_visible_tiles: usize,
pub overzoomed_visible_tiles: usize,
pub dropped_by_budget: usize,
pub budget_hit: bool,
pub cancelled_stale_pending: usize,
pub requested_tiles: usize,
pub speculative_requested_tiles: usize,
pub exact_cache_hits: usize,
pub fallback_hits: usize,
pub child_fallback_hits: usize,
pub child_fallback_visible_tiles: usize,
pub cache_misses: usize,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TileManagerCounters {
pub frames: u64,
pub budget_hit_frames: u64,
pub dropped_by_budget: u64,
pub exact_cache_hits: u64,
pub fallback_hits: u64,
pub child_fallback_hits: u64,
pub cache_misses: u64,
pub requested_tiles: u64,
pub speculative_requested_tiles: u64,
pub cancelled_stale_pending: u64,
pub cancelled_evicted_pending: u64,
}
#[derive(Debug, Default)]
pub struct VisibleTileSet {
pub tiles: Vec<VisibleTile>,
}
impl VisibleTileSet {
#[inline]
pub fn len(&self) -> usize {
self.tiles.len()
}
#[inline]
pub fn is_empty(&self) -> bool {
self.tiles.is_empty()
}
#[inline]
pub fn loaded_count(&self) -> usize {
self.tiles.iter().filter(|t| t.data.is_some()).count()
}
#[inline]
pub fn iter(&self) -> std::slice::Iter<'_, VisibleTile> {
self.tiles.iter()
}
}
impl<'a> IntoIterator for &'a VisibleTileSet {
type Item = &'a VisibleTile;
type IntoIter = std::slice::Iter<'a, VisibleTile>;
fn into_iter(self) -> Self::IntoIter {
self.tiles.iter()
}
}
#[derive(Debug, Clone)]
pub struct VisibleTile {
pub target: TileId,
pub actual: TileId,
pub data: Option<TileData>,
pub fade_opacity: f32,
}
impl VisibleTile {
#[inline]
pub fn is_loaded(&self) -> bool {
self.data.is_some()
}
#[inline]
pub fn is_fallback(&self) -> bool {
self.target != self.actual
}
#[inline]
pub fn is_overzoomed(&self) -> bool {
self.target.zoom > self.actual.zoom && self.data.is_some()
}
#[inline]
pub fn is_child_fallback(&self) -> bool {
self.actual.zoom > self.target.zoom && self.data.is_some()
}
#[inline]
pub fn texture_region(&self) -> TileTextureRegion {
if self.actual.zoom > self.target.zoom {
TileTextureRegion::FULL
} else {
TileTextureRegion::from_tiles(&self.target, &self.actual)
}
}
#[inline]
pub fn pixel_crop_rect(&self, width: u32, height: u32) -> Option<TilePixelRect> {
TilePixelRect::from_tiles(&self.target, &self.actual, width, height)
}
}
pub struct TileManager {
source: Box<dyn TileSource>,
cache: TileCache,
lifecycle: TileLifecycleTracker,
selection_config: TileSelectionConfig,
last_selection_stats: TileSelectionStats,
counters: TileManagerCounters,
last_desired_tiles: HashSet<TileId>,
}
impl std::fmt::Debug for TileManager {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TileManager")
.field("cache_len", &self.cache.len())
.field("cache_capacity", &self.cache.capacity())
.finish_non_exhaustive()
}
}
impl TileManager {
pub fn new(source: Box<dyn TileSource>, cache_capacity: usize) -> Self {
Self::new_with_config(source, cache_capacity, TileSelectionConfig::default())
}
pub fn new_with_config(
source: Box<dyn TileSource>,
cache_capacity: usize,
selection_config: TileSelectionConfig,
) -> Self {
Self {
source,
cache: TileCache::new(cache_capacity),
lifecycle: TileLifecycleTracker::default(),
selection_config,
last_selection_stats: TileSelectionStats::default(),
counters: TileManagerCounters::default(),
last_desired_tiles: HashSet::new(),
}
}
pub fn update(
&mut self,
viewport_bounds: &WorldBounds,
zoom: u8,
camera_world: (f64, f64),
camera_distance: f64,
) -> VisibleTileSet {
self.update_with_view(viewport_bounds, zoom, camera_world, camera_distance, None)
}
pub fn update_with_frustum(
&mut self,
frustum: &rustial_math::Frustum,
zoom: u8,
camera_world: (f64, f64),
) -> VisibleTileSet {
self.begin_lifecycle_frame();
self.poll_completed();
let mut stats = TileSelectionStats::default();
let max_tiles = self
.selection_config
.effective_visible_tile_budget(self.cache.capacity());
let desired = rustial_math::visible_tiles_frustum(frustum, zoom, max_tiles, camera_world);
self.last_desired_tiles = desired.iter().copied().collect();
stats.raw_candidate_tiles = desired.len();
let desired_set = desired_with_ancestor_retention(&desired);
stats.cancelled_stale_pending = self.prune_stale_pending(&desired_set);
let now = SystemTime::now();
for id in self.cache.expired_ids_at(now) {
let _ = self.cache.mark_expired(id);
}
let mut visible = VisibleTileSet {
tiles: Vec::with_capacity(desired.len()),
};
let mut missing = Vec::new();
let mut refresh = Vec::new();
let mut bootstrap = Vec::new();
let fade_duration = self.selection_config.raster_fade_duration;
let max_ancestor_fade = self.selection_config.max_fading_ancestor_levels;
let max_child_depth = self.selection_config.max_child_depth;
for &target in &desired {
self.lifecycle.record_selected(target);
let cached = self.cache.get(&target).map(|entry| {
(
entry.data().cloned(),
entry
.freshness()
.is_some_and(|freshness| freshness.is_expired_at(now)),
entry.is_reloading(),
entry.loaded_at(),
entry.is_pending(),
)
});
match cached {
Some((Some(data), is_expired, is_reloading, loaded_at, _)) => {
self.cache.touch(&target);
if is_expired && !is_reloading && self.cache.start_reload(target) {
refresh.push(RequestCandidate::new(
target,
camera_world,
RequestUrgency::Refresh,
));
}
stats.exact_cache_hits += 1;
stats.exact_visible_tiles += 1;
let fade_opacity = compute_fade_opacity(now, loaded_at, fade_duration);
if fade_opacity < 1.0 {
emit_crossfade_parent(
&mut visible,
target,
1.0 - fade_opacity,
max_ancestor_fade,
&mut self.cache,
);
}
visible.tiles.push(VisibleTile {
target,
actual: target,
data: Some(data),
fade_opacity,
});
self.record_visible_tile_use(target, target, true);
}
Some((None, _, _, _, is_pending)) => {
self.cache.touch(&target);
let children = self.find_loaded_children(&target, max_child_depth);
if !children.is_empty() {
stats.child_fallback_hits += 1;
for (child_id, child_data) in children {
stats.child_fallback_visible_tiles += 1;
visible.tiles.push(VisibleTile {
target,
actual: child_id,
data: Some(child_data),
fade_opacity: 1.0,
});
self.record_visible_tile_use(target, child_id, true);
}
} else {
let (actual, data) = self.find_loaded_ancestor(&target);
if data.is_some() && actual != target {
stats.fallback_hits += 1;
stats.fallback_visible_tiles += 1;
} else if data.is_none() {
stats.cache_misses += 1;
stats.missing_visible_tiles += 1;
bootstrap.push(target);
}
visible.tiles.push(VisibleTile {
target,
actual,
data,
fade_opacity: 1.0,
});
self.record_visible_tile_use(
target,
actual,
visible.tiles.last().is_some_and(|tile| tile.data.is_some()),
);
}
if !is_pending {
let urgency =
if visible.tiles.last().is_some_and(|tile| tile.data.is_none()) {
RequestUrgency::Coverage
} else {
RequestUrgency::FallbackRefine
};
self.cache.remove(&target);
missing.push(RequestCandidate::new(target, camera_world, urgency));
}
}
None => {
let children = self.find_loaded_children(&target, max_child_depth);
if !children.is_empty() {
stats.child_fallback_hits += 1;
for (child_id, child_data) in children {
stats.child_fallback_visible_tiles += 1;
visible.tiles.push(VisibleTile {
target,
actual: child_id,
data: Some(child_data),
fade_opacity: 1.0,
});
self.record_visible_tile_use(target, child_id, true);
}
} else {
let (actual, data) = self.find_loaded_ancestor(&target);
if data.is_some() && actual != target {
stats.fallback_hits += 1;
stats.fallback_visible_tiles += 1;
} else if data.is_none() {
stats.cache_misses += 1;
stats.missing_visible_tiles += 1;
bootstrap.push(target);
}
visible.tiles.push(VisibleTile {
target,
actual,
data,
fade_opacity: 1.0,
});
self.record_visible_tile_use(
target,
actual,
visible.tiles.last().is_some_and(|tile| tile.data.is_some()),
);
}
let urgency = if visible.tiles.last().is_some_and(|tile| tile.data.is_none()) {
RequestUrgency::Coverage
} else {
RequestUrgency::FallbackRefine
};
missing.push(RequestCandidate::new(target, camera_world, urgency));
}
}
}
let (requested, cancelled_evicted_pending) =
self.request_tiles_with_bootstrap(&mut refresh, &mut missing, &bootstrap, camera_world);
stats.requested_tiles = requested.len();
stats.visible_tiles = visible.tiles.len();
self.counters.frames += 1;
if stats.budget_hit {
self.counters.budget_hit_frames += 1;
}
self.counters.dropped_by_budget += stats.dropped_by_budget as u64;
self.counters.exact_cache_hits += stats.exact_cache_hits as u64;
self.counters.fallback_hits += stats.fallback_hits as u64;
self.counters.child_fallback_hits += stats.child_fallback_hits as u64;
self.counters.cache_misses += stats.cache_misses as u64;
self.counters.requested_tiles += stats.requested_tiles as u64;
self.counters.cancelled_stale_pending += stats.cancelled_stale_pending as u64;
self.counters.cancelled_evicted_pending += cancelled_evicted_pending as u64;
self.last_selection_stats = stats;
visible
}
pub fn update_with_covering(
&mut self,
frustum: &rustial_math::Frustum,
cam: &rustial_math::CoveringCamera,
opts: &rustial_math::CoveringTilesOptions,
camera_world: (f64, f64),
) -> VisibleTileSet {
self.begin_lifecycle_frame();
self.poll_completed();
let mut stats = TileSelectionStats::default();
let max_tiles = self
.selection_config
.effective_visible_tile_budget(self.cache.capacity())
.min(opts.max_tiles);
let effective_opts = rustial_math::CoveringTilesOptions {
max_tiles,
..opts.clone()
};
let desired = rustial_math::visible_tiles_covering(frustum, cam, &effective_opts);
let previous_desired = self.last_desired_tiles.clone();
stats.raw_candidate_tiles = desired.len();
let desired_set = desired_with_temporal_retention(&desired, &previous_desired);
stats.cancelled_stale_pending = self.prune_stale_pending(&desired_set);
self.last_desired_tiles = desired.iter().copied().collect();
let now = SystemTime::now();
for id in self.cache.expired_ids_at(now) {
let _ = self.cache.mark_expired(id);
}
let mut visible = VisibleTileSet {
tiles: Vec::with_capacity(desired.len()),
};
let mut missing = Vec::new();
let mut refresh = Vec::new();
let fade_duration = self.selection_config.raster_fade_duration;
let max_ancestor_fade = self.selection_config.max_fading_ancestor_levels;
let max_child_depth = self.selection_config.max_child_depth;
let mut bootstrap = Vec::new();
for &target in &desired {
self.lifecycle.record_selected(target);
let cached = self.cache.get(&target).map(|entry| {
(
entry.data().cloned(),
entry
.freshness()
.is_some_and(|freshness| freshness.is_expired_at(now)),
entry.is_reloading(),
entry.loaded_at(),
entry.is_pending(),
)
});
match cached {
Some((Some(data), is_expired, is_reloading, loaded_at, _)) => {
self.cache.touch(&target);
if is_expired && !is_reloading && self.cache.start_reload(target) {
refresh.push(RequestCandidate::new(
target,
camera_world,
RequestUrgency::Refresh,
));
}
stats.exact_cache_hits += 1;
stats.exact_visible_tiles += 1;
let fade_opacity = compute_fade_opacity(now, loaded_at, fade_duration);
if fade_opacity < 1.0 {
emit_crossfade_parent(
&mut visible,
target,
1.0 - fade_opacity,
max_ancestor_fade,
&mut self.cache,
);
}
visible.tiles.push(VisibleTile {
target,
actual: target,
data: Some(data),
fade_opacity,
});
self.record_visible_tile_use(target, target, true);
}
Some((None, _, _, _, is_pending)) => {
self.cache.touch(&target);
let children = self.find_loaded_children(&target, max_child_depth);
if !children.is_empty() {
stats.child_fallback_hits += 1;
for (child_id, child_data) in children {
stats.child_fallback_visible_tiles += 1;
visible.tiles.push(VisibleTile {
target,
actual: child_id,
data: Some(child_data),
fade_opacity: 1.0,
});
self.record_visible_tile_use(target, child_id, true);
}
} else {
let (actual, data) = self.find_loaded_ancestor(&target);
if data.is_some() && actual != target {
stats.fallback_hits += 1;
stats.fallback_visible_tiles += 1;
} else if data.is_none() {
stats.cache_misses += 1;
stats.missing_visible_tiles += 1;
bootstrap.push(target);
}
visible.tiles.push(VisibleTile {
target,
actual,
data,
fade_opacity: 1.0,
});
self.record_visible_tile_use(
target,
actual,
visible.tiles.last().is_some_and(|tile| tile.data.is_some()),
);
}
if !is_pending {
let urgency =
if visible.tiles.last().is_some_and(|tile| tile.data.is_none()) {
RequestUrgency::Coverage
} else {
RequestUrgency::FallbackRefine
};
self.cache.remove(&target);
missing.push(RequestCandidate::new(target, camera_world, urgency));
}
}
None => {
let children = self.find_loaded_children(&target, max_child_depth);
if !children.is_empty() {
stats.child_fallback_hits += 1;
for (child_id, child_data) in children {
stats.child_fallback_visible_tiles += 1;
visible.tiles.push(VisibleTile {
target,
actual: child_id,
data: Some(child_data),
fade_opacity: 1.0,
});
self.record_visible_tile_use(target, child_id, true);
}
} else {
let (actual, data) = self.find_loaded_ancestor(&target);
if data.is_some() && actual != target {
stats.fallback_hits += 1;
stats.fallback_visible_tiles += 1;
} else if data.is_none() {
stats.cache_misses += 1;
stats.missing_visible_tiles += 1;
bootstrap.push(target);
}
visible.tiles.push(VisibleTile {
target,
actual,
data,
fade_opacity: 1.0,
});
self.record_visible_tile_use(
target,
actual,
visible.tiles.last().is_some_and(|tile| tile.data.is_some()),
);
}
let urgency = if visible.tiles.last().is_some_and(|tile| tile.data.is_none()) {
RequestUrgency::Coverage
} else {
RequestUrgency::FallbackRefine
};
missing.push(RequestCandidate::new(target, camera_world, urgency));
}
}
}
let (requested, cancelled_evicted_pending) =
self.request_tiles_with_bootstrap(&mut refresh, &mut missing, &bootstrap, camera_world);
stats.requested_tiles = requested.len();
stats.visible_tiles = visible.tiles.len();
self.counters.frames += 1;
if stats.budget_hit {
self.counters.budget_hit_frames += 1;
}
self.counters.dropped_by_budget += stats.dropped_by_budget as u64;
self.counters.exact_cache_hits += stats.exact_cache_hits as u64;
self.counters.fallback_hits += stats.fallback_hits as u64;
self.counters.child_fallback_hits += stats.child_fallback_hits as u64;
self.counters.cache_misses += stats.cache_misses as u64;
self.counters.requested_tiles += stats.requested_tiles as u64;
self.counters.cancelled_stale_pending += stats.cancelled_stale_pending as u64;
self.counters.cancelled_evicted_pending += cancelled_evicted_pending as u64;
self.last_selection_stats = stats;
visible
}
pub fn update_with_view(
&mut self,
viewport_bounds: &WorldBounds,
zoom: u8,
camera_world: (f64, f64),
_camera_distance: f64,
flat_view: Option<&FlatTileView>,
) -> VisibleTileSet {
self.begin_lifecycle_frame();
self.poll_completed();
if zoom < self.selection_config.source_min_zoom {
self.last_desired_tiles.clear();
self.last_selection_stats = TileSelectionStats::default();
return VisibleTileSet::default();
}
let source_zoom = zoom.min(self.selection_config.source_max_zoom);
let is_overzoomed = zoom > source_zoom;
let mut stats = TileSelectionStats::default();
let max_tiles = self
.selection_config
.effective_visible_tile_budget(self.cache.capacity());
let mut source_tiles = if let Some(view) = flat_view {
rustial_math::visible_tiles_flat_view_capped_with_config(
viewport_bounds,
source_zoom,
view,
&self.selection_config.flat_view,
max_tiles,
)
} else {
rustial_math::visible_tiles(viewport_bounds, source_zoom)
};
stats.raw_candidate_tiles = source_tiles.len();
if source_tiles.len() > max_tiles {
stats.budget_hit = true;
stats.dropped_by_budget = stats.raw_candidate_tiles - max_tiles;
source_tiles.truncate(max_tiles);
}
let previous_desired = self.last_desired_tiles.clone();
let desired_set = desired_with_temporal_retention(&source_tiles, &previous_desired);
stats.cancelled_stale_pending = self.prune_stale_pending(&desired_set);
self.last_desired_tiles = source_tiles.iter().copied().collect();
let now = SystemTime::now();
for id in self.cache.expired_ids_at(now) {
let _ = self.cache.mark_expired(id);
}
let mut visible = VisibleTileSet {
tiles: Vec::with_capacity(source_tiles.len()),
};
let mut missing = Vec::new();
let mut refresh = Vec::new();
let mut bootstrap = Vec::new();
let fade_duration = self.selection_config.raster_fade_duration;
let max_ancestor_fade = self.selection_config.max_fading_ancestor_levels;
let max_child_depth = self.selection_config.max_child_depth;
for &source_tile in &source_tiles {
self.lifecycle.record_selected(source_tile);
let display_targets = if is_overzoomed {
overzoomed_display_targets(&source_tile, zoom)
} else {
vec![source_tile]
};
let cached = self.cache.get(&source_tile).map(|entry| {
(
entry.data().cloned(),
entry
.freshness()
.is_some_and(|freshness| freshness.is_expired_at(now)),
entry.is_reloading(),
entry.loaded_at(),
entry.is_pending(),
)
});
match cached {
Some((Some(data), is_expired, is_reloading, loaded_at, _)) => {
self.cache.touch(&source_tile);
if is_expired && !is_reloading && self.cache.start_reload(source_tile) {
refresh.push(RequestCandidate::new(
source_tile,
camera_world,
RequestUrgency::Refresh,
));
}
stats.exact_cache_hits += 1;
let fade_opacity = compute_fade_opacity(now, loaded_at, fade_duration);
for target in display_targets {
if is_overzoomed {
stats.overzoomed_visible_tiles += 1;
} else {
stats.exact_visible_tiles += 1;
}
if fade_opacity < 1.0 {
emit_crossfade_parent(
&mut visible,
target,
1.0 - fade_opacity,
max_ancestor_fade,
&mut self.cache,
);
}
visible.tiles.push(VisibleTile {
target,
actual: source_tile,
data: Some(data.clone()),
fade_opacity,
});
self.record_visible_tile_use(target, source_tile, true);
}
}
Some((None, _, _, _, is_pending)) => {
self.cache.touch(&source_tile);
let children = if !is_overzoomed {
self.find_loaded_children(&source_tile, max_child_depth)
} else {
Vec::new()
};
if !children.is_empty() {
stats.child_fallback_hits += 1;
for (child_id, child_data) in children {
for target in &display_targets {
stats.child_fallback_visible_tiles += 1;
visible.tiles.push(VisibleTile {
target: *target,
actual: child_id,
data: Some(child_data.clone()),
fade_opacity: 1.0,
});
self.record_visible_tile_use(*target, child_id, true);
}
}
} else {
let (actual, data) = self.find_loaded_ancestor(&source_tile);
if data.is_some() && actual != source_tile {
stats.fallback_hits += 1;
} else if data.is_none() {
stats.cache_misses += 1;
bootstrap.push(source_tile);
}
for target in display_targets {
if data.is_some() && actual != source_tile {
stats.fallback_visible_tiles += 1;
} else if data.is_none() {
stats.missing_visible_tiles += 1;
}
visible.tiles.push(VisibleTile {
target,
actual,
data: data.clone(),
fade_opacity: 1.0,
});
self.record_visible_tile_use(
target,
actual,
visible.tiles.last().is_some_and(|tile| tile.data.is_some()),
);
}
}
if !is_pending {
let urgency =
if visible.tiles.last().is_some_and(|tile| tile.data.is_none()) {
RequestUrgency::Coverage
} else {
RequestUrgency::FallbackRefine
};
self.cache.remove(&source_tile);
missing.push(RequestCandidate::new(source_tile, camera_world, urgency));
}
}
None => {
let children = if !is_overzoomed {
self.find_loaded_children(&source_tile, max_child_depth)
} else {
Vec::new()
};
if !children.is_empty() {
stats.child_fallback_hits += 1;
for (child_id, child_data) in children {
for target in &display_targets {
stats.child_fallback_visible_tiles += 1;
visible.tiles.push(VisibleTile {
target: *target,
actual: child_id,
data: Some(child_data.clone()),
fade_opacity: 1.0,
});
self.record_visible_tile_use(*target, child_id, true);
}
}
} else {
let (actual, data) = self.find_loaded_ancestor(&source_tile);
if data.is_some() && actual != source_tile {
stats.fallback_hits += 1;
} else if data.is_none() {
stats.cache_misses += 1;
bootstrap.push(source_tile);
}
for target in display_targets {
if data.is_some() && actual != source_tile {
stats.fallback_visible_tiles += 1;
} else if data.is_none() {
stats.missing_visible_tiles += 1;
}
visible.tiles.push(VisibleTile {
target,
actual,
data: data.clone(),
fade_opacity: 1.0,
});
self.record_visible_tile_use(
target,
actual,
visible.tiles.last().is_some_and(|tile| tile.data.is_some()),
);
}
}
let urgency = if visible.tiles.last().is_some_and(|tile| tile.data.is_none()) {
RequestUrgency::Coverage
} else {
RequestUrgency::FallbackRefine
};
missing.push(RequestCandidate::new(source_tile, camera_world, urgency));
}
}
}
let (requested, cancelled_evicted_pending) =
self.request_tiles_with_bootstrap(&mut refresh, &mut missing, &bootstrap, camera_world);
stats.requested_tiles = requested.len();
stats.visible_tiles = visible.tiles.len();
self.counters.frames += 1;
if stats.budget_hit {
self.counters.budget_hit_frames += 1;
}
self.counters.dropped_by_budget += stats.dropped_by_budget as u64;
self.counters.exact_cache_hits += stats.exact_cache_hits as u64;
self.counters.fallback_hits += stats.fallback_hits as u64;
self.counters.child_fallback_hits += stats.child_fallback_hits as u64;
self.counters.cache_misses += stats.cache_misses as u64;
self.counters.requested_tiles += stats.requested_tiles as u64;
self.counters.cancelled_stale_pending += stats.cancelled_stale_pending as u64;
self.counters.cancelled_evicted_pending += cancelled_evicted_pending as u64;
self.last_selection_stats = stats;
visible
}
#[inline]
pub fn last_selection_stats(&self) -> &TileSelectionStats {
&self.last_selection_stats
}
#[inline]
pub fn desired_tiles(&self) -> &HashSet<TileId> {
&self.last_desired_tiles
}
#[inline]
pub fn counters(&self) -> &TileManagerCounters {
&self.counters
}
#[inline]
pub fn selection_config(&self) -> &TileSelectionConfig {
&self.selection_config
}
#[inline]
pub fn set_selection_config(&mut self, config: TileSelectionConfig) {
self.selection_config = config;
}
#[inline]
pub fn cache(&self) -> &TileCache {
&self.cache
}
#[inline]
pub fn cache_stats(&self) -> TileCacheStats {
self.cache.stats()
}
#[inline]
pub fn source_diagnostics(&self) -> Option<TileSourceDiagnostics> {
self.source.diagnostics()
}
#[inline]
pub fn lifecycle_diagnostics(&self) -> TileLifecycleDiagnostics {
self.lifecycle.diagnostics()
}
#[inline]
pub fn cached_count(&self) -> usize {
self.cache.len()
}
pub fn prefetch_with_view(
&mut self,
viewport_bounds: &WorldBounds,
zoom: u8,
camera_world: (f64, f64),
flat_view: Option<&FlatTileView>,
max_requests: usize,
) -> usize {
if max_requests == 0 || zoom < self.selection_config.source_min_zoom {
return 0;
}
let source_zoom = zoom.min(self.selection_config.source_max_zoom);
let max_tiles = self
.selection_config
.effective_visible_tile_budget(self.cache.capacity());
let mut predicted_tiles = if let Some(view) = flat_view {
rustial_math::visible_tiles_flat_view_capped_with_config(
viewport_bounds,
source_zoom,
view,
&self.selection_config.flat_view,
max_tiles,
)
} else {
rustial_math::visible_tiles(viewport_bounds, source_zoom)
};
if predicted_tiles.len() > max_tiles {
predicted_tiles.truncate(max_tiles);
}
self.prefetch_tiles(predicted_tiles, camera_world, max_requests)
}
pub fn prefetch_zoom_direction(
&mut self,
camera_world: (f64, f64),
direction: ZoomPrefetchDirection,
max_requests: usize,
) -> usize {
if max_requests == 0 || self.last_desired_tiles.is_empty() {
return 0;
}
let mut anchors: Vec<RequestCandidate> = self
.last_desired_tiles
.iter()
.copied()
.map(|tile| RequestCandidate::new(tile, camera_world, RequestUrgency::Refresh))
.collect();
sort_request_candidates(&mut anchors);
let mut tiles = Vec::new();
let mut seen = HashSet::new();
for anchor in anchors {
match direction {
ZoomPrefetchDirection::In => {
if anchor.tile.zoom >= self.selection_config.source_max_zoom {
continue;
}
for child in anchor.tile.children() {
if seen.insert(child) {
tiles.push(child);
}
}
}
ZoomPrefetchDirection::Out => {
let Some(parent) = anchor.tile.parent() else {
continue;
};
if parent.zoom < self.selection_config.source_min_zoom {
continue;
}
if seen.insert(parent) {
tiles.push(parent);
}
}
}
}
self.prefetch_tiles(tiles, camera_world, max_requests)
}
pub fn prefetch_route(
&mut self,
route: &[GeoCoord],
zoom: u8,
camera_world: (f64, f64),
max_requests: usize,
) -> usize {
if max_requests == 0 || route.len() < 2 || zoom < self.selection_config.source_min_zoom {
return 0;
}
let source_zoom = zoom.min(self.selection_config.source_max_zoom);
let tiles = tiles_along_route(route, source_zoom, camera_world);
self.prefetch_tiles(tiles, camera_world, max_requests)
}
fn prefetch_tiles<I>(
&mut self,
tiles: I,
camera_world: (f64, f64),
max_requests: usize,
) -> usize
where
I: IntoIterator<Item = TileId>,
{
if max_requests == 0 {
return 0;
}
let mut candidates: Vec<RequestCandidate> = tiles
.into_iter()
.filter(|tile| !self.last_desired_tiles.contains(tile))
.filter(|tile| self.cache.get(tile).is_none())
.map(|tile| RequestCandidate::new(tile, camera_world, RequestUrgency::Refresh))
.collect();
if candidates.is_empty() {
return 0;
}
sort_request_candidates(&mut candidates);
let mut requested = Vec::new();
for candidate in candidates.into_iter().take(max_requests) {
let insert = self.cache.insert_pending_with_eviction(candidate.tile);
self.record_evicted_tiles(&insert.evicted);
self.counters.cancelled_evicted_pending +=
self.cancel_evicted_pending(&insert.evicted) as u64;
if insert.inserted {
self.lifecycle.record_queued(candidate.tile);
requested.push(candidate.tile);
}
}
if !requested.is_empty() {
for &tile in &requested {
self.lifecycle.record_dispatched(tile);
}
self.source.request_many(&requested);
}
let requested_count = requested.len();
self.last_selection_stats.speculative_requested_tiles += requested_count;
self.counters.speculative_requested_tiles += requested_count as u64;
requested_count
}
#[inline]
fn begin_lifecycle_frame(&mut self) {
self.lifecycle.begin_frame(self.counters.frames + 1);
}
#[inline]
fn record_visible_tile_use(&mut self, target: TileId, actual: TileId, has_data: bool) {
if !has_data {
return;
}
if target == actual {
self.lifecycle.record_used_as_exact(actual);
} else {
self.lifecycle.record_used_as_fallback(actual);
}
}
fn record_evicted_tiles(&mut self, evicted: &[crate::tile_cache::EvictedTile]) {
for tile in evicted {
if tile.was_pending() {
self.lifecycle.record_evicted_while_pending(tile.id);
} else if tile.entry.is_renderable() {
self.lifecycle.record_evicted_after_renderable_use(tile.id);
}
}
}
pub fn promote_decoded(&mut self, decoded: Vec<(TileId, crate::tile_source::TileResponse)>) {
for (id, response) in decoded {
match response.data.validate() {
Ok(()) => {
self.lifecycle.record_decoded(id);
let evicted = self.cache.promote_with_eviction(id, response);
self.lifecycle.record_promoted_to_cache(id);
self.record_evicted_tiles(&evicted);
let cancelled = self.cancel_evicted_pending(&evicted);
self.counters.cancelled_evicted_pending += cancelled as u64;
}
Err(err) => {
self.lifecycle.record_failed(id);
self.cache.mark_failed(id, &err)
}
}
}
}
fn poll_completed(&mut self) {
let completed = self.source.poll();
for (id, result) in completed {
match result {
Ok(response) if response.not_modified => {
self.lifecycle.record_completed(id);
self.cache.refresh_ttl(id, response.freshness);
}
Ok(response) => match response.data.validate() {
Ok(()) => {
self.lifecycle.record_completed(id);
self.lifecycle.record_decoded(id);
let evicted = self.cache.promote_with_eviction(id, response);
self.lifecycle.record_promoted_to_cache(id);
self.record_evicted_tiles(&evicted);
let cancelled = self.cancel_evicted_pending(&evicted);
self.counters.cancelled_evicted_pending += cancelled as u64;
}
Err(err) => {
self.lifecycle.record_completed(id);
self.lifecycle.record_failed(id);
self.cache.mark_failed(id, &err)
}
},
Err(err) => {
self.lifecycle.record_completed(id);
self.lifecycle.record_failed(id);
self.cache.mark_failed(id, &err)
}
}
}
}
fn cancel_evicted_pending(&self, evicted: &[crate::tile_cache::EvictedTile]) -> usize {
let pending_ids: Vec<TileId> = evicted
.iter()
.filter(|tile| tile.was_pending())
.map(|tile| tile.id)
.collect();
self.source.cancel_many(&pending_ids);
pending_ids.len()
}
fn prune_stale_pending(&mut self, desired: &HashSet<TileId>) -> usize {
let stale_pending: Vec<TileId> = self
.cache
.inflight_ids()
.into_iter()
.filter(|id| !pending_tile_relevant_to_desired(*id, desired))
.collect();
self.source.cancel_many(&stale_pending);
for id in &stale_pending {
self.lifecycle.record_cancelled_as_stale(*id);
if !self.cache.cancel_reload(id) {
self.cache.remove(id);
}
}
stale_pending.len()
}
fn find_loaded_ancestor(&mut self, tile: &TileId) -> (TileId, Option<TileData>) {
let mut current = *tile;
let mut depth = 0u8;
while depth < MAX_ANCESTOR_DEPTH {
if let Some(parent) = current.parent() {
let loaded = self
.cache
.get(&parent)
.and_then(|entry| entry.data())
.cloned();
if let Some(data) = loaded {
self.cache.touch(&parent);
return (parent, Some(data));
}
current = parent;
depth += 1;
} else {
break;
}
}
(*tile, None)
}
fn find_loaded_children(&mut self, target: &TileId, max_depth: u8) -> Vec<(TileId, TileData)> {
if max_depth == 0 {
return Vec::new();
}
let mut frontier = vec![*target];
for _depth in 0..max_depth {
let mut next_frontier = Vec::with_capacity(frontier.len() * 4);
let mut all_loaded = true;
let mut children_data = Vec::with_capacity(frontier.len() * 4);
for tile in &frontier {
for child in tile.children() {
let loaded = self
.cache
.get(&child)
.and_then(|entry| entry.data())
.cloned();
if let Some(data) = loaded {
children_data.push((child, data));
next_frontier.push(child);
} else {
all_loaded = false;
break;
}
}
if !all_loaded {
break;
}
}
if all_loaded && !children_data.is_empty() {
for (child_id, _) in &children_data {
self.cache.touch(child_id);
}
return children_data;
}
frontier = next_frontier;
}
Vec::new()
}
fn request_parent_chain(
&mut self,
tile: TileId,
_camera_world: (f64, f64),
requested: &mut Vec<TileId>,
requested_set: &mut HashSet<TileId>,
cancelled_evicted_pending: &mut usize,
) {
let mut chain = Vec::new();
let mut current = tile;
let mut depth = 0u8;
while depth < MAX_ANCESTOR_DEPTH {
let Some(parent) = current.parent() else {
break;
};
match self.cache.get(&parent) {
Some(entry)
if entry.is_renderable() || entry.is_pending() || entry.is_reloading() =>
{
break
}
Some(_) => {
current = parent;
depth += 1;
}
None => {
chain.push(parent);
current = parent;
depth += 1;
}
}
}
chain.reverse();
for ancestor in chain {
if !requested_set.insert(ancestor) {
continue;
}
let insert = self.cache.insert_pending_with_eviction(ancestor);
self.record_evicted_tiles(&insert.evicted);
*cancelled_evicted_pending += self.cancel_evicted_pending(&insert.evicted);
if insert.inserted {
self.lifecycle.record_queued(ancestor);
requested.push(ancestor);
}
}
}
fn request_tiles_with_bootstrap(
&mut self,
refresh: &mut Vec<RequestCandidate>,
missing: &mut Vec<RequestCandidate>,
bootstrap: &[TileId],
camera_world: (f64, f64),
) -> (Vec<TileId>, usize) {
sort_request_candidates(refresh);
sort_request_candidates(missing);
let budget = self.selection_config.max_requests_per_frame;
let mut requested = Vec::with_capacity(refresh.len() + missing.len() + bootstrap.len() * 2);
let mut requested_set = HashSet::with_capacity(requested.capacity().max(1));
let mut cancelled_evicted_pending = 0usize;
let mut revalidate_pairs: Vec<(TileId, crate::tile_source::RevalidationHint)> =
Vec::with_capacity(refresh.len());
for candidate in refresh.drain(..) {
if requested_set.insert(candidate.tile) {
let hint = self
.cache
.revalidation_hint(&candidate.tile)
.unwrap_or_default();
revalidate_pairs.push((candidate.tile, hint));
requested.push(candidate.tile);
}
}
let pre_bootstrap_len = requested.len();
for &tile in bootstrap {
self.request_parent_chain(
tile,
camera_world,
&mut requested,
&mut requested_set,
&mut cancelled_evicted_pending,
);
}
let mut new_request_ids: Vec<TileId> = requested[pre_bootstrap_len..].to_vec();
for candidate in missing.drain(..) {
if new_request_ids.len() >= budget {
break;
}
if !requested_set.insert(candidate.tile) {
continue;
}
let insert = self.cache.insert_pending_with_eviction(candidate.tile);
self.record_evicted_tiles(&insert.evicted);
cancelled_evicted_pending += self.cancel_evicted_pending(&insert.evicted);
if insert.inserted {
self.lifecycle.record_queued(candidate.tile);
requested.push(candidate.tile);
new_request_ids.push(candidate.tile);
}
}
if !new_request_ids.is_empty() {
for &tile in &new_request_ids {
self.lifecycle.record_dispatched(tile);
}
self.source.request_many(&new_request_ids);
}
if !revalidate_pairs.is_empty() {
for (tile, _) in &revalidate_pairs {
self.lifecycle.record_dispatched(*tile);
}
self.source.request_revalidate_many(&revalidate_pairs);
}
(requested, cancelled_evicted_pending)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tile_cache::TileCacheEntry;
use crate::tile_lifecycle::TileLifecycleEventKind;
use crate::tile_source::{DecodedImage, TileData, TileError, TileResponse, TileSource};
use rustial_math::tile_bounds_world;
use std::sync::{Arc, Mutex};
struct MockSource {
ready: Mutex<Vec<(TileId, Result<TileResponse, TileError>)>>,
}
impl MockSource {
fn new() -> Self {
Self {
ready: Mutex::new(Vec::new()),
}
}
}
impl TileSource for MockSource {
fn request(&self, id: TileId) {
let data = TileData::Raster(DecodedImage {
width: 256,
height: 256,
data: vec![128u8; 256 * 256 * 4].into(),
});
self.ready
.lock()
.unwrap()
.push((id, Ok(TileResponse::from_data(data))));
}
fn poll(&self) -> Vec<(TileId, Result<TileResponse, TileError>)> {
let mut ready = self.ready.lock().unwrap();
std::mem::take(&mut *ready)
}
}
struct FailingSource;
impl TileSource for FailingSource {
fn request(&self, _id: TileId) {}
fn poll(&self) -> Vec<(TileId, Result<TileResponse, TileError>)> {
Vec::new()
}
}
struct DelayedFailSource {
pending: Mutex<Vec<TileId>>,
}
impl DelayedFailSource {
fn new() -> Self {
Self {
pending: Mutex::new(Vec::new()),
}
}
}
impl TileSource for DelayedFailSource {
fn request(&self, id: TileId) {
self.pending.lock().unwrap().push(id);
}
fn poll(&self) -> Vec<(TileId, Result<TileResponse, TileError>)> {
let ids: Vec<TileId> = std::mem::take(&mut *self.pending.lock().unwrap());
ids.into_iter()
.map(|id| (id, Err(TileError::Network("timeout".into()))))
.collect()
}
}
#[derive(Clone, Default)]
struct RecordingSource {
requested: Arc<Mutex<Vec<TileId>>>,
cancelled: Arc<Mutex<Vec<TileId>>>,
}
impl RecordingSource {
fn requested_ids(&self) -> Vec<TileId> {
self.requested.lock().unwrap().clone()
}
fn cancelled_ids(&self) -> Vec<TileId> {
self.cancelled.lock().unwrap().clone()
}
}
impl TileSource for RecordingSource {
fn request(&self, id: TileId) {
self.requested.lock().unwrap().push(id);
}
fn request_many(&self, ids: &[TileId]) {
self.requested.lock().unwrap().extend_from_slice(ids);
}
fn poll(&self) -> Vec<(TileId, Result<TileResponse, TileError>)> {
Vec::new()
}
fn cancel(&self, id: TileId) {
self.cancelled.lock().unwrap().push(id);
}
fn cancel_many(&self, ids: &[TileId]) {
self.cancelled.lock().unwrap().extend_from_slice(ids);
}
}
struct InvalidImageSource {
ready: Mutex<Vec<(TileId, Result<TileResponse, TileError>)>>,
}
impl InvalidImageSource {
fn new() -> Self {
Self {
ready: Mutex::new(Vec::new()),
}
}
}
impl TileSource for InvalidImageSource {
fn request(&self, id: TileId) {
self.ready.lock().unwrap().push((
id,
Ok(TileResponse::from_data(TileData::Raster(DecodedImage {
width: 2,
height: 2,
data: vec![255u8; 15].into(),
}))),
));
}
fn poll(&self) -> Vec<(TileId, Result<TileResponse, TileError>)> {
std::mem::take(&mut *self.ready.lock().unwrap())
}
}
fn full_world_bounds() -> WorldBounds {
let extent = rustial_math::WebMercator::max_extent();
WorldBounds::new(
rustial_math::WorldCoord::new(-extent, -extent, 0.0),
rustial_math::WorldCoord::new(extent, extent, 0.0),
)
}
fn dummy_tile_data() -> TileData {
TileData::Raster(DecodedImage {
width: 256,
height: 256,
data: vec![0u8; 256 * 256 * 4].into(),
})
}
fn dummy_tile_response() -> TileResponse {
TileResponse::from_data(dummy_tile_data())
}
fn tile_center(tile: TileId) -> (f64, f64) {
let bounds = tile_bounds_world(&tile);
(
(bounds.min.position.x + bounds.max.position.x) * 0.5,
(bounds.min.position.y + bounds.max.position.y) * 0.5,
)
}
fn inset_bounds(bounds: WorldBounds, inset: f64) -> WorldBounds {
WorldBounds::new(
rustial_math::WorldCoord::new(
bounds.min.position.x + inset,
bounds.min.position.y + inset,
0.0,
),
rustial_math::WorldCoord::new(
bounds.max.position.x - inset,
bounds.max.position.y - inset,
0.0,
),
)
}
#[test]
fn zoom_0_one_tile() {
let mut mgr = TileManager::new(Box::new(MockSource::new()), 100);
let vis = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), 0.0);
assert_eq!(vis.len(), 1);
assert!(vis.tiles[0].data.is_none());
let vis = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), 0.0);
assert_eq!(vis.len(), 1);
assert!(vis.tiles[0].is_loaded());
assert!(!vis.tiles[0].is_fallback());
}
#[test]
fn zoom_1_four_tiles() {
let mut mgr = TileManager::new(Box::new(MockSource::new()), 100);
let _ = mgr.update(&full_world_bounds(), 1, (0.0, 0.0), 0.0);
let vis = mgr.update(&full_world_bounds(), 1, (0.0, 0.0), 0.0);
assert_eq!(vis.len(), 4);
for tile in &vis {
assert!(tile.is_loaded());
}
}
#[test]
fn parent_fallback_when_pending() {
let mut mgr = TileManager::new(Box::new(MockSource::new()), 100);
let _ = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), 0.0);
let vis = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), 0.0);
assert!(vis.tiles[0].is_loaded());
let vis = mgr.update(&full_world_bounds(), 1, (0.0, 0.0), 0.0);
assert_eq!(vis.len(), 4);
for tile in &vis {
assert_eq!(tile.target.zoom, 1);
assert_eq!(tile.actual.zoom, 0);
assert!(tile.is_loaded());
assert!(tile.is_fallback());
}
}
#[test]
fn no_fallback_when_no_ancestor_loaded() {
let mut mgr = TileManager::new(Box::new(FailingSource), 100);
let vis = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), 0.0);
assert_eq!(vis.len(), 1);
assert!(!vis.tiles[0].is_loaded());
assert!(!vis.tiles[0].is_fallback());
}
#[test]
fn failed_tile_uses_parent_fallback() {
let mut mgr = TileManager::new(Box::new(DelayedFailSource::new()), 100);
let z0 = TileId::new(0, 0, 0);
mgr.cache.insert_pending(z0);
mgr.cache.promote(z0, dummy_tile_response());
let _ = mgr.update(&full_world_bounds(), 1, (0.0, 0.0), 0.0);
let vis = mgr.update(&full_world_bounds(), 1, (0.0, 0.0), 0.0);
assert_eq!(vis.len(), 4);
for tile in &vis {
assert_eq!(tile.target.zoom, 1);
assert_eq!(tile.actual.zoom, 0);
assert!(tile.is_loaded());
assert!(tile.is_fallback());
}
}
#[test]
fn cache_accessor() {
let mgr = TileManager::new(Box::new(MockSource::new()), 50);
assert!(mgr.cache().is_empty());
assert_eq!(mgr.cache().capacity(), 50);
assert_eq!(mgr.cached_count(), 0);
}
#[test]
fn no_duplicate_requests() {
let mut mgr = TileManager::new(Box::new(FailingSource), 100);
let _ = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), 0.0);
let _ = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), 0.0);
assert_eq!(mgr.cached_count(), 1);
}
#[test]
fn visible_tile_set_helpers() {
let set = VisibleTileSet::default();
assert!(set.is_empty());
assert_eq!(set.len(), 0);
assert_eq!(set.loaded_count(), 0);
let mut mgr = TileManager::new(Box::new(MockSource::new()), 100);
let _ = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), 0.0);
let vis = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), 0.0);
assert_eq!(vis.len(), 1);
assert_eq!(vis.loaded_count(), 1);
assert_eq!(vis.iter().count(), 1);
}
#[test]
fn visible_tile_is_fallback() {
let tile_exact = VisibleTile {
target: TileId::new(1, 0, 0),
actual: TileId::new(1, 0, 0),
data: None,
fade_opacity: 1.0,
};
assert!(!tile_exact.is_fallback());
let tile_fallback = VisibleTile {
target: TileId::new(1, 0, 0),
actual: TileId::new(0, 0, 0),
data: None,
fade_opacity: 1.0,
};
assert!(tile_fallback.is_fallback());
}
#[test]
fn visible_tile_texture_region_matches_parent_subrect() {
let tile = VisibleTile {
target: TileId::new(3, 4, 2),
actual: TileId::new(1, 1, 0),
data: None,
fade_opacity: 1.0,
};
let region = tile.texture_region();
assert!((region.u_min - 0.0).abs() < 1e-6);
assert!((region.v_min - 0.5).abs() < 1e-6);
assert!((region.u_max - 0.25).abs() < 1e-6);
assert!((region.v_max - 0.75).abs() < 1e-6);
}
#[test]
fn visible_tile_pixel_crop_rect_matches_parent_subrect() {
let tile = VisibleTile {
target: TileId::new(3, 4, 2),
actual: TileId::new(1, 1, 0),
data: None,
fade_opacity: 1.0,
};
let crop = tile.pixel_crop_rect(256, 256).unwrap();
assert_eq!(crop.x, 0);
assert_eq!(crop.y, 128);
assert_eq!(crop.width, 64);
assert_eq!(crop.height, 64);
}
#[test]
fn debug_impl() {
let mgr = TileManager::new(Box::new(MockSource::new()), 100);
let dbg = format!("{mgr:?}");
assert!(dbg.contains("TileManager"));
assert!(dbg.contains("cache_len"));
}
#[test]
fn requests_missing_tiles_nearest_first_within_same_zoom() {
let source = RecordingSource::default();
let mut mgr = TileManager::new(Box::new(source.clone()), 100);
let focus = TileId::new(1, 0, 0);
let _ = mgr.update(&full_world_bounds(), 1, tile_center(focus), 0.0);
let requested = source.requested_ids();
assert_eq!(requested.len(), 5);
assert_eq!(requested[0], TileId::new(0, 0, 0));
assert_eq!(requested[1], focus);
}
#[test]
fn coverage_requests_sort_ahead_of_refresh_requests() {
let coverage = TileId::new(4, 8, 8);
let refresh = TileId::new(4, 7, 7);
let camera_world = tile_center(refresh);
let mut candidates = vec![
RequestCandidate::new(refresh, camera_world, RequestUrgency::Refresh),
RequestCandidate::new(coverage, camera_world, RequestUrgency::Coverage),
];
sort_request_candidates(&mut candidates);
assert_eq!(candidates[0].tile, coverage);
assert_eq!(candidates[1].tile, refresh);
}
#[test]
fn coverage_requests_sort_ahead_of_fallback_refine_requests() {
let coverage = TileId::new(4, 8, 8);
let fallback_refine = TileId::new(4, 7, 7);
let camera_world = tile_center(fallback_refine);
let mut candidates = vec![
RequestCandidate::new(
fallback_refine,
camera_world,
RequestUrgency::FallbackRefine,
),
RequestCandidate::new(coverage, camera_world, RequestUrgency::Coverage),
];
sort_request_candidates(&mut candidates);
assert_eq!(candidates[0].tile, coverage);
assert_eq!(candidates[1].tile, fallback_refine);
}
#[test]
fn fallback_refine_requests_sort_ahead_of_refresh_requests() {
let fallback_refine = TileId::new(4, 8, 8);
let refresh = TileId::new(4, 7, 7);
let camera_world = tile_center(refresh);
let mut candidates = vec![
RequestCandidate::new(refresh, camera_world, RequestUrgency::Refresh),
RequestCandidate::new(
fallback_refine,
camera_world,
RequestUrgency::FallbackRefine,
),
];
sort_request_candidates(&mut candidates);
assert_eq!(candidates[0].tile, fallback_refine);
assert_eq!(candidates[1].tile, refresh);
}
#[test]
fn coarse_tiles_sort_first_within_same_priority_tier() {
let coarse = TileId::new(3, 4, 4);
let fine = TileId::new(5, 16, 16);
let camera_world = tile_center(fine);
let mut candidates = vec![
RequestCandidate::new(fine, camera_world, RequestUrgency::FallbackRefine),
RequestCandidate::new(coarse, camera_world, RequestUrgency::FallbackRefine),
];
sort_request_candidates(&mut candidates);
assert_eq!(candidates[0].tile, coarse);
assert_eq!(candidates[1].tile, fine);
}
#[test]
fn visible_requests_dispatch_before_refresh_revalidations() {
let source = RecordingSource::default();
let mut mgr = TileManager::new(Box::new(source.clone()), 64);
let camera_world = (0.0, 0.0);
let refresh_tile = TileId::new(4, 7, 7);
let fallback_tile = TileId::new(4, 8, 8);
let mut refresh = vec![RequestCandidate::new(
refresh_tile,
camera_world,
RequestUrgency::Refresh,
)];
let mut missing = vec![RequestCandidate::new(
fallback_tile,
camera_world,
RequestUrgency::FallbackRefine,
)];
let (requested, _) =
mgr.request_tiles_with_bootstrap(&mut refresh, &mut missing, &[], camera_world);
assert_eq!(requested, vec![refresh_tile, fallback_tile]);
assert_eq!(source.requested_ids(), vec![fallback_tile, refresh_tile]);
}
#[test]
fn cancels_pending_requests_that_scroll_out_of_view() {
let source = RecordingSource::default();
let mut mgr = TileManager::new(Box::new(source.clone()), 100);
let keep = TileId::new(1, 0, 0);
let _ = mgr.update(&full_world_bounds(), 1, tile_center(keep), 0.0);
assert_eq!(mgr.cached_count(), 5);
let narrow_bounds = inset_bounds(tile_bounds_world(&keep), 1.0);
let vis = mgr.update(&narrow_bounds, 1, tile_center(keep), 0.0);
assert_eq!(vis.len(), 1);
assert_eq!(mgr.cached_count(), 5);
let cancelled = source.cancelled_ids();
assert!(cancelled.is_empty());
assert!(!cancelled.contains(&keep));
}
#[test]
fn invalid_completed_tile_is_retried_after_failure() {
let mut mgr = TileManager::new(Box::new(InvalidImageSource::new()), 100);
let _ = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), 0.0);
let vis = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), 0.0);
assert_eq!(vis.len(), 1);
assert!(!vis.tiles[0].is_loaded());
assert!(matches!(
mgr.cache.get(&TileId::new(0, 0, 0)),
Some(TileCacheEntry::Pending)
));
}
#[test]
fn tiny_cache_caps_requests_to_avoid_thrashing() {
let source = RecordingSource::default();
let mut mgr = TileManager::new_with_config(
Box::new(source.clone()),
1,
TileSelectionConfig {
visible_tile_budget: 1,
..TileSelectionConfig::default()
},
);
let _ = mgr.update(
&full_world_bounds(),
1,
tile_center(TileId::new(1, 0, 0)),
0.0,
);
let requested = source.requested_ids();
let cancelled = source.cancelled_ids();
assert_eq!(requested.len(), 2);
assert_eq!(requested[0], TileId::new(0, 0, 0));
assert_eq!(cancelled.len(), 1);
assert_eq!(cancelled[0], TileId::new(0, 0, 0));
assert_eq!(mgr.cached_count(), 1);
}
#[test]
fn explicit_visible_tile_budget_is_respected() {
let mut mgr = TileManager::new_with_config(
Box::new(FailingSource),
512,
TileSelectionConfig {
visible_tile_budget: 1,
..TileSelectionConfig::default()
},
);
let vis = mgr.update(&full_world_bounds(), 1, (0.0, 0.0), 0.0);
assert_eq!(vis.len(), 1);
}
#[test]
fn tiny_cache_still_caps_effective_budget_below_policy_budget() {
let source = RecordingSource::default();
let mut mgr = TileManager::new_with_config(
Box::new(source.clone()),
1,
TileSelectionConfig {
visible_tile_budget: 512,
..TileSelectionConfig::default()
},
);
let _ = mgr.update(
&full_world_bounds(),
1,
tile_center(TileId::new(1, 0, 0)),
0.0,
);
let requested = source.requested_ids();
assert_eq!(requested.len(), 2);
assert_eq!(requested[0], TileId::new(0, 0, 0));
assert_eq!(mgr.cached_count(), 1);
}
#[test]
fn update_with_view_uses_shared_flat_tile_selection() {
let source = RecordingSource::default();
let mut mgr = TileManager::new(Box::new(source), 512);
let center = rustial_math::GeoCoord::from_lat_lon(39.8180, 2.6514);
let center_world = rustial_math::WebMercator::project(¢er);
let bounds = WorldBounds::new(
rustial_math::WorldCoord::new(
center_world.position.x - 220_000.0,
center_world.position.y - 220_000.0,
0.0,
),
rustial_math::WorldCoord::new(
center_world.position.x + 220_000.0,
center_world.position.y + 220_000.0,
0.0,
),
);
let view = rustial_math::FlatTileView::new(
rustial_math::WorldCoord::new(
center_world.position.x,
center_world.position.y,
center_world.position.z,
),
26_001.0,
76.5_f64.to_radians(),
79.9_f64.to_radians(),
std::f64::consts::FRAC_PI_4,
1280,
720,
);
let raw = rustial_math::visible_tiles(&bounds, 12);
let vis = mgr.update_with_view(
&bounds,
12,
(center_world.position.x, center_world.position.y),
26_001.0,
Some(&view),
);
assert!(raw.len() > vis.len());
assert!(!vis.is_empty());
assert!(vis.iter().all(|tile| tile.target.zoom == 12));
assert!(vis.iter().all(|tile| tile.actual.zoom <= 12));
}
#[test]
fn selection_stats_report_budget_hits_and_dropped_tiles() {
let mut mgr = TileManager::new_with_config(
Box::new(FailingSource),
512,
TileSelectionConfig {
visible_tile_budget: 1,
..TileSelectionConfig::default()
},
);
let vis = mgr.update(&full_world_bounds(), 1, (0.0, 0.0), 0.0);
let stats = mgr.last_selection_stats();
assert_eq!(vis.len(), 1);
assert!(stats.budget_hit);
assert_eq!(stats.raw_candidate_tiles, 4);
assert_eq!(stats.visible_tiles, 1);
assert_eq!(stats.dropped_by_budget, 3);
assert_eq!(mgr.counters().budget_hit_frames, 1);
assert_eq!(mgr.counters().dropped_by_budget, 3);
}
#[test]
fn selection_stats_report_exact_hits_and_requests() {
let mut mgr = TileManager::new(Box::new(MockSource::new()), 100);
let _ = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), 0.0);
let _ = mgr.update(&full_world_bounds(), 0, (0.0, 0.0), 0.0);
let stats = mgr.last_selection_stats();
assert_eq!(stats.visible_tiles, 1);
assert_eq!(stats.exact_visible_tiles, 1);
assert_eq!(stats.fallback_visible_tiles, 0);
assert_eq!(stats.missing_visible_tiles, 0);
assert_eq!(stats.exact_cache_hits, 1);
assert_eq!(stats.requested_tiles, 0);
assert_eq!(mgr.counters().exact_cache_hits, 1);
}
#[test]
fn selection_stats_report_fallback_hits_and_stale_cancellations() {
let source = RecordingSource::default();
let mut mgr = TileManager::new(Box::new(source.clone()), 100);
let z0 = TileId::new(0, 0, 0);
mgr.cache.insert_pending(z0);
mgr.cache.promote(z0, dummy_tile_response());
let _ = mgr.update(&full_world_bounds(), 1, (0.0, 0.0), 0.0);
let keep = TileId::new(1, 0, 0);
let narrow_bounds = inset_bounds(tile_bounds_world(&keep), 1.0);
let _ = mgr.update(&narrow_bounds, 1, tile_center(keep), 0.0);
let stats = mgr.last_selection_stats();
assert_eq!(stats.visible_tiles, 1);
assert_eq!(stats.fallback_visible_tiles, 1);
assert_eq!(stats.missing_visible_tiles, 0);
assert_eq!(stats.cancelled_stale_pending, 0);
assert_eq!(mgr.counters().fallback_hits, 5);
assert_eq!(mgr.counters().cancelled_stale_pending, 0);
assert!(source.cancelled_ids().is_empty());
}
#[test]
fn zoom_below_source_min_returns_empty() {
let source = MockSource::new();
let config = TileSelectionConfig {
source_min_zoom: 2,
source_max_zoom: 14,
..TileSelectionConfig::default()
};
let mut mgr = TileManager::new_with_config(Box::new(source), 100, config);
let result = mgr.update(&full_world_bounds(), 1, (0.0, 0.0), 0.0);
assert!(
result.is_empty(),
"zoom 1 < source_min_zoom 2 should return empty"
);
assert_eq!(mgr.last_selection_stats().visible_tiles, 0);
}
#[test]
fn zoom_at_source_min_returns_tiles() {
let source = MockSource::new();
let config = TileSelectionConfig {
source_min_zoom: 2,
source_max_zoom: 14,
..TileSelectionConfig::default()
};
let mut mgr = TileManager::new_with_config(Box::new(source), 100, config);
let result = mgr.update(&full_world_bounds(), 2, (0.0, 0.0), 0.0);
assert!(
!result.is_empty(),
"zoom == source_min_zoom should return tiles"
);
}
#[test]
fn overzoom_clamps_requests_to_source_max_zoom() {
let source = MockSource::new();
let config = TileSelectionConfig {
source_min_zoom: 0,
source_max_zoom: 1,
..TileSelectionConfig::default()
};
let mut mgr = TileManager::new_with_config(Box::new(source), 100, config);
let _ = mgr.update(&full_world_bounds(), 2, (0.0, 0.0), 0.0);
let result = mgr.update(&full_world_bounds(), 2, (0.0, 0.0), 0.0);
assert!(!result.is_empty());
for tile in result.iter() {
assert_eq!(tile.target.zoom, 2);
assert_eq!(tile.actual.zoom, 1);
let region = tile.texture_region();
assert!(!region.is_full());
}
let stats = mgr.last_selection_stats();
assert!(stats.overzoomed_visible_tiles > 0);
}
#[test]
fn overzoom_texture_region_maps_correctly() {
let source = MockSource::new();
let config = TileSelectionConfig {
source_min_zoom: 0,
source_max_zoom: 0,
..TileSelectionConfig::default()
};
let mut mgr = TileManager::new_with_config(Box::new(source), 100, config);
let _ = mgr.update(&full_world_bounds(), 1, (0.0, 0.0), 0.0);
let result = mgr.update(&full_world_bounds(), 1, (0.0, 0.0), 0.0);
assert_eq!(
result.len(),
4,
"4 display tiles at zoom 1 from 1 source tile at zoom 0"
);
let mut regions: Vec<_> = result.iter().map(|t| t.texture_region()).collect();
regions.sort_by(|a, b| {
a.u_min
.partial_cmp(&b.u_min)
.unwrap()
.then(a.v_min.partial_cmp(&b.v_min).unwrap())
});
for region in ®ions {
assert!(!region.is_full());
let u_size = region.u_max - region.u_min;
let v_size = region.v_max - region.v_min;
assert!(
(u_size - 0.5).abs() < 1e-5,
"each quadrant is half the tile"
);
assert!(
(v_size - 0.5).abs() < 1e-5,
"each quadrant is half the tile"
);
}
}
#[test]
fn overzoomed_display_targets_computes_children() {
let parent = TileId::new(1, 0, 0);
let children = overzoomed_display_targets(&parent, 2);
assert_eq!(children.len(), 4);
assert!(children.contains(&TileId::new(2, 0, 0)));
assert!(children.contains(&TileId::new(2, 1, 0)));
assert!(children.contains(&TileId::new(2, 0, 1)));
assert!(children.contains(&TileId::new(2, 1, 1)));
}
#[test]
fn overzoomed_display_targets_same_zoom_returns_self() {
let tile = TileId::new(3, 2, 1);
let targets = overzoomed_display_targets(&tile, 3);
assert_eq!(targets, vec![tile]);
}
#[test]
fn missing_tile_requests_parent_chain_before_exact_tile() {
let source = RecordingSource::default();
let mut mgr = TileManager::new_with_config(
Box::new(source.clone()),
16,
TileSelectionConfig {
visible_tile_budget: 1,
..TileSelectionConfig::default()
},
);
let target = TileId::new(2, 1, 1);
let bounds = inset_bounds(tile_bounds_world(&target), 1.0);
let _ = mgr.update(&bounds, 2, tile_center(target), 0.0);
let requested = source.requested_ids();
assert_eq!(
requested,
vec![TileId::new(0, 0, 0), TileId::new(1, 0, 0), target]
);
}
#[test]
fn desired_ancestor_retention_avoids_cancelling_bootstrap_parents() {
let source = RecordingSource::default();
let mut mgr = TileManager::new_with_config(
Box::new(source.clone()),
16,
TileSelectionConfig {
visible_tile_budget: 1,
..TileSelectionConfig::default()
},
);
let target = TileId::new(2, 1, 1);
let bounds = inset_bounds(tile_bounds_world(&target), 1.0);
let _ = mgr.update(&bounds, 2, tile_center(target), 0.0);
let _ = mgr.update(&bounds, 2, tile_center(target), 0.0);
assert!(source.cancelled_ids().is_empty());
assert_eq!(mgr.cached_count(), 3);
}
#[test]
fn previous_desired_tile_gets_one_frame_retention_before_stale_cancel() {
let source = RecordingSource::default();
let mut mgr = TileManager::new(Box::new(source.clone()), 32);
let first = TileId::new(14, 0, 0);
let second = TileId::new(14, 4_096, 4_096);
mgr.cache.insert_pending(first);
mgr.cache.insert_pending(second);
let previous_desired = HashSet::from([first]);
let retained = desired_with_temporal_retention(&[second], &previous_desired);
assert_eq!(mgr.prune_stale_pending(&retained), 0);
assert!(
!source.cancelled_ids().contains(&first),
"the previous desired tile should survive one extra frame of camera motion"
);
let current_only = desired_with_temporal_retention(&[second], &HashSet::from([second]));
assert_eq!(mgr.prune_stale_pending(¤t_only), 1);
assert!(
source.cancelled_ids().contains(&first),
"once the tile is outside both the current and immediately previous desired sets it should be cancelled"
);
}
#[test]
fn adjacent_same_zoom_pending_tile_survives_small_pan_horizon() {
let source = RecordingSource::default();
let mut mgr = TileManager::new(Box::new(source.clone()), 64);
let adjacent = TileId::new(10, 100, 100);
let desired = TileId::new(10, 101, 100);
mgr.cache.insert_pending(adjacent);
let desired_set = desired_with_ancestor_retention(&[desired]);
let cancelled = mgr.prune_stale_pending(&desired_set);
assert_eq!(cancelled, 0);
assert!(source.cancelled_ids().is_empty());
assert!(mgr.cache.get(&adjacent).is_some());
}
#[test]
fn nearby_descendant_pending_tile_survives_zoom_in_horizon() {
let source = RecordingSource::default();
let mut mgr = TileManager::new(Box::new(source.clone()), 64);
let pending_child = TileId::new(11, 204, 200);
let desired = TileId::new(10, 101, 100);
mgr.cache.insert_pending(pending_child);
let desired_set = desired_with_ancestor_retention(&[desired]);
let cancelled = mgr.prune_stale_pending(&desired_set);
assert_eq!(cancelled, 0);
assert!(source.cancelled_ids().is_empty());
assert!(mgr.cache.get(&pending_child).is_some());
}
#[test]
fn nearby_ancestor_pending_tile_survives_zoom_out_horizon() {
let source = RecordingSource::default();
let mut mgr = TileManager::new(Box::new(source.clone()), 64);
let pending_parent = TileId::new(9, 51, 50);
let desired = TileId::new(10, 101, 100);
mgr.cache.insert_pending(pending_parent);
let desired_set = desired_with_ancestor_retention(&[desired]);
let cancelled = mgr.prune_stale_pending(&desired_set);
assert_eq!(cancelled, 0);
assert!(source.cancelled_ids().is_empty());
assert!(mgr.cache.get(&pending_parent).is_some());
}
#[test]
fn stale_prune_preserves_reloading_renderable_payload() {
let source = RecordingSource::default();
let mut mgr = TileManager::new(Box::new(source.clone()), 64);
let reloading = TileId::new(10, 500, 500);
let desired = TileId::new(10, 0, 0);
mgr.cache.promote(reloading, dummy_tile_response());
assert!(mgr.cache.start_reload(reloading));
assert!(mgr.cache.get(&reloading).unwrap().is_reloading());
let desired_set = desired_with_ancestor_retention(&[desired]);
let cancelled = mgr.prune_stale_pending(&desired_set);
assert_eq!(cancelled, 1);
assert!(source.cancelled_ids().contains(&reloading));
let entry = mgr
.cache
.get(&reloading)
.expect("reloading entry should survive as expired");
assert!(entry.is_expired());
assert!(entry.is_renderable());
}
#[test]
fn stale_prune_removes_pure_pending_entry() {
let source = RecordingSource::default();
let mut mgr = TileManager::new(Box::new(source.clone()), 64);
let pending = TileId::new(10, 500, 500);
let desired = TileId::new(10, 0, 0);
mgr.cache.insert_pending(pending);
let desired_set = desired_with_ancestor_retention(&[desired]);
let cancelled = mgr.prune_stale_pending(&desired_set);
assert_eq!(cancelled, 1);
assert!(source.cancelled_ids().contains(&pending));
assert!(!mgr.cache.contains(&pending));
}
#[test]
fn speculative_prefetch_requests_only_tiles_outside_current_desired_set() {
let source = RecordingSource::default();
let mut mgr = TileManager::new_with_config(
Box::new(source.clone()),
32,
TileSelectionConfig::default(),
);
let current = TileId::new(2, 1, 1);
let predicted = TileId::new(2, 2, 1);
let current_bounds = inset_bounds(tile_bounds_world(¤t), 1.0);
let predicted_bounds = inset_bounds(tile_bounds_world(&predicted), 1.0);
let _ = mgr.update(¤t_bounds, 2, tile_center(current), 0.0);
let before = source.requested_ids().len();
let prefetched =
mgr.prefetch_with_view(&predicted_bounds, 2, tile_center(predicted), None, 2);
let requested = source.requested_ids();
assert_eq!(prefetched, 1);
assert_eq!(requested.len(), before + 1);
assert_eq!(requested.last().copied(), Some(predicted));
assert_eq!(mgr.last_selection_stats().speculative_requested_tiles, 1);
assert_eq!(mgr.counters().speculative_requested_tiles, 1);
}
#[test]
fn speculative_prefetch_skips_when_prediction_matches_current_desired_tiles() {
let source = RecordingSource::default();
let mut mgr = TileManager::new_with_config(
Box::new(source.clone()),
32,
TileSelectionConfig::default(),
);
let current = TileId::new(2, 1, 1);
let current_bounds = inset_bounds(tile_bounds_world(¤t), 1.0);
let _ = mgr.update(¤t_bounds, 2, tile_center(current), 0.0);
let before = source.requested_ids().len();
let prefetched = mgr.prefetch_with_view(¤t_bounds, 2, tile_center(current), None, 2);
assert_eq!(prefetched, 0);
assert_eq!(source.requested_ids().len(), before);
}
#[test]
fn zoom_in_prefetch_requests_children_of_centre_tiles() {
let source = RecordingSource::default();
let mut mgr = TileManager::new_with_config(
Box::new(source.clone()),
32,
TileSelectionConfig::default(),
);
let current = TileId::new(2, 1, 1);
let current_bounds = inset_bounds(tile_bounds_world(¤t), 1.0);
let _ = mgr.update(¤t_bounds, 2, tile_center(current), 0.0);
let before = source.requested_ids().len();
let prefetched =
mgr.prefetch_zoom_direction(tile_center(current), ZoomPrefetchDirection::In, 4);
let requested = source.requested_ids();
assert_eq!(prefetched, 4);
assert_eq!(requested.len(), before + 4);
assert!(requested[before..].contains(&TileId::new(3, 2, 2)));
assert!(requested[before..].contains(&TileId::new(3, 3, 2)));
assert!(requested[before..].contains(&TileId::new(3, 2, 3)));
assert!(requested[before..].contains(&TileId::new(3, 3, 3)));
}
#[test]
fn zoom_out_prefetch_requests_parent_tiles() {
let source = RecordingSource::default();
let mut mgr = TileManager::new_with_config(
Box::new(source.clone()),
32,
TileSelectionConfig::default(),
);
let current = TileId::new(2, 1, 1);
mgr.cache.insert_pending(current);
mgr.cache.promote(current, dummy_tile_response());
let current_bounds = inset_bounds(tile_bounds_world(¤t), 1.0);
let _ = mgr.update(¤t_bounds, 2, tile_center(current), 0.0);
let before = source.requested_ids().len();
let prefetched =
mgr.prefetch_zoom_direction(tile_center(current), ZoomPrefetchDirection::Out, 2);
let requested = source.requested_ids();
assert_eq!(prefetched, 1);
assert_eq!(requested.len(), before + 1);
assert_eq!(requested.last().copied(), Some(TileId::new(1, 0, 0)));
}
#[test]
fn route_prefetch_requests_tiles_along_polyline_ahead_of_camera() {
let source = RecordingSource::default();
let mut mgr = TileManager::new_with_config(
Box::new(source.clone()),
64,
TileSelectionConfig::default(),
);
let route = vec![
GeoCoord::from_lat_lon(0.0, 0.0),
GeoCoord::from_lat_lon(0.0, 30.0),
GeoCoord::from_lat_lon(0.0, 60.0),
];
let cam = WebMercator::project_clamped(&route[0]);
let camera_world = (cam.position.x, cam.position.y);
let current = geo_to_tile(&route[0], 4).tile_id();
let current_bounds = inset_bounds(tile_bounds_world(¤t), 1.0);
let _ = mgr.update(¤t_bounds, 4, camera_world, 0.0);
let before = source.requested_ids().len();
let prefetched = mgr.prefetch_route(&route, 4, camera_world, 8);
let requested = source.requested_ids();
assert!(prefetched > 0, "route prefetch should request tiles ahead");
assert_eq!(requested.len(), before + prefetched);
}
#[test]
fn route_prefetch_budget_is_respected() {
let source = RecordingSource::default();
let mut mgr = TileManager::new_with_config(
Box::new(source.clone()),
64,
TileSelectionConfig::default(),
);
let route = vec![
GeoCoord::from_lat_lon(0.0, 0.0),
GeoCoord::from_lat_lon(0.0, 30.0),
GeoCoord::from_lat_lon(0.0, 60.0),
];
let cam = WebMercator::project_clamped(&route[0]);
let camera_world = (cam.position.x, cam.position.y);
let current = geo_to_tile(&route[0], 4).tile_id();
let current_bounds = inset_bounds(tile_bounds_world(¤t), 1.0);
let _ = mgr.update(¤t_bounds, 4, camera_world, 0.0);
let prefetched = mgr.prefetch_route(&route, 4, camera_world, 2);
assert!(
prefetched <= 2,
"route prefetch must respect max_requests budget"
);
}
#[test]
fn route_prefetch_empty_route_returns_zero() {
let source = RecordingSource::default();
let mut mgr = TileManager::new(Box::new(source.clone()), 32);
assert_eq!(mgr.prefetch_route(&[], 4, (0.0, 0.0), 8), 0);
assert_eq!(
mgr.prefetch_route(&[GeoCoord::from_lat_lon(0.0, 0.0)], 4, (0.0, 0.0), 8,),
0
);
}
#[test]
fn tiles_along_route_produces_ordered_unique_tiles() {
let route = vec![
GeoCoord::from_lat_lon(0.0, 0.0),
GeoCoord::from_lat_lon(0.0, 10.0),
GeoCoord::from_lat_lon(0.0, 20.0),
];
let cam = WebMercator::project_clamped(&route[0]);
let camera_world = (cam.position.x, cam.position.y);
let tiles = tiles_along_route(&route, 4, camera_world);
assert!(!tiles.is_empty());
let unique: HashSet<_> = tiles.iter().copied().collect();
assert_eq!(
unique.len(),
tiles.len(),
"tiles_along_route must not produce duplicates"
);
assert!(tiles.iter().all(|t| t.zoom == 4));
}
#[test]
fn compute_fade_opacity_disabled_returns_one() {
let now = SystemTime::now();
assert_eq!(compute_fade_opacity(now, Some(now), 0.0), 1.0);
}
#[test]
fn compute_fade_opacity_no_loaded_at_returns_one() {
let now = SystemTime::now();
assert_eq!(compute_fade_opacity(now, None, 0.3), 1.0);
}
#[test]
fn compute_fade_opacity_ramps_from_zero_to_one() {
use std::time::Duration;
let loaded = SystemTime::now();
let half = loaded + Duration::from_millis(150);
let full = loaded + Duration::from_millis(300);
let over = loaded + Duration::from_millis(600);
let at_zero = compute_fade_opacity(loaded, Some(loaded), 0.3);
assert!((at_zero - 0.0).abs() < 0.01, "at load time: {at_zero}");
let at_half = compute_fade_opacity(half, Some(loaded), 0.3);
assert!((at_half - 0.5).abs() < 0.05, "at 150ms: {at_half}");
let at_full = compute_fade_opacity(full, Some(loaded), 0.3);
assert!((at_full - 1.0).abs() < 0.01, "at 300ms: {at_full}");
let at_over = compute_fade_opacity(over, Some(loaded), 0.3);
assert_eq!(at_over, 1.0, "past duration should clamp to 1.0");
}
#[test]
fn crossfade_emits_parent_while_child_fading() {
let source = MockSource::new();
let config = TileSelectionConfig {
raster_fade_duration: 10.0, ..TileSelectionConfig::default()
};
let mut mgr = TileManager::new_with_config(Box::new(source), 100, config);
let parent = TileId::new(0, 0, 0);
let child = TileId::new(1, 0, 0);
mgr.cache.insert_pending(parent);
mgr.cache.promote(parent, dummy_tile_response());
mgr.cache.insert_pending(child);
mgr.cache.promote(child, dummy_tile_response());
let bounds = inset_bounds(tile_bounds_world(&child), 1.0);
let vis = mgr.update(&bounds, 1, tile_center(child), 0.0);
let child_tiles: Vec<_> = vis.tiles.iter().filter(|t| t.actual == child).collect();
let parent_tiles: Vec<_> = vis.tiles.iter().filter(|t| t.actual == parent).collect();
assert!(!child_tiles.is_empty(), "child tile should be present");
let child_fade = child_tiles[0].fade_opacity;
assert!(
child_fade < 1.0,
"child should be fading (got {child_fade})"
);
assert!(
!parent_tiles.is_empty(),
"cross-fade parent should be emitted while child fades"
);
let parent_fade = parent_tiles[0].fade_opacity;
let sum = child_fade + parent_fade;
assert!(
(sum - 1.0).abs() < 0.05,
"child + parent opacities should sum to ~1.0 (got {sum})"
);
}
#[test]
fn max_fading_ancestor_cap_respected() {
let mut cache = TileCache::new(100);
let mut visible = VisibleTileSet { tiles: Vec::new() };
for z in 0..5 {
let id = TileId::new(z, 0, 0);
cache.insert_pending(id);
cache.promote(id, dummy_tile_response());
}
let target = TileId::new(4, 0, 0);
emit_crossfade_parent(&mut visible, target, 0.5, 2, &mut cache);
assert_eq!(visible.tiles.len(), 1);
assert_eq!(visible.tiles[0].actual.zoom, 3);
}
#[test]
fn from_child_tile_returns_correct_sub_region() {
let parent = TileId::new(1, 0, 0);
let child_tl = TileId::new(2, 0, 0);
let region = TileTextureRegion::from_child_tile(&parent, &child_tl).unwrap();
assert!((region.u_min - 0.0).abs() < 1e-6);
assert!((region.v_min - 0.0).abs() < 1e-6);
assert!((region.u_max - 0.5).abs() < 1e-6);
assert!((region.v_max - 0.5).abs() < 1e-6);
let child_br = TileId::new(2, 1, 1);
let region = TileTextureRegion::from_child_tile(&parent, &child_br).unwrap();
assert!((region.u_min - 0.5).abs() < 1e-6);
assert!((region.v_min - 0.5).abs() < 1e-6);
assert!((region.u_max - 1.0).abs() < 1e-6);
assert!((region.v_max - 1.0).abs() < 1e-6);
}
#[test]
fn from_child_tile_returns_none_for_non_descendant() {
let parent = TileId::new(1, 0, 0);
let wrong_child = TileId::new(2, 3, 3);
assert!(TileTextureRegion::from_child_tile(&parent, &wrong_child).is_none());
}
#[test]
fn from_child_tile_returns_none_when_child_zoom_lte_target() {
let parent = TileId::new(2, 0, 0);
let same = TileId::new(2, 0, 0);
assert!(TileTextureRegion::from_child_tile(&parent, &same).is_none());
let higher = TileId::new(1, 0, 0);
assert!(TileTextureRegion::from_child_tile(&parent, &higher).is_none());
}
#[test]
fn child_fallback_uses_cached_children() {
let source = MockSource::new();
let config = TileSelectionConfig {
max_child_depth: 2,
..TileSelectionConfig::default()
};
let mut mgr = TileManager::new_with_config(Box::new(source), 100, config);
let parent = TileId::new(1, 0, 0);
let children = parent.children();
for child in &children {
mgr.cache.insert_pending(*child);
mgr.cache.promote(*child, dummy_tile_response());
}
let bounds = inset_bounds(tile_bounds_world(&parent), 1.0);
let vis = mgr.update(&bounds, 1, tile_center(parent), 0.0);
let child_tiles: Vec<_> = vis.tiles.iter().filter(|t| t.is_child_fallback()).collect();
assert_eq!(child_tiles.len(), 4, "expected 4 child-fallback tiles");
for ct in &child_tiles {
assert_eq!(ct.target, parent);
assert!(ct.data.is_some());
assert_eq!(ct.actual.zoom, 2);
}
let stats = mgr.last_selection_stats();
assert_eq!(stats.child_fallback_hits, 1);
assert_eq!(stats.child_fallback_visible_tiles, 4);
}
#[test]
fn child_fallback_prefers_children_over_parent() {
let source = MockSource::new();
let config = TileSelectionConfig {
max_child_depth: 2,
..TileSelectionConfig::default()
};
let mut mgr = TileManager::new_with_config(Box::new(source), 100, config);
let z0 = TileId::new(0, 0, 0);
mgr.cache.insert_pending(z0);
mgr.cache.promote(z0, dummy_tile_response());
let target = TileId::new(1, 0, 0);
let children = target.children();
for child in &children {
mgr.cache.insert_pending(*child);
mgr.cache.promote(*child, dummy_tile_response());
}
let bounds = inset_bounds(tile_bounds_world(&target), 1.0);
let vis = mgr.update(&bounds, 1, tile_center(target), 0.0);
let child_tiles: Vec<_> = vis.tiles.iter().filter(|t| t.is_child_fallback()).collect();
let parent_tiles: Vec<_> = vis.tiles.iter().filter(|t| t.actual == z0).collect();
assert_eq!(child_tiles.len(), 4, "expected child fallback tiles");
assert_eq!(
parent_tiles.len(),
0,
"should not use parent when children available"
);
}
#[test]
fn child_fallback_incomplete_children_falls_through_to_parent() {
let source = MockSource::new();
let config = TileSelectionConfig {
max_child_depth: 2,
..TileSelectionConfig::default()
};
let mut mgr = TileManager::new_with_config(Box::new(source), 100, config);
let z0 = TileId::new(0, 0, 0);
mgr.cache.insert_pending(z0);
mgr.cache.promote(z0, dummy_tile_response());
let target = TileId::new(1, 0, 0);
let children = target.children();
for child in &children[..3] {
mgr.cache.insert_pending(*child);
mgr.cache.promote(*child, dummy_tile_response());
}
let bounds = inset_bounds(tile_bounds_world(&target), 1.0);
let vis = mgr.update(&bounds, 1, tile_center(target), 0.0);
let parent_tiles: Vec<_> = vis.tiles.iter().filter(|t| t.actual == z0).collect();
assert!(!parent_tiles.is_empty(), "should fall back to z0 parent");
let child_fb: Vec<_> = vis.tiles.iter().filter(|t| t.is_child_fallback()).collect();
assert!(
child_fb.is_empty(),
"incomplete children should not be used"
);
}
#[test]
fn child_fallback_max_depth_cap_respected() {
let source = MockSource::new();
let config = TileSelectionConfig {
max_child_depth: 1,
..TileSelectionConfig::default()
};
let mut mgr = TileManager::new_with_config(Box::new(source), 100, config);
let target = TileId::new(1, 0, 0);
for child in target.children() {
for grandchild in child.children() {
mgr.cache.insert_pending(grandchild);
mgr.cache.promote(grandchild, dummy_tile_response());
}
}
let bounds = inset_bounds(tile_bounds_world(&target), 1.0);
let vis = mgr.update(&bounds, 1, tile_center(target), 0.0);
let child_fb: Vec<_> = vis.tiles.iter().filter(|t| t.is_child_fallback()).collect();
assert!(
child_fb.is_empty(),
"depth=1 should not reach grandchildren"
);
}
#[test]
fn child_fallback_disabled_when_max_child_depth_zero() {
let source = MockSource::new();
let config = TileSelectionConfig {
max_child_depth: 0,
..TileSelectionConfig::default()
};
let mut mgr = TileManager::new_with_config(Box::new(source), 100, config);
let target = TileId::new(1, 0, 0);
for child in target.children() {
mgr.cache.insert_pending(child);
mgr.cache.promote(child, dummy_tile_response());
}
let bounds = inset_bounds(tile_bounds_world(&target), 1.0);
let vis = mgr.update(&bounds, 1, tile_center(target), 0.0);
let child_fb: Vec<_> = vis.tiles.iter().filter(|t| t.is_child_fallback()).collect();
assert!(
child_fb.is_empty(),
"max_child_depth=0 should disable child fallback"
);
}
#[test]
fn stale_pending_same_zoom_tiles_are_pruned_when_unrelated_to_desired_view() {
let source = RecordingSource::default();
let mut mgr = TileManager::new(Box::new(source.clone()), 128);
let stale = TileId::new(4, 0, 0);
let desired = TileId::new(4, 8, 8);
mgr.cache.insert_pending(stale);
let desired_set = desired_with_ancestor_retention(&[desired]);
let cancelled = mgr.prune_stale_pending(&desired_set);
assert_eq!(cancelled, 1);
assert_eq!(source.cancelled_ids(), vec![stale]);
assert!(mgr.cache.get(&stale).is_none());
}
#[test]
fn is_child_fallback_returns_true_for_child_actual() {
let tile = VisibleTile {
target: TileId::new(1, 0, 0),
actual: TileId::new(2, 0, 0),
data: Some(dummy_tile_response().data),
fade_opacity: 1.0,
};
assert!(tile.is_child_fallback());
assert!(!tile.is_overzoomed());
}
#[test]
fn lifecycle_diagnostics_track_request_completion_and_exact_use() {
let mut mgr = TileManager::new(Box::new(MockSource::new()), 100);
let bounds = full_world_bounds();
let _ = mgr.update(&bounds, 0, (0.0, 0.0), 0.0);
let _ = mgr.update(&bounds, 0, (0.0, 0.0), 0.0);
let diagnostics = mgr.lifecycle_diagnostics();
let record = diagnostics
.active_records
.iter()
.find(|record| record.tile == TileId::new(0, 0, 0))
.expect("expected z0 lifecycle record");
assert_eq!(record.first_selected_frame, Some(1));
assert_eq!(record.first_queued_frame, Some(1));
assert_eq!(record.first_dispatched_frame, Some(1));
assert_eq!(record.first_completed_frame, Some(2));
assert_eq!(record.first_decoded_frame, Some(2));
assert_eq!(record.first_promoted_frame, Some(2));
assert_eq!(record.first_renderable_frame, Some(2));
assert_eq!(record.first_exact_frame, Some(2));
assert_eq!(record.queued_frames_to_dispatch, Some(0));
assert_eq!(record.in_flight_frames_to_complete, Some(1));
assert_eq!(record.completion_to_visible_use_frames, Some(0));
assert!(diagnostics
.recent_events
.iter()
.any(|event| event.tile == TileId::new(0, 0, 0)
&& event.kind == TileLifecycleEventKind::Queued));
assert!(diagnostics
.recent_events
.iter()
.any(|event| event.tile == TileId::new(0, 0, 0)
&& event.kind == TileLifecycleEventKind::UsedAsExact));
}
#[test]
fn lifecycle_diagnostics_track_stale_cancellation() {
let source = RecordingSource::default();
let mut mgr = TileManager::new(Box::new(source.clone()), 128);
let first = TileId::new(4, 0, 0);
let desired = TileId::new(4, 8, 8);
mgr.begin_lifecycle_frame();
mgr.cache.insert_pending(first);
mgr.lifecycle.record_queued(first);
let desired_set = desired_with_ancestor_retention(&[desired]);
let cancelled_count = mgr.prune_stale_pending(&desired_set);
let diagnostics = mgr.lifecycle_diagnostics();
let cancelled = diagnostics
.recent_terminal_records
.iter()
.find(|record| record.tile == first)
.expect("expected stale-cancelled tile lifecycle record");
assert_eq!(
cancelled.terminal_event,
Some(TileLifecycleEventKind::CancelledAsStale)
);
assert_eq!(cancelled.tile, first);
assert_eq!(cancelled_count, 1);
assert_eq!(source.cancelled_ids(), vec![first]);
assert!(diagnostics
.recent_events
.iter()
.any(|event| event.tile == first
&& event.kind == TileLifecycleEventKind::CancelledAsStale));
}
}