diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml deleted file mode 100644 index 8c2fb8d..0000000 --- a/.forgejo/workflows/ci.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: CI - -on: - push: - -jobs: - test: - runs-on: docker - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install system dependencies - run: | - apt-get update - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - python3-venv pipx libgl1 libxcb-cursor0 libxkbcommon-x11-0 libegl1 libdbus-1-3 \ - libopengl0 libx11-6 libxext6 libxi6 libxrender1 libxrandr2 \ - libxcb1 libxcb-render0 libxcb-keysyms1 libxcb-image0 libxcb-shm0 \ - libxcb-icccm4 libxcb-xfixes0 libxcb-shape0 libxcb-randr0 libxcb-xinerama0 \ - libxkbcommon0 - - - name: Install Poetry - run: | - pipx install poetry==1.8.3 - /root/.local/bin/poetry --version - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - - - name: Install project deps (including test extras) - run: | - poetry install --with test - - - name: Run test script - run: | - ./tests.sh - - # Notify if any previous step in this job failed - - name: Notify on failure - if: ${{ failure() }} - env: - WEBHOOK_URL: ${{ secrets.NODERED_WEBHOOK_URL }} - REPOSITORY: ${{ forgejo.repository }} - RUN_NUMBER: ${{ forgejo.run_number }} - SERVER_URL: ${{ forgejo.server_url }} - run: | - curl -X POST \ - -H "Content-Type: application/json" \ - -d "{\"repository\":\"$REPOSITORY\",\"run_number\":\"$RUN_NUMBER\",\"status\":\"failure\",\"url\":\"$SERVER_URL/$REPOSITORY/actions/runs/$RUN_NUMBER\"}" \ - "$WEBHOOK_URL" diff --git a/.forgejo/workflows/lint.yml b/.forgejo/workflows/lint.yml deleted file mode 100644 index fbe5a7e..0000000 --- a/.forgejo/workflows/lint.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Lint - -on: - push: - -jobs: - test: - runs-on: docker - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install system dependencies - run: | - apt-get update - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - black pyflakes3 vulture python3-bandit - - - name: Run linters - run: | - black --diff --check bouquin/* - black --diff --check tests/* - pyflakes3 bouquin/* - pyflakes3 tests/* - vulture - bandit -s B110 -r bouquin/ - - # Notify if any previous step in this job failed - - name: Notify on failure - if: ${{ failure() }} - env: - WEBHOOK_URL: ${{ secrets.NODERED_WEBHOOK_URL }} - REPOSITORY: ${{ forgejo.repository }} - RUN_NUMBER: ${{ forgejo.run_number }} - SERVER_URL: ${{ forgejo.server_url }} - run: | - curl -X POST \ - -H "Content-Type: application/json" \ - -d "{\"repository\":\"$REPOSITORY\",\"run_number\":\"$RUN_NUMBER\",\"status\":\"failure\",\"url\":\"$SERVER_URL/$REPOSITORY/actions/runs/$RUN_NUMBER\"}" \ - "$WEBHOOK_URL" diff --git a/.forgejo/workflows/trivy.yml b/.forgejo/workflows/trivy.yml deleted file mode 100644 index fad2f6f..0000000 --- a/.forgejo/workflows/trivy.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Trivy - -on: - schedule: - - cron: '0 1 * * *' - push: - -jobs: - test: - runs-on: docker - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install system dependencies - run: | - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends wget gnupg - wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | tee /usr/share/keyrings/trivy.gpg > /dev/null - echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | tee -a /etc/apt/sources.list.d/trivy.list - apt-get update - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends trivy - - - name: Run trivy - run: | - trivy fs --no-progress --ignore-unfixed --format table --disable-telemetry . - - # Notify if any previous step in this job failed - - name: Notify on failure - if: ${{ failure() }} - env: - WEBHOOK_URL: ${{ secrets.NODERED_WEBHOOK_URL }} - REPOSITORY: ${{ forgejo.repository }} - RUN_NUMBER: ${{ forgejo.run_number }} - SERVER_URL: ${{ forgejo.server_url }} - run: | - curl -X POST \ - -H "Content-Type: application/json" \ - -d "{\"repository\":\"$REPOSITORY\",\"run_number\":\"$RUN_NUMBER\",\"status\":\"failure\",\"url\":\"$SERVER_URL/$REPOSITORY/actions/runs/$RUN_NUMBER\"}" \ - "$WEBHOOK_URL" diff --git a/.gitignore b/.gitignore index 07c956d..2352872 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,3 @@ __pycache__ .pytest_cache dist .coverage -*.db -*.pdf -*.csv -*.html diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index 6281daa..0000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,26 +0,0 @@ -repos: - - repo: https://github.com/pycqa/flake8 - rev: 7.3.0 - hooks: - - id: flake8 - args: ["--select=F"] - types: [python] - - - repo: https://github.com/psf/black-pre-commit-mirror - rev: 25.11.0 - hooks: - - id: black - language_version: python3 - - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 - hooks: - - id: trailing-whitespace - - id: end-of-file-fixer - - - repo: https://github.com/PyCQA/bandit - rev: 1.9.2 - hooks: - - id: bandit - files: ^bouquin/ - args: ["-s", "B110"] diff --git a/CHANGELOG.md b/CHANGELOG.md index 45edf09..8bcacf5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,195 +1,7 @@ -# 0.7.3 - - * Allow optionally moving unchecked TODOs to the next day (even if it's the weekend) rather than next weekday. - * Add 'group by activity' in timesheet/invoice reports, rather than just by time period. - -# 0.7.2 - - * Fix Manage Reminders dialog (the actions column was missing, to edit/delete reminders) - -# 0.7.1 - - * Reduce the scope for toggling a checkbox on/off when not clicking precisely on it (must be to the left of the first letter) - * Moving unchecked TODO items to the next weekday now brings across the header that was above it, if present - * Invoicing should not be enabled by default - * Fix Reminders to fire right on the minute after adding them during runtime - * It is now possible to set up Webhooks for Reminders! A URL and a secret value (sent as X-Bouquin-Header) can be set in the Settings. - * Improvements to StatisticsDialog: it now shows statistics about logged time, reminders, etc. Sections are grouped for better readability - -# 0.7.0 - - * New Invoicing feature! This is tied to time logging and (optionally) documents and reminders features. - * Add 'Last week' to Time Report dialog range option - * Add 'Change Date' button to the History Dialog (same as the one used in Time log dialogs) - -# 0.6.4 - - * Time reports: Fix report 'group by' logic to not show ambiguous 'note' data. - * Time reports: Add default option to 'don't group'. This gives every individual time log row (and so the 'note' is shown in this case) - * Reminders: Ability to explicitly set the date of a reminder and have it handle recurrence based on that date - -# 0.6.3 - - * Allow 'this week', 'this month', 'this year' granularity in Timesheet reports. Default date range to start from this month. - * Allow 'All Projects' for timesheet reports. - * Make Reminder alarm proposed time be 5 minutes into the future (no point in being right now - that time is already passed) - * Allow more advanced recurring reminders (fortnightly, every specific day of month, monthly on the Nth weekday) - -# 0.6.2 - - * Ensure that adding a document whilst on an older date page, uses that date as its upload date - * Add 'Created at' to time log table. - * Show total hours for the day in the time log table (not just in the widget in sidebar) - * Pomodoro timer is now in the sidebar when toggled on, rather than as a separate dialog, so it stays out of the way - * Indent tabs by 4 spaces in code block editor dialog - -# 0.6.1 - - * Consolidate some code related to opening documents using the Documents feature. - * Ensure time log dialog gets closed when Pomodoro Timer finishes and user logs time. - * More code coverage - -# 0.6.0 - - * Add 'Documents' feature. Documents are tied to Projects in the same way as Time Logging, and can be tagged via the Tags feature. - * Close time log dialog if opened via the + button from sidebar widget - * Only show tags in Statistics widget if tags are enabled - * Fix rounding up/down in Pomodoro timer to the closest 15 min interval - -# 0.5.5 - - * Add + button to time log widget in side bar to have a simplified log entry dialog (without summary or report option) - * Allow click-and-drag mouse select on lines with checkbox, to capture the checkbox as well as the text. - * Allow changing the date when logging time (rather than having to go to that date before clicking on adding time log/opening time log manager) - * Ensure time log reports have an extension - -# 0.5.4 - - * Ensure pressing enter a second time on a new line with a checkbox, erases the checkbox (if it had no text added to it) - -# 0.5.3 - - * Prevent triple-click select from selecting the list item (e.g checkbox, bullet) - * Use DejaVu Sans font for regular text instead of heavier Noto - might help with the freeze issues. - * Change History icon (again) - * Make it easier to check on or off the checkbox by adding some buffer (instead of having to precisely click inside it) - * Prevent double-click of checkbox leading to selecting/highlighting it - * Slightly fade the text of a checkbox line if the checkbox is checked. - * Fix weekend date colours being incorrect on theme change while app is running - * Avoid capturing checkbox/bullet etc in the task text that would get offered as the 'note' when Pomodoro timer stops - * Code Blocks are now their own QDialog to try and reduce risk of getting trapped in / bleeding in/out of text in code blocks. - -# 0.5.2 - - * Update icon again to remove background - * Adjust History icon and reorder toolbar items - * Try to address checkbox/bullet size issues (again) - * Fix HTML export of markdown (with newlines, tables and other styling preserved) - * Remove table tool - -# 0.5.1 - - * Try to address Noto Sans font issue that works for both numbers and checkbox/bullets. - * Update icon - * Update French translations - * Improve size of flashing reminder dialog - -# 0.5 - - * More Italian translations, thank you @mdaleo404 - * Set locked status on window title when locked - * Don't exit on incorrect key, let it be tried again - * Make reminders be its own dataset rather than tied to current string. - * Add support for repeated reminders - * Make reminders be a feature that can be turned on and off - * Add syntax highlighting for code blocks (right-click to set it) - * Add a Pomodoro-style timer for measuring time spent on a task (stopping the timer offers to log it to Time Log) - * Add ability to create markdown tables. Right-click to edit the table in a friendlier table dialog - -# 0.4.5 - - * Make it possible to delete revisions - * Make it possible to force-lock the screen even if idle timer hasn't tripped - * Add shortcuts for lock and unlock of screen - * Other misc bug fixes - -# 0.4.4.1 - - * Adjust some widget heights/settings text wrap - * Adjust shortcuts - * History unicode symbol - * Icon in version dialog - -# 0.4.4 - - * Moving unchecked TODOs now includes those up to 7 days ago, not just yesterday - * Moving unchecked TODOs now skips placing them on weekends. - * Moving unchecked TODOs now automatically occurs after midnight if the app is open (not just on startup) - * Check for new version / download new AppImage via the Help -> Version screen. - * Remove extra newline after headings - -# 0.4.3 - - * Ship Noto Sans Symbols2 font, which seems to work better for unicode symbols on Fedora - -# 0.4.2 - - * Improve Statistics widget height - * Improve SaveDialog widget width - * Make Tags and TimeLog optional features that can be switched on/off in Settings (enabled by default) - * Make it possible to change regular text size - * Refactored Settings dialog to use tabs to reduce its size - -# 0.4.1 - - * Allow time log entries to be edited directly in their table cells - * Miscellaneous bug fixes for editing (list cursor positions/text selectivity, alarm removing newline) - * Add 'Close tab' nav item and shortcut - -# 0.4 - - * Remove screenshot tool - * Improve width of bug report dialog - * Improve size of checkboxes - * Convert bullet - to actual unicode bullets - * Add alarm option to set reminders - * Add time logging and reporting - -# 0.3.2 - - * Add weekday letters on left axis of Statistics page - * Allow clicking on a date in the Statistics heatmap and have it open that page - * Add the ability to choose the database path at startup - * Add in-app bug report functionality - -# 0.3.1 - - * Make it possible to add a tag from the Tag Browser - * Add a statistics dialog with heatmap - * Remove export to .txt (just use .md) - * Restore link styling and clickability - -# 0.3 - - * Introduce Tags - * Make translations dynamically detected from the locales dir rather than hardcoded - * Add Italian translations (thanks @mdaleo404) - * Add version information in the navigation - * Increase line spacing between lines (except for code blocks) - * Prevent being able to left-click a date and have it load in current tab if it is already open in another tab - * Avoid second checkbox/bullet on second newline after first newline - * Avoid Home/left arrow jumping to the left side of a list symbol - * Various test additions/fixes - -# 0.2.1.8 - - * Translate all strings, add French, add locale choice in settings - * Fix hiding status bar (including find bar) when locked - # 0.2.1.7 * Fix being able to set bold, italic and strikethrough at the same time. - * Fixes for system dark theme and move stylesheets for Calendar/Lock Overlay into the ThemeManager + * Fixes for system dark theme * Add AppImage # 0.2.1.6 diff --git a/README.md b/README.md index da87442..96bdf58 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,9 @@ # Bouquin -
|
- {logo_html}
-
- {company_html}
-
- |
-
- {invoice_title}
-
|
-
|
- BILL TO
- {client_block}
- |
-
-
-
{tax_summary_text}
- |
-
| ITEMS AND DESCRIPTION | -QTY/HRS | -PRICE | -AMOUNT ({currency}) | -
|---|
|
- PAYMENT DETAILS
-
-{payment_text}
-
- |
-
-
-
|
-
{html.escape(strings._("time_report_total").format(hours=total_hours))}
- - -""" - - # ---------- Render HTML to PDF ---------- - printer = QPrinter(QPrinter.HighResolution) - printer.setOutputFormat(QPrinter.PdfFormat) - printer.setOutputFileName(filename) - printer.setPageOrientation(QPageLayout.Orientation.Landscape) - - doc = QTextDocument() - # attach the chart image as a resource - doc.addResource(QTextDocument.ImageResource, QUrl("chart"), chart) - doc.setHtml(html_doc) - - try: - doc.print_(printer) - except Exception as exc: # very defensive - QMessageBox.warning( - self, - strings._("export_pdf_error_title"), - strings._("export_pdf_error_message").format(error=str(exc)), - ) - - def _update_invoice_button_state(self) -> None: - data = self.project_combo.currentData() - if data is not None: - self.invoice_btn.show() - else: - self.invoice_btn.hide() - - def _on_manage_invoices(self) -> None: - from .invoices import InvoicesDialog - - dlg = InvoicesDialog(self._db, parent=self) - - # When the dialog says "reminders changed", forward that outward - dlg.remindersChanged.connect(self.remindersChanged.emit) - - dlg.exec() - - def _on_create_invoice(self) -> None: - idx = self.project_combo.currentIndex() - if idx < 0: - return - - project_id_data = self.project_combo.itemData(idx) - if project_id_data is None: - # Currently invoices are per-project, not cross-project - QMessageBox.information( - self, - strings._("invoice_project_required_title"), - strings._("invoice_project_required_message"), - ) - return - - proj_id = int(project_id_data) - - # Ensure we have a recent run to base this on - if not self._last_time_logs: - QMessageBox.information( - self, - strings._("invoice_need_report_title"), - strings._("invoice_need_report_message"), - ) - return - - start = self.from_date.date().toString("yyyy-MM-dd") - end = self.to_date.date().toString("yyyy-MM-dd") - - from .invoices import InvoiceDialog - - dlg = InvoiceDialog(self._db, proj_id, start, end, self._last_time_logs, self) - dlg.remindersChanged.connect(self.remindersChanged.emit) - dlg.exec() diff --git a/bouquin/toolbar.py b/bouquin/toolbar.py index 92383e6..acf0413 100644 --- a/bouquin/toolbar.py +++ b/bouquin/toolbar.py @@ -1,11 +1,9 @@ from __future__ import annotations -from PySide6.QtCore import Qt, Signal -from PySide6.QtGui import QAction, QActionGroup, QFont, QFontDatabase, QKeySequence +from PySide6.QtCore import Signal, Qt +from PySide6.QtGui import QAction, QKeySequence, QFont, QFontDatabase, QActionGroup from PySide6.QtWidgets import QToolBar -from . import strings - class ToolBar(QToolBar): boldRequested = Signal() @@ -18,115 +16,83 @@ class ToolBar(QToolBar): checkboxesRequested = Signal() historyRequested = Signal() insertImageRequested = Signal() - alarmRequested = Signal() - timerRequested = Signal() - documentsRequested = Signal() - fontSizeLargerRequested = Signal() - fontSizeSmallerRequested = Signal() def __init__(self, parent=None): - super().__init__(strings._("toolbar_format"), parent) - self.setObjectName(strings._("toolbar_format")) + super().__init__("Format", parent) + self.setObjectName("Format") self.setToolButtonStyle(Qt.ToolButtonTextOnly) self._build_actions() self._apply_toolbar_styles() def _build_actions(self): self.actBold = QAction("B", self) - self.actBold.setToolTip(strings._("toolbar_bold")) + self.actBold.setToolTip("Bold") self.actBold.setCheckable(True) self.actBold.setShortcut(QKeySequence.Bold) self.actBold.triggered.connect(self.boldRequested) self.actItalic = QAction("I", self) - self.actItalic.setToolTip(strings._("toolbar_italic")) + self.actItalic.setToolTip("Italic") self.actItalic.setCheckable(True) self.actItalic.setShortcut(QKeySequence.Italic) self.actItalic.triggered.connect(self.italicRequested) self.actStrike = QAction("S", self) - self.actStrike.setToolTip(strings._("toolbar_strikethrough")) + self.actStrike.setToolTip("Strikethrough") self.actStrike.setCheckable(True) self.actStrike.setShortcut("Ctrl+-") self.actStrike.triggered.connect(self.strikeRequested) self.actCode = QAction(">", self) - self.actCode.setToolTip(strings._("toolbar_code_block")) + self.actCode.setToolTip("Code block") self.actCode.setShortcut("Ctrl+`") self.actCode.triggered.connect(self.codeRequested) # Headings self.actH1 = QAction("H1", self) - self.actH1.setToolTip(strings._("toolbar_heading") + " 1") + self.actH1.setToolTip("Heading 1") self.actH1.setCheckable(True) self.actH1.setShortcut("Ctrl+1") self.actH1.triggered.connect(lambda: self.headingRequested.emit(24)) self.actH2 = QAction("H2", self) - self.actH2.setToolTip(strings._("toolbar_heading") + " 2") + self.actH2.setToolTip("Heading 2") self.actH2.setCheckable(True) self.actH2.setShortcut("Ctrl+2") self.actH2.triggered.connect(lambda: self.headingRequested.emit(18)) self.actH3 = QAction("H3", self) - self.actH3.setToolTip(strings._("toolbar_heading") + " 3") + self.actH3.setToolTip("Heading 3") self.actH3.setCheckable(True) self.actH3.setShortcut("Ctrl+3") self.actH3.triggered.connect(lambda: self.headingRequested.emit(14)) - self.actNormal = QAction("P", self) - self.actNormal.setToolTip(strings._("toolbar_normal_paragraph_text")) + self.actNormal = QAction("N", self) + self.actNormal.setToolTip("Normal paragraph text") self.actNormal.setCheckable(True) - self.actNormal.setShortcut("Ctrl+.") + self.actNormal.setShortcut("Ctrl+N") self.actNormal.triggered.connect(lambda: self.headingRequested.emit(0)) - self.actFontSmaller = QAction("P-", self) - self.actFontSmaller.setToolTip(strings._("toolbar_font_smaller")) - self.actFontSmaller.setShortcut("Ctrl+Shift+-") - self.actFontSmaller.triggered.connect(self.fontSizeSmallerRequested) - - self.actFontLarger = QAction("P+", self) - self.actFontLarger.setToolTip(strings._("toolbar_font_larger")) - self.actFontLarger.setShortcut("Ctrl+Shift+=") - self.actFontLarger.triggered.connect(self.fontSizeLargerRequested) - # Lists self.actBullets = QAction("•", self) - self.actBullets.setToolTip(strings._("toolbar_bulleted_list")) + self.actBullets.setToolTip("Bulleted list") self.actBullets.setCheckable(True) self.actBullets.triggered.connect(self.bulletsRequested) self.actNumbers = QAction("1.", self) - self.actNumbers.setToolTip(strings._("toolbar_numbered_list")) + self.actNumbers.setToolTip("Numbered list") self.actNumbers.setCheckable(True) self.actNumbers.triggered.connect(self.numbersRequested) - self.actCheckboxes = QAction("☑", self) - self.actCheckboxes.setToolTip(strings._("toolbar_toggle_checkboxes")) + self.actCheckboxes = QAction("☐", self) + self.actCheckboxes.setToolTip("Toggle checkboxes") self.actCheckboxes.triggered.connect(self.checkboxesRequested) # Images - self.actInsertImg = QAction("📸", self) - self.actInsertImg.setToolTip(strings._("insert_images")) + self.actInsertImg = QAction("Image", self) + self.actInsertImg.setToolTip("Insert image") self.actInsertImg.setShortcut("Ctrl+Shift+I") self.actInsertImg.triggered.connect(self.insertImageRequested) # History button - self.actHistory = QAction("↺", self) - self.actHistory.setToolTip(strings._("history")) + self.actHistory = QAction("History", self) self.actHistory.triggered.connect(self.historyRequested) - # Alarm / reminder - self.actAlarm = QAction("⏰", self) - self.actAlarm.setToolTip(strings._("toolbar_alarm")) - self.actAlarm.triggered.connect(self.alarmRequested) - - # Focus timer - self.actTimer = QAction("⌛", self) - self.actTimer.setToolTip(strings._("toolbar_pomodoro_timer")) - self.actTimer.setCheckable(True) - self.actTimer.triggered.connect(self.timerRequested) - - # Documents - self.actDocuments = QAction("📁", self) - self.actDocuments.setToolTip(strings._("toolbar_documents")) - self.actDocuments.triggered.connect(self.documentsRequested) - # Set exclusive buttons in QActionGroups self.grpHeadings = QActionGroup(self) self.grpHeadings.setExclusive(True) @@ -158,15 +124,10 @@ class ToolBar(QToolBar): self.actH2, self.actH3, self.actNormal, - self.actFontSmaller, - self.actFontLarger, self.actBullets, self.actNumbers, self.actCheckboxes, self.actInsertImg, - self.actAlarm, - self.actTimer, - self.actDocuments, self.actHistory, ] ) @@ -183,20 +144,14 @@ class ToolBar(QToolBar): self._style_letter_button(self.actH1, "H1") self._style_letter_button(self.actH2, "H2") self._style_letter_button(self.actH3, "H3") - self._style_letter_button(self.actNormal, "P") - self._style_letter_button(self.actFontSmaller, "P-") - self._style_letter_button(self.actFontLarger, "P+") + self._style_letter_button(self.actNormal, "N") # Lists self._style_letter_button(self.actBullets, "•") self._style_letter_button(self.actNumbers, "1.") - self._style_letter_button(self.actCheckboxes, "☑") - self._style_letter_button(self.actAlarm, "⏰") - self._style_letter_button(self.actTimer, "⌛") - self._style_letter_button(self.actDocuments, "📁") # History - self._style_letter_button(self.actHistory, "↺") + self._style_letter_button(self.actHistory, "View History") def _style_letter_button( self, diff --git a/bouquin/version_check.py b/bouquin/version_check.py deleted file mode 100644 index 5b62d02..0000000 --- a/bouquin/version_check.py +++ /dev/null @@ -1,406 +0,0 @@ -from __future__ import annotations - -import importlib.metadata -import os -import re -import subprocess # nosec -import tempfile -from importlib.resources import files -from pathlib import Path - -import requests -from PySide6.QtCore import QStandardPaths, Qt -from PySide6.QtGui import QGuiApplication, QImage, QPainter, QPixmap -from PySide6.QtSvg import QSvgRenderer -from PySide6.QtWidgets import QApplication, QMessageBox, QProgressDialog, QWidget - -from . import strings -from .settings import APP_NAME - -# Where to fetch the latest version string from -VERSION_URL = "https://mig5.net/bouquin/version.txt" - -# Name of the installed distribution according to pyproject.toml -# (used with importlib.metadata.version) -DIST_NAME = "bouquin" - -# Base URL where AppImages are hosted -APPIMAGE_BASE_URL = "https://git.mig5.net/mig5/bouquin/releases/download" - -# Where we expect to find the bundled public key, relative to the *installed* package. -GPG_PUBKEY_RESOURCE = ("bouquin", "keys", "mig5.asc") - - -class VersionChecker: - """ - Handles: - * showing the version dialog - * checking for updates - * downloading & verifying a new AppImage - - All dialogs use `parent` as their parent widget. - """ - - def __init__(self, parent: QWidget | None = None): - self._parent = parent - - # ---------- Version helpers ---------- # - - def _logo_pixmap(self, logical_size: int = 96) -> QPixmap: - """ - Render the SVG logo to a high-DPI-aware QPixmap so it stays crisp. - """ - svg_path = Path(__file__).resolve().parent / "icons" / "bouquin.svg" - - # Logical size (what Qt layouts see) - dpr = QGuiApplication.primaryScreen().devicePixelRatio() - img_size = int(logical_size * dpr) - - image = QImage(img_size, img_size, QImage.Format_ARGB32) - image.fill(Qt.transparent) - - renderer = QSvgRenderer(str(svg_path)) - painter = QPainter(image) - renderer.render(painter) - painter.end() - - pixmap = QPixmap.fromImage(image) - pixmap.setDevicePixelRatio(dpr) - return pixmap - - def current_version(self) -> str: - """ - Return the current app version as reported by importlib.metadata - """ - try: - return importlib.metadata.version(DIST_NAME) - except importlib.metadata.PackageNotFoundError: - # Fallback for editable installs / dev trees - return "0.0.0" - - @staticmethod - def _parse_version(v: str) -> tuple[int, ...]: - """ - Very small helper to compare simple semantic versions like 1.2.3. - Extracts numeric components and returns them as a tuple. - """ - parts = re.findall(r"\d+", v) - if not parts: - return (0,) - return tuple(int(p) for p in parts) - - def _is_newer_version(self, available: str, current: str) -> bool: - """ - True if `available` > `current` according to _parse_version. - """ - return self._parse_version(available) > self._parse_version(current) - - # ---------- Public entrypoint for Help → Version ---------- # - - def show_version_dialog(self) -> None: - """ - Show the Version dialog with a 'Check for updates' button. - """ - version = self.current_version() - version_formatted = f"{APP_NAME} {version}" - - box = QMessageBox(self._parent) - box.setWindowTitle(strings._("version")) - - box.setIconPixmap(self._logo_pixmap(96)) - - box.setText(version_formatted) - - check_button = box.addButton( - strings._("check_for_updates"), QMessageBox.ActionRole - ) - box.addButton(QMessageBox.Close) - - box.exec() - - if box.clickedButton() is check_button: - self.check_for_updates() - - # ---------- Core update logic ---------- # - - def check_for_updates(self) -> None: - """ - Fetch VERSION_URL, compare against the current version, and optionally - download + verify a new AppImage. - """ - current = self.current_version() - - try: - resp = requests.get(VERSION_URL, timeout=10) - resp.raise_for_status() - available_raw = resp.text.strip() - except Exception as e: - QMessageBox.warning( - self._parent, - strings._("update"), - strings._("could_not_check_for_updates") + str(e), - ) - return - - if not available_raw: - QMessageBox.warning( - self._parent, - strings._("update"), - strings._("update_server_returned_an_empty_version_string"), - ) - return - - if not self._is_newer_version(available_raw, current): - QMessageBox.information( - self._parent, - strings._("update"), - strings._("you_are_running_the_latest_version") + f"({current}).", - ) - return - - # Newer version is available - reply = QMessageBox.question( - self._parent, - strings._("update"), - ( - strings._("there_is_a_new_version_available") - + available_raw - + "\n\n" - + strings._("download_the_appimage") - ), - QMessageBox.Yes | QMessageBox.No, - ) - if reply != QMessageBox.Yes: - return - - self._download_and_verify_appimage(available_raw) - - # ---------- Download + verification helpers ---------- # - def _download_file( - self, - url: str, - dest_path: Path, - timeout: int = 30, - progress: QProgressDialog | None = None, - label: str | None = None, - ) -> None: - """ - Stream a URL to a local file, optionally updating a QProgressDialog. - If the user cancels via the dialog, raises RuntimeError. - """ - resp = requests.get(url, timeout=timeout, stream=True) - resp.raise_for_status() - - dest_path.parent.mkdir(parents=True, exist_ok=True) - - total_bytes: int | None = None - content_length = resp.headers.get("Content-Length") - if content_length is not None: - try: - total_bytes = int(content_length) - except ValueError: - total_bytes = None - - if progress is not None: - progress.setLabelText( - label or strings._("downloading") + f" {dest_path.name}..." - ) - # Unknown size → busy indicator; known size → real range - if total_bytes is not None and total_bytes > 0: - progress.setRange(0, total_bytes) - else: - progress.setRange(0, 0) # pragma: no cover - progress.setValue(0) - progress.show() - QApplication.processEvents() - - downloaded = 0 - with dest_path.open("wb") as f: - for chunk in resp.iter_content(chunk_size=8192): - if not chunk: - continue # pragma: no cover - - f.write(chunk) - downloaded += len(chunk) - - if progress is not None: - if total_bytes is not None and total_bytes > 0: - progress.setValue(downloaded) - else: - # Just bump a little so the dialog looks alive - progress.setValue(progress.value() + 1) # pragma: no cover - QApplication.processEvents() - - if progress.wasCanceled(): - raise RuntimeError(strings._("download_cancelled")) - - if progress is not None and total_bytes is not None and total_bytes > 0: - progress.setValue(total_bytes) - QApplication.processEvents() - - def _download_and_verify_appimage(self, version: str) -> None: - """ - Download the AppImage + its GPG signature to the user's Downloads dir, - then verify it with a bundled public key. - """ - # Where to put the file - download_dir = QStandardPaths.writableLocation(QStandardPaths.DownloadLocation) - if not download_dir: - download_dir = os.path.expanduser("~/Downloads") - download_dir = Path(download_dir) - download_dir.mkdir(parents=True, exist_ok=True) - - # Construct AppImage filename and URLs - appimage_path = download_dir / "Bouquin.AppImage" - sig_path = Path(str(appimage_path) + ".asc") - - appimage_url = f"{APPIMAGE_BASE_URL}/{version}/Bouquin.AppImage" - sig_url = f"{appimage_url}.asc" - - # Progress dialog covering both downloads - progress = QProgressDialog( - "Downloading update...", - "Cancel", - 0, - 100, - self._parent, - ) - progress.setWindowTitle(strings._("update")) - progress.setWindowModality(Qt.WindowModal) - progress.setAutoClose(False) - progress.setAutoReset(False) - - try: - # AppImage download - self._download_file( - appimage_url, - appimage_path, - progress=progress, - label=strings._("downloading") + " Bouquin.AppImage...", - ) - # Signature download (usually tiny, but we still show it) - self._download_file( - sig_url, - sig_path, - progress=progress, - label=strings._("downloading") + " signature...", - ) - except RuntimeError: - # User cancelled - for p in (appimage_path, sig_path): - try: - if p.exists(): - p.unlink() # pragma: no cover - except OSError: # pragma: no cover - pass - - progress.close() - QMessageBox.information( - self._parent, - strings._("update"), - strings._("download_cancelled"), - ) - return - except Exception as e: - # Other error - for p in (appimage_path, sig_path): - try: - if p.exists(): - p.unlink() # pragma: no cover - except OSError: # pragma: no cover - pass - - progress.close() - QMessageBox.critical( - self._parent, - strings._("update"), - strings._("failed_to_download_update") + str(e), - ) - return - - progress.close() - - # Load the bundled public key - try: - pkg, *rel = GPG_PUBKEY_RESOURCE - pubkey_bytes = (files(pkg) / "/".join(rel)).read_bytes() - except Exception as e: # pragma: no cover - QMessageBox.critical( - self._parent, - strings._("update"), - strings._("could_not_read_bundled_gpg_public_key") + str(e), - ) - # On failure, delete the downloaded files for safety - for p in (appimage_path, sig_path): - try: - if p.exists(): - p.unlink() - except OSError: # pragma: no cover - pass - return - - # Use a temporary GNUPGHOME so we don't touch the user's main keyring - try: - with tempfile.TemporaryDirectory() as gnupg_home: - pubkey_path = Path(gnupg_home) / "pubkey.asc" - pubkey_path.write_bytes(pubkey_bytes) - - # Import the key - subprocess.run( - ["gpg", "--homedir", gnupg_home, "--import", str(pubkey_path)], - check=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - ) # nosec - - # Verify the signature - subprocess.run( - [ - "gpg", - "--homedir", - gnupg_home, - "--verify", - str(sig_path), - str(appimage_path), - ], - check=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - ) # nosec - except FileNotFoundError: - # gpg not installed / not on PATH - for p in (appimage_path, sig_path): - try: - if p.exists(): - p.unlink() # pragma: no cover - except OSError: # pragma: no cover - pass - - QMessageBox.critical( - self._parent, - strings._("update"), - strings._("could_not_find_gpg_executable"), - ) - return - except subprocess.CalledProcessError as e: - for p in (appimage_path, sig_path): - try: - if p.exists(): - p.unlink() # pragma: no cover - except OSError: # pragma: no cover - pass - - QMessageBox.critical( - self._parent, - strings._("update"), - strings._("gpg_signature_verification_failed") - + e.stderr.decode(errors="ignore"), - ) - return - - # Success - QMessageBox.information( - self._parent, - strings._("update"), - strings._("downloaded_and_verified_new_appimage") + str(appimage_path), - ) diff --git a/find_unused_strings.py b/find_unused_strings.py deleted file mode 100755 index 5341001..0000000 --- a/find_unused_strings.py +++ /dev/null @@ -1,259 +0,0 @@ -#!/usr/bin/env python3 - -import argparse -import ast -import json -from pathlib import Path -from typing import Dict, Set - - -BASE_DIR = Path(__file__).resolve().parent / "bouquin" -LOCALES_DIR = BASE_DIR / "locales" - - -def load_json_keys(locale: str) -> Set[str]: - """Load all keys from the given locale JSON file.""" - path = LOCALES_DIR / f"{locale}.json" - with path.open(encoding="utf-8") as f: - data = json.load(f) - return set(data.keys()) - - -class KeyParamFinder(ast.NodeVisitor): - """ - First pass: - For each function/method, figure out which parameters are later passed - into _(), translated(), or strings._(). - - Example: in your _prompt_name, it discovers that title_key and label_key - are translation-key parameters. - """ - - def __init__(self) -> None: - # func_name -> {"param_positions": {param: arg_index}, "key_param_positions": set[arg_index]} - self.func_info: Dict[str, dict] = {} - self.current_func_name_stack: list[str] = [] - self.current_param_positions_stack: list[Dict[str, int]] = [] - self.current_class_stack: list[str] = [] - - # Track when we're inside a class so we can treat "self" specially - def visit_ClassDef(self, node: ast.ClassDef) -> None: - self.current_class_stack.append(node.name) - self.generic_visit(node) - self.current_class_stack.pop() - - def _enter_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None: - funcname = node.name - params = [arg.arg for arg in node.args.args] - - # If we're inside a class and there is at least one param, - # assume the first one is "self"/"cls" and is implicit at call sites. - is_method = bool(self.current_class_stack) and len(params) > 0 - - param_positions: Dict[str, int] = {} - for i, name in enumerate(params): - if is_method and i == 0: - # skip self/cls; it doesn't correspond to an explicit arg in calls like self.method(...) - continue - call_index = i - 1 if is_method else i - param_positions[name] = call_index - - self.current_func_name_stack.append(funcname) - self.current_param_positions_stack.append(param_positions) - - self.func_info.setdefault( - funcname, - { - "param_positions": param_positions, - "key_param_positions": set(), - }, - ) - # If the function name is reused, last definition wins - self.func_info[funcname]["param_positions"] = param_positions - - def _exit_function(self) -> None: - self.current_func_name_stack.pop() - self.current_param_positions_stack.pop() - - def visit_FunctionDef(self, node: ast.FunctionDef) -> None: - self._enter_function(node) - self.generic_visit(node) - self._exit_function() - - def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: - self._enter_function(node) - self.generic_visit(node) - self._exit_function() - - def visit_Call(self, node: ast.Call) -> None: - # Only care about calls *inside* functions - if not self.current_func_name_stack: - return self.generic_visit(node) - - func = node.func - func_name: str | None = None - - if isinstance(func, ast.Name): - func_name = func.id - elif isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name): - # e.g. strings._(...) - func_name = f"{func.value.id}.{func.attr}" - - # Is this a translation call? - if func_name in {"_", "translated", "strings._"}: - cur_name = self.current_func_name_stack[-1] - param_positions = self.current_param_positions_stack[-1] - - # Positional first arg - if node.args: - first = node.args[0] - if isinstance(first, ast.Name): - pname = first.id - if pname in param_positions: - idx = param_positions[pname] - self.func_info[cur_name]["key_param_positions"].add(idx) - - # Keyword args, e.g. strings._(key=title_key) - for kw in node.keywords or []: - if isinstance(kw.value, ast.Name): - pname = kw.value.id - if pname in param_positions: - idx = param_positions[pname] - self.func_info[cur_name]["key_param_positions"].add(idx) - - self.generic_visit(node) - - -class UsedKeyCollector(ast.NodeVisitor): - """ - Second pass: - - Collect string literals passed directly to _()/translated()/strings._() - - Collect string literals passed into parameters that we know are - "translation-key parameters" of wrapper functions/methods. - """ - - def __init__(self, func_info: Dict[str, dict]) -> None: - self.func_info = func_info - self.used_keys: Set[str] = set() - - def visit_Call(self, node: ast.Call) -> None: - func = node.func - - def full_name(f: ast.expr) -> str | None: - if isinstance(f, ast.Name): - return f.id - if isinstance(f, ast.Attribute) and isinstance(f.value, ast.Name): - return f"{f.value.id}.{f.attr}" - return None - - func_full = full_name(func) - - # 1) Direct translation calls like _("key") or strings._("key") - if func_full in {"_", "translated", "strings._"}: - if node.args: - first = node.args[0] - if isinstance(first, ast.Constant) and isinstance(first.value, str): - self.used_keys.add(first.value) - for kw in node.keywords or []: - if isinstance(kw.value, ast.Constant) and isinstance( - kw.value.value, str - ): - self.used_keys.add(kw.value.value) - - # 2) Wrapper calls: functions whose params we know are translation-key params - called_base_name: str | None = None - if isinstance(func, ast.Name): - called_base_name = func.id - elif isinstance(func, ast.Attribute): - called_base_name = func.attr # e.g. self._prompt_name -> "_prompt_name" - - if called_base_name in self.func_info: - info = self.func_info[called_base_name] - param_positions: Dict[str, int] = info["param_positions"] - key_positions: Set[int] = info["key_param_positions"] - - # positional args - for idx, arg in enumerate(node.args): - if ( - idx in key_positions - and isinstance(arg, ast.Constant) - and isinstance(arg.value, str) - ): - self.used_keys.add(arg.value) - - # keyword args - for kw in node.keywords or []: - if kw.arg is None: - continue # **kwargs, ignore - param_name = kw.arg - if param_name in param_positions: - idx = param_positions[param_name] - if idx in key_positions: - val = kw.value - if isinstance(val, ast.Constant) and isinstance(val.value, str): - self.used_keys.add(val.value) - - self.generic_visit(node) - - -def collect_used_keys() -> Set[str]: - """Parse all .py files and collect all translation keys used.""" - trees: list[ast.AST] = [] - - # Read and parse all Python files in this folder - for path in BASE_DIR.glob("*.py"): - # Optionally skip this script itself - if path.name == Path(__file__).name: - continue - src = path.read_text(encoding="utf-8") - tree = ast.parse(src, filename=str(path)) - trees.append(tree) - - # First pass: find which parameters are translation-key params - finder = KeyParamFinder() - for tree in trees: - finder.visit(tree) - - # Second pass: collect string literals passed to those parameters - collector = UsedKeyCollector(finder.func_info) - for tree in trees: - collector.visit(tree) - - return collector.used_keys - - -def main() -> None: - parser = argparse.ArgumentParser( - description="Find missing or unused strings for a given locale" - ) - parser.add_argument( - "--locale", - type=str, - default="en", - help="Locale key e.g en, fr, it", - ) - args = parser.parse_args() - - json_keys = load_json_keys(args.locale) - used_keys = collect_used_keys() - - unused_keys = sorted(json_keys - used_keys) - missing_in_json = sorted(used_keys - json_keys) - - print("=== Unused keys in JSON (present in locales but never used in code) ===") - if unused_keys: - for k in unused_keys: - print(" ", k) - else: - print(" (none)") - - print("\n=== Keys used in code but missing from JSON ===") - if missing_in_json: - for k in missing_in_json: - print(" ", k) - else: - print(" (none)") - - -if __name__ == "__main__": - main() diff --git a/poetry.lock b/poetry.lock index 115621c..6382937 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2,13 +2,13 @@ [[package]] name = "certifi" -version = "2025.11.12" +version = "2025.10.5" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" files = [ - {file = "certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b"}, - {file = "certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"}, + {file = "certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de"}, + {file = "certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43"}, ] [[package]] @@ -146,103 +146,115 @@ files = [ [[package]] name = "coverage" -version = "7.13.0" +version = "7.10.7" description = "Code coverage measurement for Python" optional = false -python-versions = ">=3.10" +python-versions = ">=3.9" files = [ - {file = "coverage-7.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:02d9fb9eccd48f6843c98a37bd6817462f130b86da8660461e8f5e54d4c06070"}, - {file = "coverage-7.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:367449cf07d33dc216c083f2036bb7d976c6e4903ab31be400ad74ad9f85ce98"}, - {file = "coverage-7.13.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cdb3c9f8fef0a954c632f64328a3935988d33a6604ce4bf67ec3e39670f12ae5"}, - {file = "coverage-7.13.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d10fd186aac2316f9bbb46ef91977f9d394ded67050ad6d84d94ed6ea2e8e54e"}, - {file = "coverage-7.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f88ae3e69df2ab62fb0bc5219a597cb890ba5c438190ffa87490b315190bb33"}, - {file = "coverage-7.13.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4be718e51e86f553bcf515305a158a1cd180d23b72f07ae76d6017c3cc5d791"}, - {file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a00d3a393207ae12f7c49bb1c113190883b500f48979abb118d8b72b8c95c032"}, - {file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a7b1cd820e1b6116f92c6128f1188e7afe421c7e1b35fa9836b11444e53ebd9"}, - {file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:37eee4e552a65866f15dedd917d5e5f3d59805994260720821e2c1b51ac3248f"}, - {file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:62d7c4f13102148c78d7353c6052af6d899a7f6df66a32bddcc0c0eb7c5326f8"}, - {file = "coverage-7.13.0-cp310-cp310-win32.whl", hash = "sha256:24e4e56304fdb56f96f80eabf840eab043b3afea9348b88be680ec5986780a0f"}, - {file = "coverage-7.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:74c136e4093627cf04b26a35dab8cbfc9b37c647f0502fc313376e11726ba303"}, - {file = "coverage-7.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0dfa3855031070058add1a59fdfda0192fd3e8f97e7c81de0596c145dea51820"}, - {file = "coverage-7.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fdb6f54f38e334db97f72fa0c701e66d8479af0bc3f9bfb5b90f1c30f54500f"}, - {file = "coverage-7.13.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7e442c013447d1d8d195be62852270b78b6e255b79b8675bad8479641e21fd96"}, - {file = "coverage-7.13.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ed5630d946859de835a85e9a43b721123a8a44ec26e2830b296d478c7fd4259"}, - {file = "coverage-7.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f15a931a668e58087bc39d05d2b4bf4b14ff2875b49c994bbdb1c2217a8daeb"}, - {file = "coverage-7.13.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30a3a201a127ea57f7e14ba43c93c9c4be8b7d17a26e03bb49e6966d019eede9"}, - {file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a485ff48fbd231efa32d58f479befce52dcb6bfb2a88bb7bf9a0b89b1bc8030"}, - {file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:22486cdafba4f9e471c816a2a5745337742a617fef68e890d8baf9f3036d7833"}, - {file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:263c3dbccc78e2e331e59e90115941b5f53e85cfcc6b3b2fbff1fd4e3d2c6ea8"}, - {file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5330fa0cc1f5c3c4c3bb8e101b742025933e7848989370a1d4c8c5e401ea753"}, - {file = "coverage-7.13.0-cp311-cp311-win32.whl", hash = "sha256:0f4872f5d6c54419c94c25dd6ae1d015deeb337d06e448cd890a1e89a8ee7f3b"}, - {file = "coverage-7.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51a202e0f80f241ccb68e3e26e19ab5b3bf0f813314f2c967642f13ebcf1ddfe"}, - {file = "coverage-7.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:d2a9d7f1c11487b1c69367ab3ac2d81b9b3721f097aa409a3191c3e90f8f3dd7"}, - {file = "coverage-7.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0b3d67d31383c4c68e19a88e28fc4c2e29517580f1b0ebec4a069d502ce1e0bf"}, - {file = "coverage-7.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:581f086833d24a22c89ae0fe2142cfaa1c92c930adf637ddf122d55083fb5a0f"}, - {file = "coverage-7.13.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0a3a30f0e257df382f5f9534d4ce3d4cf06eafaf5192beb1a7bd066cb10e78fb"}, - {file = "coverage-7.13.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:583221913fbc8f53b88c42e8dbb8fca1d0f2e597cb190ce45916662b8b9d9621"}, - {file = "coverage-7.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f5d9bd30756fff3e7216491a0d6d520c448d5124d3d8e8f56446d6412499e74"}, - {file = "coverage-7.13.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a23e5a1f8b982d56fa64f8e442e037f6ce29322f1f9e6c2344cd9e9f4407ee57"}, - {file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b01c22bc74a7fb44066aaf765224c0d933ddf1f5047d6cdfe4795504a4493f8"}, - {file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:898cce66d0836973f48dda4e3514d863d70142bdf6dfab932b9b6a90ea5b222d"}, - {file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3ab483ea0e251b5790c2aac03acde31bff0c736bf8a86829b89382b407cd1c3b"}, - {file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d84e91521c5e4cb6602fe11ece3e1de03b2760e14ae4fcf1a4b56fa3c801fcd"}, - {file = "coverage-7.13.0-cp312-cp312-win32.whl", hash = "sha256:193c3887285eec1dbdb3f2bd7fbc351d570ca9c02ca756c3afbc71b3c98af6ef"}, - {file = "coverage-7.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:4f3e223b2b2db5e0db0c2b97286aba0036ca000f06aca9b12112eaa9af3d92ae"}, - {file = "coverage-7.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:086cede306d96202e15a4b77ace8472e39d9f4e5f9fd92dd4fecdfb2313b2080"}, - {file = "coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf"}, - {file = "coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a"}, - {file = "coverage-7.13.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74"}, - {file = "coverage-7.13.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6"}, - {file = "coverage-7.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b"}, - {file = "coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232"}, - {file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971"}, - {file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d"}, - {file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137"}, - {file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511"}, - {file = "coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1"}, - {file = "coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a"}, - {file = "coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6"}, - {file = "coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a"}, - {file = "coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8"}, - {file = "coverage-7.13.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053"}, - {file = "coverage-7.13.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071"}, - {file = "coverage-7.13.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e"}, - {file = "coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493"}, - {file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0"}, - {file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e"}, - {file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c"}, - {file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e"}, - {file = "coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46"}, - {file = "coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39"}, - {file = "coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e"}, - {file = "coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256"}, - {file = "coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a"}, - {file = "coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9"}, - {file = "coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19"}, - {file = "coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be"}, - {file = "coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb"}, - {file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8"}, - {file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b"}, - {file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9"}, - {file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927"}, - {file = "coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f"}, - {file = "coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc"}, - {file = "coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b"}, - {file = "coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28"}, - {file = "coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe"}, - {file = "coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657"}, - {file = "coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff"}, - {file = "coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3"}, - {file = "coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b"}, - {file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d"}, - {file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e"}, - {file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940"}, - {file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2"}, - {file = "coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7"}, - {file = "coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc"}, - {file = "coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a"}, - {file = "coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904"}, - {file = "coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936"}, + {file = "coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a"}, + {file = "coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5"}, + {file = "coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17"}, + {file = "coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b"}, + {file = "coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87"}, + {file = "coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e"}, + {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e"}, + {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df"}, + {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0"}, + {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13"}, + {file = "coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b"}, + {file = "coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807"}, + {file = "coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59"}, + {file = "coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a"}, + {file = "coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699"}, + {file = "coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d"}, + {file = "coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e"}, + {file = "coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23"}, + {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab"}, + {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82"}, + {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2"}, + {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61"}, + {file = "coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14"}, + {file = "coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2"}, + {file = "coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a"}, + {file = "coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417"}, + {file = "coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973"}, + {file = "coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c"}, + {file = "coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7"}, + {file = "coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6"}, + {file = "coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59"}, + {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b"}, + {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a"}, + {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb"}, + {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1"}, + {file = "coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256"}, + {file = "coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba"}, + {file = "coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf"}, + {file = "coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d"}, + {file = "coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b"}, + {file = "coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e"}, + {file = "coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b"}, + {file = "coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49"}, + {file = "coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911"}, + {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0"}, + {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f"}, + {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c"}, + {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f"}, + {file = "coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698"}, + {file = "coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843"}, + {file = "coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546"}, + {file = "coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c"}, + {file = "coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15"}, + {file = "coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4"}, + {file = "coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0"}, + {file = "coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0"}, + {file = "coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65"}, + {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541"}, + {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6"}, + {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999"}, + {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2"}, + {file = "coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a"}, + {file = "coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb"}, + {file = "coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb"}, + {file = "coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520"}, + {file = "coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32"}, + {file = "coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f"}, + {file = "coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a"}, + {file = "coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360"}, + {file = "coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69"}, + {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14"}, + {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe"}, + {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e"}, + {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd"}, + {file = "coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2"}, + {file = "coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681"}, + {file = "coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880"}, + {file = "coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63"}, + {file = "coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2"}, + {file = "coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d"}, + {file = "coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0"}, + {file = "coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699"}, + {file = "coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9"}, + {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f"}, + {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1"}, + {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0"}, + {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399"}, + {file = "coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235"}, + {file = "coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d"}, + {file = "coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a"}, + {file = "coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3"}, + {file = "coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c"}, + {file = "coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396"}, + {file = "coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40"}, + {file = "coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594"}, + {file = "coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a"}, + {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b"}, + {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3"}, + {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0"}, + {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f"}, + {file = "coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431"}, + {file = "coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07"}, + {file = "coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260"}, + {file = "coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239"}, ] [package.dependencies] @@ -253,27 +265,27 @@ toml = ["tomli"] [[package]] name = "desktop-entry-lib" -version = "5.0" +version = "3.2" description = "A library for working with .desktop files" optional = false -python-versions = ">=3.10" +python-versions = ">=3.9" files = [ - {file = "desktop_entry_lib-5.0-py3-none-any.whl", hash = "sha256:e60a0c2c5e42492dbe5378e596b1de87d1b1c4dc74d1f41998a164ee27a1226f"}, - {file = "desktop_entry_lib-5.0.tar.gz", hash = "sha256:9a621bac1819fe21021356e41fec0ac096ed56e6eb5dcfe0639cd8654914b864"}, + {file = "desktop-entry-lib-3.2.tar.gz", hash = "sha256:12189249f86dde52d055ac28897ad1ed14ef965407a50fb3ad4ac6cb1a7e8cde"}, + {file = "desktop_entry_lib-3.2-py3-none-any.whl", hash = "sha256:600748b2aab2cafbb3ebc08b5c1ded024d66e0756868b6d26b5aff44d336c4b5"}, ] [package.extras] -xdg-desktop-portal = ["jeepney"] +test = ["pyfakefs", "pytest", "pytest-cov"] [[package]] name = "exceptiongroup" -version = "1.3.1" +version = "1.3.0" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" files = [ - {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, - {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, + {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, + {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, ] [package.dependencies] @@ -298,30 +310,15 @@ all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2 [[package]] name = "iniconfig" -version = "2.3.0" +version = "2.1.0" description = "brain-dead simple config-ini parsing" optional = false -python-versions = ">=3.10" +python-versions = ">=3.8" files = [ - {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, - {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, + {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, + {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, ] -[[package]] -name = "markdown" -version = "3.10" -description = "Python implementation of John Gruber's Markdown." -optional = false -python-versions = ">=3.10" -files = [ - {file = "markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c"}, - {file = "markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e"}, -] - -[package.extras] -docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"] -testing = ["coverage", "pyyaml"] - [[package]] name = "packaging" version = "25.0" @@ -380,57 +377,57 @@ tomli = {version = "*", markers = "python_version < \"3.11\""} [[package]] name = "pyside6" -version = "6.10.1" +version = "6.10.0" description = "Python bindings for the Qt cross-platform application and UI framework" optional = false -python-versions = "<3.15,>=3.9" +python-versions = "<3.14,>=3.9" files = [ - {file = "pyside6-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:d0e70dd0e126d01986f357c2a555722f9462cf8a942bf2ce180baf69f468e516"}, - {file = "pyside6-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4053bf51ba2c2cb20e1005edd469997976a02cec009f7c46356a0b65c137f1fa"}, - {file = "pyside6-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:7d3ca20a40139ca5324a7864f1d91cdf2ff237e11bd16354a42670f2a4eeb13c"}, - {file = "pyside6-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:9f89ff994f774420eaa38cec6422fddd5356611d8481774820befd6f3bb84c9e"}, - {file = "pyside6-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:9c5c1d94387d1a32a6fae25348097918ef413b87dfa3767c46f737c6d48ae437"}, + {file = "pyside6-6.10.0-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:c2cbc5dc2a164e3c7c51b3435e24203e90e5edd518c865466afccbd2e5872bb0"}, + {file = "pyside6-6.10.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ae8c3c8339cd7c3c9faa7cc5c52670dcc8662ccf4b63a6fed61c6345b90c4c01"}, + {file = "pyside6-6.10.0-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:9f402f883e640048fab246d36e298a5e16df9b18ba2e8c519877e472d3602820"}, + {file = "pyside6-6.10.0-cp39-abi3-win_amd64.whl", hash = "sha256:70a8bcc73ea8d6baab70bba311eac77b9a1d31f658d0b418e15eb6ea36c97e6f"}, + {file = "pyside6-6.10.0-cp39-abi3-win_arm64.whl", hash = "sha256:4b709bdeeb89d386059343a5a706fc185cee37b517bda44c7d6b64d5fdaf3339"}, ] [package.dependencies] -PySide6_Addons = "6.10.1" -PySide6_Essentials = "6.10.1" -shiboken6 = "6.10.1" +PySide6_Addons = "6.10.0" +PySide6_Essentials = "6.10.0" +shiboken6 = "6.10.0" [[package]] name = "pyside6-addons" -version = "6.10.1" +version = "6.10.0" description = "Python bindings for the Qt cross-platform application and UI framework (Addons)" optional = false -python-versions = "<3.15,>=3.9" +python-versions = "<3.14,>=3.9" files = [ - {file = "pyside6_addons-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:4d2b82bbf9b861134845803837011e5f9ac7d33661b216805273cf0c6d0f8e82"}, - {file = "pyside6_addons-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:330c229b58d30083a7b99ed22e118eb4f4126408429816a4044ccd0438ae81b4"}, - {file = "pyside6_addons-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:56864b5fecd6924187a2d0f7e98d968ed72b6cc267caa5b294cd7e88fff4e54c"}, - {file = "pyside6_addons-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:b6e249d15407dd33d6a2ffabd9dc6d7a8ab8c95d05f16a71dad4d07781c76341"}, - {file = "pyside6_addons-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:0de303c0447326cdc6c8be5ab066ef581e2d0baf22560c9362d41b8304fdf2db"}, + {file = "pyside6_addons-6.10.0-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:88e61e21ee4643cdd9efb39ec52f4dc1ac74c0b45c5b7fa453d03c094f0a8a5c"}, + {file = "pyside6_addons-6.10.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:08d4ed46c4c9a353a9eb84134678f8fdd4ce17fb8cce2b3686172a7575025464"}, + {file = "pyside6_addons-6.10.0-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:15d32229d681be0bba1b936c4a300da43d01e1917ada5b57f9e03a387c245ab0"}, + {file = "pyside6_addons-6.10.0-cp39-abi3-win_amd64.whl", hash = "sha256:99d93a32c17c5f6d797c3b90dd58f2a8bae13abde81e85802c34ceafaee11859"}, + {file = "pyside6_addons-6.10.0-cp39-abi3-win_arm64.whl", hash = "sha256:92536427413f3b6557cf53f1a515cd766725ee46a170aff57ad2ff1dfce0ffb1"}, ] [package.dependencies] -PySide6_Essentials = "6.10.1" -shiboken6 = "6.10.1" +PySide6_Essentials = "6.10.0" +shiboken6 = "6.10.0" [[package]] name = "pyside6-essentials" -version = "6.10.1" +version = "6.10.0" description = "Python bindings for the Qt cross-platform application and UI framework (Essentials)" optional = false -python-versions = "<3.15,>=3.9" +python-versions = "<3.14,>=3.9" files = [ - {file = "pyside6_essentials-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:cd224aff3bb26ff1fca32c050e1c4d0bd9f951a96219d40d5f3d0128485b0bbe"}, - {file = "pyside6_essentials-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e9ccbfb58c03911a0bce1f2198605b02d4b5ca6276bfc0cbcf7c6f6393ffb856"}, - {file = "pyside6_essentials-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:ec8617c9b143b0c19ba1cc5a7e98c538e4143795480cb152aee47802c18dc5d2"}, - {file = "pyside6_essentials-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:9555a48e8f0acf63fc6a23c250808db841b28a66ed6ad89ee0e4df7628752674"}, - {file = "pyside6_essentials-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:4d1d248644f1778f8ddae5da714ca0f5a150a5e6f602af2765a7d21b876da05c"}, + {file = "pyside6_essentials-6.10.0-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:003e871effe1f3e5b876bde715c15a780d876682005a6e989d89f48b8b93e93a"}, + {file = "pyside6_essentials-6.10.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1d5e013a8698e37ab8ef360e6960794eb5ef20832a8d562e649b8c5a0574b2d8"}, + {file = "pyside6_essentials-6.10.0-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:b1dd0864f0577a448fb44426b91cafff7ee7cccd1782ba66491e1c668033f998"}, + {file = "pyside6_essentials-6.10.0-cp39-abi3-win_amd64.whl", hash = "sha256:fc167eb211dd1580e20ba90d299e74898e7a5a1306d832421e879641fc03b6fe"}, + {file = "pyside6_essentials-6.10.0-cp39-abi3-win_arm64.whl", hash = "sha256:6dd0936394cb14da2fd8e869899f5e0925a738b1c8d74c2f22503720ea363fb1"}, ] [package.dependencies] -shiboken6 = "6.10.1" +shiboken6 = "6.10.0" [[package]] name = "pytest" @@ -534,153 +531,147 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "shiboken6" -version = "6.10.1" +version = "6.10.0" description = "Python/C++ bindings helper module" optional = false -python-versions = "<3.15,>=3.9" +python-versions = "<3.14,>=3.9" files = [ - {file = "shiboken6-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:9f2990f5b61b0b68ecadcd896ab4441f2cb097eef7797ecc40584107d9850d71"}, - {file = "shiboken6-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f4221a52dfb81f24a0d20cc4f8981cb6edd810d5a9fb28287ce10d342573a0e4"}, - {file = "shiboken6-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:c095b00f4d6bf578c0b2464bb4e264b351a99345374478570f69e2e679a2a1d0"}, - {file = "shiboken6-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:c1601d3cda1fa32779b141663873741b54e797cb0328458d7466281f117b0a4e"}, - {file = "shiboken6-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:5cf800917008587b551005a45add2d485cca66f5f7ecd5b320e9954e40448cc9"}, + {file = "shiboken6-6.10.0-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:7a5f5f400ebfb3a13616030815708289c2154e701a60b9db7833b843e0bee543"}, + {file = "shiboken6-6.10.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e612734da515d683696980107cdc0396a3ae0f07b059f0f422ec8a2333810234"}, + {file = "shiboken6-6.10.0-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:b01377e68d14132360efb0f4b7233006d26aa8ae9bb50edf00960c2a5f52d148"}, + {file = "shiboken6-6.10.0-cp39-abi3-win_amd64.whl", hash = "sha256:0bc5631c1bf150cbef768a17f5f289aae1cb4db6c6b0c19b2421394e27783717"}, + {file = "shiboken6-6.10.0-cp39-abi3-win_arm64.whl", hash = "sha256:dfc4beab5fec7dbbebbb418f3bf99af865d6953aa0795435563d4cbb82093b61"}, ] [[package]] name = "sqlcipher3-wheels" -version = "0.5.6" +version = "0.5.5.post0" description = "DB-API 2.0 interface for SQLCipher 3.x" optional = false python-versions = "*" files = [ - {file = "sqlcipher3_wheels-0.5.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e16c8caf59e86589fb5f52253420db07121f1f96e2a12e244f6fdcaf8b946530"}, - {file = "sqlcipher3_wheels-0.5.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:337f2e059114729dd1529ee356c98e2aa06440d6a9772917514a3bda0647c61c"}, - {file = "sqlcipher3_wheels-0.5.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f6bd900658446e1cdeebda0760adb9a89f55888b460623db88b100845cb51bc2"}, - {file = "sqlcipher3_wheels-0.5.6-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:dc6fcca569858145cb5ba3c878997d1788973e36f689090178f807b9a44d9ca6"}, - {file = "sqlcipher3_wheels-0.5.6-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:eef50cc39554ad1fb82faa33d25c7f3cb11e2f7087b41109bc169db2c942f0c7"}, - {file = "sqlcipher3_wheels-0.5.6-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:0fc36fc67f639a0e03cf6f7c6a5d1bc5cdd8005e8e07da3b21c54d4d81ed353b"}, - {file = "sqlcipher3_wheels-0.5.6-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:53d0b861668d6847c7cc0dc7b443263b95a5cd211bcc326a457bd3122ebbb5a0"}, - {file = "sqlcipher3_wheels-0.5.6-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:10aef293397a4ab25d8346ba5f96181214ab9c6a8836d83320cf23a2ad773a2c"}, - {file = "sqlcipher3_wheels-0.5.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1105e7edba36a29625a824bff0eca3685c1cf6e391182b85a9a73b4b1604eef3"}, - {file = "sqlcipher3_wheels-0.5.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5db9b4035e42a27672abbe75120908c74a235a496cd92b4c685fda1e95e9b19c"}, - {file = "sqlcipher3_wheels-0.5.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f9e3fb5e96c5067a8cfd7b2fa7d939e529e30439058bbc15d0e9adca5e4cff1b"}, - {file = "sqlcipher3_wheels-0.5.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6f3c1a8a4a2c04225f5159cf7f1c315101a89271afbaef4205c6fc50766c5535"}, - {file = "sqlcipher3_wheels-0.5.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fc0504a1dbe6d478614ef55eb80d0c02ead24bc91f34b41c07d404452389f42d"}, - {file = "sqlcipher3_wheels-0.5.6-cp310-cp310-win32.whl", hash = "sha256:05ef2b35f176e3b29092ec9aa03b09f4803feddbabdc2174e7ccc608758f2beb"}, - {file = "sqlcipher3_wheels-0.5.6-cp310-cp310-win_amd64.whl", hash = "sha256:0f6873e4badf64eb8c5771c9e8a726df46ac663bc8051dfefb51fe2a46358b37"}, - {file = "sqlcipher3_wheels-0.5.6-cp310-cp310-win_arm64.whl", hash = "sha256:9fd30c1cffa10f63f504a33494564efc0e0a475bbf069487016a9d2462d115e5"}, - {file = "sqlcipher3_wheels-0.5.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a6c511bacd40ba769368b1abbf97fbefb285f525e6d2a399a704c22ba2aae37f"}, - {file = "sqlcipher3_wheels-0.5.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa25610cda2b2a1b1cefddbd93488e939cf0059480f2fda5a8704acddd0e8935"}, - {file = "sqlcipher3_wheels-0.5.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5a5258fb99e99b6fda6f011a0a4094ff99fe2e9b9ac7ce81cf646e0e779829a3"}, - {file = "sqlcipher3_wheels-0.5.6-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:459836d52904fa006bf36e2144959bd21577c32947fdd173db50b037108a8620"}, - {file = "sqlcipher3_wheels-0.5.6-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:5b36f9949f4d35c72f0626aaac109b17688c1d6a9a6e11de2538b4cfc32cfad0"}, - {file = "sqlcipher3_wheels-0.5.6-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:87301b545556a1811780bb6fc6480ab1f2640d1d5b5e5e33ed404559ae383647"}, - {file = "sqlcipher3_wheels-0.5.6-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:fcc4705b5b7bd3508d08a6389a45e14591071a3e575c2864c9c1c615df89e0da"}, - {file = "sqlcipher3_wheels-0.5.6-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:0a231eb677a8246c47e423c710198631850c0a090e8f02a7fb1ad266ba517c56"}, - {file = "sqlcipher3_wheels-0.5.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:71ef871c65ad7c61048acb4f57da29bc0d5e35874183006222c229b5f1f64c73"}, - {file = "sqlcipher3_wheels-0.5.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c3480298c9bc4117207535636fe74b01b4860ecd74a028c73b42f5f0ddaa8661"}, - {file = "sqlcipher3_wheels-0.5.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d48cf218ed13f17e3037564f08fba7ddf2c260dac7993e3d4ac58ee30483f115"}, - {file = "sqlcipher3_wheels-0.5.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ff57a80904b9bd55e18774cb59bffacad06e196298381ee576ce683d1c09b032"}, - {file = "sqlcipher3_wheels-0.5.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:50978685717cd9293ff5508c192695a894879f9faed5142d0e8d7b63310f87c2"}, - {file = "sqlcipher3_wheels-0.5.6-cp311-cp311-win32.whl", hash = "sha256:24207dbb699ca68fc5fc7248385fdf33a92fb1e17a6ea88d3cf2345a18fb29ff"}, - {file = "sqlcipher3_wheels-0.5.6-cp311-cp311-win_amd64.whl", hash = "sha256:40b1f8188a0aa5bbec354a12561b014b43a6a0d0a0d230a8a9378ed7b826b0ec"}, - {file = "sqlcipher3_wheels-0.5.6-cp311-cp311-win_arm64.whl", hash = "sha256:107ef02bbd0f2ffb39a564c14ebf3bedfa4569949a0d72ec8e106f754d715b7c"}, - {file = "sqlcipher3_wheels-0.5.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:59a572b18d1ef8318e9f583a7b3e1a67b4b04ed4b783c3f29fa806635274d12a"}, - {file = "sqlcipher3_wheels-0.5.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:32dfb8903b24db5879b1f922114f650bc6a15df9d071c55eefeb6937e13b2d20"}, - {file = "sqlcipher3_wheels-0.5.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f5770257736c43cbf910a22f74c1490ef1ecde0432e475904f038e64ffdacb0"}, - {file = "sqlcipher3_wheels-0.5.6-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c33f99ddfe08c0f34807046800e510316b8bac2974b3c5fb9ecb1ee25c391ac8"}, - {file = "sqlcipher3_wheels-0.5.6-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:97d4c000deeb72c2421f555f3e55a8c161ddfb0499caabf60df2bfde6460a5fc"}, - {file = "sqlcipher3_wheels-0.5.6-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:67d9889028b4adfcaecd32e1e60330e1764c209ad12438f0eec2a5145ebf4a2d"}, - {file = "sqlcipher3_wheels-0.5.6-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:00cf178b15da486ab43ee2bed41edb1b393c5cfe2a48cae68893a2b31260dbd3"}, - {file = "sqlcipher3_wheels-0.5.6-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:95bfa4c5ffdd72d9d8676c913d585b7885a42824824cf1d9e93d3669f01492dd"}, - {file = "sqlcipher3_wheels-0.5.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:030ab50a8f4153cfe8dd5c98724909b210243af2350b9c79914838905a99518e"}, - {file = "sqlcipher3_wheels-0.5.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5dc3c3d9deea654f8ea9c1dbc7bc90561331e4da9c7055381fac6498ca7267a3"}, - {file = "sqlcipher3_wheels-0.5.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8cc986e8aa89e5a4a30b4eb8fd841d913a4e22ada99ec42be83f69bde3d86a31"}, - {file = "sqlcipher3_wheels-0.5.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a41f0d30fa63d8db915566ec6987e68f064d96052cd6492ed8384b3e4807e60b"}, - {file = "sqlcipher3_wheels-0.5.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f32fefe8a41e68334c545465813782fd45ef5cfe1082d012d95514c8a78e8015"}, - {file = "sqlcipher3_wheels-0.5.6-cp312-cp312-win32.whl", hash = "sha256:ac2332f44758794a2fa19c77b824853e2a57ce5c27cc71c61066a52845be22d0"}, - {file = "sqlcipher3_wheels-0.5.6-cp312-cp312-win_amd64.whl", hash = "sha256:6f016ba5a2a531938f332a234865dfc25d3a69abc169c3bf1d5c06c3c3f24601"}, - {file = "sqlcipher3_wheels-0.5.6-cp312-cp312-win_arm64.whl", hash = "sha256:101ce0f7403801b6988d1f6c94244900e0f6c5378666e0ffd74b300687a6f9ef"}, - {file = "sqlcipher3_wheels-0.5.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:94527fa3994c0fa1275c23d9fbb02512aacc675f1e45f566c660f4f9d5376e75"}, - {file = "sqlcipher3_wheels-0.5.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0920a4b24362522ba83b36a47495d174221361213207191c325749a621fabeca"}, - {file = "sqlcipher3_wheels-0.5.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5061b07b121ebd76aa697755b1b8f642cc3a27a0f6d392180ab249b35f1c2394"}, - {file = "sqlcipher3_wheels-0.5.6-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:79de8511bb1fec62128e1b366cdc0cbd2ad1d725f3e29f9c91e96946a3c67945"}, - {file = "sqlcipher3_wheels-0.5.6-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:4b92c2f35bb8153cc20bcfc651536f51cc1194403782c542a852497ac789cbe2"}, - {file = "sqlcipher3_wheels-0.5.6-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:2d55211e3d2addff8a2df7335927d7fe6d75aa9ed12b396a22a5a0bfe2773ed9"}, - {file = "sqlcipher3_wheels-0.5.6-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:8cb31de5d67799cc2bba92f23adc10281d66c2c16ca6418b94d80500a164aa60"}, - {file = "sqlcipher3_wheels-0.5.6-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:123796de3e471db5ed8b4ee4f97ec562ad38347ad678dad71133eade280202e0"}, - {file = "sqlcipher3_wheels-0.5.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b6d34fabacfad4f301a22b5d8466d7ee3481f735bdb327d8756f04c81d3516c4"}, - {file = "sqlcipher3_wheels-0.5.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:91b02fc765485c5b65f2a3eacfd2e16059253e007d0b5a5f24bba5fcea9032dd"}, - {file = "sqlcipher3_wheels-0.5.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:13db7f23c553ffdd35f6e3b26415bdb9f100dcf89038873965caef769e8f1af5"}, - {file = "sqlcipher3_wheels-0.5.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:4ba79a81cd591d32a3a225e3e9b50a9871324d0e414fb6d0866049d8820e4e46"}, - {file = "sqlcipher3_wheels-0.5.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f97be07997681ca90fb339d5411fcb957bd7cbe810389404baed207cb366badd"}, - {file = "sqlcipher3_wheels-0.5.6-cp313-cp313-win32.whl", hash = "sha256:9e56e0a7aa778da3d46323fc1233da5dcede795a6c7fe4c11980fec0ce8c3fe3"}, - {file = "sqlcipher3_wheels-0.5.6-cp313-cp313-win_amd64.whl", hash = "sha256:744845e4aa3cc614590f967aa1d38cc5d549177a2a83ed68c1821b5fb0505f8a"}, - {file = "sqlcipher3_wheels-0.5.6-cp313-cp313-win_arm64.whl", hash = "sha256:c92de0b940533ca3a5b43a45d0768e0698b6ca95020b2fd47ec269b6bfc228d1"}, - {file = "sqlcipher3_wheels-0.5.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:a3f558df797aabf51680b3fbce48c4b3df89c36ad7fcaa3886b2ed8057aa2786"}, - {file = "sqlcipher3_wheels-0.5.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7e216586720663960c82f046c495ef6d828e8e95c8fcf4c767b555fb9b8feead"}, - {file = "sqlcipher3_wheels-0.5.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3e4ef70d3af8ebe6ababe8eff93b8bd4ad288d0a38ab29a2420c91d636fbfe14"}, - {file = "sqlcipher3_wheels-0.5.6-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:11e34aac6cb7e29d23e339c5de9e87700ddf09886e104640578b5afb566a2c50"}, - {file = "sqlcipher3_wheels-0.5.6-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:79e220312a075546e6be0a6062dda6315857b1478d78f97eb352f1383dde8ce2"}, - {file = "sqlcipher3_wheels-0.5.6-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:b953af7b57867bcffeeab59681921671615ae4b42fd0a9234ad0be7e0e43dfd4"}, - {file = "sqlcipher3_wheels-0.5.6-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:130ac318dbcb3a51a4377b0bf3e450c6c21d508a8b00d2d9d4b3ee6a46ab3595"}, - {file = "sqlcipher3_wheels-0.5.6-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:5154c8022e58722987522ddce30f19fb69d6f8f6314959100d9f37c3dc5cba5b"}, - {file = "sqlcipher3_wheels-0.5.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f91d1f5b7b927aa00a8d83724c58875d9d0e47bd81ca40445090ab521b5fa"}, - {file = "sqlcipher3_wheels-0.5.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e1c140bfa6b0a7e08f414f2a9f8f529f7d8c4cfa8386ce588e6c747c4ccc6615"}, - {file = "sqlcipher3_wheels-0.5.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:18fc56dfb32c6ce370d929897205027f78275c32446d6b1be712d462789ae8c2"}, - {file = "sqlcipher3_wheels-0.5.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c03ec5e058fbf3fd94ecd8e0448834e8e7f46418eaec5fe5c7a0982c6e62c13f"}, - {file = "sqlcipher3_wheels-0.5.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08756c1b25aebabb25a55dfe6f323876caea0c69511e34553807ae1d7ab843dd"}, - {file = "sqlcipher3_wheels-0.5.6-cp314-cp314-win32.whl", hash = "sha256:bdbc58d224d27c002aed8a6361b43f3651943ecbfac69cd2674bbe681cf83790"}, - {file = "sqlcipher3_wheels-0.5.6-cp314-cp314-win_amd64.whl", hash = "sha256:dcc313f4519922c1ec3406b010d53f700750c1cf5331b9633a3c8b196307e852"}, - {file = "sqlcipher3_wheels-0.5.6-cp314-cp314-win_arm64.whl", hash = "sha256:dc1f0c77cc0395680176913a1d634a4014a1ebf02e7a7b2ac03a180b44241842"}, - {file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fc30e82d2b8f139ac1ab81a3b3d9a59da8e3ce3b1e753285727480667efd5417"}, - {file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f11d1d2c41141dd95f7d45f03dbe9f69a6427463e69db50609d83c0cd29980b5"}, - {file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:92beff11fd9683941de7b47b8fc280e834b135ba7966d139b0ce2159b551ebad"}, - {file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:3060403647df7d44844c2808a384e4c4cf4a2a1b65e509a8016aca971c08ad39"}, - {file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:9380de7e8fc952f376c9dae9ba1cdbb6a24ff5e41fd8f3b3cf39f1e305ed3248"}, - {file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:9a26be381b0fb1c8d4fcdfd48182c78217ae9458513e4fe51b5045d4f94d41cb"}, - {file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-manylinux_2_28_s390x.whl", hash = "sha256:c3be08f8d81372a6d084062f969f88be0b942ac449b0ac01825b853c12705421"}, - {file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c5bd4abbebc15f8a2a9a653500cd1abeb3aac13887fcc83de31ca40fce32e3a2"}, - {file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4bb3c2c8e9a1e16455b989b2c7598b8053029bcbb519dc22601fa82bc8896f89"}, - {file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:aac8ca9d2b4e18637e61ea1d8193500a1186f0b113b9224dc74186190f41c8e7"}, - {file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3f237a41c3f08e69f2532aec29a2589097baa73886164537d90c744d3d2eb3b3"}, - {file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:e6e59c3e0301cb04351b1cb12231aaadb40f56f779fb50a7857c6b4ed4c57297"}, - {file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba2296a608081f4474f4447658a1e032d0b5506153baf68233471afde1463da9"}, - {file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-win32.whl", hash = "sha256:8c8edfbd38a49ebbec2d1d56a000a499da2ac80b00488c156a1e0b8a7b8c10c6"}, - {file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-win_amd64.whl", hash = "sha256:21df85bc14d5d86225c1e7466ff65cbcc10f0d1d4f466823b4534c4c0564554c"}, - {file = "sqlcipher3_wheels-0.5.6-cp314-cp314t-win_arm64.whl", hash = "sha256:64df3e807fb0e6d89c1e90ce7c900bb82b695c474e1a0945a5f92862cac8b63d"}, - {file = "sqlcipher3_wheels-0.5.6-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:3784b22a29e4a675b456ca6ff1841d61e0eb97a28d0ba23d3d8cb5fe6da88238"}, - {file = "sqlcipher3_wheels-0.5.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e27881be24f03d8a67a6db763f5671aaa05205de2380b1793b5e20bdabe49fba"}, - {file = "sqlcipher3_wheels-0.5.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:667b6eec50ed03111676a0f4565be133643c9ad8bc88e6eea1c96b2af590c417"}, - {file = "sqlcipher3_wheels-0.5.6-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:4eaaa5cf77b125e05908b1200681e2988b1a6a307c7e677967053a1e4b07fba5"}, - {file = "sqlcipher3_wheels-0.5.6-cp38-cp38-manylinux_2_28_i686.whl", hash = "sha256:4ead5b8f2607718548c8571e4a89fe735dd53443a2b5e42d8147eecd11b0d94b"}, - {file = "sqlcipher3_wheels-0.5.6-cp38-cp38-manylinux_2_28_ppc64le.whl", hash = "sha256:d82a8a7b478d23368320ad185533d063ec14d11a1d188f07ace513a66bfa9580"}, - {file = "sqlcipher3_wheels-0.5.6-cp38-cp38-manylinux_2_28_s390x.whl", hash = "sha256:39d871ee8c13d9b0326b24a02e5af21a7b1c8fb5e6f6f4ec62b935392202ec69"}, - {file = "sqlcipher3_wheels-0.5.6-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:5a8737683621c2917a4ee9ff774e597a368c5b3d23f08ae53897d6bd1f8bfc0e"}, - {file = "sqlcipher3_wheels-0.5.6-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:08b6922d5020384fa641c8dc416f6f2b143110c86dcf3aae086e7ce15b192eae"}, - {file = "sqlcipher3_wheels-0.5.6-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:f9dcc7f830ec56c090884a83be265c51c0a4fd60bb033b000c69c3bee08d77d8"}, - {file = "sqlcipher3_wheels-0.5.6-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:0848f628b1528dd6a19a36679d8cde4b6f1f8d288757ba2e3df5578b79d79e90"}, - {file = "sqlcipher3_wheels-0.5.6-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:1476bb15586ce27ea5fae7c54469b2be4efe51ca9cefa20871a6c394a18892cc"}, - {file = "sqlcipher3_wheels-0.5.6-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:de17d373d9e7807236013950f598bf59b9ed7c375938fdb95378a7114e55ff95"}, - {file = "sqlcipher3_wheels-0.5.6-cp38-cp38-win32.whl", hash = "sha256:02fa9e7f98a8e9be871219014b9ac015ba630b51615d90a2c06d45547a4b0cf1"}, - {file = "sqlcipher3_wheels-0.5.6-cp38-cp38-win_amd64.whl", hash = "sha256:6b2d7daab225c578aec8109fde99624f281b4ccdc6c53c8cd8feb86d8e7d3cf2"}, - {file = "sqlcipher3_wheels-0.5.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:abef5e28b4d1ca518291a8ca27af1cf9e4d68dd4a264d83874ec4d0a69589395"}, - {file = "sqlcipher3_wheels-0.5.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cd4c12a5a60cbd533ba4a3b4131d23302283ba597739c7867066b4efefe178db"}, - {file = "sqlcipher3_wheels-0.5.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4b7672837f1b9a6a67e375b743d74371d0428ead79ff367591145d06f3711c96"}, - {file = "sqlcipher3_wheels-0.5.6-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:61c33e2697b0d91f3cbe806104e1d5b93961d3ab55ba55ee53bb36efe83c9933"}, - {file = "sqlcipher3_wheels-0.5.6-cp39-cp39-manylinux_2_28_i686.whl", hash = "sha256:2e6eb09782dd719a1bb34af6e5ef25e5713c1f806231b472fcf64eb9288957af"}, - {file = "sqlcipher3_wheels-0.5.6-cp39-cp39-manylinux_2_28_ppc64le.whl", hash = "sha256:6469b756ced0293e74806db2f114e5307cd4b05a559e986d3cc0b2eeb1eb8153"}, - {file = "sqlcipher3_wheels-0.5.6-cp39-cp39-manylinux_2_28_s390x.whl", hash = "sha256:b6492f9bcb9296ac2179b5c9f7e7f329449b580836c0e8e5cfc2f3fe9af3486c"}, - {file = "sqlcipher3_wheels-0.5.6-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:2e4968d98917309463f02e4a48abebd95ed3d37968346f2693ed8a08e2fe9794"}, - {file = "sqlcipher3_wheels-0.5.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:50214729697a1ee9e7603ba62b8ea46d78903ae1332caaa94fbaedde113944b7"}, - {file = "sqlcipher3_wheels-0.5.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1ec9fd1dd5774d665903b8ba2e3e4f8ed72879dc42f6e9b2815040f0cb2d8ccd"}, - {file = "sqlcipher3_wheels-0.5.6-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ced8ab30d205c8b6225b5703885576e629266767b091158731ec76c8c490bef4"}, - {file = "sqlcipher3_wheels-0.5.6-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:3c7242a267dd802fee273084a5707a95d02df4102afbea133c8f716234c7edcc"}, - {file = "sqlcipher3_wheels-0.5.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7a6c239d15085af4b0f3433fa274c1fc37369509b99a7c035a359d5142a0536d"}, - {file = "sqlcipher3_wheels-0.5.6-cp39-cp39-win32.whl", hash = "sha256:cc29963df04a73d8420a4d023ba016c9013d86378969d8a11fe2148477282936"}, - {file = "sqlcipher3_wheels-0.5.6-cp39-cp39-win_amd64.whl", hash = "sha256:38cc7bb3a371c4a5fe7f4236a409e64f1286796d780833243f9e15ef852f159d"}, - {file = "sqlcipher3_wheels-0.5.6-cp39-cp39-win_arm64.whl", hash = "sha256:186e49af3ddb98d260b95d436eaf58f2125712c268c8475627129c1f80a68164"}, - {file = "sqlcipher3_wheels-0.5.6.tar.gz", hash = "sha256:1d232c14be44db95a7f3018433cae01ecd18803fa2468fce3cc45ebd5e034942"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:836cff85673ab9bdfe0f3e2bc38aefddb5f3a4c0de397b92f83546bb94ea38aa"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a3fde9076a8810d19044f65fdfeeee5a9d044176ce91adc2258c8b18cb945474"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ad3ccb27f3fc9260b1bcebfd33fc5af1c2a1bf6a50e8e1bf7991d492458b438"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94bb8ab8cf7ae3dc0d51dcb75bf242ae4bd2f18549bfc975fd696c181e9ea8ca"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:df0bf0a169b480615ea2021e7266e1154990762216d1fd8105b93d1fee336f49"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79cc1af145345e9bd625c961e4efc8fc6c6eefcaec90fbcf1c6b981492c08031"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0d8b9f1c6d283acc5a0da16574c0f7690ba5b14cb5935f3078ccf8404a530075"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:952a23069a149a192a5eb8a9e552772b38c012825238175bc810f445a3aa8000"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:24f1a57a4aa18d9ecd38cfce69dd06e58cfb521151a8316e18183e603e7108f4"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6568c64adc55f9882ba36c11a446810bd5d4c03796aab8ecb9024f3bca9eb2cd"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:26c2b58d2f2a9dd23ad4c310fb6c0f0c82ca4f36a0d4177a70f0efeb332798ee"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:46827ffc7e705c5ecdf23ec69f56dd55b20857dc3c3c4893e360de8a38b4e708"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4033bbe2f0342936736ce7b8b2626f532509315576d5376764b410deae181cad"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-win32.whl", hash = "sha256:bfb26dbba945860427bd3f82c132e6d2ef409baa062d315b952dd5a930b25870"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-win_amd64.whl", hash = "sha256:168270b8fb295314aa4ee9f74435ceee42207bd16fe908f646a829b3a9daedad"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp310-cp310-win_arm64.whl", hash = "sha256:1f1bb2c4c6defa812eb0238055a283cf3c2f400e956a57c25cf65cbdbac6783f"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:55d557376a90f14baf0f35e917f8644c3a8cf48897947fcd7ecf51d490dd689f"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0f1739264a971901088fe1670befb8a8a707543186c8eecc58158ca287e309b2"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:920e19345b6c5335b61e9fbed2625f96dbc3b0269ab5120baeae2c9288f0be01"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6462734f6d0703f863f5968419d229de75bbf2a829f762bfb257b6df2355f977"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c397305b65612da76f254c692ff866571aa98fd3817ed0e40fce6d568d704966"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cf3467fe834075b58215c50f9db7355ef86a73d256ac8ba5fffb8c946741a5dc"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a76d783c095a3c95185757c418e3bad3eab69cbf986970d422cce5431e84d7f5"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bf8d78895ee0f04dc525942a1f40796fa7c3d7d7fb36c987f55c243ce34192d"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d583a10dbe9a1752968788c2d6438461ec7068608ceaa72e6468d80727c3152e"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8c3156b39bb8f24dfbe17a49126d8fa404b00c01d7aa84e64a2293db1dae1a38"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:15c3cf77b31973aa008174518fa439d8620a093964c2d9edcb8d23d543345839"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f5743db0f3492359c2ab3a56b6bed00ecba193f2c75c74e8e3d78a45f1eb7c95"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cc40a213a3633f19c96432304a16f0cff7c4aeca1a3d2042d4be36e576e64a70"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-win32.whl", hash = "sha256:433456ce962ae50887d6428d55bad46e5748a2cdd3d036180eb0bcdbe8bae9f9"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-win_amd64.whl", hash = "sha256:ca4332b1890cc4f80587be8bd529e20475bd3291e07f11115b1fc773947b264a"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp311-cp311-win_arm64.whl", hash = "sha256:a4634300cb2440baf17a78d6481d10902de4a2a6518f83a5ab2fe081e6b20b42"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bc8df43c11d767c6aac5cc300c1957356a9fd1b25f1946891003cf20a0146241"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:797653f08ecffcef2948dfd907fb20dab402d9efde6217c96befafc236e96c5b"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dca428fde0d1a522473f766465324d6539d324f219f4f7c159a3eb0d4f9983c5"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48e97922240a04b44637eabf39f86d243fe61fe7db1bd2ad219eb4053158f263"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d8d3a366e52a6732b1ccff14f9ca77ecbee53abfce87c417bf05d4301484584f"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dce28a2431260251d7acf253ea1950983e48dfec64245126b39a770d5a88f507"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea64cce27152cae453c353038336bda0dc1f885e5e8e30b5cd28b8c9b498bbeb"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02d9e6120a496f083c525efc34408d4f2ca282da05bebcc967a0aa1e12a0d6ca"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:875cfc61bbf694b8327c2485e5ed40573e8b715f4e583502f12c51c8d5a92dd5"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0e9ed3ff9c00ba3888f8dbc0c7c84377ef66f21c5f4ac373fc690dcf5e9bd594"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:08ad6d767502429e497b6d03b5ae11e43e896d36f05ac8e60c12d8f124378bc1"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ecdefdcf7ab8cb14b3147a59af83e8e3e5e3bed46fc43ab86a657f5c306a83d2"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:75ffe5677407bf20a32486eb6facfbb07a353ce7c9aecc9fefd4e9d3275605d7"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-win32.whl", hash = "sha256:97b6c6556b430b5c0dff53e8f709f90ba53294c2a3958a8c38f573c6dbf467d9"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-win_amd64.whl", hash = "sha256:248cae211991f1ffb3a885a1223e62abee70c6c208fc2224a8dbf73d4e825baa"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp312-cp312-win_arm64.whl", hash = "sha256:5a49fc3a859a53fd025dc2fa08410292d267018897fc63198de6c92860fa8be7"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2f798e1591fa5ba14d9da08a54f18e7000fd74973cde12eb862a3928a69b7996"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:369011b8dc68741313a8b77bb68a70b76052390eaf819e4cd6e13d0acbea602d"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5658990462a728c1f4b472d23c1f7f577eb2bced5bbbf7c2b45158b8340484bd"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:907a166e4e563da67fe22c480244459512e32d3e00853b3f1e6fdb9da6aa2da6"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0ba972405e9f16042e37cbcb4fef47248339c8410847390d41268bd45dc3f6ca"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b5f1b380fe3b869f701f9d2a8c09e9edfeec261573c8bb009a3336717260d65"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e2978c9d964ad643b0bc61e19d8d608a515ff270e9a2f1f6c5aeb8ad56255def"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:29457feb1516a2542aa7676e6d03bf913191690bf1ed6c82353782a380388508"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:021af568414741a45bfca41d682da64916a7873498a31d896cc34ad540939c6b"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7471d12eef489eea60cc3806bae0690f6d0733f7aea371a3ad5c5642f3bc04a9"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0f5faf683db9ade192a870e28b1eeeec2eb0aeca18e88fa52195a4639974c7cb"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:64fe6bac67d2b807b91102eef41d7f389e008ded80575ba597b454e05f9522e5"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:63ef1e23eb9729e79783e2ab4b19f64276c155ba0a85ba1eeb21e248c6ce0956"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-win32.whl", hash = "sha256:4eafde00138dd3753085b4f5eab0811247207b699de862589f886a94ad3628a4"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-win_amd64.whl", hash = "sha256:909864f275460646d0bf5475dc42e9c2cadd79cd40805ea32fe9a69300595301"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp313-cp313-win_arm64.whl", hash = "sha256:a831846cc6b01d7f99576efbf797b61a269dffa6885f530b6957573ce1a24f10"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:aaad03f3eb099401306fead806908c85b923064a9da7a99c33a72c3b7c9143bf"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74d4b4cde184d9e354152fd1867bcbaee468529865703ad863840a0ce4eb60cd"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ac3ad3cf9e1d0f08e8d22a65115368f2b22b9e96403fa644e146e1221c65c454"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45c4d3dca4acdbc5543bb00aee1e0715db797aa2819db5b7ca3feed3ab3366ff"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cf7026851ea60d63c1a88f62439da78b68bfbfec192c781255e3cfb34b6efc12"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7ae4a83678c41c2cdbf3c2b18fc46be32225260c7b4807087bdb43793ee90fa"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-musllinux_1_2_aarch64.whl", hash = "sha256:678c67d60b35eced29777fe9398b6e6a6638156f143c80662a0c7c99ce115be7"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-musllinux_1_2_i686.whl", hash = "sha256:9830af5aef2c17686d6e7c78c20b92c7b57c9d7921a03e4c549b48fe0e98c5c0"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-musllinux_1_2_ppc64le.whl", hash = "sha256:08651e17c868a6a22124b6ab71e939a5bb4737e0535f381ce35077dc8116c4b3"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-musllinux_1_2_s390x.whl", hash = "sha256:b58b4c944d7ce20cd7b426ae8834433b5b1152391960960b778b37803f0ffc1c"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:2b38818468ddb0c8fc4b172031d65ced3be22ba82360c45909a0546b2857d3e4"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-win32.whl", hash = "sha256:91d1f2284d13b68f213d05b51cd6242f4cfa065d291af6f353f9cbedd28d8c0d"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp36-cp36m-win_amd64.whl", hash = "sha256:a5c724f95366ba7a2895147b0690609b6774384fa8621aa46a66cf332e4b612f"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e37b33263fad4accdba45c8566566d45fc01f47fd4afa3e265df9e0e3581d4f4"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f1e29495bc968e37352c315d23a38462d7e77fcfa1597d005d17ed93f9f3103"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ad8a774f545eb5471587e0389fca4f855f36d58901c65547796d59fc13aee458"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:75a5a0251e4ceca127b26d18f0965b7f3c820a2dd2c51c25015c819300fd5859"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:853e40535f0f57e8b605e1cbce1399c96bcd5ab99e60992d2c7669c689d0cbe5"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e04e1dd62d019cde936d18fcd21361f6c4695e0e73fd6dc509c4ccd9446d26d"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:2df377e3d04f5c427c9f79ef95bdf0b982bde76c1dbd4441f83268f3f1993a53"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:5f3cb8db19e7462ccb2e34b56feaccb2aac675ad8f77e28f8222b3e7c47d1f92"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:b40860b3e6d6108473836a29d3600e1b335192637e16e9421b43b58147ced3c1"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:ca4fd9e8b669e81bb74479bde61ee475d7a6832d667e2ce80e6136ddd7a0fedd"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:771e74a22f48c40b6402d0ca1d569ced5a796e118d4472da388744b5aa0ebd3f"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-win32.whl", hash = "sha256:4589bfca18ecf787598262327f7329fe1f4fc2655c04899d84451562e2099a57"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp37-cp37m-win_amd64.whl", hash = "sha256:f646ab958a601bad8925a876f5aa68bdf0ec3584630143ed1ad8e9df4e447044"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:dfb8106a05af1cb1eadeea996171b52c80f18851972e49ffe91539e4fc064b0f"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b8b9b77a898b721fc634858fc43552119d3d303485adc6f28f3e76f028d5ea04"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c65efab1a0ab14f75314039694ac35d3181a5c8cf43584bd537b36caf2a6ccf9"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b450eee7e201c48aae58e2d45ef5d309a19cd49952cfb58d546fefbeef0a100"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8f0997202d7628c4312f0398122bdc5ada7fa79939d248652af40d9da689ef8"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dae69bef7628236d426e408fb14a40f0027bac1658a06efd29549b26ba369372"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef48e874bcc3ebf623672ec99f9aaa7b8a4f62fb270e33dad6db3739ea111086"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9006dc1a73e2b2a53421aa72decbcff08cb109f67a20f7d15a64ab140e0a1d2"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:96c07a345740fa71c0d8fc5fa7ea182ee24f62ebbf33d4d10c8c72d866dc332d"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:85a78af6f6e782e0df36f93c9e7c2dd568204f60a2ea55025c21d1837dea95ec"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:19eadc55bf69f9e9799a808cdcfc6657cf30511cb32849235d555adfa048a99f"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:377e8ad3bb3c17c43f860b570fd15e048246ade92babc9b310f2c417500aca57"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:f8e07aec529d6fa31516201c524b0cfac108a9a6044a148f236291aae7991195"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-win32.whl", hash = "sha256:703ab55b77b1c1ebb80eb0b27574a8eadf109739d252de7f646dc41cb82f1a65"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp38-cp38-win_amd64.whl", hash = "sha256:b4f4b2e019c6d1ad33d5fc3163d31d0f731a073a5a52cdfae7b85408548ce342"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a84e3b098a29b8c298b01291cf8bc850a507ca45507d43674a84a8d33b7595b2"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9905b580cfdbd6945e44d81332483deace167d33e956ffae5c4b27eddeb676e7"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e0c403a7418631dc7185ef8053acc765101f4f64cc0bf50d1bc44ae7d40fc28e"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d63f89bf28de4ce82a7c324275ce733bf31eb29ec1121e48261af89b5b7f30b"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fc6dc782f5be4883279079c79fa88578258a0fd24651f6d69b0f4be2716f7d7e"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743c177822c60e66c5c9579b4f384bd98e60fd4a2abe0eacdec0af4747d925bc"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ec5eeb87220e4d2abf6faad1ecb3b3ee88c4d9caad6cf2ce4c0a73a91c4c7ad9"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9318b814363b4bc062e54852ea62f58b69e7da9e51211afd6c55e9170e1ae9a0"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:e7a1f58a2614f2ad9fcb4822f6da56313cbb88309880512bf5d01bd3d9142b87"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ae9427ddde670f605c84f704c12d840628467cc0f0a8e9ce6489577eef6a0479"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:be75ae311433254af3c6fe6eb68bf80ac6ef547ec0cf4594f5b301a044682186"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:73e1438437bbe67d453e2908b90b17b357a992a9ac0011ad20af1ea7c2b4cd58"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:393c0a9af080c1c1a0801cca9448eff3633dafc1a7934fdce58a8d1c15d8bd2b"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-win32.whl", hash = "sha256:13a79fc8e9b69bf6d70e7fa5a53bd42fab83dc3e6e93da7fa82871ec40874e43"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-win_amd64.whl", hash = "sha256:b1648708e5bf599b4455bf330faa4777c3963669acb2f3fa25240123a01f8b2b"}, + {file = "sqlcipher3_wheels-0.5.5.post0-cp39-cp39-win_arm64.whl", hash = "sha256:4c3dd2f54bdd518b90e28b07c31cdfe34c8bd182c5107a30a9c2ef9569cd6cf9"}, + {file = "sqlcipher3_wheels-0.5.5.post0.tar.gz", hash = "sha256:2c291ba05fa3e57c9b4d407d2751aa69266b5372468e7402daaa312b251aca7f"}, ] [[package]] @@ -747,22 +738,22 @@ files = [ [[package]] name = "urllib3" -version = "2.6.2" +version = "2.5.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" files = [ - {file = "urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd"}, - {file = "urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797"}, + {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, + {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, ] [package.extras] -brotli = ["brotli (>=1.2.0)", "brotlicffi (>=1.2.0.0)"] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["backports-zstd (>=1.0.0)"] +zstd = ["zstandard (>=0.18.0)"] [metadata] lock-version = "2.0" -python-versions = ">=3.10,<3.14" -content-hash = "86db2b4d6498ce9eba7fa9aedf9ce58fec6a63542b5f3bdb3cf6c4067e5b87df" +python-versions = ">=3.9,<3.14" +content-hash = "8c65ccc55e84371f8695117dcd01ca9ad2d78b159327045eced824e5f425a7d0" diff --git a/pyproject.toml b/pyproject.toml index 521e3d7..72a0f00 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,20 +1,16 @@ [tool.poetry] name = "bouquin" -version = "0.7.3" +version = "0.2.1.6" description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher." authors = ["Miguel JacqHTML paragraph
-""" - result = fresh_db._strip_markdown(text) - assert "My Document" in result - assert "paragraph" in result - assert "const x" not in result - assert "https://example.com" not in result - assert "" not in result
-
-
-def test_db_count_words_simple(fresh_db):
- """Test word counting on simple text."""
- text = "This is a simple test with seven words"
- count = fresh_db._count_words(text)
- assert count == 8
-
-
-def test_db_count_words_empty(fresh_db):
- """Test word counting on empty text."""
- count = fresh_db._count_words("")
- assert count == 0
-
-
-def test_db_count_words_with_markdown(fresh_db):
- """Test word counting strips markdown first."""
- text = "**Bold** and *italic* and `code` words"
- count = fresh_db._count_words(text)
- # Should count: Bold, and, italic, and, words (5 words, code is in backticks so stripped)
- assert count == 5
-
-
-def test_db_count_words_with_unicode(fresh_db):
- """Test word counting with unicode characters."""
- text = "Hello 世界 café naïve résumé"
- count = fresh_db._count_words(text)
- # Should count all words including unicode
- assert count >= 5
-
-
-def test_db_count_words_with_numbers(fresh_db):
- """Test word counting includes numbers."""
- text = "There are 123 apples and 456 oranges"
- count = fresh_db._count_words(text)
- assert count == 7
-
-
-def test_db_count_words_with_punctuation(fresh_db):
- """Test word counting handles punctuation correctly."""
- text = "Hello, world! How are you? I'm fine, thanks."
- count = fresh_db._count_words(text)
- # Hello, world, How, are, you, I, m, fine, thanks = 9 words
- assert count == 9
-
-
-# ============================================================================
-# DB gather_stats Tests
-# ============================================================================
-
-
-def test_db_gather_stats_empty_database(fresh_db):
- """Test gather_stats on empty database."""
- stats = fresh_db.gather_stats()
-
- assert len(stats) == 22
- (
- pages_with_content,
- total_revisions,
- page_most_revisions,
- page_most_revisions_count,
- words_by_date,
- total_words,
- unique_tags,
- page_most_tags,
- page_most_tags_count,
- revisions_by_date,
- time_minutes_by_date,
- total_time_minutes,
- day_most_time,
- day_most_time_minutes,
- project_most_minutes_name,
- project_most_minutes,
- activity_most_minutes_name,
- activity_most_minutes,
- reminders_by_date,
- total_reminders,
- day_most_reminders,
- day_most_reminders_count,
- ) = stats
-
- assert pages_with_content == 0
- assert total_revisions == 0
- assert page_most_revisions is None
- assert page_most_revisions_count == 0
- assert len(words_by_date) == 0
- assert total_words == 0
- assert unique_tags == 0
- assert page_most_tags is None
- assert page_most_tags_count == 0
- assert len(revisions_by_date) == 0
-
-
-def test_db_gather_stats_with_content(fresh_db):
- """Test gather_stats with actual content."""
- # Add multiple pages with different content
- fresh_db.save_new_version("2024-01-01", "Hello world this is a test", "v1")
- fresh_db.save_new_version(
- "2024-01-01", "Hello world this is version two", "v2"
- ) # 2nd revision
- fresh_db.save_new_version("2024-01-02", "Another page with more words here", "v1")
-
- stats = fresh_db.gather_stats()
-
- (
- pages_with_content,
- total_revisions,
- page_most_revisions,
- page_most_revisions_count,
- words_by_date,
- total_words,
- unique_tags,
- page_most_tags,
- page_most_tags_count,
- revisions_by_date,
- *_rest,
- ) = stats
-
- assert pages_with_content == 2
- assert total_revisions == 3
- assert page_most_revisions == "2024-01-01"
- assert page_most_revisions_count == 2
- assert total_words > 0
- assert len(words_by_date) == 2
-
-
-def test_db_gather_stats_word_counting(fresh_db):
- """Test that gather_stats counts words correctly."""
- # Add page with known word count
- fresh_db.save_new_version("2024-01-01", "one two three four five", "test")
-
- stats = fresh_db.gather_stats()
- _, _, _, _, words_by_date, total_words, _, _, _, *_rest = stats
-
- assert total_words == 5
-
- test_date = date(2024, 1, 1)
- assert test_date in words_by_date
- assert words_by_date[test_date] == 5
-
-
-def test_db_gather_stats_with_tags(fresh_db):
- """Test gather_stats with tags."""
- # Add tags
- fresh_db.add_tag("tag1", "#ff0000")
- fresh_db.add_tag("tag2", "#00ff00")
- fresh_db.add_tag("tag3", "#0000ff")
-
- # Add pages with tags
- fresh_db.save_new_version("2024-01-01", "Page 1", "test")
- fresh_db.save_new_version("2024-01-02", "Page 2", "test")
-
- fresh_db.set_tags_for_page(
- "2024-01-01", ["tag1", "tag2", "tag3"]
- ) # Page 1 has 3 tags
- fresh_db.set_tags_for_page("2024-01-02", ["tag1"]) # Page 2 has 1 tag
-
- stats = fresh_db.gather_stats()
- _, _, _, _, _, _, unique_tags, page_most_tags, page_most_tags_count, *_rest = stats
-
- assert unique_tags == 3
- assert page_most_tags == "2024-01-01"
- assert page_most_tags_count == 3
-
-
-def test_db_gather_stats_revisions_by_date(fresh_db):
- """Test revisions_by_date tracking."""
- # Add multiple revisions on different dates
- fresh_db.save_new_version("2024-01-01", "First", "v1")
- fresh_db.save_new_version("2024-01-01", "Second", "v2")
- fresh_db.save_new_version("2024-01-01", "Third", "v3")
- fresh_db.save_new_version("2024-01-02", "Fourth", "v1")
-
- stats = fresh_db.gather_stats()
- _, _, _, _, _, _, _, _, _, revisions_by_date, *_rest = stats
-
- assert date(2024, 1, 1) in revisions_by_date
- assert revisions_by_date[date(2024, 1, 1)] == 3
- assert date(2024, 1, 2) in revisions_by_date
- assert revisions_by_date[date(2024, 1, 2)] == 1
-
-
-def test_db_gather_stats_handles_malformed_dates(fresh_db):
- """Test that gather_stats handles malformed dates gracefully."""
- # This is hard to test directly since the DB enforces date format
- # But we can test that normal dates work
- fresh_db.save_new_version("2024-01-15", "Test", "v1")
-
- stats = fresh_db.gather_stats()
- _, _, _, _, _, _, _, _, _, revisions_by_date, *_rest = stats
-
- # Should have parsed the date correctly
- assert date(2024, 1, 15) in revisions_by_date
-
-
-def test_db_gather_stats_current_version_only(fresh_db):
- """Test that word counts use current version only, not all revisions."""
- # Add multiple revisions
- fresh_db.save_new_version("2024-01-01", "one two three", "v1")
- fresh_db.save_new_version("2024-01-01", "one two three four five", "v2")
-
- stats = fresh_db.gather_stats()
- _, _, _, _, words_by_date, total_words, _, _, _, *_rest = stats
-
- # Should count words from current version (5 words), not old version
- assert total_words == 5
- assert words_by_date[date(2024, 1, 1)] == 5
-
-
-def test_db_gather_stats_no_tags(fresh_db):
- """Test gather_stats when there are no tags."""
- fresh_db.save_new_version("2024-01-01", "No tags here", "test")
-
- stats = fresh_db.gather_stats()
- _, _, _, _, _, _, unique_tags, page_most_tags, page_most_tags_count, *_rest = stats
-
- assert unique_tags == 0
- assert page_most_tags is None
- assert page_most_tags_count == 0
-
-
-def test_db_gather_stats_exception_in_dates_with_content(fresh_db, monkeypatch):
- """Test that gather_stats handles exception in dates_with_content."""
-
- def bad_dates():
- raise RuntimeError("Simulated error")
-
- monkeypatch.setattr(fresh_db, "dates_with_content", bad_dates)
-
- # Should still return stats without crashing
- stats = fresh_db.gather_stats()
- pages_with_content = stats[0]
-
- # Should default to 0 when exception occurs
- assert pages_with_content == 0
-
-
-def test_delete_version(fresh_db):
- """Test deleting a specific version by version_id."""
- d = date.today().isoformat()
-
- # Create multiple versions
- vid1, _ = fresh_db.save_new_version(d, "version 1", "note1")
- vid2, _ = fresh_db.save_new_version(d, "version 2", "note2")
- vid3, _ = fresh_db.save_new_version(d, "version 3", "note3")
-
- # Verify all versions exist
- versions = fresh_db.list_versions(d)
- assert len(versions) == 3
-
- # Delete the second version
- fresh_db.delete_version(version_id=vid2)
-
- # Verify it's deleted
- versions_after = fresh_db.list_versions(d)
- assert len(versions_after) == 2
-
- # Make sure the deleted version is not in the list
- version_ids = [v["id"] for v in versions_after]
- assert vid2 not in version_ids
- assert vid1 in version_ids
- assert vid3 in version_ids
-
-
-def test_update_reminder_active(fresh_db):
- """Test updating the active status of a reminder."""
- from bouquin.reminders import Reminder, ReminderType
-
- # Create a reminder object
- reminder = Reminder(
- id=None,
- text="Test reminder",
- reminder_type=ReminderType.ONCE,
- time_str="14:30",
- date_iso=date.today().isoformat(),
- active=True,
- )
-
- # Save it
- reminder_id = fresh_db.save_reminder(reminder)
-
- # Verify it's active
- reminders = fresh_db.get_all_reminders()
- active_reminder = [r for r in reminders if r.id == reminder_id][0]
- assert active_reminder.active is True
-
- # Deactivate it
- fresh_db.update_reminder_active(reminder_id, False)
-
- # Verify it's inactive
- reminders = fresh_db.get_all_reminders()
- inactive_reminder = [r for r in reminders if r.id == reminder_id][0]
- assert inactive_reminder.active is False
-
- # Reactivate it
- fresh_db.update_reminder_active(reminder_id, True)
-
- # Verify it's active again
- reminders = fresh_db.get_all_reminders()
- reactivated_reminder = [r for r in reminders if r.id == reminder_id][0]
- assert reactivated_reminder.active is True
diff --git a/tests/test_document_utils.py b/tests/test_document_utils.py
deleted file mode 100644
index e1301df..0000000
--- a/tests/test_document_utils.py
+++ /dev/null
@@ -1,289 +0,0 @@
-import tempfile
-from pathlib import Path
-from unittest.mock import patch
-
-from PySide6.QtCore import QUrl
-from PySide6.QtGui import QDesktopServices
-from PySide6.QtWidgets import QMessageBox, QWidget
-
-
-def test_open_document_from_db_success(qtbot, app, fresh_db):
- """Test successfully opening a document."""
- # Import here to avoid circular import issues
- from bouquin.document_utils import open_document_from_db
-
- # Add a project and document
- proj_id = fresh_db.add_project("Test Project")
- doc_path = Path(tempfile.mktemp(suffix=".txt"))
- doc_path.write_text("test content for document")
-
- try:
- doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
-
- # Mock QDesktopServices.openUrl
- with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
- # Call the function
- success = open_document_from_db(
- fresh_db, doc_id, doc_path.name, parent_widget=None
- )
-
- # Verify success
- assert success is True
-
- # Verify openUrl was called with a QUrl
- assert mock_open.called
- args = mock_open.call_args[0]
- assert isinstance(args[0], QUrl)
-
- # Verify the URL points to a local file
- url_string = args[0].toString()
- assert url_string.startswith("file://")
- assert "bouquin_doc_" in url_string
- assert doc_path.suffix in url_string
- finally:
- doc_path.unlink(missing_ok=True)
-
-
-def test_open_document_from_db_with_parent_widget(qtbot, app, fresh_db):
- """Test opening a document with a parent widget provided."""
- from bouquin.document_utils import open_document_from_db
-
- # Create a parent widget
- parent = QWidget()
- qtbot.addWidget(parent)
-
- # Add a project and document
- proj_id = fresh_db.add_project("Test Project")
- doc_path = Path(tempfile.mktemp(suffix=".pdf"))
- doc_path.write_text("PDF content")
-
- try:
- doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
-
- with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
- success = open_document_from_db(
- fresh_db, doc_id, doc_path.name, parent_widget=parent
- )
-
- assert success is True
- assert mock_open.called
- finally:
- doc_path.unlink(missing_ok=True)
-
-
-def test_open_document_from_db_nonexistent_document(qtbot, app, fresh_db):
- """Test opening a non-existent document returns False."""
- from bouquin.document_utils import open_document_from_db
-
- # Try to open a document that doesn't exist
- success = open_document_from_db(
- fresh_db, doc_id=99999, file_name="nonexistent.txt", parent_widget=None
- )
-
- # Should return False
- assert success is False
-
-
-def test_open_document_from_db_shows_error_with_parent(qtbot, app, fresh_db):
- """Test that error dialog is shown when parent widget is provided."""
- from bouquin.document_utils import open_document_from_db
-
- parent = QWidget()
- qtbot.addWidget(parent)
-
- # Mock QMessageBox.warning
- with patch.object(QMessageBox, "warning") as mock_warning:
- success = open_document_from_db(
- fresh_db, doc_id=99999, file_name="nonexistent.txt", parent_widget=parent
- )
-
- # Should return False and show warning
- assert success is False
- assert mock_warning.called
-
- # Verify warning was shown with correct parent
- call_args = mock_warning.call_args[0]
- assert call_args[0] is parent
-
-
-def test_open_document_from_db_no_error_dialog_without_parent(qtbot, app, fresh_db):
- """Test that no error dialog is shown when parent widget is None."""
- from bouquin.document_utils import open_document_from_db
-
- with patch.object(QMessageBox, "warning") as mock_warning:
- success = open_document_from_db(
- fresh_db, doc_id=99999, file_name="nonexistent.txt", parent_widget=None
- )
-
- # Should return False but NOT show warning
- assert success is False
- assert not mock_warning.called
-
-
-def test_open_document_from_db_preserves_file_extension(qtbot, app, fresh_db):
- """Test that the temporary file has the correct extension."""
- from bouquin.document_utils import open_document_from_db
-
- # Test various file extensions
- extensions = [".txt", ".pdf", ".docx", ".xlsx", ".jpg", ".png"]
-
- for ext in extensions:
- proj_id = fresh_db.add_project(f"Project for {ext}")
- doc_path = Path(tempfile.mktemp(suffix=ext))
- doc_path.write_text(f"content for {ext}")
-
- try:
- doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
-
- with patch.object(
- QDesktopServices, "openUrl", return_value=True
- ) as mock_open:
- open_document_from_db(fresh_db, doc_id, doc_path.name)
-
- # Get the URL that was opened
- url = mock_open.call_args[0][0]
- url_string = url.toString()
-
- # Verify the extension is preserved
- assert ext in url_string, f"Extension {ext} not found in {url_string}"
- finally:
- doc_path.unlink(missing_ok=True)
-
-
-def test_open_document_from_db_file_without_extension(qtbot, app, fresh_db):
- """Test opening a document without a file extension."""
- from bouquin.document_utils import open_document_from_db
-
- proj_id = fresh_db.add_project("Test Project")
- doc_path = Path(tempfile.mktemp()) # No suffix
- doc_path.write_text("content without extension")
-
- try:
- doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
-
- with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
- success = open_document_from_db(fresh_db, doc_id, doc_path.name)
-
- # Should still succeed
- assert success is True
- assert mock_open.called
- finally:
- doc_path.unlink(missing_ok=True)
-
-
-def test_open_document_from_db_desktop_services_failure(qtbot, app, fresh_db):
- """Test handling when QDesktopServices.openUrl returns False."""
- from bouquin.document_utils import open_document_from_db
-
- proj_id = fresh_db.add_project("Test Project")
- doc_path = Path(tempfile.mktemp(suffix=".txt"))
- doc_path.write_text("test content")
-
- try:
- doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
-
- # Mock openUrl to return False (failure)
- with patch.object(QDesktopServices, "openUrl", return_value=False):
- success = open_document_from_db(fresh_db, doc_id, doc_path.name)
-
- # Should return False
- assert success is False
- finally:
- doc_path.unlink(missing_ok=True)
-
-
-def test_open_document_from_db_binary_content(qtbot, app, fresh_db):
- """Test opening a document with binary content."""
- from bouquin.document_utils import open_document_from_db
-
- proj_id = fresh_db.add_project("Test Project")
- doc_path = Path(tempfile.mktemp(suffix=".bin"))
-
- # Write some binary data
- binary_data = bytes([0, 1, 2, 3, 255, 254, 253])
- doc_path.write_bytes(binary_data)
-
- try:
- doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
-
- with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
- success = open_document_from_db(fresh_db, doc_id, doc_path.name)
-
- assert success is True
- assert mock_open.called
- finally:
- doc_path.unlink(missing_ok=True)
-
-
-def test_open_document_from_db_large_file(qtbot, app, fresh_db):
- """Test opening a large document."""
- from bouquin.document_utils import open_document_from_db
-
- proj_id = fresh_db.add_project("Test Project")
- doc_path = Path(tempfile.mktemp(suffix=".bin"))
-
- # Create a 1MB file
- large_data = b"x" * (1024 * 1024)
- doc_path.write_bytes(large_data)
-
- try:
- doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
-
- with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
- success = open_document_from_db(fresh_db, doc_id, doc_path.name)
-
- assert success is True
- assert mock_open.called
- finally:
- doc_path.unlink(missing_ok=True)
-
-
-def test_open_document_from_db_temp_file_prefix(qtbot, app, fresh_db):
- """Test that temporary files have the correct prefix."""
- from bouquin.document_utils import open_document_from_db
-
- proj_id = fresh_db.add_project("Test Project")
- doc_path = Path(tempfile.mktemp(suffix=".txt"))
- doc_path.write_text("test")
-
- try:
- doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
-
- with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
- open_document_from_db(fresh_db, doc_id, doc_path.name)
-
- url = mock_open.call_args[0][0]
- url_path = url.toLocalFile()
-
- # Verify the temp file has the bouquin_doc_ prefix
- assert "bouquin_doc_" in url_path
- finally:
- doc_path.unlink(missing_ok=True)
-
-
-def test_open_document_from_db_multiple_calls(qtbot, app, fresh_db):
- """Test opening the same document multiple times."""
- from bouquin.document_utils import open_document_from_db
-
- proj_id = fresh_db.add_project("Test Project")
- doc_path = Path(tempfile.mktemp(suffix=".txt"))
- doc_path.write_text("test content")
-
- try:
- doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
-
- with patch.object(QDesktopServices, "openUrl", return_value=True) as mock_open:
- # Open the same document 3 times
- for _ in range(3):
- success = open_document_from_db(fresh_db, doc_id, doc_path.name)
- assert success is True
-
- # Should have been called 3 times
- assert mock_open.call_count == 3
-
- # Each call should create a different temp file
- call_urls = [call[0][0].toString() for call in mock_open.call_args_list]
- # All URLs should be different (different temp files)
- assert len(set(call_urls)) == 3
- finally:
- doc_path.unlink(missing_ok=True)
diff --git a/tests/test_documents.py b/tests/test_documents.py
deleted file mode 100644
index 0740b40..0000000
--- a/tests/test_documents.py
+++ /dev/null
@@ -1,1060 +0,0 @@
-import tempfile
-from pathlib import Path
-from unittest.mock import MagicMock, patch
-
-from bouquin.db import DBConfig
-from bouquin.documents import DocumentsDialog, TodaysDocumentsWidget
-from PySide6.QtCore import Qt, QUrl
-from PySide6.QtGui import QDesktopServices
-from PySide6.QtWidgets import QDialog, QFileDialog, QMessageBox
-
-# =============================================================================
-# TodaysDocumentsWidget Tests
-# =============================================================================
-
-
-def test_todays_documents_widget_init(qtbot, app, fresh_db):
- """Test TodaysDocumentsWidget initialization."""
- date_iso = "2024-01-15"
- widget = TodaysDocumentsWidget(fresh_db, date_iso)
- qtbot.addWidget(widget)
-
- assert widget._db is fresh_db
- assert widget._current_date == date_iso
- assert widget.toggle_btn is not None
- assert widget.open_btn is not None
- assert widget.list is not None
- assert not widget.body.isVisible()
-
-
-def test_todays_documents_widget_reload_no_documents(qtbot, app, fresh_db):
- """Test reload when there are no documents for today."""
- date_iso = "2024-01-15"
- widget = TodaysDocumentsWidget(fresh_db, date_iso)
- qtbot.addWidget(widget)
-
- # Should have one disabled item saying "no documents"
- assert widget.list.count() == 1
- item = widget.list.item(0)
- assert not (item.flags() & Qt.ItemIsEnabled)
-
-
-def test_todays_documents_widget_reload_with_documents(qtbot, app, fresh_db):
- """Test reload when there are documents for today."""
- # Add a project
- proj_id = fresh_db.add_project("Test Project")
-
- # Add a document to the project
- doc_path = Path(tempfile.mktemp(suffix=".txt"))
- doc_path.write_text("test content")
- try:
- fresh_db.add_document_from_path(proj_id, str(doc_path))
-
- # Mark document as accessed today
- date_iso = "2024-01-15"
- # The todays_documents method checks updated_at, so we need to ensure
- # the document shows up in today's query
-
- widget = TodaysDocumentsWidget(fresh_db, date_iso)
- qtbot.addWidget(widget)
-
- # At minimum, widget should be created without error
- assert widget.list is not None
- finally:
- doc_path.unlink(missing_ok=True)
-
-
-def test_todays_documents_widget_set_current_date(qtbot, app, fresh_db):
- """Test changing the current date."""
- widget = TodaysDocumentsWidget(fresh_db, "2024-01-15")
- qtbot.addWidget(widget)
-
- # Change date
- widget.set_current_date("2024-01-16")
- assert widget._current_date == "2024-01-16"
-
-
-def test_todays_documents_widget_open_document(qtbot, app, fresh_db):
- """Test opening a document."""
- # Add a project and document
- proj_id = fresh_db.add_project("Test Project")
- doc_path = Path(tempfile.mktemp(suffix=".txt"))
- doc_path.write_text("test content")
-
- try:
- doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
-
- widget = TodaysDocumentsWidget(fresh_db, "2024-01-15")
- qtbot.addWidget(widget)
-
- # Mock QDesktopServices.openUrl
- with patch.object(
- QDesktopServices, "openUrl", return_value=True
- ) as mock_open_url:
- widget._open_document(doc_id, doc_path.name)
-
- # Verify openUrl was called
- assert mock_open_url.called
- args = mock_open_url.call_args[0]
- assert isinstance(args[0], QUrl)
- finally:
- doc_path.unlink(missing_ok=True)
-
-
-def test_todays_documents_widget_open_document_error(qtbot, app, fresh_db):
- """Test opening a non-existent document shows error."""
- widget = TodaysDocumentsWidget(fresh_db, "2024-01-15")
- qtbot.addWidget(widget)
-
- # Try to open non-existent document
- with patch.object(QMessageBox, "warning") as mock_warning:
- widget._open_document(99999, "nonexistent.txt")
- assert mock_warning.called
-
-
-def test_todays_documents_widget_open_documents_dialog(qtbot, app, fresh_db):
- """Test opening the full documents dialog."""
- widget = TodaysDocumentsWidget(fresh_db, "2024-01-15")
- qtbot.addWidget(widget)
-
- # Mock DocumentsDialog
- mock_dialog = MagicMock()
- mock_dialog.exec.return_value = QDialog.Accepted
-
- with patch("bouquin.documents.DocumentsDialog", return_value=mock_dialog):
- widget._open_documents_dialog()
- assert mock_dialog.exec.called
-
-
-# =============================================================================
-# DocumentsDialog Tests
-# =============================================================================
-
-
-def test_documents_dialog_init(qtbot, app, fresh_db):
- """Test DocumentsDialog initialization."""
- dialog = DocumentsDialog(fresh_db)
- qtbot.addWidget(dialog)
-
- assert dialog._db is fresh_db
- assert dialog.project_combo is not None
- assert dialog.search_edit is not None
- assert dialog.table is not None
- assert dialog.table.columnCount() == 5
-
-
-def test_documents_dialog_init_with_initial_project(qtbot, app, fresh_db):
- """Test DocumentsDialog with initial project ID."""
- proj_id = fresh_db.add_project("Test Project")
-
- dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id)
- qtbot.addWidget(dialog)
-
- # Should select the specified project
- # Verify project combo is populated
- assert dialog.project_combo.count() > 0
-
-
-def test_documents_dialog_reload_projects(qtbot, app, fresh_db):
- """Test reloading projects list."""
- # Add some projects
- fresh_db.add_project("Project 1")
- fresh_db.add_project("Project 2")
-
- dialog = DocumentsDialog(fresh_db)
- qtbot.addWidget(dialog)
-
- # Check projects are loaded (including "All projects" option)
- assert dialog.project_combo.count() >= 2
-
-
-def test_documents_dialog_reload_documents_no_project(qtbot, app, fresh_db):
- """Test reloading documents when no project is selected."""
- dialog = DocumentsDialog(fresh_db)
- qtbot.addWidget(dialog)
-
- dialog._reload_documents()
- # Should not crash
-
-
-def test_documents_dialog_reload_documents_with_project(qtbot, app, fresh_db):
- """Test reloading documents for a specific project."""
- # Add project and document
- proj_id = fresh_db.add_project("Test Project")
- doc_path = Path(tempfile.mktemp(suffix=".txt"))
- doc_path.write_text("test content")
-
- try:
- fresh_db.add_document_from_path(proj_id, str(doc_path))
-
- dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id)
- qtbot.addWidget(dialog)
-
- # Table should have at least one row
- assert (
- dialog.table.rowCount() >= 0
- ) # Might be 0 or 1 depending on how DB is set up
- finally:
- doc_path.unlink(missing_ok=True)
-
-
-def test_documents_dialog_add_document(qtbot, app, fresh_db):
- """Test adding a document."""
- proj_id = fresh_db.add_project("Test Project")
-
- dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id)
- qtbot.addWidget(dialog)
-
- # Create a temporary file to add
- doc_path = Path(tempfile.mktemp(suffix=".txt"))
- doc_path.write_text("test content")
-
- try:
- # Mock file dialog to return our test file
- with patch.object(
- QFileDialog, "getOpenFileNames", return_value=([str(doc_path)], "")
- ):
- dialog._on_add_clicked()
-
- # Verify document was added (table should reload)
- # The count might not change if the view isn't refreshed properly in test
- # but the DB should have the document
- docs = fresh_db.documents_for_project(proj_id)
- assert len(docs) > 0
- finally:
- doc_path.unlink(missing_ok=True)
-
-
-def test_documents_dialog_add_document_no_project(qtbot, app, fresh_db):
- """Test adding a document with no project selected shows warning."""
- dialog = DocumentsDialog(fresh_db)
- qtbot.addWidget(dialog)
-
- # Set to "All projects" (None)
- dialog.project_combo.setCurrentIndex(0)
-
- with patch.object(QMessageBox, "warning") as mock_warning:
- dialog._on_add_clicked()
- assert mock_warning.called
-
-
-def test_documents_dialog_add_document_file_error(qtbot, app, fresh_db):
- """Test adding a document that doesn't exist shows warning."""
- proj_id = fresh_db.add_project("Test Project")
-
- dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id)
- qtbot.addWidget(dialog)
-
- # Mock file dialog to return a non-existent file
- with patch.object(
- QFileDialog, "getOpenFileNames", return_value=(["/nonexistent/file.txt"], "")
- ):
- with patch.object(QMessageBox, "warning"):
- dialog._on_add_clicked()
- # Should show warning for file not found
- # (this depends on add_document_from_path implementation)
-
-
-def test_documents_dialog_open_document(qtbot, app, fresh_db):
- """Test opening a document from the dialog."""
- proj_id = fresh_db.add_project("Test Project")
- doc_path = Path(tempfile.mktemp(suffix=".txt"))
- doc_path.write_text("test content")
-
- try:
- doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
-
- dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id)
- qtbot.addWidget(dialog)
-
- with patch.object(
- QDesktopServices, "openUrl", return_value=True
- ) as mock_open_url:
- dialog._open_document(doc_id, doc_path.name)
- assert mock_open_url.called
- finally:
- doc_path.unlink(missing_ok=True)
-
-
-def test_documents_dialog_delete_document(qtbot, app, fresh_db):
- """Test deleting a document."""
- proj_id = fresh_db.add_project("Test Project")
- doc_path = Path(tempfile.mktemp(suffix=".txt"))
- doc_path.write_text("test content")
-
- try:
- fresh_db.add_document_from_path(proj_id, str(doc_path))
-
- dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id)
- qtbot.addWidget(dialog)
-
- # Select the document in the table
- if dialog.table.rowCount() > 0:
- dialog.table.setCurrentCell(0, 0)
-
- # Mock confirmation dialog
- with patch.object(
- QMessageBox, "question", return_value=QMessageBox.StandardButton.Yes
- ):
- dialog._on_delete_clicked()
-
- # Document should be deleted
- fresh_db.documents_for_project(proj_id)
- # Depending on implementation, might be 0 or filtered
- finally:
- doc_path.unlink(missing_ok=True)
-
-
-def test_documents_dialog_delete_document_no_selection(qtbot, app, fresh_db):
- """Test deleting with no selection does nothing."""
- dialog = DocumentsDialog(fresh_db)
- qtbot.addWidget(dialog)
-
- # Don't select anything
- dialog.table.setCurrentCell(-1, -1)
-
- # Should not crash
- dialog._on_delete_clicked()
-
-
-def test_documents_dialog_delete_document_cancelled(qtbot, app, fresh_db):
- """Test cancelling document deletion."""
- proj_id = fresh_db.add_project("Test Project")
- doc_path = Path(tempfile.mktemp(suffix=".txt"))
- doc_path.write_text("test content")
-
- try:
- fresh_db.add_document_from_path(proj_id, str(doc_path))
-
- dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id)
- qtbot.addWidget(dialog)
-
- if dialog.table.rowCount() > 0:
- dialog.table.setCurrentCell(0, 0)
-
- # Mock confirmation dialog to return No
- with patch.object(
- QMessageBox, "question", return_value=QMessageBox.StandardButton.No
- ):
- dialog._on_delete_clicked()
-
- # Document should still exist
- docs = fresh_db.documents_for_project(proj_id)
- assert len(docs) > 0
- finally:
- doc_path.unlink(missing_ok=True)
-
-
-def test_documents_dialog_edit_description(qtbot, app, fresh_db):
- """Test editing a document's description inline."""
- proj_id = fresh_db.add_project("Test Project")
- doc_path = Path(tempfile.mktemp(suffix=".txt"))
- doc_path.write_text("test content")
-
- try:
- fresh_db.add_document_from_path(proj_id, str(doc_path))
-
- dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id)
- qtbot.addWidget(dialog)
-
- if dialog.table.rowCount() > 0:
- # Get the description cell
- desc_item = dialog.table.item(0, dialog.DESC_COL)
- if desc_item:
- # Simulate editing
- desc_item.setText("New description")
- dialog._on_item_changed(desc_item)
-
- # Verify description was updated in DB
- docs = fresh_db.documents_for_project(proj_id)
- if len(docs) > 0:
- _, _, _, _, description, _, _ = docs[0]
- assert description == "New description"
- finally:
- doc_path.unlink(missing_ok=True)
-
-
-def test_documents_dialog_edit_tags(qtbot, app, fresh_db):
- """Test editing a document's tags inline."""
- proj_id = fresh_db.add_project("Test Project")
- doc_path = Path(tempfile.mktemp(suffix=".txt"))
- doc_path.write_text("test content")
-
- try:
- doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
-
- dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id)
- qtbot.addWidget(dialog)
-
- if dialog.table.rowCount() > 0:
- # Get the tags cell
- tags_item = dialog.table.item(0, dialog.TAGS_COL)
- if tags_item:
- # Simulate editing tags
- tags_item.setText("tag1, tag2, tag3")
- dialog._on_item_changed(tags_item)
-
- # Verify tags were updated in DB
- tags = fresh_db.get_tags_for_document(doc_id)
- tag_names = [name for (_, name, _) in tags]
- assert "tag1" in tag_names
- assert "tag2" in tag_names
- assert "tag3" in tag_names
- finally:
- doc_path.unlink(missing_ok=True)
-
-
-def test_documents_dialog_tags_color_application(qtbot, app, fresh_db):
- """Test that tag colors are applied to the tags cell."""
- proj_id = fresh_db.add_project("Test Project")
- doc_path = Path(tempfile.mktemp(suffix=".txt"))
- doc_path.write_text("test content")
-
- try:
- doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
-
- # Add a tag with a color
- fresh_db.add_tag("colored_tag", "#FF0000")
- fresh_db.set_tags_for_document(doc_id, ["colored_tag"])
-
- dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id)
- qtbot.addWidget(dialog)
-
- if dialog.table.rowCount() > 0:
- tags_item = dialog.table.item(0, dialog.TAGS_COL)
- if tags_item:
- # Check that background color was applied
- bg_color = tags_item.background().color()
- assert bg_color.isValid()
- finally:
- doc_path.unlink(missing_ok=True)
-
-
-def test_documents_dialog_search_functionality(qtbot, app, fresh_db):
- """Test search functionality across all projects."""
- # Add multiple projects with documents
- proj1 = fresh_db.add_project("Project 1")
- proj2 = fresh_db.add_project("Project 2")
-
- doc1_path = Path(tempfile.mktemp(suffix=".txt"))
- doc1_path.write_text("apple content")
- doc2_path = Path(tempfile.mktemp(suffix=".txt"))
- doc2_path.write_text("banana content")
-
- try:
- fresh_db.add_document_from_path(proj1, str(doc1_path))
- fresh_db.add_document_from_path(proj2, str(doc2_path))
-
- dialog = DocumentsDialog(fresh_db)
- qtbot.addWidget(dialog)
-
- # Perform search
- dialog.search_edit.setText("apple")
- dialog._on_search_text_changed("apple")
-
- # Should show search results
- # Implementation depends on search_documents query
- finally:
- doc1_path.unlink(missing_ok=True)
- doc2_path.unlink(missing_ok=True)
-
-
-def test_documents_dialog_manage_projects_button(qtbot, app, fresh_db):
- """Test clicking manage projects button."""
- dialog = DocumentsDialog(fresh_db)
- qtbot.addWidget(dialog)
-
- # Mock TimeCodeManagerDialog
- mock_mgr_dialog = MagicMock()
- mock_mgr_dialog.exec.return_value = QDialog.Accepted
-
- with patch("bouquin.documents.TimeCodeManagerDialog", return_value=mock_mgr_dialog):
- dialog._manage_projects()
- assert mock_mgr_dialog.exec.called
-
-
-def test_documents_dialog_format_size(qtbot, app, fresh_db):
- """Test file size formatting."""
- dialog = DocumentsDialog(fresh_db)
- qtbot.addWidget(dialog)
-
- # Test various sizes
- assert "B" in dialog._format_size(500)
- assert "KB" in dialog._format_size(2048)
- assert "MB" in dialog._format_size(2 * 1024 * 1024)
- assert "GB" in dialog._format_size(2 * 1024 * 1024 * 1024)
-
-
-def test_documents_dialog_current_project_all(qtbot, app, fresh_db):
- """Test _current_project returns None for 'All Projects'."""
- dialog = DocumentsDialog(fresh_db)
- qtbot.addWidget(dialog)
-
- # Set to first item (All Projects)
- dialog.project_combo.setCurrentIndex(0)
-
- proj_id = dialog._current_project()
- assert proj_id is None
-
-
-def test_documents_dialog_current_project_specific(qtbot, app, fresh_db):
- """Test _current_project returns correct project ID."""
- proj_id = fresh_db.add_project("Test Project")
-
- dialog = DocumentsDialog(fresh_db)
- qtbot.addWidget(dialog)
-
- # Find and select the test project
- for i in range(dialog.project_combo.count()):
- if dialog.project_combo.itemData(i) == proj_id:
- dialog.project_combo.setCurrentIndex(i)
- break
-
- current_proj = dialog._current_project()
- if current_proj is not None:
- assert current_proj == proj_id
-
-
-def test_documents_dialog_table_double_click_opens_document(qtbot, app, fresh_db):
- """Test double-clicking a document opens it."""
- proj_id = fresh_db.add_project("Test Project")
- doc_path = Path(tempfile.mktemp(suffix=".txt"))
- doc_path.write_text("test content")
-
- try:
- fresh_db.add_document_from_path(proj_id, str(doc_path))
-
- dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id)
- qtbot.addWidget(dialog)
-
- if dialog.table.rowCount() > 0:
- with patch.object(QDesktopServices, "openUrl", return_value=True):
- # Simulate double-click
- dialog._on_open_clicked()
-
- # Should attempt to open if a row is selected
- # (behavior depends on whether table selection is set up properly)
- finally:
- doc_path.unlink(missing_ok=True)
-
-
-def test_documents_dialog_selected_doc_meta_no_selection(qtbot, app, fresh_db):
- """Test _selected_doc_meta with no selection."""
- dialog = DocumentsDialog(fresh_db)
- qtbot.addWidget(dialog)
-
- doc_id, file_name = dialog._selected_doc_meta()
- assert doc_id is None
- assert file_name is None
-
-
-def test_documents_dialog_selected_doc_meta_with_selection(qtbot, app, fresh_db):
- """Test _selected_doc_meta with a valid selection."""
- proj_id = fresh_db.add_project("Test Project")
- doc_path = Path(tempfile.mktemp(suffix=".txt"))
- doc_path.write_text("test content")
-
- try:
- fresh_db.add_document_from_path(proj_id, str(doc_path))
-
- dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id)
- qtbot.addWidget(dialog)
-
- if dialog.table.rowCount() > 0:
- dialog.table.setCurrentCell(0, 0)
-
- sel_doc_id, sel_file_name = dialog._selected_doc_meta()
- # May or may not be None depending on how table is populated
- # At minimum, should not crash
- finally:
- doc_path.unlink(missing_ok=True)
-
-
-def test_documents_dialog_item_changed_ignores_during_reload(qtbot, app, fresh_db):
- """Test _on_item_changed is ignored during reload."""
- dialog = DocumentsDialog(fresh_db)
- qtbot.addWidget(dialog)
-
- # Set reloading flag
- dialog._reloading_docs = True
-
- # Create a mock item
- from PySide6.QtWidgets import QTableWidgetItem
-
- item = QTableWidgetItem("test")
-
- # Should not crash or do anything
- dialog._on_item_changed(item)
-
- dialog._reloading_docs = False
-
-
-def test_documents_dialog_search_clears_properly(qtbot, app, fresh_db):
- """Test clearing search box resets to project view."""
- dialog = DocumentsDialog(fresh_db)
- qtbot.addWidget(dialog)
-
- # Enter search text
- dialog.search_edit.setText("test")
- dialog._on_search_text_changed("test")
-
- # Clear search
- dialog.search_edit.clear()
- dialog._on_search_text_changed("")
-
- # Should reset to normal project view
- assert dialog._search_text == ""
-
-
-def test_todays_documents_widget_reload_with_project_names(qtbot, app, fresh_db):
- """Test reload when documents have project names."""
- # Add a project and document
- proj_id = fresh_db.add_project("My Project")
- doc_path = Path(tempfile.mktemp(suffix=".txt"))
- doc_path.write_text("test content")
-
- try:
- doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
-
- # Mock todays_documents to return a document with project name
- with patch.object(fresh_db, "todays_documents") as mock_today:
- mock_today.return_value = [(doc_id, doc_path.name, "My Project")]
-
- widget = TodaysDocumentsWidget(fresh_db, "2024-01-15")
- qtbot.addWidget(widget)
- widget.reload()
-
- # Should have one item with project name in label
- assert widget.list.count() == 1
- item = widget.list.item(0)
- assert "My Project" in item.text()
- assert doc_path.name in item.text()
-
- # Check data was stored
- data = item.data(Qt.ItemDataRole.UserRole)
- assert isinstance(data, dict)
- assert data["doc_id"] == doc_id
- assert data["file_name"] == doc_path.name
- finally:
- doc_path.unlink(missing_ok=True)
-
-
-def test_todays_documents_widget_on_toggle_expand(qtbot, app, fresh_db):
- """Test toggle behavior when expanding."""
- widget = TodaysDocumentsWidget(fresh_db, "2024-01-15")
- qtbot.addWidget(widget)
- widget.show()
- qtbot.waitExposed(widget)
-
- # Initially collapsed
- assert not widget.body.isVisible()
-
- # Call _on_toggle directly
- widget._on_toggle(True)
-
- # Should be expanded
- assert widget.body.isVisible()
- assert widget.toggle_btn.arrowType() == Qt.DownArrow
-
-
-def test_todays_documents_widget_on_toggle_collapse(qtbot, app, fresh_db):
- """Test toggle behavior when collapsing."""
- widget = TodaysDocumentsWidget(fresh_db, "2024-01-15")
- qtbot.addWidget(widget)
- widget.show()
- qtbot.waitExposed(widget)
-
- # Expand first
- widget._on_toggle(True)
- assert widget.body.isVisible()
-
- # Now collapse
- widget._on_toggle(False)
-
- # Should be collapsed
- assert not widget.body.isVisible()
- assert widget.toggle_btn.arrowType() == Qt.RightArrow
-
-
-def test_todays_documents_widget_set_current_date_triggers_reload(qtbot, app, fresh_db):
- """Test that set_current_date triggers a reload."""
- widget = TodaysDocumentsWidget(fresh_db, "2024-01-15")
- qtbot.addWidget(widget)
-
- # Mock reload to verify it's called
- with patch.object(widget, "reload") as mock_reload:
- widget.set_current_date("2024-01-16")
-
- assert widget._current_date == "2024-01-16"
- assert mock_reload.called
-
-
-def test_todays_documents_widget_double_click_with_invalid_data(qtbot, app, fresh_db):
- """Test double-clicking item with invalid data."""
- widget = TodaysDocumentsWidget(fresh_db, "2024-01-15")
- qtbot.addWidget(widget)
-
- # Add item with invalid data
- from PySide6.QtWidgets import QListWidgetItem
-
- item = QListWidgetItem("Test")
- item.setData(Qt.ItemDataRole.UserRole, "not a dict")
- widget.list.addItem(item)
-
- # Double-click should not crash
- widget._open_selected_document(item)
-
-
-def test_todays_documents_widget_double_click_with_missing_doc_id(qtbot, app, fresh_db):
- """Test double-clicking item with missing doc_id."""
- widget = TodaysDocumentsWidget(fresh_db, "2024-01-15")
- qtbot.addWidget(widget)
-
- from PySide6.QtWidgets import QListWidgetItem
-
- item = QListWidgetItem("Test")
- item.setData(Qt.ItemDataRole.UserRole, {"file_name": "test.txt"})
- widget.list.addItem(item)
-
- # Should return early without crashing
- widget._open_selected_document(item)
-
-
-def test_todays_documents_widget_double_click_with_missing_filename(
- qtbot, app, fresh_db
-):
- """Test double-clicking item with missing file_name."""
- widget = TodaysDocumentsWidget(fresh_db, "2024-01-15")
- qtbot.addWidget(widget)
-
- from PySide6.QtWidgets import QListWidgetItem
-
- item = QListWidgetItem("Test")
- item.setData(Qt.ItemDataRole.UserRole, {"doc_id": 1})
- widget.list.addItem(item)
-
- # Should return early without crashing
- widget._open_selected_document(item)
-
-
-def test_documents_dialog_reload_calls_on_init(qtbot, app, fresh_db):
- """Test that _reload_documents is called on initialization."""
- # Add a project so the combo will have items
- fresh_db.add_project("Test Project")
-
- # This covers line 300
- dialog = DocumentsDialog(fresh_db)
- qtbot.addWidget(dialog)
-
- # Should have projects loaded (covers _reload_projects line 300-301)
- assert dialog.project_combo.count() > 0
-
-
-def test_documents_dialog_tags_column_hidden_when_disabled(qtbot, app, tmp_path):
- """Test that tags column is hidden when tags are disabled in config."""
- # Create a config with tags disabled
- db_path = tmp_path / "test.db"
- cfg = DBConfig(
- path=db_path,
- key="test-key",
- idle_minutes=0,
- theme="light",
- move_todos=True,
- tags=False, # Tags disabled
- time_log=True,
- reminders=True,
- locale="en",
- font_size=11,
- )
-
- from bouquin.db import DBManager
-
- db = DBManager(cfg)
- db.connect()
-
- try:
- # Add project and document
- proj_id = db.add_project("Test Project")
- doc_path = Path(tempfile.mktemp(suffix=".txt"))
- doc_path.write_text("test")
-
- try:
- db.add_document_from_path(proj_id, str(doc_path))
-
- # Patch load_db_config to return our custom config
- with patch("bouquin.documents.load_db_config", return_value=cfg):
- dialog = DocumentsDialog(db, initial_project_id=proj_id)
- qtbot.addWidget(dialog)
-
- # Tags column should be hidden (covers lines 400-401)
- # The column is hidden inside _reload_documents when there are rows
- assert dialog.table.isColumnHidden(dialog.TAGS_COL)
- finally:
- doc_path.unlink(missing_ok=True)
- finally:
- db.close()
-
-
-def test_documents_dialog_project_changed_triggers_reload(qtbot, app, fresh_db):
- """Test that changing project triggers document reload."""
- fresh_db.add_project("Project 1")
- fresh_db.add_project("Project 2")
-
- dialog = DocumentsDialog(fresh_db)
- qtbot.addWidget(dialog)
-
- # Mock _reload_documents
- with patch.object(dialog, "_reload_documents") as mock_reload:
- # Change project
- dialog._on_project_changed(1)
-
- # Should have triggered reload (covers line 421-424)
- assert mock_reload.called
-
-
-def test_documents_dialog_add_with_cancelled_dialog(qtbot, app, fresh_db):
- """Test adding document when file dialog is cancelled."""
- proj_id = fresh_db.add_project("Test Project")
- dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id)
- qtbot.addWidget(dialog)
-
- # Mock file dialog to return empty (cancelled)
- with patch.object(QFileDialog, "getOpenFileNames", return_value=([], "")):
- initial_count = dialog.table.rowCount()
- dialog._on_add_clicked()
-
- # No documents should be added (covers line 442)
- assert dialog.table.rowCount() == initial_count
-
-
-def test_documents_dialog_delete_with_cancelled_confirmation(qtbot, app, fresh_db):
- """Test deleting document when user cancels confirmation."""
- proj_id = fresh_db.add_project("Test Project")
- doc_path = Path(tempfile.mktemp(suffix=".txt"))
- doc_path.write_text("test")
-
- try:
- fresh_db.add_document_from_path(proj_id, str(doc_path))
-
- dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id)
- qtbot.addWidget(dialog)
-
- if dialog.table.rowCount() > 0:
- dialog.table.setCurrentCell(0, 0)
-
- # Mock to return No
- with patch.object(
- QMessageBox, "question", return_value=QMessageBox.StandardButton.No
- ):
- dialog._on_delete_clicked()
-
- # Document should still exist (covers line 486)
- docs = fresh_db.documents_for_project(proj_id)
- assert len(docs) > 0
- finally:
- doc_path.unlink(missing_ok=True)
-
-
-def test_documents_dialog_edit_tags_with_empty_result(qtbot, app, fresh_db):
- """Test editing tags when result is empty after setting."""
- proj_id = fresh_db.add_project("Test Project")
- doc_path = Path(tempfile.mktemp(suffix=".txt"))
- doc_path.write_text("test")
-
- try:
- fresh_db.add_document_from_path(proj_id, str(doc_path))
-
- dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id)
- qtbot.addWidget(dialog)
-
- if dialog.table.rowCount() > 0:
- tags_item = dialog.table.item(0, dialog.TAGS_COL)
- if tags_item:
- # Set empty tags
- tags_item.setText("")
- dialog._on_item_changed(tags_item)
-
- # Background should be cleared (covers lines 523-524)
- # Just verify no crash
- finally:
- doc_path.unlink(missing_ok=True)
-
-
-def test_documents_dialog_on_open_with_no_selection(qtbot, app, fresh_db):
- """Test _on_open_clicked with no selection."""
- dialog = DocumentsDialog(fresh_db)
- qtbot.addWidget(dialog)
-
- # Don't select anything
- dialog.table.setCurrentCell(-1, -1)
-
- # Should not crash (early return)
- dialog._on_open_clicked()
-
-
-def test_documents_dialog_search_with_results(qtbot, app, fresh_db):
- """Test search functionality with actual results."""
- proj_id = fresh_db.add_project("Test Project")
- doc_path = Path(tempfile.mktemp(suffix=".txt"))
- doc_path.write_text("searchable content")
-
- try:
- doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
-
- # Update document to have searchable description
- fresh_db.update_document_description(doc_id, "searchable description")
-
- dialog = DocumentsDialog(fresh_db)
- qtbot.addWidget(dialog)
-
- # Mock search_documents to return results
- with patch.object(fresh_db, "search_documents") as mock_search:
- mock_search.return_value = [
- (
- doc_id,
- proj_id,
- "Test Project",
- doc_path.name,
- "searchable description",
- 100,
- "2024-01-15",
- )
- ]
-
- # Perform search
- dialog.search_edit.setText("searchable")
- dialog._on_search_text_changed("searchable")
-
- # Should show results
- assert dialog.table.rowCount() > 0
- finally:
- doc_path.unlink(missing_ok=True)
-
-
-def test_documents_dialog_on_item_changed_invalid_item(qtbot, app, fresh_db):
- """Test _on_item_changed with None item."""
- dialog = DocumentsDialog(fresh_db)
- qtbot.addWidget(dialog)
-
- # Call with None
- dialog._on_item_changed(None)
-
- # Should not crash
-
-
-def test_documents_dialog_on_item_changed_no_file_item(qtbot, app, fresh_db):
- """Test _on_item_changed when file item is None."""
- dialog = DocumentsDialog(fresh_db)
- qtbot.addWidget(dialog)
-
- # Manually add a row without proper file item
- dialog.table.setRowCount(1)
- from PySide6.QtWidgets import QTableWidgetItem
-
- desc_item = QTableWidgetItem("Test")
- dialog.table.setItem(0, dialog.DESC_COL, desc_item)
-
- # Call on_item_changed
- dialog._on_item_changed(desc_item)
-
- # Should return early without crashing
-
-
-def test_documents_dialog_format_size_edge_cases(qtbot, app, fresh_db):
- """Test _format_size with edge cases."""
- dialog = DocumentsDialog(fresh_db)
- qtbot.addWidget(dialog)
-
- # Test 0 bytes
- assert dialog._format_size(0) == "0 B"
-
- # Test exact KB boundary
- assert "1.0 KB" in dialog._format_size(1024)
-
- # Test exact MB boundary
- assert "1.0 MB" in dialog._format_size(1024 * 1024)
-
- # Test exact GB boundary
- assert "1.0 GB" in dialog._format_size(1024 * 1024 * 1024)
-
-
-def test_documents_dialog_selected_doc_meta_no_file_item(qtbot, app, fresh_db):
- """Test _selected_doc_meta when file item is None."""
- dialog = DocumentsDialog(fresh_db)
- qtbot.addWidget(dialog)
-
- # Add a row without file item
- dialog.table.setRowCount(1)
- dialog.table.setCurrentCell(0, 0)
-
- doc_id, file_name = dialog._selected_doc_meta()
-
- # Should return None, None
- assert doc_id is None
- assert file_name is None
-
-
-def test_documents_dialog_initial_project_selection(qtbot, app, fresh_db):
- """Test dialog with initial_project_id selects correct project."""
- proj_id = fresh_db.add_project("Selected Project")
-
- # Add a document to ensure something shows
- doc_path = Path(tempfile.mktemp(suffix=".txt"))
- doc_path.write_text("test")
-
- try:
- fresh_db.add_document_from_path(proj_id, str(doc_path))
-
- dialog = DocumentsDialog(fresh_db, initial_project_id=proj_id)
- qtbot.addWidget(dialog)
-
- # Should have selected the project
- current_proj = dialog._current_project()
- assert current_proj == proj_id
- finally:
- doc_path.unlink(missing_ok=True)
-
-
-def test_todays_documents_widget_reload_multiple_documents(qtbot, app, fresh_db):
- """Test reload with multiple documents."""
- proj_id = fresh_db.add_project("Project")
-
- # Add multiple documents
- doc_ids = []
- for i in range(3):
- doc_path = Path(tempfile.mktemp(suffix=".txt"))
- doc_path.write_text(f"content {i}")
- try:
- doc_id = fresh_db.add_document_from_path(proj_id, str(doc_path))
- doc_ids.append((doc_id, doc_path.name))
- finally:
- doc_path.unlink(missing_ok=True)
-
- # Mock todays_documents
- with patch.object(fresh_db, "todays_documents") as mock_today:
- mock_today.return_value = [
- (doc_id, name, "Project") for doc_id, name in doc_ids
- ]
-
- widget = TodaysDocumentsWidget(fresh_db, "2024-01-15")
- qtbot.addWidget(widget)
- widget.reload()
-
- # Should have 3 items
- assert widget.list.count() == 3
-
-
-def test_documents_dialog_manage_projects_button_clicked(qtbot, app, fresh_db):
- """Test clicking manage projects button."""
- dialog = DocumentsDialog(fresh_db)
- qtbot.addWidget(dialog)
-
- # Mock TimeCodeManagerDialog
- mock_mgr_dialog = MagicMock()
- mock_mgr_dialog.exec.return_value = QDialog.Accepted
-
- with patch("bouquin.documents.TimeCodeManagerDialog", return_value=mock_mgr_dialog):
- dialog._manage_projects()
-
- # Should have opened the manager dialog
- assert mock_mgr_dialog.exec.called
diff --git a/tests/test_find_bar.py b/tests/test_find_bar.py
index de67c7e..47fab42 100644
--- a/tests/test_find_bar.py
+++ b/tests/test_find_bar.py
@@ -1,9 +1,9 @@
import pytest
-from bouquin.find_bar import FindBar
-from bouquin.markdown_editor import MarkdownEditor
-from bouquin.theme import Theme, ThemeConfig, ThemeManager
+
from PySide6.QtGui import QTextCursor
-from PySide6.QtWidgets import QTextEdit, QWidget
+from bouquin.markdown_editor import MarkdownEditor
+from bouquin.theme import ThemeManager, ThemeConfig, Theme
+from bouquin.find_bar import FindBar
@pytest.fixture
@@ -15,6 +15,7 @@ def editor(app, qtbot):
return ed
+@pytest.mark.gui
def test_findbar_basic_navigation(qtbot, editor):
editor.from_markdown("alpha\nbeta\nalpha\nGamma\n")
editor.moveCursor(QTextCursor.Start)
@@ -111,6 +112,7 @@ def test_update_highlight_clear_when_empty(qtbot, editor):
assert not editor.extraSelections()
+@pytest.mark.gui
def test_maybe_hide_and_wrap_prev(qtbot, editor):
editor.setPlainText("a a a")
fb = FindBar(editor=editor, shortcut_parent=editor)
@@ -131,40 +133,3 @@ def test_maybe_hide_and_wrap_prev(qtbot, editor):
c.movePosition(QTextCursor.Start)
editor.setTextCursor(c)
fb.find_prev()
-
-
-def _make_fb(editor, qtbot):
- """Create a FindBar with a live parent kept until teardown."""
- parent = QWidget()
- qtbot.addWidget(parent)
- fb = FindBar(editor=editor, parent=parent)
- qtbot.addWidget(fb)
- parent.show()
- fb.show()
- return fb, parent
-
-
-def test_find_next_early_returns_no_editor(qtbot):
- # No editor: should early return and not crash
- fb, _parent = _make_fb(editor=None, qtbot=qtbot)
- fb.find_next()
-
-
-def test_find_next_early_returns_empty_text(qtbot):
- ed = QTextEdit()
- fb, _parent = _make_fb(editor=ed, qtbot=qtbot)
- fb.edit.setText("") # empty -> early return
- fb.find_next()
-
-
-def test_find_prev_early_returns_empty_text(qtbot):
- ed = QTextEdit()
- fb, _parent = _make_fb(editor=ed, qtbot=qtbot)
- fb.edit.setText("") # empty -> early return
- fb.find_prev()
-
-
-def test_update_highlight_early_returns_no_editor(qtbot):
- fb, _parent = _make_fb(editor=None, qtbot=qtbot)
- fb.edit.setText("abc")
- fb._update_highlight() # should return without error
diff --git a/tests/test_history_dialog.py b/tests/test_history_dialog.py
index 98ab9c8..8b58f7a 100644
--- a/tests/test_history_dialog.py
+++ b/tests/test_history_dialog.py
@@ -1,6 +1,7 @@
-from bouquin.history_dialog import HistoryDialog
+from PySide6.QtWidgets import QWidget, QMessageBox
from PySide6.QtCore import Qt, QTimer
-from PySide6.QtWidgets import QApplication, QMessageBox, QWidget
+
+from bouquin.history_dialog import HistoryDialog
def test_history_dialog_lists_and_revert(qtbot, fresh_db):
@@ -82,229 +83,3 @@ def test_history_dialog_revert_error_shows_message(qtbot, fresh_db):
dlg._revert()
finally:
t.stop()
-
-
-def test_revert_returns_when_no_item_selected(qtbot, fresh_db):
- d = "2000-01-01"
- fresh_db.save_new_version(d, "v1", "first")
- w = QWidget()
- dlg = HistoryDialog(fresh_db, d, parent=w)
- qtbot.addWidget(dlg)
- dlg.show()
- # No selection at all -> early return
- dlg.list.clearSelection()
- dlg._revert() # should not raise
-
-
-def test_revert_returns_when_current_selected(qtbot, fresh_db):
- d = "2000-01-02"
- fresh_db.save_new_version(d, "v1", "first")
- # Create a second version so there is a 'current'
- fresh_db.save_new_version(d, "v2", "second")
- w = QWidget()
- dlg = HistoryDialog(fresh_db, d, parent=w)
- qtbot.addWidget(dlg)
- dlg.show()
- # Select the current item -> early return
- for i in range(dlg.list.count()):
- item = dlg.list.item(i)
- if item.data(Qt.UserRole) == dlg._current_id:
- dlg.list.setCurrentItem(item)
- break
- dlg._revert() # no-op
-
-
-def test_revert_exception_shows_message(qtbot, fresh_db, monkeypatch):
- """
- Trigger the exception path in _revert() and auto-accept the modal
- QMessageBox that HistoryDialog pops so the test doesn't hang.
- """
- d = "2000-01-03"
- fresh_db.save_new_version(d, "v1", "first")
- fresh_db.save_new_version(d, "v2", "second")
-
- w = QWidget()
- dlg = HistoryDialog(fresh_db, d, parent=w)
- qtbot.addWidget(dlg)
- dlg.show()
-
- # Select a non-current item
- for i in range(dlg.list.count()):
- item = dlg.list.item(i)
- if item.data(Qt.UserRole) != dlg._current_id:
- dlg.list.setCurrentItem(item)
- break
-
- # Make revert raise to hit the except/critical message path.
- def boom(*_a, **_k):
- raise RuntimeError("nope")
-
- monkeypatch.setattr(dlg._db, "revert_to_version", boom)
-
- # Prepare a small helper that keeps trying to close an active modal box,
- # but gives up after a bounded number of attempts.
- def make_closer(max_tries=50, interval_ms=10):
- tries = {"n": 0}
-
- def closer():
- tries["n"] += 1
- w = QApplication.activeModalWidget()
- if isinstance(w, QMessageBox):
- # Prefer clicking the OK button if present; otherwise accept().
- ok = w.button(QMessageBox.Ok)
- if ok is not None:
- ok.click()
- else:
- w.accept()
- elif tries["n"] < max_tries:
- QTimer.singleShot(interval_ms, closer)
-
- return closer
-
- # Schedule auto-close right before we trigger the modal dialog.
- QTimer.singleShot(0, make_closer())
-
- # Should show the critical box, which our timer will accept; _revert returns.
- dlg._revert()
-
-
-def test_delete_version_from_history(qtbot, fresh_db):
- """Test deleting a version through the history dialog."""
- d = "2001-01-01"
-
- # Create multiple versions
- vid1, _ = fresh_db.save_new_version(d, "v1", "first")
- vid2, _ = fresh_db.save_new_version(d, "v2", "second")
- vid3, _ = fresh_db.save_new_version(d, "v3", "third")
-
- w = QWidget()
- dlg = HistoryDialog(fresh_db, d, parent=w)
- qtbot.addWidget(dlg)
- dlg.show()
-
- # Verify we have 3 versions
- assert dlg.list.count() == 3
-
- # Select the first version (oldest, not current)
- dlg.list.setCurrentRow(2) # Last row is oldest version
-
- # Call _delete
- dlg._delete()
-
- # Verify the version was deleted
- assert dlg.list.count() == 2
-
- # Verify from DB
- versions = fresh_db.list_versions(d)
- assert len(versions) == 2
-
-
-def test_delete_current_version_returns_early(qtbot, fresh_db):
- """Test that deleting the current version returns early without deleting."""
- d = "2001-01-02"
-
- # Create versions
- vid1, _ = fresh_db.save_new_version(d, "v1", "first")
- vid2, _ = fresh_db.save_new_version(d, "v2", "second")
-
- w = QWidget()
- dlg = HistoryDialog(fresh_db, d, parent=w)
- qtbot.addWidget(dlg)
- dlg.show()
-
- # Find and select the current version
- for i in range(dlg.list.count()):
- item = dlg.list.item(i)
- if item.data(Qt.UserRole) == dlg._current_id:
- dlg.list.setCurrentItem(item)
- break
-
- # Try to delete - should return early
- dlg._delete()
-
- # Verify nothing was deleted
- versions = fresh_db.list_versions(d)
- assert len(versions) == 2
-
-
-def test_delete_version_with_error(qtbot, fresh_db, monkeypatch):
- """Test that delete version error shows a message box."""
- d = "2001-01-03"
-
- # Create versions
- vid1, _ = fresh_db.save_new_version(d, "v1", "first")
- vid2, _ = fresh_db.save_new_version(d, "v2", "second")
-
- w = QWidget()
- dlg = HistoryDialog(fresh_db, d, parent=w)
- qtbot.addWidget(dlg)
- dlg.show()
-
- # Select a non-current version
- for i in range(dlg.list.count()):
- item = dlg.list.item(i)
- if item.data(Qt.UserRole) != dlg._current_id:
- dlg.list.setCurrentItem(item)
- break
-
- # Make delete_version raise an error
- def boom(*args, **kwargs):
- raise RuntimeError("Delete failed")
-
- monkeypatch.setattr(dlg._db, "delete_version", boom)
-
- # Set up auto-closer for message box
- def make_closer(max_tries=50, interval_ms=10):
- tries = {"n": 0}
-
- def closer():
- tries["n"] += 1
- w = QApplication.activeModalWidget()
- if isinstance(w, QMessageBox):
- ok = w.button(QMessageBox.Ok)
- if ok is not None:
- ok.click()
- else:
- w.accept()
- elif tries["n"] < max_tries:
- QTimer.singleShot(interval_ms, closer)
-
- return closer
-
- QTimer.singleShot(0, make_closer())
-
- # Call delete - should show error message
- dlg._delete()
-
-
-def test_delete_multiple_versions(qtbot, fresh_db):
- """Test deleting multiple versions at once."""
- d = "2001-01-04"
-
- # Create multiple versions
- vid1, _ = fresh_db.save_new_version(d, "v1", "first")
- vid2, _ = fresh_db.save_new_version(d, "v2", "second")
- vid3, _ = fresh_db.save_new_version(d, "v3", "third")
- vid4, _ = fresh_db.save_new_version(d, "v4", "fourth")
-
- w = QWidget()
- dlg = HistoryDialog(fresh_db, d, parent=w)
- qtbot.addWidget(dlg)
- dlg.show()
-
- # Select multiple non-current items
- selected_count = 0
- for i in range(dlg.list.count()):
- item = dlg.list.item(i)
- if item.data(Qt.UserRole) != dlg._current_id:
- item.setSelected(True)
- selected_count += 1
- if selected_count >= 2: # Select 2 items
- break
-
- # Delete them
- dlg._delete()
-
- # Verify versions were deleted (should have current + 1 remaining)
- versions = fresh_db.list_versions(d)
- assert len(versions) == 2 # Current + 1 that wasn't deleted
diff --git a/tests/test_invoices.py b/tests/test_invoices.py
deleted file mode 100644
index 89ef202..0000000
--- a/tests/test_invoices.py
+++ /dev/null
@@ -1,1346 +0,0 @@
-from datetime import date, timedelta
-
-import pytest
-from bouquin.invoices import (
- _INVOICE_REMINDER_TIME,
- InvoiceDetailMode,
- InvoiceDialog,
- InvoiceLineItem,
- InvoicesDialog,
- _invoice_due_reminder_text,
-)
-from bouquin.reminders import Reminder, ReminderType
-from PySide6.QtCore import QDate, Qt
-from PySide6.QtWidgets import QMessageBox
-
-# ============================================================================
-# Tests for InvoiceDetailMode enum
-# ============================================================================
-
-
-def test_invoice_detail_mode_enum_values(app):
- """Test InvoiceDetailMode enum has expected values."""
- assert InvoiceDetailMode.DETAILED == "detailed"
- assert InvoiceDetailMode.SUMMARY == "summary"
-
-
-def test_invoice_detail_mode_is_string(app):
- """Test InvoiceDetailMode enum inherits from str."""
- assert isinstance(InvoiceDetailMode.DETAILED, str)
- assert isinstance(InvoiceDetailMode.SUMMARY, str)
-
-
-# ============================================================================
-# Tests for InvoiceLineItem dataclass
-# ============================================================================
-
-
-def test_invoice_line_item_creation(app):
- """Test creating an InvoiceLineItem instance."""
- item = InvoiceLineItem(
- description="Development work",
- hours=5.5,
- rate_cents=10000,
- amount_cents=55000,
- )
-
- assert item.description == "Development work"
- assert item.hours == 5.5
- assert item.rate_cents == 10000
- assert item.amount_cents == 55000
-
-
-def test_invoice_line_item_with_zero_values(app):
- """Test InvoiceLineItem with zero values."""
- item = InvoiceLineItem(
- description="",
- hours=0.0,
- rate_cents=0,
- amount_cents=0,
- )
-
- assert item.description == ""
- assert item.hours == 0.0
- assert item.rate_cents == 0
- assert item.amount_cents == 0
-
-
-# ============================================================================
-# Tests for _invoice_due_reminder_text helper function
-# ============================================================================
-
-
-def test_invoice_due_reminder_text_normal(app):
- """Test reminder text generation with normal inputs."""
- result = _invoice_due_reminder_text("Project Alpha", "INV-001")
- assert result == "Invoice INV-001 for Project Alpha is due"
-
-
-def test_invoice_due_reminder_text_with_whitespace(app):
- """Test reminder text strips whitespace from inputs."""
- result = _invoice_due_reminder_text(" Project Beta ", " INV-002 ")
- assert result == "Invoice INV-002 for Project Beta is due"
-
-
-def test_invoice_due_reminder_text_empty_project(app):
- """Test reminder text with empty project name."""
- result = _invoice_due_reminder_text("", "INV-003")
- assert result == "Invoice INV-003 for (no project) is due"
-
-
-def test_invoice_due_reminder_text_empty_invoice_number(app):
- """Test reminder text with empty invoice number."""
- result = _invoice_due_reminder_text("Project Gamma", "")
- assert result == "Invoice ? for Project Gamma is due"
-
-
-def test_invoice_due_reminder_text_both_empty(app):
- """Test reminder text with both inputs empty."""
- result = _invoice_due_reminder_text("", "")
- assert result == "Invoice ? for (no project) is due"
-
-
-# ============================================================================
-# Tests for InvoiceDialog
-# ============================================================================
-
-
-@pytest.fixture
-def invoice_dialog_setup(qtbot, fresh_db):
- """Set up a project with time logs for InvoiceDialog testing."""
- # Create a project
- proj_id = fresh_db.add_project("Test Project")
-
- # Create an activity
- act_id = fresh_db.add_activity("Development")
-
- # Set billing info
- fresh_db.upsert_project_billing(
- proj_id,
- hourly_rate_cents=15000, # $150/hr
- currency="USD",
- tax_label="VAT",
- tax_rate_percent=20.0,
- client_name="John Doe",
- client_company="Acme Corp",
- client_address="123 Main St",
- client_email="john@acme.com",
- )
-
- # Create some time logs
- today = date.today()
- start_date = (today - timedelta(days=7)).isoformat()
- end_date = today.isoformat()
-
- # Add time logs for testing (2.5 hours = 150 minutes)
- for i in range(3):
- log_date = (today - timedelta(days=i)).isoformat()
- fresh_db.add_time_log(
- log_date,
- proj_id,
- act_id,
- 150, # 2.5 hours in minutes
- f"Note {i}",
- )
-
- time_rows = fresh_db.time_logs_for_range(proj_id, start_date, end_date)
-
- return {
- "db": fresh_db,
- "proj_id": proj_id,
- "act_id": act_id,
- "start_date": start_date,
- "end_date": end_date,
- "time_rows": time_rows,
- }
-
-
-def test_invoice_dialog_init(qtbot, invoice_dialog_setup):
- """Test InvoiceDialog initialization."""
- setup = invoice_dialog_setup
- dialog = InvoiceDialog(
- setup["db"],
- setup["proj_id"],
- setup["start_date"],
- setup["end_date"],
- setup["time_rows"],
- )
- qtbot.addWidget(dialog)
-
- assert dialog._db is setup["db"]
- assert dialog._project_id == setup["proj_id"]
- assert dialog._start == setup["start_date"]
- assert dialog._end == setup["end_date"]
- assert len(dialog._time_rows) == 3
-
-
-def test_invoice_dialog_init_without_time_rows(qtbot, invoice_dialog_setup):
- """Test InvoiceDialog initialization without explicit time_rows."""
- setup = invoice_dialog_setup
- dialog = InvoiceDialog(
- setup["db"],
- setup["proj_id"],
- setup["start_date"],
- setup["end_date"],
- )
- qtbot.addWidget(dialog)
-
- # Should fetch time rows from DB
- assert len(dialog._time_rows) == 3
-
-
-def test_invoice_dialog_loads_billing_defaults(qtbot, invoice_dialog_setup):
- """Test that InvoiceDialog loads billing defaults from project."""
- setup = invoice_dialog_setup
- dialog = InvoiceDialog(
- setup["db"],
- setup["proj_id"],
- setup["start_date"],
- setup["end_date"],
- setup["time_rows"],
- )
- qtbot.addWidget(dialog)
-
- assert dialog.currency_edit.text() == "USD"
- assert dialog.rate_spin.value() == 150.0
- assert dialog.client_name_edit.text() == "John Doe"
- assert dialog.client_company_combo.currentText() == "Acme Corp"
-
-
-def test_invoice_dialog_no_billing_defaults(qtbot, fresh_db):
- """Test InvoiceDialog with project that has no billing info."""
- proj_id = fresh_db.add_project("Test Project No Billing")
- today = date.today()
- start = (today - timedelta(days=1)).isoformat()
- end = today.isoformat()
-
- dialog = InvoiceDialog(fresh_db, proj_id, start, end)
- qtbot.addWidget(dialog)
-
- # Should use defaults
- assert dialog.currency_edit.text() == "AUD"
- assert dialog.rate_spin.value() == 0.0
- assert dialog.client_name_edit.text() == ""
-
-
-def test_invoice_dialog_project_name(qtbot, invoice_dialog_setup):
- """Test _project_name method."""
- setup = invoice_dialog_setup
- dialog = InvoiceDialog(
- setup["db"],
- setup["proj_id"],
- setup["start_date"],
- setup["end_date"],
- setup["time_rows"],
- )
- qtbot.addWidget(dialog)
-
- project_name = dialog._project_name()
- assert project_name == "Test Project"
-
-
-def test_invoice_dialog_suggest_invoice_number(qtbot, invoice_dialog_setup):
- """Test _suggest_invoice_number method."""
- setup = invoice_dialog_setup
- dialog = InvoiceDialog(
- setup["db"],
- setup["proj_id"],
- setup["start_date"],
- setup["end_date"],
- setup["time_rows"],
- )
- qtbot.addWidget(dialog)
-
- invoice_number = dialog._suggest_invoice_number()
- # Should be in format YYYY-001 for first invoice (3 digits)
- current_year = date.today().year
- assert invoice_number.startswith(str(current_year))
- assert invoice_number.endswith("-001")
-
-
-def test_invoice_dialog_suggest_invoice_number_increments(qtbot, invoice_dialog_setup):
- """Test that invoice number suggestions increment."""
- setup = invoice_dialog_setup
-
- # Create an invoice first
- dialog1 = InvoiceDialog(
- setup["db"],
- setup["proj_id"],
- setup["start_date"],
- setup["end_date"],
- setup["time_rows"],
- )
- qtbot.addWidget(dialog1)
-
- # Save an invoice to increment the counter
- invoice_number_1 = dialog1._suggest_invoice_number()
- setup["db"].create_invoice(
- project_id=setup["proj_id"],
- invoice_number=invoice_number_1,
- issue_date=date.today().isoformat(),
- due_date=(date.today() + timedelta(days=14)).isoformat(),
- currency="USD",
- tax_label=None,
- tax_rate_percent=None,
- detail_mode=InvoiceDetailMode.DETAILED,
- line_items=[],
- time_log_ids=[],
- )
-
- # Create another dialog and check the number increments
- dialog2 = InvoiceDialog(
- setup["db"],
- setup["proj_id"],
- setup["start_date"],
- setup["end_date"],
- setup["time_rows"],
- )
- qtbot.addWidget(dialog2)
-
- invoice_number_2 = dialog2._suggest_invoice_number()
- current_year = date.today().year
- assert invoice_number_2 == f"{current_year}-002"
-
-
-def test_invoice_dialog_populate_detailed_rows(qtbot, invoice_dialog_setup):
- """Test _populate_detailed_rows method."""
- setup = invoice_dialog_setup
- dialog = InvoiceDialog(
- setup["db"],
- setup["proj_id"],
- setup["start_date"],
- setup["end_date"],
- setup["time_rows"],
- )
- qtbot.addWidget(dialog)
-
- dialog._populate_detailed_rows(15000) # $150/hr in cents
-
- # Check that table has rows
- assert dialog.table.rowCount() == 3
-
- # Check that hours are displayed (COL_HOURS uses cellWidget, not item)
- for row in range(3):
- hours_widget = dialog.table.cellWidget(row, dialog.COL_HOURS)
- assert hours_widget is not None
- assert hours_widget.value() == 2.5
-
-
-def test_invoice_dialog_total_hours_from_table(qtbot, invoice_dialog_setup):
- """Test _total_hours_from_table method."""
- setup = invoice_dialog_setup
- dialog = InvoiceDialog(
- setup["db"],
- setup["proj_id"],
- setup["start_date"],
- setup["end_date"],
- setup["time_rows"],
- )
- qtbot.addWidget(dialog)
-
- dialog._populate_detailed_rows(15000)
-
- total_hours = dialog._total_hours_from_table()
- # 3 rows * 2.5 hours = 7.5 hours
- assert total_hours == 7.5
-
-
-def test_invoice_dialog_detail_line_items(qtbot, invoice_dialog_setup):
- """Test _detail_line_items method."""
- setup = invoice_dialog_setup
- dialog = InvoiceDialog(
- setup["db"],
- setup["proj_id"],
- setup["start_date"],
- setup["end_date"],
- setup["time_rows"],
- )
- qtbot.addWidget(dialog)
-
- dialog.rate_spin.setValue(150.0)
- dialog._populate_detailed_rows(15000)
-
- line_items = dialog._detail_line_items()
- assert len(line_items) == 3
-
- for item in line_items:
- assert isinstance(item, InvoiceLineItem)
- assert item.hours == 2.5
- assert item.rate_cents == 15000
- assert item.amount_cents == 37500 # 2.5 * 15000
-
-
-def test_invoice_dialog_summary_line_items(qtbot, invoice_dialog_setup):
- """Test _summary_line_items method."""
- setup = invoice_dialog_setup
- dialog = InvoiceDialog(
- setup["db"],
- setup["proj_id"],
- setup["start_date"],
- setup["end_date"],
- setup["time_rows"],
- )
- qtbot.addWidget(dialog)
-
- dialog.rate_spin.setValue(150.0)
- dialog._populate_detailed_rows(15000)
-
- line_items = dialog._summary_line_items()
- assert len(line_items) == 1 # Summary should have one line
-
- item = line_items[0]
- assert isinstance(item, InvoiceLineItem)
- # The description comes from summary_desc_edit which has a localized default
- # Just check it's not empty
- assert len(item.description) > 0
- assert item.hours == 7.5 # Total of 3 * 2.5
- assert item.rate_cents == 15000
- assert item.amount_cents == 112500 # 7.5 * 15000
-
-
-def test_invoice_dialog_recalc_amounts(qtbot, invoice_dialog_setup):
- """Test _recalc_amounts method."""
- setup = invoice_dialog_setup
- dialog = InvoiceDialog(
- setup["db"],
- setup["proj_id"],
- setup["start_date"],
- setup["end_date"],
- setup["time_rows"],
- )
- qtbot.addWidget(dialog)
-
- dialog._populate_detailed_rows(15000)
- dialog.rate_spin.setValue(200.0) # Change rate to $200/hr
-
- dialog._recalc_amounts()
-
- # Check that amounts were recalculated
- for row in range(3):
- amount_item = dialog.table.item(row, dialog.COL_AMOUNT)
- assert amount_item is not None
- # 2.5 hours * $200 = $500
- assert amount_item.text() == "500.00"
-
-
-def test_invoice_dialog_recalc_totals(qtbot, invoice_dialog_setup):
- """Test _recalc_totals method."""
- setup = invoice_dialog_setup
- dialog = InvoiceDialog(
- setup["db"],
- setup["proj_id"],
- setup["start_date"],
- setup["end_date"],
- setup["time_rows"],
- )
- qtbot.addWidget(dialog)
-
- dialog.rate_spin.setValue(100.0)
- dialog._populate_detailed_rows(10000)
-
- # Enable tax
- dialog.tax_checkbox.setChecked(True)
- dialog.tax_rate_spin.setValue(10.0)
-
- dialog._recalc_totals()
-
- # 7.5 hours * $100 = $750
- # Tax: $750 * 10% = $75
- # Total: $750 + $75 = $825
- assert "750.00" in dialog.subtotal_label.text()
- assert "75.00" in dialog.tax_label_total.text()
- assert "825.00" in dialog.total_label.text()
-
-
-def test_invoice_dialog_on_tax_toggled(qtbot, invoice_dialog_setup):
- """Test _on_tax_toggled method."""
- setup = invoice_dialog_setup
- dialog = InvoiceDialog(
- setup["db"],
- setup["proj_id"],
- setup["start_date"],
- setup["end_date"],
- setup["time_rows"],
- )
- qtbot.addWidget(dialog)
- dialog.show()
-
- # Initially unchecked (from fixture setup with tax)
- dialog.tax_checkbox.setChecked(False)
- dialog._on_tax_toggled(False)
-
- # Tax fields should be hidden
- assert not dialog.tax_label.isVisible()
- assert not dialog.tax_label_edit.isVisible()
- assert not dialog.tax_rate_label.isVisible()
- assert not dialog.tax_rate_spin.isVisible()
-
- # Check the box
- dialog.tax_checkbox.setChecked(True)
- dialog._on_tax_toggled(True)
-
- # Tax fields should be visible
- assert dialog.tax_label.isVisible()
- assert dialog.tax_label_edit.isVisible()
- assert dialog.tax_rate_label.isVisible()
- assert dialog.tax_rate_spin.isVisible()
-
-
-def test_invoice_dialog_on_client_company_changed(qtbot, invoice_dialog_setup):
- """Test _on_client_company_changed method for autofill."""
- setup = invoice_dialog_setup
-
- # Create another project with different client
- proj_id_2 = setup["db"].add_project("Project 2")
- setup["db"].upsert_project_billing(
- proj_id_2,
- hourly_rate_cents=20000,
- currency="EUR",
- tax_label="GST",
- tax_rate_percent=15.0,
- client_name="Jane Smith",
- client_company="Tech Industries",
- client_address="456 Oak Ave",
- client_email="jane@tech.com",
- )
-
- dialog = InvoiceDialog(
- setup["db"],
- setup["proj_id"],
- setup["start_date"],
- setup["end_date"],
- setup["time_rows"],
- )
- qtbot.addWidget(dialog)
-
- # Initially should have first project's client
- assert dialog.client_name_edit.text() == "John Doe"
-
- # Change to second company
- dialog.client_company_combo.setCurrentText("Tech Industries")
- dialog._on_client_company_changed("Tech Industries")
-
- # Should autofill with second client's info
- assert dialog.client_name_edit.text() == "Jane Smith"
- assert dialog.client_addr_edit.toPlainText() == "456 Oak Ave"
- assert dialog.client_email_edit.text() == "jane@tech.com"
-
-
-def test_invoice_dialog_create_due_date_reminder(qtbot, invoice_dialog_setup):
- """Test _create_due_date_reminder method."""
- setup = invoice_dialog_setup
- dialog = InvoiceDialog(
- setup["db"],
- setup["proj_id"],
- setup["start_date"],
- setup["end_date"],
- setup["time_rows"],
- )
- qtbot.addWidget(dialog)
-
- due_date = (date.today() + timedelta(days=14)).isoformat()
- invoice_number = "INV-TEST-001"
- invoice_id = 999 # Fake invoice ID for testing
-
- dialog._create_due_date_reminder(invoice_id, invoice_number, due_date)
-
- # Check that reminder was created
- reminders = setup["db"].get_all_reminders()
- assert len(reminders) > 0
-
- # Find our reminder
- expected_text = _invoice_due_reminder_text("Test Project", invoice_number)
- matching_reminders = [r for r in reminders if r.text == expected_text]
- assert len(matching_reminders) == 1
-
- reminder = matching_reminders[0]
- assert reminder.reminder_type == ReminderType.ONCE
- assert reminder.date_iso == due_date
- assert reminder.time_str == _INVOICE_REMINDER_TIME
-
-
-# ============================================================================
-# Tests for InvoicesDialog
-# ============================================================================
-
-
-@pytest.fixture
-def invoices_dialog_setup(qtbot, fresh_db):
- """Set up projects with invoices for InvoicesDialog testing."""
- # Create projects
- proj_id_1 = fresh_db.add_project("Project Alpha")
- proj_id_2 = fresh_db.add_project("Project Beta")
-
- # Create invoices for project 1
- today = date.today()
- for i in range(3):
- issue_date = (today - timedelta(days=i * 7)).isoformat()
- due_date = (today - timedelta(days=i * 7) + timedelta(days=14)).isoformat()
- paid_at = today.isoformat() if i == 0 else None # First one is paid
-
- fresh_db.create_invoice(
- project_id=proj_id_1,
- invoice_number=f"ALPHA-{i+1}",
- issue_date=issue_date,
- due_date=due_date,
- currency="USD",
- tax_label="VAT",
- tax_rate_percent=20.0,
- detail_mode=InvoiceDetailMode.DETAILED,
- line_items=[("Development work", 10.0, 15000)], # 10 hours at $150/hr
- time_log_ids=[],
- )
-
- # Update paid_at separately if needed
- if paid_at:
- invoice_rows = fresh_db.get_all_invoices(proj_id_1)
- if invoice_rows:
- inv_id = invoice_rows[0]["id"]
- fresh_db.set_invoice_field_by_id(inv_id, "paid_at", paid_at)
-
- # Create invoices for project 2
- for i in range(2):
- issue_date = (today - timedelta(days=i * 10)).isoformat()
- due_date = (today - timedelta(days=i * 10) + timedelta(days=30)).isoformat()
-
- fresh_db.create_invoice(
- project_id=proj_id_2,
- invoice_number=f"BETA-{i+1}",
- issue_date=issue_date,
- due_date=due_date,
- currency="EUR",
- tax_label=None,
- tax_rate_percent=None,
- detail_mode=InvoiceDetailMode.SUMMARY,
- line_items=[("Consulting services", 10.0, 20000)], # 10 hours at $200/hr
- time_log_ids=[],
- )
-
- return {
- "db": fresh_db,
- "proj_id_1": proj_id_1,
- "proj_id_2": proj_id_2,
- }
-
-
-def test_invoices_dialog_init(qtbot, invoices_dialog_setup):
- """Test InvoicesDialog initialization."""
- setup = invoices_dialog_setup
- dialog = InvoicesDialog(setup["db"])
- qtbot.addWidget(dialog)
-
- assert dialog._db is setup["db"]
- assert dialog.project_combo.count() >= 2 # 2 projects
-
-
-def test_invoices_dialog_init_with_project_id(qtbot, invoices_dialog_setup):
- """Test InvoicesDialog initialization with specific project."""
- setup = invoices_dialog_setup
- dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
- qtbot.addWidget(dialog)
-
- # Should select the specified project
- current_proj = dialog._current_project()
- assert current_proj == setup["proj_id_1"]
-
-
-def test_invoices_dialog_reload_projects(qtbot, invoices_dialog_setup):
- """Test _reload_projects method."""
- setup = invoices_dialog_setup
- dialog = InvoicesDialog(setup["db"])
- qtbot.addWidget(dialog)
-
- initial_count = dialog.project_combo.count()
- assert initial_count >= 2 # Should have 2 projects from setup
-
- # Create a new project
- setup["db"].add_project("Project Gamma")
-
- # Reload projects
- dialog._reload_projects()
-
- # Should have one more project
- assert dialog.project_combo.count() == initial_count + 1
-
-
-def test_invoices_dialog_current_project_specific(qtbot, invoices_dialog_setup):
- """Test _current_project method when specific project is selected."""
- setup = invoices_dialog_setup
- dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
- qtbot.addWidget(dialog)
-
- current_proj = dialog._current_project()
- assert current_proj == setup["proj_id_1"]
-
-
-def test_invoices_dialog_reload_invoices_all_projects(qtbot, invoices_dialog_setup):
- """Test _reload_invoices with first project selected by default."""
- setup = invoices_dialog_setup
- dialog = InvoicesDialog(setup["db"])
- qtbot.addWidget(dialog)
-
- # First project should be selected by default (Project Alpha with 3 invoices)
- # The exact project depends on creation order, so just check we have some invoices
- assert dialog.table.rowCount() in [2, 3] # Either proj1 (3) or proj2 (2)
-
-
-def test_invoices_dialog_reload_invoices_single_project(qtbot, invoices_dialog_setup):
- """Test _reload_invoices with single project selected."""
- setup = invoices_dialog_setup
- dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
- qtbot.addWidget(dialog)
-
- dialog._reload_invoices()
-
- # Should show only 3 invoices from proj1
- assert dialog.table.rowCount() == 3
-
-
-def test_invoices_dialog_on_project_changed(qtbot, invoices_dialog_setup):
- """Test _on_project_changed method."""
- setup = invoices_dialog_setup
- dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_2"])
- qtbot.addWidget(dialog)
-
- # Start with project 2 (2 invoices)
- assert dialog.table.rowCount() == 2
-
- # Find the index of project 1
- for i in range(dialog.project_combo.count()):
- if dialog.project_combo.itemData(i) == setup["proj_id_1"]:
- dialog.project_combo.setCurrentIndex(i)
- break
-
- dialog._on_project_changed(dialog.project_combo.currentIndex())
-
- # Should now show 3 invoices from proj1
- assert dialog.table.rowCount() == 3
-
-
-def test_invoices_dialog_remove_invoice_due_reminder(qtbot, invoices_dialog_setup):
- """Test _remove_invoice_due_reminder method."""
- setup = invoices_dialog_setup
-
- # Create a reminder for an invoice
- due_date = (date.today() + timedelta(days=7)).isoformat()
- invoice_number = "TEST-REMINDER-001"
- project_name = "Project Alpha"
-
- reminder_text = _invoice_due_reminder_text(project_name, invoice_number)
- reminder = Reminder(
- id=None,
- text=reminder_text,
- time_str=_INVOICE_REMINDER_TIME,
- reminder_type=ReminderType.ONCE,
- date_iso=due_date,
- active=True,
- )
- reminder.id = setup["db"].save_reminder(reminder)
-
- # Verify reminder exists
- reminders = setup["db"].get_all_reminders()
- assert len(reminders) == 1
-
- # Create dialog and populate with invoices
- dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
- qtbot.addWidget(dialog)
-
- # Manually add a row to test the removal (simulating the invoice row)
- row = dialog.table.rowCount()
- dialog.table.insertRow(row)
-
- # Set the project and invoice number items
- from PySide6.QtWidgets import QTableWidgetItem
-
- proj_item = QTableWidgetItem(project_name)
- num_item = QTableWidgetItem(invoice_number)
- dialog.table.setItem(row, dialog.COL_PROJECT, proj_item)
- dialog.table.setItem(row, dialog.COL_NUMBER, num_item)
-
- # Mock invoice_id
- num_item.setData(Qt.ItemDataRole.UserRole, 999)
-
- # Call the removal method
- dialog._remove_invoice_due_reminder(row, 999)
-
- # Reminder should be deleted
- reminders_after = setup["db"].get_all_reminders()
- assert len(reminders_after) == 0
-
-
-def test_invoices_dialog_on_item_changed_invoice_number(qtbot, invoices_dialog_setup):
- """Test _on_item_changed for invoice number editing."""
- setup = invoices_dialog_setup
- dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
- qtbot.addWidget(dialog)
-
- # Get the first row's invoice ID
- num_item = dialog.table.item(0, dialog.COL_NUMBER)
- inv_id = num_item.data(Qt.ItemDataRole.UserRole)
-
- # Change the invoice number
- num_item.setText("ALPHA-MODIFIED")
-
- # Trigger the change handler
- dialog._on_item_changed(num_item)
-
- # Verify the change was saved to DB
- invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "invoice_number")
- assert invoice_data["invoice_number"] == "ALPHA-MODIFIED"
-
-
-def test_invoices_dialog_on_item_changed_empty_invoice_number(
- qtbot, invoices_dialog_setup, monkeypatch
-):
- """Test _on_item_changed rejects empty invoice number."""
- setup = invoices_dialog_setup
- dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
- qtbot.addWidget(dialog)
-
- # Mock QMessageBox to auto-close
- def mock_warning(*args, **kwargs):
- return QMessageBox.Ok
-
- monkeypatch.setattr(QMessageBox, "warning", mock_warning)
-
- # Get the first row's invoice number item
- num_item = dialog.table.item(0, dialog.COL_NUMBER)
- original_number = num_item.text()
-
- # Try to set empty invoice number
- num_item.setText("")
- dialog._on_item_changed(num_item)
-
- # Should be reset to original
- assert num_item.text() == original_number
-
-
-def test_invoices_dialog_on_item_changed_issue_date(qtbot, invoices_dialog_setup):
- """Test _on_item_changed for issue date editing."""
- setup = invoices_dialog_setup
- dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
- qtbot.addWidget(dialog)
-
- # Get the first row
- num_item = dialog.table.item(0, dialog.COL_NUMBER)
- inv_id = num_item.data(Qt.ItemDataRole.UserRole)
-
- issue_item = dialog.table.item(0, dialog.COL_ISSUE_DATE)
- new_date = "2024-01-15"
- issue_item.setText(new_date)
-
- dialog._on_item_changed(issue_item)
-
- # Verify change was saved
- invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "issue_date")
- assert invoice_data["issue_date"] == new_date
-
-
-def test_invoices_dialog_on_item_changed_invalid_date(
- qtbot, invoices_dialog_setup, monkeypatch
-):
- """Test _on_item_changed rejects invalid date format."""
- setup = invoices_dialog_setup
- dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
- qtbot.addWidget(dialog)
-
- # Mock QMessageBox
- def mock_warning(*args, **kwargs):
- return QMessageBox.Ok
-
- monkeypatch.setattr(QMessageBox, "warning", mock_warning)
-
- issue_item = dialog.table.item(0, dialog.COL_ISSUE_DATE)
- original_date = issue_item.text()
-
- # Try to set invalid date
- issue_item.setText("not-a-date")
- dialog._on_item_changed(issue_item)
-
- # Should be reset to original
- assert issue_item.text() == original_date
-
-
-def test_invoices_dialog_on_item_changed_due_before_issue(
- qtbot, invoices_dialog_setup, monkeypatch
-):
- """Test _on_item_changed rejects due date before issue date."""
- setup = invoices_dialog_setup
- dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
- qtbot.addWidget(dialog)
-
- # Mock QMessageBox
- def mock_warning(*args, **kwargs):
- return QMessageBox.Ok
-
- monkeypatch.setattr(QMessageBox, "warning", mock_warning)
-
- # Set issue date
- issue_item = dialog.table.item(0, dialog.COL_ISSUE_DATE)
- issue_item.setText("2024-02-01")
- dialog._on_item_changed(issue_item)
-
- # Try to set due date before issue date
- due_item = dialog.table.item(0, dialog.COL_DUE_DATE)
- original_due = due_item.text()
- due_item.setText("2024-01-01")
- dialog._on_item_changed(due_item)
-
- # Should be reset
- assert due_item.text() == original_due
-
-
-def test_invoices_dialog_on_item_changed_currency(qtbot, invoices_dialog_setup):
- """Test _on_item_changed for currency editing."""
- setup = invoices_dialog_setup
- dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
- qtbot.addWidget(dialog)
-
- # Get the first row
- num_item = dialog.table.item(0, dialog.COL_NUMBER)
- inv_id = num_item.data(Qt.ItemDataRole.UserRole)
-
- currency_item = dialog.table.item(0, dialog.COL_CURRENCY)
- currency_item.setText("gbp") # lowercase
-
- dialog._on_item_changed(currency_item)
-
- # Should be normalized to uppercase
- assert currency_item.text() == "GBP"
-
- # Verify change was saved
- invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "currency")
- assert invoice_data["currency"] == "GBP"
-
-
-def test_invoices_dialog_on_item_changed_tax_rate(qtbot, invoices_dialog_setup):
- """Test _on_item_changed for tax rate editing."""
- setup = invoices_dialog_setup
- dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
- qtbot.addWidget(dialog)
-
- # Get the first row
- num_item = dialog.table.item(0, dialog.COL_NUMBER)
- inv_id = num_item.data(Qt.ItemDataRole.UserRole)
-
- tax_rate_item = dialog.table.item(0, dialog.COL_TAX_RATE)
- tax_rate_item.setText("15.5")
-
- dialog._on_item_changed(tax_rate_item)
-
- # Verify change was saved
- invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "tax_rate_percent")
- assert invoice_data["tax_rate_percent"] == 15.5
-
-
-def test_invoices_dialog_on_item_changed_invalid_tax_rate(
- qtbot, invoices_dialog_setup, monkeypatch
-):
- """Test _on_item_changed rejects invalid tax rate."""
- setup = invoices_dialog_setup
- dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
- qtbot.addWidget(dialog)
-
- # Mock QMessageBox
- def mock_warning(*args, **kwargs):
- return QMessageBox.Ok
-
- monkeypatch.setattr(QMessageBox, "warning", mock_warning)
-
- tax_rate_item = dialog.table.item(0, dialog.COL_TAX_RATE)
- original_rate = tax_rate_item.text()
-
- # Try to set invalid tax rate
- tax_rate_item.setText("not-a-number")
- dialog._on_item_changed(tax_rate_item)
-
- # Should be reset to original
- assert tax_rate_item.text() == original_rate
-
-
-def test_invoices_dialog_on_item_changed_subtotal(qtbot, invoices_dialog_setup):
- """Test _on_item_changed for subtotal editing."""
- setup = invoices_dialog_setup
- dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
- qtbot.addWidget(dialog)
-
- # Get the first row
- num_item = dialog.table.item(0, dialog.COL_NUMBER)
- inv_id = num_item.data(Qt.ItemDataRole.UserRole)
-
- subtotal_item = dialog.table.item(0, dialog.COL_SUBTOTAL)
- subtotal_item.setText("1234.56")
-
- dialog._on_item_changed(subtotal_item)
-
- # Verify change was saved (in cents)
- invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "subtotal_cents")
- assert invoice_data["subtotal_cents"] == 123456
-
- # Should be normalized to 2 decimals
- assert subtotal_item.text() == "1234.56"
-
-
-def test_invoices_dialog_on_item_changed_invalid_amount(
- qtbot, invoices_dialog_setup, monkeypatch
-):
- """Test _on_item_changed rejects invalid amount."""
- setup = invoices_dialog_setup
- dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
- qtbot.addWidget(dialog)
-
- # Mock QMessageBox
- def mock_warning(*args, **kwargs):
- return QMessageBox.Ok
-
- monkeypatch.setattr(QMessageBox, "warning", mock_warning)
-
- subtotal_item = dialog.table.item(0, dialog.COL_SUBTOTAL)
- original_subtotal = subtotal_item.text()
-
- # Try to set invalid amount
- subtotal_item.setText("not-a-number")
- dialog._on_item_changed(subtotal_item)
-
- # Should be reset to original
- assert subtotal_item.text() == original_subtotal
-
-
-def test_invoices_dialog_on_item_changed_paid_at_removes_reminder(
- qtbot, invoices_dialog_setup
-):
- """Test that marking invoice as paid removes due date reminder."""
- setup = invoices_dialog_setup
-
- # Create a reminder for an invoice
- due_date = (date.today() + timedelta(days=7)).isoformat()
- invoice_number = "ALPHA-1"
- project_name = "Project Alpha"
-
- reminder_text = _invoice_due_reminder_text(project_name, invoice_number)
- reminder = Reminder(
- id=None,
- text=reminder_text,
- time_str=_INVOICE_REMINDER_TIME,
- reminder_type=ReminderType.ONCE,
- date_iso=due_date,
- active=True,
- )
- reminder.id = setup["db"].save_reminder(reminder)
-
- # Verify reminder exists
- reminders = setup["db"].get_all_reminders()
- assert any(r.text == reminder_text for r in reminders)
-
- dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
- qtbot.addWidget(dialog)
-
- # Find the ALPHA-1 invoice row
- for row in range(dialog.table.rowCount()):
- num_item = dialog.table.item(row, dialog.COL_NUMBER)
- if num_item and num_item.text() == "ALPHA-1":
- # Mark as paid
- paid_item = dialog.table.item(row, dialog.COL_PAID_AT)
- paid_item.setText(date.today().isoformat())
- dialog._on_item_changed(paid_item)
- break
-
- # Reminder should be removed
- reminders_after = setup["db"].get_all_reminders()
- assert not any(r.text == reminder_text for r in reminders_after)
-
-
-def test_invoices_dialog_ignores_changes_while_reloading(qtbot, invoices_dialog_setup):
- """Test that _on_item_changed is ignored during reload."""
- setup = invoices_dialog_setup
- dialog = InvoicesDialog(setup["db"], initial_project_id=setup["proj_id_1"])
- qtbot.addWidget(dialog)
-
- # Set reloading flag
- dialog._reloading_invoices = True
-
- # Try to change an item
- num_item = dialog.table.item(0, dialog.COL_NUMBER)
- original_number = num_item.text()
- inv_id = num_item.data(Qt.ItemDataRole.UserRole)
-
- num_item.setText("SHOULD-BE-IGNORED")
- dialog._on_item_changed(num_item)
-
- # Change should not be saved to DB
- invoice_data = setup["db"].get_invoice_field_by_id(inv_id, "invoice_number")
- assert invoice_data["invoice_number"] == original_number
-
-
-def test_invoice_dialog_update_mode_enabled(qtbot, invoice_dialog_setup):
- """Test _update_mode_enabled method."""
- setup = invoice_dialog_setup
- dialog = InvoiceDialog(
- setup["db"],
- setup["proj_id"],
- setup["start_date"],
- setup["end_date"],
- setup["time_rows"],
- )
- qtbot.addWidget(dialog)
- dialog.show()
-
- # Initially detailed mode should be selected
- assert dialog.rb_detailed.isChecked()
-
- # Table should be enabled in detailed mode
- assert dialog.table.isEnabled()
-
- # Switch to summary mode
- dialog.rb_summary.setChecked(True)
- dialog._update_mode_enabled()
-
- # Table should be disabled in summary mode
- assert not dialog.table.isEnabled()
-
-
-def test_invoice_dialog_with_no_time_logs(qtbot, fresh_db):
- """Test InvoiceDialog with project that has no time logs."""
- proj_id = fresh_db.add_project("Empty Project")
- today = date.today()
- start = (today - timedelta(days=7)).isoformat()
- end = today.isoformat()
-
- dialog = InvoiceDialog(fresh_db, proj_id, start, end)
- qtbot.addWidget(dialog)
-
- # Should handle empty time logs gracefully
- assert len(dialog._time_rows) == 0
- assert dialog.table.rowCount() == 0
-
-
-def test_invoice_dialog_loads_client_company_list(qtbot, invoice_dialog_setup):
- """Test that InvoiceDialog loads existing client companies."""
- setup = invoice_dialog_setup
-
- # Create another project with a different client company
- proj_id_2 = setup["db"].add_project("Project 2")
- setup["db"].upsert_project_billing(
- proj_id_2,
- hourly_rate_cents=10000,
- currency="EUR",
- tax_label="VAT",
- tax_rate_percent=19.0,
- client_name="Jane Doe",
- client_company="Beta Corp",
- client_address="456 Main St",
- client_email="jane@beta.com",
- )
-
- dialog = InvoiceDialog(
- setup["db"],
- setup["proj_id"],
- setup["start_date"],
- setup["end_date"],
- setup["time_rows"],
- )
- qtbot.addWidget(dialog)
-
- # Should have both companies in the combo
- companies = [
- dialog.client_company_combo.itemText(i)
- for i in range(dialog.client_company_combo.count())
- ]
- assert "Acme Corp" in companies
- assert "Beta Corp" in companies
-
-
-def test_invoice_line_item_equality(app):
- """Test InvoiceLineItem equality."""
- item1 = InvoiceLineItem("Work", 5.0, 10000, 50000)
- item2 = InvoiceLineItem("Work", 5.0, 10000, 50000)
- item3 = InvoiceLineItem("Other", 5.0, 10000, 50000)
-
- assert item1 == item2
- assert item1 != item3
-
-
-def test_invoices_dialog_empty_database(qtbot, fresh_db):
- """Test InvoicesDialog with no projects or invoices."""
- dialog = InvoicesDialog(fresh_db)
- qtbot.addWidget(dialog)
-
- # Should have no projects in combo
- assert dialog.project_combo.count() == 0
- assert dialog.table.rowCount() == 0
-
-
-def test_invoice_dialog_tax_initially_disabled(qtbot, fresh_db):
- """Test that tax fields are hidden when tax_rate_percent is None."""
- proj_id = fresh_db.add_project("No Tax Project")
- fresh_db.upsert_project_billing(
- proj_id,
- hourly_rate_cents=10000,
- currency="USD",
- tax_label="Tax",
- tax_rate_percent=None, # No tax
- client_name="Client",
- client_company="Company",
- client_address="Address",
- client_email="email@test.com",
- )
-
- today = date.today()
- start = (today - timedelta(days=1)).isoformat()
- end = today.isoformat()
-
- dialog = InvoiceDialog(fresh_db, proj_id, start, end)
- qtbot.addWidget(dialog)
- dialog.show()
-
- # Tax checkbox should be unchecked
- assert not dialog.tax_checkbox.isChecked()
-
- # Tax fields should be hidden
- assert not dialog.tax_label.isVisible()
- assert not dialog.tax_label_edit.isVisible()
- assert not dialog.tax_rate_label.isVisible()
- assert not dialog.tax_rate_spin.isVisible()
-
-
-def test_invoice_dialog_dates_default_values(qtbot, invoice_dialog_setup):
- """Test that issue and due dates have correct default values."""
- setup = invoice_dialog_setup
- dialog = InvoiceDialog(
- setup["db"],
- setup["proj_id"],
- setup["start_date"],
- setup["end_date"],
- setup["time_rows"],
- )
- qtbot.addWidget(dialog)
-
- # Issue date should be today
- assert dialog.issue_date_edit.date() == QDate.currentDate()
-
- # Due date should be 14 days from today
- QDate.currentDate().addDays(14)
- assert dialog.issue_date_edit.date() == QDate.currentDate()
-
-
-def test_invoice_dialog_checkbox_toggle_updates_totals(qtbot, invoice_dialog_setup):
- """Test that unchecking a line item updates the total cost."""
- setup = invoice_dialog_setup
- dialog = InvoiceDialog(
- setup["db"],
- setup["proj_id"],
- setup["start_date"],
- setup["end_date"],
- setup["time_rows"],
- )
- qtbot.addWidget(dialog)
-
- dialog.rate_spin.setValue(100.0)
- dialog._populate_detailed_rows(10000)
- dialog.tax_checkbox.setChecked(False)
-
- # Initial total: 3 rows * 2.5 hours * $100 = $750
- dialog._recalc_totals()
- assert "750.00" in dialog.subtotal_label.text()
- assert "750.00" in dialog.total_label.text()
-
- # Uncheck the first row
- include_item = dialog.table.item(0, dialog.COL_INCLUDE)
- include_item.setCheckState(Qt.Unchecked)
-
- # Wait for signal processing
- qtbot.wait(10)
-
- # New total: 2 rows * 2.5 hours * $100 = $500
- assert "500.00" in dialog.subtotal_label.text()
- assert "500.00" in dialog.total_label.text()
-
-
-def test_invoice_dialog_checkbox_toggle_with_tax(qtbot, invoice_dialog_setup):
- """Test that checkbox toggling works correctly with tax enabled."""
- setup = invoice_dialog_setup
- dialog = InvoiceDialog(
- setup["db"],
- setup["proj_id"],
- setup["start_date"],
- setup["end_date"],
- setup["time_rows"],
- )
- qtbot.addWidget(dialog)
-
- dialog.rate_spin.setValue(100.0)
- dialog._populate_detailed_rows(10000)
- dialog.tax_checkbox.setChecked(True)
- dialog.tax_rate_spin.setValue(10.0)
-
- # Initial: 3 rows * 2.5 hours * $100 = $750
- # Tax: $750 * 10% = $75
- # Total: $825
- dialog._recalc_totals()
- assert "750.00" in dialog.subtotal_label.text()
- assert "75.00" in dialog.tax_label_total.text()
- assert "825.00" in dialog.total_label.text()
-
- # Uncheck two rows
- dialog.table.item(0, dialog.COL_INCLUDE).setCheckState(Qt.Unchecked)
- dialog.table.item(1, dialog.COL_INCLUDE).setCheckState(Qt.Unchecked)
-
- # Wait for signal processing
- qtbot.wait(10)
-
- # New total: 1 row * 2.5 hours * $100 = $250
- # Tax: $250 * 10% = $25
- # Total: $275
- assert "250.00" in dialog.subtotal_label.text()
- assert "25.00" in dialog.tax_label_total.text()
- assert "275.00" in dialog.total_label.text()
-
-
-def test_invoice_dialog_rechecking_items_updates_totals(qtbot, invoice_dialog_setup):
- """Test that rechecking a previously unchecked item updates totals."""
- setup = invoice_dialog_setup
- dialog = InvoiceDialog(
- setup["db"],
- setup["proj_id"],
- setup["start_date"],
- setup["end_date"],
- setup["time_rows"],
- )
- qtbot.addWidget(dialog)
-
- dialog.rate_spin.setValue(100.0)
- dialog._populate_detailed_rows(10000)
- dialog.tax_checkbox.setChecked(False)
-
- # Uncheck all items
- for row in range(dialog.table.rowCount()):
- dialog.table.item(row, dialog.COL_INCLUDE).setCheckState(Qt.Unchecked)
-
- qtbot.wait(10)
-
- # Total should be 0
- assert "0.00" in dialog.total_label.text()
-
- # Re-check first item
- dialog.table.item(0, dialog.COL_INCLUDE).setCheckState(Qt.Checked)
- qtbot.wait(10)
-
- # Total should be 1 row * 2.5 hours * $100 = $250
- assert "250.00" in dialog.total_label.text()
-
-
-def test_invoices_dialog_select_initial_project(qtbot, invoices_dialog_setup):
- """Test _select_initial_project method."""
- setup = invoices_dialog_setup
- dialog = InvoicesDialog(setup["db"])
- qtbot.addWidget(dialog)
-
- # Initially should have first project selected (either proj1 or proj2)
- initial_proj = dialog._current_project()
- assert initial_proj in [setup["proj_id_1"], setup["proj_id_2"]]
-
- # Select specific project
- dialog._select_initial_project(setup["proj_id_2"])
-
- # Should now have proj_id_2 selected
- assert dialog._current_project() == setup["proj_id_2"]
diff --git a/tests/test_key_prompt.py b/tests/test_key_prompt.py
index 9aedffb..07a0044 100644
--- a/tests/test_key_prompt.py
+++ b/tests/test_key_prompt.py
@@ -1,204 +1,9 @@
from bouquin.key_prompt import KeyPrompt
-from PySide6.QtCore import QTimer
-from PySide6.QtWidgets import QFileDialog, QLineEdit
def test_key_prompt_roundtrip(qtbot):
kp = KeyPrompt()
qtbot.addWidget(kp)
kp.show()
- kp.key_entry.setText("swordfish")
+ kp.edit.setText("swordfish")
assert kp.key() == "swordfish"
-
-
-def test_key_prompt_with_db_path_browse(qtbot, app, tmp_path, monkeypatch):
- """Test KeyPrompt with DB path selection - covers lines 57-67"""
- test_db = tmp_path / "test.db"
- test_db.touch()
-
- # Create prompt with show_db_change=True
- prompt = KeyPrompt(show_db_change=True)
- qtbot.addWidget(prompt)
-
- # Mock the file dialog to return a file
- def mock_get_open_filename(*args, **kwargs):
- return str(test_db), "SQLCipher DB (*.db)"
-
- monkeypatch.setattr(QFileDialog, "getOpenFileName", mock_get_open_filename)
-
- # Simulate clicking the browse button
- # Find the browse button by looking through the widget's children
- browse_btn = None
- for child in prompt.findChildren(object):
- if hasattr(child, "clicked") and hasattr(child, "text"):
- if (
- "select" in str(child.text()).lower()
- or "browse" in str(child.text()).lower()
- ):
- browse_btn = child
- break
-
- if browse_btn:
- browse_btn.click()
- qtbot.wait(50)
-
- # Verify the path was set
- assert prompt.path_edit is not None
- assert str(test_db) in prompt.path_edit.text()
-
-
-def test_key_prompt_with_db_path_no_file_selected(qtbot, app, tmp_path, monkeypatch):
- """Test KeyPrompt when cancel is clicked in file dialog - covers line 64 condition"""
- # Create prompt with show_db_change=True
- prompt = KeyPrompt(show_db_change=True)
- qtbot.addWidget(prompt)
-
- # Mock the file dialog to return empty string (user cancelled)
- def mock_get_open_filename(*args, **kwargs):
- return "", ""
-
- monkeypatch.setattr(QFileDialog, "getOpenFileName", mock_get_open_filename)
-
- # Store original path text
- original_text = prompt.path_edit.text() if prompt.path_edit else ""
-
- # Simulate clicking the browse button
- browse_btn = None
- for child in prompt.findChildren(object):
- if hasattr(child, "clicked") and hasattr(child, "text"):
- if (
- "select" in str(child.text()).lower()
- or "browse" in str(child.text()).lower()
- ):
- browse_btn = child
- break
-
- if browse_btn:
- browse_btn.click()
- qtbot.wait(50)
-
- # Path should not have changed since no file was selected
- if prompt.path_edit:
- assert prompt.path_edit.text() == original_text
-
-
-def test_key_prompt_with_existing_db_path(qtbot, app, tmp_path):
- """Test KeyPrompt with existing DB path provided"""
- test_db = tmp_path / "existing.db"
- test_db.touch()
-
- prompt = KeyPrompt(show_db_change=True, initial_db_path=test_db)
- qtbot.addWidget(prompt)
-
- # Verify the path is pre-filled
- assert prompt.path_edit is not None
- assert str(test_db) in prompt.path_edit.text()
-
-
-def test_key_prompt_with_db_path_none_and_show_db_change(qtbot, app):
- """Test KeyPrompt with show_db_change but no initial_db_path"""
- prompt = KeyPrompt(show_db_change=True, initial_db_path=None)
- qtbot.addWidget(prompt)
-
- # Path edit should exist but be empty
- assert prompt.path_edit is not None
- assert prompt.path_edit.text() == ""
-
-
-def test_key_prompt_accept_with_valid_key(qtbot, app):
- """Test accepting prompt with valid key"""
- prompt = KeyPrompt()
- qtbot.addWidget(prompt)
-
- # Enter a key
- prompt.key_entry.setText("test-key-123")
-
- # Accept
- QTimer.singleShot(0, prompt.accept)
- qtbot.wait(50)
-
- assert prompt.key_entry.text() == "test-key-123"
-
-
-def test_key_prompt_without_db_change(qtbot, app):
- """Test KeyPrompt without show_db_change"""
- prompt = KeyPrompt(show_db_change=False)
- qtbot.addWidget(prompt)
-
- # Path edit should not exist
- assert prompt.path_edit is None
-
-
-def test_key_prompt_password_visibility(qtbot, app):
- """Test password entry mode"""
- prompt = KeyPrompt()
- qtbot.addWidget(prompt)
-
- # Initially should be password mode
- assert prompt.key_entry.echoMode() == QLineEdit.EchoMode.Password
-
- # Enter some text
- prompt.key_entry.setText("secret")
-
- # The text should be obscured
- assert prompt.key_entry.echoMode() == QLineEdit.EchoMode.Password
-
-
-def test_key_prompt_key_method(qtbot, app):
- """Test the key() method returns entered text"""
- prompt = KeyPrompt()
- qtbot.addWidget(prompt)
-
- prompt.key_entry.setText("my-secret-key")
-
- assert prompt.key() == "my-secret-key"
-
-
-def test_key_prompt_db_path_method(qtbot, app, tmp_path):
- """Test the db_path() method returns selected path"""
- test_db = tmp_path / "test.db"
- test_db.touch()
-
- prompt = KeyPrompt(show_db_change=True, initial_db_path=test_db)
- qtbot.addWidget(prompt)
-
- # Should return the db_path
- assert prompt.db_path() == test_db
-
-
-def test_key_prompt_browse_with_initial_path(qtbot, app, tmp_path, monkeypatch):
- """Test browsing when initial_db_path is set"""
- initial_db = tmp_path / "initial.db"
- initial_db.touch()
-
- new_db = tmp_path / "new.db"
- new_db.touch()
-
- prompt = KeyPrompt(show_db_change=True, initial_db_path=initial_db)
- qtbot.addWidget(prompt)
-
- # Mock the file dialog to return a different file
- def mock_get_open_filename(*args, **kwargs):
- # Verify that start_dir was passed correctly
- return str(new_db), "SQLCipher DB (*.db)"
-
- monkeypatch.setattr(QFileDialog, "getOpenFileName", mock_get_open_filename)
-
- # Find and click browse button
- browse_btn = None
- for child in prompt.findChildren(object):
- if hasattr(child, "clicked") and hasattr(child, "text"):
- if (
- "select" in str(child.text()).lower()
- or "browse" in str(child.text()).lower()
- ):
- browse_btn = child
- break
-
- if browse_btn:
- browse_btn.click()
- qtbot.wait(50)
-
- # Verify new path was set
- assert str(new_db) in prompt.path_edit.text()
- assert prompt.db_path() == new_db
diff --git a/tests/test_lock_overlay.py b/tests/test_lock_overlay.py
index 46b3cfd..db6529b 100644
--- a/tests/test_lock_overlay.py
+++ b/tests/test_lock_overlay.py
@@ -1,9 +1,11 @@
-from bouquin.lock_overlay import LockOverlay
-from bouquin.theme import Theme, ThemeConfig, ThemeManager
+import pytest
from PySide6.QtCore import QEvent
from PySide6.QtWidgets import QWidget
+from bouquin.lock_overlay import LockOverlay
+from bouquin.theme import ThemeManager, ThemeConfig, Theme
+@pytest.mark.gui
def test_lock_overlay_reacts_to_theme(app, qtbot):
host = QWidget()
qtbot.addWidget(host)
diff --git a/tests/test_main.py b/tests/test_main.py
index 5bfb774..94d3b11 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -1,6 +1,5 @@
import importlib
import runpy
-
import pytest
@@ -43,9 +42,6 @@ def test_main_creates_and_shows(monkeypatch):
def setOrganizationName(self, *_):
pass
- def setWindowIcon(self, *_):
- pass
-
def exec(self):
return 0
diff --git a/tests/test_main_window.py b/tests/test_main_window.py
index 6c09e71..cad9139 100644
--- a/tests/test_main_window.py
+++ b/tests/test_main_window.py
@@ -1,33 +1,21 @@
-import importlib.metadata
-from datetime import date, timedelta
-from pathlib import Path
-from unittest.mock import Mock, patch
-
-import bouquin.main_window as mwmod
-import bouquin.version_check as version_check
import pytest
-from bouquin.db import DBConfig, DBManager
-from bouquin.key_prompt import KeyPrompt
+import bouquin.main_window as mwmod
from bouquin.main_window import MainWindow
-from bouquin.settings import get_settings
from bouquin.theme import Theme, ThemeConfig, ThemeManager
-from PySide6.QtCore import QDate, QEvent, QPoint, QRect, Qt, QTimer
-from PySide6.QtGui import QCloseEvent, QKeyEvent, QMouseEvent, QTextCursor
-from PySide6.QtWidgets import QApplication, QDialog, QMessageBox, QTableView, QWidget
+from bouquin.settings import get_settings
+from bouquin.key_prompt import KeyPrompt
+from PySide6.QtCore import QEvent, QDate, QTimer
+from PySide6.QtWidgets import QTableView, QApplication
+@pytest.mark.gui
def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db):
s = get_settings()
- s.setValue("db/default_db", str(tmp_db_cfg.path))
+ s.setValue("db/path", str(tmp_db_cfg.path))
s.setValue("db/key", tmp_db_cfg.key)
s.setValue("ui/idle_minutes", 0)
s.setValue("ui/theme", "light")
s.setValue("ui/move_todos", True)
- s.setValue("ui/tags", True)
- s.setValue("ui/time_log", True)
- s.setValue("ui/reminders", True)
- s.setValue("ui/locale", "en")
- s.setValue("ui/font_size", 11)
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
w = MainWindow(themes=themes)
@@ -53,7 +41,7 @@ def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db):
def _auto_accept_keyprompt():
for wdg in QApplication.topLevelWidgets():
if isinstance(wdg, KeyPrompt):
- wdg.key_entry.setText(tmp_db_cfg.key)
+ wdg.edit.setText(tmp_db_cfg.key)
wdg.accept()
w._enter_lock()
@@ -64,7 +52,7 @@ def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db):
def test_load_yesterday_todos_moves_items(qtbot, app, tmp_db_cfg, fresh_db):
s = get_settings()
- s.setValue("db/default_db", str(tmp_db_cfg.path))
+ s.setValue("db/path", str(tmp_db_cfg.path))
s.setValue("db/key", tmp_db_cfg.key)
s.setValue("ui/move_todos", True)
s.setValue("ui/theme", "light")
@@ -78,14 +66,15 @@ def test_load_yesterday_todos_moves_items(qtbot, app, tmp_db_cfg, fresh_db):
qtbot.addWidget(w)
w.show()
- w._load_unchecked_todos()
+ w._load_yesterday_todos()
assert "carry me" in w.editor.to_markdown()
y_txt = fresh_db.get_entry(y)
assert "carry me" not in y_txt or "- [ ]" not in y_txt
-def test_open_docs_warning(qtbot, app, fresh_db, tmp_path, monkeypatch):
+@pytest.mark.gui
+def test_open_docs_and_bugs_warning(qtbot, app, fresh_db, tmp_path, monkeypatch):
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
w = MainWindow(themes=themes)
qtbot.addWidget(w)
@@ -106,14 +95,19 @@ def test_open_docs_warning(qtbot, app, fresh_db, tmp_path, monkeypatch):
t = str(text)
if "wiki" in t:
called["docs"] = True
+ if "forms/mig5/contact" in t or "contact" in t:
+ called["bugs"] = True
return 0
monkeypatch.setattr(mwmod, "QMessageBox", DummyMB, raising=True) # capture warnings
+ # Trigger both actions
w._open_docs()
- assert called["docs"]
+ w._open_bugs()
+ assert called["docs"] and called["bugs"]
+@pytest.mark.gui
def test_export_success_and_error(qtbot, app, fresh_db, tmp_path, monkeypatch):
# Seed some content
fresh_db.save_new_version("2001-01-01", "alpha", "n1")
@@ -123,7 +117,7 @@ def test_export_success_and_error(qtbot, app, fresh_db, tmp_path, monkeypatch):
from bouquin.settings import get_settings
s = get_settings()
- s.setValue("db/default_db", str(fresh_db.cfg.path))
+ s.setValue("db/path", str(fresh_db.cfg.path))
s.setValue("db/key", fresh_db.cfg.key)
s.setValue("ui/idle_minutes", 0)
s.setValue("ui/theme", "light")
@@ -133,20 +127,25 @@ def test_export_success_and_error(qtbot, app, fresh_db, tmp_path, monkeypatch):
# Save as Markdown without extension -> should append .md and write file
dest1 = tmp_path / "export_one" # no suffix
- monkeypatch.setattr(
- mwmod.QFileDialog,
- "getSaveFileName",
- staticmethod(lambda *a, **k: (str(dest1), "Markdown (*.md)")),
- )
- # Use real QMessageBox class; just force decisions and silence popups
+ def fake_save1(*a, **k):
+ return str(dest1), "Markdown (*.md)"
+
+ monkeypatch.setattr(mwmod.QFileDialog, "getSaveFileName", staticmethod(fake_save1))
+
+ info_log = {"ok": False}
+
+ # Auto-accept the warning dialog
monkeypatch.setattr(
mwmod.QMessageBox, "exec", lambda self: mwmod.QMessageBox.Yes, raising=False
)
+ info_log = {"ok": False}
monkeypatch.setattr(
- mwmod.QMessageBox, "information", staticmethod(lambda *a, **k: 0), raising=False
+ mwmod.QMessageBox,
+ "information",
+ staticmethod(lambda *a, **k: info_log.__setitem__("ok", True) or 0),
+ raising=False,
)
- # Critical should never trigger in the success path
monkeypatch.setattr(
mwmod.QMessageBox,
"critical",
@@ -155,9 +154,9 @@ def test_export_success_and_error(qtbot, app, fresh_db, tmp_path, monkeypatch):
),
raising=False,
)
-
w._export()
assert dest1.with_suffix(".md").exists()
+ assert info_log["ok"]
# Now force an exception during export to hit error branch (patch the window's DB)
def boom():
@@ -167,13 +166,14 @@ def test_export_success_and_error(qtbot, app, fresh_db, tmp_path, monkeypatch):
# Different filename to avoid overwriting
dest2 = tmp_path / "export_two"
- monkeypatch.setattr(
- mwmod.QFileDialog,
- "getSaveFileName",
- staticmethod(lambda *a, **k: (str(dest2), "CSV (*.csv)")),
- )
+
+ def fake_save2(*a, **k):
+ return str(dest2), "CSV (*.csv)"
+
+ monkeypatch.setattr(mwmod.QFileDialog, "getSaveFileName", staticmethod(fake_save2))
errs = {"hit": False}
+ # Auto-accept the warning dialog and capture the error message
monkeypatch.setattr(
mwmod.QMessageBox, "exec", lambda self: mwmod.QMessageBox.Yes, raising=False
)
@@ -190,6 +190,7 @@ def test_export_success_and_error(qtbot, app, fresh_db, tmp_path, monkeypatch):
assert errs["hit"]
+@pytest.mark.gui
def test_backup_path(qtbot, app, fresh_db, tmp_path, monkeypatch):
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
@@ -197,7 +198,7 @@ def test_backup_path(qtbot, app, fresh_db, tmp_path, monkeypatch):
from bouquin.settings import get_settings
s = get_settings()
- s.setValue("db/default_db", str(fresh_db.cfg.path))
+ s.setValue("db/path", str(fresh_db.cfg.path))
s.setValue("db/key", fresh_db.cfg.key)
s.setValue("ui/idle_minutes", 0)
s.setValue("ui/theme", "light")
@@ -247,12 +248,13 @@ def test_backup_path(qtbot, app, fresh_db, tmp_path, monkeypatch):
assert str(dest.with_suffix(".db")) in hit["text"]
+@pytest.mark.gui
def test_close_tab_edges_and_autosave(qtbot, app, fresh_db, monkeypatch):
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
from bouquin.settings import get_settings
s = get_settings()
- s.setValue("db/default_db", str(fresh_db.cfg.path))
+ s.setValue("db/path", str(fresh_db.cfg.path))
s.setValue("db/key", fresh_db.cfg.key)
s.setValue("ui/idle_minutes", 0)
s.setValue("ui/theme", "light")
@@ -281,6 +283,7 @@ def test_close_tab_edges_and_autosave(qtbot, app, fresh_db, monkeypatch):
monkeypatch.delattr(w, "_save_editor_content", raising=False)
+@pytest.mark.gui
def test_restore_window_position_variants(qtbot, app, fresh_db, monkeypatch):
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
w = MainWindow(themes=themes)
@@ -326,6 +329,7 @@ def test_restore_window_position_variants(qtbot, app, fresh_db, monkeypatch):
assert called["max"]
+@pytest.mark.gui
def test_calendar_pos_mapping_and_context_menu(qtbot, app, fresh_db, monkeypatch):
# Seed DB so refresh marks does something
fresh_db.save_new_version("2021-08-15", "note", "")
@@ -398,6 +402,7 @@ def test_calendar_pos_mapping_and_context_menu(qtbot, app, fresh_db, monkeypatch
w._show_calendar_context_menu(cal_pos)
+@pytest.mark.gui
def test_event_filter_keypress_starts_idle_timer(qtbot, app):
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
w = MainWindow(themes=themes)
@@ -405,2026 +410,3 @@ def test_event_filter_keypress_starts_idle_timer(qtbot, app):
w.show()
ev = QEvent(QEvent.KeyPress)
w.eventFilter(w, ev)
-
-
-def _make_main_window(tmp_db_cfg, app, monkeypatch, fresh_db=None):
- """Create a MainWindow wired to an existing db config so it doesn't prompt for key."""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- # Force MainWindow to pick up our provided cfg
- monkeypatch.setattr(mwmod, "load_db_config", lambda: tmp_db_cfg, raising=True)
- # Make sure DB connects fine
- if fresh_db is None:
- fresh_db = DBManager(tmp_db_cfg)
- assert fresh_db.connect()
- w = MainWindow(themes)
- return w
-
-
-def test_init_exits_when_prompt_rejected(app, monkeypatch, tmp_path):
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
-
- # cfg with empty key and non-existent path => first_time True
- cfg = DBConfig(
- path=tmp_path / "does_not_exist.db",
- key="",
- idle_minutes=0,
- theme="light",
- move_todos=True,
- )
- monkeypatch.setattr(mwmod, "load_db_config", lambda: cfg, raising=True)
-
- # Avoid accidentaly creating DB by short-circuiting the prompt loop
- class MW(MainWindow):
- def _prompt_for_key_until_valid(self, first_time: bool) -> bool: # noqa: N802
- assert first_time is True
- return False
-
- with pytest.raises(SystemExit):
- MW(themes)
-
-
-@pytest.mark.parametrize(
- "msg, expect_key_msg",
- [
- ("file is not a database", True),
- ("totally unrelated", False),
- ],
-)
-def test_try_connect_maps_errors(
- qtbot, tmp_db_cfg, app, monkeypatch, msg, expect_key_msg
-):
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
- qtbot.addWidget(w)
-
- # Make connect() raise
- def boom(self):
- raise Exception(msg)
-
- monkeypatch.setattr(mwmod.DBManager, "connect", boom, raising=True)
-
- shown = {}
-
- def fake_critical(parent, title, text, *a, **k):
- shown["title"] = title
- shown["text"] = str(text)
- return 0
-
- monkeypatch.setattr(
- mwmod.QMessageBox, "critical", staticmethod(fake_critical), raising=True
- )
-
- w._try_connect()
-
- # And we still showed the right error message
- assert "database" in shown["title"].lower()
- if expect_key_msg:
- assert "key" in shown["text"].lower()
- else:
- assert "unrelated" in shown["text"].lower()
- # If closeEvent later explodes here, that is an app bug (should guard partial init).
-
-
-# ---- _prompt_for_key_until_valid (324-332) ----
-
-
-def test_prompt_for_key_cancel_returns_false(qtbot, tmp_db_cfg, app, monkeypatch):
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
- qtbot.addWidget(w)
-
- class CancelPrompt:
- def __init__(self, *a, **k):
- pass
-
- def exec(self):
- return mwmod.QDialog.Rejected
-
- def key(self):
- return ""
-
- def db_path(self) -> Path | None:
- return "foo.db"
-
- monkeypatch.setattr(mwmod, "KeyPrompt", CancelPrompt, raising=True)
- assert w._prompt_for_key_until_valid(first_time=False) is False
-
-
-def test_prompt_for_key_accept_then_connects(qtbot, tmp_db_cfg, app, monkeypatch):
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
- qtbot.addWidget(w)
-
- class OKPrompt:
- def __init__(self, *a, **k):
- pass
-
- def exec(self):
- return mwmod.QDialog.Accepted
-
- def key(self):
- return "abc"
-
- def db_path(self) -> Path | None:
- return "foo.db"
-
- monkeypatch.setattr(mwmod, "KeyPrompt", OKPrompt, raising=True)
- monkeypatch.setattr(w, "_try_connect", lambda: True, raising=True)
- assert w._prompt_for_key_until_valid(first_time=True) is True
- assert w.cfg.key == "abc"
-
-
-# ---- Tabs/date management (368, 383, 388-391, 427-428, …) ----
-
-
-def test_reorder_tabs_by_date_and_tab_lookup(qtbot, tmp_db_cfg, app, monkeypatch):
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
- qtbot.addWidget(w)
-
- d1 = QDate.fromString("2024-01-10", "yyyy-MM-dd")
- d2 = QDate.fromString("2024-01-01", "yyyy-MM-dd")
- d3 = QDate.fromString("2024-02-01", "yyyy-MM-dd")
-
- e2 = w._create_new_tab(d2)
- e3 = w._create_new_tab(d3)
- e1 = w._create_new_tab(d1) # out of order on purpose
-
- # Force a reorder and verify ascending order for the three we added.
- w._reorder_tabs_by_date()
- order = [w.tab_widget.tabText(i) for i in range(w.tab_widget.count())]
- today = QDate.currentDate().toString("yyyy-MM-dd")
- order_wo_today = [d for d in order if d != today]
- assert order_wo_today[:3] == ["2024-01-01", "2024-01-10", "2024-02-01"]
-
- # _tab_index_for_date finds existing and returns -1 for absent
- assert w._tab_index_for_date(d2) != -1
- assert w._tab_index_for_date(QDate.fromString("1999-12-31", "yyyy-MM-dd")) == -1
- assert e1 is not None and e2 is not None and e3 is not None
-
-
-def test_open_close_tabs_and_focus(qtbot, tmp_db_cfg, app, monkeypatch):
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
- qtbot.addWidget(w)
-
- today = QDate.currentDate()
- ed = w._open_date_in_tab(today) # creates new if needed
- assert hasattr(ed, "current_date")
- # make another tab to allow closing
- w._create_new_tab(today.addDays(1))
- count = w.tab_widget.count()
- w._close_tab(0)
- assert w.tab_widget.count() == count - 1
-
-
-# ---- _set_editor_markdown_preserve_view & load with extra_data (631) ----
-
-
-def test_load_selected_date_appends_extra_data_with_newline(
- qtbot, tmp_db_cfg, app, monkeypatch, fresh_db
-):
- w = _make_main_window(tmp_db_cfg, app, monkeypatch, fresh_db)
- qtbot.addWidget(w)
- w.db.save_new_version("2020-01-01", "first line", "seed")
- w._load_selected_date("2020-01-01", extra_data="- [ ] carry")
- assert "carry" in w.editor.toPlainText()
-
-
-# ---- _save_editor_content early return (679) ----
-
-
-def test_save_editor_content_returns_without_current_date(
- qtbot, tmp_db_cfg, app, monkeypatch
-):
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
- qtbot.addWidget(w)
-
- from PySide6.QtWidgets import QTextEdit
-
- other = QTextEdit()
- w._save_editor_content(other) # should early-return, not crash
-
-
-# ---- _adjust_today creates a tab (695-696) ----
-
-
-def test_adjust_today_creates_tab(qtbot, tmp_db_cfg, app, monkeypatch):
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
- qtbot.addWidget(w)
- w._adjust_today()
- today = QDate.currentDate().toString("yyyy-MM-dd")
- assert any(w.tab_widget.tabText(i) == today for i in range(w.tab_widget.count()))
-
-
-# ---- _on_date_changed guard for context menus (745) ----
-
-
-def test_on_date_changed_early_return_when_context_menu(
- qtbot, tmp_db_cfg, app, monkeypatch
-):
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
- qtbot.addWidget(w)
- w._showing_context_menu = True
- # Should not throw or load
- w._on_date_changed()
- assert w._showing_context_menu is True # not toggled here
-
-
-# ---- _save_current explicit paths (793-801, 809-810) ----
-
-
-def test_save_current_explicit_cancel(qtbot, tmp_db_cfg, app, monkeypatch):
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
- qtbot.addWidget(w)
-
- class CancelSave:
- def __init__(self, *a, **k):
- pass
-
- def exec(self):
- return mwmod.QDialog.Rejected
-
- def note_text(self):
- return ""
-
- monkeypatch.setattr(mwmod, "SaveDialog", CancelSave, raising=True)
- # returns early, does not raise
- w._save_current(explicit=True)
-
-
-def test_save_current_explicit_accept(qtbot, tmp_db_cfg, app, monkeypatch):
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
- qtbot.addWidget(w)
-
- class OKSave:
- def __init__(self, *a, **k):
- pass
-
- def exec(self):
- return mwmod.QDialog.Accepted
-
- def note_text(self):
- return "manual"
-
- monkeypatch.setattr(mwmod, "SaveDialog", OKSave, raising=True)
- w._save_current(explicit=True) # should start/restart timers without error
-
-
-# ---- Search highlighting (852-854) ----
-
-
-def test_apply_search_highlights_adds_and_clears(qtbot, tmp_db_cfg, app, monkeypatch):
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
- qtbot.addWidget(w)
- d1 = QDate.currentDate()
- d2 = d1.addDays(1)
- w._apply_search_highlights({d1, d2})
- # now drop d2 so clear-path runs
- w._apply_search_highlights({d1})
- fmt = w.calendar.dateTextFormat(d1)
- assert fmt.background().style() != Qt.NoBrush
-
-
-# ---- History dialog glue (956, 961-962) ----
-
-
-def test_open_history_accept_refreshes(qtbot, tmp_db_cfg, app, monkeypatch):
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
- qtbot.addWidget(w)
-
- called = {"load": False, "refresh": False}
-
- def fake_load(date_iso=None):
- called["load"] = True
-
- def fake_refresh():
- called["refresh"] = True
-
- monkeypatch.setattr(w, "_load_selected_date", fake_load, raising=True)
- monkeypatch.setattr(w, "_refresh_calendar_marks", fake_refresh, raising=True)
-
- class Dlg:
- def __init__(self, *a, **k):
- pass
-
- def exec(self):
- return mwmod.QDialog.Accepted
-
- monkeypatch.setattr(mwmod, "HistoryDialog", Dlg, raising=True)
- w._open_history()
- assert called["load"] and called["refresh"]
-
-
-# ---- Insert image picker (974, 981-989) ----
-
-
-def test_on_insert_image_calls_editor_insert(
- qtbot, tmp_db_cfg, app, monkeypatch, tmp_path
-):
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
- qtbot.addWidget(w)
-
- # Make a tiny PNG file
- from PySide6.QtGui import QImage
-
- img_path = tmp_path / "tiny.png"
- img = QImage(1, 1, QImage.Format_ARGB32)
- img.fill(0xFFFFFFFF)
- img.save(str(img_path))
-
- called = []
-
- def fake_picker(*a, **k):
- return ([str(img_path)], "Images (*.png)")
-
- monkeypatch.setattr(
- mwmod.QFileDialog, "getOpenFileNames", staticmethod(fake_picker), raising=True
- )
-
- def recorder(path):
- called.append(Path(path))
-
- monkeypatch.setattr(w.editor, "insert_image_from_path", recorder, raising=True)
-
- w._on_insert_image()
- assert called and called[0].name == "tiny.png"
-
-
-# ---- Export flow branches (1062, 1078-1107) ----
-
-
-@pytest.mark.parametrize(
- "filter_label, method",
- [
- ("JSON (*.json)", "export_json"),
- ("CSV (*.csv)", "export_csv"),
- ("HTML (*.html)", "export_html"),
- ("Markdown (*.md)", "export_markdown"),
- ("SQL (*.sql)", "export_sql"),
- ],
-)
-def test_export_calls_correct_method(
- qtbot, tmp_db_cfg, app, monkeypatch, tmp_path, filter_label, method
-):
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
- qtbot.addWidget(w)
-
- # Confirm 'Yes' using the real QMessageBox; patch methods only
- monkeypatch.setattr(
- mwmod.QMessageBox, "exec", lambda self: mwmod.QMessageBox.Yes, raising=False
- )
- monkeypatch.setattr(
- mwmod.QMessageBox, "information", staticmethod(lambda *a, **k: 0), raising=False
- )
- monkeypatch.setattr(
- mwmod.QMessageBox, "critical", staticmethod(lambda *a, **k: 0), raising=False
- )
-
- # Return a path without extension to exercise default-ext logic too
- out = tmp_path / "out"
- monkeypatch.setattr(
- mwmod.QFileDialog,
- "getSaveFileName",
- staticmethod(lambda *a, **k: (str(out), filter_label)),
- raising=True,
- )
-
- # Provide some entries
- monkeypatch.setattr(
- w.db, "get_all_entries", lambda: [("2024-01-01", "x")], raising=True
- )
-
- called = {"name": None}
-
- def mark(*a, **k):
- called["name"] = method
-
- monkeypatch.setattr(w.db, method, mark, raising=True)
- w._export()
- assert called["name"] == method
-
-
-def test_export_unknown_filter_raises_and_is_caught(
- qtbot, tmp_db_cfg, app, monkeypatch, tmp_path
-):
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
- qtbot.addWidget(w)
-
- # Pre-export confirmation: Yes
- monkeypatch.setattr(
- mwmod.QMessageBox, "exec", lambda self: mwmod.QMessageBox.Yes, raising=False
- )
-
- out = tmp_path / "weird"
- monkeypatch.setattr(
- mwmod.QFileDialog,
- "getSaveFileName",
- staticmethod(lambda *a, **k: (str(out), "WUT (*.wut)")),
- raising=True,
- )
-
- # Capture the critical call
- seen = {}
-
- def critical(parent, title, text, *a, **k):
- seen["title"] = title
- seen["text"] = str(text)
- return 0
-
- monkeypatch.setattr(
- mwmod.QMessageBox, "critical", staticmethod(critical), raising=False
- )
-
- # And stub entries
- monkeypatch.setattr(w.db, "get_all_entries", lambda: [], raising=True)
-
- w._export()
- assert "export" in seen["title"].lower()
-
-
-# ---- Backup handler (1147-1148) ----
-
-
-def test_backup_success_and_error(qtbot, tmp_db_cfg, app, monkeypatch, tmp_path):
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
- qtbot.addWidget(w)
-
- # Always choose a filename and the SQL option
- out = tmp_path / "backup"
- monkeypatch.setattr(
- mwmod.QFileDialog,
- "getSaveFileName",
- staticmethod(lambda *a, **k: (str(out), "SQLCipher (*.db)")),
- raising=True,
- )
-
- called = {"ok": False, "err": False}
-
- def ok_export(path):
- called["ok"] = True
-
- def crit(parent, title, text, *a, **k):
- called["err"] = True
- return 0
-
- # First success
- monkeypatch.setattr(w.db, "export_sqlcipher", ok_export, raising=True)
- monkeypatch.setattr(
- mwmod.QMessageBox, "information", staticmethod(lambda *a, **k: 0), raising=False
- )
- monkeypatch.setattr(
- mwmod.QMessageBox, "critical", staticmethod(crit), raising=False
- )
- w._backup()
- assert called["ok"]
-
- # Then failure
- def boom(path):
- raise RuntimeError("nope")
-
- monkeypatch.setattr(w.db, "export_sqlcipher", boom, raising=True)
- w._backup()
- assert called["err"]
-
-
-# ---- Help openers (1152-1169) ----
-
-
-def test_open_docs_show_warning_on_failure(qtbot, tmp_db_cfg, app, monkeypatch):
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
- qtbot.addWidget(w)
-
- monkeypatch.setattr(
- mwmod.QDesktopServices, "openUrl", staticmethod(lambda *a: False), raising=True
- )
- seen = {"docs": False, "bugs": False}
-
- def warn(parent, title, text, *a, **k):
- if "documentation" in title.lower():
- seen["docs"] = True
- return 0
-
- monkeypatch.setattr(
- mwmod, "QMessageBox", type("MB", (), {"warning": staticmethod(warn)})
- )
- w._open_docs()
-
- assert seen["docs"]
-
-
-def test_open_version(qtbot, tmp_db_cfg, app, monkeypatch):
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
- qtbot.addWidget(w)
-
- called = {"title": None, "text": None, "check_called": False}
-
- # Fake QMessageBox that mimics the bits VersionChecker.show_version_dialog uses
- class FakeMessageBox:
- # provide the enum attributes the code references
- Information = 0
- ActionRole = 1
- Close = 2
-
- def __init__(self, parent=None):
- self._parent = parent
- self._icon = None
- self._title = ""
- self._text = ""
- self._buttons = []
- self._clicked = None
-
- def setIcon(self, icon):
- self._icon = icon
-
- def setIconPixmap(self, icon):
- self._icon = icon
-
- def setWindowTitle(self, title):
- self._title = title
- called["title"] = title
-
- def setText(self, text):
- self._text = text
- called["text"] = text
-
- def addButton(self, *args, **kwargs):
- # We don't care about the label/role, we just need a distinct object
- btn = object()
- self._buttons.append(btn)
- return btn
-
- def exec(self):
- # Simulate user clicking the *Close* button, i.e. the second button
- if self._buttons:
- # show_version_dialog adds buttons in order:
- # 0 -> "Check for updates"
- # 1 -> Close
- self._clicked = self._buttons[-1]
-
- def clickedButton(self):
- return self._clicked
-
- # Patch the QMessageBox used *inside* version_check.py
- monkeypatch.setattr(version_check, "QMessageBox", FakeMessageBox)
-
- # Optional: track if check_for_updates would be called
- def fake_check_for_updates(self):
- called["check_called"] = True
-
- monkeypatch.setattr(
- version_check.VersionChecker, "check_for_updates", fake_check_for_updates
- )
-
- # Call the entrypoint
- w._open_version()
-
- # Assertions: title and text got set correctly
- assert called["title"] is not None
- assert "version" in called["title"].lower()
-
- version = importlib.metadata.version("bouquin")
- assert version in called["text"]
-
- # And we simulated closing, so "Check for updates" should not have fired
- assert called["check_called"] is False
-
-
-# ---- Idle/lock/event filter helpers (1176, 1181-1187, 1193-1202, 1231-1233) ----
-
-
-def test_apply_idle_minutes_paths_and_unlock(qtbot, tmp_db_cfg, app, monkeypatch):
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
- qtbot.addWidget(w)
-
- # remove timer to hit early return
- delattr(w, "_idle_timer")
- w._apply_idle_minutes(5) # no crash
-
- # re-create a timer and simulate locking then disabling idle
- w._idle_timer = QTimer(w)
- w._idle_timer.setSingleShot(True)
- w._locked = True
- w._apply_idle_minutes(0)
- assert not w._locked # unlocked and overlay hidden path covered
-
-
-def test_event_filter_sets_context_flag_and_keystart(
- qtbot, tmp_db_cfg, app, monkeypatch
-):
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
- qtbot.addWidget(w)
- # Right-click on calendar with a real QMouseEvent
- me = QMouseEvent(
- QEvent.MouseButtonPress,
- QPoint(0, 0),
- Qt.RightButton,
- Qt.RightButton,
- Qt.NoModifier,
- )
- w.eventFilter(w.calendar, me)
- assert getattr(w, "_showing_context_menu", False) is True
-
- # KeyPress restarts idle timer when unlocked (real QKeyEvent)
- w._locked = False
- ke = QKeyEvent(QEvent.KeyPress, Qt.Key_A, Qt.NoModifier)
- w.eventFilter(w, ke)
-
-
-def test_unlock_exception_path(qtbot, tmp_db_cfg, app, monkeypatch):
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
- qtbot.addWidget(w)
-
- def boom(*a, **k):
- raise RuntimeError("nope")
-
- monkeypatch.setattr(w, "_prompt_for_key_until_valid", boom, raising=True)
-
- captured = {}
-
- def critical(parent, title, text, *a, **k):
- captured["title"] = title
- return 0
-
- monkeypatch.setattr(
- mwmod, "QMessageBox", type("MB", (), {"critical": staticmethod(critical)})
- )
- w._on_unlock_clicked()
- assert "unlock" in captured["title"].lower()
-
-
-# ---- Focus helpers (1273-1275, 1290) ----
-
-
-def test_focus_editor_now_and_app_state(qtbot, tmp_db_cfg, app, monkeypatch):
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
- qtbot.addWidget(w)
- w.show()
- # Locked => early return
- w._locked = True
- w._focus_editor_now()
- # Active window path (force isActiveWindow)
- w._locked = False
- monkeypatch.setattr(w, "isActiveWindow", lambda: True, raising=True)
- w._focus_editor_now()
- # App state callback path
- w._on_app_state_changed(Qt.ApplicationActive)
-
-
-# ---- _rect_on_any_screen false path (1039) ----
-
-
-def test_rect_on_any_screen_false(qtbot, tmp_db_cfg, app, monkeypatch):
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
- qtbot.addWidget(w)
- sc = QApplication.primaryScreen().availableGeometry()
- far = QRect(sc.right() + 10_000, sc.bottom() + 10_000, 100, 100)
- assert not w._rect_on_any_screen(far)
-
-
-def test_reorder_tabs_with_undated_page_stays_last(qtbot, app, tmp_db_cfg, monkeypatch):
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
- qtbot.addWidget(w)
-
- # Create two dated tabs (out of order), plus an undated QWidget page.
- d1 = QDate.fromString("2024-02-01", "yyyy-MM-dd")
- d2 = QDate.fromString("2024-01-01", "yyyy-MM-dd")
- w._create_new_tab(d1)
- w._create_new_tab(d2)
-
- misc = QWidget()
- w.tab_widget.addTab(misc, "misc")
-
- # Reorder and check: dated tabs sorted first, undated kept after them
- w._reorder_tabs_by_date()
- labels = [w.tab_widget.tabText(i) for i in range(w.tab_widget.count())]
- assert labels[0] <= labels[1]
- assert labels[-1] == "misc"
-
- # Also cover lookup for an existing date
- assert w._tab_index_for_date(d2) != -1
-
-
-def test_index_for_date_insert_positions(qtbot, app, tmp_db_cfg, monkeypatch):
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
- qtbot.addWidget(w)
-
- a = QDate.fromString("2024-01-01", "yyyy-MM-dd")
- c = QDate.fromString("2024-03-01", "yyyy-MM-dd")
- d = QDate.fromString("2024-04-01", "yyyy-MM-dd")
-
- def expected():
- key = (d.year(), d.month(), d.day())
- for i in range(w.tab_widget.count()):
- ed = w.tab_widget.widget(i)
- cur = getattr(ed, "current_date", None)
- if isinstance(cur, QDate) and cur.isValid():
- if (cur.year(), cur.month(), cur.day()) > key:
- return i
- return w.tab_widget.count()
-
- w._create_new_tab(a)
- w._create_new_tab(c)
-
- # B belongs between A and C
- b = QDate.fromString("2024-02-01", "yyyy-MM-dd")
- assert w._index_for_date_insert(b) == 1
-
- # Date prior to first should insert at 0
- z = QDate.fromString("2023-12-31", "yyyy-MM-dd")
- assert w._index_for_date_insert(z) == 0
-
- # Date after last should append
- d = QDate.fromString("2024-04-01", "yyyy-MM-dd")
- assert w._index_for_date_insert(d) == expected()
-
-
-def test_on_tab_changed_early_and_stop_guard(qtbot, app, tmp_db_cfg, monkeypatch):
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
- qtbot.addWidget(w)
-
- # Early return path
- w._on_tab_changed(-1)
-
- # Create two tabs then make stop() raise to hit try/except guard
- w._create_new_tab(QDate.fromString("2024-01-01", "yyyy-MM-dd"))
- w._create_new_tab(QDate.fromString("2024-01-02", "yyyy-MM-dd"))
-
- def boom():
- raise RuntimeError("stop failed")
-
- monkeypatch.setattr(w._save_timer, "stop", boom, raising=True)
- w._on_tab_changed(1) # should not raise
-
-
-def test_show_calendar_context_menu_no_action(qtbot, app, tmp_db_cfg, monkeypatch):
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
- qtbot.addWidget(w)
- before = w.tab_widget.count()
-
- class DummyMenu:
- def __init__(self, *a, **k):
- pass
-
- def addAction(self, *a, **k):
- return object()
-
- def exec_(self, *a, **k):
- return None # return no action
-
- monkeypatch.setattr(mwmod, "QMenu", DummyMenu, raising=True)
- w._show_calendar_context_menu(w.calendar.rect().center()) # nothing should happen
- assert w.tab_widget.count() == before
-
-
-def test_export_cancel_then_empty_filename(
- qtbot, app, tmp_db_cfg, monkeypatch, tmp_path
-):
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
- qtbot.addWidget(w)
-
- # 1) cancel at the confirmation dialog
- class FullMB:
- Yes = QMessageBox.Yes
- No = QMessageBox.No
- Warning = QMessageBox.Warning
-
- def __init__(self, *a, **k):
- pass
-
- def setWindowTitle(self, *a):
- pass
-
- def setText(self, *a):
- pass
-
- def setStandardButtons(self, *a):
- pass
-
- def setIcon(self, *a):
- pass
-
- def show(self):
- pass
-
- def adjustSize(self):
- pass
-
- def exec(self):
- return self.No
-
- @staticmethod
- def information(*a, **k):
- return 0
-
- @staticmethod
- def critical(*a, **k):
- return 0
-
- monkeypatch.setattr(mwmod, "QMessageBox", FullMB, raising=True)
- w._export() # returns early on No
-
- # 2) Yes in confirmation, but user cancels file dialog (empty filename)
- class YesOnly(FullMB):
- def exec(self):
- return self.Yes
-
- monkeypatch.setattr(mwmod, "QMessageBox", YesOnly, raising=True)
- monkeypatch.setattr(
- mwmod.QFileDialog,
- "getSaveFileName",
- staticmethod(lambda *a, **k: ("", "Markdown (*.md)")),
- raising=False,
- )
- w._export() # returns early at filename check
-
-
-def test_set_editor_markdown_preserve_view_preserves(
- qtbot, app, tmp_db_cfg, monkeypatch
-):
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
- qtbot.addWidget(w)
-
- long = "line\n" * 200
- w.editor.from_markdown(long)
- w.editor.verticalScrollBar().setValue(50)
- w.editor.moveCursor(QTextCursor.End)
- pos_before = w.editor.textCursor().position()
- v_before = w.editor.verticalScrollBar().value()
-
- # Same markdown → no rewrite, caret/scroll restored
- w._set_editor_markdown_preserve_view(long)
- assert w.editor.textCursor().position() == pos_before
- assert w.editor.verticalScrollBar().value() == v_before
-
- # Different markdown → rewritten but caret restoration still executes
- changed = long + "extra\n"
- w._set_editor_markdown_preserve_view(changed)
- assert w.editor.to_markdown().endswith("extra\n")
-
-
-def test_load_date_into_editor_with_extra_data_forces_save(
- qtbot, app, tmp_db_cfg, monkeypatch
-):
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
- qtbot.addWidget(w)
-
- called = {"iso": None, "explicit": None}
-
- def save_date(iso, explicit):
- called.update(iso=iso, explicit=explicit)
-
- monkeypatch.setattr(w, "_save_date", save_date, raising=True)
- d = QDate.fromString("2020-01-01", "yyyy-MM-dd")
- w._load_date_into_editor(d, extra_data="- [ ] carry")
- assert called["iso"] == "2020-01-01" and called["explicit"] is True
-
-
-def test_reorder_tabs_moves_and_undated(qtbot, app, tmp_db_cfg, monkeypatch):
- """Covers moveTab for both dated and undated buckets."""
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
- qtbot.addWidget(w)
-
- # Create out-of-order dated tabs
- d1 = QDate.fromString("2024-01-10", "yyyy-MM-dd")
- d2 = QDate.fromString("2024-01-01", "yyyy-MM-dd")
- d3 = QDate.fromString("2024-02-01", "yyyy-MM-dd")
- w._create_new_tab(d1)
- w._create_new_tab(d3)
- w._create_new_tab(d2)
-
- # Also insert an "undated" plain QWidget at the front to force a move later
- undated = QWidget()
- w.tab_widget.insertTab(0, undated, "undated")
-
- moved = {"calls": []}
- tb = w.tab_widget.tabBar()
-
- def spy_moveTab(frm, to):
- moved["calls"].append((frm, to))
- # don't actually move; we just need the call for coverage
-
- monkeypatch.setattr(tb, "moveTab", spy_moveTab, raising=True)
-
- w._reorder_tabs_by_date()
- assert moved["calls"] # both dated and undated moves should have occurred
-
-
-def test_date_from_calendar_view_none(qtbot, app, tmp_db_cfg, monkeypatch):
- """Covers early return when calendar view can't be found."""
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
- qtbot.addWidget(w)
-
- monkeypatch.setattr(
- w.calendar,
- "findChild",
- lambda *a, **k: None, # pretend the internal view is missing
- raising=False,
- )
- assert w._date_from_calendar_pos(QPoint(1, 1)) is None
-
-
-def test_date_from_calendar_no_first_or_last(qtbot, app, tmp_db_cfg, monkeypatch):
- """
- Covers early returns when first_index or last_index cannot be found
- """
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
- qtbot.addWidget(w)
-
- class FakeModel:
- def rowCount(self):
- return 1
-
- def columnCount(self):
- return 1
-
- class Idx:
- def data(self):
- return None # never equals first/last-day
-
- def index(self, *_):
- return self.Idx()
-
- class FakeView:
- def model(self):
- return FakeModel()
-
- class VP:
- def mapToGlobal(self, p):
- return p
-
- def mapFrom(self, *_): # calendar-local -> viewport
- return QPoint(0, 0)
-
- def viewport(self):
- return self.VP()
-
- def indexAt(self, *_):
- # return an *instance* whose isValid() returns False
- return type("I", (), {"isValid": lambda self: False})()
-
- monkeypatch.setattr(
- w.calendar,
- "findChild",
- lambda *a, **k: FakeView(),
- raising=False,
- )
- # Early return when first day (1) can't be found
- assert w._date_from_calendar_pos(QPoint(5, 5)) is None
-
-
-def test_save_editor_content_returns_if_no_conn(qtbot, app, tmp_db_cfg, monkeypatch):
- """Covers DB not connected branch."""
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
- qtbot.addWidget(w)
-
- # window editor has a current_date; simulate a dropped connection
- assert isinstance(w.db, DBManager)
- w.db.conn = None
- # no crash -> branch hit
- w._save_editor_content(w.editor)
-
-
-def test_on_date_changed_stops_timer_and_saves_prev_when_dirty(
- qtbot, app, tmp_db_cfg, monkeypatch
-):
- """
- Covers the exception-protected _save_timer.stop() and
- the prev-date save path.
- """
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
- qtbot.addWidget(w)
-
- # Make timer.stop() raise so the except path is covered
- w._dirty = True
- w.editor.current_date = QDate.fromString("2024-01-01", "yyyy-MM-dd")
- # make timer.stop() raise so the except path is exercised
- monkeypatch.setattr(
- w._save_timer,
- "stop",
- lambda: (_ for _ in ()).throw(RuntimeError("boom")),
- raising=True,
- )
-
- saved = {"iso": None}
-
- def stub_save_date(iso, explicit=False, note=None):
- saved["iso"] = iso
-
- monkeypatch.setattr(w, "_save_date", stub_save_date, raising=False)
-
- w._on_date_changed()
- assert saved["iso"] == "2024-01-01"
-
-
-def test_bind_toolbar_idempotent(qtbot, app, tmp_db_cfg, monkeypatch):
- """Covers early return when toolbar is already bound."""
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
- qtbot.addWidget(w)
- w._bind_toolbar()
- # Call again; line is covered if this no-ops
- w._bind_toolbar()
-
-
-def test_on_insert_image_no_selection_returns(qtbot, app, tmp_db_cfg, monkeypatch):
- """Covers the early return when user selects no files."""
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
- qtbot.addWidget(w)
-
- monkeypatch.setattr(
- mwmod.QFileDialog,
- "getOpenFileNames",
- staticmethod(lambda *a, **k: ([], "")),
- raising=True,
- )
-
- # Ensure we would notice if it tried to insert anything
- monkeypatch.setattr(
- w.editor,
- "insert_image_from_path",
- lambda *a, **k: (_ for _ in ()).throw(AssertionError("should not insert")),
- raising=True,
- )
- w._on_insert_image()
-
-
-def test_apply_idle_minutes_starts_when_unlocked(qtbot, app, tmp_db_cfg, monkeypatch):
- """Covers set + start when minutes>0 and not locked."""
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
- qtbot.addWidget(w)
- w._locked = False
-
- hit = {"start": False}
- monkeypatch.setattr(
- w._idle_timer,
- "start",
- lambda *a, **k: hit.__setitem__("start", True),
- raising=True,
- )
- w._apply_idle_minutes(7)
- assert hit["start"]
-
-
-def test_close_event_handles_settings_failures(qtbot, app, tmp_db_cfg, monkeypatch):
- """
- Covers exception swallowing around settings writes & ensures close proceeds
- """
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
- qtbot.addWidget(w)
-
- # A couple of tabs so _save_editor_content runs during close
- w._create_new_tab(QDate.fromString("2024-01-01", "yyyy-MM-dd"))
-
- # Make settings.setValue raise for coverage
- orig_set = w.settings.setValue
-
- def flaky_set(*a, **k):
- if a[0] in ("main/geometry", "main/windowState"):
- raise RuntimeError("boom")
- return orig_set(*a, **k)
-
- monkeypatch.setattr(w.settings, "setValue", flaky_set, raising=True)
-
- # Should not crash
- w.close()
-
-
-def test_on_date_changed_ignored_when_context_menu_shown(
- qtbot, app, tmp_db_cfg, monkeypatch
-):
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
- # Simulate flag set by context menu to ensure early return path
- w._showing_context_menu = True
- w._on_date_changed() # should simply return without raising
- assert True # reached
-
-
-def test_closeEvent_swallows_exceptions(qtbot, app, tmp_db_cfg, monkeypatch):
- called = {"save": False, "close": False}
- w = _make_main_window(tmp_db_cfg, app, monkeypatch)
-
- def bad_save(editor):
- called["save"] = True
- raise RuntimeError("boom")
-
- class DummyDB:
- def __init__(self):
- self.conn = object() # <-- make conn truthy so branch is taken
-
- def close(self):
- called["close"] = True
- raise RuntimeError("kaboom")
-
- class DummyTabs:
- def count(self):
- return 1 # <-- ensures the save loop runs
-
- def widget(self, i):
- return object() # any non-None editor-like object
-
- # Patch the pieces the closeEvent checks
- w.tab_widget = DummyTabs()
- w.db = DummyDB()
- monkeypatch.setattr(w, "_save_editor_content", bad_save, raising=True)
-
- # Fire the event
- ev = QCloseEvent()
- w.closeEvent(ev)
-
- assert called["save"] and called["close"]
-
-
-# ============================================================================
-# Tag Save Handler Tests
-# ============================================================================
-
-
-def test_main_window_do_tag_save_with_editor(app, fresh_db, tmp_db_cfg, monkeypatch):
- """Test _do_tag_save when editor has current_date"""
- # Skip the key prompt
- monkeypatch.setattr(
- "bouquin.main_window.KeyPrompt",
- lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key),
- )
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- window = MainWindow(themes)
-
- # Set a date on the editor
- date = QDate(2024, 1, 15)
- window.editor.current_date = date
- window.editor.from_markdown("Test content")
-
- # Call _do_tag_save
- window._do_tag_save()
-
- # Should have saved
- fresh_db.get_entry("2024-01-15")
- # May or may not have content depending on timing, but should not crash
- assert True
-
-
-def test_main_window_do_tag_save_no_editor(app, fresh_db, tmp_db_cfg, monkeypatch):
- """Test _do_tag_save when editor doesn't have current_date"""
- monkeypatch.setattr(
- "bouquin.main_window.KeyPrompt",
- lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key),
- )
-
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- window = MainWindow(themes)
-
- # Remove current_date attribute
- if hasattr(window.editor, "current_date"):
- delattr(window.editor, "current_date")
-
- # Call _do_tag_save - should handle gracefully
- window._do_tag_save()
-
- assert True
-
-
-def test_main_window_on_tag_added_triggers_deferred_save(
- app, fresh_db, tmp_db_cfg, monkeypatch
-):
- """Test that _on_tag_added defers the save"""
- monkeypatch.setattr(
- "bouquin.main_window.KeyPrompt",
- lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key),
- )
-
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- window = MainWindow(themes)
-
- # Mock QTimer.singleShot
- with patch("PySide6.QtCore.QTimer.singleShot") as mock_timer:
- window._on_tag_added()
-
- # Should have called singleShot
- mock_timer.assert_called_once()
- args = mock_timer.call_args[0]
- assert args[0] == 0 # Delay of 0
- assert callable(args[1]) # Callback function
-
-
-# ============================================================================
-# Tag Activation Tests
-# ============================================================================
-
-
-def test_main_window_on_tag_activated_with_date(app, fresh_db, tmp_db_cfg, monkeypatch):
- """Test _on_tag_activated when passed a date string"""
- monkeypatch.setattr(
- "bouquin.main_window.KeyPrompt",
- lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key),
- )
-
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- window = MainWindow(themes)
-
- # Mock _load_selected_date
- window._load_selected_date = Mock()
-
- # Call with date format
- window._on_tag_activated("2024-01-15")
-
- # Should have called _load_selected_date
- window._load_selected_date.assert_called_once_with("2024-01-15")
-
-
-def test_main_window_on_tag_activated_with_tag_name(
- app, fresh_db, tmp_db_cfg, monkeypatch
-):
- """Test _on_tag_activated when passed a tag name"""
- monkeypatch.setattr(
- "bouquin.main_window.KeyPrompt",
- lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key),
- )
-
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- window = MainWindow(themes)
-
- # Mock the tag browser dialog (it's imported locally in the method)
- with patch("bouquin.tag_browser.TagBrowserDialog") as mock_dialog:
- mock_instance = Mock()
- mock_instance.openDateRequested = Mock()
- mock_instance.exec.return_value = QDialog.Accepted
- mock_dialog.return_value = mock_instance
-
- # Call with tag name
- window._on_tag_activated("worktag")
-
- # Should have opened dialog
- mock_dialog.assert_called_once()
- # Check focus_tag was passed
- call_kwargs = mock_dialog.call_args[1]
- assert call_kwargs.get("focus_tag") == "worktag"
-
-
-# ============================================================================
-# Settings Path Change Tests
-# ============================================================================
-
-
-def test_main_window_settings_path_change_success(
- app, fresh_db, tmp_db_cfg, tmp_path, monkeypatch
-):
- """Test changing database path in settings"""
- monkeypatch.setattr(
- "bouquin.main_window.KeyPrompt",
- lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key),
- )
-
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- window = MainWindow(themes)
-
- new_path = tmp_path / "new.db"
-
- # Mock the settings dialog
- with patch("bouquin.main_window.SettingsDialog") as mock_dialog:
- mock_instance = Mock()
- mock_instance.exec.return_value = QDialog.Accepted
-
- # Create a new config with different path
- new_cfg = Mock()
- new_cfg.path = str(new_path)
- new_cfg.key = tmp_db_cfg.key
- new_cfg.idle_minutes = 15
- new_cfg.theme = "light"
- new_cfg.move_todos = True
- new_cfg.locale = "en"
- new_cfg.font_size = 11
-
- mock_instance.config = new_cfg
- mock_dialog.return_value = mock_instance
-
- # Mock _prompt_for_key_until_valid to return True
- window._prompt_for_key_until_valid = Mock(return_value=True)
- # Also mock _load_selected_date and _refresh_calendar_marks since we don't have a real DB connection
- window._load_selected_date = Mock()
- window._refresh_calendar_marks = Mock()
-
- # Open settings
- window._open_settings()
-
- # Path should have changed
- assert window.cfg.path == str(new_path)
-
-
-def test_main_window_settings_path_change_failure(
- app, fresh_db, tmp_db_cfg, tmp_path, monkeypatch
-):
- """Test failed database path change shows warning"""
- monkeypatch.setattr(
- "bouquin.main_window.KeyPrompt",
- lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key),
- )
-
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- window = MainWindow(themes)
-
- new_path = tmp_path / "new.db"
-
- # Mock the settings dialog
- with patch("bouquin.main_window.SettingsDialog") as mock_dialog:
- mock_instance = Mock()
- mock_instance.exec.return_value = QDialog.Accepted
-
- new_cfg = Mock()
- new_cfg.path = str(new_path)
- new_cfg.key = tmp_db_cfg.key
- new_cfg.idle_minutes = 15
- new_cfg.theme = "light"
- new_cfg.move_todos = True
- new_cfg.locale = "en"
- new_cfg.font_size = 11
-
- mock_instance.config = new_cfg
- mock_dialog.return_value = mock_instance
-
- # Mock _prompt_for_key_until_valid to return False (failure)
- window._prompt_for_key_until_valid = Mock(return_value=False)
-
- # Mock QMessageBox.warning
- with patch.object(QMessageBox, "warning") as mock_warning:
- # Open settings
- window._open_settings()
-
- # Warning should have been shown
- mock_warning.assert_called_once()
-
-
-def test_main_window_settings_no_path_change(app, fresh_db, tmp_db_cfg, monkeypatch):
- """Test settings change without path change"""
- monkeypatch.setattr(
- "bouquin.main_window.KeyPrompt",
- lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key),
- )
-
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- window = MainWindow(themes)
-
- old_path = window.cfg.path
-
- # Mock the settings dialog
- with patch("bouquin.main_window.SettingsDialog") as mock_dialog:
- mock_instance = Mock()
- mock_instance.exec.return_value = QDialog.Accepted
-
- # Create config with SAME path
- new_cfg = Mock()
- new_cfg.path = old_path
- new_cfg.key = tmp_db_cfg.key
- new_cfg.idle_minutes = 20 # Changed
- new_cfg.theme = "dark" # Changed
- new_cfg.move_todos = False # Changed
- new_cfg.locale = "fr" # Changed
- new_cfg.font_size = 12 # Changed
-
- mock_instance.config = new_cfg
- mock_dialog.return_value = mock_instance
-
- # Open settings
- window._open_settings()
-
- # Settings should be updated but path didn't change
- assert window.cfg.idle_minutes == 20
- assert window.cfg.theme == "dark"
- assert window.cfg.path == old_path
- assert window.cfg.font_size == 12
-
-
-def test_main_window_settings_cancelled(app, fresh_db, tmp_db_cfg, monkeypatch):
- """Test cancelling settings dialog"""
- monkeypatch.setattr(
- "bouquin.main_window.KeyPrompt",
- lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key),
- )
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- window = MainWindow(themes)
-
- old_theme = window.cfg.theme
-
- # Mock the settings dialog to be rejected
- with patch("bouquin.main_window.SettingsDialog") as mock_dialog:
- mock_instance = Mock()
- mock_instance.exec.return_value = QDialog.Rejected
- mock_dialog.return_value = mock_instance
-
- # Open settings
- window._open_settings()
-
- # Settings should NOT change
- assert window.cfg.theme == old_theme
-
-
-# ============================================================================
-# Update Tag Views Tests
-# ============================================================================
-
-
-def test_main_window_update_tag_views_for_date(app, fresh_db, tmp_db_cfg, monkeypatch):
- """Test _update_tag_views_for_date"""
- monkeypatch.setattr(
- "bouquin.main_window.KeyPrompt",
- lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key),
- )
-
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- window = MainWindow(themes)
-
- # Set tags for a date
- fresh_db.set_tags_for_page("2024-01-15", ["test"])
-
- # Update tag views
- window._update_tag_views_for_date("2024-01-15")
-
- # Tags widget should have been updated
- assert window.tags._current_date == "2024-01-15"
-
-
-def test_main_window_update_tag_views_no_tags_widget(
- app, fresh_db, tmp_db_cfg, monkeypatch
-):
- """Test _update_tag_views_for_date when tags widget doesn't exist"""
- monkeypatch.setattr(
- "bouquin.main_window.KeyPrompt",
- lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key),
- )
-
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- window = MainWindow(themes)
-
- # Remove tags widget
- delattr(window, "tags")
-
- # Should handle gracefully
- window._update_tag_views_for_date("2024-01-15")
-
- assert True
-
-
-def test_main_window_without_tags(qtbot, app, tmp_db_cfg):
- """Test main window when tags feature is disabled."""
- s = get_settings()
- s.setValue("db/default_db", str(tmp_db_cfg.path))
- s.setValue("db/key", tmp_db_cfg.key)
- s.setValue("ui/idle_minutes", 0)
- s.setValue("ui/theme", "light")
- s.setValue("ui/move_todos", True)
- s.setValue("ui/tags", False) # Disabled
- s.setValue("ui/time_log", True)
- s.setValue("ui/reminders", True)
- s.setValue("ui/locale", "en")
- s.setValue("ui/font_size", 11)
-
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- window = MainWindow(themes=themes)
- qtbot.addWidget(window)
- window.show()
-
- # Verify tags widget is hidden
- assert window.tags.isHidden()
-
-
-def test_main_window_without_time_log(qtbot, app, tmp_db_cfg):
- """Test main window when time_log feature is disabled."""
- s = get_settings()
- s.setValue("db/default_db", str(tmp_db_cfg.path))
- s.setValue("db/key", tmp_db_cfg.key)
- s.setValue("ui/idle_minutes", 0)
- s.setValue("ui/theme", "light")
- s.setValue("ui/move_todos", True)
- s.setValue("ui/tags", True)
- s.setValue("ui/time_log", False) # Disabled
- s.setValue("ui/reminders", True)
- s.setValue("ui/locale", "en")
- s.setValue("ui/font_size", 11)
-
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- window = MainWindow(themes=themes)
- qtbot.addWidget(window)
- window.show()
-
- # Verify time_log widget is hidden
- assert window.time_log.isHidden()
- assert not window.toolBar.actTimer.isVisible()
-
-
-def test_main_window_without_documents(qtbot, app, tmp_db_cfg):
- """Test main window when documents feature is disabled."""
- s = get_settings()
- s.setValue("db/default_db", str(tmp_db_cfg.path))
- s.setValue("db/key", tmp_db_cfg.key)
- s.setValue("ui/idle_minutes", 0)
- s.setValue("ui/theme", "light")
- s.setValue("ui/move_todos", True)
- s.setValue("ui/tags", True)
- s.setValue("ui/time_log", True)
- s.setValue("ui/reminders", True)
- s.setValue("ui/documents", False) # Disabled
- s.setValue("ui/locale", "en")
- s.setValue("ui/font_size", 11)
-
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- window = MainWindow(themes=themes)
- qtbot.addWidget(window)
- window.show()
-
- # Verify documents widget is hidden
- assert window.todays_documents.isHidden()
- assert not window.toolBar.actDocuments.isVisible()
-
-
-def test_export_csv_format(qtbot, app, tmp_path, monkeypatch):
- """Test exporting to CSV format - covers export path lines"""
- db_path = tmp_path / "notebook.db"
- s = get_settings()
- s.setValue("db/default_db", str(db_path))
- s.setValue("db/key", "test-key")
- s.setValue("ui/idle_minutes", 0)
- s.setValue("ui/theme", "light")
-
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- w = MainWindow(themes=themes)
- qtbot.addWidget(w)
- w.show()
-
- # Add some data
- w.db.save_new_version("2024-01-01", "Test content", "test")
-
- # Mock file dialog to return CSV
- dest = tmp_path / "export_test.csv"
- monkeypatch.setattr(
- mwmod.QFileDialog,
- "getSaveFileName",
- staticmethod(lambda *a, **k: (str(dest), "CSV (*.csv)")),
- )
-
- # Mock QMessageBox to auto-accept
- monkeypatch.setattr(
- mwmod.QMessageBox, "exec", lambda self: mwmod.QMessageBox.Yes, raising=False
- )
- monkeypatch.setattr(
- mwmod.QMessageBox, "information", staticmethod(lambda *a, **k: 0), raising=False
- )
-
- w._export()
- assert dest.exists()
-
-
-def test_settings_dialog_with_locale_change(qtbot, app, tmp_path, monkeypatch):
- """Test opening settings dialog and changing locale - covers settings dialog paths"""
- db_path = tmp_path / "notebook.db"
- s = get_settings()
- s.setValue("db/default_db", str(db_path))
- s.setValue("db/key", "test-key")
- s.setValue("ui/idle_minutes", 0)
- s.setValue("ui/theme", "light")
-
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- w = MainWindow(themes=themes)
- qtbot.addWidget(w)
- w.show()
-
- # Mock the settings dialog to auto-accept
- from bouquin.settings_dialog import SettingsDialog
-
- SettingsDialog.exec
-
- def fake_exec(self):
- # Change locale before accepting
- idx = self.locale_combobox.findData("fr")
- if idx >= 0:
- self.locale_combobox.setCurrentIndex(idx)
- return mwmod.QDialog.Accepted
-
- monkeypatch.setattr(SettingsDialog, "exec", fake_exec)
-
- w._open_settings()
- qtbot.wait(50)
-
-
-def test_statistics_dialog_open(qtbot, app, tmp_path, monkeypatch):
- """Test opening statistics dialog - covers statistics dialog paths"""
- db_path = tmp_path / "notebook.db"
- s = get_settings()
- s.setValue("db/default_db", str(db_path))
- s.setValue("db/key", "test-key")
- s.setValue("ui/idle_minutes", 0)
- s.setValue("ui/theme", "light")
-
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- w = MainWindow(themes=themes)
- qtbot.addWidget(w)
- w.show()
-
- # Add some data
- w.db.save_new_version("2024-01-01", "Test content", "test")
-
- from bouquin.statistics_dialog import StatisticsDialog
-
- StatisticsDialog.exec
-
- def fake_exec(self):
- # Just accept immediately
- return mwmod.QDialog.Accepted
-
- monkeypatch.setattr(StatisticsDialog, "exec", fake_exec)
-
- w._open_statistics()
- qtbot.wait(50)
-
-
-def test_bug_report_dialog_open(qtbot, app, tmp_path, monkeypatch):
- """Test opening bug report dialog"""
- db_path = tmp_path / "notebook.db"
- s = get_settings()
- s.setValue("db/default_db", str(db_path))
- s.setValue("db/key", "test-key")
- s.setValue("ui/idle_minutes", 0)
- s.setValue("ui/theme", "light")
-
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- w = MainWindow(themes=themes)
- qtbot.addWidget(w)
- w.show()
-
- from bouquin.bug_report_dialog import BugReportDialog
-
- BugReportDialog.exec
-
- def fake_exec(self):
- return mwmod.QDialog.Rejected
-
- monkeypatch.setattr(BugReportDialog, "exec", fake_exec)
-
- w._open_bugs()
- qtbot.wait(50)
-
-
-def test_history_dialog_open_and_restore(qtbot, app, tmp_path, monkeypatch):
- """Test opening history dialog and restoring a version"""
- db_path = tmp_path / "notebook.db"
- s = get_settings()
- s.setValue("db/default_db", str(db_path))
- s.setValue("db/key", "test-key")
- s.setValue("ui/idle_minutes", 0)
- s.setValue("ui/theme", "light")
-
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- w = MainWindow(themes=themes)
- qtbot.addWidget(w)
- w.show()
-
- # Add some data
- date_str = QDate.currentDate().toString("yyyy-MM-dd")
- w.db.save_new_version(date_str, "Version 1", "v1")
- w.db.save_new_version(date_str, "Version 2", "v2")
-
- from bouquin.history_dialog import HistoryDialog
-
- def fake_exec(self):
- # Simulate selecting first version and accepting
- if self.list.count() > 0:
- self.list.setCurrentRow(0)
- self._revert()
- return mwmod.QDialog.Accepted
-
- monkeypatch.setattr(HistoryDialog, "exec", fake_exec)
-
- w._open_history()
- qtbot.wait(50)
-
-
-def test_goto_today_button(qtbot, app, tmp_path):
- """Test going to today's date"""
- db_path = tmp_path / "notebook.db"
- s = get_settings()
- s.setValue("db/default_db", str(db_path))
- s.setValue("db/key", "test-key")
- s.setValue("ui/idle_minutes", 0)
- s.setValue("ui/theme", "light")
-
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- w = MainWindow(themes=themes)
- qtbot.addWidget(w)
- w.show()
-
- # Move to a different date
- past_date = QDate.currentDate().addDays(-30)
- w.calendar.setSelectedDate(past_date)
-
- # Go back to today
- w._adjust_today()
- qtbot.wait(50)
-
- assert w.calendar.selectedDate() == QDate.currentDate()
-
-
-def test_adjust_font_size(qtbot, app, tmp_path):
- """Test adjusting font size"""
- db_path = tmp_path / "notebook.db"
- s = get_settings()
- s.setValue("db/default_db", str(db_path))
- s.setValue("db/key", "test-key")
- s.setValue("ui/idle_minutes", 0)
- s.setValue("ui/theme", "light")
- s.setValue("ui/font_size", 12)
-
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- w = MainWindow(themes=themes)
- qtbot.addWidget(w)
- w.show()
-
- initial_size = w.editor.font().pointSize()
-
- # Increase font size
- w._on_font_larger_requested()
- qtbot.wait(50)
- assert w.editor.font().pointSize() > initial_size
-
- # Decrease font size
- w._on_font_smaller_requested()
- qtbot.wait(50)
-
-
-def test_calendar_date_selection(qtbot, app, tmp_path):
- """Test selecting a date from calendar"""
- db_path = tmp_path / "notebook.db"
- s = get_settings()
- s.setValue("db/default_db", str(db_path))
- s.setValue("db/key", "test-key")
- s.setValue("ui/idle_minutes", 0)
- s.setValue("ui/theme", "light")
-
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- w = MainWindow(themes=themes)
- qtbot.addWidget(w)
- w.show()
-
- # Select a specific date
- test_date = QDate(2024, 6, 15)
- w.calendar.setSelectedDate(test_date)
- qtbot.wait(50)
-
- # The window should load that date
- assert test_date.toString("yyyy-MM-dd") in str(w._current_date_iso())
-
-
-def test_main_window_without_reminders(qtbot, app, tmp_db_cfg):
- """Test main window when reminders feature is disabled."""
- s = get_settings()
- s.setValue("db/default_db", str(tmp_db_cfg.path))
- s.setValue("db/key", tmp_db_cfg.key)
- s.setValue("ui/idle_minutes", 0)
- s.setValue("ui/theme", "light")
- s.setValue("ui/move_todos", True)
- s.setValue("ui/tags", True)
- s.setValue("ui/time_log", True)
- s.setValue("ui/reminders", False) # Disabled
- s.setValue("ui/locale", "en")
- s.setValue("ui/font_size", 11)
-
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- window = MainWindow(themes=themes)
- qtbot.addWidget(window)
- window.show()
-
- # Verify reminders widget is hidden
- assert window.upcoming_reminders.isHidden()
- assert not window.toolBar.actAlarm.isVisible()
-
-
-def test_close_current_tab(qtbot, app, tmp_db_cfg, fresh_db):
- """Test closing the current tab via _close_current_tab."""
- s = get_settings()
- s.setValue("db/default_db", str(tmp_db_cfg.path))
- s.setValue("db/key", tmp_db_cfg.key)
- s.setValue("ui/idle_minutes", 0)
- s.setValue("ui/theme", "light")
- s.setValue("ui/move_todos", True)
- s.setValue("ui/tags", True)
- s.setValue("ui/time_log", True)
- s.setValue("ui/reminders", True)
- s.setValue("ui/locale", "en")
- s.setValue("ui/font_size", 11)
-
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- window = MainWindow(themes=themes)
- qtbot.addWidget(window)
- window.show()
-
- # Open multiple tabs
- today = date.today().isoformat()
- tomorrow = (date.today() + timedelta(days=1)).isoformat()
-
- window._open_date_in_tab(QDate.fromString(today, "yyyy-MM-dd"))
- window._open_date_in_tab(QDate.fromString(tomorrow, "yyyy-MM-dd"))
-
- initial_count = window.tab_widget.count()
- assert initial_count >= 2
-
- # Close current tab
- window._close_current_tab()
-
- # Verify tab was closed
- assert window.tab_widget.count() == initial_count - 1
-
-
-def test_parse_today_alarms(qtbot, app, tmp_db_cfg, fresh_db, freeze_qt_time):
- """Test parsing inline alarms from markdown (⏰ HH:MM format)."""
- from PySide6.QtCore import QTime
-
- s = get_settings()
- s.setValue("db/default_db", str(tmp_db_cfg.path))
- s.setValue("db/key", tmp_db_cfg.key)
- s.setValue("ui/idle_minutes", 0)
- s.setValue("ui/theme", "light")
- s.setValue("ui/move_todos", True)
- s.setValue("ui/tags", True)
- s.setValue("ui/time_log", True)
- s.setValue("ui/reminders", True)
- s.setValue("ui/locale", "en")
- s.setValue("ui/font_size", 11)
-
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- window = MainWindow(themes=themes)
- qtbot.addWidget(window)
- window.show()
-
- # Open today's date
- today_qdate = QDate.currentDate()
- window._open_date_in_tab(today_qdate)
-
- # Set content with a future alarm
- future_time = QTime.currentTime().addSecs(3600)
- alarm_text = f"Do something ⏰ {future_time.hour():02d}:{future_time.minute():02d}"
-
- # Set the editor's current_date attribute
- window.editor.current_date = today_qdate
- window.editor.setPlainText(alarm_text)
-
- # Clear any existing timers
- window._reminder_timers = []
-
- # Trigger alarm parsing
- window._rebuild_reminders_for_today()
-
- # Verify timer was created (not DB reminder)
- assert len(window._reminder_timers) > 0
-
-
-def test_parse_today_alarms_invalid_time(qtbot, app, tmp_db_cfg, fresh_db):
- """Test that invalid time formats are skipped."""
- s = get_settings()
- s.setValue("db/default_db", str(tmp_db_cfg.path))
- s.setValue("db/key", tmp_db_cfg.key)
- s.setValue("ui/idle_minutes", 0)
- s.setValue("ui/theme", "light")
- s.setValue("ui/move_todos", True)
- s.setValue("ui/tags", True)
- s.setValue("ui/time_log", True)
- s.setValue("ui/reminders", True)
- s.setValue("ui/locale", "en")
- s.setValue("ui/font_size", 11)
-
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- window = MainWindow(themes=themes)
- qtbot.addWidget(window)
- window.show()
-
- # Open today's date
- today_qdate = QDate.currentDate()
- window._open_date_in_tab(today_qdate)
-
- # Set content with invalid time
- alarm_text = "Do something ⏰ 25:99" # Invalid time
-
- window.editor.current_date = today_qdate
- window.editor.setPlainText(alarm_text)
-
- # Clear any existing timers
- window._reminder_timers = []
-
- # Trigger alarm parsing - should not crash
- window._rebuild_reminders_for_today()
-
- # No timer should be created for invalid time
- assert len(window._reminder_timers) == 0
-
-
-def test_parse_today_alarms_past_time(qtbot, app, tmp_db_cfg, fresh_db, freeze_qt_time):
- """Test that past alarms are skipped."""
- from PySide6.QtCore import QTime
-
- s = get_settings()
- s.setValue("db/default_db", str(tmp_db_cfg.path))
- s.setValue("db/key", tmp_db_cfg.key)
- s.setValue("ui/idle_minutes", 0)
- s.setValue("ui/theme", "light")
- s.setValue("ui/move_todos", True)
- s.setValue("ui/tags", True)
- s.setValue("ui/time_log", True)
- s.setValue("ui/reminders", True)
- s.setValue("ui/locale", "en")
- s.setValue("ui/font_size", 11)
-
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- window = MainWindow(themes=themes)
- qtbot.addWidget(window)
- window.show()
-
- # Open today's date
- today_qdate = QDate.currentDate()
- window._open_date_in_tab(today_qdate)
-
- # Set content with past time
- past_time = QTime.currentTime().addSecs(-3600) # 1 hour ago
- alarm_text = f"Do something ⏰ {past_time.hour():02d}:{past_time.minute():02d}"
-
- window.editor.current_date = today_qdate
- window.editor.setPlainText(alarm_text)
-
- # Clear any existing timers
- window._reminder_timers = []
-
- # Trigger alarm parsing
- window._rebuild_reminders_for_today()
-
- # Past alarms should not create timers
- assert len(window._reminder_timers) == 0
-
-
-def test_parse_today_alarms_no_text(qtbot, app, tmp_db_cfg, fresh_db, freeze_qt_time):
- """Test alarm with no text before emoji uses fallback."""
- from PySide6.QtCore import QTime
-
- s = get_settings()
- s.setValue("db/default_db", str(tmp_db_cfg.path))
- s.setValue("db/key", tmp_db_cfg.key)
- s.setValue("ui/idle_minutes", 0)
- s.setValue("ui/theme", "light")
- s.setValue("ui/move_todos", True)
- s.setValue("ui/tags", True)
- s.setValue("ui/time_log", True)
- s.setValue("ui/reminders", True)
- s.setValue("ui/locale", "en")
- s.setValue("ui/font_size", 11)
-
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- window = MainWindow(themes=themes)
- qtbot.addWidget(window)
- window.show()
-
- # Open today's date
- today_qdate = QDate.currentDate()
- window._open_date_in_tab(today_qdate)
-
- # Set content with alarm but no text
- future_time = QTime.currentTime().addSecs(3600)
- alarm_text = f"⏰ {future_time.hour():02d}:{future_time.minute():02d}"
-
- window.editor.current_date = today_qdate
- window.editor.setPlainText(alarm_text)
-
- # Clear any existing timers
- window._reminder_timers = []
-
- # Trigger alarm parsing
- window._rebuild_reminders_for_today()
-
- # Timer should be created even without text
- assert len(window._reminder_timers) > 0
-
-
-def test_open_history_with_editor(qtbot, app, tmp_db_cfg, fresh_db):
- """Test opening history when editor has content."""
- from unittest.mock import patch
-
- s = get_settings()
- s.setValue("db/default_db", str(tmp_db_cfg.path))
- s.setValue("db/key", tmp_db_cfg.key)
- s.setValue("ui/idle_minutes", 0)
- s.setValue("ui/theme", "light")
- s.setValue("ui/move_todos", True)
- s.setValue("ui/tags", True)
- s.setValue("ui/time_log", True)
- s.setValue("ui/reminders", True)
- s.setValue("ui/locale", "en")
- s.setValue("ui/font_size", 11)
-
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- window = MainWindow(themes=themes)
- qtbot.addWidget(window)
- window.show()
-
- # Create some history
- today = date.today().isoformat()
- fresh_db.save_new_version(today, "v1", "note1")
- fresh_db.save_new_version(today, "v2", "note2")
-
- # Open today's date
- window._open_date_in_tab(QDate.fromString(today, "yyyy-MM-dd"))
-
- # Open history
- with patch("bouquin.history_dialog.HistoryDialog.exec") as mock_exec:
- mock_exec.return_value = False # User cancels
- window._open_history()
-
- # HistoryDialog should have been created and shown
- mock_exec.assert_called_once()
diff --git a/tests/test_markdown_editor.py b/tests/test_markdown_editor.py
index dcacbc5..002ab63 100644
--- a/tests/test_markdown_editor.py
+++ b/tests/test_markdown_editor.py
@@ -1,26 +1,9 @@
-import base64
-
import pytest
+
+from PySide6.QtCore import Qt, QPoint
+from PySide6.QtGui import QImage, QColor, QKeyEvent, QTextCursor
from bouquin.markdown_editor import MarkdownEditor
-from bouquin.markdown_highlighter import MarkdownHighlighter
-from bouquin.theme import Theme, ThemeConfig, ThemeManager
-from PySide6.QtCore import QMimeData, QPoint, Qt, QUrl
-from PySide6.QtGui import (
- QColor,
- QFont,
- QImage,
- QKeyEvent,
- QTextCharFormat,
- QTextCursor,
- QTextDocument,
-)
-from PySide6.QtWidgets import QApplication, QTextEdit
-
-
-def _today():
- from datetime import date
-
- return date.today().isoformat()
+from bouquin.theme import ThemeManager, ThemeConfig, Theme
def text(editor) -> str:
@@ -49,15 +32,6 @@ def editor(app, qtbot):
return ed
-@pytest.fixture
-def editor_hello(app):
- tm = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- e = MarkdownEditor(tm)
- e.setPlainText("hello")
- e.moveCursor(QTextCursor.MoveOperation.End)
- return e
-
-
def test_from_and_to_markdown_roundtrip(editor):
md = "# Title\n\nThis is **bold** and _italic_ and ~~strike~~.\n\n- [ ] task\n- [x] done\n\n```\ncode\n```"
editor.from_markdown(md)
@@ -95,10 +69,11 @@ def test_insert_image_from_path(editor, tmp_path):
editor.insert_image_from_path(img)
md = editor.to_markdown()
- # Accept either "image/png" or older "image/image/png" prefix
- assert "data:image/png;base64" in md or "data:image/image/png;base64" in md
+ # Images are saved as base64 data URIs in markdown
+ assert "data:image/image/png;base64" in md
+@pytest.mark.gui
def test_checkbox_toggle_by_click(editor, qtbot):
# Load a markdown checkbox
editor.from_markdown("- [ ] task here")
@@ -108,10 +83,13 @@ def test_checkbox_toggle_by_click(editor, qtbot):
# Click on the first character region to toggle
c = editor.textCursor()
+ from PySide6.QtGui import QTextCursor
+
c.movePosition(QTextCursor.StartOfBlock)
editor.setTextCursor(c)
r = editor.cursorRect()
center = r.center()
+ # Send click slightly right to land within checkbox icon region
pos = QPoint(r.left() + 2, center.y())
qtbot.mouseClick(editor.viewport(), Qt.LeftButton, pos=pos)
@@ -120,6 +98,7 @@ def test_checkbox_toggle_by_click(editor, qtbot):
assert "☑" in display2
+@pytest.mark.gui
def test_apply_heading_levels(editor, qtbot):
editor.setPlainText("hello")
editor.selectAll()
@@ -136,6 +115,7 @@ def test_apply_heading_levels(editor, qtbot):
assert not editor.toPlainText().startswith("#")
+@pytest.mark.gui
def test_enter_on_nonempty_list_continues(qtbot, editor):
qtbot.addWidget(editor)
editor.show()
@@ -147,9 +127,10 @@ def test_enter_on_nonempty_list_continues(qtbot, editor):
ev = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier, "\n")
editor.keyPressEvent(ev)
txt = editor.toPlainText()
- assert "\n\u2022 " in txt
+ assert "\n- " in txt
+@pytest.mark.gui
def test_enter_on_empty_list_marks_empty(qtbot, editor):
qtbot.addWidget(editor)
editor.show()
@@ -160,27 +141,92 @@ def test_enter_on_empty_list_marks_empty(qtbot, editor):
ev = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier, "\n")
editor.keyPressEvent(ev)
- assert editor.toPlainText().startswith("\u2022 \n")
+ assert editor.toPlainText().startswith("- \n")
-def test_triple_backtick_triggers_code_dialog_but_no_block_on_empty_code(editor, qtbot):
- # Start empty
+@pytest.mark.gui
+def test_triple_backtick_autoexpands(editor, qtbot):
editor.from_markdown("")
press_backtick(qtbot, editor, 2)
- press_backtick(qtbot, editor, 1) # triggers the 3rd-backtick shortcut
+ press_backtick(qtbot, editor, 1) # triggers expansion
qtbot.wait(0)
t = text(editor)
-
- # The two typed backticks should have been removed
- assert "`" not in t
-
- # With the new dialog-based implementation, and our test stub that accepts
- # the dialog with empty code, no fenced code block is inserted.
- assert "```" not in t
- assert t == ""
+ assert t.count("```") == 2
+ assert t.startswith("```\n\n```")
+ assert t.endswith("\n")
+ # caret is on the blank line inside the block
+ assert editor.textCursor().blockNumber() == 1
+ assert lines_keep(editor)[1] == ""
+@pytest.mark.gui
+def test_toolbar_inserts_block_on_own_lines(editor, qtbot):
+ editor.from_markdown("hello")
+ editor.moveCursor(QTextCursor.End)
+ editor.apply_code() # > action
+ qtbot.wait(0)
+
+ t = text(editor)
+ assert "hello```" not in t # never inline
+ assert t.startswith("hello\n```")
+ assert t.endswith("```\n")
+ # caret inside block (blank line)
+ assert editor.textCursor().blockNumber() == 2
+ assert lines_keep(editor)[2] == ""
+
+
+@pytest.mark.gui
+def test_toolbar_inside_block_does_not_insert_inline_fences(editor, qtbot):
+ editor.from_markdown("")
+ editor.apply_code() # create a block (caret now on blank line inside)
+ qtbot.wait(0)
+
+ pos_before = editor.textCursor().position()
+ t_before = text(editor)
+
+ editor.apply_code() # pressing > inside should be a no-op
+ qtbot.wait(0)
+
+ assert text(editor) == t_before
+ assert editor.textCursor().position() == pos_before
+
+
+@pytest.mark.gui
+def test_toolbar_on_opening_fence_jumps_inside(editor, qtbot):
+ editor.from_markdown("")
+ editor.apply_code()
+ qtbot.wait(0)
+
+ # Go to opening fence (line 0)
+ editor.moveCursor(QTextCursor.Start)
+ editor.apply_code() # should jump inside the block
+ qtbot.wait(0)
+
+ assert editor.textCursor().blockNumber() == 1
+ assert lines_keep(editor)[1] == ""
+
+
+@pytest.mark.gui
+def test_toolbar_on_closing_fence_jumps_out(editor, qtbot):
+ editor.from_markdown("")
+ editor.apply_code()
+ qtbot.wait(0)
+
+ # Go to closing fence line (template: 0 fence, 1 blank, 2 fence, 3 blank-after)
+ editor.moveCursor(QTextCursor.End) # blank-after
+ editor.moveCursor(QTextCursor.Up) # closing fence
+ editor.moveCursor(QTextCursor.StartOfLine)
+
+ editor.apply_code() # jump to the line after the fence
+ qtbot.wait(0)
+
+ # Now on the blank line after the block
+ assert editor.textCursor().block().text() == ""
+ assert editor.textCursor().block().previous().text().strip() == "```"
+
+
+@pytest.mark.gui
def test_down_escapes_from_last_code_line(editor, qtbot):
editor.from_markdown("```\nLINE\n```\n")
# Put caret at end of "LINE"
@@ -196,6 +242,7 @@ def test_down_escapes_from_last_code_line(editor, qtbot):
assert editor.textCursor().block().previous().text().strip() == "```"
+@pytest.mark.gui
def test_down_on_closing_fence_at_eof_creates_line(editor, qtbot):
editor.from_markdown("```\ncode\n```") # no trailing newline
# caret on closing fence line
@@ -211,6 +258,7 @@ def test_down_on_closing_fence_at_eof_creates_line(editor, qtbot):
assert editor.textCursor().block().previous().text().strip() == "```"
+@pytest.mark.gui
def test_no_orphan_two_backticks_lines_after_edits(editor, qtbot):
editor.from_markdown("")
# create a block via typing
@@ -222,2135 +270,3 @@ def test_no_orphan_two_backticks_lines_after_edits(editor, qtbot):
# ensure there are no stray "``" lines
assert not any(ln.strip() == "``" for ln in lines_keep(editor))
-
-
-def _fmt_at(block, pos):
- """Return a *copy* of the char format at pos so it doesn't dangle."""
- layout = block.layout()
- for fr in list(layout.formats()):
- if fr.start <= pos < fr.start + fr.length:
- return QTextCharFormat(fr.format)
- return None
-
-
-@pytest.fixture
-def highlighter(app):
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- doc = QTextDocument()
- hl = MarkdownHighlighter(doc, themes)
- return doc, hl
-
-
-def test_headings_and_inline_styles(highlighter):
- doc, hl = highlighter
- doc.setPlainText("# H1\n## H2\n### H3\n***b+i*** **b** *i* __b__ _i_\n")
- hl.rehighlight()
-
- # H1: '#' markers hidden (very small size), text bold/larger
- b0 = doc.findBlockByNumber(0)
- fmt_marker = _fmt_at(b0, 0)
- assert fmt_marker is not None
- assert fmt_marker.fontPointSize() <= 0.2 # marker hidden
-
- fmt_h1_text = _fmt_at(b0, 2)
- assert fmt_h1_text is not None
- assert fmt_h1_text.fontWeight() == QFont.Weight.Bold
-
- # Bold-italic precedence
- b3 = doc.findBlockByNumber(3)
- line = b3.text()
- triple = "***b+i***"
- start = line.find(triple)
- assert start != -1
- pos_inside = start + 3 # skip the *** markers, land on 'b'
- f_bi_inner = _fmt_at(b3, pos_inside)
- assert f_bi_inner is not None
- assert f_bi_inner.fontWeight() == QFont.Weight.Bold and f_bi_inner.fontItalic()
-
- # Bold without triples
- f_b = _fmt_at(b3, b3.text().find("**b**") + 2)
- assert f_b.fontWeight() == QFont.Weight.Bold
-
- # Italic without bold
- f_i = _fmt_at(b3, b3.text().rfind("_i_") + 1)
- assert f_i.fontItalic()
-
-
-def test_code_blocks_inline_code_and_strike_overlay(highlighter):
- doc, hl = highlighter
- doc.setPlainText("```\n**B**\n```\nX ~~**boom**~~ Y `code`\n")
- hl.rehighlight()
-
- # Fence and inner lines use code block format
- fence = doc.findBlockByNumber(0)
- inner = doc.findBlockByNumber(1)
-
- fmt_fence = _fmt_at(fence, 0)
- fmt_inner = _fmt_at(inner, 0)
- assert fmt_fence is not None and fmt_inner is not None
-
- # check key properties
- assert fmt_inner.fontFixedPitch() or fmt_inner.font().styleHint() == QFont.Monospace
- assert fmt_inner.background() == hl.code_block_format.background()
-
- # Inline code uses fixed pitch and hides the backticks
- inline = doc.findBlockByNumber(3)
- start = inline.text().find("`code`")
-
- fmt_inline_char = _fmt_at(inline, start + 1)
- fmt_inline_tick = _fmt_at(inline, start)
- assert fmt_inline_char is not None and fmt_inline_tick is not None
- assert fmt_inline_char.fontFixedPitch()
- assert fmt_inline_tick.fontPointSize() <= 0.2 # backtick hidden
-
- boom_pos = inline.text().find("boom")
- fmt_boom = _fmt_at(inline, boom_pos)
- assert fmt_boom is not None
- assert fmt_boom.fontStrikeOut() and fmt_boom.fontWeight() == QFont.Weight.Bold
-
-
-def test_theme_change_rehighlight(highlighter):
- doc, hl = highlighter
- hl._on_theme_changed()
- doc.setPlainText("`x`")
- hl.rehighlight()
- b = doc.firstBlock()
- fmt = _fmt_at(b, 1)
- assert fmt is not None and fmt.fontFixedPitch()
-
-
-@pytest.fixture
-def hl_light(app):
- # Light theme path
- tm = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- doc = QTextDocument()
- hl = MarkdownHighlighter(doc, tm)
- return doc, hl
-
-
-@pytest.fixture
-def hl_light_edit(app, qtbot):
- tm = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- doc = QTextDocument()
- edit = QTextEdit() # <-- give the doc a layout
- edit.setDocument(doc)
- qtbot.addWidget(edit)
- edit.show()
- qtbot.wait(10) # let Qt build the layouts
- hl = MarkdownHighlighter(doc, tm)
- return doc, hl, edit
-
-
-def fmt(doc, block_no, pos):
- """Return the QTextCharFormat at character position `pos` in the given block."""
- b = doc.findBlockByNumber(block_no)
- it = b.begin()
- off = 0
- while not it.atEnd():
- frag = it.fragment()
- length = frag.length() # includes chars in this fragment
- if off + length > pos:
- return frag.charFormat()
- off += length
- it = it.next()
- # Fallback (shouldn't happen in our tests)
- cf = QTextCharFormat()
- return cf
-
-
-def test_light_palette_specific_colors(hl_light_edit, qtbot):
- doc, hl, edit = hl_light_edit
- doc.setPlainText("```\ncode\n```")
- hl.rehighlight()
- # the second block ("code") is the one inside the fenced block
- b_code = doc.firstBlock().next()
- fmt = _fmt_at(b_code, 0)
- assert fmt is not None and fmt.background().style() != 0
-
-
-def test_code_block_light_colors(hl_light):
- """Ensure code block colors use the light palette (covers 74-75)."""
- doc, hl = hl_light
- doc.setPlainText("```\ncode\n```")
- hl.rehighlight()
- # Background is a light gray and text is dark/black-ish in light theme
- bg = hl.code_block_format.background().color()
- fg = hl.code_block_format.foreground().color()
- assert bg.red() >= 240 and bg.green() >= 240 and bg.blue() >= 240
- assert fg.red() < 40 and fg.green() < 40 and fg.blue() < 40
-
-
-def test_end_guard_skips_italic_followed_by_marker(hl_light):
- """
- Triggers the end-following guard for italic e.g. '*i**'.
- """
- doc, hl = hl_light
- doc.setPlainText("*i**")
- hl.rehighlight()
- # The 'i' should not get italic due to the guard (closing '*' followed by '*')
- f = fmt(doc, 0, 1)
- assert not f.fontItalic()
-
-
-def test_char_rect_at_edges_and_click_checkbox(editor, qtbot):
- """
- Exercises char_rect_at()-style logic and checkbox toggle via click
- to push coverage on geometry-dependent paths.
- """
- editor.from_markdown("- [ ] task")
- c = editor.textCursor()
- c.movePosition(QTextCursor.StartOfBlock)
- editor.setTextCursor(c)
- r = editor.cursorRect()
- qtbot.mouseClick(editor.viewport(), Qt.LeftButton, pos=r.center())
- assert "☑" in editor.toPlainText()
-
-
-def test_heading_apply_levels_and_inline_styles(editor):
- editor.setPlainText("hello")
- editor.selectAll()
- editor.apply_heading(18) # H2
- assert editor.toPlainText().startswith("## ")
- editor.selectAll()
- editor.apply_heading(12) # normal
- assert not editor.toPlainText().startswith("#")
-
- # Bold/italic/strike together to nudge style branches
- editor.setPlainText("hi")
- editor.selectAll()
- editor.apply_weight()
- editor.apply_italic()
- editor.apply_strikethrough()
- md = editor.to_markdown()
- assert "**" in md and "*" in md and "~~" in md
-
-
-def test_insert_image_and_markdown_roundtrip(editor, tmp_path):
- img = tmp_path / "p.png"
- qimg = QImage(2, 2, QImage.Format_RGBA8888)
- qimg.fill(QColor(255, 0, 0))
- assert qimg.save(str(img))
- editor.insert_image_from_path(img)
- # At least a replacement char shows in the plain-text view
- assert "\ufffc" in editor.toPlainText()
- # And markdown contains a data: URI
- assert "data:image" in editor.to_markdown()
-
-
-def test_apply_italic_and_strike(editor):
- # Italic: insert markers with no selection and place caret in between
- editor.setPlainText("x")
- editor.moveCursor(QTextCursor.MoveOperation.End)
- editor.apply_italic()
- assert editor.toPlainText().endswith("x**")
- assert editor.textCursor().position() == len(editor.toPlainText()) - 1
-
- # With selection toggling
- editor.setPlainText("*y*")
- c = editor.textCursor()
- c.movePosition(QTextCursor.MoveOperation.End, QTextCursor.MoveMode.MoveAnchor)
- c.movePosition(QTextCursor.MoveOperation.Start, QTextCursor.MoveMode.KeepAnchor)
- editor.setTextCursor(c)
- editor.apply_italic()
- assert editor.toPlainText() == "y"
-
- # Strike: no selection case inserts placeholder and moves caret
- editor.setPlainText("z")
- editor.moveCursor(QTextCursor.MoveOperation.End)
- editor.apply_strikethrough()
- assert editor.toPlainText().endswith("z~~~~")
- assert editor.textCursor().position() == len(editor.toPlainText()) - 2
-
-
-def test_insert_image_from_path_invalid_returns(editor_hello, tmp_path):
- # Non-existent path should just return (early exit)
- bad = tmp_path / "missing.png"
- editor_hello.insert_image_from_path(bad)
- # Nothing new added
- assert editor_hello.toPlainText() == "hello"
-
-
-# ============================================================================
-# setDocument Tests
-# ============================================================================
-
-
-def test_markdown_editor_set_document(app):
- """Test setting a new document on the editor"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(themes)
-
- # Create a new document
- new_doc = QTextDocument()
- new_doc.setPlainText("New document content")
-
- # Set the document
- editor.setDocument(new_doc)
-
- # Verify document was set
- assert editor.document() == new_doc
- assert "New document content" in editor.toPlainText()
-
-
-def test_markdown_editor_set_document_with_highlighter(app):
- """Test setting document preserves highlighter"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(themes)
-
- # Ensure highlighter exists
- assert hasattr(editor, "highlighter")
-
- # Create and set new document
- new_doc = QTextDocument()
- new_doc.setPlainText("# Heading")
- editor.setDocument(new_doc)
-
- # Highlighter should be attached to new document
- assert editor.highlighter.document() == new_doc
-
-
-# ============================================================================
-# showEvent Tests
-# ============================================================================
-
-
-def test_markdown_editor_show_event(app, qtbot):
- """Test showEvent triggers code block background update"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(themes)
- editor.from_markdown("```python\ncode\n```")
-
- # Show the editor
- editor.show()
- qtbot.waitExposed(editor)
-
- # Process events to let QTimer.singleShot fire
- QApplication.processEvents()
-
- # Editor should be visible
- assert editor.isVisible()
-
-
-# ============================================================================
-# Checkbox Transformation Tests
-# ============================================================================
-
-
-def test_markdown_editor_transform_unchecked_checkbox(app, qtbot):
- """Test transforming - [ ] to unchecked checkbox"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(themes)
- editor.show()
- qtbot.waitExposed(editor)
-
- # Type checkbox markdown
- editor.insertPlainText("- [ ] Task")
-
- # Process events to let transformation happen
- QApplication.processEvents()
-
- # Should contain checkbox character
- text = editor.toPlainText()
- assert editor._CHECK_UNCHECKED_DISPLAY in text
-
-
-def test_markdown_editor_transform_checked_checkbox(app, qtbot):
- """Test transforming - [x] to checked checkbox"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(themes)
- editor.show()
- qtbot.waitExposed(editor)
-
- # Type checked checkbox markdown
- editor.insertPlainText("- [x] Done")
-
- # Process events
- QApplication.processEvents()
-
- # Should contain checked checkbox character
- text = editor.toPlainText()
- assert editor._CHECK_CHECKED_DISPLAY in text
-
-
-def test_markdown_editor_transform_todo(app, qtbot):
- """Test transforming TODO to unchecked checkbox"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(themes)
- editor.show()
- qtbot.waitExposed(editor)
-
- # Type TODO
- editor.insertPlainText("TODO: Important task")
-
- # Process events
- QApplication.processEvents()
-
- # Should contain checkbox and no TODO
- text = editor.toPlainText()
- assert editor._CHECK_UNCHECKED_DISPLAY in text
-
-
-def test_markdown_editor_transform_todo_with_indent(app, qtbot):
- """Test transforming indented TODO"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(themes)
- editor.show()
- qtbot.waitExposed(editor)
-
- # Type indented TODO
- editor.insertPlainText(" TODO: Indented task")
-
- # Process events
- QApplication.processEvents()
-
- # Should handle indented TODO
- text = editor.toPlainText()
- assert editor._CHECK_UNCHECKED_DISPLAY in text
-
-
-def test_markdown_editor_transform_todo_with_colon(app, qtbot):
- """Test transforming TODO: with colon"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(themes)
- editor.show()
- qtbot.waitExposed(editor)
-
- # Type TODO with colon
- editor.insertPlainText("TODO: Task with colon")
-
- # Process events
- QApplication.processEvents()
-
- text = editor.toPlainText()
- assert editor._CHECK_UNCHECKED_DISPLAY in text
-
-
-def test_markdown_editor_transform_todo_with_dash(app, qtbot):
- """Test transforming TODO- with dash"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(themes)
- editor.show()
- qtbot.waitExposed(editor)
-
- # Type TODO with dash
- editor.insertPlainText("TODO- Task with dash")
-
- # Process events
- QApplication.processEvents()
-
- text = editor.toPlainText()
- assert editor._CHECK_UNCHECKED_DISPLAY in text
-
-
-def test_markdown_editor_no_transform_when_updating(app):
- """Test that transformation doesn't happen when _updating flag is set"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(themes)
-
- # Set updating flag
- editor._updating = True
-
- # Try to insert checkbox markdown
- editor.insertPlainText("- [ ] Task")
-
- # Should NOT transform since _updating is True
- # This tests the early return in _on_text_changed
- assert editor._updating
-
-
-# ============================================================================
-# Code Block Tests
-# ============================================================================
-
-
-def test_markdown_editor_is_inside_code_block(app):
- """Test detecting if cursor is inside code block"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(themes)
- editor.from_markdown("```python\ncode here\n```\noutside")
-
- # Move cursor to inside code block
- cursor = editor.textCursor()
- cursor.setPosition(10) # Inside the code block
- editor.setTextCursor(cursor)
-
- block = cursor.block()
- # Test the method exists and can be called
- result = editor._is_inside_code_block(block)
- assert isinstance(result, bool)
-
-
-def test_markdown_editor_code_block_spacing(app):
- """Test code block spacing application"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(themes)
- editor.from_markdown("```python\nline1\nline2\n```")
-
- # Apply code block spacing
- editor._apply_code_block_spacing()
-
- # Should complete without error
- assert True
-
-
-def test_markdown_editor_update_code_block_backgrounds(app):
- """Test updating code block backgrounds"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(themes)
- editor.from_markdown("```python\ncode\n```")
-
- # Update backgrounds
- editor._update_code_block_row_backgrounds()
-
- # Should complete without error
- assert True
-
-
-# ============================================================================
-# Image Insertion Tests
-# ============================================================================
-
-
-def test_markdown_editor_insert_image_from_path(app, tmp_path):
- """Test inserting image from file path"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(themes)
-
- # Create a real PNG image (1x1 pixel)
- # PNG file signature + minimal valid PNG data
- png_data = (
- b"\x89PNG\r\n\x1a\n" # PNG signature
- b"\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
- b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89" # IHDR chunk
- b"\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01"
- b"\r\n-\xb4" # IDAT chunk
- b"\x00\x00\x00\x00IEND\xaeB`\x82" # IEND chunk
- )
- image_path = tmp_path / "test.png"
- image_path.write_bytes(png_data)
-
- # Insert image
- editor.insert_image_from_path(image_path)
-
- # Check that document has content (image + newline)
- # Images don't show in toPlainText() but affect document structure
- doc = editor.document()
- assert doc.characterCount() > 1 # Should have image char + newline
-
-
-# ============================================================================
-# Formatting Tests
-# ============================================================================
-
-
-def test_markdown_editor_toggle_bold_empty_selection(app):
- """Test toggling bold with no selection"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(themes)
- editor.insertPlainText("text")
-
- # Move cursor to middle of text (no selection)
- cursor = editor.textCursor()
- cursor.setPosition(2)
- editor.setTextCursor(cursor)
-
- # Toggle bold (inserts ** markers with cursor between them)
- editor.apply_weight()
-
- # Should have inserted bold markers
- text = editor.toPlainText()
- assert "**" in text
-
- # Should handle empty selection
- assert True
-
-
-def test_markdown_editor_toggle_italic_empty_selection(app):
- """Test toggling italic with no selection"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(themes)
- editor.insertPlainText("text")
-
- # Move cursor to middle (no selection)
- cursor = editor.textCursor()
- cursor.setPosition(2)
- editor.setTextCursor(cursor)
-
- # Toggle italic
- editor.apply_italic()
-
- # Should handle empty selection
- assert True
-
-
-def test_markdown_editor_toggle_strikethrough_empty_selection(app):
- """Test toggling strikethrough with no selection"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(themes)
- editor.insertPlainText("text")
-
- cursor = editor.textCursor()
- cursor.setPosition(2)
- editor.setTextCursor(cursor)
-
- editor.apply_strikethrough()
-
- assert True
-
-
-def test_markdown_editor_toggle_code_empty_selection(app):
- """Test toggling code with no selection"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(themes)
- editor.insertPlainText("text")
-
- cursor = editor.textCursor()
- cursor.setPosition(2)
- editor.setTextCursor(cursor)
-
- editor.apply_code()
-
- assert True
-
-
-# ============================================================================
-# Heading Tests
-# ============================================================================
-
-
-def test_markdown_editor_set_heading_various_levels(app):
- """Test setting different heading levels"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(themes)
-
- for level in [14, 18, 24]:
- editor.clear()
- editor.insertPlainText("Heading text")
-
- # Select all
- cursor = editor.textCursor()
- cursor.select(QTextCursor.Document)
- editor.setTextCursor(cursor)
-
- # Set heading level
- editor.apply_heading(level)
-
- # Should have heading markdown
- text = editor.toPlainText()
- assert "#" in text
-
-
-def test_markdown_editor_set_heading_zero_removes_heading(app):
- """Test setting heading level 0 removes heading"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(themes)
- editor.from_markdown("# Heading")
-
- # Select heading
- cursor = editor.textCursor()
- cursor.select(QTextCursor.Document)
- editor.setTextCursor(cursor)
-
- # Set to level 0 (remove heading)
- editor.apply_heading(0)
-
- # Should not have heading markers
- text = editor.toPlainText()
- assert not text.startswith("#")
-
-
-# ============================================================================
-# List Tests
-# ============================================================================
-
-
-def test_markdown_editor_toggle_list_bullet(app):
- """Test toggling bullet list"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(themes)
- editor.insertPlainText("Item 1\nItem 2")
-
- # Select all
- cursor = editor.textCursor()
- cursor.select(QTextCursor.Document)
- editor.setTextCursor(cursor)
-
- # Toggle bullet list
- editor.toggle_bullets()
-
- # Should have bullet markers
- text = editor.toPlainText()
- assert "•" in text or "-" in text
-
-
-def test_markdown_editor_toggle_list_ordered(app):
- """Test toggling ordered list"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(themes)
- editor.insertPlainText("Item 1\nItem 2")
-
- cursor = editor.textCursor()
- cursor.select(QTextCursor.Document)
- editor.setTextCursor(cursor)
-
- editor.toggle_numbers()
-
- text = editor.toPlainText()
- assert "1" in text or "2" in text
-
-
-# ============================================================================
-# Code Block Tests
-# ============================================================================
-
-
-def test_markdown_editor_apply_code_selected_text(app):
- """Test toggling code block with selected text"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(themes)
- editor.insertPlainText("def hello():\n print('hi')")
-
- # Select all
- cursor = editor.textCursor()
- cursor.select(QTextCursor.Document)
- editor.setTextCursor(cursor)
-
- # Toggle code block
- editor.apply_code()
-
- # Should have code fence
- text = editor.toPlainText()
- assert "```" in text
-
-
-def test_markdown_editor_apply_code_remove(app):
- """Test removing code block"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(themes)
- editor.from_markdown("```python\ncode\n```")
-
- # Select all
- cursor = editor.textCursor()
- cursor.select(QTextCursor.Document)
- editor.setTextCursor(cursor)
-
- # Toggle off
- editor.apply_code()
-
- # Code fences should be reduced/removed
- editor.toPlainText()
- # May still have ``` but different structure
- assert True # Just verify no crash
-
-
-# ============================================================================
-# Checkbox Tests
-# ============================================================================
-
-
-def test_markdown_editor_insert_checkbox_unchecked(app):
- """Test inserting unchecked checkbox"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(themes)
-
- editor.toggle_checkboxes()
-
- text = editor.toPlainText()
- assert editor._CHECK_UNCHECKED_DISPLAY in text
-
-
-# ============================================================================
-# Toggle Checkboxes Tests
-# ============================================================================
-
-
-def test_markdown_editor_toggle_checkboxes_none_selected(app):
- """Test toggling checkboxes with no selection"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(themes)
- editor.from_markdown("☐ Task 1\n☐ Task 2")
-
- # No selection, just cursor
- editor.toggle_checkboxes()
-
- # Should handle gracefully
- assert True
-
-
-def test_markdown_editor_toggle_checkboxes_mixed(app):
- """Test toggling mixed checked/unchecked checkboxes"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(themes)
- editor.from_markdown("☐ Task 1\n☑ Task 2\n☐ Task 3")
-
- # Select all
- cursor = editor.textCursor()
- cursor.select(QTextCursor.Document)
- editor.setTextCursor(cursor)
-
- # Toggle
- editor.toggle_checkboxes()
-
- # Should toggle all
- text = editor.toPlainText()
- assert (
- editor._CHECK_CHECKED_DISPLAY in text or editor._CHECK_UNCHECKED_DISPLAY in text
- )
-
-
-# ============================================================================
-# Markdown Conversion Tests
-# ============================================================================
-
-
-def test_markdown_editor_to_markdown_with_checkboxes(app):
- """Test converting to markdown preserves checkboxes"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(themes)
- editor.from_markdown("- [ ] Task 1\n- [x] Task 2")
-
- md = editor.to_markdown()
-
- # Should have checkbox markdown
- assert "[ ]" in md or "[x]" in md
-
-
-def test_markdown_editor_from_markdown_with_images(app):
- """Test loading markdown with images"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(themes)
-
- md_with_image = "# Title\n\n\n\nText"
- editor.from_markdown(md_with_image)
-
- # Should load without error
- text = editor.toPlainText()
- assert "Title" in text
-
-
-def test_markdown_editor_from_markdown_with_links(app):
- """Test loading markdown with links"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(themes)
-
- md_with_link = "[Click here](https://example.com)"
- editor.from_markdown(md_with_link)
-
- text = editor.toPlainText()
- assert "Click here" in text
-
-
-# ============================================================================
-# Selection and Cursor Tests
-# ============================================================================
-
-
-def test_markdown_editor_select_word_under_cursor(app):
- """Test selecting word under cursor"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(themes)
- editor.insertPlainText("Hello world test")
-
- # Move cursor to middle of word
- cursor = editor.textCursor()
- cursor.setPosition(7) # Middle of "world"
- editor.setTextCursor(cursor)
-
- # Select word (via double-click or other mechanism)
- cursor.select(QTextCursor.WordUnderCursor)
- editor.setTextCursor(cursor)
-
- assert cursor.hasSelection()
-
-
-def test_markdown_editor_get_selected_blocks(app):
- """Test getting selected blocks"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(themes)
- editor.insertPlainText("Line 1\nLine 2\nLine 3")
-
- # Select multiple lines
- cursor = editor.textCursor()
- cursor.setPosition(0)
- cursor.setPosition(14, QTextCursor.KeepAnchor)
- editor.setTextCursor(cursor)
-
- # Should have selection
- assert cursor.hasSelection()
-
-
-# ============================================================================
-# Key Event Tests
-# ============================================================================
-
-
-def test_markdown_editor_key_press_tab(app):
- """Test tab key handling"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(themes)
- editor.show()
-
- # Create tab key event
- event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Tab, Qt.NoModifier)
-
- # Send event
- editor.keyPressEvent(event)
-
- # Should insert tab or spaces
- text = editor.toPlainText()
- assert len(text) > 0 or text == "" # Tab or spaces inserted
-
-
-def test_markdown_editor_key_press_return_in_list(app):
- """Test return key in list"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(themes)
- editor.from_markdown("- Item 1")
-
- # Move cursor to end
- cursor = editor.textCursor()
- cursor.movePosition(QTextCursor.End)
- editor.setTextCursor(cursor)
-
- # Press return
- event = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier)
- editor.keyPressEvent(event)
-
- # Should create new list item
- text = editor.toPlainText()
- assert "Item 1" in text
-
-
-# ============================================================================
-# Link Handling Tests
-# ============================================================================
-
-
-def test_markdown_editor_anchor_at_cursor(app):
- """Test getting anchor at cursor position"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(themes)
- editor.from_markdown("[link](https://example.com)")
-
- # Move cursor over link
- cursor = editor.textCursor()
- cursor.setPosition(2)
- editor.setTextCursor(cursor)
-
- # Get anchor (if any)
- anchor = cursor.charFormat().anchorHref()
-
- # May or may not have anchor depending on rendering
- assert isinstance(anchor, str)
-
-
-def test_markdown_editor_mouse_move_over_link(app):
- """Test mouse movement over link"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(themes)
- editor.from_markdown("[link](https://example.com)")
- editor.show()
-
- # Simulate mouse move
- # This tests viewport event handling
- assert True # Just verify no crash
-
-
-# ============================================================================
-# Theme Mode Tests
-# ============================================================================
-
-
-def test_markdown_highlighter_light_mode(app):
- """Test highlighter in light mode"""
- doc = QTextDocument()
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- highlighter = MarkdownHighlighter(doc, themes)
-
- # Check that light mode colors are set
- bg = highlighter.code_block_format.background().color()
- assert bg.isValid()
- # Check it's a light color (high RGB values, close to 245)
- assert bg.red() > 240 and bg.green() > 240 and bg.blue() > 240
-
- fg = highlighter.code_block_format.foreground().color()
- assert fg.isValid()
- # Check it's a dark color for text
- assert fg.red() < 50 and fg.green() < 50 and fg.blue() < 50
-
-
-def test_markdown_highlighter_dark_mode(app):
- """Test highlighter in dark mode"""
- doc = QTextDocument()
- themes = ThemeManager(app, ThemeConfig(theme=Theme.DARK))
- highlighter = MarkdownHighlighter(doc, themes)
-
- # Check that dark mode uses palette colors
- bg = highlighter.code_block_format.background().color()
- fg = highlighter.code_block_format.foreground().color()
-
- assert bg.isValid()
- assert fg.isValid()
-
-
-# ============================================================================
-# Highlighting Pattern Tests
-# ============================================================================
-
-
-def test_markdown_highlighter_triple_backtick_code(app):
- """Test highlighting triple backtick code blocks"""
- doc = QTextDocument()
- doc.setPlainText("```python\ndef hello():\n pass\n```")
-
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- highlighter = MarkdownHighlighter(doc, themes)
-
- # Force rehighlight
- highlighter.rehighlight()
-
- # Should complete without errors
- assert True
-
-
-def test_markdown_highlighter_inline_code(app):
- """Test highlighting inline code with backticks"""
- doc = QTextDocument()
- doc.setPlainText("Here is `inline code` in text")
-
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- highlighter = MarkdownHighlighter(doc, themes)
- highlighter.rehighlight()
-
- assert True
-
-
-def test_markdown_highlighter_bold_text(app):
- """Test highlighting bold text"""
- doc = QTextDocument()
- doc.setPlainText("This is **bold** text")
-
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- highlighter = MarkdownHighlighter(doc, themes)
- highlighter.rehighlight()
-
- assert True
-
-
-def test_markdown_highlighter_italic_text(app):
- """Test highlighting italic text"""
- doc = QTextDocument()
- doc.setPlainText("This is *italic* text")
-
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- highlighter = MarkdownHighlighter(doc, themes)
- highlighter.rehighlight()
-
- assert True
-
-
-def test_markdown_highlighter_headings(app):
- """Test highlighting various heading levels"""
- doc = QTextDocument()
- doc.setPlainText("# H1\n## H2\n### H3\n#### H4\n##### H5\n###### H6")
-
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- highlighter = MarkdownHighlighter(doc, themes)
- highlighter.rehighlight()
-
- assert True
-
-
-def test_markdown_highlighter_links(app):
- """Test highlighting markdown links"""
- doc = QTextDocument()
- doc.setPlainText("[link text](https://example.com)")
-
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- highlighter = MarkdownHighlighter(doc, themes)
- highlighter.rehighlight()
-
- assert True
-
-
-def test_markdown_highlighter_images(app):
- """Test highlighting markdown images"""
- doc = QTextDocument()
- doc.setPlainText("")
-
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- highlighter = MarkdownHighlighter(doc, themes)
- highlighter.rehighlight()
-
- assert True
-
-
-def test_markdown_highlighter_blockquotes(app):
- """Test highlighting blockquotes"""
- doc = QTextDocument()
- doc.setPlainText("> This is a quote\n> Second line")
-
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- highlighter = MarkdownHighlighter(doc, themes)
- highlighter.rehighlight()
-
- assert True
-
-
-def test_markdown_highlighter_lists(app):
- """Test highlighting lists"""
- doc = QTextDocument()
- doc.setPlainText("- Item 1\n- Item 2\n- Item 3")
-
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- highlighter = MarkdownHighlighter(doc, themes)
- highlighter.rehighlight()
-
- assert True
-
-
-def test_markdown_highlighter_ordered_lists(app):
- """Test highlighting ordered lists"""
- doc = QTextDocument()
- doc.setPlainText("1. First\n2. Second\n3. Third")
-
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- highlighter = MarkdownHighlighter(doc, themes)
- highlighter.rehighlight()
-
- assert True
-
-
-def test_markdown_highlighter_horizontal_rules(app):
- """Test highlighting horizontal rules"""
- doc = QTextDocument()
- doc.setPlainText("Text above\n\n---\n\nText below")
-
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- highlighter = MarkdownHighlighter(doc, themes)
- highlighter.rehighlight()
-
- assert True
-
-
-def test_markdown_highlighter_strikethrough(app):
- """Test highlighting strikethrough text"""
- doc = QTextDocument()
- doc.setPlainText("This is ~~strikethrough~~ text")
-
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- highlighter = MarkdownHighlighter(doc, themes)
- highlighter.rehighlight()
-
- assert True
-
-
-def test_markdown_highlighter_mixed_formatting(app):
- """Test highlighting mixed markdown formatting"""
- doc = QTextDocument()
- doc.setPlainText(
- "# Title\n\nThis is **bold** and *italic* with `code`.\n\n- List item\n- Another item"
- )
-
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- highlighter = MarkdownHighlighter(doc, themes)
- highlighter.rehighlight()
-
- assert True
-
-
-def test_markdown_highlighter_switch_dark_mode(app):
- """Test that dark mode uses different colors than light mode"""
- doc = QTextDocument()
- doc.setPlainText("# Test")
-
- # Create light mode highlighter
- themes_light = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- highlighter_light = MarkdownHighlighter(doc, themes_light)
- light_bg = highlighter_light.code_block_format.background().color()
-
- # Create dark mode highlighter with new document (to avoid conflicts)
- doc2 = QTextDocument()
- doc2.setPlainText("# Test")
- themes_dark = ThemeManager(app, ThemeConfig(theme=Theme.DARK))
- highlighter_dark = MarkdownHighlighter(doc2, themes_dark)
- dark_bg = highlighter_dark.code_block_format.background().color()
-
- # In light mode, background should be light (high RGB values)
- # In dark mode, background should be darker (lower RGB values)
- # Note: actual values depend on system palette and theme settings
- assert light_bg.isValid()
- assert dark_bg.isValid()
-
- # At least one of these should be true (depending on system theme):
- # - Light is lighter than dark, OR
- # - Both are set to valid colors (if system theme overrides)
- is_light_lighter = (
- light_bg.red() + light_bg.green() + light_bg.blue()
- > dark_bg.red() + dark_bg.green() + dark_bg.blue()
- )
- both_valid = light_bg.isValid() and dark_bg.isValid()
-
- assert is_light_lighter or both_valid # At least colors are being set
-
-
-# ============================================================================
-# MarkdownHighlighter Tests - Missing Coverage
-# ============================================================================
-
-
-def test_markdown_highlighter_code_block_detection(qtbot, app):
- """Test code block detection and highlighting."""
- theme_manager = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- doc = QTextDocument()
- highlighter = MarkdownHighlighter(doc, theme_manager)
-
- # Set text with code block
- text = """
-Some text
-```python
-def hello():
- pass
-```
-More text
-"""
- doc.setPlainText(text)
-
- # The highlighter should process the text
- # Just ensure no crash
- assert highlighter is not None
-
-
-def test_markdown_highlighter_headers(qtbot, app):
- """Test header highlighting."""
- theme_manager = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- doc = QTextDocument()
- highlighter = MarkdownHighlighter(doc, theme_manager)
-
- text = """
-# Header 1
-## Header 2
-### Header 3
-Normal text
-"""
- doc.setPlainText(text)
-
- assert highlighter is not None
-
-
-def test_markdown_highlighter_emphasis(qtbot, app):
- """Test emphasis highlighting."""
- theme_manager = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- doc = QTextDocument()
- highlighter = MarkdownHighlighter(doc, theme_manager)
-
- text = "**bold** and *italic* and ***both***"
- doc.setPlainText(text)
-
- assert highlighter is not None
-
-
-def test_markdown_highlighter_horizontal_rule(qtbot, app):
- """Test horizontal rule highlighting."""
- theme_manager = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- doc = QTextDocument()
- highlighter = MarkdownHighlighter(doc, theme_manager)
-
- text = """
-Text above
----
-Text below
-***
-More text
-___
-End
-"""
- doc.setPlainText(text)
-
- assert highlighter is not None
-
-
-def test_markdown_highlighter_complex_document(qtbot, app):
- """Test highlighting a complex document with mixed elements."""
- theme_manager = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- doc = QTextDocument()
- highlighter = MarkdownHighlighter(doc, theme_manager)
-
- text = """
-# Main Title
-
-This is a paragraph with **bold** and *italic* text.
-
-## Code Example
-
-Here's some `inline code` and a block:
-
-```python
-def fibonacci(n):
- if n <= 1:
- return n
- return fibonacci(n-1) + fibonacci(n-2)
-```
-
-## Lists
-
-- Item with *emphasis*
-- Another item with **bold**
-- [A link](https://example.com)
-
-> A blockquote with **formatted** text
-> Second line
-
----
-
-### Final Section
-
-~~Strikethrough~~ and normal text.
-"""
- doc.setPlainText(text)
-
- # Should handle complex document
- assert highlighter is not None
-
-
-def test_markdown_highlighter_empty_document(qtbot, app):
- """Test highlighting empty document."""
- theme_manager = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- doc = QTextDocument()
- highlighter = MarkdownHighlighter(doc, theme_manager)
-
- doc.setPlainText("")
-
- assert highlighter is not None
-
-
-def test_markdown_highlighter_update_on_text_change(qtbot, app):
- """Test that highlighter updates when text changes."""
- theme_manager = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- doc = QTextDocument()
- highlighter = MarkdownHighlighter(doc, theme_manager)
-
- doc.setPlainText("Initial text")
- doc.setPlainText("# Header text")
- doc.setPlainText("**Bold text**")
-
- # Should handle updates
- assert highlighter is not None
-
-
-def test_markdown_highlighter_nested_emphasis(qtbot, app):
- """Test nested emphasis patterns."""
- theme_manager = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- doc = QTextDocument()
- highlighter = MarkdownHighlighter(doc, theme_manager)
-
- text = "This has **bold with *italic* inside** and more"
- doc.setPlainText(text)
-
- assert highlighter is not None
-
-
-def test_markdown_highlighter_unclosed_code_block(qtbot, app):
- """Test handling of unclosed code block."""
- theme_manager = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- doc = QTextDocument()
- highlighter = MarkdownHighlighter(doc, theme_manager)
-
- text = """
-```python
-def hello():
- print("world")
-"""
- doc.setPlainText(text)
-
- # Should handle gracefully
- assert highlighter is not None
-
-
-def test_markdown_highlighter_special_characters(qtbot, app):
- """Test handling special characters in markdown."""
- theme_manager = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- doc = QTextDocument()
- highlighter = MarkdownHighlighter(doc, theme_manager)
-
- text = """
-Special chars: < > & " '
-Escaped: \\* \\_ \\`
-Unicode: 你好 café résumé
-"""
- doc.setPlainText(text)
-
- assert highlighter is not None
-
-
-@pytest.mark.parametrize(
- "markdown_line",
- [
- "- [ ] Task", # checkbox
- "- Task", # bullet
- "1. Task", # numbered
- ],
-)
-def test_home_on_list_line_moves_to_text_start(qtbot, editor, markdown_line):
- """Home on a list line should jump to just after the list marker."""
- editor.from_markdown(markdown_line)
-
- # Put caret at end of the line
- cursor = editor.textCursor()
- cursor.movePosition(QTextCursor.End)
- editor.setTextCursor(cursor)
-
- # Press Home (no modifiers)
- qtbot.keyPress(editor, Qt.Key_Home)
- qtbot.wait(0)
-
- c = editor.textCursor()
- block = c.block()
- line = block.text()
- pos_in_block = c.position() - block.position()
-
- # The first character of the user text is the 'T' in "Task"
- logical_start = line.index("Task")
-
- assert not c.hasSelection()
- assert pos_in_block == logical_start
-
-
-@pytest.mark.parametrize(
- "markdown_line",
- [
- "- [ ] Task", # checkbox
- "- Task", # bullet
- "1. Task", # numbered
- ],
-)
-def test_shift_home_on_list_line_selects_text_after_marker(
- qtbot, editor, markdown_line
-):
- """
- Shift+Home from the end of a list line should select the text after the marker,
- not the marker itself.
- """
- editor.from_markdown(markdown_line)
-
- # Put caret at end of the line
- cursor = editor.textCursor()
- cursor.movePosition(QTextCursor.End)
- editor.setTextCursor(cursor)
-
- # Shift+Home: extend selection back to "logical home"
- qtbot.keyPress(editor, Qt.Key_Home, Qt.ShiftModifier)
- qtbot.wait(0)
-
- c = editor.textCursor()
- block = c.block()
- line = block.text()
- block_start = block.position()
-
- logical_start = line.index("Task")
- expected_start = block_start + logical_start
- expected_end = block_start + len(line)
-
- assert c.hasSelection()
- assert c.selectionStart() == expected_start
- assert c.selectionEnd() == expected_end
- # Selected text is exactly the user-visible text, not the marker
- assert c.selectedText() == line[logical_start:]
-
-
-def test_up_from_below_checkbox_moves_to_text_start(qtbot, editor):
- """
- Up from the line below a checkbox should land to the right of the checkbox,
- where the text starts, not to the left of the marker.
- """
- editor.from_markdown("- [ ] Task\nSecond line")
-
- # Put caret somewhere on the second line (end of document is fine)
- cursor = editor.textCursor()
- cursor.movePosition(QTextCursor.End)
- editor.setTextCursor(cursor)
-
- # Press Up to move to the checkbox line
- qtbot.keyPress(editor, Qt.Key_Up)
- qtbot.wait(0)
-
- c = editor.textCursor()
- block = c.block()
- line = block.text()
- pos_in_block = c.position() - block.position()
-
- logical_start = line.index("Task")
- assert pos_in_block >= logical_start
-
-
-def test_backspace_on_empty_checkbox_removes_marker(qtbot, editor):
- """
- When a checkbox line has no text after the marker, Backspace at/after the
- text position should delete the marker itself, leaving a plain empty line.
- """
- editor.from_markdown("- [ ] ")
-
- # Put caret at end of the checkbox line (after the marker)
- cursor = editor.textCursor()
- cursor.movePosition(QTextCursor.End)
- editor.setTextCursor(cursor)
-
- qtbot.keyPress(editor, Qt.Key_Backspace)
- qtbot.wait(0)
-
- first_block = editor.document().firstBlock()
- # Marker should be gone
- assert first_block.text() == ""
- assert editor._CHECK_UNCHECKED_DISPLAY not in editor.toPlainText()
-
-
-def test_render_images_with_corrupted_data(qtbot, app):
- """Test rendering images with corrupted data that creates null QImage"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(theme_manager=themes)
- qtbot.addWidget(editor)
- editor.show()
-
- # Create some binary data that will decode but not form a valid image
- corrupted_data = base64.b64encode(b"not an image file").decode("utf-8")
- markdown = f""
-
- editor.from_markdown(markdown)
- qtbot.wait(50)
-
- # Should still work without crashing
- text = editor.to_markdown()
- assert len(text) >= 0
-
-
-def test_editor_with_code_blocks(qtbot, app):
- """Test editor with code blocks"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(theme_manager=themes)
- qtbot.addWidget(editor)
- editor.show()
-
- code_markdown = """
-Some text
-
-```python
-def hello():
- print("world")
-```
-
-More text
-"""
- editor.from_markdown(code_markdown)
- qtbot.wait(50)
-
- result = editor.to_markdown()
- assert "def hello" in result or "python" in result
-
-
-def test_editor_undo_redo(qtbot, app):
- """Test undo/redo functionality"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(theme_manager=themes)
- qtbot.addWidget(editor)
- editor.show()
-
- # Type some text
- editor.from_markdown("Initial text")
- qtbot.wait(50)
-
- # Add more text
- editor.insertPlainText(" additional")
- qtbot.wait(50)
-
- # Undo
- editor.undo()
- qtbot.wait(50)
-
- # Redo
- editor.redo()
- qtbot.wait(50)
-
- assert len(editor.to_markdown()) > 0
-
-
-def test_editor_cut_copy_paste(qtbot, app):
- """Test cut/copy/paste operations"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(theme_manager=themes)
- qtbot.addWidget(editor)
- editor.show()
-
- editor.from_markdown("Test content for copy")
- qtbot.wait(50)
-
- # Select all
- editor.selectAll()
-
- # Copy
- editor.copy()
- qtbot.wait(50)
-
- # Move to end and paste
- cursor = editor.textCursor()
- cursor.movePosition(QTextCursor.End)
- editor.setTextCursor(cursor)
-
- editor.paste()
- qtbot.wait(50)
-
- # Should have content twice (or clipboard might be empty in test env)
- assert len(editor.to_markdown()) > 0
-
-
-def test_editor_with_blockquotes(qtbot, app):
- """Test editor with blockquotes"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(theme_manager=themes)
- qtbot.addWidget(editor)
- editor.show()
-
- quote_markdown = """
-Normal text
-
-> This is a quote
-> With multiple lines
-
-More normal text
-"""
- editor.from_markdown(quote_markdown)
- qtbot.wait(50)
-
- result = editor.to_markdown()
- assert ">" in result or "quote" in result
-
-
-def test_editor_with_horizontal_rules(qtbot, app):
- """Test editor with horizontal rules"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(theme_manager=themes)
- qtbot.addWidget(editor)
- editor.show()
-
- hr_markdown = """
-Section 1
-
----
-
-Section 2
-"""
- editor.from_markdown(hr_markdown)
- qtbot.wait(50)
-
- result = editor.to_markdown()
- assert "Section" in result
-
-
-def test_editor_with_mixed_content(qtbot, app):
- """Test editor with mixed markdown content"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(theme_manager=themes)
- qtbot.addWidget(editor)
- editor.show()
-
- mixed_markdown = """
-# Heading
-
-This is **bold** and *italic* text.
-
-- [ ] Todo item
-- [x] Completed item
-
-```python
-code()
-```
-
-[Link](https://example.com)
-
-> Quote
-
-| Table | Header |
-|-------|--------|
-| A | B |
-"""
- editor.from_markdown(mixed_markdown)
- qtbot.wait(50)
-
- result = editor.to_markdown()
- # Should contain various markdown elements
- assert len(result) > 50
-
-
-def test_editor_insert_text_at_cursor(qtbot, app):
- """Test inserting text at cursor position"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(theme_manager=themes)
- qtbot.addWidget(editor)
- editor.show()
-
- editor.from_markdown("Start Middle End")
- qtbot.wait(50)
-
- # Move cursor to middle
- cursor = editor.textCursor()
- cursor.setPosition(6)
- editor.setTextCursor(cursor)
-
- # Insert text
- editor.insertPlainText("INSERTED ")
- qtbot.wait(50)
-
- result = editor.to_markdown()
- assert "INSERTED" in result
-
-
-def test_editor_delete_operations(qtbot, app):
- """Test delete operations"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- editor = MarkdownEditor(theme_manager=themes)
- qtbot.addWidget(editor)
- editor.show()
-
- editor.from_markdown("Text to delete")
- qtbot.wait(50)
-
- # Select some text and delete
- cursor = editor.textCursor()
- cursor.setPosition(0)
- cursor.setPosition(4, QTextCursor.KeepAnchor)
- editor.setTextCursor(cursor)
-
- cursor.removeSelectedText()
- qtbot.wait(50)
-
- result = editor.to_markdown()
- assert "Text" not in result or len(result) < 15
-
-
-def test_markdown_highlighter_dark_theme(qtbot, app):
- """Test markdown highlighter with dark theme"""
- # Create theme manager with dark theme
- themes = ThemeManager(app, ThemeConfig(theme=Theme.DARK))
-
- # Create a text document
- doc = QTextDocument()
-
- # Create highlighter with dark theme
- highlighter = MarkdownHighlighter(doc, themes)
-
- # Set some markdown text
- doc.setPlainText("# Heading\n\nSome **bold** text\n\n```python\ncode\n```")
-
- # The highlighter should work with dark theme
- assert highlighter is not None
- assert highlighter.code_block_format is not None
-
-
-def test_markdown_highlighter_light_theme(qtbot, app):
- """Test markdown highlighter with light theme"""
- # Create theme manager with light theme
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
-
- # Create a text document
- doc = QTextDocument()
-
- # Create highlighter with light theme
- highlighter = MarkdownHighlighter(doc, themes)
-
- # Set some markdown text
- doc.setPlainText("# Heading\n\nSome **bold** text")
-
- # The highlighter should work with light theme
- assert highlighter is not None
- assert highlighter.code_block_format is not None
-
-
-def test_markdown_highlighter_system_dark_theme(qtbot, app, monkeypatch):
- """Test markdown highlighter with system dark theme"""
- # Create theme manager with system theme
- themes = ThemeManager(app, ThemeConfig(theme=Theme.SYSTEM))
-
- # Mock the system to be dark
- monkeypatch.setattr(themes, "_is_system_dark", True)
-
- # Create a text document
- doc = QTextDocument()
-
- # Create highlighter
- highlighter = MarkdownHighlighter(doc, themes)
-
- # Set some markdown text
- doc.setPlainText("# Dark Theme Heading\n\n**Bold text**")
-
- # The highlighter should use dark theme colors
- assert highlighter is not None
-
-
-def test_markdown_highlighter_with_headings(qtbot, app):
- """Test highlighting various heading levels"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- doc = QTextDocument()
- highlighter = MarkdownHighlighter(doc, themes)
-
- markdown = """
-# H1 Heading
-## H2 Heading
-### H3 Heading
-#### H4 Heading
-##### H5 Heading
-###### H6 Heading
-"""
- doc.setPlainText(markdown)
-
- # Should highlight all headings
- assert highlighter.h1_format is not None
- assert highlighter.h2_format is not None
-
-
-def test_markdown_highlighter_with_emphasis(qtbot, app):
- """Test highlighting bold and italic"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- doc = QTextDocument()
- highlighter = MarkdownHighlighter(doc, themes)
-
- markdown = """
-**Bold text**
-*Italic text*
-***Bold and italic***
-__Also bold__
-_Also italic_
-"""
- doc.setPlainText(markdown)
-
- # Should have emphasis formats
- assert highlighter is not None
-
-
-def test_markdown_highlighter_with_code(qtbot, app):
- """Test highlighting inline code and code blocks"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- doc = QTextDocument()
- highlighter = MarkdownHighlighter(doc, themes)
-
- markdown = """
-Inline `code` here.
-
-```python
-def hello():
- print("world")
-```
-
-More text.
-"""
- doc.setPlainText(markdown)
-
- # Should highlight code
- assert highlighter.code_block_format is not None
-
-
-def test_markdown_highlighter_with_links(qtbot, app):
- """Test highlighting links"""
- themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
- doc = QTextDocument()
- highlighter = MarkdownHighlighter(doc, themes)
-
- markdown = """
-[Link text](https://example.com)
-