1.2 FrameName value type
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -41,7 +41,7 @@ Each type lives in `src/FrameProcessor/Domain/`. Tests in `tests/FrameProcessor.
|
||||
- ASP.NET route value binding (e.g., `IParsable<MacAddress>`).
|
||||
- **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`.
|
||||
|
||||
104
src/FrameProcessor/Domain/FrameName.cs
Normal file
104
src/FrameProcessor/Domain/FrameName.cs
Normal file
@@ -0,0 +1,104 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace FrameProcessor.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// URL-friendly identifier for a frame. Valid characters are the RFC 3986
|
||||
/// "unreserved" set: <c>A-Z a-z 0-9 - . _ ~</c>. Must be non-empty.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(FrameNameJsonConverter))]
|
||||
public readonly record struct FrameName : IParsable<FrameName>, ISpanParsable<FrameName>
|
||||
{
|
||||
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<char> s, out FrameName result)
|
||||
=> TryParseCore(s, null, out result);
|
||||
|
||||
private static bool TryParseCore(ReadOnlySpan<char> 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<char> 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<char> 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<FrameName>
|
||||
{
|
||||
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);
|
||||
}
|
||||
100
tests/FrameProcessor.Tests/FrameNameTests.cs
Normal file
100
tests/FrameProcessor.Tests/FrameNameTests.cs
Normal file
@@ -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<FormatException>(() => 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<FrameName>(json);
|
||||
var written = JsonSerializer.Serialize(name);
|
||||
|
||||
Assert.Equal("living-room", name.Value);
|
||||
Assert.Equal(json, written);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsonDeserialize_BadInput_Throws()
|
||||
{
|
||||
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<FrameName>("\"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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user