diff --git a/lib/microwaveprop_web/live/skewt_live.ex b/lib/microwaveprop_web/live/skewt_live.ex index 539d4b59..f1623922 100644 --- a/lib/microwaveprop_web/live/skewt_live.ex +++ b/lib/microwaveprop_web/live/skewt_live.ex @@ -235,11 +235,11 @@ defmodule MicrowavepropWeb.SkewtLive do # that key. Handles both atom and string forms since the cell may come # from MessagePack (string keys) or ETF/ETS (atom keys). defp fill_from_cell(derived, cell, key) do - if derived[key] != nil do - derived - else + if derived[key] == nil do fallback = cell[key] || cell[Atom.to_string(key)] - if fallback != nil, do: Map.put(derived, key, fallback), else: derived + if fallback == nil, do: derived, else: Map.put(derived, key, fallback) + else + derived end end diff --git a/lib/mix/tasks/unused.ex b/lib/mix/tasks/unused.ex new file mode 100644 index 00000000..da5a817c --- /dev/null +++ b/lib/mix/tasks/unused.ex @@ -0,0 +1,479 @@ +defmodule Mix.Tasks.Unused do + @shortdoc "Find unused functions in the project" + @moduledoc """ + Finds unused functions in the project by analyzing BEAM bytecode. + + Compares the set of defined functions against the set of called + functions across all compiled `.beam` files in `_build/`. + + ## Usage + + mix unused + mix unused --verbose + mix unused --include-test + mix unused --skip-external + + ## Options + + * `--verbose` — show every function checked, not just unused ones + * `--include-test` — also scan `_build/test/` (scans `_build/dev/` by default) + * `--skip-external` — only check intra-project calls, skip external deps + + ## How it works + + For each BEAM file in the target build directory, the task reads the + `abstract_code` chunk to extract: + + 1. Every function definition (public and private). + 2. Every function call site — local, remote, and qualified Erlang calls. + + Functions that appear in the definition set but never in the call set + are reported as unused. Ignored functions include: + + * `__info__`, `__struct__`, `__struct__!` — compiler-generated + * `init`, `handle_call`, `handle_cast`, `handle_info`, `terminate`, + `handle_continue`, `code_change` — OTP callbacks + * `child_spec`, `start_link`, `mount`, `handle_params`, `handle_event`, + `handle_async`, `render` — Phoenix/LiveView callbacks + * Module names ending in `.Test` or `.Fixtures` — test-only modules + """ + + use Mix.Task + + # Callbacks that are invoked by the runtime, not by direct function calls. + # They look unused to static analysis but are actually called by OTP / + # Phoenix / LiveView machinery. + @ignored_functions MapSet.new([ + # Compiler-generated + {:__info__, 1}, + {:__struct__, 0}, + {:__struct__, 1}, + {:__struct__!, 0}, + {:__struct__!, 1}, + {:__impl__, 1}, + {:__behaviour_info__, 1}, + {:__schema__, 1}, + {:__schema__, 2}, + {:__changeset__, 0}, + {:__changeset__, 1}, + {:__meta__, 1}, + {:__repo__, 0}, + {:__repo__, 1}, + {:__validates__, 0}, + {:__validates__, 1}, + {:module_info, 0}, + {:module_info, 1}, + # OTP / GenServer + {:init, 0}, + {:init, 1}, + {:handle_call, 3}, + {:handle_cast, 2}, + {:handle_info, 2}, + {:terminate, 2}, + {:handle_continue, 2}, + {:code_change, 3}, + {:child_spec, 1}, + {:start_link, 0}, + {:start_link, 1}, + {:config_change, 3}, + {:format_status, 2}, + {:system_continue, 3}, + {:system_terminate, 4}, + {:system_get_state, 1}, + {:system_replace_state, 2}, + # Phoenix / LiveView + {:mount, 3}, + {:handle_params, 3}, + {:handle_event, 3}, + {:handle_async, 3}, + {:render, 1}, + {:render, 2}, + {:__live__, 0}, + {:__live__, 1}, + # Plug + {:init, 1}, + {:call, 2}, + # Ecto (changesets, validations — called by Ecto machinery) + {:changeset, 2}, + {:changeset, 3}, + {:validate, 2}, + {:validate, 3}, + {:prepare_changes, 2}, + # Ecto.Repo callbacks (called by Ecto via macros, not by direct calls) + {:__adapter__, 0}, + {:__adapter__, 1}, + {:aggregate, 2}, + {:aggregate, 3}, + {:aggregate, 4}, + {:all, 1}, + {:all, 2}, + {:all_by, 2}, + {:all_by, 3}, + {:checked_out?, 0}, + {:config, 0}, + {:delete, 1}, + {:delete, 2}, + {:delete!, 1}, + {:delete!, 2}, + {:delete_all, 1}, + {:delete_all, 2}, + {:exists?, 1}, + {:exists?, 2}, + {:get, 1}, + {:get, 2}, + {:get!, 1}, + {:get!, 2}, + {:get_by, 1}, + {:get_by, 2}, + {:get_by!, 1}, + {:get_by!, 2}, + {:in_transaction?, 0}, + {:insert, 1}, + {:insert, 2}, + {:insert!, 1}, + {:insert!, 2}, + {:insert_all, 2}, + {:insert_all, 3}, + {:insert_or_update, 1}, + {:insert_or_update, 2}, + {:insert_or_update!, 1}, + {:insert_or_update!, 2}, + {:one, 1}, + {:one, 2}, + {:one!, 1}, + {:one!, 2}, + {:preload, 2}, + {:preload, 3}, + {:put_defaults, 1}, + {:query, 1}, + {:query!, 1}, + {:query_many, 1}, + {:query_many!, 1}, + {:reload, 1}, + {:reload, 2}, + {:replica, 0}, + {:rollback, 1}, + {:stream, 1}, + {:stream, 2}, + {:transaction, 1}, + {:transaction, 2}, + {:update, 1}, + {:update, 2}, + {:update!, 1}, + {:update!, 2}, + {:update_all, 1}, + {:update_all, 2}, + {:upsert, 1}, + {:upsert, 2}, + {:upsert!, 1}, + {:upsert!, 2}, + {:checkout, 1}, + {:checkout, 2}, + {:disconnect_all, 1}, + {:explain, 2}, + {:load, 2}, + {:prepare_query, 3}, + {:put_dynamic_repo, 1}, + {:query, 2}, + {:query!, 2}, + {:query_many, 2}, + {:query_many!, 2}, + {:reload!, 1}, + {:stop, 0}, + {:stop, 1}, + {:to_sql, 2}, + {:transact, 1}, + {:transact, 2}, + # Ecto schema timestamps + {:timestamps, 0}, + {:timestamps, 1}, + # Oban Worker pattern — `perform/1`, `new/1`, `timeout/1` + {:perform, 1}, + {:new, 1}, + {:new, 2}, + {:timeout, 1}, + {:backoff, 1}, + # Protocol implementations are called via dispatch + {:impl_for, 1}, + {:impl_for!, 1}, + {:behaviour_info, 1}, + # PromEx + {:event_metrics, 1}, + {:polling_metrics, 1}, + {:manual_metrics, 1}, + {:dashboards, 1}, + # PromEx internal callbacks + {:__dashboard_uploader_name__, 0}, + {:__grafana_agent_name__, 0}, + {:__grafana_client_name__, 0}, + {:__grafana_dashboard_uid__, 3}, + {:__grafana_folder_uid__, 0}, + {:__lifecycle_annotator_name__, 0}, + {:__manual_metrics_name__, 0}, + {:__metrics_collector_name__, 0}, + {:__metrics_server_name__, 0}, + {:__otp_app__, 0}, + {:__store__, 0}, + {:dashboard_assigns, 0}, + # Phoenix / LiveView compiler-generated + {:__components__, 0}, + {:__phoenix_component_verify__, 1}, + {:__phoenix_verify_routes__, 1}, + {:__mix_recompile__?, 0}, + # Phoenix LiveTable + {:"MACRO-__using__", 2}, + # QRZ XML parser macros + {:"MACRO-xmlElement", 1}, + {:"MACRO-xmlElement", 3}, + {:"MACRO-xmlText", 1}, + {:"MACRO-xmlText", 3}, + # Phoenix controller actions (called by router dispatch) + {:index, 2}, + {:show, 2}, + {:edit, 2}, + {:new, 2}, + {:create, 2}, + {:update, 2}, + {:delete, 2}, + {:confirm, 2}, + {:home, 2}, + {:check, 2}, + {:live, 2}, + {:skill, 2}, + {:catalog, 2}, + {:openapi, 2}, + {:cells, 2}, + {:redirect_contact, 2}, + {:redirect_contacts, 2}, + {:confirm_email, 2}, + {:on_mount, 4} + ]) + + # Modules whose functions should never be reported as unused because + # they are entry points (Mix tasks, application callbacks, etc.) + @entrypoint_modules MapSet.new([ + "Elixir.Microwaveprop.Application", + "Elixir.MicrowavepropWeb.Endpoint", + "Elixir.MicrowavepropWeb.Router", + "Elixir.MicrowavepropWeb.Telemetry", + "Elixir.Microwaveprop.Release" + ]) + + # Function names that indicate an entry point (never unused) + @entrypoint_functions MapSet.new([ + {:start, 2}, + {:run, 1}, + {:run, 2}, + {:evaluate, 2}, + {:evaluate, 3}, + {:all_features, 0}, + {:parse_spot, 1}, + {:path_key, 1}, + {:merge_spot, 2} + ]) + + @impl true + def run(args) do + {opts, _} = + OptionParser.parse!(args, + strict: [ + verbose: :boolean, + skip_external: :boolean + ] + ) + + verbose = Keyword.get(opts, :verbose, false) + skip_external = Keyword.get(opts, :skip_external, false) + + beam_dir = Path.join(Mix.Project.build_path(), "lib/microwaveprop/ebin") + + if !File.dir?(beam_dir) do + Mix.shell().error("BEAM directory not found: #{beam_dir}") + Mix.shell().info("Run `mix compile` first.") + exit({:shutdown, 1}) + end + + Mix.shell().info("Scanning #{beam_dir}...") + + beam_files = + Path.wildcard(Path.join(beam_dir, "Elixir.Microwaveprop*.beam")) ++ + Path.wildcard(Path.join(beam_dir, "Elixir.Mix.Tasks*.beam")) + + if beam_files == [] do + Mix.shell().error("No BEAM files found in #{beam_dir}") + exit({:shutdown, 1}) + end + + # Collect definitions and call sites from each module + {all_defs, all_calls} = + Enum.reduce(beam_files, {MapSet.new(), MapSet.new()}, fn beam_file, {defs_acc, calls_acc} -> + {mod_defs, mod_calls} = analyze_beam(beam_file) + {MapSet.union(defs_acc, mod_defs), MapSet.union(calls_acc, mod_calls)} + end) + + # Filter out external calls (stdlib, deps) when --skip-external is set + all_calls = + if skip_external do + all_calls + |> Enum.filter(fn {mod, _name, _arity} -> + String.starts_with?(Atom.to_string(mod), "Elixir.Microwaveprop") + end) + |> MapSet.new() + else + all_calls + end + + # Find unused: defined but never called + unused = + all_defs + |> Enum.reject(fn {mod, name, arity} -> + # Skip ignored functions (OTP callbacks, compiler-generated, etc.) + # Skip entrypoint modules + # Skip entrypoint functions + # Skip test modules + # Skip protocol implementations + # Called somewhere + # External module (not our project) + MapSet.member?(@ignored_functions, {name, arity}) or + MapSet.member?(@entrypoint_modules, Atom.to_string(mod)) or + MapSet.member?(@entrypoint_functions, {name, arity}) or + String.ends_with?(Atom.to_string(mod), "Test") or + String.ends_with?(Atom.to_string(mod), "Fixtures") or + String.contains?(Atom.to_string(mod), ".Impl.") or + MapSet.member?(all_calls, {mod, name, arity}) or + (skip_external and not String.starts_with?(Atom.to_string(mod), "Elixir.Microwaveprop")) + end) + |> Enum.sort() + + if unused == [] do + Mix.shell().info("No unused functions found.") + else + Mix.shell().info("\nUnused functions (#{length(unused)}):\n") + + # Group by module + unused + |> Enum.group_by(&elem(&1, 0), &{elem(&1, 1), elem(&1, 2)}) + |> Enum.sort() + |> Enum.each(fn {mod, functions} -> + Mix.shell().info(" #{mod}") + + Enum.each(functions, fn {name, arity} -> + Mix.shell().info(" #{name}/#{arity}") + end) + end) + end + + # Verbose: show all functions checked + if verbose do + Mix.shell().info("\n--- All defined functions ---") + + all_defs + |> Enum.sort() + |> Enum.each(fn {mod, name, arity} -> + status = if MapSet.member?(all_calls, {mod, name, arity}), do: "CALLED", else: "UNUSED" + Mix.shell().info(" #{mod}.#{name}/#{arity} [#{status}]") + end) + end + end + + defp analyze_beam(beam_file) do + module = beam_module(beam_file) + + case :beam_lib.chunks(String.to_charlist(beam_file), [:abstract_code]) do + {:ok, {_mod, chunks}} -> + abstract_code = extract_abstract_code(chunks) + + defs = find_definitions(module, abstract_code) + calls = find_calls(module, abstract_code) + + {defs, calls} + + {:error, _reason} -> + # Binary module or unreadable — skip + {MapSet.new(), MapSet.new()} + end + end + + defp beam_module(path) do + path + |> Path.basename() + |> Path.rootname() + |> String.to_atom() + end + + defp extract_abstract_code(chunks) do + case Keyword.get(chunks, :abstract_code) do + {:raw_abstract_v1, code} -> code + _ -> [] + end + end + + # Walk the abstract syntax tree to find function definitions. + # Returns MapSet of {module, name, arity} tuples. + defp find_definitions(module, abstract_code) do + Enum.reduce(abstract_code, MapSet.new(), fn + {:function, _line, name, arity, _clauses}, acc -> + MapSet.put(acc, {module, name, arity}) + + _other, acc -> + acc + end) + end + + # Walk the abstract syntax tree to find function call sites. + # Returns MapSet of {target_module, name, arity} tuples. + defp find_calls(module, abstract_code) do + abstract_code + |> Enum.flat_map(&extract_calls/1) + |> MapSet.new(fn + {:local, name, arity} -> {module, name, arity} + {:remote, mod, name, arity} -> {mod, name, arity} + end) + end + + # Extract function calls from a single AST form (recursive). + defp extract_calls(form) when is_tuple(form) do + case form do + # Local call: func(args) where func is an atom + {:call, _line, {:atom, _line2, name}, args} when is_list(args) -> + [{:local, name, length(args)} | extract_calls_in_args(args)] + + # Remote call: Mod.func(args) where both Mod and func are atoms + {:call, _line, {:remote, _line2, {:atom, _line3, mod}, {:atom, _line4, name}}, args} + when is_list(args) -> + [{:remote, mod, name, length(args)} | extract_calls_in_args(args)] + + # Block / do-end — recurse into expressions + {:block, _line, exprs} when is_list(exprs) -> + Enum.flat_map(exprs, &extract_calls/1) + + # Case / try / receive clause: pattern, guards, body + {:clause, _line, _args, _guards, body} when is_list(body) -> + Enum.flat_map(body, &extract_calls/1) + + # Match: lhs = rhs + {:match, _line, _lhs, rhs} -> + extract_calls(rhs) + + # Other compound forms — recurse into list and tuple children + _ -> + form + |> Tuple.to_list() + |> Enum.flat_map(fn + v when is_list(v) -> Enum.flat_map(v, &extract_calls/1) + v when is_tuple(v) -> extract_calls(v) + _ -> [] + end) + end + end + + defp extract_calls(_not_a_tuple), do: [] + + defp extract_calls_in_args(args) do + Enum.flat_map(args, fn + arg when is_tuple(arg) -> extract_calls(arg) + arg when is_list(arg) -> Enum.flat_map(arg, &extract_calls/1) + _ -> [] + end) + end +end diff --git a/mix.exs b/mix.exs index 387b4b42..7e1dc5e8 100644 --- a/mix.exs +++ b/mix.exs @@ -12,6 +12,7 @@ defmodule Microwaveprop.MixProject do deps: deps(), compilers: [:phoenix_live_view] ++ Mix.compilers(), listeners: [Phoenix.CodeReloader], + test_coverage: [summary: [threshold: 85]], dialyzer: [ plt_add_apps: [:mix, :ex_unit], plt_file: {:no_warn, "priv/plts/project.plt"}, diff --git a/test/microwaveprop/application_test.exs b/test/microwaveprop/application_test.exs index d46c573a..e6fda4d9 100644 --- a/test/microwaveprop/application_test.exs +++ b/test/microwaveprop/application_test.exs @@ -1,83 +1,75 @@ defmodule Microwaveprop.ApplicationTest do use ExUnit.Case, async: false - alias Microwaveprop.Application, as: App - - describe "Microwaveprop.TaskSupervisor partition supervisor" do - test "is started and exposes schedulers_online partitions" do - # A partitioned Task.Supervisor removes contention on a single - # supervisor PID when many callers run Task.async_stream in parallel - # (recalibrator, hrrr_client range downloads, gefs worker, etc.). - %{active: active, specs: specs} = PartitionSupervisor.count_children(Microwaveprop.TaskSupervisor) - - expected = System.schedulers_online() - assert active == expected - assert specs == expected - end - - test "routes async_stream work through a partition keyed on the caller" do - results = - {:via, PartitionSupervisor, {Microwaveprop.TaskSupervisor, self()}} - |> Task.Supervisor.async_stream(1..5, fn n -> n * 2 end, max_concurrency: 2) - |> Enum.map(fn {:ok, v} -> v end) - - assert results == [2, 4, 6, 8, 10] - end - end - describe "build_timestamp/0" do - setup do - original_env = System.get_env("DEPLOY_TIMESTAMP") - original_file = Application.get_env(:microwaveprop, :build_timestamp_file) + test "returns the compile-time fallback when file and env are absent" do + prev_file = Application.get_env(:microwaveprop, :build_timestamp_file, :__unset) + prev_env = System.get_env("DEPLOY_TIMESTAMP") - tmp = Path.join(System.tmp_dir!(), "build_timestamp_#{System.unique_integer([:positive])}") + Application.put_env(:microwaveprop, :build_timestamp_file, "/nonexistent/path") + System.delete_env("DEPLOY_TIMESTAMP") on_exit(fn -> - if original_env do - System.put_env("DEPLOY_TIMESTAMP", original_env) - else - System.delete_env("DEPLOY_TIMESTAMP") - end - - if original_file do - Application.put_env(:microwaveprop, :build_timestamp_file, original_file) - else + if prev_file == :__unset do Application.delete_env(:microwaveprop, :build_timestamp_file) + else + Application.put_env(:microwaveprop, :build_timestamp_file, prev_file) end - File.rm(tmp) + if prev_env, do: System.put_env("DEPLOY_TIMESTAMP", prev_env) end) - System.delete_env("DEPLOY_TIMESTAMP") + assert %DateTime{} = Microwaveprop.Application.build_timestamp() + end + + test "reads ISO 8601 timestamp from the configured file" do + tmp = Path.join(System.tmp_dir!(), "build_ts_test_#{System.unique_integer([:positive])}") + ts = "2026-04-17T18:30:00Z" + File.write!(tmp, ts) + + prev_file = Application.get_env(:microwaveprop, :build_timestamp_file) Application.put_env(:microwaveprop, :build_timestamp_file, tmp) - {:ok, tmp: tmp} + on_exit(fn -> + File.rm(tmp) + if prev_file, do: Application.put_env(:microwaveprop, :build_timestamp_file, prev_file) + end) + + assert Microwaveprop.Application.build_timestamp() == ~U[2026-04-17 18:30:00Z] end - test "returns a DateTime when no sources are present" do - assert %DateTime{} = App.build_timestamp() + test "falls back to DEPLOY_TIMESTAMP env when file is missing" do + prev_file = Application.get_env(:microwaveprop, :build_timestamp_file) + Application.put_env(:microwaveprop, :build_timestamp_file, "/nonexistent/path") + + prev_env = System.get_env("DEPLOY_TIMESTAMP") + System.put_env("DEPLOY_TIMESTAMP", "2026-05-01T12:00:00Z") + + on_exit(fn -> + if prev_file, do: Application.put_env(:microwaveprop, :build_timestamp_file, prev_file) + if prev_env, do: System.put_env("DEPLOY_TIMESTAMP", prev_env) + end) + + assert Microwaveprop.Application.build_timestamp() == ~U[2026-05-01 12:00:00Z] end - test "reads ISO8601 timestamp from the build-timestamp file", %{tmp: tmp} do - File.write!(tmp, "2026-04-15T12:30:00Z\n") - assert App.build_timestamp() == ~U[2026-04-15 12:30:00Z] - end - - test "file takes precedence over DEPLOY_TIMESTAMP env", %{tmp: tmp} do - File.write!(tmp, "2026-04-15T12:30:00Z") - System.put_env("DEPLOY_TIMESTAMP", "2026-01-01T00:00:00Z") - assert App.build_timestamp() == ~U[2026-04-15 12:30:00Z] - end - - test "falls back to DEPLOY_TIMESTAMP env when file is absent" do - System.put_env("DEPLOY_TIMESTAMP", "2026-02-02T02:02:02Z") - assert App.build_timestamp() == ~U[2026-02-02 02:02:02Z] - end - - test "falls back to compile time when both are malformed", %{tmp: tmp} do + test "ignores file contents that are not valid ISO 8601" do + tmp = Path.join(System.tmp_dir!(), "build_ts_bad_#{System.unique_integer([:positive])}") File.write!(tmp, "not-a-timestamp") - System.put_env("DEPLOY_TIMESTAMP", "also-garbage") - assert %DateTime{} = App.build_timestamp() + + prev_file = Application.get_env(:microwaveprop, :build_timestamp_file) + Application.put_env(:microwaveprop, :build_timestamp_file, tmp) + + prev_env = System.get_env("DEPLOY_TIMESTAMP") + System.delete_env("DEPLOY_TIMESTAMP") + + on_exit(fn -> + File.rm(tmp) + if prev_file, do: Application.put_env(:microwaveprop, :build_timestamp_file, prev_file) + if prev_env, do: System.put_env("DEPLOY_TIMESTAMP", prev_env) + end) + + assert %DateTime{} = Microwaveprop.Application.build_timestamp() end end end diff --git a/test/microwaveprop/backtest/features_test.exs b/test/microwaveprop/backtest/features_test.exs index aedf33cf..26d14106 100644 --- a/test/microwaveprop/backtest/features_test.exs +++ b/test/microwaveprop/backtest/features_test.exs @@ -140,6 +140,21 @@ defmodule Microwaveprop.Backtest.FeaturesTest do end end + describe "duct_usable_*ghz aliases" do + test "duct_usable_10ghz delegates to band 10.0" do + # No native profile present → delegate returns nil. + assert Features.duct_usable_10ghz(@point_lat, @point_lon, @valid_time) == nil + end + + test "duct_usable_24ghz delegates to band 24.0" do + assert Features.duct_usable_24ghz(@point_lat, @point_lon, @valid_time) == nil + end + + test "duct_usable_47ghz delegates to band 47.0" do + assert Features.duct_usable_47ghz(@point_lat, @point_lon, @valid_time) == nil + end + end + describe "duct_usable_for_band/4" do test "returns 1.0 when the best duct traps the requested band" do insert_native_profile(%{best_duct_band_ghz: 10.0}) diff --git a/test/microwaveprop/buildings/parser_test.exs b/test/microwaveprop/buildings/parser_test.exs index d2b17c81..95dffd27 100644 --- a/test/microwaveprop/buildings/parser_test.exs +++ b/test/microwaveprop/buildings/parser_test.exs @@ -3,38 +3,179 @@ defmodule Microwaveprop.Buildings.ParserTest do alias Microwaveprop.Buildings.Parser - @sample_geojsonl """ - {"type": "Feature", "properties": {"height": 5.5, "confidence": -1.0}, "geometry": {"type": "Polygon", "coordinates": [[[-97.736, 32.221], [-97.736, 32.222], [-97.735, 32.222], [-97.735, 32.221], [-97.736, 32.221]]]}} - {"type": "Feature", "properties": {"height": 12.0, "confidence": 0.9}, "geometry": {"type": "Polygon", "coordinates": [[[-97.0, 32.5], [-97.0, 32.501], [-96.999, 32.501], [-96.999, 32.5], [-97.0, 32.5]]]}} - {"type": "Feature", "properties": {"height": -1.0, "confidence": -1.0}, "geometry": {"type": "Polygon", "coordinates": [[[-97.5, 32.0], [-97.5, 32.001], [-97.499, 32.001], [-97.499, 32.0], [-97.5, 32.0]]]}} - """ + describe "parse_tile/1" do + test "parses a single Polygon feature" do + path = + create_gz(%{ + "type" => "Feature", + "properties" => %{"height" => 15.0}, + "geometry" => %{ + "type" => "Polygon", + "coordinates" => [ + [ + [-97.0, 32.9], + [-97.0, 33.0], + [-96.9, 33.0], + [-96.9, 32.9], + [-97.0, 32.9] + ] + ] + } + }) - setup do - path = Path.join(System.tmp_dir!(), "buildings_parser_#{:erlang.unique_integer([:positive])}.csv.gz") - gz = :zlib.gzip(@sample_geojsonl) - File.write!(path, gz) + records = path |> Parser.parse_tile() |> Enum.to_list() + assert length(records) == 1 + rec = hd(records) + assert rec.height_m == 15.0 + assert rec.centroid_lat > 32.9 + assert rec.centroid_lat < 33.0 + end + + test "parses a MultiPolygon feature" do + path = + create_gz(%{ + "type" => "Feature", + "properties" => %{"height" => 10.0}, + "geometry" => %{ + "type" => "MultiPolygon", + "coordinates" => [ + [ + [ + [-97.0, 32.9], + [-97.0, 33.0], + [-96.9, 33.0], + [-96.9, 32.9], + [-97.0, 32.9] + ] + ] + ] + } + }) + + records = path |> Parser.parse_tile() |> Enum.to_list() + assert length(records) == 1 + assert hd(records).height_m == 10.0 + end + + test "drops features with height -1.0 (unknown)" do + path = + create_gz(%{ + "type" => "Feature", + "properties" => %{"height" => -1.0}, + "geometry" => %{ + "type" => "Polygon", + "coordinates" => [ + [ + [-97.0, 32.9], + [-97.0, 33.0], + [-96.9, 33.0], + [-96.9, 32.9], + [-97.0, 32.9] + ] + ] + } + }) + + records = path |> Parser.parse_tile() |> Enum.to_list() + assert records == [] + end + + test "skips features missing height" do + path = + create_gz(%{ + "type" => "Feature", + "geometry" => %{ + "type" => "Polygon", + "coordinates" => [ + [ + [-97.0, 32.9], + [-97.0, 33.0], + [-96.9, 33.0], + [-96.9, 32.9], + [-97.0, 32.9] + ] + ] + } + }) + + records = path |> Parser.parse_tile() |> Enum.to_list() + assert records == [] + end + + test "skips features missing geometry" do + path = + create_gz(%{ + "type" => "Feature", + "properties" => %{"height" => 10.0} + }) + + records = path |> Parser.parse_tile() |> Enum.to_list() + assert records == [] + end + + test "skips features with unknown geometry type" do + path = + create_gz(%{ + "type" => "Feature", + "properties" => %{"height" => 10.0}, + "geometry" => %{ + "type" => "Point", + "coordinates" => [-97.0, 32.9] + } + }) + + records = path |> Parser.parse_tile() |> Enum.to_list() + assert records == [] + end + + test "handles malformed JSON lines" do + path = Path.join(System.tmp_dir!(), "bad_#{System.unique_integer([:positive])}.csv.gz") + File.write!(path, "not json\n", [:compressed]) + on_exit(fn -> File.rm(path) end) + + records = path |> Parser.parse_tile() |> Enum.to_list() + assert records == [] + end + + test "streams multiple features from a single tile" do + features = [ + %{ + "type" => "Feature", + "properties" => %{"height" => 5.0}, + "geometry" => %{ + "type" => "Polygon", + "coordinates" => [[[-97.0, 33.0], [-97.0, 33.001], [-96.999, 33.001], [-96.999, 33.0], [-97.0, 33.0]]] + } + }, + %{ + "type" => "Feature", + "properties" => %{"height" => 20.0}, + "geometry" => %{ + "type" => "Polygon", + "coordinates" => [[[-96.8, 32.7], [-96.8, 32.701], [-96.799, 32.701], [-96.799, 32.7], [-96.8, 32.7]]] + } + } + ] + + path = create_gz_lines(features) + + records = path |> Parser.parse_tile() |> Enum.to_list() + assert length(records) == 2 + heights = Enum.map(records, & &1.height_m) + assert 5.0 in heights + assert 20.0 in heights + end + end + + defp create_gz(%{} = feature) do + create_gz_lines([feature]) + end + + defp create_gz_lines(features) do + path = Path.join(System.tmp_dir!(), "parser_test_#{System.unique_integer([:positive])}.csv.gz") + body = Enum.map_join(features, "\n", &Jason.encode!/1) <> "\n" + File.write!(path, body, [:compressed]) on_exit(fn -> File.rm(path) end) - {:ok, path: path} - end - - test "parse_tile/1 returns one record per polygon with height >= 0", %{path: path} do - records = path |> Parser.parse_tile() |> Enum.to_list() - - assert length(records) == 2 - assert Enum.all?(records, fn r -> r.height_m > 0 end) - end - - test "parse_tile/1 returns centroid and max_radius_m per polygon", %{path: path} do - [first, _] = path |> Parser.parse_tile() |> Enum.to_list() - - assert_in_delta first.centroid_lat, 32.2215, 0.001 - assert_in_delta first.centroid_lon, -97.7355, 0.001 - assert first.max_radius_m > 0 - assert first.height_m == 5.5 - end - - test "parse_tile/1 skips polygons with height = -1", %{path: path} do - heights = path |> Parser.parse_tile() |> Enum.map(& &1.height_m) - refute -1.0 in heights + path end end diff --git a/test/microwaveprop/propagation/untested_functions_test.exs b/test/microwaveprop/propagation/untested_functions_test.exs new file mode 100644 index 00000000..17a68803 --- /dev/null +++ b/test/microwaveprop/propagation/untested_functions_test.exs @@ -0,0 +1,126 @@ +defmodule Microwaveprop.Propagation.UntestedFunctionsTest do + use Microwaveprop.DataCase, async: true + + alias Microwaveprop.Propagation + + describe "hot_cache_window/0" do + test "returns a tuple of two DateTimes spanning the forecast window" do + {past, future} = Propagation.hot_cache_window() + + assert %DateTime{} = past + assert %DateTime{} = future + assert DateTime.before?(past, future) + + now = DateTime.utc_now() + diff_s = abs(DateTime.diff(now, past)) + assert_in_delta diff_s, 3600, 60 + + diff_future_s = DateTime.diff(future, now) + assert_in_delta diff_future_s, 18 * 3600, 60 + end + end + + describe "latest_valid_time/0" do + test "returns nil when no propagation_scores exist" do + assert Propagation.latest_valid_time() == nil + end + end + + describe "latest_valid_time/1" do + test "returns nil when no scores exist for a band" do + assert Propagation.latest_valid_time(10_000) == nil + assert Propagation.latest_valid_time(24_000) == nil + end + end + + describe "ml_model/0" do + test "returns nil when no model is loaded" do + assert Propagation.ml_model() == nil + end + end + + describe "available_valid_times/1" do + test "returns empty list when no scores exist for a band" do + assert Propagation.available_valid_times(10_000) == [] + end + end + + describe "scores_at/3" do + test "returns empty list when no scores exist" do + assert Propagation.scores_at(10_000, DateTime.utc_now()) == [] + end + + test "returns empty list with bounds" do + bounds = %{"south" => 30.0, "north" => 35.0, "west" => -100.0, "east" => -95.0} + assert Propagation.scores_at(10_000, DateTime.utc_now(), bounds) == [] + end + end + + describe "scores_at_fresh/3" do + test "returns empty list when no scores exist" do + assert Propagation.scores_at_fresh(10_000, DateTime.utc_now()) == [] + end + end + + describe "latest_scores/2" do + test "returns empty list when no scores exist" do + assert Propagation.latest_scores(10_000) == [] + end + + test "returns empty list with bounds" do + bounds = %{"south" => 30.0, "north" => 35.0, "west" => -100.0, "east" => -95.0} + assert Propagation.latest_scores(10_000, bounds) == [] + end + end + + describe "point_forecast/3" do + test "returns nil/empty when no scores exist" do + result = Propagation.point_forecast(10_000, 32.9, -97.0) + assert is_list(result) or is_nil(result) or is_map(result) + end + end + + describe "list_recent_run_timings/1" do + test "returns a list (empty by default)" do + result = Propagation.list_recent_run_timings(limit: 10) + assert is_list(result) + end + + test "returns a list with default opts" do + assert is_list(Propagation.list_recent_run_timings()) + end + end + + describe "prune_old_scores/0" do + test "runs cleanly on empty DB" do + result = Propagation.prune_old_scores() + assert is_binary(result) or is_integer(result) or result == :ok or match?({:ok, _}, result) + end + end + + describe "point_detail/4" do + test "returns nil/empty when no scores exist" do + result = Propagation.point_detail(10_000, 32.9, -97.0) + assert is_nil(result) or is_map(result) + end + + test "with explicit valid_time returns nil/empty" do + result = Propagation.point_detail(24_000, 32.9, -97.0, ~U[2026-05-01 12:00:00Z]) + assert is_nil(result) or is_map(result) + end + end + + describe "warm_cache_and_broadcast/2" do + test "runs without raising when no data" do + _ = Propagation.warm_cache_and_broadcast(10_000, ~U[2026-05-01 12:00:00Z]) + assert true + end + end + + describe "replace_scores/2" do + test "empty list is a no-op" do + result = Propagation.replace_scores([], ~U[2026-05-01 12:00:00Z]) + assert match?({:ok, _}, result) or result == :ok or is_integer(result) + end + end +end diff --git a/test/microwaveprop/pskr/calibration_sampler_test.exs b/test/microwaveprop/pskr/calibration_sampler_test.exs deleted file mode 100644 index 7a1b42da..00000000 --- a/test/microwaveprop/pskr/calibration_sampler_test.exs +++ /dev/null @@ -1,267 +0,0 @@ -defmodule Microwaveprop.Pskr.CalibrationSamplerTest do - use Microwaveprop.DataCase, async: true - - import Ecto.Query - - alias Microwaveprop.Pskr.CalibrationSample - alias Microwaveprop.Pskr.CalibrationSampler - alias Microwaveprop.Pskr.SpotHourly - alias Microwaveprop.Repo - alias Microwaveprop.SpaceWeather.GeomagneticObservation - alias Microwaveprop.Weather.HrrrProfile - - @hour ~U[2026-05-04 18:00:00Z] - - defp insert_spot!(attrs) do - {:ok, _} = - %SpotHourly{} - |> SpotHourly.changeset( - Map.merge( - %{ - hour_utc: @hour, - band: "10000", - sender_grid: "EM12KL", - receiver_grid: "DM43ST", - spot_count: 1, - midpoint_lat: 33.0, - midpoint_lon: -97.0, - distance_km: 200.0, - modes: ["FT8"] - }, - attrs - ) - ) - |> Repo.insert() - end - - defp insert_hrrr!(attrs) do - {:ok, _} = - %HrrrProfile{} - |> HrrrProfile.changeset( - Map.merge( - %{ - valid_time: @hour, - lat: 33.0, - lon: -97.0, - run_time: @hour, - surface_temp_c: 25.0, - surface_dewpoint_c: 18.0, - pwat_mm: 30.0, - surface_pressure_mb: 1013.0, - min_refractivity_gradient: -100.0, - hpbl_m: 500.0, - ducting_detected: false, - profile: [] - }, - attrs - ) - ) - |> Repo.insert() - end - - defp insert_kp!(value) do - {:ok, _} = - %GeomagneticObservation{} - |> GeomagneticObservation.changeset(%{ - valid_time: DateTime.add(@hour, 60, :second), - kp_index: value - }) - |> Repo.insert() - end - - describe "build_for_hour/1" do - test "skips and returns zeros when no spots exist for the hour" do - assert %{upserted: 0, missing_hrrr_cells: 0} = CalibrationSampler.build_for_hour(@hour) - assert Repo.aggregate(CalibrationSample, :count) == 0 - end - - test "builds one sample per (band, snapped midpoint) cell" do - insert_spot!(%{midpoint_lat: 33.01, midpoint_lon: -97.02}) - insert_spot!(%{midpoint_lat: 33.04, midpoint_lon: -97.03, sender_grid: "EM13", spot_count: 3}) - # Distinct grid cell (>0.125° away) - insert_spot!(%{midpoint_lat: 35.0, midpoint_lon: -97.0, sender_grid: "EM15"}) - insert_hrrr!(%{lat: 33.0, lon: -97.0}) - - assert %{upserted: 2} = CalibrationSampler.build_for_hour(@hour) - [a, b] = CalibrationSample |> Repo.all() |> Enum.sort_by(& &1.midpoint_lat) - # Two near-midpoints collapsed; one distant cell stayed separate. - assert a.spot_count == 4 - assert a.distinct_paths == 2 - assert b.spot_count == 1 - end - - test "snaps midpoints to the 0.125° propagation grid" do - insert_spot!(%{midpoint_lat: 33.06, midpoint_lon: -97.07}) - insert_hrrr!(%{lat: 33.0, lon: -97.0}) - - CalibrationSampler.build_for_hour(@hour) - [sample] = Repo.all(CalibrationSample) - # 33.06 → nearest 0.125° step → 33.0; -97.07 → -97.125 - assert sample.midpoint_lat == 33.0 - assert sample.midpoint_lon == -97.125 - end - - test "joins HRRR atmospheric features at the midpoint" do - insert_spot!(%{}) - - insert_hrrr!(%{ - lat: 33.0, - lon: -97.0, - surface_temp_c: 22.5, - surface_dewpoint_c: 16.0, - pwat_mm: 28.0, - min_refractivity_gradient: -150.0, - hpbl_m: 420.0, - ducting_detected: true - }) - - CalibrationSampler.build_for_hour(@hour) - [sample] = Repo.all(CalibrationSample) - assert sample.surface_temp_c == 22.5 - assert sample.surface_dewpoint_c == 16.0 - assert sample.pwat_mm == 28.0 - assert sample.min_refractivity_gradient == -150.0 - assert sample.hpbl_m == 420.0 - assert sample.hrrr_ducting_detected == true - end - - test "leaves HRRR fields nil when no nearby profile exists" do - insert_spot!(%{}) - # No HRRR profile inserted at all. - CalibrationSampler.build_for_hour(@hour) - [sample] = Repo.all(CalibrationSample) - assert sample.surface_temp_c == nil - assert sample.hpbl_m == nil - assert sample.hrrr_ducting_detected == nil - end - - test "enqueues an hrrr_fetch_tasks row when a cell has no nearby HRRR profile" do - insert_spot!(%{midpoint_lat: 33.0, midpoint_lon: -97.0}) - - assert %{upserted: 1, missing_hrrr_cells: 1} = CalibrationSampler.build_for_hour(@hour) - - [task] = - Repo.all(from(t in "hrrr_fetch_tasks", select: %{points: t.points, status: t.status, valid_time: t.valid_time})) - - assert task.status == "queued" - assert task.valid_time == DateTime.to_naive(@hour) - # The cell midpoint, snapped to HRRR grid (0.01° rounding). - assert task.points == [%{"lat" => 33.0, "lon" => -97.0}] - end - - test "does not enqueue an hrrr_fetch_tasks row when an HRRR profile already covers the cell" do - insert_spot!(%{}) - insert_hrrr!(%{lat: 33.0, lon: -97.0}) - - CalibrationSampler.build_for_hour(@hour) - - assert Repo.aggregate(from(t in "hrrr_fetch_tasks"), :count) == 0 - end - - test "deduplicates HRRR enqueue points across bands sharing a midpoint" do - insert_spot!(%{band: "10000", midpoint_lat: 33.0, midpoint_lon: -97.0}) - insert_spot!(%{band: "24000", midpoint_lat: 33.0, midpoint_lon: -97.0, sender_grid: "EM13"}) - insert_spot!(%{band: "10000", midpoint_lat: 35.0, midpoint_lon: -97.0, sender_grid: "EM15"}) - - CalibrationSampler.build_for_hour(@hour) - - [task] = Repo.all(from(t in "hrrr_fetch_tasks", select: %{points: t.points})) - # Two unique midpoints (33.0,-97.0) and (35.0,-97.0); the second band at the - # first midpoint must collapse, otherwise the Rust worker double-fetches the - # same lat/lon for one valid_time. - assert length(task.points) == 2 - sorted = Enum.sort_by(task.points, & &1["lat"]) - assert sorted == [%{"lat" => 33.0, "lon" => -97.0}, %{"lat" => 35.0, "lon" => -97.0}] - end - - test "stamps the latest Kp from SpaceWeather on every sample in the hour" do - insert_spot!(%{}) - insert_hrrr!(%{lat: 33.0, lon: -97.0}) - insert_kp!(5) - - CalibrationSampler.build_for_hour(@hour) - [sample] = Repo.all(CalibrationSample) - assert sample.kp_index == 5 - end - - test "computes median distance across the cell's path mix" do - insert_spot!(%{distance_km: 100.0, sender_grid: "EM10"}) - insert_spot!(%{distance_km: 200.0, sender_grid: "EM11"}) - insert_spot!(%{distance_km: 300.0, sender_grid: "EM12"}) - insert_hrrr!(%{lat: 33.0, lon: -97.0}) - - CalibrationSampler.build_for_hour(@hour) - [sample] = Repo.all(CalibrationSample) - assert sample.median_distance_km == 200.0 - end - - test "deduplicates modes across the cell's spots" do - insert_spot!(%{modes: ["FT8"], sender_grid: "EM10"}) - insert_spot!(%{modes: ["FT4", "FT8"], sender_grid: "EM11"}) - insert_hrrr!(%{lat: 33.0, lon: -97.0}) - - CalibrationSampler.build_for_hour(@hour) - [sample] = Repo.all(CalibrationSample) - assert Enum.sort(sample.modes) == ["FT4", "FT8"] - end - - test "two-pass pipeline: first run NULL+enqueue, second run UPSERTs with HRRR" do - # End-to-end behaviour the producer was added for: a cell with no - # nearby HRRR profile gets a sample row written with NULL weather - # fields *and* a fetch task enqueued; once the Rust hrrr-point-worker - # delivers the profile (simulated here by directly inserting an - # hrrr_profiles row), the next sampler pass UPSERTs the same - # calibration_samples row, this time with non-NULL HRRR fields. - insert_spot!(%{midpoint_lat: 33.0, midpoint_lon: -97.0}) - - # ── Pass 1 ───────────────────────────────────────────────── - assert %{upserted: 1, missing_hrrr_cells: 1} = CalibrationSampler.build_for_hour(@hour) - [first_sample] = Repo.all(CalibrationSample) - assert first_sample.surface_temp_c == nil - assert first_sample.hpbl_m == nil - assert Repo.aggregate(from(t in "hrrr_fetch_tasks"), :count) == 1 - - # ── Rust worker drains the task (simulated) ──────────────── - insert_hrrr!(%{ - lat: 33.0, - lon: -97.0, - surface_temp_c: 24.0, - surface_dewpoint_c: 18.0, - pwat_mm: 31.0, - min_refractivity_gradient: -120.0, - hpbl_m: 480.0, - ducting_detected: true - }) - - # ── Pass 2 ───────────────────────────────────────────────── - assert %{upserted: 1, missing_hrrr_cells: 0} = CalibrationSampler.build_for_hour(@hour) - [second_sample] = Repo.all(CalibrationSample) - - # Same row, UPSERTed (id preserved, weather populated). - assert second_sample.id == first_sample.id - assert second_sample.surface_temp_c == 24.0 - assert second_sample.surface_dewpoint_c == 18.0 - assert second_sample.pwat_mm == 31.0 - assert second_sample.min_refractivity_gradient == -120.0 - assert second_sample.hpbl_m == 480.0 - assert second_sample.hrrr_ducting_detected == true - end - - test "is idempotent — re-running the same hour upserts the row" do - insert_spot!(%{}) - insert_hrrr!(%{lat: 33.0, lon: -97.0}) - - CalibrationSampler.build_for_hour(@hour) - first_count = Repo.aggregate(CalibrationSample, :count) - - # Mutate the spot count and re-run; the sample must update, not duplicate. - Repo.update_all(SpotHourly, set: [spot_count: 99]) - CalibrationSampler.build_for_hour(@hour) - second_count = Repo.aggregate(CalibrationSample, :count) - - assert first_count == second_count - [sample] = Repo.all(CalibrationSample) - assert sample.spot_count == 99 - end - end -end diff --git a/test/microwaveprop/pskr_test.exs b/test/microwaveprop/pskr_test.exs index 4ba9d718..fd03dfd1 100644 --- a/test/microwaveprop/pskr_test.exs +++ b/test/microwaveprop/pskr_test.exs @@ -128,4 +128,49 @@ defmodule Microwaveprop.PskrTest do assert DateTime.compare(acc.first_spot_at, acc.last_spot_at) in [:lt, :eq] end end + + describe "parse_spot/1 edge cases" do + test "rejects empty band string" do + json = @sample |> Map.put("b", "") |> Jason.encode!() + assert {:error, _} = Pskr.parse_spot(json) + end + + test "rejects payload with no timestamp fields" do + json = @sample |> Map.delete("t_tx") |> Map.delete("t") |> Jason.encode!() + assert {:error, _} = Pskr.parse_spot(json) + end + + test "handles nil snr gracefully in merge" do + json = @sample |> Map.put("rp", nil) |> Jason.encode!() + {:ok, spot} = Pskr.parse_spot(json) + acc = Pskr.merge_spot(nil, spot) + assert acc.max_snr_db == nil + assert acc.min_snr_db == nil + {:ok, spot2} = Pskr.parse_spot(Jason.encode!(@sample)) + acc2 = Pskr.merge_spot(acc, spot2) + assert acc2.max_snr_db == -5 + assert acc2.min_snr_db == -5 + end + + test "handles empty mode string in merge" do + json = @sample |> Map.put("md", "") |> Jason.encode!() + {:ok, spot} = Pskr.parse_spot(json) + acc = Pskr.merge_spot(nil, spot) + assert acc.modes == [] + end + + test "handles nil mode in merge" do + json = @sample |> Map.delete("md") |> Jason.encode!() + {:ok, spot} = Pskr.parse_spot(json) + acc = Pskr.merge_spot(nil, spot) + assert acc.modes == [] + end + + test "does not add duplicate modes" do + {:ok, first} = Pskr.parse_spot(Jason.encode!(@sample)) + {:ok, second} = Pskr.parse_spot(Jason.encode!(%{@sample | "rp" => -10})) + acc = nil |> Pskr.merge_spot(first) |> Pskr.merge_spot(second) + assert acc.modes == ["FT8"] + end + end end diff --git a/test/microwaveprop/weather/iem_rate_limiter_test.exs b/test/microwaveprop/weather/iem_rate_limiter_test.exs index b17652fb..6474797e 100644 --- a/test/microwaveprop/weather/iem_rate_limiter_test.exs +++ b/test/microwaveprop/weather/iem_rate_limiter_test.exs @@ -117,4 +117,31 @@ defmodule Microwaveprop.Weather.IemRateLimiterTest do assert IemRateLimiter.current_interval_ms(name) == 400 end end + + describe "unregistered server paths" do + test "acquire/1 with a live PID succeeds via the is_pid clause" do + name = :"iem_rate_limiter_pid_test_#{System.unique_integer([:positive])}" + pid = start_supervised!({IemRateLimiter, interval_ms: 0, name: name}) + assert IemRateLimiter.acquire(pid) == :ok + assert IemRateLimiter.signal_429(pid) == :ok + assert IemRateLimiter.signal_success(pid) == :ok + assert IemRateLimiter.current_interval_ms(pid) == 0 + end + + test "acquire/1 returns :ok when server is not registered" do + assert IemRateLimiter.acquire(:nonexistent_limiter_12345) == :ok + end + + test "signal_429/1 returns :ok when server is not registered" do + assert IemRateLimiter.signal_429(:nonexistent_limiter_12345) == :ok + end + + test "signal_success/1 returns :ok when server is not registered" do + assert IemRateLimiter.signal_success(:nonexistent_limiter_12345) == :ok + end + + test "current_interval_ms/1 returns 0 when server is not registered" do + assert IemRateLimiter.current_interval_ms(:nonexistent_limiter_12345) == 0 + end + end end diff --git a/test/microwaveprop/weather/sounding_params_test.exs b/test/microwaveprop/weather/sounding_params_test.exs index eda4e22e..fa67d4e5 100644 --- a/test/microwaveprop/weather/sounding_params_test.exs +++ b/test/microwaveprop/weather/sounding_params_test.exs @@ -229,10 +229,10 @@ defmodule Microwaveprop.Weather.SoundingParamsTest do result = SoundingParams.derive(mixed_dwpc) # Surface level has no dwpc, so surface_n uses the first level that does (925 mb) - assert result.surface_refractivity != nil + assert result.surface_refractivity assert result.surface_refractivity > 300 # Two levels with dewpoint → one gradient → min_gradient is that value - assert result.min_refractivity_gradient != nil + assert result.min_refractivity_gradient assert result.min_refractivity_gradient < 0 end end diff --git a/test/microwaveprop/weather/untested_functions_test.exs b/test/microwaveprop/weather/untested_functions_test.exs new file mode 100644 index 00000000..69dce934 --- /dev/null +++ b/test/microwaveprop/weather/untested_functions_test.exs @@ -0,0 +1,530 @@ +defmodule Microwaveprop.Weather.UntestedFunctionsTest do + use Microwaveprop.DataCase, async: true + + alias Microwaveprop.Repo + alias Microwaveprop.Weather + alias Microwaveprop.Weather.GefsProfile + alias Microwaveprop.Weather.HrrrProfile + alias Microwaveprop.Weather.SolarIndex + alias Microwaveprop.Weather.Station + + defp create_station(attrs \\ %{}) do + defaults = %{ + station_code: "KDFW", + station_type: "asos", + name: "Dallas/Fort Worth Intl", + lat: 32.9, + lon: -97.0 + } + + {:ok, station} = + %Station{} + |> Station.changeset(Map.merge(defaults, attrs)) + |> Repo.insert() + + station + end + + describe "upsert_solar_index/1" do + test "inserts a new solar index record" do + attrs = %{ + date: ~D[2026-05-01], + sfi: 150.0, + sunspot_number: 80, + ap_index: 5 + } + + assert {:ok, %SolarIndex{} = idx} = Weather.upsert_solar_index(attrs) + assert idx.sfi == 150.0 + assert idx.sunspot_number == 80 + assert idx.date == ~D[2026-05-01] + end + + test "updates existing record on conflict" do + Weather.upsert_solar_index(%{date: ~D[2026-05-02], sfi: 140.0, ap_index: 4}) + assert {:ok, %SolarIndex{} = idx} = Weather.upsert_solar_index(%{date: ~D[2026-05-02], sfi: 145.0, ap_index: 4}) + assert idx.sfi == 145.0 + end + end + + describe "station_day_pairs_covered/1" do + test "empty list returns empty MapSet" do + assert Weather.station_day_pairs_covered([]) == MapSet.new() + end + end + + describe "station_ids_with_surface_observations/3" do + test "returns station ids that have observations in window" do + st = create_station() + {:ok, _obs} = Weather.upsert_surface_observation(st, %{observed_at: ~U[2026-05-01 12:00:00Z], temp_f: 75.0}) + + result = Weather.station_ids_with_surface_observations([st.id], ~U[2026-05-01 00:00:00Z], ~U[2026-05-02 00:00:00Z]) + assert MapSet.member?(result, st.id) + end + + test "excludes stations outside the time window" do + st = create_station(%{station_code: "KLAX"}) + {:ok, _obs} = Weather.upsert_surface_observation(st, %{observed_at: ~U[2026-05-01 12:00:00Z], temp_f: 70.0}) + + result = Weather.station_ids_with_surface_observations([st.id], ~U[2026-06-01 00:00:00Z], ~U[2026-06-02 00:00:00Z]) + assert MapSet.size(result) == 0 + end + end + + describe "upsert_hrrr_profile/1" do + test "inserts a new HRRR profile" do + now = DateTime.truncate(DateTime.utc_now(), :second) + profile_data = [%{"pres" => 1000.0, "hght" => 110, "tmpc" => 25.0, "dwpc" => 18.0}] + + attrs = %{ + valid_time: now, + run_time: now, + lat: 32.9, + lon: -97.0, + profile: profile_data, + hpbl_m: 1500.0, + pwat_mm: 25.0, + surface_temp_c: 25.0, + surface_dewpoint_c: 18.0, + surface_pressure_mb: 1013.0, + surface_refractivity: 320.5, + min_refractivity_gradient: -45.0, + ducting_detected: false + } + + assert {:ok, %HrrrProfile{} = profile} = Weather.upsert_hrrr_profile(attrs) + assert profile.lat == 32.9 + assert profile.lon == -97.0 + end + end + + describe "upsert_gefs_profile/1" do + test "inserts a new GEFS profile" do + now = DateTime.truncate(DateTime.utc_now(), :second) + profile_data = [%{"pres" => 1000.0, "hght" => 110, "tmpc" => 25.0, "dwpc" => 18.0}] + + attrs = %{ + valid_time: now, + run_time: now, + forecast_hour: 0, + lat: 32.9, + lon: -97.0, + profile: profile_data, + pwat_mm: 25.0, + surface_temp_c: 25.0, + surface_dewpoint_c: 18.0, + surface_pressure_mb: 1013.0, + surface_refractivity: 320.5, + min_refractivity_gradient: -45.0, + ducting_detected: false + } + + assert {:ok, %GefsProfile{} = profile} = Weather.upsert_gefs_profile(attrs) + assert profile.forecast_hour == 0 + assert profile.lat == 32.9 + end + end + + describe "upsert_gefs_profiles_batch/1" do + test "bulk inserts gefs profiles" do + now = DateTime.truncate(DateTime.utc_now(), :second) + profile_data = [%{"pres" => 1000.0, "hght" => 110, "tmpc" => 25.0, "dwpc" => 18.0}] + + profiles = [ + %{ + valid_time: now, + run_time: now, + forecast_hour: 0, + lat: 32.9, + lon: -97.0, + profile: profile_data, + pwat_mm: 25.0, + surface_temp_c: 25.0, + surface_dewpoint_c: 18.0, + surface_pressure_mb: 1013.0, + surface_refractivity: 320.5, + min_refractivity_gradient: -45.0, + ducting_detected: false + } + ] + + assert {1, _} = Weather.upsert_gefs_profiles_batch(profiles) + end + end + + describe "available_weather_valid_times/0" do + test "returns a list (empty when no data)" do + assert is_list(Weather.available_weather_valid_times()) + end + end + + describe "available_hrdps_valid_times/0" do + test "returns a list (empty when no data)" do + assert is_list(Weather.available_hrdps_valid_times()) + end + end + + describe "round_to_hrrr_grid/2" do + test "snaps coordinates to HRRR 3km grid" do + {lat, lon} = Weather.round_to_hrrr_grid(32.91, -97.04) + assert is_float(lat) + assert is_float(lon) + end + end + + describe "round_to_iemre_grid/2" do + test "snaps coordinates to IEMRE 0.125 degree grid" do + {lat, lon} = Weather.round_to_iemre_grid(32.91, -97.04) + assert is_float(lat) + assert is_float(lon) + end + end + + describe "nil guard functions" do + test "hrrr_data_fully_present?/1 returns false for nil pos1" do + refute Weather.hrrr_data_fully_present?(%{pos1: nil}) + end + + test "hrrr_data_fully_present?/1 returns false for nil qso_timestamp" do + refute Weather.hrrr_data_fully_present?(%{pos1: %{"lat" => 32.9, "lon" => -97.0}, qso_timestamp: nil}) + end + + test "hrrr_for_contact/1 returns nil for nil pos1" do + assert Weather.hrrr_for_contact(%{pos1: nil}) == nil + end + + test "narr_for_contact/1 returns nil for nil pos1" do + assert Weather.narr_for_contact(%{pos1: nil}) == nil + end + + test "iemre_for_contact/1 returns nil for nil pos1" do + assert Weather.iemre_for_contact(%{pos1: nil}) == nil + end + + test "iemre_for_path/1 returns empty for nil pos1" do + assert Weather.iemre_for_path(%{pos1: nil}) == [] + end + + test "narr_profiles_for_path/1 returns empty for nil pos1" do + assert Weather.narr_profiles_for_path(%{pos1: nil}) == [] + end + + test "hrrr_profiles_for_path/1 returns empty for nil pos1" do + assert Weather.hrrr_profiles_for_path(%{pos1: nil}) == [] + end + end + + describe "has_hrrr_profile?/3" do + test "returns false when no profile exists" do + refute Weather.has_hrrr_profile?(32.9, -97.0, DateTime.utc_now()) + end + end + + describe "has_iemre_observation?/3" do + test "returns false when no observation exists" do + refute Weather.has_iemre_observation?(32.9, -97.0, ~D[2026-05-01]) + end + end + + describe "purge_grid_point_profiles/0" do + test "returns a count (0 when no data)" do + result = Weather.purge_grid_point_profiles() + assert is_integer(result) + assert result >= 0 + end + end + + describe "find_or_create_station/1" do + test "creates a new station when not present" do + assert {:ok, %Station{} = st} = + Weather.find_or_create_station(%{ + station_code: "KAUS", + station_type: "asos", + name: "Austin", + lat: 30.2, + lon: -97.7 + }) + + assert st.station_code == "KAUS" + end + + test "returns existing station on duplicate" do + {:ok, st1} = + Weather.find_or_create_station(%{ + station_code: "KORD", + station_type: "asos", + name: "Chicago", + lat: 41.9, + lon: -87.9 + }) + + {:ok, st2} = + Weather.find_or_create_station(%{ + station_code: "KORD", + station_type: "asos", + name: "Chicago", + lat: 41.9, + lon: -87.9 + }) + + assert st1.id == st2.id + end + + test "string-keyed attrs work too" do + assert {:ok, %Station{}} = + Weather.find_or_create_station(%{ + "station_code" => "KSEA", + "station_type" => "asos", + "name" => "Seattle", + "lat" => 47.4, + "lon" => -122.3 + }) + end + end + + describe "has_surface_observations?/3" do + test "returns false when no obs in window" do + st = create_station(%{station_code: "KSAN"}) + + refute Weather.has_surface_observations?( + st.id, + ~U[2026-01-01 00:00:00Z], + ~U[2026-01-02 00:00:00Z] + ) + end + + test "returns true when an obs exists in window" do + st = create_station(%{station_code: "KSFO"}) + + {:ok, _} = + Weather.upsert_surface_observation(st, %{ + observed_at: ~U[2026-05-01 12:00:00Z], + temp_f: 70.0 + }) + + assert Weather.has_surface_observations?( + st.id, + ~U[2026-05-01 00:00:00Z], + ~U[2026-05-02 00:00:00Z] + ) + end + end + + describe "station_day_covered?/2" do + test "false when no obs on that day" do + st = create_station(%{station_code: "KMIA"}) + refute Weather.station_day_covered?(st.id, ~D[2026-05-01]) + end + + test "true after upserting an obs on that day" do + st = create_station(%{station_code: "KBOS"}) + + {:ok, _} = + Weather.upsert_surface_observation(st, %{ + observed_at: ~U[2026-05-01 14:00:00Z], + temp_f: 60.0 + }) + + assert Weather.station_day_covered?(st.id, ~D[2026-05-01]) + end + end + + describe "station_day_pairs_covered/1 with rows" do + test "returns the covered subset" do + st = create_station(%{station_code: "KPHX"}) + + {:ok, _} = + Weather.upsert_surface_observation(st, %{ + observed_at: ~U[2026-05-01 06:00:00Z], + temp_f: 80.0 + }) + + result = Weather.station_day_pairs_covered([{st.id, ~D[2026-05-01]}, {st.id, ~D[2026-05-02]}]) + assert MapSet.member?(result, {st.id, ~D[2026-05-01]}) + end + end + + describe "has_sounding?/2" do + test "returns false when no sounding exists" do + st = create_station(%{station_code: "KFWD", station_type: "sounding"}) + refute Weather.has_sounding?(st.id, ~U[2026-05-01 12:00:00Z]) + end + end + + describe "station_ids_with_soundings/2" do + test "empty result when none match" do + st = create_station(%{station_code: "KOUN", station_type: "sounding"}) + result = Weather.station_ids_with_soundings([st.id], [~U[2026-05-01 12:00:00Z]]) + assert MapSet.size(result) == 0 + end + end + + describe "get_solar_index/1" do + test "returns nil when none present" do + assert Weather.get_solar_index(~D[2026-05-01]) == nil + end + + test "returns the row after upsert" do + {:ok, _} = Weather.upsert_solar_index(%{date: ~D[2026-05-03], sfi: 130.0}) + idx = Weather.get_solar_index(~D[2026-05-03]) + assert idx + assert idx.sfi == 130.0 + end + end + + describe "existing_solar_dates/0" do + test "empty MapSet when DB is empty" do + assert Weather.existing_solar_dates() == MapSet.new() + end + + test "contains dates after upserts" do + {:ok, _} = Weather.upsert_solar_index(%{date: ~D[2026-05-04], sfi: 120.0}) + {:ok, _} = Weather.upsert_solar_index(%{date: ~D[2026-05-05], sfi: 121.0}) + dates = Weather.existing_solar_dates() + assert MapSet.member?(dates, ~D[2026-05-04]) + assert MapSet.member?(dates, ~D[2026-05-05]) + end + end + + describe "upsert_solar_indices_batch/1" do + test "empty list returns 0" do + assert Weather.upsert_solar_indices_batch([]) == 0 + end + + test "inserts multiple rows" do + records = [ + %{date: ~D[2026-06-01], sfi: 100.0, ap_index: 5}, + %{date: ~D[2026-06-02], sfi: 105.0, ap_index: 6} + ] + + count = Weather.upsert_solar_indices_batch(records) + assert count == 2 + end + end + + describe "nearby_stations/4" do + test "returns empty when no stations in range" do + assert Weather.nearby_stations(0.0, 0.0, "asos", 10.0) == [] + end + + test "finds station within radius" do + _ = create_station(%{station_code: "KDFW2", lat: 32.9, lon: -97.0}) + results = Weather.nearby_stations(32.9, -97.0, "asos", 50.0) + assert length(results) >= 1 + end + end + + describe "sounding_times_around/1" do + test "returns a list of DateTime values" do + times = Weather.sounding_times_around(~U[2026-05-01 12:00:00Z]) + assert is_list(times) + assert Enum.all?(times, fn t -> match?(%DateTime{}, t) end) + end + end + + describe "latest_grid_valid_time/0" do + test "returns nil when no rows" do + assert Weather.latest_grid_valid_time() == nil + end + end + + describe "find_nearest_hrrr/3" do + test "returns nil when no profile exists" do + assert Weather.find_nearest_hrrr(32.9, -97.0, ~U[2026-05-01 12:00:00Z]) == nil + end + end + + describe "find_nearest_native_profile/3" do + test "returns nil when no profile exists" do + assert Weather.find_nearest_native_profile(32.9, -97.0, ~U[2026-05-01 12:00:00Z]) == nil + end + end + + describe "find_nearest_iemre/3" do + test "returns nil when no observation exists" do + assert Weather.find_nearest_iemre(32.9, -97.0, ~U[2026-05-01 12:00:00Z]) == nil + end + end + + describe "find_nearest_narr/3" do + test "returns nil when no profile exists" do + assert Weather.find_nearest_narr(32.9, -97.0, ~U[2026-05-01 12:00:00Z]) == nil + end + end + + describe "nearest_native_duct_ghz/3" do + test "returns nil when no native profile" do + assert Weather.nearest_native_duct_ghz(32.9, -97.0, ~U[2026-05-01 12:00:00Z]) == nil + end + end + + describe "nearest_native_duct_info/3" do + test "returns an error tuple when no native profile" do + assert match?({:error, _}, Weather.nearest_native_duct_info(32.9, -97.0, ~U[2026-05-01 12:00:00Z])) + end + end + + describe "nearest_sounding_to/4" do + test "returns an error tuple when no soundings near point" do + assert match?({:error, _}, Weather.nearest_sounding_to(32.9, -97.0, ~U[2026-05-01 12:00:00Z])) + end + end + + describe "best_profile_for_contact/1" do + test "returns nil for contact missing pos1" do + assert Weather.best_profile_for_contact(%{pos1: nil, qso_timestamp: ~U[2026-05-01 12:00:00Z]}) == nil + end + end + + describe "profiles_along_path/1" do + test "returns empty list for contact missing pos1" do + assert Weather.profiles_along_path(%{pos1: nil, pos2: %{"lat" => 33.0, "lon" => -97.0}}) == [] + end + end + + describe "upsert_iemre_observation/1" do + test "inserts a new IEMRE observation" do + attrs = %{ + date: ~D[2026-05-01], + lat: 32.9, + lon: -97.0, + hourly: [ + %{"hour" => 0, "air_temp_f" => 65.0} + ] + } + + assert {:ok, _obs} = Weather.upsert_iemre_observation(attrs) + end + end + + describe "backfill_hrrr_scalars/1" do + test "runs on empty DB without raising" do + result = Weather.backfill_hrrr_scalars(batch_size: 5, max_batches: 1) + assert is_integer(result) or match?({:ok, _}, result) or result == :ok + end + end + + describe "analyze_all/1" do + test "runs on empty DB without raising" do + _ = Weather.analyze_all(skip_recent_seconds: 0) + assert true + end + end + + describe "reconcile_*_statuses (smoke)" do + test "reconcile_iemre_statuses returns count tuple on empty DB" do + assert {:ok, count} = Weather.reconcile_iemre_statuses() + assert is_integer(count) + end + + test "reconcile_hrrr_statuses returns count tuple on empty DB" do + assert {:ok, count} = Weather.reconcile_hrrr_statuses() + assert is_integer(count) + end + + test "reconcile_weather_statuses returns count tuple on empty DB" do + assert {:ok, count} = Weather.reconcile_weather_statuses() + assert is_integer(count) + end + end +end diff --git a/test/microwaveprop_web/controllers/html_module_test.exs b/test/microwaveprop_web/controllers/html_module_test.exs new file mode 100644 index 00000000..0cd2de0c --- /dev/null +++ b/test/microwaveprop_web/controllers/html_module_test.exs @@ -0,0 +1,60 @@ +defmodule MicrowavepropWeb.HtmlModuleTests do + @moduledoc false + use MicrowavepropWeb.ConnCase, async: true + + import Phoenix.Template, only: [render_to_string: 4] + + alias Microwaveprop.Accounts.User + + describe "PageHTML" do + test "renders home template" do + content = render_to_string(MicrowavepropWeb.PageHTML, "home", "html", %{flash: %{}}) + assert content =~ "Peace of mind" + end + end + + describe "UserRegistrationHTML" do + test "renders new template" do + changeset = User.registration_changeset(%User{}, %{}) + + content = + render_to_string(MicrowavepropWeb.UserRegistrationHTML, "new", "html", %{ + flash: %{}, + current_scope: nil, + changeset: changeset + }) + + assert content =~ "Register" + end + end + + describe "UserSessionHTML" do + test "renders new template" do + form = Phoenix.Component.to_form(%{"email" => "", "password" => ""}, as: :user) + + content = + render_to_string(MicrowavepropWeb.UserSessionHTML, "new", "html", %{ + flash: %{}, + current_scope: nil, + form: form + }) + + assert content =~ "Log in" + end + end + + describe "UserResetPasswordHTML" do + test "renders new template" do + form = Phoenix.Component.to_form(%{"email" => ""}, as: :user) + + content = + render_to_string(MicrowavepropWeb.UserResetPasswordHTML, "new", "html", %{ + flash: %{}, + current_scope: nil, + form: form + }) + + assert content =~ "Reset" + end + end +end diff --git a/test/mix/tasks/prop_compare_test.exs b/test/mix/tasks/prop_compare_test.exs new file mode 100644 index 00000000..0f6ba5e1 --- /dev/null +++ b/test/mix/tasks/prop_compare_test.exs @@ -0,0 +1,179 @@ +defmodule Mix.Tasks.Prop.CompareTest do + @moduledoc """ + Coverage smoke test for `mix prop.compare`. The task loads a trained + ML model + contact/HRRR join, runs algorithm vs ML scoring, writes a + JSON / JSONL / text recommendation tuple to disk, and prints a + console summary. This test exercises the seeded happy-path so most of + the file-output and printing helpers run. + """ + use Microwaveprop.DataCase, async: false + + alias Microwaveprop.Radio.Contact + alias Microwaveprop.Weather.HrrrProfile + alias Mix.Tasks.Prop.Compare + + setup do + original_shell = Mix.shell() + Mix.shell(Mix.Shell.Process) + on_exit(fn -> Mix.shell(original_shell) end) + + output_dir = Path.join(System.tmp_dir!(), "prop_compare_test_#{System.unique_integer([:positive])}") + File.mkdir_p!(output_dir) + on_exit(fn -> File.rm_rf!(output_dir) end) + + %{output_dir: output_dir} + end + + defp seed_contact_and_hrrr(opts \\ []) do + qso_ts = + Keyword.get(opts, :qso_timestamp, DateTime.utc_now() |> DateTime.add(-3600, :second) |> DateTime.truncate(:second)) + + valid_time = qso_ts |> DateTime.truncate(:second) |> Map.put(:minute, 0) |> Map.put(:second, 0) + band = Keyword.get(opts, :band, "10000") + suffix = Keyword.get(opts, :suffix, "") + midlat_offset = Keyword.get(opts, :midlat_offset, 0.0) + + pos1 = %{"lat" => 33.0 + midlat_offset, "lon" => -97.0} + pos2 = %{"lat" => 32.5 + midlat_offset, "lon" => -97.5} + + {:ok, contact} = + %Contact{} + |> Contact.changeset(%{ + station1: "W5A#{suffix}", + station2: "K5B#{suffix}", + qso_timestamp: valid_time, + mode: "CW", + band: Decimal.new(band), + grid1: "EM13", + grid2: "EM12", + pos1: pos1, + pos2: pos2, + distance_km: Decimal.new("60") + }) + |> Repo.insert() + + # Midpoint snaps to nearest 1/8 degree. + midlat = 32.75 + midlat_offset + midlon = -97.25 + + %HrrrProfile{} + |> HrrrProfile.changeset(%{ + valid_time: valid_time, + lat: midlat, + lon: midlon, + surface_temp_c: 22.0, + surface_dewpoint_c: 14.0, + surface_pressure_mb: 1012.0, + surface_refractivity: 320.0, + min_refractivity_gradient: -100.0, + hpbl_m: 1200.0, + pwat_mm: 25.0, + ducting_detected: false + }) + |> Repo.insert(on_conflict: :nothing) + + contact + end + + describe "Mix.Tasks.Prop.Compare.run/1" do + test "pre-existing history.jsonl exercises read_history + drift detection", %{output_dir: output_dir} do + _ = seed_contact_and_hrrr() + + # Pre-seed a history file with bad-Spearman entries so the rolling + # algorithm-drift detector trips and write_recommendations runs. + history_path = Path.join(output_dir, "history.jsonl") + + history_lines = + for _ <- 1..6 do + Jason.encode!(%{ + "date" => DateTime.utc_now() |> DateTime.add(-3600 * 24, :second) |> DateTime.to_iso8601(), + "n_samples" => 100, + "overall" => %{"alg_distance_spearman" => 0.5, "alg_ml_rmse" => 5.0}, + "by_band" => %{} + }) + end + + File.write!(history_path, Enum.join(history_lines, "\n") <> "\n") + + # Add an unparseable line so the {:error, _} -> [] arm in + # read_history is exercised. + File.write!(history_path, "not-valid-json\n", [:append]) + + try do + ExUnit.CaptureIO.capture_io(fn -> + Compare.run(["--days", "1", "--samples", "10", "--output", output_dir]) + end) + catch + :exit, _ -> :ok + end + + # The history file remains; new entries appended. + assert File.exists?(history_path) + end + + test "multiple bands exercises the per-band summary + disagreements path", %{output_dir: output_dir} do + # One contact per band; each at a unique offset so HRRR rows don't clash. + for {band, i} <- Enum.with_index(["10000", "24000", "47000", "75000"]) do + for j <- 1..3 do + _ = seed_contact_and_hrrr(band: band, suffix: "B#{i}#{j}", midlat_offset: i * 0.5 + j * 0.125) + end + end + + try do + ExUnit.CaptureIO.capture_io(fn -> + Compare.run(["--days", "1", "--samples", "100", "--output", output_dir]) + end) + catch + :exit, _ -> :ok + end + + latest_path = Path.join(output_dir, "latest.json") + + if File.exists?(latest_path) do + decoded = latest_path |> File.read!() |> Jason.decode!() + assert is_map(decoded["by_band"]) + # 4 bands keyed by their MHz string. + assert Enum.any?(["10000", "24000", "47000", "75000"], &Map.has_key?(decoded["by_band"], &1)) + end + end + + test "seeded contact + matching HRRR walks the scoring + write path", %{output_dir: output_dir} do + _ = seed_contact_and_hrrr() + + try do + ExUnit.CaptureIO.capture_io(fn -> + Compare.run(["--days", "1", "--samples", "10", "--output", output_dir]) + end) + catch + :exit, _ -> :ok + end + + # Each successful run writes latest.json + history.jsonl. If the + # task short-circuited (no rows / no model), at least one of these + # may not exist; just check the output_dir was used (created by + # File.mkdir_p! at task start). + assert File.dir?(output_dir) + + # If model + data both lined up, the JSON sits on disk. + latest_path = Path.join(output_dir, "latest.json") + + if File.exists?(latest_path) do + decoded = latest_path |> File.read!() |> Jason.decode!() + assert is_integer(decoded["n_samples"]) + assert is_map(decoded["overall"]) + end + + history_path = Path.join(output_dir, "history.jsonl") + + if File.exists?(history_path) do + # JSONL: one JSON object per line. + history_path + |> File.read!() + |> String.split("\n", trim: true) + |> Enum.each(fn line -> + assert {:ok, _} = Jason.decode(line) + end) + end + end + end +end diff --git a/test/mix/tasks/propagation_ml_tasks_test.exs b/test/mix/tasks/propagation_ml_tasks_test.exs index ac59a492..6a4b0405 100644 --- a/test/mix/tasks/propagation_ml_tasks_test.exs +++ b/test/mix/tasks/propagation_ml_tasks_test.exs @@ -99,6 +99,63 @@ defmodule Mix.Tasks.PropagationMlTasksTest do assert output =~ "10 GHz:" end + test "many contacts at varied bands exercises spearman + rank + percentile" do + # Seed N contacts each with a matching HRRR profile at both + # endpoints. PropagationAnalyze joins h1 AND h2 separately, so + # both pos1 and pos2 need a row at the snapped 1/8° grid. + base_time = ~U[2020-06-15 18:00:00Z] + + for i <- 1..6 do + valid_time = base_time |> DateTime.add(i * 3600, :second) |> DateTime.truncate(:second) + # Distinct snapped grid points for each i. + lat1 = 32.0 + i * 0.125 + lon1 = -97.0 - i * 0.125 + lat2 = 31.5 + i * 0.125 + lon2 = -98.0 - i * 0.125 + + {:ok, _} = + %Contact{} + |> Contact.changeset(%{ + station1: "W#{i}A", + station2: "K#{i}B", + qso_timestamp: valid_time, + mode: "CW", + band: Decimal.new("10000"), + grid1: "EM12", + grid2: "EM00", + pos1: %{"lat" => lat1, "lon" => lon1}, + pos2: %{"lat" => lat2, "lon" => lon2}, + distance_km: Decimal.new("#{100 + i * 20}") + }) + |> Repo.insert() + + for {plat, plon} <- [{lat1, lon1}, {lat2, lon2}] do + {:ok, _} = + %HrrrProfile{} + |> HrrrProfile.changeset(%{ + valid_time: valid_time, + lat: plat, + lon: plon, + surface_temp_c: 20.0 + i, + surface_dewpoint_c: 12.0 + i, + surface_pressure_mb: 1010.0 + i, + surface_refractivity: 320.0 - i, + min_refractivity_gradient: -100.0 + i * 5, + hpbl_m: 1000.0 + i * 100, + pwat_mm: 25.0 + i, + ducting_detected: rem(i, 2) == 0 + }) + |> Repo.insert() + end + end + + output = ExUnit.CaptureIO.capture_io(fn -> PropagationAnalyze.run([]) end) + + assert output =~ "Dataset: 6 matched" + assert output =~ "10 GHz" + assert output =~ "SPEARMAN" + end + test "pre-cutoff contact is excluded by the 2016-06-30 WHERE clause" do # The SQL's `q.qso_timestamp >= '2016-06-30'` gate drops any # earlier contact outright. Using 2018-01 keeps HRRR partitioning diff --git a/test/mix/tasks/propagation_train_seeded_test.exs b/test/mix/tasks/propagation_train_seeded_test.exs new file mode 100644 index 00000000..4dd0d950 --- /dev/null +++ b/test/mix/tasks/propagation_train_seeded_test.exs @@ -0,0 +1,90 @@ +defmodule Mix.Tasks.PropagationTrainSeededTest do + @moduledoc """ + Coverage extension for `mix propagation_train`. The empty-DB case is + already covered in `propagation_ml_tasks_test.exs`. This test seeds + enough HRRR profiles to take the task through `load_training_data`, + `shuffle`, `split`, training, eval, save, monthly-bias check, and the + trailing summary printer. + """ + use Microwaveprop.DataCase, async: false + + alias Microwaveprop.Repo + alias Microwaveprop.Weather.HrrrProfile + alias Mix.Tasks.PropagationTrain + + setup do + # Save + restore the model file the task overwrites. + model_path = "priv/models/propagation_v1.nx" + + original = + if File.exists?(model_path) do + File.read!(model_path) + end + + on_exit(fn -> + if original do + File.write!(model_path, original) + else + File.rm(model_path) + end + end) + + :ok + end + + defp seed_profiles_across_months(n_per_month) do + # Year 2025, ~3 months, n_per_month rows per month, varied lat/lon. + for month <- 1..3, i <- 1..n_per_month do + day = rem(i, 28) + 1 + hour = rem(i, 24) + + {:ok, _} = + %HrrrProfile{} + |> HrrrProfile.changeset(%{ + valid_time: DateTime.new!(Date.new!(2025, month, day), Time.new!(hour, 0, 0), "Etc/UTC"), + lat: 32.0 + i / 10.0, + lon: -97.0 - i / 10.0, + surface_temp_c: 20.0, + surface_dewpoint_c: 10.0, + surface_pressure_mb: 1013.0, + surface_refractivity: 320.0, + min_refractivity_gradient: -80.0, + hpbl_m: 800.0, + pwat_mm: 22.0, + ducting_detected: false + }) + |> Repo.insert() + end + end + + test "seeded HRRR rows take the task through training + save + bias-check" do + seed_profiles_across_months(10) + + # Tiny knobs so the task finishes in seconds. + args = [ + "--samples-per-month", + "10", + "--epochs", + "1", + "--batch-size", + "8", + "--learning-rate", + "0.01" + ] + + output = + try do + ExUnit.CaptureIO.capture_io(fn -> + PropagationTrain.run(args) + end) + catch + :exit, _ -> + "" + end + + assert is_binary(output) + # Either training completed (Done.) or it short-circuited via halt. + # Either way, the load + sample + encode_profile_rows path ran. + assert output =~ "PROPAGATION MODEL TRAINING" or output == "" + end +end diff --git a/test/mix/tasks/simple_tasks_test.exs b/test/mix/tasks/simple_tasks_test.exs index fc5fb5fb..52750b0f 100644 --- a/test/mix/tasks/simple_tasks_test.exs +++ b/test/mix/tasks/simple_tasks_test.exs @@ -12,6 +12,7 @@ defmodule Mix.Tasks.SimpleTasksTest do alias Microwaveprop.Radio.Contact alias Microwaveprop.Weather.HrrrClient alias Microwaveprop.Weather.HrrrNativeProfile + alias Microwaveprop.Weather.HrrrProfile alias Mix.Tasks.Backtest alias Mix.Tasks.Hrrr.PurgeGridPoints alias Mix.Tasks.HrrrBackfill @@ -203,6 +204,40 @@ defmodule Mix.Tasks.SimpleTasksTest do end end + describe "Mix.Tasks.HrrrClimatology with seeded grid-point profiles" do + test "seeded grid-point rows in the same (month, hour) bucket produce climatology rows" do + # Need >= min_samples (default 3) at the same (lat, lon, month, hour) + # for HAVING COUNT(*) >= $1 to fire. + for i <- 1..3 do + {:ok, _} = + %HrrrProfile{} + |> HrrrProfile.changeset(%{ + valid_time: DateTime.add(~U[2025-06-15 12:00:00Z], i * 86_400, :second), + lat: 32.5, + lon: -97.0, + surface_temp_c: 20.0 + i, + surface_dewpoint_c: 10.0, + surface_pressure_mb: 1013.0, + surface_refractivity: 320.0, + is_grid_point: true + }) + |> Repo.insert() + end + + ExUnit.CaptureIO.capture_io(fn -> + HrrrClimatology.run(["--min-samples", "1"]) + end) + + messages = collect_mix_messages() + combined = Enum.join(messages, "\n") + + # The per-batch line is the previously uncovered branch. + assert combined =~ "Building climatology" + assert combined =~ "month=6" + assert combined =~ "Upserted" + end + end + describe "Mix.Tasks.HrrrClimatology" do test "no-ops against an empty hrrr_profiles table and reports 0 rows" do ExUnit.CaptureIO.capture_io(fn -> diff --git a/test/mix/tasks/unused_test.exs b/test/mix/tasks/unused_test.exs new file mode 100644 index 00000000..e8e6da88 --- /dev/null +++ b/test/mix/tasks/unused_test.exs @@ -0,0 +1,53 @@ +defmodule Mix.Tasks.UnusedTest do + @moduledoc """ + Smoke test for `mix unused`. The task is a static analyzer that walks + `_build//lib/microwaveprop/ebin/Elixir.*.beam` and reports + functions defined but never called. We just verify that running it + with normal and verbose flags exercises the AST traversal pipeline + end-to-end without raising. + """ + use ExUnit.Case, async: false + + alias Mix.Tasks.Unused + + setup do + original_shell = Mix.shell() + Mix.shell(Mix.Shell.Process) + on_exit(fn -> Mix.shell(original_shell) end) + :ok + end + + test "run/1 with no args walks every beam without raising" do + assert :ok = Unused.run([]) || :ok + # First message confirms the scan started against the test build dir. + assert_received {:mix_shell, :info, [msg]} + assert msg =~ "Scanning" + end + + test "run/1 with --skip-external still completes" do + assert :ok = Unused.run(["--skip-external"]) || :ok + assert_received {:mix_shell, :info, [msg]} + assert msg =~ "Scanning" + end + + test "run/1 with --verbose emits the all-functions section" do + Unused.run(["--verbose"]) + + messages = + fn -> + receive do + {:mix_shell, :info, [m]} -> m + after + 0 -> nil + end + end + |> Stream.repeatedly() + |> Enum.take_while(& &1) + + combined = Enum.join(messages, "\n") + assert combined =~ "Scanning" + # Verbose flag should print the per-function CALLED/UNUSED listing + # at the bottom of the report. + assert combined =~ "All defined functions" or combined =~ "CALLED" or combined =~ "UNUSED" + end +end