diff --git a/docs/dev/implementation-plans/progress-activity-indicator.md b/docs/dev/implementation-plans/progress-activity-indicator.md deleted file mode 100644 index e776e0127..000000000 --- a/docs/dev/implementation-plans/progress-activity-indicator.md +++ /dev/null @@ -1,407 +0,0 @@ -# Progress Activity Indicator Implementation Plan - -**Status:** Proposed -**Date:** 2026-05-14 - -## Goal - -Add a small activity indicator for fitting and other long-running -calculations. The indicator should read as the same feature in terminal -and Jupyter, while using environment-appropriate rendering underneath. - -This is an activity indicator, not a numeric progress bar. Most -deterministic minimizers do not expose a reliable total work estimate, -and the existing fit progress table updates only when meaningful fit -state changes. A spinner-style indicator communicates that work is -continuing without implying a percentage that may be unavailable. - -## User-Facing Behavior - -### Visibility - -The indicator is controlled by existing verbosity: - -| Verbosity | Behavior | -| --------- | ---------------------------------------------------------------------------------------------------- | -| `silent` | Show nothing. No table, no activity indicator, no status line. | -| `short` | Show the activity indicator, but not the detailed fit progress table. Keep existing short summaries. | -| `full` | Show the detailed progress table and the activity indicator below it. | - -This changes the current fit tracker behavior where `short` returns -before creating any live progress output. - -### Labels - -Use the following labels: - -| Work type | Label | -| --------------------------------- | ------------ | -| Deterministic single fit | `fitting` | -| Sequential fit | `fitting` | -| Bayesian DREAM burn phase | `burn-in` | -| Bayesian DREAM sampling phase | `sampling` | -| Posterior predictive plots/checks | `processing` | -| Posterior pair plots | `processing` | -| Other long calculations | `processing` | - -The label must be updateable while work is running. DREAM should switch -from `burn-in` to `sampling` when sampler progress reports the phase -change. - -### Visual Style - -Use compact Unicode spinner frames: - -```text -⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏ -``` - -Examples: - -```text -⠋ fitting -⠴ burn-in -⠇ sampling -⠙ processing -``` - -The indicator should be a single line. In `full` mode it appears below -the progress table. In `short` mode it appears as the only live progress -element. - -## Current Code Paths - -### Fit Progress - -The single-fit and sampler progress lifecycle is owned by -`src/easydiffraction/analysis/fit_helpers/tracking.py`. - -Important methods: - -- `FitProgressTracker.start_tracking(...)` -- `FitProgressTracker.add_tracking_info(...)` -- `FitProgressTracker.track_sampler_progress(...)` -- `FitProgressTracker.finish_tracking(...)` -- `_make_display_handle()` -- `_TerminalLiveHandle` - -The existing table update path uses `render_table(...)`, which delegates -to `TableRenderer` and then to either: - -- `PandasTableBackend` in Jupyter -- `RichTableBackend` in terminal - -### Sequential Fit - -Sequential fitting currently has separate progress output in -`src/easydiffraction/analysis/sequential.py`. - -Important functions: - -- `fit_sequential(...)` -- `_run_fit_loop(...)` -- `_report_chunk_progress(...)` - -Sequential fit should reuse the same activity indicator abstraction -rather than adding a separate spinner implementation. - -### Posterior And Other Long Calculations - -Posterior display work is routed through: - -- `src/easydiffraction/project/display.py` -- `src/easydiffraction/display/plotting.py` - -Important entry points include: - -- `PosteriorDisplay.pairs(...)` -- `PosteriorDisplay.predictive(...)` -- `Plotter.plot_posterior_pairs(...)` -- `Plotter.plot_posterior_predictive(...)` - -These should use the generic `processing` label if an activity indicator -is added to those paths. - -## Architecture - -### Add A Shared Activity Indicator - -Add a small shared display helper, preferably: - -```text -src/easydiffraction/display/progress.py -``` - -Recommended public/internal shape: - -```python -class ActivityIndicator: - def __init__(self, label: str = "processing", *, verbosity: VerbosityEnum) -> None: ... - def start(self) -> None: ... - def update(self, *, label: str | None = None, content: object | None = None) -> None: ... - def stop(self, *, final_label: str | None = None) -> None: ... -``` - -The exact class name can change during implementation, but it should -provide these capabilities: - -- no output in `silent` -- live output in `short` and `full` -- label updates while running -- optional table/content rendering above the indicator in `full` -- terminal and Jupyter implementations behind one API -- safe cleanup on exceptions - -### Terminal Rendering - -Use Rich for terminal rendering. - -Preferred implementation: - -- keep using `rich.live.Live` -- render a `rich.console.Group` -- group content should be: - - the progress table renderable, when present - - the activity indicator line - -The indicator line can be either: - -- Rich's built-in `Spinner`, if it works cleanly inside the existing - `Live` setup, or -- a local unicode-frame renderable driven by the same frame list. - -Do not create a second independent `Live` instance for the same output -area. The table and spinner should be refreshed together through one -live handle. - -### Jupyter Rendering - -Use an IPython `DisplayHandle` and HTML. - -The Jupyter spinner should be browser-driven CSS animation, not a -Python-loop animation. This matters because Python may not regain -control during expensive calculations, but CSS keeps animating once the -HTML has been displayed. - -Recommended HTML structure: - -```html -
{text}'
+
+ def _html_indicator(self) -> str:
+ safe_label = html.escape(self._label)
+
+ if self._running:
+ return (
+ ' Table:
+ def build_renderable(
+ self,
+ alignments: object,
+ df: object,
+ ) -> object:
"""
Construct a Rich Table with formatted data and alignment.
Parameters
----------
- df : object
- DataFrame-like object providing rows to render.
alignments : object
Iterable of text alignment values for columns.
- color : str
- Rich color name used for borders/index style.
+ df : object
+ DataFrame-like object providing rows to render.
Returns
-------
- Table
+ object
A :class:`~rich.table.Table` configured for display.
"""
+ color = self._rich_border_color
table = Table(
title=None,
box=RICH_TABLE_BOX,
@@ -172,6 +175,5 @@ def render(
object
Backend-defined return value (commonly ``None``).
"""
- color = self._rich_border_color
- table = self._build_table(df, alignments, color)
+ table = self.build_renderable(alignments, df)
self._update_display(table, display_handle)
diff --git a/src/easydiffraction/display/tables.py b/src/easydiffraction/display/tables.py
index cda6f0d19..574d245a0 100644
--- a/src/easydiffraction/display/tables.py
+++ b/src/easydiffraction/display/tables.py
@@ -75,6 +75,47 @@ def show_config(self) -> None:
console.paragraph('Current tabler configuration')
TableRenderer.get().render(df)
+ @staticmethod
+ def _prepare_dataframe(df: object) -> tuple[object, object]:
+ """
+ Normalize input table data for backend consumption.
+
+ Parameters
+ ----------
+ df : object
+ DataFrame with a two-level column index where the second
+ level provides per-column alignment.
+
+ Returns
+ -------
+ tuple[object, object]
+ Normalized ``(alignments, dataframe)`` pair.
+ """
+ prepared_df = df.copy()
+ prepared_df.index += 1
+
+ alignments = prepared_df.columns.get_level_values(1)
+ prepared_df.columns = prepared_df.columns.get_level_values(0)
+ return alignments, prepared_df
+
+ def build_renderable(self, df: object) -> object:
+ """
+ Build a backend-native renderable without displaying it.
+
+ Parameters
+ ----------
+ df : object
+ DataFrame with a two-level column index where the second
+ level provides per-column alignment.
+
+ Returns
+ -------
+ object
+ Backend-native renderable, such as a Rich table or HTML.
+ """
+ alignments, prepared_df = self._prepare_dataframe(df)
+ return self._backend.build_renderable(alignments, prepared_df)
+
def render(self, df: object, display_handle: object | None = None) -> object:
"""
Render a DataFrame as a table using the active backend.
@@ -94,19 +135,8 @@ def render(self, df: object, display_handle: object | None = None) -> object:
object
Backend-specific return value (usually ``None``).
"""
- # Work on a copy to avoid mutating the original DataFrame
- df = df.copy()
-
- # Force starting index from 1
- df.index += 1
-
- # Extract column alignments
- alignments = df.columns.get_level_values(1)
-
- # Remove alignments from df (Keep only the first index level)
- df.columns = df.columns.get_level_values(0)
-
- return self._backend.render(alignments, df, display_handle)
+ alignments, prepared_df = self._prepare_dataframe(df)
+ return self._backend.render(alignments, prepared_df, display_handle)
class TableRendererFactory(RendererFactoryBase):
diff --git a/src/easydiffraction/project/display.py b/src/easydiffraction/project/display.py
index 2071463d5..6bab7df3d 100644
--- a/src/easydiffraction/project/display.py
+++ b/src/easydiffraction/project/display.py
@@ -13,6 +13,9 @@
from easydiffraction.display.plotting import PlotterEngineEnum
from easydiffraction.display.plotting import PosteriorPairPlotStyleEnum
from easydiffraction.display.plotting import _MeasVsCalcPlotOptions
+from easydiffraction.display.progress import ACTIVITY_LABEL_PROCESSING
+from easydiffraction.display.progress import activity_indicator
+from easydiffraction.utils.enums import VerbosityEnum
from easydiffraction.utils.utils import render_object_help
from easydiffraction.utils.utils import render_table
@@ -128,12 +131,16 @@ def pairs(
max_parameters: int = 6,
) -> None:
"""Plot posterior pair relationships for sampled parameters."""
- self._project.rendering.plotter.plot_posterior_pairs(
- parameters=parameters,
- style=style,
- threshold=threshold,
- max_parameters=max_parameters,
- )
+ with activity_indicator(
+ ACTIVITY_LABEL_PROCESSING,
+ verbosity=VerbosityEnum(self._project.verbosity),
+ ):
+ self._project.rendering.plotter.plot_posterior_pairs(
+ parameters=parameters,
+ style=style,
+ threshold=threshold,
+ max_parameters=max_parameters,
+ )
def distribution(self, param: object) -> None:
"""Plot one sampled parameter's posterior distribution."""
@@ -150,14 +157,18 @@ def predictive(
x: object | None = None,
) -> None:
"""Plot posterior predictive summaries for one experiment."""
- self._project.rendering.plotter.plot_posterior_predictive(
- expt_name=expt_name,
- style=style,
- x_min=x_min,
- x_max=x_max,
- show_residual=show_residual,
- x=x,
- )
+ with activity_indicator(
+ ACTIVITY_LABEL_PROCESSING,
+ verbosity=VerbosityEnum(self._project.verbosity),
+ ):
+ self._project.rendering.plotter.plot_posterior_predictive(
+ expt_name=expt_name,
+ style=style,
+ x_min=x_min,
+ x_max=x_max,
+ show_residual=show_residual,
+ x=x,
+ )
def help(self) -> None:
"""Print available posterior-display methods."""
@@ -213,19 +224,23 @@ def pattern(
msg = self._status_by_name(statuses, 'auto').reason
raise ValueError(msg)
if 'uncertainty' in auto_include:
- self._project.rendering.plotter._plot_posterior_predictive_request(
- expt_name=expt_name,
- style='band',
- plot_options=_MeasVsCalcPlotOptions(
- x_min=x_min,
- x_max=x_max,
- show_residual=True if 'residual' in auto_include else None,
- show_background='background' in auto_include,
- show_bragg='bragg' in auto_include,
- show_excluded='excluded' in auto_include,
- x=x,
- ),
- )
+ with activity_indicator(
+ ACTIVITY_LABEL_PROCESSING,
+ verbosity=VerbosityEnum(self._project.verbosity),
+ ):
+ self._project.rendering.plotter._plot_posterior_predictive_request(
+ expt_name=expt_name,
+ style='band',
+ plot_options=_MeasVsCalcPlotOptions(
+ x_min=x_min,
+ x_max=x_max,
+ show_residual=True if 'residual' in auto_include else None,
+ show_background='background' in auto_include,
+ show_bragg='bragg' in auto_include,
+ show_excluded='excluded' in auto_include,
+ x=x,
+ ),
+ )
return
self._show_point_estimate_pattern(
expt_name=expt_name,
@@ -243,19 +258,23 @@ def pattern(
raise ValueError(msg)
if 'uncertainty' in normalized_include:
- self._project.rendering.plotter._plot_posterior_predictive_request(
- expt_name=expt_name,
- style='band',
- plot_options=_MeasVsCalcPlotOptions(
- x_min=x_min,
- x_max=x_max,
- show_residual=True if 'residual' in normalized_include else None,
- show_background='background' in normalized_include,
- show_bragg='bragg' in normalized_include,
- show_excluded='excluded' in normalized_include,
- x=x,
- ),
- )
+ with activity_indicator(
+ ACTIVITY_LABEL_PROCESSING,
+ verbosity=VerbosityEnum(self._project.verbosity),
+ ):
+ self._project.rendering.plotter._plot_posterior_predictive_request(
+ expt_name=expt_name,
+ style='band',
+ plot_options=_MeasVsCalcPlotOptions(
+ x_min=x_min,
+ x_max=x_max,
+ show_residual=True if 'residual' in normalized_include else None,
+ show_background='background' in normalized_include,
+ show_bragg='bragg' in normalized_include,
+ show_excluded='excluded' in normalized_include,
+ x=x,
+ ),
+ )
return
self._show_point_estimate_pattern(
diff --git a/src/easydiffraction/utils/utils.py b/src/easydiffraction/utils/utils.py
index a81f1cb0b..2fe66e739 100644
--- a/src/easydiffraction/utils/utils.py
+++ b/src/easydiffraction/utils/utils.py
@@ -563,6 +563,38 @@ def render_table(
tabler.render(df, display_handle=display_handle)
+def build_table_renderable(
+ columns_data: object,
+ columns_alignment: object,
+ columns_headers: object = None,
+) -> object:
+ """
+ Build a table renderable for the active display backend.
+
+ Parameters
+ ----------
+ columns_data : object
+ A list of rows, where each row is a list of cell values.
+ columns_alignment : object
+ A list of alignment strings (e.g. ``'left'``, ``'right'``,
+ ``'center'``) matching the number of columns.
+ columns_headers : object, default=None
+ Optional list of column header strings.
+
+ Returns
+ -------
+ object
+ Backend-native renderable, such as a Rich table or HTML.
+ """
+ headers = [
+ (col, align) for col, align in zip(columns_headers, columns_alignment, strict=False)
+ ]
+ df = pd.DataFrame(columns_data, columns=pd.MultiIndex.from_tuples(headers))
+
+ tabler = TableRenderer.get()
+ return tabler.build_renderable(df)
+
+
def _help_first_sentence(docstring: str | None) -> str:
"""Return the first paragraph of a docstring on one line."""
if not docstring:
diff --git a/tests/integration/fitting/test_bayesian_tracker_and_base.py b/tests/integration/fitting/test_bayesian_tracker_and_base.py
index f63cb111f..e6988a0f3 100644
--- a/tests/integration/fitting/test_bayesian_tracker_and_base.py
+++ b/tests/integration/fitting/test_bayesian_tracker_and_base.py
@@ -27,7 +27,25 @@ def test_tracker_terminal_flow_prints_and_updates_best(monkeypatch, capsys):
import easydiffraction.analysis.fit_helpers.tracking as tracking_mod
from easydiffraction.analysis.fit_helpers.tracking import FitProgressTracker
- monkeypatch.setattr(tracking_mod, 'in_jupyter', lambda: False)
+ events: list[tuple[str, object]] = []
+
+ class FakeIndicator:
+ def __init__(self, label, *, verbosity, display_handle=None):
+ del display_handle
+ events.append(('init', label, verbosity))
+
+ def start(self):
+ events.append(('start', None))
+
+ def update(self, *, label=None, content=None):
+ events.append(('update', label))
+ del content
+
+ def stop(self):
+ events.append(('stop', None))
+
+ monkeypatch.setattr(tracking_mod, 'ActivityIndicator', FakeIndicator)
+ monkeypatch.setattr(tracking_mod, 'build_table_renderable', lambda **kwargs: 'table')
tracker = FitProgressTracker()
tracker.start_tracking('dummy')
@@ -46,6 +64,7 @@ def test_tracker_terminal_flow_prints_and_updates_best(monkeypatch, capsys):
out = capsys.readouterr().out
assert 'Best goodness-of-fit' in out
assert tracker.best_iteration is not None
+ assert ('init', tracking_mod.ACTIVITY_LABEL_FITTING, tracker._verbosity) in events
def test_tracker_sampler_progress_renders_and_completes(monkeypatch, capsys):
@@ -53,7 +72,25 @@ def test_tracker_sampler_progress_renders_and_completes(monkeypatch, capsys):
from easydiffraction.analysis.fit_helpers.tracking import FitProgressTracker
from easydiffraction.analysis.fit_helpers.tracking import SamplerProgressUpdate
- monkeypatch.setattr(tracking_mod, 'in_jupyter', lambda: False)
+ events: list[tuple[str, object]] = []
+
+ class FakeIndicator:
+ def __init__(self, label, *, verbosity, display_handle=None):
+ del display_handle
+ events.append(('init', label, verbosity))
+
+ def start(self):
+ events.append(('start', None))
+
+ def update(self, *, label=None, content=None):
+ events.append(('update', label))
+ del content
+
+ def stop(self):
+ events.append(('stop', None))
+
+ monkeypatch.setattr(tracking_mod, 'ActivityIndicator', FakeIndicator)
+ monkeypatch.setattr(tracking_mod, 'build_table_renderable', lambda **kwargs: 'table')
tracker = FitProgressTracker()
tracker.start_tracking('dream', mode='sampling')
@@ -90,6 +127,8 @@ def test_tracker_sampler_progress_renders_and_completes(monkeypatch, capsys):
assert 'Bayesian sampling complete.' in out
assert tracker.best_chi2 == pytest.approx(1.0)
assert tracker.best_iteration == 10
+ assert ('update', tracking_mod.ACTIVITY_LABEL_BURN_IN) in events
+ assert ('update', tracking_mod.ACTIVITY_LABEL_SAMPLING) in events
def test_tracker_helper_error_paths_and_short_mode(monkeypatch):
@@ -97,7 +136,24 @@ def test_tracker_helper_error_paths_and_short_mode(monkeypatch):
from easydiffraction.analysis.fit_helpers.tracking import FitProgressTracker
from easydiffraction.utils.enums import VerbosityEnum
- monkeypatch.setattr(tracking_mod, 'in_jupyter', lambda: False)
+ events: list[tuple[str, object]] = []
+
+ class FakeIndicator:
+ def __init__(self, label, *, verbosity, display_handle=None):
+ del display_handle
+ events.append(('init', label, verbosity))
+
+ def start(self):
+ events.append(('start', None))
+
+ def update(self, *, label=None, content=None):
+ events.append(('update', label))
+ del content
+
+ def stop(self):
+ events.append(('stop', None))
+
+ monkeypatch.setattr(tracking_mod, 'ActivityIndicator', FakeIndicator)
tracker = FitProgressTracker()
tracker._verbosity = VerbosityEnum.SHORT
@@ -111,14 +167,17 @@ def test_tracker_helper_error_paths_and_short_mode(monkeypatch):
FitProgressTracker()._sampler_iteration_label(1)
assert FitProgressTracker._rows_match_on_columns(['1', 'a'], ['1', 'b'], (0,)) is True
+ assert events == [
+ ('init', tracking_mod.ACTIVITY_LABEL_SAMPLING, VerbosityEnum.SHORT),
+ ('start', None),
+ ('update', tracking_mod.ACTIVITY_LABEL_SAMPLING),
+ ('stop', None),
+ ]
-def test_tracker_final_sampler_row_replaces_last_row(monkeypatch):
- import easydiffraction.analysis.fit_helpers.tracking as tracking_mod
+def test_tracker_final_sampler_row_replaces_last_row():
from easydiffraction.analysis.fit_helpers.tracking import FitProgressTracker
- monkeypatch.setattr(tracking_mod, 'render_table', lambda **kwargs: None)
-
tracker = FitProgressTracker()
tracker._tracking_mode = 'sampling'
tracker._sampler_total_iterations = 10
@@ -137,44 +196,32 @@ def test_tracker_final_sampler_row_replaces_last_row(monkeypatch):
def test_make_display_handle_uses_terminal_live_when_available(monkeypatch):
import easydiffraction.analysis.fit_helpers.tracking as tracking_mod
- class FakeLive:
- def __init__(self, *, console, auto_refresh):
- self.console = console
- self.auto_refresh = auto_refresh
- self.started = False
- self.stopped = False
-
- def start(self):
- self.started = True
-
- def stop(self):
- self.stopped = True
-
- monkeypatch.setattr(tracking_mod, 'in_jupyter', lambda: False)
- monkeypatch.setattr(tracking_mod, 'Live', FakeLive)
- monkeypatch.setattr(tracking_mod.ConsoleManager, 'get', lambda: 'console')
-
- handle = tracking_mod._make_display_handle()
+ sentinel = object()
- assert isinstance(handle, tracking_mod._TerminalLiveHandle)
- assert handle._live.console == 'console'
- assert handle._live.auto_refresh is True
- assert handle._live.started is True
+ monkeypatch.setattr(tracking_mod, 'make_display_handle', lambda: sentinel)
- handle.close()
-
- assert handle._live.stopped is True
+ assert tracking_mod._make_display_handle() is sentinel
def test_tracker_misc_helper_paths(monkeypatch):
import easydiffraction.analysis.fit_helpers.tracking as tracking_mod
from easydiffraction.analysis.fit_helpers.tracking import FitProgressTracker
+ from easydiffraction.utils.enums import VerbosityEnum
render_calls: list[dict[str, object]] = []
- monkeypatch.setattr(tracking_mod, 'render_table', lambda **kwargs: render_calls.append(kwargs))
+ update_calls: list[dict[str, object]] = []
monkeypatch.setattr(
tracking_mod, 'calculate_reduced_chi_square', lambda residuals, n_params: 3.0
)
+ monkeypatch.setattr(
+ tracking_mod,
+ 'build_table_renderable',
+ lambda **kwargs: render_calls.append(kwargs) or 'renderable',
+ )
+
+ class FakeIndicator:
+ def update(self, *, label=None, content=None):
+ update_calls.append({'label': label, 'content': content})
tracker = FitProgressTracker()
tracker._tracking_mode = tracking_mod.TRACKING_MODE_SAMPLER
@@ -197,13 +244,21 @@ def test_tracker_misc_helper_paths(monkeypatch):
assert tracker._current_elapsed_time() is None
assert tracker._format_elapsed_time() == ''
+ tracker._verbosity = VerbosityEnum.FULL
+ tracker._activity_indicator = FakeIndicator()
tracker._replace_last_tracking_row(['1'])
assert tracker._df_rows == [['1']]
assert len(render_calls) == 1
+ assert update_calls == [
+ {
+ 'label': tracking_mod.ACTIVITY_LABEL_FITTING,
+ 'content': 'renderable',
+ }
+ ]
-def test_tracker_final_rows_cover_fallbacks_and_close_suppression():
+def test_tracker_final_rows_cover_fallbacks_and_activity_labels():
import easydiffraction.analysis.fit_helpers.tracking as tracking_mod
from easydiffraction.analysis.fit_helpers.tracking import FitProgressTracker
@@ -218,15 +273,19 @@ def test_tracker_final_rows_cover_fallbacks_and_close_suppression():
tracker._tracking_mode = tracking_mod.TRACKING_MODE_FIT
tracker._fitting_time = 1.5
assert tracker._final_fit_tracking_row() == ['8', '1.50', '', '']
-
- class BadHandle:
- @staticmethod
- def close() -> None:
- message = 'boom'
- raise RuntimeError(message)
-
- tracker._display_handle = BadHandle()
- tracker._close_display_handle()
+ tracker._tracking_mode = tracking_mod.TRACKING_MODE_SAMPLER
+ assert tracker._default_activity_label() == tracking_mod.ACTIVITY_LABEL_SAMPLING
+ tracker._tracking_mode = tracking_mod.TRACKING_MODE_FIT
+ assert tracker._default_activity_label() == tracking_mod.ACTIVITY_LABEL_FITTING
+ assert (
+ tracker._activity_label_for_sampler_phase('burn-in') == tracking_mod.ACTIVITY_LABEL_BURN_IN
+ )
+ assert (
+ tracker._activity_label_for_sampler_phase('sampling')
+ == tracking_mod.ACTIVITY_LABEL_SAMPLING
+ )
+ assert tracker._activity_label_for_sampler_phase('annealing') == 'annealing'
+ assert tracker._activity_label_for_sampler_phase('') == tracking_mod.ACTIVITY_LABEL_SAMPLING
def test_minimizer_base_fit_flow_and_finalize():
diff --git a/tests/unit/easydiffraction/analysis/fit_helpers/test_tracking.py b/tests/unit/easydiffraction/analysis/fit_helpers/test_tracking.py
index 561eb54c3..877a46b1a 100644
--- a/tests/unit/easydiffraction/analysis/fit_helpers/test_tracking.py
+++ b/tests/unit/easydiffraction/analysis/fit_helpers/test_tracking.py
@@ -13,11 +13,11 @@ def test_module_import():
def test_tracker_terminal_flow_prints_and_updates_best(monkeypatch, capsys):
- import easydiffraction.analysis.fit_helpers.tracking as tracking_mod
+ import easydiffraction.display.progress as progress_mod
from easydiffraction.analysis.fit_helpers.tracking import FitProgressTracker
- # Force terminal branch (not notebook): tracking imports in_jupyter directly
- monkeypatch.setattr(tracking_mod, 'in_jupyter', lambda: False)
+ # Force terminal branch (not notebook) in the shared progress layer.
+ monkeypatch.setattr(progress_mod, 'in_jupyter', lambda: False)
tracker = FitProgressTracker()
tracker.start_tracking('dummy')
@@ -43,3 +43,37 @@ def test_tracker_terminal_flow_prints_and_updates_best(monkeypatch, capsys):
out2 = capsys.readouterr().out
assert 'Best goodness-of-fit' in out2
assert tracker.best_iteration is not None
+
+
+def test_tracker_fit_adds_timed_rows_and_resets_counter(monkeypatch):
+ import easydiffraction.analysis.fit_helpers.tracking as tracking_mod
+ from easydiffraction.analysis.fit_helpers.tracking import FitProgressTracker
+
+ chi2_values = iter([5.0, 4.0, 3.97, 3.97, 3.97, 3.97])
+ perf_counter_values = iter([0.0, 0.0, 2.0, 6.9, 7.1, 11.9, 12.2])
+
+ monkeypatch.setattr(
+ tracking_mod,
+ 'calculate_reduced_chi_square',
+ lambda residuals, n_parameters: next(chi2_values),
+ )
+ monkeypatch.setattr(
+ tracking_mod.time,
+ 'perf_counter',
+ lambda: next(perf_counter_values),
+ )
+
+ tracker = FitProgressTracker()
+ tracker.start_timer()
+
+ for _ in range(6):
+ tracker.track(np.array([1.0]), parameters=[1.0])
+
+ assert tracker._df_rows == [
+ ['1', '0.00', '5.00', ''],
+ ['2', '2.00', '4.00', '20.0% ↓'],
+ ['4', '7.10', '3.97', ''],
+ ['6', '12.20', '3.97', ''],
+ ]
+ assert tracker._last_progress_time == 12.2
+ assert tracker._previous_chi2 == 4.0
diff --git a/tests/unit/easydiffraction/analysis/minimizers/test_base.py b/tests/unit/easydiffraction/analysis/minimizers/test_base.py
index 0ad0ca127..3b18f5354 100644
--- a/tests/unit/easydiffraction/analysis/minimizers/test_base.py
+++ b/tests/unit/easydiffraction/analysis/minimizers/test_base.py
@@ -158,3 +158,46 @@ def _check_success(self, raw_result):
assert minimizer.started is True
assert minimizer.stopped is True
+
+
+def test_minimizer_base_fit_preserves_solver_prep_error_during_cleanup(monkeypatch):
+ import easydiffraction.analysis.fit_helpers.tracking as tracking_mod
+ from easydiffraction.analysis.minimizers.base import MinimizerBase
+
+ monkeypatch.setattr(
+ tracking_mod.FitProgressTracker,
+ '_start_activity_indicator',
+ lambda self: None,
+ )
+ monkeypatch.setattr(
+ tracking_mod.FitProgressTracker,
+ '_stop_activity_indicator',
+ lambda self: None,
+ )
+ monkeypatch.setattr(tracking_mod.console, 'print', lambda *args, **kwargs: None)
+
+ class M(MinimizerBase):
+ def __init__(self):
+ super().__init__(name='dummy', method='m', max_iterations=5)
+
+ def _prepare_solver_args(self, parameters):
+ del parameters
+ msg = 'prep failed'
+ raise ValueError(msg)
+
+ def _run_solver(self, objective_function, **kwargs):
+ del objective_function, kwargs
+ msg = 'should not run solver'
+ raise AssertionError(msg)
+
+ def _sync_result_to_parameters(self, parameters, raw_result):
+ del parameters, raw_result
+
+ def _check_success(self, raw_result):
+ del raw_result
+ return True
+
+ minimizer = M()
+
+ with pytest.raises(ValueError, match='prep failed'):
+ minimizer.fit(parameters=[_DummyParam(1.0)], objective_function=lambda _: np.array([0.0]))
diff --git a/tests/unit/easydiffraction/analysis/test_analysis.py b/tests/unit/easydiffraction/analysis/test_analysis.py
index c5b2ef248..637facc02 100644
--- a/tests/unit/easydiffraction/analysis/test_analysis.py
+++ b/tests/unit/easydiffraction/analysis/test_analysis.py
@@ -1,6 +1,8 @@
# SPDX-FileCopyrightText: 2025 EasyScience contributors
# SPDX-License-Identifier: BSD-3-Clause
+from types import SimpleNamespace
+
def test_module_import():
import easydiffraction.analysis.analysis as MUT
@@ -132,3 +134,96 @@ def values(self):
a.display.fit_results()
assert process_called['called'], '_process_fit_results should be called'
+
+
+def test_fit_single_short_reuses_tracker_display_handle(monkeypatch):
+ from easydiffraction.analysis.analysis import Analysis
+ from easydiffraction.utils.enums import VerbosityEnum
+
+ class Handle:
+ def __init__(self) -> None:
+ self.closed = False
+
+ def close(self) -> None:
+ self.closed = True
+
+ class Experiments:
+ def __init__(self) -> None:
+ self.names = ['e1']
+
+ def __getitem__(self, name: str) -> object:
+ del name
+ return object()
+
+ class Tracker:
+ def __init__(self) -> None:
+ self.best_iteration = 7
+ self.display_handles: list[object | None] = []
+
+ def _set_shared_display_handle(self, display_handle: object | None) -> None:
+ self.display_handles.append(display_handle)
+
+ project = SimpleNamespace(
+ experiments=Experiments(),
+ structures=object(),
+ _varname='proj',
+ )
+ analysis = Analysis(project=project)
+ tracker = Tracker()
+ analysis.fitter.minimizer = SimpleNamespace(tracker=tracker)
+ analysis.fitter.results = None
+
+ handle = Handle()
+ short_display_handles: list[object | None] = []
+
+ def fake_make_display_handle() -> Handle:
+ return handle
+
+ def fake_fit(
+ structures: object,
+ experiments: list[object],
+ *,
+ analysis: object,
+ verbosity: object,
+ use_physical_limits: bool,
+ random_seed: int | None,
+ ) -> None:
+ del structures, experiments, analysis, verbosity, use_physical_limits, random_seed
+ analysis_obj = fake_fit.analysis_obj
+ analysis_obj.fitter.results = SimpleNamespace(
+ reduced_chi_square=1.23,
+ success=True,
+ parameters=[],
+ )
+
+ fake_fit.analysis_obj = analysis
+
+ def fake_update_short_table(
+ self,
+ short_rows: list[list[str]],
+ expt_name: str,
+ results: object,
+ display_handle: object | None,
+ ) -> None:
+ del self, expt_name, results
+ short_rows.append(['e1', '1.23', '7', '✅'])
+ short_display_handles.append(display_handle)
+
+ monkeypatch.setattr(
+ 'easydiffraction.analysis.analysis.make_display_handle', fake_make_display_handle
+ )
+ monkeypatch.setattr(analysis, '_snapshot_params', lambda expt_name, results: None)
+ monkeypatch.setattr(analysis.fitter, 'fit', fake_fit)
+ monkeypatch.setattr(Analysis, '_fit_single_update_short_table', fake_update_short_table)
+
+ analysis._fit_single(
+ VerbosityEnum.SHORT,
+ project.structures,
+ project.experiments,
+ use_physical_limits=False,
+ random_seed=None,
+ )
+
+ assert tracker.display_handles == [handle, None]
+ assert short_display_handles == [handle]
+ assert handle.closed is True
diff --git a/tests/unit/easydiffraction/analysis/test_sequential.py b/tests/unit/easydiffraction/analysis/test_sequential.py
index 3179a0b13..5e01b586d 100644
--- a/tests/unit/easydiffraction/analysis/test_sequential.py
+++ b/tests/unit/easydiffraction/analysis/test_sequential.py
@@ -4,7 +4,9 @@
from __future__ import annotations
+import contextlib
import csv
+from types import SimpleNamespace
import pytest
@@ -14,6 +16,8 @@
from easydiffraction.analysis.sequential import _build_csv_header
from easydiffraction.analysis.sequential import _read_csv_for_recovery
from easydiffraction.analysis.sequential import _write_csv_header
+from easydiffraction.display.progress import ACTIVITY_LABEL_FITTING
+from easydiffraction.utils.enums import VerbosityEnum
# ------------------------------------------------------------------
@@ -300,3 +304,130 @@ def test_fields_accessible(self):
assert template.diffrn_field_names == ['temp']
assert template.minimizer_tag == 'lmfit'
assert template.calculator_tag == 'cryspy'
+
+
+def test_fit_sequential_short_starts_and_stops_shared_indicator(monkeypatch, tmp_path):
+ import easydiffraction.analysis.sequential as sequential_mod
+
+ events: list[tuple[object, ...]] = []
+ template = _minimal_template()
+
+ class FakeIndicator:
+ def __init__(self, label, *, verbosity):
+ events.append(('init', label, verbosity))
+
+ def start(self):
+ events.append(('start',))
+
+ def update(self):
+ events.append(('update',))
+
+ def stop(self):
+ events.append(('stop',))
+
+ def fake_run_fit_loop(
+ pool_cm, chunks, template_arg, csv_info, extract_diffrn, verb, indicator
+ ):
+ del pool_cm, csv_info, extract_diffrn
+ assert chunks == [['scan_001.xye']]
+ assert template_arg == template
+ assert verb is VerbosityEnum.SHORT
+ assert indicator is not None
+ indicator.update()
+
+ monkeypatch.setattr(sequential_mod, 'ActivityIndicator', FakeIndicator)
+ monkeypatch.setattr(sequential_mod.mp, 'parent_process', lambda: None)
+ monkeypatch.setattr(sequential_mod, '_check_seq_preconditions', lambda project: None)
+ monkeypatch.setattr(
+ sequential_mod,
+ 'extract_data_paths_from_dir',
+ lambda data_dir, file_pattern='*': ['scan_001.xye'],
+ )
+ monkeypatch.setattr(sequential_mod, '_build_template', lambda project: template)
+ monkeypatch.setattr(
+ sequential_mod,
+ '_setup_csv_and_recovery',
+ lambda project, template_arg, verb: (
+ tmp_path / 'results.csv',
+ ['file_path'],
+ set(),
+ template_arg,
+ ),
+ )
+ monkeypatch.setattr(sequential_mod, '_resolve_workers', lambda max_workers, chunk_size: (1, 1))
+ monkeypatch.setattr(
+ sequential_mod,
+ '_create_pool_context',
+ lambda max_workers: (contextlib.nullcontext(None), None, None, None),
+ )
+ monkeypatch.setattr(sequential_mod, '_run_fit_loop', fake_run_fit_loop)
+ monkeypatch.setattr(sequential_mod, '_restore_main_state', lambda *args: None)
+
+ analysis = SimpleNamespace(
+ project=SimpleNamespace(verbosity='short'),
+ fitter=SimpleNamespace(selection='lmfit'),
+ )
+
+ sequential_mod.fit_sequential(analysis, data_dir=str(tmp_path))
+
+ assert events == [
+ ('init', ACTIVITY_LABEL_FITTING, VerbosityEnum.SHORT),
+ ('start',),
+ ('update',),
+ ('stop',),
+ ]
+
+
+def test_fit_sequential_silent_does_not_start_indicator(monkeypatch, tmp_path):
+ import easydiffraction.analysis.sequential as sequential_mod
+
+ template = _minimal_template()
+
+ class FailingIndicator:
+ def __init__(self, *args, **kwargs):
+ message = 'silent mode should not create an activity indicator'
+ raise AssertionError(message)
+
+ def fake_run_fit_loop(
+ pool_cm, chunks, template_arg, csv_info, extract_diffrn, verb, indicator
+ ):
+ del pool_cm, csv_info, extract_diffrn
+ assert chunks == [['scan_001.xye']]
+ assert template_arg == template
+ assert verb is VerbosityEnum.SILENT
+ assert indicator is None
+
+ monkeypatch.setattr(sequential_mod, 'ActivityIndicator', FailingIndicator)
+ monkeypatch.setattr(sequential_mod.mp, 'parent_process', lambda: None)
+ monkeypatch.setattr(sequential_mod, '_check_seq_preconditions', lambda project: None)
+ monkeypatch.setattr(
+ sequential_mod,
+ 'extract_data_paths_from_dir',
+ lambda data_dir, file_pattern='*': ['scan_001.xye'],
+ )
+ monkeypatch.setattr(sequential_mod, '_build_template', lambda project: template)
+ monkeypatch.setattr(
+ sequential_mod,
+ '_setup_csv_and_recovery',
+ lambda project, template_arg, verb: (
+ tmp_path / 'results.csv',
+ ['file_path'],
+ set(),
+ template_arg,
+ ),
+ )
+ monkeypatch.setattr(sequential_mod, '_resolve_workers', lambda max_workers, chunk_size: (1, 1))
+ monkeypatch.setattr(
+ sequential_mod,
+ '_create_pool_context',
+ lambda max_workers: (contextlib.nullcontext(None), None, None, None),
+ )
+ monkeypatch.setattr(sequential_mod, '_run_fit_loop', fake_run_fit_loop)
+ monkeypatch.setattr(sequential_mod, '_restore_main_state', lambda *args: None)
+
+ analysis = SimpleNamespace(
+ project=SimpleNamespace(verbosity='silent'),
+ fitter=SimpleNamespace(selection='lmfit'),
+ )
+
+ sequential_mod.fit_sequential(analysis, data_dir=str(tmp_path))
diff --git a/tests/unit/easydiffraction/display/plotters/test_plotly.py b/tests/unit/easydiffraction/display/plotters/test_plotly.py
index 5c3c8dd50..5b5836229 100644
--- a/tests/unit/easydiffraction/display/plotters/test_plotly.py
+++ b/tests/unit/easydiffraction/display/plotters/test_plotly.py
@@ -828,7 +828,9 @@ def fake_show_figure(self, fig):
)
fig = captured['fig']
- predictive_band_trace = next(trace for trace in fig.data if trace.name == '95% interval')
+ predictive_band_trace = next(
+ trace for trace in fig.data if trace.name == '95% credible interval'
+ )
max_posterior_trace = next(trace for trace in fig.data if trace.name == 'Max posterior')
residual_trace = next(trace for trace in fig.data if trace.name == 'Residual (Imeas - Icalc)')
diff --git a/tests/unit/easydiffraction/display/tablers/test_pandas.py b/tests/unit/easydiffraction/display/tablers/test_pandas.py
index 2a2828a9c..e4a77c5b6 100644
--- a/tests/unit/easydiffraction/display/tablers/test_pandas.py
+++ b/tests/unit/easydiffraction/display/tablers/test_pandas.py
@@ -33,3 +33,15 @@ def test_apply_styling_returns_styler(self):
df = pd.DataFrame({'A': [1.0], 'B': [2.0]})
styler = backend._apply_styling(df, ['left', 'right'], '#aabbcc')
assert hasattr(styler, 'to_html')
+
+ def test_build_renderable_returns_html(self):
+ from easydiffraction.display.tablers.pandas import PandasTableBackend
+
+ pytest.importorskip('jinja2')
+ backend = PandasTableBackend()
+ df = pd.DataFrame({'A': [1.0], 'B': [2.0]})
+
+ html = backend.build_renderable(['left', 'right'], df)
+
+ assert isinstance(html, str)
+ assert '= {
'Posterior histogram',
'Marginal density',
- '68% credible interval',
'95% credible interval',
'Median',
'Max posterior',
}
marginal_trace = next(trace for trace in figure.data if trace.name == 'Marginal density')
histogram_trace = next(trace for trace in figure.data if trace.name == 'Posterior histogram')
- interval_68_trace = next(
- trace for trace in figure.data if trace.name == '68% credible interval'
- )
interval_trace = next(trace for trace in figure.data if trace.name == '95% credible interval')
max_posterior_trace = next(trace for trace in figure.data if trace.name == 'Max posterior')
assert marginal_trace.line.color == POSTERIOR_PAIR_MARGINAL_DENSITY_LINE_COLOR
@@ -640,7 +635,7 @@ def test_build_param_distribution_plot_returns_plotly_figure():
assert marginal_trace.fillcolor == POSTERIOR_PAIR_MARGINAL_DENSITY_FILL_COLOR
assert marginal_trace.hovertemplate == 'length_a: %{x:.4f}
density: %{y:.4f} '
assert histogram_trace.xbins.size is not None
- assert interval_68_trace.fillcolor == POSTERIOR_INTERVAL_68_FILL_COLOR
+ assert '68% credible interval' not in {trace.name for trace in figure.data}
assert interval_trace.fillcolor == POSTERIOR_INTERVAL_95_FILL_COLOR
assert max_posterior_trace.line.dash == POSTERIOR_POINT_ESTIMATE_LINE_DASH
assert figure.layout.xaxis.range is not None
@@ -685,7 +680,7 @@ def test_plot_posterior_predictive_summary_uses_consistent_labels_and_styles(mon
measured_trace = next(trace for trace in fig.data if trace.name == 'Measured')
max_posterior_trace = next(trace for trace in fig.data if trace.name == 'Max posterior')
- assert upper_band_trace.name == '95% interval'
+ assert upper_band_trace.name == '95% credible interval'
assert upper_band_trace.fillcolor == POSTERIOR_INTERVAL_95_FILL_COLOR
assert upper_band_trace.legendrank == 30
assert measured_trace.legendrank == 10
diff --git a/tests/unit/easydiffraction/display/test_progress.py b/tests/unit/easydiffraction/display/test_progress.py
new file mode 100644
index 000000000..f690159ed
--- /dev/null
+++ b/tests/unit/easydiffraction/display/test_progress.py
@@ -0,0 +1,210 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""Tests for display/progress.py."""
+
+from __future__ import annotations
+
+from easydiffraction.utils.enums import VerbosityEnum
+
+
+def test_make_display_handle_uses_terminal_live_when_available(monkeypatch):
+ import easydiffraction.display.progress as progress_mod
+
+ class FakeLive:
+ def __init__(
+ self,
+ renderable=None,
+ *,
+ console,
+ auto_refresh,
+ refresh_per_second,
+ get_renderable=None,
+ ):
+ self.renderable = renderable
+ self.console = console
+ self.auto_refresh = auto_refresh
+ self.refresh_per_second = refresh_per_second
+ self.get_renderable = get_renderable
+ self.started = False
+ self.stopped = False
+ self.refresh_calls = 0
+
+ def start(self):
+ self.started = True
+
+ def stop(self):
+ self.stopped = True
+
+ def refresh(self):
+ self.refresh_calls += 1
+
+ monkeypatch.setattr(progress_mod, 'in_jupyter', lambda: False)
+ monkeypatch.setattr(progress_mod, 'Live', FakeLive)
+ monkeypatch.setattr(progress_mod.ConsoleManager, 'get', lambda: 'console')
+
+ handle = progress_mod.make_display_handle()
+
+ assert isinstance(handle, progress_mod._TerminalLiveHandle)
+ assert handle._live.console == 'console'
+ assert handle._live.auto_refresh is True
+ assert handle._live.get_renderable is not None
+ assert handle._live.started is True
+
+ handle.update('content')
+
+ assert handle._live.refresh_calls == 1
+ assert handle._live.get_renderable() == 'content'
+
+ handle.close()
+
+ assert handle._live.stopped is True
+
+
+def test_activity_indicator_silent_does_not_create_handles():
+ from easydiffraction.display.progress import ActivityIndicator
+
+ indicator = ActivityIndicator(verbosity=VerbosityEnum.SILENT)
+ indicator.start()
+
+ assert indicator._live is None
+ assert indicator._display_handle is None
+
+
+def test_activity_indicator_html_style_prevents_stretching():
+ import easydiffraction.display.progress as progress_mod
+
+ indicator = progress_mod.ActivityIndicator(verbosity=VerbosityEnum.FULL)
+ style = indicator._html_style()
+
+ assert 'align-items: flex-start;' in style
+ assert progress_mod.ACTIVITY_ACCENT_COLOR in style
+ assert 'font-weight: 400;' in style
+ assert 'var(--jp-ui-font-family' in style
+
+
+def test_activity_indicator_terminal_line_uses_accent_style():
+ import easydiffraction.display.progress as progress_mod
+
+ indicator = progress_mod.ActivityIndicator(label='Fitting...', verbosity=VerbosityEnum.FULL)
+ indicator._running = True
+
+ line = indicator._terminal_indicator_line()
+
+ assert line is not None
+ assert line.style == progress_mod.ACTIVITY_TERMINAL_STYLE
+ assert 'bold' not in str(line.style)
+
+
+def test_activity_indicator_terminal_live_uses_dynamic_renderable(monkeypatch):
+ import easydiffraction.display.progress as progress_mod
+
+ class FakeLive:
+ def __init__(
+ self,
+ renderable=None,
+ *,
+ console,
+ auto_refresh,
+ refresh_per_second,
+ get_renderable=None,
+ ):
+ self.renderable = renderable
+ self.console = console
+ self.auto_refresh = auto_refresh
+ self.refresh_per_second = refresh_per_second
+ self.get_renderable = get_renderable
+ self.refresh_calls = 0
+ self.started = False
+
+ def start(self):
+ self.started = True
+
+ def stop(self):
+ self.started = False
+
+ def refresh(self):
+ self.refresh_calls += 1
+
+ monkeypatch.setattr(progress_mod, 'in_jupyter', lambda: False)
+ monkeypatch.setattr(progress_mod, 'Live', FakeLive)
+ monkeypatch.setattr(progress_mod.ConsoleManager, 'get', lambda: 'console')
+
+ indicator = progress_mod.ActivityIndicator(label='Fitting...', verbosity=VerbosityEnum.FULL)
+ indicator.start()
+ indicator._current_frame = lambda: 'X'
+
+ assert indicator._live is not None
+ assert indicator._live.get_renderable is not None
+ assert indicator._live.renderable is None
+ assert indicator._live.get_renderable().plain == 'X Fitting...'
+
+ indicator.update(label='Sampling...')
+
+ assert indicator._live.refresh_calls == 1
+ assert indicator._live.get_renderable().plain == 'X Sampling...'
+
+
+def test_activity_indicator_render_html_uses_current_label():
+ from easydiffraction.display.progress import ActivityIndicator
+
+ indicator = ActivityIndicator(label='Fitting...', verbosity=VerbosityEnum.FULL)
+ indicator._running = True
+
+ html = indicator._render_html()
+
+ assert 'Fitting...' in html
+
+
+def test_activity_indicator_updates_shared_terminal_handle_without_ipython(monkeypatch):
+ import easydiffraction.display.progress as progress_mod
+
+ class FakeLive:
+ def __init__(
+ self,
+ renderable=None,
+ *,
+ console,
+ auto_refresh,
+ refresh_per_second,
+ get_renderable=None,
+ ):
+ self.renderable = renderable
+ self.console = console
+ self.auto_refresh = auto_refresh
+ self.refresh_per_second = refresh_per_second
+ self.get_renderable = get_renderable
+ self.refresh_calls = 0
+ self.started = False
+
+ def start(self):
+ self.started = True
+
+ def stop(self):
+ self.started = False
+
+ def refresh(self):
+ self.refresh_calls += 1
+
+ monkeypatch.setattr(progress_mod, 'in_jupyter', lambda: False)
+ monkeypatch.setattr(progress_mod, 'HTML', None)
+ monkeypatch.setattr(progress_mod, 'DisplayHandle', None)
+ monkeypatch.setattr(progress_mod, 'Live', FakeLive)
+ monkeypatch.setattr(progress_mod.ConsoleManager, 'get', lambda: 'console')
+
+ handle = progress_mod.make_display_handle()
+ indicator = progress_mod.ActivityIndicator(
+ label='Fitting...',
+ verbosity=VerbosityEnum.FULL,
+ display_handle=handle,
+ )
+ indicator._current_frame = lambda: 'X'
+
+ indicator.start()
+
+ assert handle._live.refresh_calls == 1
+ assert handle._live.get_renderable().plain == 'X Fitting...'
+
+ indicator.update(label='Sampling...')
+
+ assert handle._live.refresh_calls == 2
+ assert handle._live.get_renderable().plain == 'X Sampling...'
diff --git a/tests/unit/easydiffraction/display/test_tables.py b/tests/unit/easydiffraction/display/test_tables.py
index 2fb16fe13..9920e7e12 100644
--- a/tests/unit/easydiffraction/display/test_tables.py
+++ b/tests/unit/easydiffraction/display/test_tables.py
@@ -2,6 +2,8 @@
# SPDX-License-Identifier: BSD-3-Clause
"""Tests for display/tables.py (TableEngineEnum, TableRenderer, TableRendererFactory)."""
+from types import SimpleNamespace
+
import pandas as pd
@@ -59,3 +61,30 @@ def test_render(self, monkeypatch, capsys):
# Reset singleton to not leak state
monkeypatch.setattr(TableRenderer, '_instance', None)
+
+ def test_build_renderable_normalizes_dataframe(self, monkeypatch):
+ from easydiffraction.display.tables import TableRenderer
+
+ monkeypatch.setattr(TableRenderer, '_instance', None)
+
+ headers = [('Col', 'left')]
+ df = pd.DataFrame([['val']], columns=pd.MultiIndex.from_tuples(headers))
+ calls: dict[str, object] = {}
+
+ def fake_build_renderable(alignments, prepared_df):
+ calls['alignments'] = list(alignments)
+ calls['columns'] = list(prepared_df.columns)
+ calls['index'] = list(prepared_df.index)
+ return 'renderable'
+
+ renderer = TableRenderer.get()
+ renderer._backend = SimpleNamespace(build_renderable=fake_build_renderable)
+
+ assert renderer.build_renderable(df) == 'renderable'
+ assert calls == {
+ 'alignments': ['left'],
+ 'columns': ['Col'],
+ 'index': [1],
+ }
+
+ monkeypatch.setattr(TableRenderer, '_instance', None)
diff --git a/tests/unit/easydiffraction/project/test_display.py b/tests/unit/easydiffraction/project/test_display.py
index 8e8f7695d..b67d7c022 100644
--- a/tests/unit/easydiffraction/project/test_display.py
+++ b/tests/unit/easydiffraction/project/test_display.py
@@ -4,15 +4,18 @@
from __future__ import annotations
+from contextlib import contextmanager
from types import SimpleNamespace
import pytest
from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum
from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum
+from easydiffraction.display.progress import ACTIVITY_LABEL_PROCESSING
from easydiffraction.display.plotting import _MeasVsCalcPlotOptions
from easydiffraction.project.display import PatternOptionStatus
from easydiffraction.project.display import ProjectDisplay
+from easydiffraction.utils.enums import VerbosityEnum
def _make_project_stub() -> tuple[SimpleNamespace, list[tuple[str, tuple, dict]]]:
@@ -47,6 +50,7 @@ def _recorder(*args, **kwargs):
project = SimpleNamespace(
analysis=SimpleNamespace(display=analysis_display),
rendering=SimpleNamespace(plotter=plotter),
+ verbosity='full',
)
return project, calls
@@ -207,9 +211,19 @@ def test_fit_display_delegates_to_analysis_and_rendering():
)
-def test_posterior_display_delegates_to_rendering_plotter():
+def test_posterior_display_delegates_to_rendering_plotter(monkeypatch):
+ import easydiffraction.project.display as display_mod
+
project, calls = _make_project_stub()
display = ProjectDisplay(project)
+ indicator_calls: list[tuple[str, VerbosityEnum]] = []
+
+ @contextmanager
+ def fake_activity_indicator(label, *, verbosity):
+ indicator_calls.append((label, verbosity))
+ yield object()
+
+ monkeypatch.setattr(display_mod, 'activity_indicator', fake_activity_indicator)
display.posterior.pairs(parameters=['a'], threshold=0.5, max_parameters=3)
display.posterior.distribution('a')
@@ -239,6 +253,10 @@ def test_posterior_display_delegates_to_rendering_plotter():
'x': 'd_spacing',
},
)
+ assert indicator_calls == [
+ (ACTIVITY_LABEL_PROCESSING, VerbosityEnum.FULL),
+ (ACTIVITY_LABEL_PROCESSING, VerbosityEnum.FULL),
+ ]
def test_pattern_auto_routes_measured_and_excluded_to_plot_meas():
@@ -266,7 +284,9 @@ def test_pattern_auto_routes_measured_and_excluded_to_plot_meas():
]
-def test_pattern_uncertainty_routes_to_posterior_predictive():
+def test_pattern_uncertainty_routes_to_posterior_predictive(monkeypatch):
+ import easydiffraction.project.display as display_mod
+
project, calls = _make_project_stub()
display = ProjectDisplay(project)
display._pattern_option_statuses = lambda expt_name: _make_statuses(
@@ -276,6 +296,14 @@ def test_pattern_uncertainty_routes_to_posterior_predictive():
excluded=True,
uncertainty=True,
)
+ indicator_calls: list[tuple[str, VerbosityEnum]] = []
+
+ @contextmanager
+ def fake_activity_indicator(label, *, verbosity):
+ indicator_calls.append((label, verbosity))
+ yield object()
+
+ monkeypatch.setattr(display_mod, 'activity_indicator', fake_activity_indicator)
display.pattern(
'hrpt',
@@ -303,6 +331,7 @@ def test_pattern_uncertainty_routes_to_posterior_predictive():
},
)
]
+ assert indicator_calls == [(ACTIVITY_LABEL_PROCESSING, VerbosityEnum.FULL)]
def test_pattern_measured_and_calculated_suppresses_background_and_bragg():