Skip to content

Commit 6587328

Browse files
committed
test(template overview): refractor and add more tests
1 parent eda983b commit 6587328

3 files changed

Lines changed: 619 additions & 883 deletions

File tree

ardupilot_methodic_configurator/frontend_tkinter_directory_selection.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,8 @@ def __init__( # pylint: disable=too-many-arguments, too-many-positional-argumen
8989
def on_select_directory(self) -> bool:
9090
if self.is_template_selection:
9191
if isinstance(self.parent.root, tk.Tk): # this keeps mypy and pyright happy
92-
TemplateOverviewWindow(self.parent.root)
92+
to = TemplateOverviewWindow(self.parent.root)
93+
to.run_app()
9394
selected_directory = ProgramSettings.get_recently_used_dirs()[0]
9495
logging_info(_("Selected template directory: %s"), selected_directory)
9596
else:

ardupilot_methodic_configurator/frontend_tkinter_template_overview.py

Lines changed: 112 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@
1313
import argparse
1414
import tkinter as tk
1515
from logging import basicConfig as logging_basicConfig
16-
from logging import debug as logging_debug
1716
from logging import getLevelName as logging_getLevelName
17+
from logging import info as logging_info
18+
from tkinter import font as tkfont
1819
from tkinter import ttk
1920
from typing import Optional
2021

@@ -39,14 +40,20 @@ class TemplateOverviewWindow(BaseWindow):
3940
4041
Attributes:
4142
window (tk.Tk|None): The root Tkinter window object for the GUI.
42-
43-
Methods:
44-
on_row_double_click(event): Handles the event triggered when a row in the Treeview is double-clicked, allowing the user
45-
to store the corresponding template directory.
43+
sort_column (str): The column currently being used for sorting
44+
tree (ttk.Treeview): The treeview widget displaying templates
45+
image_label (ttk.Label): Label for displaying vehicle images
4646
4747
"""
4848

4949
def __init__(self, parent: Optional[tk.Tk] = None) -> None:
50+
"""
51+
Initialize the TemplateOverviewWindow.
52+
53+
Args:
54+
parent: Optional parent Tk window
55+
56+
"""
5057
super().__init__(parent)
5158
title = _("Amilcar Lucas's - ArduPilot methodic configurator {} - Template Overview and selection")
5259
self.root.title(title.format(__version__))
@@ -63,8 +70,12 @@ def __init__(self, parent: Optional[tk.Tk] = None) -> None:
6370
self.image_label = ttk.Label(self.top_frame)
6471
self.image_label.pack(side=tk.RIGHT, anchor=tk.NE, padx=(20, 20), pady=IMAGE_HEIGHT_PX / 2)
6572

66-
self.sort_column: str
73+
self.sort_column: str = ""
74+
self.setup_treeview()
75+
self.bind_events()
6776

77+
def setup_treeview(self) -> None:
78+
"""Set up the treeview with columns and styling."""
6879
style = ttk.Style(self.root)
6980
# Add padding to Treeview heading style
7081
style.layout(
@@ -99,76 +110,87 @@ def __init__(self, parent: Optional[tk.Tk] = None) -> None:
99110
for col in columns:
100111
self.tree.heading(col, text=col)
101112

102-
# Populate the Treeview with data from the template overview
113+
self.populate_treeview()
114+
self._adjust_treeview_column_widths()
115+
self.tree.pack(fill=tk.BOTH, expand=True)
116+
117+
def populate_treeview(self) -> None:
118+
"""Populate the treeview with data from vehicle components."""
103119
for key, template_overview in VehicleComponents.get_vehicle_components_overviews().items():
104120
attribute_names = template_overview.attributes()
105121
values = (key, *(getattr(template_overview, attr, "") for attr in attribute_names))
106122
self.tree.insert("", "end", text=key, values=values)
107123

108-
self._adjust_treeview_column_widths()
109-
110-
self.tree.bind("<ButtonRelease-1>", self.__on_row_selection_change)
111-
self.tree.bind("<Up>", self.__on_row_selection_change)
112-
self.tree.bind("<Down>", self.__on_row_selection_change)
113-
self.tree.bind("<Double-1>", self.__on_row_double_click)
114-
self.tree.pack(fill=tk.BOTH, expand=True)
124+
def bind_events(self) -> None:
125+
"""Bind events to the treeview."""
126+
self.tree.bind("<ButtonRelease-1>", self._on_row_selection_change)
127+
self.tree.bind("<Up>", self._on_row_selection_change)
128+
self.tree.bind("<Down>", self._on_row_selection_change)
129+
self.tree.bind("<Double-1>", self._on_row_double_click)
115130

116131
for col in self.tree["columns"]:
117132
col_str = str(col)
118133
self.tree.heading(
119134
col_str,
120135
text=col_str,
121-
command=lambda col2=col_str: self.__sort_by_column(col2, reverse=False), # type: ignore[misc]
136+
command=lambda col2=col_str: self._sort_by_column(col2, reverse=False), # type: ignore[misc]
122137
)
123138

124-
if isinstance(self.root, tk.Toplevel):
125-
try:
126-
while self.root.children:
127-
self.root.update_idletasks()
128-
self.root.update()
129-
except tk.TclError as _exp:
130-
pass
131-
else:
132-
self.root.mainloop()
133-
134139
def _adjust_treeview_column_widths(self) -> None:
135140
"""Adjusts the column widths of the Treeview to fit the contents of each column."""
136141
for col in self.tree["columns"]:
137142
max_width = 0
138143
for subtitle in col.title().split("\n"):
139-
max_width = max(max_width, tk.font.Font().measure(subtitle)) # pyright: ignore[reportAttributeAccessIssue]
144+
max_width = max(max_width, tkfont.Font().measure(subtitle))
140145

141146
# Iterate over all rows and update the max_width if a wider entry is found
142147
for item in self.tree.get_children():
143148
item_text = self.tree.item(item, "values")[self.tree["columns"].index(col)]
144-
text_width = tk.font.Font().measure(item_text) # pyright: ignore[reportAttributeAccessIssue]
149+
text_width = tkfont.Font().measure(item_text)
145150
max_width = max(max_width, text_width)
146151

147152
# Update the column's width property to accommodate the largest text width
148153
self.tree.column(col, width=int(max_width * 0.6 + 10))
149154

150-
def __on_row_selection_change(self, _event: tk.Event) -> None:
155+
def _on_row_selection_change(self, _event: tk.Event) -> None:
151156
"""Handle row single-click event."""
152-
self.root.after(0, self.__update_selection)
157+
self.root.after(0, self._update_selection)
153158

154-
def __update_selection(self) -> None:
159+
def _update_selection(self) -> None:
155160
"""Update selection after keypress event."""
156161
selected_item = self.tree.selection()
157162
if selected_item:
158163
item_id = selected_item[0]
159164
selected_template_relative_path = self.tree.item(item_id)["text"]
160-
ProgramSettings.store_template_dir(selected_template_relative_path)
165+
self.store_template_dir(selected_template_relative_path)
161166
self._display_vehicle_image(selected_template_relative_path)
162167

163-
def __on_row_double_click(self, event: tk.Event) -> None:
168+
def store_template_dir(self, template_path: str) -> None:
169+
"""
170+
Store the selected template directory.
171+
172+
This method is separated from the UI event handler to improve testability.
173+
174+
Args:
175+
template_path: The path to store
176+
177+
"""
178+
ProgramSettings.store_template_dir(template_path)
179+
180+
def _on_row_double_click(self, event: tk.Event) -> None:
164181
"""Handle row double-click event."""
165182
item_id = self.tree.identify_row(event.y)
166183
if item_id:
167184
selected_template_relative_path = self.tree.item(item_id)["text"]
168-
ProgramSettings.store_template_dir(selected_template_relative_path)
169-
self.root.destroy()
185+
self.store_template_dir(selected_template_relative_path)
186+
self.close_window()
187+
188+
def close_window(self) -> None:
189+
"""Close the window - separated for testability."""
190+
self.root.destroy()
170191

171-
def __sort_by_column(self, col: str, reverse: bool) -> None:
192+
def _sort_by_column(self, col: str, reverse: bool) -> None:
193+
"""Sort treeview items by the specified column."""
172194
if hasattr(self, "sort_column") and self.sort_column and self.sort_column != col:
173195
self.tree.heading(self.sort_column, text=self.sort_column)
174196
self.tree.heading(col, text=col + (" ▼" if reverse else " ▲"))
@@ -185,7 +207,7 @@ def __sort_by_column(self, col: str, reverse: bool) -> None:
185207
self.tree.move(k, "", index)
186208

187209
# reverse sort next time
188-
self.tree.heading(col, command=lambda: self.__sort_by_column(col, not reverse))
210+
self.tree.heading(col, command=lambda: self._sort_by_column(col, not reverse))
189211

190212
def _display_vehicle_image(self, template_path: str) -> None:
191213
"""Display the vehicle image corresponding to the selected template."""
@@ -194,7 +216,7 @@ def _display_vehicle_image(self, template_path: str) -> None:
194216
if isinstance(widget, ttk.Label) and widget == self.image_label:
195217
widget.destroy()
196218
try:
197-
vehicle_image_filepath = VehicleComponents.get_vehicle_image_filepath(template_path)
219+
vehicle_image_filepath = self.get_vehicle_image_filepath(template_path)
198220
self.image_label = self.put_image_in_label(self.top_frame, vehicle_image_filepath, IMAGE_HEIGHT_PX)
199221
except FileNotFoundError:
200222
self.image_label = ttk.Label(
@@ -204,6 +226,36 @@ def _display_vehicle_image(self, template_path: str) -> None:
204226
)
205227
self.image_label.pack(side=tk.RIGHT, anchor=tk.NE, padx=(4, 0), pady=(0, 0))
206228

229+
def get_vehicle_image_filepath(self, template_path: str) -> str:
230+
"""
231+
Get the filepath for a vehicle image.
232+
233+
Separated from display method for testability.
234+
235+
Args:
236+
template_path: Path to the template
237+
238+
Returns:
239+
Path to the vehicle image
240+
241+
Raises:
242+
FileNotFoundError: If the image file doesn't exist
243+
244+
"""
245+
return VehicleComponents.get_vehicle_image_filepath(template_path)
246+
247+
def run_app(self) -> None:
248+
"""Run the TemplateOverviewWindow application."""
249+
if isinstance(self.root, tk.Toplevel):
250+
try:
251+
while self.root.children:
252+
self.root.update_idletasks()
253+
self.root.update()
254+
except tk.TclError as _exp:
255+
pass
256+
elif isinstance(self.root, tk.Tk):
257+
self.root.mainloop()
258+
207259

208260
def argument_parser() -> argparse.Namespace:
209261
"""
@@ -217,25 +269,38 @@ def argument_parser() -> argparse.Namespace:
217269
"""
218270
parser = argparse.ArgumentParser(
219271
description=_(
220-
"ArduPilot methodic configurator is a GUI-based tool designed to simplify "
221-
"the management and visualization of ArduPilot parameters. It enables users "
222-
"to browse through various vehicle templates, edit parameter files, and "
223-
"apply changes directly to the flight controller. The tool is built to "
224-
"semi-automate the configuration process of ArduPilot for drones by "
225-
"providing a clear and intuitive interface for parameter management."
272+
"ArduPilot Template Overview - A component of the ArduPilot Methodic Configurator suite. "
273+
"This tool presents available vehicle templates in a user-friendly interface, allowing you "
274+
"to browse, compare, and select the most appropriate template for your vehicle configuration. "
275+
"Select a template that most closely resembles your vehicle's component setup to streamline "
276+
"the configuration process. The selected template will serve as a starting point for more "
277+
"detailed parameter configuration."
226278
)
227279
)
228280
return add_common_arguments(parser).parse_args()
229281

230282

283+
def setup_logging(loglevel: str) -> None:
284+
"""
285+
Set up logging with the specified log level.
286+
287+
Args:
288+
loglevel: The log level as a string (e.g. 'DEBUG', 'INFO')
289+
290+
"""
291+
logging_basicConfig(level=logging_getLevelName(loglevel), format="%(asctime)s - %(levelname)s - %(message)s")
292+
293+
231294
def main() -> None:
295+
"""Main entry point for the application."""
232296
args = argument_parser()
297+
setup_logging(args.loglevel)
233298

234-
logging_basicConfig(level=logging_getLevelName(args.loglevel), format="%(asctime)s - %(levelname)s - %(message)s")
235-
236-
TemplateOverviewWindow(None)
299+
window = TemplateOverviewWindow()
300+
window.run_app()
237301

238-
logging_debug(ProgramSettings.get_recently_used_dirs()[0])
302+
if window and ProgramSettings.get_recently_used_dirs():
303+
logging_info(ProgramSettings.get_recently_used_dirs()[0])
239304

240305

241306
if __name__ == "__main__":

0 commit comments

Comments
 (0)