Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-geomap/tests/osm2d_deck_scale.rs

//! **The OSM 2D map's SCALE, measured on screen, through the deck the app ships.**
//!
//! The owner, live: *"osm 2d in facet-demo has GOT back the scale bug"* — found by a human
//! looking at a screen, while `facett-geomap`'s 68 unit tests were all green. That gap is
//! the whole point of this file.
//!
//! The green guard is `osm2d::tests::set_scale_routes_through_update_and_scale_reads_back`.
//! It asserts a **state round-trip**: `set_scale(4.0)` then `scale() == 4.0` and
//! `camera.zoom == 2048`. Both hold. It is green because it drives `OsmView::local()`,
//! which has **never been fitted** — its camera is still the default `zoom = 512`, so
//! `scale()` (`camera.zoom / 512.0`) reads exactly `1.0` and the arithmetic works out.
//!
//! A real map is fitted on its first frame. `MercatorCamera::fit_merc_bbox` sets
//! `zoom` to whatever fills the pane with the data — for a city/country dataset that is
//! **10⁵–10⁶ px per Mercator unit**. `scale()` then returns *thousands*. And the deck
//! toolbar that drives it, `FacetDeck::scale_active` (facett-core/src/lib.rs), is
//! written against a `[0.25, 4.0]` contract:
//!
//! ```ignore
//! let s = (f.scale() * k).clamp(0.25, 4.0);
//! f.set_scale(s);
//! ```
//!
//! So on the shipped OSM 2D tab, clicking **+** computes `2000 × 1.1 = 2200`, clamps it
//! to `4.0`, and calls `set_scale(4.0)` → `zoom = 2048`. **A zoom-IN click zooms the map
//! out by roughly a thousandfold**, and the `−` and `Reset` buttons do the same. The
//! percentage beside them reads something like `200000%`.
//!
//! Nothing caught this because nothing had ever rendered the map inside a `FacetDeck`
//! and then measured **how big the map is drawn**. So that is what this file does:
//!
//! * it drives `FacetDeck::ui` — the exact call `facett-demo`'s `DemoState::draw_app`
//!   makes (`facett-demo/src/lib.rs`), which is what `DemoApp::ui` (the real
//!   `eframe::App`) calls. No `draw_ui` helper, no test-only `CentralPanel` wrapper: the
//!   widget tree here is the widget tree a user clicks;
//! * it lets the **real** first-frame fit happen against the **real** pane rect the deck
//!   allocated (read back out of `deck.state_json()["menu"]["content_rect"]`), instead of
//!   a hand-picked rect;
//! * it measures scale as **pixels between two projected map points** — the on-screen
//!   size of the drawn world, through `MercatorCamera::to_px`, the single projection
//!   every layer of the painter uses. Not a field read-back.
//!
//! RED-when-broken: if a zoom-in click shrinks the map, if a zoom-out click grows it, if
//! `Reset` throws the camera away, or if the facet's `scale()` leaves the band the deck
//! is allowed to multiply and clamp, the assertions below fail with the measured pixels.

use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use facett_core::{Facet, FacetDeck};
use facett_geomap::osm2d::OsmView;

/// The tab title `OsmView::local()` carries, and the deck key its state lands under.
const TAB: &str = "OSM 2D";

/// **The on-screen scale, in pixels.** Two fixed points one hundredth of a Mercator unit
/// apart (~400 m at Liechtenstein's latitude — a couple of city blocks), projected
/// through the camera the deck is holding, with the pane rect the deck actually laid
/// out. This is the thing a human sees: how many pixels a fixed piece of the world
/// occupies. Doubling it means the map got twice as big.
fn on_screen_px_per_world_unit(deck: &mut FacetDeck, rect: egui::Rect) -> f64 {
    let view: &mut OsmView = deck.facet_mut(TAB).expect("the deck is holding the OSM 2D facet");
    let cam = view.state();
    let (cx, cy) = cam.center;
    let a = cam.to_px(cx - 0.005, cy, rect);
    let b = cam.to_px(cx + 0.005, cy, rect);
    ((b.x - a.x) as f64).abs()
}

