Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,22 @@ jobs:
./plugins/renderers/image_renderer
- name: Import smoke test
run: python -c "import vf_core.main, mock_message_source, ais_decoder_processor, image_renderer"

test:
name: Tests (py${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.13"]
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Install core (with dev deps) and plugins under test
run: |
pip install ./core[dev]
pip install ./plugins/message_processors/ais_decoder_processor
- name: Run tests
run: pytest
2 changes: 2 additions & 0 deletions core/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ dependencies = [
[project.optional-dependencies]
dev = [
"ruff==0.15.17",
"pytest>=8",
"pytest-asyncio>=0.24",
]

[project.scripts]
Expand Down
3 changes: 3 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[pytest]
asyncio_mode = auto
testpaths = tests
85 changes: 85 additions & 0 deletions tests/test_ais_decoder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import pytest
from vf_core.message_bus import MessageBus

# The decoder plugin pulls in pyais; skip the whole module if it isn't installed.
ais_mod = pytest.importorskip("ais_decoder_processor")
AISDecoderProcessor = ais_mod.AISDecoderProcessor


@pytest.fixture
def processor():
return AISDecoderProcessor(bus=MessageBus())


def test_position_report_is_dynamic_only(processor):
decoded = {"msg_type": 1, "mmsi": 123456789, "lat": 53.4, "lon": -3.0, "speed": 12.3}
msg = processor._normalise(decoded)
assert msg is not None
assert msg["identifier"] == "123456789"
assert msg["source_type"] == "ais"
assert msg["lat"] == 53.4
assert msg["lon"] == -3.0
assert msg["speed"] == 12.3
assert "name" not in msg
assert "extension" not in msg


def test_handled_fields_not_passed_through(processor):
decoded = {"msg_type": 1, "mmsi": 123456789, "repeat": 0, "spare": 0, "lat": 1.0}
msg = processor._normalise(decoded)
for withdrawn in ("msg_type", "mmsi", "repeat", "spare"):
assert withdrawn not in msg
assert msg["lat"] == 1.0


@pytest.mark.parametrize("mmsi", ["12345678", "1234567890", "111234567"])
def test_invalid_mmsi_is_filtered(processor, mmsi):
# Too short, too long, and SAR-aircraft (111...) MMSIs are all rejected.
assert processor._normalise({"msg_type": 1, "mmsi": mmsi, "lat": 1.0}) is None


def test_type5_static_sets_name_and_extension(processor):
decoded = {
"msg_type": 5,
"mmsi": 234567890,
"shipname": "TEST VESSEL ",
"callsign": "ABC123",
"ship_type": 70,
"imo": 9000001,
"to_bow": 100,
"to_stern": 20,
"to_port": 5,
"to_starboard": 6,
}
msg = processor._normalise(decoded)
assert msg["identifier"] == "234567890"
assert msg["name"] == "TEST VESSEL" # whitespace stripped

ext = msg["extension"]
assert ext["callsign"] == "ABC123"
assert ext["ship_type"] == 70
assert ext["bow"] == 100
assert ext["stern"] == 20
assert "ship_type_name" in ext


def test_type24_part_a_sets_name_only(processor):
decoded = {"msg_type": 24, "mmsi": 234567890, "part_num": 0, "shipname": "PART A"}
msg = processor._normalise(decoded)
assert msg["name"] == "PART A"
assert "extension" not in msg


def test_type24_part_b_sets_extension(processor):
decoded = {
"msg_type": 24,
"mmsi": 234567890,
"part_num": 1,
"callsign": "CS1",
"ship_type": 60,
}
msg = processor._normalise(decoded)
assert "name" not in msg
ext = msg["extension"]
assert ext["callsign"] == "CS1"
assert ext["ship_type"] == 60
95 changes: 95 additions & 0 deletions tests/test_config_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import pytest
from vf_core.config_manager import ConfigManager


def _cm(tmp_path, name="config.toml"):
return ConfigManager(tmp_path / name)


def test_get_returns_default_for_missing_key(tmp_path):
cm = _cm(tmp_path)
assert cm.get("missing") is None
assert cm.get("a.b.c", "default") == "default"


def test_set_and_get_nested(tmp_path):
cm = _cm(tmp_path)
cm.set("a.b.c", 123)
assert cm.get("a.b.c") == 123
assert cm.get("a.b") == {"c": 123}


def test_get_returns_deep_copy(tmp_path):
cm = _cm(tmp_path)
cm.set("section", {"key": [1, 2]})
got = cm.get("section")
got["key"].append(3)
assert cm.get("section") == {"key": [1, 2]} # internal state untouched


def test_set_deep_copies_value(tmp_path):
cm = _cm(tmp_path)
original = {"key": [1, 2]}
cm.set("section", original)
original["key"].append(3)
assert cm.get("section") == {"key": [1, 2]}


def test_set_raises_on_non_dict_descent(tmp_path):
cm = _cm(tmp_path)
cm.set("a", 1)
with pytest.raises(TypeError):
cm.set("a.b", 2)


def test_has(tmp_path):
cm = _cm(tmp_path)
cm.set("a.b", 1)
assert cm.has("a.b")
assert cm.has("a")
assert not cm.has("a.b.c")
assert not cm.has("missing")


def test_load_missing_file_is_empty(tmp_path):
cm = _cm(tmp_path, "does_not_exist.toml")
cm.load() # must not raise
assert cm.get_all() == {}


def test_save_and_load_roundtrip(tmp_path):
path = tmp_path / "config.toml"
cm = ConfigManager(path)
cm.set("plugins.sources", ["mock_message_source"])
cm.set("SYSTEM.mapbox_api_key", "abc")
cm.save()
assert path.exists()

reloaded = ConfigManager(path)
reloaded.load()
assert reloaded.get("plugins.sources") == ["mock_message_source"]
assert reloaded.get("SYSTEM.mapbox_api_key") == "abc"


def test_save_creates_parent_dirs(tmp_path):
path = tmp_path / "nested" / "dir" / "config.toml"
cm = ConfigManager(path)
cm.set("a", 1)
cm.save()
assert path.exists()


def test_load_invalid_toml_raises_value_error(tmp_path):
path = tmp_path / "bad.toml"
path.write_text("not = = valid toml")
cm = ConfigManager(path)
with pytest.raises(ValueError):
cm.load()


def test_get_all_returns_copy(tmp_path):
cm = _cm(tmp_path)
cm.set("a", {"b": 1})
snapshot = cm.get_all()
snapshot["a"]["b"] = 999
assert cm.get("a.b") == 1
86 changes: 86 additions & 0 deletions tests/test_message_bus.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import asyncio

from vf_core.message_bus import MessageBus


async def _collect(bus, topic, n, out):
"""Subscribe and append the first x messages into "out", then stop."""
async for msg in bus.subscribe(topic):
out.append(msg)
if len(out) >= n:
break


async def test_publish_delivers_to_subscriber():
bus = MessageBus()
received = []
task = asyncio.create_task(_collect(bus, "ais.raw", 1, received))
await asyncio.sleep(0.05) # let the subscription register

await bus.publish("ais.raw", "!AIVDM,1,1,,B,13P;lhP005wj=OrNShTenrj80@3Q,0*28")

await asyncio.wait_for(task, timeout=1)
assert received == ["!AIVDM,1,1,,B,13P;lhP005wj=OrNShTenrj80@3Q,0*28"]


async def test_publish_reaches_all_subscribers():
bus = MessageBus()
a, b = [], []
ta = asyncio.create_task(_collect(bus, "t", 1, a))
tb = asyncio.create_task(_collect(bus, "t", 1, b))
await asyncio.sleep(0.05)

await bus.publish("t", 210)

await asyncio.wait_for(asyncio.gather(ta, tb), timeout=1)
assert a == [210]
assert b == [210]


async def test_publish_without_subscribers_is_noop():
bus = MessageBus()
await bus.publish("nobody", "x") # must not raise


async def test_subscriber_only_receives_its_topic():
bus = MessageBus()
received = []
task = asyncio.create_task(_collect(bus, "wanted", 1, received))
await asyncio.sleep(0.05)

await bus.publish("other", "ignored")
await bus.publish("wanted", "kept")

await asyncio.wait_for(task, timeout=1)
assert received == ["kept"]


async def test_messages_preserve_publish_order():
bus = MessageBus()
received = []
task = asyncio.create_task(_collect(bus, "t", 3, received))
await asyncio.sleep(0.05)

for i in range(3):
await bus.publish("t", i)

await asyncio.wait_for(task, timeout=1)
assert received == [0, 1, 2]


async def test_shutdown_stops_subscribers():
bus = MessageBus()
received = []

async def consume():
async for msg in bus.subscribe("t"):
received.append(msg)

task = asyncio.create_task(consume())
await asyncio.sleep(0.05)

await bus.shutdown()

# The shutdown sentinel should end the receive loop cleanly.
await asyncio.wait_for(task, timeout=1)
assert received == []
80 changes: 80 additions & 0 deletions tests/test_render_strategies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import asyncio

from vf_core.render_strategies import PeriodicRenderStrategy, QueuedRenderStrategy


async def test_periodic_coalesces_multiple_requests():
calls = 0

async def render():
nonlocal calls
calls += 1

strat = PeriodicRenderStrategy(render, min_interval=0.1)
await strat.start()
try:
# Several synchronous requests arrive before the loop renders once.
for _ in range(5):
strat.request_render()
await asyncio.sleep(0.05) # still within the first interval
assert calls == 1 # coalesced into a single render
finally:
await strat.stop()


async def test_periodic_renders_again_after_interval():
calls = 0

async def render():
nonlocal calls
calls += 1

strat = PeriodicRenderStrategy(render, min_interval=0.1)
await strat.start()
try:
strat.request_render()
await asyncio.sleep(0.05)
assert calls == 1

strat.request_render()
await asyncio.sleep(0.2) # past the interval
assert calls == 2
finally:
await strat.stop()


async def test_queued_processes_each_request_in_order():
rendered = []

async def render(data):
rendered.append(data)

strat = QueuedRenderStrategy(render, min_interval=0)
await strat.start()
try:
for i in range(3):
strat.request_render(i)
await asyncio.sleep(0.1)
assert rendered == [0, 1, 2]
finally:
await strat.stop()


async def test_queued_drops_oldest_when_full():
rendered = []

async def render(data):
rendered.append(data)

strat = QueuedRenderStrategy(render, min_interval=0)
# Fill beyond the queue maxsize (20) before the loop starts consuming.
for i in range(25):
strat.request_render(i)

await strat.start()
try:
await asyncio.sleep(0.1)
# The oldest 5 are dropped, the most recent 20 survive in order.
assert rendered == list(range(5, 25))
finally:
await strat.stop()