31febb9f56
Parse releases with an explicit fixed positional model and document the whole logic in British English doc-blocks. Model: - [0]=Artist, [1]=Title are fixed and never matched against dictionaries, so titles like "Split"/"Demo"/"De" are never eaten. - [len-1]=Group is always read from the line, not from the query key. - [len-2]=Year when it is a 4-digit 19xx|20xx token; otherwise the slot is resolved as a middle-zone token (e.g. FLAC still picked up as format). - The middle zone is resolved via case-insensitive dictionaries in the order format -> source -> bit-depth -> resolution -> edition -> language -> catalogue number. Position within the zone is irrelevant. Catalogue numbers: - Any "(...)" token in the middle zone is a catalogue number regardless of content (digits, roman numerals, dots, underscores, mixed case). Removed the hasDigit requirement and character whitelist that rejected real catalogues like (DYN.3140-2) and (GOWP_DCCCLXXXIX). - Splitting never breaks tokens inside parentheses. Case handling: lookups are case-insensitive; stored values keep their original case from the line. Documentation: replaced Ukrainian comments in parser.go and analyzer.go with British-English doc-blocks describing the model, tail resolution order, catalogue rule and confidence scoring. Tests: - Add TestParseCatalogNumberWithDotAndRoman for (DYN.3140-2)/(GOWP_DCCCLXXXIX). - Update case-insensitive, split-title, no-year and language expectations. - go build, go vet and go test all pass.
402 lines
14 KiB
Go
402 lines
14 KiB
Go
package moonblood
|
||
|
||
import (
|
||
"bufio"
|
||
"os"
|
||
"testing"
|
||
)
|
||
|
||
func TestParseMoonblood(t *testing.T) {
|
||
file, err := os.Open("../../resources/moonblood.txt")
|
||
if err != nil {
|
||
t.Skipf("resource file not found (DB mode), skipping: %v", err)
|
||
t.Fatal(err)
|
||
}
|
||
defer file.Close()
|
||
|
||
scanner := bufio.NewScanner(file)
|
||
total, success := 0, 0
|
||
|
||
for scanner.Scan() {
|
||
line := scanner.Text()
|
||
if line == "" {
|
||
continue
|
||
}
|
||
|
||
result := Parse(line)
|
||
total++
|
||
|
||
if result.Group == "MOONBLOOD" && result.Year != 0 && result.Format == "FLAC" {
|
||
success++
|
||
}
|
||
|
||
if result.Confidence < 0.7 {
|
||
t.Logf("Low confidence: %s (%.2f)", line, result.Confidence)
|
||
}
|
||
}
|
||
|
||
t.Logf("Parsed %d releases, %d successful (%.1f%%)", total, success, float64(success)/float64(total)*100)
|
||
}
|
||
|
||
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) {
|
||
// [0]=Artist, [1]=Title. Artist сам може містити дефіс — тоді тайтл
|
||
// стає токеном [1], а {"Violence", "All_My_Life"} щось лишається unknown.
|
||
r := Parse("Ultra-Violence-All_My_Life-EP-16BIT-WEB-FLAC-2026-MOONBLOOD")
|
||
if r.Artist != "Ultra" || r.Title != "Violence" {
|
||
t.Errorf("Artist/Title = %q/%q, want Ultra / Violence", r.Artist, 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-SPLIT-FOO-16BIT-WEB-FLAC-2025-MOONBLOOD")
|
||
if len(r.UnknownTokens) != 1 || r.UnknownTokens[0] != "FOO" {
|
||
t.Errorf("UnknownTokens = %v, want [FOO]", r.UnknownTokens)
|
||
}
|
||
if r.Edition != "SPLIT" || 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 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) {
|
||
// [1]=Title (фіксовано), а Evolution — не словниковий токен у середині:
|
||
// потрапляє в 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) {
|
||
// [1]=Title (один токен), решта, що не словник — 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)
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|