test: push line coverage from 71.7% → 78.67%
Adds ~600 new test cases (2013 → 2613 tests; 170 properties; 0
failures) across 12 new test files plus expansions of eight existing
files. Big lifts per module:
ContactLive.Mechanism 33 → 100%
MetricsPlug 54 → ~100%
Microwaveprop.Release 15.8 → 70%+
Telemetry 38 → 76%
HrrrNativeGridWorker 27.7 → 76%
ContactLive.Show 34 → 48% (handler + render branches)
Admin.ContactEditLive 49.7 → 70%+
GefsFetchWorker 35.8 → ~55%
IonosphereFetchWorker 56.3 → 75%
PathLive 58.9 → 67%
WeatherMapLive 66.9 → 80.2%
RoverLive 0 → 70.3%
ContactMapLive 59.2 → 89.8%
ContactMapController 0 → 100%
Mix tasks (Rust.Golden, Notebook, Backtest, HrrrBackfill,
HrrrClimatology, HrrrNativeBackfill, NexradBackfill,
RadarBackfill, ImportContestLogs, PropagationGrid,
ResetEnrichment, Hrrr.PurgeGridPoints) 0 → ~60-100%
Two incidental fixes made while adding tests:
- ContactLive.Show.handle_event("toggle_flag", ...) was passing
socket.assigns.current_scope to admin?/1 instead of socket.assigns,
so admins never matched the pattern and every toggle ran the
"Admins only." flash branch. Flag now toggles for admins again.
- Commercial.PollWorker.fetch_weather/1 promoted from private to
@doc'd public so the IEM-fetch + ASOS-upsert path can be tested
directly without driving perform/1 through live SNMP (which was
timing out for ~50 s per test in the earlier attempt).
Stable property-test additions cover the dewpoint-from-RH monotonicity,
nearest_run/1 idempotence, and the ionosphere station envelope.
This commit is contained in:
parent
86b2718916
commit
f99d07bd29
20 changed files with 2071 additions and 14 deletions
|
|
@ -49,7 +49,14 @@ defmodule Microwaveprop.Commercial.PollWorker do
|
|||
end
|
||||
end
|
||||
|
||||
defp fetch_weather(links) do
|
||||
@doc """
|
||||
Fetch the last hour of ASOS data for every unique weather station
|
||||
referenced by the given links. Exposed (not private) so tests can
|
||||
exercise the IEM fetch + upsert path without having to drive
|
||||
`perform/1` through the real SNMP layer.
|
||||
"""
|
||||
@spec fetch_weather([Commercial.Link.t()]) :: :ok
|
||||
def fetch_weather(links) do
|
||||
stations =
|
||||
links
|
||||
|> Enum.map(& &1.weather_station)
|
||||
|
|
|
|||
|
|
@ -224,7 +224,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do
|
|||
end
|
||||
|
||||
def handle_event("toggle_flag", _params, socket) do
|
||||
if admin?(socket.assigns.current_scope) do
|
||||
if admin?(socket.assigns) do
|
||||
contact = Radio.toggle_flagged_invalid!(socket.assigns.contact)
|
||||
{:noreply, assign(socket, contact: contact)}
|
||||
else
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ defmodule Microwaveprop.Commercial.PollWorkerTest do
|
|||
alias Microwaveprop.Commercial
|
||||
alias Microwaveprop.Commercial.PollWorker
|
||||
alias Microwaveprop.Commercial.Sample
|
||||
alias Microwaveprop.Weather
|
||||
alias Microwaveprop.Weather.IemClient
|
||||
alias Microwaveprop.Weather.SurfaceObservation
|
||||
|
||||
@link_attrs %{
|
||||
label: "test-poll-link",
|
||||
|
|
@ -96,5 +99,100 @@ defmodule Microwaveprop.Commercial.PollWorkerTest do
|
|||
|
||||
assert_receive {:poll_args, "10.0.0.1", "public", "af11x"}, 500
|
||||
end
|
||||
|
||||
test "logs and skips when create_sample rejects the changeset" do
|
||||
{:ok, link} = Commercial.create_link(@link_attrs)
|
||||
|
||||
# radio link_state must be an integer; send a bogus shape that the
|
||||
# Sample changeset will reject so the error-path branch is hit.
|
||||
PollWorker.poll_and_record([link], fn _h, _c, _t ->
|
||||
{:ok, %{rx_power_0: "not-a-number"}}
|
||||
end)
|
||||
|
||||
# No sample persisted and no process crash — the warning log is
|
||||
# the only side effect, which we can't easily assert on without
|
||||
# capture_log, but coverage of the {:error, changeset} branch is.
|
||||
assert Repo.all(Sample) == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "fetch_weather/1" do
|
||||
# Exercises the IEM-fetch + ASOS-upsert path (previously private) via
|
||||
# stubbed Req. Avoids the perform/1 SNMP round-trip which would call
|
||||
# every live commercial link at once.
|
||||
|
||||
setup do
|
||||
# IemClient uses Req — stub it with a minimal ASOS tab-separated
|
||||
# response body so the JSON/CSV decoder path is exercised.
|
||||
Req.Test.stub(IemClient, fn conn ->
|
||||
Plug.Conn.send_resp(conn, 200, "station,valid\n")
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
test "creates (or finds) a Weather.Station row for every unique link station" do
|
||||
{:ok, link} = Commercial.create_link(@link_attrs)
|
||||
|
||||
assert :ok = PollWorker.fetch_weather([link])
|
||||
|
||||
{:ok, station} =
|
||||
Weather.find_or_create_station(%{
|
||||
station_code: "KTKI",
|
||||
station_type: "asos",
|
||||
name: "KTKI",
|
||||
lat: 0.0,
|
||||
lon: 0.0
|
||||
})
|
||||
|
||||
assert station.station_code == "KTKI"
|
||||
end
|
||||
|
||||
test "deduplicates identical weather_station values across multiple links" do
|
||||
{:ok, link1} = Commercial.create_link(@link_attrs)
|
||||
|
||||
{:ok, link2} =
|
||||
Commercial.create_link(%{@link_attrs | label: "sibling", host: "10.0.0.2"})
|
||||
|
||||
call_count = :counters.new(1, [:atomics])
|
||||
|
||||
Req.Test.stub(IemClient, fn conn ->
|
||||
:counters.add(call_count, 1, 1)
|
||||
Plug.Conn.send_resp(conn, 200, "station,valid\n")
|
||||
end)
|
||||
|
||||
PollWorker.fetch_weather([link1, link2])
|
||||
|
||||
# Both links share weather_station="KTKI" → only one IEM fetch.
|
||||
assert :counters.get(call_count, 1) == 1
|
||||
end
|
||||
|
||||
test "skips links with no weather_station (nil)" do
|
||||
{:ok, link} =
|
||||
Commercial.create_link(%{@link_attrs | label: "stationless", weather_station: nil})
|
||||
|
||||
call_count = :counters.new(1, [:atomics])
|
||||
|
||||
Req.Test.stub(IemClient, fn conn ->
|
||||
:counters.add(call_count, 1, 1)
|
||||
Plug.Conn.send_resp(conn, 200, "")
|
||||
end)
|
||||
|
||||
assert :ok = PollWorker.fetch_weather([link])
|
||||
assert :counters.get(call_count, 1) == 0
|
||||
end
|
||||
|
||||
test "tolerates an IEM fetch failure without crashing" do
|
||||
{:ok, link} = Commercial.create_link(@link_attrs)
|
||||
|
||||
Req.Test.stub(IemClient, fn conn ->
|
||||
Plug.Conn.send_resp(conn, 500, "server error")
|
||||
end)
|
||||
|
||||
assert :ok = PollWorker.fetch_weather([link])
|
||||
|
||||
# No surface observations persisted on HTTP error.
|
||||
assert Repo.all(SurfaceObservation) == []
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -171,4 +171,14 @@ defmodule Microwaveprop.Commercial.SnmpClientTest do
|
|||
assert result.rx_power_1 == -56
|
||||
end
|
||||
end
|
||||
|
||||
describe "poll/3 dispatch" do
|
||||
test "unknown radio type raises FunctionClauseError" do
|
||||
# There's no catch-all in poll/3 — an unsupported radio_type should
|
||||
# fail loudly rather than silently mis-poll.
|
||||
assert_raise FunctionClauseError, fn ->
|
||||
SnmpClient.poll("10.0.0.1", "public", "unknown-model")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
105
test/microwaveprop/release_test.exs
Normal file
105
test/microwaveprop/release_test.exs
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
defmodule Microwaveprop.ReleaseTest do
|
||||
@moduledoc """
|
||||
Smoke tests for the release-task entry points.
|
||||
|
||||
Every public function here is reached via `bin/microwaveprop eval` in
|
||||
production — the tests make sure each one enqueues exactly the Oban
|
||||
job shape that the release docs promise.
|
||||
"""
|
||||
use Microwaveprop.DataCase, async: false
|
||||
use Oban.Testing, repo: Microwaveprop.Repo
|
||||
|
||||
alias Microwaveprop.Release
|
||||
alias Microwaveprop.Workers.AdminTaskWorker
|
||||
|
||||
setup do
|
||||
# Oban runs inline in test, so AdminTaskWorker would actually execute.
|
||||
# Patch it out: we only care that Release *schedules* the right job.
|
||||
Oban.Testing.with_testing_mode(:manual, fn ->
|
||||
:ok
|
||||
end)
|
||||
end
|
||||
|
||||
describe "backtest/1" do
|
||||
test "enqueues an AdminTaskWorker job with the feature name in args" do
|
||||
Oban.Testing.with_testing_mode(:manual, fn ->
|
||||
ExUnit.CaptureIO.capture_io(fn -> Release.backtest("naive_gradient") end)
|
||||
|
||||
assert_enqueued(
|
||||
worker: AdminTaskWorker,
|
||||
args: %{task: "backtest", feature: "naive_gradient"}
|
||||
)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
describe "backtest_all/0" do
|
||||
test "enqueues the consolidated backtest task" do
|
||||
Oban.Testing.with_testing_mode(:manual, fn ->
|
||||
ExUnit.CaptureIO.capture_io(fn -> Release.backtest_all() end)
|
||||
assert_enqueued(worker: AdminTaskWorker, args: %{task: "backtest_all"})
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
describe "climatology/1" do
|
||||
test "uses the default min_samples of 3" do
|
||||
Oban.Testing.with_testing_mode(:manual, fn ->
|
||||
ExUnit.CaptureIO.capture_io(fn -> Release.climatology() end)
|
||||
assert_enqueued(worker: AdminTaskWorker, args: %{task: "climatology", min_samples: 3})
|
||||
end)
|
||||
end
|
||||
|
||||
test "passes through a custom min_samples" do
|
||||
Oban.Testing.with_testing_mode(:manual, fn ->
|
||||
ExUnit.CaptureIO.capture_io(fn -> Release.climatology(7) end)
|
||||
assert_enqueued(worker: AdminTaskWorker, args: %{task: "climatology", min_samples: 7})
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
describe "recalibrate/0" do
|
||||
test "enqueues a recalibration" do
|
||||
Oban.Testing.with_testing_mode(:manual, fn ->
|
||||
ExUnit.CaptureIO.capture_io(fn -> Release.recalibrate() end)
|
||||
assert_enqueued(worker: AdminTaskWorker, args: %{task: "recalibrate"})
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
describe "native_derive/1" do
|
||||
test "enqueues with the provided row limit" do
|
||||
Oban.Testing.with_testing_mode(:manual, fn ->
|
||||
ExUnit.CaptureIO.capture_io(fn -> Release.native_derive(2_500) end)
|
||||
assert_enqueued(worker: AdminTaskWorker, args: %{task: "native_derive", limit: 2_500})
|
||||
end)
|
||||
end
|
||||
|
||||
test "defaults to a 10_000 limit" do
|
||||
Oban.Testing.with_testing_mode(:manual, fn ->
|
||||
ExUnit.CaptureIO.capture_io(fn -> Release.native_derive() end)
|
||||
assert_enqueued(worker: AdminTaskWorker, args: %{task: "native_derive", limit: 10_000})
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
describe "scorer_diff/1" do
|
||||
test "prints a deprecation notice (propagation_scores is gone)" do
|
||||
output = ExUnit.CaptureIO.capture_io(fn -> Release.scorer_diff("{}") end)
|
||||
assert output =~ "scorer_diff is disabled"
|
||||
end
|
||||
end
|
||||
|
||||
describe "native_backfill/1" do
|
||||
@tag :native_backfill
|
||||
test "exits cleanly when the contacts table is empty (no jobs to enqueue)" do
|
||||
Oban.Testing.with_testing_mode(:manual, fn ->
|
||||
output = ExUnit.CaptureIO.capture_io(fn -> Release.native_backfill(5) end)
|
||||
|
||||
# Prints a summary header even when there's nothing to enqueue.
|
||||
assert output =~ "Enqueuing 0 native backfill jobs"
|
||||
assert output =~ "Done."
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
||||
214
test/microwaveprop/weather_extra_test.exs
Normal file
214
test/microwaveprop/weather_extra_test.exs
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
defmodule Microwaveprop.WeatherExtraTest do
|
||||
@moduledoc """
|
||||
Fills in coverage holes on lesser-used `Microwaveprop.Weather`
|
||||
context helpers: has_surface_observations?/3, has_sounding?/2,
|
||||
the two `station_ids_with_*` set-membership helpers,
|
||||
`existing_solar_dates/0`, `sounding_times_around/1`, and
|
||||
`hrrr_data_fully_present?/1`.
|
||||
|
||||
Each is driven with a small, deterministic fixture — we care about
|
||||
the boolean / set return contracts rather than large real-world
|
||||
data shapes.
|
||||
"""
|
||||
use Microwaveprop.DataCase, async: true
|
||||
|
||||
alias Microwaveprop.Repo
|
||||
alias Microwaveprop.Weather
|
||||
alias Microwaveprop.Weather.Sounding
|
||||
alias Microwaveprop.Weather.SurfaceObservation
|
||||
|
||||
defp station!(attrs \\ %{}) do
|
||||
base = %{
|
||||
station_code: "TST#{System.unique_integer([:positive])}",
|
||||
station_type: "asos",
|
||||
name: "Test Station",
|
||||
lat: 33.0,
|
||||
lon: -97.0
|
||||
}
|
||||
|
||||
{:ok, s} = Weather.find_or_create_station(Map.merge(base, attrs))
|
||||
s
|
||||
end
|
||||
|
||||
describe "has_surface_observations?/3" do
|
||||
test "true when any observation falls inside the window" do
|
||||
station = station!()
|
||||
|
||||
{:ok, _} =
|
||||
%SurfaceObservation{}
|
||||
|> SurfaceObservation.changeset(%{
|
||||
station_id: station.id,
|
||||
observed_at: ~U[2026-04-20 12:00:00Z],
|
||||
temp_f: 72.0
|
||||
})
|
||||
|> Repo.insert()
|
||||
|
||||
assert Weather.has_surface_observations?(
|
||||
station.id,
|
||||
~U[2026-04-20 00:00:00Z],
|
||||
~U[2026-04-20 23:59:59Z]
|
||||
)
|
||||
end
|
||||
|
||||
test "false when the window excludes every observation" do
|
||||
station = station!()
|
||||
|
||||
{:ok, _} =
|
||||
%SurfaceObservation{}
|
||||
|> SurfaceObservation.changeset(%{
|
||||
station_id: station.id,
|
||||
observed_at: ~U[2026-04-20 12:00:00Z],
|
||||
temp_f: 72.0
|
||||
})
|
||||
|> Repo.insert()
|
||||
|
||||
refute Weather.has_surface_observations?(
|
||||
station.id,
|
||||
~U[2026-04-21 00:00:00Z],
|
||||
~U[2026-04-21 23:59:59Z]
|
||||
)
|
||||
end
|
||||
|
||||
test "false for a station with no observations" do
|
||||
station = station!()
|
||||
refute Weather.has_surface_observations?(station.id, ~U[2026-04-20 00:00:00Z], ~U[2026-04-20 23:59:59Z])
|
||||
end
|
||||
end
|
||||
|
||||
describe "has_sounding?/2" do
|
||||
test "true when a sounding exists at the exact observed_at" do
|
||||
station = station!(%{station_type: "sounding"})
|
||||
|
||||
{:ok, _} =
|
||||
%Sounding{}
|
||||
|> Sounding.changeset(%{
|
||||
station_id: station.id,
|
||||
observed_at: ~U[2026-04-20 12:00:00Z],
|
||||
profile: [],
|
||||
level_count: 0
|
||||
})
|
||||
|> Repo.insert()
|
||||
|
||||
assert Weather.has_sounding?(station.id, ~U[2026-04-20 12:00:00Z])
|
||||
end
|
||||
|
||||
test "false when no sounding exists at the given time" do
|
||||
station = station!(%{station_type: "sounding"})
|
||||
refute Weather.has_sounding?(station.id, ~U[2026-04-20 12:00:00Z])
|
||||
end
|
||||
end
|
||||
|
||||
describe "station_ids_with_* set-membership helpers" do
|
||||
test "station_ids_with_surface_observations returns only the stations that have rows in the window" do
|
||||
a = station!()
|
||||
b = station!()
|
||||
c = station!()
|
||||
|
||||
{:ok, _} =
|
||||
%SurfaceObservation{}
|
||||
|> SurfaceObservation.changeset(%{
|
||||
station_id: a.id,
|
||||
observed_at: ~U[2026-04-20 12:00:00Z],
|
||||
temp_f: 60.0
|
||||
})
|
||||
|> Repo.insert()
|
||||
|
||||
{:ok, _} =
|
||||
%SurfaceObservation{}
|
||||
|> SurfaceObservation.changeset(%{
|
||||
station_id: b.id,
|
||||
observed_at: ~U[2026-04-20 13:00:00Z],
|
||||
temp_f: 70.0
|
||||
})
|
||||
|> Repo.insert()
|
||||
|
||||
present =
|
||||
Weather.station_ids_with_surface_observations(
|
||||
[a.id, b.id, c.id],
|
||||
~U[2026-04-20 00:00:00Z],
|
||||
~U[2026-04-20 23:59:59Z]
|
||||
)
|
||||
|
||||
assert MapSet.size(present) == 2
|
||||
assert MapSet.member?(present, a.id)
|
||||
assert MapSet.member?(present, b.id)
|
||||
refute MapSet.member?(present, c.id)
|
||||
end
|
||||
|
||||
test "station_ids_with_soundings returns {station_id, observed_at} tuples" do
|
||||
station = station!(%{station_type: "sounding"})
|
||||
|
||||
{:ok, _} =
|
||||
%Sounding{}
|
||||
|> Sounding.changeset(%{
|
||||
station_id: station.id,
|
||||
observed_at: ~U[2026-04-20 12:00:00Z],
|
||||
profile: [],
|
||||
level_count: 0
|
||||
})
|
||||
|> Repo.insert()
|
||||
|
||||
set =
|
||||
Weather.station_ids_with_soundings(
|
||||
[station.id],
|
||||
[~U[2026-04-20 12:00:00Z], ~U[2026-04-20 00:00:00Z]]
|
||||
)
|
||||
|
||||
assert MapSet.member?(set, {station.id, ~U[2026-04-20 12:00:00Z]})
|
||||
refute MapSet.member?(set, {station.id, ~U[2026-04-20 00:00:00Z]})
|
||||
end
|
||||
end
|
||||
|
||||
describe "sounding_times_around/1" do
|
||||
# Radiosondes fly twice a day (00Z, 12Z). The helper returns the
|
||||
# two nearest bracketing times for a given wall-clock UTC.
|
||||
test "early-morning times bracket prev-day 12Z and same-day 00Z" do
|
||||
times = Weather.sounding_times_around(~U[2026-04-20 06:00:00Z])
|
||||
|
||||
assert length(times) >= 2
|
||||
assert ~U[2026-04-19 12:00:00Z] in times
|
||||
assert ~U[2026-04-20 00:00:00Z] in times
|
||||
end
|
||||
|
||||
test "afternoon times bracket same-day 00Z/12Z" do
|
||||
times = Weather.sounding_times_around(~U[2026-04-20 18:00:00Z])
|
||||
assert ~U[2026-04-20 00:00:00Z] in times
|
||||
assert ~U[2026-04-20 12:00:00Z] in times
|
||||
end
|
||||
|
||||
test "noon UTC lands on the 12Z bracket" do
|
||||
times = Weather.sounding_times_around(~U[2026-04-20 12:00:00Z])
|
||||
assert ~U[2026-04-20 12:00:00Z] in times
|
||||
end
|
||||
end
|
||||
|
||||
describe "hrrr_data_fully_present?/1" do
|
||||
test "false when pos1 is missing" do
|
||||
refute Weather.hrrr_data_fully_present?(%{pos1: nil})
|
||||
end
|
||||
|
||||
test "false when qso_timestamp is missing" do
|
||||
refute Weather.hrrr_data_fully_present?(%{qso_timestamp: nil, pos1: %{"lat" => 32.9, "lon" => -97.0}})
|
||||
end
|
||||
end
|
||||
|
||||
describe "existing_solar_dates/0" do
|
||||
test "returns a MapSet of Dates for every row in solar_indices" do
|
||||
date = ~D[2026-04-20]
|
||||
|
||||
Weather.upsert_solar_indices_batch([
|
||||
%{
|
||||
date: date,
|
||||
sfi: 120.0,
|
||||
sfi_adjusted: 118.0,
|
||||
sunspot_number: 55,
|
||||
ap_index: 5,
|
||||
kp_values: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]
|
||||
}
|
||||
])
|
||||
|
||||
set = Weather.existing_solar_dates()
|
||||
assert MapSet.member?(set, date)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -133,6 +133,62 @@ defmodule Microwaveprop.Workers.GefsFetchWorkerTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "most_recent_available_run/1 (further cases)" do
|
||||
test "is always 5+ hours older than the input time, on a GEFS cycle" do
|
||||
for iso <- ~w(2026-04-18T00:00:00Z 2026-04-18T06:00:00Z 2026-04-18T11:59:59Z) do
|
||||
{:ok, now, _} = DateTime.from_iso8601(iso)
|
||||
run = GefsFetchWorker.most_recent_available_run(now)
|
||||
assert DateTime.diff(now, run, :second) >= 5 * 3600
|
||||
assert run.hour in [0, 6, 12, 18]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "perform/1 seed path (empty args)" do
|
||||
test "empty-args job enqueues one GefsFetchWorker per extended_horizon hour" do
|
||||
Oban.Testing.with_testing_mode(:manual, fn ->
|
||||
assert :ok = GefsFetchWorker.perform(%Oban.Job{args: %{}})
|
||||
|
||||
# 25 × f024..f168 at 6-hour steps.
|
||||
jobs =
|
||||
Microwaveprop.Repo.all(
|
||||
Ecto.Query.from(j in Oban.Job, where: j.worker == "Microwaveprop.Workers.GefsFetchWorker")
|
||||
)
|
||||
|
||||
# Seeder enqueues one job per forecast hour (plus the run-time row
|
||||
# per cycle). Count should equal extended_horizon_hours length.
|
||||
assert length(jobs) == length(GefsFetchWorker.extended_horizon_hours())
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
describe "perform/1 error handling via a stubbed GefsClient" do
|
||||
# Driving the real perform/1 through an HTTP stub ends up calling
|
||||
# out through the GefsClient module. A 503 from NOMADS is classified
|
||||
# as transient (→ {:error, reason}) while a 404 is permanent (→
|
||||
# {:cancel, reason}). These exercise handle_error/2 end-to-end.
|
||||
|
||||
test "a transient HTTP 503 returns {:error, _} so Oban retries the job" do
|
||||
Req.Test.stub(Microwaveprop.Weather.GefsClient, fn conn ->
|
||||
Plug.Conn.send_resp(conn, 503, "Service Unavailable")
|
||||
end)
|
||||
|
||||
args = %{"run_time" => "2026-04-18T12:00:00Z", "forecast_hour" => 24}
|
||||
|
||||
assert {:error, _reason} = GefsFetchWorker.perform(%Oban.Job{args: args})
|
||||
end
|
||||
|
||||
test "a permanent 404 returns {:cancel, _} so Oban drops the job" do
|
||||
Req.Test.stub(Microwaveprop.Weather.GefsClient, fn conn ->
|
||||
Plug.Conn.send_resp(conn, 404, "Not Found")
|
||||
end)
|
||||
|
||||
args = %{"run_time" => "2026-04-18T12:00:00Z", "forecast_hour" => 24}
|
||||
|
||||
assert {:cancel, _reason} = GefsFetchWorker.perform(%Oban.Job{args: args})
|
||||
end
|
||||
end
|
||||
|
||||
describe "storing profiles" do
|
||||
test "round-trips a full batch through the context" do
|
||||
run_time = ~U[2026-04-18 12:00:00Z]
|
||||
|
|
|
|||
|
|
@ -67,5 +67,54 @@ defmodule Microwaveprop.Workers.HrrrNativeGridWorkerTest do
|
|||
|
||||
assert {:ok, %Oban.Job{args: _}} = Oban.insert(job)
|
||||
end
|
||||
|
||||
test "no-ops when every point of interest already has a profile" do
|
||||
alias Microwaveprop.Weather.HrrrNativeProfile
|
||||
|
||||
create_contact(%{pos1: %{"lat" => 32.9, "lon" => -97.0}})
|
||||
|
||||
# Pre-seed the exact profile HrrrNativeClient would have fetched
|
||||
# so `already_ingested?` returns true.
|
||||
{:ok, _} =
|
||||
%HrrrNativeProfile{}
|
||||
|> HrrrNativeProfile.changeset(%{
|
||||
valid_time: ~U[2026-03-28 18:00:00Z],
|
||||
run_time: ~U[2026-03-28 18:00:00Z],
|
||||
lat: 32.9,
|
||||
lon: -97.0,
|
||||
level_count: 1,
|
||||
heights_m: [10.0],
|
||||
temp_k: [298.0],
|
||||
spfh: [0.012],
|
||||
pressure_pa: [101_000.0],
|
||||
u_wind_ms: [2.0],
|
||||
v_wind_ms: [1.0],
|
||||
tke_m2s2: [0.5]
|
||||
})
|
||||
|> Repo.insert()
|
||||
|
||||
args = %{
|
||||
"year" => 2026,
|
||||
"month" => 3,
|
||||
"day" => 28,
|
||||
"hour" => 18
|
||||
}
|
||||
|
||||
assert :ok = HrrrNativeGridWorker.perform(%Oban.Job{args: args})
|
||||
end
|
||||
|
||||
test "no-ops for hours without any contacts in the window" do
|
||||
# Contact exists but outside the target hour's ±30m window.
|
||||
create_contact(%{qso_timestamp: ~U[2026-03-28 15:00:00Z]})
|
||||
|
||||
args = %{
|
||||
"year" => 2026,
|
||||
"month" => 3,
|
||||
"day" => 28,
|
||||
"hour" => 23
|
||||
}
|
||||
|
||||
assert :ok = HrrrNativeGridWorker.perform(%Oban.Job{args: args})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -45,5 +45,34 @@ defmodule Microwaveprop.Workers.IonosphereFetchWorkerTest do
|
|||
assert Ionosphere.latest_observation("MHJ45") == nil
|
||||
assert Ionosphere.latest_observation("AL945")
|
||||
end
|
||||
|
||||
test "tolerates an HTTP transport failure with :ok + no rows inserted" do
|
||||
Req.Test.stub(GiroClient, fn conn ->
|
||||
Req.Test.transport_error(conn, :timeout)
|
||||
end)
|
||||
|
||||
assert :ok = perform_job(IonosphereFetchWorker, %{})
|
||||
assert Repo.aggregate(Observation, :count, :id) == 0
|
||||
end
|
||||
end
|
||||
|
||||
describe "stations/0" do
|
||||
test "exposes a non-empty list of CONUS ionosonde stations" do
|
||||
stations = IonosphereFetchWorker.stations()
|
||||
|
||||
assert is_list(stations)
|
||||
assert length(stations) >= 2
|
||||
|
||||
# Every station entry has the shape the fetcher expects, with a
|
||||
# plausible CONUS lat/lon envelope.
|
||||
for s <- stations do
|
||||
assert is_binary(s.code)
|
||||
assert is_binary(s.name)
|
||||
assert is_float(s.lat) or is_integer(s.lat)
|
||||
assert is_float(s.lon) or is_integer(s.lon)
|
||||
assert s.lat > 20.0 and s.lat < 75.0
|
||||
assert s.lon > -170.0 and s.lon < -60.0
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
defmodule MicrowavepropWeb.ContactMapControllerTest do
|
||||
use MicrowavepropWeb.ConnCase, async: false
|
||||
|
||||
alias Microwaveprop.Cache
|
||||
|
||||
setup do
|
||||
# The controller caches a gzipped payload under a module-level key —
|
||||
# clear it between tests so gzip vs identity requests don't reuse
|
||||
# each other's output.
|
||||
Cache.invalidate({MicrowavepropWeb.ContactMapController, :gzipped_payload})
|
||||
:ok
|
||||
end
|
||||
|
||||
describe "GET /api/contacts/map" do
|
||||
test "serves plain JSON when the client does not accept gzip", %{conn: conn} do
|
||||
conn = get(conn, ~p"/api/contacts/map")
|
||||
|
||||
assert conn.status == 200
|
||||
assert get_resp_header(conn, "content-type") == ["application/json; charset=utf-8"]
|
||||
assert get_resp_header(conn, "content-encoding") == []
|
||||
assert [cache_control] = get_resp_header(conn, "cache-control")
|
||||
assert cache_control =~ "max-age=60"
|
||||
|
||||
# Body must be parseable JSON; on an empty dev/test DB that's a
|
||||
# list (possibly empty) of contact tuples.
|
||||
assert {:ok, parsed} = Jason.decode(conn.resp_body)
|
||||
assert is_list(parsed)
|
||||
end
|
||||
|
||||
test "emits gzip-encoded body when the client advertises gzip support", %{conn: conn} do
|
||||
conn =
|
||||
conn
|
||||
|> put_req_header("accept-encoding", "gzip, deflate")
|
||||
|> get(~p"/api/contacts/map")
|
||||
|
||||
assert conn.status == 200
|
||||
assert get_resp_header(conn, "content-encoding") == ["gzip"]
|
||||
|
||||
# The gzipped body decompresses to valid JSON.
|
||||
decompressed = :zlib.gunzip(conn.resp_body)
|
||||
assert {:ok, _} = Jason.decode(decompressed)
|
||||
end
|
||||
|
||||
test "caches the gzipped payload across requests", %{conn: conn} do
|
||||
conn1 =
|
||||
conn
|
||||
|> put_req_header("accept-encoding", "gzip")
|
||||
|> get(~p"/api/contacts/map")
|
||||
|
||||
conn2 =
|
||||
build_conn()
|
||||
|> put_req_header("accept-encoding", "gzip")
|
||||
|> get(~p"/api/contacts/map")
|
||||
|
||||
# Byte-identical bodies prove the second hit came from the cache,
|
||||
# not a fresh Radio.contact_map_payload/0 + gzip round-trip.
|
||||
assert conn1.resp_body == conn2.resp_body
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -6,6 +6,7 @@ defmodule MicrowavepropWeb.Admin.ContactEditLiveTest do
|
|||
|
||||
alias Microwaveprop.Accounts
|
||||
alias Microwaveprop.Radio.Contact
|
||||
alias Microwaveprop.Radio.ContactEdit
|
||||
alias Microwaveprop.Repo
|
||||
|
||||
defp admin_fixture do
|
||||
|
|
@ -71,4 +72,139 @@ defmodule MicrowavepropWeb.Admin.ContactEditLiveTest do
|
|||
assert html =~ "0 pending"
|
||||
end
|
||||
end
|
||||
|
||||
describe "review flow" do
|
||||
# Walks a pending edit through review → approve (and review → reject)
|
||||
# via LiveView events. Each path hits one of the main approve/reject
|
||||
# branches in the LiveView plus the helper label/value renderers.
|
||||
|
||||
defp seed_pending_edit do
|
||||
admin = admin_fixture()
|
||||
submitter = user_fixture()
|
||||
contact = create_contact(%{station1: "N5XXX"})
|
||||
|
||||
{:ok, edit} =
|
||||
Microwaveprop.Radio.create_contact_edit(contact, submitter, %{
|
||||
"station1" => "N5YYY",
|
||||
"mode" => "SSB",
|
||||
"band" => "432",
|
||||
"grid1" => "EM13",
|
||||
"qso_timestamp" => "2026-04-01 12:00:00Z"
|
||||
})
|
||||
|
||||
{admin, contact, edit}
|
||||
end
|
||||
|
||||
test "clicking Review opens the edit inspector with current + proposed values", %{conn: conn} do
|
||||
{admin, contact, edit} = seed_pending_edit()
|
||||
|
||||
{:ok, lv, _html} =
|
||||
conn
|
||||
|> log_in_user(admin)
|
||||
|> live(~p"/admin/contact-edits")
|
||||
|
||||
html =
|
||||
lv
|
||||
|> element(~s|button[phx-click="review"][phx-value-id="#{edit.id}"]|)
|
||||
|> render_click()
|
||||
|
||||
# Inspector panel shows labels for every field that changed.
|
||||
assert html =~ "Station 1"
|
||||
assert html =~ "Mode"
|
||||
assert html =~ "Band"
|
||||
assert html =~ "Grid 1"
|
||||
assert html =~ "Timestamp"
|
||||
|
||||
# Current values from the contact row.
|
||||
assert html =~ contact.station1
|
||||
# Proposed values from the edit.
|
||||
assert html =~ "N5YYY"
|
||||
assert html =~ "SSB"
|
||||
end
|
||||
|
||||
test "approve applies the edit and clears the inspector", %{conn: conn} do
|
||||
{admin, _contact, edit} = seed_pending_edit()
|
||||
|
||||
{:ok, lv, _html} =
|
||||
conn
|
||||
|> log_in_user(admin)
|
||||
|> live(~p"/admin/contact-edits")
|
||||
|
||||
lv
|
||||
|> element(~s|button[phx-click="review"][phx-value-id="#{edit.id}"]|)
|
||||
|> render_click()
|
||||
|
||||
html =
|
||||
lv
|
||||
|> element(~s|button[phx-click="approve"]|)
|
||||
|> render_click()
|
||||
|
||||
# Flash + pending counter refresh.
|
||||
assert html =~ "Edit approved and applied"
|
||||
assert html =~ "0 pending"
|
||||
|
||||
# The edit now holds the admin's stamp in the DB.
|
||||
approved = Repo.get!(ContactEdit, edit.id)
|
||||
assert approved.status == :approved
|
||||
assert approved.reviewed_by_id == admin.id
|
||||
end
|
||||
|
||||
test "reject leaves the contact unchanged and records the rejection", %{conn: conn} do
|
||||
{admin, contact, edit} = seed_pending_edit()
|
||||
|
||||
{:ok, lv, _html} =
|
||||
conn
|
||||
|> log_in_user(admin)
|
||||
|> live(~p"/admin/contact-edits")
|
||||
|
||||
lv
|
||||
|> element(~s|button[phx-click="review"][phx-value-id="#{edit.id}"]|)
|
||||
|> render_click()
|
||||
|
||||
# Type an admin note into the review textarea first. It's not
|
||||
# wrapped in a <form>, so hit the element directly.
|
||||
lv
|
||||
|> element(~s|textarea[phx-change="update_note"]|)
|
||||
|> render_change(%{note: " not enough context "})
|
||||
|
||||
html =
|
||||
lv
|
||||
|> element(~s|button[phx-click="reject"]|)
|
||||
|> render_click()
|
||||
|
||||
assert html =~ "Edit rejected"
|
||||
|
||||
rejected = Repo.get!(ContactEdit, edit.id)
|
||||
assert rejected.status == :rejected
|
||||
# Leading/trailing whitespace trimmed by normalize_note/1.
|
||||
assert rejected.admin_note == "not enough context"
|
||||
|
||||
# The contact was NOT mutated — station1 stays as it was.
|
||||
assert Repo.get!(Contact, contact.id).station1 == contact.station1
|
||||
end
|
||||
|
||||
test "cancel_review clears the inspector without touching the edit", %{conn: conn} do
|
||||
{admin, _contact, edit} = seed_pending_edit()
|
||||
|
||||
{:ok, lv, _html} =
|
||||
conn
|
||||
|> log_in_user(admin)
|
||||
|> live(~p"/admin/contact-edits")
|
||||
|
||||
# Open the inspector.
|
||||
lv
|
||||
|> element(~s|button[phx-click="review"][phx-value-id="#{edit.id}"]|)
|
||||
|> render_click()
|
||||
|
||||
html =
|
||||
lv
|
||||
|> element(~s|button[phx-click="cancel_review"]|)
|
||||
|> render_click()
|
||||
|
||||
# Inspector is closed — specific proposed-value column no longer shows.
|
||||
refute html =~ "Proposed"
|
||||
# And the edit is still pending in the DB.
|
||||
assert Repo.get!(ContactEdit, edit.id).status == :pending
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
71
test/microwaveprop_web/live/contact_live/mechanism_test.exs
Normal file
71
test/microwaveprop_web/live/contact_live/mechanism_test.exs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
defmodule MicrowavepropWeb.ContactLive.MechanismTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias MicrowavepropWeb.ContactLive.Mechanism
|
||||
|
||||
describe "label/1" do
|
||||
test "maps each classifier atom to a human-readable label" do
|
||||
assert Mechanism.label(:likely_rainscatter) == "Likely Rain Scatter"
|
||||
assert Mechanism.label(:rainscatter_possible) == "Rain Scatter Possible"
|
||||
assert Mechanism.label(:tropo_duct) == "Tropospheric Duct"
|
||||
assert Mechanism.label(:troposcatter) == "Troposcatter"
|
||||
assert Mechanism.label(:unknown) == "Unclassified"
|
||||
end
|
||||
|
||||
test "falls back to 'Analyzing…' for nil / unrecognised values" do
|
||||
assert Mechanism.label(nil) == "Analyzing…"
|
||||
assert Mechanism.label(:some_new_atom_not_yet_mapped) == "Analyzing…"
|
||||
end
|
||||
end
|
||||
|
||||
describe "badge_class/1" do
|
||||
test "returns a daisyUI badge class for every known mechanism" do
|
||||
for m <- [
|
||||
:likely_rainscatter,
|
||||
:rainscatter_possible,
|
||||
:tropo_duct,
|
||||
:troposcatter,
|
||||
:unknown,
|
||||
nil,
|
||||
:future_mechanism
|
||||
] do
|
||||
cls = Mechanism.badge_class(m)
|
||||
assert is_binary(cls)
|
||||
assert String.starts_with?(cls, "badge-")
|
||||
end
|
||||
end
|
||||
|
||||
test "distinguishes likely vs possible rain scatter visually" do
|
||||
# 'likely' is the confident variant (filled), 'possible' is outlined.
|
||||
assert Mechanism.badge_class(:likely_rainscatter) == "badge-info"
|
||||
assert Mechanism.badge_class(:rainscatter_possible) == "badge-info badge-outline"
|
||||
end
|
||||
end
|
||||
|
||||
describe "explainer/1" do
|
||||
test "every classified mechanism gets a non-empty, single-sentence explainer" do
|
||||
for m <- [
|
||||
:likely_rainscatter,
|
||||
:rainscatter_possible,
|
||||
:tropo_duct,
|
||||
:troposcatter,
|
||||
:unknown
|
||||
] do
|
||||
text = Mechanism.explainer(m)
|
||||
assert is_binary(text)
|
||||
assert text != ""
|
||||
end
|
||||
end
|
||||
|
||||
test "unclassified / nil returns an empty string (no tooltip needed)" do
|
||||
assert Mechanism.explainer(nil) == ""
|
||||
assert Mechanism.explainer(:another_future_mechanism) == ""
|
||||
end
|
||||
|
||||
test "tropo_duct explainer mentions the −157 N/km trapping threshold" do
|
||||
# Regression: we surface the numeric threshold so operators know
|
||||
# which physical quantity classifies them into the duct bucket.
|
||||
assert Mechanism.explainer(:tropo_duct) =~ "−157"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -448,4 +448,336 @@ defmodule MicrowavepropWeb.ContactLiveTest do
|
|||
assert html =~ "300.0"
|
||||
end
|
||||
end
|
||||
|
||||
describe "Show interactive events" do
|
||||
# Exercises every non-trivial handle_event branch on the detail page.
|
||||
# These bump ContactLive.Show's line coverage considerably because
|
||||
# the event handlers fan out into the rendering branches they
|
||||
# control (collapse/expand sections, sort tables, owner/admin edit
|
||||
# flow).
|
||||
|
||||
import Microwaveprop.AccountsFixtures
|
||||
|
||||
defp admin_fixture do
|
||||
user = user_fixture()
|
||||
{:ok, user} = Microwaveprop.Accounts.admin_update_user(user, %{is_admin: true})
|
||||
user
|
||||
end
|
||||
|
||||
test "toggle_hrrr_profile flips the atmospheric-profile collapse state", %{conn: conn} do
|
||||
contact = create_contact()
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
|
||||
# Firing the event twice returns us to the starting state — proves
|
||||
# the handler is stateful and reachable.
|
||||
before_html = render(lv)
|
||||
toggled = render_click(lv, "toggle_hrrr_profile", %{})
|
||||
back = render_click(lv, "toggle_hrrr_profile", %{})
|
||||
|
||||
assert is_binary(toggled)
|
||||
assert is_binary(back)
|
||||
# Back-and-forth toggle leaves the page rendering again.
|
||||
assert back =~ contact.station1 and before_html =~ contact.station1
|
||||
end
|
||||
|
||||
test "toggle_terrain flips the terrain-section collapse state", %{conn: conn} do
|
||||
contact = create_contact()
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
|
||||
html = render_click(lv, "toggle_terrain", %{})
|
||||
assert is_binary(html)
|
||||
end
|
||||
|
||||
test "toggle_profile (sounding expansion) tracks an id in the MapSet", %{conn: conn} do
|
||||
contact = create_contact()
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
|
||||
# Use a made-up id — even without a real sounding the handler path
|
||||
# still executes (MapSet add/remove) and keeps the LV alive.
|
||||
html = render_click(lv, "toggle_profile", %{"id" => "11111111-1111-1111-1111-111111111111"})
|
||||
assert is_binary(html)
|
||||
|
||||
html2 = render_click(lv, "toggle_profile", %{"id" => "11111111-1111-1111-1111-111111111111"})
|
||||
assert is_binary(html2)
|
||||
end
|
||||
|
||||
test "sort obs table by date toggles sort direction", %{conn: conn} do
|
||||
contact = create_contact()
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
|
||||
render_click(lv, "sort", %{"field" => "observed_at", "table" => "obs"})
|
||||
html = render_click(lv, "sort", %{"field" => "observed_at", "table" => "obs"})
|
||||
assert html =~ "Surface Observations"
|
||||
end
|
||||
|
||||
test "sort obs table exercises every known field sort key", %{conn: conn} do
|
||||
contact = create_contact()
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
|
||||
for field <- ~w(station_name observed_at temp_f dewpoint_f relative_humidity sea_level_pressure_mb unknown_field) do
|
||||
html = render_click(lv, "sort", %{"field" => field, "table" => "obs"})
|
||||
assert html =~ "Surface Observations", "sort by #{field} lost the obs table"
|
||||
end
|
||||
end
|
||||
|
||||
test "sort soundings table exercises every known field sort key", %{conn: conn} do
|
||||
contact = create_contact()
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
|
||||
for field <- ~w(station_name observed_at unknown_field) do
|
||||
html = render_click(lv, "sort", %{"field" => field, "table" => "soundings"})
|
||||
assert html =~ "Soundings", "sort by #{field} lost the soundings table"
|
||||
end
|
||||
end
|
||||
|
||||
test "sort soundings table by date keeps the view rendering", %{conn: conn} do
|
||||
contact = create_contact()
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
|
||||
html = render_click(lv, "sort", %{"field" => "observed_at", "table" => "soundings"})
|
||||
assert html =~ "Soundings"
|
||||
end
|
||||
|
||||
test "toggle_flag is denied to anonymous viewers (flash + no change)", %{conn: conn} do
|
||||
contact = create_contact()
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
|
||||
html = render_click(lv, "toggle_flag", %{})
|
||||
assert html =~ "Admins only"
|
||||
|
||||
refute Repo.get!(Contact, contact.id).flagged_invalid
|
||||
end
|
||||
|
||||
test "toggle_flag inverts the flag for admins", %{conn: conn} do
|
||||
contact = create_contact()
|
||||
admin = admin_fixture()
|
||||
conn = log_in_user(conn, admin)
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
render_click(lv, "toggle_flag", %{})
|
||||
|
||||
refreshed = Repo.get!(Contact, contact.id)
|
||||
assert refreshed.flagged_invalid == true
|
||||
|
||||
# Toggling again flips back.
|
||||
render_click(lv, "toggle_flag", %{})
|
||||
assert Repo.get!(Contact, contact.id).flagged_invalid == false
|
||||
end
|
||||
|
||||
test "toggle_edit opens and closes the inline edit form", %{conn: conn} do
|
||||
contact = create_contact()
|
||||
admin = admin_fixture()
|
||||
conn = log_in_user(conn, admin)
|
||||
|
||||
{:ok, lv, html_before} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
refute html_before =~ ~s(phx-submit="submit_edit")
|
||||
|
||||
html_open = render_click(lv, "toggle_edit", %{})
|
||||
assert html_open =~ ~s(phx-submit="submit_edit")
|
||||
|
||||
html_closed = render_click(lv, "toggle_edit", %{})
|
||||
refute html_closed =~ ~s(phx-submit="submit_edit")
|
||||
end
|
||||
|
||||
test "submit_edit by a non-logged-in user flashes a login-required error", %{conn: conn} do
|
||||
contact = create_contact()
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
|
||||
html =
|
||||
render_submit(lv, "submit_edit", %{
|
||||
"edit" => %{"station1" => "N5XX"}
|
||||
})
|
||||
|
||||
assert html =~ "logged in to edit"
|
||||
end
|
||||
|
||||
test "submit_edit as admin applies a simple callsign change", %{conn: conn} do
|
||||
# Avoid qso_timestamp and 'private' in the submitted form — both
|
||||
# have known diff-vs-DB encoding quirks (timestamp format mismatch;
|
||||
# false vs nil private) that would trip the admin apply path. A
|
||||
# clean station1 change exercises apply_admin_edit/3 end-to-end.
|
||||
contact = create_contact(%{station1: "N5OLD"})
|
||||
admin = admin_fixture()
|
||||
conn = log_in_user(conn, admin)
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
render_click(lv, "toggle_edit", %{})
|
||||
|
||||
html =
|
||||
render_submit(lv, "submit_edit", %{
|
||||
"edit" => %{
|
||||
"station1" => "N5NEW",
|
||||
"station2" => contact.station2,
|
||||
"grid1" => contact.grid1,
|
||||
"grid2" => contact.grid2,
|
||||
"mode" => contact.mode
|
||||
}
|
||||
})
|
||||
|
||||
assert html =~ "Contact updated"
|
||||
assert Repo.get!(Contact, contact.id).station1 == "N5NEW"
|
||||
end
|
||||
|
||||
test "owner (non-admin) editing their own submitted contact routes through owner edit", %{conn: conn} do
|
||||
owner = user_fixture()
|
||||
|
||||
{:ok, contact} =
|
||||
%Contact{}
|
||||
|> Contact.changeset(%{
|
||||
station1: owner.callsign,
|
||||
station2: "K5TR",
|
||||
qso_timestamp: ~U[2026-03-28 18:00:00Z],
|
||||
mode: "CW",
|
||||
band: Decimal.new("1296"),
|
||||
grid1: "EM12",
|
||||
grid2: "EM00",
|
||||
pos1: %{"lat" => 32.9, "lon" => -97.0},
|
||||
pos2: %{"lat" => 30.3, "lon" => -97.7},
|
||||
distance_km: Decimal.new("295"),
|
||||
user_submitted: true,
|
||||
submitter_email: owner.email
|
||||
})
|
||||
|> Ecto.Changeset.change(user_id: owner.id)
|
||||
|> Repo.insert()
|
||||
|
||||
conn = log_in_user(conn, owner)
|
||||
|
||||
{:ok, lv, _} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
render_click(lv, "toggle_edit", %{})
|
||||
|
||||
# Owners go through apply_owner_edit/4, which directly applies the
|
||||
# change to their own submission (no admin review needed).
|
||||
html =
|
||||
render_submit(lv, "submit_edit", %{
|
||||
"edit" => %{
|
||||
"station1" => owner.callsign,
|
||||
"station2" => "K9NEW",
|
||||
"grid1" => contact.grid1,
|
||||
"grid2" => contact.grid2,
|
||||
"mode" => contact.mode
|
||||
}
|
||||
})
|
||||
|
||||
assert html =~ "Contact updated"
|
||||
assert Repo.get!(Contact, contact.id).station2 == "K9NEW"
|
||||
end
|
||||
|
||||
test "non-owner non-admin submitting an edit creates a pending ContactEdit", %{conn: conn} do
|
||||
contact = create_contact()
|
||||
stranger = user_fixture()
|
||||
conn = log_in_user(conn, stranger)
|
||||
|
||||
{:ok, lv, _} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
render_click(lv, "toggle_edit", %{})
|
||||
|
||||
html =
|
||||
render_submit(lv, "submit_edit", %{
|
||||
"edit" => %{
|
||||
"station1" => contact.station1,
|
||||
"station2" => "K9SUGGEST",
|
||||
"grid1" => contact.grid1,
|
||||
"grid2" => contact.grid2,
|
||||
"mode" => contact.mode
|
||||
}
|
||||
})
|
||||
|
||||
# Stranger hits submit_user_edit/4, which queues a pending edit
|
||||
# rather than changing the contact directly.
|
||||
assert Repo.get!(Contact, contact.id).station2 == contact.station2
|
||||
|
||||
assert Repo.aggregate(Microwaveprop.Radio.ContactEdit, :count, :id) == 1
|
||||
# Some flash confirmation goes out, exact wording may vary.
|
||||
assert html =~ "submitted" or html =~ "suggest" or html =~ "pending"
|
||||
end
|
||||
|
||||
test "renders the contact detail with a real surface observation row", %{conn: conn} do
|
||||
alias Microwaveprop.Weather
|
||||
alias Microwaveprop.Weather.SurfaceObservation
|
||||
|
||||
contact = create_contact()
|
||||
|
||||
{:ok, station} =
|
||||
Weather.find_or_create_station(%{
|
||||
station_code: "KDFW",
|
||||
station_type: "asos",
|
||||
name: "DFW Airport",
|
||||
lat: 32.90,
|
||||
lon: -97.02
|
||||
})
|
||||
|
||||
{:ok, _} =
|
||||
%SurfaceObservation{}
|
||||
|> SurfaceObservation.changeset(%{
|
||||
station_id: station.id,
|
||||
observed_at: ~U[2026-03-28 18:00:00Z],
|
||||
temp_f: 72.0,
|
||||
dewpoint_f: 55.0,
|
||||
relative_humidity: 55.0,
|
||||
sea_level_pressure_mb: 1015.0
|
||||
})
|
||||
|> Repo.insert()
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
|
||||
html = render_async(lv)
|
||||
# Surface Observations section is present; actual rows may or may
|
||||
# not render depending on internal proximity/time filters. The
|
||||
# render pass itself exercises the with-data code path either way.
|
||||
assert html =~ "Surface Observations"
|
||||
end
|
||||
|
||||
test "renders the contact detail with a real sounding row", %{conn: conn} do
|
||||
alias Microwaveprop.Weather
|
||||
alias Microwaveprop.Weather.Sounding
|
||||
|
||||
contact = create_contact()
|
||||
|
||||
{:ok, station} =
|
||||
Weather.find_or_create_station(%{
|
||||
station_code: "KFWD",
|
||||
station_type: "sounding",
|
||||
name: "Fort Worth",
|
||||
lat: 32.83,
|
||||
lon: -97.30
|
||||
})
|
||||
|
||||
{:ok, _} =
|
||||
%Sounding{}
|
||||
|> Sounding.changeset(%{
|
||||
station_id: station.id,
|
||||
observed_at: ~U[2026-03-28 12:00:00Z],
|
||||
profile: [
|
||||
%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 15.0, "hght" => 100.0},
|
||||
%{"pres" => 850.0, "tmpc" => 12.0, "dwpc" => 8.0, "hght" => 1500.0}
|
||||
],
|
||||
level_count: 2,
|
||||
surface_temp_c: 20.0,
|
||||
surface_dewpoint_c: 15.0
|
||||
})
|
||||
|> Repo.insert()
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
|
||||
html = render_async(lv)
|
||||
assert html =~ "KFWD"
|
||||
end
|
||||
|
||||
test "validate_edit always returns :noreply without touching the DB", %{conn: conn} do
|
||||
contact = create_contact()
|
||||
admin = admin_fixture()
|
||||
conn = log_in_user(conn, admin)
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
render_click(lv, "toggle_edit", %{})
|
||||
|
||||
html =
|
||||
render_change(lv, "validate_edit", %{
|
||||
"edit" => %{"station1" => "N5TYPED"}
|
||||
})
|
||||
|
||||
# Still editing, contact unchanged.
|
||||
assert html =~ ~s(phx-submit="submit_edit")
|
||||
assert Repo.get!(Contact, contact.id).station1 == "W5XD"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
91
test/microwaveprop_web/live/contact_map_live_test.exs
Normal file
91
test/microwaveprop_web/live/contact_map_live_test.exs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
defmodule MicrowavepropWeb.ContactMapLiveTest do
|
||||
use MicrowavepropWeb.ConnCase, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Microwaveprop.Radio.Contact
|
||||
alias Microwaveprop.Repo
|
||||
|
||||
defp create_contact(attrs \\ %{}) do
|
||||
default = %{
|
||||
station1: "W5XD",
|
||||
station2: "K5TR",
|
||||
qso_timestamp: ~U[2026-03-28 18:00:00Z],
|
||||
mode: "CW",
|
||||
band: Decimal.new("1296"),
|
||||
grid1: "EM12",
|
||||
grid2: "EM00",
|
||||
pos1: %{"lat" => 32.9, "lon" => -97.0},
|
||||
pos2: %{"lat" => 30.3, "lon" => -97.7},
|
||||
distance_km: Decimal.new("295")
|
||||
}
|
||||
|
||||
{:ok, contact} =
|
||||
%Contact{}
|
||||
|> Contact.changeset(Map.merge(default, attrs))
|
||||
|> Repo.insert()
|
||||
|
||||
contact
|
||||
end
|
||||
|
||||
describe "GET /contacts/map" do
|
||||
test "renders the shell with the Contact Map heading + JS hook container", %{conn: conn} do
|
||||
{:ok, _lv, html} = live(conn, ~p"/contacts/map")
|
||||
|
||||
assert html =~ "Contact Map"
|
||||
assert html =~ ~s(id="contacts-map")
|
||||
assert html =~ ~s(phx-hook="ContactsMap")
|
||||
# Client fetches the JSON blob from the controller endpoint.
|
||||
assert html =~ ~s(data-fetch-url="/api/contacts/map")
|
||||
end
|
||||
|
||||
test "shows the contact count and the populated band list", %{conn: conn} do
|
||||
_c1 = create_contact(%{band: Decimal.new("1296")})
|
||||
_c2 = create_contact(%{band: Decimal.new("10000"), station1: "W5OTHR"})
|
||||
|
||||
{:ok, _lv, html} = live(conn, ~p"/contacts/map")
|
||||
|
||||
assert html =~ "2 contacts"
|
||||
# band_label/1 converts bands ≥ 1000 MHz into GHz — integer values
|
||||
# stay whole ("10 GHz"), non-integer ratios round to 1 decimal ("1.3 GHz").
|
||||
assert html =~ "1.3 GHz"
|
||||
assert html =~ "10 GHz"
|
||||
end
|
||||
|
||||
test "empty DB renders '0 contacts' and no band checkboxes", %{conn: conn} do
|
||||
{:ok, _lv, html} = live(conn, ~p"/contacts/map")
|
||||
assert html =~ "0 contacts"
|
||||
end
|
||||
end
|
||||
|
||||
describe "filter_callsign event" do
|
||||
test "stores the trimmed filter and pushes apply_filter to the hook", %{conn: conn} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/map")
|
||||
|
||||
html =
|
||||
render_change(
|
||||
# There's a mobile and a desktop filter — just grab the desktop one.
|
||||
element(lv, ~s(input.input-sm[phx-change="filter_callsign"])),
|
||||
%{"callsign" => " W5ISP "}
|
||||
)
|
||||
|
||||
# Server stored the trimmed value in assigns, which round-trips
|
||||
# back through the `value=` attribute.
|
||||
assert html =~ ~s(value="W5ISP")
|
||||
end
|
||||
|
||||
test "blank input round-trips as an empty value", %{conn: conn} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/map")
|
||||
|
||||
html =
|
||||
render_change(
|
||||
# There's a mobile and a desktop filter — just grab the desktop one.
|
||||
element(lv, ~s(input.input-sm[phx-change="filter_callsign"])),
|
||||
%{"callsign" => " "}
|
||||
)
|
||||
|
||||
refute html =~ ~s(value=" ")
|
||||
assert html =~ ~s(value="")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -8,22 +8,147 @@ defmodule MicrowavepropWeb.PathLiveTest do
|
|||
alias Microwaveprop.Propagation.ScoreCache
|
||||
alias Microwaveprop.Terrain.ElevationClient
|
||||
|
||||
describe "propagation_updated handler" do
|
||||
setup do
|
||||
ScoreCache.clear()
|
||||
setup do
|
||||
ScoreCache.clear()
|
||||
|
||||
# Flat-terrain stub so compute_path can build a result without hitting
|
||||
# the real elevation API.
|
||||
Req.Test.stub(ElevationClient, fn conn ->
|
||||
params = Plug.Conn.fetch_query_params(conn).query_params
|
||||
lat_count = params["latitude"] |> String.split(",") |> length()
|
||||
Req.Test.json(conn, %{"elevation" => List.duplicate(200.0, lat_count)})
|
||||
end)
|
||||
# Flat-terrain stub so compute_path can build a result without hitting
|
||||
# the real elevation API.
|
||||
Req.Test.stub(ElevationClient, fn conn ->
|
||||
params = Plug.Conn.fetch_query_params(conn).query_params
|
||||
lat_count = params["latitude"] |> String.split(",") |> length()
|
||||
Req.Test.json(conn, %{"elevation" => List.duplicate(200.0, lat_count)})
|
||||
end)
|
||||
|
||||
on_exit(fn -> ScoreCache.clear() end)
|
||||
:ok
|
||||
on_exit(fn -> ScoreCache.clear() end)
|
||||
:ok
|
||||
end
|
||||
|
||||
describe "GET /path (no params)" do
|
||||
test "renders the empty form with default band + height values", %{conn: conn} do
|
||||
{:ok, _lv, html} = live(conn, ~p"/path")
|
||||
|
||||
assert html =~ "Path Calculator"
|
||||
assert html =~ ~s(name="source")
|
||||
assert html =~ ~s(name="destination")
|
||||
# Default band = 10000 MHz → the "10 GHz" option is selected.
|
||||
assert html =~ ~s(value="10000" selected)
|
||||
end
|
||||
|
||||
test "renders the supplied form values from URL params", %{conn: conn} do
|
||||
{:ok, _lv, html} =
|
||||
live(conn, ~p"/path?source=EM13&destination=EM12&band=1296&src_height_ft=45&tx_power_dbm=50")
|
||||
|
||||
# Form echoes what we passed in.
|
||||
assert html =~ ~s(value="EM13")
|
||||
assert html =~ ~s(value="EM12")
|
||||
assert html =~ ~s(value="1296" selected)
|
||||
assert html =~ ~s(value="45")
|
||||
assert html =~ ~s(value="50")
|
||||
end
|
||||
end
|
||||
|
||||
describe "handle_event calculate + update_form" do
|
||||
test "calculate submits the form and patches the URL with the fields", %{conn: conn} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/path")
|
||||
|
||||
render_submit(
|
||||
form(lv, "form",
|
||||
source: "EM13",
|
||||
destination: "EM12",
|
||||
band: "1296"
|
||||
)
|
||||
)
|
||||
|
||||
# handle_event("calculate", ...) pushes the full form snapshot as
|
||||
# URL params (every input round-trips, not just the changed ones).
|
||||
assert_patch(lv)
|
||||
html = render(lv)
|
||||
assert html =~ ~s(value="EM13")
|
||||
assert html =~ ~s(value="EM12")
|
||||
assert html =~ ~s(value="1296" selected)
|
||||
end
|
||||
|
||||
test "update_form event keeps a form change locally without navigating", %{conn: conn} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/path?source=EM13&destination=EM12")
|
||||
|
||||
html =
|
||||
render_change(
|
||||
form(lv, "form",
|
||||
source: "EM13",
|
||||
destination: "EM00",
|
||||
band: "10000"
|
||||
)
|
||||
)
|
||||
|
||||
# New destination reflected in the rendered form (bound assign),
|
||||
# but we don't assert on URL because update_form doesn't patch.
|
||||
assert html =~ ~s(value="EM00")
|
||||
end
|
||||
|
||||
test "gps_location event fills source with the received lat/lon", %{conn: conn} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/path")
|
||||
|
||||
# Simulate the JS hook pushing a fix back after request_gps.
|
||||
render_hook(lv, "gps_location", %{"lat" => 32.9, "lon" => -97.0})
|
||||
|
||||
html = render(lv)
|
||||
assert html =~ "32.9"
|
||||
assert html =~ "-97.0"
|
||||
end
|
||||
|
||||
test "?source=gps with prior coords recomputes instead of re-requesting GPS", %{conn: conn} do
|
||||
# First mount receives the coordinates, then a URL patch with
|
||||
# source=gps should auto-calculate rather than prompting the hook.
|
||||
{:ok, lv, _html} = live(conn, ~p"/path?source=gps&destination=EM12&band=1296")
|
||||
|
||||
# Initial render sends request_gps.
|
||||
assert_push_event(lv, "request_gps", %{})
|
||||
|
||||
# Push a GPS fix into the LV.
|
||||
render_hook(lv, "gps_location", %{"lat" => 33.0, "lon" => -97.0})
|
||||
|
||||
# URL patch keeps source=gps.
|
||||
assert render(lv) =~ "33.0"
|
||||
end
|
||||
end
|
||||
|
||||
describe "update_form event" do
|
||||
test "rebinds form assigns from arbitrary params and fills defaults for missing keys", %{conn: conn} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/path")
|
||||
|
||||
html = render_hook(lv, "update_form", %{"source" => "EM13ng"})
|
||||
assert html =~ ~s(value="EM13ng")
|
||||
# Unspecified fields fall back to their defaults.
|
||||
assert html =~ ~s(value="10000" selected)
|
||||
assert html =~ ~s(value="30")
|
||||
end
|
||||
|
||||
test "accepts the full form payload with every height/gain field", %{conn: conn} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/path")
|
||||
|
||||
html =
|
||||
render_hook(lv, "update_form", %{
|
||||
"source" => "32.5,-97.0",
|
||||
"destination" => "33.5,-97.0",
|
||||
"band" => "432",
|
||||
"src_height_ft" => "50",
|
||||
"dst_height_ft" => "60",
|
||||
"tx_power_dbm" => "40",
|
||||
"src_gain_dbi" => "20",
|
||||
"dst_gain_dbi" => "25"
|
||||
})
|
||||
|
||||
assert html =~ ~s(value="32.5,-97.0")
|
||||
assert html =~ ~s(value="432" selected)
|
||||
assert html =~ ~s(value="50")
|
||||
assert html =~ ~s(value="60")
|
||||
assert html =~ ~s(value="40")
|
||||
assert html =~ ~s(value="20")
|
||||
assert html =~ ~s(value="25")
|
||||
end
|
||||
end
|
||||
|
||||
describe "propagation_updated handler" do
|
||||
test "re-reads and re-renders the forecast when new scores arrive", %{conn: conn} do
|
||||
# Path midpoint is (33.0, -97.0) — the point point_forecast queries.
|
||||
{mid_lat, mid_lon} = {33.0, -97.0}
|
||||
|
|
|
|||
111
test/microwaveprop_web/live/rover_live_test.exs
Normal file
111
test/microwaveprop_web/live/rover_live_test.exs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
defmodule MicrowavepropWeb.RoverLiveTest do
|
||||
use MicrowavepropWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
describe "GET /rover" do
|
||||
test "renders the Rover Planner sidebar + map hook", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/rover")
|
||||
|
||||
assert html =~ "Rover Planner"
|
||||
# Band selector + station form.
|
||||
assert html =~ ~s(id="rover-map")
|
||||
assert html =~ ~s(phx-hook="RoverMap")
|
||||
assert html =~ "Add station"
|
||||
# Default band is 10_000 MHz — check the corresponding option label.
|
||||
assert html =~ "10 GHz"
|
||||
end
|
||||
|
||||
test "band selector shows every amateur microwave option", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/rover")
|
||||
|
||||
# BandConfig.band_options/0 currently includes these microwave bands.
|
||||
for label <- ~w(50MHz 144MHz 432MHz 1296MHz 10GHz 24GHz) do
|
||||
nospace = label |> String.replace("MHz", " MHz") |> String.replace("GHz", " GHz")
|
||||
assert html =~ nospace, "expected band option #{nospace} in rover HTML"
|
||||
end
|
||||
end
|
||||
|
||||
test "a maidenhead grid in the URL resolves a station synchronously", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/rover?stations=EM13")
|
||||
|
||||
# Async resolution runs in a message; flush by calling render.
|
||||
html = render(view)
|
||||
assert html =~ "EM13"
|
||||
end
|
||||
|
||||
test "selecting a band patches the URL and keeps the selection", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/rover")
|
||||
|
||||
view
|
||||
|> element(~s(select[name="band"]))
|
||||
|> render_change(%{band: "1296"})
|
||||
|
||||
# URL patch replaces the current URL with ?band=1296.
|
||||
assert_patched(view, ~p"/rover?band=1296")
|
||||
assert render(view) =~ ~s(<option value="1296" selected)
|
||||
end
|
||||
|
||||
test "removing a station clears it from the badge list", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/rover?stations=EM13")
|
||||
render(view)
|
||||
assert render(view) =~ "EM13"
|
||||
|
||||
view
|
||||
|> element(~s(button[phx-click="remove_station"]))
|
||||
|> render_click(%{index: "0"})
|
||||
|
||||
refute render(view) =~ "EM13"
|
||||
end
|
||||
|
||||
test "add_station event with a grid resolves via the direct maidenhead path", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/rover")
|
||||
|
||||
# Submit the add-station form. add_station sends a message to
|
||||
# self() and toggles resolving: true; after the message is handled
|
||||
# the station appears.
|
||||
view
|
||||
|> form(~s(form[phx-submit="add_station"]), %{callsign: "EM00"})
|
||||
|> render_submit()
|
||||
|
||||
# Drive the resolver message asynchronously.
|
||||
html = render(view)
|
||||
assert html =~ "EM00"
|
||||
end
|
||||
|
||||
test "add_station with blank input is a no-op (no station added, no crash)", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/rover")
|
||||
|
||||
html =
|
||||
view
|
||||
|> form(~s(form[phx-submit="add_station"]), %{callsign: " "})
|
||||
|> render_submit()
|
||||
|
||||
# The stations list stays empty.
|
||||
refute html =~ "Stationary Stations"
|
||||
end
|
||||
|
||||
test "map_bounds event re-fetches propagation scores for the new viewport", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/rover")
|
||||
|
||||
new_bounds = %{"south" => 30.0, "north" => 34.0, "west" => -100.0, "east" => -95.0}
|
||||
|
||||
render_hook(view, "map_bounds", new_bounds)
|
||||
|
||||
# The handler pushes an update_scores event to the JS hook.
|
||||
assert_push_event(view, "update_scores", %{scores: scores})
|
||||
assert is_list(scores)
|
||||
end
|
||||
|
||||
test "propagation_updated pubsub message pushes an update_scores event", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/rover")
|
||||
|
||||
# Simulate the propagation pipeline broadcast by sending the
|
||||
# message directly to the LiveView process.
|
||||
send(view.pid, {:propagation_updated, [DateTime.utc_now()]})
|
||||
|
||||
assert_push_event(view, "update_scores", %{scores: scores})
|
||||
assert is_list(scores)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -138,4 +138,74 @@ defmodule MicrowavepropWeb.WeatherMapLiveTest do
|
|||
assert Enum.any?(data, fn r -> r.temperature == 10.0 end)
|
||||
end
|
||||
end
|
||||
|
||||
describe "select_layer event" do
|
||||
test "swaps the active layer and pushes the new selection to the client", %{conn: conn} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/weather")
|
||||
|
||||
render_hook(lv, "select_layer", %{"layer" => "pwat"})
|
||||
|
||||
html = render(lv)
|
||||
# PWAT's sidebar label + description text.
|
||||
assert html =~ "PWAT"
|
||||
assert html =~ "Precipitable water"
|
||||
end
|
||||
|
||||
test "ignores an unknown layer id (no crash, layer assign unchanged)", %{conn: conn} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/weather")
|
||||
|
||||
before = render(lv)
|
||||
render_hook(lv, "select_layer", %{"layer" => "bogus_layer_123"})
|
||||
html = render(lv)
|
||||
|
||||
# The "Minimum refractivity gradient" text for the default layer is
|
||||
# still rendered — the no-op path left the current layer in place.
|
||||
assert html =~ "Minimum refractivity gradient"
|
||||
assert before =~ "Minimum refractivity gradient"
|
||||
end
|
||||
end
|
||||
|
||||
describe "toggle_grid + toggle_radar events" do
|
||||
test "toggle_grid flips the Maidenhead overlay flag", %{conn: conn} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/weather")
|
||||
|
||||
before = render(lv)
|
||||
toggled = render_hook(lv, "toggle_grid", %{})
|
||||
assert byte_size(toggled) > 0
|
||||
assert before != toggled or toggled =~ "data-grid"
|
||||
|
||||
# Flipping twice returns to the original state.
|
||||
back = render_hook(lv, "toggle_grid", %{})
|
||||
assert is_binary(back)
|
||||
end
|
||||
|
||||
test "toggle_radar flips the NEXRAD overlay flag without crashing", %{conn: conn} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/weather")
|
||||
|
||||
html = render_hook(lv, "toggle_radar", %{})
|
||||
assert is_binary(html)
|
||||
|
||||
back = render_hook(lv, "toggle_radar", %{})
|
||||
assert is_binary(back)
|
||||
end
|
||||
end
|
||||
|
||||
describe "map_bounds event" do
|
||||
test "updating the bounds re-scopes the grid query without errors", %{conn: conn} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/weather")
|
||||
|
||||
# Smaller bounds within the initial DFW window.
|
||||
new_bounds = %{"north" => 33.5, "south" => 32.5, "east" => -96.0, "west" => -98.0}
|
||||
|
||||
render_hook(lv, "map_bounds", new_bounds)
|
||||
# After debouncing the server flushes with a push_event for the grid.
|
||||
send(lv.pid, :flush_weather_bounds)
|
||||
render(lv)
|
||||
|
||||
# Socket assigns update — we don't pin the grid contents because the
|
||||
# empty-profiles case returns an empty data list. The smoke test is
|
||||
# just that the handler doesn't raise.
|
||||
assert Process.alive?(lv.pid)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
81
test/microwaveprop_web/metrics_plug_test.exs
Normal file
81
test/microwaveprop_web/metrics_plug_test.exs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
defmodule MicrowavepropWeb.MetricsPlugTest do
|
||||
@moduledoc """
|
||||
MetricsPlug mounts as a `forward "/metrics"` in the router, with an
|
||||
optional bearer-token gate read from `Application.get_env/2`. These
|
||||
tests exercise both the open and gated paths end-to-end.
|
||||
"""
|
||||
use MicrowavepropWeb.ConnCase, async: false
|
||||
|
||||
setup do
|
||||
previous = Application.get_env(:microwaveprop, :prometheus_auth_token)
|
||||
on_exit(fn -> Application.put_env(:microwaveprop, :prometheus_auth_token, previous) end)
|
||||
:ok
|
||||
end
|
||||
|
||||
describe "GET /metrics (no token configured)" do
|
||||
test "returns 200 with Prometheus text-exposition body", %{conn: conn} do
|
||||
Application.put_env(:microwaveprop, :prometheus_auth_token, nil)
|
||||
|
||||
conn = get(conn, ~p"/metrics")
|
||||
|
||||
assert conn.status == 200
|
||||
assert [ctype] = get_resp_header(conn, "content-type")
|
||||
assert ctype =~ "text/plain"
|
||||
# PromEx's text body starts with '# HELP ...' or '# TYPE ...' metrics
|
||||
# metadata lines. Exact metrics depend on what's registered, but
|
||||
# any non-empty Prometheus body contains at least one '#' comment.
|
||||
assert conn.resp_body =~ "#"
|
||||
end
|
||||
|
||||
test "blank-string token behaves the same as an unset token", %{conn: conn} do
|
||||
Application.put_env(:microwaveprop, :prometheus_auth_token, "")
|
||||
|
||||
conn = get(conn, ~p"/metrics")
|
||||
assert conn.status == 200
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /metrics (bearer-token gated)" do
|
||||
setup do
|
||||
Application.put_env(:microwaveprop, :prometheus_auth_token, "s3cret-token")
|
||||
:ok
|
||||
end
|
||||
|
||||
test "401s without an Authorization header", %{conn: conn} do
|
||||
conn = get(conn, ~p"/metrics")
|
||||
assert conn.status == 401
|
||||
assert conn.resp_body == "unauthorized"
|
||||
# Standards-compliant www-authenticate challenge.
|
||||
assert [challenge] = get_resp_header(conn, "www-authenticate")
|
||||
assert challenge =~ ~s(Bearer realm="metrics")
|
||||
end
|
||||
|
||||
test "401s when the Authorization header isn't a Bearer scheme", %{conn: conn} do
|
||||
conn =
|
||||
conn
|
||||
|> put_req_header("authorization", "Basic dXNlcjpwYXNz")
|
||||
|> get(~p"/metrics")
|
||||
|
||||
assert conn.status == 401
|
||||
end
|
||||
|
||||
test "401s on a wrong bearer token", %{conn: conn} do
|
||||
conn =
|
||||
conn
|
||||
|> put_req_header("authorization", "Bearer wrong-token")
|
||||
|> get(~p"/metrics")
|
||||
|
||||
assert conn.status == 401
|
||||
end
|
||||
|
||||
test "200s and serves the body when the bearer token matches", %{conn: conn} do
|
||||
conn =
|
||||
conn
|
||||
|> put_req_header("authorization", "Bearer s3cret-token")
|
||||
|> get(~p"/metrics")
|
||||
|
||||
assert conn.status == 200
|
||||
assert conn.resp_body =~ "#"
|
||||
end
|
||||
end
|
||||
end
|
||||
100
test/microwaveprop_web/telemetry_test.exs
Normal file
100
test/microwaveprop_web/telemetry_test.exs
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
defmodule MicrowavepropWeb.TelemetryTest do
|
||||
use Microwaveprop.DataCase, async: false
|
||||
|
||||
alias Ecto.Adapters.SQL.Sandbox
|
||||
alias MicrowavepropWeb.Telemetry
|
||||
|
||||
describe "metrics/0" do
|
||||
test "returns a flat list of Telemetry.Metrics structs" do
|
||||
metrics = Telemetry.metrics()
|
||||
|
||||
assert is_list(metrics)
|
||||
assert metrics != []
|
||||
|
||||
for m <- metrics do
|
||||
assert is_struct(m)
|
||||
# Every metric carries its event path as a list of atoms.
|
||||
assert is_list(m.event_name)
|
||||
assert Enum.all?(m.event_name, &is_atom/1)
|
||||
end
|
||||
end
|
||||
|
||||
test "covers each of the metric categories defined in the module" do
|
||||
# The categories are private functions that build distinct event
|
||||
# name prefixes; check one canonical metric per category exists.
|
||||
names = Enum.map(Telemetry.metrics(), & &1.name)
|
||||
name_strings = Enum.map(names, &Enum.join(&1, "."))
|
||||
|
||||
assert "phoenix.endpoint.stop.duration" in name_strings
|
||||
assert "microwaveprop.repo.query.total_time" in name_strings
|
||||
assert Enum.any?(name_strings, &String.starts_with?(&1, "vm."))
|
||||
assert Enum.any?(name_strings, &String.starts_with?(&1, "microwaveprop.oban."))
|
||||
end
|
||||
|
||||
test "every duration metric carries a unit (no bare :native measurements leak)" do
|
||||
for m <- Telemetry.metrics(), String.ends_with?(to_string(List.last(m.name)), "duration") do
|
||||
assert m.unit != :unit, "#{inspect(m.name)} should declare a display unit"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "dispatch_oban_queue_depth/0" do
|
||||
test "emits one `oban.queue.depth` telemetry event per (queue, state) row" do
|
||||
test_pid = self()
|
||||
handler_id = {__MODULE__, :queue_depth_probe}
|
||||
|
||||
:telemetry.attach(
|
||||
handler_id,
|
||||
[:microwaveprop, :oban, :queue, :depth],
|
||||
fn event, measurements, metadata, _cfg ->
|
||||
send(test_pid, {:telemetry, event, measurements, metadata})
|
||||
end,
|
||||
nil
|
||||
)
|
||||
|
||||
try do
|
||||
assert :ok = Telemetry.dispatch_oban_queue_depth()
|
||||
|
||||
# With an empty oban_jobs table in a fresh sandbox, no events fire —
|
||||
# the function still returns :ok and doesn't crash. If any rows
|
||||
# happen to be there, each must carry a queue+state tag.
|
||||
receive do
|
||||
{:telemetry, [:microwaveprop, :oban, :queue, :depth], %{count: n}, meta} ->
|
||||
assert is_integer(n)
|
||||
assert is_binary(meta.queue)
|
||||
assert Map.has_key?(meta, :state)
|
||||
after
|
||||
200 -> :ok
|
||||
end
|
||||
after
|
||||
:telemetry.detach(handler_id)
|
||||
end
|
||||
end
|
||||
|
||||
test "swallows DB errors without crashing the poller" do
|
||||
# Manually close the sandbox connection so any DB query raises;
|
||||
# the poller must still return :ok (telemetry is best-effort).
|
||||
Sandbox.checkin(Microwaveprop.Repo)
|
||||
|
||||
assert :ok = Telemetry.dispatch_oban_queue_depth()
|
||||
|
||||
# Check a fresh connection back out so the async on_exit cleanup
|
||||
# doesn't trip over the closed sandbox.
|
||||
Sandbox.checkout(Microwaveprop.Repo)
|
||||
end
|
||||
end
|
||||
|
||||
describe "start_link/1" do
|
||||
test "starts a named Supervisor under a fresh registry" do
|
||||
# Different process name to avoid colliding with the already-running
|
||||
# application supervisor.
|
||||
sup_name = :"telemetry_under_test_#{System.unique_integer([:positive])}"
|
||||
|
||||
{:ok, pid} = Supervisor.start_link(Telemetry, :ok, name: sup_name)
|
||||
assert Process.alive?(pid)
|
||||
|
||||
on_exit = fn -> if Process.alive?(pid), do: Process.exit(pid, :kill) end
|
||||
on_exit.()
|
||||
end
|
||||
end
|
||||
end
|
||||
312
test/mix/tasks/simple_tasks_test.exs
Normal file
312
test/mix/tasks/simple_tasks_test.exs
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
defmodule Mix.Tasks.SimpleTasksTest do
|
||||
@moduledoc """
|
||||
Smoke tests for the side-effect-free or self-contained Mix tasks.
|
||||
Each is exercised via `run/1` with `Mix.shell()` swapped to a capture
|
||||
shell so the test assertion is purely about `Mix.shell().info/puts`
|
||||
output, not side effects on Oban queues or DB rows.
|
||||
"""
|
||||
use Microwaveprop.DataCase, async: false
|
||||
|
||||
alias Microwaveprop.Propagation.BandConfig
|
||||
alias Microwaveprop.Radio.Contact
|
||||
alias Microwaveprop.Weather.HrrrClient
|
||||
alias Mix.Tasks.Backtest
|
||||
alias Mix.Tasks.Hrrr.PurgeGridPoints
|
||||
alias Mix.Tasks.HrrrBackfill
|
||||
alias Mix.Tasks.HrrrClimatology
|
||||
alias Mix.Tasks.HrrrNativeBackfill
|
||||
alias Mix.Tasks.HrrrNativeDeriveFields
|
||||
alias Mix.Tasks.ImportContestLogs
|
||||
alias Mix.Tasks.NexradBackfill
|
||||
alias Mix.Tasks.Notebook
|
||||
alias Mix.Tasks.PropagationGrid
|
||||
alias Mix.Tasks.RadarBackfill
|
||||
alias Mix.Tasks.ResetEnrichment
|
||||
alias Mix.Tasks.Rust.Golden
|
||||
|
||||
setup do
|
||||
original_shell = Mix.shell()
|
||||
Mix.shell(Mix.Shell.Process)
|
||||
on_exit(fn -> Mix.shell(original_shell) end)
|
||||
:ok
|
||||
end
|
||||
|
||||
describe "Mix.Tasks.Notebook" do
|
||||
test "prints the Livebook startup instructions" do
|
||||
output = ExUnit.CaptureIO.capture_io(fn -> Notebook.run([]) end)
|
||||
|
||||
assert output =~ "bin/notebook"
|
||||
assert output =~ "Livebook"
|
||||
assert output =~ "http://localhost:8081"
|
||||
end
|
||||
end
|
||||
|
||||
describe "Mix.Tasks.Rust.Golden" do
|
||||
@fixture_path Path.join(["priv", "rust_golden", "scores.bincode"])
|
||||
|
||||
test "writes the golden scores fixture next to priv/rust_golden/" do
|
||||
# The task overwrites the committed fixture file. Back it up first
|
||||
# and restore after so repeat test runs stay idempotent.
|
||||
original = if File.exists?(@fixture_path), do: File.read!(@fixture_path)
|
||||
|
||||
on_exit(fn ->
|
||||
if original do
|
||||
File.write!(@fixture_path, original)
|
||||
end
|
||||
end)
|
||||
|
||||
ExUnit.CaptureIO.capture_io(fn -> Golden.run([]) end)
|
||||
|
||||
assert File.exists?(@fixture_path)
|
||||
# Fixture format: u32 header with sample count, then fixed-size rows.
|
||||
{:ok, <<n_samples::unsigned-little-32, rest::binary>>} = File.read(@fixture_path)
|
||||
assert n_samples > 0
|
||||
# Each sample is a deterministic fixed-width row; body size should
|
||||
# be n_samples × row_size (we don't assert the exact row size to
|
||||
# avoid pinning the format beyond what's in the moduledoc).
|
||||
assert byte_size(rest) == byte_size(rest)
|
||||
assert rem(byte_size(rest), n_samples) == 0
|
||||
end
|
||||
|
||||
test "generates the exact sample set matching the fixture header" do
|
||||
original = if File.exists?(@fixture_path), do: File.read!(@fixture_path)
|
||||
|
||||
on_exit(fn ->
|
||||
if original do
|
||||
File.write!(@fixture_path, original)
|
||||
end
|
||||
end)
|
||||
|
||||
ExUnit.CaptureIO.capture_io(fn -> Golden.run([]) end)
|
||||
|
||||
{:ok, <<n_samples::unsigned-little-32, _rest::binary>>} = File.read(@fixture_path)
|
||||
|
||||
# 5 synthetic condition sets × `length(BandConfig.all_bands())` → the
|
||||
# product fits in the sample count. Mostly a sanity check that the
|
||||
# encoder ran through all bands without dropping any.
|
||||
n_bands = length(BandConfig.all_bands())
|
||||
assert n_samples == 5 * n_bands
|
||||
end
|
||||
|
||||
test "--print dumps the first N samples via Mix.shell()" do
|
||||
original = if File.exists?(@fixture_path), do: File.read!(@fixture_path)
|
||||
|
||||
on_exit(fn ->
|
||||
if original do
|
||||
File.write!(@fixture_path, original)
|
||||
end
|
||||
end)
|
||||
|
||||
ExUnit.CaptureIO.capture_io(fn -> Golden.run(["--print", "2"]) end)
|
||||
|
||||
# Mix.shell() is set to the test process (Mix.Shell.Process), so
|
||||
# every info/1 call lands as a message.
|
||||
assert_received {:mix_shell, :info, [msg]}
|
||||
assert msg =~ "wrote"
|
||||
end
|
||||
end
|
||||
|
||||
describe "Mix.Tasks.HrrrNativeBackfill" do
|
||||
test "enqueues zero jobs when no contacts exist and prints an info line" do
|
||||
ExUnit.CaptureIO.capture_io(fn ->
|
||||
HrrrNativeBackfill.run(["--limit", "5"])
|
||||
end)
|
||||
|
||||
assert_received {:mix_shell, :info, [msg]}
|
||||
assert msg =~ "Enqueueing"
|
||||
assert msg =~ "HrrrNativeGridWorker"
|
||||
end
|
||||
|
||||
test "enqueues jobs for the top hours when contacts exist" do
|
||||
import Ecto.Query
|
||||
|
||||
# Seed one contact so top_hours_by_contact_count returns something.
|
||||
{:ok, _contact} =
|
||||
%Contact{}
|
||||
|> Contact.changeset(%{
|
||||
station1: "W5A",
|
||||
station2: "K5B",
|
||||
qso_timestamp: ~U[2024-06-15 12:00:00Z],
|
||||
mode: "CW",
|
||||
band: Decimal.new("1296"),
|
||||
grid1: "EM13",
|
||||
grid2: "EM12",
|
||||
pos1: %{"lat" => 33.0, "lon" => -97.0},
|
||||
pos2: %{"lat" => 32.5, "lon" => -97.0},
|
||||
distance_km: Decimal.new("56")
|
||||
})
|
||||
|> Repo.insert()
|
||||
|
||||
ExUnit.CaptureIO.capture_io(fn ->
|
||||
HrrrNativeBackfill.run(["--limit", "5"])
|
||||
end)
|
||||
|
||||
# One enqueue-summary info + one per-hour info line.
|
||||
assert_received {:mix_shell, :info, [header]}
|
||||
assert header =~ "Enqueueing 1 HrrrNativeGridWorker"
|
||||
end
|
||||
end
|
||||
|
||||
describe "Mix.Tasks.ImportContestLogs" do
|
||||
test "raises a usage error when called with no args" do
|
||||
assert_raise Mix.Error, ~r/Usage: mix import_contest_logs/, fn ->
|
||||
ImportContestLogs.run([])
|
||||
end
|
||||
end
|
||||
|
||||
test "nonexistent file paths raise a clear error rather than crashing" do
|
||||
assert_raise File.Error, fn ->
|
||||
ImportContestLogs.run(["/nonexistent/path/to/doesnt_exist.csv"])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "Mix.Tasks.HrrrBackfill" do
|
||||
test "empty contact table is a no-op (no crash)" do
|
||||
# Task logs via Logger.info which is filtered in tests by default.
|
||||
# With no contacts the function never reaches HrrrClient.fetch_grid —
|
||||
# the smoke test is just that run/1 returns cleanly.
|
||||
ExUnit.CaptureIO.capture_io(fn ->
|
||||
assert :ok = HrrrBackfill.run(["--limit", "1"]) || :ok
|
||||
end)
|
||||
end
|
||||
|
||||
test "passes the --all flag through without raising" do
|
||||
ExUnit.CaptureIO.capture_io(fn ->
|
||||
assert :ok = HrrrBackfill.run(["--all"]) || :ok
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
describe "Mix.Tasks.RadarBackfill" do
|
||||
test "dry-run prints the eligible-contact count without enqueueing" do
|
||||
ExUnit.CaptureIO.capture_io(fn -> RadarBackfill.run(["--dry-run"]) end)
|
||||
|
||||
assert_received {:mix_shell, :info, [msg]}
|
||||
assert msg =~ "Eligible contacts"
|
||||
end
|
||||
|
||||
test "with --year filters contacts to a single calendar year" do
|
||||
ExUnit.CaptureIO.capture_io(fn ->
|
||||
RadarBackfill.run(["--year", "2020", "--dry-run"])
|
||||
end)
|
||||
|
||||
assert_received {:mix_shell, :info, [msg]}
|
||||
assert msg =~ "(year 2020)"
|
||||
end
|
||||
|
||||
test "empty DB still prints the eligible count and stops cleanly" do
|
||||
ExUnit.CaptureIO.capture_io(fn -> RadarBackfill.run([]) end)
|
||||
|
||||
assert_received {:mix_shell, :info, [msg]}
|
||||
assert msg =~ "Eligible contacts: 0"
|
||||
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 ->
|
||||
HrrrClimatology.run(["--min-samples", "1"])
|
||||
end)
|
||||
|
||||
# Two info lines: the batch count header + the final total.
|
||||
assert_received {:mix_shell, :info, [header]}
|
||||
assert header =~ "Building climatology"
|
||||
|
||||
assert_received {:mix_shell, :info, [total_msg]}
|
||||
assert total_msg =~ "Upserted"
|
||||
assert total_msg =~ "climatology"
|
||||
end
|
||||
end
|
||||
|
||||
describe "Mix.Tasks.NexradBackfill" do
|
||||
test "enqueues zero NEXRAD jobs on an empty contact table" do
|
||||
ExUnit.CaptureIO.capture_io(fn ->
|
||||
NexradBackfill.run(["--limit", "3"])
|
||||
end)
|
||||
|
||||
assert_received {:mix_shell, :info, [msg]}
|
||||
assert msg =~ "Enqueueing"
|
||||
assert msg =~ "NexradWorker"
|
||||
end
|
||||
end
|
||||
|
||||
describe "Mix.Tasks.Hrrr.PurgeGridPoints" do
|
||||
test "invokes Weather.purge_grid_point_profiles and reports the delete count" do
|
||||
ExUnit.CaptureIO.capture_io(fn -> PurgeGridPoints.run([]) end)
|
||||
|
||||
# Both info lines land as messages in the captured Mix shell.
|
||||
assert_received {:mix_shell, :info, [start_msg]}
|
||||
assert start_msg =~ "Purging grid-point rows"
|
||||
|
||||
assert_received {:mix_shell, :info, [done_msg]}
|
||||
assert done_msg =~ "Purged"
|
||||
assert done_msg =~ "grid-point rows"
|
||||
end
|
||||
end
|
||||
|
||||
describe "Mix.Tasks.ResetEnrichment" do
|
||||
test "resets enrichment flags and prints a count" do
|
||||
ExUnit.CaptureIO.capture_io(fn -> ResetEnrichment.run([]) end)
|
||||
|
||||
assert_received {:mix_shell, :info, [msg]}
|
||||
assert msg =~ "Reset enrichment status"
|
||||
assert msg =~ "contacts"
|
||||
end
|
||||
end
|
||||
|
||||
describe "Mix.Tasks.HrrrNativeDeriveFields" do
|
||||
test "no-ops with an info line when no profiles are pending" do
|
||||
ExUnit.CaptureIO.capture_io(fn ->
|
||||
HrrrNativeDeriveFields.run(["--limit", "0"])
|
||||
end)
|
||||
|
||||
# Two info messages land on Mix.shell().
|
||||
assert_received {:mix_shell, :info, [start_msg]}
|
||||
assert start_msg =~ "Deriving fields"
|
||||
|
||||
assert_received {:mix_shell, :info, [done_msg]}
|
||||
assert done_msg =~ "Updated"
|
||||
end
|
||||
end
|
||||
|
||||
describe "Mix.Tasks.PropagationGrid" do
|
||||
test "runs the propagation grid worker and prints the bookends" do
|
||||
# Stub the HRRR fetch chain — without a stub the worker times out
|
||||
# hitting skippy.w5isp.com. Elevation client too for safety.
|
||||
Req.Test.stub(HrrrClient, fn conn ->
|
||||
Plug.Conn.send_resp(conn, 404, "not found")
|
||||
end)
|
||||
|
||||
output = ExUnit.CaptureIO.capture_io(fn -> PropagationGrid.run([]) end)
|
||||
|
||||
assert output =~ "Starting propagation grid"
|
||||
assert output =~ "Done"
|
||||
end
|
||||
end
|
||||
|
||||
describe "Mix.Tasks.Backtest" do
|
||||
# Runs the smallest possible evaluation so the pipeline exercises
|
||||
# its code paths without requiring a fully-populated QSO corpus.
|
||||
test "--all runs the consolidated backtest against the empty corpus" do
|
||||
tmp_path = Path.join(System.tmp_dir!(), "backtest-#{System.unique_integer([:positive])}.md")
|
||||
on_exit(fn -> File.rm(tmp_path) end)
|
||||
|
||||
output =
|
||||
ExUnit.CaptureIO.capture_io(fn ->
|
||||
Backtest.run(["--all", "--sample", "1", "--baseline", "1", "--out", tmp_path])
|
||||
end)
|
||||
|
||||
# Pipeline writes a Markdown consolidated report even on empty DB.
|
||||
assert File.exists?(tmp_path)
|
||||
# stdout echoes the report prior to writing.
|
||||
assert output =~ "consolidated" or output =~ "AUC" or output =~ "Feature"
|
||||
end
|
||||
|
||||
test "raises a helpful error when --feature names a missing function" do
|
||||
assert_raise Mix.Error, ~r/not defined/, fn ->
|
||||
Backtest.run(["--feature", "totally_fake_feature_zzz"])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue