use crate::embedded;
use std::io::ErrorKind;
use std::path::Path;
#[derive(Debug)]
pub struct Theme {
pub name: &'static str,
pub embedded: &'static str,
}
pub static THEMES: [Theme; 7] = [
Theme {
name: DEFAULT_THEME_NAME,
embedded: embedded::DEFAULT_PAGE,
},
Theme {
name: "minimal",
embedded: embedded::MINIMAL_PAGE,
},
Theme {
name: "terminal",
embedded: embedded::TERMINAL_PAGE,
},
Theme {
name: "paper",
embedded: embedded::PAPER_PAGE,
},
Theme {
name: "amber",
embedded: embedded::AMBER_PAGE,
},
Theme {
name: "compact",
embedded: embedded::COMPACT_PAGE,
},
Theme {
name: "matrix",
embedded: embedded::MATRIX_PAGE,
},
];
pub const DEFAULT_THEME_NAME: &str = "default";
pub const THEME_NAMES: &str = "default, minimal, terminal, paper, amber, compact, matrix";
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 page_path(&self) -> String {
format!("{}.html", self.name)
}
pub fn page(&self, content_dir: &Path) -> Result<Vec<u8>, std::io::Error> {
let path = content_dir.join(self.page_path());
match std::fs::read(&path) {
Ok(bytes) => Ok(bytes),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(self.embedded.as_bytes().to_vec()),
Err(err) => {
tracing::warn!(
theme = self.name,
path = %path.display(),
error = %err,
"no se pudo leer la página del tema: se responderá el respaldo de texto"
);
Err(err)
}
}
}
}
#[cfg(test)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Incumplimiento {
Peticion(&'static str),
Movimiento(&'static str),
}
#[cfg(test)]
pub fn incumplimientos(pagina: &[u8]) -> Vec<Incumplimiento> {
let mut fallos = Vec::new();
let pagina = pagina_sin_comentarios(pagina);
if let Some(token) = token_que_pide(&pagina) {
fallos.push(Incumplimiento::Peticion(token));
}
if let Some(token) = token_que_anima(&pagina) {
fallos.push(Incumplimiento::Movimiento(token));
}
fallos
}
#[cfg(test)]
fn pagina_sin_comentarios(pagina: &[u8]) -> Vec<u8> {
let mut salida = Vec::with_capacity(pagina.len());
let mut i = 0;
let mut region: Option<&'static str> = None;
while i < pagina.len() {
if pagina[i..].starts_with(b"<!--") {
match busca(&pagina[i + 4..], b"-->") {
Some(fin) => i += 4 + fin + 3,
None => return salida,
}
continue;
}
match region {
None => {
if let Some((false, nombre, fin)) = etiqueta_de_region(pagina, i) {
salida.extend_from_slice(&pagina[i..fin]);
region = Some(nombre);
i = fin;
continue;
}
salida.push(pagina[i]);
i += 1;
}
Some(nombre) => {
if let Some((true, cerrada, fin)) = etiqueta_de_region(pagina, i)
&& cerrada == nombre
{
salida.extend_from_slice(&pagina[i..fin]);
region = None;
i = fin;
continue;
}
if pagina[i] == b'/' && pagina.get(i + 1) == Some(&b'*') {
match busca(&pagina[i + 2..], b"*/") {
Some(cierre) => i += 2 + cierre + 2,
None => return salida,
}
continue;
}
if nombre == "script" && pagina[i] == b'/' && pagina.get(i + 1) == Some(&b'/') {
let desde = i + 2;
match pagina[desde..].iter().position(|&byte| byte == b'\n') {
Some(fin) => i = desde + fin,
None => return salida,
}
continue;
}
if matches!(pagina[i], b'"' | b'\'' | b'`') {
let comilla = pagina[i];
salida.push(comilla);
i += 1;
while i < pagina.len() {
let byte = pagina[i];
salida.push(byte);
i += 1;
if byte == b'\\' {
if let Some(escapado) = pagina.get(i) {
salida.push(*escapado);
i += 1;
}
} else if byte == comilla {
break;
}
}
continue;
}
salida.push(pagina[i]);
i += 1;
}
}
}
salida
}
#[cfg(test)]
fn etiqueta_de_region(pagina: &[u8], i: usize) -> Option<(bool, &'static str, usize)> {
if pagina.get(i) != Some(&b'<') {
return None;
}
let mut j = i + 1;
let cierre = pagina.get(j) == Some(&b'/');
if cierre {
j += 1;
}
let inicio = j;
while pagina
.get(j)
.copied()
.is_some_and(|byte| byte.is_ascii_alphabetic())
{
j += 1;
}
let nombre = &pagina[inicio..j];
let nombre = if nombre.eq_ignore_ascii_case(b"script") {
"script"
} else if nombre.eq_ignore_ascii_case(b"style") {
"style"
} else {
return None;
};
let fin = j + pagina[j..].iter().position(|&byte| byte == b'>')? + 1;
Some((cierre, nombre, fin))
}
#[cfg(test)]
fn token_que_pide(pagina: &[u8]) -> Option<&'static str> {
if contiene(pagina, "@import") {
return Some("@import");
}
if tiene_url_externa(pagina) {
return Some("url(");
}
if let Some(token) = etiqueta_que_pide(pagina) {
return Some(token);
}
if contiene_ignore_case(pagina, "srcset") {
return Some("srcset");
}
TOKENS_JS
.iter()
.copied()
.find(|token| contiene(pagina, token))
}
#[cfg(test)]
const ETIQUETAS_QUE_PIDEN: [(&str, &str, &str); 10] = [
("script", "src", "<script src="),
("img", "src", "<img src="),
("source", "src", "<source src="),
("video", "src", "<video src="),
("audio", "src", "<audio src="),
("iframe", "src", "<iframe src="),
("embed", "src", "<embed src="),
("object", "data", "<object data="),
("form", "action", "<form action="),
("link", "href", "<link href="),
];
#[cfg(test)]
const TOKENS_JS: [&str; 6] = [
"fetch(",
"XMLHttpRequest",
"WebSocket",
"EventSource",
"sendBeacon",
"new Image",
];
#[cfg(test)]
fn etiqueta_que_pide(pagina: &[u8]) -> Option<&'static str> {
let mut i = 0;
while i < pagina.len() {
if pagina[i] != b'<' {
i += 1;
continue;
}
let mut j = i + 1;
if pagina.get(j) == Some(&b'/') {
i = j + 1;
continue;
}
let inicio_nombre = j;
while pagina
.get(j)
.copied()
.is_some_and(|byte| byte.is_ascii_alphanumeric())
{
j += 1;
}
let nombre = pagina[inicio_nombre..j].to_ascii_lowercase();
let Some(fin_rel) = pagina[j..].iter().position(|&byte| byte == b'>') else {
break;
};
let fin = j + fin_rel;
let cuerpo = &pagina[j..fin];
if let Some(token) = etiqueta_prohibida(&nombre, cuerpo) {
return Some(token);
}
i = fin + 1;
}
None
}
#[cfg(test)]
fn etiqueta_prohibida(nombre: &[u8], cuerpo: &[u8]) -> Option<&'static str> {
for &(etiqueta, atributo, token) in ETIQUETAS_QUE_PIDEN.iter() {
if nombre != etiqueta.as_bytes() {
continue;
}
let valor = valor_de_atributo(cuerpo, atributo)?;
if etiqueta == "script" || !es_destino_incrustado(&valor) {
return Some(token);
}
return None;
}
None
}
#[cfg(test)]
fn valor_de_atributo(cuerpo: &[u8], atributo: &str) -> Option<Vec<u8>> {
let aguja = atributo.as_bytes();
let mut k = 0;
while let Some(rel) = buscar_ignore_case(cuerpo.get(k..).unwrap_or_default(), aguja) {
let inicio = k + rel;
let tras = inicio + aguja.len();
let ok_antes = inicio == 0 || !es_byte_de_atributo(cuerpo[inicio - 1]);
let ok_despues = cuerpo
.get(tras)
.copied()
.is_none_or(|byte| !es_byte_de_atributo(byte));
if ok_antes && ok_despues {
let mut c = tras;
while cuerpo.get(c).copied().is_some_and(es_espacio) {
c += 1;
}
if cuerpo.get(c) == Some(&b'=') {
c += 1;
while cuerpo.get(c).copied().is_some_and(es_espacio) {
c += 1;
}
return Some(match cuerpo.get(c).copied() {
Some(comilla @ (b'"' | b'\'')) => {
let desde = c + 1;
let mut hasta = desde;
while cuerpo
.get(hasta)
.copied()
.is_some_and(|byte| byte != comilla)
{
hasta += 1;
}
cuerpo[desde..hasta].to_vec()
}
_ => {
let desde = c;
let mut hasta = desde;
while cuerpo
.get(hasta)
.copied()
.is_some_and(|byte| !es_espacio(byte) && byte != b'>')
{
hasta += 1;
}
cuerpo[desde..hasta].to_vec()
}
});
}
}
k = inicio + 1;
}
None
}
#[cfg(test)]
fn es_destino_incrustado(valor: &[u8]) -> bool {
let prefijo: Vec<u8> = valor
.iter()
.skip_while(|byte| byte.is_ascii_whitespace())
.copied()
.take(5)
.collect();
prefijo.starts_with(b"#") || prefijo.starts_with(b"data:")
}
#[cfg(test)]
fn es_byte_de_atributo(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_'
}
#[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(pagina: &[u8]) -> Option<&'static str> {
if let Some(token) = css_que_anima(pagina) {
return Some(token);
}
guion_que_anima(pagina)
}
#[cfg(test)]
fn css_que_anima(pagina: &[u8]) -> Option<&'static str> {
let bloque = bloque_reduce(pagina).filter(|cuerpo| es_bloque_con_contenido(cuerpo));
let mut desde = 0;
while let Some((posicion, propiedad, valor)) = primera_declaracion(pagina, 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 guion_que_anima(pagina: &[u8]) -> Option<&'static str> {
let token = if contiene(pagina, "requestAnimationFrame") {
"requestAnimationFrame"
} else if contiene(pagina, "setInterval") {
"setInterval"
} else {
return None;
};
if contiene(pagina, "prefers-reduced-motion") {
None
} else {
Some(token)
}
}
#[cfg(test)]
fn bloque_reduce(pagina: &[u8]) -> Option<&[u8]> {
let mut cursor = busca(pagina, AGUJA_REDUCE)? + AGUJA_REDUCE.len();
while pagina.get(cursor).copied().is_some_and(es_espacio) {
cursor += 1;
}
if pagina.get(cursor) != Some(&b'{') {
return None;
}
cursor += 1;
let inicio = cursor;
let mut profundidad = 1u32;
while let Some(&byte) = pagina.get(cursor) {
match byte {
b'{' => profundidad += 1,
b'}' => {
profundidad -= 1;
if profundidad == 0 {
return Some(&pagina[inicio..cursor]);
}
}
_ => {}
}
cursor += 1;
}
Some(&pagina[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(pagina: &[u8], propiedad: &str, valor: &[u8]) -> bool {
let mut desde = 0;
while let Some((posicion, encontrado)) = primera_declaracion_de(pagina, propiedad, desde) {
if encontrado == valor {
return true;
}
desde = posicion + propiedad.len();
}
false
}
#[cfg(test)]
fn primera_declaracion(pagina: &[u8], desde: usize) -> Option<(usize, &'static str, &[u8])> {
PROPIEDADES_QUE_ANIMAN
.iter()
.filter_map(|&propiedad| {
let (posicion, valor) = primera_declaracion_de(pagina, propiedad, desde)?;
Some((posicion, propiedad, valor))
})
.min_by_key(|(posicion, _, _)| *posicion)
}
#[cfg(test)]
fn primera_declaracion_de<'a>(
pagina: &'a [u8],
propiedad: &str,
desde: usize,
) -> Option<(usize, &'a [u8])> {
let posicion = posicion_declaracion(pagina, propiedad, desde)?;
Some((
posicion,
primer_token(&pagina[posicion + propiedad.len()..]),
))
}
#[cfg(test)]
fn posicion_declaracion(pagina: &[u8], propiedad: &str, desde: usize) -> Option<usize> {
let aguja = propiedad.as_bytes();
let mut cursor = desde;
loop {
let posicion = cursor + busca(pagina.get(cursor..)?, aguja)?;
if (posicion == 0 || !es_byte_de_identificador(pagina[posicion - 1]))
&& !pagina
.get(posicion + aguja.len())
.copied()
.is_some_and(es_byte_de_identificador)
{
let mut dos_puntos = posicion + aguja.len();
while pagina.get(dos_puntos).copied().is_some_and(es_espacio) {
dos_puntos += 1;
}
if pagina.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(pagina: &[u8]) -> bool {
const AGUJA: &[u8] = b"url(";
let mut desde = 0;
while let Some(pos) = busca(pagina.get(desde..).unwrap_or_default(), AGUJA) {
let inicio = desde + pos + AGUJA.len();
let destino = pagina.get(inicio..).unwrap_or_default();
if !es_destino_incrustado(destino) {
return true;
}
desde = inicio;
}
false
}
#[cfg(test)]
fn contiene(pajar: &[u8], aguja: &str) -> bool {
String::from_utf8_lossy(pajar).contains(aguja)
}
#[cfg(test)]
fn contiene_ignore_case(pajar: &[u8], aguja: &str) -> bool {
buscar_ignore_case(pajar, aguja.as_bytes()).is_some()
}
#[cfg(test)]
fn buscar_ignore_case(pajar: &[u8], aguja: &[u8]) -> Option<usize> {
if aguja.is_empty() || aguja.len() > pajar.len() {
return None;
}
pajar
.windows(aguja.len())
.position(|ventana| ventana.eq_ignore_ascii_case(aguja))
}
#[cfg(test)]
fn busca(pajar: &[u8], aguja: &[u8]) -> Option<usize> {
if aguja.is_empty() || aguja.len() > pajar.len() {
return None;
}
pajar
.windows(aguja.len())
.position(|ventana| ventana == aguja)
}
#[cfg(test)]
mod tests {
use super::{
DEFAULT_THEME_NAME, Incumplimiento, THEME_NAMES, THEMES, default_theme, incumplimientos,
resolve, theme_by_name,
};
use std::fs;
use std::path::PathBuf;
fn content_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("content")
}
const NOMBRES: [&str; 7] = [
"default", "minimal", "terminal", "paper", "amber", "compact", "matrix",
];
const SOBRIAS: [&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 estilo(pagina: &[u8]) -> &[u8] {
let Some(inicio) = super::busca(pagina, b"<style") else {
return pagina;
};
let desde = &pagina[inicio..];
let Some(abre) = desde.iter().position(|&byte| byte == b'>') else {
return pagina;
};
let cuerpo = &desde[abre + 1..];
let Some(cierra) = super::busca(cuerpo, b"</style>") else {
return pagina;
};
&cuerpo[..cierra]
}
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(pagina: &str) -> bool {
incumplimientos(pagina.as_bytes())
.iter()
.any(|i| matches!(i, Incumplimiento::Movimiento(_)))
}
fn incumple_red(pagina: &str) -> bool {
incumplimientos(pagina.as_bytes())
.iter()
.any(|i| matches!(i, Incumplimiento::Peticion(_)))
}
#[test]
fn la_tabla_declara_los_siete_temas() {
assert_eq!(
THEMES.len(),
NOMBRES.len(),
"`THEMES` tiene que declarar los siete 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 siete nombres: {THEME_NAMES}"
);
}
#[test]
fn theme_by_name_solo_acepta_los_siete() {
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 siete");
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_nombre_deriva_su_ruta_y_tiene_su_fichero() {
for theme in THEMES.iter() {
let esperada = format!("{}.html", theme.name);
assert_eq!(
theme.page_path(),
esperada,
"la ruta de la página del tema `{}` tiene que derivarse de su nombre",
theme.name
);
let fichero = content_dir().join(&esperada);
assert!(
fichero.is_file(),
"el tema `{}` tiene que tener su página en {}",
theme.name,
fichero.display()
);
}
}
#[test]
fn las_paginas_embebidas_son_byte_a_byte_las_de_content() {
for theme in THEMES.iter() {
let ruta = content_dir().join(theme.page_path());
let del_disco = match fs::read(&ruta) {
Ok(bytes) => bytes,
Err(error) => panic!(
"no se pudo leer {} (tema `{}`): {error}",
ruta.display(),
theme.name
),
};
assert_eq!(
theme.embedded.as_bytes(),
del_disco.as_slice(),
"la página embebida del tema `{}` no coincide byte a byte con {}",
theme.name,
ruta.display()
);
}
}
fn dir_temporal(nombre: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("errserve-theme-{}-{nombre}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("el directorio temporal tiene que poder crearse");
dir
}
#[test]
fn la_pagina_legible_del_disco_gana_a_la_embebida() {
let dir = dir_temporal("legible");
let propia = b"<p>pagina propia del operador, distinguible</p>".to_vec();
fs::write(dir.join("matrix.html"), &propia).expect("el fixture tiene que poder escribirse");
let theme = theme_by_name("matrix").expect("`matrix` es uno de los siete");
assert_eq!(
theme
.page(&dir)
.expect("la página legible del disco tiene que ganar"),
propia,
"un fichero legible en CONTENT_DIR/<nombre>.html tiene que ganar a la copia embebida"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn la_pagina_ausente_cae_a_la_embebida_sin_warn() {
let dir = dir_temporal("ausente");
for theme in THEMES.iter() {
let pagina = theme
.page(&dir)
.expect("un fichero ausente no es un fallo: cae a la copia embebida");
assert_eq!(
pagina.as_slice(),
theme.embedded.as_bytes(),
"sin fichero en el disco, el tema `{}` tiene que servir su copia embebida",
theme.name
);
}
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn la_pagina_presente_pero_ilegible_es_un_error_de_e_s() {
let dir = dir_temporal("ilegible");
fs::create_dir(dir.join("matrix.html")).expect("el fixture tiene que poder crearse");
let theme = theme_by_name("matrix").expect("`matrix` es uno de los siete");
assert!(
theme.page(&dir).is_err(),
"una página del operador que existe y no se puede leer tiene que ser un error de E/S, \
no la copia embebida"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn cada_pagina_sobria_cubre_el_marcado() {
let mut fallos = Vec::new();
for nombre in SOBRIAS {
let theme = theme_by_name(nombre).expect("es uno de los siete");
let css = String::from_utf8_lossy(estilo(theme.embedded.as_bytes())).into_owned();
for selector in ["body", "h1", "h2", "hr", "p", "footer"] {
if !tiene_regla(&css, selector) {
fallos.push(format!(
"la página del tema `{nombre}` no estila `{selector}`"
));
}
}
}
assert!(
fallos.is_empty(),
"cada página sobria tiene que cubrir el marcado ({} incumplimiento(s)):\n- {}",
fallos.len(),
fallos.join("\n- ")
);
}
#[test]
fn matrix_queda_fuera_de_la_cobertura_de_marcado() {
let matrix = theme_by_name("matrix").expect("`matrix` es uno de los siete");
assert!(
!SOBRIAS.contains(&matrix.name),
"`matrix` no puede estar en la lista de las seis sobrias"
);
assert_eq!(matrix.page_path(), "matrix.html");
}
#[test]
fn ninguna_pagina_puede_hacer_una_peticion() {
let mut fallos = Vec::new();
for theme in THEMES.iter() {
for incumplimiento in incumplimientos(theme.embedded.as_bytes()) {
if let Incumplimiento::Peticion(token) = incumplimiento {
fallos.push(format!(
"la página del tema `{}` contiene `{token}`: el navegador tendría que \
pedir algo por la red",
theme.name
));
}
}
}
assert!(
fallos.is_empty(),
"ninguna página puede hacer que el navegador pida algo por la red ({} \
incumplimiento(s)):\n- {}",
fallos.len(),
fallos.join("\n- ")
);
}
const AUTOCONTENIDA: &str = r#"
<style>.caja { fill: url(#gradiente); }</style>
<canvas id="lluvia"></canvas>
<script>var ctx = document.querySelector('canvas').getContext('2d'); ctx.fillRect(0,0,1,1);</script>
"#;
const CON_DATOS: &str = r#"
<style>body { background: url(data:image/png;base64,AAAA); } .a { fill: url(#gradiente); }</style>
<img src="data:image/png;base64,AAAA" alt="" />
"#;
const COMENTADA: &str = r#"
<!-- Esta página no usa `fetch(` ni `<script src=`. -->
<style>body { color: #222; }</style>
"#;
const COMENTARIO_ABIERTO: &str = r#"
<!-- sin cerrar: `fetch(` y `<script src=` aquí dentro ya no son etiquetas
<style>body { color: #222; }</style>
"#;
const CADENA_CON_BARRAS: &str = r#"
<style>a::before { content: "/*"; } body { background: url(foto.png); }</style>
"#;
#[test]
fn una_pagina_autocontenida_no_hace_ninguna_peticion() {
assert!(
incumplimientos(AUTOCONTENIDA.as_bytes()).is_empty(),
"una página con su estilo, su canvas y su guion, sin tokens de la lista cerrada, \
tiene que cumplir. Página: {AUTOCONTENIDA:?}"
);
}
#[test]
fn una_referencia_interna_y_un_dato_incrustado_no_son_una_peticion() {
assert!(
incumplimientos(CON_DATOS.as_bytes()).is_empty(),
"`url(#…)`, `url(data:…)` y `<img src=\"data:…\">` no son peticiones: no hay nada que \
el navegador tenga que pedir. Página: {CON_DATOS:?}"
);
}
#[test]
fn una_peticion_en_el_guion_incumple() {
for (pagina, token) in [
(r#"<script>fetch('/api');</script>"#, "fetch("),
(
r#"<script>var x = new XMLHttpRequest();</script>"#,
"XMLHttpRequest",
),
(
r#"<script>var ws = new WebSocket('wss://ejemplo');</script>"#,
"WebSocket",
),
(
r#"<script>new EventSource('/sse');</script>"#,
"EventSource",
),
(
r#"<script>navigator.sendBeacon('/x', 'y');</script>"#,
"sendBeacon",
),
(
r#"<script>var i = new Image(); i.src = '/x.png';</script>"#,
"new Image",
),
] {
assert!(
incumplimientos(pagina.as_bytes()).contains(&Incumplimiento::Peticion(token)),
"esta página pide algo con `{token}` y tiene que incumplirlo **nombrando ese \
token**: {pagina:?}"
);
}
}
#[test]
fn un_recurso_externo_en_el_marcado_incumple() {
for (pagina, token) in [
(
r#"<script src="https://ejemplo/x.js"></script>"#,
"<script src=",
),
(r#"<img src="/foto.png" alt="" />"#, "<img src="),
(
r#"<link rel="stylesheet" href="https://ejemplo/x.css" />"#,
"<link href=",
),
(r#"<iframe src="/marco"></iframe>"#, "<iframe src="),
(r#"<source src="/v.mp4" />"#, "<source src="),
(r#"<video src="/v.mp4"></video>"#, "<video src="),
(r#"<audio src="/a.mp3"></audio>"#, "<audio src="),
(r#"<embed src="/x" />"#, "<embed src="),
(r#"<object data="/x"></object>"#, "<object data="),
(r#"<form action="/enviar"></form>"#, "<form action="),
(r#"<img srcset="/x1.png 1x" alt="" />"#, "srcset"),
] {
assert!(
incumplimientos(pagina.as_bytes()).contains(&Incumplimiento::Peticion(token)),
"esta página carga algo de fuera con `{token}` y tiene que incumplirlo: {pagina:?}"
);
}
}
#[test]
fn un_url_externo_en_un_estilo_incrustado_incumple() {
assert!(
incumplimientos(br#"<style>body { background: url(fondo.png); }</style>"#)
.contains(&Incumplimiento::Peticion("url(")),
"un `url(fondo.png)` en un `<style>` incrustado pide un fichero y tiene que incumplir"
);
assert!(
incumplimientos(br#"<style>@import "otra.css";</style>"#)
.contains(&Incumplimiento::Peticion("@import")),
"un `@import` —en su forma sin `url(`— carga otra hoja y tiene que incumplir"
);
}
#[test]
fn un_comentario_no_es_una_peticion() {
assert!(
incumplimientos(COMENTADA.as_bytes()).is_empty(),
"un `fetch(` o un `<script src=` dentro de un comentario no son el contrato: un \
comentario no puede hacer que el navegador pida nada. Página: {COMENTADA:?}"
);
assert!(
incumplimientos(COMENTARIO_ABIERTO.as_bytes()).is_empty(),
"un comentario que abre y no cierra llega hasta el final de la página: lo que queda \
detrás es prosa, no etiquetas. Página: {COMENTARIO_ABIERTO:?}"
);
}
#[test]
fn una_etiqueta_en_mayusculas_incumple() {
assert!(
incumplimientos(br#"<SCRIPT SRC="https://ejemplo/x.js"></SCRIPT>"#)
.contains(&Incumplimiento::Peticion("<script src=")),
"`<SCRIPT SRC=…>` tiene que incumplir: HTML no distingue mayúsculas"
);
}
#[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 válido, no un comentario: no puede tapar el \
`url(foto.png)` que viene detrás. Página: {CADENA_CON_BARRAS:?}"
);
}
#[test]
fn una_barra_doble_en_prosa_html_no_tapa_una_peticion() {
assert!(
incumplimientos(br#"<p>// nota visible</p><img src="/foto.png" alt="" />"#)
.contains(&Incumplimiento::Peticion("<img src=")),
"un `//` en prosa HTML no puede tapar el `<img src=` que viene detrás en la misma línea"
);
assert!(
incumplimientos(br#"<script>var x = 1; // fetch('/tapado')</script>"#).is_empty(),
"un `//` dentro de un guion sí es comentario: su `fetch(` no cuenta"
);
}
const ANIMADA_CON_FRENO: &str = r#"
<style>@keyframes parpadeo { from { opacity: 1 } to { opacity: 0 } }
body { animation: parpadeo 2s infinite; }
@media (prefers-reduced-motion: reduce) { body { animation: none; } }</style>
"#;
const ANIMADA_SIN_FRENO: &str = r#"
<style>body { transition: opacity 0.2s; }</style>
"#;
const QUIETA: &str = r#"
<style>body { color: #222; }</style>
"#;
const NOMBRA_SIN_NEUTRALIZAR: &str = r#"
<style>main { animation: entrada 180ms ease-out; }
@media (prefers-reduced-motion: reduce) { /* esta animación se apaga sola */ }</style>
"#;
const NEUTRALIZA_BAJO_LA_OTRA_PREFERENCIA: &str = r#"
<style>main { animation: entrada 180ms ease-out; }
@media (prefers-reduced-motion: no-preference) { main { animation: none; } }</style>
"#;
const REDUCE_VACIO: &str = r#"
<style>main { animation: entrada 180ms ease-out; }
@media (prefers-reduced-motion: reduce) { }</style>
"#;
const REDUCE_QUE_NEUTRALIZA: &str = r#"
<style>@keyframes entrada { from { opacity: 0 } to { opacity: 1 } }
main { animation: entrada 180ms ease-out; }
@media (prefers-reduced-motion: reduce) { main { animation: none; } }</style>
"#;
const REDUCE_QUE_NEUTRALIZA_OTRA: &str = r#"
<style>main { transition: opacity 200ms; }
@media (prefers-reduced-motion: reduce) { main { animation: none; } }</style>
"#;
const SOLO_FOTOGRAMAS: &str = r#"
<style>@keyframes entrada { from { opacity: 0 } to { opacity: 1 } }</style>
"#;
const REDUCE_CON_REGLA_ANIDADA: &str = r#"
<style>main { animation: entrada 180ms ease-out; }
@media (prefers-reduced-motion: reduce) {
main { color: inherit; }
main { animation: none; }
}</style>
"#;
const TRANSICION_CON_FRENO: &str = r#"
<style>.pastilla { transition: transform 120ms ease; }
@media (prefers-reduced-motion: reduce) { .pastilla { transition: none; } }</style>
"#;
const REDUCE_QUE_ANIMA: &str = r#"
<style>@media (prefers-reduced-motion: reduce) { main { animation: entrada 180ms ease-out; } }</style>
"#;
const DOS_ANIMACIONES_UNA_NEUTRALIZADA: &str = r#"
<style>.glitch { animation: glitch-x 1.6s infinite; }
.caret { animation: parpadeo 1s infinite; }
@media (prefers-reduced-motion: reduce) { .glitch { animation: none; } }</style>
"#;
#[test]
fn una_pagina_quieta_cumple_los_dos_contratos() {
assert!(
incumplimientos(QUIETA.as_bytes()).is_empty(),
"una página que no pide nada y no anima tiene que cumplir: {QUIETA:?}"
);
}
#[test]
fn una_pagina_que_anima_solo_cumple_si_se_puede_apagar() {
assert!(
incumple_movimiento(ANIMADA_SIN_FRENO),
"una página que anima y no declara `prefers-reduced-motion` tiene que incumplir: \
{ANIMADA_SIN_FRENO:?}"
);
assert!(
!incumple_movimiento(ANIMADA_CON_FRENO),
"una página que anima **y** neutraliza dentro del bloque reduce tiene que cumplir: \
{ANIMADA_CON_FRENO:?}"
);
assert!(
!incumple_movimiento(QUIETA),
"una página sin animación tiene que cumplir sin declarar nada: {QUIETA:?}"
);
}
#[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. Página: {REDUCE_VACIO:?}"
);
}
#[test]
fn un_bloque_reduce_que_neutraliza_la_animacion_cumple() {
assert!(
!incumple_movimiento(REDUCE_QUE_NEUTRALIZA),
"una página que anima y neutraliza su animación dentro del bloque reduce tiene que \
cumplir: {REDUCE_QUE_NEUTRALIZA:?}"
);
}
#[test]
fn una_transicion_neutralizada_con_su_forma_cumple() {
assert!(
!incumple_movimiento(TRANSICION_CON_FRENO),
"una transición neutralizada con `transition: none` tiene que cumplir: \
{TRANSICION_CON_FRENO:?}"
);
}
#[test]
fn una_pagina_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. Página: {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. Página: {NEUTRALIZA_BAJO_LA_OTRA_PREFERENCIA:?}"
);
}
#[test]
fn neutralizar_otra_animacion_distinta_de_la_declarada_no_basta() {
assert!(
incumple_movimiento(REDUCE_QUE_NEUTRALIZA_OTRA),
"la página declara una `transition` y el bloque reduce solo trae `animation: none`: \
la transición declarada sigue sin neutralizarse. Página: {REDUCE_QUE_NEUTRALIZA_OTRA:?}"
);
}
#[test]
fn unos_fotogramas_sin_declaracion_que_los_use_no_obligan() {
assert!(
!incumple_movimiento(SOLO_FOTOGRAMAS),
"una página 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. Página: {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_animacion_declarada_dentro_del_bloque_reduce_no_se_neutraliza_sola() {
assert!(
incumple_movimiento(REDUCE_QUE_ANIMA),
"una declaración que anima **dentro** del bloque reduce no se neutraliza sola, así \
que la página tiene que incumplir. Página: {REDUCE_QUE_ANIMA:?}"
);
}
#[test]
fn dos_animaciones_declaradas_y_una_sola_neutralizada_pasan_la_guarda() {
assert!(
!incumple_movimiento(DOS_ANIMACIONES_UNA_NEUTRALIZADA),
"la guarda es **por familia**, no por declaración: con `animation: none` en el bloque \
reduce, la página pasa aunque `.caret` no quede ligada. Página: \
{DOS_ANIMACIONES_UNA_NEUTRALIZADA:?}"
);
}
#[test]
fn un_guion_que_anima_sin_mencionar_la_preferencia_incumple() {
assert!(
incumple_movimiento(
r#"<script>requestAnimationFrame(function f(){ requestAnimationFrame(f); });</script>"#
),
"un guion que anima con `requestAnimationFrame` y no menciona `prefers-reduced-motion` \
tiene que incumplir"
);
assert!(
incumple_movimiento(r#"<script>setInterval(function () {}, 1000);</script>"#),
"un guion que anima con `setInterval` y no menciona la preferencia tiene que incumplir"
);
assert!(
!incumple_movimiento(
r#"<script>var r = matchMedia('(prefers-reduced-motion: reduce)'); if (!r.matches) requestAnimationFrame(f);</script>"#
),
"un guion que anima **y** menciona `prefers-reduced-motion` tiene que cumplir"
);
}
#[test]
fn un_guion_que_menciona_la_preferencia_y_anima_igual_pasa_la_guarda() {
assert!(
!incumple_movimiento(
r#"<script>var r = matchMedia('(prefers-reduced-motion: reduce)'); requestAnimationFrame(function f(){ requestAnimationFrame(f); });</script>"#
),
"un guion que menciona `prefers-reduced-motion` y luego anima igual tiene que **pasar** \
la guarda: mide la mención, no la eficacia de la rama"
);
}
#[test]
fn un_comentario_no_anima() {
assert!(
incumplimientos(
br#"<!-- esta pagina no usa animation ni requestAnimationFrame -->
<style>body { color: #222; }</style>"#
)
.is_empty(),
"mencionar `animation` o `requestAnimationFrame` en un comentario no obliga a nada"
);
}
#[test]
fn ninguna_pagina_que_anima_deja_de_poder_apagarse() {
let mut fallos = Vec::new();
for theme in THEMES.iter() {
for incumplimiento in incumplimientos(theme.embedded.as_bytes()) {
if let Incumplimiento::Movimiento(token) = incumplimiento {
fallos.push(format!(
"la página del tema `{}` anima con `{token}` y no se puede apagar",
theme.name
));
}
}
}
assert!(
fallos.is_empty(),
"una página que anima tiene que poder apagarse ({} incumplimiento(s)):\n- {}",
fallos.len(),
fallos.join("\n- ")
);
}
#[test]
fn incumplimientos_distingue_red_de_movimiento() {
assert!(incumple_red(r#"<img src="/x.png" alt="" />"#));
assert!(!incumple_movimiento(r#"<img src="/x.png" alt="" />"#));
assert!(incumple_movimiento(ANIMADA_SIN_FRENO));
assert!(!incumple_red(ANIMADA_SIN_FRENO));
}
}