use crate::embedded;
use std::io::ErrorKind;
use std::path::Path;
#[derive(Debug)]
pub struct Theme {
pub name: &'static str,
pub sheet_path: &'static str,
pub embedded: &'static [u8],
}
pub static THEMES: [Theme; 6] = [
Theme {
name: DEFAULT_THEME_NAME,
sheet_path: "css/error.css",
embedded: embedded::DEFAULT_SHEET,
},
Theme {
name: "minimal",
sheet_path: "themes/minimal.css",
embedded: include_bytes!("../content/themes/minimal.css"),
},
Theme {
name: "terminal",
sheet_path: "themes/terminal.css",
embedded: include_bytes!("../content/themes/terminal.css"),
},
Theme {
name: "paper",
sheet_path: "themes/paper.css",
embedded: include_bytes!("../content/themes/paper.css"),
},
Theme {
name: "amber",
sheet_path: "themes/amber.css",
embedded: include_bytes!("../content/themes/amber.css"),
},
Theme {
name: "compact",
sheet_path: "themes/compact.css",
embedded: include_bytes!("../content/themes/compact.css"),
},
];
pub const DEFAULT_THEME_NAME: &str = "default";
pub const THEME_NAMES: &str = "default, minimal, terminal, paper, amber, compact";
pub fn theme_by_name(name: &str) -> Option<&'static Theme> {
THEMES.iter().find(|theme| theme.name == name)
}
pub fn default_theme() -> &'static Theme {
&THEMES[0]
}
pub fn resolve(requested: Option<&str>, configured: &'static Theme) -> &'static Theme {
requested.and_then(theme_by_name).unwrap_or(configured)
}
impl Theme {
pub fn sheet(&self, content_dir: &Path) -> Vec<u8> {
let path = content_dir.join(self.sheet_path);
match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(err) if err.kind() == ErrorKind::NotFound => self.embedded.to_vec(),
Err(err) => {
tracing::warn!(
theme = self.name,
path = %path.display(),
error = %err,
"no se pudo leer la hoja del tema: se usa la copia embebida"
);
self.embedded.to_vec()
}
}
}
}
#[cfg(test)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Incumplimiento {
Peticion(&'static str),
Movimiento(&'static str),
}
#[cfg(test)]
pub fn incumplimientos(hoja: &[u8]) -> Vec<Incumplimiento> {
let mut fallos = Vec::new();
let hoja = hoja_sin_comentarios(hoja);
if let Some(token) = token_que_pide(&hoja) {
fallos.push(Incumplimiento::Peticion(token));
}
if let Some(token) = token_que_anima(&hoja) {
fallos.push(Incumplimiento::Movimiento(token));
}
fallos
}
#[cfg(test)]
fn hoja_sin_comentarios(hoja: &[u8]) -> Vec<u8> {
let mut salida = Vec::with_capacity(hoja.len());
let mut i = 0;
while i < hoja.len() {
match hoja[i] {
b'/' if hoja.get(i + 1) == Some(&b'*') => {
match crate::render::busca(&hoja[i + 2..], b"*/") {
Some(cierre) => i += 2 + cierre + 2,
None => return salida,
}
}
comilla @ (b'"' | b'\'') => {
salida.push(comilla);
i += 1;
while i < hoja.len() {
let byte = hoja[i];
salida.push(byte);
i += 1;
if byte == b'\\' {
if let Some(escapado) = hoja.get(i) {
salida.push(*escapado);
i += 1;
}
} else if byte == comilla {
break;
}
}
}
byte => {
salida.push(byte);
i += 1;
}
}
}
salida
}
#[cfg(test)]
fn token_que_pide(hoja: &[u8]) -> Option<&'static str> {
if contiene(hoja, "@import") {
return Some("@import");
}
if tiene_url_externa(hoja) {
return Some("url(");
}
None
}
#[cfg(test)]
const PROPIEDADES_QUE_ANIMAN: [&str; 4] = [
"animation",
"animation-name",
"transition",
"transition-property",
];
#[cfg(test)]
const NEUTRALIZA_ANIMACION: [&str; 2] = ["animation", "animation-name"];
#[cfg(test)]
const NEUTRALIZA_TRANSICION: [&str; 2] = ["transition", "transition-property"];
#[cfg(test)]
const AGUJA_REDUCE: &[u8] = b"@media (prefers-reduced-motion: reduce)";
#[cfg(test)]
fn token_que_anima(hoja: &[u8]) -> Option<&'static str> {
let bloque = bloque_reduce(hoja).filter(|cuerpo| es_bloque_con_contenido(cuerpo));
let mut desde = 0;
while let Some((posicion, propiedad, valor)) = primera_declaracion(hoja, desde) {
desde = posicion + propiedad.len();
if valor == b"none" {
continue;
}
if !bloque.is_some_and(|cuerpo| bloque_neutraliza(cuerpo, propiedad)) {
return Some(propiedad);
}
}
None
}
#[cfg(test)]
fn bloque_reduce(hoja: &[u8]) -> Option<&[u8]> {
let mut cursor = crate::render::busca(hoja, AGUJA_REDUCE)? + AGUJA_REDUCE.len();
while hoja.get(cursor).copied().is_some_and(es_espacio) {
cursor += 1;
}
if hoja.get(cursor) != Some(&b'{') {
return None;
}
cursor += 1;
let inicio = cursor;
let mut profundidad = 1u32;
while let Some(&byte) = hoja.get(cursor) {
match byte {
b'{' => profundidad += 1,
b'}' => {
profundidad -= 1;
if profundidad == 0 {
return Some(&hoja[inicio..cursor]);
}
}
_ => {}
}
cursor += 1;
}
Some(&hoja[inicio..])
}
#[cfg(test)]
fn es_bloque_con_contenido(cuerpo: &[u8]) -> bool {
cuerpo.iter().any(|&byte| !es_espacio(byte))
}
#[cfg(test)]
fn bloque_neutraliza(cuerpo: &[u8], propiedad: &str) -> bool {
let formas = if propiedad.starts_with("animation") {
NEUTRALIZA_ANIMACION
} else {
NEUTRALIZA_TRANSICION
};
formas
.iter()
.any(|&forma| declara_con_valor(cuerpo, forma, b"none"))
}
#[cfg(test)]
fn declara_con_valor(hoja: &[u8], propiedad: &str, valor: &[u8]) -> bool {
let mut desde = 0;
while let Some((posicion, encontrado)) = primera_declaracion_de(hoja, propiedad, desde) {
if encontrado == valor {
return true;
}
desde = posicion + propiedad.len();
}
false
}
#[cfg(test)]
fn primera_declaracion(hoja: &[u8], desde: usize) -> Option<(usize, &'static str, &[u8])> {
PROPIEDADES_QUE_ANIMAN
.iter()
.filter_map(|&propiedad| {
let (posicion, valor) = primera_declaracion_de(hoja, propiedad, desde)?;
Some((posicion, propiedad, valor))
})
.min_by_key(|(posicion, _, _)| *posicion)
}
#[cfg(test)]
fn primera_declaracion_de<'a>(
hoja: &'a [u8],
propiedad: &str,
desde: usize,
) -> Option<(usize, &'a [u8])> {
let posicion = posicion_declaracion(hoja, propiedad, desde)?;
Some((posicion, primer_token(&hoja[posicion + propiedad.len()..])))
}
#[cfg(test)]
fn posicion_declaracion(hoja: &[u8], propiedad: &str, desde: usize) -> Option<usize> {
let aguja = propiedad.as_bytes();
let mut cursor = desde;
loop {
let posicion = cursor + crate::render::busca(hoja.get(cursor..)?, aguja)?;
if (posicion == 0 || !es_byte_de_identificador(hoja[posicion - 1]))
&& !hoja
.get(posicion + aguja.len())
.copied()
.is_some_and(es_byte_de_identificador)
{
let mut dos_puntos = posicion + aguja.len();
while hoja.get(dos_puntos).copied().is_some_and(es_espacio) {
dos_puntos += 1;
}
if hoja.get(dos_puntos) == Some(&b':') {
return Some(posicion);
}
}
cursor = posicion + 1;
}
}
#[cfg(test)]
fn primer_token(resto: &[u8]) -> &[u8] {
let mut cursor = 0;
while resto.get(cursor).copied().is_some_and(es_espacio) {
cursor += 1;
}
if resto.get(cursor) == Some(&b':') {
cursor += 1;
}
while resto.get(cursor).copied().is_some_and(es_espacio) {
cursor += 1;
}
let inicio = cursor;
while resto
.get(cursor)
.copied()
.is_some_and(|byte| !es_espacio(byte) && byte != b';' && byte != b'}')
{
cursor += 1;
}
&resto[inicio..cursor]
}
#[cfg(test)]
fn es_byte_de_identificador(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_'
}
#[cfg(test)]
fn es_espacio(byte: u8) -> bool {
matches!(byte, b' ' | b'\t' | b'\n' | b'\r')
}
#[cfg(test)]
fn tiene_url_externa(hoja: &[u8]) -> bool {
const AGUJA: &[u8] = b"url(";
let mut desde = 0;
while let Some(pos) = crate::render::busca(hoja.get(desde..).unwrap_or_default(), AGUJA) {
let inicio = desde + pos + AGUJA.len();
let destino = hoja
.get(inicio..)
.unwrap_or_default()
.iter()
.find(|byte| !byte.is_ascii_whitespace());
if destino != Some(&b'#') {
return true;
}
desde = inicio;
}
false
}
#[cfg(test)]
fn contiene(pajar: &[u8], aguja: &str) -> bool {
String::from_utf8_lossy(pajar).contains(aguja)
}
#[cfg(test)]
mod tests {
use super::{
DEFAULT_THEME_NAME, Incumplimiento, THEME_NAMES, THEMES, default_theme, incumplimientos,
resolve, theme_by_name,
};
use crate::embedded;
use std::path::PathBuf;
fn content_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("content")
}
const NOMBRES: [&str; 6] = [
"default", "minimal", "terminal", "paper", "amber", "compact",
];
fn es_de_identificador(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_'
}
fn es_espacio(byte: u8) -> bool {
matches!(byte, b' ' | b'\t' | b'\n' | b'\r')
}
fn sin_comentarios(css: &str) -> String {
let mut salida = String::with_capacity(css.len());
let mut resto = css;
while let Some(inicio) = resto.find("/*") {
salida.push_str(&resto[..inicio]);
match resto[inicio + 2..].find("*/") {
Some(fin) => resto = &resto[inicio + 2 + fin + 2..],
None => return salida,
}
}
salida.push_str(resto);
salida
}
fn tiene_regla(css: &str, selector: &str) -> bool {
let css = sin_comentarios(css);
let bytes = css.as_bytes();
for (inicio, _) in css.match_indices(selector) {
if inicio > 0 && es_de_identificador(bytes[inicio - 1]) {
continue;
}
let mut cursor = inicio + selector.len();
if bytes.get(cursor).copied().is_some_and(es_de_identificador) {
continue;
}
while bytes.get(cursor).copied().is_some_and(es_espacio) {
cursor += 1;
}
if matches!(bytes.get(cursor), Some(b'{') | Some(b',')) {
return true;
}
}
false
}
fn incumple_movimiento(hoja: &str) -> bool {
incumplimientos(hoja.as_bytes())
.iter()
.any(|i| matches!(i, Incumplimiento::Movimiento(_)))
}
#[test]
fn las_hojas_embebidas_son_byte_a_byte_las_de_content() {
for theme in THEMES.iter() {
let ruta = content_dir().join(theme.sheet_path);
let del_disco = match std::fs::read(&ruta) {
Ok(bytes) => bytes,
Err(error) => panic!(
"no se pudo leer {} (tema `{}`): {error}",
ruta.display(),
theme.name
),
};
assert_eq!(
theme.embedded,
del_disco.as_slice(),
"la hoja embebida del tema `{}` no coincide byte a byte con {}",
theme.name,
ruta.display()
);
}
}
#[test]
fn la_tabla_declara_los_seis_temas() {
assert_eq!(
THEMES.len(),
NOMBRES.len(),
"`THEMES` tiene que declarar los seis temas incorporados"
);
assert_eq!(
THEMES[0].name, DEFAULT_THEME_NAME,
"`THEMES[0]` tiene que ser el tema por defecto"
);
for (theme, nombre) in THEMES.iter().zip(NOMBRES) {
assert_eq!(
theme.name, nombre,
"el orden de `THEMES` tiene que ser el de la lista de nombres"
);
}
let enumerados: Vec<&str> = THEME_NAMES.split(',').map(str::trim).collect();
assert_eq!(
enumerados, NOMBRES,
"`THEME_NAMES` tiene que enumerar los seis nombres: {THEME_NAMES}"
);
}
#[test]
fn theme_by_name_solo_acepta_los_seis() {
for nombre in NOMBRES {
assert!(
theme_by_name(nombre).is_some(),
"`theme_by_name` tiene que encontrar el tema `{nombre}`"
);
}
for desconocido in [
"",
"neon",
"Default",
"TERMINAL",
"Themes/default",
"default ",
] {
assert!(
theme_by_name(desconocido).is_none(),
"`theme_by_name({desconocido:?})` tiene que ser `None`: la comparación es exacta"
);
}
}
#[test]
fn resolve_el_conocido_gana_y_lo_demas_cae_al_configurado() {
let configurado = theme_by_name("minimal").expect("`minimal` es uno de los seis");
assert_eq!(
resolve(Some("terminal"), configurado).name,
"terminal",
"un `X-Theme` conocido tiene que ganar al tema configurado"
);
assert_eq!(
resolve(Some("neon"), configurado).name,
"minimal",
"un `X-Theme` desconocido cae al configurado, no a otro tema"
);
assert_eq!(
resolve(Some(""), configurado).name,
"minimal",
"un `X-Theme` vacío no es un tema: cae al configurado"
);
assert_eq!(
resolve(None, configurado).name,
"minimal",
"sin `X-Theme` el tema es el configurado"
);
assert_eq!(
resolve(None, default_theme()).name,
DEFAULT_THEME_NAME,
"sin `X-Theme` y con el defecto configurado, el tema es `default`"
);
}
#[test]
fn cada_hoja_cubre_el_marcado() {
let mut fallos = Vec::new();
for theme in THEMES.iter() {
let hoja = String::from_utf8_lossy(theme.embedded);
for selector in ["body", "h1", "h2", "hr", "p", "footer"] {
if !tiene_regla(&hoja, selector) {
fallos.push(format!(
"la hoja del tema `{}` no define `{selector}`",
theme.name
));
}
}
}
assert!(
fallos.is_empty(),
"cada hoja de tema tiene que cubrir el marcado ({} incumplimiento(s)):\n- {}",
fallos.len(),
fallos.join("\n- ")
);
}
#[test]
fn ninguna_hoja_puede_hacer_una_peticion() {
let mut fallos = Vec::new();
for theme in THEMES.iter() {
for incumplimiento in incumplimientos(theme.embedded) {
if let Incumplimiento::Peticion(token) = incumplimiento {
fallos.push(format!(
"la hoja del tema `{}` contiene `{token}`: el navegador tendría que pedir \
algo por la red",
theme.name
));
}
}
}
assert!(
fallos.is_empty(),
"ninguna hoja puede hacer que el navegador pida algo por la red ({} \
incumplimiento(s)):\n- {}",
fallos.len(),
fallos.join("\n- ")
);
}
const AUTOCONTENIDA: &str = r#"
.caja { fill: url(#gradiente); }
@font-face { font-family: Arial; src: local("Arial"); }
"#;
const ANIMADA_CON_FRENO: &str = r#"
@keyframes parpadeo { from { opacity: 1 } to { opacity: 0 } }
body { animation: parpadeo 2s infinite; }
@media (prefers-reduced-motion: reduce) { body { animation: none; } }
"#;
const ANIMADA_SIN_FRENO: &str = r#"
body { transition: opacity 0.2s; }
"#;
const QUIETA: &str = r#"
body { color: #222; }
"#;
const NOMBRA_SIN_NEUTRALIZAR: &str = r#"
main { animation: entrada 180ms ease-out; }
@media (prefers-reduced-motion: reduce) { /* esta animación se apaga sola */ }
"#;
const NEUTRALIZA_BAJO_LA_OTRA_PREFERENCIA: &str = r#"
main { animation: entrada 180ms ease-out; }
@media (prefers-reduced-motion: no-preference) { main { animation: none; } }
"#;
const REDUCE_VACIO: &str = r#"
main { animation: entrada 180ms ease-out; }
@media (prefers-reduced-motion: reduce) { }
"#;
const REDUCE_QUE_NEUTRALIZA: &str = r#"
@keyframes entrada { from { opacity: 0 } to { opacity: 1 } }
main { animation: entrada 180ms ease-out; }
@media (prefers-reduced-motion: reduce) { main { animation: none; } }
"#;
const REDUCE_QUE_NEUTRALIZA_OTRA: &str = r#"
main { transition: opacity 200ms; }
@media (prefers-reduced-motion: reduce) { main { animation: none; } }
"#;
const REDUCE_QUE_ANIMA: &str = r#"
@media (prefers-reduced-motion: reduce) { main { animation: entrada 180ms ease-out; } }
"#;
const SOLO_FOTOGRAMAS: &str = r#"
@keyframes entrada { from { opacity: 0 } to { opacity: 1 } }
"#;
const REDUCE_CON_REGLA_ANIDADA: &str = r#"
main { animation: entrada 180ms ease-out; }
@media (prefers-reduced-motion: reduce) {
main { color: inherit; }
main { animation: none; }
}
"#;
const PIDEN_FICHERO: [(&str, &str); 3] = [
(r#"body { background: url(foto.png); }"#, "url("),
(
r#"body { background: url(https://ejemplo/x.png); }"#,
"url(",
),
(r#"@import "otra.css";"#, "@import"),
];
const COMENTADA: &str = r#"
/* Esta hoja no usa `animation` ni `@import`, y no referencia `url(` remotos. */
body { color: #222; }
"#;
const COMENTADA_CON_PETICION: &str = r#"
/* Nada de `url(` remotos por aquí. */
body { background: url(foto.png); }
"#;
const COMENTARIO_ABIERTO: &str = r#"
/* sin cerrar: `animation` y `@import` aquí dentro ya no son reglas
body { color: #222; }
"#;
const AUTOCONTENIDA_MULTIPLE: &str = r#"
.a { fill: url(#uno); }
.b { fill: url( #dos ); }
.c { fill: url(#); }
"#;
const INTERNA_Y_EXTERNA: &str = r#"
.a { background: image-set(url(#uno) 1x, url(foto.png) 2x); }
"#;
const CADENA_CON_BARRAS: &str = r#"
a::before { content: "/*"; }
body { background: url(foto.png); }
"#;
#[test]
fn una_referencia_al_propio_documento_no_es_una_peticion() {
assert!(
incumplimientos(AUTOCONTENIDA.as_bytes()).is_empty(),
"`url(#…)` y `src: local(…)` no son peticiones: no hay nada que el navegador tenga que \
pedir, así que la hoja tiene que cumplir el contrato. Hoja: {AUTOCONTENIDA:?}"
);
}
#[test]
fn una_hoja_que_pide_un_fichero_incumple() {
for (hoja, token) in PIDEN_FICHERO {
assert!(
incumplimientos(hoja.as_bytes()).contains(&Incumplimiento::Peticion(token)),
"esta hoja pide un fichero con `{token}` y tiene que incumplirlo **nombrando ese \
token**: {hoja:?}"
);
}
}
#[test]
fn una_hoja_que_anima_solo_cumple_si_se_puede_apagar() {
assert!(
incumplimientos(ANIMADA_SIN_FRENO.as_bytes())
.iter()
.any(|i| matches!(i, Incumplimiento::Movimiento(_))),
"una hoja que anima y no declara `prefers-reduced-motion` tiene que incumplir: \
{ANIMADA_SIN_FRENO:?}"
);
assert!(
incumplimientos(ANIMADA_CON_FRENO.as_bytes()).is_empty(),
"una hoja que anima **y** declara `prefers-reduced-motion` tiene que cumplir: \
{ANIMADA_CON_FRENO:?}"
);
assert!(
incumplimientos(QUIETA.as_bytes()).is_empty(),
"una hoja sin animación tiene que cumplir sin declarar nada: {QUIETA:?}"
);
}
#[test]
fn una_hoja_que_anima_respeta_reduced_motion() {
assert!(
incumple_movimiento(ANIMADA_SIN_FRENO),
"una hoja que anima y no neutraliza dentro de `@media \
(prefers-reduced-motion: reduce)` tiene que incumplir: {ANIMADA_SIN_FRENO:?}"
);
assert!(
!incumple_movimiento(ANIMADA_CON_FRENO),
"una hoja que anima **y** neutraliza dentro de `@media \
(prefers-reduced-motion: reduce)` tiene que cumplir: {ANIMADA_CON_FRENO:?}"
);
assert!(
!incumple_movimiento(QUIETA),
"una hoja sin animación tiene que cumplir sin declarar nada: {QUIETA:?}"
);
let mut fallos = Vec::new();
for theme in THEMES.iter() {
for incumplimiento in incumplimientos(theme.embedded) {
if let Incumplimiento::Movimiento(token) = incumplimiento {
fallos.push(format!(
"la hoja del tema `{}` anima con `{token}` y no neutraliza la animación \
dentro de `@media (prefers-reduced-motion: reduce)`",
theme.name
));
}
}
}
assert!(
fallos.is_empty(),
"una hoja que anima tiene que poder apagarse ({} incumplimiento(s)):\n- {}",
fallos.len(),
fallos.join("\n- ")
);
}
#[test]
fn una_hoja_que_solo_nombra_prefers_reduced_motion_incumple() {
assert!(
incumple_movimiento(NOMBRA_SIN_NEUTRALIZAR),
"nombrar `prefers-reduced-motion` no neutraliza nada: el bloque reduce trae solo un \
comentario, así que la animación declarada sigue viva y la hoja tiene que incumplir. \
Hoja: {NOMBRA_SIN_NEUTRALIZAR:?}"
);
}
#[test]
fn una_media_query_que_no_es_la_de_movimiento_reducido_no_basta() {
assert!(
incumple_movimiento(NEUTRALIZA_BAJO_LA_OTRA_PREFERENCIA),
"neutralizar bajo `prefers-reduced-motion: no-preference` no es la puerta que el \
usuario cierra, así que la hoja tiene que incumplir. \
Hoja: {NEUTRALIZA_BAJO_LA_OTRA_PREFERENCIA:?}"
);
}
#[test]
fn un_bloque_reduce_vacio_no_basta() {
assert!(
incumple_movimiento(REDUCE_VACIO),
"el bloque `@media (prefers-reduced-motion: reduce)` tiene que **tener** la \
neutralización: uno vacío no apaga nada, así que la hoja tiene que incumplir. \
Hoja: {REDUCE_VACIO:?}"
);
}
#[test]
fn un_bloque_reduce_que_neutraliza_la_animacion_cumple() {
assert!(
!incumple_movimiento(REDUCE_QUE_NEUTRALIZA),
"una hoja que anima y neutraliza su animación dentro del bloque reduce tiene que \
cumplir: {REDUCE_QUE_NEUTRALIZA:?}"
);
}
#[test]
fn neutralizar_otra_animacion_distinta_de_la_declarada_no_basta() {
assert!(
incumple_movimiento(REDUCE_QUE_NEUTRALIZA_OTRA),
"la hoja declara una `transition` y el bloque reduce solo trae `animation: none`: la \
transición declarada sigue sin neutralizarse, así que la hoja tiene que incumplir. \
Hoja: {REDUCE_QUE_NEUTRALIZA_OTRA:?}"
);
}
#[test]
fn una_animacion_declarada_dentro_del_bloque_reduce_no_se_neutraliza_sola() {
assert!(
incumple_movimiento(REDUCE_QUE_ANIMA),
"el bloque reduce trae una declaración que **anima** en vez de apagar, así que no \
neutraliza nada y la hoja tiene que incumplir. Hoja: {REDUCE_QUE_ANIMA:?}"
);
}
#[test]
fn unos_fotogramas_sin_declaracion_que_los_use_no_obligan() {
assert!(
!incumple_movimiento(SOLO_FOTOGRAMAS),
"una hoja cuyo único contenido son `@keyframes` —sin ninguna declaración de \
`animation` ni de `transition`— tiene que cumplir sin necesitar el bloque reduce, \
porque definir fotogramas no anima nada. Hoja: {SOLO_FOTOGRAMAS:?}"
);
}
#[test]
fn una_regla_anidada_dentro_del_bloque_reduce_cuenta() {
assert!(
!incumple_movimiento(REDUCE_CON_REGLA_ANIDADA),
"la neutralización dentro de una regla anidada del bloque reduce tiene que contar, \
aunque no sea la primera regla que se cierra: {REDUCE_CON_REGLA_ANIDADA:?}"
);
}
#[test]
fn una_hoja_quieta_cumple_los_dos_contratos() {
assert!(
incumplimientos(QUIETA.as_bytes()).is_empty(),
"una hoja que no pide nada y no anima tiene que cumplir: {QUIETA:?}"
);
}
#[test]
fn un_comentario_no_es_una_peticion_ni_una_animacion() {
assert!(
incumplimientos(COMENTADA.as_bytes()).is_empty(),
"un `url(`, un `@import` o un `animation` dentro de un comentario no son el contrato: \
un comentario no puede hacer que el navegador pida nada ni puede animar. \
Hoja: {COMENTADA:?}"
);
assert!(
incumplimientos(COMENTADA_CON_PETICION.as_bytes())
.iter()
.any(|i| matches!(i, Incumplimiento::Peticion(_))),
"limpiar los comentarios no puede tapar una petición de verdad: el comentario menciona \
`url(` y la regla trae un `url(foto.png)`. Hoja: {COMENTADA_CON_PETICION:?}"
);
}
#[test]
fn un_comentario_sin_cerrar_se_ignora() {
assert!(
incumplimientos(COMENTARIO_ABIERTO.as_bytes()).is_empty(),
"un comentario que abre y no cierra llega hasta el final de la hoja: lo que queda \
detrás es prosa, no reglas. Hoja: {COMENTARIO_ABIERTO:?}"
);
}
#[test]
fn el_contrato_de_red_mira_todas_las_referencias() {
assert!(
incumplimientos(AUTOCONTENIDA_MULTIPLE.as_bytes()).is_empty(),
"varias referencias internas —una con espacios de por medio y otra con el fragmento \
vacío— siguen sin ser peticiones: {AUTOCONTENIDA_MULTIPLE:?}"
);
assert!(
incumplimientos(INTERNA_Y_EXTERNA.as_bytes())
.contains(&Incumplimiento::Peticion("url(")),
"una interna **y** una externa en la misma declaración: la externa tiene que \
detectarse igual: {INTERNA_Y_EXTERNA:?}"
);
}
#[test]
fn una_cadena_con_barras_no_abre_un_comentario() {
assert!(
incumplimientos(CADENA_CON_BARRAS.as_bytes())
.contains(&Incumplimiento::Peticion("url(")),
"un `/*` dentro de una cadena es CSS válido, no un comentario: no puede tapar el \
`url(foto.png)` que viene detrás. Hoja: {CADENA_CON_BARRAS:?}"
);
assert!(
incumplimientos(br#"a { background: url("a/*b.png"); }"#)
.contains(&Incumplimiento::Peticion("url(")),
"una URL con `/*` dentro sigue siendo una petición externa"
);
assert!(
incumplimientos(br#"/* El operador dice: "no uses url( remotos". */ body { background: url(foto.png); }"#)
.contains(&Incumplimiento::Peticion("url(")),
"una comilla dentro de un comentario no puede desactivar el limpiador: el comentario se \
va entero y la petición de detrás tiene que seguir viéndose"
);
}
#[test]
fn ninguna_hoja_puede_cerrar_el_comentario_ni_el_style() {
const PROHIBIDOS: [&str; 2] = ["-->", "</style>"];
let mut fallos = Vec::new();
for theme in THEMES.iter() {
let hoja = String::from_utf8_lossy(theme.embedded);
for prohibido in PROHIBIDOS {
if hoja.contains(prohibido) {
fallos.push(format!(
"la hoja del tema `{}` contiene `{prohibido}`",
theme.name
));
}
}
}
assert!(
fallos.is_empty(),
"ninguna hoja puede contener `-->` ni `</style>` ({} incumplimiento(s)):\n- {}",
fallos.len(),
fallos.join("\n- ")
);
}
#[test]
fn la_hoja_default_es_la_del_asset() {
let (bytes, content_type) = embedded::asset("/css/error.css")
.expect("la hoja `/css/error.css` tiene que estar en la tabla de assets");
assert_eq!(
content_type, "text/css",
"Content-Type del asset de la hoja"
);
let default = default_theme();
assert_eq!(
default.sheet_path, "css/error.css",
"la hoja del tema `default` es el asset público"
);
assert!(
std::ptr::eq(default.embedded, bytes),
"el tema `default` y el asset tienen que **compartir la hoja** —el mismo puntero, no \
solo los mismos bytes—, pero `THEMES[0].embedded` y el asset apuntan a direcciones \
distintas"
);
}
}