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)); } }