From 38ab1144fb68f60f96532ac4714feb2302d6acf1 Mon Sep 17 00:00:00 2001 From: Mike Cifelli Date: Sun, 19 Mar 2017 12:54:35 -0400 Subject: [PATCH] Clean up terminal code and unit tests The terminal unit tests were updated so that they don't rely on an arbitrary delay. --- src/main/LispMain.java | 22 +- src/terminal/ControlSequenceHandler.java | 26 ++ src/terminal/LispTerminal.java | 250 ++++++++++-------- test/terminal/FlushListener.java | 38 +++ ...inalAttempt.java => LispTerminalTest.java} | 216 +++++++++++++-- 5 files changed, 413 insertions(+), 139 deletions(-) create mode 100644 src/terminal/ControlSequenceHandler.java create mode 100644 test/terminal/FlushListener.java rename test/terminal/{LispTerminalAttempt.java => LispTerminalTest.java} (52%) diff --git a/src/main/LispMain.java b/src/main/LispMain.java index d0031fe..c4026af 100644 --- a/src/main/LispMain.java +++ b/src/main/LispMain.java @@ -1,8 +1,12 @@ package main; +import static terminal.LispTerminal.END_OF_SEGMENT; + import java.io.*; import java.util.function.Function; +import com.googlecode.lanterna.terminal.*; + import interpreter.*; import terminal.LispTerminal; @@ -17,25 +21,27 @@ public class LispMain { private PipedOutputStream inputWriter; private PipedInputStream outputReader; private PipedOutputStream outputWriter; - private LispTerminal terminal; + private LispTerminal lispTerminal; private LispMain() throws IOException { inputReader = new PipedInputStream(); inputWriter = new PipedOutputStream(inputReader); outputReader = new PipedInputStream(); outputWriter = new PipedOutputStream(outputReader); - terminal = new LispTerminal(inputWriter, outputReader); + lispTerminal = new LispTerminal(createIOSafeTerminal(), inputWriter, outputReader); + } + + private IOSafeTerminal createIOSafeTerminal() throws IOException { + return IOSafeTerminalAdapter.createRuntimeExceptionConvertingAdapter(new DefaultTerminalFactory().createTerminal()); } public static void main(String[] args) throws IOException { - LispMain main = new LispMain(); - - main.run(args); + new LispMain().run(args); } private void run(String[] args) { if (args.length == 0) - terminal.run(); + lispTerminal.run(); LispInterpreter interpreter = buildInterpreter(args); interpreter.interpret(); @@ -79,7 +85,7 @@ public class LispMain { private void shutdown() { try { - terminal.finish(); + lispTerminal.finish(); outputWriter.close(); } catch (IOException e) { // TODO Auto-generated catch block @@ -120,7 +126,7 @@ public class LispMain { @Override public String apply(String s) { - return s + 'x'; + return s + END_OF_SEGMENT; } }; } diff --git a/src/terminal/ControlSequenceHandler.java b/src/terminal/ControlSequenceHandler.java new file mode 100644 index 0000000..bc89a5d --- /dev/null +++ b/src/terminal/ControlSequenceHandler.java @@ -0,0 +1,26 @@ +package terminal; + +import static terminal.ControlSequenceHandler.Command.SGR; + +class ControlSequenceHandler { + + public static final boolean isEscape(char c) { + return c == '\u001B'; + } + + private boolean inControlSequence; + private int code; + private Command command; + + public ControlSequenceHandler() { + // TODO Auto-generated constructor stub + this.inControlSequence = false; + this.code = 0; + this.command = SGR; + } + + public static enum Command { + SGR + } + +} diff --git a/src/terminal/LispTerminal.java b/src/terminal/LispTerminal.java index 7bc5faa..391a883 100644 --- a/src/terminal/LispTerminal.java +++ b/src/terminal/LispTerminal.java @@ -1,95 +1,55 @@ package terminal; +import static terminal.ControlSequenceHandler.isEscape; +import static util.Characters.EOF; + import java.io.*; import java.util.concurrent.*; import com.googlecode.lanterna.TerminalPosition; import com.googlecode.lanterna.input.*; -import com.googlecode.lanterna.terminal.*; +import com.googlecode.lanterna.terminal.IOSafeTerminal; public class LispTerminal { - // private static final String ANSI_RESET = "001B[0m"; - // private static final String ANSI_RED = "\u001B[31m"; - // private static final String ANSI_GREEN = "\u001B[32m"; - // private static final String ANSI_YELLOW = "\u001B[33m"; - // private static final String ANSI_PURPLE = "\u001B[35m"; + public static final char END_OF_SEGMENT = 'x'; private IOSafeTerminal terminal; - private boolean isFinished; - private String currentLine; - private TerminalPosition origin; private PipedOutputStream inputWriter; private PipedInputStream outputReader; - private ExecutorService executor; + private ExecutorService executorService; + private ControlSequenceHandler controlSequenceHandler; + private TerminalPosition origin; + private String inputLine; + private String outputSegment; + private boolean isFinished; - LispTerminal(IOSafeTerminal terminal, PipedOutputStream inputWriter, PipedInputStream outputReader) { + public LispTerminal(IOSafeTerminal terminal, PipedOutputStream inputWriter, PipedInputStream outputReader) { this.terminal = terminal; - initialize(inputWriter, outputReader); - } - - public LispTerminal(PipedOutputStream inputWriter, PipedInputStream outputReader) { - try { - Terminal unsafe = new DefaultTerminalFactory().createTerminal(); - this.terminal = IOSafeTerminalAdapter.createRuntimeExceptionConvertingAdapter(unsafe); - } catch (IOException e) { - // TODO - e.printStackTrace(); - } - - initialize(inputWriter, outputReader); - } - - private void initialize(PipedOutputStream inputWriter, PipedInputStream outputReader) { - this.isFinished = false; - this.currentLine = ""; - this.origin = terminal.getCursorPosition(); this.inputWriter = inputWriter; this.outputReader = outputReader; - this.executor = Executors.newFixedThreadPool(2); + this.executorService = Executors.newFixedThreadPool(2); + this.controlSequenceHandler = new ControlSequenceHandler(); + this.origin = terminal.getCursorPosition(); + this.inputLine = ""; + this.outputSegment = ""; + this.isFinished = false; } public void run() { - executor.execute(this::readInput); - executor.execute(this::writeOutput); - executor.shutdown(); + executorService.execute(this::readInput); + executorService.execute(this::writeOutput); + executorService.shutdown(); } public void readInput() { - while (!isFinished) { - handleKeyStroke(getKeyStroke()); - terminal.flush(); - - try { - Thread.sleep(1); - } catch (InterruptedException e) { - isFinished = true; - } - } + while (!isFinished) + processNextKey(); } - public void writeOutput() { - try { - for (int c = outputReader.read(); c != -1; c = outputReader.read()) { - synchronized (this) { - terminal.setCursorVisible(false); - - if (c == 'x') { - terminal.flush(); - origin = terminal.getCursorPosition(); - terminal.setCursorVisible(true); - } else { - terminal.putCharacter((char) c); - } - } - } - } catch (IOException e) { - // TODO Auto-generated catch block - e.printStackTrace(); - } - - terminal.setCursorVisible(true); - terminal.close(); + private void processNextKey() { + handleKey(getKeyStroke()); + takeNap(); } private KeyStroke getKeyStroke() { @@ -98,6 +58,7 @@ public class LispTerminal { try { keyStroke = terminal.pollInput(); } catch (IllegalStateException e) { + // TODO - Issue #299 terminal.putCharacter('\n'); System.exit(0); } @@ -105,14 +66,19 @@ public class LispTerminal { return keyStroke; } - private synchronized void handleKeyStroke(KeyStroke keyStroke) { + private synchronized void handleKey(KeyStroke keyStroke) { if (keyStroke == null) return; + doKey(keyStroke); + terminal.flush(); + } + + private synchronized void doKey(KeyStroke keyStroke) { KeyType keyType = keyStroke.getKeyType(); if (keyStroke.isCtrlDown()) - doControlKeyCharacter(keyStroke); + doControlKey(keyStroke); else if (keyType == KeyType.ArrowLeft) moveCursorLeft(); else if (keyType == KeyType.ArrowRight) @@ -127,40 +93,28 @@ public class LispTerminal { doCharacter(keyStroke); } - private synchronized void doControlKeyCharacter(KeyStroke keyStroke) { + private synchronized void doControlKey(KeyStroke keyStroke) { KeyType keyType = keyStroke.getKeyType(); if (keyType == KeyType.Character) - if (keyStroke.getCharacter() == 'd') { - doEnter(); - finish(); - } + doControlCharacter(keyStroke); } - private synchronized void doEnter() { - terminal.putCharacter('\n'); - currentLine += "\n"; + private synchronized void doControlCharacter(KeyStroke keyStroke) { + if (keyStroke.getCharacter() == 'd') + doControlD(); + } - try { - inputWriter.write(currentLine.getBytes()); - inputWriter.flush(); - } catch (IOException e) { - // TODO Auto-generated catch block - e.printStackTrace(); - } - - currentLine = ""; - origin = terminal.getCursorPosition(); + private synchronized void doControlD() { + doEnter(); + finish(); } private synchronized void moveCursorLeft() { TerminalPosition cursorPosition = terminal.getCursorPosition(); if (isPossibleToMoveLeft(cursorPosition)) - if (cursorPosition.getColumn() == 0) - terminal.setCursorPosition(terminal.getTerminalSize().getColumns(), cursorPosition.getRow() - 1); - else - terminal.setCursorPosition(cursorPosition.withRelativeColumn(-1)); + retractCursor(cursorPosition); } private synchronized boolean isPossibleToMoveLeft(TerminalPosition cursorPosition) { @@ -168,13 +122,22 @@ public class LispTerminal { } private synchronized int distanceFromOrigin(TerminalPosition cursorPosition) { - int cursorColumn = cursorPosition.getColumn(); - int cursorRow = cursorPosition.getRow(); - int originColumn = origin.getColumn(); - int originRow = origin.getRow(); + int columnDifference = cursorPosition.getColumn() - origin.getColumn(); + int rowDifference = cursorPosition.getRow() - origin.getRow(); int totalColumns = terminal.getTerminalSize().getColumns(); - return cursorColumn - originColumn + (totalColumns * (cursorRow - originRow)); + return columnDifference + (totalColumns * rowDifference); + } + + private synchronized void retractCursor(TerminalPosition cursorPosition) { + if (isAtStartOfRow(cursorPosition)) + terminal.setCursorPosition(terminal.getTerminalSize().getColumns(), cursorPosition.getRow() - 1); + else + terminal.setCursorPosition(cursorPosition.withRelativeColumn(-1)); + } + + private boolean isAtStartOfRow(TerminalPosition cursorPosition) { + return cursorPosition.getColumn() == 0; } private synchronized void moveCursorRight() { @@ -184,42 +147,52 @@ public class LispTerminal { advanceCursor(cursorPosition); } + private synchronized boolean isPossibleToMoveRight(TerminalPosition cursorPosition) { + return distanceFromOrigin(cursorPosition) < inputLine.length(); + } + private synchronized void advanceCursor(TerminalPosition cursorPosition) { - if (isCursorAtEndOfRow(cursorPosition)) + if (isAtEndOfRow(cursorPosition)) terminal.setCursorPosition(0, cursorPosition.getRow() + 1); else terminal.setCursorPosition(cursorPosition.withRelativeColumn(1)); } - private synchronized boolean isCursorAtEndOfRow(TerminalPosition cursorPosition) { + private synchronized boolean isAtEndOfRow(TerminalPosition cursorPosition) { return cursorPosition.getColumn() == terminal.getTerminalSize().getColumns() - 1; } - private synchronized boolean isPossibleToMoveRight(TerminalPosition cursorPosition) { - return distanceFromOrigin(cursorPosition) < currentLine.length(); + private synchronized void doEnter() { + terminal.putCharacter('\n'); + inputLine += "\n"; + + try { + inputWriter.write(inputLine.getBytes()); + inputWriter.flush(); + } catch (IOException e) { + // TODO Auto-generated catch block + e.printStackTrace(); + } + + inputLine = ""; + origin = terminal.getCursorPosition(); } private synchronized void doBackspace() { TerminalPosition cursorPosition = terminal.getCursorPosition(); if (isPossibleToMoveLeft(cursorPosition)) { - String remaining = currentLine.substring(distanceFromOrigin(cursorPosition), currentLine.length()); - currentLine = currentLine.substring(0, distanceFromOrigin(cursorPosition) - 1) + remaining; + String remaining = inputLine.substring(distanceFromOrigin(cursorPosition), inputLine.length()); + inputLine = inputLine.substring(0, distanceFromOrigin(cursorPosition) - 1) + remaining; - if (cursorPosition.getColumn() == 0) - terminal.setCursorPosition(terminal.getTerminalSize().getColumns(), cursorPosition.getRow() - 1); - else - terminal.setCursorPosition(cursorPosition.withRelativeColumn(-1)); + retractCursor(cursorPosition); for (char c : remaining.toCharArray()) terminal.putCharacter(c); terminal.putCharacter(' '); - if (cursorPosition.getColumn() == 0) - terminal.setCursorPosition(terminal.getTerminalSize().getColumns(), cursorPosition.getRow() - 1); - else - terminal.setCursorPosition(cursorPosition.withRelativeColumn(-1)); + retractCursor(cursorPosition); } } @@ -227,8 +200,8 @@ public class LispTerminal { TerminalPosition cursorPosition = terminal.getCursorPosition(); if (isPossibleToMoveRight(cursorPosition)) { - String remaining = currentLine.substring(distanceFromOrigin(cursorPosition) + 1, currentLine.length()); - currentLine = currentLine.substring(0, distanceFromOrigin(cursorPosition)) + remaining; + String remaining = inputLine.substring(distanceFromOrigin(cursorPosition) + 1, inputLine.length()); + inputLine = inputLine.substring(0, distanceFromOrigin(cursorPosition)) + remaining; for (char c : remaining.toCharArray()) terminal.putCharacter(c); @@ -243,8 +216,8 @@ public class LispTerminal { if (isPossibleToMoveRight(cursorPosition)) { String remaining = keyStroke.getCharacter() - + currentLine.substring(distanceFromOrigin(cursorPosition), currentLine.length()); - currentLine = currentLine.substring(0, distanceFromOrigin(cursorPosition)) + remaining; + + inputLine.substring(distanceFromOrigin(cursorPosition), inputLine.length()); + inputLine = inputLine.substring(0, distanceFromOrigin(cursorPosition)) + remaining; for (char c : remaining.toCharArray()) terminal.putCharacter(c); @@ -252,12 +225,61 @@ public class LispTerminal { advanceCursor(cursorPosition); } else { terminal.putCharacter(keyStroke.getCharacter()); - currentLine += keyStroke.getCharacter(); + inputLine += keyStroke.getCharacter(); advanceCursor(cursorPosition); } } + private void takeNap() { + try { + Thread.sleep(1); + } catch (InterruptedException e) { + isFinished = true; + } + } + + public void writeOutput() { + try { + for (int c = outputReader.read(); c != EOF; c = outputReader.read()) + processOutput((char) c); + } catch (IOException e) { + // TODO Auto-generated catch block + e.printStackTrace(); + } + + terminal.setCursorVisible(true); + terminal.flush(); + terminal.close(); + } + + private synchronized void processOutput(char c) { + if (isEscape(c)) + parseControlSequence(); + else if (isEndOfSegment(c)) + printSegment(); + else + outputSegment += c; + } + + private synchronized void parseControlSequence() {} + + private synchronized boolean isEndOfSegment(char c) { + return c == END_OF_SEGMENT; + } + + private synchronized void printSegment() { + terminal.setCursorVisible(false); + + for (char c : outputSegment.toCharArray()) + terminal.putCharacter(c); + + terminal.flush(); + terminal.setCursorVisible(true); + outputSegment = ""; + origin = terminal.getCursorPosition(); + } + public void finish() { isFinished = true; diff --git a/test/terminal/FlushListener.java b/test/terminal/FlushListener.java new file mode 100644 index 0000000..a754875 --- /dev/null +++ b/test/terminal/FlushListener.java @@ -0,0 +1,38 @@ +package terminal; + +import com.googlecode.lanterna.TerminalSize; +import com.googlecode.lanterna.terminal.Terminal; +import com.googlecode.lanterna.terminal.virtual.VirtualTerminalListener; + +public class FlushListener implements VirtualTerminalListener { + + private int flushCount; + + public FlushListener() { + this.flushCount = 0; + } + + public int getFlushCount() { + return flushCount; + } + + public void resetFlushCount() { + flushCount = 0; + } + + @Override + public void onResized(Terminal terminal, TerminalSize newSize) {} + + @Override + public synchronized void onFlush() { + flushCount++; + notify(); + } + + @Override + public void onBell() {} + + @Override + public void onClose() {} + +} \ No newline at end of file diff --git a/test/terminal/LispTerminalAttempt.java b/test/terminal/LispTerminalTest.java similarity index 52% rename from test/terminal/LispTerminalAttempt.java rename to test/terminal/LispTerminalTest.java index 0ea6366..7bc44a3 100644 --- a/test/terminal/LispTerminalAttempt.java +++ b/test/terminal/LispTerminalTest.java @@ -1,6 +1,7 @@ package terminal; -import static org.junit.Assert.assertTrue; +import static org.junit.Assert.*; +import static terminal.LispTerminal.END_OF_SEGMENT; import java.io.*; @@ -10,52 +11,100 @@ import com.googlecode.lanterna.*; import com.googlecode.lanterna.input.*; import com.googlecode.lanterna.terminal.virtual.*; -public class LispTerminalAttempt { +public class LispTerminalTest { private PipedInputStream inputReader; private PipedOutputStream inputWriter; private PipedInputStream outputReader; private PipedOutputStream outputWriter; + private FlushListener flushListener; private VirtualTerminal virtualTerminal; private LispTerminal lispTerminal; private void pressKey(KeyType keyType) { virtualTerminal.addInput(new KeyStroke(keyType)); - sleep(); + waitForFlushes(1); + } + + private void pressControlKey(KeyType keyType) { + virtualTerminal.addInput(new KeyStroke(keyType, true, false)); + waitForFlushes(1); } private void enterCharacter(char character) { virtualTerminal.addInput(new KeyStroke(character, false, false)); - sleep(); + waitForFlushes(1); + } + + private void enterControlCharacter(char character) { + virtualTerminal.addInput(new KeyStroke(character, true, false)); + waitForFlushes(1); } private void enterCharacters(String characters) { for (char c : characters.toCharArray()) virtualTerminal.addInput(new KeyStroke(c, false, false)); - sleep(); + waitForFlushes(characters.length()); } - private void sleep() { + private void produceOutput(String output) { try { - Thread.sleep(15); + for (char c : output.toCharArray()) + outputWriter.write(c); + + outputWriter.write(END_OF_SEGMENT); + outputWriter.flush(); + waitForFlushes(1); + } catch (IOException e) {} + } + + private void waitForFlushes(int flushCount) { + try { + synchronized (flushListener) { + while (flushListener.getFlushCount() < flushCount) + flushListener.wait(); + + flushListener.resetFlushCount(); + } } catch (InterruptedException e) {} } - private void assertCursorPosition(int column, int row) { - assertTrue(virtualTerminal.getCursorBufferPosition().getColumn() == column); - assertTrue(virtualTerminal.getCursorBufferPosition().getRow() == row); - } - - private void assertCharacterAtPosition(char character, int column, int row) { - TerminalPosition position = new TerminalPosition(column, row); - assertTrue(virtualTerminal.getBufferCharacter(position).getCharacter() == character); - } - private void setColumns(int columns) { virtualTerminal.setTerminalSize(new TerminalSize(columns, virtualTerminal.getTerminalSize().getRows())); } + private void assertCursorPosition(int column, int row) { + assertEquals(column, virtualTerminal.getCursorBufferPosition().getColumn()); + assertEquals(row, virtualTerminal.getCursorBufferPosition().getRow()); + } + + private void assertCharacterAtPosition(char character, int column, int row) { + TerminalPosition position = new TerminalPosition(column, row); + assertEquals(character, virtualTerminal.getBufferCharacter(position).getCharacter()); + } + + private void assertInputWritten(String expected) { + String actual = ""; + + try { + inputWriter.close(); + + for (int c = inputReader.read(); c != -1; c = inputReader.read()) { + actual += (char) c; + } + } catch (IOException e) {} + + assertEquals(expected, actual); + } + + private void assertInputStreamClosed() { + try { + inputWriter.write(0); + fail("input stream not closed"); + } catch (IOException e) {} + } + @Before public void setUp() throws IOException { inputReader = new PipedInputStream(); @@ -63,6 +112,8 @@ public class LispTerminalAttempt { outputReader = new PipedInputStream(); outputWriter = new PipedOutputStream(outputReader); virtualTerminal = new DefaultVirtualTerminal(); + flushListener = new FlushListener(); + virtualTerminal.addVirtualTerminalListener(flushListener); lispTerminal = new LispTerminal(virtualTerminal, inputWriter, outputReader); lispTerminal.run(); } @@ -222,4 +273,135 @@ public class LispTerminalAttempt { assertCharacterAtPosition('5', 3, 0); } + @Test + public void deleteDoesNothingAtOrigin() { + pressKey(KeyType.Delete); + assertCursorPosition(0, 0); + } + + @Test + public void deleteDoesNothingAtEndOfInput() { + enterCharacters("del"); + pressKey(KeyType.Delete); + assertCursorPosition(3, 0); + assertCharacterAtPosition('d', 0, 0); + assertCharacterAtPosition('e', 1, 0); + assertCharacterAtPosition('l', 2, 0); + } + + @Test + public void deleteWorksAtStartOfInput() { + enterCharacters("del"); + pressKey(KeyType.ArrowLeft); + pressKey(KeyType.ArrowLeft); + pressKey(KeyType.ArrowLeft); + pressKey(KeyType.Delete); + pressKey(KeyType.Delete); + pressKey(KeyType.Delete); + assertCursorPosition(0, 0); + assertCharacterAtPosition(' ', 0, 0); + assertCharacterAtPosition(' ', 1, 0); + assertCharacterAtPosition(' ', 2, 0); + } + + @Test + public void deleteWorksAcrossRow() { + setColumns(4); + enterCharacters("delete"); + pressKey(KeyType.ArrowLeft); + pressKey(KeyType.ArrowLeft); + pressKey(KeyType.ArrowLeft); + pressKey(KeyType.ArrowLeft); + pressKey(KeyType.ArrowLeft); + pressKey(KeyType.Delete); + assertCursorPosition(1, 0); + assertCharacterAtPosition('d', 0, 0); + assertCharacterAtPosition('l', 1, 0); + assertCharacterAtPosition('e', 2, 0); + assertCharacterAtPosition('t', 3, 0); + assertCharacterAtPosition('e', 0, 1); + assertCharacterAtPosition(' ', 1, 1); + } + + @Test + public void enterMovesToNextLine() { + pressKey(KeyType.Enter); + assertCursorPosition(0, 1); + } + + @Test + public void enterWritesLineToPipedStream() { + enterCharacters("enter"); + pressKey(KeyType.Enter); + assertInputWritten("enter\n"); + } + + @Test + public void enterPressedInMiddleOfInput_WritesEntireLineToPipedStream() { + enterCharacters("enter"); + pressKey(KeyType.ArrowLeft); + pressKey(KeyType.ArrowLeft); + pressKey(KeyType.Enter); + assertInputWritten("enter\n"); + } + + @Test + public void controlDWorks() { + enterCharacters("control-d"); + enterControlCharacter('d'); + assertInputStreamClosed(); + assertInputWritten("control-d\n"); + } + + @Test + public void controlDWorksInMiddleOfInput() { + enterCharacters("control-d"); + pressKey(KeyType.ArrowLeft); + pressKey(KeyType.ArrowLeft); + enterControlCharacter('d'); + assertInputStreamClosed(); + assertInputWritten("control-d\n"); + } + + @Test + public void escapeDoesNothing() { + pressKey(KeyType.Escape); + assertCursorPosition(0, 0); + assertInputWritten(""); + } + + @Test + public void controlQDoesNothing() { + enterControlCharacter('q'); + assertCursorPosition(0, 0); + assertInputWritten(""); + } + + @Test + public void controlEnterDoesNothing() { + pressControlKey(KeyType.Enter); + assertCursorPosition(0, 0); + assertInputWritten(""); + } + + @Test + public void outputIsWritten() { + produceOutput("output"); + assertCharacterAtPosition('o', 0, 0); + assertCharacterAtPosition('u', 1, 0); + assertCharacterAtPosition('t', 2, 0); + assertCharacterAtPosition('p', 3, 0); + assertCharacterAtPosition('u', 4, 0); + assertCharacterAtPosition('t', 5, 0); + } + + @Test + public void endOfSegmentCharacterIsNotPrinted() { + produceOutput("> " + END_OF_SEGMENT); + assertCursorPosition(2, 0); + assertCharacterAtPosition('>', 0, 0); + assertCharacterAtPosition(' ', 1, 0); + assertCharacterAtPosition(' ', 2, 0); + } + }