diff --git a/lib/microwaveprop/weather/grid_cache.ex b/lib/microwaveprop/weather/grid_cache.ex index 5631cbde..c3776e7d 100644 --- a/lib/microwaveprop/weather/grid_cache.ex +++ b/lib/microwaveprop/weather/grid_cache.ex @@ -392,7 +392,7 @@ defmodule Microwaveprop.Weather.GridCache do case Valkey.get(redis_key) do {:ok, %{} = chunk_map} -> - case Map.get(chunk_map, {lat, lon}) do + case chunk_map |> Map.get(chunk_key, %{}) |> Map.get({lat, lon}) do nil -> emit_lookup(false) :miss diff --git a/test/microwaveprop/weather/grid_cache_valkey_test.exs b/test/microwaveprop/weather/grid_cache_valkey_test.exs index d5b22854..ededd4b6 100644 --- a/test/microwaveprop/weather/grid_cache_valkey_test.exs +++ b/test/microwaveprop/weather/grid_cache_valkey_test.exs @@ -1,4 +1,4 @@ -defmodule Microwaveprop.Weather.GridCacheValkeyTest do +defmodule Microwaveprop.Weather.GridCacheValkey.EmptyCacheTest do use ExUnit.Case, async: false alias Microwaveprop.Valkey.Conn @@ -14,8 +14,8 @@ defmodule Microwaveprop.Weather.GridCacheValkeyTest do def command(_conn, ["ZRANGEBYSCORE", "prop:wg:vts", "-inf", _], _opts), do: {:ok, []} def command(_conn, ["GET" | _], _opts), do: {:ok, nil} def command(_conn, ["MGET" | _], _opts), do: {:ok, [nil]} + def command(_conn, ["SCAN", "0", "MATCH", _, "COUNT", "500"], _opts), do: {:ok, ["0", []]} def command(_conn, _, _opts), do: {:ok, ["0", []]} - @impl true def pipeline(_conn, cmds, _opts), do: {:ok, List.duplicate("OK", length(cmds))} end @@ -23,7 +23,6 @@ defmodule Microwaveprop.Weather.GridCacheValkeyTest do setup do prev_url = Application.get_env(:microwaveprop, :valkey_url) prev_adapter = Application.get_env(:microwaveprop, :valkey_adapter) - Application.put_env(:microwaveprop, :valkey_url, "redis://localhost:6379") Application.put_env(:microwaveprop, :valkey_adapter, StubAdapter) @@ -34,7 +33,6 @@ defmodule Microwaveprop.Weather.GridCacheValkeyTest do {:ok, pid} = Task.start(fn -> Process.sleep(:infinity) end) Process.register(pid, Conn) - GridCache.clear() on_exit(fn -> @@ -55,58 +53,144 @@ defmodule Microwaveprop.Weather.GridCacheValkeyTest do :ok end - describe "valkey backend" do - test "fetch returns :miss when ZREVRANGE returns empty" do - assert GridCache.fetch(~U[2026-05-01 12:00:00Z]) == :miss - end + test "fetch returns :miss when ZREVRANGE returns empty" do + assert GridCache.fetch(~U[2026-05-01 12:00:00Z]) == :miss + end - test "latest_valid_time returns nil when ZSET empty" do - assert GridCache.latest_valid_time() == nil - end + test "latest_valid_time returns nil when ZSET empty" do + assert GridCache.latest_valid_time() == nil + end - test "prune_older_than returns 0 when no expired entries" do - assert GridCache.prune_older_than(~U[2026-05-01 00:00:00Z]) == 0 - end + test "prune_older_than returns 0 when no expired entries" do + assert GridCache.prune_older_than(~U[2026-05-01 00:00:00Z]) == 0 + end - test "broadcast_put writes to valkey" do - vt = ~U[2026-05-01 12:00:00Z] - assert GridCache.broadcast_put(vt, [%{lat: 32.0, lon: -97.0, temperature: 25.0}]) == :ok - end + test "broadcast_put writes to valkey" do + assert GridCache.broadcast_put(~U[2026-05-01 12:00:00Z], [%{lat: 32.0, lon: -97.0, temperature: 25.0}]) == :ok + end - test "put writes chunks and updates index" do - vt = ~U[2026-05-01 12:00:00Z] - assert GridCache.put(vt, [%{lat: 32.0, lon: -97.0, temperature: 25.0}]) == :ok - end + test "put writes chunks and updates index" do + assert GridCache.put(~U[2026-05-01 12:00:00Z], [%{lat: 32.0, lon: -97.0, temperature: 25.0}]) == :ok + end - test "put with large number of rows" do - vt = ~U[2026-05-01 12:00:00Z] - rows = for i <- 0..19, j <- 0..19, do: %{lat: 32.0 + i * 0.25, lon: -97.0 + j * 0.25, temperature: 20.0} - assert GridCache.put(vt, rows) == :ok - end + test "fetch_point returns :miss for unknown point" do + assert GridCache.fetch_point(~U[2026-05-01 12:00:00Z], 32.0, -97.0) == :miss + end - test "put handles pipeline error gracefully" do - # Restart GridCache to set up a failing stub - # The default StubAdapter always succeeds — the failure path - # (logging on :pipeline error) is covered by the existing Valkey tests. - vt = ~U[2026-05-01 12:00:00Z] - assert GridCache.put(vt, [%{lat: 32.0, lon: -97.0}]) == :ok - end + test "fetch_bounds returns :miss for empty cache" do + assert GridCache.fetch_bounds(~U[2026-05-01 12:00:00Z], %{"south" => 30, "north" => 35, "west" => -100, "east" => -95}) == + :miss + end - test "fetch_point returns :miss for unknown point" do - assert GridCache.fetch_point(~U[2026-05-01 12:00:00Z], 32.0, -97.0) == :miss - end - - test "fetch_bounds returns :miss for empty cache" do - bounds = %{"south" => 30.0, "north" => 35.0, "west" => -100.0, "east" => -95.0} - assert GridCache.fetch_bounds(~U[2026-05-01 12:00:00Z], bounds) == :miss - end - - test "claim_fill and release_fill work" do - vt = ~U[2026-05-01 12:00:00Z] - assert GridCache.claim_fill(vt) == true - assert GridCache.claim_fill(vt) == false - GridCache.release_fill(vt) - assert GridCache.claim_fill(vt) == true - end + test "claim_fill and release_fill work" do + vt = ~U[2026-05-01 12:00:00Z] + assert GridCache.claim_fill(vt) == true + assert GridCache.claim_fill(vt) == false + GridCache.release_fill(vt) + assert GridCache.claim_fill(vt) == true + end +end + +defmodule Microwaveprop.Weather.GridCacheValkey.DataCacheTest do + use ExUnit.Case, async: false + + alias Microwaveprop.Valkey.Conn + alias Microwaveprop.Weather.GridCache + + defmodule DataStubAdapter do + @moduledoc false + @behaviour Microwaveprop.Valkey.Adapter + + @vt_iso "2026-05-01T12:00:00Z" + @chunk_key "prop:wg:#{@vt_iso}:6:-20" + @chunk_cells %{ + {32.0, -97.0} => %{lat: 32.0, lon: -97.0, temperature: 25.0}, + {40.0, -74.0} => %{lat: 40.0, lon: -74.0, temperature: 20.0} + } + @chunk_data %{{6, -20} => @chunk_cells} + # For fetch_point, the code does Map.get(chunk_map, {lat, lon}) + # which looks up directly in the outer map. Precompute what the + # actual lookup would return so we can stub correctly. + @encoded :erlang.term_to_binary(@chunk_data) + + @impl true + def command(_conn, ["ZREVRANGE", "prop:wg:vts", "0", "0"], _opts), do: {:ok, [@vt_iso]} + + def command(_conn, ["SCAN", "0", "MATCH", "prop:wg:#{@vt_iso}:*", "COUNT", "500"], _opts), + do: {:ok, ["0", [@chunk_key]]} + + def command(_conn, ["MGET" | tail], _opts) do + vals = + Enum.map(tail, fn + @chunk_key -> @encoded + _ -> nil + end) + + {:ok, vals} + end + + def command(_conn, ["GET", @chunk_key], _opts), do: {:ok, @encoded} + def command(_conn, ["GET" | _], _opts), do: {:ok, nil} + def command(_conn, ["ZRANGEBYSCORE", "prop:wg:vts", "-inf", _], _opts), do: {:ok, []} + def command(_conn, _, _opts), do: {:ok, ["0", []]} + @impl true + def pipeline(_conn, cmds, _opts), do: {:ok, List.duplicate("OK", length(cmds))} + end + + setup do + prev_url = Application.get_env(:microwaveprop, :valkey_url) + prev_adapter = Application.get_env(:microwaveprop, :valkey_adapter) + Application.put_env(:microwaveprop, :valkey_url, "redis://localhost:6379") + Application.put_env(:microwaveprop, :valkey_adapter, DataStubAdapter) + + case Process.whereis(Conn) do + nil -> :ok + _ -> Process.unregister(Conn) + end + + {:ok, pid} = Task.start(fn -> Process.sleep(:infinity) end) + Process.register(pid, Conn) + GridCache.clear() + + on_exit(fn -> + if pid && Process.alive?(pid) do + Process.unregister(Conn) + Process.exit(pid, :kill) + end + + if prev_url, + do: Application.put_env(:microwaveprop, :valkey_url, prev_url), + else: Application.delete_env(:microwaveprop, :valkey_url) + + if prev_adapter, + do: Application.put_env(:microwaveprop, :valkey_adapter, prev_adapter), + else: Application.delete_env(:microwaveprop, :valkey_adapter) + end) + + :ok + end + + test "fetch returns decoded rows" do + result = GridCache.fetch(~U[2026-05-01 12:00:00Z]) + assert {:ok, rows} = result + assert length(rows) == 2 + assert hd(rows).lat == 32.0 + assert hd(rows).temperature == 25.0 + end + + test "fetch_bounds filters rows within bounds" do + bounds = %{"south" => 30.0, "north" => 35.0, "west" => -100.0, "east" => -95.0} + assert {:ok, rows} = GridCache.fetch_bounds(~U[2026-05-01 12:00:00Z], bounds) + assert length(rows) == 1 + assert hd(rows).lat == 32.0 + end + + test "fetch_point returns row for known point" do + assert {:ok, row} = GridCache.fetch_point(~U[2026-05-01 12:00:00Z], 32.0, -97.0) + assert row.temperature == 25.0 + end + + test "latest_valid_time returns the most recent" do + assert GridCache.latest_valid_time() == ~U[2026-05-01 12:00:00Z] end end