Sprint 0 — industrialisation : CI, tracing, tests xdg-shell, GPLv3

5 livrables d'industrialisation posés avant la phase 13 (client réel).

- .gitlab-ci.yml : pipeline minimal (fmt-check + tests core +
  tests frontend nightly). Pas de cross-compile Redox dans le MVP.
- rustfmt.toml + fmt.sh : config formatter racine + boucle sur les 23
  crates ; fmt sweep appliqué (d'où les diffs cosmétiques).
- tracing dans wayland-frontend : 22 println/eprintln remplacés par
  debug/info/warn/error selon sévérité. Préfixe "[frontend]"
  supprimé (le target tracing le fournit).
- tracing-subscriber dans le compositor : TeeWriter écrit sur
  stdout + /scheme/debug, filtre via RUST_LOG (défaut info).
  DebugSink/dlog supprimés.
- 15 tests unitaires xdg-shell après extraction de 2 helpers libres
  (clamp_to_min_max + should_throttle_configure). Couvre
  compute_resize_geom, contraintes min/max et throttling configure.
- LICENSE (GPLv3 texte officiel FSF) + .editorconfig.

Total tests : 27 → 42 automatisés (compositor-core + frontend).

Leyoda 2026 – GPLv3
This commit is contained in:
Votre Nom 2026-05-14 20:46:07 +02:00
parent 2150f31d82
commit 8795f39f08
30 changed files with 1373 additions and 298 deletions

View file

@ -27,17 +27,19 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tracing::{debug, error, info, warn};
use redox_wl_compositor_core::{Framebuffer, SurfaceBuffer, SurfaceId, SurfaceRegistry};
use wayland_protocols::xdg::shell::server::{
xdg_popup, xdg_positioner, xdg_surface, xdg_toplevel, xdg_wm_base,
};
use wayland_server::{
Client, DataInit, Display as WlDisplay, DisplayHandle, GlobalDispatch, Resource,
backend::{ClientData, ClientId, DisconnectReason},
protocol::{
wl_buffer, wl_callback, wl_compositor, wl_keyboard, wl_pointer, wl_region, wl_seat,
wl_shm, wl_shm_pool, wl_surface,
wl_buffer, wl_callback, wl_compositor, wl_keyboard, wl_pointer, wl_region, wl_seat, wl_shm,
wl_shm_pool, wl_surface,
},
Client, DataInit, Display as WlDisplay, DisplayHandle, GlobalDispatch, Resource,
};
use redox_wl_input::InputEvent as RedoxInputEvent;
@ -259,6 +261,40 @@ fn compute_resize_geom(
(new_x, new_y, new_w, new_h)
}
/// Phase 7.9 : applique les contraintes `set_min_size` / `set_max_size`.
/// Convention spec wayland-protocols : `(0, 0)` = pas de contrainte
/// active (axe par axe). Une dimension `min > 0` impose `w >= min`,
/// une dimension `max > 0` impose `w <= max`.
fn clamp_to_min_max(w: u32, h: u32, min: (i32, i32), max: (i32, i32)) -> (u32, u32) {
let mut w = w;
let mut h = h;
if min.0 > 0 {
w = w.max(min.0 as u32);
}
if min.1 > 0 {
h = h.max(min.1 as u32);
}
if max.0 > 0 {
w = w.min(max.0 as u32);
}
if max.1 > 0 {
h = h.min(max.1 as u32);
}
(w, h)
}
/// Phase 7.9 : décide si un `xdg_toplevel.configure` doit être envoyé
/// pendant un drag de resize. Skip si la taille n'a pas bougé OU si
/// le dernier configure date de moins de `min_interval` (~60 fps).
fn should_throttle_configure(
new_size: (u32, u32),
last_size: (u32, u32),
elapsed: Duration,
min_interval: Duration,
) -> bool {
new_size == last_size || elapsed < min_interval
}
/// Phase 7.7 : état d'un drag interactif en cours. Posé par le handler
/// `xdg_toplevel.Move`/`Resize`, lu par `forward_input` pour appliquer
/// les deltas, retiré au release du bouton gauche.
@ -432,10 +468,7 @@ impl WaylandFrontend {
let cd = DumbClientData {
dead_clients: Arc::clone(&self.dead_clients),
};
let _ = self
.display
.handle()
.insert_client(stream, Arc::new(cd));
let _ = self.display.handle().insert_client(stream, Arc::new(cd));
}
Ok(None) => break, // pas de client en attente
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
@ -497,8 +530,8 @@ impl WaylandFrontend {
// déjà déconnecté.
self.pointers.retain(|p| p.client().is_some());
self.keyboards.retain(|k| k.client().is_some());
println!(
"[frontend] garbage_collect: client {cid:?} → destroyed {} surfaces",
info!(
"garbage_collect: client {cid:?} → destroyed {} surfaces",
to_destroy.len()
);
}
@ -571,19 +604,12 @@ impl WaylandFrontend {
.as_ref()
.and_then(|s| s.data::<Arc<SurfaceData>>())
.and_then(|d| *d.id.lock().unwrap());
println!(
"[frontend] focus change: {old_sid:?} → {new_sid:?}"
);
info!("focus change: {old_sid:?} → {new_sid:?}");
// Phase 7.9 : envoyer configure(w, h, [Activated]) au nouveau
// focused toplevel et configure(w, h, []) à l'ancien, pour que
// les toolkits puissent styler le focus (titlebar active, etc.).
let empty_state: Vec<u8> = vec![];
let activated_state: Vec<u8> = vec![
(xdg_toplevel::State::Activated as u32) as u8,
0,
0,
0,
];
let activated_state: Vec<u8> = vec![(xdg_toplevel::State::Activated as u32) as u8, 0, 0, 0];
if let Some(sid) = old_sid {
if let Some(tl) = self.toplevels_by_id.get(&sid).cloned() {
self.send_focus_configure(&tl, sid, empty_state);
@ -616,12 +642,7 @@ impl WaylandFrontend {
// surface_x, surface_y : on prend la position relative
// simple basée sur cursor - surface origin
let (sx, sy) = self.surface_local_cursor(new);
ptr.enter(
serial_enter,
new,
fixed_from_int(sx),
fixed_from_int(sy),
);
ptr.enter(serial_enter, new, fixed_from_int(sx), fixed_from_int(sy));
if SEAT_VERSION >= 5 {
ptr.frame();
}
@ -658,7 +679,12 @@ impl WaylandFrontend {
let (w, h) = self
.registry
.get(sid)
.and_then(|s| s.current().buffer.as_ref().map(|b| (b.width as i32, b.height as i32)))
.and_then(|s| {
s.current()
.buffer
.as_ref()
.map(|b| (b.width as i32, b.height as i32))
})
.unwrap_or(DEFAULT_TOPLEVEL_SIZE);
toplevel.configure(w, h, states);
if let Some(td) = toplevel.data::<Arc<XdgToplevelData>>() {
@ -709,22 +735,12 @@ impl WaylandFrontend {
);
// Phase 7.9 : clamp aux contraintes min/max envoyées
// par le client via xdg_toplevel.set_min/max_size.
// (0, 0) = pas de contrainte.
if let Some(td) = drag.xdg_toplevel_res.data::<Arc<XdgToplevelData>>() {
let (min_w, min_h) = *td.min_size.lock().unwrap();
let (max_w, max_h) = *td.max_size.lock().unwrap();
if min_w > 0 {
new_w = new_w.max(min_w as u32);
}
if min_h > 0 {
new_h = new_h.max(min_h as u32);
}
if max_w > 0 {
new_w = new_w.min(max_w as u32);
}
if max_h > 0 {
new_h = new_h.min(max_h as u32);
}
let min = *td.min_size.lock().unwrap();
let max = *td.max_size.lock().unwrap();
let clamped = clamp_to_min_max(new_w, new_h, min, max);
new_w = clamped.0;
new_h = clamped.1;
}
// Mettre à jour la position côté compositor immédiatement
// (la taille effective dépendra du prochain commit du client).
@ -738,22 +754,20 @@ impl WaylandFrontend {
// (re)allocations côté client : skip si la taille n'a
// pas changé OU si moins de 16ms depuis le dernier
// configure (~60fps max).
let same_size = (new_w, new_h) == drag.last_configure_size;
let too_soon =
drag.last_configure_at.elapsed() < Duration::from_millis(16);
if same_size || too_soon {
if should_throttle_configure(
(new_w, new_h),
drag.last_configure_size,
drag.last_configure_at.elapsed(),
Duration::from_millis(16),
) {
return true;
}
// Annoncer la nouvelle taille au client. Le state
// `Resizing` (3) indique au toolkit qu'il est en train
// d'être redimensionné.
let resizing_state: Vec<u8> = vec![
(xdg_toplevel::State::Resizing as u32) as u8,
0,
0,
0,
];
let resizing_state: Vec<u8> =
vec![(xdg_toplevel::State::Resizing as u32) as u8, 0, 0, 0];
drag.xdg_toplevel_res
.configure(new_w as i32, new_h as i32, resizing_state);
let serial = self.next_xdg_serial;
@ -876,14 +890,14 @@ impl WaylandFrontend {
// Phase 7.7 : si drag actif et le bouton gauche est relâché,
// sortir du mode drag avant l'envoi du button au client.
if !*left && self.interactive_drag.is_some() {
println!("[frontend] left-release → exit interactive drag");
info!("left-release → exit interactive drag");
self.interactive_drag = None;
}
if *left {
let hit = self.registry.hit_test(self.cursor_x, self.cursor_y);
println!(
"[frontend] left-click @ ({}, {}) → hit_test = {:?}",
debug!(
"left-click @ ({}, {}) → hit_test = {:?}",
self.cursor_x, self.cursor_y, hit
);
if let Some(sid) = hit {
@ -959,11 +973,7 @@ impl WaylandFrontend {
continue;
}
if *dy != 0 {
ptr.axis(
time,
wl_pointer::Axis::VerticalScroll,
fixed_from_int(*dy),
);
ptr.axis(time, wl_pointer::Axis::VerticalScroll, fixed_from_int(*dy));
}
if *dx != 0 {
ptr.axis(
@ -1271,7 +1281,7 @@ impl wayland_server::Dispatch<wl_shm::WlShm, ()> for WaylandFrontend {
data_init.init(id, Arc::new(Mutex::new(p)));
}
Err(e) => {
eprintln!("[frontend] shm CreatePool mmap failed: {e}");
error!("shm CreatePool mmap failed: {e}");
// En cas d'échec on ne peut pas init le pool. Le client va
// probablement avoir un fd resource leaked, mais c'est
// moins grave qu'un crash. Implementation simple pour 6.4.
@ -1321,7 +1331,8 @@ impl wayland_server::Dispatch<wl_shm_pool::WlShmPool, Arc<Mutex<ShmPool>>> for W
if valid {
let pool_size = pool.lock().unwrap().size as i64;
let needed = (offset as i64).saturating_add(
(stride as i64).saturating_mul((height as i64).saturating_sub(1))
(stride as i64)
.saturating_mul((height as i64).saturating_sub(1))
.saturating_add((width as i64).saturating_mul(4)),
);
if needed > pool_size {
@ -1329,8 +1340,8 @@ impl wayland_server::Dispatch<wl_shm_pool::WlShmPool, Arc<Mutex<ShmPool>>> for W
}
}
if !valid {
println!(
"[frontend] wl_shm_pool.create_buffer rejected: \
warn!(
"wl_shm_pool.create_buffer rejected: \
offset={offset} width={width} height={height} stride={stride}"
);
}
@ -1431,17 +1442,11 @@ impl wayland_server::Dispatch<wl_surface::WlSurface, Arc<SurfaceData>> for Wayla
} else {
let pool = bd.pool.lock().unwrap();
let pixels_opt = unsafe {
pool.read_argb(
bd.offset as usize,
bd.width,
bd.height,
bd.stride,
)
pool.read_argb(bd.offset as usize, bd.width, bd.height, bd.stride)
};
drop(pool);
if let Some(pixels) = pixels_opt {
let sb =
SurfaceBuffer::from_pixels(bd.width, bd.height, pixels);
let sb = SurfaceBuffer::from_pixels(bd.width, bd.height, pixels);
// Pour une surface curseur, on stocke le buffer
// mais visible=false (cf draw_cursor).
state.registry.modify_pending(id, |s| {
@ -1449,9 +1454,7 @@ impl wayland_server::Dispatch<wl_surface::WlSurface, Arc<SurfaceData>> for Wayla
s.visible = !is_cursor;
});
} else {
println!(
"[frontend] commit: read_argb refused buffer (overrun guard)"
);
warn!("commit: read_argb refused buffer (overrun guard)");
}
}
}
@ -1683,8 +1686,8 @@ impl wayland_server::Dispatch<xdg_surface::XdgSurface, Arc<XdgSurfaceData>> for
// abus délibérés.
let last = *data.last_serial.lock().unwrap();
if serial > last {
println!(
"[frontend] xdg_surface.ack_configure: serial {serial} > last_sent {last}, ignoring"
warn!(
"xdg_surface.ack_configure: serial {serial} > last_sent {last}, ignoring"
);
return;
}
@ -1901,9 +1904,7 @@ fn send_keymap_no_keymap(kb: &wl_keyboard::WlKeyboard) {
use std::os::fd::AsFd;
// ---- xdg_toplevel ----
impl wayland_server::Dispatch<xdg_toplevel::XdgToplevel, Arc<XdgToplevelData>>
for WaylandFrontend
{
impl wayland_server::Dispatch<xdg_toplevel::XdgToplevel, Arc<XdgToplevelData>> for WaylandFrontend {
fn request(
state: &mut Self,
_client: &Client,
@ -1922,19 +1923,16 @@ impl wayland_server::Dispatch<xdg_toplevel::XdgToplevel, Arc<XdgToplevelData>>
}
xdg_toplevel::Request::SetMinSize { width, height } => {
*data.min_size.lock().unwrap() = (width, height);
println!("[frontend] xdg_toplevel.set_min_size({width}, {height})");
debug!("xdg_toplevel.set_min_size({width}, {height})");
}
xdg_toplevel::Request::SetMaxSize { width, height } => {
*data.max_size.lock().unwrap() = (width, height);
println!("[frontend] xdg_toplevel.set_max_size({width}, {height})");
debug!("xdg_toplevel.set_max_size({width}, {height})");
}
xdg_toplevel::Request::Move {
seat: _,
serial,
} => {
xdg_toplevel::Request::Move { seat: _, serial } => {
// Phase 7.7/7.8 : entrer en mode drag-move.
if serial == 0 {
println!("[frontend] xdg_toplevel.move: serial=0 refused");
warn!("xdg_toplevel.move: serial=0 refused");
return;
}
// Phase 7.8 : validation soft du serial — log warning si
@ -1942,17 +1940,17 @@ impl wayland_server::Dispatch<xdg_toplevel::XdgToplevel, Arc<XdgToplevelData>>
// tous les clients utiliseront un vrai serial capté
// depuis pointer.button).
if state.last_button_serial != 0 && serial != state.last_button_serial {
println!(
"[frontend] xdg_toplevel.move: serial {serial} ≠ last_button {} (accepting)",
debug!(
"xdg_toplevel.move: serial {serial} ≠ last_button {} (accepting)",
state.last_button_serial
);
}
if state.interactive_drag.is_some() {
println!("[frontend] xdg_toplevel.move: drag already in progress");
warn!("xdg_toplevel.move: drag already in progress");
return;
}
let Some(xdg_surf) = data.xdg_surface.lock().unwrap().clone() else {
println!("[frontend] xdg_toplevel.move: no parent xdg_surface");
warn!("xdg_toplevel.move: no parent xdg_surface");
return;
};
let Some(xdg_data) = xdg_surf.data::<Arc<XdgSurfaceData>>() else {
@ -1966,8 +1964,8 @@ impl wayland_server::Dispatch<xdg_toplevel::XdgToplevel, Arc<XdgToplevelData>>
let caller_cid = resource.client().map(|c| c.id());
let owner_cid = surf_data.client_id.lock().unwrap().clone();
if caller_cid != owner_cid {
println!(
"[frontend] xdg_toplevel.move: caller={caller_cid:?} ≠ owner={owner_cid:?} → REJECTED"
warn!(
"xdg_toplevel.move: caller={caller_cid:?} ≠ owner={owner_cid:?} → REJECTED"
);
return;
}
@ -1997,8 +1995,8 @@ impl wayland_server::Dispatch<xdg_toplevel::XdgToplevel, Arc<XdgToplevelData>>
last_configure_size: (start_w, start_h),
last_configure_at: Instant::now(),
};
println!(
"[frontend] xdg_toplevel.move: enter drag sid={:?} start=({},{}) cursor=({},{})",
info!(
"xdg_toplevel.move: enter drag sid={:?} start=({},{}) cursor=({},{})",
sid, st.x, st.y, state.cursor_x, state.cursor_y
);
state.interactive_drag = Some(drag);
@ -2014,21 +2012,21 @@ impl wayland_server::Dispatch<xdg_toplevel::XdgToplevel, Arc<XdgToplevelData>>
Err(_) => 0,
};
if serial == 0 {
println!("[frontend] xdg_toplevel.resize: serial=0 refused");
warn!("xdg_toplevel.resize: serial=0 refused");
return;
}
if edges_raw == 0 {
println!("[frontend] xdg_toplevel.resize: edges=None, ignoring");
warn!("xdg_toplevel.resize: edges=None, ignoring");
return;
}
if state.last_button_serial != 0 && serial != state.last_button_serial {
println!(
"[frontend] xdg_toplevel.resize: serial {serial} ≠ last_button {} (accepting, durcir 7.9)",
debug!(
"xdg_toplevel.resize: serial {serial} ≠ last_button {} (accepting, durcir 7.9)",
state.last_button_serial
);
}
if state.interactive_drag.is_some() {
println!("[frontend] xdg_toplevel.resize: drag already in progress");
warn!("xdg_toplevel.resize: drag already in progress");
return;
}
let Some(xdg_surf) = data.xdg_surface.lock().unwrap().clone() else {
@ -2045,8 +2043,8 @@ impl wayland_server::Dispatch<xdg_toplevel::XdgToplevel, Arc<XdgToplevelData>>
let caller_cid = resource.client().map(|c| c.id());
let owner_cid = surf_data.client_id.lock().unwrap().clone();
if caller_cid != owner_cid {
println!(
"[frontend] xdg_toplevel.resize: caller={caller_cid:?} ≠ owner={owner_cid:?} → REJECTED"
warn!(
"xdg_toplevel.resize: caller={caller_cid:?} ≠ owner={owner_cid:?} → REJECTED"
);
return;
}
@ -2057,11 +2055,11 @@ impl wayland_server::Dispatch<xdg_toplevel::XdgToplevel, Arc<XdgToplevelData>>
return;
};
let st = s.current();
let (start_w, start_h) = st
.buffer
.as_ref()
.map(|b| (b.width, b.height))
.unwrap_or((DEFAULT_TOPLEVEL_SIZE.0 as u32, DEFAULT_TOPLEVEL_SIZE.1 as u32));
let (start_w, start_h) =
st.buffer.as_ref().map(|b| (b.width, b.height)).unwrap_or((
DEFAULT_TOPLEVEL_SIZE.0 as u32,
DEFAULT_TOPLEVEL_SIZE.1 as u32,
));
let drag = InteractiveDrag {
surface_id: sid,
xdg_toplevel_res: resource.clone(),
@ -2076,8 +2074,8 @@ impl wayland_server::Dispatch<xdg_toplevel::XdgToplevel, Arc<XdgToplevelData>>
last_configure_size: (start_w, start_h),
last_configure_at: Instant::now(),
};
println!(
"[frontend] xdg_toplevel.resize: enter drag sid={sid:?} edges={edges_raw} start_geom=({},{},{},{}) cursor=({},{})",
info!(
"xdg_toplevel.resize: enter drag sid={sid:?} edges={edges_raw} start_geom=({},{},{},{}) cursor=({},{})",
st.x, st.y, start_w, start_h, state.cursor_x, state.cursor_y
);
state.interactive_drag = Some(drag);
@ -2098,3 +2096,151 @@ impl wayland_server::Dispatch<xdg_toplevel::XdgToplevel, Arc<XdgToplevelData>>
}
}
}
// ---------------------------------------------------------------------------
// Tests unitaires xdg-shell (sprint 0 point 4).
//
// Couverture : la logique pure extraite des handlers Dispatch — c.-à-d.
// `compute_resize_geom`, `clamp_to_min_max`, `should_throttle_configure`.
// Pas de Wayland-server runtime ici : ces fns sont libres et déterministes,
// donc isolables. Les tests "protocole" (ack_configure serial, set_focus
// configure) nécessitent un vrai client + Display et sont reportés à une
// future phase d'intégration.
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
// ---- compute_resize_geom : bord Right (8) ----
#[test]
fn resize_right_grows_width_keeps_origin() {
let (x, y, w, h) = compute_resize_geom(8, 100, 200, 300, 150, 50, 0);
assert_eq!((x, y), (100, 200));
assert_eq!((w, h), (350, 150));
}
// ---- compute_resize_geom : bord Left (4) ----
#[test]
fn resize_left_moves_origin_and_shrinks_on_positive_dx() {
let (x, y, w, h) = compute_resize_geom(4, 100, 200, 300, 150, 40, 0);
assert_eq!((x, y), (140, 200));
assert_eq!((w, h), (260, 150));
}
// ---- compute_resize_geom : bord Bottom (2) ----
#[test]
fn resize_bottom_grows_height_keeps_origin() {
let (x, y, w, h) = compute_resize_geom(2, 100, 200, 300, 150, 0, 70);
assert_eq!((x, y), (100, 200));
assert_eq!((w, h), (300, 220));
}
// ---- compute_resize_geom : bord Top (1) ----
#[test]
fn resize_top_moves_origin_and_shrinks_on_positive_dy() {
let (x, y, w, h) = compute_resize_geom(1, 100, 200, 300, 150, 0, 60);
assert_eq!((x, y), (100, 260));
assert_eq!((w, h), (300, 90));
}
// ---- compute_resize_geom : coin BottomRight (Bottom|Right = 10) ----
#[test]
fn resize_bottom_right_corner_grows_both_axes() {
let (x, y, w, h) = compute_resize_geom(10, 100, 200, 300, 150, 25, 35);
assert_eq!((x, y), (100, 200));
assert_eq!((w, h), (325, 185));
}
// ---- compute_resize_geom : delta trop négatif clamp à MIN_RESIZE_DIM ----
#[test]
fn resize_clamps_to_min_resize_dim_on_extreme_shrink() {
let (_x, _y, w, h) = compute_resize_geom(10, 0, 0, 300, 150, -10_000, -10_000);
assert_eq!((w, h), (MIN_RESIZE_DIM, MIN_RESIZE_DIM));
}
// ---- compute_resize_geom : edges=0 (aucun bord) → géom inchangée ----
#[test]
fn resize_with_no_edges_returns_starting_geometry() {
let (x, y, w, h) = compute_resize_geom(0, 50, 60, 200, 100, 999, 999);
assert_eq!((x, y, w, h), (50, 60, 200, 100));
}
// ---- clamp_to_min_max : (0, 0) = pas de contrainte ----
#[test]
fn clamp_with_zero_constraints_is_identity() {
let (w, h) = clamp_to_min_max(320, 200, (0, 0), (0, 0));
assert_eq!((w, h), (320, 200));
}
// ---- clamp_to_min_max : min seul force le plancher ----
#[test]
fn clamp_applies_min_floor() {
let (w, h) = clamp_to_min_max(50, 30, (150, 80), (0, 0));
assert_eq!((w, h), (150, 80));
}
// ---- clamp_to_min_max : max seul force le plafond ----
#[test]
fn clamp_applies_max_ceiling() {
let (w, h) = clamp_to_min_max(900, 500, (0, 0), (600, 400));
assert_eq!((w, h), (600, 400));
}
// ---- clamp_to_min_max : min + max coexistent ----
#[test]
fn clamp_applies_both_min_and_max() {
// size dans la fenêtre → identique
let (w, h) = clamp_to_min_max(320, 200, (150, 80), (600, 400));
assert_eq!((w, h), (320, 200));
// size sous le plancher → remontée
let (w, h) = clamp_to_min_max(10, 10, (150, 80), (600, 400));
assert_eq!((w, h), (150, 80));
// size au-dessus du plafond → descente
let (w, h) = clamp_to_min_max(9999, 9999, (150, 80), (600, 400));
assert_eq!((w, h), (600, 400));
}
// ---- clamp_to_min_max : axes indépendants ----
#[test]
fn clamp_constraints_are_independent_per_axis() {
// min sur width seulement
let (w, h) = clamp_to_min_max(10, 200, (150, 0), (0, 0));
assert_eq!((w, h), (150, 200));
// max sur height seulement
let (w, h) = clamp_to_min_max(300, 9999, (0, 0), (0, 400));
assert_eq!((w, h), (300, 400));
}
// ---- should_throttle_configure : même taille → skip ----
#[test]
fn throttle_skips_when_size_unchanged() {
assert!(should_throttle_configure(
(320, 200),
(320, 200),
Duration::from_secs(10),
Duration::from_millis(16),
));
}
// ---- should_throttle_configure : trop tôt → skip ----
#[test]
fn throttle_skips_when_elapsed_below_min_interval() {
assert!(should_throttle_configure(
(321, 200),
(320, 200),
Duration::from_millis(8),
Duration::from_millis(16),
));
}
// ---- should_throttle_configure : taille différente + délai OK → envoie ----
#[test]
fn throttle_allows_configure_when_size_changed_and_interval_elapsed() {
assert!(!should_throttle_configure(
(321, 200),
(320, 200),
Duration::from_millis(20),
Duration::from_millis(16),
));
}
}