417 lines
12 KiB
Elixir
417 lines
12 KiB
Elixir
defmodule Aprsme.PacketReplayTest do
|
|
use Aprsme.DataCase, async: false
|
|
|
|
alias Aprsme.PacketReplay
|
|
|
|
setup do
|
|
start_supervised!({Registry, keys: :unique, name: Aprsme.ReplayRegistry})
|
|
:ok
|
|
end
|
|
|
|
describe "start_replay/1" do
|
|
test "raises error when bounds are missing" do
|
|
user_id = "test_user"
|
|
opts = [user_id: user_id]
|
|
|
|
assert_raise ArgumentError, "Map bounds are required for packet replay", fn ->
|
|
PacketReplay.start_replay(opts)
|
|
end
|
|
end
|
|
|
|
test "fails when user_id is missing" do
|
|
bounds = [-74.0, 40.0, -73.0, 41.0]
|
|
opts = [bounds: bounds]
|
|
|
|
assert_raise KeyError, fn ->
|
|
PacketReplay.start_replay(opts)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "stop_replay/1" do
|
|
test "returns error when no replay session exists" do
|
|
assert {:error, :not_found} = PacketReplay.stop_replay("nonexistent_user")
|
|
end
|
|
end
|
|
|
|
describe "set_replay_speed/2" do
|
|
test "validates speed is positive number" do
|
|
user_id = "test_user"
|
|
|
|
# These should raise function clause errors
|
|
assert_raise FunctionClauseError, fn ->
|
|
PacketReplay.set_replay_speed(user_id, 0)
|
|
end
|
|
|
|
assert_raise FunctionClauseError, fn ->
|
|
PacketReplay.set_replay_speed(user_id, -1.0)
|
|
end
|
|
|
|
assert_raise FunctionClauseError, fn ->
|
|
PacketReplay.set_replay_speed(user_id, "invalid")
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "update_filters/2" do
|
|
test "validates filters must be a list" do
|
|
user_id = "test_user"
|
|
|
|
assert_raise FunctionClauseError, fn ->
|
|
PacketReplay.update_filters(user_id, %{callsign: "N0CALL"})
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "init/1" do
|
|
test "initializes with default values" do
|
|
user_id = "test_user"
|
|
bounds = [-74.0, 40.0, -73.0, 41.0]
|
|
opts = [user_id: user_id, bounds: bounds]
|
|
|
|
assert {:ok, state} = PacketReplay.init(opts)
|
|
|
|
assert state.user_id == user_id
|
|
assert state.bounds == bounds
|
|
assert state.replay_speed == 5.0
|
|
assert state.limit == 5000
|
|
assert state.with_position == true
|
|
assert state.paused == false
|
|
assert state.packets_sent == 0
|
|
assert is_nil(state.callsign)
|
|
assert is_nil(state.region)
|
|
assert is_nil(state.replay_timer)
|
|
assert is_nil(state.last_packet_time)
|
|
assert %DateTime{} = state.start_time
|
|
assert %DateTime{} = state.end_time
|
|
assert %DateTime{} = state.replay_started_at
|
|
end
|
|
|
|
test "respects custom options" do
|
|
user_id = "test_user"
|
|
bounds = [-74.0, 40.0, -73.0, 41.0]
|
|
start_time = DateTime.add(DateTime.utc_now(), -1800, :second)
|
|
end_time = DateTime.utc_now()
|
|
|
|
opts = [
|
|
user_id: user_id,
|
|
bounds: bounds,
|
|
callsign: "N0CALL",
|
|
start_time: start_time,
|
|
end_time: end_time,
|
|
replay_speed: 2.0,
|
|
limit: 1000,
|
|
with_position: false
|
|
]
|
|
|
|
assert {:ok, state} = PacketReplay.init(opts)
|
|
|
|
assert state.callsign == "N0CALL"
|
|
assert state.start_time == start_time
|
|
assert state.end_time == end_time
|
|
assert state.replay_speed == 2.0
|
|
assert state.limit == 1000
|
|
assert state.with_position == false
|
|
end
|
|
|
|
test "limits start_time to 1 hour ago maximum" do
|
|
user_id = "test_user"
|
|
bounds = [-74.0, 40.0, -73.0, 41.0]
|
|
old_start_time = DateTime.add(DateTime.utc_now(), -7200, :second)
|
|
|
|
opts = [
|
|
user_id: user_id,
|
|
bounds: bounds,
|
|
start_time: old_start_time
|
|
]
|
|
|
|
assert {:ok, state} = PacketReplay.init(opts)
|
|
|
|
one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second)
|
|
assert DateTime.diff(state.start_time, one_hour_ago, :second) >= -10
|
|
end
|
|
|
|
test "sets replay topic correctly" do
|
|
user_id = "test_user"
|
|
bounds = [-74.0, 40.0, -73.0, 41.0]
|
|
opts = [user_id: user_id, bounds: bounds]
|
|
|
|
assert {:ok, state} = PacketReplay.init(opts)
|
|
|
|
assert state.replay_topic == "replay:#{user_id}"
|
|
end
|
|
end
|
|
|
|
describe "module constants and specs" do
|
|
test "has correct topic constant" do
|
|
assert Code.ensure_loaded?(PacketReplay)
|
|
end
|
|
|
|
test "has correct typespec for init" do
|
|
result = PacketReplay.init(user_id: "test", bounds: [0, 0, 1, 1])
|
|
assert {:ok, _state} = result
|
|
|
|
result2 = PacketReplay.init(user_id: "test", bounds: [0, 0, 1, 1], replay_speed: 2.0)
|
|
assert {:ok, _state} = result2
|
|
end
|
|
end
|
|
|
|
describe "handle_call/3" do
|
|
setup do
|
|
# Flush any messages lingering from init send_self.
|
|
{:ok, state} = PacketReplay.init(user_id: "caller", bounds: [0, 0, 1, 1])
|
|
|
|
receive do
|
|
:start_replay -> :ok
|
|
after
|
|
0 -> :ok
|
|
end
|
|
|
|
{:ok, state: state}
|
|
end
|
|
|
|
test "pause cancels any pending timer and marks state paused", %{state: state} do
|
|
timer_ref = Process.send_after(self(), :noop, 60_000)
|
|
state = %{state | replay_timer: timer_ref}
|
|
|
|
assert {:reply, :ok, new_state} = PacketReplay.handle_call(:pause, self(), state)
|
|
assert new_state.paused
|
|
assert is_nil(new_state.replay_timer)
|
|
end
|
|
|
|
test "resume from paused sends continue message and flips flag", %{state: state} do
|
|
state = %{state | paused: true}
|
|
assert {:reply, :ok, new_state} = PacketReplay.handle_call(:resume, self(), state)
|
|
refute new_state.paused
|
|
assert_received {:continue_replay}
|
|
end
|
|
|
|
test "resume when already running is a no-op", %{state: state} do
|
|
assert {:reply, :ok, ^state} = PacketReplay.handle_call(:resume, self(), state)
|
|
end
|
|
|
|
test "set_speed updates the replay speed", %{state: state} do
|
|
assert {:reply, :ok, %{replay_speed: 7.5}} =
|
|
PacketReplay.handle_call({:set_speed, 7.5}, self(), state)
|
|
end
|
|
|
|
test "update_filters merges new keys into state", %{state: state} do
|
|
filters = [callsign: "K1ABC", replay_speed: 3.0]
|
|
|
|
assert {:reply, :ok, new_state} =
|
|
PacketReplay.handle_call({:update_filters, filters}, self(), state)
|
|
|
|
assert new_state.callsign == "K1ABC"
|
|
assert new_state.replay_speed == 3.0
|
|
assert new_state.packets_sent == 0
|
|
end
|
|
|
|
test "update_filters rejects invalid bounds and keeps the old bounds", %{state: state} do
|
|
filters = [bounds: "not a list"]
|
|
|
|
assert {:reply, :ok, new_state} =
|
|
PacketReplay.handle_call({:update_filters, filters}, self(), state)
|
|
|
|
# Should have reverted to the original bounds.
|
|
assert new_state.bounds == state.bounds
|
|
end
|
|
|
|
test "get_info returns a public view of state", %{state: state} do
|
|
assert {:reply, info, ^state} = PacketReplay.handle_call(:get_info, self(), state)
|
|
assert info.user_id == "caller"
|
|
assert info.bounds == [0, 0, 1, 1]
|
|
assert info.paused == false
|
|
assert info.packets_sent == 0
|
|
end
|
|
end
|
|
|
|
describe "handle_info/2 paused path" do
|
|
test "pause-time :send_packet reschedules via timer rather than broadcasting" do
|
|
{:ok, state} = PacketReplay.init(user_id: "paused", bounds: [0, 0, 1, 1])
|
|
|
|
receive do
|
|
:start_replay -> :ok
|
|
after
|
|
0 -> :ok
|
|
end
|
|
|
|
state = %{state | paused: true}
|
|
packet = %{received_at: DateTime.utc_now()}
|
|
stream = Stream.repeatedly(fn -> {0.0, packet} end)
|
|
|
|
assert {:noreply, new_state} =
|
|
PacketReplay.handle_info({:send_packet, packet, stream}, state)
|
|
|
|
# A new timer ref should be set for rescheduling.
|
|
assert is_reference(new_state.replay_timer)
|
|
Process.cancel_timer(new_state.replay_timer)
|
|
end
|
|
end
|
|
|
|
describe "terminate/2" do
|
|
test "cancels pending timer and returns :ok" do
|
|
{:ok, state} = PacketReplay.init(user_id: "terminated", bounds: [0, 0, 1, 1])
|
|
|
|
receive do
|
|
:start_replay -> :ok
|
|
after
|
|
0 -> :ok
|
|
end
|
|
|
|
timer_ref = Process.send_after(self(), :noop, 60_000)
|
|
state = %{state | replay_timer: timer_ref}
|
|
|
|
assert :ok = PacketReplay.terminate(:shutdown, state)
|
|
# Timer should have been cancelled.
|
|
assert Process.read_timer(timer_ref) == false
|
|
end
|
|
|
|
test "terminate is idempotent with no pending timer" do
|
|
{:ok, state} = PacketReplay.init(user_id: "noterm", bounds: [0, 0, 1, 1])
|
|
|
|
receive do
|
|
:start_replay -> :ok
|
|
after
|
|
0 -> :ok
|
|
end
|
|
|
|
assert :ok = PacketReplay.terminate(:normal, state)
|
|
end
|
|
end
|
|
|
|
describe "handle_call/3 extra branches" do
|
|
setup do
|
|
{:ok, state} = PacketReplay.init(user_id: "extra", bounds: [0, 0, 1, 1])
|
|
|
|
receive do
|
|
:start_replay -> :ok
|
|
after
|
|
0 -> :ok
|
|
end
|
|
|
|
{:ok, state: state}
|
|
end
|
|
|
|
test "pause with no active timer flips paused flag", %{state: state} do
|
|
state = %{state | replay_timer: nil}
|
|
assert {:reply, :ok, new_state} = PacketReplay.handle_call(:pause, self(), state)
|
|
assert new_state.paused
|
|
assert is_nil(new_state.replay_timer)
|
|
end
|
|
|
|
test "update_filters accepts valid bounds format", %{state: state} do
|
|
filters = [bounds: [-74.0, 40.0, -73.0, 41.0]]
|
|
|
|
assert {:reply, :ok, new_state} =
|
|
PacketReplay.handle_call({:update_filters, filters}, self(), state)
|
|
|
|
assert new_state.bounds == [-74.0, 40.0, -73.0, 41.0]
|
|
end
|
|
|
|
test "update_filters with nil bounds keeps existing", %{state: state} do
|
|
filters = [bounds: nil]
|
|
|
|
assert {:reply, :ok, new_state} =
|
|
PacketReplay.handle_call({:update_filters, filters}, self(), state)
|
|
|
|
# nil bounds → filter branch keeps the old bounds via state.bounds.
|
|
# After Map.put, new_state.bounds is nil, then the guard checks
|
|
# is_nil(bounds) and keeps new_state as-is (so bounds stays nil).
|
|
assert is_nil(new_state.bounds) or new_state.bounds == state.bounds
|
|
end
|
|
|
|
test "update_filters cancels an active timer", %{state: state} do
|
|
timer = Process.send_after(self(), :noop, 60_000)
|
|
state = %{state | replay_timer: timer}
|
|
|
|
assert {:reply, :ok, new_state} =
|
|
PacketReplay.handle_call({:update_filters, [callsign: "X"]}, self(), state)
|
|
|
|
# Timer should be gone from state and cancelled.
|
|
assert is_nil(new_state.replay_timer)
|
|
end
|
|
end
|
|
|
|
describe "handle_info({:send_packet, packet, stream}) active path" do
|
|
test "broadcasts a packet, increments counter, and stops when stream is empty" do
|
|
{:ok, state} = PacketReplay.init(user_id: "send_empty", bounds: [0, 0, 1, 1])
|
|
|
|
receive do
|
|
:start_replay -> :ok
|
|
after
|
|
0 -> :ok
|
|
end
|
|
|
|
packet = %Aprsme.Packet{
|
|
sender: "SENDPKT1",
|
|
base_callsign: "SENDPKT1",
|
|
ssid: "0",
|
|
received_at: DateTime.utc_now(),
|
|
has_position: true
|
|
}
|
|
|
|
empty_stream = Stream.unfold(nil, fn _ -> nil end)
|
|
|
|
assert {:stop, :normal, new_state} =
|
|
PacketReplay.handle_info({:send_packet, packet, empty_stream}, state)
|
|
|
|
assert new_state.packets_sent == 1
|
|
end
|
|
|
|
test "broadcasts and schedules next when stream has more packets" do
|
|
{:ok, state} = PacketReplay.init(user_id: "send_more", bounds: [0, 0, 1, 1])
|
|
|
|
receive do
|
|
:start_replay -> :ok
|
|
after
|
|
0 -> :ok
|
|
end
|
|
|
|
packet = %Aprsme.Packet{
|
|
sender: "SENDPKT2",
|
|
base_callsign: "SENDPKT2",
|
|
ssid: "0",
|
|
received_at: DateTime.utc_now(),
|
|
has_position: true
|
|
}
|
|
|
|
next_packet = %Aprsme.Packet{
|
|
sender: "NEXT",
|
|
base_callsign: "NEXT",
|
|
ssid: "0",
|
|
received_at: DateTime.add(DateTime.utc_now(), 1, :second),
|
|
has_position: true
|
|
}
|
|
|
|
# Simple stream: one element, then done.
|
|
stream =
|
|
Stream.unfold([{0.0, next_packet}], fn
|
|
[h | t] -> {h, t}
|
|
[] -> nil
|
|
end)
|
|
|
|
assert {:noreply, new_state} =
|
|
PacketReplay.handle_info({:send_packet, packet, stream}, state)
|
|
|
|
assert new_state.packets_sent == 1
|
|
assert is_reference(new_state.replay_timer)
|
|
Process.cancel_timer(new_state.replay_timer)
|
|
end
|
|
end
|
|
|
|
describe "handle_info(:start_replay, ...)" do
|
|
test "broadcasts replay_started then stops when no matching packets" do
|
|
{:ok, state} = PacketReplay.init(user_id: "empty_stream", bounds: [0, 0, 1, 1])
|
|
|
|
receive do
|
|
:start_replay -> :ok
|
|
after
|
|
0 -> :ok
|
|
end
|
|
|
|
# With no seeded packets in the DB, stream_packets_for_replay returns an
|
|
# empty stream and handle_info :start_replay should stop the server.
|
|
assert {:stop, :normal, _state} = PacketReplay.handle_info(:start_replay, state)
|
|
end
|
|
end
|
|
end
|