1.1 MacAddress value type
Permissive parser (colon, hyphen, unseparated; case-insensitive; surrounding whitespace) with canonical lowercase no-separator ToString. Implements IParsable/ISpanParsable for ASP.NET route binding and ships a System.Text.Json converter that normalizes on read. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -33,7 +33,7 @@ When working through this file, mark increments complete by changing `[ ]` to `[
|
||||
|
||||
Each type lives in `src/FrameProcessor/Domain/`. Tests in `tests/FrameProcessor.Tests/`.
|
||||
|
||||
### [ ] 1.1 `MacAddress`
|
||||
### [x] 1.1 `MacAddress`
|
||||
- `record struct` storing 6 bytes.
|
||||
- `static bool TryParse(string, out MacAddress)` accepting `AA:BB:CC:DD:EE:FF`, `aa-bb-cc-dd-ee-ff`, `AABBCCDDEEFF`, surrounding whitespace.
|
||||
- `ToString()` → canonical lowercase no-separator (`aabbccddeeff`).
|
||||
|
||||
156
src/FrameProcessor/Domain/MacAddress.cs
Normal file
156
src/FrameProcessor/Domain/MacAddress.cs
Normal file
@@ -0,0 +1,156 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace FrameProcessor.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// A 48-bit MAC address. Canonical form is lowercase hex without separators (e.g. <c>aabbccddeeff</c>);
|
||||
/// parsing accepts colon, hyphen, or unseparated hex with arbitrary surrounding whitespace.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(MacAddressJsonConverter))]
|
||||
public readonly record struct MacAddress : IParsable<MacAddress>, ISpanParsable<MacAddress>
|
||||
{
|
||||
private readonly byte _b0;
|
||||
private readonly byte _b1;
|
||||
private readonly byte _b2;
|
||||
private readonly byte _b3;
|
||||
private readonly byte _b4;
|
||||
private readonly byte _b5;
|
||||
|
||||
public MacAddress(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
if (bytes.Length != 6)
|
||||
{
|
||||
throw new ArgumentException("MAC address must be exactly 6 bytes.", nameof(bytes));
|
||||
}
|
||||
|
||||
_b0 = bytes[0];
|
||||
_b1 = bytes[1];
|
||||
_b2 = bytes[2];
|
||||
_b3 = bytes[3];
|
||||
_b4 = bytes[4];
|
||||
_b5 = bytes[5];
|
||||
}
|
||||
|
||||
public override string ToString() => string.Create(12, this, static (span, mac) =>
|
||||
{
|
||||
WriteHex(span[..2], mac._b0);
|
||||
WriteHex(span.Slice(2, 2), mac._b1);
|
||||
WriteHex(span.Slice(4, 2), mac._b2);
|
||||
WriteHex(span.Slice(6, 2), mac._b3);
|
||||
WriteHex(span.Slice(8, 2), mac._b4);
|
||||
WriteHex(span.Slice(10, 2), mac._b5);
|
||||
});
|
||||
|
||||
public static bool TryParse(string? s, out MacAddress result)
|
||||
{
|
||||
if (s is null)
|
||||
{
|
||||
result = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
return TryParse(s.AsSpan(), out result);
|
||||
}
|
||||
|
||||
public static bool TryParse(ReadOnlySpan<char> s, out MacAddress result)
|
||||
{
|
||||
result = default;
|
||||
s = s.Trim();
|
||||
if (s.IsEmpty)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Span<char> hex = stackalloc char[12];
|
||||
var written = 0;
|
||||
foreach (var c in s)
|
||||
{
|
||||
if (c is ':' or '-')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (written == 12)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!IsHexDigit(c))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
hex[written++] = c;
|
||||
}
|
||||
|
||||
if (written != 12)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Span<byte> bytes = stackalloc byte[6];
|
||||
for (var i = 0; i < 6; i++)
|
||||
{
|
||||
if (!byte.TryParse(hex.Slice(i * 2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var b))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bytes[i] = b;
|
||||
}
|
||||
|
||||
result = new MacAddress(bytes);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static MacAddress Parse(string s)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(s);
|
||||
return TryParse(s, out var result)
|
||||
? result
|
||||
: throw new FormatException($"'{s}' is not a valid MAC address.");
|
||||
}
|
||||
|
||||
public static MacAddress Parse(string s, IFormatProvider? provider) => Parse(s);
|
||||
|
||||
public static bool TryParse(string? s, IFormatProvider? provider, out MacAddress result)
|
||||
=> TryParse(s, out result);
|
||||
|
||||
public static MacAddress Parse(ReadOnlySpan<char> s, IFormatProvider? provider)
|
||||
=> TryParse(s, out var result)
|
||||
? result
|
||||
: throw new FormatException($"'{s}' is not a valid MAC address.");
|
||||
|
||||
public static bool TryParse(ReadOnlySpan<char> s, IFormatProvider? provider, out MacAddress result)
|
||||
=> TryParse(s, out result);
|
||||
|
||||
private static bool IsHexDigit(char c)
|
||||
=> (uint)(c - '0') <= 9 || (uint)(c - 'a') <= 5 || (uint)(c - 'A') <= 5;
|
||||
|
||||
private static void WriteHex(Span<char> dest, byte value)
|
||||
{
|
||||
dest[0] = HexChar(value >> 4);
|
||||
dest[1] = HexChar(value & 0xF);
|
||||
}
|
||||
|
||||
private static char HexChar(int nibble) => (char)(nibble < 10 ? '0' + nibble : 'a' + (nibble - 10));
|
||||
}
|
||||
|
||||
internal sealed class MacAddressJsonConverter : JsonConverter<MacAddress>
|
||||
{
|
||||
public override MacAddress Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
var s = reader.GetString();
|
||||
if (!MacAddress.TryParse(s, out var mac))
|
||||
{
|
||||
throw new JsonException($"'{s}' is not a valid MAC address.");
|
||||
}
|
||||
|
||||
return mac;
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, MacAddress value, JsonSerializerOptions options)
|
||||
=> writer.WriteStringValue(value.ToString());
|
||||
}
|
||||
112
tests/FrameProcessor.Tests/MacAddressTests.cs
Normal file
112
tests/FrameProcessor.Tests/MacAddressTests.cs
Normal file
@@ -0,0 +1,112 @@
|
||||
using System.Text.Json;
|
||||
using FrameProcessor.Domain;
|
||||
|
||||
namespace FrameProcessor.Tests;
|
||||
|
||||
public class MacAddressTests
|
||||
{
|
||||
private static readonly byte[] ExpectedBytes = [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF];
|
||||
private const string Canonical = "aabbccddeeff";
|
||||
|
||||
[Theory]
|
||||
[InlineData("AA:BB:CC:DD:EE:FF")]
|
||||
[InlineData("aa:bb:cc:dd:ee:ff")]
|
||||
[InlineData("AA-BB-CC-DD-EE-FF")]
|
||||
[InlineData("aa-bb-cc-dd-ee-ff")]
|
||||
[InlineData("AABBCCDDEEFF")]
|
||||
[InlineData("aabbccddeeff")]
|
||||
[InlineData(" AA:BB:CC:DD:EE:FF ")]
|
||||
[InlineData("\tAA-bb-CC-dd-EE-ff\n")]
|
||||
public void TryParse_AcceptedForms_ProduceCanonicalString(string input)
|
||||
{
|
||||
Assert.True(MacAddress.TryParse(input, out var mac));
|
||||
Assert.Equal(Canonical, mac.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equality_IsByteWise_AcrossInputForms()
|
||||
{
|
||||
Assert.True(MacAddress.TryParse("AA:BB:CC:DD:EE:FF", out var a));
|
||||
Assert.True(MacAddress.TryParse("aa-bb-cc-dd-ee-ff", out var b));
|
||||
Assert.True(MacAddress.TryParse("AABBCCDDEEFF", out var c));
|
||||
|
||||
Assert.Equal(a, b);
|
||||
Assert.Equal(b, c);
|
||||
Assert.Equal(a.GetHashCode(), b.GetHashCode());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equality_DiffersOnAnySingleByte()
|
||||
{
|
||||
Assert.True(MacAddress.TryParse("aabbccddeeff", out var a));
|
||||
Assert.True(MacAddress.TryParse("aabbccddeefe", out var b));
|
||||
|
||||
Assert.NotEqual(a, b);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("not-a-mac")]
|
||||
[InlineData("AA:BB:CC:DD:EE")] // too short
|
||||
[InlineData("AA:BB:CC:DD:EE:FF:00")] // too long
|
||||
[InlineData("ZZ:BB:CC:DD:EE:FF")] // non-hex digit
|
||||
[InlineData("AA:BB:CC:DD:EE:F")] // odd hex count
|
||||
[InlineData("AA BB CC DD EE FF")] // unsupported space separator
|
||||
public void TryParse_RejectsBadInput(string input)
|
||||
{
|
||||
Assert.False(MacAddress.TryParse(input, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_Null_ReturnsFalse()
|
||||
{
|
||||
Assert.False(MacAddress.TryParse((string?)null, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_Throws_OnBadInput()
|
||||
{
|
||||
Assert.Throws<FormatException>(() => MacAddress.Parse("nope"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_FromBytes_RoundTripsThroughToString()
|
||||
{
|
||||
var mac = new MacAddress(ExpectedBytes);
|
||||
Assert.Equal(Canonical, mac.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_FromWrongLength_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => new MacAddress(new byte[] { 0x00 }));
|
||||
Assert.Throws<ArgumentException>(() => new MacAddress(new byte[7]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsonRoundTrip_NormalizesToCanonicalForm()
|
||||
{
|
||||
var json = "\"AA:BB:CC:DD:EE:FF\"";
|
||||
var mac = JsonSerializer.Deserialize<MacAddress>(json);
|
||||
var written = JsonSerializer.Serialize(mac);
|
||||
|
||||
Assert.Equal($"\"{Canonical}\"", written);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsonDeserialize_BadInput_Throws()
|
||||
{
|
||||
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<MacAddress>("\"not-a-mac\""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IParsable_Hookup_Works()
|
||||
{
|
||||
var mac = MacAddress.Parse("AA:BB:CC:DD:EE:FF", provider: null);
|
||||
Assert.Equal(Canonical, mac.ToString());
|
||||
|
||||
Assert.True(MacAddress.TryParse("AA:BB:CC:DD:EE:FF", provider: null, out var mac2));
|
||||
Assert.Equal(mac, mac2);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1 @@
|
||||
namespace FrameProcessor.Tests;
|
||||
|
||||
public class UnitTest1
|
||||
{
|
||||
[Fact]
|
||||
public void Test1()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
namespace FrameProcessor.Tests;
|
||||
|
||||
Reference in New Issue
Block a user