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("![Test cover](cover-image.png)\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("![Second](cover-image.jpg)\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()