diff --git a/docs/dev/issues/closed/cli-fit-command-never-saves-results-to-disk.md b/docs/dev/issues/closed/cli-fit-command-never-saves-results-to-disk.md index 911a21ea2..aa204278a 100644 --- a/docs/dev/issues/closed/cli-fit-command-never-saves-results-to-disk.md +++ b/docs/dev/issues/closed/cli-fit-command-never-saves-results-to-disk.md @@ -1,10 +1,8 @@ # 137. CLI `fit` Command Never Saves Results to Disk -Closed on re-verification — **not a defect**. `Analysis.fit()` -auto-saves via `self.project.save()` in `_run_single` / `_run_joint` / -`_run_sequential` (`analysis.py:2661,2688,2727`) whenever the project -path is set. The CLI `fit` command's non-`--dry` path leaves -`project.info.path` set, so results persist; `--dry` nulls the path to -suppress saving (asserted by `test_cli_fit_dry_clears_path`). The -original audit finding read the CLI in isolation and missed `fit()`'s -internal save; no code change is needed. +Closed after fixing the CLI persistence boundary. `Analysis.fit()` +updates fit state without saving the project. The CLI `fit` command now +calls `project.save()` explicitly after a successful non-`--dry` fit, so +updated results still persist. With `--dry`, the project path is cleared +for the fit and the explicit save is skipped, preventing project files +and fit sidecars from being overwritten. diff --git a/docs/dev/issues/open/lowest_live-notebook-plotly-delivery-loader-vs-native-mimetype.md b/docs/dev/issues/open/lowest_live-notebook-plotly-delivery-loader-vs-native-mimetype.md index a19c5481a..f3b70b402 100644 --- a/docs/dev/issues/open/lowest_live-notebook-plotly-delivery-loader-vs-native-mimetype.md +++ b/docs/dev/issues/open/lowest_live-notebook-plotly-delivery-loader-vs-native-mimetype.md @@ -41,7 +41,16 @@ gap. Two ways to fix it: (the loader only needs Plotly once it renders), then calls the same `renderSpec` entry point. `google.colab.output.pauseOutputUntil` holds the output frame until the figure — or a visible error message — is on - screen. Concurrent outputs in one cell share frame-global loading + screen. Colab pauses outputframe auto-resizing (and the cell's later + outputs) while that promise is pending, so the bootstrap asks for a + remeasure just after it resolves; otherwise the height taken before + the plot replaced its placeholder stays as blank space below the + chart. That request is scheduled on the next paint with a timer + backstop, because a hidden browser tab never paints. Where that API is + absent, the fallback measures Colab's output area — every output of + the cell, so siblings are not clipped — rather than the root scroll + height, which is clamped to the frame viewport and so can only grow + it. Concurrent outputs in one cell share frame-global loading promises, so each asset is fetched and parsed only once. An immutable, exact-release-tag jsDelivr URL is the fallback while a new documentation version is deploying or if that deployment fails. Dev diff --git a/docs/docs/tutorials/bayesian-dream-lbco-hrpt.ipynb b/docs/docs/tutorials/bayesian-dream-lbco-hrpt.ipynb index 5118d3721..eda8e2ebb 100644 --- a/docs/docs/tutorials/bayesian-dream-lbco-hrpt.ipynb +++ b/docs/docs/tutorials/bayesian-dream-lbco-hrpt.ipynb @@ -769,6 +769,26 @@ "source": [ "project.display.posterior.predictive(expt_name='hrpt', x_min=92, x_max=93)" ] + }, + { + "cell_type": "markdown", + "id": "63", + "metadata": {}, + "source": [ + "## 💾 Save Project\n", + "\n", + "Save the fitted parameters and analysis results." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "64", + "metadata": {}, + "outputs": [], + "source": [ + "project.save()" + ] } ], "metadata": { diff --git a/docs/docs/tutorials/bayesian-dream-lbco-hrpt.py b/docs/docs/tutorials/bayesian-dream-lbco-hrpt.py index 504d397cc..10fb7a5c9 100644 --- a/docs/docs/tutorials/bayesian-dream-lbco-hrpt.py +++ b/docs/docs/tutorials/bayesian-dream-lbco-hrpt.py @@ -362,3 +362,11 @@ # %% project.display.posterior.predictive(expt_name='hrpt', x_min=92, x_max=93) + +# %% [markdown] +# ## 💾 Save Project +# +# Save the fitted parameters and analysis results. + +# %% +project.save() diff --git a/docs/docs/tutorials/bayesian-emcee-lbco-hrpt.ipynb b/docs/docs/tutorials/bayesian-emcee-lbco-hrpt.ipynb index a34779094..ecaff5c57 100644 --- a/docs/docs/tutorials/bayesian-emcee-lbco-hrpt.ipynb +++ b/docs/docs/tutorials/bayesian-emcee-lbco-hrpt.ipynb @@ -755,6 +755,26 @@ "source": [ "project.display.posterior.predictive(expt_name='hrpt', x_min=92, x_max=93)" ] + }, + { + "cell_type": "markdown", + "id": "62", + "metadata": {}, + "source": [ + "## 💾 Save Project\n", + "\n", + "Save the fitted parameters and analysis results." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "63", + "metadata": {}, + "outputs": [], + "source": [ + "project.save()" + ] } ], "metadata": { diff --git a/docs/docs/tutorials/bayesian-emcee-lbco-hrpt.py b/docs/docs/tutorials/bayesian-emcee-lbco-hrpt.py index 6d44aeaf5..c5cde4ff4 100644 --- a/docs/docs/tutorials/bayesian-emcee-lbco-hrpt.py +++ b/docs/docs/tutorials/bayesian-emcee-lbco-hrpt.py @@ -355,3 +355,11 @@ # %% project.display.posterior.predictive(expt_name='hrpt', x_min=92, x_max=93) + +# %% [markdown] +# ## 💾 Save Project +# +# Save the fitted parameters and analysis results. + +# %% +project.save() diff --git a/docs/docs/tutorials/bayesian-emcee-tbti-heidi.ipynb b/docs/docs/tutorials/bayesian-emcee-tbti-heidi.ipynb index 24114daea..b69083dba 100644 --- a/docs/docs/tutorials/bayesian-emcee-tbti-heidi.ipynb +++ b/docs/docs/tutorials/bayesian-emcee-tbti-heidi.ipynb @@ -621,6 +621,26 @@ "source": [ "project.display.posterior.predictive(expt_name='heidi')" ] + }, + { + "cell_type": "markdown", + "id": "53", + "metadata": {}, + "source": [ + "## 💾 Save Project\n", + "\n", + "Save the fitted parameters and analysis results." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "54", + "metadata": {}, + "outputs": [], + "source": [ + "project.save()" + ] } ], "metadata": { diff --git a/docs/docs/tutorials/bayesian-emcee-tbti-heidi.py b/docs/docs/tutorials/bayesian-emcee-tbti-heidi.py index d08514a3f..30079d6fa 100644 --- a/docs/docs/tutorials/bayesian-emcee-tbti-heidi.py +++ b/docs/docs/tutorials/bayesian-emcee-tbti-heidi.py @@ -276,3 +276,11 @@ # %% project.display.posterior.predictive(expt_name='heidi') + +# %% [markdown] +# ## 💾 Save Project +# +# Save the fitted parameters and analysis results. + +# %% +project.save() diff --git a/docs/docs/tutorials/calibrate-beer-ess.ipynb b/docs/docs/tutorials/calibrate-beer-ess.ipynb index 34f97b65a..be1b4f1a5 100644 --- a/docs/docs/tutorials/calibrate-beer-ess.ipynb +++ b/docs/docs/tutorials/calibrate-beer-ess.ipynb @@ -785,6 +785,26 @@ " x_max=2.13,\n", ")" ] + }, + { + "cell_type": "markdown", + "id": "67", + "metadata": {}, + "source": [ + "## 💾 Save Project\n", + "\n", + "Save the fitted parameters and analysis results." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "68", + "metadata": {}, + "outputs": [], + "source": [ + "project.save()" + ] } ], "metadata": { diff --git a/docs/docs/tutorials/calibrate-beer-ess.py b/docs/docs/tutorials/calibrate-beer-ess.py index d5bf653e3..e5818ab81 100644 --- a/docs/docs/tutorials/calibrate-beer-ess.py +++ b/docs/docs/tutorials/calibrate-beer-ess.py @@ -350,3 +350,11 @@ x_min=2.08, x_max=2.13, ) + +# %% [markdown] +# ## 💾 Save Project +# +# Save the fitted parameters and analysis results. + +# %% +project.save() diff --git a/docs/docs/tutorials/refine-cosio-d20-tscan.ipynb b/docs/docs/tutorials/refine-cosio-d20-tscan.ipynb index 5a40faa65..d597a9d4b 100644 --- a/docs/docs/tutorials/refine-cosio-d20-tscan.ipynb +++ b/docs/docs/tutorials/refine-cosio-d20-tscan.ipynb @@ -870,6 +870,26 @@ "display.fit.series(struct.atom_sites['O2'].fract_z, versus=temperature)\n", "display.fit.series(struct.atom_sites['O3'].fract_z, versus=temperature)" ] + }, + { + "cell_type": "markdown", + "id": "75", + "metadata": {}, + "source": [ + "## 💾 Save Project\n", + "\n", + "Save the fitted parameters and analysis results." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "76", + "metadata": {}, + "outputs": [], + "source": [ + "project.save()" + ] } ], "metadata": { diff --git a/docs/docs/tutorials/refine-cosio-d20-tscan.py b/docs/docs/tutorials/refine-cosio-d20-tscan.py index 7aad643de..b060aefa0 100644 --- a/docs/docs/tutorials/refine-cosio-d20-tscan.py +++ b/docs/docs/tutorials/refine-cosio-d20-tscan.py @@ -404,3 +404,11 @@ display.fit.series(struct.atom_sites['O1'].fract_z, versus=temperature) display.fit.series(struct.atom_sites['O2'].fract_z, versus=temperature) display.fit.series(struct.atom_sites['O3'].fract_z, versus=temperature) + +# %% [markdown] +# ## 💾 Save Project +# +# Save the fitted parameters and analysis results. + +# %% +project.save() diff --git a/docs/docs/tutorials/refine-cosio-d20.ipynb b/docs/docs/tutorials/refine-cosio-d20.ipynb index 5643b6e3f..a7721536d 100644 --- a/docs/docs/tutorials/refine-cosio-d20.ipynb +++ b/docs/docs/tutorials/refine-cosio-d20.ipynb @@ -665,6 +665,26 @@ "The HTML report is written automatically when the project is saved;\n", "enable `project.report.pdf` as well for a PDF version." ] + }, + { + "cell_type": "markdown", + "id": "57", + "metadata": {}, + "source": [ + "## 💾 Save Project\n", + "\n", + "Save the fitted parameters and analysis results." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "58", + "metadata": {}, + "outputs": [], + "source": [ + "project.save()" + ] } ], "metadata": { diff --git a/docs/docs/tutorials/refine-cosio-d20.py b/docs/docs/tutorials/refine-cosio-d20.py index c90ac0f98..c3fa9dd9b 100644 --- a/docs/docs/tutorials/refine-cosio-d20.py +++ b/docs/docs/tutorials/refine-cosio-d20.py @@ -299,3 +299,11 @@ # # The HTML report is written automatically when the project is saved; # enable `project.report.pdf` as well for a PDF version. + +# %% [markdown] +# ## 💾 Save Project +# +# Save the fitted parameters and analysis results. + +# %% +project.save() diff --git a/docs/docs/user-guide/analysis-workflow/analysis.md b/docs/docs/user-guide/analysis-workflow/analysis.md index 82fad40ea..4de074cbe 100644 --- a/docs/docs/user-guide/analysis-workflow/analysis.md +++ b/docs/docs/user-guide/analysis-workflow/analysis.md @@ -312,12 +312,14 @@ Select a Bayesian minimizer through the same category-owned selector: project.analysis.minimizer.type = 'emcee' project.save_as(dir_path='lbco_hrpt') # required before an emcee fit project.analysis.fit() +project.save() ``` For a saved `emcee` project, resume the chain with extra steps: ```python project.analysis.fit(resume=True, extra_steps=500) +project.save() ``` After a Bayesian fit, inspect posterior summaries and plots: diff --git a/src/easydiffraction/__main__.py b/src/easydiffraction/__main__.py index 937c9db6e..baddd54c2 100644 --- a/src/easydiffraction/__main__.py +++ b/src/easydiffraction/__main__.py @@ -299,6 +299,8 @@ def fit( if dry: project.metadata._path = None project.analysis.fit() + if not dry: + project.save() _display_fit_outputs(project) diff --git a/src/easydiffraction/analysis/analysis.py b/src/easydiffraction/analysis/analysis.py index 7e08ec1f2..da6f174a6 100644 --- a/src/easydiffraction/analysis/analysis.py +++ b/src/easydiffraction/analysis/analysis.py @@ -2860,9 +2860,6 @@ def _run_single( ) self._stamp_software_provenance() - if self.project.metadata.path is not None: - self.project.save() - def _run_joint( self, *, @@ -2887,9 +2884,6 @@ def _run_joint( ) self._stamp_software_provenance() - if self.project.metadata.path is not None: - self.project.save() - def _run_sequential(self) -> None: """ Execute sequential fitting from persisted sequential settings. @@ -2926,9 +2920,6 @@ def _run_sequential(self) -> None: self._stamp_software_provenance() - if self.project.metadata.path is not None: - self.project.save() - def _fit_joint( self, verb: VerbosityEnum, diff --git a/src/easydiffraction/core/validation.py b/src/easydiffraction/core/validation.py index 31b7763e5..f55e10a17 100644 --- a/src/easydiffraction/core/validation.py +++ b/src/easydiffraction/core/validation.py @@ -25,7 +25,8 @@ def validate_datablock_name(value: str, *, kind: str) -> str: - """Return a datablock name that survives backend normalization. + """ + Return a datablock name that survives backend normalization. Calculator backends such as Cryspy normalize CIF datablock names to lowercase. Reject uppercase names at the public model boundary so diff --git a/src/easydiffraction/datablocks/experiment/item/base.py b/src/easydiffraction/datablocks/experiment/item/base.py index 33909850d..42e3ae3ea 100644 --- a/src/easydiffraction/datablocks/experiment/item/base.py +++ b/src/easydiffraction/datablocks/experiment/item/base.py @@ -650,7 +650,7 @@ def _get_valid_linked_structures( A list of valid linked structures. """ if not self.linked_structures: - log.warning('No linked structures defined. Returning empty pattern.') + # log.warning('No linked structures defined.') return [] valid_linked_structures = [] diff --git a/src/easydiffraction/display/plotters/plotly.py b/src/easydiffraction/display/plotters/plotly.py index 7852a7e37..38571e1a6 100644 --- a/src/easydiffraction/display/plotters/plotly.py +++ b/src/easydiffraction/display/plotters/plotly.py @@ -1406,6 +1406,64 @@ def _serialize_html_colab(cls, fig: object) -> str: return loading; }} + function resizeColabOutput() {{ + var output = + window.google && window.google.colab && window.google.colab.output; + if (!output) {{ + return; + }} + try {{ + if (typeof output.resizeIframeToContent === 'function') {{ + output.resizeIframeToContent(); + }} else if (typeof output.setIframeHeight === 'function') {{ + // The root element's scroll height never drops below the + // current iframe viewport, so it can only ever grow the frame. + // Measure the intrinsic content instead, preferring Colab's own + // output area: it holds every output of this cell, so a sibling + // figure is never clipped. + var area = + typeof output.getDefaultOutputArea === 'function' + ? output.getDefaultOutputArea() + : null; + var content = area || document.body; + var bounds = content.getBoundingClientRect(); + var style = + typeof window.getComputedStyle === 'function' + ? window.getComputedStyle(content) + : null; + var gap = style ? parseFloat(style.marginBottom) || 0 : 0; + output.setIframeHeight( + Math.ceil(bounds.bottom + (window.scrollY || 0) + gap), + true + ); + }} + }} catch (error) {{ + console.warn('Unable to resize the Colab plot output.', error); + }} + }} + + function afterLayout(callback) {{ + var done = false; + function once() {{ + if (done) {{ + return; + }} + done = true; + callback(); + }} + try {{ + if (typeof window.requestAnimationFrame === 'function') {{ + window.requestAnimationFrame(once); + }} + // requestAnimationFrame never fires while the browser tab is + // hidden, so keep a timer as the backstop: first one through + // wins, and neither can leave the callback unrun. + window.setTimeout(once, 100); + }} catch (error) {{ + once(); + }} + }} + // The loader does not touch Plotly until it renders, so both assets // can travel together: one round trip instead of two per frame. var rendering = Promise.all([ @@ -1422,7 +1480,7 @@ def _serialize_html_colab(cls, fig: object) -> str: ); }}); - var settled = rendering.catch(function (error) {{ + var painted = rendering.catch(function (error) {{ console.error(error); var target = document.getElementById({json.dumps(plot_id)}); var figure = target && (target.closest('.ed-figure') || target); @@ -1432,7 +1490,7 @@ def _serialize_html_colab(cls, fig: object) -> str: }} }}); - // Hand Colab the settled promise, so the output frame is released + // Hand Colab the painted promise, so the output frame is released // only once the plot (or its error message) is on screen, and Colab // never receives a rejected promise of ours. if ( @@ -1441,8 +1499,16 @@ def _serialize_html_colab(cls, fig: object) -> str: window.google.colab.output && typeof window.google.colab.output.pauseOutputUntil === 'function' ) {{ - window.google.colab.output.pauseOutputUntil(settled); + window.google.colab.output.pauseOutputUntil(painted); }} + + // Colab holds outputframe auto-resizing (and the cell's later + // outputs) while that promise is pending, so remeasure once it has + // resolved: the frame otherwise keeps the height measured before the + // plot replaced its placeholder. + painted.then(function () {{ + afterLayout(resizeColabOutput); + }}); }})(); """.strip() diff --git a/src/easydiffraction/project/project.py b/src/easydiffraction/project/project.py index 4828fc4b0..39afc5ba9 100644 --- a/src/easydiffraction/project/project.py +++ b/src/easydiffraction/project/project.py @@ -576,40 +576,41 @@ def save(self) -> None: # Ensure project directory exists self.metadata.path.mkdir(parents=True, exist_ok=True) + tree_lines: list[str] = [] # Save project-level configuration with (self.metadata.path / 'project.edi').open('w') as f: f.write(section_to_edi(project_config_to_cif(self))) - console.print('├── 📄 project.edi') + tree_lines.append('├── 📄 project.edi') # Save structures sm_dir = self.metadata.path / 'structures' sm_dir.mkdir(parents=True, exist_ok=True) - console.print('├── 📁 structures/') + tree_lines.append('├── 📁 structures/') for structure in self.structures.values(): file_name: str = f'{structure.name}.edi' file_path = sm_dir / file_name with file_path.open('w') as f: f.write(section_to_edi(structure.as_cif)) - console.print(f'│ └── 📄 {file_name}') + tree_lines.append(f'│ └── 📄 {file_name}') # Save experiments expt_dir = self.metadata.path / 'experiments' expt_dir.mkdir(parents=True, exist_ok=True) - console.print('├── 📁 experiments/') + tree_lines.append('├── 📁 experiments/') for experiment in self.experiments.values(): file_name: str = f'{experiment.name}.edi' file_path = expt_dir / file_name with file_path.open('w') as f: f.write(section_to_edi(experiment.as_cif)) - console.print(f'│ └── 📄 {file_name}') + tree_lines.append(f'│ └── 📄 {file_name}') # Save analysis analysis_dir = self.metadata.path / 'analysis' analysis_dir.mkdir(parents=True, exist_ok=True) with (analysis_dir / 'analysis.edi').open('w') as f: f.write(section_to_edi(self.analysis.as_cif)) - console.print('├── 📁 analysis/') + tree_lines.append('├── 📁 analysis/') write_analysis_results_sidecar( analysis=self.analysis, analysis_dir=analysis_dir, @@ -622,16 +623,20 @@ def save(self) -> None: ) for index, file_name in enumerate(analysis_file_names): branch = '└──' if index == len(analysis_file_names) - 1 else '├──' - console.print(f'│ {branch} 📄 {file_name}') + tree_lines.append(f'│ {branch} 📄 {file_name}') report_paths = self.report._save_configured() if report_paths: reports_dir = self.metadata.path / 'reports' - console.print('└── 📁 reports/') + tree_lines.append('└── 📁 reports/') for index, report_path in enumerate(report_paths): branch = '└──' if index == len(report_paths) - 1 else '├──' relative_path = report_path.relative_to(reports_dir) - console.print(f' {branch} 📄 {relative_path}') + tree_lines.append(f' {branch} 📄 {relative_path}') + + # A single console call keeps the tree in one notebook + # output block. + console.print('\n'.join(tree_lines)) self.metadata.update_last_modified() self._saved = True diff --git a/tests/integration/fitting/test_cli_entrypoints.py b/tests/integration/fitting/test_cli_entrypoints.py index 9074e5317..563734be8 100644 --- a/tests/integration/fitting/test_cli_entrypoints.py +++ b/tests/integration/fitting/test_cli_entrypoints.py @@ -81,6 +81,10 @@ class FakeProject: metadata = FakeMetadata() experiments = [FakeExperiment()] + @staticmethod + def save() -> None: + calls.append('SAVE') + class _analysis: @staticmethod def fit() -> None: @@ -123,13 +127,15 @@ def pattern(expt_name: str, **kwargs) -> None: result = runner.invoke(main_mod.app, ['fit', str(project_dir)]) assert result.exit_code == 0 - assert calls == ['FIT', 'DISPLAY', 'PLOT_CORR', 'PLOT_exp1_False'] + assert calls == ['FIT', 'SAVE', 'DISPLAY', 'PLOT_CORR', 'PLOT_exp1_False'] def test_cli_fit_dry_clears_path(monkeypatch, tmp_path): import easydiffraction.__main__ as main_mod from easydiffraction.project.project import Project + calls: list[str] = [] + class FakeMetadata: _path = '/some/path' @@ -140,6 +146,10 @@ class FakeProject: metadata = FakeMetadata() experiments = [FakeExperiment()] + @staticmethod + def save() -> None: + calls.append('SAVE') + class _analysis: @staticmethod def fit() -> None: @@ -182,3 +192,4 @@ def pattern(expt_name: str, **kwargs) -> None: assert result.exit_code == 0 assert fake_project.metadata._path is None + assert calls == [] diff --git a/tests/unit/easydiffraction/analysis/test_analysis.py b/tests/unit/easydiffraction/analysis/test_analysis.py index 5aeb8f6a9..baaa10129 100644 --- a/tests/unit/easydiffraction/analysis/test_analysis.py +++ b/tests/unit/easydiffraction/analysis/test_analysis.py @@ -852,7 +852,7 @@ def fake_update_short_table( assert handle.closed is True -def test_run_sequential_sets_mode_and_saves_project(monkeypatch, tmp_path): +def test_run_sequential_sets_mode_without_saving_project(monkeypatch, tmp_path): from easydiffraction.analysis.analysis import Analysis project = SimpleNamespace( @@ -919,7 +919,7 @@ def fake_fit_sequential( ('reverse', True), ('update_categories', None), ] - assert project.save_calls == 1 + assert project.save_calls == 0 assert analysis.fit_results is None assert analysis.fitter.results is None diff --git a/tests/unit/easydiffraction/analysis/test_analysis_coverage.py b/tests/unit/easydiffraction/analysis/test_analysis_coverage.py index 6cef62060..1c05f4aad 100644 --- a/tests/unit/easydiffraction/analysis/test_analysis_coverage.py +++ b/tests/unit/easydiffraction/analysis/test_analysis_coverage.py @@ -2543,7 +2543,9 @@ def test_run_single_aborts_when_preparation_fails(self, monkeypatch): a._run_single() assert called == [] - def test_run_single_invokes_fit_and_stamps_provenance(self, monkeypatch, tmp_path): + def test_run_single_invokes_fit_and_stamps_provenance_without_saving( + self, monkeypatch, tmp_path + ): from easydiffraction.analysis.analysis import Analysis from easydiffraction.utils.enums import VerbosityEnum @@ -2570,7 +2572,7 @@ def test_run_single_invokes_fit_and_stamps_provenance(self, monkeypatch, tmp_pat a._run_single(resume=True, extra_steps=5) assert captured == {'resume': True, 'extra_steps': 5, 'stamped': True} - assert project.save_calls == 1 + assert project.save_calls == 0 def test_run_joint_rejects_resume(self): import pytest @@ -2592,6 +2594,37 @@ def test_run_joint_aborts_when_preparation_fails(self, monkeypatch): a._run_joint() assert called == [] + def test_run_joint_invokes_fit_and_stamps_provenance_without_saving( + self, monkeypatch, tmp_path + ): + from easydiffraction.analysis.analysis import Analysis + from easydiffraction.utils.enums import VerbosityEnum + + project = _fit_project(structures=['s'], experiments=['e'], path=tmp_path) + project.save_calls = 0 + project.save = lambda: setattr(project, 'save_calls', project.save_calls + 1) + a = Analysis(project=project) + + captured = {} + monkeypatch.setattr( + a, + '_prepare_fit_run', + lambda *, resume=False: (VerbosityEnum.SILENT, ['s'], ['e']), + ) + monkeypatch.setattr( + a, + '_fit_joint', + lambda verb, structures, experiments, *, fit_options: captured.update( + resume=fit_options.resume, extra_steps=fit_options.extra_steps + ), + ) + monkeypatch.setattr(a, '_stamp_software_provenance', lambda: captured.update(stamped=True)) + + a._run_joint() + + assert captured == {'resume': False, 'extra_steps': None, 'stamped': True} + assert project.save_calls == 0 + # ------------------------------------------------------------------ # Joint fitting execution diff --git a/tests/unit/easydiffraction/datablocks/experiment/item/test_base_coverage.py b/tests/unit/easydiffraction/datablocks/experiment/item/test_base_coverage.py index 84c6223b1..2680e8081 100644 --- a/tests/unit/easydiffraction/datablocks/experiment/item/test_base_coverage.py +++ b/tests/unit/easydiffraction/datablocks/experiment/item/test_base_coverage.py @@ -660,8 +660,8 @@ def test_no_linked_structures_warns_and_returns_empty(self, monkeypatch): result = ex._get_valid_linked_structures(_FakeStructures([])) + # assert any('No linked structures defined' in w for w in warnings) assert result == [] - assert any('No linked structures defined' in w for w in warnings) def test_skips_phases_absent_from_structures(self, monkeypatch): from easydiffraction.datablocks.experiment.item import base as item_base diff --git a/tests/unit/easydiffraction/display/plotters/test_plotly_coverage.py b/tests/unit/easydiffraction/display/plotters/test_plotly_coverage.py index 272b1c5a1..f9ee31381 100644 --- a/tests/unit/easydiffraction/display/plotters/test_plotly_coverage.py +++ b/tests/unit/easydiffraction/display/plotters/test_plotly_coverage.py @@ -863,6 +863,7 @@ def test_show_figure_live_emits_single_output_with_render(monkeypatch): assert first.count('') == 1 # exactly one render script element assert 'renderSpec' in first assert 'Plotly' in first + assert 'resizeIframeToContent' not in first captured.clear() plotter._show_figure(go.Figure()) @@ -941,8 +942,22 @@ def test_show_figure_colab_loads_assets_in_every_isolated_output(monkeypatch): # Both assets travel together, and the frame is released only # once the plot (or its error message) is on screen. assert 'Promise.all(' in html - assert 'pauseOutputUntil(settled)' in html + assert 'pauseOutputUntil(painted)' in html assert 'window.edFigures.renderSpec' in html + # Colab can retain the height measured before an asynchronous + # plot replaces its placeholder, so remeasure once the paused + # promise has resolved and auto-resizing has resumed. + assert 'painted.then(function () {\n afterLayout(resizeColabOutput);' in html + assert 'resizeIframeToContent' in html + # The fallback measures Colab's output area (all outputs of the + # cell), never documentElement.scrollHeight, which is clamped to + # the current iframe viewport and so cannot report a shrink. + assert 'getDefaultOutputArea' in html + assert 'content.getBoundingClientRect()' in html + assert 'documentElement.scrollHeight' not in html + # A hidden browser tab never paints, so the remeasure cannot + # depend on requestAnimationFrame alone. + assert 'window.setTimeout(once, 100)' in html # No stylesheet in Colab, so the placeholder styles itself. assert 'Loading plot…' in html assert 'position: absolute' in html @@ -970,8 +985,118 @@ def test_show_figure_shared_docs_mode_takes_precedence_over_colab(monkeypatch): assert len(captured) == 1 assert 'ed-figure-spec' in captured[0] assert 'pauseOutputUntil' not in captured[0] + assert 'resizeIframeToContent' not in captured[0] assert 'assets/javascripts/vendor/plotly' not in captured[0] # The docs stylesheet owns the placeholder's look; inlining more # than the height would shadow later stylesheet edits. assert '
skeleton, + getAttribute: () => null, + setAttribute: () => {}, +}; +const target = { closest: () => figure, querySelector: () => skeleton }; +// The cell's outputs end at 300px; the frame viewport is still 800px. +const outputArea = { getBoundingClientRect: () => ({ bottom: 300 }) }; +const document = { + head: { + appendChild(script) { + setTimeout(() => { + if (script.src.includes('plotly')) { + window.Plotly = { newPlot: () => Promise.resolve() }; + } else { + window.edFigures = { renderSpec: () => Promise.resolve() }; + } + script.onload(); + }, 0); + }, + }, + createElement: () => ({ remove() {} }), + getElementById: () => target, + body: { getBoundingClientRect: () => ({ bottom: 320 }) }, + documentElement: { scrollHeight: 800, clientHeight: 800 }, +}; +const output = { + pauseOutputUntil: (promise) => { + promise.then(() => requested.push('paused-resolved')); + }, + setIframeHeight: (height) => requested.push(height), +}; +if (withOutputArea) { + output.getDefaultOutputArea = () => outputArea; +} +const window = { + document, + setTimeout, + requestAnimationFrame: (callback) => setTimeout(callback, 0), + innerHeight: 800, + scrollY: 0, + getComputedStyle: () => ({ marginBottom: '0px' }), + google: { colab: { output } }, +}; + +new Function('window', 'document', 'console', source)(window, document, console); +setTimeout(() => console.log(JSON.stringify(requested)), 250); +""" + + +@pytest.mark.parametrize( + ('mode', 'expected_height'), + [ + # Colab's own output area spans every output of the cell. + ('with-output-area', 300), + # Without it, the frame's body is the next best content anchor. + ('without-output-area', 320), + ], +) +def test_colab_resize_fallback_shrinks_to_content(tmp_path, monkeypatch, mode, expected_height): + """The height requested is the content's, not the frame viewport's.""" + import json + import re + import shutil + import subprocess # noqa: S404 + + import easydiffraction.display.plotters.plotly as pp + + node = shutil.which('node') + if node is None: # pragma: no cover - Node is provided by the pixi env + pytest.skip('node is required to run the Colab bootstrap') + + monkeypatch.setattr(pp, '_get_version_for_url', lambda: '0.19.0') + html = pp.PlotlyPlotter._serialize_html_colab(go.Figure(go.Scatter(x=[1], y=[2]))) + script = re.search(r'', html, re.DOTALL) + assert script is not None + + bootstrap = tmp_path / 'bootstrap.js' + bootstrap.write_text(script.group(1)) + harness = tmp_path / 'harness.js' + harness.write_text(_COLAB_RESIZE_HARNESS) + + completed = subprocess.run( # noqa: S603 + [node, str(harness), str(bootstrap), mode], + capture_output=True, + text=True, + timeout=60, + check=True, + ) + requested = json.loads(completed.stdout.strip().splitlines()[-1]) + + assert 'paused-resolved' in requested + heights = [entry for entry in requested if isinstance(entry, int)] + # A viewport-clamped measurement (documentElement.scrollHeight) would + # request 800 here and leave the blank space in place. + assert heights == [expected_height] diff --git a/tests/unit/easydiffraction/project/test_project_save.py b/tests/unit/easydiffraction/project/test_project_save.py index 5b3047a1e..e031a44f9 100644 --- a/tests/unit/easydiffraction/project/test_project_save.py +++ b/tests/unit/easydiffraction/project/test_project_save.py @@ -64,6 +64,27 @@ def test_project_save_lists_existing_analysis_results_csv(tmp_path, monkeypatch, assert 'results.csv' in out +def test_project_save_prints_file_tree_in_one_console_call(tmp_path, monkeypatch): + from easydiffraction.project import project as project_module + from easydiffraction.project.project import Project + + printed = [] + monkeypatch.setattr(project_module.console, 'print', printed.append) + + project = Project(name='p1') + project.report.html = False + project.save_as(str(tmp_path / 'proj_dir')) + + expected_tree = ( + '├── 📄 project.edi\n' + '├── 📁 structures/\n' + '├── 📁 experiments/\n' + '├── 📁 analysis/\n' + '│ └── 📄 analysis.edi' + ) + assert printed == [expected_tree] + + def test_project_save_as_overwrites_existing_directory_by_default(tmp_path, monkeypatch): from easydiffraction.analysis.analysis import Analysis from easydiffraction.project.project import Project diff --git a/tests/unit/easydiffraction/test___main__.py b/tests/unit/easydiffraction/test___main__.py index 0e54240db..d8dd48c63 100644 --- a/tests/unit/easydiffraction/test___main__.py +++ b/tests/unit/easydiffraction/test___main__.py @@ -166,6 +166,10 @@ class FakeProject: info = FakeInfo() experiments = [FakeExperiment()] + @staticmethod + def save(): + calls.append('SAVE') + class _analysis: @staticmethod def fit(): @@ -208,7 +212,7 @@ def pattern(expt_name, **kwargs): result = runner.invoke(main_mod.app, ['fit', str(proj_dir)]) assert result.exit_code == 0 - assert calls == ['FIT', 'DISPLAY', 'PLOT_CORR', 'PLOT_exp1_False'] + assert calls == ['FIT', 'SAVE', 'DISPLAY', 'PLOT_CORR', 'PLOT_exp1_False'] def test_cli_fit_skips_fit_reports_for_sequential_mode(monkeypatch, tmp_path): @@ -227,6 +231,10 @@ class FakeProject: info = FakeInfo() experiments = [FakeExperiment()] + @staticmethod + def save(): + calls.append('SAVE') + class _analysis: class _fitting_mode: type = 'sequential' @@ -268,13 +276,15 @@ def pattern(expt_name, **kwargs): result = runner.invoke(main_mod.app, ['fit', str(proj_dir)]) assert result.exit_code == 0 - assert calls == ['FIT', 'PLOT_exp1_False'] + assert calls == ['FIT', 'SAVE', 'PLOT_exp1_False'] def test_cli_fit_dry_clears_path(monkeypatch, tmp_path): import easydiffraction.__main__ as main_mod from easydiffraction.project.project import Project + calls = [] + class FakeInfo: _path = '/some/path' @@ -285,6 +295,10 @@ class FakeProject: metadata = FakeInfo() experiments = [FakeExperiment()] + @staticmethod + def save(): + calls.append('SAVE') + class _analysis: @staticmethod def fit(): @@ -326,6 +340,7 @@ def pattern(expt_name, **kwargs): result = runner.invoke(main_mod.app, ['fit', '--dry', str(proj_dir)]) assert result.exit_code == 0 assert fake_project.metadata._path is None + assert calls == [] def test_cli_undo_noop_exits_zero_and_does_not_save(monkeypatch, tmp_path):