1use std::collections::HashSet;
6use std::fmt;
7use std::sync::{Mutex, MutexGuard, OnceLock, PoisonError};
8
9use super::profile::{accepted_syllables, default_profile, profile};
10use super::ShuangpinProfile;
11use crate::diagnostics;
12use crate::error::{EngineError, Result};
13use crate::types::{ShuangpinCustomTable, ShuangpinProfileKind};
14
15pub const MULTI_LETTER_INITIALS: [&str; 3] = ["zh", "ch", "sh"];
17const SINGLE_LETTER_INITIALS: &[u8] = b"bpmfdtnlgkhjqxrzcsyw";
19pub const FINALS: [&str; 33] = [
21 "a", "o", "e", "i", "u", "v", "ai", "ei", "ui", "ao", "ou", "iu", "ie", "ve", "ue", "an", "en",
22 "in", "un", "ang", "eng", "ing", "ong", "ia", "ua", "uo", "uai", "ian", "uan", "iao", "iang",
23 "uang", "iong",
24];
25pub const ZERO_INITIALS: [&str; 12] = [
27 "a", "ai", "an", "ang", "ao", "e", "ei", "en", "eng", "er", "o", "ou",
28];
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum CustomTablePart {
33 Initials,
34 Finals,
35 ZeroInitials,
36}
37
38impl CustomTablePart {
39 pub fn name(self) -> &'static str {
40 match self {
41 Self::Initials => "initials",
42 Self::Finals => "finals",
43 Self::ZeroInitials => "zero_initials",
44 }
45 }
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum CustomProfileError {
51 UnknownUnit { part: CustomTablePart, unit: String },
53 DuplicateUnit { part: CustomTablePart, unit: String },
55 MissingUnit {
57 part: CustomTablePart,
58 unit: &'static str,
59 },
60 InvalidKey {
62 part: CustomTablePart,
63 unit: String,
64 key: String,
65 },
66 InitialKeyTaken { unit: &'static str, key: String },
68 AmbiguousCode {
70 code: String,
71 first: String,
72 second: String,
73 },
74}
75
76impl fmt::Display for CustomProfileError {
77 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
78 match self {
79 Self::UnknownUnit { part, unit } => {
80 write!(formatter, "unknown {} unit {unit:?}", part.name())
81 }
82 Self::DuplicateUnit { part, unit } => {
83 write!(formatter, "{} unit {unit:?} appears twice", part.name())
84 }
85 Self::MissingUnit { part, unit } => {
86 write!(formatter, "{} unit {unit:?} has no key", part.name())
87 }
88 Self::InvalidKey { part, unit, key } => {
89 write!(
90 formatter,
91 "{} unit {unit:?} has an invalid key {key:?}",
92 part.name()
93 )
94 }
95 Self::InitialKeyTaken { unit, key } => {
96 write!(formatter, "initial {unit:?} cannot take key {key:?}")
97 }
98 Self::AmbiguousCode {
99 code,
100 first,
101 second,
102 } => write!(
103 formatter,
104 "code {code:?} reads as both {first:?} and {second:?}"
105 ),
106 }
107 }
108}
109
110impl std::error::Error for CustomProfileError {}
111
112struct Canonical {
114 initials: Vec<(&'static str, String)>,
115 finals: Vec<(&'static str, String)>,
116 zero_initials: Vec<(&'static str, String)>,
117}
118
119#[derive(Clone, Copy, PartialEq, Eq)]
120enum InitialText {
121 Single(u8),
122 Multi(&'static str),
123}
124
125#[derive(Clone, Copy, PartialEq, Eq)]
126struct DecodedSyllable {
127 initial: InitialText,
128 final_unit: &'static str,
129}
130
131impl DecodedSyllable {
132 fn matches(self, text: &str) -> bool {
133 match self.initial {
134 InitialText::Single(key) => {
135 text.as_bytes().first() == Some(&key) && text.get(1..) == Some(self.final_unit)
136 }
137 InitialText::Multi(initial) => text
138 .strip_prefix(initial)
139 .is_some_and(|rest| rest == self.final_unit),
140 }
141 }
142
143 fn write_to(self, target: &mut String) {
144 target.clear();
145 match self.initial {
146 InitialText::Single(key) => target.push(char::from(key)),
147 InitialText::Multi(initial) => target.push_str(initial),
148 }
149 target.push_str(self.final_unit);
150 }
151}
152
153pub fn custom_profile(
155 table: &ShuangpinCustomTable,
156) -> Result<&'static ShuangpinProfile, CustomProfileError> {
157 let canonical = canonicalize(table)?;
158 let mut interned = interned();
159 if let Some(found) = interned.iter().find(|profile| canonical.matches(profile)) {
160 return Ok(found);
161 }
162 check_codes(&canonical)?;
163 let profile: &'static ShuangpinProfile = Box::leak(Box::new(ShuangpinProfile {
164 kind: ShuangpinProfileKind::Custom,
165 initials: leak_pairs(canonical.initials),
166 zero_initials: leak_pairs(canonical.zero_initials),
167 finals: leak_pairs(canonical.finals),
168 }));
169 interned.push(profile);
170 Ok(profile)
171}
172
173pub fn session_profile(
175 kind: ShuangpinProfileKind,
176 table: Option<&ShuangpinCustomTable>,
177 shuangpin_enabled: bool,
178) -> Result<&'static ShuangpinProfile> {
179 if let Some(builtin) = profile(kind) {
180 return Ok(builtin);
181 }
182 let resolved = match table {
183 Some(table) => custom_profile(table).map_err(|error| error.to_string()),
184 None => Err("no table".to_owned()),
185 };
186 match resolved {
187 Ok(custom) => Ok(custom),
188 Err(_) if !shuangpin_enabled => Ok(default_profile()),
189 Err(reason) => Err(EngineError::invalid(format!(
190 "{}: {reason}",
191 diagnostics::INVALID_CUSTOM_SHUANGPIN_PROFILE
192 ))),
193 }
194}
195
196pub fn validate_custom_profile(table: &ShuangpinCustomTable) -> Result<(), CustomProfileError> {
198 custom_profile(table).map(|_| ())
199}
200
201pub fn check_custom_profile(table: &ShuangpinCustomTable) -> Result<(), CustomProfileError> {
203 check_codes(&canonicalize(table)?)
204}
205
206pub fn builtin_table(kind: ShuangpinProfileKind) -> Option<ShuangpinCustomTable> {
208 fn owned(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
209 pairs
210 .iter()
211 .map(|(unit, key)| ((*unit).to_owned(), (*key).to_owned()))
212 .collect()
213 }
214 let source = profile(kind)?;
215 Some(ShuangpinCustomTable {
216 initials: owned(source.initials),
217 finals: owned(source.finals),
218 zero_initials: owned(source.zero_initials),
219 })
220}
221
222fn interned() -> MutexGuard<'static, Vec<&'static ShuangpinProfile>> {
224 static INTERNED: OnceLock<Mutex<Vec<&'static ShuangpinProfile>>> = OnceLock::new();
225 INTERNED
226 .get_or_init(Mutex::default)
227 .lock()
228 .unwrap_or_else(PoisonError::into_inner)
229}
230
231fn leak_pairs(pairs: Vec<(&'static str, String)>) -> &'static [(&'static str, &'static str)] {
232 let leaked: Vec<(&'static str, &'static str)> = pairs
233 .into_iter()
234 .map(|(unit, key)| (unit, &*Box::leak(key.into_boxed_str())))
235 .collect();
236 Box::leak(leaked.into_boxed_slice())
237}
238
239impl Canonical {
240 fn matches(&self, profile: &ShuangpinProfile) -> bool {
241 fn same(left: &[(&'static str, String)], right: &[(&str, &str)]) -> bool {
242 left.len() == right.len()
243 && left.iter().all(|(unit, key)| {
244 right
245 .iter()
246 .any(|(other_unit, other_key)| unit == other_unit && key == other_key)
247 })
248 }
249 same(&self.initials, profile.initials)
250 && same(&self.finals, profile.finals)
251 && same(&self.zero_initials, profile.zero_initials)
252 }
253
254 fn initial_for_key(&self, key: u8) -> InitialText {
256 self.initials
257 .iter()
258 .find(|(_, mapped)| mapped.as_bytes() == [key])
259 .map_or(InitialText::Single(key), |(unit, _)| {
260 InitialText::Multi(unit)
261 })
262 }
263
264 fn decode_pair(
267 &self,
268 first: u8,
269 second: u8,
270 buffer: &mut String,
271 ) -> Result<Option<DecodedSyllable>, CustomProfileError> {
272 let accepted = accepted_syllables();
273 let mut found: Option<DecodedSyllable> = None;
274 let initial = self.initial_for_key(first);
275 for (unit, _) in self
276 .finals
277 .iter()
278 .filter(|(_, key)| key.as_bytes() == [second])
279 {
280 buffer.clear();
281 match initial {
282 InitialText::Single(key) => buffer.push(char::from(key)),
283 InitialText::Multi(unit) => buffer.push_str(unit),
284 }
285 let normalized = if *unit == "v"
286 && matches!(initial, InitialText::Single(b'j' | b'q' | b'x' | b'y'))
287 {
288 "u"
289 } else {
290 unit
291 };
292 buffer.push_str(normalized);
293 if !accepted.contains(buffer.as_str()) {
294 continue;
295 }
296 let decoded = DecodedSyllable {
297 initial,
298 final_unit: normalized,
299 };
300 match &found {
301 Some(previous) if *previous != decoded => {
302 let mut previous_text = String::with_capacity(8);
303 previous.write_to(&mut previous_text);
304 return Err(CustomProfileError::AmbiguousCode {
305 code: code_text(first, second),
306 first: previous_text,
307 second: buffer.clone(),
308 });
309 }
310 Some(_) => {}
311 None => found = Some(decoded),
312 }
313 }
314 Ok(found)
315 }
316}
317
318fn code_text(first: u8, second: u8) -> String {
319 [char::from(first), char::from(second)].iter().collect()
320}
321
322fn canonicalize(table: &ShuangpinCustomTable) -> Result<Canonical, CustomProfileError> {
323 Ok(Canonical {
324 initials: canonical_part(
325 CustomTablePart::Initials,
326 &table.initials,
327 &MULTI_LETTER_INITIALS,
328 )?,
329 finals: canonical_part(CustomTablePart::Finals, &table.finals, &FINALS)?,
330 zero_initials: canonical_part(
331 CustomTablePart::ZeroInitials,
332 &table.zero_initials,
333 &ZERO_INITIALS,
334 )?,
335 })
336}
337
338fn canonical_part(
339 part: CustomTablePart,
340 entries: &[(String, String)],
341 units: &[&'static str],
342) -> Result<Vec<(&'static str, String)>, CustomProfileError> {
343 let mut keys: Vec<Option<String>> = vec![None; units.len()];
344 for (unit, key) in entries {
345 let Some(index) = units.iter().position(|known| known == unit) else {
346 return Err(CustomProfileError::UnknownUnit {
347 part,
348 unit: unit.clone(),
349 });
350 };
351 if keys[index].is_some() {
352 return Err(CustomProfileError::DuplicateUnit {
353 part,
354 unit: unit.clone(),
355 });
356 }
357 let key = key.to_ascii_lowercase();
358 if !key_is_valid(part, key.as_bytes()) {
359 return Err(CustomProfileError::InvalidKey {
360 part,
361 unit: unit.clone(),
362 key,
363 });
364 }
365 keys[index] = Some(key);
366 }
367 units
368 .iter()
369 .zip(keys)
370 .map(|(unit, key)| {
371 key.map(|key| (*unit, key))
372 .ok_or(CustomProfileError::MissingUnit { part, unit })
373 })
374 .collect()
375}
376
377fn key_is_valid(part: CustomTablePart, key: &[u8]) -> bool {
379 match (part, key) {
380 (CustomTablePart::Initials, [key]) => key.is_ascii_lowercase(),
381 (CustomTablePart::Finals, [key]) => key.is_ascii_lowercase() || *key == b';',
382 (CustomTablePart::ZeroInitials, [first, second]) => {
383 first.is_ascii_lowercase() && (second.is_ascii_lowercase() || *second == b';')
384 }
385 _ => false,
386 }
387}
388
389fn check_codes(canonical: &Canonical) -> Result<(), CustomProfileError> {
390 let inherited_shoudao = canonical.matches(profile(ShuangpinProfileKind::Shoudao).unwrap());
392 let mut initial_keys: HashSet<u8> = HashSet::new();
393 for (unit, key) in &canonical.initials {
394 let byte = key.as_bytes()[0];
395 if SINGLE_LETTER_INITIALS.contains(&byte) || !initial_keys.insert(byte) {
396 return Err(CustomProfileError::InitialKeyTaken {
397 unit,
398 key: key.clone(),
399 });
400 }
401 }
402 let mut final_keys: Vec<u8> = canonical
403 .finals
404 .iter()
405 .map(|(_, key)| key.as_bytes()[0])
406 .collect();
407 final_keys.sort_unstable();
408 final_keys.dedup();
409 let mut buffer = String::with_capacity(8);
410 for first in b'a'..=b'z' {
411 for &second in &final_keys {
412 match canonical.decode_pair(first, second, &mut buffer) {
413 Err(CustomProfileError::AmbiguousCode {
414 code,
415 first,
416 second,
417 }) if inherited_shoudao && code == "dk" && first == "den" && second == "dia" => {}
418 result => {
419 result?;
420 }
421 }
422 }
423 }
424 let mut zero_codes: Vec<(&str, &str)> = Vec::with_capacity(canonical.zero_initials.len());
425 for (syllable, code) in &canonical.zero_initials {
426 if let Some((other, _)) = zero_codes.iter().find(|(_, used)| used == code) {
428 return Err(CustomProfileError::AmbiguousCode {
429 code: code.clone(),
430 first: (*other).to_owned(),
431 second: (*syllable).to_owned(),
432 });
433 }
434 let bytes = code.as_bytes();
435 if let Some(decoded) = canonical.decode_pair(bytes[0], bytes[1], &mut buffer)? {
436 if !decoded.matches(syllable) {
437 return Err(CustomProfileError::AmbiguousCode {
438 code: code.clone(),
439 first: buffer.clone(),
440 second: (*syllable).to_owned(),
441 });
442 }
443 }
444 zero_codes.push((syllable, code));
445 }
446 Ok(())
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452 use crate::shuangpin::profile::profile;
453 use crate::shuangpin::scheme::ShuangpinScheme;
454 use crate::shuangpin::utils::cvt_single_sp_to_pinyin;
455 use crate::types::SchemeKey;
456
457 const BUILTIN: [ShuangpinProfileKind; 5] = [
458 ShuangpinProfileKind::Xiaohe,
459 ShuangpinProfileKind::Ziranma,
460 ShuangpinProfileKind::Shoudao,
461 ShuangpinProfileKind::Microsoft,
462 ShuangpinProfileKind::SmartAbc,
463 ];
464
465 fn owned(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
466 pairs
467 .iter()
468 .map(|(unit, key)| ((*unit).to_owned(), (*key).to_owned()))
469 .collect()
470 }
471
472 fn table_of(kind: ShuangpinProfileKind) -> ShuangpinCustomTable {
473 builtin_table(kind).unwrap()
474 }
475
476 fn set(units: impl IntoIterator<Item = &'static str>) -> Vec<&'static str> {
477 let mut units: Vec<&'static str> = units.into_iter().collect();
478 units.sort_unstable();
479 units
480 }
481
482 fn replace(entries: &mut [(String, String)], unit: &str, key: &str) {
483 entries
484 .iter_mut()
485 .find(|(known, _)| known == unit)
486 .unwrap()
487 .1 = key.to_owned();
488 }
489
490 #[test]
491 fn builtin_profiles_use_the_same_units() {
492 for kind in BUILTIN {
493 let source = profile(kind).unwrap();
494 assert_eq!(
495 set(source.initials.iter().map(|(unit, _)| *unit)),
496 set(MULTI_LETTER_INITIALS),
497 "{kind:?}"
498 );
499 assert_eq!(
500 set(source.finals.iter().map(|(unit, _)| *unit)),
501 set(FINALS),
502 "{kind:?}"
503 );
504 assert_eq!(
505 set(source.zero_initials.iter().map(|(unit, _)| *unit)),
506 set(ZERO_INITIALS),
507 "{kind:?}"
508 );
509 }
510 }
511
512 #[test]
514 fn builtin_tables_pass_and_decode_as_the_builtin_profile() {
515 for kind in BUILTIN {
516 let builtin = profile(kind).unwrap();
517 let custom = custom_profile(&table_of(kind)).unwrap();
518 assert_eq!(custom.kind, ShuangpinProfileKind::Custom);
519 assert_eq!(custom.uses_semicolon_key(), builtin.uses_semicolon_key());
520 for first in b'a'..=b'z' {
521 for second in (b'a'..=b'z').chain(*b";") {
522 let code = code_text(first, second);
523 assert_eq!(
524 cvt_single_sp_to_pinyin(&code, custom),
525 cvt_single_sp_to_pinyin(&code, builtin),
526 "{kind:?} {code}"
527 );
528 }
529 }
530 }
531 }
532
533 #[test]
534 fn shoudao_copy_only_inherits_its_known_collision_while_unchanged() {
535 let mut changed = table_of(ShuangpinProfileKind::Shoudao);
536 replace(&mut changed.zero_initials, "a", "oa");
537 assert!(matches!(
538 check_custom_profile(&changed),
539 Err(CustomProfileError::AmbiguousCode { code, .. }) if code == "dk"
540 ));
541 }
542
543 #[test]
544 fn the_same_table_is_interned_once() {
545 let first = custom_profile(&table_of(ShuangpinProfileKind::Ziranma)).unwrap();
546 let mut shuffled = table_of(ShuangpinProfileKind::Ziranma);
547 shuffled.finals.reverse();
548 for (_, key) in &mut shuffled.zero_initials {
549 *key = key.to_ascii_uppercase();
550 }
551 let second = custom_profile(&shuffled).unwrap();
552 assert!(std::ptr::eq(first, second));
553 let other = custom_profile(&table_of(ShuangpinProfileKind::Xiaohe)).unwrap();
554 assert!(!std::ptr::eq(first, other));
555 }
556
557 #[test]
558 fn checking_a_custom_profile_reuses_the_initial_text_buffer() {
559 let table = table_of(ShuangpinProfileKind::Xiaohe);
560 let canonical = canonicalize(&table).unwrap();
561 let (_, allocations) = crate::ime::personal_rerank::allocations::count(|| {
562 check_codes(&canonical).unwrap();
563 });
564 assert!(
565 allocations <= 10,
566 "custom profile code validation allocated {allocations} buffers"
567 );
568 }
569
570 #[test]
571 fn a_custom_profile_moves_finals_and_initials() {
572 let mut table = table_of(ShuangpinProfileKind::Xiaohe);
574 replace(&mut table.initials, "zh", "a");
575 table.zero_initials = owned(&[
576 ("a", "oa"),
577 ("ai", "od"),
578 ("an", "oj"),
579 ("ang", "oh"),
580 ("ao", "oc"),
581 ("e", "oe"),
582 ("ei", "ow"),
583 ("en", "of"),
584 ("eng", "og"),
585 ("er", "or"),
586 ("o", "oo"),
587 ("ou", "oz"),
588 ]);
589 replace(&mut table.finals, "ing", ";");
590 let custom = custom_profile(&table).unwrap();
591 assert!(custom.uses_semicolon_key());
592 assert_eq!(cvt_single_sp_to_pinyin("ah", custom), "zhang");
593 assert_eq!(cvt_single_sp_to_pinyin("oa", custom), "a");
594 assert_eq!(cvt_single_sp_to_pinyin("x;", custom), "xing");
595 assert_eq!(cvt_single_sp_to_pinyin("xk", custom), "");
596 assert_eq!(cvt_single_sp_to_pinyin("vh", custom), "");
597 }
598
599 #[test]
600 fn a_zero_initial_can_use_semicolon_without_a_final_on_semicolon() {
601 let mut table = table_of(ShuangpinProfileKind::Xiaohe);
602 replace(&mut table.zero_initials, "o", "o;");
603 let custom = custom_profile(&table).unwrap();
604 assert_eq!(cvt_single_sp_to_pinyin("o;", custom), "o");
605 assert!(custom.uses_semicolon_key());
606
607 let mut scheme = ShuangpinScheme::new(custom);
608 assert!(scheme.handle_key(SchemeKey::Letter(b'o')));
609 assert!(scheme.handle_key(SchemeKey::Semicolon));
610 assert_eq!(scheme.preedit(), "o;");
611 }
612
613 #[test]
614 fn incomplete_or_unknown_units_are_refused() {
615 let mut table = table_of(ShuangpinProfileKind::Xiaohe);
616 table.finals.retain(|(unit, _)| unit != "iong");
617 assert_eq!(
618 validate_custom_profile(&table),
619 Err(CustomProfileError::MissingUnit {
620 part: CustomTablePart::Finals,
621 unit: "iong",
622 })
623 );
624 let mut table = table_of(ShuangpinProfileKind::Xiaohe);
625 table.initials.push(("b".to_owned(), "b".to_owned()));
626 assert_eq!(
627 validate_custom_profile(&table),
628 Err(CustomProfileError::UnknownUnit {
629 part: CustomTablePart::Initials,
630 unit: "b".to_owned(),
631 })
632 );
633 let mut table = table_of(ShuangpinProfileKind::Xiaohe);
634 table.finals.push(("iu".to_owned(), "q".to_owned()));
635 assert_eq!(
636 validate_custom_profile(&table),
637 Err(CustomProfileError::DuplicateUnit {
638 part: CustomTablePart::Finals,
639 unit: "iu".to_owned(),
640 })
641 );
642 assert!(matches!(
643 validate_custom_profile(&ShuangpinCustomTable::default()),
644 Err(CustomProfileError::MissingUnit { .. })
645 ));
646 }
647
648 #[test]
649 fn keys_must_fit_their_place() {
650 for (part, unit, key) in [
651 (CustomTablePart::Finals, "iu", "qq"),
652 (CustomTablePart::Finals, "iu", "1"),
653 (CustomTablePart::Finals, "iu", ""),
654 (CustomTablePart::Initials, "zh", ";"),
655 (CustomTablePart::ZeroInitials, "a", "a"),
656 (CustomTablePart::ZeroInitials, "a", ";a"),
657 (CustomTablePart::ZeroInitials, "a", "aé"),
658 ] {
659 let mut table = table_of(ShuangpinProfileKind::Xiaohe);
660 let entries = match part {
661 CustomTablePart::Initials => &mut table.initials,
662 CustomTablePart::Finals => &mut table.finals,
663 CustomTablePart::ZeroInitials => &mut table.zero_initials,
664 };
665 replace(entries, unit, key);
666 assert_eq!(
667 validate_custom_profile(&table),
668 Err(CustomProfileError::InvalidKey {
669 part,
670 unit: unit.to_owned(),
671 key: key.to_ascii_lowercase(),
672 }),
673 "{unit} {key:?}"
674 );
675 }
676 }
677
678 #[test]
679 fn initials_cannot_take_a_single_letter_initial_or_share_a_key() {
680 let mut table = table_of(ShuangpinProfileKind::Xiaohe);
681 replace(&mut table.initials, "sh", "s");
682 assert_eq!(
683 validate_custom_profile(&table),
684 Err(CustomProfileError::InitialKeyTaken {
685 unit: "sh",
686 key: "s".to_owned(),
687 })
688 );
689 let mut table = table_of(ShuangpinProfileKind::Xiaohe);
690 replace(&mut table.initials, "ch", "u");
691 assert!(matches!(
692 validate_custom_profile(&table),
693 Err(CustomProfileError::InitialKeyTaken { key, .. }) if key == "u"
694 ));
695 }
696
697 #[test]
698 fn colliding_codes_are_refused() {
699 let mut table = table_of(ShuangpinProfileKind::Xiaohe);
701 replace(&mut table.finals, "ang", "k");
702 assert!(matches!(
703 validate_custom_profile(&table),
704 Err(CustomProfileError::AmbiguousCode { .. })
705 ));
706 let mut table = table_of(ShuangpinProfileKind::Xiaohe);
708 replace(&mut table.zero_initials, "a", "ua");
709 assert_eq!(
710 validate_custom_profile(&table),
711 Err(CustomProfileError::AmbiguousCode {
712 code: "ua".to_owned(),
713 first: "sha".to_owned(),
714 second: "a".to_owned(),
715 })
716 );
717 let mut table = table_of(ShuangpinProfileKind::Xiaohe);
719 replace(&mut table.zero_initials, "o", "aa");
720 assert_eq!(
721 validate_custom_profile(&table),
722 Err(CustomProfileError::AmbiguousCode {
723 code: "aa".to_owned(),
724 first: "a".to_owned(),
725 second: "o".to_owned(),
726 })
727 );
728 }
729
730 #[test]
731 fn custom_has_no_builtin_table() {
732 assert_eq!(builtin_table(ShuangpinProfileKind::Custom), None);
733 }
734
735 #[test]
737 fn check_agrees_with_validate() {
738 let mut tables: Vec<ShuangpinCustomTable> = BUILTIN.into_iter().map(table_of).collect();
739 tables.push(ShuangpinCustomTable::default());
740 for (part, unit, key) in [
741 (CustomTablePart::Finals, "ang", "k"),
742 (CustomTablePart::Finals, "ing", ";"),
743 (CustomTablePart::Finals, "iu", "1"),
744 (CustomTablePart::Initials, "sh", "s"),
745 (CustomTablePart::Initials, "zh", "a"),
746 (CustomTablePart::ZeroInitials, "a", "ua"),
747 (CustomTablePart::ZeroInitials, "o", "aa"),
748 (CustomTablePart::ZeroInitials, "o", "o;"),
749 ] {
750 let mut table = table_of(ShuangpinProfileKind::Xiaohe);
751 let entries = match part {
752 CustomTablePart::Initials => &mut table.initials,
753 CustomTablePart::Finals => &mut table.finals,
754 CustomTablePart::ZeroInitials => &mut table.zero_initials,
755 };
756 replace(entries, unit, key);
757 tables.push(table);
758 }
759 let mut missing = table_of(ShuangpinProfileKind::Microsoft);
760 missing.finals.retain(|(unit, _)| unit != "iong");
761 tables.push(missing);
762 for table in &tables {
763 assert_eq!(check_custom_profile(table), validate_custom_profile(table));
764 }
765 assert!(tables
766 .iter()
767 .any(|table| check_custom_profile(table).is_ok()));
768 assert!(tables
769 .iter()
770 .any(|table| check_custom_profile(table).is_err()));
771 }
772
773 #[test]
774 fn check_leaves_nothing_behind() {
775 let mut table = table_of(ShuangpinProfileKind::Ziranma);
777 replace(&mut table.zero_initials, "o", "o;");
778 let canonical = canonicalize(&table).unwrap();
779 let interned_now = || interned().iter().any(|profile| canonical.matches(profile));
780 assert_eq!(check_custom_profile(&table), Ok(()));
781 assert_eq!(check_custom_profile(&table), Ok(()));
782 assert!(!interned_now());
783 validate_custom_profile(&table).unwrap();
784 assert!(interned_now());
785 }
786
787 #[test]
788 fn an_invalid_table_is_not_interned() {
789 let mut table = table_of(ShuangpinProfileKind::Shoudao);
790 replace(&mut table.finals, "ang", "k");
791 assert!(custom_profile(&table).is_err());
792 assert!(custom_profile(&table).is_err());
793 }
794}