from __future__ import annotations from pathlib import Path import shutil import sys import unittest from unittest.mock import patch import uuid from types import SimpleNamespace sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) from project_editor import ( # noqa: E402 ProjectEditorError, ProjectSourceRegistry, check_devlog_readiness, corrective_guidance, ) from management_launcher import EDITOR_SCRIPTS # noqa: E402 from project_providers import ProjectProviderError, derive_repository_urls # noqa: E402 class FakeProjectSourceError(RuntimeError): pass class FakeRepositoryNotPublicError(FakeProjectSourceError): pass def fake_importer(**overrides: object) -> SimpleNamespace: values: dict[str, object] = { "ProjectSourceError": FakeProjectSourceError, "RepositoryNotPublicError": FakeRepositoryNotPublicError, "fetch_json": lambda *_args, **_kwargs: {"private": False, "default_branch": "main"}, "ensure_mirror": lambda *_args, **_kwargs: (Path("mirror.git"), True), "resolve_branch": lambda *_args, **_kwargs: "a" * 40, "validate_snapshot": lambda *_args, **_kwargs: ({}, {}), } values.update(overrides) return SimpleNamespace(**values) class ProjectEditorTests(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"project-editor-test-{uuid.uuid4().hex}" (self.site_root / "configs").mkdir(parents=True) (self.site_root / "content" / "projects").mkdir(parents=True) (self.site_root / "Test.lektorproject").write_text("[project]\nname = Test\n", encoding="utf-8") (self.site_root / "content" / "projects" / "contents.lr").write_text( "_model: section\n---\ntitle: Projects\n", encoding="utf-8" ) (self.site_root / "configs" / "project-sources.ini").write_text( "# Preserve this header.\n\n" "[alpha]\n" "repository = https://git.example.test/team/alpha.git\n" "web_url = https://git.example.test/team/alpha\n" "api_url = https://git.example.test/api/v1/repos/team/alpha\n" "branch = main\n" "featured_order = 10\n" "future_option = preserve me\n\n" "[beta]\n" "repository = https://git.example.test/team/beta.git\n" "web_url = https://git.example.test/team/beta\n" "api_url = https://git.example.test/api/v1/repos/team/beta\n" "branch = stable\n", encoding="utf-8", ) self.registry = ProjectSourceRegistry(self.site_root) def tearDown(self) -> None: shutil.rmtree(self.site_root) def test_current_registry_loads(self) -> None: registry = ProjectSourceRegistry(Path(__file__).resolve().parents[1]) sources = registry.list_sources() self.assertGreaterEqual(len(sources), 1) self.assertTrue(all(source.repository.startswith("https://") for source in sources)) def test_management_launcher_exposes_project_editor_as_required_script(self) -> None: self.assertIn(("Project Editor", "project_editor.py", True), EDITOR_SCRIPTS) def test_create_edit_and_delete_preserve_other_configuration(self) -> None: created = self.registry.create_source( "gamma", "https://git.example.test/team/gamma", "main", "", ) alpha_before = self.registry.load_source("alpha") saved = self.registry.save_source( created, created.web_url, "release", "30", ) self.registry.delete_source(saved) text = (self.site_root / "configs" / "project-sources.ini").read_text(encoding="utf-8") self.assertIn("# Preserve this header.", text) self.assertIn("future_option = preserve me", text) self.assertIn("[beta]", text) self.assertNotIn("[gamma]", text) self.assertEqual(self.registry.load_source("alpha").repository, alpha_before.repository) def test_edit_preserves_unknown_options_and_detects_external_change(self) -> None: source = self.registry.load_source("alpha") saved = self.registry.save_source( source, source.web_url, "release", "11" ) self.assertEqual(dict(saved.unknown_options)["future_option"], "preserve me") with (self.site_root / "configs" / "project-sources.ini").open("a", encoding="utf-8") as output: output.write("\n# external change\n") with self.assertRaisesRegex(ProjectEditorError, "changed on disk"): self.registry.save_source( saved, saved.web_url, saved.branch, "11" ) def test_delete_never_touches_external_repository_and_refuses_last_source(self) -> None: beta = self.registry.load_source("beta") self.registry.delete_source(beta) alpha = self.registry.load_source("alpha") with self.assertRaisesRegex(ProjectEditorError, "at least one"): self.registry.delete_source(alpha) def test_invalid_urls_and_ids_are_rejected(self) -> None: with self.assertRaises(ProjectEditorError): self.registry.create_source( "Not Valid", "http://example.test/team/repo", "main", "" ) def test_one_public_url_derives_github_and_gitea_configuration(self) -> None: github = derive_repository_urls("https://github.com/example/public-project.git") self.assertEqual(github.provider, "github") self.assertEqual(github.web_url, "https://github.com/example/public-project") self.assertEqual(github.repository, f"{github.web_url}.git") self.assertEqual( github.api_url, "https://api.github.com/repos/example/public-project" ) gitea = derive_repository_urls("https://git.example.test/team/project") self.assertEqual(gitea.provider, "gitea") self.assertEqual(gitea.repository, "https://git.example.test/team/project.git") self.assertEqual( gitea.api_url, "https://git.example.test/api/v1/repos/team/project" ) saved = self.registry.create_source( "github-project", "https://github.com/example/public-project", "main", "20", ) self.assertEqual(saved.repository, github.repository) self.assertEqual(saved.web_url, github.web_url) self.assertEqual(saved.api_url, github.api_url) def test_gitlab_url_is_rejected_clearly(self) -> None: with self.assertRaisesRegex(ProjectProviderError, "GitLab.*not supported"): derive_repository_urls("https://gitlab.com/example/project") @patch("project_editor.shutil.which", return_value="git") @patch("project_editor.tempfile.TemporaryDirectory") def test_inaccessible_repository_is_not_reported_as_missing_devlog( self, temporary: object, _which: object ) -> None: temporary.return_value.__enter__.return_value = str(self.site_root / "temporary") def fail_fetch(*_args: object, **_kwargs: object) -> object: raise FakeProjectSourceError("initial repository fetch failed") importer = fake_importer(ensure_mirror=fail_fetch) result = check_devlog_readiness(self.registry.load_source("alpha"), importer) self.assertEqual(result.status, "Repository inaccessible") self.assertNotIn("Initialize Devlog", result.details) @patch("project_editor.shutil.which", return_value="git") @patch("project_editor.tempfile.TemporaryDirectory") def test_missing_devlog_has_corrective_standalone_editor_guidance( self, temporary: object, _which: object ) -> None: temporary.return_value.__enter__.return_value = str(self.site_root / "temporary") def fail_validation(*_args: object, **_kwargs: object) -> object: raise FakeProjectSourceError( "publishing tree is missing ['.labyricorn/devlog/contents.lr']" ) importer = fake_importer(validate_snapshot=fail_validation) source = self.registry.load_source("alpha") result = check_devlog_readiness(source, importer) self.assertEqual(result.status, "Not initialized") guidance = corrective_guidance(source) for phrase in ( "git clone", "Copy scripts/devlog_editor.py", "python devlog_editor.py", "Initialize Devlog", "Create and save", "Publish Labyricorn Changes", "commit and push", "Check Devlog Status", ): self.assertIn(phrase, guidance) @patch("project_editor.shutil.which", return_value="git") @patch("project_editor.tempfile.TemporaryDirectory") def test_valid_remote_snapshot_reports_ready( self, temporary: object, _which: object ) -> None: temporary.return_value.__enter__.return_value = str(self.site_root / "temporary") records = { ".labyricorn/project/contents.lr": {"title": "Alpha", "project_id": "alpha"}, ".labyricorn/devlog/contents.lr": {"title": "Alpha devlog"}, } importer = fake_importer( resolve_branch=lambda *_args: "b" * 40, validate_snapshot=lambda *_args: ({}, records), ) result = check_devlog_readiness(self.registry.load_source("alpha"), importer) self.assertTrue(result.ready) self.assertEqual(result.status, "Ready") self.assertIn("Valid devlog entries: 0", result.details) if __name__ == "__main__": unittest.main()