miller/pkg/transformers/bootstrap_ci_test.go
John Kerl 570fcf0de4
Remove os.Exit callsites below the entrypoint: phase 3 (plans/exit.md) (#2204)
Phase 3 of plans/exit.md: the streaming-interface change, the load-bearing
piece for #341 (DSL exit statement) and #440 (strict mode).

- RecordTransformer.Transform and RecordTransformerFunc now return error.
  All 69 Transform implementations and their dispatch helpers updated
  (mechanical rewrite, compiler- and errcheck-verified).
- runSingleTransformerBatch, on a Transform error, forwards any output
  produced before the failure plus an end-of-stream marker downstream, so
  the rest of the chain and the record-writer drain and finish cleanly;
  runSingleTransformer then surfaces the error to stream.Stream's select
  loop (non-blocking send; first error wins) and signals upstream-done so
  the record-reader stops. This is exactly the flush-then-exit sequencing a
  future DSL 'exit N' needs.
- dataProcessingErrorChannel and FileOutputHandler.recordErroredChannel are
  now chan error instead of chan bool; ChannelWriter still prints write-
  error details at the site and sends the 'exiting due to data error'
  sentinel, preserving the exact stderr shape pinned by regression cases.
- Mid-stream os.Exit sites converted to returned errors: put/filter DSL
  begin/main/end-block errors and the non-boolean filter-expression case,
  tee write/close failures, split write/open/close failures, join left-file
  ingest failures (both half-streaming and sorted paths, with full error
  plumbing through JoinBucketKeeper), histogram/stats2 ingest errors, surv
  fit errors, and step stepper allocation (tStepperAllocator now returns
  (tStepper, error); bad EWMA coefficients propagate; negative slwin
  parameters are reported by the CLI parser via the existing
  bad-stepper-name pattern).
- The two genuinely internal join-bucket-keeper states now use
  lib.InternalCodingErrorWithMessageIf instead of hand-rolled print+exit.
- pkg/transformers is now os.Exit-free.

Behavior notes: the non-boolean filter message gains the standard 'mlr: '
prefix and a newline (it previously printed with neither); tee errors now
include the underlying cause. All 4779 regression cases pass unchanged;
mlr head early-out latency is unaffected (0.02s over 50M records).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 15:07:09 -04:00

172 lines
4.9 KiB
Go

package transformers
import (
"testing"
"github.com/johnkerl/miller/v6/pkg/lib"
"github.com/johnkerl/miller/v6/pkg/mlrval"
"github.com/johnkerl/miller/v6/pkg/types"
)
func TestBootstrapCIRejectsBadAccumulatorName(t *testing.T) {
_, err := NewTransformerBootstrapCI(
[]string{"nonesuch"},
[]string{"x"},
[]string{},
100,
0.95,
false,
)
if err == nil {
t.Fatal("expected error for unknown accumulator name, got nil")
}
}
func TestBootstrapCIParseCLIValidation(t *testing.T) {
cases := []struct {
name string
args []string
}{
{"missing -f", []string{"bootstrap-ci"}},
{"non-positive -n", []string{"bootstrap-ci", "-f", "x", "-n", "0"}},
{"confidence level too low", []string{"bootstrap-ci", "-f", "x", "-c", "0"}},
{"confidence level too high", []string{"bootstrap-ci", "-f", "x", "-c", "1"}},
{"unknown accumulator", []string{"bootstrap-ci", "-f", "x", "-a", "nonesuch"}},
{"unknown option", []string{"bootstrap-ci", "-f", "x", "-q"}},
}
for _, tc := range cases {
argi := 0
_, err := transformerBootstrapCIParseCLI(&argi, len(tc.args), tc.args, nil, true)
if err == nil {
t.Errorf("%s: expected parse error, got nil", tc.name)
}
}
}
// feedBootstrapCI passes records with the given x-values (and a constant
// group-by field g=one) through the transformer, followed by an end-of-stream
// marker, returning the transformer's output records.
func feedBootstrapCI(tr *TransformerBootstrapCI, xValues []int64) []*types.RecordAndContext {
context := types.NewContext()
inputDownstreamDoneChannel := make(chan bool, 1)
outputDownstreamDoneChannel := make(chan bool, 1)
outputRecordsAndContexts := make([]*types.RecordAndContext, 0)
for _, xValue := range xValues {
record := mlrval.NewMlrmapAsRecord()
record.PutCopy("g", mlrval.FromString("one"))
record.PutCopy("x", mlrval.FromInt(xValue))
_ = tr.Transform(
types.NewRecordAndContext(record, context),
&outputRecordsAndContexts,
inputDownstreamDoneChannel,
outputDownstreamDoneChannel,
)
}
_ = tr.Transform(
types.NewEndOfStreamMarker(context),
&outputRecordsAndContexts,
inputDownstreamDoneChannel,
outputDownstreamDoneChannel,
)
return outputRecordsAndContexts
}
func TestBootstrapCIEndToEnd(t *testing.T) {
lib.SeedRandom(12345)
tr, err := NewTransformerBootstrapCI(
[]string{"mean"},
[]string{"x"},
[]string{"g"},
500,
0.95,
false,
)
if err != nil {
t.Fatal(err)
}
outputRecordsAndContexts := feedBootstrapCI(tr, []int64{1, 2, 3, 4, 5})
// One stats record plus the end-of-stream marker
if len(outputRecordsAndContexts) != 2 {
t.Fatalf("expected 2 output records-and-contexts, got %d", len(outputRecordsAndContexts))
}
if !outputRecordsAndContexts[1].EndOfStream {
t.Fatal("expected second output to be the end-of-stream marker")
}
outrec := outputRecordsAndContexts[0].Record
if got := outrec.Get("g"); got == nil || got.String() != "one" {
t.Errorf("expected group-by field g=one, got %v", got)
}
pointEstimate := outrec.Get("x_mean")
lo := outrec.Get("x_mean_lo")
hi := outrec.Get("x_mean_hi")
if pointEstimate == nil || lo == nil || hi == nil {
t.Fatalf("missing output fields; record is %s", outrec.String())
}
if got, ok := pointEstimate.GetNumericToFloatValue(); !ok || got != 3.0 {
t.Errorf("expected point estimate x_mean=3, got %v", pointEstimate)
}
loValue, ok := lo.GetNumericToFloatValue()
if !ok {
t.Fatalf("x_mean_lo is not numeric: %v", lo)
}
hiValue, ok := hi.GetNumericToFloatValue()
if !ok {
t.Fatalf("x_mean_hi is not numeric: %v", hi)
}
// The resampled means all lie within [min, max] of the data, and the
// confidence interval must bracket the point estimate.
if loValue < 1.0 || hiValue > 5.0 {
t.Errorf("confidence interval [%v, %v] out of data range [1, 5]", loValue, hiValue)
}
if loValue > 3.0 || hiValue < 3.0 {
t.Errorf("confidence interval [%v, %v] does not bracket point estimate 3", loValue, hiValue)
}
if loValue >= hiValue {
t.Errorf("expected lo < hi, got [%v, %v]", loValue, hiValue)
}
}
func TestBootstrapCISingleValueDegenerate(t *testing.T) {
lib.SeedRandom(12345)
tr, err := NewTransformerBootstrapCI(
[]string{"mean"},
[]string{"x"},
[]string{"g"},
100,
0.95,
false,
)
if err != nil {
t.Fatal(err)
}
outputRecordsAndContexts := feedBootstrapCI(tr, []int64{7})
if len(outputRecordsAndContexts) != 2 {
t.Fatalf("expected 2 output records-and-contexts, got %d", len(outputRecordsAndContexts))
}
outrec := outputRecordsAndContexts[0].Record
// With a single datum, every resample is that datum, so the interval is
// degenerate at the point estimate.
for _, key := range []string{"x_mean", "x_mean_lo", "x_mean_hi"} {
value := outrec.Get(key)
if value == nil {
t.Fatalf("missing output field %s; record is %s", key, outrec.String())
}
if got, ok := value.GetNumericToFloatValue(); !ok || got != 7.0 {
t.Errorf("expected %s=7, got %v", key, value)
}
}
}