Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions news_responder_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -651,7 +651,8 @@ def connect_thread():
self.root.after(0, lambda: self.update_tech_status('ai', True))

except Exception as e:
self.root.after(0, lambda: self.update_status(f"Warning: Could not load API keys: {e}", 50))
# Python clears the exception binding before Tk runs this callback.
self.root.after(0, lambda error=str(e): self.update_status(f"Warning: Could not load API keys: {error}", 50))

self.root.after(0, lambda: self.update_status("Loading Feedly RSS feed...", 60))
self.refresh_rss_feed()
Expand Down Expand Up @@ -679,9 +680,9 @@ def connect_thread():
self.root.after(0, lambda: self.connect_btn.configure(text="Reconnect", bg=self.colors['bg_input']))

except Exception as e:
self.root.after(0, lambda: self.update_status(f"Connection failed: {str(e)}", 0))
self.root.after(0, lambda error=str(e): self.update_status(f"Connection failed: {error}", 0))
self.root.after(0, lambda: self.update_progress_title("Error"))
self.root.after(0, lambda: messagebox.showerror("Connection Error", str(e)))
self.root.after(0, lambda error=str(e): messagebox.showerror("Connection Error", error))

threading.Thread(target=connect_thread, daemon=True).start()

Expand Down Expand Up @@ -824,8 +825,8 @@ def process_thread():
self.root.after(0, lambda: self.update_progress_title("Complete"))

except Exception as e:
self.root.after(0, lambda: self.update_status(f"Error: {str(e)}", 0))
self.root.after(0, lambda: messagebox.showerror("Processing Error", str(e)))
self.root.after(0, lambda error=str(e): self.update_status(f"Error: {error}", 0))
self.root.after(0, lambda error=str(e): messagebox.showerror("Processing Error", error))

finally:
self.is_processing = False
Expand Down
64 changes: 64 additions & 0 deletions tests/test_gui_callbacks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Run worker callbacks after the worker exits, without opening a GUI."""
import ast
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock

import pytest


def worker_method(name, **globals_override):
# Load just the worker method so headless CI needs neither Tcl nor a display.
source = Path(__file__).resolve().parents[1] / "news_responder_gui.py"
tree = ast.parse(source.read_text())
gui = next(node for node in tree.body if isinstance(node, ast.ClassDef))
method = next(node for node in gui.body if getattr(node, "name", None) == name)
namespace = {
"threading": SimpleNamespace(Thread=lambda target, **kw: SimpleNamespace(start=target)),
"GSPREAD_AVAILABLE": True,
"GOOGLE_CREDS_FILE": "unused.json",
"Credentials": Mock(),
"gspread": Mock(),
"messagebox": Mock(),
}
namespace.update(globals_override)
exec(compile(ast.Module(body=[method], type_ignores=[]), str(source), "exec"), namespace)
return namespace[name], namespace


@pytest.mark.parametrize("method_name, error_title", [
("connect_sheet", "Connection Error"),
("start_processing", "Processing Error"),
])
def test_error_callbacks_survive_worker_exit(method_name, error_title):
method, namespace = worker_method(method_name)
callbacks = []
gui = Mock(is_processing=False)
gui.root.after.side_effect = lambda delay, callback: callbacks.append(callback)
namespace["Credentials"].from_service_account_file.side_effect = RuntimeError("service unavailable")
gui.sheet.get_all_values.side_effect = RuntimeError("service unavailable")

method(gui)
for callback in callbacks:
callback()

namespace["messagebox"].showerror.assert_called_once_with(error_title, "service unavailable")
assert any("service unavailable" in call.args[0] for call in gui.update_status.call_args_list)


def test_api_key_warning_survives_worker_exit():
method, namespace = worker_method("connect_sheet", SHEET_ID="example", SHEET_NAME="Comments", COL_CHATGPT=14)
callbacks = []
gui = Mock(api_keys={}, colors={"bg_input": "#000000"})
gui.root.after.side_effect = lambda delay, callback: callbacks.append(callback)
spreadsheet = namespace["gspread"].authorize.return_value.open_by_key.return_value
sheet = Mock()
sheet.get_all_values.return_value = [["header"]]
spreadsheet.worksheet.side_effect = [sheet, RuntimeError("API tab missing")]

method(gui)
for callback in callbacks:
callback()

assert any("API tab missing" in call.args[0] for call in gui.update_status.call_args_list)
namespace["messagebox"].showerror.assert_not_called()
Loading