mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 15:09:26 -07:00
178 lines
7.9 KiB
C#
178 lines
7.9 KiB
C#
using System.Text.Json;
|
|
using RadicadeServer.RecNet.Avatar;
|
|
using Xunit;
|
|
|
|
namespace RadicadeServer.Tests;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public class EquipmentCatalogTests
|
|
{
|
|
private static string Fixture(string name) => Path.Combine(AppContext.BaseDirectory, "Fixtures", name);
|
|
|
|
private static List<Equipment> ShippedCatalog()
|
|
{
|
|
var path = Fixture("equipment.json");
|
|
Assert.True(File.Exists(path), $"missing fixture {path}");
|
|
var rows = JsonSerializer.Deserialize<List<Equipment>>(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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>The 37 tool prefabs the 2023 client ships skins for.</summary>
|
|
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<List<Equipment>>(
|
|
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));
|
|
}
|
|
}
|