From cb74d201c4a6f02eadc0f7991bca342d334db23b Mon Sep 17 00:00:00 2001 From: Marc Fehr Date: Thu, 15 Oct 2026 23:00:04 +0200 Subject: [PATCH 1/3] test: cover the notebook front end, plots and fields of the explorer The CI installed neither ipywidgets nor matplotlib, so notebook.py and plots.py could not be imported in the tests and fields.py was barely reached; the test job now installs the notebook extras. - fields: planar slices only, frames equal to Slice.to_global (one mesh, three meshes, cell-centred), a cell-centred slice on a mesh border with two sides, value ranges from the .bnd files and from the frames. - plots: series with one and two units, device activation, slices in all render modes. - notebook: loading a case, the time bar, render and scale modes, switching slices, a case with nothing to show, the directory browser and its errors. The overview of a loaded simulation is now built after its slices are read, so it counts them instead of showing the previous simulation's count. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 +- fdsreader/explorer/notebook.py | 2 +- tests/explorer/test_fields.py | 87 +++++++++++++++++ tests/explorer/test_notebook.py | 160 ++++++++++++++++++++++++++++++++ tests/explorer/test_plots.py | 82 ++++++++++++++++ 5 files changed, 331 insertions(+), 2 deletions(-) create mode 100644 tests/explorer/test_fields.py create mode 100644 tests/explorer/test_notebook.py create mode 100644 tests/explorer/test_plots.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 85cd03fa..ec6988d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,7 +69,7 @@ jobs: python-version: ${{ matrix.python-version }} cache: pip - name: Install dependencies - run: pip install -e ".[dev,export]" + run: pip install -e ".[dev,export,notebook]" - name: Prepare test cases run: cd tests/cases && for f in *.tgz; do tar -xzvf "$f"; done - name: Run tests diff --git a/fdsreader/explorer/notebook.py b/fdsreader/explorer/notebook.py index 88dfc7b3..28a6f50f 100644 --- a/fdsreader/explorer/notebook.py +++ b/fdsreader/explorer/notebook.py @@ -667,8 +667,8 @@ def _on_simulation_loaded(self, sim): self._setup_fields(sim) return - self.summary.value = self._overview() self._setup_fields(sim) # also positions the time bar + self.summary.value = self._overview() # after the fields, so it can count the slices self._refresh_curves() def _overview(self): diff --git a/tests/explorer/test_fields.py b/tests/explorer/test_fields.py new file mode 100644 index 00000000..e60c42de --- /dev/null +++ b/tests/explorer/test_fields.py @@ -0,0 +1,87 @@ +"""Slice fields read one time step at a time, checked against the eager path of fdsreader. + +Uses the stored cases steckler_data (one node-based slice in one mesh) and slcf_masks_fds611 (three meshes, node-based +and cell-centred slices, 3D slices, and a cell-centred slice on a mesh border). +""" + +import shutil + +import numpy as np +import pytest +from fds_cases import CASES_DIR, load + +from fdsreader.explorer.fields import SliceField, _bnd_range, _dedup, fields_of + + +@pytest.fixture(scope="module") +def steckler(): + return load(CASES_DIR / "steckler_data") + + +@pytest.fixture(scope="module") +def masks(): + return load(CASES_DIR / "slcf_masks_fds611") + + +def test_only_planar_slices_are_fields(masks): + fields = fields_of(masks) + assert {f.source.id for f in fields} == {s.id for s in masks.slices if s.orientation != 0} + assert all(f.drawable for f in fields) + assert not SliceField(masks.slices.get_by_id("vol")).drawable # a 3D slice has no plane to draw + + +@pytest.mark.parametrize("case, slice_id", [("steckler", ""), ("masks", "z04"), ("masks", "z04_cc"), ("masks", "y08")]) +def test_a_frame_equals_the_eager_global_array(case, slice_id, request): + sim = request.getfixturevalue(case) + slc = sim.slices.get_by_id(slice_id) if slice_id else sim.slices[0] + field = SliceField(slc) + eager = slc.to_global() + for t in (0, len(field.times) - 1): + np.testing.assert_allclose(field.frame(t), eager[t], equal_nan=True) + + +def test_axes_and_coordinates(masks): + field = SliceField(masks.slices.get_by_id("y08")) + assert field.axes == ("x", "z") + assert field.shape == (21, 11) + np.testing.assert_allclose(field.coords["x"][[0, -1]], [0.0, 2.0]) + np.testing.assert_allclose(field.coords["y"], [0.8], atol=1e-6) + + +def test_a_cell_centred_slice_on_a_mesh_border_has_two_sides(masks): + # x = 0.5 is the upper x face of M3: FDS writes the cells on either side of it + slc = masks.slices.get_by_id("x05_cc") + meshes = [{sub.mesh.id for sub, *_ in SliceField(slc, side=side)._placement} for side in (0, 1, 5)] + assert meshes[0] != meshes[1] + assert meshes[2] == meshes[1] # a side beyond the last falls back to the last one + + +def test_value_range_from_the_bnd_files(steckler): + field = SliceField(steckler.slices[0]) + low, high, diverging = field.value_range() + assert (low, high) == pytest.approx(_bnd_range(steckler.slices[0])) + assert not diverging + assert field.cmap == "inferno" + + +def test_value_range_without_bnd_files_scans_the_frames(tmp_path): + case = shutil.copytree(CASES_DIR / "steckler_data", tmp_path / "steckler") + for bnd in case.glob("*.sf.bnd"): + bnd.unlink() + slc = load(case).slices[0] + assert _bnd_range(slc) is None + field = SliceField(slc) + frames = np.stack([field.frame(t) for t in range(len(field.times))]) + assert field.value_range()[:2] == pytest.approx((np.nanmin(frames), np.nanmax(frames))) + + +def test_dedup_drops_coordinates_written_with_limited_precision(): + np.testing.assert_array_equal(_dedup([0.2, 0.1, 0.1000001, 0.3]), [0.1, 0.2, 0.3]) + assert _dedup([]).size == 0 + + +def test_clear_cache_keeps_the_field_usable(steckler): + field = SliceField(steckler.slices[0]) + before = field.frame(1) + field.clear_cache() + np.testing.assert_array_equal(field.frame(1), before) diff --git a/tests/explorer/test_notebook.py b/tests/explorer/test_notebook.py new file mode 100644 index 00000000..7539a708 --- /dev/null +++ b/tests/explorer/test_notebook.py @@ -0,0 +1,160 @@ +"""The Jupyter front end, driven through its widgets without a browser or kernel. + +Plots go to static Output widgets (interactive_canvas=False) on the Agg backend. Uses the stored case steckler_data +(devices, an HRR file and one slice) and slcf_masks_fds611 (several slices, no devices). +""" + +import shutil + +import pytest +from fds_cases import CASES_DIR + +pytest.importorskip("ipywidgets") +plt = pytest.importorskip("matplotlib.pyplot") + +from fdsreader.explorer import notebook # noqa: E402 +from fdsreader.explorer.notebook import NotebookExplorer, SimulationBrowser, explore # noqa: E402 + +# plt.show() into an Output widget has nothing to show on the Agg backend +pytestmark = pytest.mark.filterwarnings("ignore:FigureCanvasAgg is non-interactive") + + +@pytest.fixture(autouse=True) +def _agg_and_close(): + plt.switch_backend("Agg") + yield + plt.close("all") + + +@pytest.fixture +def steckler(tmp_path): + # A copy, as the explorer may write or remove a pickle cache next to the data + return shutil.copytree(CASES_DIR / "steckler_data", tmp_path / "steckler") + + +@pytest.fixture +def explorer(steckler): + return NotebookExplorer(path=steckler, interactive_canvas=False) + + +def test_loading_fills_curves_slices_and_time_bar(explorer): + assert explorer.sim is not None + assert "Steckler" in explorer.summary.value and "1 slices" in explorer.summary.value + assert explorer.series_select.value == (0,) + assert len(explorer.selection) == 1 + assert [label for label, _ in explorer.slice_dropdown.options] == [explorer.field.label] + assert explorer.time_slider.max == len(explorer.field.times) - 1 + assert not explorer.time_slider.disabled + assert "step 1 of" in explorer.time_readout.value + + +def test_the_time_bar_moves_slice_and_curves(explorer): + explorer.time_slider.value = 3 + assert "step 4 of" in explorer.time_readout.value + assert explorer.current_time == pytest.approx(explorer.field.times[3]) + + +def test_render_modes_show_the_level_slider_for_contours(explorer): + explorer.render_mode.value = "filled" + assert explorer.level_slider.layout.display == "flex" + explorer.level_slider.value = 5 + explorer.render_mode.value = "lines" + explorer.render_mode.value = "image" + assert explorer.level_slider.layout.display == "none" + + +def test_manual_scale_starts_from_the_global_limits(explorer): + explorer.scale_mode.value = "manual" + assert explorer.scale_manual.layout.display == "flex" + low, high, _ = explorer.field.value_range() + assert (explorer.scale_min.value, explorer.scale_max.value) == (round(low, 4), round(high, 4)) + explorer.scale_max.value = explorer.scale_min.value - 1 # an inverted range is reported, not drawn as is + assert explorer.scale_note.value + explorer.scale_mode.value = "step" + assert explorer.scale_manual.layout.display == "none" + + +def test_colour_map_and_several_curves(explorer): + explorer.cmap_select.value = "viridis" + explorer.series_select.value = tuple(range(min(3, len(explorer.series)))) + assert len(explorer.selection) == min(3, len(explorer.series)) + explorer.series_select.value = () + assert explorer.selection == [] + + +def test_switching_slices(tmp_path): + case = shutil.copytree(CASES_DIR / "slcf_masks_fds611", tmp_path / "masks") + explorer = NotebookExplorer(path=case, interactive_canvas=False) + assert len(explorer.slice_dropdown.options) == 8 # the planar slices; the 3D ones are left out + explorer.slice_dropdown.value = 3 + assert explorer.field is explorer.fields[3] + assert explorer.time_slider.max == len(explorer.fields[3].times) - 1 + + +def test_a_simulation_with_nothing_to_show(simulation_with_nothing): + explorer = NotebookExplorer(path=simulation_with_nothing, interactive_canvas=False) + assert "no device" in explorer.summary.value + assert "no slice" in explorer.time_readout.value + assert explorer.field is None and explorer.current_time is None + assert explorer.slice_dropdown.disabled and explorer.series_select.disabled + + +def test_explore_returns_the_explorer(steckler): + assert isinstance(explore(steckler, interactive_canvas=False), NotebookExplorer) + + +def test_the_browser_finds_one_or_several_simulations(tmp_path, steckler): + browser = SimulationBrowser(start_dir=tmp_path) + assert browser.load_button.disabled and "No" in browser.status.value + assert [label for label, _ in browser.folders.options] == ["steckler/"] + + browser.folders.value = str(steckler) # selecting a folder opens it + assert browser.current == steckler.resolve() + assert not browser.load_button.disabled and browser.smv_picker.layout.display == "none" + + shutil.copy(steckler / "Steckler_010.smv", steckler / "copy.smv") + browser.go_to(steckler) + assert browser.smv_picker.layout.display == "flex" and "2 simulations" in browser.status.value + + +def test_the_browser_navigates_and_reports_problems(tmp_path, steckler): + loaded = [] + browser = SimulationBrowser(start_dir=steckler, on_load=loaded.append) + browser.path_box.value = ".." # relative to the folder on screen + assert browser.current == tmp_path.resolve() + browser.go_to(tmp_path / "missing") + assert "Not a directory" in browser.status.value + browser.go_to(steckler) + browser.load() + assert loaded == [browser.sim] and "Loaded" in browser.status.value + + browser.go_to(tmp_path) # the picker still points into the previous folder + browser.smv_picker.options = [("old", str(steckler / "Steckler_010.smv"))] + browser.load() + assert "No simulation selected" in browser.status.value + + +def test_the_browser_reports_a_simulation_it_cannot_read(tmp_path): + broken = tmp_path / "broken" + broken.mkdir() + (broken / "b.smv").write_text("GRID\n not a grid\n") + browser = SimulationBrowser(start_dir=broken) + browser.load() + assert browser.sim is None and "Could not load" in browser.status.value + + +def test_backend_selection_without_a_shell(): + assert notebook._select_backend(False) is False + assert notebook._select_backend(None) is False + with pytest.raises(RuntimeError): + notebook._select_backend(True) + + +def test_drawing_on_a_long_lived_figure(fake_field): + # The ipympl path redraws one figure in place instead of rendering into an Output widget + fig = plt.figure() + with notebook._figure_surface(fig, None, (4, 3)) as surface: + assert surface is fig + surface.add_subplot(111) + notebook._clear_surface(fig, None) + assert not fig.axes diff --git a/tests/explorer/test_plots.py b/tests/explorer/test_plots.py new file mode 100644 index 00000000..768e52da --- /dev/null +++ b/tests/explorer/test_plots.py @@ -0,0 +1,82 @@ +"""Matplotlib drawing of the explorer, on the Agg backend so no display is needed.""" + +import numpy as np +import pytest + +plt = pytest.importorskip("matplotlib.pyplot") + +import fdsreader # noqa: E402 +from fdsreader.explorer.data import list_devices # noqa: E402 +from fdsreader.explorer.plots import plot_device, plot_series, plot_slice # noqa: E402 + + +@pytest.fixture(autouse=True) +def _agg_and_close(): + plt.switch_backend("Agg") + yield + plt.close("all") + + +def test_nothing_selected(): + ax = plot_series([]) + assert ax.get_title() == "Nothing selected" + assert ax.get_xlabel() == "Time [s]" + + +def test_one_series_names_its_range(fake_series): + ax = plot_series(fake_series[:1], cursor_time=2.0) + assert ax.get_title().startswith("DEVC A") + assert "max 60" in ax.get_title() + assert len(ax.get_lines()) == 2 # the curve and the cursor + + +def test_several_series_get_a_legend(fake_series): + ax = plot_series(fake_series) + assert ax.get_title() == "2 series" + assert ax.get_legend() is not None + + +def test_a_second_unit_goes_on_a_second_axis(fake_series): + hrr = dict(fake_series[0], label="HRR [kW]", unit="kW", quantity="HRR", source="hrr") + ax = plot_series([fake_series[0], hrr]) + twins = [other for other in ax.figure.axes if other is not ax] + assert len(twins) == 1 and "kW" in twins[0].get_ylabel() + + +def test_a_device_shows_when_it_activated(tiny_case): + fdsreader.settings.ENABLE_CACHING = False + sim = fdsreader.Simulation(str(tiny_case)) + device = next(d for d in list_devices(sim) if d.id == "TC_2") + ax = plot_device(sim, device) + assert "position (2, 0, 2)" in ax.get_title() + assert any(text.get_text() == "activated" for text in ax.texts) + + +@pytest.mark.filterwarnings("ignore:genfromtxt") +def test_a_device_without_data_draws_no_data(devc_without_data): + fdsreader.settings.ENABLE_CACHING = False + sim = fdsreader.Simulation(str(devc_without_data)) + ax = plot_device(sim, list_devices(sim)[0]) + assert all(len(line.get_xdata()) == 0 for line in ax.get_lines()) + + +@pytest.mark.parametrize("render", ["image", "filled", "lines"]) +def test_a_slice_in_each_render_mode(fake_field, render): + ax = plot_slice(fake_field, 1, render=render, n_levels=6) + assert ax.get_xlabel() == "y [m]" and ax.get_ylabel() == "z [m]" + assert "t = 1.00 s" in ax.get_title() + colorbar = ax.figure.axes[-1] + assert colorbar.get_ylabel() == "TEMPERATURE [C]" + if render == "image": + assert ax.images[0].get_clim() == pytest.approx(fake_field.value_range()[:2]) + else: + assert ax.get_xlim() == pytest.approx((-2.0, 2.0)) + + +def test_a_slice_with_given_limits_and_frame(fake_field): + frame = np.zeros(fake_field.shape) + ax = plot_slice(fake_field, 0, vmin=0.0, vmax=1.0, cmap="viridis", frame=frame) + image = ax.images[0] + assert image.get_clim() == (0.0, 1.0) + assert image.get_cmap().name == "viridis" + assert not image.get_array().any() From 25c348fe47812612da0d83541370aa517a160917 Mon Sep 17 00:00:00 2001 From: Marc Fehr Date: Thu, 15 Oct 2026 23:05:51 +0200 Subject: [PATCH 2/3] test: cover the command line and the curses side of the explorer - cli: a slice drawn as text, with a curve, out of range and as JSON; saving pictures and animations, and the errors when that is not possible; handing over to the interactive view; running as python -m fdsreader.explorer; the lazy exports of the package. - tui: key names, clipped writing, colour pairs for 8 and 256 colours, the list picker, drawing the screen and the help, and the main loop, on a fake window that plays back keys. save_figure now closes the figure when writing an animation fails. Co-Authored-By: Claude Opus 5.5 --- fdsreader/explorer/cli.py | 3 +- tests/explorer/test_cli_slices.py | 160 +++++++++++++++++++++++++++ tests/explorer/test_tui_curses.py | 172 ++++++++++++++++++++++++++++++ 3 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 tests/explorer/test_cli_slices.py create mode 100644 tests/explorer/test_tui_curses.py diff --git a/fdsreader/explorer/cli.py b/fdsreader/explorer/cli.py index 5d062146..e8c47bd1 100644 --- a/fdsreader/explorer/cli.py +++ b/fdsreader/explorer/cli.py @@ -164,7 +164,8 @@ def draw(step): anim.save(str(path), writer=writer) except Exception as exc: raise SystemExit(f"{PROG}: could not write {path}: {exc}") - plt.close(fig) + finally: + plt.close(fig) return f"wrote {path} ({len(list(steps))} frames)" panels = (1 if field is None else 1) + (1 if series else 0) diff --git a/tests/explorer/test_cli_slices.py b/tests/explorer/test_cli_slices.py new file mode 100644 index 00000000..c8558f2c --- /dev/null +++ b/tests/explorer/test_cli_slices.py @@ -0,0 +1,160 @@ +"""The command line front end on a case with a slice: drawing it as text, saving pictures and animations, +handing over to the interactive view, and running as ``python -m fdsreader.explorer``. + +Uses a copy of the stored case steckler_data (devices, an HRR file and one slice). +""" + +import gc +import json +import runpy +import shutil +import sys + +import pytest +from fds_cases import CASES_DIR + +import fdsreader.explorer +from fdsreader.explorer import cli, tui +from fdsreader.explorer.cli import main + + +@pytest.fixture +def steckler(tmp_path): + return shutil.copytree(CASES_DIR / "steckler_data", tmp_path / "steckler") + + +def run(capsys, *argv): + """Run the CLI and return (exit status, stdout, stderr).""" + status = main([str(a) for a in argv]) + captured = capsys.readouterr() + return status, captured.out, captured.err + + +def test_overview_and_list_count_the_slice(steckler, capsys): + _, out, _ = run(capsys, steckler) + assert "slices (SLCF) 1" in out + _, out, _ = run(capsys, steckler, "--list") + assert "--slice 0 TEMPERATURE [C]" in out + + +def test_a_slice_drawn_as_text(steckler, capsys): + status, out, _ = run(capsys, steckler, "--slice", "0", "--time", "5", "--height", "10", "--width", "40") + assert status == 0 + title = out.splitlines()[0] + assert title.startswith("TEMPERATURE [C]") and "(step 6 of 101)" in title + assert len(out.splitlines()) > 10 + + +def test_render_slice_notes_limits_it_cannot_use(steckler): + # The command line rejects such limits already, so the note is reached through the function + fields = cli.fields_of(cli._load(steckler)) + state = cli.ExplorerState(scale_mode="manual", manual_limits=(5.0, 1.0)) + assert any( + line.startswith(" note: min must be below max") for line in cli.render_slice(fields, state, 8, 30, " .#") + ) + with pytest.raises(SystemExit, match="no slice output"): + cli.render_slice([], state, 8, 30, " .#") + + +def test_a_slice_out_of_range(steckler, capsys): + status, _, err = run(capsys, steckler, "--slice", "3") + assert status == 1 and "out of range (0 … 0)" in err + + +def test_a_slice_and_a_curve_together(steckler, capsys): + device = next(entry["name"] for entry in cli.build_series(cli._load(steckler)) if entry["source"] == "device") + _, out, _ = run(capsys, steckler, "--slice", "0", "--curve", device, "--height", "12", "--width", "40") + assert out.startswith("TEMPERATURE [C]") and device in out + + +def test_json_with_a_slice(steckler, capsys): + _, out, _ = run(capsys, steckler, "--json", "--slice", "0", "--time", "2") + data = json.loads(out) + assert data["chid"] == "Steckler_010" + + +@pytest.mark.parametrize("suffix", [".png", ".pdf"]) +def test_save_a_picture_of_slice_and_curve(steckler, tmp_path, capsys, suffix): + pytest.importorskip("matplotlib") + target = tmp_path / f"view{suffix}" + status, out, _ = run(capsys, steckler, "--slice", "0", "--curve", "HRR", "--save", target, "--render", "filled") + assert status == 0 and out.strip() == f"wrote {target}" + assert target.stat().st_size > 0 + + +def test_save_a_picture_of_curves_only(tiny_case, tmp_path, capsys): + pytest.importorskip("matplotlib") + target = tmp_path / "curves.png" + run(capsys, tiny_case, "--curve", "TC_1", "--save", target) + assert target.stat().st_size > 0 + + +def test_save_an_animation(tmp_path, capsys): + pytest.importorskip("matplotlib") + pytest.importorskip("PIL") + masks = shutil.copytree(CASES_DIR / "slcf_masks_fds611", tmp_path / "masks") # three frames, so it is quick + target = tmp_path / "slice.gif" + _, out, _ = run(capsys, masks, "--slice", "0", "--save", target) + assert "(3 frames)" in out and target.stat().st_size > 0 + + +def test_an_animation_needs_a_slice(tiny_case, tmp_path, capsys): + pytest.importorskip("matplotlib") + with pytest.raises(SystemExit, match="needs a slice"): + run(capsys, tiny_case, "--save", tmp_path / "x.gif") + + +@pytest.mark.filterwarnings("ignore:Animation was deleted without rendering") +def test_an_animation_that_cannot_be_written(tmp_path, capsys, monkeypatch): + animation = pytest.importorskip("matplotlib.animation") + plt = pytest.importorskip("matplotlib.pyplot") + + def fail(*args, **kwargs): + raise RuntimeError("no ffmpeg") + + monkeypatch.setattr(animation.Animation, "save", fail) + masks = shutil.copytree(CASES_DIR / "slcf_masks_fds611", tmp_path / "masks") + with pytest.raises(SystemExit, match="could not write"): + run(capsys, masks, "--slice", "0", "--save", tmp_path / "x.mp4") + assert not plt.get_fignums() # the figure is closed although writing failed + gc.collect() # the unsaved animation warns when it is collected, which belongs to this test + + +def test_interactive_hands_the_state_to_the_terminal_view(steckler, capsys, monkeypatch): + seen = {} + monkeypatch.setattr(tui, "run", lambda sim, state: seen.update(sim=sim, state=state)) + status, _, _ = run(capsys, steckler, "-i", "--curve", "HRR", "--curve", "Q_RADI") + assert status == 0 + assert seen["state"].scale_mode == "global" # frames stay comparable while stepping + assert seen["state"].curves == (0, 1) + + +def test_interactive_without_curses(steckler, capsys, monkeypatch): + def missing(sim, state): + raise ModuleNotFoundError("the interactive explorer needs the curses module") + + monkeypatch.setattr(tui, "run", missing) + status, _, err = run(capsys, steckler, "-i") + assert status == 1 and "curses" in err + + +def test_save_without_matplotlib(steckler, tmp_path, monkeypatch): + monkeypatch.setitem(sys.modules, "matplotlib", None) # makes the import fail + with pytest.raises(SystemExit, match="needs matplotlib"): + cli.save_figure([], cli.ExplorerState(), [], tmp_path / "x.png", 10, 10) + + +def test_running_the_package(steckler, capsys, monkeypatch): + monkeypatch.setattr(sys, "argv", ["fdsreader-explore", str(steckler)]) + monkeypatch.delitem(sys.modules, "fdsreader.explorer.__main__", raising=False) + with pytest.raises(SystemExit) as exit_info: + runpy.run_module("fdsreader.explorer", run_name="__main__") + assert exit_info.value.code == 0 + assert "Steckler_010" in capsys.readouterr().out + + +def test_the_package_exports_the_front_ends_lazily(): + assert "NotebookExplorer" in dir(fdsreader.explorer) + assert fdsreader.explorer.RAMP + with pytest.raises(AttributeError): + _ = fdsreader.explorer.no_such_thing diff --git a/tests/explorer/test_tui_curses.py b/tests/explorer/test_tui_curses.py new file mode 100644 index 00000000..ad89757a --- /dev/null +++ b/tests/explorer/test_tui_curses.py @@ -0,0 +1,172 @@ +"""The curses side of the interactive front end, on a fake window that records what is written and plays back +keys, so no terminal is needed.""" + +import curses + +import pytest + +from fdsreader.explorer import tui +from fdsreader.explorer.render import RAMP +from fdsreader.explorer.tui import Interactive + + +class FakeSim: + chid = "fake" + root_path = "." + + +class FakeWindow: + """Enough of a curses window for the explorer: a size, written text and a queue of keys.""" + + def __init__(self, rows=30, cols=120, keys=()): + self.rows, self.cols = rows, cols + self.keys = list(keys) + self.written = [] + self.timeouts = [] + + def getmaxyx(self): + return self.rows, self.cols + + def addnstr(self, row, col, text, n, attr=0): + if (row, col + len(text[:n])) == (self.rows - 1, self.cols): + raise curses.error # curses raises when the bottom-right cell is written + self.written.append((row, col, text[:n], attr)) + + def getch(self): + return self.keys.pop(0) + + def text(self): + return "\n".join(text for _, _, text, _ in self.written) + + def erase(self): + self.written.clear() + + def refresh(self): + pass + + def keypad(self, flag): + pass + + def timeout(self, ms): + self.timeouts.append(ms) + + +@pytest.fixture +def app(monkeypatch, fake_field, fake_series): + monkeypatch.setattr(tui, "fields_of", lambda sim: [fake_field, fake_field]) + monkeypatch.setattr(tui, "build_series", lambda sim: fake_series) + return Interactive(FakeSim()) + + +def keys(*names): + return [ord(k) if isinstance(k, str) else k for k in names] + + +def test_key_names(): + assert tui._key_name(curses.KEY_LEFT) == "left" + assert tui._key_name(curses.KEY_NPAGE) == "pgdn" + assert tui._key_name(10) == tui._key_name(13) == "enter" + assert tui._key_name(27) == "escape" + assert tui._key_name(ord("q")) == "q" + assert tui._key_name(100000) == "" + + +def test_put_clips_to_the_window(): + window = FakeWindow(rows=5, cols=10) + tui._put(window, 9, 0, "below") + tui._put(window, 0, 10, "right of it") + tui._put(window, 1, 4, "far too long a line") + tui._put(window, 2, 0, "abcdef", width=3) + tui._put(window, 4, 9, "x") # the bottom-right cell raises inside curses + assert [text for _, _, text, _ in window.written] == ["far to", "abc"] + + +def test_ramped_lines_colour_each_cell(): + window = FakeWindow() + attrs = list(range(100, 100 + len(RAMP))) + tui._draw_ramped(window, 0, 0, RAMP[0] + RAMP[-1], attrs, 10) + assert [attr for *_, attr in window.written] == [0, attrs[-1]] # blank cells stay uncoloured + tui._draw_ramped(window, 1, 0, "plain", None, 10) + assert window.written[-1][2] == "plain" + + +@pytest.mark.parametrize("n_colours", [8, 256]) +def test_colour_pairs(monkeypatch, n_colours): + monkeypatch.setattr(curses, "has_colors", lambda: True) + monkeypatch.setattr(curses, "start_color", lambda: None) + monkeypatch.setattr(curses, "use_default_colors", lambda: (_ for _ in ()).throw(curses.error())) + monkeypatch.setattr(curses, "COLORS", n_colours, raising=False) + pairs = [] + + def init_pair(i, fg, bg): + if i == 2: + raise curses.error # a pair the terminal refuses + pairs.append(fg) + + monkeypatch.setattr(curses, "init_pair", init_pair) + monkeypatch.setattr(curses, "color_pair", lambda i: 1000 + i) + attrs = tui._init_colors() + assert len(attrs) == len(RAMP) and attrs[1] == 0 + assert pairs[-1] == (tui._HEAT_256 if n_colours >= 256 else tui._HEAT_8)[len(RAMP) - 1] + + +def test_no_colours(monkeypatch): + monkeypatch.setattr(curses, "has_colors", lambda: False) + assert tui._init_colors() is None + + +def test_pick_one(): + window = FakeWindow(keys=keys("j", curses.KEY_DOWN, "k", 10)) + assert tui._pick(window, "Slice", ["a", "b", "c"], [0], False) == [1] + assert "enter to choose" in window.text() + + +def test_pick_several_and_cancel(): + window = FakeWindow(keys=keys(" ", curses.KEY_END, " ", curses.KEY_HOME, " ", 10)) + # untick a, tick c, tick a again + assert tui._pick(window, "Curves", ["a", "b", "c"], [0], True) == [0, 2] + assert tui._pick(FakeWindow(keys=[27]), "Curves", ["a"], [], True) is None + assert tui._pick(FakeWindow(keys=keys("q")), "Curves", ["a"], [], True) is None + assert tui._pick(FakeWindow(), "Curves", [], [], True) is None + + +def test_pick_scrolls_a_long_list(): + window = FakeWindow(rows=8, keys=[curses.KEY_NPAGE, curses.KEY_NPAGE, curses.KEY_PPAGE, 10]) + choices = [f"item {i}" for i in range(30)] + assert tui._pick(window, "Slice", choices, [], False) == [4] + assert "5/30" in window.text() + + +def test_draw_the_screen_and_the_help(app): + window = FakeWindow() + tui._draw(window, app, None) + assert "fake" in window.text() and "? for help" in window.text() + app.handle_key("?") + tui._draw(window, app, None) + assert "any key to go back" in window.text() + + +def test_run_until_quit(app, monkeypatch): + window = FakeWindow( + keys=keys( + "f", + curses.KEY_DOWN, + 10, # pick the second slice + "c", + " ", + 10, # untick the first curve + " ", + -1, # play: the wait runs out and the next frame is drawn + curses.KEY_RESIZE, + "q", + ) + ) + monkeypatch.setattr(curses, "wrapper", lambda main: main(window)) + monkeypatch.setattr(curses, "curs_set", lambda visibility: None) + monkeypatch.setattr(curses, "has_colors", lambda: False) + monkeypatch.setattr(tui, "Interactive", lambda sim, state=None: app) + assert tui.run(FakeSim()) is app + assert app.state.field_index == 1 + assert app.state.curves == () + assert app.state.timestep == 1 + assert app.interval in window.timeouts From 0845043317465a6ccd1ce912408aeb4cd9fd2898 Mon Sep 17 00:00:00 2001 From: Marc Fehr Date: Thu, 15 Oct 2026 23:09:50 +0200 Subject: [PATCH 3/3] fix: explorer figures without a slice panel to spare, and without switching the backend - save_figure counted a slice panel even without a slice, so saving curves alone gave an empty left panel; it now builds its figures with their own Agg canvas instead of switching matplotlib to Agg for the whole process, which would stop plots in a notebook that calls it. - The notebook explorer draws the curves once when loading a simulation. Tests: the number of panels of saved figures, the backend after saving, a strict xfail for the mesh-border nodes where the explorer and to_global differ, a skip for the curses tests where curses is missing, shared helpers and fixtures for the explorer tests (the touched test files are now formatted with ruff), and one copy of the case for the CLI tests. Co-Authored-By: Claude Opus 5.5 --- fdsreader/explorer/cli.py | 26 ++++++++------ fdsreader/explorer/notebook.py | 5 +-- tests/explorer/conftest.py | 58 ++++++++++++++++++++++-------- tests/explorer/explorer_helpers.py | 17 +++++++++ tests/explorer/test_cli.py | 15 ++------ tests/explorer/test_cli_slices.py | 56 +++++++++++++++++++---------- tests/explorer/test_fields.py | 11 ++++++ tests/explorer/test_notebook.py | 9 +---- tests/explorer/test_plots.py | 7 +--- tests/explorer/test_tui.py | 57 ++++++++++++++++------------- tests/explorer/test_tui_curses.py | 20 +++-------- 11 files changed, 170 insertions(+), 111 deletions(-) create mode 100644 tests/explorer/explorer_helpers.py diff --git a/fdsreader/explorer/cli.py b/fdsreader/explorer/cli.py index e8c47bd1..ec8925a6 100644 --- a/fdsreader/explorer/cli.py +++ b/fdsreader/explorer/cli.py @@ -123,12 +123,18 @@ def render_slice(fields, state, height, width, ramp): def save_figure(fields, state, series, path, height, width): """Write the current view to an image or an animation, using matplotlib.""" try: - import matplotlib - - matplotlib.use("Agg") - import matplotlib.pyplot as plt + # A figure with its own Agg canvas, rather than pyplot, leaves the backend of the process alone, e.g. when + # this is called from a notebook + from matplotlib.backends.backend_agg import FigureCanvasAgg + from matplotlib.figure import Figure except ModuleNotFoundError: raise SystemExit(f'{PROG}: --save needs matplotlib: pip install "fdsreader[notebook]"') + + def new_figure(**kwargs): + fig = Figure(**kwargs) + FigureCanvasAgg(fig) + return fig + from .plots import plot_series, plot_slice field = state.field(fields) @@ -140,7 +146,8 @@ def save_figure(fields, state, series, path, height, width): from matplotlib import animation steps = range(0, len(field.times), max(1, len(field.times) // 120)) - fig, ax = plt.subplots() + fig = new_figure() + ax = fig.subplots() def draw(step): ax.clear() @@ -164,13 +171,11 @@ def draw(step): anim.save(str(path), writer=writer) except Exception as exc: raise SystemExit(f"{PROG}: could not write {path}: {exc}") - finally: - plt.close(fig) return f"wrote {path} ({len(list(steps))} frames)" - panels = (1 if field is None else 1) + (1 if series else 0) - fig, axes = plt.subplots(1, max(1, panels), figsize=(7 * max(1, panels), 5.5)) - axes = np.atleast_1d(axes) + panels = (0 if field is None else 1) + (1 if series else 0) + fig = new_figure(figsize=(7 * max(1, panels), 5.5)) + axes = np.atleast_1d(fig.subplots(1, max(1, panels))) at = 0 if field is not None: timestep = state.clamp(fields) @@ -192,7 +197,6 @@ def draw(step): plot_series(series, ax=axes[at], cursor_time=state.time(fields) if field is not None else None) fig.tight_layout() fig.savefig(str(path), dpi=150) - plt.close(fig) return f"wrote {path}" diff --git a/fdsreader/explorer/notebook.py b/fdsreader/explorer/notebook.py index 28a6f50f..ec70e8b7 100644 --- a/fdsreader/explorer/notebook.py +++ b/fdsreader/explorer/notebook.py @@ -667,9 +667,10 @@ def _on_simulation_loaded(self, sim): self._setup_fields(sim) return - self._setup_fields(sim) # also positions the time bar + self._setup_fields(sim) # also positions the time bar, and draws the curves when there is a slice self.summary.value = self._overview() # after the fields, so it can count the slices - self._refresh_curves() + if not self.fields: + self._refresh_curves() def _overview(self): """Short HTML summary: what is in this simulation, at a glance.""" diff --git a/tests/explorer/conftest.py b/tests/explorer/conftest.py index 70435cc3..3f7027d2 100644 --- a/tests/explorer/conftest.py +++ b/tests/explorer/conftest.py @@ -55,15 +55,12 @@ def __init__(self, nx=8, ny=16, n_t=3, quantity="TEMPERATURE", unit="C"): self.unit = unit self.times = np.linspace(0.0, 2.0, n_t) self.axes = ("y", "z") - self.coords = {"x": np.array([0.0]), - "y": np.linspace(-2.0, 2.0, nx), - "z": np.linspace(0.0, 8.0, ny)} + self.coords = {"x": np.array([0.0]), "y": np.linspace(-2.0, 2.0, nx), "z": np.linspace(0.0, 8.0, ny)} self.shape = (nx, ny) # a blob that rises and grows, so frames differ both in where the heat is and # in how hot it gets -- which is what makes a per-step colour scale visible yy, zz = np.meshgrid(self.coords["y"], self.coords["z"], indexing="ij") - self._frames = [20 + (150 + 180 * k) * np.exp(-(yy ** 2) - (zz - 1 - 2 * k) ** 2) - for k in range(n_t)] + self._frames = [20 + (150 + 180 * k) * np.exp(-(yy**2) - (zz - 1 - 2 * k) ** 2) for k in range(n_t)] def frame(self, timestep): return self._frames[timestep] @@ -90,12 +87,26 @@ def fake_field(): def fake_series(): times = np.linspace(0.0, 4.0, 21) return [ - {"label": "DEVC A — TEMPERATURE [C]", "name": "A", "times": times, - "values": 20 + 10 * times, "quantity": "TEMPERATURE", "unit": "C", - "source": "device", "device": None}, - {"label": "DEVC B — TEMPERATURE [C]", "name": "B", "times": times, - "values": 20 + 4 * times ** 2, "quantity": "TEMPERATURE", "unit": "C", - "source": "device", "device": None}, + { + "label": "DEVC A — TEMPERATURE [C]", + "name": "A", + "times": times, + "values": 20 + 10 * times, + "quantity": "TEMPERATURE", + "unit": "C", + "source": "device", + "device": None, + }, + { + "label": "DEVC B — TEMPERATURE [C]", + "name": "B", + "times": times, + "values": 20 + 4 * times**2, + "quantity": "TEMPERATURE", + "unit": "C", + "source": "device", + "device": None, + }, ] @@ -110,9 +121,7 @@ def devc_without_data(request, tmp_path): root = tmp_path / request.param root.mkdir() (root / "c.smv").write_text( - "CHID\n c\n\n" - "CSVF\n devc\n c_devc.csv\n\n" - "DEVICE\n TC % TEMPERATURE\n 1.0 0.0 1.0 0.0 0.0 1.0\n" + "CHID\n c\n\nCSVF\n devc\n c_devc.csv\n\nDEVICE\n TC % TEMPERATURE\n 1.0 0.0 1.0 0.0 0.0 1.0\n" ) if request.param == "empty": (root / "c_devc.csv").write_text("") @@ -149,3 +158,24 @@ def devc_line_case(tmp_path): (root / "c_devc.csv").write_text("s,C\nTime,TC\n0.0,20.0\n1.0,30.0\n2.0,45.0\n") (root / "c_line.csv").write_text("m,C\nHeight,LINE\n0.5,100.0\n1.5,200.0\n") return root + + +@pytest.fixture +def app(monkeypatch, fake_field, fake_series): + """An interactive application over one field shown twice and two curves.""" + from explorer_helpers import FakeSim + + from fdsreader.explorer import tui + + monkeypatch.setattr(tui, "fields_of", lambda sim: [fake_field, fake_field]) + monkeypatch.setattr(tui, "build_series", lambda sim: fake_series) + return tui.Interactive(FakeSim()) + + +@pytest.fixture +def agg(): + """Draw on the Agg backend, so no display is needed, and close every figure afterwards.""" + plt = pytest.importorskip("matplotlib.pyplot") + plt.switch_backend("Agg") + yield plt + plt.close("all") diff --git a/tests/explorer/explorer_helpers.py b/tests/explorer/explorer_helpers.py new file mode 100644 index 00000000..dfaa083c --- /dev/null +++ b/tests/explorer/explorer_helpers.py @@ -0,0 +1,17 @@ +"""Helpers shared by the explorer tests.""" + +from fdsreader.explorer.cli import main + + +class FakeSim: + """Just enough of a simulation for the interactive front end when fields and series are patched in.""" + + chid = "fake" + root_path = "." + + +def run(capsys, *argv): + """Run the CLI and return (exit status, stdout, stderr).""" + status = main([str(a) for a in argv]) + captured = capsys.readouterr() + return status, captured.out, captured.err diff --git a/tests/explorer/test_cli.py b/tests/explorer/test_cli.py index fdaa493a..6f498f8b 100644 --- a/tests/explorer/test_cli.py +++ b/tests/explorer/test_cli.py @@ -3,15 +3,7 @@ import json import pytest - -from fdsreader.explorer.cli import main - - -def run(capsys, *argv): - """Run the CLI and return (exit status, stdout, stderr).""" - status = main([str(a) for a in argv]) - captured = capsys.readouterr() - return status, captured.out, captured.err +from explorer_helpers import run def test_overview(tiny_case, capsys): @@ -40,7 +32,7 @@ def test_curve_is_drawn_with_axes_and_a_legend(tiny_case, capsys): assert lines[0].startswith(" 100 |") assert lines[0].rstrip().endswith("*") assert lines[0].split("|", 1)[1].startswith(" ") - assert lines[-4].startswith(" 20 |*") # and the bottom row starts at t = 0 + assert lines[-4].startswith(" 20 |*") # and the bottom row starts at t = 0 assert lines[-3].startswith(" +---") # then the axis rule assert "t [s]" in lines[-2] assert lines[-1] == " * DEVC TC_1 — TEMPERATURE [C]" @@ -59,8 +51,7 @@ def test_mixed_units_are_flagged(tiny_case, capsys): def test_cursor_is_drawn_at_the_requested_time(tiny_case, capsys): _, without, _ = run(capsys, tiny_case, "--curve", "TC_1", "--width", "20") - _, with_cursor, _ = run(capsys, tiny_case, "--curve", "TC_1", "--width", "20", - "--time", "5") + _, with_cursor, _ = run(capsys, tiny_case, "--curve", "TC_1", "--width", "20", "--time", "5") assert with_cursor.count("|") > without.count("|") diff --git a/tests/explorer/test_cli_slices.py b/tests/explorer/test_cli_slices.py index c8558f2c..22bf22ac 100644 --- a/tests/explorer/test_cli_slices.py +++ b/tests/explorer/test_cli_slices.py @@ -11,23 +11,17 @@ import sys import pytest +from explorer_helpers import run from fds_cases import CASES_DIR import fdsreader.explorer from fdsreader.explorer import cli, tui -from fdsreader.explorer.cli import main -@pytest.fixture -def steckler(tmp_path): - return shutil.copytree(CASES_DIR / "steckler_data", tmp_path / "steckler") - - -def run(capsys, *argv): - """Run the CLI and return (exit status, stdout, stderr).""" - status = main([str(a) for a in argv]) - captured = capsys.readouterr() - return status, captured.out, captured.err +@pytest.fixture(scope="module") +def steckler(tmp_path_factory): + # One copy for the module: the tests only read it, and the pickle cache is off + return shutil.copytree(CASES_DIR / "steckler_data", tmp_path_factory.mktemp("cli") / "steckler") def test_overview_and_list_count_the_slice(steckler, capsys): @@ -74,19 +68,46 @@ def test_json_with_a_slice(steckler, capsys): @pytest.mark.parametrize("suffix", [".png", ".pdf"]) -def test_save_a_picture_of_slice_and_curve(steckler, tmp_path, capsys, suffix): - pytest.importorskip("matplotlib") +def test_save_a_picture_of_slice_and_curve(steckler, tmp_path, capsys, suffix, monkeypatch): + counts = _axes_when_saved(monkeypatch) target = tmp_path / f"view{suffix}" status, out, _ = run(capsys, steckler, "--slice", "0", "--curve", "HRR", "--save", target, "--render", "filled") assert status == 0 and out.strip() == f"wrote {target}" assert target.stat().st_size > 0 + assert counts == [2] -def test_save_a_picture_of_curves_only(tiny_case, tmp_path, capsys): - pytest.importorskip("matplotlib") +def _axes_when_saved(monkeypatch): + """The number of axes of each figure the CLI saves, excluding colour bars.""" + figure = pytest.importorskip("matplotlib.figure") + counts = [] + savefig = figure.Figure.savefig + + def counting(fig, *args, **kwargs): + counts.append(sum(1 for ax in fig.axes if ax.get_label() != "")) + return savefig(fig, *args, **kwargs) + + monkeypatch.setattr(figure.Figure, "savefig", counting) + return counts + + +def test_save_a_picture_of_curves_only(tiny_case, tmp_path, capsys, monkeypatch): + counts = _axes_when_saved(monkeypatch) target = tmp_path / "curves.png" run(capsys, tiny_case, "--curve", "TC_1", "--save", target) assert target.stat().st_size > 0 + assert counts == [1] # no empty panel for a slice that is not there + + +def test_saving_leaves_the_matplotlib_backend_alone(steckler, tmp_path, capsys): + matplotlib = pytest.importorskip("matplotlib") + before = matplotlib.get_backend() + matplotlib.use("svg") + try: + run(capsys, steckler, "--slice", "0", "--save", tmp_path / "x.png") + assert matplotlib.get_backend() == "svg" + finally: + matplotlib.use(before) def test_save_an_animation(tmp_path, capsys): @@ -107,7 +128,6 @@ def test_an_animation_needs_a_slice(tiny_case, tmp_path, capsys): @pytest.mark.filterwarnings("ignore:Animation was deleted without rendering") def test_an_animation_that_cannot_be_written(tmp_path, capsys, monkeypatch): animation = pytest.importorskip("matplotlib.animation") - plt = pytest.importorskip("matplotlib.pyplot") def fail(*args, **kwargs): raise RuntimeError("no ffmpeg") @@ -116,7 +136,6 @@ def fail(*args, **kwargs): masks = shutil.copytree(CASES_DIR / "slcf_masks_fds611", tmp_path / "masks") with pytest.raises(SystemExit, match="could not write"): run(capsys, masks, "--slice", "0", "--save", tmp_path / "x.mp4") - assert not plt.get_fignums() # the figure is closed although writing failed gc.collect() # the unsaved animation warns when it is collected, which belongs to this test @@ -139,7 +158,8 @@ def missing(sim, state): def test_save_without_matplotlib(steckler, tmp_path, monkeypatch): - monkeypatch.setitem(sys.modules, "matplotlib", None) # makes the import fail + for module in ("matplotlib", "matplotlib.figure", "matplotlib.backends.backend_agg"): + monkeypatch.setitem(sys.modules, module, None) # makes the import fail with pytest.raises(SystemExit, match="needs matplotlib"): cli.save_figure([], cli.ExplorerState(), [], tmp_path / "x.png", 10, 10) diff --git a/tests/explorer/test_fields.py b/tests/explorer/test_fields.py index e60c42de..70800475 100644 --- a/tests/explorer/test_fields.py +++ b/tests/explorer/test_fields.py @@ -40,6 +40,17 @@ def test_a_frame_equals_the_eager_global_array(case, slice_id, request): np.testing.assert_allclose(field.frame(t), eager[t], equal_nan=True) +@pytest.mark.xfail( + strict=True, + reason="on nodes shared by two meshes the explorer keeps the first mesh, to_global the upper one", +) +def test_mesh_border_nodes_match_the_eager_global_array(masks): + # At x = 0.3, y = 1.0 M1 writes the placeholder of 'Under', which ends there, and M3 a gas value + slc = masks.slices.get_by_id("z07") + field = SliceField(slc) + np.testing.assert_allclose(field.frame(len(field.times) - 1), slc.to_global()[-1], equal_nan=True) + + def test_axes_and_coordinates(masks): field = SliceField(masks.slices.get_by_id("y08")) assert field.axes == ("x", "z") diff --git a/tests/explorer/test_notebook.py b/tests/explorer/test_notebook.py index 7539a708..1599511b 100644 --- a/tests/explorer/test_notebook.py +++ b/tests/explorer/test_notebook.py @@ -16,14 +16,7 @@ from fdsreader.explorer.notebook import NotebookExplorer, SimulationBrowser, explore # noqa: E402 # plt.show() into an Output widget has nothing to show on the Agg backend -pytestmark = pytest.mark.filterwarnings("ignore:FigureCanvasAgg is non-interactive") - - -@pytest.fixture(autouse=True) -def _agg_and_close(): - plt.switch_backend("Agg") - yield - plt.close("all") +pytestmark = [pytest.mark.usefixtures("agg"), pytest.mark.filterwarnings("ignore:FigureCanvasAgg is non-interactive")] @pytest.fixture diff --git a/tests/explorer/test_plots.py b/tests/explorer/test_plots.py index 768e52da..7cfc079c 100644 --- a/tests/explorer/test_plots.py +++ b/tests/explorer/test_plots.py @@ -9,12 +9,7 @@ from fdsreader.explorer.data import list_devices # noqa: E402 from fdsreader.explorer.plots import plot_device, plot_series, plot_slice # noqa: E402 - -@pytest.fixture(autouse=True) -def _agg_and_close(): - plt.switch_backend("Agg") - yield - plt.close("all") +pytestmark = pytest.mark.usefixtures("agg") def test_nothing_selected(): diff --git a/tests/explorer/test_tui.py b/tests/explorer/test_tui.py index 8b897182..a92b982e 100644 --- a/tests/explorer/test_tui.py +++ b/tests/explorer/test_tui.py @@ -5,35 +5,23 @@ """ import pytest +from explorer_helpers import FakeSim from fdsreader.explorer import tui from fdsreader.explorer.tui import SIDE_BY_SIDE_COLUMNS, Interactive, layout -class FakeSim: - chid = "fake" - root_path = "." - - -@pytest.fixture -def app(monkeypatch, fake_field, fake_series): - """An application over one field and two curves.""" - monkeypatch.setattr(tui, "fields_of", lambda sim: [fake_field, fake_field]) - monkeypatch.setattr(tui, "build_series", lambda sim: fake_series) - return Interactive(FakeSim()) - - # -- layout --------------------------------------------------------------- def test_wide_terminal_puts_the_panes_side_by_side(): boxes = layout(40, SIDE_BY_SIDE_COLUMNS) - assert boxes["slice"][0] == boxes["curves"][0] # same top row - assert boxes["curves"][1] > 0 # curves start further right + assert boxes["slice"][0] == boxes["curves"][0] # same top row + assert boxes["curves"][1] > 0 # curves start further right def test_narrow_terminal_stacks_the_panes(): boxes = layout(40, SIDE_BY_SIDE_COLUMNS - 1) - assert boxes["curves"][1] == 0 # both start at the left - assert boxes["curves"][0] > boxes["slice"][0] # curves sit below + assert boxes["curves"][1] == 0 # both start at the left + assert boxes["curves"][0] > boxes["slice"][0] # curves sit below @pytest.mark.parametrize("rows,cols", [(6, 20), (24, 80), (60, 200), (1, 1)]) @@ -76,7 +64,7 @@ def test_play_toggles_and_advance_stops_at_the_end(app): for _ in range(len(app.field.times) + 5): app.advance() assert app.state.timestep == len(app.field.times) - 1 - assert not app.playing # it stops rather than looping + assert not app.playing # it stops rather than looping def test_advance_does_nothing_while_paused(app): @@ -105,7 +93,7 @@ def test_bracket_keys_change_field_and_clamp(app): app.handle_key("]") assert app.state.field_index == 1 app.handle_key("]") - assert app.state.field_index == 1 # only two fields + assert app.state.field_index == 1 # only two fields app.handle_key("[") app.handle_key("[") assert app.state.field_index == 0 @@ -133,7 +121,7 @@ def test_quit_and_help(app): assert app.handle_key("q") == "quit" assert app.handle_key("?") is None assert app.showing_help - app.handle_key("l") # any other key closes it again + app.handle_key("l") # any other key closes it again assert not app.showing_help @@ -169,7 +157,7 @@ def test_a_simulation_without_slices(monkeypatch, fake_series): assert app.field is None assert "no slice output" in app.title() assert "no slice" in " ".join(app.slice_text(20, 60)).lower() - app.handle_key("right") # must not raise + app.handle_key("right") # must not raise app.advance() @@ -182,7 +170,7 @@ def test_devices_without_data_leave_the_curve_list_empty(devc_without_data): assert app.series == [] assert app.state.curves == () assert "press c" in " ".join(app.curves_text(20, 60)) - app.handle_key("c") # opening the picker must not raise + app.handle_key("c") # opening the picker must not raise assert app.curve_choices() == [] @@ -195,8 +183,29 @@ def test_a_simulation_with_nothing_in_it_still_draws(simulation_with_nothing): assert app.field is None assert app.current_time is None if hasattr(app, "current_time") else True # every key, on a simulation with nothing to show - for key in ("right", "left", "h", "l", "pgup", "pgdn", "<", ">", "home", "end", - " ", "+", "-", "g", "s", "[", "]", "f", "c", "?", "q"): + for key in ( + "right", + "left", + "h", + "l", + "pgup", + "pgdn", + "<", + ">", + "home", + "end", + " ", + "+", + "-", + "g", + "s", + "[", + "]", + "f", + "c", + "?", + "q", + ): app.handle_key(key) app.advance() assert any(line.strip() for line in app.slice_text(20, 60)) diff --git a/tests/explorer/test_tui_curses.py b/tests/explorer/test_tui_curses.py index ad89757a..32918df4 100644 --- a/tests/explorer/test_tui_curses.py +++ b/tests/explorer/test_tui_curses.py @@ -1,18 +1,13 @@ """The curses side of the interactive front end, on a fake window that records what is written and plays back keys, so no terminal is needed.""" -import curses - import pytest +from explorer_helpers import FakeSim -from fdsreader.explorer import tui -from fdsreader.explorer.render import RAMP -from fdsreader.explorer.tui import Interactive - +curses = pytest.importorskip("curses") # not part of the standard library on Windows -class FakeSim: - chid = "fake" - root_path = "." +from fdsreader.explorer import tui # noqa: E402 +from fdsreader.explorer.render import RAMP # noqa: E402 class FakeWindow: @@ -51,13 +46,6 @@ def timeout(self, ms): self.timeouts.append(ms) -@pytest.fixture -def app(monkeypatch, fake_field, fake_series): - monkeypatch.setattr(tui, "fields_of", lambda sim: [fake_field, fake_field]) - monkeypatch.setattr(tui, "build_series", lambda sim: fake_series) - return Interactive(FakeSim()) - - def keys(*names): return [ord(k) if isinstance(k, str) else k for k in names]