use crate::embedded;
use std::borrow::Cow;
use std::io::ErrorKind;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct Theme {
pub name: Cow<'static, str>,
pub embedded: Option<&'static str>,
}
pub static INCORPORATED: [Theme; 7] = [
Theme {
name: Cow::Borrowed(DEFAULT_THEME_NAME),
embedded: Some(embedded::DEFAULT_PAGE),
},
Theme {
name: Cow::Borrowed("minimal"),
embedded: Some(embedded::MINIMAL_PAGE),
},
Theme {
name: Cow::Borrowed("terminal"),
embedded: Some(embedded::TERMINAL_PAGE),
},
Theme {
name: Cow::Borrowed("paper"),
embedded: Some(embedded::PAPER_PAGE),
},
Theme {
name: Cow::Borrowed("amber"),
embedded: Some(embedded::AMBER_PAGE),
},
Theme {
name: Cow::Borrowed("compact"),
embedded: Some(embedded::COMPACT_PAGE),
},
Theme {
name: Cow::Borrowed("matrix"),
embedded: Some(embedded::MATRIX_PAGE),
},
];
pub const DEFAULT_THEME_NAME: &str = "default";
#[derive(Debug, Clone)]
pub struct Themes {
temas: Vec<Theme>,
}
impl Themes {
pub fn incorporados() -> Self {
Self {
temas: INCORPORATED.to_vec(),
}
}
pub fn descubrir(content_dir: &Path) -> Self {
let mut registro = Self::incorporados();
let mut descubiertos: Vec<Theme> = Vec::new();
match std::fs::read_dir(content_dir) {
Ok(entradas) => {
for entrada in entradas.filter_map(Result::ok) {
if let Some(tema) = tema_descubierto(&entrada) {
descubiertos.push(tema);
}
}
}
Err(err) if err.kind() == ErrorKind::NotFound => {}
Err(err) => {
tracing::warn!(
dir = %content_dir.display(),
error = %err,
"no se pudo listar el directorio de contenido: se sirven solo los temas incorporados"
);
}
}
descubiertos.sort_by(|a, b| a.name.as_ref().cmp(b.name.as_ref()));
registro.temas.extend(descubiertos);
registro
}
pub fn by_name(&self, name: &str) -> Option<&Theme> {
self.temas.iter().find(|theme| theme.name == name)
}
pub fn names(&self) -> String {
self.iter()
.map(|theme| theme.name.as_ref())
.collect::<Vec<_>>()
.join(", ")
}
pub fn iter(&self) -> std::slice::Iter<'_, Theme> {
self.temas.iter()
}
}
fn tema_descubierto(entrada: &std::fs::DirEntry) -> Option<Theme> {
let path = entrada.path();
if !path.is_file() {
return None;
}
if path.extension().and_then(|ext| ext.to_str()) != Some("html") {
return None;
}
let stem = path.file_stem().and_then(|stem| stem.to_str())?;
if stem.is_empty() {
return None;
}
if INCORPORATED.iter().any(|tema| tema.name == stem) {
return None;
}
let file_name = path.file_name().and_then(|name| name.to_str())?;
if crate::resolve::code_from_path(&format!("/{file_name}")).is_some() {
tracing::warn!(
file = %path.display(),
"el nombre del fichero reclama la resolución de códigos: no se registra como tema"
);
return None;
}
Some(Theme {
name: Cow::Owned(stem.to_string()),
embedded: None,
})
}
pub fn default_theme() -> &'static Theme {
&INCORPORATED[0]
}
pub fn resolve<'a>(
registry: &'a Themes,
requested: Option<&str>,
configured: &'a Theme,
) -> &'a Theme {
requested
.and_then(|nombre| registry.by_name(nombre))
.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 => match self.embedded {
Some(embedded) => Ok(embedded.as_bytes().to_vec()),
None => {
tracing::warn!(
theme = %self.name,
path = %path.display(),
"el tema descubierto no tiene fichero ni copia embebida: se responderá el respaldo de texto"
);
Err(err)
}
},
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, INCORPORATED, Incumplimiento, Theme, Themes, default_theme,
incumplimientos, resolve,
};
use std::fs;
use std::path::PathBuf;
fn content_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("content")
}
fn embebida(theme: &Theme) -> &'static str {
theme
.embedded
.expect("un tema incorporado siempre tiene copia embebida")
}
fn fixtures_dir(nombre: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures")
.join(nombre)
}
fn descubrir(fixture: &str) -> Themes {
Themes::descubrir(&fixtures_dir(fixture))
}
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_incorporados() {
assert_eq!(
INCORPORATED.len(),
NOMBRES.len(),
"`INCORPORATED` tiene que declarar los siete temas incorporados"
);
assert_eq!(
INCORPORATED[0].name, DEFAULT_THEME_NAME,
"`INCORPORATED[0]` tiene que ser el tema por defecto"
);
for (theme, nombre) in INCORPORATED.iter().zip(NOMBRES) {
assert_eq!(
theme.name, nombre,
"el orden de `INCORPORATED` tiene que ser el de la lista de nombres"
);
}
for theme in INCORPORATED.iter() {
assert!(
theme.embedded.is_some(),
"cada tema incorporado tiene que llevar su copia embebida (`embedded` `Some`)"
);
}
assert_eq!(
default_theme().name,
DEFAULT_THEME_NAME,
"`default_theme()` tiene que ser el tema por defecto (el primero)"
);
}
#[test]
fn by_name_solo_acepta_los_incorporados() {
let registro = Themes::incorporados();
for nombre in NOMBRES {
assert!(
registro.by_name(nombre).is_some(),
"`by_name` tiene que encontrar el tema `{nombre}`"
);
}
for desconocido in [
"",
"neon",
"Default",
"TERMINAL",
"Themes/default",
"default ",
] {
assert!(
registro.by_name(desconocido).is_none(),
"`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 registro = Themes::incorporados();
let configurado = registro
.by_name("minimal")
.expect("`minimal` es uno de los siete");
assert_eq!(
resolve(®istro, Some("terminal"), configurado).name,
"terminal",
"un `X-Theme` conocido tiene que ganar al tema configurado"
);
assert_eq!(
resolve(®istro, Some("neon"), configurado).name,
"minimal",
"un `X-Theme` desconocido cae al configurado, no a otro tema"
);
assert_eq!(
resolve(®istro, Some(""), configurado).name,
"minimal",
"un `X-Theme` vacío no es un tema: cae al configurado"
);
assert_eq!(
resolve(®istro, None, configurado).name,
"minimal",
"sin `X-Theme` el tema es el configurado"
);
assert_eq!(
resolve(®istro, 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 INCORPORATED.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 INCORPORATED.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!(
embebida(theme).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 registro = Themes::incorporados();
let theme = registro
.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 INCORPORATED.iter() {
let pagina = theme
.page(&dir)
.expect("un fichero ausente no es un fallo: cae a la copia embebida");
assert_eq!(
pagina.as_slice(),
embebida(theme).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 registro = Themes::incorporados();
let theme = registro
.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();
let registro = Themes::incorporados();
for nombre in SOBRIAS {
let theme = registro.by_name(nombre).expect("es uno de los siete");
let css = String::from_utf8_lossy(estilo(embebida(theme).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 registro = Themes::incorporados();
let matrix = registro
.by_name("matrix")
.expect("`matrix` es uno de los siete");
assert!(
!SOBRIAS.contains(&matrix.name.as_ref()),
"`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 INCORPORATED.iter() {
for incumplimiento in incumplimientos(embebida(theme).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 INCORPORATED.iter() {
for incumplimiento in incumplimientos(embebida(theme).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));
}
#[test]
fn el_registro_enumera_los_nombres_en_orden() {
assert_eq!(
Themes::incorporados().names(),
NOMBRES.join(", "),
"`names()` tiene que enumerar los siete incorporados en orden"
);
}
#[test]
fn un_fichero_de_mas_es_un_tema() {
let registro = descubrir("temas-extras");
let neon = registro
.by_name("neon")
.expect("`neon.html` en la raíz de CONTENT_DIR es un tema");
assert!(
neon.embedded.is_none(),
"un tema descubierto **no** tiene copia embebida: su única copia es su fichero"
);
assert_eq!(neon.page_path(), "neon.html");
assert_eq!(
registro.names(),
"default, minimal, terminal, paper, amber, compact, matrix, neon",
"los incorporados van primero y el descubierto detrás"
);
}
#[test]
fn un_fichero_con_el_tallo_de_un_incorporado_no_anade_tema() {
let registro = descubrir("temas-extras");
assert_eq!(
registro.iter().filter(|t| t.name == "default").count(),
1,
"`default.html` es la página del `default` incorporado en el disco, no un `default` \
de más"
);
let default = registro
.by_name("default")
.expect("`default` sigue en el registro");
assert!(
default.embedded.is_some(),
"el `default` del registro es el incorporado: conserva su copia embebida"
);
}
#[test]
fn un_fichero_con_nombre_de_codigo_no_es_un_tema() {
let registro = descubrir("temas-extras");
for nombre in ["404", "999", "200"] {
assert!(
registro.by_name(nombre).is_none(),
"`{nombre}.html` no es un tema: su path lo reclama la resolución de códigos, así \
que no se serviría ni como asset ni como página de tema (`/999.html` o \
`/200.html` responderían el código por defecto, que es otra página)"
);
}
}
#[test]
fn solo_se_mira_la_raiz_de_content_dir() {
let registro = descubrir("temas-extras");
assert!(
registro.by_name("profundo").is_none(),
"un fichero en `temas/profundo.html` no es un tema: el nombre de un tema no es una ruta"
);
assert!(
registro.by_name("neon").is_some(),
"`neon.html` está en la raíz del directorio, así que sí es un tema"
);
}
#[test]
fn la_extension_tiene_que_ser_html() {
let registro = descubrir("temas-extension");
assert_eq!(
registro.names(),
NOMBRES.join(", "),
"el registro tiene que quedarse con los siete incorporados"
);
assert_eq!(
registro.iter().count(),
7,
"`neon.htm`, `neon.HTML` y `neon.css` no añaden ningún tema"
);
assert!(
registro.by_name("neon").is_none(),
"`neon.htm`, `neon.HTML` y `neon.css` no son temas porque la extensión tiene que ser \
exactamente `html`"
);
}
#[test]
fn un_directorio_que_no_existe_da_los_siete() {
let registro = Themes::descubrir(&fixtures_dir("inexistente-y-ademas-inexistente"));
assert_eq!(
registro.names(),
NOMBRES.join(", "),
"un CONTENT_DIR que no existe da los siete incorporados **sin** fallar"
);
}
#[test]
fn un_directorio_que_no_se_puede_listar_deja_los_siete() {
let registro = Themes::descubrir(&fixtures_dir("temas-extras").join("neon.html"));
assert_eq!(
registro.names(),
NOMBRES.join(", "),
"un CONTENT_DIR que existe y no se puede listar deja los siete incorporados sin fallar"
);
}
#[cfg(unix)]
#[test]
fn un_enlace_simbolico_a_un_fichero_legible_es_un_tema() {
use std::os::unix::fs::symlink;
let dir = dir_temporal("enlaces");
symlink(
fixtures_dir("temas-extras").join("neon.html"),
dir.join("enlazado.html"),
)
.expect("el enlace al fixture tiene que poder crearse");
symlink(dir.join("no-existe.html"), dir.join("roto.html"))
.expect("el enlace roto tiene que poder crearse");
symlink(&dir, dir.join("a-directorio.html"))
.expect("el enlace al directorio tiene que poder crearse");
let registro = Themes::descubrir(&dir);
assert!(
registro.by_name("enlazado").is_some(),
"un enlace simbólico a un fichero legible es un tema"
);
assert!(
registro.by_name("roto").is_none(),
"un enlace roto no es un tema: apunta a un destino que no existe"
);
assert!(
registro.by_name("a-directorio").is_none(),
"un enlace a un directorio no es un tema"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn el_tema_descubierto_cuyo_fichero_desaparece_es_un_error_de_e_s() {
let dir = dir_temporal("descubierto-desaparece");
let bytes = fs::read(fixtures_dir("temas-extras").join("neon.html"))
.expect("el fixture tiene que poder leerse");
fs::write(dir.join("neon.html"), &bytes).expect("el fixture tiene que poder escribirse");
let registro = Themes::descubrir(&dir);
let neon = registro
.by_name("neon")
.expect("`neon.html` es un tema descubierto");
let con_fichero = neon
.page(&dir)
.expect("con el fichero presente, la página se lee");
assert_eq!(
con_fichero, bytes,
"con el fichero presente, `page` devuelve sus bytes"
);
fs::remove_file(dir.join("neon.html")).expect("el fichero tiene que poder borrarse");
assert!(
neon.page(&dir).is_err(),
"un tema descubierto cuyo fichero desaparece es un error de E/S: no tiene copia \
embebida a la que caer, así que la respuesta del servicio será el respaldo de texto \
con el estado intacto, y **no** un 500"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn la_ruta_sale_del_nombre_del_registro() {
let registro = descubrir("temas-extras");
let configurado = registro
.by_name("minimal")
.expect("`minimal` es uno de los siete");
for intento in [
"../../etc/passwd",
"/etc/passwd",
"..",
"neon/../../x",
"neon.html",
] {
let elegido = resolve(®istro, Some(intento), configurado);
assert_eq!(
elegido.name, "minimal",
"`X-Theme: {intento}` no es un nombre del registro: tiene que caer al configurado"
);
assert_eq!(
elegido.page_path(),
"minimal.html",
"la ruta sale del registro, nunca del valor de `X-Theme`: `{intento}` no puede \
componer una ruta"
);
}
}
#[test]
fn anadir_un_tema_con_el_servicio_en_marcha_no_lo_convierte_en_tema() {
let dir = dir_temporal("anadir-en-marcha");
let registro = Themes::descubrir(&dir);
assert!(
registro.by_name("nuevo").is_none(),
"en un directorio vacío el registro son los siete incorporados y nada más"
);
assert_eq!(
registro.names(),
NOMBRES.join(", "),
"sin ningún fichero, el registro enumera los siete incorporados"
);
fs::write(dir.join("nuevo.html"), b"<p>tema que llega tarde</p>")
.expect("el tema tiene que poder escribirse");
assert!(
registro.by_name("nuevo").is_none(),
"el registro ya construido no ve un fichero que apareció después: añadir un tema exige \
reiniciar"
);
assert_eq!(
registro.names(),
NOMBRES.join(", "),
"el registro que ya existe sigue enumerando los siete: no ha cambiado"
);
let siguiente = Themes::descubrir(&dir);
assert!(
siguiente.by_name("nuevo").is_some(),
"un `nuevo.html` presente al arrancar sí es un tema"
);
assert_eq!(
siguiente.names(),
format!("{}, nuevo", NOMBRES.join(", ")),
"el registro del arranque siguiente enumera los siete y, detrás, el descubierto"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn los_descubiertos_van_tras_los_siete_y_ordenados_por_nombre() {
let dir = dir_temporal("orden-alfabetico");
for nombre in ["alfa", "media", "zeta"] {
fs::write(dir.join(format!("{nombre}.html")), b"<p>tema</p>")
.expect("el tema tiene que poder escribirse");
}
let registro = Themes::descubrir(&dir);
assert_eq!(
registro.names(),
"default, minimal, terminal, paper, amber, compact, matrix, alfa, media, zeta",
"los descubiertos van **tras** los siete incorporados y **ordenados por nombre**, no \
en el orden del sistema de ficheros"
);
let _ = fs::remove_dir_all(&dir);
}
}