diff --git a/CHANGELOG.md b/CHANGELOG.md index 9890522..2811afe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,18 @@ project uses [semantic versioning][semver]. the repository. The desktop app now uses the same mark as its window icon, built in memory, so the shell entry, the window and the report header finally agree on what this tool looks like. +- **A file list beside the queue, showing both checksums.** The interface could + say a job was "Verified" but never showed the evidence — the source hash and + the destination's, per file — which was only in the CSV and the PDF. The + selected job's files now sit next to the queue: size, both hashes head:tail + (`3F2A:05D1`, because a prefix cannot distinguish a match from a near-miss), + the full values and every destination path in the tooltip, and a doubled + check mark for a copy that was written *and* read back. That mark depends on + the job's verification mode: a source-only job, whose "verified" never reads + the destination, shows a single check and a summary that says so. + Destinations that + disagree with one another read `mismatch` rather than showing the first and + hiding the rest. - **A stall is reported instead of looking like a slow link.** A hung network handle raises nothing — it just stops returning bytes — so nothing could be retried and the job sat at a stale throughput figure. Source reads are now @@ -63,6 +75,17 @@ project uses [semantic versioning][semver]. ### Fixed +- **The file list no longer implies stronger evidence than the engine + produced.** A cancelled job's summary says "cancelled after 2 of 5 files" + instead of "verified 2 of 2". A full verification whose page cache could not + be evicted (`Job.cache_eviction_failed`) shows one check and says the + read-back could not be confirmed. A checksum of "none" shows `≈` and + "size-checked", not a double check. The destination column is green only for + a verified, read-back, checksummed match; failed destinations show no + checksum; a source-only or unchecked job labels the column "As written"; + a source/destination difference reads `mismatch` in red; warnings are + counted in the header with the list in a tooltip; C4 identifiers keep their + case when abbreviated. - The stalled stage now clears as soon as bytes resume on a slow link, instead of staying up until the whole 8 MiB chunk has assembled. - Time spent paused no longer counts toward the stall watchdog, so resuming a diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3b72a51..005f7b4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,7 +37,7 @@ pip install -e ".[dev]" `ffmpeg` and `ffprobe` on `PATH` are optional — the suite runs without them. ```sh -pytest # 571 tests +pytest # 593 tests pytest --fuzz # property tests at 3000 examples each, about 2 min ruff check src tests pytest --cov=offloader --cov-report=term-missing diff --git a/README.md b/README.md index b459cc8..aa36a55 100644 --- a/README.md +++ b/README.md @@ -272,6 +272,30 @@ destination file rather than leaving something that looks complete. A source that stops delivering bytes reads as `Stalled on …` with the time since the last one, rather than a throughput figure that has quietly stopped being true. +Beside it, the **file list** for the selected job: every file with its size, +the source checksum and the destination's next to each other, and a doubled +check mark for a copy that was both written and read back (full verification). +A source-only job gets a single check and its summary says "verified in transit +(source only, not read back)": the bytes written matched the source, but the +destination was never re-read. Hashes are shown +head:tail (`3F2A:05D1`) because a prefix alone cannot tell a matching pair from +a near-miss; the full values are in the tooltip, along with every destination +path. Destinations that disagree with each other read `mismatch` — both copies +came from one read, so they cannot legitimately differ. This is the same +evidence the CSV and the PDF carry, in front of the person deciding whether to +reformat the card. + +What each mark means: `✓✓` written and read back from the destination and the +checksums matched (the destination column is green only then); `✓` written but +not read back (source-only, or full verification whose cache eviction failed, +so the re-read may have come from memory); `≈` size matched but the algorithm +produced no checksum; `✕` failed, with no destination checksum shown; `⊘` +cancelled; `–` skipped. A source-only or no-verification job labels the column +"As written": that hash is of the bytes handed to the writer, not of the file on +disk. `mismatch` in red means a destination differs from the source or from +another destination. A cancelled job's summary reads "cancelled after 2 of 5 +files", and a warning count in the header lists every warning in its tooltip. + Two guards run before anything is queued: - **Duplicate offload protection.** The source's file listing — names and sizes, @@ -375,13 +399,13 @@ what makes the report layer testable without moving bytes. ```sh pip install -e ".[dev]" -pytest # 571 tests +pytest # 593 tests pytest --fuzz # same suite, 3000 examples per property (~2 min) ruff check src tests pytest --cov=offloader --cov-report=term-missing ``` -576 tests at 86% line coverage. They cover formatting against the reference's +612 tests at 86% line coverage. They cover formatting against the reference's exact strings, checksum vectors and streaming equivalence, copy/verify behaviour including simulated destination corruption, pause/resume/cancel concurrency, retry discrimination, BRAW container parsing, ffprobe parsing, diff --git a/docs/images/app-preset-editor.png b/docs/images/app-preset-editor.png index 4ec3850..6bc04fe 100644 Binary files a/docs/images/app-preset-editor.png and b/docs/images/app-preset-editor.png differ diff --git a/docs/images/app-preset-mode.png b/docs/images/app-preset-mode.png index 992f11b..776414b 100644 Binary files a/docs/images/app-preset-mode.png and b/docs/images/app-preset-mode.png differ diff --git a/docs/images/app-simple-mode.png b/docs/images/app-simple-mode.png index 5908984..b9a1d22 100644 Binary files a/docs/images/app-simple-mode.png and b/docs/images/app-simple-mode.png differ diff --git a/pyproject.toml b/pyproject.toml index 8db0d68..17e9451 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,6 +94,7 @@ select = ["F", "E9", "I", "B", "UP"] # Qt's model API is defined with QModelIndex() defaults; matching the framework # signature matters more here than the general rule against call-in-default. "src/offloader/gui/queue_view.py" = ["B008"] +"src/offloader/gui/file_list.py" = ["B008"] [tool.ruff.lint.isort] known-first-party = ["offloader"] diff --git a/src/offloader/engine.py b/src/offloader/engine.py index 9fbd9d6..516c13d 100644 --- a/src/offloader/engine.py +++ b/src/offloader/engine.py @@ -657,6 +657,7 @@ def run(source_root: Path, options: OffloadOptions, processors=host.processors, system_ram=host.system_ram, paranoid=options.paranoid, + scanned_files=len(files), ) thumb_dir = options.thumbnail_dir or (dest_roots[0] / f"{job.name}_Reports" / "thumbs") @@ -806,6 +807,8 @@ def copy_once(_src=source, _partials=partials, _idx=index, # file is already in place and reported Verified. evicted = _confirm_source(_src, result.source_checksum, options.algorithm, control) + if not evicted: + job.cache_eviction_failed = True if not evicted and not reread_noted: reread_noted = True job.warnings.append( @@ -883,7 +886,10 @@ def copy_once(_src=source, _partials=partials, _idx=index, counters.job_bytes_total)) # Evict first, or the read-back is served from the page # cache and verifies our own memory against itself. - if not integrity.evict_from_cache(partial) and not evict_noted: + evicted_ok = integrity.evict_from_cache(partial) + if not evicted_ok: + job.cache_eviction_failed = True + if not evicted_ok and not evict_noted: evict_noted = True job.warnings.append( "could not evict files from the page cache on this " diff --git a/src/offloader/gui/file_list.py b/src/offloader/gui/file_list.py new file mode 100644 index 0000000..3c0a435 --- /dev/null +++ b/src/offloader/gui/file_list.py @@ -0,0 +1,414 @@ +"""Per-file detail for one job: what landed, and what it hashed to. + +The queue answers "did the job succeed". This answers "which file, and prove +it" — the source checksum and the destination's beside each other, which is the +whole claim the tool makes and the one thing the interface never showed. It is +in the CSV and the PDF; an operator deciding whether to erase a card should not +have to open a report to see it. + +Hashes are abbreviated head:tail rather than truncated, because the first four +characters alone cannot distinguish a matching pair from a mismatched one that +happens to share a prefix. The full value is in the tooltip. +""" + +from __future__ import annotations + +from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt +from PySide6.QtGui import QColor +from PySide6.QtWidgets import ( + QAbstractItemView, + QHeaderView, + QTableView, + QVBoxLayout, + QWidget, +) + +from ..models import FileStatus, Job, VerificationMode +from ..util import format_size +from . import theme +from .widgets import label, row + +COLUMNS = ("", "File", "Size", "Source", "Destination") +#: Without a read-back the destination column is the hash of the bytes as they +#: were handed to the writer, so the header must not call it "Destination". +AS_WRITTEN_HEADER = "As written" +MISMATCH = "mismatch" +COL_MARK = 0 +COL_SIZE = 2 +COL_SOURCE = 3 +COL_DESTINATION = 4 + +#: One glyph per verdict. Doubled for a verified copy, because that is two +#: separate facts — it was written, and it was read back and matched. Only a +#: FULL job earns it: see `mark_for`. +MARKS = { + FileStatus.VERIFIED: "✓✓", + FileStatus.COPIED: "✓", + FileStatus.FAILED: "✕", + FileStatus.SKIPPED: "–", + FileStatus.CANCELLED: "⊘", +} +#: Verified by size alone: the algorithm produced no checksum, so nothing was +#: compared but length. +SIZE_ONLY_MARK = "≈" + + +def read_back(job: Job | None) -> bool: + """Whether this job's VERIFIED status means the destination was re-read. + + `SOURCE_ONLY` also produces VERIFIED, but compares the source stream with + the bytes handed to the writer; the destination is never read back. A FULL + job whose page cache could not be evicted re-read the file, but possibly + from memory, so that is not a confirmed read-back either. + """ + return (job is not None + and job.verification is VerificationMode.FULL + and not job.cache_eviction_failed) + + +def mark_for(status: FileStatus, job: Job | None, checksummed: bool = True) -> str: + """The glyph for a file: the double check only for a read-back job. + + `checksummed` is False when the algorithm produced no checksum: VERIFIED + then rests on file size alone and gets its own mark. + """ + if status is FileStatus.VERIFIED: + if not checksummed: + return SIZE_ONLY_MARK + if not read_back(job): + return MARKS[FileStatus.COPIED] + return MARKS.get(status, "?") + + +def _is_c4(value: str) -> bool: + # A C4 ID is "c4" + base58, 90 characters, and case-sensitive. No hex + # digest is that long. + return len(value) == 90 and value[:2] == "c4" + + +def same_checksum(a: str, b: str) -> bool: + """Hex digests compare case-insensitively; C4 identifiers exactly.""" + if _is_c4(a) or _is_c4(b): + return a == b + return a.lower() == b.lower() + + +def abbreviate(checksum: str | None) -> str: + """`28f7e67d9b39ea9b` as `28F7:EA9B`. + + Head *and* tail: a prefix on its own is not enough to tell a matching pair + from a near-miss, which is the only thing these two columns exist to show. + """ + if not checksum: + return "—" + clean = checksum.strip() + # Hex is shown upper-cased; a C4 ID is case-sensitive base58, so folding it + # would let two different IDs abbreviate identically. + if not _is_c4(clean): + clean = clean.upper() + if len(clean) <= 9: + return clean + return f"{clean[:4]}:{clean[-4:]}" + + +def _destination_checksum(entry) -> tuple[str, bool]: + """The destinations' checksum and whether they all agree with each other. + + Several destinations are written from the same read, so a disagreement + between them is a real finding and must not be hidden by showing only the + first one. + """ + # A FAILED destination keeps the write-stream digest the engine seeded it + # with, which describes no file on disk (the partial was discarded). It is + # blanked here, in the pane, rather than in the engine. + sums = [d.checksum for d in entry.destinations + if d.checksum and d.status is not FileStatus.FAILED] + if not sums: + return "—", True + first = sums[0] + return first, all(same_checksum(value, first) for value in sums) + + +def _contradicts_source(entry) -> bool: + """Some destination's digest, failed ones included, differs from the source. + + This is how a full-verification mismatch shows: that destination is FAILED + and its digest is the reread one, which is real evidence of a difference. + """ + if not entry.checksum: + return False + return any(d.checksum and not same_checksum(d.checksum, entry.checksum) + for d in entry.destinations) + + +def read_back_like(job: Job | None) -> bool: + """Whether the destination column holds a re-read digest at all. + + True for any FULL job, even one with failed eviction (the file was re-read; + it is just not a confirmed device read), so the header stays "Destination". + """ + return job is not None and job.verification is VerificationMode.FULL + + +def _tooltip_header(job: Job | None) -> str | None: + if job is None or read_back(job): + return None + if job.verification is VerificationMode.FULL: + return ("the destination was re-read, but the page cache could not be " + "evicted first, so the read may have come from memory") + return ("the hash of the bytes as written (handed to the writer); the " + "destination file was not read back") + + +class FileModel(QAbstractTableModel): + def __init__(self, parent=None) -> None: + super().__init__(parent) + self._job: Job | None = None + + def set_job(self, job: Job | None) -> None: + self.beginResetModel() + self._job = job + self.endResetModel() + + @property + def job(self) -> Job | None: + return self._job + + @property + def files(self) -> list: + return list(self._job.files) if self._job is not None else [] + + # ---------------------------------------------------------------- Qt API + def rowCount(self, parent=QModelIndex()) -> int: # noqa: N802 + return 0 if parent.isValid() else len(self.files) + + def columnCount(self, parent=QModelIndex()) -> int: # noqa: N802 + return 0 if parent.isValid() else len(COLUMNS) + + def headerData(self, section, orientation, role=Qt.DisplayRole): # noqa: N802 + if role == Qt.DisplayRole and orientation == Qt.Horizontal: + if section == COL_DESTINATION and not read_back_like(self._job): + return AS_WRITTEN_HEADER + return COLUMNS[section] + if (role == Qt.ToolTipRole and orientation == Qt.Horizontal + and section == COL_DESTINATION): + return _tooltip_header(self._job) + return None + + def data(self, index: QModelIndex, role=Qt.DisplayRole): + if not index.isValid(): + return None + entry = self.files[index.row()] + column = index.column() + destination, agree = _destination_checksum(entry) + matched = (bool(entry.checksum) and destination != "—" + and same_checksum(destination, entry.checksum)) + mismatch = (not agree) or _contradicts_source(entry) + checksummed = bool(entry.checksum) + # Green means one thing: the destination was independently read back + # and matched. Never for none/source-only/COPIED/FAILED/CANCELLED rows. + proved = (entry.status is FileStatus.VERIFIED + and read_back(self._job) and checksummed + and matched and not mismatch) + + if role == Qt.DisplayRole: + if mismatch: + shown = MISMATCH + elif entry.status is FileStatus.FAILED: + shown = "—" + else: + shown = abbreviate(destination) + return ( + mark_for(entry.status, self._job, checksummed), + str(entry.relative), + format_size(entry.size), + abbreviate(entry.checksum), + shown, + )[column] + + if role == Qt.ForegroundRole: + if column == COL_MARK: + return QColor(theme.status_color(entry.status.value.lower())) + if column == COL_DESTINATION: + if mismatch: + return QColor(theme.status_color("failed")) + if proved: + return QColor(theme.status_color("verified")) + return None + + if role == Qt.ToolTipRole: + lines = [str(entry.source)] + if entry.status is FileStatus.VERIFIED: + if not checksummed: + lines.append( + "size matched, no checksum: the algorithm produced no " + "checksum, so only the file length was compared") + elif read_back(self._job): + lines.append( + "verified: written and read back from the destination") + elif self._job is not None and self._job.verification is VerificationMode.FULL: + lines.append( + "verified, but not read back from the device: the " + "page cache could not be evicted, so the re-read may " + "have come from memory") + else: + lines.append( + "verified in transit (source only): the destination " + "was not read back. Its value is the hash of the bytes " + "as written, not of the file on disk") + if entry.checksum: + lines.append(f"source: {entry.checksum}") + for destination_entry in entry.destinations: + failed = destination_entry.status is FileStatus.FAILED + value = destination_entry.checksum or "—" + if failed and (not entry.checksum or not value or same_checksum( + value, entry.checksum)): + # The seeded write-stream digest of a failed copy describes + # no file on disk; only a differing re-read digest is kept. + value = "—" + lines.append( + f"{destination_entry.status.value.lower():<11} " + f"{value} {destination_entry.path}") + if destination_entry.error: + lines.append(f" error: {destination_entry.error}") + if mismatch: + lines.append("the destination does not match the source") + return "\n".join(lines) + + if role == Qt.TextAlignmentRole: + if column == COL_MARK: + return int(Qt.AlignCenter) + if column == COL_SIZE: + return int(Qt.AlignRight | Qt.AlignVCenter) + return None + + +class FileListPanel(QWidget): + """The selected job's files, or a line saying why there are none to show.""" + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self.model = FileModel(self) + + self.table = QTableView() + self.table.setModel(self.model) + self.table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.table.setSelectionMode(QAbstractItemView.SingleSelection) + self.table.setAlternatingRowColors(True) + self.table.verticalHeader().setVisible(False) + self.table.setShowGrid(False) + self.table.verticalHeader().setDefaultSectionSize(26) + + header = self.table.horizontalHeader() + header.setSectionResizeMode(COL_MARK, QHeaderView.Fixed) + header.resizeSection(COL_MARK, 34) + header.setSectionResizeMode(1, QHeaderView.Stretch) + for column in (COL_SIZE, COL_SOURCE, COL_DESTINATION): + header.setSectionResizeMode(column, QHeaderView.Fixed) + header.resizeSection(COL_SIZE, 76) + # Wide enough for "mismatch" as well as a head:tail pair, so the column + # does not jump width when a job goes wrong. Kept as tight as that + # allows, because everything here is width the file name does not get — + # and a clip name truncated to "A002_08…" identifies nothing. + header.resizeSection(COL_SOURCE, 90) + header.resizeSection(COL_DESTINATION, 90) + + self._title = label("Files", "heading") + self._summary = label("", "muted") + self._warnings = label("", "muted") + self._warnings.setVisible(False) + self._empty = label(self.DEFAULT_MESSAGE, "muted") + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(8) + layout.addWidget(row(self._title, 12, self._summary, 12, + self._warnings, None)) + layout.addWidget(self._empty) + layout.addWidget(self.table, 1) + self.show_job(None, None) + + DEFAULT_MESSAGE = "Select a job to see the files it copied." + + def show_job(self, job: Job | None, pending: str | None = None) -> None: + """Display `job`'s files, or `pending` when there are none yet.""" + message = pending or self.DEFAULT_MESSAGE + if job is self.model.job: + # The same job object cannot have grown rows — the engine hands the + # job over once, finished. Only the waiting message can change, and + # rebuilding here would reset the table on every progress event of + # a selected running job. + if job is None: + self._empty.setText(message) + return + + self.model.set_job(job) + self._show_warnings(job) + has_rows = bool(job is not None and job.files) + self.table.setVisible(has_rows) + self._empty.setVisible(not has_rows) + if not has_rows: + self._empty.setText(message) + self._summary.setText("") + return + self._summary.setText(self._describe(job)) + + def _show_warnings(self, job: Job | None) -> None: + """A count in the header, the full list in the tooltip. + + Shown for completed and cancelled jobs alike: the completion dialog + appears once and is skipped for a cancelled job. + """ + warnings = list(job.warnings) if job is not None else [] + self._warnings.setVisible(bool(warnings)) + if warnings: + noun = "warning" if len(warnings) == 1 else "warnings" + self._warnings.setText(f"⚠ {len(warnings)} {noun}") + self._warnings.setToolTip("\n".join(warnings)) + else: + self._warnings.setText("") + self._warnings.setToolTip("") + + @staticmethod + def _describe(job: Job) -> str: + total = len(job.files) + checked = [entry for entry in job.files + if entry.status is FileStatus.VERIFIED] + verified = sum(1 for entry in checked if entry.checksum) + size_only = len(checked) - verified + failed = sum(1 for entry in job.files + if entry.status is FileStatus.FAILED) + if read_back(job): + how = "" + elif job.verification is VerificationMode.FULL: + how = ", read-back could not be confirmed (cache not evicted)" + else: + how = " in transit (source only, not read back)" + + # Counted rather than inferred from the job's verdict: "Verified: 5 of + # 6" is the number that decides whether a card can be erased. + counts = [] + if verified: + counts.append(f"{verified} verified{how}") + if size_only: + counts.append(f"{size_only} size-checked (no checksum)") + if failed: + counts.append(f"{failed} failed") + + if job.cancelled: + # Only the files reached are listed; say how many were not, so + # "2 verified" cannot be read as the whole card. + reached = (f"{total} of {job.scanned_files}" if job.scanned_files + else f"{total}") + head = "; ".join([f"cancelled after {reached} files", *counts]) + return " · ".join([head, format_size(job.total_bytes), + job.hash_label]) + + parts = [f"{total} files", format_size(job.total_bytes), job.hash_label] + if verified: + parts.append(f"verified{how} {verified} of {total}") + if size_only: + parts.append(f"size-checked (no checksum) {size_only} of {total}") + if failed: + parts.append(f"{failed} failed") + return " · ".join(parts) diff --git a/src/offloader/gui/main_window.py b/src/offloader/gui/main_window.py index 06fa395..b664b38 100644 --- a/src/offloader/gui/main_window.py +++ b/src/offloader/gui/main_window.py @@ -23,6 +23,7 @@ from ..util import format_size from . import theme from .drives import DrivesPanel +from .file_list import FileListPanel from .preset_mode import PresetModePanel from .queue_view import QueuePanel, reveal from .simple_mode import SimpleModePanel @@ -72,6 +73,8 @@ def __init__(self, source: Path | None = None) -> None: self.drives.useAsDestination.connect(self._add_destination) self.queue = QueuePanel(self.controller) + self.files = FileListPanel() + self.queue.selectionChanged.connect(self._show_files) # ----------------------------------------------------------- chrome self._preset_button = button("Presets") @@ -106,9 +109,24 @@ def __init__(self, source: Path | None = None) -> None: upper.setStretchFactor(1, 1) upper.setSizes([280, 900]) + # Jobs on the left, the selected job's files on the right: the queue is + # the rail and this is its detail pane. Split rather than tabbed, so + # the proof of what landed is visible at the same time as the job it + # belongs to. + # Weighted towards the queue: its six columns include two fixed-width + # ones, and squeezing it clips the throughput figure off the right. + # The detail pane's columns are mostly fixed too, hence the floor. + self.files.setMinimumWidth(330) + lower = QSplitter(Qt.Horizontal) + lower.addWidget(self.queue) + lower.addWidget(self.files) + lower.setStretchFactor(0, 5) + lower.setStretchFactor(1, 2) + lower.setSizes([800, 480]) + vertical = QSplitter(Qt.Vertical) vertical.addWidget(upper) - vertical.addWidget(self.queue) + vertical.addWidget(lower) vertical.setStretchFactor(0, 3) vertical.setStretchFactor(1, 2) vertical.setSizes([520, 300]) @@ -208,6 +226,29 @@ def _show_about(self) -> None: "", ) + def _show_files(self, item) -> None: + """Follow the queue's selection with the file detail. + + A job's files only exist once the engine hands the finished job back, + so until then the pane says which state it is waiting on rather than + sitting empty and looking broken. + """ + if item is None: + self.files.show_job(None) + elif item.job is not None: + self.files.show_job(item.job) + elif item.state.is_terminal: + # Terminal with nothing to show: cancelled or failed before the + # engine got as far as handing a job back. Saying it will appear + # once the job finishes would be a promise nothing will keep. + self.files.show_job( + None, f"{item.status_text} — no files were recorded." + + (f"\n{item.error}" if item.error else "")) + else: + self.files.show_job( + None, f"{item.status_text} — the file list appears " + "once the job finishes.") + # ---------------------------------------------------------------- routing def _set_source(self, path: Path) -> None: self.simple.set_source(path) diff --git a/src/offloader/gui/queue_view.py b/src/offloader/gui/queue_view.py index 2a3b30d..bf1c7fa 100644 --- a/src/offloader/gui/queue_view.py +++ b/src/offloader/gui/queue_view.py @@ -6,7 +6,7 @@ import sys from pathlib import Path -from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt, QTimer +from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt, QTimer, Signal from PySide6.QtGui import QColor, QPainter from PySide6.QtWidgets import ( QAbstractItemView, @@ -230,6 +230,11 @@ def reveal(path: Path) -> None: class QueuePanel(QWidget): """Queue table plus the transport controls that act on the selection.""" + #: The selected job, or None. Emitted on selection *and* on any change to + #: an item, so a detail view following it sees a job's files the moment it + #: finishes rather than when the operator next clicks. + selectionChanged = Signal(object) + def __init__(self, controller: QueueController, parent=None) -> None: super().__init__(parent) self.controller = controller @@ -363,6 +368,7 @@ def _sync_buttons(self) -> None: self._remove.setEnabled(terminal or queued) self._reports.setEnabled(bool(item and item.reports)) self._clear.setEnabled(any(i.state.is_terminal for i in self.controller.items)) + self.selectionChanged.emit(item) # ---------------------------------------------------------------- actions def _toggle_pause(self) -> None: diff --git a/src/offloader/models.py b/src/offloader/models.py index 0c9da07..e459005 100644 --- a/src/offloader/models.py +++ b/src/offloader/models.py @@ -234,6 +234,13 @@ class Job: #: erasing a card — empty files, verifications that may have been served #: from cache. warnings: list[str] = field(default_factory=list) + #: How many files the scan found, so a cancelled job can say how far it got. + #: Zero means unknown (a job built by hand or loaded from elsewhere). + scanned_files: int = 0 + #: The page cache could not be dropped before a re-read, so a "read back" + #: may have been served from memory. Structured, not only a warning string, + #: because the file list must not claim a read-back this flag disputes. + cache_eviction_failed: bool = False @property def total_bytes(self) -> int: diff --git a/tests/conftest.py b/tests/conftest.py index 3ef08f0..451b00d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,9 @@ from __future__ import annotations import datetime as _dt +import gc import os +import sys from pathlib import Path import pytest @@ -17,6 +19,24 @@ ) +@pytest.fixture(autouse=True) +def _finalise_qt_objects_on_the_main_thread(): + """Collect garbage on the main thread after every test once Qt is up. + + GUI tests leave parentless widgets to the garbage collector. The collector + runs on whichever thread happens to allocate, and a later engine test's + reader thread was the one to trigger it on macOS, so QWidget destructors + ran off the GUI thread and the whole pytest process died with a + segmentation fault inside an unrelated retry test. Collecting here keeps + the destructors on the thread Qt requires. + """ + yield + widgets = sys.modules.get("PySide6.QtWidgets") + if widgets is not None and widgets.QApplication.instance() is not None: + widgets.QApplication.processEvents() + gc.collect() + + @pytest.fixture def source_tree(tmp_path: Path) -> Path: """A small synthetic card: two clips, a sidecar, and OS junk.""" diff --git a/tests/test_gui_file_list.py b/tests/test_gui_file_list.py new file mode 100644 index 0000000..f49f42e --- /dev/null +++ b/tests/test_gui_file_list.py @@ -0,0 +1,520 @@ +"""The per-file detail pane: the two checksums, side by side. + +This pane exists to show the claim the tool makes, so the tests are mostly +about the cases where the claim is *not* clean — a destination that disagrees +with the source, destinations that disagree with each other, a file that +failed. Those are the rows an operator must not misread as verified. +""" + +from __future__ import annotations + +import errno +import os +from pathlib import Path + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +pytest.importorskip("PySide6", reason="GUI extra not installed") + +from PySide6.QtCore import Qt # noqa: E402 +from PySide6.QtWidgets import QApplication # noqa: E402 + +from offloader import integrity # noqa: E402 +from offloader.gui import theme # noqa: E402 +from offloader.gui.file_list import ( # noqa: E402 + COL_DESTINATION, + COL_MARK, + COL_SOURCE, + MARKS, + FileListPanel, + FileModel, + abbreviate, + mark_for, +) +from offloader.models import ( # noqa: E402 + Destination, + FileEntry, + FileStatus, + Job, + VerificationMode, +) + +SOURCE_ROOT = Path("E:\\") +DEST_ROOT = Path("D:\\archive") + + +@pytest.fixture(scope="session") +def qapp(): + return QApplication.instance() or QApplication([]) + + +def _entry(name: str, checksum: str | None, + destinations: list[tuple[str | None, FileStatus]], + size: int = 1024) -> FileEntry: + return FileEntry( + source=SOURCE_ROOT / name, source_root=SOURCE_ROOT, size=size, + created=0.0, modified=0.0, checksum=checksum, + destinations=[ + Destination(root=DEST_ROOT, path=DEST_ROOT / name, + status=status, checksum=value) + for value, status in destinations + ], + ) + + +def _job(*entries: FileEntry) -> Job: + job = Job(name="A001", source_root=SOURCE_ROOT, + destination_roots=[DEST_ROOT], + verification=VerificationMode.FULL, hash_label="XXHash3-64") + job.files.extend(entries) + return job + + +def _display(model: FileModel, row: int, column: int): + return model.data(model.index(row, column), Qt.DisplayRole) + + +def _colour(model: FileModel, row: int, column: int): + return model.data(model.index(row, column), Qt.ForegroundRole) + + +# ------------------------------------------------------------- abbreviation + + +@pytest.mark.parametrize("value,expected", [ + ("28f7e67d9b39ea9b", "28F7:EA9B"), + ("3f2a9c17b48e05d1", "3F2A:05D1"), + (None, "—"), + ("", "—"), + ("abc", "ABC"), +]) +def test_abbreviation(value, expected): + assert abbreviate(value) == expected + + +def test_abbreviation_keeps_the_tail(): + """Two checksums sharing a prefix must not abbreviate to the same string — + the whole point of these columns is telling a match from a near-miss.""" + one = abbreviate("28f7e67d9b39aaaa") + two = abbreviate("28f7e67d9b39bbbb") + assert one != two + + +# ------------------------------------------------------------------- the rows + + +def test_a_verified_file_shows_both_hashes_and_a_double_mark(qapp): + checksum = "28f7e67d9b39ea9b" + model = FileModel() + model.set_job(_job(_entry("A.braw", checksum, + [(checksum, FileStatus.VERIFIED)]))) + + assert _display(model, 0, COL_MARK) == "✓✓" + assert _display(model, 0, COL_SOURCE) == "28F7:EA9B" + assert _display(model, 0, COL_DESTINATION) == "28F7:EA9B" + assert _colour(model, 0, COL_DESTINATION).name() == \ + theme.status_color("verified") + + +def test_a_destination_that_does_not_match_is_not_coloured_as_verified(qapp): + """The pair is the evidence. A destination hash that differs from the + source must not be painted the same green as one that agrees, even if the + engine somehow called the file copied.""" + model = FileModel() + model.set_job(_job(_entry("A.braw", "28f7e67d9b39ea9b", + [("ffffffffffffffff", FileStatus.COPIED)]))) + + assert _display(model, 0, COL_SOURCE) != _display(model, 0, COL_DESTINATION) + assert _display(model, 0, COL_DESTINATION) == "mismatch" + assert _colour(model, 0, COL_DESTINATION).name() == theme.status_color("failed") + + +def test_destinations_disagreeing_with_each_other_say_mismatch(qapp): + """Both copies came from one read, so they cannot legitimately differ. + Showing only the first would hide it.""" + model = FileModel() + entry = _entry("A.braw", "28f7e67d9b39ea9b", + [("28f7e67d9b39ea9b", FileStatus.VERIFIED), + ("0000000000000000", FileStatus.FAILED)]) + model.set_job(_job(entry)) + + assert _display(model, 0, COL_DESTINATION) == "mismatch" + assert _colour(model, 0, COL_DESTINATION).name() == \ + theme.status_color("failed") + + +def test_destinations_that_all_agree_show_the_hash(qapp): + """The counterpart to the mismatch case, and the one a two-destination + preset produces every time. Only disagreement was covered, so a check + that reported `mismatch` for every multi-destination job would have + passed — and made the normal case look like a fault.""" + checksum = "28f7e67d9b39ea9b" + model = FileModel() + model.set_job(_job(_entry("A.braw", checksum, + [(checksum, FileStatus.VERIFIED), + (checksum, FileStatus.VERIFIED)]))) + + assert _display(model, 0, COL_DESTINATION) == "28F7:EA9B" + assert _colour(model, 0, COL_DESTINATION).name() == \ + theme.status_color("verified") + + +def test_the_tooltip_lists_every_destination(qapp): + """With two copies, seeing only one of them in the tooltip would hide + which of the pair was the one that failed.""" + checksum = "28f7e67d9b39ea9b" + model = FileModel() + model.set_job(_job(_entry("A.braw", checksum, + [(checksum, FileStatus.VERIFIED), + (checksum, FileStatus.VERIFIED)]))) + + tooltip = model.data(model.index(0, COL_DESTINATION), Qt.ToolTipRole) + assert tooltip.count(str(DEST_ROOT / "A.braw")) == 2 + + +def test_a_destination_error_reaches_the_tooltip(qapp): + """Why a copy failed is the first thing asked, and the row has no room + for it.""" + model = FileModel() + entry = _entry("A.braw", "28f7e67d9b39ea9b", [(None, FileStatus.FAILED)]) + entry.destinations[0].error = "The device is not ready" + model.set_job(_job(entry)) + + tooltip = model.data(model.index(0, COL_MARK), Qt.ToolTipRole) + assert "The device is not ready" in tooltip + + +def test_a_file_with_no_destinations_is_not_called_verified(qapp): + """A skipped file has none, and an empty destination list must not fall + through to the colour that means proved.""" + model = FileModel() + model.set_job(_job(_entry("A.braw", "28f7e67d9b39ea9b", []))) + + assert _display(model, 0, COL_DESTINATION) == "—" + assert _colour(model, 0, COL_DESTINATION) is None + assert _display(model, 0, COL_MARK) == MARKS[FileStatus.SKIPPED] + + +def test_a_failed_file_is_marked_and_coloured_as_failed(qapp): + model = FileModel() + model.set_job(_job(_entry("A.braw", None, [(None, FileStatus.FAILED)]))) + + assert _display(model, 0, COL_MARK) == "✕" + assert _colour(model, 0, COL_MARK).name() == theme.status_color("failed") + assert _display(model, 0, COL_SOURCE) == "—" + + +def test_the_tooltip_carries_the_full_hashes_not_the_abbreviation(qapp): + """The abbreviation is for scanning; anyone checking a value against a + manifest needs all of it.""" + checksum = "28f7e67d9b39ea9b" + model = FileModel() + model.set_job(_job(_entry("A.braw", checksum, + [(checksum, FileStatus.VERIFIED)]))) + + tooltip = model.data(model.index(0, COL_SOURCE), Qt.ToolTipRole) + assert checksum in tooltip + assert str(DEST_ROOT / "A.braw") in tooltip + + +def test_an_empty_model_has_no_rows(qapp): + model = FileModel() + assert model.rowCount() == 0 + model.set_job(None) + assert model.rowCount() == 0 + + +# ----------------------------------------------------------------- the panel + + +def test_the_summary_counts_verified_files(qapp): + good = "28f7e67d9b39ea9b" + panel = FileListPanel() + panel.show_job(_job( + _entry("A.braw", good, [(good, FileStatus.VERIFIED)]), + _entry("B.braw", good, [(good, FileStatus.VERIFIED)]), + _entry("C.braw", None, [(None, FileStatus.FAILED)]), + )) + + summary = panel._summary.text() + assert "3 files" in summary + assert "verified 2 of 3" in summary + assert "1 failed" in summary + assert panel.model.rowCount() == 3 + + +def test_a_job_with_no_files_shows_the_waiting_message(qapp): + panel = FileListPanel() + panel.show_job(None, "Running — the file list appears once the job finishes.") + assert "Running" in panel._empty.text() + assert panel.model.rowCount() == 0 + + +def test_showing_the_same_job_again_does_not_rebuild(qapp): + """Called on every progress event of the selected job, so a rebuild here + would reset the table dozens of times a second.""" + good = "28f7e67d9b39ea9b" + job = _job(_entry("A.braw", good, [(good, FileStatus.VERIFIED)])) + panel = FileListPanel() + panel.show_job(job) + + resets = [] + panel.model.modelReset.connect(lambda: resets.append(1)) + panel.show_job(job) + panel.show_job(job) + + assert resets == [] + + +def test_the_waiting_message_still_updates_while_empty(qapp): + """Queued to running is a change the operator should see, and both states + have no job object to tell apart.""" + panel = FileListPanel() + panel.show_job(None, "Queued — the file list appears once the job finishes.") + panel.show_job(None, "Running — the file list appears once the job finishes.") + assert "Running" in panel._empty.text() + + +# ------------------------------------------- verification mode qualifies it + + +def _engine_job(tmp_path: Path, mode: VerificationMode) -> Job: + """A job from the real engine, so VERIFIED is whatever the engine says.""" + from offloader import engine + + card = tmp_path / "card" + card.mkdir() + (card / "A.bin").write_bytes(b"x" * 2048) + return engine.run(card, engine.OffloadOptions( + destinations=[tmp_path / "dest"], algorithm="xxh3-64", + verification=mode, thumbnail_count=0, extra_probe=False)) + + +def test_an_engine_source_only_job_is_not_shown_as_read_back(qapp, tmp_path): + job = _engine_job(tmp_path, VerificationMode.SOURCE_ONLY) + assert job.verification is VerificationMode.SOURCE_ONLY + assert job.files[0].status is FileStatus.VERIFIED + + model = FileModel() + model.set_job(job) + assert _display(model, 0, COL_MARK) == MARKS[FileStatus.COPIED] + assert _display(model, 0, COL_MARK) != "✓✓" + assert mark_for(FileStatus.VERIFIED, job) == "✓" + # The destination hash is the streamed one, so it is not green proof. + assert _colour(model, 0, COL_DESTINATION) is None + tip = model.data(model.index(0, 1), Qt.ToolTipRole) + assert "not read back" in tip + + panel = FileListPanel() + panel.show_job(job) + summary = panel._summary.text() + assert "source only" in summary and "not read back" in summary + assert "1 of 1" in summary + + +def test_an_engine_full_job_is_shown_as_read_back(qapp, tmp_path, monkeypatch): + # Eviction always fails on macOS, which is its own case below; this + # one is about what a confirmed read-back looks like. + monkeypatch.setattr(integrity, "evict_from_cache", lambda p: True) + job = _engine_job(tmp_path, VerificationMode.FULL) + assert job.files[0].status is FileStatus.VERIFIED + + model = FileModel() + model.set_job(job) + assert _display(model, 0, COL_MARK) == "✓✓" + assert _colour(model, 0, COL_DESTINATION).name() == \ + theme.status_color("verified") + assert "read back from the destination" in \ + model.data(model.index(0, 1), Qt.ToolTipRole) + + panel = FileListPanel() + panel.show_job(job) + summary = panel._summary.text() + assert "verified 1 of 1" in summary + assert "source only" not in summary + + +# ------------------------------------------ private review findings (PR 12) + + +def _run(tmp_path: Path, *, files: int = 1, dests: int = 1, + mode: VerificationMode = VerificationMode.FULL, + algorithm: str = "xxh3-64", control=None, progress=None, + retry=None, **extra) -> Job: + """An engine-driven job on tmp_path trees.""" + from offloader import engine + from offloader import retry as retry_mod + + card = tmp_path / "card" + card.mkdir(exist_ok=True) + for i in range(files): + (card / f"F{i}.bin").write_bytes(bytes([i + 1]) * 2048) + return engine.run(card, engine.OffloadOptions( + destinations=[tmp_path / f"dest{i}" for i in range(dests)], + algorithm=algorithm, verification=mode, thumbnail_count=0, + extra_probe=False, retry=retry or retry_mod.NO_RETRY, **extra), + progress=progress, control=control) + + +def _show(job: Job) -> tuple[FileModel, FileListPanel]: + model = FileModel() + model.set_job(job) + panel = FileListPanel() + panel.show_job(job) + return model, panel + + +def _tip(model: FileModel, row: int = 0) -> str: + return model.data(model.index(row, 1), Qt.ToolTipRole) + + +def test_a_cancelled_job_says_how_far_it_got(qapp, tmp_path): + from offloader import engine + + control = engine.JobControl() + + def progress(event): + if event.file_index == 2: + control.cancel() + + job = _run(tmp_path, files=5, control=control, progress=progress) + assert job.cancelled and len(job.files) == 2 + + _, panel = _show(job) + summary = panel._summary.text() + assert "cancelled after 2 of 5 files" in summary + assert "2 verified" in summary + assert "verified 2 of 2" not in summary + + +def test_failed_cache_eviction_is_not_a_read_back(qapp, tmp_path, monkeypatch): + from offloader import integrity + + monkeypatch.setattr(integrity, "evict_from_cache", lambda path: False) + job = _run(tmp_path) + assert job.cache_eviction_failed + assert job.files[0].status is FileStatus.VERIFIED + + model, panel = _show(job) + assert _display(model, 0, COL_MARK) == "✓" + assert _colour(model, 0, COL_DESTINATION) is None + assert "not read back" in _tip(model) + assert "read-back could not be confirmed" in panel._summary.text() + + +def test_checksum_none_is_size_checked_not_verified(qapp, tmp_path): + job = _run(tmp_path, algorithm="none") + model, panel = _show(job) + assert _display(model, 0, COL_MARK) not in ("✓✓", "✓") + assert _display(model, 0, COL_MARK) == "≈" + assert "size matched, no checksum" in _tip(model) + assert _colour(model, 0, COL_DESTINATION) is None + summary = panel._summary.text() + assert "size-checked" in summary and "verified" not in summary + + +@pytest.mark.parametrize("mode", [VerificationMode.NONE, + VerificationMode.SOURCE_ONLY]) +def test_only_a_read_back_destination_is_green(qapp, tmp_path, mode): + job = _run(tmp_path, mode=mode) + model, panel = _show(job) + assert _colour(model, 0, COL_DESTINATION) is None + # Not labelled as if it were read from the destination. + assert model.headerData(COL_DESTINATION, Qt.Horizontal) != "Destination" + assert "as written" in model.headerData( + COL_DESTINATION, Qt.Horizontal, Qt.ToolTipRole) + + +def test_source_only_tooltip_says_hash_as_written(qapp, tmp_path): + job = _run(tmp_path, mode=VerificationMode.SOURCE_ONLY) + model, _ = _show(job) + tip = _tip(model) + assert "as written" in tip and "not read back" in tip + assert "matched" not in tip + + +def test_a_failed_destination_shows_no_checksum(qapp, tmp_path, monkeypatch): + from offloader import engine + + real = engine.hash_file + calls = [] + + def flaky(path, algorithm, *args, **kwargs): + calls.append(path) + if len(calls) == 2: + raise PermissionError("denied") + return real(path, algorithm, *args, **kwargs) + + monkeypatch.setattr(engine, "hash_file", flaky) + job = _run(tmp_path, dests=2) + entry = job.files[0] + assert [d.status for d in entry.destinations] == [ + FileStatus.VERIFIED, FileStatus.FAILED] + + model, _ = _show(job) + assert _display(model, 0, COL_MARK) == "✕" + assert _display(model, 0, COL_DESTINATION) == "—" + assert _colour(model, 0, COL_DESTINATION) is None + failed_line = [line for line in _tip(model).splitlines() + if line.startswith("failed")][0] + assert entry.checksum not in failed_line and "—" in failed_line + + +def test_warnings_are_counted_in_the_header(qapp, tmp_path, monkeypatch): + from offloader import engine + from offloader import retry as retry_mod + + real = engine.hash_file + calls = [] + + def once(path, algorithm, *args, **kwargs): + calls.append(path) + if len(calls) == 1: + raise OSError(errno.EIO, "transient") + return real(path, algorithm, *args, **kwargs) + + monkeypatch.setattr(engine, "hash_file", once) + # Keep the eviction warning out of the count: it is platform-dependent. + monkeypatch.setattr(integrity, "evict_from_cache", lambda p: True) + job = _run(tmp_path, retry=retry_mod.RetryPolicy(attempts=2, delay=0.0)) + assert job.warnings + + _, panel = _show(job) + assert not panel._warnings.isHidden() + assert panel._warnings.text().startswith("⚠ 1 warning") + assert "attempt 2" in panel._warnings.toolTip() + + +def test_warnings_show_for_a_cancelled_job_too(qapp): + good = "28f7e67d9b39ea9b" + job = _job(_entry("A.braw", good, [(good, FileStatus.VERIFIED)])) + job.cancelled = True + job.warnings.extend(["one", "two"]) + _, panel = _show(job) + assert panel._warnings.text().startswith("⚠ 2 warnings") + assert panel._warnings.toolTip() == "one\ntwo" + + +def test_c4_ids_differing_only_in_case_abbreviate_differently(): + base = "c4" + "1" * 84 + "aBcD" + other = "c4" + "1" * 84 + "abcd" + assert len(base) == 90 + assert abbreviate(base) != abbreviate(other) + assert abbreviate(base).endswith("aBcD") + # Hex digests are still folded. + assert abbreviate("28f7e67d9b39ea9b") == "28F7:EA9B" + + +def test_a_full_verification_mismatch_says_mismatch_in_red( + qapp, tmp_path, monkeypatch): + from offloader import engine + + monkeypatch.setattr(engine, "hash_file", lambda *a, **k: "deadbeefdeadbeef") + job = _run(tmp_path) + assert job.files[0].status is FileStatus.FAILED + + model, _ = _show(job) + assert _display(model, 0, COL_MARK) == "✕" + assert _display(model, 0, COL_DESTINATION) == "mismatch" + assert _colour(model, 0, COL_DESTINATION).name() == theme.status_color("failed") diff --git a/tools/screenshots.py b/tools/screenshots.py index 66d358a..285cead 100644 --- a/tools/screenshots.py +++ b/tools/screenshots.py @@ -26,6 +26,7 @@ import sys import tempfile import time +from datetime import datetime, timedelta from pathlib import Path REPO = Path(__file__).resolve().parent.parent @@ -47,7 +48,13 @@ from offloader.gui.main_window import MainWindow # noqa: E402 from offloader.gui.preset_editor import PresetEditor # noqa: E402 from offloader.gui.worker import JobState, QueueItem # noqa: E402 -from offloader.models import VerificationMode # noqa: E402 +from offloader.models import ( # noqa: E402 + Destination, + FileEntry, + FileStatus, + Job, + VerificationMode, +) from offloader.presets import PRESET_COLORS, Preset # noqa: E402 from offloader.volumes import Volume # noqa: E402 @@ -136,6 +143,40 @@ def seed_config() -> None: json.dumps([p.to_dict() for p in PRESETS], indent=2), encoding="utf-8") +def finished_job() -> Job: + """A completed job, so the file pane has checksums to show. + + The detail pane's whole point is the source and destination hashes beside + each other, and a screenshot of it empty would document nothing. Invented + clips, but real structure: every checksum here is what the pane will render + for a genuine offload, and the pairs match because the job verified. + """ + root = Path("E:\\") + destination = Path("D:\\video\\080426\\A002") + clips = [ + ("A002_08041151_C001.braw", 24_411_238_400, "3f2a9c17b48e05d1"), + ("A002_08041203_C002.braw", 31_884_902_400, "b71e04c9a3fd2b68"), + ("A002_08041219_C003.braw", 28_106_342_400, "0c4d8ba25e91f7a3"), + ("A002_08041244_C004.braw", 19_907_481_600, "e58f13d072ac4b96"), + ("A002_08041302_C005.braw", 34_022_297_600, "9a2b6e8f14c703de"), + ] + # A real duration, so the queue's finished row reads "138.33 GB in 0:07:08" + # rather than an elapsed time of zero. + started = datetime.now() - timedelta(seconds=428) + job = Job(name="A002", source_root=root, destination_roots=[destination], + verification=VerificationMode.FULL, hash_label="XXHash3-64", + started=started, finished=started + timedelta(seconds=428)) + for name, size, checksum in clips: + job.files.append(FileEntry( + source=root / name, source_root=root, size=size, + created=0.0, modified=0.0, checksum=checksum, + destinations=[Destination( + root=destination, path=destination / name, + status=FileStatus.VERIFIED, checksum=checksum)], + )) + return job + + def fill_queue(window: MainWindow) -> None: """One job running, one waiting, one done. @@ -159,9 +200,31 @@ def fill_queue(window: MainWindow) -> None: preset=PRESETS[0], state=JobState.DONE, fraction=1.0, stage="verify", bytes_done=int(129.7 * GB), bytes_total=int(129.7 * GB), - started_at=now - 940, finished_at=now - 512), + started_at=now - 940, finished_at=now - 512, + job=finished_job()), ] controller.itemsChanged.emit() + # The finished job, so the picture shows the file pane doing its job rather + # than inviting the reader to select something. Scrolled back afterwards: + # selecting the last row scrolls it into view, which pushed the running job + # — the thing the queue panel is there to show — out of the frame. + window.queue.table.selectRow(2) + window.queue.table.scrollToTop() + + +def stamp_rate(item, mb_per_sec: float = 594.2, span: float = 2.0) -> None: + """Give a hand-built queue item a trailing progress sample. + + The rate and the ETA come from a window of samples measured against the + clock, so an item assembled here has neither and the throughput column + renders empty. Called immediately before each picture rather than once when + the queue is built: a sample older than the window reads as no rate at all, + which made the figure come and go with however long Qt took to start. + """ + rate = int(mb_per_sec * 1024 * 1024) + item._samples.clear() + item._samples.append((time.monotonic() - span, + item.bytes_done - int(rate * span))) def settle(app: QApplication, rounds: int = 12) -> None: @@ -204,6 +267,11 @@ def main(argv: list[str] | None = None) -> int: window._set_mode(0) settle(app) assert_no_real_volumes(window) + stamp_rate(window.controller.items[0]) + # The column repaints itself, but the running-job line above it is only + # rebuilt when the panel ticks — without this the two disagree about + # whether there is a rate at all. + window.queue._tick() shoot(window, out, "app-preset-mode.png") window._set_mode(1) @@ -216,6 +284,8 @@ def main(argv: list[str] | None = None) -> int: # Checked again: the panel polls every few seconds, so a scan that slipped # past the stub would land between the two pictures. assert_no_real_volumes(window) + stamp_rate(window.controller.items[0]) + window.queue._tick() shoot(window, out, "app-simple-mode.png") editor = PresetEditor(PRESETS[2])