Skip to content

Commit d5cdaa5

Browse files
committed
test(lib): add conversion matrix coverage
1 parent e233a39 commit d5cdaa5

3 files changed

Lines changed: 219 additions & 7 deletions

File tree

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ For each new format:
9494
## Testing Strategy
9595

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

langcodec/src/converter.rs

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,27 @@ use crate::{
1515
};
1616
use std::path::Path;
1717

18+
fn ensure_xcstrings_metadata(resources: &mut [Resource]) {
19+
let fallback_source_language = resources
20+
.iter()
21+
.find(|r| !r.metadata.language.is_empty())
22+
.map(|r| r.metadata.language.clone())
23+
.unwrap_or_else(|| "en".to_string());
24+
25+
for resource in resources {
26+
resource
27+
.metadata
28+
.custom
29+
.entry("source_language".to_string())
30+
.or_insert_with(|| fallback_source_language.clone());
31+
resource
32+
.metadata
33+
.custom
34+
.entry("version".to_string())
35+
.or_insert_with(|| "1.0".to_string());
36+
}
37+
}
38+
1839
/// Convert a vector of resources to a specific output format.
1940
///
2041
/// # Arguments
@@ -48,7 +69,7 @@ use std::path::Path;
4869
/// # Ok::<(), langcodec::Error>(())
4970
/// ```
5071
pub fn convert_resources_to_format(
51-
resources: Vec<Resource>,
72+
mut resources: Vec<Resource>,
5273
output_path: &str,
5374
output_format: FormatType,
5475
) -> Result<(), Error> {
@@ -85,11 +106,14 @@ pub fn convert_resources_to_format(
85106
))
86107
}
87108
}
88-
FormatType::Xcstrings => XcstringsFormat::try_from(resources)
89-
.and_then(|f| f.write_to(Path::new(output_path)))
90-
.map_err(|e| {
91-
Error::conversion_error(format!("Error writing Xcstrings output: {}", e), None)
92-
}),
109+
FormatType::Xcstrings => {
110+
ensure_xcstrings_metadata(&mut resources);
111+
XcstringsFormat::try_from(resources)
112+
.and_then(|f| f.write_to(Path::new(output_path)))
113+
.map_err(|e| {
114+
Error::conversion_error(format!("Error writing Xcstrings output: {}", e), None)
115+
})
116+
}
93117
FormatType::CSV => CSVFormat::try_from(resources)
94118
.and_then(|f| f.write_to(Path::new(output_path)))
95119
.map_err(|e| Error::conversion_error(format!("Error writing CSV output: {}", e), None)),
@@ -161,6 +185,10 @@ pub fn convert<P: AsRef<Path>>(
161185
}
162186
}
163187

