using RadicadeServer.RecNet.Avatar;
using Xunit;
namespace RadicadeServer.Tests;
///
/// The loader prefers the fuller 2022-avatar.json and keeps avatar.json as a fallback, and it
/// only backfills metadata when the chosen catalog is missing it. These tests pin that
/// preference order so the served avatar list cannot silently fall back.
///
public class AvatarCatalogSourceTests
{
[Fact]
public void NewerCatalogIsPreferredOverTheFallback()
{
Assert.Equal("2022-avatar.json", DBConfig.AvatarCatalogFileNames[0]);
Assert.Contains("avatar.json", DBConfig.AvatarCatalogFileNames);
}
[Fact]
public void CandidatePathsTryThePreferredFileInEveryDirectoryFirst()
{
var paths = DBConfig.AvatarCatalogCandidatePaths().ToArray();
Assert.NotEmpty(paths);
var firstFallback = Array.FindIndex(paths, path => path.EndsWith("avatar.json", StringComparison.OrdinalIgnoreCase)
&& !path.EndsWith("2022-avatar.json", StringComparison.OrdinalIgnoreCase));
var lastPreferred = Array.FindLastIndex(paths, path => path.EndsWith("2022-avatar.json", StringComparison.OrdinalIgnoreCase));
Assert.True(lastPreferred >= 0, "2022-avatar.json should be among the candidate paths.");
Assert.True(firstFallback > lastPreferred,
"every 2022-avatar.json location must be tried before any avatar.json location.");
}
[Fact]
public void CompleteCatalogNeedsNoEnrichment()
{
var catalogJson = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "avatar.json"));
var items = AvatarUnlockCatalog.ParseCatalog(catalogJson);
// The compact avatar.json carries no item ids, so it is enriched from merged-avatar.json.
Assert.True(AvatarUnlockCatalog.NeedsMetadataEnrichment(items));
Assert.False(AvatarUnlockCatalog.NeedsMetadataEnrichment(null));
Assert.False(AvatarUnlockCatalog.NeedsMetadataEnrichment(Array.Empty()));
Assert.False(AvatarUnlockCatalog.NeedsMetadataEnrichment(
[new AvatarItem { AvatarItemDesc = "item,", AvatarItemId = 12, ThumbnailImage = "a.png" }]));
}
[Fact]
public void ResolvableFilterDropsOnlyUnknownItems()
{
var original = DBConfig.ResolvableAvatarItemDescs;
try
{
DBConfig.ResolvableAvatarItemDescs = new HashSet(StringComparer.OrdinalIgnoreCase)
{
"8aa79563-ace1-4ba7-ad0c-f3210a78142f,"
};
var kept = AvatarUnlockCatalog.KeepResolvableItems(
[
new AvatarItem { AvatarItemDesc = "8aa79563-ace1-4ba7-ad0c-f3210a78142f,,," },
new AvatarItem { AvatarItemDesc = "not-in-the-client-database,,," }
]);
var item = Assert.Single(kept);
Assert.Equal("8aa79563-ace1-4ba7-ad0c-f3210a78142f,", item.AvatarItemDesc);
}
finally
{
DBConfig.ResolvableAvatarItemDescs = original;
}
}
[Fact]
public void ResolvableFilterIsANoOpWithoutALookup()
{
var original = DBConfig.ResolvableAvatarItemDescs;
try
{
DBConfig.ResolvableAvatarItemDescs = new HashSet(StringComparer.OrdinalIgnoreCase);
var kept = AvatarUnlockCatalog.KeepResolvableItems(
[new AvatarItem { AvatarItemDesc = "anything," }]);
Assert.Equal("anything,", Assert.Single(kept).AvatarItemDesc);
}
finally
{
DBConfig.ResolvableAvatarItemDescs = original;
}
}
///
/// The client only ships assets for items released before its build, so a newer row makes it
/// log "Unknown Material guid" and throw "Bad outfit string. No prefab found!". Rain Cloud
/// Hat (2023-04-07) is the item that actually crashed a boot in the 20230301 build.
///
[Fact]
public void ItemsReleasedAfterTheClientBuildAreNotServed()
{
const string rainCloudHat = "24781418-38ad-411f-925e-2321fe616b51,";
const string turkeySweater = "002a0f2f-1a24-4439-b578-470818ef8325,";
var kept = AvatarUnlockCatalog.KeepClientAvailableItems(
[
new AvatarItem { AvatarItemDesc = rainCloudHat, CreatedAt = new DateTime(2023, 4, 7, 18, 16, 3, DateTimeKind.Utc) },
new AvatarItem { AvatarItemDesc = turkeySweater, CreatedAt = new DateTime(2022, 11, 1, 17, 51, 50, DateTimeKind.Utc) },
new AvatarItem { AvatarItemDesc = "no-release-date," },
new AvatarItem { AvatarItemDesc = "offsetless,", CreatedAt = new DateTime(2023, 4, 7, 18, 16, 3, DateTimeKind.Unspecified) }
]);
Assert.DoesNotContain(kept, item => item.AvatarItemDesc == rainCloudHat);
Assert.DoesNotContain(kept, item => item.AvatarItemDesc == "offsetless,");
Assert.Contains(kept, item => item.AvatarItemDesc == turkeySweater);
Assert.Contains(kept, item => item.AvatarItemDesc == "no-release-date,");
}
[Fact]
public void ReleaseCutoffIsTheClientBuildDate()
{
Assert.Equal(new DateTime(2023, 3, 1, 0, 0, 0, DateTimeKind.Utc), DBConfig.AvatarItemReleaseCutoffUtc);
}
[Fact]
public void ShippedLookupCoversTheCatalogServedToClients()
{
var lookupPath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "avatar-item-lookup.json");
var catalogPath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "2022-avatar.json");
if (!File.Exists(lookupPath) || !File.Exists(catalogPath))
{
return;
}
var descriptions = System.Text.Json.JsonDocument.Parse(File.ReadAllText(lookupPath))
.RootElement.GetProperty("descriptions")
.EnumerateArray()
.Select(element => element.GetString()!)
.Where(description => !string.IsNullOrWhiteSpace(description))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
Assert.True(descriptions.Count > 2000, $"lookup only lists {descriptions.Count} descriptions");
var original = DBConfig.ResolvableAvatarItemDescs;
var originalCatalog = DBConfig.DefaultAvatarCatalog;
var originalFallback = DBConfig.DefaultAvatarUnlocks;
try
{
DBConfig.ResolvableAvatarItemDescs = descriptions;
DBConfig.DefaultAvatarCatalog = File.ReadAllText(catalogPath);
DBConfig.DefaultAvatarUnlocks = "[]";
var served = AvatarUnlockCatalog.GetSafeDefaultItems();
Assert.NotEmpty(served);
Assert.Equal(2990, descriptions.Count);
Assert.Equal(AvatarUnlockCatalog.ParseCatalog(DBConfig.DefaultAvatarCatalog).Count, served.Count);
Assert.DoesNotContain(served, item => !descriptions.Contains(item.AvatarItemDesc));
Assert.DoesNotContain(served, item => AvatarItemDescription.HasEmptyMaterialSlot(item.AvatarItemDesc));
Assert.DoesNotContain(served, item =>
item.CreatedAt != default && item.CreatedAt.ToUniversalTime() >= DBConfig.AvatarItemReleaseCutoffUtc);
// This item is present in the September client, unlike the old March lookup.
Assert.Contains(served, item =>
item.AvatarItemDesc.StartsWith("24781418-38ad-411f-925e-2321fe616b51", StringComparison.OrdinalIgnoreCase));
}
finally
{
DBConfig.ResolvableAvatarItemDescs = original;
DBConfig.DefaultAvatarCatalog = originalCatalog;
DBConfig.DefaultAvatarUnlocks = originalFallback;
}
}
[Fact]
public void ShippedCatalogHasUniqueServerIdsAndRetainsKnownMetadata()
{
var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", "2022-avatar.json");
if (!File.Exists(path))
{
// The fixture is optional: without it the loader simply falls back to avatar.json.
return;
}
var items = AvatarUnlockCatalog.ParseCatalog(File.ReadAllText(path));
Assert.NotEmpty(items);
Assert.DoesNotContain(items, item => AvatarItemDescription.HasEmptyMaterialSlot(item.AvatarItemDesc));
Assert.DoesNotContain(items, item => item.AvatarItemId <= 0);
Assert.Equal(items.Count, items.Select(item => item.AvatarItemId).Distinct().Count());
Assert.DoesNotContain(items, item => string.IsNullOrWhiteSpace(item.FriendlyName));
Assert.Contains(items, item => item.FriendlyName == "NFL Houston Texans Hat"
&& !string.IsNullOrWhiteSpace(item.ThumbnailImage));
// New client-only entries have no known store thumbnail; do not invent image URLs.
Assert.True(AvatarUnlockCatalog.NeedsMetadataEnrichment(items));
}
}