diff --git a/tests/test_lazymodule_filedialog_fallback.py b/tests/test_lazymodule_filedialog_fallback.py new file mode 100644 index 00000000..a85324b3 --- /dev/null +++ b/tests/test_lazymodule_filedialog_fallback.py @@ -0,0 +1,101 @@ +""" +Regression test for: AttributeError: module 'tkinter' has no attribute 'filedialog' + +On some Python installs (e.g., embedded distributions, or installs where +the Tcl/Tk runtime is missing), the `tkinter` package imports cleanly but +the `tkinter.filedialog` sub-module fails to load. The original `_LazyModule` +in src/gui_2.py used `getattr(tkinter, 'filedialog')` which raises a +confusing AttributeError at the call site. With 14 call sites in +render_projects_panel, render_workspace_settings_hub, render_fonts_panel, +and render_gemini_cli_settings, this AttributeError spammed the GUI's +stderr at 60fps whenever the Project Settings window was open. + +The fix must make `_LazyModule` fall back to a stub that mimics +`tkinter.filedialog`'s public API (askopenfilename, askdirectory, +asksaveasfilename, askopenfilenames) so the GUI does not crash. + +This test uses a deliberately-missing sub-module to exercise the fallback +path, making it deterministic across Python installs. +""" +import pytest +import importlib + +from src.gui_2 import _LazyModule + + +def test_lazymodule_falls_back_to_stub_on_attribute_error() -> None: + """ + Resolution must NOT raise AttributeError when the sub-module is + missing. Instead, _resolve() must return a stub that exposes the + public filedialog API. Before the fix, this test fails with + AttributeError: module 'os' has no attribute 'this_submodule_does_not_exist'. + """ + bad = _LazyModule("os", "this_submodule_does_not_exist") + resolved = bad._resolve() + assert resolved is not None + assert hasattr(resolved, "askopenfilename") + assert hasattr(resolved, "askdirectory") + assert hasattr(resolved, "asksaveasfilename") + assert hasattr(resolved, "askopenfilenames") + + +def test_lazymodule_stub_returns_empty_strings() -> None: + """ + The stub functions must return safe empty values: + - askopenfilename, askdirectory, asksaveasfilename: empty string "" + - askopenfilenames: empty tuple () + This ensures downstream code that does `if p and p not in app.x:` + or `if paths:` treats the missing-dialog as a no-op. + """ + bad = _LazyModule("os", "this_submodule_does_not_exist") + resolved = bad._resolve() + assert resolved.askopenfilename() == "" + assert resolved.askdirectory() == "" + assert resolved.asksaveasfilename() == "" + assert resolved.askopenfilenames() == () + + +def test_lazymodule_stub_ignores_kwargs() -> None: + """ + The stub must accept the same kwargs the real tkinter.filedialog + accepts (title, filetypes, defaultextension, initialdir) and return + the empty sentinel. This prevents TypeError if a call site passes + kwargs that the stub does not know about. + """ + bad = _LazyModule("os", "this_submodule_does_not_exist") + resolved = bad._resolve() + assert resolved.askopenfilename(title="x", filetypes=[("All", "*.*")]) == "" + assert resolved.askdirectory(title="y", initialdir="/") == "" + assert resolved.asksaveasfilename(title="z", defaultextension=".toml", filetypes=[("TOML", "*.toml")]) == "" + assert resolved.askopenfilenames(filetypes=[("Image", "*.png")]) == () + + +def test_lazymodule_real_filedialog_resolves_when_tkinter_works() -> None: + """ + On a working tkinter install (with Tcl/Tk runtime), the + `_LazyModule("tkinter", "filedialog")` instance must resolve to the + real tkinter.filedialog module. This is the smoke test: if tkinter + is healthy, the lazy import works as before. + """ + import tkinter as tk_root + try: + import tkinter.filedialog as real_filedialog + except (ImportError, AttributeError, tk_root.TclError): + pytest.skip("tkinter.filedialog not available in this Python install") + lazy = _LazyModule("tkinter", "filedialog") + resolved = lazy._resolve() + assert resolved is real_filedialog + + +def test_lazymodule_real_filedialog_does_not_raise_attribute_error() -> None: + """ + On a working tkinter install, calling .askopenfilename() through the + lazy module must not raise AttributeError. (Tests the call path + used by 14 call sites in render_projects_panel etc.) + """ + lazy = _LazyModule("tkinter", "filedialog") + resolved = lazy._resolve() + assert hasattr(resolved, "askopenfilename") + assert hasattr(resolved, "askdirectory") + assert hasattr(resolved, "asksaveasfilename") + assert hasattr(resolved, "askopenfilenames")