sprint-0: fundaciones VMS-Sailor
Sprint 0 completo del producto VMS-Sailor (Vessel Management System integrado para buques 30-40m). Brief de referencia en VMS_Sailor_v2_Parte_*.md (intacto). Core (vmssailor.core, 95.17% coverage, 99 tests verde): - ShipCoord: sistema naval x_pp/y_cl/z_bl frozen - Vessel, Deck, Bulkhead - Equipment, EquipmentModel, Sensor, EquipmentSpec - Tag, AlarmConfig, TagBinding, Scaling - CardInstance, Bus, Topology con validacion 21 puntos I/O AR-NMEA-IO-v1.0 - Alarm, PermissiveRule, Condition - Project agregado raiz con validacion cross-entity - Persistencia portable .vmsproj (SQLite) con roundtrip verificable Biblioteca curada seed (vmssailor.library): - systems_catalog.json completo (catalogo maestro Parte 1 sec 7) - 2 vessels: Sunseeker 76, Ferretti 850 - 2 motores: MTU 12V 2000 M96, Volvo D13-900 - 1 genset: Northern Lights M65C13 - yacht_motor_planeo.yaml (reglas heuristicas) - TODO marcado data_source=seed_estimate - requiere validacion datasheets Tools: - vms-validate-library: CLI valida biblioteca completa - vms-generate-test-project: CLI demo + verificacion roundtrip persistencia Design System + 8 mockups HTML estaticos: - docs/design_system.md (paleta Deep Ocean, gradientes, typography, motion) - docs/brand/ (logo + variantes SVG) - docs/mockups/splash, studio_main, runtime_overview, runtime_mimic_fuel (P&ID animado), runtime_alarms, runtime_trim (panel estrella con horizonte artificial), mobile_overview, mobile_trim - docs/mockups/index.html (galeria) Firmware (Sprint 12+ implementacion): - firmware/ar_nmea_io_v1/src/config/pinout.h con macros GPIO Decisiones autonomas documentadas en docs/decisions_sprint0.md. Stack: Python 3.11 + uv + Pydantic v2 + SQLite stdlib + hatchling + pytest 9 + ruff + mypy. Sin PySide6, FastAPI, Flutter ni firmware funcional (entran en sprints siguientes). Criterio de aceptacion Sprint 0: cumplido. - uv sync: OK - pytest: 99/99 verde - cov vmssailor.core: 95.17% (objetivo >=80%) - ruff: clean - vms-validate-library: OK - vms-generate-test-project: INTEGRIDAD OK Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
"""Fixtures comunes para los tests de vmssailor.core."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from vmssailor.core import (
|
||||
AlarmConfig,
|
||||
AlarmPriority,
|
||||
Bus,
|
||||
BusRole,
|
||||
CardInstance,
|
||||
ChannelType,
|
||||
Equipment,
|
||||
Project,
|
||||
Protocol,
|
||||
Scaling,
|
||||
ShipCoord,
|
||||
SignalType,
|
||||
SystemId,
|
||||
Tag,
|
||||
TagBinding,
|
||||
Topology,
|
||||
UnitSI,
|
||||
Vessel,
|
||||
VesselSubtype,
|
||||
VesselType,
|
||||
)
|
||||
from vmssailor.core.vessel import Deck
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def repo_root() -> Path:
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sample_ship_coord() -> ShipCoord:
|
||||
return ShipCoord(x_pp=10.0, y_cl=-1.5, z_bl=2.0)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def minimal_vessel() -> Vessel:
|
||||
return Vessel(
|
||||
id="test_vessel",
|
||||
name="Test Vessel",
|
||||
type=VesselType.YACHT_MOTOR,
|
||||
subtype=VesselSubtype.PLANING,
|
||||
length_overall_m=24.0,
|
||||
beam_max_m=5.5,
|
||||
draft_m=1.8,
|
||||
decks=[
|
||||
Deck(id="main", name="Main", z_bl_bottom=2.0, z_bl_top=4.5),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sample_card() -> CardInstance:
|
||||
return CardInstance(
|
||||
id="card_001",
|
||||
slot_number=1,
|
||||
bus_id="bus_main",
|
||||
bus_role=BusRole.MODBUS_SLAVE,
|
||||
modbus_address=1,
|
||||
physical_location="SM panel A",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sample_bus() -> Bus:
|
||||
return Bus(
|
||||
id="bus_main",
|
||||
name="Bus principal",
|
||||
protocol=Protocol.MODBUS_RTU,
|
||||
physical_port="COM3",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sample_topology(sample_bus: Bus, sample_card: CardInstance) -> Topology:
|
||||
return Topology(buses=[sample_bus], cards=[sample_card])
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sample_equipment() -> Equipment:
|
||||
return Equipment(
|
||||
id="eq_me_port",
|
||||
model_ref="mtu_12v_2000_m96",
|
||||
tag_prefix="ME_PORT",
|
||||
display_name="Motor babor",
|
||||
location=ShipCoord(x_pp=6.0, y_cl=-0.9, z_bl=1.2),
|
||||
deck_id="main",
|
||||
system_id=SystemId.MAIN_ENGINE,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sample_tag(sample_equipment: Equipment) -> Tag:
|
||||
return Tag(
|
||||
id=f"{sample_equipment.tag_prefix}.OIL_PRESS",
|
||||
equipment_id=sample_equipment.id,
|
||||
description="Presión aceite",
|
||||
unit_si=UnitSI.BAR,
|
||||
range_normal_min=3.5,
|
||||
range_normal_max=6.5,
|
||||
alarms=[
|
||||
AlarmConfig(
|
||||
id=f"{sample_equipment.tag_prefix}.OIL_PRESS.LOW",
|
||||
threshold=1.5,
|
||||
operator="<",
|
||||
priority=AlarmPriority.EMERGENCY,
|
||||
hysteresis=0.2,
|
||||
)
|
||||
],
|
||||
protocol=Protocol.MODBUS_RTU,
|
||||
physical_binding=TagBinding(
|
||||
card_id="card_001",
|
||||
channel_type=ChannelType.AI,
|
||||
channel_number=1,
|
||||
signal_type=SignalType.SIG_4_20_MA,
|
||||
scaling=Scaling(raw_min=4.0, raw_max=20.0, eng_min=0.0, eng_max=10.0),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sample_project(
|
||||
minimal_vessel: Vessel,
|
||||
sample_equipment: Equipment,
|
||||
sample_tag: Tag,
|
||||
sample_topology: Topology,
|
||||
) -> Project:
|
||||
return Project(
|
||||
id="test_project",
|
||||
name="Test project",
|
||||
customer="Pytest",
|
||||
vessel=minimal_vessel,
|
||||
systems_enabled=[SystemId.MAIN_ENGINE],
|
||||
equipment=[sample_equipment],
|
||||
tags=[sample_tag],
|
||||
topology=sample_topology,
|
||||
)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Test crítico Sprint 0: roundtrip de persistencia .vmsproj.
|
||||
|
||||
Criterio de aceptación de la Parte 6: `load_project(save_project(p))` produce
|
||||
un Project equivalente campo por campo.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from vmssailor.core import Project
|
||||
from vmssailor.core.persistence import load_project, save_project
|
||||
from vmssailor.core.persistence.vmsproj_reader import VmsProjError
|
||||
from vmssailor.tools.generate_test_project import build_demo_project
|
||||
|
||||
|
||||
def _projects_equivalent(a: Project, b: Project) -> bool:
|
||||
return a.model_dump(mode="json", exclude={"updated_at"}) == b.model_dump(
|
||||
mode="json", exclude={"updated_at"}
|
||||
)
|
||||
|
||||
|
||||
def test_roundtrip_minimal(sample_project: Project, tmp_path: Path) -> None:
|
||||
out = tmp_path / "minimal.vmsproj"
|
||||
save_project(sample_project, out)
|
||||
assert out.exists()
|
||||
loaded = load_project(out)
|
||||
assert _projects_equivalent(sample_project, loaded)
|
||||
|
||||
|
||||
def test_roundtrip_full_demo(tmp_path: Path) -> None:
|
||||
project = build_demo_project()
|
||||
out = tmp_path / "demo.vmsproj"
|
||||
save_project(project, out)
|
||||
loaded = load_project(out)
|
||||
assert _projects_equivalent(project, loaded), (
|
||||
"Roundtrip falló — el proyecto releído no coincide con el original."
|
||||
)
|
||||
# Stats deben coincidir 1:1
|
||||
assert project.stats() == loaded.stats()
|
||||
|
||||
|
||||
def test_vmsproj_is_real_sqlite(sample_project: Project, tmp_path: Path) -> None:
|
||||
out = tmp_path / "x.vmsproj"
|
||||
save_project(sample_project, out)
|
||||
# Cualquier herramienta SQLite debe abrirlo
|
||||
conn = sqlite3.connect(str(out))
|
||||
try:
|
||||
version = conn.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]
|
||||
assert version == 1
|
||||
# Meta esperada
|
||||
meta_rows = dict(conn.execute("SELECT key, value FROM meta"))
|
||||
assert meta_rows["file_format"] == "vmsproj"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_load_nonexistent_raises(tmp_path: Path) -> None:
|
||||
with pytest.raises(VmsProjError):
|
||||
load_project(tmp_path / "does_not_exist.vmsproj")
|
||||
|
||||
|
||||
def test_load_invalid_db_raises(tmp_path: Path) -> None:
|
||||
bad = tmp_path / "bad.vmsproj"
|
||||
bad.write_bytes(b"not a sqlite database at all")
|
||||
with pytest.raises(Exception): # noqa: B017 -- sqlite3 levanta varias clases distintas
|
||||
load_project(bad)
|
||||
|
||||
|
||||
def test_save_overwrites_existing(sample_project: Project, tmp_path: Path) -> None:
|
||||
out = tmp_path / "ow.vmsproj"
|
||||
save_project(sample_project, out)
|
||||
first_size = out.stat().st_size
|
||||
# Modifico el proyecto y vuelvo a guardar
|
||||
sample_project.notes = "Cambiado en memoria"
|
||||
save_project(sample_project, out)
|
||||
loaded = load_project(out)
|
||||
assert loaded.notes == "Cambiado en memoria"
|
||||
# El archivo sigue siendo válido
|
||||
assert out.stat().st_size > 0
|
||||
_ = first_size # no relevante para el assert principal
|
||||
|
||||
|
||||
def test_save_creates_parent_dirs(sample_project: Project, tmp_path: Path) -> None:
|
||||
out = tmp_path / "deep" / "nested" / "out.vmsproj"
|
||||
save_project(sample_project, out)
|
||||
assert out.exists()
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Tests de Alarm (instancia activa)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from vmssailor.core import Alarm
|
||||
from vmssailor.core.enums import AlarmPriority, AlarmState
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def test_alarm_active_basic() -> None:
|
||||
a = Alarm(
|
||||
id="alm_1",
|
||||
tag_id="ME_PORT.OIL_PRESS",
|
||||
alarm_config_id="ME_PORT.OIL_PRESS.LOW",
|
||||
priority=AlarmPriority.EMERGENCY,
|
||||
state=AlarmState.ACTIVE,
|
||||
timestamp_active=_now(),
|
||||
message="Oil pressure low",
|
||||
)
|
||||
assert a.state == AlarmState.ACTIVE
|
||||
assert a.timestamp_ack is None
|
||||
|
||||
|
||||
def test_alarm_ack_requires_timestamp_ack() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Alarm(
|
||||
id="a",
|
||||
tag_id="t",
|
||||
alarm_config_id="t.low",
|
||||
priority=AlarmPriority.HIGH,
|
||||
state=AlarmState.ACK,
|
||||
timestamp_active=_now(),
|
||||
# timestamp_ack faltante
|
||||
)
|
||||
|
||||
|
||||
def test_alarm_cleared_requires_timestamp_cleared() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Alarm(
|
||||
id="a",
|
||||
tag_id="t",
|
||||
alarm_config_id="t.low",
|
||||
priority=AlarmPriority.HIGH,
|
||||
state=AlarmState.CLEARED,
|
||||
timestamp_active=_now(),
|
||||
)
|
||||
|
||||
|
||||
def test_alarm_ack_timestamp_must_be_after_active() -> None:
|
||||
t0 = _now()
|
||||
with pytest.raises(ValidationError):
|
||||
Alarm(
|
||||
id="a",
|
||||
tag_id="t",
|
||||
alarm_config_id="t.low",
|
||||
priority=AlarmPriority.HIGH,
|
||||
state=AlarmState.ACK,
|
||||
timestamp_active=t0,
|
||||
timestamp_ack=t0 - timedelta(seconds=1),
|
||||
acknowledged_by="op1",
|
||||
)
|
||||
|
||||
|
||||
def test_alarm_acknowledged_by_without_ts_inconsistent() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Alarm(
|
||||
id="a",
|
||||
tag_id="t",
|
||||
alarm_config_id="t.low",
|
||||
priority=AlarmPriority.HIGH,
|
||||
state=AlarmState.ACTIVE,
|
||||
timestamp_active=_now(),
|
||||
acknowledged_by="op1",
|
||||
)
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Tests de Bus, CardInstance, Topology."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from vmssailor.core import (
|
||||
Bus,
|
||||
BusRole,
|
||||
CardInstance,
|
||||
Protocol,
|
||||
Topology,
|
||||
)
|
||||
|
||||
|
||||
def test_bus_modbus_rtu_ok() -> None:
|
||||
Bus(id="b", name="b", protocol=Protocol.MODBUS_RTU, physical_port="COM3")
|
||||
|
||||
|
||||
def test_bus_invalid_protocol_rejected() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Bus(id="b", name="b", protocol=Protocol.J1939, physical_port="COM3")
|
||||
|
||||
|
||||
def test_card_slave_requires_modbus_addr() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
CardInstance(
|
||||
id="c", slot_number=1, bus_id="bm", bus_role=BusRole.MODBUS_SLAVE
|
||||
)
|
||||
|
||||
|
||||
def test_card_n2k_node_no_modbus_addr() -> None:
|
||||
CardInstance(
|
||||
id="c", slot_number=1, bus_id="bm", bus_role=BusRole.NMEA2000_NODE
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
CardInstance(
|
||||
id="c", slot_number=1, bus_id="bm",
|
||||
bus_role=BusRole.NMEA2000_NODE, modbus_address=5,
|
||||
)
|
||||
|
||||
|
||||
def test_topology_unique_slots_per_bus() -> None:
|
||||
b = Bus(id="bm", name="bm", protocol=Protocol.MODBUS_RTU, physical_port="COM3")
|
||||
c1 = CardInstance(
|
||||
id="c1", slot_number=1, bus_id="bm",
|
||||
bus_role=BusRole.MODBUS_SLAVE, modbus_address=1,
|
||||
)
|
||||
c2 = CardInstance(
|
||||
id="c2", slot_number=1, bus_id="bm",
|
||||
bus_role=BusRole.MODBUS_SLAVE, modbus_address=2,
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
Topology(buses=[b], cards=[c1, c2])
|
||||
|
||||
|
||||
def test_topology_unique_modbus_addresses_per_bus() -> None:
|
||||
b = Bus(id="bm", name="bm", protocol=Protocol.MODBUS_RTU, physical_port="COM3")
|
||||
c1 = CardInstance(
|
||||
id="c1", slot_number=1, bus_id="bm",
|
||||
bus_role=BusRole.MODBUS_SLAVE, modbus_address=5,
|
||||
)
|
||||
c2 = CardInstance(
|
||||
id="c2", slot_number=2, bus_id="bm",
|
||||
bus_role=BusRole.MODBUS_SLAVE, modbus_address=5,
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
Topology(buses=[b], cards=[c1, c2])
|
||||
|
||||
|
||||
def test_topology_card_references_existing_bus() -> None:
|
||||
b = Bus(id="bm", name="bm", protocol=Protocol.MODBUS_RTU, physical_port="COM3")
|
||||
c = CardInstance(
|
||||
id="c", slot_number=1, bus_id="other_bus",
|
||||
bus_role=BusRole.MODBUS_SLAVE, modbus_address=1,
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
Topology(buses=[b], cards=[c])
|
||||
|
||||
|
||||
def test_topology_helpers(sample_topology: Topology) -> None:
|
||||
assert sample_topology.bus_by_id("bus_main") is not None
|
||||
assert sample_topology.bus_by_id("nope") is None
|
||||
assert sample_topology.card_by_id("card_001") is not None
|
||||
assert sample_topology.card_by_id("nope") is None
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Tests del sistema de coordenadas naval."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from vmssailor.core import ShipCoord
|
||||
|
||||
|
||||
def test_construct_valid() -> None:
|
||||
c = ShipCoord(x_pp=10.0, y_cl=-1.5, z_bl=2.0)
|
||||
assert c.x_pp == 10.0
|
||||
assert c.y_cl == -1.5
|
||||
assert c.z_bl == 2.0
|
||||
|
||||
|
||||
def test_frozen() -> None:
|
||||
c = ShipCoord(x_pp=10.0, y_cl=0.0, z_bl=1.0)
|
||||
with pytest.raises(ValidationError):
|
||||
c.x_pp = 99.0 # type: ignore[misc]
|
||||
|
||||
|
||||
def test_as_tuple() -> None:
|
||||
c = ShipCoord(x_pp=1.0, y_cl=2.0, z_bl=3.0)
|
||||
assert c.as_tuple() == (1.0, 2.0, 3.0)
|
||||
|
||||
|
||||
def test_is_starboard_port_centerline() -> None:
|
||||
starboard = ShipCoord(x_pp=5.0, y_cl=1.0, z_bl=0.0)
|
||||
port = ShipCoord(x_pp=5.0, y_cl=-1.0, z_bl=0.0)
|
||||
centerline = ShipCoord(x_pp=5.0, y_cl=0.0, z_bl=0.0)
|
||||
assert starboard.is_starboard()
|
||||
assert not starboard.is_port()
|
||||
assert not starboard.is_centerline()
|
||||
assert port.is_port()
|
||||
assert not port.is_starboard()
|
||||
assert centerline.is_centerline()
|
||||
assert not centerline.is_starboard()
|
||||
assert not centerline.is_port()
|
||||
|
||||
|
||||
def test_distance_to() -> None:
|
||||
a = ShipCoord(x_pp=0.0, y_cl=0.0, z_bl=0.0)
|
||||
b = ShipCoord(x_pp=3.0, y_cl=4.0, z_bl=0.0)
|
||||
assert a.distance_to(b) == pytest.approx(5.0)
|
||||
|
||||
|
||||
def test_out_of_range_rejected() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
ShipCoord(x_pp=999.0, y_cl=0.0, z_bl=0.0)
|
||||
with pytest.raises(ValidationError):
|
||||
ShipCoord(x_pp=10.0, y_cl=999.0, z_bl=0.0)
|
||||
with pytest.raises(ValidationError):
|
||||
ShipCoord(x_pp=10.0, y_cl=0.0, z_bl=-999.0)
|
||||
|
||||
|
||||
def test_extra_fields_forbidden() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
ShipCoord(x_pp=1.0, y_cl=0.0, z_bl=0.0, foo="bar") # type: ignore[call-arg]
|
||||
|
||||
|
||||
def test_str_representation() -> None:
|
||||
c = ShipCoord(x_pp=10.0, y_cl=-1.5, z_bl=2.0)
|
||||
s = str(c)
|
||||
assert "10.00" in s
|
||||
assert "-1.50" in s
|
||||
assert "+2.00" in s
|
||||
assert "[m]" in s
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Smoke tests para los enums del core."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from vmssailor.core.enums import (
|
||||
AlarmPriority,
|
||||
BusRole,
|
||||
ChannelType,
|
||||
ControlMode,
|
||||
SystemId,
|
||||
UnitSI,
|
||||
VesselType,
|
||||
)
|
||||
|
||||
|
||||
def test_systemid_main_engine_value() -> None:
|
||||
assert SystemId.MAIN_ENGINE.value == "main_engine"
|
||||
|
||||
|
||||
def test_channel_type_values() -> None:
|
||||
assert ChannelType.AI.value == "ai"
|
||||
assert ChannelType.DI.value == "di"
|
||||
assert ChannelType.DO.value == "do"
|
||||
assert ChannelType.RPM.value == "rpm"
|
||||
|
||||
|
||||
def test_alarm_priority_complete_set() -> None:
|
||||
values = {p.value for p in AlarmPriority}
|
||||
assert values == {"emergency", "high", "low", "info"}
|
||||
|
||||
|
||||
def test_busrole_includes_dual_and_bridge() -> None:
|
||||
assert BusRole.DUAL.value == "dual"
|
||||
assert BusRole.BRIDGE.value == "bridge"
|
||||
|
||||
|
||||
def test_controlmode_default_states_present() -> None:
|
||||
assert ControlMode.MONITOR.value == "monitor"
|
||||
assert ControlMode.MANUAL.value == "manual"
|
||||
assert ControlMode.AUTO.value == "auto"
|
||||
assert ControlMode.FUTURE.value == "future"
|
||||
|
||||
|
||||
def test_vesseltype_complete() -> None:
|
||||
values = {v.value for v in VesselType}
|
||||
assert {"yacht_motor", "yacht_sail", "fishing", "patrol", "ferry", "offshore_support"} <= values
|
||||
|
||||
|
||||
def test_unit_si_si_only() -> None:
|
||||
# No deben aparecer unidades imperiales en el enum interno.
|
||||
forbidden = {"ft", "psi", "F", "kt"}
|
||||
values = {u.value.lower() for u in UnitSI}
|
||||
assert not (forbidden & values), f"UnitSI debe ser solo SI. Encontrado: {forbidden & values}"
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Tests de Equipment, EquipmentModel, Sensor."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from vmssailor.core import (
|
||||
EquipmentCategory,
|
||||
EquipmentModel,
|
||||
EquipmentSpec,
|
||||
Sensor,
|
||||
SystemId,
|
||||
UnitSI,
|
||||
)
|
||||
|
||||
|
||||
def test_sensor_basic() -> None:
|
||||
s = Sensor(
|
||||
id="oil_press",
|
||||
name="Oil Pressure",
|
||||
unit_si=UnitSI.BAR,
|
||||
range_normal_min=3.0,
|
||||
range_normal_max=5.5,
|
||||
)
|
||||
assert s.unit_si == UnitSI.BAR
|
||||
|
||||
|
||||
def test_sensor_range_max_below_min_rejected() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Sensor(
|
||||
id="x",
|
||||
name="X",
|
||||
unit_si=UnitSI.BAR,
|
||||
range_normal_min=5.0,
|
||||
range_normal_max=2.0,
|
||||
)
|
||||
|
||||
|
||||
def test_equipment_model_with_sensors() -> None:
|
||||
em = EquipmentModel(
|
||||
id="mtu_test",
|
||||
manufacturer="MTU",
|
||||
model_name="Test",
|
||||
category=EquipmentCategory.ENGINE_MAIN,
|
||||
typical_systems=[SystemId.MAIN_ENGINE],
|
||||
specs=EquipmentSpec(power_kw=1432, rpm_nominal=2450),
|
||||
default_sensors=[
|
||||
Sensor(id="rpm", name="RPM", unit_si=UnitSI.RPM),
|
||||
Sensor(id="oil_press", name="Oil P", unit_si=UnitSI.BAR),
|
||||
],
|
||||
)
|
||||
assert em.specs.power_kw == 1432
|
||||
assert len(em.default_sensors) == 2
|
||||
|
||||
|
||||
def test_equipment_model_duplicate_sensor_ids_rejected() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
EquipmentModel(
|
||||
id="x",
|
||||
manufacturer="X",
|
||||
model_name="X",
|
||||
category=EquipmentCategory.ENGINE_MAIN,
|
||||
default_sensors=[
|
||||
Sensor(id="rpm", name="RPM"),
|
||||
Sensor(id="rpm", name="RPM dup"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_equipment_instance(sample_equipment) -> None:
|
||||
assert sample_equipment.tag_prefix == "ME_PORT"
|
||||
assert sample_equipment.system_id == SystemId.MAIN_ENGINE
|
||||
assert sample_equipment.installed
|
||||
|
||||
|
||||
def test_equipment_invalid_tag_prefix_rejected() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
from vmssailor.core import Equipment, ShipCoord
|
||||
|
||||
Equipment(
|
||||
id="x",
|
||||
model_ref="m",
|
||||
tag_prefix="lowercase_bad", # debe ser mayúsculas
|
||||
display_name="X",
|
||||
location=ShipCoord(x_pp=1.0, y_cl=0.0, z_bl=0.0),
|
||||
system_id=SystemId.MAIN_ENGINE,
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Tests de PermissiveRule y Condition."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from vmssailor.core import PermissiveRule
|
||||
from vmssailor.core.permissive import Condition
|
||||
|
||||
|
||||
def test_condition_basic() -> None:
|
||||
c = Condition(
|
||||
tag_ref="ME_PORT.OIL_PRESS",
|
||||
operator=">",
|
||||
threshold=0.3,
|
||||
severity="fail",
|
||||
)
|
||||
assert c.severity == "fail"
|
||||
|
||||
|
||||
def test_condition_between_requires_both_thresholds() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Condition(
|
||||
tag_ref="x",
|
||||
operator="between",
|
||||
threshold_low=1.0,
|
||||
# threshold_high faltante
|
||||
)
|
||||
|
||||
|
||||
def test_permissive_rule_empty_conditions_rejected() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
PermissiveRule(id="r", action_id="START", conditions=[])
|
||||
|
||||
|
||||
def test_permissive_rule_with_multiple_conditions() -> None:
|
||||
r = PermissiveRule(
|
||||
id="rule_start",
|
||||
action_id="START_ME_PORT",
|
||||
conditions=[
|
||||
Condition(tag_ref="ME_PORT.OIL_PRESS", operator=">", threshold=0.3),
|
||||
Condition(tag_ref="ME_PORT.COOLANT_TEMP", operator=">", threshold=5.0),
|
||||
Condition(tag_ref="ME_PORT.ESTOP_ACTIVE", operator="is_false"),
|
||||
],
|
||||
on_fail_message="No es seguro arrancar.",
|
||||
)
|
||||
assert len(r.conditions) == 3
|
||||
assert r.conditions[2].operator == "is_false"
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Tests del agregado raíz Project."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from vmssailor.core import (
|
||||
Bus,
|
||||
BusRole,
|
||||
CardInstance,
|
||||
Equipment,
|
||||
PermissiveRule,
|
||||
Project,
|
||||
Protocol,
|
||||
ShipCoord,
|
||||
SystemId,
|
||||
Tag,
|
||||
Topology,
|
||||
UnitSI,
|
||||
Vessel,
|
||||
)
|
||||
from vmssailor.core.permissive import Condition
|
||||
|
||||
|
||||
def test_project_stats(sample_project: Project) -> None:
|
||||
stats = sample_project.stats()
|
||||
assert stats["systems"] == 1
|
||||
assert stats["equipment"] == 1
|
||||
assert stats["tags"] == 1
|
||||
assert stats["tags_with_alarms"] == 1
|
||||
assert stats["buses"] == 1
|
||||
assert stats["cards"] == 1
|
||||
|
||||
|
||||
def test_project_equipment_requires_enabled_system(
|
||||
minimal_vessel: Vessel, sample_topology: Topology
|
||||
) -> None:
|
||||
eq = Equipment(
|
||||
id="eq",
|
||||
model_ref="m",
|
||||
tag_prefix="X",
|
||||
display_name="x",
|
||||
location=ShipCoord(x_pp=5.0, y_cl=0.0, z_bl=1.0),
|
||||
system_id=SystemId.WATERMAKER, # NO en systems_enabled
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
Project(
|
||||
id="p",
|
||||
name="p",
|
||||
vessel=minimal_vessel,
|
||||
systems_enabled=[SystemId.MAIN_ENGINE],
|
||||
equipment=[eq],
|
||||
topology=sample_topology,
|
||||
)
|
||||
|
||||
|
||||
def test_project_tag_equipment_id_must_exist(
|
||||
minimal_vessel: Vessel, sample_topology: Topology, sample_equipment: Equipment
|
||||
) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Project(
|
||||
id="p",
|
||||
name="p",
|
||||
vessel=minimal_vessel,
|
||||
systems_enabled=[SystemId.MAIN_ENGINE],
|
||||
equipment=[sample_equipment],
|
||||
tags=[
|
||||
Tag(
|
||||
id="GHOST.X",
|
||||
equipment_id="ghost_eq", # no existe
|
||||
unit_si=UnitSI.BAR,
|
||||
protocol=Protocol.MODBUS_RTU,
|
||||
address=1,
|
||||
)
|
||||
],
|
||||
topology=sample_topology,
|
||||
)
|
||||
|
||||
|
||||
def test_project_tag_binding_must_reference_existing_card(
|
||||
minimal_vessel: Vessel,
|
||||
) -> None:
|
||||
from vmssailor.core import ChannelType, SignalType, TagBinding
|
||||
|
||||
b = Bus(id="bm", name="bm", protocol=Protocol.MODBUS_RTU, physical_port="COM3")
|
||||
c = CardInstance(
|
||||
id="card_001", slot_number=1, bus_id="bm",
|
||||
bus_role=BusRole.MODBUS_SLAVE, modbus_address=1,
|
||||
)
|
||||
topo = Topology(buses=[b], cards=[c])
|
||||
eq = Equipment(
|
||||
id="eq",
|
||||
model_ref="m",
|
||||
tag_prefix="X",
|
||||
display_name="x",
|
||||
location=ShipCoord(x_pp=5.0, y_cl=0.0, z_bl=1.0),
|
||||
system_id=SystemId.MAIN_ENGINE,
|
||||
)
|
||||
bad_tag = Tag(
|
||||
id="X.PRESS",
|
||||
equipment_id="eq",
|
||||
unit_si=UnitSI.BAR,
|
||||
protocol=Protocol.MODBUS_RTU,
|
||||
physical_binding=TagBinding(
|
||||
card_id="ghost_card",
|
||||
channel_type=ChannelType.AI,
|
||||
channel_number=1,
|
||||
signal_type=SignalType.SIG_4_20_MA,
|
||||
),
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
Project(
|
||||
id="p",
|
||||
name="p",
|
||||
vessel=minimal_vessel,
|
||||
systems_enabled=[SystemId.MAIN_ENGINE],
|
||||
equipment=[eq],
|
||||
tags=[bad_tag],
|
||||
topology=topo,
|
||||
)
|
||||
|
||||
|
||||
def test_project_permissive_condition_must_reference_existing_tag(
|
||||
sample_project: Project,
|
||||
) -> None:
|
||||
bad_rule = PermissiveRule(
|
||||
id="bad",
|
||||
action_id="DO_X",
|
||||
conditions=[Condition(tag_ref="GHOST.TAG", operator=">", threshold=1.0)],
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
Project(
|
||||
id=sample_project.id,
|
||||
name=sample_project.name,
|
||||
vessel=sample_project.vessel,
|
||||
systems_enabled=sample_project.systems_enabled,
|
||||
equipment=sample_project.equipment,
|
||||
tags=sample_project.tags,
|
||||
topology=sample_project.topology,
|
||||
permissive_rules=[bad_rule],
|
||||
)
|
||||
|
||||
|
||||
def test_project_helpers(sample_project: Project) -> None:
|
||||
eq = sample_project.equipment_by_id("eq_me_port")
|
||||
assert eq is not None
|
||||
assert eq.tag_prefix == "ME_PORT"
|
||||
tag = sample_project.tag_by_id("ME_PORT.OIL_PRESS")
|
||||
assert tag is not None
|
||||
tags_eq = sample_project.tags_for_equipment("eq_me_port")
|
||||
assert len(tags_eq) == 1
|
||||
tags_sys = sample_project.tags_for_system(SystemId.MAIN_ENGINE)
|
||||
assert len(tags_sys) == 1
|
||||
|
||||
|
||||
def test_project_touch_updates_timestamp(sample_project: Project) -> None:
|
||||
original = sample_project.updated_at
|
||||
sample_project.touch()
|
||||
assert sample_project.updated_at >= original
|
||||
|
||||
|
||||
def test_project_equipment_unique_tag_prefix(minimal_vessel: Vessel) -> None:
|
||||
e1 = Equipment(
|
||||
id="e1", model_ref="m", tag_prefix="ME",
|
||||
display_name="A", location=ShipCoord(x_pp=1, y_cl=0, z_bl=0),
|
||||
system_id=SystemId.MAIN_ENGINE,
|
||||
)
|
||||
e2 = Equipment(
|
||||
id="e2", model_ref="m", tag_prefix="ME", # mismo prefix
|
||||
display_name="B", location=ShipCoord(x_pp=2, y_cl=0, z_bl=0),
|
||||
system_id=SystemId.MAIN_ENGINE,
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
Project(
|
||||
id="p", name="p", vessel=minimal_vessel,
|
||||
systems_enabled=[SystemId.MAIN_ENGINE],
|
||||
equipment=[e1, e2],
|
||||
)
|
||||
|
||||
|
||||
def test_project_equipment_deck_must_exist(minimal_vessel: Vessel) -> None:
|
||||
eq = Equipment(
|
||||
id="eq", model_ref="m", tag_prefix="X",
|
||||
display_name="x", location=ShipCoord(x_pp=1, y_cl=0, z_bl=0),
|
||||
deck_id="non_existent_deck",
|
||||
system_id=SystemId.MAIN_ENGINE,
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
Project(
|
||||
id="p", name="p", vessel=minimal_vessel,
|
||||
systems_enabled=[SystemId.MAIN_ENGINE],
|
||||
equipment=[eq],
|
||||
)
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Tests de Tag, AlarmConfig, TagBinding, Scaling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from vmssailor.core import (
|
||||
AlarmConfig,
|
||||
AlarmPriority,
|
||||
ChannelType,
|
||||
ControlMode,
|
||||
Protocol,
|
||||
Scaling,
|
||||
SignalType,
|
||||
Tag,
|
||||
TagBinding,
|
||||
UnitSI,
|
||||
)
|
||||
|
||||
|
||||
def test_scaling_apply_and_invert() -> None:
|
||||
s = Scaling(raw_min=4.0, raw_max=20.0, eng_min=0.0, eng_max=10.0)
|
||||
assert s.apply(4.0) == pytest.approx(0.0)
|
||||
assert s.apply(12.0) == pytest.approx(5.0)
|
||||
assert s.apply(20.0) == pytest.approx(10.0)
|
||||
assert s.invert(5.0) == pytest.approx(12.0)
|
||||
|
||||
|
||||
def test_scaling_equal_raw_rejected() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Scaling(raw_min=4.0, raw_max=4.0, eng_min=0.0, eng_max=10.0)
|
||||
|
||||
|
||||
def test_scaling_equal_eng_rejected() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Scaling(raw_min=4.0, raw_max=20.0, eng_min=5.0, eng_max=5.0)
|
||||
|
||||
|
||||
def test_tag_binding_channel_within_capacity() -> None:
|
||||
TagBinding(
|
||||
card_id="card_001",
|
||||
channel_type=ChannelType.AI,
|
||||
channel_number=4,
|
||||
signal_type=SignalType.SIG_4_20_MA,
|
||||
)
|
||||
|
||||
|
||||
def test_tag_binding_channel_exceeds_capacity_rejected() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
TagBinding(
|
||||
card_id="card_001",
|
||||
channel_type=ChannelType.AI,
|
||||
channel_number=5, # cap = 4
|
||||
signal_type=SignalType.SIG_4_20_MA,
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
TagBinding(
|
||||
card_id="card_001",
|
||||
channel_type=ChannelType.RPM,
|
||||
channel_number=2, # cap = 1
|
||||
signal_type=SignalType.PULSE_MAGNETIC_PICKUP,
|
||||
)
|
||||
|
||||
|
||||
def test_alarm_config_basic() -> None:
|
||||
a = AlarmConfig(
|
||||
id="x.low",
|
||||
threshold=1.5,
|
||||
operator="<",
|
||||
priority=AlarmPriority.EMERGENCY,
|
||||
hysteresis=0.2,
|
||||
)
|
||||
assert a.priority == AlarmPriority.EMERGENCY
|
||||
|
||||
|
||||
def test_alarm_config_invalid_operator() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
AlarmConfig(id="x", threshold=1.0, operator="??", priority=AlarmPriority.HIGH)
|
||||
|
||||
|
||||
def test_tag_controllable_must_not_be_monitor() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Tag(
|
||||
id="X.START",
|
||||
unit_si=UnitSI.BOOL,
|
||||
controllable=True,
|
||||
control_mode=ControlMode.MONITOR, # inconsistente
|
||||
protocol=Protocol.MODBUS_RTU,
|
||||
address=1,
|
||||
)
|
||||
|
||||
|
||||
def test_tag_non_controllable_must_be_monitor() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Tag(
|
||||
id="X.PRESS",
|
||||
unit_si=UnitSI.BAR,
|
||||
controllable=False,
|
||||
control_mode=ControlMode.MANUAL,
|
||||
protocol=Protocol.MODBUS_RTU,
|
||||
address=1,
|
||||
)
|
||||
|
||||
|
||||
def test_tag_internal_no_address() -> None:
|
||||
Tag(
|
||||
id="VIRT.STATE",
|
||||
unit_si=UnitSI.NONE,
|
||||
protocol=Protocol.INTERNAL,
|
||||
address=None,
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
Tag(
|
||||
id="VIRT.STATE",
|
||||
unit_si=UnitSI.NONE,
|
||||
protocol=Protocol.INTERNAL,
|
||||
address=42, # INTERNAL no debe tener address
|
||||
)
|
||||
|
||||
|
||||
def test_tag_modbus_requires_address_or_binding() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Tag(
|
||||
id="X.OIL",
|
||||
unit_si=UnitSI.BAR,
|
||||
protocol=Protocol.MODBUS_RTU,
|
||||
address=None,
|
||||
physical_binding=None,
|
||||
)
|
||||
|
||||
|
||||
def test_tag_id_pattern() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Tag(id="lowercase.bad", unit_si=UnitSI.NONE, protocol=Protocol.INTERNAL)
|
||||
|
||||
|
||||
def test_tag_alarms_unique(sample_tag: Tag) -> None:
|
||||
assert len(sample_tag.alarms) == 1
|
||||
a = sample_tag.alarms[0]
|
||||
assert a.priority == AlarmPriority.EMERGENCY
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Tests del validador cross-entity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from vmssailor.core import (
|
||||
Bus,
|
||||
BusRole,
|
||||
CardInstance,
|
||||
ChannelType,
|
||||
Equipment,
|
||||
Project,
|
||||
Protocol,
|
||||
Scaling,
|
||||
ShipCoord,
|
||||
SignalType,
|
||||
SystemId,
|
||||
Tag,
|
||||
TagBinding,
|
||||
Topology,
|
||||
UnitSI,
|
||||
Vessel,
|
||||
VesselSubtype,
|
||||
VesselType,
|
||||
)
|
||||
from vmssailor.core.validation import Severity, validate_project
|
||||
|
||||
|
||||
def _make_vessel() -> Vessel:
|
||||
return Vessel(
|
||||
id="v",
|
||||
name="V",
|
||||
type=VesselType.YACHT_MOTOR,
|
||||
subtype=VesselSubtype.PLANING,
|
||||
length_overall_m=20.0,
|
||||
beam_max_m=5.0,
|
||||
draft_m=1.5,
|
||||
)
|
||||
|
||||
|
||||
def _basic_topology() -> Topology:
|
||||
b = Bus(id="bm", name="bm", protocol=Protocol.MODBUS_RTU, physical_port="COM3")
|
||||
c = CardInstance(
|
||||
id="c1", slot_number=1, bus_id="bm",
|
||||
bus_role=BusRole.MODBUS_SLAVE, modbus_address=1,
|
||||
)
|
||||
return Topology(buses=[b], cards=[c])
|
||||
|
||||
|
||||
def test_orphan_system_warning() -> None:
|
||||
p = Project(
|
||||
id="p", name="p", vessel=_make_vessel(),
|
||||
systems_enabled=[SystemId.MAIN_ENGINE, SystemId.WATERMAKER],
|
||||
equipment=[],
|
||||
topology=_basic_topology(),
|
||||
)
|
||||
r = validate_project(p)
|
||||
assert r.ok()
|
||||
assert any(i.code == "SYSTEM_WITHOUT_EQUIPMENT" for i in r.warnings)
|
||||
|
||||
|
||||
def test_card_capacity_high_info() -> None:
|
||||
topo = _basic_topology()
|
||||
eq = Equipment(
|
||||
id="eq", model_ref="m", tag_prefix="X",
|
||||
display_name="x", location=ShipCoord(x_pp=5, y_cl=0, z_bl=1),
|
||||
system_id=SystemId.MAIN_ENGINE,
|
||||
)
|
||||
|
||||
# 4 AI = 100% (at-limit -> warning)
|
||||
tags = []
|
||||
for i in range(4):
|
||||
tags.append(
|
||||
Tag(
|
||||
id=f"X.AI{i + 1}",
|
||||
equipment_id="eq",
|
||||
unit_si=UnitSI.BAR,
|
||||
protocol=Protocol.MODBUS_RTU,
|
||||
physical_binding=TagBinding(
|
||||
card_id="c1",
|
||||
channel_type=ChannelType.AI,
|
||||
channel_number=i + 1,
|
||||
signal_type=SignalType.SIG_4_20_MA,
|
||||
scaling=Scaling(raw_min=4, raw_max=20, eng_min=0, eng_max=10),
|
||||
),
|
||||
)
|
||||
)
|
||||
p = Project(
|
||||
id="p", name="p", vessel=_make_vessel(),
|
||||
systems_enabled=[SystemId.MAIN_ENGINE],
|
||||
equipment=[eq],
|
||||
tags=tags,
|
||||
topology=topo,
|
||||
)
|
||||
r = validate_project(p)
|
||||
codes = {i.code for i in r.issues}
|
||||
assert "CARD_CAPACITY_AT_LIMIT" in codes
|
||||
|
||||
|
||||
def test_equipment_out_of_hull_warning() -> None:
|
||||
eq = Equipment(
|
||||
id="eq", model_ref="m", tag_prefix="X",
|
||||
display_name="x",
|
||||
location=ShipCoord(x_pp=100.0, y_cl=0.0, z_bl=1.0), # fuera del buque
|
||||
system_id=SystemId.MAIN_ENGINE,
|
||||
)
|
||||
p = Project(
|
||||
id="p", name="p", vessel=_make_vessel(),
|
||||
systems_enabled=[SystemId.MAIN_ENGINE],
|
||||
equipment=[eq],
|
||||
topology=_basic_topology(),
|
||||
)
|
||||
r = validate_project(p)
|
||||
codes = {i.code for i in r.issues}
|
||||
assert "EQUIPMENT_OUT_OF_HULL" in codes
|
||||
|
||||
|
||||
def test_validation_report_format() -> None:
|
||||
p = Project(
|
||||
id="p", name="p", vessel=_make_vessel(),
|
||||
systems_enabled=[],
|
||||
equipment=[],
|
||||
topology=_basic_topology(),
|
||||
)
|
||||
r = validate_project(p)
|
||||
assert "OK" in r.format() or "Total" in r.format()
|
||||
|
||||
|
||||
def test_severity_values() -> None:
|
||||
assert Severity.ERROR.value == "error"
|
||||
assert Severity.WARNING.value == "warning"
|
||||
assert Severity.INFO.value == "info"
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Tests de Vessel, Deck, Bulkhead."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from vmssailor.core import Vessel, VesselSubtype, VesselType
|
||||
from vmssailor.core.vessel import Bulkhead, Deck
|
||||
|
||||
|
||||
def test_deck_height() -> None:
|
||||
d = Deck(id="main", name="Main", z_bl_bottom=2.0, z_bl_top=4.5)
|
||||
assert d.height() == pytest.approx(2.5)
|
||||
|
||||
|
||||
def test_deck_polygon_empty_ok() -> None:
|
||||
Deck(id="main", name="Main", z_bl_bottom=2.0, z_bl_top=4.5)
|
||||
|
||||
|
||||
def test_deck_polygon_too_few_vertices_rejected() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Deck(
|
||||
id="main",
|
||||
name="Main",
|
||||
z_bl_bottom=2.0,
|
||||
z_bl_top=4.5,
|
||||
polygon_xy=[(0.0, 0.0), (1.0, 0.0)],
|
||||
)
|
||||
|
||||
|
||||
def test_minimal_vessel(minimal_vessel: Vessel) -> None:
|
||||
assert minimal_vessel.length_overall_m == 24.0
|
||||
assert minimal_vessel.type == VesselType.YACHT_MOTOR
|
||||
assert minimal_vessel.subtype == VesselSubtype.PLANING
|
||||
assert len(minimal_vessel.decks) == 1
|
||||
|
||||
|
||||
def test_vessel_duplicate_deck_ids_rejected() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Vessel(
|
||||
id="x",
|
||||
name="X",
|
||||
type=VesselType.YACHT_MOTOR,
|
||||
subtype=VesselSubtype.PLANING,
|
||||
length_overall_m=20.0,
|
||||
beam_max_m=5.0,
|
||||
draft_m=1.5,
|
||||
decks=[
|
||||
Deck(id="main", name="A", z_bl_bottom=0.0, z_bl_top=2.0),
|
||||
Deck(id="main", name="B", z_bl_bottom=2.0, z_bl_top=4.0),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_vessel_position_helpers(minimal_vessel: Vessel) -> None:
|
||||
bow = minimal_vessel.position_at_bow()
|
||||
assert bow.x_pp == minimal_vessel.length_overall_m
|
||||
origin = minimal_vessel.position_at_origin()
|
||||
assert origin.x_pp == 0.0
|
||||
|
||||
|
||||
def test_vessel_invalid_length_rejected() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Vessel(
|
||||
id="x",
|
||||
name="X",
|
||||
type=VesselType.YACHT_MOTOR,
|
||||
subtype=VesselSubtype.PLANING,
|
||||
length_overall_m=-1.0,
|
||||
beam_max_m=5.0,
|
||||
draft_m=1.5,
|
||||
)
|
||||
|
||||
|
||||
def test_bulkhead_basic() -> None:
|
||||
b = Bulkhead(id="col", name="Collision", x_pp=21.0)
|
||||
assert b.x_pp == 21.0
|
||||
assert b.description == ""
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Tests del library loader."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from vmssailor.library.loader import load_library
|
||||
|
||||
|
||||
def test_loader_returns_result_with_format() -> None:
|
||||
r = load_library()
|
||||
s = r.format()
|
||||
assert "Library load summary" in s
|
||||
assert "Vessels" in s
|
||||
assert "EquipmentModels" in s
|
||||
|
||||
|
||||
def test_loader_handles_corrupt_json(tmp_path: Path) -> None:
|
||||
# Construimos una biblioteca falsa con un JSON malo
|
||||
fake_lib = tmp_path / "lib"
|
||||
(fake_lib / "vessels").mkdir(parents=True)
|
||||
(fake_lib / "vessels" / "broken.json").write_text("{ not valid json", encoding="utf-8")
|
||||
(fake_lib / "equipment").mkdir(parents=True)
|
||||
(fake_lib / "rules").mkdir(parents=True)
|
||||
# systems_catalog.json mínimo válido
|
||||
(fake_lib / "systems_catalog.json").write_text(
|
||||
json.dumps({"_meta": {"version": 1}, "categories": []}), encoding="utf-8"
|
||||
)
|
||||
r = load_library(fake_lib)
|
||||
assert not r.ok()
|
||||
assert any("broken.json" in i.path for i in r.errors)
|
||||
|
||||
|
||||
def test_loader_handles_missing_systems_catalog(tmp_path: Path) -> None:
|
||||
fake_lib = tmp_path / "lib"
|
||||
fake_lib.mkdir()
|
||||
r = load_library(fake_lib)
|
||||
assert any("systems_catalog.json" in i.path for i in r.errors)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Tests de integridad de la biblioteca seed."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from vmssailor.core.enums import EquipmentCategory, SystemId
|
||||
from vmssailor.library import load_library, load_systems_catalog
|
||||
|
||||
|
||||
def test_systems_catalog_loads() -> None:
|
||||
cat = load_systems_catalog()
|
||||
assert "categories" in cat
|
||||
assert len(cat["categories"]) >= 10 # 11 categorías mínimo
|
||||
|
||||
|
||||
def test_systems_catalog_covers_all_enum_values() -> None:
|
||||
cat = load_systems_catalog()
|
||||
json_ids = set()
|
||||
for c in cat["categories"]:
|
||||
for s in c["systems"]:
|
||||
json_ids.add(s["id"])
|
||||
enum_ids = {s.value for s in SystemId}
|
||||
missing_in_json = enum_ids - json_ids
|
||||
missing_in_enum = json_ids - enum_ids
|
||||
assert not missing_in_json, f"SystemId enum tiene valores no en catálogo: {missing_in_json}"
|
||||
assert not missing_in_enum, f"Catálogo tiene IDs no en SystemId enum: {missing_in_enum}"
|
||||
|
||||
|
||||
def test_library_loads_without_errors() -> None:
|
||||
result = load_library()
|
||||
assert result.ok(), f"Biblioteca con errores: {result.errors}"
|
||||
|
||||
|
||||
def test_library_has_minimum_seed() -> None:
|
||||
result = load_library()
|
||||
vessel_ids = {v.id for v in result.vessels}
|
||||
assert "sunseeker_76" in vessel_ids
|
||||
assert "ferretti_850" in vessel_ids
|
||||
|
||||
eq_ids = {e.id for e in result.equipment_models}
|
||||
assert "mtu_12v_2000_m96" in eq_ids
|
||||
assert "volvo_d13_900hp" in eq_ids
|
||||
assert "northern_lights_m65c13" in eq_ids
|
||||
|
||||
|
||||
def test_seed_engines_are_engine_main() -> None:
|
||||
result = load_library()
|
||||
for em in result.equipment_models:
|
||||
if em.id.startswith("mtu_") or em.id.startswith("volvo_"):
|
||||
assert em.category == EquipmentCategory.ENGINE_MAIN, em.id
|
||||
|
||||
|
||||
def test_seed_genset_category() -> None:
|
||||
result = load_library()
|
||||
nl = next(e for e in result.equipment_models if e.id == "northern_lights_m65c13")
|
||||
assert nl.category == EquipmentCategory.GENSET
|
||||
|
||||
|
||||
def test_rules_yacht_motor_planeo_present() -> None:
|
||||
result = load_library()
|
||||
assert "yacht_motor_planeo" in result.rules
|
||||
|
||||
|
||||
def test_rules_reference_existing_equipment_models() -> None:
|
||||
result = load_library()
|
||||
# Filtrar warnings que no son del cross-ref
|
||||
cross_ref_warns = [
|
||||
w
|
||||
for w in result.warnings
|
||||
if "no existe en equipment_models" in w.message
|
||||
]
|
||||
assert not cross_ref_warns, f"Rules con model_ref roto: {cross_ref_warns}"
|
||||
|
||||
|
||||
def test_seed_marked_as_estimate() -> None:
|
||||
"""Todos los archivos seed deben venir marcados como seed_estimate."""
|
||||
result = load_library()
|
||||
for v in result.vessels:
|
||||
assert v.data_source == "seed_estimate", v.id
|
||||
for em in result.equipment_models:
|
||||
assert em.data_source == "seed_estimate", em.id
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Tests de generación de IDs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from vmssailor.shared.ids import (
|
||||
make_alarm_config_id,
|
||||
make_alarm_instance_id,
|
||||
make_project_id,
|
||||
make_tag_id,
|
||||
new_uuid,
|
||||
)
|
||||
|
||||
|
||||
def test_make_tag_id_basic() -> None:
|
||||
assert make_tag_id("ME_PORT", "oil_press") == "ME_PORT.OIL_PRESS"
|
||||
|
||||
|
||||
def test_make_tag_id_rejects_lowercase_prefix() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
make_tag_id("me_port", "oil_press")
|
||||
|
||||
|
||||
def test_make_tag_id_rejects_uppercase_sensor() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
make_tag_id("ME_PORT", "OIL_PRESS")
|
||||
|
||||
|
||||
def test_alarm_config_id() -> None:
|
||||
cid = make_alarm_config_id("ME_PORT.OIL_PRESS", "low")
|
||||
assert cid == "ME_PORT.OIL_PRESS.LOW"
|
||||
|
||||
|
||||
def test_alarm_instance_id_deterministic() -> None:
|
||||
a = make_alarm_instance_id("ME_PORT.OIL_PRESS", "ME_PORT.OIL_PRESS.LOW", 1234567890.0)
|
||||
assert a.startswith("alm.ME_PORT.OIL_PRESS.LOW.")
|
||||
|
||||
|
||||
def test_new_uuid_unique() -> None:
|
||||
a = new_uuid()
|
||||
b = new_uuid()
|
||||
assert a != b
|
||||
assert len(a) == 36 # UUID canonical
|
||||
|
||||
|
||||
def test_project_id_normalization() -> None:
|
||||
pid = make_project_id("Acme Yachts S.A.", "Sunseeker_76")
|
||||
# Espacios y puntuación se normalizan
|
||||
assert pid == "acme_yachts_s_a___sunseeker_76" or pid.startswith("acme_yachts_s_a")
|
||||
assert "__" in pid # separador customer__vessel
|
||||
Reference in New Issue
Block a user