Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions src/Cuemon.Core/Globalization/UnM49DataContainer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@

internal Dictionary<string, StatisticalRegionInfo> CountriesByIsoAlpha2 { get; } = new(StringComparer.OrdinalIgnoreCase);

private (List<Unm49RegionData> Regions, List<Unm49CountryData> Countries) LoadUnM49Data()

Check warning on line 26 in src/Cuemon.Core/Globalization/UnM49DataContainer.cs

View workflow job for this annotation

GitHub Actions / call-sonarcloud / 🔬 Code Quality Analysis

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed. (https://rules.sonarsource.com/csharp/RSPEC-3776)

Check warning on line 26 in src/Cuemon.Core/Globalization/UnM49DataContainer.cs

View workflow job for this annotation

GitHub Actions / call-sonarcloud / 🔬 Code Quality Analysis

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed. (https://rules.sonarsource.com/csharp/RSPEC-3776)
{
var resourceName = $"{nameof(Cuemon)}.{nameof(Globalization)}.unm49-data.csv";
var regions = new List<Unm49RegionData>();
Expand Down Expand Up @@ -96,7 +96,7 @@
{
// Escaped quote
current.Append('"');
i++;

Check warning on line 99 in src/Cuemon.Core/Globalization/UnM49DataContainer.cs

View workflow job for this annotation

GitHub Actions / call-sonarcloud / 🔬 Code Quality Analysis

Do not update the loop counter 'i' within the loop body. (https://rules.sonarsource.com/csharp/RSPEC-127)

Check warning on line 99 in src/Cuemon.Core/Globalization/UnM49DataContainer.cs

View workflow job for this annotation

GitHub Actions / call-sonarcloud / 🔬 Code Quality Analysis

Do not update the loop counter 'i' within the loop body. (https://rules.sonarsource.com/csharp/RSPEC-127)
}
else
{
Expand All @@ -118,7 +118,7 @@
return parts.ToArray();
}

private void BuildHierarchy(List<Unm49RegionData> regions, List<Unm49CountryData> countries)

Check warning on line 121 in src/Cuemon.Core/Globalization/UnM49DataContainer.cs

View workflow job for this annotation

GitHub Actions / call-sonarcloud / 🔬 Code Quality Analysis

Refactor this method to reduce its Cognitive Complexity from 20 to the 15 allowed. (https://rules.sonarsource.com/csharp/RSPEC-3776)

Check warning on line 121 in src/Cuemon.Core/Globalization/UnM49DataContainer.cs

View workflow job for this annotation

GitHub Actions / call-sonarcloud / 🔬 Code Quality Analysis

Refactor this method to reduce its Cognitive Complexity from 20 to the 15 allowed. (https://rules.sonarsource.com/csharp/RSPEC-3776)
{
// First pass: Create all region objects
foreach (var regionData in regions)
Expand All @@ -142,6 +142,15 @@
}

// Third pass: Create country objects as children of their parent regions
var regionsByIsoAlpha2 = new Dictionary<string, RegionInfo>(StringComparer.OrdinalIgnoreCase);
foreach (var r in World.Regions)
{
if (!regionsByIsoAlpha2.ContainsKey(r.TwoLetterISORegionName))
{
regionsByIsoAlpha2[r.TwoLetterISORegionName] = r;
}
}
Comment on lines +146 to +152

foreach (var countryData in countries)
{
if (RegionsByCode.TryGetValue(countryData.ParentCode, out var parent))
Expand All @@ -154,15 +163,10 @@
$"Country {countryData.Name} ({countryData.Code}) must have kind 'CountryOrTerritory', but was '{countryData.Kind}'.");
}

// Try to find matching RegionInfo
RegionInfo regionInfo = null;
try
{
regionInfo = World.Regions.FirstOrDefault(r => string.Equals(r.TwoLetterISORegionName, countryData.IsoAlpha2, StringComparison.OrdinalIgnoreCase));
}
catch
if (!string.IsNullOrEmpty(countryData.IsoAlpha2))
{
// Some territories may not be supported by the OS
regionsByIsoAlpha2.TryGetValue(countryData.IsoAlpha2, out regionInfo);
}

var country = new StatisticalRegionInfo(
Expand Down Expand Up @@ -207,7 +211,7 @@
return kind;
}

private void ValidateHierarchy()

Check warning on line 214 in src/Cuemon.Core/Globalization/UnM49DataContainer.cs

View workflow job for this annotation

GitHub Actions / call-sonarcloud / 🔬 Code Quality Analysis

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed. (https://rules.sonarsource.com/csharp/RSPEC-3776)

Check warning on line 214 in src/Cuemon.Core/Globalization/UnM49DataContainer.cs

View workflow job for this annotation

GitHub Actions / call-sonarcloud / 🔬 Code Quality Analysis

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed. (https://rules.sonarsource.com/csharp/RSPEC-3776)
{
// Validate World has no parent
if (!RegionsByCode.TryGetValue("001", out var world) || world == null)
Expand Down
10 changes: 6 additions & 4 deletions src/Cuemon.Core/Globalization/World.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Cuemon.Collections.Generic;

namespace Cuemon.Globalization
{
Expand All @@ -14,9 +15,9 @@ public static class World
{
var cultures = new SortedList<string, CultureInfo>();
var specificCultures = CultureInfo.GetCultures(CultureTypes.SpecificCultures);
foreach (var c in specificCultures.Where(ci => ci.LCID != 127))
foreach (var c in specificCultures.Where(ci => ci.LCID != 127)) // *should* not happen for specific cultures, but on some linux based systems there are some cultures with LCID 127 (invariant culture) that are incorrectly categorized as specific cultures, so we need to filter those out.
{
if (!cultures.ContainsKey(c.DisplayName)) { cultures.Add(c.DisplayName, c); }
Decorator.Enclose(cultures).TryAdd(c.Name, c);
}
return cultures.Values;
});
Expand All @@ -27,9 +28,10 @@ public static class World
foreach (var c in SpecificCultures.Value)
{
var region = new RegionInfo(c.Name);
if (!regions.ContainsKey(region.EnglishName)) { regions.Add(region.EnglishName, region); }
if (int.TryParse(region.Name, out _)) { continue; } // Skip statistical regions (why would Microsoft even consider having these as part of RegionInfo? No valid ISO 3166-1 alpha-2/3 code can be all digits, so these are not actual regions or countries.)
Decorator.Enclose(regions).TryAdd($"{region.Name}:{region.NativeName}", region);
}
return regions.Values;
return regions.Values.OrderBy(info => info.Name).ThenBy(info => info.NativeName);
});

private static readonly Lazy<UnM49DataContainer> UnM49Data = new(() => new UnM49DataContainer());
Expand Down
89 changes: 84 additions & 5 deletions test/Cuemon.Core.Tests/Globalization/WorldTest.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Codebelt.Extensions.Xunit;
Expand All @@ -13,17 +14,95 @@ public WorldTest(ITestOutputHelper output) : base(output)
}

[Fact]
public void Regions_ShouldReturnAllRegions()
public void Regions_ShouldContainAllExpectedIsoRegionCodes_ForBackwardCompatibility()
{
var expectedTwoLetterIsoCodes = new HashSet<string>(StringComparer.Ordinal)
{
"AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AR", "AS", "AT", "AU", "AW", "AX", "AZ",
"BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL", "BM", "BN", "BO", "BQ", "BR", "BS", "BT", "BW", "BY", "BZ",
"CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN", "CO", "CR", "CU", "CV", "CW", "CX", "CY", "CZ",
"DE", "DJ", "DK", "DM", "DO", "DZ",
"EC", "EE", "EG", "ER", "ES", "ET",
"FI", "FJ", "FK", "FM", "FO", "FR",
"GA", "GB", "GD", "GE", "GF", "GG", "GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", "GT", "GU", "GW", "GY",
"HK", "HN", "HR", "HT", "HU",
"ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", "IS", "IT",
"JE", "JM", "JO", "JP",
"KE", "KG", "KH", "KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ",
"LA", "LB", "LC", "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY",
"MA", "MC", "MD", "ME", "MF", "MG", "MH", "MK", "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", "MX", "MY", "MZ",
"NA", "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", "NR", "NU", "NZ",
"OM",
"PA", "PE", "PF", "PG", "PH", "PK", "PL", "PM", "PN", "PR", "PS", "PT", "PW", "PY",
"QA",
"RE", "RO", "RS", "RU", "RW",
"SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", "SN", "SO", "SR", "SS", "ST", "SV", "SX", "SY", "SZ",
"TC", "TD", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO", "TR", "TT", "TV", "TW", "TZ",
"UA", "UG", "UM", "US", "UY", "UZ",
"VA", "VC", "VE", "VG", "VI", "VN", "VU",
"WF", "WS",
"XK",
"YE", "YT",
"ZA", "ZM", "ZW"
};

var sut1 = World.Regions.ToList();
var actualCodes = new HashSet<string>(sut1.Select(r => r.Name), StringComparer.Ordinal);

#if NET48_OR_GREATER
Assert.NotEmpty(actualCodes);
Assert.True(actualCodes.Count > 100, "actualCodes.Count > 100");
#else
var missing = expectedTwoLetterIsoCodes.Except(actualCodes).OrderBy(c => c).ToList();
var added = actualCodes.Except(expectedTwoLetterIsoCodes).OrderBy(c => c).ToList();
foreach (var code in missing)
{
TestOutput.WriteLine($"Missing: {code} - {World.Regions.SingleOrDefault(info => info.Name == code).EnglishName}");
}
foreach (var code in added)
{
TestOutput.WriteLine($"Added: {code} - {World.Regions.Last(info => info.Name == code).EnglishName}");
Comment on lines +60 to +64
Copy link

Copilot AI Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test iterates through missing codes and tries to find the region info with World.Regions.SingleOrDefault(info => info.Name == code). However, when a code is missing, this will return null, and attempting to access .EnglishName on null will cause a NullReferenceException. The same issue exists on line 64 with the added codes, though using .Last() instead of .SingleOrDefault().

Consider changing line 60 to handle null values:

TestOutput.WriteLine($"Missing: {code} - {World.Regions.SingleOrDefault(info => info.Name == code)?.EnglishName ?? "Not found"}");

And line 64 should use .LastOrDefault() to be safe:

TestOutput.WriteLine($"Added: {code} - {World.Regions.LastOrDefault(info => info.Name == code)?.EnglishName ?? "Unknown"}");
Suggested change
TestOutput.WriteLine($"Missing: {code} - {World.Regions.SingleOrDefault(info => info.Name == code).EnglishName}");
}
foreach (var code in added)
{
TestOutput.WriteLine($"Added: {code} - {World.Regions.Last(info => info.Name == code).EnglishName}");
TestOutput.WriteLine($"Missing: {code} - {World.Regions.SingleOrDefault(info => info.Name == code)?.EnglishName ?? "Not found"}");
}
foreach (var code in added)
{
TestOutput.WriteLine($"Added: {code} - {World.Regions.LastOrDefault(info => info.Name == code)?.EnglishName ?? "Unknown"}");

Copilot uses AI. Check for mistakes.
}
Comment on lines +56 to +65
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

NullReferenceException on line 60 when a code is absent from World.Regions.

missing contains ISO codes that are in expectedTwoLetterIsoCodes but not in actualCodes (which comes from World.Regions). Calling World.Regions.SingleOrDefault(info => info.Name == code) for such a code returns null (RegionInfo is a reference type), and then .EnglishName throws before Assert.Empty(missing) is ever reached — replacing a clean test failure with an opaque NullReferenceException.

Additionally, World.Regions is re-enumerated inside both loops (lines 60 and 64) instead of reusing the already-materialized sut1.

🐛 Proposed fix — null-safe lookup, reuse sut1
-            foreach (var code in missing)
-            {
-                TestOutput.WriteLine($"Missing: {code} - {World.Regions.SingleOrDefault(info => info.Name == code).EnglishName}");
-            }
-            foreach (var code in added)
-            {
-                TestOutput.WriteLine($"Added: {code} - {World.Regions.Last(info => info.Name == code).EnglishName}");
-            }
+            foreach (var code in missing)
+            {
+                TestOutput.WriteLine($"Missing: {code} - {sut1.SingleOrDefault(info => info.Name == code)?.EnglishName ?? "(not found)"}");
+            }
+            foreach (var code in added)
+            {
+                TestOutput.WriteLine($"Added: {code} - {sut1.Last(info => info.Name == code).EnglishName}");
+            }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
var missing = expectedTwoLetterIsoCodes.Except(actualCodes).OrderBy(c => c).ToList();
var added = actualCodes.Except(expectedTwoLetterIsoCodes).OrderBy(c => c).ToList();
foreach (var code in missing)
{
TestOutput.WriteLine($"Missing: {code} - {World.Regions.SingleOrDefault(info => info.Name == code).EnglishName}");
}
foreach (var code in added)
{
TestOutput.WriteLine($"Added: {code} - {World.Regions.Last(info => info.Name == code).EnglishName}");
}
var missing = expectedTwoLetterIsoCodes.Except(actualCodes).OrderBy(c => c).ToList();
var added = actualCodes.Except(expectedTwoLetterIsoCodes).OrderBy(c => c).ToList();
foreach (var code in missing)
{
TestOutput.WriteLine($"Missing: {code} - {sut1.SingleOrDefault(info => info.Name == code)?.EnglishName ?? "(not found)"}");
}
foreach (var code in added)
{
TestOutput.WriteLine($"Added: {code} - {sut1.Last(info => info.Name == code).EnglishName}");
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/Cuemon.Core.Tests/Globalization/WorldTest.cs` around lines 56 - 65, The
test throws a NullReferenceException because missing codes come from
expectedTwoLetterIsoCodes but aren't present in World.Regions, and the code
calls .EnglishName on a null result while also re-enumerating World.Regions; fix
by reusing the materialized collection sut1 (the list derived from
World.Regions) for lookups and perform a null-safe lookup when writing
TestOutput.WriteLine for both missing and added: retrieve the matching
RegionInfo from sut1 (e.g., via FirstOrDefault/SingleOrDefault), check for null
and fall back to printing the code or a placeholder instead of accessing
.EnglishName on null, and avoid re-enumerating World.Regions inside the loops.

TestOutput.WriteLine($"Expected: {expectedTwoLetterIsoCodes.Count}, Actual: {actualCodes.Count}, Missing: {missing.Count}, Added: {added.Count}");
Assert.Empty(missing);
#endif
}

[Fact]
public void Regions_ShouldPrintAllRegionsAndHighlightIsoCodeFrequency()
{
var sut1 = World.Regions.ToList();

TestOutput.WriteLine(sut1.Count.ToString());
foreach (var r in sut1)
{
TestOutput.WriteLine($"{r.Name,-5} {r.EnglishName}");
}

var grouped = sut1.GroupBy(r => r.Name).OrderBy(g => g.Key).ToList();
var multiEntry = grouped.Where(g => g.Count() > 1).OrderByDescending(g => g.Count()).ThenBy(g => g.Key).ToList();

TestOutput.WriteLine($"Total: {sut1.Count}, Unique ISO codes: {grouped.Count}, ISO codes with multiple entries: {multiEntry.Count}");

foreach (var g in multiEntry)
{
var first = g.First();
var allEqual = g.All(r => r.Equals(first));
var distinctNativeNames = g.Select(r => r.NativeName).Distinct().ToList();
TestOutput.WriteLine($" {g.Key} ({first.EnglishName}): {g.Count()} entries | all Equals: {allEqual} | distinct NativeNames: {distinctNativeNames.Count}");
if (distinctNativeNames.Count > 1)
{
foreach (var name in distinctNativeNames)
{
TestOutput.WriteLine($" NativeName: {name}");
}
}
}

Assert.NotNull(sut1);
Assert.NotEmpty(sut1);
#if NET48_OR_GREATER
Assert.True(sut1.Count > 100, "sut1.Count > 100");
#else
Assert.True(sut1.Count > 220, "sut1.Count > 220");
Assert.True(sut1.Count > 400, "sut1.Count > 400");
#endif
}

Expand Down
Loading