use std::cmp::Ordering;
use std::sync::Arc;
use std::time::Duration;
use crate::client::{
CommandFields, Connected, Continuity, MessageOutcome, MessageResult, Recovery, RecoveryOutcome,
Resubscribed, SubscriptionId,
};
use crate::subscription::item_update::{FieldValue, ItemUpdate, SubscriptionSchema, UpdateKind};
#[derive(Debug, Clone)]
pub struct ItemUpdateBuilder {
item_name: String,
item_index: usize,
field_names: Vec<String>,
kind: UpdateKind,
command_fields: Option<(usize, usize)>,
values: Vec<Option<String>>,
changed: Vec<bool>,
}
impl ItemUpdateBuilder {
#[must_use]
pub fn new(
item_name: impl Into<String>,
field_names: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
let field_names: Vec<String> = field_names.into_iter().map(Into::into).collect();
let field_count = field_names.len();
Self {
item_name: item_name.into(),
item_index: 1,
field_names,
kind: UpdateKind::RealTime,
command_fields: None,
values: vec![None; field_count],
changed: vec![false; field_count],
}
}
#[must_use]
pub const fn kind(mut self, kind: UpdateKind) -> Self {
self.kind = kind;
self
}
#[must_use]
pub fn item_index(mut self, position: usize) -> Self {
assert!(
position > 0,
"item positions are 1-based, so `0` is not one"
);
self.item_index = position;
self
}
#[must_use]
pub fn command_fields(mut self, key: usize, command: usize) -> Self {
self.check_field_position("key", key);
self.check_field_position("command", command);
self.command_fields = Some((key, command));
self
}
#[must_use]
pub fn changed(self, field: &str, value: FieldValue<'_>) -> Self {
self.set(field, value, true)
}
#[must_use]
pub fn unchanged(self, field: &str, value: FieldValue<'_>) -> Self {
self.set(field, value, false)
}
#[must_use]
pub fn build(self) -> ItemUpdate {
let mut items = vec![None; self.item_index];
if let Some(slot) = items.last_mut() {
*slot = Some(self.item_name);
}
let schema = SubscriptionSchema::from_optional_names(
items,
self.field_names.into_iter().map(Some).collect(),
self.command_fields
.map(|(key, command)| (key.saturating_sub(1), command.saturating_sub(1))),
);
ItemUpdate::new(
Arc::new(schema),
self.item_index.saturating_sub(1),
self.kind,
self.values,
self.changed
.iter()
.enumerate()
.filter_map(|(index, changed)| changed.then_some(index))
.collect(),
)
}
#[must_use]
fn set(mut self, field: &str, value: FieldValue<'_>, changed: bool) -> Self {
let index = self.field_index(field);
if let Some(slot) = self.values.get_mut(index) {
*slot = value.text().map(str::to_owned);
}
if let Some(flag) = self.changed.get_mut(index) {
*flag = changed;
}
self
}
#[must_use]
fn field_index(&self, field: &str) -> usize {
match self.field_names.iter().position(|name| name == field) {
Some(index) => index,
None => panic!(
"`{field}` is not a field of this update; its schema is {:?}",
self.field_names
),
}
}
fn check_field_position(&self, role: &str, position: usize) {
let count = self.field_names.len();
assert!(
position >= 1 && position <= count,
"the {role} field is at position {position}, but this update has \
{count} field(s), numbered from 1"
);
}
}
#[derive(Debug, Clone)]
pub struct ConnectedBuilder {
session_id: String,
continuity: Continuity,
keepalive: Duration,
request_limit_bytes: u64,
}
impl ConnectedBuilder {
#[must_use]
pub fn new(session_id: impl Into<String>, continuity: Continuity) -> Self {
Self {
session_id: session_id.into(),
continuity,
keepalive: DEFAULT_TEST_KEEPALIVE,
request_limit_bytes: DEFAULT_TEST_REQUEST_LIMIT_BYTES,
}
}
#[must_use]
pub const fn keepalive(mut self, keepalive: Duration) -> Self {
self.keepalive = keepalive;
self
}
#[must_use]
pub const fn request_limit_bytes(mut self, limit: u64) -> Self {
self.request_limit_bytes = limit;
self
}
#[must_use]
pub fn build(self) -> Connected {
Connected::from_parts(
self.session_id,
self.continuity,
self.keepalive,
self.request_limit_bytes,
)
}
}
const DEFAULT_TEST_KEEPALIVE: Duration = Duration::from_secs(5);
const DEFAULT_TEST_REQUEST_LIMIT_BYTES: u64 = 50_000;
#[must_use]
pub fn recovery(requested_from: u64, resumed_at: u64) -> Recovery {
let outcome = match resumed_at.cmp(&requested_from) {
Ordering::Equal => RecoveryOutcome::Exact,
Ordering::Less => match requested_from.checked_sub(resumed_at) {
Some(suppressed) => RecoveryOutcome::Duplicated { suppressed },
None => RecoveryOutcome::Exact,
},
Ordering::Greater => match resumed_at.checked_sub(requested_from) {
Some(missing) => RecoveryOutcome::Gap { missing },
None => RecoveryOutcome::Exact,
},
};
Recovery::from_parts(requested_from, resumed_at, outcome)
}
#[derive(Debug, Clone, Copy)]
pub struct ResubscribedBuilder {
id: SubscriptionId,
was_active: bool,
snapshot_restarting: bool,
}
impl ResubscribedBuilder {
#[must_use]
pub const fn new(id: SubscriptionId) -> Self {
Self {
id,
was_active: false,
snapshot_restarting: false,
}
}
#[must_use]
pub const fn was_active(mut self, was_active: bool) -> Self {
self.was_active = was_active;
self
}
#[must_use]
pub const fn snapshot_restarting(mut self, snapshot_restarting: bool) -> Self {
self.snapshot_restarting = snapshot_restarting;
self
}
#[must_use]
pub const fn build(self) -> Resubscribed {
Resubscribed::from_parts(self.id, self.was_active, self.snapshot_restarting)
}
}
#[must_use]
pub const fn subscription_id(id: u64) -> SubscriptionId {
SubscriptionId::from_raw(id)
}
#[derive(Debug, Clone)]
pub struct MessageOutcomeBuilder {
sequence: Option<String>,
progressive: Option<u64>,
result: MessageResult,
}
impl MessageOutcomeBuilder {
#[must_use]
pub const fn new(result: MessageResult) -> Self {
Self {
sequence: None,
progressive: None,
result,
}
}
#[must_use]
pub fn sequence(mut self, name: impl Into<String>) -> Self {
self.sequence = Some(name.into());
self
}
#[must_use]
pub const fn progressive(mut self, progressive: u64) -> Self {
self.progressive = Some(progressive);
self
}
#[must_use]
pub fn build(self) -> MessageOutcome {
MessageOutcome::from_parts(self.sequence, self.progressive, self.result)
}
}
#[must_use]
pub fn command_fields(key: usize, command: usize) -> CommandFields {
assert!(
key >= 1 && command >= 1,
"field positions are 1-based, so `0` is not one: key {key}, command {command}"
);
CommandFields::from_parts(key, command)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::ServerError;
use crate::subscription::item_update::ItemCommand;
fn quote() -> ItemUpdateBuilder {
ItemUpdateBuilder::new("CS.D.EURUSD.CFD.IP", ["BID", "OFFER", "STATUS"])
}
#[test]
fn test_built_update_answers_every_accessor() {
let update = quote()
.kind(UpdateKind::Snapshot)
.changed("BID", FieldValue::Text("1.0921"))
.unchanged("OFFER", FieldValue::Text("1.0923"))
.build();
assert_eq!(update.item_index(), 1);
assert_eq!(update.item_name(), "CS.D.EURUSD.CFD.IP");
assert_eq!(update.declared_item_name(), Some("CS.D.EURUSD.CFD.IP"));
assert_eq!(update.kind(), UpdateKind::Snapshot);
assert!(update.is_snapshot());
assert_eq!(update.field_count(), 3);
assert_eq!(update.field_name(2), Some("OFFER"));
assert_eq!(update.field_position("STATUS"), Some(3));
assert_eq!(update.field(1), Some(FieldValue::Text("1.0921")));
assert_eq!(
update.field_by_name("OFFER"),
Some(FieldValue::Text("1.0923"))
);
assert_eq!(update.changed_count(), 1);
assert!(update.is_field_changed(1));
assert!(update.is_field_changed_by_name("BID"));
assert!(!update.is_command_mode());
assert_eq!(update.key(), None);
assert_eq!(update.command(), None);
let fields: Vec<(usize, &str, FieldValue<'_>, bool)> = update
.fields()
.map(|field| {
(
field.position(),
field.name(),
field.value(),
field.is_changed(),
)
})
.collect();
assert_eq!(
fields,
vec![
(1, "BID", FieldValue::Text("1.0921"), true),
(2, "OFFER", FieldValue::Text("1.0923"), false),
(3, "STATUS", FieldValue::Null, false),
]
);
}
#[test]
fn test_defaults_are_real_time_item_one_and_all_null() {
let update = quote().build();
assert_eq!(update.kind(), UpdateKind::RealTime);
assert!(!update.is_snapshot());
assert_eq!(update.item_index(), 1);
assert_eq!(update.changed_count(), 0);
for field in update.fields() {
assert_eq!(field.value(), FieldValue::Null, "{}", field.name());
assert!(!field.is_changed(), "{}", field.name());
}
}
#[test]
fn test_item_index_positions_the_item() {
let update = quote().item_index(4).build();
assert_eq!(update.item_index(), 4);
assert_eq!(update.item_name(), "CS.D.EURUSD.CFD.IP");
assert_eq!(update.declared_item_name(), Some("CS.D.EURUSD.CFD.IP"));
}
#[test]
fn test_null_and_empty_stay_distinct() {
let update = quote()
.changed("BID", FieldValue::Null)
.changed("OFFER", FieldValue::Text(""))
.build();
assert_eq!(update.field_by_name("BID"), Some(FieldValue::Null));
assert_eq!(update.field_by_name("OFFER"), Some(FieldValue::Text("")));
assert_ne!(update.field_by_name("BID"), update.field_by_name("OFFER"));
assert_eq!(update.field_by_name("BID").and_then(FieldValue::text), None);
assert_eq!(
update.field_by_name("OFFER").and_then(FieldValue::text),
Some("")
);
}
#[test]
fn test_absent_field_and_null_field_are_different_absences() {
let update = quote().build();
assert_eq!(update.field_by_name("BID"), Some(FieldValue::Null));
assert_eq!(update.field_by_name("NO_SUCH_FIELD"), None);
}
#[test]
fn test_changed_and_unchanged_are_reflected() {
let update = quote()
.unchanged("BID", FieldValue::Text("1.0920"))
.changed("OFFER", FieldValue::Text("1.0923"))
.changed("STATUS", FieldValue::Null)
.build();
let changed: Vec<(usize, &str)> = update
.changed_fields()
.map(|field| (field.position(), field.name()))
.collect();
assert_eq!(changed, vec![(2, "OFFER"), (3, "STATUS")]);
assert_eq!(update.changed_count(), 2);
assert!(!update.is_field_changed(1));
assert!(update.is_field_changed(2));
assert!(update.is_field_changed(3));
assert!(!update.is_field_changed_by_name("BID"));
assert!(update.is_field_changed_by_name("OFFER"));
}
#[test]
fn test_changed_fields_come_out_ascending_whatever_the_call_order() {
let update = quote()
.changed("STATUS", FieldValue::Text("TRADEABLE"))
.changed("BID", FieldValue::Text("1.0921"))
.build();
let positions: Vec<usize> = update
.changed_fields()
.map(|field| field.position())
.collect();
assert_eq!(positions, vec![1, 3]);
}
#[test]
fn test_last_call_for_a_field_wins() {
let update = quote()
.changed("BID", FieldValue::Text("1.0921"))
.unchanged("BID", FieldValue::Null)
.build();
assert_eq!(update.field_by_name("BID"), Some(FieldValue::Null));
assert!(!update.is_field_changed_by_name("BID"));
assert_eq!(update.changed_count(), 0);
}
#[test]
fn test_duplicate_field_names_resolve_to_the_first() {
let update = ItemUpdateBuilder::new("item1", ["dup", "dup"])
.changed("dup", FieldValue::Text("first"))
.build();
assert_eq!(update.field_position("dup"), Some(1));
assert_eq!(update.field(1), Some(FieldValue::Text("first")));
assert_eq!(update.field(2), Some(FieldValue::Null));
}
#[test]
fn test_command_fields_make_key_and_command_answer() {
let update = ItemUpdateBuilder::new("PORTFOLIO", ["key", "command", "qty"])
.command_fields(1, 2)
.changed("key", FieldValue::Text("AAPL"))
.changed("command", FieldValue::Text("ADD"))
.changed("qty", FieldValue::Text("10"))
.build();
assert!(update.is_command_mode());
assert_eq!(update.key(), Some(FieldValue::Text("AAPL")));
assert_eq!(update.command(), Some(ItemCommand::Add));
}
#[test]
fn test_without_command_fields_key_and_command_are_none() {
let update = quote().changed("BID", FieldValue::Text("1.0921")).build();
assert!(!update.is_command_mode());
assert_eq!(update.key(), None);
assert_eq!(update.command(), None);
}
#[test]
fn test_a_null_command_field_names_no_operation() {
let update = ItemUpdateBuilder::new("PORTFOLIO", ["key", "command"])
.command_fields(1, 2)
.changed("key", FieldValue::Text("AAPL"))
.changed("command", FieldValue::Null)
.build();
assert!(update.is_command_mode());
assert_eq!(update.key(), Some(FieldValue::Text("AAPL")));
assert_eq!(update.command(), None);
}
#[test]
#[should_panic(expected = "is not a field of this update")]
fn test_setting_an_unknown_field_panics() {
let _ = quote().changed("BIDD", FieldValue::Text("1.0921"));
}
#[test]
#[should_panic(expected = "but this update has 3 field(s)")]
fn test_command_position_past_the_schema_panics() {
let _ = quote().command_fields(1, 4);
}
#[test]
#[should_panic(expected = "numbered from 1")]
fn test_command_position_zero_panics() {
let _ = quote().command_fields(0, 1);
}
#[test]
#[should_panic(expected = "item positions are 1-based")]
fn test_item_index_zero_panics() {
let _ = quote().item_index(0);
}
#[test]
fn test_connected_carries_the_continuity_it_was_given() {
let replaced = ConnectedBuilder::new(
"S5678efgh",
Continuity::Replaced {
previous_session_id: Some("S1234abcd".to_owned()),
},
)
.build();
assert_eq!(replaced.session_id, "S5678efgh");
assert_eq!(
replaced.continuity.state_validity(),
crate::StateValidity::Invalid
);
assert_eq!(
replaced.continuity,
Continuity::Replaced {
previous_session_id: Some("S1234abcd".to_owned()),
}
);
let recovered =
ConnectedBuilder::new("S1234abcd", Continuity::Recovered { requested_from: 42 })
.build();
assert_eq!(
recovered.continuity.state_validity(),
crate::StateValidity::Pending
);
}
#[test]
fn test_connected_negotiated_values_default_and_override() {
let defaulted = ConnectedBuilder::new("S1", Continuity::New).build();
assert_eq!(defaulted.keepalive, DEFAULT_TEST_KEEPALIVE);
assert_eq!(
defaulted.request_limit_bytes,
DEFAULT_TEST_REQUEST_LIMIT_BYTES
);
let chosen = ConnectedBuilder::new("S1", Continuity::New)
.keepalive(Duration::from_millis(1500))
.request_limit_bytes(4096)
.build();
assert_eq!(chosen.keepalive, Duration::from_millis(1500));
assert_eq!(chosen.request_limit_bytes, 4096);
}
#[test]
fn test_recovery_derives_its_outcome_from_the_progressives() {
let exact = recovery(100, 100);
assert_eq!(exact.outcome, RecoveryOutcome::Exact);
assert!(exact.is_lossless());
let duplicated = recovery(100, 97);
assert_eq!(
duplicated.outcome,
RecoveryOutcome::Duplicated { suppressed: 3 }
);
assert!(duplicated.is_lossless());
let gap = recovery(100, 102);
assert_eq!(gap.outcome, RecoveryOutcome::Gap { missing: 2 });
assert!(!gap.is_lossless());
}
#[test]
fn test_recovery_keeps_both_progressives() {
let outcome = recovery(7, 4);
assert_eq!(outcome.requested_from, 7);
assert_eq!(outcome.resumed_at, 4);
}
#[test]
fn test_resubscribed_flags_default_to_false_and_set_by_name() {
let defaulted = ResubscribedBuilder::new(subscription_id(1)).build();
assert!(!defaulted.was_active);
assert!(!defaulted.snapshot_restarting);
let set = ResubscribedBuilder::new(subscription_id(2))
.was_active(true)
.snapshot_restarting(true)
.build();
assert_eq!(set.id, subscription_id(2));
assert!(set.was_active);
assert!(set.snapshot_restarting);
}
#[test]
fn test_subscription_ids_round_trip_and_compare() {
assert_eq!(subscription_id(7).get(), 7);
assert_eq!(subscription_id(7), subscription_id(7));
assert_ne!(subscription_id(7), subscription_id(8));
assert!(
subscription_id(7).to_string().ends_with("subscription#7"),
"{}",
subscription_id(7)
);
}
#[test]
fn test_message_outcome_defaults_to_a_fire_and_forget_shape() {
let outcome = MessageOutcomeBuilder::new(MessageResult::NotSent {
reason: "the connection was gone".to_owned(),
})
.build();
assert_eq!(outcome.sequence, None);
assert_eq!(outcome.progressive, None);
assert!(!outcome.is_delivered());
assert!(matches!(outcome.result, MessageResult::NotSent { .. }));
}
#[test]
fn test_message_outcome_carries_its_sequence_and_progressive() {
let delivered = MessageOutcomeBuilder::new(MessageResult::Delivered {
response: "ok".to_owned(),
})
.sequence("ORDERS")
.progressive(4)
.build();
assert_eq!(delivered.sequence.as_deref(), Some("ORDERS"));
assert_eq!(delivered.progressive, Some(4));
assert!(delivered.is_delivered());
}
#[test]
fn test_message_outcome_preserves_the_server_code() {
let refused = MessageOutcomeBuilder::new(MessageResult::Refused(ServerError::new(
33,
"progressive already used",
)))
.build();
assert!(!refused.is_delivered());
match refused.result {
MessageResult::Refused(error) => {
assert_eq!(error.code(), 33);
assert_eq!(error.message(), "progressive already used");
}
other => panic!("expected a refusal, got {other:?}"),
}
}
#[test]
fn test_command_fields_are_one_based() {
let fields = command_fields(1, 2);
assert_eq!(fields.key, 1);
assert_eq!(fields.command, 2);
}
#[test]
#[should_panic(expected = "field positions are 1-based")]
fn test_command_fields_reject_zero() {
let _ = command_fields(0, 1);
}
}