""" 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()