Skip to content

Commit 9f6cf83

Browse files
author
lefty
committed
test: add Unicode boundary and composite type tests (23 tests)
tests/test_unicode.rs (11 tests): non-Latin scripts, homoglyphs, NFD preservation, non-BMP emoji, embedded null byte tests/test_composites.rs (12 tests): Option<Vec<T>>, Vec<Option<T>>, HashMap<String,Option<T>>, newtype structs, tuple structs, enum variants
1 parent 0085a18 commit 9f6cf83

2 files changed

Lines changed: 335 additions & 0 deletions

File tree

tests/test_composites.rs

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
use std::collections::HashMap;
2+
3+
use pyo3::prelude::*;
4+
use pythonize::{depythonize, pythonize};
5+
use serde::{Deserialize, Serialize};
6+
7+
// FR-013: Color enum at module scope — shared across three test functions
8+
// (one exception to the "inside function" rule).
9+
#[derive(Serialize, Deserialize, Debug, PartialEq)]
10+
enum Color {
11+
Red, // unit variant
12+
Rgb(u8, u8, u8), // tuple variant
13+
Named { name: String }, // struct variant
14+
}
15+
16+
// ---------------------------------------------------------------------------
17+
// FR-008 — Option<Vec<i64>> round-trip
18+
// ---------------------------------------------------------------------------
19+
20+
#[test]
21+
fn test_option_vec_none_round_trip() {
22+
Python::attach(|py| {
23+
let val: Option<Vec<i64>> = None;
24+
let result = pythonize(py, &val).and_then(|o| depythonize::<Option<Vec<i64>>>(&o));
25+
// BASELINE: None round-trips as None.
26+
assert_eq!(result.unwrap(), val);
27+
});
28+
}
29+
30+
#[test]
31+
fn test_option_vec_some_empty_round_trip() {
32+
Python::attach(|py| {
33+
let val: Option<Vec<i64>> = Some(vec![]);
34+
let result = pythonize(py, &val).and_then(|o| depythonize::<Option<Vec<i64>>>(&o));
35+
// BASELINE: Some(vec![]) round-trips as Some(vec![]).
36+
assert_eq!(result.unwrap(), val);
37+
});
38+
}
39+
40+
#[test]
41+
fn test_option_vec_some_values_round_trip() {
42+
Python::attach(|py| {
43+
let val: Option<Vec<i64>> = Some(vec![1, 2, 3]);
44+
let result = pythonize(py, &val).and_then(|o| depythonize::<Option<Vec<i64>>>(&o));
45+
// BASELINE: Some([1, 2, 3]) round-trips intact.
46+
assert_eq!(result.unwrap(), val);
47+
});
48+
}
49+
50+
// ---------------------------------------------------------------------------
51+
// FR-009 — Vec<Option<i64>> round-trip
52+
// ---------------------------------------------------------------------------
53+
54+
#[test]
55+
fn test_vec_option_with_nones_round_trip() {
56+
Python::attach(|py| {
57+
let val: Vec<Option<i64>> = vec![Some(1i64), None, Some(3)];
58+
let result = pythonize(py, &val).and_then(|o| depythonize::<Vec<Option<i64>>>(&o));
59+
// BASELINE: None entries are preserved at their indices; no index shift.
60+
assert_eq!(result.unwrap(), val);
61+
});
62+
}
63+
64+
#[test]
65+
fn test_vec_option_all_nones_round_trip() {
66+
Python::attach(|py| {
67+
let val: Vec<Option<i64>> = vec![None::<i64>, None, None];
68+
let result = pythonize(py, &val).and_then(|o| depythonize::<Vec<Option<i64>>>(&o));
69+
// BASELINE: all-None vec round-trips as three None entries.
70+
assert_eq!(result.unwrap(), val);
71+
});
72+
}
73+
74+
// ---------------------------------------------------------------------------
75+
// FR-010 — HashMap<String, Option<i64>> null value semantics
76+
// ---------------------------------------------------------------------------
77+
78+
#[test]
79+
fn test_hashmap_string_option_none_value_round_trip() {
80+
Python::attach(|py| {
81+
let mut val: HashMap<String, Option<i64>> = HashMap::new();
82+
val.insert("key".to_string(), None::<i64>);
83+
val.insert("other".to_string(), Some(42i64));
84+
let result =
85+
pythonize(py, &val).and_then(|o| depythonize::<HashMap<String, Option<i64>>>(&o));
86+
// FR-010: characterising whether None dict value survives round-trip.
87+
// BASELINE: None value is preserved under "key"; not dropped or transmuted.
88+
assert_eq!(result.unwrap(), val);
89+
});
90+
}
91+
92+
#[test]
93+
fn test_struct_option_field_explicit_none_round_trip() {
94+
Python::attach(|py| {
95+
#[derive(Serialize, Deserialize, Debug, PartialEq)]
96+
struct MaybeVal {
97+
name: String,
98+
count: Option<i64>,
99+
}
100+
101+
let val = MaybeVal {
102+
name: "test".to_string(),
103+
count: None,
104+
};
105+
let result = pythonize(py, &val).and_then(|o| depythonize::<MaybeVal>(&o));
106+
// FR-010: explicit None field vs missing key — asserting observed behaviour.
107+
// BASELINE: count: None round-trips; pythonize emits explicit null, not absent key.
108+
assert_eq!(result.unwrap(), val);
109+
});
110+
}
111+
112+
// ---------------------------------------------------------------------------
113+
// FR-012 — Newtype struct and 2-tuple struct round-trip
114+
// ---------------------------------------------------------------------------
115+
116+
#[test]
117+
fn test_newtype_struct_round_trip() {
118+
Python::attach(|py| {
119+
#[derive(Serialize, Deserialize, Debug, PartialEq)]
120+
struct Wrapper(i64);
121+
122+
let val = Wrapper(42i64);
123+
let result = pythonize(py, &val).and_then(|o| depythonize::<Wrapper>(&o));
124+
// BASELINE: newtype struct serialises as its inner value (42);
125+
// depythonize reconstructs Wrapper(42) from the raw integer.
126+
assert_eq!(result.unwrap(), val);
127+
});
128+
}
129+
130+
#[test]
131+
fn test_tuple_struct_round_trip() {
132+
Python::attach(|py| {
133+
#[derive(Serialize, Deserialize, Debug, PartialEq)]
134+
struct Pair(i64, String);
135+
136+
let val = Pair(7i64, String::from("hello"));
137+
let result = pythonize(py, &val).and_then(|o| depythonize::<Pair>(&o));
138+
// BASELINE: tuple struct serialises as a Python list [7, "hello"];
139+
// depythonize reconstructs Pair(7, "hello") from the list.
140+
assert_eq!(result.unwrap(), val);
141+
});
142+
}
143+
144+
// ---------------------------------------------------------------------------
145+
// FR-013 — Enum variant representations
146+
// ---------------------------------------------------------------------------
147+
148+
#[test]
149+
fn test_enum_unit_variant_round_trip() {
150+
Python::attach(|py| {
151+
let val = Color::Red;
152+
let py_obj = pythonize(py, &val).expect("pythonize Color::Red failed");
153+
let repr = py_obj.repr().unwrap().to_string();
154+
eprintln!("FR-013 unit variant repr: {repr}");
155+
let result: Result<Color, _> = depythonize(&py_obj);
156+
// BASELINE: Color::Red serialises as the Python string 'Red' (observed repr: 'Red').
157+
assert_eq!(result.unwrap(), val);
158+
});
159+
}
160+
161+
#[test]
162+
fn test_enum_tuple_variant_round_trip() {
163+
Python::attach(|py| {
164+
let val = Color::Rgb(255, 128, 0);
165+
let py_obj = pythonize(py, &val).expect("pythonize Color::Rgb failed");
166+
let repr = py_obj.repr().unwrap().to_string();
167+
eprintln!("FR-013 tuple variant repr: {repr}");
168+
let result: Result<Color, _> = depythonize(&py_obj);
169+
// BASELINE: Color::Rgb(255,128,0) serialises as {'Rgb': (255, 128, 0)} —
170+
// value is a Python *tuple*, not a list (observed repr: {'Rgb': (255, 128, 0)}).
171+
assert_eq!(result.unwrap(), val);
172+
});
173+
}
174+
175+
#[test]
176+
fn test_enum_struct_variant_round_trip() {
177+
Python::attach(|py| {
178+
let val = Color::Named {
179+
name: String::from("crimson"),
180+
};
181+
let py_obj = pythonize(py, &val).expect("pythonize Color::Named failed");
182+
let repr = py_obj.repr().unwrap().to_string();
183+
eprintln!("FR-013 struct variant repr: {repr}");
184+
let result: Result<Color, _> = depythonize(&py_obj);
185+
// BASELINE: Color::Named serialises as {'Named': {'name': 'crimson'}}
186+
// (observed repr: {'Named': {'name': 'crimson'}}).
187+
assert_eq!(result.unwrap(), val);
188+
});
189+
}

