MIKBMCMXOMPS2TVB2HLCQ5ORKFZJBHGNT267GHSKGB7J23EWGSKQC }}// writeFileAt is like writeFile but puts the file inside an existing dir// with a caller-provided basename (must end in .data).func writeFileAt(t *testing.T, dir, base string, segs ...*utils.Segment) string {t.Helper()path := filepath.Join(dir, base)df := &utils.DataFile{Meta: &utils.DataMeta{Operator: "ML", Reviewer: "David", Duration: 3600},Segments: segs,
func TestPropagateFolder_AggregatesAndSkipsMissing(t *testing.T) {dir := t.TempDir()// File A: both filters present, one clean propagation.aPath := writeFileAt(t, dir, "a.wav.data",seg(100, 125, lbl(fFrom, "Kiwi", "Male", 100)),seg(100, 125, lbl(fTo, "Kiwi", "Duet", 70)),)// File B: only target filter — missing source, must be skipped silently.bPath := writeFileAt(t, dir, "b.wav.data",seg(200, 225, lbl(fTo, "Kiwi", "Duet", 70)),)// File C: only source filter — missing target, must be skipped silently.writeFileAt(t, dir, "c.wav.data",seg(300, 325, lbl(fFrom, "Kiwi", "Male", 100)),)// File D: both filters, but no overlap → targets examined, none propagated.dPath := writeFileAt(t, dir, "d.wav.data",seg(400, 425, lbl(fFrom, "Kiwi", "Male", 100)),seg(500, 525, lbl(fTo, "Kiwi", "Duet", 70)),)out, err := CallsPropagateFolder(CallsPropagateFolderInput{Folder: dir, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",})if err != nil {t.Fatalf("unexpected error: %v", err)}if out.FilesTotal != 4 {t.Errorf("FilesTotal: got %d, want 4", out.FilesTotal)}if out.FilesWithBothFilters != 2 {t.Errorf("FilesWithBothFilters: got %d, want 2", out.FilesWithBothFilters)}if out.FilesSkippedNoFilter != 2 {t.Errorf("FilesSkippedNoFilter: got %d, want 2", out.FilesSkippedNoFilter)}if out.FilesChanged != 1 {t.Errorf("FilesChanged: got %d, want 1", out.FilesChanged)}if out.FilesErrored != 0 {t.Errorf("FilesErrored: got %d, want 0", out.FilesErrored)}if out.TargetsExamined != 2 {t.Errorf("TargetsExamined: got %d, want 2", out.TargetsExamined)}if out.Propagated != 1 {t.Errorf("Propagated: got %d, want 1", out.Propagated)}if out.SkippedNoOverlap != 1 {t.Errorf("SkippedNoOverlap: got %d, want 1", out.SkippedNoOverlap)}// File A was changed; check on-disk state.aDf := readFile(t, aPath)if aDf.Meta.Reviewer != "Skraak" {t.Errorf("a.wav.data reviewer: got %q, want Skraak", aDf.Meta.Reviewer)}if l := findLabel(aDf, fTo, 100, 125); l == nil || l.Certainty != 90 || l.CallType != "Male" {t.Errorf("a.wav.data target label: got %+v, want cert=90 calltype=Male", l)}// File B was skipped — reviewer untouched.bDf := readFile(t, bPath)if bDf.Meta.Reviewer != "David" {t.Errorf("b.wav.data reviewer should not be touched, got %q", bDf.Meta.Reviewer)}// File D had no overlap — reviewer untouched, target still cert=70.dDf := readFile(t, dPath)if dDf.Meta.Reviewer != "David" {t.Errorf("d.wav.data reviewer should not be touched, got %q", dDf.Meta.Reviewer)}if l := findLabel(dDf, fTo, 500, 525); l == nil || l.Certainty != 70 {t.Errorf("d.wav.data target label should be unchanged cert=70, got %+v", l)}}func TestPropagateFolder_EmptyFolder(t *testing.T) {dir := t.TempDir()out, err := CallsPropagateFolder(CallsPropagateFolderInput{Folder: dir, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",})if err != nil {t.Fatalf("unexpected error: %v", err)}if out.FilesTotal != 0 || out.Propagated != 0 {t.Errorf("expected empty result, got %+v", out)}}func TestPropagateFolder_MissingRequiredFlags(t *testing.T) {dir := t.TempDir()cases := []CallsPropagateFolderInput{{Folder: "", FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi"},{Folder: dir, FromFilter: "", ToFilter: fTo, Species: "Kiwi"},{Folder: dir, FromFilter: fFrom, ToFilter: "", Species: "Kiwi"},{Folder: dir, FromFilter: fFrom, ToFilter: fTo, Species: ""},{Folder: dir, FromFilter: fFrom, ToFilter: fFrom, Species: "Kiwi"},}for i, in := range cases {if _, err := CallsPropagateFolder(in); err == nil {t.Errorf("case %d: expected error for input %+v", i, in)}}}func TestPropagateFolder_NonexistentFolder(t *testing.T) {_, err := CallsPropagateFolder(CallsPropagateFolderInput{Folder: "/nonexistent/path/xyz", FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",})if err == nil {t.Fatal("expected error for nonexistent folder")}}func TestPropagateFolder_ConflictsTaggedWithFile(t *testing.T) {dir := t.TempDir()// Two sources with different calltypes both overlapping one target.writeFileAt(t, dir, "conflict.wav.data",seg(100, 125, lbl(fFrom, "Kiwi", "Male", 100)),seg(110, 130, lbl(fFrom, "Kiwi", "Female", 100)),seg(100, 130, lbl(fTo, "Kiwi", "", 70)),)out, err := CallsPropagateFolder(CallsPropagateFolderInput{Folder: dir, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",})if err != nil {t.Fatalf("unexpected error: %v", err)}if out.SkippedConflict != 1 || len(out.Conflicts) != 1 {t.Fatalf("expected one conflict, got %+v", out)}if out.Conflicts[0].File == "" {t.Errorf("conflict should be tagged with file path, got %+v", out.Conflicts[0])}}
File string `json:"file"`FromFilter string `json:"from_filter"`ToFilter string `json:"to_filter"`Species string `json:"species"`TargetsExamined int `json:"targets_examined"`Propagated int `json:"propagated"`SkippedNoOverlap int `json:"skipped_no_overlap"`SkippedConflict int `json:"skipped_conflict"`Conflicts []PropagateConflict `json:"conflicts,omitempty"`Changes []PropagateChange `json:"changes,omitempty"`Error string `json:"error,omitempty"`
File string `json:"file"`FromFilter string `json:"from_filter"`ToFilter string `json:"to_filter"`Species string `json:"species"`FiltersMissing bool `json:"filters_missing,omitempty"`TargetsExamined int `json:"targets_examined"`Propagated int `json:"propagated"`SkippedNoOverlap int `json:"skipped_no_overlap"`SkippedConflict int `json:"skipped_conflict"`Conflicts []PropagateConflict `json:"conflicts,omitempty"`Changes []PropagateChange `json:"changes,omitempty"`Error string `json:"error,omitempty"`}type CallsPropagateFolderInput struct {Folder string `json:"folder"`FromFilter string `json:"from_filter"`ToFilter string `json:"to_filter"`Species string `json:"species"`}type CallsPropagateFolderOutput struct {Folder string `json:"folder"`FromFilter string `json:"from_filter"`ToFilter string `json:"to_filter"`Species string `json:"species"`FilesTotal int `json:"files_total"`FilesWithBothFilters int `json:"files_with_both_filters"`FilesSkippedNoFilter int `json:"files_skipped_no_filter"`FilesChanged int `json:"files_changed"`FilesErrored int `json:"files_errored"`TargetsExamined int `json:"targets_examined"`Propagated int `json:"propagated"`SkippedNoOverlap int `json:"skipped_no_overlap"`SkippedConflict int `json:"skipped_conflict"`Conflicts []PropagateConflict `json:"conflicts,omitempty"`Errors []CallsPropagateOutput `json:"errors,omitempty"`Error string `json:"error,omitempty"`
TargetStart float64 `json:"target_start"`TargetEnd float64 `json:"target_end"`TargetCallType string `json:"target_calltype,omitempty"`
File string `json:"file,omitempty"`TargetStart float64 `json:"target_start"`TargetEnd float64 `json:"target_end"`TargetCallType string `json:"target_calltype,omitempty"`
}// Fast path: skip files that don't contain both filters at all.hasFrom, hasTo := false, falsefor _, seg := range df.Segments {for _, lbl := range seg.Labels {if lbl.Filter == input.FromFilter {hasFrom = true}if lbl.Filter == input.ToFilter {hasTo = true}if hasFrom && hasTo {break}}if hasFrom && hasTo {break}}if !hasFrom || !hasTo {output.FiltersMissing = truereturn output, nil
}}return output, nil}// CallsPropagateFolder runs CallsPropagate against every .data file in a folder,// aggregating counts. Files that do not contain both --from and --to filters are// skipped silently (counted as files_skipped_no_filter). Parse/write errors on// individual files are collected in Errors; they don't abort the run.func CallsPropagateFolder(input CallsPropagateFolderInput) (CallsPropagateFolderOutput, error) {output := CallsPropagateFolderOutput{Folder: input.Folder,FromFilter: input.FromFilter,ToFilter: input.ToFilter,Species: input.Species,}if input.Folder == "" {output.Error = "--folder is required"return output, fmt.Errorf("%s", output.Error)}if input.FromFilter == "" {output.Error = "--from is required"return output, fmt.Errorf("%s", output.Error)}if input.ToFilter == "" {output.Error = "--to is required"return output, fmt.Errorf("%s", output.Error)}if input.Species == "" {output.Error = "--species is required"return output, fmt.Errorf("%s", output.Error)}if input.FromFilter == input.ToFilter {output.Error = "--from and --to must differ"return output, fmt.Errorf("%s", output.Error)}info, err := os.Stat(input.Folder)if err != nil {output.Error = fmt.Sprintf("folder not found: %s", input.Folder)return output, fmt.Errorf("%s", output.Error)}if !info.IsDir() {output.Error = fmt.Sprintf("not a directory: %s", input.Folder)return output, fmt.Errorf("%s", output.Error)}files, err := utils.FindDataFiles(input.Folder)if err != nil {output.Error = fmt.Sprintf("list .data files: %v", err)return output, fmt.Errorf("%s", output.Error)}output.FilesTotal = len(files)for _, f := range files {fileOut, err := CallsPropagate(CallsPropagateInput{File: f,FromFilter: input.FromFilter,ToFilter: input.ToFilter,Species: input.Species,})if err != nil {output.FilesErrored++output.Errors = append(output.Errors, fileOut)continue}if fileOut.FiltersMissing {output.FilesSkippedNoFilter++continue}output.FilesWithBothFilters++output.TargetsExamined += fileOut.TargetsExaminedoutput.Propagated += fileOut.Propagatedoutput.SkippedNoOverlap += fileOut.SkippedNoOverlapoutput.SkippedConflict += fileOut.SkippedConflictif fileOut.Propagated > 0 {output.FilesChanged++
file := fs.String("file", "", "Path to .data file (required)")
file := fs.String("file", "", "Path to a single .data file (mutually exclusive with --folder)")folder := fs.String("folder", "", "Path to folder containing .data files (mutually exclusive with --file)")
fmt.Fprintf(os.Stderr, "Propagate verified classifications from one filter to another within a single .data file.\n")
fmt.Fprintf(os.Stderr, "Propagate verified classifications from one filter to another within a .data file\n")fmt.Fprintf(os.Stderr, "or across every .data file in a folder.\n\n")
fmt.Fprintf(os.Stderr, "Targets already at certainty=100 or 90 are left alone.\n\n")
fmt.Fprintf(os.Stderr, "Targets already at certainty=100 or 90 are left alone.\n")fmt.Fprintf(os.Stderr, "Files that do not contain both --from and --to filter labels are skipped.\n\n")fmt.Fprintf(os.Stderr, "Exactly one of --file or --folder is required.\n\n")
input := tools.CallsPropagateInput{File: *file,
if *file != "" {result, err := tools.CallsPropagate(tools.CallsPropagateInput{File: *file,FromFilter: *from,ToFilter: *to,Species: *species,})if err != nil {fmt.Fprintf(os.Stderr, "Error: %s\n", result.Error)os.Exit(1)}enc.Encode(result)return}result, err := tools.CallsPropagateFolder(tools.CallsPropagateFolderInput{Folder: *folder,
enc := json.NewEncoder(os.Stdout)enc.SetIndent("", " ")
fmt.Fprintf(os.Stderr,"Files: %d total, %d with both filters, %d skipped (missing filter), %d changed, %d errored\n",result.FilesTotal, result.FilesWithBothFilters, result.FilesSkippedNoFilter,result.FilesChanged, result.FilesErrored)fmt.Fprintf(os.Stderr,"Targets: %d examined, %d propagated, %d no-overlap, %d conflicts\n",result.TargetsExamined, result.Propagated, result.SkippedNoOverlap, result.SkippedConflict)