use super::*;
use crate::tui::app::OpState;
use crate::tui::message::OpOutcome;
use crate::tui::tests::{connected_app, press, press_with, screen, worker};
use ratatui::crossterm::event::{KeyCode, KeyModifiers};
#[tokio::test]
async fn full_inner_queue_reports_cancel_refusal_after_outer_admission() {
let (sink, mut events) = EventSink::channel(4);
let (commands, mut held_receiver) = mpsc::channel(COMMAND_CHANNEL_CAPACITY);
let (cancel, _) = watch::channel(false);
let (release, held) = tokio::sync::oneshot::channel::<()>();
let task = tokio::spawn(async move {
held.await.unwrap();
});
let active = Active {
cancel,
commands,
task,
};
let mut app = connected_app();
app.generation = 7;
press(&mut app, KeyCode::Char('d'));
press(&mut app, KeyCode::Tab);
let mut outgoing = press(&mut app, KeyCode::Enter);
if outgoing.is_empty() {
outgoing = press(&mut app, KeyCode::Enter);
}
let [request @ Command::WhoIs { op, .. }] = outgoing.as_slice() else {
panic!("unexpected {outgoing:?}");
};
let op = *op;
for _ in 0..COMMAND_CHANNEL_CAPACITY {
active.commands.try_send(request.clone()).unwrap();
}
assert_eq!(active.commands.capacity(), 0);
assert!(!active.commands.is_closed());
let [cancel] = press_with(&mut app, KeyCode::Char('c'), KeyModifiers::CONTROL)
.try_into()
.unwrap();
assert_eq!(cancel, Command::Cancel { op });
assert_eq!(app.op.as_ref().unwrap().state, OpState::Cancelled);
let (outer, mut outer_rx) = mpsc::channel(1);
outer
.try_send(cancel)
.expect("outer UI queue admitted cancellation");
forward_operation(Some(&active), outer_rx.recv().await.unwrap(), op, 7, &sink).await;
let refusal = events.recv().await.unwrap();
for _ in 0..COMMAND_CHANNEL_CAPACITY {
assert!(matches!(
held_receiver.try_recv().unwrap(),
Command::WhoIs { .. }
));
}
assert!(held_receiver.try_recv().is_err());
let WorkerEvent::Session { generation, event } = &refusal else {
panic!("unscoped {refusal:?}");
};
assert_eq!(*generation, 7);
assert!(
matches!(event.as_ref(), WorkerEvent::OpRejected { op: id, cancel: true, error } if *id == op && error.starts_with("session command not admitted:"))
);
worker(
&mut app,
WorkerEvent::Session {
generation: 6,
event: event.clone(),
},
);
assert_eq!(
app.op.as_ref().unwrap().state,
OpState::Cancelled,
"old sessions cannot correct this cancellation"
);
worker(
&mut app,
WorkerEvent::Session {
generation: 7,
event: Box::new(WorkerEvent::OpRejected {
op: op + 1,
cancel: true,
error: "other operation".into(),
}),
},
);
assert_eq!(
app.op.as_ref().unwrap().state,
OpState::Cancelled,
"other operation IDs cannot correct this cancellation"
);
worker(&mut app, refusal);
assert_ne!(
app.op.as_ref().unwrap().state,
OpState::Cancelled,
"inner queue refusal must correct optimistic cancellation"
);
let frame = screen(&mut app, 120, 40);
assert!(frame.contains("cancel failed"), "{frame}");
assert!(app.flash.as_ref().unwrap().text.contains("retry"));
assert!(screen(&mut app, 80, 24).contains("Ctrl-C to retry; x disconnects."));
let failed = app.op.as_ref().unwrap().state.clone();
for event in [
WorkerEvent::WhoIsSent { op },
WorkerEvent::OpFinished {
op,
outcome: OpOutcome::Completed,
},
WorkerEvent::OpFinished {
op,
outcome: OpOutcome::Failed("late send failure".into()),
},
] {
worker(
&mut app,
WorkerEvent::Session {
generation: 7,
event: Box::new(event),
},
);
assert_eq!(
app.op.as_ref().unwrap().state,
failed,
"ordinary late progress cannot erase cancel refusal"
);
}
let retry = press_with(&mut app, KeyCode::Char('c'), KeyModifiers::CONTROL);
assert_eq!(retry, [Command::Cancel { op }]);
assert!(!app.quit);
outer.try_send(retry[0].clone()).unwrap();
forward_operation(Some(&active), outer_rx.recv().await.unwrap(), op, 7, &sink).await;
assert_eq!(held_receiver.try_recv().unwrap(), Command::Cancel { op });
assert!(
events.try_recv().is_err(),
"admitted retry must not produce a rejection"
);
release.send(()).unwrap();
active.task.await.unwrap();
}
#[test]
fn outer_cancel_refusal_uses_the_same_identity_and_retry_contract() {
let mut app = connected_app();
press(&mut app, KeyCode::Char('d'));
press(&mut app, KeyCode::Enter);
let commands = press(&mut app, KeyCode::Enter);
let [Command::WhoIs { op, .. }] = commands.as_slice() else {
panic!("Who-Is required");
};
let op = *op;
let [cancel] = press_with(&mut app, KeyCode::Char('c'), KeyModifiers::CONTROL)
.try_into()
.unwrap();
let link = app.link.clone();
app.command_rejected(
Command::Cancel { op: op + 1 },
link.clone(),
"unrelated".into(),
);
assert_eq!(app.op.as_ref().unwrap().state, OpState::Cancelled);
app.command_rejected(cancel, link, "outer queue full".into());
assert_eq!(
app.op.as_ref().unwrap().state,
OpState::CancelFailed("outer queue full".into())
);
assert_eq!(
press_with(&mut app, KeyCode::Char('c'), KeyModifiers::CONTROL),
[Command::Cancel { op }]
);
assert!(!app.quit);
}
#[tokio::test]
async fn a_browse_read_without_a_session_fails_under_its_request() {
let (sink, mut events) = EventSink::channel(4);
let read = Command::Browse {
req: 9,
device: 100,
read: crate::tui::point::BrowseRead::Device,
};
forward_browse(None, read, 9, 3, &sink).await;
let WorkerEvent::Session { generation, event } = events.recv().await.unwrap() else {
panic!("unscoped event");
};
assert_eq!(generation, 3);
assert!(matches!(
*event,
WorkerEvent::Browse { req: 9, result: BrowseResult::Failed(ref error) }
if error == "no active connection"
));
}
#[tokio::test(start_paused = true)]
async fn a_watch_command_waits_for_room_in_a_full_session_queue() {
use crate::tui::watched::{Point, Source, WatchCommand, WatchEvent};
use bacnet_types::enums::{ObjectType, PropertyIdentifier};
use bacnet_types::primitives::ObjectIdentifier;
let (sink, mut events) = EventSink::channel(4);
let (commands, mut held) = mpsc::channel(1);
let (cancel, _) = watch::channel(false);
let active = Active {
cancel,
commands,
task: tokio::spawn(std::future::pending::<()>()),
};
active.commands.try_send(Command::Reconnect).unwrap();
assert_eq!(active.commands.capacity(), 0, "the session queue is full");
let remove = WatchCommand::Remove { row: 3 };
let drain = async {
tokio::time::sleep(Duration::from_secs(1)).await;
assert_eq!(held.recv().await, Some(Command::Reconnect));
tokio::time::timeout(Duration::from_secs(30), held.recv())
.await
.ok()
.flatten()
};
let ((), forwarded) = tokio::join!(
forward_watch(Some(&active), remove.clone(), 7, &sink),
drain
);
assert_eq!(forwarded, Some(Command::Watch(remove)));
active.commands.try_send(Command::Reconnect).unwrap();
let add = WatchCommand::Add {
row: 4,
point: Point {
device: 100,
network: None,
object: ObjectIdentifier::new(ObjectType::ANALOG_INPUT, 1).unwrap(),
property: PropertyIdentifier::PRESENT_VALUE,
},
};
forward_watch(Some(&active), add, 7, &sink).await;
let WorkerEvent::Session { generation, event } = events.recv().await.unwrap() else {
panic!("unscoped event");
};
assert_eq!(generation, 7);
assert!(matches!(
*event,
WorkerEvent::Watch(WatchEvent::Source {
row: 4,
source: Source::Failed(_)
})
));
active.task.abort();
}