feat: Enhance dose history parsing and add unit tests for improved functionality
Build and Push Docker Image / build-and-push (push) Has been cancelled
Build and Push Docker Image / build-and-push (push) Has been cancelled
This commit is contained in:
+176
-43
@@ -822,13 +822,17 @@ class UIManager:
|
|||||||
height=4, # Reduced height to fit better in scrollable window
|
height=4, # Reduced height to fit better in scrollable window
|
||||||
wrap=tk.WORD,
|
wrap=tk.WORD,
|
||||||
font=("Consolas", 10),
|
font=("Consolas", 10),
|
||||||
state="normal",
|
state="normal", # Start enabled
|
||||||
)
|
)
|
||||||
dose_text.grid(row=0, column=0, sticky="ew")
|
dose_text.grid(row=0, column=0, sticky="ew")
|
||||||
|
|
||||||
# Populate with existing doses
|
# Store raw dose string in a variable
|
||||||
doses_str = dose_data.get(med_key, "")
|
doses_str = dose_data.get(med_key, "")
|
||||||
self._populate_dose_history(dose_text, doses_str)
|
dose_str_var = tk.StringVar(value=doses_str)
|
||||||
|
vars_dict[f"{med_key}_doses_str"] = dose_str_var
|
||||||
|
|
||||||
|
# Populate with existing doses
|
||||||
|
self._populate_dose_history(dose_text, dose_str_var.get())
|
||||||
|
|
||||||
vars_dict[f"{med_key}_doses_text"] = dose_text
|
vars_dict[f"{med_key}_doses_text"] = dose_text
|
||||||
|
|
||||||
@@ -854,11 +858,12 @@ class UIManager:
|
|||||||
|
|
||||||
def _populate_dose_history(self, text_widget: tk.Text, doses_str: str) -> None:
|
def _populate_dose_history(self, text_widget: tk.Text, doses_str: str) -> None:
|
||||||
"""Populate dose history text widget with formatted dose data."""
|
"""Populate dose history text widget with formatted dose data."""
|
||||||
|
text_widget.configure(state="normal")
|
||||||
text_widget.delete(1.0, tk.END)
|
text_widget.delete(1.0, tk.END)
|
||||||
|
|
||||||
if not doses_str or str(doses_str) == "nan":
|
if not doses_str or str(doses_str) == "nan":
|
||||||
text_widget.insert(1.0, "No doses recorded today")
|
text_widget.insert(1.0, "No doses recorded today")
|
||||||
text_widget.configure(state="disabled")
|
# Keep text widget enabled for editing
|
||||||
return
|
return
|
||||||
|
|
||||||
doses_str = str(doses_str)
|
doses_str = str(doses_str)
|
||||||
@@ -872,6 +877,7 @@ class UIManager:
|
|||||||
time_str = dt.strftime("%I:%M %p")
|
time_str = dt.strftime("%I:%M %p")
|
||||||
formatted_doses.append(f"• {time_str} - {dose}")
|
formatted_doses.append(f"• {time_str} - {dose}")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
# Handle cases where the timestamp might be malformed
|
||||||
formatted_doses.append(f"• {dose_entry}")
|
formatted_doses.append(f"• {dose_entry}")
|
||||||
|
|
||||||
if formatted_doses:
|
if formatted_doses:
|
||||||
@@ -879,6 +885,8 @@ class UIManager:
|
|||||||
else:
|
else:
|
||||||
text_widget.insert(1.0, "No doses recorded today")
|
text_widget.insert(1.0, "No doses recorded today")
|
||||||
|
|
||||||
|
# Always keep text widget enabled for user editing
|
||||||
|
|
||||||
def _take_dose_improved(
|
def _take_dose_improved(
|
||||||
self,
|
self,
|
||||||
med_name: str,
|
med_name: str,
|
||||||
@@ -886,36 +894,66 @@ class UIManager:
|
|||||||
med_key: str,
|
med_key: str,
|
||||||
vars_dict: dict[str, Any],
|
vars_dict: dict[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Handle taking a dose with improved feedback."""
|
"""Handle taking a dose with improved feedback and state management."""
|
||||||
dose = entry_var.get().strip()
|
dose = entry_var.get().strip()
|
||||||
|
|
||||||
if not dose:
|
# Get the dose text widget - this is what the save function reads from
|
||||||
messagebox.showerror("Error", f"Please enter a dose amount for {med_name}")
|
dose_text_widget = vars_dict.get(f"{med_key}_doses_text")
|
||||||
|
if not dose_text_widget:
|
||||||
|
self.logger.error(f"Dose text widget not found for {med_key}")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get current time
|
# Find the parent edit window
|
||||||
|
parent_window = dose_text_widget.winfo_toplevel()
|
||||||
|
|
||||||
|
if not dose:
|
||||||
|
messagebox.showerror(
|
||||||
|
"Error",
|
||||||
|
f"Please enter a dose amount for {med_name}",
|
||||||
|
parent=parent_window,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get current time and timestamp
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
time_str = now.strftime("%I:%M %p")
|
time_str = now.strftime("%I:%M %p")
|
||||||
|
|
||||||
# Update dose history
|
# Ensure text widget is enabled
|
||||||
dose_text_widget = vars_dict.get(f"{med_key}_doses_text")
|
dose_text_widget.configure(state="normal")
|
||||||
if dose_text_widget:
|
|
||||||
current_content = dose_text_widget.get(1.0, tk.END).strip()
|
|
||||||
|
|
||||||
new_dose_line = f"• {time_str} - {dose}"
|
# Get current content from the text widget
|
||||||
|
current_content = dose_text_widget.get(1.0, tk.END).strip()
|
||||||
|
self.logger.debug(f"Current content before adding dose: '{current_content}'")
|
||||||
|
|
||||||
if current_content == "No doses recorded today" or not current_content:
|
# Create new dose entry in the display format
|
||||||
dose_text_widget.delete(1.0, tk.END)
|
new_dose_line = f"• {time_str} - {dose}"
|
||||||
dose_text_widget.insert(1.0, new_dose_line)
|
self.logger.debug(f"New dose line: '{new_dose_line}'")
|
||||||
else:
|
|
||||||
dose_text_widget.insert(tk.END, f"\n{new_dose_line}")
|
|
||||||
|
|
||||||
# Clear entry
|
# Add the new dose to the text widget
|
||||||
|
if current_content == "No doses recorded today" or not current_content:
|
||||||
|
dose_text_widget.delete(1.0, tk.END)
|
||||||
|
dose_text_widget.insert(1.0, new_dose_line)
|
||||||
|
self.logger.debug("Added first dose")
|
||||||
|
else:
|
||||||
|
# Append to existing content with proper formatting
|
||||||
|
updated_content = current_content + f"\n{new_dose_line}"
|
||||||
|
self.logger.debug(f"Updated content: '{updated_content}'")
|
||||||
|
dose_text_widget.delete(1.0, tk.END)
|
||||||
|
dose_text_widget.insert(1.0, updated_content)
|
||||||
|
self.logger.debug("Added subsequent dose")
|
||||||
|
|
||||||
|
# Verify what's actually in the widget after insertion
|
||||||
|
final_content = dose_text_widget.get(1.0, tk.END).strip()
|
||||||
|
self.logger.debug(f"Final content in widget: '{final_content}'")
|
||||||
|
|
||||||
|
# Clear entry field
|
||||||
entry_var.set("")
|
entry_var.set("")
|
||||||
|
|
||||||
# Success feedback
|
# Success feedback
|
||||||
messagebox.showinfo(
|
messagebox.showinfo(
|
||||||
"Dose Recorded", f"{med_name} dose of {dose} recorded at {time_str}"
|
"Dose Recorded",
|
||||||
|
f"{med_name} dose of {dose} recorded at {time_str}",
|
||||||
|
parent=parent_window,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _add_improved_edit_buttons(
|
def _add_improved_edit_buttons(
|
||||||
@@ -932,13 +970,15 @@ class UIManager:
|
|||||||
|
|
||||||
# Save button
|
# Save button
|
||||||
def save_with_improved_data():
|
def save_with_improved_data():
|
||||||
|
self.logger.debug("=== SAVE FUNCTION CALLED ===")
|
||||||
|
|
||||||
# Get note text from Text widget
|
# Get note text from Text widget
|
||||||
note_text_widget = vars_dict.get("note_text")
|
note_text_widget = vars_dict.get("note_text")
|
||||||
note_content = ""
|
note_content = ""
|
||||||
if note_text_widget:
|
if note_text_widget:
|
||||||
note_content = note_text_widget.get(1.0, tk.END).strip()
|
note_content = note_text_widget.get(1.0, tk.END).strip()
|
||||||
|
|
||||||
# Extract dose data
|
# Extract dose data from the editable text widgets
|
||||||
dose_data = {}
|
dose_data = {}
|
||||||
medicine_list = [
|
medicine_list = [
|
||||||
"bupropion",
|
"bupropion",
|
||||||
@@ -949,15 +989,25 @@ class UIManager:
|
|||||||
]
|
]
|
||||||
for medicine in medicine_list:
|
for medicine in medicine_list:
|
||||||
dose_text_key = f"{medicine}_doses_text"
|
dose_text_key = f"{medicine}_doses_text"
|
||||||
if dose_text_key in vars_dict:
|
self.logger.debug(f"Processing {medicine}...")
|
||||||
dose_text_widget = vars_dict[dose_text_key]
|
|
||||||
raw_text = dose_text_widget.get(1.0, tk.END).strip()
|
if dose_text_key in vars_dict and isinstance(
|
||||||
dose_data[medicine] = self._parse_improved_dose_text(
|
vars_dict[dose_text_key], tk.Text
|
||||||
|
):
|
||||||
|
raw_text = vars_dict[dose_text_key].get(1.0, tk.END).strip()
|
||||||
|
self.logger.debug(f"Raw text for {medicine}: '{raw_text}'")
|
||||||
|
|
||||||
|
parsed_dose = self._parse_dose_history_for_saving(
|
||||||
raw_text, vars_dict["date"].get()
|
raw_text, vars_dict["date"].get()
|
||||||
)
|
)
|
||||||
|
dose_data[medicine] = parsed_dose
|
||||||
|
self.logger.debug(f"Parsed dose for {medicine}: '{parsed_dose}'")
|
||||||
else:
|
else:
|
||||||
|
self.logger.debug(f"No text widget found for {medicine}")
|
||||||
dose_data[medicine] = ""
|
dose_data[medicine] = ""
|
||||||
|
|
||||||
|
self.logger.debug(f"Final dose_data: {dose_data}")
|
||||||
|
|
||||||
callbacks["save"](
|
callbacks["save"](
|
||||||
edit_win,
|
edit_win,
|
||||||
vars_dict["date"].get(),
|
vars_dict["date"].get(),
|
||||||
@@ -997,42 +1047,105 @@ class UIManager:
|
|||||||
)
|
)
|
||||||
delete_btn.grid(row=0, column=2, sticky="ew", padx=(5, 0))
|
delete_btn.grid(row=0, column=2, sticky="ew", padx=(5, 0))
|
||||||
|
|
||||||
def _parse_improved_dose_text(self, text: str, date: str) -> str:
|
def _parse_dose_history_for_saving(self, text: str, date_str: str) -> str:
|
||||||
"""Parse improved dose text format back to CSV format."""
|
"""
|
||||||
|
Parse the user-edited dose history back into the storable format,
|
||||||
|
supporting add/delete/edit.
|
||||||
|
"""
|
||||||
|
self.logger.debug("=== PARSING DOSE HISTORY ===")
|
||||||
|
self.logger.debug(f"Input text: '{text}'")
|
||||||
|
self.logger.debug(f"Date string: '{date_str}'")
|
||||||
|
|
||||||
if not text or "No doses recorded" in text:
|
if not text or "No doses recorded" in text:
|
||||||
|
self.logger.debug("No doses to parse, returning empty string")
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
lines = text.strip().split("\n")
|
lines = text.strip().split("\n")
|
||||||
|
self.logger.debug(f"Split into {len(lines)} lines: {lines}")
|
||||||
dose_entries = []
|
dose_entries = []
|
||||||
|
|
||||||
for line in lines:
|
for line_num, line in enumerate(lines):
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
|
self.logger.debug(f"Processing line {line_num}: '{line}'")
|
||||||
|
if not line or line.lower().startswith("no doses recorded"):
|
||||||
|
self.logger.debug("Empty or placeholder line, skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Handle bullet point format: "• HH:MM AM/PM - dose"
|
||||||
if line.startswith("•") and " - " in line:
|
if line.startswith("•") and " - " in line:
|
||||||
try:
|
try:
|
||||||
# Remove bullet point and split
|
content = line.lstrip("• ").strip()
|
||||||
content = line[1:].strip() # Remove •
|
self.logger.debug(f"Bullet point content: '{content}'")
|
||||||
time_part, dose_part = content.split(" - ", 1)
|
time_part, dose_part = content.split(" - ", 1)
|
||||||
|
self.logger.debug(
|
||||||
|
f"Time part: '{time_part}', Dose part: '{dose_part}'"
|
||||||
|
)
|
||||||
|
|
||||||
# Parse time (could be 12-hour format)
|
# Try parsing as 12-hour (with AM/PM)
|
||||||
try:
|
try:
|
||||||
time_obj = datetime.strptime(time_part.strip(), "%I:%M %p")
|
time_obj = datetime.strptime(time_part.strip(), "%I:%M %p")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# Try 24-hour format
|
# Try 24-hour format fallback
|
||||||
time_obj = datetime.strptime(time_part.strip(), "%H:%M")
|
time_obj = datetime.strptime(time_part.strip(), "%H:%M")
|
||||||
|
|
||||||
# Create full timestamp
|
entry_date = datetime.strptime(date_str, "%m/%d/%Y")
|
||||||
today = datetime.strptime(date, "%m/%d/%Y")
|
full_timestamp = entry_date.replace(
|
||||||
full_timestamp = today.replace(
|
hour=time_obj.hour,
|
||||||
hour=time_obj.hour, minute=time_obj.minute, second=0
|
minute=time_obj.minute,
|
||||||
|
second=0,
|
||||||
|
microsecond=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
timestamp_str = full_timestamp.strftime("%Y-%m-%d %H:%M:%S")
|
timestamp_str = full_timestamp.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
dose_entries.append(f"{timestamp_str}:{dose_part.strip()}")
|
dose_entry = f"{timestamp_str}:{dose_part.strip()}"
|
||||||
|
dose_entries.append(dose_entry)
|
||||||
except (ValueError, IndexError):
|
self.logger.debug(f"Added dose entry: '{dose_entry}'")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(
|
||||||
|
f"Could not parse dose line: '{line}'. Error: {e}"
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return "|".join(dose_entries)
|
# Handle simple format: "HH:MM dose" or "HH:MM: dose"
|
||||||
|
elif ":" in line and not line.startswith("•"):
|
||||||
|
try:
|
||||||
|
# Try to parse as "HH:MM dose" or "HH:MM: dose"
|
||||||
|
if " " in line:
|
||||||
|
time_part, dose_part = line.split(" ", 1)
|
||||||
|
time_part = time_part.rstrip(":")
|
||||||
|
# Try 24-hour format first
|
||||||
|
try:
|
||||||
|
time_obj = datetime.strptime(time_part, "%H:%M")
|
||||||
|
except ValueError:
|
||||||
|
# Try 12-hour format
|
||||||
|
time_obj = datetime.strptime(time_part, "%I:%M")
|
||||||
|
entry_date = datetime.strptime(date_str, "%m/%d/%Y")
|
||||||
|
full_timestamp = entry_date.replace(
|
||||||
|
hour=time_obj.hour,
|
||||||
|
minute=time_obj.minute,
|
||||||
|
second=0,
|
||||||
|
microsecond=0,
|
||||||
|
)
|
||||||
|
timestamp_str = full_timestamp.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
dose_entries.append(f"{timestamp_str}:{dose_part.strip()}")
|
||||||
|
self.logger.debug(
|
||||||
|
"Added simple dose entry: '%s:%s'",
|
||||||
|
timestamp_str,
|
||||||
|
dose_part.strip(),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(
|
||||||
|
f"Could not parse simple dose line: '{line}'. Error: {e}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# If user just types a dose (no time), store as-is with no timestamp
|
||||||
|
elif line:
|
||||||
|
self.logger.debug(f"Line with no time, storing as-is: '{line}'")
|
||||||
|
dose_entries.append(line)
|
||||||
|
|
||||||
|
result = "|".join(dose_entries)
|
||||||
|
self.logger.debug(f"Final parsed result: '{result}'")
|
||||||
|
return result
|
||||||
|
|
||||||
def _bind_mousewheel_to_widget_tree(
|
def _bind_mousewheel_to_widget_tree(
|
||||||
self, widget: tk.Widget, canvas: tk.Canvas
|
self, widget: tk.Widget, canvas: tk.Canvas
|
||||||
@@ -1218,6 +1331,7 @@ class UIManager:
|
|||||||
|
|
||||||
# Save button - create a custom callback to handle dose data
|
# Save button - create a custom callback to handle dose data
|
||||||
def save_with_doses():
|
def save_with_doses():
|
||||||
|
self.logger.debug("save_with_doses called")
|
||||||
# Extract dose data from the text widgets
|
# Extract dose data from the text widgets
|
||||||
dose_data = {}
|
dose_data = {}
|
||||||
|
|
||||||
@@ -1229,15 +1343,24 @@ class UIManager:
|
|||||||
"quetiapine",
|
"quetiapine",
|
||||||
]:
|
]:
|
||||||
dose_text_key = f"{medicine}_doses_text"
|
dose_text_key = f"{medicine}_doses_text"
|
||||||
|
self.logger.debug(f"Looking for key: {dose_text_key}")
|
||||||
|
|
||||||
if dose_text_key in vars_dict and isinstance(
|
if dose_text_key in vars_dict and isinstance(
|
||||||
vars_dict[dose_text_key], tk.Text
|
vars_dict[dose_text_key], tk.Text
|
||||||
):
|
):
|
||||||
raw_text = vars_dict[dose_text_key].get(1.0, tk.END).strip()
|
raw_text = vars_dict[dose_text_key].get(1.0, tk.END).strip()
|
||||||
dose_data[medicine] = self._parse_dose_text(
|
self.logger.debug(f"Raw text for {medicine}: '{raw_text}'")
|
||||||
|
dose_data[medicine] = self._parse_dose_history_for_saving(
|
||||||
raw_text, vars_dict["date"].get()
|
raw_text, vars_dict["date"].get()
|
||||||
)
|
)
|
||||||
|
self.logger.debug(
|
||||||
|
f"Parsed dose data for {medicine}: '{dose_data[medicine]}'"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
|
self.logger.debug(
|
||||||
|
f"Key {dose_text_key} not found in vars_dict or not a Text "
|
||||||
|
"widget"
|
||||||
|
)
|
||||||
dose_data[medicine] = ""
|
dose_data[medicine] = ""
|
||||||
|
|
||||||
callbacks["save"](
|
callbacks["save"](
|
||||||
@@ -1453,7 +1576,14 @@ class UIManager:
|
|||||||
|
|
||||||
def _parse_dose_text(self, text: str, date: str) -> str:
|
def _parse_dose_text(self, text: str, date: str) -> str:
|
||||||
"""Parse dose text from edit window back to CSV format."""
|
"""Parse dose text from edit window back to CSV format."""
|
||||||
|
self.logger.debug(
|
||||||
|
f"_parse_dose_text called with text: '{text}' and date: '{date}'"
|
||||||
|
)
|
||||||
|
|
||||||
if not text or text == "No doses recorded":
|
if not text or text == "No doses recorded":
|
||||||
|
self.logger.debug(
|
||||||
|
"Text is empty or 'No doses recorded', returning empty string"
|
||||||
|
)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
lines = text.strip().split("\n")
|
lines = text.strip().split("\n")
|
||||||
@@ -1499,6 +1629,9 @@ class UIManager:
|
|||||||
dose_entries.append(f"{timestamp_str}:{dose_part}")
|
dose_entries.append(f"{timestamp_str}:{dose_part}")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# If parsing fails, skip this line
|
# If parsing fails, skip this line
|
||||||
|
self.logger.debug(f"Failed to parse line: '{line}'")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return "|".join(dose_entries)
|
result = "|".join(dose_entries)
|
||||||
|
self.logger.debug(f"_parse_dose_text returning: '{result}'")
|
||||||
|
return result
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import pytest
|
||||||
|
from datetime import datetime
|
||||||
|
import tkinter as tk
|
||||||
|
from src.ui_manager import UIManager
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def root_window():
|
||||||
|
root = tk.Tk()
|
||||||
|
yield root
|
||||||
|
root.destroy()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ui_manager(root_window):
|
||||||
|
class DummyLogger:
|
||||||
|
def debug(self, *a, **k): pass
|
||||||
|
def warning(self, *a, **k): pass
|
||||||
|
def error(self, *a, **k): pass
|
||||||
|
return UIManager(root_window, DummyLogger())
|
||||||
|
|
||||||
|
def test_parse_dose_history_for_saving_bullet_and_delete(ui_manager):
|
||||||
|
# Simulate user editing: add, delete, and custom lines
|
||||||
|
date_str = "07/30/2025"
|
||||||
|
# User deletes one line, adds a custom one
|
||||||
|
text = """
|
||||||
|
• 09:00 AM - 150mg
|
||||||
|
• 06:00 PM - 150mg
|
||||||
|
Custom note
|
||||||
|
""".strip()
|
||||||
|
result = ui_manager._parse_dose_history_for_saving(text, date_str)
|
||||||
|
# Should parse both bullets and keep the custom line
|
||||||
|
assert "2025-07-30 09:00:00:150mg" in result
|
||||||
|
assert "2025-07-30 18:00:00:150mg" in result
|
||||||
|
assert "Custom note" in result
|
||||||
|
# If user deletes all, should return empty string
|
||||||
|
assert ui_manager._parse_dose_history_for_saving("", date_str) == ""
|
||||||
|
assert ui_manager._parse_dose_history_for_saving("No doses recorded today", date_str) == ""
|
||||||
|
|
||||||
|
def test_parse_dose_history_for_saving_simple_time(ui_manager):
|
||||||
|
date_str = "07/30/2025"
|
||||||
|
text = "09:00 150mg\n18:00 150mg"
|
||||||
|
result = ui_manager._parse_dose_history_for_saving(text, date_str)
|
||||||
|
assert "2025-07-30 09:00:00:150mg" in result
|
||||||
|
assert "2025-07-30 18:00:00:150mg" in result
|
||||||
|
|
||||||
|
def test_parse_dose_history_for_saving_mixed(ui_manager):
|
||||||
|
date_str = "07/30/2025"
|
||||||
|
text = "• 09:00 AM - 150mg\n18:00 150mg\nJust a note"
|
||||||
|
result = ui_manager._parse_dose_history_for_saving(text, date_str)
|
||||||
|
assert "2025-07-30 09:00:00:150mg" in result
|
||||||
|
assert "2025-07-30 18:00:00:150mg" in result
|
||||||
|
assert "Just a note" in result
|
||||||
Reference in New Issue
Block a user