/* GuessTheNumber.java = simple “guess the number” game tests/GuessTheNumberTest.java = JUnit test for the game tests/SystemInputOutputTesterTest.java = Test the main JUnit testing factory. tests/RandomSourceTest.java = Test the "Random" class related functionality and mocks used by the "GuessTheNumber" class. tests/ExpectedException.java = Independent exception (child of Error) to throw when the "code under test" was expected to throw an exception (typically an assertion error), but did not. library/RandomSource.java = creates "Random" instances (or mock) for the Program Under Test library/RandomCreatorInterface.java = interface to create "Random" instances (enables lambda expressions) library/AbstractMockRandomSwapper.java = swaps mock Random subclasses in on construction and out on close library/MockRandomSingleValue.java = mock implementation for single-use random number library/SystemInputOutputTester.java = Builder for console I/O testing library/CallbackThrowsException.java = interface for call back to "main" method -- to handle checked exceptions library/MockStandardOutputStream.java = Capture Standard Output (or Error) library/MockInputStream.java = Provide input when needed. Calls back to SystemInputOutputTester to validate intervening output and to provide the input data. library/ExpectedInputOutputStepBase.java = common base class for input/output expectations library/ExpectedTextOutputStep.java = Expect that the given text output is written to Standard Output (or Error). library/ProvideLineInputStep.java = Provide line of Console Input at that point in the expectations list. */ //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // // File Name: GuessTheNumber.java // package p20230510_TestConsoleIO; import p20230510_TestConsoleIO.library.RandomSource; import java.util.Scanner; public class GuessTheNumber { public static void main(final String[] args) { System.out.println("Welcome to the number guessing program."); System.out.println("I am thinking of a number in the range of 1 to 100, inclusive."); System.out.println("You need to guess this number, with a minimum number of guesses."); System.out.println("Each time you guess, I will tell you if my number is HIGHER or LOWER than your guess."); final var random = RandomSource.create(); final int numberToGuess = random.nextInt(100) + 1; final var scanner = new Scanner(System.in); var numberOfGuesses = 0; while (true) { System.out.println("What is your guess?"); final var userGuess = scanner.nextInt(); ++numberOfGuesses; if (userGuess < numberToGuess) { System.out.println("Your guess of " + userGuess + " is TOO LOW."); } else if (userGuess > numberToGuess) { System.out.println("Your guess of " + userGuess + " is TOO HIGH."); } else { System.out.println("Your guess of " + userGuess + " is CORRECT in " + (numberOfGuesses == 1 ? "one guess!!!" : (numberOfGuesses + " guesses!"))); break; } } } } //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // // File Name: tests/GuessTheNumberTest.java // package p20230510_TestConsoleIO.tests; // https://mastodon.social/@GeePawHill/110341886456025504 = Hard to TDD "Hello World" and simple console I/O "games" // https://mastodon.social/@JeffGrigg/110347321176183414 = Jeff "Grigg says that "doing TDD through a User Interface is Always Difficult." // https://mastodon.social/@GeePawHill/110351924530710192 = GeePawHill proposes hiding complexity in a library to protect students // https://mastodon.social/@JeffGrigg/110356052836313274 = posting my implementation // https://mastodon.social/@JeffGrigg/110358610496829363 = my implementation, cleaned and improved -- "Cleaned it up a bit. Better interfaces and reduced coupling. Separate "library" and "tests" packages. Added tests." import org.junit.Test; import p20230510_TestConsoleIO.GuessTheNumber; import p20230510_TestConsoleIO.library.MockRandomSingleValue; import p20230510_TestConsoleIO.library.SystemInputOutputTester; import java.io.IOException; public class GuessTheNumberTest { @Test public void testHighsAndLowsTo33() throws IOException { final var systemNumberWeAreTryingToGuess = 33; try (final var mockRandomNumberGenerator = new MockRandomSingleValue(systemNumberWeAreTryingToGuess)) { SystemInputOutputTester .whenCallingThisMethod(() -> { GuessTheNumber.main(null); }) .assertTextOutput("Welcome to the number guessing program.") // .assertTextOutput("I am thinking of a number in the range of 1 to 100, inclusive.") .assertTextOutput("You need to guess this number, with a minimum number of guesses.") .assertTextOutput("Each time you guess, I will tell you if my number is HIGHER or LOWER than your guess.") // .assertTextOutput("What is your guess?") .provideLineInput("50") .assertTextOutput("Your guess of 50 is TOO HIGH.") // .assertTextOutput("What is your guess?") .provideLineInput("25") .assertTextOutput("Your guess of 25 is TOO LOW.") // .assertTextOutput("What is your guess?") .provideLineInput("37") .assertTextOutput("Your guess of 37 is TOO HIGH.") // .assertTextOutput("What is your guess?") .provideLineInput("31") .assertTextOutput("Your guess of 31 is TOO LOW.") // .assertTextOutput("What is your guess?") .provideLineInput("34") .assertTextOutput("Your guess of 34 is TOO HIGH.") // .assertTextOutput("What is your guess?") .provideLineInput("32") .assertTextOutput("Your guess of 32 is TOO LOW.") // .assertTextOutput("What is your guess?") .provideLineInput("33") .assertTextOutput("Your guess of 33 is CORRECT in 7 guesses!") // .assertThatTheMethodReturnsHere(); } } @Test public void testMaximumGuesses() throws IOException { final var systemNumberWeAreTryingToGuess = 100; try (final var mockRandomNumberGenerator = new MockRandomSingleValue(systemNumberWeAreTryingToGuess)) { SystemInputOutputTester .whenCallingThisMethod(() -> { GuessTheNumber.main(null); }) .assertTextOutput("Welcome to the number guessing program.") // .assertTextOutput("I am thinking of a number in the range of 1 to 100, inclusive.") .assertTextOutput("You need to guess this number, with a minimum number of guesses.") .assertTextOutput("Each time you guess, I will tell you if my number is HIGHER or LOWER than your guess.") // .assertTextOutput("What is your guess?") .provideLineInput("50") .assertTextOutput("Your guess of 50 is TOO LOW.") // .assertTextOutput("What is your guess?") .provideLineInput("75") .assertTextOutput("Your guess of 75 is TOO LOW.") // .assertTextOutput("What is your guess?") .provideLineInput("88") .assertTextOutput("Your guess of 88 is TOO LOW.") // .assertTextOutput("What is your guess?") .provideLineInput("94") .assertTextOutput("Your guess of 94 is TOO LOW.") // .assertTextOutput("What is your guess?") .provideLineInput("97") .assertTextOutput("Your guess of 97 is TOO LOW.") // .assertTextOutput("What is your guess?") .provideLineInput("99") .assertTextOutput("Your guess of 99 is TOO LOW.") // .assertTextOutput("What is your guess?") .provideLineInput("100") .assertTextOutput("Your guess of 100 is CORRECT in 7 guesses!") // .assertThatTheMethodReturnsHere(); } } @Test public void testFirstGuessIsCorrect() throws IOException { final var systemNumberWeAreTryingToGuess = 24; try (final var mockRandomNumberGenerator = new MockRandomSingleValue(systemNumberWeAreTryingToGuess)) { SystemInputOutputTester .whenCallingThisMethod(() -> { GuessTheNumber.main(null); }) .assertTextOutput("Welcome to the number guessing program.") // .assertTextOutput("I am thinking of a number in the range of 1 to 100, inclusive.") .assertTextOutput("You need to guess this number, with a minimum number of guesses.") .assertTextOutput("Each time you guess, I will tell you if my number is HIGHER or LOWER than your guess.") // .assertTextOutput("What is your guess?") .provideLineInput("24") .assertTextOutput("Your guess of 24 is CORRECT in one guess!!!") // .assertThatTheMethodReturnsHere(); } } @Test public void testSecondGuessIsCorrect() throws IOException { final var systemNumberWeAreTryingToGuess = 33; try (final var mockRandomNumberGenerator = new MockRandomSingleValue(systemNumberWeAreTryingToGuess)) { SystemInputOutputTester .whenCallingThisMethod(() -> { GuessTheNumber.main(null); }) .assertTextOutput("Welcome to the number guessing program.") // .assertTextOutput("I am thinking of a number in the range of 1 to 100, inclusive.") .assertTextOutput("You need to guess this number, with a minimum number of guesses.") .assertTextOutput("Each time you guess, I will tell you if my number is HIGHER or LOWER than your guess.") // .assertTextOutput("What is your guess?") .provideLineInput("86") .assertTextOutput("Your guess of 86 is TOO HIGH.") // .assertTextOutput("What is your guess?") .provideLineInput("33") .assertTextOutput("Your guess of 33 is CORRECT in 2 guesses!") // .assertThatTheMethodReturnsHere(); } } } //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // // File Name: tests/SystemInputOutputTesterTest.java // package p20230510_TestConsoleIO.tests; import junit.framework.AssertionFailedError; import org.junit.Test; import p20230510_TestConsoleIO.library.SystemInputOutputTester; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import static org.junit.Assert.assertEquals; public class SystemInputOutputTesterTest { private static class DoNothingProgram { public static void main(String[] args) { // Intentionally do NOTHING here. } } @Test public void testDoNothingProgram() { SystemInputOutputTester .whenCallingThisMethod(() -> { DoNothingProgram.main(null); }) .assertThatTheMethodReturnsHere(); } @Test public void testMissingMessage() { try { SystemInputOutputTester .whenCallingThisMethod(() -> { DoNothingProgram.main(null); }) .assertTextOutput("This program does not and should not produce this output.") .assertThatTheMethodReturnsHere(); throw new ExpectedException("Expected AssertionFailedError to be thrown by the code above."); } catch (final AssertionFailedError ex) { assertEquals("Failed to find in <>", ex.getMessage()); } } @Test public void testProgramTerminatesWithMoreInputToProcess() { try { SystemInputOutputTester .whenCallingThisMethod(() -> { DoNothingProgram.main(null); }) .provideLineInput("Unused Input Line") .assertThatTheMethodReturnsHere(); throw new ExpectedException("Expected AssertionFailedError to be thrown by the code above."); } catch (final AssertionFailedError ex) { assertEquals("Program has exited, but the tests say that we should provide further input." + System.lineSeparator() + " ProvideLineInputStep('Unused Input Line')", ex.getMessage()); } } @Test public void testHelloWorldProgram() { SystemInputOutputTester .whenCallingThisMethod(() -> { HelloWorldProgram.main(null); }) .assertTextOutput("Hello world!") .assertThatTheMethodReturnsHere(); } private static class HelloWorldProgram { public static void main(String[] args) { System.out.println("Hello world!"); } } @Test public void testHelloNameProgram() { SystemInputOutputTester .whenCallingThisMethod(() -> { HelloNameProgram.main(null); }) .assertTextOutput("What is your name? ") .provideLineInput("John") .assertTextOutput("Hello John!") .assertThatTheMethodReturnsHere(); } private static class HelloNameProgram { public static void main(String[] args) throws IOException { System.out.println("What is your name? "); final var in = new BufferedReader(new InputStreamReader(System.in)); final var name = in.readLine(); System.out.println("Hello " + name + "!"); } } } //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // // File Name: tests/RandomSourceTest.java // package p20230510_TestConsoleIO.tests; import org.junit.Test; import p20230510_TestConsoleIO.library.MockRandomSingleValue; import p20230510_TestConsoleIO.library.RandomSource; import java.io.IOException; import java.rmi.server.ExportException; import java.util.Random; import java.util.TreeSet; import static org.junit.Assert.*; public class RandomSourceTest { private static final double ODDS_OF_FALSE_TEST_FAILURE = 0.000001; // -- PER number we're looking for. private static final String NL = System.lineSeparator(); private final Random _random = RandomSource.create(); private double _lowestConfidence = 1.0; @Test public void testCreateRandomNumberGenerator() throws IOException { // Initial (production) state: assertSame("Initial Java Random number generator class;", Random.class, _random.getClass()); // Creating mock Random number generator swaps in a factory for it as a side-effect: try (final var mockRandomNumberGenerator = new MockRandomSingleValue(13)) { assertSame(mockRandomNumberGenerator, RandomSource.create()); assertEquals("Mock Random number, as configured above;", 13, mockRandomNumberGenerator.nextInt(100) + 1); try { mockRandomNumberGenerator.nextInt(100); throw new ExportException("Expected AssertionError when MockRandomSingleValue instance is used more than once."); } catch (final AssertionError ex) { assertEquals("'Random' value must be in the range of 0 <= value < bound (of 100). The value -1 is outside this range in class " + MockRandomSingleValue.class.getName() + ".", ex.getMessage()); } } // Restored to initial (production) state by ".close()" call at the end of the "try (with resource)" block, above. assertSame("Initial Random generator restored on 'close();", Random.class, _random.getClass()); } @Test public void testD1() { assertEquals("1st;", 0, _random.nextInt(1)); assertEquals("2nd;", 0, _random.nextInt(1)); assertEquals("3rd;", 0, _random.nextInt(1)); assertEquals("4th;", 0, _random.nextInt(1)); assertEquals("5th;", 0, _random.nextInt(1)); } @Test public void test2toN() { _lowestConfidence = 1.0; var increment = 1; for (var bound = 2; bound <= 200; bound += increment) { if (bound >= 10) { increment = 5; } final var firstAndLastToFind = new TreeSet(); firstAndLastToFind.add(0); firstAndLastToFind.add(bound - 1); verifyThatBoundryValueAreFound(bound, firstAndLastToFind); } System.out.println("Lowest Confidence Level = " + _lowestConfidence); } @Test public void testNegativeOneIsOutOfBounds() { final var bound = 5; final var firstAndLastToFind = new TreeSet(); firstAndLastToFind.add(-1); try { verifyThatBoundryValueAreFound(bound, firstAndLastToFind); throw new ExpectedException("Expected AssertionFailedError to be thrown by the code above."); } catch (final AssertionError ex) { final var exceptionMessage = ex.getMessage(); final var expectedMessagePrefix = "Unable to find value(s) [-1] after "; final var expectedMessageSuffix = " tries." + NL + " Values found = [0, 1, 2, 3, 4]" + NL + " Giving up because the count of unique values found (5) is (at least) equal to the bound value of 5."; assertTrue("Expected message" + NL + " <" + exceptionMessage + ">" + NL + " to start with" + NL + " <" + expectedMessagePrefix + ">.", exceptionMessage.startsWith(expectedMessagePrefix)); assertTrue("Expected message" + NL + " <" + exceptionMessage + ">" + NL + " to end with" + NL + " <" + expectedMessageSuffix + ">.", exceptionMessage.endsWith(expectedMessageSuffix)); } } @Test public void testValueEqualsBoundNeverHappens() { final var bound = 20; final var firstAndLastToFind = new TreeSet(); firstAndLastToFind.add(bound); try { verifyThatBoundryValueAreFound(bound, firstAndLastToFind); throw new ExpectedException("Expected AssertionFailedError to be thrown by the code above."); } catch (final AssertionError ex) { final var exceptionMessage = ex.getMessage(); final var expectedMessagePrefix = "Unable to find value(s) [20] after "; final var expectedMessageSuffix = " tries." + NL + " Values found = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]" + NL + " Giving up because the count of unique values found (20) is (at least) equal to the bound value of 20."; assertTrue("Expected message" + NL + " <" + exceptionMessage + ">" + NL + " to start with" + NL + " <" + expectedMessagePrefix + ">.", exceptionMessage.startsWith(expectedMessagePrefix)); assertTrue("Expected message" + NL + " <" + exceptionMessage + ">" + NL + " to end with" + NL + " <" + expectedMessageSuffix + ">.", exceptionMessage.endsWith(expectedMessageSuffix)); } } private void verifyThatBoundryValueAreFound(final int bound, final TreeSet firstAndLastToFind) { final var valuesFound = new TreeSet(); int numberOfTries = 0; double oddsOfGettingThisFarWithNoMatch = 1.0; // (100% = always gets here!) while (oddsOfGettingThisFarWithNoMatch > ODDS_OF_FALSE_TEST_FAILURE) { final var randomValue = _random.nextInt(bound); ++numberOfTries; final double oddsOfNotGettingAMatchThisTime = ((double) (bound - firstAndLastToFind.size())) / bound; oddsOfGettingThisFarWithNoMatch *= oddsOfNotGettingAMatchThisTime; if (oddsOfGettingThisFarWithNoMatch > 0.0 && oddsOfGettingThisFarWithNoMatch < _lowestConfidence) { _lowestConfidence = oddsOfGettingThisFarWithNoMatch; } final var isAUniqueNewNumber = valuesFound.add(randomValue); if (firstAndLastToFind.remove(randomValue)) { // true if we did find and remove a value if (firstAndLastToFind.isEmpty()) { break; // Successfully hit first and last expected value. Test is successful. } else { oddsOfGettingThisFarWithNoMatch = 1.0; // Reset = odds of finding 2nd match. } } if (isAUniqueNewNumber) { oddsOfGettingThisFarWithNoMatch = 1.0; // Restore optimisim, as long as we're seeing new numbers. final var numberOfValuesFound = valuesFound.size(); if (numberOfValuesFound >= bound) { fail("Unable to find value(s) " + firstAndLastToFind.toString() + " after " + numberOfTries + " tries." + NL + " Values found = " + valuesFound.toString() + NL + " Giving up because the count of unique values found (" + numberOfValuesFound + ") is (at least) equal to the bound value of " + bound + "."); } } } // Failure case: [NEVER happens when "break;" is taken from loop, above.] if (!firstAndLastToFind.isEmpty()) { fail("Unable to find value(s) " + firstAndLastToFind.toString() + " after " + numberOfTries + " tries." + NL + " Values found = " + valuesFound.toString() + NL + " [There's a small chance that this test failure may be in error, as it is testing randomness!]"); } } } //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // // File Name: tests/ExpectedException.java // package p20230510_TestConsoleIO.tests; class ExpectedException extends Error { public ExpectedException(final String message) { super(message); } } //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // // File Name: library/RandomSource.java // package p20230510_TestConsoleIO.library; import java.util.Random; abstract public class RandomSource { private RandomSource() { throw new IllegalStateException("Not expecting to create instances of this class."); } private static RandomCreatorInterface _creator = () -> { return new Random(); }; public static Random create() { return _creator.create(); } public static RandomCreatorInterface swapRandomConstructor(final RandomCreatorInterface newRandomCreator) { final var oldRandomCreator = _creator; _creator = newRandomCreator; return oldRandomCreator; } } //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // // File Name: library/RandomCreatorInterface.java // package p20230510_TestConsoleIO.library; import java.util.Random; public interface RandomCreatorInterface { Random create(); } //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // // File Name: library/AbstractMockRandomSwapper.java // package p20230510_TestConsoleIO.library; import java.io.Closeable; import java.io.IOException; import java.util.Random; import static org.junit.Assert.fail; abstract public class AbstractMockRandomSwapper extends Random implements Closeable { private final RandomCreatorInterface _oldRandomConstructor; protected AbstractMockRandomSwapper() { _oldRandomConstructor = RandomSource.swapRandomConstructor(() -> { return this; }); } @Override public void close() throws IOException { final var randomConstructorSwappedOut = RandomSource.swapRandomConstructor(_oldRandomConstructor); } @Override final public int nextInt(final int bound) { final var returnValue = nextIntImpl(bound); if (returnValue < 0 || returnValue >= bound) { fail("'Random' value must be in the range of 0 <= value < bound (of " + bound + ")." + " The value " + returnValue + " is outside this range in class " + this.getClass().getName() + "."); } return returnValue; } abstract public int nextIntImpl(final int bound); } //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // // File Name: library/MockRandomSingleValue.java // package p20230510_TestConsoleIO.library; public class MockRandomSingleValue extends AbstractMockRandomSwapper { private int _targetValueToGuess; public MockRandomSingleValue(final int targetValueToGuess) { _targetValueToGuess = targetValueToGuess; } @Override public int nextIntImpl(int bound) { final var returnValue = _targetValueToGuess - 1; _targetValueToGuess = 0; // invalid value -- for any subsequent calls return returnValue; } } //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // // File Name: library/SystemInputOutputTester.java // package p20230510_TestConsoleIO.library; import java.io.PrintStream; import java.util.ArrayList; import java.util.Iterator; import java.util.List; public class SystemInputOutputTester { private final CallbackThrowsException _callback; private final List _expectedInputOutputSteps = new ArrayList(); private Iterator _expectedInputOutputStepIterator = null; private final MockInputStream _mockInputStream = new MockInputStream(this); private final MockStandardOutputStream _mockStandardOutputStream = new MockStandardOutputStream(); //protected final MockStandardOutputStream _mockStandardErrorStream = new MockStandardOutputStream(this); public SystemInputOutputTester(final CallbackThrowsException callback) { _callback = callback; } public static SystemInputOutputTester whenCallingThisMethod(final CallbackThrowsException callback) { return new SystemInputOutputTester(callback); } public SystemInputOutputTester assertTextOutput(final String expectedOutputMessage) { _expectedInputOutputSteps.add(new ExpectedTextOutputStep(_mockStandardOutputStream, expectedOutputMessage)); return this; } public SystemInputOutputTester provideLineInput(final String lineOfInputText) { _expectedInputOutputSteps.add(new ProvideLineInputStep(lineOfInputText)); return this; } public void assertThatTheMethodReturnsHere() { final var oldInput = System.in; final var oldOutput = System.out; //final var oldError = System.err; try { System.setIn(_mockInputStream); System.setOut(new PrintStream(_mockStandardOutputStream)); //System.setErr(new PrintStream(_mockStandardErrorStream)); _expectedInputOutputStepIterator = _expectedInputOutputSteps.iterator(); try { _callback.callback(); } catch (final RuntimeException ex) { throw ex; } catch (final Exception ex) { throw new RuntimeException("Nested Exception " + ex.getClass().getName() + ": " + ex.getMessage(), ex); } System.out.flush(); System.err.flush(); while (_expectedInputOutputStepIterator.hasNext()) { final var step = _expectedInputOutputStepIterator.next(); final var isAfterExit = true; final var didProvideNewUserInput = step.validate(this); if (didProvideNewUserInput) { step.throwException("Program has exited, but the tests say that we should provide further input."); } } } finally { System.setIn(oldInput); System.setOut(oldOutput); //System.setErr(oldError); } } public void validateToAndIncludingNextInput() { System.out.flush(); System.err.flush(); while (_expectedInputOutputStepIterator.hasNext()) { final var step = _expectedInputOutputStepIterator.next(); final var isAfterExit = false; final var didProvideNewUserInput = step.validate(this); if (didProvideNewUserInput) { break; } } } protected void provideLineOfInput(final String lineOfInputText) { _mockInputStream.provideLineOfInput(lineOfInputText); } } //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // // File Name: library/CallbackThrowsException.java // package p20230510_TestConsoleIO.library; public interface CallbackThrowsException { public void callback() throws Exception; } //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // // File Name: library/MockStandardOutputStream.java // package p20230510_TestConsoleIO.library; import org.jetbrains.annotations.NotNull; import java.io.IOException; import java.io.OutputStream; import static org.junit.Assert.assertEquals; public class MockStandardOutputStream extends OutputStream { private final StringBuilder _sequenceOfByteValues = new StringBuilder(); private final StringBuilder _accumulatedOutput = new StringBuilder(); @Override public void write(final int byteValue) throws IOException { _sequenceOfByteValues.append((char) byteValue); } public StringBuilder getUpdatedStringBuilder() { if (_sequenceOfByteValues.length() > 0) { final String stringValue = getStringValue(); _accumulatedOutput.append(stringValue); } return _accumulatedOutput; } @NotNull private String getStringValue() { final var bytes = new byte[_sequenceOfByteValues.length()]; final var charArray = _sequenceOfByteValues.toString().toCharArray(); assertEquals(bytes.length, charArray.length); for (int idx = 0; idx < bytes.length; ++idx) { bytes[idx] = (byte) charArray[idx]; } _sequenceOfByteValues.setLength(0); final var stringValue = new String(bytes); return stringValue; } } //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // // File Name: library/MockInputStream.java // package p20230510_TestConsoleIO.library; import java.io.IOException; import java.io.InputStream; public class MockInputStream extends InputStream { private final SystemInputOutputTester _systemInputOutputTester; private final StringBuilder _inputToProvide = new StringBuilder(); public MockInputStream(final SystemInputOutputTester systemInputOutputTester) { _systemInputOutputTester = systemInputOutputTester; } @Override public int read() throws IOException { if (_inputToProvide.length() == 0) { _systemInputOutputTester.validateToAndIncludingNextInput(); } if (_inputToProvide.length() == 0) { return -1; // = End Of File } else { final var firstCharacter = _inputToProvide.charAt(0); _inputToProvide.delete(0, 1); return firstCharacter; } } @Override public int read(final byte byteArray[], final int offset, final int length) throws IOException { if (_inputToProvide.length() == 0) { _systemInputOutputTester.validateToAndIncludingNextInput(); } if (_inputToProvide.length() == 0) { return -1; // = End Of File } else { int charsRead = 0; for (; charsRead < length && _inputToProvide.length() > 0; ++charsRead) { final var firstCharacter = _inputToProvide.charAt(0); _inputToProvide.delete(0, 1); byteArray[offset + charsRead] = (byte) firstCharacter; } return charsRead; } } public void provideLineOfInput(final String lineOfInputText) { _inputToProvide.append(lineOfInputText); _inputToProvide.append('\n'); } } //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // // File Name: library/ExpectedInputOutputStepBase.java // package p20230510_TestConsoleIO.library; import junit.framework.AssertionFailedError; abstract public class ExpectedInputOutputStepBase { protected final AssertionFailedError _locationOfAssertionInitialization = new AssertionFailedError( "Location of assertion initialization. [Look up in the Test's chained method calls.]"); protected ExpectedInputOutputStepBase() { } public abstract boolean validate(final SystemInputOutputTester systemInputOutputTester); public void throwException(final String message) { final var ex = new AssertionFailedError(message + System.lineSeparator() + " " + this.toString()); ex.initCause(_locationOfAssertionInitialization); throw ex; } } //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // // File Name: library/ExpectedTextOutputStep.java // package p20230510_TestConsoleIO.library; import junit.framework.AssertionFailedError; public class ExpectedTextOutputStep extends ExpectedInputOutputStepBase { private final MockStandardOutputStream _mockOutputStream; private final String _expectedOutputMessage; public ExpectedTextOutputStep(final MockStandardOutputStream mockOutputStream, final String expectedOutputMessage) { _mockOutputStream = mockOutputStream; _expectedOutputMessage = expectedOutputMessage; } @Override public boolean validate(final SystemInputOutputTester systemInputOutputTester) { final var stringBuilder = _mockOutputStream.getUpdatedStringBuilder(); final var currentStringValue = stringBuilder.toString(); final var foundAtIndex = currentStringValue.indexOf(_expectedOutputMessage); if (foundAtIndex >= 0) { // Remove the string value we just found: stringBuilder.delete(0, foundAtIndex + _expectedOutputMessage.length()); // If followed immediately by a new line, remove that too: final var newlineCharacterSequence = System.lineSeparator(); if (stringBuilder.toString().startsWith(newlineCharacterSequence)) { stringBuilder.delete(0, newlineCharacterSequence.length()); } return false; // Did NOT provide new *input* data. } else { final var remainingOutputWithNewlinesVisible = currentStringValue .replace("\r\n", "[NL]") .replace("\r", "\\r") .replace("\n", "\\n"); final var failEx = new AssertionFailedError("Failed to find <" + _expectedOutputMessage + "> in <" + remainingOutputWithNewlinesVisible + ">"); failEx.initCause(super._locationOfAssertionInitialization); throw failEx; } } } //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // // File Name: library/ProvideLineInputStep.java // package p20230510_TestConsoleIO.library; public class ProvideLineInputStep extends ExpectedInputOutputStepBase { private final String _lineOfInputText; public ProvideLineInputStep(final String lineOfInputText) { _lineOfInputText = lineOfInputText; } @Override public boolean validate(final SystemInputOutputTester systemInputOutputTester) { systemInputOutputTester.provideLineOfInput(_lineOfInputText); return true; // *DID* provide new *input* data. } @Override public String toString() { return "ProvideLineInputStep('" + _lineOfInputText + "')"; } }