449 lines
15 KiB
Go
449 lines
15 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"sourcedock.dev/petrbalvin/interpres/v2"
|
|
)
|
|
|
|
func TestRunParsesValidTOML(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
in := bytes.NewReader([]byte(`name = "x"
|
|
port = 8080
|
|
enabled = true
|
|
`))
|
|
if code := Run(nil, in, &stdout, &stderr); code != 0 {
|
|
t.Fatalf("Run returned %d, stderr = %q", code, stderr.String())
|
|
}
|
|
var got map[string]any
|
|
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
|
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
|
|
}
|
|
if name := got["name"].(map[string]any); name["type"] != "string" || name["value"] != "x" {
|
|
t.Errorf("name = %#v", got["name"])
|
|
}
|
|
if port := got["port"].(map[string]any); port["type"] != "integer" || port["value"] != "8080" {
|
|
t.Errorf("port = %#v", got["port"])
|
|
}
|
|
if en := got["enabled"].(map[string]any); en["type"] != "bool" || en["value"] != "true" {
|
|
t.Errorf("enabled = %#v", got["enabled"])
|
|
}
|
|
}
|
|
|
|
func TestRunRejectsInvalidInput(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
in := bytes.NewReader([]byte("v = \n"))
|
|
code := Run(nil, in, &stdout, &stderr)
|
|
if code != 1 {
|
|
t.Errorf("Run returned %d, want 1 (parse error); stderr = %q", code, stderr.String())
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Errorf("stdout should be empty on parse error, got %q", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestRunReadErrorReturnsTwo(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
code := Run(nil, errorReader{}, &stdout, &stderr)
|
|
if code != 2 {
|
|
t.Errorf("Run returned %d, want 2 (read error); stderr = %q", code, stderr.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "read stdin") {
|
|
t.Errorf("stderr = %q, want it to mention 'read stdin'", stderr.String())
|
|
}
|
|
}
|
|
|
|
// errorReader is an io.Reader that always reports an error.
|
|
type errorReader struct{}
|
|
|
|
func (errorReader) Read(_ []byte) (int, error) { return 0, errors.New("boom") }
|
|
|
|
func TestRunEncodeErrorReturnsTwo(t *testing.T) {
|
|
var stderr bytes.Buffer
|
|
w := errorWriter{}
|
|
in := bytes.NewReader([]byte(`k = "v"` + "\n"))
|
|
code := Run(nil, in, w, &stderr)
|
|
if code != 2 {
|
|
t.Errorf("Run returned %d, want 2 (encode error); stderr = %q", code, stderr.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "encode") {
|
|
t.Errorf("stderr = %q, want it to mention 'encode'", stderr.String())
|
|
}
|
|
}
|
|
|
|
// errorWriter is an io.Writer that always reports an error.
|
|
type errorWriter struct{}
|
|
|
|
func (errorWriter) Write(_ []byte) (int, error) { return 0, errors.New("boom") }
|
|
|
|
func TestTagHandlesAllScalarTypes(t *testing.T) {
|
|
now := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
ldt := interpres.LocalDateTime{Time: now}
|
|
ld := interpres.LocalDate{Time: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)}
|
|
lt := interpres.LocalTime{Time: time.Date(0, 1, 1, 3, 4, 5, 0, time.UTC)}
|
|
|
|
cases := []struct {
|
|
name string
|
|
in any
|
|
want map[string]string
|
|
}{
|
|
{"string", "x", map[string]string{"type": "string", "value": "x"}},
|
|
{"bool", true, map[string]string{"type": "bool", "value": "true"}},
|
|
{"bool-false", false, map[string]string{"type": "bool", "value": "false"}},
|
|
{"int64", int64(42), map[string]string{"type": "integer", "value": "42"}},
|
|
{"float", 1.5, map[string]string{"type": "float", "value": "1.5"}},
|
|
{"time", now, map[string]string{"type": "datetime", "value": "2026-01-02T03:04:05Z"}},
|
|
{"ldt", ldt, map[string]string{"type": "datetime-local", "value": "2026-01-02T03:04:05"}},
|
|
{"ld", ld, map[string]string{"type": "date-local", "value": "2026-01-02"}},
|
|
{"lt", lt, map[string]string{"type": "time-local", "value": "03:04:05"}},
|
|
}
|
|
for _, c := range cases {
|
|
got, err := tag(c.in)
|
|
if err != nil {
|
|
t.Errorf("%s: tag returned error %v", c.name, err)
|
|
continue
|
|
}
|
|
m, ok := got.(map[string]string)
|
|
if !ok {
|
|
t.Errorf("%s: tag returned %T, want map[string]string", c.name, got)
|
|
continue
|
|
}
|
|
if m["type"] != c.want["type"] || m["value"] != c.want["value"] {
|
|
t.Errorf("%s: tag = %#v, want %#v", c.name, m, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTagRejectsUnsupportedType(t *testing.T) {
|
|
// Channels are not produced by the parser; tag must reject them explicitly.
|
|
var ch chan int
|
|
got, err := tag(ch)
|
|
if err == nil {
|
|
t.Fatalf("expected error for chan int, got value %#v", got)
|
|
}
|
|
if !strings.Contains(err.Error(), "unsupported") {
|
|
t.Errorf("err = %v, want substring 'unsupported'", err)
|
|
}
|
|
if got != nil {
|
|
t.Errorf("got = %#v, want nil", got)
|
|
}
|
|
|
|
// The same applies when an unsupported type is nested inside a table.
|
|
got, err = tag(map[string]any{"x": ch})
|
|
if err == nil {
|
|
t.Fatalf("expected error for nested chan int, got %#v", got)
|
|
}
|
|
}
|
|
|
|
// Exercise the table, array-of-tables, and array shapes so all of tag's
|
|
// switch arms are visited.
|
|
func TestTagHandlesContainers(t *testing.T) {
|
|
tbl := map[string]any{
|
|
"name": "x",
|
|
"items": []any{
|
|
int64(1), int64(2),
|
|
},
|
|
}
|
|
got, err := tag(tbl)
|
|
if err != nil {
|
|
t.Fatalf("tag(tbl) returned error: %v", err)
|
|
}
|
|
m, ok := got.(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("tag returned %T, want map", got)
|
|
}
|
|
name, ok := m["name"].(map[string]string)
|
|
if !ok || name["value"] != "x" {
|
|
t.Errorf("name = %#v", m["name"])
|
|
}
|
|
items, ok := m["items"].([]any)
|
|
if !ok {
|
|
t.Fatalf("items = %T, want []any", m["items"])
|
|
}
|
|
if len(items) != 2 {
|
|
t.Fatalf("len(items) = %d, want 2", len(items))
|
|
}
|
|
for _, raw := range items {
|
|
if _, ok := raw.(map[string]string); !ok {
|
|
t.Errorf("item = %T, want map[string]string", raw)
|
|
}
|
|
}
|
|
|
|
// Direct tests of the array branches.
|
|
if got, err := tag([]any{int64(1)}); err != nil {
|
|
t.Errorf("tag([]any) returned error: %v", err)
|
|
} else if len(got.([]any)) != 1 {
|
|
t.Errorf("[]any tag = %#v", got)
|
|
}
|
|
if got, err := tag([]map[string]any{{"k": "v"}}); err != nil {
|
|
t.Errorf("tag([]map) returned error: %v", err)
|
|
} else if len(got.([]any)) != 1 {
|
|
t.Errorf("[]map[string]any tag = %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestFormatFloatAllBranches(t *testing.T) {
|
|
cases := map[float64]string{
|
|
1.5: "1.5",
|
|
0: "0",
|
|
1e10: "1e+10",
|
|
-2.5e-3: "-0.0025",
|
|
}
|
|
for in, want := range cases {
|
|
if got := formatFloat(in); got != want {
|
|
t.Errorf("formatFloat(%v) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTaggedHelper(t *testing.T) {
|
|
got := tagged("string", "x")
|
|
if got["type"] != "string" || got["value"] != "x" {
|
|
t.Errorf("tagged = %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestValidateStdinAcceptsValidDocument(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
in := bytes.NewReader([]byte("title = \"ok\"\n"))
|
|
if code := Run([]string{"-validate"}, in, &stdout, &stderr); code != 0 {
|
|
t.Fatalf("Run returned %d, stderr = %q", code, stderr.String())
|
|
}
|
|
if stdout.Len() != 0 || stderr.Len() != 0 {
|
|
t.Fatalf("validate should be quiet on success, stdout %q stderr %q", stdout.String(), stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestValidateStdinRejectsInvalidDocument(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
in := bytes.NewReader([]byte("title = \"unterminated\n"))
|
|
if code := Run([]string{"-validate"}, in, &stdout, &stderr); code != 1 {
|
|
t.Fatalf("Run returned %d, want 1; stderr = %q", code, stderr.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "<stdin>") || !strings.Contains(stderr.String(), "line 1") {
|
|
t.Fatalf("stderr = %q, want the name and the line", stderr.String())
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("stdout should stay empty, got %q", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestValidateFiles(t *testing.T) {
|
|
dir := t.TempDir()
|
|
good := dir + "/good.toml"
|
|
bad := dir + "/bad.toml"
|
|
if err := os.WriteFile(good, []byte("a = 1\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(bad, []byte("a =\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var stdout, stderr bytes.Buffer
|
|
if code := Run([]string{"-validate", good}, nil, &stdout, &stderr); code != 0 {
|
|
t.Fatalf("one valid file: Run returned %d, stderr = %q", code, stderr.String())
|
|
}
|
|
if code := Run([]string{"-validate", good, bad}, nil, &stdout, &stderr); code != 1 {
|
|
t.Fatalf("valid plus invalid: Run returned %d, want 1; stderr = %q", code, stderr.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), bad) || !strings.Contains(stderr.String(), "line 1") {
|
|
t.Fatalf("stderr = %q, want the file name and the line", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestValidateMissingFileReturnsTwo(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
if code := Run([]string{"-validate", "no-such-file.toml"}, nil, &stdout, &stderr); code != 2 {
|
|
t.Fatalf("Run returned %d, want 2; stderr = %q", code, stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestAdapterModeRejectsPositionalArgument(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
in := bytes.NewReader([]byte("a = 1\n"))
|
|
if code := Run([]string{"file.toml"}, in, &stdout, &stderr); code != 2 {
|
|
t.Fatalf("Run returned %d, want 2; stderr = %q", code, stderr.String())
|
|
}
|
|
if !strings.Contains(stderr.String(), "-validate") {
|
|
t.Fatalf("stderr = %q, want it to point at -validate", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestUnknownFlagReturnsTwo(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
if code := Run([]string{"-nope"}, nil, &stdout, &stderr); code != 2 {
|
|
t.Fatalf("Run returned %d, want 2; stderr = %q", code, stderr.String())
|
|
}
|
|
}
|
|
|
|
// --- encoder mode ----------------------------------------------------------
|
|
|
|
func TestRunEncoderScalars(t *testing.T) {
|
|
in := `{
|
|
"s": {"type": "string", "value": "quote \" and backslash \\"},
|
|
"nl": {"type": "string", "value": "line1\nline2"},
|
|
"i": {"type": "integer", "value": "-9223372036854775808"},
|
|
"g": {"type": "float", "value": "1.5"},
|
|
"f": {"type": "float", "value": "inf"},
|
|
"b": {"type": "bool", "value": "false"},
|
|
"dt": {"type": "datetime", "value": "1979-05-27T07:32:00-07:00"},
|
|
"ldt": {"type": "datetime-local", "value": "1979-05-27T07:32:00"},
|
|
"ld": {"type": "date-local", "value": "1979-05-27"},
|
|
"lt": {"type": "time-local", "value": "07:32:00.999"}
|
|
}
|
|
`
|
|
var stdout, stderr bytes.Buffer
|
|
code := Run([]string{"-encode"}, strings.NewReader(in), &stdout, &stderr)
|
|
if code != 0 {
|
|
t.Fatalf("Run returned %d, stderr = %q", code, stderr.String())
|
|
}
|
|
want := "b = false\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\n" +
|
|
"lt = 07:32:00.999\n" +
|
|
"nl = \"line1\\nline2\"\n" +
|
|
"s = \"quote \\\" and backslash \\\\\"\n"
|
|
if stdout.String() != want {
|
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", stdout.String(), want)
|
|
}
|
|
}
|
|
|
|
func TestRunEncoderNested(t *testing.T) {
|
|
in := `{
|
|
"tbl": {"x": {"type": "bool", "value": "true"},
|
|
"sub": {"y": {"type": "integer", "value": "1"}}},
|
|
"items": [{"n": {"type": "string", "value": "a"}},
|
|
{"n": {"type": "string", "value": "b"}}],
|
|
"list": [{"type": "integer", "value": "1"}, {"type": "string", "value": "two"}],
|
|
"emptyTbl": {},
|
|
"emptyArr": []
|
|
}
|
|
`
|
|
var stdout, stderr bytes.Buffer
|
|
code := Run([]string{"-encode"}, strings.NewReader(in), &stdout, &stderr)
|
|
if code != 0 {
|
|
t.Fatalf("Run returned %d, stderr = %q", code, stderr.String())
|
|
}
|
|
want := "emptyArr = []\n" +
|
|
"list = [1, \"two\"]\n" +
|
|
"\n[emptyTbl]\n" +
|
|
"\n[tbl]\nx = true\n" +
|
|
"\n[tbl.sub]\ny = 1\n" +
|
|
"\n[[items]]\nn = \"a\"\n" +
|
|
"\n[[items]]\nn = \"b\"\n"
|
|
if stdout.String() != want {
|
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", stdout.String(), want)
|
|
}
|
|
}
|
|
|
|
func TestRunEncoderFloatTagDecides(t *testing.T) {
|
|
// A float with no fraction is described by a bare integer literal, so the
|
|
// tag decides the type; the output must stay a float.
|
|
var stdout, stderr bytes.Buffer
|
|
in := `{"whole": {"type": "float", "value": "1"}, "exp": {"type": "float", "value": "5e+22"}}`
|
|
code := Run([]string{"-encode"}, strings.NewReader(in), &stdout, &stderr)
|
|
if code != 0 {
|
|
t.Fatalf("Run returned %d, stderr = %q", code, stderr.String())
|
|
}
|
|
if want := "exp = 5e+22\nwhole = 1.0\n"; stdout.String() != want {
|
|
t.Errorf("output mismatch:\ngot: %q\nwant: %q", stdout.String(), want)
|
|
}
|
|
}
|
|
|
|
func TestRunEncoderRejectsBadInput(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in string
|
|
want string
|
|
}{
|
|
{"not-json", "not json", "decode JSON"},
|
|
{"top-level-array", `[{"type": "integer", "value": "1"}]`, "must be a JSON object"},
|
|
{"untagged-scalar", `{"x": 1}`, "unsupported JSON value"},
|
|
{"literal-mismatch", `{"x": {"type": "integer", "value": "1.5"}}`, "parsed as float64"},
|
|
{"offset-for-local", `{"x": {"type": "datetime-local", "value": "1979-05-27T07:32:00Z"}}`, "parsed as interpres.OffsetDateTime"},
|
|
{"bad-literal", `{"x": {"type": "date-local", "value": "nope"}}`, "date-local"},
|
|
{"smuggled-statement", `{"x": {"type": "integer", "value": "1\nx = 2"}}`, "not a single bare value"},
|
|
}
|
|
for _, c := range cases {
|
|
var stdout, stderr bytes.Buffer
|
|
code := Run([]string{"-encode"}, strings.NewReader(c.in), &stdout, &stderr)
|
|
if code != 2 {
|
|
t.Errorf("%s: Run returned %d, want 2; stderr = %q", c.name, code, stderr.String())
|
|
continue
|
|
}
|
|
if !strings.Contains(stderr.String(), c.want) {
|
|
t.Errorf("%s: stderr = %q, want it to mention %q", c.name, stderr.String(), c.want)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Errorf("%s: stdout should be empty, got %q", c.name, stdout.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunEncoderFlagConflicts(t *testing.T) {
|
|
var stdout, stderr bytes.Buffer
|
|
if code := Run([]string{"-encode", "-validate"}, strings.NewReader(""), &stdout, &stderr); code != 2 {
|
|
t.Errorf("Run returned %d, want 2 for the two modes together", code)
|
|
}
|
|
if !strings.Contains(stderr.String(), "cannot be combined") {
|
|
t.Errorf("stderr = %q, want it to explain the conflict", stderr.String())
|
|
}
|
|
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
if code := Run([]string{"-encode", "file.json"}, strings.NewReader(""), &stdout, &stderr); code != 2 {
|
|
t.Errorf("Run returned %d, want 2 for an argument", code)
|
|
}
|
|
}
|
|
|
|
func TestEncodeAfterDecodeRoundTrip(t *testing.T) {
|
|
doc := `title = "x"
|
|
flt = 1.5
|
|
whole = 7.0
|
|
big = 9223372036854775807
|
|
when = 1979-05-27T07:32:00-07:00
|
|
day = 1979-05-27
|
|
clock = 07:32:00.999
|
|
list = [1, "two"]
|
|
multi = "a\nb"
|
|
|
|
[tbl]
|
|
x = true
|
|
|
|
[[items]]
|
|
n = "a"
|
|
`
|
|
var tagged, stderr bytes.Buffer
|
|
if code := Run(nil, strings.NewReader(doc), &tagged, &stderr); code != 0 {
|
|
t.Fatalf("decode returned %d, stderr = %q", code, stderr.String())
|
|
}
|
|
var out bytes.Buffer
|
|
if code := Run([]string{"-encode"}, bytes.NewReader(tagged.Bytes()), &out, &stderr); code != 0 {
|
|
t.Fatalf("encode returned %d, stderr = %q", code, stderr.String())
|
|
}
|
|
want, err := interpres.Parse([]byte(doc))
|
|
if err != nil {
|
|
t.Fatalf("parse of the original: %v", err)
|
|
}
|
|
got, err := interpres.Parse(out.Bytes())
|
|
if err != nil {
|
|
t.Fatalf("parse of the encoder output (%q): %v", out.String(), err)
|
|
}
|
|
if !reflect.DeepEqual(want, got) {
|
|
t.Errorf("round trip changed the document:\noriginal: %#v\nencoded: %#v\noutput: %q", want, got, out.String())
|
|
}
|
|
}
|