diff --git a/news_responder_gui.py b/news_responder_gui.py index fc368db..015836f 100644 --- a/news_responder_gui.py +++ b/news_responder_gui.py @@ -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() @@ -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() @@ -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 diff --git a/tests/test_gui_callbacks.py b/tests/test_gui_callbacks.py new file mode 100644 index 0000000..3ee4470 --- /dev/null +++ b/tests/test_gui_callbacks.py @@ -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()