test: add FuzzMarshal, golden messages, synctest cancellation and cross smoke
Test / test (push) Successful in 1m31s

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-22 01:03:27 +02:00
parent 3e741e7790
commit bef1d3fbd9
10 changed files with 242 additions and 1 deletions
+5
View File
@@ -194,6 +194,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- An offset date-time written with the `+00:00` offset kept the anonymous
location `time.Parse` invents for it, so a round trip through the tree and
`Marshal`, which writes a zero offset as `Z`, changed the value's
reflection-visible location. The zero offset normalises to `time.UTC` at
parse, and the tree is stable across the round trip.
- Decoding into a defined type whose underlying kind is string or bool, such
as `type Name string`, panicked instead of storing the value, because a
value of the predeclared type is not assignable to a defined type and the
+6
View File
@@ -258,6 +258,12 @@ func parseDateTime(tok string) (any, bool) {
if err != nil {
return nil, false
}
// A zero offset carries its own anonymous location from time.Parse,
// while the written form is "Z" either way; normalising to UTC keeps
// the tree identical across the round trip.
if _, off := t.Zone(); off == 0 {
t = t.In(time.UTC)
}
return OffsetDateTime{t}, true
case dateTimeLocal:
layout := localClockLayout
+120
View File
@@ -13,6 +13,7 @@ import (
"strings"
"sync/atomic"
"testing"
"testing/synctest"
"time"
)
@@ -1574,3 +1575,122 @@ func TestCancelInsideValue(t *testing.T) {
t.Errorf("err = %v, want context.Canceled", err)
}
}
func TestCancellationSynctest(t *testing.T) {
// The bubble makes the cost of the immediate-cancellation path visible in
// virtual microseconds, and synctest.Wait holds the test to leaving no
// goroutine behind.
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
start := time.Now()
var tree map[string]any
err := NewDecoder().DecodeContext(ctx, []byte("a = 1\n"), &tree)
if !errors.Is(err, context.Canceled) {
t.Errorf("err = %v, want context.Canceled", err)
}
if d := time.Since(start); d != 0 {
t.Errorf("the parse consumed %v of virtual time, want none", d)
}
synctest.Wait()
})
}
func TestErrorMessagesGolden(t *testing.T) {
// The exact texts the library promises, pinned against unintended edits.
type Config struct {
Weight uint8 `toml:"weight"`
}
tests := []struct {
name string
read func() error
want string
}{
{
name: "missing equals",
read: func() error { _, err := ParseMap([]byte("a 1\n")); return err },
want: "interpres: line 1: expected '=' after key",
},
{
name: "duplicate key",
read: func() error { _, err := ParseMap([]byte("a = 1\na = 2\n")); return err },
want: `interpres: line 2: duplicate key "a"`,
},
{
name: "unterminated string",
read: func() error { _, err := ParseMap([]byte("a = \"open\n")); return err },
want: "interpres: line 1: unterminated string",
},
{
name: "leading zero",
read: func() error { _, err := ParseMap([]byte("a = 01\n")); return err },
want: "interpres: line 1: leading zeros are not allowed in numbers",
},
{
name: "bad escape",
read: func() error { _, err := ParseMap([]byte(`a = "\q"` + "\n")); return err },
want: `interpres: line 1: invalid escape sequence \q`,
},
{
name: "nesting limit",
read: func() error {
var b strings.Builder
b.WriteString("a = ")
for range 11 {
b.WriteString("[")
}
for range 11 {
b.WriteString("]")
}
b.WriteString("\n")
var tree map[string]any
err := NewDecoder().MaxDepth(10).Decode([]byte(b.String()), &tree)
return err
},
want: "interpres: line 1: nesting exceeds the limit of 10",
},
{
name: "decode overflow",
read: func() error {
var cfg Config
return Unmarshal([]byte("weight = 300\n"), &cfg)
},
want: "interpres: weight: integer 300 overflows uint8",
},
{
name: "unknown field",
read: func() error {
var cfg struct {
Known int `toml:"known"`
}
return NewDecoder().DisallowUnknownFields().Decode([]byte("mystery = 1\n"), &cfg)
},
want: `interpres: unknown field "mystery" for struct { Known int "toml:\"known\"" }`,
},
{
name: "decode target",
read: func() error { return Unmarshal([]byte("a = 1\n"), Config{}) },
want: "interpres: decode target must be a non-nil pointer",
},
{
name: "encode nil pointer",
read: func() error {
var p *Config
_, err := Marshal(p)
return err
},
want: "interpres: cannot marshal nil pointer",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.read()
if err == nil {
t.Fatalf("no error, want %q", tt.want)
}
if err.Error() != tt.want {
t.Errorf("message = %q, want %q", err.Error(), tt.want)
}
})
}
}
+68
View File
@@ -121,3 +121,71 @@ func tomlEqual(a, b any) bool {
return reflect.DeepEqual(a, b)
}
}
// FuzzMarshal drives the encoder with generated Go values and holds it to
// the same round-trip invariant FuzzParse holds the parser to: a value built
// only of encodable kinds must marshal, the document must re-parse, and the
// tree must equal the value it came from.
func FuzzMarshal(f *testing.F) {
seeds := [][]byte{
{},
{0, 0, 1, 2},
{1, 1, 2, 3, 2, 2, 3, 4},
{0, 3, 1, 9, 3, 3, 2, 8, 1, 0, 1, 7},
}
for _, s := range seeds {
f.Add(s)
}
f.Fuzz(func(t *testing.T, data []byte) {
v := fuzzValue(data)
out, err := Marshal(v)
if err != nil {
t.Fatalf("marshal of an encodable value failed: %v\nvalue: %#v", err, v)
}
tree, err := ParseMap(out)
if err != nil {
t.Fatalf("re-parse of the emitted document failed: %v\ndoc:\n%s", err, out)
}
if !tomlEqual(v, tree) {
t.Fatalf("round-trip changed the value\nvalue: %#v\ndoc:\n%s\ntree: %#v", v, out, tree)
}
})
}
// fuzzKeys is the fixed key pool the generated values draw from, so keys are
// always valid bare keys and repeat often.
var fuzzKeys = []string{"alpha", "beta", "gamma", "delta"}
// fuzzValue builds a map[string]any of encodable kinds from data: integers,
// positive floats, short strings, nested tables and scalar arrays. The bytes
// decide the shape deterministically.
func fuzzValue(data []byte) map[string]any {
root := map[string]any{}
cur := root
depth := 0
for i := 0; i+3 < len(data); i += 4 {
key := fuzzKeys[int(data[i])%len(fuzzKeys)]
switch data[i+1] % 5 {
case 0:
cur[key] = int64(data[i+2])<<8 | int64(data[i+3])
case 1:
cur[key] = float64(int(data[i+2])%1000)/8.0 + 0.125
case 2:
cur[key] = string(rune('a' + int(data[i+2])%26))
case 3:
cur[key] = []any{
int64(data[i+2]),
float64(int(data[i+3])%100)/4.0 + 0.25,
string(rune('a' + int(data[i+3])%26)),
}
case 4:
if depth < 6 {
next := map[string]any{}
cur[key] = next
cur = next
depth++
}
}
}
return root
}
+25
View File
@@ -796,3 +796,28 @@ func TestParseAsAndNewSchema(t *testing.T) {
}
NewSchema[map[string]any]() // must not panic
}
func TestZeroOffsetRoundTrip(t *testing.T) {
// A document may write a zero offset as +00:00; the tree must hold the
// same value after a round trip, because the written form is "Z" either
// way.
src := []byte("a = 1979-05-27T07:32:00+00:00\n")
tree, err := ParseMap(src)
if err != nil {
t.Fatal(err)
}
out, err := Marshal(tree)
if err != nil {
t.Fatal(err)
}
re, err := ParseMap(out)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(tree, re) {
t.Errorf("round trip changed the tree: %#v vs %#v", tree, re)
}
if got := tree["a"].(OffsetDateTime).String(); got != "1979-05-27T07:32Z" {
t.Errorf("a = %q, want 1979-05-27T07:32Z", got)
}
}
+10 -1
View File
@@ -32,7 +32,7 @@ test:
close($c);
die qq{no total line in coverage.out\n} unless defined $total;
printf qq{Total coverage: %s%%\n}, $total;
exit($total < 80 ? 1 : 0);
exit($total < 82 ? 1 : 0);
# The same suite under the race detector. The expensive one.
race:
@@ -100,6 +100,15 @@ toml-test: build
coverage-html: test
go tool cover -html=coverage.out -o coverage.html
# Cross-compile smoke: the library and the command build for the foreign architectures; not a gate, it is a nightly convenience and runs std-lib only.
cross:
GOARCH=arm64 go build ./...
GOARCH=loong64 go build ./...
GOARCH=riscv64 go build ./...
GOARCH=arm64 CGO_ENABLED=0 go build -o /dev/null {{package}}
GOARCH=loong64 CGO_ENABLED=0 go build -o /dev/null {{package}}
GOARCH=riscv64 CGO_ENABLED=0 go build -o /dev/null {{package}}
# Runs the example program under examples/basic; not standard because `run` runs the adapter, and an example is documentation, not the product.
example:
go run ./examples/basic
+2
View File
@@ -0,0 +1,2 @@
go test fuzz v1
[]byte("0=00:00\n1=0000-01-01 00:00:00.0+00:00#000000000000")
+2
View File
@@ -0,0 +1,2 @@
go test fuzz v1
[]byte("e = \"\\\\e[0m\\\\x41\\\\x7f\\\\x00\"\n")
+2
View File
@@ -0,0 +1,2 @@
go test fuzz v1
[]byte("m = {\n\ttitle = \"one\",\n\tnums = [1, 2,],\n\tinner = { deep = true }, # trailing\n}\n")
+2
View File
@@ -0,0 +1,2 @@
go test fuzz v1
[]byte("t = 13:37\nbig = 1979-05-27 07:32:00.5+01:00\nshort = 1979-05-27 07:32\n")