Mercurial > jouvence
changeset 1:74b83e3d921e
Add more states, add more tests.
author | Ludovic Chabant <ludovic@chabant.com> |
---|---|
date | Mon, 02 Jan 2017 21:54:59 -0800 |
parents | 243401c49520 |
children | 59fe8cb6190d |
files | fontaine/document.py fontaine/parser.py fontaine/renderer.py tests/conftest.py tests/test_character.yaml tests/test_dialogue.yaml tests/test_lyrics.yaml tests/test_pagebreak.yaml tests/test_parenthetical.yaml tests/test_renderer.py tests/test_scripts.yaml tests/test_titlepage.yaml |
diffstat | 12 files changed, 612 insertions(+), 90 deletions(-) [+] |
line wrap: on
line diff
--- a/fontaine/document.py Mon Jan 02 12:30:49 2017 -0800 +++ b/fontaine/document.py Mon Jan 02 21:54:59 2017 -0800 @@ -46,8 +46,9 @@ 'TYPE_%s' % add_type_name.upper()) def _type_adder(_text): - self.paragraphs.append( - FontaineSceneElement(add_type, _text)) + new_p = FontaineSceneElement(add_type, _text) + self.paragraphs.append(new_p) + return new_p adder = _type_adder self._adders[add_type_name] = adder @@ -55,6 +56,9 @@ else: raise AttributeError + def addPageBreak(self): + self.paragraphs.append(FontaineSceneElement(TYPE_PAGEBREAK, None)) + def lastParagraph(self): try: return self.paragraphs[-1] @@ -74,20 +78,32 @@ TYPE_ACTION = 0 -TYPE_CHARACTER = 1 -TYPE_DIALOG = 2 -TYPE_PARENTHETICAL = 3 +TYPE_CENTEREDACTION = 1 +TYPE_CHARACTER = 2 +TYPE_DIALOG = 3 +TYPE_PARENTHETICAL = 4 +TYPE_TRANSITION = 5 +TYPE_LYRICS = 6 +TYPE_PAGEBREAK = 7 def _scene_element_type_str(t): if t == TYPE_ACTION: return 'ACTION' + if t == TYPE_CENTEREDACTION: + return 'CENTEREDACTION' if t == TYPE_CHARACTER: return 'CHARACTER' if t == TYPE_DIALOG: return 'DIALOG' if t == TYPE_PARENTHETICAL: return 'PARENTHETICAL' + if t == TYPE_TRANSITION: + return 'TRANSITION' + if t == TYPE_LYRICS: + return 'LYRICS' + if t == TYPE_PAGEBREAK: + return 'PAGEBREAK' raise NotImplementedError()
--- a/fontaine/parser.py Mon Jan 02 12:30:49 2017 -0800 +++ b/fontaine/parser.py Mon Jan 02 21:54:59 2017 -0800 @@ -7,6 +7,10 @@ class FontaineState: can_merge = False + needs_pending_empty_lines = True + + def __init__(self): + self.has_pending_empty_line = False def match(self, fp, ctx): return False @@ -14,10 +18,18 @@ def consume(self, fp, ctx): raise NotImplementedError() + def merge(self): + pass + def exit(self, ctx): pass +class _PassThroughState(FontaineState): + def consume(self, fp, ctx): + return ANY_STATE + + class FontaineParserError(Exception): def __init__(self, line_no, message): super().__init__("Error line %d: %s" % (line_no, message)) @@ -39,42 +51,34 @@ self._cur_key = None self._cur_val = None - def consume(self, fp, ctx): - line = fp.readline() - if not line: - return EOF_STATE + def match(self, fp, ctx): + line = fp.peekline() + return RE_TITLE_KEY_VALUE.match(line) - if RE_EMPTY_LINE.match(line): - self._commit(ctx) - # Finished with the page title, now move on to the first scene. - # However, if we never had any page title, go back to the beginning - # so we don't consume anybody else's empty lines. - if len(ctx.document.title_values) == 0: - fp.seek0() - return ANY_STATE + def consume(self, fp, ctx): + while True: + line = fp.readline() + if not line: + return EOF_STATE - m = RE_TITLE_KEY_VALUE.match(line) - if m: - # Commit current value, start new one. - self._commit(ctx) - self._cur_key = m.group('key') - self._cur_val = line[m.end():].strip() - else: - if self._cur_val is None: - if len(ctx.document.title_values) == 0: - # Early exit because there's no title page. - # Go back to the beginning so we don't consume somebody's - # first line of text. - fp.seek0() - return ANY_STATE + m = RE_TITLE_KEY_VALUE.match(line) + if m: + # Commit current value, start new one. + self._commit(ctx) + self._cur_key = m.group('key') + self._cur_val = line[m.end():].strip() + else: + # Keep accumulating the value of one of the title page's + # values. + self._cur_val += line.strip() - raise FontaineParserError( - fp.line_no, - "Page title needs to be followed by 2 empty lines.") + if RE_EMPTY_LINE.match(fp.peekline()): + self._commit(ctx) + # Finished with the page title, now move on to the first scene. + self.has_pending_empty_line = True + break - # Keep accumulating the value of one of the title page's values. - self._cur_val += line.strip() - return True + return ANY_STATE def exit(self, ctx): self._commit(ctx) @@ -92,25 +96,30 @@ class _SceneHeaderState(FontaineState): def match(self, fp, ctx): - lines = fp.peeklines(2) + lines = fp.peeklines(3) return ( RE_EMPTY_LINE.match(lines[0]) and - RE_SCENE_HEADER_PATTERN.match(lines[1])) + RE_SCENE_HEADER_PATTERN.match(lines[1]) and + RE_EMPTY_LINE.match(lines[2])) def consume(self, fp, ctx): fp.readline() # Get past the blank line. line = fp.readline().rstrip('\r\n') line = line.lstrip('.') # In case it was forced. ctx.document.addScene(line) + self.has_pending_empty_line = True return ANY_STATE class _ActionState(FontaineState): can_merge = True + needs_pending_empty_lines = False def __init__(self): super().__init__() self.text = '' + self._to_merge = None + self._was_merged = False def match(self, fp, ctx): return True @@ -123,21 +132,85 @@ return EOF_STATE if is_first_line: - line = line.lstrip('!') + line = line.lstrip('!') # In case it was forced. is_first_line = False + # If the next line is empty, strip the carriage return from + # the line we just got because it's probably gonna be the + # last one. + if RE_EMPTY_LINE.match(fp.peekline()): + stripped_line = line.rstrip("\r\n") + self.text += stripped_line + self._to_merge = line[len(stripped_line):] + break + # ...otherwise, add the line with in full. self.text += line - if RE_EMPTY_LINE.match(fp.peekline()): - break + return ANY_STATE - return ANY_STATE + def merge(self): + # Put back the stuff we stripped from what we thought was the + # last line. + self.text += self._to_merge + self._was_merged = True def exit(self, ctx): ctx.document.lastScene().addAction(self.text) -RE_CHARACTER_LINE = re.compile(r"^[A-Z\-]+\s*(\(.*\))?$", re.M) +RE_CENTERED_LINE = re.compile(r"^\s*>\s*.*\s*<\s*$", re.M) + + +class _CenteredActionState(FontaineState): + def __init__(self): + super().__init__() + self.text = '' + self._aborted = False + + def match(self, fp, ctx): + lines = fp.peeklines(2) + return ( + RE_EMPTY_LINE.match(lines[0]) and + RE_CENTERED_LINE.match(lines[1])) + + def consume(self, fp, ctx): + snapshot = fp.snapshot() + fp.readline() # Get past the empty line. + while True: + line = fp.readline() + if not line: + return EOF_STATE + + clean_line = line.rstrip('\r\n') + eol = line[len(clean_line):] + + clean_line = clean_line.strip() + if clean_line[0] != '>' or clean_line[-1] != '<': + # The whole paragraph must have `>` and `<` wrappers, so + # if we detect a line that doesn't have them, we make this + # paragraph be a normal action instead. + fp.restore(snapshot) + self.has_pending_empty_line = True + self._aborted = True + return _ActionState() + else: + # Remove wrapping `>`/`<`, and spaces. + clean_line = clean_line[1:-1].strip() + + if RE_EMPTY_LINE.match(fp.peekline()): + self.text += clean_line + self.has_pending_empty_line = True + break + self.text += clean_line + eol + + return ANY_STATE + + def exit(self, ctx): + if not self._aborted: + ctx.document.lastScene().addCenteredAction(self.text) + + +RE_CHARACTER_LINE = re.compile(r"^\s*[A-Z\-]+\s*(\(.*\))?$", re.M) class _CharacterState(FontaineState): @@ -150,6 +223,7 @@ def consume(self, fp, ctx): fp.readline() # Get past the empty line. line = fp.readline().rstrip('\r\n') + line = line.lstrip() # Remove indenting. line = line.lstrip('@') # In case it was forced. ctx.document.lastScene().addCharacter(line) return [_ParentheticalState, _DialogState] @@ -166,9 +240,15 @@ return RE_PARENTHETICAL_LINE.match(line) def consume(self, fp, ctx): - line = fp.readline().rstrip('\r\n') + line = fp.readline().lstrip().rstrip('\r\n') ctx.document.lastScene().addParenthetical(line) - return [_DialogState, _CharacterState, _ActionState] + + next_line = fp.peekline() + if not RE_EMPTY_LINE.match(next_line): + return _DialogState() + + self.has_pending_empty_line = True + return ANY_STATE class _DialogState(FontaineState): @@ -177,6 +257,8 @@ self.text = '' def match(self, fp, ctx): + # We only get here from a `_CharacterState` or `_ParentheticalState` + # so we just need to check there's some text. line = fp.peekline() return not RE_EMPTY_LINE.match(line) @@ -185,9 +267,23 @@ line = fp.readline() if not line: return EOF_STATE + + line = line.lstrip() # Remove indenting. + + # Next we could be either continuing the dialog line, going to + # a parenthetical, or exiting dialog altogether. + next_line = fp.peekline() + + if RE_PARENTHETICAL_LINE.match(next_line): + self.text += line.rstrip('\r\n') + return _ParentheticalState() + + if RE_EMPTY_LINE.match(next_line): + self.text += line.rstrip('\r\n') + self.has_pending_empty_line = True + break self.text += line - if RE_EMPTY_LINE.match(fp.peekline()): - break + return ANY_STATE def exit(self, ctx): @@ -195,11 +291,80 @@ class _LyricsState(FontaineState): - pass + def __init__(self): + super().__init__() + self.text = '' + self._aborted = False + + # No `match` method, this can only be forced. + # (see `_ForcedParagraphStates`) + + def consume(self, fp, ctx): + snapshot = fp.snapshot() + fp.readline() # Get past the empty line. + while True: + line = fp.readline() + if not line: + return EOF_STATE + + if line.startswith('~'): + line = line.lstrip('~') + else: + logger.debug("Rolling back lyrics into action paragraph.") + fp.restore(snapshot) + self.has_pending_empty_line = True + self._aborted = True + return _ActionState() + + if RE_EMPTY_LINE.match(fp.peekline()): + self.text += line.rstrip('\r\n') + self.has_pending_empty_line = True + break + self.text += line + + return ANY_STATE + + def exit(self, ctx): + if not self._aborted: + ctx.document.lastScene().addLyrics(self.text) + + +RE_TRANSITION_LINE = re.compile(r"^\s*[^a-z]+TO\:$", re.M) class _TransitionState(FontaineState): - pass + def match(self, fp, ctx): + lines = fp.peeklines(3) + return ( + RE_EMPTY_LINE.match(lines[0]) and + RE_TRANSITION_LINE.match(lines[1]) and + RE_EMPTY_LINE.match(lines[2])) + + def consume(self, fp, ctx): + fp.readline() # Get past the empty line. + line = fp.readline().lstrip().rstrip('\r\n') + line = line.lstrip('>') # In case it was forced. + ctx.document.lastScene().addTransition(line) + self.has_pending_empty_line = True + + +RE_PAGE_BREAK_LINE = re.compile(r"^\=\=\=+$", re.M) + + +class _PageBreakState(FontaineState): + def match(self, fp, ctx): + lines = fp.peeklines(3) + return ( + RE_EMPTY_LINE.match(lines[0]) and + RE_PAGE_BREAK_LINE.match(lines[1]) and + RE_EMPTY_LINE.match(lines[2])) + + def consume(self, fp, ctx): + fp.readline() + fp.readline() + ctx.document.lastScene().addPageBreak() + self.has_pending_empty_line = True + return ANY_STATE class _ForcedParagraphStates(FontaineState): @@ -214,24 +379,41 @@ def __init__(self): super().__init__() self._state_cls = None + self._consume_empty_line = False def match(self, fp, ctx): lines = fp.peeklines(2) + symbol = lines[1][:1] if (RE_EMPTY_LINE.match(lines[0]) and - lines[1][:1] in self.STATE_SYMBOLS): - self._state_cls = self.STATE_SYMBOLS[lines[1][:1]] + symbol in self.STATE_SYMBOLS): + # Special case: don't force a transition state if it's + # really some centered text. + if symbol == '>' and RE_CENTERED_LINE.match(lines[1]): + return False + + self._state_cls = self.STATE_SYMBOLS[symbol] + + # Special case: for forced action paragraphs, don't leave + # the blank line there. + if symbol == '!': + self._consume_empty_line = True + return True return False def consume(self, fp, ctx): + if self._consume_empty_line: + fp.readline() return self._state_cls() -STATES = [ +ROOT_STATES = [ _ForcedParagraphStates, # Must be first. _SceneHeaderState, _CharacterState, _TransitionState, + _PageBreakState, + _CenteredActionState, _ActionState, # Must be last. ] @@ -240,25 +422,21 @@ def __init__(self, fp): self.line_no = 1 self._fp = fp - - def read(self, size=-1): - return self._doRead(size, True) - - def read1(self): - return self.read(1) - - def peek1(self): - pos = self._fp.tell() - c = self._doRead(1, False) - self._fp.seek(pos) - return c + self._blankAt0 = False def readline(self, size=-1): + if self._blankAt0: + self._blankAt0 = False + return '\n' + data = self._fp.readline(size) self.line_no += 1 return data def peekline(self): + if self._blankAt0: + return '\n' + pos = self._fp.tell() line = self._fp.readline() self._fp.seek(pos) @@ -267,16 +445,29 @@ def peeklines(self, count): pos = self._fp.tell() lines = [] + if self._blankAt0: + lines.append('\n') + count -= 1 for i in range(count): lines.append(self._fp.readline()) self._fp.seek(pos) return lines - def seek0(self): - self._fp.seek(0) - self.line_no = 1 + def snapshot(self): + return (self._fp.tell(), self._blankAt0, self.line_no) + + def restore(self, snapshot): + self._fp.seek(snapshot[0]) + self._blankAt0 = snapshot[1] + self.line_no = snapshot[2] - def _doRead(self, size, advance_line_no): + def _addBlankAt0(self): + if self._fp.tell() != 0: + raise Exception( + "Can't add blank line at 0 if reading has started.") + self._blankAt0 = True + + def _read(self, size, advance_line_no): data = self._fp.read(size) if advance_line_no: self.line_no += data.count('\n') @@ -294,7 +485,24 @@ return self.fp.line_no def run(self): + # Start with the page title... unless it doesn't match, in which + # case we start with a "pass through" state that will just return + # `ANY_STATE` so we can start matching stuff. self.state = _TitlePageState() + if not self.state.match(self.fp, self): + logger.debug("No title page value found on line 1, " + "using pass-through state with added blank line.") + self.state = _PassThroughState() + if not RE_EMPTY_LINE.match(self.fp.peekline()): + # Add a fake empty line at the beginning of the text if + # there's not one already. This makes state matching easier. + self.fp._addBlankAt0() + # Make this added empty line "pending" so if the first line + # is an action paragraph, it doesn't include it. + self.state.has_pending_empty_line = True + + # Start parsing! Here we try to do a mostly-forward-only parser with + # non overlapping regexes to make it decently fast. while True: logger.debug("State '%s' consuming from '%s'..." % (self.state.__class__.__name__, self.fp.peekline())) @@ -313,17 +521,13 @@ "states, or `EOF_STATE` if they reached the end of the " "file.") - if res is True: - # State continues to consume. - continue - - if res is ANY_STATE or isinstance(res, list): + elif res is ANY_STATE or isinstance(res, list): # State wants to exit, we need to figure out what is the # next state. pos = self.fp._fp.tell() next_states = res if next_states is ANY_STATE: - next_states = STATES + next_states = ROOT_STATES logger.debug("Trying to match next state from: %s" % [t.__name__ for t in next_states]) for sc in next_states: @@ -337,31 +541,43 @@ else: raise Exception("Can't match following state after: %s" % self.state) + + # Handle the current state before we move on to the new one. if self.state: if type(self.state) == type(res) and self.state.can_merge: # Don't switch states if the next state is the same # type and that type supports merging. + self.state.merge() continue self.state.exit(self) + if (self.state.has_pending_empty_line and + not res.needs_pending_empty_lines): + logger.debug("Skipping pending blank line from %s" % + self.state.__class__.__name__) + self.fp.readline() self.state = res - continue - if isinstance(res, FontaineState): + elif isinstance(res, FontaineState): # State wants to exit, wants a specific state to be next. if self.state: self.state.exit(self) + if (self.state.has_pending_empty_line and + not res.needs_pending_empty_lines): + logger.debug("Skipping pending blank line from %s" % + self.state.__class__.__name__) + self.fp.readline() self.state = res - continue - if res is EOF_STATE: + elif res is EOF_STATE: # Reached end of file. if self.state: self.state.exit(self) break - raise Exception("Unsupported state result: %s" % res) + else: + raise Exception("Unsupported state result: %s" % res) class FontaineParser:
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/fontaine/renderer.py Mon Jan 02 21:54:59 2017 -0800 @@ -0,0 +1,49 @@ +import re + + +RE_ITALICS = re.compile( + r"(?P<before>^|\s)(?P<esc>\\)?\*(?P<text>.*[^\s\*])\*(?=[^a-zA-Z0-9\*]|$)") +RE_BOLD = re.compile( + r"(?P<before>^|\s)(?P<esc>\\)?\*\*(?P<text>.*[^\s])\*\*(?=[^a-zA-Z0-9]|$)") +RE_UNDERLINE = re.compile( + r"(?P<before>^|\s)(?P<esc>\\)?_(?P<text>.*[^\s])_(?=[^a-zA-Z0-9]|$)") + + +class BaseRenderer: + def render_text(self, text): + # Replace bold stuff to catch double asterisks. + text = RE_BOLD.sub(self._do_write_bold, text) + text = RE_ITALICS.sub(self._do_write_italics, text) + text = RE_UNDERLINE.sub(self._do_write_underline, text) + + return text + + def _do_write_italics(self, m): + if m.group('esc'): + return m.group('before') + '*' + m.group('text') + '*' + return ( + m.group('before') + + self.write_italics(m.group('text'))) + + def _do_write_bold(self, m): + if m.group('esc'): + return m.group('before') + '**' + m.group('text') + '**' + return ( + m.group('before') + + self.write_bold(m.group('text'))) + + def _do_write_underline(self, m): + if m.group('esc'): + return m.group('before') + '_' + m.group('text') + '_' + return ( + m.group('before') + + self.write_underline(m.group('text'))) + + def write_italics(self, text): + raise NotImplementedError() + + def write_bold(self, text): + raise NotImplementedError() + + def write_underline(self, text): + raise NotImplementedError()
--- a/tests/conftest.py Mon Jan 02 12:30:49 2017 -0800 +++ b/tests/conftest.py Mon Jan 02 21:54:59 2017 -0800 @@ -4,8 +4,11 @@ import pytest from fontaine.document import ( FontaineSceneElement, - TYPE_ACTION, TYPE_CHARACTER, TYPE_DIALOG, TYPE_PARENTHETICAL) + TYPE_ACTION, TYPE_CENTEREDACTION, TYPE_CHARACTER, TYPE_DIALOG, + TYPE_PARENTHETICAL, TYPE_TRANSITION, TYPE_LYRICS, TYPE_PAGEBREAK, + _scene_element_type_str) from fontaine.parser import FontaineParser, FontaineParserError +from fontaine.renderer import BaseRenderer def pytest_addoption(parser): @@ -67,6 +70,20 @@ return FontaineSceneElement(TYPE_DIALOG, text) +def _t(text): + return FontaineSceneElement(TYPE_TRANSITION, text) + + +def _l(text): + return FontaineSceneElement(TYPE_LYRICS, text) + + +class UnexpectedScriptOutput(Exception): + def __init__(self, actual, expected): + self.actual = actual + self.expected = expected + + class FontaineScriptTestFile(pytest.File): def collect(self): spec = yaml.load_all(self.fspath.open(encoding='utf8')) @@ -96,15 +113,46 @@ doc = parser.parseString(intext) if title is not None: assert title == doc.title_values - assert_scenes(doc.scenes, make_scenes(expected)) + + exp_scenes = make_scenes(expected) + try: + assert_scenes(doc.scenes, exp_scenes) + except AssertionError: + raise UnexpectedScriptOutput(doc.scenes, exp_scenes) def repr_failure(self, excinfo): if isinstance(excinfo.value, FontaineParserError): return ('\n'.join( ['Parser error:', str(excinfo.value)])) + if isinstance(excinfo.value, UnexpectedScriptOutput): + return ('\n'.join( + ['Unexpected output:'] + + ['', 'Actual:'] + + list(_repr_doc_scenes(excinfo.value.actual)) + + ['', 'Expected:'] + + list(_repr_expected_scenes(excinfo.value.expected)))) return super().repr_failure(excinfo) +def _repr_doc_scenes(scenes): + for s in scenes: + yield 'Scene: "%s"' % s.header + for p in s.paragraphs: + yield ' %s: "%s"' % (_scene_element_type_str(p.type), + p.text) + + +def _repr_expected_scenes(scenes): + for s in scenes: + yield 'Scene: "%s"' % s[0] + for p in s[1:]: + if isinstance(p, str): + yield ' ACTION: "%s"' % p + else: + yield ' %s: "%s"' % (_scene_element_type_str(p.type), + p.text) + + def make_scenes(spec): if not isinstance(spec, list): raise Exception("Script specs must be lists.") @@ -114,21 +162,45 @@ cur_paras = [] for item in spec: + if item == '<pagebreak>': + cur_paras.append(FontaineSceneElement(TYPE_PAGEBREAK, None)) + continue + token = item[:1] if token == '.': if cur_header or cur_paras: out.append([cur_header] + cur_paras) cur_header = item[1:] + cur_paras = [] elif token == '!': - cur_paras.append(item[1:]) + if item[1:3] == '><': + cur_paras.append( + FontaineSceneElement(TYPE_CENTEREDACTION, item[3:])) + else: + cur_paras.append(item[1:]) elif token == '@': cur_paras.append(_c(item[1:])) elif token == '=': cur_paras.append(_d(item[1:])) elif token == '_': cur_paras.append(_p(item[1:])) + elif token == '>': + cur_paras.append(_t(item[1:])) + elif token == '~': + cur_paras.append(_l(item[1:])) else: raise Exception("Unknown token: %s" % token) if cur_header or cur_paras: out.append([cur_header] + cur_paras) return out + + +class TestRenderer(BaseRenderer): + def write_bold(self, text): + pass + + def write_italics(self, text): + pass + + def write_underline(self, text): + pass
--- a/tests/test_character.yaml Mon Jan 02 12:30:49 2017 -0800 +++ b/tests/test_character.yaml Mon Jan 02 21:54:59 2017 -0800 @@ -1,5 +1,5 @@ --- -in: "\nSTEEL\nThe man's a myth!" +in: "STEEL\nThe man's a myth!" out: - '@STEEL' - "=The man's a myth!" @@ -14,7 +14,7 @@ - '@HANS (on the radio)' - "=What was it you said?" --- -in: "\n@McCLANE\nYippie ki-yay!" +in: "@McCLANE\nYippie ki-yay!" out: - '@McCLANE' - "=Yippie ki-yay!"
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/test_dialogue.yaml Mon Jan 02 21:54:59 2017 -0800 @@ -0,0 +1,16 @@ +--- +in: | + SANBORN + A good ole boy. +out: + - "@SANBORN" + - "=A good ole boy." +--- +in: | + DAN + Then let's retire them. + _Permanently_. +out: + - "@DAN" + - "=Then let's retire them.\n_Permanently_." +
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/test_lyrics.yaml Mon Jan 02 21:54:59 2017 -0800 @@ -0,0 +1,10 @@ +--- +in: "~Willy Wonka! Willy Wonka! The amazing chocolatier!" +out: + - "~Willy Wonka! Willy Wonka! The amazing chocolatier!" +--- +in: | + ~This is lyrics + But this is not +out: + - "!~This is lyrics\nBut this is not"
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/test_pagebreak.yaml Mon Jan 02 21:54:59 2017 -0800 @@ -0,0 +1,16 @@ +--- +in: | + Here is some action. + + NARRATOR + A page break is coming. + + === + + There it was. +out: + - "!Here is some action." + - "@NARRATOR" + - "=A page break is coming." + - "<pagebreak>" + - "!There it was."
--- a/tests/test_parenthetical.yaml Mon Jan 02 12:30:49 2017 -0800 +++ b/tests/test_parenthetical.yaml Mon Jan 02 21:54:59 2017 -0800 @@ -1,6 +1,5 @@ --- in: | - STEEL (starting the engine) So much for retirement! @@ -8,3 +7,18 @@ - '@STEEL' - '_(starting the engine)' - '=So much for retirement!' +--- +in: | + STEEL + They're coming out of the woodwork! + (pause) + No, everybody we've put away! + (pause) + Point Blank Sniper? +out: + - "@STEEL" + - "=They're coming out of the woodwork!" + - "_(pause)" + - "=No, everybody we've put away!" + - "_(pause)" + - "=Point Blank Sniper?"
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/test_renderer.py Mon Jan 02 21:54:59 2017 -0800 @@ -0,0 +1,64 @@ +import pytest +from fontaine.renderer import BaseRenderer + + +class TestRenderer(BaseRenderer): + def write_bold(self, text): + return 'B:' + text + ':B' + + def write_italics(self, text): + return 'I:' + text + ':I' + + def write_underline(self, text): + return 'U:' + text + ':U' + + +@pytest.mark.parametrize('intext, expected', [ + ("_Underline_", "U:Underline:U"), + ("Here's an _underline_.", "Here's an U:underline:U."), + ("Here's an _underline_ too", "Here's an U:underline:U too"), + ("This is not_underline_", "This is not_underline_"), + ("This is not _underline_either", "This is not _underline_either"), + ("This is _two underlined_ words.", "This is U:two underlined:U words."), + ("This is _three underlined words_.", + "This is U:three underlined words:U."), + ("This is an \_escaped_ one.", "This is an _escaped_ one.") +]) +def test_underline(intext, expected): + r = TestRenderer() + out = r.render_text(intext) + assert out == expected + + +@pytest.mark.parametrize('intext, expected', [ + ("*Italics*", "I:Italics:I"), + ("Here's some *italics*.", "Here's some I:italics:I."), + ("Here's some *italics* too", "Here's some I:italics:I too"), + ("This is not*italics*", "This is not*italics*"), + ("This is not *italics*either", "This is not *italics*either"), + ("This is *two italics* words.", "This is I:two italics:I words."), + ("This is *three italics words*.", + "This is I:three italics words:I."), + ("This is some \*escaped* one.", "This is some *escaped* one.") +]) +def test_italics(intext, expected): + r = TestRenderer() + out = r.render_text(intext) + assert out == expected + + +@pytest.mark.parametrize('intext, expected', [ + ("**Bold**", "B:Bold:B"), + ("Here's some **bold**.", "Here's some B:bold:B."), + ("Here's some **bold** too", "Here's some B:bold:B too"), + ("This is not**bold**", "This is not**bold**"), + ("This is not **bold**either", "This is not **bold**either"), + ("This is **two bold** words.", "This is B:two bold:B words."), + ("This is **three bold words**.", + "This is B:three bold words:B."), + ("This is some \**escaped** one.", "This is some **escaped** one.") +]) +def test_bold(intext, expected): + r = TestRenderer() + out = r.render_text(intext) + assert out == expected
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tests/test_scripts.yaml Mon Jan 02 21:54:59 2017 -0800 @@ -0,0 +1,49 @@ +--- +in: | + The General Lee flies through the air. FREEZE FRAME. + + NARRATOR + Shoot, to the Dukes that's about like taking Grandma for a Sunday drive. + + >**End of Act One**< + + === + + >**Act Two**< + + The General Lee hangs in the air, right where we left it. +out: + - "!The General Lee flies through the air. FREEZE FRAME." + - "@NARRATOR" + - "=Shoot, to the Dukes that's about like taking Grandma for a Sunday drive." + - "!><**End of Act One**" + - "<pagebreak>" + - "!><**Act Two**" + - "!The General Lee hangs in the air, right where we left it." +--- +in: | + EXT. BRICK'S POOL - DAY + + Steel, in the middle of a heated phone call: + + STEEL + They're coming out of the woodwork! + (pause) + No, everybody we've put away! + (pause) + Point Blank Sniper? + + .SNIPER SCOPE POV + + From what seems like only INCHES AWAY. _Steel's face FILLS the *Leupold Mark 4* scope_. +out: + - ".EXT. BRICK'S POOL - DAY" + - "!Steel, in the middle of a heated phone call:" + - "@STEEL" + - "=They're coming out of the woodwork!" + - "_(pause)" + - "=No, everybody we've put away!" + - "_(pause)" + - "=Point Blank Sniper?" + - ".SNIPER SCOPE POV" + - "!From what seems like only INCHES AWAY. _Steel's face FILLS the *Leupold Mark 4* scope_."
--- a/tests/test_titlepage.yaml Mon Jan 02 12:30:49 2017 -0800 +++ b/tests/test_titlepage.yaml Mon Jan 02 21:54:59 2017 -0800 @@ -6,17 +6,17 @@ in: "\n" title: {} out: - - "!\n" + - "!" --- in: "\n\n" title: {} out: - - "!\n\n" + - "!\n" --- in: "\n\n\n" title: {} out: - - "!\n\n\n" + - "!\n\n" --- in: | Title: This simple test @@ -33,5 +33,5 @@ title: Title: "This simple test" out: - - "!It doesn't have much.\n" + - "!It doesn't have much."