slint-mapping 0.1.0

Map framework for Slint — interactive map component with tile rendering, panning, zooming, and marker overlays.
Documentation
//! Real-Slint integration tests.
//!
//! These tests instantiate a `MapView`, attach a `MapController` to it
//! with the bundled sample tile bundle, then invoke the same `pan` /
//! `zoom-by` callbacks that the TouchArea inside `MapView` would fire
//! in response to user gestures. The assertions verify that:
//!
//!   - The camera state mirrored on the Slint side (`latitude`,
//!     `longitude`, `zoom` properties) tracks the camera updates.
//!   - The tile model the controller pushes into `MapView.tiles` has
//!     non-zero contents once the window has been given a size.
//!   - The zoom-anchored-at-cursor invariant holds in the round-trip
//!     between Slint property reads and writes.
//!
//! Unlike the pure-Rust tests in `src/camera.rs`, this exercises the
//! Slint type bindings, the property accessors generated by
//! slint-build, the `ModelRc<Tile>` push path, and the
//! `on_pan` / `on_zoom_by` callback dispatch.

use slint::{ComponentHandle, Model, PhysicalSize};
use slint_mapping::sources::FileTileSource;
use slint_mapping::{MapController, MapView};
use std::cell::Cell;

/// Initialize the i-slint-backend-testing backend exactly once *per
/// thread*. `cargo test` runs each `#[test]` in its own thread; slint's
/// testing backend stores platform state thread-locally, so init must
/// run in each test thread but exactly once (a double-init panics).
fn init_backend() {
    thread_local! {
        static INITED: Cell<bool> = const { Cell::new(false) };
    }
    INITED.with(|done| {
        if !done.get() {
            i_slint_backend_testing::init_no_event_loop();
            done.set(true);
        }
    });
}

/// Create a freshly-wired MapView + controller pointed at the bundled
/// sample tiles, with the window sized to 800×600 logical pixels so
/// `MapController::refresh` has a viewport to compute against.
fn make_map() -> (MapView, MapController) {
    init_backend();
    let map = MapView::new().expect("MapView::new");
    map.window().set_size(PhysicalSize::new(800, 600));
    let source = FileTileSource::new(slint_mapping::SAMPLE_TILES_DIR);
    let controller = MapController::new(&map, source);
    (map, controller)
}

#[test]
fn initial_state_matches_slint_defaults() {
    let (map, _c) = make_map();
    assert_eq!(map.get_latitude(), 0.0);
    assert_eq!(map.get_longitude(), 0.0);
    assert_eq!(map.get_zoom(), 2.0);
}

#[test]
fn set_centre_updates_slint_properties() {
    let (map, c) = make_map();
    c.set_centre(-0.1276, 51.5074);
    c.set_zoom(3.0);
    assert!(
        (map.get_longitude() - -0.1276).abs() < 1e-3,
        "longitude property: got {}",
        map.get_longitude()
    );
    assert!(
        (map.get_latitude() - 51.5074).abs() < 1e-3,
        "latitude property: got {}",
        map.get_latitude()
    );
    assert_eq!(map.get_zoom(), 3.0);
}

#[test]
fn invoke_pan_callback_moves_camera() {
    let (map, _c) = make_map();
    let lon_before = map.get_longitude();
    // Drag right by 200px — the camera should pan west (longitude decreases).
    map.invoke_pan(200.0, 0.0);
    let lon_after = map.get_longitude();
    assert!(
        lon_after < lon_before,
        "drag right should decrease longitude, before={lon_before} after={lon_after}"
    );
}

#[test]
fn invoke_pan_then_unpan_round_trips() {
    let (map, _c) = make_map();
    let lon0 = map.get_longitude();
    let lat0 = map.get_latitude();
    map.invoke_pan(137.0, -89.0);
    map.invoke_pan(-137.0, 89.0);
    assert!(
        (map.get_longitude() - lon0).abs() < 1e-3,
        "lon round-trip: {} -> {}",
        lon0,
        map.get_longitude()
    );
    assert!(
        (map.get_latitude() - lat0).abs() < 1e-3,
        "lat round-trip: {} -> {}",
        lat0,
        map.get_latitude()
    );
}

