A batch carries the Mattermost user id of each message; the turn's speaker is the username of the one user who wrote all of it (Client::username, kept in a map on the event loop), and None for two or more people or a failed lookup. The turn's text is unchanged. Checks: reference replayed from the task text on a fresh ref-02 worktree with its own build dir, byte-identical; make gate ok with 1148 tests (1138 + 10); /tmp unchanged around both gate runs; 21 mutations, all caught; new tests stressed 40 times under load, no failure; every before block found once at ref 02; no chat-template tags. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
402 lines
12 KiB
Rust
402 lines
12 KiB
Rust
//! Which posts become turns, in which session, and how messages wait for a running turn (M4a spec,
|
|
//! section 7, including its table of examples), with the author of each (M5c spec, section 11).
|
|
//! Do not edit.
|
|
|
|
use bxcore::SessionId;
|
|
use gatewayd::mm::Post;
|
|
use gatewayd::sessions::{
|
|
ApprovalCommand, BUSY, Batch, Ignored, Message, Pushed, Queues, Route, Router, Thread,
|
|
UNKNOWN_COMMAND, named,
|
|
};
|
|
|
|
const BOT: &str = "b0000000000000000000000000";
|
|
const KYLE: &str = "k0000000000000000000000000";
|
|
const EVE: &str = "e0000000000000000000000000";
|
|
const DM: &str = "d0000000000000000000000000";
|
|
const SHARED: &str = "c0000000000000000000000000";
|
|
const OTHER: &str = "o0000000000000000000000000";
|
|
const ROOT: &str = "r0000000000000000000000000";
|
|
const POST: &str = "p0000000000000000000000000";
|
|
|
|
fn router() -> Router {
|
|
Router::new(
|
|
BOT,
|
|
"boxmaker-straylight",
|
|
&[KYLE.to_string()],
|
|
&[SHARED.to_string()],
|
|
)
|
|
}
|
|
|
|
fn post(channel: &str, root: &str, message: &str) -> Post {
|
|
Post {
|
|
id: POST.to_string(),
|
|
user_id: KYLE.to_string(),
|
|
channel_id: channel.to_string(),
|
|
root_id: root.to_string(),
|
|
message: message.to_string(),
|
|
create_at: 5,
|
|
delete_at: 0,
|
|
kind: String::new(),
|
|
}
|
|
}
|
|
|
|
fn known(root: &str) -> bool {
|
|
root == ROOT
|
|
}
|
|
|
|
fn unknown(_: &str) -> bool {
|
|
false
|
|
}
|
|
|
|
fn queued(route: Route) -> Message {
|
|
match route {
|
|
Route::Queue(m) => m,
|
|
other => panic!("not queued: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_examples_in_the_spec() {
|
|
let r = router();
|
|
let cases = [
|
|
("", "@boxmaker-straylight summarise the audit log", true),
|
|
(ROOT, "and the older files?", true),
|
|
(ROOT, "@hermes what do you think?", false),
|
|
("", "@boxmaker-straylight @hermes compare notes", true),
|
|
("", "@boxmaker-straylightx hello", false),
|
|
("", "@channel standup in five", false),
|
|
];
|
|
for (root, message, yes) in cases {
|
|
let got = r.route(&post(SHARED, root, message), "O", &known);
|
|
assert_eq!(matches!(got, Route::Queue(_)), yes, "{message}: {got:?}");
|
|
if !yes {
|
|
assert_eq!(got, Route::Ignore(Ignored::NotForUs), "{message}");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn naming() {
|
|
assert_eq!(named("@Boxmaker-Straylight, look"), ["boxmaker-straylight"]);
|
|
assert_eq!(named("ask @boxmaker-straylight."), ["boxmaker-straylight"]);
|
|
assert_eq!(named("@a.b_c-d... and @e"), ["a.b_c-d", "e"]);
|
|
assert_eq!(named("@ alone, @@x, trailing @"), ["x"]);
|
|
assert_eq!(named("ünïcødé @ʙob @bob"), ["bob"]);
|
|
assert!(named("no names here").is_empty());
|
|
let r = router();
|
|
for message in [
|
|
"hi @BOXMAKER-STRAYLIGHT",
|
|
"@boxmaker-straylight.",
|
|
"(@boxmaker-straylight)",
|
|
] {
|
|
assert!(
|
|
matches!(
|
|
r.route(&post(SHARED, "", message), "P", &unknown),
|
|
Route::Queue(_)
|
|
),
|
|
"{message}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn replies_in_our_thread_that_name_everyone_are_still_ours() {
|
|
let r = router();
|
|
for message in ["@here any news?", "@all done", "thanks @channel"] {
|
|
assert!(
|
|
matches!(
|
|
r.route(&post(SHARED, ROOT, message), "O", &known),
|
|
Route::Queue(_)
|
|
),
|
|
"{message}"
|
|
);
|
|
}
|
|
let got = r.route(
|
|
&post(
|
|
SHARED,
|
|
"q0000000000000000000000000",
|
|
"a reply in someone else's thread",
|
|
),
|
|
"O",
|
|
&known,
|
|
);
|
|
assert_eq!(got, Route::Ignore(Ignored::NotForUs));
|
|
}
|
|
|
|
#[test]
|
|
fn who_and_where() {
|
|
let r = router();
|
|
let mut own = post(DM, "", "hello");
|
|
own.user_id = BOT.to_string();
|
|
assert_eq!(r.route(&own, "D", &unknown), Route::Ignore(Ignored::Own));
|
|
let mut system = post(DM, "", "joined");
|
|
system.kind = "system_join_channel".to_string();
|
|
assert_eq!(
|
|
r.route(&system, "D", &unknown),
|
|
Route::Ignore(Ignored::System)
|
|
);
|
|
let mut stranger = post(DM, "", "@boxmaker-straylight hello");
|
|
stranger.user_id = EVE.to_string();
|
|
assert_eq!(
|
|
r.route(&stranger, "D", &unknown),
|
|
Route::Ignore(Ignored::NotAllowed)
|
|
);
|
|
let mut stranger_cmd = post(DM, "", "!approve 1");
|
|
stranger_cmd.user_id = EVE.to_string();
|
|
assert_eq!(
|
|
r.route(&stranger_cmd, "D", &unknown),
|
|
Route::Ignore(Ignored::NotAllowed)
|
|
);
|
|
let naming = "@boxmaker-straylight hello";
|
|
assert_eq!(
|
|
r.route(&post(OTHER, "", naming), "O", &unknown),
|
|
Route::Ignore(Ignored::NotForUs)
|
|
);
|
|
assert_eq!(
|
|
r.route(&post(SHARED, "", naming), "X", &unknown),
|
|
Route::Ignore(Ignored::NotForUs)
|
|
);
|
|
assert!(matches!(
|
|
r.route(&post(SHARED, "", naming), "G", &unknown),
|
|
Route::Queue(_)
|
|
));
|
|
assert!(matches!(
|
|
r.route(&post(DM, "", "no name needed"), "D", &unknown),
|
|
Route::Queue(_)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn sessions_and_threads() {
|
|
let r = router();
|
|
let top = queued(r.route(&post(DM, "", "hello"), "D", &unknown));
|
|
assert_eq!(top.session.as_str(), format!("mm-{POST}"));
|
|
assert_eq!(
|
|
top.thread,
|
|
Thread {
|
|
channel: DM.to_string(),
|
|
root: POST.to_string()
|
|
}
|
|
);
|
|
assert!(!top.resume && !top.joins_thread);
|
|
assert_eq!(top.text, "hello");
|
|
assert_eq!(top.user, KYLE);
|
|
let reply = queued(r.route(&post(DM, ROOT, "more"), "D", &unknown));
|
|
assert_eq!(reply.session.as_str(), format!("mm-{ROOT}"));
|
|
assert_eq!(reply.thread.root, ROOT);
|
|
assert!(reply.resume);
|
|
let channel = queued(r.route(&post(SHARED, "", "@boxmaker-straylight hi"), "O", &unknown));
|
|
assert!(channel.joins_thread && !channel.resume);
|
|
assert_eq!(channel.text, "hi", "the leading mention is stripped (M4b)");
|
|
}
|
|
|
|
#[test]
|
|
fn commands() {
|
|
let r = router();
|
|
let thread = Thread {
|
|
channel: DM.to_string(),
|
|
root: ROOT.to_string(),
|
|
};
|
|
for (message, command) in [
|
|
("!approve 42", ApprovalCommand::Approve(42)),
|
|
(
|
|
"!deny 42 not now",
|
|
ApprovalCommand::Deny(42, Some("not now".to_string())),
|
|
),
|
|
("!deny", ApprovalCommand::Malformed),
|
|
(
|
|
"@boxmaker-straylight !approve 7",
|
|
ApprovalCommand::Approve(7),
|
|
),
|
|
] {
|
|
assert_eq!(
|
|
r.route(&post(DM, ROOT, message), "D", &unknown),
|
|
Route::Approval {
|
|
thread: thread.clone(),
|
|
command,
|
|
user: KYLE.to_string(),
|
|
post: POST.to_string(),
|
|
at: 5,
|
|
},
|
|
"{message}"
|
|
);
|
|
}
|
|
for message in ["!approved", "!help", "!"] {
|
|
assert_eq!(
|
|
r.route(&post(DM, ROOT, message), "D", &unknown),
|
|
Route::Reply {
|
|
thread: thread.clone(),
|
|
text: UNKNOWN_COMMAND.to_string(),
|
|
},
|
|
"{message}"
|
|
);
|
|
}
|
|
assert_eq!(
|
|
queued(r.route(&post(DM, ROOT, "!!approve is a word"), "D", &unknown)).text,
|
|
"!approve is a word"
|
|
);
|
|
assert_eq!(
|
|
queued(r.route(&post(DM, ROOT, "!!"), "D", &unknown)).text,
|
|
"!"
|
|
);
|
|
assert_eq!(
|
|
queued(r.route(&post(DM, ROOT, " !help"), "D", &unknown)).text,
|
|
" !help"
|
|
);
|
|
}
|
|
|
|
fn message(root: &str, resume: bool, text: &str) -> Message {
|
|
message_by(KYLE, root, resume, text)
|
|
}
|
|
|
|
fn message_by(user: &str, root: &str, resume: bool, text: &str) -> Message {
|
|
Message {
|
|
session: SessionId::new(&format!("mm-{root}")).unwrap(),
|
|
thread: Thread {
|
|
channel: DM.to_string(),
|
|
root: root.to_string(),
|
|
},
|
|
resume,
|
|
text: text.to_string(),
|
|
joins_thread: false,
|
|
user: user.to_string(),
|
|
}
|
|
}
|
|
|
|
fn senders(batch: &Batch) -> Vec<&str> {
|
|
batch.senders.iter().map(String::as_str).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn messages_wait_for_the_running_turn_and_go_together() {
|
|
let mut q = Queues::new(3);
|
|
let first = message(ROOT, false, "one");
|
|
let Pushed::Start(batch) = q.push(first.clone()) else {
|
|
panic!("not started")
|
|
};
|
|
assert_eq!(
|
|
batch,
|
|
Batch {
|
|
session: first.session.clone(),
|
|
thread: first.thread.clone(),
|
|
resume: false,
|
|
text: "one".to_string(),
|
|
senders: vec![KYLE.to_string()],
|
|
}
|
|
);
|
|
assert_eq!(q.push(message(ROOT, true, "two")), Pushed::Waiting);
|
|
assert_eq!(q.push(message(ROOT, true, "three\nlines")), Pushed::Waiting);
|
|
let other = message(POST, false, "elsewhere");
|
|
assert!(
|
|
matches!(q.push(other.clone()), Pushed::Start(_)),
|
|
"another session starts at once"
|
|
);
|
|
assert_eq!(q.running(), 2);
|
|
let mut roots: Vec<String> = q.threads().into_iter().map(|t| t.root).collect();
|
|
roots.sort();
|
|
assert_eq!(roots, [POST, ROOT]);
|
|
let next = q.finish(&first.session).unwrap();
|
|
assert_eq!(
|
|
(next.text.as_str(), next.resume),
|
|
("two\n\nthree\nlines", true)
|
|
);
|
|
assert_eq!(q.finish(&first.session), None);
|
|
assert_eq!(q.finish(&other.session), None);
|
|
assert_eq!(q.running(), 0);
|
|
assert!(
|
|
matches!(q.push(message(ROOT, true, "later")), Pushed::Start(_)),
|
|
"idle again"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_full_queue_drops_the_message() {
|
|
let mut q = Queues::new(2);
|
|
let m = message(ROOT, false, "run");
|
|
assert!(matches!(q.push(m.clone()), Pushed::Start(_)));
|
|
assert_eq!(q.push(message(ROOT, true, "a")), Pushed::Waiting);
|
|
assert_eq!(q.push(message(ROOT, true, "b")), Pushed::Waiting);
|
|
assert_eq!(
|
|
q.push(message_by(EVE, ROOT, true, "c")),
|
|
Pushed::Full(m.thread.clone())
|
|
);
|
|
let next = q.finish(&m.session).unwrap();
|
|
assert_eq!(next.text, "a\n\nb");
|
|
assert_eq!(
|
|
senders(&next),
|
|
[KYLE, KYLE],
|
|
"the dropped message's author is not kept"
|
|
);
|
|
assert_eq!(next.sender(), Some(KYLE));
|
|
assert!(!BUSY.is_empty());
|
|
assert_eq!(q.finish(&SessionId::new("mm-never").unwrap()), None);
|
|
}
|
|
|
|
#[test]
|
|
fn a_queued_message_names_its_author() {
|
|
let r = Router::new(
|
|
BOT,
|
|
"boxmaker-straylight",
|
|
&[KYLE.to_string(), EVE.to_string()],
|
|
&[SHARED.to_string()],
|
|
);
|
|
for (text, expected) in [
|
|
("hello", "hello"),
|
|
("!!approve is a word", "!approve is a word"),
|
|
] {
|
|
let mut from_eve = post(DM, ROOT, text);
|
|
from_eve.user_id = EVE.to_string();
|
|
let m = queued(r.route(&from_eve, "D", &unknown));
|
|
assert_eq!(
|
|
(m.user.as_str(), m.text.as_str()),
|
|
(EVE, expected),
|
|
"{text}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_batch_carries_the_author_of_each_message() {
|
|
let mut q = Queues::new(5);
|
|
let Pushed::Start(first) = q.push(message(ROOT, false, "one")) else {
|
|
panic!("not started")
|
|
};
|
|
assert_eq!((senders(&first), first.sender()), (vec![KYLE], Some(KYLE)));
|
|
assert_eq!(q.push(message_by(EVE, ROOT, true, "two")), Pushed::Waiting);
|
|
assert_eq!(q.push(message(ROOT, true, "three")), Pushed::Waiting);
|
|
let session = SessionId::new(&format!("mm-{ROOT}")).unwrap();
|
|
let mixed = q.finish(&session).unwrap();
|
|
assert_eq!(mixed.text, "two\n\nthree", "the text is joined as before");
|
|
assert_eq!(senders(&mixed), [EVE, KYLE]);
|
|
assert_eq!(mixed.sender(), None, "two people: no one sender");
|
|
assert_eq!(q.push(message_by(EVE, ROOT, true, "four")), Pushed::Waiting);
|
|
assert_eq!(q.push(message_by(EVE, ROOT, true, "five")), Pushed::Waiting);
|
|
let one = q.finish(&session).unwrap();
|
|
assert_eq!(
|
|
(senders(&one), one.sender()),
|
|
(vec![EVE, EVE], Some(EVE)),
|
|
"each batch has only its own messages' authors"
|
|
);
|
|
assert_eq!(q.finish(&session), None);
|
|
}
|
|
|
|
#[test]
|
|
fn a_batch_with_no_authors_has_no_sender() {
|
|
let batch = Batch {
|
|
session: SessionId::new(&format!("mm-{ROOT}")).unwrap(),
|
|
thread: Thread {
|
|
channel: DM.to_string(),
|
|
root: ROOT.to_string(),
|
|
},
|
|
resume: false,
|
|
text: "orphan".to_string(),
|
|
senders: Vec::new(),
|
|
};
|
|
assert_eq!(batch.sender(), None);
|
|
let three = Batch {
|
|
senders: vec![KYLE.to_string(), KYLE.to_string(), EVE.to_string()],
|
|
..batch
|
|
};
|
|
assert_eq!(three.sender(), None, "the last one differs");
|
|
}
|