diff --git a/CHANGELOG.md b/CHANGELOG.md index 2927a9d..1bcf11a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to `atb-cli` are documented here. Format follows [Keep a Cha ## [Unreleased] +### Changed + +- `--species` and `--genus` now match GTDB taxonomy. ATB stores GTDB names, + which split NCBI genera and species into alphabetic clades + (`Enterococcus_A faecium`, `Enterococcus_B faecium`, + `Campylobacter jejunii_A`), so an NCBI-style query such as + `atb mlst --species "Enterococcus faecium"` previously returned nothing. The + GTDB suffix is now stripped from the stored name during comparison, so an + unsuffixed query matches every clade while an explicit clade name + (`Campylobacter_D`) still selects only that clade. This applies to `query`, + `mlst`, and `amr` on every backend (SQLite index, parquet scan, and AMR genus + partitions). + ### Fixed - `atb ` no longer printed `Run 'atb --help' for usage.`, a diff --git a/internal/amr/amr.go b/internal/amr/amr.go index d95068a..efd569b 100644 --- a/internal/amr/amr.go +++ b/internal/amr/amr.go @@ -87,6 +87,12 @@ func Query(dataDir string, filters Filters) ([]Result, error) { return nil, err } + // Expand each requested genus to the GTDB clade partitions that hold its + // rows (e.g. "Enterococcus" -> "Enterococcus_A", "Enterococcus_B") so a + // split genus is read from every clade file rather than falling back to a + // full monolithic scan. + filters.Genera = expandGeneraToPartitions(dataDir, filters.Genera) + // Try SQLite indexes first for each genus. if len(filters.Genera) > 0 { return queryWithIndexes(dataDir, filters) @@ -351,9 +357,13 @@ func matchesFilters(row pq.AMRRow, f Filters) bool { return true } +// matchesAny reports whether value matches any candidate taxon name, treating +// GTDB alphabetic suffixes as equivalent so an NCBI-style query +// ("Enterococcus faecium") matches the GTDB-split names stored in the data +// ("Enterococcus_A faecium"). func matchesAny(value string, candidates []string) bool { for _, c := range candidates { - if strings.EqualFold(value, c) { + if match.SpeciesMatches(c, value) { return true } } diff --git a/internal/amr/amr_gtdb_test.go b/internal/amr/amr_gtdb_test.go new file mode 100644 index 0000000..0f30ad2 --- /dev/null +++ b/internal/amr/amr_gtdb_test.go @@ -0,0 +1,93 @@ +package amr_test + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + parquetgo "github.com/parquet-go/parquet-go" + + "github.com/allthebacteria/atb-cli/internal/amr" + pq "github.com/allthebacteria/atb-cli/internal/parquet" +) + +// writeAMRParquetAt writes the fixture rows to an explicit parquet path. +func writeAMRParquetAt(t *testing.T, path string, rows []amrFixtureRow) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + f, err := os.Create(path) + if err != nil { + t.Fatalf("create parquet: %v", err) + } + defer f.Close() + + w := parquetgo.NewGenericWriter[pq.AMRRow](f) + var idx int + for _, r := range rows { + for i := 0; i < r.count; i++ { + row := pq.AMRRow{ + Name: fmt.Sprintf("SAMN%08d", idx), + GeneSymbol: fmt.Sprintf("gene_%d", idx), + ElementType: "AMR", + Coverage: 100, + Identity: 100, + Method: "EXACT", + Class: "BETA-LACTAM", + Species: r.species, + Genus: r.genus, + } + if _, err := w.Write([]pq.AMRRow{row}); err != nil { + t.Fatalf("write row: %v", err) + } + idx++ + } + } + if err := w.Close(); err != nil { + t.Fatalf("close writer: %v", err) + } +} + +// TestQueryAMRSpeciesGTDBSuffix verifies that an NCBI-style --species query +// matches GTDB-split rows on the monolithic scan path. +func TestQueryAMRSpeciesGTDBSuffix(t *testing.T) { + dir := t.TempDir() + writeAMRFixture(t, dir, []amrFixtureRow{ + {species: "Enterococcus_A faecium", genus: "Enterococcus_A", count: 2}, + {species: "Enterococcus_B faecium", genus: "Enterococcus_B", count: 3}, + {species: "Escherichia coli", genus: "Escherichia", count: 4}, + }) + + results, err := amr.Query(dir, amr.Filters{ + Genera: []string{"Enterococcus"}, + Species: []string{"Enterococcus faecium"}, + }) + if err != nil { + t.Fatalf("Query: %v", err) + } + if len(results) != 5 { + t.Fatalf("expected 5 GTDB-split rows, got %d", len(results)) + } +} + +// TestQueryAMRGenusGTDBPartitions verifies that an NCBI-style genus query reads +// every on-disk GTDB clade partition, even when no monolithic file is present. +func TestQueryAMRGenusGTDBPartitions(t *testing.T) { + dir := t.TempDir() + writeAMRParquetAt(t, filepath.Join(dir, amr.PartitionDir, "Enterococcus_A.parquet"), + []amrFixtureRow{{species: "Enterococcus_A faecium", genus: "Enterococcus_A", count: 2}}) + writeAMRParquetAt(t, filepath.Join(dir, amr.PartitionDir, "Enterococcus_B.parquet"), + []amrFixtureRow{{species: "Enterococcus_B faecium", genus: "Enterococcus_B", count: 3}}) + + results, err := amr.Query(dir, amr.Filters{ + Genera: []string{"Enterococcus"}, + }) + if err != nil { + t.Fatalf("Query: %v", err) + } + if len(results) != 5 { + t.Fatalf("expected 5 rows across both clade partitions, got %d", len(results)) + } +} diff --git a/internal/amr/partition.go b/internal/amr/partition.go index 7b7213f..ce3704b 100644 --- a/internal/amr/partition.go +++ b/internal/amr/partition.go @@ -11,6 +11,7 @@ import ( parquetgo "github.com/parquet-go/parquet-go" + "github.com/allthebacteria/atb-cli/internal/match" pq "github.com/allthebacteria/atb-cli/internal/parquet" ) @@ -135,6 +136,60 @@ func BuildPartitions(dataDir string, logFn func(string, ...any)) error { return nil } +// expandGeneraToPartitions replaces each requested genus with the on-disk +// partition genera whose canonical (GTDB-suffix-stripped) name matches it, so +// an NCBI-style genus such as "Enterococcus" resolves to the clade partitions +// "Enterococcus_A" and "Enterococcus_B". A genus with no matching partition is +// kept unchanged so the caller can fall back to the monolithic scan. When no +// partition directory exists, the input is returned as-is. +func expandGeneraToPartitions(dataDir string, genera []string) []string { + if len(genera) == 0 { + return genera + } + + entries, err := os.ReadDir(filepath.Join(dataDir, PartitionDir)) + if err != nil { + return genera + } + var partitions []string + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".parquet") { + continue + } + name := strings.TrimSuffix(e.Name(), ".parquet") + if name == otherPartition { + continue + } + partitions = append(partitions, name) + } + + seen := make(map[string]struct{}, len(genera)) + var out []string + add := func(g string) { + if _, ok := seen[g]; ok { + return + } + seen[g] = struct{}{} + out = append(out, g) + } + for _, g := range genera { + var matched []string + for _, p := range partitions { + if match.SpeciesMatches(g, p) { + matched = append(matched, p) + } + } + if len(matched) > 0 { + for _, m := range matched { + add(m) + } + continue + } + add(g) + } + return out +} + // PartitionPath returns the path to a genus partition file if it exists. // Returns empty string if the partition doesn't exist. Lookup is case- // insensitive so that GTDB letter clades (e.g. Legionella_C) match files diff --git a/internal/cli/amr_cmd.go b/internal/cli/amr_cmd.go index aebdd3a..22c8cba 100644 --- a/internal/cli/amr_cmd.go +++ b/internal/cli/amr_cmd.go @@ -188,14 +188,6 @@ Run 'atb fetch' to download the data before querying.`, if hqOnly { fmt.Fprintf(os.Stderr, "Loading HQ sample set...\n") assemblyPath := filepath.Join(dir, "assembly.parquet") - lowerGenera := make(map[string]bool, len(genera)) - for _, g := range genera { - lowerGenera[strings.ToLower(g)] = true - } - lowerSpecies := make(map[string]bool, len(speciesList)) - for _, s := range speciesList { - lowerSpecies[strings.ToLower(s)] = true - } hqRows, hqErr := pq.ReadStreamFiltered[pq.AssemblyRow](assemblyPath, func(r pq.AssemblyRow) bool { if r.HQFilter != "PASS" { return false @@ -203,11 +195,11 @@ Run 'atb fetch' to download the data before querying.`, if speciesLike != "" && !match.Like(r.SylphSpecies, speciesLike) { return false } - if len(lowerSpecies) > 0 { - return lowerSpecies[strings.ToLower(r.SylphSpecies)] + if len(speciesList) > 0 { + return matchesAnySpecies(speciesList, r.SylphSpecies) } - if len(lowerGenera) > 0 { - return lowerGenera[strings.ToLower(pq.GenusFromSpecies(r.SylphSpecies))] + if len(genera) > 0 { + return matchesAnySpecies(genera, pq.GenusFromSpecies(r.SylphSpecies)) } return true }, 0) @@ -489,6 +481,18 @@ func intersectSampleSets(a, b map[string]struct{}) map[string]struct{} { return out } +// matchesAnySpecies reports whether the stored GTDB species or genus matches +// any of the user-supplied queries, treating GTDB alphabetic suffixes as +// equivalent (see match.SpeciesMatches). +func matchesAnySpecies(queries []string, stored string) bool { + for _, q := range queries { + if match.SpeciesMatches(q, stored) { + return true + } + } + return false +} + // amrColumns returns the fixed column order for AMR output. Headers match the // AMRFinderPlus v4.2.5 TSV verbatim so downstream tooling sees the same names // regardless of source. When withENA is true, country/collection_date/ diff --git a/internal/index/query.go b/internal/index/query.go index 5535cb8..706843f 100644 --- a/internal/index/query.go +++ b/internal/index/query.go @@ -11,6 +11,7 @@ import ( _ "modernc.org/sqlite" "github.com/allthebacteria/atb-cli/internal/match" + pq "github.com/allthebacteria/atb-cli/internal/parquet" ) // DB wraps a read-only SQLite connection to the index. @@ -160,22 +161,76 @@ func (d *DB) MLSTForSample(sampleAccession string) (map[string]string, error) { return result, err } +// speciesMatching returns the distinct stored sylph_species values for which +// keep reports true. It is used to resolve a species or genus filter to the +// concrete GTDB-suffixed names present in the index, so matching can strip +// GTDB suffixes in Go where SQLite has no regex support. +func (d *DB) speciesMatching(keep func(stored string) bool) ([]string, error) { + rows, err := d.db.Query("SELECT DISTINCT sylph_species FROM samples") + if err != nil { + return nil, fmt.Errorf("distinct species query: %w", err) + } + defer rows.Close() + + var names []string + for rows.Next() { + var s string + if err := rows.Scan(&s); err != nil { + return nil, fmt.Errorf("scanning species: %w", err) + } + if keep(s) { + names = append(names, s) + } + } + return names, rows.Err() +} + +// inClause builds a "column IN (?, ?, ...)" condition and its arguments for the +// given values. An empty list yields a condition that matches no rows, so a +// filter that resolves to nothing returns zero results rather than everything. +func inClause(column string, values []string) (string, []any) { + if len(values) == 0 { + return "1 = 0", nil + } + placeholders := make([]string, len(values)) + args := make([]any, len(values)) + for i, v := range values { + placeholders[i] = "?" + args[i] = v + } + return fmt.Sprintf("%s IN (%s)", column, strings.Join(placeholders, ", ")), args +} + // Query runs a filtered query returning result rows as map[string]string. func (d *DB) Query(params QueryParams) ([]map[string]string, error) { var conditions []string var args []any if params.Species != "" { - conditions = append(conditions, "lower(sylph_species) = lower(?)") - args = append(args, params.Species) + names, err := d.speciesMatching(func(stored string) bool { + return match.SpeciesMatches(params.Species, stored) + }) + if err != nil { + return nil, err + } + cond, inArgs := inClause("sylph_species", names) + conditions = append(conditions, cond) + args = append(args, inArgs...) } if params.SpeciesLike != "" { conditions = append(conditions, "lower(sylph_species) LIKE ? ESCAPE '\\'") args = append(args, match.ToSQLLike(params.SpeciesLike)) } if params.Genus != "" { - conditions = append(conditions, "lower(substr(sylph_species, 1, instr(sylph_species, ' ') - 1)) = lower(?)") - args = append(args, params.Genus) + names, err := d.speciesMatching(func(stored string) bool { + return match.SpeciesMatches(params.Genus, pq.GenusFromSpecies(stored)) + }) + if err != nil { + return nil, err + } + cond, inArgs := inClause("sylph_species", names) + conditions = append(conditions, cond) + args = append(args, inArgs...) } if params.HQOnly { conditions = append(conditions, "hq_filter = 'PASS'") diff --git a/internal/index/query_test.go b/internal/index/query_test.go index 9e9a663..990405c 100644 --- a/internal/index/query_test.go +++ b/internal/index/query_test.go @@ -5,8 +5,97 @@ import ( "path/filepath" "strings" "testing" + + parquetgo "github.com/parquet-go/parquet-go" + + pq "github.com/allthebacteria/atb-cli/internal/parquet" ) +// buildGTDBIndex builds an index from a minimal assembly.parquet whose species +// carry GTDB alphabetic suffixes, so tests can assert that an NCBI-style query +// still finds the suffixed rows. +func buildGTDBIndex(t *testing.T, species ...string) *DB { + t.Helper() + dir := t.TempDir() + + f, err := os.Create(filepath.Join(dir, "assembly.parquet")) + if err != nil { + t.Fatalf("create assembly.parquet: %v", err) + } + w := parquetgo.NewGenericWriter[pq.AssemblyRow](f) + for i, sp := range species { + row := pq.AssemblyRow{ + SampleAccession: pad(i), + SylphSpecies: sp, + HQFilter: "PASS", + AsmFastaOnOSF: 1, + Dataset: "test", + } + if _, err := w.Write([]pq.AssemblyRow{row}); err != nil { + t.Fatalf("write row: %v", err) + } + } + if err := w.Close(); err != nil { + t.Fatalf("close writer: %v", err) + } + if err := f.Close(); err != nil { + t.Fatalf("close file: %v", err) + } + + if err := Build(dir, func(string, ...any) {}); err != nil { + t.Fatalf("Build: %v", err) + } + db, err := Open(dir) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { db.Close() }) + return db +} + +func pad(i int) string { + s := "0000000" + string(rune('0'+i%10)) + return "SAMN" + s +} + +func TestQuerySpeciesGTDBSuffix(t *testing.T) { + db := buildGTDBIndex(t, + "Enterococcus_A faecium", + "Enterococcus_B faecium", + "Enterococcus_A faecalis", + "Escherichia coli", + ) + + rows, err := db.Query(QueryParams{Species: "Enterococcus faecium"}) + if err != nil { + t.Fatalf("Query: %v", err) + } + if len(rows) != 2 { + t.Fatalf("expected 2 rows for NCBI 'Enterococcus faecium', got %d", len(rows)) + } + for _, r := range rows { + if !strings.HasSuffix(r["sylph_species"], "faecium") { + t.Errorf("unexpected species %q", r["sylph_species"]) + } + } +} + +func TestQueryGenusGTDBSuffix(t *testing.T) { + db := buildGTDBIndex(t, + "Enterococcus_A faecium", + "Enterococcus_B faecium", + "Escherichia coli", + ) + + rows, err := db.Query(QueryParams{Genus: "Enterococcus"}) + if err != nil { + t.Fatalf("Query: %v", err) + } + if len(rows) != 2 { + t.Fatalf("expected 2 Enterococcus rows for NCBI genus, got %d", len(rows)) + } +} + // buildTestIndex creates a fresh index from test fixtures in a temp dir. func buildTestIndex(t *testing.T) *DB { t.Helper() diff --git a/internal/match/match.go b/internal/match/match.go index 4bcbbbb..5f2cde9 100644 --- a/internal/match/match.go +++ b/internal/match/match.go @@ -2,7 +2,41 @@ // filtering paths, so they return the same rows as the SQLite index paths. package match -import "strings" +import ( + "regexp" + "strings" +) + +// gtdbSuffix matches the GTDB alphabetic suffix appended to a taxon word, e.g. +// the "_A" in "Enterococcus_A" or "jejunii_A". GTDB splits NCBI genera and +// species and marks the pieces with an uppercase-letter suffix. +var gtdbSuffix = regexp.MustCompile(`_[A-Z]+$`) + +// CanonicalSpecies removes the GTDB alphabetic suffix from each whitespace word +// of a species name and collapses runs of whitespace to a single space, so a +// GTDB-suffixed name and the NCBI name a user types reduce to the same string. +// "Enterococcus_A faecium" and "Enterococcus faecium" both yield +// "Enterococcus faecium". +func CanonicalSpecies(species string) string { + fields := strings.Fields(species) + for i, word := range fields { + fields[i] = gtdbSuffix.ReplaceAllString(word, "") + } + return strings.Join(fields, " ") +} + +// SpeciesMatches reports whether a user-supplied species or genus query matches +// a stored GTDB name, case-insensitively. The GTDB suffix is stripped from the +// stored value only: an NCBI-style query ("Enterococcus faecium") matches every +// GTDB clade ("Enterococcus_A faecium", "Enterococcus_B faecium"), while a query +// that carries an explicit suffix ("Enterococcus_A faecium") still selects only +// that clade. +func SpeciesMatches(query, stored string) bool { + if strings.EqualFold(query, stored) { + return true + } + return strings.EqualFold(query, CanonicalSpecies(stored)) +} // Like reports whether value matches pattern. % matches any sequence of // characters, every other character including _ is literal, and matching is diff --git a/internal/match/match_test.go b/internal/match/match_test.go index d7e9c91..12bee89 100644 --- a/internal/match/match_test.go +++ b/internal/match/match_test.go @@ -66,3 +66,56 @@ func TestToSQLLike(t *testing.T) { }) } } + +func TestCanonicalSpecies(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"genus suffix", "Enterococcus_A faecium", "Enterococcus faecium"}, + {"epithet suffix", "Campylobacter jejunii_A", "Campylobacter jejunii"}, + {"suffix on both words", "Enterococcus_B faecium_C", "Enterococcus faecium"}, + {"no suffix unchanged", "Escherichia coli", "Escherichia coli"}, + {"single letter suffix", "Pseudomonas_E fluorescens", "Pseudomonas fluorescens"}, + {"multi letter suffix", "Genus_AB species", "Genus species"}, + {"empty", "", ""}, + {"lone genus with suffix", "Streptococcus_A", "Streptococcus"}, + {"lowercase suffix not stripped", "Genus_a species", "Genus_a species"}, + {"internal underscore not a suffix", "Foo_Abar species", "Foo_Abar species"}, + {"digit suffix not stripped", "Genus_1 species", "Genus_1 species"}, + {"extra whitespace collapsed", "Enterococcus_A faecium", "Enterococcus faecium"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := match.CanonicalSpecies(tt.in); got != tt.want { + t.Errorf("CanonicalSpecies(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestSpeciesMatches(t *testing.T) { + tests := []struct { + name string + query, stored string + want bool + }{ + {"ncbi query matches gtdb genus suffix", "Enterococcus faecium", "Enterococcus_A faecium", true}, + {"ncbi query matches gtdb epithet suffix", "Campylobacter jejunii", "Campylobacter jejunii_A", true}, + {"case insensitive", "enterococcus FAECIUM", "Enterococcus_A faecium", true}, + {"different species do not match", "Enterococcus faecium", "Enterococcus faecalis", false}, + {"unsuffixed genus query matches a clade", "Enterococcus", "Enterococcus_A", true}, + {"suffixed query matches its own clade exactly", "Enterococcus_A faecium", "Enterococcus_A faecium", true}, + {"suffixed query does not match another clade", "Enterococcus_A faecium", "Enterococcus_B faecium", false}, + {"suffixed genus query does not match another clade", "Campylobacter_D", "Campylobacter_E", false}, + {"empty both", "", "", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := match.SpeciesMatches(tt.query, tt.stored); got != tt.want { + t.Errorf("SpeciesMatches(%q, %q) = %v, want %v", tt.query, tt.stored, got, tt.want) + } + }) + } +} diff --git a/internal/query/executor.go b/internal/query/executor.go index c2e562b..122f6dc 100644 --- a/internal/query/executor.go +++ b/internal/query/executor.go @@ -47,7 +47,7 @@ func Execute(dataDir string, filters Filters, columns []string) ([]ResultRow, er if !filters.MatchesSpeciesLike(row.SylphSpecies) { return false } - if filters.Genus != "" && !strings.EqualFold(pq.GenusFromSpecies(row.SylphSpecies), filters.Genus) { + if !filters.MatchesGenus(row.SylphSpecies) { return false } if filters.Dataset != "" && !strings.EqualFold(row.Dataset, filters.Dataset) { diff --git a/internal/query/filter.go b/internal/query/filter.go index e06bd6b..10637d0 100644 --- a/internal/query/filter.go +++ b/internal/query/filter.go @@ -8,6 +8,7 @@ import ( "github.com/BurntSushi/toml" "github.com/allthebacteria/atb-cli/internal/match" + pq "github.com/allthebacteria/atb-cli/internal/parquet" ) // FilterFile is the top-level structure for a TOML filter file. @@ -82,13 +83,25 @@ func (f *Filters) NeedsSylph() bool { return false } -// MatchesSpecies performs a case-insensitive exact match against the Species filter. -// An empty filter matches everything. +// MatchesSpecies matches the Species filter against a species name, treating +// GTDB alphabetic suffixes as equivalent so an NCBI-style query +// ("Enterococcus faecium") matches the GTDB-split names stored in the data +// ("Enterococcus_A faecium"). An empty filter matches everything. func (f *Filters) MatchesSpecies(species string) bool { if f.Species == "" { return true } - return strings.EqualFold(f.Species, species) + return match.SpeciesMatches(f.Species, species) +} + +// MatchesGenus matches the Genus filter against the genus token of a species +// name, treating GTDB alphabetic suffixes as equivalent so "Enterococcus" +// matches "Enterococcus_A faecium". An empty filter matches everything. +func (f *Filters) MatchesGenus(species string) bool { + if f.Genus == "" { + return true + } + return match.SpeciesMatches(f.Genus, pq.GenusFromSpecies(species)) } // MatchesSpeciesLike performs a wildcard match using % against the SpeciesLike diff --git a/internal/query/filter_test.go b/internal/query/filter_test.go index 57a02ae..e788e94 100644 --- a/internal/query/filter_test.go +++ b/internal/query/filter_test.go @@ -200,6 +200,9 @@ func TestMatchesSpecies(t *testing.T) { {"exact match", "Salmonella enterica", "Salmonella enterica", true}, {"case insensitive match", "salmonella enterica", "Salmonella enterica", true}, {"no match", "E. coli", "Salmonella enterica", false}, + {"ncbi query matches gtdb genus suffix", "Enterococcus faecium", "Enterococcus_A faecium", true}, + {"ncbi query matches gtdb epithet suffix", "Campylobacter jejunii", "Campylobacter jejunii_A", true}, + {"different species still no match", "Enterococcus faecium", "Enterococcus_A faecalis", false}, } for _, tc := range cases { @@ -213,6 +216,31 @@ func TestMatchesSpecies(t *testing.T) { } } +func TestMatchesGenus(t *testing.T) { + cases := []struct { + name string + filter string + species string + want bool + }{ + {"empty filter matches all", "", "Salmonella enterica", true}, + {"genus match", "Salmonella", "Salmonella enterica", true}, + {"case insensitive", "salmonella", "Salmonella enterica", true}, + {"no match", "Escherichia", "Salmonella enterica", false}, + {"ncbi genus matches gtdb genus suffix", "Enterococcus", "Enterococcus_A faecium", true}, + {"different genus still no match", "Enterococcus", "Streptococcus_A pyogenes", false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := Filters{Genus: tc.filter} + if got := f.MatchesGenus(tc.species); got != tc.want { + t.Errorf("MatchesGenus(%q) with filter %q = %v, want %v", tc.species, tc.filter, got, tc.want) + } + }) + } +} + func TestMatchesSpeciesLike(t *testing.T) { cases := []struct { name string