This commit is contained in:
bob
2026-06-02 13:31:27 -05:00
parent 15310f0fd0
commit edc649fc36
33 changed files with 6407 additions and 8 deletions

View File

@@ -0,0 +1,205 @@
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;
/// <summary>
/// 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.
/// </summary>
public class TradeupGraphBuilderTests
{
private static SkinTrackerDbContext NewContext()
{
var options = new DbContextOptionsBuilder<SkinTrackerDbContext>()
.UseInMemoryDatabase($"tradeups-{Guid.NewGuid()}")
.Options;
return new SkinTrackerDbContext(options);
}
private static async Task<TradeupGraph> BuildAsync(SkinTrackerDbContext db)
{
await db.SaveChangesAsync();
var builder = new TradeupGraphBuilder(db, NullLogger<TradeupGraphBuilder>.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<Skin> 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> { 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);
}
}

View File

@@ -0,0 +1,52 @@
using BlueLaminate.Core.Tradeups;
using Xunit;
namespace BlueLaminate.Tests.Tradeups;
public class TradeupMathTests
{
[Fact]
public void NormalizedFraction_maps_value_into_its_own_range()
{
// Mid-point of a 0.060.80 range.
var frac = TradeupMath.NormalizedFraction(0.43m, 0.06m, 0.80m);
Assert.Equal(0.5m, frac, precision: 6);
}
[Theory]
[InlineData(-0.5)] // below min
[InlineData(2.0)] // above max
public void NormalizedFraction_clamps_out_of_range_values(double value)
{
var frac = TradeupMath.NormalizedFraction((decimal)value, 0.0m, 1.0m);
Assert.InRange(frac, 0m, 1m);
}
[Fact]
public void NormalizedFraction_returns_zero_for_zero_width_range()
{
Assert.Equal(0m, TradeupMath.NormalizedFraction(0.3m, 0.3m, 0.3m));
}
[Fact]
public void OutputFloat_maps_average_fraction_onto_output_range()
{
// avg 0.10 onto a 0.000.70 output range → 0.07 (the FN/MW boundary).
var outFloat = TradeupMath.OutputFloat(0.10m, 0.00m, 0.70m);
Assert.Equal(0.07m, outFloat, precision: 6);
}
[Fact]
public void Full_contract_float_math_matches_hand_calculation()
{
// Ten inputs, each normalised to its own range, then averaged and mapped onto the
// output's 0.000.80 range. Five inputs at fraction 0.2 and five at 0.4 → avg 0.3.
var fractions = new[] { 0.2m, 0.2m, 0.2m, 0.2m, 0.2m, 0.4m, 0.4m, 0.4m, 0.4m, 0.4m };
var avg = fractions.Sum() / fractions.Length;
Assert.Equal(0.3m, avg, precision: 6);
var outFloat = TradeupMath.OutputFloat(avg, 0.00m, 0.80m);
Assert.Equal(0.24m, outFloat, precision: 6);
Assert.Equal(WearBand.FieldTested, WearBands.FromFloat(outFloat));
}
}

View File

@@ -0,0 +1,204 @@
using BlueLaminate.Core.Tradeups;
using Xunit;
namespace BlueLaminate.Tests.Tradeups;
public class TradeupSelectorTests
{
private const decimal Bucket = 0.005m;
private static SelectableInput Item(decimal fraction, decimal price)
=> new(fraction, new InputListing(
SkinId: 1,
MarketHashName: "Test Skin",
Marketplace: "test",
InspectLink: null,
ExternalId: "0",
FloatValue: fraction,
Price: price));
[Fact]
public void Cheapest_full_selection_is_the_ten_cheapest_copies()
{
// 12 copies priced 1..12 at assorted fractions. With no binding float target the
// cheapest ten (cost 55) must be attainable at some summed-fraction bucket.
var pool = new List<SelectableInput>();
for (var i = 1; i <= 12; i++)
{
pool.Add(Item(fraction: 0.01m * i, price: i));
}
var selection = TradeupSelector.Solve(pool, contractSize: 10, Bucket);
var selections = selection.Selections().ToList();
Assert.NotEmpty(selections);
var cheapest = selections.MinBy(s => s.Cost);
Assert.Equal(55m, cheapest.Cost);
var picks = cheapest.Picks.ToList();
Assert.Equal(10, picks.Count);
Assert.Equal(Enumerable.Range(1, 10).Select(i => (decimal)i), picks.Select(p => p.Price).OrderBy(p => p));
}
[Fact]
public void Reported_average_fraction_is_a_conservative_upper_bound()
{
var pool = new List<SelectableInput>();
for (var i = 0; i < 10; i++)
{
pool.Add(Item(fraction: 0.123m, price: 1m));
}
var only = Assert.Single(TradeupSelector.Solve(pool, 10, Bucket).Selections());
var trueAverage = only.Picks.ToList().Average(p => p.FloatValue);
Assert.True(only.AverageFraction >= trueAverage,
$"bucketed average {only.AverageFraction} should round up from true {trueAverage}");
// 0.123 rounds up to the 0.125 bucket (0.005 grid).
Assert.Equal(0.125m, only.AverageFraction);
}
[Fact]
public void Selecting_cheaper_low_float_copies_lowers_the_attainable_average()
{
// Cheap high-float copies vs. pricier low-float copies. A lower average is only
// reachable by paying for the low-float set, so a low-average selection must cost
// more than the unconstrained cheapest.
var pool = new List<SelectableInput>();
for (var i = 0; i < 10; i++)
{
pool.Add(Item(fraction: 0.60m, price: 1m)); // cheap, bad float
}
for (var i = 0; i < 10; i++)
{
pool.Add(Item(fraction: 0.10m, price: 5m)); // pricey, good float
}
var selections = TradeupSelector.Solve(pool, 10, Bucket).Selections().ToList();
var cheapest = selections.MinBy(s => s.Cost);
Assert.Equal(10m, cheapest.Cost); // ten cheap copies
Assert.Equal(0.60m, cheapest.AverageFraction);
var lowestFloat = selections.MinBy(s => s.AverageFraction);
Assert.Equal(0.10m, lowestFloat.AverageFraction);
Assert.Equal(50m, lowestFloat.Cost); // forced onto the pricey low-float set
}
[Fact]
public void No_full_selection_when_pool_is_too_small()
{
var pool = Enumerable.Range(0, 5).Select(i => Item(0.2m, i + 1)).ToList();
Assert.Empty(TradeupSelector.Solve(pool, 10, Bucket).Selections());
}
private static TradeupSelector.RewardItem Reward(int bucket, double reward, decimal price = 0m)
=> new(bucket, reward, new InputListing(1, "x", "m", null, "0", 0.1m, price));
[Fact]
public void MaxReward_picks_the_highest_reward_set_within_the_cap()
{
// Six items; pick 3 maximising reward with bucket sum ≤ 6. The three best rewards
// (9, 8, 7) sit at buckets 2,2,2 = 6 ≤ cap, so they win.
var items = new[]
{
Reward(2, 9), Reward(2, 8), Reward(2, 7),
Reward(1, 1), Reward(1, 2), Reward(1, 3),
};
var picks = TradeupSelector.SolveMaxReward(items, contractSize: 3, capBucket: 6);
Assert.NotNull(picks);
Assert.Equal(3, picks!.ToList().Count);
}
[Fact]
public void MaxReward_respects_the_float_cap_even_at_the_cost_of_reward()
{
// The fat-reward items sit at high buckets; the cap forces the low-bucket set.
var items = new[]
{
Reward(5, 100, price: 50m), // too high-float to fit under a tight cap
Reward(1, 5, price: 1m),
Reward(1, 4, price: 1m),
Reward(1, 3, price: 1m),
};
var picks = TradeupSelector.SolveMaxReward(items, contractSize: 3, capBucket: 3);
Assert.NotNull(picks);
var chosen = picks!.ToList();
Assert.Equal(3, chosen.Count);
Assert.All(chosen, p => Assert.Equal(1m, p.Price)); // the three low-float copies
}
[Fact]
public void MaxReward_returns_null_when_the_contract_cannot_be_filled_under_the_cap()
{
// Only two items fit under the cap, but three are required.
var items = new[] { Reward(1, 5), Reward(1, 4), Reward(9, 100) };
Assert.Null(TradeupSelector.SolveMaxReward(items, contractSize: 3, capBucket: 3));
}
}
public class OutputPriceBookTests
{
private static TradeupListingRow Row(WearBand band, bool st, decimal price)
{
// A float squarely inside the band, so Build files it where we expect.
var f = band switch
{
WearBand.FactoryNew => 0.03m,
WearBand.MinimalWear => 0.10m,
WearBand.FieldTested => 0.25m,
WearBand.WellWorn => 0.40m,
_ => 0.60m,
};
return new TradeupListingRow(1, "M4A4 | X-Ray", "csmoney", null, "0", st, false, f, price);
}
[Fact]
public void Liquid_band_uses_its_own_price()
{
// Ten MW listings -> the MW band is trusted at its own lowest ask.
var rows = Enumerable.Range(0, 10).Select(i => Row(WearBand.MinimalWear, true, 90m + i));
var book = TradeupListingData.Build(rows).OutputPrices;
var r = book.Resolve(1, statTrak: true, outputFloat: 0.10m, thinThreshold: 10);
Assert.Equal(OutputPriceBasis.Band, r.Basis);
Assert.Equal(90m, r.LowestAsk);
}
[Fact]
public void Thin_band_falls_back_to_the_skin_overall_floor()
{
// The X-Ray case: two outlier FN listings ($1287) but a liquid MW market (~$90).
// A produced FN output must price off the $90 floor, not the $1287 band outlier.
var rows = new List<TradeupListingRow>
{
Row(WearBand.FactoryNew, true, 1287m),
Row(WearBand.FactoryNew, true, 1290m),
};
rows.AddRange(Enumerable.Range(0, 10).Select(i => Row(WearBand.MinimalWear, true, 90m + i)));
var book = TradeupListingData.Build(rows).OutputPrices;
var r = book.Resolve(1, statTrak: true, outputFloat: 0.0695m, thinThreshold: 10);
Assert.Equal(OutputPriceBasis.Floor, r.Basis);
Assert.Equal(90m, r.LowestAsk); // skin-wide floor, not the $1287 FN outlier
Assert.Equal(2, r.BandLiquidity); // still reports the thin band count → triggers CSFloat
}
[Fact]
public void Unlisted_skin_resolves_to_none()
{
var book = TradeupListingData.Build(Array.Empty<TradeupListingRow>()).OutputPrices;
var r = book.Resolve(1, statTrak: false, outputFloat: 0.1m, thinThreshold: 10);
Assert.Equal(OutputPriceBasis.None, r.Basis);
Assert.Null(r.LowestAsk);
}
}

View File

@@ -0,0 +1,42 @@
using BlueLaminate.Core.Tradeups;
using Xunit;
namespace BlueLaminate.Tests.Tradeups;
public class WeaponRarityTests
{
[Theory]
[InlineData("Consumer Grade", WeaponRarity.Consumer)]
[InlineData("Industrial Grade", WeaponRarity.Industrial)]
[InlineData("Mil-Spec Grade", WeaponRarity.MilSpec)]
[InlineData("Restricted", WeaponRarity.Restricted)]
[InlineData("Classified", WeaponRarity.Classified)]
[InlineData("Covert", WeaponRarity.Covert)]
public void Maps_each_weapon_tier_literal(string literal, WeaponRarity expected)
{
Assert.True(WeaponRarityExtensions.TryParse(literal, out var rarity));
Assert.Equal(expected, rarity);
}
[Theory]
[InlineData("Contraband")] // The Howl
[InlineData("Extraordinary")] // Gloves
public void Reports_non_weapon_rarities_as_not_a_tier(string literal)
{
Assert.False(WeaponRarityExtensions.TryParse(literal, out _));
}
[Fact]
public void Throws_on_unknown_literal_so_a_catalogue_rename_is_loud()
{
Assert.Throws<ArgumentException>(() => WeaponRarityExtensions.TryParse("Mythical", out _));
}
[Fact]
public void Tiers_are_strictly_ordered()
{
Assert.True(WeaponRarity.Consumer < WeaponRarity.Industrial);
Assert.True(WeaponRarity.MilSpec < WeaponRarity.Restricted);
Assert.True(WeaponRarity.Classified < WeaponRarity.Covert);
}
}

View File

@@ -0,0 +1,31 @@
using BlueLaminate.Core.Tradeups;
using Xunit;
namespace BlueLaminate.Tests.Tradeups;
public class WearBandTests
{
[Theory]
[InlineData(0.00, WearBand.FactoryNew)]
[InlineData(0.0699, WearBand.FactoryNew)]
[InlineData(0.07, WearBand.MinimalWear)] // boundary is upper-exclusive
[InlineData(0.1499, WearBand.MinimalWear)]
[InlineData(0.15, WearBand.FieldTested)]
[InlineData(0.3799, WearBand.FieldTested)]
[InlineData(0.38, WearBand.WellWorn)]
[InlineData(0.4499, WearBand.WellWorn)]
[InlineData(0.45, WearBand.BattleScarred)]
[InlineData(1.00, WearBand.BattleScarred)]
public void FromFloat_classifies_on_absolute_thresholds(double value, WearBand expected)
{
Assert.Equal(expected, WearBands.FromFloat((decimal)value));
}
[Fact]
public void ToName_matches_listing_wear_strings()
{
Assert.Equal("Factory New", WearBand.FactoryNew.ToName());
Assert.Equal("Field-Tested", WearBand.FieldTested.ToName());
Assert.Equal("Battle-Scarred", WearBand.BattleScarred.ToName());
}
}