diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 798baf3..520ff6e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/core/pyproject.toml b/core/pyproject.toml index dfacd7b..c2a73ae 100644 --- a/core/pyproject.toml +++ b/core/pyproject.toml @@ -19,6 +19,8 @@ dependencies = [ [project.optional-dependencies] dev = [ "ruff==0.15.17", + "pytest>=8", + "pytest-asyncio>=0.24", ] [project.scripts] diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..78c5011 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +asyncio_mode = auto +testpaths = tests diff --git a/tests/test_ais_decoder.py b/tests/test_ais_decoder.py new file mode 100644 index 0000000..f8d5554 --- /dev/null +++ b/tests/test_ais_decoder.py @@ -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 diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py new file mode 100644 index 0000000..9ad6461 --- /dev/null +++ b/tests/test_config_manager.py @@ -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 diff --git a/tests/test_message_bus.py b/tests/test_message_bus.py new file mode 100644 index 0000000..a06f1ff --- /dev/null +++ b/tests/test_message_bus.py @@ -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 == [] diff --git a/tests/test_render_strategies.py b/tests/test_render_strategies.py new file mode 100644 index 0000000..bed368d --- /dev/null +++ b/tests/test_render_strategies.py @@ -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()