Skip to content

Commit 806d45b

Browse files
committed
test(lib): add proptest roundtrip invariants
1 parent d5cdaa5 commit 806d45b

3 files changed

Lines changed: 291 additions & 1 deletion

File tree

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ For each new format:
9595

9696
- [x] Start with unit tests near each format parser/writer
9797
- [x] Add conversion matrix tests for common paths (strings↔android↔xcstrings↔csv/tsv)
98-
- [ ] Property tests where feasible (e.g., round‑trip invariants)
98+
- [x] Property tests where feasible (e.g., round‑trip invariants)
9999
- [ ] Large sample corpora in `tests/data/` for regression
100100

101101
## Contribution Guide Enhancements

langcodec/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,5 @@ encoding_rs = "0.8"
2828
encoding_rs_io = "0.1"
2929

3030
[dev-dependencies]
31+
proptest = "1.6"
3132
tempfile = "3.8"
Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
use langcodec::Codec;
2+
use langcodec::converter::{convert, convert_resources_to_format};
3+
use langcodec::formats::FormatType;
4+
use langcodec::types::{Entry, EntryStatus, Metadata, Resource, Translation};
5+
use proptest::prelude::*;
6+
use std::collections::{BTreeMap, HashMap};
7+
use std::path::Path;
8+
9+
fn key_strategy() -> impl Strategy<Value = String> {
10+
proptest::string::string_regex("[a-z][a-z0-9_]{0,15}").expect("valid key regex")
11+
}
12+
13+
fn value_strategy() -> impl Strategy<Value = String> {
14+
proptest::string::string_regex("[A-Za-z0-9 _\\-\\.,!\\?]{1,30}").expect("valid value regex")
15+
}
16+
17+
fn single_lang_dataset_strategy() -> impl Strategy<Value = BTreeMap<String, String>> {
18+
prop::collection::btree_map(key_strategy(), value_strategy(), 1..8)
19+
}
20+
21+
fn two_lang_dataset_strategy() -> impl Strategy<Value = BTreeMap<String, (String, String)>> {
22+
prop::collection::btree_map(key_strategy(), (value_strategy(), value_strategy()), 1..8)
23+
}
24+
25+
fn build_resource(language: &str, values: &BTreeMap<String, String>) -> Resource {
26+
let mut custom = HashMap::new();
27+
custom.insert("source_language".to_string(), "en".to_string());
28+
custom.insert("version".to_string(), "1.0".to_string());
29+
30+
let entries = values
31+
.iter()
32+
.map(|(key, value)| Entry {
33+
id: key.clone(),
34+
value: Translation::Singular(value.clone()),
35+
comment: None,
36+
status: EntryStatus::Translated,
37+
custom: HashMap::new(),
38+
})
39+
.collect();
40+
41+
Resource {
42+
metadata: Metadata {
43+
language: language.to_string(),
44+
domain: "Localizable".to_string(),
45+
custom,
46+
},
47+
entries,
48+
}
49+
}
50+
51+
fn build_two_lang_resources(values: &BTreeMap<String, (String, String)>) -> Vec<Resource> {
52+
let en_map = values
53+
.iter()
54+
.map(|(key, (en, _))| (key.clone(), en.clone()))
55+
.collect::<BTreeMap<_, _>>();
56+
let fr_map = values
57+
.iter()
58+
.map(|(key, (_, fr))| (key.clone(), fr.clone()))
59+
.collect::<BTreeMap<_, _>>();
60+
61+
vec![build_resource("en", &en_map), build_resource("fr", &fr_map)]
62+
}
63+
64+
fn expected_single_lang_map(
65+
values: &BTreeMap<String, String>,
66+
) -> BTreeMap<(String, String), String> {
67+
values
68+
.iter()
69+
.map(|(key, value)| (("en".to_string(), key.clone()), value.clone()))
70+
.collect()
71+
}
72+
73+
fn expected_two_lang_map(
74+
values: &BTreeMap<String, (String, String)>,
75+
) -> BTreeMap<(String, String), String> {
76+
let mut out = BTreeMap::new();
77+
for (key, (en, fr)) in values {
78+
out.insert(("en".to_string(), key.clone()), en.clone());
79+
out.insert(("fr".to_string(), key.clone()), fr.clone());
80+
}
81+
out
82+
}
83+
84+
fn read_resources(path: &Path, lang_hint: Option<&str>) -> Result<Vec<Resource>, String> {
85+
let mut codec = Codec::new();
86+
codec
87+
.read_file_by_extension(path, lang_hint.map(|lang| lang.to_string()))
88+
.map_err(|e| e.to_string())?;
89+
Ok(codec.resources)
90+
}
91+
92+
fn canonical_singular_map(resources: &[Resource]) -> BTreeMap<(String, String), String> {
93+
let mut out = BTreeMap::new();
94+
95+
for resource in resources {
96+
for entry in &resource.entries {
97+
if let Translation::Singular(value) = &entry.value {
98+
out.insert(
99+
(resource.metadata.language.clone(), entry.id.clone()),
100+
value.clone(),
101+
);
102+
}
103+
}
104+
}
105+
106+
out
107+
}
108+
109+
proptest! {
110+
#![proptest_config(ProptestConfig::with_cases(16))]
111+
112+
#[test]
113+
fn strings_android_strings_roundtrip_preserves_singular_entries(values in single_lang_dataset_strategy()) {
114+
let tmp = tempfile::tempdir().map_err(|e| TestCaseError::fail(e.to_string()))?;
115+
let input = tmp.path().join("seed.strings");
116+
let middle = tmp.path().join("middle.xml");
117+
let output = tmp.path().join("roundtrip.strings");
118+
119+
let seed = vec![build_resource("en", &values)];
120+
convert_resources_to_format(
121+
seed,
122+
input.to_str().expect("path to str"),
123+
FormatType::Strings(Some("en".to_string())),
124+
)
125+
.map_err(|e| TestCaseError::fail(e.to_string()))?;
126+
127+
convert(
128+
&input,
129+
FormatType::Strings(Some("en".to_string())),
130+
&middle,
131+
FormatType::AndroidStrings(Some("en".to_string())),
132+
)
133+
.map_err(|e| TestCaseError::fail(e.to_string()))?;
134+
135+
convert(
136+
&middle,
137+
FormatType::AndroidStrings(Some("en".to_string())),
138+
&output,
139+
FormatType::Strings(Some("en".to_string())),
140+
)
141+
.map_err(|e| TestCaseError::fail(e.to_string()))?;
142+
143+
let actual = read_resources(&output, Some("en")).map_err(TestCaseError::fail)?;
144+
prop_assert_eq!(
145+
canonical_singular_map(&actual),
146+
expected_single_lang_map(&values)
147+
);
148+
}
149+
}
150+
151+
proptest! {
152+
#![proptest_config(ProptestConfig::with_cases(16))]
153+
154+
#[test]
155+
fn android_strings_android_roundtrip_preserves_singular_entries(values in single_lang_dataset_strategy()) {
156+
let tmp = tempfile::tempdir().map_err(|e| TestCaseError::fail(e.to_string()))?;
157+
let input = tmp.path().join("seed.xml");
158+
let middle = tmp.path().join("middle.strings");
159+
let output = tmp.path().join("roundtrip.xml");
160+
161+
let seed = vec![build_resource("en", &values)];
162+
convert_resources_to_format(
163+
seed,
164+
input.to_str().expect("path to str"),
165+
FormatType::AndroidStrings(Some("en".to_string())),
166+
)
167+
.map_err(|e| TestCaseError::fail(e.to_string()))?;
168+
169+
convert(
170+
&input,
171+
FormatType::AndroidStrings(Some("en".to_string())),
172+
&middle,
173+
FormatType::Strings(Some("en".to_string())),
174+
)
175+
.map_err(|e| TestCaseError::fail(e.to_string()))?;
176+
177+
convert(
178+
&middle,
179+
FormatType::Strings(Some("en".to_string())),
180+
&output,
181+
FormatType::AndroidStrings(Some("en".to_string())),
182+
)
183+
.map_err(|e| TestCaseError::fail(e.to_string()))?;
184+
185+
let actual = read_resources(&output, Some("en")).map_err(TestCaseError::fail)?;
186+
prop_assert_eq!(
187+
canonical_singular_map(&actual),
188+
expected_single_lang_map(&values)
189+
);
190+
}
191+
}
192+
193+
proptest! {
194+
#![proptest_config(ProptestConfig::with_cases(16))]
195+
196+
#[test]
197+
fn strings_xcstrings_strings_roundtrip_preserves_singular_entries(values in single_lang_dataset_strategy()) {
198+
let tmp = tempfile::tempdir().map_err(|e| TestCaseError::fail(e.to_string()))?;
199+
let input = tmp.path().join("seed.strings");
200+
let middle = tmp.path().join("middle.xcstrings");
201+
let output = tmp.path().join("roundtrip.strings");
202+
203+
let seed = vec![build_resource("en", &values)];
204+
convert_resources_to_format(
205+
seed,
206+
input.to_str().expect("path to str"),
207+
FormatType::Strings(Some("en".to_string())),
208+
)
209+
.map_err(|e| TestCaseError::fail(e.to_string()))?;
210+
211+
convert(
212+
&input,
213+
FormatType::Strings(Some("en".to_string())),
214+
&middle,
215+
FormatType::Xcstrings,
216+
)
217+
.map_err(|e| TestCaseError::fail(e.to_string()))?;
218+
219+
convert(
220+
&middle,
221+
FormatType::Xcstrings,
222+
&output,
223+
FormatType::Strings(Some("en".to_string())),
224+
)
225+
.map_err(|e| TestCaseError::fail(e.to_string()))?;
226+
227+
let actual = read_resources(&output, Some("en")).map_err(TestCaseError::fail)?;
228+
prop_assert_eq!(
229+
canonical_singular_map(&actual),
230+
expected_single_lang_map(&values)
231+
);
232+
}
233+
}
234+
235+
proptest! {
236+
#![proptest_config(ProptestConfig::with_cases(16))]
237+
238+
#[test]
239+
fn csv_xcstrings_csv_roundtrip_preserves_multilang_entries(values in two_lang_dataset_strategy()) {
240+
let tmp = tempfile::tempdir().map_err(|e| TestCaseError::fail(e.to_string()))?;
241+
let input = tmp.path().join("seed.csv");
242+
let middle = tmp.path().join("middle.xcstrings");
243+
let output = tmp.path().join("roundtrip.csv");
244+
245+
let seed = build_two_lang_resources(&values);
246+
convert_resources_to_format(
247+
seed,
248+
input.to_str().expect("path to str"),
249+
FormatType::CSV,
250+
)
251+
.map_err(|e| TestCaseError::fail(e.to_string()))?;
252+
253+
convert(&input, FormatType::CSV, &middle, FormatType::Xcstrings)
254+
.map_err(|e| TestCaseError::fail(e.to_string()))?;
255+
convert(&middle, FormatType::Xcstrings, &output, FormatType::CSV)
256+
.map_err(|e| TestCaseError::fail(e.to_string()))?;
257+
258+
let actual = read_resources(&output, None).map_err(TestCaseError::fail)?;
259+
prop_assert_eq!(canonical_singular_map(&actual), expected_two_lang_map(&values));
260+
}
261+
}
262+
263+
proptest! {
264+
#![proptest_config(ProptestConfig::with_cases(16))]
265+
266+
#[test]
267+
fn csv_tsv_csv_roundtrip_preserves_multilang_entries(values in two_lang_dataset_strategy()) {
268+
let tmp = tempfile::tempdir().map_err(|e| TestCaseError::fail(e.to_string()))?;
269+
let input = tmp.path().join("seed.csv");
270+
let middle = tmp.path().join("middle.tsv");
271+
let output = tmp.path().join("roundtrip.csv");
272+
273+
let seed = build_two_lang_resources(&values);
274+
convert_resources_to_format(
275+
seed,
276+
input.to_str().expect("path to str"),
277+
FormatType::CSV,
278+
)
279+
.map_err(|e| TestCaseError::fail(e.to_string()))?;
280+
281+
convert(&input, FormatType::CSV, &middle, FormatType::TSV)
282+
.map_err(|e| TestCaseError::fail(e.to_string()))?;
283+
convert(&middle, FormatType::TSV, &output, FormatType::CSV)
284+
.map_err(|e| TestCaseError::fail(e.to_string()))?;
285+
286+
let actual = read_resources(&output, None).map_err(TestCaseError::fail)?;
287+
prop_assert_eq!(canonical_singular_map(&actual), expected_two_lang_map(&values));
288+
}
289+
}

0 commit comments

Comments
 (0)