# Copyright (c) 2011 Martin Vilcans
# Licensed under the MIT license:
# http://www.opensource.org/licenses/mit-license.php
from unittest import TestCase
from screenplain.parsers import fountain
from screenplain.types import (
Slug, Action, Dialog, DualDialog, Transition, Section, PageBreak
)
from screenplain.richstring import plain, italic, empty_string
from io import StringIO
def parse(lines):
content = '\n'.join(lines)
return list(fountain.parse(StringIO(content)))
class SlugTests(TestCase):
def test_slug_with_prefix(self):
paras = parse([
'INT. SOMEWHERE - DAY',
'',
'THIS IS JUST ACTION',
])
self.assertEqual([Slug, Action], [type(p) for p in paras])
def test_slug_must_be_single_line(self):
paras = parse([
'INT. SOMEWHERE - DAY',
'ANOTHER LINE',
'',
'Some action',
])
self.assertEqual([Dialog, Action], [type(p) for p in paras])
# What looks like a scene headingis parsed as a character name.
# Unexpected perhaps, but that's how I interpreted the spec.
self.assertEqual(plain('INT. SOMEWHERE - DAY'), paras[0].character)
self.assertEqual([plain('Some action')], paras[1].lines)
def test_action_is_not_a_slug(self):
paras = parse([
'',
'THIS IS JUST ACTION',
])
self.assertEqual([Action], [type(p) for p in paras])
def test_two_lines_creates_no_slug(self):
types = [type(p) for p in parse([
'',
'',
'This is a slug',
'',
])]
# This used to be Slug. Changed in the Jan 2012 version of the spec.
self.assertEqual([Action], types)
def test_period_creates_slug(self):
paras = parse([
'.SNIPER SCOPE POV',
'',
])
self.assertEqual(1, len(paras))
self.assertEqual(Slug, type(paras[0]))
self.assertEqual(plain('SNIPER SCOPE POV'), paras[0].line)
def test_more_than_one_period_does_not_create_slug(self):
paras = parse([
'..AND THEN...',
'',
])
self.assertEqual(1, len(paras))
self.assertEqual(Action, type(paras[0]))
self.assertEqual(plain('..AND THEN...'), paras[0].lines[0])
def test_scene_number_is_parsed(self):
paras = parse(['EXT SOMEWHERE - DAY #42#'])
self.assertEqual(plain('EXT SOMEWHERE - DAY'), paras[0].line)
self.assertEqual(plain('42'), paras[0].scene_number)
def test_only_last_two_hashes_in_slug_used_for_scene_number(self):
paras = parse(['INT ROOM #237 #42#'])
self.assertEqual(plain('42'), paras[0].scene_number)
self.assertEqual(plain('INT ROOM #237'), paras[0].line)
def test_scene_number_must_be_alphanumeric(self):
paras = parse(['.SOMEWHERE #*HELLO*#'])
self.assertIsNone(paras[0].scene_number)
self.assertEqual(
(plain)(u'SOMEWHERE #') + (italic)(u'HELLO') + (plain)(u'#'),
paras[0].line
)
class SectionTests(TestCase):
def test_section_parsed_correctly(self):
paras = parse([
'# first level',
'',
'## second level',
])
self.assertEqual([Section, Section], [type(p) for p in paras])
self.assertEqual(1, paras[0].level)
self.assertEqual(plain('first level'), paras[0].text)
self.assertEqual(2, paras[1].level)
self.assertEqual(plain('second level'), paras[1].text)
def test_multiple_sections_in_one_paragraph(self):
paras = parse([
'# first level',
'## second level',
'# first level again'
])
self.assertEqual(
[Section, Section, Section],
[type(p) for p in paras]
)
self.assertEqual(1, paras[0].level)
self.assertEqual(plain('first level'), paras[0].text)
self.assertEqual(2, paras[1].level)
self.assertEqual(plain('second level'), paras[1].text)
self.assertEqual(1, paras[2].level)
self.assertEqual(plain('first level again'), paras[2].text)
def test_multiple_sections_with_synopsis(self):
paras = parse([
'# first level',
'= level one synopsis',
'## second level',
])
self.assertEqual([
Section(plain(u'first level'), 1, 'level one synopsis'),
Section(plain(u'second level'), 2, None),
], paras)
class DialogTests(TestCase):
# A Character element is any line entirely in caps, with one empty
# line before it and without an empty line after it.
def test_all_caps_is_character(self):
paras = [p for p in parse([
'SOME GUY',
'Hello',
])]
self.assertEqual(1, len(paras))
dialog = paras[0]
self.assertEqual(Dialog, type(dialog))
self.assertEqual(plain('SOME GUY'), dialog.character)
def test_alphanumeric_character(self):
paras = parse([
'R2D2',
'Bee-bop',
])
self.assertEqual([Dialog], [type(p) for p in paras])
self.assertEqual(plain('R2D2'), paras[0].character)
# Spec http://fountain.io/syntax#section-character:
# Character names must include at least one alphabetical character.
# "R2D2" works, but "23" does not.
def test_nonalpha_character(self):
paras = parse([
'23',
'Hello',
])
self.assertEqual([Action], [type(p) for p in paras])
# Spec http://fountain.io/syntax#section-character:
# You can force a Character element by preceding it with the "at" symbol @.
def test_at_sign_forces_dialog(self):
paras = parse([
'@McCLANE',
'Yippee ki-yay',
])
self.assertEqual([Dialog], [type(p) for p in paras])
self.assertEqual(plain('McCLANE'), paras[0].character)
def test_twospaced_line_is_not_character(self):
paras = parse([
'SCANNING THE AISLES... ',
'Where is that pit boss?',
])
self.assertEqual([Action], [type(p) for p in paras])
def test_simple_parenthetical(self):
paras = parse([
'STEEL',
'(starting the engine)',
'So much for retirement!',
])
self.assertEqual(1, len(paras))
dialog = paras[0]
self.assertEqual(2, len(dialog.blocks))
self.assertEqual(
(True, plain('(starting the engine)')),
dialog.blocks[0]
)
self.assertEqual(
(False, plain('So much for retirement!')),
dialog.blocks[1]
)
def test_twospace_keeps_dialog_together(self):
paras = parse([
'SOMEONE',
'One',
' ',
'Two',
])
self.assertEqual([Dialog], [type(p) for p in paras])
self.assertEqual([
(False, plain('One')),
(False, empty_string),
(False, plain('Two')),
], paras[0].blocks)
def test_dual_dialog(self):
paras = parse([
'BRICK',
'Fuck retirement.',
'',
'STEEL ^',
'Fuck retirement!',
])
self.assertEqual([DualDialog], [type(p) for p in paras])
dual = paras[0]
self.assertEqual(plain('BRICK'), dual.left.character)
self.assertEqual(
[(False, plain('Fuck retirement.'))],
dual.left.blocks
)
self.assertEqual(plain('STEEL'), dual.right.character)
self.assertEqual(
[(False, plain('Fuck retirement!'))],
dual.right.blocks
)
def test_dual_dialog_without_previous_dialog_is_ignored(self):
paras = parse([
'Brick strolls down the street.',
'',
'BRICK ^',
'Nice retirement.',
])
self.assertEqual([Action, Dialog], [type(p) for p in paras])
dialog = paras[1]
self.assertEqual(plain('BRICK ^'), dialog.character)
self.assertEqual([
(False, plain('Nice retirement.'))
], dialog.blocks)
def test_leading_and_trailing_spaces_in_dialog(self):
paras = parse([
'JULIET',
'O Romeo, Romeo! wherefore art thou Romeo?',
' Deny thy father and refuse thy name; ',
'Or, if thou wilt not, be but sworn my love,',
" And I'll no longer be a Capulet.",
])
self.assertEqual([Dialog], [type(p) for p in paras])
self.assertEqual([
(False, plain(u'O Romeo, Romeo! wherefore art thou Romeo?')),
(False, plain(u'Deny thy father and refuse thy name;')),
(False, plain(u'Or, if thou wilt not, be but sworn my love,')),
(False, plain(u"And I'll no longer be a Capulet.")),
], paras[0].blocks)
class TransitionTests(TestCase):
def test_standard_transition(self):
paras = parse([
'Jack begins to argue vociferously in Vietnamese (?)',
'',
'CUT TO:',
'',
"EXT. BRICK'S POOL - DAY",
])
self.assertEqual([Action, Transition, Slug], [type(p) for p in paras])
def test_transition_must_end_with_to(self):
paras = parse([
'CUT TOO:',
'',
"EXT. BRICK'S POOL - DAY",
])
self.assertEqual([Action, Slug], [type(p) for p in paras])
def test_transition_needs_to_be_upper_case(self):
paras = parse([
'Jack begins to argue vociferously in Vietnamese (?)',
'',
'cut to:',
'',
"EXT. BRICK'S POOL - DAY",
])
self.assertEqual([Action, Action, Slug], [type(p) for p in paras])
def test_not_a_transition_on_trailing_whitespace(self):
paras = parse([
'Jack begins to argue vociferously in Vietnamese (?)',
'',
'CUT TO: ',
'',
"EXT. BRICK'S POOL - DAY",
])
self.assertEqual([Action, Action, Slug], [type(p) for p in paras])
def test_transition_does_not_have_to_be_followed_by_slug(self):
# The "followed by slug" requirement is gone from the Jan 2012 spec
paras = parse([
'Bill lights a cigarette.',
'',
'CUT TO:',
'',
'SOME GUY mowing the lawn.',
])
self.assertEqual(
[Action, Transition, Action],
[type(p) for p in paras]
)
def test_greater_than_sign_means_transition(self):
paras = parse([
'Bill blows out the match.',
'',
'> FADE OUT.',
'',
'.DARKNESS',
])
self.assertEqual([Action, Transition, Slug], [type(p) for p in paras])
self.assertEqual(plain('FADE OUT.'), paras[1].line)
def test_centered_text_is_not_parsed_as_transition(self):
paras = parse([
'Bill blows out the match.',
'',
'> THE END. <',
'',
'bye!'
])
self.assertEqual([Action, Action, Action], [type(p) for p in paras])
def test_transition_at_end(self):
paras = parse([
'They stroll hand in hand down the street.',
'',
'> FADE OUT.',
])
self.assertEqual([Action, Transition], [type(p) for p in paras])
self.assertEqual(plain('FADE OUT.'), paras[1].line)
class ActionTests(TestCase):
def test_action_preserves_leading_whitespace(self):
paras = parse([
'hello',
'',
' two spaces',
' three spaces ',
])
self.assertEqual([Action, Action], [type(p) for p in paras])
self.assertEqual(
[
plain(u' two spaces'),
plain(u' three spaces'),
], paras[1].lines
)
def test_single_centered_line(self):
paras = parse(['> center me! <'])
self.assertEqual([Action], [type(p) for p in paras])
self.assertTrue(paras[0].centered)
def test_full_centered_paragraph(self):
lines = [
'> first! <',
' > second! <',
'> third!< ',
]
paras = parse(lines)
self.assertEqual([Action], [type(p) for p in paras])
self.assertTrue(paras[0].centered)
self.assertEqual([
plain('first!'),
plain('second!'),
plain('third!'),
], paras[0].lines)
def test_upper_case_centered_not_parsed_as_dialog(self):
paras = parse([
'> FIRST! <',
' > SECOND! <',
'> THIRD! <',
])
self.assertEqual([Action], [type(p) for p in paras])
self.assertTrue(paras[0].centered)
def test_centering_marks_in_middle_of_paragraphs_are_verbatim(self):
lines = [
'first!',
'> second! <',
'third!',
]
paras = parse(lines)
self.assertEqual([Action], [type(p) for p in paras])
self.assertFalse(paras[0].centered)
self.assertEqual([plain(line) for line in lines], paras[0].lines)
class SynopsisTests(TestCase):
def test_synopsis_after_slug_adds_synopsis_to_scene(self):
paras = parse([
"EXT. BRICK'S PATIO - DAY",
'',
"= Set up Brick & Steel's new life."
'',
])
self.assertEqual([Slug], [type(p) for p in paras])
self.assertEqual(
"Set up Brick & Steel's new life.",
paras[0].synopsis
)
def test_synopsis_in_section(self):
paras = parse([
'# section one',
'',
'= In which we get to know our characters'
])
self.assertEqual([Section], [type(p) for p in paras])
self.assertEqual(
'In which we get to know our characters',
paras[0].synopsis
)
def test_synopsis_syntax_parsed_as_literal(self):
paras = parse([
'Some action',
'',
'= A line that just happens to look like a synopsis'
])
self.assertEqual([Action, Action], [type(p) for p in paras])
self.assertEqual(
[plain('= A line that just happens to look like a synopsis')],
paras[1].lines
)
class TitlePageTests(TestCase):
def test_basic_title_page(self):
lines = [
'Title:',
' _**BRICK & STEEL**_',
' _**FULL RETIRED**_',
'Author: Stu Maschwitz',
]
self.assertDictEqual(
{
'Title': ['_**BRICK & STEEL**_', '_**FULL RETIRED**_'],
'Author': ['Stu Maschwitz'],
},
fountain.parse_title_page(lines)
)
def test_multiple_values(self):
lines = [
'Title: Death',
'Title: - a love story',
'Title:',
' (which happens to be true)',
]
self.assertDictEqual(
{
'Title': [
'Death',
'- a love story',
'(which happens to be true)'
]
},
fountain.parse_title_page(lines)
)
def test_empty_value_ignored(self):
lines = [
'Title:',
'Author: John August',
]
self.assertDictEqual(
{'Author': ['John August']},
fountain.parse_title_page(lines)
)
def test_unparsable_title_page_returns_none(self):
lines = [
'Title: Inception',
' additional line',
]
self.assertIsNone(fountain.parse_title_page(lines))
class PageBreakTests(TestCase):
def test_page_break_is_parsed(self):
paras = parse([
'====',
'',
'So here we go'
])
self.assertEqual([PageBreak, Action], [type(p) for p in paras])