use crate::char_struct::CharType;
use crate::rules::RuleMeta;
use crate::rules::context::RuleContext;
use crate::rules::korean::rule_69::encode_ascii_unit;
use crate::rules::traits::{BrailleRule, Phase, RuleResult};
pub static META_29: RuleMeta = RuleMeta {
section: "29",
subsection: None,
name: "roman_indicator",
standard_ref: "2024 Korean Braille Standard, Ch.4 Sec.10 Art.29",
description: "Roman letter indicator ⠴ (enter) and terminator ⠲ (exit)",
};
pub const ROMAN_INDICATOR: u8 = 52;
#[cfg(test)]
pub const ROMAN_TERMINATOR: u8 = 50;
pub const ENGLISH_CONTINUATION: u8 = 48;
pub struct Rule29;
fn prev_word_is_numeric(prev_word: &str) -> bool {
!prev_word.is_empty()
&& prev_word
.chars()
.all(|ch| ch.is_ascii_digit() || matches!(ch, ',' | '.'))
}
fn should_enter_as_roman_indicator(ctx: &RuleContext) -> bool {
let prev_is_numeric_or_digit = ctx.prev_char().is_some_and(|ch| ch.is_ascii_digit())
|| prev_word_is_numeric(ctx.prev_word);
encode_ascii_unit(ctx.word_chars, ctx.index).is_some() && prev_is_numeric_or_digit
}
impl BrailleRule for Rule29 {
fn meta(&self) -> &'static RuleMeta {
&META_29
}
fn phase(&self) -> Phase {
Phase::ModeManagement
}
fn matches(&self, ctx: &RuleContext) -> bool {
if !ctx.state.english_indicator {
return false;
}
if !ctx.state.is_english && matches!(ctx.char_type, CharType::English(_)) {
return true;
}
if ctx.state.is_english && !matches!(ctx.char_type, CharType::English(_)) {
return true;
}
false
}
fn apply(&self, ctx: &mut RuleContext) -> Result<RuleResult, String> {
if !ctx.state.is_english && matches!(ctx.char_type, CharType::English(_)) {
if ctx.state.needs_english_continuation && !should_enter_as_roman_indicator(ctx) {
ctx.emit(ENGLISH_CONTINUATION); } else {
ctx.emit(ROMAN_INDICATOR); }
ctx.state.is_english = true;
ctx.state.needs_english_continuation = false;
}
Ok(RuleResult::Continue) }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn indicator_values() {
assert_eq!(ROMAN_INDICATOR, 52);
assert_eq!(ROMAN_TERMINATOR, 50);
assert_eq!(ENGLISH_CONTINUATION, 48);
}
#[test]
fn golden_test_roman_in_korean() {
let result = crate::encode_to_unicode("그는 Canada로").unwrap();
assert!(result.contains('⠴'), "Should contain roman indicator ⠴");
}
#[rstest::rstest]
#[case::pure_digits("123", true)]
#[case::digits_with_comma("1,234", true)]
#[case::decimal("3.14", true)]
#[case::compound_punctuation("1.234,567", true)]
#[case::empty_string("", false)]
#[case::digits_with_letter("12a", false)]
#[case::letters_only("hello", false)]
fn prev_word_is_numeric_paths(#[case] input: &str, #[case] expected: bool) {
assert_eq!(prev_word_is_numeric(input), expected);
}
fn make_ctx<'a>(
word_chars: &'a [char],
index: usize,
char_type: &'a CharType,
skip_count: &'a mut usize,
state: &'a mut crate::rules::context::EncoderState,
result: &'a mut Vec<u8>,
prev_word: &'a str,
) -> RuleContext<'a> {
RuleContext {
word_chars,
index,
char_type,
prev_word,
remaining_words: &[],
has_korean_char: false,
is_all_uppercase: false,
ascii_starts_at_beginning: true,
skip_count,
state,
result,
}
}
#[test]
fn rule29_meta_and_phase() {
let r = Rule29;
assert_eq!(r.meta().section, "29");
assert!(matches!(r.phase(), Phase::ModeManagement));
}
#[test]
fn rule29_matches_false_when_indicator_off() {
let chars: Vec<char> = "A".chars().collect();
let ct = CharType::new(chars[0]).unwrap();
let mut skip = 0usize;
let mut state = crate::rules::context::EncoderState::new(false); let mut out = Vec::new();
let ctx = make_ctx(&chars, 0, &ct, &mut skip, &mut state, &mut out, "");
assert!(!Rule29.matches(&ctx));
}
#[test]
fn rule29_matches_when_entering_english() {
let chars: Vec<char> = "A".chars().collect();
let ct = CharType::new(chars[0]).unwrap();
let mut skip = 0usize;
let mut state = crate::rules::context::EncoderState::new(true);
state.is_english = false;
let mut out = Vec::new();
let ctx = make_ctx(&chars, 0, &ct, &mut skip, &mut state, &mut out, "");
assert!(Rule29.matches(&ctx));
}
#[test]
fn rule29_matches_when_exiting_english() {
let chars: Vec<char> = "ㄱ".chars().collect();
let ct = CharType::new(chars[0]).unwrap();
let mut skip = 0usize;
let mut state = crate::rules::context::EncoderState::new(true);
state.is_english = true; let mut out = Vec::new();
let ctx = make_ctx(&chars, 0, &ct, &mut skip, &mut state, &mut out, "");
assert!(Rule29.matches(&ctx));
}
#[test]
fn rule29_apply_enters_english_with_indicator() {
let chars: Vec<char> = "A".chars().collect();
let ct = CharType::new(chars[0]).unwrap();
let mut skip = 0usize;
let mut state = crate::rules::context::EncoderState::new(true);
let mut out = Vec::new();
let mut ctx = make_ctx(&chars, 0, &ct, &mut skip, &mut state, &mut out, "");
let res = Rule29.apply(&mut ctx).unwrap();
assert!(matches!(res, RuleResult::Continue));
assert_eq!(out, vec![ROMAN_INDICATOR]);
assert!(state.is_english);
}
#[test]
fn rule29_apply_continuation_after_numeric_prev_word() {
let chars: Vec<char> = "A".chars().collect();
let ct = CharType::new(chars[0]).unwrap();
let mut skip = 0usize;
let mut state = crate::rules::context::EncoderState::new(true);
state.needs_english_continuation = true;
let mut out = Vec::new();
let mut ctx = make_ctx(&chars, 0, &ct, &mut skip, &mut state, &mut out, "123");
Rule29.apply(&mut ctx).unwrap();
assert_eq!(out.len(), 1);
assert!(matches!(out[0], ROMAN_INDICATOR | ENGLISH_CONTINUATION));
}
#[test]
fn rule29_apply_continuation_marker_path() {
let chars: Vec<char> = "A".chars().collect();
let ct = CharType::new(chars[0]).unwrap();
let mut skip = 0usize;
let mut state = crate::rules::context::EncoderState::new(true);
state.needs_english_continuation = true;
let mut out = Vec::new();
let mut ctx = make_ctx(&chars, 0, &ct, &mut skip, &mut state, &mut out, "");
Rule29.apply(&mut ctx).unwrap();
assert_eq!(out, vec![ENGLISH_CONTINUATION]);
}
#[test]
fn rule29_apply_no_change_when_exiting() {
let chars: Vec<char> = "가".chars().collect();
let ct = CharType::new(chars[0]).unwrap();
let mut skip = 0usize;
let mut state = crate::rules::context::EncoderState::new(true);
state.is_english = true;
let mut out = Vec::new();
let mut ctx = make_ctx(&chars, 0, &ct, &mut skip, &mut state, &mut out, "");
let res = Rule29.apply(&mut ctx).unwrap();
assert!(matches!(res, RuleResult::Continue));
assert!(out.is_empty());
}
#[test]
fn rule29_prev_word_numeric_drives_roman_indicator() {
let out = crate::encode("1,234 km").expect("must encode");
assert!(!out.is_empty());
assert!(out.contains(&ROMAN_INDICATOR));
}
#[test]
fn rule29_matches_false_when_already_in_english_with_english_char() {
let chars: Vec<char> = "A".chars().collect();
let ct = CharType::new(chars[0]).unwrap();
let mut skip = 0usize;
let mut state = crate::rules::context::EncoderState::new(true);
state.is_english = true; let mut out = Vec::new();
let ctx = make_ctx(&chars, 0, &ct, &mut skip, &mut state, &mut out, "");
assert!(!Rule29.matches(&ctx));
}
}