feat: AR-ElecArrangement initial commit — Python FastAPI + uvicorn (LAN desktop app, packaged as .exe via PyInstaller)
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
AR-ElecArrangement — backend package.
|
||||
|
||||
Aplicación servidor para diseño eléctrico de buques. El cliente (browser)
|
||||
consume la API REST + WebSocket expuesta por ``arelec.main``.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__author__ = "Alvaro Enrique Romero Donado"
|
||||
@@ -0,0 +1 @@
|
||||
"""Modelo de datos del proyecto: ship, decks, zones, appliances, panels, cables…"""
|
||||
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
Raíz del proyecto y serialización al formato ``.area``.
|
||||
|
||||
Un ``.area`` es un archivo ZIP que contiene:
|
||||
|
||||
::
|
||||
|
||||
project.json — metadata del proyecto y modelo serializado
|
||||
assets/ — recursos (íconos custom, capturas, fotografías)
|
||||
|
||||
El formato JSON usa el modelo declarado por el resto de ``arelec.core`` y
|
||||
está versionado por el campo ``schema_version``. Los .area producidos por
|
||||
una versión del software pueden ser leídos por versiones posteriores
|
||||
(migraciones aditivas), nunca por anteriores.
|
||||
|
||||
En Sprint 0 el modelo es mínimo (solo metadata) — los sub-modelos (ship,
|
||||
decks, appliances, panels, cables…) se agregan en sprints siguientes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import zipfile
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProjectMetadata:
|
||||
"""Datos administrativos del proyecto."""
|
||||
|
||||
name: str = "Nuevo proyecto"
|
||||
author: str = ""
|
||||
company: str = ""
|
||||
notes: str = ""
|
||||
schema_version: int = SCHEMA_VERSION
|
||||
created_at: str = field(
|
||||
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
||||
)
|
||||
modified_at: str = field(
|
||||
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Project:
|
||||
"""
|
||||
Raíz del modelo de datos. Sprint 0 contiene solo metadata; los sub-modelos
|
||||
(ship, decks, appliances, panels, cable_runs, batteries, etc.) se acoplan
|
||||
a esta clase en sprints siguientes.
|
||||
|
||||
La instancia se serializa íntegra como ``project.json`` dentro del ZIP
|
||||
``.area``.
|
||||
"""
|
||||
|
||||
metadata: ProjectMetadata = field(default_factory=ProjectMetadata)
|
||||
# Sub-modelos futuros se irán agregando aquí:
|
||||
# ship: Ship | None = None
|
||||
# decks: list[Deck] = field(default_factory=list)
|
||||
# appliances: list[Appliance] = field(default_factory=list)
|
||||
# batteries: list[BatteryBank] = field(default_factory=list)
|
||||
# panels: list[Panel] = field(default_factory=list)
|
||||
# cable_runs: list[CableRun] = field(default_factory=list)
|
||||
|
||||
# ── Serialization ───────────────────────────────────────────────────────
|
||||
def to_json(self) -> dict[str, Any]:
|
||||
"""Convertir a dict serializable (JSON-safe)."""
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data: dict[str, Any]) -> "Project":
|
||||
"""Reconstruir desde dict cargado de ``project.json``."""
|
||||
meta_dict = data.get("metadata", {})
|
||||
return cls(metadata=ProjectMetadata(**meta_dict))
|
||||
|
||||
# ── .area I/O ───────────────────────────────────────────────────────────
|
||||
def save(self, path: str | Path) -> None:
|
||||
"""
|
||||
Persistir el proyecto a ``path`` (extensión ``.area`` sugerida).
|
||||
|
||||
Toca ``metadata.modified_at`` con la hora UTC actual antes de
|
||||
serializar.
|
||||
"""
|
||||
path = Path(path)
|
||||
self.metadata.modified_at = datetime.now(timezone.utc).isoformat()
|
||||
payload = json.dumps(self.to_json(), indent=2, ensure_ascii=False)
|
||||
# ``ZIP_DEFLATED`` para que los .area no crezcan absurdo en proyectos
|
||||
# grandes — el contenido es texto y comprime ~10×.
|
||||
with zipfile.ZipFile(path, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("project.json", payload)
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: str | Path) -> "Project":
|
||||
"""
|
||||
Cargar un proyecto desde ``path``. Lanza:
|
||||
|
||||
- ``FileNotFoundError`` si el archivo no existe
|
||||
- ``zipfile.BadZipFile`` si el archivo no es ZIP válido
|
||||
- ``ValueError`` si ``schema_version`` es mayor que ``SCHEMA_VERSION``
|
||||
(el .area fue creado por una versión más nueva del software)
|
||||
- ``KeyError`` si falta ``project.json`` en el ZIP
|
||||
"""
|
||||
path = Path(path)
|
||||
with zipfile.ZipFile(path, mode="r") as zf:
|
||||
with zf.open("project.json") as f:
|
||||
data = json.loads(f.read().decode("utf-8"))
|
||||
|
||||
version = data.get("metadata", {}).get("schema_version", 1)
|
||||
if version > SCHEMA_VERSION:
|
||||
raise ValueError(
|
||||
f"El proyecto requiere schema_version={version}, "
|
||||
f"este software soporta hasta {SCHEMA_VERSION}. "
|
||||
f"Actualiza AR-ElecArrangement."
|
||||
)
|
||||
return cls.from_json(data)
|
||||
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
Conversiones SI ↔ imperial usadas en la UI.
|
||||
|
||||
REGLA INTERNA DEL PROYECTO: todo el modelo de datos y todos los cálculos
|
||||
trabajan en SI (m, kg, A, V, W). Las conversiones a unidades imperiales
|
||||
(pies, AWG, BTU/h) ocurren ÚNICAMENTE en el borde UI cuando el usuario lo
|
||||
pide. Persistir AWG o pies en .area está prohibido.
|
||||
|
||||
Tablas
|
||||
------
|
||||
- AWG ↔ mm²: ABYC E-11 Tabla VI / IEC 60228 (relación logarítmica + valores
|
||||
tabulados estándar).
|
||||
- ft ↔ m: 1 ft = 0.3048 m exacto (NIST).
|
||||
- lb ↔ kg: 1 lb = 0.45359237 kg exacto (NIST).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# ── Lengths ─────────────────────────────────────────────────────────────────
|
||||
M_PER_FT: float = 0.3048
|
||||
FT_PER_M: float = 1.0 / M_PER_FT
|
||||
|
||||
|
||||
def m_to_ft(m: float) -> float:
|
||||
return m * FT_PER_M
|
||||
|
||||
|
||||
def ft_to_m(ft: float) -> float:
|
||||
return ft * M_PER_FT
|
||||
|
||||
|
||||
# ── Mass ────────────────────────────────────────────────────────────────────
|
||||
KG_PER_LB: float = 0.45359237
|
||||
LB_PER_KG: float = 1.0 / KG_PER_LB
|
||||
|
||||
|
||||
def kg_to_lb(kg: float) -> float:
|
||||
return kg * LB_PER_KG
|
||||
|
||||
|
||||
def lb_to_kg(lb: float) -> float:
|
||||
return lb * KG_PER_LB
|
||||
|
||||
|
||||
# ── Wire calibre — AWG ↔ mm² ────────────────────────────────────────────────
|
||||
# Tabla estándar de conductores (no se usa la fórmula logarítmica analítica
|
||||
# porque los valores comerciales están redondeados).
|
||||
# Cobertura: AWG 24 a 4/0 y mm² 0.2 a 120 — rango típico marino.
|
||||
AWG_TO_MM2: dict[str, float] = {
|
||||
"24": 0.205,
|
||||
"22": 0.324,
|
||||
"20": 0.519,
|
||||
"18": 0.823,
|
||||
"16": 1.31,
|
||||
"14": 2.08,
|
||||
"12": 3.31,
|
||||
"10": 5.26,
|
||||
"8": 8.37,
|
||||
"6": 13.3,
|
||||
"4": 21.2,
|
||||
"2": 33.6,
|
||||
"1": 42.4,
|
||||
"1/0": 53.5,
|
||||
"2/0": 67.4,
|
||||
"3/0": 85.0,
|
||||
"4/0": 107.0,
|
||||
}
|
||||
|
||||
# Lista de mm² comerciales IEC para la conversión inversa.
|
||||
COMMERCIAL_MM2: tuple[float, ...] = (
|
||||
0.5, 0.75, 1.0, 1.5, 2.5, 4.0, 6.0, 10.0, 16.0, 25.0, 35.0,
|
||||
50.0, 70.0, 95.0, 120.0, 150.0, 185.0, 240.0,
|
||||
)
|
||||
|
||||
|
||||
def awg_to_mm2(awg: str) -> float:
|
||||
"""Convertir designación AWG (p.ej. ``"10"``, ``"1/0"``) a mm²."""
|
||||
if awg not in AWG_TO_MM2:
|
||||
raise ValueError(f"AWG no soportado: {awg!r}. Válidos: {list(AWG_TO_MM2)}")
|
||||
return AWG_TO_MM2[awg]
|
||||
|
||||
|
||||
def mm2_to_awg(mm2: float) -> str:
|
||||
"""Mapear área (mm²) al AWG cuya área es ≥ la pedida (criterio conservador)."""
|
||||
if mm2 <= 0:
|
||||
raise ValueError(f"mm² debe ser > 0, recibido: {mm2}")
|
||||
# AWG_TO_MM2 está ordenado de menor a mayor área
|
||||
for awg, area in AWG_TO_MM2.items():
|
||||
if area >= mm2:
|
||||
return awg
|
||||
raise ValueError(f"mm² {mm2} excede el AWG máximo soportado (4/0 = 107 mm²)")
|
||||
|
||||
|
||||
# ── Power / energy ──────────────────────────────────────────────────────────
|
||||
def hp_to_kw(hp: float) -> float:
|
||||
"""Caballos métricos (CV / PS) a kW: 1 hp métrico = 0.7355 kW."""
|
||||
return hp * 0.7355
|
||||
|
||||
|
||||
def kw_to_hp(kw: float) -> float:
|
||||
return kw / 0.7355
|
||||
|
||||
|
||||
def btu_per_h_to_kw(btu_h: float) -> float:
|
||||
"""BTU/h a kW (capacidad frigorífica de A/C)."""
|
||||
return btu_h * 0.000293071
|
||||
|
||||
|
||||
def kw_to_btu_per_h(kw: float) -> float:
|
||||
return kw / 0.000293071
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
"""
|
||||
AR-ElecArrangement — entrypoint del servidor.
|
||||
|
||||
Lanza FastAPI/uvicorn en ``0.0.0.0:5505`` (accesible desde la LAN para que
|
||||
los tablets se conecten) y sirve el frontend compilado de ``frontend/dist/``.
|
||||
|
||||
Cuando se ejecuta como ``python -m backend.main`` o desde el ``.exe`` empacado
|
||||
por PyInstaller, después de iniciar el server espera 1 s y abre el browser
|
||||
predeterminado apuntando a ``http://localhost:5505``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import webbrowser
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, HTTPException, UploadFile, File
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from arelec import __version__
|
||||
from arelec.core.project import Project
|
||||
|
||||
# ── Paths ────────────────────────────────────────────────────────────────────
|
||||
# En desarrollo: __file__ = backend/main.py → BACKEND_DIR = backend/
|
||||
# Empacado: __file__ está dentro de _MEIPASS → BACKEND_DIR = sys._MEIPASS/backend
|
||||
if getattr(sys, "frozen", False):
|
||||
BASE_DIR = Path(sys._MEIPASS) # type: ignore[attr-defined]
|
||||
else:
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
FRONTEND_DIST = BASE_DIR / "frontend" / "dist"
|
||||
DATA_DIR = BASE_DIR / "data"
|
||||
|
||||
HOST = "0.0.0.0"
|
||||
PORT = 5505
|
||||
|
||||
|
||||
# ── FastAPI app ──────────────────────────────────────────────────────────────
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Hook de arranque/apagado del server."""
|
||||
print(f"[arelec] Servidor iniciado en http://localhost:{PORT} (v{__version__})")
|
||||
print(f"[arelec] Tablets en LAN: http://<ip-de-este-pc>:{PORT}")
|
||||
yield
|
||||
print("[arelec] Servidor detenido.")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="AR-ElecArrangement",
|
||||
version=__version__,
|
||||
description="Diseño eléctrico de buques — backend",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Acceso LAN sin restricciones (la app está pensada para LAN privada del usuario,
|
||||
# no para Internet pública). Si en el futuro se ofrece cloud, restringir aquí.
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1024)
|
||||
|
||||
|
||||
# ── API ──────────────────────────────────────────────────────────────────────
|
||||
@app.get("/api/health")
|
||||
def health() -> dict[str, str]:
|
||||
"""Heartbeat para clientes que verifican que el server está vivo."""
|
||||
return {"status": "ok", "version": __version__}
|
||||
|
||||
|
||||
# ── Project I/O (Sprint 0 — sólo metadata, expandirá en Sprint 1+) ──────────
|
||||
@app.post("/api/project/new")
|
||||
def project_new() -> dict[str, Any]:
|
||||
"""Crear un proyecto vacío y devolver su JSON serializado."""
|
||||
return Project().to_json()
|
||||
|
||||
|
||||
@app.post("/api/project/open")
|
||||
async def project_open(file: UploadFile = File(...)) -> dict[str, Any]:
|
||||
"""
|
||||
Abrir un archivo ``.area`` subido por el cliente. Lo guarda en un tempfile,
|
||||
lo carga vía ``Project.load``, devuelve el JSON serializado.
|
||||
"""
|
||||
if not (file.filename or "").lower().endswith(".area"):
|
||||
raise HTTPException(400, "El archivo debe tener extensión .area")
|
||||
data = await file.read()
|
||||
if not data:
|
||||
raise HTTPException(400, "Archivo vacío")
|
||||
with tempfile.NamedTemporaryFile(suffix=".area", delete=False) as tmp:
|
||||
tmp.write(data)
|
||||
tmp_path = Path(tmp.name)
|
||||
try:
|
||||
proj = Project.load(tmp_path)
|
||||
return proj.to_json()
|
||||
except Exception as e: # noqa: BLE001
|
||||
# No exponemos str(e) al cliente para evitar filtrar rutas internas
|
||||
# o detalles de implementación. El tipo de error es suficiente.
|
||||
raise HTTPException(400, "No es un .area válido o el archivo está dañado.") from e
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
_WINDOWS_RESERVED_RE = re.compile(
|
||||
r"^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def _safe_filename(name: str) -> str:
|
||||
"""Sanitiza el nombre del proyecto para uso seguro como nombre de archivo.
|
||||
|
||||
- Elimina caracteres no permitidos en Windows/POSIX (``/``, ``\\``, ``:``,
|
||||
``*``, ``?``, ``"``, ``<``, ``>``, ``|``, bytes nulos).
|
||||
- Colapsa espacios/guiones bajos múltiples consecutivos.
|
||||
- Trunca a 200 caracteres para evitar rutas demasiado largas.
|
||||
- Sustituye cadena vacía o nombres reservados de Windows por ``proyecto``.
|
||||
"""
|
||||
safe = re.sub(r'[\\/:*?"<>|\x00]', "_", name)
|
||||
safe = re.sub(r"[_\s]{2,}", "_", safe).strip("_").strip()
|
||||
safe = safe[:200]
|
||||
if not safe or _WINDOWS_RESERVED_RE.match(safe):
|
||||
safe = "proyecto"
|
||||
return safe
|
||||
|
||||
|
||||
@app.post("/api/project/save")
|
||||
async def project_save(payload: dict[str, Any]) -> FileResponse:
|
||||
"""
|
||||
Recibe un proyecto en JSON, lo serializa a ``.area`` y lo devuelve para
|
||||
descarga directa por el browser.
|
||||
"""
|
||||
proj = Project.from_json(payload)
|
||||
out_dir = Path(tempfile.mkdtemp(prefix="arelec_save_"))
|
||||
out_path = out_dir / (_safe_filename(proj.metadata.name) + ".area")
|
||||
proj.save(out_path)
|
||||
return FileResponse(
|
||||
path=out_path,
|
||||
media_type="application/zip",
|
||||
filename=out_path.name,
|
||||
)
|
||||
|
||||
|
||||
# ── Frontend (montaje al final para que /api/* tenga precedencia) ────────────
|
||||
if FRONTEND_DIST.exists():
|
||||
app.mount("/", StaticFiles(directory=str(FRONTEND_DIST), html=True), name="frontend")
|
||||
else:
|
||||
@app.get("/")
|
||||
def _no_frontend_msg() -> dict[str, str]:
|
||||
return {
|
||||
"warning": "frontend/dist no encontrado",
|
||||
"hint": "Compilar frontend: cd frontend && npm install && npm run build",
|
||||
}
|
||||
|
||||
|
||||
# ── Auto-launch del browser cuando corre como .exe ───────────────────────────
|
||||
def _open_browser_when_ready() -> None:
|
||||
"""Espera 1 s y abre el browser default. Llamado solo desde .exe / __main__."""
|
||||
time.sleep(1.0)
|
||||
url = f"http://localhost:{PORT}"
|
||||
try:
|
||||
webbrowser.open(url)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[arelec] No se pudo abrir el browser automáticamente: {e}")
|
||||
print(f"[arelec] Abre manualmente: {url}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Entrypoint cuando se corre como ``python -m backend.main`` o ``.exe``."""
|
||||
# Solo abrir browser cuando no estamos en modo --reload (dev)
|
||||
if os.environ.get("ARELEC_NO_BROWSER") != "1":
|
||||
threading.Thread(target=_open_browser_when_ready, daemon=True).start()
|
||||
|
||||
uvicorn.run(
|
||||
"backend.main:app" if not getattr(sys, "frozen", False) else app,
|
||||
host=HOST,
|
||||
port=PORT,
|
||||
log_level="info",
|
||||
reload=False,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,10 @@
|
||||
-r requirements.txt
|
||||
|
||||
# ── Tests ────────────────────────────────────────────────────────────────────
|
||||
pytest>=8.3
|
||||
pytest-asyncio>=0.25
|
||||
httpx>=0.28
|
||||
|
||||
# ── Linting / formato ────────────────────────────────────────────────────────
|
||||
ruff>=0.8
|
||||
mypy>=1.13
|
||||
@@ -0,0 +1,20 @@
|
||||
# ── Server ───────────────────────────────────────────────────────────────────
|
||||
# Versiones mínimas conocidas-buenas; sin pin estricto para que pip resuelva
|
||||
# la última disponible compatible con la versión de Python instalada.
|
||||
# Probado contra Python 3.11–3.14.
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.34
|
||||
pydantic>=2.11 # Python 3.14 wheels disponibles desde 2.11
|
||||
websockets>=13.1
|
||||
python-multipart>=0.0.20 # uploads en /api/project/open
|
||||
|
||||
# ── Cálculo (Sprint 4+) ──────────────────────────────────────────────────────
|
||||
# numpy y pandas se agregan cuando arranquen los motores de cálculo.
|
||||
# scipy se agrega cuando salga rueda para Python 3.14 (ETA: ya en pre-release).
|
||||
# networkx se agrega en Sprint 9 (routing automático).
|
||||
|
||||
# ── Exports (Sprint 10) ──────────────────────────────────────────────────────
|
||||
# ezdxf, reportlab, openpyxl entran cuando empecemos exports DXF/PDF/Excel.
|
||||
|
||||
# ── Empaquetado .exe (Sprint 14) ─────────────────────────────────────────────
|
||||
# pyinstaller se agrega al cierre cuando empacamos el .exe final.
|
||||
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
Sprint 0 — sanity: un Project recién creado se guarda y se carga sin pérdida.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from arelec.core.project import Project, ProjectMetadata, SCHEMA_VERSION
|
||||
|
||||
|
||||
def test_project_default_metadata() -> None:
|
||||
p = Project()
|
||||
assert p.metadata.name == "Nuevo proyecto"
|
||||
assert p.metadata.schema_version == SCHEMA_VERSION
|
||||
assert p.metadata.created_at != ""
|
||||
|
||||
|
||||
def test_save_and_load_roundtrip(tmp_path: Path) -> None:
|
||||
path = tmp_path / "demo.area"
|
||||
original = Project(metadata=ProjectMetadata(
|
||||
name="Yate Demo",
|
||||
author="ARD",
|
||||
company="AR ShipDesign",
|
||||
notes="Sprint 0 test",
|
||||
))
|
||||
original.save(path)
|
||||
assert path.exists()
|
||||
assert path.stat().st_size > 0
|
||||
|
||||
loaded = Project.load(path)
|
||||
assert loaded.metadata.name == "Yate Demo"
|
||||
assert loaded.metadata.author == "ARD"
|
||||
assert loaded.metadata.company == "AR ShipDesign"
|
||||
assert loaded.metadata.notes == "Sprint 0 test"
|
||||
assert loaded.metadata.schema_version == SCHEMA_VERSION
|
||||
|
||||
|
||||
def test_save_touches_modified_at(tmp_path: Path) -> None:
|
||||
p = Project()
|
||||
original_modified = p.metadata.modified_at
|
||||
p.save(tmp_path / "t.area")
|
||||
# Save siempre actualiza modified_at (aunque sea el mismo segundo, queda con
|
||||
# nueva timestamp ISO — comparamos que fue tocado).
|
||||
assert p.metadata.modified_at >= original_modified
|
||||
|
||||
|
||||
def test_load_rejects_future_schema(tmp_path: Path) -> None:
|
||||
"""Un .area creado por una versión más nueva debe rechazarse limpio."""
|
||||
path = tmp_path / "future.area"
|
||||
p = Project(metadata=ProjectMetadata(schema_version=SCHEMA_VERSION + 99))
|
||||
p.save(path)
|
||||
with pytest.raises(ValueError, match="schema_version"):
|
||||
Project.load(path)
|
||||
|
||||
|
||||
def test_load_rejects_missing_project_json(tmp_path: Path) -> None:
|
||||
import zipfile
|
||||
|
||||
path = tmp_path / "empty.area"
|
||||
with zipfile.ZipFile(path, mode="w") as zf:
|
||||
zf.writestr("readme.txt", "no project.json")
|
||||
with pytest.raises(KeyError):
|
||||
Project.load(path)
|
||||
|
||||
|
||||
def test_load_rejects_non_zip(tmp_path: Path) -> None:
|
||||
import zipfile
|
||||
|
||||
path = tmp_path / "garbage.area"
|
||||
path.write_bytes(b"this is not a zip file")
|
||||
with pytest.raises(zipfile.BadZipFile):
|
||||
Project.load(path)
|
||||
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Sprint 0 — sanity: conversiones SI ↔ imperial round-trip y casos límite.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from arelec.core.units import (
|
||||
AWG_TO_MM2,
|
||||
awg_to_mm2,
|
||||
ft_to_m,
|
||||
kg_to_lb,
|
||||
kw_to_hp,
|
||||
lb_to_kg,
|
||||
m_to_ft,
|
||||
mm2_to_awg,
|
||||
hp_to_kw,
|
||||
)
|
||||
|
||||
|
||||
def test_length_roundtrip() -> None:
|
||||
assert m_to_ft(1.0) == pytest.approx(3.28084, rel=1e-4)
|
||||
assert ft_to_m(m_to_ft(42.0)) == pytest.approx(42.0)
|
||||
|
||||
|
||||
def test_mass_roundtrip() -> None:
|
||||
assert kg_to_lb(1.0) == pytest.approx(2.20462, rel=1e-4)
|
||||
assert lb_to_kg(kg_to_lb(100.0)) == pytest.approx(100.0)
|
||||
|
||||
|
||||
def test_power_roundtrip() -> None:
|
||||
assert hp_to_kw(1.0) == pytest.approx(0.7355)
|
||||
assert kw_to_hp(hp_to_kw(150.0)) == pytest.approx(150.0)
|
||||
|
||||
|
||||
def test_awg_lookup_known_values() -> None:
|
||||
# ABYC E-11 tabla VI — valores publicados estándar
|
||||
assert awg_to_mm2("10") == 5.26
|
||||
assert awg_to_mm2("4/0") == 107.0
|
||||
assert awg_to_mm2("14") == 2.08
|
||||
|
||||
|
||||
def test_mm2_to_awg_picks_next_larger() -> None:
|
||||
# 5.0 mm² no es exactamente AWG 10 (5.26), pero AWG 10 cubre el cable.
|
||||
# Criterio conservador: elegir AWG cuya área es ≥ pedida.
|
||||
assert mm2_to_awg(5.0) == "10"
|
||||
assert mm2_to_awg(5.26) == "10"
|
||||
assert mm2_to_awg(2.0) == "14" # 2.08
|
||||
assert mm2_to_awg(100.0) == "4/0" # 107
|
||||
|
||||
|
||||
def test_mm2_to_awg_rejects_invalid() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
mm2_to_awg(0)
|
||||
with pytest.raises(ValueError):
|
||||
mm2_to_awg(-1.0)
|
||||
with pytest.raises(ValueError):
|
||||
mm2_to_awg(500.0) # más grande que 4/0
|
||||
|
||||
|
||||
def test_awg_table_monotonic() -> None:
|
||||
"""La tabla AWG debe ir de menor a mayor área (asumido por mm2_to_awg)."""
|
||||
areas = list(AWG_TO_MM2.values())
|
||||
assert areas == sorted(areas)
|
||||
Reference in New Issue
Block a user