diff --git a/README.md b/README.md index efc690e..548bf7a 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ Once installed, you can call the CLI. Example using demo files: ```bash -ecoffitter --input demo_files/censored.txt --params demo_files/params.txt --outfile demo_files/output.txt +ecoff-fitter --input demo_files/input.txt --params demo_files/params.txt --outfile demo_files/output.txt ``` Instead of using a parameter file, you can also specify parameters directly. @@ -155,9 +155,9 @@ Instead of using a parameter file, you can also specify parameters directly. Usage: ```bash -ecoff_fitter [-h] --input INPUT [--params PARAMS] +ecoff-fitter [-h] --input INPUT [--params PARAMS] [--dilution_factor DILUTION_FACTOR] -[--distributions ] [--boundary_support boundary_support] +[--distributions DISTRIBUTIONS] [--boundary_support BOUNDARY_SUPPORT] [--percentile PERCENTILE] [--outfile OUTFILE] [--verbose] ``` diff --git a/demo_files/output.pdf b/demo_files/output.pdf index 37e6362..4ba591a 100644 Binary files a/demo_files/output.pdf and b/demo_files/output.pdf differ diff --git a/demo_files/~$demo_input.xlsx b/demo_files/~$demo_input.xlsx deleted file mode 100644 index 5a93205..0000000 Binary files a/demo_files/~$demo_input.xlsx and /dev/null differ diff --git a/gui.py b/gui.py index e84079a..07b2547 100644 --- a/gui.py +++ b/gui.py @@ -160,48 +160,30 @@ def run_ecoff(self): individual_results[col] = (fitter, result) - text = "ECOFF RESULTS\n" - text += "=====================================\n\n" + text = "ECOFF RESULTS\n=====================================\n\n" - if len(individual_results.keys())>1: - text += "GLOBAL FIT\n" - text += "-------------------------------------\n" - text += f" ECOFF: {global_result[0]:.4f}\n" - text += f" z-value: {global_result[1]:.4f}\n" - for i in range(global_fitter.distributions): - if global_fitter.distributions > 1: - text += f" Component {i+1}:\n" - text += f" mu = {dilution_factor**global_fitter.mus_[i]:.4f}\n" - text += f" sigma (folds) = {dilution_factor**global_fitter.sigmas_[i]:.4f}\n" - text += "\n" - - text += "INDIVIDUAL FITS:\n" - text += "-------------------------------------\n" + global_report = GenerateReport.from_fitter(global_fitter, global_result) + if len(individual_results) > 1: + + text += global_report.to_text("GLOBAL FIT") + text += "\nINDIVIDUAL FITS:\n-------------------------------------\n" + + + # Individual fits + for name, (fitter, result) in individual_results.items(): + rep = GenerateReport.from_fitter(fitter, result) + text += rep.to_text(label=name) - for col, (fitter, result) in individual_results.items(): - text += f"{col}\n" - text += f" ECOFF: {result[0]:.4f}\n" - text += f" z-value: {result[1]:.4f}\n" - for i in range(fitter.distributions): - if fitter.distributions>1: - text += f" Component {i+1}:\n" - text += f" mu = {dilution_factor**fitter.mus_[i]:.4f}\n" - text += f" sigma (folds) = {dilution_factor**fitter.sigmas_[i]:.4f}\n" - text += "\n" if outfile: + validate_output_path(outfile) if len(individual_results.keys())==1: - validate_output_path(outfile) - report = GenerateReport.from_fitter(global_fitter, global_result) if outfile.endswith(".pdf"): - report.save_pdf(outfile) + global_report.save_pdf(outfile) else: - report.write_out(outfile) - text += f"\nSaved global report to: {outfile}" + global_report.write_out(outfile) elif (len(individual_results.keys()))>1: # Build section reports - global_report = GenerateReport.from_fitter(global_fitter, global_result) - indiv_reports = { name: GenerateReport.from_fitter(fitter, result) for name, (fitter, result) in individual_results.items() @@ -209,7 +191,10 @@ def run_ecoff(self): # Build combined PDF combined = CombinedReport(outfile, global_report, indiv_reports) - combined.save_pdf() + if outfile.endswith(".pdf"): + combined.save_pdf() + else: + combined.write_out() for widget in self.plot_frame.winfo_children(): diff --git a/src/ecoff_fitter/cli.py b/src/ecoff_fitter/cli.py index e49c5f6..11dd165 100644 --- a/src/ecoff_fitter/cli.py +++ b/src/ecoff_fitter/cli.py @@ -9,11 +9,13 @@ import argparse from typing import Any, List, Optional from ecoff_fitter import ECOFFitter -from ecoff_fitter.report import GenerateReport +from ecoff_fitter.report import GenerateReport, CombinedReport from ecoff_fitter.defence import validate_output_path +from ecoff_fitter.utils import read_multi_obs_input from unittest.mock import MagicMock, patch + def build_parser() -> argparse.ArgumentParser: """Create and configure the command-line argument parser.""" parser = argparse.ArgumentParser( @@ -27,7 +29,7 @@ def build_parser() -> argparse.ArgumentParser: required=True, help=( "Path to the input MIC dataset (CSV, TSV, XLSX, or XLS) " - "with columns 'MIC' and 'observations'." + "with columns 'MIC' and assay name." ), ) parser.add_argument( @@ -80,28 +82,74 @@ def main(argv: Optional[List[str]] = None) -> None: parser = build_parser() args = parser.parse_args(argv) - fitter = ECOFFitter( - input=args.input, - params=args.params, - dilution_factor=args.dilution_factor, - distributions=args.distributions, - boundary_support=args.boundary_support, - ) - - result = fitter.generate(percentile=args.percentile) - - report = GenerateReport.from_fitter(fitter, result) - - report.print_stats(args.verbose) - - if args.outfile: - - validate_output_path(args.outfile) - - if args.outfile.endswith(".pdf"): - report.save_pdf(args.outfile) - else: - report.write_out(args.outfile) + try: + + data_dict = read_multi_obs_input(args.input) + df_global = data_dict['global'] + df_individual = data_dict['individual'] + + global_fitter = ECOFFitter( + input=df_global, + params=args.params, + distributions=args.distributions, + boundary_support=args.boundary_support, + dilution_factor=args.dilution_factor + ) + + global_result = global_fitter.generate(percentile=args.percentile) + + individual_results = {} + for col, subdf in df_individual.items(): + + fitter = ECOFFitter( + input=subdf, + params=args.params, + dilution_factor=args.dilution_factor, + distributions=args.distributions, + boundary_support=args.boundary_support, + ) + + result = fitter.generate(percentile=args.percentile) + individual_results[col] = (fitter, result) + + text = "\n\nECOFF RESULTS\n=====================================\n\n" + + global_report = GenerateReport.from_fitter(global_fitter, global_result) + + if len(individual_results) > 1: + text += global_report.to_text("GLOBAL FIT") + text += "\nINDIVIDUAL FITS:\n-------------------------------------\n" + + # Individual fits + for name, (fitter, result) in individual_results.items(): + rep = GenerateReport.from_fitter(fitter, result) + text += rep.to_text(label=name) + + if args.outfile: + validate_output_path(args.outfile) + if len(individual_results.keys())==1: + + if args.outfile.endswith(".pdf"): + global_report.save_pdf(args.outfile) + else: + global_report.write_out(args.outfile) + elif (len(individual_results.keys()))>1: + # Build section reports + indiv_reports = { + name: GenerateReport.from_fitter(fitter, result) + for name, (fitter, result) in individual_results.items() + } + # Build combined PDF + combined = CombinedReport(args.outfile, global_report, indiv_reports) + if args.outfile.endswith(".pdf"): + combined.save_pdf() + else: + combined.write_out() + + print (text) + + except Exception as e: + print ('Error', str(e)) if __name__ == "__main__": diff --git a/src/ecoff_fitter/report.py b/src/ecoff_fitter/report.py index 6fe2df6..08515b4 100644 --- a/src/ecoff_fitter/report.py +++ b/src/ecoff_fitter/report.py @@ -81,48 +81,44 @@ def intervals( self.fitter.define_intervals(), ) - def print_stats(self, verbose: bool = False) -> None: - print(f"\nECOFF (original scale): {self.ecoff:.2}") - - if self.distributions == 1: - mu = self.mus[0] - sigma = self.sigmas[0] - print(f"μ: {self.dilution_factor**mu:.2f}") - print(f"σ (folds): {self.dilution_factor**sigma:.2f}") - else: - print("\nComponent means and sigmas (original scale):") - for i, (mu, sigma) in enumerate(zip(self.mus, self.sigmas), start=1): - print( - f" μ{i}: {self.dilution_factor**mu:.4f}, " - f"σ{i} (folds): {self.dilution_factor**sigma:.4f}" - ) + def to_text(self, label: str | None = None, verbose: bool = False) -> str: + """ + Produce the exact text representation currently created inside the GUI. + `label` is optional (e.g., column name or 'GLOBAL FIT'). + """ + lines = [] + + if label: + lines.append(f"{label}") + lines.append("-------------------------------------") + + lines.append(f" ECOFF: {self.ecoff:.4f}") + # z-values stored in self.z are ECOFF values for 99, 97.5, 95 percentiles + lines.append(f" log scale: {np.log2(self.ecoff):.4f}\n") + + # Mixture components + for i, (mu, sigma) in enumerate(zip(self.mus, self.sigmas), start=1): + prefix = f" Component {i}:" if self.distributions > 1 else "" + mu_line = f" μ = {self.dilution_factor ** mu:.4f}" + sigma_line = f" σ (folds) = {self.dilution_factor ** sigma:.4f}" + if prefix: + lines.append(prefix) + lines.append(mu_line) + lines.append(sigma_line) + + # Verbose model details if verbose and self.model is not None: - print("\n--- Model details ---") - print(self.model) + lines.append("") + lines.append("--- Model details ---") + lines.append(str(self.model)) - def write_out(self, path: str) -> None: - z0, z1, z2 = self.z + return "\n".join(lines) + "\n" + + def write_out(self, path: str) -> None: with open(path, "w") as f: - f.write(f"ECOFF: {self.ecoff:.2f}\n") - f.write(f"99th percentile: {z0:.2f}\n") - f.write(f"97.5th percentile: {z1:.2f}\n") - f.write(f"95th percentile: {z2:.2f}\n") - - if self.distributions == 1: - mu = self.mus[0] - sigma = self.sigmas[0] - f.write( - f"μ: {self.dilution_factor**mu}, " - f"σ (folds): {self.dilution_factor**sigma}\n" - ) - else: - for i, (mu, sigma) in enumerate(zip(self.mus, self.sigmas), start=1): - f.write( - f"μ{i}: {self.dilution_factor**mu}, " - f"σ{i} (folds): {self.dilution_factor**sigma}\n" - ) + f.write(self.to_text()) print(f"\nResults saved to: {path}") @@ -136,9 +132,13 @@ def save_pdf(self, outfile: str) -> None: def _make_pdf(self, title: Optional[str] = None) -> Figure: fig, (ax_plot, ax_text) = plt.subplots( - nrows=1, ncols=2, figsize=(10, 4), gridspec_kw={"width_ratios": [2, 1]} + nrows=1, + ncols=2, + figsize=(10, 4), + gridspec_kw={"width_ratios": [2, 1]} ) + # Plot area low_log, high_log, weights = self.intervals plot_mic_distribution( @@ -149,50 +149,31 @@ def _make_pdf(self, title: Optional[str] = None) -> Figure: dilution_factor=self.dilution_factor, mus=self.mus, sigmas=self.sigmas, - log2_ecoff=np.log2(self.ecoff) if self.ecoff else None, + log2_ecoff=np.log2(self.ecoff), ax=ax_plot, ) - ax_plot.legend(fontsize=7, frameon=False) if title: ax_plot.set_title(title) - # Right-hand text ------------- - z0, z1, z2 = self.z + ax_plot.legend(fontsize=7, frameon=False) + + # Text area ax_text.axis("off") - lines = [ - f"ECOFF: {self.ecoff:.2f}", - f"99th percentile: {z0:.2f}", - f"97.5th percentile: {z1:.2f}", - f"95th percentile: {z2:.2f}", - ] - - if self.distributions == 1: - mu = self.mus[0] - sigma = self.sigmas[0] - lines += [ - f"μ: {self.dilution_factor**mu:.2f}", - f"σ: {self.dilution_factor**sigma:.2f}", - ] - else: - for i, (mu, sigma) in enumerate(zip(self.mus, self.sigmas), start=1): - lines.append( - f"μ{i}: {self.dilution_factor**mu:.4f}, " - f"σ{i} (folds): {self.dilution_factor**sigma:.4f}" - ) + # re-use the unified report formatter + text = self.to_text(label=None if title is None else title) ax_text.text( 0.05, - 0.9, - "\n".join(lines), - fontsize=11, + 0.95, + text, + fontsize=10, va="top", - family="monospace", + family="monospace" ) fig.tight_layout(rect=(0, 0, 1, 0.95)) - return fig @@ -204,7 +185,7 @@ def __init__( individual_reports: Dict[str, GenerateReport], ) -> None: """ - outfile: PDF filename + outfile: PDF filename (for save_pdf) global_report: GenerateReport instance individual_reports: dict {column_name: GenerateReport} """ @@ -212,6 +193,33 @@ def __init__( self.global_report = global_report self.individual_reports = individual_reports + def write_out(self) -> None: + """ + Write a consolidated text report containing: + - Global fit summary + - Individual fit summaries + + Uses GenerateReport.to_text() for formatting consistency. + """ + lines: list[str] = [] + + # ----- GLOBAL REPORT ----- + lines.append("===== GLOBAL FIT =====") + lines.append(self.global_report.to_text(label="GLOBAL FIT")) + + # ----- INDIVIDUAL REPORTS ----- + for name, report in self.individual_reports.items(): + lines.append(f"\n===== INDIVIDUAL FIT: {name} =====") + lines.append(report.to_text(label=name)) + + # Join and write file + text = "\n".join(lines) + + with open(self.outfile, "w") as f: + f.write(text) + + print(f"\nCombined text report saved to: {self.outfile}") + def save_pdf(self) -> None: from matplotlib.backends.backend_pdf import PdfPages @@ -229,3 +237,5 @@ def save_pdf(self) -> None: plt.close(fig) print(f"Combined PDF saved to {self.outfile}") + + print(f"Combined PDF saved to {self.outfile}") diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..16c9145 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,112 @@ +import pytest +from unittest.mock import MagicMock +import ecoff_fitter.cli as cli + + +@pytest.fixture +def mock_loader(monkeypatch): + """ + Pretend that the input contains only a single global dataset + and no individual datasets. + """ + monkeypatch.setattr( + cli, + "read_multi_obs_input", + lambda path: {"global": MagicMock(), "individual": {}}, + ) + + +@pytest.fixture +def mock_fitter(monkeypatch): + """Mock ECOFFitter so it always returns a simple fixed result.""" + mock_instance = MagicMock() + mock_instance.generate.return_value = (4.0, 2.0, 1.0, 0.5) + + monkeypatch.setattr(cli, "ECOFFitter", MagicMock(return_value=mock_instance)) + return mock_instance + + +@pytest.fixture +def mock_report(monkeypatch): + """Mock GenerateReport.from_fitter.""" + report_instance = MagicMock() + report_cls = MagicMock() + report_cls.from_fitter.return_value = report_instance + + monkeypatch.setattr(cli, "GenerateReport", report_cls) + return report_instance + + +@pytest.fixture +def mock_validate(monkeypatch): + mock = MagicMock() + monkeypatch.setattr(cli, "validate_output_path", mock) + return mock + + +def test_parser_accepts_basic_args(): + parser = cli.build_parser() + args = parser.parse_args(["--input", "data.csv", "--percentile", "95"]) + assert args.input == "data.csv" + assert args.percentile == 95 + + +def test_parser_requires_input(): + parser = cli.build_parser() + with pytest.raises(SystemExit): + parser.parse_args([]) + +def test_main_runs_minimal(mock_loader, mock_fitter, mock_report, capsys): + cli.main(["--input", "fake.csv"]) + # Output contains header + out = capsys.readouterr().out + assert "ECOFF RESULTS" in out + # ECOFFitter used + assert cli.ECOFFitter.called + # global fitter generate called + mock_fitter.generate.assert_called_once() + + +def test_main_outfile_txt(mock_loader, mock_fitter, mock_report, mock_validate): + cli.main(["--input", "fake.csv", "--outfile", "results.txt"]) + mock_validate.assert_called_once_with("results.txt") + + +def test_main_outfile_pdf(mock_loader, mock_fitter, mock_report, mock_validate): + cli.main(["--input", "fake.csv", "--outfile", "report.pdf"]) + mock_validate.assert_called_once_with("report.pdf") + + +def test_main_runs_with_multiple_individuals(monkeypatch, mock_fitter, mock_report, mock_validate): + """ + Ensure the CLI runs when multiple individual datasets are present + and that CombinedReport is invoked. + """ + + # Make two fake individual datasets + fake_global = MagicMock() + fake_indiv1 = MagicMock() + fake_indiv2 = MagicMock() + + def fake_loader(path): + return { + "global": fake_global, + "individual": { + "A": fake_indiv1, + "B": fake_indiv2, + }, + } + + monkeypatch.setattr(cli, "read_multi_obs_input", fake_loader) + + # Mock CombinedReport so we can detect when it's used + combined_instance = MagicMock() + combined_cls = MagicMock(return_value=combined_instance) + monkeypatch.setattr(cli, "CombinedReport", combined_cls) + + cli.main(["--input", "fake.csv", "--outfile", "combined.pdf"]) + + mock_validate.assert_called_once_with("combined.pdf") + + combined_cls.assert_called_once() + combined_instance.save_pdf.assert_called_once() diff --git a/tests/test_ecoff_fitter.py b/tests/test_ecoff_fitter.py index 99f8ec2..192dc44 100644 --- a/tests/test_ecoff_fitter.py +++ b/tests/test_ecoff_fitter.py @@ -5,9 +5,7 @@ from ecoff_fitter import ECOFFitter -# ============================================================ # __init__.py AND PACKAGE IMPORT TESTS -# ============================================================ def test_ecoffitter_importable_from_root(): """Package root must expose ECOFFitter.""" @@ -30,9 +28,7 @@ def fake_main(): assert called["hit"] is True -# ============================================================ # DATA FIXTURES -# ============================================================ @pytest.fixture def simple_data(): @@ -50,9 +46,9 @@ def censored_data(): }) -# ============================================================ + # INITIALIZATION -# ============================================================ + def test_init_with_dataframe(simple_data): fitter = ECOFFitter(simple_data, dilution_factor=2, distributions=1) @@ -62,9 +58,7 @@ def test_init_with_dataframe(simple_data): assert "observations" in fitter.obj_df.columns -# ============================================================ # INTERVAL CONSTRUCTION -# ============================================================ def test_define_intervals_uncensored(simple_data): fitter = ECOFFitter(simple_data) diff --git a/tests/test_report.py b/tests/test_report.py index 678d07b..0d8714c 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -1,29 +1,35 @@ import io import sys import pytest -from unittest.mock import MagicMock, patch, call +from unittest.mock import MagicMock, patch, call, ANY import ecoff_fitter.cli as cli import ecoff_fitter.report as report -# have called a combination of cli.GenerateREport and report.GenerateReport for depth +@pytest.fixture +def mock_loader(monkeypatch): + """ + Ensures CLI never touches the filesystem and always receives + one global dataset and zero individual datasets (simplest case). + """ + monkeypatch.setattr( + cli, + "read_multi_obs_input", + lambda _: {"global": MagicMock(), "individual": {}}, + ) + @pytest.fixture def mock_fitter(monkeypatch): - """Mock ECOFFitter to avoid real fitting.""" mock = MagicMock(name="ECOFFitterMock") - - # New result format: (ecoff, z, mu, sigma) mock.generate.return_value = (4.0, 2.0, 1.0, 0.5) - monkeypatch.setattr(cli, "ECOFFitter", MagicMock(return_value=mock)) return mock @pytest.fixture def mock_report(monkeypatch): - """Mock GenerateReport to avoid plotting and file writing.""" report_instance = MagicMock(name="ReportInstance") report_cls = MagicMock() report_cls.from_fitter.return_value = report_instance @@ -33,29 +39,23 @@ def mock_report(monkeypatch): @pytest.fixture def mock_validate(monkeypatch): - """Mock validate_output_path to skip filesystem checks.""" mock = MagicMock() monkeypatch.setattr(cli, "validate_output_path", mock) return mock -# ----------------------------- -# Basic parser and help tests -# ----------------------------- +# Basic parser tests def test_build_parser_help(capsys): - """Parser should show usage and required arguments.""" parser = cli.build_parser() with pytest.raises(SystemExit): parser.parse_args([]) - captured = capsys.readouterr() - output = captured.err or captured.out - assert "usage:" in output.lower() + output = (capsys.readouterr().err or "").lower() + assert "usage" in output assert "--input" in output def test_parser_accepts_basic_args(): - """Parser should correctly interpret key CLI arguments.""" parser = cli.build_parser() args = parser.parse_args( ["--input", "data.csv", "--distributions", "2", "--percentile", "97.5"] @@ -65,253 +65,191 @@ def test_parser_accepts_basic_args(): assert args.percentile == 97.5 -# ----------------------------- -# Integration-like CLI tests -# ----------------------------- - -def test_main_runs_with_minimal_args(mock_fitter, mock_report, monkeypatch): - """Main should call ECOFFitter and GenerateReport correctly.""" - argv = ["--input", "fake.csv"] +# CLI integration tests - cli.main(argv) +def test_main_runs_with_minimal_args(mock_loader, mock_fitter, mock_report): + cli.main(["--input", "fake.csv"]) - cli.ECOFFitter.assert_called_once_with( - input="fake.csv", - params=None, - dilution_factor=2, - distributions=1, - boundary_support=1, - ) + # ECOFFitter MUST be called at least once + assert cli.ECOFFitter.called + # global fitter generate() must run once mock_fitter.generate.assert_called_once_with(percentile=99.0) - cli.GenerateReport.from_fitter.assert_called_once() - - -def test_main_verbose_flag_triggers_print(mock_fitter, mock_report, capsys): - """Verbose flag should pass through to print_stats(verbose=True).""" - argv = ["--input", "data.csv", "--verbose"] - cli.main(argv) - mock_report.print_stats.assert_called_once_with(True) + # one GenerateReport created + cli.GenerateReport.from_fitter.assert_called() -def test_main_outfile_txt(mock_fitter, mock_report, mock_validate): - """If outfile ends with .txt, report.write_out() should be used.""" - argv = ["--input", "file.csv", "--outfile", "result.txt"] - cli.main(argv) - mock_validate.assert_called_once_with("result.txt") - mock_report.write_out.assert_called_once_with("result.txt") - mock_report.save_pdf.assert_not_called() -def test_main_outfile_pdf(mock_fitter, mock_report, mock_validate): - """If outfile ends with .pdf, report.save_pdf() should be used.""" - argv = ["--input", "file.csv", "--outfile", "report.pdf"] - cli.main(argv) - mock_validate.assert_called_once_with("report.pdf") - mock_report.save_pdf.assert_called_once_with("report.pdf") - mock_report.write_out.assert_not_called() - - -def test_main_invalid_percentile_raises(monkeypatch): - """Percentile outside 0–100 should raise AssertionError.""" +def test_main_invalid_percentile_prints_error(mock_loader, monkeypatch, capsys): mock_fitter = MagicMock() - mock_fitter.generate.side_effect = AssertionError( - "percentile must be between 0 and 100" - ) + mock_fitter.generate.side_effect = AssertionError("percentile must be between 0 and 100") + monkeypatch.setattr(cli, "ECOFFitter", MagicMock(return_value=mock_fitter)) - argv = ["--input", "data.csv", "--percentile", "200"] - with pytest.raises(AssertionError): - cli.main(argv) + cli.main(["--input", "data.csv", "--percentile", "200"]) + + out = capsys.readouterr().out.lower() + assert "error" in out + assert "percentile" in out -# ----------------------------- -# GenerateReport.from_fitter -# ----------------------------- +# GenerateReport tests def test_generate_report_from_fitter_single_distribution(): - """from_fitter should correctly build a report for 1-distribution models.""" fitter = MagicMock() fitter.distributions = 1 fitter.dilution_factor = 2 - fitter.mus_ = [1.0] fitter.sigmas_ = [0.5] - fitter.define_intervals.return_value = ("low", "high", "weights") + fitter.compute_ecoff.side_effect = [(10,), (8,), (6,)] - fitter.compute_ecoff.side_effect = [ - (10.0,), # 99 - (8.0,), # 97.5 - (6.0,), # 95 - ] - - # result tuple is ignored for mus/sigmas in the new API - result = (4.0, "ignored", 1.0, 0.5) - - r = report.GenerateReport.from_fitter(fitter, result) + r = report.GenerateReport.from_fitter(fitter, (4.0, None)) assert r.ecoff == 4.0 assert r.mus == [1.0] assert r.sigmas == [0.5] - - assert r.z == (10.0, 8.0, 6.0) - assert r.intervals == ("low", "high", "weights") - + assert r.z == (10, 8, 6) def test_generate_report_from_fitter_two_distributions(): - """from_fitter should correctly build a report for 2-distribution models.""" fitter = MagicMock() fitter.distributions = 2 fitter.dilution_factor = 2 - fitter.mus_ = [1.0, 2.0] fitter.sigmas_ = [0.2, 0.5] - fitter.define_intervals.return_value = ("low", "high", "weights") + fitter.compute_ecoff.side_effect = [(10,), (8,), (6,)] - fitter.compute_ecoff.side_effect = [ - (10.0,), # 99 - (8.0,), # 97.5 - (6.0,), # 95 - ] - - # result tuple is ignored for mus/sigmas in new API - result = (4.0, "ignored", 1.0, 0.2, 2.0, 0.5) + r = report.GenerateReport.from_fitter(fitter, (4.0, None)) - r = report.GenerateReport.from_fitter(fitter, result) - - assert r.ecoff == 4.0 assert r.mus == [1.0, 2.0] assert r.sigmas == [0.2, 0.5] - - assert r.z == (10.0, 8.0, 6.0) - assert r.intervals == ("low", "high", "weights") + assert r.z == (10, 8, 6) - -# ----------------------------- -# Write-out tests -# ----------------------------- +# write_out def test_generate_report_write_out(tmp_path): - """write_out should write correct text output for a 2-distribution report.""" path = tmp_path / "out.txt" - # ---- Mock a minimal fitter ---- fitter = MagicMock() fitter.distributions = 2 fitter.dilution_factor = 2 - fitter.mus_ = [1.0, 2.0] + fitter.mus_ = [1, 2] fitter.sigmas_ = [0.2, 0.4] fitter.define_intervals.return_value = ("a", "b", "c") - # ---- Build report using new API ---- - r = cli.GenerateReport( - fitter=fitter, - ecoff=4.0, - z=(10.0, 8.0, 6.0), - ) + r = cli.GenerateReport(fitter=fitter, ecoff=4.0, z=(10, 8, 6)) r.write_out(str(path)) text = path.read_text() - # Check essential content - assert "ECOFF: 4.00" in text - assert "99th percentile: 10.00" in text - assert "97.5th percentile: 8.00" in text - assert "95th percentile: 6.00" in text + assert "ECOFF: 4.0000" in text + assert "log scale:" in text - # Component values must still appear exactly - assert f"{2**1.0}" in text - assert f"{2**0.2}" in text - assert f"{2**2.0}" in text - assert f"{2**0.4}" in text + # Rounded 4-decimal sigma outputs + assert "1.1487" in text # 2**0.2 + assert "1.3195" in text # 2**0.4 + + +# save_pdf tests @patch("ecoff_fitter.report.plot_mic_distribution") @patch("ecoff_fitter.report.PdfPages") @patch("ecoff_fitter.report.plt") def test_generate_report_save_pdf(mock_plt, mock_pdf, mock_plot): - """save_pdf should produce a PDF using PdfPages.""" - fake_fig = MagicMock(name="Figure") - fake_ax1 = MagicMock(name="PlotAxis") - fake_ax2 = MagicMock(name="TextAxis") + fake_fig = MagicMock() + fake_ax1 = MagicMock() + fake_ax2 = MagicMock() mock_plt.subplots.return_value = (fake_fig, (fake_ax1, fake_ax2)) mock_pdf.return_value.__enter__.return_value = MagicMock() - # ---- mock fitter ---- fitter = MagicMock() fitter.distributions = 1 fitter.dilution_factor = 2 - fitter.mus_ = [1.0] + fitter.mus_ = [1] fitter.sigmas_ = [0.2] fitter.define_intervals.return_value = ("low", "high", "weights") - r = cli.GenerateReport( - fitter=fitter, - ecoff=4.0, - z=(10.0, 8.0, 6.0), - ) + r = cli.GenerateReport(fitter=fitter, ecoff=4.0, z=(10, 8, 6)) r.save_pdf("out.pdf") mock_pdf.assert_called_once_with("out.pdf") - mock_plot.assert_called_once() - fake_ax1.legend.assert_called_once() fake_ax2.axis.assert_called_once_with("off") mock_plt.close.assert_called_once_with(fake_fig) -def test_generate_report_print_stats_single_dist(capsys): - # ---- Mock fitter ---- + +# to_text tests +def test_generate_report_to_text_single_dist(): fitter = MagicMock() fitter.distributions = 1 fitter.dilution_factor = 2 fitter.mus_ = [1.0] fitter.sigmas_ = [0.5] - fitter.define_intervals.return_value = ("a", "b", "c") - r = cli.GenerateReport( - fitter=fitter, - ecoff=4.0, - z=(10, 8, 6), - ) + r = cli.GenerateReport(fitter=fitter, ecoff=4.0, z=(10, 8, 6)) + text = r.to_text() - r.print_stats(verbose=False) - out = capsys.readouterr().out + assert "ECOFF: 4.0000" in text + assert "log scale: 2.0000" in text + assert "μ = 2.0000" in text + assert "σ (folds) = 1.4142" in text - assert "ECOFF (original scale): 4" in out - assert "μ: 2.00" in out # 2**1.0 - assert "σ (folds): 1.41" in out # 2**0.5 - assert "Model details" not in out -def test_generate_report_print_stats_two_dist_verbose(capsys): - # ---- Mock fitter ---- +def test_generate_report_to_text_two_dist_verbose(): fitter = MagicMock() fitter.distributions = 2 fitter.dilution_factor = 2 - fitter.mus_ = [1.0, 2.0] + fitter.mus_ = [1, 2] fitter.sigmas_ = [0.2, 0.5] - fitter.define_intervals.return_value = ("a", "b", "c") fitter.model_ = "FAKE_MODEL_DETAILS" - r = cli.GenerateReport( - fitter=fitter, - ecoff=4.0, - z=(10, 8, 6), + r = cli.GenerateReport(fitter=fitter, ecoff=4.0, z=(10, 8, 6)) + text = r.to_text(verbose=True) + + assert "Component 1" in text + assert "Component 2" in text + assert "--- Model details ---" in text + assert "FAKE_MODEL_DETAILS" in text + +def test_combined_report_write_out(tmp_path): + # CombinedReport now writes to its outfile + outfile = tmp_path / "combined.txt" + + # Mock reports + global_report = MagicMock() + global_report.to_text.return_value = "GLOBAL TEXT\n" + + report_A = MagicMock() + report_A.to_text.return_value = "REPORT A TEXT\n" + + report_B = MagicMock() + report_B.to_text.return_value = "REPORT B TEXT\n" + + combined = report.CombinedReport( + outfile=str(outfile), + global_report=global_report, + individual_reports={"A": report_A, "B": report_B}, ) - r.print_stats(verbose=True) - out = capsys.readouterr().out + # Act + combined.write_out() # NO ARGUMENT NOW + + text = outfile.read_text() + + assert "===== GLOBAL FIT =====" in text + assert "GLOBAL TEXT" in text - # WT component (lowest mean) - assert "μ1: 2.0000" in out - assert "σ1 (folds): 1.1487" in out # 2**0.2 + assert "===== INDIVIDUAL FIT: A =====" in text + assert "REPORT A TEXT" in text - # Resistant component - assert "μ2: 4.0000" in out - assert "σ2 (folds): 1.4142" in out # 2**0.5 + assert "===== INDIVIDUAL FIT: B =====" in text + assert "REPORT B TEXT" in text + global_report.to_text.assert_called_with(label="GLOBAL FIT") + report_A.to_text.assert_called_with(label="A") + report_B.to_text.assert_called_with(label="B") - assert "Model details" in out - assert "FAKE_MODEL_DETAILS" in out