use crate::protocol::{self, TouchPortalCommand, TouchPortalOutput};
use eyre::{Context, Result};
use std::collections::{HashMap, VecDeque};
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::{mpsc, Mutex};
#[derive(Debug, Clone)]
pub struct MockExpectations {
expected_calls: Arc<Mutex<HashMap<String, VecDeque<serde_json::Value>>>>,
}
impl MockExpectations {
pub fn new() -> Self {
Self {
expected_calls: Arc::new(Mutex::new(HashMap::new())),
}
}
pub async fn expect_action_call(
&self,
callback_name: impl Into<String>,
args: serde_json::Value,
) {
let callback_name = callback_name.into();
let mut expected = self.expected_calls.lock().await;
expected
.entry(callback_name)
.or_insert_with(VecDeque::new)
.push_back(args);
}
pub async fn check_action_call(
&self,
callback_name: impl Into<String>,
args: serde_json::Value,
) -> Result<()> {
let callback_name = callback_name.into();
tracing::debug!(callback = %callback_name, ?args, "action callback invoked");
let mut expected = self.expected_calls.lock().await;
let Some(expected_calls) = expected.get_mut(&callback_name) else {
eyre::bail!(
"Unexpected action callback '{}' was called with arguments: {:?}",
callback_name,
args
);
};
if expected_calls.is_empty() {
eyre::bail!(
"Action callback '{}' was called more times than expected",
callback_name
);
}
let Some(expected_args) = expected_calls.front() else {
eyre::bail!("Internal error: expected_calls was empty after is_empty() check");
};
if expected_args != &args {
eyre::bail!(
"Call to '{}' had wrong arguments. Expected: {:?}, Actual: {:?}",
callback_name,
expected_args,
args
);
}
expected_calls.pop_front();
tracing::info!(callback = %callback_name, "✅ action callback matches expectations");
Ok(())
}
pub async fn verify(&self) -> Result<()> {
let expected = self.expected_calls.lock().await;
for (callback_name, expected_calls) in expected.iter() {
if !expected_calls.is_empty() {
eyre::bail!(
"Expected {} more calls to '{}' but plugin finished",
expected_calls.len(),
callback_name
);
}
}
tracing::info!("✅ All expected action calls were satisfied");
Ok(())
}
pub async fn clear(&self) {
self.expected_calls.lock().await.clear();
}
}
impl Default for MockExpectations {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug)]
pub struct MockTouchPortalServer {
listener: TcpListener,
captured_messages: Arc<Mutex<Vec<TouchPortalCommand>>>,
action_invocations: Arc<Mutex<Vec<String>>>,
test_scenarios: Vec<TestScenario>,
expectations: MockExpectations,
}
pub struct TestScenario {
pub name: String,
pub messages: Vec<TouchPortalOutput>,
pub delay: Duration,
pub assertions:
Option<Box<dyn Fn(&[TouchPortalCommand], &[String]) -> Result<()> + Send + Sync>>,
}
impl std::fmt::Debug for TestScenario {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TestScenario")
.field("name", &self.name)
.field("messages", &self.messages)
.field("delay", &self.delay)
.field("assertions", &self.assertions.is_some())
.finish()
}
}
impl Clone for TestScenario {
fn clone(&self) -> Self {
Self {
name: self.name.clone(),
messages: self.messages.clone(),
delay: self.delay,
assertions: None, }
}
}
impl MockTouchPortalServer {
pub async fn new() -> Result<Self> {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.context("bind mock TouchPortal server")?;
Ok(Self {
listener,
captured_messages: Arc::new(Mutex::new(Vec::new())),
action_invocations: Arc::new(Mutex::new(Vec::new())),
test_scenarios: Vec::new(),
expectations: MockExpectations::new(),
})
}
pub fn local_addr(&self) -> Result<SocketAddr> {
self.listener
.local_addr()
.context("get mock TouchPortal server address")
}
pub fn add_test_scenario(&mut self, scenario: TestScenario) {
self.test_scenarios.push(scenario);
}
pub async fn action_invocations(&self) -> Vec<String> {
self.action_invocations.lock().await.clone()
}
pub async fn clear_action_invocations(&self) {
self.action_invocations.lock().await.clear();
}
pub fn expectations(&self) -> &MockExpectations {
&self.expectations
}
pub async fn captured_messages(&self) -> Vec<TouchPortalCommand> {
self.captured_messages.lock().await.clone()
}
pub async fn clear_captured_messages(&self) {
self.captured_messages.lock().await.clear();
}
pub fn take_expectations(&mut self) -> MockExpectations {
std::mem::take(&mut self.expectations)
}
pub async fn run_test_scenarios(self) -> Result<()> {
tracing::info!(
"mock TouchPortal server listening on {}",
self.local_addr()?
);
let (stream, addr) = self
.listener
.accept()
.await
.context("accept plugin connection")?;
tracing::info!("plugin connected from {}", addr);
self.handle_connection(stream).await
}
async fn handle_connection(&self, stream: TcpStream) -> Result<()> {
let (read_half, write_half) = stream.into_split();
let mut reader = BufReader::new(read_half);
let mut writer = BufWriter::new(write_half);
let (tx, mut rx) = mpsc::channel::<TouchPortalOutput>(32);
let mut writer_task = {
tokio::spawn(async move {
while let Some(message) = rx.recv().await {
let json =
serde_json::to_string(&message).context("serialize message to plugin")?;
tracing::trace!(?json, "mock TouchPortal -> plugin");
writer
.write_all(json.as_bytes())
.await
.context("write message to plugin")?;
writer
.write_all(b"\n")
.await
.context("write newline to plugin")?;
writer.flush().await.context("flush to plugin")?;
}
Ok::<(), eyre::Report>(())
})
};
let mut scenario_task = {
let tx = tx.clone();
let mut scenarios = self.test_scenarios.clone();
let captured_messages = self.captured_messages.clone();
let action_invocations = self.action_invocations.clone();
tokio::spawn(async move {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let pair_scenario = TestScenario {
name: "Automatic Pair Test".to_string(),
messages: Vec::new(),
delay: Duration::from_millis(50),
assertions: Some(Box::new(|commands, _actions| {
let pair_commands = commands
.iter()
.filter(|cmd| matches!(cmd, TouchPortalCommand::Pair(_)))
.count();
if pair_commands == 1 {
tracing::info!("✅ Plugin successfully paired with mock TouchPortal");
Ok(())
} else {
eyre::bail!("Expected exactly 1 pair command, got {}", pair_commands)
}
})),
};
scenarios.insert(0, pair_scenario);
let close_scenario = TestScenario {
name: "Automatic ClosePlugin".to_string(),
messages: vec![TouchPortalOutput::ClosePlugin(
protocol::ClosePluginMessage {
plugin_id: "mock-plugin".to_string(),
},
)],
delay: Duration::from_millis(100),
assertions: None,
};
scenarios.push(close_scenario);
for scenario in scenarios {
tracing::info!(scenario.name, "executing test scenario");
for message in &scenario.messages {
if let TouchPortalOutput::Action(action_msg) = message {
action_invocations
.lock()
.await
.push(action_msg.action_id.clone());
}
if tx.send(message.clone()).await.is_err() {
tracing::warn!("failed to send test message to plugin");
break;
}
if !scenario.delay.is_zero() {
tokio::time::sleep(scenario.delay).await;
}
}
if let Some(assertions) = &scenario.assertions {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let messages = captured_messages.lock().await;
let actions = action_invocations.lock().await;
match assertions(&messages, &actions) {
Ok(()) => {
tracing::info!(scenario.name, "✅ assertions passed");
}
Err(e) => {
tracing::error!(scenario.name, error = %e, "❌ assertions failed");
}
}
}
}
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
tracing::info!("Mock server scenarios completed");
})
};
let mut line = String::new();
loop {
tokio::select! {
result = reader.read_line(&mut line) => {
let n = result.context("read from plugin")?;
if n == 0 {
tracing::info!("plugin disconnected");
break;
}
let json: serde_json::Value = serde_json::from_str(&line.trim())
.context("parse JSON from plugin")?;
tracing::trace!(?json, "plugin -> mock TouchPortal");
if let Ok(command) = serde_json::from_value::<TouchPortalCommand>(json.clone()) {
self.captured_messages.lock().await.push(command.clone());
match command {
TouchPortalCommand::Pair(_pair_cmd) => {
let info = protocol::InfoMessage {
sdk_version: crate::ApiVersion::V4_3,
tp_version_string: "Mock TouchPortal v4.3.0".to_string(),
tp_version_code: 430000,
plugin_version: None,
settings: vec![],
current_page_path_main_device: Some("mock-page.tml".to_string()),
current_page_path_secondary_devices: vec![],
};
if tx.send(TouchPortalOutput::Info(info)).await.is_err() {
tracing::warn!("failed to send info response to plugin");
break;
}
}
_ => {
tracing::debug!(?command, "received command from plugin");
}
}
}
line.clear();
}
_ = &mut writer_task, if !writer_task.is_finished() => {
tracing::info!("writer task completed");
break;
}
_ = &mut scenario_task, if !scenario_task.is_finished() => {
tracing::info!("scenario task completed");
}
}
}
Ok(())
}
}
impl TestScenario {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
messages: Vec::new(),
delay: Duration::from_millis(100),
assertions: None,
}
}
pub fn with_delay(mut self, delay: Duration) -> Self {
self.delay = delay;
self
}
pub fn with_assertions<F>(mut self, assertions: F) -> Self
where
F: Fn(&[TouchPortalCommand], &[String]) -> Result<()> + Send + Sync + 'static,
{
self.assertions = Some(Box::new(assertions));
self
}
pub fn with_message(mut self, message: TouchPortalOutput) -> Self {
self.messages.push(message);
self
}
pub fn with_action(
self,
action_id: impl Into<String>,
data: Vec<(impl Into<String>, impl Into<String>)>,
) -> Self {
let message = TouchPortalOutput::Action(protocol::ActionMessage {
plugin_id: "mock-plugin".to_string(),
action_id: action_id.into(),
data: data
.into_iter()
.map(|(id, value)| protocol::IdValuePair {
id: id.into(),
value: value.into(),
})
.collect(),
});
self.with_message(message)
}
pub fn with_page_change(
self,
page_name: impl Into<String>,
previous_page_name: Option<impl Into<String>>,
) -> Self {
let message = TouchPortalOutput::Broadcast(protocol::BroadcastEvent::PageChange(
protocol::BroadcastPageChangeEvent {
page_name: page_name.into(),
previous_page_name: previous_page_name.map(|s| s.into()),
device_ip: Some("127.0.0.1".to_string()),
device_name: Some("Mock Device".to_string()),
device_id: Some("mock-device-1".to_string()),
},
));
self.with_message(message)
}
}