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