pub mod node;
pub mod nodes;
pub use node::{PasteCtx, PasteNode, PasteNodeConfig, WmClassPattern};
use crate::clipboard::{Clipboard, X11Clipboard};
use crate::config::PasteShortcut;
use crate::error::TalkError;
const PASTE_PREVIEW_CHARS: usize = 60;
pub fn log_preview(text: &str) -> String {
let char_count = text.chars().count();
let escaped: String = text
.chars()
.take(PASTE_PREVIEW_CHARS)
.map(|c| match c {
'\n' => '␊',
'\r' => '␍',
'\t' => '␉',
other => other,
})
.collect();
let ellipsis = if char_count > PASTE_PREVIEW_CHARS {
"…"
} else {
""
};
format!("{char_count} chars: \"{escaped}{ellipsis}\"")
}
const FOCUS_MAX_RETRIES: u32 = 5;
const FOCUS_INITIAL_DELAY_MS: u64 = 50;
#[derive(Debug, Clone, Copy)]
pub struct PasteTiming {
pub restore_settle_ms: u64,
pub chunk_fetch_timeout_ms: u64,
pub target_quiescence_ms: u64,
}
impl Default for PasteTiming {
fn default() -> Self {
Self {
restore_settle_ms: 200,
chunk_fetch_timeout_ms: node::DEFAULT_CHUNK_FETCH_TIMEOUT_MS,
target_quiescence_ms: node::DEFAULT_TARGET_QUIESCENCE_MS,
}
}
}
pub const PASTE_CHUNK_CHARS: usize = 150;
pub async fn ensure_focus(window_id: &str) -> Result<(), TalkError> {
let mut delay_ms = FOCUS_INITIAL_DELAY_MS;
for attempt in 1..=FOCUS_MAX_RETRIES {
focus_window(window_id).await;
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
if let Some(active) = get_active_window().await {
if active == window_id {
log::debug!("target window {} focused (attempt {})", window_id, attempt);
return Ok(());
}
log::debug!(
"focus attempt {}/{}: expected {}, got {}",
attempt,
FOCUS_MAX_RETRIES,
window_id,
active,
);
} else {
log::debug!(
"focus attempt {}/{}: could not determine active window",
attempt,
FOCUS_MAX_RETRIES,
);
}
delay_ms *= 2;
}
Err(TalkError::Clipboard(format!(
"could not focus target window {} after {} attempts \
— aborting to avoid sending keys to the wrong window",
window_id, FOCUS_MAX_RETRIES,
)))
}
pub fn split_into_char_chunks(text: &str, max_chars: usize) -> Vec<String> {
let words: Vec<&str> = text.split_whitespace().collect();
if words.is_empty() {
return vec![text.to_string()];
}
let mut chunks = Vec::new();
let mut current = String::new();
for word in &words {
let candidate_len = if current.is_empty() {
word.len()
} else {
current.len() + 1 + word.len() };
if !current.is_empty() && candidate_len > max_chars {
chunks.push(current);
current = format!(" {word}");
} else if current.is_empty() {
current = (*word).to_string();
} else {
current.push(' ');
current.push_str(word);
}
}
if !current.is_empty() {
chunks.push(current);
}
chunks
}
pub fn default_root(no_chunk_paste: bool) -> Box<dyn PasteNode> {
let mut tree = PasteNodeConfig::Chunk {
chunk_chars: PASTE_CHUNK_CHARS,
child: Box::new(PasteNodeConfig::Clipboard {
shortcut: PasteShortcut::CtrlShiftV,
restore_settle_ms: PasteTiming::default().restore_settle_ms,
chunk_fetch_timeout_ms: PasteTiming::default().chunk_fetch_timeout_ms,
target_quiescence_ms: PasteTiming::default().target_quiescence_ms,
target_fetch_retries: node::DEFAULT_TARGET_FETCH_RETRIES,
}),
};
if no_chunk_paste {
tree = tree.strip_chunks();
}
tree.build()
}
pub fn build_root_from_config(cfg: &PasteNodeConfig, no_chunk_paste: bool) -> Box<dyn PasteNode> {
if no_chunk_paste {
cfg.clone().strip_chunks().build()
} else {
cfg.build()
}
}
pub fn timing_from_root(cfg: &PasteNodeConfig) -> PasteTiming {
node::timing_from_tree(cfg)
}
#[allow(clippy::too_many_arguments)]
pub async fn paste_with_root(
root: &dyn PasteNode,
target_window: Option<&String>,
text: &str,
delete_chars_before_paste: usize,
t_stop: Option<std::time::Instant>,
sink: &dyn crate::telemetry::TelemetrySink,
timing: PasteTiming,
alert: Option<std::sync::Arc<dyn Fn() + Send + Sync>>,
) -> Result<(), TalkError> {
let clipboard = X11Clipboard::new();
let total_chars = text.len() as u64;
let _ = timing;
log::trace!(
"paste: BEGIN delete_before={} target_window={:?} text={}",
delete_chars_before_paste,
target_window,
log_preview(text),
);
if let Some(wid) = target_window {
log::debug!("refocusing target window: {}", wid);
ensure_focus(wid).await?;
if let Some(active) = get_active_window().await {
log::trace!("paste: active window after focus = {}", active);
}
}
if delete_chars_before_paste > 0 {
log::info!("deleting {} chars before paste", delete_chars_before_paste);
simulate_backspace(delete_chars_before_paste).await?;
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
}
let saved_clipboard = clipboard.get_text().await.ok();
log::trace!(
"paste: saved original clipboard = {}",
saved_clipboard
.as_deref()
.map(log_preview)
.unwrap_or_else(|| "<none>".to_string()),
);
if let Some(t) = t_stop {
log::info!("timing: stop +{}ms first_paste", t.elapsed().as_millis());
}
let target_client_base = resolve_target_client_base(target_window).await;
let target_window_str: Option<&str> = target_window.map(|s| s.as_str());
let ctx = PasteCtx {
target_window: target_window_str,
delete_chars_before_paste,
t_stop,
sink,
clipboard: &clipboard,
target_client_base,
expected_target_fetches: std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)),
alert,
};
let paste_result = root.paste(text, &ctx).await;
if let Some(saved) = saved_clipboard {
log::trace!(
"paste: restoring original clipboard = {}",
log_preview(&saved)
);
let _ = clipboard.set_text(&saved).await;
}
paste_result?;
log::trace!("paste: END (total_chars={})", total_chars);
Ok(())
}
async fn resolve_target_client_base(target_window: Option<&String>) -> Option<u32> {
let wid = match target_window.and_then(|s| s.parse::<u32>().ok()) {
Some(w) => w,
None => {
if target_window.is_some() {
log::debug!(
"paste: target_window {:?} could not be parsed as u32 \
— falling back to legacy served_count gate",
target_window,
);
}
return None;
}
};
let base = tokio::task::spawn_blocking(move || crate::x11::x11_client_base(wid))
.await
.ok()
.flatten();
match base {
Some(b) => {
log::debug!(
"paste: resolved target X11 client-base {:#x} for window {} \
(deterministic gate enabled)",
b,
wid,
);
Some(b)
}
None => {
log::debug!(
"paste: failed to resolve X11 client-base for window {} \
— falling back to legacy served_count gate",
wid,
);
None
}
}
}
pub struct RealtimeClipboardGuard {
clipboard: X11Clipboard,
saved: Option<String>,
}
impl RealtimeClipboardGuard {
pub async fn begin(timing: PasteTiming) -> Self {
let _ = timing;
let clipboard = X11Clipboard::new();
let saved = clipboard.get_text().await.ok();
log::trace!(
"paste(realtime): saved original clipboard = {}",
saved
.as_deref()
.map(log_preview)
.unwrap_or_else(|| "<none>".to_string()),
);
Self { clipboard, saved }
}
pub async fn paste_segment(
&self,
root: &dyn PasteNode,
segment: &str,
sink: &dyn crate::telemetry::TelemetrySink,
) -> Result<(), TalkError> {
let ctx = PasteCtx {
target_window: None,
delete_chars_before_paste: 0,
t_stop: None,
sink,
clipboard: &self.clipboard,
target_client_base: None,
expected_target_fetches: std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)),
alert: None,
};
root.paste(segment, &ctx).await
}
pub async fn finish(self) {
if let Some(saved) = self.saved {
log::debug!("restoring original clipboard");
log::trace!(
"paste(realtime): restoring original clipboard = {}",
log_preview(&saved),
);
let _ = self.clipboard.set_text(&saved).await;
}
}
}
pub async fn get_active_window() -> Option<String> {
tokio::task::spawn_blocking(|| crate::x11::x11_get_active_window().map(|wid| wid.to_string()))
.await
.ok()?
}
pub async fn focus_window(window_id: &str) -> bool {
let wid: u32 = match window_id.parse() {
Ok(v) => v,
Err(_) => return false,
};
tokio::task::spawn_blocking(move || crate::x11::x11_activate_window(wid))
.await
.unwrap_or(false)
}
pub fn paste_keysyms(shortcut: &PasteShortcut) -> Vec<u32> {
const CONTROL_L: u32 = 0xffe3;
const SHIFT_L: u32 = 0xffe1;
const KEY_V: u32 = 0x0076;
match shortcut {
PasteShortcut::CtrlShiftV => vec![CONTROL_L, SHIFT_L, KEY_V],
PasteShortcut::CtrlV => vec![CONTROL_L, KEY_V],
}
}
pub async fn simulate_paste(shortcut: PasteShortcut) -> Result<(), TalkError> {
let keysyms = paste_keysyms(&shortcut);
let ok = tokio::task::spawn_blocking(move || crate::x11::x11_send_key_combo(&keysyms))
.await
.unwrap_or(false);
if !ok {
return Err(TalkError::Clipboard(
"XTest key simulation failed".to_string(),
));
}
Ok(())
}
pub async fn simulate_backspace(count: usize) -> Result<(), TalkError> {
if count == 0 {
return Ok(());
}
const BACKSPACE: u32 = 0xff08;
let ok = tokio::task::spawn_blocking(move || crate::x11::x11_send_key_repeat(BACKSPACE, count))
.await
.unwrap_or(false);
if !ok {
return Err(TalkError::Clipboard(
"XTest backspace simulation failed".to_string(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_chunk_short_text_fits_in_one() {
let chunks = split_into_char_chunks("hello world", 150);
assert_eq!(chunks, vec!["hello world"]);
}
#[test]
fn test_chunk_exactly_at_limit() {
let text = "one two three four f";
assert_eq!(text.len(), 20);
let chunks = split_into_char_chunks(text, 20);
assert_eq!(chunks, vec![text]);
}
#[test]
fn test_chunk_splits_on_word_boundary() {
let chunks = split_into_char_chunks("hello world", 8);
assert_eq!(chunks, vec!["hello", " world"]);
}
#[test]
fn test_chunk_long_word_exceeds_limit() {
let chunks = split_into_char_chunks("supercalifragilistic", 5);
assert_eq!(chunks, vec!["supercalifragilistic"]);
}
#[test]
fn test_chunk_multiple_chunks() {
let text = "aaa bbb ccc ddd eee fff";
let chunks = split_into_char_chunks(text, 10);
assert_eq!(chunks, vec!["aaa bbb", " ccc ddd", " eee fff"]);
}
#[test]
fn test_chunk_concatenation_reproduces_original() {
let text = "The quick brown fox jumps over the lazy dog and then some more words follow after that";
let chunks = split_into_char_chunks(text, 30);
let reassembled: String = chunks.concat();
assert_eq!(reassembled, text);
}
#[test]
fn test_chunk_empty_string() {
let chunks = split_into_char_chunks("", 150);
assert_eq!(chunks, vec![""]);
}
#[test]
fn test_chunk_whitespace_only() {
let chunks = split_into_char_chunks(" ", 150);
assert_eq!(chunks, vec![" "]);
}
#[test]
fn test_chunk_single_word() {
let chunks = split_into_char_chunks("hello", 150);
assert_eq!(chunks, vec!["hello"]);
}
#[test]
fn test_paste_keysyms_ctrl_shift_v() {
let keysyms = paste_keysyms(&PasteShortcut::CtrlShiftV);
assert_eq!(keysyms, vec![0xffe3, 0xffe1, 0x0076]);
}
#[test]
fn test_paste_keysyms_ctrl_v() {
let keysyms = paste_keysyms(&PasteShortcut::CtrlV);
assert_eq!(keysyms, vec![0xffe3, 0x0076]);
}
#[test]
fn test_log_preview_short_text_not_truncated() {
assert_eq!(log_preview("hello"), "5 chars: \"hello\"");
}
#[test]
fn test_log_preview_empty() {
assert_eq!(log_preview(""), "0 chars: \"\"");
}
#[test]
fn test_log_preview_escapes_newlines_and_tabs() {
assert_eq!(log_preview("a\nb\tc\rd"), "7 chars: \"a␊b␉c␍d\"");
}
#[test]
fn test_log_preview_truncates_with_ellipsis() {
let text = "x".repeat(PASTE_PREVIEW_CHARS + 10);
let preview = log_preview(&text);
let expected_body = "x".repeat(PASTE_PREVIEW_CHARS);
assert_eq!(
preview,
format!("{} chars: \"{}…\"", PASTE_PREVIEW_CHARS + 10, expected_body),
);
}
#[test]
fn test_log_preview_boundary_exactly_preview_chars_no_ellipsis() {
let text = "y".repeat(PASTE_PREVIEW_CHARS);
let preview = log_preview(&text);
assert!(!preview.contains('…'));
assert_eq!(
preview,
format!("{} chars: \"{}\"", PASTE_PREVIEW_CHARS, text),
);
}
#[test]
fn test_log_preview_multibyte_char_boundary_safe() {
let text = "😀".repeat(PASTE_PREVIEW_CHARS + 5);
let preview = log_preview(&text);
assert!(preview.starts_with(&format!("{} chars: ", PASTE_PREVIEW_CHARS + 5)));
assert!(preview.ends_with("…\""));
let shown = "😀".repeat(PASTE_PREVIEW_CHARS);
assert!(preview.contains(&shown));
}
}
#[cfg(test)]
mod tree_tests {
use super::node::{PasteCtx, PasteNode, PasteNodeConfig};
use super::nodes::chunk::ChunkNode;
use super::nodes::glob_match;
use crate::clipboard::X11Clipboard;
use crate::config::{Config, PasteConfig, PasteShortcut};
use crate::telemetry::{NoOpSink, TelemetrySink, TranscriptionEvent};
use async_trait::async_trait;
use std::sync::Arc;
use std::sync::Mutex;
fn parse_paste(yaml: &str) -> Option<PasteConfig> {
let cfg: Config = serde_yaml::from_str(yaml).expect("yaml fixture must parse as Config");
cfg.paste
}
#[test]
fn flat_yaml_deserialises_as_flat_variant() {
let yaml = r#"
output_dir: /tmp/x
providers: {}
paste:
chunk_chars: 80
shortcut: ctrl_v
restore_settle_ms: 250
chunk_fetch_timeout_ms: 600
"#;
let p = parse_paste(yaml).expect("paste section");
match p {
PasteConfig::Flat(ref f) => {
assert_eq!(f.chunk_chars, 80);
assert_eq!(f.shortcut, PasteShortcut::CtrlV);
assert_eq!(f.restore_settle_ms, 250);
assert_eq!(f.chunk_fetch_timeout_ms, 600);
}
PasteConfig::Tree(_) => panic!("expected flat variant for legacy YAML"),
}
let tree = p.to_tree();
match tree {
PasteNodeConfig::Chunk { chunk_chars, child } => {
assert_eq!(chunk_chars, 80);
match *child {
PasteNodeConfig::Clipboard {
shortcut,
restore_settle_ms,
chunk_fetch_timeout_ms,
target_quiescence_ms,
target_fetch_retries,
} => {
assert_eq!(shortcut, PasteShortcut::CtrlV);
assert_eq!(restore_settle_ms, 250);
assert_eq!(chunk_fetch_timeout_ms, 600);
assert_eq!(target_quiescence_ms, 50);
assert_eq!(target_fetch_retries, 2);
}
other => panic!("expected Clipboard child, got {:?}", other),
}
}
other => panic!("expected Chunk root, got {:?}", other),
}
}
#[test]
fn flat_yaml_with_chunk_chars_zero_skips_chunk_wrapper() {
let yaml = r#"
output_dir: /tmp/x
providers: {}
paste:
chunk_chars: 0
"#;
let p = parse_paste(yaml).expect("paste section");
match p.to_tree() {
PasteNodeConfig::Clipboard { .. } => {}
other => panic!("expected Clipboard root for chunk_chars=0, got {:?}", other),
}
}
#[test]
fn tree_yaml_deserialises_as_tree_variant() {
let yaml = r#"
output_dir: /tmp/x
providers: {}
paste:
node: chunk
chunk_chars: 120
child:
node: clipboard
shortcut: ctrl_shift_v
restore_settle_ms: 150
chunk_fetch_timeout_ms: 350
target_quiescence_ms: 60
"#;
let p = parse_paste(yaml).expect("paste section");
match p {
PasteConfig::Tree(t) => match t {
PasteNodeConfig::Chunk { chunk_chars, child } => {
assert_eq!(chunk_chars, 120);
match *child {
PasteNodeConfig::Clipboard {
shortcut,
restore_settle_ms,
chunk_fetch_timeout_ms,
target_quiescence_ms,
target_fetch_retries,
} => {
assert_eq!(shortcut, PasteShortcut::CtrlShiftV);
assert_eq!(restore_settle_ms, 150);
assert_eq!(chunk_fetch_timeout_ms, 350);
assert_eq!(target_quiescence_ms, 60);
assert_eq!(target_fetch_retries, 2);
}
other => panic!("expected Clipboard child, got {:?}", other),
}
}
other => panic!("expected Chunk root, got {:?}", other),
},
PasteConfig::Flat(_) => panic!("expected tree variant for `node:`-tagged YAML"),
}
}
#[test]
fn tree_yaml_with_match_wm_class_routing() {
let yaml = r#"
output_dir: /tmp/x
providers: {}
paste:
node: match-wm-class
patterns:
- match: "firefox.*"
child:
node: clipboard
shortcut: ctrl_v
restore_settle_ms: 200
chunk_fetch_timeout_ms: 400
- match: "*.Emacs"
child:
node: xtest-type
default:
node: clipboard
shortcut: ctrl_shift_v
restore_settle_ms: 200
chunk_fetch_timeout_ms: 400
"#;
let p = parse_paste(yaml).expect("paste section");
match p.to_tree() {
PasteNodeConfig::MatchWmClass { patterns, default } => {
assert_eq!(patterns.len(), 2);
assert_eq!(patterns[0].pattern, "firefox.*");
assert_eq!(patterns[1].pattern, "*.Emacs");
assert!(matches!(*default, PasteNodeConfig::Clipboard { .. }));
}
other => panic!("expected MatchWmClass root, got {:?}", other),
}
}
#[test]
fn missing_paste_section_yields_none_and_default_root_replicates_legacy() {
let yaml = r#"
output_dir: /tmp/x
providers: {}
"#;
let cfg: Config = serde_yaml::from_str(yaml).expect("parses");
assert!(cfg.paste.is_none());
let default = PasteNodeConfig::Chunk {
chunk_chars: super::PASTE_CHUNK_CHARS,
child: Box::new(PasteNodeConfig::Clipboard {
shortcut: PasteShortcut::CtrlShiftV,
restore_settle_ms: super::PasteTiming::default().restore_settle_ms,
chunk_fetch_timeout_ms: super::PasteTiming::default().chunk_fetch_timeout_ms,
target_quiescence_ms: super::PasteTiming::default().target_quiescence_ms,
target_fetch_retries: super::node::DEFAULT_TARGET_FETCH_RETRIES,
}),
};
let timing = super::node::timing_from_tree(&default);
assert_eq!(timing.restore_settle_ms, 200);
assert_eq!(timing.chunk_fetch_timeout_ms, 500);
assert_eq!(timing.target_quiescence_ms, 50);
}
#[test]
fn glob_first_match_wins_in_wm_class_patterns() {
assert!(glob_match("firefox.*", "firefox.Firefox"));
assert!(glob_match("*.Firefox", "firefox.Firefox"));
assert!(glob_match("*", "firefox.Firefox"));
}
#[test]
fn glob_matches_wm_class_strings() {
assert!(glob_match("*.Emacs", "emacs.Emacs"));
assert!(!glob_match("*.Emacs", "vim.Vim"));
assert!(glob_match("Navigator.*", "Navigator.Firefox"));
assert!(glob_match("*", "anything.AtAll"));
}
#[test]
fn no_chunk_paste_strips_chunk_wrappers_anywhere_in_tree() {
let tree = PasteNodeConfig::Chunk {
chunk_chars: 100,
child: Box::new(PasteNodeConfig::MatchWmClass {
patterns: vec![super::node::WmClassPattern {
pattern: "*".to_string(),
child: Box::new(PasteNodeConfig::Chunk {
chunk_chars: 50,
child: Box::new(PasteNodeConfig::Clipboard {
shortcut: PasteShortcut::CtrlV,
restore_settle_ms: 200,
chunk_fetch_timeout_ms: 400,
target_quiescence_ms: 50,
target_fetch_retries: 2,
}),
}),
}],
default: Box::new(PasteNodeConfig::Clipboard {
shortcut: PasteShortcut::CtrlShiftV,
restore_settle_ms: 200,
chunk_fetch_timeout_ms: 400,
target_quiescence_ms: 50,
target_fetch_retries: 2,
}),
}),
};
let stripped = tree.strip_chunks();
match stripped {
PasteNodeConfig::MatchWmClass { patterns, default } => {
assert!(matches!(
*patterns[0].child,
PasteNodeConfig::Clipboard { .. }
));
assert!(matches!(*default, PasteNodeConfig::Clipboard { .. }));
}
other => panic!("expected MatchWmClass after strip, got {:?}", other),
}
}
struct RecordingSink(Arc<Mutex<Vec<String>>>);
#[async_trait]
impl PasteNode for RecordingSink {
async fn paste(
&self,
text: &str,
_ctx: &PasteCtx<'_>,
) -> Result<(), crate::error::TalkError> {
self.0
.lock()
.expect("lock RecordingSink")
.push(text.to_string());
Ok(())
}
}
struct ProgressRecorder(Arc<Mutex<Vec<(u64, u64)>>>);
impl TelemetrySink for ProgressRecorder {
fn emit(&self, ev: TranscriptionEvent) {
if let TranscriptionEvent::PasteProgress {
chars_pasted,
total_chars,
..
} = ev
{
self.0
.lock()
.expect("lock ProgressRecorder")
.push((chars_pasted, total_chars));
}
}
}
#[tokio::test]
async fn chunk_node_forwards_same_chunks_as_split_into_char_chunks() {
let text = "aaa bbb ccc ddd eee fff";
let chunk_chars = 10;
let expected = super::split_into_char_chunks(text, chunk_chars);
let received = Arc::new(Mutex::new(Vec::<String>::new()));
let progress = Arc::new(Mutex::new(Vec::<(u64, u64)>::new()));
let progress_sink = ProgressRecorder(progress.clone());
let chunk = ChunkNode {
chunk_chars,
child: Box::new(RecordingSink(received.clone())),
};
let clipboard = X11Clipboard::new();
let ctx = PasteCtx {
target_window: None,
delete_chars_before_paste: 0,
t_stop: None,
sink: &progress_sink,
clipboard: &clipboard,
target_client_base: None,
expected_target_fetches: Arc::new(std::sync::atomic::AtomicU32::new(0)),
alert: None,
};
chunk.paste(text, &ctx).await.expect("paste");
let got = received.lock().expect("lock received").clone();
assert_eq!(got, expected);
let total = text.len() as u64;
let progress = progress.lock().expect("lock progress").clone();
assert_eq!(progress.len(), expected.len());
let mut cum: u64 = 0;
for ((cp, tc), chunk) in progress.iter().zip(expected.iter()) {
cum += chunk.len() as u64;
assert_eq!(*tc, total);
assert_eq!(*cp, cum);
}
}
#[tokio::test]
async fn chunk_node_with_zero_chunk_chars_pastes_whole_text_once() {
let text = "hello world";
let received = Arc::new(Mutex::new(Vec::<String>::new()));
let chunk = ChunkNode {
chunk_chars: 0,
child: Box::new(RecordingSink(received.clone())),
};
let clipboard = X11Clipboard::new();
let ctx = PasteCtx {
target_window: None,
delete_chars_before_paste: 0,
t_stop: None,
sink: &NoOpSink,
clipboard: &clipboard,
target_client_base: None,
expected_target_fetches: Arc::new(std::sync::atomic::AtomicU32::new(0)),
alert: None,
};
chunk.paste(text, &ctx).await.expect("paste");
let got = received.lock().expect("lock").clone();
assert_eq!(got, vec!["hello world".to_string()]);
}
}