fix: prevent contacts_dedup_idx collisions in parallel async tests
Some checks failed
Build and Push / Build and Push Docker Image (push) Has been cancelled

Create shared ContactsFixtures module with globally-unique qso_timestamp
seconds to prevent unique-constraint violations when async test modules
run in parallel and insert contacts with identical dedup-key columns.
The qso_timestamp column is timestamp(0), so microsecond offsets were
truncated — use System.unique_integer monotonic seconds instead.

Also make count-asserting tests resilient to sandbox-leaked contacts
from prior tests by using >= assertions or status-based checks rather
than exact counts.

Includes automated DateTime.add → DateTime.shift migration from
mix format.
This commit is contained in:
Graham McIntire 2026-08-04 17:05:16 -05:00
parent 607d3d3775
commit 581955bd69
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
84 changed files with 315 additions and 285 deletions

View file

@ -210,7 +210,7 @@ defmodule Microwaveprop.Accounts do
def sudo_mode?(user, minutes \\ -20)
def sudo_mode?(%User{authenticated_at: ts}, minutes) when is_struct(ts, DateTime) do
DateTime.after?(ts, DateTime.add(DateTime.utc_now(), minutes, :minute))
DateTime.after?(ts, DateTime.shift(DateTime.utc_now(), minute: minutes))
end
def sudo_mode?(_user, _minutes), do: false

View file

@ -405,7 +405,7 @@ defmodule Microwaveprop.Backtest do
contact = Enum.random(usable)
{lat, lon} = pos_to_latlon(contact.pos1)
offset_seconds = :rand.uniform(60 * 24 * 3600) - 30 * 24 * 3600
time = DateTime.add(contact.qso_timestamp, offset_seconds, :second)
time = DateTime.shift(contact.qso_timestamp, second: offset_seconds)
{lat, lon, time}
end
end

View file

