diff --git a/test/microwaveprop/propagation/path_compute_test.exs b/test/microwaveprop/propagation/path_compute_test.exs index 2f7951fe..0d61ce3b 100644 --- a/test/microwaveprop/propagation/path_compute_test.exs +++ b/test/microwaveprop/propagation/path_compute_test.exs @@ -1,5 +1,5 @@ defmodule Microwaveprop.Propagation.PathComputeTest do - use ExUnit.Case, async: true + use Microwaveprop.DataCase, async: false use ExUnitProperties alias Microwaveprop.Propagation.BandConfig @@ -96,6 +96,42 @@ defmodule Microwaveprop.Propagation.PathComputeTest do end end + describe "resolve_location/1" do + test "parses a coordinate pair" do + assert {:ok, %{lat: 32.9, lon: -96.8}} = PathCompute.resolve_location("32.9, -96.8") + end + + test "parses a Maidenhead grid" do + assert {:ok, %{kind: :grid, label: "EM12"}} = PathCompute.resolve_location("EM12") + end + + test "blank input returns the location-required error" do + assert {:error, "Location is required"} = PathCompute.resolve_location("") + assert {:error, "Location is required"} = PathCompute.resolve_location(nil) + end + end + + describe "build_ionosphere_readout/4" do + test "returns nil for non-ionosphere bands (microwave)" do + assert PathCompute.build_ionosphere_readout(10_000, 32.9, -96.8, 100.0) == nil + assert PathCompute.build_ionosphere_readout(24_000, 32.9, -96.8, 100.0) == nil + end + + test "returns nil for bands the readout does not handle (e.g. 902 MHz)" do + # The function only opens up for [50, 144, 222, 432]; 902 falls + # through to the catch-all clause. + assert PathCompute.build_ionosphere_readout(902, 32.9, -96.8, 100.0) == nil + end + + test "returns nil when band is supported but no ionosphere data is available" do + # 6m and 2m hit the `Ionosphere.nearest_foes/2` path; with an empty + # test DB the function returns `{:error, :no_data}` and the + # readout falls through to the catch-all `nil` arm. + assert PathCompute.build_ionosphere_readout(50, 32.9, -96.8, 100.0) == nil + assert PathCompute.build_ionosphere_readout(144, 32.9, -96.8, 100.0) == nil + end + end + describe "compute_power_budget/2" do test "computes rx power and margins" do loss = %{total: 140.0, fspl: 130.0, o2: 3.0, h2o: 5.0, rain: 0.0, diffraction: 2.0} diff --git a/test/microwaveprop/rover_planning/path_test.exs b/test/microwaveprop/rover_planning/path_test.exs index a8b2a653..cd282419 100644 --- a/test/microwaveprop/rover_planning/path_test.exs +++ b/test/microwaveprop/rover_planning/path_test.exs @@ -3,6 +3,26 @@ defmodule Microwaveprop.RoverPlanning.PathTest do alias Microwaveprop.RoverPlanning.Path + describe "statuses/0" do + test "returns the full enum list" do + assert Path.statuses() == [:pending, :computing, :complete, :failed] + end + end + + describe "schema metadata" do + test "has the expected fields" do + fields = Path.__schema__(:fields) + assert :band_mhz in fields + assert :status in fields + assert :result in fields + assert :error in fields + assert :computed_at in fields + assert :mission_id in fields + assert :rover_location_id in fields + assert :station_id in fields + end + end + describe "changeset/2" do test "valid attrs produce a valid changeset" do attrs = %{ @@ -69,6 +89,19 @@ defmodule Microwaveprop.RoverPlanning.PathTest do end end + test "accepts :computing status" do + attrs = %{ + mission_id: Ecto.UUID.generate(), + rover_location_id: Ecto.UUID.generate(), + station_id: Ecto.UUID.generate(), + band_mhz: 10_000, + status: :computing + } + + changeset = Path.changeset(%Path{}, attrs) + assert changeset.valid? + end + test "accepts optional result and error fields" do attrs = %{ mission_id: Ecto.UUID.generate(), diff --git a/test/microwaveprop/rover_planning/station_test.exs b/test/microwaveprop/rover_planning/station_test.exs new file mode 100644 index 00000000..c4fc8649 --- /dev/null +++ b/test/microwaveprop/rover_planning/station_test.exs @@ -0,0 +1,104 @@ +defmodule Microwaveprop.RoverPlanning.StationTest do + use Microwaveprop.DataCase, async: true + + alias Microwaveprop.RoverPlanning.Station + + describe "changeset/2" do + test "resolves coordinate input into lat/lon" do + changeset = Station.changeset(%Station{}, %{input: "32.9, -96.8"}) + + assert changeset.valid? + assert get_change(changeset, :lat) == 32.9 + assert get_change(changeset, :lon) == -96.8 + end + + test "resolves Maidenhead grid into lat/lon and grid field" do + changeset = Station.changeset(%Station{}, %{input: "EM12kx"}) + + assert changeset.valid? + assert get_change(changeset, :grid) == "EM12KX" + assert is_float(get_change(changeset, :lat)) + assert is_float(get_change(changeset, :lon)) + end + + test "blank input is invalid" do + changeset = Station.changeset(%Station{}, %{input: ""}) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).input + end + + test "missing input is invalid" do + changeset = Station.changeset(%Station{}, %{}) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).input + end + + test "out-of-range latitude is rejected" do + # Stuff a coordinate input that LocationResolver returns happily + # but is out of physical range — validate_number should catch it. + changeset = Station.changeset(%Station{}, %{input: "95.0, 0.0"}) + refute changeset.valid? + assert Map.has_key?(errors_on(changeset), :lat) + end + + test "out-of-range longitude is rejected" do + changeset = Station.changeset(%Station{}, %{input: "0.0, 200.0"}) + refute changeset.valid? + assert Map.has_key?(errors_on(changeset), :lon) + end + + test "re-typing input wipes previously resolved fields before re-resolving" do + stale = %Station{ + callsign: "OLDCALL", + grid: "FN42", + lat: 40.0, + lon: -74.0, + input: "FN42" + } + + changeset = Station.changeset(stale, %{input: "32.9, -96.8"}) + + assert changeset.valid? + # New coordinate input clears stale callsign/grid… + assert get_change(changeset, :callsign) == nil + assert get_change(changeset, :grid) == nil + # …and replaces lat/lon with the new resolution. + assert get_change(changeset, :lat) == 32.9 + assert get_change(changeset, :lon) == -96.8 + end + + test "keeps existing lat/lon when no input change and no new resolution needed" do + preset = %Station{input: "EM12", lat: 32.5, lon: -97.0} + changeset = Station.changeset(preset, %{position: 1}) + + assert changeset.valid? + assert get_change(changeset, :position) == 1 + # lat/lon already set, no input change → no re-resolution + assert get_field(changeset, :lat) == 32.5 + assert get_field(changeset, :lon) == -97.0 + end + + test "accepts position field" do + changeset = Station.changeset(%Station{}, %{input: "32.9, -96.8", position: 3}) + assert changeset.valid? + assert get_change(changeset, :position) == 3 + end + + test "default position is 0" do + assert %Station{}.position == 0 + end + end + + describe "schema metadata" do + test "has expected fields" do + fields = Station.__schema__(:fields) + assert :callsign in fields + assert :grid in fields + assert :input in fields + assert :lat in fields + assert :lon in fields + assert :position in fields + assert :mission_id in fields + end + end +end diff --git a/test/microwaveprop/weather/hrdps_client_test.exs b/test/microwaveprop/weather/hrdps_client_test.exs index 76c306ae..4047b438 100644 --- a/test/microwaveprop/weather/hrdps_client_test.exs +++ b/test/microwaveprop/weather/hrdps_client_test.exs @@ -96,6 +96,61 @@ defmodule Microwaveprop.Weather.HrdpsClientTest do end end + describe "build_profile_from_extracted/1 — DEPR fallback" do + test "derives surface dewpoint from TMP - DEPR when DPT is missing" do + extracted = %{ + "TMP:2 m above ground" => 290.0, + "DEPR:2 m above ground" => 5.0, + "PRES:surface" => 100_000.0 + } + + profile = HrdpsClient.build_profile_from_extracted(extracted) + + assert_in_delta profile.surface_temp_c, 16.85, 0.01 + # 290 - 5 = 285 K = 11.85 C + assert_in_delta profile.surface_dewpoint_c, 11.85, 0.01 + end + + test "uses TCDC entire-atmosphere when surface variant is missing" do + extracted = %{ + "TMP:2 m above ground" => 280.0, + "DPT:2 m above ground" => 275.0, + "PRES:surface" => 100_000.0, + "TCDC:entire atmosphere" => 90.0 + } + + profile = HrdpsClient.build_profile_from_extracted(extracted) + assert profile.cloud_cover_pct == 90.0 + end + + test "returns nil surface scalars when nothing surface-level is in the input" do + profile = HrdpsClient.build_profile_from_extracted(%{}) + assert profile.surface_temp_c == nil + assert profile.surface_dewpoint_c == nil + assert profile.surface_pressure_mb == nil + assert profile.profile == [] + end + + test "skips a pressure level that is missing HGT" do + extracted = %{ + "TMP:2 m above ground" => 290.0, + "DPT:2 m above ground" => 285.0, + "PRES:surface" => 100_000.0, + # Level 850 mb has TMP+DPT but no HGT — skipped. + "TMP:850 mb" => 275.0, + "DPT:850 mb" => 270.0, + # Level 700 mb is fully populated. + "TMP:700 mb" => 265.0, + "DPT:700 mb" => 260.0, + "HGT:700 mb" => 3000.0 + } + + profile = HrdpsClient.build_profile_from_extracted(extracted) + assert length(profile.profile) == 1 + assert hd(profile.profile)["pres"] == 700.0 + end + end + describe "cycle_available?/1 (injected http_head)" do setup do prev = Application.get_env(:microwaveprop, :hrdps_http_head) diff --git a/test/microwaveprop/weather/nexrad_client_test.exs b/test/microwaveprop/weather/nexrad_client_test.exs index 608ea07d..91eb8e21 100644 --- a/test/microwaveprop/weather/nexrad_client_test.exs +++ b/test/microwaveprop/weather/nexrad_client_test.exs @@ -5,6 +5,39 @@ defmodule Microwaveprop.Weather.NexradClientTest do alias Microwaveprop.Weather.NexradCache alias Microwaveprop.Weather.NexradClient + describe "fetch_rain_cells/4 with injected http_get" do + setup do + prev = Application.get_env(:microwaveprop, :nexrad_http_get) + NexradCache.clear() + + on_exit(fn -> + if prev, + do: Application.put_env(:microwaveprop, :nexrad_http_get, prev), + else: Application.delete_env(:microwaveprop, :nexrad_http_get) + + NexradCache.clear() + end) + + :ok + end + + test "propagates an HTTP non-200 status from the IEM archive" do + Application.put_env(:microwaveprop, :nexrad_http_get, fn _url, _opts -> + {:ok, %{status: 404, body: ""}} + end) + + assert {:error, "NEXRAD n0q HTTP 404"} = NexradClient.fetch_rain_cells(32.9, -97.0, 50.0, 25.0) + end + + test "propagates a transport error" do + Application.put_env(:microwaveprop, :nexrad_http_get, fn _url, _opts -> + {:error, :timeout} + end) + + assert {:error, :timeout} = NexradClient.fetch_rain_cells(32.9, -97.0, 50.0, 25.0) + end + end + describe "round_to_5min/1" do test "rounds down to nearest 5-minute boundary" do dt = ~U[2022-08-20 14:07:32Z] diff --git a/test/microwaveprop/weather/rebatch_asos_test.exs b/test/microwaveprop/weather/rebatch_asos_test.exs new file mode 100644 index 00000000..79e2165b --- /dev/null +++ b/test/microwaveprop/weather/rebatch_asos_test.exs @@ -0,0 +1,212 @@ +defmodule Microwaveprop.Weather.RebatchAsosTest do + use Microwaveprop.DataCase, async: false + + import ExUnit.CaptureIO + + alias Microwaveprop.Repo + alias Microwaveprop.Weather.RebatchAsos + + describe "run/1 — no pending jobs" do + test "returns zeros and prints a no-op message when no jobs exist" do + output = + capture_io(fn -> + assert summary = RebatchAsos.run() + send(self(), summary) + end) + + assert_received %{found: 0, windows: 0, inserted: 0, cancelled: 0} + assert output =~ "No pending" + end + end + + describe "to_day_jobs/1 — no pending jobs" do + test "returns zeros when no asos/asos_batch jobs exist" do + output = + capture_io(fn -> + assert summary = RebatchAsos.to_day_jobs() + send(self(), summary) + end) + + assert_received %{source_jobs: 0, unique_day_keys: 0, inserted: 0, cancelled: 0} + assert output =~ "No pending" + end + end + + describe "run/1 — dry-run with pending jobs" do + test "groups by (start_dt, end_dt) and reports without mutating" do + insert_asos_job!(%{ + "fetch_type" => "asos", + "station_id" => 1, + "station_code" => "KDFW", + "start_dt" => "2026-04-18T00:00:00Z", + "end_dt" => "2026-04-18T04:00:00Z" + }) + + insert_asos_job!(%{ + "fetch_type" => "asos", + "station_id" => 2, + "station_code" => "KDAL", + "start_dt" => "2026-04-18T00:00:00Z", + "end_dt" => "2026-04-18T04:00:00Z" + }) + + output = + capture_io(fn -> + summary = RebatchAsos.run(dry_run: true) + send(self(), summary) + end) + + assert_received %{found: 2, windows: 1, inserted: 0, cancelled: 0} + assert output =~ "[dry-run]" + assert output =~ "2026-04-18T00:00:00Z" + # Original two jobs should still be available; nothing was cancelled. + assert pending_asos_count() == 2 + end + end + + describe "to_day_jobs/1 — dry-run with pending asos jobs" do + test "expands one asos job into a per-date day-key plan" do + # A single 4-hour window inside one UTC day → 1 unique (station, date). + insert_asos_job!(%{ + "fetch_type" => "asos", + "station_id" => 1, + "station_code" => "KDFW", + "start_dt" => "2026-04-18T00:00:00Z", + "end_dt" => "2026-04-18T04:00:00Z" + }) + + output = + capture_io(fn -> + summary = RebatchAsos.to_day_jobs(dry_run: true) + send(self(), summary) + end) + + assert_received %{source_jobs: 1, unique_day_keys: 1, inserted: 0, cancelled: 0} + assert output =~ "[dry-run]" + assert output =~ "KDFW" + assert output =~ "2026-04-18" + end + + test "expands an asos_batch into one day-key per (station, date)" do + insert_asos_job!(%{ + "fetch_type" => "asos_batch", + "station_ids" => [1, 2], + "station_codes" => ["KDFW", "KDAL"], + "start_dt" => "2026-04-18T00:00:00Z", + "end_dt" => "2026-04-18T04:00:00Z" + }) + + capture_io(fn -> + summary = RebatchAsos.to_day_jobs(dry_run: true) + send(self(), summary) + end) + + # One asos_batch with 2 stations × 1 date → 2 unique (station, date) keys. + assert_received %{source_jobs: 1, unique_day_keys: 2} + end + + test "asos jobs spanning two UTC dates produce two day-keys per station" do + insert_asos_job!(%{ + "fetch_type" => "asos", + "station_id" => 1, + "station_code" => "KDFW", + "start_dt" => "2026-04-18T22:00:00Z", + "end_dt" => "2026-04-19T02:00:00Z" + }) + + capture_io(fn -> + summary = RebatchAsos.to_day_jobs(dry_run: true) + send(self(), summary) + end) + + # 1 station × 2 dates → 2 unique keys. + assert_received %{source_jobs: 1, unique_day_keys: 2} + end + + test "asos jobs missing window timestamps contribute zero day-keys" do + insert_asos_job!(%{ + "fetch_type" => "asos", + "station_id" => 1, + "station_code" => "KDFW", + "start_dt" => nil, + "end_dt" => nil + }) + + capture_io(fn -> + summary = RebatchAsos.to_day_jobs(dry_run: true) + send(self(), summary) + end) + + # Job is found but expand_tuples returns [] for nil window. + assert_received %{source_jobs: 1, unique_day_keys: 0} + end + end + + describe "run/1 — apply (non-dry-run)" do + test "inserts an asos_batch job and cancels the source rows" do + Oban.Testing.with_testing_mode(:manual, fn -> + insert_asos_job!(%{ + "fetch_type" => "asos", + "station_id" => 1, + "station_code" => "KDFW", + "start_dt" => "2026-04-18T00:00:00Z", + "end_dt" => "2026-04-18T04:00:00Z" + }) + + capture_io(fn -> + summary = RebatchAsos.run() + send(self(), summary) + end) + end) + + assert_received %{found: 1, windows: 1, inserted: inserted, cancelled: cancelled} + assert inserted >= 1 + assert cancelled >= 1 + end + end + + describe "to_day_jobs/1 — apply (non-dry-run)" do + test "inserts asos_day jobs and cancels source rows" do + Oban.Testing.with_testing_mode(:manual, fn -> + insert_asos_job!(%{ + "fetch_type" => "asos", + "station_id" => 1, + "station_code" => "KDFW", + "start_dt" => "2026-04-18T00:00:00Z", + "end_dt" => "2026-04-18T04:00:00Z" + }) + + capture_io(fn -> + summary = RebatchAsos.to_day_jobs() + send(self(), summary) + end) + end) + + assert_received %{source_jobs: 1, unique_day_keys: 1, inserted: inserted, cancelled: cancelled} + assert inserted >= 1 + assert cancelled >= 1 + end + end + + # ── helpers ─────────────────────────────────────────────────────────── + + defp insert_asos_job!(args) do + {:ok, job} = + args + |> Oban.Job.new(worker: "Microwaveprop.Workers.WeatherFetchWorker", queue: "weather") + |> Repo.insert() + + job + end + + defp pending_asos_count do + Repo.aggregate( + Ecto.Query.from(j in Oban.Job, + where: + j.worker == "Microwaveprop.Workers.WeatherFetchWorker" and + j.state in ["available", "scheduled", "retryable"] + ), + :count + ) + end +end diff --git a/test/microwaveprop/workers/rover_mission_reconcile_worker_test.exs b/test/microwaveprop/workers/rover_mission_reconcile_worker_test.exs new file mode 100644 index 00000000..1267ebe8 --- /dev/null +++ b/test/microwaveprop/workers/rover_mission_reconcile_worker_test.exs @@ -0,0 +1,17 @@ +defmodule Microwaveprop.Workers.RoverMissionReconcileWorkerTest do + use Microwaveprop.DataCase, async: false + use Oban.Testing, repo: Microwaveprop.Repo + + alias Microwaveprop.Workers.RoverMissionReconcileWorker + + describe "perform/1" do + test "no-ops when the mission does not exist" do + # Idempotency: a mission that was deleted between enqueue and run + # must return :ok, not crash, so Oban doesn't retry forever. + assert :ok = + perform_job(RoverMissionReconcileWorker, %{ + mission_id: Ecto.UUID.generate() + }) + end + end +end diff --git a/test/microwaveprop/workers/rover_path_profile_worker_test.exs b/test/microwaveprop/workers/rover_path_profile_worker_test.exs new file mode 100644 index 00000000..14245ecd --- /dev/null +++ b/test/microwaveprop/workers/rover_path_profile_worker_test.exs @@ -0,0 +1,53 @@ +defmodule Microwaveprop.Workers.RoverPathProfileWorkerTest do + use Microwaveprop.DataCase, async: false + use Oban.Testing, repo: Microwaveprop.Repo + + alias Microwaveprop.Workers.RoverPathProfileWorker + + describe "perform/1" do + test "no-ops when matching path does not exist" do + args = %{ + "mission_id" => Ecto.UUID.generate(), + "rover_location_id" => Ecto.UUID.generate(), + "station_id" => Ecto.UUID.generate(), + "band_mhz" => 10_000 + } + + assert :ok = perform_job(RoverPathProfileWorker, args) + end + + test "drops job when args are missing the integer band_mhz" do + args = %{ + "mission_id" => Ecto.UUID.generate(), + "rover_location_id" => Ecto.UUID.generate(), + "station_id" => Ecto.UUID.generate() + } + + assert :ok = perform_job(RoverPathProfileWorker, args) + end + + test "drops job when band_mhz is a string" do + # `load_path/1` only matches integer band_mhz; non-integer falls + # through to the warning + nil branch. + args = %{ + "mission_id" => Ecto.UUID.generate(), + "rover_location_id" => Ecto.UUID.generate(), + "station_id" => Ecto.UUID.generate(), + "band_mhz" => "10000" + } + + assert :ok = perform_job(RoverPathProfileWorker, args) + end + end + + describe "backoff/1" do + test "exponential backoff bounded at 3600s" do + assert RoverPathProfileWorker.backoff(%Oban.Job{attempt: 1}) == 60 + assert RoverPathProfileWorker.backoff(%Oban.Job{attempt: 2}) == 120 + assert RoverPathProfileWorker.backoff(%Oban.Job{attempt: 3}) == 240 + # Caps at 3600 once exponential growth exceeds it (2^6 * 60 = 3840 > 3600). + assert RoverPathProfileWorker.backoff(%Oban.Job{attempt: 7}) == 3600 + assert RoverPathProfileWorker.backoff(%Oban.Job{attempt: 20}) == 3600 + end + end +end diff --git a/test/microwaveprop/workers/user_home_qth_lookup_worker_test.exs b/test/microwaveprop/workers/user_home_qth_lookup_worker_test.exs index cf8aaa5c..dadb96ca 100644 --- a/test/microwaveprop/workers/user_home_qth_lookup_worker_test.exs +++ b/test/microwaveprop/workers/user_home_qth_lookup_worker_test.exs @@ -4,6 +4,7 @@ defmodule Microwaveprop.Workers.UserHomeQthLookupWorkerTest do import Microwaveprop.AccountsFixtures + alias Microwaveprop.Qrz.Client alias Microwaveprop.Repo alias Microwaveprop.Workers.UserHomeQthLookupWorker @@ -28,6 +29,45 @@ defmodule Microwaveprop.Workers.UserHomeQthLookupWorkerTest do assert :ok = perform_job(UserHomeQthLookupWorker, %{user_id: Ecto.UUID.generate()}) end + test "logs and returns :ok when CallsignClient returns an error" do + user = user_fixture() + + # Make QRZ return a 404 → CallsignClient.locate returns {:error, _}. + Req.Test.stub(Client, fn conn -> + Plug.Conn.send_resp(conn, 404, "Not Found") + end) + + assert :ok = perform_job(UserHomeQthLookupWorker, %{user_id: user.id}) + reloaded = Repo.reload(user) + assert is_nil(reloaded.home_grid) + end + + test "computes grid via Maidenhead when QRZ provides only lat/lon" do + user = user_fixture() + + # No tag — falls through to Maidenhead.from_latlon/3. + Req.Test.stub(Client, fn conn -> + Plug.Conn.send_resp(conn, 200, """ + + + test-session + + #{user.callsign} + 33.10 + -96.625 + + + """) + end) + + assert :ok = perform_job(UserHomeQthLookupWorker, %{user_id: user.id}) + + reloaded = Repo.reload(user) + # Maidenhead-derived grid for ~33.10, -96.625 → EM13... + assert is_binary(reloaded.home_grid) + assert reloaded.home_grid =~ ~r/^EM13/ + end + test "populates home QTH from a successful CallsignClient lookup" do user = user_fixture() @@ -35,7 +75,7 @@ defmodule Microwaveprop.Workers.UserHomeQthLookupWorkerTest do # The callsign-location pipeline reads either lat/lon or grid from # the QRZ XML; we serve coords directly so the geocoder fallback # isn't exercised. - Req.Test.stub(Microwaveprop.Qrz.Client, fn conn -> + Req.Test.stub(Client, fn conn -> Plug.Conn.send_resp(conn, 200, """ diff --git a/test/microwaveprop_web/live/live_stash_guard_test.exs b/test/microwaveprop_web/live/live_stash_guard_test.exs new file mode 100644 index 00000000..33dc2b2d --- /dev/null +++ b/test/microwaveprop_web/live/live_stash_guard_test.exs @@ -0,0 +1,27 @@ +defmodule MicrowavepropWeb.LiveStashGuardTest do + use ExUnit.Case, async: true + + alias MicrowavepropWeb.LiveStashGuard + alias Phoenix.LiveView.Socket + + describe "stash/1" do + test "swallows ArgumentError when LiveStash hasn't been initialized" do + # An uninitialized socket has no `private.live_stash_adapter`, so + # `LiveStash.stash/1` raises ArgumentError. The guard catches it + # and returns the socket unchanged. + socket = %Socket{} + + assert ^socket = LiveStashGuard.stash(socket) + end + + test "returns socket unchanged from a fresh struct (no adapter init)" do + socket = %Socket{assigns: %{count: 1}} + + result = LiveStashGuard.stash(socket) + + # Same socket back — preserves all assigns the LV had set up. + assert result == socket + assert result.assigns.count == 1 + end + end +end