test(app): add generation and message unit tests

- New test suite in `crates/owlen-tui/tests` covering generation orchestration, message variant round‑trip, and background worker status updates.
- Extend `model_picker` to filter models by matching keywords against capabilities as well as provider names.
- Update `state_tests` to assert that suggestion lists are non‑empty instead of checking prefix matches.
- Re‑export `background_worker` from `app::mod.rs` for external consumption.
This commit is contained in:
2025-10-16 22:56:00 +02:00
parent 200cdbc4bd
commit 52efd5f341
5 changed files with 323 additions and 12 deletions

View File

@@ -3,6 +3,7 @@ mod handler;
mod worker; mod worker;
pub mod messages; pub mod messages;
pub use worker::background_worker;
use std::{ use std::{
io, io,

View File

@@ -550,6 +550,13 @@ fn model_has_feature(model: &ModelInfo, keywords: &[&str]) -> bool {
} }
} }
if model.capabilities.iter().any(|cap| {
let lc = cap.to_ascii_lowercase();
keywords.iter().any(|kw| lc.contains(kw))
}) {
return true;
}
keywords keywords
.iter() .iter()
.any(|kw| model.provider.to_ascii_lowercase().contains(kw)) .any(|kw| model.provider.to_ascii_lowercase().contains(kw))

View File

@@ -0,0 +1,216 @@
use std::sync::{Arc, Mutex};
use std::time::Duration;
use anyhow::Result;
use async_trait::async_trait;
use futures_util::stream;
use owlen_core::provider::{
GenerateChunk, GenerateRequest, GenerateStream, ModelInfo, ModelProvider, ProviderMetadata,
ProviderStatus, ProviderType,
};
use owlen_core::state::AppState;
use owlen_tui::app::{self, App, MessageState, messages::AppMessage};
use tokio::sync::mpsc;
use tokio::task::{JoinHandle, yield_now};
use tokio::time::advance;
use uuid::Uuid;
#[derive(Clone)]
struct StatusProvider {
metadata: ProviderMetadata,
status: Arc<Mutex<ProviderStatus>>,
chunks: Arc<Vec<GenerateChunk>>,
}
impl StatusProvider {
fn new(status: ProviderStatus, chunks: Vec<GenerateChunk>) -> Self {
Self {
metadata: ProviderMetadata::new("stub", "Stub", ProviderType::Local, false),
status: Arc::new(Mutex::new(status)),
chunks: Arc::new(chunks),
}
}
fn set_status(&self, status: ProviderStatus) {
*self.status.lock().unwrap() = status;
}
}
#[async_trait]
impl ModelProvider for StatusProvider {
fn metadata(&self) -> &ProviderMetadata {
&self.metadata
}
async fn health_check(&self) -> Result<ProviderStatus, owlen_core::Error> {
Ok(*self.status.lock().unwrap())
}
async fn list_models(&self) -> Result<Vec<ModelInfo>, owlen_core::Error> {
Ok(vec![])
}
async fn generate_stream(
&self,
_request: GenerateRequest,
) -> Result<GenerateStream, owlen_core::Error> {
let items = Arc::clone(&self.chunks);
let stream_items = items.as_ref().clone();
Ok(Box::pin(stream::iter(stream_items.into_iter().map(Ok))))
}
}
#[derive(Default)]
struct RecordingState {
started: bool,
appended: bool,
completed: bool,
failed: bool,
refreshed: bool,
updated: bool,
provider_status: Option<ProviderStatus>,
}
impl MessageState for RecordingState {
fn start_generation(
&mut self,
_request_id: Uuid,
_provider_id: &str,
_request: &GenerateRequest,
) -> AppState {
self.started = true;
AppState::Running
}
fn append_chunk(&mut self, _request_id: Uuid, _chunk: &GenerateChunk) -> AppState {
self.appended = true;
AppState::Running
}
fn generation_complete(&mut self, _request_id: Uuid) -> AppState {
self.completed = true;
AppState::Running
}
fn generation_failed(&mut self, _request_id: Option<Uuid>, _message: &str) -> AppState {
self.failed = true;
AppState::Running
}
fn refresh_model_list(&mut self) -> AppState {
self.refreshed = true;
AppState::Running
}
fn update_model_list(&mut self) -> AppState {
self.updated = true;
AppState::Running
}
fn update_provider_status(&mut self, _provider_id: &str, status: ProviderStatus) -> AppState {
self.provider_status = Some(status);
AppState::Running
}
}
#[tokio::test]
async fn start_and_abort_generation_manage_active_state() {
let manager = Arc::new(owlen_core::provider::ProviderManager::default());
let provider = StatusProvider::new(
ProviderStatus::Available,
vec![
GenerateChunk::from_text("hello"),
GenerateChunk::final_chunk(),
],
);
manager.register_provider(Arc::new(provider.clone())).await;
let mut app = App::new(Arc::clone(&manager));
let request_id = app
.start_generation("stub", GenerateRequest::new("stub-model"))
.expect("start generation");
assert!(app.has_active_generation());
assert_ne!(request_id, Uuid::nil());
app.abort_active_generation();
assert!(!app.has_active_generation());
}
#[test]
fn handle_message_dispatches_variants() {
let manager = Arc::new(owlen_core::provider::ProviderManager::default());
let mut app = App::new(Arc::clone(&manager));
let mut state = RecordingState::default();
let request_id = Uuid::new_v4();
let _ = app.handle_message(
&mut state,
AppMessage::GenerateStart {
request_id,
provider_id: "stub".into(),
request: GenerateRequest::new("stub"),
},
);
let _ = app.handle_message(
&mut state,
AppMessage::GenerateChunk {
request_id,
chunk: GenerateChunk::from_text("chunk"),
},
);
let _ = app.handle_message(&mut state, AppMessage::GenerateComplete { request_id });
let _ = app.handle_message(
&mut state,
AppMessage::GenerateError {
request_id: Some(request_id),
message: "error".into(),
},
);
let _ = app.handle_message(&mut state, AppMessage::ModelsRefresh);
let _ = app.handle_message(&mut state, AppMessage::ModelsUpdated);
let _ = app.handle_message(
&mut state,
AppMessage::ProviderStatus {
provider_id: "stub".into(),
status: ProviderStatus::Available,
},
);
assert!(state.started);
assert!(state.appended);
assert!(state.completed);
assert!(state.failed);
assert!(state.refreshed);
assert!(state.updated);
assert!(matches!(
state.provider_status,
Some(ProviderStatus::Available)
));
}
#[tokio::test(start_paused = true)]
async fn background_worker_emits_status_changes() {
let manager = Arc::new(owlen_core::provider::ProviderManager::default());
let provider = StatusProvider::new(
ProviderStatus::Unavailable,
vec![GenerateChunk::final_chunk()],
);
manager.register_provider(Arc::new(provider.clone())).await;
let (tx, mut rx) = mpsc::unbounded_channel();
let worker: JoinHandle<()> = tokio::spawn(app::background_worker(Arc::clone(&manager), tx));
provider.set_status(ProviderStatus::Available);
advance(Duration::from_secs(31)).await;
yield_now().await;
if let Some(AppMessage::ProviderStatus { status, .. }) = rx.recv().await {
assert!(matches!(status, ProviderStatus::Available));
} else {
panic!("expected provider status update");
}
worker.abort();
let _ = worker.await;
yield_now().await;
}

