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