package tools
import (
"path/filepath"
"testing"
"skraak/utils"
)
// helpers
func seg(start, end float64, labels ...*utils.Label) *utils.Segment {
return &utils.Segment{
StartTime: start,
EndTime: end,
FreqLow: 100,
FreqHigh: 8000,
Labels: labels,
}
}
func lbl(filter, species, calltype string, certainty int) *utils.Label {
return &utils.Label{
Filter: filter,
Species: species,
CallType: calltype,
Certainty: certainty,
}
}
func writeFile(t *testing.T, segs ...*utils.Segment) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "test.data")
df := &utils.DataFile{
Meta: &utils.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) *utils.DataFile {
t.Helper()
df, err := utils.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 *utils.DataFile, filter string, start, end float64) *utils.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.Propagated != 0 || out.SkippedNoOverlap != 1 {
t.Fatalf("counts wrong: %+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)
}
}