Skip to content

Commit 5b5223e

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

File tree

3 files changed

+556
-897
lines changed

3 files changed

+556
-897
lines changed

ardupilot_methodic_configurator/frontend_tkinter_directory_selection.py

+2-1
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

+120-55
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,24 @@ 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()
76+
77+
def run_app(self) -> None:
78+
"""Run the TemplateOverviewWindow application."""
79+
if isinstance(self.root, tk.Toplevel):
80+
try:
81+
while self.root.children:
82+
self.root.update_idletasks()
83+
self.root.update()
84+
except tk.TclError as _exp:
85+
pass
86+
elif isinstance(self.root, tk.Tk):
87+
self.root.mainloop()
6788

89+
def _setup_treeview(self) -> None:
90+
"""Set up the treeview with columns and styling."""
6891
style = ttk.Style(self.root)
6992
# Add padding to Treeview heading style
7093
style.layout(
@@ -99,76 +122,87 @@ def __init__(self, parent: Optional[tk.Tk] = None) -> None:
99122
for col in columns:
100123
self.tree.heading(col, text=col)
101124

102-
# Populate the Treeview with data from the template overview
125+
self._populate_treeview()
126+
self._adjust_treeview_column_widths()
127+
self.tree.pack(fill=tk.BOTH, expand=True)
128+
129+
def _populate_treeview(self) -> None:
130+
"""Populate the treeview with data from vehicle components."""
103131
for key, template_overview in VehicleComponents.get_vehicle_components_overviews().items():
104132
attribute_names = template_overview.attributes()
105133
values = (key, *(getattr(template_overview, attr, "") for attr in attribute_names))
106134
self.tree.insert("", "end", text=key, values=values)
107135

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)
115-
116-
for col in self.tree["columns"]:
117-
col_str = str(col)
118-
self.tree.heading(
119-
col_str,
120-
text=col_str,
121-
command=lambda col2=col_str: self.__sort_by_column(col2, reverse=False), # type: ignore[misc]
122-
)
123-
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-
134136
def _adjust_treeview_column_widths(self) -> None:
135137
"""Adjusts the column widths of the Treeview to fit the contents of each column."""
136138
for col in self.tree["columns"]:
137139
max_width = 0
138140
for subtitle in col.title().split("\n"):
139-
max_width = max(max_width, tk.font.Font().measure(subtitle)) # pyright: ignore[reportAttributeAccessIssue]
141+
max_width = max(max_width, tkfont.Font().measure(subtitle))
140142

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

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

150-
def __on_row_selection_change(self, _event: tk.Event) -> None:
152+
def _bind_events(self) -> None:
153+
"""Bind events to the treeview."""
154+
self.tree.bind("<ButtonRelease-1>", self._on_row_selection_change)
155+
self.tree.bind("<Up>", self._on_row_selection_change)
156+
self.tree.bind("<Down>", self._on_row_selection_change)
157+
self.tree.bind("<Double-1>", self._on_row_double_click)
158+
159+
for col in self.tree["columns"]:
160+
col_str = str(col)
161+
self.tree.heading(
162+
col_str,
163+
text=col_str,
164+
command=lambda col2=col_str: self._sort_by_column(col2, reverse=False), # type: ignore[misc]
165+
)
166+
167+
def _on_row_selection_change(self, _event: tk.Event) -> None:
151168
"""Handle row single-click event."""
152-
self.root.after(0, self.__update_selection)
169+
self.root.after(0, self._update_selection)
153170

154-
def __update_selection(self) -> None:
171+
def _update_selection(self) -> None:
155172
"""Update selection after keypress event."""
156173
selected_item = self.tree.selection()
157174
if selected_item:
158175
item_id = selected_item[0]
159176
selected_template_relative_path = self.tree.item(item_id)["text"]
160-
ProgramSettings.store_template_dir(selected_template_relative_path)
177+
self.store_template_dir(selected_template_relative_path)
161178
self._display_vehicle_image(selected_template_relative_path)
162179

163-
def __on_row_double_click(self, event: tk.Event) -> None:
180+
def store_template_dir(self, template_path: str) -> None:
181+
"""
182+
Store the selected template directory.
183+
184+
This method is separated from the UI event handler to improve testability.
185+
186+
Args:
187+
template_path: The path to store
188+
189+
"""
190+
ProgramSettings.store_template_dir(template_path)
191+
192+
def _on_row_double_click(self, event: tk.Event) -> None:
164193
"""Handle row double-click event."""
165194
item_id = self.tree.identify_row(event.y)
166195
if item_id:
167196
selected_template_relative_path = self.tree.item(item_id)["text"]
168-
ProgramSettings.store_template_dir(selected_template_relative_path)
169-
self.root.destroy()
197+
self.store_template_dir(selected_template_relative_path)
198+
self.close_window()
199+
200+
def close_window(self) -> None:
201+
"""Close the window - separated for testability."""
202+
self.root.destroy()
170203

171-
def __sort_by_column(self, col: str, reverse: bool) -> None:
204+
def _sort_by_column(self, col: str, reverse: bool) -> None:
205+
"""Sort treeview items by the specified column."""
172206
if hasattr(self, "sort_column") and self.sort_column and self.sort_column != col:
173207
self.tree.heading(self.sort_column, text=self.sort_column)
174208
self.tree.heading(col, text=col + (" ▼" if reverse else " ▲"))
@@ -185,7 +219,7 @@ def __sort_by_column(self, col: str, reverse: bool) -> None:
185219
self.tree.move(k, "", index)
186220

187221
# reverse sort next time
188-
self.tree.heading(col, command=lambda: self.__sort_by_column(col, not reverse))
222+
self.tree.heading(col, command=lambda: self._sort_by_column(col, not reverse))
189223

190224
def _display_vehicle_image(self, template_path: str) -> None:
191225
"""Display the vehicle image corresponding to the selected template."""
@@ -194,7 +228,7 @@ def _display_vehicle_image(self, template_path: str) -> None:
194228
if isinstance(widget, ttk.Label) and widget == self.image_label:
195229
widget.destroy()
196230
try:
197-
vehicle_image_filepath = VehicleComponents.get_vehicle_image_filepath(template_path)
231+
vehicle_image_filepath = self.get_vehicle_image_filepath(template_path)
198232
self.image_label = self.put_image_in_label(self.top_frame, vehicle_image_filepath, IMAGE_HEIGHT_PX)
199233
except FileNotFoundError:
200234
self.image_label = ttk.Label(
@@ -204,6 +238,24 @@ def _display_vehicle_image(self, template_path: str) -> None:
204238
)
205239
self.image_label.pack(side=tk.RIGHT, anchor=tk.NE, padx=(4, 0), pady=(0, 0))
206240

241+
def get_vehicle_image_filepath(self, template_path: str) -> str:
242+
"""
243+
Get the filepath for a vehicle image.
244+
245+
Separated from display method for testability.
246+
247+
Args:
248+
template_path: Path to the template
249+
250+
Returns:
251+
Path to the vehicle image
252+
253+
Raises:
254+
FileNotFoundError: If the image file doesn't exist
255+
256+
"""
257+
return VehicleComponents.get_vehicle_image_filepath(template_path)
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)