using BlueLaminate.Core.Tradeups; using BlueLaminate.EFCore.Data; using BlueLaminate.EFCore.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace BlueLaminate.Tests.Tradeups; /// /// Graph-derivation rules verified against a synthetic in-memory catalogue — never the /// live database (see the no-live-DB-perturbation rule). Each test seeds the minimal /// catalogue shape it needs. /// public class TradeupGraphBuilderTests { private static SkinTrackerDbContext NewContext() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase($"tradeups-{Guid.NewGuid()}") .Options; return new SkinTrackerDbContext(options); } private static async Task BuildAsync(SkinTrackerDbContext db) { await db.SaveChangesAsync(); var builder = new TradeupGraphBuilder(db, NullLogger.Instance); return await builder.BuildAsync(); } private sealed class Catalogue { private int _nextId = 1; public Weapon Rifle { get; } = new() { Id = 1000, Name = "AK-47", Type = "Rifle", Team = "Both" }; public Weapon Knife { get; } = new() { Id = 1001, Name = "Karambit", Type = "Knives", Team = "Both" }; public Weapon Glove { get; } = new() { Id = 1002, Name = "Sport Gloves", Type = "Gloves", Team = "Both" }; public List Skins { get; } = new(); public Skin Add( Collection collection, string rarity, Weapon? weapon = null, decimal? floatMin = 0.0m, decimal? floatMax = 1.0m) { var skin = new Skin { Id = _nextId, Slug = $"skin-{_nextId}", Name = $"Skin {_nextId}", Rarity = rarity, Weapon = weapon ?? Rifle, FloatMin = floatMin, FloatMax = floatMax, Collections = new List { collection }, }; _nextId++; Skins.Add(skin); return skin; } } private static Collection NewCollection(string name, string type = "Collection") => new() { Name = name, Slug = $"col-{name}", Type = type }; [Fact] public async Task Resolves_across_a_rarity_gap() { // MilSpec + Classified present, Restricted absent → MilSpec must resolve to Classified. await using var db = NewContext(); var cat = new Catalogue(); var col = NewCollection("Gap"); cat.Add(col, "Mil-Spec Grade"); cat.Add(col, "Classified"); db.Skins.AddRange(cat.Skins); var graph = await BuildAsync(db); var group = Assert.Single(graph.Groups); Assert.Equal(WeaponRarity.MilSpec, group.InputRarity); Assert.Equal(WeaponRarity.Classified, group.OutputRarity); } [Fact] public async Task Covert_is_an_output_but_never_an_input() { await using var db = NewContext(); var cat = new Catalogue(); var col = NewCollection("Tops at Covert"); cat.Add(col, "Restricted"); cat.Add(col, "Classified"); cat.Add(col, "Covert"); // a weapon Covert (eligible output) db.Skins.AddRange(cat.Skins); var graph = await BuildAsync(db); Assert.Contains(graph.Groups, g => g.InputRarity == WeaponRarity.Restricted && g.OutputRarity == WeaponRarity.Classified); Assert.Contains(graph.Groups, g => g.InputRarity == WeaponRarity.Classified && g.OutputRarity == WeaponRarity.Covert); Assert.DoesNotContain(graph.Groups, g => g.InputRarity == WeaponRarity.Covert); } [Fact] public async Task Knife_with_covert_rarity_is_excluded_as_output() { // The only "Covert" in the collection is a knife → the Covert tier has no eligible // output, so Classified resolves to nothing and yields no group. await using var db = NewContext(); var cat = new Catalogue(); var col = NewCollection("Knife Covert"); cat.Add(col, "Classified"); cat.Add(col, "Covert", weapon: cat.Knife); db.Skins.AddRange(cat.Skins); var graph = await BuildAsync(db); Assert.Empty(graph.Groups); } [Fact] public async Task Contraband_and_gloves_are_not_weapon_tiers() { await using var db = NewContext(); var cat = new Catalogue(); var col = NewCollection("Howl"); cat.Add(col, "Classified"); cat.Add(col, "Contraband"); // The Howl cat.Add(col, "Extraordinary", weapon: cat.Glove); // a glove db.Skins.AddRange(cat.Skins); var graph = await BuildAsync(db); // Classified has no higher weapon tier present → no tradeup. Assert.Empty(graph.Groups); } [Fact] public async Task Skins_without_float_bounds_are_excluded() { await using var db = NewContext(); var cat = new Catalogue(); var col = NewCollection("No Float"); cat.Add(col, "Mil-Spec Grade", floatMin: null, floatMax: null); // floatless → not a tier cat.Add(col, "Classified"); db.Skins.AddRange(cat.Skins); var graph = await BuildAsync(db); // The only would-be input tier is excluded, so nothing resolves. Assert.Empty(graph.Groups); } [Fact] public async Task Limited_edition_pseudo_collection_is_skipped() { await using var db = NewContext(); var cat = new Catalogue(); var col = NewCollection("Limited Edition Item"); cat.Add(col, "Mil-Spec Grade"); cat.Add(col, "Classified"); db.Skins.AddRange(cat.Skins); var graph = await BuildAsync(db); Assert.Empty(graph.Groups); } [Fact] public async Task Containers_are_not_treated_as_collections() { await using var db = NewContext(); var cat = new Catalogue(); var crate = NewCollection("Some Case", type: "Container"); cat.Add(crate, "Mil-Spec Grade"); cat.Add(crate, "Classified"); db.Skins.AddRange(cat.Skins); var graph = await BuildAsync(db); // Grouping is by Type='Collection' only; case weapons carry a separate Collection // source in reality, but a Container source on its own yields nothing. Assert.Empty(graph.Groups); } [Fact] public async Task Output_skins_carry_float_bounds_and_stattrak_flag() { await using var db = NewContext(); var cat = new Catalogue(); var col = NewCollection("Bounds"); cat.Add(col, "Classified"); var covert = cat.Add(col, "Covert", floatMin: 0.0m, floatMax: 0.8m); covert.StatTrakAvailable = true; db.Skins.AddRange(cat.Skins); var graph = await BuildAsync(db); var group = Assert.Single(graph.Groups); var output = Assert.Single(group.OutputSkins); Assert.Equal(0.0m, output.FloatMin); Assert.Equal(0.8m, output.FloatMax); Assert.True(output.StatTrakAvailable); } }