fix(engine): correct Android waste fan overlap and resume layout desync
Android Release / build-apk (push) Successful in 4m41s

Bug 1 (card_plugin): waste Draw-Three fan step was a fixed 0.28×card_width,
chosen for the desktop gap ratio (H_GAP_DIVISOR=4). On Android
(H_GAP_DIVISOR=32) the column spacing is only 1.031×card_width, so the same
fraction pushed the top fanned card's centre past the waste column's right
edge. Fix: derive fan_step from column spacing × 0.224 — preserves 0.28×cw
on desktop while reducing to ≈0.231×cw on Android, keeping fanned cards
within their column footprint. Adds regression test on 900×2000 portrait window.

Bug 2 (safe_area): refresh_insets stored its retry counter as Local<u32>,
making it impossible to re-arm after a background/foreground cycle. On resume
the counter was already saturated so JNI was never re-queried; layouts
computed with stale (zero) insets pushed the top card row up under the HUD.
Fix: convert tries to SafeAreaPollTries Resource; add android::rearm_on_resumed
which resets both counter and SafeAreaInsets on AppLifecycle::WillResume so
the poller re-fires; add on_app_resumed (all platforms) which emits a synthetic
WindowResized on WillResume to immediately trigger layout recomputation. Adds
pure-function regression test in layout.rs pinning the suspend→resume invariant.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-17 19:16:24 -07:00
parent 980312c22c
commit 04e99a8d24
3 changed files with 208 additions and 10 deletions
+68
View File
@@ -605,6 +605,74 @@ mod tests {
);
}
/// Suspend → resume layout-consistency invariant.
///
/// If the resume handler resets `SafeAreaInsets` to zero and then the JNI
/// poller re-resolves the same values, `compute_layout` must produce an
/// identical result to the fresh-launch layout. This test also verifies
/// that a layout computed with `safe_area_top = 0` (the brief window while
/// insets haven't re-resolved after resume) differs visibly from the
/// correct layout, confirming that the bug would manifest without the fix.
#[test]
fn suspend_resume_layout_matches_fresh_launch() {
let window = Vec2::new(900.0, 2000.0);
let safe_top = 27.0_f32;
let safe_bottom = 110.0_f32;
// Fresh-launch layout — insets known from startup.
let fresh = compute_layout(window, safe_top, safe_bottom, true);
// Layout computed during the brief post-resume window before insets
// re-resolve (safe_area_top temporarily 0).
let wrong = compute_layout(window, 0.0, safe_bottom, true);
// Verify the "wrong" layout actually differs — the bug would push the
// top card row upward by exactly safe_top pixels.
let fresh_stock_y = fresh.pile_positions[&PileType::Stock].y;
let wrong_stock_y = wrong.pile_positions[&PileType::Stock].y;
// In Bevy's +y-is-up system, adding safe_area_top pushes the stock
// downward (y direction). So wrong_stock_y > fresh_stock_y by safe_top.
assert!(
(wrong_stock_y - fresh_stock_y - safe_top).abs() < 1e-3,
"wrong layout must displace stock upward by safe_top ({safe_top}): \
fresh={fresh_stock_y:.2} wrong={wrong_stock_y:.2} delta={:.2}",
wrong_stock_y - fresh_stock_y,
);
// After the poller re-resolves correct insets the layout must be
// identical to the fresh-launch layout.
let corrected = compute_layout(window, safe_top, safe_bottom, true);
assert_eq!(
corrected.card_size, fresh.card_size,
"card size must be preserved after resume",
);
assert!(
(corrected.pile_positions[&PileType::Stock].y - fresh_stock_y).abs() < 1e-3,
"stock y must match fresh launch after resume: \
corrected={:.2} fresh={fresh_stock_y:.2}",
corrected.pile_positions[&PileType::Stock].y,
);
assert!(
(corrected.pile_positions[&PileType::Stock].x
- fresh.pile_positions[&PileType::Stock].x)
.abs()
< 1e-3,
"stock x must be unchanged after resume",
);
// The HUD band top clearance (distance from window top to card top)
// must match as well — this is the quantity directly visible in Bug 2.
let card_top = |layout: &super::Layout| {
layout.pile_positions[&PileType::Stock].y + layout.card_size.y / 2.0
};
assert!(
(card_top(&corrected) - card_top(&fresh)).abs() < 1e-3,
"top-of-card must match fresh launch after resume: \
corrected={:.2} fresh={:.2}",
card_top(&corrected),
card_top(&fresh),
);
}
/// safe_area_bottom must not affect horizontal positions.
#[test]
fn safe_area_bottom_does_not_affect_horizontal_layout() {