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/cli.py b/fdsreader/explorer/cli.py index 5d062146..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,12 +171,11 @@ 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) 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) @@ -191,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 88dfc7b3..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.summary.value = self._overview() - self._setup_fields(sim) # also positions the time bar - self._refresh_curves() + 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 + 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 new file mode 100644 index 00000000..22bf22ac --- /dev/null +++ b/tests/explorer/test_cli_slices.py @@ -0,0 +1,180 @@ +"""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 explorer_helpers import run +from fds_cases import CASES_DIR + +import fdsreader.explorer +from fdsreader.explorer import cli, tui + + +@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): + _, 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, 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 _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): + 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") + + 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") + 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): + 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) + + +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_fields.py b/tests/explorer/test_fields.py new file mode 100644 index 00000000..70800475 --- /dev/null +++ b/tests/explorer/test_fields.py @@ -0,0 +1,98 @@ +"""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) + + +@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") + 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..1599511b --- /dev/null +++ b/tests/explorer/test_notebook.py @@ -0,0 +1,153 @@ +"""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.usefixtures("agg"), pytest.mark.filterwarnings("ignore:FigureCanvasAgg is non-interactive")] + + +@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..7cfc079c --- /dev/null +++ b/tests/explorer/test_plots.py @@ -0,0 +1,77 @@ +"""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 + +pytestmark = pytest.mark.usefixtures("agg") + + +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() 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 new file mode 100644 index 00000000..32918df4 --- /dev/null +++ b/tests/explorer/test_tui_curses.py @@ -0,0 +1,160 @@ +"""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 pytest +from explorer_helpers import FakeSim + +curses = pytest.importorskip("curses") # not part of the standard library on Windows + +from fdsreader.explorer import tui # noqa: E402 +from fdsreader.explorer.render import RAMP # noqa: E402 + + +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) + + +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