Some checks failed
Native code generation / deterministic (push) Failing after 2m0s
Imaging and meshing gate / native (push) Failing after 4m11s
JPEG 2000 feature / linux (push) Successful in 2m52s
Native Rust workspace compile / compile (push) Failing after 6m15s
Skia feature / linux (push) Has been cancelled
444 lines
15 KiB
Rust
444 lines
15 KiB
Rust
#![allow(clippy::float_cmp)] // Fixture values are exactly representable protocol literals.
|
|
|
|
use libremetaverse_rlv::{
|
|
RLV_RESTRICTION_NAMES, RlvAction, RlvAttachmentPoint, RlvCameraQuery, RlvCommon, RlvDirective,
|
|
RlvGroupTarget, RlvParseErrorKind, RlvParser, RlvQuery, RlvRestriction,
|
|
RlvRestrictionOperation, RlvRestrictionType, RlvTarget, RlvValue, RlvWearableType,
|
|
attachment_from_name, restriction_from_name, restriction_name, wearable_from_name,
|
|
};
|
|
use libremetaverse_types::UUID;
|
|
use libremetaverse_types::compat::{Guid, Object};
|
|
|
|
fn sender() -> Guid {
|
|
UUID::parse("ffffffff-ffff-4fff-8fff-ffffffffffff".to_owned())
|
|
.expect("fixture UUID")
|
|
.guid()
|
|
}
|
|
|
|
fn parse(message: &str) -> libremetaverse_rlv::RlvMessage {
|
|
RlvParser::parse_message(message, sender(), "Sender Name").expect("valid RLV message")
|
|
}
|
|
|
|
#[test]
|
|
fn message_preserves_original_casing_options_and_byte_locations() {
|
|
let parsed = parse("@FlY=ADD,getdebug_RenderMode:MiXeD=+123");
|
|
assert_eq!(parsed.commands.len(), 2);
|
|
let first = &parsed.commands[0];
|
|
assert_eq!(first.raw_behavior, "FlY");
|
|
assert_eq!(first.behavior, "fly");
|
|
assert_eq!(first.raw_parameter, "ADD");
|
|
assert_eq!(first.parameter, "add");
|
|
assert_eq!(first.source.start, 1);
|
|
assert_eq!(first.source.end, 8);
|
|
assert!(matches!(
|
|
first.directive,
|
|
RlvDirective::Restriction {
|
|
behavior: RlvRestrictionType::Fly,
|
|
operation: RlvRestrictionOperation::Add,
|
|
ref values,
|
|
} if values.is_empty()
|
|
));
|
|
let second = &parsed.commands[1];
|
|
assert_eq!(second.raw_behavior, "getdebug_RenderMode");
|
|
assert_eq!(second.behavior, "getdebug_rendermode");
|
|
assert_eq!(second.option, "MiXeD");
|
|
assert!(matches!(
|
|
second.directive,
|
|
RlvDirective::Query {
|
|
channel: 123,
|
|
query: RlvQuery::Debug(ref name),
|
|
} if name == "rendermode"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn bare_clear_and_all_parameter_classes_are_typed() {
|
|
assert!(matches!(
|
|
parse("@ClEaR").commands[0].directive,
|
|
RlvDirective::Clear
|
|
));
|
|
assert!(matches!(
|
|
parse("@unsit=FoRcE").commands[0].directive,
|
|
RlvDirective::Action(RlvAction::Unsit)
|
|
));
|
|
assert!(matches!(
|
|
parse("@jump=Y").commands[0].directive,
|
|
RlvDirective::Restriction {
|
|
operation: RlvRestrictionOperation::Remove,
|
|
..
|
|
}
|
|
));
|
|
assert!(matches!(
|
|
parse("@version= 42 ").commands[0].directive,
|
|
RlvDirective::Query {
|
|
channel: 42,
|
|
query: RlvQuery::Version { modern: false }
|
|
}
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn action_aliases_and_targets_remain_distinct() {
|
|
let attach = &parse("@addoutfitallover:Clothing/Hats=force").commands[0].directive;
|
|
assert!(matches!(
|
|
attach,
|
|
RlvDirective::Action(RlvAction::Attach {
|
|
folder_path,
|
|
replace: false,
|
|
recursive: true,
|
|
}) if folder_path == "Clothing/Hats"
|
|
));
|
|
let this = &parse("@attachthisoverorreplace:R UPPER ARM=force").commands[0].directive;
|
|
assert!(matches!(
|
|
this,
|
|
RlvDirective::Action(RlvAction::AttachThis {
|
|
target: RlvTarget::AttachmentPoint(RlvAttachmentPoint::RightUpperArm),
|
|
replace: true,
|
|
recursive: false,
|
|
})
|
|
));
|
|
let remove = &parse("@remoutfit:Tattoo=force").commands[0].directive;
|
|
assert!(matches!(
|
|
remove,
|
|
RlvDirective::Action(RlvAction::RemoveOutfit(RlvTarget::WearableType(
|
|
RlvWearableType::Tattoo
|
|
)))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn numeric_uuid_group_and_teleport_options_are_validated() {
|
|
let adjusted = &parse("@adjustheight:1.25;2;bad;ignored=force").commands[0].directive;
|
|
assert!(matches!(
|
|
adjusted,
|
|
RlvDirective::Action(RlvAction::AdjustHeight { distance, factor, delta })
|
|
if *distance == 1.25 && *factor == 2.0 && *delta == 0.0
|
|
));
|
|
let teleported = &parse("@tpto:Region Name/1/2/3;4=force").commands[0].directive;
|
|
assert!(matches!(
|
|
teleported,
|
|
RlvDirective::Action(RlvAction::Teleport {
|
|
x,
|
|
y,
|
|
z,
|
|
region: Some(region),
|
|
look_at: Some(look_at),
|
|
}) if *x == 1.0 && *y == 2.0 && *z == 3.0 && region == "Region Name" && *look_at == 4.0
|
|
));
|
|
let grouped = &parse("@setgroup:My Group;Role Name=force").commands[0].directive;
|
|
assert!(matches!(
|
|
grouped,
|
|
RlvDirective::Action(RlvAction::SetGroup {
|
|
target: RlvGroupTarget::Name(name),
|
|
role: Some(role),
|
|
}) if name == "My Group" && role == "Role Name"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn query_variants_preserve_filters_separators_paths_and_aliases() {
|
|
let status = &parse("@getstatus:TP; ! =7").commands[0].directive;
|
|
assert!(matches!(
|
|
status,
|
|
RlvDirective::Query {
|
|
query: RlvQuery::Status { all_senders: false, filter, separator },
|
|
channel: 7,
|
|
} if filter == "tp" && separator == " ! "
|
|
));
|
|
let folders = &parse("@findfolders:Hat&&Red;|=8").commands[0].directive;
|
|
assert!(matches!(
|
|
folders,
|
|
RlvDirective::Query {
|
|
query: RlvQuery::FindFolders { first_only: false, terms, separator },
|
|
..
|
|
} if terms == &["Hat", "Red"] && separator == "|"
|
|
));
|
|
let path = &parse("@getpathnew:root=9").commands[0].directive;
|
|
assert!(matches!(
|
|
path,
|
|
RlvDirective::Query {
|
|
query: RlvQuery::Path {
|
|
legacy: false,
|
|
target: RlvTarget::AttachmentPoint(RlvAttachmentPoint::AvatarCenter),
|
|
},
|
|
..
|
|
}
|
|
));
|
|
assert!(matches!(
|
|
parse("@getcam_fov=10").commands[0].directive,
|
|
RlvDirective::Query {
|
|
query: RlvQuery::Camera(RlvCameraQuery::CurrentFov),
|
|
..
|
|
}
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn restriction_options_are_strongly_typed() {
|
|
let notify = &parse("@notify:1234;sendim=add").commands[0].directive;
|
|
assert!(matches!(
|
|
notify,
|
|
RlvDirective::Restriction { values, .. }
|
|
if values == &[RlvValue::Integer(1234), RlvValue::String("sendim".to_owned())]
|
|
));
|
|
let attachment = &parse("@detach:R Upper Arm=n").commands[0].directive;
|
|
assert!(matches!(
|
|
attachment,
|
|
RlvDirective::Restriction { values, .. }
|
|
if values == &[RlvValue::AttachmentPoint(RlvAttachmentPoint::RightUpperArm)]
|
|
));
|
|
let wearable = &parse("@remoutfit:UNIVERSAL=n").commands[0].directive;
|
|
assert!(matches!(
|
|
wearable,
|
|
RlvDirective::Restriction { values, .. }
|
|
if values == &[RlvValue::WearableType(RlvWearableType::Universal)]
|
|
));
|
|
let uuid = "00000000-0000-4000-8000-000000000000";
|
|
assert!(matches!(
|
|
&parse(&format!("@accepttp:{uuid}=add")).commands[0].directive,
|
|
RlvDirective::Restriction { values, .. } if matches!(values.as_slice(), [RlvValue::Uuid(_)])
|
|
));
|
|
|
|
// The pinned parser reads the first camera scalar and deliberately ignores
|
|
// later semicolon fields for this family.
|
|
assert!(matches!(
|
|
&parse("@camzoommax:2.5;ignored;3=n").commands[0].directive,
|
|
RlvDirective::Restriction { values, .. }
|
|
if values == &[RlvValue::Real(2.5)]
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn every_pinned_restriction_name_round_trips_without_normalization() {
|
|
assert_eq!(RLV_RESTRICTION_NAMES.len(), 119);
|
|
for (name, restriction) in RLV_RESTRICTION_NAMES {
|
|
assert_eq!(restriction_from_name(name), Some(*restriction));
|
|
assert_eq!(restriction_name(*restriction), *name);
|
|
}
|
|
assert_eq!(
|
|
restriction_from_name("TouchFar"),
|
|
Some(RlvRestrictionType::TouchFar)
|
|
);
|
|
assert_eq!(restriction_from_name("touch-far"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn every_pinned_restriction_has_at_least_one_typed_valid_option_form() {
|
|
let uuid = "00000000-0000-4000-8000-000000000000";
|
|
let candidates = [
|
|
"",
|
|
"1",
|
|
"1.0",
|
|
"1;1;1",
|
|
uuid,
|
|
"chest",
|
|
"shirt",
|
|
"Folder Name",
|
|
];
|
|
for (name, expected) in RLV_RESTRICTION_NAMES {
|
|
let accepted = candidates.iter().any(|option| {
|
|
let message = format!("@{name}:{option}=n");
|
|
RlvParser::parse_message(&message, sender(), "sender")
|
|
.ok()
|
|
.is_some_and(|message| {
|
|
matches!(
|
|
message.commands[0].directive,
|
|
RlvDirective::Restriction { behavior, .. } if behavior == *expected
|
|
)
|
|
})
|
|
});
|
|
assert!(accepted, "no valid typed option form for {name}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn attachment_and_wearable_alias_tables_are_case_insensitive_but_not_trimmed() {
|
|
assert_eq!(
|
|
attachment_from_name("ROOT"),
|
|
Some(RlvAttachmentPoint::AvatarCenter)
|
|
);
|
|
assert_eq!(
|
|
attachment_from_name("l upper leg"),
|
|
Some(RlvAttachmentPoint::LeftUpperLeg)
|
|
);
|
|
assert_eq!(attachment_from_name(" chest "), None);
|
|
assert_eq!(
|
|
wearable_from_name("UnderShirt"),
|
|
Some(RlvWearableType::Undershirt)
|
|
);
|
|
assert_eq!(
|
|
wearable_from_name("universal"),
|
|
Some(RlvWearableType::Universal)
|
|
);
|
|
assert_eq!(wearable_from_name(" shirt "), None);
|
|
}
|
|
|
|
#[test]
|
|
fn mapped_attachment_tag_helper_uses_last_recognized_tag() {
|
|
let mut output = None;
|
|
assert!(RlvCommon::try_get_attachment_point_from_item_name(
|
|
"Item (mouth) (unknown) (SPINE)".to_owned(),
|
|
&mut output,
|
|
));
|
|
assert_eq!(output, Some(Some(RlvAttachmentPoint::Spine)));
|
|
assert!(!RlvCommon::try_get_attachment_point_from_item_name(
|
|
"Item (unknown)".to_owned(),
|
|
&mut output,
|
|
));
|
|
assert_eq!(output, None);
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_corpus_has_stable_categories_locations_and_never_panics() {
|
|
let cases = [
|
|
("", RlvParseErrorKind::MissingPrefix),
|
|
("fly=n", RlvParseErrorKind::MissingPrefix),
|
|
("@", RlvParseErrorKind::EmptyCommand),
|
|
("@fly", RlvParseErrorKind::MissingEquals),
|
|
("@=n", RlvParseErrorKind::EmptyBehavior),
|
|
("@fly=", RlvParseErrorKind::EmptyParameter),
|
|
("@unknown=force", RlvParseErrorKind::UnknownAction),
|
|
("@unknown=n", RlvParseErrorKind::UnknownRestriction),
|
|
("@unknown=123", RlvParseErrorKind::UnknownQuery),
|
|
("@fly=maybe", RlvParseErrorKind::UnsupportedParameter),
|
|
("@version=0", RlvParseErrorKind::ZeroQueryChannel),
|
|
("@sit:not-a-uuid=force", RlvParseErrorKind::InvalidUuid),
|
|
("@camdrawmin:0.39=n", RlvParseErrorKind::InvalidOption),
|
|
("@detach: chest =n", RlvParseErrorKind::InvalidOption),
|
|
("@fly=n,", RlvParseErrorKind::EmptyCommand),
|
|
];
|
|
for (message, expected) in cases {
|
|
let result =
|
|
std::panic::catch_unwind(|| RlvParser::parse_message(message, sender(), "sender"));
|
|
let error = result.expect("parser never panics").expect_err(message);
|
|
assert_eq!(error.kind, expected, "{message}");
|
|
assert!(error.span.start <= error.span.end);
|
|
assert!(error.span.end <= message.len());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn deterministic_single_byte_mutations_are_bounded_and_never_panic() {
|
|
const SEEDS: &[&str] = &[
|
|
"@fly=n",
|
|
"@version=42",
|
|
"@notify:1234;sendim=add",
|
|
"@attachthisoverorreplace:r upper arm=force",
|
|
"@sit:00000000-0000-4000-8000-000000000000=force",
|
|
"@getstatus:tp;|=7,@getcam_fov=8",
|
|
];
|
|
const REPLACEMENTS: &[u8] = b"@,:=;0An?-_ ";
|
|
|
|
let mut corpus = Vec::new();
|
|
for seed in SEEDS {
|
|
for index in 0..seed.len() {
|
|
let mut deleted = seed.as_bytes().to_vec();
|
|
deleted.remove(index);
|
|
corpus.push(String::from_utf8(deleted).expect("ASCII fixture"));
|
|
|
|
for replacement in REPLACEMENTS {
|
|
let mut replaced = seed.as_bytes().to_vec();
|
|
replaced[index] = *replacement;
|
|
corpus.push(String::from_utf8(replaced).expect("ASCII fixture"));
|
|
}
|
|
}
|
|
for index in 0..=seed.len() {
|
|
for insertion in REPLACEMENTS {
|
|
let mut inserted = seed.as_bytes().to_vec();
|
|
inserted.insert(index, *insertion);
|
|
corpus.push(String::from_utf8(inserted).expect("ASCII fixture"));
|
|
}
|
|
}
|
|
}
|
|
|
|
assert!(corpus.len() >= 4_000, "mutation corpus unexpectedly small");
|
|
for message in corpus {
|
|
let result = std::panic::catch_unwind(|| {
|
|
RlvParser::parse_message(&message, sender(), "mutated sender")
|
|
});
|
|
let parsed = result.expect("bounded parser must not panic for any mutation");
|
|
if let Err(error) = parsed {
|
|
assert!(error.span.start <= error.span.end, "{message:?}");
|
|
assert!(error.span.end <= message.len(), "{message:?}");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn command_count_and_message_size_are_bounded() {
|
|
let oversized_count = format!(
|
|
"@{}",
|
|
std::iter::repeat_n("fly=n", 129)
|
|
.collect::<Vec<_>>()
|
|
.join(",")
|
|
);
|
|
assert_eq!(
|
|
RlvParser::parse_message(&oversized_count, sender(), "sender")
|
|
.expect_err("too many commands")
|
|
.kind,
|
|
RlvParseErrorKind::TooManyCommands
|
|
);
|
|
let oversized_message = format!("@getinv:{}=1", "x".repeat(64 * 1024));
|
|
assert_eq!(
|
|
RlvParser::parse_message(&oversized_message, sender(), "sender")
|
|
.expect_err("oversized message")
|
|
.kind,
|
|
RlvParseErrorKind::MessageTooLong
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mapped_restriction_preserves_alias_exception_equality_and_snapshot_args() {
|
|
let first = RlvRestriction::new(
|
|
RlvRestrictionType::FarTouch,
|
|
sender(),
|
|
"Object A".to_owned(),
|
|
vec![Object::Real(2.5)],
|
|
)
|
|
.expect("restriction");
|
|
let second = RlvRestriction::new(
|
|
RlvRestrictionType::FarTouch,
|
|
sender(),
|
|
"Renamed Object".to_owned(),
|
|
vec![Object::Real(2.5)],
|
|
)
|
|
.expect("restriction");
|
|
assert_eq!(first.behavior(), RlvRestrictionType::TouchFar);
|
|
assert_eq!(first.original_behavior(), RlvRestrictionType::FarTouch);
|
|
assert!(!first.is_exception());
|
|
assert_eq!(first, second);
|
|
assert_eq!(first.get_hash_code(), second.get_hash_code());
|
|
assert_eq!(first.args().as_slice(), &[Object::Real(2.5)]);
|
|
assert!(first.to_string().contains("Behavior=TouchFar"));
|
|
assert!(first.equals(Some(Object::opaque(second))));
|
|
|
|
let exception = RlvRestriction::from_values(
|
|
RlvRestrictionType::RecvChat,
|
|
sender(),
|
|
"Object".to_owned(),
|
|
vec![RlvValue::Uuid(sender())],
|
|
);
|
|
assert!(exception.is_exception());
|
|
|
|
let exact_real = 2.500_000_000_000_001_f64;
|
|
let exact = RlvRestriction::new(
|
|
RlvRestrictionType::CamZoomMax,
|
|
sender(),
|
|
"Exact value".to_owned(),
|
|
vec![Object::Real(exact_real)],
|
|
)
|
|
.expect("restriction");
|
|
assert_eq!(exact.args().as_slice(), &[Object::Real(exact_real)]);
|
|
let rounded = RlvRestriction::new(
|
|
RlvRestrictionType::CamZoomMax,
|
|
sender(),
|
|
"Rounded value".to_owned(),
|
|
vec![Object::Real(2.5)],
|
|
)
|
|
.expect("restriction");
|
|
assert_ne!(
|
|
exact, rounded,
|
|
"mapped equality uses the exact Args snapshot"
|
|
);
|
|
}
|