|
| 1 | +/// Which operators the Terminal rebinds, and what that costs. |
| 2 | +/// https://github.com/asc-community/AngouriMath/issues/1133 |
| 3 | +module AngouriMath.Terminal.OperatorsAtThePromptTest |
| 4 | + |
| 5 | +open Xunit |
| 6 | +open AngouriMath.Terminal.Lib.FSharpInteractive |
| 7 | +open AngouriMath.Terminal.Lib.PreRunCode |
| 8 | +open AngouriMath.Terminal.Lib.Consts |
| 9 | + |
| 10 | +let private newKernel () = |
| 11 | + let k = |
| 12 | + match createKernel () with |
| 13 | + | Result.Error _ -> |
| 14 | + Assert.True(false, "the kernel did not load") |
| 15 | + raise (System.Exception()) |
| 16 | + | Result.Ok k -> k |
| 17 | + match enableAngouriMath k with |
| 18 | + | ExecutionResult.Error reason -> |
| 19 | + Assert.True(false, $"AngouriMath did not load: {reason}") |
| 20 | + raise (System.Exception()) |
| 21 | + | _ -> k |
| 22 | + |
| 23 | +/// One kernel for the cases that only read, for the reason CompilerServiceWorksTest gives: a |
| 24 | +/// kernel starts a compiler service and loads AngouriMath into it, and doing that once per case |
| 25 | +/// was thirteen full loads in this class alone. The cases that change a session — the two that |
| 26 | +/// `open` something — take a fresh one each, because that is the whole of what they are testing. |
| 27 | +let private shared = lazy (newKernel ()) |
| 28 | + |
| 29 | +let private answers (k: Microsoft.DotNet.Interactive.FSharp.FSharpKernel) (code: string) (expected: string) = |
| 30 | + match execute k code with |
| 31 | + | ExecutionResult.PlainTextSuccess actual -> Assert.Equal(expected, actual) |
| 32 | + | ExecutionResult.LatexSuccess (_, actual) -> Assert.Equal(expected, actual) |
| 33 | + | other -> Assert.True(false, $"expected {expected}, and the prompt answered {other}") |
| 34 | + |
| 35 | +/// The Terminal rebinds the five arithmetic operators the wiki says it rebinds, and no longer |
| 36 | +/// rebinds the comparisons, which it never said it did. |
| 37 | +/// |
| 38 | +/// The comparisons cost ordinary F#: `if`, `while`, `List.filter` and a `match` guard all want a |
| 39 | +/// `bool` and were getting an `Entity`, so a large part of the language did not typecheck at a |
| 40 | +/// prompt whose whole purpose is to be typed at. |
| 41 | +[<Theory>] |
| 42 | +[<InlineData("if 1 < 2 then 1 else 0", "1")>] |
| 43 | +[<InlineData("[1;2;3] |> List.filter (fun x -> x > 1)", "[2; 3]")>] |
| 44 | +[<InlineData("let b: bool = 1 < 2\nb", "True")>] |
| 45 | +[<InlineData("seq { 1..100 } |> Seq.filter (fun x -> x % 7 = 0) |> Seq.length", "14")>] |
| 46 | +[<InlineData("let (|Even|Odd|) n = if n % 2 = 0 then Even else Odd\nmatch 4 with Even -> \"e\" | Odd -> \"o\"", "e")>] |
| 47 | +let ``Ordinary F# with a comparison in it compiles`` (code: string) (expected: string) = |
| 48 | + answers shared.Value code expected |
| 49 | + |
| 50 | +/// And the reading the Terminal exists for is untouched: arithmetic on anything still builds an |
| 51 | +/// expression, which is what the wiki documents and what makes this a symbolic prompt rather than |
| 52 | +/// an F# one. |
| 53 | +[<Theory>] |
| 54 | +[<InlineData("x + 1", "x + 1")>] |
| 55 | +[<InlineData("3 / 2", "3/2")>] |
| 56 | +[<InlineData("x ** 2", "x ^ 2")>] |
| 57 | +[<InlineData("solutions \"x\" \"x2 - 5x + 6 = 0\"", "{ 2, 3 }")>] |
| 58 | +[<InlineData("derivative \"x\" \"x3\"", "3 * x ^ 2")>] |
| 59 | +let ``The symbolic reading of arithmetic survives`` (code: string) (expected: string) = |
| 60 | + answers shared.Value code expected |
| 61 | + |
| 62 | +/// What narrowing costs, stated rather than glossed: a bare `x > 0` no longer builds an |
| 63 | +/// inequality. It is one line to get back, and that line is the whole of the old behaviour. |
| 64 | +[<Fact>] |
| 65 | +let ``A comparison on symbols is one open away`` () = |
| 66 | + let k = newKernel () |
| 67 | + |
| 68 | + match execute k "x > 0" with |
| 69 | + | ExecutionResult.Error message -> Assert.Contains("comparison", message) |
| 70 | + | other -> Assert.True(false, $"expected `x > 0` to be an ordinary comparison now, got {other}") |
| 71 | + |
| 72 | + match execute k "open AngouriMath.Interactive.AggressiveOperators" with |
| 73 | + | ExecutionResult.VoidSuccess -> () |
| 74 | + | other -> Assert.True(false, $"opening the aggressive operators answered {other}") |
| 75 | + |
| 76 | + answers k "x > 0" "x > 0" |
| 77 | + |
| 78 | +/// <summary> |
| 79 | +/// <c>fib</c> compiles, which is what #1133 asked for — but not by default, and the difference is |
| 80 | +/// the point rather than a caveat. |
| 81 | +/// </summary> |
| 82 | +/// <remarks> |
| 83 | +/// <para> |
| 84 | +/// Rebinding arithmetic and writing ordinary F# are <b>mutually exclusive</b>, and that was |
| 85 | +/// measured rather than assumed. With F#'s own operators, <c>fib 20</c> is 6765 and <c>3 / 2</c> is |
| 86 | +/// <c>1</c>; with arithmetic rebound, <c>3 / 2</c> is <c>3/2</c> and <c>x + 1</c> is an expression |
| 87 | +/// while <c>fib</c> does not typecheck. Both readings of <c>/</c> cannot be the return type of one |
| 88 | +/// operator. |
| 89 | +/// </para> |
| 90 | +/// <para> |
| 91 | +/// An overload that keeps <c>int + int</c> an <c>int</c> was tried, as a witness-constrained SRTP |
| 92 | +/// operator, and it does resolve the simple cases — <c>1 + 1</c> is 2, <c>x + 1</c> is an |
| 93 | +/// expression, <c>1 + 4.5</c> is <c>11/2</c>, all at once. It does not resolve <c>let rec</c>: the |
| 94 | +/// operator has to be picked while the function's own type is still being inferred, so it falls to |
| 95 | +/// the general case and <c>fib</c> fails anyway. A type annotation does not help, which was checked |
| 96 | +/// rather than assumed. |
| 97 | +/// </para> |
| 98 | +/// <para> |
| 99 | +/// So the answer is that a session says which reading it wants. <c>operators ()</c> at the prompt |
| 100 | +/// prints the two lines, and either can follow the other any number of times. |
| 101 | +/// </para> |
| 102 | +/// </remarks> |
| 103 | +[<Fact>] |
| 104 | +let ``Ordinary F# is one open away, and reversible`` () = |
| 105 | + let k = newKernel () |
| 106 | + |
| 107 | + // As the prompt starts: expressions, and fib does not compile. |
| 108 | + answers k "x + 1" "x + 1" |
| 109 | + answers k "3 / 2" "3/2" |
| 110 | + match execute k "let rec fib n = if n < 2 then n else fib (n-1) + fib (n-2)\nfib 20" with |
| 111 | + | ExecutionResult.Error _ -> () |
| 112 | + | other -> Assert.True(false, $"fib compiles by default now, so this test is out of date: {other}") |
| 113 | + |
| 114 | + // One line, and it does. |
| 115 | + match execute k "open Microsoft.FSharp.Core.Operators" with |
| 116 | + | ExecutionResult.VoidSuccess -> () |
| 117 | + | other -> Assert.True(false, $"restoring F#'s operators answered {other}") |
| 118 | + answers k "let rec fib n = if n < 2 then n else fib (n-1) + fib (n-2)\nfib 20" "6765" |
| 119 | + answers k "[ for i in 1..10 -> i * i ] |> List.sum" "385" |
| 120 | + answers k "3 / 2" "1" |
| 121 | + |
| 122 | + // And back, in the same session. |
| 123 | + match execute k "open AngouriMath.Interactive.ArithmeticOperators" with |
| 124 | + | ExecutionResult.VoidSuccess -> () |
| 125 | + | other -> Assert.True(false, $"going back answered {other}") |
| 126 | + answers k "x + 1" "x + 1" |
| 127 | + answers k "3 / 2" "3/2" |
| 128 | + |
| 129 | +/// And the prompt says so, rather than leaving it to be discovered. |
| 130 | +[<Fact>] |
| 131 | +let ``The prompt explains which operators it has`` () = |
| 132 | + // It prints rather than returns, so what is asserted is that it runs and says nothing back: |
| 133 | + // a returned sentence would go to AngouriMath's formatter, which would try to parse it. |
| 134 | + match execute shared.Value "operators ()" with |
| 135 | + | ExecutionResult.VoidSuccess -> () |
| 136 | + | other -> Assert.True(false, $"operators () answered {other}") |
0 commit comments