/// The pane rect the deck allocated for the active facet, straight out of its own
/// `state_json` — the rect the map was really painted into.
fn content_rect(deck: &FacetDeck) -> egui::Rect {
    let r = &deck.state_json()["menu"]["content_rect"];
    let f = |i: usize| r[i].as_f64().expect("content_rect is four numbers") as f32;
    egui::Rect::from_min_size(egui::pos2(f(0), f(1)), egui::vec2(f(2), f(3)))
}

/// A deck holding exactly the OSM 2D facet, driven through `FacetDeck::ui` — the call
/// `DemoState::draw_app` makes. Runs two frames: the first lets the pane get a real
/// rect and the fit commit, the second settles the toolbar layout.
fn fitted_deck_harness() -> Harness<'static, FacetDeck> {
    let deck = FacetDeck::new(vec![Box::new(OsmView::local()) as Box<dyn Facet>]);
    let mut harness = Harness::builder()
        .with_size(egui::vec2(1200.0, 900.0))
        .build_ui_state(|ui, deck: &mut FacetDeck| deck.ui(ui), deck);
    harness.run();
    harness.run();
    harness
}

/// **RED-when-broken: on the fitted map, the deck's ZOOM-IN button makes the map
/// BIGGER.** The whole visible bug in one assertion — measured in pixels, not fields.
#[test]
fn deck_zoom_in_grows_the_drawn_map_instead_of_collapsing_it() {
    let mut harness = fitted_deck_harness();

    let rect = content_rect(harness.state());
    assert!(rect.width() > 64.0 && rect.height() > 64.0, "the pane got a real rect: {rect:?}");

    let before = on_screen_px_per_world_unit(harness.state_mut(), rect);
    assert!(before > 0.0, "the map is drawn at a positive scale before the click");

    // The REAL toolbar button `FacetDeck::ui` paints for a `scalable()` facet, clicked
    // the way a user clicks it. `OsmView::caps()` advertises `.scalable()`, so this
    // button is live on the shipped OSM 2D tab.
    harness.get_by_label("+").click();
    harness.run();

    let after = on_screen_px_per_world_unit(harness.state_mut(), rect);

    assert!(
        after > before,
        "clicking ZOOM IN (+) must make the map BIGGER on screen, but it went {:.0} px \
         → {:.0} px per world unit (a {:.0}× collapse). This is the scale bug: the deck \
         computes `scale() * 1.1` and clamps it to [0.25, 4.0], while a FITTED OsmView \
         reports `scale() = camera.zoom / 512` in the thousands — so the clamp throws \
         the whole camera away on the first click.",
        before,
        after,
        before / after.max(f64::MIN_POSITIVE),
    );
    // …and by roughly the 1.1× the button promises, not some other amount.
    let ratio = after / before;
    assert!(
        (ratio - 1.1).abs() < 0.02,
        "a + click is a 1.1× zoom step, got {ratio:.4}× ({before:.0} → {after:.0} px)",
    );
}

/// **RED-when-broken: the ZOOM-OUT button shrinks the map, and the two buttons are
/// inverses.** The `−` half of the same defect: today it is clamped identically, so
/// `−` and `+` both land the camera on the same wrong zoom and pressing one after the
/// other does not come home.
#[test]
fn deck_zoom_out_shrinks_the_drawn_map_and_the_two_buttons_are_inverses() {
    let mut harness = fitted_deck_harness();
    let rect = content_rect(harness.state());
    let start = on_screen_px_per_world_unit(harness.state_mut(), rect);

    harness.get_by_label("−").click();
    harness.run();
    let out = on_screen_px_per_world_unit(harness.state_mut(), rect);
    assert!(
        out < start,
        "clicking ZOOM OUT (−) must make the map SMALLER, got {start:.0} → {out:.0} px",
    );

    harness.get_by_label("+").click();
    harness.run();
    let back = on_screen_px_per_world_unit(harness.state_mut(), rect);
    assert!(
        (back / start - 1.0).abs() < 0.02,
        "− then + returns to where it started: {start:.0} → {out:.0} → {back:.0} px",
    );
}

