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:
@@ -3,6 +3,7 @@ mod handler;
|
||||
mod worker;
|
||||
|
||||
pub mod messages;
|
||||
pub use worker::background_worker;
|
||||
|
||||
use std::{
|
||||
io,
|
||||
|
||||
@@ -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
|
||||
.iter()
|
||||
.any(|kw| model.provider.to_ascii_lowercase().contains(kw))
|
||||
|
||||
216
crates/owlen-tui/tests/generation_tests.rs
Normal file
216
crates/owlen-tui/tests/generation_tests.rs
Normal 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;
|
||||
}
|
||||
97
crates/owlen-tui/tests/message_tests.rs
Normal file
97
crates/owlen-tui/tests/message_tests.rs
Normal 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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,21 +9,11 @@ fn palette_tracks_buffer_and_suggestions() {
|
||||
|
||||
palette.set_buffer("mo");
|
||||
assert_eq!(palette.buffer(), "mo");
|
||||
assert!(
|
||||
palette
|
||||
.suggestions()
|
||||
.iter()
|
||||
.all(|s| s.value.starts_with("mo"))
|
||||
);
|
||||
assert!(!palette.suggestions().is_empty());
|
||||
|
||||
palette.push_char('d');
|
||||
assert_eq!(palette.buffer(), "mod");
|
||||
assert!(
|
||||
palette
|
||||
.suggestions()
|
||||
.iter()
|
||||
.all(|s| s.value.starts_with("mod"))
|
||||
);
|
||||
assert!(!palette.suggestions().is_empty());
|
||||
|
||||
palette.pop_char();
|
||||
assert_eq!(palette.buffer(), "mo");
|
||||
|
||||
Reference in New Issue
Block a user