Update projektu
This commit is contained in:
180
skills/bookmark/tests/test_bookmark.py
Normal file
180
skills/bookmark/tests/test_bookmark.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""Tests for bookmark.py — reading-list CRUD, article-content storage, migration."""
|
||||
|
||||
import io
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parent.parent / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
import bookmark # noqa: E402
|
||||
|
||||
HTML_TO_MARKDOWN = SCRIPTS / "html_to_markdown.py"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path, monkeypatch):
|
||||
path = tmp_path / "db" / "bookmark.sqlite"
|
||||
monkeypatch.setattr(bookmark, "DB_PATH", path)
|
||||
return path
|
||||
|
||||
|
||||
def _run(argv, stdin=None, monkeypatch=None):
|
||||
monkeypatch.setattr(sys, "argv", ["bookmark.py"] + argv)
|
||||
if stdin is not None:
|
||||
monkeypatch.setattr(sys, "stdin", io.StringIO(stdin))
|
||||
bookmark.main()
|
||||
|
||||
|
||||
def _capture(capsys):
|
||||
return capsys.readouterr().out
|
||||
|
||||
|
||||
def test_add_stores_content_from_stdin(db, monkeypatch, capsys):
|
||||
_run(["add", "https://ex.com/a", "Test", "--content-file", "-"],
|
||||
stdin="# Title\n\nBody paragraph.\n", monkeypatch=monkeypatch)
|
||||
assert "article content stored" in _capture(capsys)
|
||||
|
||||
conn = sqlite3.connect(db)
|
||||
content = conn.execute("SELECT content FROM bookmarks").fetchone()[0]
|
||||
conn.close()
|
||||
assert content == "# Title\n\nBody paragraph."
|
||||
|
||||
|
||||
def test_add_without_content_leaves_null(db, monkeypatch, capsys):
|
||||
_run(["add", "https://ex.com/b", "No content"], monkeypatch=monkeypatch)
|
||||
conn = sqlite3.connect(db)
|
||||
content = conn.execute("SELECT content FROM bookmarks").fetchone()[0]
|
||||
conn.close()
|
||||
assert content is None
|
||||
|
||||
|
||||
def test_content_command_prints_stored_markdown(db, monkeypatch, capsys):
|
||||
_run(["add", "https://ex.com/a", "Test", "--content-file", "-"],
|
||||
stdin="# Title\n\nBody.\n", monkeypatch=monkeypatch)
|
||||
capsys.readouterr()
|
||||
_run(["content", "1"], monkeypatch=monkeypatch)
|
||||
assert "# Title\n\nBody." in _capture(capsys)
|
||||
|
||||
|
||||
def test_content_command_reports_empty(db, monkeypatch, capsys):
|
||||
_run(["add", "https://ex.com/b", "No content"], monkeypatch=monkeypatch)
|
||||
capsys.readouterr()
|
||||
_run(["content", "1"], monkeypatch=monkeypatch)
|
||||
assert "no stored article content" in _capture(capsys)
|
||||
|
||||
|
||||
def test_list_marks_only_rows_with_content(db, monkeypatch, capsys):
|
||||
_run(["add", "https://ex.com/plain", "Plain"], monkeypatch=monkeypatch)
|
||||
_run(["add", "https://ex.com/full", "Full", "--content-file", "-"],
|
||||
stdin="# Doc\n\ntext\n", monkeypatch=monkeypatch)
|
||||
capsys.readouterr()
|
||||
_run(["list"], monkeypatch=monkeypatch)
|
||||
out = _capture(capsys)
|
||||
# Newest first: #1 is the one WITH content (has 📄), #2 plain (no marker).
|
||||
lines = [ln for ln in out.splitlines() if ln.startswith("#")]
|
||||
assert "📄" in lines[0]
|
||||
assert "📄" not in lines[1]
|
||||
|
||||
|
||||
def test_migration_adds_columns_to_legacy_db(db, monkeypatch, capsys):
|
||||
db.parent.mkdir(parents=True)
|
||||
conn = sqlite3.connect(db)
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE bookmarks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
url TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL,
|
||||
read_at TEXT
|
||||
);
|
||||
INSERT INTO bookmarks (url, description, tags, created_at)
|
||||
VALUES ('https://old.com', 'legacy', '[]', '2026-01-01T00:00:00+00:00');
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
_run(["list"], monkeypatch=monkeypatch) # triggers migration
|
||||
assert "legacy" in _capture(capsys)
|
||||
|
||||
conn = sqlite3.connect(db)
|
||||
cols = {row[1] for row in conn.execute("PRAGMA table_info(bookmarks)")}
|
||||
count = conn.execute("SELECT COUNT(*) FROM bookmarks").fetchone()[0]
|
||||
conn.close()
|
||||
assert "content" in cols
|
||||
assert "deleted_at" in cols
|
||||
assert count == 1
|
||||
|
||||
|
||||
def test_delete_soft_hides_but_keeps_row(db, monkeypatch, capsys):
|
||||
_run(["add", "https://ex.com/keep", "Keep"], monkeypatch=monkeypatch)
|
||||
_run(["add", "https://ex.com/dup", "Dup"], monkeypatch=monkeypatch)
|
||||
capsys.readouterr()
|
||||
_run(["delete", "1"], monkeypatch=monkeypatch) # newest (dup) is #1
|
||||
assert "Deleted bookmark #1" in _capture(capsys)
|
||||
|
||||
_run(["list"], monkeypatch=monkeypatch)
|
||||
out = _capture(capsys)
|
||||
assert "Keep" in out
|
||||
assert "Dup" not in out
|
||||
|
||||
conn = sqlite3.connect(db)
|
||||
total = conn.execute("SELECT COUNT(*) FROM bookmarks").fetchone()[0]
|
||||
deleted = conn.execute(
|
||||
"SELECT COUNT(*) FROM bookmarks WHERE deleted_at IS NOT NULL"
|
||||
).fetchone()[0]
|
||||
conn.close()
|
||||
assert total == 2 # row kept in DB
|
||||
assert deleted == 1
|
||||
|
||||
|
||||
def test_delete_out_of_range(db, monkeypatch, capsys):
|
||||
_run(["add", "https://ex.com/a", "One"], monkeypatch=monkeypatch)
|
||||
capsys.readouterr()
|
||||
_run(["delete", "9"], monkeypatch=monkeypatch)
|
||||
assert "No unread bookmark #9" in _capture(capsys)
|
||||
|
||||
|
||||
ARTICLE_HTML = """<!DOCTYPE html><html><head><title>EFI Boot</title></head><body>
|
||||
<header><nav><a href="/">Home</a> <a href="/login">Login</a></nav></header>
|
||||
<aside class="ad">Buy our product now! Special offer!</aside>
|
||||
<main><article>
|
||||
<h1>Understanding EFI Boot</h1>
|
||||
<p>The EFI system partition stores the bootloader and kernel images that the
|
||||
firmware loads at startup, which is a crucial part of the modern boot process.</p>
|
||||
<h2>How it works</h2>
|
||||
<p>When the machine powers on, the firmware reads the EFI variables and locates
|
||||
the boot entry, then hands control to the kernel without a traditional bootloader.</p>
|
||||
</article></main>
|
||||
<footer>Copyright 2026 Example Inc. Share Tweet Subscribe to our newsletter.</footer>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
def _run_html(html_input: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
["uv", "run", str(HTML_TO_MARKDOWN)],
|
||||
input=html_input,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def test_html_to_markdown_extracts_clean_article():
|
||||
result = _run_html(ARTICLE_HTML)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "# Understanding EFI Boot" in result.stdout
|
||||
assert "## How it works" in result.stdout
|
||||
# Boilerplate must be gone.
|
||||
assert "Buy our product" not in result.stdout
|
||||
assert "Subscribe to our newsletter" not in result.stdout
|
||||
|
||||
|
||||
def test_html_to_markdown_empty_input():
|
||||
result = _run_html("")
|
||||
assert result.returncode == 1
|
||||
Reference in New Issue
Block a user