diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/accumulator/median/test_median_core.py b/documentdb_tests/compatibility/tests/core/operator/expressions/accumulator/median/test_median_core.py new file mode 100644 index 00000000..8e7a1821 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/accumulator/median/test_median_core.py @@ -0,0 +1,352 @@ +from __future__ import annotations + +import math + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.accumulator.median.utils.median_common import ( # noqa: E501 + MedianTest, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import ( + DECIMAL128_INFINITY, + DECIMAL128_NAN, + DECIMAL128_NEGATIVE_INFINITY, + DECIMAL128_NEGATIVE_NAN, + DECIMAL128_NEGATIVE_ZERO, + DECIMAL128_ZERO, + DOUBLE_NEGATIVE_ZERO, + DOUBLE_ZERO, + FLOAT_INFINITY, + FLOAT_NAN, + FLOAT_NEGATIVE_INFINITY, +) + +# Property [Core Median Behavior]: $median returns the 50th percentile of +# numeric operands, with negative zero normalized to positive zero. +MEDIAN_CORE_TESTS: list[MedianTest] = [ + MedianTest( + "core_single_int32", + args=42, + expected=42.0, + msg="$median of a single int32 should return that value as double", + ), + MedianTest( + "core_single_int64", + args=Int64(42), + expected=42.0, + msg="$median of a single int64 should return that value as double", + ), + MedianTest( + "core_single_double", + args=42.0, + expected=42.0, + msg="$median of a single double should return that value", + ), + MedianTest( + "core_single_decimal", + args=Decimal128("42"), + expected=Decimal128("42"), + msg="$median of a single Decimal128 should return that value", + ), + MedianTest( + "core_two_equal", + args=[5, 5], + expected=5.0, + msg="$median of two equal values should return that value", + ), + MedianTest( + "core_multiple_distinct_odd", + args=[10, 20, 30], + expected=20.0, + msg="$median of odd number of elements returns middle element", + ), + MedianTest( + "core_multiple_distinct_even", + args=[10, 20, 30, 40], + expected=25.0, + msg="$median of even number of elements returns average of middle elements", + ), + MedianTest( + "core_positive_negative_cancel", + args=[1, -1], + expected=DOUBLE_ZERO, + msg="$median of positive and negative values that cancel should return 0.0", + ), + MedianTest( + "core_negative_values_odd", + args=[-10, -20, -30], + expected=-20.0, + msg="$median of negative values returns correct negative median", + ), + MedianTest( + "core_negative_values_even", + args=[-10, -20], + expected=-15.0, + msg="$median of negative values returns correct negative median", + ), + MedianTest( + "core_zero", + args=0, + expected=DOUBLE_ZERO, + msg="$median of zero should return 0.0", + ), + MedianTest( + "core_zero_pair", + args=[0, 0], + expected=DOUBLE_ZERO, + msg="$median of two zeros should return 0.0", + ), + MedianTest( + "core_neg_zero_double", + args=DOUBLE_NEGATIVE_ZERO, + expected=DOUBLE_ZERO, + msg="$median should normalize double negative zero to positive zero", + ), + MedianTest( + "core_neg_zero_double_in_list", + args=[DOUBLE_NEGATIVE_ZERO, DOUBLE_NEGATIVE_ZERO], + expected=DOUBLE_ZERO, + msg="$median should normalize double negative zero to positive zero in a list", + ), + MedianTest( + "core_neg_zero_pos_zero_cancel", + args=[DOUBLE_NEGATIVE_ZERO, DOUBLE_ZERO], + expected=DOUBLE_ZERO, + msg="$median of -0.0 and 0.0 should return positive zero", + ), + MedianTest( + "core_neg_zero_decimal", + args=DECIMAL128_NEGATIVE_ZERO, + expected=DECIMAL128_ZERO, + msg="$median should normalize Decimal128 negative zero to positive zero", + ), + MedianTest( + "core_neg_zero_decimal_in_list", + args=[DECIMAL128_NEGATIVE_ZERO, DECIMAL128_NEGATIVE_ZERO], + expected=DECIMAL128_ZERO, + msg="$median should normalize Decimal128 negative zero to positive zero in a list", + ), + MedianTest( + "core_neg_zero_pos_zero_decimal_cancel", + args=[DECIMAL128_NEGATIVE_ZERO, DECIMAL128_ZERO], + expected=DECIMAL128_ZERO, + msg="$median of Decimal128 -0 and 0 should return positive Decimal128 zero", + ), +] + +# Property [NaN and Infinity]: NaN propagates through median and dominates +# Infinity. Infinity propagates when mixed with finite values. Cancellation +# of Infinity and -Infinity produces NaN. Decimal128 variants follow the +# same rules but produce Decimal128 results. +MEDIAN_NAN_INFINITY_TESTS: list[MedianTest] = [ + MedianTest( + "nan_scalar", + args=FLOAT_NAN, + expected=pytest.approx(math.nan, nan_ok=True), + msg="$median should return NaN for a NaN scalar operand", + ), + MedianTest( + "nan_pair", + args=[FLOAT_NAN, FLOAT_NAN], + expected=pytest.approx(math.nan, nan_ok=True), + msg="$median of two NaN values should return NaN", + ), + MedianTest( + "inf_scalar", + args=FLOAT_INFINITY, + expected=FLOAT_INFINITY, + msg="$median should return Infinity for an Infinity scalar operand", + ), + MedianTest( + "inf_pair", + args=[FLOAT_INFINITY, FLOAT_INFINITY], + expected=FLOAT_INFINITY, + msg="$median of two Infinity values should return Infinity", + ), + MedianTest( + "neg_inf_scalar", + args=FLOAT_NEGATIVE_INFINITY, + expected=FLOAT_NEGATIVE_INFINITY, + msg="$median should return -Infinity for a -Infinity scalar operand", + ), + MedianTest( + "neg_inf_pair", + args=[FLOAT_NEGATIVE_INFINITY, FLOAT_NEGATIVE_INFINITY], + expected=FLOAT_NEGATIVE_INFINITY, + msg="$median of two -Infinity values should return -Infinity", + ), + MedianTest( + "decimal_nan_scalar", + args=DECIMAL128_NAN, + expected=DECIMAL128_NAN, + msg="$median should return Decimal128 NaN for a Decimal128 NaN scalar operand", + ), + MedianTest( + "decimal_nan_pair", + args=[DECIMAL128_NAN, DECIMAL128_NAN], + expected=DECIMAL128_NAN, + msg="$median of two Decimal128 NaN values should return Decimal128 NaN", + ), + MedianTest( + "decimal_inf_scalar", + args=DECIMAL128_INFINITY, + expected=DECIMAL128_INFINITY, + msg="$median should return Decimal128 Infinity for a Decimal128 Infinity scalar operand", + ), + MedianTest( + "decimal_inf_pair", + args=[DECIMAL128_INFINITY, DECIMAL128_INFINITY], + expected=DECIMAL128_INFINITY, + msg="$median of two Decimal128 Infinity values should return Decimal128 Infinity", + ), + MedianTest( + "decimal_neg_inf_scalar", + args=DECIMAL128_NEGATIVE_INFINITY, + expected=DECIMAL128_NEGATIVE_INFINITY, + msg="$median should return Decimal128 -Infinity for a Decimal128 -Infinity scalar operand", + ), + MedianTest( + "decimal_neg_inf_pair", + args=[DECIMAL128_NEGATIVE_INFINITY, DECIMAL128_NEGATIVE_INFINITY], + expected=DECIMAL128_NEGATIVE_INFINITY, + msg="$median of two Decimal128 -Infinity values should return Decimal128 -Infinity", + ), + MedianTest( + "nan_with_numeric", + args=[FLOAT_NAN, 5], + expected=pytest.approx(math.nan, nan_ok=True), + msg="$median should return NaN when NaN is mixed with a numeric value", + ), + MedianTest( + "nan_with_null", + args=[FLOAT_NAN, None], + expected=pytest.approx(math.nan, nan_ok=True), + msg="$median should return NaN when NaN is mixed with null", + ), + MedianTest( + "nan_dominates_inf", + args=[FLOAT_NAN, FLOAT_INFINITY], + expected=pytest.approx(math.nan, nan_ok=True), + msg="$median should return NaN when NaN is mixed with Infinity", + ), + MedianTest( + "inf_with_finite", + args=[FLOAT_INFINITY, 5], + expected=FLOAT_INFINITY, + msg="$median should return Infinity when Infinity is mixed with finite value", + ), + MedianTest( + "neg_inf_with_finite", + args=[FLOAT_NEGATIVE_INFINITY, 5], + expected=FLOAT_NEGATIVE_INFINITY, + msg="$median should return -Infinity when -Infinity is mixed with finite value", + ), + MedianTest( + "inf_neg_inf_cancel", + args=[FLOAT_INFINITY, FLOAT_NEGATIVE_INFINITY], + expected=pytest.approx(math.nan, nan_ok=True), + msg="$median should return NaN when Infinity and -Infinity cancel", + ), + MedianTest( + "inf_with_null", + args=[FLOAT_INFINITY, None], + expected=FLOAT_INFINITY, + msg="$median should return Infinity when Infinity is mixed with null", + ), + MedianTest( + "decimal_nan_with_numeric", + args=[DECIMAL128_NAN, Decimal128("5")], + expected=DECIMAL128_NAN, + msg="$median should return Decimal128 NaN when Decimal128 NaN is present", + ), + MedianTest( + "decimal_inf_with_finite", + args=[DECIMAL128_INFINITY, Decimal128("5")], + expected=DECIMAL128_INFINITY, + msg="$median should return Decimal128 Infinity with finite Decimal128", + ), + MedianTest( + "decimal_neg_inf_with_finite", + args=[DECIMAL128_NEGATIVE_INFINITY, Decimal128("5")], + expected=DECIMAL128_NEGATIVE_INFINITY, + msg="$median should return Decimal128 -Infinity with finite Decimal128", + ), + MedianTest( + "decimal_inf_neg_inf_cancel", + args=[DECIMAL128_INFINITY, DECIMAL128_NEGATIVE_INFINITY], + expected=DECIMAL128_NAN, + msg="$median should return Decimal128 NaN when Decimal128 Inf and -Inf cancel", + ), + MedianTest( + "decimal_negative_nan_preserved", + args=DECIMAL128_NEGATIVE_NAN, + expected=DECIMAL128_NEGATIVE_NAN, + msg="$median should preserve Decimal128 -NaN sign bit", + ), + MedianTest( + "decimal_nan_dominates_inf", + args=[DECIMAL128_NAN, DECIMAL128_INFINITY], + expected=DECIMAL128_NAN, + msg="$median should return Decimal128 NaN when NaN dominates Decimal128 Infinity", + ), + MedianTest( + "decimal_nan_with_null", + args=[DECIMAL128_NAN, None], + expected=DECIMAL128_NAN, + msg="$median should return Decimal128 NaN when mixed with null", + ), + MedianTest( + "decimal_inf_with_null", + args=[DECIMAL128_INFINITY, None], + expected=DECIMAL128_INFINITY, + msg="$median should return Decimal128 Infinity when mixed with null", + ), + # Cross-type: double NaN + Decimal128 value produces Decimal128 NaN. + MedianTest( + "cross_double_nan_decimal_value", + args=[FLOAT_NAN, Decimal128("5")], + expected=DECIMAL128_NAN, + msg="$median should return Decimal128 NaN for double NaN + Decimal128 value", + ), + MedianTest( + "cross_decimal_nan_int32", + args=[DECIMAL128_NAN, 5], + expected=DECIMAL128_NAN, + msg="$median should return Decimal128 NaN for Decimal128 NaN + int32", + ), + # Cross-type Infinity cancellation produces Decimal128 NaN. + MedianTest( + "cross_decimal_inf_double_neg_inf", + args=[DECIMAL128_INFINITY, FLOAT_NEGATIVE_INFINITY], + expected=DECIMAL128_NAN, + msg="$median should return Decimal128 NaN for Decimal128 Inf + double -Inf", + ), + MedianTest( + "cross_double_inf_decimal_neg_inf", + args=[FLOAT_INFINITY, DECIMAL128_NEGATIVE_INFINITY], + expected=DECIMAL128_NAN, + msg="$median should return Decimal128 NaN for double Inf + Decimal128 -Inf", + ), +] + +MEDIAN_CORE_ALL_TESTS = MEDIAN_CORE_TESTS + MEDIAN_NAN_INFINITY_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(MEDIAN_CORE_ALL_TESTS)) +def test_median_core(collection, test_case: MedianTest): + """Test $median cases.""" + result = execute_expression( + collection, {"$median": {"input": test_case.args, "method": "approximate"}} + ) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/accumulator/median/test_median_errors.py b/documentdb_tests/compatibility/tests/core/operator/expressions/accumulator/median/test_median_errors.py new file mode 100644 index 00000000..76c577f9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/accumulator/median/test_median_errors.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.accumulator.median.utils.median_common import ( # noqa: E501 + MedianTest, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, +) +from documentdb_tests.framework.error_codes import ( + FAILED_TO_PARSE_ERROR, + INVALID_DOLLAR_FIELD_PATH, +) +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Syntax Validation / Errors]: $median expects an object containing +# 'input' and 'method' fields. Invalid formats, missing fields, or invalid +# option values result in appropriate parsing or validation errors. +MEDIAN_ERROR_TESTS: list[MedianTest] = [ + MedianTest( + "missing_method", + args={"input": "$values"}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$median should reject configuration with missing method field", + ), + MedianTest( + "invalid_method", + args={"input": "$values", "method": "exact"}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$median should reject method other than approximate", + ), + MedianTest( + "missing_input", + args={"method": "approximate"}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$median should reject configuration with missing input field", + ), + MedianTest( + "extra_field", + args={"input": "$values", "method": "approximate", "extraField": 123}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$median should reject configuration with unrecognized extra fields", + ), + MedianTest( + "non_object_arg_array", + args=[10, 20], + error_code=FAILED_TO_PARSE_ERROR, + msg="$median should reject a non-object array argument", + ), + MedianTest( + "non_object_arg_scalar", + args=123, + error_code=FAILED_TO_PARSE_ERROR, + msg="$median should reject a non-object scalar argument", + ), + MedianTest( + "fieldpath_bare_dollar", + args={"input": "$", "method": "approximate"}, + error_code=INVALID_DOLLAR_FIELD_PATH, + msg="$median should reject bare '$' as an invalid field path in input", + ), + MedianTest( + "fieldpath_double_dollar", + args={"input": "$$", "method": "approximate"}, + error_code=FAILED_TO_PARSE_ERROR, + msg="$median should reject '$$' as an empty variable name in input", + ), +] + + +@pytest.mark.parametrize("test_case", pytest_params(MEDIAN_ERROR_TESTS)) +def test_median_errors(collection, test_case: MedianTest): + """Test $median error cases.""" + # When test_case.args is a dict, we wrap it in $median directly. + # When it's not a dict (like list or scalar), we also wrap it directly in $median. + expression = {"$median": test_case.args} + result = execute_expression(collection, expression) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/accumulator/median/test_median_input_forms.py b/documentdb_tests/compatibility/tests/core/operator/expressions/accumulator/median/test_median_input_forms.py new file mode 100644 index 00000000..def65eb9 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/accumulator/median/test_median_input_forms.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import pytest +from bson import Decimal128, Int64 + +from documentdb_tests.compatibility.tests.core.operator.expressions.accumulator.median.utils.median_common import ( # noqa: E501 + MedianTest, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, + execute_project_with_insert, +) +from documentdb_tests.framework.assertions import assertSuccess +from documentdb_tests.framework.parametrize import pytest_params + +# Property [Array Traversal (Single Expression Form)]: when a single +# expression resolves to an array, $median traverses one level into it to +# find the median of its numeric elements. Nested arrays and non-numeric elements are +# ignored. +MEDIAN_ARRAY_TRAVERSAL_TESTS: list[MedianTest] = [ + MedianTest( + "traversal_basic", + args={"$literal": [10, 20, 30]}, + expected=20.0, + msg="$median should traverse a literal array and find the median of its numeric elements", + ), + MedianTest( + "traversal_nested_array_ignored", + args={"$literal": [10, [20, 30], 40]}, + expected=25.0, + msg="$median should ignore nested arrays as non-numeric during traversal", + ), + MedianTest( + "traversal_double_nested", + args={"$literal": [[10, 20, 30]]}, + expected=None, + msg="$median should return null when the only element is a nested array", + ), + MedianTest( + "traversal_non_numeric_ignored", + args={"$literal": [10, "hello", Int64(20), None, 30.0, Decimal128("7")]}, + expected=15.0, + msg="$median should ignore non-numeric elements and null during array traversal", + ), + MedianTest( + "traversal_all_non_numeric", + args={"$literal": ["hello", True]}, + expected=None, + msg="$median should return null when all traversed elements are non-numeric", + ), + MedianTest( + "traversal_all_null", + args={"$literal": [None, None]}, + expected=None, + msg="$median should return null when all traversed elements are null", + ), + MedianTest( + "traversal_empty_array", + args={"$literal": []}, + expected=None, + msg="$median should return null for an empty traversed array", + ), +] + +# Property [Arity]: $median returns null for an empty operand list and +# correctly computes the median of large operand counts. +MEDIAN_ARITY_TESTS: list[MedianTest] = [ + MedianTest( + "arity_empty_array", + args=[], + expected=None, + msg="$median should return null for an empty operand list", + ), + MedianTest( + "arity_single_element_list", + args=[42], + expected=42.0, + msg="$median of a single-element list should return that value as double", + ), + MedianTest( + "arity_10_000_elements", + args=list(range(10_000)), + expected=4999.5, + msg="$median should correctly compute median of 10000 elements", + ), + MedianTest( + "arity_10_000_identical", + args=[7] * 10_000, + expected=7.0, + msg="$median of 10000 identical values should return that value", + ), +] + +# Property [Expression Arguments]: $median accepts arbitrary expressions as +# operands, evaluating each before computing the median. +MEDIAN_EXPRESSION_ARGS_TESTS: list[MedianTest] = [ + MedianTest( + "expr_add", + args={"$literal": [10, 20, {"$add": [15, 15]}]}, + expected=20.0, + msg="$median should accept $add expression as an operand within the array", + ), + MedianTest( + "expr_returning_null", + args={"$literal": [10, {"$literal": None}, 20]}, + expected=15.0, + msg="$median should ignore an expression that returns null within the array", + ), + MedianTest( + "expr_returning_non_numeric", + args={"$literal": [10, {"$toString": 42}, 20]}, + expected=15.0, + msg="$median should ignore an expression that returns a non-numeric type", + ), +] + +MEDIAN_INPUT_FORMS_TESTS = ( + MEDIAN_ARRAY_TRAVERSAL_TESTS + MEDIAN_ARITY_TESTS + MEDIAN_EXPRESSION_ARGS_TESTS +) + + +@pytest.mark.parametrize("test_case", pytest_params(MEDIAN_INPUT_FORMS_TESTS)) +def test_median_input_form(collection, test_case: MedianTest): + """Test $median input cases.""" + result = execute_expression( + collection, {"$median": {"input": test_case.args, "method": "approximate"}} + ) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) + + +def test_median_document_fields(collection): + """Test $median reads values from document fields.""" + result = execute_project_with_insert( + collection, + {"arr": [10, 20, 30]}, + {"result": {"$median": {"input": "$arr", "method": "approximate"}}}, + ) + assertSuccess(result, [{"result": 20.0}], msg="$median should read values from document fields") + + +def test_median_dotted_field_path_traversal(collection): + """Test $median traverses arrays via dotted field paths.""" + result = execute_project_with_insert( + collection, + {"a": [{"b": 10}, {"b": 20}, {"b": 30}]}, + {"result": {"$median": {"input": "$a.b", "method": "approximate"}}}, + ) + assertSuccess( + result, + [{"result": 20.0}], + msg="$median should traverse an array of objects via dotted path and find the median", + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/accumulator/median/test_median_null.py b/documentdb_tests/compatibility/tests/core/operator/expressions/accumulator/median/test_median_null.py new file mode 100644 index 00000000..c32ce4f6 --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/accumulator/median/test_median_null.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import pytest + +from documentdb_tests.compatibility.tests.core.operator.expressions.accumulator.median.utils.median_common import ( # noqa: E501 + MedianTest, +) +from documentdb_tests.compatibility.tests.core.operator.expressions.utils.utils import ( + assert_expression_result, + execute_expression, +) +from documentdb_tests.framework.parametrize import pytest_params +from documentdb_tests.framework.test_constants import MISSING + +# Property [Null Propagation]: if the sole operand is null, or missing, the +# result is null. If all values in a list are null or missing the result is +# null. +MEDIAN_NULL_TESTS: list[MedianTest] = [ + MedianTest( + "null_sole_operand", + args=None, + expected=None, + msg="$median should return null when the sole operand is null", + ), + MedianTest( + "null_missing_sole", + args=MISSING, + expected=None, + msg="$median should return null when the sole operand is a missing field", + ), + MedianTest( + "null_expr_returning_null", + args={"$literal": None}, + expected=None, + msg="$median should return null when an expression returns null", + ), + MedianTest( + "null_all_null", + args=[None, None], + expected=None, + msg="$median should return null when all operands are null", + ), + MedianTest( + "null_all_missing", + args=[MISSING, MISSING], + expected=None, + msg="$median should return null when all operands are missing", + ), + MedianTest( + "null_mixed_null_and_missing", + args=[None, MISSING], + expected=None, + msg="$median should return null when operands are a mix of null and missing", + ), +] + +# Property [Null/Missing Exclusion]: null, missing, and non-numeric values in +# a list are excluded, so they do not affect the median of the remaining numeric +# values. +MEDIAN_NULL_EXCLUSION_TESTS: list[MedianTest] = [ + MedianTest( + "null_excluded_from_list", + args=[10, None, 20], + expected=15.0, + msg="$median should exclude null from calculation in a list", + ), + MedianTest( + "null_missing_excluded_from_list", + args=[10, MISSING, 20], + expected=15.0, + msg="$median should exclude missing from calculation in a list", + ), + MedianTest( + "null_excluded_at_start", + args=[None, 10, 20], + expected=15.0, + msg="$median should exclude null at the start of a list", + ), + MedianTest( + "null_excluded_at_end", + args=[10, 20, None], + expected=15.0, + msg="$median should exclude null at the end of a list", + ), + MedianTest( + "null_missing_excluded_at_start", + args=[MISSING, 10, 20], + expected=15.0, + msg="$median should exclude missing at the start of a list", + ), + MedianTest( + "null_missing_excluded_at_end", + args=[10, 20, MISSING], + expected=15.0, + msg="$median should exclude missing at the end of a list", + ), + MedianTest( + "null_mixed_with_numeric", + args=[MISSING, None, 30], + expected=30.0, + msg="$median should exclude null and missing, finding median of remaining values", + ), + MedianTest( + "non_numeric_excluded", + args=["hello", None, 10, 20, "world", MISSING], + expected=15.0, + msg="$median should exclude non-numeric strings along with null and missing", + ), +] + +MEDIAN_NULL_ALL_TESTS = MEDIAN_NULL_TESTS + MEDIAN_NULL_EXCLUSION_TESTS + + +@pytest.mark.parametrize("test_case", pytest_params(MEDIAN_NULL_ALL_TESTS)) +def test_median_null(collection, test_case: MedianTest): + """Test $median cases.""" + result = execute_expression( + collection, {"$median": {"input": test_case.args, "method": "approximate"}} + ) + assert_expression_result( + result, + expected=test_case.expected, + error_code=test_case.error_code, + msg=test_case.msg, + ) diff --git a/documentdb_tests/compatibility/tests/core/operator/expressions/accumulator/median/utils/median_common.py b/documentdb_tests/compatibility/tests/core/operator/expressions/accumulator/median/utils/median_common.py new file mode 100644 index 00000000..04fbc0cc --- /dev/null +++ b/documentdb_tests/compatibility/tests/core/operator/expressions/accumulator/median/utils/median_common.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from documentdb_tests.framework.test_case import BaseTestCase + + +@dataclass(frozen=True) +class MedianTest(BaseTestCase): + """Test case for $median operator.""" + + args: Any = None