3.1.0: se instala solo en su sitio, manual dentro y el puerto para una sola copia

- instalar.py: instalar un paquete lo copia a la carpeta de programas del
  usuario (%LOCALAPPDATA%\Programs\OrganiLAB) con sus datos y su ffmpeg, lo da
  de alta desde ahí y lo vuelve a abrir desde su sitio. Del origen no se borra
  nada: queda instalado-en.txt, que además hace que abrir esa copia abra la
  instalada. Un destino ocupado no se pisa, y si ya hay un OrganiLAB instalado
  lo dice en vez de poner otro encima.
- static/index.html: vista «Manual», con cada función del programa, qué hace
  y cómo lo hace (41 entradas, un apartado por pestaña).
- organizador.py: ServidorLocal pide el puerto en exclusiva. Con «reutilizar
  dirección», en Windows dos copias escuchaban a la vez en el mismo puerto y
  se contestaban la una por la otra sin que nada fallara a la vista.
- Pruebas: probar.py 19, prueba_funcional.py 26 de 26, prueba_ventana.py 18
  desde el código y 17 con el paquete, incluida la instalación entera en un
  equipo de ensayo (ORGANILAB_ENSAYO) que no toca el Windows de verdad.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-10-04 12:28:03 -05:00
padre 6c21145b1c
commit 50562aeaab
Se han modificado 8 ficheros con 562 adiciones y 43 borrados

Ver fichero

