DHIPFBFPF4F7SLMMBVHO4TUFPLFYWY4KGOYZEHOQGUQNZELRSW3AC // Validate required string fields are non-emptylocationName := strings.TrimSpace(record[0])if locationName == "" {return nil, fmt.Errorf("empty location_name in row %d", i+1)}directoryPath := strings.TrimSpace(record[2])if directoryPath == "" {return nil, fmt.Errorf("empty directory_path in row %d", i+1)}dateRange := strings.TrimSpace(record[3])if dateRange == "" {return nil, fmt.Errorf("empty date_range in row %d", i+1)}
return locations, nil}
// Validate location_id formatlocationID := record[1]if err := utils.ValidateShortID(locationID, "location_id"); err != nil {return nil, fmt.Errorf("invalid location_id in row %d: %v", i+1, err)}
// parseBulkCSVRow parses a single CSV record into a bulkLocationData.// Columns: location_name, location_id, directory_path, date_range, sample_rate, file_count.// Returns an error if columns are missing, fields are empty, or numeric fields are invalid.func parseBulkCSVRow(record []string) (bulkLocationData, error) {if len(record) < 6 {return bulkLocationData{}, fmt.Errorf("insufficient columns (expected 6, got %d)", len(record))}
sampleRate, err := strconv.Atoi(record[4])if err != nil {return nil, fmt.Errorf("invalid sample_rate in row %d: %v", i+1, err)}
locationName := strings.TrimSpace(record[0])if locationName == "" {return bulkLocationData{}, fmt.Errorf("empty location_name")}directoryPath := strings.TrimSpace(record[2])if directoryPath == "" {return bulkLocationData{}, fmt.Errorf("empty directory_path")}dateRange := strings.TrimSpace(record[3])if dateRange == "" {return bulkLocationData{}, fmt.Errorf("empty date_range")}
// Validate sample rate is in reasonable rangeif err := utils.ValidateSampleRate(sampleRate); err != nil {return nil, fmt.Errorf("invalid sample_rate in row %d: %v", i+1, err)}
locationID := record[1]if err := utils.ValidateShortID(locationID, "location_id"); err != nil {return bulkLocationData{}, fmt.Errorf("invalid location_id: %v", err)}
fileCount, err := strconv.Atoi(record[5])if err != nil {return nil, fmt.Errorf("invalid file_count in row %d: %v", i+1, err)}
sampleRate, err := strconv.Atoi(record[4])if err != nil {return bulkLocationData{}, fmt.Errorf("invalid sample_rate: %v", err)}if err := utils.ValidateSampleRate(sampleRate); err != nil {return bulkLocationData{}, fmt.Errorf("invalid sample_rate: %v", err)}
locations = append(locations, bulkLocationData{LocationName: locationName,LocationID: locationID,DirectoryPath: directoryPath,DateRange: dateRange,SampleRate: sampleRate,FileCount: fileCount,})
fileCount, err := strconv.Atoi(record[5])if err != nil {return bulkLocationData{}, fmt.Errorf("invalid file_count: %v", err)
package impimport ("strings""testing")const validLocID = "IBv_KxDGsNQs"func validRow() []string {return []string{"Site A", validLocID, "/data/site_a", "2024-01-01_2024-01-31", "48000", "1200"}}func TestParseBulkCSVRow_Valid(t *testing.T) {got, err := parseBulkCSVRow(validRow())if err != nil {t.Fatalf("unexpected error: %v", err)}want := bulkLocationData{LocationName: "Site A",LocationID: validLocID,DirectoryPath: "/data/site_a",DateRange: "2024-01-01_2024-01-31",SampleRate: 48000,FileCount: 1200,}if got != want {t.Errorf("got %+v want %+v", got, want)}}func TestParseBulkCSVRow_TrimsWhitespace(t *testing.T) {row := []string{" Site A ", validLocID, " /data/site_a ", " 2024-01-01_2024-01-31 ", "48000", "1200"}got, err := parseBulkCSVRow(row)if err != nil {t.Fatalf("unexpected error: %v", err)}if got.LocationName != "Site A" || got.DirectoryPath != "/data/site_a" || got.DateRange != "2024-01-01_2024-01-31" {t.Errorf("whitespace not trimmed: %+v", got)}}func TestParseBulkCSVRow_Errors(t *testing.T) {mutate := func(idx int, val string) []string {r := validRow()r[idx] = valreturn r}tests := []struct {name stringrow []stringerrSubstr string}{{"too few columns", []string{"a", "b", "c"}, "insufficient columns"},{"empty location_name", mutate(0, ""), "empty location_name"},{"empty location_name whitespace", mutate(0, " "), "empty location_name"},{"empty directory_path", mutate(2, ""), "empty directory_path"},{"empty date_range", mutate(3, ""), "empty date_range"},{"bad location_id (short)", mutate(1, "abc"), "invalid location_id"},{"bad location_id (bad chars)", mutate(1, "IBv KxDGsNQs"), "invalid location_id"},{"non-numeric sample_rate", mutate(4, "fast"), "invalid sample_rate"},{"out-of-range sample_rate", mutate(4, "10"), "invalid sample_rate"},{"non-numeric file_count", mutate(5, "many"), "invalid file_count"},}for _, tt := range tests {t.Run(tt.name, func(t *testing.T) {_, err := parseBulkCSVRow(tt.row)if err == nil {t.Fatalf("expected error containing %q, got nil", tt.errSubstr)}if !strings.Contains(err.Error(), tt.errSubstr) {t.Errorf("error %q does not contain %q", err.Error(), tt.errSubstr)}})}}
package callsimport ("errors""sync/atomic""testing")type fakeResult struct {path stringcalls []ClusteredCallwritten boolskipped boolerr error}func (r fakeResult) filePath() string { return r.path }func (r fakeResult) getCalls() []ClusteredCall { return r.calls }func (r fakeResult) wasWritten() bool { return r.written }func (r fakeResult) wasSkipped() bool { return r.skipped }func (r fakeResult) getError() error { return r.err }func sendAll(results []parallelResult) <-chan parallelResult {ch := make(chan parallelResult, len(results))for _, r := range results {ch <- r}close(ch)return ch}func TestAggregateResults_CountsAndSpecies(t *testing.T) {results := []parallelResult{fakeResult{path: "a.wav", written: true, calls: []ClusteredCall{{File: "a.wav", EbirdCode: "tomtit1", StartTime: 1},{File: "a.wav", EbirdCode: "tomtit1", StartTime: 2},}},fakeResult{path: "b.wav", skipped: true},fakeResult{path: "c.wav", written: true, calls: []ClusteredCall{{File: "c.wav", EbirdCode: "bellbird1", StartTime: 0.5},}},}var processed atomic.Int32var progressCalls intstats := aggregateResults(sendAll(results), len(results), &processed, false,func(cur, total int, msg string) { progressCalls++ })if stats.filesProcessed != 3 {t.Errorf("filesProcessed: got %d want 3", stats.filesProcessed)}if stats.dataFilesWritten != 2 {t.Errorf("written: got %d want 2", stats.dataFilesWritten)}if stats.dataFilesSkipped != 1 {t.Errorf("skipped: got %d want 1", stats.dataFilesSkipped)}if len(stats.calls) != 3 {t.Errorf("calls: got %d want 3", len(stats.calls))}if stats.speciesCount["tomtit1"] != 2 || stats.speciesCount["bellbird1"] != 1 {t.Errorf("speciesCount: got %v", stats.speciesCount)}if stats.firstErr != nil {t.Errorf("firstErr: got %v want nil", stats.firstErr)}if progressCalls != 3 {t.Errorf("progressCalls: got %d want 3", progressCalls)}if stats.filesDeleted != 0 {t.Errorf("filesDeleted: got %d want 0 (deleteFiles=false)", stats.filesDeleted)}}func TestAggregateResults_KeepsFirstError(t *testing.T) {err1 := errors.New("first")err2 := errors.New("second")results := []parallelResult{fakeResult{path: "a", err: err1},fakeResult{path: "b", err: err2},fakeResult{path: "c"},}var processed atomic.Int32stats := aggregateResults(sendAll(results), 3, &processed, false, nil)if stats.firstErr != err1 {t.Errorf("firstErr: got %v want %v", stats.firstErr, err1)}}func TestAggregateResults_NilProgressHandler(t *testing.T) {var processed atomic.Int32stats := aggregateResults(sendAll([]parallelResult{fakeResult{path: "a"}}), 1, &processed, false, nil)if stats.filesProcessed != 1 {t.Errorf("filesProcessed: got %d want 1", stats.filesProcessed)}}func TestSortCallsByFileAndTime(t *testing.T) {calls := []ClusteredCall{{File: "b.wav", StartTime: 1},{File: "a.wav", StartTime: 2},{File: "a.wav", StartTime: 1},{File: "b.wav", StartTime: 0.5},}sortCallsByFileAndTime(calls)want := []struct {file stringstart float64}{{"a.wav", 1},{"a.wav", 2},{"b.wav", 0.5},{"b.wav", 1},}for i, w := range want {if calls[i].File != w.file || calls[i].StartTime != w.start {t.Errorf("calls[%d]: got %+v want %+v", i, calls[i], w)}}}
package callsimport ("reflect""testing""skraak/utils")func TestFilterLabels(t *testing.T) {a := &utils.Label{Filter: "kiwi.txt", Species: "Kiwi"}b := &utils.Label{Filter: "tomtit.txt", Species: "Tomtit"}c := &utils.Label{Filter: "kiwi.txt", Species: "Kiwi2"}labels := []*utils.Label{a, b, c}tests := []struct {name stringfilter stringwant []*utils.Label}{{"no filter returns all", "", labels},{"matching filter returns subset", "kiwi.txt", []*utils.Label{a, c}},{"non-matching filter returns nil", "missing.txt", nil},}for _, tt := range tests {t.Run(tt.name, func(t *testing.T) {got := filterLabels(labels, tt.filter)if !reflect.DeepEqual(got, tt.want) {t.Errorf("got %v want %v", got, tt.want)}})}}func TestBuildLabelSummaries_OmitsEmptyFields(t *testing.T) {labels := []*utils.Label{{Filter: "f", Certainty: 80, Species: "Sp"},{Filter: "f", Certainty: 100, Species: "Sp", CallType: "song", Comment: "hi", Bookmark: true},}got := buildLabelSummaries(labels)if len(got) != 2 {t.Fatalf("len=%d want 2", len(got))}if got[0].CallType != "" || got[0].Comment != "" || got[0].Bookmark {t.Errorf("empty fields leaked: %+v", got[0])}if got[1].CallType != "song" || got[1].Comment != "hi" || !got[1].Bookmark {t.Errorf("populated fields wrong: %+v", got[1])}}func TestUpdateReviewStatus(t *testing.T) {out := &CallsSummariseOutput{}labels := []*utils.Label{{Certainty: 100},{Certainty: 100, CallType: "song"},{Certainty: 0},{Certainty: 50, Comment: "hmm", Bookmark: true},}for _, l := range labels {updateReviewStatus(l, out)}want := ReviewStatus{Confirmed: 2,DontKnow: 1,Unreviewed: 1,WithCallType: 1,WithComments: 1,Bookmarked: 1,}if out.ReviewStatus != want {t.Errorf("got %+v want %+v", out.ReviewStatus, want)}}func TestUpdateFilterStats_AccumulatesAcrossLabels(t *testing.T) {out := &CallsSummariseOutput{Filters: map[string]FilterStats{}}labels := []*utils.Label{{Filter: "kiwi.txt", Species: "Kiwi"},{Filter: "kiwi.txt", Species: "Kiwi", CallType: "brrr"},{Filter: "kiwi.txt", Species: "Kiwi", CallType: "brrr"},{Filter: "kiwi.txt", Species: "Kiwi2"},{Filter: "tomtit.txt", Species: "Tomtit", CallType: "song"},}for _, l := range labels {updateFilterStats(l, out)}kiwi := out.Filters["kiwi.txt"]if kiwi.Segments != 4 {t.Errorf("kiwi.Segments=%d want 4", kiwi.Segments)}if kiwi.Species["Kiwi"] != 3 || kiwi.Species["Kiwi2"] != 1 {t.Errorf("kiwi.Species=%v", kiwi.Species)}if kiwi.Calltypes["Kiwi"]["brrr"] != 2 {t.Errorf("kiwi calltype brrr=%d want 2", kiwi.Calltypes["Kiwi"]["brrr"])}tomtit := out.Filters["tomtit.txt"]if tomtit.Segments != 1 || tomtit.Calltypes["Tomtit"]["song"] != 1 {t.Errorf("tomtit=%+v", tomtit)}}func TestTrackMeta(t *testing.T) {ops, revs := map[string]bool{}, map[string]bool{}trackMeta(nil, ops, revs)if len(ops) != 0 || len(revs) != 0 {t.Errorf("nil meta should be no-op")}trackMeta(&utils.DataMeta{Operator: "alice", Reviewer: ""}, ops, revs)trackMeta(&utils.DataMeta{Operator: "", Reviewer: "bob"}, ops, revs)trackMeta(&utils.DataMeta{Operator: "alice", Reviewer: "bob"}, ops, revs)if !ops["alice"] || len(ops) != 1 {t.Errorf("operators=%v want only alice", ops)}if !revs["bob"] || len(revs) != 1 {t.Errorf("reviewers=%v want only bob", revs)}}func TestExtractRelativePath(t *testing.T) {tests := []struct {dataPath stringwant string}{{"/folder/tx51_LISTENING_20260221_203004.WAV.data", "tx51_LISTENING_20260221_203004.WAV"},{"foo.wav.data", "foo.wav"},{"/a/b/c/file.WAV.data", "file.WAV"},{"noslash.data", "noslash"},}for _, tt := range tests {t.Run(tt.dataPath, func(t *testing.T) {got := extractRelativePath("", tt.dataPath)if got != tt.want {t.Errorf("got %q want %q", got, tt.want)}})}}func TestFinaliseSummary_SortsAndClearsEmptyCalltypes(t *testing.T) {out := &CallsSummariseOutput{Filters: map[string]FilterStats{"f1": {Segments: 1, Species: map[string]int{"S": 1}, Calltypes: map[string]map[string]int{}},"f2": {Segments: 1, Species: map[string]int{"S": 1}, Calltypes: map[string]map[string]int{"S": {"c": 1}}},},Segments: []SegmentSummary{{File: "b.wav", StartTime: 1},{File: "a.wav", StartTime: 5},{File: "a.wav", StartTime: 2},},}finaliseSummary(out, map[string]bool{"zoe": true, "alice": true}, map[string]bool{"bob": true}, false)if out.Filters["f1"].Calltypes != nil {t.Errorf("empty calltypes not cleared: %v", out.Filters["f1"].Calltypes)}if out.Filters["f2"].Calltypes == nil {t.Errorf("populated calltypes cleared in error")}wantOps := []string{"alice", "zoe"}if !reflect.DeepEqual(out.Operators, wantOps) {t.Errorf("operators=%v want %v", out.Operators, wantOps)}wantOrder := []struct {file stringstart float64}{{"a.wav", 2}, {"a.wav", 5}, {"b.wav", 1}}for i, w := range wantOrder {if out.Segments[i].File != w.file || out.Segments[i].StartTime != w.start {t.Errorf("segment[%d]=%+v want file=%s start=%v", i, out.Segments[i], w.file, w.start)}}}
package callsimport ("encoding/json""testing")func TestAviaNZMetaJSONRoundTrip(t *testing.T) {reviewer := "alice"tests := []struct {name stringin AviaNZMeta}{{"no reviewer", AviaNZMeta{Operator: "bob", Duration: 60.5}},{"with reviewer", AviaNZMeta{Operator: "bob", Reviewer: &reviewer, Duration: 0}},}for _, tt := range tests {t.Run(tt.name, func(t *testing.T) {b, err := json.Marshal(tt.in)if err != nil {t.Fatalf("marshal: %v", err)}var out AviaNZMetaif err := json.Unmarshal(b, &out); err != nil {t.Fatalf("unmarshal: %v", err)}if out.Operator != tt.in.Operator || out.Duration != tt.in.Duration {t.Errorf("operator/duration mismatch: got %+v want %+v", out, tt.in)}if (out.Reviewer == nil) != (tt.in.Reviewer == nil) {t.Errorf("reviewer nil mismatch: got %v want %v", out.Reviewer, tt.in.Reviewer)}if out.Reviewer != nil && tt.in.Reviewer != nil && *out.Reviewer != *tt.in.Reviewer {t.Errorf("reviewer mismatch: got %q want %q", *out.Reviewer, *tt.in.Reviewer)}})}}func TestAviaNZMetaOmitsNilReviewer(t *testing.T) {b, err := json.Marshal(AviaNZMeta{Operator: "bob", Duration: 1})if err != nil {t.Fatalf("marshal: %v", err)}if got := string(b); got != `{"Operator":"bob","Duration":1}` {t.Errorf("unexpected JSON: %s", got)}}func TestAviaNZLabelJSONRoundTrip(t *testing.T) {in := AviaNZLabel{Species: "Tomtit", Certainty: 100, Filter: "kiwi.txt"}b, err := json.Marshal(in)if err != nil {t.Fatalf("marshal: %v", err)}var out AviaNZLabelif err := json.Unmarshal(b, &out); err != nil {t.Fatalf("unmarshal: %v", err)}if out != in {t.Errorf("got %+v want %+v", out, in)}}func TestAviaNZSegmentJSONRoundTrip(t *testing.T) {raw := `[1.5,2.5,500,8000,[{"species":"Bellbird","certainty":80,"filter":"f.txt"}]]`var seg AviaNZSegmentif err := json.Unmarshal([]byte(raw), &seg); err != nil {t.Fatalf("unmarshal: %v", err)}start, ok := seg[0].(float64)if !ok || start != 1.5 {t.Errorf("start: got %v (%T), want 1.5", seg[0], seg[0])}end, ok := seg[1].(float64)if !ok || end != 2.5 {t.Errorf("end: got %v (%T), want 2.5", seg[1], seg[1])}labels, ok := seg[4].([]any)if !ok || len(labels) != 1 {t.Fatalf("labels: got %v (%T)", seg[4], seg[4])}b, err := json.Marshal(seg)if err != nil {t.Fatalf("marshal: %v", err)}var seg2 AviaNZSegmentif err := json.Unmarshal(b, &seg2); err != nil {t.Fatalf("re-unmarshal: %v", err)}}
package cmdimport ("flag""io""strings""testing")func silentFlagSet() *flag.FlagSet {fs := flag.NewFlagSet("test", flag.ContinueOnError)fs.SetOutput(io.Discard)fs.Usage = func() {}return fs}func TestCheckFlags(t *testing.T) {tests := []struct {name stringpairs []stringwantErr boolwantMissing []string}{{"all present", []string{"--db", "x.duckdb", "--id", "abc"}, false, nil},{"empty value", []string{"--db", "", "--id", "abc"}, true, []string{"--db"}},{"multiple missing", []string{"--db", "", "--id", ""}, true, []string{"--db", "--id"}},{"no pairs", []string{}, false, nil},{"single missing", []string{"--name", ""}, true, []string{"--name"}},}for _, tt := range tests {t.Run(tt.name, func(t *testing.T) {err := checkFlags(silentFlagSet(), tt.pairs...)if (err != nil) != tt.wantErr {t.Fatalf("err=%v, wantErr=%v", err, tt.wantErr)}if tt.wantErr {for _, name := range tt.wantMissing {if !strings.Contains(err.Error(), name) {t.Errorf("err %q missing flag name %q", err.Error(), name)}}}})}}func TestCheckNonZeroFlags(t *testing.T) {type pair = struct {Name stringValue int}tests := []struct {name stringpairs []pairwantErr boolwantMissing []string}{{"all non-zero", []pair{{"--n", 5}, {"--m", 1}}, false, nil},{"zero value", []pair{{"--n", 0}, {"--m", 1}}, true, []string{"--n"}},{"multiple zero", []pair{{"--n", 0}, {"--m", 0}}, true, []string{"--n", "--m"}},{"empty pairs", nil, false, nil},{"negative is allowed", []pair{{"--n", -1}}, false, nil},}for _, tt := range tests {t.Run(tt.name, func(t *testing.T) {err := checkNonZeroFlags(silentFlagSet(), tt.pairs...)if (err != nil) != tt.wantErr {t.Fatalf("err=%v, wantErr=%v", err, tt.wantErr)}if tt.wantErr {for _, name := range tt.wantMissing {if !strings.Contains(err.Error(), name) {t.Errorf("err %q missing flag name %q", err.Error(), name)}}}})}}
## [2026-05-13] Targeted unit tests on pure-logic seamsFilled zero/low-coverage gaps without duplicating what `shell_scripts/` already integration-tests. Philosophy: extract pure logic, table-test it, leave DB/filesystem paths to shell scripts.- `cmd/common_test.go`: `checkFlags` and `checkNonZeroFlags` (cmd 0% → 0.9%).- `tools/calls/avianz_types_test.go`: JSON round-trip for `AviaNZMeta`/`AviaNZLabel`/`AviaNZSegment` to lock the wire format.- `tools/calls/parallel_aggregate_test.go`: fan-in aggregator tested with a `parallelResult` fake; covers species counting, written/skipped counting, first-error retention, nil progress handler, sort.- `tools/calls/calls_summarise_test.go`: pure helpers (`filterLabels`, `buildLabelSummaries`, `updateFilterStats`, `updateReviewStatus`, `trackMeta`, `extractRelativePath`, `finaliseSummary`). tools/calls 54% → 58%.- `tools/import/bulk_file_import.go`: extracted `parseBulkCSVRow` from the inline `bulkReadCSV` loop. `tools/import/bulk_csv_test.go` table-tests valid rows, whitespace handling, and every error branch. tools/import 0.8% → 3.7%.
Intentionally not touched: `db/validation.go` (DB-bound — covered by `test_db_state.sh` / `test_write_tools.sh`), most `cmd/*.go` (CLI dispatchers — covered by shell scripts), `tui/classify.go` (Bubble Tea event loop), `tools/import/import_segments.go` DB paths (covered by `test_import.sh`).