diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cc4de2..0cdcb00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,15 @@ 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. 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 diff --git a/README.md b/README.md index e36c90d..db39dee 100644 --- a/README.md +++ b/README.md @@ -272,6 +272,16 @@ 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. 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. + Two guards run before anything is queued: - **Duplicate offload protection.** The source's file listing — names and sizes, 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/gui/file_list.py b/src/offloader/gui/file_list.py new file mode 100644 index 0000000..8d044c4 --- /dev/null +++ b/src/offloader/gui/file_list.py @@ -0,0 +1,239 @@ +"""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 +from ..util import format_size +from . import theme +from .widgets import label, row + +COLUMNS = ("", "File", "Size", "Source", "Destination") +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. +MARKS = { + FileStatus.VERIFIED: "✓✓", + FileStatus.COPIED: "✓", + FileStatus.FAILED: "✕", + FileStatus.SKIPPED: "–", + FileStatus.CANCELLED: "⊘", +} + + +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().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. + """ + sums = [d.checksum for d in entry.destinations if d.checksum] + if not sums: + return "—", True + first = sums[0] + return first, all(value == first for value in sums) + + +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: + return COLUMNS[section] + 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 == entry.checksum + + if role == Qt.DisplayRole: + return ( + MARKS.get(entry.status, "?"), + str(entry.relative), + format_size(entry.size), + abbreviate(entry.checksum), + abbreviate(destination) if agree else "mismatch", + )[column] + + if role == Qt.ForegroundRole: + if column == COL_MARK: + return QColor(theme.status_color(entry.status.value.lower())) + if column == COL_DESTINATION: + # Green only when this column has actually proved something: + # the two sides agree, and every destination agrees too. + if not agree: + return QColor(theme.status_color("failed")) + if matched: + return QColor(theme.status_color("verified")) + return None + + if role == Qt.ToolTipRole: + lines = [str(entry.source)] + if entry.checksum: + lines.append(f"source: {entry.checksum}") + for destination_entry in entry.destinations: + lines.append( + f"{destination_entry.status.value.lower():<11} " + f"{destination_entry.checksum or '—'} " + f"{destination_entry.path}") + if destination_entry.error: + lines.append(f" error: {destination_entry.error}") + if entry.checksum and not matched and destination != "—": + 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._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, 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) + 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)) + + @staticmethod + def _describe(job: Job) -> str: + verified = sum(1 for entry in job.files + if entry.status is FileStatus.VERIFIED) + failed = sum(1 for entry in job.files + if entry.status is FileStatus.FAILED) + parts = [f"{len(job.files)} files", + format_size(job.total_bytes), + job.hash_label] + # Counted rather than inferred from the job's verdict: "Verified: 5 of + # 6" is the number that decides whether a card can be erased. + if verified: + parts.append(f"verified {verified} of {len(job.files)}") + 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/tests/test_gui_file_list.py b/tests/test_gui_file_list.py new file mode 100644 index 0000000..72872b8 --- /dev/null +++ b/tests/test_gui_file_list.py @@ -0,0 +1,272 @@ +"""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 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.gui import theme # noqa: E402 +from offloader.gui.file_list import ( # noqa: E402 + COL_DESTINATION, + COL_MARK, + COL_SOURCE, + MARKS, + FileListPanel, + FileModel, + abbreviate, +) +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 _colour(model, 0, COL_DESTINATION) is None + + +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() 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])