"""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()