use paft_utils::{
Canonical, CanonicalError, MAX_CANONICAL_TOKEN_LEN, StringCode, canonicalize,
has_canonical_token_boundaries,
};
use std::borrow::Cow;
fn invalid_canonical_value(err: CanonicalError) -> String {
let CanonicalError::InvalidCanonicalToken { value } = err else {
panic!("unexpected canonical error variant");
};
value
}
#[test]
fn canonicalize_applies_normalization_rules() {
assert_eq!(canonicalize("usd"), "USD");
assert_eq!(canonicalize("Pre-Market"), "PRE_MARKET");
assert_eq!(canonicalize("S&P 500"), "S_P_500");
assert_eq!(canonicalize(" multiple spaces "), "MULTIPLE_SPACES");
}
#[test]
fn canonicalize_collapses_and_trims_underscores() {
assert_eq!(canonicalize("__foo--bar__"), "FOO_BAR");
assert_eq!(canonicalize("!@#"), "");
}
#[test]
fn canonical_token_boundaries_require_alphanumeric_edges_after_trim() {
assert!(has_canonical_token_boundaries("USD"));
assert!(has_canonical_token_boundaries(" market perform "));
assert!(has_canonical_token_boundaries("10%_OWNER"));
assert!(!has_canonical_token_boundaries(""));
assert!(!has_canonical_token_boundaries(" "));
assert!(!has_canonical_token_boundaries("$USD"));
assert!(!has_canonical_token_boundaries("---NYSE"));
assert!(!has_canonical_token_boundaries("CLOSED!"));
assert!(!has_canonical_token_boundaries("€USD"));
}
#[test]
fn canonical_try_new_rejects_empty_tokens() {
let err = Canonical::try_new("***").unwrap_err();
let value = invalid_canonical_value(err);
assert_eq!(value, "***");
let err = Canonical::try_new("").unwrap_err();
let value = invalid_canonical_value(err);
assert_eq!(value, "");
}
#[test]
fn canonical_try_new_rejects_all_separator_inputs() {
let separator_inputs = vec![
"***", "!!!", "@@@", "###", "$$$", "%%%", "^^^", "&&&", "***", "(((", ")))", "---", "+++",
"===", "{{{", "}}}", "|||", "\\\\\\", ":::", ";;%", ",,,", "...", "???", "~~~", "```",
"___", ];
for input in separator_inputs {
let err = Canonical::try_new(input).unwrap_err();
let value = invalid_canonical_value(err);
assert_eq!(
value, input,
"Failed to reject all-separator input: {input}"
);
}
}
#[test]
fn canonical_try_new_rejects_all_non_ascii_inputs() {
let non_ascii_inputs = vec![
"€€€",
"¥¥¥",
"£££",
"¢¢¢",
"αβγ",
"你好",
"Здравствуй",
"مرحبا",
"Γεια σας",
"שלום",
"नमस्ते",
"こんにちは",
"안녕하세요",
"🚀🚀🚀",
"🎉🎉🎉",
"💯💯💯",
"ééé", "ñññ",
"üüü",
"ççç",
];
for input in non_ascii_inputs {
let err = Canonical::try_new(input).unwrap_err();
let value = invalid_canonical_value(err);
assert_eq!(
value, input,
"Failed to reject all-non-ASCII input: {input}"
);
}
}
#[test]
fn canonical_try_new_accepts_valid_tokens() {
let canonical = Canonical::try_new("foo bar").expect("valid token");
assert_eq!(canonical.as_str(), "FOO_BAR");
assert_eq!(canonical.to_string(), "FOO_BAR");
}
#[test]
fn canonical_try_new_rejects_overlong_tokens() {
let max = "A".repeat(MAX_CANONICAL_TOKEN_LEN);
let canonical = Canonical::try_new(&max).expect("max-length token is accepted");
assert_eq!(canonical.as_str(), max);
let too_long = "A".repeat(MAX_CANONICAL_TOKEN_LEN + 1);
assert!(matches!(
Canonical::try_new(&too_long),
Err(CanonicalError::CanonicalTokenTooLong {
max_len: MAX_CANONICAL_TOKEN_LEN
})
));
let too_long_after_canonicalization = "a-".repeat(MAX_CANONICAL_TOKEN_LEN);
assert!(matches!(
Canonical::try_new(&too_long_after_canonicalization),
Err(CanonicalError::CanonicalTokenTooLong {
max_len: MAX_CANONICAL_TOKEN_LEN
})
));
let trailing_separator_at_limit = format!("{max}-");
let canonical =
Canonical::try_new(&trailing_separator_at_limit).expect("trailing separator is trimmed");
assert_eq!(canonical.as_str(), max);
let separator_then_token_at_limit = format!("{max}-B");
assert!(matches!(
Canonical::try_new(&separator_then_token_at_limit),
Err(CanonicalError::CanonicalTokenTooLong {
max_len: MAX_CANONICAL_TOKEN_LEN
})
));
}
#[test]
fn canonical_roundtrip_property() {
let test_cases = vec![
"USD",
"usd",
"FOO_BAR",
"foo bar",
"123",
"1 2 3",
"S&P 500",
"Pre-Market",
"multiple spaces",
" leading spaces ",
"trailing spaces ",
" both ",
"a1B2c3",
"TEST123",
"TEST_123",
"TEST_123_TEST",
];
for input in test_cases {
let canonical_result = Canonical::try_new(input);
if let Ok(canonical) = canonical_result {
let canonical_str = canonical.as_str();
let canonicalize_result = canonicalize(input);
assert_eq!(
canonical_str,
canonicalize_result.as_ref(),
"Round-trip failed for input: {input:?}"
);
}
else {
let canonicalize_result = canonicalize(input);
assert!(
canonicalize_result.as_ref().is_empty()
|| canonicalize_result.as_ref().len() > MAX_CANONICAL_TOKEN_LEN,
"Canonical creation failed but canonicalize produced storable result for input: {input:?}"
);
}
}
}
#[test]
fn canonicalize_idempotent_property() {
let test_cases = vec![
"",
"USD",
"usd",
"FOO_BAR",
"foo bar",
"123",
"1 2 3",
"S&P 500",
"Pre-Market",
"multiple spaces",
" leading spaces ",
"trailing spaces ",
" both ",
"a1B2c3",
"TEST123",
"TEST_123",
"TEST_123_TEST",
"_A",
"A_",
"_FOO_",
"FOO__BAR",
"***",
"€€€",
"🚀🚀🚀",
"ééé",
"你好",
];
for input in test_cases {
let c1 = canonicalize(input);
if !c1.is_empty() {
let result_str = c1.as_ref();
assert!(
result_str
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_'),
"canonicalize result should only contain A-Z, 0-9, and underscores: {result_str:?}"
);
assert!(
!result_str.starts_with('_') && !result_str.ends_with('_'),
"canonicalize result should not start or end with underscore: {result_str:?}"
);
let c2 = canonicalize(c1.as_ref());
if c1.as_ref() == input {
match c2 {
std::borrow::Cow::Borrowed(_) => {} std::borrow::Cow::Owned(_) => {
panic!("Expected borrowed result for already canonical input: {input:?}")
}
}
}
}
let c2 = canonicalize(c1.as_ref());
assert_eq!(
c1, c2,
"canonicalize is not idempotent for input: {input:?}"
);
}
}
#[test]
fn canonicalize_separator_collapse_edge_cases() {
let test_cases = vec![
("__us d - - e__", "US_D_E"),
(" ", ""), ("\t\t\t", ""), ("\n\n\n", ""), ("___", ""), ("a___b", "A_B"), ("_a_b_", "A_B"), ("a_b__c", "A_B_C"), ("a__b__c", "A_B_C"), ("a_b_c_", "A_B_C"), ("_a_b_c", "A_B_C"), ];
for (input, expected) in test_cases {
let result = canonicalize(input);
assert_eq!(
result.as_ref(),
expected,
"Failed for input: {:?} -> expected: {:?}, got: {:?}",
input,
expected,
result.as_ref()
);
}
}
#[test]
fn canonicalize_and_canonical_try_new_are_consistent() {
let max_len_token = "A".repeat(MAX_CANONICAL_TOKEN_LEN);
let overlong_token = "A".repeat(MAX_CANONICAL_TOKEN_LEN + 1);
let test_cases = vec![
"", "USD", "usd", "FOO_BAR", "foo bar", "123", "1 2 3", "***", "€€€", "S&P 500", "_A", "A_", "_FOO_", "FOO__BAR", "a1B2c3", "TEST123", &max_len_token,
&overlong_token,
];
for input in test_cases {
let canonical_result = Canonical::try_new(input);
let canonicalize_result = canonicalize(input);
let canonicalize_is_empty = canonicalize_result.as_ref().is_empty();
match canonical_result {
Ok(_) => {
assert!(
!canonicalize_is_empty
&& canonicalize_result.as_ref().len() <= MAX_CANONICAL_TOKEN_LEN,
"Canonical::try_new succeeded but canonicalize produced invalid token for: {input:?}"
);
}
Err(_) => {
assert!(
canonicalize_is_empty
|| canonicalize_result.as_ref().len() > MAX_CANONICAL_TOKEN_LEN,
"Canonical::try_new failed but canonicalize produced storable token for: {input:?}"
);
}
}
}
}
#[test]
fn canonical_from_str_delegates_to_try_new() {
let canonical: Canonical = " other value ".parse().expect("valid token");
assert_eq!(canonical.as_ref(), "OTHER_VALUE");
}
#[test]
fn string_code_default_is_canonical() {
#[derive(Debug)]
struct Dummy;
impl StringCode for Dummy {
fn code(&self) -> &'static str {
"DUMMY"
}
}
let value = Dummy;
assert!(value.is_canonical());
assert_eq!(value.code(), "DUMMY");
}
#[test]
fn canonicalize_fast_path_for_already_canonical_strings() {
let canonical_input = "USD";
let result = canonicalize(canonical_input);
match result {
std::borrow::Cow::Borrowed(s) => assert_eq!(s, canonical_input),
std::borrow::Cow::Owned(_) => {
panic!("Expected borrowed result for already canonical input")
}
}
}
#[test]
fn canonicalize_normalizes_non_canonical_strings() {
let non_canonical_input = "usd";
let result = canonicalize(non_canonical_input);
match result {
std::borrow::Cow::Owned(s) => assert_eq!(s, "USD"),
std::borrow::Cow::Borrowed(_) => panic!("Expected owned result for non-canonical input"),
}
}
#[test]
fn canonicalize_correctly_handles_leading_underscores() {
assert_eq!(canonicalize("_A"), "A");
assert_eq!(canonicalize("A_"), "A");
assert_eq!(canonicalize("_FOO_"), "FOO");
assert_eq!(canonicalize("_"), "");
match canonicalize("_A") {
std::borrow::Cow::Owned(s) => assert_eq!(s, "A"),
std::borrow::Cow::Borrowed(_) => {
panic!("Expected owned result for input with leading underscore")
}
}
match canonicalize("A_") {
std::borrow::Cow::Owned(s) => assert_eq!(s, "A"),
std::borrow::Cow::Borrowed(_) => {
panic!("Expected owned result for input with trailing underscore")
}
}
}
#[test]
fn canonicalize_additional_edge_cases() {
assert_eq!(canonicalize(" usd eur "), "USD_EUR");
assert_eq!(canonicalize("__X__"), "X");
assert_eq!(canonicalize("x__y"), "X_Y");
assert_eq!(canonicalize("A_B"), "A_B");
match canonicalize("A_B") {
std::borrow::Cow::Borrowed(s) => assert_eq!(s, "A_B"),
std::borrow::Cow::Owned(_) => {
panic!("Expected borrowed result for already canonical input")
}
}
assert_eq!(canonicalize("€USD—¥"), "USD");
}
#[test]
fn canonicalize_is_idempotent() {
let test_cases = vec![
"",
"USD",
"usd",
"FOO_BAR",
"foo bar",
"123",
"1 2 3",
"_A",
"A_",
"_FOO_",
"€USD—¥",
"x__y",
"__X__",
" usd eur ",
"!@#$%",
"a1B2c3",
"_a1B2c3_",
];
for input in test_cases {
let once = canonicalize(input);
let twice = canonicalize(&once);
assert_eq!(
once, twice,
"canonicalize is not idempotent for input: {input:?}"
);
}
}
#[test]
fn canonicalize_result_is_canonical() {
let test_cases = vec![
"USD",
"usd",
"FOO_BAR",
"foo bar",
"123",
"1 2 3",
"_A",
"A_",
"_FOO_",
"€USD—¥",
"x__y",
"__X__",
" usd eur ",
"!@#$%",
"a1B2c3",
"_a1B2c3_",
];
for input in test_cases {
let once = canonicalize(input);
let twice = canonicalize(&once);
assert_eq!(
once, twice,
"canonicalize is not idempotent for input: {input:?}"
);
let result_str = once.as_ref();
if !result_str.is_empty() {
if input
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
&& !input.starts_with('_')
&& !input.ends_with('_')
&& !input.contains("__")
{
match &once {
Cow::Borrowed(_) => {} Cow::Owned(_) => {
panic!("Already canonical input {input:?} should return borrowed")
}
}
}
}
}
}
#[test]
fn pathological_separators_collapse_to_single_underscore_and_trim() {
let test_cases = vec![
("!@#$", ""),
("a!@#$b", "A_B"),
("a!@#$", "A"),
("!@#$b", "B"),
("a!@#$b!@#$c", "A_B_C"),
("___a___", "A"),
("a___b", "A_B"),
("a___", "A"),
("___", ""),
("a!@#$_b!@#$", "A_B"),
("!@#$a!@#$", "A"),
("a!@#$b_", "A_B"),
("_a!@#$b", "A_B"),
("_a!@#$b_", "A_B"),
];
for (input, expected) in test_cases {
let result = canonicalize(input);
assert_eq!(
result.as_ref(),
expected,
"Failed for input: {:?} -> expected: {:?}, got: {:?}",
input,
expected,
result.as_ref()
);
}
}
#[test]
fn canonicalize_sanity_properties() {
let test_cases = vec![
"",
"USD",
"usd",
"FOO_BAR",
"foo bar",
"123",
"1 2 3",
"_A",
"A_",
"_FOO_",
"€USD—¥",
"x__y",
"__X__",
" usd eur ",
"!@#$%",
"a1B2c3",
"_a1B2c3_",
];
for input in test_cases {
let result = canonicalize(input);
let double_result = canonicalize(result.as_ref());
assert_eq!(result, double_result, "Not idempotent for input: {input:?}");
if !result.as_ref().is_empty() {
let expected_borrowed = input
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
&& !input.starts_with('_')
&& !input.ends_with('_')
&& !input.contains("__");
match &result {
Cow::Borrowed(_) => {
assert!(expected_borrowed, "Unexpected borrow for input: {input:?}");
}
Cow::Owned(_) => {
assert!(!expected_borrowed, "Unexpected owned for input: {input:?}");
}
}
}
match Canonical::try_new(input) {
Ok(_) => assert!(
!result.as_ref().is_empty() && result.as_ref().len() <= MAX_CANONICAL_TOKEN_LEN,
"Canonical::try_new succeeded but canonicalize produced invalid token for: {input:?}"
),
Err(_) => assert!(
result.as_ref().is_empty() || result.as_ref().len() > MAX_CANONICAL_TOKEN_LEN,
"Canonical::try_new failed but canonicalize produced storable token for: {input:?}"
),
}
}
}
#[test]
fn canonicalize_handles_mixed_canonical_and_non_canonical() {
let inputs_and_expected = vec![
("USD", "USD"), ("usd", "USD"), ("FOO_BAR", "FOO_BAR"), ("foo bar", "FOO_BAR"), ("123", "123"), ("1 2 3", "1_2_3"), ("_A", "A"), ("A_", "A"), ("_FOO_", "FOO"), ];
for (input, expected) in inputs_and_expected {
let result = canonicalize(input);
assert_eq!(result.as_ref(), expected);
if input == expected {
match result {
std::borrow::Cow::Borrowed(s) => assert_eq!(s, input),
std::borrow::Cow::Owned(_) => {
panic!("Expected borrowed result for canonical input: {input}")
}
}
} else {
match result {
std::borrow::Cow::Owned(s) => assert_eq!(s, expected),
std::borrow::Cow::Borrowed(_) => {
panic!("Expected owned result for non-canonical input: {input}")
}
}
}
}
}