Files
AR-Shipdesign/tests/test_serialization.py
T
alro65 3b0d5e9e50 Modulo 1: serializacion Hull / Project en formato .arsd (Task 11)
- hull.py: Hull.to_dict() serializa a dict JSON con formato hull_v1
  (arrays numpy -> listas Python); Hull.from_dict() deserializa con
  validacion de claves y forma de array.

- project.py: Project.hull (property lazy) deserializa el Hull desde
  ship_data; Project.set_hull() persiste el Hull y marca is_modified.

- main_window.py: _on_new_project guarda el Hull en el proyecto;
  _on_project_loaded restaura el Hull en todos los visores al abrir
  un archivo .arsd; _on_hull_changed_from_editor mantiene el proyecto
  sincronizado con ediciones en el editor de offsets.

- test_serialization.py: 26 tests (round-trip dict, round-trip ZIP,
  5 familias parametricas, escritura atomica, proyecto sin Hull).

Suite total: 112 tests -- 112 passed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 08:33:34 -04:00

258 lines
10 KiB
Python

"""
Tests Modulo 1 -- Serializacion Hull / Project (.arsd).
Verifica que:
- Hull.to_dict() produce un dict JSON-serializable
- Hull.from_dict(Hull.to_dict(h)) recupera el mismo Hull bit a bit
- Project.set_hull() / Project.hull guarda y restaura el Hull
- Project.save() / Project.load() persiste el Hull en el ZIP .arsd
- Proyectos sin Hull (legados o nuevos vacios) cargan sin error
Autor: Alvaro Romero | Modulo 1 -- AR-ShipDesign
IACS Rec.34 par.6 -- trazabilidad de datos de entrada.
"""
from __future__ import annotations
import json
import tempfile
from pathlib import Path
import numpy as np
import pytest
from arshipdesign.core.hull import Hull
from arshipdesign.core.offsets import OffsetsTable
from arshipdesign.core.project import Project
from arshipdesign.parametric import generate_hull, HullFamily
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def wigley_hull() -> Hull:
return Hull.from_wigley(lpp=15.0, beam=4.0, draft=1.60,
n_stations=21, n_waterlines=11)
@pytest.fixture(scope="module")
def displacement_hull() -> Hull:
return generate_hull(HullFamily.DISPLACEMENT, lpp=20.0, beam=6.0,
draft=2.40, depth=3.20, cb=0.55)
# ---------------------------------------------------------------------------
# 1. Hull.to_dict
# ---------------------------------------------------------------------------
class TestHullToDict:
def test_format_key(self, wigley_hull: Hull) -> None:
d = wigley_hull.to_dict()
assert d["format"] == "hull_v1"
def test_required_scalar_keys(self, wigley_hull: Hull) -> None:
d = wigley_hull.to_dict()
for key in ("name", "lpp", "beam", "depth", "draft"):
assert key in d, f"Falta clave '{key}' en Hull.to_dict()"
def test_offsets_sub_dict(self, wigley_hull: Hull) -> None:
od = wigley_hull.to_dict()["offsets"]
for key in ("lpp", "beam", "draft", "x_stations", "z_waterlines",
"station_labels", "data"):
assert key in od, f"Falta clave '{key}' en offsets dict"
def test_json_serializable(self, wigley_hull: Hull) -> None:
"""El dict debe poder pasarse por json.dumps sin error."""
d = wigley_hull.to_dict()
txt = json.dumps(d)
assert len(txt) > 100
def test_data_shape(self, wigley_hull: Hull) -> None:
od = wigley_hull.to_dict()["offsets"]
n_sta = wigley_hull.offsets.n_stations
n_wl = wigley_hull.offsets.n_waterlines
assert len(od["data"]) == n_sta
assert len(od["data"][0]) == n_wl
def test_values_preserved(self, wigley_hull: Hull) -> None:
d = wigley_hull.to_dict()
assert abs(d["lpp"] - wigley_hull.lpp) < 1e-12
assert abs(d["draft"] - wigley_hull.draft) < 1e-12
assert d["name"] == wigley_hull.name
# ---------------------------------------------------------------------------
# 2. Hull.from_dict round-trip
# ---------------------------------------------------------------------------
class TestHullFromDict:
def test_roundtrip_dimensions(self, wigley_hull: Hull) -> None:
h2 = Hull.from_dict(wigley_hull.to_dict())
assert abs(h2.lpp - wigley_hull.lpp) < 1e-12
assert abs(h2.beam - wigley_hull.beam) < 1e-12
assert abs(h2.depth - wigley_hull.depth) < 1e-12
assert abs(h2.draft - wigley_hull.draft) < 1e-12
assert h2.name == wigley_hull.name
def test_roundtrip_offsets_shape(self, wigley_hull: Hull) -> None:
h2 = Hull.from_dict(wigley_hull.to_dict())
assert h2.offsets.n_stations == wigley_hull.offsets.n_stations
assert h2.offsets.n_waterlines == wigley_hull.offsets.n_waterlines
def test_roundtrip_offsets_values(self, wigley_hull: Hull) -> None:
h2 = Hull.from_dict(wigley_hull.to_dict())
np.testing.assert_array_almost_equal(
h2.offsets.data, wigley_hull.offsets.data, decimal=12
)
def test_roundtrip_hydrostatics(self, wigley_hull: Hull) -> None:
h2 = Hull.from_dict(wigley_hull.to_dict())
assert abs(h2.volume_of_displacement() - wigley_hull.volume_of_displacement()) < 1e-9
assert abs(h2.block_coefficient() - wigley_hull.block_coefficient()) < 1e-12
assert abs(h2.km_transverse() - wigley_hull.km_transverse()) < 1e-9
def test_roundtrip_station_labels(self, wigley_hull: Hull) -> None:
h2 = Hull.from_dict(wigley_hull.to_dict())
assert h2.offsets.station_labels == wigley_hull.offsets.station_labels
def test_from_dict_displacement_hull(self, displacement_hull: Hull) -> None:
h2 = Hull.from_dict(displacement_hull.to_dict())
assert abs(h2.lpp - displacement_hull.lpp) < 1e-9
assert h2.offsets.n_stations == displacement_hull.offsets.n_stations
def test_missing_key_raises(self, wigley_hull: Hull) -> None:
d = wigley_hull.to_dict()
del d["offsets"]["x_stations"]
with pytest.raises((KeyError, Exception)):
Hull.from_dict(d)
# ---------------------------------------------------------------------------
# 3. Project.set_hull / Project.hull property
# ---------------------------------------------------------------------------
class TestProjectHull:
def test_new_project_has_no_hull(self) -> None:
proj = Project.new("Sin casco")
assert proj.hull is None
def test_set_hull_stores_format(self, wigley_hull: Hull) -> None:
proj = Project.new("Test")
proj.set_hull(wigley_hull)
assert proj.ship_data["hull"]["format"] == "hull_v1"
def test_set_hull_marks_modified(self, wigley_hull: Hull) -> None:
proj = Project.new("Test")
proj._is_modified = False # reset manually
proj.set_hull(wigley_hull)
assert proj.is_modified
def test_hull_property_returns_hull(self, wigley_hull: Hull) -> None:
proj = Project.new("Test")
proj.set_hull(wigley_hull)
h = proj.hull
assert h is not None
assert abs(h.lpp - wigley_hull.lpp) < 1e-9
def test_hull_property_preserves_hydrostatics(self, wigley_hull: Hull) -> None:
proj = Project.new("Test")
proj.set_hull(wigley_hull)
h = proj.hull
assert abs(h.block_coefficient() - wigley_hull.block_coefficient()) < 1e-9
def test_corrupt_hull_data_returns_none(self) -> None:
proj = Project.new("Test")
proj.ship_data["hull"] = {"format": "unknown_v99", "bad": True}
assert proj.hull is None
# ---------------------------------------------------------------------------
# 4. Project.save / Project.load (.arsd round-trip)
# ---------------------------------------------------------------------------
class TestProjectSaveLoad:
def test_save_and_load_preserves_hull(self, wigley_hull: Hull) -> None:
proj = Project.new("Velero de prueba")
proj.set_hull(wigley_hull)
with tempfile.TemporaryDirectory() as tmp:
p = Path(tmp) / "velero.arsd"
proj.save(p)
assert p.exists()
assert p.stat().st_size > 500 # no vacio
proj2 = Project.load(p)
h2 = proj2.hull
assert h2 is not None
assert h2.name == wigley_hull.name
assert abs(h2.lpp - wigley_hull.lpp) < 1e-9
np.testing.assert_array_almost_equal(
h2.offsets.data, wigley_hull.offsets.data, decimal=12
)
def test_save_and_load_no_hull(self) -> None:
"""Un proyecto sin Hull debe cargarse sin error y retornar hull=None."""
proj = Project.new("Proyecto vacio")
with tempfile.TemporaryDirectory() as tmp:
p = Path(tmp) / "vacio.arsd"
proj.save(p)
proj2 = Project.load(p)
assert proj2.hull is None
def test_file_is_valid_zip(self, wigley_hull: Hull) -> None:
import zipfile
proj = Project.new("Test ZIP")
proj.set_hull(wigley_hull)
with tempfile.TemporaryDirectory() as tmp:
p = Path(tmp) / "test.arsd"
proj.save(p)
assert zipfile.is_zipfile(p)
def test_ship_json_inside_zip(self, wigley_hull: Hull) -> None:
import zipfile
proj = Project.new("Test ZIP")
proj.set_hull(wigley_hull)
with tempfile.TemporaryDirectory() as tmp:
p = Path(tmp) / "test.arsd"
proj.save(p)
with zipfile.ZipFile(p, "r") as zf:
assert "ship.json" in zf.namelist()
ship = json.loads(zf.read("ship.json").decode("utf-8"))
assert ship["hull"]["format"] == "hull_v1"
def test_roundtrip_five_hull_families(self) -> None:
"""Todos los generadores parametricos deben sobrevivir el round-trip."""
for family in HullFamily:
hull = generate_hull(family, lpp=12.0, beam=3.5,
draft=1.20, depth=2.00)
d = hull.to_dict()
h2 = Hull.from_dict(d)
assert abs(h2.lpp - hull.lpp) < 1e-9, f"{family.value}: Lpp no coincide"
# Hidrostáticos reproducibles
assert abs(h2.volume_of_displacement() -
hull.volume_of_displacement()) < 1e-6, \
f"{family.value}: V no coincide"
def test_save_is_atomic(self, wigley_hull: Hull) -> None:
"""El archivo temporal .arsd.tmp no debe quedar si save tiene exito."""
proj = Project.new("Atomic test")
proj.set_hull(wigley_hull)
with tempfile.TemporaryDirectory() as tmp:
p = Path(tmp) / "atomic.arsd"
proj.save(p)
tmp_p = p.with_suffix(".arsd.tmp")
assert not tmp_p.exists(), ".arsd.tmp debe eliminarse tras save exitoso"
def test_save_as_updates_path(self, wigley_hull: Hull) -> None:
proj = Project.new("SaveAs test")
proj.set_hull(wigley_hull)
with tempfile.TemporaryDirectory() as tmp:
p1 = Path(tmp) / "first.arsd"
p2 = Path(tmp) / "second.arsd"
proj.save(p1)
assert proj.path == p1
proj.save(p2)
assert proj.path == p2
assert p2.exists()