remove time graph visualiser. More tests. Other fixes
This commit is contained in:
parent
0b3249c7ef
commit
985541a1d8
18 changed files with 4087 additions and 971 deletions
|
|
@ -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"]
|
||||
|
|
|
|||
335
tests/test_db.py
335
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 <b>bold</b> and <i>italic</i> text with <div>divs</div>"
|
||||
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
|
||||
|
||||
<p>HTML paragraph</p>
|
||||
"""
|
||||
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 "<p>" 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
2558
tests/test_time_log.py
Normal file
2558
tests/test_time_log.py
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue