Improve coverage (#5)

pull/6/head
Josh Karpel 3 years ago committed by GitHub
parent e9df199431
commit 749ea528e9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -22,6 +22,8 @@ exclude_lines =
if 0:
if False:
if __name__ == .__main__.:
assert False
pragma: unreachable
pragma: debugging
pragma: never runs

@ -12,3 +12,7 @@ class UnknownModeError(SpielException):
class NoDeckFound(SpielException):
pass
class Quit(SpielException):
pass

@ -4,6 +4,7 @@ import sys
import termios
from contextlib import contextmanager
from enum import Enum, unique
from io import UnsupportedOperation
from itertools import product
from typing import (
Callable,
@ -17,7 +18,7 @@ from typing import (
Union,
)
from .exceptions import DuplicateInputHandler
from .exceptions import DuplicateInputHandler, Quit
from .modes import Mode
from .state import State
@ -32,7 +33,11 @@ CC = 6
@contextmanager
def no_echo() -> Iterator[None]:
fd = sys.stdin.fileno()
try:
fd = sys.stdin.fileno()
except UnsupportedOperation:
yield
return
old = termios.tcgetattr(fd)
@ -58,6 +63,7 @@ class SpecialCharacters(Enum):
CtrlDown = "ctrl-down"
CtrlRight = "ctrl-right"
CtrlLeft = "ctrl-left"
CtrlK = "ctrl-k"
ShiftUp = "shift-up"
ShiftDown = "shift-down"
ShiftRight = "shift-right"
@ -80,6 +86,7 @@ SPECIAL_CHARACTERS = {
"\x1b[B": SpecialCharacters.Down,
"\x1b[C": SpecialCharacters.Right,
"\x1b[D": SpecialCharacters.Left,
"\x0b": SpecialCharacters.CtrlK,
"\x1b[1;5A": SpecialCharacters.CtrlUp,
"\x1b[1;5B": SpecialCharacters.CtrlDown,
"\x1b[1;5C": SpecialCharacters.CtrlRight,
@ -108,6 +115,9 @@ ARROWS = [
def get_character(stream: TextIO) -> Union[str, SpecialCharacters]:
result = stream.read(1)
if result == "": # this happens when stdin gets closed; equivalent to a quit
raise Quit()
if result[-1] == "\x1b":
result += stream.read(2)
@ -140,7 +150,7 @@ def handle_input(state: State, stream: TextIO) -> Optional[NoReturn]:
return handler(state)
def action(
def input_handler(
*characters: Character,
modes: Optional[Iterable[Mode]] = None,
handlers: InputHandlers = INPUT_HANDLERS,
@ -158,31 +168,36 @@ def action(
return decorator
@action(SpecialCharacters.Right)
@input_handler(SpecialCharacters.Right)
def next_slide(state: State) -> None:
state.next_slide()
@action(SpecialCharacters.Left)
@input_handler(SpecialCharacters.Left)
def previous_slide(state: State) -> None:
state.previous_slide()
@action(SpecialCharacters.Up, modes=[Mode.DECK])
@input_handler(SpecialCharacters.Up, modes=[Mode.DECK])
def up_grid_row(state: State) -> None:
state.previous_slide(move=state.deck_grid_width)
@action(SpecialCharacters.Down, modes=[Mode.DECK])
@input_handler(SpecialCharacters.Down, modes=[Mode.DECK])
def down_grid_row(state: State) -> None:
state.next_slide(move=state.deck_grid_width)
@action("d")
@input_handler("d")
def deck_mode(state: State) -> None:
state.mode = Mode.DECK
@action("s")
@input_handler("s")
def slide_mode(state: State) -> None:
state.mode = Mode.SLIDE
@input_handler(SpecialCharacters.CtrlK)
def kill(state: State) -> None:
raise Quit()

@ -45,6 +45,7 @@ class DeckReloader(FileSystemEventHandler):
last_reload: DateTime = field(default_factory=now)
def on_modified(self, event: FileSystemEvent) -> None:
self.last_reload = now()
try:
self.state.deck = load_deck(self.deck_path)
self.state.set_message(
@ -54,18 +55,13 @@ class DeckReloader(FileSystemEventHandler):
)
)
except Exception:
try:
exc_type, exc_obj, exc_tb = sys.exc_info()
self.state.set_message(
lambda: Text(
f"Error: {self.last_reload.diff_for_humans(None, False)}: {exc_obj!r}{Control.bell()}",
style=Style(color="bright_red"),
)
exc_type, exc_obj, exc_tb = sys.exc_info()
self.state.set_message(
lambda: Text(
f"Error: {self.last_reload.diff_for_humans(None, False)}: {exc_obj!r}{Control.bell()}",
style=Style(color="bright_red"),
)
except Exception:
# If something goes wrong generating the reloader's message, don't let the reloader die!
pass
self.last_reload = now()
)
def __hash__(self) -> int:
return hash((type(self), id(self)))
@ -76,10 +72,11 @@ class DeckWatcher(ContextManager):
event_handler: FileSystemEventHandler
path: Path
poll: bool = False
observer: Optional[Observer] = None
def __enter__(self) -> DeckWatcher:
def __post_init__(self) -> None:
self.observer = (PollingObserver if self.poll else Observer)(timeout=0.1)
def __enter__(self) -> DeckWatcher:
self.observer.schedule(self.event_handler, str(self.path))
self.observer.start()
@ -91,8 +88,7 @@ class DeckWatcher(ContextManager):
exc_value: Optional[BaseException],
traceback: Optional[TracebackType],
) -> Optional[bool]:
if self.observer is not None:
self.observer.stop()
self.observer.join()
self.observer.stop()
self.observer.join()
return None

@ -7,6 +7,7 @@ from typer import Argument, Option, Typer
from spiel.constants import PACKAGE_NAME, __version__
from spiel.load import DeckReloader, DeckWatcher, load_deck
from spiel.modes import Mode
from spiel.present import present_deck
from spiel.state import State
@ -16,6 +17,7 @@ app = Typer()
@app.command()
def present(
path: Path = Argument(..., help="The path to the slide deck file."),
mode: Mode = Option(default=Mode.SLIDE, help="The mode to start presenting in."),
watch: bool = Option(
default=False, help="If enabled, reload the deck when the slide deck file changes."
),
@ -24,7 +26,11 @@ def present(
help="If enabled, poll the filesystem for changes (implies --watch). Use this option on systems that don't support file modification notifications.",
),
) -> None:
state = State(console=Console(), deck=load_deck(path))
state = State(
console=Console(),
deck=load_deck(path),
mode=mode,
)
watcher = (
DeckWatcher(event_handler=DeckReloader(state, path), path=path, poll=poll)

@ -1,6 +1,7 @@
from enum import Enum
from enum import Enum, unique
class Mode(Enum):
@unique
class Mode(str, Enum):
SLIDE = "slide"
DECK = "deck"

@ -2,13 +2,16 @@ import sys
from itertools import islice
from math import ceil
from rich.console import ConsoleRenderable
from rich.layout import Layout
from rich.live import Live
from rich.padding import Padding
from rich.panel import Panel
from rich.style import Style
from .exceptions import UnknownModeError
from spiel import Slide
from .exceptions import Quit, UnknownModeError
from .footer import Footer
from .input import handle_input, no_echo
from .modes import Mode
@ -16,53 +19,60 @@ from .state import State
from .utils import clamp, joinify
def render_slide(slide: Slide) -> ConsoleRenderable:
return Padding(slide.content, pad=1)
def split_layout_into_deck_grid(root: Layout, state: State) -> Layout:
grid_width = state.deck_grid_width
row_of_current_slide = state.current_slide_idx // grid_width
num_rows = ceil(len(state.deck) / grid_width)
start_row = clamp(
value=row_of_current_slide - (grid_width // 2),
lower=0,
upper=max(num_rows - grid_width, 0),
)
start_slide_idx = grid_width * start_row
slides = islice(enumerate(state.deck.slides, start=1), start_slide_idx, None)
rows = [Layout(name=str(r)) for r in range(grid_width)]
cols = [[Layout(name=f"{r}-{c}") for c in range(grid_width)] for r, _ in enumerate(rows)]
root.split_column(*rows)
for row, layouts in zip(rows, cols):
for layout in layouts:
slide_number, slide = next(slides, (None, None))
if slide is None:
layout.update("")
else:
is_active_slide = slide is state.current_slide
layout.update(
Panel(
slide.content,
title=joinify(" | ", [slide_number, slide.title]),
border_style=Style(
color="bright_cyan" if is_active_slide else None,
dim=not is_active_slide,
),
)
)
row.split_row(*layouts)
return root
def present_deck(state: State) -> None:
footer = Layout(Footer(state), name="footer", size=1)
console = state.console
def get_renderable() -> Layout:
current_slide = state.deck[state.current_slide_idx]
body = Layout(name="body", ratio=1)
if state.mode is Mode.SLIDE:
body.update(Padding(current_slide.content, pad=1))
body.update(render_slide(current_slide))
elif state.mode is Mode.DECK:
grid_width = state.deck_grid_width
row_of_current_slide = state.current_slide_idx // grid_width
num_rows = ceil(len(state.deck) / grid_width)
start_row = clamp(
value=row_of_current_slide - (grid_width // 2),
lower=0,
upper=max(num_rows - grid_width, 0),
)
start_slide_idx = grid_width * start_row
slides = islice(enumerate(state.deck.slides, start=1), start_slide_idx, None)
rows = [Layout(name=str(r)) for r in range(grid_width)]
cols = [
[Layout(name=f"{r}-{c}") for c in range(grid_width)] for r, _ in enumerate(rows)
]
body.split_column(*rows)
for row, layouts in zip(rows, cols):
for layout in layouts:
slide_number, slide = next(slides, (None, None))
if slide is None:
layout.update("")
else:
is_active_slide = slide is state.current_slide
layout.update(
Panel(
slide.content,
title=joinify(" | ", [slide_number, slide.title]),
border_style=Style(
color="bright_cyan" if is_active_slide else None,
dim=not is_active_slide,
),
)
)
row.split_row(*layouts)
else:
split_layout_into_deck_grid(body, state)
else: # pragma: unreachable
raise UnknownModeError(f"Unrecognized mode: {state.mode!r}")
root = Layout(name="root")
@ -72,7 +82,7 @@ def present_deck(state: State) -> None:
with no_echo(), Live(
get_renderable=get_renderable,
console=console,
console=(state.console),
screen=True,
auto_refresh=True,
refresh_per_second=10,
@ -82,6 +92,5 @@ def present_deck(state: State) -> None:
while True:
handle_input(state, sys.stdin)
live.refresh()
except Exception:
live.stop()
console.print_exception(show_locals=True)
except Quit:
return

@ -1,7 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Callable, List, Union
from typing import Callable, Iterator, List, Union
from rich.console import ConsoleRenderable, RichCast
from rich.text import Text
@ -38,6 +38,9 @@ class Deck:
def __len__(self) -> int:
return len(self.slides)
def __iter__(self) -> Iterator[Slide]:
yield from self.slides
def add_slides(self, *slides: Slide) -> Deck:
self.slides.extend(slides)
return self

@ -2,6 +2,7 @@ from dataclasses import dataclass
from typing import Callable, Union
from rich.console import Console
from rich.style import Style
from rich.text import Text
from .modes import Mode
@ -42,7 +43,13 @@ class State:
@property
def message(self) -> Text:
if callable(self._message):
return self._message()
try:
return self._message()
except Exception:
return Text(
"Internal Error: failed to display message.",
style=Style(color="bright_red"),
)
else:
return self._message
@ -51,7 +58,7 @@ class State:
@property
def deck_grid_width(self) -> int:
return self.console.size.width // 30
return max(self.console.size.width // 30, 1)
@dataclass

@ -1,47 +1,13 @@
import sys
import traceback
from io import StringIO
from pathlib import Path
from textwrap import dedent
from typing import Callable, List, Union
import pytest
from click.testing import Result
from rich.console import Console
from typer.testing import CliRunner
from spiel.main import app
from spiel.slides import Deck, Slide
from spiel.state import State
CLI = Callable[[List[Union[str, Path]]], Result]
@pytest.fixture
def runner() -> CliRunner:
return CliRunner()
@pytest.fixture
def cli(runner: CliRunner) -> CLI:
def invoker(args: List[Union[str, Path]]) -> Result:
real_args = [str(arg) for arg in args if arg]
print(real_args)
result = runner.invoke(app, real_args)
print("result:", result)
if result.exc_info is not None: # pragma: debugging
print("traceback:\n")
exc_type, exc_val, exc_tb = result.exc_info
traceback.print_exception(exc_val, exc_val, exc_tb, file=sys.stdout)
print()
print("exit code:", result.exit_code)
print("output:\n", result.output)
return result
return invoker
@pytest.fixture
def three_slide_deck() -> Deck:

@ -1,22 +1,45 @@
import subprocess
import sys
import traceback
from pathlib import Path
import pytest
from typer.testing import CliRunner
from spiel.constants import PACKAGE_NAME, __version__
from tests.conftest import CLI
from spiel.main import app
from spiel.modes import Mode
@pytest.fixture
def runner() -> CliRunner:
return CliRunner()
def test_help(cli: CLI) -> None:
result = cli(["--help"])
def test_help(runner: CliRunner) -> None:
result = runner.invoke(app, ["--help"])
assert result.exit_code == 0
def test_help_via_main() -> None:
result = subprocess.run([sys.executable, "-m", PACKAGE_NAME, "--help"])
assert result.returncode == 0
def test_version(cli: CLI) -> None:
result = cli(["version"])
def test_version(runner: CliRunner) -> None:
result = runner.invoke(app, ["version"])
assert result.exit_code == 0
assert PACKAGE_NAME in result.stdout
assert __version__ in result.stdout
@pytest.mark.parametrize("deck_path", (Path(__file__).parents[1] / "examples").glob("*.py"))
@pytest.mark.parametrize("mode", list(Mode))
@pytest.mark.parametrize("stdin", ["", "s", "d"])
def test_display_example_decks(runner: CliRunner, deck_path: Path, mode: Mode, stdin: str) -> None:
result = runner.invoke(app, ["present", str(deck_path), "--mode", mode], input=stdin)
assert result.exit_code == 0

@ -9,3 +9,7 @@ def test_can_add_slide_to_deck(three_slide_deck: Deck) -> None:
assert len(three_slide_deck) == initial_len + 1
assert three_slide_deck[-1] is new_slide
def test_iterate_yields_deck_slides(three_slide_deck: Deck) -> None:
assert list(iter(three_slide_deck)) == three_slide_deck.slides

@ -0,0 +1,35 @@
from io import StringIO
from typing import Callable
import pytest
from rich.console import Console
from rich.layout import Layout
from rich.text import Text
from spiel import Slide
from spiel.present import render_slide, split_layout_into_deck_grid
from spiel.state import State
@pytest.mark.parametrize(
"make_slide",
[
lambda: Slide(Text("foobar")),
lambda: Slide.from_function(lambda: Text("foobar")),
],
)
def test_can_render_slide(
make_slide: Callable[[], Slide], console: Console, output: StringIO
) -> None:
renderable = render_slide(make_slide())
console.print(renderable)
result = output.getvalue()
assert "foobar" in result
def test_can_render_deck_grid(three_slide_state: State) -> None:
root = Layout()
split_layout_into_deck_grid(root, three_slide_state)

@ -1,67 +0,0 @@
import os
import string
from random import sample
from typing import List
import hypothesis.strategies as st
from hypothesis import given, settings
from rich.console import Console
from rich.text import Text
from spiel import Deck, Slide
from spiel.input import (
INPUT_HANDLERS,
InputHandler,
deck_mode,
next_slide,
previous_slide,
slide_mode,
)
from spiel.modes import Mode
from spiel.state import State
def test_next_slide_goes_to_next_slide(three_slide_state: State) -> None:
next_slide(three_slide_state)
assert three_slide_state.current_slide is three_slide_state.deck[1]
def test_previous_slide_goes_to_previous_slide(three_slide_state: State) -> None:
three_slide_state.jump_to_slide(2)
previous_slide(three_slide_state)
assert three_slide_state.current_slide is three_slide_state.deck[1]
def test_enter_deck_mode(three_slide_state: State) -> None:
deck_mode(three_slide_state)
assert three_slide_state.mode is Mode.DECK
def test_enter_slide_mode(three_slide_state: State) -> None:
slide_mode(three_slide_state)
assert three_slide_state.mode is Mode.SLIDE
@given(input_handlers=st.lists(st.sampled_from(list(set(INPUT_HANDLERS.values())))))
@settings(max_examples=1_000 if os.getenv("CI") else 100)
def test_input_sequences_dont_crash(input_handlers: List[InputHandler]) -> None:
state = State(
console=Console(),
deck=Deck(
name="deck",
slides=[
Slide(
Text(f"This is slide {n + 1}"), title="".join(sample(string.ascii_letters, 30))
)
for n in range(30)
],
),
)
for input_handler in input_handlers:
input_handler(state)

@ -1,22 +1,75 @@
import os
import string
from random import sample
from typing import List
import pytest
from hypothesis import given, settings
from hypothesis import strategies as st
from rich.console import Console
from rich.text import Text
from spiel.exceptions import DuplicateInputHandler
from spiel.input import InputHandlers, action
from spiel import Deck, Slide
from spiel.exceptions import Quit
from spiel.input import (
INPUT_HANDLERS,
InputHandler,
deck_mode,
kill,
next_slide,
previous_slide,
slide_mode,
)
from spiel.modes import Mode
from spiel.state import State
@pytest.fixture
def handlers() -> InputHandlers:
return {} # type: ignore
def test_next_slide_goes_to_next_slide(three_slide_state: State) -> None:
next_slide(three_slide_state)
assert three_slide_state.current_slide is three_slide_state.deck[1]
def test_previous_slide_goes_to_previous_slide(three_slide_state: State) -> None:
three_slide_state.jump_to_slide(2)
previous_slide(three_slide_state)
assert three_slide_state.current_slide is three_slide_state.deck[1]
def test_enter_deck_mode(three_slide_state: State) -> None:
deck_mode(three_slide_state)
assert three_slide_state.mode is Mode.DECK
def test_enter_slide_mode(three_slide_state: State) -> None:
slide_mode(three_slide_state)
assert three_slide_state.mode is Mode.SLIDE
def test_kill(three_slide_state: State) -> None:
with pytest.raises(Quit):
kill(three_slide_state)
def test_register_already_registered_raises_error(handlers: InputHandlers) -> None:
@action("a")
def a(state: State) -> None: # pragma: never runs
pass
with pytest.raises(DuplicateInputHandler):
@given(input_handlers=st.lists(st.sampled_from(list(set(INPUT_HANDLERS.values()) - {kill}))))
@settings(max_examples=1_000 if os.getenv("CI") else 100)
def test_input_sequences_dont_crash(input_handlers: List[InputHandler]) -> None:
state = State(
console=Console(),
deck=Deck(
name="deck",
slides=[
Slide(
Text(f"This is slide {n + 1}"), title="".join(sample(string.ascii_letters, 30))
)
for n in range(30)
],
),
)
@action("a")
def a(state: State) -> None: # pragma: never runs
pass
for input_handler in input_handlers:
input_handler(state)

@ -0,0 +1,39 @@
from io import StringIO
import pytest
from spiel.exceptions import DuplicateInputHandler
from spiel.input import (
SPECIAL_CHARACTERS,
InputHandlers,
SpecialCharacters,
get_character,
input_handler,
)
from spiel.state import State
@pytest.fixture
def handlers() -> InputHandlers:
return {} # type: ignore
def test_register_already_registered_raises_error(handlers: InputHandlers) -> None:
@input_handler("a")
def a(state: State) -> None: # pragma: never runs
pass
with pytest.raises(DuplicateInputHandler):
@input_handler("a")
def a(state: State) -> None: # pragma: never runs
pass
@pytest.mark.parametrize("input, expected", SPECIAL_CHARACTERS.items())
def test_get_character_recognizes_special_characters(
input: str, expected: SpecialCharacters
) -> None:
io = StringIO(input)
assert get_character(io) == expected

@ -1,3 +1,4 @@
from io import StringIO
from pathlib import Path
from textwrap import dedent
from time import sleep
@ -55,3 +56,37 @@ def test_reloader_triggers_when_file_modified(file_with_empty_deck: Path) -> Non
assert (
False
), f"Reloader never triggered, current file contents:\n{file_with_empty_deck.read_text()}" # pragma: debugging
def test_reloader_captures_error_in_message(
file_with_empty_deck: Path, console: Console, output: StringIO
) -> None:
state = State(console=Console(), deck=load_deck(file_with_empty_deck))
reloader = DeckReloader(state=state, deck_path=file_with_empty_deck)
with DeckWatcher(event_handler=reloader, path=file_with_empty_deck, poll=True):
sleep(0.01)
file_with_empty_deck.write_text(
dedent(
"""\
from spiel import Deck
DECK = Deck(name="modified")
foobar
"""
)
)
sleep(0.01)
for attempt in range(10):
console.print(state.message)
result = output.getvalue()
if "NameError" in result and "foobar" in result:
return # test succeeded
sleep(0.1)
assert (
False
), f"Reloader never triggered, current file contents:\n{file_with_empty_deck.read_text()}" # pragma: debugging

@ -1,4 +1,10 @@
from spiel.state import State
import pytest
from rich.console import Console
from rich.style import Style
from rich.text import Text
from spiel import Deck
from spiel.state import State, TextLike
def test_initial_state_has_first_slide_current(three_slide_state: State) -> None:
@ -41,3 +47,42 @@ def test_previous_from_first_slide_stays_put(three_slide_state: State) -> None:
three_slide_state.previous_slide()
assert three_slide_state.current_slide is three_slide_state.deck[0]
@pytest.mark.parametrize(
"width, expected",
[
(20, 1),
(30, 1),
(40, 1),
(60, 2),
(80, 2),
(95, 3),
(120, 4),
],
)
def test_deck_grid_width(width: int, expected: int, three_slide_deck: Deck) -> None:
console = Console(width=width)
state = State(console=console, deck=three_slide_deck)
assert state.deck_grid_width == expected
@pytest.mark.parametrize(
"message, expected",
[
(Text("foobar"), Text("foobar")),
(lambda: Text("wizbang"), Text("wizbang")),
(
lambda: 1 / 0,
Text(
"Internal Error: failed to display message.",
style=Style(color="bright_red"),
),
),
],
)
def test_set_message(message: TextLike, expected: Text, three_slide_state: State) -> None:
three_slide_state.set_message(message)
assert three_slide_state.message == expected

Loading…
Cancel
Save