From 70a979de91fba72932d907868a911d80e1b763eb Mon Sep 17 00:00:00 2001
From: Daniel Mulholland
Date: Sun, 8 Nov 2026 08:08:42 +1000
Subject: [PATCH] Live Log: follows the stepped run across runs, run selector,
latest step highlighted
The feed counted the lines already shown and appended only the tail of the
session's current log. Each run has its own, shorter log, so after Jump (or
stepping off the end of a run) nothing reached the tree and run 1's rows
stayed on screen. The Live Log is now a panel with a run selector (Run N of
M, previous/next), shows the run being stepped and follows it into the next
run, keeps finished runs readable while stepping on, tints and bolds the
rows the latest step added, keeps the newest row in view, and holds at most
the newest 5,000 rows of a run (saying so). The Results dock's live view
now lists every run so far, not just the current one.
---
docs/manual.adoc | 11 +
docs/manual.html | 12 +
new_docs/book.html | 8 +-
new_docs/chapters/ch15-debugger.adoc | 8 +-
raptor/gui/ui/live_log_panel.py | 316 +++++++++++++++++++++++++++
raptor/gui/ui/main_window.py | 72 ++----
tests/unit/test_gui_live_log_runs.py | 163 ++++++++++++++
7 files changed, 531 insertions(+), 59 deletions(-)
create mode 100644 raptor/gui/ui/live_log_panel.py
create mode 100644 tests/unit/test_gui_live_log_runs.py
diff --git a/docs/manual.adoc b/docs/manual.adoc
index 500ddb8b..e9fb14fe 100644
--- a/docs/manual.adoc
+++ b/docs/manual.adoc
@@ -960,6 +960,17 @@ discards the stepped run and begins a fresh one; **Terminate**
finalizes at the current sim time and keeps partial results; **Finish**
completes the run and produces full reports.
+While you step, the **Live Log** tab (beside the Inspector) lists the
+events of the run on screen. It follows every step: the rows the latest
+step added are tinted and in bold, and the newest row is kept in view.
+The selector above the list reads *Run 2 of 5 (stepping)*; its arrows
+and drop-down bring back any run that has already finished, so you can
+read run 1 again while run 2 is stepped. Stepping on leaves a finished
+run alone; choose the last entry to follow the live run again. The list
+holds the newest 5,000 rows of a run and says so when it is cutting; the
+full log is in the Results Event Log when the runs end. Clicking a row
+finds its element on the diagram.
+
While **Animate** is on, the status bar carries a live
**system-status square** (green = fully working, yellow = degraded,
red = fully down) and cumulative readouts — Ao, MTBDE, MDT, tracked
diff --git a/docs/manual.html b/docs/manual.html
index d337c877..9ce6ab5b 100644
--- a/docs/manual.html
+++ b/docs/manual.html
@@ -1481,6 +1481,18 @@ Debug tab: stepping debugger
completes the run and produces full reports.
+
While you step, the Live Log tab (beside the Inspector) lists the
+events of the run on screen. It follows every step: the rows the latest
+step added are tinted and in bold, and the newest row is kept in view.
+The selector above the list reads Run 2 of 5 (stepping); its arrows
+and drop-down bring back any run that has already finished, so you can
+read run 1 again while run 2 is stepped. Stepping on leaves a finished
+run alone; choose the last entry to follow the live run again. The list
+holds the newest 5,000 rows of a run and says so when it is cutting; the
+full log is in the Results Event Log when the runs end. Clicking a row
+finds its element on the diagram.
+
+
While Animate is on, the status bar carries a live
system-status square (green = fully working, yellow = degraded,
red = fully down) and cumulative readouts — Ao, MTBDE, MDT, tracked
diff --git a/new_docs/book.html b/new_docs/book.html
index 7a9af372..fddda924 100644
--- a/new_docs/book.html
+++ b/new_docs/book.html
@@ -9214,8 +9214,9 @@
22.4. Step into the bakery
repairing, blue standby;
-the event log in the Results dock — one new coloured row per
-press, the same rows Chapter 3, Redundancy: Two Pumps Are Better Than One taught you to read;
+the Live Log tab beside the Inspector — one new coloured row per
+press, the same rows Chapter 3, Redundancy: Two Pumps Are Better Than One taught you to read, the newest
+ones tinted so the event you just witnessed stands out;
the status bar — plain words about the event you just witnessed,
@@ -9353,6 +9354,9 @@
22.6. Sabotage: Failure Effects View
Jump lets the current run go by without stopping and pauses at
the next run’s first change, with the dice carried on from where the run before left them.
+The Live Log follows the run being stepped; its run selector
+(Run 2 of 3) brings back the runs already finished, so you can reread
+run 1 while stepping run 2.
The session is single-use; the next Step starts a fresh one with fresh dice.
diff --git a/new_docs/chapters/ch15-debugger.adoc b/new_docs/chapters/ch15-debugger.adoc
index 4955973d..7c044e66 100644
--- a/new_docs/chapters/ch15-debugger.adoc
+++ b/new_docs/chapters/ch15-debugger.adoc
@@ -51,8 +51,9 @@ colour change. Watch three things move together:
* the **diagram** — blocks re-colour live: green running, red
repairing, blue standby;
-* the **event log** in the Results dock — one new coloured row per
- press, the same rows <> taught you to read;
+* the **Live Log** tab beside the Inspector — one new coloured row per
+ press, the same rows <> taught you to read, the newest
+ ones tinted so the event you just witnessed stands out;
* the **status bar** — plain words about the event you just witnessed,
e.g. `Node end → Bad (t = 180.974304)`.
@@ -139,6 +140,9 @@ events, closing bookkeeping — and then every later run the model is set
to make, and presents the full reports, exactly like a batch run:
summary, event log, the works. A stepped session covers all the model's runs: **Jump** lets the current run go by without stopping and pauses at
the next run's first change, with the dice carried on from where the run before left them.
+The Live Log follows the run being stepped; its run selector
+(*Run 2 of 3*) brings back the runs already finished, so you can reread
+run 1 while stepping run 2.
The session is single-use; the next Step starts a fresh one with fresh dice.
.Limits of this model — verification is not validation
diff --git a/raptor/gui/ui/live_log_panel.py b/raptor/gui/ui/live_log_panel.py
new file mode 100644
index 00000000..79b61969
--- /dev/null
+++ b/raptor/gui/ui/live_log_panel.py
@@ -0,0 +1,316 @@
+"""The Live Log page: the event log of a stepped simulation, run by run.
+
+A stepped session spans every configured run. The page follows the run on
+screen (new rows land as each step happens, the rows of the latest step are
+highlighted and scrolled into view) and keeps the earlier runs reachable
+through a run selector, so a finished run can be read again while the next
+one is stepped.
+
+Only the rows on screen live in the tree: the session keeps every run's
+lines, the tree shows at most `ROW_CAP` of the chosen run (the newest), and
+says so when it is cutting.
+"""
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+from PySide6.QtCore import Qt, Signal
+from PySide6.QtGui import QBrush, QColor, QFont
+from PySide6.QtWidgets import (
+ QComboBox, QHBoxLayout, QLabel, QToolButton, QTreeWidget,
+ QTreeWidgetItem, QVBoxLayout, QWidget,
+)
+
+from raptor.api.tables import parse_event_log_line
+from raptor.gui.resources.design_tokens import active_colour
+
+#: Most rows the tree holds for one run (the newest are kept).
+ROW_CAP = 5000
+#: Opacity of the "this step" tint over the row (0-255), so the State
+#: colouring and the alternating rows underneath stay readable.
+_HIGHLIGHT_ALPHA = 80
+
+
+class LiveLogPanel(QWidget):
+ """Run selector, status line and the event-log tree."""
+
+ #: The run on screen was changed by the user (0-based index).
+ run_selected = Signal(int)
+
+ def __init__(self, parent: QWidget | None = None):
+ super().__init__(parent)
+ lay = QVBoxLayout(self)
+ lay.setContentsMargins(0, 0, 0, 0)
+
+ self.caption = QLabel("Live event log — stepping / animated run")
+ self.caption.setObjectName("SectionNote")
+ self.caption.setWordWrap(True)
+ lay.addWidget(self.caption)
+
+ bar = QHBoxLayout()
+ bar.setContentsMargins(0, 0, 0, 0)
+ self.prev_button = QToolButton()
+ self.prev_button.setText("◀")
+ self.prev_button.setAccessibleName("Previous run")
+ self.prev_button.setToolTip("Show the previous run's event log")
+ self.next_button = QToolButton()
+ self.next_button.setText("▶")
+ self.next_button.setAccessibleName("Next run")
+ self.next_button.setToolTip("Show the next run's event log")
+ self.run_combo = QComboBox()
+ self.run_combo.setAccessibleName("Run shown in the live log")
+ self.run_combo.setToolTip(
+ "Which run's events are listed. The run being stepped is the "
+ "last one; earlier runs stay here to read.")
+ self.run_combo.setSizeAdjustPolicy(
+ QComboBox.SizeAdjustPolicy.AdjustToContents)
+ bar.addWidget(self.prev_button)
+ bar.addWidget(self.run_combo, 1)
+ bar.addWidget(self.next_button)
+ lay.addLayout(bar)
+
+ self.status = QLabel("")
+ self.status.setObjectName("SectionNote")
+ self.status.setWordWrap(True)
+ self.status.setAccessibleName("Live log position")
+ lay.addWidget(self.status)
+
+ self.tree = QTreeWidget()
+ self.tree.setHeaderLabels(["Time", "Type", "Element", "State", "Detail"])
+ self.tree.setRootIsDecorated(False)
+ self.tree.setUniformRowHeights(True)
+ self.tree.setAlternatingRowColors(True)
+ self.tree.setAccessibleName("Live event log")
+ lay.addWidget(self.tree, 1)
+
+ # Whole-run state, all reset by `clear()`.
+ self._runs: list[Sequence[str]] = [] # finished runs' lines
+ self._current: Sequence[str] = [] # the run being stepped
+ self._total = 0
+ self._shown = 0 # run index on screen
+ self._fed = 0 # lines of `_shown` in tree
+ self._highlighted: list[QTreeWidgetItem] = []
+ self._cut = 0 # rows dropped from the top
+ self._t = 0.0
+ self._building = False
+
+ self.run_combo.currentIndexChanged.connect(self._on_combo)
+ self.prev_button.clicked.connect(lambda: self._step_run(-1))
+ self.next_button.clicked.connect(lambda: self._step_run(+1))
+ self._sync_buttons()
+
+ # -- public ---------------------------------------------------------
+ @property
+ def current_index(self) -> int:
+ """0-based index of the run being stepped (the newest)."""
+ return len(self._runs)
+
+ @property
+ def shown_index(self) -> int:
+ return self._shown
+
+ @property
+ def following(self) -> bool:
+ """True when the run on screen is the one being stepped."""
+ return self._shown == self.current_index
+
+ def clear(self) -> None:
+ self._runs = []
+ self._current = []
+ self._total = 0
+ self._shown = 0
+ self._fed = 0
+ self._cut = 0
+ self._highlighted = []
+ self.tree.clear()
+ self._building = True
+ self.run_combo.clear()
+ self._building = False
+ self.status.setText("")
+ self._sync_buttons()
+
+ def update_session(self, finished: Sequence[Sequence[str]],
+ current: Sequence[str], total_runs: int,
+ time: float) -> None:
+ """Called after every step: `finished` are the runs already done,
+ `current` is the live line list of the run being stepped."""
+ had_runs = len(self._runs)
+ was_following = self.following or not self.run_combo.count()
+ self._runs = list(finished)
+ self._current = current
+ self._total = max(int(total_runs), len(self._runs) + 1)
+ self._t = float(time)
+ if len(self._runs) != had_runs or not self.run_combo.count():
+ self._rebuild_combo()
+ if was_following:
+ # A new run started while its predecessor was on screen: move
+ # on to it (the tree must not keep showing the old run's rows).
+ if self._shown != self.current_index:
+ self._show_run(self.current_index)
+ else:
+ self._set_combo_index(self._shown)
+ self._append_new_lines()
+ self._update_status()
+ self._sync_buttons()
+
+ def refresh_colours(self) -> None:
+ """Re-tint the highlighted rows after a theme change."""
+ self._apply_highlight(self._highlighted)
+
+ # -- run selection --------------------------------------------------
+ def _rebuild_combo(self) -> None:
+ self._building = True
+ self.run_combo.clear()
+ n = self.current_index + 1
+ for i in range(n):
+ label = f"Run {i + 1} of {self._total}"
+ if i < n - 1:
+ label += " (finished)"
+ else:
+ label += " (stepping)"
+ self.run_combo.addItem(label, i)
+ self._building = False
+ self._set_combo_index(self._shown)
+
+ def _set_combo_index(self, i: int) -> None:
+ self._building = True
+ self.run_combo.setCurrentIndex(min(i, self.run_combo.count() - 1))
+ self._building = False
+
+ def _on_combo(self, idx: int) -> None:
+ if self._building or idx < 0:
+ return
+ self._show_run(idx)
+ self._update_status()
+ self._sync_buttons()
+ self.run_selected.emit(idx)
+
+ def _step_run(self, delta: int) -> None:
+ target = self.run_combo.currentIndex() + delta
+ if 0 <= target < self.run_combo.count():
+ self.run_combo.setCurrentIndex(target)
+
+ def _sync_buttons(self) -> None:
+ i = self.run_combo.currentIndex()
+ n = self.run_combo.count()
+ self.prev_button.setEnabled(i > 0)
+ self.next_button.setEnabled(0 <= i < n - 1)
+
+ # -- rows -----------------------------------------------------------
+ def _lines_of(self, run: int) -> Sequence[str]:
+ if run < len(self._runs):
+ return self._runs[run]
+ return self._current
+
+ def _show_run(self, run: int) -> None:
+ """Rebuild the tree from the chosen run (the newest ROW_CAP rows)."""
+ self._shown = run
+ self._set_combo_index(run)
+ self.tree.setUpdatesEnabled(False)
+ try:
+ self.tree.clear()
+ self._highlighted = []
+ lines = self._lines_of(run)
+ start = max(0, len(lines) - ROW_CAP)
+ self._cut = start
+ self._fed = start
+ self._add_rows(lines[start:])
+ self._fed = len(lines)
+ if self.following:
+ self._apply_highlight(self._last_step_rows(len(lines)))
+ finally:
+ self.tree.setUpdatesEnabled(True)
+ self.tree.scrollToBottom() if self.following else self.tree.scrollToTop()
+
+ def _append_new_lines(self) -> None:
+ lines = self._current
+ if len(lines) < self._fed: # the run restarted under us
+ self._show_run(self.current_index)
+ return
+ new = lines[self._fed:]
+ if not new:
+ return
+ self._add_rows(new)
+ self._fed = len(lines)
+ # Keep the tree bounded: the session still holds every line.
+ extra = self.tree.topLevelItemCount() - ROW_CAP
+ if extra > 0:
+ for _ in range(extra):
+ self.tree.takeTopLevelItem(0)
+ self._cut += extra
+ self._apply_highlight(self._last_step_rows(len(lines), len(new)))
+ self.tree.scrollToBottom()
+
+ def _rows(self) -> list[QTreeWidgetItem]:
+ out = []
+ for i in range(self.tree.topLevelItemCount()):
+ it = self.tree.topLevelItem(i)
+ if it is not None:
+ out.append(it)
+ return out
+
+ def _last_step_rows(self, total: int, n_new: int = 0) -> list[QTreeWidgetItem]:
+ """The rows the latest step added (or, on a rebuild, the rows of the
+ newest instant)."""
+ rows = self._rows()
+ if not rows:
+ return []
+ if n_new:
+ return rows[-min(n_new, len(rows)):]
+ # Rebuild of the run being stepped: the rows stamped with the
+ # newest time.
+ stamp = rows[-1].text(0)
+ newest = []
+ for it in reversed(rows):
+ if it.text(0) != stamp:
+ break
+ newest.append(it)
+ return newest[::-1]
+
+ def _add_rows(self, lines: Sequence[str]) -> None:
+ from raptor.gui.ui.results import ResultsDock
+ c = active_colour()
+ for line in lines:
+ item = QTreeWidgetItem(self.tree, list(parse_event_log_line(line)))
+ colour = ResultsDock._state_colour(item.text(3), c)
+ if colour is not None:
+ item.setForeground(3, colour)
+ # The element name gets the same tint so a state change is
+ # visible when the columns are narrow.
+ item.setForeground(2, colour)
+
+ def _apply_highlight(self, items: Sequence[QTreeWidgetItem]) -> None:
+ """Tint the latest step's rows (and bold them); clear the old ones."""
+ for it in self._highlighted:
+ try:
+ for col in range(self.tree.columnCount()):
+ it.setData(col, Qt.ItemDataRole.BackgroundRole, None)
+ f = it.font(col)
+ f.setBold(False)
+ it.setFont(col, f)
+ except RuntimeError: # row already trimmed away
+ continue
+ tint = QColor(active_colour()["accent"])
+ tint.setAlpha(_HIGHLIGHT_ALPHA)
+ brush = QBrush(tint)
+ for it in items:
+ for col in range(self.tree.columnCount()):
+ it.setBackground(col, brush)
+ f = QFont(it.font(col))
+ f.setBold(True)
+ it.setFont(col, f)
+ self._highlighted = list(items)
+
+ def _update_status(self) -> None:
+ total_lines = len(self._lines_of(self._shown))
+ parts = [f"Run {self._shown + 1} of {self._total}"]
+ if self.following:
+ parts.append(f"t = {self._t:.3f}")
+ else:
+ parts.append("finished")
+ parts.append(f"{total_lines:,} events")
+ text = " · ".join(parts)
+ if self._cut:
+ text += (f" — showing the newest {total_lines - self._cut:,}; "
+ "the full log is in the Results Event Log when the runs end")
+ self.status.setText(text)
diff --git a/raptor/gui/ui/main_window.py b/raptor/gui/ui/main_window.py
index 1ca5d4a6..7195d536 100644
--- a/raptor/gui/ui/main_window.py
+++ b/raptor/gui/ui/main_window.py
@@ -70,7 +70,6 @@
from raptor.gui.ui.explorer import ExplorerDock
from raptor.gui.ui.results import ResultsDock
from raptor.gui.ui.data_tables import DataTablesDock
-from raptor.api.tables import parse_event_log_line
from raptor.gui.resources import icons
from raptor.gui.resources.design_tokens import (
APPENDIX_D_STATE_COLOURS, ThemeManager, active_colour,
@@ -1664,30 +1663,17 @@ def _setup_docks(self):
# Live Log dock: while stepping / live-running, the right dock
# shows the streaming event log (like the Results Event Log)
# instead of the Inspector; the Inspector returns after.
- from PySide6.QtWidgets import QTreeWidget as _QTree
- live_page = QWidget()
- lv = QVBoxLayout(live_page)
- lv.setContentsMargins(0, 0, 0, 0)
- self._live_log_tree = _QTree()
- self._live_log_tree.setHeaderLabels(
- ["Time", "Type", "Element", "State", "Detail"])
- self._live_log_tree.setRootIsDecorated(False)
- self._live_log_tree.setUniformRowHeights(True)
- self._live_log_tree.setAlternatingRowColors(True)
+ # The panel carries a run selector, a position line and the tree.
+ from raptor.gui.ui.live_log_panel import LiveLogPanel
+ self._live_log_panel = LiveLogPanel()
+ live_page = self._live_log_panel
+ self._live_log_tree = self._live_log_panel.tree
+ self._live_log_caption = self._live_log_panel.caption
# Same affordances as the Results dock's Event Log: the State
# column coloured green/amber/red, and a click on a Block/Node row
- # locating that element on the canvas. The live log was a plain
- # grey tree, so watching a run meant reading it column by column
- # and then hunting for the element by eye.
+ # locating that element on the canvas.
self._live_log_tree.itemSelectionChanged.connect(
self._on_live_log_row_clicked)
- # Caption above the log.
- self._live_log_caption = QLabel(
- "Live event log — stepping / animated run")
- self._live_log_caption.setObjectName("SectionNote")
- self._live_log_caption.setWordWrap(True)
- lv.addWidget(self._live_log_caption)
- lv.addWidget(self._live_log_tree)
self._live_log_dock = QDockWidget("Live Log", self)
self._set_dock_icon(self._live_log_dock, "log_icon")
self._live_log_dock.setWidget(live_page)
@@ -2681,6 +2667,7 @@ def _on_theme_changed(self, dark: bool):
self._ribbon._refresh_theme(c)
self._apply_action_icons()
self._apply_dock_icons()
+ self._live_log_panel.refresh_colours()
for rethemer in (self._dock_titlebar_rethemers or []):
rethemer()
fev_inspector = getattr(self, "_fev_inspector", None)
@@ -3410,9 +3397,7 @@ def _reset_live_log(self):
"""Clear the stepping Live Log dock and its fed-counter together:
the counter reset alone would leave the previous run's rows above
the new session's lines."""
- if hasattr(self, "_live_log_fed"):
- self._live_log_fed = 0
- self._live_log_tree.clear()
+ self._live_log_panel.clear()
self._live_log_dock.setWindowTitle("Live Log")
def _invalidate_step_session(self):
@@ -5919,30 +5904,6 @@ def _show_live_log_dock(self, show: bool):
self._live_log_dock.hide()
self._inspector_dock.show()
- def _feed_live_log(self, logs: list[str]):
- """Append new log lines, coloured like the Results Event Log.
-
- Five columns (Time/Type/Element/State/Detail) from the engine's own
- tab-separated line -- `parse_event_log_line` is shared with the
- export path, so the live log and the spreadsheet can never disagree
- about a line's shape. The State cell is coloured green/amber/red by
- `ResultsDock._state_colour`, the same mapping the Event Log tab
- uses, so a run's health is readable at a glance instead of by
- reading the text."""
- from PySide6.QtWidgets import QTreeWidgetItem
- from raptor.gui.resources.design_tokens import active_colour
- from raptor.gui.ui.results import ResultsDock
- c = active_colour()
- for line in logs:
- item = QTreeWidgetItem(self._live_log_tree,
- list(parse_event_log_line(line)))
- colour = ResultsDock._state_colour(item.text(3), c)
- if colour is not None:
- item.setForeground(3, colour)
- # The element name gets the same tint so a state change is
- # visible when the columns are narrow.
- item.setForeground(2, colour)
-
def _on_live_log_row_clicked(self):
"""Focus the canvas on the selected live-log row's element."""
items = self._live_log_tree.selectedItems()
@@ -5974,12 +5935,11 @@ def _refresh_live_step_view(self, session):
getattr(getattr(session.cascade, "system_state", None),
"color", None))
self._show_live_log_dock(True)
- new_logs = getattr(session, "logs", [])
- prev_n = getattr(self, "_live_log_fed", 0)
- if len(new_logs) > prev_n:
- self._feed_live_log(new_logs[prev_n:])
- self._live_log_fed = len(new_logs)
- self._live_log_tree.scrollToBottom()
+ # Follows the run being stepped, across run boundaries; the
+ # selector keeps the finished runs readable.
+ self._live_log_panel.update_session(
+ [lg for lg, _ in getattr(session, "completed", [])],
+ session.logs, getattr(session, "num_runs", 1), session.time)
logs = session.logs
self._statusbar.showMessage(
f"Stepping — t = {session.time:.3f} "
@@ -5992,7 +5952,9 @@ def _refresh_live_step_view(self, session):
self._live_log_dock.setWindowTitle(
f"Live Log — t = {session.time:.3f} ({len(logs)} events)")
if self._results_dock.isVisible() and self._live_sim_action.isChecked():
- self._results.show_live_logs([logs], 1)
+ self._results.show_live_logs(
+ [lg for lg, _ in session.completed] + [logs],
+ session.num_runs)
QApplication.processEvents()
def _describe_and_focus_step(self, session, prev_statuses,
diff --git a/tests/unit/test_gui_live_log_runs.py b/tests/unit/test_gui_live_log_runs.py
new file mode 100644
index 00000000..f38b7b5f
--- /dev/null
+++ b/tests/unit/test_gui_live_log_runs.py
@@ -0,0 +1,163 @@
+"""The Live Log of a stepped simulation follows the run being stepped, keeps
+the finished runs reachable, and marks the rows of the latest step."""
+from __future__ import annotations
+
+import os
+
+import pytest
+
+pytest.importorskip("PySide6")
+os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+
+from PySide6.QtWidgets import QApplication
+
+from raptor.core.fileio.rbd import load_rbd
+from raptor.gui.ui.main_window import MainWindow
+
+REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+FIXTURE = os.path.join(REPO, "tests", "golden", "TwoBlockRedundancy", "input.rbd")
+
+
+@pytest.fixture(scope="module")
+def qapp():
+ return QApplication.instance() or QApplication([])
+
+
+@pytest.fixture
+def window(qapp):
+ w = MainWindow()
+ rbd = load_rbd(FIXTURE)
+ rbd.section("SYSTEM_DATA").rows[0]["TermCondition"] = "200"
+ rbd.section("SYSTEM_DATA").rows[0]["NumRuns"] = "3"
+ w._load_into_app(rbd, None)
+ w.show()
+ w._live_sim_action.setChecked(True)
+ qapp.processEvents()
+ yield w
+ w._invalidate_step_session()
+ w.close()
+ w.deleteLater()
+
+
+def _rows(panel):
+ return [panel.tree.topLevelItem(i)
+ for i in range(panel.tree.topLevelItemCount())]
+
+
+def _highlighted(panel):
+ return [r for r in _rows(panel) if r.font(0).bold()]
+
+
+def test_the_log_follows_every_step(window, qapp):
+ panel = window._live_log_panel
+ seen = []
+ for _ in range(3):
+ window._on_step()
+ qapp.processEvents()
+ seen.append(len(_rows(panel)))
+ assert len(_rows(panel)) == len(window._step_session.logs)
+ assert seen == sorted(seen) and seen[-1] > seen[0]
+
+
+def test_the_next_run_shows_its_own_rows_not_the_old_ones(window, qapp):
+ """Jump used to leave run 1's rows on screen: the fed-line counter was
+ never reset, so run 2's shorter log never reached the tree."""
+ panel = window._live_log_panel
+ for _ in range(3):
+ window._on_step()
+ run1_rows = len(_rows(panel))
+ window._on_step_jump()
+ qapp.processEvents()
+ session = window._step_session
+ assert session.run_index == 1
+ assert len(_rows(panel)) == len(session.logs) != run1_rows
+ window._on_step()
+ qapp.processEvents()
+ assert len(_rows(panel)) == len(session.logs)
+ assert panel.run_combo.currentIndex() == 1
+ assert "Run 2 of 3" in panel.status.text()
+
+
+def test_an_earlier_run_can_be_looked_at_while_stepping_on(window, qapp):
+ panel = window._live_log_panel
+ window._on_step()
+ window._on_step_jump()
+ window._on_step()
+ qapp.processEvents()
+ finished = window._step_session.completed[0][0]
+ assert panel.run_combo.count() == 2
+ panel.prev_button.click()
+ qapp.processEvents()
+ assert panel.shown_index == 0 and not panel.following
+ assert len(_rows(panel)) == len(finished)
+ assert _rows(panel)[0].text(0) == finished[0].split("\t")[0].strip()
+ assert not _highlighted(panel), "a finished run has no current event"
+ # Stepping on leaves the run on screen alone...
+ before = [r.text(0) for r in _rows(panel)]
+ window._on_step()
+ qapp.processEvents()
+ assert [r.text(0) for r in _rows(panel)] == before
+ assert panel.run_combo.currentIndex() == 0
+ # ...and going back to the current run shows the newest rows again.
+ panel.next_button.click()
+ qapp.processEvents()
+ assert panel.following
+ assert len(_rows(panel)) == len(window._step_session.logs)
+ assert _highlighted(panel)
+
+
+def test_the_latest_steps_rows_are_highlighted_and_it_moves(window, qapp):
+ panel = window._live_log_panel
+ window._on_step()
+ qapp.processEvents()
+ n1 = len(_rows(panel))
+ first = _highlighted(panel)
+ assert first and len(first) == n1
+ window._on_step()
+ qapp.processEvents()
+ rows = _rows(panel)
+ now = _highlighted(panel)
+ assert now and len(now) == len(rows) - n1
+ assert all(r in rows[n1:] for r in now)
+ assert not any(r.font(0).bold() for r in rows[:n1])
+ # The tint is the theme's accent, translucent so state colours show.
+ tint = now[0].background(0).color()
+ assert 0 < tint.alpha() < 255
+
+
+def test_the_newest_row_is_scrolled_into_view(window, qapp):
+ panel = window._live_log_panel
+ window.resize(1400, 500)
+ for _ in range(25):
+ window._on_step()
+ qapp.processEvents()
+ bar = panel.tree.verticalScrollBar()
+ assert bar.value() == bar.maximum()
+
+
+def test_the_tree_is_capped_and_says_so(window, qapp, monkeypatch):
+ from raptor.gui.ui import live_log_panel
+ monkeypatch.setattr(live_log_panel, "ROW_CAP", 6)
+ for _ in range(6):
+ window._on_step()
+ qapp.processEvents()
+ panel = window._live_log_panel
+ assert len(_rows(panel)) == 6
+ assert len(window._step_session.logs) > 6
+ assert "newest 6" in panel.status.text()
+
+
+def test_the_results_event_log_lists_every_run_so_far(window, qapp):
+ window._results.show()
+ window._on_step()
+ window._on_step_jump()
+ window._on_step()
+ qapp.processEvents()
+ assert len(window._results._event_logs) == 2
+
+
+def test_closing_the_session_clears_the_panel(window, qapp):
+ window._on_step()
+ window._invalidate_step_session()
+ panel = window._live_log_panel
+ assert panel.tree.topLevelItemCount() == 0 and panel.run_combo.count() == 0