#[test]
fn invoke_zoom_by_changes_zoom_level() {
    let (map, _c) = make_map();
    let z0 = map.get_zoom();
    map.invoke_zoom_by(1.0, 400.0, 300.0); // anchor at centre
    assert!(
        (map.get_zoom() - (z0 + 1.0)).abs() < 1e-6,
        "expected zoom {} after +1, got {}",
        z0 + 1.0,
        map.get_zoom()
    );
}

#[test]
fn zoom_anchored_at_centre_does_not_drift_camera() {
    let (map, c) = make_map();
    c.set_centre(2.3522, 48.8566); // Paris
    c.set_zoom(3.0);
    let lon0 = map.get_longitude();
    let lat0 = map.get_latitude();
    map.invoke_zoom_by(1.0, 400.0, 300.0); // centre anchor at 800x600
    assert!(
        (map.get_longitude() - lon0).abs() < 1e-4,
        "centre-anchored zoom should NOT move longitude: {lon0} -> {}",
        map.get_longitude()
    );
    assert!(
        (map.get_latitude() - lat0).abs() < 1e-4,
        "centre-anchored zoom should NOT move latitude: {lat0} -> {}",
        map.get_latitude()
    );
}

#[test]
fn zoom_anchored_off_centre_does_move_camera() {
    let (map, c) = make_map();
    c.set_centre(0.0, 0.0);
    c.set_zoom(3.0);
    let lon0 = map.get_longitude();
    // Zoom in anchored at top-left (0,0) — camera should shift so the
    // geo-coord at (0,0) px stays put. With a centred camera, the
    // top-left geo coord is north-west of centre; zooming in must
    // move the camera toward it (decrease lon, increase lat).
    map.invoke_zoom_by(2.0, 0.0, 0.0);
    assert!(
        map.get_longitude() < lon0,
        "off-centre zoom-in should shift camera toward anchor (lon decrease), got {}",
        map.get_longitude()
    );
    assert!(
        map.get_latitude() > 0.0,
        "off-centre zoom-in should shift camera toward anchor (lat increase), got {}",
        map.get_latitude()
    );
}

#[test]
fn zoom_clamps_to_source_max() {
    let (map, c) = make_map();
    c.set_zoom(2.0);
    // FileTileSource defaults to max_zoom 22; overshoot heavily.
    for _ in 0..50 {
        map.invoke_zoom_by(1.0, 400.0, 300.0);
    }
    assert!(
        map.get_zoom() <= 22.0,
        "zoom must not exceed source max, got {}",
        map.get_zoom()
    );
}

#[test]
fn zoom_clamps_to_source_min() {
    let (map, c) = make_map();
    c.set_zoom(2.0);
    for _ in 0..50 {
        map.invoke_zoom_by(-1.0, 400.0, 300.0);
    }
    assert!(
        map.get_zoom() >= 0.0,
        "zoom must not go below source min, got {}",
        map.get_zoom()
    );
}

#[test]
fn refresh_populates_the_tile_model() {
    let (map, _c) = make_map();
    // With the sample bundle (covers z=0-3) and the default camera at
    // (0, 0, z=2), the controller's refresh should request ~4-9 tiles
    // depending on viewport math + over-draw.
    let tiles = map.get_tiles();
    let count = tiles.row_count();
    assert!(
        count > 0,
        "tile model should be populated for an 800x600 viewport at z=2"
    );
    assert!(
        count <= 64,
        "didn't expect more than the whole z=2 grid (16) plus over-draw, got {count}"
    );
}

#[test]
fn pan_refresh_keeps_tile_model_non_empty() {
    let (map, _c) = make_map();
    let count_before = map.get_tiles().row_count();
    assert!(count_before > 0);
    // Pan by 600px in each direction; controller refreshes after each.
    map.invoke_pan(600.0, 0.0);
    map.invoke_pan(0.0, 400.0);
    let count_after = map.get_tiles().row_count();
    assert!(
        count_after > 0,
        "tile model should not empty out under panning"
    );
}

#[test]
fn changing_source_via_controller_re_renders() {
    let (map, c) = make_map();
    let count_before = map.get_tiles().row_count();
    // Swap to a synthetic source (different tile bundle).
    c.set_source(slint_mapping::sources::SyntheticTileSource::new());
    let count_after = map.get_tiles().row_count();
    // Synthetic source returns Some for every key, so count >= original.
    assert!(
        count_after >= count_before,
        "source swap should not lose tiles, before={count_before} after={count_after}"
    );
}