calls_propagate_test.go
package calls
import (
"path/filepath"
"testing"
"skraak/datafile"
)
// helpers
func seg(start, end float64, labels ...*datafile.Label) *datafile.Segment {
return &datafile.Segment{
StartTime: start,
EndTime: end,
FreqLow: 100,
FreqHigh: 8000,
Labels: labels,
}
}
func lbl(filter, species, calltype string, certainty int) *datafile.Label {
return &datafile.Label{
Filter: filter,
Species: species,
CallType: calltype,
Certainty: certainty,
}
}
func writeFile(t *testing.T, segs ...*datafile.Segment) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "test.data")
df := &datafile.DataFile{
Meta: &datafile.DataMeta{Operator: "ML", Reviewer: "David", Duration: 3600},
Segments: segs,
}
if err := df.Write(path); err != nil {
t.Fatalf("write fixture: %v", err)
}
return path
}
func readFile(t *testing.T, path string) *datafile.DataFile {
t.Helper()
df, err := datafile.ParseDataFile(path)
if err != nil {
t.Fatalf("parse %s: %v", path, err)
}
return df
}
// findLabel returns the label with matching filter and time on the parsed file, or nil.
func findLabel(df *datafile.DataFile, filter string, start, end float64) *datafile.Label {
for _, s := range df.Segments {
if s.StartTime != start || s.EndTime != end {
continue
}
for _, l := range s.Labels {
if l.Filter == filter {
return l
}
}
}
return nil
}
const (
fFrom = "opensoundscape-kiwi-1.2"
fTo = "opensoundscape-kiwi-1.5"
)
func TestPropagate_HappyPathSingle(t *testing.T) {
path := writeFile(t,
seg(100, 125, lbl(fFrom, "Kiwi", "Male", 100)),
seg(100, 125, lbl(fTo, "Kiwi", "Duet", 70)),
)
out, err := CallsPropagate(CallsPropagateInput{
File: path, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",
})
if err != nil {
t.Fatalf("unexpected error: %v (%s)", err, out.Error)
}
if out.Propagated != 1 || out.TargetsExamined != 1 || out.SkippedConflict != 0 || out.SkippedNoOverlap != 0 {
t.Fatalf("counts wrong: %+v", out)
}
df := readFile(t, path)
target := findLabel(df, fTo, 100, 125)
if target == nil {
t.Fatal("target label missing")
}
if target.Species != "Kiwi" || target.CallType != "Male" || target.Certainty != 90 {
t.Errorf("target not updated correctly: species=%q calltype=%q cert=%d", target.Species, target.CallType, target.Certainty)
}
if df.Meta.Reviewer != "Skraak" {
t.Errorf("reviewer = %q, want Skraak", df.Meta.Reviewer)
}
}
func TestPropagate_NoOverlap(t *testing.T) {
path := writeFile(t,
seg(100, 125, lbl(fFrom, "Kiwi", "Male", 100)),
seg(500, 525, lbl(fTo, "Kiwi", "Duet", 70)),
)
out, err := CallsPropagate(CallsPropagateInput{
File: path, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out.Propagated != 0 || out.TargetsExamined != 1 || out.SkippedNoOverlap != 1 {
t.Fatalf("counts wrong: %+v", out)
}
df := readFile(t, path)
target := findLabel(df, fTo, 500, 525)
if target.Certainty != 70 {
t.Errorf("target should not be modified, cert=%d", target.Certainty)
}
if df.Meta.Reviewer != "David" {
t.Errorf("reviewer should stay David (no write), got %q", df.Meta.Reviewer)
}
}
func TestPropagate_SourceWrongSpecies_Ignored(t *testing.T) {
path := writeFile(t,
seg(100, 125, lbl(fFrom, "Weka", "", 100)),
seg(100, 125, lbl(fTo, "Kiwi", "Duet", 70)),
)
out, err := CallsPropagate(CallsPropagateInput{
File: path, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out.Propagated != 0 || out.SkippedNoOverlap != 1 {
t.Fatalf("counts wrong: %+v", out)
}
}
func TestPropagate_SourceWrongCertainty_Ignored(t *testing.T) {
// cert=70 and cert=0 source labels must NOT count as sources.
path := writeFile(t,
seg(100, 125, lbl(fFrom, "Kiwi", "Male", 70)),
seg(200, 225, lbl(fFrom, "Don't Know", "", 0)),
seg(100, 125, lbl(fTo, "Kiwi", "Duet", 70)),
seg(200, 225, lbl(fTo, "Kiwi", "Male", 70)),
)
out, err := CallsPropagate(CallsPropagateInput{
File: path, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out.Propagated != 0 || out.SkippedNoOverlap != 2 {
t.Fatalf("counts wrong: %+v", out)
}
}
func TestPropagate_SourceWrongFilter_Ignored(t *testing.T) {
path := writeFile(t,
seg(100, 125, lbl("some-other-filter", "Kiwi", "Male", 100)),
seg(100, 125, lbl(fTo, "Kiwi", "Duet", 70)),
)
out, err := CallsPropagate(CallsPropagateInput{
File: path, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !out.FiltersMissing || out.Propagated != 0 || out.TargetsExamined != 0 {
t.Fatalf("expected FiltersMissing=true with zero counts, got: %+v", out)
}
}
func TestPropagate_TargetCert100_NotTouched(t *testing.T) {
// Target with cert=100 is human-verified — must NOT be overwritten.
path := writeFile(t,
seg(100, 125, lbl(fFrom, "Kiwi", "Male", 100)),
seg(100, 125, lbl(fTo, "Kiwi", "Male", 100)),
)
out, err := CallsPropagate(CallsPropagateInput{
File: path, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out.TargetsExamined != 0 || out.Propagated != 0 {
t.Fatalf("cert=100 target must not be examined: %+v", out)
}
df := readFile(t, path)
if df.Meta.Reviewer != "David" {
t.Errorf("reviewer should stay David (no write), got %q", df.Meta.Reviewer)
}
}
func TestPropagate_TargetCert90_NotTouched(t *testing.T) {
// Target with cert=90 (already propagated earlier) must NOT be re-propagated.
path := writeFile(t,
seg(100, 125, lbl(fFrom, "Kiwi", "Male", 100)),
seg(100, 125, lbl(fTo, "Kiwi", "Female", 90)),
)
out, err := CallsPropagate(CallsPropagateInput{
File: path, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out.TargetsExamined != 0 || out.Propagated != 0 {
t.Fatalf("cert=90 target must not be examined: %+v", out)
}
df := readFile(t, path)
target := findLabel(df, fTo, 100, 125)
if target.Certainty != 90 || target.CallType != "Female" {
t.Errorf("cert=90 target was modified: %+v", target)
}
}
func TestPropagate_TargetCert0_Propagated(t *testing.T) {
// Target at cert=0 ("Don't Know" / "Noise") SHOULD be propagated when an
// overlapping cert=100 source exists — rescues labels from the noise bucket
// so they surface for review even if occasionally wrong.
path := writeFile(t,
seg(100, 125, lbl(fFrom, "Kiwi", "Male", 100)),
seg(100, 125, lbl(fTo, "Don't Know", "", 0)),
seg(200, 225, lbl(fFrom, "Kiwi", "Female", 100)),
seg(200, 225, lbl(fTo, "Noise", "", 0)),
)
out, err := CallsPropagate(CallsPropagateInput{
File: path, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out.TargetsExamined != 2 || out.Propagated != 2 {
t.Fatalf("cert=0 targets must be propagated: %+v", out)
}
df := readFile(t, path)
for _, c := range []struct {
start, end float64
calltype string
}{{100, 125, "Male"}, {200, 225, "Female"}} {
l := findLabel(df, fTo, c.start, c.end)
if l == nil || l.Species != "Kiwi" || l.CallType != c.calltype || l.Certainty != 90 {
t.Errorf("at %v-%v got %+v, want Kiwi+%s cert=90", c.start, c.end, l, c.calltype)
}
}
}
func TestPropagate_MultipleSourcesAgree(t *testing.T) {
// Two overlapping sources with same calltype → propagate.
path := writeFile(t,
seg(100, 110, lbl(fFrom, "Kiwi", "Male", 100)),
seg(105, 120, lbl(fFrom, "Kiwi", "Male", 100)),
seg(100, 125, lbl(fTo, "Kiwi", "Duet", 70)),
)
out, err := CallsPropagate(CallsPropagateInput{
File: path, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out.Propagated != 1 || out.SkippedConflict != 0 {
t.Fatalf("counts wrong: %+v", out)
}
df := readFile(t, path)
target := findLabel(df, fTo, 100, 125)
if target.CallType != "Male" {
t.Errorf("calltype should be Male, got %q", target.CallType)
}
}
func TestPropagate_MultipleSourcesConflict(t *testing.T) {
// Two overlapping sources with different calltypes → conflict, skip, report.
path := writeFile(t,
seg(100, 110, lbl(fFrom, "Kiwi", "Male", 100)),
seg(115, 120, lbl(fFrom, "Kiwi", "Female", 100)),
seg(100, 125, lbl(fTo, "Kiwi", "Duet", 70)),
)
out, err := CallsPropagate(CallsPropagateInput{
File: path, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out.Propagated != 0 || out.SkippedConflict != 1 {
t.Fatalf("expected 1 conflict skip: %+v", out)
}
if len(out.Conflicts) != 1 {
t.Fatalf("expected 1 conflict report, got %d", len(out.Conflicts))
}
if out.Conflicts[0].TargetStart != 100 || out.Conflicts[0].TargetEnd != 125 {
t.Errorf("conflict target wrong: %+v", out.Conflicts[0])
}
if len(out.Conflicts[0].SourceChoices) != 2 {
t.Errorf("expected 2 source choices, got %d", len(out.Conflicts[0].SourceChoices))
}
// Target must NOT be modified.
df := readFile(t, path)
target := findLabel(df, fTo, 100, 125)
if target.CallType != "Duet" || target.Certainty != 70 {
t.Errorf("conflicted target was modified: %+v", target)
}
if df.Meta.Reviewer != "David" {
t.Errorf("reviewer should stay David (no write), got %q", df.Meta.Reviewer)
}
}
func TestPropagate_EmptyCallTypePropagates(t *testing.T) {
// Source with empty calltype → target gets empty calltype.
path := writeFile(t,
seg(100, 125, lbl(fFrom, "Kiwi", "", 100)),
seg(100, 125, lbl(fTo, "Kiwi", "Male", 70)),
)
out, err := CallsPropagate(CallsPropagateInput{
File: path, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out.Propagated != 1 {
t.Fatalf("expected propagated=1: %+v", out)
}
df := readFile(t, path)
target := findLabel(df, fTo, 100, 125)
if target.CallType != "" {
t.Errorf("calltype should be cleared, got %q", target.CallType)
}
if target.Species != "Kiwi" || target.Certainty != 90 {
t.Errorf("target fields wrong: %+v", target)
}
}
func TestPropagate_SpeciesOverride(t *testing.T) {
// Target species was different from --species; must be overwritten.
path := writeFile(t,
seg(100, 125, lbl(fFrom, "Kiwi", "Male", 100)),
seg(100, 125, lbl(fTo, "Don't Know", "", 70)),
)
out, err := CallsPropagate(CallsPropagateInput{
File: path, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out.Propagated != 1 {
t.Fatalf("expected propagated=1: %+v", out)
}
df := readFile(t, path)
target := findLabel(df, fTo, 100, 125)
if target.Species != "Kiwi" || target.CallType != "Male" || target.Certainty != 90 {
t.Errorf("target not overwritten correctly: %+v", target)
}
}
func TestPropagate_OverlapBoundaryExclusive(t *testing.T) {
// Segments touching at a point (src ends exactly where tgt starts) do NOT overlap.
path := writeFile(t,
seg(100, 125, lbl(fFrom, "Kiwi", "Male", 100)),
seg(125, 150, lbl(fTo, "Kiwi", "Duet", 70)),
)
out, err := CallsPropagate(CallsPropagateInput{
File: path, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out.Propagated != 0 || out.SkippedNoOverlap != 1 {
t.Fatalf("touching boundary must not count as overlap: %+v", out)
}
}
func TestPropagate_OverlapPartial(t *testing.T) {
// 1-second overlap is enough.
path := writeFile(t,
seg(100, 126, lbl(fFrom, "Kiwi", "Male", 100)),
seg(125, 150, lbl(fTo, "Kiwi", "Duet", 70)),
)
out, err := CallsPropagate(CallsPropagateInput{
File: path, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out.Propagated != 1 {
t.Fatalf("expected propagated=1: %+v", out)
}
}
func TestPropagate_SupersetEitherDirection(t *testing.T) {
// Source engulfs target.
path1 := writeFile(t,
seg(100, 200, lbl(fFrom, "Kiwi", "Male", 100)),
seg(110, 150, lbl(fTo, "Kiwi", "Duet", 70)),
)
if out, _ := CallsPropagate(CallsPropagateInput{File: path1, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi"}); out.Propagated != 1 {
t.Errorf("source-engulfs-target: %+v", out)
}
// Target engulfs source.
path2 := writeFile(t,
seg(110, 150, lbl(fFrom, "Kiwi", "Male", 100)),
seg(100, 200, lbl(fTo, "Kiwi", "Duet", 70)),
)
if out, _ := CallsPropagate(CallsPropagateInput{File: path2, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi"}); out.Propagated != 1 {
t.Errorf("target-engulfs-source: %+v", out)
}
}
func TestPropagate_MissingFlags(t *testing.T) {
cases := []struct {
name string
in CallsPropagateInput
}{
{"no file", CallsPropagateInput{FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi"}},
{"no from", CallsPropagateInput{File: "x", ToFilter: fTo, Species: "Kiwi"}},
{"no to", CallsPropagateInput{File: "x", FromFilter: fFrom, Species: "Kiwi"}},
{"no species", CallsPropagateInput{File: "x", FromFilter: fFrom, ToFilter: fTo}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, err := CallsPropagate(c.in)
if err == nil {
t.Errorf("expected error")
}
})
}
}
func TestPropagate_SameFromAndTo(t *testing.T) {
_, err := CallsPropagate(CallsPropagateInput{
File: "x", FromFilter: fFrom, ToFilter: fFrom, Species: "Kiwi",
})
if err == nil {
t.Error("expected error when --from == --to")
}
}
func TestPropagate_NonexistentFile(t *testing.T) {
_, err := CallsPropagate(CallsPropagateInput{
File: "/nonexistent/path.data", FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",
})
if err == nil {
t.Error("expected error for nonexistent file")
}
}
func TestPropagate_RealisticMixed(t *testing.T) {
// Mimics the 20260228_211500.WAV.data case: cert=0 "Don't Know" and cert=100 Kiwi sources
// coexist; only cert=100 Kiwi gets propagated.
path := writeFile(t,
// Sources (kiwi-1.2)
seg(45, 52.5, lbl(fFrom, "Don't Know", "", 0)),
seg(142.5, 177.5, lbl(fFrom, "Kiwi", "Male", 100)),
seg(195, 217.5, lbl(fFrom, "Don't Know", "", 0)),
seg(647.5, 682.5, lbl(fFrom, "Kiwi", "Female", 100)),
seg(815, 855, lbl(fFrom, "Kiwi", "Duet", 100)),
// Targets (kiwi-1.5)
seg(147.5, 167.5, lbl(fTo, "Kiwi", "Male", 70)),
seg(647.5, 672.5, lbl(fTo, "Kiwi", "Female", 70)),
seg(815, 852.5, lbl(fTo, "Kiwi", "Duet", 70)),
)
out, err := CallsPropagate(CallsPropagateInput{
File: path, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out.TargetsExamined != 3 || out.Propagated != 3 || out.SkippedConflict != 0 {
t.Fatalf("counts wrong: %+v", out)
}
df := readFile(t, path)
expect := []struct {
start, end float64
calltype string
}{
{147.5, 167.5, "Male"},
{647.5, 672.5, "Female"},
{815, 852.5, "Duet"},
}
for _, e := range expect {
l := findLabel(df, fTo, e.start, e.end)
if l == nil || l.Certainty != 90 || l.CallType != e.calltype || l.Species != "Kiwi" {
t.Errorf("at %v-%v got %+v, want Kiwi+%s cert=90", e.start, e.end, l, e.calltype)
}
}
}
func TestPropagate_NoWriteIfNothingChanged(t *testing.T) {
// File with only non-target segments should not be rewritten (reviewer unchanged).
path := writeFile(t,
seg(100, 125, lbl(fFrom, "Kiwi", "Male", 100)),
)
out, err := CallsPropagate(CallsPropagateInput{
File: path, FromFilter: fFrom, ToFilter: fTo, Species: "Kiwi",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out.Propagated != 0 || out.TargetsExamined != 0 {
t.Fatalf("expected no activity: %+v", out)
}
df := readFile(t, path)
if df.Meta.Reviewer != "David" {
t.Errorf("reviewer should not be touched, got %q", df.Meta.Reviewer)
}
}
// 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 ...*datafile.Segment) string {
t.Helper()
path := filepath.Join(dir, base)
df := &datafile.DataFile{
Meta: &datafile.DataMeta{Operator: "ML", Reviewer: "David", Duration: 3600},
Segments: segs,
}
if err := df.Write(path); err != nil {
t.Fatalf("write fixture: %v", err)
}
return path
}
// assertPropagateStats checks output stats against expected values.
func assertPropagateStats(t *testing.T, got, want CallsPropagateFolderOutput) {
t.Helper()
checks := []struct {
name string
got int
want int
}{
{"FilesTotal", got.FilesTotal, want.FilesTotal},
{"FilesWithBothFilters", got.FilesWithBothFilters, want.FilesWithBothFilters},
{"FilesSkippedNoFilter", got.FilesSkippedNoFilter, want.FilesSkippedNoFilter},
{"FilesChanged", got.FilesChanged, want.FilesChanged},
{"FilesErrored", got.FilesErrored, want.FilesErrored},
{"TargetsExamined", got.TargetsExamined, want.TargetsExamined},
{"Propagated", got.Propagated, want.Propagated},
{"SkippedNoOverlap", got.SkippedNoOverlap, want.SkippedNoOverlap},
}
for _, c := range checks {
if c.got != c.want {
t.Errorf("%s: got %d, want %d", c.name, c.got, c.want)
}
}
}
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)
}
assertPropagateStats(t, out, CallsPropagateFolderOutput{
FilesTotal: 4,
FilesWithBothFilters: 2,
FilesSkippedNoFilter: 2,
FilesChanged: 1,
FilesErrored: 0,
TargetsExamined: 2,
Propagated: 1,
SkippedNoOverlap: 1,
})
t.Run("file_a_propagated", func(t *testing.T) {
aDf := readFile(t, aPath)
if aDf.Meta.Reviewer != "Skraak" {
t.Errorf("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("target label: got %+v, want cert=90 calltype=Male", l)
}
})
t.Run("file_b_skipped", func(t *testing.T) {
bDf := readFile(t, bPath)
if bDf.Meta.Reviewer != "David" {
t.Errorf("reviewer should not be touched, got %q", bDf.Meta.Reviewer)
}
})
t.Run("file_d_no_overlap", func(t *testing.T) {
dDf := readFile(t, dPath)
if dDf.Meta.Reviewer != "David" {
t.Errorf("reviewer should not be touched, got %q", dDf.Meta.Reviewer)
}
if l := findLabel(dDf, fTo, 500, 525); l == nil || l.Certainty != 70 {
t.Errorf("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])
}
}
// --- validateFolderInput ---
func TestValidateFolderInput(t *testing.T) {
tests := []struct {
name string
input CallsPropagateFolderInput
wantErr bool
}{
{name: "missing folder", input: CallsPropagateFolderInput{FromFilter: "a", ToFilter: "b", Species: "Kiwi"}, wantErr: true},
{name: "missing from", input: CallsPropagateFolderInput{Folder: "/tmp", ToFilter: "b", Species: "Kiwi"}, wantErr: true},
{name: "missing to", input: CallsPropagateFolderInput{Folder: "/tmp", FromFilter: "a", Species: "Kiwi"}, wantErr: true},
{name: "missing species", input: CallsPropagateFolderInput{Folder: "/tmp", FromFilter: "a", ToFilter: "b"}, wantErr: true},
{name: "from==to", input: CallsPropagateFolderInput{Folder: "/tmp", FromFilter: "x", ToFilter: "x", Species: "Kiwi"}, wantErr: true},
{name: "not a dir", input: CallsPropagateFolderInput{Folder: "/dev/null", FromFilter: "a", ToFilter: "b", Species: "Kiwi"}, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var output CallsPropagateFolderOutput
err := validateFolderInput(&output, tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("validateFolderInput() error=%v, wantErr=%v", err, tt.wantErr)
}
if tt.wantErr && output.Error == "" {
t.Error("expected output.Error to be set on validation failure")
}
})
}
}
// --- accumulatePropagateResult ---
func TestAccumulatePropagateResult(t *testing.T) {
t.Run("filters missing skips file", func(t *testing.T) {
var output CallsPropagateFolderOutput
accumulatePropagateResult("test.data", CallsPropagateOutput{FiltersMissing: true}, &output)
if output.FilesSkippedNoFilter != 1 {
t.Errorf("expected 1 skipped, got %d", output.FilesSkippedNoFilter)
}
if output.FilesWithBothFilters != 0 {
t.Error("should not count as having both filters")
}
})
t.Run("normal result aggregates counts", func(t *testing.T) {
var output CallsPropagateFolderOutput
accumulatePropagateResult("test.data", CallsPropagateOutput{
TargetsExamined: 5,
Propagated: 2,
SkippedNoOverlap: 2,
SkippedConflict: 1,
Conflicts: []PropagateConflict{{TargetStart: 1.0, TargetEnd: 2.0}},
}, &output)
if output.TargetsExamined != 5 || output.Propagated != 2 {
t.Errorf("counts wrong: %+v", output)
}
if output.FilesChanged != 1 {
t.Errorf("should mark 1 file changed, got %d", output.FilesChanged)
}
if len(output.Conflicts) != 1 || output.Conflicts[0].File != "test.data" {
t.Errorf("conflict file not tagged: %+v", output.Conflicts)
}
})
t.Run("zero propagated not counted as changed", func(t *testing.T) {
var output CallsPropagateFolderOutput
accumulatePropagateResult("test.data", CallsPropagateOutput{
TargetsExamined: 3,
}, &output)
if output.FilesChanged != 0 {
t.Errorf("should not count as changed, got %d", output.FilesChanged)
}
})
}