202 lines
9.0 KiB
Python
202 lines
9.0 KiB
Python
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_tracked_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), 40)
|
||
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_freeform_tags_are_normalized_deduplicated_and_do_not_create_records(self) -> None:
|
||
entry = self.repository.create_entry(
|
||
"test-entry", self.values(tags="writing, Weird Experiment, weird-experiment")
|
||
)
|
||
|
||
self.assertEqual(entry.document.get("tags"), "writing, weird-experiment")
|
||
self.assertFalse((self.site_root / "content" / "tags" / "weird-experiment").exists())
|
||
|
||
def test_loading_and_saving_preserves_tracked_and_freeform_tags(self) -> None:
|
||
entry = self.repository.create_entry(
|
||
"test-entry", self.values(tags="writing, retrocomputing")
|
||
)
|
||
values = self.repository.values_for(entry.document)
|
||
values["summary"] = "Edited without changing tags."
|
||
saved = self.repository.save_entry(entry, values)
|
||
|
||
self.assertEqual(saved.document.get("tags"), "writing, retrocomputing")
|
||
|
||
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()
|