From b735adc067a17994d072ccb7620bc763d4fdff05 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 9 May 2026 17:23:43 -0500 Subject: [PATCH] =?UTF-8?q?feat(insights):=20RF-aware=20channel-conflict?= =?UTF-8?q?=20=E2=80=94=20multi-source=20noise=20+=20Preseem?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same-channel conflicts at one site are not all equal. A pair of APs sharing a channel in a clean RF environment is a planning issue; the same pair in a contaminated environment is an outage waiting to happen. This commit makes the rule reason across sources before deciding urgency. - Towerops.Snmp.RfHealth — cross-source helper that combines: * Most recent noise-floor / noise sensor on the AP (dBm) * Preseem rf_score and qoe_score from the linked AccessPoint row * Average CPE SNR and client count from wireless_clients Returns a snapshot map and an interference_severity/1 classifier (:critical | :warning | :ok). Critical when noise floor >= -80 dBm OR Preseem rf_score < 4.0; warning when avg CPE SNR < 15 with active clients. - OwnFleetChannelConflict now enriches each conflicting AP with its noise floor, rf_score, qoe_score, avg CPE SNR, CPE count, and a per-AP rf_severity. Urgency promotes to critical when 3+ APs share a channel OR any conflicting AP shows critical RF health. - UI: amber card now renders an evidence table with width, noise, rf_score, CPE SNR, and CPE count per AP, plus a "Critical RF environment detected" badge when has_critical_rf is true. Also strengthens the previous flake fix on agent_live_test.exs:567 by switching to Req.Test shared mode (the test is sync), so the ReleaseChecker stub is visible to the LiveView process regardless of when it spawns relative to setup. The earlier Req.Test.allow approach relied on stub timing that didn't always hold under full-suite load. 11 new tests across RfHealth + the enriched rule. LLM enrichment automatically picks up the richer metadata via the existing Phase 1 worker. --- CHANGELOG.txt | 20 +++ .../rules/own_fleet_channel_conflict.ex | 63 ++++++-- lib/towerops/snmp/rf_health.ex | 144 +++++++++++++++++ .../live/insights_live/index.html.heex | 79 +++++++--- priv/static/changelog.txt | 2 +- .../rules/own_fleet_channel_conflict_test.exs | 78 ++++++++++ test/towerops/snmp/rf_health_test.exs | 147 ++++++++++++++++++ test/towerops_web/live/agent_live_test.exs | 14 +- 8 files changed, 506 insertions(+), 41 deletions(-) create mode 100644 lib/towerops/snmp/rf_health.ex create mode 100644 test/towerops/snmp/rf_health_test.exs diff --git a/CHANGELOG.txt b/CHANGELOG.txt index bf1a9306..a9d2f2b0 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,23 @@ +2026-05-09 +feat(insights): RF-aware channel-conflict — multi-source noise + Preseem + Towerops.Snmp.RfHealth — cross-source helper that combines: + * Most recent noise-floor / noise sensor value (dBm) on the AP + * Preseem rf_score and qoe_score from the linked AccessPoint row + * Average CPE SNR and client count from wireless_clients + Returns a snapshot map and an `interference_severity/1` classifier + (:critical | :warning | :ok). Critical when noise floor >= -80 dBm + OR Preseem rf_score < 4.0; warning when avg CPE SNR < 15 with + active clients. + OwnFleetChannelConflict rule now enriches each conflicting AP with + noise floor, rf_score, qoe_score, avg CPE SNR, CPE count, and per-AP + rf_severity. Urgency promotes to critical when 3+ APs share a + channel OR any conflicting AP shows critical RF health — so a + same-channel conflict in a clean RF environment stays "warning" + while one in a contaminated environment escalates. + UI: amber card now shows a small evidence table with width, noise, + rf_score, CPE SNR, and CPE count per AP, plus a "Critical RF + environment detected" badge when has_critical_rf is true. + 2026-05-09 feat(snmp): canonical AP radio state + own-fleet channel conflict rule Towerops.Snmp.RadioState — derives current_channel from existing diff --git a/lib/towerops/recommendations/rules/own_fleet_channel_conflict.ex b/lib/towerops/recommendations/rules/own_fleet_channel_conflict.ex index 542a7b25..c70a1612 100644 --- a/lib/towerops/recommendations/rules/own_fleet_channel_conflict.ex +++ b/lib/towerops/recommendations/rules/own_fleet_channel_conflict.ex @@ -8,9 +8,17 @@ defmodule Towerops.Recommendations.Rules.OwnFleetChannelConflict do rule needs no neighbor scan data — just the `current_channel` already populated on `snmp_devices` by `Towerops.Snmp.RadioState`. - Emits one `own_fleet_channel_conflict` insight per (site, channel) - group, listing the conflicting APs in the metadata so the operator - can pick which one to move. + Each conflicting AP is enriched with cross-source RF health data via + `Towerops.Snmp.RfHealth` so the operator sees the real impact: + + * Noise floor (dBm) from the AP's most recent noise-floor sensor + * Preseem rf_score and qoe_score (when the AP is matched) + * Average CPE SNR for currently connected subscribers + + Urgency is bumped to **critical** when: + * 3+ APs share a channel, OR + * Any conflicting AP shows critical RF health (noise floor ≥ -80 dBm + or Preseem rf_score < 4.0) """ import Ecto.Query @@ -18,6 +26,7 @@ defmodule Towerops.Recommendations.Rules.OwnFleetChannelConflict do alias Towerops.Devices.Device alias Towerops.Repo alias Towerops.Snmp.Device, as: SnmpDevice + alias Towerops.Snmp.RfHealth @spec evaluate(Ecto.UUID.t()) :: [map()] def evaluate(organization_id) when is_binary(organization_id) do @@ -44,42 +53,70 @@ defmodule Towerops.Recommendations.Rules.OwnFleetChannelConflict do defp build_insight({{site_id, channel}, group}, organization_id) do [{first_device, first_snmp} | _] = group - aps = + enriched = Enum.map(group, fn {device, snmp} -> - %{ + health = RfHealth.for_device(device.id) + severity = RfHealth.interference_severity(health) + + ap = %{ "device_id" => device.id, "name" => device.name, "channel" => snmp.current_channel, "frequency_mhz" => snmp.current_frequency_mhz, - "channel_width_mhz" => snmp.current_channel_width_mhz + "channel_width_mhz" => snmp.current_channel_width_mhz, + "noise_floor_dbm" => health.noise_floor_dbm, + "rf_score" => health.rf_score, + "qoe_score" => health.qoe_score, + "avg_cpe_snr_db" => health.avg_cpe_snr_db, + "cpe_count" => health.client_count, + "rf_severity" => Atom.to_string(severity) } + + {ap, severity} end) + aps = Enum.map(enriched, fn {ap, _sev} -> ap end) + severities = Enum.map(enriched, fn {_ap, sev} -> sev end) + has_critical_rf = :critical in severities + %{ organization_id: organization_id, site_id: site_id, device_id: first_device.id, type: "own_fleet_channel_conflict", - urgency: urgency_for(length(group)), + urgency: urgency_for(length(group), has_critical_rf), channel: "proactive", source: "system", title: "#{length(group)} APs at the same site share channel #{channel} " <> "(#{first_snmp.current_frequency_mhz} MHz)", - description: - "Multiple access points at the same site are configured on the " <> - "same channel. Without strict GPS sync this causes co-channel " <> - "interference between sectors and degrades airtime efficiency.", + description: description_for(group, has_critical_rf), metadata: %{ "dedup_key" => "own_fleet_channel_conflict:#{site_id}:#{channel}", "channel" => channel, "frequency_mhz" => first_snmp.current_frequency_mhz, "ap_count" => length(group), + "has_critical_rf" => has_critical_rf, "aps" => aps } } end - defp urgency_for(count) when count >= 3, do: "critical" - defp urgency_for(_), do: "warning" + defp urgency_for(count, has_critical_rf) when count >= 3 or has_critical_rf == true, do: "critical" + + defp urgency_for(_count, _has_critical_rf), do: "warning" + + defp description_for(group, true) do + "#{length(group)} access points at the same site share a channel " <> + "AND at least one of them shows critical RF health " <> + "(elevated noise floor or low Preseem RF score). This is the " <> + "high-impact case — co-channel self-interference compounded by " <> + "external interference." + end + + defp description_for(group, false) do + "#{length(group)} access points at the same site are configured on " <> + "the same channel. Without strict GPS sync this causes co-channel " <> + "interference between sectors and degrades airtime efficiency." + end end diff --git a/lib/towerops/snmp/rf_health.ex b/lib/towerops/snmp/rf_health.ex new file mode 100644 index 00000000..3b548630 --- /dev/null +++ b/lib/towerops/snmp/rf_health.ex @@ -0,0 +1,144 @@ +defmodule Towerops.Snmp.RfHealth do + @moduledoc """ + Cross-source RF health snapshot for an access point. + + Combines: + + * **Noise floor** (dBm) from the most recent `noise-floor` / `noise` + sensor on the AP's snmp_device. + * **Preseem scores** (`rf_score`, `qoe_score`) from the linked + `preseem_access_points` row, if any. + * **Average CPE SNR** and **client count** from the wireless + clients currently associated with the AP. + + Used by RF-aware recommendation rules to enrich evidence and modulate + urgency. Returns a struct-shaped map with explicit `nil` fields when + data is missing, so callers can treat the absence of a signal as + separate from "signal observed but healthy". + """ + + import Ecto.Query + + alias Towerops.Preseem.AccessPoint + alias Towerops.Repo + alias Towerops.Snmp.Device, as: SnmpDevice + alias Towerops.Snmp.Sensor + alias Towerops.Snmp.WirelessClient + + @cpe_lookback_seconds 2 * 3600 + + @noise_critical_dbm -80.0 + @rf_score_critical 4.0 + @cpe_snr_warning_db 15.0 + + @type t :: %{ + noise_floor_dbm: float() | nil, + rf_score: float() | nil, + qoe_score: float() | nil, + avg_cpe_snr_db: float() | nil, + client_count: non_neg_integer() + } + + @spec for_device(Ecto.UUID.t()) :: t() + def for_device(device_id) when is_binary(device_id) do + %{ + noise_floor_dbm: latest_noise_floor(device_id), + rf_score: preseem_field(device_id, :rf_score), + qoe_score: preseem_field(device_id, :qoe_score), + avg_cpe_snr_db: avg_cpe_snr(device_id), + client_count: cpe_count(device_id) + } + end + + @doc """ + Classify the RF health snapshot as :critical, :warning, or :ok. + + Tolerates missing fields — if no signals are present, returns :ok. + """ + @spec interference_severity(map()) :: :critical | :warning | :ok + def interference_severity(%{} = h) do + cond do + noise_floor_critical?(Map.get(h, :noise_floor_dbm)) -> :critical + rf_score_critical?(Map.get(h, :rf_score)) -> :critical + cpe_snr_warning?(Map.get(h, :avg_cpe_snr_db), Map.get(h, :client_count, 0)) -> :warning + true -> :ok + end + end + + defp noise_floor_critical?(nil), do: false + defp noise_floor_critical?(value) when is_number(value), do: value >= @noise_critical_dbm + + defp rf_score_critical?(nil), do: false + defp rf_score_critical?(value) when is_number(value), do: value < @rf_score_critical + + defp cpe_snr_warning?(_snr, count) when count == 0, do: false + defp cpe_snr_warning?(nil, _count), do: false + + defp cpe_snr_warning?(snr, _count) when is_number(snr) do + snr < @cpe_snr_warning_db + end + + defp latest_noise_floor(device_id) do + sensor = + Repo.one( + from(s in Sensor, + join: d in SnmpDevice, + on: d.id == s.snmp_device_id, + where: + d.device_id == ^device_id and s.sensor_type in ^["noise-floor", "noise", "noise_floor"] and + not is_nil(s.last_value), + order_by: [desc: s.updated_at], + limit: 1, + select: s.last_value + ) + ) + + sensor + end + + defp preseem_field(device_id, field) do + Repo.one( + from(ap in AccessPoint, + where: ap.device_id == ^device_id, + order_by: [desc: ap.updated_at], + limit: 1, + select: field(ap, ^field) + ) + ) + end + + defp avg_cpe_snr(device_id) do + cutoff = + DateTime.utc_now() + |> DateTime.add(-@cpe_lookback_seconds, :second) + |> DateTime.truncate(:second) + + from(c in WirelessClient, + where: + c.device_id == ^device_id and + not is_nil(c.snr) and + c.last_seen_at >= ^cutoff, + select: avg(c.snr) + ) + |> Repo.one() + |> decimal_to_float() + end + + defp cpe_count(device_id) do + cutoff = + DateTime.utc_now() + |> DateTime.add(-@cpe_lookback_seconds, :second) + |> DateTime.truncate(:second) + + from(c in WirelessClient, + where: c.device_id == ^device_id and c.last_seen_at >= ^cutoff, + select: count(c.id) + ) + |> Repo.one() + |> Kernel.||(0) + end + + defp decimal_to_float(nil), do: nil + defp decimal_to_float(%Decimal{} = d), do: Decimal.to_float(d) + defp decimal_to_float(n) when is_number(n), do: n * 1.0 +end diff --git a/lib/towerops_web/live/insights_live/index.html.heex b/lib/towerops_web/live/insights_live/index.html.heex index 87c7c40c..c83768bc 100644 --- a/lib/towerops_web/live/insights_live/index.html.heex +++ b/lib/towerops_web/live/insights_live/index.html.heex @@ -504,34 +504,67 @@ {insight.metadata["ap_count"]} + <%= if insight.metadata["has_critical_rf"] do %> + + {t("Critical RF environment detected")} + + <% end %> <%= if is_list(insight.metadata["aps"]) and insight.metadata["aps"] != [] do %> -
+