@@ -169,10 +169,11 @@ class Windows(unittest.TestCase):
abrir la página de ese otro. Y este Windows no puede abrir la ventana (aquí, porque falta el cargador
de WebView2): la interfaz va al navegador y el programa se va solo cuando esa página deja de preguntar."""
tmp = tempfile.mkdtemp(prefix="organilab-imprevistos-")
tapon = socket.socket()
tapon.bind(("127.0.0.1", 0))
tapon.listen(1)
ocupado = tapon.getsockname()[1]
# quien ocupa el puerto es un servidor como el de otro OrganiLAB: pide «reutilizar dirección», y en
# Windows eso dejaba que un segundo programa escuchara en el mismo puerto sin que nada fallara
import http.server
tapon = http.server.ThreadingHTTPServer(("127.0.0.1", 0), http.server.BaseHTTPRequestHandler)
ocupado = tapon.server_address[1]
try:
for f in ("organizador.py", "reglas.py", "asistente.py", "actualizador.py", "resolve_bridge.py", "firma.py", "ventana.py", "instalar.py"):
shutil.copy(os.path.join(AQUI, f), tmp)
@@ -192,7 +193,72 @@ class Windows(unittest.TestCase):
self.assertTrue(p.stdout.strip().splitlines()[-1].startswith("FIN "))
self.assertFalse(os.path.exists(os.path.join(tmp, "datos", "sesion.json")))
finally:
tapon.close()
tapon.server_close()
shutil.rmtree(tmp, ignore_errors=True)
def test_instalarse_en_su_sitio(self):
"""Instalar un paquete lo lleva a su sitio definitivo con sus datos, sin borrar nada del origen. No viaja
lo que es de la sesión abierta ni lo que se rehace solo, y la copia de origen queda sabiendo adónde se fue."""
import sqlite3
import winreg
import instalar
tmp = tempfile.mkdtemp(prefix="organilab-mudanza-")
base, destino = os.path.join(tmp, "Descargas", "OrganizadorLabstream"), os.path.join(tmp, "Programs", "OrganiLAB")
for rel in ("OrganizadorLabstream.exe", "organizador.py", "static/index.html", "static/organilab.ico", "ffmpeg/ffmpeg.exe",
"datos/config.json", "datos/bitacora.jsonl", "datos/sesion.json", "datos/ventana/EBWebView/cache.bin",
"actualizacion/nuevo.zip", "__pycache__/organizador.cpython-312.pyc"):
p = os.path.join(base, rel)
os.makedirs(os.path.dirname(p), exist_ok=True)
with open(p, "wb") as fh:
fh.write(rel.encode() * 50)
con = sqlite3.connect(os.path.join(base, "datos", "inventario.sqlite"))
con.execute("CREATE TABLE archivos (ruta TEXT)")
con.execute("INSERT INTO archivos VALUES ('V:/Cliente/C0001.MP4')")
con.commit()
con.close()
inicio, escritorio = os.path.join(tmp, "inicio", "OrganiLAB.lnk"), os.path.join(tmp, "escritorio", "OrganiLAB.lnk")
os.makedirs(os.path.dirname(inicio))
os.makedirs(os.path.dirname(escritorio))
viejos = instalar._accesos, instalar.CLAVE, instalar.sitio_definitivo
instalar._accesos, instalar.CLAVE, instalar.sitio_definitivo = (lambda: (inicio, escritorio)), r"Software\OrganiLAB-ensayo\Mudanza", (lambda: destino)
try:
self.assertTrue(instalar.hay_que_mudar(base))
self.assertEqual(instalar.mudado(base), "")
pasos = []
r = instalar.poner(base, "3.1.0", avance=pasos.append)
self.assertTrue(r["mudado"])
self.assertTrue(instalar._misma(r["base"], destino))
self.assertTrue(pasos and 0 < pasos[-1] <= 1)
for rel in ("OrganizadorLabstream.exe", "organizador.py", "static/index.html", "ffmpeg/ffmpeg.exe", "datos/config.json", "datos/bitacora.jsonl"):
self.assertTrue(os.path.exists(os.path.join(destino, rel)), rel)
for rel in ("datos/sesion.json", "datos/ventana", "actualizacion", "__pycache__", instalar.MARCA_MUDANZA):
self.assertFalse(os.path.exists(os.path.join(destino, rel)), rel)
con = sqlite3.connect(os.path.join(destino, "datos", "inventario.sqlite"))
self.assertEqual(con.execute("SELECT ruta FROM archivos").fetchall(), [("V:/Cliente/C0001.MP4",)])
con.close()
# del origen no se ha ido nada, y ya sabe adónde se instaló
for rel in ("OrganizadorLabstream.exe", "ffmpeg/ffmpeg.exe", "datos/sesion.json", "datos/inventario.sqlite"):
self.assertTrue(os.path.exists(os.path.join(base, rel)), rel)
self.assertTrue(instalar._misma(os.path.dirname(instalar.mudado(base)), destino))
# dado de alta desde su sitio, no desde Descargas
self.assertTrue(instalar.instalado_aqui(destino))
self.assertTrue(instalar._misma(instalar.leer_acceso(inicio)["exe"], os.path.join(destino, "OrganizadorLabstream.exe")))
# instalar otra vez desde el origen avisa de que ya hay uno; desde su sitio, es solo darlo de alta
with self.assertRaises(instalar.YaInstalado):
instalar.poner(base, "3.1.0")
self.assertFalse(instalar.poner(destino, "3.1.0")["mudado"])
# y un destino ocupado no se pisa nunca
instalar.desinstalar(destino)
with self.assertRaises(FileExistsError):
instalar.mudar(base)
self.assertTrue(os.path.exists(os.path.join(destino, "organizador.py")))
finally:
for clave in (instalar.CLAVE, r"Software\OrganiLAB-ensayo"):
try:
winreg.DeleteKey(winreg.HKEY_CURRENT_USER, clave)
except OSError:
pass
instalar._accesos, instalar.CLAVE, instalar.sitio_definitivo = viejos
shutil.rmtree(tmp, ignore_errors=True)
def test_orden_de_arranque(self):
@@ -215,6 +281,24 @@ class Windows(unittest.TestCase):
self.assertEqual(p.stdout.strip(), "sigue")
class Manual(unittest.TestCase):
def test_manual_al_dia(self):
"""El manual vive dentro de la interfaz, con un apartado por cada pestaña del programa: si se añade una
vista y no se documenta, esto falla."""
import re
with open(os.path.join(AQUI, "static", "index.html"), encoding="utf-8") as fh:
html = fh.read()
vistas = re.findall(r'<a data-t="([a-z]+)"[^>]*>([^<]+)</a>', html[:html.index("</nav>")])
manual = html[html.index('<section id="t-manual">'):html.index("<!-- ============ AJUSTES")]
apartados = re.findall(r"<h3>([^<]+)</h3>", manual)
self.assertGreaterEqual(len(vistas), 8)
for clave, etiqueta in vistas:
if clave != "manual":
self.assertIn(etiqueta.replace("⚙", "").strip(), apartados)
self.assertGreater(manual.count("<dt>"), 35)
self.assertEqual(manual.count("<dt>"), manual.count("<dd>"))
class Firma(unittest.TestCase):
@classmethod
def setUpClass(cls):