Files
QuoteForge/internal/article/generator_test.go
T
Mikhail ChusavitinandClaude Sonnet 5 006d7e87c5 fix: артикул — распознавание DPU/BlueField профиля (BF3/BF2)
DPU-лоты (DPU_BF3_B3220-32G) не подходили под regex parsePortSpeed
(рассчитан на NIC/HCA-суффиксы NpMGbE/pFC) и падали в заглушку
категории NET с предупреждением. Добавлен parseDPUModel — структурный
разбор DPU_{FAMILY}_{MODEL} без каталога вендоров.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 11:56:42 +03:00

260 lines
9.0 KiB
Go

package article
import (
"path/filepath"
"strings"
"testing"
"time"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
)
func TestBuild_ParsesNetAndPSU(t *testing.T) {
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
if err != nil {
t.Fatalf("init local db: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
ServerID: 1,
Source: "estimate",
Version: "S-2026-02-11-001",
Name: "test",
CreatedAt: time.Now(),
SyncedAt: time.Now(),
}); err != nil {
t.Fatalf("save local pricelist: %v", err)
}
localPL, err := local.GetLocalPricelistByServerID(1)
if err != nil {
t.Fatalf("get local pricelist: %v", err)
}
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
{PricelistID: localPL.ID, LotName: "NIC_2p25G_MCX512A-AC", LotCategory: "NIC", Price: 1},
{PricelistID: localPL.ID, LotName: "HBA_2pFC32_Gen6", LotCategory: "HBA", Price: 1},
{PricelistID: localPL.ID, LotName: "PS_1000W_Platinum", LotCategory: "PS", Price: 1},
}); err != nil {
t.Fatalf("save local items: %v", err)
}
items := models.ConfigItems{
{LotName: "NIC_2p25G_MCX512A-AC", Quantity: 1},
{LotName: "HBA_2pFC32_Gen6", Quantity: 1},
{LotName: "PS_1000W_Platinum", Quantity: 2},
{LotName: "SVC_1yW_x86", Quantity: 1},
}
result, err := Build(local, items, BuildOptions{
ServerModel: "DL380GEN11",
})
if err != nil {
t.Fatalf("build article: %v", err)
}
if result.Article == "" {
t.Fatalf("expected article to be non-empty")
}
if contains(result.Article, "UNKNET") || contains(result.Article, "UNKPSU") {
t.Fatalf("unexpected UNK in article: %s", result.Article)
}
if !contains(result.Article, "1yW") {
t.Fatalf("expected support segment 1yW in article: %s", result.Article)
}
}
// TestBuild_CompressArticle_NoGPU_PSUNotNIC reproduces the bug where 2 PSUs produced
// "2xNIC" in the article because compressArticle used hard-coded indices that assumed
// GPU was always present.
func TestBuild_CompressArticle_NoGPU_PSUNotNIC(t *testing.T) {
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
if err != nil {
t.Fatalf("init local db: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
ServerID: 2,
Source: "estimate",
Version: "S-2026-05-19-001",
Name: "test",
CreatedAt: time.Now(),
SyncedAt: time.Now(),
}); err != nil {
t.Fatalf("save local pricelist: %v", err)
}
localPL, err := local.GetLocalPricelistByServerID(2)
if err != nil {
t.Fatalf("get local pricelist: %v", err)
}
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
{PricelistID: localPL.ID, LotName: "CPU_INTEL_8358", LotCategory: "CPU", Price: 1},
{PricelistID: localPL.ID, LotName: "MEM_DDR4_64G_3200", LotCategory: "MEM", Price: 1},
{PricelistID: localPL.ID, LotName: "SSD_SATA_0.4T", LotCategory: "SSD", Price: 1},
{PricelistID: localPL.ID, LotName: "SSD_SATA_0.9T", LotCategory: "SSD", Price: 1},
{PricelistID: localPL.ID, LotName: "HDD_SATA_16T", LotCategory: "HDD", Price: 1},
{PricelistID: localPL.ID, LotName: "NIC_2p25G_MCX512A", LotCategory: "NIC", Price: 1},
{PricelistID: localPL.ID, LotName: "HBA_2pFC32_Gen6", LotCategory: "HBA", Price: 1},
{PricelistID: localPL.ID, LotName: "NIC_4p1G_I350", LotCategory: "NIC", Price: 1},
{PricelistID: localPL.ID, LotName: "PS_1500W_Platinum", LotCategory: "PS", Price: 1},
}); err != nil {
t.Fatalf("save local items: %v", err)
}
// PS_1500W → "2x1.5kW" (7 chars) brings uncompressed article to 81 chars, triggering
// compressArticle. Before the fix, compressArticle used hard-coded index 5 for NET, but
// without GPU the PSU sits at index 5, so compressNetSegment("2x1.5kW") returned "2xNIC".
items := models.ConfigItems{
{LotName: "CPU_INTEL_8358", Quantity: 2},
{LotName: "MEM_DDR4_64G_3200", Quantity: 16}, // 1024 GiB = 1T
{LotName: "SSD_SATA_0.4T", Quantity: 2},
{LotName: "SSD_SATA_0.9T", Quantity: 4},
{LotName: "HDD_SATA_16T", Quantity: 6},
{LotName: "NIC_2p25G_MCX512A", Quantity: 1},
{LotName: "HBA_2pFC32_Gen6", Quantity: 1},
{LotName: "NIC_4p1G_I350", Quantity: 1},
{LotName: "PS_1500W_Platinum", Quantity: 2},
}
result, err := Build(local, items, BuildOptions{
ServerModel: "NF5280M6",
})
if err != nil {
t.Fatalf("build article: %v", err)
}
if len([]rune(result.Article)) > 80 {
t.Fatalf("article too long (%d): %s", len([]rune(result.Article)), result.Article)
}
// PSU segment must not be mis-labeled as NIC during compression
// The correct behaviour: PSU is dropped, NET stays as-is or compressed to HBA/NIC labels
// Before the fix: article ended with "-2xNIC" (PSU turned into NIC)
// After the fix: article must not contain a standalone "NIC" that came from PSU wattage
if strings.HasSuffix(result.Article, "-2xNIC") {
t.Fatalf("PSU mis-labeled as NIC in article: %s", result.Article)
}
t.Logf("article: %s (warnings: %v)", result.Article, result.Warnings)
}
func contains(s, sub string) bool {
return strings.Contains(s, sub)
}
func TestParsePortSpeed_DPUModel(t *testing.T) {
cases := map[string]string{
"DPU_BF3_B3220-32G": "BF3_B3220-32G",
"DPU_BF2_B2220Q": "BF2_B2220Q",
}
for in, want := range cases {
if got := parsePortSpeed(in); got != want {
t.Errorf("parsePortSpeed(%q) = %q, want %q", in, got, want)
}
}
}
// TestBuild_ParsesDPU verifies a DPU lot (e.g. NVIDIA BlueField) resolves to its
// family+model token instead of falling back to the "NET" category placeholder —
// DPU lots don't carry a NIC/HCA-style port-speed suffix, so parsePortSpeed needs
// its own DPU branch (parseDPUModel).
func TestBuild_ParsesDPU(t *testing.T) {
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
if err != nil {
t.Fatalf("init local db: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
ServerID: 10, Source: "estimate", Version: "v1", Name: "t",
IsActive: true, CreatedAt: time.Now(), SyncedAt: time.Now(),
}); err != nil {
t.Fatalf("save pricelist: %v", err)
}
pl, err := local.GetLocalPricelistByServerID(10)
if err != nil {
t.Fatalf("get pricelist: %v", err)
}
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
{PricelistID: pl.ID, LotName: "DPU_BF3_B3220-32G", LotCategory: "DPU", Price: 1},
}); err != nil {
t.Fatalf("save items: %v", err)
}
result, err := Build(local, models.ConfigItems{
{LotName: "DPU_BF3_B3220-32G", Quantity: 1},
}, BuildOptions{ServerModel: "X1"})
if err != nil {
t.Fatalf("build: %v", err)
}
if len(result.Warnings) != 0 {
t.Fatalf("expected no warnings, got %v", result.Warnings)
}
if !contains(result.Article, "BF3_B3220-32G") {
t.Fatalf("expected DPU model token in article: %s", result.Article)
}
}
func TestParseGPUModel_VendorLetterSuffix(t *testing.T) {
cases := map[string]string{
"GPU_NV_RTX_PRO_6000D_SERVER_84GB_PCIE": "RTX6000D_84GB",
"GPU_NV_RTX_PRO_6000_SERVER_96GB_PCIE": "RTX6000_96GB",
}
for in, want := range cases {
got, ok := parseGPUModel(in)
if !ok || got != want {
t.Errorf("parseGPUModel(%q) = %q, %v, want %q, true", in, got, ok, want)
}
}
}
// TestBuild_UnparseableModel_CategoryPlaceholder: a GPU LOT whose name does not
// match the expected shape must not silently vanish or emit "UNK" — the article
// carries the category token and the segment is flagged not-recognized with a
// warning naming the lot.
func TestBuild_UnparseableModel_CategoryPlaceholder(t *testing.T) {
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
if err != nil {
t.Fatalf("init local db: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
ServerID: 9, Source: "estimate", Version: "v1", Name: "t",
IsActive: true, CreatedAt: time.Now(), SyncedAt: time.Now(),
}); err != nil {
t.Fatalf("save pricelist: %v", err)
}
pl, err := local.GetLocalPricelistByServerID(9)
if err != nil {
t.Fatalf("get pricelist: %v", err)
}
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
{PricelistID: pl.ID, LotName: "WEIRDGPUNAME", LotCategory: "GPU", Price: 1},
}); err != nil {
t.Fatalf("save items: %v", err)
}
result, err := Build(local, models.ConfigItems{
{LotName: "WEIRDGPUNAME", Quantity: 4},
}, BuildOptions{ServerModel: "X1"})
if err != nil {
t.Fatalf("build: %v", err)
}
if contains(result.Article, "UNK") {
t.Fatalf("article must not contain UNK: %s", result.Article)
}
if !contains(result.Article, "4xGPU") {
t.Fatalf("expected category placeholder 4xGPU in article: %s", result.Article)
}
var gpu *ResultSegment
for i := range result.Segments {
if result.Segments[i].Group == "GPU" {
gpu = &result.Segments[i]
}
}
if gpu == nil || gpu.Recognized {
t.Fatalf("GPU segment must be present and not recognized: %+v", result.Segments)
}
if len(result.Warnings) == 0 || !contains(strings.Join(result.Warnings, "|"), "WEIRDGPUNAME") {
t.Fatalf("expected a warning naming WEIRDGPUNAME, got %v", result.Warnings)
}
}