"""End-to-end tests for the ``scrivcli`` command line.""" from __future__ import annotations import subprocess import tempfile import unittest from pathlib import Path from helpers import item, make_bundle, run_cli, run_cli_bytes UUID = "11111111-2222-3333-4444-555555555555" def u(n: int) -> str: return f"00000000-0000-0000-0000-{n:012d}" SAMPLE_BINDER = ( item(u(1), "Draft", type="DraftFolder", children=( item( u(2), "Chapter One", type="Folder", inner=( "No1" "23" ), children=item(u(3), "Scene A") + item(u(4), ""), ) + item(u(5), "Scene B") )) + item(u(6), "Research", type="ResearchFolder", children=item(u(7), "Photo", type="Image")) + item(u(8), "Trash", type="TrashFolder", children=item(u(9), "Scene B")) ) SAMPLE_DEFINITIONS = ( '' 'Done' 'Night' ) SAMPLE_FILES = { f"Files/Data/{u(3)}/content.rtf": r"{\rtf1\ansi Hello brave new world.}", f"Files/Data/{u(5)}/content.rtf": r"{\rtf1\ansi One two three.}", } TREE = """\ Test ├── (draft) Draft │ ├── Chapter One [Red] [Done] [{Night}] [no-compile] │ │ ├── Scene A │ │ └── (Untitled) │ └── Scene B ├── (research) Research │ └── Photo └── (trash) Trash └── Scene B """ FLAT = """\ Draft Draft/Chapter One [Red] [Done] [{Night}] [no-compile] Draft/Chapter One/Scene A Draft/Chapter One/(Untitled) Draft/Scene B Research Research/Photo Trash Trash/Scene B """ class TestCommands(unittest.TestCase): @classmethod def setUpClass(cls) -> None: cls._tmp = tempfile.TemporaryDirectory() cls.bundle = make_bundle( Path(cls._tmp.name), SAMPLE_BINDER, extra=SAMPLE_DEFINITIONS, files=SAMPLE_FILES ) @classmethod def tearDownClass(cls) -> None: cls._tmp.cleanup() def ok(self, *args: str) -> str: result = run_cli(args[0], self.bundle, *args[1:]) self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.stderr, "") return result.stdout def fails(self, *args: str) -> str: result = run_cli(args[0], self.bundle, *args[1:]) self.assertEqual(result.returncode, 1) self.assertEqual(result.stdout, "") self.assertNotIn("Traceback", result.stderr) return result.stderr def test_ls_tree(self) -> None: self.assertEqual(self.ok("ls"), TREE) def test_ls_tree_with_ids(self) -> None: lines = self.ok("ls", "--ids").splitlines() self.assertEqual(lines[0], "Test") self.assertEqual( lines[2], f"│ ├── Chapter One [Red] [Done] [{{Night}}] [no-compile]", ) self.assertEqual(lines[4], f"│ │ └── (Untitled) ") self.assertEqual(lines[-1], f" └── Scene B ") def test_ls_no_badges(self) -> None: out = self.ok("ls", "--no-badges") self.assertEqual(out, TREE.replace(" [Red] [Done] [{Night}] [no-compile]", "")) def test_ls_flat(self) -> None: self.assertEqual(self.ok("ls", "--flat"), FLAT) def test_ls_flat_ids(self) -> None: lines = self.ok("ls", "--flat", "--ids", "--no-badges").splitlines() self.assertEqual(lines[3], f"Draft/Chapter One/(Untitled) ") def test_ls_flat_text_only(self) -> None: self.assertEqual(self.ok("ls", "--flat", "--text-only").splitlines(), [ "Draft/Chapter One/Scene A", "Draft/Chapter One/(Untitled)", "Draft/Scene B", "Trash/Scene B", ]) def test_show_by_title_path_and_id(self) -> None: for ref in ("Scene A", "Draft/Chapter One/Scene A", u(3), u(3).lower()): with self.subTest(ref=ref): self.assertEqual(self.ok("show", ref).strip(), "Hello brave new world.") def test_show_empty_document(self) -> None: self.assertEqual(self.ok("show", "(Untitled)").strip(), "") def test_show_non_text_items(self) -> None: self.assertIn("'Photo' is a Image, not a text document", self.fails("show", "Photo")) self.assertIn("is a Folder, not a text document", self.fails("show", "Chapter One")) def test_show_ambiguous_title(self) -> None: err = self.fails("show", "Scene B") self.assertIn("Multiple documents match 'Scene B'", err) self.assertIn(f"Draft/Scene B ", err) self.assertIn(f"Trash/Scene B ", err) self.assertTrue(err.rstrip().endswith("Refine the reference (use a slash-path or ID).")) def test_show_ambiguity_resolved_by_path(self) -> None: self.assertEqual(self.ok("show", "Draft/Scene B").strip(), "One two three.") def test_show_unknown_reference(self) -> None: self.assertEqual(self.fails("show", "Nope"), "scrivcli: No document matching 'Nope'\n") def test_meta_document(self) -> None: out = self.ok("meta", "Draft/Scene B") self.assertRegex(out, r"Title +Scene B\n") self.assertRegex(out, r"Type +Text\n") self.assertRegex(out, rf"ID +{u(5)}\n") self.assertRegex(out, r"Path +Draft/Scene B\n") self.assertRegex(out, r"Label +-\n") self.assertRegex(out, r"Words +3\n") self.assertRegex(out, r"Synopsis +-\n?$") def test_meta_folder_with_badges(self) -> None: out = self.ok("meta", "Chapter One") self.assertRegex(out, r"Label +Red\n") self.assertRegex(out, r"Status +Done\n") self.assertRegex(out, r"Keywords +Night\n") self.assertRegex(out, r"Include in compile +no\n") self.assertNotIn("Words", out) self.assertNotIn("Synopsis", out) def test_meta_ambiguous(self) -> None: self.assertIn("Multiple documents match", self.fails("meta", "Scene B")) def test_info(self) -> None: lines = self.ok("info").splitlines() self.assertEqual(lines[0], f"Project {self.bundle}") self.assertIn("Version 2.0 (creator SCRMAC-3.5.2-17035)", lines) self.assertIn("Author -", lines) self.assertIn("Format mac", lines) self.assertIn("Documents 4", lines) self.assertIn("Words 7", lines) def test_usage_errors_exit_2(self) -> None: for args in ((), ("ls",), ("bogus",)): with self.subTest(args=args): result = run_cli(*args) self.assertEqual(result.returncode, 2) self.assertEqual(result.stdout, "") self.assertIn("usage: scrivcli", result.stderr) class TestMalformedProjects(unittest.TestCase): def setUp(self) -> None: self._tmp = tempfile.TemporaryDirectory() self.tmp = Path(self._tmp.name) def tearDown(self) -> None: self._tmp.cleanup() def assertCleanFailure( self, result: subprocess.CompletedProcess[str], *fragments: str ) -> None: self.assertEqual(result.returncode, 1) self.assertEqual(result.stdout, "") self.assertTrue(result.stderr.startswith("scrivcli: "), result.stderr) self.assertNotIn("Traceback", result.stderr) for fragment in fragments: self.assertIn(fragment, result.stderr) def _bundle_with_scrivx(self, text: str) -> Path: bundle = self.tmp / "Broken.scriv" bundle.mkdir() (bundle / "Broken.scrivx").write_text(text, encoding="utf-8") return bundle def test_malformed_xml(self) -> None: bundle = self._bundle_with_scrivx("") for command in ("info", "ls"): with self.subTest(command=command): result = run_cli(command, bundle) self.assertCleanFailure(result, "Malformed project file", "Broken.scrivx") def test_empty_project_file(self) -> None: bundle = self._bundle_with_scrivx("") self.assertCleanFailure(run_cli("info", bundle), "Broken.scrivx") def test_missing_binder(self) -> None: bundle = self._bundle_with_scrivx("") self.assertCleanFailure(run_cli("ls", bundle), "No ", "Broken.scrivx") def test_invalid_metadata_definition_ids(self) -> None: cases = { "label": '', "status": ( 'Draft' "" ), "keyword": ( 'A' 'B' ), } for kind, extra in cases.items(): with self.subTest(kind=kind): bundle = make_bundle(self.tmp, item(UUID, "Doc"), extra=extra, name=kind) result = run_cli("meta", bundle, "Doc") self.assertCleanFailure( result, "Invalid metadata", f"{kind}.scrivx", f"{kind} definition" ) def test_missing_bundle(self) -> None: result = run_cli("info", self.tmp / "Nope.scriv") self.assertCleanFailure(result, "Not a Scrivener bundle", "Nope.scriv") def test_bundle_without_project_file(self) -> None: (self.tmp / "Empty.scriv").mkdir() result = run_cli("ls", self.tmp / "Empty.scriv") self.assertCleanFailure(result, "No .scrivx file") class TestShowRaw(unittest.TestCase): def setUp(self) -> None: self._tmp = tempfile.TemporaryDirectory() self.tmp = Path(self._tmp.name) def tearDown(self) -> None: self._tmp.cleanup() def assertRawRoundTrip(self, content: bytes, name: str) -> None: bundle = make_bundle( self.tmp, item(UUID, "Doc"), files={f"Files/Data/{UUID}/content.rtf": content}, name=name, ) result = run_cli_bytes("show", bundle, "Doc", "--raw") self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.stdout, content) def test_without_trailing_newline(self) -> None: self.assertRawRoundTrip(rb"{\rtf1\ansi Hello}", "no_newline") def test_with_trailing_newline(self) -> None: self.assertRawRoundTrip(b"{\\rtf1\\ansi Hello}\n", "newline") def test_crlf_and_non_utf8_bytes(self) -> None: self.assertRawRoundTrip(b"{\\rtf1\\ansi \\bin2 \xff\xfe}\r\n\r\n", "binary") def test_missing_content_prints_nothing(self) -> None: bundle = make_bundle(self.tmp, item(UUID, "Doc"), name="empty") result = run_cli_bytes("show", bundle, "Doc", "--raw") self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.stdout, b"") if __name__ == "__main__": unittest.main()