From 83e4daefdb42298014054cc720f401f298aee714 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 5 Oct 2026 15:26:52 +0200 Subject: [PATCH] Test internal/datafiles, internal/datatype, internal/proven and internal/builtinfunc, and allow the root to import them --- builtins_test.go | 38 ------ calc_test.go | 6 - imports_test.go | 12 +- internal/builtinfunc/builtinfunc_test.go | 80 +++++++++++++ internal/datafiles/datafiles_test.go | 143 +++++++++++++++++++++++ internal/datatype/datatype_test.go | 25 ++++ internal/proven/proven_test.go | 88 ++++++++++++++ 7 files changed, 344 insertions(+), 48 deletions(-) create mode 100644 internal/builtinfunc/builtinfunc_test.go create mode 100644 internal/datafiles/datafiles_test.go create mode 100644 internal/datatype/datatype_test.go create mode 100644 internal/proven/proven_test.go diff --git a/builtins_test.go b/builtins_test.go index 4ca623f..4d2408c 100644 --- a/builtins_test.go +++ b/builtins_test.go @@ -4,13 +4,11 @@ import ( "encoding/base64" "fmt" "regexp" - "slices" "strconv" "strings" "testing" "time" - "github.com/larvit/fejkdata/internal/grammar" ) func TestBuiltinIDGenerators(t *testing.T) { @@ -191,31 +189,6 @@ func ibanValid(s string) bool { return rem == 1 } -// TestRegistryShapes pins the builtin contract compileFormat relies on: every entry -// supplies prep, and args parsed at compile only behind a check. -func TestRegistryShapes(t *testing.T) { - for name, b := range builtins { - if b.prep == nil { - t.Errorf("builtin %q has no prep: compileFormat would call a nil func", name) - } - if b.arity != 0 && b.checkArgs == nil { - t.Errorf("builtin %q parses args in prep with no check", name) - } - } -} - -// TestArgGuardsPanic pins the guards that report a builtin arg its check should -// have rejected. No data reaches them — checkFunc runs a builtin's check before -// compileFormat ever calls prep — so they are exercised directly. -func TestArgGuardsPanic(t *testing.T) { - for name, call := range map[string]func(){ - "atoi on an unvalidated arg": func() { atoi("nope") }, - "atof on an unvalidated arg": func() { atof("nope") }, - } { - mustPanic(t, name, call) - } -} - // mustPanic fails unless call panics with internalError's phrase, which is what // separates a reported invariant break from a silently wrong value. func mustPanic(t *testing.T, name string, call func()) { @@ -450,14 +423,3 @@ func TestArgErrorsNameTheSpelling(t *testing.T) { } } } - -func TestNoRefOperandsMatchesTheOperandGrammar(t *testing.T) { - for name, b := range builtins { - if b.operands == nil { - continue - } - if takesRef := slices.ContainsFunc(b.operands([]string{"/x"}), grammar.IsRef); b.noRefOperands == takesRef { - t.Errorf("%s: noRefOperands = %v, but its operands func reads a reference: %v", name, b.noRefOperands, takesRef) - } - } -} diff --git a/calc_test.go b/calc_test.go index 73b4020..c50be75 100644 --- a/calc_test.go +++ b/calc_test.go @@ -113,12 +113,6 @@ func TestParsedCallReadsOnlyAnOperandBuiltin(t *testing.T) { } } -// TestCalcPrepPanicsOnAnUncheckedExpression pins the guard on calc's own invariant: -// checkCalc parsed the expression before prep sees it. -func TestCalcPrepPanicsOnAnUncheckedExpression(t *testing.T) { - mustPanic(t, "prep on an expression that does not parse", func() { calcPrep([]string{"1 +"}) }) -} - // --- one draw, one value: a calc operand reads the expansion's draw --- // TestANamedCalcOperandIsTheOneShown pins how a calc computes from the value its format diff --git a/imports_test.go b/imports_test.go index 6b6bea8..32fe9e7 100644 --- a/imports_test.go +++ b/imports_test.go @@ -29,10 +29,14 @@ func TestPackageImports(t *testing.T) { } checked := 0 allowed := map[string][]string{ - ".": {"internal/drawstate", "internal/grammar"}, - "cmd/fejkdata": {"."}, - "internal/drawstate": nil, - "internal/grammar": nil, + ".": {"internal/builtinfunc", "internal/datafiles", "internal/datatype", "internal/drawstate", "internal/grammar", "internal/proven"}, + "cmd/fejkdata": {"."}, + "internal/builtinfunc": {"internal/datatype", "internal/drawstate", "internal/grammar", "internal/proven"}, + "internal/datafiles": {"internal/grammar"}, + "internal/datatype": nil, + "internal/drawstate": nil, + "internal/grammar": nil, + "internal/proven": {"internal/datatype", "internal/grammar"}, } seen := map[string]bool{} err = filepath.WalkDir(".", func(dir string, d fs.DirEntry, err error) error { diff --git a/internal/builtinfunc/builtinfunc_test.go b/internal/builtinfunc/builtinfunc_test.go new file mode 100644 index 0000000..140b0f1 --- /dev/null +++ b/internal/builtinfunc/builtinfunc_test.go @@ -0,0 +1,80 @@ +package builtinfunc + +import ( + "fmt" + "slices" + "strings" + "testing" + + "github.com/larvit/fejkdata/internal/grammar" +) + +// TestRegistryShapes pins the builtin contract Prep relies on: every entry supplies prep, +// and args parsed at compile only behind a check. +func TestRegistryShapes(t *testing.T) { + for name, b := range builtins { + if b.prep == nil { + t.Errorf("builtin %q has no prep: Prep would call a nil func", name) + } + if b.arity != 0 && b.checkArgs == nil { + t.Errorf("builtin %q parses args in prep with no check", name) + } + } +} + +func TestNoRefOperandsMatchesTheOperandGrammar(t *testing.T) { + for name, b := range builtins { + if b.operands == nil { + continue + } + if takesRef := slices.ContainsFunc(b.operands([]string{"/x"}), grammar.IsRef); b.noRefOperands == takesRef { + t.Errorf("%s: noRefOperands = %v, but its operands func reads a reference: %v", name, b.noRefOperands, takesRef) + } + } +} + +func TestCheckNamesWhatACallGetsWrong(t *testing.T) { + for _, c := range []struct { + name string + args []string + want string + }{ + {"nope", nil, `unknown function "nope"`}, + {"luhn", []string{"1"}, "luhn takes 0 arguments, got 1"}, + {"hex", nil, "hex takes 1 argument, got 0"}, + {"calc", []string{"a +"}, `calc("a +"): `}, + {"calc", []string{"a", "-1"}, "calc decimals "}, + {"calc", nil, "calc takes an expression and an optional decimals count, got 0 args"}, + } { + if err := Check(c.name, c.args); err == nil || !strings.HasPrefix(err.Error(), c.want) { + t.Errorf("Check(%s, %q) = %v, want it to start %q", c.name, c.args, err, c.want) + } + } + if err := Check("calc", []string{"a / b", "2"}); err != nil { + t.Errorf("Check(calc) = %v, want an expression over any operand accepted", err) + } +} + +// TestArgGuardsPanic pins the guards that report a builtin arg its check should have +// rejected. No data reaches them, since Check runs before Prep, so they are called directly. +func TestArgGuardsPanic(t *testing.T) { + for name, call := range map[string]func(){ + "atoi on an unvalidated arg": func() { atoi("nope") }, + "atof on an unvalidated arg": func() { atof("nope") }, + "prep on an expression that won't parse": func() { calcPrep([]string{"1 +"}) }, + } { + mustPanic(t, name, call) + } +} + +// mustPanic fails unless call panics with internalError's phrase, which is what +// separates a reported invariant break from a silently wrong value. +func mustPanic(t *testing.T, name string, call func()) { + t.Helper() + defer func() { + if r := recover(); r == nil || !strings.HasPrefix(fmt.Sprint(r), "fejkdata: internal error: ") { + t.Errorf("%s: recovered %v, want the invariant reported as an internal error", name, r) + } + }() + call() +} diff --git a/internal/datafiles/datafiles_test.go b/internal/datafiles/datafiles_test.go new file mode 100644 index 0000000..ad1bf9f --- /dev/null +++ b/internal/datafiles/datafiles_test.go @@ -0,0 +1,143 @@ +package datafiles + +import ( + "errors" + "path/filepath" + "reflect" + "strings" + "testing" + "testing/fstest" +) + +type handed struct { + dir []string + name string + json any +} + +func walk(t *testing.T, src Source) ([]handed, error) { + t.Helper() + var got []handed + err := src.Walk(func(c Category) error { + got = append(got, handed{c.Dir, c.Name, c.JSON}) + return nil + }) + return got, err +} + +func TestWalkHandsEachCategoryInOrder(t *testing.T) { + got, err := walk(t, Source{FS: fstest.MapFS{ + "a.json": {Data: []byte(`{"x":"1"}`)}, + "sub/b.json": {Data: []byte(`"y"`)}, + "sub/notes.txt": {Data: []byte("not data")}, + "empty/x.txt": {Data: []byte("not data")}, + ".git/c.json": {Data: []byte(`"hidden"`)}, + "sub/.d.json": {Data: []byte(`"hidden"`)}, + "sub/deep/e.json": {Data: []byte(`"z"`)}, + }}) + if err != nil { + t.Fatal(err) + } + want := []handed{ + {nil, "a", map[string]any{"x": "1"}}, + {[]string{"sub"}, "b", "y"}, + {[]string{"sub", "deep"}, "e", "z"}, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("Walk handed %#v, want %#v", got, want) + } +} + +func TestWalkStartsAtTheBaseDir(t *testing.T) { + got, err := walk(t, Source{FS: fstest.MapFS{"data/a.json": {Data: []byte(`"x"`)}, "b.json": {Data: []byte(`"y"`)}}, BaseDir: "data"}) + if err != nil || len(got) != 1 || got[0].name != "a" || got[0].dir != nil { + t.Errorf("Walk = %#v, %v, want a alone at the base", got, err) + } +} + +func TestReadRowsReadsOnlyAFileBesideTheCategory(t *testing.T) { + src := Source{FS: fstest.MapFS{ + "t.json": {Data: []byte(`{"rows":"t.tsv"}`)}, + "t.tsv": {Data: []byte("key\nA\n")}, + "sub/u.tsv": {Data: []byte("key\nB\n")}, + "sub/u.json": {Data: []byte(`{"rows":"u.tsv"}`)}, + }, Label: "lbl"} + rows := map[string]string{} + err := src.Walk(func(c Category) error { + data, err := c.ReadRows(c.Name + ".tsv") + rows[c.Name] = data + if err != nil { + return err + } + if _, err := c.ReadRows("x.tsv"); err == nil || err.Error() != "rows names x.tsv, which is not beside it in "+map[string]string{"t": "lbl", "u": "lbl/sub"}[c.Name] { + t.Errorf("ReadRows(x.tsv) = %v, want the file named missing beside %s", err, c.Name) + } + return nil + }) + if err != nil || rows["t"] != "key\nA\n" || rows["u"] != "key\nB\n" { + t.Errorf("Walk = %v, read %q", err, rows) + } +} + +func TestWalkRefusesARowsFileNoCategoryNames(t *testing.T) { + _, err := walk(t, Source{FS: fstest.MapFS{"a.json": {Data: []byte(`"x"`)}, "a.tsv": {Data: []byte("key\n")}}, Label: "lbl"}) + if err == nil || !strings.HasPrefix(err.Error(), "lbl/a.tsv: no category names it in its rows") { + t.Errorf("Walk = %v, want a.tsv refused as named by no category", err) + } +} + +func TestWalkLabelsWhatACategoryFileGetsWrong(t *testing.T) { + for _, c := range []struct { + name string + fs fstest.MapFS + want string + }{ + {"its compile", fstest.MapFS{"a.json": {Data: []byte(`"x"`)}}, "lbl/a.json: bad"}, + {"its JSON", fstest.MapFS{"a.json": {Data: []byte(`{`)}}, "lbl/a.json: unexpected end of JSON input"}, + {"its name", fstest.MapFS{"a b.json": {Data: []byte(`"x"`)}}, "lbl/a b.json: category "}, + {"its folder's name", fstest.MapFS{"a b/c.json": {Data: []byte(`"x"`)}}, "lbl/a b: folder "}, + } { + err := Source{FS: c.fs, Label: "lbl"}.Walk(func(Category) error { return errors.New("bad") }) + if err == nil || !strings.HasPrefix(err.Error(), c.want) { + t.Errorf("%s: Walk = %v, want it to start %q", c.name, err, c.want) + } + } +} + +func TestWalkChecksAFolderNameOnlyWhenItHoldsData(t *testing.T) { + if _, err := walk(t, Source{FS: fstest.MapFS{"a b/notes.txt": {Data: []byte("x")}, "c.json": {Data: []byte(`"x"`)}}}); err != nil { + t.Errorf("Walk = %v, want a folder holding no data skipped whatever its name", err) + } +} + +func TestWalkRefusesADiskPathThatIsNoDirectory(t *testing.T) { + dir := t.TempDir() + for _, c := range []struct{ path, want string }{ + {"", "a data path is empty"}, + {filepath.Join(dir, "none"), "data path " + filepath.Join(dir, "none") + ": "}, + } { + _, err := walk(t, Source{FS: fstest.MapFS{}, Label: c.path, OnDisk: true}) + if err == nil || !strings.HasPrefix(err.Error(), c.want) { + t.Errorf("Walk(%q) = %v, want it to start %q", c.path, err, c.want) + } + } +} + +func TestLoadHandsOneCategoryAndItsRows(t *testing.T) { + src := Source{FS: fstest.MapFS{ + "data/sub/t.json": {Data: []byte(`{"rows":"t.tsv"}`)}, + "data/sub/t.tsv": {Data: []byte("key\nA\n")}, + "data/sub/u.json": {Data: []byte(`{`)}, + }, BaseDir: "data"} + var got handed + var rows string + err := src.Load([]string{"sub"}, "t", func(c Category) error { + got = handed{c.Dir, c.Name, c.JSON} + var err error + rows, err = c.ReadRows("t.tsv") + return err + }) + if err != nil || got.name != "t" || !reflect.DeepEqual(got.dir, []string{"sub"}) || rows != "key\nA\n" { + t.Errorf("Load = %v, handed %#v with rows %q", err, got, rows) + } +} diff --git a/internal/datatype/datatype_test.go b/internal/datatype/datatype_test.go new file mode 100644 index 0000000..ec1d1f3 --- /dev/null +++ b/internal/datatype/datatype_test.go @@ -0,0 +1,25 @@ +package datatype + +import "testing" + +func TestEachDatatypeIsSpelledAsDataWritesIt(t *testing.T) { + for d, want := range map[DataType][2]string{ + String: {"string", "text"}, + Integer: {"integer", "an integer"}, + Number: {"number", "a number"}, + Boolean: {"boolean", "a boolean"}, + } { + if d.String() != want[0] || Noun(d) != want[1] { + t.Errorf("%d: String = %q, Noun = %q, want %q", int(d), d.String(), Noun(d), want) + } + } + if Count != 4 { + t.Errorf("Count = %d, want the four datatypes", Count) + } +} + +func TestStringNamesADatatypeNoDataWrites(t *testing.T) { + if got := DataType(9).String(); got != "DataType(9)" { + t.Errorf("String = %q, want DataType(9)", got) + } +} diff --git a/internal/proven/proven_test.go b/internal/proven/proven_test.go new file mode 100644 index 0000000..db75ece --- /dev/null +++ b/internal/proven/proven_test.go @@ -0,0 +1,88 @@ +package proven + +import ( + "math" + "testing" + + "github.com/larvit/fejkdata/internal/datatype" + "github.com/larvit/fejkdata/internal/grammar" +) + +func TestLiteralSaysWhichDatatypesItsTextIs(t *testing.T) { + for _, c := range []struct { + text string + not [datatype.Count]bool + }{ + {"42", [datatype.Count]bool{datatype.Boolean: true}}, + {"1.5", [datatype.Count]bool{datatype.Integer: true, datatype.Boolean: true}}, + {"true", [datatype.Count]bool{datatype.Integer: true, datatype.Number: true}}, + {"007", [datatype.Count]bool{datatype.Integer: true, datatype.Number: true, datatype.Boolean: true}}, + {"-0", [datatype.Count]bool{datatype.Integer: true, datatype.Number: true, datatype.Boolean: true}}, + } { + v := Literal(c.text) + for d := datatype.Integer; d <= datatype.Boolean; d++ { + if (v.Not[d] != "") != c.not[d] { + t.Errorf("Literal(%q).Not[%s] = %q", c.text, d, v.Not[d]) + } + } + } + if v := Literal("-0"); v.Not[datatype.Integer] != `"-0" is zero written with a sign; write "0"` { + t.Errorf("Literal(-0) = %q, want the unsigned spelling named", v.Not[datatype.Integer]) + } +} + +func TestOrKeepsTheBoundsOfBothAndTheFirstReason(t *testing.T) { + v := Bounded(1, 2, true).Or(Bounded(-3, 0.5, false)) + if v.Lo != -3 || v.Hi != 2 || v.Integral || v.NonZero != 0 { + t.Errorf("Or = %+v, want [-3, 2], not integral, possibly zero", v) + } + if v := Unproven("first").Or(Unproven("second")); v.NotOperand != "first" || v.Not[datatype.Number] != "first" { + t.Errorf("Or = %+v, want the first reason kept", v) + } +} + +func TestCalcBoundsAnExpressionFromItsOperands(t *testing.T) { + operand := func(name string) Value { + return map[string]Value{"a": Bounded(1, 9, true), "b": Bounded(0, 9, true), "big": Bounded(0, 1e299, true), "word": Unproven("it is text")}[name] + } + for expr, want := range map[string]string{ + "a * 2 + 1": "", + "a / b": "divides by b, which is not proven nonzero", + "big * 100": "(big * 100) is not proven within 1e300", + "big": "", + "word + 1": `operand "word": it is text`, + "-(a - 10)": "", + } { + c, err := grammar.ParseCalc(expr) + if err != nil { + t.Fatal(err) + } + v, doubt := Calc(c.Expr, operand) + if doubt != want { + t.Errorf("Calc(%s) doubts %q, want %q", expr, doubt, want) + } + if expr == "a * 2 + 1" && (v.Lo != 3 || v.Hi != 19 || !v.Integral || v.NonZero != 3) { + t.Errorf("Calc(%s) = %+v, want [3, 19], integral, nonzero by 3", expr, v) + } + if expr == "-(a - 10)" && (v.Lo != 1 || v.Hi != 9) { + t.Errorf("Calc(%s) = %+v, want [1, 9]", expr, v) + } + } + lone, _ := grammar.ParseCalc("x") + if _, doubt := Calc(lone.Expr, func(string) Value { return Bounded(0, 1e301, true) }); doubt != "x is not proven within 1e300" { + t.Errorf("Calc(x) doubts %q, want a lone operand held to the limit", doubt) + } +} + +func TestPrintedNumberWidensItsBoundsByTheRounding(t *testing.T) { + v := PrintedNumber("float(0,1,1)", Bounded(0.2, 0.8, false), 1) + if math.Abs(v.Lo-0.15) > 1e-12 || math.Abs(v.Hi-0.85) > 1e-12 || v.Not[datatype.Integer] == "" || v.Not[datatype.Number] != "" { + t.Errorf("PrintedNumber = %+v, want [0.15, 0.85], a number and no integer", v) + } + if v := PrintedNumber("calc(a)", Bounded(1, 2, true), ShortestDecimals); v.Not[datatype.Integer] != "" { + t.Errorf("PrintedNumber = %+v, want a whole value printed as an integer", v) + } + if v := Printing("digits(3)", datatype.String, Bounded(0, 999, true)); v.Not[datatype.Integer] != "{digits(3)} prints text, not an integer" { + t.Errorf("Printing = %q, want the text named", v.Not[datatype.Integer]) + } +}