diff --git a/shared/pkg/sink/jsonl/jsonl.go b/shared/pkg/sink/jsonl/jsonl.go index 0cdd2b2..762829c 100644 --- a/shared/pkg/sink/jsonl/jsonl.go +++ b/shared/pkg/sink/jsonl/jsonl.go @@ -31,6 +31,10 @@ type Sink struct { buf []byte keys []string + // keyStack holds one reusable sorted-key buffer per Fields nesting depth, + // so the hand-rolled object encoder sorts keys without per-record allocs. + keyStack [][]string + fieldsBuf bytes.Buffer fieldsEnc *json.Encoder } @@ -79,11 +83,11 @@ func (s *Sink) appendRecord(b []byte, r domain.Record, c domain.Classification) b = appendStringBytes(b, r.Data) if len(r.Fields) > 0 { b = append(b, `,"fields":`...) - fields, err := s.encodeFields(r.Fields) + nb, err := s.appendFields(b, r.Fields) if err != nil { return nil, err } - b = append(b, fields...) + b = nb } if len(r.Meta) > 0 { b = append(b, `,"meta":`...) @@ -119,14 +123,168 @@ func (s *Sink) appendRecord(b []byte, r domain.Record, c domain.Classification) return append(b, '}', '\n'), nil } -// encodeFields serializes the free-form Fields map through encoding/json -// (sorted keys, json.Number passthrough) into a reused scratch buffer. -func (s *Sink) encodeFields(fields map[string]any) ([]byte, error) { +// maxFastDepth bounds fast-path recursion over objects and arrays; deeper or +// cyclic values fall back to encoding/json instead of overflowing the stack. +const maxFastDepth = 64 + +// appendFields emits Fields via the 0-alloc fast path when it can prove +// byte-equality with encoding/json; anything else rewinds b and defers to +// encoding/json for identical bytes or the reference error. +func (s *Sink) appendFields(b []byte, fields map[string]any) ([]byte, error) { + saved := len(b) + if nb, ok := s.appendObject(b, fields, 0); ok { + return nb, nil + } + b = b[:saved] s.fieldsBuf.Reset() if err := s.fieldsEnc.Encode(fields); err != nil { return nil, err } - return bytes.TrimSuffix(s.fieldsBuf.Bytes(), []byte("\n")), nil + return append(b, bytes.TrimSuffix(s.fieldsBuf.Bytes(), []byte("\n"))...), nil +} + +// appendObject emits m with sorted keys, matching encoding/json's ordering. +// depth counts every nesting level and doubles as the per-depth key-buffer +// index; buffers are released on every exit so caller keys are not retained. +func (s *Sink) appendObject(b []byte, m map[string]any, depth int) ([]byte, bool) { + if depth >= maxFastDepth { + return b, false + } + for len(s.keyStack) <= depth { + s.keyStack = append(s.keyStack, nil) + } + keys := s.keyStack[depth][:0] + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + s.keyStack[depth] = keys + b = append(b, '{') + for i, k := range keys { + if i > 0 { + b = append(b, ',') + } + b = appendString(b, k) + b = append(b, ':') + nb, ok := s.appendValue(b, m[k], depth+1) + if !ok { + s.releaseKeys(depth) + return b, false + } + b = nb + } + s.releaseKeys(depth) + return append(b, '}'), true +} + +// releaseKeys zeroes the key buffer at depth, keeping its capacity. +func (s *Sink) releaseKeys(depth int) { + keys := s.keyStack[depth] + for i := range keys { + keys[i] = "" + } + s.keyStack[depth] = keys[:0] +} + +// appendArray emits elements one level deeper, so array nesting counts against +// maxFastDepth like object nesting (a cyclic slice must not recurse forever). +func (s *Sink) appendArray(b []byte, a []any, depth int) ([]byte, bool) { + if depth >= maxFastDepth { + return b, false + } + b = append(b, '[') + for i, v := range a { + if i > 0 { + b = append(b, ',') + } + nb, ok := s.appendValue(b, v, depth+1) + if !ok { + return b, false + } + b = nb + } + return append(b, ']'), true +} + +// appendValue encodes one Fields value, returning ok=false for anything not in +// the confirmed source universe (string, json.Number, bool, nil, map, slice) +// plus float64, so the caller can rewind and defer to encoding/json. +func (s *Sink) appendValue(b []byte, v any, depth int) ([]byte, bool) { + switch val := v.(type) { + case nil: + return append(b, "null"...), true + case bool: + if val { + return append(b, "true"...), true + } + return append(b, "false"...), true + case string: + return appendString(b, val), true + case json.Number: + num := string(val) + if num == "" { + num = "0" + } + if !isValidNumber(num) { + return b, false + } + return append(b, num...), true + case float64: + if math.IsNaN(val) || math.IsInf(val, 0) { + return b, false + } + return appendFloat(b, val), true + case map[string]any: + return s.appendObject(b, val, depth) + case []any: + return s.appendArray(b, val, depth) + default: + return b, false + } +} + +// isValidNumber reports whether s is a valid JSON number literal, copied from +// encoding/json so json.Number passthrough matches the reference exactly. +func isValidNumber(s string) bool { + if s == "" { + return false + } + if s[0] == '-' { + s = s[1:] + if s == "" { + return false + } + } + switch { + default: + return false + case s[0] == '0': + s = s[1:] + case '1' <= s[0] && s[0] <= '9': + s = s[1:] + for len(s) > 0 && '0' <= s[0] && s[0] <= '9' { + s = s[1:] + } + } + if len(s) >= 2 && s[0] == '.' && '0' <= s[1] && s[1] <= '9' { + s = s[2:] + for len(s) > 0 && '0' <= s[0] && s[0] <= '9' { + s = s[1:] + } + } + if len(s) >= 2 && (s[0] == 'e' || s[0] == 'E') { + s = s[1:] + if s[0] == '+' || s[0] == '-' { + s = s[1:] + if s == "" { + return false + } + } + for len(s) > 0 && '0' <= s[0] && s[0] <= '9' { + s = s[1:] + } + } + return s == "" } // appendMeta serializes the string map with sorted keys, matching diff --git a/shared/pkg/sink/jsonl/jsonl_bench_test.go b/shared/pkg/sink/jsonl/jsonl_bench_test.go index abd0ba1..463a279 100644 --- a/shared/pkg/sink/jsonl/jsonl_bench_test.go +++ b/shared/pkg/sink/jsonl/jsonl_bench_test.go @@ -2,6 +2,7 @@ package jsonl import ( "context" + "encoding/json" "io" "testing" @@ -38,8 +39,8 @@ func BenchmarkWrite(b *testing.B) { } } -// BenchmarkWriteStructured measures a record carrying decoded Fields, whose -// free-form map still serializes through encoding/json and so allocates. +// BenchmarkWriteStructured measures a record carrying decoded Fields through +// the fast path that proves byte-equality with encoding/json. func BenchmarkWriteStructured(b *testing.B) { s := New(io.Discard) defer func() { _ = s.Close() }() @@ -68,3 +69,35 @@ func BenchmarkWriteStructured(b *testing.B) { } } } + +// BenchmarkWriteStructuredNumbers mirrors BenchmarkWriteStructured but its +// Fields carry json.Number values (the jsonl source's real output type via +// dec.UseNumber), so the proof reflects production data rather than float64. +func BenchmarkWriteStructuredNumbers(b *testing.B) { + s := New(io.Discard) + defer func() { _ = s.Close() }() + + r := domain.Record{ + ID: "events:1", + Kind: domain.KindJSON, + Data: []byte(`{"level":"error","http":{"status":503},"msg":"upstream timeout"}`), + Fields: map[string]any{"level": "error", "http": map[string]any{"status": json.Number("503")}, "msg": "upstream timeout"}, + Meta: map[string]string{"source": "events", "line": "1"}, + } + c := domain.Classification{ + Category: "alert", + Confidence: 1, + Stage: "rules", + Reasons: []domain.Reason{{Code: "server-error", Detail: "field http.status >= 500"}}, + } + + ctx := context.Background() + b.ReportAllocs() + b.SetBytes(int64(len(r.Data))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + if err := s.Write(ctx, r, c); err != nil { + b.Fatal(err) + } + } +} diff --git a/shared/pkg/sink/jsonl/jsonl_test.go b/shared/pkg/sink/jsonl/jsonl_test.go index afe4f00..b7a16ed 100644 --- a/shared/pkg/sink/jsonl/jsonl_test.go +++ b/shared/pkg/sink/jsonl/jsonl_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "errors" + "io" "math" "strings" "testing" @@ -145,14 +146,31 @@ func TestWriteMatchesEncodingJSON(t *testing.T) { {"confidence exactly zero", domain.Record{ID: "x", Data: []byte("d")}, domain.Classification{Category: "x", Confidence: 0}}, {"confidence exactly one fast path pin", domain.Record{ID: "x", Data: []byte("d")}, domain.Classification{Category: "x", Confidence: 1}}, {"negative zero confidence", domain.Record{ID: "x", Data: []byte("d")}, domain.Classification{Category: "x", Confidence: math.Copysign(0, -1)}}, + + // Broad Fields corpus: every value the fast path recognizes, plus the + // edges (malformed/empty json.Number, non-finite float, unsupported + // type) that must rewind and defer to encoding/json for identical bytes, + // or error when the reference errors (messages are not compared). + {"fields json.Number ints and signs", domain.Record{ID: "x", Data: []byte("d"), Fields: map[string]any{"a": json.Number("0"), "b": json.Number("-0"), "c": json.Number("42"), "d": json.Number("-42")}}, domain.Classification{Category: "x", Confidence: 1}}, + {"fields json.Number decimals and exponents", domain.Record{ID: "x", Data: []byte("d"), Fields: map[string]any{"a": json.Number("3.14"), "b": json.Number("-3.14"), "c": json.Number("1e10"), "d": json.Number("1E-10"), "e": json.Number("-2.5e+3")}}, domain.Classification{Category: "x", Confidence: 1}}, + {"fields empty json.Number becomes zero", domain.Record{ID: "x", Data: []byte("d"), Fields: map[string]any{"a": json.Number("")}}, domain.Classification{Category: "x", Confidence: 1}}, + {"fields malformed json.Number falls back to error", domain.Record{ID: "x", Data: []byte("d"), Fields: map[string]any{"a": json.Number("1.2.3")}}, domain.Classification{Category: "x", Confidence: 1}}, + {"fields malformed json.Number leading zero", domain.Record{ID: "x", Data: []byte("d"), Fields: map[string]any{"a": json.Number("01")}}, domain.Classification{Category: "x", Confidence: 1}}, + {"fields bool and nil", domain.Record{ID: "x", Data: []byte("d"), Fields: map[string]any{"t": true, "f": false, "n": nil}}, domain.Classification{Category: "x", Confidence: 1}}, + {"fields nested maps and arrays", domain.Record{ID: "x", Data: []byte("d"), Fields: map[string]any{"outer": map[string]any{"inner": map[string]any{"leaf": json.Number("7")}}, "list": []any{json.Number("1"), "two", true, nil}}}, domain.Classification{Category: "x", Confidence: 1}}, + {"fields array of mixed scalars and objects", domain.Record{ID: "x", Data: []byte("d"), Fields: map[string]any{"xs": []any{map[string]any{"k": "v"}, json.Number("3"), []any{false, nil}, "s"}}}, domain.Classification{Category: "x", Confidence: 1}}, + {"fields keys needing escaping", domain.Record{ID: "x", Data: []byte("d"), Fields: map[string]any{"q\"q": 1.0, "ctl\x01byte": 2.0, "uni\u65e5\u672c": 3.0, "sep\u2028par\u2029": 4.0, "&": 5.0, "": 6.0}}, domain.Classification{Category: "x", Confidence: 1}}, + {"fields float64 special values", domain.Record{ID: "x", Data: []byte("d"), Fields: map[string]any{"zero": float64(0), "negzero": math.Copysign(0, -1), "one": float64(1), "tiny": 1e-9, "huge": 1e21, "big": 1.5e300, "frac": 3.5}}, domain.Classification{Category: "x", Confidence: 1}}, + {"fields NaN float surfaces error", domain.Record{ID: "x", Data: []byte("d"), Fields: map[string]any{"bad": math.NaN()}}, domain.Classification{Category: "x", Confidence: 1}}, + {"fields Inf float surfaces error", domain.Record{ID: "x", Data: []byte("d"), Fields: map[string]any{"bad": math.Inf(1)}}, domain.Classification{Category: "x", Confidence: 1}}, + {"fields unsupported type falls back", domain.Record{ID: "x", Data: []byte("d"), Fields: map[string]any{"i": int(5), "f32": float32(1.5)}}, domain.Classification{Category: "x", Confidence: 1}}, + {"fields empty nested map and array", domain.Record{ID: "x", Data: []byte("d"), Fields: map[string]any{"m": map[string]any{}, "a": []any{}}}, domain.Classification{Category: "x", Confidence: 1}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { var got bytes.Buffer s := New(&got) - if err := s.Write(context.Background(), tc.r, tc.c); err != nil { - t.Fatalf("Write() error = %v", err) - } + writeErr := s.Write(context.Background(), tc.r, tc.c) if err := s.Close(); err != nil { t.Fatalf("Close() error = %v", err) } @@ -166,8 +184,15 @@ func TestWriteMatchesEncodingJSON(t *testing.T) { var want bytes.Buffer enc := json.NewEncoder(&want) enc.SetEscapeHTML(false) - if err := enc.Encode(ref); err != nil { - t.Fatalf("reference encode: %v", err) + refErr := enc.Encode(ref) + if refErr != nil { + if writeErr == nil { + t.Fatalf("reference encoder errored (%v) but sink succeeded: %q", refErr, got.String()) + } + return + } + if writeErr != nil { + t.Fatalf("Write() error = %v, but reference encoder succeeded", writeErr) } if got.String() != want.String() { t.Fatalf("encoder drift\n got: %q\nwant: %q", got.String(), want.String()) @@ -269,3 +294,326 @@ func TestWriteHonorsContextCancellation(t *testing.T) { t.Fatalf("Write() error = %v, want context.Canceled", err) } } + +// TestStructuredFastPathZeroAllocs pins the hand-rolled Fields encoder to 0 +// heap allocations per record over a nested structured record, mirroring the +// plain path's 0-alloc guarantee. +func TestStructuredFastPathZeroAllocs(t *testing.T) { + s := New(io.Discard) + defer func() { _ = s.Close() }() + ctx := context.Background() + r := domain.Record{ + ID: "events:1", + Kind: domain.KindJSON, + Data: []byte(`{"level":"error"}`), + Fields: map[string]any{"level": "error", "http": map[string]any{"status": json.Number("503")}, "tags": []any{"a", "b"}}, + Meta: map[string]string{"source": "events", "line": "1"}, + } + c := domain.Classification{Category: "alert", Confidence: 1, Stage: "rules"} + if n := testing.AllocsPerRun(200, func() { + if err := s.Write(ctx, r, c); err != nil { + t.Fatalf("Write() error = %v", err) + } + }); n != 0 { + t.Fatalf("structured fast path allocs = %v, want 0", n) + } +} + +// fieldsGen builds a map[string]any from fuzz bytes, drawing from the confirmed +// Fields type universe (string, json.Number, bool, nil, nested map, slice) plus +// a few unsupported types (int, float32) and non-finite floats to exercise the +// encoding/json fallback and its error path. +type fieldsGen struct { + b []byte + i int +} + +func (g *fieldsGen) next() byte { + if g.i >= len(g.b) { + return 0 + } + v := g.b[g.i] + g.i++ + return v +} + +func (g *fieldsGen) str() string { + n := int(g.next() % 8) + out := make([]byte, n) + for i := range out { + out[i] = g.next() + } + return string(out) +} + +func (g *fieldsGen) key() string { + specials := []string{"a", "b", "q\"q", "ctl\x01", "sep

", "&", "日本", ""} + if g.next()%2 == 0 { + return specials[int(g.next())%len(specials)] + } + return g.str() +} + +func (g *fieldsGen) number() json.Number { + choices := []string{"0", "-0", "1", "-1", "42", "-42", "3.14", "-3.14", "1e10", "1E-10", "-2.5e+3", "", "1.", "01", "abc", "-", "1e", "+5", "1.2.3"} + return json.Number(choices[int(g.next())%len(choices)]) +} + +func (g *fieldsGen) float() float64 { + choices := []float64{0, math.Copysign(0, -1), 1, -1, 3.5, 1e-9, 1e21, 1.5e300, math.NaN(), math.Inf(1), math.Inf(-1)} + return choices[int(g.next())%len(choices)] +} + +func (g *fieldsGen) value(depth int) any { + sel := g.next() + if depth >= 6 { + sel %= 6 // stop nesting: only scalars past this depth + } + switch sel % 10 { + case 0: + return g.str() + case 1, 9: + return g.number() + case 2: + return g.next()%2 == 0 + case 3: + return nil + case 4: + return g.float() + case 5: + return int(int8(g.next())) // unsupported by fast path -> fallback + case 6: + return g.object(depth + 1) + case 7: + return g.array(depth + 1) + case 8: + return float32(g.next()) // unsupported by fast path -> fallback + } + return nil +} + +func (g *fieldsGen) object(depth int) map[string]any { + n := int(g.next() % 5) + m := make(map[string]any, n) + for i := 0; i < n; i++ { + m[g.key()] = g.value(depth) + } + return m +} + +func (g *fieldsGen) array(depth int) []any { + n := int(g.next() % 5) + a := make([]any, n) + for i := range a { + a[i] = g.value(depth) + } + return a +} + +// FuzzFieldsMatchEncodingJSON is the guard behind the byte-for-byte claim: for +// any generated Fields map the sink output must equal encoding/json +// (SetEscapeHTML(false)) exactly, or both must surface an error. +func FuzzFieldsMatchEncodingJSON(f *testing.F) { + f.Add([]byte{}) + f.Add([]byte{1, 2, 3, 4, 5, 6, 7, 8}) + f.Add([]byte{6, 2, 0, 65, 1, 7, 3, 4, 5, 9, 8, 6, 6}) + f.Add(bytes.Repeat([]byte{4, 1, 9, 6, 7}, 8)) + + f.Fuzz(func(t *testing.T, data []byte) { + g := &fieldsGen{b: data} + fields := g.object(0) + if len(fields) == 0 { + return // empty Fields is omitted from the wire; nothing to compare + } + r := domain.Record{ID: "x", Data: []byte("d"), Fields: fields} + c := domain.Classification{Category: "x", Confidence: 1} + + var got bytes.Buffer + s := New(&got) + writeErr := s.Write(context.Background(), r, c) + if err := s.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + + ref := entry{ID: r.ID, Data: string(r.Data), Fields: fields, Category: c.Category, Confidence: c.Confidence} + var want bytes.Buffer + enc := json.NewEncoder(&want) + enc.SetEscapeHTML(false) + refErr := enc.Encode(ref) + + if refErr != nil { + if writeErr == nil { + t.Fatalf("reference errored (%v) but sink succeeded: %q\nfields=%#v", refErr, got.String(), fields) + } + return + } + if writeErr != nil { + t.Fatalf("sink errored (%v) but reference succeeded\nfields=%#v", writeErr, fields) + } + if got.String() != want.String() { + t.Fatalf("encoder drift\n got: %q\nwant: %q\nfields=%#v", got.String(), want.String(), fields) + } + }) +} + +func nestedMapChain(depth int) map[string]any { + v := map[string]any{"leaf": "end"} + for i := 0; i < depth; i++ { + v = map[string]any{"k": v} + } + return v +} + +func nestedArrayChain(depth int) []any { + v := []any{"end"} + for i := 0; i < depth; i++ { + v = []any{v} + } + return v +} + +// TestFieldsCyclesAndDepth pins the fallback boundary: cyclic Fields must +// return an error without crashing, and deep acyclic Fields must stay +// byte-identical to encoding/json on both sides of maxFastDepth. +func TestFieldsCyclesAndDepth(t *testing.T) { + selfMap := map[string]any{} + selfMap["self"] = selfMap + selfSlice := []any{nil} + selfSlice[0] = selfSlice + mutualMap := map[string]any{} + mutualSlice := []any{mutualMap} + mutualMap["back"] = mutualSlice + + cyclic := []struct { + name string + fields map[string]any + }{ + {"self-referential map", selfMap}, + {"self-referential slice", map[string]any{"a": selfSlice}}, + {"mutual map and slice cycle", mutualMap}, + } + for _, tc := range cyclic { + t.Run(tc.name, func(t *testing.T) { + var got bytes.Buffer + s := New(&got) + err := s.Write(context.Background(), domain.Record{ID: "x", Data: []byte("d"), Fields: tc.fields}, + domain.Classification{Category: "x", Confidence: 1}) + if err == nil { + t.Fatalf("Write() = nil error for cyclic Fields, want the encoding/json cycle error") + } + }) + } + + deep := []struct { + name string + fields map[string]any + }{ + {"map chain below the limit", map[string]any{"root": nestedMapChain(maxFastDepth - 3)}}, + {"map chain at the limit", map[string]any{"root": nestedMapChain(maxFastDepth)}}, + {"map chain past the limit", map[string]any{"root": nestedMapChain(maxFastDepth + 1)}}, + {"map chain far past the limit", map[string]any{"root": nestedMapChain(200)}}, + {"array chain past the limit", map[string]any{"root": nestedArrayChain(maxFastDepth + 10)}}, + {"mixed chain past the limit", map[string]any{"root": []any{nestedMapChain(maxFastDepth)}}}, + } + for _, tc := range deep { + t.Run(tc.name, func(t *testing.T) { + var got bytes.Buffer + s := New(&got) + if err := s.Write(context.Background(), domain.Record{ID: "x", Data: []byte("d"), Fields: tc.fields}, + domain.Classification{Category: "x", Confidence: 1}); err != nil { + t.Fatalf("Write() error = %v", err) + } + if err := s.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + ref := entry{ID: "x", Data: "d", Fields: tc.fields, Category: "x", Confidence: 1} + var want bytes.Buffer + enc := json.NewEncoder(&want) + enc.SetEscapeHTML(false) + if err := enc.Encode(ref); err != nil { + t.Fatalf("reference encode error = %v", err) + } + if got.String() != want.String() { + t.Fatalf("encoder drift\n got: %q\nwant: %q", got.String(), want.String()) + } + }) + } +} + +func TestReleaseKeysClearsReferences(t *testing.T) { + s := New(io.Discard) + fields := map[string]any{ + "outer": map[string]any{"inner": map[string]any{"leaf": "v"}}, + "other": "x", + } + if err := s.Write(context.Background(), domain.Record{ID: "x", Data: []byte("d"), Fields: fields}, + domain.Classification{Category: "c", Confidence: 1}); err != nil { + t.Fatalf("Write() error = %v", err) + } + if len(s.keyStack) == 0 { + t.Fatal("keyStack unused; expected the nested write to exercise it") + } + for depth, keys := range s.keyStack { + if len(keys) != 0 { + t.Fatalf("keyStack[%d] len = %d after Write, want 0", depth, len(keys)) + } + full := keys[:cap(keys)] + for i, k := range full { + if k != "" { + t.Fatalf("keyStack[%d][%d] retains %q after release", depth, i, k) + } + } + } + + fields["outer"].(map[string]any)["inner"].(map[string]any)["bad"] = struct{}{} + if err := s.Write(context.Background(), domain.Record{ID: "y", Data: []byte("d"), Fields: fields}, + domain.Classification{Category: "c", Confidence: 1}); err != nil { + t.Fatalf("Write() with fallback error = %v", err) + } + for depth, keys := range s.keyStack { + if len(keys) != 0 { + t.Fatalf("keyStack[%d] len = %d after fallback unwind, want 0", depth, len(keys)) + } + full := keys[:cap(keys)] + for i, k := range full { + if k != "" { + t.Fatalf("keyStack[%d][%d] retains %q after fallback unwind", depth, i, k) + } + } + } +} + +func TestIsValidNumber(t *testing.T) { + cases := []struct { + in string + want bool + }{ + {"0", true}, + {"-0", true}, + {"42", true}, + {"-42", true}, + {"3.14", true}, + {"1e10", true}, + {"1E-10", true}, + {"-2.5e+3", true}, + {"0.5", true}, + {"", false}, + {"-", false}, + {"01", false}, + {"1.", false}, + {".5", false}, + {"1e", false}, + {"1e+", false}, + {"+5", false}, + {"1.2.3", false}, + {"abc", false}, + {"0x10", false}, + {"NaN", false}, + {"Infinity", false}, + } + for _, tc := range cases { + if got := isValidNumber(tc.in); got != tc.want { + t.Errorf("isValidNumber(%q) = %v, want %v", tc.in, got, tc.want) + } + } +}