diff --git a/Rules/Languages/en/SharedRules/general.yaml b/Rules/Languages/en/SharedRules/general.yaml index 832384da4..d256e8846 100644 --- a/Rules/Languages/en/SharedRules/general.yaml +++ b/Rules/Languages/en/SharedRules/general.yaml @@ -1,5 +1,49 @@ --- +# W3C definite-integral arguments are the integration domain or the two bounds, not the sign. +- name: definite-integral-intent + tag: definite-integral + match: "(count(*)=1 or count(*)=2) and not(contains(@data-intent-property, ':silent:'))" + replace: + - bookmark: "@id" + - t: "integral" + - test: + if: "count(*)=1" + then: [t: "over", x: "*[1]"] + else: [t: "from", x: "*[1]", t: "to", x: "*[2]"] + +# A semantic reading also works for prime notation; do not assume a Leibniz fraction. +- name: derivative-intent + tag: derivative + match: "count(*)=3 and not(contains(@data-intent-property, ':silent:'))" + replace: + - bookmark: "@id" + - t: "the" + - test: + - if: "*[3][self::m:mn and .='1']" + then: [t: "derivative of"] + - else_if: "*[3][self::m:mn and string-length(.)>0 and translate(., '0123456789', '')='']" + then: [x: "ToOrdinal(*[3])", t: "derivative of"] + else: [t: "derivative of order", x: "*[3]", t: "of"] + - x: "*[1]" + - t: "with respect to" + - x: "*[2]" + +- name: blank-intent + tag: blank + match: "count(*)=0" + replace: [bookmark: "@id", t: "blank"] + +# Preserve operands while suppressing the default-silent operator, including a bare operator leaf. +- name: invisible-operator-intent + tag: [invisible-times, invisible-separator] + match: "not(@data-intent-property) or contains(@data-intent-property, ':silent:')" + replace: + - test: + if: "*" + then: [x: "*"] + else: [t: ""] + # number-sets are a little messy in that the base was converted to a number-set, so we have to match that (simple) case last - name: pos-neg-number-sets tag: number-sets @@ -190,13 +234,16 @@ - t: "of" # phrase(the square root 'of' 25 equals 5) - x: "*[2]" +# W3C supplies the period alone; inferred MathCAT intents also carry a non-repeating prefix. - name: repeating-decimal tag: repeating-decimal - match: "." + match: "(count(*)=1 or count(*)=2) and not(contains(@data-intent-property, ':silent:'))" replace: - - x: "*[1]" - - t: "with repeating digits" # phrase('with repeating digits') - - spell: "*[2]" + - test: + if: "count(*)=2" + then: [x: "*[1]", t: "with repeating digits"] + else: [t: "repeating decimal"] + - spell: "*[last()]" - name: msubsup-skip-super # handles single, double, etc., prime diff --git a/Rules/Languages/en/definitions.yaml b/Rules/Languages/en/definitions.yaml index fc18be07f..9bc650629 100644 --- a/Rules/Languages/en/definitions.yaml +++ b/Rules/Languages/en/definitions.yaml @@ -82,14 +82,16 @@ "binomial": "infix=binomial; choose; end binomial", ### Functions and Inverses - # Interval intents (open-/closed-interval, etc.) are handled by rules due to special cases involving ∞. + # Speech rules for open-interval, open-closed-interval, closed-interval, and closed-open-interval + # are defined elsewhere: SharedRules/general.yaml (intervals). "inverse":"function=inverse || postfix=inverse", "domain": "function= ; domain", "codomain": "function= ; codomain", "image":"function=image", - #"fraction":"function=fraction; over; end fraction", # NOTE: Fails + # Speech rules defined elsewhere: ClearSpeak_Rules.yaml and SimpleSpeak_Rules.yaml. + # "fraction":"function=fraction; over; end fraction", "mixed-fraction":"infix=and", # NOTE: in website says function, but follow infix speech pattern. "quotient":"function=integer part: divided by", "evaluated-at":"infix=evaluated at", @@ -121,9 +123,11 @@ ### Calculus - # "definite-integral":"function=integral over || function=integral from; to", # Property ??? - # "derivative":"function=the derivative; with respect to", # Property ??? - # "partial-derivative":"function=partial", ## Note, included with infix, but separately under calculus tab has ??? for functionality + # Speech rules defined elsewhere: SharedRules/general.yaml (definite-integral-intent, derivative-intent). + # "definite-integral":"function=integral over || function=integral from; to", + # "derivative":"function=the derivative; with respect to", + # The active prefix mapping for partial-derivative is in Other below. + # "partial-derivative":"function=partial", "limit": "prefix=limit as|the limit as", "tends-to":"infix=tends to", @@ -228,9 +232,14 @@ "point":"prefix=point", ## NOTE: Has ??? for property in site. Should it be prefix? Or something else. ### Separators + # Speech rules defined elsewhere: SharedRules/general.yaml (invisible-operator-intent). + # "invisible-separator": "silent=", + # "invisible-times": "silent=", "time-separator":"infix=", ### General Concepts + # Speech rules defined elsewhere: SharedRules/general.yaml (blank-intent). + # "blank": "nofix=blank", "fenced-group":"function=fenced-group", # appears both under general concepts and grouping # NOTE: in site mentions "the pair x and y", due to being defined as function, needs the "of" keyword "ordered-pair": "function=the pair: and", # Needs to be tested @@ -251,7 +260,8 @@ ### Grouping "annotation":"infix= ; which is ;", # NOTE: Follows the same order as indexed-by, in site listed as function. Should it be infix? "braced-group":"function=grouped; end grouped", # NOTE: not tested, site is missing "of" keyword function introduces - #"repeating-decimal":"function=repeating decimal", # NOTE: BREAKS TEST. Check again. Site is missing "of" keyword function introduces. + # Speech rules defined elsewhere: SharedRules/general.yaml (repeating-decimal). + # "repeating-decimal":"function=repeating decimal", ### Other @@ -362,11 +372,14 @@ "modified-variable": "silent= ", "say-super": "infix=super| superscript| superscript", # used with 'mo' for superscripts (e.g, "<") "skip-super": "silent=", # used with 'mo' for superscripts (e.g, "*") + # Speech rules defined elsewhere: SharedRules/general.yaml (bigop-both, bigop-under). # "large-op": "infix=over || other=from,to", "lim-sup": "prefix=lim sup as| the limit superior as| the limit superior as", "lim-inf": "prefix=lim inf as| the limit inferior as| the limit inferior as", "logarithm-with-base": "prefix=log base| the log base| the log base", - # "pochhammer": "infix=permutations of", # arguments are in reverse order, so can't work here + # Infix speech rules defined elsewhere: SharedRules/general.yaml (permutation) reverses the arguments. + # The active function mapping above handles the other fixity. + # "pochhammer": "infix=permutations of", "trace": "function= ; trace| trace| the trace; end trace", "dimension": "function=; dimension| dimension| the dimension; end dimension", "homomorphism": "function= ; homomorphism| homomorphism| the homomorphism; end homomorphism", diff --git a/Rules/definitions.yaml b/Rules/definitions.yaml index 07681390c..307395601 100644 --- a/Rules/definitions.yaml +++ b/Rules/definitions.yaml @@ -36,7 +36,7 @@ "arccot", "arccotan", "arccotanh", "arccoth", "arccsc", "arccsch", "arcsec", "arcsech", "arcsin", "arcsinh", "arctan", "arctang", "arctanh", - "exp", "Exp", "expt", "arg", "im", "Im", "ℑ", re", "Re", "ℜ", "Pr", "cis", + "exp", "Exp", "expt", "arg", "im", "Im", "ℑ", "re", "Re", "ℜ", "Pr", "cis", "gcd", "lcm", "min", "max", "deg", "det", "rank", "rg", "sgn", "dim", "hom", "ker", "tr", "rot", "div", diff --git a/src/canonicalize.rs b/src/canonicalize.rs index 1e3936f8d..560a7067a 100644 --- a/src/canonicalize.rs +++ b/src/canonicalize.rs @@ -1254,6 +1254,11 @@ impl CanonicalizeContext { if element_name == "mrow" && children.len() == 1 && CanonicalizeContext::is_ok_to_merge_mrow_child(mathml) { // "lift" the child up so all the links (e.g., siblings) are correct let child = as_element(children[0]); + // The synthesized wrapper can disappear, but its original token must + // not inherit a marker that chemistry cleanup uses to delete added leaves. + if is_leaf(child) && mathml.attribute_value(CHANGED_ATTR).as_deref() == Some(ADDED_ATTR_VALUE) { + mathml.remove_attribute(CHANGED_ATTR); + } mathml.replace_children(child.children()); set_mathml_name(mathml, as_str!(name(child))); add_attrs(mathml, &child.attributes()); @@ -4719,6 +4724,28 @@ mod canonicalize_tests { are_strs_canonically_equal_result(target_str, target_str, &[]) } + #[test] + fn mstyle_split_function_preserves_content() -> Result<()> { + // A style wrapper must preserve a split function name when its mi children merge. + crate::interface::set_rules_dir(abs_rules_dir_path())?; + crate::interface::set_preference("Language", "ru")?; + let canonical = crate::interface::set_mathml("a=Rez,b=Imz")?; + assert!(canonical.contains(">Re"), "{canonical}"); + assert!(canonical.contains(">Im"), "{canonical}"); + Ok(()) + } + + #[test] + fn mpadded_split_function_preserves_content() -> Result<()> { + // mpadded follows the same wrapper cleanup path; chemistry rollback must keep both names. + crate::interface::set_rules_dir(abs_rules_dir_path())?; + crate::interface::set_preference("Language", "ru")?; + let canonical = crate::interface::set_mathml("a=Rez,b=Imz")?; + assert!(canonical.contains(">Re"), "{canonical}"); + assert!(canonical.contains(">Im"), "{canonical}"); + Ok(()) + } + #[test] fn plane1_common() -> Result<()> { let test_str = " diff --git a/tests/Languages/en/intent.rs b/tests/Languages/en/intent.rs index 795854781..10f93494a 100644 --- a/tests/Languages/en/intent.rs +++ b/tests/Languages/en/intent.rs @@ -142,3 +142,5 @@ fn intent_prob_x() -> Result<()> { return Ok(()); } + +mod w3c; diff --git a/tests/Languages/en/intent/w3c.rs b/tests/Languages/en/intent/w3c.rs new file mode 100644 index 000000000..0f4d8190f --- /dev/null +++ b/tests/Languages/en/intent/w3c.rs @@ -0,0 +1,148 @@ +use crate::common::*; +use anyhow::Result; + +// Check this language's W3C speech in both styles and at every verbosity. +fn test_w3c_speech(mathml: &str, expected: &str) -> Result<()> { + for style in ["ClearSpeak", "SimpleSpeak"] { + for verbosity in ["Terse", "Medium", "Verbose"] { + test_prefs("en", style, vec![("Verbosity", verbosity), ("IntentErrorRecovery", "Error")], mathml, expected)?; + } + } + Ok(()) +} + +#[test] +fn w3c_repeating_decimal_one_argument() -> Result<()> { + // W3C supplies the period alone; a missing second child must never cause an XPath error. + test_w3c_speech("45_", "repeating decimal 4 5")?; + test_w3c_speech("", "repeating decimal 4 5") +} + +#[test] +fn w3c_repeating_decimal_leading_zeroes() -> Result<()> { + // A period is a digit sequence: 003 must retain both leading zeroes rather than become 3. + test_w3c_speech("003¯", "repeating decimal 0 0 3") +} + +#[test] +fn w3c_repeating_decimal_legacy_arguments() -> Result<()> { + // Keep the existing prefix/period extension used by automatic inference. + test_w3c_speech("12003", "12 with repeating digits 0 0 3")?; + test_w3c_speech("1245¯", "12 with repeating digits 4 5") +} + +#[test] +fn w3c_repeating_decimal_complete_w3c_example() -> Result<()> { + // The W3C surrounding decimal must retain its prefix as well as both period digits. + let mathml="0.045_"; + let (language,expected) = ("en","0.0 with repeating digits 4 5"); + for style in ["ClearSpeak","SimpleSpeak"] { + for verbosity in ["Terse","Medium","Verbose"] { + test_prefs(language,style,vec![("Verbosity",verbosity),("IntentErrorRecovery","Error"),("DecimalSeparators","."),("BlockSeparators",", ")],mathml,expected)?; + } + } + Ok(()) +} + +#[test] +fn w3c_repeating_decimal_semantic_navigation() -> Result<()> { + // Semantic reading must use the same one-argument rule when revisiting the current expression. + let (language,expected) = ("en","repeating decimal 4 5"); + for style in ["ClearSpeak","SimpleSpeak"] { + init_nav(language,"")?; + set_preference("SpeechStyle",style)?; + let speech=do_navigate_command("ReadCurrent")?; + assert!(speech.contains(expected),"{language}/{style}: {speech}"); + } + Ok(()) +} + +#[test] +fn w3c_blank_intent_representations() -> Result<()> { + // The semantic blank is independent of its visible spelling, including a space-only blank. + test_w3c_speech("__", "blank")?; + test_w3c_speech("?", "blank")?; + test_w3c_speech("", "blank") +} + +#[test] +fn w3c_invisible_times_keeps_operands() -> Result<()> { + // W3C assigns silent fixity: suppress the operator name, never its operands. + test_w3c_speech("xy", "x y") +} + +#[test] +fn w3c_invisible_separator_keeps_operands() -> Result<()> { + // A silent separator must not add the word 'comma' or drop any listed operand. + test_w3c_speech("xyz", "x y z") +} + +#[test] +fn w3c_invisible_operator_leaves_are_silent() -> Result<()> { + // Explicit annotations on the Unicode operator itself have no spoken name or argument. + test_w3c_speech("⁢", "")?; + test_w3c_speech("⁣", "") +} + +#[test] +fn w3c_definite_integral_domain() -> Result<()> { + // The single W3C argument is an integration domain, not an integrand or integral sign. + test_w3c_speech("∫x", "integral over x") +} + +#[test] +fn w3c_definite_integral_bounds() -> Result<()> { + // Two W3C arguments are lower and upper bounds, in that order; neither may disappear. + test_w3c_speech("∫01", "integral from 0 to 1") +} + +#[test] +fn w3c_definite_integral_infinite_bounds() -> Result<()> { + // Infinite bounds must retain both signs and endpoint values. + let mathml="∫−∞∞"; + test_w3c_speech(mathml, "integral from negative infinity to infinity") +} + +#[test] +fn w3c_derivative_leibniz_second_order() -> Result<()> { + // The three arguments denote function, variable, and order, regardless of fraction layout. + let mathml = "d2ydx2"; + test_w3c_speech(mathml, "the second derivative of y with respect to x") +} + +#[test] +fn w3c_derivative_prime_first_order() -> Result<()> { + // Prime notation needs the same semantic meaning, without assuming a spoken Leibniz fraction. + let mathml = "f′(x)"; + test_w3c_speech(mathml, "the derivative of f with respect to x") +} + +#[test] +fn w3c_derivative_symbolic_order() -> Result<()> { + // Symbolic orders need an order phrase, not a numeric-ordinal conversion of the letter n. + let mathml="fxn"; + test_w3c_speech(mathml, "the derivative of order n of f with respect to x") +} + +#[test] +fn w3c_calculus_intent_read_current() -> Result<()> { + // Semantic navigation must use the same integral and derivative readings as full speech. + let (language, integral, derivative) = ("en", "integral from 0 to 1", "the derivative of f with respect to x"); + for style in ["ClearSpeak", "SimpleSpeak"] { + init_nav(language, "∫01")?; + set_preference("SpeechStyle", style)?; + let speech=do_navigate_command("ReadCurrent")?.split_whitespace().collect::>().join(" "); + assert!(speech.contains(integral), "{language}/{style}: {speech}"); + init_nav(language, "f′")?; + set_preference("SpeechStyle", style)?; + let speech=do_navigate_command("ReadCurrent")?.split_whitespace().collect::>().join(" "); + assert!(speech.contains(derivative), "{language}/{style}: {speech}"); + } + Ok(()) +} + +#[test] +fn w3c_binomial_coefficient() -> Result<()> { + // The W3C alias reuses binomial speech without changing inferred semantics. + test_w3c_speech("73", "7 choose 3") +} diff --git a/tests/Languages/intent.rs b/tests/Languages/intent.rs index 2ef247e0b..0d4496c21 100644 --- a/tests/Languages/intent.rs +++ b/tests/Languages/intent.rs @@ -3,4 +3,4 @@ mod general; mod calculus; mod linear_algebra; -mod tables; \ No newline at end of file +mod tables; diff --git a/tests/Languages/intent/general.rs b/tests/Languages/intent/general.rs index c8e9c94df..dd7f3d4be 100644 --- a/tests/Languages/intent/general.rs +++ b/tests/Languages/intent/general.rs @@ -114,53 +114,3 @@ fn evaluated_at() -> Result<()> { return Ok(()); } - -#[test] -fn child_intent_blocks_ratio() -> Result<()> { - // The intent on the ∶ says it is not a ratio colon, so the mrow must not be inferred as a ratio. - let expr = " - - 20 - ∶ - 5 - - "; - test("en", "SimpleSpeak", expr, "20 divides 5")?; - return Ok(()); -} - -#[test] -fn child_intent_supplies_ratio() -> Result<()> { - // The intent on the operator says it is a ∶, so the mrow is inferred as a ratio as if ∶ had been written. - let expr = " - - 20 - ÷ - 5 - - "; - let target = " - - 20 - 5 - - "; - test_intent(expr, target, vec![])?; - return Ok(()); -} - -#[test] -fn child_intent_supplies_number_set() -> Result<()> { - // The intent on the base says it is ℝ, so the msup is inferred as a number set as if ℝ had been written. - let expr = "R+"; - test("en", "SimpleSpeak", expr, "the positive double-struck cap r")?; - return Ok(()); -} - -#[test] -fn child_intent_blocks_number_set() -> Result<()> { - // The intent on the base says it is not ℝ, so the msup must not be inferred as a number set. - let expr = "ℝ+"; - test("en", "SimpleSpeak", expr, "foo superscript plus")?; - return Ok(()); -} diff --git a/tests/Languages/ru/intent.rs b/tests/Languages/ru/intent.rs index 40dd2e1c5..9fbcadf96 100644 --- a/tests/Languages/ru/intent.rs +++ b/tests/Languages/ru/intent.rs @@ -138,3 +138,5 @@ fn intent_prob_x() -> Result<()> { test("ru", "ClearSpeak", expr, "probability от икс")?; return Ok(()); } + +mod w3c; diff --git a/tests/Languages/ru/intent/w3c.rs b/tests/Languages/ru/intent/w3c.rs new file mode 100644 index 000000000..9db592184 --- /dev/null +++ b/tests/Languages/ru/intent/w3c.rs @@ -0,0 +1,148 @@ +use crate::common::*; +use anyhow::Result; + +// Check this language's W3C speech in both styles and at every verbosity. +fn test_w3c_speech(mathml: &str, expected: &str) -> Result<()> { + for style in ["ClearSpeak", "SimpleSpeak"] { + for verbosity in ["Terse", "Medium", "Verbose"] { + test_prefs("ru", style, vec![("Verbosity", verbosity), ("IntentErrorRecovery", "Error")], mathml, expected)?; + } + } + Ok(()) +} + +#[test] +fn w3c_repeating_decimal_one_argument() -> Result<()> { + // W3C supplies the period alone; a missing second child must never cause an XPath error. + test_w3c_speech("45_", "период 4 5")?; + test_w3c_speech("", "период 4 5") +} + +#[test] +fn w3c_repeating_decimal_leading_zeroes() -> Result<()> { + // A period is a digit sequence: 003 must retain both leading zeroes rather than become 3. + test_w3c_speech("003¯", "период 0 0 3") +} + +#[test] +fn w3c_repeating_decimal_legacy_arguments() -> Result<()> { + // Keep the existing prefix/period extension used by automatic inference. + test_w3c_speech("12003", "12 с повторяющимися цифрами 0 0 3")?; + test_w3c_speech("1245¯", "12 с повторяющимися цифрами 4 5") +} + +#[test] +fn w3c_repeating_decimal_complete_w3c_example() -> Result<()> { + // The W3C surrounding decimal must retain its prefix as well as both period digits. + let mathml="0.045_"; + let (language,expected) = ("ru","0.0 с повторяющимися цифрами 4 5"); + for style in ["ClearSpeak","SimpleSpeak"] { + for verbosity in ["Terse","Medium","Verbose"] { + test_prefs(language,style,vec![("Verbosity",verbosity),("IntentErrorRecovery","Error"),("DecimalSeparators","."),("BlockSeparators",", ")],mathml,expected)?; + } + } + Ok(()) +} + +#[test] +fn w3c_repeating_decimal_semantic_navigation() -> Result<()> { + // Semantic reading must use the same one-argument rule when revisiting the current expression. + let (language,expected) = ("ru","период 4 5"); + for style in ["ClearSpeak","SimpleSpeak"] { + init_nav(language,"")?; + set_preference("SpeechStyle",style)?; + let speech=do_navigate_command("ReadCurrent")?; + assert!(speech.contains(expected),"{language}/{style}: {speech}"); + } + Ok(()) +} + +#[test] +fn w3c_blank_intent_representations() -> Result<()> { + // The semantic blank is independent of its visible spelling, including a space-only blank. + test_w3c_speech("__", "пустое место")?; + test_w3c_speech("?", "пустое место")?; + test_w3c_speech("", "пустое место") +} + +#[test] +fn w3c_invisible_times_keeps_operands() -> Result<()> { + // W3C assigns silent fixity: suppress the operator name, never its operands. + test_w3c_speech("xy", "икс игрек") +} + +#[test] +fn w3c_invisible_separator_keeps_operands() -> Result<()> { + // A silent separator must not add the word 'comma' or drop any listed operand. + test_w3c_speech("xyz", "икс игрек зэт") +} + +#[test] +fn w3c_invisible_operator_leaves_are_silent() -> Result<()> { + // Explicit annotations on the Unicode operator itself have no spoken name or argument. + test_w3c_speech("⁢", "")?; + test_w3c_speech("⁣", "") +} + +#[test] +fn w3c_definite_integral_domain() -> Result<()> { + // The single W3C argument is an integration domain, not an integrand or integral sign. + test_w3c_speech("∫x", "интеграл по икс") +} + +#[test] +fn w3c_definite_integral_bounds() -> Result<()> { + // Two W3C arguments are lower and upper bounds, in that order; neither may disappear. + test_w3c_speech("∫01", "интеграл от 0 до 1") +} + +#[test] +fn w3c_definite_integral_infinite_bounds() -> Result<()> { + // Infinite bounds need the Russian genitive after both 'от' and 'до'. + let mathml="∫−∞∞"; + test_w3c_speech(mathml, "интеграл от минус бесконечности до бесконечности") +} + +#[test] +fn w3c_derivative_leibniz_second_order() -> Result<()> { + // The three arguments denote function, variable, and order, regardless of fraction layout. + let mathml = "d2ydx2"; + test_w3c_speech(mathml, "производная порядка 2 игрек по икс") +} + +#[test] +fn w3c_derivative_prime_first_order() -> Result<()> { + // Prime notation needs the same semantic meaning, without assuming a spoken Leibniz fraction. + let mathml = "f′(x)"; + test_w3c_speech(mathml, "производная эф по икс") +} + +#[test] +fn w3c_derivative_symbolic_order() -> Result<()> { + // Symbolic orders need an order phrase, not a numeric-ordinal conversion of the letter n. + let mathml="fxn"; + test_w3c_speech(mathml, "производная порядка эн эф по икс") +} + +#[test] +fn w3c_calculus_intent_read_current() -> Result<()> { + // Semantic navigation must use the same integral and derivative readings as full speech. + let (language, integral, derivative) = ("ru", "интеграл от 0 до 1", "производная эф по икс"); + for style in ["ClearSpeak", "SimpleSpeak"] { + init_nav(language, "∫01")?; + set_preference("SpeechStyle", style)?; + let speech=do_navigate_command("ReadCurrent")?.split_whitespace().collect::>().join(" "); + assert!(speech.contains(integral), "{language}/{style}: {speech}"); + init_nav(language, "f′")?; + set_preference("SpeechStyle", style)?; + let speech=do_navigate_command("ReadCurrent")?.split_whitespace().collect::>().join(" "); + assert!(speech.contains(derivative), "{language}/{style}: {speech}"); + } + Ok(()) +} + +#[test] +fn w3c_binomial_coefficient() -> Result<()> { + // The W3C alias reuses binomial speech without changing inferred semantics. + test_w3c_speech("73", "число сочетаний из 7 по 3") +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 837fca05b..c93766d32 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -252,6 +252,9 @@ pub fn test_intent(mathml: &str, target: &str, test_prefs: Vec<(&str, &str)>) -> prefs.set_user_prefs("BlockSeparators", ", ").unwrap(); }); + // Default fixities come from language definitions; use a stable baseline instead of the OS locale. + // Explicit test preferences below can still select another language. + set_preference("Language", "en").unwrap(); set_preference("IntentErrorRecovery", "Error").unwrap(); set_preference("SpeechStyle", "SimpleSpeak").unwrap(); // avoids possibility of "LiteralSpeak" for &(pref_name, pref_value) in &test_prefs {