miller/pkg/transformers/stats1_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

150 lines
4.6 KiB
Go

package transformers
import (
"testing"
"github.com/johnkerl/miller/v6/pkg/mlrval"
"github.com/johnkerl/miller/v6/pkg/types"
)
// makeStats1TestRecord builds a record from alternating key/value pairs, e.g.
// makeStats1TestRecord("x", "1", "g", "a").
func makeStats1TestRecord(kvPairs ...string) *types.RecordAndContext {
record := mlrval.NewMlrmapAsRecord()
for i := 0; i < len(kvPairs); i += 2 {
record.PutReference(kvPairs[i], mlrval.FromInferredType(kvPairs[i+1]))
}
context := types.NewNilContext()
return types.NewRecordAndContext(record, context)
}
func runStats1TestTransformer(
tr RecordTransformer,
inputs []*types.RecordAndContext,
) []*types.RecordAndContext {
inputDownstreamDoneChannel := make(chan bool, 1)
outputDownstreamDoneChannel := make(chan bool, 1)
outputs := make([]*types.RecordAndContext, 0)
for _, input := range inputs {
_ = tr.Transform(input, &outputs, inputDownstreamDoneChannel, outputDownstreamDoneChannel)
}
// End-of-stream marker
eos := types.NewRecordAndContext(nil, types.NewNilContext())
eos.EndOfStream = true
_ = tr.Transform(eos, &outputs, inputDownstreamDoneChannel, outputDownstreamDoneChannel)
return outputs
}
// TestStats1SlidingWindow exercises 'mlr stats1 -a sum,mean,min,max -f x -w 3'.
func TestStats1SlidingWindow(t *testing.T) {
tr, err := NewTransformerStats1(
[]string{"sum", "mean", "min", "max"},
[]string{"x"},
[]string{}, // groupByFieldNameList
false, false, // doRegexValueFieldNames, doRegexGroupByFieldNames
false, false, // invertRegexValueFieldNames, invertRegexGroupByFieldNames
false, // doInterpolatedPercentiles
false, // doIterativeStats
3, // slidingWindowSize
)
if err != nil {
t.Fatal(err)
}
inputs := []*types.RecordAndContext{
makeStats1TestRecord("x", "1"),
makeStats1TestRecord("x", "2"),
makeStats1TestRecord("x", "3"),
makeStats1TestRecord("x", "4"),
makeStats1TestRecord("x", "5"),
}
outputs := runStats1TestTransformer(tr, inputs)
// 5 data records plus end-of-stream marker
if len(outputs) != 6 {
t.Fatalf("got %d outputs, want 6", len(outputs))
}
expectations := []struct{ sum, mean, min, max string }{
{"1", "1", "1", "1"}, // window [1]
{"3", "1.5", "1", "2"}, // window [1,2]
{"6", "2", "1", "3"}, // window [1,2,3]
{"9", "3", "2", "4"}, // window [2,3,4]
{"12", "4", "3", "5"}, // window [3,4,5]
}
for i, expectation := range expectations {
record := outputs[i].Record
for name, want := range map[string]string{
"x_sum": expectation.sum,
"x_mean": expectation.mean,
"x_min": expectation.min,
"x_max": expectation.max,
} {
value := record.Get(name)
if value == nil {
t.Fatalf("record %d: field %s missing", i, name)
}
if value.String() != want {
t.Errorf("record %d: %s got %s, want %s", i, name, value.String(), want)
}
}
}
if !outputs[5].EndOfStream {
t.Errorf("last output should be the end-of-stream marker")
}
}
// TestStats1SlidingWindowGrouped exercises 'mlr stats1 -a count,sum -f x -g g -w 2':
// windows are maintained per group.
func TestStats1SlidingWindowGrouped(t *testing.T) {
tr, err := NewTransformerStats1(
[]string{"count", "sum"},
[]string{"x"},
[]string{"g"}, // groupByFieldNameList
false, false, // doRegexValueFieldNames, doRegexGroupByFieldNames
false, false, // invertRegexValueFieldNames, invertRegexGroupByFieldNames
false, // doInterpolatedPercentiles
false, // doIterativeStats
2, // slidingWindowSize
)
if err != nil {
t.Fatal(err)
}
inputs := []*types.RecordAndContext{
makeStats1TestRecord("g", "a", "x", "1"),
makeStats1TestRecord("g", "b", "x", "10"),
makeStats1TestRecord("g", "a", "x", "2"),
makeStats1TestRecord("g", "b", "x", "20"),
makeStats1TestRecord("g", "a", "x", "3"),
}
outputs := runStats1TestTransformer(tr, inputs)
// 5 data records plus end-of-stream marker
if len(outputs) != 6 {
t.Fatalf("got %d outputs, want 6", len(outputs))
}
expectations := []struct{ count, sum string }{
{"1", "1"}, // group a, window [1]
{"1", "10"}, // group b, window [10]
{"2", "3"}, // group a, window [1,2]
{"2", "30"}, // group b, window [10,20]
{"2", "5"}, // group a, window [2,3]
}
for i, expectation := range expectations {
record := outputs[i].Record
count := record.Get("x_count")
sum := record.Get("x_sum")
if count == nil || sum == nil {
t.Fatalf("record %d: missing x_count or x_sum", i)
}
if count.String() != expectation.count {
t.Errorf("record %d: x_count got %s, want %s", i, count.String(), expectation.count)
}
if sum.String() != expectation.sum {
t.Errorf("record %d: x_sum got %s, want %s", i, sum.String(), expectation.sum)
}
}
}