fix(m2): fix repository tag model issue

This commit is contained in:
yyc12345 committed 2026-10-01 21:34:58 +08:00
1 parent 01367edaa9
commit e2bb0298b1
5 files changed
+150 -15

No files matched your search

+72
View File
@@ -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);
}
+63
View File
@@ -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,
};
}
+2 -2
View File
@@ -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)