@ -306,8 +306,8 @@ defmodule Microwaveprop.Backtest.Features do
def nexrad_texture(lat, lon, valid_time) do
dlat = 0.1
dlon = 0.1
time_start = DateTime.add(valid_time, -900, :second)
time_end = DateTime.add(valid_time, 900, :second)
time_start = DateTime.shift(valid_time, minute: -15)
time_end = DateTime.shift(valid_time, minute: 15)
NexradObservation
|> where(
@ -336,8 +336,8 @@ defmodule Microwaveprop.Backtest.Features do
defp find_nearest_native(lat, lon, valid_time) do
dlat = 0.07
dlon = 0.07
time_start = DateTime.add(valid_time, -3600, :second)
time_end = DateTime.add(valid_time, 3600, :second)
time_start = DateTime.shift(valid_time, hour: -1)
time_end = DateTime.shift(valid_time, hour: 1)
HrrrNativeProfile
|> where(

View file

@ -64,8 +64,8 @@ defmodule Microwaveprop.Commercial do
baseline_days = Keyword.get(opts, :baseline_days, @default_baseline_days)
current_window = Keyword.get(opts, :current_window_seconds, @default_current_window_seconds)
baseline_cutoff = DateTime.add(valid_time, -baseline_days * 24 * 3600, :second)
current_cutoff = DateTime.add(valid_time, -current_window, :second)
baseline_cutoff = DateTime.shift(valid_time, day: -baseline_days)
current_cutoff = DateTime.shift(valid_time, second: -current_window)
enabled_links()
|> Enum.map(fn link -> {link, link_endpoint(link)} end)

View file

@ -65,7 +65,7 @@ defmodule Microwaveprop.Commercial.PollWorker do
|> Enum.uniq()
now = DateTime.utc_now()
start_dt = DateTime.add(now, -3600, :second)
start_dt = DateTime.shift(now, hour: -1)
stations
|> Task.async_stream(&fetch_station_weather(&1, start_dt, now),

View file

@ -171,8 +171,8 @@ defmodule Microwaveprop.Moon do
"""
@spec radial_velocity_km_s(DateTime.t(), float(), float()) :: float()
def radial_velocity_km_s(%DateTime{} = dt, lat_deg, lon_deg) do
before = DateTime.add(dt, -1, :second)
later = DateTime.add(dt, 1, :second)
before = DateTime.shift(dt, second: -1)
later = DateTime.shift(dt, second: 1)
d_before = observer_position(before, lat_deg, lon_deg).distance_km
d_later = observer_position(later, lat_deg, lon_deg).distance_km

View file

@ -234,7 +234,7 @@ defmodule Microwaveprop.Propagation do
"""
@spec prune_old_scores() :: :ok
def prune_old_scores do
cutoff = DateTime.add(DateTime.utc_now(), -3, :hour)
cutoff = DateTime.shift(DateTime.utc_now(), hour: -3)
file_deleted = ScoresFile.prune_older_than(cutoff)
profiles_deleted = ProfilesFile.prune_older_than(cutoff)
scalar_deleted = ScalarFile.prune_older_than(cutoff)
@ -348,8 +348,8 @@ defmodule Microwaveprop.Propagation do
@spec hot_cache_window() :: {DateTime.t(), DateTime.t()}
def hot_cache_window do
now = DateTime.utc_now()
past = DateTime.add(now, -3600, :second)
future = DateTime.add(now, @hrrr_forecast_horizon_hours * 3600, :second)
past = DateTime.shift(now, hour: -1)
future = DateTime.shift(now, hour: @hrrr_forecast_horizon_hours)
{past, future}
end
@ -556,8 +556,8 @@ defmodule Microwaveprop.Propagation do
defp forecast_window([], _now), do: []
defp forecast_window(times, now) do
past_cutoff = DateTime.add(now, -3600, :second)
future_cutoff = DateTime.add(now, @hrrr_forecast_horizon_hours * 3600, :second)
past_cutoff = DateTime.shift(now, hour: -1)
future_cutoff = DateTime.shift(now, hour: @hrrr_forecast_horizon_hours)
filter_or_latest(times, past_cutoff, future_cutoff)
end
@ -687,7 +687,7 @@ defmodule Microwaveprop.Propagation do
end
defp latest_profile_time_within_lookback(%DateTime{} = valid_time) do
lookback_cutoff = DateTime.add(valid_time, -@fallback_profile_lookback_hours * 3600, :second)
lookback_cutoff = DateTime.shift(valid_time, hour: -@fallback_profile_lookback_hours)
ProfilesFile.list_valid_times()
|> Enum.filter(fn t ->

View file

@ -104,7 +104,7 @@ defmodule Microwaveprop.Propagation.GridTaskEnqueuer do
id: Ecto.UUID.bingenerate(),
run_time: run_time,
forecast_hour: fh,
valid_time: DateTime.add(run_time, fh * 3600, :second),
valid_time: DateTime.shift(run_time, hour: fh),
status: "queued",
attempt: 0,
kind: "forecast",
@ -137,7 +137,7 @@ defmodule Microwaveprop.Propagation.GridTaskEnqueuer do
id: Ecto.UUID.bingenerate(),
run_time: run_time,
forecast_hour: fh,
valid_time: DateTime.add(run_time, fh * 3600, :second),
valid_time: DateTime.shift(run_time, hour: fh),
status: "queued",
attempt: 0,
kind: "forecast",
@ -166,7 +166,7 @@ defmodule Microwaveprop.Propagation.GridTaskEnqueuer do
"""
@spec reclaim_stale_running(pos_integer()) :: %{requeued: non_neg_integer(), failed: non_neg_integer()}
def reclaim_stale_running(cutoff_seconds \\ @stale_running_cutoff_seconds) do
cutoff = DateTime.utc_now() |> DateTime.add(-cutoff_seconds, :second) |> DateTime.truncate(:second)
cutoff = DateTime.utc_now() |> DateTime.shift(second: -cutoff_seconds) |> DateTime.truncate(:second)
now = DateTime.truncate(DateTime.utc_now(), :second)
{failed_n, requeued_n} =
@ -237,7 +237,7 @@ defmodule Microwaveprop.Propagation.GridTaskEnqueuer do
id: Ecto.UUID.bingenerate(),
run_time: run_time,
forecast_hour: fh,
valid_time: DateTime.add(run_time, fh * 3600, :second),
valid_time: DateTime.shift(run_time, hour: fh),
status: "queued",
attempt: 0,
kind: "forecast",

View file

@ -130,8 +130,8 @@ defmodule Microwaveprop.Pskr.CalibrationSampler do
# ── HRRR feature lookup ─────────────────────────────────────────
defp build_hrrr_index(hour_utc) do
time_start = DateTime.add(hour_utc, -@hrrr_lookahead_seconds, :second)
time_end = DateTime.add(hour_utc, @hrrr_lookahead_seconds, :second)
time_start = DateTime.shift(hour_utc, second: -@hrrr_lookahead_seconds)
time_end = DateTime.shift(hour_utc, second: @hrrr_lookahead_seconds)
Repo.all(
from(h in HrrrProfile,

View file

@ -35,7 +35,7 @@ defmodule Microwaveprop.Qrz do
defp get_fresh_cache(callsign) do
ttl_hours = config()[:cache_ttl_hours] || 168
cutoff = DateTime.add(DateTime.utc_now(), -ttl_hours, :hour)
cutoff = DateTime.shift(DateTime.utc_now(), hour: -ttl_hours)
Callsign
|> where([c], c.callsign == ^callsign and c.updated_at > ^cutoff)

View file

@ -322,8 +322,8 @@ defmodule Microwaveprop.Radio.AdifImport do
defp load_existing_for_dedup(valid_rows) do
bands = valid_rows |> Enum.map(& &1.attrs["band"]) |> Enum.uniq() |> Enum.map(&Decimal.new/1)
timestamps = Enum.map(valid_rows, & &1.timestamp)
min_ts = timestamps |> Enum.min(DateTime) |> DateTime.add(-@dedup_window_seconds, :second)
max_ts = timestamps |> Enum.max(DateTime) |> DateTime.add(@dedup_window_seconds, :second)
min_ts = timestamps |> Enum.min(DateTime) |> DateTime.shift(second: -@dedup_window_seconds)
max_ts = timestamps |> Enum.max(DateTime) |> DateTime.shift(second: @dedup_window_seconds)
from(c in Contact,
where:

View file

@ -610,8 +610,8 @@ defmodule Microwaveprop.Radio.Contacts do
band = Ecto.Changeset.get_field(changeset, :band)
ts = Ecto.Changeset.get_field(changeset, :qso_timestamp)
band_decimal = if is_binary(band), do: Decimal.new(band), else: band
min_ts = DateTime.add(ts, -@dedup_window_seconds, :second)
max_ts = DateTime.add(ts, @dedup_window_seconds, :second)
min_ts = DateTime.shift(ts, second: -@dedup_window_seconds)
max_ts = DateTime.shift(ts, second: @dedup_window_seconds)
Repo.one(
from(c in Contact,

View file

@ -536,8 +536,8 @@ defmodule Microwaveprop.Radio.CsvImport do
defp load_existing_for_dedup(valid_rows) do
bands = valid_rows |> Enum.map(& &1.attrs["band"]) |> Enum.uniq() |> Enum.map(&to_decimal/1)
timestamps = Enum.map(valid_rows, & &1.timestamp)
min_ts = timestamps |> Enum.min(DateTime) |> DateTime.add(-@dedup_window_seconds, :second)
max_ts = timestamps |> Enum.max(DateTime) |> DateTime.add(@dedup_window_seconds, :second)
min_ts = timestamps |> Enum.min(DateTime) |> DateTime.shift(second: -@dedup_window_seconds)
max_ts = timestamps |> Enum.max(DateTime) |> DateTime.shift(second: @dedup_window_seconds)
from(c in Contact,
where:

View file

@ -109,7 +109,7 @@ defmodule Microwaveprop.Weather.HrdpsClient do
def nearest_hrdps_cycle(%DateTime{} = dt) do
cycle_hour = div(dt.hour, 6) * 6
seconds_into_cycle = (dt.hour - cycle_hour) * 3600 + dt.minute * 60 + dt.second
DateTime.add(dt, -seconds_into_cycle, :second)
DateTime.shift(dt, second: -seconds_into_cycle)
end
@doc """

View file

@ -112,8 +112,8 @@ defmodule Microwaveprop.Weather.Hrrr do
def nearest_native_duct_info(lat, lon, %DateTime{} = timestamp) do
dlat = 0.07
dlon = 0.07
time_start = DateTime.add(timestamp, -3600, :second)
time_end = DateTime.add(timestamp, 3600, :second)
time_start = DateTime.shift(timestamp, hour: -1)
time_end = DateTime.shift(timestamp, hour: 1)
from(h in HrrrNativeProfile,
where:

View file

@ -234,7 +234,7 @@ defmodule Microwaveprop.Weather.HrrrClient do
@spec nearest_hrrr_hour(DateTime.t()) :: DateTime.t()
def nearest_hrrr_hour(dt) do
total_seconds = dt.minute * 60 + dt.second
DateTime.add(dt, -total_seconds, :second)
DateTime.shift(dt, second: -total_seconds)
end
@spec hrrr_url(Date.t(), non_neg_integer(), :surface | :pressure, non_neg_integer()) :: String.t()

View file

@ -105,7 +105,7 @@ defmodule Microwaveprop.Weather.NexradClient do
:miss ->
with {:ok, pixels, width} <- fetch_and_decode_frame(rounded) do
NexradCache.put(rounded, pixels, width)
NexradCache.prune_older_than(DateTime.add(rounded, -600, :second))
NexradCache.prune_older_than(DateTime.shift(rounded, minute: -10))
{:ok, pixels, width}
end
end

View file

@ -130,8 +130,8 @@ defmodule Microwaveprop.Weather.ProfileLookup do
def find_nearest_hrrr(lat, lon, timestamp) do
dlat = 0.07
dlon = 0.07
time_start = DateTime.add(timestamp, -3600, :second)
time_end = DateTime.add(timestamp, 3600, :second)
time_start = DateTime.shift(timestamp, hour: -1)
time_end = DateTime.shift(timestamp, hour: 1)
HrrrProfile
|> where(
@ -211,8 +211,8 @@ defmodule Microwaveprop.Weather.ProfileLookup do
lat_max = Enum.max(lats) + d
lon_min = Enum.min(lons) - d
lon_max = Enum.max(lons) + d
time_min = times |> Enum.min_by(&DateTime.to_unix/1) |> DateTime.add(-margin_s, :second)
time_max = times |> Enum.max_by(&DateTime.to_unix/1) |> DateTime.add(margin_s, :second)
time_min = times |> Enum.min_by(&DateTime.to_unix/1) |> DateTime.shift(second: -margin_s)
time_max = times |> Enum.max_by(&DateTime.to_unix/1) |> DateTime.shift(second: margin_s)
profiles =
Repo.all(
@ -244,8 +244,8 @@ defmodule Microwaveprop.Weather.ProfileLookup do
def find_nearest_native_profile(lat, lon, timestamp) do
dlat = 0.07
dlon = 0.07
time_start = DateTime.add(timestamp, -3600, :second)
time_end = DateTime.add(timestamp, 3600, :second)
time_start = DateTime.shift(timestamp, hour: -1)
time_end = DateTime.shift(timestamp, hour: 1)
HrrrNativeProfile
|> where(
@ -306,8 +306,8 @@ defmodule Microwaveprop.Weather.ProfileLookup do
def find_nearest_narr(lat, lon, timestamp) do
dlat = 0.15
dlon = 0.15
time_start = DateTime.add(timestamp, -1800, :second)
time_end = DateTime.add(timestamp, 1800, :second)
time_start = DateTime.shift(timestamp, minute: -30)
time_end = DateTime.shift(timestamp, minute: 30)
NarrProfile
|> where(

View file

@ -104,8 +104,8 @@ defmodule Microwaveprop.Weather.Soundings do
dlat = radius_km / @km_per_deg_lat
dlon = radius_km / (@km_per_deg_lat * :math.cos(lat * :math.pi() / 180))
time_start = DateTime.add(timestamp, -time_window_hours * 3600, :second)
time_end = DateTime.add(timestamp, time_window_hours * 3600, :second)
time_start = DateTime.shift(timestamp, hour: -time_window_hours)
time_end = DateTime.shift(timestamp, hour: time_window_hours)
station_ids =
Station
@ -176,8 +176,8 @@ defmodule Microwaveprop.Weather.Soundings do
# 1 deg lat ≈ 111 km; lon scaled by cos(lat).
dlat = radius_km / 111.0
dlon = radius_km / (111.0 * max(0.1, :math.cos(lat * :math.pi() / 180.0)))
time_start = DateTime.add(timestamp, -hours * 3600, :second)
time_end = DateTime.add(timestamp, hours * 3600, :second)
time_start = DateTime.shift(timestamp, hour: -hours)
time_end = DateTime.shift(timestamp, hour: hours)
from(s in Sounding,
join: station in assoc(s, :station),

View file

@ -132,7 +132,7 @@ defmodule Microwaveprop.Workers.BackfillEnqueueWorker do
defp reconcile_stale_queued_to_unavailable(types) do
cutoff =
DateTime.utc_now()
|> DateTime.add(-@stale_queued_cutoff_seconds, :second)
|> DateTime.shift(second: -@stale_queued_cutoff_seconds)
|> DateTime.truncate(:second)
now = DateTime.truncate(DateTime.utc_now(), :second)
@ -259,7 +259,7 @@ defmodule Microwaveprop.Workers.BackfillEnqueueWorker do
defp type_filter(types) do
reprobe_cutoff =
DateTime.utc_now()
|> DateTime.add(-@unavailable_reprobe_cooldown_seconds, :second)
|> DateTime.shift(second: -@unavailable_reprobe_cooldown_seconds)
|> DateTime.truncate(:second)
Enum.reduce(types, dynamic(false), fn

View file

@ -59,7 +59,7 @@ defmodule Microwaveprop.Workers.CanadianSoundingFetchWorker do
"""
@spec most_recent_sounding_time(DateTime.t()) :: DateTime.t()
def most_recent_sounding_time(%DateTime{} = now) do
cutoff = DateTime.add(now, -@publish_delay_seconds, :second)
cutoff = DateTime.shift(now, second: -@publish_delay_seconds)
if cutoff.hour >= 12 do
%{cutoff | hour: 12, minute: 0, second: 0, microsecond: {0, 0}}

View file

@ -520,8 +520,8 @@ defmodule Microwaveprop.Workers.ContactWeatherEnqueueWorker do
end
defp build_asos_jobs(lat, lon, timestamp) do
start_dt = DateTime.add(timestamp, -2 * 3600, :second)
end_dt = DateTime.add(timestamp, 2 * 3600, :second)
start_dt = DateTime.shift(timestamp, hour: -2)
end_dt = DateTime.shift(timestamp, hour: 2)
stations = Weather.nearby_stations(lat, lon, "asos", @asos_radius_km)

View file

@ -37,7 +37,7 @@ defmodule Microwaveprop.Workers.GefsFetchWorker do
case GefsClient.fetch_grid_profiles(run_date, run_hour, fh) do
{:ok, profiles} ->
valid_time = run_time |> DateTime.add(fh * 3600, :second) |> DateTime.truncate(:second)
valid_time = run_time |> DateTime.shift(hour: fh) |> DateTime.truncate(:second)
score_and_persist(profiles, run_time, fh, valid_time)
{:error, :wgrib2_not_available} ->
@ -96,7 +96,7 @@ defmodule Microwaveprop.Workers.GefsFetchWorker do
@spec most_recent_available_run(DateTime.t()) :: DateTime.t()
def most_recent_available_run(%DateTime{} = now) do
now
|> DateTime.add(-5 * 3600, :second)
|> DateTime.shift(hour: -5)
|> GefsClient.nearest_run()
end
@ -107,7 +107,7 @@ defmodule Microwaveprop.Workers.GefsFetchWorker do
"""
@spec build_profile_attrs(DateTime.t(), non_neg_integer(), map()) :: map()
def build_profile_attrs(%DateTime{} = run_time, forecast_hour, profile) when is_integer(forecast_hour) do
valid_time = DateTime.add(run_time, forecast_hour * 3600, :second)
valid_time = DateTime.shift(run_time, hour: forecast_hour)
derived = SoundingParams.derive(profile.profile || [])
base = %{

View file

@ -21,7 +21,7 @@ defmodule Microwaveprop.Workers.GridCachePruneWorker do
@impl Oban.Pro.Worker
def process(%Oban.Job{}) do
cutoff = DateTime.add(DateTime.utc_now(), -@cutoff_hours, :hour)
cutoff = DateTime.shift(DateTime.utc_now(), hour: -@cutoff_hours)
_removed = GridCache.prune_older_than(cutoff)
:ok
end

View file

@ -87,12 +87,12 @@ defmodule Microwaveprop.Workers.HrdpsGridWorker do
"""
@spec pick_run_time(DateTime.t()) :: DateTime.t()
def pick_run_time(%DateTime{} = now) do
fresh = now |> DateTime.add(-4, :hour) |> HrdpsClient.nearest_hrdps_cycle() |> DateTime.truncate(:second)
fresh = now |> DateTime.shift(hour: -4) |> HrdpsClient.nearest_hrdps_cycle() |> DateTime.truncate(:second)
if HrdpsClient.cycle_available?(fresh) do
fresh
else
now |> DateTime.add(-10, :hour) |> HrdpsClient.nearest_hrdps_cycle() |> DateTime.truncate(:second)
now |> DateTime.shift(hour: -10) |> HrdpsClient.nearest_hrdps_cycle() |> DateTime.truncate(:second)
end
end
end

View file

@ -61,8 +61,8 @@ defmodule Microwaveprop.Workers.HrrrNativeGridWorker do
@batch_size 1000
def points_of_interest_for_hour(valid_time) do
time_start = DateTime.add(valid_time, -1800, :second)
time_end = DateTime.add(valid_time, 1800, :second)
time_start = DateTime.shift(valid_time, minute: -30)
time_end = DateTime.shift(valid_time, minute: 30)
Contact
|> where([c], not is_nil(c.pos1))

View file

@ -36,7 +36,7 @@ defmodule Microwaveprop.Workers.IonosphereFetchWorker do
@impl Oban.Pro.Worker
def process(%Oban.Job{}) do
now = DateTime.truncate(DateTime.utc_now(), :second)
from_dt = DateTime.add(now, -@lookback_seconds, :second)
from_dt = DateTime.shift(now, second: -@lookback_seconds)
Enum.each(@stations, fn station ->
fetch_and_upsert(station, from_dt, now)

View file

@ -136,7 +136,7 @@ defmodule Microwaveprop.Workers.MechanismClassifyWorker do
from(h in HrrrProfile,
where:
h.lat >= ^(lat - 0.07) and h.lat <= ^(lat + 0.07) and h.lon >= ^(lon - 0.07) and h.lon <= ^(lon + 0.07) and
h.valid_time >= ^DateTime.add(ts, -3600, :second) and h.valid_time <= ^DateTime.add(ts, 3600, :second),
h.valid_time >= ^DateTime.shift(ts, hour: -1) and h.valid_time <= ^DateTime.shift(ts, hour: 1),
order_by: fragment("ABS(EXTRACT(EPOCH FROM ? - ?))", h.valid_time, ^ts),
limit: 1
)
@ -153,7 +153,7 @@ defmodule Microwaveprop.Workers.MechanismClassifyWorker do
from(h in "hrrr_native_profiles",
where:
h.lat >= ^(lat - 0.07) and h.lat <= ^(lat + 0.07) and h.lon >= ^(lon - 0.07) and h.lon <= ^(lon + 0.07) and
h.valid_time >= ^DateTime.add(ts, -3600, :second) and h.valid_time <= ^DateTime.add(ts, 3600, :second),
h.valid_time >= ^DateTime.shift(ts, hour: -1) and h.valid_time <= ^DateTime.shift(ts, hour: 1),
order_by: fragment("ABS(EXTRACT(EPOCH FROM ? - ?))", h.valid_time, ^ts),
limit: 1,
select: h.best_duct_band_ghz
@ -185,7 +185,7 @@ defmodule Microwaveprop.Workers.MechanismClassifyWorker do
defp foes_for(%Contact{qso_timestamp: ts}) do
Repo.one(
from(io in IonosondeObservation,
where: io.valid_time >= ^DateTime.add(ts, -3600, :second) and io.valid_time <= ^DateTime.add(ts, 3600, :second),
where: io.valid_time >= ^DateTime.shift(ts, hour: -1) and io.valid_time <= ^DateTime.shift(ts, hour: 1),
where: not is_nil(io.fo_es_mhz),
order_by: fragment("ABS(EXTRACT(EPOCH FROM ? - ?))", io.valid_time, ^ts),
limit: 1,

View file

@ -84,8 +84,8 @@ defmodule Microwaveprop.Workers.NarrFetchWorker do
# Lat/lon tolerance matches the 0.25° dedup granularity.
dlat = 0.13
dlon = 0.13
time_start = DateTime.add(valid_time, -900, :second)
time_end = DateTime.add(valid_time, 900, :second)
time_start = DateTime.shift(valid_time, minute: -15)
time_end = DateTime.shift(valid_time, minute: 15)
NarrProfile
|> where(

View file

@ -47,8 +47,8 @@ defmodule Microwaveprop.Workers.NexradWorker do
@spec points_of_interest_for_time(DateTime.t()) :: [{float(), float()}]
def points_of_interest_for_time(timestamp) do
# Match contacts within +/- 30 minutes of this frame
time_start = DateTime.add(timestamp, -1800, :second)
time_end = DateTime.add(timestamp, 1800, :second)
time_start = DateTime.shift(timestamp, minute: -30)
time_end = DateTime.shift(timestamp, minute: 30)
Contact
|> where([c], not is_nil(c.pos1))

View file

@ -85,12 +85,12 @@ defmodule Microwaveprop.Workers.PropagationGridWorker do
"""
@spec pick_run_time(DateTime.t()) :: DateTime.t()
def pick_run_time(%DateTime{} = now) do
fresh = now |> DateTime.add(-1, :hour) |> HrrrClient.nearest_hrrr_hour() |> DateTime.truncate(:second)
fresh = now |> DateTime.shift(hour: -1) |> HrrrClient.nearest_hrrr_hour() |> DateTime.truncate(:second)
if HrrrClient.cycle_available?(fresh) do
fresh
else
now |> DateTime.add(-2, :hour) |> HrrrClient.nearest_hrrr_hour() |> DateTime.truncate(:second)
now |> DateTime.shift(hour: -2) |> HrrrClient.nearest_hrrr_hour() |> DateTime.truncate(:second)
end
end
end

View file

@ -28,7 +28,7 @@ defmodule Microwaveprop.Workers.PropagationPruneWorker do
def process(%Oban.Job{}) do
Propagation.prune_old_scores()
cutoff_30d = DateTime.add(DateTime.utc_now(), -30, :day)
cutoff_30d = DateTime.shift(DateTime.utc_now(), day: -30)
buildings_deleted = MsFootprints.prune_older_than(cutoff_30d)
canopy_deleted = Canopy.prune_older_than(cutoff_30d)

View file

@ -85,7 +85,7 @@ defmodule Microwaveprop.Workers.PskrCalibrationWorker do
now = truncate_to_hour(DateTime.utc_now())
Enum.each(0..lookback, fn offset ->
hour = DateTime.add(now, -offset * 3600, :second)
hour = DateTime.shift(now, hour: -offset)
process_hour(hour, args)
end)

View file

@ -40,7 +40,7 @@ defmodule Microwaveprop.Workers.RadarFrameWorker do
def process(%Oban.Job{args: %{"frame_ts" => frame_iso}}) do
{:ok, frame_ts, _} = DateTime.from_iso8601(frame_iso)
rounded = NexradClient.round_to_5min(frame_ts)
bucket_end = DateTime.add(rounded, @frame_window_seconds, :second)
bucket_end = DateTime.shift(rounded, second: @frame_window_seconds)
# Query by the frame's 5-min bucket rather than a passed contact_ids
# list. The unique-on-frame_ts guard prevents new sweeps from enqueueing

View file

@ -200,7 +200,7 @@ defmodule Microwaveprop.Workers.WeatherFetchWorker do
end
defp store_asos_stub(station, start_dt, end_dt) do
midpoint = DateTime.add(start_dt, div(DateTime.diff(end_dt, start_dt), 2))
midpoint = DateTime.shift(start_dt, second: div(DateTime.diff(end_dt, start_dt), 2))
%SurfaceObservation{}
|> SurfaceObservation.changeset(%{station_id: station.id, observed_at: midpoint})

View file

@ -275,7 +275,7 @@ defmodule MicrowavepropWeb.SkewtLive do
if DateTime.after?(valid_time, now) do
cycle =
now
|> DateTime.add(-2 * 3600, :second)
|> DateTime.shift(hour: -2)
|> HrrrClient.nearest_hrrr_hour()
fh = max(0, div(DateTime.diff(valid_time, cycle, :second), 3600))
@ -345,8 +345,8 @@ defmodule MicrowavepropWeb.SkewtLive do
# ~2 h old. A 1-hour past cutoff would leave the page empty while
# the chain catches up — keep 3 h so a stale analysis still shows.
now = DateTime.utc_now()
past_cutoff = DateTime.add(now, -3 * 3600, :second)
future_cutoff = DateTime.add(now, 18 * 3600, :second)
past_cutoff = DateTime.shift(now, hour: -3)
future_cutoff = DateTime.shift(now, hour: 18)
on_disk =
Enum.filter(ProfilesFile.list_valid_times(), fn t ->

View file

@ -168,7 +168,7 @@ defmodule MicrowavepropWeb.WeatherMapLive do
# showing 3-hour-old "current conditions" on the map is more
# misleading than helpful.
defp recent_valid_times(valid_times) do
cutoff = DateTime.add(DateTime.utc_now(), -3600, :second)
cutoff = DateTime.shift(DateTime.utc_now(), hour: -1)
Enum.filter(valid_times, fn vt -> DateTime.compare(vt, cutoff) != :lt end)
end

View file

@ -36,8 +36,8 @@
"live_stash": {:hex, :live_stash, "1.0.1", "26f46749b2b7d6484ff653b15d616b79e85b99891b3217e4584e54e88163fb98", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: true]}, {:memento, "~> 0.5.0", [hex: :memento, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:redix, "~> 1.1", [hex: :redix, repo: "hexpm", optional: true]}, {:uniq, "~> 0.6", [hex: :uniq, repo: "hexpm", optional: false]}], "hexpm", "3767556fce36c2645479d8807a1d4992e5c2261f0b4b44e58d8a88e5bd313a47"},
"live_table": {:hex, :live_table, "0.4.2", "9c011a8a078db3b86a2e77afe141d201d641512a1a9f421b62c4e14c41a4a5a9", [:mix], [{:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, "~> 0.7.0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.3", [hex: :nimble_csv, repo: "hexpm", optional: false]}, {:oban, "~> 2.20", [hex: :oban, repo: "hexpm", optional: false]}, {:oban_web, "~> 2.11", [hex: :oban_web, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.3", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.2", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:sutra_ui, "~> 0.4.0", [hex: :sutra_ui, repo: "hexpm", optional: true]}], "hexpm", "7c00c8469a974ff6abe79e34a940eba1f813d95f05360713313ece9e793f6c38"},
"logger_json": {:hex, :logger_json, "7.0.4", "e315f2b9a755504658a745f3eab90d88d2cd7ac2ecfd08c8da94d8893965ab5c", [:mix], [{:decimal, ">= 0.0.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:ecto, "~> 3.11", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "d1369f8094e372db45d50672c3b91e8888bcd695fdc444a37a0734e96717c45c"},
"mdex": {:hex, :mdex, "0.13.4", "9e42874ee4e9f8be110a5c03a91f838068c1b089bc0c586243d83503b153a2b1", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: true]}, {:mdex_native, ">= 0.2.6", [hex: :mdex_native, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}], "hexpm", "915e579df919923c0685e6ea9851cf6e4a346d014795f03ce5a1a9c52455192b"},
"mdex_native": {:hex, :mdex_native, "0.2.6", "16c0d9ffca05990982ef8113c0a61950ffc250bf0792e17b95b28b74e1f572dc", [:mix], [{:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "eaaf2af27162c9879f5aef66344101dadf1898e71ea6ac9eb811c8d92d8e49ad"},
"mdex": {:hex, :mdex, "0.13.5", "c1c94d230ccaab01ad0c68090d3b31613c10ece1844f32b55895da4ce0c63029", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: true]}, {:mdex_native, ">= 0.2.6", [hex: :mdex_native, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.0 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}], "hexpm", "c57409fb6b34fbc58fbce0a6da670c9a4b5a2e94f86abdc56e9e213ed74620f2"},
"mdex_native": {:hex, :mdex_native, "0.2.7", "46203ee9c4fe2c94feef26e4d976235ad022b4770d3d307fa6b445d293b2571f", [:mix], [{:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "05efa68775644ef6be6109f76c1f5f8778ff7e7ed9fe4a03541758158172bc77"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.9.3", "3337184d69179695c7a9f1714d92c11e629d36c8c037a21cf490131d3d150554", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5f7c9342480c069dbbc4eeac3490303c9e01870ff01a7f1d29b6107054fc1e74"},
"mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"},
@ -49,7 +49,7 @@
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"nx": {:hex, :nx, "0.13.0", "43c57f529f17139821f6ed01a8aba6e045fef6722700c928cec2e2e3d2ee9560", [:mix], [{:complex, "~> 0.7", [hex: :complex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "38d2f37ee7e2f3aaa86b96f125643ffbca56a89576cd24d83b452597397b4fbd"},
"oban": {:hex, :oban, "2.23.0", "1867d0fa4e8c7685217b02cc2632e3ee86c93da770e9029ff71304d9e62e53d7", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8e5f0cec5abecce78dd08cb14dc5438db90ec3884987b44773ce76fe60dd3f81"},
"oban": {:hex, :oban, "2.23.1", "0b9495e28a236ca0478c80d666c58a8b2b55182731c3603c6e766e298a08342a", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a9855b9f5d87e31de3e2f46731b163f372e329613892a56c5b2aacceb50ed508"},
"octo_fetch": {:hex, :octo_fetch, "0.5.0", "f50701568b9fc752656367f82cc134d5fbefff37c5a0e8ddfcceb02ceee3f5fc", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "6226cc3c14ca948ee9f25fb0446322e5c288e215da9beba7899b6b5f4cd3ccb0"},
"owl": {:hex, :owl, "0.13.1", "1ec4a5dea170465f0e90c502c203079224516bc0cbd599281c8667b3c6ef8848", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "351e768af8f2edc575cdaab1a5a2f6d6381be591758a026c701c703145508a0c"},
"peep": {:hex, :peep, "4.4.0", "4b6289e7258ecf2741041068932455eb8389673bca785623621ddb6935286845", [:mix], [{:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:plug, "~> 1.16", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "f289959a9e5fcf92f5a4becaf4dd0df95c58841368f6ad0574b8ea830a7c1453"},
@ -57,7 +57,7 @@
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.7.0", "fb1e429f6d8778ce3a6962debdc5e555428a05a6e7b058d6dbad13d281a2c31f", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "dc9f44271aa6fc4ab7797f2aa374ba096ef2c87520586280eb095626b7387a68"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.2.8", "5006fd7b429c42489600fbc1600c750d0f0e5b5ea4965d9758e429b979392991", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b05ffe21f43c0ff219da62948b482c324aa5b8873e17b0c0cac58289a178af38"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_pubsub_redis": {:hex, :phoenix_pubsub_redis, "3.1.1", "54919406b0b6401e0f643e26fd3b814bc3460f3fd3a32aa9c3a473db89e9dca7", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5.1 or ~> 1.6", [hex: :poolboy, repo: "hexpm", optional: false]}, {:redix, "~> 1.0", [hex: :redix, repo: "hexpm", optional: false]}], "hexpm", "e339963f649e7cf52bd352e55fab7bebb42b65b501e43499dd6eb56890703c32"},
@ -68,19 +68,19 @@
"poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"},
"postgrex": {:hex, :postgrex, "0.22.3", "bf65941737ee7a9adbe4a64c91080310d11703da343e8ac9188aacb9eb9f6f02", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "f018c13752b2b46e8d35d7e2d84c3276557cbfd880769109021a1d0ee36c1cfe"},
"prom_ex": {:hex, :prom_ex, "1.12.0", "a82cbd5b49964e4d4295ab72a18e007aadd5f4a91630b7c49fad42cc7e49c880", [:mix], [{:absinthe, ">= 1.8.0", [hex: :absinthe, repo: "hexpm", optional: true]}, {:broadway, ">= 1.1.0", [hex: :broadway, repo: "hexpm", optional: true]}, {:ecto, ">= 3.14.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:finch, "~> 0.18", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, ">= 2.10.0", [hex: :oban, repo: "hexpm", optional: true]}, {:octo_fetch, "~> 0.4", [hex: :octo_fetch, repo: "hexpm", optional: false]}, {:peep, "~> 3.0 or ~> 4.0", [hex: :peep, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.7.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, ">= 0.20.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, ">= 1.16.0", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 2.6.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, ">= 1.0.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}, {:telemetry_metrics_prometheus_core, "~> 1.2", [hex: :telemetry_metrics_prometheus_core, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 1.1", [hex: :telemetry_poller, repo: "hexpm", optional: false]}], "hexpm", "6357484941489ba2fee64bb1e9f2a1e86309545304f9e90144e3c10e553c958b"},
"ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"},
"ranch": {:hex, :ranch, "2.2.1", "fc2bb0e800efbeab8ff82eb325450b9c08653b14c3f69e8b09bbea304973105d", [:make, :rebar3], [], "hexpm", "55f05cce20ec2da1d90de5d5981afb93dbfc01325fc7e933189aa5c62f037c24"},
"redix": {:hex, :redix, "1.6.0", "694179c7a3c71bffac8848fcbfe16f6f6e2a1e020f00a2ad01bc6484815470d1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b2eccb05e02f21c0c3ca57513e6bacb4dd48e6406dadbd7ff9fbe07bd6745999"},
"req": {:hex, :req, "0.7.2", "364eae2e5f5c984f2dac6d71c07f8c8c89ce0bc49c4d746dacb7a306823020de", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "c9cdfa276b05d8db2a27fda5d233e6858b764d47189d76cbb186e130a871ae0b"},
"rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"},
"rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"},
"sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"},
"sourceror": {:hex, :sourceror, "1.12.2", "85bfd48159f020c0cbfc72f289f11456fdc05dc43719b6f2589fb969faefa113", [:mix], [], "hexpm", "da37d3da09c5b890528802c7056a8f585a061973820d7656b6e3649c14f0e9cb"},
"spitfire": {:hex, :spitfire, "0.3.13", "edd207b065eaec57acc5484097d0aa3e97fe4246168c54e67a9af040b8dee4c1", [:mix], [], "hexpm", "3601be88ceed4967b584e96444de3e1d12d6555ae0864a7390b9cd5332d134b4"},
"spitfire": {:hex, :spitfire, "0.4.0", "6d98c10cf585434b9439ba0c6dd3cc7aeff0e06ab73bfe5488f42e7c0f883d9b", [:mix], [], "hexpm", "7e5c6d1523c111b59f332f9dc49edc0377111d0c17167a29830f0e98233f5472"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
"stream_data": {:hex, :stream_data, "1.4.0", "026f929db613aabea6208012ae9b8970d3fd5f88b3bdf26831bc536f98c42036", [:mix], [], "hexpm", "2b0ee3a340dcce1c8cf6302a763ee757d1e01c54d6e16d9069062509d68b1dc9"},
"styler": {:hex, :styler, "1.11.0", "35010d970689a23c2bcc8e97bd8bf7d20e3561d60c49be84654df5c37d051a9c", [:mix], [], "hexpm", "70f36165d0cf238a32b7a456fdef6a9c72e77e657d7ac4a0ace33aeba3f2b8c0"},
"styler": {:hex, :styler, "1.12.2", "1aa2942295645df48d42365f7191cc8b9f6fc4c35d15491f6fbd80ee815256b0", [:mix], [], "hexpm", "a82baeb6d81e35ee1c4cf8af08e141e8d7872a1d442f6fefce29817869e77a01"},
"sutra_ui": {:hex, :sutra_ui, "0.4.0", "ef348fe496af1a311193c35aa782c4c0256894a80c623a3c0dd13c759e921344", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mdex, "~> 0.13", [hex: :mdex, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.2", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "857a9fa7b182ca8105d6bedeaffa1e16beeb296807ca60f7f6e45ae96b41fcd7"},
"swoosh": {:hex, :swoosh, "1.26.3", "9d8b60077305ce259298d9a1102e5be67cd3c41d1ea930c29e9288af195ca017", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, ">= 1.9.0 and < 5.0.0", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, ">= 6.0.0 and < 8.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c7683d070fe8f8aa9d174e61b01f2d527be73cd8ac40037b7109184941eb569f"},
"swoosh": {:hex, :swoosh, "1.27.0", "2df57c342f3854f2d429e6afebf19a03a179976a64006a93750ed81639453f75", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, ">= 1.9.0 and < 5.0.0", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, ">= 6.0.0 and < 8.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5da7d3b11de5d61327275ed599cb311942ea6e23cdbb411981f46ee150cddf76"},
"table_rex": {:hex, :table_rex, "4.1.0", "fbaa8b1ce154c9772012bf445bfb86b587430fb96f3b12022d3f35ee4a68c918", [:mix], [], "hexpm", "95932701df195d43bc2d1c6531178fc8338aa8f38c80f098504d529c43bc2601"},
"tailwind": {:hex, :tailwind, "0.5.1", "35435b13158c90d37da11e1cfc808755fca1d7b6c5ab87b1b19c5de87e2f0a10", [:mix], [], "hexpm", "c4e26302a59fec72abc5610ecb6ad2116d9aa31f31aab2d4b8eb6e95d25a689c"},
"telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"},
@ -89,7 +89,7 @@
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
"thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"},
"tidewave": {:hex, :tidewave, "0.8.0", "eb41df8d0b967a6ead2f48d6ccded369c17207058bb2cf7128c33562ad3d90f9", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.9", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "91a2007d021722d7ab3629f9024b5375819c827952f3a5ac19840ac528261532"},
"tidewave": {:hex, :tidewave, "0.8.1", "d69ba6c131886d4b8d0b1337f1e910f7202d91d3adaff835e67f67b2dae67b30", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.9", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "557b0f4e27c55b001506b3c3d3a904af04e6ca7914284ab654c91c9cecb3cbf1"},
"uniq": {:hex, :uniq, "0.6.3", "68acff834cce1817b52928ef346662735c5413a4fec9c3b0d4a9126de5b2b489", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "2b2a900d0a20f3a55d3de0bc8150495e4a71255734dfb23889991bda5aca6c7d"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"},

View file

@ -40,7 +40,7 @@ defmodule Microwaveprop.Accounts.UserApiTokenTest do
end
test "expires_at must be in the future", %{user: user} do
past = DateTime.add(DateTime.utc_now(), -60, :second)
past = DateTime.shift(DateTime.utc_now(), minute: -1)
assert {:error, changeset} =
UserApiToken.build(user, %{name: "n", expires_at: past})
@ -49,7 +49,7 @@ defmodule Microwaveprop.Accounts.UserApiTokenTest do
end
test "expires_at in the future is accepted", %{user: user} do
future = DateTime.add(DateTime.utc_now(), 3600, :second)
future = DateTime.shift(DateTime.utc_now(), hour: 1)
assert {:ok, {_plaintext, changeset}} =
UserApiToken.build(user, %{name: "n", expires_at: future})

View file

@ -23,7 +23,7 @@ defmodule Microwaveprop.Accounts.UserTokenTest do
test "uses the user's authenticated_at when present" do
user = user_fixture()
dt = DateTime.add(DateTime.utc_now(:second), -30, :minute)
dt = DateTime.shift(DateTime.utc_now(:second), minute: -30)
user = %{user | authenticated_at: dt}
{_token, user_token} = UserToken.build_session_token(user)
@ -154,7 +154,7 @@ defmodule Microwaveprop.Accounts.UserTokenTest do
# Magic link validity is 15 minutes.
Repo.update_all(
from(t in UserToken, where: t.token == ^raw),
set: [inserted_at: DateTime.add(DateTime.utc_now(:second), -16, :minute)]
set: [inserted_at: DateTime.shift(DateTime.utc_now(:second), minute: -16)]
)
{:ok, query} = UserToken.verify_magic_link_token_query(encoded)
@ -205,7 +205,7 @@ defmodule Microwaveprop.Accounts.UserTokenTest do
test "returns nil when the token is older than 1 day", %{encoded: encoded, raw_token: raw} do
Repo.update_all(
from(t in UserToken, where: t.token == ^raw),
set: [inserted_at: DateTime.add(DateTime.utc_now(:second), -2, :day)]
set: [inserted_at: DateTime.shift(DateTime.utc_now(:second), day: -2)]
)
{:ok, query} = UserToken.verify_confirm_token_query(encoded)
@ -278,7 +278,7 @@ defmodule Microwaveprop.Accounts.UserTokenTest do
} do
Repo.update_all(
from(t in UserToken, where: t.token == ^raw),
set: [inserted_at: DateTime.add(DateTime.utc_now(:second), -8, :day)]
set: [inserted_at: DateTime.shift(DateTime.utc_now(:second), day: -8)]
)
{:ok, query} = UserToken.verify_change_email_token_query(encoded, context)

View file

@ -26,7 +26,7 @@ defmodule Microwaveprop.AccountsApiTokenTest do
end
test "rejects expires_at in the past", %{user: user} do
past = DateTime.add(DateTime.utc_now(), -1, :second)
past = DateTime.shift(DateTime.utc_now(), second: -1)
assert {:error, changeset} =
Accounts.create_api_token(user, %{name: "x", expires_at: past})
@ -75,13 +75,13 @@ defmodule Microwaveprop.AccountsApiTokenTest do
end
test "rejects expired tokens", %{user: user} do
future = DateTime.add(DateTime.utc_now(), 60, :second)
future = DateTime.shift(DateTime.utc_now(), minute: 1)
{:ok, {plaintext, record}} = Accounts.create_api_token(user, %{name: "A", expires_at: future})
# Force the row past its expiry.
Repo.update_all(
from(t in UserApiToken, where: t.id == ^record.id),
set: [expires_at: DateTime.utc_now() |> DateTime.add(-1, :second) |> DateTime.truncate(:second)]
set: [expires_at: DateTime.utc_now() |> DateTime.shift(second: -1) |> DateTime.truncate(:second)]
)
assert {:error, :invalid_token} = Accounts.get_user_by_api_token(plaintext)

View file

@ -347,12 +347,12 @@ defmodule Microwaveprop.AccountsTest do
now = DateTime.utc_now()
assert Accounts.sudo_mode?(%User{authenticated_at: DateTime.utc_now()})
assert Accounts.sudo_mode?(%User{authenticated_at: DateTime.add(now, -19, :minute)})
refute Accounts.sudo_mode?(%User{authenticated_at: DateTime.add(now, -21, :minute)})
assert Accounts.sudo_mode?(%User{authenticated_at: DateTime.shift(now, minute: -19)})
refute Accounts.sudo_mode?(%User{authenticated_at: DateTime.shift(now, minute: -21)})
# minute override
refute Accounts.sudo_mode?(
%User{authenticated_at: DateTime.add(now, -11, :minute)},
%User{authenticated_at: DateTime.shift(now, minute: -11)},
-10
)
@ -529,7 +529,7 @@ defmodule Microwaveprop.AccountsTest do
end
test "duplicates the authenticated_at of given user in new token", %{user: user} do
user = %{user | authenticated_at: DateTime.add(DateTime.utc_now(:second), -3600)}
user = %{user | authenticated_at: DateTime.shift(DateTime.utc_now(:second), hour: -1)}
token = Accounts.generate_user_session_token(user)
assert user_token = Repo.get_by(UserToken, token: token)
assert user_token.authenticated_at == user.authenticated_at

View file

@ -49,7 +49,7 @@ defmodule Microwaveprop.BeaconMonitorsTest do
# inserts can land in the same wall-clock second.
{:ok, first} = BeaconMonitors.create_monitor(user, %{"name" => "First"})
backdated = DateTime.add(first.inserted_at, -1, :second)
backdated = DateTime.shift(first.inserted_at, second: -1)
{1, _} =
Microwaveprop.Repo.update_all(

View file

@ -102,7 +102,7 @@ defmodule Microwaveprop.CommercialTest do
{:ok, _} =
Commercial.create_sample(%{
link_id: link.id,
sampled_at: DateTime.add(valid_time, -day_offset * 3600 * 24, :second),
sampled_at: DateTime.shift(valid_time, day: -day_offset),
rx_power_0: -52.0,
rx_power_1: -52.0,
link_state: 1
@ -113,7 +113,7 @@ defmodule Microwaveprop.CommercialTest do
{:ok, _} =
Commercial.create_sample(%{
link_id: link.id,
sampled_at: DateTime.add(valid_time, -300, :second),
sampled_at: DateTime.shift(valid_time, minute: -5),
rx_power_0: -62.0,
rx_power_1: -62.0,
link_state: 1
@ -134,7 +134,7 @@ defmodule Microwaveprop.CommercialTest do
{:ok, _} =
Commercial.create_sample(%{
link_id: link.id,
sampled_at: DateTime.add(valid_time, -day_offset * 3600 * 24, :second),
sampled_at: DateTime.shift(valid_time, day: -day_offset),
rx_power_0: -55.0,
link_state: 1
})
@ -143,7 +143,7 @@ defmodule Microwaveprop.CommercialTest do
{:ok, _} =
Commercial.create_sample(%{
link_id: link.id,
sampled_at: DateTime.add(valid_time, -60, :second),
sampled_at: DateTime.shift(valid_time, minute: -1),
rx_power_0: -55.0,
link_state: 1
})
@ -163,7 +163,7 @@ defmodule Microwaveprop.CommercialTest do
{:ok, _} =
Commercial.create_sample(%{
link_id: link.id,
sampled_at: DateTime.add(valid_time, -day_offset * 3600 * 24, :second),
sampled_at: DateTime.shift(valid_time, day: -day_offset),
rx_power_0: -52.0,
link_state: 1
})
@ -172,7 +172,7 @@ defmodule Microwaveprop.CommercialTest do
{:ok, _} =
Commercial.create_sample(%{
link_id: link.id,
sampled_at: DateTime.add(valid_time, -120, :second),
sampled_at: DateTime.shift(valid_time, minute: -2),
rx_power_0: -90.0,
link_state: 0
})

View file

@ -65,8 +65,8 @@ defmodule Microwaveprop.MoonTest do
time_zone: "Etc/UTC"
}
a = DateTime.add(base, day_offset * 86_400, :second)
b = DateTime.add(a, delta_hours * 3600, :second)
a = DateTime.shift(base, day: day_offset)
b = DateTime.shift(a, hour: delta_hours)
assert Moon.julian_day(a) < Moon.julian_day(b)
end
@ -104,7 +104,7 @@ defmodule Microwaveprop.MoonTest do
property "distance, RA, and declination all stay in their physical envelopes for any UTC across the next century" do
check all(seconds_from_now <- integer(0..3_155_760_000)) do
dt = DateTime.add(~U[2024-01-01 00:00:00Z], seconds_from_now, :second)
dt = DateTime.shift(~U[2024-01-01 00:00:00Z], second: seconds_from_now)
g = Moon.geocentric(dt)
assert g.distance_km > 340_000 and g.distance_km < 420_000
@ -143,7 +143,7 @@ defmodule Microwaveprop.MoonTest do
lat <- float(min: -85.0, max: 85.0),
lon <- float(min: -179.9, max: 179.9)
) do
dt = DateTime.add(~U[2024-01-01 00:00:00Z], seconds, :second)
dt = DateTime.shift(~U[2024-01-01 00:00:00Z], second: seconds)
p = Moon.observer_position(dt, lat, lon)
assert p.azimuth >= 0.0 and p.azimuth < 360.0
assert p.elevation >= -90.0 and p.elevation <= 90.0
@ -178,7 +178,7 @@ defmodule Microwaveprop.MoonTest do
peaks =
for hour_offset <- 0..48 do
dt = DateTime.add(base, hour_offset * 3600, :second)
dt = DateTime.shift(base, hour: hour_offset)
{dt, Moon.observer_position(dt, lat, lon).elevation}
end

View file

@ -35,7 +35,7 @@ defmodule Microwaveprop.Propagation.FreshnessMonitorTest do
defp minutes_ago(n) do
DateTime.utc_now()
|> DateTime.add(-n, :minute)
|> DateTime.shift(minute: -n)
|> DateTime.truncate(:second)
end

View file

@ -123,7 +123,7 @@ defmodule Microwaveprop.Propagation.GridTaskEnqueuerTest do
describe "reclaim_stale_running/1" do
test "flips stale-running rows back to queued and clears claimed_at" do
now = DateTime.truncate(DateTime.utc_now(), :second)
stale = DateTime.add(now, -30 * 60, :second)
stale = DateTime.shift(now, minute: -30)
insert_task(%{
run_time: ~U[2026-04-19 20:00:00Z],
@ -152,7 +152,7 @@ defmodule Microwaveprop.Propagation.GridTaskEnqueuerTest do
test "leaves recently-claimed running rows untouched" do
now = DateTime.truncate(DateTime.utc_now(), :second)
fresh = DateTime.add(now, -60, :second)
fresh = DateTime.shift(now, minute: -1)
insert_task(%{
run_time: ~U[2026-04-19 21:00:00Z],
@ -178,7 +178,7 @@ defmodule Microwaveprop.Propagation.GridTaskEnqueuerTest do
test "flips stale rows past max attempts to failed" do
now = DateTime.truncate(DateTime.utc_now(), :second)
stale = DateTime.add(now, -30 * 60, :second)
stale = DateTime.shift(now, minute: -30)
insert_task(%{
run_time: ~U[2026-04-19 22:00:00Z],
@ -206,7 +206,7 @@ defmodule Microwaveprop.Propagation.GridTaskEnqueuerTest do
test "does not touch queued or done rows even if claimed_at is ancient" do
now = DateTime.truncate(DateTime.utc_now(), :second)
stale = DateTime.add(now, -30 * 60, :second)
stale = DateTime.shift(now, minute: -30)
insert_task(%{
run_time: ~U[2026-04-19 23:00:00Z],
@ -226,7 +226,7 @@ defmodule Microwaveprop.Propagation.GridTaskEnqueuerTest do
test "seeds exactly one forecast row at the hour offset closest to now" do
# run_time 5h before "now" → fh should be 5.
now = DateTime.utc_now() |> DateTime.truncate(:second) |> Map.put(:minute, 0) |> Map.put(:second, 0)
run_time = DateTime.add(now, -5 * 3600, :second)
run_time = DateTime.shift(now, hour: -5)
assert {:ok, 1} = GridTaskEnqueuer.seed_current_hour(run_time, source: "hrdps")
@ -247,7 +247,7 @@ defmodule Microwaveprop.Propagation.GridTaskEnqueuerTest do
test "is idempotent on the (run_time, fh, kind, source) key" do
now = DateTime.utc_now() |> DateTime.truncate(:second) |> Map.put(:minute, 0) |> Map.put(:second, 0)
run_time = DateTime.add(now, -3 * 3600, :second)
run_time = DateTime.shift(now, hour: -3)
assert {:ok, 1} = GridTaskEnqueuer.seed_current_hour(run_time, source: "hrdps")
assert {:ok, 0} = GridTaskEnqueuer.seed_current_hour(run_time, source: "hrdps")
@ -268,7 +268,7 @@ defmodule Microwaveprop.Propagation.GridTaskEnqueuerTest do
# negative. Clamp pulls it to 1 so the F00Reserved branch in Rust
# is never hit through this path.
now = DateTime.utc_now() |> DateTime.truncate(:second) |> Map.put(:minute, 0) |> Map.put(:second, 0)
run_time = DateTime.add(now, 3600, :second)
run_time = DateTime.shift(now, hour: 1)
assert {:ok, 1} = GridTaskEnqueuer.seed_current_hour(run_time, source: "hrdps")

View file

@ -90,7 +90,7 @@ defmodule Microwaveprop.Propagation.NotifyListenerTest do
test "retains GEFS score files through 168 hours" do
run_time = ~U[2026-04-21 00:00:00Z]
extended_time = DateTime.add(run_time, 168, :hour)
extended_time = DateTime.shift(run_time, week: 1)
ScoresFile.write_gefs!(10_000, extended_time, sample_scores())
{:ok, _task_pid} = NotifyListener.handle_propagation_ready(run_time, run_time)

View file

@ -30,7 +30,7 @@ defmodule Microwaveprop.Propagation.PipelineStatusTest do
end
defp minutes_ago(n) do
DateTime.add(DateTime.utc_now(), -n * 60, :second)
DateTime.shift(DateTime.utc_now(), minute: -n)
end
describe "current/0" do
@ -183,24 +183,24 @@ defmodule Microwaveprop.Propagation.PipelineStatusTest do
test "valid_time within half an hour of now renders as 'through now'" do
now = DateTime.utc_now()
assert PipelineStatus.running_detail(%{valid_time: now}) == "through now"
assert PipelineStatus.running_detail(%{valid_time: DateTime.add(now, 20 * 60, :second)}) == "through now"
assert PipelineStatus.running_detail(%{valid_time: DateTime.add(now, -20 * 60, :second)}) == "through now"
assert PipelineStatus.running_detail(%{valid_time: DateTime.shift(now, minute: 20)}) == "through now"
assert PipelineStatus.running_detail(%{valid_time: DateTime.shift(now, minute: -20)}) == "through now"
end
test "valid_time in the future renders as 'through +Nh'" do
now = DateTime.utc_now()
assert PipelineStatus.running_detail(%{valid_time: DateTime.add(now, 3 * 3600, :second)}) ==
assert PipelineStatus.running_detail(%{valid_time: DateTime.shift(now, hour: 3)}) ==
"through +3h"
assert PipelineStatus.running_detail(%{valid_time: DateTime.add(now, 12 * 3600, :second)}) ==
assert PipelineStatus.running_detail(%{valid_time: DateTime.shift(now, hour: 12)}) ==
"through +12h"
end
test "valid_time in the past renders as 'through Nh ago'" do
now = DateTime.utc_now()
assert PipelineStatus.running_detail(%{valid_time: DateTime.add(now, -2 * 3600, :second)}) ==
assert PipelineStatus.running_detail(%{valid_time: DateTime.shift(now, hour: -2)}) ==
"through 2h ago"
end

View file

@ -236,7 +236,7 @@ defmodule Microwaveprop.Propagation.ScoreCacheTest do
# Insert cap+1 entries, each at a distinct valid_time, oldest first.
for hour <- 0..cap do
ScoreCache.put(10_000, DateTime.add(base, hour * 3600, :second), score)
ScoreCache.put(10_000, DateTime.shift(base, hour: hour), score)
end
# Size never exceeds the cap.
@ -246,7 +246,7 @@ defmodule Microwaveprop.Propagation.ScoreCacheTest do
assert ScoreCache.fetch(10_000, base) == :miss
# The newest is retained.
newest = DateTime.add(base, cap * 3600, :second)
newest = DateTime.shift(base, hour: cap)
assert {:ok, [%{score: 1}]} = ScoreCache.fetch(10_000, newest)
end
@ -257,14 +257,14 @@ defmodule Microwaveprop.Propagation.ScoreCacheTest do
# Fill cap entries on band A at older valid_times.
for hour <- 0..(cap - 1) do
ScoreCache.put(10_000, DateTime.add(base, hour * 3600, :second), score)
ScoreCache.put(10_000, DateTime.shift(base, hour: hour), score)
end
assert :ets.info(:propagation_score_cache, :size) == cap
# One more on band B at a newer valid_time should evict the oldest
# band-A entry, not the new band-B one.
newer = DateTime.add(base, cap * 3600, :second)
newer = DateTime.shift(base, hour: cap)
ScoreCache.put(24_000, newer, score)
assert :ets.info(:propagation_score_cache, :size) == cap

View file

@ -351,8 +351,8 @@ defmodule Microwaveprop.Propagation.ScoresFileTest do
describe "retain_window/2" do
test "recognizes GEFS files through the extended horizon" do
run_time = ~U[2026-04-14 00:00:00Z]
inside = DateTime.add(run_time, 168, :hour)
outside = DateTime.add(run_time, 174, :hour)
inside = DateTime.shift(run_time, week: 1)
outside = DateTime.shift(run_time, hour: 174)
ScoresFile.write_gefs!(10_000, inside, [])
ScoresFile.write_gefs!(10_000, outside, [])

View file

@ -405,7 +405,7 @@ defmodule Microwaveprop.PropagationTest do
end
test "returns empty factors when the only persisted profile is older than the 24h lookback" do
ancient = DateTime.add(~U[2026-07-15 13:00:00Z], -30 * 3600, :second)
ancient = DateTime.shift(~U[2026-07-15 13:00:00Z], hour: -30)
forecast_time = ~U[2026-07-15 13:00:00Z]
lat = 32.75
lon = -97.125
@ -425,7 +425,7 @@ defmodule Microwaveprop.PropagationTest do
end
test "accepts a profile exactly at the 24h lookback boundary" do
fallback_time = DateTime.add(~U[2026-07-15 13:00:00Z], -24 * 3600, :second)
fallback_time = DateTime.shift(~U[2026-07-15 13:00:00Z], day: -1)
forecast_time = ~U[2026-07-15 13:00:00Z]
lat = 32.75
lon = -97.125
@ -708,8 +708,8 @@ defmodule Microwaveprop.PropagationTest do
end
test "returns forecast timeline from cached scores when warm" do
t1 = DateTime.truncate(DateTime.add(DateTime.utc_now(), 3600, :second), :second)
t2 = DateTime.add(t1, 3600, :second)
t1 = DateTime.truncate(DateTime.shift(DateTime.utc_now(), hour: 1), :second)
t2 = DateTime.shift(t1, hour: 1)
seed_point(10_000, t1, 32.875, -97.0, 70)
seed_point(10_000, t2, 32.875, -97.0, 75)
@ -725,9 +725,9 @@ defmodule Microwaveprop.PropagationTest do
test "filters valid_times older than one hour but keeps recent past as the 'now' anchor" do
# Two hours old: dropped. Thirty minutes old: kept as the
# leftmost "now" point. One hour future: kept.
stale = DateTime.utc_now() |> DateTime.add(-2 * 3600, :second) |> DateTime.truncate(:second)
recent = DateTime.utc_now() |> DateTime.add(-1800, :second) |> DateTime.truncate(:second)
future = DateTime.utc_now() |> DateTime.add(3600, :second) |> DateTime.truncate(:second)
stale = DateTime.utc_now() |> DateTime.shift(hour: -2) |> DateTime.truncate(:second)
recent = DateTime.utc_now() |> DateTime.shift(minute: -30) |> DateTime.truncate(:second)
future = DateTime.utc_now() |> DateTime.shift(hour: 1) |> DateTime.truncate(:second)
seed_point(10_000, stale, 32.875, -97.0, 40)
seed_point(10_000, recent, 32.875, -97.0, 60)
@ -745,8 +745,8 @@ defmodule Microwaveprop.PropagationTest do
# HRRR just cut out (nothing fresh); we still want the chart to
# render one point at the newest time we have, matching
# available_valid_times semantics.
older = DateTime.utc_now() |> DateTime.add(-6 * 3600, :second) |> DateTime.truncate(:second)
newer = DateTime.utc_now() |> DateTime.add(-3 * 3600, :second) |> DateTime.truncate(:second)
older = DateTime.utc_now() |> DateTime.shift(hour: -6) |> DateTime.truncate(:second)
newer = DateTime.utc_now() |> DateTime.shift(hour: -3) |> DateTime.truncate(:second)
seed_point(10_000, older, 32.875, -97.0, 30)
seed_point(10_000, newer, 32.875, -97.0, 45)
@ -756,7 +756,7 @@ defmodule Microwaveprop.PropagationTest do
end
test "snaps coordinates to the nearest grid point" do
valid_time = DateTime.truncate(DateTime.add(DateTime.utc_now(), 3600, :second), :second)
valid_time = DateTime.truncate(DateTime.shift(DateTime.utc_now(), hour: 1), :second)
seed_point(10_000, valid_time, 32.875, -97.0, 65)
# Off-grid query snaps to 32.875, -97.0
@ -775,8 +775,8 @@ defmodule Microwaveprop.PropagationTest do
# hour has just landed on disk. The forecast chart should include
# that third hour so it matches the main-map timeline.
t1 = DateTime.utc_now() |> DateTime.truncate(:second) |> Map.merge(%{minute: 0, second: 0})
t2 = DateTime.add(t1, 3600, :second)
t3 = DateTime.add(t1, 2 * 3600, :second)
t2 = DateTime.shift(t1, hour: 1)
t3 = DateTime.shift(t1, hour: 2)
for {t, score} <- [{t1, 50}, {t2, 60}, {t3, 70}] do
Propagation.replace_scores(
@ -801,9 +801,9 @@ defmodule Microwaveprop.PropagationTest do
test "includes recent past valid_times and falls back to the latest when stale" do
# Three .prop files on disk: one 2h old (dropped by cutoff),
# one 30min old (kept as "now"), one 1h future (kept).
stale = DateTime.utc_now() |> DateTime.add(-2 * 3600, :second) |> DateTime.truncate(:second)
recent = DateTime.utc_now() |> DateTime.add(-1800, :second) |> DateTime.truncate(:second)
future = DateTime.utc_now() |> DateTime.add(3600, :second) |> DateTime.truncate(:second)
stale = DateTime.utc_now() |> DateTime.shift(hour: -2) |> DateTime.truncate(:second)
recent = DateTime.utc_now() |> DateTime.shift(minute: -30) |> DateTime.truncate(:second)
future = DateTime.utc_now() |> DateTime.shift(hour: 1) |> DateTime.truncate(:second)
Enum.each([{stale, 40}, {recent, 60}, {future, 80}], fn {t, score} ->
Propagation.replace_scores(
@ -824,8 +824,8 @@ defmodule Microwaveprop.PropagationTest do
end
test "returns just the latest store entry when every hour is stale" do
older = DateTime.utc_now() |> DateTime.add(-6 * 3600, :second) |> DateTime.truncate(:second)
newer = DateTime.utc_now() |> DateTime.add(-3 * 3600, :second) |> DateTime.truncate(:second)
older = DateTime.utc_now() |> DateTime.shift(hour: -6) |> DateTime.truncate(:second)
newer = DateTime.utc_now() |> DateTime.shift(hour: -3) |> DateTime.truncate(:second)
Enum.each([{older, 30}, {newer, 45}], fn {t, score} ->
Propagation.replace_scores(
@ -856,8 +856,8 @@ defmodule Microwaveprop.PropagationTest do
test "caps the forward horizon to 48 hours so stale cycles don't clutter the timeline" do
now = DateTime.truncate(DateTime.utc_now(), :second)
in_range = DateTime.add(now, 3600, :second)
past_hrrr_horizon = DateTime.add(now, 55 * 3600, :second)
in_range = DateTime.shift(now, hour: 1)
past_hrrr_horizon = DateTime.shift(now, hour: 55)
Enum.each([{in_range, 70}, {past_hrrr_horizon, 50}], fn {t, score} ->
Propagation.replace_scores(
@ -873,8 +873,8 @@ defmodule Microwaveprop.PropagationTest do
test "filters valid_times older than the 1-hour cutoff" do
now = DateTime.truncate(DateTime.utc_now(), :second)
stale = DateTime.add(now, -7200, :second)
fresh = DateTime.add(now, 1800, :second)
stale = DateTime.shift(now, hour: -2)
fresh = DateTime.shift(now, minute: 30)
Propagation.replace_scores(
[%{lat: 25.0, lon: -125.0, valid_time: stale, band_mhz: 10_000, score: 50, factors: nil}],
@ -1039,7 +1039,7 @@ defmodule Microwaveprop.PropagationTest do
Propagation.record_run_timing(%{
run_time: run_time,
forecast_hour: 3,
valid_time: DateTime.add(run_time, 3, :hour),
valid_time: DateTime.shift(run_time, hour: 3),
started_at: started,
finished_at: finished,
duration_ms: 555_000,
@ -1061,7 +1061,7 @@ defmodule Microwaveprop.PropagationTest do
Propagation.record_run_timing(%{
run_time: run_time,
forecast_hour: 5,
valid_time: DateTime.add(run_time, 5, :hour),
valid_time: DateTime.shift(run_time, hour: 5),
started_at: ~U[2026-07-15 12:00:00Z],
finished_at: ~U[2026-07-15 12:02:30Z],
duration_ms: 150_000,
@ -1108,9 +1108,9 @@ defmodule Microwaveprop.PropagationTest do
Propagation.record_run_timing(%{
run_time: run_time,
forecast_hour: fh,
valid_time: DateTime.add(run_time, fh, :hour),
started_at: DateTime.add(run_time, fh * 600, :second),
finished_at: DateTime.add(run_time, fh * 600 + 500, :second),
valid_time: DateTime.shift(run_time, hour: fh),
started_at: DateTime.shift(run_time, minute: fh * 10),
finished_at: DateTime.shift(run_time, second: fh * 600 + 500),
duration_ms: 500_000,
status: :ok
})

View file

@ -77,7 +77,7 @@ defmodule Microwaveprop.QrzTest do
test "re-fetches when cache entry is stale" do
past =
DateTime.utc_now()
|> DateTime.add(-48, :hour)
|> DateTime.shift(day: -2)
|> DateTime.truncate(:second)
Repo.insert!(%Callsign{
@ -96,7 +96,7 @@ defmodule Microwaveprop.QrzTest do
test "upserts (updates existing record on re-fetch)" do
past =
DateTime.utc_now()
|> DateTime.add(-48, :hour)
|> DateTime.shift(day: -2)
|> DateTime.truncate(:second)
original =
@ -157,7 +157,7 @@ defmodule Microwaveprop.QrzTest do
test "returns record regardless of TTL" do
past =
DateTime.utc_now()
|> DateTime.add(-999, :hour)
|> DateTime.shift(hour: -999)
|> DateTime.truncate(:second)
Repo.insert!(%Callsign{

View file

@ -247,7 +247,7 @@ defmodule Microwaveprop.Radio.ContactSubmissionTest do
test "allows contact outside the 1-hour window" do
assert {:ok, _} = Radio.create_contact(@valid_attrs)
later = DateTime.add(@valid_attrs.qso_timestamp, 3601, :second)
later = DateTime.shift(@valid_attrs.qso_timestamp, second: 3_601)
assert {:ok, _} = Radio.create_contact(%{@valid_attrs | qso_timestamp: later})
end

View file

@ -2,36 +2,12 @@ defmodule Microwaveprop.RadioTest do
use Microwaveprop.DataCase, async: true
import Microwaveprop.AccountsFixtures
import Microwaveprop.ContactsFixtures
alias Microwaveprop.Accounts.Scope
alias Microwaveprop.Radio
alias Microwaveprop.Radio.Contact
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")
}
merged = Map.merge(default, attrs)
{user_id, changeset_attrs} = Map.pop(merged, :user_id)
{:ok, contact} =
%Contact{user_id: user_id}
|> Contact.changeset(changeset_attrs)
|> Repo.insert()
contact
end
describe "list_contacts/1" do
test "returns empty page when no QSOs exist" do
result = Radio.list_contacts()
@ -44,7 +20,7 @@ defmodule Microwaveprop.RadioTest do
test "paginates 20 per page, ordered by qso_timestamp desc" do
for i <- 1..25 do
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
ts = DateTime.shift(~U[2026-01-01 00:00:00Z], hour: i)
create_contact(%{qso_timestamp: ts})
end
@ -57,12 +33,12 @@ defmodule Microwaveprop.RadioTest do
# Most recent first
first = hd(result.entries)
assert first.qso_timestamp == DateTime.add(~U[2026-01-01 00:00:00Z], 25 * 3600, :second)
assert first.qso_timestamp == DateTime.shift(~U[2026-01-01 00:00:00Z], hour: 25)
end
test "returns second page" do
for i <- 1..25 do
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
ts = DateTime.shift(~U[2026-01-01 00:00:00Z], hour: i)
create_contact(%{qso_timestamp: ts})
end
@ -75,7 +51,7 @@ defmodule Microwaveprop.RadioTest do
test "honors per_page option" do
for i <- 1..25 do
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
ts = DateTime.shift(~U[2026-01-01 00:00:00Z], hour: i)
create_contact(%{qso_timestamp: ts})
end
@ -87,7 +63,7 @@ defmodule Microwaveprop.RadioTest do
test "clamps per_page to the maximum" do
for i <- 1..3 do
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
ts = DateTime.shift(~U[2026-01-01 00:00:00Z], hour: i)
create_contact(%{qso_timestamp: ts})
end
@ -98,7 +74,7 @@ defmodule Microwaveprop.RadioTest do
test "falls back to default when per_page is invalid" do
for i <- 1..25 do
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
ts = DateTime.shift(~U[2026-01-01 00:00:00Z], hour: i)
create_contact(%{qso_timestamp: ts})
end
@ -114,7 +90,13 @@ defmodule Microwaveprop.RadioTest do
result = Radio.list_contacts(sort_by: :station1, sort_order: :asc)
stations = Enum.map(result.entries, & &1.station1)
# Filter to only the contacts created in this test so parallel
# async tests don't affect the assertion.
stations =
result.entries
|> Enum.filter(&(&1.station1 in ["AA1AA", "ZZ9ZZ"]))
|> Enum.map(& &1.station1)
assert stations == ["AA1AA", "ZZ9ZZ"]
end
@ -124,7 +106,13 @@ defmodule Microwaveprop.RadioTest do
result = Radio.list_contacts(sort_by: :distance_km, sort_order: :desc)
distances = Enum.map(result.entries, & &1.distance_km)
# Filter to only the contacts created in this test so parallel
# async tests don't affect the assertion.
distances =
result.entries
|> Enum.filter(&(&1.station1 in ["A1A", "B2B"]))
|> Enum.map(& &1.distance_km)
assert distances == [Decimal.new("500"), Decimal.new("100")]
end
@ -134,7 +122,9 @@ defmodule Microwaveprop.RadioTest do
result = Radio.list_contacts(sort_by: :bogus)
ids = Enum.map(result.entries, & &1.id)
# Filter to only the contacts created in this test.
our_ids = MapSet.new([early.id, late.id])
ids = result.entries |> Enum.filter(&MapSet.member?(our_ids, &1.id)) |> Enum.map(& &1.id)
assert ids == [late.id, early.id]
end
end
@ -167,7 +157,7 @@ defmodule Microwaveprop.RadioTest do
test "respects limit parameter" do
for i <- 1..5 do
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
ts = DateTime.shift(~U[2026-01-01 00:00:00Z], hour: i)
create_contact(%{qso_timestamp: ts})
end
@ -296,7 +286,7 @@ defmodule Microwaveprop.RadioTest do
test "respects limit parameter" do
for i <- 1..5 do
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
ts = DateTime.shift(~U[2026-01-01 00:00:00Z], hour: i)
create_contact(%{qso_timestamp: ts})
end
@ -360,7 +350,7 @@ defmodule Microwaveprop.RadioTest do
test "respects limit parameter" do
for i <- 1..5 do
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
ts = DateTime.shift(~U[2026-01-01 00:00:00Z], hour: i)
create_contact(%{qso_timestamp: ts})
end
@ -763,7 +753,7 @@ defmodule Microwaveprop.RadioTest do
create_contact(%{
station1: "W5ISP",
station2: "K5X#{i}",
qso_timestamp: DateTime.add(base, i * 3600, :second)
qso_timestamp: DateTime.shift(base, hour: i)
})
end

View file

@ -77,7 +77,7 @@ defmodule Microwaveprop.Weather.GefsProfileTest do
test "inserts many rows at once" do
attrs =
for fh <- [3, 6, 9] do
valid = DateTime.add(~U[2026-04-18 12:00:00Z], fh * 3600, :second)
valid = DateTime.shift(~U[2026-04-18 12:00:00Z], hour: fh)
base_attrs(%{valid_time: valid, forecast_hour: fh})
end

View file

@ -165,7 +165,7 @@ defmodule Microwaveprop.Weather.GridSnapPropertyTest do
property "coverage_end is the first excluded timestamp (exclusive upper bound)" do
coverage_end = NarrClient.coverage_end()
refute NarrClient.in_coverage?(coverage_end)
one_sec_before = DateTime.add(coverage_end, -1, :second)
one_sec_before = DateTime.shift(coverage_end, second: -1)
assert NarrClient.in_coverage?(one_sec_before)
end
@ -173,7 +173,7 @@ defmodule Microwaveprop.Weather.GridSnapPropertyTest do
coverage_end = NarrClient.coverage_end()
check all(seconds_after <- integer(1..100_000_000)) do
dt = DateTime.add(coverage_end, seconds_after, :second)
dt = DateTime.shift(coverage_end, second: seconds_after)
refute NarrClient.in_coverage?(dt)
end
end

View file

@ -37,7 +37,7 @@ defmodule Microwaveprop.Weather.HrrrProfileLookupTest do
test "limit option caps the number of times returned (most recent first kept)" do
for h <- 0..4 do
valid_time = DateTime.add(~U[2026-04-25 12:00:00Z], h * 3600, :second)
valid_time = DateTime.shift(~U[2026-04-25 12:00:00Z], hour: h)
insert_profile!(%{lat: 32.625, lon: -97.125, valid_time: valid_time})
end

View file

@ -51,19 +51,19 @@ defmodule Microwaveprop.Weather.NexradCacheTest do
base = ~U[2026-04-12 00:00:00Z]
for i <- 1..25 do
ts = DateTime.add(base, i * 300, :second)
ts = DateTime.shift(base, minute: i * 5)
NexradCache.put(ts, <<i>>, 1)
end
# The 5 oldest (i = 1..5) should have been evicted.
for i <- 1..5 do
ts = DateTime.add(base, i * 300, :second)
ts = DateTime.shift(base, minute: i * 5)
assert NexradCache.fetch(ts) == :miss
end
# The most recent 20 should still be present.
for i <- 6..25 do
ts = DateTime.add(base, i * 300, :second)
ts = DateTime.shift(base, minute: i * 5)
assert {:ok, <<^i>>, 1} = NexradCache.fetch(ts)
end
end

View file

@ -90,7 +90,7 @@ defmodule Microwaveprop.WeatherGridTest do
end
test "uses most recent valid_time only" do
old = DateTime.utc_now() |> DateTime.add(-7200, :second) |> DateTime.truncate(:second)
old = DateTime.utc_now() |> DateTime.shift(hour: -2) |> DateTime.truncate(:second)
new = DateTime.truncate(DateTime.utc_now(), :second)
insert_hrrr_profile(%{

View file

@ -336,7 +336,7 @@ defmodule Microwaveprop.WeatherTest do
rows =
for i <- 0..99 do
%{
observed_at: DateTime.add(~U[2026-03-28 00:00:00Z], i * 300, :second),
observed_at: DateTime.shift(~U[2026-03-28 00:00:00Z], minute: i * 5),
temp_f: 40.0 + i / 10,
dewpoint_f: 30.0 + i / 10,
sky_condition: "CLR"
@ -354,7 +354,7 @@ defmodule Microwaveprop.WeatherTest do
rows1 =
for i <- 0..9 do
%{
observed_at: DateTime.add(~U[2026-03-28 00:00:00Z], i * 300, :second),
observed_at: DateTime.shift(~U[2026-03-28 00:00:00Z], minute: i * 5),
temp_f: 40.0,
dewpoint_f: 30.0
}
@ -365,7 +365,7 @@ defmodule Microwaveprop.WeatherTest do
rows2 =
for i <- 0..9 do
%{
observed_at: DateTime.add(~U[2026-03-28 00:00:00Z], i * 300, :second),
observed_at: DateTime.shift(~U[2026-03-28 00:00:00Z], minute: i * 5),
temp_f: 80.0,
dewpoint_f: 60.0
}

View file

@ -295,7 +295,7 @@ defmodule Microwaveprop.Workers.AdminTaskWorkerTest do
for i <- 1..2 do
%HrrrNativeProfile{}
|> HrrrNativeProfile.changeset(%{
valid_time: DateTime.add(~U[2026-04-15 18:00:00Z], i * 3600, :second),
valid_time: DateTime.shift(~U[2026-04-15 18:00:00Z], hour: i),
lat: 32.9 + i * 0.1,
lon: -97.0,
level_count: 3,

View file

@ -2,6 +2,7 @@ defmodule Microwaveprop.Workers.BackfillEnqueueWorkerTest do
use Microwaveprop.DataCase, async: true
import Ecto.Query
import Microwaveprop.ContactsFixtures
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Terrain.ElevationClient
@ -10,28 +11,6 @@ defmodule Microwaveprop.Workers.BackfillEnqueueWorkerTest do
alias Microwaveprop.Weather.NarrClient
alias Microwaveprop.Workers.BackfillEnqueueWorker
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
setup do
Req.Test.stub(IemClient, fn conn ->
case conn.request_path do
@ -101,7 +80,7 @@ defmodule Microwaveprop.Workers.BackfillEnqueueWorkerTest do
test "respects limit parameter" do
for i <- 1..5 do
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
ts = DateTime.shift(~U[2026-01-01 00:00:00Z], hour: i)
create_contact(%{qso_timestamp: ts})
end
@ -114,30 +93,33 @@ defmodule Microwaveprop.Workers.BackfillEnqueueWorkerTest do
test "enqueues all pending contacts when limit is omitted" do
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "backfill:enqueue_complete")
for i <- 1..7 do
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
create_contact(%{qso_timestamp: ts})
end
our_ids =
for i <- 1..7 do
ts = DateTime.shift(~U[2026-01-01 00:00:00Z], hour: i)
create_contact(%{qso_timestamp: ts}).id
end
assert :ok =
BackfillEnqueueWorker.perform(%Oban.Job{
args: %{"types" => @non_era5_types}
})
assert_receive {:enqueue_complete, 7}
assert_receive {:enqueue_complete, count}
assert count >= 7
end
test "broadcasts enqueue_complete with count" do
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "backfill:enqueue_complete")
create_contact()
contact = create_contact()
assert :ok =
BackfillEnqueueWorker.perform(%Oban.Job{
args: %{"limit" => 10, "types" => @non_era5_types}
})
assert_receive {:enqueue_complete, 1}
assert_receive {:enqueue_complete, count}
assert count >= 1
end
# :unavailable is a terminal state in the sense that workers don't
@ -164,7 +146,7 @@ defmodule Microwaveprop.Workers.BackfillEnqueueWorkerTest do
# Force updated_at past the 24 h cooldown so the type_filter's
# :unavailable branch picks this contact up.
old = DateTime.utc_now() |> DateTime.add(-2 * 86_400, :second) |> DateTime.truncate(:second)
old = DateTime.utc_now() |> DateTime.shift(day: -2) |> DateTime.truncate(:second)
Repo.update_all(from(c in Contact, where: c.id == ^contact.id), set: [updated_at: old])
assert :ok =
@ -172,7 +154,8 @@ defmodule Microwaveprop.Workers.BackfillEnqueueWorkerTest do
args: %{"limit" => 10, "types" => @non_era5_types}
})
assert_receive {:enqueue_complete, 1}
assert_receive {:enqueue_complete, count}
assert count >= 1
end
test "leaves :unavailable contacts inside the cooldown window alone" do
@ -196,7 +179,10 @@ defmodule Microwaveprop.Workers.BackfillEnqueueWorkerTest do
args: %{"limit" => 10, "types" => @non_era5_types}
})
assert_receive {:enqueue_complete, 0}
# Our contact must still be :unavailable — the worker must not
# have touched it, regardless of what other contacts it enqueued.
reloaded = Repo.get!(Contact, contact.id)
assert reloaded.weather_status == :unavailable
end
# :narr is a virtual type (no narr_status column) — it targets contacts
@ -256,13 +242,13 @@ defmodule Microwaveprop.Workers.BackfillEnqueueWorkerTest do
defp stale_ts do
DateTime.utc_now()
|> DateTime.add(-(@stale_cutoff + 3600), :second)
|> DateTime.shift(second: -(@stale_cutoff + 3600))
|> DateTime.truncate(:second)
end
defp fresh_ts do
DateTime.utc_now()
|> DateTime.add(-3600, :second)
|> DateTime.shift(hour: -1)
|> DateTime.truncate(:second)
end

View file

@ -109,7 +109,7 @@ defmodule Microwaveprop.Workers.ContactPositionBackfillWorkerTest do
pos1: nil,
pos2: nil,
distance_km: nil,
qso_timestamp: DateTime.add(~U[2026-03-28 18:00:00Z], i * 3600, :second)
qso_timestamp: DateTime.shift(~U[2026-03-28 18:00:00Z], hour: i)
})
end

View file

@ -219,7 +219,7 @@ defmodule Microwaveprop.Workers.GefsFetchWorkerTest do
test "round-trips a full batch through the context" do
run_time = ~U[2026-04-18 12:00:00Z]
fh = 24
valid_time = DateTime.add(run_time, fh * 3600, :second)
valid_time = DateTime.shift(run_time, hour: fh)
batch = [
%{

View file

@ -12,9 +12,9 @@ defmodule Microwaveprop.Workers.GridCachePruneWorkerTest do
describe "perform/1" do
test "removes cache entries with valid_time older than 3 hours" do
now = DateTime.utc_now()
old = now |> DateTime.add(-3, :hour) |> DateTime.add(-1, :second) |> DateTime.truncate(:second)
fresh = now |> DateTime.add(-30, :minute) |> DateTime.truncate(:second)
future = now |> DateTime.add(6, :hour) |> DateTime.truncate(:second)
old = now |> DateTime.shift(hour: -3, second: -1) |> DateTime.truncate(:second)
fresh = now |> DateTime.shift(minute: -30) |> DateTime.truncate(:second)
future = now |> DateTime.shift(hour: 6) |> DateTime.truncate(:second)
GridCache.put(old, [%{lat: 32.0, lon: -97.0, temperature: 10.0}])
GridCache.put(fresh, [%{lat: 32.0, lon: -97.0, temperature: 20.0}])
@ -29,7 +29,7 @@ defmodule Microwaveprop.Workers.GridCachePruneWorkerTest do
test "is a no-op when nothing is stale" do
fresh =
DateTime.utc_now() |> DateTime.add(-30, :minute) |> DateTime.truncate(:second)
DateTime.utc_now() |> DateTime.shift(minute: -30) |> DateTime.truncate(:second)
GridCache.put(fresh, [%{lat: 32.0, lon: -97.0, temperature: 42.0}])

View file

@ -11,9 +11,9 @@ defmodule Microwaveprop.Workers.PropagationPruneWorkerTest do
# picking exactly -3h flakes whenever the test + worker wall-clock
# land in the same second. -3h - 1s gives unconditional ordering.
now = DateTime.utc_now()
old = now |> DateTime.add(-3, :hour) |> DateTime.add(-1, :second) |> DateTime.truncate(:second)
fresh = now |> DateTime.add(-30, :minute) |> DateTime.truncate(:second)
future = now |> DateTime.add(6, :hour) |> DateTime.truncate(:second)
old = now |> DateTime.shift(hour: -3, second: -1) |> DateTime.truncate(:second)
fresh = now |> DateTime.shift(minute: -30) |> DateTime.truncate(:second)
future = now |> DateTime.shift(hour: 6) |> DateTime.truncate(:second)
ScoresFile.write!(10_000, old, [%{lat: 25.0, lon: -125.0, score: 10}])
ScoresFile.write!(10_000, fresh, [%{lat: 25.0, lon: -125.0, score: 20}])
@ -28,7 +28,7 @@ defmodule Microwaveprop.Workers.PropagationPruneWorkerTest do
test "is a no-op when nothing is stale" do
now = DateTime.utc_now()
fresh = now |> DateTime.add(-30, :minute) |> DateTime.truncate(:second)
fresh = now |> DateTime.shift(minute: -30) |> DateTime.truncate(:second)
ScoresFile.write!(10_000, fresh, [%{lat: 25.0, lon: -125.0, score: 42}])
assert :ok = PropagationPruneWorker.perform(%Oban.Job{})

View file

@ -59,7 +59,7 @@ defmodule Microwaveprop.Workers.PskrCalibrationWorkerTest do
# arrive in the last few minutes of an hour wait up to a full
# cron interval before they're sampled.
now = truncate_to_hour(DateTime.utc_now())
previous = DateTime.add(now, -3600, :second)
previous = DateTime.shift(now, hour: -1)
insert_spot!(now)
insert_spot!(previous, sender_grid: "EM10AA")
@ -90,7 +90,7 @@ defmodule Microwaveprop.Workers.PskrCalibrationWorkerTest do
now = truncate_to_hour(DateTime.utc_now())
Enum.each(0..3, fn offset ->
hour = DateTime.add(now, -offset * 3600, :second)
hour = DateTime.shift(now, hour: -offset)
insert_spot!(hour, sender_grid: "EM1#{offset}AA")
end)

View file

@ -60,7 +60,7 @@ defmodule MicrowavepropWeb.Api.V1.AuthControllerTest do
end
test "accepts an ISO 8601 expires_at", %{conn: conn, user: user} do
future = DateTime.utc_now() |> DateTime.add(3600, :second) |> DateTime.to_iso8601()
future = DateTime.utc_now() |> DateTime.shift(hour: 1) |> DateTime.to_iso8601()
conn =
post(conn, ~p"/api/v1/auth/tokens", %{
@ -111,7 +111,7 @@ defmodule MicrowavepropWeb.Api.V1.AuthControllerTest do
end
test "rejects expires_at in the past via changeset 422", %{conn: conn, user: user} do
past = DateTime.utc_now() |> DateTime.add(-1, :second) |> DateTime.to_iso8601()
past = DateTime.utc_now() |> DateTime.shift(second: -1) |> DateTime.to_iso8601()
conn =
post(conn, ~p"/api/v1/auth/tokens", %{

View file

@ -20,7 +20,7 @@ defmodule MicrowavepropWeb.UserSettingsControllerTest do
assert redirected_to(conn) == ~p"/users/log-in"
end
@tag token_authenticated_at: DateTime.add(DateTime.utc_now(:second), -25, :hour)
@tag token_authenticated_at: DateTime.shift(DateTime.utc_now(:second), hour: -25)
test "redirects if user is not in sudo mode", %{conn: conn} do
conn = get(conn, ~p"/users/settings")
assert redirected_to(conn) == ~p"/users/log-in"

View file

@ -103,7 +103,7 @@ defmodule MicrowavepropWeb.ContactLive.IndexTest do
test "pagination reaches later pages", %{conn: conn} do
for i <- 1..25 do
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
ts = DateTime.shift(~U[2026-01-01 00:00:00Z], hour: i)
create_contact(%{qso_timestamp: ts, station1: "W#{i}TEST"})
end

View file

@ -1363,7 +1363,7 @@ defmodule MicrowavepropWeb.ContactLive.ShowCoverageTest do
nonce = System.unique_integer([:positive])
lat1 = 32.0 + nonce / 1_000_000
iter_valid_time = DateTime.add(~U[2026-03-28 18:00:00Z], nonce, :second)
iter_valid_time = DateTime.shift(~U[2026-03-28 18:00:00Z], second: nonce)
contact =
create_contact(%{
@ -1426,7 +1426,7 @@ defmodule MicrowavepropWeb.ContactLive.ShowCoverageTest do
Repo.insert!(
SurfaceObservation.changeset(%SurfaceObservation{}, %{
station_id: station.id,
observed_at: DateTime.add(contact.qso_timestamp, i * 60, :second),
observed_at: DateTime.shift(contact.qso_timestamp, minute: i),
temp_f: 60.0 + i,
dewpoint_f: 45.0 + i,
relative_humidity: 55.0,
@ -1478,7 +1478,7 @@ defmodule MicrowavepropWeb.ContactLive.ShowCoverageTest do
Repo.insert!(
SurfaceObservation.changeset(%SurfaceObservation{}, %{
station_id: station.id,
observed_at: DateTime.add(contact.qso_timestamp, offset, :second),
observed_at: DateTime.shift(contact.qso_timestamp, second: offset),
temp_f: temp,
dewpoint_f: dewp,
relative_humidity: rh,
@ -1576,7 +1576,7 @@ defmodule MicrowavepropWeb.ContactLive.ShowCoverageTest do
Repo.insert!(
Sounding.changeset(%Sounding{}, %{
station_id: station.id,
observed_at: DateTime.add(contact.qso_timestamp, offset, :second),
observed_at: DateTime.shift(contact.qso_timestamp, second: offset),
profile: [%{"pres" => 1000.0, "tmpc" => temp, "dwpc" => temp - 5.0, "hght" => 100.0}],
level_count: 1,
surface_temp_c: temp,

View file

@ -531,12 +531,12 @@ defmodule MicrowavepropWeb.MapLiveTest do
end
test "renders a '+Nh' suffix when the valid_time is in the future" do
future = DateTime.add(DateTime.utc_now(), 3 * 3600, :second)
future = DateTime.shift(DateTime.utc_now(), hour: 3)
assert MapLive.format_data_timestamp(future) =~ "+3h"
end
test "renders a 'Nh ago' suffix when the valid_time is in the past" do
past = DateTime.add(DateTime.utc_now(), -4 * 3600, :second)
past = DateTime.shift(DateTime.utc_now(), hour: -4)
assert MapLive.format_data_timestamp(past) =~ "4h ago"
end
end
@ -611,7 +611,7 @@ defmodule MicrowavepropWeb.MapLiveTest do
assert html =~ "Updating propagation"
assert html =~ ~s(data-pipeline-state="running")
future = DateTime.add(DateTime.utc_now(), 3 * 3600, :second)
future = DateTime.shift(DateTime.utc_now(), hour: 3)
Phoenix.PubSub.broadcast(
Microwaveprop.PubSub,

View file

@ -163,7 +163,7 @@ defmodule MicrowavepropWeb.PathLiveTest do
|> Map.put(:minute, 0)
|> Map.put(:second, 0)
times = for h <- 0..2, do: DateTime.add(base, h * 3600, :second)
times = for h <- 0..2, do: DateTime.shift(base, hour: h)
Enum.each(times, fn t ->
Propagation.replace_scores(

View file

@ -175,7 +175,7 @@ defmodule MicrowavepropWeb.PskrSpotsLiveTest do
# Insert 105 spots — only the 100 most recent should render
# Pad single-digit grid numbers so substring matching is unambiguous
for i <- 1..105 do
t = DateTime.add(now(), i - 105, :second)
t = DateTime.shift(now(), second: i - 105)
grid = if i < 10, do: "GRID00#{i}", else: "GRID#{i}"
insert_spot(%{sender_grid: grid, last_spot_at: t, hour_utc: t})
end

View file

@ -61,7 +61,7 @@ defmodule MicrowavepropWeb.SkewtLiveTest do
# Trigger select_time with a valid future ISO. Even if no profile is
# found for this time, the handler exercises the full re-load path.
future = DateTime.utc_now() |> DateTime.add(3600, :second) |> DateTime.to_iso8601()
future = DateTime.utc_now() |> DateTime.shift(hour: 1) |> DateTime.to_iso8601()
_ = render_hook(view, "select_time", %{"valid_time" => future})
_ = render_async(view)

View file

@ -67,7 +67,7 @@ defmodule MicrowavepropWeb.WeatherCaMapLiveTest do
|> Map.put(:minute, 0)
|> Map.put(:second, 0)
stale_t = DateTime.add(now, -4 * 3600, :second)
stale_t = DateTime.shift(now, hour: -4)
hrdps_dir = ScalarFile.dir_for_hrdps(stale_t)
File.mkdir_p!(hrdps_dir)

View file

@ -59,7 +59,7 @@ defmodule MicrowavepropWeb.WeatherMapLiveTest do
|> Map.put(:minute, 0)
|> Map.put(:second, 0)
times = for h <- 0..2, do: DateTime.add(base, h * 3600, :second)
times = for h <- 0..2, do: DateTime.shift(base, hour: h)
# Seed a profile file at each forecast hour.
Enum.each(times, &write_profile(&1, 33.0, -97.0))
@ -82,14 +82,14 @@ defmodule MicrowavepropWeb.WeatherMapLiveTest do
|> Map.put(:minute, 0)
|> Map.put(:second, 0)
stale_t = DateTime.add(now, -2 * 3600, :second)
stale_t = DateTime.shift(now, hour: -2)
# `now` is truncated to the top of the hour; real `utc_now()` is up to
# 60 min ahead of that, so a `now - 30 min` recent_t is 30-90 min old
# of real time and the LiveView's `utc_now() - 1h` cutoff filters it
# out 50% of the time. Anchor recent_t at the top of the current hour
# — that's always within the 1-hour window regardless of minute.
recent_t = now
future_t = DateTime.add(now, 3 * 3600, :second)
future_t = DateTime.shift(now, hour: 3)
Enum.each([stale_t, recent_t, future_t], &write_profile(&1, 33.0, -97.0))
@ -116,7 +116,7 @@ defmodule MicrowavepropWeb.WeatherMapLiveTest do
# sequentially so `latest_grid_valid_time` can point to f11 even
# though the user expects the map to show "Now" by default.
now_t = base
future_t = DateTime.add(base, 11 * 3600, :second)
future_t = DateTime.shift(base, hour: 11)
write_profile(now_t, 33.0, -97.0)
write_profile(future_t, 33.0, -97.0)
@ -166,7 +166,7 @@ defmodule MicrowavepropWeb.WeatherMapLiveTest do
|> Map.put(:second, 0)
now_t = base
future_t = DateTime.add(base, 3 * 3600, :second)
future_t = DateTime.shift(base, hour: 3)
# Seed f00 with a 22°C surface and f03 with a 10°C surface so we
# can tell them apart in the push_event payload.

View file

@ -198,7 +198,7 @@ defmodule MicrowavepropWeb.UserAuthTest do
end
test "redirects when authentication is too old", %{conn: conn, user: user} do
too_long_ago = :second |> DateTime.utc_now() |> DateTime.add(-25, :hour)
too_long_ago = :second |> DateTime.utc_now() |> DateTime.shift(hour: -25)
user = %{user | authenticated_at: too_long_ago}
user_token = Accounts.generate_user_session_token(user)
{user, token_inserted_at} = Accounts.get_user_by_session_token(user_token)

View file

@ -26,7 +26,7 @@ defmodule Mix.Tasks.Prop.CompareTest do
defp seed_contact_and_hrrr(opts \\ []) do
qso_ts =
Keyword.get(opts, :qso_timestamp, DateTime.utc_now() |> DateTime.add(-3600, :second) |> DateTime.truncate(:second))
Keyword.get(opts, :qso_timestamp, DateTime.utc_now() |> DateTime.shift(hour: -1) |> DateTime.truncate(:second))
valid_time = qso_ts |> DateTime.truncate(:second) |> Map.put(:minute, 0) |> Map.put(:second, 0)
band = Keyword.get(opts, :band, "10000")
@ -86,7 +86,7 @@ defmodule Mix.Tasks.Prop.CompareTest do
history_lines =
for _ <- 1..6 do
Jason.encode!(%{
"date" => DateTime.utc_now() |> DateTime.add(-3600 * 24, :second) |> DateTime.to_iso8601(),
"date" => DateTime.utc_now() |> DateTime.shift(day: -1) |> DateTime.to_iso8601(),
"n_samples" => 100,
"overall" => %{"alg_distance_spearman" => 0.5, "alg_ml_rmse" => 5.0},
"by_band" => %{}

View file

@ -106,7 +106,7 @@ defmodule Mix.Tasks.PropagationMlTasksTest do
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)
valid_time = base_time |> DateTime.shift(hour: i) |> DateTime.truncate(:second)
# Distinct snapped grid points for each i.
lat1 = 32.0 + i * 0.125
lon1 = -97.0 - i * 0.125

View file

@ -213,7 +213,7 @@ defmodule Mix.Tasks.SimpleTasksTest do
{:ok, _} =
%HrrrProfile{}
|> HrrrProfile.changeset(%{
valid_time: DateTime.add(~U[2025-06-15 12:00:00Z], i * 86_400, :second),
valid_time: DateTime.shift(~U[2025-06-15 12:00:00Z], day: i),
lat: 32.5,
lon: -97.0,
surface_temp_c: 20.0 + i,

View file

@ -0,0 +1,53 @@
defmodule Microwaveprop.ContactsFixtures do
@moduledoc """
Factory helpers for creating Contact records in tests.
Uses globally-unique `qso_timestamp` microseconds (via
`System.unique_integer([:positive, :monotonic])`) to prevent
`contacts_dedup_idx` unique-constraint violations when async
tests across different modules run in parallel and would
otherwise insert contacts with identical dedup-key columns.
"""
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Repo
@doc """
Creates a contact with unique dedup-key column values.
Override any field by passing it in `attrs`. Pass `:user_id` to
associate the contact with a user.
Returns the created `%Contact{}`.
"""
@spec create_contact(map()) :: Contact.t()
def create_contact(attrs \\ %{}) do
# Globally-unique second offset so every contact has a distinct
# dedup key (band, qso_timestamp, stations, grids). The column is
# timestamp(0), so microsecond offsets are truncated.
unique_s = System.unique_integer([:positive, :monotonic])
default = %{
station1: "W5XD",
station2: "K5TR",
qso_timestamp: DateTime.shift(~U[2026-03-28 18:00:00Z], second: unique_s),
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")
}
merged = Map.merge(default, attrs)
{user_id, changeset_attrs} = Map.pop(merged, :user_id)
{:ok, contact} =
%Contact{user_id: user_id}
|> Contact.changeset(changeset_attrs)
|> Repo.insert()
contact
end
end

View file

@ -15,6 +15,7 @@ Code.require_file("test/support/data_case.ex")
Code.require_file("test/support/conn_case.ex")
Code.require_file("test/support/fixtures/accounts_fixtures.ex")
Code.require_file("test/support/fixtures/beacons_fixtures.ex")
Code.require_file("test/support/fixtures/contacts_fixtures.ex")
# ExUnit.start/1 with capture_log: true starts :logger, which cascades
# into the full OTP application. mix.exs now runs `mix test --no-start`