Skip to content

Commit 6c680f6

Browse files
Copilotjongalloway
andauthored
fix: clamp ToHex inputs and add Vibrant/ToHex unit tests
Co-authored-by: jongalloway <68539+jongalloway@users.noreply.github.com> Agent-Logs-Url: https://github.com/jongalloway/DiagramForge/sessions/b80f6794-8fb7-4dfc-984f-9eb955250f03
1 parent 0e62a0a commit 6c680f6

2 files changed

Lines changed: 117 additions & 4 deletions

File tree

src/DiagramForge/Models/ColorUtils.cs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -244,11 +244,18 @@ public static string Vibrant(string hex, double amplify = 3.0)
244244

245245
/// <summary>
246246
/// Formats RGB(A) channels back to a hex color string.
247+
/// Channels are clamped to the valid byte range (0–255) before formatting.
247248
/// Omits the alpha byte when <paramref name="a"/> is 255 (fully opaque).
248249
/// </summary>
249-
public static string ToHex(int r, int g, int b, int a = 255) =>
250-
a == 255
251-
? $"#{r:X2}{g:X2}{b:X2}"
252-
: $"#{r:X2}{g:X2}{b:X2}{a:X2}";
250+
public static string ToHex(int r, int g, int b, int a = 255)
251+
{
252+
int cr = Clamp(r);
253+
int cg = Clamp(g);
254+
int cb = Clamp(b);
255+
int ca = Clamp(a);
256+
return ca == 255
257+
? $"#{cr:X2}{cg:X2}{cb:X2}"
258+
: $"#{cr:X2}{cg:X2}{cb:X2}{ca:X2}";
259+
}
253260
}
254261

tests/DiagramForge.Tests/Models/ColorUtilsTests.cs

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,5 +352,111 @@ public void ChooseTextColor_LightBackground_ReturnsDarkText()
352352
string chosen = ColorUtils.ChooseTextColor("#FFFFFF");
353353
Assert.Equal("#0F172A", chosen);
354354
}
355+
356+
// ── ToHex ─────────────────────────────────────────────────────────────────
357+
358+
[Fact]
359+
public void ToHex_OpaqueChannels_ReturnsRrggbbFormat()
360+
{
361+
string result = ColorUtils.ToHex(0x4F, 0x81, 0xBD);
362+
Assert.Equal("#4F81BD", result, ignoreCase: true);
363+
Assert.Equal(7, result.Length);
364+
}
365+
366+
[Fact]
367+
public void ToHex_WithNonOpaqueAlpha_ReturnsRrggbbaaFormat()
368+
{
369+
string result = ColorUtils.ToHex(0x4F, 0x81, 0xBD, 0xCC);
370+
Assert.Equal("#4F81BDCC", result, ignoreCase: true);
371+
Assert.Equal(9, result.Length);
372+
}
373+
374+
[Fact]
375+
public void ToHex_OpaqueAlpha255_OmitsAlphaByte()
376+
{
377+
string result = ColorUtils.ToHex(100, 150, 200, 255);
378+
Assert.Equal(7, result.Length); // #RRGGBB only
379+
}
380+
381+
[Fact]
382+
public void ToHex_OverflowChannels_ClampsTo255()
383+
{
384+
// Values above 255 should clamp — not produce out-of-range hex
385+
string result = ColorUtils.ToHex(300, 400, 500);
386+
Assert.Equal("#FFFFFF", result, ignoreCase: true);
387+
}
388+
389+
[Fact]
390+
public void ToHex_NegativeChannels_ClampsToZero()
391+
{
392+
string result = ColorUtils.ToHex(-10, -50, -100);
393+
Assert.Equal("#000000", result, ignoreCase: true);
394+
}
395+
396+
[Fact]
397+
public void ToHex_MixedOutOfRangeChannels_ClampedIndividually()
398+
{
399+
// r=300→FF, g=0x81 stays, b=-1→00
400+
string result = ColorUtils.ToHex(300, 0x81, -1);
401+
Assert.Equal("#FF8100", result, ignoreCase: true);
402+
}
403+
404+
// ── Vibrant ───────────────────────────────────────────────────────────────
405+
406+
[Fact]
407+
public void Vibrant_PastelInput_ProducesMoreSaturatedResult()
408+
{
409+
// A pastel blue with clear hue deviation across channels
410+
string pastel = "#AACCEE";
411+
string vibrant = ColorUtils.Vibrant(pastel);
412+
var (pr, pg, pb) = ColorUtils.ParseHex(pastel);
413+
var (vr, vg, vb) = ColorUtils.ParseHex(vibrant);
414+
415+
// The max-min spread (saturation proxy) should be larger after vibrant
416+
int pastelSpread = Math.Max(pr, Math.Max(pg, pb)) - Math.Min(pr, Math.Min(pg, pb));
417+
int vibrantSpread = Math.Max(vr, Math.Max(vg, vb)) - Math.Min(vr, Math.Min(vg, vb));
418+
Assert.True(vibrantSpread > pastelSpread, $"Expected vibrant spread ({vibrantSpread}) > pastel spread ({pastelSpread}).");
419+
}
420+
421+
[Fact]
422+
public void Vibrant_PreservesAlphaChannel()
423+
{
424+
string result = ColorUtils.Vibrant("#AACCEECC");
425+
var (_, _, _, a) = ColorUtils.ParseHexWithAlpha(result);
426+
Assert.Equal(0xCC, a);
427+
Assert.Equal(9, result.Length); // #RRGGBBAA
428+
}
429+
430+
[Fact]
431+
public void Vibrant_OpaqueInput_OutputHasNoAlphaSuffix()
432+
{
433+
string result = ColorUtils.Vibrant("#AACCEE");
434+
Assert.Equal(7, result.Length); // #RRGGBB
435+
}
436+
437+
[Fact]
438+
public void Vibrant_ReturnsValidHexString()
439+
{
440+
string result = ColorUtils.Vibrant("#B3D9F2");
441+
// Should start with # and be parseable
442+
Assert.StartsWith("#", result);
443+
var (r, g, b) = ColorUtils.ParseHex(result); // must not throw
444+
Assert.InRange(r, 0, 255);
445+
Assert.InRange(g, 0, 255);
446+
Assert.InRange(b, 0, 255);
447+
}
448+
449+
[Fact]
450+
public void Vibrant_HigherAmplify_ProducesMoreIntenseResult()
451+
{
452+
string pastel = "#AACCEE";
453+
string vibrant1 = ColorUtils.Vibrant(pastel, amplify: 2.0);
454+
string vibrant2 = ColorUtils.Vibrant(pastel, amplify: 4.0);
455+
var (r1, g1, b1) = ColorUtils.ParseHex(vibrant1);
456+
var (r2, g2, b2) = ColorUtils.ParseHex(vibrant2);
457+
int spread1 = Math.Max(r1, Math.Max(g1, b1)) - Math.Min(r1, Math.Min(g1, b1));
458+
int spread2 = Math.Max(r2, Math.Max(g2, b2)) - Math.Min(r2, Math.Min(g2, b2));
459+
Assert.True(spread2 >= spread1, "Higher amplify should produce equal or greater channel spread.");
460+
}
355461
}
356462

0 commit comments

Comments
 (0)