Files
PowderCoatingLogix/tests/PowderCoating.UnitTests/ColumbiaCatalogMapperTests.cs
T
spouliot 2b420d4623 Add Columbia catalog mapper, shared upsert, and sync service
Phase 2: the mapping and sync core.

- ColumbiaCatalogMapper (pure/static, unit-tested): maps an API product to a
  PowderCatalogItem. Derives manufacturer (PPG/KP Pigments/Columbia) from
  taxonomy+SKU; flags additives into the Powder Additives category; takes base
  price from the top-level price with variant fallback; captures variation /
  tiered pricing as JSON; parses the free-text cure schedule into all curves
  (three degree glyphs, @/at, multi-curve in order, partial-cure -> none) with
  the first as the primary temp/time; strips HTML descriptions; joins color
  groups; normalizes chemistry; flags clear-coat powders.

- PowderCatalogUpsertService (IPowderCatalogUpsertService): single upsert path
  matching on (VendorName, SKU). Copies only feed-sourced fields and leaves
  enrichment fields (specific gravity, coverage, transfer efficiency, finish)
  untouched so syncs never wipe lazily-enriched TDS/AI data.

- ColumbiaCatalogSyncService (IColumbiaCatalogSyncService): pulls the full
  catalog, maps + de-dupes, upserts, then reconciles discontinuations ONLY on a
  complete pull (a partial pull throws and aborts before the sweep). Reactivates
  reappearing items; records last-synced/last-result platform settings.

- 25 mapper unit tests covering the cure parser, manufacturer derivation,
  simple/variable pricing, chemistry, color, and HTML cases from real records.
  Full suite green (261 passed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 11:02:12 -04:00

246 lines
9.4 KiB
C#

using System.Text.Json;
using PowderCoating.Application.DTOs.Columbia;
using PowderCoating.Infrastructure.Services.Columbia;
namespace PowderCoating.UnitTests;
/// <summary>
/// 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.
/// </summary>
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<List<ColumbiaCatalogMapper.CureCurve>>(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));
}
// ── 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<ColumbiaVariationPricing>
{
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<ColumbiaVariationPricing>
{
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 = "<strong>Blue Wrinkle</strong>\r\n\r\nThis is a vibrant &amp; bright coating.";
var text = ColumbiaCatalogMapper.StripHtml(html);
Assert.Equal("Blue Wrinkle This is a vibrant & bright coating.", text);
}
[Fact]
public void DetectRequiresClearCoat_IllusionDescription_IsTrue()
{
var p = new ColumbiaProduct
{
Description = "This Illusion powder requires a clear coat to activate the effect.",
};
Assert.True(ColumbiaCatalogMapper.DetectRequiresClearCoat(p));
}
// ── End-to-end mapping invariants ─────────────────────────────────────
[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);
}
}