/// **RED-when-broken: RESET returns the map to its FIT, not to a hardcoded zoom.**
/// `FacetDeck::reset_scale` calls `set_scale(1.0)`, which the docs on `OsmView::scale`
/// call "native". For a map, native is the fit the pane opened on — the whole dataset
/// filling the pane. Today `set_scale(1.0)` means `zoom = 512`, a whole-world view, so
/// Reset on a city map throws the user out to the globe.
#[test]
fn deck_reset_returns_to_the_fit_the_pane_opened_on() {
    let mut harness = fitted_deck_harness();
    let rect = content_rect(harness.state());
    let fitted = on_screen_px_per_world_unit(harness.state_mut(), rect);

    // Navigate away first, so Reset has something to undo.
    harness.get_by_label("+").click();
    harness.run();
    harness.get_by_label("+").click();
    harness.run();

    harness.get_by_label("Reset").click();
    harness.run();
    let reset = on_screen_px_per_world_unit(harness.state_mut(), rect);

    assert!(
        (reset / fitted - 1.0).abs() < 0.02,
        "Reset restores the opening FIT ({fitted:.0} px per world unit), got {reset:.0} px \
         — a {:.0}× jump away from the data the pane was showing",
        (fitted / reset.max(f64::MIN_POSITIVE)).max(reset / fitted),
    );
}

/// **RED-when-broken: a fitted map's `scale()` stays inside the band the deck is allowed
/// to drive.** The structural half of the same bug, stated as the contract it breaks.
///
/// `FacetDeck::scale_active` clamps to `[0.25, 4.0]` and `reset_scale` writes `1.0`.
/// That is the deck's whole vocabulary for a `scalable()` facet. A facet whose `scale()`
/// lands outside it cannot be driven at all: every click saturates the clamp and writes
/// a value unrelated to where the camera was. The unit guard misses this because it
/// never fits — this asserts on the value AFTER a real frame, which is the only state a
/// user ever sees.
#[test]
fn a_fitted_maps_scale_stays_inside_the_decks_own_clamp_band() {
    let mut harness = fitted_deck_harness();
    let view: &mut OsmView = harness.state_mut().facet_mut(TAB).expect("the OSM 2D facet");

    let zoom = view.state().zoom;
    assert!(zoom > 512.0, "the first frame really fitted the data (zoom {zoom})");

    let s = <OsmView as Facet>::scale(view);
    assert!(
        (0.25..=4.0).contains(&s),
        "a fitted OSM 2D reports scale() = {s} (camera zoom {zoom}), outside the \
         [0.25, 4.0] band FacetDeck::scale_active clamps into — so the +/−/Reset toolbar \
         and Ctrl-+/−/0 cannot express the camera at all, and the toolbar's readout \
         shows {:.0}%",
        s * 100.0,
    );
}

