From 985541a1d8d10bf738fbf550a73aae8281845bb3 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Wed, 19 Nov 2025 15:33:31 +1100 Subject: [PATCH] remove time graph visualiser. More tests. Other fixes --- CHANGELOG.md | 1 - README.md | 21 +- bouquin/db.py | 45 +- bouquin/locales/en.json | 1 - bouquin/tag_browser.py | 11 - bouquin/tag_graph_dialog.py | 309 -- bouquin/time_log.py | 2 +- poetry.lock | 398 ++- pyproject.toml | 4 +- .../bouquin_tag_relationship_graph.png | Bin 30181 -> 0 bytes tests/test_bug_report_dialog.py | 129 + tests/test_db.py | 335 +++ tests/test_markdown_editor.py | 195 ++ tests/test_statistics_dialog.py | 319 +- tests/test_tag_graph_dialog.py | 365 --- tests/test_tags.py | 361 ++- tests/test_time_log.py | 2558 +++++++++++++++++ vulture_ignorelist.py | 4 - 18 files changed, 4087 insertions(+), 971 deletions(-) delete mode 100644 bouquin/tag_graph_dialog.py delete mode 100644 screenshots/bouquin_tag_relationship_graph.png delete mode 100644 tests/test_tag_graph_dialog.py create mode 100644 tests/test_time_log.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 45235b3..cbea884 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,6 @@ * Remove screenshot tool * Improve width of bug report dialog - * Add Tag relationship visualisation tool * Improve size of checkboxes * Convert bullet - to actual unicode bullets * Add alarm option to set reminders diff --git a/README.md b/README.md index 447ddda..9e3e466 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,11 @@ ## Introduction -Bouquin ("Book-ahn") is a simple, opinionated notebook application written in Python, PyQt and SQLCipher. +Bouquin ("Book-ahn") is a notebook and planner application written in Python, PyQt and SQLCipher. + +It is designed to treat each day as its own 'page', complete with Markdown rendering, tagging, +search, reminders and time logging for those of us who need to keep track of not just TODOs, but +also how long we spent on them. It uses [SQLCipher bindings](https://pypi.org/project/sqlcipher3-wheels) as a drop-in replacement for SQLite3. This means that the underlying database for the notebook is encrypted at rest. @@ -23,27 +27,22 @@ report from within the app. ![Screenshot of Bouquin History Preview pane](https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/history_preview.png) ![Screenshot of Bouquin History Diff pane](https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/history_diff.png) -### Tag relationship visualiser -![Screenshot of Tag Relationship Visualiser](https://git.mig5.net/mig5/bouquin/raw/branch/main/screenshots/bouquin_tag_relationship_graph.png) - -## Features +## Some of the features * Data is encrypted at rest * Encryption key is prompted for and never stored, unless user chooses to via Settings - * Every 'page' is linked to the calendar day * All changes are version controlled, with ability to view/diff versions and revert - * Text is Markdown with basic styling + * Automatic rendering of basic Markdown syntax * Tabs are supported - right-click on a date from the calendar to open it in a new tab. * Images are supported - * Search all pages, or find text on page (Ctrl+F) - * Add tags to pages, find pages by tag in the Tag Browser, and customise tag names and colours + * Search all pages, or find text on current page + * Add and manage tags * Automatic periodic saving (or explicitly save) - * Transparent integrity checking of the database when it opens * Automatic locking of the app after a period of inactivity (default 15 min) * Rekey the database (change the password) * Export the database to json, html, csv, markdown or .sql (for sqlite3) * Backup the database to encrypted SQLCipher format (which can then be loaded back in to a Bouquin) - * Dark and light themes + * Dark and light theme support * Automatically generate checkboxes when typing 'TODO' * It is possible to automatically move unchecked checkboxes from yesterday to today, on startup * English, French and Italian locales provided diff --git a/bouquin/db.py b/bouquin/db.py index fe51d31..10492d5 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -790,49 +790,6 @@ class DBManager: revisions_by_date, ) - def get_tag_cooccurrences(self): - """ - Compute tag–tag co-occurrence across pages. - - Returns: - tags_by_id: dict[int, TagRow] # id -> (id, name, color) - edges: list[(int, int, int)] # (tag_id1, tag_id2, page_count) - tag_page_counts: dict[int, int] # tag_id -> number of pages it appears on - """ - cur = self.conn.cursor() - - # 1) All tags (reuse existing helper) - all_tags: list[TagRow] = self.list_tags() - tags_by_id: dict[int, TagRow] = {t[0]: t for t in all_tags} - - # 2) How many pages each tag appears on (for node sizing) - rows = cur.execute( - """ - SELECT tag_id, COUNT(DISTINCT page_date) AS c - FROM page_tags - GROUP BY tag_id; - """ - ).fetchall() - tag_page_counts = {r["tag_id"]: r["c"] for r in rows} - - # 3) Co-occurrence of tag pairs on the same page - rows = cur.execute( - """ - SELECT - pt1.tag_id AS tag1, - pt2.tag_id AS tag2, - COUNT(DISTINCT pt1.page_date) AS c - FROM page_tags AS pt1 - JOIN page_tags AS pt2 - ON pt1.page_date = pt2.page_date - AND pt1.tag_id < pt2.tag_id - GROUP BY pt1.tag_id, pt2.tag_id; - """, - ).fetchall() - - edges = [(r["tag1"], r["tag2"], r["c"]) for r in rows] - return tags_by_id, edges, tag_page_counts - # -------- Time logging: projects & activities --------------------- def list_projects(self) -> list[ProjectRow]: @@ -1037,7 +994,7 @@ class DBManager: AND t.page_date BETWEEN ? AND ? GROUP BY bucket, activity_name ORDER BY bucket, LOWER(activity_name); - """, # nosec B608: bucket_expr comes from a fixed internal list + """, # nosec (project_id, start_date_iso, end_date_iso), ).fetchall() diff --git a/bouquin/locales/en.json b/bouquin/locales/en.json index c67b5c3..0cb34b3 100644 --- a/bouquin/locales/en.json +++ b/bouquin/locales/en.json @@ -135,7 +135,6 @@ "delete_tag": "Delete tag", "delete_tag_confirm": "Are you sure you want to delete the tag '{name}'? This will remove it from all pages.", "tag_already_exists_with_that_name": "A tag already exists with that name", - "tag_graph": "Tag relationship graph", "statistics": "Statistics", "main_window_statistics_accessible_flag": "Stat&istics", "stats_pages_with_content": "Pages with content (current version)", diff --git a/bouquin/tag_browser.py b/bouquin/tag_browser.py index c3163b6..83c17c0 100644 --- a/bouquin/tag_browser.py +++ b/bouquin/tag_browser.py @@ -14,7 +14,6 @@ from PySide6.QtWidgets import ( ) from .db import DBManager -from .tag_graph_dialog import TagGraphDialog from . import strings from sqlcipher3.dbapi2 import IntegrityError @@ -72,10 +71,6 @@ class TagBrowserDialog(QDialog): self.delete_btn.setEnabled(False) btn_row.addWidget(self.delete_btn) - self.tag_graph_btn = QPushButton(strings._("tag_graph")) - self.tag_graph_btn.clicked.connect(self._open_tag_graph) - btn_row.addWidget(self.tag_graph_btn) - btn_row.addStretch(1) layout.addLayout(btn_row) @@ -256,9 +251,3 @@ class TagBrowserDialog(QDialog): self._db.delete_tag(tag_id) self._populate(None) self.tagsModified.emit() - - # ------------ Tag graph handler --------------- # - def _open_tag_graph(self): - dlg = TagGraphDialog(self._db, self) - dlg.resize(800, 600) - dlg.exec() diff --git a/bouquin/tag_graph_dialog.py b/bouquin/tag_graph_dialog.py deleted file mode 100644 index a4065cb..0000000 --- a/bouquin/tag_graph_dialog.py +++ /dev/null @@ -1,309 +0,0 @@ -import networkx as nx -import numpy as np -import pyqtgraph as pg -from pyqtgraph.Qt import QtCore - -from PySide6.QtWidgets import QDialog, QVBoxLayout, QToolTip -from PySide6.QtGui import QFont, QCursor, QColor - -from .db import DBManager -from . import strings - - -class DraggableGraphItem(pg.GraphItem): - """GraphItem where individual nodes can be dragged with the left mouse button, - and hover events can be reported back to the owning dialog. - """ - - def __init__(self, on_position_changed=None, on_hover=None, **kwds): - # Our own fields MUST be set before super().__init__ because - # GraphItem.__init__ will call self.setData(...) - self._drag_index = None - self._drag_offset = None - self._on_position_changed = on_position_changed - self._on_hover = on_hover - self.pos = None - self._data_kwargs = {} # cache of last setData kwargs - - super().__init__(**kwds) - self.setAcceptHoverEvents(True) - - def setData(self, **kwds): - """Cache kwargs so we don't lose size/adj/brush on drag.""" - if "pos" in kwds: - self.pos = kwds["pos"] - self._data_kwargs.update(kwds) - super().setData(**self._data_kwargs) - - def mouseDragEvent(self, ev): - # --- start of drag --- - if ev.isStart(): - if ev.button() != QtCore.Qt.MouseButton.LeftButton: - ev.ignore() - return - - pos = ev.buttonDownPos() - pts = self.scatter.pointsAt(pos) - - # pointsAt may return an empty list/array - if pts is None or len(pts) == 0: - ev.ignore() - return - - spot = pts[0] - self._drag_index = spot.index() - - node_pos = np.array(self.pos[self._drag_index], dtype=float) - - if hasattr(pos, "x"): - mouse = np.array([pos.x(), pos.y()], dtype=float) - else: - mouse = np.array(pos, dtype=float) - - self._drag_offset = node_pos - mouse - ev.accept() - return - - # --- end of drag --- - if ev.isFinish(): - self._drag_index = None - self._drag_offset = None - ev.accept() - return - - # --- drag in progress --- - if self._drag_index is None: - ev.ignore() - return - - pos = ev.pos() - if hasattr(pos, "x"): - mouse = np.array([pos.x(), pos.y()], dtype=float) - else: - mouse = np.array(pos, dtype=float) - - new_pos = mouse + self._drag_offset - self.pos[self._drag_index] = new_pos # mutate in-place - - # Repaint graph, preserving all the other kwargs (size, adj, colours, ...) - self.setData(pos=self.pos) - - if self._on_position_changed is not None: - self._on_position_changed(self.pos) - - ev.accept() - - def hoverEvent(self, ev): - """Report which node (if any) is under the mouse while hovering.""" - # Leaving the item entirely - if ev.isExit(): - if self._on_hover is not None: - self._on_hover(None, ev) - return - - pos = ev.pos() - pts = self.scatter.pointsAt(pos) - - if pts is None or len(pts) == 0: - if self._on_hover is not None: - self._on_hover(None, ev) - return - - idx = pts[0].index() - if self._on_hover is not None: - self._on_hover(idx, ev) - - -class TagGraphDialog(QDialog): - def __init__(self, db: DBManager, parent=None): - super().__init__(parent) - self.setWindowTitle(strings._("tag_graph")) - - layout = QVBoxLayout(self) - self.view = pg.GraphicsLayoutWidget() - layout.addWidget(self.view) - - self.plot = self.view.addPlot() - self.plot.hideAxis("bottom") - self.plot.hideAxis("left") - - # Dark-ish background, Grafana / neon style - self.view.setBackground("#050816") - self.plot.setMouseEnabled(x=True, y=True) - self.plot.getViewBox().setDefaultPadding(0.15) - - # State for tags / edges / labels / halo - self._label_items = [] - self._tag_ids = [] - self._tag_names = {} - self._tag_page_counts = {} - - self._halo_sizes = [] - self._halo_brushes = [] - - self.graph_item = DraggableGraphItem( - on_position_changed=self._on_positions_changed, - on_hover=self._on_hover_index, - ) - self.plot.addItem(self.graph_item) - - # Separate scatter for "halo" glow behind nodes - self._halo_item = pg.ScatterPlotItem(pxMode=True) - self._halo_item.setZValue(-1) # draw behind nodes/labels - self.plot.addItem(self._halo_item) - - self._populate_graph(db) - - def _populate_graph(self, db: DBManager): - tags_by_id, edges, tag_page_counts = db.get_tag_cooccurrences() - - if not tags_by_id: - return - - # Map tag_id -> index - tag_ids = list(tags_by_id.keys()) - self._tag_ids = tag_ids - self._tag_page_counts = dict(tag_page_counts) - self._tag_names = {tid: tags_by_id[tid][1] for tid in tag_ids} - - idx_of = {tid: i for i, tid in enumerate(tag_ids)} - N = len(tag_ids) - - # ---- Layout: prefer a weighted spring layout via networkx (topic islands) - if edges: - G = nx.Graph() - for tid in tag_ids: - G.add_node(tid) - for t1, t2, w in edges: - G.add_edge(t1, t2, weight=w) - - pos_dict = nx.spring_layout(G, weight="weight", k=1.2, iterations=80) - pos = np.array([pos_dict[tid] for tid in tag_ids], dtype=float) - else: - # Fallback: random-ish blob - pos = np.random.normal(size=(N, 2)) - - # Adjacency (edges) - adj = np.array([[idx_of[t1], idx_of[t2]] for t1, t2, _ in edges], dtype=int) - - # Node sizes: proportional to how often tag is used - max_pages = max(tag_page_counts.values() or [1]) - sizes = np.array( - [10 + 20 * (tag_page_counts.get(tid, 0) / max_pages) for tid in tag_ids], - dtype=float, - ) - - # ---- Neon-style nodes ---- - # Inner fill: dark; outline: tag hex colour - node_brushes = [] - node_pens = [] - - dark_fill = (5, 8, 22, 230) # almost background, slightly lighter - - # For halo - halo_sizes = [] - halo_brushes = [] - - for i, tid in enumerate(tag_ids): - _id, name, color = tags_by_id[tid] - - # node interior (dark) + bright outline - node_brushes.append(pg.mkBrush(dark_fill)) - node_pens.append(pg.mkPen(color, width=2.5)) - - # halo: semi-transparent version of DB colour, larger than node - qcol = QColor(color) - qcol.setAlpha(90) - halo_brushes.append(pg.mkBrush(qcol)) - halo_sizes.append(sizes[i] * 1.8) - - self._halo_sizes = halo_sizes - self._halo_brushes = halo_brushes - - # ---- Edges: softer neon-ish lines with opacity / width based on co-occurrence ---- - if edges: - weights = np.array([w for _, _, w in edges], dtype=float) - max_w = weights.max() if weights.size else 1.0 - weight_factors = (weights / max_w).clip(0.0, 1.0) - - # bright cyan-ish neon - base_color = (56, 189, 248) # tailwind-ish cyan-400 - edge_pens = [] - - for wf in weight_factors: - alpha = int(40 + 160 * wf) # 40–200 - width = 0.7 + 2.3 * wf # 0.7–3.0 - edge_pens.append(pg.mkPen((*base_color, alpha), width=width)) - else: - edge_pens = None - - # Assign data to GraphItem (this will set self.graph_item.pos) - self.graph_item.setData( - pos=pos, - adj=adj, - size=sizes, - symbolBrush=node_brushes, - symbolPen=node_pens, - edgePen=edge_pens, - pxMode=True, - ) - - # ---- Neon halo layer (behind nodes) ---- - xs = [p[0] for p in pos] - ys = [p[1] for p in pos] - self._halo_item.setData( - x=xs, - y=ys, - size=self._halo_sizes, - brush=self._halo_brushes, - pen=None, - ) - - # ---- Add text labels for each tag ---- - self._label_items = [] # reset - font = QFont() - font.setPointSize(8) - - for i, tid in enumerate(tag_ids): - _id, name, color = tags_by_id[tid] - label = pg.TextItem(text=name, color=color, anchor=(0.5, 0.5)) - label.setFont(font) - self.plot.addItem(label) - self._label_items.append(label) - - # Initial placement of labels - self._on_positions_changed(pos) - - def _on_positions_changed(self, pos): - """Called by DraggableGraphItem whenever node positions change.""" - if not self._label_items: - return - - # Update labels - for i, label in enumerate(self._label_items): - label.setPos(float(pos[i, 0]), float(pos[i, 1]) + 0.15) - - # Update halo positions to match nodes - if self._halo_sizes and self._halo_brushes: - xs = [p[0] for p in pos] - ys = [p[1] for p in pos] - self._halo_item.setData( - x=xs, - y=ys, - size=self._halo_sizes, - brush=self._halo_brushes, - pen=None, - ) - - def _on_hover_index(self, index, ev): - """Show ': N pages' when hovering a node.""" - if index is None or not self._tag_ids: - QToolTip.hideText() - return - - tag_id = self._tag_ids[index] - name = self._tag_names.get(tag_id, "") - count = self._tag_page_counts.get(tag_id, 0) - text = f"{name}: {count} page{'s' if count != 1 else ''}" - - QToolTip.showText(QCursor.pos(), text, self) diff --git a/bouquin/time_log.py b/bouquin/time_log.py index 786c14c..997fa2c 100644 --- a/bouquin/time_log.py +++ b/bouquin/time_log.py @@ -161,7 +161,7 @@ class TimeLogWidget(QFrame): # Always refresh summary + header totals self._reload_summary() - if self.toggle_btn.isChecked(): + if not self.toggle_btn.isChecked(): self.summary_label.setText(strings._("time_log_collapsed_hint")) diff --git a/poetry.lock b/poetry.lock index 4fa328d..ac46700 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2,13 +2,13 @@ [[package]] name = "certifi" -version = "2025.10.5" +version = "2025.11.12" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" files = [ - {file = "certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de"}, - {file = "certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43"}, + {file = "certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b"}, + {file = "certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"}, ] [[package]] @@ -146,133 +146,141 @@ files = [ [[package]] name = "coverage" -version = "7.10.7" +version = "7.12.0" description = "Code coverage measurement for Python" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" files = [ - {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"}, + {file = "coverage-7.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:32b75c2ba3f324ee37af3ccee5b30458038c50b349ad9b88cee85096132a575b"}, + {file = "coverage-7.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cb2a1b6ab9fe833714a483a915de350abc624a37149649297624c8d57add089c"}, + {file = "coverage-7.12.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5734b5d913c3755e72f70bf6cc37a0518d4f4745cde760c5d8e12005e62f9832"}, + {file = "coverage-7.12.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b527a08cdf15753279b7afb2339a12073620b761d79b81cbe2cdebdb43d90daa"}, + {file = "coverage-7.12.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bb44c889fb68004e94cab71f6a021ec83eac9aeabdbb5a5a88821ec46e1da73"}, + {file = "coverage-7.12.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b59b501455535e2e5dde5881739897967b272ba25988c89145c12d772810ccb"}, + {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d8842f17095b9868a05837b7b1b73495293091bed870e099521ada176aa3e00e"}, + {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5a6f20bf48b8866095c6820641e7ffbe23f2ac84a2efc218d91235e404c7777"}, + {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5f3738279524e988d9da2893f307c2093815c623f8d05a8f79e3eff3a7a9e553"}, + {file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0d68c1f7eabbc8abe582d11fa393ea483caf4f44b0af86881174769f185c94d"}, + {file = "coverage-7.12.0-cp310-cp310-win32.whl", hash = "sha256:7670d860e18b1e3ee5930b17a7d55ae6287ec6e55d9799982aa103a2cc1fa2ef"}, + {file = "coverage-7.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:f999813dddeb2a56aab5841e687b68169da0d3f6fc78ccf50952fa2463746022"}, + {file = "coverage-7.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa124a3683d2af98bd9d9c2bfa7a5076ca7e5ab09fdb96b81fa7d89376ae928f"}, + {file = "coverage-7.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d93fbf446c31c0140208dcd07c5d882029832e8ed7891a39d6d44bd65f2316c3"}, + {file = "coverage-7.12.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:52ca620260bd8cd6027317bdd8b8ba929be1d741764ee765b42c4d79a408601e"}, + {file = "coverage-7.12.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f3433ffd541380f3a0e423cff0f4926d55b0cc8c1d160fdc3be24a4c03aa65f7"}, + {file = "coverage-7.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7bbb321d4adc9f65e402c677cd1c8e4c2d0105d3ce285b51b4d87f1d5db5245"}, + {file = "coverage-7.12.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22a7aade354a72dff3b59c577bfd18d6945c61f97393bc5fb7bd293a4237024b"}, + {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ff651dcd36d2fea66877cd4a82de478004c59b849945446acb5baf9379a1b64"}, + {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:31b8b2e38391a56e3cea39d22a23faaa7c3fc911751756ef6d2621d2a9daf742"}, + {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:297bc2da28440f5ae51c845a47c8175a4db0553a53827886e4fb25c66633000c"}, + {file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ff7651cc01a246908eac162a6a86fc0dbab6de1ad165dfb9a1e2ec660b44984"}, + {file = "coverage-7.12.0-cp311-cp311-win32.whl", hash = "sha256:313672140638b6ddb2c6455ddeda41c6a0b208298034544cfca138978c6baed6"}, + {file = "coverage-7.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1783ed5bd0d5938d4435014626568dc7f93e3cb99bc59188cc18857c47aa3c4"}, + {file = "coverage-7.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:4648158fd8dd9381b5847622df1c90ff314efbfc1df4550092ab6013c238a5fc"}, + {file = "coverage-7.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:29644c928772c78512b48e14156b81255000dcfd4817574ff69def189bcb3647"}, + {file = "coverage-7.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8638cbb002eaa5d7c8d04da667813ce1067080b9a91099801a0053086e52b736"}, + {file = "coverage-7.12.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:083631eeff5eb9992c923e14b810a179798bb598e6a0dd60586819fc23be6e60"}, + {file = "coverage-7.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:99d5415c73ca12d558e07776bd957c4222c687b9f1d26fa0e1b57e3598bdcde8"}, + {file = "coverage-7.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e949ebf60c717c3df63adb4a1a366c096c8d7fd8472608cd09359e1bd48ef59f"}, + {file = "coverage-7.12.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d907ddccbca819afa2cd014bc69983b146cca2735a0b1e6259b2a6c10be1e70"}, + {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b1518ecbad4e6173f4c6e6c4a46e49555ea5679bf3feda5edb1b935c7c44e8a0"}, + {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51777647a749abdf6f6fd8c7cffab12de68ab93aab15efc72fbbb83036c2a068"}, + {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:42435d46d6461a3b305cdfcad7cdd3248787771f53fe18305548cba474e6523b"}, + {file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5bcead88c8423e1855e64b8057d0544e33e4080b95b240c2a355334bb7ced937"}, + {file = "coverage-7.12.0-cp312-cp312-win32.whl", hash = "sha256:dcbb630ab034e86d2a0f79aefd2be07e583202f41e037602d438c80044957baa"}, + {file = "coverage-7.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:2fd8354ed5d69775ac42986a691fbf68b4084278710cee9d7c3eaa0c28fa982a"}, + {file = "coverage-7.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:737c3814903be30695b2de20d22bcc5428fdae305c61ba44cdc8b3252984c49c"}, + {file = "coverage-7.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47324fffca8d8eae7e185b5bb20c14645f23350f870c1649003618ea91a78941"}, + {file = "coverage-7.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ccf3b2ede91decd2fb53ec73c1f949c3e034129d1e0b07798ff1d02ea0c8fa4a"}, + {file = "coverage-7.12.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b365adc70a6936c6b0582dc38746b33b2454148c02349345412c6e743efb646d"}, + {file = "coverage-7.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc13baf85cd8a4cfcf4a35c7bc9d795837ad809775f782f697bf630b7e200211"}, + {file = "coverage-7.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:099d11698385d572ceafb3288a5b80fe1fc58bf665b3f9d362389de488361d3d"}, + {file = "coverage-7.12.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:473dc45d69694069adb7680c405fb1e81f60b2aff42c81e2f2c3feaf544d878c"}, + {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:583f9adbefd278e9de33c33d6846aa8f5d164fa49b47144180a0e037f0688bb9"}, + {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b2089cc445f2dc0af6f801f0d1355c025b76c24481935303cf1af28f636688f0"}, + {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:950411f1eb5d579999c5f66c62a40961f126fc71e5e14419f004471957b51508"}, + {file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b1aab7302a87bafebfe76b12af681b56ff446dc6f32ed178ff9c092ca776e6bc"}, + {file = "coverage-7.12.0-cp313-cp313-win32.whl", hash = "sha256:d7e0d0303c13b54db495eb636bc2465b2fb8475d4c8bcec8fe4b5ca454dfbae8"}, + {file = "coverage-7.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:ce61969812d6a98a981d147d9ac583a36ac7db7766f2e64a9d4d059c2fe29d07"}, + {file = "coverage-7.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bcec6f47e4cb8a4c2dc91ce507f6eefc6a1b10f58df32cdc61dff65455031dfc"}, + {file = "coverage-7.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:459443346509476170d553035e4a3eed7b860f4fe5242f02de1010501956ce87"}, + {file = "coverage-7.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:04a79245ab2b7a61688958f7a855275997134bc84f4a03bc240cf64ff132abf6"}, + {file = "coverage-7.12.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09a86acaaa8455f13d6a99221d9654df249b33937b4e212b4e5a822065f12aa7"}, + {file = "coverage-7.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:907e0df1b71ba77463687a74149c6122c3f6aac56c2510a5d906b2f368208560"}, + {file = "coverage-7.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b57e2d0ddd5f0582bae5437c04ee71c46cd908e7bc5d4d0391f9a41e812dd12"}, + {file = "coverage-7.12.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58c1c6aa677f3a1411fe6fb28ec3a942e4f665df036a3608816e0847fad23296"}, + {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4c589361263ab2953e3c4cd2a94db94c4ad4a8e572776ecfbad2389c626e4507"}, + {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:91b810a163ccad2e43b1faa11d70d3cf4b6f3d83f9fd5f2df82a32d47b648e0d"}, + {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:40c867af715f22592e0d0fb533a33a71ec9e0f73a6945f722a0c85c8c1cbe3a2"}, + {file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:68b0d0a2d84f333de875666259dadf28cc67858bc8fd8b3f1eae84d3c2bec455"}, + {file = "coverage-7.12.0-cp313-cp313t-win32.whl", hash = "sha256:73f9e7fbd51a221818fd11b7090eaa835a353ddd59c236c57b2199486b116c6d"}, + {file = "coverage-7.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:24cff9d1f5743f67db7ba46ff284018a6e9aeb649b67aa1e70c396aa1b7cb23c"}, + {file = "coverage-7.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c87395744f5c77c866d0f5a43d97cc39e17c7f1cb0115e54a2fe67ca75c5d14d"}, + {file = "coverage-7.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a1c59b7dc169809a88b21a936eccf71c3895a78f5592051b1af8f4d59c2b4f92"}, + {file = "coverage-7.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8787b0f982e020adb732b9f051f3e49dd5054cebbc3f3432061278512a2b1360"}, + {file = "coverage-7.12.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ea5a9f7dc8877455b13dd1effd3202e0bca72f6f3ab09f9036b1bcf728f69ac"}, + {file = "coverage-7.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fdba9f15849534594f60b47c9a30bc70409b54947319a7c4fd0e8e3d8d2f355d"}, + {file = "coverage-7.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a00594770eb715854fb1c57e0dea08cce6720cfbc531accdb9850d7c7770396c"}, + {file = "coverage-7.12.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5560c7e0d82b42eb1951e4f68f071f8017c824ebfd5a6ebe42c60ac16c6c2434"}, + {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2e26b481c9159c2773a37947a9718cfdc58893029cdfb177531793e375cfc"}, + {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6e1a8c066dabcde56d5d9fed6a66bc19a2883a3fe051f0c397a41fc42aedd4cc"}, + {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f7ba9da4726e446d8dd8aae5a6cd872511184a5d861de80a86ef970b5dacce3e"}, + {file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e0f483ab4f749039894abaf80c2f9e7ed77bbf3c737517fb88c8e8e305896a17"}, + {file = "coverage-7.12.0-cp314-cp314-win32.whl", hash = "sha256:76336c19a9ef4a94b2f8dc79f8ac2da3f193f625bb5d6f51a328cd19bfc19933"}, + {file = "coverage-7.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c1059b600aec6ef090721f8f633f60ed70afaffe8ecab85b59df748f24b31fe"}, + {file = "coverage-7.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:172cf3a34bfef42611963e2b661302a8931f44df31629e5b1050567d6b90287d"}, + {file = "coverage-7.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:aa7d48520a32cb21c7a9b31f81799e8eaec7239db36c3b670be0fa2403828d1d"}, + {file = "coverage-7.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:90d58ac63bc85e0fb919f14d09d6caa63f35a5512a2205284b7816cafd21bb03"}, + {file = "coverage-7.12.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca8ecfa283764fdda3eae1bdb6afe58bf78c2c3ec2b2edcb05a671f0bba7b3f9"}, + {file = "coverage-7.12.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:874fe69a0785d96bd066059cd4368022cebbec1a8958f224f0016979183916e6"}, + {file = "coverage-7.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3c889c0b8b283a24d721a9eabc8ccafcfc3aebf167e4cd0d0e23bf8ec4e339"}, + {file = "coverage-7.12.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bb5b894b3ec09dcd6d3743229dc7f2c42ef7787dc40596ae04c0edda487371e"}, + {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:79a44421cd5fba96aa57b5e3b5a4d3274c449d4c622e8f76882d76635501fd13"}, + {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:33baadc0efd5c7294f436a632566ccc1f72c867f82833eb59820ee37dc811c6f"}, + {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c406a71f544800ef7e9e0000af706b88465f3573ae8b8de37e5f96c59f689ad1"}, + {file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e71bba6a40883b00c6d571599b4627f50c360b3d0d02bfc658168936be74027b"}, + {file = "coverage-7.12.0-cp314-cp314t-win32.whl", hash = "sha256:9157a5e233c40ce6613dead4c131a006adfda70e557b6856b97aceed01b0e27a"}, + {file = "coverage-7.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e84da3a0fd233aeec797b981c51af1cabac74f9bd67be42458365b30d11b5291"}, + {file = "coverage-7.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:01d24af36fedda51c2b1aca56e4330a3710f83b02a5ff3743a6b015ffa7c9384"}, + {file = "coverage-7.12.0-py3-none-any.whl", hash = "sha256:159d50c0b12e060b15ed3d39f87ed43d4f7f7ad40b8a534f4dd331adbb51104a"}, + {file = "coverage-7.12.0.tar.gz", hash = "sha256:fc11e0a4e372cb5f282f16ef90d4a585034050ccda536451901abfb19a57f40c"}, ] +[package.dependencies] +tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} + [package.extras] toml = ["tomli"] [[package]] name = "desktop-entry-lib" -version = "3.2" +version = "5.0" description = "A library for working with .desktop files" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" files = [ - {file = "desktop-entry-lib-3.2.tar.gz", hash = "sha256:12189249f86dde52d055ac28897ad1ed14ef965407a50fb3ad4ac6cb1a7e8cde"}, - {file = "desktop_entry_lib-3.2-py3-none-any.whl", hash = "sha256:600748b2aab2cafbb3ebc08b5c1ded024d66e0756868b6d26b5aff44d336c4b5"}, + {file = "desktop_entry_lib-5.0-py3-none-any.whl", hash = "sha256:e60a0c2c5e42492dbe5378e596b1de87d1b1c4dc74d1f41998a164ee27a1226f"}, + {file = "desktop_entry_lib-5.0.tar.gz", hash = "sha256:9a621bac1819fe21021356e41fec0ac096ed56e6eb5dcfe0639cd8654914b864"}, ] [package.extras] -test = ["pyfakefs", "pytest", "pytest-cov"] +xdg-desktop-portal = ["jeepney"] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, + {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + +[package.extras] +test = ["pytest (>=6)"] [[package]] name = "idna" @@ -290,97 +298,13 @@ all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2 [[package]] name = "iniconfig" -version = "2.1.0" +version = "2.3.0" description = "brain-dead simple config-ini parsing" optional = false -python-versions = ">=3.8" -files = [ - {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, - {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, -] - -[[package]] -name = "networkx" -version = "3.5" -description = "Python package for creating and manipulating graphs and networks" -optional = false -python-versions = ">=3.11" -files = [ - {file = "networkx-3.5-py3-none-any.whl", hash = "sha256:0030d386a9a06dee3565298b4a734b68589749a544acbb6c412dc9e2489ec6ec"}, - {file = "networkx-3.5.tar.gz", hash = "sha256:d4c6f9cf81f52d69230866796b82afbccdec3db7ae4fbd1b65ea750feed50037"}, -] - -[package.extras] -default = ["matplotlib (>=3.8)", "numpy (>=1.25)", "pandas (>=2.0)", "scipy (>=1.11.2)"] -developer = ["mypy (>=1.15)", "pre-commit (>=4.1)"] -doc = ["intersphinx-registry", "myst-nb (>=1.1)", "numpydoc (>=1.8.0)", "pillow (>=10)", "pydata-sphinx-theme (>=0.16)", "sphinx (>=8.0)", "sphinx-gallery (>=0.18)", "texext (>=0.6.7)"] -example = ["cairocffi (>=1.7)", "contextily (>=1.6)", "igraph (>=0.11)", "momepy (>=0.7.2)", "osmnx (>=2.0.0)", "scikit-learn (>=1.5)", "seaborn (>=0.13)"] -extra = ["lxml (>=4.6)", "pydot (>=3.0.1)", "pygraphviz (>=1.14)", "sympy (>=1.10)"] -test = ["pytest (>=7.2)", "pytest-cov (>=4.0)", "pytest-xdist (>=3.0)"] -test-extras = ["pytest-mpl", "pytest-randomly"] - -[[package]] -name = "numpy" -version = "2.2.6" -description = "Fundamental package for array computing in Python" -optional = false python-versions = ">=3.10" files = [ - {file = "numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb"}, - {file = "numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90"}, - {file = "numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163"}, - {file = "numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf"}, - {file = "numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83"}, - {file = "numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915"}, - {file = "numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680"}, - {file = "numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289"}, - {file = "numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d"}, - {file = "numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3"}, - {file = "numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae"}, - {file = "numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a"}, - {file = "numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42"}, - {file = "numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491"}, - {file = "numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a"}, - {file = "numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf"}, - {file = "numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1"}, - {file = "numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab"}, - {file = "numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47"}, - {file = "numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303"}, - {file = "numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff"}, - {file = "numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c"}, - {file = "numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3"}, - {file = "numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282"}, - {file = "numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87"}, - {file = "numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249"}, - {file = "numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49"}, - {file = "numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de"}, - {file = "numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4"}, - {file = "numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2"}, - {file = "numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84"}, - {file = "numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b"}, - {file = "numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d"}, - {file = "numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566"}, - {file = "numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f"}, - {file = "numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f"}, - {file = "numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868"}, - {file = "numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d"}, - {file = "numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd"}, - {file = "numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c"}, - {file = "numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6"}, - {file = "numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda"}, - {file = "numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40"}, - {file = "numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8"}, - {file = "numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f"}, - {file = "numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa"}, - {file = "numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571"}, - {file = "numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1"}, - {file = "numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff"}, - {file = "numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06"}, - {file = "numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d"}, - {file = "numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db"}, - {file = "numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543"}, - {file = "numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00"}, - {file = "numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd"}, + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, ] [[package]] @@ -437,20 +361,7 @@ files = [ [package.dependencies] desktop-entry-lib = "*" requests = "*" - -[[package]] -name = "pyqtgraph" -version = "0.14.0" -description = "Scientific Graphics and GUI Library for Python" -optional = false -python-versions = ">=3.10" -files = [ - {file = "pyqtgraph-0.14.0-py3-none-any.whl", hash = "sha256:7abb7c3e17362add64f8711b474dffac5e7b0e9245abdf992e9a44119b7aa4f5"}, -] - -[package.dependencies] -colorama = "*" -numpy = ">=1.25.0" +tomli = {version = "*", markers = "python_version < \"3.11\""} [[package]] name = "pyside6" @@ -519,10 +430,12 @@ files = [ [package.dependencies] colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} iniconfig = ">=1" packaging = ">=20" pluggy = ">=1.5,<2" pygments = ">=2.7.2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] @@ -749,6 +662,57 @@ files = [ {file = "sqlcipher3_wheels-0.5.5.post0.tar.gz", hash = "sha256:2c291ba05fa3e57c9b4d407d2751aa69266b5372468e7402daaa312b251aca7f"}, ] +[[package]] +name = "tomli" +version = "2.3.0" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +files = [ + {file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"}, + {file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"}, + {file = "tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf"}, + {file = "tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441"}, + {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845"}, + {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c"}, + {file = "tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456"}, + {file = "tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be"}, + {file = "tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac"}, + {file = "tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22"}, + {file = "tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f"}, + {file = "tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52"}, + {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8"}, + {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6"}, + {file = "tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876"}, + {file = "tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878"}, + {file = "tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b"}, + {file = "tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae"}, + {file = "tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b"}, + {file = "tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf"}, + {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f"}, + {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05"}, + {file = "tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606"}, + {file = "tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999"}, + {file = "tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e"}, + {file = "tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3"}, + {file = "tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc"}, + {file = "tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0"}, + {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879"}, + {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005"}, + {file = "tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463"}, + {file = "tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8"}, + {file = "tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77"}, + {file = "tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf"}, + {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530"}, + {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b"}, + {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67"}, + {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f"}, + {file = "tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0"}, + {file = "tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba"}, + {file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"}, + {file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"}, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -779,5 +743,5 @@ zstd = ["zstandard (>=0.18.0)"] [metadata] lock-version = "2.0" -python-versions = ">=3.11,<3.14" -content-hash = "02e508f6d5fc3843084d48a8e495af69555ce6163e281d62b2d3242378e68274" +python-versions = ">=3.10,<3.14" +content-hash = "d5fd8ea759b6bd3f23336930bdce9241659256ed918ec31746787cc86e817235" diff --git a/pyproject.toml b/pyproject.toml index cb85a11..0057d21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,12 +10,10 @@ packages = [{ include = "bouquin" }] include = ["bouquin/locales/*.json"] [tool.poetry.dependencies] -python = ">=3.11,<3.14" +python = ">=3.10,<3.14" pyside6 = ">=6.8.1,<7.0.0" sqlcipher3-wheels = "^0.5.5.post0" requests = "^2.32.5" -pyqtgraph = "^0.14.0" -networkx = "^3.5" [tool.poetry.scripts] bouquin = "bouquin.__main__:main" diff --git a/screenshots/bouquin_tag_relationship_graph.png b/screenshots/bouquin_tag_relationship_graph.png deleted file mode 100644 index 083bf6afa9e72e178435a38e69f90e110fb8b7b6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 30181 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 diff --git a/tests/test_bug_report_dialog.py b/tests/test_bug_report_dialog.py index 7488f32..8d773e9 100644 --- a/tests/test_bug_report_dialog.py +++ b/tests/test_bug_report_dialog.py @@ -1,5 +1,8 @@ import bouquin.bug_report_dialog as bugmod from bouquin.bug_report_dialog import BugReportDialog +from bouquin import strings +from PySide6.QtWidgets import QMessageBox +from PySide6.QtGui import QTextCursor def test_bug_report_truncates_text_to_max_chars(qtbot): @@ -193,3 +196,129 @@ def test_bug_report_send_failure_non_201_shows_critical_and_not_accepted( # Dialog should NOT be accepted on failure assert accepted.get("called") is not True + + +def test_bug_report_dialog_text_limit_clamps_cursor(qtbot): + """Test that cursor position is clamped when text exceeds limit.""" + strings.load_strings("en") + dialog = BugReportDialog() + qtbot.addWidget(dialog) + dialog.show() + + # Set text that exceeds MAX_CHARS + max_chars = dialog.MAX_CHARS + long_text = "A" * (max_chars + 100) + + # Set text and move cursor to end + dialog.text_edit.setPlainText(long_text) + dialog.text_edit.moveCursor(QTextCursor.MoveOperation.End) + + # Text should be truncated + assert len(dialog.text_edit.toPlainText()) == max_chars + + # Cursor should be clamped to max position + final_cursor = dialog.text_edit.textCursor() + assert final_cursor.position() <= max_chars + + +def test_bug_report_dialog_empty_text_shows_warning(qtbot, monkeypatch): + """Test that sending empty report shows warning.""" + strings.load_strings("en") + dialog = BugReportDialog() + qtbot.addWidget(dialog) + dialog.show() + + # Clear any text + dialog.text_edit.clear() + + warning_shown = {"shown": False} + + def mock_warning(*args): + warning_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "warning", mock_warning) + + # Try to send empty report + dialog._send() + + assert warning_shown["shown"] + + +def test_bug_report_dialog_whitespace_only_shows_warning(qtbot, monkeypatch): + """Test that sending whitespace-only report shows warning.""" + strings.load_strings("en") + dialog = BugReportDialog() + qtbot.addWidget(dialog) + dialog.show() + + # Set whitespace only + dialog.text_edit.setPlainText(" \n\n \t\t ") + + warning_shown = {"shown": False} + + def mock_warning(*args): + warning_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "warning", mock_warning) + + dialog._send() + + assert warning_shown["shown"] + + +def test_bug_report_dialog_network_error(qtbot, monkeypatch): + """Test handling network error during send.""" + strings.load_strings("en") + dialog = BugReportDialog() + qtbot.addWidget(dialog) + dialog.show() + + dialog.text_edit.setPlainText("Test bug report") + + # Mock requests.post to raise exception + import requests + + def mock_post(*args, **kwargs): + raise requests.exceptions.ConnectionError("Network error") + + monkeypatch.setattr(requests, "post", mock_post) + + critical_shown = {"shown": False} + + def mock_critical(*args): + critical_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "critical", mock_critical) + + dialog._send() + + assert critical_shown["shown"] + + +def test_bug_report_dialog_timeout_error(qtbot, monkeypatch): + """Test handling timeout error during send.""" + strings.load_strings("en") + dialog = BugReportDialog() + qtbot.addWidget(dialog) + dialog.show() + + dialog.text_edit.setPlainText("Test bug report") + + # Mock requests.post to raise timeout + import requests + + def mock_post(*args, **kwargs): + raise requests.exceptions.Timeout("Request timed out") + + monkeypatch.setattr(requests, "post", mock_post) + + critical_shown = {"shown": False} + + def mock_critical(*args): + critical_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "critical", mock_critical) + + dialog._send() + + assert critical_shown["shown"] diff --git a/tests/test_db.py b/tests/test_db.py index dd2d55b..678374c 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -3,6 +3,7 @@ import json, csv import datetime as dt from sqlcipher3 import dbapi2 as sqlite from bouquin.db import DBManager +from datetime import date, timedelta def _today(): @@ -17,6 +18,10 @@ def _tomorrow(): return (dt.date.today() + dt.timedelta(days=1)).isoformat() +def _days_ago(n): + return (date.today() - timedelta(days=n)).isoformat() + + def _entry(text, i=0): return f"{text} line {i}\nsecond line\n\n- [x] done\n- [ ] todo" @@ -201,3 +206,333 @@ def test_integrity_check_raises_without_details(tmp_db_cfg): db.conn = _Conn([(None,), (None,)]) with pytest.raises(sqlite.IntegrityError): db._integrity_ok() + + +# ============================================================================ +# DB _strip_markdown and _count_words Tests +# ============================================================================ + + +def test_db_strip_markdown_empty_text(fresh_db): + """Test strip_markdown with empty text.""" + result = fresh_db._strip_markdown("") + assert result == "" + + +def test_db_strip_markdown_none_text(fresh_db): + """Test strip_markdown with None.""" + result = fresh_db._strip_markdown(None) + assert result == "" + + +def test_db_strip_markdown_fenced_code_blocks(fresh_db): + """Test stripping fenced code blocks.""" + text = """ +Some text here +```python +def hello(): + print("world") +``` +More text after +""" + result = fresh_db._strip_markdown(text) + assert "def hello" not in result + assert "Some text" in result + assert "More text" in result + + +def test_db_strip_markdown_inline_code(fresh_db): + """Test stripping inline code.""" + text = "Here is some `inline code` in text" + result = fresh_db._strip_markdown(text) + assert "`" not in result + assert "inline code" not in result + assert "Here is some" in result + assert "in text" in result + + +def test_db_strip_markdown_links(fresh_db): + """Test converting markdown links to plain text.""" + text = "Check out [this link](https://example.com) for more info" + result = fresh_db._strip_markdown(text) + assert "this link" in result + assert "https://example.com" not in result + assert "[" not in result + assert "]" not in result + + +def test_db_strip_markdown_emphasis_and_headers(fresh_db): + """Test stripping emphasis markers and headers.""" + text = """ +# Header 1 +## Header 2 +**bold text** and *italic text* +> blockquote +_underline_ +""" + result = fresh_db._strip_markdown(text) + assert "#" not in result + assert "*" not in result + assert "_" not in result + assert ">" not in result + assert "bold text" in result + assert "italic text" in result + + +def test_db_strip_markdown_html_tags(fresh_db): + """Test stripping HTML tags.""" + text = "Some bold and italic text with
divs
" + result = fresh_db._strip_markdown(text) + # The regex replaces tags with spaces, may leave some angle brackets from malformed HTML + # The important thing is that the words are preserved + assert "bold" in result + assert "italic" in result + assert "divs" in result + + +def test_db_strip_markdown_complex_document(fresh_db): + """Test stripping complex markdown document.""" + text = """ +# My Document + +This is a paragraph with **bold** and *italic* text. + +```javascript +const x = 10; +console.log(x); +``` + +Here's a [link](https://example.com) and some `code`. + +> A blockquote + +

