Files
tapir/internal/reconcile/reconcile_spots_test.go
mathias 4a2067063d
CI / Deploy via GitOps (push) Successful in 7s
CI / Lint / Test / Vet (push) Successful in 33s
CI / Build & Import (push) Successful in 23s
feat: upload audio, get a private Swedish transcript, discuss it (#33, ADR-033) (#39)
Upload a recording (audio, or video with the camera off), get a private Swedish transcript, and chat about it.

- ADR-033: RLS-scoped private transcript store; ffprobe/ffmpeg intake; gateway transcription (iguana kb-whisper, berget fallback); in-process single-replica worker; chat over the transcript (local models only).
- ADR-034: optional two-engine mode (pianissimo + kb-whisper) reconciled by a LOCAL model; off unless TAPIR_RECONCILE_BASE_MODEL is set.
- ADR-035: real progress (phases, running/quiet/lost), phase logging, every judged spot stored, merged segments.
- Hardening from four adversarial reviews: errors logged and stored as kinds never text; per-engine deadlines; converted-length check; bounded queue; client hang-ups are not errors; strict local-alias check.
- Dockerfile: static ffmpeg/ffprobe 7.0.2 pinned by sha256 (#34). Built and inspected by hand; CI does not build the image on a PR, so its first CI build is this merge.

Uploads stay OFF until TAPIR_UPLOADS_ENABLED is set in the deployment.

Closes #33, #34, #42, #43, #44.

Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-03 14:13:37 +00:00

131 lines
5.5 KiB
Go

package reconcile_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.d-ma.be/mathias/tapir/internal/domain"
"git.d-ma.be/mathias/tapir/internal/reconcile"
)
// Every judged spot is stored with a few words of left context, and the saved segments
// carry the merged words while keeping the base's timing (ADR-035 decisions 6 and 7).
func TestReconcileSpots_AKeptSpotAndAChangedSpotAreBothStoredWithTheirVerdict(t *testing.T) {
adj := &scripted{fn: func(i int, _ reconcile.Request) (reconcile.Verdict, error) {
if i == 0 {
return reconcile.VerdictBase, nil
}
return reconcile.VerdictAlt, nil
}}
out := merge(t, tr(progBase), tr(progAlt), adj)
require.Len(t, out.Reconciliation.Contested, 2)
assert.Equal(t, "base", out.Reconciliation.Contested[0].Used)
assert.Equal(t, "1015", out.Reconciliation.Contested[0].Base)
assert.Equal(t, "alt", out.Reconciliation.Contested[1].Used)
assert.Equal(t, 1, out.Reconciliation.Changed, "the exact counts are unchanged by storing more")
}
func TestReconcileSpots_AutoResolvedDisputesAreNotStored(t *testing.T) {
out := merge(t, tr("i takrenns delen ska vi ju betala"), tr("i takrennsdelen ska vi betala"), always(reconcile.VerdictAlt))
assert.Empty(t, out.Reconciliation.Contested, "compounds and fillers were never judged; they are counted, not listed")
}
func TestReconcileSpots_ASpotCarriesTheWordsJustBeforeIt(t *testing.T) {
out := merge(t, tr("grannen målar ett vanligt staket mot gatan"), tr("grannen målar ett KORT staket mot gatan"), always(reconcile.VerdictAlt))
require.Len(t, out.Reconciliation.Contested, 1)
assert.Equal(t, "grannen målar ett", out.Reconciliation.Contested[0].Before)
}
func TestReconcileSpots_TheContextIsAFewWordsNotTheWholeMeeting(t *testing.T) {
long := "ett två tre fyra fem sex sju åtta nio tio elva tolv"
out := merge(t, tr(long+" X slut"), tr(long+" Y slut"), always(reconcile.VerdictAlt))
require.Len(t, out.Reconciliation.Contested, 1)
assert.Equal(t, "fem sex sju åtta nio tio elva tolv", out.Reconciliation.Contested[0].Before,
"eight words: enough to find the place, small enough to stay out of the way")
}
func segmented(text string, segs ...domain.Segment) domain.Transcription {
t := tr(text)
t.Segments = segs
return t
}
func TestReconcileSegments_AChangedWordReplacesTheBaseWordInItsOwnSegmentOnly(t *testing.T) {
base := segmented("ett två tre. fyra fem sex. sju åtta nio.",
domain.Segment{Start: 0, End: 10, Text: "ett två tre."},
domain.Segment{Start: 10, End: 20, Text: "fyra fem sex."},
domain.Segment{Start: 20, End: 30, Text: "sju åtta nio."})
out := merge(t, base, tr("ett två tre. fyra femm sex. sju åtta nio."), always(reconcile.VerdictAlt))
require.Len(t, out.Segments, 3)
assert.Equal(t, "ett två tre.", out.Segments[0].Text, "an untouched segment keeps its exact text")
assert.Equal(t, "fyra femm sex.", out.Segments[1].Text)
assert.Equal(t, "sju åtta nio.", out.Segments[2].Text)
assert.InDelta(t, 10.0, out.Segments[1].Start, 1e-9, "timing stays the base's")
assert.InDelta(t, 20.0, out.Segments[1].End, 1e-9)
}
func TestReconcileSegments_AWordOnlyTheAlternativeHeardLandsInTheSegmentItBelongsTo(t *testing.T) {
base := segmented("a b c. d e f.", domain.Segment{Start: 0, End: 5, Text: "a b c."}, domain.Segment{Start: 5, End: 10, Text: "d e f."})
out := merge(t, base, tr("a b c. d NY e f."), always(reconcile.VerdictAlt))
assert.Equal(t, "a b c.", out.Segments[0].Text)
assert.Equal(t, "d NY e f.", out.Segments[1].Text)
}
func TestReconcileSegments_AWordOnlyTheBaseHeardIsRemovedFromItsSegment(t *testing.T) {
base := segmented("a b c. d X e f.", domain.Segment{Start: 0, End: 5, Text: "a b c."}, domain.Segment{Start: 5, End: 10, Text: "d X e f."})
out := merge(t, base, tr("a b c. d e f."), always(reconcile.VerdictAlt))
assert.Equal(t, "d e f.", out.Segments[1].Text)
}
func TestReconcileSegments_AKeptBaseLeavesEverySegmentAsItWas(t *testing.T) {
base := segmented("a x b", domain.Segment{Start: 0, End: 4, Text: "a x b"})
out := merge(t, base, tr("a y b"), always(reconcile.VerdictBase))
assert.Equal(t, base.Segments, out.Segments)
}
func TestReconcileSegments_TheCallersSegmentsAreNeverChangedInPlace(t *testing.T) {
segs := []domain.Segment{{Start: 0, End: 4, Text: "a x b"}}
base := segmented("a x b", segs...)
_ = merge(t, base, tr("a y b"), always(reconcile.VerdictAlt))
assert.Equal(t, "a x b", segs[0].Text, "Reconcile builds new segments; it does not edit the base transcription's")
}
func TestReconcileSegments_SegmentsThatDoNotLineUpWithTheTextAreLeftAsTheBases(t *testing.T) {
// A word wrapped across a segment edge makes the segments' word count differ from
// the joined text's. Guessing would put words in the wrong place, so leave them.
base := segmented("a x b c", domain.Segment{Start: 0, End: 4, Text: "a x"}, domain.Segment{Start: 4, End: 8, Text: "b c d"})
out := merge(t, base, tr("a y b c"), always(reconcile.VerdictAlt))
assert.Equal(t, base.Segments, out.Segments)
assert.Equal(t, "a y b c", out.Text, "the merged text is unaffected")
}
func TestReconcileSegments_ASpanThatCrossesASegmentEdgeIsRemovedFromBothSegments(t *testing.T) {
base := segmented("a b c d", domain.Segment{Start: 0, End: 5, Text: "a b"}, domain.Segment{Start: 5, End: 10, Text: "c d"})
out := merge(t, base, tr("a X d"), always(reconcile.VerdictAlt))
require.Equal(t, "a X d", out.Text)
assert.Equal(t, "a X", out.Segments[0].Text)
assert.Equal(t, "d", out.Segments[1].Text, "'c' was replaced too: it must not linger in the second segment")
}