package groups import ( "testing" ) func TestParseFullTail(t *testing.T) { r := Parse("Fane-Dragged_Through_the_Unending-24BIT-WEB-FLAC-2025-MOONBLOOD") if r.Artist != "Fane" { t.Errorf("Artist = %q, want %q", r.Artist, "Fane") } if r.Title != "Dragged_Through_the_Unending" { t.Errorf("Title = %q, want %q", r.Title, "Dragged_Through_the_Unending") } if r.Year != 2025 || r.Format != "FLAC" || r.Source != "WEB" || r.BitDepth != "24BIT" { t.Errorf("tail fields wrong: %+v", r) } if len(r.UnknownTokens) != 0 { t.Errorf("UnknownTokens = %v, want empty", r.UnknownTokens) } if r.Confidence < 0.99 { t.Errorf("Confidence = %.2f, want ~1.0", r.Confidence) } } func TestParseEditionAndDemo(t *testing.T) { r := Parse("Satans_Grind-Rotten_Demo-DEMO-16BIT-WEB-FLAC-2016-MOONBLOOD") if r.Edition != "DEMO" { t.Errorf("Edition = %q, want %q", r.Edition, "DEMO") } if r.Artist != "Satans_Grind" || r.Title != "Rotten_Demo" { t.Errorf("Artist/Title = %q/%q", r.Artist, r.Title) } if len(r.UnknownTokens) != 0 { t.Errorf("UnknownTokens = %v, want empty", r.UnknownTokens) } } func TestParseArtistWithHyphen(t *testing.T) { // Artist сам містить дефіс, але Ultra-Violence ще не в словнику: // core контекстуальний — все між артистом і першим словниковим // маркером (EP) є назвою, зібраною назад через "-". r := Parse("Ultra-Violence-All_My_Life-EP-16BIT-WEB-FLAC-2026-MOONBLOOD") if r.Artist != "Ultra" { t.Errorf("Artist = %q, want Ultra", r.Artist) } if r.Title != "Violence" { t.Errorf("Title = %q, want Violence", r.Title) } if r.Edition != "EP" || r.BitDepth != "16BIT" || r.Source != "WEB" || r.Year != 2026 { t.Errorf("tail fields wrong: %+v", r) } if len(r.UnknownTokens) != 1 || r.UnknownTokens[0] != "All_My_Life" { t.Errorf("UnknownTokens = %v, want [All_My_Life]", r.UnknownTokens) } } func TestParseLanguageAndCDSource(t *testing.T) { r := Parse("Burn_Thou_in_Effigy-Memorias_Postumas_do_Imperador-BR-CD-FLAC-2026-MOONBLOOD") if r.Language != "BR" || r.Source != "CD" || r.Year != 2026 || r.Format != "FLAC" { t.Errorf("tail fields wrong: %+v", r) } if len(r.UnknownTokens) != 0 { t.Errorf("UnknownTokens = %v, want empty", r.UnknownTokens) } } func TestParseNoYear(t *testing.T) { // Without a year the tail still parses; group stays the last token. r := Parse("Fane-Gallant_Shroud-EP-16BIT-WEB-FLAC-MOONBLOOD") if r.Group != "MOONBLOOD" || r.Year != 0 { t.Errorf("Group/Year = %q/%d", r.Group, r.Year) } if r.Edition != "EP" || r.Source != "WEB" || r.Format != "FLAC" { t.Errorf("tail fields wrong: %+v", r) } if r.Artist != "Fane" || r.Title != "Gallant_Shroud" { t.Errorf("Artist/Title = %q/%q", r.Artist, r.Title) } } func TestParseResolutionSlot(t *testing.T) { r := Parse("Ice_Nine_Kills-Last_Chance_To_Make_Amends-REMASTERED-24BIT-44KHZ-WEB-FLAC-2024-FLACCiD") if r.Group != "FLACCiD" { t.Errorf("Group = %q, want FLACCiD", r.Group) } if r.Resolution != "44KHZ" { t.Errorf("Resolution = %q, want 44KHZ", r.Resolution) } if r.BitDepth != "24BIT" || r.Edition != "REMASTERED" || r.Source != "WEB" || r.Year != 2024 { t.Errorf("tail fields wrong: %+v", r) } if r.Artist != "Ice_Nine_Kills" || r.Title != "Last_Chance_To_Make_Amends" { t.Errorf("Artist/Title = %q/%q", r.Artist, r.Title) } if len(r.UnknownTokens) != 0 { t.Errorf("UnknownTokens = %v, want empty", r.UnknownTokens) } } func TestParseVinylIsSource(t *testing.T) { r := Parse("ROTH-Alles_Endet-DE-VINYL-FLAC-2026-TOTENKVLT") if r.Language != "DE" { t.Errorf("Language = %q, want DE", r.Language) } if r.Source != "VINYL" { t.Errorf("Source = %q, want VINYL", r.Source) } if r.Edition != "" { t.Errorf("Edition = %q, want empty (VINYL is source, not edition)", r.Edition) } if len(r.UnknownTokens) != 0 { t.Errorf("UnknownTokens = %v, want empty", r.UnknownTokens) } } func TestParseCaseInsensitiveDictionaries(t *testing.T) { r := Parse("Artist-Some_Title-16bit-web-flac-2020-moonblood") // Значення зберігаються в оригінальному регістрі (як у рядку), // а звірка зі словниками йде регістронезалежно. if r.BitDepth != "16bit" || r.Source != "web" || r.Format != "flac" { t.Errorf("case-insensitive lookup failed: %+v", r) } if r.Artist != "Artist" || r.Title != "Some_Title" { t.Errorf("Artist/Title = %q/%q", r.Artist, r.Title) } } func TestParseUnknownTailTokenReported(t *testing.T) { // Невідомий токен ПІСЛЯ останнього словникового маркера (за ним уже // нічого відомого немає) → у UnknownTokens. r := Parse("Artist-Some_Title-16BIT-WEB-FLAC-FOO-2025-MOONBLOOD") if len(r.UnknownTokens) != 1 || r.UnknownTokens[0] != "FOO" { t.Errorf("UnknownTokens = %v, want [FOO]", r.UnknownTokens) } if len(r.TitleExtra) != 0 { t.Errorf("TitleExtra = %v, want empty", r.TitleExtra) } if r.Edition != "" || r.BitDepth != "16BIT" || r.Source != "WEB" || r.Year != 2025 { t.Errorf("tail fields wrong: %+v", r) } if r.Artist != "Artist" || r.Title != "Some_Title" { t.Errorf("Artist/Title = %q/%q", r.Artist, r.Title) } } func TestParseContextualCoreAbsorbsTitleTail(t *testing.T) { // Права межа core контекстуальна (перший словниковий маркер), але // невідомі токени всередині core-зони — в UnknownTokens, не з'їдаються. r := Parse("Artist-Some_Title-Part_Two-Part_Three-16BIT-WEB-FLAC-2025-MOONBLOOD") if r.Artist != "Artist" { t.Errorf("Artist = %q, want Artist", r.Artist) } if r.Title != "Some_Title" { t.Errorf("Title = %q, want Some_Title", r.Title) } if r.BitDepth != "16BIT" || r.Source != "WEB" || r.Format != "FLAC" || r.Year != 2025 { t.Errorf("tail fields wrong: %+v", r) } if len(r.UnknownTokens) != 2 || r.UnknownTokens[0] != "Part_Two" || r.UnknownTokens[1] != "Part_Three" { t.Errorf("UnknownTokens = %v, want [Part_Two Part_Three]", r.UnknownTokens) } } func TestParseCoreTokensNeverReported(t *testing.T) { // Артист/тайтл ніколи не потрапляють в UnknownTokens. r := Parse("Fane-Dragged_Through_the_Unending-24BIT-WEB-FLAC-2025-MOONBLOOD") if len(r.UnknownTokens) != 0 { t.Errorf("UnknownTokens = %v, want empty", r.UnknownTokens) } if r.Artist != "Fane" || r.Title != "Dragged_Through_the_Unending" { t.Errorf("Artist/Title = %q/%q", r.Artist, r.Title) } } func TestParseAnyGroupNoWarning(t *testing.T) { cases := []string{ "Jetsex-Down_Down_Down-24BIT-WEB-FLAC-2025-FLACCiD", "Sabaton-The_Last_Stand-24BIT-96KHZ-WEB-FLAC-2016-FLACCiD", "Wind_Walkers-What_If_I_Break-16BIT-WEB-FLAC-2023-FLACCiD", } for _, line := range cases { r := Parse(line) if r.Group != "FLACCiD" { t.Errorf("%s: Group = %q, want FLACCiD", line, r.Group) } if len(r.Warnings) != 0 { t.Errorf("%s: Warnings = %v, want empty", line, r.Warnings) } if len(r.UnknownTokens) != 0 { t.Errorf("%s: UnknownTokens = %v, want empty", line, r.UnknownTokens) } if r.Year == 0 || r.Format != "FLAC" || r.Source != "WEB" || r.BitDepth == "" { t.Errorf("%s: tail fields wrong: %+v", line, r) } } } func TestParseCommentExtracted(t *testing.T) { r := Parse("Rain_City_Drive-Rain_City_Drive-DELUXE_EDITION-REPACK-16BIT-WEB-FLAC-2023-FLACCiD") if r.Comment != "REPACK" { t.Errorf("Comment = %q, want REPACK", r.Comment) } if r.Edition != "DELUXE_EDITION" || r.BitDepth != "16BIT" || r.Source != "WEB" || r.Year != 2023 { t.Errorf("tail fields wrong: %+v", r) } if r.Artist != "Rain_City_Drive" || r.Title != "Rain_City_Drive" { t.Errorf("Artist/Title = %q/%q", r.Artist, r.Title) } if len(r.UnknownTokens) != 0 || len(r.Warnings) != 0 { t.Errorf("UnknownTokens=%v Warnings=%v, want empty", r.UnknownTokens, r.Warnings) } } func TestParseNumericArtistNotAYear(t *testing.T) { // [0]=Artist — навіть якщо це число-рік (1914, 1928), а не "рік" токен. for _, line := range []string{ "1914-Eschatology_of_War-24BIT-WEB-FLAC-2015-MOONBLOOD", "1928-Lappel_du_Spectre-FR-CD-FLAC-2025-MOONBLOOD", } { r := Parse(line) if r.Artist != "1914" && r.Artist != "1928" { t.Errorf("%s: Artist = %q, want numeric artist", line, r.Artist) } if len(r.UnknownTokens) != 0 { t.Errorf("%s: UnknownTokens = %v, want empty", line, r.UnknownTokens) } } } func TestParseCatalogNumber(t *testing.T) { r := Parse("Antidot-Beyond_Trance_And_Time-(GLOBAL006)-REPACK-EP-FLAC-2026-AFTERHOUR") if r.CatalogNo != "GLOBAL006" { t.Errorf("CatalogNo = %q, want GLOBAL006", r.CatalogNo) } if r.Comment != "REPACK" || r.Edition != "EP" || r.Format != "FLAC" || r.Year != 2026 { t.Errorf("tail fields wrong: %+v", r) } if r.Artist != "Antidot" || r.Title != "Beyond_Trance_And_Time" { t.Errorf("Artist/Title = %q/%q", r.Artist, r.Title) } if len(r.UnknownTokens) != 0 { t.Errorf("UnknownTokens = %v, want empty", r.UnknownTokens) } } func TestParseCatalogNumberWithDashesInside(t *testing.T) { r := Parse("Murder_Intentions-Excessive_Display_Of_Human_Nature-(BRC30-001-CD-RRR108)-CD-FLAC-2018-86D") if r.CatalogNo != "BRC30-001-CD-RRR108" { t.Errorf("CatalogNo = %q, want BRC30-001-CD-RRR108", r.CatalogNo) } if r.Source != "CD" || r.Format != "FLAC" || r.Year != 2018 || r.Group != "86D" { t.Errorf("tail fields wrong: %+v", r) } if r.Artist != "Murder_Intentions" || r.Title != "Excessive_Display_Of_Human_Nature" { t.Errorf("Artist/Title = %q/%q", r.Artist, r.Title) } if len(r.UnknownTokens) != 0 { t.Errorf("UnknownTokens = %v, want empty", r.UnknownTokens) } } func TestParseCatalogNumberWithDotAndRoman(t *testing.T) { // Катало у дужках у середній зоні — незалежно від вмісту // (крапка, римські, букви, дефіси): все валідне. cases := []struct{ line, catno string }{ {"Nervosa-Perpetual_Chaos-(DYN.3140-2)-LIMITED_EDITION-CD-FLAC-2021-MOONBLOOD", "DYN.3140-2"}, {"Black_Ceremonial_Kult-Crowned_in_Chaos-(GOWP_DCCCLXXXIX)-CDEP-FLAC-2021-MOONBLOOD", "GOWP_DCCCLXXXIX"}, } for _, c := range cases { r := Parse(c.line) if r.CatalogNo != c.catno { t.Errorf("CatalogNo = %q, want %q (release: %s)", r.CatalogNo, c.catno, c.line) } if len(r.UnknownTokens) != 0 { t.Errorf("UnknownTokens = %v, want empty (release: %s)", r.UnknownTokens, c.line) } } } func TestParseSplitKeepsParensAndSplitsTitles(t *testing.T) { // Evolution — не словниковий токен у core-зоні → в UnknownTokens, // не з'їдається. (SMG409) — катало, перший відомий маркер. r := Parse("Ferosity-De-Evolution-(SMG409)-CD-FLAC-2025-86D") if r.CatalogNo != "SMG409" { t.Errorf("CatalogNo = %q, want SMG409", r.CatalogNo) } if r.Artist != "Ferosity" || r.Title != "De" { t.Errorf("Artist/Title = %q/%q, want Ferosity / De", r.Artist, r.Title) } if len(r.UnknownTokens) != 1 || r.UnknownTokens[0] != "Evolution" { t.Errorf("UnknownTokens = %v, want [Evolution]", r.UnknownTokens) } } func TestParseLanguageRequiresKnownNeighbor(t *testing.T) { // Мовний маркер валідний, коли праворуч — відомий хвостовий маркер. r := Parse("ROTH-Alles_Endet-DE-VINYL-FLAC-2026-TOTENKVLT") if r.Language != "DE" || r.Source != "VINYL" { t.Errorf("Language/Source = %q/%q, want DE/VINYL", r.Language, r.Source) } } func TestParseLanguageBetweenTitleAndFormat(t *testing.T) { // A: мовний маркер у зоні між title і format — валідний, // все ліворуч від нього — artist+title, нічого в unknown. r := Parse("Kollektiver_Brechreiz-Heilig_Scheint_Scheinheilig-(NG207)-DE-CD-FLAC-2009-SHELTER") if r.CatalogNo != "NG207" { t.Errorf("CatalogNo = %q, want NG207", r.CatalogNo) } if r.Language != "DE" || r.Source != "CD" || r.Format != "FLAC" || r.Year != 2009 { t.Errorf("tail fields wrong: %+v", r) } if r.Artist != "Kollektiver_Brechreiz" || r.Title != "Heilig_Scheint_Scheinheilig" { t.Errorf("Artist/Title = %q/%q", r.Artist, r.Title) } if len(r.UnknownTokens) != 0 || len(r.Warnings) != 0 { t.Errorf("UnknownTokens=%v Warnings=%v, want empty", r.UnknownTokens, r.Warnings) } } func TestParseLanguageNeverBeforeFormat(t *testing.T) { // DE — словниковий токен у середині (language.txt), позиція неважлива: // Language=DE. r := Parse("Some_Artist-Some_Song-DE-FLAC-2020-SOMEGROUP") if r.Language != "DE" { t.Errorf("Language = %q, want DE", r.Language) } if len(r.UnknownTokens) != 0 { t.Errorf("UnknownTokens = %v, want empty", r.UnknownTokens) } if r.Title != "Some_Song" { t.Errorf("Title = %q, want Some_Song", r.Title) } } func TestParseCompoundEditionRecognized(t *testing.T) { // REMASTERED_REISSUE — складений маркер у dicts/edition.txt, розпізнається // як edition і не йде в UnknownTokens. r := Parse("Ominous_Scriptures-Incarnation_of_the_Unheavenly-(LSR07)-REMASTERED_REISSUE-CD-FLAC-2021-86D") if r.CatalogNo != "LSR07" { t.Errorf("CatalogNo = %q, want LSR07", r.CatalogNo) } if r.Edition != "REMASTERED_REISSUE" { t.Errorf("Edition = %q, want REMASTERED_REISSUE", r.Edition) } if len(r.UnknownTokens) != 0 { t.Errorf("UnknownTokens = %v, want empty", r.UnknownTokens) } if r.Artist != "Ominous_Scriptures" || r.Title != "Incarnation_of_the_Unheavenly" { t.Errorf("Artist/Title = %q/%q", r.Artist, r.Title) } if r.Source != "CD" || r.Format != "FLAC" || r.Year != 2021 { t.Errorf("tail fields wrong: %+v", r) } } func TestParseMixedCaseTitleNeverReported(t *testing.T) { // Хвіст назви (_Sky_Burial) — не словник, але він ДО першого маркера // (катало (RTM103)) → частина назви, не UnknownTokens. r := Parse("Enthrallment-Against_the_Will_to_Live_-_Sky_Burial-(RTM103)-CD-FLAC-2021-86D") if r.Artist != "Enthrallment" || r.Title != "Against_the_Will_to_Live_" { t.Errorf("Artist/Title = %q/%q", r.Artist, r.Title) } if len(r.UnknownTokens) != 1 || r.UnknownTokens[0] != "_Sky_Burial" { t.Errorf("UnknownTokens = %v, want [_Sky_Burial]", r.UnknownTokens) } if r.CatalogNo != "RTM103" { t.Errorf("CatalogNo = %q, want RTM103", r.CatalogNo) } } func contains(list []string, s string) bool { for _, v := range list { if v == s { return true } } return false } func TestParseParenthesizedTitleNotCatNo(t *testing.T) { // Дужки в тайтлі при WEB/BitDepth — не каталономер, лишається в тайтлі. r := Parse("The_Amazons-21st_Century_Fiction_(Epilogue)-16BIT-WEB-FLAC-2026-ENViED") if r.CatalogNo != "" { t.Errorf("CatalogNo = %q, want empty", r.CatalogNo) } if r.Title != "21st_Century_Fiction_(Epilogue)" { t.Errorf("Title = %q, want 21st_Century_Fiction_(Epilogue)", r.Title) } if r.Source != "WEB" || r.BitDepth != "16BIT" || r.Year != 2026 { t.Errorf("tail fields wrong: %+v", r) } if len(r.UnknownTokens) != 0 { t.Errorf("UnknownTokens = %v, want empty", r.UnknownTokens) } } func TestParseArtistDictionaryCoversReleases(t *testing.T) { // artists.txt: "Iku-Turso" у словнику → Artist = 2 токени, // Title = [2], хвостова зона зсувається і парситься як завжди. r := Parse("Iku-Turso-Ikuinen_Steelfest_MMXXII_Live-24BIT-WEB-FLAC-2016-MOONBLOOD") if r.Artist != "Iku-Turso" { t.Errorf("Artist = %q, want Iku-Turso", r.Artist) } if r.Title != "Ikuinen_Steelfest_MMXXII_Live" { t.Errorf("Title = %q, want Ikuinen_Steelfest_MMXXII_Live", r.Title) } if r.BitDepth != "24BIT" || r.Source != "WEB" || r.Format != "FLAC" || r.Year != 2016 { t.Errorf("tail fields wrong: %+v", r) } if len(r.UnknownTokens) != 0 || len(r.TitleExtra) != 0 || len(r.Warnings) != 0 { t.Errorf("Unknown=%v Extra=%v Warnings=%v, want empty", r.UnknownTokens, r.TitleExtra, r.Warnings) } } func TestParseArtistDictionaryMultiTokenArtist(t *testing.T) { // artists.txt: "Shadu-Nar-Mattaru" — артист з ТРЬОХ токенів. // Найдовший словниковий префікс виграє: Artist = 3 токени, // Title = наступний токен, хвіст парситься як завжди. r := Parse("Shadu-Nar-Mattaru-On_Pathways_Macabre-24BIT-WEB-FLAC-2023-MOONBLOOD") if r.Artist != "Shadu-Nar-Mattaru" { t.Errorf("Artist = %q, want Shadu-Nar-Mattaru", r.Artist) } if r.Title != "On_Pathways_Macabre" { t.Errorf("Title = %q, want On_Pathways_Macabre", r.Title) } if r.BitDepth != "24BIT" || r.Source != "WEB" || r.Format != "FLAC" || r.Year != 2023 { t.Errorf("tail fields wrong: %+v", r) } if len(r.UnknownTokens) != 0 || len(r.TitleExtra) != 0 || len(r.Warnings) != 0 { t.Errorf("Unknown=%v Extra=%v Warnings=%v, want empty", r.UnknownTokens, r.TitleExtra, r.Warnings) } } func TestParseOverrideWinsOverPositionalModel(t *testing.T) { // overrides.txt (найвищий пріоритет): точний lookup по Raw // фіксує Artist/Title; хвіст парситься як завжди. raw := "Milicia_Oscura-Anti-Cristo-ES-16BIT-WEB-FLAC-2013-MOONBLOOD" r := Parse(raw) if r.Artist != "Milicia_Oscura" { t.Errorf("Artist = %q, want Milicia_Oscura", r.Artist) } if r.Title != "Anti-Cristo" { t.Errorf("Title = %q, want Anti-Cristo", r.Title) } if r.Language != "ES" || r.BitDepth != "16BIT" || r.Source != "WEB" || r.Format != "FLAC" || r.Year != 2013 || r.Group != "MOONBLOOD" { t.Errorf("tail fields wrong: %+v", r) } if len(r.UnknownTokens) != 0 || len(r.TitleExtra) != 0 || len(r.Warnings) != 0 { t.Errorf("Unknown=%v Extra=%v Warnings=%v, want empty", r.UnknownTokens, r.TitleExtra, r.Warnings) } } func TestParseTypeNormalizedSourceStaysRaw(t *testing.T) { // CDEP у source.txt: Source лишається сирим, Type нормалізується в EP. r := Parse("Black_Ceremonial_Kult-Crowned_in_Chaos-CDEP-FLAC-2021-MOONBLOOD") if r.Type != "EP" { t.Errorf("Type = %q, want EP", r.Type) } if r.Source != "CDEP" { t.Errorf("Source = %q, want raw CDEP", r.Source) } } func TestParseTypeFirstMatchWins(t *testing.T) { // REMASTERED_EP → EP; наступний типовий токен не перезаписує. r := Parse("Some_Artist-Some_Title-REMASTERED_EP-SINGLE-FLAC-2021-MOONBLOOD") if r.Type != "EP" { t.Errorf("Type = %q, want EP (first match wins)", r.Type) } if r.Edition != "REMASTERED_EP" { t.Errorf("Edition = %q, want REMASTERED_EP", r.Edition) } } func TestAnalyzeTypeDistribution(t *testing.T) { stats := AnalyzeTitles([]string{ "Some_Artist-Some_Title-CDEP-FLAC-2021-MOONBLOOD", "Other_Artist-Other_Title-EP-FLAC-2022-MOONBLOOD", "Third_Artist-Third_Title-SPLIT-FLAC-2023-MOONBLOOD", }) if stats.TypeDistribution["EP"] != 2 { t.Errorf("TypeDistribution[EP] = %d, want 2", stats.TypeDistribution["EP"]) } if stats.TypeDistribution["Split"] != 1 { t.Errorf("TypeDistribution[Split] = %d, want 1", stats.TypeDistribution["Split"]) } }