From ac254613d2f91a2a0e6f8bdec4dd2c7e252f6f04 Mon Sep 17 00:00:00 2001 From: ShehaoLi Date: Tue, 2 Jun 2026 03:15:34 -0700 Subject: [PATCH] Improve calibration export workflow receipts --- docs/operator_guide.md | 7 + .../services/calibration_receipts.py | 81 +++++++++++ .../application/services/export_receipts.py | 66 +++++++++ src/app/presentation/tk/controller.py | 72 +++++++++- src/app/presentation/tk/run_panel.py | 10 +- src/app/presentation/tk/ui_event_handler.py | 37 ++++- src/app/presentation/tk/view_model.py | 2 + tests/test_workflow_receipts.py | 126 ++++++++++++++++++ 8 files changed, 389 insertions(+), 12 deletions(-) create mode 100644 src/app/application/services/calibration_receipts.py create mode 100644 src/app/application/services/export_receipts.py create mode 100644 tests/test_workflow_receipts.py diff --git a/docs/operator_guide.md b/docs/operator_guide.md index c64a3b6..4283659 100644 --- a/docs/operator_guide.md +++ b/docs/operator_guide.md @@ -27,6 +27,10 @@ This guide summarizes the live workflow for the desktop app. The original Word g - Free-run mode captures without arming an edge trigger. - Triggered mode arms the selected oscilloscope trigger channel. +After loading a reference MAT file, the right-side receipt shows the filename, directory, frequency coverage, +point count, whether phase data exists, whether reference correction is active, and whether the current sweep +range extends beyond the reference coverage. Out-of-range reference correction uses edge-clamped values. + ## 4. Run A Sweep 1. Review hardware settings and safety limits. @@ -38,6 +42,9 @@ This guide summarizes the live workflow for the desktop app. The original Word g ## 5. Output Files Saving a measurement writes MAT, CSV, and TXT files. If plot figures are supplied, gain and gain-dB PNG files are also written. +After a manual save, the export receipt shows the output directory, artifact list, source, correction mode, +point count, timestamp, and whether the data came from the simulated no-hardware fixture path or a live-path run. +The receipt is an operator aid; it is not live hardware validation by itself. The CSV columns are: diff --git a/src/app/application/services/calibration_receipts.py b/src/app/application/services/calibration_receipts.py new file mode 100644 index 0000000..891acf2 --- /dev/null +++ b/src/app/application/services/calibration_receipts.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +import numpy as np + +from app.domain.models import AppSettings, ReferenceCurve + + +@dataclass(frozen=True, slots=True) +class ReferenceReceipt: + summary: str + warnings: tuple[str, ...] = () + + +def build_reference_receipt( + *, + path: Path, + curve: ReferenceCurve, + settings: AppSettings | None, + calibration_enabled: bool, +) -> ReferenceReceipt: + freq = np.atleast_1d(np.asarray(curve.freq_hz, dtype=float).squeeze()) + phase = None if curve.phase_deg is None else np.atleast_1d(np.asarray(curve.phase_deg, dtype=float).squeeze()) + + point_count = int(freq.size) + phase_present = phase is not None and phase.size > 0 and bool(np.any(np.isfinite(phase))) + coverage_text = "coverage unavailable" + warnings: list[str] = [] + + if point_count > 0: + finite_freq = freq[np.isfinite(freq)] + if finite_freq.size > 0: + ref_min = float(np.min(finite_freq)) + ref_max = float(np.max(finite_freq)) + coverage_text = f"{_format_frequency(ref_min)} - {_format_frequency(ref_max)}" + if ref_min == ref_max: + warnings.append("Reference has one frequency; all sweep points use the same reference value.") + if settings is not None: + sweep_min = min(settings.sweep.start_hz, settings.sweep.stop_hz) + sweep_max = max(settings.sweep.start_hz, settings.sweep.stop_hz) + if sweep_min < ref_min or sweep_max > ref_max: + warnings.append( + "Sweep range is outside reference coverage; out-of-range points will use edge-clamped reference values." + ) + else: + warnings.append("Reference frequency data has no finite values.") + else: + warnings.append("Reference frequency data is empty.") + + if settings is None: + warnings.append("Sweep settings unavailable; reference coverage was not checked against the current sweep.") + + correction_text = "active" if calibration_enabled else "inactive" + run_mode_text = settings.run_mode.correction_mode.value if settings is not None else "unknown" + sweep_text = _format_sweep_range(settings) if settings is not None else "sweep unavailable" + dir_text = str(path.parent) if str(path.parent) else "." + + summary = "\n".join( + [ + f"Reference: {path.name}", + f"Dir: {dir_text}", + f"Coverage: {coverage_text} · {point_count} pts · phase {'yes' if phase_present else 'no'}", + f"Correction: {correction_text} · mode {run_mode_text}", + f"Sweep: {sweep_text}", + ] + ) + return ReferenceReceipt(summary=summary, warnings=tuple(warnings)) + + +def _format_sweep_range(settings: AppSettings) -> str: + return f"{_format_frequency(settings.sweep.start_hz)} - {_format_frequency(settings.sweep.stop_hz)}" + + +def _format_frequency(freq_hz: float) -> str: + if abs(freq_hz) >= 1_000_000: + return f"{freq_hz / 1_000_000:.3g} MHz" + if abs(freq_hz) >= 1_000: + return f"{freq_hz / 1_000:.3g} kHz" + return f"{freq_hz:.3g} Hz" diff --git a/src/app/application/services/export_receipts.py b/src/app/application/services/export_receipts.py new file mode 100644 index 0000000..749e70c --- /dev/null +++ b/src/app/application/services/export_receipts.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path + +from app.application.dto import SaveArtifacts +from app.domain.models import AppSettings, SweepResult + + +@dataclass(frozen=True, slots=True) +class ExportReceipt: + summary: str + artifacts: tuple[Path, ...] + + +def build_export_receipt( + *, + artifacts: SaveArtifacts, + settings: AppSettings, + result: SweepResult, + source_text: str, + fixture_badge_text: str, + saved_at: datetime | None = None, +) -> ExportReceipt: + saved_at = saved_at or datetime.now() + paths = _artifact_paths(artifacts) + artifact_names = ", ".join(path.name for path in paths) + boundary = _boundary_text(source_text=source_text, fixture_badge_text=fixture_badge_text) + + summary = "\n".join( + [ + f"Saved: {artifacts.mat_path.name}", + f"Dir: {artifacts.mat_path.parent}", + f"Artifacts: {artifact_names}", + ( + f"Metadata: source={source_text or 'unknown'}; " + f"correction={settings.run_mode.correction_mode.value}; " + f"points={len(result.points)}; " + f"saved={saved_at.strftime('%Y-%m-%d %H:%M:%S')}" + ), + f"Boundary: {boundary}", + ] + ) + return ExportReceipt(summary=summary, artifacts=paths) + + +def _artifact_paths(artifacts: SaveArtifacts) -> tuple[Path, ...]: + paths = [ + artifacts.mat_path, + artifacts.csv_path, + artifacts.txt_path, + artifacts.gain_plot_path, + artifacts.db_plot_path, + ] + return tuple(path for path in paths if path is not None) + + +def _boundary_text(*, source_text: str, fixture_badge_text: str) -> str: + source_lower = source_text.lower() + fixture_lower = fixture_badge_text.lower() + if "fixture" in source_lower or "simulated" in fixture_lower or "no hardware" in fixture_lower: + return "No hardware simulated fixture; not live hardware validation" + if "loaded measurement" in source_lower: + return "Loaded file; live hardware state not implied" + return "Live path selected; export does not prove hardware validation" diff --git a/src/app/presentation/tk/controller.py b/src/app/presentation/tk/controller.py index ea8e1e4..0d1ae64 100644 --- a/src/app/presentation/tk/controller.py +++ b/src/app/presentation/tk/controller.py @@ -18,13 +18,15 @@ format_connection_receipt, format_scan_receipt, ) +from app.application.services.calibration_receipts import build_reference_receipt +from app.application.services.export_receipts import build_export_receipt from app.application.services.sweep_task_runner import SweepTaskRunner from app.application.services.connection_monitor import ConnectionMonitor from app.application.use_cases.load_measurement import LoadMeasurementUseCase from app.application.use_cases.load_reference import LoadReferenceUseCase from app.application.use_cases.save_measurement import SaveMeasurementUseCase from app.application.use_cases.settings_use_case import SettingsUseCase -from app.domain.models import InstrumentEndpoint +from app.domain.models import InstrumentEndpoint, ReferenceCurve from app.presentation.tk import dialogs from app.presentation.tk.app_window import AppWindow from app.presentation.tk.mapper import settings_to_vm, vm_to_settings @@ -61,6 +63,9 @@ def __init__( self._event_queue: queue.Queue[object] = queue.Queue() self._reference_interpolator = None + self._reference_curve: ReferenceCurve | None = None + self._reference_path: Path | None = None + self._reference_trace_tokens: list[str] = [] self._paths = paths or AppPaths.default() self._resolve_address = resolve_address self._connection_target_lock = threading.Lock() @@ -99,6 +104,7 @@ def initialize(self) -> None: on_figure_change=self.on_figure_change, on_mag_phase_change=self.on_mag_phase_change, ) + self._bind_reference_receipt_traces() try: settings = self.settings_use_case.load() @@ -122,6 +128,9 @@ def on_start(self) -> None: try: settings = vm_to_settings(self.vm) self._ui_handler.set_live_source() + self._refresh_reference_receipt(record=True) + if bool(self.vm.calibration_enabled.get()) and self._reference_interpolator is None: + self._ui_handler.record_event("Calibration enabled but no reference loaded", level="Warning") self._refresh_connection_targets(settings) self._task_runner.start( settings=settings, @@ -176,9 +185,17 @@ def on_save_data(self) -> None: settings=settings, target=dialogs_to_target(fp, self.window), ) - self._ui_handler.set_export_saved(path_name=artifacts.mat_path.name) + receipt = build_export_receipt( + artifacts=artifacts, + settings=settings, + result=self._ui_handler.latest_result, + source_text=self.vm.data_source_text.get(), + fixture_badge_text=self.vm.fixture_badge_text.get(), + ) + self._ui_handler.set_export_saved(receipt_text=receipt.summary) dialogs.show_info(self.window, f"Saved: {artifacts.mat_path.name}") except Exception as exc: # noqa: BLE001 + self._ui_handler.record_event(f"Save data failed: {exc}", level="Warning") dialogs.show_warning(self.window, f"Failed to save data: {exc}") def on_load_data(self) -> None: @@ -222,12 +239,19 @@ def on_load_reference(self) -> None: return try: - _curve, interpolator = self.load_reference_use_case.execute(str(fp)) + curve, interpolator = self.load_reference_use_case.execute(str(fp)) self._reference_interpolator = interpolator + self._reference_curve = curve + self._reference_path = Path(fp) self.vm.calibration_enabled.set(True) - self._ui_handler.set_reference_loaded(path_name=Path(fp).name) + warnings = self._refresh_reference_receipt(record=True) + if warnings: + self.vm.status_text.set("Reference loaded with coverage warning") + else: + self.vm.status_text.set("Reference loaded") dialogs.show_info(self.window, "Reference loaded") except Exception as exc: # noqa: BLE001 + self._ui_handler.record_event(f"Reference load failed: {exc}", level="Warning") dialogs.show_warning(self.window, f"Failed to load reference: {exc}") def on_scan_resources(self) -> None: @@ -237,6 +261,7 @@ def on_scan_resources(self) -> None: self.vm.status_text.set("Resource scan completed") except Exception as exc: # noqa: BLE001 self.vm.discovery_status_text.set(f"Resource scan failed: {exc}") + self._ui_handler.record_event(f"Resource scan failed: {exc}", level="Warning") dialogs.show_warning(self.window, f"Resource scan failed: {exc}") def on_test_connect(self) -> None: @@ -248,6 +273,7 @@ def on_test_connect(self) -> None: self.vm.status_text.set("Connection test completed") except Exception as exc: # noqa: BLE001 self.vm.discovery_status_text.set(f"Connection test failed: {exc}") + self._ui_handler.record_event(f"Connection test failed: {exc}", level="Warning") dialogs.show_warning(self.window, f"Connection test failed: {exc}") def on_figure_change(self) -> None: @@ -330,6 +356,44 @@ def _apply_connection_checks(self, checks) -> None: else: self.vm.osc_connection_text.set(text) + def _bind_reference_receipt_traces(self) -> None: + variables = [ + self.vm.freq_unit, + self.vm.start_freq, + self.vm.stop_freq, + self.vm.calibration_enabled, + self.vm.correction_mode, + self.vm.trigger_mode, + ] + for variable in variables: + token = variable.trace_add("write", self._on_reference_setting_changed) + self._reference_trace_tokens.append(token) + + def _on_reference_setting_changed(self, *_args) -> None: + self._refresh_reference_receipt(record=False) + + def _refresh_reference_receipt(self, *, record: bool) -> tuple[str, ...]: + if self._reference_curve is None or self._reference_path is None: + return () + + try: + settings = vm_to_settings(self.vm) + except Exception: + settings = None + + receipt = build_reference_receipt( + path=self._reference_path, + curve=self._reference_curve, + settings=settings, + calibration_enabled=bool(self.vm.calibration_enabled.get()), + ) + self._ui_handler.set_reference_loaded( + receipt_text=receipt.summary, + warnings=receipt.warnings, + record=record, + ) + return receipt.warnings + def dialogs_to_target(path, window: AppWindow): from app.application.dto import SaveTarget diff --git a/src/app/presentation/tk/run_panel.py b/src/app/presentation/tk/run_panel.py index 7dcb82b..0744909 100644 --- a/src/app/presentation/tk/run_panel.py +++ b/src/app/presentation/tk/run_panel.py @@ -49,7 +49,8 @@ def _build(self) -> None: self._build_run_card(1) self._build_export_card(2) self._build_source_card(3) - self._build_safety_card(4) + self._build_event_card(4) + self._build_safety_card(5) def _title(self, text: str, row: int) -> None: label = tk.Label( @@ -141,7 +142,8 @@ def _build_export_card(self, row: int) -> None: self.btn_load_settings = ttk.Button(actions, text="Load Settings") self.btn_load_settings.grid(row=2, column=1, sticky="ew") - self._wrapped_label(card, self._vm.export_receipt_text, 1, MUTED) + self._wrapped_label(card, self._vm.reference_receipt_text, 1, MUTED) + self._wrapped_label(card, self._vm.export_receipt_text, 2, MUTED) def _build_source_card(self, row: int) -> None: card = self._card("Source receipt", row) @@ -173,6 +175,10 @@ def _build_safety_card(self, row: int) -> None: tk.Label(line, text="□", bg=CARD_BG, fg=AMBER, width=2, anchor="w").pack(side=tk.LEFT) tk.Label(line, text=item, bg=CARD_BG, fg=TEXT, anchor="w").pack(side=tk.LEFT, fill=tk.X, expand=True) + def _build_event_card(self, row: int) -> None: + card = self._card("Event history", row) + self._wrapped_label(card, self._vm.event_history_text, 0, MUTED) + def _metric( self, parent: tk.Misc, diff --git a/src/app/presentation/tk/ui_event_handler.py b/src/app/presentation/tk/ui_event_handler.py index f554711..56738d7 100644 --- a/src/app/presentation/tk/ui_event_handler.py +++ b/src/app/presentation/tk/ui_event_handler.py @@ -24,6 +24,7 @@ def __init__(self, *, window: AppWindow, vm: ViewModel) -> None: self._vm = vm self._latest_result = SweepResult() self._started_at: float | None = None + self._event_history: list[str] = [] @property def latest_result(self) -> SweepResult: @@ -39,6 +40,7 @@ def prepare_for_sweep_start(self) -> None: self._vm.latest_frequency_text.set("-") self._vm.export_receipt_text.set("No export yet") self.set_live_source() + self.record_event("Sweep started") def set_result(self, result: SweepResult, *, refresh_plot: bool = True) -> None: self._latest_result = result @@ -62,6 +64,7 @@ def set_fixture_source(self, *, label: str, path_name: str) -> None: self._vm.progress_text.set(f"{point_count} / {point_count}") self._vm.latest_frequency_text.set(_format_frequency(self._latest_result.points[-1].freq_hz)) self._vm.elapsed_text.set("00:00") + self.record_event(f"Loaded simulated fixture: {path_name}") def set_loaded_source(self, *, path_name: str) -> None: self._vm.data_source_text.set(f"Loaded measurement · {path_name}") @@ -69,12 +72,30 @@ def set_loaded_source(self, *, path_name: str) -> None: self._vm.validation_receipt_text.set("Loaded file; live hardware state not implied") self._vm.export_receipt_text.set("Loaded measurement; Save Data exports the current result") self._vm.run_state_text.set("Data loaded") - - def set_reference_loaded(self, *, path_name: str) -> None: - self._vm.validation_receipt_text.set(f"Reference loaded: {path_name}") - - def set_export_saved(self, *, path_name: str) -> None: - self._vm.export_receipt_text.set(f"Saved measurement: {path_name}") + self.record_event(f"Loaded measurement: {path_name}") + + def set_reference_loaded( + self, + *, + receipt_text: str, + warnings: tuple[str, ...] = (), + record: bool = True, + ) -> None: + self._vm.reference_receipt_text.set(receipt_text) + if record: + self.record_event("Reference receipt updated") + for warning in warnings: + self.record_event(warning, level="Warning") + + def set_export_saved(self, *, receipt_text: str) -> None: + self._vm.export_receipt_text.set(receipt_text) + self.record_event("Export artifacts saved") + + def record_event(self, message: str, *, level: str = "Info") -> None: + line = f"{level}: {message}" + self._event_history.append(line) + self._event_history = self._event_history[-5:] + self._vm.event_history_text.set("\n".join(self._event_history)) def refresh_plot(self) -> None: self._window.plot_widget.update_result( @@ -107,6 +128,7 @@ def handle(self, event: object) -> None: return if isinstance(event, SweepWarning): + self.record_event(event.message, level="Warning") if event.code in {"READY", "FREQ_MISMATCH", "AMP_MISMATCH"}: self._vm.status_text.set(event.message) else: @@ -118,6 +140,7 @@ def handle(self, event: object) -> None: self._vm.run_state_text.set("Failed") self._window.btn_start.configure(state="normal") self._window.btn_stop.configure(state="disabled") + self.record_event(event.message, level="Error") dialogs.show_warning(self._window, event.message) return @@ -128,6 +151,7 @@ def handle(self, event: object) -> None: self._vm.elapsed_text.set(_format_elapsed(self._started_at)) self._window.btn_start.configure(state="normal") self._window.btn_stop.configure(state="disabled") + self.record_event("Sweep stopped") return if isinstance(event, SweepCompleted): @@ -138,6 +162,7 @@ def handle(self, event: object) -> None: self._vm.elapsed_text.set(_format_elapsed(self._started_at)) self._window.btn_start.configure(state="normal") self._window.btn_stop.configure(state="disabled") + self.record_event("Sweep completed") def _format_elapsed(started_at: float | None) -> str: diff --git a/src/app/presentation/tk/view_model.py b/src/app/presentation/tk/view_model.py index 9ccfb4c..380fff6 100644 --- a/src/app/presentation/tk/view_model.py +++ b/src/app/presentation/tk/view_model.py @@ -57,7 +57,9 @@ def __init__(self, root: tk.Misc) -> None: self.data_source_text = tk.StringVar(root, value="Live instrument path") self.fixture_badge_text = tk.StringVar(root, value="") self.validation_receipt_text = tk.StringVar(root, value="Live run requires operator hardware checks") + self.reference_receipt_text = tk.StringVar(root, value="No reference loaded") self.export_receipt_text = tk.StringVar(root, value="No export yet") + self.event_history_text = tk.StringVar(root, value="No warnings or workflow events") self.awg_connection_text = tk.StringVar(root, value="AWG offline") self.osc_connection_text = tk.StringVar(root, value="OSC offline") self.discovery_status_text = tk.StringVar(root, value="Scan VISA resources or test current addresses") diff --git a/tests/test_workflow_receipts.py b/tests/test_workflow_receipts.py new file mode 100644 index 0000000..fdf4bd0 --- /dev/null +++ b/tests/test_workflow_receipts.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from datetime import datetime +import sys +from pathlib import Path +import tkinter as tk +import unittest + +import numpy as np + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from app.application.dto import SaveArtifacts +from app.application.services.calibration_receipts import build_reference_receipt +from app.application.services.export_receipts import build_export_receipt +from app.domain.enums import CorrectionMode +from app.domain.models import ReferenceCurve, SweepPoint, SweepResult +from app.infrastructure.persistence.settings_defaults import DefaultSettingsFactory +from app.presentation.tk.ui_event_handler import UiEventHandler +from app.presentation.tk.view_model import ViewModel + + +class WorkflowReceiptTests(unittest.TestCase): + def test_reference_receipt_reports_coverage_phase_and_active_correction(self) -> None: + settings = DefaultSettingsFactory().create() + settings.sweep.start_hz = 1_000.0 + settings.sweep.stop_hz = 1_000_000.0 + settings.run_mode.correction_mode = CorrectionMode.DUAL + curve = ReferenceCurve( + freq_hz=np.array([1_000.0, 10_000.0, 1_000_000.0]), + gain_db=np.array([0.1, 0.2, 0.3]), + phase_deg=np.array([1.0, 2.0, 3.0]), + ) + + receipt = build_reference_receipt( + path=Path("/tmp/reference_fixture.mat"), + curve=curve, + settings=settings, + calibration_enabled=True, + ) + + self.assertIn("Reference: reference_fixture.mat", receipt.summary) + self.assertIn("1 kHz - 1 MHz", receipt.summary) + self.assertIn("3 pts", receipt.summary) + self.assertIn("phase yes", receipt.summary) + self.assertIn("Correction: active", receipt.summary) + self.assertIn("mode dual", receipt.summary) + self.assertEqual(receipt.warnings, ()) + + def test_reference_receipt_warns_when_sweep_exceeds_coverage(self) -> None: + settings = DefaultSettingsFactory().create() + settings.sweep.start_hz = 100.0 + settings.sweep.stop_hz = 2_000_000.0 + curve = ReferenceCurve( + freq_hz=np.array([1_000.0, 1_000_000.0]), + gain_db=np.array([0.0, 0.5]), + phase_deg=None, + ) + + receipt = build_reference_receipt( + path=Path("/tmp/reference.mat"), + curve=curve, + settings=settings, + calibration_enabled=True, + ) + + self.assertIn("phase no", receipt.summary) + self.assertTrue(any("outside reference coverage" in warning for warning in receipt.warnings)) + self.assertTrue(any("edge-clamped" in warning for warning in receipt.warnings)) + + def test_export_receipt_lists_artifacts_and_no_hardware_boundary(self) -> None: + settings = DefaultSettingsFactory().create() + settings.run_mode.correction_mode = CorrectionMode.DUAL + result = SweepResult( + points=[ + SweepPoint(freq_hz=1_000.0, gain_linear=1.0, gain_db=0.0, phase_deg=1.0), + SweepPoint(freq_hz=2_000.0, gain_linear=2.0, gain_db=6.0, phase_deg=2.0), + ] + ) + artifacts = SaveArtifacts( + mat_path=Path("/tmp/out/measurement.mat"), + csv_path=Path("/tmp/out/measurement.csv"), + txt_path=Path("/tmp/out/measurement.txt"), + gain_plot_path=Path("/tmp/out/measurement_gain.png"), + db_plot_path=None, + ) + + receipt = build_export_receipt( + artifacts=artifacts, + settings=settings, + result=result, + source_text="Fixture replay · hyperframe_simulated_fixture.mat", + fixture_badge_text="No hardware - simulated fixture", + saved_at=datetime(2026, 6, 2, 9, 30, 0), + ) + + self.assertIn("measurement.mat", receipt.summary) + self.assertIn("measurement.csv", receipt.summary) + self.assertIn("measurement.txt", receipt.summary) + self.assertIn("measurement_gain.png", receipt.summary) + self.assertIn("correction=dual", receipt.summary) + self.assertIn("points=2", receipt.summary) + self.assertIn("2026-06-02 09:30:00", receipt.summary) + self.assertIn("No hardware simulated fixture", receipt.summary) + self.assertEqual(len(receipt.artifacts), 4) + + def test_ui_handler_updates_reference_export_and_event_history_state(self) -> None: + root = tk.Tcl() + vm = ViewModel(root) + handler = UiEventHandler(window=object(), vm=vm) + + handler.set_reference_loaded( + receipt_text="Reference: ref.mat\nCoverage: 1 kHz - 1 MHz · 2 pts · phase yes", + warnings=("Sweep range is outside reference coverage.",), + ) + handler.set_export_saved(receipt_text="Saved: measurement.mat\nArtifacts: measurement.mat") + + self.assertIn("Reference: ref.mat", vm.reference_receipt_text.get()) + self.assertIn("Saved: measurement.mat", vm.export_receipt_text.get()) + self.assertIn("Reference receipt updated", vm.event_history_text.get()) + self.assertIn("outside reference coverage", vm.event_history_text.get()) + self.assertIn("Export artifacts saved", vm.event_history_text.get()) + + +if __name__ == "__main__": + unittest.main()