diff --git a/src/fable-library-py/fable_library/core/option.pyi b/src/fable-library-py/fable_library/core/option.pyi index b0ec30ff99..ef88a73827 100644 --- a/src/fable-library-py/fable_library/core/option.pyi +++ b/src/fable-library-py/fable_library/core/option.pyi @@ -5,7 +5,8 @@ from typing import overload from . import Array class SomeWrapper[T]: - value: T + @property + def value(self) -> T: ... def __init__(self, value: T) -> None: ... def __repr__(self) -> str: ... def __str__(self) -> str: ... diff --git a/src/fable-library-py/src/options.rs b/src/fable-library-py/src/options.rs index 9d59505f12..5121e8ce2a 100644 --- a/src/fable-library-py/src/options.rs +++ b/src/fable-library-py/src/options.rs @@ -67,9 +67,12 @@ impl SomeWrapper { op: CompareOp, py: Python<'py>, ) -> PyResult { + if other.is_none() { + return Ok(matches!(op, CompareOp::Ne | CompareOp::Gt | CompareOp::Ge)); + } + let other_value = extract_value(py, other)?; - let result = self.value.bind(py).rich_compare(other_value, op)?; - result.is_truthy() + compare_values(self.value.bind(py), other_value.bind(py), op) } pub fn __repr__(&self, py: Python<'_>) -> PyResult { @@ -167,6 +170,16 @@ impl SomeWrapper { } } +// A `None` argument here is the inner option's `None` case, not an absent value. +fn compare_values(a: &Bound<'_, PyAny>, b: &Bound<'_, PyAny>, op: CompareOp) -> PyResult { + match (a.is_none(), b.is_none()) { + (true, true) => Ok(matches!(op, CompareOp::Eq | CompareOp::Le | CompareOp::Ge)), + (true, false) => Ok(matches!(op, CompareOp::Ne | CompareOp::Lt | CompareOp::Le)), + (false, true) => Ok(matches!(op, CompareOp::Ne | CompareOp::Gt | CompareOp::Ge)), + (false, false) => a.rich_compare(b, op)?.is_truthy(), + } +} + // Helper function to check if an object is a Some wrapper fn is_some_wrapper(py: Python<'_>, obj: &Bound<'_, PyAny>) -> bool { obj.is_instance(&py.get_type::()) diff --git a/src/fable-library-py/tests/test_option.py b/src/fable-library-py/tests/test_option.py new file mode 100644 index 0000000000..fc6323fb98 --- /dev/null +++ b/src/fable-library-py/tests/test_option.py @@ -0,0 +1,46 @@ +"""Tests for nested options, represented by `fable_library.core.option.SomeWrapper`. + +Only nesting materializes a wrapper: `Some 42` and `Some (Some 42)` are the bare value, +`Some None` is `SomeWrapper(None)`. Two options of the same F# type can therefore put a +wrapper next to `None` or next to a bare value. +""" + +from fable_library.option import some +from fable_library.util import compare, compare_arrays, equal_arrays, equals + + +def test_equality_between_different_nesting_depths() -> None: + assert (some(None) == some(some(None))) is False + assert (some(some(None)) == some(None)) is False + assert (some(None) != some(some(None))) is True + assert equals(some(None), some(some(None))) is False + + +def test_equality_between_equal_nesting_depths() -> None: + assert (some(None) == some(None)) is True + assert (some(some(None)) == some(some(None))) is True + assert equals(some(some(None)), some(some(None))) is True + + +def test_equality_against_none_and_bare_values() -> None: + assert (some(None) == None) is False # noqa: E711 + assert (None == some(None)) is False # noqa: E711 + assert (some(None) == 42) is False + assert (42 == some(None)) is False + + +def test_equality_is_inherited_by_containers() -> None: + assert equal_arrays([some(None)], [some(some(None))]) is False + assert equal_arrays([some(None)], [some(None)]) is True + assert compare_arrays([some(None)], [some(some(None))]) == -1 + + +def test_comparison_orders_none_before_some() -> None: + assert compare(some(None), some(some(None))) == -1 + assert compare(some(some(None)), some(None)) == 1 + assert compare(some(None), some(None)) == 0 + assert compare(None, some(None)) == -1 + assert compare(some(None), None) == 1 + # `int option option`: `Some None` against `Some (Some 42)` + assert compare(some(None), 42) == -1 + assert compare(42, some(None)) == 1 diff --git a/tests/Python/TestOption.fs b/tests/Python/TestOption.fs index f776bdb992..f15d8b3745 100644 --- a/tests/Python/TestOption.fs +++ b/tests/Python/TestOption.fs @@ -191,6 +191,38 @@ let ``test Option.map/map2/map3/bind/filter with nested options work`` () = Some(Some 5) |> Option.bind (fun inner -> Some inner) |> equal (Some(Some 5)) Some(Some 5) |> Option.filter Option.isSome |> equal (Some(Some 5)) +[] +let ``test equality on nested options of different depth works`` () = + let a: int option option option = Some None + let b: int option option option = Some(Some None) + (a = b) |> equal false + (b = a) |> equal false + (a = a) |> equal true + (b = b) |> equal true + notEqual a b + let ra: Result = Ok a + let rb: Result = Ok b + (ra = rb) |> equal false + notEqual ra rb + let c: int option option = Some None + let d: int option option = Some(Some 42) + (c = d) |> equal false + (d = c) |> equal false + +[] +let ``test comparison on nested options of different depth works`` () = + let a: int option option option = Some None + let b: int option option option = Some(Some None) + compare a b |> equal -1 + compare b a |> equal 1 + compare a a |> equal 0 + compare (None: int option option option) a |> equal -1 + compare a (None: int option option option) |> equal 1 + let c: int option option = Some None + let d: int option option = Some(Some 42) + compare c d |> equal -1 + compare d c |> equal 1 + [] let ``test ValueOption.map/map2/map3/bind/filter/defaultWith/orElseWith work`` () = ValueSome 5 |> ValueOption.map (fun x -> x + 1) |> equal (ValueSome 6)