diff --git a/BlocksScreen/lib/panels/widgets/jobStatusPage.py b/BlocksScreen/lib/panels/widgets/jobStatusPage.py index 828d70b2..45efbebe 100644 --- a/BlocksScreen/lib/panels/widgets/jobStatusPage.py +++ b/BlocksScreen/lib/panels/widgets/jobStatusPage.py @@ -89,6 +89,11 @@ def __init__(self, parent) -> None: self._displayed_layer = 0 self._last_z = 0.0 self._filament_used = 0.0 + self._file_position = 0.0 + self._raw_progress = 0.0 + self._gcode_start_byte = 0 + self._gcode_end_byte = 0 + self._layer_frozen = False self._setupUI() self.cancel_print_dialog = BasePopup(self, floating=True) self.tune_menu_btn.clicked.connect(self.tune_clicked.emit) @@ -175,19 +180,33 @@ def handleCancel(self) -> None: pass self.cancel_print_dialog.open() - @QtCore.pyqtSlot(str, name="on_print_start") - def on_print_start(self, file: str) -> None: - """Start a print job, show job status page""" - self._current_file_name = file - self.js_file_name_label.setText(self._current_file_name) - self.layer_display_button.setText("0") + def _reset_job_display(self) -> None: + """Clear all per-job layer/progress state so a new print never shows + stale values. Runs for both screen- and Mainsail-initiated prints + (see ``on_print_start`` and the filename-change branch).""" + self.total_layers = "?" self.total_layer_reported = False + self.layer_fallback = False + self._layer_frozen = False self._displayed_layer = 0 self._last_z = 0.0 self._filament_used = 0.0 + self._file_position = 0.0 + self._raw_progress = 0.0 + self._gcode_start_byte = 0 + self._gcode_end_byte = 0 + self._print_duration = 0.0 + self.layer_display_button.setText("0") + self.layer_display_button.secondary_text = "?" self.print_time_display_button.setText("?") self.printing_progress_bar.reset() - self._print_duration = 0.0 + + @QtCore.pyqtSlot(str, name="on_print_start") + def on_print_start(self, file: str) -> None: + """Start a print job, show job status page""" + self._current_file_name = file + self.js_file_name_label.setText(self._current_file_name) + self._reset_job_display() self.request_file_info.emit(file) self.print_start.emit(file) self._post_event(events.PrintStart(self._current_file_name, self.file_metadata)) @@ -200,6 +219,8 @@ def on_fileinfo(self, metadata: dict) -> None: self.total_layers = str(layer_count) if layer_count >= 0 else "---" self.total_layer_reported = layer_count >= 0 self.layer_display_button.secondary_text = self.total_layers + self._gcode_start_byte = int(metadata.get("gcode_start_byte", 0) or 0) + self._gcode_end_byte = int(metadata.get("gcode_end_byte", 0) or 0) self.file_metadata = metadata self._load_thumbnails(*metadata.get("thumbnail_images", ())) # Reconnect mid-print: metadata just arrived, recompute the current layer now. @@ -213,6 +234,8 @@ def pause_resume_print(self) -> None: 10000, lambda: self.pause_printing_btn.setEnabled(True) ) if self._internal_print_status == "printing": + # Snapshot the layer on click so the park Z-lift never bumps it. + self._layer_frozen = True self.print_pause.emit() if self._internal_print_status == "paused": self.print_resume.emit() @@ -228,6 +251,8 @@ def _handle_print_state(self, state: str) -> None: is_invalid = lstate in self._INVALID_STATES if lstate == "paused": + # Freeze the layer for the whole pause (covers auto-pause too). + self._layer_frozen = True self.pause_printing_btn.setEnabled(True) self.pause_printing_btn.setText("Resume") self.pause_printing_btn.setPixmap( @@ -235,6 +260,7 @@ def _handle_print_state(self, state: str) -> None: ) event_state = "pause" elif lstate == "printing": + self._layer_frozen = False self.pause_printing_btn.setEnabled(True) self.pause_printing_btn.setText("Pause") self.pause_printing_btn.setPixmap( @@ -251,6 +277,10 @@ def _handle_print_state(self, state: str) -> None: elif is_invalid: if lstate == "complete": self.print_finish.emit() + # Show the finished job as 100% on its final layer. + self.printing_progress_bar.set_progress(1.0) + if self.total_layer_reported: + self.layer_display_button.setText(str(self.total_layers)) # Completed/errored print reuses the cancel page as the reprint prompt. if lstate in ("complete", "error") and _was_active: self.call_cancel_panel.emit(True) @@ -259,11 +289,10 @@ def _handle_print_state(self, state: str) -> None: _event_file = self._current_file_name _event_meta = self.file_metadata if is_invalid: + # Keep the final layer/total visible until the next print resets it. self._internal_print_status = "" self._current_file_name = "" - self.total_layers = "?" - self.total_layer_reported = False - self._displayed_layer = 0 + self._layer_frozen = False self._last_z = 0.0 self._filament_used = 0.0 self._print_duration = 0.0 @@ -299,9 +328,14 @@ def on_print_stats_update(self, field: str, value: dict | float | str) -> None: if "state" in field: self._handle_print_state(value) elif "filename" in field: + _new_file = bool(value) and value != self._current_file_name self._current_file_name = value if self.js_file_name_label.text().lower() != value.lower(): self.js_file_name_label.setText(self._current_file_name) + # New file (e.g. Mainsail-started print): clear stale + # layer/progress before its metadata arrives. + if _new_file: + self._reset_job_display() # Fetch metadata even when hidden so layers recover on reconnect. if value: self.request_file_info.emit(value) @@ -318,7 +352,9 @@ def on_print_stats_update(self, field: str, value: dict | float | str) -> None: self.total_layer_reported = False if "current_layer" in value: - if value["current_layer"] is not None: + if self._layer_frozen: + pass # Hold the snapshot while paused. + elif value["current_layer"] is not None: _reported_layer = int(value["current_layer"]) self.layer_display_button.setText(str(_reported_layer)) self._displayed_layer = _reported_layer @@ -353,6 +389,7 @@ def _update_layer_from_z(self) -> None: """Recompute fallback layer from last Z on filament advance, so park/travel Z is ignored (Mainsail getPrintCurrentLayer).""" if ( self._internal_print_status != "printing" + or self._layer_frozen # held while paused (park Z-lift ignored) or not self.layer_fallback or self._print_duration <= 0 # skip pre-print homing/purge moves ): @@ -388,10 +425,32 @@ def virtual_sdcard_update(self, field: str, value: float | bool) -> None: field (str): Name of the updated field on the virtual_sdcard object value (float | bool): The updated information for the corresponding field """ - if not self.isVisible(): - return - if field == "progress": - self.printing_progress_bar.setValue(value) + # Track position/progress always so the bar is correct on next show. + if field == "file_position": + self._file_position = float(value) + elif field == "progress": + self._raw_progress = float(value) + else: + return # is_active and other fields have nothing to render + if self.isVisible(): + self.printing_progress_bar.set_progress(self._compute_progress()) + + def _compute_progress(self) -> float: + """File-relative progress [0, 1], matching Mainsail's default. + + From Mainsail's getPrintPercentByFilepositionRelative (getters.ts): + clip file position to gcode_start_byte/gcode_end_byte so start macros + read 0% and end gcode isn't counted; fall back to virtual_sdcard.progress. + """ + start = self._gcode_start_byte + end = self._gcode_end_byte + if start and end and end > start: + if self._file_position <= start: + return 0.0 + if self._file_position >= end: + return 1.0 + return (self._file_position - start) / (end - start) + return self._raw_progress def _setupUI(self) -> None: """Setup widget ui""" diff --git a/BlocksScreen/lib/utils/blocks_progressbar.py b/BlocksScreen/lib/utils/blocks_progressbar.py index 414097bb..bc08f573 100644 --- a/BlocksScreen/lib/utils/blocks_progressbar.py +++ b/BlocksScreen/lib/utils/blocks_progressbar.py @@ -9,7 +9,6 @@ class CustomProgressBar(QtWidgets.QProgressBar): QtWidgets (QtWidget): Parent widget Raises: - ValueError: Thrown when setting progress is not between 0.0 and 1.0 ValueError: Thrown when setting bar color is not between 0 and 255. """ @@ -25,7 +24,6 @@ def __init__(self, parent=None): self._padding = 50 self._pixmap: QtGui.QPixmap = QtGui.QPixmap() self._pixmap_cached: QtGui.QPixmap = QtGui.QPixmap() - self._pixmap_dirty: bool = True self._bar_color = QtGui.QColor(223, 223, 223) self.setMinimumSize(100, 100) self._inner_rect: QtCore.QRectF = QtCore.QRectF() @@ -84,19 +82,15 @@ def minimumSizeHint(self) -> QtCore.QSize: self._inner_rect = self._calculate_inner_geometry() return QtCore.QSize(100, 100) - def setValue(self, value: float) -> None: - """Set progress value - - Args: - value (float): Progress value between 0.0 and 1.0 + def set_progress(self, fraction: float) -> None: + """Set progress from a 0.0-1.0 fraction (clamped).""" + self.progress_value = max(0.0, min(fraction, 1.0)) * 100 + self.update() - Raises: - ValueError: If provided value in not between 0.0 and 1.0 - """ - if not (0 <= value <= 100): - raise ValueError("Argument `value` expected value between 0.0 and 1.0 ") - value *= 100 - self.progress_value = value + def reset(self) -> None: + """Clear progress back to 0%.""" + self.progress_value = 0 + super().reset() self.update() def set_bar_color(self, red: int, green: int, blue: int) -> None: diff --git a/tests/widgets/test_job_status_page_unit.py b/tests/widgets/test_job_status_page_unit.py index f1644823..070e3a9a 100644 --- a/tests/widgets/test_job_status_page_unit.py +++ b/tests/widgets/test_job_status_page_unit.py @@ -27,6 +27,9 @@ def set_message(self, *a): def setPixmap(self, *a): pass + def set_progress(self, *a): # real CustomProgressBar takes a 0-1 float + pass + return Stub @@ -270,23 +273,23 @@ def test_no_update_when_hidden(self, widget): from unittest.mock import patch widget.hide() - with patch.object(widget.printing_progress_bar, "setValue") as mock_test: - widget.virtual_sdcard_update("progress", 50) + with patch.object(widget.printing_progress_bar, "set_progress") as mock_test: + widget.virtual_sdcard_update("progress", 0.5) mock_test.assert_not_called() def test_progress_field_sets_value(self, widget): from unittest.mock import patch widget.show() - with patch.object(widget.printing_progress_bar, "setValue") as mock_test: - widget.virtual_sdcard_update("progress", 75) - mock_test.assert_called_once_with(75) + with patch.object(widget.printing_progress_bar, "set_progress") as mock_test: + widget.virtual_sdcard_update("progress", 0.75) + mock_test.assert_called_once_with(0.75) def test_non_progress_field_ignored(self, widget): from unittest.mock import patch widget.hide() - with patch.object(widget.printing_progress_bar, "setValue") as mock_test: + with patch.object(widget.printing_progress_bar, "set_progress") as mock_test: widget.virtual_sdcard_update("is_active", True) mock_test.assert_not_called() @@ -420,3 +423,110 @@ def test_secondary_text_set_to_total_layers(self, widget): _metadata = self._ready_widget(widget) widget.on_fileinfo(_metadata) assert widget.layer_display_button.secondary_text == "20" + + +class TestComputeProgress: + """_compute_progress mirrors Mainsail file-relative progress.""" + + def _bounds(self, widget): + widget._gcode_start_byte = 100 + widget._gcode_end_byte = 1100 + + def test_relative_midway(self, widget): + self._bounds(widget) + widget._file_position = 600 + assert widget._compute_progress() == pytest.approx(0.5) + + def test_before_start_is_zero(self, widget): + """Start-macro bytes (mesh/plate cal) report 0%.""" + self._bounds(widget) + widget._file_position = 50 + assert widget._compute_progress() == 0.0 + + def test_after_end_is_one(self, widget): + """Excluded end gcode reports 100%.""" + self._bounds(widget) + widget._file_position = 5000 + assert widget._compute_progress() == 1.0 + + def test_fallback_to_raw_without_bytes(self, widget): + widget._gcode_start_byte = 0 + widget._gcode_end_byte = 0 + widget._raw_progress = 0.42 + assert widget._compute_progress() == 0.42 + + +class TestResetOnNewFile: + """A new filename clears stale layer/total; a repeat does not.""" + + def test_new_filename_clears_stale_total(self, widget): + widget._current_file_name = "old.gcode" + widget.total_layers = "690" + widget.layer_display_button.secondary_text = "690" + widget.on_print_stats_update("filename", "new.gcode") + assert widget.total_layers == "?" + assert widget.layer_display_button.secondary_text == "?" + + def test_same_filename_does_not_reset(self, widget): + widget._current_file_name = "same.gcode" + widget.layer_display_button.secondary_text = "250" + widget.on_print_stats_update("filename", "same.gcode") + assert widget.layer_display_button.secondary_text == "250" + + +class TestPauseFreeze: + """Layer is frozen across a pause so the park Z-lift never bumps it.""" + + def test_pause_click_freezes(self, widget): + widget._internal_print_status = "printing" + widget.pause_resume_print() + assert widget._layer_frozen is True + + def test_paused_state_freezes(self, widget): + widget._handle_print_state("paused") + assert widget._layer_frozen is True + + def test_printing_state_unfreezes(self, widget): + widget._layer_frozen = True + widget._handle_print_state("printing") + assert widget._layer_frozen is False + + def test_frozen_ignores_reported_layer(self, widget): + widget._layer_frozen = True + widget.layer_display_button.setText("4") + widget.on_print_stats_update("info", {"current_layer": 80}) + assert widget.layer_display_button.text() == "4" + + def test_frozen_blocks_z_recompute(self, widget): + widget.show() + widget._internal_print_status = "printing" + widget.layer_fallback = True + widget._print_duration = 10.0 + widget.file_metadata = { + "object_height": 10.0, + "layer_height": 0.2, + "first_layer_height": 0.2, + } + widget.layer_display_button.setText("4") + widget._layer_frozen = True + widget.on_gcode_move_update("gcode_position", [0, 0, 15.0, 0]) + widget.on_print_stats_update("filament_used", 5.0) + assert widget.layer_display_button.text() == "4" + + +class TestCompletionDisplay: + """A completed print shows the final layer=total and is not blanked.""" + + def _complete(self, widget): + widget._internal_print_status = "printing" + widget.total_layers = "250" + widget.total_layer_reported = True + widget._handle_print_state("complete") + + def test_shows_final_layer(self, widget): + self._complete(widget) + assert widget.layer_display_button.text() == "250" + + def test_total_not_blanked(self, widget): + self._complete(widget) + assert widget.total_layers == "250" diff --git a/tests/widgets/test_progress_bar_unit.py b/tests/widgets/test_progress_bar_unit.py new file mode 100644 index 00000000..4a919b76 --- /dev/null +++ b/tests/widgets/test_progress_bar_unit.py @@ -0,0 +1,42 @@ +"""Unit tests for CustomProgressBar (blocks_progressbar.py)""" + +import pytest + +from lib.utils.blocks_progressbar import CustomProgressBar + + +@pytest.fixture() +def bar(qtbot): + """Create a CustomProgressBar registered with qtbot.""" + w = CustomProgressBar() + qtbot.addWidget(w) + return w + + +class TestSetProgress: + """set_progress maps a 0.0-1.0 fraction onto a 0-100 percent, clamped.""" + + def test_fraction_scaled_to_percent(self, bar): + bar.set_progress(0.5) + assert bar.progress_value == 50 + + def test_full(self, bar): + bar.set_progress(1.0) + assert bar.progress_value == 100 + + def test_clamps_above_one(self, bar): + bar.set_progress(1.4) + assert bar.progress_value == 100 + + def test_clamps_below_zero(self, bar): + bar.set_progress(-0.2) + assert bar.progress_value == 0 + + +class TestReset: + """reset returns the bar to 0%.""" + + def test_reset_clears_progress(self, bar): + bar.set_progress(0.8) + bar.reset() + assert bar.progress_value == 0