use crate::char_struct::CharType;
use crate::rules::RuleMeta;
use crate::rules::context::RuleContext;
use crate::rules::traits::{BrailleRule, Phase, RuleResult};
pub static META: RuleMeta = RuleMeta {
section: "41",
subsection: None,
name: "numeric_comma",
standard_ref: "2024 Korean Braille Standard, Ch.5 Sec.11 Art.41",
description: "Attached comma within a numeric/ASCII sequence uses ⠂ (2)",
};
const NUMERIC_COMMA: u8 = 2;
pub struct Rule41;
impl BrailleRule for Rule41 {
fn meta(&self) -> &'static RuleMeta {
&META
}
fn phase(&self) -> Phase {
Phase::CoreEncoding
}
fn priority(&self) -> u16 {
400 }
fn matches(&self, ctx: &RuleContext) -> bool {
let CharType::Symbol(c) = ctx.char_type else {
return false;
};
if *c != ',' {
return false;
}
let (has_numeric_prefix, has_ascii_prefix) = scan_prefix(ctx.word_chars, ctx.index);
let next_char = ctx.word_chars.get(ctx.index + 1).copied();
let next_is_digit = next_char.is_some_and(|ch| ch.is_ascii_digit());
let next_is_ascii = next_char.is_some_and(|ch| ch.is_ascii_alphabetic());
let next_is_alphanumeric = next_is_digit || next_is_ascii;
((ctx.state.is_number || has_numeric_prefix) && next_is_digit)
|| (has_ascii_prefix && next_is_alphanumeric)
}
fn apply(&self, ctx: &mut RuleContext) -> Result<RuleResult, String> {
ctx.emit(NUMERIC_COMMA);
Ok(RuleResult::Consumed)
}
}
fn scan_prefix(word_chars: &[char], index: usize) -> (bool, bool) {
match word_chars[..index]
.iter()
.rev()
.copied()
.find(|prev| *prev != ' ')
{
Some(prev) => (prev.is_ascii_digit(), prev.is_ascii_alphabetic()),
None => (false, false),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[rstest::rstest]
#[case::digit_prefix("1,000", 1, true, false)]
#[case::ascii_prefix("A,B", 1, false, true)]
fn scan_prefix_paths(
#[case] input: &str,
#[case] idx: usize,
#[case] expect_num: bool,
#[case] expect_ascii: bool,
) {
let chars: Vec<char> = input.chars().collect();
let (num, ascii) = scan_prefix(&chars, idx);
assert_eq!(num, expect_num);
assert_eq!(ascii, expect_ascii);
}
#[test]
fn meta_is_correct() {
assert_eq!(META.section, "41");
assert_eq!(META.name, "numeric_comma");
}
#[test]
fn rule41_matches_false_for_non_symbol_ctx() {
let mut owned = crate::test_helpers::CtxOwned::for_text("ab", false);
let ctx = owned.ctx_at(0);
assert!(!Rule41.matches(&ctx));
}
#[rstest::rstest]
#[case::between_digits("1,000", 1)]
#[case::between_ascii_letters("A,B", 1)]
#[case::ascii_before_digit("A,1", 1)]
fn rule41_matches_numeric_or_ascii_comma_context(#[case] input: &str, #[case] index: usize) {
let mut owned = crate::test_helpers::CtxOwned::for_text(input, false);
let ctx = owned.ctx_at(index);
assert!(Rule41.matches(&ctx));
}
#[rstest::rstest]
#[case::music_button_list("3,", "7")]
#[case::music_fraction_list("1/3,", "2/3의")]
fn rule41_does_not_cross_whitespace_token_boundaries(
#[case] current_word: &str,
#[case] next_word: &str,
) {
let mut owned = crate::test_helpers::CtxOwned::for_text(current_word, false)
.with_remaining_words([next_word]);
let comma_index = current_word
.chars()
.position(|ch| ch == ',')
.expect("test word must contain a comma");
let ctx = owned.ctx_at(comma_index);
assert!(!Rule41.matches(&ctx));
}
#[rstest::rstest]
#[case::rule41_grouped_number("9,375명", '⠂')]
#[case::rule41_verse_reference("창세기 12,1-9", '⠂')]
#[case::rule49_korean_list("근면, 검소, 협동은 우리 겨레의 미덕이다.", '⠐')]
fn full_encoder_preserves_pdf_comma_boundaries(
#[case] input: &str,
#[case] expected_comma: char,
) {
let comma_byte = input.find(',').expect("PDF example must contain comma");
let prefix = crate::encode_to_unicode(&input[..comma_byte]).expect("prefix must encode");
let actual = crate::encode_to_unicode(input).expect("PDF example must encode");
let comma_cell = actual.chars().nth(prefix.chars().count());
assert!(actual.starts_with(&prefix));
assert_eq!(comma_cell, Some(expected_comma));
}
#[test]
fn scan_prefix_skips_space_then_finds_digit() {
let chars: Vec<char> = "1 ,".chars().collect();
let (num, _) = scan_prefix(&chars, 2);
assert!(num);
}
}