From 63186ee06dfca807a0c3f252835d6388feedd737 Mon Sep 17 00:00:00 2001 From: RobertBeekman Date: Sat, 24 Oct 2026 15:53:52 +0200 Subject: [PATCH] Core - Fix color gradient and utility bugs found by the tests (#914) * Profile adaption - Fix arrow keys section including Page Down - The arrow keys keyboard section now starts at Arrow Up instead of the key before it (Page Down) - Updated the keyboard section test to expect only the four arrow keys * Color quantizer - Let fully saturated colors be vibrant and grays be muted - The saturation limits of the color types are now inclusive, so 100% saturated colors count as vibrant and 0% saturated colors count as muted - The lightness limits stay exclusive so pure black and white are still left out - Updated the quantizer tests to match * Color gradients - Fix stop placement, seamless toggling and list events - Stops on an occupied position now move down 0.001 instead of 0.002 (rounding error in the distance check) - Flip, rotate, spread and interpolate move all stops together, so a stop can take a position another stop is leaving - Spreading or randomizing a single stop puts it at 0 instead of NaN - ToggleSeamless does nothing on an empty gradient and caps stretched stops at 1 instead of 100 - RemoveAt reports the removed stop and its index - Insert links the stop like Add does and raises the add before the move - Remove, RemoveAt, Clear and replacing a stop through the indexer unlink and stop listening to the actual removed stop - Copying from a list of stops sorts them by position - Obsolete GetPositionsArray no longer skips the seamless positions, obsolete GetColor picks the right colors after a repeat - Updated the gradient tests to match * Core - Fix small color, enum, string and type utility bugs - Darken keeps the alpha of the color - GetAllValuesAndDescriptions(Type) uses the [Description] attribute like the generic version - UrlFriendly cuts titles at 80 characters instead of 81 - RemapInternationalCharToAscii knows both cases of r, l, d, thorn, sharp s, h and j with accents - IsGenericType returns false instead of throwing when the base type is not generic - IsCastableFrom also finds conversion operators declared on the target type - GetDisplayName keeps the brackets of array types when humanizing - Updated the utility tests to match --- .../ColorScience/ColorQuantizerTests.cs | 16 +- .../Models/ColorGradientTests.cs | 159 +++++++++--------- .../Profiles/Adaption/AdaptionHintTests.cs | 3 +- .../Utilities/ColorExtensionsTests.cs | 5 +- .../Utilities/EnumUtilitiesTests.cs | 5 +- .../Utilities/StringUtilitiesTests.cs | 27 +-- .../Utilities/TypeExtensionsTests.cs | 24 +-- .../Quantization/ColorQuantizer.cs | 4 +- .../Extensions/SKColorExtensions.cs | 2 +- src/Artemis.Core/Extensions/TypeExtensions.cs | 19 ++- .../KeyboardSectionAdaptionHint.cs | 2 +- .../Models/Profile/Colors/ColorGradient.cs | 152 ++++++++++------- .../Profile/Colors/ColorGradientStop.cs | 29 +++- src/Artemis.Core/Utilities/EnumUtilities.cs | 12 +- src/Artemis.Core/Utilities/StringUtilities.cs | 16 +- 15 files changed, 274 insertions(+), 201 deletions(-) diff --git a/src/Artemis.Core.Tests/ColorScience/ColorQuantizerTests.cs b/src/Artemis.Core.Tests/ColorScience/ColorQuantizerTests.cs index 929fec2a1..74c0255ce 100644 --- a/src/Artemis.Core.Tests/ColorScience/ColorQuantizerTests.cs +++ b/src/Artemis.Core.Tests/ColorScience/ColorQuantizerTests.cs @@ -121,15 +121,17 @@ public class ColorQuantizerTests } [Fact] - public void FindColorVariation_FullySaturatedColorsAreNeverVibrant() + public void FindColorVariation_FullySaturatedColorsAreVibrantAndGraysAreMuted() { - // Pinned as-is: a color only counts as vibrant when its saturation is below the maximum of 100%, so pure colors like - // red are skipped. The same goes for grays, a color only counts as muted when it has more than 0% saturation - ColorQuantizer.FindColorVariation([SKColors.Red], ColorType.Vibrant).ShouldBe(SKColor.Empty); - ColorQuantizer.FindColorVariation([SKColors.Gray], ColorType.Muted).ShouldBe(SKColor.Empty); + ColorQuantizer.FindColorVariation([SKColors.Red], ColorType.Vibrant).ShouldBe(SKColors.Red); + ColorQuantizer.FindColorVariation([SKColors.Gray], ColorType.Muted).ShouldBe(SKColors.Gray); + } - ColorQuantizer.FindColorVariation([SKColors.Red], ColorType.Vibrant, true).ShouldBe(SKColors.Red); - ColorQuantizer.FindColorVariation([SKColors.Gray], ColorType.Muted, true).ShouldBe(SKColors.Gray); + [Fact] + public void FindColorVariation_PureBlackAndWhiteAreNeverMuted() + { + ColorQuantizer.FindColorVariation([SKColors.Black], ColorType.DarkMuted).ShouldBe(SKColor.Empty); + ColorQuantizer.FindColorVariation([SKColors.White], ColorType.LightMuted).ShouldBe(SKColor.Empty); } [Fact] diff --git a/src/Artemis.Core.Tests/Models/ColorGradientTests.cs b/src/Artemis.Core.Tests/Models/ColorGradientTests.cs index f40505c3e..cd8f13914 100644 --- a/src/Artemis.Core.Tests/Models/ColorGradientTests.cs +++ b/src/Artemis.Core.Tests/Models/ColorGradientTests.cs @@ -159,9 +159,7 @@ public class ColorGradientTests gradient.Add(stop); - // Pinned as-is: stops step down 0.001 at a time until they are at least 0.001 away from other stops, but 0.5 - 0.499 - // comes out a hair under 0.001 in floating point, so the stop takes a second step and ends up 0.002 lower - stop.Position.ShouldBe(0.498f, 0.0001f); + stop.Position.ShouldBe(0.499f, 0.0001f); gradient.IndexOf(stop).ShouldBe(1); } @@ -184,7 +182,7 @@ public class ColorGradientTests gradient.Add(stop); - stop.Position.ShouldBe(0.496f, 0.0001f); + stop.Position.ShouldBe(0.498f, 0.0001f); } [Fact] @@ -199,33 +197,32 @@ public class ColorGradientTests } [Fact] - public void Insert_RaisesTheMoveBeforeTheAdd() + public void Insert_RaisesAnAddAndThenAMoveIntoPlace() { ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Blue, 1)]; List events = RecordCollectionChanges(gradient); + ColorGradientStop middle = new(SKColors.Lime, 0.5f); - gradient.Insert(0, new ColorGradientStop(SKColors.Lime, 0.5f)); + gradient.Insert(0, middle); - // Pinned as-is: Insert reorders before reporting the add, so listeners hear about a move of a stop they - // were never told about, and the add then reports the index the stop ended up at. Add does it the other way around - events.Select(e => e.Action).ShouldBe([NotifyCollectionChangedAction.Move, NotifyCollectionChangedAction.Add]); - events[1].NewStartingIndex.ShouldBe(1); + events.Select(e => e.Action).ShouldBe([NotifyCollectionChangedAction.Add, NotifyCollectionChangedAction.Move]); + events[0].NewStartingIndex.ShouldBe(0); + events[0].NewItems![0].ShouldBeSameAs(middle); + gradient.IndexOf(middle).ShouldBe(1); } [Fact] - public void Insert_DoesNotMoveAStopOffAnOccupiedPosition() + public void Insert_MovesAStopOnAnOccupiedPositionSlightlyDown() { ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Blue, 0.5f)]; ColorGradientStop stop = new(SKColors.Lime, 0.5f); gradient.Insert(0, stop); - // Pinned as-is: unlike Add, Insert doesn't link the stop to the gradient, so two stops can share a position. - // Moving the stop later doesn't avoid other stops either - stop.Position.ShouldBe(0.5f); + // Like Add, the stop also keeps avoiding other stops when it is moved later + stop.Position.ShouldBe(0.499f, 0.0001f); stop.Position = 0f; - stop.Position.ShouldBe(0f); - gradient.Count(s => s.Position == 0f).ShouldBe(2); + stop.Position.ShouldBe(0.001f, 0.0001f); } [Fact] @@ -270,6 +267,21 @@ public class ColorGradientTests changes.ShouldBe(1); } + [Fact] + public void Indexer_LinksTheNewStopAndUnlinksTheReplacedStop() + { + ColorGradientStop red = new(SKColors.Red, 0); + ColorGradient gradient = [red, new ColorGradientStop(SKColors.Blue, 1)]; + ColorGradientStop lime = new(SKColors.Lime, 1); + + gradient[0] = lime; + red.Position = 1; + + // The new stop avoids the other stops, the replaced stop no longer does + lime.Position.ShouldBe(0.999f, 0.0001f); + red.Position.ShouldBe(1f); + } + [Fact] public void Remove_RemovesTheStopAndRaisesARemove() { @@ -298,20 +310,21 @@ public class ColorGradientTests } [Fact] - public void Remove_OfAnEqualStopRemovesTheGradientsStopButKeepsListeningToIt() + public void Remove_OfAnEqualStopRemovesTheGradientsStopAndStopsListeningToIt() { ColorGradientStop red = new(SKColors.Red, 0); ColorGradient gradient = [red, new ColorGradientStop(SKColors.Blue, 1)]; + List events = RecordCollectionChanges(gradient); int changes = 0; gradient.StopChanged += (_, _) => changes++; + // Stops are equal when their color and position match, so removing a look-alike removes the gradient's own stop gradient.Remove(new ColorGradientStop(SKColors.Red, 0)).ShouldBeTrue(); red.Color = SKColors.White; - // Pinned as-is: stops are equal when their color and position match, so removing a look-alike removes the - // gradient's own stop. Only the look-alike is unsubscribed though, the gradient keeps reacting to the removed stop gradient.Count.ShouldBe(1); - changes.ShouldBe(1); + events.Single().OldItems![0].ShouldBeSameAs(red); + changes.ShouldBe(0); } [Fact] @@ -329,7 +342,7 @@ public class ColorGradientTests } [Fact] - public void Remove_LeavesTheStopLinkedToTheGradient() + public void Remove_UnlinksTheStopFromTheGradient() { ColorGradientStop red = new(SKColors.Red, 0); ColorGradientStop blue = new(SKColors.Blue, 1); @@ -338,8 +351,8 @@ public class ColorGradientTests gradient.Remove(blue); blue.Position = 0f; - // Pinned as-is: a removed stop still avoids the positions of the stops in the gradient it was removed from - blue.Position.ShouldBe(0.001f, 0.0001f); + // A removed stop no longer avoids the positions of the stops in the gradient it was removed from + blue.Position.ShouldBe(0f); } [Fact] @@ -358,18 +371,17 @@ public class ColorGradientTests } [Fact] - public void RemoveAt_ReportsTheIndexAsTheRemovedStop() + public void RemoveAt_ReportsTheRemovedStopAndItsIndex() { - ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Blue, 1)]; + ColorGradientStop blue = new(SKColors.Blue, 1); + ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0), blue]; List events = RecordCollectionChanges(gradient); gradient.RemoveAt(1); - // Pinned as-is: the index is passed where the event expects the removed item, so listeners get the number 1 as - // the removed stop and no index. Remove reports the stop and its index properly events.Single().Action.ShouldBe(NotifyCollectionChangedAction.Remove); - events.Single().OldItems![0].ShouldBe(1); - events.Single().OldStartingIndex.ShouldBe(-1); + events.Single().OldItems![0].ShouldBeSameAs(blue); + events.Single().OldStartingIndex.ShouldBe(1); } [Fact] @@ -547,17 +559,20 @@ public class ColorGradientTests gradient.ToggleSeamless(); - // Pinned as-is: the remaining stops are stretched as if they were squeezed by an earlier toggle. Blue would land - // past the end (1.35) so it stays where it was, the cap of 100 in ToggleSeamless looks like it was meant to be 1 + // The remaining stops are stretched as if they were squeezed by an earlier toggle, Blue would land past the end + // (1.35) so it stops at the end gradient.Select(s => s.Color).ShouldBe([SKColors.Red, SKColors.Lime, SKColors.Blue]); - gradient.Select(s => s.Position).ShouldBe([0f, 0.75f, 0.9f]); + gradient.Select(s => s.Position).ShouldBe([0f, 0.75f, 1f]); } [Fact] - public void ToggleSeamless_OnAnEmptyGradientThrows() + public void ToggleSeamless_OnAnEmptyGradientDoesNothing() { - // Pinned as-is: an empty gradient counts as seamless, so toggling tries to remove a last stop that isn't there - Should.Throw(() => new ColorGradient().ToggleSeamless()); + ColorGradient gradient = []; + + gradient.ToggleSeamless(); + + gradient.ShouldBeEmpty(); } #endregion @@ -582,7 +597,7 @@ public class ColorGradientTests } [Fact] - public void SpreadStops_AvoidsAStopThatIsStillInTheWay() + public void SpreadStops_CanMoveAStopToWhereAnotherStopWas() { ColorGradient gradient = [ @@ -594,19 +609,18 @@ public class ColorGradientTests gradient.SpreadStops(); - // Pinned as-is: stops are moved one by one, Lime can't take 0.333 while Blue still sits there and moves down instead - gradient.Select(s => s.Position).ShouldBe([0f, 0.331f, 0.667f, 1f], 0.0001f); + // Lime takes 0.333 even though Blue is still there at the moment Lime moves + gradient.Select(s => s.Position).ShouldBe([0f, 0.333f, 0.667f, 1f], 0.0001f); } [Fact] - public void SpreadStops_WithASingleStopGivesItNoPosition() + public void SpreadStops_WithASingleStopMovesItToTheStart() { ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0.5f)]; gradient.SpreadStops(); - // Pinned as-is: spreading divides by the number of gaps, which is zero for a single stop - float.IsNaN(gradient[0].Position).ShouldBeTrue(); + gradient[0].Position.ShouldBe(0f); } [Fact] @@ -637,11 +651,9 @@ public class ColorGradientTests gradient.FlipStops(); - // Pinned as-is: stops are moved one by one, Red can't take 1 while Blue still sits there so it moves down (by - // 0.002, see Add_MovesAStopOnAnOccupiedPositionSlightlyDown). - // The same happens to any pair of stops on mirrored positions, like 0.1 and 0.9 + // Red and Blue swap places, even though the other one is still there at the moment the first of them moves gradient.Select(s => s.Color).ShouldBe([SKColors.Blue, SKColors.Lime, SKColors.Red]); - gradient.Select(s => s.Position).ShouldBe([0f, 0.75f, 0.998f], 0.0001f); + gradient.Select(s => s.Position).ShouldBe([0f, 0.75f, 1f], 0.0001f); } [Fact] @@ -656,10 +668,9 @@ public class ColorGradientTests gradient.RotateStops(false); - // Pinned as-is: stops are moved one by one, starting with the last stop which goes to the position of the first. - // The first stop is still there at that moment, so the last stop lands just below it + // The last stop wraps around to the position of the first gradient.Select(s => s.Color).ShouldBe([SKColors.Blue, SKColors.Red, SKColors.Lime]); - gradient.Select(s => s.Position).ShouldBe([0.099f, 0.5f, 0.9f], 0.0001f); + gradient.Select(s => s.Position).ShouldBe([0.1f, 0.5f, 0.9f], 0.0001f); } [Fact] @@ -674,10 +685,9 @@ public class ColorGradientTests gradient.RotateStops(true); - // Pinned as-is: like rotating forwards, the first stop to move lands just below the stop that still occupies its - // new position + // The first stop wraps around to the position of the last gradient.Select(s => s.Color).ShouldBe([SKColors.Lime, SKColors.Blue, SKColors.Red]); - gradient.Select(s => s.Position).ShouldBe([0.1f, 0.5f, 0.898f], 0.0001f); + gradient.Select(s => s.Position).ShouldBe([0.1f, 0.5f, 0.9f], 0.0001f); } [Fact] @@ -689,11 +699,10 @@ public class ColorGradientTests forward.RotateStops(false); backward.RotateStops(true); - // Pinned as-is: see above, at the start of the gradient there is no room below so the stop lands just above 0 forward.Select(s => s.Color).ShouldBe([SKColors.Blue, SKColors.Red, SKColors.Lime]); - forward.Select(s => s.Position).ShouldBe([0.001f, 0.5f, 1f], 0.0001f); + forward.Select(s => s.Position).ShouldBe([0f, 0.5f, 1f], 0.0001f); backward.Select(s => s.Color).ShouldBe([SKColors.Lime, SKColors.Blue, SKColors.Red]); - backward.Select(s => s.Position).ShouldBe([0f, 0.5f, 0.998f], 0.0001f); + backward.Select(s => s.Position).ShouldBe([0f, 0.5f, 1f], 0.0001f); } [Fact] @@ -732,14 +741,13 @@ public class ColorGradientTests } [Fact] - public void Randomize_WithASingleStopGivesItNoPosition() + public void Randomize_WithASingleStopPutsItAtTheStart() { ColorGradient gradient = []; gradient.Randomize(1); - // Pinned as-is: like SpreadStops, the spacing divides by the number of gaps, which is zero for a single stop - float.IsNaN(gradient.Single().Position).ShouldBeTrue(); + gradient.Single().Position.ShouldBe(0f); } [Fact] @@ -832,11 +840,10 @@ public class ColorGradientTests ColorGradient end = from.Interpolate(to, 1f); // The first two stops follow the target's stops, the third has no counterpart and takes the target's color at its - // own position. Pinned as-is: the second stop goes to the target's last stop at 1, which is still taken by the - // third stop, so it lands just below it + // own position. The second stop goes all the way to the target's last stop at 1, where the third stop also is end.Count.ShouldBe(3); end.Colors.ShouldBe([SKColors.Black, SKColors.White, SKColors.White]); - end.Positions.ShouldBe([0f, 0.998f, 1f], 0.0001f); + end.Positions.ShouldBe([0f, 1f, 1f], 0.0001f); } #endregion @@ -874,7 +881,7 @@ public class ColorGradientTests copyChanges.ShouldBe(2); originalChanges.ShouldBe(0); - copiedBlue.Position.ShouldBe(0.498f, 0.0001f, "the copied stops avoid each other's position"); + copiedBlue.Position.ShouldBe(0.499f, 0.0001f,"the copied stops avoid each other's position"); } [Fact] @@ -902,16 +909,14 @@ public class ColorGradientTests } [Fact] - public void Copy_FromAListKeepsTheOrderOfTheList() + public void Copy_FromAListSortsTheStops() { List stops = [new ColorGradientStop(SKColors.Blue, 1), new ColorGradientStop(SKColors.Red, 0)]; ColorGradient gradient = new(stops); - // Pinned as-is: the copy constructors don't sort, so stops out of order stay out of order until one of them changes, - // and GetColor relies on stops being in order - gradient.Colors.ShouldBe([SKColors.Blue, SKColors.Red]); - gradient.GetColor(0.5f).ShouldBe(SKColors.Blue); + gradient.Colors.ShouldBe([SKColors.Red, SKColors.Blue]); + gradient.GetColor(0.5f).ShouldBe(new SKColor(127, 0, 127)); } [Fact] @@ -967,13 +972,12 @@ public class ColorGradientTests } [Fact] - public void ObsoletePositionsArray_IgnoresSeamlessWithoutRepeats() + public void ObsoletePositionsArray_ClosesTheLoopWithoutRepeats() { ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Blue, 1)]; - // Pinned as-is: without repeats the seamless positions are skipped (the check looks inverted), so there is one - // position less than there are colors - GetPositionsArray(gradient, 0, true).ShouldBe([0f, 1f]); + // A position for each of the three colors + GetPositionsArray(gradient, 0, true).ShouldBe([0f, 0.5f, 1f]); GetColorsArray(gradient, 0, true).Length.ShouldBe(3); } @@ -996,24 +1000,21 @@ public class ColorGradientTests } [Fact] - public void ObsoleteGetColor_PicksTheWrongColorAfterARepeat() + public void ObsoleteGetColor_FadesAgainAfterARepeat() { ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Blue, 1)]; - // Pinned as-is: the second repeat starts at 0.5, where the first repeat also ends. Colors are looked up by - // position, which finds the end of the first repeat (blue) instead of the start of the second (red), so the - // second half is solid blue instead of fading from red to blue again - GetColor(gradient, 0.75f, 1, false).ShouldBe(SKColors.Blue); + // The second repeat starts at 0.5, where the first repeat ends, and fades from red to blue again + GetColor(gradient, 0.75f, 1, false).ShouldBe(new SKColor(127, 0, 127)); } [Fact] - public void ObsoleteGetColor_IgnoresSeamlessWithoutRepeats() + public void ObsoleteGetColor_SeamlessWithoutRepeatsTurnsBackTowardsTheFirstColor() { ColorGradient gradient = [new ColorGradientStop(SKColors.Red, 0), new ColorGradientStop(SKColors.Blue, 1)]; - // Pinned as-is: because of the missing seamless positions (see ObsoletePositionsArray_IgnoresSeamlessWithoutRepeats) - // this fades from red to blue as if seamless was off, instead of turning back towards red past the middle - GetColor(gradient, 0.75f, 0, true).ShouldBe(new SKColor(63, 0, 191)); + // Red at the start, blue in the middle and red again at the end + GetColor(gradient, 0.75f, 0, true).ShouldBe(new SKColor(127, 0, 127)); } [Fact] diff --git a/src/Artemis.Core.Tests/Profiles/Adaption/AdaptionHintTests.cs b/src/Artemis.Core.Tests/Profiles/Adaption/AdaptionHintTests.cs index 93022679f..12a5b0c92 100644 --- a/src/Artemis.Core.Tests/Profiles/Adaption/AdaptionHintTests.cs +++ b/src/Artemis.Core.Tests/Profiles/Adaption/AdaptionHintTests.cs @@ -148,8 +148,7 @@ public class AdaptionHintTests : AdaptionTestBase LedId.Keyboard_Num0, LedId.Keyboard_NumPeriodAndDelete ] }, - // Pinned as-is: the arrow keys section starts one key early and includes Page Down - {KeyboardSection.ArrowKeys, [LedId.Keyboard_PageDown, LedId.Keyboard_ArrowUp, LedId.Keyboard_ArrowLeft, LedId.Keyboard_ArrowDown, LedId.Keyboard_ArrowRight]}, + {KeyboardSection.ArrowKeys, [LedId.Keyboard_ArrowUp, LedId.Keyboard_ArrowLeft, LedId.Keyboard_ArrowDown, LedId.Keyboard_ArrowRight]}, { KeyboardSection.MediaKeys, [ LedId.Keyboard_MediaMute, LedId.Keyboard_MediaStop, LedId.Keyboard_MediaPreviousTrack, diff --git a/src/Artemis.Core.Tests/Utilities/ColorExtensionsTests.cs b/src/Artemis.Core.Tests/Utilities/ColorExtensionsTests.cs index a82320387..4d891fdd3 100644 --- a/src/Artemis.Core.Tests/Utilities/ColorExtensionsTests.cs +++ b/src/Artemis.Core.Tests/Utilities/ColorExtensionsTests.cs @@ -60,10 +60,9 @@ public class ColorExtensionsTests } [Fact] - public void Darken_MakesTheColorOpaque() + public void Darken_KeepsTheAlpha() { - // Pinned as-is: the color is rebuilt from its hue, saturation and lightness, which drops the alpha - SKColors.Red.WithAlpha(100).Darken(0.5f).Alpha.ShouldBe((byte) 255); + SKColors.Red.WithAlpha(100).Darken(0.5f).Alpha.ShouldBe((byte) 100); } [Theory] diff --git a/src/Artemis.Core.Tests/Utilities/EnumUtilitiesTests.cs b/src/Artemis.Core.Tests/Utilities/EnumUtilitiesTests.cs index f2556566f..5d1da63da 100644 --- a/src/Artemis.Core.Tests/Utilities/EnumUtilitiesTests.cs +++ b/src/Artemis.Core.Tests/Utilities/EnumUtilitiesTests.cs @@ -21,14 +21,13 @@ public class EnumUtilitiesTests } [Fact] - public void GetAllValuesAndDescriptions_UsesDescriptionsOnlyWhenGivenTheTypeAsGenericArgument() + public void GetAllValuesAndDescriptions_UsesDescriptionsBothWhenGivenTheTypeAsGenericArgumentAndAsType() { (KeyboardSection, string) generic = EnumUtilities.GetAllValuesAndDescriptions().Single(v => v.Item1 == KeyboardSection.LedStrips); (Enum, string) byType = EnumUtilities.GetAllValuesAndDescriptions(typeof(KeyboardSection)).Single(v => v.Item1.Equals(KeyboardSection.LedStrips)); - // Pinned as-is: the generic version uses the [Description] attribute, the version taking a type ignores it generic.Item2.ShouldBe("LED Strips"); - byType.Item2.ShouldBe("Led strips"); + byType.Item2.ShouldBe("LED Strips"); } [Fact] diff --git a/src/Artemis.Core.Tests/Utilities/StringUtilitiesTests.cs b/src/Artemis.Core.Tests/Utilities/StringUtilitiesTests.cs index b6abfbc8c..9f434fc9d 100644 --- a/src/Artemis.Core.Tests/Utilities/StringUtilitiesTests.cs +++ b/src/Artemis.Core.Tests/Utilities/StringUtilitiesTests.cs @@ -27,8 +27,7 @@ public class StringUtilitiesTests [Fact] public void UrlFriendly_CutsLongTitlesShort() { - // Pinned as-is: the limit is meant to be 80 characters, but the character at position 80 is still included - StringUtilities.UrlFriendly(new string('a', 100)).Length.ShouldBe(81); + StringUtilities.UrlFriendly(new string('a', 100)).Length.ShouldBe(80); } [Fact] @@ -64,14 +63,22 @@ public class StringUtilitiesTests } [Theory] - [InlineData('Ř')] - [InlineData('Ł')] - [InlineData('Đ')] - [InlineData('þ')] - public void RemapInternationalCharToAscii_OnlyKnowsOneCaseOfSomeLetters(char c) + [InlineData('ř', "r")] + [InlineData('Ř', "r")] + [InlineData('ł', "l")] + [InlineData('Ł', "l")] + [InlineData('đ', "d")] + [InlineData('Đ', "d")] + [InlineData('þ', "th")] + [InlineData('Þ', "th")] + [InlineData('ĥ', "h")] + [InlineData('Ĥ', "h")] + [InlineData('ĵ', "j")] + [InlineData('Ĵ', "j")] + [InlineData('ß', "ss")] + [InlineData('ẞ', "ss")] + public void RemapInternationalCharToAscii_KnowsBothCasesOfEveryLetter(char c, string expected) { - // Pinned as-is: most letters are lowercased before they are looked up, but ř, ł, đ, þ, ß, ĥ and ĵ are compared - // as typed, so their other case isn't recognized. For the thorn only the uppercase Þ is known - StringUtilities.RemapInternationalCharToAscii(c).ShouldBe(""); + StringUtilities.RemapInternationalCharToAscii(c).ShouldBe(expected); } } diff --git a/src/Artemis.Core.Tests/Utilities/TypeExtensionsTests.cs b/src/Artemis.Core.Tests/Utilities/TypeExtensionsTests.cs index 8a35dd6ef..fd315a94a 100644 --- a/src/Artemis.Core.Tests/Utilities/TypeExtensionsTests.cs +++ b/src/Artemis.Core.Tests/Utilities/TypeExtensionsTests.cs @@ -34,12 +34,11 @@ public class TypeExtensionsTests } [Fact] - public void IsGenericType_ThrowsWhenTheBaseTypeIsNotGeneric() + public void IsGenericType_IsFalseWhenTheBaseTypeIsNotGeneric() { - // Pinned as-is: asking for the generic definition of a base type that isn't generic throws instead of returning false. - // This includes types further down the line, only the direct base type is looked at - Should.Throw(() => typeof(string).IsGenericType(typeof(GenericBase<>))); - Should.Throw(() => typeof(DerivedTwice).IsGenericType(typeof(GenericBase<>))); + // Only the direct base type is looked at, so this includes types further down the line + typeof(string).IsGenericType(typeof(GenericBase<>)).ShouldBeFalse(); + typeof(DerivedTwice).IsGenericType(typeof(GenericBase<>)).ShouldBeFalse(); } [Theory] @@ -114,12 +113,14 @@ public class TypeExtensionsTests } [Fact] - public void IsCastableFrom_OnlyFindsOperatorsDeclaredOnTheSourceType() + public void IsCastableFrom_FindsOperatorsDeclaredOnEitherType() { + // Celsius declares the conversion to Fahrenheit, Fahrenheit declares the conversion from Kelvin typeof(Fahrenheit).IsCastableFrom(typeof(Celsius)).ShouldBeTrue(); + typeof(Fahrenheit).IsCastableFrom(typeof(Kelvin)).ShouldBeTrue(); - // Pinned as-is: Fahrenheit declares a conversion from Kelvin, but only the operators of Kelvin are looked at - typeof(Fahrenheit).IsCastableFrom(typeof(Kelvin)).ShouldBeFalse(); + // The operator on Fahrenheit only converts from Kelvin + typeof(Fahrenheit).IsCastableFrom(typeof(string)).ShouldBeFalse(); } [Theory] @@ -221,12 +222,11 @@ public class TypeExtensionsTests } [Fact] - public void GetDisplayName_OfAnArrayLosesItsBracketsWhenHumanized() + public void GetDisplayName_OfAnArrayKeepsItsBrackets() { typeof(ColorGradient[]).GetDisplayName().ShouldBe("ColorGradient[]"); - - // Pinned as-is: humanizing drops the brackets, so a list of gradients reads the same as a single gradient - typeof(ColorGradient[]).GetDisplayName(true).ShouldBe("Color gradient"); + typeof(ColorGradient[]).GetDisplayName(true).ShouldBe("Color gradient[]"); + typeof(int[,]).GetDisplayName().ShouldBe("int[,]"); } #endregion diff --git a/src/Artemis.Core/ColorScience/Quantization/ColorQuantizer.cs b/src/Artemis.Core/ColorScience/Quantization/ColorQuantizer.cs index 322ecdbea..5889132b6 100644 --- a/src/Artemis.Core/ColorScience/Quantization/ColorQuantizer.cs +++ b/src/Artemis.Core/ColorScience/Quantization/ColorQuantizer.cs @@ -182,10 +182,12 @@ public static class ColorQuantizer saturation /= 100f; luma /= 100f; - if (!ignoreLimits && ((saturation <= GetMinSaturation(type)) || (saturation >= GetMaxSaturation(type)) || (luma <= GetMinLuma(type)) || (luma >= GetMaxLuma(type)))) + if (!ignoreLimits && ((saturation < GetMinSaturation(type)) || (saturation > GetMaxSaturation(type)) || (luma <= GetMinLuma(type)) || (luma >= GetMaxLuma(type)))) { //if either saturation or luma falls outside the min-max, return the //lowest score possible unless we're ignoring these limits. + //the saturation limits are inclusive so fully saturated colors can be vibrant and grays can be muted, + //the luma limits are not so pure black and white are left out. return float.MinValue; } diff --git a/src/Artemis.Core/Extensions/SKColorExtensions.cs b/src/Artemis.Core/Extensions/SKColorExtensions.cs index b18e35e0f..f386abeca 100644 --- a/src/Artemis.Core/Extensions/SKColorExtensions.cs +++ b/src/Artemis.Core/Extensions/SKColorExtensions.cs @@ -67,7 +67,7 @@ public static class SKColorExtensions { c.ToHsl(out float h, out float s, out float l); l *= 1f - amount; - return SKColor.FromHsl(h, s, l); + return SKColor.FromHsl(h, s, l, c.Alpha); } private static byte ClampToByte(float value) diff --git a/src/Artemis.Core/Extensions/TypeExtensions.cs b/src/Artemis.Core/Extensions/TypeExtensions.cs index 2972c79ec..3c59ae1bb 100644 --- a/src/Artemis.Core/Extensions/TypeExtensions.cs +++ b/src/Artemis.Core/Extensions/TypeExtensions.cs @@ -55,7 +55,7 @@ public static class TypeExtensions if (type == null) return false; - return type.BaseType?.GetGenericTypeDefinition() == genericType; + return type.BaseType is {IsGenericType: true} baseType && baseType.GetGenericTypeDefinition() == genericType; } /// @@ -111,9 +111,7 @@ public static class TypeExtensions return true; if (PrimitiveTypeConversions.ContainsKey(to) && PrimitiveTypeConversions[to].Contains(from)) return true; - bool castable = from.GetMethods(BindingFlags.Public | BindingFlags.Static) - .Any(m => m.ReturnType == to && (m.Name == "op_Implicit" || m.Name == "op_Explicit")); - return castable; + return HasConversionOperator(to, from); } /// @@ -141,6 +139,15 @@ public static class TypeExtensions return 0; } + private static bool HasConversionOperator(Type to, Type from) + { + // A conversion operator can be declared on either of the two types + return from.GetMethods(BindingFlags.Public | BindingFlags.Static) + .Any(m => m.ReturnType == to && (m.Name == "op_Implicit" || m.Name == "op_Explicit")) || + to.GetMethods(BindingFlags.Public | BindingFlags.Static) + .Any(m => m.ReturnType == to && (m.Name == "op_Implicit" || m.Name == "op_Explicit") && m.GetParameters()[0].ParameterType.IsAssignableFrom(from)); + } + /// /// Returns the default value of the given type /// @@ -200,6 +207,10 @@ public static class TypeExtensions /// public static string GetDisplayName(this Type type, bool humanize = false) { + // Name the element type separately so humanizing doesn't drop the brackets + if (type.IsArray) + return $"{type.GetElementType()!.GetDisplayName(humanize)}[{new string(',', type.GetArrayRank() - 1)}]"; + if (!type.IsGenericType) { string displayValue = TypeKeywords.TryGetValue(type, out string? keyword) ? keyword! : type.Name; diff --git a/src/Artemis.Core/Models/Profile/AdaptionHints/KeyboardSectionAdaptionHint.cs b/src/Artemis.Core/Models/Profile/AdaptionHints/KeyboardSectionAdaptionHint.cs index df6a78d93..5f30b34ae 100644 --- a/src/Artemis.Core/Models/Profile/AdaptionHints/KeyboardSectionAdaptionHint.cs +++ b/src/Artemis.Core/Models/Profile/AdaptionHints/KeyboardSectionAdaptionHint.cs @@ -20,7 +20,7 @@ public class KeyboardSectionAdaptionHint : CorePropertyChanged, IAdaptionHint {KeyboardSection.FunctionKeys, Enum.GetValues().Where(l => l >= LedId.Keyboard_F1 && l <= LedId.Keyboard_F12).ToList()}, {KeyboardSection.NumberKeys, Enum.GetValues().Where(l => l >= LedId.Keyboard_1 && l <= LedId.Keyboard_0).ToList()}, {KeyboardSection.NumPad, Enum.GetValues().Where(l => l >= LedId.Keyboard_NumLock && l <= LedId.Keyboard_NumPeriodAndDelete).ToList()}, - {KeyboardSection.ArrowKeys, Enum.GetValues().Where(l => l >= LedId.Keyboard_PageDown && l <= LedId.Keyboard_ArrowRight).ToList()}, + {KeyboardSection.ArrowKeys, Enum.GetValues().Where(l => l >= LedId.Keyboard_ArrowUp && l <= LedId.Keyboard_ArrowRight).ToList()}, {KeyboardSection.MediaKeys, Enum.GetValues().Where(l => l >= LedId.Keyboard_MediaMute && l <= LedId.Keyboard_MediaNextTrack).ToList()}, }; diff --git a/src/Artemis.Core/Models/Profile/Colors/ColorGradient.cs b/src/Artemis.Core/Models/Profile/Colors/ColorGradient.cs index d36a07a4c..18476d896 100644 --- a/src/Artemis.Core/Models/Profile/Colors/ColorGradient.cs +++ b/src/Artemis.Core/Models/Profile/Colors/ColorGradient.cs @@ -87,6 +87,9 @@ public class ColorGradient : IList, IList, INotifyCollectionC stop.ColorGradient = this; _stops.Add(stop); } + + // The list may not be in order, the rest of the gradient expects the stops to be sorted by position + Update(); } /// @@ -129,7 +132,7 @@ public class ColorGradient : IList, IList, INotifyCollectionC [Obsolete("Use the Positions property instead", true)] public float[] GetPositionsArray(int timesToRepeat = 0, bool seamless = false) { - if (timesToRepeat == 0 && seamless) + if (timesToRepeat == 0 && !seamless) return Positions; List result = []; @@ -190,31 +193,34 @@ public class ColorGradient : IList, IList, INotifyCollectionC // Walk through the stops until we find the one at or after the requested position, that becomes the right stop // The left stop is the previous stop before the right one was found. - float left = stops[0]; - float? right = null; - foreach (float stop in stops) + // Keep track of indexes rather than positions, repeats share positions where one ends and the next begins + int leftIndex = 0; + int rightIndex = -1; + for (int i = 0; i < stops.Length; i++) { - if (stop >= position) + if (stops[i] >= position) { - right = stop; + rightIndex = i; break; } - left = stop; + leftIndex = i; } // Get the left stop's color - SKColor leftColor = colors[Array.IndexOf(stops, left)]; + float left = stops[leftIndex]; + SKColor leftColor = colors[leftIndex]; // If no right stop was found or the left and right stops are on the same spot, return the left stop's color - if (right == null || left == right) + if (rightIndex == -1 || left == stops[rightIndex]) return leftColor; // Get the right stop's color - SKColor rightColor = colors[Array.IndexOf(stops, right)]; + float right = stops[rightIndex]; + SKColor rightColor = colors[rightIndex]; // Interpolate the position between the left and right color - position = MathF.Round((position - left) / (right.Value - left), 2); + position = MathF.Round((position - left) / (right - left), 2); byte a = (byte) ((rightColor.Alpha - leftColor.Alpha) * position + leftColor.Alpha); byte r = (byte) ((rightColor.Red - leftColor.Red) * position + leftColor.Red); byte g = (byte) ((rightColor.Green - leftColor.Green) * position + leftColor.Green); @@ -284,17 +290,12 @@ public class ColorGradient : IList, IList, INotifyCollectionC /// public void SpreadStops() { - try + MoveStops(() => { - _updating = true; + // A single stop has nothing to spread across and stays at the start for (int i = 0; i < Count; i++) - this[i].Position = MathF.Round(i / ((float) Count - 1), 3, MidpointRounding.AwayFromZero); - } - finally - { - _updating = false; - Update(); - } + this[i].Position = Count > 1 ? MathF.Round(i / ((float) Count - 1), 3, MidpointRounding.AwayFromZero) : 0; + }); } /// @@ -307,6 +308,10 @@ public class ColorGradient : IList, IList, INotifyCollectionC /// public void ToggleSeamless() { + // An empty gradient has no stops to repeat or remove + if (Count == 0) + return; + try { _updating = true; @@ -321,7 +326,7 @@ public class ColorGradient : IList, IList, INotifyCollectionC { float multiplier = Count / (Count - 1f); foreach (ColorGradientStop stop in this) - stop.Position = MathF.Round(Math.Min(stop.Position * multiplier, 100f), 3, MidpointRounding.AwayFromZero); + stop.Position = MathF.Round(Math.Min(stop.Position * multiplier, 1f), 3, MidpointRounding.AwayFromZero); } } else @@ -348,17 +353,11 @@ public class ColorGradient : IList, IList, INotifyCollectionC /// public void FlipStops() { - try + MoveStops(() => { - _updating = true; foreach (ColorGradientStop stop in this) stop.Position = 1 - stop.Position; - } - finally - { - _updating = false; - Update(); - } + }); } /// @@ -368,9 +367,8 @@ public class ColorGradient : IList, IList, INotifyCollectionC /// A boolean indicating whether or not the invert the rotation. public void RotateStops(bool inverse) { - try + MoveStops(() => { - _updating = true; List stops = inverse ? this.OrderBy(s => s.Position).ToList() : this.OrderByDescending(s => s.Position).ToList(); @@ -378,12 +376,7 @@ public class ColorGradient : IList, IList, INotifyCollectionC float lastStopPosition = stops.Last().Position; foreach (ColorGradientStop stop in stops) (stop.Position, lastStopPosition) = (lastStopPosition, stop.Position); - } - finally - { - _updating = false; - Update(); - } + }); } /// @@ -401,7 +394,7 @@ public class ColorGradient : IList, IList, INotifyCollectionC for (int index = 0; index < stops; index++) { SKColor skColor = SKColor.FromHsv(random.NextSingle() * 360, 100, 100); - float position = 1f / (stops - 1f) * index; + float position = stops > 1 ? 1f / (stops - 1f) * index : 0; Add(new ColorGradientStop(skColor, position)); } } @@ -431,22 +424,25 @@ public class ColorGradient : IList, IList, INotifyCollectionC } // Interpolate stops - int index = 0; - foreach (ColorGradientStop stop in interpolated.ToList()) + interpolated.MoveStops(() => { - if (index < targetValue.Count) + int index = 0; + foreach (ColorGradientStop stop in interpolated) { - ColorGradientStop targetStop = targetValue[index]; - stop.Interpolate(targetStop, progress); - } - // Interpolate stops not on the target gradient - else - { - stop.Color = stop.Color.Interpolate(targetValue.GetColor(stop.Position), progress); - } + if (index < targetValue.Count) + { + ColorGradientStop targetStop = targetValue[index]; + stop.Interpolate(targetStop, progress); + } + // Interpolate stops not on the target gradient + else + { + stop.Color = stop.Color.Interpolate(targetValue.GetColor(stop.Position), progress); + } - index++; - } + index++; + } + }); return interpolated; } @@ -494,6 +490,28 @@ public class ColorGradient : IList, IList, INotifyCollectionC StopChanged?.Invoke(this, EventArgs.Empty); } + private void MoveStops(Action move) + { + // Unlink the stops while they move so a stop can take a position another stop is about to leave, + // otherwise it would avoid that stop and land next to it + try + { + _updating = true; + foreach (ColorGradientStop stop in _stops) + stop.ColorGradient = null; + + move(); + } + finally + { + foreach (ColorGradientStop stop in _stops) + stop.ColorGradient = this; + + _updating = false; + Update(); + } + } + private void Update() { _dirty = true; @@ -627,7 +645,11 @@ public class ColorGradient : IList, IList, INotifyCollectionC public void Clear() { foreach (ColorGradientStop item in _stops) + { item.PropertyChanged -= ItemOnPropertyChanged; + item.ColorGradient = null; + } + _stops.Clear(); OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); } @@ -673,13 +695,13 @@ public class ColorGradient : IList, IList, INotifyCollectionC /// public bool Remove(ColorGradientStop item) { - item.PropertyChanged -= ItemOnPropertyChanged; + // Stops are compared by color and position, so this may find a different stop that looks the same int index = _stops.IndexOf(item); - bool removed = _stops.Remove(item); - if (removed) - OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, item, index)); + if (index == -1) + return false; - return removed; + RemoveAt(index); + return true; } /// @@ -720,17 +742,23 @@ public class ColorGradient : IList, IList, INotifyCollectionC public void Insert(int index, ColorGradientStop item) { _stops.Insert(index, item); + item.ColorGradient = this; + // Update the position, reapplying the overlap-check in Position's setter + item.Position = item.Position; item.PropertyChanged += ItemOnPropertyChanged; + + OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, item, index)); Update(); - OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, item, _stops.IndexOf(item))); } /// public void RemoveAt(int index) { - _stops[index].PropertyChanged -= ItemOnPropertyChanged; + ColorGradientStop item = _stops[index]; + item.PropertyChanged -= ItemOnPropertyChanged; + item.ColorGradient = null; _stops.RemoveAt(index); - OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, index)); + OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, item, index)); } /// @@ -744,8 +772,12 @@ public class ColorGradient : IList, IList, INotifyCollectionC { ColorGradientStop? oldValue = _stops[index]; oldValue.PropertyChanged -= ItemOnPropertyChanged; + oldValue.ColorGradient = null; _stops[index] = value; - _stops[index].PropertyChanged += ItemOnPropertyChanged; + value.ColorGradient = this; + // Update the position, reapplying the overlap-check in Position's setter + value.Position = value.Position; + value.PropertyChanged += ItemOnPropertyChanged; Update(); OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Replace, value, oldValue)); } diff --git a/src/Artemis.Core/Models/Profile/Colors/ColorGradientStop.cs b/src/Artemis.Core/Models/Profile/Colors/ColorGradientStop.cs index ff692afc0..9bc5789ac 100644 --- a/src/Artemis.Core/Models/Profile/Colors/ColorGradientStop.cs +++ b/src/Artemis.Core/Models/Profile/Colors/ColorGradientStop.cs @@ -9,6 +9,9 @@ namespace Artemis.Core; /// public class ColorGradientStop : CorePropertyChanged { + // The smallest distance kept between two stops in the same gradient + private const float MINIMUM_DISTANCE = 0.001f; + private SKColor _color; private float _position; @@ -75,23 +78,31 @@ public class ColorGradientStop : CorePropertyChanged { if (ColorGradient == null) return stopPosition; + // Find the first available spot going down - while (ColorGradient.Any(s => !ReferenceEquals(s, this) && Math.Abs(s.Position - stopPosition) < 0.001f)) - stopPosition -= 0.001f; + float position = stopPosition; + for (int step = 1; IsOccupied(position); step++) + position = stopPosition - step * MINIMUM_DISTANCE; // If we ran out of space, try going up - if (stopPosition < 0) + if (position < 0) { - stopPosition = 0; - while (ColorGradient.Any(s => !ReferenceEquals(s, this) && Math.Abs(s.Position - stopPosition) < 0.001f)) - stopPosition += 0.001f; + position = 0; + for (int step = 1; IsOccupied(position); step++) + position = step * MINIMUM_DISTANCE; } // If we ran out of space there too, movement isn't possible - if (stopPosition > 1) - stopPosition = Position; + if (position > 1) + position = Position; - return stopPosition; + return position; + } + + private bool IsOccupied(float position) + { + // Allow for a little rounding error, 0.5 - 0.499 comes out a hair under 0.001 in floating point + return ColorGradient != null && ColorGradient.Any(s => !ReferenceEquals(s, this) && Math.Abs(s.Position - position) < MINIMUM_DISTANCE - 0.00001f); } /// diff --git a/src/Artemis.Core/Utilities/EnumUtilities.cs b/src/Artemis.Core/Utilities/EnumUtilities.cs index 6291d0867..2591ebae6 100644 --- a/src/Artemis.Core/Utilities/EnumUtilities.cs +++ b/src/Artemis.Core/Utilities/EnumUtilities.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using System.Linq; +using System.Reflection; using Humanizer; namespace Artemis.Core; @@ -30,7 +32,15 @@ public static class EnumUtilities if (!t.IsEnum) throw new ArgumentException($"{t} must be an enum type"); - return Enum.GetValues(t).Cast().Select(e => (e, HumanizeValue(e))).ToList(); + return Enum.GetValues(t).Cast().Select(e => (e, GetDescription(e))).ToList(); + } + + private static string GetDescription(Enum value) + { + // Use the [Description] attribute like the generic version does, Humanizer can only read it when the type is known + string name = value.ToString(); + DescriptionAttribute? description = value.GetType().GetField(name)?.GetCustomAttribute(); + return description?.Description ?? HumanizeValue(value); } /// diff --git a/src/Artemis.Core/Utilities/StringUtilities.cs b/src/Artemis.Core/Utilities/StringUtilities.cs index aba045180..862dce834 100644 --- a/src/Artemis.Core/Utilities/StringUtilities.cs +++ b/src/Artemis.Core/Utilities/StringUtilities.cs @@ -53,7 +53,7 @@ public static class StringUtilities if (prevlen != sb.Length) prevdash = false; } - if (i == maxlen) break; + if (i == maxlen - 1) break; } if (prevdash) @@ -114,31 +114,31 @@ public static class StringUtilities { return "g"; } - else if (c == 'ř') + else if (s == "ř") { return "r"; } - else if (c == 'ł') + else if (s == "ł") { return "l"; } - else if (c == 'đ') + else if (s == "đ") { return "d"; } - else if (c == 'ß') + else if (s == "ß") { return "ss"; } - else if (c == 'Þ') + else if (s == "þ") { return "th"; } - else if (c == 'ĥ') + else if (s == "ĥ") { return "h"; } - else if (c == 'ĵ') + else if (s == "ĵ") { return "j"; }