fix(m2): fix repository tag model issue
This commit is contained in:
1 parent
01367edaa9
commit
e2bb0298b1
5 files changed
+150
-15
No files matched your search
@@ -0,0 +1,72 @@
|
||||
using YKanBan.Models;
|
||||
|
||||
namespace YKanBan.Tests.Models;
|
||||
|
||||
/// <summary>Tests for the RGB color value type and its #RRGGBB text conversions.</summary>
|
||||
[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<FormatException>(() => 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()));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
namespace YKanBan.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 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").
|
||||
/// </summary>
|
||||
public readonly record struct RgbColor(byte R, byte G, byte B)
|
||||
{
|
||||
/// <summary>Parses a "#RRGGBB" text with case-insensitive hex digits.</summary>
|
||||
/// <exception cref="FormatException">The text is not in #RRGGBB form.</exception>
|
||||
public static RgbColor Parse(string text) =>
|
||||
TryParse(text, out RgbColor color)
|
||||
? color
|
||||
: throw new FormatException($"Not a valid #RRGGBB color: '{text}'.");
|
||||
|
||||
/// <summary>Attempts to parse a "#RRGGBB" text; no whitespace tolerance.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Returns the canonical lowercase "#RRGGBB" form used by the database and exports.</summary>
|
||||
public override string ToString() => $"#{R:x2}{G:x2}{B:x2}";
|
||||
|
||||
/// <summary>Parses exactly two hex digits into a byte value.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Converts one hex digit to its value; -1 for any other character.</summary>
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,6 @@ namespace YKanBan.Models;
|
||||
/// <summary>A tag assignable to cards. Tags are never garbage-collected: unreferenced tags stay until deleted explicitly.</summary>
|
||||
/// <param name="Id">Row id.</param>
|
||||
/// <param name="Name">Arbitrary non-empty text; no charset or length rules.</param>
|
||||
/// <param name="Color">Fixed #RRGGBB form.</param>
|
||||
/// <param name="Color">The tag color; persisted as #RRGGBB text.</param>
|
||||
/// <param name="Description">Free-form description, possibly empty.</param>
|
||||
public sealed record TagModel(long Id, string Name, string Color, string Description);
|
||||
public sealed record TagModel(long Id, string Name, RgbColor Color, string Description);
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/// <summary>Creates a tag.</summary>
|
||||
/// <exception cref="SqliteException">The name is empty/not unique, or the color is not #RRGGBB.</exception>
|
||||
public TagModel AddTag(string name, string color, string description)
|
||||
/// <exception cref="SqliteException">The name is empty/not unique.</exception>
|
||||
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
|
||||
|
||||
/// <summary>Rewrites name, color and description; a no-change call writes nothing.</summary>
|
||||
/// <exception cref="InvalidOperationException">The tag does not exist.</exception>
|
||||
/// <exception cref="SqliteException">The new name is empty/not unique, or the color is not #RRGGBB.</exception>
|
||||
public TagModel UpdateTag(long tagId, string name, string color, string description)
|
||||
/// <exception cref="SqliteException">The new name is empty/not unique.</exception>
|
||||
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));
|
||||
|
||||
/// <summary>Reads a tags row from the reader cursor.</summary>
|
||||
/// <summary>Reads a tags row from the reader cursor; color text converts to components.</summary>
|
||||
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));
|
||||
|
||||
/// <summary>Creates a command bound to this connection.</summary>
|
||||
private SqliteCommand CreateCommand(string sql, SqliteTransaction? transaction = null)
|
||||
|
||||
Reference in new issue
Block a user