Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions crates/message-format-conformance/src/harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,15 +140,15 @@ fn cases() -> Vec<Case> {
args: Vec::new(),
expected: include_str!("../../../fixtures/expected/raw_match_integer_exact.txt")
.trim_end(),
host_mode: HostMode::Noop,
host_mode: HostMode::Builtin { locale: "en-US" },
},
Case {
source: include_str!("../../../fixtures/mf2/raw_match_offset_selector.mf2"),
message_id: "main",
args: Vec::new(),
expected: include_str!("../../../fixtures/expected/raw_match_offset_selector.txt")
.trim_end(),
host_mode: HostMode::Noop,
host_mode: HostMode::Builtin { locale: "en-US" },
},
Case {
source: include_str!("../../../fixtures/mf2/raw_match_two_locals.mf2"),
Expand Down
33 changes: 11 additions & 22 deletions crates/message-format-conformance/src/tr35/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,19 +160,18 @@ pub(super) fn assert_format_err(
}
}

/// Assert that formatting returns the expected output and reports a matching error.
pub(super) fn assert_format_with_error(
source: &str,
args: &[(&str, Value)],
expected: &str,
check: impl Fn(&FormatError) -> bool,
) {
let output = format_output_with_locale(source, args, "en").expect("format failed");
assert_eq!(strip_bidi(&output.value), expected, "source: {source}");
/// Assert that formatting reports exactly the expected diagnostic multiset.
pub(super) fn assert_errors_multiset(actual: &[FormatError], expected: &[FormatError]) {
let mut remaining = expected.to_vec();
for error in actual {
let Some(index) = remaining.iter().position(|candidate| candidate == error) else {
panic!("unexpected diagnostic {error:?}; expected {expected:?}");
};
remaining.remove(index);
}
assert!(
output.errors.iter().any(check),
"expected a matching format error for: {source}: {:?}",
output.errors
remaining.is_empty(),
"missing diagnostics {remaining:?}; actual {actual:?}"
);
}

Expand Down Expand Up @@ -278,13 +277,3 @@ pub(super) fn function_error(error: MessageFunctionError) -> FormatError {
pub(super) fn missing_arg(name: &str) -> FormatError {
FormatError::MissingArg(name.to_string())
}

pub(super) fn bad_selector_with_source(expected: FormatError) -> impl Fn(&FormatError) -> bool {
move |error| {
matches!(
error,
FormatError::BadSelector { source }
if source.as_deref() == Some(&expected)
)
}
}
140 changes: 136 additions & 4 deletions crates/message-format-conformance/src/tr35/number_fn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,130 @@ fn integer_literal() {
assert_format("{42 :integer}", &[], "42");
}

/// Resolved numeric locals retain exact integer payloads beyond the f64 safe
/// integer range until the runtime renders them.
#[test]
fn numeric_local_preserves_exact_integer() {
assert_format(
".local $n = {9007199254740993 :number useGrouping=never} {{{$n}}}",
&[],
"9007199254740993",
);
}

/// Re-annotating a resolved number overrides only the supplied options and
/// retains inherited options from the local declaration.
#[test]
fn numeric_local_reannotation_inherits_options() {
assert_format(
".local $n = {4.20 :number minimumFractionDigits=2 signDisplay=always} .local $m = {$n :number minimumFractionDigits=1} {{{$m}}}",
&[],
"+4.2",
);
}

/// Re-annotation of an input number also preserves its resolved options.
#[test]
fn numeric_input_reannotation_inherits_options() {
assert_format(
".input {$n :number minimumFractionDigits=2 signDisplay=always} .local $m = {$n :number minimumFractionDigits=1} {{{$m}}}",
&[("n", Value::Float(4.2))],
"+4.2",
);
}

/// Offset applies to the exact resolved payload while retaining inherited
/// number formatting options.
#[test]
fn numeric_local_offset_inherits_options() {
assert_format(
".local $n = {4.2 :number minimumFractionDigits=2 signDisplay=always} .local $m = {$n :offset add=1} {{{$m}}}",
&[],
"+5.20",
);
}

/// Integer conversion truncates a resolved number and drops inherited digit
/// and fraction formatting options.
#[test]
fn numeric_local_integer_drops_number_options() {
assert_format(
".local $n = {4.2 :number minimumFractionDigits=2 signDisplay=always} .local $m = {$n :integer} {{{$m}}}",
&[],
"+4",
);
}

/// Exact matching uses the numeric payload even when grouped display options
/// are inherited by the resolved local.
#[test]
fn numeric_local_match_uses_exact_payload() {
assert_format(
".local $n = {1234 :number useGrouping=always} .match $n 1234 {{exact}} * {{other}}",
&[],
"exact",
);
}

/// Exact numeric matching takes precedence over the plural keyword arm.
#[test]
fn numeric_local_match_exact_precedes_keyword() {
assert_format(
".local $n = {1 :number minimumFractionDigits=2} .match $n 1 {{exact}} one {{one}} * {{other}}",
&[],
"exact",
);
}

/// A resolved numeric local with no exact arm does not use its display text as
/// a plural category selector.
#[test]
fn numeric_local_match_keyword_uses_plural_category() {
assert_format(
".local $n = {1 :number minimumFractionDigits=2} .match $n one {{one}} * {{other}}",
&[],
"other",
);
}

/// Alias chains preserve the resolved numeric call and its inherited options.
#[test]
fn numeric_local_alias_chain_preserves_resolved_value() {
assert_format(
".local $n = {4.2 :number minimumFractionDigits=2} .local $alias = {$n} .local $m = {$alias :number signDisplay=always} {{{$m}}}",
&[],
"+4.20",
);
}

/// A dynamic select option stays runtime-resolved: the invalid inner number
/// annotation reports bad-option and the enclosing match reports bad-selector.
#[test]
fn numeric_local_dynamic_select_reports_both_diagnostics() {
let output = format_output(
".local $bad = {exact} .local $n = {1 :number select=$bad} .match $n 1 {{one}} * {{other {$n}}}",
&[],
);
assert_eq!(output.value, "other 1");
assert_eq!(
output
.errors
.iter()
.filter(|error| is_bad_option(error))
.count(),
1,
"expected one bad-option diagnostic: {:?}",
output.errors
);
assert_errors_multiset(
&output.errors,
&[
function_error(message_format::runtime::MessageFunctionError::BadOption),
message_format::runtime::FormatError::BadSelector { source: None },
],
);
}

// ---------------------------------------------------------------------------
// TR35 §14 — :number selection via select= option
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -166,13 +290,21 @@ fn number_selection_ordinal() {
);
}

/// TR35 §14 — dynamic numeric selector mode still prefers exact keys over keywords.
/// Dynamic selector modes remain runtime options and therefore produce a bad
/// selector instead of being rewritten to a synthesized exact selector.
#[test]
fn number_selection_dynamic_mode_prefers_exact_key() {
assert_format(
fn number_selection_dynamic_mode_is_not_synthesized() {
let output = format_output(
".input { $mode }\n.input { $x :number select=$mode }\n.match $x\n1 {{EXACT}}\none {{ONE}}\n* {{OTHER}}",
&[("mode", Value::Str("plural".into())), ("x", Value::Int(1))],
"EXACT",
);
assert_eq!(output.value, "OTHER");
assert_errors_multiset(
&output.errors,
&[
function_error(message_format::runtime::MessageFunctionError::BadOption),
message_format::runtime::FormatError::BadSelector { source: None },
],
);
}

Expand Down
74 changes: 50 additions & 24 deletions crates/message-format-conformance/src/tr35/pattern_selection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0 OR MIT

use super::helpers::*;
use message_format::runtime::{MessageFunctionError, Value};
use message_format::runtime::{FormatError, MessageFunctionError, Value};

// ---------------------------------------------------------------------------
// TR35 §12 — Pattern selection basics (SEL-2, SEL-11)
Expand Down Expand Up @@ -139,22 +139,34 @@ fn literal_key_quoted_unquoted_equal() {
/// E-7/SEL-5 — Selector with missing arg: only catch-all matches.
#[test]
fn selector_missing_arg_falls_to_catchall() {
assert_format_with_error(
let output = format_output(
".input { $x :string }\n.match $x\na {{A}}\n* {{CATCHALL}}",
&[], // no args → selector can't resolve
"CATCHALL",
bad_selector_with_source(missing_arg("x")),
);
assert_eq!(output.value, "CATCHALL");
assert_errors_multiset(
&output.errors,
&[
FormatError::MissingArg("x".into()),
FormatError::BadSelector { source: None },
],
);
}

/// E-7/SEL-5 — Invalid runtime selector options still emit `Bad Selector`.
#[test]
fn selector_bad_option_falls_to_catchall() {
assert_format_with_error(
let output = format_output(
".input { $mode }\n.input { $x :number select=$mode }\n.match $x\none {{ONE}}\n* {{CATCHALL}}",
&[("mode", Value::Str("bogus".into())), ("x", Value::Int(1))],
"CATCHALL",
bad_selector_with_source(function_error(MessageFunctionError::BadOption)),
);
assert_eq!(output.value, "CATCHALL");
assert_errors_multiset(
&output.errors,
&[
function_error(MessageFunctionError::BadOption),
FormatError::BadSelector { source: None },
],
);
}

Expand All @@ -166,22 +178,36 @@ fn selector_with_multiple_missing_inputs_reports_all_missing_args() {
&[],
);
assert_eq!(output.value, "CATCHALL");
assert!(
output
.errors
.iter()
.any(bad_selector_with_source(missing_arg("x"))),
"missing primary bad-selector diagnostic: {:?}",
output.errors
);
assert!(
output.errors.contains(&missing_arg("mode")),
"missing secondary missing-arg diagnostic: {:?}",
output.errors
);
assert!(
output.errors.contains(&missing_arg("digits")),
"missing tertiary missing-arg diagnostic: {:?}",
output.errors
assert_errors_multiset(
&output.errors,
&[
missing_arg("x"),
missing_arg("mode"),
missing_arg("digits"),
FormatError::BadSelector { source: None },
],
);
}

/// A fallback propagated through a local is re-annotated using the current
/// variable's fallback representation, without invoking the function host.
#[test]
fn local_fallback_reannotation_uses_current_variable_name() {
let output = format_output(
".local $a = {$missing}\n.local $b = {$a :number}\n{{{$b}}}",
&[],
);
assert_eq!(output.value, "{$b}");
assert_errors_multiset(&output.errors, &[missing_arg("missing")]);
}

/// Plain local aliases retain their own fallback identity when rendered.
#[test]
fn local_fallback_aliases_use_each_variable_name() {
let output = format_output(
".local $a = {$missing}\n.local $b = {$a}\n{{{$a} {$b}}}",
&[],
);
assert_eq!(output.value, "{$a} {$b}");
assert_errors_multiset(&output.errors, &[missing_arg("missing")]);
}
Loading