open-direct lows: the open hears the mouth's refusal; one copy of the daily policy text #37
5 changed files with 144 additions and 23 deletions
|
|
@ -151,6 +151,19 @@ impl<'de> Deserialize<'de> for Refusal {
|
|||
}
|
||||
}
|
||||
|
||||
/// The mouth refused a request outright with a typed refusal frame, and nothing was done. An error of `open_direct` when the
|
||||
/// mouth does not serve the kind: told as not sent, unlike a silent mouth, whose open may have gone out.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct MouthRefused(pub Refusal);
|
||||
|
||||
impl std::fmt::Display for MouthRefused {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "the mouth refused the request: {}", self.0.name())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for MouthRefused {}
|
||||
|
||||
/// The mouth's typed answer to a request that names a Persona it does not
|
||||
/// serve: the pending request, the ack and the send alike.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ use serde::de::DeserializeOwned;
|
|||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::chat_client::{
|
||||
ACK, Chat, ChatAck, ChatDelivery, ChatOpenDirectReceipt, ChatOpenDirectRequest, ChatPending, ChatPendingRequest, ChatRead, ChatRefusal, ChatSendReceipt, ChatSendRequest, PENDING, PENDING_REQUEST, OPEN_DIRECT_RECEIPT, OPEN_DIRECT_REQUEST, PENDING_V1, READ, REFUSAL, SEND_RECEIPT,
|
||||
ACK, Chat, ChatAck, ChatDelivery, ChatOpenDirectReceipt, ChatOpenDirectRequest, ChatPending, ChatPendingRequest, ChatRead, ChatRefusal, MouthRefused, Refusal, ChatSendReceipt, ChatSendRequest, PENDING, PENDING_REQUEST, OPEN_DIRECT_RECEIPT, OPEN_DIRECT_REQUEST, PENDING_V1, READ, REFUSAL, SEND_RECEIPT,
|
||||
SEND_REQUEST, decode, encode,
|
||||
};
|
||||
|
||||
|
|
@ -92,14 +92,14 @@ impl RudpChat {
|
|||
Ok(Link { transport, runtime_id })
|
||||
}
|
||||
|
||||
/// Puts one document and, when `answer` names a kind and record key, waits for
|
||||
/// Puts one document and, when `answer` names (kind, record key) pairs, waits for
|
||||
/// the document the mouth answers it with. Documents of other kinds or keys
|
||||
/// (a late receipt of an earlier send) are skipped.
|
||||
///
|
||||
/// A failed exchange drops the link: a restarted mouth silently ignores packets from a sender it has not connected, so a held link
|
||||
/// would time out on every call after it. The next call opens a fresh one. Nothing is repeated on it: the caller's request id is
|
||||
/// what makes a repeat of a send the same send.
|
||||
fn exchange<T: Serialize>(&self, kind: &str, record_key: &str, document: &T, answer: Option<(&[&str], &str)>) -> Result<Option<(String, Vec<u8>)>> {
|
||||
fn exchange<T: Serialize>(&self, kind: &str, record_key: &str, document: &T, answer: Option<&[(&str, &str)]>) -> Result<Option<(String, Vec<u8>)>> {
|
||||
let result = self.exchange_on_link(kind, record_key, document, answer);
|
||||
if result.is_err()
|
||||
&& let Some(mut link) = self.link.borrow_mut().take()
|
||||
|
|
@ -109,7 +109,7 @@ impl RudpChat {
|
|||
result
|
||||
}
|
||||
|
||||
fn exchange_on_link<T: Serialize>(&self, kind: &str, record_key: &str, document: &T, answer: Option<(&[&str], &str)>) -> Result<Option<(String, Vec<u8>)>> {
|
||||
fn exchange_on_link<T: Serialize>(&self, kind: &str, record_key: &str, document: &T, answer: Option<&[(&str, &str)]>) -> Result<Option<(String, Vec<u8>)>> {
|
||||
let mut slot = self.link.borrow_mut();
|
||||
if slot.is_none() {
|
||||
*slot = Some(self.open()?);
|
||||
|
|
@ -131,7 +131,7 @@ impl RudpChat {
|
|||
};
|
||||
link.transport.send_schema_message(&message)?;
|
||||
let deadline = Instant::now() + self.reply_timeout;
|
||||
let Some((answer_kinds, answer_key)) = answer else {
|
||||
let Some(answers) = answer else {
|
||||
// Nothing is answered; the ack is done when the mouth has the packet.
|
||||
let left = deadline.saturating_duration_since(Instant::now());
|
||||
link.transport.flush_reliable(left)?;
|
||||
|
|
@ -144,7 +144,7 @@ impl RudpChat {
|
|||
link.transport.poll_resends()?;
|
||||
if let Some(CultNetMessage::DocumentPutRaw { document, .. }) = link.transport.receive_schema_message_once()? {
|
||||
let tagged = document.tags.as_deref() == Some(&[TAG.to_string()][..]);
|
||||
if tagged && answer_kinds.contains(&document.schema_id.as_str()) && document.record_key == answer_key {
|
||||
if tagged && answers.contains(&(document.schema_id.as_str(), document.record_key.as_str())) {
|
||||
return Ok(Some((document.schema_id, document.payload)));
|
||||
}
|
||||
}
|
||||
|
|
@ -152,7 +152,7 @@ impl RudpChat {
|
|||
}
|
||||
|
||||
fn call<Q: Serialize, A: DeserializeOwned>(&self, kind: &str, key: &str, request: &Q, answer: (&str, &str)) -> Result<A> {
|
||||
let (_, payload) = self.exchange(kind, key, request, Some((&[answer.0][..], answer.1)))?.ok_or_else(|| anyhow!("no answer payload"))?;
|
||||
let (_, payload) = self.exchange(kind, key, request, Some(&[answer]))?.ok_or_else(|| anyhow!("no answer payload"))?;
|
||||
decode(&payload)
|
||||
}
|
||||
}
|
||||
|
|
@ -160,8 +160,8 @@ impl RudpChat {
|
|||
impl Chat for RudpChat {
|
||||
fn pending(&self, after_seq: u64) -> Result<Vec<ChatDelivery>> {
|
||||
let request = ChatPendingRequest { persona: self.persona.clone(), after_seq };
|
||||
let kinds: &[&str] = &[PENDING, PENDING_V1, REFUSAL];
|
||||
let (kind, payload) = self.exchange(PENDING_REQUEST, &self.persona, &request, Some((kinds, self.persona.as_str())))?.ok_or_else(|| anyhow!("no answer payload"))?;
|
||||
let p = self.persona.as_str();
|
||||
let (kind, payload) = self.exchange(PENDING_REQUEST, p, &request, Some(&[(PENDING, p), (PENDING_V1, p), (REFUSAL, p)]))?.ok_or_else(|| anyhow!("no answer payload"))?;
|
||||
if kind == REFUSAL {
|
||||
let refusal: ChatRefusal = decode(&payload)?;
|
||||
bail!("the mouth refused the pending request: {}", refusal.refusal.name());
|
||||
|
|
@ -182,7 +182,19 @@ impl Chat for RudpChat {
|
|||
}
|
||||
|
||||
fn open_direct(&self, request: &ChatOpenDirectRequest) -> Result<ChatOpenDirectReceipt> {
|
||||
let receipt: ChatOpenDirectReceipt = self.call(OPEN_DIRECT_REQUEST, &request.request_id, request, (OPEN_DIRECT_RECEIPT, &request.request_id))?;
|
||||
// A mouth that does not serve the kind answers with a refusal keyed by the persona, not by the request: heard here,
|
||||
// it ends the wait, and the open is known not to have been sent.
|
||||
let p = self.persona.as_str();
|
||||
let answers = [(OPEN_DIRECT_RECEIPT, request.request_id.as_str()), (REFUSAL, p)];
|
||||
let (kind, payload) = self.exchange(OPEN_DIRECT_REQUEST, &request.request_id, request, Some(&answers))?.ok_or_else(|| anyhow!("no answer payload"))?;
|
||||
if kind == REFUSAL {
|
||||
let refusal: ChatRefusal = decode(&payload)?;
|
||||
if refusal.persona != self.persona || refusal.refusal != Refusal::UnknownKind {
|
||||
bail!("the mouth refused the open: {}", refusal.refusal.name());
|
||||
}
|
||||
return Err(MouthRefused(refusal.refusal).into());
|
||||
}
|
||||
let receipt: ChatOpenDirectReceipt = decode(&payload)?;
|
||||
if receipt.request_id != request.request_id {
|
||||
bail!("the mouth answered another open");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ use uuid::Uuid;
|
|||
|
||||
use crate::address::{Addressee, Name, addresses, localpart, project_names};
|
||||
use crate::chat_image::{self, Image, MAX_PER_LINE};
|
||||
use crate::chat_client::{Chat, ChatDelivery, ChatOpenDirectRequest, ChatRead, DirectOutcome, ChatSendRequest, ContextLine, Refusal, SendBasis};
|
||||
use crate::chat_client::{Chat, ChatDelivery, MouthRefused, ChatOpenDirectRequest, ChatRead, DirectOutcome, ChatSendRequest, ContextLine, Refusal, SendBasis};
|
||||
use crate::connector::render_plan;
|
||||
use crate::control::{ControlSource, Effective, HeldReason, derive};
|
||||
use crate::heartbeat::{HeartbeatView, PollTrace};
|
||||
|
|
@ -413,6 +413,12 @@ fn start_in_bounds(ask: &StartSelfRun) -> bool {
|
|||
(1..=BRIEF_MAX_CHARS).contains(&ask.brief.chars().count()) && repo_in_bounds(&ask.repo) && campaign_in_bounds(&ask.campaign)
|
||||
}
|
||||
|
||||
/// The `open_direct` field's description: the base text with the mouth's creation policy before the closing line.
|
||||
fn open_direct_description() -> String {
|
||||
let (ask, tail) = OPEN_DIRECT_BASE.split_once(" Their replies").expect("the base names the reply channel");
|
||||
format!("{ask} {OPEN_DAILY_POLICY} Their replies{tail}")
|
||||
}
|
||||
|
||||
/// The JSON Schema every result of a process is constrained to: one per process, with the room
|
||||
/// enum from the process's conversation and the campaign enum from the mind's campaigns in force
|
||||
/// (none in force: `start_self_run` can only be null). The people she may name and the open items
|
||||
|
|
@ -422,7 +428,11 @@ const REMEMBER_DESCRIPTION: &str = "What you must still know when this conversat
|
|||
const INTENT_DESCRIPTION: &str = "Write this first. What you are going after right now in what you steward, and what this reply does about it. Name the thing: a repo, a run, a commitment, a person, an idea. Waiting is an answer only when you name whose decision you wait on, why it is theirs and not yours, and what this turn does toward it. If you have already asked and nothing more can be done, say so and name the next thing.";
|
||||
const INTENT_MAX_CHARS: usize = 600;
|
||||
const SETTLE_DESCRIPTION: &str = "Close an open item shown under 'You committed to' or 'Asked of you, not yet taken on' in this session: done when you have done it, declined when you will not (a commitment you let go: tell whoever holds the other parts), wrong when it was a mistake. Item is the id shown there. Leave empty when you close none.";
|
||||
const OPEN_DIRECT_DESCRIPTION: &str = "Ask one person to talk with you in a direct message, or null. Only a public conversation may; `with` is an id copied exactly from the people this session showed you, never a Mind and never yourself. `text` is the invitation: the person sees it as the reason before they accept, and it is the first line of the conversation, so say who you are and why you ask. The invitation is the ask: they accept or decline. A decline stands until they write to you first, so do not ask again. The mouth allows one new conversation with a person a day. Their replies reach you in the direct message, not here.";
|
||||
/// The mouth's creation policy as this side states it to the Mind, in the schema and in the DailyLimit notice. The mouth (Bifrost
|
||||
/// `openDirectNow`) is the authority and answers `DailyLimit`; this is a display copy with no channel from the mouth. If the mouth's
|
||||
/// policy changes, change this text with it.
|
||||
const OPEN_DAILY_POLICY: &str = "The mouth allows one new conversation with a person a day.";
|
||||
const OPEN_DIRECT_BASE: &str = "Ask one person to talk with you in a direct message, or null. Only a public conversation may; `with` is an id copied exactly from the people this session showed you, never a Mind and never yourself. `text` is the invitation: the person sees it as the reason before they accept, and it is the first line of the conversation, so say who you are and why you ask. The invitation is the ask: they accept or decline. A decline stands until they write to you first, so do not ask again. Their replies reach you in the direct message, not here.";
|
||||
const START_SELF_RUN_DESCRIPTION: &str = "A brief for your Self: it reaches your live Self within a minute, or starts one. It is also how you answer a `reply` line; your Self's replies to your briefs come back as `reply` lines.";
|
||||
const FOR_ITEM_DESCRIPTION: &str = "The open commitment or ask this Self run answers: null, an open item id shown in this session, or new:<i> for remember[i] (sort Asked or Committed) written in this same reply. An item taken on leaves the open lists.";
|
||||
|
||||
|
|
@ -491,7 +501,7 @@ pub fn reply_schema(rooms: &[String], campaigns: &[String], may_open: bool) -> S
|
|||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": OPEN_DIRECT_DESCRIPTION,
|
||||
"description": open_direct_description(),
|
||||
"required": ["with", "text"],
|
||||
"properties": { "with": { "type": "string" }, "text": { "type": "string", "minLength": 1 } }
|
||||
}
|
||||
|
|
@ -1921,9 +1931,13 @@ fn act<C: ControlSource, S: ControlSource>(inbox: &PersonaInbox<'_, C, S>, batch
|
|||
let request_id = if finality.revised { format!("{batch_id}-r{turn}-d") } else { format!("{batch_id}-d") };
|
||||
let request = ChatOpenDirectRequest { request_id, persona: instance.0.clone(), user_id: ask.with.clone(), text: ask.text.clone() };
|
||||
// A refusal and a transport fault are told, never a failed step: the memories and settles of this reply still land.
|
||||
let outcome = organs.chat.open_direct(&request).ok().map(|receipt| receipt.outcome);
|
||||
outputs.push(TraceItem { provenance: "her own".into(), room: String::new(), text: format!("open_direct {}: {}", ask.with, outcome.map_or("unanswered", DirectOutcome::name)) });
|
||||
notice = Some(organ_notice(unix_ms(organs.clock.now()), &open_told(&ask.with, outcome)));
|
||||
let answer = organs.chat.open_direct(&request);
|
||||
// A mouth that refused the kind outright did nothing; a silent one may have.
|
||||
let unserved = answer.as_ref().err().and_then(|error| error.downcast_ref::<MouthRefused>()).map(|refused| refused.0.name());
|
||||
let outcome = answer.ok().map(|receipt| receipt.outcome);
|
||||
outputs.push(TraceItem { provenance: "her own".into(), room: String::new(), text: format!("open_direct {}: {}", ask.with, unserved.or(outcome.map(DirectOutcome::name)).unwrap_or("unanswered")) });
|
||||
let told = if unserved.is_some() { format!("Your invitation to {} was not sent: the mouth does not serve invitations yet.", ask.with) } else { open_told(&ask.with, outcome) };
|
||||
notice = Some(organ_notice(unix_ms(organs.clock.now()), &told));
|
||||
}
|
||||
// The one origin of this turn: its memories and its brief record the same conversation.
|
||||
let origin = origin_of(config, batch, &inbox.origin);
|
||||
|
|
@ -2903,7 +2917,7 @@ fn open_told(with: &str, outcome: Option<DirectOutcome>) -> String {
|
|||
Some(D::NoSuchUser) => format!("The mouth knows no such user as {with}."),
|
||||
Some(D::InvitePending) => format!("{with} has not answered your earlier invitation; it stays open. Do not send another."),
|
||||
Some(D::Declined) => format!("{with} declined your invitation or left that conversation. That stands until they write to you first; do not ask again."),
|
||||
Some(D::DailyLimit) => format!("The mouth allows one new conversation with a person a day, and you opened one with {with} today. Try again tomorrow if it still matters, or wait for them to write."),
|
||||
Some(D::DailyLimit) => format!("{OPEN_DAILY_POLICY} You opened one with {with} today. Try again tomorrow if it still matters, or wait for them to write."),
|
||||
Some(refused @ (D::PersonaMismatch | D::UnknownPersona | D::EmptyText | D::SendFailed)) => format!("Your invitation to {with} was not sent: the mouth refused it ({}).", refused.name()),
|
||||
None => format!("The mouth did not answer your invitation to {with}, so it may or may not have gone out. Do not repeat it unless they stay silent."),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,6 +80,8 @@ struct FakeChat {
|
|||
opens: RefCell<Vec<ChatOpenDirectRequest>>,
|
||||
/// How the fake mouth answers an open; none is a transport error.
|
||||
open_outcome: Cell<Option<DirectOutcome>>,
|
||||
/// The mouth refuses the open kind outright (an older mouth): the open errors with the typed refusal.
|
||||
open_unserved: Cell<bool>,
|
||||
}
|
||||
|
||||
impl FakeChat {
|
||||
|
|
@ -150,6 +152,9 @@ impl Chat for FakeChat {
|
|||
fn open_direct(&self, request: &ChatOpenDirectRequest) -> Result<ChatOpenDirectReceipt> {
|
||||
self.log.borrow_mut().push("open".into());
|
||||
self.opens.borrow_mut().push(request.clone());
|
||||
if self.open_unserved.get() {
|
||||
return Err(crate::chat_client::MouthRefused(Refusal::UnknownKind).into());
|
||||
}
|
||||
let Some(outcome) = self.open_outcome.get() else { return Err(anyhow!("CANARY-open-down {}", request.user_id)) };
|
||||
let landed = matches!(outcome, DirectOutcome::Opened | DirectOutcome::Existing);
|
||||
Ok(ChatOpenDirectReceipt { request_id: request.request_id.clone(), outcome, room_id: landed.then(|| "!dm:chat".to_string()), event_id: landed.then(|| "$open".to_string()) })
|
||||
|
|
@ -448,6 +453,7 @@ impl Rig {
|
|||
keep_on_ack: Cell::new(false),
|
||||
opens: RefCell::default(),
|
||||
open_outcome: Cell::new(Some(DirectOutcome::Opened)),
|
||||
open_unserved: Cell::new(false),
|
||||
},
|
||||
model: FakeSession {
|
||||
log: log.clone(),
|
||||
|
|
@ -7799,7 +7805,7 @@ fn a_held_brake_stops_the_open() {
|
|||
fn a_refused_or_unanswered_open_is_told_and_the_turn_stands() {
|
||||
let cases: [(Option<DirectOutcome>, &str); 4] = [
|
||||
(Some(DirectOutcome::Declined), "until they write to you first"),
|
||||
(Some(DirectOutcome::InvitePending), "has not answered"),
|
||||
(Some(DirectOutcome::InvitePending), "has not answered your earlier invitation; it stays open. Do not send another."),
|
||||
(None, "did not answer"),
|
||||
(Some(DirectOutcome::DailyLimit), "today"),
|
||||
];
|
||||
|
|
@ -7822,6 +7828,29 @@ fn a_refused_or_unanswered_open_is_told_and_the_turn_stands() {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_open_the_mouth_does_not_serve_is_told_as_not_sent() {
|
||||
let kept = json!({ "intent": "Ask them over", "say": [{ "room": "swarm", "text": "yes" }], "open_direct": { "with": "@ann:chat", "text": "Hello Ann" }, "start_self_run": null, "remember": [], "settle": [], "urgency_permille": null }).to_string();
|
||||
let rig = public_rig_with_a_next_line(&[kept, says(&[])]);
|
||||
rig.chat.open_unserved.set(true);
|
||||
let Outcome::Served { failure, .. } = rig.go() else { panic!("served") };
|
||||
assert_eq!(failure, None);
|
||||
let line = input_of(&rig, 1);
|
||||
assert!(line.contains("was not sent") && !line.contains("may or may not"), "{line}");
|
||||
let trace = rig.trace.0.borrow();
|
||||
assert!(trace[0].outputs.iter().any(|item| item.text == "open_direct @ann:chat: UnknownKind"), "{trace:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_daily_creation_policy_has_one_text_in_the_schema_and_the_notice() {
|
||||
let schema = reply_schema(&["swarm".to_string()], &[], true);
|
||||
let told = open_told("@ann:chat", Some(DirectOutcome::DailyLimit));
|
||||
assert!(told.contains(OPEN_DAILY_POLICY), "{told}");
|
||||
let schema: Value = serde_json::from_str(&schema).unwrap();
|
||||
let described = schema.pointer("/properties/open_direct/anyOf/1/description").and_then(Value::as_str).unwrap_or_default();
|
||||
assert!(described.contains(OPEN_DAILY_POLICY), "{described}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pending_invitation_is_told_not_to_be_sent_again() {
|
||||
let told = open_told("@ann:chat", Some(DirectOutcome::InvitePending));
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ use std::thread::JoinHandle;
|
|||
use std::time::Duration;
|
||||
|
||||
use cultnet_rs::{CultNetMessage, CultNetRawDocumentRecord, CultNetRawPayloadEncoding, CultNetRudpSocketTransportConnection, CultNetRudpSocketTransportOptions};
|
||||
use mind_body::chat_client::{self, ACK, Chat, ChatAck, ChatOpenDirectReceipt, ChatOpenDirectRequest, DirectOutcome, OPEN_DIRECT_RECEIPT, OPEN_DIRECT_REQUEST, ChatPending, ChatPendingRequest, ChatRead, ChatSendReceipt, ChatSendRequest, PENDING, PENDING_REQUEST, PENDING_V1, READ, SEND_RECEIPT, SEND_REQUEST};
|
||||
use mind_body::chat_client::{self, ChatRefusal, MouthRefused, Refusal, REFUSAL, ACK, Chat, ChatAck, ChatOpenDirectReceipt, ChatOpenDirectRequest, DirectOutcome, OPEN_DIRECT_RECEIPT, OPEN_DIRECT_REQUEST, ChatPending, ChatPendingRequest, ChatRead, ChatSendReceipt, ChatSendRequest, PENDING, PENDING_REQUEST, PENDING_V1, READ, SEND_RECEIPT, SEND_REQUEST};
|
||||
use mind_body::chat_rudp::{RudpChat, connection_id};
|
||||
|
||||
const ACK_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v1/gamecult.persona.chat_ack.v1.msgpack");
|
||||
|
|
@ -23,11 +23,16 @@ const PENDING_REQUEST_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v1/ga
|
|||
const SEND_RECEIPT_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v2/gamecult.persona.chat_send_receipt.v2.sent.msgpack");
|
||||
const SEND_REQUEST_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v2/gamecult.persona.chat_send_request.v2.nil-basis.msgpack");
|
||||
const OPEN_REQUEST_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v4/gamecult.persona.chat_open_direct_request.v1.msgpack");
|
||||
const UNKNOWN_KIND_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v4/gamecult.persona.chat_refusal.v1.unknown-kind.msgpack");
|
||||
const REFUSAL_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v1/gamecult.persona.chat_refusal.v1.msgpack");
|
||||
const OPENED_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v4/gamecult.persona.chat_open_direct_receipt.v1.opened.msgpack");
|
||||
|
||||
const KEY: &str = "connection-key-for-test";
|
||||
const TAG: &str = "cultnet.transport.rudp.v0";
|
||||
|
||||
/// What a fixture mouth answers an open with: the document kind, a record key (none: the request's own), and its bytes.
|
||||
type OpenReply = (&'static str, Option<&'static str>, &'static [u8]);
|
||||
|
||||
type Seen = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
struct Mouth {
|
||||
|
|
@ -70,15 +75,15 @@ fn mouth(key: &str) -> Mouth {
|
|||
|
||||
/// A mouth that has no image field yet: it answers a pending request in the v1 kind.
|
||||
fn legacy_mouth(key: &str) -> Mouth {
|
||||
serve(key, "127.0.0.1:0", PENDING_V1, PENDING_V1_BYTES)
|
||||
serve(key, "127.0.0.1:0", PENDING_V1, PENDING_V1_BYTES, (OPEN_DIRECT_RECEIPT, None, OPENED_BYTES))
|
||||
}
|
||||
|
||||
/// The same mouth on a chosen address: a restart binds the address the old one held.
|
||||
fn mouth_at(key: &str, bind: &str) -> Mouth {
|
||||
serve(key, bind, PENDING, PENDING_BYTES)
|
||||
serve(key, bind, PENDING, PENDING_BYTES, (OPEN_DIRECT_RECEIPT, None, OPENED_BYTES))
|
||||
}
|
||||
|
||||
fn serve(key: &str, bind: &str, pending_kind: &'static str, pending_bytes: &'static [u8]) -> Mouth {
|
||||
fn serve(key: &str, bind: &str, pending_kind: &'static str, pending_bytes: &'static [u8], open: OpenReply) -> Mouth {
|
||||
let socket = UdpSocket::bind(bind).unwrap();
|
||||
socket.set_read_timeout(Some(Duration::from_millis(20))).unwrap();
|
||||
let address = socket.local_addr().unwrap();
|
||||
|
|
@ -98,7 +103,7 @@ fn serve(key: &str, bind: &str, pending_kind: &'static str, pending_bytes: &'sta
|
|||
let reply = match document.schema_id.as_str() {
|
||||
PENDING_REQUEST if for_eureka(&chat_client::decode::<ChatPendingRequest>(&document.payload).unwrap().persona) => Some(answer(pending_kind, "eureka", pending_bytes)),
|
||||
SEND_REQUEST if for_eureka(&chat_client::decode::<ChatSendRequest>(&document.payload).unwrap().persona) => Some(answer(SEND_RECEIPT, &document.record_key, SEND_RECEIPT_BYTES)),
|
||||
OPEN_DIRECT_REQUEST if for_eureka(&chat_client::decode::<ChatOpenDirectRequest>(&document.payload).unwrap().persona) => Some(answer(OPEN_DIRECT_RECEIPT, &document.record_key, OPENED_BYTES)),
|
||||
OPEN_DIRECT_REQUEST if for_eureka(&chat_client::decode::<ChatOpenDirectRequest>(&document.payload).unwrap().persona) => Some(answer(open.0, open.1.unwrap_or(&document.record_key), open.2)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(reply) = reply {
|
||||
|
|
@ -231,3 +236,51 @@ fn an_open_crosses_a_real_rudp_session_in_the_mouths_bytes_and_a_receipt_for_ano
|
|||
let seen = mouth.seen.lock().unwrap().clone();
|
||||
assert_eq!(seen[0], (OPEN_DIRECT_REQUEST.to_string(), OPEN_REQUEST_BYTES.to_vec()));
|
||||
}
|
||||
|
||||
/// A mouth that answers every open with `reply`.
|
||||
fn opening_with(reply: OpenReply) -> Mouth {
|
||||
serve(KEY, "127.0.0.1:0", PENDING, PENDING_BYTES, reply)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mouth_that_does_not_serve_the_open_kind_refuses_it_and_the_open_is_told_not_sent_at_once() {
|
||||
let mouth = opening_with((REFUSAL, Some("eureka"), UNKNOWN_KIND_BYTES));
|
||||
let chat = client(&mouth, KEY, "eureka", Duration::from_secs(20));
|
||||
let request = chat_client::decode::<ChatOpenDirectRequest>(OPEN_REQUEST_BYTES).unwrap();
|
||||
let started = std::time::Instant::now();
|
||||
let error = chat.open_direct(&request).unwrap_err();
|
||||
assert!(started.elapsed() < Duration::from_secs(5), "the refusal ends the wait: {:?}", started.elapsed());
|
||||
assert_eq!(error.downcast_ref::<MouthRefused>(), Some(&MouthRefused(Refusal::UnknownKind)), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refusal_for_another_persona_or_another_kind_does_not_satisfy_the_open() {
|
||||
let wrong_persona = opening_with((REFUSAL, Some("someone-else"), UNKNOWN_KIND_BYTES));
|
||||
let request = chat_client::decode::<ChatOpenDirectRequest>(OPEN_REQUEST_BYTES).unwrap();
|
||||
let error = client(&wrong_persona, KEY, "eureka", Duration::from_millis(800)).open_direct(&request).unwrap_err();
|
||||
assert!(error.downcast_ref::<MouthRefused>().is_none(), "a refusal keyed by another persona is not heard: {error}");
|
||||
let wrong_kind = opening_with((SEND_RECEIPT, Some("eureka"), SEND_RECEIPT_BYTES));
|
||||
let error = client(&wrong_kind, KEY, "eureka", Duration::from_millis(800)).open_direct(&request).unwrap_err();
|
||||
assert!(error.downcast_ref::<MouthRefused>().is_none(), "another kind is not heard: {error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refusal_of_another_kind_ends_the_wait_but_is_not_told_as_not_sent() {
|
||||
// The fixture refusal is persona "ghost", UnknownPersona: the mouth refuses this Persona's open for a reason other than the kind.
|
||||
let mouth = opening_with((REFUSAL, Some("ghost"), REFUSAL_BYTES));
|
||||
let request = chat_client::decode::<ChatOpenDirectRequest>(OPEN_REQUEST_BYTES).unwrap();
|
||||
let started = std::time::Instant::now();
|
||||
let error = client(&mouth, KEY, "ghost", Duration::from_secs(20)).open_direct(&request).unwrap_err();
|
||||
assert!(started.elapsed() < Duration::from_secs(5), "the refusal ends the wait: {:?}", started.elapsed());
|
||||
assert!(error.downcast_ref::<MouthRefused>().is_none(), "only UnknownKind is a known not-sent: {error}");
|
||||
assert!(error.to_string().contains("UnknownPersona"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_kind_refusal_keyed_by_this_persona_but_naming_another_is_not_heard_as_not_sent() {
|
||||
let other = chat_client::encode(&ChatRefusal { persona: "someone-else".into(), refusal: Refusal::UnknownKind }).unwrap();
|
||||
let mouth = opening_with((REFUSAL, Some("eureka"), Box::leak(other.into_boxed_slice())));
|
||||
let request = chat_client::decode::<ChatOpenDirectRequest>(OPEN_REQUEST_BYTES).unwrap();
|
||||
let error = client(&mouth, KEY, "eureka", Duration::from_secs(20)).open_direct(&request).unwrap_err();
|
||||
assert!(error.downcast_ref::<MouthRefused>().is_none(), "the payload names another Persona: {error}");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue