One merge window, not fifteen

show_conflict_summary() built a fresh ConflictSummaryDialog every time and
only reassigned self._conflict_dialog — the old dialog was still a child of
the window, so it stayed on screen. check_for_external_changes() runs
resolve_conflicts() on every Syncthing watch tick, so that was one window per
merge, forever. Fifteen had piled up on trav's desktop.

The dialog is now a session-long log: add_event() folds each merge in as its
own timestamped entry, newest first, and the restore button names the merge
it would undo. MainWindow reuses the open dialog and clears its reference on
finished, so closing it lets the next merge open a fresh one instead of
touching a deleted C++ object.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8Mzze7shr5pZoEgKQU5NW
This commit is contained in:
2026-08-20 20:33:14 -04:00
co-authored by Claude Opus 5
parent afadf31a99
commit 5df0af0bae
4 changed files with 195 additions and 36 deletions
+101
View File
@@ -0,0 +1,101 @@
"""Round 38: one merge window, and play counts that can't conflict.
Two bugs stacked fifteen "Synced changes merged" windows on the desktop: the
dialog was never reused, and playing a song rewrote all 15 MB of library.json —
so Syncthing produced a conflict file roughly once per track. The dialog is now
a session-long log, and play counts live in per-machine journal files that only
their owner ever writes.
"""
from datetime import datetime
import pytest
from lintunes.storage.conflict_resolver import ConflictSummary
def _summary(file="library.json", lines=("2 track(s) reconciled.",), backup="/b/1"):
return ConflictSummary(file, "library", list(lines), backup)
@pytest.fixture
def dialog_cls(qapp):
from lintunes.gui.conflict_dialog import ConflictSummaryDialog
return ConflictSummaryDialog
class TestOneMergeWindow:
def test_second_merge_appends_instead_of_replacing(self, dialog_cls):
dlg = dialog_cls([_summary(lines=["first merge"])], manager=None)
dlg.add_event([_summary(lines=["second merge"])])
text = dlg._body.toPlainText()
assert "first merge" in text and "second merge" in text
# Newest first.
assert text.index("second merge") < text.index("first merge")
def test_events_are_timestamped_once_there_is_more_than_one(self, dialog_cls):
dlg = dialog_cls([_summary()], manager=None)
assert "──" not in dlg._body.toPlainText() # a lone merge needs no header
dlg.add_event([_summary()], when=datetime(2026, 8, 20, 18, 42))
text = dlg._body.toPlainText()
assert "6:42 PM" in text
assert "2 merges this session." in dlg._intro.text()
def test_restore_targets_the_newest_backup_and_names_it(self, dialog_cls):
dlg = dialog_cls([_summary(backup="/b/old")], manager=None)
assert dlg._backup_dir == "/b/old"
dlg.add_event([_summary(backup="/b/new")], when=datetime(2026, 8, 20, 18, 42))
assert dlg._backup_dir == "/b/new"
assert dlg._restore_btn.text() == "Restore backup from 6:42 PM"
def test_empty_event_leaves_the_previous_backup_reachable(self, dialog_cls):
dlg = dialog_cls([_summary(backup="/b/old")], manager=None)
dlg.add_event([])
assert dlg._backup_dir == "/b/old"
assert dlg._restore_btn.isEnabled()
def test_no_backup_disables_the_backup_buttons(self, dialog_cls):
dlg = dialog_cls([_summary(backup="")], manager=None)
assert not dlg._open_btn.isEnabled()
assert not dlg._restore_btn.isEnabled()
class TestWindowReuse:
"""MainWindow isn't constructible headless, so exercise the two real methods
on a minimal QWidget host — that's where the stacking bug lived."""
@pytest.fixture
def host(self, qapp):
from PyQt6.QtWidgets import QWidget
from lintunes.gui.main_window import MainWindow
class Host(QWidget):
_manager = None
_conflict_dialog = None
show_conflict_summary = MainWindow.show_conflict_summary
_on_conflict_dialog_closed = MainWindow._on_conflict_dialog_closed
return Host()
def test_repeated_merges_make_exactly_one_dialog(self, host):
from lintunes.gui.conflict_dialog import ConflictSummaryDialog
for i in range(15):
host.show_conflict_summary([_summary(lines=[f"merge {i}"])])
dialogs = host.findChildren(ConflictSummaryDialog)
assert len(dialogs) == 1
text = dialogs[0]._body.toPlainText()
assert "merge 0" in text and "merge 14" in text
def test_closing_lets_the_next_merge_open_a_fresh_window(self, host):
host.show_conflict_summary([_summary()])
first = host._conflict_dialog
first.accept() # "Keep merged"
assert host._conflict_dialog is None
host.show_conflict_summary([_summary()])
assert host._conflict_dialog is not None
assert host._conflict_dialog is not first