// ─────────────────────────────────────────────────────────────────────────────────
// The SECOND half of the same defect: the deck's band is not just an awkward base,
// it is a CAGE. Anchoring `scale()` to the fit (`fit_zoom`) fixed the value the map
// reports *at the fit* — but a map is not used at the fit. The wheel, the pinch and
// the map's own keyboard zoom drive `MercatorCamera::zoom_to`/`zoom_by` DIRECTLY,
// bounded only by `[ZOOM_MIN, ZOOM_MAX]` = `[64, 80_000_000]` — a 1.25-million-fold
// range. They never pass through `Facet::set_scale`. So one second of wheel puts
// `scale()` back outside `[0.25, 4.0]`, and the very next toolbar click re-enacts
// the original bug in full: `FacetDeck::scale_active` computes `scale() * k`,
// saturates the clamp, and writes an absolute value unrelated to where the camera
// was.
//
// Note the LIMIT of the guard directly above. Given the `fit_zoom` fix, `scale()` is
// `camera.zoom / fit_zoom` and `fit()` sets `fit_zoom = camera.zoom`, so straight after
// a fit it is *exactly* 1.0 by construction. It therefore only ever bites on a
// regression of that anchoring itself (it does: restoring the old absolute
// `camera.zoom / 512.0` turns it red). It cannot say anything about the state a user
// reaches one wheel notch later, which is where the band actually fails. The tests
// below carry that intent forward onto navigated state.
//
// The measured vacuity of the ORIGINAL unit guard, for the record: with the old
// absolute `/ 512.0` knob restored, all 68 `facett-geomap` lib tests — including
// `osm2d::tests::set_scale_routes_through_update_and_scale_reads_back` — stay GREEN,
// while the pixel guards in this file go red. A state round-trip on a view that is
// never fitted and never rendered cannot see a projection bug.
// ─────────────────────────────────────────────────────────────────────────────────

/// A REAL wheel gesture over the middle of the map — `Event::PointerMoved` so the
/// canvas is hovered, then `Event::MouseWheel`, exactly as `robot_demo`'s driver
/// sends them. This is the path at `osm2d.rs:1429` (`cam.zoom_to` + `Msg::ZoomTo`),
/// the slippy-map standard, and the first thing anyone does to a map.
fn wheel_over_map(harness: &mut Harness<'static, FacetDeck>, rect: egui::Rect, dy: f32) {
    harness.input_mut().events.push(egui::Event::PointerMoved(rect.center()));
    harness.input_mut().events.push(egui::Event::MouseWheel {
        unit: egui::MouseWheelUnit::Point,
        delta: egui::vec2(0.0, dy),
        modifiers: egui::Modifiers::default(),
        phase: egui::TouchPhase::Move,
    });
    harness.run();
}

/// **RED-when-broken: after the user WHEELS IN, the toolbar's `+` still makes the map
/// bigger.** The residual scale bug, measured in pixels.
///
/// Wheel in a few notches (a normal thing to do to a map — it is how you read a
/// street), then click `+`. Before the fix the map SNAPS OUT by however far past 4×
/// the fit the wheel had taken it: `scale()` reports e.g. 6.9, the deck computes
/// `6.9 × 1.1 = 7.6`, clamps to `4.0`, and `set_scale(4.0)` drops the camera to
/// `4 × fit_zoom`. A zoom-IN click that zooms OUT — the exact symptom, still live
/// after the `fit_zoom` fix, just one wheel notch away instead of zero.
#[test]
fn zoom_in_after_a_wheel_gesture_still_grows_the_map() {
    let mut harness = fitted_deck_harness();
    let rect = content_rect(harness.state());

    // ~1.9× per event (WHEEL_ZOOM_RATE 0.0015, k clamped to 2.0) → well past 4× the fit.
    for _ in 0..3 {
        wheel_over_map(&mut harness, rect, 600.0);
    }

    let s = {
        let view: &mut OsmView = harness.state_mut().facet_mut(TAB).expect("the OSM 2D facet");
        <OsmView as Facet>::scale(view)
    };
    assert!(
        s > 4.0,
        "precondition: the wheel really took the camera past the deck's 4.0 ceiling \
         (scale() = {s}); if it did not, this test is not exercising the defect",
    );

    let before = on_screen_px_per_world_unit(harness.state_mut(), rect);
    harness.get_by_label("+").click();
    harness.run();
    let after = on_screen_px_per_world_unit(harness.state_mut(), rect);

    assert!(
        after > before,
        "after a WHEEL zoom-in, clicking ZOOM IN (+) must still make the map BIGGER, \
         but it went {:.0} px → {:.0} px per world unit (a {:.1}× COLLAPSE). The camera \
         was at {:.1}× the fit; FacetDeck::scale_active clamped {:.1} × 1.1 into \
         [0.25, 4.0] and slammed the camera to 4× the fit. The wheel drives \
         MercatorCamera::zoom_to directly over [ZOOM_MIN, ZOOM_MAX] and never passes \
         through set_scale, so the deck's band cannot cage it — the band has to come \
         from the facet.",
        before,
        after,
        before / after.max(f64::MIN_POSITIVE),
        s,
        s,
    );
    let ratio = after / before;
    assert!(
        (ratio - 1.1).abs() < 0.02,
        "a + click is a 1.1× zoom step wherever the camera happens to be, got \
         {ratio:.4}× ({before:.0} → {after:.0} px)",
    );
}