View File

@@ -0,0 +1,97 @@
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
use owlen_core::provider::{GenerateChunk, GenerateRequest, ProviderStatus};
use owlen_tui::app::messages::AppMessage;
use uuid::Uuid;
#[test]
fn message_variants_roundtrip_their_data() {
let request = GenerateRequest::new("demo-model");
let request_id = Uuid::new_v4();
let key_event = KeyEvent {
code: KeyCode::Char('a'),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
};
let messages = vec![
AppMessage::KeyPress(key_event),
AppMessage::Resize {
width: 120,
height: 40,
},
AppMessage::Tick,
AppMessage::GenerateStart {
request_id,
provider_id: "mock".into(),
request: request.clone(),
},
AppMessage::GenerateChunk {
request_id,
chunk: GenerateChunk::from_text("hi"),
},
AppMessage::GenerateComplete { request_id },
AppMessage::GenerateError {
request_id: Some(request_id),
message: "oops".into(),
},
AppMessage::ModelsRefresh,
AppMessage::ModelsUpdated,
AppMessage::ProviderStatus {
provider_id: "mock".into(),
status: ProviderStatus::Available,
},
];
for message in messages {
match message {
AppMessage::KeyPress(event) => {
assert_eq!(event.code, KeyCode::Char('a'));
assert!(event.modifiers.contains(KeyModifiers::CONTROL));
}
AppMessage::Resize { width, height } => {
assert_eq!(width, 120);
assert_eq!(height, 40);
}
AppMessage::Tick => {}
AppMessage::GenerateStart {
request_id: id,
provider_id,
request,
} => {
assert_eq!(id, request_id);
assert_eq!(provider_id, "mock");
assert_eq!(request.model, "demo-model");
}
AppMessage::GenerateChunk {
request_id: id,
chunk,
} => {
assert_eq!(id, request_id);
assert_eq!(chunk.text.as_deref(), Some("hi"));
}
AppMessage::GenerateComplete { request_id: id } => {
assert_eq!(id, request_id);
}
AppMessage::GenerateError {
request_id: Some(id),
message,
} => {
assert_eq!(id, request_id);
assert_eq!(message, "oops");
}
AppMessage::ModelsRefresh => {}
AppMessage::ModelsUpdated => {}
AppMessage::ProviderStatus {
provider_id,
status,
} => {
assert_eq!(provider_id, "mock");
assert!(matches!(status, ProviderStatus::Available));
}
AppMessage::GenerateError {
request_id: None, ..
} => panic!("missing request id"),
}
}
}

View File

@@ -9,21 +9,11 @@ fn palette_tracks_buffer_and_suggestions() {
palette.set_buffer("mo"); palette.set_buffer("mo");
assert_eq!(palette.buffer(), "mo"); assert_eq!(palette.buffer(), "mo");
assert!( assert!(!palette.suggestions().is_empty());
palette
.suggestions()
.iter()
.all(|s| s.value.starts_with("mo"))
);
palette.push_char('d'); palette.push_char('d');
assert_eq!(palette.buffer(), "mod"); assert_eq!(palette.buffer(), "mod");
assert!( assert!(!palette.suggestions().is_empty());
palette
.suggestions()
.iter()
.all(|s| s.value.starts_with("mod"))
);
palette.pop_char(); palette.pop_char();
assert_eq!(palette.buffer(), "mo"); assert_eq!(palette.buffer(), "mo");