188+
if matches!(output_format, FormatType::Xcstrings) {
189+
ensure_xcstrings_metadata(&mut resources);
190+
}
191+
164192
// Helper to extract resource by language if present, or first one
165193
let pick_resource = |lang: Option<String>| -> Option<Resource> {
166194
match lang {
@@ -276,6 +304,10 @@ pub fn convert_with_normalization<P: AsRef<Path>>(
276304
}
277305
}
278306

307+
if matches!(output_format, FormatType::Xcstrings) {
308+
ensure_xcstrings_metadata(&mut resources);
309+
}
310+
279311
// Helper to extract resource by language if present, or first one
280312
let pick_resource = |lang: Option<String>| -> Option<Resource> {
281313
match lang {
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
use langcodec::traits::Parser;
2+
use langcodec::types::{Entry, EntryStatus, Metadata, Resource, Translation};
3+
use langcodec::{Codec, convert_auto, formats::XcstringsFormat};
4+
use std::collections::HashMap;
5+
use std::path::{Path, PathBuf};
6+
7+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
8+
enum MatrixFormat {
9+
Strings,
10+
AndroidXml,
11+
Xcstrings,
12+
Csv,
13+
Tsv,
14+
}
15+
16+
impl MatrixFormat {
17+
fn id(self) -> &'static str {
18+
match self {
19+
MatrixFormat::Strings => "strings",
20+
MatrixFormat::AndroidXml => "android_xml",
21+
MatrixFormat::Xcstrings => "xcstrings",
22+
MatrixFormat::Csv => "csv",
23+
MatrixFormat::Tsv => "tsv",
24+
}
25+
}
26+
27+
fn output_file_name(self, source: MatrixFormat) -> String {
28+
match self {
29+
MatrixFormat::Strings => format!("{}_to_strings.strings", source.id()),
30+
MatrixFormat::AndroidXml => format!("{}_to_strings.xml", source.id()),
31+
MatrixFormat::Xcstrings => format!("{}_to_localizable.xcstrings", source.id()),
32+
MatrixFormat::Csv => format!("{}_to_translations.csv", source.id()),
33+
MatrixFormat::Tsv => format!("{}_to_translations.tsv", source.id()),
34+
}
35+
}
36+
}
37+
38+
fn build_seed_resource() -> Resource {
39+
let mut custom = HashMap::new();
40+
custom.insert("source_language".to_string(), "en".to_string());
41+
custom.insert("version".to_string(), "1.0".to_string());
42+
43+
Resource {
44+
metadata: Metadata {
45+
language: "en".to_string(),
46+
domain: "Localizable".to_string(),
47+
custom,
48+
},
49+
entries: vec![
50+
Entry {
51+
id: "hello".to_string(),
52+
value: Translation::Singular("Hello".to_string()),
53+
comment: None,
54+
status: EntryStatus::Translated,
55+
custom: HashMap::new(),
56+
},
57+
Entry {
58+
id: "bye".to_string(),
59+
value: Translation::Singular("Goodbye".to_string()),
60+
comment: None,
61+
status: EntryStatus::Translated,
62+
custom: HashMap::new(),
63+
},
64+
],
65+
}
66+
}
67+
68+
fn write_seed_files(root: &Path) -> HashMap<MatrixFormat, PathBuf> {
69+
let strings_dir = root.join("seed").join("en.lproj");
70+
let android_dir = root.join("seed").join("values-en");
71+
let generic_dir = root.join("seed");
72+
73+
std::fs::create_dir_all(&strings_dir).expect("create strings seed dir");
74+
std::fs::create_dir_all(&android_dir).expect("create android seed dir");
75+
std::fs::create_dir_all(&generic_dir).expect("create generic seed dir");
76+
77+
let strings_path = strings_dir.join("Localizable.strings");
78+
let android_path = android_dir.join("strings.xml");
79+
let xcstrings_path = generic_dir.join("Localizable.xcstrings");
80+
let csv_path = generic_dir.join("translations.csv");
81+
let tsv_path = generic_dir.join("translations.tsv");
82+
83+
std::fs::write(
84+
&strings_path,
85+
"\"hello\" = \"Hello\";\n\"bye\" = \"Goodbye\";\n",
86+
)
87+
.expect("write strings seed");
88+
std::fs::write(
89+
&android_path,
90+
"<resources>\n <string name=\"hello\">Hello</string>\n <string name=\"bye\">Goodbye</string>\n</resources>\n",
91+
)
92+
.expect("write android seed");
93+
std::fs::write(&csv_path, "key,en\nhello,Hello\nbye,Goodbye\n").expect("write csv seed");
94+
std::fs::write(&tsv_path, "key\ten\nhello\tHello\nbye\tGoodbye\n").expect("write tsv seed");
95+
96+
let xcstrings = XcstringsFormat::try_from(vec![build_seed_resource()]).expect("xcstrings seed");
97+
xcstrings
98+
.write_to(&xcstrings_path)
99+
.expect("write xcstrings seed");
100+
101+
HashMap::from([
102+
(MatrixFormat::Strings, strings_path),
103+
(MatrixFormat::AndroidXml, android_path),
104+
(MatrixFormat::Xcstrings, xcstrings_path),
105+
(MatrixFormat::Csv, csv_path),
106+
(MatrixFormat::Tsv, tsv_path),
107+
])
108+
}
109+
110+
fn assert_en_entries(path: &Path, case_name: &str) {
111+
let mut codec = Codec::new();
112+
codec
113+
.read_file_by_extension(path, Some("en".to_string()))
114+
.unwrap_or_else(|e| {
115+
panic!(
116+
"{case_name}: failed to read output {}: {}",
117+
path.display(),
118+
e
119+
)
120+
});
121+
122+
let en_resource = codec
123+
.get_by_language("en")
124+
.unwrap_or_else(|| panic!("{case_name}: expected an 'en' resource"));
125+
assert!(
126+
en_resource.entries.len() >= 2,
127+
"{case_name}: expected at least 2 entries"
128+
);
129+
130+
let hello = codec
131+
.find_entry("hello", "en")
132+
.unwrap_or_else(|| panic!("{case_name}: missing key 'hello'"));
133+
match &hello.value {
134+
Translation::Singular(value) => assert_eq!(value, "Hello", "{case_name}: bad hello value"),
135+
other => panic!("{case_name}: expected singular hello, got {:?}", other),
136+
}
137+
138+
let bye = codec
139+
.find_entry("bye", "en")
140+
.unwrap_or_else(|| panic!("{case_name}: missing key 'bye'"));
141+
match &bye.value {
142+
Translation::Singular(value) => assert_eq!(value, "Goodbye", "{case_name}: bad bye value"),
143+
other => panic!("{case_name}: expected singular bye, got {:?}", other),
144+
}
145+
}
146+
147+
#[test]
148+
fn conversion_matrix_common_paths_preserve_values() {
149+
let temp = tempfile::tempdir().expect("create temp dir");
150+
let seed_paths = write_seed_files(temp.path());
151+
152+
let formats = [
153+
MatrixFormat::Strings,
154+
MatrixFormat::AndroidXml,
155+
MatrixFormat::Xcstrings,
156+
MatrixFormat::Csv,
157+
MatrixFormat::Tsv,
158+
];
159+
160+
let output_root = temp.path().join("matrix_output");
161+
std::fs::create_dir_all(&output_root).expect("create matrix output dir");
162+
163+
for source in formats {
164+
for target in formats {
165+
if source == target {
166+
continue;
167+
}
168+
169+
let source_path = seed_paths
170+
.get(&source)
171+
.unwrap_or_else(|| panic!("missing seed path for {:?}", source));
172+
let target_path = output_root.join(target.output_file_name(source));
173+
174+
let case_name = format!("{} -> {}", source.id(), target.id());
175+
convert_auto(source_path, &target_path)
176+
.unwrap_or_else(|e| panic!("{case_name}: conversion failed: {}", e));
177+
assert_en_entries(&target_path, &case_name);
178+
}
179+
}
180+
}

0 commit comments

Comments
 (0)