diff options
Diffstat (limited to 'crates/asist-app/tests')
| -rw-r--r-- | crates/asist-app/tests/integration.rs (renamed from crates/asist-app/tests/integracion.rs) | 318 |
1 files changed, 155 insertions, 163 deletions
diff --git a/crates/asist-app/tests/integracion.rs b/crates/asist-app/tests/integration.rs index d0fca38..0fdaf89 100644 --- a/crates/asist-app/tests/integracion.rs +++ b/crates/asist-app/tests/integration.rs @@ -1,12 +1,11 @@ -//! Pruebas contra los servidores de verdad. +//! Tests against the real servers. //! -//! Se saltan solas cuando no hay nada escuchando, para que `cargo test` siga -//! siendo útil sin 5 GB de modelos cargados. Con los servidores arriba -//! comprueban lo que las pruebas unitarias no pueden: que el protocolo que se -//! ha escrito es el que los servidores hablan, y que las latencias siguen -//! donde se midieron. +//! They skip themselves when nothing is listening, so `cargo test` stays +//! useful without 5 GB of models loaded. With the servers up they check what +//! unit tests cannot: that the protocol written here is the one the servers +//! speak, and that latencies are still where they were measured. //! -//! Para ejecutarlas: scripts/servidores.sh arrancar && cargo test -- --ignored +//! To run them: scripts/servers.sh start && cargo test -- --ignored use std::path::PathBuf; use std::sync::{Mutex, MutexGuard, OnceLock}; @@ -20,14 +19,14 @@ use asist_llm::{Delta, LlmClient, Message}; use asist_tools::FrameSource; use asist_tts::TtsClient; -/// Las pruebas se turnan la GPU. +/// The tests take turns on the GPU. /// -/// Los dos servidores comparten una tarjeta de 4 GB, y medido: con la prueba -/// de síntesis corriendo a la vez, el primer token del LLM pasa de ~0,5 s a -/// ~5 s. En paralelo esto no mide latencia, mide quién llegó primero. El -/// pipeline de verdad no tiene el problema porque la síntesis no arranca -/// hasta que el modelo cierra su primera frase. -fn en_exclusiva() -> MutexGuard<'static, ()> { +/// Both servers share a 4 GB card, and measured: with the synthesis test +/// running at the same time, the LLM first token goes from ~0.5 s to ~5 s. +/// In parallel this does not measure latency, it measures who got there +/// first. The real pipeline does not have this problem because synthesis +/// does not start until the model closes its first sentence. +fn exclusive() -> MutexGuard<'static, ()> { static GPU: OnceLock<Mutex<()>> = OnceLock::new(); GPU.get_or_init(|| Mutex::new(())) .lock() @@ -36,11 +35,11 @@ fn en_exclusiva() -> MutexGuard<'static, ()> { fn config() -> Config { let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../config/asistente.toml"); - Config::load(path).expect("no se pudo cargar la configuración") + Config::load(path).expect("could not load the configuration") } -/// Devuelve los clientes, o `None` si los servidores no están arriba. -fn clientes() -> Option<(Config, LlmClient, TtsClient)> { +/// Returns the clients, or `None` if the servers are not up. +fn clients() -> Option<(Config, LlmClient, TtsClient)> { let config = config(); let llm = LlmClient::new(config.llm_authority(), &config.llm); let tts = TtsClient::new(config.tts_authority(), &config.tts); @@ -52,9 +51,9 @@ fn clientes() -> Option<(Config, LlmClient, TtsClient)> { } #[test] -fn el_llm_responde_en_streaming_sin_razonar_en_voz_alta() { - let _gpu = en_exclusiva(); - let Some((config, llm, _)) = clientes() else { +fn llm_streams_without_thinking_aloud() { + let _gpu = exclusive(); + let Some((config, llm, _)) = clients() else { return; }; @@ -70,27 +69,27 @@ fn el_llm_responde_en_streaming_sin_razonar_en_voz_alta() { } true }) - .expect("la petición al LLM falló"); + .expect("the LLM request failed"); - assert!(!outcome.text.trim().is_empty(), "respuesta vacía"); - // La plantilla con <think> sin cerrar deja esto en 7-9 s. Si esta prueba - // se cae, casi seguro que llama-server arrancó sin --chat-template-file. - let ttft = ttft.expect("no llegó ningún fragmento con texto"); + assert!(!outcome.text.trim().is_empty(), "empty answer"); + // The template with an unclosed <think> puts this at 7-9 s. If this test + // fails, llama-server was almost certainly started without --chat-template-file. + let ttft = ttft.expect("no fragment with text arrived"); assert!( ttft < Duration::from_secs(3), - "el primer token tardó {ttft:?}: ¿arrancó llama-server con la plantilla sin razonamiento?" + "the first token took {ttft:?}: was llama-server started with the no-reasoning template?" ); assert!( !outcome.text.contains("<think>"), - "el razonamiento se está colando en la respuesta: {}", + "reasoning is leaking into the answer: {}", outcome.text ); } #[test] -fn el_llm_puede_pedir_una_herramienta() { - let _gpu = en_exclusiva(); - let Some((config, llm, _)) = clientes() else { +fn llm_can_request_a_tool() { + let _gpu = exclusive(); + let Some((config, llm, _)) = clients() else { return; }; @@ -105,60 +104,60 @@ fn el_llm_puede_pedir_una_herramienta() { let outcome = llm .stream(&chat, Some(&tools), &Cancel::new(), |_| true) - .expect("la petición al LLM falló"); + .expect("the LLM request failed"); if outcome.tool_calls.is_empty() { - // Un modelo de 2B no siempre acierta a llamar; lo que se comprueba es - // que el ida y vuelta funciona, no que el modelo sea listo. - eprintln!("el modelo no pidió herramienta: {}", outcome.text); + // A 2B model does not always manage to call; what is checked is that the + // round trip works, not that the model is smart. + eprintln!("the model did not ask for a tool: {}", outcome.text); return; } let call = &outcome.tool_calls[0]; assert_eq!(call.name, "hora_actual"); let result = tools.dispatch(call); - assert!(result.ok, "la herramienta falló: {}", result.output); + assert!(result.ok, "the tool failed: {}", result.output); assert!(!result.output.trim().is_empty()); } #[test] -fn la_sintesis_empieza_a_sonar_antes_de_terminar() { - let _gpu = en_exclusiva(); - let Some((config, _, tts)) = clientes() else { +fn synthesis_starts_playing_before_it_finishes() { + let _gpu = exclusive(); + let Some((config, _, tts)) = clients() else { return; }; if let Some(reference) = &config.tts.reference { tts.register_voice(reference) - .expect("no se registró la voz"); + .expect("the voice was not registered"); } - // Sin precalentar, la primera síntesis carga los grafos y mide el arranque - // del servidor en vez del régimen normal. - tts.warmup().expect("falló el precalentado"); + // Without warmup, the first synthesis loads the graphs and measures the + // server startup instead of the steady state. + tts.warmup().expect("warmup failed"); - let mut bloques = 0usize; + let mut blocks = 0usize; let outcome = tts .speak( "Hola, esto es una prueba de latencia del sintetizador de voz.", &Cancel::new(), |samples| { if !samples.is_empty() { - bloques += 1; + blocks += 1; } true }, ) - .expect("la síntesis falló"); + .expect("synthesis failed"); - assert!(outcome.samples > 0, "no se recibió audio"); + assert!(outcome.samples > 0, "no audio received"); assert!( - bloques > 1, - "el audio llegó de una sola vez ({bloques} bloque): revisa --codec-chunk-dur, \ - que de fábrica son 24 s y bloquean el streaming" + blocks > 1, + "the audio arrived all at once ({blocks} block): check --codec-chunk-dur, \ + whose 24 s default blocks streaming" ); - let ttfb = outcome.ttfb.expect("sin marca de primer bloque"); + let ttfb = outcome.ttfb.expect("no first-block mark"); assert!( ttfb < Duration::from_secs(2), - "el primer audio tardó {ttfb:?}; con --codec-chunk-dur 1.0 se midió 585 ms" + "first audio took {ttfb:?}; 585 ms was measured with --codec-chunk-dur 1.0" ); eprintln!( "ttfb={:?} audio={:.2}s rtf={:.2}", @@ -169,120 +168,120 @@ fn la_sintesis_empieza_a_sonar_antes_de_terminar() { } #[test] -fn una_sintesis_se_puede_cortar_a_media_frase() { - let _gpu = en_exclusiva(); - let Some((config, _, tts)) = clientes() else { +fn synthesis_can_be_cut_mid_sentence() { + let _gpu = exclusive(); + let Some((config, _, tts)) = clients() else { return; }; if let Some(reference) = &config.tts.reference { let _ = tts.register_voice(reference); } - // Es la operación que sostiene el barge-in: si no se pudiera cortar, el - // asistente seguiría hablando encima del usuario hasta acabar la frase. + // It is the operation barge-in relies on: if it could not be cut, the + // assistant would keep talking over the user until the sentence ended. let cancel = Cancel::new(); - let mut recibidos = 0usize; + let mut received = 0usize; let outcome = tts .speak( "Esta es una frase larga que no debería llegar a escucharse entera \ porque se va a cortar en cuanto empiece a sonar el primer bloque.", &cancel, |samples| { - recibidos += samples.len(); - // Cortar en el primer bloque. + received += samples.len(); + // Cut at the first block. false }, ) - .expect("la síntesis falló"); + .expect("synthesis failed"); - assert!(outcome.cancelled, "debió quedar marcada como cancelada"); + assert!(outcome.cancelled, "it should have been marked as cancelled"); assert!( outcome.audio_secs() < 3.0, - "se recibieron {:.1} s de audio: el corte no surtió efecto", + "{:.1} s of audio received: the cut had no effect", outcome.audio_secs() ); } #[test] -fn la_voz_clonada_queda_registrada() { - let _gpu = en_exclusiva(); - let Some((config, _, tts)) = clientes() else { +fn cloned_voice_gets_registered() { + let _gpu = exclusive(); + let Some((config, _, tts)) = clients() else { return; }; let Some(reference) = &config.tts.reference else { - eprintln!("sin voz de referencia configurada; prueba omitida"); + eprintln!("no reference voice configured; test skipped"); return; }; tts.register_voice(reference) - .expect("no se registró la voz"); - let voces = tts.voices().expect("no se pudieron listar las voces"); + .expect("the voice was not registered"); + let voices = tts.voices().expect("could not list the voices"); assert!( - voces.contains(&reference.name), - "«{}» no aparece entre {voces:?}", + voices.contains(&reference.name), + "«{}» no aparece entre {voices:?}", reference.name ); } #[test] -#[ignore = "carga el modelo de ASR (~200 MB) y decodifica; lento"] -fn el_asr_transcribe_lo_que_sintetiza_el_tts() { - let _gpu = en_exclusiva(); - // El bucle completo sin micrófono: se sintetiza una frase conocida y se - // comprueba que el reconocedor la recupera. Es la única prueba que ejerce - // ASR y TTS contra el mismo audio. - let Some((config, _, tts)) = clientes() else { +#[ignore = "loads the ASR model (~200 MB) and decodes; slow"] +fn asr_transcribes_what_tts_synthesizes() { + let _gpu = exclusive(); + // The full loop without a microphone: a known sentence is synthesized and + // the recognizer is checked to get it back. It is the only test that + // exercises ASR and TTS on the same audio. + let Some((config, _, tts)) = clients() else { return; }; if let Some(reference) = &config.tts.reference { let _ = tts.register_voice(reference); } - let frase = "hola qué tal estás hoy"; + let sentence = "hola qué tal estás hoy"; let mut audio: Vec<f32> = Vec::new(); - tts.speak(frase, &Cancel::new(), |samples| { + tts.speak(sentence, &Cancel::new(), |samples| { audio.extend_from_slice(samples); true }) - .expect("la síntesis falló"); - assert!(!audio.is_empty(), "no se generó audio"); + .expect("synthesis failed"); + assert!(!audio.is_empty(), "no audio was generated"); let recognizer = - asist_asr::Recognizer::load(&config.asr).expect("no se pudo cargar el modelo de ASR"); - let texto = recognizer + asist_asr::Recognizer::load(&config.asr).expect("could not load the ASR model"); + let text_in = recognizer .transcribe(&audio, asist_tts::SAMPLE_RATE) - .expect("la transcripción falló") + .expect("transcription failed") .to_lowercase(); - eprintln!("dicho: «{frase}» / oído: «{texto}»"); - // No se exige una coincidencia exacta —24 kHz remuestreados a 16 kHz y un - // modelo de 180M dan para lo que dan—, sino que reconozca algo. - let acertadas = frase + eprintln!("said: «{sentence}» / heard: «{text_in}»"); + // An exact match is not required (24 kHz resampled to 16 kHz and a 180M + // model only go so far), just that it recognizes something. + let hits = sentence .split_whitespace() - .filter(|palabra| texto.contains(palabra)) + .filter(|word| text_in.contains(word)) .count(); assert!( - acertadas >= 2, - "sólo se reconocieron {acertadas} palabras de «{frase}» en «{texto}»" + hits >= 2, + "only {hits} words of «{sentence}» were recognized in «{text_in}»" ); } // --------------------------------------------------------------------------- -// Buscar en internet y mirar por la cámara +// Web search and camera // --------------------------------------------------------------------------- #[test] -fn la_busqueda_devuelve_algo_pronunciable() { +fn search_returns_something_speakable() { let config = config(); if !config.search.enabled { - eprintln!("búsqueda desactivada; prueba omitida"); + eprintln!("search disabled; test skipped"); return; } let Ok(key) = std::env::var(&config.search.api_key_env) else { - eprintln!("sin ${}; prueba omitida", config.search.api_key_env); + eprintln!("no ${}; test skipped", config.search.api_key_env); return; }; if key.trim().is_empty() { - eprintln!("${} vacía; prueba omitida", config.search.api_key_env); + eprintln!("${} is empty; test skipped", config.search.api_key_env); return; } @@ -294,29 +293,29 @@ fn la_busqueda_devuelve_algo_pronunciable() { let started = Instant::now(); let out = tool .call(&serde_json::json!({ "consulta": "capital de Australia" })) - .expect("la búsqueda falló"); + .expect("the search failed"); eprintln!( - "búsqueda en {:?}: {}", + "search in {:?}: {}", started.elapsed(), &out[..out.len().min(200)] ); assert!(!out.trim().is_empty()); assert!( out.to_lowercase().contains("canberra"), - "esperaba la respuesta en el resumen, salió: {out}" + "expected the answer in the summary, got: {out}" ); - // Lo que devuelve se lee en voz alta; una parrafada de miles de caracteres - // ahoga al modelo que tiene que resumirla. + // What it returns is read aloud; a wall of thousands of characters drowns + // the model that has to summarize it. assert!( out.len() < 6000, - "el resultado es demasiado largo: {} bytes", + "the result is too long: {} bytes", out.len() ); } #[test] -fn una_consulta_inventada_no_revienta_el_turno() { +fn made_up_query_does_not_break_the_turn() { let config = config(); let Ok(key) = std::env::var(&config.search.api_key_env) else { return; @@ -324,8 +323,8 @@ fn una_consulta_inventada_no_revienta_el_turno() { if key.trim().is_empty() { return; } - // Vía el registro, que es como llega de verdad: un fallo tiene que volver - // como texto para el modelo, nunca como un error que corte la respuesta. + // Through the registry, which is how it really arrives: a failure must come + // back as text for the model, never as an error that cuts the answer. let mut registry = ToolRegistry::new(); registry.register(std::sync::Arc::new(asist_tools::WebSearch::new( asist_tools::SearchBackend::Tavily { @@ -342,14 +341,14 @@ fn una_consulta_inventada_no_revienta_el_turno() { assert!(!outcome.ok); assert!( !outcome.output.contains("clave-invalida"), - "el mensaje de error no debe llevar la clave dentro: {}", + "the error message must not contain the key: {}", outcome.output ); } fn camera_source(config: &Config) -> Option<asist_tools::Camera> { if !config.camera.enabled || !config.camera.device.exists() { - eprintln!("sin cámara; prueba omitida"); + eprintln!("no camera; test skipped"); return None; } Some(asist_tools::Camera::new(asist_tools::CameraConfig { @@ -371,13 +370,13 @@ fn screen_source(config: &Config) -> Option<asist_tools::Screen> { save_dir: None, }); if !config.screen.enabled || screen.available().is_err() { - eprintln!("sin pantalla capturable; prueba omitida"); + eprintln!("no capturable screen; test skipped"); return None; } Some(screen) } -/// Ancho de un JPEG, leído de su cabecera SOF. +/// Width of a JPEG, read from its SOF header. fn jpeg_width(bytes: &[u8]) -> Option<u32> { let mut i = 2; while i + 9 < bytes.len() { @@ -395,60 +394,53 @@ fn jpeg_width(bytes: &[u8]) -> Option<u32> { } #[test] -fn la_camara_captura_un_jpeg() { +fn camera_captures_a_jpeg() { let config = config(); let Some(source) = camera_source(&config) else { return; }; let started = Instant::now(); - let frame = source.capture().expect("la captura falló"); - eprintln!( - "fotograma de {} KB en {:?}", - frame.len() / 1024, - started.elapsed() - ); + let frame = source.capture().expect("the capture failed"); + eprintln!("{} KB frame in {:?}", frame.len() / 1024, started.elapsed()); - assert!( - frame.len() > 1000, - "el fotograma es sospechosamente pequeño" - ); - // Cabecera JPEG: si no está, ffmpeg devolvió otra cosa y el modelo la - // rechazaría sin decir por qué. - assert_eq!(&frame[..2], &[0xFF, 0xD8], "no parece un JPEG"); + assert!(frame.len() > 1000, "the frame is suspiciously small"); + // JPEG header: if it is missing, ffmpeg returned something else and the + // model would reject it without saying why. + assert_eq!(&frame[..2], &[0xFF, 0xD8], "does not look like a JPEG"); } #[test] -fn la_pantalla_se_captura_al_ancho_configurado() { +fn screen_is_captured_at_the_configured_width() { let config = config(); let Some(source) = screen_source(&config) else { return; }; let started = Instant::now(); - let frame = source.capture().expect("la captura falló"); + let frame = source.capture().expect("the capture failed"); eprintln!( - "captura de {} KB en {:?}", + "{} KB capture in {:?}", frame.len() / 1024, started.elapsed() ); - assert_eq!(&frame[..2], &[0xFF, 0xD8], "no parece un JPEG"); + assert_eq!(&frame[..2], &[0xFF, 0xD8], "does not look like a JPEG"); - // El ancho es lo que separa leer de inventar, así que se comprueba que el - // guion de verdad redujo la imagen y no la mandó a tamaño completo. - let width = jpeg_width(&frame).expect("no se pudo leer el ancho del JPEG"); + // Width is what separates reading from making things up, so it is checked + // that the script really scaled the image down and did not send it full size. + let width = jpeg_width(&frame).expect("could not read the JPEG width"); assert_eq!( width, config.screen.width, - "la captura salió a {width} px y se pedían {}", + "the capture came out at {width} px and {} was requested", config.screen.width ); } #[test] -#[ignore = "enciende la cámara y hace una pasada de visión; lento"] -fn el_modelo_describe_lo_que_ve_la_camara() { - let _gpu = en_exclusiva(); - let Some((config, llm, _)) = clientes() else { +#[ignore = "turns the camera on and runs a vision pass; slow"] +fn model_describes_what_the_camera_sees() { + let _gpu = exclusive(); + let Some((config, llm, _)) = clients() else { return; }; let Some(source) = camera_source(&config) else { @@ -459,25 +451,25 @@ fn el_modelo_describe_lo_que_ve_la_camara() { let started = Instant::now(); let out = tool .call(&serde_json::json!({ "pregunta": "¿Qué se ve en la imagen?" })) - .expect("la descripción falló"); + .expect("the description failed"); - eprintln!("visión en {:?}: {out}", started.elapsed()); + eprintln!("vision in {:?}: {out}", started.elapsed()); assert!( out.split_whitespace().count() >= 3, - "descripción vacía o mínima: {out}" + "empty or minimal description: {out}" ); assert!( started.elapsed() < Duration::from_secs(20), - "tardó {:?}; revisa camera.width/height", + "took {:?}; check camera.width/height", started.elapsed() ); } #[test] -#[ignore = "captura la pantalla y hace una pasada de visión; lento"] -fn el_modelo_describe_la_pantalla() { - let _gpu = en_exclusiva(); - let Some((config, llm, _)) = clientes() else { +#[ignore = "captures the screen and runs a vision pass; slow"] +fn model_describes_the_screen() { + let _gpu = exclusive(); + let Some((config, llm, _)) = clients() else { return; }; let Some(source) = screen_source(&config) else { @@ -488,30 +480,30 @@ fn el_modelo_describe_la_pantalla() { let started = Instant::now(); let out = tool .call(&serde_json::json!({ "pregunta": "¿Qué hay en la pantalla?" })) - .expect("la descripción falló"); + .expect("the description failed"); - eprintln!("pantalla en {:?}: {out}", started.elapsed()); + eprintln!("screen in {:?}: {out}", started.elapsed()); assert!( out.split_whitespace().count() >= 3, - "descripción vacía o mínima: {out}" + "empty or minimal description: {out}" ); - // A 1280 px se midieron 7,6 s. Si esto se dispara, o la GPU está ocupada o - // alguien subió screen.width sin mirar el coste. + // 7.6 s were measured at 1280 px. If this shoots up, either the GPU is busy + // or someone raised screen.width without looking at the cost. assert!( started.elapsed() < Duration::from_secs(30), - "tardó {:?}; revisa screen.width", + "took {:?}; check screen.width", started.elapsed() ); } #[test] -#[ignore = "sale a internet con los dos buscadores; lento"] -fn los_dos_buscadores_responden_a_lo_mismo() { - // Compara lo que devuelve cada uno para las mismas preguntas. No afirma - // cuál es mejor —eso depende de la pregunta— sino que ambos funcionan y - // deja las cifras a la vista para elegir con datos. +#[ignore = "goes online with both search backends; slow"] +fn both_search_backends_answer_the_same() { + // Compares what each one returns for the same questions. It does not claim + // which is better (that depends on the question) but that both work, and it + // leaves the numbers visible to choose with data. let config = config(); - let preguntas = [ + let questions = [ "capital de Australia", "qué tiempo hace hoy en Buenos Aires", ]; @@ -531,26 +523,26 @@ fn los_dos_buscadores_responden_a_lo_mismo() { } } if backends.is_empty() { - eprintln!("ningún buscador configurado; prueba omitida"); + eprintln!("no search backend configured; test skipped"); return; } - for (nombre, backend) in backends { + for (name_in, backend) in backends { let tool = asist_tools::WebSearch::new(backend, 3, Duration::from_secs(30)); - for pregunta in preguntas { + for question in questions { let started = Instant::now(); - match tool.call(&serde_json::json!({ "consulta": pregunta })) { + match tool.call(&serde_json::json!({ "consulta": question })) { Ok(out) => { - let primera = out.lines().next().unwrap_or("").to_string(); + let first_answer = out.lines().next().unwrap_or("").to_string(); eprintln!( - "{nombre:7} {:>6.2}s {:>5} bytes «{pregunta}»\n {}", + "{name_in:7} {:>6.2}s {:>5} bytes «{question}»\n {}", started.elapsed().as_secs_f32(), out.len(), - &primera[..primera.len().min(150)] + &first_answer[..first_answer.len().min(150)] ); assert!(!out.trim().is_empty()); } - Err(err) => panic!("{nombre} falló en «{pregunta}»: {err}"), + Err(err) => panic!("{name_in} failed on «{question}»: {err}"), } } } |