This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
import unittest
|
||||
import uuid
|
||||
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
||||
|
||||
from article_editor import ArticleEditorError, ArticleRepository, suggest_slug # noqa: E402
|
||||
|
||||
|
||||
class ArticleEditorTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
cache_root = Path(__file__).resolve().parents[1] / ".cache"
|
||||
cache_root.mkdir(exist_ok=True)
|
||||
self.site_root = cache_root / f"article-editor-test-{uuid.uuid4().hex}"
|
||||
(self.site_root / "content" / "articles").mkdir(parents=True)
|
||||
(self.site_root / "content" / "tags" / "writing").mkdir(parents=True)
|
||||
(self.site_root / "models").mkdir()
|
||||
(self.site_root / "Test.lektorproject").write_text(
|
||||
"[project]\nname = Test\n", encoding="utf-8"
|
||||
)
|
||||
(self.site_root / "content" / "articles" / "contents.lr").write_text(
|
||||
"_model: section\n---\ntitle: Articles\n", encoding="utf-8"
|
||||
)
|
||||
(self.site_root / "content" / "tags" / "writing" / "contents.lr").write_text(
|
||||
"_model: tag\n---\ntitle: Writing\n---\nsummary: Writing.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
source_model = Path(__file__).resolve().parents[1] / "models" / "entry.ini"
|
||||
shutil.copy2(source_model, self.site_root / "models" / "entry.ini")
|
||||
self.repository = ArticleRepository(self.site_root)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
shutil.rmtree(self.site_root)
|
||||
|
||||
@staticmethod
|
||||
def values(**overrides: str) -> dict[str, str]:
|
||||
values = {
|
||||
"title": "Test Entry",
|
||||
"date": "2026-08-13",
|
||||
"updated": "",
|
||||
"author": "Test Author",
|
||||
"tags": "writing",
|
||||
"kicker": "Article",
|
||||
"summary": "A test summary.",
|
||||
"body": "A **Markdown** body.",
|
||||
"external_url": "",
|
||||
"published_urls": "https://example.test/post",
|
||||
}
|
||||
values.update(overrides)
|
||||
return values
|
||||
|
||||
def test_current_site_article_records_and_controlled_tags_load(self) -> None:
|
||||
repository = ArticleRepository(Path(__file__).resolve().parents[1])
|
||||
entries = repository.list_entries()
|
||||
self.assertGreaterEqual(len(entries), 7)
|
||||
self.assertGreaterEqual(len(repository.tags), 45)
|
||||
self.assertTrue(all(entry.document.get("kicker") == "Article" for entry in entries))
|
||||
for entry in entries:
|
||||
with self.subTest(slug=entry.slug):
|
||||
self.assertEqual(
|
||||
entry.document.render().encode("utf-8"), entry.original_bytes
|
||||
)
|
||||
|
||||
def test_create_uses_existing_record_conventions_and_refuses_collision(self) -> None:
|
||||
entry = self.repository.create_entry(
|
||||
"test-entry", self.values(body=" preserved indentation\n\nBody text.")
|
||||
)
|
||||
rendered = entry.path.read_text(encoding="utf-8")
|
||||
self.assertIn("_model: entry\n---\ntitle: Test Entry\n", rendered)
|
||||
self.assertIn("tags: writing\n", rendered)
|
||||
self.assertIn(
|
||||
"published_urls:\n\nhttps://example.test/post\n---\nbody:\n",
|
||||
rendered,
|
||||
)
|
||||
with self.assertRaises(ArticleEditorError):
|
||||
self.repository.create_entry("test-entry", self.values())
|
||||
|
||||
def test_edit_preserves_unmanaged_and_unchanged_blocks_exactly(self) -> None:
|
||||
entry = self.repository.create_entry(
|
||||
"test-entry", self.values(body=" preserved indentation\n\nBody text.")
|
||||
)
|
||||
original = entry.path.read_text(encoding="utf-8")
|
||||
marker = "---\neditor_note:\nKeep this unknown field exactly. \n"
|
||||
original = original.replace("---\nbody:\n", marker + "---\nbody:\n")
|
||||
entry.path.write_text(original, encoding="utf-8")
|
||||
|
||||
loaded = self.repository.load_entry("test-entry")
|
||||
values = self.repository.values_for(loaded.document)
|
||||
values["summary"] = "An edited summary."
|
||||
saved = self.repository.save_entry(loaded, values)
|
||||
rendered = saved.path.read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn(marker, rendered)
|
||||
self.assertIn("summary: An edited summary.\n", rendered)
|
||||
self.assertIn("body:\n preserved indentation\n\nBody text.\n", rendered)
|
||||
self.assertIn("published_urls:\n\nhttps://example.test/post\n", rendered)
|
||||
|
||||
def test_save_detects_an_external_change(self) -> None:
|
||||
entry = self.repository.create_entry("test-entry", self.values())
|
||||
with entry.path.open("a", encoding="utf-8") as output:
|
||||
output.write("\n")
|
||||
with self.assertRaisesRegex(ArticleEditorError, "changed on disk"):
|
||||
self.repository.save_entry(entry, self.values(summary="Changed"))
|
||||
|
||||
def test_delete_removes_only_the_loaded_entry_directory(self) -> None:
|
||||
entry = self.repository.create_entry("test-entry", self.values())
|
||||
attachment = entry.path.parent / "narration.mp3"
|
||||
attachment.write_bytes(b"disposable attachment")
|
||||
neighbor = self.repository.create_entry("neighbor-entry", self.values())
|
||||
|
||||
self.repository.delete_entry(entry)
|
||||
|
||||
self.assertFalse(entry.path.parent.exists())
|
||||
self.assertTrue(neighbor.path.is_file())
|
||||
|
||||
def test_delete_detects_an_external_change(self) -> None:
|
||||
entry = self.repository.create_entry("test-entry", self.values())
|
||||
with entry.path.open("a", encoding="utf-8") as output:
|
||||
output.write("\n")
|
||||
with self.assertRaisesRegex(ArticleEditorError, "changed on disk"):
|
||||
self.repository.delete_entry(entry)
|
||||
self.assertTrue(entry.path.is_file())
|
||||
|
||||
def test_unapproved_tag_is_rejected(self) -> None:
|
||||
with self.assertRaisesRegex(ArticleEditorError, "unapproved tag"):
|
||||
self.repository.create_entry("test-entry", self.values(tags="invented"))
|
||||
|
||||
def test_cover_image_is_copied_renamed_referenced_and_reopened(self) -> None:
|
||||
entry = self.repository.create_entry("test-entry", self.values())
|
||||
source = self.site_root / "source.png"
|
||||
source.write_bytes(b"disposable png bytes")
|
||||
|
||||
saved, cover = self.repository.add_or_replace_cover(
|
||||
entry, source, "Test cover", replace=False
|
||||
)
|
||||
|
||||
self.assertEqual(cover.name, "cover-image.png")
|
||||
self.assertEqual(cover.path.read_bytes(), source.read_bytes())
|
||||
self.assertTrue(saved.document.get("body").startswith("\n\n"))
|
||||
reopened = self.repository.load_entry("test-entry")
|
||||
self.assertEqual(self.repository.cover_image(reopened), cover)
|
||||
|
||||
def test_cover_replacement_requires_confirmation_and_removes_old_cover(self) -> None:
|
||||
entry = self.repository.create_entry("test-entry", self.values())
|
||||
first = self.site_root / "first.png"
|
||||
second = self.site_root / "second.jpg"
|
||||
first.write_bytes(b"first")
|
||||
second.write_bytes(b"second")
|
||||
saved, _cover = self.repository.add_or_replace_cover(
|
||||
entry, first, "First", replace=False
|
||||
)
|
||||
with self.assertRaisesRegex(ArticleEditorError, "confirm replacement"):
|
||||
self.repository.add_or_replace_cover(saved, second, "Second", replace=False)
|
||||
|
||||
replaced, cover = self.repository.add_or_replace_cover(
|
||||
saved, second, "Second", replace=True
|
||||
)
|
||||
self.assertEqual(cover.name, "cover-image.jpg")
|
||||
self.assertFalse((replaced.path.parent / "cover-image.png").exists())
|
||||
self.assertTrue((replaced.path.parent / "cover-image.jpg").is_file())
|
||||
self.assertTrue(replaced.document.get("body").startswith("\n\n"))
|
||||
|
||||
def test_remove_cover_removes_reference_and_article_local_file(self) -> None:
|
||||
entry = self.repository.create_entry("test-entry", self.values())
|
||||
source = self.site_root / "source.png"
|
||||
source.write_bytes(b"cover")
|
||||
saved, cover = self.repository.add_or_replace_cover(
|
||||
entry, source, "Test cover", replace=False
|
||||
)
|
||||
|
||||
without_cover = self.repository.remove_cover(saved, remove_file=True)
|
||||
|
||||
self.assertIsNone(self.repository.cover_image(without_cover))
|
||||
self.assertFalse(cover.path.exists())
|
||||
self.assertEqual(without_cover.document.get("body"), self.values()["body"])
|
||||
|
||||
def test_slug_suggestion_matches_repository_style(self) -> None:
|
||||
self.assertEqual(suggest_slug("We’re Testing: A GUI!"), "were-testing-a-gui")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,138 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
import unittest
|
||||
import uuid
|
||||
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
||||
|
||||
from blog_editor import BlogEditorError, BlogRepository, suggest_slug # noqa: E402
|
||||
|
||||
|
||||
class BlogEditorTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
cache_root = Path(__file__).resolve().parents[1] / ".cache"
|
||||
cache_root.mkdir(exist_ok=True)
|
||||
self.site_root = cache_root / f"blog-editor-test-{uuid.uuid4().hex}"
|
||||
(self.site_root / "content" / "blog").mkdir(parents=True)
|
||||
(self.site_root / "content" / "tags" / "writing").mkdir(parents=True)
|
||||
(self.site_root / "models").mkdir()
|
||||
(self.site_root / "Test.lektorproject").write_text(
|
||||
"[project]\nname = Test\n", encoding="utf-8"
|
||||
)
|
||||
(self.site_root / "content" / "blog" / "contents.lr").write_text(
|
||||
"_model: section\n---\ntitle: Blog\n", encoding="utf-8"
|
||||
)
|
||||
(self.site_root / "content" / "tags" / "writing" / "contents.lr").write_text(
|
||||
"_model: tag\n---\ntitle: Writing\n---\nsummary: Writing.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
source_model = Path(__file__).resolve().parents[1] / "models" / "entry.ini"
|
||||
shutil.copy2(source_model, self.site_root / "models" / "entry.ini")
|
||||
self.repository = BlogRepository(self.site_root)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
shutil.rmtree(self.site_root)
|
||||
|
||||
@staticmethod
|
||||
def values(**overrides: str) -> dict[str, str]:
|
||||
values = {
|
||||
"title": "Test Entry",
|
||||
"date": "2026-08-13",
|
||||
"updated": "",
|
||||
"author": "Test Author",
|
||||
"tags": "writing",
|
||||
"kicker": "Blog",
|
||||
"summary": "A test summary.",
|
||||
"body": "A **Markdown** body.",
|
||||
"external_url": "",
|
||||
"published_urls": "https://example.test/post",
|
||||
}
|
||||
values.update(overrides)
|
||||
return values
|
||||
|
||||
def test_current_site_blog_records_and_controlled_tags_load(self) -> None:
|
||||
repository = BlogRepository(Path(__file__).resolve().parents[1])
|
||||
entries = repository.list_entries()
|
||||
self.assertGreaterEqual(len(entries), 7)
|
||||
self.assertGreaterEqual(len(repository.tags), 45)
|
||||
self.assertTrue(all(entry.document.get("kicker") == "Blog" for entry in entries))
|
||||
for entry in entries:
|
||||
with self.subTest(slug=entry.slug):
|
||||
self.assertEqual(
|
||||
entry.document.render().encode("utf-8"), entry.original_bytes
|
||||
)
|
||||
|
||||
def test_create_uses_existing_record_conventions_and_refuses_collision(self) -> None:
|
||||
entry = self.repository.create_entry(
|
||||
"test-entry", self.values(body=" preserved indentation\n\nBody text.")
|
||||
)
|
||||
rendered = entry.path.read_text(encoding="utf-8")
|
||||
self.assertIn("_model: entry\n---\ntitle: Test Entry\n", rendered)
|
||||
self.assertIn("tags: writing\n", rendered)
|
||||
self.assertIn(
|
||||
"published_urls:\n\nhttps://example.test/post\n---\nbody:\n",
|
||||
rendered,
|
||||
)
|
||||
with self.assertRaises(BlogEditorError):
|
||||
self.repository.create_entry("test-entry", self.values())
|
||||
|
||||
def test_edit_preserves_unmanaged_and_unchanged_blocks_exactly(self) -> None:
|
||||
entry = self.repository.create_entry(
|
||||
"test-entry", self.values(body=" preserved indentation\n\nBody text.")
|
||||
)
|
||||
original = entry.path.read_text(encoding="utf-8")
|
||||
marker = "---\neditor_note:\nKeep this unknown field exactly. \n"
|
||||
original = original.replace("---\nbody:\n", marker + "---\nbody:\n")
|
||||
entry.path.write_text(original, encoding="utf-8")
|
||||
|
||||
loaded = self.repository.load_entry("test-entry")
|
||||
values = self.repository.values_for(loaded.document)
|
||||
values["summary"] = "An edited summary."
|
||||
saved = self.repository.save_entry(loaded, values)
|
||||
rendered = saved.path.read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn(marker, rendered)
|
||||
self.assertIn("summary: An edited summary.\n", rendered)
|
||||
self.assertIn("body:\n preserved indentation\n\nBody text.\n", rendered)
|
||||
self.assertIn("published_urls:\n\nhttps://example.test/post\n", rendered)
|
||||
|
||||
def test_save_detects_an_external_change(self) -> None:
|
||||
entry = self.repository.create_entry("test-entry", self.values())
|
||||
with entry.path.open("a", encoding="utf-8") as output:
|
||||
output.write("\n")
|
||||
with self.assertRaisesRegex(BlogEditorError, "changed on disk"):
|
||||
self.repository.save_entry(entry, self.values(summary="Changed"))
|
||||
|
||||
def test_delete_removes_only_the_loaded_entry_directory(self) -> None:
|
||||
entry = self.repository.create_entry("test-entry", self.values())
|
||||
attachment = entry.path.parent / "narration.mp3"
|
||||
attachment.write_bytes(b"disposable attachment")
|
||||
neighbor = self.repository.create_entry("neighbor-entry", self.values())
|
||||
|
||||
self.repository.delete_entry(entry)
|
||||
|
||||
self.assertFalse(entry.path.parent.exists())
|
||||
self.assertTrue(neighbor.path.is_file())
|
||||
|
||||
def test_delete_detects_an_external_change(self) -> None:
|
||||
entry = self.repository.create_entry("test-entry", self.values())
|
||||
with entry.path.open("a", encoding="utf-8") as output:
|
||||
output.write("\n")
|
||||
with self.assertRaisesRegex(BlogEditorError, "changed on disk"):
|
||||
self.repository.delete_entry(entry)
|
||||
self.assertTrue(entry.path.is_file())
|
||||
|
||||
def test_unapproved_tag_is_rejected(self) -> None:
|
||||
with self.assertRaisesRegex(BlogEditorError, "unapproved tag"):
|
||||
self.repository.create_entry("test-entry", self.values(tags="invented"))
|
||||
|
||||
def test_slug_suggestion_matches_repository_style(self) -> None:
|
||||
self.assertEqual(suggest_slug("We’re Testing: A GUI!"), "were-testing-a-gui")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,91 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
||||
|
||||
from narration_import import NarrationImportError, import_wav, remove_narration # noqa: E402
|
||||
|
||||
|
||||
class NarrationImportTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
cache_root = Path(__file__).resolve().parents[1] / ".cache"
|
||||
cache_root.mkdir(exist_ok=True)
|
||||
self.directory = cache_root / f"narration-import-test-{uuid.uuid4().hex}"
|
||||
self.directory.mkdir()
|
||||
self.source = self.directory / "source.wav"
|
||||
self.source.write_bytes(b"disposable wav data")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
shutil.rmtree(self.directory)
|
||||
|
||||
def test_success_uses_fixed_preset_and_replaces_only_after_temp_output(self) -> None:
|
||||
destination = self.directory / "narration.mp3"
|
||||
destination.write_bytes(b"old narration")
|
||||
|
||||
def successful_ffmpeg(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
Path(command[-1]).write_bytes(b"converted mp3")
|
||||
return subprocess.CompletedProcess(command, 0, "", "")
|
||||
|
||||
with mock.patch("narration_import.subprocess.run", side_effect=successful_ffmpeg) as run:
|
||||
result = import_wav(self.directory, self.source, ffmpeg="ffmpeg")
|
||||
|
||||
self.assertEqual(result, destination)
|
||||
self.assertEqual(destination.read_bytes(), b"converted mp3")
|
||||
command = run.call_args.args[0]
|
||||
self.assertEqual(
|
||||
command[1:-1],
|
||||
["-y", "-i", str(self.source), "-vn", "-ar", "44100", "-ac", "1",
|
||||
"-c:a", "libmp3lame", "-b:a", "96k"],
|
||||
)
|
||||
self.assertFalse(any(self.directory.glob(".narration-*.mp3")))
|
||||
|
||||
def test_failed_conversion_preserves_existing_narration(self) -> None:
|
||||
destination = self.directory / "narration.mp3"
|
||||
destination.write_bytes(b"old narration")
|
||||
failed = subprocess.CompletedProcess(["ffmpeg"], 1, "", "bad WAV")
|
||||
|
||||
with mock.patch("narration_import.subprocess.run", return_value=failed):
|
||||
with self.assertRaisesRegex(NarrationImportError, "bad WAV"):
|
||||
import_wav(self.directory, self.source, ffmpeg="ffmpeg")
|
||||
|
||||
self.assertEqual(destination.read_bytes(), b"old narration")
|
||||
self.assertFalse(any(self.directory.glob(".narration-*.mp3")))
|
||||
|
||||
def test_missing_ffmpeg_and_removal_are_safe(self) -> None:
|
||||
with mock.patch("narration_import.shutil.which", return_value=None):
|
||||
with self.assertRaisesRegex(NarrationImportError, "ffmpeg is required"):
|
||||
import_wav(self.directory, self.source)
|
||||
unrelated = self.directory / "other-attachment.txt"
|
||||
unrelated.write_text("keep", encoding="utf-8")
|
||||
narration = self.directory / "narration.mp3"
|
||||
narration.write_bytes(b"narration")
|
||||
self.assertTrue(remove_narration(self.directory))
|
||||
self.assertFalse(narration.exists())
|
||||
self.assertEqual(unrelated.read_text(encoding="utf-8"), "keep")
|
||||
|
||||
def test_repository_fallback_is_used_when_ffmpeg_is_not_on_path(self) -> None:
|
||||
fallback = self.directory / "ffmpeg.exe"
|
||||
fallback.write_bytes(b"placeholder executable")
|
||||
|
||||
def successful_ffmpeg(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
Path(command[-1]).write_bytes(b"converted mp3")
|
||||
return subprocess.CompletedProcess(command, 0, "", "")
|
||||
|
||||
with mock.patch("narration_import.shutil.which", return_value=None), mock.patch(
|
||||
"narration_import.subprocess.run", side_effect=successful_ffmpeg
|
||||
) as run:
|
||||
import_wav(self.directory, self.source, fallback_ffmpeg=fallback)
|
||||
|
||||
self.assertEqual(run.call_args.args[0][0], str(fallback))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user