diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 0000000..b6c47b2 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,51 @@ +name: Fuzz + +# Property-based fuzz tests (tagged :fuzz) run on a schedule rather than as a +# PR/push gate. They use a fresh random seed each run to DISCOVER inputs that +# crash a function, so they can legitimately go red — which makes them a poor +# merge gate but a good nightly signal. When this job fails, ExUnit prints the +# seed and StreamData prints the shrunk failing input; reproduce locally with +# `mix test --only fuzz --seed `. +on: + schedule: + # 03:00 UTC daily + - cron: "0 3 * * *" + workflow_dispatch: {} + +jobs: + fuzz: + name: Run fuzz tests + runs-on: ubuntu-latest + env: + # Explore more of the input space than the local default (100); this job + # is not latency-bound. + FUZZ_MAX_RUNS: "1000" + strategy: + matrix: + include: + - elixir: "1.19" + otp: "28" + cache_version: ["4"] + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - name: Set up Elixir + uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1 + with: + elixir-version: ${{ matrix.elixir }} + otp-version: ${{ matrix.otp }} + - name: Restore dependencies cache + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + id: mix-cache + with: + path: deps + key: ${{matrix.cache_version}}-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-mix-${{ hashFiles('**/mix.lock') }} + restore-keys: ${{matrix.cache_version}}-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-mix- + - name: Install Mix Dependencies + if: steps.mix-cache.outputs.cache-hit != 'true' + run: | + mix local.rebar --force + mix local.hex --force + mix deps.get + - name: Run fuzz tests + run: mix test --only fuzz diff --git a/CLAUDE.md b/CLAUDE.md index f8643ca..e022737 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,3 +67,10 @@ Expressions preserve types through evaluation (integers, floats, booleans, DateT ## Testing CI tests against Elixir 1.15/OTP 26, 1.18/OTP 27, and 1.19/OTP 28. Format checking runs only on 1.19/OTP 28. + +Beyond `@expression_doc` doctests and `expression_test.exs`, the suite includes +systematic **type-matrix** tests per function category +(`test/*_functions_type_test.exs`) and **property-based fuzz** tests +(`test/expression_fuzz_test.exs`, tagged `:fuzz`, excluded from `mix test` — +run with `mix test --only fuzz`). These document current V1 behavior (pinning +crashes, not endorsing them). See `TESTING.md` for the full approach. diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..3ca959b --- /dev/null +++ b/TESTING.md @@ -0,0 +1,179 @@ +# Testing Guide + +This document describes how the Expression library is tested and how to add +tests when you change or add a function. + +## Test layout + +| Location | Purpose | +|----------|---------| +| `@expression_doc` annotations in `lib/expression/callbacks/standard.ex` | Happy-path examples that double as doctests | +| `test/expression_test.exs` | Core engine + hand-written edge cases (incl. the pilot type tests for `upper`, `lower`, `abs`, `round`, `date`) | +| `test/*_functions_type_test.exs` | Systematic **type-matrix** tests, one file per function category | +| `test/crash_safe_classification_test.exs` | Deterministic guard that the crash-safe set never raises across the type matrix | +| `test/expression_fuzz_test.exs` | Property-based **crash-safety** tests (tagged `:fuzz`) | +| `test/support/*.ex` | Reusable test helpers (compiled via `elixirc_paths` for `:test`) | + +The category type-test files are: + +- `test/string_functions_type_test.exs` +- `test/number_functions_type_test.exs` +- `test/date_functions_type_test.exs` +- `test/logical_functions_type_test.exs` +- `test/enum_functions_type_test.exs` + +## Running tests + +```bash +# Everything except fuzz tests (the default, fast suite) +mix test + +# A single file +mix test test/string_functions_type_test.exs + +# A single test by line number +mix test test/string_functions_type_test.exs:42 + +# Watch mode during development +mix test.watch + +# Property-based fuzz tests ONLY (see "Fuzz tests" below) +mix test --only fuzz +``` + +Fuzz tests are **excluded from the default suite** via +`ExUnit.start(exclude: [:fuzz])` in `test/test_helper.exs`. You opt into them +explicitly with `--only fuzz`. + +## Guiding philosophy: document current behavior + +The type-test and fuzz files **document the V1 engine's actual behavior — they +do not endorse it.** Many functions raise on unexpected input rather than +returning an error map. Where that happens, the test pins the exact exception +so that any future change to the behavior is deliberate, not accidental: + +```elixir +test "nil raises ArithmeticError" do + # Known crash behavior, documented not endorsed: Kernel.rem/2 with nil. + assert_raise ArithmeticError, fn -> evaluate_with_value("rem(value, 3)", nil) end +end +``` + +When you read `# Known crash behavior, documented not endorsed: ...` or +`# Surprising: ...` in a test, it is recording a real, observed behavior — not +the behavior we wish the function had. If you *fix* such a behavior, update the +corresponding test to assert the new (better) result. + +## Test support helpers + +### `Expression.Test.TypeTestMatrix` + +Imported by every type-test file. Provides a canonical set of sample values for +each runtime type, plus convenience evaluators: + +- `evaluate_with_value(expr, value, extra_context \\ %{})` — evaluate a block + expression (no leading `@`) with `value` bound to the `value` key. +- `complex_value(value, extra \\ %{})` — build a "complex" map carrying a + `__value__` key, as produced by flow results. +- `error_value(message \\ ...)` — build a V1 error map. +- `all_test_values/0`, `test_values_for/1`, `invalid_values_for/1` — the matrix + values themselves. + +### `Expression.Test.FuzzHelpers` + +Imported by the fuzz file. Provides `StreamData` generators (`any_value/0`, +`string_value/0`, `list_value/0`, `enumerable_value/0`, …) and +`assert_no_crash/2`, which fails only if evaluating an expression *raises* or +`throw`s/`exit`s (returning an error map is fine). + +### `Expression.Test.CrashSafe` + +The single source of truth for the crash-safe function set. `groups/0`, +`group/1`, and `all/0` return `{label, expression}` entries consumed by both the +deterministic classification test and the fuzz suite. + +## The context-coercion gotcha + +`Expression.Context` coerces context values **before callbacks see them**: + +- numeric-looking strings (`"123"`, `"3.14"`) become numbers, +- ISO-date-looking strings become `Date`/`DateTime`/`Time` structs, +- `"true"`/`"false"` become booleans. + +So `evaluate_with_value("upper(value)", "123")` does **not** pass the string +`"123"` to `upper` — it passes the integer `123`. To test a function with a +genuine string argument, embed a **string literal in the expression source**: + +```elixir +# context-coerced: "123" -> 123 before the callback +evaluate_with_value("fixed(value, 2)", "123") # => "123.00" + +# literal string survives to the callback +Expression.evaluate_block!(~s|fixed("3.14", 2)|) # => "3.14" +``` + +## Fuzz tests + +`test/expression_fuzz_test.exs` enforces a single invariant: **the function must +never raise** on arbitrary input (returning a value or an error map is fine). + +The V1 engine does **not** uphold this invariant universally, so the suite +fuzzes only the subset of functions empirically confirmed crash-safe across the +whole type matrix. That subset is defined once in `Expression.Test.CrashSafe` +(`test/support/crash_safe.ex`) and consumed by two suites: + +- `test/crash_safe_classification_test.exs` — a **deterministic** guard (runs in + the default suite, every CI build) that evaluates each crash-safe function + against the full type matrix and fails if any raises. This is what pins the + classification; it cannot drift on a lucky seed. +- `test/expression_fuzz_test.exs` — random exploration on top, for inputs the + fixed matrix doesn't contain. + +Functions that currently *do* crash are not in `CrashSafe`; each is pinned with +its exact exception in the relevant `*_functions_type_test.exs` file. + +These tests are **not** a merge gate. They use a random seed each run and exist +to *discover* new crashing inputs, so they can legitimately go red when they +find one — useful as a signal, but a poor fit for a branch-protection check. +Instead they run on a schedule via `.github/workflows/fuzz.yml` (nightly, plus a +manual "Run workflow" button). Run them locally with: + +```bash +mix test --only fuzz + +# Search deeper (the scheduled job uses 1000 generations per property): +FUZZ_MAX_RUNS=1000 mix test --only fuzz +``` + +When a run fails, ExUnit prints the seed and StreamData prints the shrunk +failing input. Reproduce it deterministically with: + +```bash +mix test --only fuzz --seed +``` + +If you harden a known-crashing function so it returns an error map instead of +raising, add it to the appropriate category in `Expression.Test.CrashSafe` (and +update its type test). Both the deterministic guard and the fuzz suite pick it +up automatically. + +## Adding tests for a new function + +When you add a function to `Expression.Callbacks.Standard`: + +1. **Add `@expression_doc` examples** in the source for the happy path. These + run as doctests automatically. +2. **Add type-matrix tests** to the matching `test/_functions_type_test.exs` + file. Cover, at minimum: + - `nil`, + - the type(s) the function is meant to accept, + - wrong types (numbers, strings, booleans, lists, maps), + - a complex value via `complex_value/1` to confirm `__value__` extraction, + - any function-specific edge cases (empty string/list, unicode, zero, + negatives, leap years, …). + **Discover the real behavior by running it** — do not guess. If it crashes, + pin the exact exception with `assert_raise` and a `# Known crash behavior` + comment. +3. **If the function is crash-safe**, add it to the relevant `@crash_safe_*` + list in `test/expression_fuzz_test.exs` so the no-crash invariant is guarded. +4. Run `mix format` and make sure `mix test` is green. diff --git a/mix.exs b/mix.exs index 3076992..2858455 100644 --- a/mix.exs +++ b/mix.exs @@ -9,6 +9,7 @@ defmodule Expression.MixProject do aliases: aliases(), version: @version, elixir: "~> 1.13", + elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, deps: deps(), package: package(), @@ -32,6 +33,10 @@ defmodule Expression.MixProject do [plt_file: {:no_warn, "priv/plts/expression.plt"}, ignore_warnings: ".dialyzer_ignore.exs"] end + # Test support modules live in test/support and are only compiled for :test. + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + # Run "mix help compile.app" to learn about applications. def application do [ @@ -51,6 +56,7 @@ defmodule Expression.MixProject do {:mix_test_watch, "~> 1.0", only: :dev, runtime: false}, {:nimble_parsec, "~> 1.1"}, {:number, "~> 1.0"}, + {:stream_data, "~> 1.0", only: [:test, :dev]}, {:decimal, "~> 2.0"}, {:timex, "~> 3.7"} ] diff --git a/mix.lock b/mix.lock index 390f44c..d29c457 100644 --- a/mix.lock +++ b/mix.lock @@ -28,6 +28,7 @@ "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "statistex": {:hex, :statistex, "1.1.0", "7fec1eb2f580a0d2c1a05ed27396a084ab064a40cfc84246dbfb0c72a5c761e5", [:mix], [], "hexpm", "f5950ea26ad43246ba2cce54324ac394a4e7408fdcf98b8e230f503a0cba9cf5"}, + "stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"}, "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, "timex": {:hex, :timex, "3.7.13", "0688ce11950f5b65e154e42b47bf67b15d3bc0e0c3def62199991b8a8079a1e2", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "09588e0522669328e973b8b4fd8741246321b3f0d32735b589f78b136e6d4c54"}, "tzdata": {:hex, :tzdata, "1.1.3", "b1cef7bb6de1de90d4ddc25d33892b32830f907e7fc2fccd1e7e22778ab7dfbc", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d4ca85575a064d29d4e94253ee95912edfb165938743dbf002acdf0dcecb0c28"}, diff --git a/test/crash_safe_classification_test.exs b/test/crash_safe_classification_test.exs new file mode 100644 index 0000000..a406a85 --- /dev/null +++ b/test/crash_safe_classification_test.exs @@ -0,0 +1,32 @@ +defmodule CrashSafeClassificationTest do + @moduledoc """ + Deterministic guard for the crash-safe classification in + `Expression.Test.CrashSafe`. + + `ExpressionFuzzTest` explores the same set with random inputs, but it is + excluded from the default suite and uses a fresh seed each run. This test pins + the classification deterministically: every function claimed crash-safe is + evaluated against the entire type matrix on every CI run, and must return a + value or error map rather than raising. If someone adds a function to + `CrashSafe` that actually crashes on a matrix value, this fails immediately — + no random seed required. + """ + use ExUnit.Case, async: true + + import Expression.Test.TypeTestMatrix + alias Expression.Test.CrashSafe + + for {label, expr} <- CrashSafe.all() do + test "#{label} never raises across the full type matrix" do + values = all_test_values() + + results = + for value <- values do + # Raising here fails the test; a value or error map is acceptable. + Expression.evaluate_block!(unquote(expr), %{"value" => value}) + end + + assert length(results) == length(values) + end + end +end diff --git a/test/date_functions_type_test.exs b/test/date_functions_type_test.exs new file mode 100644 index 0000000..325f9e2 --- /dev/null +++ b/test/date_functions_type_test.exs @@ -0,0 +1,621 @@ +defmodule DateFunctionsTypeTest do + @moduledoc """ + Systematic type-matrix tests for the "date" category of expression functions. + + These tests DOCUMENT CURRENT BEHAVIOR of the V1 engine, including crashes. + Tests marked "Known crash behavior, documented not endorsed" capture cases + where a function raises instead of returning an error map. They exist so that + any change to these behaviors is deliberate, not accidental. + + Note on context coercion: `Expression.Context` coerces context values before + callbacks see them. ISO8601-looking strings become Date/DateTime/Time structs + and numeric strings become numbers. To exercise a function with an actual + string argument the string must be embedded as a literal in the expression. + + Covered: datevalue, parse_datevalue, day, month, year, hour, minute, second, + time, timevalue, datetime_add, datetime_from_unix, weekday, edate, now, today. + Excluded: date/3 (pilot-covered in expression_test.exs). + """ + use ExUnit.Case, async: true + + import Expression.Test.TypeTestMatrix + + describe "day/1 type handling" do + test "extracts day from Date, DateTime and NaiveDateTime" do + assert 15 == evaluate_with_value("day(value)", ~D[2023-06-15]) + assert 15 == evaluate_with_value("day(value)", ~U[2023-06-15 10:30:00Z]) + assert 15 == evaluate_with_value("day(value)", ~N[2023-06-15 10:30:00]) + end + + test "works on leap day" do + assert 29 == evaluate_with_value("day(value)", ~D[2024-02-29]) + end + + test "date-looking string passed via context is coerced to a Date first" do + assert 15 == evaluate_with_value("day(value)", "2023-01-15") + end + + test "literal string raises MatchError" do + # Known crash behavior, documented not endorsed: day/1 pattern matches + # %{day: day} against its argument; a raw string (which bypasses context + # coercion) does not match and raises MatchError. + assert_raise MatchError, fn -> + Expression.evaluate_block!(~s|day("2023-01-15")|) + end + end + + test "extracts day from complex value's __value__ key" do + assert 15 == evaluate_with_value("day(value)", complex_value(~D[2023-06-15])) + end + + test "nil raises MatchError" do + # Known crash behavior, documented not endorsed: nil does not match the + # %{day: day} pattern, so day(nil) raises MatchError. + assert_raise MatchError, fn -> evaluate_with_value("day(value)", nil) end + end + + test "non-date garbage raises MatchError" do + # Known crash behavior, documented not endorsed: integers, booleans, + # lists, Time structs and string-keyed maps all fail the %{day: day} + # pattern match and raise. + for garbage <- [42, true, [1, 2], %{"day" => 9}, ~T[10:30:00]] do + assert_raise MatchError, fn -> evaluate_with_value("day(value)", garbage) end + end + end + end + + describe "month/1 type handling" do + test "extracts month from Date, DateTime and NaiveDateTime" do + assert 6 == evaluate_with_value("month(value)", ~D[2023-06-15]) + assert 6 == evaluate_with_value("month(value)", ~U[2023-06-15 10:30:00Z]) + assert 6 == evaluate_with_value("month(value)", ~N[2023-06-15 10:30:00]) + end + + test "date-looking string passed via context is coerced to a Date first" do + assert 1 == evaluate_with_value("month(value)", "2023-01-15") + end + + test "literal string raises MatchError" do + # Known crash behavior, documented not endorsed: month/1 pattern matches + # %{month: month}; raw strings do not match and raise MatchError. + assert_raise MatchError, fn -> + Expression.evaluate_block!(~s|month("2023-01-15")|) + end + end + + test "extracts month from complex value's __value__ key" do + assert 6 == evaluate_with_value("month(value)", complex_value(~D[2023-06-15])) + end + + test "nil raises MatchError" do + # Known crash behavior, documented not endorsed: nil fails the + # %{month: month} pattern match. + assert_raise MatchError, fn -> evaluate_with_value("month(value)", nil) end + end + + test "non-date garbage raises MatchError" do + # Known crash behavior, documented not endorsed: Time has no month field + # and scalars/collections fail the map pattern match. + for garbage <- [42, true, [1, 2], %{"a" => 1}, ~T[10:30:00]] do + assert_raise MatchError, fn -> evaluate_with_value("month(value)", garbage) end + end + end + end + + describe "year/1 type handling" do + test "extracts year from Date and DateTime" do + assert 2023 == evaluate_with_value("year(value)", ~D[2023-06-15]) + assert 2023 == evaluate_with_value("year(value)", ~U[2023-06-15 10:30:00Z]) + end + + test "NaiveDateTime raises FunctionClauseError" do + # Known crash behavior, documented not endorsed: unlike day/1 and + # month/1, year/1 routes through DateHelpers.extract_dateish/1 which has + # no clause for NaiveDateTime. + assert_raise FunctionClauseError, fn -> + evaluate_with_value("year(value)", ~N[2023-06-15 10:30:00]) + end + end + + test "literal date string is parsed (unlike day/month)" do + # year/1 uses extract_dateish, which parses date strings itself, so the + # literal-string form works here even though it crashes for day/month. + assert 2023 == Expression.evaluate_block!(~s|year("2023-01-15")|) + end + + test "non-date string raises MatchError" do + # Known crash behavior, documented not endorsed: extract_dateish returns + # nil for unparseable strings, which then fails the %{year: year} match. + assert_raise MatchError, fn -> + Expression.evaluate_block!(~s|year("not a date")|) + end + end + + test "extracts year from complex value's __value__ key" do + assert 2023 == evaluate_with_value("year(value)", complex_value(~D[2023-06-15])) + end + + test "nil raises MatchError" do + # Known crash behavior, documented not endorsed: extract_dateish(nil) + # returns nil, failing the subsequent %{year: year} pattern match. + assert_raise MatchError, fn -> evaluate_with_value("year(value)", nil) end + end + + test "non-date garbage raises FunctionClauseError" do + # Known crash behavior, documented not endorsed: extract_dateish has no + # clause for integers, booleans or Time structs. + for garbage <- [42, true, ~T[10:30:00]] do + assert_raise FunctionClauseError, fn -> + evaluate_with_value("year(value)", garbage) + end + end + end + end + + describe "hour/1 type handling" do + test "extracts hour from DateTime, NaiveDateTime and Time" do + assert 13 == evaluate_with_value("hour(value)", ~U[2023-06-15 13:45:30Z]) + assert 13 == evaluate_with_value("hour(value)", ~N[2023-06-15 13:45:30]) + assert 13 == evaluate_with_value("hour(value)", ~T[13:45:30]) + end + + test "Date raises MatchError" do + # Known crash behavior, documented not endorsed: hour/1 pattern matches + # %{hour: hour} directly; Date has no hour field. + assert_raise MatchError, fn -> evaluate_with_value("hour(value)", ~D[2023-06-15]) end + end + + test "extracts hour from complex value's __value__ key" do + assert 13 == evaluate_with_value("hour(value)", complex_value(~U[2023-06-15 13:45:30Z])) + end + + test "nil raises MatchError" do + # Known crash behavior, documented not endorsed: nil fails the + # %{hour: hour} pattern match. + assert_raise MatchError, fn -> evaluate_with_value("hour(value)", nil) end + end + + test "non-date garbage raises MatchError" do + # Known crash behavior, documented not endorsed: atom-keyed pattern match + # rejects scalars, lists and string-keyed maps alike. + for garbage <- [42, true, [], %{"hour" => 5}] do + assert_raise MatchError, fn -> evaluate_with_value("hour(value)", garbage) end + end + end + end + + describe "minute/1 type handling" do + test "extracts minute from DateTime" do + assert 45 == evaluate_with_value("minute(value)", ~U[2023-06-15 13:45:30Z]) + end + + test "Date is upgraded to midnight, returning 0" do + # minute/1 routes through extract_datetimeish, which converts a Date to + # a midnight DateTime instead of crashing as hour/1 does. + assert 0 == evaluate_with_value("minute(value)", ~D[2023-06-15]) + end + + test "Time raises MatchError, unlike hour/1 and second/1" do + # Known crash behavior, documented not endorsed: extract_datetimeish has + # no clause for Time (returns nil), so minute(~T[...]) raises even though + # hour/1 and second/1 accept Time structs. Asymmetric by accident. + assert_raise MatchError, fn -> evaluate_with_value("minute(value)", ~T[13:45:30]) end + end + + test "NaiveDateTime raises MatchError" do + # Known crash behavior, documented not endorsed: extract_datetimeish's + # catch-all returns nil for NaiveDateTime. + assert_raise MatchError, fn -> + evaluate_with_value("minute(value)", ~N[2023-06-15 13:45:30]) + end + end + + test "extracts minute from complex value's __value__ key" do + assert 45 == evaluate_with_value("minute(value)", complex_value(~U[2023-06-15 13:45:30Z])) + end + + test "nil and non-date garbage raise MatchError" do + # Known crash behavior, documented not endorsed: extract_datetimeish + # returns nil for these inputs, failing the %{minute: minute} match. + for garbage <- [nil, 42, true, []] do + assert_raise MatchError, fn -> evaluate_with_value("minute(value)", garbage) end + end + end + end + + describe "second/1 type handling" do + test "extracts second from DateTime, NaiveDateTime and Time" do + assert 30 == evaluate_with_value("second(value)", ~U[2023-06-15 13:45:30Z]) + assert 30 == evaluate_with_value("second(value)", ~N[2023-06-15 13:45:30]) + assert 30 == evaluate_with_value("second(value)", ~T[13:45:30]) + end + + test "Date raises MatchError" do + # Known crash behavior, documented not endorsed: second/1 pattern matches + # %{second: second} directly; Date has no second field. + assert_raise MatchError, fn -> evaluate_with_value("second(value)", ~D[2023-06-15]) end + end + + test "extracts second from complex value's __value__ key" do + assert 30 == evaluate_with_value("second(value)", complex_value(~U[2023-06-15 13:45:30Z])) + end + + test "nil and non-date garbage raise MatchError" do + # Known crash behavior, documented not endorsed: nil and scalars fail + # the %{second: second} pattern match. + for garbage <- [nil, 42, true, []] do + assert_raise MatchError, fn -> evaluate_with_value("second(value)", garbage) end + end + end + end + + describe "datevalue/1 and datevalue/2 type handling" do + test "literal date string returns a map with __value__, date and datetime" do + assert %{ + "__value__" => "2022-01-01 00:00:00", + "date" => ~D[2022-01-01], + "datetime" => ~U[2022-01-01 00:00:00Z] + } == Expression.evaluate_block!(~s|datevalue("2022-01-01")|) + end + + test "Date input is upgraded to a midnight DateTime" do + result = evaluate_with_value("datevalue(value)", ~D[2022-01-01]) + assert result["__value__"] == "2022-01-01 00:00:00" + assert result["date"] == ~D[2022-01-01] + # Microsecond precision differs from a plain ~U sigil, so compare fields. + assert %DateTime{year: 2022, month: 1, day: 1, hour: 0} = result["datetime"] + end + + test "DateTime input preserves the time component" do + result = evaluate_with_value("datevalue(value)", ~U[2022-01-01 10:30:00Z]) + assert result["__value__"] == "2022-01-01 10:30:00" + assert result["datetime"] == ~U[2022-01-01 10:30:00Z] + end + + test "custom strftime format is applied to __value__" do + result = evaluate_with_value(~s|datevalue(value, "%d/%m/%Y")|, ~U[2022-01-31 10:30:00Z]) + assert result["__value__"] == "31/01/2022" + assert result["date"] == ~D[2022-01-31] + end + + test "datevalue/1 raises ArgumentError on nil, non-dates and bad strings" do + # Known crash behavior, documented not endorsed: extract_datetimeish + # returns nil for these inputs and datevalue/1 then calls + # Timex.format!(nil, ...) which raises ArgumentError (:invalid_date). + # NaiveDateTime is also rejected by extract_datetimeish's catch-all. + for garbage <- [nil, 42, true, [], ~N[2022-01-01 10:30:00]] do + assert_raise ArgumentError, fn -> evaluate_with_value("datevalue(value)", garbage) end + end + + assert_raise ArgumentError, fn -> + Expression.evaluate_block!(~s|datevalue("not a date")|) + end + end + + test "datevalue/2 silently returns nil on invalid input, unlike datevalue/1" do + # The /2 arity guards with `if datetime = extract_datetimeish(...)` and + # has no else branch, so the same inputs that crash /1 return nil here. + assert nil == evaluate_with_value(~s|datevalue(value, "%Y")|, nil) + assert nil == evaluate_with_value(~s|datevalue(value, "%Y")|, 42) + end + + test "extracts date from complex value's __value__ key" do + result = evaluate_with_value("datevalue(value)", complex_value(~D[2022-01-01])) + assert result["date"] == ~D[2022-01-01] + end + end + + describe "parse_datevalue/2 type handling" do + test "parses an ISO8601 string with a matching strftime format" do + assert ~U[2016-02-29 22:25:00Z] == + Expression.evaluate_block!( + ~s|parse_datevalue("2016-02-29T22:25:00-00:00", "%FT%T%:z")| + ) + end + + test "parses a leap day date-only format to midnight UTC" do + assert ~U[2024-02-29 00:00:00Z] == + Expression.evaluate_block!(~s|parse_datevalue("2024-02-29", "%Y-%m-%d")|) + end + + test "returns nil when the string does not match the format" do + assert nil == Expression.evaluate_block!(~s|parse_datevalue("garbage", "%FT%T%:z")|) + end + + test "returns nil for nil and non-string garbage" do + # Timex.parse returns {:error, _} for non-binary input rather than + # raising, so every garbage input maps to nil. + for garbage <- [nil, 42, true, [], ~D[2023-06-15]] do + assert nil == evaluate_with_value(~s|parse_datevalue(value, "%FT%T%:z")|, garbage) + end + end + + test "extracts string from complex value's __value__ key" do + assert ~U[2016-02-29 22:25:00Z] == + evaluate_with_value( + ~s|parse_datevalue(value, "%FT%T%:z")|, + complex_value("2016-02-29T22:25:00-00:00") + ) + end + end + + describe "time/3 type handling" do + test "builds a Time from integer arguments" do + assert ~T[12:13:14] == Expression.evaluate_block!("time(12, 13, 14)") + end + + test "extracts integers from complex values' __value__ keys" do + assert ~T[12:13:14] == evaluate_with_value("time(value, 13, 14)", complex_value(12)) + end + + test "performs no range validation, returning an invalid Time struct" do + # Known crash behavior, documented not endorsed: time/3 builds the Time + # struct directly (no Time.new!), so out-of-range values produce a struct + # that crashes later, e.g. when inspected or converted to a string. + assert %Time{hour: 25, minute: 99, second: 99} = + Expression.evaluate_block!("time(25, 99, 99)") + end + + test "performs no type validation: nil and strings are embedded as-is" do + # Known crash behavior, documented not endorsed: nil/string fields make + # the struct unusable; any downstream rendering raises + # FunctionClauseError in Calendar.ISO. + assert %Time{hour: nil, minute: 0, second: 0} = + evaluate_with_value("time(value, 0, 0)", nil) + + assert %Time{hour: "12", minute: "13", second: "14"} = + Expression.evaluate_block!(~s|time("12", "13", "14")|) + end + + test "floats are embedded as-is, producing an invalid struct" do + # Known crash behavior, documented not endorsed: same lack of validation + # as above, with float fields. + assert %Time{hour: 1.5} = Expression.evaluate_block!("time(1.5, 0, 0)") + end + end + + describe "timevalue/1 type handling" do + test "parses H:M and H:M:S strings" do + assert ~T[02:30:00] == Expression.evaluate_block!(~s|timevalue("2:30")|) + assert ~T[02:30:55] == Expression.evaluate_block!(~s|timevalue("2:30:55")|) + end + + test "extracts string from complex value's __value__ key" do + assert ~T[02:30:00] == evaluate_with_value("timevalue(value)", complex_value("2:30")) + end + + test "nil and non-string garbage raise FunctionClauseError" do + # Known crash behavior, documented not endorsed: timevalue/1 calls + # String.split/3 directly on the input; non-binaries (including an + # already-parsed Time struct) raise FunctionClauseError. + for garbage <- [nil, 42, true, ["2:30"], ~T[02:30:00]] do + assert_raise FunctionClauseError, fn -> + evaluate_with_value("timevalue(value)", garbage) + end + end + end + + test "non-numeric time string raises ArgumentError" do + # Known crash behavior, documented not endorsed: String.to_integer/1 + # raises on the non-numeric segment. + assert_raise ArgumentError, fn -> + Expression.evaluate_block!(~s|timevalue("garbage")|) + end + end + + test "out-of-range time string returns an invalid Time struct" do + # Known crash behavior, documented not endorsed: no range validation, so + # "25:99" yields a Time struct that crashes when rendered. + assert %Time{hour: 25, minute: 99, second: 0} = + Expression.evaluate_block!(~s|timevalue("25:99")|) + end + end + + describe "datetime_add/3 type handling" do + test "clamps month-end arithmetic (Jan 31 + 1 month = Feb 28)" do + assert ~U[2023-02-28 00:00:00Z] == + evaluate_with_value(~s|datetime_add(value, 1, "M")|, ~U[2023-01-31 00:00:00Z]) + end + + test "handles leap day arithmetic" do + assert ~U[2024-02-29 00:00:00.000000Z] == + evaluate_with_value(~s|datetime_add(value, 1, "D")|, ~D[2024-02-28]) + end + + test "Date input is upgraded to a midnight DateTime" do + assert ~U[2023-02-28 00:00:00.000000Z] == + evaluate_with_value(~s|datetime_add(value, 1, "M")|, ~D[2023-01-31]) + end + + test "literal date string is parsed by extract_datetimeish" do + assert ~U[2023-01-16 00:00:00Z] == + Expression.evaluate_block!(~s|datetime_add("2023-01-15", 1, "D")|) + end + + test "returns an error map for nil, non-dates and NaiveDateTime" do + # NaiveDateTime falls through extract_datetimeish's catch-all, so it is + # treated as an invalid date even though it carries date fields. + for garbage <- [nil, 42, true, [], ~N[2023-01-15 10:00:00]] do + assert %{"error" => true, "message" => "Invalid date"} = + evaluate_with_value(~s|datetime_add(value, 1, "D")|, garbage) + end + end + + test "extracts datetime from complex value's __value__ key" do + assert ~U[2023-02-28 00:00:00Z] == + evaluate_with_value( + ~s|datetime_add(value, 1, "M")|, + complex_value(~U[2023-01-31 00:00:00Z]) + ) + end + + test "unknown unit raises CaseClauseError" do + # Known crash behavior, documented not endorsed: the unit case statement + # has no fallback clause for unrecognised units. + assert_raise CaseClauseError, fn -> + evaluate_with_value(~s|datetime_add(value, 1, "x")|, ~D[2023-01-15]) + end + end + + test "literal string or nil offset raises ArithmeticError" do + # Known crash behavior, documented not endorsed: Timex.shift does not + # coerce string/nil offsets. A string offset only works when supplied via + # context, where Expression.Context coerces "1" to the integer 1. + assert_raise ArithmeticError, fn -> + evaluate_with_value(~s|datetime_add(value, "1", "D")|, ~D[2023-01-15]) + end + + assert_raise ArithmeticError, fn -> + evaluate_with_value(~s|datetime_add(value, nil, "D")|, ~D[2023-01-15]) + end + end + end + + describe "datetime_from_unix/2 type handling" do + test "epoch zero in seconds" do + assert ~U[1970-01-01 00:00:00Z] == + Expression.evaluate_block!(~s|datetime_from_unix(0, "second")|) + end + + test "integer seconds and string milliseconds parse equivalently" do + assert DateTime.from_unix!(1_701_903_600, :second) == + Expression.evaluate_block!(~s|datetime_from_unix(1701903600, "second")|) + + assert DateTime.from_unix!(1_701_903_600_000, :millisecond) == + Expression.evaluate_block!(~s|datetime_from_unix("1701903600000", "millisecond")|) + end + + test "negative timestamps resolve to pre-epoch datetimes" do + assert ~U[1969-12-31 00:00:00Z] == + Expression.evaluate_block!(~s|datetime_from_unix(-86400, "second")|) + end + + test "extracts timestamp from complex value's __value__ key" do + assert DateTime.from_unix!(1_701_903_600, :second) == + evaluate_with_value( + ~s|datetime_from_unix(value, "second")|, + complex_value(1_701_903_600) + ) + end + + test "nil, booleans, floats and unknown units raise FunctionClauseError" do + # Known crash behavior, documented not endorsed: parse_unix/2 only has + # clauses for binary/integer timestamps and "second"/"millisecond" units. + for garbage <- [nil, true, 1.5] do + assert_raise FunctionClauseError, fn -> + evaluate_with_value(~s|datetime_from_unix(value, "second")|, garbage) + end + end + + assert_raise FunctionClauseError, fn -> + Expression.evaluate_block!(~s|datetime_from_unix(0, "fortnight")|) + end + end + + test "non-numeric string raises ArgumentError" do + # Known crash behavior, documented not endorsed: String.to_integer/1 + # raises on non-numeric timestamp strings. + assert_raise ArgumentError, fn -> + Expression.evaluate_block!(~s|datetime_from_unix("garbage", "second")|) + end + end + end + + describe "weekday/1 type handling" do + test "returns 1 (Sunday) through 7 (Saturday) across a known week" do + # 2022-11-06 was a Sunday, 2022-11-07 a Monday, 2022-11-12 a Saturday. + assert 1 == evaluate_with_value("weekday(value)", ~D[2022-11-06]) + assert 2 == evaluate_with_value("weekday(value)", ~D[2022-11-07]) + assert 7 == evaluate_with_value("weekday(value)", ~D[2022-11-12]) + end + + test "accepts DateTime and NaiveDateTime" do + assert 1 == evaluate_with_value("weekday(value)", ~U[2022-11-06 10:00:00Z]) + assert 1 == evaluate_with_value("weekday(value)", ~N[2022-11-06 10:00:00]) + end + + test "date-looking string via context is coerced; literal string raises" do + assert 1 == evaluate_with_value("weekday(value)", "2022-11-06") + + # Known crash behavior, documented not endorsed: Timex.weekday returns an + # error tuple for raw strings, and the subsequent + 1 raises + # ArithmeticError. + assert_raise ArithmeticError, fn -> + Expression.evaluate_block!(~s|weekday("2022-11-06")|) + end + end + + test "extracts date from complex value's __value__ key" do + assert 1 == evaluate_with_value("weekday(value)", complex_value(~D[2022-11-06])) + end + + test "nil and non-date garbage raise ArithmeticError" do + # Known crash behavior, documented not endorsed: Timex.weekday's error + # tuple flows into integer arithmetic for all invalid inputs. + for garbage <- [nil, 42, true, []] do + assert_raise ArithmeticError, fn -> evaluate_with_value("weekday(value)", garbage) end + end + end + end + + describe "edate/2 type handling" do + test "clamps month-end arithmetic (Jan 31 + 1 month = Feb 28)" do + assert ~D[2023-02-28] == evaluate_with_value("edate(value, 1)", ~D[2023-01-31]) + end + + test "handles leap-year boundaries" do + assert ~D[2024-02-29] == evaluate_with_value("edate(value, -1)", ~D[2024-03-31]) + assert ~D[2025-02-28] == evaluate_with_value("edate(value, 12)", ~D[2024-02-29]) + end + + test "DateTime input preserves the time component" do + assert ~U[2023-02-28 10:00:00Z] == + evaluate_with_value("edate(value, 1)", ~U[2023-01-31 10:00:00Z]) + end + + test "literal date string is parsed by extract_dateish" do + assert ~D[2022-11-10] == Expression.evaluate_block!(~s|edate("2022-10-10", 1)|) + end + + test "extracts date from complex value's __value__ key" do + assert ~D[2022-11-10] == + evaluate_with_value("edate(value, months)", complex_value("2022-10-10"), %{ + "months" => 1 + }) + end + + test "nil returns a bare {:error, :invalid_date} tuple" do + # Surprising: not an error map and not a crash — Timex.shift(nil, ...) + # returns its error tuple, which leaks straight through to the caller. + assert {:error, :invalid_date} == evaluate_with_value("edate(value, 1)", nil) + end + + test "non-date garbage raises FunctionClauseError" do + # Known crash behavior, documented not endorsed: extract_dateish has no + # clause for integers, booleans, lists or NaiveDateTime. + for garbage <- [42, true, [], ~N[2023-01-31 10:00:00]] do + assert_raise FunctionClauseError, fn -> + evaluate_with_value("edate(value, 1)", garbage) + end + end + end + + test "literal string month offset raises ArithmeticError" do + # Known crash behavior, documented not endorsed: Timex.shift does not + # coerce string offsets. + assert_raise ArithmeticError, fn -> + evaluate_with_value(~s|edate(value, "1")|, ~D[2023-01-15]) + end + end + end + + describe "now/0 and today/0 return types" do + test "now() returns a UTC DateTime" do + assert %DateTime{time_zone: "Etc/UTC"} = Expression.evaluate_block!("now()") + end + + test "today() returns a Date" do + assert %Date{} = Expression.evaluate_block!("today()") + end + end +end diff --git a/test/enum_functions_type_test.exs b/test/enum_functions_type_test.exs new file mode 100644 index 0000000..9c1bae0 --- /dev/null +++ b/test/enum_functions_type_test.exs @@ -0,0 +1,493 @@ +defmodule EnumFunctionsTypeTest do + @moduledoc """ + Systematic type-matrix tests for the ENUM (list/collection) category of + expression functions. + + These tests DOCUMENT CURRENT BEHAVIOR of the V1 engine — they do not endorse + it. Crashes are pinned with `assert_raise` so behavioral changes surface in + CI. Surprising-but-real behaviors get a `# Surprising:` comment explaining + the mechanism. + + Covered: filter, find, map, reduce, chunk_every, reject, uniq, sort_by, + with_index, append, delete, has_member, has_all_members, has_any_member, + concatenate (concatenate_vargs). Plus a dispatch finding for `first`. + + Lambda syntax in THIS engine uses Elixir's capture form: + * single-argument predicates/mappers: `& &1 == "B"`, `&(&1 * &1)` + * indexing into a list item: `& &1[0] == "Hi"` + * reduce reducers get item then acc: `& &1 + &2` (item = &1, acc = &2) + NOTE: the parser reads `<>` as the `!=` operator, so string-concatenation + lambdas silently become comparisons — arithmetic (`+`) is used here instead. + + A recurring theme: the "Invalid enumerable" guard is applied INCONSISTENTLY. + filter/map/reduce/reject/sort_by/chunk_every return an error map for a + non-enumerable input; uniq/with_index quietly return `[]`; and find/has_member + have NO guard at all, so they raise Protocol.UndefinedError on the same input. + + NOTE on context coercion: `Expression.Context` coerces context values BEFORE + callbacks see them — numeric-looking strings ("123", "3.14") become numbers, + and ISO-date / "true"/"false" strings become structs/booleans. To exercise a + function with a genuine string argument, the string is embedded as a LITERAL + in the expression source, not passed via context. + """ + use ExUnit.Case, async: true + + import Expression.Test.TypeTestMatrix + + @invalid_enumerable_error %{ + "__type__" => "expression/v1error", + "__value__" => nil, + "error" => true, + "message" => "Invalid enumerable" + } + + describe "filter/2 type handling" do + test "keeps only items for which the lambda is truthy" do + assert ["B", "B"] == + Expression.evaluate_block!(~s|filter(["A", "B", "C", "B"], & &1 == "B")|) + end + + test "empty list returns empty list" do + assert [] == Expression.evaluate_block!(~s|filter([], & &1 == "B")|) + end + + test "non-enumerable inputs (nil, number, literal string) return an Invalid enumerable error map" do + # filter guards with is_nil/Enumerable.impl_for, returning an error map + # rather than raising. Strings are NOT Enumerable in Elixir. + assert @invalid_enumerable_error == evaluate_with_value(~s|filter(value, & &1 == "B")|, nil) + assert @invalid_enumerable_error == evaluate_with_value(~s|filter(value, & &1 == "B")|, 42) + + assert @invalid_enumerable_error == + Expression.evaluate_block!(~s|filter("hi", & &1 == "B")|) + end + + test "a map IS enumerable: it filters its key/value tuples" do + # Surprising: maps satisfy Enumerable, so filter iterates {key, value} + # tuples; none equal the scalar "B", giving an empty list. + assert [] == evaluate_with_value(~s|filter(value, & &1 == "B")|, %{"a" => 1}) + end + + test "extracts __value__ from complex values" do + assert ["B", "B"] == + evaluate_with_value( + ~s|filter(value, & &1 == "B")|, + complex_value(["A", "B", "C", "B"]) + ) + end + + test "error-map argument collapses to nil __value__ and returns the error map" do + assert @invalid_enumerable_error == + evaluate_with_value(~s|filter(value, & &1 == "B")|, error_value()) + end + end + + describe "find/2 type handling" do + test "returns the first item for which the lambda is truthy" do + assert ["Hi", "World"] == + Expression.evaluate_block!( + ~s|find([["Hello", "World"], ["Hi", "World"]], & &1[0] == "Hi")| + ) + end + + test "no match returns nil; empty list returns nil" do + assert nil == Expression.evaluate_block!(~s|find([1, 2, 3], & &1 == 9)|) + assert nil == Expression.evaluate_block!(~s|find([], & &1 == 1)|) + end + + test "non-enumerable inputs raise Protocol.UndefinedError (NO guard, unlike filter)" do + # Surprising asymmetry, documented not endorsed: find has no is_nil / + # Enumerable.impl_for guard, so nil and literal strings reach Enum.map and + # crash instead of returning an Invalid enumerable error map. + assert_raise Protocol.UndefinedError, fn -> + evaluate_with_value(~s|find(value, & &1 == 1)|, nil) + end + + assert_raise Protocol.UndefinedError, fn -> + Expression.evaluate_block!(~s|find("hi", & &1 == "h")|) + end + end + + test "error-map argument collapses to nil and therefore also raises" do + # The error map's __value__ is nil, which then hits the unguarded Enum.map. + assert_raise Protocol.UndefinedError, fn -> + evaluate_with_value(~s|find(value, & &1 == 1)|, error_value()) + end + end + + test "extracts __value__ from complex values" do + assert 2 == evaluate_with_value(~s|find(value, & &1 == 2)|, complex_value([1, 2, 3])) + end + end + + describe "map/2 type handling" do + test "applies the mapper to every item, including over a Range" do + assert [1, 4, 9] == Expression.evaluate_block!("map(1..3, &(&1 * &1))") + assert [1, 4, 9] == Expression.evaluate_block!("map([1, 2, 3], &(&1 * &1))") + end + + test "empty list returns empty list" do + assert [] == Expression.evaluate_block!("map([], &(&1 * &1))") + end + + test "non-enumerable inputs (nil, literal string) return an Invalid enumerable error map" do + assert @invalid_enumerable_error == evaluate_with_value("map(value, &(&1 * &1))", nil) + assert @invalid_enumerable_error == Expression.evaluate_block!(~s|map("hi", &(&1))|) + end + + test "a map IS enumerable: the mapper receives {key, value} tuples" do + # Surprising: maps satisfy Enumerable, so the identity mapper yields the + # underlying key/value tuples rather than an error. + assert [{"a", 1}] == evaluate_with_value("map(value, &(&1))", %{"a" => 1}) + end + + test "extracts __value__ from complex values" do + assert [1, 4, 9] == evaluate_with_value("map(value, &(&1 * &1))", complex_value([1, 2, 3])) + end + end + + describe "reduce/3 type handling" do + test "reduces with the item as &1 and the accumulator as &2" do + assert 6 == Expression.evaluate_block!("reduce(1..3, 0, & &1 + &2)") + assert 11 == Expression.evaluate_block!("reduce([1, 2, 3], 5, & &1 + &2)") + end + + test "empty list returns the initial accumulator unchanged" do + assert 5 == Expression.evaluate_block!("reduce([], 5, & &1 + &2)") + end + + test "non-enumerable inputs (nil, literal string) return an Invalid enumerable error map" do + assert @invalid_enumerable_error == evaluate_with_value("reduce(value, 0, & &1 + &2)", nil) + assert @invalid_enumerable_error == Expression.evaluate_block!(~s|reduce("hi", 0, & &1)|) + end + + test "extracts __value__ from complex values" do + assert 6 == evaluate_with_value("reduce(value, 0, & &1 + &2)", complex_value([1, 2, 3])) + end + end + + describe "chunk_every/2 type handling" do + test "splits an enumerable into chunks of the given size" do + assert [[1, 2], [3, 4], [5]] == + Expression.evaluate_block!("chunk_every([1, 2, 3, 4, 5], 2)") + + assert [] == Expression.evaluate_block!("chunk_every([], 2)") + end + + test "non-enumerable inputs (nil, literal string, number) return an Invalid enumerable error map" do + assert @invalid_enumerable_error == evaluate_with_value("chunk_every(value, 2)", nil) + assert @invalid_enumerable_error == Expression.evaluate_block!(~s|chunk_every("hello", 2)|) + assert @invalid_enumerable_error == evaluate_with_value("chunk_every(value, 2)", 42) + end + + test "a map IS enumerable: it chunks its key/value tuples" do + # Surprising: maps satisfy Enumerable, so they chunk into lists of tuples + # rather than returning an error. + assert [[{"a", 1}]] == evaluate_with_value("chunk_every(value, 2)", %{"a" => 1}) + end + + test "extracts __value__ from complex values" do + assert [[1, 2, 3], [4, 5]] == + evaluate_with_value("chunk_every(value, 3)", complex_value([1, 2, 3, 4, 5])) + end + end + + describe "reject/2 type handling" do + test "drops items for which the lambda is truthy" do + assert ["A", "C"] == + Expression.evaluate_block!(~s|reject(["A", "B", "C", "B"], & &1 == "B")|) + + assert [] == Expression.evaluate_block!(~s|reject([], & &1 == "B")|) + end + + test "non-enumerable inputs (nil, literal string) return an Invalid enumerable error map" do + assert @invalid_enumerable_error == evaluate_with_value(~s|reject(value, & &1 == "B")|, nil) + + assert @invalid_enumerable_error == + Expression.evaluate_block!(~s|reject("hi", & &1 == "B")|) + end + + test "extracts __value__ from complex values" do + assert ["A"] == + evaluate_with_value(~s|reject(value, & &1 == "B")|, complex_value(["A", "B"])) + end + end + + describe "uniq/1 type handling" do + test "removes duplicate values, preserving first-seen order" do + assert ["A", "B", "C"] == Expression.evaluate_block!(~s|uniq(["A", "B", "C", "B"])|) + assert [] == Expression.evaluate_block!("uniq([])") + end + + test "non-enumerable inputs return [] (NOT an error map, unlike filter/map)" do + # Surprising asymmetry, documented not endorsed: uniq's guard returns an + # empty list for nil / literal string / number, where filter and map would + # return an Invalid enumerable error map. + assert [] == evaluate_with_value("uniq(value)", nil) + assert [] == Expression.evaluate_block!(~s|uniq("hello")|) + assert [] == evaluate_with_value("uniq(value)", 42) + end + + test "a map IS enumerable: it de-duplicates its key/value tuples" do + assert [{"a", 1}, {"b", 1}] == evaluate_with_value("uniq(value)", %{"a" => 1, "b" => 1}) + end + + test "error-map argument collapses to nil __value__ and returns []" do + assert [] == evaluate_with_value("uniq(value)", error_value()) + end + + test "extracts __value__ from complex values" do + assert [1, 2] == evaluate_with_value("uniq(value)", complex_value([1, 1, 2])) + end + end + + describe "sort_by/2 type handling" do + test "sorts by the result of the (single-argument) sorter lambda" do + # The sorter receives each item; & &1 sorts by the item itself. + assert ["a", "b", "c"] == Expression.evaluate_block!(~s|sort_by(["c", "a", "b"], & &1)|) + assert [] == Expression.evaluate_block!(~s|sort_by([], & &1)|) + end + + test "non-enumerable inputs (nil, literal string) return an Invalid enumerable error map" do + assert @invalid_enumerable_error == evaluate_with_value(~s|sort_by(value, & &1)|, nil) + assert @invalid_enumerable_error == Expression.evaluate_block!(~s|sort_by("hi", & &1)|) + end + + test "extracts __value__ from complex values" do + assert [1, 2, 3] == evaluate_with_value("sort_by(value, & &1)", complex_value([3, 1, 2])) + end + end + + describe "with_index/1 type handling" do + test "wraps each item with its zero-based index" do + assert [["A", 0], ["B", 1], ["C", 2]] == + Expression.evaluate_block!(~s|with_index(["A", "B", "C"])|) + + assert [] == Expression.evaluate_block!("with_index([])") + end + + test "non-enumerable inputs return [] (NOT an error map)" do + # Like uniq, with_index's guard quietly returns [] for nil / literal + # string / number rather than an Invalid enumerable error map. + assert [] == evaluate_with_value("with_index(value)", nil) + assert [] == Expression.evaluate_block!(~s|with_index("hi")|) + assert [] == evaluate_with_value("with_index(value)", 42) + end + + test "a map IS enumerable: it indexes its key/value tuples" do + assert [[{"a", 1}, 0]] == evaluate_with_value("with_index(value)", %{"a" => 1}) + end + + test "extracts __value__ from complex values" do + assert [["A", 0], ["B", 1]] == + evaluate_with_value("with_index(value)", complex_value(["A", "B"])) + end + end + + describe "append/2 type handling" do + test "appends a scalar item, or concatenates a list payload" do + assert ["A", "B", "C"] == Expression.evaluate_block!(~s|append(["A", "B"], "C")|) + + assert ["A", "B", "C", "B"] == + Expression.evaluate_block!(~s|append(["A", "B"], ["C", "B"])|) + + assert ["C"] == Expression.evaluate_block!(~s|append([], "C")|) + end + + test "a nil payload is appended as a single nil element" do + # Surprising: nil is not a list, so it is wrapped in [nil] and appended + # rather than being ignored. + assert ["A", nil] == evaluate_with_value(~s|append(["A"], value)|, nil) + end + + test "a non-list FIRST argument raises Protocol.UndefinedError" do + # Known crash behavior, documented not endorsed: Enum.concat/2 requires the + # first argument to be enumerable; nil, numbers and literal strings fail. + assert_raise Protocol.UndefinedError, fn -> + evaluate_with_value(~s|append(value, "C")|, nil) + end + + assert_raise Protocol.UndefinedError, fn -> + evaluate_with_value(~s|append(value, "C")|, 42) + end + + assert_raise Protocol.UndefinedError, fn -> + Expression.evaluate_block!(~s|append("ab", "C")|) + end + end + + test "a map FIRST argument IS enumerable: its tuples are concatenated" do + assert [{"a", 1}, "C"] == evaluate_with_value(~s|append(value, "C")|, %{"a" => 1}) + end + + test "extracts __value__ from complex values" do + assert ["A", "B", "C"] == + evaluate_with_value(~s|append(value, "C")|, complex_value(["A", "B"])) + end + end + + describe "delete/2 type handling" do + test "deletes a key from a map; a missing key leaves the map unchanged" do + assert %{"age" => 32} == + evaluate_with_value(~s|delete(value, "gender")|, %{"gender" => "?", "age" => 32}) + + assert %{"age" => 32} == evaluate_with_value(~s|delete(value, "missing")|, %{"age" => 32}) + end + + test "non-map inputs (nil, list, literal string, number) raise BadMapError" do + # Known crash behavior, documented not endorsed: Map.delete/2 requires a + # map and has no graceful fallback in delete/3. + assert_raise BadMapError, fn -> evaluate_with_value(~s|delete(value, "k")|, nil) end + assert_raise BadMapError, fn -> evaluate_with_value(~s|delete(value, "k")|, [1, 2, 3]) end + assert_raise BadMapError, fn -> Expression.evaluate_block!(~s|delete("hi", "k")|) end + assert_raise BadMapError, fn -> evaluate_with_value(~s|delete(value, "k")|, 42) end + end + + test "extracts the inner map from complex values" do + assert %{"a" => 2} == + evaluate_with_value(~s|delete(value, "g")|, complex_value(%{"g" => 1, "a" => 2})) + end + end + + describe "has_member/2 type handling" do + test "returns whether the list contains the item" do + assert true == Expression.evaluate_block!(~s|has_member(["A", "B", "C"], "C")|) + assert false == Expression.evaluate_block!(~s|has_member(["A", "B"], "Z")|) + assert false == Expression.evaluate_block!(~s|has_member([], "C")|) + end + + test "non-enumerable first arguments raise Protocol.UndefinedError (NO guard)" do + # Surprising asymmetry, documented not endorsed: has_member calls + # Enum.member?/2 directly with no is_list guard, so nil, numbers and + # literal strings crash — unlike has_all_members/has_any_member which guard. + assert_raise Protocol.UndefinedError, fn -> + evaluate_with_value(~s|has_member(value, "C")|, nil) + end + + assert_raise Protocol.UndefinedError, fn -> + evaluate_with_value(~s|has_member(value, "C")|, 42) + end + + assert_raise Protocol.UndefinedError, fn -> + Expression.evaluate_block!(~s|has_member("abc", "a")|) + end + end + + test "a map IS enumerable: membership is tested against its key/value tuples" do + # Surprising: a bare key is not a member; only a {key, value} tuple would be. + assert false == evaluate_with_value(~s|has_member(value, "a")|, %{"a" => 1}) + end + + test "error-map first argument collapses to nil and raises" do + assert_raise Protocol.UndefinedError, fn -> + evaluate_with_value(~s|has_member(value, "C")|, error_value()) + end + end + + test "extracts __value__ from complex values" do + assert true == + evaluate_with_value(~s|has_member(value, "C")|, complex_value(["A", "B", "C"])) + end + end + + describe "has_all_members/2 type handling" do + test "returns whether the list contains every provided item" do + assert true == Expression.evaluate_block!(~s|has_all_members(["A", "B", "C"], ["C", "B"])|) + assert false == Expression.evaluate_block!(~s|has_all_members(["A", "B"], ["C", "B"])|) + end + + test "an empty items list is vacuously true" do + # Surprising: Enum.all?/2 over an empty list is true, so every list + # "contains all" of nothing. + assert true == Expression.evaluate_block!(~s|has_all_members(["A"], [])|) + end + + test "non-list arguments return false (guarded, never raises)" do + # has_all_members guards with is_list on BOTH arguments, so any non-list + # input degrades to false rather than crashing. + assert false == evaluate_with_value(~s|has_all_members(value, ["C"])|, nil) + assert false == evaluate_with_value(~s|has_all_members(["A"], value)|, nil) + assert false == Expression.evaluate_block!(~s|has_all_members("ab", ["a"])|) + assert false == evaluate_with_value(~s|has_all_members(value, ["a"])|, %{"a" => 1}) + end + + test "extracts __value__ from complex values" do + assert true == + evaluate_with_value( + "has_all_members(value, items)", + complex_value(["A", "B", "C"]), + %{"items" => complex_value(["C", "B"])} + ) + end + end + + describe "has_any_member/2 type handling" do + test "returns whether the list contains any provided item" do + assert true == Expression.evaluate_block!(~s|has_any_member(["A", "B", "C"], ["Z", "C"])|) + assert false == Expression.evaluate_block!(~s|has_any_member(["A", "B"], ["Z"])|) + end + + test "an empty items list is false" do + # Mirror of has_all_members: Enum.any?/2 over an empty list is false. + assert false == Expression.evaluate_block!(~s|has_any_member(["A"], [])|) + end + + test "non-list arguments return false (guarded, never raises)" do + assert false == evaluate_with_value(~s|has_any_member(value, ["C"])|, nil) + assert false == evaluate_with_value(~s|has_any_member(["A"], value)|, nil) + end + + test "extracts __value__ from complex values" do + assert true == + evaluate_with_value( + "has_any_member(value, items)", + complex_value(["A", "B"]), + %{"items" => complex_value(["Z", "B"])} + ) + end + end + + describe "concatenate/N (concatenate_vargs) type handling" do + test "joins string arguments into one string" do + assert "abc" == Expression.evaluate_block!(~s|concatenate("a", "b", "c")|) + assert "" == Expression.evaluate_block!(~s|concatenate()|) + end + + test "nil, numbers and booleans are stringified via default_value/to_string" do + # nil contributes the empty string; numbers and booleans are stringified. + assert "ac" == evaluate_with_value(~s|concatenate("a", value, "c")|, nil) + assert "a42" == evaluate_with_value(~s|concatenate("a", value)|, 42) + assert "atrue" == evaluate_with_value(~s|concatenate("a", value)|, true) + end + + test "a list argument is interpreted as a charlist (raw bytes), not its text form" do + # Surprising: to_string([1, 2]) treats the list as a charlist, so the + # numbers become raw bytes appended to "a". + assert <<97, 1, 2>> == evaluate_with_value(~s|concatenate("a", value)|, [1, 2]) + end + + test "a map argument raises Protocol.UndefinedError (no String.Chars for Map)" do + # Known crash behavior, documented not endorsed: to_string/1 has no + # String.Chars implementation for a plain map. + assert_raise Protocol.UndefinedError, fn -> + evaluate_with_value(~s|concatenate("a", value)|, %{"x" => 1}) + end + end + + test "extracts __value__ from complex values" do + assert "aZ" == evaluate_with_value(~s|concatenate("a", value)|, complex_value("Z")) + end + end + + describe "first/N dispatch (enum-ish, but NOT implemented)" do + # FINDING: there is no `first` callback in Expression.Callbacks.Standard, so + # the dispatcher returns the "not implemented" error string for every call. + # FLOIP-style `first` is simply not provided by this engine. + test "every invocation returns the 'first is not implemented' error string" do + assert ~s|ERROR: "first is not implemented."| == + Expression.evaluate_block!("first([1, 2, 3])") + + assert ~s|ERROR: "first is not implemented."| == + evaluate_with_value("first(value)", nil) + end + end +end diff --git a/test/expression_fuzz_test.exs b/test/expression_fuzz_test.exs new file mode 100644 index 0000000..ce6f89b --- /dev/null +++ b/test/expression_fuzz_test.exs @@ -0,0 +1,123 @@ +defmodule ExpressionFuzzTest do + @moduledoc """ + Property-based (fuzz) tests for the crash-safety invariant of expression + functions. + + The invariant: evaluating a function with arbitrary runtime input must never + RAISE. Returning a normal value, or an `expression/v1error` error map, is an + acceptable outcome — only an unhandled exception is a failure. + + ## Scope: the crash-safe subset only + + These tests DOCUMENT CURRENT BEHAVIOR, they do not endorse it. The V1 engine + does NOT uphold the no-crash invariant universally — many functions raise on + unexpected input types (this is captured, function by function, in the + `*_functions_type_test.exs` files). Fuzzing every function against + `any_value/0` would therefore fail immediately and tell us nothing new. + + Instead this suite fuzzes only the functions that were empirically confirmed + crash-safe across the full type matrix (nil, booleans, numbers, strings, + lists, maps, complex `__value__` maps, Dates/DateTimes, Decimals, error maps). + That set lives in `Expression.Test.CrashSafe` — a single source shared with + `CrashSafeClassificationTest`, which pins the same classification + deterministically against the type matrix on every CI run. This suite adds + random exploration on top: if a future change makes one of those functions + crash on some input, the property fails. + + Functions NOT in `Expression.Test.CrashSafe` currently raise on at least some + inputs; each such crash is pinned, with its exact exception, in the + corresponding `*_functions_type_test.exs` file. Moving a function into the + crash-safe set is only valid once it returns an error map instead of raising. + + ## Running + + These tests are excluded from the default suite and are NOT a merge gate (a + property test uses a fresh random seed each run, so it can legitimately go red + when it discovers a new crashing input — unsuitable for blocking PRs). They + run on a schedule instead, via `.github/workflows/fuzz.yml`. Run locally with: + + mix test --only fuzz + + Generations per property default to 100; set `FUZZ_MAX_RUNS` higher for a + deeper search (the scheduled job uses 1000): + + FUZZ_MAX_RUNS=1000 mix test --only fuzz + + When a run fails, ExUnit prints the seed and StreamData prints the shrunk + failing input; reproduce with `mix test --only fuzz --seed `. + """ + use ExUnit.Case, async: true + use ExUnitProperties + + import Expression.Test.FuzzHelpers + alias Expression.Test.CrashSafe + + @moduletag :fuzz + + # Number of generations StreamData runs per property. Defaults to 100 for a + # fast local run; the scheduled CI fuzz job sets FUZZ_MAX_RUNS higher (e.g. + # 1000) to explore more of the input space, since it is not latency-bound. + # A non-numeric or unset value falls back to the default rather than crashing + # every property at setup. + defp max_runs do + case Integer.parse(System.get_env("FUZZ_MAX_RUNS", "100")) do + {n, _} when n > 0 -> n + _ -> 100 + end + end + + # Crash-safe function lists come from Expression.Test.CrashSafe (single source, + # shared with CrashSafeClassificationTest). Enum functions are fuzzed with an + # enumerable-biased generator so the list/map code paths actually get + # exercised; the others use any_value/0. + + describe "string functions never crash on arbitrary input" do + for {label, expr} <- CrashSafe.group("string") do + property "#{label}/1" do + check all(value <- any_value(), max_runs: max_runs()) do + assert_no_crash(unquote(expr), %{"value" => value}) + end + end + end + end + + describe "logical functions never crash on arbitrary input" do + for {label, expr} <- CrashSafe.group("logical") do + property "#{label}" do + check all(value <- any_value(), max_runs: max_runs()) do + assert_no_crash(unquote(expr), %{"value" => value}) + end + end + end + end + + describe "number functions never crash on arbitrary input" do + for {label, expr} <- CrashSafe.group("number") do + property "#{label}" do + check all(value <- any_value(), max_runs: max_runs()) do + assert_no_crash(unquote(expr), %{"value" => value}) + end + end + end + end + + describe "enum functions never crash on arbitrary input" do + for {label, expr} <- CrashSafe.group("enum") do + property "#{label}" do + check all(value <- enumerable_value(), max_runs: max_runs()) do + assert_no_crash(unquote(expr), %{"value" => value}) + end + end + end + end + + describe "other functions never crash on arbitrary input" do + for {label, expr} <- CrashSafe.group("other") do + property "#{label}" do + check all(value <- any_value(), max_runs: max_runs()) do + assert_no_crash(unquote(expr), %{"value" => value}) + end + end + end + end +end diff --git a/test/expression_test.exs b/test/expression_test.exs index 19c6d3a..bcfa182 100644 --- a/test/expression_test.exs +++ b/test/expression_test.exs @@ -800,4 +800,170 @@ defmodule ExpressionTest do ) end end + + # Pilot type-matrix tests. These document the *current* behavior of five + # representative functions across input types, including behaviors that are + # arguably bugs (raises instead of error maps). See TESTING.md. + + describe "upper/1 type handling" do + import Expression.Test.TypeTestMatrix + + test "uppercases plain strings" do + assert "HELLO" == evaluate_with_value("upper(value)", "hello") + assert "" == evaluate_with_value("upper(value)", "") + end + + test "numeric strings are coerced to numbers by the context, yielding nil" do + # Expression.Context parses "123" into the integer 123 before the + # callback runs, so upper/1 sees a number and returns nil. + assert nil == evaluate_with_value("upper(value)", "123") + end + + test "uppercases unicode and preserves emoji" do + assert "HÉLLO WÖRLD" == evaluate_with_value("upper(value)", "héllo wörld") + assert "👋🌍" == evaluate_with_value("upper(value)", "👋🌍") + end + + test "returns nil for nil and all non-string types" do + for value <- [nil, 42, 3.14, true, false, [1, 2], %{"k" => "v"}] do + assert nil == evaluate_with_value("upper(value)", value), + "expected upper(#{inspect(value)}) to be nil" + end + end + + test "extracts __value__ from complex values" do + assert "HELLO" == evaluate_with_value("upper(value)", complex_value("hello")) + end + end + + describe "lower/1 type handling" do + import Expression.Test.TypeTestMatrix + + test "lowercases plain strings" do + assert "hello" == evaluate_with_value("lower(value)", "HELLO") + assert "" == evaluate_with_value("lower(value)", "") + end + + test "lowercases unicode" do + assert "héllo wörld" == evaluate_with_value("lower(value)", "HÉLLO WÖRLD") + end + + test "returns nil for nil and all non-string types" do + for value <- [nil, 42, 3.14, true, [1, 2], %{"k" => "v"}] do + assert nil == evaluate_with_value("lower(value)", value), + "expected lower(#{inspect(value)}) to be nil" + end + end + + test "extracts __value__ from complex values" do + assert "hello" == evaluate_with_value("lower(value)", complex_value("HELLO")) + end + end + + describe "abs/1 type handling" do + import Expression.Test.TypeTestMatrix + + test "returns absolute value for integers and floats" do + assert 5 == evaluate_with_value("abs(value)", -5) + assert 5 == evaluate_with_value("abs(value)", 5) + assert 0 == evaluate_with_value("abs(value)", 0) + assert 5.5 == evaluate_with_value("abs(value)", -5.5) + assert 10_000_000_000_000_000_000 == evaluate_with_value("abs(value)", -(10 ** 19)) + end + + test "coerces numeric strings" do + assert 12 == evaluate_with_value("abs(value)", "-12") + assert 3.5 == evaluate_with_value("abs(value)", "-3.5") + end + + test "extracts __value__ from complex values" do + assert 3 == evaluate_with_value("abs(value)", complex_value(-3)) + end + + test "currently raises ArgumentError for nil and non-numeric input" do + # Known crash behavior, documented not endorsed: abs/1 does not guard + # against non-numbers and raises instead of returning an error map. + for value <- [nil, "hello", true, [1], %{"k" => "v"}] do + assert_raise ArgumentError, fn -> + evaluate_with_value("abs(value)", value) + end + end + end + end + + describe "round/1 and round/2 type handling" do + import Expression.Test.TypeTestMatrix + + test "rounds floats and returns a string" do + assert "4" == evaluate_with_value("round(value)", 3.7) + assert "-4" == evaluate_with_value("round(value)", -3.7) + assert "3.14" == evaluate_with_value("round(value, 2)", 3.14159) + end + + test "coerces numeric strings" do + assert "4" == evaluate_with_value("round(value)", "3.7") + end + + test "extracts __value__ from complex values" do + assert "3" == evaluate_with_value("round(value)", complex_value(2.5)) + end + + test "currently raises FunctionClauseError for nil, integers and non-numeric input" do + # Known crash behavior, documented not endorsed: round/1 funnels + # everything through Decimal.from_float/1, which only accepts floats — + # plain integers, nil and unparseable strings all crash. + for value <- [nil, 5, "abc", [1], %{"k" => "v"}] do + assert_raise FunctionClauseError, fn -> + evaluate_with_value("round(value)", value) + end + end + end + end + + describe "date/3 type handling" do + import Expression.Test.TypeTestMatrix + + test "builds a Date from integers" do + assert ~D[2023-06-15] == Expression.evaluate_block!("date(2023, 6, 15)") + end + + test "coerces numeric strings" do + assert ~D[2023-06-15] == + Expression.evaluate_block!( + "date(y, m, d)", + %{"y" => "2023", "m" => "6", "d" => "15"} + ) + end + + test "extracts __value__ from complex values" do + assert ~D[2023-06-15] == + Expression.evaluate_block!( + "date(y, m, d)", + %{"y" => complex_value(2023), "m" => 6, "d" => 15} + ) + end + + test "returns an error map for nil arguments" do + assert %{ + "__type__" => "expression/v1error", + "error" => true, + "message" => "Invalid date: date(nil, 6, 15)" + } = Expression.evaluate_block!("date(y, 6, 15)", %{"y" => nil}) + end + + test "returns an error map for impossible dates" do + assert %{"__type__" => "expression/v1error", "error" => true} = + Expression.evaluate_block!("date(2023, 2, 30)") + + assert %{"__type__" => "expression/v1error", "error" => true} = + Expression.evaluate_block!("date(2023, 13, 1)") + end + + test "handles leap years" do + assert ~D[2024-02-29] == Expression.evaluate_block!("date(2024, 2, 29)") + + assert %{"__type__" => "expression/v1error", "error" => true} = + Expression.evaluate_block!("date(2023, 2, 29)") + end + end end diff --git a/test/logical_functions_type_test.exs b/test/logical_functions_type_test.exs new file mode 100644 index 0000000..3543e55 --- /dev/null +++ b/test/logical_functions_type_test.exs @@ -0,0 +1,414 @@ +defmodule LogicalFunctionsTypeTest do + @moduledoc """ + Systematic type-matrix tests for the "logical" category of expression + functions: if/3, not/1, and/n, or/n, switch/n, isnumber/1, isbool/1, + isstring/1, is_error/1, is_nil_or_empty/1. + + These tests document CURRENT behavior, they do not endorse it. + + Discovered truthiness model (Elixir semantics, after `__value__` extraction): + + falsy: nil, false, + error maps (their `__value__` is nil, so they collapse to nil), + complex maps whose `__value__` is nil or false + truthy: everything else, including 0, 0.0, "", [], %{}, "false" + + Context coercion gotcha: `Expression.evaluate_block!/2` coerces context + values (numeric-looking strings become numbers, "true"/"false" become + booleans) BEFORE callbacks see them. String-literal behavior is therefore + tested with strings embedded directly in the expression source. + """ + use ExUnit.Case, async: true + + import Expression.Test.TypeTestMatrix + + # The discovered truthiness table, shared by if/and/or/not. + # {value, truthy?} + defp truthiness_table do + [ + {nil, false}, + {false, false}, + {true, true}, + {0, true}, + {1, true}, + {0.0, true}, + {"", true}, + {"text", true}, + {[], true}, + {%{}, true}, + # error maps carry "__value__" => nil, which eval! extracts -> nil -> falsy + {error_value(), false}, + {complex_value(false), false}, + {complex_value(nil), false}, + {complex_value(0), true}, + {complex_value("x"), true} + ] + end + + describe "if/3 type handling" do + test "truthiness table: only nil, false and nil/false-valued complex/error maps are falsy" do + for {value, truthy?} <- truthiness_table() do + expected = if truthy?, do: "T", else: "F" + + assert evaluate_with_value(~s|if(value, "T", "F")|, value) == expected, + "if(#{inspect(value)}, ...) expected the #{expected} branch" + end + end + + test "nil condition takes the else branch" do + assert evaluate_with_value(~s|if(value, "T", "F")|, nil) == "F" + end + + test "unresolved variable in condition acts as nil (else branch)" do + assert Expression.evaluate_block!(~s|if(does_not_exist, "T", "F")|, %{}) == "F" + end + + test "complex values are unwrapped to __value__ before the truth test" do + assert evaluate_with_value(~s|if(value, "T", "F")|, complex_value(false)) == "F" + assert evaluate_with_value(~s|if(value, "T", "F")|, complex_value("anything")) == "T" + end + + test "branch values preserve their type" do + assert Expression.evaluate_block!("if(true, 1, 2)", %{}) == 1 + assert Expression.evaluate_block!("if(false, 1, 2)", %{}) == 2 + end + + test "branches are NOT lazy: the untaken branch is still evaluated and may raise" do + # Known crash behavior, documented not endorsed: function arguments are + # evaluated eagerly before dispatch, so a raising expression in the + # untaken branch still raises (abs("garbage") -> ArgumentError). + assert_raise ArgumentError, fn -> + Expression.evaluate_block!(~s|if(true, "ok", abs("garbage"))|, %{}) + end + + assert_raise ArgumentError, fn -> + Expression.evaluate_block!(~s|if(false, abs("garbage"), "ok")|, %{}) + end + end + end + + describe "not/1 type handling" do + test "truthiness table: not(x) is the strict boolean negation of x's truthiness" do + for {value, truthy?} <- truthiness_table() do + assert evaluate_with_value("not(value)", value) == not truthy?, + "not(#{inspect(value)}) expected #{not truthy?}" + end + end + + test "nil negates to true" do + assert evaluate_with_value("not(value)", nil) == true + end + + test "always returns a strict boolean, never the operand" do + assert evaluate_with_value("not(value)", "text") == false + assert evaluate_with_value("not(value)", 0) == false + end + + test "error maps collapse to nil and negate to true" do + assert evaluate_with_value("not(value)", error_value()) == true + end + end + + describe "and/n (and_vargs) type handling" do + test "truthiness table: each value combined with a true companion" do + for {value, truthy?} <- truthiness_table() do + assert evaluate_with_value("and(value, true)", value) == truthy?, + "and(#{inspect(value)}, true) expected #{truthy?}" + end + end + + test "returns a strict boolean even for truthy non-boolean operands" do + assert Expression.evaluate_block!(~s|and(1, "x")|, %{}) == true + end + + test "nil operand makes the conjunction false" do + assert evaluate_with_value("and(true, value)", nil) == false + end + + test "zero-argument and() is vacuously true" do + assert Expression.evaluate_block!("and()", %{}) == true + end + + test "does NOT short-circuit: a raising later argument still raises after false" do + # Known crash behavior, documented not endorsed: all arguments are + # evaluated eagerly, so and(false, abs("garbage")) raises ArgumentError + # instead of returning false. + assert_raise ArgumentError, fn -> + Expression.evaluate_block!(~s|and(false, abs("garbage"))|, %{}) + end + end + end + + describe "or/n (or_vargs) type handling" do + test "truthiness table: each value combined with a false companion" do + for {value, truthy?} <- truthiness_table() do + result = evaluate_with_value("or(value, false)", value) + # or returns the first truthy VALUE (unwrapped), otherwise false + expected = if truthy?, do: Expression.Eval.default_value(value), else: false + + assert result == expected, + "or(#{inspect(value)}, false) expected #{inspect(expected)}, got #{inspect(result)}" + end + end + + test "returns the first truthy value itself, not a coerced boolean" do + assert Expression.evaluate_block!(~s|or(false, "foo")|, %{}) == "foo" + assert evaluate_with_value(~s|or(value, "fallback")|, complex_value(42)) == 42 + end + + test "returns false (not the last operand) when all operands are falsy" do + assert Expression.evaluate_block!("or(false, false)", %{}) == false + assert evaluate_with_value(~s|or(value, value)|, nil) == false + end + + test "nil and error-map operands are skipped in favour of a later truthy value" do + assert evaluate_with_value(~s|or(value, "fallback")|, nil) == "fallback" + assert evaluate_with_value(~s|or(value, "fallback")|, error_value()) == "fallback" + end + + test "zero-argument or() is false" do + assert Expression.evaluate_block!("or()", %{}) == false + end + + test "does NOT short-circuit: a raising later argument raises even after true" do + # Known crash behavior, documented not endorsed: although or_vargs uses + # reduce_while internally, arguments are already evaluated eagerly before + # dispatch, so or(true, abs("garbage")) raises ArgumentError instead of + # returning true. + assert_raise ArgumentError, fn -> + Expression.evaluate_block!(~s|or(true, abs("garbage"))|, %{}) + end + end + end + + describe "isnumber/1 type handling" do + test "type matrix" do + cases = [ + {nil, false}, + {true, false}, + {false, false}, + {0, true}, + {1, true}, + {42, true}, + {-7, true}, + {3.14, true}, + {[], false}, + {%{}, false}, + # Decimal structs are NOT considered numbers + {Decimal.new("1.5"), false}, + {~D[2023-06-15], false}, + {error_value(), false}, + {complex_value(42), true}, + {complex_value("text"), false} + ] + + for {value, expected} <- cases do + assert evaluate_with_value("isnumber(value)", value) == expected, + "isnumber(#{inspect(value)}) expected #{expected}" + end + end + + test "string literals: matched against regex ~r/^\\d+?.?\\d+$/ with quirks" do + # Multi-digit and decimal strings match + assert Expression.evaluate_block!(~s|isnumber("123")|, %{}) == true + assert Expression.evaluate_block!(~s|isnumber("12")|, %{}) == true + assert Expression.evaluate_block!(~s|isnumber("3.14")|, %{}) == true + assert Expression.evaluate_block!(~s|isnumber("hello")|, %{}) == false + assert Expression.evaluate_block!(~s|isnumber("")|, %{}) == false + end + + test "regex quirk: single-digit string is NOT a number" do + # The regex requires at least two digit characters, so "5" fails. + # (Via context this is masked because "5" is coerced to integer 5.) + assert Expression.evaluate_block!(~s|isnumber("5")|, %{}) == false + end + + test "regex quirk: negative number string is NOT a number" do + assert Expression.evaluate_block!(~s|isnumber("-5")|, %{}) == false + end + + test "regex quirk: the unescaped dot matches any character, so \"1a1\" IS a number" do + assert Expression.evaluate_block!(~s|isnumber("1a1")|, %{}) == true + end + + test "context coercion masks string behavior: numeric string via context is a real number" do + assert evaluate_with_value("isnumber(value)", "5") == true + assert evaluate_with_value("isnumber(value)", "3.14") == true + end + end + + describe "isbool/1 type handling" do + test "type matrix: only true and false are booleans" do + cases = [ + {nil, false}, + {true, true}, + {false, true}, + {0, false}, + {1, false}, + {"", false}, + {[], false}, + {%{}, false}, + {error_value(), false}, + {complex_value(true), true}, + {complex_value("text"), false} + ] + + for {value, expected} <- cases do + assert evaluate_with_value("isbool(value)", value) == expected, + "isbool(#{inspect(value)}) expected #{expected}" + end + end + + test "string literal \"true\" is not a boolean" do + assert Expression.evaluate_block!(~s|isbool("true")|, %{}) == false + assert Expression.evaluate_block!(~s|isbool("false")|, %{}) == false + end + + test "context coercion masks string behavior: \"true\" via context becomes boolean true" do + assert evaluate_with_value("isbool(value)", "true") == true + end + end + + describe "isstring/1 type handling" do + test "type matrix" do + cases = [ + {nil, false}, + {true, false}, + {0, false}, + {3.14, false}, + {"hello", true}, + {"", true}, + {[], false}, + {%{}, false}, + {~D[2023-06-15], false}, + {Decimal.new("1.5"), false}, + # error maps collapse to their nil __value__ + {error_value(), false}, + {complex_value("text"), true}, + {complex_value(42), false} + ] + + for {value, expected} <- cases do + assert evaluate_with_value("isstring(value)", value) == expected, + "isstring(#{inspect(value)}) expected #{expected}" + end + end + + test "string literal in expression source is a string" do + assert Expression.evaluate_block!(~s|isstring("123")|, %{}) == true + end + + test "context coercion gotcha: numeric string via context is NOT a string anymore" do + # Expression.Context coerces "123" to integer 123 before the callback + # sees it, so the same characters answer differently by path. + assert evaluate_with_value("isstring(value)", "123") == false + end + end + + describe "is_error/1 type handling" do + test "type matrix: only maps with __type__ expression/v1error are errors" do + cases = [ + {nil, false}, + {true, false}, + {0, false}, + {"hello", false}, + {[], false}, + {%{}, false}, + {error_value(), true}, + {complex_value("text"), false} + ] + + for {value, expected} <- cases do + assert evaluate_with_value("is_error(value)", value) == expected, + "is_error(#{inspect(value)}) expected #{expected}" + end + end + + test "error detection survives __value__ extraction (uses with_defaults: false)" do + # Every other logical function sees error maps as nil; is_error is the + # one place the raw map is inspected, keyed solely on __type__. + assert evaluate_with_value("is_error(value)", %{"__type__" => "expression/v1error"}) == + true + end + + test "an ordinary complex map is not an error" do + assert evaluate_with_value("is_error(value)", complex_value(nil)) == false + end + end + + describe "is_nil_or_empty/1 type handling" do + test "type matrix: nil and empty string only; other empties are not 'empty'" do + cases = [ + {nil, true}, + {"", true}, + # whitespace is not empty + {" ", false}, + {"x", false}, + {0, false}, + {false, false}, + # empty list and empty map are NOT considered empty + {[], false}, + {%{}, false}, + # error maps collapse to nil __value__ -> true + {error_value(), true}, + {complex_value(nil), true}, + {complex_value(""), true}, + {complex_value("x"), false} + ] + + for {value, expected} <- cases do + assert evaluate_with_value("is_nil_or_empty(value)", value) == expected, + "is_nil_or_empty(#{inspect(value)}) expected #{expected}" + end + end + + test "empty string literal in expression source" do + assert Expression.evaluate_block!(~s|is_nil_or_empty("")|, %{}) == true + end + + test "unresolved variables count as nil" do + assert Expression.evaluate_block!("is_nil_or_empty(does_not_exist)", %{}) == true + end + end + + describe "switch/n (switch_vargs) type handling" do + test "returns the result paired with the matching case" do + assert Expression.evaluate_block!(~s|switch(1, 1, "one", 2, "two")|, %{}) == "one" + assert Expression.evaluate_block!(~s|switch("a", "a", "letter-a")|, %{}) == "letter-a" + end + + test "no match with even argument count returns nil" do + assert Expression.evaluate_block!(~s|switch(5, 1, "one", 2, "two")|, %{}) == nil + end + + test "no match with odd argument count returns the trailing default" do + assert Expression.evaluate_block!(~s|switch(5, 1, "one", 2, "two", "default")|, %{}) == + "default" + end + + test "subject with no cases at all returns nil" do + assert Expression.evaluate_block!("switch(1)", %{}) == nil + end + + test "nil subject falls through to the default" do + assert evaluate_with_value(~s|switch(value, 1, "one", "default")|, nil) == "default" + end + + test "duplicate cases: the LAST matching pair wins, contradicting the docs" do + # The docstring promises "the first matching value", but the + # implementation builds a Map via Map.new/2, where later duplicate keys + # overwrite earlier ones. Documented, not endorsed. + assert Expression.evaluate_block!(~s|switch(1, 1, "first", 1, "second")|, %{}) == "second" + end + + test "matching is exact-term equality: 1.0 and \"1\" do not match case 1" do + assert Expression.evaluate_block!(~s|switch(1.0, 1, "int-one", "dflt")|, %{}) == "dflt" + assert Expression.evaluate_block!(~s|switch("1", 1, "int-one", "dflt")|, %{}) == "dflt" + end + + test "complex subject is unwrapped to __value__; error subject acts as nil" do + assert evaluate_with_value(~s|switch(value, 1, "one")|, complex_value(1)) == "one" + assert evaluate_with_value(~s|switch(value, 1, "one", "dflt")|, error_value()) == "dflt" + end + end +end diff --git a/test/number_functions_type_test.exs b/test/number_functions_type_test.exs new file mode 100644 index 0000000..bfcbe82 --- /dev/null +++ b/test/number_functions_type_test.exs @@ -0,0 +1,344 @@ +defmodule NumberFunctionsTypeTest do + @moduledoc """ + Systematic type-matrix tests for the NUMBER category of expression functions. + + These tests DOCUMENT CURRENT BEHAVIOR of the V1 engine — they do not endorse + it. Crashes are pinned with `assert_raise` so behavioral changes surface in CI. + + Covered: fixed, power, rem, rand_between, percent, parse_float (dispatch + finding), max (max_vargs), min (min_vargs), sum (sum_vargs). + Excluded: abs and round — already covered by the pilot in expression_test.exs. + + NOTE on context coercion: Expression.Context coerces numeric-looking strings + ("123", "3.14") into numbers BEFORE callbacks see them. Tests that need a + true string argument embed a literal in the expression itself. + """ + use ExUnit.Case, async: true + + import Expression.Test.TypeTestMatrix + + describe "fixed/2 and fixed/3 type handling" do + test "formats numbers as strings with thousands separators by default" do + assert "4.21" == Expression.evaluate_block!("fixed(4.209922, 2)") + assert "1,234,567.89" == Expression.evaluate_block!("fixed(1234567.891, 2)") + assert "1234567.89" == Expression.evaluate_block!("fixed(1234567.891, 2, true)") + assert "1,234,567.89" == Expression.evaluate_block!("fixed(1234567.891, 2, false)") + end + + test "nil raises Expression.Error (graceful 'not a number' coercion error)" do + # Known crash behavior, documented not endorsed: coerce_to_number!/1 + # raises for nil; evaluate_block!/2 re-raises it as Expression.Error. + assert_raise Expression.Error, ~r/expression is not a number: `nil`/, fn -> + evaluate_with_value("fixed(value, 2)", nil) + end + end + + test "numeric-looking strings work (literal and context-coerced), other strings raise" do + # Context coercion turns "123" into the integer 123 before the callback. + assert "123.00" == evaluate_with_value("fixed(value, 2)", "123") + # A literal numeric string survives as a string and is coerced by fixed itself. + assert "3.14" == Expression.evaluate_block!(~s|fixed("3.14", 2)|) + + assert_raise Expression.Error, ~r/expression is not a number: `"hello"`/, fn -> + Expression.evaluate_block!(~s|fixed("hello", 2)|) + end + end + + test "booleans, lists, maps and Decimals all raise Expression.Error" do + for value <- [true, false, [1, 2], %{}, Decimal.new("1.5")] do + assert_raise Expression.Error, ~r/expression is not a number/, fn -> + evaluate_with_value("fixed(value, 2)", value) + end + end + end + + test "extracts __value__ from complex values" do + assert "4.21" == evaluate_with_value("fixed(value, 2)", complex_value(4.209922)) + end + + test "edge cases: zero, negatives, very large ints, very small floats" do + assert "0.00" == Expression.evaluate_block!("fixed(0, 2)") + assert "-5.56" == Expression.evaluate_block!("fixed(-5.555, 2)") + + assert "10,000,000,000,000,000,000.00" == + evaluate_with_value("fixed(value, 2)", 10 ** 19) + + assert "0.00" == evaluate_with_value("fixed(value, 2)", 1.0e-10) + + # Known crash behavior, documented not endorsed: the PARSER cannot read + # scientific-notation literals, so this fails before fixed/3 is called. + assert_raise Expression.Error, ~r/Unable to parse block/, fn -> + Expression.evaluate_block!("fixed(1.0e-10, 2)") + end + end + + test "fixed/3 with a non-boolean no_commas raises CaseClauseError" do + # Known crash behavior, documented not endorsed: fixed/4 only matches + # literal true/false for no_commas; nil falls through the case. + assert_raise CaseClauseError, fn -> + evaluate_with_value("fixed(1.5, 2, value)", nil) + end + end + end + + describe "power/2 type handling" do + test "always returns a float, even for integer inputs" do + assert 8.0 == Expression.evaluate_block!("power(2, 3)") + assert 1.0 == Expression.evaluate_block!("power(0, 0)") + end + + test "nil raises ArgumentError from :math.pow/2" do + # Known crash behavior, documented not endorsed: nil is passed straight + # to :math.pow/2 which rejects non-numbers. + assert_raise ArgumentError, fn -> evaluate_with_value("power(value, 2)", nil) end + end + + test "context-coerced numeric strings work, literal strings raise" do + # "123" becomes the integer 123 via context coercion before power sees it. + assert 15_129.0 == evaluate_with_value("power(value, 2)", "123") + + # Known crash behavior, documented not endorsed: power does no string + # coercion of its own — even a numeric-looking literal string raises. + assert_raise ArgumentError, fn -> Expression.evaluate_block!(~s|power("2", 3)|) end + assert_raise ArgumentError, fn -> Expression.evaluate_block!(~s|power("hello", 3)|) end + end + + test "booleans, lists, maps and Decimals raise ArgumentError" do + for value <- [true, [1], %{}, Decimal.new("2")] do + assert_raise ArgumentError, fn -> evaluate_with_value("power(value, 2)", value) end + end + end + + test "extracts __value__ from complex values" do + assert 4.0 == evaluate_with_value("power(value, 2)", complex_value(2)) + end + + test "edge cases: negative base with fractional exponent, huge values" do + # Known crash behavior, documented not endorsed: sqrt of a negative + # number raises ArithmeticError rather than returning an error map. + assert_raise ArithmeticError, fn -> Expression.evaluate_block!("power(-2, 0.5)") end + + assert 1.0e38 == evaluate_with_value("power(value, 2)", 10 ** 19) + # credo:disable-for-next-line Credo.Check.Readability.LargeNumbers + assert 1.0715086071862673e301 == Expression.evaluate_block!("power(2, 1000)") + assert 1.0000000000000001e-20 == evaluate_with_value("power(value, 2)", 1.0e-10) + end + end + + describe "rem/2 type handling" do + test "returns integer remainder; sign follows the dividend" do + assert 1 == Expression.evaluate_block!("rem(85, 3)") + assert -1 == Expression.evaluate_block!("rem(-7, 3)") + end + + test "nil raises ArithmeticError" do + # Known crash behavior, documented not endorsed: Kernel.rem/2 with nil. + assert_raise ArithmeticError, fn -> evaluate_with_value("rem(value, 3)", nil) end + end + + test "context-coerced numeric strings work, literal strings raise" do + assert 1 == evaluate_with_value("rem(value, 3)", "85") + + # Known crash behavior, documented not endorsed: no string coercion. + assert_raise ArithmeticError, fn -> Expression.evaluate_block!(~s|rem("85", 3)|) end + end + + test "floats, booleans and lists raise ArithmeticError (integers only)" do + assert_raise ArithmeticError, fn -> Expression.evaluate_block!("rem(85.5, 3)") end + + for value <- [true, [1], %{}] do + assert_raise ArithmeticError, fn -> evaluate_with_value("rem(value, 3)", value) end + end + end + + test "division by zero raises ArithmeticError" do + # Known crash behavior, documented not endorsed: rem(85, 0) crashes + # instead of returning an error map. + assert_raise ArithmeticError, fn -> Expression.evaluate_block!("rem(85, 0)") end + end + + test "extracts __value__ from complex values and handles big integers" do + assert 1 == evaluate_with_value("rem(value, 3)", complex_value(85)) + assert 3 == evaluate_with_value("rem(value, 7)", 10 ** 19) + end + end + + describe "rand_between/2 type handling" do + test "returns an integer within the inclusive range" do + for _ <- 1..20 do + result = Expression.evaluate_block!("rand_between(1, 10)") + assert is_integer(result) + assert result in 1..10 + end + end + + test "degenerate range returns the single member" do + assert 5 == Expression.evaluate_block!("rand_between(5, 5)") + end + + test "nil, floats, literal strings and booleans raise ArgumentError" do + # Known crash behavior, documented not endorsed: Range construction + # (min..max) requires integers on both sides. + assert_raise ArgumentError, fn -> evaluate_with_value("rand_between(value, 10)", nil) end + assert_raise ArgumentError, fn -> Expression.evaluate_block!("rand_between(1.5, 10)") end + assert_raise ArgumentError, fn -> Expression.evaluate_block!(~s|rand_between("1", 10)|) end + assert_raise ArgumentError, fn -> evaluate_with_value("rand_between(value, 3)", true) end + end + + test "reversed bounds still produce a value within the bounds" do + # Surprising but current: 10..1 builds a descending range (with a + # deprecation warning) rather than raising. + result = Expression.evaluate_block!("rand_between(10, 1)") + assert result in 1..10 + end + + test "extracts __value__ from complex values" do + assert 1 == evaluate_with_value("rand_between(value, 1)", complex_value(1)) + end + end + + describe "percent/1 type handling" do + test "formats numbers as percentage strings with 0 precision" do + assert "20%" == Expression.evaluate_block!("percent(0.2)") + assert "20%" == Expression.evaluate_block!("percent(2/10)") + assert "0%" == Expression.evaluate_block!("percent(0)") + assert "-50%" == Expression.evaluate_block!("percent(-0.5)") + # No clamping: values above 1.0 simply exceed 100%. + assert "200%" == Expression.evaluate_block!("percent(2)") + end + + test "numeric-looking strings work (both context-coerced and literal)" do + assert "20%" == evaluate_with_value("percent(value)", "0.2") + # percent/2 runs its argument through parse_float/1, so even a true + # literal string is parsed. + assert "20%" == Expression.evaluate_block!(~s|percent("0.2")|) + end + + test "unparseable literal string returns nil (with-clause passthrough)" do + # Surprising: parse_float/1 returns nil for unparseable strings and the + # `with` in percent/2 passes that nil through as the result. + assert nil == Expression.evaluate_block!(~s|percent("hello")|) + end + + test "nil, booleans, lists and Decimals raise FunctionClauseError" do + # Known crash behavior, documented not endorsed: parse_float/1 only has + # clauses for numbers and binaries. + for value <- [nil, true, [1], %{}, Decimal.new("0.2")] do + assert_raise FunctionClauseError, fn -> evaluate_with_value("percent(value)", value) end + end + end + + test "extracts __value__ from complex values" do + assert "20%" == evaluate_with_value("percent(value)", complex_value(0.2)) + end + + test "very small floats round down to 0%" do + assert "0%" == evaluate_with_value("percent(value)", 1.0e-10) + end + end + + describe "parse_float/1 dispatch (number category, but not callable)" do + # FINDING: parse_float is annotated @expression_category "number" but is + # defined WITHOUT the ctx argument (arity 1). The callback dispatcher looks + # for parse_float/2 (args + ctx), misses, and returns an error string. The + # function is therefore unreachable from expressions; it only serves as an + # internal helper (e.g. for percent/2). + test "every invocation returns the 'wrong number of arguments' error string" do + for expr <- ["parse_float(1.5)", ~s|parse_float("1.5")|, ~s|parse_float("abc")|] do + assert ~s|ERROR: "wrong number of arguments to parse_float."| == + Expression.evaluate_block!(expr) + end + + assert ~s|ERROR: "wrong number of arguments to parse_float."| == + evaluate_with_value("parse_float(value)", nil) + end + end + + describe "max/N (max_vargs) type handling" do + test "returns the maximum of numeric arguments, preserving type" do + assert 3 == Expression.evaluate_block!("max(1, 2, 3)") + assert 2.0 == Expression.evaluate_block!("max(1, 2.0)") + assert 2 == Expression.evaluate_block!("max(1.5, 2, 0)") + assert 1 == Expression.evaluate_block!("max(1)") + end + + test "nil WINS over any number (Erlang term ordering: number < atom)" do + # Surprising: Enum.max/1 uses term ordering, so nil (an atom) is + # considered greater than every number. + assert nil == evaluate_with_value("max(1, value, 3)", nil) + assert nil == evaluate_with_value("max(value, value2)", nil, %{"value2" => nil}) + end + + test "strings, booleans, lists and maps beat numbers via term ordering" do + # number < atom < ... < map < list < bitstring + assert "2" == Expression.evaluate_block!(~s|max(1, "2", 3)|) + assert "b" == Expression.evaluate_block!(~s|max(1, "b", 3)|) + assert true == Expression.evaluate_block!("max(1, true, 3)") + assert [9, 1] == evaluate_with_value("max(value, 1)", [9, 1]) + assert %{} == evaluate_with_value("max(1, value, 3)", %{}) + # Decimal structs are maps, so they also beat plain numbers — but the + # comparison is structural, not numeric. + assert Decimal.new("99") == evaluate_with_value("max(value, 1)", Decimal.new("99")) + end + + test "extracts __value__ from complex values" do + assert 5 == evaluate_with_value("max(value, 2)", complex_value(5)) + end + end + + describe "min/N (min_vargs) type handling" do + test "returns the minimum of numeric arguments" do + assert 1 == Expression.evaluate_block!("min(1, 2, 3)") + end + + test "numbers WIN over nil, strings, booleans and maps (term ordering)" do + # Mirror image of max: numbers sort lowest, so non-numeric junk among + # the arguments is silently ignored rather than raising. + assert 1 == evaluate_with_value("min(1, value, 3)", nil) + assert 2 == evaluate_with_value("min(value, 2)", nil) + assert 1 == Expression.evaluate_block!(~s|min(1, "b", 3)|) + assert 1 == Expression.evaluate_block!("min(1, true)") + assert 1 == evaluate_with_value("min(1, value)", %{}) + end + + test "extracts __value__ from complex values" do + assert 0 == evaluate_with_value("min(value, 2)", complex_value(0)) + end + end + + describe "sum/N (sum_vargs) type handling" do + test "sums numeric arguments, preserving integer/float types" do + assert 6 == Expression.evaluate_block!("sum(1, 2, 3)") + assert 6.5 == Expression.evaluate_block!("sum(1.5, 2, 3)") + assert 1 == Expression.evaluate_block!("sum(1)") + end + + test "nil raises ArithmeticError (unlike max/min which tolerate it)" do + # Known crash behavior, documented not endorsed: Enum.sum/1 does real + # arithmetic, so a single nil argument crashes the whole expression. + assert_raise ArithmeticError, fn -> evaluate_with_value("sum(1, value, 3)", nil) end + end + + test "literal strings, booleans, lists and Decimals raise ArithmeticError" do + # Even a numeric-looking literal string is not coerced. + assert_raise ArithmeticError, fn -> Expression.evaluate_block!(~s|sum(1, "2", 3)|) end + assert_raise ArithmeticError, fn -> Expression.evaluate_block!("sum(1, true, 3)") end + + for value <- [[1, 2], Decimal.new("1.5")] do + assert_raise ArithmeticError, fn -> evaluate_with_value("sum(value, 1)", value) end + end + end + + test "context-coerced numeric strings work" do + assert 6 == evaluate_with_value("sum(1, value, 3)", "2") + end + + test "extracts __value__ from complex values" do + assert 7 == evaluate_with_value("sum(value, 2)", complex_value(5)) + end + + test "big integers sum with exact arithmetic" do + assert 10_000_000_000_000_000_001 == evaluate_with_value("sum(value, 1)", 10 ** 19) + end + end +end diff --git a/test/string_functions_type_test.exs b/test/string_functions_type_test.exs new file mode 100644 index 0000000..bbbd0fc --- /dev/null +++ b/test/string_functions_type_test.exs @@ -0,0 +1,639 @@ +defmodule StringFunctionsTypeTest do + @moduledoc """ + Systematic type-matrix tests for the STRING category of expression functions. + + These tests DOCUMENT CURRENT BEHAVIOR of the V1 engine — they do not endorse + it. Crashes are pinned with `assert_raise` so behavioral changes surface in + CI. Surprising-but-real behaviors get a `# Surprising:` comment explaining + the mechanism. + + Covered: proper, len, left, right, mid, substitute, rept, trim (unimplemented + finding), clean, char, code, unicode, unichar, split, word, word_count, + word_slice, first_word, remove_first_word, remove_last_word, read_digits, + url_encode, url_decode, regex_capture, regex_named_capture. + Excluded: upper and lower — already covered by the pilot in expression_test.exs. + + NOTE on context coercion: `Expression.Context` coerces context values BEFORE + callbacks see them — numeric-looking strings ("123", "3.14") become numbers, + and ISO-date / "true"/"false" strings become structs/booleans. To exercise a + function with a genuine string argument, the string is embedded as a LITERAL + in the expression source (e.g. `proper("foo bar")`), not passed via context. + + A recurring theme below: several functions normalise their input with + `to_string/1` (tolerating nil/numbers), while sibling functions call + `String.split/3` or pattern-match directly (crashing on the same inputs). + These asymmetries are accidental but real, and are pinned as such. + """ + use ExUnit.Case, async: true + + import Expression.Test.TypeTestMatrix + + describe "proper/1 type handling" do + test "capitalizes the first letter of every word" do + assert "Foo Bar" == Expression.evaluate_block!(~s|proper("foo bar")|) + assert "Héllo 👋" == Expression.evaluate_block!(~s|proper("héllo 👋")|) + assert "" == Expression.evaluate_block!(~s|proper("")|) + end + + test "non-binary inputs return nil (is_binary guard), never raise" do + # proper/1 guards on is_binary, so nil, numbers, booleans, lists and maps + # all fall through to the implicit nil. + for value <- [nil, 42, true, [1, 2], %{}] do + assert nil == evaluate_with_value("proper(value)", value) + end + end + + test "extracts __value__ from complex values; nil __value__ returns nil" do + assert "Foo Bar" == evaluate_with_value("proper(value)", complex_value("foo bar")) + assert nil == evaluate_with_value("proper(value)", complex_value(nil)) + end + end + + describe "len/1 type handling" do + test "returns grapheme length, counting emoji as single graphemes" do + assert 5 == Expression.evaluate_block!(~s|len("hello")|) + assert 2 == Expression.evaluate_block!(~s|len("👋🌍")|) + assert 0 == Expression.evaluate_block!(~s|len("")|) + end + + test "nil is treated as the empty string, returning 0" do + # Surprising: len(nil) is 0 rather than raising, because to_string(nil) + # is the empty string "". + assert 0 == evaluate_with_value("len(value)", nil) + end + + test "numbers and booleans are stringified then measured" do + # to_string(12345) -> "12345" (length 5); to_string(true) -> "true". + assert 5 == evaluate_with_value("len(value)", 12_345) + assert 4 == evaluate_with_value("len(value)", true) + end + + test "lists are stringified (charlist-style) before measuring" do + # to_string([1, 2, 3]) interprets the list as a charlist, not "[1, 2, 3]". + assert 3 == evaluate_with_value("len(value)", [1, 2, 3]) + end + + test "maps raise Protocol.UndefinedError (no String.Chars for Map)" do + # Known crash behavior, documented not endorsed: to_string/1 has no + # String.Chars implementation for plain maps. + assert_raise Protocol.UndefinedError, fn -> + evaluate_with_value("len(value)", %{"a" => 1}) + end + end + + test "extracts __value__ from complex values" do + assert 5 == evaluate_with_value("len(value)", complex_value("hello")) + assert 0 == evaluate_with_value("len(value)", complex_value(nil)) + end + end + + describe "left/2 type handling" do + test "returns the first N characters, Unicode-safe" do + assert "foo" == Expression.evaluate_block!(~s|left("foobar", 3)|) + assert "👋🌍" == Expression.evaluate_block!(~s|left("👋🌍ab", 2)|) + # Asking for more than available simply returns the whole string. + assert "ab" == Expression.evaluate_block!(~s|left("ab", 10)|) + end + + test "non-binary inputs return nil (is_binary guard)" do + for value <- [nil, 12_345, [1, 2], %{}] do + assert nil == evaluate_with_value("left(value, 3)", value) + end + end + + test "negative size raises FunctionClauseError" do + # Known crash behavior, documented not endorsed: String.slice/3 has no + # clause for a negative length. + assert_raise FunctionClauseError, fn -> + Expression.evaluate_block!(~s|left("foobar", -2)|) + end + end + + test "extracts __value__ from complex values" do + assert "foo" == evaluate_with_value("left(value, 3)", complex_value("foobar")) + end + end + + describe "right/2 type handling" do + test "returns the last N characters, Unicode-safe" do + assert "ing" == Expression.evaluate_block!(~s|right("testing", 3)|) + assert "ab" == Expression.evaluate_block!(~s|right("ab", 10)|) + assert "" == Expression.evaluate_block!(~s|right("abc", 0)|) + end + + test "non-binary inputs return nil (is_binary guard)" do + for value <- [nil, 12_345, [1, 2], %{}] do + assert nil == evaluate_with_value("right(value, 3)", value) + end + end + + test "extracts __value__ from complex values" do + assert "ing" == evaluate_with_value("right(value, 3)", complex_value("testing")) + end + end + + describe "mid/3 type handling" do + test "returns a substring from a 1-based start for num_chars characters" do + assert "World" == Expression.evaluate_block!(~s|mid("Hello World", 7, 5)|) + end + + test "nil and numbers are stringified first (to_string)" do + # Unlike left/right, mid/3 calls to_string/1, so nil -> "" -> "" and + # numbers are sliced as their string form. + assert "" == evaluate_with_value("mid(value, 1, 3)", nil) + assert "123" == evaluate_with_value("mid(value, 1, 3)", 12_345) + end + + test "maps raise Protocol.UndefinedError" do + # Known crash behavior, documented not endorsed: to_string/1 rejects maps. + assert_raise Protocol.UndefinedError, fn -> + evaluate_with_value("mid(value, 1, 3)", %{}) + end + end + + test "extracts __value__ from complex values" do + assert "Hel" == evaluate_with_value("mid(value, 1, 3)", complex_value("Hello")) + end + end + + describe "substitute/3 type handling" do + test "replaces all occurrences of a pattern" do + assert "I can do" == + Expression.evaluate_block!(~s|substitute("I can't", "can't", "can do")|) + + # Every occurrence is replaced, not just the first. + assert "bbnbnb" == + evaluate_with_value(~s|substitute(value, "a", "b")|, complex_value("banana")) + + # A pattern that does not appear leaves the subject unchanged. + assert "abc" == Expression.evaluate_block!(~s|substitute("abc", "z", "y")|) + end + + test "non-binary subjects return nil (is_binary guard)" do + for value <- [nil, 42] do + assert nil == evaluate_with_value(~s|substitute(value, "a", "b")|, value) + end + end + + test "extracts __value__ from complex values" do + assert "I can do" == + evaluate_with_value( + ~s|substitute(value, "can't", "can do")|, + complex_value("I can't") + ) + end + end + + describe "rept/2 type handling" do + test "repeats a string a given number of times" do + assert "*****" == Expression.evaluate_block!(~s|rept("*", 5)|) + assert "" == Expression.evaluate_block!(~s|rept("a", 0)|) + end + + test "non-binary values return nil (is_binary guard)" do + for value <- [nil, 42] do + assert nil == evaluate_with_value("rept(value, 5)", value) + end + end + + test "negative count raises ArgumentError" do + # Known crash behavior, documented not endorsed: String.duplicate/2 + # rejects a negative count. + assert_raise ArgumentError, fn -> Expression.evaluate_block!(~s|rept("a", -1)|) end + end + + test "extracts __value__ from complex values" do + assert "xxx" == evaluate_with_value("rept(value, 3)", complex_value("x")) + end + end + + describe "trim/1 dispatch (string category, but NOT implemented)" do + # FINDING: there is no `trim` callback in Expression.Callbacks.Standard, so + # the dispatcher returns the "not implemented" error string for every call. + # FLOIP defines trim() but this engine never implemented it. + test "every invocation returns the 'trim is not implemented' error string" do + assert ~s|ERROR: "trim is not implemented."| == + Expression.evaluate_block!(~s|trim(" hi ")|) + + assert ~s|ERROR: "trim is not implemented."| == + evaluate_with_value("trim(value)", nil) + end + end + + describe "clean/1 type handling" do + test "removes non-printable characters" do + assert "ABC" == evaluate_with_value("clean(value)", <<65, 0, 66, 0, 67>>) + # Printable Unicode (including emoji) is preserved. + assert "héllo👋" == Expression.evaluate_block!(~s|clean("héllo👋")|) + end + + test "nil and numbers are stringified (to_string), never raise" do + assert "" == evaluate_with_value("clean(value)", nil) + assert "42" == evaluate_with_value("clean(value)", 42) + end + + test "maps raise Protocol.UndefinedError" do + # Known crash behavior, documented not endorsed: to_string/1 rejects maps. + assert_raise Protocol.UndefinedError, fn -> evaluate_with_value("clean(value)", %{}) end + end + + test "extracts __value__ from complex values" do + assert "AB" == evaluate_with_value("clean(value)", complex_value(<<65, 0, 66>>)) + end + end + + describe "char/1 type handling" do + test "returns the single byte for a codepoint via <>" do + assert "A" == Expression.evaluate_block!("char(65)") + end + + test "is byte-based, not codepoint-based: emits raw (possibly invalid) bytes" do + # Surprising: char/1 builds <> (a single byte), so 233 yields the + # Latin-1 byte <<233>>, which is NOT valid UTF-8, rather than "é". + result = Expression.evaluate_block!("char(233)") + assert <<233>> == result + refute String.valid?(result) + end + + test "wraps modulo 256: negative and >255 codes truncate to one byte" do + # Surprising: <<-1>> == <<255>> and <<256>> == <<0>>; no validation. + assert <<255>> == Expression.evaluate_block!("char(-1)") + assert <<0>> == Expression.evaluate_block!("char(256)") + end + + test "nil, strings and floats raise ArgumentError" do + # Known crash behavior, documented not endorsed: <> requires an + # integer; nil, a string literal and a float all fail binary construction. + assert_raise ArgumentError, fn -> evaluate_with_value("char(value)", nil) end + assert_raise ArgumentError, fn -> Expression.evaluate_block!(~s|char("A")|) end + assert_raise ArgumentError, fn -> Expression.evaluate_block!("char(65.5)") end + end + + test "extracts __value__ from complex values" do + assert "A" == evaluate_with_value("char(value)", complex_value(65)) + end + end + + describe "code/1 type handling" do + test "returns the numeric code of the first character" do + assert 65 == Expression.evaluate_block!(~s|code("A")|) + end + + test "nil returns nil (guarded with `if code`)" do + assert nil == evaluate_with_value("code(value)", nil) + end + + test "empty string and multi-byte first chars raise MatchError" do + # Known crash behavior, documented not endorsed: code/1 matches the input + # against the single-byte pattern <>, which fails for "" and for any + # multi-byte first grapheme (e.g. an emoji). + assert_raise MatchError, fn -> Expression.evaluate_block!(~s|code("")|) end + assert_raise MatchError, fn -> Expression.evaluate_block!(~s|code("👋")|) end + end + + test "non-binary truthy values (e.g. an integer) raise MatchError" do + # Known crash behavior, documented not endorsed: an integer is truthy so + # it enters the body, then fails the <> = code match. + assert_raise MatchError, fn -> evaluate_with_value("code(value)", 65) end + end + + test "extracts __value__ from complex values" do + assert 65 == evaluate_with_value("code(value)", complex_value("A")) + end + end + + describe "unicode/1 type handling" do + test "returns the Unicode codepoint of the first character" do + assert 65 == Expression.evaluate_block!(~s|unicode("A")|) + assert 233 == Expression.evaluate_block!(~s|unicode("é")|) + end + + test "nil, empty string and non-strings raise MatchError" do + # Known crash behavior, documented not endorsed: unicode/1 matches + # <> against its argument with no guard; nil, "" and integers + # all fail the match. + assert_raise MatchError, fn -> evaluate_with_value("unicode(value)", nil) end + assert_raise MatchError, fn -> Expression.evaluate_block!(~s|unicode("")|) end + assert_raise MatchError, fn -> evaluate_with_value("unicode(value)", 65) end + end + + test "extracts __value__ from complex values" do + assert 233 == evaluate_with_value("unicode(value)", complex_value("é")) + end + end + + describe "unichar/1 type handling" do + test "returns the Unicode character for a codepoint (utf8-aware)" do + assert "A" == Expression.evaluate_block!("unichar(65)") + assert "é" == Expression.evaluate_block!("unichar(233)") + # Unlike char/1, unichar/1 is codepoint-aware and handles astral planes. + assert "👋" == Expression.evaluate_block!("unichar(128075)") + end + + test "nil, strings and negative/invalid codepoints raise ArgumentError" do + # Known crash behavior, documented not endorsed: <> requires a + # valid non-negative codepoint integer. + assert_raise ArgumentError, fn -> evaluate_with_value("unichar(value)", nil) end + assert_raise ArgumentError, fn -> Expression.evaluate_block!(~s|unichar("A")|) end + assert_raise ArgumentError, fn -> Expression.evaluate_block!("unichar(-1)") end + end + + test "extracts __value__ from complex values" do + assert "é" == evaluate_with_value("unichar(value)", complex_value(233)) + end + end + + describe "split/1 and split/2 type handling" do + test "split/1 splits on single spaces; split/2 on a custom pattern" do + assert ["a", "b", "c"] == Expression.evaluate_block!(~s|split("a b c")|) + assert ["a", "b", "c"] == Expression.evaluate_block!(~s|split("a,b,c", ",")|) + end + + test "nil and numbers raise FunctionClauseError" do + # Known crash behavior, documented not endorsed: split calls String.split/2 + # directly on the (uncoerced) input, which has no clause for non-binaries. + assert_raise FunctionClauseError, fn -> evaluate_with_value("split(value)", nil) end + assert_raise FunctionClauseError, fn -> evaluate_with_value("split(value)", 42) end + end + + test "extracts __value__ from complex values" do + assert ["a", "b", "c"] == evaluate_with_value("split(value)", complex_value("a b c")) + end + end + + describe "word/2 and word/3 type handling" do + test "extracts the nth word, splitting on punctuation by default" do + assert "cow" == Expression.evaluate_block!(~s|word("hello cow-boy", 2)|) + # by_spaces: true splits only on spaces, keeping the hyphenated token. + assert "cow-boy" == Expression.evaluate_block!(~s|word("hello cow-boy", 2, true)|) + # Negative n counts back from the end. + assert "boy" == Expression.evaluate_block!(~s|word("hello cow-boy", -1)|) + end + + test "nil and numbers are stringified first (to_string)" do + # to_string(nil) -> "" yields a single empty word at index 1. + assert "" == evaluate_with_value("word(value, 1)", nil) + assert "42" == evaluate_with_value("word(value, 1)", 42) + end + + test "out-of-range index raises MatchError" do + # Known crash behavior, documented not endorsed: Enum.slice returns [] for + # an out-of-range index, which fails the `[part] = ...` match. + assert_raise MatchError, fn -> Expression.evaluate_block!(~s|word("a b", 5)|) end + end + + test "extracts __value__ from complex values" do + assert "cow" == evaluate_with_value("word(value, 2)", complex_value("hello cow-boy")) + end + end + + describe "word_count/1 and word_count/2 type handling" do + test "counts words, splitting on punctuation by default" do + assert 3 == Expression.evaluate_block!(~s|word_count("hello cow-boy")|) + # by_spaces: true counts the hyphenated token as one word. + assert 2 == Expression.evaluate_block!(~s|word_count("hello cow-boy", true)|) + end + + test "the empty string counts as 1 word, not 0" do + # Surprising: String.split("", pattern) returns [""], so the count is 1. + assert 1 == Expression.evaluate_block!(~s|word_count("")|) + end + + test "nil short-circuits to 0, but numbers raise FunctionClauseError" do + # Surprising asymmetry: word_count has an explicit `is_nil` guard returning + # 0, but no to_string fallback — so a number falls straight into + # String.split/2 and crashes. + assert 0 == evaluate_with_value("word_count(value)", nil) + + assert_raise FunctionClauseError, fn -> evaluate_with_value("word_count(value)", 42) end + end + + test "extracts __value__ from complex values" do + assert 3 == evaluate_with_value("word_count(value)", complex_value("hello cow-boy")) + end + end + + describe "word_slice/2, /3 and /4 type handling" do + test "slices a range of words" do + assert "expressions are fun" == + Expression.evaluate_block!(~s|word_slice("FLOIP expressions are fun", 2)|) + + assert "expressions are" == + Expression.evaluate_block!(~s|word_slice("FLOIP expressions are fun", 2, 4)|) + + # Negative start counts back from the end. + assert "fun" == + Expression.evaluate_block!(~s|word_slice("FLOIP expressions are fun", -1)|) + + # Negative stop is relative to the end too. + assert "a b" == Expression.evaluate_block!(~s|word_slice("a b c d", 1, -2)|) + end + + test "nil is stringified to the empty string, returning empty" do + assert "" == evaluate_with_value("word_slice(value, -1)", nil) + end + + test "a start of 0 raises CondClauseError (no clause for start == 0)" do + # Known crash behavior, documented not endorsed: word_slice/2 only has + # cond clauses for start > 0 and start < 0, so 0 falls through. + assert_raise CondClauseError, fn -> + Expression.evaluate_block!(~s|word_slice("a b c", 0)|) + end + end + + test "extracts __value__ from complex values" do + assert "expressions are fun" == + evaluate_with_value( + "word_slice(value, 2)", + complex_value("FLOIP expressions are fun") + ) + end + end + + describe "first_word/1 type handling" do + test "returns the first space-delimited word" do + assert "foo" == Expression.evaluate_block!(~s|first_word("foo bar baz")|) + end + + test "nil and numbers are stringified first (to_string)" do + # first_word always splits a non-empty list, so nil -> "" -> [""] -> "". + assert "" == evaluate_with_value("first_word(value)", nil) + assert "42" == evaluate_with_value("first_word(value)", 42) + end + + test "maps raise Protocol.UndefinedError" do + # Known crash behavior, documented not endorsed: to_string/1 rejects maps. + assert_raise Protocol.UndefinedError, fn -> + evaluate_with_value("first_word(value)", %{}) + end + end + + test "extracts __value__ from complex values" do + assert "foo" == evaluate_with_value("first_word(value)", complex_value("foo bar")) + end + end + + describe "remove_first_word/1 and /2 type handling" do + test "removes the first word, default or custom separator" do + assert "bar" == Expression.evaluate_block!(~s|remove_first_word("foo bar")|) + assert "bar" == Expression.evaluate_block!(~s|remove_first_word("foo-bar", "-")|) + # A single word leaves nothing behind. + assert "" == Expression.evaluate_block!(~s|remove_first_word("foo")|) + end + + test "nil and numbers are stringified first (to_string), returning empty" do + # remove_first_word uses to_string, so non-strings degrade gracefully. + assert "" == evaluate_with_value("remove_first_word(value)", nil) + assert "" == evaluate_with_value(~s|remove_first_word(value, "-")|, nil) + assert "" == evaluate_with_value("remove_first_word(value)", 42) + end + + test "extracts __value__ from complex values" do + assert "bar" == evaluate_with_value("remove_first_word(value)", complex_value("foo bar")) + end + end + + describe "remove_last_word/1 and /2 type handling" do + test "removes the last word, default or custom separator" do + assert "foo" == Expression.evaluate_block!(~s|remove_last_word("foo bar")|) + assert "foo" == Expression.evaluate_block!(~s|remove_last_word("foo-bar", "-")|) + assert "" == Expression.evaluate_block!(~s|remove_last_word("foo")|) + end + + test "nil and numbers raise FunctionClauseError (NO to_string, unlike remove_first_word)" do + # Surprising asymmetry, documented not endorsed: remove_last_word calls + # String.split/2 on the raw input without the to_string normalisation that + # remove_first_word performs, so the same nil/number inputs crash here. + assert_raise FunctionClauseError, fn -> + evaluate_with_value("remove_last_word(value)", nil) + end + + assert_raise FunctionClauseError, fn -> + evaluate_with_value("remove_last_word(value)", 42) + end + end + + test "extracts __value__ from complex values" do + assert "foo" == evaluate_with_value("remove_last_word(value)", complex_value("foo bar")) + end + end + + describe "read_digits/1 type handling" do + test "spells out digits and the plus sign for TTS" do + assert "plus two seven one" == Expression.evaluate_block!(~s|read_digits("+271")|) + # Non-digit, non-plus characters are dropped entirely. + assert "" == Expression.evaluate_block!(~s|read_digits("abc")|) + end + + test "nil and numbers are stringified first (to_string)" do + assert "" == evaluate_with_value("read_digits(value)", nil) + # An integer has no leading '+', so only its digits are spelled out. + assert "two seven one" == evaluate_with_value("read_digits(value)", 271) + end + + test "maps raise Protocol.UndefinedError" do + # Known crash behavior, documented not endorsed: to_string/1 rejects maps. + assert_raise Protocol.UndefinedError, fn -> + evaluate_with_value("read_digits(value)", %{}) + end + end + + test "extracts __value__ from complex values" do + assert "plus two seven one" == + evaluate_with_value("read_digits(value)", complex_value("+271")) + end + end + + describe "url_encode/1 type handling" do + test "percent-encodes a string" do + assert "hello%20world" == Expression.evaluate_block!(~s|url_encode("hello world")|) + # URI.encode leaves sub-delims like & untouched by default. + assert "a%20b&c" == Expression.evaluate_block!(~s|url_encode("a b&c")|) + end + + test "nil and numbers are stringified first (to_string)" do + assert "" == evaluate_with_value("url_encode(value)", nil) + assert "42" == evaluate_with_value("url_encode(value)", 42) + end + + test "maps raise Protocol.UndefinedError" do + # Known crash behavior, documented not endorsed: to_string/1 rejects maps. + assert_raise Protocol.UndefinedError, fn -> + evaluate_with_value("url_encode(value)", %{}) + end + end + + test "extracts __value__ from complex values" do + assert "hello%20world" == + evaluate_with_value("url_encode(value)", complex_value("hello world")) + end + end + + describe "url_decode/1 type handling" do + test "percent-decodes a string" do + assert "hello world" == Expression.evaluate_block!(~s|url_decode("hello%20world")|) + end + + test "nil and numbers raise FunctionClauseError (NO to_string, unlike url_encode)" do + # Surprising asymmetry, documented not endorsed: url_decode calls + # URI.decode/1 directly on its input without the to_string normalisation + # that url_encode performs, so non-binaries crash here but not there. + assert_raise FunctionClauseError, fn -> evaluate_with_value("url_decode(value)", nil) end + assert_raise FunctionClauseError, fn -> evaluate_with_value("url_decode(value)", 42) end + end + + test "extracts __value__ from complex values" do + assert "hello world" == + evaluate_with_value("url_decode(value)", complex_value("hello%20world")) + end + end + + describe "regex_capture/2 type handling" do + test "returns capture groups, or nil when nothing matches" do + assert ["ing"] == Expression.evaluate_block!(~s|regex_capture("testing", "test(.+)")|) + assert nil == Expression.evaluate_block!(~s|regex_capture("testing", "foo(.+)")|) + end + + test "non-binary subjects return nil (is_binary guard)" do + assert nil == evaluate_with_value(~s|regex_capture(value, "x")|, nil) + assert nil == evaluate_with_value(~s|regex_capture(value, "4")|, 42) + end + + test "an invalid regex pattern raises Regex.CompileError" do + # Known crash behavior, documented not endorsed: Regex.compile!/1 raises + # on a malformed pattern rather than returning an error. + assert_raise Regex.CompileError, fn -> + Expression.evaluate_block!(~s|regex_capture("testing", "(")|) + end + end + + test "extracts __value__ from complex values" do + assert ["ing"] == + evaluate_with_value(~s|regex_capture(value, "test(.+)")|, complex_value("testing")) + end + end + + describe "regex_named_capture/2 type handling" do + test "returns a map of named captures, or an empty map when nothing matches" do + assert %{"m" => "ing"} == + Expression.evaluate_block!(~s|regex_named_capture("testing", "test(?P.+)")|) + + assert %{} == + Expression.evaluate_block!(~s|regex_named_capture("testing", "foo(?P.+)")|) + end + + test "non-binary subjects return an empty map (explicit else branch)" do + # Unlike regex_capture (which returns nil), the named variant has an else + # branch returning %{} for non-binary subjects. + assert %{} == evaluate_with_value(~s|regex_named_capture(value, "x")|, nil) + end + + test "extracts __value__ from complex values" do + assert %{"m" => "ing"} == + evaluate_with_value( + ~s|regex_named_capture(value, "test(?P.+)")|, + complex_value("testing") + ) + end + end +end diff --git a/test/support/crash_safe.ex b/test/support/crash_safe.ex new file mode 100644 index 0000000..830a296 --- /dev/null +++ b/test/support/crash_safe.ex @@ -0,0 +1,73 @@ +defmodule Expression.Test.CrashSafe do + @moduledoc """ + Single source of truth for the expression functions empirically confirmed + crash-safe — i.e. they never raise, returning a value or an error map — across + the full type matrix (`Expression.Test.TypeTestMatrix.all_test_values/0`). + + Two suites consume this list so the classification cannot silently drift: + + * `ExpressionFuzzTest` fuzzes each entry with random inputs (the no-crash + invariant under exploration). + * `CrashSafeClassificationTest` runs each entry against the full type matrix + deterministically (the no-crash invariant pinned, every CI run). + + Each entry is `{label, expression}` where `value` is the argument under test, + bound to the `value` key in the context. Multi-argument functions exercise the + first argument and hold the rest fixed. + + Functions NOT listed here currently raise on at least some inputs; those + crashes are pinned per-function in the `*_functions_type_test.exs` files. + Moving a function in here is only valid once it has been hardened to return an + error map instead of raising. + """ + + @groups [ + {"string", + [ + {"upper", "upper(value)"}, + {"lower", "lower(value)"}, + {"proper", "proper(value)"}, + {"trim", "trim(value)"} + ]}, + {"logical", + [ + {"not", "not(value)"}, + {"if", "if(value, 1, 2)"}, + {"and", "and(value, true)"}, + {"or", "or(value, false)"}, + {"isnumber", "isnumber(value)"}, + {"isbool", "isbool(value)"}, + {"isstring", "isstring(value)"}, + {"is_error", "is_error(value)"}, + {"is_nil_or_empty", "is_nil_or_empty(value)"} + ]}, + {"number", + [ + {"max", "max(value, 1)"}, + {"min", "min(value, 1)"} + ]}, + {"enum", + [ + {"uniq", "uniq(value)"}, + {"with_index", "with_index(value)"}, + {"has_all_members", "has_all_members(value, [1])"}, + {"has_any_member", "has_any_member(value, [1])"} + ]}, + {"other", + [ + {"json", "json(value)"} + ]} + ] + + @doc "Crash-safe functions grouped by category: `[{category, [{label, expr}]}]`." + def groups, do: @groups + + @doc "All crash-safe `{label, expression}` entries, flattened across categories." + def all, do: Enum.flat_map(@groups, fn {_category, entries} -> entries end) + + @doc "The `{label, expression}` entries for a single category." + def group(category) do + {^category, entries} = List.keyfind(@groups, category, 0) + entries + end +end diff --git a/test/support/fuzz_helpers.ex b/test/support/fuzz_helpers.ex new file mode 100644 index 0000000..c6c2b1c --- /dev/null +++ b/test/support/fuzz_helpers.ex @@ -0,0 +1,132 @@ +defmodule Expression.Test.FuzzHelpers do + @moduledoc """ + StreamData generators and assertion helpers for property-based (fuzz) + testing of expression functions. + + The goal of fuzz testing here is a single invariant: evaluating any + expression function with arbitrary inputs must never raise. Returning an + error map (`__type__ => "expression/v1error"`) is an acceptable outcome. + """ + + import ExUnit.Assertions + + @doc "Generates any value the expression runtime might encounter." + def any_value do + StreamData.one_of([ + StreamData.constant(nil), + StreamData.boolean(), + StreamData.integer(), + StreamData.float(), + string_value(), + list_value(), + map_value(), + date_value(), + datetime_value(), + complex_value(scalar_value()) + ]) + end + + @doc """ + Generates values biased toward enumerables (lists and maps), with occasional + non-enumerable scalars mixed in. Used to fuzz enum-category functions, whose + interesting code paths only run on lists/maps — `any_value/0` would mostly hit + the trivial guard branch, overstating coverage relative to the run count. + """ + def enumerable_value do + StreamData.frequency([ + {6, list_value()}, + {3, map_value()}, + {2, any_value()} + ]) + end + + @doc "Generates strings: printable utf8, including empty and numeric-looking." + def string_value do + StreamData.one_of([ + StreamData.string(:printable), + StreamData.string(:alphanumeric), + StreamData.constant(""), + StreamData.map(StreamData.integer(), &to_string/1), + StreamData.map(StreamData.float(), &to_string/1) + ]) + end + + @doc "Generates integers and floats." + def numeric_value do + StreamData.one_of([StreamData.integer(), StreamData.float()]) + end + + @doc "Generates lists of scalar values, possibly nested one level." + def list_value do + StreamData.list_of( + StreamData.one_of([scalar_value(), StreamData.list_of(scalar_value(), max_length: 3)]), + max_length: 5 + ) + end + + @doc "Generates string-keyed maps of scalar values." + def map_value do + StreamData.map_of(StreamData.string(:alphanumeric, min_length: 1), scalar_value(), + max_length: 4 + ) + end + + @doc "Generates Date values." + def date_value do + StreamData.map( + StreamData.integer(0..3_000), + &Date.add(~D[2020-01-01], &1 - 1500) + ) + end + + @doc "Generates DateTime values." + def datetime_value do + StreamData.map( + StreamData.integer(-50_000_000..50_000_000), + &DateTime.add(~U[2020-01-01 00:00:00Z], &1, :second) + ) + end + + @doc "Wraps a generator's values in a complex map with a `__value__` key." + def complex_value(inner_generator) do + StreamData.map(inner_generator, &%{"__value__" => &1, "label" => "fuzz"}) + end + + defp scalar_value do + StreamData.one_of([ + StreamData.constant(nil), + StreamData.boolean(), + StreamData.integer(), + StreamData.float(), + StreamData.string(:printable) + ]) + end + + @doc """ + Asserts that evaluating `expression` with `context` does not raise. + + Any return value — including error maps — passes. Only a raised exception + fails the assertion. + """ + def assert_no_crash(expression, context \\ %{}) do + Expression.evaluate_block!(expression, context) + rescue + exception -> + flunk(""" + Expression crashed instead of returning a value or an error map. + + Expression: #{inspect(expression)} + Context: #{inspect(context)} + Raised: #{Exception.format(:error, exception, __STACKTRACE__)} + """) + catch + kind, reason -> + flunk(""" + Expression #{kind} instead of returning a value or an error map. + + Expression: #{inspect(expression)} + Context: #{inspect(context)} + Caught: #{Exception.format(kind, reason, __STACKTRACE__)} + """) + end +end diff --git a/test/support/type_test_matrix.ex b/test/support/type_test_matrix.ex new file mode 100644 index 0000000..a673b65 --- /dev/null +++ b/test/support/type_test_matrix.ex @@ -0,0 +1,94 @@ +defmodule Expression.Test.TypeTestMatrix do + @moduledoc """ + Standard test values for systematic type testing of expression functions. + + Provides a canonical set of sample values for every type the expression + language can encounter at runtime, so type-handling tests across functions + exercise the same inputs consistently. + """ + + @doc "All sample values across every category, as a flat list." + def all_test_values do + Enum.flat_map(categories(), &test_values_for/1) + end + + @doc "The list of value categories available in the matrix." + def categories do + [ + :nil_value, + :boolean, + :integer, + :float, + :string, + :list, + :map, + :complex, + :date, + :decimal, + :error + ] + end + + @doc "Sample values for a single category." + def test_values_for(:nil_value), do: [nil] + def test_values_for(:boolean), do: [true, false] + def test_values_for(:integer), do: [0, 1, 42, -7, 1_000_000_000_000] + def test_values_for(:float), do: [0.0, 3.14, -2.5, 1.0e-10] + + def test_values_for(:string), + do: ["", "hello", "123", "3.14", "héllo wörld", "👋🌍", " ", "hello world"] + + def test_values_for(:list), + do: [[], [1, 2, 3], ["a", "b"], [1, "two", true, nil], [[1, 2], [3, 4]]] + + def test_values_for(:map), + do: [%{}, %{"key" => "value"}, %{"outer" => %{"inner" => 1}}] + + def test_values_for(:complex), + do: [complex_value("text"), complex_value(42), complex_value(nil)] + + def test_values_for(:date), + do: [~D[2023-06-15], ~T[10:30:00], ~U[2023-06-15 10:30:00Z], ~N[2023-06-15 10:30:00]] + + def test_values_for(:decimal), do: [Decimal.new("1.5"), Decimal.new(0)] + def test_values_for(:error), do: [error_value()] + + @doc """ + Values generally invalid for the given function category — useful for + asserting graceful handling of type mismatches. + """ + def invalid_values_for(:string), do: test_values_for(:list) ++ test_values_for(:map) + + def invalid_values_for(:number), + do: ["hello", true, [], %{}, ~D[2023-06-15]] + + def invalid_values_for(:date), do: ["not a date", 123, true, [], %{}] + def invalid_values_for(:enum), do: ["hello", 123, true, %{"key" => "value"}] + def invalid_values_for(:logical), do: [[], %{}, ~D[2023-06-15]] + + @doc """ + A complex value: a map carrying a `__value__` key, as produced by flow + results. Functions are expected to extract `__value__` as the default value. + """ + def complex_value(value, extra \\ %{}) do + Map.merge(%{"__value__" => value, "label" => "complex"}, extra) + end + + @doc "An error map as returned by the V1 engine when evaluation fails." + def error_value(message \\ "Something went wrong") do + %{ + "__type__" => "expression/v1error", + "__value__" => nil, + "error" => true, + "message" => message + } + end + + @doc """ + Evaluates `expression` (block syntax, no leading `@`) with `value` bound to + the `value` key in the context. Convenience for type matrix tests. + """ + def evaluate_with_value(expression, value, extra_context \\ %{}) do + Expression.evaluate_block!(expression, Map.merge(%{"value" => value}, extra_context)) + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index 869559e..7cd8f40 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1 +1,2 @@ -ExUnit.start() +# Support modules in test/support are compiled via elixirc_paths (see mix.exs). +ExUnit.start(exclude: [:fuzz])