{t("Conflicting APs")}

-
    -
  • - <%= if ap["device_id"] do %> - <.link - navigate={~p"/devices/#{ap["device_id"]}"} - class="underline hover:no-underline" - > - {ap["name"] || "Unnamed"} - - <% else %> - {ap["name"] || "Unnamed"} - <% end %> - <%= if ap["channel_width_mhz"] do %> - - ({ap["channel_width_mhz"]} MHz wide) - - <% end %> -
  • -
+ + + + + + + + + + + + + + + + + + + + + +
{t("AP")}{t("Width")}{t("Noise")}{t("RF score")}{t("CPE SNR")}{t("CPEs")}
+ <%= if ap["device_id"] do %> + <.link + navigate={~p"/devices/#{ap["device_id"]}"} + class="underline hover:no-underline" + > + {ap["name"] || "Unnamed"} + + <% else %> + {ap["name"] || "Unnamed"} + <% end %> + <%= if ap["rf_severity"] == "critical" do %> + + crit + + <% end %> + + {ap["channel_width_mhz"] && "#{ap["channel_width_mhz"]} MHz"} + + {ap["noise_floor_dbm"] && "#{ap["noise_floor_dbm"]} dBm"} + + {ap["rf_score"] && Float.round(ap["rf_score"] / 1.0, 1)} + + {ap["avg_cpe_snr_db"] && + "#{Float.round(ap["avg_cpe_snr_db"] / 1.0, 1)} dB"} + {ap["cpe_count"]}
<% end %>
diff --git a/priv/static/changelog.txt b/priv/static/changelog.txt index 7d2a9f41..fd64bc0f 100644 --- a/priv/static/changelog.txt +++ b/priv/static/changelog.txt @@ -1,5 +1,5 @@ 2026-05-09 — Smarter recommendations + AI-generated insight summaries -* New recommendation: same-channel conflicts within your own network — when two or more of your APs at the same site sit on the same channel, the system flags the conflict with a clickable list so you can pick which one to retune +* New recommendation: same-channel conflicts within your own network — when two or more of your APs at the same site sit on the same channel, the system flags the conflict with a clickable evidence table showing noise floor, RF score, average CPE SNR, and client count per AP, and escalates to critical when the RF environment is also contaminated * New recommendation: sector overload detection — APs with low free airtime and active subscribers get flagged with subscriber count, QoE score, and AP model so you can decide whether to split the sector or upgrade the radio * New recommendation: CPE re-aim candidates — wireless clients with both weak signal and low SNR (the alignment-issue signature) get individual cards with signal, SNR, TX/RX rate, and distance, so each truck-roll candidate is actionable on its own * New recommendation: when a wireless AP has strong same-channel interference, the system suggests a specific cleaner channel and shows the top interfering neighbors with their signal levels diff --git a/test/towerops/recommendations/rules/own_fleet_channel_conflict_test.exs b/test/towerops/recommendations/rules/own_fleet_channel_conflict_test.exs index cc8e66be..c67e1c6b 100644 --- a/test/towerops/recommendations/rules/own_fleet_channel_conflict_test.exs +++ b/test/towerops/recommendations/rules/own_fleet_channel_conflict_test.exs @@ -10,8 +10,10 @@ defmodule Towerops.Recommendations.Rules.OwnFleetChannelConflictTest do import Towerops.DevicesFixtures import Towerops.OrganizationsFixtures + alias Towerops.Preseem.AccessPoint alias Towerops.Recommendations.Rules.OwnFleetChannelConflict alias Towerops.Snmp.Device, as: SnmpDevice + alias Towerops.Snmp.Sensor setup do user = user_fixture() @@ -101,4 +103,80 @@ defmodule Towerops.Recommendations.Rules.OwnFleetChannelConflictTest do assert OwnFleetChannelConflict.evaluate(org.id) == [] end + + describe "RF health enrichment" do + defp put_noise_floor(device, value) do + snmp = Repo.get_by!(SnmpDevice, device_id: device.id) + + %Sensor{} + |> Sensor.changeset(%{ + snmp_device_id: snmp.id, + sensor_type: "noise-floor", + sensor_index: "n_#{System.unique_integer([:positive])}", + sensor_oid: "1.2.3.#{System.unique_integer([:positive])}", + sensor_unit: "dBm", + sensor_divisor: 1, + last_value: value + }) + |> Repo.insert!() + end + + defp link_preseem(org, device, attrs) do + %AccessPoint{} + |> AccessPoint.changeset( + Map.merge( + %{ + organization_id: org.id, + preseem_id: "ap-#{System.unique_integer([:positive])}", + name: device.name, + device_id: device.id, + match_confidence: "auto_ip" + }, + attrs + ) + ) + |> Repo.insert!() + end + + test "enriches each AP with noise floor and rf_score in metadata", + %{org: org, site: site} do + a = insert_ap(org, site, "AP-A", 149, 5745) + b = insert_ap(org, site, "AP-B", 149, 5745) + + put_noise_floor(a, -82.0) + link_preseem(org, b, %{rf_score: 6.5, qoe_score: 80.0}) + + [insight] = OwnFleetChannelConflict.evaluate(org.id) + + [ap_a, ap_b] = Enum.sort_by(insight.metadata["aps"], & &1["name"]) + + assert ap_a["noise_floor_dbm"] == -82.0 + assert ap_b["rf_score"] == 6.5 + assert ap_b["qoe_score"] == 80.0 + assert insight.metadata["has_critical_rf"] == false + end + + test "promotes urgency to critical when noise floor is high (>= -80 dBm)", + %{org: org, site: site} do + a = insert_ap(org, site, "AP-A", 149, 5745) + _b = insert_ap(org, site, "AP-B", 149, 5745) + + put_noise_floor(a, -78.0) + + [insight] = OwnFleetChannelConflict.evaluate(org.id) + assert insight.urgency == "critical" + assert insight.metadata["has_critical_rf"] == true + end + + test "promotes urgency to critical when Preseem rf_score is low (< 4.0)", + %{org: org, site: site} do + a = insert_ap(org, site, "AP-A", 149, 5745) + _b = insert_ap(org, site, "AP-B", 149, 5745) + + link_preseem(org, a, %{rf_score: 3.2}) + + [insight] = OwnFleetChannelConflict.evaluate(org.id) + assert insight.urgency == "critical" + end + end end diff --git a/test/towerops/snmp/rf_health_test.exs b/test/towerops/snmp/rf_health_test.exs new file mode 100644 index 00000000..216cfa43 --- /dev/null +++ b/test/towerops/snmp/rf_health_test.exs @@ -0,0 +1,147 @@ +defmodule Towerops.Snmp.RfHealthTest do + @moduledoc """ + Coverage for the cross-source RF health helper that combines noise-floor + sensor readings, Preseem RF / QoE scores, and average CPE SNR into a + single map per AP for use by RF-aware recommendation rules. + """ + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + import Towerops.DevicesFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Preseem.AccessPoint + alias Towerops.Snmp.Device, as: SnmpDevice + alias Towerops.Snmp.RfHealth + alias Towerops.Snmp.Sensor + alias Towerops.Snmp.WirelessClient + + setup do + user = user_fixture() + org = organization_fixture(user.id) + device = device_fixture(%{organization_id: org.id, name: "AP-Test"}) + + {:ok, snmp} = + %SnmpDevice{} + |> SnmpDevice.changeset(%{device_id: device.id, sys_name: "AP-Test"}) + |> Repo.insert() + + %{org: org, device: device, snmp: snmp} + end + + defp insert_sensor(snmp, attrs) do + full = + Map.merge( + %{ + snmp_device_id: snmp.id, + sensor_type: "noise-floor", + sensor_index: "n_#{System.unique_integer([:positive])}", + sensor_oid: "1.2.3.#{System.unique_integer([:positive])}", + sensor_unit: "dBm", + sensor_divisor: 1 + }, + attrs + ) + + %Sensor{} |> Sensor.changeset(full) |> Repo.insert!() + end + + defp insert_client(org, device, attrs) do + full = + Map.merge( + %{ + organization_id: org.id, + device_id: device.id, + mac_address: "AA:BB:CC:#{3 |> :crypto.strong_rand_bytes() |> Base.encode16()}", + last_seen_at: DateTime.truncate(DateTime.utc_now(), :second) + }, + attrs + ) + + %WirelessClient{} |> WirelessClient.changeset(full) |> Repo.insert!() + end + + describe "for_device/1" do + test "returns nil-ish struct when no signals present", %{device: device} do + assert h = RfHealth.for_device(device.id) + assert is_nil(h.noise_floor_dbm) + assert is_nil(h.rf_score) + assert is_nil(h.qoe_score) + assert is_nil(h.avg_cpe_snr_db) + assert h.client_count == 0 + end + + test "reads the most recent noise-floor sensor", %{device: device, snmp: snmp} do + insert_sensor(snmp, %{last_value: -82.0}) + + h = RfHealth.for_device(device.id) + assert h.noise_floor_dbm == -82.0 + end + + test "accepts the alternate sensor_type 'noise'", %{device: device, snmp: snmp} do + insert_sensor(snmp, %{sensor_type: "noise", last_value: -75.0}) + + h = RfHealth.for_device(device.id) + assert h.noise_floor_dbm == -75.0 + end + + test "pulls rf_score and qoe_score from the linked Preseem AccessPoint", + %{org: org, device: device} do + {:ok, _ap} = + %AccessPoint{} + |> AccessPoint.changeset(%{ + organization_id: org.id, + preseem_id: "ap-rf-#{System.unique_integer([:positive])}", + name: "Linked", + device_id: device.id, + match_confidence: "auto_ip", + rf_score: 4.2, + qoe_score: 67.0 + }) + |> Repo.insert() + + h = RfHealth.for_device(device.id) + assert h.rf_score == 4.2 + assert h.qoe_score == 67.0 + end + + test "computes average CPE SNR and counts clients seen recently", + %{org: org, device: device} do + insert_client(org, device, %{snr: 20, signal_strength: -65}) + insert_client(org, device, %{snr: 10, signal_strength: -78}) + insert_client(org, device, %{snr: nil, signal_strength: -70}) + + h = RfHealth.for_device(device.id) + assert h.avg_cpe_snr_db == 15.0 + assert h.client_count == 3 + end + end + + describe "interference_severity/1" do + test "returns :critical when noise floor is high (>= -80 dBm)" do + assert :critical = RfHealth.interference_severity(%{noise_floor_dbm: -78.0}) + end + + test "returns :critical when Preseem rf_score is low (< 4.0)" do + assert :critical = RfHealth.interference_severity(%{rf_score: 3.5}) + end + + test "returns :warning when avg CPE SNR is low (< 15)" do + assert :warning = + RfHealth.interference_severity(%{avg_cpe_snr_db: 12.0, client_count: 5}) + end + + test "returns :ok when nothing concerning" do + h = %{noise_floor_dbm: -92.0, rf_score: 7.5, avg_cpe_snr_db: 25.0, client_count: 5} + assert :ok = RfHealth.interference_severity(h) + end + + test "ignores avg_cpe_snr_db when client_count is 0" do + assert :ok = RfHealth.interference_severity(%{avg_cpe_snr_db: 5.0, client_count: 0}) + end + + test "tolerates missing fields" do + assert :ok = RfHealth.interference_severity(%{}) + end + end +end diff --git a/test/towerops_web/live/agent_live_test.exs b/test/towerops_web/live/agent_live_test.exs index 9a63615e..89f6a4c1 100644 --- a/test/towerops_web/live/agent_live_test.exs +++ b/test/towerops_web/live/agent_live_test.exs @@ -577,15 +577,21 @@ defmodule ToweropsWeb.AgentLiveTest do # test is deterministic. Without an explicit stub, behavior depends on # whether other tests have left a Req.Test stub registered for this # module — which makes this test flaky under the full suite. + # + # Use shared mode so the stub is visible to the LiveView process + # regardless of when it spawns relative to this test's setup, and + # regardless of whether the handler hops through a Task. This test + # is sync (use ToweropsWeb.ConnCase without async: true), so shared + # mode is safe. + Req.Test.set_req_test_to_shared(%{async: false}) + Req.Test.stub(ReleaseChecker, fn conn -> Req.Test.transport_error(conn, :econnrefused) end) - {:ok, view, _html} = live(conn, ~p"/agents/#{agent_token.id}") + on_exit(fn -> Req.Test.set_req_test_to_private(%{}) end) - # Allow the LiveView process to use stubs registered in this test - # process (Req.Test.stub is owner-scoped by default). - Req.Test.allow(ReleaseChecker, self(), view.pid) + {:ok, view, _html} = live(conn, ~p"/agents/#{agent_token.id}") html = view