diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index 15c5624..f82009e 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -41,7 +41,7 @@ Each type lives in `src/FrameProcessor/Domain/`. Tests in `tests/FrameProcessor. - ASP.NET route value binding (e.g., `IParsable`). - **Tests:** `MacAddressTests` covering round-trip from all four input forms, equality on bytes, rejection of bad input. -### [ ] 1.2 `FrameName` +### [x] 1.2 `FrameName` - `record struct` wrapping a validated string. - Constructor / `TryParse` enforces RFC 3986 unreserved (`A-Z a-z 0-9 - . _ ~`), non-empty. - JSON converter + `IParsable`. diff --git a/src/FrameProcessor/Domain/FrameName.cs b/src/FrameProcessor/Domain/FrameName.cs new file mode 100644 index 0000000..ef3a883 --- /dev/null +++ b/src/FrameProcessor/Domain/FrameName.cs @@ -0,0 +1,104 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace FrameProcessor.Domain; + +/// +/// URL-friendly identifier for a frame. Valid characters are the RFC 3986 +/// "unreserved" set: A-Z a-z 0-9 - . _ ~. Must be non-empty. +/// +[JsonConverter(typeof(FrameNameJsonConverter))] +public readonly record struct FrameName : IParsable, ISpanParsable +{ + private readonly string? _value; + + private FrameName(string value) + { + _value = value; + } + + public string Value => _value ?? string.Empty; + + public override string ToString() => Value; + + public static bool TryParse(string? s, out FrameName result) + { + if (s is null) + { + result = default; + return false; + } + + return TryParseCore(s.AsSpan(), s, out result); + } + + public static bool TryParse(ReadOnlySpan s, out FrameName result) + => TryParseCore(s, null, out result); + + private static bool TryParseCore(ReadOnlySpan s, string? original, out FrameName result) + { + result = default; + if (s.IsEmpty) + { + return false; + } + + foreach (var c in s) + { + if (!IsUnreserved(c)) + { + return false; + } + } + + result = new FrameName(original ?? new string(s)); + return true; + } + + public static FrameName Parse(string s) + { + ArgumentNullException.ThrowIfNull(s); + return TryParse(s, out var result) + ? result + : throw new FormatException( + $"'{s}' is not a valid frame name. Allowed characters: A-Z a-z 0-9 - . _ ~ (non-empty)."); + } + + public static FrameName Parse(string s, IFormatProvider? provider) => Parse(s); + + public static bool TryParse(string? s, IFormatProvider? provider, out FrameName result) + => TryParse(s, out result); + + public static FrameName Parse(ReadOnlySpan s, IFormatProvider? provider) + => TryParse(s, out var result) + ? result + : throw new FormatException( + $"'{new string(s)}' is not a valid frame name. Allowed characters: A-Z a-z 0-9 - . _ ~ (non-empty)."); + + public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, out FrameName result) + => TryParse(s, out result); + + private static bool IsUnreserved(char c) + => (uint)(c - 'A') <= 25 + || (uint)(c - 'a') <= 25 + || (uint)(c - '0') <= 9 + || c is '-' or '.' or '_' or '~'; +} + +internal sealed class FrameNameJsonConverter : JsonConverter +{ + public override FrameName Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var s = reader.GetString(); + if (!FrameName.TryParse(s, out var name)) + { + throw new JsonException( + $"'{s}' is not a valid frame name. Allowed characters: A-Z a-z 0-9 - . _ ~ (non-empty)."); + } + + return name; + } + + public override void Write(Utf8JsonWriter writer, FrameName value, JsonSerializerOptions options) + => writer.WriteStringValue(value.Value); +} diff --git a/tests/FrameProcessor.Tests/FrameNameTests.cs b/tests/FrameProcessor.Tests/FrameNameTests.cs new file mode 100644 index 0000000..b054e2a --- /dev/null +++ b/tests/FrameProcessor.Tests/FrameNameTests.cs @@ -0,0 +1,100 @@ +using System.Text.Json; +using FrameProcessor.Domain; + +namespace FrameProcessor.Tests; + +public class FrameNameTests +{ + [Theory] + [InlineData("living-room")] + [InlineData("kitchen")] + [InlineData("Bedroom_2")] + [InlineData("frame.1")] + [InlineData("a~b")] + [InlineData("ABC")] + [InlineData("0")] + [InlineData("A-Za-z0-9-._~")] + public void TryParse_AcceptsUrlSafeNames(string input) + { + Assert.True(FrameName.TryParse(input, out var name)); + Assert.Equal(input, name.Value); + Assert.Equal(input, name.ToString()); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("living room")] // space + [InlineData("living/room")] // reserved + [InlineData("living?room")] // reserved + [InlineData("living#room")] // reserved + [InlineData("living%20room")] // percent + [InlineData("café")] // non-ASCII + [InlineData("\tname")] // whitespace + [InlineData("name\n")] // newline + public void TryParse_RejectsInvalidNames(string input) + { + Assert.False(FrameName.TryParse(input, out _)); + } + + [Fact] + public void TryParse_Null_ReturnsFalse() + { + Assert.False(FrameName.TryParse((string?)null, out _)); + } + + [Fact] + public void Parse_Throws_OnBadInput() + { + var ex = Assert.Throws(() => FrameName.Parse("bad name")); + Assert.Contains("bad name", ex.Message); + Assert.Contains("A-Z", ex.Message); + } + + [Fact] + public void Equality_IsValueWise() + { + Assert.True(FrameName.TryParse("living-room", out var a)); + Assert.True(FrameName.TryParse("living-room", out var b)); + Assert.True(FrameName.TryParse("kitchen", out var c)); + + Assert.Equal(a, b); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + Assert.NotEqual(a, c); + } + + [Fact] + public void JsonRoundTrip_PreservesValue() + { + var json = "\"living-room\""; + var name = JsonSerializer.Deserialize(json); + var written = JsonSerializer.Serialize(name); + + Assert.Equal("living-room", name.Value); + Assert.Equal(json, written); + } + + [Fact] + public void JsonDeserialize_BadInput_Throws() + { + Assert.Throws(() => JsonSerializer.Deserialize("\"bad name\"")); + } + + [Fact] + public void IParsable_Hookup_Works() + { + var name = FrameName.Parse("living-room", provider: null); + Assert.Equal("living-room", name.Value); + + Assert.True(FrameName.TryParse("living-room", provider: null, out var name2)); + Assert.Equal(name, name2); + } + + [Fact] + public void Default_HasEmptyValue() + { + FrameName name = default; + Assert.Equal(string.Empty, name.Value); + Assert.Equal(string.Empty, name.ToString()); + } +}