/// **RED-when-broken: after the user WHEELS OUT, the toolbar's `−` still makes the map
/// smaller.** The mirror image: wheeled out below 0.25× the fit, `−` clamps UP to 0.25
/// and the map jumps IN.
#[test]
fn zoom_out_after_a_wheel_gesture_still_shrinks_the_map() {
    let mut harness = fitted_deck_harness();
    let rect = content_rect(harness.state());

    for _ in 0..3 {
        wheel_over_map(&mut harness, rect, -600.0);
    }

    let s = {
        let view: &mut OsmView = harness.state_mut().facet_mut(TAB).expect("the OSM 2D facet");
        <OsmView as Facet>::scale(view)
    };
    assert!(
        s < 0.25,
        "precondition: the wheel really took the camera below the deck's 0.25 floor \
         (scale() = {s})",
    );

    let before = on_screen_px_per_world_unit(harness.state_mut(), rect);
    harness.get_by_label("−").click();
    harness.run();
    let after = on_screen_px_per_world_unit(harness.state_mut(), rect);

    assert!(
        after < before,
        "after a WHEEL zoom-out, clicking ZOOM OUT (−) must still make the map SMALLER, \
         but it went {:.1} px → {:.1} px per world unit (a {:.1}× JUMP IN). The camera \
         was at {:.4}× the fit and the deck's clamp floor of 0.25 yanked it back in.",
        before,
        after,
        after / before.max(f64::MIN_POSITIVE),
        s,
    );
}

/// **RED-when-broken: the toolbar can express the whole camera range the map itself
/// allows.** The structural statement that replaces the now-tautological
/// `a_fitted_maps_scale_stays_inside_the_decks_own_clamp_band`.
///
/// A map's zoom range is `[ZOOM_MIN, ZOOM_MAX]` — 1.25 million-fold. The deck's
/// `[0.25, 4.0]` is 16-fold. Whatever the deck's band is, driving `+` repeatedly must
/// keep GROWING the drawn map until the *camera's* own ceiling, not stall 2 clicks in
/// against a document-viewer constant. Asserted on applied pixels, not on `scale()`.
#[test]
fn repeated_zoom_in_keeps_growing_the_map_well_past_the_decks_document_band() {
    let mut harness = fitted_deck_harness();
    let rect = content_rect(harness.state());
    let fitted = on_screen_px_per_world_unit(harness.state_mut(), rect);

    // 20 clicks of 1.1× is 6.7× — comfortably past the 4.0 ceiling, nowhere near
    // ZOOM_MAX (80e6 ÷ a ~8e5 fit ≈ 96×).
    let mut prev = fitted;
    for i in 1..=20 {
        harness.get_by_label("+").click();
        harness.run();
        let now = on_screen_px_per_world_unit(harness.state_mut(), rect);
        assert!(
            now > prev,
            "click #{i} of ZOOM IN must still grow the map ({prev:.0} → {now:.0} px per \
             world unit). It stopped at {:.2}× the opening fit — the deck's [0.25, 4.0] \
             clamp, a document-viewer constant, capping a map whose own camera allows \
             {:.0}× more. The band has to come from the facet.",
            prev / fitted,
            80_000_000.0 / prev.max(f64::MIN_POSITIVE),
        );
        prev = now;
    }

    assert!(
        prev / fitted > 6.0,
        "20 × 1.1 = 6.7× the opening fit; got {:.2}×",
        prev / fitted,
    );
}