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), 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/src/spmkit/cli/app.py b/src/spmkit/cli/app.py index e823af2..059f35b 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 @@ -13,8 +14,9 @@ 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 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})") @@ -89,14 +120,43 @@ 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 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 +173,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 +191,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,17 +402,19 @@ 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"), + 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).""" 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 +530,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/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/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/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/src/spmkit/core/project.py b/src/spmkit/core/project.py index 941ce06..4e171e9 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,7 @@ 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 +67,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/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 65bfcec..abb62ef 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) @@ -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")) diff --git a/tests/core/test_cli_image.py b/tests/core/test_cli_image.py new file mode 100644 index 0000000..d9c60ff --- /dev/null +++ b/tests/core/test_cli_image.py @@ -0,0 +1,227 @@ +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 click import unstyle +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 _compact_output(output: str) -> str: + return "".join(unstyle(output).replace("│", " ").split()) + + +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 + 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: + roughness_help = runner.invoke(app, ["roughness", "--help"]) + analyze_help = runner.invoke(app, ["analyze", "--help"]) + + assert roughness_help.exit_code == 0, roughness_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: + 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 "Scan1" in _compact_output(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 + 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: + 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 = _compact_output(result.output) + assert "ambigua" 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 == [] + + +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 + 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/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) 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] 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) diff --git a/tests/core/test_project.py b/tests/core/test_project.py index f1a9f23..1fe8e32 100644 --- a/tests/core/test_project.py +++ b/tests/core/test_project.py @@ -2,11 +2,31 @@ 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/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 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] 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/cli/test_image_journey.py b/tests/e2e/cli/test_image_journey.py new file mode 100644 index 0000000..fc43c47 --- /dev/null +++ b/tests/e2e/cli/test_image_journey.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +import csv +import json +import math +from pathlib import Path + +import pytest +from click import unstyle +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 _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)} + + +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 + 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, + ["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 = _compact_output(ambiguous_result.output) + 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 + 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( + 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 "fueradeloslímites" in _compact_output(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..74aa03c --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,56 @@ +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 + + +@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/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"] diff --git a/tests/e2e/gui/test_image_journey.py b/tests/e2e/gui/test_image_journey.py new file mode 100644 index 0000000..1f9f85b --- /dev/null +++ b/tests/e2e/gui/test_image_journey.py @@ -0,0 +1,175 @@ +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) 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.""" 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 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) 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: