516 lines
13 KiB
Python
516 lines
13 KiB
Python
"""
|
|
Test conftest to provide headless-safe tkinter/ttk stubs so GUI tests can run in CI
|
|
without a real Tcl/Tk installation. This file is intentionally minimal: it implements
|
|
only the methods and attributes used by the project test-suite.
|
|
"""
|
|
from types import SimpleNamespace
|
|
import builtins
|
|
|
|
import tkinter as tk
|
|
from tkinter import ttk
|
|
|
|
|
|
class DummyVar:
|
|
def __init__(self, value=None):
|
|
self._value = value
|
|
|
|
def get(self):
|
|
return self._value
|
|
|
|
def set(self, v):
|
|
self._value = v
|
|
|
|
|
|
class DummyWidget:
|
|
def __init__(self, master=None, **kwargs):
|
|
self.master = master
|
|
self.children = {}
|
|
self._config = dict(kwargs)
|
|
# Option database placeholder used by some GUI code (kept as method _options)
|
|
# expose a tk interpreter object for tkinter internals
|
|
try:
|
|
self.tk = getattr(master, "tk")
|
|
except Exception:
|
|
# Provide a tiny interpreter facade used by some widgets/PIL backends
|
|
class DummyInterp:
|
|
def __init__(self):
|
|
self._commands = {}
|
|
|
|
def createcommand(self, name, func):
|
|
self._commands[name] = func
|
|
return name
|
|
|
|
def deletecommand(self, name):
|
|
self._commands.pop(name, None)
|
|
|
|
def call(self, *args, **kwargs):
|
|
return None
|
|
|
|
self.tk = DummyInterp()
|
|
# default identifiers used by tkinter internals
|
|
self._last_child_ids = {}
|
|
parent_w = getattr(master, "_w", ".")
|
|
self._w = parent_w + f".{id(self)}"
|
|
|
|
# geometry managers
|
|
def pack(self, *a, **k):
|
|
return None
|
|
|
|
def grid(self, *a, **k):
|
|
return None
|
|
|
|
def place(self, *a, **k):
|
|
return None
|
|
|
|
def pack_forget(self):
|
|
return None
|
|
|
|
def pack_propagate(self, flag=None):
|
|
# emulate tkinter.Frame.pack_propagate; tests only set False to disable
|
|
# geometry propagation. We store it for introspection if needed.
|
|
if not hasattr(self, '_pack_propagate'):
|
|
self._pack_propagate = True
|
|
if flag is not None:
|
|
self._pack_propagate = bool(flag)
|
|
return None
|
|
|
|
def config(self, **kwargs):
|
|
self._config.update(kwargs)
|
|
|
|
# tkinter widgets often expose 'configure' as an alias
|
|
def configure(self, **kwargs):
|
|
return self.config(**kwargs)
|
|
|
|
def bind(self, *a, **k):
|
|
return None
|
|
|
|
def destroy(self):
|
|
return None
|
|
|
|
def grab_set(self):
|
|
return None
|
|
|
|
def grab_release(self):
|
|
return None
|
|
|
|
def after(self, ms, func=None, *args):
|
|
# return a fake id
|
|
return f"after-{id(self)}-{ms}"
|
|
|
|
def after_idle(self, func, *args):
|
|
# matplotlib backend expects after_idle to schedule an idle call.
|
|
# For headless tests call immediately and return an id-like token.
|
|
try:
|
|
func(*args)
|
|
except Exception:
|
|
# ignore exceptions raised by idle work in tests
|
|
pass
|
|
return f"after-idle-{id(self)}"
|
|
|
|
def after_cancel(self, id_):
|
|
return None
|
|
|
|
def see(self, *a, **k):
|
|
return None
|
|
|
|
def focus(self, *a, **k):
|
|
return None
|
|
|
|
def focus_set(self):
|
|
return None
|
|
|
|
def selection_set(self, *a, **k):
|
|
return None
|
|
|
|
def __setitem__(self, key, value):
|
|
# allow widget[key] = child assignment used by some windows
|
|
self.children[key] = value
|
|
|
|
def __getitem__(self, key):
|
|
return self.children.get(key)
|
|
|
|
def selection(self):
|
|
return []
|
|
|
|
# window-like helpers expected on Toplevel/Tk
|
|
def title(self, *a, **k):
|
|
return None
|
|
|
|
def geometry(self, *a, **k):
|
|
return None
|
|
|
|
def transient(self, master):
|
|
return None
|
|
|
|
def resizable(self, a, b):
|
|
return None
|
|
|
|
def protocol(self, *a, **k):
|
|
return None
|
|
|
|
def minsize(self, *a, **k):
|
|
return None
|
|
|
|
def rowconfigure(self, index, **kwargs):
|
|
if not hasattr(self, "_row_config"):
|
|
self._row_config = {}
|
|
self._row_config[index] = kwargs
|
|
return None
|
|
|
|
def columnconfigure(self, index, **kwargs):
|
|
if not hasattr(self, "_col_config"):
|
|
self._col_config = {}
|
|
self._col_config[index] = kwargs
|
|
return None
|
|
|
|
# aliases some code expects
|
|
def grid_rowconfigure(self, index, **kwargs):
|
|
return self.rowconfigure(index, **kwargs)
|
|
|
|
def grid_columnconfigure(self, index, **kwargs):
|
|
return self.columnconfigure(index, **kwargs)
|
|
|
|
def set(self, *a, **k):
|
|
# scrollbar.set compatibility
|
|
return None
|
|
|
|
def create_image(self, *a, **k):
|
|
return f"img-{id(self)}"
|
|
|
|
def create_rectangle(self, *a, **k):
|
|
return f"rect-{id(self)}"
|
|
|
|
def create_line(self, *a, **k):
|
|
return f"line-{id(self)}"
|
|
|
|
def create_oval(self, *a, **k):
|
|
return f"oval-{id(self)}"
|
|
|
|
def delete(self, *a, **k):
|
|
# generic delete for canvas or widgets
|
|
return None
|
|
|
|
def itemconfig(self, *a, **k):
|
|
return None
|
|
|
|
def move(self, *a, **k):
|
|
return None
|
|
|
|
def create_text(self, *a, **k):
|
|
return f"text-{id(self)}"
|
|
|
|
def create_window(self, *a, **k):
|
|
return f"window-{id(self)}"
|
|
|
|
def cget(self, key):
|
|
return self._config.get(key)
|
|
|
|
def winfo_exists(self):
|
|
return True
|
|
|
|
def winfo_width(self):
|
|
return getattr(self, '_width', 100)
|
|
|
|
def winfo_height(self):
|
|
return getattr(self, '_height', 100)
|
|
|
|
def winfo_children(self):
|
|
return list(self.children.values())
|
|
|
|
def pack_info(self):
|
|
return {}
|
|
|
|
def grid_info(self):
|
|
return {}
|
|
|
|
def winfo_toplevel(self):
|
|
# walk up to the top-level
|
|
node = self
|
|
while getattr(node, "master", None) is not None:
|
|
node = node.master
|
|
return node
|
|
|
|
def winfo_rootx(self):
|
|
return 0
|
|
|
|
def winfo_rooty(self):
|
|
return 0
|
|
|
|
def winfo_x(self):
|
|
return 0
|
|
|
|
def winfo_y(self):
|
|
return 0
|
|
|
|
def wait_window(self, widget=None):
|
|
# non-blocking stub for tests
|
|
return None
|
|
|
|
def _options(self, options: dict):
|
|
"""Convert an options dict into the flattened list expected by
|
|
tk interpreter calls (e.g. ['-title', 'MyTitle', '-message', '...']).
|
|
Tests only need that this is callable and returns a sequence.
|
|
"""
|
|
res = []
|
|
for k, v in (options or {}).items():
|
|
# tkinter option names are prefixed with '-' in the interpreter
|
|
res.append(f"-{k}")
|
|
res.append(v)
|
|
return res
|
|
|
|
# Minimal text buffer API used by scrolledtext.ScrolledText in the GUI
|
|
def insert(self, index, text, tag=None):
|
|
if not hasattr(self, '_text'):
|
|
self._text = ''
|
|
# simple append semantics for tests
|
|
self._text = (self._text or '') + str(text)
|
|
|
|
def delete(self, start, end=None):
|
|
# clear buffer entirely for simplicity
|
|
self._text = ''
|
|
|
|
def get(self, start, end=None):
|
|
return getattr(self, '_text', '')
|
|
|
|
def tag_config(self, *a, **k):
|
|
return None
|
|
|
|
def index(self, idx):
|
|
# return a dummy index
|
|
return '1.0'
|
|
|
|
|
|
class DummyTreeview(DummyWidget):
|
|
def __init__(self, master=None, **kwargs):
|
|
super().__init__(master, **kwargs)
|
|
self._items = {}
|
|
|
|
def delete(self, *items):
|
|
for it in items:
|
|
if it in self._items:
|
|
del self._items[it]
|
|
|
|
def insert(self, parent, pos, iid=None, values=None):
|
|
if iid is None:
|
|
# generate a deterministic id
|
|
iid = f"item-{len(self._items)+1}"
|
|
self._items[iid] = values
|
|
return iid
|
|
|
|
def get_children(self):
|
|
return list(self._items.keys())
|
|
|
|
def heading(self, *a, **k):
|
|
return None
|
|
|
|
def column(self, *a, **k):
|
|
return None
|
|
|
|
def configure(self, **kwargs):
|
|
# accept yscroll or other widget bindings
|
|
self._config.update(kwargs)
|
|
return None
|
|
|
|
def yview(self, *a, **k):
|
|
return None
|
|
|
|
def yview(self, *a, **k):
|
|
return None
|
|
|
|
def item(self, iid, **kwargs):
|
|
# emulate getting/setting an item
|
|
if kwargs:
|
|
# set values if provided
|
|
if 'values' in kwargs:
|
|
self._items[iid] = kwargs['values']
|
|
return None
|
|
return {'values': self._items.get(iid)}
|
|
|
|
def set(self, item, column=None, value=None):
|
|
# behave like setting a cell value
|
|
if item in self._items:
|
|
vals = list(self._items[item]) if self._items[item] is not None else []
|
|
# try to set by index
|
|
try:
|
|
idx = int(column)
|
|
# expand if needed
|
|
while len(vals) <= idx:
|
|
vals.append(None)
|
|
vals[idx] = value
|
|
self._items[item] = tuple(vals)
|
|
except Exception:
|
|
# fallback: ignore
|
|
pass
|
|
|
|
def selection(self):
|
|
# return empty selection by default
|
|
return []
|
|
|
|
|
|
class DummyTk(DummyWidget):
|
|
def __init__(self, *a, **k):
|
|
super().__init__(None)
|
|
# tk attribute used by tkinter internals: provide a fake interpreter
|
|
class _Interp:
|
|
def __init__(self):
|
|
self._commands = {}
|
|
|
|
def createcommand(self, name, func):
|
|
self._commands[name] = func
|
|
return name
|
|
|
|
def deletecommand(self, name):
|
|
self._commands.pop(name, None)
|
|
|
|
def call(self, *args, **kwargs):
|
|
return None
|
|
|
|
self.tk = _Interp()
|
|
self._last_child_ids = {}
|
|
self._w = "."
|
|
|
|
def after(self, ms, func=None, *args):
|
|
# For logger tests which schedule via after
|
|
return f"after-{ms}"
|
|
|
|
def title(self, *a, **k):
|
|
return None
|
|
|
|
def geometry(self, *a, **k):
|
|
return None
|
|
|
|
def protocol(self, *a, **k):
|
|
return None
|
|
|
|
def withdraw(self):
|
|
return None
|
|
|
|
def wait_window(self, widget=None):
|
|
return None
|
|
|
|
def focus_set(self):
|
|
return None
|
|
|
|
def winfo_x(self):
|
|
return 0
|
|
self._w = "."
|
|
# emulate option database expected by some GUI code
|
|
self._options = {}
|
|
def winfo_y(self):
|
|
return 0
|
|
|
|
|
|
# Apply the lightweight stubs to tkinter and ttk used names
|
|
tk.Tk = DummyTk
|
|
tk.Toplevel = DummyWidget
|
|
tk.StringVar = DummyVar
|
|
tk.BooleanVar = DummyVar
|
|
tk.DoubleVar = DummyVar
|
|
tk.IntVar = DummyVar
|
|
|
|
ttk.Frame = DummyWidget
|
|
ttk.LabelFrame = DummyWidget
|
|
ttk.Scrollbar = DummyWidget
|
|
ttk.Button = DummyWidget
|
|
ttk.Label = DummyWidget
|
|
ttk.Scale = DummyWidget
|
|
ttk.Combobox = DummyWidget
|
|
ttk.Checkbutton = DummyWidget
|
|
ttk.Treeview = DummyTreeview
|
|
|
|
# Replace tk.Canvas as matplotlib backend sometimes creates it
|
|
tk.Canvas = DummyWidget
|
|
|
|
# Ensure scrolledtext.ScrolledText uses our DummyWidget so get/insert/delete work
|
|
try:
|
|
import tkinter.scrolledtext as scrolledtext
|
|
scrolledtext.ScrolledText = DummyWidget
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
class DummyMenu(DummyWidget):
|
|
def add_cascade(self, label=None, menu=None, **kwargs):
|
|
return None
|
|
|
|
def add_command(self, label=None, command=None, **kwargs):
|
|
return None
|
|
|
|
def add_separator(self):
|
|
return None
|
|
|
|
|
|
tk.Menu = DummyMenu
|
|
|
|
# Add set to scrollbar widget
|
|
def _scrollbar_set(*a, **k):
|
|
return None
|
|
|
|
ttk.Scrollbar.set = staticmethod(lambda *a, **k: None)
|
|
|
|
# Some modules import names at top-level; also set them on builtins to be safe
|
|
builtins._DUMMY_TK_INSTALLED_FOR_TESTS = True
|
|
|
|
|
|
# PhotoImage stub used by some GUI code
|
|
class DummyPhotoImage:
|
|
def __init__(self, *a, **k):
|
|
self._size = k.get('size', (1, 1))
|
|
|
|
# Provide a .tk attribute similar to real PhotoImage which holds the tk interpreter
|
|
try:
|
|
self.tk = getattr(tk, '_default_root').tk
|
|
except Exception:
|
|
self.tk = SimpleNamespace(call=lambda *a, **k: None)
|
|
|
|
def subsample(self, *a, **k):
|
|
return self
|
|
|
|
def zoom(self, *a, **k):
|
|
return self
|
|
|
|
|
|
tk.PhotoImage = DummyPhotoImage
|
|
|
|
|
|
# Minimal ttk.Style stub
|
|
class DummyStyle:
|
|
def __init__(self):
|
|
self._styles = {}
|
|
|
|
def configure(self, style_name, **kwargs):
|
|
self._styles[style_name] = kwargs
|
|
|
|
def lookup(self, style_name, option, default=None):
|
|
return self._styles.get(style_name, {}).get(option, default)
|
|
|
|
|
|
ttk.Style = DummyStyle
|
|
|
|
# If PIL.ImageTk is available, ensure its PhotoImage is replaced by our dummy to avoid
|
|
# AttributeError when Pillow's PhotoImage expects a real tk interpreter in headless tests.
|
|
try:
|
|
from PIL import ImageTk
|
|
|
|
ImageTk.PhotoImage = DummyPhotoImage
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# provide a default root used by some libraries
|
|
_DEFAULT_ROOT = DummyTk()
|
|
tk._default_root = _DEFAULT_ROOT
|
|
|
|
# ensure Scrollbar is available on tk and ttk
|
|
tk.Scrollbar = DummyWidget
|
|
ttk.Scrollbar = DummyWidget
|
|
|
|
# add update stubs used by some widgets/backends
|
|
def _noop_update(self=None):
|
|
return None
|
|
|
|
DummyWidget.update = _noop_update
|
|
DummyWidget.update_idletasks = _noop_update
|
|
DummyTk.update = _noop_update
|
|
DummyTk.update_idletasks = _noop_update
|