feat(experiments): ship seven treatments to everyone (#2798)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-15 18:03:35 +02:00
1 parent 9e614026d7
commit 910db6734b
167 files changed
+4608 -58147

No files matched your search

+45 -436
View File
@@ -6,24 +6,20 @@ use crate::{
types::{
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
AppSetupConfigUpdateRequest, BlockedMessageGroupsConfigUpdateRequest,
CreateRegistrationUrlRequest, DeferredPhoneGateUpdateRequest,
ExperimentDeliveryConfigUpdateRequest, ExpressionInfoCardConfigUpdateRequest,
AppSetupConfigUpdateRequest, CreateRegistrationUrlRequest,
DeferredPhoneGateUpdateRequest, ExperimentDeliveryConfigUpdateRequest,
GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
GuildActivityLogPresentationConfigUpdateRequest,
GuildHeaderCollapseConfigUpdateRequest, InstanceAttachmentDecayUpdateRequest,
InstanceBlueskyIntegrationUpdateRequest, InstanceBlueskyKeyIntegrationUpdateRequest,
InstanceCaptchaIntegrationUpdateRequest, InstanceConfigUpdateRequest,
InstanceEmailIntegrationUpdateRequest, InstanceEmailSmtpIntegrationUpdateRequest,
InstanceEmailSmtpTestRequest, InstanceGifIntegrationUpdateRequest,
InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest,
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters,
MessageHoverTrackingConfigUpdateRequest, MessageKeyboardFocusConfigUpdateRequest,
NoiseSuppressionBackend, PremiumMode, RegistrationMode, SsoConfigUpdateRequest,
TypingIndicatorReworkConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES,
VOICE_NS_MAX_TARGETED_USERS, VoiceE2eeScope, VoiceNoiseSuppressionConfigUpdateRequest,
InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest,
InstanceBlueskyKeyIntegrationUpdateRequest, InstanceCaptchaIntegrationUpdateRequest,
InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest,
InstanceEmailSmtpIntegrationUpdateRequest, InstanceEmailSmtpTestRequest,
InstanceGifIntegrationUpdateRequest, InstanceIntegrationsUpdateRequest,
InstanceMediaUpdateRequest, InstancePolicyUpdateRequest,
InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest,
InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule,
LimitRuleFilters, NoiseSuppressionBackend, PremiumMode, RegistrationMode,
SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS,
VoiceE2eeScope, VoiceNoiseSuppressionConfigUpdateRequest,
VoiceNoiseSuppressionGuildOverride,
},
},
@@ -211,36 +207,6 @@ pub async fn instance_config_post(
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_message_hover_tracking" => match build_message_hover_tracking_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_message_keyboard_focus" => match build_message_keyboard_focus_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_blocked_message_groups" => match build_blocked_message_groups_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_guild_activity_log_presentation" => {
match build_guild_activity_log_presentation_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
}
}
"update_expression_info_card" => match build_expression_info_card_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_guild_header_collapse" => match build_guild_header_collapse_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_typing_indicator_rework" => match build_typing_indicator_rework_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_experiment_delivery" => match build_experiment_delivery_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
@@ -481,10 +447,10 @@ fn build_gateway_rollout_update(form: &MultiValueForm) -> InstanceConfigUpdateRe
}
}
const EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX: u32 = 10_000;
const VOICE_NS_ROLLOUT_BASIS_POINTS_MAX: u32 = 10_000;
const VOICE_NS_SUPPRESSION_STRENGTH_MAX: u32 = 100;
const EXPERIMENT_MAX_ROLLOUT_SALT_CHARS: usize = 64;
const EXPERIMENT_MAX_SNOWFLAKE_LENGTH: usize = 20;
const VOICE_NS_MAX_ROLLOUT_SALT_CHARS: usize = 64;
const VOICE_NS_MAX_SNOWFLAKE_LENGTH: usize = 20;
const EXPERIMENT_MIN_POLL_INTERVAL_SECONDS: u64 = 60;
const EXPERIMENT_MAX_POLL_INTERVAL_SECONDS: u64 = 86_400;
const EXPERIMENT_MAX_POLL_JITTER_PERCENT: u32 = 50;
@@ -510,36 +476,35 @@ where
Ok(Some(value))
}
fn parse_experiment_rollout_salt(
fn parse_voice_noise_suppression_rollout_salt(
form: &MultiValueForm,
key: &str,
) -> Result<Option<String>, String> {
let Some(raw) = form.first(key) else {
let Some(raw) = form.first("voice_ns_rollout_salt") else {
return Ok(None);
};
let salt = raw.trim();
if salt.is_empty() || salt.encode_utf16().count() > EXPERIMENT_MAX_ROLLOUT_SALT_CHARS {
if salt.is_empty() || salt.encode_utf16().count() > VOICE_NS_MAX_ROLLOUT_SALT_CHARS {
return Err(format!(
"Rollout salt must be between 1 and {EXPERIMENT_MAX_ROLLOUT_SALT_CHARS} characters"
"Rollout salt must be between 1 and {VOICE_NS_MAX_ROLLOUT_SALT_CHARS} characters"
));
}
Ok(Some(salt.to_owned()))
}
fn is_experiment_snowflake(value: &str) -> bool {
fn is_voice_noise_suppression_snowflake(value: &str) -> bool {
!value.is_empty()
&& value.len() <= EXPERIMENT_MAX_SNOWFLAKE_LENGTH
&& value.len() <= VOICE_NS_MAX_SNOWFLAKE_LENGTH
&& value.bytes().all(|byte| byte.is_ascii_digit())
}
fn parse_experiment_user_ids(value: &str, label: &str) -> Result<Vec<String>, String> {
fn parse_voice_noise_suppression_user_ids(value: &str, label: &str) -> Result<Vec<String>, String> {
let mut ids: Vec<String> = Vec::new();
for (index, candidate) in value.split([',', '\n', '\r']).enumerate() {
let candidate = candidate.trim();
if candidate.is_empty() {
continue;
}
if !is_experiment_snowflake(candidate) {
if !is_voice_noise_suppression_snowflake(candidate) {
return Err(format!(
"{label} entry {} must contain 1 to 20 decimal digits",
index + 1
@@ -571,7 +536,7 @@ fn parse_voice_noise_suppression_guild_overrides(
format!("Guild overrides line {line_number} must use guild_id=backend")
})?;
let guild_id = guild_id.trim();
if !is_experiment_snowflake(guild_id) {
if !is_voice_noise_suppression_snowflake(guild_id) {
return Err(format!(
"Guild overrides line {line_number} must use a guild ID with 1 to 20 decimal digits"
));
@@ -637,14 +602,14 @@ fn build_voice_noise_suppression_update(
"voice_ns_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
VOICE_NS_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_experiment_rollout_salt(form, "voice_ns_rollout_salt")?,
included_user_ids: Some(parse_experiment_user_ids(
rollout_salt: parse_voice_noise_suppression_rollout_salt(form)?,
included_user_ids: Some(parse_voice_noise_suppression_user_ids(
form.first("voice_ns_included_user_ids").unwrap_or_default(),
"Included user IDs",
)?),
excluded_user_ids: Some(parse_experiment_user_ids(
excluded_user_ids: Some(parse_voice_noise_suppression_user_ids(
form.first("voice_ns_excluded_user_ids").unwrap_or_default(),
"Excluded user IDs",
)?),
@@ -664,221 +629,6 @@ fn build_voice_noise_suppression_update(
})
}
fn build_message_hover_tracking_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
message_hover_tracking: Some(MessageHoverTrackingConfigUpdateRequest {
enabled: Some(form.bool_value("message_hover_enabled")),
rollout_basis_points: parse_form_number(
form,
"message_hover_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_experiment_rollout_salt(form, "message_hover_rollout_salt")?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("message_hover_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("message_hover_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
}),
..Default::default()
})
}
fn build_message_keyboard_focus_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
message_keyboard_focus: Some(MessageKeyboardFocusConfigUpdateRequest {
enabled: Some(form.bool_value("message_keyboard_focus_enabled")),
rollout_basis_points: parse_form_number(
form,
"message_keyboard_focus_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_experiment_rollout_salt(
form,
"message_keyboard_focus_rollout_salt",
)?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("message_keyboard_focus_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("message_keyboard_focus_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
}),
..Default::default()
})
}
fn build_blocked_message_groups_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
blocked_message_groups: Some(BlockedMessageGroupsConfigUpdateRequest {
enabled: Some(form.bool_value("blocked_groups_enabled")),
rollout_basis_points: parse_form_number(
form,
"blocked_groups_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_experiment_rollout_salt(form, "blocked_groups_rollout_salt")?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("blocked_groups_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("blocked_groups_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
}),
..Default::default()
})
}
fn build_guild_activity_log_presentation_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
guild_activity_log_presentation: Some(GuildActivityLogPresentationConfigUpdateRequest {
enabled: Some(form.bool_value("guild_activity_log_presentation_enabled")),
rollout_basis_points: parse_form_number(
form,
"guild_activity_log_presentation_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_experiment_rollout_salt(
form,
"guild_activity_log_presentation_rollout_salt",
)?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("guild_activity_log_presentation_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("guild_activity_log_presentation_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
}),
..Default::default()
})
}
fn build_expression_info_card_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
expression_info_card: Some(ExpressionInfoCardConfigUpdateRequest {
enabled: Some(form.bool_value("expression_card_enabled")),
rollout_basis_points: parse_form_number(
form,
"expression_card_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_experiment_rollout_salt(form, "expression_card_rollout_salt")?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("expression_card_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("expression_card_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
}),
..Default::default()
})
}
fn build_guild_header_collapse_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
guild_header_collapse: Some(GuildHeaderCollapseConfigUpdateRequest {
enabled: Some(form.bool_value("guild_header_collapse_enabled")),
rollout_basis_points: parse_form_number(
form,
"guild_header_collapse_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_experiment_rollout_salt(
form,
"guild_header_collapse_rollout_salt",
)?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("guild_header_collapse_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("guild_header_collapse_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
}),
..Default::default()
})
}
fn build_typing_indicator_rework_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
typing_indicator_rework: Some(TypingIndicatorReworkConfigUpdateRequest {
enabled: Some(form.bool_value("typing_indicator_rework_enabled")),
rollout_basis_points: parse_form_number(
form,
"typing_indicator_rework_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_experiment_rollout_salt(
form,
"typing_indicator_rework_rollout_salt",
)?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("typing_indicator_rework_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("typing_indicator_rework_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
}),
..Default::default()
})
}
fn build_experiment_delivery_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
@@ -1555,9 +1305,9 @@ mod tests {
}
#[test]
fn parse_experiment_user_ids_splits_newlines_and_commas() {
fn parse_voice_noise_suppression_user_ids_splits_newlines_and_commas() {
assert_eq!(
parse_experiment_user_ids(" 1 ,2\n3\r\n 4 ,, 5 ", "Included user IDs")
parse_voice_noise_suppression_user_ids(" 1 ,2\n3\r\n 4 ,, 5 ", "Included user IDs")
.expect("valid IDs"),
vec![
"1".to_owned(),
@@ -1570,15 +1320,16 @@ mod tests {
}
#[test]
fn parse_experiment_user_ids_dedupes_preserving_order() {
fn parse_voice_noise_suppression_user_ids_dedupes_preserving_order() {
assert_eq!(
parse_experiment_user_ids("20,10,20,10,30", "Included user IDs").expect("valid IDs"),
parse_voice_noise_suppression_user_ids("20,10,20,10,30", "Included user IDs")
.expect("valid IDs"),
vec!["20".to_owned(), "10".to_owned(), "30".to_owned()]
);
}
#[test]
fn parse_experiment_user_ids_rejects_non_digit_and_overlong_values() {
fn parse_voice_noise_suppression_user_ids_rejects_non_digit_and_overlong_values() {
for value in [
"abc",
"12a",
@@ -1588,8 +1339,11 @@ mod tests {
"<script>",
] {
assert_eq!(
parse_experiment_user_ids(&format!("123,{value}"), "Included user IDs")
.expect_err("invalid ID"),
parse_voice_noise_suppression_user_ids(
&format!("123,{value}"),
"Included user IDs"
)
.expect_err("invalid ID"),
"Included user IDs entry 2 must contain 1 to 20 decimal digits",
"{value}"
);
@@ -1597,17 +1351,18 @@ mod tests {
}
#[test]
fn parse_experiment_user_ids_rejects_exceeding_the_cap() {
fn parse_voice_noise_suppression_user_ids_rejects_exceeding_the_cap() {
let value = (0..VOICE_NS_MAX_TARGETED_USERS)
.map(|index| index.to_string())
.collect::<Vec<_>>()
.join("\n");
let ids = parse_experiment_user_ids(&format!("{value}\n999"), "Included user IDs")
.expect("valid IDs at cap");
let ids =
parse_voice_noise_suppression_user_ids(&format!("{value}\n999"), "Included user IDs")
.expect("valid IDs at cap");
assert_eq!(ids.len(), VOICE_NS_MAX_TARGETED_USERS);
assert_eq!(ids.last(), Some(&"999".to_owned()));
assert_eq!(
parse_experiment_user_ids(&format!("{value}\n1000"), "Included user IDs")
parse_voice_noise_suppression_user_ids(&format!("{value}\n1000"), "Included user IDs")
.expect_err("too many IDs"),
"Included user IDs must contain at most 1000 unique IDs"
);
@@ -1800,152 +1555,6 @@ mod tests {
}
}
#[test]
fn build_guild_header_collapse_update_reads_the_whole_form() {
let form = MultiValueForm::parse(
b"guild_header_collapse_enabled=true&guild_header_collapse_rollout_basis_points=2500&guild_header_collapse_rollout_salt=%20guild-header-collapse-v2%20&guild_header_collapse_included_user_ids=1500000000000000001%0A1500000000000000001&guild_header_collapse_excluded_user_ids=1500000000000000002%2C%201500000000000000003",
);
let update = build_guild_header_collapse_update(&form)
.expect("valid form")
.guild_header_collapse
.expect("guild header collapse update");
assert_eq!(update.enabled, Some(true));
assert_eq!(update.rollout_basis_points, Some(2_500));
assert_eq!(
update.rollout_salt,
Some("guild-header-collapse-v2".to_owned())
);
assert_eq!(
update.included_user_ids,
Some(vec!["1500000000000000001".to_owned()])
);
assert_eq!(
update.excluded_user_ids,
Some(vec![
"1500000000000000002".to_owned(),
"1500000000000000003".to_owned()
])
);
}
#[test]
fn build_guild_header_collapse_update_leaves_the_rollout_inert_when_nothing_is_submitted() {
let form = MultiValueForm::parse(b"_csrf=token");
let request = build_guild_header_collapse_update(&form).expect("valid form");
assert_eq!(
serde_json::to_value(request).expect("serializable update"),
serde_json::json!({"guild_header_collapse": {
"enabled": false,
"included_user_ids": [],
"excluded_user_ids": [],
}})
);
}
#[test]
fn build_guild_header_collapse_update_rejects_a_rollout_above_the_maximum() {
let form = MultiValueForm::parse(b"guild_header_collapse_rollout_basis_points=10001");
assert_eq!(
build_guild_header_collapse_update(&form).expect_err("invalid rollout"),
"Rollout basis points must be a whole number between 0 and 10000"
);
}
#[test]
fn build_guild_header_collapse_update_reads_only_its_own_prefix() {
let form = MultiValueForm::parse(
b"guild_header_collapse_enabled=true&guild_header_collapse_rollout_basis_points=2500&guild_header_collapse_rollout_salt=guild-header-collapse-v2&guild_header_collapse_included_user_ids=1500000000000000001&expression_card_rollout_basis_points=750&expression_card_rollout_salt=expression-info-card-v2&expression_card_excluded_user_ids=1500000000000000009",
);
let update = build_guild_header_collapse_update(&form)
.expect("valid form")
.guild_header_collapse
.expect("guild header collapse update");
assert_eq!(update.enabled, Some(true));
assert_eq!(update.rollout_basis_points, Some(2_500));
assert_eq!(
update.rollout_salt,
Some("guild-header-collapse-v2".to_owned())
);
assert_eq!(
update.included_user_ids,
Some(vec!["1500000000000000001".to_owned()])
);
assert_eq!(update.excluded_user_ids, Some(Vec::new()));
}
#[test]
fn build_typing_indicator_rework_update_reads_the_whole_form() {
let form = MultiValueForm::parse(
b"typing_indicator_rework_enabled=true&typing_indicator_rework_rollout_basis_points=2500&typing_indicator_rework_rollout_salt=%20typing-indicator-rework-v2%20&typing_indicator_rework_included_user_ids=1500000000000000001%0A1500000000000000001&typing_indicator_rework_excluded_user_ids=1500000000000000002%2C%201500000000000000003",
);
let update = build_typing_indicator_rework_update(&form)
.expect("valid form")
.typing_indicator_rework
.expect("typing indicator rework update");
assert_eq!(update.enabled, Some(true));
assert_eq!(update.rollout_basis_points, Some(2_500));
assert_eq!(
update.rollout_salt,
Some("typing-indicator-rework-v2".to_owned())
);
assert_eq!(
update.included_user_ids,
Some(vec!["1500000000000000001".to_owned()])
);
assert_eq!(
update.excluded_user_ids,
Some(vec![
"1500000000000000002".to_owned(),
"1500000000000000003".to_owned()
])
);
}
#[test]
fn build_typing_indicator_rework_update_leaves_the_rollout_inert_when_nothing_is_submitted() {
let form = MultiValueForm::parse(b"_csrf=token");
let request = build_typing_indicator_rework_update(&form).expect("valid form");
assert_eq!(
serde_json::to_value(request).expect("serializable update"),
serde_json::json!({"typing_indicator_rework": {
"enabled": false,
"included_user_ids": [],
"excluded_user_ids": [],
}})
);
}
#[test]
fn build_typing_indicator_rework_update_rejects_a_rollout_above_the_maximum() {
let form = MultiValueForm::parse(b"typing_indicator_rework_rollout_basis_points=10001");
assert_eq!(
build_typing_indicator_rework_update(&form).expect_err("invalid rollout"),
"Rollout basis points must be a whole number between 0 and 10000"
);
}
#[test]
fn build_typing_indicator_rework_update_reads_only_its_own_prefix() {
let form = MultiValueForm::parse(
b"expression_card_enabled=true&expression_card_rollout_basis_points=2500&expression_card_rollout_salt=expression-info-card-v2&expression_card_included_user_ids=1500000000000000001&typing_indicator_rework_rollout_basis_points=750&typing_indicator_rework_rollout_salt=typing-indicator-rework-v2&typing_indicator_rework_excluded_user_ids=1500000000000000009",
);
let update = build_typing_indicator_rework_update(&form)
.expect("valid form")
.typing_indicator_rework
.expect("typing indicator rework update");
assert_eq!(update.enabled, Some(false));
assert_eq!(update.rollout_basis_points, Some(750));
assert_eq!(
update.rollout_salt,
Some("typing-indicator-rework-v2".to_owned())
);
assert_eq!(update.included_user_ids, Some(Vec::new()));
assert_eq!(
update.excluded_user_ids,
Some(vec!["1500000000000000009".to_owned()])
);
}
#[test]
fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() {
let form = MultiValueForm::parse(b"_csrf=token");