diff --git a/YKanBan.Tests/Models/RgbColorTests.cs b/YKanBan.Tests/Models/RgbColorTests.cs new file mode 100644 index 0000000..a37fd42 --- /dev/null +++ b/YKanBan.Tests/Models/RgbColorTests.cs @@ -0,0 +1,72 @@ +using YKanBan.Models; + +namespace YKanBan.Tests.Models; + +/// Tests for the RGB color value type and its #RRGGBB text conversions. +[TestClass] +public class RgbColorTests +{ + [TestMethod] + public void ParseExtractsComponents() + { + RgbColor color = RgbColor.Parse("#ff0a00"); + Assert.AreEqual(new RgbColor(0xFF, 0x0A, 0x00), color); + } + + [TestMethod] + public void ParseAcceptsUppercaseAndMixedCaseHex() + { + Assert.AreEqual(new RgbColor(0xAB, 0xCD, 0xEF), RgbColor.Parse("#ABCDEF")); + Assert.AreEqual(new RgbColor(0xAB, 0xCD, 0xEF), RgbColor.Parse("#aBcDeF")); + } + + [TestMethod] + [DataRow("ff0000")] + [DataRow("#ff000")] + [DataRow("#ff00000")] + [DataRow("#gg0000")] + [DataRow("#ff 000")] + [DataRow(" #ff0000")] + [DataRow("#ff0000 ")] + [DataRow("#")] + [DataRow("")] + public void TryParseRejectsMalformedText(string text) + { + Assert.IsFalse(RgbColor.TryParse(text, out _)); + } + + [TestMethod] + public void TryParseRejectsNull() + { + Assert.IsFalse(RgbColor.TryParse(null, out _)); + } + + [TestMethod] + public void ParseThrowsFormatExceptionForMalformedText() + { + Assert.ThrowsExactly(() => RgbColor.Parse("red")); + } + + [TestMethod] + public void ToStringProducesCanonicalLowercaseForm() + { + Assert.AreEqual("#ee0a0a", new RgbColor(0xEE, 0x0A, 0x0A).ToString()); + + // Uppercase input normalizes to the canonical lowercase output. + Assert.AreEqual("#ee0a0a", RgbColor.Parse("#EE0A0A").ToString()); + } + + [TestMethod] + public void EqualityComparesComponents() + { + Assert.AreEqual(RgbColor.Parse("#ff0000"), RgbColor.Parse("#FF0000")); + Assert.AreNotEqual(RgbColor.Parse("#ff0000"), RgbColor.Parse("#ff0001")); + } + + [TestMethod] + public void TextRoundTripPreservesColor() + { + RgbColor original = new(0x12, 0x34, 0x56); + Assert.AreEqual(original, RgbColor.Parse(original.ToString())); + } +} diff --git a/YKanBan.Tests/Storage/Workspace/WorkspaceRepositoryTests.cs b/YKanBan.Tests/Storage/Workspace/WorkspaceRepositoryTests.cs index 1582980..5981d83 100644 --- a/YKanBan.Tests/Storage/Workspace/WorkspaceRepositoryTests.cs +++ b/YKanBan.Tests/Storage/Workspace/WorkspaceRepositoryTests.cs @@ -235,7 +235,7 @@ public class WorkspaceRepositoryTests using var fixture = new RepositoryFixture(); fixture.Clock = 2000; - TagModel result = fixture.Repository.UpdateTag(fixture.BugTagId, "bug", "#ff0000", ""); + TagModel result = fixture.Repository.UpdateTag(fixture.BugTagId, "bug", RgbColor.Parse("#ff0000"), ""); Assert.AreEqual("bug", result.Name); Assert.AreEqual(0, fixture.AppDb.GetWorkspaces().Count); @@ -247,10 +247,10 @@ public class WorkspaceRepositoryTests using var fixture = new RepositoryFixture(); fixture.Clock = 3000; - TagModel updated = fixture.Repository.UpdateTag(fixture.BugTagId, "defect", "#ee0a0a", "desc"); + TagModel updated = fixture.Repository.UpdateTag(fixture.BugTagId, "defect", RgbColor.Parse("#ee0a0a"), "desc"); Assert.AreEqual("defect", updated.Name); - Assert.AreEqual("#ee0a0a", updated.Color); + Assert.AreEqual(new RgbColor(0xEE, 0x0A, 0x0A), updated.Color); Assert.AreEqual("desc", updated.Description); Assert.AreEqual(3000L, fixture.LastEditedAt); } diff --git a/YKanBan/Models/RgbColor.cs b/YKanBan/Models/RgbColor.cs new file mode 100644 index 0000000..9644bee --- /dev/null +++ b/YKanBan/Models/RgbColor.cs @@ -0,0 +1,63 @@ +namespace YKanBan.Models; + +/// +/// An RGB color without an alpha channel, stored as one byte per component +/// — mirroring the color structures of WinForms, WPF and Avalonia minus +/// the alpha byte. The type is total: every combination of components is +/// valid, and all validation happens at the text boundary. Converts to and +/// from the persisted "#RRGGBB" text form; ToString always produces the +/// canonical lowercase form (e.g. "#ff0a00"). +/// +public readonly record struct RgbColor(byte R, byte G, byte B) +{ + /// Parses a "#RRGGBB" text with case-insensitive hex digits. + /// The text is not in #RRGGBB form. + public static RgbColor Parse(string text) => + TryParse(text, out RgbColor color) + ? color + : throw new FormatException($"Not a valid #RRGGBB color: '{text}'."); + + /// Attempts to parse a "#RRGGBB" text; no whitespace tolerance. + public static bool TryParse(string? text, out RgbColor color) + { + color = default; + if (text is not { Length: 7 } || text[0] != '#') + { + return false; + } + + // Two hex digits per component: R, G, B. + if (TryHexPair(text, 1, out byte r) && TryHexPair(text, 3, out byte g) && TryHexPair(text, 5, out byte b)) + { + color = new RgbColor(r, g, b); + return true; + } + return false; + } + + /// Returns the canonical lowercase "#RRGGBB" form used by the database and exports. + public override string ToString() => $"#{R:x2}{G:x2}{B:x2}"; + + /// Parses exactly two hex digits into a byte value. + private static bool TryHexPair(string text, int start, out byte value) + { + value = 0; + int high = HexDigit(text[start]); + int low = HexDigit(text[start + 1]); + if (high < 0 || low < 0) + { + return false; + } + value = (byte)((high << 4) | low); + return true; + } + + /// Converts one hex digit to its value; -1 for any other character. + private static int HexDigit(char character) => character switch + { + >= '0' and <= '9' => character - '0', + >= 'a' and <= 'f' => character - 'a' + 10, + >= 'A' and <= 'F' => character - 'A' + 10, + _ => -1, + }; +} diff --git a/YKanBan/Models/TagModel.cs b/YKanBan/Models/TagModel.cs index c124ce6..b849bf5 100644 --- a/YKanBan/Models/TagModel.cs +++ b/YKanBan/Models/TagModel.cs @@ -3,6 +3,6 @@ namespace YKanBan.Models; /// A tag assignable to cards. Tags are never garbage-collected: unreferenced tags stay until deleted explicitly. /// Row id. /// Arbitrary non-empty text; no charset or length rules. -/// Fixed #RRGGBB form. +/// The tag color; persisted as #RRGGBB text. /// Free-form description, possibly empty. -public sealed record TagModel(long Id, string Name, string Color, string Description); +public sealed record TagModel(long Id, string Name, RgbColor Color, string Description); diff --git a/YKanBan/Storage/Workspace/WorkspaceRepository.cs b/YKanBan/Storage/Workspace/WorkspaceRepository.cs index 565e53d..3dbd4fa 100644 --- a/YKanBan/Storage/Workspace/WorkspaceRepository.cs +++ b/YKanBan/Storage/Workspace/WorkspaceRepository.cs @@ -145,7 +145,7 @@ public sealed class WorkspaceRepository : IDisposable } if (!reader.IsDBNull(6)) { - tags.Add(new TagModel(reader.GetInt64(6), reader.GetString(7), reader.GetString(8), reader.GetString(9))); + tags.Add(new TagModel(reader.GetInt64(6), reader.GetString(7), RgbColor.Parse(reader.GetString(8)), reader.GetString(9))); } } return cards; @@ -183,7 +183,7 @@ public sealed class WorkspaceRepository : IDisposable } if (!reader.IsDBNull(6)) { - tags.Add(new TagModel(reader.GetInt64(6), reader.GetString(7), reader.GetString(8), reader.GetString(9))); + tags.Add(new TagModel(reader.GetInt64(6), reader.GetString(7), RgbColor.Parse(reader.GetString(8)), reader.GetString(9))); } } return card; @@ -313,13 +313,13 @@ public sealed class WorkspaceRepository : IDisposable } /// Creates a tag. - /// The name is empty/not unique, or the color is not #RRGGBB. - public TagModel AddTag(string name, string color, string description) + /// The name is empty/not unique. + public TagModel AddTag(string name, RgbColor color, string description) { using var transaction = _connection.BeginTransaction(); long id = ExecuteInsert(transaction, "INSERT INTO tags (name, color, description) VALUES ($name, $color, $description);", - ("$name", name), ("$color", color), ("$description", description)); + ("$name", name), ("$color", color.ToString()), ("$description", description)); transaction.Commit(); TouchEdited(); @@ -328,8 +328,8 @@ public sealed class WorkspaceRepository : IDisposable /// Rewrites name, color and description; a no-change call writes nothing. /// The tag does not exist. - /// The new name is empty/not unique, or the color is not #RRGGBB. - public TagModel UpdateTag(long tagId, string name, string color, string description) + /// The new name is empty/not unique. + public TagModel UpdateTag(long tagId, string name, RgbColor color, string description) { TagModel existing = GetTag(tagId) ?? throw new InvalidOperationException($"Tag {tagId} does not exist."); @@ -343,7 +343,7 @@ public sealed class WorkspaceRepository : IDisposable using var command = CreateCommand( "UPDATE tags SET name = $name, color = $color, description = $description WHERE id = $id;"); command.Parameters.AddWithValue("$name", name); - command.Parameters.AddWithValue("$color", color); + command.Parameters.AddWithValue("$color", color.ToString()); command.Parameters.AddWithValue("$description", description); command.Parameters.AddWithValue("$id", tagId); command.ExecuteNonQuery(); @@ -394,9 +394,9 @@ public sealed class WorkspaceRepository : IDisposable private static ColumnModel ReadColumn(SqliteDataReader reader) => new(reader.GetInt64(0), reader.GetString(1), reader.GetString(2), reader.GetInt64(3), reader.GetInt64(4)); - /// Reads a tags row from the reader cursor. + /// Reads a tags row from the reader cursor; color text converts to components. private static TagModel ReadTag(SqliteDataReader reader) => - new(reader.GetInt64(0), reader.GetString(1), reader.GetString(2), reader.GetString(3)); + new(reader.GetInt64(0), reader.GetString(1), RgbColor.Parse(reader.GetString(2)), reader.GetString(3)); /// Creates a command bound to this connection. private SqliteCommand CreateCommand(string sql, SqliteTransaction? transaction = null)