From a3f4663bb0c2f02ad802f42e8507df8327aecce8 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:27:47 -0400 Subject: [PATCH 01/20] test: verificar valores exactos de Sa y Sq (cherry picked from commit 8c3d58fe4647d568f1413aa7aa8199e04d3a3d97) --- tests/core/test_roughness.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/core/test_roughness.py b/tests/core/test_roughness.py index 34b1eb9..2629e40 100644 --- a/tests/core/test_roughness.py +++ b/tests/core/test_roughness.py @@ -25,6 +25,16 @@ def test_flat_surface_zero_roughness() -> None: assert r.Ssk == 0.0 # guardia contra división por cero +def test_sa_sq_exact_values() -> None: + data = np.array([[1.0, 2.0], [3.0, 4.0]]) + ch = SPMChannel(name="Z", data=data, unit="m", x_range=1e-6, y_range=1e-6) + r = roughness.statistics(ch) + assert r.Sa == pytest.approx(1.0) + assert r.Sq == pytest.approx(np.sqrt(1.25)) + assert r.unit == "m" + assert r.n_points == 4 + + def test_sz_is_peak_to_valley() -> None: data = np.zeros((8, 8)) data[0, 0] = 10.0 From aa0639e6305ba495848b376255241b2c60378cd0 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:39:35 -0400 Subject: [PATCH 02/20] test: verify roughness CSV and JSON round trip (cherry picked from commit 52299f7b8bb82b7c51321ce3e648a7af4a2b0781) --- tests/core/test_export.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/core/test_export.py b/tests/core/test_export.py index 7f07e90..d13d7bb 100644 --- a/tests/core/test_export.py +++ b/tests/core/test_export.py @@ -2,6 +2,7 @@ from __future__ import annotations +import csv import json from pathlib import Path @@ -28,6 +29,33 @@ def test_roughness_to_csv(flat_noisy: SPMChannel, tmp_path: Path) -> None: assert "Sq" in text +def test_roughness_round_trip_csv_json(tmp_path: Path) -> None: + channel = SPMChannel( + name="Z", + data=np.array([[1.0, 2.0], [3.0, 4.0]]), + unit="m", + x_range=1.0, + y_range=1.0, + ) + result = roughness.statistics(channel) + + csv_path = to_csv(result, tmp_path / "roughness.csv") + json_path = to_json(result, tmp_path / "roughness.json") + + with csv_path.open(newline="") as file: + csv_result = {row["key"]: row["value"] for row in csv.DictReader(file)} + json_result = json.loads(json_path.read_text()) + + assert float(csv_result["Sa"]) == result.Sa + assert float(csv_result["Sq"]) == result.Sq + assert csv_result["unit"] == result.unit + assert int(csv_result["n_points"]) == result.n_points + assert json_result["Sa"] == result.Sa + assert json_result["Sq"] == result.Sq + assert json_result["unit"] == result.unit + assert json_result["n_points"] == result.n_points + + def test_profile_to_csv(tmp_path: Path) -> None: ch = SPMChannel(name="Z", data=np.zeros((5, 5)), unit="m", x_range=1e-6, y_range=1e-6) prof = profiles.line(ch, (0, 0), (4, 0), n=5) From 84f9ae051c2e8429271fdf8281238ec44fdcf28e Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:00:12 -0400 Subject: [PATCH 03/20] test(gui): aislar recursos Qt entre flujos E2E (cherry picked from commit 29afdff3f46cd37d3af20f134687a871c949ffe1) --- tests/gui/test_e2e_flows.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/gui/test_e2e_flows.py b/tests/gui/test_e2e_flows.py index aa3f0cf..5211313 100644 --- a/tests/gui/test_e2e_flows.py +++ b/tests/gui/test_e2e_flows.py @@ -68,6 +68,9 @@ def test_e2e_force_flow(qtbot, synthetic_volume) -> None: # type: ignore[no-unt assert mvm.result is not None # el mapa de módulo se calculó (grilla definida) for key in ("force_canvas", "map_canvas", "smfs_canvas", "batch_table"): assert not ws.panel(key).errored + ws.close() + ws.deleteLater() + qtbot.wait(0) def test_e2e_image_flow(qtbot) -> None: # type: ignore[no-untyped-def] @@ -81,6 +84,9 @@ def test_e2e_image_flow(qtbot) -> None: # type: ignore[no-untyped-def] assert ivm.roughness() is not None # la rugosidad se computa sobre la topografía for key in ("image_canvas", "grains_canvas", "spectral_canvas"): assert not ws.panel(key).errored + ws.close() + ws.deleteLater() + qtbot.wait(0) def test_e2e_resonance_flow(qtbot) -> None: # type: ignore[no-untyped-def] @@ -93,3 +99,6 @@ def test_e2e_resonance_flow(qtbot) -> None: # type: ignore[no-untyped-def] assert rvm.result is not None assert abs(rvm.result.peak.f0 - 72_800.0) < 500.0 # recupera f0 del pico sintético assert not ws.panel("resonance_canvas").errored + ws.close() + ws.deleteLater() + qtbot.wait(0) From fd64cbc503cdf7a067226941ef61bb93e1f1c5af Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:29:54 -0400 Subject: [PATCH 04/20] test: verifica plane_fit no destructivo (cherry picked from commit bc01805b9085af659420600f0ea68234e0ecd13f) --- tests/core/test_leveling.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/core/test_leveling.py b/tests/core/test_leveling.py index a8e51f1..5240052 100644 --- a/tests/core/test_leveling.py +++ b/tests/core/test_leveling.py @@ -23,6 +23,17 @@ def test_plane_fit_preserves_metadata(tilted_surface: SPMChannel) -> None: assert leveled.shape == tilted_surface.shape +def test_plane_fit_does_not_mutate_or_share_input_data(tilted_surface: SPMChannel) -> None: + original_data = tilted_surface.data.copy() + + leveled = leveling.plane_fit(tilted_surface) + + assert np.array_equal(tilted_surface.data, original_data) + assert isinstance(leveled, SPMChannel) + assert leveled is not tilted_surface + assert not np.shares_memory(leveled.data, tilted_surface.data) + + def test_polynomial_flattens_curvature() -> None: rows = cols = 32 yy, xx = np.mgrid[0:rows, 0:cols] From 09f2efcfdad132b6f806f77fedd771b5af0fe387 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:35:55 -0400 Subject: [PATCH 05/20] =?UTF-8?q?feat:=20a=C3=B1ade=20SHA-256=20a=20proyec?= =?UTF-8?q?tos=20spmproj?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit 6b7e43143d109fb99a819bfc73f10de69a638dd2) --- src/spmkit/core/project.py | 21 +++++++++++++++++++-- src/spmkit/gui/app_workspace.py | 2 +- tests/core/test_project.py | 22 ++++++++++++++++++++++ tests/gui/test_spmproj.py | 20 ++++++++++++++++++++ 4 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/spmkit/core/project.py b/src/spmkit/core/project.py index 941ce06..d184ba9 100644 --- a/src/spmkit/core/project.py +++ b/src/spmkit/core/project.py @@ -10,6 +10,7 @@ from __future__ import annotations +import hashlib import json from dataclasses import dataclass, field from pathlib import Path @@ -25,6 +26,16 @@ class OpenFile: path: str kind: str # "image" | "force" + sha256: str | None = None + + @classmethod + def from_path(cls, path: str | Path, kind: str) -> OpenFile: + ruta = Path(path) + resumen = hashlib.sha256() + with ruta.open("rb") as flujo: + for bloque in iter(lambda: flujo.read(1024 * 1024), b""): + resumen.update(bloque) + return cls(path=str(ruta), kind=kind, sha256=resumen.hexdigest()) @dataclass @@ -40,7 +51,9 @@ def to_dict(self) -> dict[str, Any]: return { "version": self.version, "perspective": self.perspective, - "files": [{"path": f.path, "kind": f.kind} for f in self.files], + "files": [ + {"path": f.path, "kind": f.kind, "sha256": f.sha256} for f in self.files + ], "params": self.params, } @@ -56,7 +69,11 @@ def load_project(path: str | Path) -> ProjectState: """Lee un ``.spmproj``, tolerante a campos faltantes/desconocidos.""" raw = json.loads(Path(path).read_text(encoding="utf-8")) files = [ - OpenFile(path=str(f["path"]), kind=str(f.get("kind", "force"))) + OpenFile( + path=str(f["path"]), + kind=str(f.get("kind", "force")), + sha256=str(f["sha256"]) if f.get("sha256") is not None else None, + ) for f in raw.get("files", []) if isinstance(f, dict) and f.get("path") ] diff --git a/src/spmkit/gui/app_workspace.py b/src/spmkit/gui/app_workspace.py index 65bfcec..d24540e 100644 --- a/src/spmkit/gui/app_workspace.py +++ b/src/spmkit/gui/app_workspace.py @@ -175,7 +175,7 @@ def _save_project(ws: Workspace, vm: ForceViewModel, session: dict[str, Any]) -> ) if not path: return - files = [OpenFile(session["path"], session["kind"])] if session.get("path") else [] + files = [OpenFile.from_path(session["path"], session["kind"])] if session.get("path") else [] state = ProjectState(files=files, params=vm.params, perspective=ws.active_perspective) save_project(state, path) _remember_dir(path) diff --git a/tests/core/test_project.py b/tests/core/test_project.py index f1a9f23..2551e2b 100644 --- a/tests/core/test_project.py +++ b/tests/core/test_project.py @@ -2,11 +2,33 @@ from __future__ import annotations +import hashlib import json from spmkit.core.project import OpenFile, ProjectState, load_project, save_project +def test_roundtrip_con_hash_sha256(tmp_path) -> None: # type: ignore[no-untyped-def] + contenido = b"spmkit-project-hash\x00\xff" + archivo = tmp_path / "datos.bin" + archivo.write_bytes(contenido) + esperado = hashlib.sha256(contenido).hexdigest() + state = ProjectState( + files=[OpenFile.from_path(archivo, "force")], + perspective="map", + ) + + path = save_project(state, tmp_path / "sesion.spmproj") + + raw = json.loads(path.read_text(encoding="utf-8")) + assert raw["files"] == [ + {"path": str(archivo), "kind": "force", "sha256": esperado} + ] + loaded = load_project(path) + assert loaded.files == [OpenFile(str(archivo), "force", esperado)] + assert loaded.perspective == "map" + + def test_roundtrip(tmp_path) -> None: # type: ignore[no-untyped-def] state = ProjectState( files=[OpenFile("a.nid", "force"), OpenFile("b.gwy", "image")], diff --git a/tests/gui/test_spmproj.py b/tests/gui/test_spmproj.py index e684b4d..db955d3 100644 --- a/tests/gui/test_spmproj.py +++ b/tests/gui/test_spmproj.py @@ -2,6 +2,7 @@ from __future__ import annotations +import hashlib from pathlib import Path import pytest @@ -19,6 +20,25 @@ def _force_sample() -> Path | None: return next(iter(_SAMPLES.glob("*.jpk-force")), None) +def test_spmproj_save_incluye_hash_sin_cargar_datos(qtbot, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def] + contenido = b"sesion-gui-spmkit" + archivo = tmp_path / "sesion.bin" + archivo.write_bytes(contenido) + proj = tmp_path / "sesion.spmproj" + ws = build_workspace() + qtbot.addWidget(ws) + vm = ws.panel("force_canvas")._vm + session = {"path": str(archivo), "kind": "force"} + monkeypatch.setattr( + QFileDialog, "getSaveFileName", staticmethod(lambda *a, **k: (str(proj), "")) + ) + + _save_project(ws, vm, session) + + state = load_project(proj) + assert state.files[0].sha256 == hashlib.sha256(contenido).hexdigest() + + def test_spmproj_save_and_open(qtbot, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def] sample = _force_sample() if sample is None: From d710ae69cf6200b953e528c0259e97058c33800c Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:42:46 -0400 Subject: [PATCH 06/20] =?UTF-8?q?A=C3=B1ade=20selecci=C3=B3n=20inequ=C3=AD?= =?UTF-8?q?voca=20al=20CLI=20de=20imagen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit 149c675a607157c0d355e57cdfa8d9040132151e) --- src/spmkit/cli/app.py | 85 ++++++++--- src/spmkit/core/models/spmdata.py | 26 ++++ tests/core/test_cli_image.py | 217 +++++++++++++++++++++++++++ tests/core/test_spmdata_selection.py | 81 ++++++++++ 4 files changed, 392 insertions(+), 17 deletions(-) create mode 100644 tests/core/test_cli_image.py create mode 100644 tests/core/test_spmdata_selection.py diff --git a/src/spmkit/cli/app.py b/src/spmkit/cli/app.py index e823af2..33554af 100644 --- a/src/spmkit/cli/app.py +++ b/src/spmkit/cli/app.py @@ -6,6 +6,7 @@ from __future__ import annotations +from enum import StrEnum from pathlib import Path import typer @@ -15,6 +16,7 @@ from spmkit import __version__, load from spmkit.core.analysis import kpfm, leveling, roughness, spectral from spmkit.core.export import to_csv, to_json +from spmkit.core.models import SPMChannel, SPMData from spmkit.core.verify import trace_nid app = typer.Typer( @@ -26,6 +28,31 @@ console = Console() +class _Level(StrEnum): + PLANE = "plane" + POLY = "poly" + ROWS = "rows" + NONE = "none" + + +def _select_channel( + data: SPMData, + name: str, + *, + direction: str | None = None, + group: str | None = None, + option_prefix: str = "", +) -> SPMChannel: + try: + return data.select(name, direction=direction, group=group) + except (KeyError, ValueError) as exc: + detail = str(exc.args[0]) if exc.args else str(exc) + raise typer.BadParameter( + f"{detail} Revise --{option_prefix}channel y use " + f"--{option_prefix}direction/--{option_prefix}group para precisar la selección." + ) from exc + + def _version_callback(value: bool) -> None: if value: console.print(f"spmkit {__version__}") @@ -47,12 +74,13 @@ def main( @app.command() -def info(file: Path = typer.Argument(..., exists=True, help="Archivo .nid o .nhf")) -> None: +def info(file: Path = typer.Argument(..., exists=True, help="Archivo .nid, .nhf o .gwy")) -> None: """Muestra metadatos y canales del archivo.""" data = load(file) table = Table(title=f"{file.name} · formato {data.metadata.get('format', '?')}") table.add_column("Canal", style="cyan") table.add_column("Dirección") + table.add_column("Grupo") table.add_column("Forma") table.add_column("Unidad") table.add_column("Tamaño X·Y", justify="right") @@ -60,6 +88,7 @@ def info(file: Path = typer.Argument(..., exists=True, help="Archivo .nid o .nhf table.add_row( ch.name, ch.direction, + ch.group, f"{ch.shape[0]}×{ch.shape[1]}", ch.unit, f"{ch.x_range * 1e6:.2f}×{ch.y_range * 1e6:.2f} µm", @@ -69,13 +98,15 @@ def info(file: Path = typer.Argument(..., exists=True, help="Archivo .nid o .nhf @app.command(name="roughness") def roughness_cmd( - file: Path = typer.Argument(..., exists=True, help="Archivo .nid o .nhf"), + file: Path = typer.Argument(..., exists=True, help="Archivo .nid, .nhf o .gwy"), channel: str = typer.Option("Z-Axis", "--channel", "-c", help="Canal a analizar"), - level: str = typer.Option("plane", "--level", "-l", help="Nivelación: plane|poly|none"), + direction: str | None = typer.Option(None, "--direction", help="Dirección del canal"), + group: str | None = typer.Option(None, "--group", help="Grupo del canal"), + level: _Level = typer.Option(_Level.PLANE, "--level", "-l", help="Nivelación"), ) -> None: """Calcula parámetros de rugosidad (ISO 25178) de un canal.""" data = load(file) - ch = data[channel] + ch = _select_channel(data, channel, direction=direction, group=group) ch = _apply_level(ch, level) result = roughness.statistics(ch) table = Table(title=f"Rugosidad · {channel} ({result.unit})") @@ -91,12 +122,14 @@ def roughness_cmd( @app.command(name="psd") def psd_cmd( - file: Path = typer.Argument(..., exists=True, help="Archivo .nid o .nhf"), + file: Path = typer.Argument(..., exists=True, help="Archivo .nid, .nhf o .gwy"), channel: str = typer.Option("Z-Axis", "--channel", "-c", help="Canal a analizar"), + direction: str | None = typer.Option(None, "--direction", help="Dirección del canal"), + group: str | None = typer.Option(None, "--group", help="Grupo del canal"), ) -> None: """Análisis espectral: dimensión fractal, Hurst y longitud de correlación.""" data = load(file) - ch = data[channel] + ch = _select_channel(data, channel, direction=direction, group=group) ch = leveling.plane_fit(ch) frac = spectral.fractal_dimension(ch) corr = spectral.correlation_length(ch) @@ -113,11 +146,15 @@ def psd_cmd( @app.command() def analyze( - file: Path = typer.Argument(..., exists=True, help="Archivo .nid o .nhf"), + file: Path = typer.Argument(..., exists=True, help="Archivo .nid, .nhf o .gwy"), output: Path = typer.Option(Path("./results"), "--output", "-o", help="Carpeta de salida"), channel: str = typer.Option("Z-Axis", "--channel", "-c"), + direction: str | None = typer.Option(None, "--direction", help="Dirección del canal"), + group: str | None = typer.Option(None, "--group", help="Grupo del canal"), cpd_channel: str = typer.Option("CPD", "--cpd-channel"), - level: str = typer.Option("plane", "--level", "-l"), + cpd_direction: str | None = typer.Option(None, "--cpd-direction", help="Dirección de CPD"), + cpd_group: str | None = typer.Option(None, "--cpd-group", help="Grupo de CPD"), + level: _Level = typer.Option(_Level.PLANE, "--level", "-l"), tip_work_function: float | None = typer.Option( None, "--tip-wf", help="Función de trabajo de la punta (eV) para KPFM" ), @@ -127,14 +164,24 @@ def analyze( output.mkdir(parents=True, exist_ok=True) stem = file.stem - ch = _apply_level(data[channel], level) + ch = _apply_level( + _select_channel(data, channel, direction=direction, group=group), + level, + ) rough = roughness.statistics(ch) to_csv(rough, output / f"{stem}_roughness.csv") to_json(rough, output / f"{stem}_roughness.json") console.print(f"[green]✓[/] Rugosidad → {output / (stem + '_roughness.csv')}") if cpd_channel in data.names: - cpd = kpfm.statistics(data[cpd_channel], tip_work_function=tip_work_function) + cpd_ch = _select_channel( + data, + cpd_channel, + direction=cpd_direction, + group=cpd_group, + option_prefix="cpd-", + ) + cpd = kpfm.statistics(cpd_ch, tip_work_function=tip_work_function) to_csv(cpd, output / f"{stem}_kpfm.csv") to_json(cpd, output / f"{stem}_kpfm.json") console.print(f"[green]✓[/] KPFM → {output / (stem + '_kpfm.csv')}") @@ -328,8 +375,10 @@ def evaporation( @app.command() def figure( - file: Path = typer.Argument(..., exists=True, help="Archivo .nid/.nhf/.gwy"), + file: Path = typer.Argument(..., exists=True, help="Archivo .nid, .nhf o .gwy"), channel: str = typer.Option("Z-Axis", "--channel", "-c"), + direction: str | None = typer.Option(None, "--direction", help="Dirección del canal"), + group: str | None = typer.Option(None, "--group", help="Grupo del canal"), output: Path = typer.Option(Path("figure.png"), "--output", "-o", help="png|svg|pdf"), colormap: str = typer.Option("batlow", "--colormap"), title: str = typer.Option("", "--title"), @@ -338,7 +387,7 @@ def figure( from spmkit.core.viz import FigureSpec, save_figure data = load(file) - ch = data[channel] + ch = _select_channel(data, channel, direction=direction, group=group) spec = FigureSpec( title=title or ch.name, colormap=colormap, colorbar_label=f"{ch.name} ({ch.unit})" ) @@ -454,14 +503,16 @@ def workspace( raise typer.Exit(code=run(str(file) if file else None)) -def _apply_level(ch, level: str): # type: ignore[no-untyped-def] - if level == "plane": +def _apply_level(ch: SPMChannel, level: _Level) -> SPMChannel: + if level is _Level.PLANE: return leveling.plane_fit(ch) - if level == "poly": + if level is _Level.POLY: return leveling.polynomial(ch, order=2) - if level == "none": + if level is _Level.ROWS: + return leveling.align_rows(ch, method="median") + if level is _Level.NONE: return ch - raise typer.BadParameter("level debe ser plane|poly|none") + raise AssertionError(f"Nivelado no soportado: {level}") def _force_recipe(model: str, tip_radius: float, recipe_path: Path | None = None): # type: ignore[no-untyped-def] diff --git a/src/spmkit/core/models/spmdata.py b/src/spmkit/core/models/spmdata.py index 5dec333..6b13382 100644 --- a/src/spmkit/core/models/spmdata.py +++ b/src/spmkit/core/models/spmdata.py @@ -112,6 +112,32 @@ def get(self, name: str, direction: str = "forward") -> SPMChannel: return ch raise KeyError(f"Canal no encontrado: {name!r}. Disponibles: {self.names}") + def select( + self, + name: str, + *, + direction: str | None = None, + group: str | None = None, + ) -> SPMChannel: + """Selecciona un único canal por los campos suministrados.""" + matches = [ + channel + for channel in self.channels + if channel.name == name + and (direction is None or channel.direction == direction) + and (group is None or channel.group == group) + ] + selection = f"name={name!r}, direction={direction!r}, group={group!r}" + identities = ", ".join( + f"(name={channel.name!r}, direction={channel.direction!r}, group={channel.group!r})" + for channel in (matches or self.channels) + ) + if not matches: + raise KeyError(f"Canal no encontrado para {selection}. Disponibles: {identities}") + if len(matches) > 1: + raise ValueError(f"Selección ambigua para {selection}. Disponibles: {identities}") + return matches[0] + def __getitem__(self, name: str) -> SPMChannel: return self.get(name) diff --git a/tests/core/test_cli_image.py b/tests/core/test_cli_image.py new file mode 100644 index 0000000..83825c2 --- /dev/null +++ b/tests/core/test_cli_image.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +import importlib +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import numpy as np +import pytest +from typer.testing import CliRunner + +from spmkit.core.models import SPMChannel, SPMData + +cli_app = importlib.import_module("spmkit.cli.app") +app = cli_app.app +runner = CliRunner() + + +def _channel( + direction: str = "forward", + group: str = "Scan 1", + *, + name: str = "Z-Axis", + values: np.ndarray | None = None, +) -> SPMChannel: + return SPMChannel( + name=name, + data=np.asarray(values if values is not None else np.arange(9).reshape(3, 3)), + unit="m" if name == "Z-Axis" else "V", + x_range=1e-6, + y_range=1e-6, + direction=direction, + group=group, + ) + + +def _invoke_roughness( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + data: SPMData, + *args: str, +) -> tuple[Any, list[SPMChannel]]: + source = tmp_path / "synthetic.gwy" + source.touch() + selected: list[SPMChannel] = [] + monkeypatch.setattr(cli_app, "load", lambda _path: data) + + def fake_statistics(channel: SPMChannel) -> SimpleNamespace: + selected.append(channel) + return SimpleNamespace(unit=channel.unit, to_dict=lambda: {"Sa": 0.0}) + + monkeypatch.setattr(cli_app.roughness, "statistics", fake_statistics) + result = runner.invoke(app, ["roughness", str(source), *args], terminal_width=200) + return result, selected + + +@pytest.mark.parametrize("command", ["roughness", "analyze", "psd", "figure"]) +def test_help_canales_incluye_selectores_y_gwy(command: str) -> None: + result = runner.invoke(app, [command, "--help"]) + + assert result.exit_code == 0, result.output + assert "--direction" in result.output + assert "--group" in result.output + assert ".gwy" in result.output + + +def test_help_level_muestra_choices_y_analyze_muestra_selectores_cpd() -> None: + roughness_help = runner.invoke(app, ["roughness", "--help"]) + analyze_help = runner.invoke(app, ["analyze", "--help"]) + + assert roughness_help.exit_code == 0, roughness_help.output + assert all(choice in roughness_help.output for choice in ("plane", "poly", "rows", "none")) + assert "--cpd-direction" in analyze_help.output + assert "--cpd-group" in analyze_help.output + + +def test_info_muestra_grupo(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + source = tmp_path / "synthetic.gwy" + source.touch() + monkeypatch.setattr(cli_app, "load", lambda _path: SPMData(channels=(_channel(),))) + + result = runner.invoke(app, ["info", str(source)]) + + assert result.exit_code == 0, result.output + assert "Grupo" in result.output + assert "Scan 1" in result.output + + +def test_level_invalido_se_rechaza_durante_parsing_con_choices(tmp_path: Path) -> None: + source = tmp_path / "synthetic.gwy" + source.touch() + + result = runner.invoke(app, ["roughness", str(source), "--level", "invalid"]) + + assert result.exit_code == 2 + assert "invalid" in result.output + assert all(choice in result.output for choice in ("plane", "poly", "rows", "none")) + + +def test_roughness_rows_usa_mediana(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + raw = np.array([[1.0, 2.0, 100.0], [10.0, 20.0, 30.0], [5.0, 5.0, 9.0]]) + data = SPMData(channels=(_channel(values=raw),)) + + result, selected = _invoke_roughness(monkeypatch, tmp_path, data, "--level", "rows") + + assert result.exit_code == 0, result.output + np.testing.assert_allclose(np.median(selected[0].data, axis=1), 0.0) + + +def test_roughness_selecciona_canal_exacto(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + channels = ( + _channel("forward", "Scan 1"), + _channel("forward", "Scan 2"), + _channel("backward", "Scan 1"), + ) + + result, selected = _invoke_roughness( + monkeypatch, + tmp_path, + SPMData(channels=channels), + "--direction", + "forward", + "--group", + "Scan 2", + "--level", + "none", + ) + + assert result.exit_code == 0, result.output + assert selected == [channels[1]] + + +def test_roughness_ambigua_es_bad_parameter_accionable( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + data = SPMData(channels=(_channel(group="Scan 1"), _channel(group="Scan 2"))) + + result, selected = _invoke_roughness(monkeypatch, tmp_path, data) + + assert result.exit_code == 2 + normalized_output = " ".join(result.output.replace("│", " ").split()) + assert "ambigua" in normalized_output + assert "Scan 1" in normalized_output + assert "Scan 2" in normalized_output + assert "--direction" in normalized_output + assert "--group" in normalized_output + assert selected == [] + + +def test_roughness_ausente_incluye_opciones_disponibles( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + data = SPMData(channels=(_channel(name="Height", group="Topography"),)) + + result, selected = _invoke_roughness(monkeypatch, tmp_path, data) + + assert result.exit_code == 2 + assert "Disponibles" in result.output + assert "Height" in result.output + assert "Topography" in result.output + assert selected == [] + + +def test_analyze_omite_cpd_solo_si_el_nombre_no_existe( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + source = tmp_path / "synthetic.gwy" + source.touch() + data = SPMData(channels=(_channel(),)) + monkeypatch.setattr(cli_app, "load", lambda _path: data) + monkeypatch.setattr( + cli_app.roughness, + "statistics", + lambda channel: SimpleNamespace(unit=channel.unit, to_dict=lambda: {"Sa": 0.0}), + ) + monkeypatch.setattr(cli_app, "to_csv", lambda *_args: None) + monkeypatch.setattr(cli_app, "to_json", lambda *_args: None) + + result = runner.invoke( + app, ["analyze", str(source), "--output", str(tmp_path / "output"), "--level", "none"] + ) + + assert result.exit_code == 0, result.output + assert "Sin canal CPD" in result.output + + +def test_analyze_rechaza_cpd_ambiguo_si_el_nombre_existe( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + source = tmp_path / "synthetic.gwy" + source.touch() + data = SPMData( + channels=( + _channel(), + _channel(name="CPD", group="Scan 1"), + _channel(name="CPD", group="Scan 2"), + ) + ) + monkeypatch.setattr(cli_app, "load", lambda _path: data) + monkeypatch.setattr( + cli_app.roughness, + "statistics", + lambda channel: SimpleNamespace(unit=channel.unit, to_dict=lambda: {"Sa": 0.0}), + ) + monkeypatch.setattr(cli_app, "to_csv", lambda *_args: None) + monkeypatch.setattr(cli_app, "to_json", lambda *_args: None) + + result = runner.invoke( + app, ["analyze", str(source), "--output", str(tmp_path / "output"), "--level", "none"] + ) + + assert result.exit_code == 2 + assert "ambigua" in result.output + assert "Scan 1" in result.output + assert "Scan 2" in result.output + assert "--cpd-direction" in result.output + assert "--cpd-group" in result.output diff --git a/tests/core/test_spmdata_selection.py b/tests/core/test_spmdata_selection.py new file mode 100644 index 0000000..f203713 --- /dev/null +++ b/tests/core/test_spmdata_selection.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import numpy as np +import pytest + +from spmkit.core.models import SPMChannel, SPMData + + +def _channel(name: str, direction: str, group: str) -> SPMChannel: + return SPMChannel( + name=name, + data=np.zeros((2, 2)), + unit="m", + x_range=1e-6, + y_range=1e-6, + direction=direction, + group=group, + ) + + +@pytest.fixture +def duplicate_channels() -> SPMData: + return SPMData( + channels=( + _channel("Z-Axis", "forward", "Scan 1"), + _channel("Z-Axis", "backward", "Scan 1"), + _channel("Z-Axis", "forward", "Scan 2"), + _channel("CPD", "forward", "Scan 1"), + ) + ) + + +def test_select_devuelve_la_identidad_exacta(duplicate_channels: SPMData) -> None: + selected = duplicate_channels.select("Z-Axis", direction="forward", group="Scan 2") + + assert selected is duplicate_channels.channels[2] + + +def test_select_filtra_solo_los_campos_suministrados(duplicate_channels: SPMData) -> None: + selected = duplicate_channels.select("Z-Axis", direction="backward") + + assert selected is duplicate_channels.channels[1] + + +def test_select_rechaza_nombre_ambiguo_con_identidades(duplicate_channels: SPMData) -> None: + with pytest.raises(ValueError, match="ambigua") as exc_info: + duplicate_channels.select("Z-Axis") + + message = str(exc_info.value) + assert "forward" in message + assert "backward" in message + assert "Scan 1" in message + assert "Scan 2" in message + + +def test_select_rechaza_direccion_ambigua_sin_grupo(duplicate_channels: SPMData) -> None: + with pytest.raises(ValueError, match="ambigua") as exc_info: + duplicate_channels.select("Z-Axis", direction="forward") + + message = str(exc_info.value) + assert "Scan 1" in message + assert "Scan 2" in message + + +def test_select_ausente_describe_seleccion_y_opciones(duplicate_channels: SPMData) -> None: + with pytest.raises(KeyError) as exc_info: + duplicate_channels.select("Z-Axis", direction="backward", group="Scan 2") + + message = str(exc_info.value) + assert "Z-Axis" in message + assert "backward" in message + assert "Scan 2" in message + assert "Disponibles" in message + assert "forward" in message + assert "Scan 1" in message + + +def test_get_y_getitem_conservan_el_fallback_compatible(duplicate_channels: SPMData) -> None: + assert duplicate_channels.get("Z-Axis") is duplicate_channels.channels[0] + assert duplicate_channels.get("Z-Axis", direction="missing") is duplicate_channels.channels[0] + assert duplicate_channels["Z-Axis"] is duplicate_channels.channels[0] From 3864084dbf2a761724c26a7f2edccf492ec38eac Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:06:08 -0400 Subject: [PATCH 07/20] =?UTF-8?q?feat(cli):=20a=C3=B1adir=20exportaci?= =?UTF-8?q?=C3=B3n=20de=20perfiles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit 31e70a2815970a259a60a35c6187aca78f2c447e) --- .superpowers/sdd/slice-b-task-4-report.md | 40 ++++++ src/spmkit/cli/app.py | 31 ++++- tests/e2e/cli/test_image_journey.py | 158 ++++++++++++++++++++++ tests/e2e/conftest.py | 60 ++++++++ tests/e2e/library/test_image_journey.py | 98 ++++++++++++++ 5 files changed, 385 insertions(+), 2 deletions(-) create mode 100644 .superpowers/sdd/slice-b-task-4-report.md create mode 100644 tests/e2e/cli/test_image_journey.py create mode 100644 tests/e2e/conftest.py create mode 100644 tests/e2e/library/test_image_journey.py diff --git a/.superpowers/sdd/slice-b-task-4-report.md b/.superpowers/sdd/slice-b-task-4-report.md new file mode 100644 index 0000000..c655082 --- /dev/null +++ b/.superpowers/sdd/slice-b-task-4-report.md @@ -0,0 +1,40 @@ +# Slice B Task 4 — reporte + +## Resultado + +- Se añadió `profile` al CLI monolítico usando `_select_channel`, `_apply_level`, + `profiles.line` y `to_csv`. +- `--x1` y `--y1` son opciones requeridas; toda coordenada se documenta en píxeles. +- Los errores de extracción de perfil se traducen a `typer.BadParameter`. +- El colormap por defecto de `figure` cambió de `batlow` a `gold`. +- Se añadieron journeys de librería y CLI sobre un `.gwy` real, temporal y reproducible. + +## TDD + +- RED: el journey focal ejecutó dos pruebas y falló en la tercera con + `No such command 'profile'`. +- GREEN: el mismo comando terminó con `3 passed` tras el adaptador mínimo. + +## Verificación + +- `pytest tests/e2e/library/test_image_journey.py tests/e2e/cli/test_image_journey.py + -q --no-cov`: 3 passed. +- `ruff check src/spmkit/cli/app.py tests/e2e`: limpio. +- `mypy src/spmkit/cli/app.py`: limpio. +- `pytest tests/core/test_cli_image.py -q --no-cov`: 13 passed. +- QA manual: ayuda visible, CSV `distance[m],height[m]` generado desde un `.gwy` real y + coordenada fuera de rango rechazada con código 2. + +## Auto-revisión y evidencia de depuración + +- Hipótesis: la selección ambigua podía elegir un canal arbitrario. Evidencia: el journey real + devuelve código 2 y exige `--direction/--group`. +- Hipótesis: un punto inválido podía filtrar un traceback. Evidencia: la QA devuelve código 2 y + el mensaje `Punto fuera de los límites de la imagen`. +- Hipótesis: el export podía perder unidades o valores. Evidencia: CSV/JSON reabiertos coinciden + con resultados en memoria y el PNG conserva firma válida. + +No se encontraron concerns dentro del alcance. Dos gates globales ajenos al cambio quedaron +registrados: `make check` se detiene por el stub `types-PyYAML` ausente en el venv, y la suite GUI +completa produce un segfault preexistente en `tests/gui/test_map_vm.py:76`, también con Qt +offscreen. Los gates focales solicitados están verdes. diff --git a/src/spmkit/cli/app.py b/src/spmkit/cli/app.py index 33554af..059f35b 100644 --- a/src/spmkit/cli/app.py +++ b/src/spmkit/cli/app.py @@ -14,7 +14,7 @@ from rich.table import Table from spmkit import __version__, load -from spmkit.core.analysis import kpfm, leveling, roughness, spectral +from spmkit.core.analysis import kpfm, leveling, profiles, roughness, spectral from spmkit.core.export import to_csv, to_json from spmkit.core.models import SPMChannel, SPMData from spmkit.core.verify import trace_nid @@ -120,6 +120,33 @@ def roughness_cmd( console.print(table) +@app.command(help="Extrae un perfil de línea entre coordenadas de píxel.") +def profile( + file: Path = typer.Argument(..., exists=True, help="Archivo .nid, .nhf o .gwy"), + channel: str = typer.Option("Z-Axis", "--channel", "-c", help="Canal a analizar"), + direction: str | None = typer.Option(None, "--direction", help="Dirección del canal"), + group: str | None = typer.Option(None, "--group", help="Grupo del canal"), + x0: float = typer.Option(0.0, "--x0", help="Coordenada X inicial en píxeles"), + y0: float = typer.Option(0.0, "--y0", help="Coordenada Y inicial en píxeles"), + x1: float = typer.Option(..., "--x1", help="Coordenada X final en píxeles"), + y1: float = typer.Option(..., "--y1", help="Coordenada Y final en píxeles"), + n: int | None = typer.Option(None, "--n", help="Número de muestras"), + level: _Level = typer.Option(_Level.PLANE, "--level", "-l", help="Nivelación"), + output: Path = typer.Option(Path("profile.csv"), "--output", "-o", help="CSV de salida"), +) -> None: + data = load(file) + ch = _apply_level( + _select_channel(data, channel, direction=direction, group=group), + level, + ) + try: + result = profiles.line(ch, (x0, y0), (x1, y1), n=n) + except ValueError as exc: + raise typer.BadParameter(str(exc)) from exc + to_csv(result, output) + console.print(f"[green]✓[/] Perfil → {output}") + + @app.command(name="psd") def psd_cmd( file: Path = typer.Argument(..., exists=True, help="Archivo .nid, .nhf o .gwy"), @@ -380,7 +407,7 @@ def figure( direction: str | None = typer.Option(None, "--direction", help="Dirección del canal"), group: str | None = typer.Option(None, "--group", help="Grupo del canal"), output: Path = typer.Option(Path("figure.png"), "--output", "-o", help="png|svg|pdf"), - colormap: str = typer.Option("batlow", "--colormap"), + colormap: str = typer.Option("gold", "--colormap"), title: str = typer.Option("", "--title"), ) -> None: """Exporta una figura de publicación (con scale bar y colormap científico).""" diff --git a/tests/e2e/cli/test_image_journey.py b/tests/e2e/cli/test_image_journey.py new file mode 100644 index 0000000..496a455 --- /dev/null +++ b/tests/e2e/cli/test_image_journey.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import csv +import json +import math +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from spmkit import load +from spmkit.cli.app import app +from spmkit.core.analysis import kpfm, leveling, roughness + +runner = CliRunner() + + +def _csv_scalars(path: Path) -> dict[str, str]: + with path.open(newline="", encoding="utf-8") as stream: + return {row["key"]: row["value"] for row in csv.DictReader(stream)} + + +def test_real_gwy_cli_info_selection_and_analysis(real_gwy_path: Path, tmp_path: Path) -> None: + info_result = runner.invoke(app, ["info", str(real_gwy_path)], terminal_width=200) + assert info_result.exit_code == 0, info_result.output + assert "formato gwy" in info_result.output + assert "Grupo" in info_result.output + assert "Z-Axis forward" in info_result.output + assert "Z-Axis backward" in info_result.output + assert "CPD forward" in info_result.output + + roughness_result = runner.invoke( + app, + ["roughness", str(real_gwy_path), "--direction", "forward", "--level", "plane"], + ) + assert roughness_result.exit_code == 0, roughness_result.output + + ambiguous_result = runner.invoke(app, ["roughness", str(real_gwy_path)]) + assert ambiguous_result.exit_code == 2 + normalized_error = " ".join(ambiguous_result.output.replace("│", " ").split()) + assert "ambigua" in normalized_error.casefold() + assert "--direction/--group" in normalized_error + + output_dir = tmp_path / "analysis" + analyze_result = runner.invoke( + app, + [ + "analyze", + str(real_gwy_path), + "--output", + str(output_dir), + "--direction", + "forward", + "--cpd-direction", + "forward", + "--tip-wf", + "4.7", + ], + ) + assert analyze_result.exit_code == 0, analyze_result.output + + data = load(real_gwy_path) + expected_roughness = roughness.statistics( + leveling.plane_fit(data.select("Z-Axis", direction="forward")) + ) + expected_kpfm = kpfm.statistics( + data.select("CPD", direction="forward"), tip_work_function=4.7 + ) + stem = real_gwy_path.stem + roughness_csv = _csv_scalars(output_dir / f"{stem}_roughness.csv") + roughness_json = json.loads( + (output_dir / f"{stem}_roughness.json").read_text(encoding="utf-8") + ) + kpfm_csv = _csv_scalars(output_dir / f"{stem}_kpfm.csv") + kpfm_json = json.loads((output_dir / f"{stem}_kpfm.json").read_text(encoding="utf-8")) + assert roughness_csv["unit"] == roughness_json["unit"] == expected_roughness.unit + assert float(roughness_csv["Sq"]) == pytest.approx(expected_roughness.Sq) + assert roughness_json["Sq"] == pytest.approx(expected_roughness.Sq) + assert kpfm_csv["unit"] == kpfm_json["unit"] == expected_kpfm.unit + assert float(kpfm_csv["mean"]) == pytest.approx(expected_kpfm.mean) + assert kpfm_json["work_function"] == pytest.approx(expected_kpfm.work_function) + + +def test_real_gwy_cli_profile_and_default_figure(real_gwy_path: Path, tmp_path: Path) -> None: + profile_help = runner.invoke(app, ["profile", "--help"]) + assert profile_help.exit_code == 0, profile_help.output + assert "coordenadas de píxel" in profile_help.output + assert "--x1" in profile_help.output and "required" in profile_help.output + assert "--y1" in profile_help.output and "required" in profile_help.output + + profile_path = tmp_path / "profile.csv" + profile_result = runner.invoke( + app, + [ + "profile", + str(real_gwy_path), + "--direction", + "forward", + "--x0", + "0.5", + "--y0", + "0.5", + "--x1", + "5.5", + "--y1", + "3.5", + "--n", + "3", + "--level", + "none", + "--output", + str(profile_path), + ], + ) + assert profile_result.exit_code == 0, profile_result.output + with profile_path.open(newline="", encoding="utf-8") as stream: + rows = list(csv.reader(stream)) + assert rows[0] == ["distance[m]", "height[m]"] + assert len(rows) == 4 + for row in rows[1:]: + assert len(row) == 2 + assert all(math.isfinite(float(value)) for value in row) + + invalid_profile = runner.invoke( + app, + [ + "profile", + str(real_gwy_path), + "--direction", + "forward", + "--x1", + "7", + "--y1", + "4", + ], + ) + assert invalid_profile.exit_code == 2 + assert "fuera de los límites" in invalid_profile.output + + figure_help = runner.invoke(app, ["figure", "--help"]) + assert figure_help.exit_code == 0, figure_help.output + assert "gold" in figure_help.output + figure_path = tmp_path / "figure.png" + figure_result = runner.invoke( + app, + [ + "figure", + str(real_gwy_path), + "--direction", + "forward", + "--output", + str(figure_path), + ], + ) + assert figure_result.exit_code == 0, figure_result.output + figure_bytes = figure_path.read_bytes() + assert figure_bytes.startswith(b"\x89PNG\r\n\x1a\n") + assert len(figure_bytes) > 1_000 diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 0000000..6c510e3 --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest + +from spmkit.core.io import save_gwy +from spmkit.core.models import SPMChannel, SPMData + + +def pytest_configure(config: pytest.Config) -> None: + config.option.importmode = "importlib" + + +@pytest.fixture +def real_gwy_path(tmp_path: Path) -> Path: + pytest.importorskip("gwyfile") + + rows, cols = np.indices((5, 7), dtype=np.float64) + texture = ((cols + 2.0 * rows) % 3.0 - 1.0) * 0.25e-9 + topography_forward = 10e-9 + 2e-9 * cols + 3e-9 * rows + texture + topography_backward = 20e-9 - 1e-9 * cols + 1.5e-9 * rows - texture + cpd = 0.1 + 0.01 * rows + 0.005 * cols + x_range = 7e-6 + y_range = 10e-6 + + data = SPMData( + channels=( + SPMChannel( + name="Z-Axis", + data=topography_forward, + unit="m", + x_range=x_range, + y_range=y_range, + direction="forward", + group="Topography forward", + ), + SPMChannel( + name="Z-Axis", + data=topography_backward, + unit="m", + x_range=x_range, + y_range=y_range, + direction="backward", + group="Topography backward", + ), + SPMChannel( + name="CPD", + data=cpd, + unit="V", + x_range=x_range, + y_range=y_range, + direction="forward", + group="Potential forward", + ), + ), + metadata={"format": "synthetic"}, + ) + return save_gwy(data, tmp_path / "image_journey.gwy") diff --git a/tests/e2e/library/test_image_journey.py b/tests/e2e/library/test_image_journey.py new file mode 100644 index 0000000..62f10cd --- /dev/null +++ b/tests/e2e/library/test_image_journey.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import csv +import json +from pathlib import Path + +import numpy as np +import pytest + +from spmkit import load +from spmkit.core.analysis import kpfm, leveling, profiles, roughness +from spmkit.core.export import to_csv, to_json +from spmkit.core.viz import FigureSpec, save_figure + + +def test_real_gwy_library_image_journey(real_gwy_path: Path, tmp_path: Path) -> None: + data = load(real_gwy_path) + + assert data.metadata["format"] == "gwy" + assert data.source_path == str(real_gwy_path) + assert len(data.channels) == 3 + assert data.names == ["Z-Axis", "Z-Axis", "CPD"] + forward = data.select("Z-Axis", direction="forward", group="Z-Axis forward") + backward = data.select("Z-Axis", direction="backward", group="Z-Axis backward") + assert forward.shape == backward.shape == (5, 7) + assert forward.unit == backward.unit == "m" + assert forward.x_range == backward.x_range == pytest.approx(7e-6) + assert forward.y_range == backward.y_range == pytest.approx(10e-6) + assert not np.array_equal(forward.data, backward.data) + + raw = forward.data.copy() + leveled = leveling.plane_fit(forward) + np.testing.assert_array_equal(forward.data, raw) + assert not np.shares_memory(leveled.data, forward.data) + + roughness_result = roughness.statistics(leveled) + assert roughness_result.unit == "m" + assert all( + np.isfinite(value) + for value in ( + roughness_result.Sa, + roughness_result.Sq, + roughness_result.Sz, + roughness_result.Sp, + roughness_result.Sv, + roughness_result.Ssk, + roughness_result.Sku, + ) + ) + + profile = profiles.line(forward, (0.5, 0.5), (5.5, 3.5), n=3) + assert profile.unit == "m" + assert profile.distance_unit == "m" + assert len(profile) == 3 + assert profile.height[0] == pytest.approx(float(np.mean(raw[:2, :2]))) + assert profile.height[1] == pytest.approx(float(raw[2, 3])) + assert profile.height[-1] == pytest.approx(float(np.mean(raw[3:5, 5:7]))) + expected_distance = np.hypot(5.0 * forward.pixel_size_x, 3.0 * forward.pixel_size_y) + assert profile.distance[-1] == pytest.approx(expected_distance) + + cpd_channel = data.select("CPD", direction="forward", group="CPD forward") + assert cpd_channel.shape == (5, 7) + assert cpd_channel.unit == "V" + cpd_result = kpfm.statistics(cpd_channel, tip_work_function=4.7) + assert cpd_result.mean == pytest.approx(0.135) + assert cpd_result.minimum == pytest.approx(0.1) + assert cpd_result.maximum == pytest.approx(0.17) + assert cpd_result.contrast == pytest.approx(0.07) + assert cpd_result.work_function == pytest.approx(4.7 - cpd_result.mean) + assert cpd_result.work_function_unit == "eV" + + roughness_csv = to_csv(roughness_result, tmp_path / "roughness.csv") + with roughness_csv.open(newline="", encoding="utf-8") as stream: + roughness_rows = {row["key"]: row["value"] for row in csv.DictReader(stream)} + assert roughness_rows["unit"] == roughness_result.unit + assert float(roughness_rows["Sq"]) == pytest.approx(roughness_result.Sq) + assert int(roughness_rows["n_points"]) == roughness_result.n_points + + profile_csv = to_csv(profile, tmp_path / "profile.csv") + with profile_csv.open(newline="", encoding="utf-8") as stream: + profile_rows = list(csv.DictReader(stream)) + assert tuple(profile_rows[0]) == ("distance[m]", "height[m]") + np.testing.assert_allclose( + [float(row["distance[m]"]) for row in profile_rows], profile.distance + ) + np.testing.assert_allclose([float(row["height[m]"]) for row in profile_rows], profile.height) + + kpfm_json = to_json(cpd_result, tmp_path / "kpfm.json") + reopened_kpfm = json.loads(kpfm_json.read_text(encoding="utf-8")) + assert reopened_kpfm["unit"] == cpd_result.unit + assert reopened_kpfm["work_function_unit"] == cpd_result.work_function_unit + assert reopened_kpfm["mean"] == pytest.approx(cpd_result.mean) + assert reopened_kpfm["work_function"] == pytest.approx(cpd_result.work_function) + + figure_path = save_figure(forward, FigureSpec(), tmp_path / "topography.png") + figure_bytes = figure_path.read_bytes() + assert figure_bytes.startswith(b"\x89PNG\r\n\x1a\n") + assert len(figure_bytes) > 1_000 From 7940d2f220088f9f9556ecd4ce769dffbb5e6ae0 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:17:47 -0400 Subject: [PATCH 08/20] fix: modernize isinstance type checks (cherry picked from commit e3749396f8128bf6ac570435df1884d0b0ac7c41) --- src/spmkit/core/export/writers.py | 2 +- src/spmkit/core/viz/forcecurve.py | 8 ++++---- src/spmkit/gui/app_workspace.py | 2 +- src/spmkit/gui/panels/force_canvas.py | 2 +- src/spmkit/gui/panels/inspector.py | 4 ++-- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/spmkit/core/export/writers.py b/src/spmkit/core/export/writers.py index 11c05b0..5a15109 100644 --- a/src/spmkit/core/export/writers.py +++ b/src/spmkit/core/export/writers.py @@ -42,7 +42,7 @@ def _to_serializable(obj: Any) -> Any: return obj.item() if isinstance(obj, dict): return {k: _to_serializable(v) for k, v in obj.items()} - if isinstance(obj, (list, tuple)): + if isinstance(obj, list | tuple): return [_to_serializable(v) for v in obj] return obj diff --git a/src/spmkit/core/viz/forcecurve.py b/src/spmkit/core/viz/forcecurve.py index 09ffa09..d3d8b8d 100644 --- a/src/spmkit/core/viz/forcecurve.py +++ b/src/spmkit/core/viz/forcecurve.py @@ -21,7 +21,7 @@ def _modulus_label(ctx: dict[str, Any]) -> str: e = ctx.get("young_modulus") - if not isinstance(e, (int, float)) or not np.isfinite(e): + if not isinstance(e, int | float) or not np.isfinite(e): return "" es = ctx.get("young_modulus_std", 0.0) or 0.0 scale, unit = 1.0, "Pa" @@ -30,7 +30,7 @@ def _modulus_label(ctx: dict[str, Any]) -> str: scale, unit = s, u break r2 = ctx.get("r_squared") - r2_txt = f"\nR² = {r2:.4f}" if isinstance(r2, (int, float)) and np.isfinite(r2) else "" + r2_txt = f"\nR² = {r2:.4f}" if isinstance(r2, int | float) and np.isfinite(r2) else "" return f"E = {e / scale:.3g} ± {es / scale:.2g} {unit}{r2_txt}" @@ -55,7 +55,7 @@ def render_force_curve( ctx = ctx or {} contact = ctx.get("contact_point") - offset = float(contact) if (indentation and isinstance(contact, (int, float))) else 0.0 + offset = float(contact) if (indentation and isinstance(contact, int | float)) else 0.0 def axis_of(seg: Any) -> np.ndarray: return (display_axis(seg.separation, seg.raw_height) - offset) * _NM @@ -82,7 +82,7 @@ def axis_of(seg: Any) -> np.ndarray: lw=2.2, label="ajuste", ) - if isinstance(contact, (int, float)) and ctx.get("contact_detected", True): + if isinstance(contact, int | float) and ctx.get("contact_detected", True): # Punto de contacto en oro (coherente con la app y el logo Fathom). ax.axvline((float(contact) - offset) * _NM, color="#B26A1E", ls="--", lw=0.9) diff --git a/src/spmkit/gui/app_workspace.py b/src/spmkit/gui/app_workspace.py index d24540e..abb62ef 100644 --- a/src/spmkit/gui/app_workspace.py +++ b/src/spmkit/gui/app_workspace.py @@ -256,7 +256,7 @@ def _suggested(name: str) -> str: def _scalar_results(ctx: dict) -> dict: """Filtra el contexto a valores serializables (descarta el objeto de ajuste).""" - return {k: v for k, v in ctx.items() if isinstance(v, (int, float, str, bool)) or v is None} + return {k: v for k, v in ctx.items() if isinstance(v, int | float | str | bool) or v is None} def _results_tsv(ctx: dict) -> str: diff --git a/src/spmkit/gui/panels/force_canvas.py b/src/spmkit/gui/panels/force_canvas.py index c59cee4..34b804a 100644 --- a/src/spmkit/gui/panels/force_canvas.py +++ b/src/spmkit/gui/panels/force_canvas.py @@ -216,7 +216,7 @@ def _on_results(self, ctx: dict) -> None: """Re-render con la curva calibrada + overlay de ajuste + residuos.""" self._last_ctx = ctx cp = ctx.get("contact_point") - self._contact = float(cp) if isinstance(cp, (int, float)) else None + self._contact = float(cp) if isinstance(cp, int | float) else None self._refresh_offset() curve = self._vm.result_curve() if curve is not None: diff --git a/src/spmkit/gui/panels/inspector.py b/src/spmkit/gui/panels/inspector.py index f1f441e..4df6bec 100644 --- a/src/spmkit/gui/panels/inspector.py +++ b/src/spmkit/gui/panels/inspector.py @@ -47,7 +47,7 @@ def _fmt_modulus(e: float, es: float) -> str: def _fmt_scaled(value: object, scale: float, unit: str, prec: str = ".3g") -> str: """Formatea un número escalado (p. ej. N→nN) o ``—`` si no es finito.""" - if not isinstance(value, (int, float)) or not math.isfinite(float(value)): + if not isinstance(value, int | float) or not math.isfinite(float(value)): return _EMPTY return f"{float(value) * scale:{prec}} {unit}" @@ -99,7 +99,7 @@ def _on_results(self, ctx: dict) -> None: ) r2 = ctx.get("r_squared") self._values["r_squared"].setText( - f"{float(r2):.4f}" if isinstance(r2, (int, float)) and math.isfinite(r2) else _EMPTY + f"{float(r2):.4f}" if isinstance(r2, int | float) and math.isfinite(r2) else _EMPTY ) self._values["contact"].setText(_fmt_scaled(ctx.get("contact_point"), 1e9, "nm")) self._values["adhesion"].setText(_fmt_scaled(ctx.get("adhesion"), 1e9, "nN")) From adb805b93e05bc4ef2126e7410fc8c911a18971e Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:12:12 -0400 Subject: [PATCH 09/20] test(e2e): aislar identidades de journeys (cherry picked from commit 34e24e680516b4ca8fb6b363e0205d8948df0672) --- .superpowers/sdd/slice-b-task-4-report.md | 8 ++++++++ tests/e2e/cli/__init__.py | 1 + tests/e2e/conftest.py | 4 ---- tests/e2e/library/__init__.py | 1 + 4 files changed, 10 insertions(+), 4 deletions(-) create mode 100644 tests/e2e/cli/__init__.py create mode 100644 tests/e2e/library/__init__.py diff --git a/.superpowers/sdd/slice-b-task-4-report.md b/.superpowers/sdd/slice-b-task-4-report.md index c655082..082cc54 100644 --- a/.superpowers/sdd/slice-b-task-4-report.md +++ b/.superpowers/sdd/slice-b-task-4-report.md @@ -38,3 +38,11 @@ No se encontraron concerns dentro del alcance. Dos gates globales ajenos al camb registrados: `make check` se detiene por el stub `types-PyYAML` ausente en el venv, y la suite GUI completa produce un segfault preexistente en `tests/gui/test_map_vm.py:76`, también con Qt offscreen. Los gates focales solicitados están verdes. + +## Corrección de revisión + +Se detectó que `tests/e2e/conftest.py` cambiaba globalmente el modo de importación de Pytest para +evitar la colisión entre los dos módulos `test_image_journey.py`. Se eliminó por completo ese hook +y se dieron identidades de paquete explícitas a `tests/e2e/library` y `tests/e2e/cli` mediante sus +respectivos `__init__.py`. Los dos journeys pasan juntos y la colección combinada de `tests/core` +y `tests/e2e` termina correctamente sin alterar la configuración global de Pytest. diff --git a/tests/e2e/cli/__init__.py b/tests/e2e/cli/__init__.py new file mode 100644 index 0000000..1c5882c --- /dev/null +++ b/tests/e2e/cli/__init__.py @@ -0,0 +1 @@ +"""Journeys end-to-end de la interfaz CLI.""" diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 6c510e3..74aa03c 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -9,10 +9,6 @@ from spmkit.core.models import SPMChannel, SPMData -def pytest_configure(config: pytest.Config) -> None: - config.option.importmode = "importlib" - - @pytest.fixture def real_gwy_path(tmp_path: Path) -> Path: pytest.importorskip("gwyfile") diff --git a/tests/e2e/library/__init__.py b/tests/e2e/library/__init__.py new file mode 100644 index 0000000..fbb27f3 --- /dev/null +++ b/tests/e2e/library/__init__.py @@ -0,0 +1 @@ +"""Journeys end-to-end de la API de librería.""" From dca86b2495126f26fb057c65f03e75ef7d791b98 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:22:44 -0400 Subject: [PATCH 10/20] test(e2e): caracteriza journey GUI de imagen (cherry picked from commit adcd5e512864c352985dfaf73d9a89c6a3acc075) --- scripts/run_gui_tests.sh | 2 +- tests/e2e/gui/test_image_journey.py | 181 ++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/gui/test_image_journey.py diff --git a/scripts/run_gui_tests.sh b/scripts/run_gui_tests.sh index cfad3a5..8aa6ee5 100644 --- a/scripts/run_gui_tests.sh +++ b/scripts/run_gui_tests.sh @@ -78,7 +78,7 @@ _run_one() { } failed="" -for f in tests/gui/test_*.py; do +for f in tests/gui/test_*.py tests/e2e/gui/test_*.py; do echo "▶ $f" _run_one "$f" case $? in diff --git a/tests/e2e/gui/test_image_journey.py b/tests/e2e/gui/test_image_journey.py new file mode 100644 index 0000000..1c8ef8c --- /dev/null +++ b/tests/e2e/gui/test_image_journey.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import csv +from pathlib import Path + +import numpy as np +from PyQt6.QtCore import QCoreApplication, QEvent, Qt +from PyQt6.QtWidgets import QFileDialog, QPushButton + +from spmkit.gui.app_workspace import build_workspace + + +def test_corrupt_gwy_via_open_action_keeps_gui_alive( + qtbot, monkeypatch, tmp_path: Path +) -> None: # type: ignore[no-untyped-def] + corrupt_path = tmp_path / "corrupt_image.gwy" + corrupt_path.write_bytes(b"not a Gwyddion file") + monkeypatch.setattr( + QFileDialog, + "getOpenFileName", + staticmethod(lambda *args, **kwargs: (str(corrupt_path), "")), + ) + ws = build_workspace() + ws.show() + qtbot.wait(0) + + try: + open_action = next( + action for action in ws._persp_bar.actions() if "Abrir" in action.text() + ) + open_action.trigger() + qtbot.wait(0) + + canvas = ws.panel("image_canvas") + assert canvas is not None + assert not ws.isHidden() + assert ws.active_perspective != "image" + assert canvas._vm.data is None + assert ws._status._message.text() == ( + "No se pudo abrir corrupt_image.gwy: " + "archivo .gwy inválido o corrupto: corrupt_image.gwy" + ) + finally: + ws.close() + ws.deleteLater() + QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete) + QCoreApplication.processEvents() + qtbot.wait(0) + + +def test_real_gwy_gui_image_journey( + qtbot, monkeypatch, real_gwy_path: Path, tmp_path: Path +) -> None: # type: ignore[no-untyped-def] + monkeypatch.setattr( + QFileDialog, + "getOpenFileName", + staticmethod(lambda *args, **kwargs: (str(real_gwy_path), "")), + ) + ws = build_workspace() + ws.show() + qtbot.wait(0) + + try: + open_action = next( + action for action in ws._persp_bar.actions() if "Abrir" in action.text() + ) + open_action.trigger() + qtbot.wait(0) + + assert ws.active_perspective == "image" + status = ws._status._message.text() + assert real_gwy_path.name in status + assert "imagen" in status + assert "3 canales" in status + + canvas = ws.panel("image_canvas") + analysis = ws.panel("image_analysis") + assert canvas is not None and analysis is not None + image_vm = canvas._vm + assert image_vm.data is not None + assert len(image_vm.data.channels) == 3 + assert image_vm.names == ["Z-Axis", "Z-Axis", "CPD"] + + selector = canvas._channel + assert selector.count() == 3 + forward_label = selector.itemText(0) + backward_label = selector.itemText(1) + assert forward_label != backward_label + assert "Z-Axis" in forward_label and "Z-Axis" in backward_label + + forward = image_vm.raw_channel_at(0) + backward = image_vm.raw_channel_at(1) + assert forward is not None and backward is not None + assert forward.direction == "forward" + assert backward.direction == "backward" + forward_raw = np.asarray(forward.data).copy() + backward_raw = np.asarray(backward.data).copy() + assert not np.array_equal(forward_raw, backward_raw) + + selector.setCurrentIndex(0) + assert image_vm.current_index == 0 + selector.setCurrentIndex(1) + assert image_vm.current_index == 1 + assert image_vm.raw_channel_at(1) is backward + np.testing.assert_array_equal(image_vm.raw_channel_at(0).data, forward_raw) + np.testing.assert_array_equal(image_vm.raw_channel_at(1).data, backward_raw) + + selector.setCurrentIndex(0) + profile = image_vm.profile((0.5, 0.5), (5.5, 3.5)) + assert profile is not None + assert image_vm.last_profile is profile + assert analysis._plot.listDataItems() + + profile_path = tmp_path / "profile.csv" + monkeypatch.setattr( + QFileDialog, + "getSaveFileName", + staticmethod(lambda *args, **kwargs: (str(profile_path), "")), + ) + profile_button = next( + button + for button in analysis.findChildren(QPushButton) + if "Exportar perfil" in button.text() + ) + qtbot.mouseClick(profile_button, Qt.MouseButton.LeftButton) + assert profile_path.is_file() + with profile_path.open(newline="", encoding="utf-8") as stream: + reader = csv.DictReader(stream) + rows = list(reader) + assert reader.fieldnames == ["distance[m]", "height[m]"] + assert len(rows) == len(profile) + np.testing.assert_allclose( + [float(row["distance[m]"]) for row in rows], profile.distance + ) + np.testing.assert_allclose([float(row["height[m]"]) for row in rows], profile.height) + + selector.setCurrentIndex(2) + assert image_vm.current_index == 2 + assert image_vm.channel == "CPD" + assert analysis._wf.isVisible() + analysis._wf.setValue(4.5) + assert image_vm.tip_work_function == 4.5 + readout = analysis._readout.text() + assert "KPFM (CPD)" in readout + assert "Φ muestra" in readout + + figure_action = next( + action for action in ws._persp_bar.actions() if action.text() == "Figura" + ) + figure_action.trigger() + qtbot.wait(0) + assert ws.active_perspective == "figure" + + figure = ws.panel("figure_editor") + assert figure is not None + assert figure._vm.channel == "Z-Axis" + assert "Z-Axis" in figure._channel.currentText() + assert figure._cmap.currentText() == "gold" + assert figure._vm.spec.colormap == "gold" + + figure_path = tmp_path / "figure.png" + monkeypatch.setattr( + QFileDialog, + "getSaveFileName", + staticmethod(lambda *args, **kwargs: (str(figure_path), "")), + ) + figure_button = next( + button + for button in figure.findChildren(QPushButton) + if button.text() == "Exportar figura…" + ) + qtbot.mouseClick(figure_button, Qt.MouseButton.LeftButton) + figure_bytes = figure_path.read_bytes() + assert figure_bytes.startswith(b"\x89PNG\r\n\x1a\n") + assert len(figure_bytes) > 1_000 + finally: + ws.close() + ws.deleteLater() + QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete) + QCoreApplication.processEvents() + qtbot.wait(0) From 90856d3200d45f09e5eace98cafa8fcc15293756 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:56:30 -0400 Subject: [PATCH 11/20] test: estabiliza salida Rich en CI (cherry picked from commit 43456aed06e675cd6f2ab802da5bc324ea7fce81) --- tests/core/test_cli_image.py | 44 ++++++++++++++++++----------- tests/e2e/cli/test_image_journey.py | 27 +++++++++++------- 2 files changed, 44 insertions(+), 27 deletions(-) diff --git a/tests/core/test_cli_image.py b/tests/core/test_cli_image.py index 83825c2..d9c60ff 100644 --- a/tests/core/test_cli_image.py +++ b/tests/core/test_cli_image.py @@ -7,6 +7,7 @@ import numpy as np import pytest +from click import unstyle from typer.testing import CliRunner from spmkit.core.models import SPMChannel, SPMData @@ -16,6 +17,10 @@ runner = CliRunner() +def _compact_output(output: str) -> str: + return "".join(unstyle(output).replace("│", " ").split()) + + def _channel( direction: str = "forward", group: str = "Scan 1", @@ -59,9 +64,10 @@ def test_help_canales_incluye_selectores_y_gwy(command: str) -> None: result = runner.invoke(app, [command, "--help"]) assert result.exit_code == 0, result.output - assert "--direction" in result.output - assert "--group" in result.output - assert ".gwy" in result.output + output = _compact_output(result.output) + assert "--direction" in output + assert "--group" in output + assert ".gwy" in output def test_help_level_muestra_choices_y_analyze_muestra_selectores_cpd() -> None: @@ -69,9 +75,11 @@ def test_help_level_muestra_choices_y_analyze_muestra_selectores_cpd() -> None: analyze_help = runner.invoke(app, ["analyze", "--help"]) assert roughness_help.exit_code == 0, roughness_help.output - assert all(choice in roughness_help.output for choice in ("plane", "poly", "rows", "none")) - assert "--cpd-direction" in analyze_help.output - assert "--cpd-group" in analyze_help.output + roughness_output = _compact_output(roughness_help.output) + analyze_output = _compact_output(analyze_help.output) + assert all(choice in roughness_output for choice in ("plane", "poly", "rows", "none")) + assert "--cpd-direction" in analyze_output + assert "--cpd-group" in analyze_output def test_info_muestra_grupo(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: @@ -83,7 +91,7 @@ def test_info_muestra_grupo(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> assert result.exit_code == 0, result.output assert "Grupo" in result.output - assert "Scan 1" in result.output + assert "Scan1" in _compact_output(result.output) def test_level_invalido_se_rechaza_durante_parsing_con_choices(tmp_path: Path) -> None: @@ -93,8 +101,9 @@ def test_level_invalido_se_rechaza_durante_parsing_con_choices(tmp_path: Path) - result = runner.invoke(app, ["roughness", str(source), "--level", "invalid"]) assert result.exit_code == 2 - assert "invalid" in result.output - assert all(choice in result.output for choice in ("plane", "poly", "rows", "none")) + output = _compact_output(result.output) + assert "invalid" in output + assert all(choice in output for choice in ("plane", "poly", "rows", "none")) def test_roughness_rows_usa_mediana(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: @@ -138,10 +147,10 @@ def test_roughness_ambigua_es_bad_parameter_accionable( result, selected = _invoke_roughness(monkeypatch, tmp_path, data) assert result.exit_code == 2 - normalized_output = " ".join(result.output.replace("│", " ").split()) + normalized_output = _compact_output(result.output) assert "ambigua" in normalized_output - assert "Scan 1" in normalized_output - assert "Scan 2" in normalized_output + assert "Scan1" in normalized_output + assert "Scan2" in normalized_output assert "--direction" in normalized_output assert "--group" in normalized_output assert selected == [] @@ -210,8 +219,9 @@ def test_analyze_rechaza_cpd_ambiguo_si_el_nombre_existe( ) assert result.exit_code == 2 - assert "ambigua" in result.output - assert "Scan 1" in result.output - assert "Scan 2" in result.output - assert "--cpd-direction" in result.output - assert "--cpd-group" in result.output + output = _compact_output(result.output) + assert "ambigua" in output + assert "Scan1" in output + assert "Scan2" in output + assert "--cpd-direction" in output + assert "--cpd-group" in output diff --git a/tests/e2e/cli/test_image_journey.py b/tests/e2e/cli/test_image_journey.py index 496a455..bf3e5cf 100644 --- a/tests/e2e/cli/test_image_journey.py +++ b/tests/e2e/cli/test_image_journey.py @@ -6,6 +6,7 @@ from pathlib import Path import pytest +from click import unstyle from typer.testing import CliRunner from spmkit import load @@ -15,6 +16,10 @@ runner = CliRunner() +def _compact_output(output: str) -> str: + return "".join(unstyle(output).replace("│", " ").split()) + + def _csv_scalars(path: Path) -> dict[str, str]: with path.open(newline="", encoding="utf-8") as stream: return {row["key"]: row["value"] for row in csv.DictReader(stream)} @@ -23,11 +28,12 @@ def _csv_scalars(path: Path) -> dict[str, str]: def test_real_gwy_cli_info_selection_and_analysis(real_gwy_path: Path, tmp_path: Path) -> None: info_result = runner.invoke(app, ["info", str(real_gwy_path)], terminal_width=200) assert info_result.exit_code == 0, info_result.output - assert "formato gwy" in info_result.output - assert "Grupo" in info_result.output - assert "Z-Axis forward" in info_result.output - assert "Z-Axis backward" in info_result.output - assert "CPD forward" in info_result.output + info_output = _compact_output(info_result.output) + assert "formatogwy" in info_output + assert "Grupo" in info_output + assert "Z-Axisforward" in info_output + assert "Z-Axisbackward" in info_output + assert "CPDforward" in info_output roughness_result = runner.invoke( app, @@ -37,7 +43,7 @@ def test_real_gwy_cli_info_selection_and_analysis(real_gwy_path: Path, tmp_path: ambiguous_result = runner.invoke(app, ["roughness", str(real_gwy_path)]) assert ambiguous_result.exit_code == 2 - normalized_error = " ".join(ambiguous_result.output.replace("│", " ").split()) + normalized_error = _compact_output(ambiguous_result.output) assert "ambigua" in normalized_error.casefold() assert "--direction/--group" in normalized_error @@ -84,9 +90,10 @@ def test_real_gwy_cli_info_selection_and_analysis(real_gwy_path: Path, tmp_path: def test_real_gwy_cli_profile_and_default_figure(real_gwy_path: Path, tmp_path: Path) -> None: profile_help = runner.invoke(app, ["profile", "--help"]) assert profile_help.exit_code == 0, profile_help.output - assert "coordenadas de píxel" in profile_help.output - assert "--x1" in profile_help.output and "required" in profile_help.output - assert "--y1" in profile_help.output and "required" in profile_help.output + profile_output = _compact_output(profile_help.output) + assert "coordenadasdepíxel" in profile_output + assert "--x1" in profile_output and "required" in profile_output + assert "--y1" in profile_output and "required" in profile_output profile_path = tmp_path / "profile.csv" profile_result = runner.invoke( @@ -135,7 +142,7 @@ def test_real_gwy_cli_profile_and_default_figure(real_gwy_path: Path, tmp_path: ], ) assert invalid_profile.exit_code == 2 - assert "fuera de los límites" in invalid_profile.output + assert "fueradeloslímites" in _compact_output(invalid_profile.output) figure_help = runner.invoke(app, ["figure", "--help"]) assert figure_help.exit_code == 0, figure_help.output From 67d8cb6f636117d93b4ea0f659d5bf7c1ac4c3c2 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:44:57 -0400 Subject: [PATCH 12/20] build: excluye GUI legacy de artefactos (cherry picked from commit df91a701b0c5e2b64e59bfff884d00d4a655dddb) --- pyproject.toml | 2 ++ src/spmkit/cli/app.py | 8 +------- tests/core/test_cli_gui.py | 12 ++++++++++++ 3 files changed, 15 insertions(+), 7 deletions(-) create mode 100644 tests/core/test_cli_gui.py diff --git a/pyproject.toml b/pyproject.toml index 416a7da..138fc22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,12 +69,14 @@ spmkit = "spmkit.cli.app:app" [tool.hatch.build.targets.wheel] packages = ["src/spmkit"] +exclude = ["/src/spmkit/gui/legacy"] [tool.hatch.build.targets.sdist] # El sdist no necesita las imágenes de docs (banners/capturas ~4 MB); mantiene el texto. exclude = [ "docs/images/", "/.github", + "/src/spmkit/gui/legacy", "/reference", "/ui_preview", "/site", diff --git a/src/spmkit/cli/app.py b/src/spmkit/cli/app.py index 059f35b..8c655d7 100644 --- a/src/spmkit/cli/app.py +++ b/src/spmkit/cli/app.py @@ -501,15 +501,9 @@ def verify( @app.command() def gui( file: Path | None = typer.Argument(None, help="Archivo a abrir al arrancar (solo Fathom)"), - legacy: bool = typer.Option(False, "--legacy", help="Lanza la app clásica de 7 pestañas"), ) -> None: - """Lanza la GUI: **Fathom** por defecto, o la clásica con ``--legacy`` (requiere 'gui').""" + """Lanza la GUI Fathom (requiere 'gui').""" try: - if legacy: - from spmkit.gui.legacy import run as run_legacy - - run_legacy() - return from spmkit.gui.app import run except ImportError: console.print("[red]La GUI requiere PyQt6. Instala con:[/] pip install 'spmkit[gui]'") diff --git a/tests/core/test_cli_gui.py b/tests/core/test_cli_gui.py new file mode 100644 index 0000000..0568956 --- /dev/null +++ b/tests/core/test_cli_gui.py @@ -0,0 +1,12 @@ +from typer.testing import CliRunner + +from spmkit.cli.app import app + +runner = CliRunner() + + +def test_gui_help_no_anuncia_legacy() -> None: + result = runner.invoke(app, ["gui", "--help"]) + + assert result.exit_code == 0, result.output + assert "--legacy" not in result.output From 28763b2677da1cf24dc1f693f908f5cae0b60f76 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:49:30 -0400 Subject: [PATCH 13/20] chore: unblock full mypy check (cherry picked from commit 43c3e2854520b3bb72cfb4ceade2b88708f47651) --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 138fc22..eefbd24 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,7 @@ dev = [ "pytest-cov>=5.0", "ruff>=0.6", "mypy>=1.10", + "types-PyYAML>=6.0", "black>=24.0", "pre-commit>=3.7", ] @@ -112,6 +113,7 @@ target-version = ["py311"] # sigue siendo compatible con 3.11+ (lo garantiza ruff target-version py311). python_version = "3.12" packages = ["spmkit"] +exclude = ["^src/spmkit/gui/legacy/"] ignore_missing_imports = true disallow_untyped_defs = true warn_unused_ignores = true From e982130f8e0f7a323cd8572410f3dce6f2162016 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:44:39 -0400 Subject: [PATCH 14/20] Revert "build: excluye GUI legacy de artefactos" This reverts commit 67d8cb6f636117d93b4ea0f659d5bf7c1ac4c3c2. --- pyproject.toml | 2 -- src/spmkit/cli/app.py | 8 +++++++- tests/core/test_cli_gui.py | 12 ------------ 3 files changed, 7 insertions(+), 15 deletions(-) delete mode 100644 tests/core/test_cli_gui.py diff --git a/pyproject.toml b/pyproject.toml index eefbd24..c1bfa71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,14 +70,12 @@ spmkit = "spmkit.cli.app:app" [tool.hatch.build.targets.wheel] packages = ["src/spmkit"] -exclude = ["/src/spmkit/gui/legacy"] [tool.hatch.build.targets.sdist] # El sdist no necesita las imágenes de docs (banners/capturas ~4 MB); mantiene el texto. exclude = [ "docs/images/", "/.github", - "/src/spmkit/gui/legacy", "/reference", "/ui_preview", "/site", diff --git a/src/spmkit/cli/app.py b/src/spmkit/cli/app.py index 8c655d7..059f35b 100644 --- a/src/spmkit/cli/app.py +++ b/src/spmkit/cli/app.py @@ -501,9 +501,15 @@ def verify( @app.command() def gui( file: Path | None = typer.Argument(None, help="Archivo a abrir al arrancar (solo Fathom)"), + legacy: bool = typer.Option(False, "--legacy", help="Lanza la app clásica de 7 pestañas"), ) -> None: - """Lanza la GUI Fathom (requiere 'gui').""" + """Lanza la GUI: **Fathom** por defecto, o la clásica con ``--legacy`` (requiere 'gui').""" try: + if legacy: + from spmkit.gui.legacy import run as run_legacy + + run_legacy() + return from spmkit.gui.app import run except ImportError: console.print("[red]La GUI requiere PyQt6. Instala con:[/] pip install 'spmkit[gui]'") diff --git a/tests/core/test_cli_gui.py b/tests/core/test_cli_gui.py deleted file mode 100644 index 0568956..0000000 --- a/tests/core/test_cli_gui.py +++ /dev/null @@ -1,12 +0,0 @@ -from typer.testing import CliRunner - -from spmkit.cli.app import app - -runner = CliRunner() - - -def test_gui_help_no_anuncia_legacy() -> None: - result = runner.invoke(app, ["gui", "--help"]) - - assert result.exit_code == 0, result.output - assert "--legacy" not in result.output From 9199a36c805040031ca993f0c6bd53f6384f0a13 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:44:39 -0400 Subject: [PATCH 15/20] Revert "chore: unblock full mypy check" This reverts commit 28763b2677da1cf24dc1f693f908f5cae0b60f76. --- pyproject.toml | 2 -- 1 file changed, 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c1bfa71..416a7da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,6 @@ dev = [ "pytest-cov>=5.0", "ruff>=0.6", "mypy>=1.10", - "types-PyYAML>=6.0", "black>=24.0", "pre-commit>=3.7", ] @@ -111,7 +110,6 @@ target-version = ["py311"] # sigue siendo compatible con 3.11+ (lo garantiza ruff target-version py311). python_version = "3.12" packages = ["spmkit"] -exclude = ["^src/spmkit/gui/legacy/"] ignore_missing_imports = true disallow_untyped_defs = true warn_unused_ignores = true From 9e993a4aead4481432b9a3693e15e49478db9b8a Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:45:15 -0400 Subject: [PATCH 16/20] chore(audit): remove codex internal .superpowers report pulled in by cherry-picks --- .superpowers/sdd/slice-b-task-4-report.md | 48 ----------------------- 1 file changed, 48 deletions(-) delete mode 100644 .superpowers/sdd/slice-b-task-4-report.md diff --git a/.superpowers/sdd/slice-b-task-4-report.md b/.superpowers/sdd/slice-b-task-4-report.md deleted file mode 100644 index 082cc54..0000000 --- a/.superpowers/sdd/slice-b-task-4-report.md +++ /dev/null @@ -1,48 +0,0 @@ -# Slice B Task 4 — reporte - -## Resultado - -- Se añadió `profile` al CLI monolítico usando `_select_channel`, `_apply_level`, - `profiles.line` y `to_csv`. -- `--x1` y `--y1` son opciones requeridas; toda coordenada se documenta en píxeles. -- Los errores de extracción de perfil se traducen a `typer.BadParameter`. -- El colormap por defecto de `figure` cambió de `batlow` a `gold`. -- Se añadieron journeys de librería y CLI sobre un `.gwy` real, temporal y reproducible. - -## TDD - -- RED: el journey focal ejecutó dos pruebas y falló en la tercera con - `No such command 'profile'`. -- GREEN: el mismo comando terminó con `3 passed` tras el adaptador mínimo. - -## Verificación - -- `pytest tests/e2e/library/test_image_journey.py tests/e2e/cli/test_image_journey.py - -q --no-cov`: 3 passed. -- `ruff check src/spmkit/cli/app.py tests/e2e`: limpio. -- `mypy src/spmkit/cli/app.py`: limpio. -- `pytest tests/core/test_cli_image.py -q --no-cov`: 13 passed. -- QA manual: ayuda visible, CSV `distance[m],height[m]` generado desde un `.gwy` real y - coordenada fuera de rango rechazada con código 2. - -## Auto-revisión y evidencia de depuración - -- Hipótesis: la selección ambigua podía elegir un canal arbitrario. Evidencia: el journey real - devuelve código 2 y exige `--direction/--group`. -- Hipótesis: un punto inválido podía filtrar un traceback. Evidencia: la QA devuelve código 2 y - el mensaje `Punto fuera de los límites de la imagen`. -- Hipótesis: el export podía perder unidades o valores. Evidencia: CSV/JSON reabiertos coinciden - con resultados en memoria y el PNG conserva firma válida. - -No se encontraron concerns dentro del alcance. Dos gates globales ajenos al cambio quedaron -registrados: `make check` se detiene por el stub `types-PyYAML` ausente en el venv, y la suite GUI -completa produce un segfault preexistente en `tests/gui/test_map_vm.py:76`, también con Qt -offscreen. Los gates focales solicitados están verdes. - -## Corrección de revisión - -Se detectó que `tests/e2e/conftest.py` cambiaba globalmente el modo de importación de Pytest para -evitar la colisión entre los dos módulos `test_image_journey.py`. Se eliminó por completo ese hook -y se dieron identidades de paquete explícitas a `tests/e2e/library` y `tests/e2e/cli` mediante sus -respectivos `__init__.py`. Los dos journeys pasan juntos y la colección combinada de `tests/core` -y `tests/e2e` termina correctamente sin alterar la configuración global de Pytest. From 8cd46a22584dec108ee145fca6097243e733adda Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:54:18 -0400 Subject: [PATCH 17/20] fix(analysis): validate profile line boundaries (extracted from codex 820df23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selección parcial de 820df23 'Fortalece fronteras cientificas de imagen': solo la validación de límites de profiles.line (ndim 2D, is_spatial, coordenadas finitas dentro de la imagen, n >= 1). Necesaria para que el CLI profile recién integrado falle con error claro en vez de recortar silenciosamente. La parte de leveling/kpfm/roughness del commit original NO se integra (superseded por la implementación Gwyddion parity de main). Origen: commit 820df239137ae1fc3425e5b42aed7960ef356809 (codex/slice-b-image-complete) --- src/spmkit/core/analysis/profiles.py | 13 +++++++++ tests/core/test_profiles.py | 43 ++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/src/spmkit/core/analysis/profiles.py b/src/spmkit/core/analysis/profiles.py index 2ed7628..c46358f 100644 --- a/src/spmkit/core/analysis/profiles.py +++ b/src/spmkit/core/analysis/profiles.py @@ -58,10 +58,23 @@ def line( Un :class:`Profile` con distancia física acumulada y altura. """ z = np.asarray(channel.data, dtype=np.float64) + if z.ndim != 2 or not channel.is_spatial: + raise ValueError("El perfil requiere un canal de imagen espacial 2D") (x0, y0), (x1, y1) = p0, p1 + rows_count, cols_count = z.shape + endpoints = (x0, y0, x1, y1) + if not all(np.isfinite(value) for value in endpoints) or not ( + 0 <= x0 <= cols_count - 1 + and 0 <= x1 <= cols_count - 1 + and 0 <= y0 <= rows_count - 1 + and 0 <= y1 <= rows_count - 1 + ): + raise ValueError("Punto fuera de los límites de la imagen") seg_px = float(np.hypot(x1 - x0, y1 - y0)) if n is None: n = max(2, int(round(seg_px)) + 1) + elif n < 1: + raise ValueError("n debe ser >= 1") cols = np.linspace(x0, x1, n) rows = np.linspace(y0, y1, n) diff --git a/tests/core/test_profiles.py b/tests/core/test_profiles.py index 027bde5..c19c2dd 100644 --- a/tests/core/test_profiles.py +++ b/tests/core/test_profiles.py @@ -3,6 +3,7 @@ from __future__ import annotations import numpy as np +import pytest from spmkit.core.analysis import profiles from spmkit.core.models import SPMChannel @@ -29,3 +30,45 @@ def test_bilinear_midpoint() -> None: ch = SPMChannel(name="Z", data=data, unit="m", x_range=1e-6, y_range=1e-6) prof = profiles.line(ch, (0.5, 0), (0.5, 0), n=1) assert prof.height[0] == 1.0 + + +def test_diagonal_profile_uses_anisotropic_physical_ranges() -> None: + ch = SPMChannel(name="Z", data=np.zeros((4, 4)), unit="m", x_range=2e-6, y_range=6e-6) + + prof = profiles.line(ch, (0, 0), (3, 3), n=4) + + expected = np.hypot(3 * ch.pixel_size_x, 3 * ch.pixel_size_y) + assert prof.distance[-1] == pytest.approx(expected) + + +def test_profile_rejects_non_spatial_channel() -> None: + ch = SPMChannel(name="Spectrum", data=np.zeros((1, 4)), unit="V", x_range=1.0, y_range=1.0) + + with pytest.raises(ValueError, match="espacial"): + profiles.line(ch, (0, 0), (3, 0)) + + +@pytest.mark.parametrize("n", [0, -1]) +def test_profile_rejects_sample_count_below_one(n: int) -> None: + ch = SPMChannel(name="Z", data=np.zeros((2, 2)), unit="m", x_range=1e-6, y_range=1e-6) + + with pytest.raises(ValueError, match="n debe ser >= 1"): + profiles.line(ch, (0, 0), (1, 1), n=n) + + +@pytest.mark.parametrize( + ("p0", "p1"), + [ + ((-0.1, 0), (1, 1)), + ((0, -0.1), (1, 1)), + ((0, 0), (2, 1)), + ((0, 0), (1, 2)), + ], +) +def test_profile_rejects_endpoints_outside_image( + p0: tuple[float, float], p1: tuple[float, float] +) -> None: + ch = SPMChannel(name="Z", data=np.zeros((2, 2)), unit="m", x_range=1e-6, y_range=1e-6) + + with pytest.raises(ValueError, match="fuera"): + profiles.line(ch, p0, p1, n=2) From 607f425bc1d3b080a7fa1cc4c80e4db155835295 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:34:54 -0400 Subject: [PATCH 18/20] style: apply Black formatting to recovered branch files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Los cherry-picks de codex/slice-b-image-complete trajeron 4 archivos sin el formato Black del repo (line-length 100). Corrige el check 'Black (format check)' del job lint del PR #80 sin cambiar lógica. Archivos: project.py (SHA-256 spmproj), test_project.py, test_image_journey.py (cli y gui). --- src/spmkit/core/project.py | 4 +--- tests/core/test_project.py | 4 +--- tests/e2e/cli/test_image_journey.py | 8 ++------ tests/e2e/gui/test_image_journey.py | 12 +++--------- 4 files changed, 7 insertions(+), 21 deletions(-) diff --git a/src/spmkit/core/project.py b/src/spmkit/core/project.py index d184ba9..4e171e9 100644 --- a/src/spmkit/core/project.py +++ b/src/spmkit/core/project.py @@ -51,9 +51,7 @@ def to_dict(self) -> dict[str, Any]: return { "version": self.version, "perspective": self.perspective, - "files": [ - {"path": f.path, "kind": f.kind, "sha256": f.sha256} for f in self.files - ], + "files": [{"path": f.path, "kind": f.kind, "sha256": f.sha256} for f in self.files], "params": self.params, } diff --git a/tests/core/test_project.py b/tests/core/test_project.py index 2551e2b..1fe8e32 100644 --- a/tests/core/test_project.py +++ b/tests/core/test_project.py @@ -21,9 +21,7 @@ def test_roundtrip_con_hash_sha256(tmp_path) -> None: # type: ignore[no-untyped path = save_project(state, tmp_path / "sesion.spmproj") raw = json.loads(path.read_text(encoding="utf-8")) - assert raw["files"] == [ - {"path": str(archivo), "kind": "force", "sha256": esperado} - ] + assert raw["files"] == [{"path": str(archivo), "kind": "force", "sha256": esperado}] loaded = load_project(path) assert loaded.files == [OpenFile(str(archivo), "force", esperado)] assert loaded.perspective == "map" diff --git a/tests/e2e/cli/test_image_journey.py b/tests/e2e/cli/test_image_journey.py index bf3e5cf..fc43c47 100644 --- a/tests/e2e/cli/test_image_journey.py +++ b/tests/e2e/cli/test_image_journey.py @@ -69,14 +69,10 @@ def test_real_gwy_cli_info_selection_and_analysis(real_gwy_path: Path, tmp_path: expected_roughness = roughness.statistics( leveling.plane_fit(data.select("Z-Axis", direction="forward")) ) - expected_kpfm = kpfm.statistics( - data.select("CPD", direction="forward"), tip_work_function=4.7 - ) + expected_kpfm = kpfm.statistics(data.select("CPD", direction="forward"), tip_work_function=4.7) stem = real_gwy_path.stem roughness_csv = _csv_scalars(output_dir / f"{stem}_roughness.csv") - roughness_json = json.loads( - (output_dir / f"{stem}_roughness.json").read_text(encoding="utf-8") - ) + roughness_json = json.loads((output_dir / f"{stem}_roughness.json").read_text(encoding="utf-8")) kpfm_csv = _csv_scalars(output_dir / f"{stem}_kpfm.csv") kpfm_json = json.loads((output_dir / f"{stem}_kpfm.json").read_text(encoding="utf-8")) assert roughness_csv["unit"] == roughness_json["unit"] == expected_roughness.unit diff --git a/tests/e2e/gui/test_image_journey.py b/tests/e2e/gui/test_image_journey.py index 1c8ef8c..1f9f85b 100644 --- a/tests/e2e/gui/test_image_journey.py +++ b/tests/e2e/gui/test_image_journey.py @@ -25,9 +25,7 @@ def test_corrupt_gwy_via_open_action_keeps_gui_alive( qtbot.wait(0) try: - open_action = next( - action for action in ws._persp_bar.actions() if "Abrir" in action.text() - ) + open_action = next(action for action in ws._persp_bar.actions() if "Abrir" in action.text()) open_action.trigger() qtbot.wait(0) @@ -61,9 +59,7 @@ def test_real_gwy_gui_image_journey( qtbot.wait(0) try: - open_action = next( - action for action in ws._persp_bar.actions() if "Abrir" in action.text() - ) + open_action = next(action for action in ws._persp_bar.actions() if "Abrir" in action.text()) open_action.trigger() qtbot.wait(0) @@ -129,9 +125,7 @@ def test_real_gwy_gui_image_journey( rows = list(reader) assert reader.fieldnames == ["distance[m]", "height[m]"] assert len(rows) == len(profile) - np.testing.assert_allclose( - [float(row["distance[m]"]) for row in rows], profile.distance - ) + np.testing.assert_allclose([float(row["distance[m]"]) for row in rows], profile.distance) np.testing.assert_allclose([float(row["height[m]"]) for row in rows], profile.height) selector.setCurrentIndex(2) From 06bbd8220ce3f1a7d7b9b69fe538db56958d8872 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:34:59 -0400 Subject: [PATCH 19/20] test: guard e2e GUI journey from collection without PyQt6 tests/e2e/gui/test_image_journey.py importa PyQt6 a nivel de modulo y el job 'test' de CI instala el paquete sin el extra 'gui', provocando un error de coleccion que hacia fallar CI/test (3.11 y 3.12). Mismo patron que tests/gui/conftest.py: con collect_ignore_glob se omite la carpeta cuando no hay PyQt6+pytest-qt; el job 'gui' dedicado (que ya corre tests/e2e/gui/test_*.py via run_gui_tests.sh) los ejecuta normalmente. --- tests/e2e/gui/conftest.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 tests/e2e/gui/conftest.py diff --git a/tests/e2e/gui/conftest.py b/tests/e2e/gui/conftest.py new file mode 100644 index 0000000..7d21129 --- /dev/null +++ b/tests/e2e/gui/conftest.py @@ -0,0 +1,19 @@ +"""Fixtures compartidas de los tests E2E de GUI (journey de imagen). + +Mismo patrón que ``tests/gui/conftest.py``: si el entorno no tiene el stack de GUI +(PyQt6 + pytest-qt), se omite esta carpeta en la colección en vez de fallar al +importar. Con el extra ``gui`` instalado (job de CI dedicado), corren normalmente. +""" + +from __future__ import annotations + +try: + import PyQt6 # noqa: F401 + import pytestqt # noqa: F401 + + _HAS_GUI = True +except ImportError: # pragma: no cover - CI sin extra gui + _HAS_GUI = False + +#: Sin el stack de GUI, pytest ignora los ``test_*.py`` de esta carpeta. +collect_ignore_glob = [] if _HAS_GUI else ["test_*.py"] From 2e6b91b93d3f73dc70d3517b0cc5e42583177597 Mon Sep 17 00:00:00 2001 From: kegouro <141108917+kegouro@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:35:05 -0400 Subject: [PATCH 20/20] docs: sync CLI command count and index for the new profile command El PR anade el comando CLI 'profile' (recuperado de codex/slice-b), por lo que el source declara 20 comandos y no 19. check_docs_sync.py esperaba el conteo anterior y la tabla C del user-guide no documentaba 'profile', lo que hacia fallar el check 'Deploy docs / verify'. - scripts/check_docs_sync.py: expectativa 19 -> 20. - docs/user-guide.md: fila 'profile' en el CLI command index. - docs/manual/artifacts-manifest.json: regenerado con scripts/update_manual_provenance.py --write (user-guide.md cambio de hash). --- docs/manual/artifacts-manifest.json | 4 ++-- docs/user-guide.md | 1 + scripts/check_docs_sync.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/manual/artifacts-manifest.json b/docs/manual/artifacts-manifest.json index 96ae5e7..fdddca9 100644 --- a/docs/manual/artifacts-manifest.json +++ b/docs/manual/artifacts-manifest.json @@ -1,9 +1,9 @@ { "artifacts": [ { - "bytes": 56482, + "bytes": 56542, "path": "docs/user-guide.md", - "sha256": "2af676b1a962202ec7c5424a95d7477a6b99621c88ad433720cff46b2e117a43" + "sha256": "ecc0da824cd3886aa59ec6322f3b27ad15168bd5203fe4b191ce108e8fa2fb3e" }, { "bytes": 39958, diff --git a/docs/user-guide.md b/docs/user-guide.md index 282424d..f1ff0e8 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -1488,6 +1488,7 @@ spmkit gui --legacy |---------|----------|-------| | `info` | Inspection | `.nid`, `.nhf` | | `roughness` | Image analysis | `.nid`, `.nhf`, `.gwy` | +| `profile` | Profile extraction | `.nid`, `.nhf`, `.gwy` | | `psd` | Spectral | `.nid`, `.nhf`, `.gwy` | | `analyze` | Pipeline | `.nid`, `.nhf`, `.gwy` | | `nanomech` | Force | `.nid` (spectroscopy) | diff --git a/scripts/check_docs_sync.py b/scripts/check_docs_sync.py index 38f1fcf..c8fedaf 100644 --- a/scripts/check_docs_sync.py +++ b/scripts/check_docs_sync.py @@ -243,7 +243,7 @@ def main() -> int: ) commands = cli_commands() - checks.require(len(commands) == 19, "source declares 19 CLI commands") + checks.require(len(commands) == 20, "source declares 20 CLI commands") markdown_manual = text(DOCS / "user-guide.md") checks.require( all(f"`{command}`" in markdown_manual for command in commands),