using System.Text.Json;
using RadicadeServer.RecNet.Avatar;
using Xunit;
namespace RadicadeServer.Tests;
///
/// Guards the shipped equipment catalog (RadicadeServer-Config/equipment.json).
///
/// That file is what every new account's equipment list is built from, and it reached this repo
/// in bad shape: 268 rows carrying 192 distinct prefab/guid pairs, 92 rows whose FriendlyName was
/// the bare prefab ("[PaintballAssaultRifle]") because the name never resolved, three guids
/// assigned to two different prefabs, and one guid that was a character too long. None of it
/// raised an error - the client just listed unnamed entries, and a skin attached to the wrong
/// prefab can never be applied - so the invariants are pinned here instead.
///
/// The current file is a dump of the 2023 client's own equipment list: 271 rows over the same 37
/// prefabs, every row named, every guid well formed and belonging to exactly one prefab.
///
public class EquipmentCatalogTests
{
private static string Fixture(string name) => Path.Combine(AppContext.BaseDirectory, "Fixtures", name);
private static List ShippedCatalog()
{
var path = Fixture("equipment.json");
Assert.True(File.Exists(path), $"missing fixture {path}");
var rows = JsonSerializer.Deserialize>(File.ReadAllText(path));
Assert.NotNull(rows);
return rows!;
}
private static string Pair(Equipment row) =>
$"{row.PrefabName}\u001f{row.ModificationGuid}";
[Fact]
public void ShippedCatalogCarriesEveryRowItDeclaresAndNoneOfThemTwice()
{
var rows = ShippedCatalog();
// A floor rather than an exact count: skins are data and will be added, but silently
// losing a chunk of the catalog is the failure worth catching.
Assert.True(rows.Count >= 271, $"equipment.json shrank to {rows.Count} rows");
var duplicates = rows
.GroupBy(Pair, StringComparer.OrdinalIgnoreCase)
.Where(group => group.Count() > 1)
.Select(group => $"{group.Key} x{group.Count()}")
.ToList();
// Service merges by pair, so a repeated row never reached the client - it just made the
// file lie about its own size. The catalog it replaced repeated 76 of them.
Assert.Empty(duplicates);
}
[Fact]
public void EveryShippedGuidIsAWholeGuid()
{
var bad = ShippedCatalog()
.Where(row => !IsGuid(row.ModificationGuid))
.Select(row => $"{row.PrefabName} {row.ModificationGuid}")
.ToList();
Assert.Empty(bad);
}
///
/// A RecNet modification guid is either the dashed 36-character form or the 22-character
/// base64url form the client emits. Anything else is a truncated or padded guid, which is how
/// the catalog this replaced carried "[Crossbow] ESQ0qOlgrkCc8MbUXGnF8Ag" - one character
/// longer than the real guid for that skin, so the Plaid crossbow could never resolve.
///
private static bool IsGuid(string? guid)
{
if (guid == null) return false;
if (guid.Length == 36)
return Guid.TryParseExact(guid, "D", out _);
if (guid.Length != 22) return false;
return guid.All(c => char.IsAsciiLetterOrDigit(c) || c == '-' || c == '_');
}
[Fact]
public void EveryShippedGuidBelongsToOnePrefab()
{
var spanning = ShippedCatalog()
.GroupBy(row => row.ModificationGuid, StringComparer.OrdinalIgnoreCase)
.Where(group => group.Select(row => row.PrefabName).Distinct(StringComparer.OrdinalIgnoreCase).Count() > 1)
.Select(group => $"{group.Key} -> {string.Join(", ", group.Select(row => row.PrefabName).Distinct())}")
.ToList();
// A skin is the pair, so a guid that names two prefabs means one of the rows is wrong and
// that skin is attached to a tool the client will never look it up under.
Assert.Empty(spanning);
}
[Fact]
public void EveryShippedRowHasARealName()
{
var unnamed = ShippedCatalog()
.Where(row => string.IsNullOrWhiteSpace(row.FriendlyName)
|| row.FriendlyName.StartsWith('['))
.Select(row => $"{row.PrefabName} {row.ModificationGuid}")
.ToList();
// A bare prefab as the name is what the equipment list renders verbatim, which is why
// 92 rows used to read "[QuestSword]" instead of "Sword (Extra)".
Assert.Empty(unnamed);
}
[Fact]
public void EveryShippedRowIsAResolvableSkin()
{
var rows = ShippedCatalog();
foreach (var row in rows)
{
// Bracketed, not necessarily ending in one: "[PaintballGun] Confetti" is a real
// prefab of its own.
Assert.StartsWith("[", row.PrefabName);
Assert.Contains("]", row.PrefabName);
Assert.False(string.IsNullOrWhiteSpace(row.ModificationGuid));
Assert.NotNull(row.Tooltip);
}
// The prefab half has to be one of the client's own tool prefabs, so a typo like the
// "[Dodgeball]", "[SciFi_Grenade]" and "[PaintBallRifleScoped]" rows the old catalog
// carried shows up here rather than as a skin the player can own but never wear. Pinned
// as a set on purpose: a genuinely new tool prefab should be a deliberate addition, and
// the count alone would not notice one typo replacing another.
var prefabs = rows.Select(row => row.PrefabName).Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase).ToList();
Assert.Equal(ShippedPrefabs.OrderBy(name => name, StringComparer.OrdinalIgnoreCase), prefabs);
}
/// The 37 tool prefabs the 2023 client ships skins for.
private static readonly string[] ShippedPrefabs =
{
"[Arena_Grenade]", "[Basketball]", "[BowlingBall]", "[Bucket]", "[Crossbow]",
"[Crossbow_Hunter]", "[DiscGolfDisc]", "[DodgeballBall]", "[Grenade]", "[Longbow]", "[MakerPen]",
"[PaintballAssaultRifle]", "[PaintballGrenadeLauncher]", "[PaintballGun]",
"[PaintballGun] Confetti", "[PaintballRifleScoped]", "[PaintballShield]",
"[PaintballShotgun]", "[Paintball_PaintThrower]", "[QuestShield]", "[QuestSword]",
"[Quest_Goblin_Wand]", "[Quest_SciFi_AutomaticGun]", "[Quest_SciFi_Pistol]",
"[Quest_SciFi_RailGun]", "[Quest_SciFi_Shotgun]", "[RecCenter_Cafe_Mug]",
"[RecRoyale_Backpack]", "[Sandbox_D10]", "[Sandbox_D12]", "[Sandbox_D20]",
"[Sandbox_D4]", "[Sandbox_D6]", "[Sandbox_D8]", "[ShareCamera]", "[SoccerShield]",
"[Vehicle_RallyBuggy]"
};
[Fact]
public void TheServedCatalogKeepsEveryRowTheFileDeclares()
{
// What the equipment endpoint actually hands a player: the file merged with the DorkNet
// skin catalog, keyed by pair exactly as EquipmentV2 does it.
var equipmentPath = Fixture("equipment.json");
var skinsPath = Fixture("equipment_skins.json"); if (!File.Exists(skinsPath)) return;
var merged = JsonSerializer.Deserialize>(
EquipmentSkinCatalog.MergeInto(File.ReadAllText(equipmentPath), File.ReadAllText(skinsPath)))!;
var served = merged
.Where(row => !string.IsNullOrWhiteSpace(row.PrefabName))
.GroupBy(Pair, StringComparer.OrdinalIgnoreCase)
.ToList();
Assert.Equal(merged.Count, served.Count);
Assert.True(served.Count >= 271);
// And the pairs the file names are the pairs the endpoint serves - no row is lost to the
// merge, and no merge-added row displaces one.
var declared = ShippedCatalog().Select(Pair).ToList();
var servedPairs = served.Select(group => group.Key).ToList();
Assert.All(declared, pair => Assert.Contains(pair, servedPairs, StringComparer.OrdinalIgnoreCase));
}
}