diff --git a/00_Common/dotnet/Games.Common.Test/TextIOTests/TokenReaderTests.cs b/00_Common/dotnet/Games.Common.Test/TextIOTests/TokenReaderTests.cs new file mode 100644 index 00000000..34443dd2 --- /dev/null +++ b/00_Common/dotnet/Games.Common.Test/TextIOTests/TokenReaderTests.cs @@ -0,0 +1,88 @@ +using System; +using System.IO; +using System.Linq; +using FluentAssertions; +using FluentAssertions.Execution; +using Xunit; + +using static System.Environment; +using TwoStrings = System.ValueTuple; + +namespace Games.Common.IO +{ + public class TokenReaderTests + { + private readonly StringWriter _outputWriter; + + public TokenReaderTests() + { + _outputWriter = new StringWriter(); + } + + [Fact] + public void ReadTokens_QuantityNeededZero_ThrowsArgumentException() + { + var sut = CreateTokenReader(""); + + Action readTokens = () => sut.ReadTokens("", 0); + + readTokens.Should().Throw() + .WithMessage("'quantityNeeded' must be greater than zero.*") + .WithParameterName("quantityNeeded"); + } + + + [Theory] + [MemberData(nameof(ReadTokensTestCases))] + public void ReadTokens_ReadingValuesHasExpectedPromptsAndResults( + string prompt, + uint tokenCount, + string input, + string expectedOutput, + T[] expectedResult) + { + var sut = CreateTokenReader(input); + + var result = sut.ReadTokens(prompt, tokenCount); + var output = _outputWriter.ToString(); + + using var _ = new AssertionScope(); + output.Should().Be(expectedOutput); + result.Select(t => t.ToString()).Should().BeEquivalentTo(expectedResult); + } + + private TokenReader CreateTokenReader(string input) => + new TokenReader( + new TextIO( + new StringReader(input + NewLine), + _outputWriter)); + + public static TheoryData ReadTokensTestCases() + { + return new() + { + { "Name", 1, "Bill", "Name? ", new[] { "Bill" } }, + { "Names", 2, " Bill , Bloggs ", "Names? ", new[] { "Bill", "Bloggs" } }, + { "Names", 2, $" Bill{NewLine}Bloggs ", "Names? ?? ", new[] { "Bill", "Bloggs" } }, + { + "Foo", + 6, + $"1,2{NewLine}\" a,b \"{NewLine},\"\"c,d{NewLine}d\"x,e,f", + $"Foo? ?? ?? ?? !Extra input ingored{NewLine}", + new[] { "1", "2", " a,b ", "", "", "d\"x" } + } + }; + } + + public static TheoryData, string, string, TwoStrings> Read2StringsTestCases() + { + static Func Read2Strings(string prompt) => io => io.Read2Strings(prompt); + + return new() + { + { Read2Strings("2 strings"), ",", "2 strings? ", ("", "") }, + { Read2Strings("Input please"), "aBc , DeF ", "Input please? ", ("aBc", "DeF") }, + }; + } + } +} \ No newline at end of file diff --git a/00_Common/dotnet/Games.Common.Test/TextIOTests/TokenizerTests.cs b/00_Common/dotnet/Games.Common.Test/TextIOTests/TokenizerTests.cs index 4e9c9e84..a4e2f762 100644 --- a/00_Common/dotnet/Games.Common.Test/TextIOTests/TokenizerTests.cs +++ b/00_Common/dotnet/Games.Common.Test/TextIOTests/TokenizerTests.cs @@ -1,3 +1,4 @@ +using System.Linq; using FluentAssertions; using Xunit; @@ -11,7 +12,7 @@ namespace Games.Common.IO { var result = Tokenizer.ParseTokens(input); - result.Should().BeEquivalentTo(expected); + result.Select(t => t.ToString()).Should().BeEquivalentTo(expected); } public static TheoryData TokenizerTestCases() => new() @@ -26,8 +27,8 @@ namespace Games.Common.IO { "\"\"", new[] { "" } }, { ",", new[] { "", "" } }, { " foo ,bar", new[] { "foo", "bar" } }, - { "\"\"bc,de", new[] { "", "de" } }, - { "a\"b,\" c,d\"e, f ,,g", new[] { "a\"b", " c,d", "f", "", "g" } } + { "\"a\"bc,de", new[] { "a" } }, + { "a\"b,\" c,d\", f ,,g", new[] { "a\"b", " c,d", "f", "", "g" } } }; } } \ No newline at end of file diff --git a/00_Common/dotnet/Games.Common/IO/TokenReader.cs b/00_Common/dotnet/Games.Common/IO/TokenReader.cs new file mode 100644 index 00000000..0045433d --- /dev/null +++ b/00_Common/dotnet/Games.Common/IO/TokenReader.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; + +namespace Games.Common.IO +{ + internal class TokenReader + { + private readonly TextIO _io; + private readonly Func _isTokenValid; + + public TokenReader(TextIO io, Func? isTokenValid = null) + { + _io = io; + _isTokenValid = isTokenValid ?? (t => true); + } + + public IEnumerable ReadTokens(string prompt, uint quantityNeeded) + { + if (quantityNeeded == 0) + { + throw new ArgumentOutOfRangeException( + nameof(quantityNeeded), + $"'{nameof(quantityNeeded)}' must be greater than zero."); + } + + var tokens = new List(); + + while(tokens.Count < quantityNeeded) + { + tokens.AddRange(ReadValidTokens(prompt, quantityNeeded - (uint)tokens.Count)); + prompt = "?"; + } + + return tokens; + } + + private IEnumerable ReadValidTokens(string prompt, uint maxCount) + { + while (true) + { + var tokensValid = true; + var tokens = new List(); + foreach (var token in ReadLineOfTokens(prompt, maxCount)) + { + if (!_isTokenValid(token)) + { + tokensValid = false; + prompt = "?"; + break; + } + + tokens.Add(token); + } + + if (tokensValid) { return tokens; } + } + } + + private IEnumerable ReadLineOfTokens(string prompt, uint maxCount) + { + var tokenCount = 0; + + foreach (var token in Tokenizer.ParseTokens(_io.ReadLine(prompt))) + { + if (++tokenCount > maxCount) + { + _io.WriteLine("!Extra input ingored"); + break; + } + + yield return token; + } + } + } +} \ No newline at end of file diff --git a/00_Common/dotnet/Games.Common/IO/Tokenizer.cs b/00_Common/dotnet/Games.Common/IO/Tokenizer.cs index 9a5d43a4..14900eb1 100644 --- a/00_Common/dotnet/Games.Common/IO/Tokenizer.cs +++ b/00_Common/dotnet/Games.Common/IO/Tokenizer.cs @@ -12,14 +12,14 @@ namespace Games.Common.IO private Tokenizer(string input) => _characters = new Queue(input); - public static IEnumerable ParseTokens(string input) + public static IEnumerable ParseTokens(string input) { if (input is null) { throw new ArgumentNullException(nameof(input)); } return new Tokenizer(input).ParseTokens(); } - private IEnumerable ParseTokens() + private IEnumerable ParseTokens() { while (true) { @@ -72,13 +72,18 @@ namespace Games.Common.IO private struct InQuotedTokenState : ITokenizerState { public (ITokenizerState, Token) Consume(char character, Token token) => - character == Quote ? (new LookForSeparatorState(), token) : (this, token.Append(character)); + character == Quote ? (new ExpectSeparatorState(), token) : (this, token.Append(character)); } - private struct LookForSeparatorState : ITokenizerState + private struct ExpectSeparatorState : ITokenizerState { public (ITokenizerState, Token) Consume(char character, Token token) => - (character == Separator ? new AtEndOfTokenState() : this, token); + character == Separator ? (new AtEndOfTokenState(), token) : (new IgnoreRestOfLineState(), token); + } + + private struct IgnoreRestOfLineState : ITokenizerState + { + public (ITokenizerState, Token) Consume(char character, Token token) => (this, token); } private struct AtEndOfTokenState : ITokenizerState