HTML 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) == 10 + ( + 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, + ) = 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, + ) = 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, _, _, _, _ = 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, _ = 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 = 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 = 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, _, _, _, _ = 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, _ = 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 diff --git a/tests/test_markdown_editor.py b/tests/test_markdown_editor.py index e8b0a44..197c1ab 100644 --- a/tests/test_markdown_editor.py +++ b/tests/test_markdown_editor.py @@ -17,6 +17,12 @@ from bouquin.markdown_highlighter import MarkdownHighlighter from bouquin.theme import ThemeManager, ThemeConfig, Theme +def _today(): + from datetime import date + + return date.today().isoformat() + + def text(editor) -> str: return editor.toPlainText() @@ -1464,3 +1470,192 @@ def test_markdown_highlighter_switch_dark_mode(app): 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 diff --git a/tests/test_statistics_dialog.py b/tests/test_statistics_dialog.py index 9334ee0..7359250 100644 --- a/tests/test_statistics_dialog.py +++ b/tests/test_statistics_dialog.py @@ -1,10 +1,14 @@ import datetime as _dt -from PySide6.QtWidgets import QLabel - -from bouquin.statistics_dialog import StatisticsDialog from bouquin import strings +from datetime import date +from PySide6.QtCore import Qt, QPoint +from PySide6.QtWidgets import QLabel +from PySide6.QtTest import QTest + +from bouquin.statistics_dialog import DateHeatmap, StatisticsDialog + class FakeStatsDB: """Minimal stub that returns a fixed stats payload.""" @@ -104,3 +108,312 @@ def test_statistics_dialog_no_data_shows_placeholder(qtbot): # When there's no data, the heatmap and metric combo shouldn't exist assert not hasattr(dlg, "metric_combo") assert not hasattr(dlg, "_heatmap") + + +def _date(year, month, day): + return date(year, month, day) + + +# ============================================================================ +# DateHeatmapTests - Missing Coverage +# ============================================================================ + + +def test_activity_heatmap_empty_data(qtbot): + """Test heatmap with empty data dict.""" + strings.load_strings("en") + heatmap = DateHeatmap() + qtbot.addWidget(heatmap) + heatmap.show() + + # Set empty data + heatmap.set_data({}) + + # Should handle empty data gracefully + assert heatmap._start is None + assert heatmap._end is None + assert heatmap._max_value == 0 + + # Size hint should return default dimensions + size = heatmap.sizeHint() + assert size.width() > 0 + assert size.height() > 0 + + # Paint should not crash + heatmap.update() + qtbot.wait(10) + + +def test_activity_heatmap_none_data(qtbot): + """Test heatmap with None data.""" + strings.load_strings("en") + heatmap = DateHeatmap() + qtbot.addWidget(heatmap) + heatmap.show() + + # Set None data + heatmap.set_data(None) + + assert heatmap._start is None + assert heatmap._end is None + + # Paint event should return early + heatmap.update() + qtbot.wait(10) + + +def test_activity_heatmap_click_when_no_data(qtbot): + """Test clicking heatmap when there's no data.""" + heatmap = DateHeatmap() + qtbot.addWidget(heatmap) + heatmap.show() + + heatmap.set_data({}) + + # Simulate click - should not crash or emit signal + clicked_dates = [] + heatmap.date_clicked.connect(clicked_dates.append) + + # Click in the middle of widget + pos = QPoint(100, 100) + QTest.mouseClick(heatmap, Qt.LeftButton, pos=pos) + + # Should not have clicked any date + assert len(clicked_dates) == 0 + + +def test_activity_heatmap_click_outside_grid(qtbot): + """Test clicking outside the grid area.""" + heatmap = DateHeatmap() + qtbot.addWidget(heatmap) + heatmap.show() + + # Set some data + data = { + date(2024, 1, 1): 5, + date(2024, 1, 2): 10, + } + heatmap.set_data(data) + + clicked_dates = [] + heatmap.date_clicked.connect(clicked_dates.append) + + # Click in top-left margin (before grid starts) + pos = QPoint(5, 5) + QTest.mouseClick(heatmap, Qt.LeftButton, pos=pos) + + assert len(clicked_dates) == 0 + + +def test_activity_heatmap_click_beyond_end_date(qtbot): + """Test clicking on trailing empty cells beyond the last date.""" + heatmap = DateHeatmap() + qtbot.addWidget(heatmap) + heatmap.show() + + # Set data that doesn't fill a complete week + data = { + date(2024, 1, 1): 5, # Monday + date(2024, 1, 2): 10, # Tuesday + } + heatmap.set_data(data) + + clicked_dates = [] + heatmap.date_clicked.connect(clicked_dates.append) + + # Try clicking far to the right (beyond end date) + # This is tricky to target precisely, but we can simulate + pos = QPoint(1000, 50) # Far right + QTest.mouseClick(heatmap, Qt.LeftButton, pos=pos) + + # Should either not click or only click valid dates + # If it did click, it should be a valid date within range + if clicked_dates: + assert clicked_dates[0] <= date(2024, 1, 2) + + +def test_activity_heatmap_click_invalid_row(qtbot): + """Test clicking below the 7 weekday rows.""" + heatmap = DateHeatmap() + qtbot.addWidget(heatmap) + heatmap.show() + + data = { + date(2024, 1, 1): 5, + date(2024, 1, 8): 10, + } + heatmap.set_data(data) + + clicked_dates = [] + heatmap.date_clicked.connect(clicked_dates.append) + + # Click below the grid (row 8 or higher) + pos = QPoint(100, 500) # Very low Y + QTest.mouseClick(heatmap, Qt.LeftButton, pos=pos) + + assert len(clicked_dates) == 0 + + +def test_activity_heatmap_right_click_ignored(qtbot): + """Test that right-click is ignored.""" + heatmap = DateHeatmap() + qtbot.addWidget(heatmap) + heatmap.show() + + data = {date(2024, 1, 1): 5} + heatmap.set_data(data) + + clicked_dates = [] + heatmap.date_clicked.connect(clicked_dates.append) + + # Right click should be ignored + pos = QPoint(100, 100) + QTest.mouseClick(heatmap, Qt.RightButton, pos=pos) + + assert len(clicked_dates) == 0 + + +def test_activity_heatmap_month_label_rendering(qtbot): + """Test heatmap spanning multiple months renders month labels.""" + heatmap = DateHeatmap() + qtbot.addWidget(heatmap) + heatmap.show() + + # Data spanning multiple months + data = { + date(2024, 1, 1): 5, + date(2024, 1, 15): 10, + date(2024, 2, 1): 8, + date(2024, 2, 15): 12, + date(2024, 3, 1): 6, + } + heatmap.set_data(data) + + # Should calculate proper size + size = heatmap.sizeHint() + assert size.width() > 0 + assert size.height() > 0 + + # Paint should work without crashing + heatmap.update() + qtbot.wait(10) + + +def test_activity_heatmap_same_month_continues(qtbot): + """Test that month labels skip weeks in the same month.""" + heatmap = DateHeatmap() + qtbot.addWidget(heatmap) + heatmap.show() + + # Multiple dates in same month + data = {} + for day in range(1, 29): # January 1-28 + data[date(2024, 1, day)] = day + + heatmap.set_data(data) + + # Should render without issues + heatmap.update() + qtbot.wait(10) + + +def test_activity_heatmap_data_with_zero_values(qtbot): + """Test heatmap with zero values in data.""" + heatmap = DateHeatmap() + qtbot.addWidget(heatmap) + heatmap.show() + + data = { + date(2024, 1, 1): 0, + date(2024, 1, 2): 5, + date(2024, 1, 3): 0, + } + heatmap.set_data(data) + + assert heatmap._max_value == 5 + + heatmap.update() + qtbot.wait(10) + + +def test_activity_heatmap_single_day(qtbot): + """Test heatmap with just one day of data.""" + heatmap = DateHeatmap() + qtbot.addWidget(heatmap) + heatmap.show() + + data = {date(2024, 1, 15): 10} + heatmap.set_data(data) + + # Should handle single day + assert heatmap._start is not None + assert heatmap._end is not None + + clicked_dates = [] + heatmap.date_clicked.connect(clicked_dates.append) + + # Click should work + pos = QPoint(100, 100) + QTest.mouseClick(heatmap, Qt.LeftButton, pos=pos) + + +# ============================================================================ +# StatisticsDialog Tests +# ============================================================================ + + +def test_statistics_dialog_with_empty_database(qtbot, fresh_db): + """Test statistics dialog with an empty database.""" + strings.load_strings("en") + + dialog = StatisticsDialog(fresh_db) + qtbot.addWidget(dialog) + dialog.show() + + # Should handle empty database gracefully + assert dialog.isVisible() + + # Heatmap should be empty + heatmap = dialog.findChild(DateHeatmap) + if heatmap: + # No crash when displaying empty heatmap + qtbot.wait(10) + + +def test_statistics_dialog_with_data(qtbot, fresh_db): + """Test statistics dialog with actual data.""" + strings.load_strings("en") + + # Add some content + fresh_db.save_new_version("2024-01-01", "Hello world", "test") + fresh_db.save_new_version("2024-01-02", "More content here", "test") + fresh_db.save_new_version("2024-01-03", "Even more text", "test") + + dialog = StatisticsDialog(fresh_db) + qtbot.addWidget(dialog) + dialog.show() + + # Should display statistics + assert dialog.isVisible() + qtbot.wait(10) + + +def test_statistics_dialog_gather_stats_exception_handling( + qtbot, fresh_db, monkeypatch +): + """Test that gather_stats handles exceptions gracefully.""" + strings.load_strings("en") + + # Make dates_with_content raise an exception + def bad_dates_with_content(): + raise RuntimeError("Simulated DB error") + + monkeypatch.setattr(fresh_db, "dates_with_content", bad_dates_with_content) + + # Should still create dialog without crashing + dialog = StatisticsDialog(fresh_db) + qtbot.addWidget(dialog) + dialog.show() + + # Should handle error gracefully + assert dialog.isVisible() diff --git a/tests/test_tag_graph_dialog.py b/tests/test_tag_graph_dialog.py deleted file mode 100644 index 60cad08..0000000 --- a/tests/test_tag_graph_dialog.py +++ /dev/null @@ -1,365 +0,0 @@ -import numpy as np -import pytest - -from pyqtgraph.Qt import QtCore - -from bouquin.tag_graph_dialog import TagGraphDialog, DraggableGraphItem - - -# --------------------------------------------------------------------------- -# Helpers for DraggableGraphItem tests -# --------------------------------------------------------------------------- - - -class DummyPos: - """Simple object with x()/y() so it looks like a QPointF to the code.""" - - def __init__(self, x, y): - self._x = float(x) - self._y = float(y) - - def x(self): - return self._x - - def y(self): - return self._y - - -class FakePoint: - """Minimal object returned from scatter.pointsAt().""" - - def __init__(self, idx): - self._idx = idx - - def index(self): - return self._idx - - -class FakeDragEvent: - """Stub object that looks like pyqtgraph's mouse drag event.""" - - def __init__(self, stage, button, start_pos, move_pos): - # stage is one of: "start", "move", "finish" - self._stage = stage - self._button = button - self._start_pos = start_pos - self._pos = move_pos - self.accepted = False - self.ignored = False - - # Life-cycle --------------------------------------------------------- - def isStart(self): - return self._stage == "start" - - def isFinish(self): - return self._stage == "finish" - - # Buttons / positions ------------------------------------------------ - def button(self): - return self._button - - def buttonDownPos(self): - return self._start_pos - - def pos(self): - return self._pos - - # Accept / ignore ---------------------------------------------------- - def accept(self): - self.accepted = True - - def ignore(self): - self.ignored = True - - -class FakeHoverEvent: - """Stub for hoverEvent tests.""" - - def __init__(self, pos=None, exit=False): - self._pos = pos - self._exit = exit - - def isExit(self): - return self._exit - - def pos(self): - return self._pos - - -# --------------------------------------------------------------------------- -# DraggableGraphItem -# --------------------------------------------------------------------------- - - -def test_draggable_graph_item_setdata_caches_kwargs(app): - item = DraggableGraphItem() - pos1 = np.array([[0.0, 0.0]], dtype=float) - adj = np.zeros((0, 2), dtype=int) - sizes = np.array([5.0], dtype=float) - - # First call sets all kwargs - item.setData(pos=pos1, adj=adj, size=sizes) - - assert item.pos is pos1 - assert "adj" in item._data_kwargs - assert "size" in item._data_kwargs - - # Second call only passes pos; cached kwargs should keep size/adj - pos2 = np.array([[1.0, 1.0]], dtype=float) - item.setData(pos=pos2) - - assert item.pos is pos2 - assert item._data_kwargs["adj"] is adj - # size should still be present and unchanged - assert np.all(item._data_kwargs["size"] == sizes) - - -def test_draggable_graph_item_drag_updates_position_and_calls_callback(app): - moved = [] - - def on_pos_changed(pos): - # Store a copy so later mutations don't affect our assertion - moved.append(np.array(pos, copy=True)) - - item = DraggableGraphItem(on_position_changed=on_pos_changed) - - # Simple 2-node graph - pos = np.array([[0.0, 0.0], [5.0, 5.0]], dtype=float) - adj = np.array([[0, 1]], dtype=int) - item.setData(pos=pos, adj=adj, size=np.array([5.0, 5.0], dtype=float)) - - # Make pointsAt always return the first node - item.scatter.pointsAt = lambda p: [FakePoint(0)] - - # Start drag on node 0 at (0, 0) - start_ev = FakeDragEvent( - stage="start", - button=QtCore.Qt.MouseButton.LeftButton, - start_pos=DummyPos(0.0, 0.0), - move_pos=None, - ) - item.mouseDragEvent(start_ev) - assert item._drag_index == 0 - assert start_ev.accepted - assert not start_ev.ignored - - # Move mouse to (2, 3) – node 0 should follow exactly - move_ev = FakeDragEvent( - stage="move", - button=QtCore.Qt.MouseButton.LeftButton, - start_pos=DummyPos(0.0, 0.0), - move_pos=DummyPos(2.0, 3.0), - ) - item.mouseDragEvent(move_ev) - assert move_ev.accepted - assert not move_ev.ignored - - assert item.pos.shape == (2, 2) - assert item.pos[0, 0] == pytest.approx(2.0) - assert item.pos[0, 1] == pytest.approx(3.0) - - # Callback should have been invoked with the updated positions - assert moved, "on_position_changed should be called at least once" - np.testing.assert_allclose(moved[-1][0], [2.0, 3.0], atol=1e-6) - - # Finish drag: internal state should reset - finish_ev = FakeDragEvent( - stage="finish", - button=QtCore.Qt.MouseButton.LeftButton, - start_pos=DummyPos(0.0, 0.0), - move_pos=DummyPos(2.0, 3.0), - ) - item.mouseDragEvent(finish_ev) - assert finish_ev.accepted - assert item._drag_index is None - assert item._drag_offset is None - - -def test_draggable_graph_item_ignores_non_left_button(app): - item = DraggableGraphItem() - - pos = np.array([[0.0, 0.0]], dtype=float) - adj = np.zeros((0, 2), dtype=int) - item.setData(pos=pos, adj=adj, size=np.array([5.0], dtype=float)) - - # pointsAt would return something, but the button is not left, - # so the event should be ignored. - item.scatter.pointsAt = lambda p: [FakePoint(0)] - - ev = FakeDragEvent( - stage="start", - button=QtCore.Qt.MouseButton.RightButton, - start_pos=DummyPos(0.0, 0.0), - move_pos=None, - ) - item.mouseDragEvent(ev) - assert ev.ignored - assert not ev.accepted - assert item._drag_index is None - - -def test_draggable_graph_item_hover_reports_index_and_exit(app): - hovered = [] - - def on_hover(idx, ev): - hovered.append(idx) - - item = DraggableGraphItem(on_hover=on_hover) - - # Case 1: exit event should report None - ev_exit = FakeHoverEvent(exit=True) - item.hoverEvent(ev_exit) - assert hovered[-1] is None - - # Case 2: no points under mouse -> None - item.scatter.pointsAt = lambda p: [] - ev_none = FakeHoverEvent(pos=DummyPos(0.0, 0.0)) - item.hoverEvent(ev_none) - assert hovered[-1] is None - - # Case 3: one point under mouse -> its index - item.scatter.pointsAt = lambda p: [FakePoint(3)] - ev_hit = FakeHoverEvent(pos=DummyPos(1.0, 2.0)) - item.hoverEvent(ev_hit) - assert hovered[-1] == 3 - - -# --------------------------------------------------------------------------- -# TagGraphDialog -# --------------------------------------------------------------------------- - - -class EmptyTagDB: - """DB stub that returns no tag data.""" - - def get_tag_cooccurrences(self): - return {}, [], {} - - -class SimpleTagDB: - """Deterministic stub for tag co-occurrence data.""" - - def __init__(self): - self.called = False - - def get_tag_cooccurrences(self): - self.called = True - tags_by_id = { - 1: (1, "alpha", "#ff0000"), - 2: (2, "beta", "#00ff00"), - 3: (3, "gamma", "#0000ff"), - } - edges = [ - (1, 2, 3), - (2, 3, 1), - ] - tag_page_counts = {1: 5, 2: 3, 3: 1} - return tags_by_id, edges, tag_page_counts - - -def test_tag_graph_dialog_handles_empty_db(app, qtbot): - dlg = TagGraphDialog(EmptyTagDB()) - qtbot.addWidget(dlg) - dlg.show() - - # When there are no tags, nothing should be populated - assert dlg._tag_ids == [] - assert dlg._label_items == [] - assert dlg._tag_names == {} - assert dlg._tag_page_counts == {} - - -def test_tag_graph_dialog_populates_graph_from_db(app, qtbot): - db = SimpleTagDB() - dlg = TagGraphDialog(db) - qtbot.addWidget(dlg) - dlg.show() - - assert db.called - - # Basic invariants about the populated state - assert dlg._tag_ids == [1, 2, 3] - assert dlg._tag_names[1] == "alpha" - assert dlg._tag_page_counts[1] == 5 - - # GraphItem should have one position per node - assert dlg.graph_item.pos.shape == (3, 2) - - # Labels and halo state should match number of tags - assert len(dlg._label_items) == 3 - assert len(dlg._halo_sizes) == 3 - assert len(dlg._halo_brushes) == 3 - - -def test_tag_graph_dialog_on_positions_changed_updates_labels_and_halo( - app, qtbot, monkeypatch -): - db = SimpleTagDB() - dlg = TagGraphDialog(db) - qtbot.addWidget(dlg) - dlg.show() - - assert len(dlg._label_items) == 3 - - # Set up fake halo sizes/brushes so the halo branch runs - dlg._halo_sizes = [10.0, 20.0, 30.0] - dlg._halo_brushes = ["a", "b", "c"] - - captured = {} - - def fake_set_data(*, x, y, size, brush, pen): - captured["x"] = x - captured["y"] = y - captured["size"] = size - captured["brush"] = brush - captured["pen"] = pen - - monkeypatch.setattr(dlg._halo_item, "setData", fake_set_data) - - # New layout positions - pos = np.array([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]], dtype=float) - dlg._on_positions_changed(pos) - - # Each label should be slightly below its node (y + 0.15) - for i, label in enumerate(dlg._label_items): - assert label.pos().x() == pytest.approx(pos[i, 0]) - assert label.pos().y() == pytest.approx(pos[i, 1] + 0.15) - - # Halo layer should receive the updated coordinates and our sizes/brushes - assert captured["x"] == [1.0, 3.0, 5.0] - assert captured["y"] == [2.0, 4.0, 6.0] - assert captured["size"] == dlg._halo_sizes - assert captured["brush"] == dlg._halo_brushes - assert captured["pen"] is None - - -def test_tag_graph_dialog_hover_index_shows_and_hides_tooltip(app, qtbot, monkeypatch): - db = SimpleTagDB() - dlg = TagGraphDialog(db) - qtbot.addWidget(dlg) - dlg.show() - - shown = {} - hidden = {"called": False} - - def fake_show_text(pos, text, widget): - shown["pos"] = pos - shown["text"] = text - shown["widget"] = widget - - def fake_hide_text(): - hidden["called"] = True - - # Patch the module-level QToolTip used by TagGraphDialog - monkeypatch.setattr("bouquin.tag_graph_dialog.QToolTip.showText", fake_show_text) - monkeypatch.setattr("bouquin.tag_graph_dialog.QToolTip.hideText", fake_hide_text) - - # Hover over first node (index 0) - dlg._on_hover_index(0, ev=None) - assert "alpha" in shown["text"] - assert "page" in shown["text"] - assert shown["widget"] is dlg - - # Now simulate leaving the item entirely - dlg._on_hover_index(None, ev=None) - assert hidden["called"] diff --git a/tests/test_tags.py b/tests/test_tags.py index 302bc4c..4374458 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -1,6 +1,6 @@ import pytest from PySide6.QtCore import Qt, QPoint, QEvent -from PySide6.QtGui import QMouseEvent +from PySide6.QtGui import QMouseEvent, QColor from PySide6.QtWidgets import QApplication, QMessageBox, QInputDialog, QColorDialog from bouquin.db import DBManager from bouquin.strings import load_strings @@ -9,6 +9,8 @@ from bouquin.tag_browser import TagBrowserDialog from bouquin.flow_layout import FlowLayout from sqlcipher3.dbapi2 import IntegrityError +import bouquin.strings as strings + # ============================================================================ # DB Layer Tag Tests @@ -1798,3 +1800,360 @@ def test_multiple_widgets_same_database(app, fresh_db): widget2._on_toggle(True) assert widget2.chip_layout.count() == 1 + + +def test_tag_browser_add_tag_with_color(qtbot, fresh_db, monkeypatch): + """Test adding a new tag with color selection.""" + strings.load_strings("en") + + browser = TagBrowserDialog(fresh_db) + qtbot.addWidget(browser) + browser.show() + + # Mock input dialog and color dialog + def mock_get_text(*args, **kwargs): + return "NewTag", True + + def mock_get_color(initial, parent): + return QColor("#ff0000") + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + monkeypatch.setattr(QColorDialog, "getColor", mock_get_color) + + tags_before = len(fresh_db.list_tags()) + + # Trigger add tag + browser._add_a_tag() + + # Should have added tag + tags_after = len(fresh_db.list_tags()) + assert tags_after == tags_before + 1 + + +def test_tag_browser_add_tag_cancelled_at_name(qtbot, fresh_db, monkeypatch): + """Test cancelling tag addition at name input.""" + strings.load_strings("en") + + browser = TagBrowserDialog(fresh_db) + qtbot.addWidget(browser) + browser.show() + + # Mock cancelled input + def mock_get_text(*args, **kwargs): + return "", False + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + + tags_before = len(fresh_db.list_tags()) + + browser._add_a_tag() + + # Should not have added tag + tags_after = len(fresh_db.list_tags()) + assert tags_after == tags_before + + +def test_tag_browser_add_tag_cancelled_at_color(qtbot, fresh_db, monkeypatch): + """Test cancelling tag addition at color selection.""" + strings.load_strings("en") + + browser = TagBrowserDialog(fresh_db) + qtbot.addWidget(browser) + browser.show() + + # Name input succeeds, color cancelled + def mock_get_text(*args, **kwargs): + return "NewTag", True + + def mock_get_color(initial, parent): + return QColor() # Invalid color = cancelled + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + monkeypatch.setattr(QColorDialog, "getColor", mock_get_color) + + tags_before = len(fresh_db.list_tags()) + + browser._add_a_tag() + + # Should not have added tag + tags_after = len(fresh_db.list_tags()) + assert tags_after == tags_before + + +def test_tag_browser_add_duplicate_tag_shows_error(qtbot, fresh_db, monkeypatch): + """Test adding duplicate tag shows error.""" + strings.load_strings("en") + + # Add existing tag + fresh_db.add_tag("Existing", "#ff0000") + + browser = TagBrowserDialog(fresh_db) + qtbot.addWidget(browser) + browser.show() + + # Try to add same tag + def mock_get_text(*args, **kwargs): + return "Existing", True + + def mock_get_color(initial, parent): + return QColor("#00ff00") + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + monkeypatch.setattr(QColorDialog, "getColor", mock_get_color) + + critical_shown = {"shown": False} + + def mock_critical(*args): + critical_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "critical", mock_critical) + + browser._add_a_tag() + + # Should show error + assert critical_shown["shown"] + + +def test_tag_browser_edit_tag_integrity_error(qtbot, fresh_db, monkeypatch): + """Test editing tag to duplicate name shows error.""" + strings.load_strings("en") + + # Add two tags + fresh_db.add_tag("Tag1", "#ff0000") + fresh_db.add_tag("Tag2", "#00ff00") + + browser = TagBrowserDialog(fresh_db) + qtbot.addWidget(browser) + browser.show() + + # Select first tag + browser._populate(None) + tree = browser.tree + if tree.topLevelItemCount() > 0: + tree.setCurrentItem(tree.topLevelItem(0)) + + # Try to rename to Tag2 (duplicate) + def mock_get_text(*args, **kwargs): + return "Tag2", True + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + + critical_shown = {"shown": False} + + def mock_critical(*args): + critical_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "critical", mock_critical) + + browser._edit_tag_name() + + # Should show error + assert critical_shown["shown"] + + +def test_tag_browser_change_tag_color_integrity_error(qtbot, fresh_db, monkeypatch): + """Test changing tag color with integrity error.""" + strings.load_strings("en") + + fresh_db.add_tag("TestTag", "#ff0000") + + browser = TagBrowserDialog(fresh_db) + qtbot.addWidget(browser) + browser.show() + + browser._populate(None) + tree = browser.tree + if tree.topLevelItemCount() > 0: + tree.setCurrentItem(tree.topLevelItem(0)) + + # Mock color dialog + def mock_get_color(initial, parent): + return QColor("#00ff00") + + monkeypatch.setattr(QColorDialog, "getColor", mock_get_color) + + # Mock update_tag to raise IntegrityError + fresh_db.update_tag + + def bad_update(*args): + raise IntegrityError("Simulated error") + + monkeypatch.setattr(fresh_db, "update_tag", bad_update) + + critical_shown = {"shown": False} + + def mock_critical(*args): + critical_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "critical", mock_critical) + + browser._change_tag_color() + + # Should show error + assert critical_shown["shown"] + + +def test_tag_browser_change_tag_color_cancelled(qtbot, fresh_db, monkeypatch): + """Test cancelling color change.""" + strings.load_strings("en") + + fresh_db.add_tag("TestTag", "#ff0000") + + browser = TagBrowserDialog(fresh_db) + qtbot.addWidget(browser) + browser.show() + + browser._populate(None) + tree = browser.tree + if tree.topLevelItemCount() > 0: + tree.setCurrentItem(tree.topLevelItem(0)) + + # Mock cancelled color dialog + def mock_get_color(initial, parent): + return QColor() # Invalid = cancelled + + monkeypatch.setattr(QColorDialog, "getColor", mock_get_color) + + # Should not crash + browser._change_tag_color() + + +def test_tag_chip_runtime_error_on_mouse_release(qtbot, monkeypatch): + """Test TagChip handles RuntimeError on mouseReleaseEvent.""" + chip = TagChip(1, "test", "#ff0000") + qtbot.addWidget(chip) + chip.show() + + # Mock super().mouseReleaseEvent to raise RuntimeError + from PySide6.QtWidgets import QFrame + + original_mouse_release = QFrame.mouseReleaseEvent + + def bad_mouse_release(self, event): + raise RuntimeError("Widget deleted") + + monkeypatch.setattr(QFrame, "mouseReleaseEvent", bad_mouse_release) + + clicked_names = [] + chip.clicked.connect(clicked_names.append) + + # Simulate left click + from PySide6.QtTest import QTest + + QTest.mouseClick(chip, Qt.LeftButton) + + # Should have emitted signal despite RuntimeError + assert "test" in clicked_names + + # Restore original + monkeypatch.setattr(QFrame, "mouseReleaseEvent", original_mouse_release) + + +def test_page_tags_widget_many_tags(qtbot, fresh_db): + """Test page tags widget with many tags.""" + strings.load_strings("en") + + # Add many tags + for i in range(20): + fresh_db.add_tag(f"Tag{i}", f"#{i:02x}0000") + + fresh_db.save_new_version("2024-01-01", "Content", "test") + + # Add all tags to page + tag_names = [f"Tag{i}" for i in range(20)] + fresh_db.set_tags_for_page("2024-01-01", tag_names) + + widget = PageTagsWidget(fresh_db) + qtbot.addWidget(widget) + widget.show() + + # Set current date + widget.set_current_date("2024-01-01") + + # Should display all tags + qtbot.wait(50) + + +def test_page_tags_widget_tag_click(qtbot, fresh_db): + """Test clicking on a tag in PageTagsWidget.""" + strings.load_strings("en") + + fresh_db.add_tag("Clickable", "#ff0000") + fresh_db.save_new_version("2024-01-01", "Content", "test") + fresh_db.set_tags_for_page("2024-01-01", ["Clickable"]) + + widget = PageTagsWidget(fresh_db) + qtbot.addWidget(widget) + widget.show() + + widget.set_current_date("2024-01-01") + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + + # Find the tag chip + chips = widget.findChildren(TagChip) + assert len(chips) > 0 + + # Click it - shouldn't crash + from PySide6.QtTest import QTest + + QTest.mouseClick(chips[0], Qt.LeftButton) + + +def test_page_tags_widget_no_date_set(qtbot, fresh_db): + """Test PageTagsWidget with no date set.""" + strings.load_strings("en") + + widget = PageTagsWidget(fresh_db) + qtbot.addWidget(widget) + widget.show() + + # Should handle no date gracefully + widget.set_current_date(None) + qtbot.wait(10) + + +def test_page_tags_widget_date_with_no_tags(qtbot, fresh_db): + """Test PageTagsWidget for date with no tags.""" + strings.load_strings("en") + + fresh_db.save_new_version("2024-01-01", "Content", "test") + + widget = PageTagsWidget(fresh_db) + qtbot.addWidget(widget) + widget.show() + + widget.set_current_date("2024-01-01") + + # Should show no tags + pills = widget.findChildren(TagChip) + assert len(pills) == 0 + + +def test_page_tags_widget_updates_on_tag_change(qtbot, fresh_db): + """Test PageTagsWidget updates when tags change.""" + strings.load_strings("en") + + fresh_db.add_tag("Initial", "#ff0000") + fresh_db.save_new_version("2024-01-01", "Content", "test") + fresh_db.set_tags_for_page("2024-01-01", ["Initial"]) + + widget = PageTagsWidget(fresh_db) + qtbot.addWidget(widget) + widget.show() + + widget.set_current_date("2024-01-01") + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + + assert widget.chip_layout.count() == 1 + + # Add another tag + fresh_db.add_tag("Second", "#00ff00") + fresh_db.set_tags_for_page("2024-01-01", ["Initial", "Second"]) + + # Reload + widget.set_current_date("2024-01-01") + qtbot.wait(100) + + assert widget.chip_layout.count() == 2 diff --git a/tests/test_time_log.py b/tests/test_time_log.py new file mode 100644 index 0000000..6cca759 --- /dev/null +++ b/tests/test_time_log.py @@ -0,0 +1,2558 @@ +import pytest +from datetime import date, timedelta +from PySide6.QtCore import Qt, QDate +from PySide6.QtWidgets import ( + QMessageBox, + QInputDialog, + QFileDialog, +) +from sqlcipher3.dbapi2 import IntegrityError + +from bouquin.theme import ThemeManager, ThemeConfig, Theme +from bouquin.time_log import ( + TimeLogWidget, + TimeLogDialog, + TimeCodeManagerDialog, + TimeReportDialog, +) +import bouquin.strings as strings + + +@pytest.fixture +def theme_manager(app): + return ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + + +def _today(): + return date.today().isoformat() + + +def _yesterday(): + return (date.today() - timedelta(days=1)).isoformat() + + +def _tomorrow(): + return (date.today() + timedelta(days=1)).isoformat() + + +# ============================================================================ +# DB Methods Tests +# ============================================================================ + + +def test_list_projects_empty(fresh_db): + """List projects on empty db returns empty list.""" + projects = fresh_db.list_projects() + assert projects == [] + + +def test_add_project(fresh_db): + """Add a project and verify it's retrievable.""" + proj_id = fresh_db.add_project("Project Alpha") + assert proj_id > 0 + + projects = fresh_db.list_projects() + assert len(projects) == 1 + assert projects[0] == (proj_id, "Project Alpha") + + +def test_add_project_duplicate_name(fresh_db): + """Adding project with duplicate name is idempotent.""" + id1 = fresh_db.add_project("Duplicate") + id2 = fresh_db.add_project("Duplicate") + assert id1 == id2 + + projects = fresh_db.list_projects() + assert len(projects) == 1 + + +def test_add_project_empty_name(fresh_db): + """Adding project with empty name raises ValueError.""" + with pytest.raises(ValueError, match="empty project name"): + fresh_db.add_project("") + + with pytest.raises(ValueError, match="empty project name"): + fresh_db.add_project(" ") + + +def test_add_project_strips_whitespace(fresh_db): + """Project name is trimmed of leading/trailing whitespace.""" + fresh_db.add_project(" Trimmed ") + projects = fresh_db.list_projects() + assert projects[0][1] == "Trimmed" + + +def test_list_projects_sorted(fresh_db): + """Projects are returned sorted case-insensitively by name.""" + fresh_db.add_project("Zebra") + fresh_db.add_project("alpha") + fresh_db.add_project("Beta") + + projects = fresh_db.list_projects() + names = [p[1] for p in projects] + assert names == ["alpha", "Beta", "Zebra"] + + +def test_rename_project(fresh_db): + """Rename a project.""" + proj_id = fresh_db.add_project("Old Name") + fresh_db.rename_project(proj_id, "New Name") + + projects = fresh_db.list_projects() + assert len(projects) == 1 + assert projects[0] == (proj_id, "New Name") + + +def test_rename_project_to_existing_name_raises(fresh_db): + """Renaming to existing name raises IntegrityError.""" + fresh_db.add_project("Project A") + id_b = fresh_db.add_project("Project B") + + with pytest.raises(IntegrityError): + fresh_db.rename_project(id_b, "Project A") + + +def test_rename_project_empty_name_does_nothing(fresh_db): + """Renaming to empty string does nothing.""" + proj_id = fresh_db.add_project("Original") + fresh_db.rename_project(proj_id, "") + + projects = fresh_db.list_projects() + assert projects[0][1] == "Original" + + +def test_delete_project(fresh_db): + """Delete a project.""" + proj_id = fresh_db.add_project("To Delete") + fresh_db.delete_project(proj_id) + + projects = fresh_db.list_projects() + assert len(projects) == 0 + + +def test_delete_project_with_time_entries_raises(fresh_db): + """Deleting project with time entries raises IntegrityError.""" + proj_id = fresh_db.add_project("Active Project") + act_id = fresh_db.add_activity("Coding") + fresh_db.add_time_log(_today(), proj_id, act_id, 60) + + with pytest.raises(IntegrityError): + fresh_db.delete_project(proj_id) + + +def test_list_activities_empty(fresh_db): + """List activities on empty db returns empty list.""" + activities = fresh_db.list_activities() + assert activities == [] + + +def test_add_activity(fresh_db): + """Add an activity and verify it's retrievable.""" + act_id = fresh_db.add_activity("Coding") + assert act_id > 0 + + activities = fresh_db.list_activities() + assert len(activities) == 1 + assert activities[0] == (act_id, "Coding") + + +def test_add_activity_duplicate_name(fresh_db): + """Adding activity with duplicate name is idempotent.""" + id1 = fresh_db.add_activity("Meeting") + id2 = fresh_db.add_activity("Meeting") + assert id1 == id2 + + activities = fresh_db.list_activities() + assert len(activities) == 1 + + +def test_add_activity_empty_name(fresh_db): + """Adding activity with empty name raises ValueError.""" + with pytest.raises(ValueError, match="empty activity name"): + fresh_db.add_activity("") + + with pytest.raises(ValueError, match="empty activity name"): + fresh_db.add_activity(" ") + + +def test_add_activity_strips_whitespace(fresh_db): + """Activity name is trimmed of leading/trailing whitespace.""" + fresh_db.add_activity(" Planning ") + activities = fresh_db.list_activities() + assert activities[0][1] == "Planning" + + +def test_list_activities_sorted(fresh_db): + """Activities are returned sorted case-insensitively by name.""" + fresh_db.add_activity("Writing") + fresh_db.add_activity("coding") + fresh_db.add_activity("Planning") + + activities = fresh_db.list_activities() + names = [a[1] for a in activities] + assert names == ["coding", "Planning", "Writing"] + + +def test_rename_activity(fresh_db): + """Rename an activity.""" + act_id = fresh_db.add_activity("Old Activity") + fresh_db.rename_activity(act_id, "New Activity") + + activities = fresh_db.list_activities() + assert len(activities) == 1 + assert activities[0] == (act_id, "New Activity") + + +def test_rename_activity_to_existing_name_raises(fresh_db): + """Renaming to existing name raises IntegrityError.""" + fresh_db.add_activity("Activity A") + id_b = fresh_db.add_activity("Activity B") + + with pytest.raises(IntegrityError): + fresh_db.rename_activity(id_b, "Activity A") + + +def test_rename_activity_empty_name_does_nothing(fresh_db): + """Renaming to empty string does nothing.""" + act_id = fresh_db.add_activity("Original") + fresh_db.rename_activity(act_id, "") + + activities = fresh_db.list_activities() + assert activities[0][1] == "Original" + + +def test_delete_activity(fresh_db): + """Delete an activity.""" + act_id = fresh_db.add_activity("To Delete") + fresh_db.delete_activity(act_id) + + activities = fresh_db.list_activities() + assert len(activities) == 0 + + +def test_delete_activity_with_time_entries_raises(fresh_db): + """Deleting activity with time entries raises IntegrityError.""" + proj_id = fresh_db.add_project("Some Project") + act_id = fresh_db.add_activity("Used Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 60) + + with pytest.raises(IntegrityError): + fresh_db.delete_activity(act_id) + + +def test_add_time_log(fresh_db): + """Add a time log entry.""" + proj_id = fresh_db.add_project("Research") + act_id = fresh_db.add_activity("Reading") + + entry_id = fresh_db.add_time_log(_today(), proj_id, act_id, 90, "Paper review") + assert entry_id > 0 + + entries = fresh_db.time_log_for_date(_today()) + assert len(entries) == 1 + entry = entries[0] + assert entry[0] == entry_id + assert entry[1] == _today() + assert entry[2] == proj_id + assert entry[3] == "Research" + assert entry[4] == act_id + assert entry[5] == "Reading" + assert entry[6] == 90 + assert entry[7] == "Paper review" + + +def test_add_time_log_creates_page_if_needed(fresh_db): + """Adding time log creates page row if it doesn't exist.""" + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + + # Verify page doesn't exist + dates = fresh_db.dates_with_content() + assert _today() not in dates + + fresh_db.add_time_log(_today(), proj_id, act_id, 60) + + # Page should now exist (even with no text content) + entries = fresh_db.time_log_for_date(_today()) + assert len(entries) == 1 + + +def test_add_time_log_without_note(fresh_db): + """Add time log without note (None).""" + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + + fresh_db.add_time_log(_today(), proj_id, act_id, 30) + entries = fresh_db.time_log_for_date(_today()) + assert entries[0][7] is None + + +def test_update_time_log(fresh_db): + """Update an existing time log entry.""" + proj1_id = fresh_db.add_project("Project 1") + proj2_id = fresh_db.add_project("Project 2") + act1_id = fresh_db.add_activity("Activity 1") + act2_id = fresh_db.add_activity("Activity 2") + + entry_id = fresh_db.add_time_log(_today(), proj1_id, act1_id, 60, "Original") + + fresh_db.update_time_log(entry_id, proj2_id, act2_id, 120, "Updated") + + entries = fresh_db.time_log_for_date(_today()) + assert len(entries) == 1 + entry = entries[0] + assert entry[0] == entry_id + assert entry[2] == proj2_id + assert entry[3] == "Project 2" + assert entry[4] == act2_id + assert entry[5] == "Activity 2" + assert entry[6] == 120 + assert entry[7] == "Updated" + + +def test_delete_time_log(fresh_db): + """Delete a time log entry.""" + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + + entry_id = fresh_db.add_time_log(_today(), proj_id, act_id, 60) + fresh_db.delete_time_log(entry_id) + + entries = fresh_db.time_log_for_date(_today()) + assert len(entries) == 0 + + +def test_time_log_for_date_empty(fresh_db): + """Query time log for date with no entries.""" + entries = fresh_db.time_log_for_date(_today()) + assert entries == [] + + +def test_time_log_for_date_multiple_entries(fresh_db): + """Query returns multiple entries sorted by project, activity, id.""" + proj_a = fresh_db.add_project("AAA") + proj_b = fresh_db.add_project("BBB") + act_x = fresh_db.add_activity("XXX") + act_y = fresh_db.add_activity("YYY") + + # Add in non-sorted order + fresh_db.add_time_log(_today(), proj_b, act_y, 10) + fresh_db.add_time_log(_today(), proj_a, act_x, 20) + fresh_db.add_time_log(_today(), proj_a, act_y, 30) + fresh_db.add_time_log(_today(), proj_b, act_x, 40) + + entries = fresh_db.time_log_for_date(_today()) + assert len(entries) == 4 + + # Should be sorted by project name, then activity name + assert entries[0][3] == "AAA" and entries[0][5] == "XXX" + assert entries[1][3] == "AAA" and entries[1][5] == "YYY" + assert entries[2][3] == "BBB" and entries[2][5] == "XXX" + assert entries[3][3] == "BBB" and entries[3][5] == "YYY" + + +def test_time_report_by_day(fresh_db): + """Time report grouped by day.""" + proj_id = fresh_db.add_project("Project") + act1_id = fresh_db.add_activity("Activity 1") + act2_id = fresh_db.add_activity("Activity 2") + + # Add entries for multiple days + fresh_db.add_time_log(_yesterday(), proj_id, act1_id, 60) + fresh_db.add_time_log(_yesterday(), proj_id, act2_id, 30) + fresh_db.add_time_log(_today(), proj_id, act1_id, 90) + fresh_db.add_time_log(_today(), proj_id, act2_id, 45) + + report = fresh_db.time_report(proj_id, _yesterday(), _today(), "day") + + assert len(report) == 4 + # Each row is (period, activity_name, total_minutes) + yesterday_act1 = next( + r for r in report if r[0] == _yesterday() and r[1] == "Activity 1" + ) + assert yesterday_act1[2] == 60 + + today_act1 = next(r for r in report if r[0] == _today() and r[1] == "Activity 1") + assert today_act1[2] == 90 + + +def test_time_report_by_week(fresh_db): + """Time report grouped by week.""" + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + + # Add entries spanning multiple weeks + date1 = "2024-01-01" # Monday, Week 1 + date2 = "2024-01-03" # Same week + date3 = "2024-01-08" # Following Monday, Week 2 + + fresh_db.add_time_log(date1, proj_id, act_id, 60) + fresh_db.add_time_log(date2, proj_id, act_id, 30) + fresh_db.add_time_log(date3, proj_id, act_id, 45) + + report = fresh_db.time_report(proj_id, date1, date3, "week") + + # Should have 2 rows (2 weeks) + assert len(report) == 2 + + # First week total + assert report[0][2] == 90 # 60 + 30 + # Second week total + assert report[1][2] == 45 + + +def test_time_report_by_month(fresh_db): + """Time report grouped by month.""" + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + + # Add entries spanning multiple months + date1 = "2024-01-15" + date2 = "2024-01-25" + date3 = "2024-02-10" + + fresh_db.add_time_log(date1, proj_id, act_id, 60) + fresh_db.add_time_log(date2, proj_id, act_id, 30) + fresh_db.add_time_log(date3, proj_id, act_id, 45) + + report = fresh_db.time_report(proj_id, date1, date3, "month") + + # Should have 2 rows (2 months) + assert len(report) == 2 + + # January total + jan_row = next(r for r in report if r[0] == "2024-01") + assert jan_row[2] == 90 # 60 + 30 + + # February total + feb_row = next(r for r in report if r[0] == "2024-02") + assert feb_row[2] == 45 + + +def test_time_report_multiple_activities(fresh_db): + """Time report aggregates by activity within period.""" + proj_id = fresh_db.add_project("Project") + act1_id = fresh_db.add_activity("Activity 1") + act2_id = fresh_db.add_activity("Activity 2") + + fresh_db.add_time_log(_today(), proj_id, act1_id, 60) + fresh_db.add_time_log(_today(), proj_id, act1_id, 30) # Same activity + fresh_db.add_time_log(_today(), proj_id, act2_id, 45) + + report = fresh_db.time_report(proj_id, _today(), _today(), "day") + + assert len(report) == 2 + act1_row = next(r for r in report if r[1] == "Activity 1") + assert act1_row[2] == 90 # 60 + 30 aggregated + + act2_row = next(r for r in report if r[1] == "Activity 2") + assert act2_row[2] == 45 + + +def test_time_report_filters_by_project(fresh_db): + """Time report only includes specified project.""" + proj1_id = fresh_db.add_project("Project 1") + proj2_id = fresh_db.add_project("Project 2") + act_id = fresh_db.add_activity("Activity") + + fresh_db.add_time_log(_today(), proj1_id, act_id, 60) + fresh_db.add_time_log(_today(), proj2_id, act_id, 90) + + report = fresh_db.time_report(proj1_id, _today(), _today(), "day") + + assert len(report) == 1 + assert report[0][2] == 60 + + +def test_time_report_empty(fresh_db): + """Time report with no matching entries.""" + proj_id = fresh_db.add_project("Project") + + report = fresh_db.time_report(proj_id, _today(), _today(), "day") + assert report == [] + + +# ============================================================================ +# TimeLogWidget Tests +# ============================================================================ + + +def test_time_log_widget_creation(qtbot, fresh_db): + """TimeLogWidget can be created.""" + widget = TimeLogWidget(fresh_db) + qtbot.addWidget(widget) + assert widget is not None + assert not widget.toggle_btn.isChecked() + assert not widget.body.isVisible() + + +def test_time_log_widget_toggle(qtbot, fresh_db): + """Toggle expands/collapses the widget.""" + widget = TimeLogWidget(fresh_db) + qtbot.addWidget(widget) + widget.show() + + # Initially collapsed + assert not widget.body.isVisible() + assert widget.toggle_btn.arrowType() == Qt.RightArrow + + # Toggle to expand + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + assert widget.body.isVisible() + assert widget.toggle_btn.arrowType() == Qt.DownArrow + + # Toggle to collapse + widget.toggle_btn.setChecked(False) + widget._on_toggle(False) + assert not widget.body.isVisible() + assert widget.toggle_btn.arrowType() == Qt.RightArrow + + +def test_time_log_widget_set_current_date_no_entries(qtbot, fresh_db): + """Set current date with no entries.""" + strings.load_strings("en") + widget = TimeLogWidget(fresh_db) + qtbot.addWidget(widget) + + widget.set_current_date(_today()) + assert widget._current_date == _today() + + # When collapsed, shows hint + assert "Time log" in widget.summary_label.text() + + +def test_time_log_widget_set_current_date_with_entries(qtbot, fresh_db): + """Set current date with entries shows summary.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 90) # 1.5 hours + + widget = TimeLogWidget(fresh_db) + qtbot.addWidget(widget) + + # Expand first + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + + widget.set_current_date(_today()) + + # Should show total in title + assert "1.5" in widget.toggle_btn.text() or "1.50" in widget.toggle_btn.text() + + # Body should show breakdown + summary = widget.summary_label.text() + assert "Project" in summary + + +def test_time_log_widget_open_dialog(qtbot, fresh_db, monkeypatch): + """Open dialog button opens TimeLogDialog.""" + widget = TimeLogWidget(fresh_db) + qtbot.addWidget(widget) + widget.set_current_date(_today()) + + dialog_shown = {"shown": False} + + def mock_exec(self): + dialog_shown["shown"] = True + return 0 + + monkeypatch.setattr(TimeLogDialog, "exec", mock_exec) + + widget.open_btn.click() + assert dialog_shown["shown"] + + +def test_time_log_widget_no_date_open_dialog_does_nothing(qtbot, fresh_db, monkeypatch): + """Open dialog with no date set does nothing.""" + widget = TimeLogWidget(fresh_db) + qtbot.addWidget(widget) + + dialog_shown = {"shown": False} + + def mock_exec(self): + dialog_shown["shown"] = True + return 0 + + monkeypatch.setattr(TimeLogDialog, "exec", mock_exec) + + widget.open_btn.click() + assert not dialog_shown["shown"] + + +def test_time_log_widget_updates_after_dialog_close(qtbot, fresh_db, monkeypatch): + """Widget refreshes summary after dialog closes.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + + widget = TimeLogWidget(fresh_db) + qtbot.addWidget(widget) + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + widget.set_current_date(_today()) + + # Add entry via DB (simulating dialog action) + def mock_exec(self): + fresh_db.add_time_log(_today(), proj_id, act_id, 120) + return 0 + + monkeypatch.setattr(TimeLogDialog, "exec", mock_exec) + + widget.open_btn.click() + + # Summary should be updated + assert "2.0" in widget.toggle_btn.text() or "2.00" in widget.toggle_btn.text() + + +# ============================================================================ +# TimeLogDialog Tests +# ============================================================================ + + +def test_time_log_dialog_creation(qtbot, fresh_db): + """TimeLogDialog can be created.""" + strings.load_strings("en") + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + assert _today() in dialog.windowTitle() + assert dialog.project_combo.count() == 0 + assert dialog.table.rowCount() == 0 + + +def test_time_log_dialog_loads_projects(qtbot, fresh_db): + """Dialog loads existing projects into combo.""" + fresh_db.add_project("Project A") + fresh_db.add_project("Project B") + + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + assert dialog.project_combo.count() == 2 + + +def test_time_log_dialog_loads_activities_for_autocomplete(qtbot, fresh_db): + """Dialog loads activities for autocomplete.""" + fresh_db.add_activity("Coding") + fresh_db.add_activity("Testing") + + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + completer = dialog.activity_edit.completer() + assert completer is not None + + +def test_time_log_dialog_loads_existing_entries(qtbot, fresh_db): + """Dialog loads existing time log entries.""" + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 90) + + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + assert dialog.table.rowCount() == 1 + assert "Project" in dialog.table.item(0, 0).text() + assert "Activity" in dialog.table.item(0, 1).text() + assert ( + "1.5" in dialog.table.item(0, 2).text() + or "1.50" in dialog.table.item(0, 2).text() + ) + + +def test_time_log_dialog_add_entry_without_project_shows_warning( + qtbot, fresh_db, monkeypatch +): + """Add entry without selecting project shows warning.""" + strings.load_strings("en") + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + dialog.activity_edit.setText("Some Activity") + dialog.hours_spin.setValue(1.0) + + warning_shown = {"shown": False} + + def mock_warning(*args): + warning_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "warning", mock_warning) + + dialog._on_add_or_update() + assert warning_shown["shown"] + + +def test_time_log_dialog_add_entry_without_activity_shows_warning( + qtbot, fresh_db, monkeypatch +): + """Add entry without activity shows warning.""" + strings.load_strings("en") + fresh_db.add_project("Project") + + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + dialog.project_combo.setCurrentIndex(0) + dialog.hours_spin.setValue(1.0) + + warning_shown = {"shown": False} + + def mock_warning(*args): + warning_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "warning", mock_warning) + + dialog._on_add_or_update() + assert warning_shown["shown"] + + +def test_time_log_dialog_add_entry_success(qtbot, fresh_db): + """Successfully add a new time entry.""" + fresh_db.add_project("Project") + + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + dialog.project_combo.setCurrentIndex(0) + dialog.activity_edit.setText("New Activity") + dialog.hours_spin.setValue(2.5) + + dialog._on_add_or_update() + + assert dialog.table.rowCount() == 1 + assert "New Activity" in dialog.table.item(0, 1).text() + assert ( + "2.5" in dialog.table.item(0, 2).text() + or "2.50" in dialog.table.item(0, 2).text() + ) + + +def test_time_log_dialog_select_row_enables_delete(qtbot, fresh_db): + """Selecting a row enables delete button and populates fields.""" + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 90) + + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + # Initially delete is disabled + assert not dialog.delete_btn.isEnabled() + + # Select first row + dialog.table.selectRow(0) + + # Delete should be enabled + assert dialog.delete_btn.isEnabled() + + # Fields should be populated + assert dialog.activity_edit.text() == "Activity" + assert dialog.hours_spin.value() == 1.5 + + +def test_time_log_dialog_update_entry(qtbot, fresh_db): + """Update an existing entry.""" + proj1_id = fresh_db.add_project("Project 1") + fresh_db.add_project("Project 2") + act_id = fresh_db.add_activity("Activity") + fresh_db.add_time_log(_today(), proj1_id, act_id, 60) + + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + # Select and modify + dialog.table.selectRow(0) + dialog.project_combo.setCurrentIndex(1) # Project 2 + dialog.hours_spin.setValue(2.0) + + dialog._on_add_or_update() + + # Should still have 1 row (updated, not added) + assert dialog.table.rowCount() == 1 + assert "Project 2" in dialog.table.item(0, 0).text() + assert ( + "2.0" in dialog.table.item(0, 2).text() + or "2.00" in dialog.table.item(0, 2).text() + ) + + +def test_time_log_dialog_delete_entry(qtbot, fresh_db): + """Delete a time log entry.""" + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 60) + + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + assert dialog.table.rowCount() == 1 + + dialog.table.selectRow(0) + dialog._on_delete_entry() + + assert dialog.table.rowCount() == 0 + + +def test_time_log_dialog_manage_projects_opens_dialog(qtbot, fresh_db, monkeypatch): + """Manage projects button opens TimeCodeManagerDialog.""" + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + manager_shown = {"shown": False} + + def mock_exec(self): + manager_shown["shown"] = True + return 0 + + monkeypatch.setattr(TimeCodeManagerDialog, "exec", mock_exec) + + dialog.manage_projects_btn.click() + assert manager_shown["shown"] + + +def test_time_log_dialog_manage_activities_opens_dialog(qtbot, fresh_db, monkeypatch): + """Manage activities button opens TimeCodeManagerDialog.""" + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + manager_shown = {"shown": False} + + def mock_exec(self): + manager_shown["shown"] = True + return 0 + + monkeypatch.setattr(TimeCodeManagerDialog, "exec", mock_exec) + + dialog.manage_activities_btn.click() + assert manager_shown["shown"] + + +def test_time_log_dialog_run_report_opens_dialog(qtbot, fresh_db, monkeypatch): + """Run report button opens TimeReportDialog.""" + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + report_shown = {"shown": False} + + def mock_exec(self): + report_shown["shown"] = True + return 0 + + monkeypatch.setattr(TimeReportDialog, "exec", mock_exec) + + dialog.report_btn.click() + assert report_shown["shown"] + + +# ============================================================================ +# TimeCodeManagerDialog Tests +# ============================================================================ + + +def test_time_code_manager_dialog_creation(qtbot, fresh_db): + """TimeCodeManagerDialog can be created.""" + strings.load_strings("en") + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + assert dialog.tabs.count() == 2 + assert dialog.tabs.currentIndex() == 0 # Projects tab + + +def test_time_code_manager_dialog_focus_activities_tab(qtbot, fresh_db): + """Can focus on activities tab initially.""" + dialog = TimeCodeManagerDialog(fresh_db, focus_tab="activities") + qtbot.addWidget(dialog) + + assert dialog.tabs.currentIndex() == 1 + + +def test_time_code_manager_dialog_loads_projects(qtbot, fresh_db): + """Dialog loads existing projects.""" + fresh_db.add_project("Project A") + fresh_db.add_project("Project B") + + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + assert dialog.project_list.count() == 2 + + +def test_time_code_manager_dialog_loads_activities(qtbot, fresh_db): + """Dialog loads existing activities.""" + fresh_db.add_activity("Activity 1") + fresh_db.add_activity("Activity 2") + + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + assert dialog.activity_list.count() == 2 + + +def test_time_code_manager_add_project(qtbot, fresh_db, monkeypatch): + """Add a new project.""" + strings.load_strings("en") + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + def mock_get_text(parent, title, label, mode, default): + return "New Project", True + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + + dialog._add_project() + + assert dialog.project_list.count() == 1 + assert dialog.project_list.item(0).text() == "New Project" + + +def test_time_code_manager_add_project_cancelled(qtbot, fresh_db, monkeypatch): + """Cancel adding project.""" + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + def mock_get_text(parent, title, label, mode, default): + return "", False + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + + dialog._add_project() + + assert dialog.project_list.count() == 0 + + +def test_time_code_manager_add_project_invalid_shows_warning( + qtbot, fresh_db, monkeypatch +): + """Adding invalid project shows warning.""" + strings.load_strings("en") + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + def mock_get_text(parent, title, label, mode, default): + return "Valid Name", True + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + + # Force add_project to raise ValueError + original_add = fresh_db.add_project + + def bad_add(name): + raise ValueError("empty project name") + + fresh_db.add_project = bad_add + + warning_shown = {"shown": False} + + def mock_warning(*args): + warning_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "warning", mock_warning) + + dialog._add_project() + assert warning_shown["shown"] + + fresh_db.add_project = original_add + + +def test_time_code_manager_rename_project(qtbot, fresh_db, monkeypatch): + """Rename an existing project.""" + strings.load_strings("en") + fresh_db.add_project("Old Name") + + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_list.setCurrentRow(0) + + def mock_get_text(parent, title, label, mode, default): + return "New Name", True + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + + dialog._rename_project() + + assert dialog.project_list.item(0).text() == "New Name" + + +def test_time_code_manager_rename_project_no_selection_shows_info( + qtbot, fresh_db, monkeypatch +): + """Rename without selection shows info message.""" + strings.load_strings("en") + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + info_shown = {"shown": False} + + def mock_info(*args): + info_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "information", mock_info) + + dialog._rename_project() + assert info_shown["shown"] + + +def test_time_code_manager_rename_project_to_duplicate_shows_warning( + qtbot, fresh_db, monkeypatch +): + """Renaming to duplicate name shows warning.""" + strings.load_strings("en") + fresh_db.add_project("Project A") + fresh_db.add_project("Project B") + + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_list.setCurrentRow(1) + + def mock_get_text(parent, title, label, mode, default): + return "Project A", True + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + + warning_shown = {"shown": False} + + def mock_warning(*args): + warning_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "warning", mock_warning) + + dialog._rename_project() + assert warning_shown["shown"] + + +def test_time_code_manager_delete_project(qtbot, fresh_db, monkeypatch): + """Delete a project.""" + strings.load_strings("en") + fresh_db.add_project("To Delete") + + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_list.setCurrentRow(0) + + def mock_question(*args, **kwargs): + return QMessageBox.StandardButton.Yes + + monkeypatch.setattr(QMessageBox, "question", mock_question) + + dialog._delete_project() + + assert dialog.project_list.count() == 0 + + +def test_time_code_manager_delete_project_cancelled(qtbot, fresh_db, monkeypatch): + """Cancel delete project.""" + strings.load_strings("en") + fresh_db.add_project("Keep Me") + + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_list.setCurrentRow(0) + + def mock_question(*args, **kwargs): + return QMessageBox.StandardButton.No + + monkeypatch.setattr(QMessageBox, "question", mock_question) + + dialog._delete_project() + + assert dialog.project_list.count() == 1 + + +def test_time_code_manager_delete_project_with_entries_shows_warning( + qtbot, fresh_db, monkeypatch +): + """Deleting project with entries shows warning.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Used Project") + act_id = fresh_db.add_activity("Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 60) + + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_list.setCurrentRow(0) + + def mock_question(*args, **kwargs): + return QMessageBox.StandardButton.Yes + + monkeypatch.setattr(QMessageBox, "question", mock_question) + + warning_shown = {"shown": False} + + def mock_warning(*args): + warning_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "warning", mock_warning) + + dialog._delete_project() + assert warning_shown["shown"] + assert dialog.project_list.count() == 1 # Not deleted + + +def test_time_code_manager_add_activity(qtbot, fresh_db, monkeypatch): + """Add a new activity.""" + strings.load_strings("en") + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + def mock_get_text(parent, title, label, mode, default): + return "New Activity", True + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + + dialog._add_activity() + + assert dialog.activity_list.count() == 1 + assert dialog.activity_list.item(0).text() == "New Activity" + + +def test_time_code_manager_rename_activity(qtbot, fresh_db, monkeypatch): + """Rename an existing activity.""" + strings.load_strings("en") + fresh_db.add_activity("Old Activity") + + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.activity_list.setCurrentRow(0) + + def mock_get_text(parent, title, label, mode, default): + return "New Activity", True + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + + dialog._rename_activity() + + assert dialog.activity_list.item(0).text() == "New Activity" + + +def test_time_code_manager_delete_activity(qtbot, fresh_db, monkeypatch): + """Delete an activity.""" + strings.load_strings("en") + fresh_db.add_activity("To Delete") + + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.activity_list.setCurrentRow(0) + + def mock_question(*args, **kwargs): + return QMessageBox.StandardButton.Yes + + monkeypatch.setattr(QMessageBox, "question", mock_question) + + dialog._delete_activity() + + assert dialog.activity_list.count() == 0 + + +def test_time_code_manager_delete_activity_with_entries_shows_warning( + qtbot, fresh_db, monkeypatch +): + """Deleting activity with entries shows warning.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Used Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 60) + + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.activity_list.setCurrentRow(0) + + def mock_question(*args, **kwargs): + return QMessageBox.StandardButton.Yes + + monkeypatch.setattr(QMessageBox, "question", mock_question) + + warning_shown = {"shown": False} + + def mock_warning(*args): + warning_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "warning", mock_warning) + + dialog._delete_activity() + assert warning_shown["shown"] + assert dialog.activity_list.count() == 1 # Not deleted + + +# ============================================================================ +# TimeReportDialog Tests +# ============================================================================ + + +def test_time_report_dialog_creation(qtbot, fresh_db): + """TimeReportDialog can be created.""" + strings.load_strings("en") + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + assert dialog.project_combo.count() == 0 + assert dialog.granularity.count() == 3 # day, week, month + + +def test_time_report_dialog_loads_projects(qtbot, fresh_db): + """Dialog loads projects.""" + fresh_db.add_project("Project A") + fresh_db.add_project("Project B") + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + assert dialog.project_combo.count() == 2 + + +def test_time_report_dialog_default_date_range(qtbot, fresh_db): + """Dialog defaults to last 7 days.""" + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + today = QDate.currentDate() + week_ago = today.addDays(-7) + + assert dialog.from_date.date() == week_ago + assert dialog.to_date.date() == today + + +def test_time_report_dialog_run_report(qtbot, fresh_db): + """Run a time report.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 90) + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_combo.setCurrentIndex(0) + dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + dialog.granularity.setCurrentIndex(0) # day + + dialog._run_report() + + assert dialog.table.rowCount() == 1 + assert "Activity" in dialog.table.item(0, 1).text() + assert "1.5" in dialog.total_label.text() or "1.50" in dialog.total_label.text() + + +def test_time_report_dialog_run_report_no_project_selected(qtbot, fresh_db): + """Run report with no project selected does nothing.""" + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog._run_report() + + # Should not crash, table remains empty + assert dialog.table.rowCount() == 0 + + +def test_time_report_dialog_export_csv_no_report_shows_info( + qtbot, fresh_db, monkeypatch +): + """Export CSV without running report shows info.""" + strings.load_strings("en") + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + info_shown = {"shown": False} + + def mock_info(*args): + info_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "information", mock_info) + + dialog._export_csv() + assert info_shown["shown"] + + +def test_time_report_dialog_export_csv_success(qtbot, fresh_db, tmp_path, monkeypatch): + """Export report to CSV.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 120) + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_combo.setCurrentIndex(0) + dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + + dialog._run_report() + + csv_file = str(tmp_path / "report.csv") + + def mock_get_save_filename(*args, **kwargs): + return csv_file, "CSV Files (*.csv)" + + monkeypatch.setattr(QFileDialog, "getSaveFileName", mock_get_save_filename) + + dialog._export_csv() + + import os + + assert os.path.exists(csv_file) + + with open(csv_file, "r") as f: + content = f.read() + assert "Activity" in content + assert "2.00" in content + + +def test_time_report_dialog_export_csv_cancelled(qtbot, fresh_db, monkeypatch): + """Cancel CSV export.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 60) + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_combo.setCurrentIndex(0) + dialog._run_report() + + def mock_get_save_filename(*args, **kwargs): + return "", "" + + monkeypatch.setattr(QFileDialog, "getSaveFileName", mock_get_save_filename) + + # Should not crash + dialog._export_csv() + + +def test_time_report_dialog_export_pdf_no_report_shows_info( + qtbot, fresh_db, monkeypatch +): + """Export PDF without running report shows info.""" + strings.load_strings("en") + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + info_shown = {"shown": False} + + def mock_info(*args): + info_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "information", mock_info) + + dialog._export_pdf() + assert info_shown["shown"] + + +def test_time_report_dialog_export_pdf_success(qtbot, fresh_db, tmp_path, monkeypatch): + """Export report to PDF.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Test Project") + act_id = fresh_db.add_activity("Testing") + fresh_db.add_time_log(_today(), proj_id, act_id, 150) + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_combo.setCurrentIndex(0) + dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + + dialog._run_report() + + pdf_file = str(tmp_path / "report.pdf") + + def mock_get_save_filename(*args, **kwargs): + return pdf_file, "PDF Files (*.pdf)" + + monkeypatch.setattr(QFileDialog, "getSaveFileName", mock_get_save_filename) + + dialog._export_pdf() + + import os + + assert os.path.exists(pdf_file) + # PDF should have content + assert os.path.getsize(pdf_file) > 0 + + +def test_time_report_dialog_export_pdf_cancelled(qtbot, fresh_db, monkeypatch): + """Cancel PDF export.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 60) + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_combo.setCurrentIndex(0) + dialog._run_report() + + def mock_get_save_filename(*args, **kwargs): + return "", "" + + monkeypatch.setattr(QFileDialog, "getSaveFileName", mock_get_save_filename) + + # Should not crash + dialog._export_pdf() + + +def test_time_report_dialog_granularity_week(qtbot, fresh_db): + """Report with week granularity.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + + # Add entries on different days of the same week + date1 = "2024-01-01" + date2 = "2024-01-03" + fresh_db.add_time_log(date1, proj_id, act_id, 60) + fresh_db.add_time_log(date2, proj_id, act_id, 90) + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_combo.setCurrentIndex(0) + dialog.from_date.setDate(QDate.fromString(date1, "yyyy-MM-dd")) + dialog.to_date.setDate(QDate.fromString(date2, "yyyy-MM-dd")) + dialog.granularity.setCurrentIndex(1) # week + + dialog._run_report() + + # Should aggregate to single week + assert dialog.table.rowCount() == 1 + hours_text = dialog.table.item(0, 2).text() + assert "2.5" in hours_text or "2.50" in hours_text + + +def test_time_report_dialog_granularity_month(qtbot, fresh_db): + """Report with month granularity.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + + # Add entries on different days of the same month + date1 = "2024-01-05" + date2 = "2024-01-25" + fresh_db.add_time_log(date1, proj_id, act_id, 60) + fresh_db.add_time_log(date2, proj_id, act_id, 90) + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_combo.setCurrentIndex(0) + dialog.from_date.setDate(QDate.fromString(date1, "yyyy-MM-dd")) + dialog.to_date.setDate(QDate.fromString(date2, "yyyy-MM-dd")) + dialog.granularity.setCurrentIndex(2) # month + + dialog._run_report() + + # Should aggregate to single month + assert dialog.table.rowCount() == 1 + hours_text = dialog.table.item(0, 2).text() + assert "2.5" in hours_text or "2.50" in hours_text + + +def test_time_report_dialog_multiple_activities_same_period(qtbot, fresh_db): + """Report shows multiple activities separately.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Project") + act1_id = fresh_db.add_activity("Activity 1") + act2_id = fresh_db.add_activity("Activity 2") + + fresh_db.add_time_log(_today(), proj_id, act1_id, 60) + fresh_db.add_time_log(_today(), proj_id, act2_id, 90) + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_combo.setCurrentIndex(0) + dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + + dialog._run_report() + + assert dialog.table.rowCount() == 2 + + +def test_time_log_widget_calculates_per_project_totals(qtbot, fresh_db): + """Widget shows per-project breakdown in summary.""" + strings.load_strings("en") + proj1_id = fresh_db.add_project("Project A") + proj2_id = fresh_db.add_project("Project B") + act_id = fresh_db.add_activity("Activity") + + fresh_db.add_time_log(_today(), proj1_id, act_id, 60) + fresh_db.add_time_log(_today(), proj2_id, act_id, 90) + + widget = TimeLogWidget(fresh_db) + qtbot.addWidget(widget) + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + widget.set_current_date(_today()) + + summary = widget.summary_label.text() + assert "Project A" in summary + assert "Project B" in summary + assert "1.00h" in summary + assert "1.50h" in summary + + +def test_time_report_dialog_csv_export_handles_os_error( + qtbot, fresh_db, tmp_path, monkeypatch +): + """CSV export handles OSError gracefully.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 60) + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_combo.setCurrentIndex(0) + dialog._run_report() + + # Use a path that will cause an error (e.g., directory instead of file) + bad_path = str(tmp_path) + + def mock_get_save_filename(*args, **kwargs): + return bad_path, "CSV Files (*.csv)" + + monkeypatch.setattr(QFileDialog, "getSaveFileName", mock_get_save_filename) + + warning_shown = {"shown": False} + + def mock_warning(*args): + warning_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "warning", mock_warning) + + dialog._export_csv() + assert warning_shown["shown"] + + +# ============================================================================ +# Additional TimeLogWidget Edge Cases +# ============================================================================ + + +def test_time_log_widget_collapsed_shows_hint_after_date_set(qtbot, fresh_db): + """When collapsed, setting date shows hint instead of full summary.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 90) + + widget = TimeLogWidget(fresh_db) + qtbot.addWidget(widget) + + # Keep collapsed + assert not widget.toggle_btn.isChecked() + + widget.set_current_date(_today()) + + # Should show hint, not full breakdown + assert ( + "hint" in widget.summary_label.text().lower() + or "time log" in widget.summary_label.text().lower() + ) + + +def test_time_log_widget_no_date_shows_no_date_message(qtbot, fresh_db): + """Widget with no date set shows appropriate message.""" + strings.load_strings("en") + widget = TimeLogWidget(fresh_db) + qtbot.addWidget(widget) + + # Expand to see summary + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + + # Don't set a date + widget._reload_summary() + + # Should indicate no date is set + summary = widget.summary_label.text() + assert "no date" in summary.lower() or "time log" in summary.lower() + + +def test_time_log_widget_header_updates_on_toggle(qtbot, fresh_db): + """Header total is visible even when collapsed.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 120) # 2 hours + + widget = TimeLogWidget(fresh_db) + qtbot.addWidget(widget) + widget.set_current_date(_today()) + + # Expand to trigger reload + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + + # Header should show total + assert "2" in widget.toggle_btn.text() + + # Collapse again + widget.toggle_btn.setChecked(False) + widget._on_toggle(False) + + # Header total should still be visible + assert "2" in widget.toggle_btn.text() + + +def test_time_log_widget_dialog_updates_on_close_when_collapsed( + qtbot, fresh_db, monkeypatch +): + """When dialog closes, widget updates summary even if collapsed.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + + widget = TimeLogWidget(fresh_db) + qtbot.addWidget(widget) + widget.set_current_date(_today()) + + # Keep collapsed + assert not widget.toggle_btn.isChecked() + + def mock_exec(self): + fresh_db.add_time_log(_today(), proj_id, act_id, 60) + return 0 + + monkeypatch.setattr(TimeLogDialog, "exec", mock_exec) + + widget.open_btn.click() + + # Should show hint after update when collapsed + assert ( + "hint" in widget.summary_label.text().lower() + or "time log" in widget.summary_label.text().lower() + ) + + +# ============================================================================ +# Additional TimeLogDialog Edge Cases +# ============================================================================ + + +def test_time_log_dialog_deselect_clears_current_entry(qtbot, fresh_db): + """Deselecting row clears current entry and disables delete.""" + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 60) + + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + # Select + dialog.table.selectRow(0) + assert dialog.delete_btn.isEnabled() + + # Clear selection + dialog.table.clearSelection() + + # Trigger selection changed + dialog._on_row_selected() + + assert not dialog.delete_btn.isEnabled() + assert dialog._current_entry_id is None + + +def test_time_log_dialog_delete_without_selection_does_nothing(qtbot, fresh_db): + """Delete button when no entry selected does nothing.""" + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + # No selection + dialog._current_entry_id = None + + # Should not crash + dialog._on_delete_entry() + + +def test_time_log_dialog_creates_activity_if_new(qtbot, fresh_db): + """Dialog creates activity if it doesn't exist.""" + fresh_db.add_project("Project") + + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + dialog.project_combo.setCurrentIndex(0) + dialog.activity_edit.setText("Brand New Activity") + dialog.hours_spin.setValue(1.0) + + dialog._on_add_or_update() + + # Activity should have been created + activities = fresh_db.list_activities() + assert len(activities) == 1 + assert activities[0][1] == "Brand New Activity" + + +def test_time_log_dialog_rounds_hours_to_minutes(qtbot, fresh_db): + """Hours are correctly converted to integer minutes.""" + fresh_db.add_project("Project") + + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + dialog.project_combo.setCurrentIndex(0) + dialog.activity_edit.setText("Activity") + dialog.hours_spin.setValue(1.75) # 1 hour 45 minutes = 105 minutes + + dialog._on_add_or_update() + + entries = fresh_db.time_log_for_date(_today()) + assert entries[0][6] == 105 # minutes + + +def test_time_log_dialog_update_button_text_changes(qtbot, fresh_db): + """Button text changes between Add and Update.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 60) + + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + # Initially "Add" + assert "add" in dialog.add_update_btn.text().lower() + + # Select entry + dialog.table.selectRow(0) + + # Should change to "Update" + assert "update" in dialog.add_update_btn.text().lower() + + # Deselect + dialog.table.clearSelection() + dialog._on_row_selected() + + # Back to "Add" + assert "add" in dialog.add_update_btn.text().lower() + + +def test_time_log_dialog_project_selection_by_name(qtbot, fresh_db): + """Selecting entry sets project combo by name match.""" + fresh_db.add_project("Project A") + proj2_id = fresh_db.add_project("Project B") + act_id = fresh_db.add_activity("Activity") + fresh_db.add_time_log(_today(), proj2_id, act_id, 60) + + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + dialog.table.selectRow(0) + + # Should select Project B + assert dialog.project_combo.currentText() == "Project B" + + +def test_time_log_dialog_project_not_found_in_combo(qtbot, fresh_db): + """If project name not found in combo, selection doesn't crash.""" + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + entry_id = fresh_db.add_time_log(_today(), proj_id, act_id, 60) + + # Delete the project from DB manually (shouldn't happen, but defensive) + fresh_db.delete_time_log(entry_id) + fresh_db.delete_project(proj_id) + + # Re-add entry with orphaned project reference (simulated edge case) + # Actually can't easily simulate this due to FK constraints + # So just test normal case - combo should work fine + proj_id = fresh_db.add_project("Project") + fresh_db.add_time_log(_today(), proj_id, act_id, 60) + + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + dialog.table.selectRow(0) + # Should not crash + + +# ============================================================================ +# Additional TimeCodeManagerDialog Edge Cases +# ============================================================================ + + +def test_time_code_manager_rename_cancelled_does_nothing(qtbot, fresh_db, monkeypatch): + """Cancelling rename does nothing.""" + strings.load_strings("en") + fresh_db.add_project("Original") + + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_list.setCurrentRow(0) + + def mock_get_text(parent, title, label, mode, default): + return "", False + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + + dialog._rename_project() + + # Name should remain unchanged + assert dialog.project_list.item(0).text() == "Original" + + +def test_time_code_manager_rename_to_same_name_does_nothing( + qtbot, fresh_db, monkeypatch +): + """Renaming to same name does nothing.""" + strings.load_strings("en") + fresh_db.add_project("Same Name") + + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_list.setCurrentRow(0) + + def mock_get_text(parent, title, label, mode, default): + return "Same Name", True + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + + dialog._rename_project() + + # Should still have one project + assert dialog.project_list.count() == 1 + + +def test_time_code_manager_delete_no_selection_shows_info(qtbot, fresh_db, monkeypatch): + """Delete without selection shows info.""" + strings.load_strings("en") + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + info_shown = {"shown": False} + + def mock_info(*args): + info_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "information", mock_info) + + dialog._delete_project() + assert info_shown["shown"] + + +def test_time_code_manager_add_empty_activity_cancelled(qtbot, fresh_db, monkeypatch): + """Adding empty activity name is cancelled.""" + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + def mock_get_text(parent, title, label, mode, default): + return "", True # Empty but OK clicked + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + + dialog._add_activity() + + # No activity should be added + assert dialog.activity_list.count() == 0 + + +def test_time_code_manager_rename_activity_no_selection(qtbot, fresh_db, monkeypatch): + """Rename activity without selection shows info.""" + strings.load_strings("en") + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + info_shown = {"shown": False} + + def mock_info(*args): + info_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "information", mock_info) + + dialog._rename_activity() + assert info_shown["shown"] + + +def test_time_code_manager_delete_activity_no_selection(qtbot, fresh_db, monkeypatch): + """Delete activity without selection shows info.""" + strings.load_strings("en") + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + info_shown = {"shown": False} + + def mock_info(*args): + info_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "information", mock_info) + + dialog._delete_activity() + assert info_shown["shown"] + + +def test_time_code_manager_delete_activity_cancelled(qtbot, fresh_db, monkeypatch): + """Cancelling delete activity keeps it.""" + strings.load_strings("en") + fresh_db.add_activity("Keep Me") + + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.activity_list.setCurrentRow(0) + + def mock_question(*args, **kwargs): + return QMessageBox.StandardButton.No + + monkeypatch.setattr(QMessageBox, "question", mock_question) + + dialog._delete_activity() + + assert dialog.activity_list.count() == 1 + + +# ============================================================================ +# Additional TimeReportDialog Edge Cases +# ============================================================================ + + +def test_time_report_dialog_empty_report_shows_zero(qtbot, fresh_db): + """Running report with no data shows zero total.""" + strings.load_strings("en") + fresh_db.add_project("Project") + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_combo.setCurrentIndex(0) + dialog._run_report() + + assert dialog.table.rowCount() == 0 + assert "0" in dialog.total_label.text() + + +def test_time_report_dialog_date_range_filters_correctly(qtbot, fresh_db): + """Report only includes entries within date range.""" + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + + # Add entries on different dates + date1 = "2024-01-01" + date2 = "2024-01-15" + date3 = "2024-01-30" + + fresh_db.add_time_log(date1, proj_id, act_id, 60) + fresh_db.add_time_log(date2, proj_id, act_id, 60) + fresh_db.add_time_log(date3, proj_id, act_id, 60) + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_combo.setCurrentIndex(0) + # Set range to only include middle date + dialog.from_date.setDate(QDate.fromString(date2, "yyyy-MM-dd")) + dialog.to_date.setDate(QDate.fromString(date2, "yyyy-MM-dd")) + + dialog._run_report() + + # Should only have one entry + assert dialog.table.rowCount() == 1 + + +def test_time_report_dialog_stores_report_state(qtbot, fresh_db): + """Dialog stores last report state for export.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("My Project") + act_id = fresh_db.add_activity("My Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 90) + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_combo.setCurrentIndex(0) + dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + dialog.granularity.setCurrentIndex(1) # week + + dialog._run_report() + + # Check stored state + assert dialog._last_project_name == "My Project" + assert dialog._last_start == _today() + assert dialog._last_end == _today() + assert "week" in dialog._last_gran_label.lower() + assert len(dialog._last_rows) == 1 + assert dialog._last_total_minutes == 90 + + +def test_time_report_dialog_pdf_export_with_multiple_periods( + qtbot, fresh_db, tmp_path, monkeypatch +): + """PDF export handles multiple time periods with chart.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + + # Add entries on multiple days + date1 = "2024-01-01" + date2 = "2024-01-02" + date3 = "2024-01-03" + + fresh_db.add_time_log(date1, proj_id, act_id, 60) + fresh_db.add_time_log(date2, proj_id, act_id, 90) + fresh_db.add_time_log(date3, proj_id, act_id, 45) + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_combo.setCurrentIndex(0) + dialog.from_date.setDate(QDate.fromString(date1, "yyyy-MM-dd")) + dialog.to_date.setDate(QDate.fromString(date3, "yyyy-MM-dd")) + dialog.granularity.setCurrentIndex(0) # day + + dialog._run_report() + + pdf_file = str(tmp_path / "chart_test.pdf") + + def mock_get_save_filename(*args, **kwargs): + return pdf_file, "PDF Files (*.pdf)" + + monkeypatch.setattr( + "PySide6.QtWidgets.QFileDialog.getSaveFileName", mock_get_save_filename + ) + + dialog._export_pdf() + + import os + + assert os.path.exists(pdf_file) + assert os.path.getsize(pdf_file) > 0 + + +def test_time_report_dialog_pdf_export_with_zero_hours( + qtbot, fresh_db, tmp_path, monkeypatch +): + """PDF export handles entries with zero hours gracefully.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + + # Add entry with 0 minutes (edge case) + fresh_db.add_time_log(_today(), proj_id, act_id, 0) + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_combo.setCurrentIndex(0) + dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + + dialog._run_report() + + pdf_file = str(tmp_path / "zero_hours.pdf") + + def mock_get_save_filename(*args, **kwargs): + return pdf_file, "PDF Files (*.pdf)" + + monkeypatch.setattr( + "PySide6.QtWidgets.QFileDialog.getSaveFileName", mock_get_save_filename + ) + + # Should not crash + dialog._export_pdf() + + +def test_time_report_dialog_csv_includes_total_row( + qtbot, fresh_db, tmp_path, monkeypatch +): + """CSV export includes total row at bottom.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 90) + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_combo.setCurrentIndex(0) + dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + + dialog._run_report() + + csv_file = str(tmp_path / "total_test.csv") + + def mock_get_save_filename(*args, **kwargs): + return csv_file, "CSV Files (*.csv)" + + monkeypatch.setattr( + "PySide6.QtWidgets.QFileDialog.getSaveFileName", mock_get_save_filename + ) + + dialog._export_csv() + + with open(csv_file, "r") as f: + lines = f.readlines() + # Should have header, data row, blank, total row + assert len(lines) >= 4 + # Last line should contain total + assert "Total" in lines[-1] or "total" in lines[-1] + + +def test_time_report_dialog_pdf_chart_with_single_period( + qtbot, fresh_db, tmp_path, monkeypatch +): + """PDF chart renders correctly with single period.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 120) + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + dialog.project_combo.setCurrentIndex(0) + dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + + dialog._run_report() + + pdf_file = str(tmp_path / "single_period.pdf") + + def mock_get_save_filename(*args, **kwargs): + return pdf_file, "PDF Files (*.pdf)" + + monkeypatch.setattr( + "PySide6.QtWidgets.QFileDialog.getSaveFileName", mock_get_save_filename + ) + + dialog._export_pdf() + + import os + + assert os.path.exists(pdf_file) + + +# ============================================================================ +# Integration Tests +# ============================================================================ + + +def test_full_workflow_add_project_activity_log_report( + qtbot, fresh_db, tmp_path, monkeypatch +): + """Test full workflow: create project, activity, log time, run report.""" + strings.load_strings("en") + + # 1. Create project via dialog + manager = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(manager) + + def mock_get_text_project(parent, title, label, mode, default): + return "Test Project", True + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text_project) + manager._add_project() + + # 2. Create activity via dialog + def mock_get_text_activity(parent, title, label, mode, default): + return "Test Activity", True + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text_activity) + manager._add_activity() + + manager.accept() + + # 3. Log time via dialog + log_dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(log_dialog) + + log_dialog.project_combo.setCurrentIndex(0) + log_dialog.activity_edit.setText("Test Activity") + log_dialog.hours_spin.setValue(2.5) + + log_dialog._on_add_or_update() + log_dialog.accept() + + # 4. Run report + report_dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(report_dialog) + + report_dialog.project_combo.setCurrentIndex(0) + report_dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + report_dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + + report_dialog._run_report() + + # Verify report + assert report_dialog.table.rowCount() == 1 + assert "Test Activity" in report_dialog.table.item(0, 1).text() + assert ( + "2.5" in report_dialog.table.item(0, 2).text() + or "2.50" in report_dialog.table.item(0, 2).text() + ) + + # 5. Export CSV + csv_file = str(tmp_path / "workflow_test.csv") + + def mock_get_save_filename(*args, **kwargs): + return csv_file, "CSV Files (*.csv)" + + monkeypatch.setattr( + "PySide6.QtWidgets.QFileDialog.getSaveFileName", mock_get_save_filename + ) + + report_dialog._export_csv() + + import os + + assert os.path.exists(csv_file) + + +def test_time_log_widget_with_multiple_projects_same_day(qtbot, fresh_db): + """Widget correctly aggregates multiple projects on same day.""" + strings.load_strings("en") + proj1_id = fresh_db.add_project("Alpha") + proj2_id = fresh_db.add_project("Beta") + proj3_id = fresh_db.add_project("Gamma") + act_id = fresh_db.add_activity("Work") + + fresh_db.add_time_log(_today(), proj1_id, act_id, 30) + fresh_db.add_time_log(_today(), proj2_id, act_id, 45) + fresh_db.add_time_log(_today(), proj3_id, act_id, 60) + + widget = TimeLogWidget(fresh_db) + qtbot.addWidget(widget) + widget.toggle_btn.setChecked(True) + widget._on_toggle(True) + widget.set_current_date(_today()) + + # Total should be 2.25 hours + title = widget.toggle_btn.text() + assert "2.25" in title or "2.2" in title + + # Summary should list all three projects + summary = widget.summary_label.text() + assert "Alpha" in summary + assert "Beta" in summary + assert "Gamma" in summary + + +def test_time_log_dialog_preserves_entry_id_through_update(qtbot, fresh_db): + """Updating entry preserves entry ID.""" + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 60) + + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + + # Select and update + dialog.table.selectRow(0) + original_id = dialog._current_entry_id + + dialog.hours_spin.setValue(2.0) + dialog._on_add_or_update() + + # Should still have same number of entries + entries = fresh_db.time_log_for_date(_today()) + assert len(entries) == 1 + assert entries[0][0] == original_id + + +def test_db_time_log_sorting_by_project_activity_id(fresh_db): + """time_log_for_date returns entries sorted correctly.""" + # Create in specific order to test sorting + proj_z = fresh_db.add_project("ZZZ") + proj_a = fresh_db.add_project("AAA") + act_y = fresh_db.add_activity("YYY") + act_x = fresh_db.add_activity("XXX") + + # Add in reverse alphabetical order + fresh_db.add_time_log(_today(), proj_z, act_y, 10) + fresh_db.add_time_log(_today(), proj_z, act_x, 20) + fresh_db.add_time_log(_today(), proj_a, act_y, 30) + fresh_db.add_time_log(_today(), proj_a, act_x, 40) + + entries = fresh_db.time_log_for_date(_today()) + + # Should be sorted: AAA/XXX, AAA/YYY, ZZZ/XXX, ZZZ/YYY + assert entries[0][3] == "AAA" and entries[0][5] == "XXX" + assert entries[1][3] == "AAA" and entries[1][5] == "YYY" + assert entries[2][3] == "ZZZ" and entries[2][5] == "XXX" + assert entries[3][3] == "ZZZ" and entries[3][5] == "YYY" + + +def test_time_code_manager_add_activity_invalid_shows_warning( + qtbot, fresh_db, monkeypatch +): + """Test adding invalid activity shows warning.""" + strings.load_strings("en") + + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + dialog.show() + + def mock_get_text(parent, title, label, mode, default): + return "Valid Name", True + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + + # Force add_activity to raise ValueError + original_add = fresh_db.add_activity + + def bad_add(name): + raise ValueError("empty activity name") + + fresh_db.add_activity = bad_add + + warning_shown = {"shown": False} + + def mock_warning(*args): + warning_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "warning", mock_warning) + + dialog._add_activity() + + assert warning_shown["shown"] + + fresh_db.add_activity = original_add + + +def test_time_code_manager_rename_activity_to_duplicate(qtbot, fresh_db, monkeypatch): + """Test renaming activity to existing name shows warning.""" + strings.load_strings("en") + + fresh_db.add_activity("Activity1") + fresh_db.add_activity("Activity2") + + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + dialog.show() + + dialog.activity_list.setCurrentRow(0) + + def mock_get_text(parent, title, label, mode, default): + return "Activity2", True + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + + warning_shown = {"shown": False} + + def mock_warning(*args): + warning_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "warning", mock_warning) + + dialog._rename_activity() + + assert warning_shown["shown"] + + +def test_time_code_manager_rename_activity_cancelled(qtbot, fresh_db, monkeypatch): + """Test cancelling activity rename.""" + strings.load_strings("en") + + fresh_db.add_activity("Original") + + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + dialog.show() + + dialog.activity_list.setCurrentRow(0) + + def mock_get_text(parent, title, label, mode, default): + return "", False + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + + dialog._rename_activity() + + # Should remain unchanged + assert dialog.activity_list.item(0).text() == "Original" + + +def test_time_code_manager_rename_activity_same_name(qtbot, fresh_db, monkeypatch): + """Test renaming activity to same name does nothing.""" + strings.load_strings("en") + + fresh_db.add_activity("SameName") + + dialog = TimeCodeManagerDialog(fresh_db) + qtbot.addWidget(dialog) + dialog.show() + + dialog.activity_list.setCurrentRow(0) + + def mock_get_text(parent, title, label, mode, default): + return "SameName", True + + monkeypatch.setattr(QInputDialog, "getText", mock_get_text) + + dialog._rename_activity() + + # Should still have one activity + assert dialog.activity_list.count() == 1 + + +def test_time_report_dialog_pdf_export_error_handling( + qtbot, fresh_db, tmp_path, monkeypatch +): + """Test PDF export handles exceptions gracefully.""" + strings.load_strings("en") + + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + fresh_db.add_time_log(_today(), proj_id, act_id, 60) + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + dialog.show() + + dialog.project_combo.setCurrentIndex(0) + from PySide6.QtCore import QDate + + dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + + dialog._run_report() + + pdf_file = str(tmp_path / "test.pdf") + + def mock_get_save_filename(*args, **kwargs): + return pdf_file, "PDF Files (*.pdf)" + + monkeypatch.setattr( + "PySide6.QtWidgets.QFileDialog.getSaveFileName", mock_get_save_filename + ) + + # Mock QTextDocument.print_ to raise exception + from PySide6.QtGui import QTextDocument + + original_print = QTextDocument.print_ + + def bad_print(self, printer): + raise Exception("Print error") + + monkeypatch.setattr(QTextDocument, "print_", bad_print) + + warning_shown = {"shown": False} + + def mock_warning(*args): + warning_shown["shown"] = True + + monkeypatch.setattr(QMessageBox, "warning", mock_warning) + + dialog._export_pdf() + + # Should show warning + assert warning_shown["shown"] + + monkeypatch.setattr(QTextDocument, "print_", original_print) + + +def test_time_log_dialog_hours_conversion_edge_cases(qtbot, fresh_db): + """Test edge cases in hours to minutes conversion.""" + strings.load_strings("en") + + fresh_db.add_project("Project") + + dialog = TimeLogDialog(fresh_db, _today()) + qtbot.addWidget(dialog) + dialog.show() + + dialog.project_combo.setCurrentIndex(0) + dialog.activity_edit.setText("Activity") + + # Test 0 hours + dialog.hours_spin.setValue(0.0) + dialog._on_add_or_update() + + entries = fresh_db.time_log_for_date(_today()) + assert entries[-1][6] == 0 + + # Test fractional hours that round + dialog.hours_spin.setValue(0.333) # ~20 minutes + dialog._on_add_or_update() + + entries = fresh_db.time_log_for_date(_today()) + # Should round to nearest minute + assert 19 <= entries[-1][6] <= 21 + + +def test_time_report_dialog_pdf_with_no_activity_data( + qtbot, fresh_db, tmp_path, monkeypatch +): + """Test PDF export with report that has no data in bars (0 minutes).""" + strings.load_strings("en") + + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + + # Add entry with 0 minutes + fresh_db.add_time_log(_today(), proj_id, act_id, 0) + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + dialog.show() + + dialog.project_combo.setCurrentIndex(0) + from PySide6.QtCore import QDate + + dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + + dialog._run_report() + + pdf_file = str(tmp_path / "zero_data.pdf") + + def mock_get_save_filename(*args, **kwargs): + return pdf_file, "PDF Files (*.pdf)" + + monkeypatch.setattr( + "PySide6.QtWidgets.QFileDialog.getSaveFileName", mock_get_save_filename + ) + + # Should handle zero data without crashing + dialog._export_pdf() + + import os + + assert os.path.exists(pdf_file) + + +def test_time_report_dialog_very_large_hours(qtbot, fresh_db): + """Test handling very large hour values.""" + strings.load_strings("en") + + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + + # Add entry with many hours + large_minutes = 10000 # ~166 hours + fresh_db.add_time_log(_today(), proj_id, act_id, large_minutes) + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + dialog.show() + + dialog.project_combo.setCurrentIndex(0) + from PySide6.QtCore import QDate + + dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) + + # Should handle large values + dialog._run_report() + + # Check total label + assert "166" in dialog.total_label.text() or "167" in dialog.total_label.text() diff --git a/vulture_ignorelist.py b/vulture_ignorelist.py index b1ec549..c4ea333 100644 --- a/vulture_ignorelist.py +++ b/vulture_ignorelist.py @@ -3,15 +3,11 @@ from bouquin.flow_layout import FlowLayout from bouquin.markdown_editor import MarkdownEditor from bouquin.markdown_highlighter import MarkdownHighlighter from bouquin.statistics_dialog import DateHeatMap -from bouquin.tag_graph_dialog import DraggableGraphItem DBManager.row_factory DateHeatMap.minimumSizeHint -DraggableGraphItem.hoverEvent -DraggableGraphItem.mouseDragEvent - FlowLayout.itemAt FlowLayout.expandingDirections FlowLayout.hasHeightForWidth