prop/test/microwaveprop/propagation/profiles_file_test.exs
Graham McIntire 26015ce8ea
ProfilesFile.read/1: drop [:safe] flag, trust our own files
The persisted grid_data files contain atoms (:base_m, :top_m,
:thickness_m, :min_freq_ghz, :native_min_gradient, :ducts, etc.) that
live in modules which aren't eagerly loaded during application start.
warm_grid_cache_from_latest_profile runs in Application.start/2 before
those modules get pulled in by their first call sites, so [:safe] was
rejecting our own legitimate files at boot — every weather page hit
crashed with "invalid or unsafe external representation of a term".

The whole point of [:safe] is to refuse atoms from untrusted external
ETF input. ProfilesFile.write!/2 in this same module is the only writer
of these files, so the input is trusted by definition — drop the flag.
The test additions round-trip the production data shape including duct
fields as a regression guard.
2026-04-15 17:04:34 -05:00

192 lines
6.4 KiB
Elixir

defmodule Microwaveprop.Propagation.ProfilesFileTest do
# async: false — tests mutate the global Application env to redirect
# the scores dir to a private tmp tree.
use ExUnit.Case, async: false
alias Microwaveprop.Propagation.ProfilesFile
setup do
dir =
Path.join(
System.tmp_dir!(),
"profiles_file_test_#{System.unique_integer([:positive])}"
)
File.mkdir_p!(dir)
prev = Application.get_env(:microwaveprop, :propagation_scores_dir)
Application.put_env(:microwaveprop, :propagation_scores_dir, dir)
on_exit(fn ->
File.rm_rf!(dir)
if prev do
Application.put_env(:microwaveprop, :propagation_scores_dir, prev)
else
Application.delete_env(:microwaveprop, :propagation_scores_dir)
end
end)
%{dir: dir}
end
describe "path_for/1" do
test "nests under base_dir/profiles/<iso>.etf.gz", %{dir: dir} do
assert ProfilesFile.path_for(~U[2026-04-14 18:00:00Z]) ==
Path.join([dir, "profiles", "2026-04-14T18:00:00Z.etf.gz"])
end
end
describe "read/1" do
test "returns the full grid_data map round-tripped through ETF" do
valid_time = ~U[2026-04-14 18:00:00Z]
grid_data = %{
{32.75, -97.125} => %{surface_temp_c: 25.0, surface_dewpoint_c: 15.0},
{33.0, -97.0} => %{surface_temp_c: 26.0}
}
ProfilesFile.write!(valid_time, grid_data)
assert {:ok, round_tripped} = ProfilesFile.read(valid_time)
assert round_tripped[{32.75, -97.125}][:surface_temp_c] == 25.0
assert round_tripped[{33.0, -97.0}][:surface_temp_c] == 26.0
end
test "returns :enoent when no file exists" do
assert ProfilesFile.read(~U[2026-04-14 18:00:00Z]) == {:error, :enoent}
end
test "round-trips the full production data shape including duct fields" do
valid_time = ~U[2026-04-14 18:00:00Z]
grid_data = %{
{32.75, -97.125} => %{
surface_temp_c: 25.0,
surface_dewpoint_c: 15.0,
surface_pressure_mb: 1013.25,
hpbl_m: 750.0,
pwat_mm: 32.5,
run_time: ~U[2026-04-14 17:00:00Z],
precip_mm: 0.0,
wind_u: 3.2,
wind_v: -1.7,
cloud_cover_pct: 25.0,
native_min_gradient: -78.4,
best_duct_freq_ghz: 24.0,
duct_count: 1,
max_duct_thickness_m: 200.0,
ducts: [
%{base_m: 200.0, top_m: 400.0, thickness_m: 200.0, min_freq_ghz: 10.0}
],
profile: [
%{"pres" => 1000.0, "tmpc" => 25.0, "dwpc" => 15.0, "hght" => 50.0}
]
}
}
ProfilesFile.write!(valid_time, grid_data)
assert {:ok, round_tripped} = ProfilesFile.read(valid_time)
point = round_tripped[{32.75, -97.125}]
assert point[:surface_temp_c] == 25.0
assert point[:run_time] == ~U[2026-04-14 17:00:00Z]
assert [duct] = point[:ducts]
assert duct[:base_m] == 200.0
assert duct[:thickness_m] == 200.0
assert duct[:min_freq_ghz] == 10.0
end
end
describe "list_valid_times/0" do
test "returns all persisted valid_times sorted ascending" do
assert ProfilesFile.list_valid_times() == []
for vt <- [~U[2026-04-14 18:00:00Z], ~U[2026-04-14 10:00:00Z], ~U[2026-04-14 14:00:00Z]] do
ProfilesFile.write!(vt, %{{25.0, -125.0} => %{surface_temp_c: 1.0}})
end
assert ProfilesFile.list_valid_times() == [
~U[2026-04-14 10:00:00Z],
~U[2026-04-14 14:00:00Z],
~U[2026-04-14 18:00:00Z]
]
end
end
describe "latest_valid_time/0" do
test "returns the most recent persisted valid_time" do
assert ProfilesFile.latest_valid_time() == nil
for vt <- [~U[2026-04-14 10:00:00Z], ~U[2026-04-14 18:00:00Z], ~U[2026-04-14 14:00:00Z]] do
ProfilesFile.write!(vt, %{{25.0, -125.0} => %{surface_temp_c: 1.0}})
end
assert ProfilesFile.latest_valid_time() == ~U[2026-04-14 18:00:00Z]
end
end
describe "write!/2 + read_point/3" do
test "round-trips a point and snaps lat/lon to the 0.125° grid" do
valid_time = ~U[2026-04-14 18:00:00Z]
grid_data = %{
{32.75, -97.125} => %{surface_temp_c: 25.0, surface_dewpoint_c: 15.0, wind_u: 3.0, wind_v: -1.0},
{25.0, -125.0} => %{surface_temp_c: 10.0}
}
ProfilesFile.write!(valid_time, grid_data)
# Exact match
assert %{surface_temp_c: 25.0, wind_u: 3.0} =
ProfilesFile.read_point(valid_time, 32.75, -97.125)
# Nudged within snap tolerance still resolves to the same cell
assert %{surface_temp_c: 25.0} =
ProfilesFile.read_point(valid_time, 32.7, -97.1)
# A point with no stored profile returns nil
assert ProfilesFile.read_point(valid_time, 40.0, -80.0) == nil
end
test "returns nil for a missing valid_time" do
assert ProfilesFile.read_point(~U[2026-04-14 18:00:00Z], 32.75, -97.125) == nil
end
end
describe "prune_older_than/1" do
test "deletes files with valid_time strictly before the cutoff" do
old = ~U[2026-04-14 10:00:00Z]
keep = ~U[2026-04-14 18:00:00Z]
ProfilesFile.write!(old, %{{25.0, -125.0} => %{surface_temp_c: 1.0}})
ProfilesFile.write!(keep, %{{25.0, -125.0} => %{surface_temp_c: 2.0}})
assert 1 == ProfilesFile.prune_older_than(~U[2026-04-14 12:00:00Z])
assert ProfilesFile.read_point(old, 25.0, -125.0) == nil
assert %{surface_temp_c: 2.0} = ProfilesFile.read_point(keep, 25.0, -125.0)
end
end
describe "retain_window/2" do
test "keeps only files whose valid_time is inside [run_time, run_time + max_fh * 3600]" do
run_time = ~U[2026-04-14 18:00:00Z]
inside_f00 = ~U[2026-04-14 18:00:00Z]
inside_f10 = ~U[2026-04-15 04:00:00Z]
after_window = ~U[2026-04-15 13:00:00Z]
before_window = ~U[2026-04-14 10:00:00Z]
for vt <- [inside_f00, inside_f10, after_window, before_window] do
ProfilesFile.write!(vt, %{{25.0, -125.0} => %{surface_temp_c: 1.0}})
end
assert 2 == ProfilesFile.retain_window(run_time, 18)
assert %{surface_temp_c: 1.0} = ProfilesFile.read_point(inside_f00, 25.0, -125.0)
assert %{surface_temp_c: 1.0} = ProfilesFile.read_point(inside_f10, 25.0, -125.0)
assert ProfilesFile.read_point(after_window, 25.0, -125.0) == nil
assert ProfilesFile.read_point(before_window, 25.0, -125.0) == nil
end
end
end