From 8f85bb68fac45c8870ee68bb26724b5dc8154d12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Sat, 19 Sep 2026 12:18:18 +0200 Subject: [PATCH] feat(encode): write the TOML 1.1 output form Assisted-by: DeepSeek V4.1 Flash --- CHANGELOG.md | 9 +++ cmd/interpres-decode/main_test.go | 6 +- datetime.go | 43 ++++++---- docs/API.md | 38 +++++++-- encode.go | 90 +++++++++++++++++++-- encode_test.go | 125 +++++++++++++++++++++++++++--- interpres.go | 9 ++- interpres_test.go | 10 ++- 8 files changed, 278 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 937c034..b15374f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- The output takes the TOML 1.1 form. A date-time writes its seconds only when + the value carries them and drops the trailing zeros of a fractional second, + so `07:32:00` is written `07:32` and half a second as `00.5`. Both are the + same value, and a document written without seconds now comes back without + them. `LocalDateTime.String()`, `LocalTime.String()` and the offset date-time + rendering follow the same rule. +- An inline table that would pass the hundredth column is written across lines + with a trailing comma and one tab of indentation per nesting level, the shape + TOML 1.1 allows an inline table to take. - TOML 1.1 is the acceptance contract, and TOML 1.0 is not. The compliance suite runs the 1.1 corpus alone, and the promise that every 1.0 document parses exactly as before is withdrawn. Nothing that parses today stops diff --git a/cmd/interpres-decode/main_test.go b/cmd/interpres-decode/main_test.go index abaf2e5..c9a6b83 100644 --- a/cmd/interpres-decode/main_test.go +++ b/cmd/interpres-decode/main_test.go @@ -307,13 +307,13 @@ func TestRunEncoderScalars(t *testing.T) { t.Fatalf("Run returned %d, stderr = %q", code, stderr.String()) } want := "b = false\n" + - "dt = 1979-05-27T07:32:00-07:00\n" + + "dt = 1979-05-27T07:32-07:00\n" + "f = inf\n" + "g = 1.5\n" + "i = -9223372036854775808\n" + "ld = 1979-05-27\n" + - "ldt = 1979-05-27T07:32:00\n" + - "lt = 07:32:00.999000000\n" + + "ldt = 1979-05-27T07:32\n" + + "lt = 07:32:00.999\n" + "nl = \"line1\\nline2\"\n" + "s = \"quote \\\" and backslash \\\\\"\n" if stdout.String() != want { diff --git a/datetime.go b/datetime.go index ccfaeab..5aa9da9 100644 --- a/datetime.go +++ b/datetime.go @@ -28,29 +28,42 @@ type LocalDate struct{ time.Time } type LocalTime struct{ time.Time } // String returns the TOML-canonical rendering of the local date-time, e.g. -// "1979-05-27T07:32:00" or "...:00.000000123" when the time has a fractional -// second. The fractional component is zero-padded to nanosecond precision. +// "1979-05-27T07:32" or "1979-05-27T07:32:00.5" when the time carries a +// fractional second. TOML 1.1 makes the seconds optional, so they appear only +// when they are non-zero, and a fraction drops its trailing zeros. func (ldt LocalDateTime) String() string { - base := ldt.Format("2006-01-02T15:04:05") - if ns := ldt.Nanosecond(); ns > 0 { - return base + "." + fmt.Sprintf("%09d", ns) - } - return base + return ldt.Format("2006-01-02T") + clockString(ldt.Time) } // String returns the TOML-canonical rendering of the local date, e.g. // "1979-05-27". func (ld LocalDate) String() string { return ld.Format("2006-01-02") } -// String returns the TOML-canonical rendering of the local time, e.g. -// "07:32:00" or "...:00.000000123" when the time has a fractional second. -// The fractional component is zero-padded to nanosecond precision. -func (lt LocalTime) String() string { - base := lt.Format("15:04:05") - if ns := lt.Nanosecond(); ns > 0 { - return base + "." + fmt.Sprintf("%09d", ns) +// String returns the TOML-canonical rendering of the local time, e.g. "07:32" +// or "07:32:00.5" when the time carries a fractional second. +func (lt LocalTime) String() string { return clockString(lt.Time) } + +// clockString renders a time of day the way TOML writes it: the seconds appear +// only when the value carries them, and a fractional second drops its trailing +// zeros, so half a second is "00.5" and not "00.500000000". Both are the same +// value either way; the shorter form is the one TOML 1.1 allows. +func clockString(t time.Time) string { + out := t.Format("15:04") + ns := t.Nanosecond() + if t.Second() != 0 || ns != 0 { + out += t.Format(":05") } - return base + if ns > 0 { + out += "." + strings.TrimRight(fmt.Sprintf("%09d", ns), "0") + } + return out +} + +// offsetString renders an offset date-time, the fourth TOML kind, in the same +// shape: no zero seconds, no trailing zeros in the fraction, and the offset +// written as "Z" when it is zero. +func offsetString(t time.Time) string { + return t.Format("2006-01-02T") + clockString(t) + t.Format("Z07:00") } var ( diff --git a/docs/API.md b/docs/API.md index a194730..8246a21 100644 --- a/docs/API.md +++ b/docs/API.md @@ -145,11 +145,12 @@ variants decode into `LocalDateTime`, `LocalDate` and `LocalTime`, whose embedded `time.Time` is normalised to UTC (midnight UTC for a local date, the zero date for a local time). Every kind may omit the seconds as of TOML 1.1 (`07:32`, `1979-05-27T07:32`); such a value carries a zero second, and the -canonical rendering writes full seconds. There is no implicit conversion -between the offset and local kinds; assigning one to the other is an error. -The four types take a bare timestamp and never a quoted string, so a document -that writes a date-time with quotes does not decode into them, and neither -`encoding.TextUnmarshaler` nor the embedded `time.Time` changes that. +encoder writes the seconds only when the value carries them, so a document +written without seconds comes back without them. There is no implicit +conversion between the offset and local kinds; assigning one to the other is an +error. The four types take a bare timestamp and never a quoted string, so a +document that writes a date-time with quotes does not decode into them, and +neither `encoding.TextUnmarshaler` nor the embedded `time.Time` changes that. ### Arrays of tables @@ -426,6 +427,23 @@ carry verbatim (an embedded run of three single quotes, a control character other than tab or newline, or a carriage return outside a CRLF pair) also keeps the basic form, so the output always re-parses to the same value. +### Inline tables + +A table element of a value array is written as one `{a = 1, b = 2}` line while +it fits. An inline table that would pass the hundredth column carries newlines +and a trailing comma instead, which TOML 1.1 allows: + +```toml +arr = [1, { + n = 1, + name = "a value long enough to push the line past the limit", +}] +``` + +The closing brace and the entries are indented one tab per nesting level, a +nested table is measured on its own line, and the output re-parses to the same +value either way. + ### Cancellation `MarshalContext` and `(*Encoder).MarshalContext` accept a `context.Context`. The @@ -440,6 +458,8 @@ The output is not byte-identical to any document that produced the value: - map keys are emitted in sorted order - the choice between `[table]` headers and inline tables is not preserved - strings use the basic quoted form unless the literal option above applies +- a date-time drops its zero seconds and the trailing zeros of its fraction, so + `07:32:00` is written `07:32`; both are the same value - floats always carry a `.` or an exponent, so a float `1` is emitted as `1.0` and stays distinguishable from the integer `1` across a round-trip; negative zero is normalised to `0.0` @@ -545,9 +565,11 @@ type LocalDate struct{ time.Time } // 1979-05-27 type LocalTime struct{ time.Time } // 07:32:00.999999 ``` -Each carries a `String()` method returning the TOML-canonical rendering, with -the fractional second zero-padded to nanosecond precision when present. The -types are produced by `Parse` and accepted by `Marshal`. +Each carries a `String()` method returning the TOML-canonical rendering: the +seconds appear only when the value carries them, and a fractional second drops +its trailing zeros, so `07:32:00` renders as `07:32` and a half second as +`00.5`. The types are produced by `Parse` and accepted by `Marshal`, which +writes them through `String()`. ## Errors diff --git a/encode.go b/encode.go index 2d0651c..071d397 100644 --- a/encode.go +++ b/encode.go @@ -28,18 +28,50 @@ var ( textMarshalerType = reflect.TypeFor[encoding.TextMarshaler]() ) +// inlineLimit is the column past which an inline table is written across +// lines. TOML 1.1 lets an inline table carry newlines and a trailing comma, so +// a long one stays readable instead of running off the line. +const inlineLimit = 100 + +// noInlineBreak is the limit a measuring encoder carries, high enough that the +// form it renders is always the single-line one. +const noInlineBreak = 1 << 30 + // encoder produces a TOML document from a Go value via a small intermediate // representation that preserves the order in which fields were declared. type encoder struct { buf bytes.Buffer ctx context.Context opts Encoder + + // inlineDepth is the nesting level inside inline tables, which decides + // their indentation. + inlineDepth int + + // limit is the column at which an inline table is broken; only a + // measuring encoder raises it. + limit int } -func newEncoder() *encoder { return &encoder{} } +func newEncoder() *encoder { return &encoder{limit: inlineLimit} } + +// flat returns an encoder that measures a value by rendering it on one line, +// so a caller can decide which form to write before writing it. +func (e *encoder) flat() *encoder { + return &encoder{ctx: e.ctx, opts: e.opts, limit: noInlineBreak} +} func (e *encoder) bytes() []byte { return e.buf.Bytes() } +// column reports how many bytes the current line already holds, so a form can +// be measured against the limit before it is written. +func (e *encoder) column() int { + if i := bytes.LastIndexByte(e.buf.Bytes(), '\n'); i >= 0 { + return e.buf.Len() - i - 1 + } + return e.buf.Len() +} + func (e *encoder) checkCtx() error { if e.ctx == nil { return nil @@ -891,7 +923,7 @@ func (e *encoder) writeValue(val any) error { case float64: return e.writeFloat(v) case time.Time: - e.buf.WriteString(v.Format(time.RFC3339Nano)) + e.buf.WriteString(offsetString(v)) return nil case LocalDateTime: e.buf.WriteString(v.String()) @@ -915,7 +947,7 @@ func (e *encoder) writeValue(val any) error { e.buf.WriteByte(']') return nil case map[string]any: - return e.writeInlineTable(v) + return e.writeInlineMap(v) case nil: return fmt.Errorf("interpres: cannot encode nil value") default: @@ -923,10 +955,24 @@ func (e *encoder) writeValue(val any) error { } } -// writeInlineTable renders m as a TOML inline table with sorted keys, the -// order buildMapDoc uses for header tables. It backs the table elements of a -// value array, where the [[header]] form is not available. -func (e *encoder) writeInlineTable(m map[string]any) error { +// writeInlineMap renders m as a TOML inline table, on one line when it fits +// there and across lines when it does not. +func (e *encoder) writeInlineMap(m map[string]any) error { + flat := e.flat() + if err := flat.writeInlineMapFlat(m); err != nil { + return err + } + if e.column()+flat.buf.Len() <= e.limit { + e.buf.Write(flat.buf.Bytes()) + return nil + } + return e.writeInlineMapMultiline(m) +} + +// writeInlineMapFlat renders m as a single-line inline table with sorted keys, +// the order buildMapDoc uses for header tables. It backs the table elements of +// a value array, where the [[header]] form is not available. +func (e *encoder) writeInlineMapFlat(m map[string]any) error { keys := slices.Sorted(maps.Keys(m)) e.buf.WriteByte('{') for i, k := range keys { @@ -945,6 +991,36 @@ func (e *encoder) writeInlineTable(m map[string]any) error { return nil } +// writeInlineMapMultiline renders m with one entry per line and a trailing +// comma, the form TOML 1.1 allows for an inline table too long for one line. +func (e *encoder) writeInlineMapMultiline(m map[string]any) error { + keys := slices.Sorted(maps.Keys(m)) + e.buf.WriteString("{\n") + e.inlineDepth++ + for _, k := range keys { + e.writeInlineIndent() + if err := e.writeKey(k); err != nil { + return err + } + e.buf.WriteString(" = ") + if err := e.writeValue(m[k]); err != nil { + return err + } + e.buf.WriteString(",\n") + } + e.inlineDepth-- + e.writeInlineIndent() + e.buf.WriteByte('}') + return nil +} + +// writeInlineIndent writes one tab per inline-table nesting level. +func (e *encoder) writeInlineIndent() { + for range e.inlineDepth { + e.buf.WriteByte('\t') + } +} + func (e *encoder) writeStringVal(s string) error { if e.opts.literalMultilineAt > 0 && strings.ContainsRune(s, '\n') && len(s) >= e.opts.literalMultilineAt && canBeLiteralMultiline(s) { diff --git a/encode_test.go b/encode_test.go index 0ace3eb..76f80ac 100644 --- a/encode_test.go +++ b/encode_test.go @@ -326,7 +326,7 @@ func TestMarshalerReturningTime(t *testing.T) { if err != nil { t.Fatalf("marshal: %v", err) } - want := "m = 2026-06-26T10:00:00Z\n" + want := "m = 2026-06-26T10:00Z\n" if string(out) != want { t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) } @@ -466,7 +466,7 @@ func TestMarshalEmbeddedScalarStruct(t *testing.T) { if err != nil { t.Fatalf("marshal: %v", err) } - want := "name = \"x\"\ns = 2026-06-26T00:00:00\n" + want := "name = \"x\"\ns = 2026-06-26T00:00\n" if string(out) != want { t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) } @@ -541,7 +541,7 @@ func TestMarshalDateTime(t *testing.T) { if err != nil { t.Fatalf("marshal: %v", err) } - want := "offset = 2026-06-26T10:00:00Z\nlocal = 2026-06-26T07:32:00\nday = 2026-06-26\nclock = 07:32:00\n" + want := "offset = 2026-06-26T10:00Z\nlocal = 2026-06-26T07:32\nday = 2026-06-26\nclock = 07:32\n" if string(out) != want { t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) } @@ -688,7 +688,7 @@ func TestMarshalInlineTableWithDatetime(t *testing.T) { if err != nil { t.Fatalf("marshal: %v", err) } - want := "mix = [1979-05-27T07:32:00Z, {t = 1979-05-27T07:32:00}]\n" + want := "mix = [1979-05-27T07:32Z, {t = 1979-05-27T07:32}]\n" if string(out) != want { t.Fatalf("output mismatch:\ngot: %q\nwant: %q", out, want) } @@ -907,7 +907,7 @@ func TestMarshalTagOptionOmitZero(t *testing.T) { if err != nil { t.Fatalf("marshal: %v", err) } - want = "name = \"x\"\ncount = 1\nratio = 0.5\nwhen = 2026-09-17T12:00:00Z\nalways = \"kept\"\n\n[server]\nhost = \"h\"\n" + want = "name = \"x\"\ncount = 1\nratio = 0.5\nwhen = 2026-09-17T12:00Z\nalways = \"kept\"\n\n[server]\nhost = \"h\"\n" if string(out) != want { t.Fatalf("output mismatch:\ngot: %q\nwant: %q", out, want) } @@ -1196,6 +1196,8 @@ qty = 2 [meta] created = 2026-06-26T10:00:00Z + +mixed = [1, {n = 1, name = "a value long enough to push this line well past the one hundred column limit"}] `) tree1, err := Parse(src) if err != nil { @@ -1258,24 +1260,35 @@ func TestLocalDateString(t *testing.T) { } func TestLocalDateTimeString(t *testing.T) { + // The rendering drops zero seconds and the trailing zeros of a fraction, + // which TOML 1.1 allows and which keeps a value written without seconds + // written without them. ldt := LocalDateTime{Time: time.Date(1979, 5, 27, 7, 32, 0, 0, time.UTC)} - if got := ldt.String(); got != "1979-05-27T07:32:00" { - t.Errorf("LocalDateTime.String() = %q, want 1979-05-27T07:32:00", got) + if got := ldt.String(); got != "1979-05-27T07:32" { + t.Errorf("LocalDateTime.String() = %q, want 1979-05-27T07:32", got) } ldt2 := LocalDateTime{Time: time.Date(1979, 5, 27, 7, 32, 0, 5, time.UTC)} if got := ldt2.String(); got != "1979-05-27T07:32:00.000000005" { t.Errorf("LocalDateTime.String() = %q, want 1979-05-27T07:32:00.000000005", got) } ldt3 := LocalDateTime{Time: time.Date(1979, 5, 27, 7, 32, 0, 500, time.UTC)} - if got := ldt3.String(); got != "1979-05-27T07:32:00.000000500" { - t.Errorf("LocalDateTime.String() = %q, want 1979-05-27T07:32:00.000000500", got) + if got := ldt3.String(); got != "1979-05-27T07:32:00.0000005" { + t.Errorf("LocalDateTime.String() = %q, want 1979-05-27T07:32:00.0000005", got) + } + ldt4 := LocalDateTime{Time: time.Date(1979, 5, 27, 7, 32, 30, 500000000, time.UTC)} + if got := ldt4.String(); got != "1979-05-27T07:32:30.5" { + t.Errorf("LocalDateTime.String() = %q, want 1979-05-27T07:32:30.5", got) } } func TestLocalTimeString(t *testing.T) { lt := LocalTime{Time: time.Date(0, 1, 1, 7, 32, 0, 0, time.UTC)} - if got := lt.String(); got != "07:32:00" { - t.Errorf("LocalTime.String() = %q, want 07:32:00", got) + if got := lt.String(); got != "07:32" { + t.Errorf("LocalTime.String() = %q, want 07:32", got) + } + lt2 := LocalTime{Time: time.Date(0, 1, 1, 7, 32, 15, 250000000, time.UTC)} + if got := lt2.String(); got != "07:32:15.25" { + t.Errorf("LocalTime.String() = %q, want 07:32:15.25", got) } } @@ -1490,7 +1503,7 @@ func TestMarshalTextLeavesDateTimesAlone(t *testing.T) { if err != nil { t.Fatalf("marshal: %v", err) } - want := "stamp = 2026-06-26T10:00:00Z\nptr = 2026-06-26T10:00:00Z\nday = 1979-05-27\nat = 1979-05-27T07:32:00\nclock = 07:32:00\n" + want := "stamp = 2026-06-26T10:00Z\nptr = 2026-06-26T10:00Z\nday = 1979-05-27\nat = 1979-05-27T07:32\nclock = 07:32\n" if string(out) != want { t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) } @@ -1572,3 +1585,91 @@ func TestMarshalTextValuesRoundTrip(t *testing.T) { t.Errorf("Tag = %q, want %q", back.Tag, in.Tag) } } + +// --- TOML 1.1 output forms ------------------------------------------------- + +func TestMarshalDateTimeRendering(t *testing.T) { + // The seconds are written only when the value carries them, and a fraction + // drops its trailing zeros. Both are the same value either way; the shorter + // form is the one TOML 1.1 allows. + base := time.Date(2026, 6, 26, 10, 0, 0, 0, time.UTC) + cases := []struct { + name string + val any + want string + }{ + {"offset-zero-seconds", base, "v = 2026-06-26T10:00Z\n"}, + {"offset-seconds", base.Add(30 * time.Second), "v = 2026-06-26T10:00:30Z\n"}, + {"offset-fraction", base.Add(500 * time.Millisecond), "v = 2026-06-26T10:00:00.5Z\n"}, + {"offset-zone", time.Date(2026, 6, 26, 10, 0, 0, 0, time.FixedZone("", -7*3600)), "v = 2026-06-26T10:00-07:00\n"}, + {"local-zero-seconds", LocalDateTime{Time: base}, "v = 2026-06-26T10:00\n"}, + {"local-fraction", LocalDateTime{Time: base.Add(2500 * time.Millisecond)}, "v = 2026-06-26T10:00:02.5\n"}, + {"date", LocalDate{Time: base}, "v = 2026-06-26\n"}, + {"time-zero-seconds", LocalTime{Time: base}, "v = 10:00\n"}, + {"time-seconds", LocalTime{Time: base.Add(15 * time.Second)}, "v = 10:00:15\n"}, + {"time-nanoseconds", LocalTime{Time: base.Add(123456789 * time.Nanosecond)}, "v = 10:00:00.123456789\n"}, + } + for _, c := range cases { + out, err := Marshal(map[string]any{"v": c.val}) + if err != nil { + t.Errorf("%s: marshal: %v", c.name, err) + continue + } + if string(out) != c.want { + t.Errorf("%s: output mismatch:\ngot: %q\nwant: %q", c.name, out, c.want) + } + } +} + +func TestMarshalInlineTableBreaksWhenLong(t *testing.T) { + // A table element of a value array is written inline; a long one carries + // newlines and a trailing comma instead of running past the line limit, + // which TOML 1.1 allows an inline table to do. + const long = "a-very-long-value-that-pushes-the-line-well-past-the-one-hundred-column-limit" + type Cfg struct { + Arr []any `toml:"arr"` + } + out, err := Marshal(Cfg{Arr: []any{int64(1), map[string]any{"n": int64(1), "name": long}}}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := "arr = [1, {\n\tn = 1,\n\tname = \"" + long + "\",\n}]\n" + if string(out) != want { + t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) + } + + // The same values without the long string stay on one line. + out, err = Marshal(Cfg{Arr: []any{int64(1), map[string]any{"n": int64(1), "name": "short"}}}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if want := "arr = [1, {n = 1, name = \"short\"}]\n"; string(out) != want { + t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) + } + + // The broken form parses back to the same tree. + tree, err := Parse(out) + if err != nil { + t.Fatalf("parse of the encoder output: %v", err) + } + if got := len(tree["arr"].([]any)); got != 2 { + t.Fatalf("arr has %d elements, want 2", got) + } +} + +func TestMarshalNestedInlineTableBreaksIndependently(t *testing.T) { + // A nested table breaks on its own measure, so a table whose entries stay + // short keeps the one-line form inside a parent that broke. + const long = "a-very-long-value-that-pushes-the-line-well-past-the-one-hundred-column-limit" + type Cfg struct { + Arr []any `toml:"arr"` + } + out, err := Marshal(Cfg{Arr: []any{int64(1), map[string]any{"n": int64(1), "sub": map[string]any{"name": long}}}}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := "arr = [1, {\n\tn = 1,\n\tsub = {name = \"" + long + "\"},\n}]\n" + if string(out) != want { + t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) + } +} diff --git a/interpres.go b/interpres.go index d4d4a29..0859975 100644 --- a/interpres.go +++ b/interpres.go @@ -232,7 +232,10 @@ type Unmarshaler interface { // inline table. // - Scalars encode as TOML scalars: bool, int64, float64, string, time.Time // (offset date-time), and LocalDateTime/LocalDate/LocalTime (local -// variants). +// variants). A date-time writes its seconds only when the value carries +// them, and drops the trailing zeros of a fractional second. +// - A table element of a value array is written as an inline table, across +// lines when it does not fit one. // - Values implementing Marshaler are encoded by calling MarshalTOML and // using its result. // - Values implementing encoding.TextMarshaler, and not one of the @@ -261,8 +264,8 @@ func MarshalContext(ctx context.Context, v any) ([]byte, error) { // An Encoder encodes Go values into TOML. // -// All options default to the behaviour earlier releases used, and the defaults -// pass the toml-test compliance suite in both directions: +// All options default to the behaviour that passes the toml-test compliance +// suite in both directions: // // GroupByKind: true (scalars first, then tables, then arrays of tables) // OmitEmptyArrays: false (a nil/empty []string slice emits [] as a value; diff --git a/interpres_test.go b/interpres_test.go index c7373fe..2026b7b 100644 --- a/interpres_test.go +++ b/interpres_test.go @@ -611,11 +611,13 @@ odt2 = 1979-05-27 07:32-07:00 if err != nil { t.Fatalf("parse: %v", err) } - if got := tree["t"].(LocalTime).String(); got != "13:37:00" { - t.Errorf("t = %q, want %q", got, "13:37:00") + // A value written without seconds comes back without them: the seconds are + // only written when the value carries them. + if got := tree["t"].(LocalTime).String(); got != "13:37" { + t.Errorf("t = %q, want %q", got, "13:37") } - if got := tree["dt"].(LocalDateTime).String(); got != "1979-05-27T07:32:00" { - t.Errorf("dt = %q, want %q", got, "1979-05-27T07:32:00") + if got := tree["dt"].(LocalDateTime).String(); got != "1979-05-27T07:32" { + t.Errorf("dt = %q, want %q", got, "1979-05-27T07:32") } if got := tree["odt1"].(time.Time).Format(time.RFC3339Nano); got != "1979-05-27T07:32:00Z" { t.Errorf("odt1 = %q", got)