1use crate::operation::{AreaOfUse, GridId, GridInterpolation, GridShiftDirection};
2use smallvec::SmallVec;
3use std::collections::HashMap;
4use std::f64::consts::PI;
5use std::path::PathBuf;
6use std::sync::{Arc, Mutex};
7use thiserror::Error;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum GridFormat {
11 Ntv2,
12 Unsupported,
13}
14
15#[derive(Debug, Clone, PartialEq)]
16pub struct GridDefinition {
17 pub id: GridId,
18 pub name: String,
19 pub format: GridFormat,
20 pub interpolation: GridInterpolation,
21 pub area_of_use: Option<AreaOfUse>,
22 pub resource_names: SmallVec<[String; 2]>,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq)]
26pub struct GridSample {
27 pub lon_shift_radians: f64,
28 pub lat_shift_radians: f64,
29}
30
31#[derive(Debug, Error, Clone)]
32pub enum GridError {
33 #[error("grid not found: {0}")]
34 NotFound(String),
35 #[error("grid resource unavailable: {0}")]
36 Unavailable(String),
37 #[error("grid parse error: {0}")]
38 Parse(String),
39 #[error("grid point outside coverage: {0}")]
40 OutsideCoverage(String),
41 #[error("unsupported grid format: {0}")]
42 UnsupportedFormat(String),
43}
44
45pub trait GridProvider: Send + Sync {
46 fn definition(
47 &self,
48 grid: &GridDefinition,
49 ) -> std::result::Result<Option<GridDefinition>, GridError>;
50 fn load(&self, grid: &GridDefinition) -> std::result::Result<Option<GridHandle>, GridError>;
51}
52
53#[derive(Clone)]
54pub struct GridHandle {
55 definition: GridDefinition,
56 data: Arc<GridData>,
57}
58
59impl GridHandle {
60 pub fn definition(&self) -> &GridDefinition {
61 &self.definition
62 }
63
64 pub fn sample(
65 &self,
66 lon_radians: f64,
67 lat_radians: f64,
68 ) -> std::result::Result<GridSample, GridError> {
69 match self.data.as_ref() {
70 GridData::Ntv2(set) => set.sample(lon_radians, lat_radians),
71 }
72 }
73
74 pub fn apply(
75 &self,
76 lon_radians: f64,
77 lat_radians: f64,
78 direction: GridShiftDirection,
79 ) -> std::result::Result<(f64, f64), GridError> {
80 match self.data.as_ref() {
81 GridData::Ntv2(set) => set.apply(lon_radians, lat_radians, direction),
82 }
83 }
84}
85
86pub(crate) struct GridRuntime {
87 providers: Vec<Arc<dyn GridProvider>>,
88 definition_cache: Mutex<HashMap<GridId, GridDefinition>>,
89 handle_cache: Mutex<HashMap<GridId, GridHandle>>,
90}
91
92impl GridRuntime {
93 pub(crate) fn new(app_provider: Option<Arc<dyn GridProvider>>) -> Self {
94 let mut providers: Vec<Arc<dyn GridProvider>> = Vec::with_capacity(2);
95 if let Some(provider) = app_provider {
96 providers.push(provider);
97 }
98 providers.push(Arc::new(EmbeddedGridProvider));
99 Self {
100 providers,
101 definition_cache: Mutex::new(HashMap::new()),
102 handle_cache: Mutex::new(HashMap::new()),
103 }
104 }
105
106 pub(crate) fn resolve_definition(
107 &self,
108 grid: &GridDefinition,
109 ) -> std::result::Result<GridDefinition, GridError> {
110 if let Some(cached) = self
111 .definition_cache
112 .lock()
113 .expect("grid definition cache poisoned")
114 .get(&grid.id)
115 .cloned()
116 {
117 return Ok(cached);
118 }
119
120 for provider in &self.providers {
121 if let Some(definition) = provider.definition(grid)? {
122 self.definition_cache
123 .lock()
124 .expect("grid definition cache poisoned")
125 .insert(grid.id, definition.clone());
126 return Ok(definition);
127 }
128 }
129
130 Err(GridError::Unavailable(grid.name.clone()))
131 }
132
133 pub(crate) fn resolve_handle(
134 &self,
135 grid: &GridDefinition,
136 ) -> std::result::Result<GridHandle, GridError> {
137 if let Some(cached) = self
138 .handle_cache
139 .lock()
140 .expect("grid handle cache poisoned")
141 .get(&grid.id)
142 .cloned()
143 {
144 return Ok(cached);
145 }
146
147 let definition = self.resolve_definition(grid)?;
148 for provider in &self.providers {
149 if let Some(handle) = provider.load(&definition)? {
150 self.handle_cache
151 .lock()
152 .expect("grid handle cache poisoned")
153 .insert(grid.id, handle.clone());
154 return Ok(handle);
155 }
156 }
157
158 Err(GridError::Unavailable(definition.name))
159 }
160}
161
162#[derive(Default)]
163pub struct EmbeddedGridProvider;
164
165impl GridProvider for EmbeddedGridProvider {
166 fn definition(
167 &self,
168 grid: &GridDefinition,
169 ) -> std::result::Result<Option<GridDefinition>, GridError> {
170 if embedded_grid_bytes(&grid.resource_names).is_some() {
171 return Ok(Some(grid.clone()));
172 }
173 Ok(None)
174 }
175
176 fn load(&self, grid: &GridDefinition) -> std::result::Result<Option<GridHandle>, GridError> {
177 let Some(bytes) = embedded_grid_bytes(&grid.resource_names) else {
178 return Ok(None);
179 };
180
181 let data = match grid.format {
182 GridFormat::Ntv2 => GridData::Ntv2(Ntv2GridSet::parse(bytes)?),
183 GridFormat::Unsupported => {
184 return Err(GridError::UnsupportedFormat(grid.name.clone()));
185 }
186 };
187
188 Ok(Some(GridHandle {
189 definition: grid.clone(),
190 data: Arc::new(data),
191 }))
192 }
193}
194
195pub struct FilesystemGridProvider {
196 roots: Vec<PathBuf>,
197}
198
199impl FilesystemGridProvider {
200 pub fn new<I>(roots: I) -> Self
201 where
202 I: IntoIterator<Item = PathBuf>,
203 {
204 Self {
205 roots: roots.into_iter().collect(),
206 }
207 }
208
209 fn locate(&self, grid: &GridDefinition) -> Option<PathBuf> {
210 for root in &self.roots {
211 for name in &grid.resource_names {
212 let candidate = root.join(name);
213 if candidate.exists() {
214 return Some(candidate);
215 }
216 }
217 }
218 None
219 }
220}
221
222impl GridProvider for FilesystemGridProvider {
223 fn definition(
224 &self,
225 grid: &GridDefinition,
226 ) -> std::result::Result<Option<GridDefinition>, GridError> {
227 if self.locate(grid).is_some() {
228 return Ok(Some(grid.clone()));
229 }
230 Ok(None)
231 }
232
233 fn load(&self, grid: &GridDefinition) -> std::result::Result<Option<GridHandle>, GridError> {
234 let Some(path) = self.locate(grid) else {
235 return Ok(None);
236 };
237
238 let bytes = std::fs::read(&path)
239 .map_err(|err| GridError::Unavailable(format!("{}: {err}", path.display())))?;
240 let data = match grid.format {
241 GridFormat::Ntv2 => GridData::Ntv2(Ntv2GridSet::parse(&bytes)?),
242 GridFormat::Unsupported => {
243 return Err(GridError::UnsupportedFormat(grid.name.clone()));
244 }
245 };
246
247 Ok(Some(GridHandle {
248 definition: grid.clone(),
249 data: Arc::new(data),
250 }))
251 }
252}
253
254enum GridData {
255 Ntv2(Ntv2GridSet),
256}
257
258fn embedded_grid_bytes(names: &[String]) -> Option<&'static [u8]> {
259 for name in names {
260 if name.eq_ignore_ascii_case("ntv2_0.gsb") {
261 return Some(include_bytes!("../data/grids/ntv2_0.gsb"));
262 }
263 }
264 None
265}
266
267#[derive(Clone)]
268struct Ntv2GridSet {
269 grids: Vec<Ntv2Grid>,
270 roots: Vec<usize>,
271}
272
273impl Ntv2GridSet {
274 fn parse(bytes: &[u8]) -> std::result::Result<Self, GridError> {
275 const HEADER_LEN: usize = 11 * 16;
276
277 if bytes.len() < HEADER_LEN {
278 return Err(GridError::Parse("NTv2 file too small".into()));
279 }
280
281 let endian = if u32::from_le_bytes(bytes[8..12].try_into().expect("slice length checked"))
282 == 11
283 {
284 Endian::Little
285 } else if u32::from_be_bytes(bytes[8..12].try_into().expect("slice length checked")) == 11 {
286 Endian::Big
287 } else {
288 return Err(GridError::Parse(
289 "invalid NTv2 header endianness marker".into(),
290 ));
291 };
292
293 if &bytes[56..63] != b"SECONDS" {
294 return Err(GridError::Parse(
295 "only NTv2 GS_TYPE=SECONDS is supported".into(),
296 ));
297 }
298
299 let num_subfiles = read_u32(bytes, 40, endian)? as usize;
300 let mut offset = HEADER_LEN;
301 let mut grids = Vec::with_capacity(num_subfiles);
302 let mut name_to_index = HashMap::new();
303 let mut parent_links: Vec<Option<String>> = Vec::with_capacity(num_subfiles);
304
305 for _ in 0..num_subfiles {
306 let header = bytes
307 .get(offset..offset + HEADER_LEN)
308 .ok_or_else(|| GridError::Parse("truncated NTv2 subfile header".into()))?;
309 if &header[0..8] != b"SUB_NAME" {
310 return Err(GridError::Parse("invalid NTv2 subfile header tag".into()));
311 }
312
313 let name = parse_label(&header[8..16]);
314 let parent = parse_label(&header[24..32]);
315 let south = read_f64(header, 72, endian)? * PI / 180.0 / 3600.0;
316 let north = read_f64(header, 88, endian)? * PI / 180.0 / 3600.0;
317 let east = -read_f64(header, 104, endian)? * PI / 180.0 / 3600.0;
318 let west = -read_f64(header, 120, endian)? * PI / 180.0 / 3600.0;
319 let res_y = read_f64(header, 136, endian)? * PI / 180.0 / 3600.0;
320 let res_x = read_f64(header, 152, endian)? * PI / 180.0 / 3600.0;
321 let gs_count = read_u32(header, 168, endian)? as usize;
322
323 if !(west < east && south < north && res_x > 0.0 && res_y > 0.0) {
324 return Err(GridError::Parse(format!(
325 "invalid NTv2 georeferencing for subgrid {name}"
326 )));
327 }
328
329 let width = (((east - west) / res_x).abs() + 0.5).floor() as usize + 1;
330 let height = (((north - south) / res_y).abs() + 0.5).floor() as usize + 1;
331 if width * height != gs_count {
332 return Err(GridError::Parse(format!(
333 "NTv2 subgrid {name} cell count mismatch: expected {} got {gs_count}",
334 width * height
335 )));
336 }
337
338 let data_len = gs_count
339 .checked_mul(4)
340 .and_then(|count| count.checked_mul(4))
341 .ok_or_else(|| GridError::Parse("NTv2 data size overflow".into()))?;
342 let data = bytes
343 .get(offset + HEADER_LEN..offset + HEADER_LEN + data_len)
344 .ok_or_else(|| {
345 GridError::Parse(format!("truncated NTv2 data for subgrid {name}"))
346 })?;
347
348 let mut lat_shift = vec![0.0f64; gs_count];
349 let mut lon_shift = vec![0.0f64; gs_count];
350 for y in 0..height {
351 for x in 0..width {
352 let source_x = width - 1 - x;
353 let record_offset = (y * width + source_x) * 16;
354 let lat = read_f32(data, record_offset, endian)? as f64 * PI / 180.0 / 3600.0;
355 let lon =
356 -(read_f32(data, record_offset + 4, endian)? as f64) * PI / 180.0 / 3600.0;
357 let dest = y * width + x;
358 lat_shift[dest] = lat;
359 lon_shift[dest] = lon;
360 }
361 }
362
363 let index = grids.len();
364 name_to_index.insert(name.clone(), index);
365 parent_links.push(
366 if parent.eq_ignore_ascii_case("none") || parent.is_empty() {
367 None
368 } else {
369 Some(parent)
370 },
371 );
372 grids.push(Ntv2Grid {
373 name,
374 extent: GridExtent {
375 west,
376 south,
377 east,
378 north,
379 res_x,
380 res_y,
381 },
382 width,
383 height,
384 lat_shift,
385 lon_shift,
386 children: Vec::new(),
387 });
388 offset += HEADER_LEN + data_len;
389 }
390
391 let mut roots = Vec::new();
392 for (idx, parent) in parent_links.into_iter().enumerate() {
393 if let Some(parent_name) = parent {
394 let Some(parent_idx) = name_to_index.get(&parent_name).copied() else {
395 return Err(GridError::Parse(format!(
396 "missing NTv2 parent subgrid {parent_name} for {}",
397 grids[idx].name
398 )));
399 };
400 grids[parent_idx].children.push(idx);
401 } else {
402 roots.push(idx);
403 }
404 }
405
406 Ok(Self { grids, roots })
407 }
408
409 fn sample(
410 &self,
411 lon_radians: f64,
412 lat_radians: f64,
413 ) -> std::result::Result<GridSample, GridError> {
414 let (grid_idx, local_lon, local_lat) = self.grid_at(lon_radians, lat_radians)?;
415 let (lon_shift, lat_shift) = interpolate(&self.grids[grid_idx], local_lon, local_lat)?;
416 Ok(GridSample {
417 lon_shift_radians: lon_shift,
418 lat_shift_radians: lat_shift,
419 })
420 }
421
422 fn apply(
423 &self,
424 lon_radians: f64,
425 lat_radians: f64,
426 direction: GridShiftDirection,
427 ) -> std::result::Result<(f64, f64), GridError> {
428 match direction {
429 GridShiftDirection::Forward => {
430 let shift = self.sample(lon_radians, lat_radians)?;
431 Ok((
432 lon_radians + shift.lon_shift_radians,
433 lat_radians + shift.lat_shift_radians,
434 ))
435 }
436 GridShiftDirection::Reverse => self.apply_inverse(lon_radians, lat_radians),
437 }
438 }
439
440 fn apply_inverse(
441 &self,
442 lon_radians: f64,
443 lat_radians: f64,
444 ) -> std::result::Result<(f64, f64), GridError> {
445 const MAX_ITERATIONS: usize = 10;
446 const TOLERANCE: f64 = 1e-12;
447
448 let mut estimate_lon = lon_radians;
449 let mut estimate_lat = lat_radians;
450
451 for _ in 0..MAX_ITERATIONS {
452 let shift = self.sample(estimate_lon, estimate_lat)?;
453 let next_lon = lon_radians - shift.lon_shift_radians;
454 let next_lat = lat_radians - shift.lat_shift_radians;
455 let diff_lon = next_lon - estimate_lon;
456 let diff_lat = next_lat - estimate_lat;
457 estimate_lon = next_lon;
458 estimate_lat = next_lat;
459 if diff_lon * diff_lon + diff_lat * diff_lat <= TOLERANCE * TOLERANCE {
460 return Ok((estimate_lon, estimate_lat));
461 }
462 }
463
464 Ok((estimate_lon, estimate_lat))
465 }
466
467 fn grid_at(
468 &self,
469 lon_radians: f64,
470 lat_radians: f64,
471 ) -> std::result::Result<(usize, f64, f64), GridError> {
472 for &root in &self.roots {
473 if self.grids[root].extent.contains(lon_radians, lat_radians) {
474 let idx = self.deepest_child(root, lon_radians, lat_radians);
475 let extent = &self.grids[idx].extent;
476 return Ok((idx, lon_radians - extent.west, lat_radians - extent.south));
477 }
478 }
479 Err(GridError::OutsideCoverage(format!(
480 "longitude {:.8} latitude {:.8}",
481 lon_radians.to_degrees(),
482 lat_radians.to_degrees()
483 )))
484 }
485
486 fn deepest_child(&self, index: usize, lon_radians: f64, lat_radians: f64) -> usize {
487 for &child in &self.grids[index].children {
488 if self.grids[child].extent.contains(lon_radians, lat_radians) {
489 return self.deepest_child(child, lon_radians, lat_radians);
490 }
491 }
492 index
493 }
494}
495
496#[derive(Clone)]
497struct Ntv2Grid {
498 name: String,
499 extent: GridExtent,
500 width: usize,
501 height: usize,
502 lat_shift: Vec<f64>,
503 lon_shift: Vec<f64>,
504 children: Vec<usize>,
505}
506
507#[derive(Clone, Copy)]
508struct GridExtent {
509 west: f64,
510 south: f64,
511 east: f64,
512 north: f64,
513 res_x: f64,
514 res_y: f64,
515}
516
517impl GridExtent {
518 fn contains(&self, lon_radians: f64, lat_radians: f64) -> bool {
519 let epsilon = (self.res_x + self.res_y) * 1e-10;
520 lon_radians >= self.west - epsilon
521 && lon_radians <= self.east + epsilon
522 && lat_radians >= self.south - epsilon
523 && lat_radians <= self.north + epsilon
524 }
525}
526
527fn interpolate(
528 grid: &Ntv2Grid,
529 local_lon: f64,
530 local_lat: f64,
531) -> std::result::Result<(f64, f64), GridError> {
532 let lam = local_lon / grid.extent.res_x;
533 let phi = local_lat / grid.extent.res_y;
534 let mut x = lam.floor() as isize;
535 let mut y = phi.floor() as isize;
536 let mut fx = lam - x as f64;
537 let mut fy = phi - y as f64;
538
539 if x < 0 {
540 if x == -1 && fx > 1.0 - 1e-9 {
541 x = 0;
542 fx = 0.0;
543 } else {
544 return Err(GridError::OutsideCoverage(grid.name.clone()));
545 }
546 }
547 if y < 0 {
548 if y == -1 && fy > 1.0 - 1e-9 {
549 y = 0;
550 fy = 0.0;
551 } else {
552 return Err(GridError::OutsideCoverage(grid.name.clone()));
553 }
554 }
555 if x as usize + 1 >= grid.width {
556 if x as usize + 1 == grid.width && fx < 1e-9 {
557 x -= 1;
558 fx = 1.0;
559 } else {
560 return Err(GridError::OutsideCoverage(grid.name.clone()));
561 }
562 }
563 if y as usize + 1 >= grid.height {
564 if y as usize + 1 == grid.height && fy < 1e-9 {
565 y -= 1;
566 fy = 1.0;
567 } else {
568 return Err(GridError::OutsideCoverage(grid.name.clone()));
569 }
570 }
571
572 let idx = |xx: usize, yy: usize| yy * grid.width + xx;
573 let x0 = x as usize;
574 let y0 = y as usize;
575 let x1 = x0 + 1;
576 let y1 = y0 + 1;
577
578 let m00 = (1.0 - fx) * (1.0 - fy);
579 let m10 = fx * (1.0 - fy);
580 let m01 = (1.0 - fx) * fy;
581 let m11 = fx * fy;
582
583 let lon = m00 * grid.lon_shift[idx(x0, y0)]
584 + m10 * grid.lon_shift[idx(x1, y0)]
585 + m01 * grid.lon_shift[idx(x0, y1)]
586 + m11 * grid.lon_shift[idx(x1, y1)];
587 let lat = m00 * grid.lat_shift[idx(x0, y0)]
588 + m10 * grid.lat_shift[idx(x1, y0)]
589 + m01 * grid.lat_shift[idx(x0, y1)]
590 + m11 * grid.lat_shift[idx(x1, y1)];
591
592 Ok((lon, lat))
593}
594
595#[derive(Clone, Copy)]
596enum Endian {
597 Little,
598 Big,
599}
600
601fn parse_label(bytes: &[u8]) -> String {
602 let end = bytes
603 .iter()
604 .position(|byte| *byte == 0)
605 .unwrap_or(bytes.len());
606 String::from_utf8_lossy(&bytes[..end]).trim().to_string()
607}
608
609fn read_u32(bytes: &[u8], offset: usize, endian: Endian) -> std::result::Result<u32, GridError> {
610 let slice = bytes
611 .get(offset..offset + 4)
612 .ok_or_else(|| GridError::Parse("truncated integer".into()))?;
613 Ok(match endian {
614 Endian::Little => u32::from_le_bytes(slice.try_into().expect("slice length checked")),
615 Endian::Big => u32::from_be_bytes(slice.try_into().expect("slice length checked")),
616 })
617}
618
619fn read_f64(bytes: &[u8], offset: usize, endian: Endian) -> std::result::Result<f64, GridError> {
620 let slice = bytes
621 .get(offset..offset + 8)
622 .ok_or_else(|| GridError::Parse("truncated float64".into()))?;
623 Ok(match endian {
624 Endian::Little => f64::from_le_bytes(slice.try_into().expect("slice length checked")),
625 Endian::Big => f64::from_be_bytes(slice.try_into().expect("slice length checked")),
626 })
627}
628
629fn read_f32(bytes: &[u8], offset: usize, endian: Endian) -> std::result::Result<f32, GridError> {
630 let slice = bytes
631 .get(offset..offset + 4)
632 .ok_or_else(|| GridError::Parse("truncated float32".into()))?;
633 Ok(match endian {
634 Endian::Little => f32::from_le_bytes(slice.try_into().expect("slice length checked")),
635 Endian::Big => f32::from_be_bytes(slice.try_into().expect("slice length checked")),
636 })
637}
638
639#[cfg(test)]
640mod tests {
641 use super::*;
642 use std::sync::atomic::{AtomicUsize, Ordering};
643
644 #[test]
645 fn embedded_ntv2_grid_samples_known_point() {
646 let provider = EmbeddedGridProvider;
647 let definition = GridDefinition {
648 id: GridId(1),
649 name: "ntv2_0.gsb".into(),
650 format: GridFormat::Ntv2,
651 interpolation: GridInterpolation::Bilinear,
652 area_of_use: None,
653 resource_names: SmallVec::from_vec(vec!["ntv2_0.gsb".into()]),
654 };
655 let handle = provider.load(&definition).unwrap().expect("embedded grid");
656 let (lon, lat) = handle
657 .apply(
658 (-80.5041667f64).to_radians(),
659 44.5458333f64.to_radians(),
660 GridShiftDirection::Forward,
661 )
662 .unwrap();
663 assert!(
664 (lon.to_degrees() - (-80.50401615833)).abs() < 1e-6,
665 "lon={lon}"
666 );
667 assert!((lat.to_degrees() - 44.5458827236).abs() < 3e-6, "lat={lat}");
668 }
669
670 struct TrackingGridProvider {
671 override_definition: bool,
672 definition_calls: Arc<AtomicUsize>,
673 load_calls: Arc<AtomicUsize>,
674 }
675
676 impl GridProvider for TrackingGridProvider {
677 fn definition(
678 &self,
679 grid: &GridDefinition,
680 ) -> std::result::Result<Option<GridDefinition>, GridError> {
681 self.definition_calls.fetch_add(1, Ordering::SeqCst);
682 if self.override_definition {
683 let mut overridden = grid.clone();
684 overridden.name = "custom override".into();
685 Ok(Some(overridden))
686 } else {
687 Ok(None)
688 }
689 }
690
691 fn load(
692 &self,
693 grid: &GridDefinition,
694 ) -> std::result::Result<Option<GridHandle>, GridError> {
695 self.load_calls.fetch_add(1, Ordering::SeqCst);
696 EmbeddedGridProvider.load(grid)
697 }
698 }
699
700 fn test_grid_definition() -> GridDefinition {
701 GridDefinition {
702 id: GridId(1),
703 name: "ntv2_0.gsb".into(),
704 format: GridFormat::Ntv2,
705 interpolation: GridInterpolation::Bilinear,
706 area_of_use: None,
707 resource_names: SmallVec::from_vec(vec!["ntv2_0.gsb".into()]),
708 }
709 }
710
711 #[test]
712 fn app_grid_provider_can_override_embedded_grid() {
713 let definition_calls = Arc::new(AtomicUsize::new(0));
714 let load_calls = Arc::new(AtomicUsize::new(0));
715 let provider = TrackingGridProvider {
716 override_definition: true,
717 definition_calls: Arc::clone(&definition_calls),
718 load_calls: Arc::clone(&load_calls),
719 };
720 let runtime = GridRuntime::new(Some(Arc::new(provider)));
721
722 let handle = runtime
723 .resolve_handle(&test_grid_definition())
724 .expect("grid should resolve");
725
726 assert_eq!(handle.definition().name, "custom override");
727 assert_eq!(definition_calls.load(Ordering::SeqCst), 1);
728 assert_eq!(load_calls.load(Ordering::SeqCst), 1);
729 }
730
731 #[test]
732 fn app_grid_provider_falls_back_to_embedded_grid() {
733 let definition_calls = Arc::new(AtomicUsize::new(0));
734 let load_calls = Arc::new(AtomicUsize::new(0));
735 let provider = TrackingGridProvider {
736 override_definition: false,
737 definition_calls: Arc::clone(&definition_calls),
738 load_calls: Arc::clone(&load_calls),
739 };
740 let runtime = GridRuntime::new(Some(Arc::new(provider)));
741
742 let handle = runtime
743 .resolve_handle(&test_grid_definition())
744 .expect("embedded grid should remain available");
745
746 assert_eq!(handle.definition().name, "ntv2_0.gsb");
747 assert_eq!(definition_calls.load(Ordering::SeqCst), 1);
748 assert_eq!(load_calls.load(Ordering::SeqCst), 1);
749 }
750}