tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 229TOML 101Markdown 82Shell 24052 Definiciones

⎇ main llimphi-hybrid/src/pruebas.rs Rust · 711 líneas · 26.0 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · Sergio ·

1// =============================================================================
2//  pruebas :: que los píxeles salgan, y contarlos como texto
3// -----------------------------------------------------------------------------
4//  La Regla 8 del repo dice que renderizar a PNG y MIRARLO es el último recurso,
5//  no el primero, porque cuesta tokens. Estas pruebas son la forma cara-cero de
6//  la misma evidencia: se rinde de verdad contra la GPU, se lee el framebuffer
7//  de vuelta y se cuentan los píxeles POR COLOR. Un histograma es una aserción;
8//  una captura es una opinión.
9//
10//  Todas necesitan una GPU real. Si no hay adaptador —CI headless sin
11//  Vulkan/GL— se saltean con un aviso en vez de fallar: un test rojo por falta
12//  de hardware enseña a ignorar los rojos, que es peor que no tener el test.
13// =============================================================================
14
15use std::collections::HashMap;
16
17use llimphi_cpu::kurbo::{Affine, Rect, RoundedRect};
18use llimphi_cpu::peniko::color::{AlphaColor, Srgb};
19use llimphi_cpu::peniko::Fill;
20use llimphi_cpu::Escena;
21
22use crate::Pintor;
23
24const ANCHO: u32 = 256;
25const ALTO: u32 = 256;
26
27fn color(r: u8, g: u8, b: u8) -> AlphaColor<Srgb> {
28    AlphaColor::<Srgb>::from_rgba8(r, g, b, 255)
29}
30
31/// El stack GPU mínimo para rendir a una textura y leerla de vuelta.
32///
33/// Devuelve `None` si la máquina no ofrece adaptador — ver el encabezado.
34struct Banco {
35    device: wgpu::Device,
36    queue: wgpu::Queue,
37    /// El backend elegido: el `Pintor` lo necesita para configurar el atlas —
38    /// ver `ajustes_para` en `pintor.rs`.
39    backend: wgpu::Backend,
40}
41
42impl Banco {
43    fn nuevo() -> Option<Self> {
44        let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
45        // `WGPU_BACKEND=gl` para reproducir el camino de Android desde la
46        // laptop. No es un lujo: el defecto de bordes que persigue
47        // `el_relleno_a_sangre_cubre_los_bordes_laterales` **no aparece** por
48        // Vulkan y el teléfono va por GLES, así que sin esta perilla el test
49        // corre siempre por el backend que no tiene el problema.
50        if let Some(b) = wgpu::Backends::from_env() {
51            desc.backends = b;
52        }
53        let instancia = wgpu::Instance::new(desc);
54        let adapter =
55            pollster::block_on(instancia.request_adapter(&wgpu::RequestAdapterOptions::default()))
56                .ok()?;
57        // Se anuncia el adaptador elegido: un `WGPU_BACKEND` mal escrito se
58        // ignora en silencio, y entonces «probé por GLES» sería mentira.
59        let info = adapter.get_info();
60        eprintln!(
61            "banco sobre {:?} · {} · {}",
62            info.backend, info.name, info.driver_info
63        );
64        let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
65            label: Some("llimphi-hybrid-pruebas"),
66            ..Default::default()
67        }))
68        .ok()?;
69        // Sin esto wgpu se traga los errores de validación y una prueba que
70        // falla no dice por qué: el síntoma llega como píxeles que no están.
71        device.on_uncaptured_error(std::sync::Arc::new(|e| eprintln!("WGPU ERROR: {e}")));
72        eprintln!(
73            "  límites: max_texture_array_layers={} max_texture_dimension_2d={}",
74            adapter.limits().max_texture_array_layers,
75            adapter.limits().max_texture_dimension_2d
76        );
77        Some(Self {
78            device,
79            queue,
80            backend: info.backend,
81        })
82    }
83
84    /// Rinde la escena y devuelve el framebuffer en RGBA8, fila por fila sin
85    /// relleno (el padding a 256 bytes que exige `copy_texture_to_buffer` se
86    /// recorta acá para que el llamador cuente píxeles y no bytes de alineación).
87    fn rendir(&self, escena: &Escena) -> Vec<u8> {
88        self.rendir_en(escena, ANCHO, ALTO)
89    }
90
91    /// Igual que `rendir`, pero a la medida que se le pida.
92    ///
93    /// Existe porque el tamaño **importa**: 256 es múltiplo de 16 y de 64, así
94    /// que el caso cómodo esconde los dos problemas de borde que hay acá —el
95    /// de vello_hybrid, que se mide en `el_relleno_a_sangre_cubre_los_bordes`,
96    /// y el de la alineación a 256 B que exige `copy_texture_to_buffer`.
97    fn rendir_en(&self, escena: &Escena, ancho: u32, alto: u32) -> Vec<u8> {
98        // Negro OPACO como base: así un píxel negro significa «nadie pintó
99        // acá», que es lo que afirman casi todas las pruebas de este archivo.
100        self.rendir_con_base(escena, ancho, alto, color(0, 0, 0))
101    }
102
103    /// Igual, pero eligiendo el `color_base` que recibe `Pintor::pintar`.
104    ///
105    /// Lo necesita `el_color_base_pinta_donde_la_escena_no_llega`, que tiene que
106    /// poder distinguir el color del clear del color que pinta la escena — con
107    /// los dos iguales, un clear que no hace nada se ve idéntico a uno que sí.
108    fn rendir_con_base(&self, escena: &Escena, ancho: u32, alto: u32, base: AlphaColor<Srgb>) -> Vec<u8> {
109        let formato = wgpu::TextureFormat::Rgba8Unorm;
110        let textura = self.device.create_texture(&wgpu::TextureDescriptor {
111            label: Some("destino"),
112            size: wgpu::Extent3d {
113                width: ancho,
114                height: alto,
115                depth_or_array_layers: 1,
116            },
117            mip_level_count: 1,
118            sample_count: 1,
119            dimension: wgpu::TextureDimension::D2,
120            format: formato,
121            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
122            view_formats: &[],
123        });
124        let vista = textura.create_view(&wgpu::TextureViewDescriptor::default());
125
126        // `copy_texture_to_buffer` exige que `bytes_per_row` sea múltiplo de
127        // 256, y un ancho cualquiera no lo cumple (1080 * 4 = 4320, que no lo
128        // es). Se redondea para arriba y el relleno se recorta al leer, para
129        // que quien cuente píxeles no tenga que saber nada de alineación.
130        let bytes_utiles = ancho * 4;
131        let bytes_por_fila = bytes_utiles.div_ceil(256) * 256;
132
133        let lectura = self.device.create_buffer(&wgpu::BufferDescriptor {
134            label: Some("lectura"),
135            size: u64::from(bytes_por_fila * alto),
136            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
137            mapped_at_creation: false,
138        });
139
140        let mut pintor = Pintor::new(&self.device, self.backend, formato, ancho, alto);
141        let mut encoder = self
142            .device
143            .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
144        pintor
145            .pintar(
146                escena,
147                &self.device,
148                &self.queue,
149                &mut encoder,
150                &vista,
151                base,
152            )
153            .expect("vello_hybrid no debe fallar al rendir");
154        encoder.copy_texture_to_buffer(
155            textura.as_image_copy(),
156            wgpu::TexelCopyBufferInfo {
157                buffer: &lectura,
158                layout: wgpu::TexelCopyBufferLayout {
159                    offset: 0,
160                    bytes_per_row: Some(bytes_por_fila),
161                    rows_per_image: Some(alto),
162                },
163            },
164            wgpu::Extent3d {
165                width: ancho,
166                height: alto,
167                depth_or_array_layers: 1,
168            },
169        );
170        self.queue.submit(std::iter::once(encoder.finish()));
171
172        let rebanada = lectura.slice(..);
173        rebanada.map_async(wgpu::MapMode::Read, |_| {});
174        // Sin `submission_index` espera a que se vacíe TODO lo encolado, que es
175        // justo lo que hace falta acá; sin `timeout`, indefinidamente. Un test
176        // que se cuelga se diagnostica; uno que lee un buffer a medio copiar da
177        // un histograma casi correcto, que es mucho peor.
178        self.device
179            .poll(wgpu::PollType::Wait {
180                submission_index: None,
181                timeout: None,
182            })
183            .expect("la GPU debe terminar la copia");
184        // `to_vec` copia y el préstamo muere en el mismo statement, así que
185        // `unmap` no tiene con qué chocar.
186        let datos = rebanada.get_mapped_range().to_vec();
187        lectura.unmap();
188
189        // Recortar el relleno de alineación: el llamador recibe filas contiguas
190        // de `ancho * 4` bytes, como si wgpu no tuviera esa exigencia.
191        if bytes_por_fila == bytes_utiles {
192            return datos;
193        }
194        let mut apretado = Vec::with_capacity((bytes_utiles * alto) as usize);
195        for fila in datos
196            .chunks_exact(bytes_por_fila as usize)
197            .take(alto as usize)
198        {
199            apretado.extend_from_slice(&fila[..bytes_utiles as usize]);
200        }
201        apretado
202    }
203}
204
205/// Cuántos píxeles hay de cada color RGB (se ignora el alfa, que en estas
206/// escenas es siempre opaco).
207fn histograma(pixeles: &[u8]) -> HashMap<(u8, u8, u8), usize> {
208    let mut h = HashMap::new();
209    for p in pixeles.chunks_exact(4) {
210        *h.entry((p[0], p[1], p[2])).or_insert(0) += 1;
211    }
212    h
213}
214
215/// El caso base: tres rectángulos de colores planos sobre un fondo.
216///
217/// Es a propósito la misma escena que el probe que corrió en el Mali a 119 fps,
218/// para que el número de acá y el del teléfono se puedan comparar de frente.
219#[test]
220fn rellenos_planos_llegan_a_los_pixeles() {
221    let Some(banco) = Banco::nuevo() else {
222        eprintln!("sin adaptador wgpu: se saltea");
223        return;
224    };
225
226    let fondo = color(60, 61, 63);
227    let (a, b) = (color(200, 60, 90), color(60, 170, 120));
228
229    let mut escena = Escena::new();
230    let todo = Rect::new(0.0, 0.0, f64::from(ANCHO), f64::from(ALTO));
231    escena.fill(Fill::NonZero, Affine::IDENTITY, fondo, None, &todo);
232    // Coordenadas enteras: sin bordes fraccionarios no hay antialiasing, y el
233    // histograma da cuentas exactas en vez de una nube de tonos intermedios.
234    escena.fill(
235        Fill::NonZero,
236        Affine::IDENTITY,
237        a,
238        None,
239        &Rect::new(32.0, 32.0, 224.0, 96.0),
240    );
241    escena.fill(
242        Fill::NonZero,
243        Affine::IDENTITY,
244        b,
245        None,
246        &Rect::new(32.0, 128.0, 224.0, 192.0),
247    );
248
249    let h = histograma(&banco.rendir(&escena));
250    // 192 x 64 = 12.288 px cada barra, exactos.
251    assert_eq!(h.get(&(200, 60, 90)).copied().unwrap_or(0), 12_288, "barra A");
252    assert_eq!(h.get(&(60, 170, 120)).copied().unwrap_or(0), 12_288, "barra B");
253    assert_eq!(
254        h.get(&(60, 61, 63)).copied().unwrap_or(0),
255        (ANCHO * ALTO) as usize - 2 * 12_288,
256        "fondo"
257    );
258    assert_eq!(h.get(&(0, 0, 0)).copied().unwrap_or(0), 0, "no debe quedar negro");
259}
260
261/// Una capa recortada tiene que recortar: lo que se pinta fuera del clip no
262/// llega al framebuffer.
263///
264/// Es la orden que más usa el compositor después de `fill` —cada panel, cada
265/// scroll, cada esquina redondeada abre una— y la que más caro sale si falla,
266/// porque el síntoma es «se pinta encima de todo» y no un error.
267#[test]
268fn la_capa_recorta_de_verdad() {
269    let Some(banco) = Banco::nuevo() else {
270        eprintln!("sin adaptador wgpu: se saltea");
271        return;
272    };
273
274    let fondo = color(10, 20, 30);
275    let tinta = color(250, 240, 230);
276
277    let mut escena = Escena::new();
278    let todo = Rect::new(0.0, 0.0, f64::from(ANCHO), f64::from(ALTO));
279    escena.fill(Fill::NonZero, Affine::IDENTITY, fondo, None, &todo);
280    escena.push_layer(
281        Fill::NonZero,
282        llimphi_cpu::peniko::Mix::Normal,
283        1.0,
284        Affine::IDENTITY,
285        &Rect::new(64.0, 64.0, 192.0, 192.0),
286    );
287    // Se pinta la pantalla ENTERA: si el recorte no funciona, el fondo desaparece.
288    escena.fill(Fill::NonZero, Affine::IDENTITY, tinta, None, &todo);
289    escena.pop_layer();
290
291    let h = histograma(&banco.rendir(&escena));
292    assert_eq!(
293        h.get(&(250, 240, 230)).copied().unwrap_or(0),
294        128 * 128,
295        "la tinta debe quedar confinada al clip de 128x128"
296    );
297    assert_eq!(
298        h.get(&(10, 20, 30)).copied().unwrap_or(0),
299        (ANCHO * ALTO) as usize - 128 * 128,
300        "el resto debe seguir siendo fondo"
301    );
302}
303
304/// Una capa con alfa atenúa lo de adentro sin tocar lo de afuera.
305///
306/// Es la mitad de `push_layer` que el recorte no cubre, y la que usa toda
307/// animación de aparecer/desaparecer del catálogo.
308#[test]
309fn la_capa_con_alfa_atenua() {
310    let Some(banco) = Banco::nuevo() else {
311        eprintln!("sin adaptador wgpu: se saltea");
312        return;
313    };
314
315    let mut escena = Escena::new();
316    let todo = Rect::new(0.0, 0.0, f64::from(ANCHO), f64::from(ALTO));
317    escena.fill(Fill::NonZero, Affine::IDENTITY, color(0, 0, 0), None, &todo);
318    escena.push_layer(
319        Fill::NonZero,
320        llimphi_cpu::peniko::Mix::Normal,
321        0.5,
322        Affine::IDENTITY,
323        &todo,
324    );
325    escena.fill(
326        Fill::NonZero,
327        Affine::IDENTITY,
328        color(255, 255, 255),
329        None,
330        &Rect::new(64.0, 64.0, 192.0, 192.0),
331    );
332    escena.pop_layer();
333
334    let pixeles = banco.rendir(&escena);
335    let i = ((128 * ANCHO + 128) * 4) as usize;
336    let gris = pixeles[i];
337    // Blanco al 50% sobre negro. Se admite ±2 por el redondeo del pipeline u8;
338    // exigir 127 clavado sería atarse a un detalle de implementación de vello.
339    assert!(
340        (126..=129).contains(&gris),
341        "el centro debería quedar a media luz, salió {gris}"
342    );
343    assert_eq!(
344        (pixeles[i], pixeles[i + 1], pixeles[i + 2]),
345        (gris, gris, gris),
346        "la atenuación debe ser pareja en los tres canales"
347    );
348}
349
350/// El trazo dibuja contorno y no relleno.
351#[test]
352fn el_trazo_deja_hueco_adentro() {
353    let Some(banco) = Banco::nuevo() else {
354        eprintln!("sin adaptador wgpu: se saltea");
355        return;
356    };
357
358    let fondo = color(9, 9, 9);
359    let mut escena = Escena::new();
360    escena.fill(
361        Fill::NonZero,
362        Affine::IDENTITY,
363        fondo,
364        None,
365        &Rect::new(0.0, 0.0, f64::from(ANCHO), f64::from(ALTO)),
366    );
367    escena.stroke(
368        &llimphi_cpu::kurbo::Stroke::new(8.0),
369        Affine::IDENTITY,
370        color(240, 30, 30),
371        None,
372        &Rect::new(64.0, 64.0, 192.0, 192.0),
373    );
374
375    let pixeles = banco.rendir(&escena);
376    let en = |x: u32, y: u32| {
377        let i = ((y * ANCHO + x) * 4) as usize;
378        (pixeles[i], pixeles[i + 1], pixeles[i + 2])
379    };
380    assert_eq!(en(128, 64), (240, 30, 30), "el borde de arriba debe estar pintado");
381    assert_eq!(en(128, 128), (9, 9, 9), "el centro debe seguir siendo fondo");
382}
383
384/// **El camino nuevo de este crate**: una imagen sube al atlas de la GPU y se
385/// dibuja desde ahí.
386///
387/// Es lo único que no se hereda del pintor CPU —allá la imagen se muestrea de un
388/// `Pixmap` en RAM— así que es donde vale la pena gastar un test. Los bytes se
389/// sintetizan acá: no hace falta ningún archivo en disco para ejercitar
390/// `upload_image` + `ImageSource::opaque_id`.
391#[test]
392fn una_imagen_cruza_por_el_atlas() {
393    let Some(banco) = Banco::nuevo() else {
394        eprintln!("sin adaptador wgpu: se saltea");
395        return;
396    };
397
398    const LADO: u32 = 32;
399    let bytes: Vec<u8> = (0..LADO * LADO)
400        .flat_map(|_| [17_u8, 200, 90, 255])
401        .collect();
402    let imagen = llimphi_cpu::peniko::ImageBrush {
403        image: llimphi_cpu::peniko::ImageData {
404            data: llimphi_cpu::peniko::Blob::from(bytes),
405            format: llimphi_cpu::peniko::ImageFormat::Rgba8,
406            // Alfa separado (no premultiplicado): el caso normal de un PNG, y
407            // el que obliga al conversor a hacer la multiplicación.
408            alpha_type: llimphi_cpu::peniko::ImageAlphaType::Alpha,
409            width: LADO,
410            height: LADO,
411        },
412        sampler: llimphi_cpu::peniko::ImageSampler::default(),
413    };
414
415    let mut escena = Escena::new();
416    escena.fill(
417        Fill::NonZero,
418        Affine::IDENTITY,
419        color(0, 0, 0),
420        None,
421        &Rect::new(0.0, 0.0, f64::from(ANCHO), f64::from(ALTO)),
422    );
423    escena.draw_image(&imagen, Affine::translate((64.0, 64.0)));
424
425    let h = histograma(&banco.rendir(&escena));
426    // Qué llegó, no sólo cuánto: un «0» pelado no distingue «la imagen no se
427    // dibujó» de «se dibujó de otro color», y son diagnósticos opuestos.
428    let mut top: Vec<_> = h.iter().collect();
429    top.sort_by_key(|(_, n)| std::cmp::Reverse(**n));
430    eprintln!("colores presentes (top 4): {:?}", &top[..top.len().min(4)]);
431    assert_eq!(
432        h.get(&(17, 200, 90)).copied().unwrap_or(0),
433        (LADO * LADO) as usize,
434        "la imagen debe llegar entera y con su color exacto"
435    );
436}
437
438/// La máscara de luminancia: rasterizada en CPU, consumida por la GPU.
439///
440/// El cruce sólo es posible porque vello_cpu y vello_hybrid cuelgan del mismo
441/// `vello_common`, así que la `Mask` viaja sin conversión. Si algún día esa
442/// unificación se rompe, este test es el que avisa.
443#[test]
444fn la_mascara_de_luminancia_recorta_por_brillo() {
445    let Some(banco) = Banco::nuevo() else {
446        eprintln!("sin adaptador wgpu: se saltea");
447        return;
448    };
449
450    let mut escena = Escena::new();
451    let todo = Rect::new(0.0, 0.0, f64::from(ANCHO), f64::from(ALTO));
452    escena.fill(Fill::NonZero, Affine::IDENTITY, color(20, 20, 20), None, &todo);
453    escena.push_luminance_mask_layer(
454        Fill::NonZero,
455        1.0,
456        Affine::IDENTITY,
457        &Rect::new(64.0, 64.0, 192.0, 192.0),
458    );
459    escena.fill(Fill::NonZero, Affine::IDENTITY, color(250, 250, 250), None, &todo);
460    escena.pop_layer();
461
462    let pixeles = banco.rendir(&escena);
463    let en = |x: u32, y: u32| {
464        let i = ((y * ANCHO + x) * 4) as usize;
465        pixeles[i]
466    };
467    assert!(
468        en(128, 128) > 200,
469        "dentro de la máscara (blanca) debe pasar la tinta, salió {}",
470        en(128, 128)
471    );
472    assert_eq!(
473        en(16, 16),
474        20,
475        "fuera de la máscara (negra) no debe pasar nada"
476    );
477}
478
479/// `append` con transformación: la razón de ser de la grabación.
480///
481/// Se graba un cuadrado en el origen, se lo incorpora trasladado, y se verifica
482/// que los píxeles aparecieron **donde los mandó la traslación** y no donde se
483/// dibujaron. Sin esto el compositor no puede reciclar subárboles cacheados.
484#[test]
485fn append_traslada_lo_grabado() {
486    let Some(banco) = Banco::nuevo() else {
487        eprintln!("sin adaptador wgpu: se saltea");
488        return;
489    };
490
491    let tinta = color(11, 222, 33);
492    let mut hija = Escena::new();
493    hija.fill(
494        Fill::NonZero,
495        Affine::IDENTITY,
496        tinta,
497        None,
498        &Rect::new(0.0, 0.0, 64.0, 64.0),
499    );
500
501    let mut madre = Escena::new();
502    madre.fill(
503        Fill::NonZero,
504        Affine::IDENTITY,
505        color(0, 0, 1),
506        None,
507        &Rect::new(0.0, 0.0, f64::from(ANCHO), f64::from(ALTO)),
508    );
509    madre.append(&hija, Some(Affine::translate((128.0, 128.0))));
510
511    let pixeles = banco.rendir(&madre);
512    let en = |x: u32, y: u32| {
513        let i = ((y * ANCHO + x) * 4) as usize;
514        (pixeles[i], pixeles[i + 1], pixeles[i + 2])
515    };
516    assert_eq!(en(160, 160), (11, 222, 33), "debe haber pintado en el destino");
517    assert_eq!(en(32, 32), (0, 0, 1), "el origen debe haber quedado limpio");
518    assert_eq!(
519        histograma(&pixeles).get(&(11, 222, 33)).copied().unwrap_or(0),
520        64 * 64,
521        "el cuadrado no debe cambiar de tamaño al trasladarse"
522    );
523}
524
525/// **Un relleno a sangre tiene que llegar a las columnas del borde.**
526///
527/// Nació de un defecto medido en metal (Redmi Note 13 Pro, Mali-G57 MC2, a
528/// 1080x2400): las 8 columnas laterales —`x 0..3` y `x 1076..1079`— salían
529/// negro puro aunque la vista raíz cubría el ancho entero con su fondo. El eje
530/// vertical **no** tenía el problema, y el `Pintor` pasa el ancho completo en el
531/// `RenderSize` sin truncar en ningún lado.
532///
533/// La hipótesis con aritmética a favor era el ancho: 2400 es múltiplo de 16 y
534/// 1080 no (67,5). Por eso el barrido mezcla a propósito anchos que **son**
535/// múltiplos de 16 con otros que no: si la hipótesis es buena, sólo los que no
536/// lo son pierden columnas, y el test queda como la prueba de regresión.
537#[test]
538fn el_relleno_a_sangre_cubre_los_bordes_laterales() {
539    let Some(banco) = Banco::nuevo() else {
540        eprintln!("sin adaptador wgpu: se saltea");
541        return;
542    };
543
544    const ALTO_P: u32 = 64;
545    let relleno = color(60, 170, 120);
546    let mut fallos = Vec::new();
547
548    for ancho in [256_u32, 1000, 1072, 1080, 1088] {
549        let mut escena = Escena::new();
550        escena.fill(
551            Fill::NonZero,
552            Affine::IDENTITY,
553            relleno,
554            None,
555            &Rect::new(0.0, 0.0, f64::from(ancho), f64::from(ALTO_P)),
556        );
557        let px = banco.rendir_en(&escena, ancho, ALTO_P);
558
559        // El banco limpia a negro OPACO, así que negro = nadie pintó acá. Una
560        // columna cuenta como vacía sólo si NINGUNA de sus filas se pintó.
561        let vacia = |x: u32| {
562            (0..ALTO_P).all(|y| {
563                let i = ((y * ancho + x) * 4) as usize;
564                px[i] == 0 && px[i + 1] == 0 && px[i + 2] == 0
565            })
566        };
567        let izq = (0..ancho).take_while(|&x| vacia(x)).count();
568        let der = (0..ancho).rev().take_while(|&x| vacia(x)).count();
569        eprintln!(
570            "ancho {ancho:5} (múltiplo de 16: {:5}) → vacías: {izq} izq · {der} der",
571            ancho % 16 == 0
572        );
573        if izq != 0 || der != 0 {
574            fallos.push((ancho, izq, der));
575        }
576    }
577
578    assert!(
579        fallos.is_empty(),
580        "un relleno a sangre dejó columnas sin pintar (ancho, izq, der): {fallos:?}"
581    );
582}
583
584/// **El techo de textura se redondea a potencia de dos.**
585///
586/// No necesita GPU: es la cuenta que decide si `vello_hybrid` sabe direccionar
587/// su textura de alfas. El caso que importa es 16383 —lo que reporta el Mali del
588/// Redmi—, cuyo `trailing_zeros()` es 0 y deja el corrimiento del shader en
589/// nada. Ver `limites_seguros`.
590#[test]
591fn el_techo_de_textura_se_redondea_a_potencia_de_dos() {
592    for (entrada, esperado) in [
593        (16383_u32, 8192_u32),
594        (16384, 16384),
595        (8192, 8192),
596        (8191, 4096),
597        (1, 1),
598        (0, 1),
599    ] {
600        let salida = crate::pintor::potencia_de_dos_hacia_abajo(entrada);
601        assert_eq!(salida, esperado, "techo para {entrada}");
602        assert!(
603            salida.is_power_of_two(),
604            "{salida} tiene que ser potencia de dos"
605        );
606        assert!(
607            salida <= entrada.max(1),
608            "el techo no puede subir: {entrada} → {salida}"
609        );
610    }
611}
612
613/// **El mismo relleno a sangre, pero por el camino que usa el compositor.**
614///
615/// El test de arriba rellena un `Rect`, que en el Pintor cae en `fill_rect`.
616/// El compositor NUNCA emite eso: `render.rs` arma siempre un `RoundedRect`
617/// con `node_rrect` —radio 0 incluido—, que se graba como `Forma::Redondeado`
618/// y termina en `fill_path`. Son dos rasterizados distintos de la misma
619/// geometría, y sólo el segundo es el que pinta el fondo de una app.
620#[test]
621fn el_relleno_a_sangre_redondeado_cubre_los_bordes_laterales() {
622    let Some(banco) = Banco::nuevo() else {
623        eprintln!("sin adaptador wgpu: se saltea");
624        return;
625    };
626
627    const ALTO_P: u32 = 64;
628    let relleno = color(60, 170, 120);
629    let mut fallos = Vec::new();
630
631    for ancho in [256_u32, 1000, 1072, 1080, 1088] {
632        let mut escena = Escena::new();
633        escena.fill(
634            Fill::NonZero,
635            Affine::IDENTITY,
636            relleno,
637            None,
638            &RoundedRect::new(0.0, 0.0, f64::from(ancho), f64::from(ALTO_P), 0.0),
639        );
640        let px = banco.rendir_en(&escena, ancho, ALTO_P);
641
642        let vacia = |x: u32| {
643            (0..ALTO_P).all(|y| {
644                let i = ((y * ancho + x) * 4) as usize;
645                px[i] == 0 && px[i + 1] == 0 && px[i + 2] == 0
646            })
647        };
648        let izq = (0..ancho).take_while(|&x| vacia(x)).count();
649        let der = (0..ancho).rev().take_while(|&x| vacia(x)).count();
650        eprintln!("ancho {ancho:5} redondeado r=0 → vacías: {izq} izq · {der} der");
651        if izq != 0 || der != 0 {
652            fallos.push((ancho, izq, der));
653        }
654    }
655
656    assert!(
657        fallos.is_empty(),
658        "el relleno redondeado dejó columnas sin pintar (ancho, izq, der): {fallos:?}"
659    );
660}
661
662/// **El `color_base` tiene que quedar donde la escena no pinta.**
663///
664/// Es la premisa entera del pase `limpiar` del `Pintor`: `vello::Renderer`
665/// limpia su target con el `base_color` de los `RenderParams`, el de
666/// `vello_hybrid` —según su doc— entra con `LoadOp::Load`, y por eso el Pintor
667/// agrega un `LoadOp::Clear` propio. Si esa premisa fuera falsa, el pase sería
668/// tanto inútil como invisible: con `color_base` igual al fondo que pinta la
669/// escena —que es el caso normal— los dos se ven exactamente igual.
670///
671/// Nació de una medición en el Redmi que no cerraba: con el clear cambiado a
672/// magenta, la pantalla no tenía **ni un** píxel magenta. Este test hace la
673/// misma pregunta donde se puede depurar.
674#[test]
675fn el_color_base_pinta_donde_la_escena_no_llega() {
676    let Some(banco) = Banco::nuevo() else {
677        eprintln!("sin adaptador wgpu: se saltea");
678        return;
679    };
680
681    // La escena pinta SÓLO un cuadrado central: las esquinas quedan a cargo del
682    // color base, y son el sitio donde se lee la respuesta.
683    let mut escena = Escena::new();
684    escena.fill(
685        Fill::NonZero,
686        Affine::IDENTITY,
687        color(60, 170, 120),
688        None,
689        &Rect::new(64.0, 64.0, 192.0, 192.0),
690    );
691
692    let base = color(255, 0, 255);
693    let px = banco.rendir_con_base(&escena, ANCHO, ALTO, base);
694
695    let leer = |x: u32, y: u32| {
696        let i = ((y * ANCHO + x) * 4) as usize;
697        (px[i], px[i + 1], px[i + 2])
698    };
699    let centro = leer(128, 128);
700    let esquina = leer(4, 4);
701    eprintln!("centro {centro:?} · esquina {esquina:?} · base esperada (255, 0, 255)");
702
703    assert_eq!(centro, (60, 170, 120), "la escena tiene que pintar el centro");
704    assert_eq!(
705        esquina,
706        (255, 0, 255),
707        "el color_base tiene que sobrevivir donde la escena no pinta; si sale \
708         (0,0,0) es que vello_hybrid limpia el attachment por su cuenta y el \
709         pase `limpiar` del Pintor no sirve para nada"
710    );
711}