tests/test_unicode.rs

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
use pyo3::prelude::*;
2+
use pythonize::{depythonize, pythonize};
3+
4+
// ---------------------------------------------------------------------------
5+
// Case 1 — Non-Latin script round-trips
6+
// Normative: String values containing non-Latin scripts must round-trip
7+
// byte-exactly. These are not characterisation tests.
8+
// ---------------------------------------------------------------------------
9+
10+
#[test]
11+
fn test_string_cyrillic_round_trip() {
12+
Python::attach(|py| {
13+
let val = String::from("Привет"); // U+041F U+0440 U+0438 U+0432 U+0435 U+0442
14+
let result = pythonize(py, &val).and_then(|py_val| depythonize::<String>(&py_val));
15+
assert_eq!(result.unwrap(), val);
16+
});
17+
}
18+
19+
#[test]
20+
fn test_string_cjk_round_trip() {
21+
Python::attach(|py| {
22+
let val = String::from("你好"); // U+4F60 U+597D
23+
let result = pythonize(py, &val).and_then(|py_val| depythonize::<String>(&py_val));
24+
assert_eq!(result.unwrap(), val);
25+
});
26+
}
27+
28+
#[test]
29+
fn test_string_arabic_round_trip() {
30+
Python::attach(|py| {
31+
let val = String::from("مرحبا"); // U+0645 U+0631 U+062D U+0628 U+0627
32+
let result = pythonize(py, &val).and_then(|py_val| depythonize::<String>(&py_val));
33+
assert_eq!(result.unwrap(), val);
34+
});
35+
}
36+
37+
#[test]
38+
fn test_string_devanagari_round_trip() {
39+
Python::attach(|py| {
40+
let val = String::from("नमस्ते"); // U+0928 U+092E U+0938 U+094D U+0924 U+0947
41+
let result = pythonize(py, &val).and_then(|py_val| depythonize::<String>(&py_val));
42+
assert_eq!(result.unwrap(), val);
43+
});
44+
}
45+
46+
#[test]
47+
fn test_string_hebrew_round_trip() {
48+
Python::attach(|py| {
49+
let val = String::from("שלום"); // U+05E9 U+05DC U+05D5 U+05DD
50+
let result = pythonize(py, &val).and_then(|py_val| depythonize::<String>(&py_val));
51+
assert_eq!(result.unwrap(), val);
52+
});
53+
}
54+
55+
// ---------------------------------------------------------------------------
56+
// Case 2 — Homoglyphs are preserved as their original codepoint
57+
// BASELINE: Python does not normalise homoglyphs; each codepoint round-trips
58+
// to itself with no cross-codepoint equality.
59+
// ---------------------------------------------------------------------------
60+
61+
#[test]
62+
fn test_string_latin_capital_a_round_trip() {
63+
Python::attach(|py| {
64+
let val = String::from("\u{0041}"); // Latin capital A (U+0041)
65+
let result = pythonize(py, &val).and_then(|py_val| depythonize::<String>(&py_val));
66+
// BASELINE: round-trip preserves U+0041; Python does not normalise to another A-homoglyph.
67+
assert_eq!(result.unwrap(), val);
68+
});
69+
}
70+
71+
#[test]
72+
fn test_string_cyrillic_capital_a_round_trip() {
73+
Python::attach(|py| {
74+
// Cyrillic capital А (U+0410) — visually identical to Latin A (U+0041)
75+
let val = String::from("\u{0410}");
76+
let result = pythonize(py, &val).and_then(|py_val| depythonize::<String>(&py_val));
77+
// BASELINE: round-trip preserves U+0410; Python does not rewrite to U+0041.
78+
assert_eq!(result.unwrap(), val);
79+
});
80+
}
81+
82+
#[test]
83+
fn test_string_fullwidth_latin_capital_a_round_trip() {
84+
Python::attach(|py| {
85+
let val = String::from("\u{FF21}"); // Fullwidth Latin capital A (U+FF21)
86+
let result = pythonize(py, &val).and_then(|py_val| depythonize::<String>(&py_val));
87+
// BASELINE: round-trip preserves U+FF21; Python does not rewrite to U+0041.
88+
assert_eq!(result.unwrap(), val);
89+
});
90+
}
91+
92+
// ---------------------------------------------------------------------------
93+
// Case 3 — NFD is preserved, no silent NFC recomposition
94+
// Confirmed baseline: CPython's PyString::new / to_cow() are length-aware;
95+
// no Unicode normalisation is applied. NFD in → NFD out.
96+
// ---------------------------------------------------------------------------
97+
98+
#[test]
99+
fn test_string_nfd_combining_acute_preserved() {
100+
Python::attach(|py| {
101+
// NFD: 'e' (U+0065, 1 byte) + combining acute accent (U+0301, 2 bytes) = 3 bytes.
102+
// Do NOT use the precomposed "é" (U+00E9, NFC, 2 bytes) — that would be trivial.
103+
let val = String::from("e\u{0301}");
104+
let result = pythonize(py, &val).and_then(|py_val| depythonize::<String>(&py_val));
105+
// BASELINE (confirmed 2026-04-04): CPython preserves NFD; no NFC recomposition.
106+
assert!(result.is_ok());
107+
let back = result.unwrap();
108+
assert_eq!(back, "e\u{0301}");
109+
assert_eq!(back.len(), 3); // 3 bytes in UTF-8; NFC "é" (U+00E9) would be 2
110+
});
111+
}
112+
113+
// ---------------------------------------------------------------------------
114+
// Case 4 — Non-BMP emoji round-trip
115+
// ---------------------------------------------------------------------------
116+
117+
#[test]
118+
fn test_string_non_bmp_emoji_round_trip() {
119+
Python::attach(|py| {
120+
// Non-BMP: 4 UTF-8 bytes; Python 3 str handles this natively.
121+
let val = String::from("🦀"); // U+1F980, Rust str len = 4
122+
let result = pythonize(py, &val).and_then(|py_val| depythonize::<String>(&py_val));
123+
assert!(result.is_ok());
124+
assert_eq!(result.unwrap(), "🦀");
125+
});
126+
}
127+
128+
// ---------------------------------------------------------------------------
129+
// Case 5 — Null byte survives round-trip
130+
// Confirmed baseline: pythonize uses length-aware CPython APIs; embedded null
131+
// is not a string terminator.
132+
// ---------------------------------------------------------------------------
133+
134+
#[test]
135+
fn test_string_embedded_null_round_trip() {
136+
Python::attach(|py| {
137+
let val = String::from("hello\x00world");
138+
let result = pythonize(py, &val).and_then(|py_val| depythonize::<String>(&py_val));
139+
// BASELINE (confirmed 2026-04-04): pythonize uses length-aware CPython APIs;
140+
// embedded null is not a string terminator.
141+
// Caveat: truncation could occur if an intermediate consumer uses
142+
// PyUnicode_AsUTF8 (no-size variant) — not pythonize's bug.
143+
assert!(result.is_ok());
144+
assert_eq!(result.unwrap(), "hello\x00world");
145+
});
146+
}

0 commit comments

Comments
 (0)