mirror of
https://github.com/dredx/prole.git
synced 2026-09-24 14:54:34 +00:00
Fix navigation infinite loop in installer and add comprehensive test coverage - Fixed infinite loop in ProleInstaller.show_page by preventing default fallback to page 0 on invalid IDs - Improved on_next/on_prev logic with robust sequence checks - Added tests/test_navigation.py to verify navigation flows and edge cases - Cleaned up redundant imports and page registrations in install.py
This commit is contained in:
parent
f7ba4f6235
commit
80587c167c
292
install.py
292
install.py
@ -195,6 +195,12 @@ class ProleInstaller:
|
|||||||
# Shared dependency catalog from installer.config
|
# Shared dependency catalog from installer.config
|
||||||
self.dependencies = list(inst_config.DEPENDENCIES)
|
self.dependencies = list(inst_config.DEPENDENCIES)
|
||||||
|
|
||||||
|
# Disk selection variables
|
||||||
|
self.selected_disk_type = tk.StringVar(value='local') # 'removable' or 'local'
|
||||||
|
self.selected_removable_disk = tk.StringVar()
|
||||||
|
self.selected_local_path = tk.StringVar(value=str(Path.home()))
|
||||||
|
self.removable_disks = [] # List of (name, mount_point)
|
||||||
|
|
||||||
# Wizard pages setup
|
# Wizard pages setup
|
||||||
self.pages = [] # list of (page_id, frame)
|
self.pages = [] # list of (page_id, frame)
|
||||||
self.page_index = 0
|
self.page_index = 0
|
||||||
@ -232,6 +238,7 @@ class ProleInstaller:
|
|||||||
self._register_canvas_renderer('init_scripts', self._render_init_scripts_page)
|
self._register_canvas_renderer('init_scripts', self._render_init_scripts_page)
|
||||||
|
|
||||||
self._register_canvas_renderer('build', self._render_build_page)
|
self._register_canvas_renderer('build', self._render_build_page)
|
||||||
|
self._register_canvas_renderer('disk_selection', self._render_disk_selection_page)
|
||||||
self._register_canvas_renderer('build_summary', self._render_build_summary_page)
|
self._register_canvas_renderer('build_summary', self._render_build_summary_page)
|
||||||
|
|
||||||
# Mirror old pages list ordering for navigation
|
# Mirror old pages list ordering for navigation
|
||||||
@ -250,11 +257,12 @@ class ProleInstaller:
|
|||||||
self._register_page('init_password', None)
|
self._register_page('init_password', None)
|
||||||
|
|
||||||
self._register_page('build', None)
|
self._register_page('build', None)
|
||||||
|
self._register_page('disk_selection', None)
|
||||||
self._register_page('build_summary', None)
|
self._register_page('build_summary', None)
|
||||||
|
|
||||||
# Initial render after window shows
|
# Initial render after window shows
|
||||||
self.root.after(100, lambda: self.show_page(0))
|
|
||||||
self.update_footer()
|
self.update_footer()
|
||||||
|
self.show_page(0)
|
||||||
|
|
||||||
# ---------------- App identity (title, Dock icon) ----------------
|
# ---------------- App identity (title, Dock icon) ----------------
|
||||||
def _set_app_identity(self):
|
def _set_app_identity(self):
|
||||||
@ -410,10 +418,27 @@ class ProleInstaller:
|
|||||||
|
|
||||||
def show_page(self, index_or_id):
|
def show_page(self, index_or_id):
|
||||||
# Resolve index and page_id
|
# Resolve index and page_id
|
||||||
|
old_idx = self.page_index
|
||||||
if isinstance(index_or_id, int):
|
if isinstance(index_or_id, int):
|
||||||
idx = max(0, min(index_or_id, len(self.pages) - 1))
|
idx = max(0, min(index_or_id, len(self.pages) - 1))
|
||||||
else:
|
else:
|
||||||
idx = next((i for i, (pid, _) in enumerate(self.pages) if pid == index_or_id), 0)
|
# Find index by page_id
|
||||||
|
idx = -1
|
||||||
|
for i, (pid, _) in enumerate(self.pages):
|
||||||
|
if pid == index_or_id:
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
|
||||||
|
if idx == -1:
|
||||||
|
msg = f"Navigation error: page ID '{index_or_id}' not found."
|
||||||
|
print(f"[ERROR] {msg}")
|
||||||
|
try:
|
||||||
|
messagebox.showerror("Navigation Error", msg)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"[DEBUG] Navigating from {old_idx} to {idx} (requested: {index_or_id})")
|
||||||
self.page_index = idx
|
self.page_index = idx
|
||||||
pid, frame = self.pages[self.page_index]
|
pid, frame = self.pages[self.page_index]
|
||||||
|
|
||||||
@ -1874,6 +1899,149 @@ class ProleInstaller:
|
|||||||
|
|
||||||
threading.Thread(target=worker, daemon=True).start()
|
threading.Thread(target=worker, daemon=True).start()
|
||||||
|
|
||||||
|
def get_removable_disks(self):
|
||||||
|
"""Detect removable media on macOS using diskutil."""
|
||||||
|
self.removable_disks = []
|
||||||
|
if platform.system() != 'Darwin':
|
||||||
|
return self.removable_disks
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get list of all disks
|
||||||
|
res = subprocess.run(['diskutil', 'list', '-plist'], capture_output=True, text=True)
|
||||||
|
if res.returncode != 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
import plistlib
|
||||||
|
data = plistlib.loads(res.stdout.encode())
|
||||||
|
all_disks = data.get('AllDisks', [])
|
||||||
|
|
||||||
|
for disk in all_disks:
|
||||||
|
# Filter for whole disks to check if they are removable
|
||||||
|
if not disk.startswith('disk') or 's' in disk:
|
||||||
|
continue
|
||||||
|
|
||||||
|
info_res = subprocess.run(['diskutil', 'info', '-plist', disk], capture_output=True, text=True)
|
||||||
|
if info_res.returncode == 0:
|
||||||
|
info = plistlib.loads(info_res.stdout.encode())
|
||||||
|
# Check for RemovableMedia or RemovableMediaOrExternalDevice
|
||||||
|
# Also check BusProtocol to catch most USB sticks if they don't report as removable
|
||||||
|
is_removable = (
|
||||||
|
info.get('RemovableMedia', False) or
|
||||||
|
info.get('RemovableMediaOrExternalDevice', False) or
|
||||||
|
info.get('BusProtocol') in ['USB', 'FireWire', 'Thunderbolt']
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure it's not the internal system drive if we are using protocol as a hint
|
||||||
|
if info.get('Internal', False) and info.get('BusProtocol') not in ['USB']:
|
||||||
|
is_removable = False
|
||||||
|
|
||||||
|
if is_removable:
|
||||||
|
# Found a removable disk, now find its mounted volumes
|
||||||
|
# We look for partitions of this disk that are mounted
|
||||||
|
for d2 in all_disks:
|
||||||
|
if d2.startswith(disk + 's'):
|
||||||
|
v_res = subprocess.run(['diskutil', 'info', '-plist', d2], capture_output=True, text=True)
|
||||||
|
if v_res.returncode == 0:
|
||||||
|
v_info = plistlib.loads(v_res.stdout.encode())
|
||||||
|
mount_point = v_info.get('MountPoint')
|
||||||
|
volume_name = v_info.get('VolumeName') or v_info.get('DeviceIdentifier')
|
||||||
|
if mount_point:
|
||||||
|
self.removable_disks.append((volume_name, mount_point))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error detecting disks: {e}")
|
||||||
|
|
||||||
|
return self.removable_disks
|
||||||
|
|
||||||
|
def _render_disk_selection_page(self):
|
||||||
|
# Letterhead at top right
|
||||||
|
content_width = self.bg_canvas.winfo_width() or 975
|
||||||
|
right_margin = content_width - 48
|
||||||
|
|
||||||
|
ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne')
|
||||||
|
ui.canvas_text(self, right_margin, 85, "Deployment Destination.", fill='#6e6e73',
|
||||||
|
font=('SF Pro Text', 18), anchor='ne')
|
||||||
|
|
||||||
|
self._render_title('Select Destination', y=150)
|
||||||
|
self._render_paragraph("Choose where you would like to deploy the built Prole application and its supporting artifacts.", y=210)
|
||||||
|
|
||||||
|
# Container for the two main options
|
||||||
|
y_options = 300
|
||||||
|
x_center = content_width // 2
|
||||||
|
|
||||||
|
# We need large friendly images. I'll use placeholders if I can't find specific ones.
|
||||||
|
# But I'll try to use symbols or colors for now if images are missing.
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageTk
|
||||||
|
# Using proleIcon.png as a placeholder for both for now, but I'll add distinct styling
|
||||||
|
icon_path = PROJECT_ROOT / 'img' / 'proleIcon.png'
|
||||||
|
icon_img = Image.open(str(icon_path)).resize((128, 128), Image.LANCZOS)
|
||||||
|
self._disk_icon_tk = ImageTk.PhotoImage(icon_img)
|
||||||
|
except Exception:
|
||||||
|
self._disk_icon_tk = None
|
||||||
|
|
||||||
|
# Option 1: Removable Disk
|
||||||
|
frame_usb = tk.Frame(self.bg_canvas, bg='white', highlightthickness=1, highlightbackground='#CCCCCC', padx=20, pady=20)
|
||||||
|
usb_window = self.bg_canvas.create_window(x_center - 250, y_options, window=frame_usb, anchor='n', width=350)
|
||||||
|
self._overlay_widgets.append(frame_usb)
|
||||||
|
self._canvas_items.append(usb_window)
|
||||||
|
|
||||||
|
if self._disk_icon_tk:
|
||||||
|
lbl_img_usb = tk.Label(frame_usb, image=self._disk_icon_tk, bg='white', cursor='hand2')
|
||||||
|
lbl_img_usb.pack()
|
||||||
|
lbl_img_usb.bind("<Button-1>", lambda e: self.selected_disk_type.set('removable'))
|
||||||
|
|
||||||
|
tk.Radiobutton(frame_usb, text="USB / Flash Drive", variable=self.selected_disk_type, value='removable',
|
||||||
|
bg='white', font=('SF Pro Text', 14, 'bold')).pack(pady=10)
|
||||||
|
|
||||||
|
# Dropdown for removable disks
|
||||||
|
disk_names = [d[0] for d in self.removable_disks] or ["No removable disks detected"]
|
||||||
|
if not self.selected_removable_disk.get() and self.removable_disks:
|
||||||
|
self.selected_removable_disk.set(self.removable_disks[0][1])
|
||||||
|
|
||||||
|
self.disk_dropdown = ttk.Combobox(frame_usb, values=disk_names, state="readonly", width=30)
|
||||||
|
self.disk_dropdown.pack(pady=5)
|
||||||
|
if disk_names: self.disk_dropdown.current(0)
|
||||||
|
|
||||||
|
def on_disk_select(event):
|
||||||
|
idx = self.disk_dropdown.current()
|
||||||
|
if idx >= 0 and idx < len(self.removable_disks):
|
||||||
|
self.selected_removable_disk.set(self.removable_disks[idx][1])
|
||||||
|
self.selected_disk_type.set('removable')
|
||||||
|
|
||||||
|
self.disk_dropdown.bind("<<ComboboxSelected>>", on_disk_select)
|
||||||
|
|
||||||
|
# Option 2: Local Folder
|
||||||
|
frame_local = tk.Frame(self.bg_canvas, bg='white', highlightthickness=1, highlightbackground='#CCCCCC', padx=20, pady=20)
|
||||||
|
local_window = self.bg_canvas.create_window(x_center + 250, y_options, window=frame_local, anchor='n', width=350)
|
||||||
|
self._overlay_widgets.append(frame_local)
|
||||||
|
self._canvas_items.append(local_window)
|
||||||
|
|
||||||
|
if self._disk_icon_tk:
|
||||||
|
lbl_img_local = tk.Label(frame_local, image=self._disk_icon_tk, bg='white', cursor='hand2')
|
||||||
|
lbl_img_local.pack()
|
||||||
|
lbl_img_local.bind("<Button-1>", lambda e: self.selected_disk_type.set('local'))
|
||||||
|
|
||||||
|
tk.Radiobutton(frame_local, text="Local Filesystem", variable=self.selected_disk_type, value='local',
|
||||||
|
bg='white', font=('SF Pro Text', 14, 'bold')).pack(pady=10)
|
||||||
|
|
||||||
|
# Path input and browse
|
||||||
|
path_frame = tk.Frame(frame_local, bg='white')
|
||||||
|
path_frame.pack(fill='x', pady=5)
|
||||||
|
|
||||||
|
ent_path = tk.Entry(path_frame, textvariable=self.selected_local_path, font=('SF Pro Text', 10), width=30)
|
||||||
|
ent_path.pack(side='left', padx=(0, 5))
|
||||||
|
|
||||||
|
def browse_local():
|
||||||
|
from tkinter import filedialog
|
||||||
|
d = filedialog.askdirectory(initialdir=self.selected_local_path.get())
|
||||||
|
if d:
|
||||||
|
self.selected_local_path.set(d)
|
||||||
|
self.selected_disk_type.set('local')
|
||||||
|
|
||||||
|
btn_browse = tk.Button(path_frame, text="Browse...", command=browse_local, bg='#F5F5DC')
|
||||||
|
btn_browse.pack(side='left')
|
||||||
|
|
||||||
def _render_build_page(self):
|
def _render_build_page(self):
|
||||||
# Letterhead at top right
|
# Letterhead at top right
|
||||||
content_width = self.bg_canvas.winfo_width() or 975
|
content_width = self.bg_canvas.winfo_width() or 975
|
||||||
@ -2030,6 +2198,16 @@ class ProleInstaller:
|
|||||||
if current_id == 'build':
|
if current_id == 'build':
|
||||||
self.show_page('init_password')
|
self.show_page('init_password')
|
||||||
return
|
return
|
||||||
|
if current_id == 'disk_selection':
|
||||||
|
self.show_page('build')
|
||||||
|
return
|
||||||
|
if current_id == 'build_summary':
|
||||||
|
disks = getattr(self, 'removable_disks', [])
|
||||||
|
if disks:
|
||||||
|
self.show_page('disk_selection')
|
||||||
|
else:
|
||||||
|
self.show_page('build')
|
||||||
|
return
|
||||||
# Default prev
|
# Default prev
|
||||||
if self.page_index > 0:
|
if self.page_index > 0:
|
||||||
self.show_page(self.page_index - 1)
|
self.show_page(self.page_index - 1)
|
||||||
@ -2038,40 +2216,56 @@ class ProleInstaller:
|
|||||||
def on_next(self):
|
def on_next(self):
|
||||||
# Special handling for dynamic labels
|
# Special handling for dynamic labels
|
||||||
current_id = self.pages[self.page_index][0]
|
current_id = self.pages[self.page_index][0]
|
||||||
|
print(f"[DEBUG] on_next: current_id='{current_id}', page_index={self.page_index}")
|
||||||
|
|
||||||
if current_id == 'welcome':
|
if current_id == 'welcome':
|
||||||
self.show_page('deps_summary')
|
self.show_page('deps_summary')
|
||||||
return
|
return
|
||||||
|
|
||||||
if current_id == 'deps_summary':
|
if current_id == 'deps_summary':
|
||||||
# Determine where to go from summary
|
# Determine where to go from summary
|
||||||
# Always go to the next dependency or Network Scan
|
# Always go to the next dependency or Network Scan
|
||||||
if self.all_dependencies_installed():
|
if self.all_dependencies_installed():
|
||||||
|
print("[DEBUG] on_next: all deps installed, going to network_scan")
|
||||||
self.show_page('network_scan')
|
self.show_page('network_scan')
|
||||||
return
|
return
|
||||||
|
|
||||||
# Go to first missing dependency page
|
# Go to first missing dependency page
|
||||||
seq = self._dep_navigation_sequence()
|
seq = self._dep_navigation_sequence()
|
||||||
target = seq[0] if seq else 'network_scan'
|
print(f"[DEBUG] on_next: missing deps sequence: {seq}")
|
||||||
self.show_page(target)
|
if seq:
|
||||||
|
self.show_page(seq[0])
|
||||||
|
else:
|
||||||
|
print("[DEBUG] on_next: all deps seem OK in sequence, going to network_scan")
|
||||||
|
self.show_page('network_scan')
|
||||||
return
|
return
|
||||||
|
|
||||||
if current_id.startswith('dep_'):
|
if current_id.startswith('dep_'):
|
||||||
# Navigate within dependency sequence
|
# Navigate within dependency sequence
|
||||||
seq = self._dep_navigation_sequence()
|
seq = self._dep_navigation_sequence()
|
||||||
|
print(f"[DEBUG] on_next: dep sequence: {seq}")
|
||||||
try:
|
try:
|
||||||
i = seq.index(current_id)
|
i = seq.index(current_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
i = -1
|
i = -1
|
||||||
|
|
||||||
if i >= 0 and i < len(seq) - 1:
|
if i >= 0 and i < len(seq) - 1:
|
||||||
self.show_page(seq[i + 1])
|
self.show_page(seq[i + 1])
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
# After last relevant dep page, go back to summary if anything is still missing
|
# After last relevant dep page, go back to summary if anything is still missing
|
||||||
if not self.all_dependencies_installed():
|
if not self.all_dependencies_installed():
|
||||||
|
print("[DEBUG] on_next: some deps still missing, returning to deps_summary")
|
||||||
self.show_page('deps_summary')
|
self.show_page('deps_summary')
|
||||||
else:
|
else:
|
||||||
|
print("[DEBUG] on_next: all deps now installed, going to network_scan")
|
||||||
self.show_page('network_scan')
|
self.show_page('network_scan')
|
||||||
return
|
return
|
||||||
|
|
||||||
if current_id == 'network_scan':
|
if current_id == 'network_scan':
|
||||||
self.show_page('env_setup')
|
self.show_page('env_setup')
|
||||||
return
|
return
|
||||||
|
|
||||||
if current_id == 'env_setup':
|
if current_id == 'env_setup':
|
||||||
# Collect values, validate, write env.sh, then go to Kerberos config
|
# Collect values, validate, write env.sh, then go to Kerberos config
|
||||||
vals = {}
|
vals = {}
|
||||||
@ -2099,9 +2293,11 @@ class ProleInstaller:
|
|||||||
return
|
return
|
||||||
self.show_page('kerberos_config')
|
self.show_page('kerberos_config')
|
||||||
return
|
return
|
||||||
|
|
||||||
if current_id == 'kerberos_config':
|
if current_id == 'kerberos_config':
|
||||||
self.show_page('init_cluster')
|
self.show_page('init_cluster')
|
||||||
return
|
return
|
||||||
|
|
||||||
if current_id == 'init_cluster':
|
if current_id == 'init_cluster':
|
||||||
# Ensure docker is started, then proceed
|
# Ensure docker is started, then proceed
|
||||||
if not self.check_docker_running():
|
if not self.check_docker_running():
|
||||||
@ -2112,12 +2308,15 @@ class ProleInstaller:
|
|||||||
return
|
return
|
||||||
self.show_page('init_db_build')
|
self.show_page('init_db_build')
|
||||||
return
|
return
|
||||||
|
|
||||||
if current_id == 'init_db_build':
|
if current_id == 'init_db_build':
|
||||||
self.show_page('init_cnpg_deploy')
|
self.show_page('init_cnpg_deploy')
|
||||||
return
|
return
|
||||||
|
|
||||||
if current_id == 'init_cnpg_deploy':
|
if current_id == 'init_cnpg_deploy':
|
||||||
self.show_page('init_password')
|
self.show_page('init_password')
|
||||||
return
|
return
|
||||||
|
|
||||||
if current_id == 'init_password':
|
if current_id == 'init_password':
|
||||||
# Validate passwords match and are not empty
|
# Validate passwords match and are not empty
|
||||||
p1 = self.db_password.get()
|
p1 = self.db_password.get()
|
||||||
@ -2130,39 +2329,41 @@ class ProleInstaller:
|
|||||||
return
|
return
|
||||||
self.show_page('build')
|
self.show_page('build')
|
||||||
return
|
return
|
||||||
|
|
||||||
if current_id == 'build':
|
if current_id == 'build':
|
||||||
# Build page button: Deploy on success (and then show summary), otherwise (re)run build
|
# Build page button: 'Build' if not yet built or failed, 'Next' on success
|
||||||
if getattr(self, '_built_success', False):
|
if getattr(self, '_built_success', False):
|
||||||
try:
|
# Detect removable media
|
||||||
self.open_drag_install_window()
|
disks = self.get_removable_disks()
|
||||||
except Exception:
|
if disks:
|
||||||
pass
|
self.show_page('disk_selection')
|
||||||
# Always navigate to Build Summary per spec
|
else:
|
||||||
self.show_page('build_summary')
|
try:
|
||||||
|
self.open_drag_install_window()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Navigate to Build Summary (Next)
|
||||||
|
self.show_page('build_summary')
|
||||||
return
|
return
|
||||||
# Start or rerun build; Build Summary opens automatically on completion
|
# Start or rerun build
|
||||||
self.perform_build()
|
self.perform_build()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if current_id == 'disk_selection':
|
||||||
|
try:
|
||||||
|
self.open_drag_install_window()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.show_page('build_summary')
|
||||||
|
return
|
||||||
|
|
||||||
# Default next
|
# Default next
|
||||||
if self.page_index < len(self.pages) - 1:
|
if self.page_index < len(self.pages) - 1:
|
||||||
|
print(f"[DEBUG] on_next: default next to index {self.page_index + 1}")
|
||||||
self.show_page(self.page_index + 1)
|
self.show_page(self.page_index + 1)
|
||||||
|
else:
|
||||||
|
print("[DEBUG] on_next: already at last page")
|
||||||
return
|
return
|
||||||
if current_id == 'build':
|
|
||||||
# Build page button: Deploy on success (and then show summary), otherwise (re)run build
|
|
||||||
if getattr(self, '_built_success', False):
|
|
||||||
try:
|
|
||||||
self.open_drag_install_window()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
# Always navigate to Build Summary per spec
|
|
||||||
self.show_page('build_summary')
|
|
||||||
return
|
|
||||||
# Start or rerun build; Build Summary opens automatically on completion
|
|
||||||
self.perform_build()
|
|
||||||
return
|
|
||||||
# No longer using external Deploy page
|
|
||||||
if self.page_index < len(self.pages) - 1:
|
|
||||||
self.show_page(self.page_index + 1)
|
|
||||||
|
|
||||||
def on_finish(self):
|
def on_finish(self):
|
||||||
# Close app on Finish
|
# Close app on Finish
|
||||||
@ -2187,12 +2388,9 @@ class ProleInstaller:
|
|||||||
# Dependencies screens use Prev/Next wording
|
# Dependencies screens use Prev/Next wording
|
||||||
self.next_button.configure(text='Next')
|
self.next_button.configure(text='Next')
|
||||||
if pid == 'build':
|
if pid == 'build':
|
||||||
# Build page: show Build/Deploy/Next depending on state
|
# Build page: show Build or Next depending on state
|
||||||
if getattr(self, '_built_success', False):
|
if getattr(self, '_built_success', False):
|
||||||
self.next_button.configure(text='Deploy')
|
self.next_button.configure(text='Next')
|
||||||
elif getattr(self, '_build_attempted', False):
|
|
||||||
# Attempted and failed → keep as Build (no Next on Build page)
|
|
||||||
self.next_button.configure(text='Build')
|
|
||||||
else:
|
else:
|
||||||
self.next_button.configure(text='Build')
|
self.next_button.configure(text='Build')
|
||||||
|
|
||||||
@ -2204,14 +2402,9 @@ class ProleInstaller:
|
|||||||
if first:
|
if first:
|
||||||
self.next_button.pack(side='right', padx=(0, 20), pady=12)
|
self.next_button.pack(side='right', padx=(0, 20), pady=12)
|
||||||
elif last:
|
elif last:
|
||||||
if pid == 'build':
|
# [Prev] [Finish] clustered right
|
||||||
# [Prev] [Next] clustered right
|
self.finish_button.pack(side='right', padx=(0, 20), pady=12)
|
||||||
self.next_button.pack(side='right', padx=(0, 20), pady=12)
|
self.prev_button.pack(side='right', padx=(0, 8), pady=12)
|
||||||
self.prev_button.pack(side='right', padx=(0, 8), pady=12)
|
|
||||||
else:
|
|
||||||
# [Prev] [Finish] clustered right
|
|
||||||
self.finish_button.pack(side='right', padx=(0, 20), pady=12)
|
|
||||||
self.prev_button.pack(side='right', padx=(0, 8), pady=12)
|
|
||||||
else:
|
else:
|
||||||
# [Prev] [Next] clustered right
|
# [Prev] [Next] clustered right
|
||||||
self.next_button.pack(side='right', padx=(0, 20), pady=12)
|
self.next_button.pack(side='right', padx=(0, 20), pady=12)
|
||||||
@ -2228,7 +2421,7 @@ class ProleInstaller:
|
|||||||
'Thanks for joining Prole. We will prepare your system and install the software needed to build and run Prole.'
|
'Thanks for joining Prole. We will prepare your system and install the software needed to build and run Prole.'
|
||||||
)
|
)
|
||||||
ttk.Label(f, text=msg, style='Body.TLabel', wraplength=800, justify='left').pack(anchor='w', padx=24)
|
ttk.Label(f, text=msg, style='Body.TLabel', wraplength=800, justify='left').pack(anchor='w', padx=24)
|
||||||
self._register_page('welcome', f)
|
# self._register_page('welcome', f)
|
||||||
|
|
||||||
def _create_page_dependencies_summary(self):
|
def _create_page_dependencies_summary(self):
|
||||||
f = self._page_container()
|
f = self._page_container()
|
||||||
@ -2257,7 +2450,7 @@ class ProleInstaller:
|
|||||||
# Now we can safely populate the UI. Start async scan to avoid startup delay
|
# Now we can safely populate the UI. Start async scan to avoid startup delay
|
||||||
self.deps_msg.configure(text='Checking dependencies...')
|
self.deps_msg.configure(text='Checking dependencies...')
|
||||||
self.start_dependency_scan()
|
self.start_dependency_scan()
|
||||||
self._register_page('deps_summary', f)
|
# self._register_page('deps_summary', f)
|
||||||
|
|
||||||
def start_dependency_scan(self):
|
def start_dependency_scan(self):
|
||||||
"""Scan dependencies in a background thread to avoid blocking UI startup."""
|
"""Scan dependencies in a background thread to avoid blocking UI startup."""
|
||||||
@ -2421,7 +2614,7 @@ class ProleInstaller:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
self._register_page(f'dep_{dep["id"]}', f)
|
# self._register_page(f'dep_{dep["id"]}', f)
|
||||||
|
|
||||||
def _dep_navigation_sequence(self, force_all: bool = False):
|
def _dep_navigation_sequence(self, force_all: bool = False):
|
||||||
"""Return a list of dependency page ids to traverse next.
|
"""Return a list of dependency page ids to traverse next.
|
||||||
@ -2429,12 +2622,15 @@ class ProleInstaller:
|
|||||||
- Else: include only missing dependency pages based on current checks.
|
- Else: include only missing dependency pages based on current checks.
|
||||||
"""
|
"""
|
||||||
if force_all or self.verify_mode.get():
|
if force_all or self.verify_mode.get():
|
||||||
return [f'dep_{d["id"]}' for d in self.dependencies]
|
res = [f'dep_{d["id"]}' for d in self.dependencies]
|
||||||
|
print(f"[DEBUG] _dep_navigation_sequence (force_all={force_all}): {res}")
|
||||||
|
return res
|
||||||
seq = []
|
seq = []
|
||||||
for d in self.dependencies:
|
for d in self.dependencies:
|
||||||
ok, _, _ = self.get_dep_info(d)
|
ok, _, _ = self.get_dep_info(d)
|
||||||
if not ok:
|
if not ok:
|
||||||
seq.append(f'dep_{d["id"]}')
|
seq.append(f'dep_{d["id"]}')
|
||||||
|
print(f"[DEBUG] _dep_navigation_sequence: {seq}")
|
||||||
return seq
|
return seq
|
||||||
|
|
||||||
def _create_page_build(self):
|
def _create_page_build(self):
|
||||||
@ -2451,7 +2647,7 @@ class ProleInstaller:
|
|||||||
self.build_output = scrolledtext.ScrolledText(f, height=12, bg='#fafafa', fg='#1d1d1f')
|
self.build_output = scrolledtext.ScrolledText(f, height=12, bg='#fafafa', fg='#1d1d1f')
|
||||||
self.build_output.pack(fill='both', expand=True, padx=24, pady=12)
|
self.build_output.pack(fill='both', expand=True, padx=24, pady=12)
|
||||||
|
|
||||||
self._register_page('build', f)
|
# self._register_page('build', f)
|
||||||
|
|
||||||
def _create_page_deploy(self):
|
def _create_page_deploy(self):
|
||||||
f = self._page_container()
|
f = self._page_container()
|
||||||
@ -2476,7 +2672,7 @@ class ProleInstaller:
|
|||||||
for step in self.deploy_steps:
|
for step in self.deploy_steps:
|
||||||
self._create_deploy_row(container, step)
|
self._create_deploy_row(container, step)
|
||||||
|
|
||||||
self._register_page('deploy', f)
|
# self._register_page('deploy', f)
|
||||||
|
|
||||||
def _create_deploy_row(self, parent, step):
|
def _create_deploy_row(self, parent, step):
|
||||||
row = ttk.Frame(parent)
|
row = ttk.Frame(parent)
|
||||||
@ -2514,7 +2710,9 @@ class ProleInstaller:
|
|||||||
for dep in self.dependencies:
|
for dep in self.dependencies:
|
||||||
ok, _, _ = self.get_dep_info(dep)
|
ok, _, _ = self.get_dep_info(dep)
|
||||||
if not ok:
|
if not ok:
|
||||||
|
print(f"[DEBUG] all_dependencies_installed: '{dep['id']}' is MISSING")
|
||||||
return False
|
return False
|
||||||
|
print("[DEBUG] all_dependencies_installed: YES (all OK)")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def get_dep_info(self, dep):
|
def get_dep_info(self, dep):
|
||||||
|
|||||||
123
tests/test_navigation.py
Normal file
123
tests/test_navigation.py
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import sys
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
import pytest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Mock tkinter and other GUI/macOS specific imports
|
||||||
|
sys.modules['tkinter'] = MagicMock()
|
||||||
|
sys.modules['tkinter.ttk'] = MagicMock()
|
||||||
|
sys.modules['tkinter.scrolledtext'] = MagicMock()
|
||||||
|
sys.modules['tkinter.messagebox'] = MagicMock()
|
||||||
|
sys.modules['Foundation'] = MagicMock()
|
||||||
|
sys.modules['AppKit'] = MagicMock()
|
||||||
|
sys.modules['PIL'] = MagicMock()
|
||||||
|
sys.modules['PIL.Image'] = MagicMock()
|
||||||
|
sys.modules['PIL.ImageTk'] = MagicMock()
|
||||||
|
|
||||||
|
import install
|
||||||
|
from install import ProleInstaller
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_installer():
|
||||||
|
with patch('install.tk.Tk'), \
|
||||||
|
patch('install.tk.Frame'), \
|
||||||
|
patch('install.tk.Canvas'), \
|
||||||
|
patch('install.tk.Label'), \
|
||||||
|
patch('install.tk.Button'), \
|
||||||
|
patch('install.ttk.Style'), \
|
||||||
|
patch('install.Path.exists', return_value=True):
|
||||||
|
|
||||||
|
root = MagicMock()
|
||||||
|
# Mock winfo methods needed for centering
|
||||||
|
root.winfo_screenwidth.return_value = 1920
|
||||||
|
root.winfo_screenheight.return_value = 1080
|
||||||
|
|
||||||
|
app = ProleInstaller(root)
|
||||||
|
return app
|
||||||
|
|
||||||
|
def test_initial_page(mock_installer):
|
||||||
|
# Should start at index 0 (welcome)
|
||||||
|
assert mock_installer.page_index == 0
|
||||||
|
assert mock_installer.pages[mock_installer.page_index][0] == 'welcome'
|
||||||
|
|
||||||
|
def test_show_page_by_id(mock_installer):
|
||||||
|
mock_installer.show_page('network_scan')
|
||||||
|
assert mock_installer.pages[mock_installer.page_index][0] == 'network_scan'
|
||||||
|
|
||||||
|
def test_show_page_invalid_id(mock_installer):
|
||||||
|
# Current page is welcome (0)
|
||||||
|
mock_installer.page_index = 0
|
||||||
|
|
||||||
|
with patch('install.messagebox.showerror') as mock_error:
|
||||||
|
mock_installer.show_page('non_existent_page')
|
||||||
|
# Should stay at current index and NOT fallback to 0 if it was elsewhere,
|
||||||
|
# or just stay at 0 if it was at 0.
|
||||||
|
# The key fix was NOT defaulting to 0 when index_or_id is a string and not found.
|
||||||
|
assert mock_installer.page_index == 0
|
||||||
|
mock_error.assert_called_once()
|
||||||
|
|
||||||
|
def test_navigation_flow_standard(mock_installer):
|
||||||
|
# Welcome -> Dependencies
|
||||||
|
mock_installer.on_next()
|
||||||
|
assert mock_installer.pages[mock_installer.page_index][0] == 'deps_summary'
|
||||||
|
|
||||||
|
# Mock all dependencies installed to skip dep pages
|
||||||
|
with patch.object(mock_installer, 'all_dependencies_installed', return_value=True):
|
||||||
|
mock_installer.on_next()
|
||||||
|
assert mock_installer.pages[mock_installer.page_index][0] == 'network_scan'
|
||||||
|
|
||||||
|
mock_installer.on_next()
|
||||||
|
assert mock_installer.pages[mock_installer.page_index][0] == 'env_setup'
|
||||||
|
|
||||||
|
def test_navigation_flow_missing_deps(mock_installer):
|
||||||
|
# Welcome -> Dependencies
|
||||||
|
mock_installer.show_page('welcome')
|
||||||
|
mock_installer.on_next()
|
||||||
|
assert mock_installer.pages[mock_installer.page_index][0] == 'deps_summary'
|
||||||
|
|
||||||
|
# Mock one dependency missing
|
||||||
|
mock_dep_id = mock_installer.dependencies[0]['id']
|
||||||
|
with patch.object(mock_installer, 'all_dependencies_installed', return_value=False), \
|
||||||
|
patch.object(mock_installer, '_dep_navigation_sequence', return_value=[f'dep_{mock_dep_id}']):
|
||||||
|
|
||||||
|
mock_installer.on_next()
|
||||||
|
assert mock_installer.pages[mock_installer.page_index][0] == f'dep_{mock_dep_id}'
|
||||||
|
|
||||||
|
# Next from dep page with all installed now
|
||||||
|
with patch.object(mock_installer, 'all_dependencies_installed', return_value=True):
|
||||||
|
mock_installer.on_next()
|
||||||
|
assert mock_installer.pages[mock_installer.page_index][0] == 'network_scan'
|
||||||
|
|
||||||
|
def test_on_prev(mock_installer):
|
||||||
|
# network_scan -> deps_summary
|
||||||
|
mock_installer.show_page('network_scan')
|
||||||
|
with patch.object(mock_installer, 'all_dependencies_installed', return_value=True):
|
||||||
|
mock_installer.on_prev()
|
||||||
|
assert mock_installer.pages[mock_installer.page_index][0] == 'deps_summary'
|
||||||
|
|
||||||
|
# deps_summary -> welcome
|
||||||
|
mock_installer.on_prev()
|
||||||
|
assert mock_installer.pages[mock_installer.page_index][0] == 'welcome'
|
||||||
|
|
||||||
|
def test_infinite_loop_prevention(mock_installer):
|
||||||
|
# This specifically tests the scenario that caused the bug:
|
||||||
|
# trying to navigate to a missing ID should NOT reset to welcome (0)
|
||||||
|
mock_installer.show_page('network_scan')
|
||||||
|
current_idx = mock_installer.page_index
|
||||||
|
|
||||||
|
mock_installer.show_page('invalid_id')
|
||||||
|
assert mock_installer.page_index == current_idx
|
||||||
|
assert mock_installer.pages[mock_installer.page_index][0] == 'network_scan'
|
||||||
|
|
||||||
|
def test_on_next_finish_loop(mock_installer):
|
||||||
|
# From last page, on_next should not crash or loop
|
||||||
|
last_idx = len(mock_installer.pages) - 1
|
||||||
|
mock_installer.show_page(last_idx)
|
||||||
|
mock_installer.on_next()
|
||||||
|
assert mock_installer.page_index == last_idx
|
||||||
|
|
||||||
|
def test_on_prev_first_page(mock_installer):
|
||||||
|
# From first page, on_prev should not crash or loop
|
||||||
|
mock_installer.show_page(0)
|
||||||
|
mock_installer.on_prev()
|
||||||
|
assert mock_installer.page_index == 0
|
||||||
Loading…
Reference in New Issue
Block a user