using System.Text.Json; using PowderCoating.Application.DTOs.Columbia; using PowderCoating.Infrastructure.Services.Columbia; namespace PowderCoating.UnitTests; /// /// Tests for the Columbia catalog mapper, focused on the fields that are tricky in the real feed: /// the free-text cure parser (multiple glyphs, multi-curve, partial-cure), manufacturer derivation /// from a multi-brand distributor, pricing across simple/variable products, and HTML stripping. /// Cases mirror records captured from the live API. /// public class ColumbiaCatalogMapperTests { // ── Cure schedule parsing ───────────────────────────────────────────── [Fact] public void ParseCureCurves_SimpleSchedule_ReturnsSingleCurve() { var curves = ColumbiaCatalogMapper.ParseCureCurves("10 minutes @ 400°F"); Assert.Single(curves); Assert.Equal(400, curves[0].TempF); Assert.Equal(10, curves[0].Minutes); } [Fact] public void ParseCureCurves_MetalTemperaturePrefixWithCelsius_ParsesFahrenheitOnly() { var curves = ColumbiaCatalogMapper.ParseCureCurves("Metal Temperature: 10 minutes at 400°F (204°C)"); Assert.Single(curves); Assert.Equal(400, curves[0].TempF); Assert.Equal(10, curves[0].Minutes); } [Theory] [InlineData("10 minutes @ 400˚F (204˚C)")] // U+02DA ring above [InlineData("10 minutes @ 400ºF (204ºC)")] // U+00BA masculine ordinal [InlineData("Metal temperature: 10 minutes @ 400F (204C)")] // no degree glyph at all public void ParseCureCurves_DegreeGlyphVariants_AllParse(string schedule) { var curves = ColumbiaCatalogMapper.ParseCureCurves(schedule); Assert.Single(curves); Assert.Equal(400, curves[0].TempF); } [Fact] public void ParseCureCurves_MultiCurve_CapturesAllInOrder_PrimaryIsFirst() { // Real record S1790085-55: standard high-temp curve first, low-temp alternates after. var schedule = "Metal Temperature: 5 minutes at 400°F (204°C) -or- 10 minutes at 360°F (182°C)* 15 minutes at 340°F (171°C)* *Low-Temp cure curve"; var curves = ColumbiaCatalogMapper.ParseCureCurves(schedule); Assert.Equal(3, curves.Count); Assert.Equal(new ColumbiaCatalogMapper.CureCurve(400, 5), curves[0]); // primary Assert.Equal(new ColumbiaCatalogMapper.CureCurve(360, 10), curves[1]); // alternate Assert.Equal(new ColumbiaCatalogMapper.CureCurve(340, 15), curves[2]); // alternate } [Fact] public void ParseCureCurves_PartialCureInstructions_ReturnsEmpty() { // Illusion powder F1697027 — multi-step, no single temp/time pair. var schedule = "(1) - Apply a basecoat and partial cure. (2) - Apply this powder and partial cure (3). - Apply a clear coat and fully cure."; var curves = ColumbiaCatalogMapper.ParseCureCurves(schedule); Assert.Empty(curves); } [Fact] public void Map_MultiCurve_SetsPrimaryTempTimeAndStoresAllCurvesJson() { var product = new ColumbiaProduct { Sku = "S1790085-55", Name = "Multi Cure", CureSchedule = "5 minutes at 400°F -or- 10 minutes at 360°F", }; var item = ColumbiaCatalogMapper.Map(product); Assert.Equal(400m, item.CureTemperatureF); Assert.Equal(5, item.CureTimeMinutes); Assert.False(string.IsNullOrEmpty(item.CureCurvesJson)); Assert.Equal("5 minutes at 400°F -or- 10 minutes at 360°F", item.CureScheduleText); var curves = JsonSerializer.Deserialize>(item.CureCurvesJson!); Assert.Equal(2, curves!.Count); } // ── Manufacturer derivation ─────────────────────────────────────────── [Fact] public void DeriveManufacturer_AddSkuPrefix_IsKpPigments() { var p = new ColumbiaProduct { Sku = "ADD-BRBDS", Name = "Barbados Blue ColorShift Pearl" }; Assert.Equal("KP Pigments", ColumbiaCatalogMapper.DeriveManufacturer(p)); Assert.True(ColumbiaCatalogMapper.IsAdditive(p)); } [Fact] public void DeriveManufacturer_PpgCategory_IsPpg() { var p = new ColumbiaProduct { Sku = "PCU75139", Name = "PPG Chrome Shadow", Categories = { new ColumbiaNamed { Name = "PPG Powders" } }, }; Assert.Equal("PPG", ColumbiaCatalogMapper.DeriveManufacturer(p)); } [Fact] public void DeriveManufacturer_HouseBrand_IsColumbia() { var p = new ColumbiaProduct { Sku = "X5004124", Name = "Blue Wrinkle", Categories = { new ColumbiaNamed { Name = "Powders" }, new ColumbiaNamed { Name = "New Releases" } }, }; Assert.Equal("Columbia Coatings", ColumbiaCatalogMapper.DeriveManufacturer(p)); Assert.False(ColumbiaCatalogMapper.IsAdditive(p)); } // ── Excluded products (swatches + tester/sample size variants) ──────── [Theory] [InlineData("T1696049-SW", "**SWATCH** - Copperhead II", true)] // swatch card [InlineData("T1696049-SW", "Copperhead II", true)] // -SW suffix alone [InlineData("X1", "**SWATCH** - Something", true)] // name marker alone [InlineData("T1696049-04", "Copperhead II (4 Ounce Tester)", true)] // tester = size variant [InlineData("XYZ", "Copperhead II (4 Ounce Tester)", true)] // tester by name [InlineData("S1760090-S", "Black Beauty Sample (5 lbs)", true)] // 5lb sample = size variant [InlineData("X5004124", "Blue Wrinkle", false)] // normal powder [InlineData("S5704126", "Smokey Blue", false)] // normal powder, SKU starts with S public void IsExcludedProduct_DetectsSwatchesTestersAndSamples(string sku, string name, bool expected) { var p = new ColumbiaProduct { Sku = sku, Name = name }; Assert.Equal(expected, ColumbiaCatalogMapper.IsExcludedProduct(p)); } // ── Pricing ─────────────────────────────────────────────────────────── [Fact] public void ParseBasePrice_SimpleProduct_UsesTopLevelPrice() { var p = new ColumbiaProduct { Price = "18.85", RegularPrice = "18.85" }; Assert.Equal(18.85m, ColumbiaCatalogMapper.ParseBasePrice(p)); } [Fact] public void ParseBasePrice_VariableProductWithZeroRegular_FallsBackToPriceThenVariants() { // Variable parent: price carries the lead variant, regular_price is "0". var p = new ColumbiaProduct { Price = "18.85", RegularPrice = "0", VariationPricing = new List { new() { Sku = "X-B", Price = "18.85" }, new() { Sku = "X-P", Price = "18.85" }, }, }; Assert.Equal(18.85m, ColumbiaCatalogMapper.ParseBasePrice(p)); } [Fact] public void BuildPriceTiersJson_VariableProduct_SerializesVariationPricing() { var p = new ColumbiaProduct { VariationPricing = new List { new() { Sku = "X-B", Price = "18.85" }, }, }; var json = ColumbiaCatalogMapper.BuildPriceTiersJson(p); Assert.NotNull(json); Assert.Contains("X-B", json); } [Fact] public void BuildPriceTiersJson_EmptyTieredPricingArray_ReturnsNull() { // Variable products carry tiered_pricing as an empty array. var p = new ColumbiaProduct { TieredPricing = JsonDocument.Parse("[]").RootElement }; Assert.Null(ColumbiaCatalogMapper.BuildPriceTiersJson(p)); } // ── Chemistry, color, HTML ──────────────────────────────────────────── [Theory] [InlineData("Polyester/TGIC", "Polyester/TGIC")] [InlineData("Polyester TGIC", "Polyester/TGIC")] [InlineData("TGIC Polyester", "Polyester/TGIC")] [InlineData("TGIC", "TGIC")] [InlineData("", null)] public void NormalizeChemistry_CollapsesPolyesterTgicVariants(string input, string? expected) { Assert.Equal(expected, ColumbiaCatalogMapper.NormalizeChemistry(input)); } [Fact] public void BuildColorFamilies_JoinsColorGroupNames() { var p = new ColumbiaProduct { PaColorGroup = { new ColumbiaNamed { Name = "Blue" }, new ColumbiaNamed { Name = "Green" } }, }; Assert.Equal("Blue,Green", ColumbiaCatalogMapper.BuildColorFamilies(p)); } [Fact] public void BuildColorFamilies_FallsBackToColorGroupAttribute() { var p = new ColumbiaProduct { Attributes = { new ColumbiaAttribute { Name = "Color Group", Options = { new ColumbiaNamed { Name = "Black" } }, }, }, }; Assert.Equal("Black", ColumbiaCatalogMapper.BuildColorFamilies(p)); } [Fact] public void StripHtml_RemovesTagsEntitiesAndCollapsesWhitespace() { var html = "Blue Wrinkle\r\n\r\nThis is a vibrant & bright coating."; var text = ColumbiaCatalogMapper.StripHtml(html); Assert.Equal("Blue Wrinkle This is a vibrant & bright coating.", text); } [Fact] public void DetectRequiresClearCoat_ExplicitRequirement_IsTrue() { var p = new ColumbiaProduct { Description = "This powder requires a clear coat to activate the effect.", }; Assert.True(ColumbiaCatalogMapper.DetectRequiresClearCoat(p)); } [Fact] public void DetectRequiresClearCoat_IllusionLine_IsTrue() { var p = new ColumbiaProduct { Name = "Illusion Cherry", Description = "A translucent red." }; Assert.True(ColumbiaCatalogMapper.DetectRequiresClearCoat(p)); } [Fact] public void DetectRequiresClearCoat_PartialCureSchedule_IsTrue() { var p = new ColumbiaProduct { Name = "Some Base", CureSchedule = "Partial Cure: 15 min total time in oven preheated to 400°F. Then apply clear.", }; Assert.True(ColumbiaCatalogMapper.DetectRequiresClearCoat(p)); } [Fact] public void DetectRequiresClearCoat_CasualMention_IsFalse() { // The over-flagging case: a passing mention is not a requirement. var p = new ColumbiaProduct { Name = "Gloss Black", Description = "Durable gloss black. Apply a clear coat for added protection if desired.", }; Assert.False(ColumbiaCatalogMapper.DetectRequiresClearCoat(p)); } // ── End-to-end mapping invariants ───────────────────────────────────── // ── Tolerant image deserialization (WordPress returns [] / false when empty) ── private static readonly JsonSerializerOptions ClientJsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, PropertyNameCaseInsensitive = true, Converters = { new ColumbiaImageJsonConverter() }, }; [Fact] public void Deserialize_FeaturedImageEmptyArray_YieldsNullImage() { // WordPress returns featured_image: [] for products with no image. var json = """{ "sku": "X1", "name": "No Image", "featured_image": [] }"""; var product = JsonSerializer.Deserialize(json, ClientJsonOptions); Assert.NotNull(product); Assert.Null(product!.FeaturedImage); Assert.Null(ColumbiaCatalogMapper.Map(product).ImageUrl); } [Fact] public void Deserialize_FeaturedImageObject_ParsesSrc() { var json = """{ "sku": "X1", "name": "Has Image", "featured_image": { "id": 5, "src": "https://x/img.png", "name": "i", "alt": "" } }"""; var product = JsonSerializer.Deserialize(json, ClientJsonOptions); Assert.Equal("https://x/img.png", product!.FeaturedImage!.Src); Assert.Equal("https://x/img.png", ColumbiaCatalogMapper.Map(product).ImageUrl); } [Fact] public void Map_AlwaysStampsSource_AndLeavesEnrichmentFieldsNull() { var p = new ColumbiaProduct { Sku = "X5004124", Name = "Blue Wrinkle", Price = "18.85" }; var item = ColumbiaCatalogMapper.Map(p); Assert.Equal("Columbia Coatings API", item.Source); // Enrichment fields are not in the feed and must stay null for lazy TDS/AI enrichment. Assert.Null(item.SpecificGravity); Assert.Null(item.CoverageSqFtPerLb); Assert.Null(item.TransferEfficiency); } }