use std::path::PathBuf;
use mac_encoding::Encoding;
const FILES: &[(Encoding, &str)] = &[
(Encoding::Arabic, "ARABIC"),
(Encoding::Celtic, "CELTIC"),
(Encoding::CentralEuropean, "CENTEURO"),
(Encoding::Croatian, "CROATIAN"),
(Encoding::Cyrillic, "CYRILLIC"),
(Encoding::Devanagari, "DEVANAGA"),
(Encoding::Dingbats, "DINGBATS"),
(Encoding::Farsi, "FARSI"),
(Encoding::Gaelic, "GAELIC"),
(Encoding::Greek, "GREEK"),
(Encoding::Gujarati, "GUJARATI"),
(Encoding::Gurmukhi, "GURMUKHI"),
(Encoding::Hebrew, "HEBREW"),
(Encoding::Icelandic, "ICELAND"),
(Encoding::Inuit, "INUIT"),
(Encoding::Keyboard, "KEYBOARD"),
(Encoding::Roman, "ROMAN"),
(Encoding::Romanian, "ROMANIAN"),
(Encoding::Symbol, "SYMBOL"),
(Encoding::Thai, "THAI"),
(Encoding::Turkish, "TURKISH"),
];
fn data_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("data/apple")
}
fn sources() -> Option<Vec<(Encoding, &'static str, String)>> {
if !data_dir().is_dir() {
eprintln!(
"Apple comparison tests stopped: {} does not exist. \
Do ./scripts/fetch-sources.sh to make these tests run.",
data_dir().display()
);
return None;
}
Some(
FILES
.iter()
.map(|(encoding, stem)| {
let path = data_dir().join(format!("{stem}.TXT"));
let source = std::fs::read_to_string(&path).unwrap_or_else(|e| {
panic!(
"the directory data/ exists, but this program cannot read {}: {e}\n\
Do ./scripts/fetch-sources.sh again.",
path.display()
)
});
(*encoding, *stem, source)
})
.collect(),
)
}
struct Mapping {
code: Vec<u8>,
text: String,
directional: bool,
}
fn parse(source: &str) -> Vec<Mapping> {
let mut out = Vec::new();
for line in source.lines() {
let line = line.split('#').next().unwrap().trim();
if line.is_empty() {
continue;
}
let mut columns = line.split_whitespace();
let code_column = columns.next().unwrap();
let Some(mut mapping_column) = columns.next() else {
continue;
};
let mut directional = false;
if mapping_column.starts_with('<') {
directional = true;
match mapping_column.split_once('+') {
Some((_, rest)) => mapping_column = rest,
None => continue,
}
}
let mut code = Vec::new();
for token in code_column.split('+') {
let hex = token.strip_prefix("0x").unwrap();
for pair in hex.as_bytes().chunks(2) {
code.push(u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap());
}
}
let text: String = mapping_column
.split('+')
.map(|t| {
let hex = t.strip_prefix("0x").unwrap();
char::from_u32(u32::from_str_radix(hex, 16).unwrap()).unwrap()
})
.collect();
out.push(Mapping {
code,
text,
directional,
});
}
out
}
#[test]
fn sources_are_complete_when_present() {
let Some(files) = sources() else { return };
assert_eq!(files.len(), mac_encoding::ALL.len());
for (encoding, name, source) in &files {
assert!(
!parse(source).is_empty(),
"{name}: parsed no mappings for {encoding}"
);
}
}
#[test]
fn every_encoding_has_a_source_file() {
assert_eq!(FILES.len(), mac_encoding::ALL.len());
for encoding in mac_encoding::ALL.iter().copied() {
assert!(
FILES.iter().any(|(e, _)| *e == encoding),
"{encoding} has no source file"
);
}
}
#[test]
fn every_mapping_decodes_to_what_apple_says() {
let Some(files) = sources() else { return };
for (encoding, name, source) in &files {
for m in parse(source) {
assert_eq!(
encoding.decode(&m.code),
m.text,
"{name}: {:02X?} must decode to {:?}",
m.code,
m.text
);
}
}
}
#[test]
fn decoding_is_never_lossy() {
let Some(files) = sources() else { return };
for (encoding, name, source) in &files {
for m in parse(source) {
assert!(
!encoding.decode(&m.code).contains('\u{FFFD}'),
"{name}: {:02X?} decoded to a replacement character",
m.code
);
}
}
}
#[test]
fn text_always_round_trips() {
let Some(files) = sources() else { return };
for (encoding, name, source) in &files {
for m in parse(source) {
let Ok(reencoded) = encoding.encode(&m.text) else {
panic!("{name}: {:?} has no encoding", m.text);
};
assert_eq!(
encoding.decode(&reencoded),
m.text,
"{name}: {:?} did not survive a round trip",
m.text
);
}
}
}
#[test]
fn bytes_round_trip_unless_the_table_collides() {
let Some(files) = sources() else { return };
for (encoding, name, source) in &files {
let mappings = parse(source);
let mut collides = std::collections::HashMap::<&str, usize>::new();
for m in &mappings {
*collides.entry(m.text.as_str()).or_default() += 1;
}
let mut saw_collision = false;
for m in &mappings {
let reencoded = encoding.encode(&m.text).unwrap();
if collides[m.text.as_str()] > 1 {
saw_collision = true;
let lowest = mappings
.iter()
.filter(|o| o.text == m.text)
.map(|o| o.code.clone())
.min()
.unwrap();
assert_eq!(
reencoded, lowest,
"{name}: {:?} chose the wrong code",
m.text
);
} else {
assert_eq!(
reencoded, m.code,
"{name}: {:?} re-encoded to a different byte",
m.text
);
}
}
assert_eq!(
encoding.encode_is_lossy(),
saw_collision,
"{name}: encode_is_lossy disagrees with the file"
);
}
}
#[test]
fn directional_tables_are_flagged() {
let Some(files) = sources() else { return };
for (encoding, name, source) in &files {
assert_eq!(
encoding.is_directional(),
parse(source).iter().any(|m| m.directional),
"{name}: is_directional disagrees with the file"
);
}
}
#[test]
fn omitted_controls_decode_as_themselves() {
let Some(files) = sources() else { return };
for (encoding, name, source) in &files {
let stated: Vec<u8> = parse(source)
.into_iter()
.filter(|m| m.code.len() == 1)
.map(|m| m.code[0])
.collect();
for byte in (0x00..=0x1Fu8).chain(std::iter::once(0x7F)) {
if stated.contains(&byte) {
continue;
}
assert_eq!(
encoding.decode(&[byte]),
(byte as char).to_string(),
"{name}: control {byte:#04X} is not identity"
);
}
}
}
#[test]
fn synthesized_controls_round_trip() {
let Some(files) = sources() else { return };
for (encoding, name, source) in &files {
let stated: std::collections::HashMap<u8, String> = parse(source)
.into_iter()
.filter(|m| m.code.len() == 1)
.map(|m| (m.code[0], m.text))
.collect();
for byte in (0x00..=0x1Fu8).chain(std::iter::once(0x7F)) {
let control = (byte as char).to_string();
let identity = stated.get(&byte).map_or(true, |text| *text == control);
if identity {
assert_eq!(
encoding.decode(&[byte]),
control,
"{name}: control {byte:#04X} does not decode to itself"
);
assert_eq!(
encoding.encode(&control),
Ok(vec![byte]),
"{name}: control {byte:#04X} does not encode to itself"
);
} else {
assert_ne!(
encoding.encode(&control).ok(),
Some(vec![byte]),
"{name}: control {byte:#04X} encodes to a byte that shows {:?}",
stated[&byte]
);
}
}
}
}
#[test]
fn every_scalar_survives_encode_and_then_decode() {
let mut buffer = [0u8; 4];
for encoding in mac_encoding::ALL.iter().copied() {
for value in 0..=0x10FFFFu32 {
let Some(scalar) = char::from_u32(value) else {
continue;
};
let text = scalar.encode_utf8(&mut buffer);
if let Ok(bytes) = encoding.encode(text) {
assert_eq!(
encoding.decode(&bytes),
*text,
"{encoding}: U+{value:04X} encoded to {bytes:02X?}, which shows other text"
);
}
}
}
}
#[test]
fn keyboard_has_no_code_for_22_control_characters() {
let without_a_code: Vec<u32> = (0x00..=0x1Fu32)
.chain(std::iter::once(0x7F))
.filter(|v| {
let c = char::from_u32(*v).unwrap();
Encoding::Keyboard.encode(&c.to_string()).is_err()
})
.collect();
assert_eq!(
without_a_code,
vec![
0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x09, 0x0A, 0x0B, 0x0C, 0x0F, 0x10, 0x11, 0x12,
0x13, 0x14, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C
]
);
assert_eq!(Encoding::Keyboard.decode(&[0x02]), "\u{21E5}");
assert!(Encoding::Keyboard.encode("\u{2}").is_err());
assert_eq!(Encoding::Keyboard.encode_html("\u{2}"), b"");
assert_eq!(Encoding::Roman.encode("\u{2}").unwrap(), vec![0x02]);
assert_eq!(Encoding::Symbol.encode("\u{2}").unwrap(), vec![0x02]);
}
#[test]
fn exactly_four_encodings_are_lossy() {
let lossy: Vec<_> = mac_encoding::ALL
.iter()
.copied()
.filter(|e| e.encode_is_lossy())
.collect();
assert_eq!(
lossy,
vec![
Encoding::Arabic,
Encoding::Farsi,
Encoding::Hebrew,
Encoding::Keyboard
]
);
assert_eq!(Encoding::Keyboard.decode(&[0x09]), "\u{2423}");
assert_eq!(Encoding::Keyboard.decode(&[0x61]), "\u{2423}");
assert_eq!(Encoding::Keyboard.encode("\u{2423}").unwrap(), vec![0x09]);
assert!(Encoding::Keyboard.encode_is_lossy());
assert!(!Encoding::Keyboard.is_directional());
}
#[test]
fn only_the_bidi_tables_are_directional() {
let flagged: Vec<_> = mac_encoding::ALL
.iter()
.copied()
.filter(|e| e.is_directional())
.collect();
assert_eq!(
flagged,
vec![Encoding::Arabic, Encoding::Farsi, Encoding::Hebrew]
);
}
#[test]
fn ascii_is_not_assumed_to_be_transparent() {
assert_eq!(Encoding::Symbol.decode(b"A"), "\u{0391}"); assert_ne!(Encoding::Dingbats.decode(b"a"), "a");
assert_ne!(Encoding::Keyboard.decode(b"a"), "a");
let reassigns =
|e: Encoding| (0x20..=0x7Eu8).any(|b| e.decode(&[b]) != (b as char).to_string());
let found: Vec<_> = mac_encoding::ALL
.iter()
.copied()
.filter(|e| reassigns(*e))
.collect();
assert_eq!(
found,
vec![Encoding::Dingbats, Encoding::Keyboard, Encoding::Symbol]
);
}
#[test]
fn two_byte_codes_win_over_one_byte_codes() {
assert_eq!(
Encoding::Devanagari.decode(&[0xE8, 0xE9]),
"\u{094D}\u{200D}"
);
assert_ne!(
Encoding::Devanagari.decode(&[0xE8, 0xE9]),
format!(
"{}{}",
Encoding::Devanagari.decode(&[0xE8]),
Encoding::Devanagari.decode(&[0xE9])
)
);
assert_eq!(
Encoding::Devanagari.encode("\u{094D}\u{200D}").unwrap(),
vec![0xE8, 0xE9]
);
}
#[test]
fn scalar_sequences_win_over_single_scalars() {
assert_eq!(Encoding::Symbol.decode(&[0xE2]), "\u{00AE}\u{F87F}");
assert_eq!(
Encoding::Symbol.encode("\u{00AE}\u{F87F}").unwrap(),
vec![0xE2]
);
assert_ne!(Encoding::Symbol.encode("\u{00AE}").unwrap(), vec![0xE2]);
}
#[test]
fn defines_every_byte_matches_the_tables() {
for encoding in mac_encoding::ALL.iter().copied() {
let complete = (0..=255u8).all(|b| encoding.decode_strict(&[b]).is_ok());
assert_eq!(encoding.defines_every_byte(), complete, "{encoding}");
}
assert!(Encoding::Roman.defines_every_byte());
assert!(!Encoding::Symbol.defines_every_byte());
}
#[test]
fn identifiers_are_unique_and_resolvable() {
let mut ids: Vec<_> = mac_encoding::ALL.iter().map(|e| e.id()).collect();
ids.sort_unstable();
let count = ids.len();
ids.dedup();
assert_eq!(ids.len(), count, "two encodings share an id");
for encoding in mac_encoding::ALL.iter().copied() {
assert_eq!(Encoding::from_id(encoding.id()), Some(encoding));
}
assert_eq!(
Encoding::from_id("central-european"),
Some(Encoding::CentralEuropean)
);
assert_eq!(Encoding::from_id("nonesuch"), None);
}