use super::*;
fn round_trip(values: Vec<Value>) {
let encoded = encode_cursor(&values).expect("encode");
let decoded = decode_cursor(&encoded).expect("decode");
assert_eq!(decoded, values, "round-trip mismatch for {encoded}");
}
#[test]
fn golden_vectors_match_python_codec() {
let cases: &[(Vec<Value>, &str)] = &[
(vec![Value::Int(1)], "WzFd"),
(vec![Value::Str("a".into())], "WyJhIl0"),
(vec![Value::Int(42), Value::Str("hello".into())], "WzQyLCJoZWxsbyJd"),
(
vec![Value::Null, Value::Bool(true), Value::Bool(false)],
"W251bGwsdHJ1ZSxmYWxzZV0",
),
(vec![Value::Str("é".into())], "WyJcdTAwZTkiXQ"),
(
vec![Value::Str("café ☕ 🚀".into())],
"WyJjYWZcdTAwZTkgXHUyNjE1IFx1ZDgzZFx1ZGU4MCJd",
),
(
vec![Value::Decimal("9.99".into())],
"W3siX190eXBlX18iOiJkZWNpbWFsIiwidiI6IjkuOTkifV0",
),
(
vec![Value::DateTime("2025-06-01T12:30:00".into())],
"W3siX190eXBlX18iOiJkYXRldGltZSIsInYiOiIyMDI1LTA2LTAxVDEyOjMwOjAwIn1d",
),
(
vec![Value::Date("2025-06-01".into())],
"W3siX190eXBlX18iOiJkYXRlIiwidiI6IjIwMjUtMDYtMDEifV0",
),
(
vec![Value::Uuid("12345678-1234-5678-1234-567812345678".into())],
"W3siX190eXBlX18iOiJ1dWlkIiwidiI6IjEyMzQ1Njc4LTEyMzQtNTY3OC0xMjM0LTU2NzgxMjM0NTY3OCJ9XQ",
),
(vec![Value::Int(-7), Value::Float(1.5)], "Wy03LDEuNV0"),
];
for (values, expected) in cases {
assert_eq!(
&encode_cursor(values).expect("encode"),
expected,
"encode {values:?}"
);
assert_eq!(
&decode_cursor(expected).unwrap(),
values,
"decode {expected}"
);
}
}
#[test]
fn round_trips_scalars() {
round_trip(vec![Value::Int(42), Value::Str("hello".into())]);
round_trip(vec![Value::Null, Value::Bool(true), Value::Bool(false)]);
round_trip(vec![Value::Float(1.5), Value::Int(-7)]);
}
#[test]
fn round_trips_typed_scalars() {
round_trip(vec![
Value::DateTime("2025-06-01T12:30:00".into()),
Value::Date("2025-06-01".into()),
Value::Decimal("3.14".into()),
Value::Uuid("12345678-1234-5678-1234-567812345678".into()),
]);
}
#[test]
fn non_ascii_is_escaped_like_ensure_ascii() {
let encoded = encode_cursor(&[Value::Str("é".into())]).expect("encode");
let bytes = URL_SAFE_NO_PAD.decode(encoded.as_bytes()).unwrap();
assert!(bytes.is_ascii(), "payload must be pure ASCII");
assert!(!bytes.windows(2).any(|w| w == [0xc3, 0xa9]));
assert_eq!(encoded, "WyJcdTAwZTkiXQ");
round_trip(vec![Value::Str("café ☕ 🚀".into())]);
}
#[test]
fn typed_tag_wire_shape() {
let encoded = encode_cursor(&[Value::Decimal("9.99".into())]).expect("encode");
let bytes = URL_SAFE_NO_PAD.decode(encoded.as_bytes()).unwrap();
assert_eq!(
std::str::from_utf8(&bytes).unwrap(),
r#"[{"__type__":"decimal","v":"9.99"}]"#
);
}
#[test]
fn rejects_malformed_cursors() {
assert!(matches!(
decode_cursor("!!!not-base64!!!"),
Err(CoreError::InvalidCursor { .. })
));
let not_a_list = URL_SAFE_NO_PAD.encode(b"{}");
assert!(matches!(
decode_cursor(¬_a_list),
Err(CoreError::InvalidCursor { .. })
));
let bad_tag = URL_SAFE_NO_PAD.encode(br#"[{"__type__":"complex","v":"1"}]"#);
assert!(matches!(
decode_cursor(&bad_tag),
Err(CoreError::InvalidCursor { reason }) if reason.contains("complex")
));
}
#[test]
fn encode_rejects_non_finite_floats() {
assert!(matches!(
encode_cursor(&[Value::Float(f64::NAN)]),
Err(CoreError::InvalidCursor { .. })
));
assert!(encode_cursor(&[Value::Float(f64::INFINITY)]).is_err());
assert!(encode_cursor(&[Value::Float(1.5)]).is_ok());
}
#[test]
fn decode_rejects_integer_above_i64() {
let payload = URL_SAFE_NO_PAD.encode(b"[9223372036854775808]");
assert!(matches!(
decode_cursor(&payload),
Err(CoreError::InvalidCursor { .. })
));
let ok = URL_SAFE_NO_PAD.encode(b"[9223372036854775807]");
assert_eq!(decode_cursor(&ok).unwrap(), vec![Value::Int(i64::MAX)]);
}
#[test]
fn decodes_padded_and_unpadded() {
let unpadded = encode_cursor(&[Value::Str("ab".into())]).expect("encode");
let padded = format!("{unpadded}{}", "=".repeat((4 - unpadded.len() % 4) % 4));
assert_eq!(
decode_cursor(&unpadded).unwrap(),
decode_cursor(&padded).unwrap()
);
}